hr dan payroll
This commit is contained in:
@@ -15,6 +15,7 @@ class JournalService
|
||||
$this->CI->load->library('TransactionService');
|
||||
$this->CI->load->library('FiscalPeriodService');
|
||||
$this->CI->load->library('JournalDescriptionService');
|
||||
$this->CI->load->library('AuditService');
|
||||
if ($this->CI->db->table_exists('companies')) $this->CI->load->library('CompanyContext');
|
||||
}
|
||||
|
||||
@@ -57,7 +58,50 @@ class JournalService
|
||||
return array('debit' => round($debit, 2), 'kredit' => round($credit, 2));
|
||||
}
|
||||
|
||||
private function validateAndNormalize(array $header, array $lines)
|
||||
public function reviseRejected($journalId, array $header, array $lines, $userId)
|
||||
{
|
||||
return $this->CI->transactionservice->run(function () use ($journalId, $header, $lines, $userId) {
|
||||
$journal = $this->CI->db->query(
|
||||
'SELECT * FROM journals WHERE id=? AND company_id=? FOR UPDATE',
|
||||
array((int)$journalId, (int)$this->CI->companycontext->id())
|
||||
)->row();
|
||||
if (!$journal) throw new BusinessException('Jurnal tidak ditemukan.');
|
||||
if ($journal->status !== 'rejected') throw new BusinessException('Hanya jurnal yang ditolak yang dapat dikoreksi.');
|
||||
if (!in_array((string)$journal->ref_type, array('', 'manual_journal'), true)) throw new BusinessException('Jurnal otomatis tidak dapat diedit melalui Jurnal Umum.');
|
||||
if ((int)$journal->created_by !== (int)$userId && !(function_exists('is_master_admin_user') && is_master_admin_user()) && $this->CI->session->userdata('role') !== 'Admin') throw new BusinessException('Hanya pembuat jurnal atau Admin yang dapat melakukan koreksi.');
|
||||
|
||||
$header['no_ref'] = $journal->no_ref;
|
||||
$header['ref_type'] = $journal->ref_type ?: 'manual_journal';
|
||||
$header['ref_id'] = $journal->ref_id;
|
||||
$header['created_by'] = $journal->created_by;
|
||||
$normalized = $this->validateAndNormalize($header, $lines, (int)$journal->id);
|
||||
$before = array('journal'=>$journal, 'lines'=>$this->CI->journalModel->lines($journal->id));
|
||||
$update = array(
|
||||
'tanggal'=>$normalized['header']['tanggal'],
|
||||
'keterangan'=>$normalized['header']['keterangan'],
|
||||
'accounting_period_id'=>$normalized['header']['accounting_period_id'],
|
||||
'status'=>'draft',
|
||||
'submitted_at'=>null,
|
||||
'submitted_by'=>null,
|
||||
'approved_at'=>null,
|
||||
'approved_by'=>null,
|
||||
'updated_by'=>(int)$userId,
|
||||
'updated_at'=>date('Y-m-d H:i:s'),
|
||||
);
|
||||
$this->CI->db->where('id', $journal->id)->update('journals', $update);
|
||||
$this->CI->db->where('journal_id', $journal->id)->delete('journal_details');
|
||||
$details=array();foreach($normalized['lines']as$line){$line['journal_id']=$journal->id;$details[]=$line;}
|
||||
$this->CI->journalModel->addLines($details);
|
||||
$notes='Jurnal dikoreksi dan dikembalikan menjadi Draft.';
|
||||
if(trim((string)$journal->rejection_reason)!=='')$notes.=' Alasan penolakan sebelumnya: '.$journal->rejection_reason;
|
||||
$this->CI->journalModel->addStatusHistory($journal->id,'rejected','draft',$userId,$notes);
|
||||
$after=array('journal'=>$this->CI->journalModel->find($journal->id),'lines'=>$this->CI->journalModel->lines($journal->id));
|
||||
$this->CI->auditservice->record('journal','journal',$journal->id,'revise_rejected',$before,$after,$userId);
|
||||
return $after['journal'];
|
||||
});
|
||||
}
|
||||
|
||||
private function validateAndNormalize(array $header, array $lines, $excludeJournalId = 0)
|
||||
{
|
||||
foreach (array('tanggal', 'no_ref', 'keterangan', 'created_by') as $required) {
|
||||
if (!isset($header[$required]) || trim((string) $header[$required]) === '') {
|
||||
@@ -69,7 +113,7 @@ class JournalService
|
||||
if (!$date || $date->format('Y-m-d') !== $header['tanggal']) {
|
||||
throw new BusinessException('Tanggal jurnal tidak valid.');
|
||||
}
|
||||
if ($this->CI->journalModel->referenceExists($header['no_ref'])) {
|
||||
if ($this->CI->journalModel->referenceExists($header['no_ref'], $excludeJournalId)) {
|
||||
throw new BusinessException('Nomor referensi jurnal sudah digunakan.');
|
||||
}
|
||||
|
||||
|
||||
@@ -244,7 +244,7 @@ class PDF extends FPDF
|
||||
// BARIS TOTAL
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
public function BukuBesarTotal($totalDebit, $totalKredit, $saldo)
|
||||
public function BukuBesarTotal($saldoAwal, $totalDebit, $totalKredit, $saldoAkhir)
|
||||
{
|
||||
$startX = ($this->GetPageWidth() - array_sum($this->colWidths)) / 2;
|
||||
$labelWidth = $this->colWidths[0] + $this->colWidths[1] + $this->colWidths[2];
|
||||
@@ -253,10 +253,18 @@ class PDF extends FPDF
|
||||
$this->SetFillColor(255, 204, 102);
|
||||
$this->SetFont('Arial', 'B', 9);
|
||||
|
||||
$this->Cell($labelWidth, 8, 'TOTAL', 1, 0, 'C', true);
|
||||
$this->Cell($labelWidth, 8, 'TOTAL MUTASI', 1, 0, 'C', true);
|
||||
$this->Cell($this->colWidths[3], 8, number_format($totalDebit, 2, ',', '.'), 1, 0, 'R', true);
|
||||
$this->Cell($this->colWidths[4], 8, number_format($totalKredit, 2, ',', '.'), 1, 0, 'R', true);
|
||||
$this->Cell($this->colWidths[5], 8, number_format($saldo, 2, ',', '.'), 1, 1, 'R', true);
|
||||
$this->Cell($this->colWidths[5], 8, number_format($saldoAkhir, 2, ',', '.'), 1, 1, 'R', true);
|
||||
$this->SetX($startX);
|
||||
$this->SetFillColor(245, 245, 245);
|
||||
$this->Cell($labelWidth, 7, 'SALDO AWAL', 1, 0, 'R', true);
|
||||
$this->Cell($this->colWidths[3]+$this->colWidths[4]+$this->colWidths[5], 7, number_format($saldoAwal,2,',','.'), 1, 1, 'R', true);
|
||||
$this->SetX($startX);
|
||||
$this->SetFillColor(255, 204, 102);
|
||||
$this->Cell($labelWidth, 8, 'SALDO AKHIR', 1, 0, 'R', true);
|
||||
$this->Cell($this->colWidths[3]+$this->colWidths[4]+$this->colWidths[5], 8, number_format($saldoAkhir,2,',','.'), 1, 1, 'R', true);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
@@ -27,8 +27,12 @@ class PayrollService
|
||||
$start=$period->attendance_start_date?:$period->start_date;$end=$period->attendance_end_date?:$period->end_date;
|
||||
if(!$db->get_where('payroll_attendance_locks',array('company_id'=>$companyId,'payroll_period_id'=>$period->id,'status'=>'locked'))->row())throw new BusinessException('Kehadiran periode ini harus dikunci sebelum payroll dihitung.');
|
||||
$employmentEmployees=$db
|
||||
->where(array('company_id'=>$companyId,'is_active'=>1))
|
||||
->where('company_id',$companyId)
|
||||
->where('join_date <=',$end)
|
||||
->group_start()
|
||||
->where('is_active',1)
|
||||
->or_where('resign_date >=',$start)
|
||||
->group_end()
|
||||
->group_start()
|
||||
->where('resign_date IS NULL',null,false)
|
||||
->or_where('resign_date','0000-00-00')
|
||||
@@ -37,9 +41,9 @@ class PayrollService
|
||||
->order_by('full_name')
|
||||
->get('k_employees')
|
||||
->result();
|
||||
if(!$canManageAll){foreach($employmentEmployees as$employee)if(!empty($employee->salary_restricted))throw new BusinessException('Periode memuat gaji terbatas. Perhitungan penuh harus dijalankan oleh pengguna dengan permission Manage All Gaji.');}
|
||||
$this->removeIneligibleDraftPayrolls($period->id,$companyId,$employmentEmployees);
|
||||
$employees=$employmentEmployees;
|
||||
if(!$canManageAll)$employees=array_values(array_filter($employees,function($employee){return empty($employee->salary_restricted);}));
|
||||
if(!$employees)throw new BusinessException('Tidak ada karyawan yang dapat dihitung dengan permission Anda.');
|
||||
foreach($employees as$employee)$this->calculateEmployee($period,$employee,$start,$end,$companyId,$uid);
|
||||
$hash=hash('sha256',json_encode($db->select('id,total_salary,version')->where(array('payroll_period_id'=>$period->id,'company_id'=>$companyId))->order_by('id')->get('k_payrolls')->result_array()));
|
||||
@@ -122,17 +126,17 @@ class PayrollService
|
||||
{
|
||||
$db=$this->CI->db;$db->trans_begin();try{$period=$this->period($id,$companyId);$before=$period;
|
||||
if($action==='revert'){if(!in_array($period->workflow_status,array('reviewed','approved'),true))throw new BusinessException('Hanya payroll Reviewed atau Approved yang dapat dikembalikan ke Draft.');$update=array('workflow_status'=>'draft','reviewed_by'=>null,'reviewed_at'=>null,'approved_by'=>null,'approved_at'=>null,'updated_at'=>date('Y-m-d H:i:s'));}
|
||||
else{$map=array('review'=>array('draft','reviewed'),'approve'=>array('reviewed','approved'),'finalize'=>array('approved','final'));if(!isset($map[$action])||$period->workflow_status!==$map[$action][0])throw new BusinessException('Tahap payroll tidak sesuai. Muat ulang halaman dan periksa status terbaru.');if(in_array($action,array('approve','finalize'),true)&&$this->hasRestrictedPayroll($id)&&!$canManageAll)throw new BusinessException('Periode memuat gaji terbatas dan memerlukan permission Manage All Gaji.');$settings=$db->get_where('payroll_settings',array('company_id'=>$companyId))->row();$separation=!$settings||(int)$settings->separation_of_duties===1;if($separation&&$action==='approve'&&(int)$period->reviewed_by===(int)$uid)throw new BusinessException('Reviewer dan approver harus pengguna yang berbeda.');if($separation&&$action==='finalize'&&((int)$period->reviewed_by===(int)$uid||(int)$period->approved_by===(int)$uid))throw new BusinessException('Finalizer harus berbeda dari reviewer dan approver.');$who=$action==='review'?'reviewed_by':($action==='approve'?'approved_by':'finalized_by');$when=$action==='review'?'reviewed_at':($action==='approve'?'approved_at':'finalized_at');$update=array('workflow_status'=>$map[$action][1],$who=>$uid,$when=>date('Y-m-d H:i:s'),'updated_at'=>date('Y-m-d H:i:s'));}
|
||||
else{$map=array('review'=>array('draft','reviewed'),'approve'=>array('reviewed','approved'),'finalize'=>array('approved','final'));if(!isset($map[$action])||$period->workflow_status!==$map[$action][0])throw new BusinessException('Tahap payroll tidak sesuai. Muat ulang halaman dan periksa status terbaru.');if($action==='review')$this->assertCalculationReady($period,$companyId);if(in_array($action,array('review','approve','finalize'),true)&&$this->hasRestrictedPayroll($id,$companyId)&&!$canManageAll)throw new BusinessException('Periode memuat gaji terbatas dan memerlukan permission Manage All Gaji.');$settings=$db->get_where('payroll_settings',array('company_id'=>$companyId))->row();$separation=!$settings||(int)$settings->separation_of_duties===1;if($separation&&$action==='approve'&&(int)$period->reviewed_by===(int)$uid)throw new BusinessException('Reviewer dan approver harus pengguna yang berbeda.');if($separation&&$action==='finalize'&&((int)$period->reviewed_by===(int)$uid||(int)$period->approved_by===(int)$uid))throw new BusinessException('Finalizer harus berbeda dari reviewer dan approver.');$who=$action==='review'?'reviewed_by':($action==='approve'?'approved_by':'finalized_by');$when=$action==='review'?'reviewed_at':($action==='approve'?'approved_at':'finalized_at');$update=array('workflow_status'=>$map[$action][1],$who=>$uid,$when=>date('Y-m-d H:i:s'),'updated_at'=>date('Y-m-d H:i:s'));}
|
||||
$db->where(array('id'=>$id,'company_id'=>$companyId))->update('k_payroll_periods',$update);$this->CI->auditservice->record('payroll','payroll_period',$id,$action,$before,$update,$uid);if($db->trans_status()===false)throw new RuntimeException('Workflow payroll gagal disimpan.');$db->trans_commit();
|
||||
}catch(Throwable$e){$db->trans_rollback();throw$e;}
|
||||
}
|
||||
|
||||
public function post($id,$date,$uid,$companyId,$canManageAll)
|
||||
{
|
||||
$db=$this->CI->db;$db->trans_begin();try{$period=$this->period($id,$companyId);if($period->workflow_status!=='final'||$period->journal_id)throw new BusinessException('Payroll belum Final atau sudah diposting.');if($this->hasRestrictedPayroll($id)&&!$canManageAll)throw new BusinessException('Posting payroll dengan gaji terbatas memerlukan permission Manage All Gaji.');$this->CI->fiscalperiodservice->assertOpen($date);
|
||||
$db=$this->CI->db;$db->trans_begin();try{$period=$this->period($id,$companyId);if($period->workflow_status!=='final'||$period->journal_id)throw new BusinessException('Payroll belum Final atau sudah diposting.');if($this->hasRestrictedPayroll($id,$companyId)&&!$canManageAll)throw new BusinessException('Posting payroll dengan gaji terbatas memerlukan permission Manage All Gaji.');$this->CI->fiscalperiodservice->assertOpen($date);
|
||||
$rows=$db->select('l.component_code,l.component_name,l.component_type,l.expense_account_id,l.liability_account_id,SUM(l.amount) amount',false)->from('payroll_calculation_lines l')->join('k_payrolls x','x.id=l.payroll_id')->where(array('x.payroll_period_id'=>$id,'x.company_id'=>$companyId))->group_by(array('l.component_code','l.component_name','l.component_type','l.expense_account_id','l.liability_account_id'))->get()->result();$net=(float)$db->select('COALESCE(SUM(total_salary),0) total',false)->get_where('k_payrolls',array('payroll_period_id'=>$id,'company_id'=>$companyId))->row()->total;$journal=array();$missing=array();
|
||||
foreach($rows as$row){try{$expense=$row->expense_account_id?(int)$row->expense_account_id:(int)$this->CI->accountmappingservice->get('payroll_expense');}catch(Throwable$e){$expense=0;}try{$liability=$row->liability_account_id?(int)$row->liability_account_id:(int)$this->CI->accountmappingservice->get($row->component_type==='deduction'?'payroll_tax_payable':'payroll_payable');}catch(Throwable$e){$liability=0;}if(in_array($row->component_type,array('earning','employer_cost'),true)&&!$expense)$missing[]=$row->component_name.' (akun beban)';if(in_array($row->component_type,array('deduction','employer_cost'),true)&&!$liability)$missing[]=$row->component_name.' (akun kewajiban)';if($row->component_type==='earning')$this->addJournal($journal,$expense,(float)$row->amount,0);elseif($row->component_type==='deduction')$this->addJournal($journal,$liability,0,(float)$row->amount);else{$this->addJournal($journal,$expense,(float)$row->amount,0);$this->addJournal($journal,$liability,0,(float)$row->amount);}}
|
||||
try{$payable=(int)$this->CI->accountmappingservice->get('payroll_payable');}catch(Throwable$e){$payable=0;}if(!$payable)$missing[]='Hutang Gaji (payroll_payable)';else$this->addJournal($journal,$payable,0,$net);if($missing)throw new BusinessException('Mapping akun payroll belum lengkap: '.implode(', ',array_unique($missing)).'.');$debit=0;$credit=0;foreach($journal as$line){$debit+=$line['debit'];$credit+=$line['kredit'];}if(abs($debit-$credit)>.01)throw new BusinessException('Jurnal payroll tidak balance. Periksa mapping komponen.');$count=$db->where(array('payroll_period_id'=>$id,'company_id'=>$companyId))->count_all_results('k_payrolls');$journalId=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>'PAYROLL-'.$period->id,'keterangan'=>'Payroll '.$period->period_name.' untuk '.$count.' karyawan','ref_type'=>'payroll','ref_id'=>$id,'created_by'=>$uid),array_values($journal),false,true);$db->where(array('id'=>$id,'company_id'=>$companyId))->update('k_payroll_periods',array('workflow_status'=>'posted','journal_id'=>$journalId,'updated_at'=>date('Y-m-d H:i:s')));$this->CI->auditservice->record('payroll','payroll_period',$id,'post',null,array('journal_id'=>$journalId),$uid);$db->trans_commit();return$journalId;
|
||||
if(!$rows||$net<=0)throw new BusinessException('Hasil payroll kosong atau total neto tidak valid. Hitung ulang payroll sebelum posting.');try{$payable=(int)$this->CI->accountmappingservice->get('payroll_payable');}catch(Throwable$e){$payable=0;}if(!$payable)$missing[]='Hutang Gaji (payroll_payable)';else$this->addJournal($journal,$payable,0,$net);if($missing)throw new BusinessException('Mapping akun payroll belum lengkap: '.implode(', ',array_unique($missing)).'.');$debit=0;$credit=0;foreach($journal as$line){$debit+=$line['debit'];$credit+=$line['kredit'];}if(abs($debit-$credit)>.01)throw new BusinessException('Jurnal payroll tidak balance. Periksa mapping komponen.');$count=$db->where(array('payroll_period_id'=>$id,'company_id'=>$companyId))->count_all_results('k_payrolls');$journalId=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>'PAYROLL-'.$period->id,'keterangan'=>'Payroll '.$period->period_name.' untuk '.$count.' karyawan','ref_type'=>'payroll','ref_id'=>$id,'created_by'=>$uid),array_values($journal),false,true);$db->where(array('id'=>$id,'company_id'=>$companyId))->update('k_payroll_periods',array('workflow_status'=>'posted','journal_id'=>$journalId,'updated_at'=>date('Y-m-d H:i:s')));$this->CI->auditservice->record('payroll','payroll_period',$id,'post',null,array('journal_id'=>$journalId),$uid);if($db->trans_status()===false)throw new RuntimeException('Posting payroll gagal disimpan.');$db->trans_commit();return$journalId;
|
||||
}catch(Throwable$e){$db->trans_rollback();throw$e;}
|
||||
}
|
||||
|
||||
@@ -140,9 +144,18 @@ class PayrollService
|
||||
|
||||
public function pay($id,$date,$cashAccountId,$uid,$companyId,$canManageAll)
|
||||
{
|
||||
$db=$this->CI->db;$db->trans_begin();try{$period=$this->period($id,$companyId);if($period->workflow_status!=='posted')throw new BusinessException('Payroll belum diposting atau sudah dibayar.');if($this->hasRestrictedPayroll($id)&&!$canManageAll)throw new BusinessException('Pembayaran payroll dengan gaji terbatas memerlukan permission Manage All Gaji.');if($db->get_where('payroll_payments',array('company_id'=>$companyId,'payroll_period_id'=>$id))->row())throw new BusinessException('Pembayaran payroll sudah tersedia.');$cashQuery=$db->where(array('id'=>$cashAccountId,'is_active'=>1));if($db->field_exists('company_id','cash_accounts'))$cashQuery->where('company_id',$companyId);$cash=$cashQuery->get('cash_accounts')->row();if(!$cash)throw new BusinessException('Rekening pembayaran aktif tidak ditemukan.');$this->CI->fiscalperiodservice->assertOpen($date);$net=(float)$db->select('COALESCE(SUM(total_salary),0) total',false)->get_where('k_payrolls',array('payroll_period_id'=>$id,'company_id'=>$companyId))->row()->total;if($net<=0)throw new BusinessException('Total pembayaran payroll tidak valid.');$number='PRP-'.$companyId.'-'.date('YmdHis');$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$number,'keterangan'=>'Pembayaran payroll '.$period->period_name.' melalui '.$cash->name,'ref_type'=>'payroll_payment','ref_id'=>$id,'created_by'=>$uid),array(array('account_id'=>$this->CI->accountmappingservice->get('payroll_payable'),'debit'=>$net,'kredit'=>0),array('account_id'=>$cash->gl_account_id,'debit'=>0,'kredit'=>$net)),false,true);$db->insert('payroll_payments',array('company_id'=>$companyId,'payroll_period_id'=>$id,'payment_no'=>$number,'payment_date'=>$date,'cash_account_id'=>$cashAccountId,'total_amount'=>$net,'status'=>'posted','journal_id'=>$journal,'created_by'=>$uid,'approved_by'=>$uid,'posted_at'=>date('Y-m-d H:i:s')));$paymentId=(int)$db->insert_id();$db->where(array('id'=>$id,'company_id'=>$companyId))->update('k_payroll_periods',array('workflow_status'=>'paid','payment_transaction_id'=>$paymentId,'updated_at'=>date('Y-m-d H:i:s')));$this->CI->auditservice->record('payroll','payroll_period',$id,'pay',null,array('payment_id'=>$paymentId,'journal_id'=>$journal),$uid);$db->trans_commit();return$journal;
|
||||
$db=$this->CI->db;$db->trans_begin();try{$period=$this->period($id,$companyId);if($period->workflow_status!=='posted')throw new BusinessException('Payroll belum diposting atau sudah dibayar.');if($this->hasRestrictedPayroll($id,$companyId)&&!$canManageAll)throw new BusinessException('Pembayaran payroll dengan gaji terbatas memerlukan permission Manage All Gaji.');if($db->get_where('payroll_payments',array('company_id'=>$companyId,'payroll_period_id'=>$id))->row())throw new BusinessException('Pembayaran payroll sudah tersedia.');$cashQuery=$db->where(array('id'=>$cashAccountId,'is_active'=>1));if($db->field_exists('company_id','cash_accounts'))$cashQuery->where('company_id',$companyId);$cash=$cashQuery->get('cash_accounts')->row();if(!$cash)throw new BusinessException('Rekening pembayaran aktif tidak ditemukan.');if(empty($cash->gl_account_id))throw new BusinessException('Rekening pembayaran belum terhubung ke akun buku besar.');$accountQuery=$db->where(array('id'=>(int)$cash->gl_account_id,'company_id'=>$companyId,'is_active'=>1));if($db->field_exists('allow_posting','accounts'))$accountQuery->where('allow_posting',1);if(!$accountQuery->count_all_results('accounts'))throw new BusinessException('Akun buku besar pada rekening pembayaran tidak aktif, tidak dapat diposting, atau berbeda perusahaan.');$this->CI->fiscalperiodservice->assertOpen($date);$net=(float)$db->select('COALESCE(SUM(total_salary),0) total',false)->get_where('k_payrolls',array('payroll_period_id'=>$id,'company_id'=>$companyId))->row()->total;if($net<=0)throw new BusinessException('Total pembayaran payroll tidak valid.');$number='PRP-'.$companyId.'-'.date('YmdHis').'-'.strtoupper(bin2hex(random_bytes(2)));$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$number,'keterangan'=>'Pembayaran payroll '.$period->period_name.' melalui '.$cash->name,'ref_type'=>'payroll_payment','ref_id'=>$id,'created_by'=>$uid),array(array('account_id'=>$this->CI->accountmappingservice->get('payroll_payable'),'debit'=>$net,'kredit'=>0),array('account_id'=>$cash->gl_account_id,'debit'=>0,'kredit'=>$net)),false,true);$db->insert('payroll_payments',array('company_id'=>$companyId,'payroll_period_id'=>$id,'payment_no'=>$number,'payment_date'=>$date,'cash_account_id'=>$cashAccountId,'total_amount'=>$net,'status'=>'posted','journal_id'=>$journal,'created_by'=>$uid,'approved_by'=>$uid,'posted_at'=>date('Y-m-d H:i:s')));$paymentId=(int)$db->insert_id();$db->where(array('id'=>$id,'company_id'=>$companyId))->update('k_payroll_periods',array('workflow_status'=>'paid','payment_transaction_id'=>$paymentId,'updated_at'=>date('Y-m-d H:i:s')));$this->CI->auditservice->record('payroll','payroll_period',$id,'pay',null,array('payment_id'=>$paymentId,'journal_id'=>$journal),$uid);if($db->trans_status()===false)throw new RuntimeException('Pembayaran payroll gagal disimpan.');$db->trans_commit();return$journal;
|
||||
}catch(Throwable$e){$db->trans_rollback();throw$e;}
|
||||
}
|
||||
|
||||
private function hasRestrictedPayroll($periodId){return$this->CI->db->from('k_payrolls p')->join('k_employees e','e.id=p.employee_id')->where('p.payroll_period_id',(int)$periodId)->where('e.salary_restricted',1)->count_all_results()>0;}
|
||||
private function assertCalculationReady($period,$companyId)
|
||||
{
|
||||
if(empty($period->calculated_at)||empty($period->calculation_hash))throw new BusinessException('Payroll belum dihitung atau berubah setelah perhitungan. Silakan Hitung Ulang.');
|
||||
$actual=(int)$this->CI->db->where(array('company_id'=>$companyId,'payroll_period_id'=>$period->id))->count_all_results('k_payrolls');
|
||||
if($actual<1)throw new BusinessException('Rincian payroll masih kosong. Silakan Hitung Ulang.');
|
||||
$current=hash('sha256',json_encode($this->CI->db->select('id,total_salary,version')->where(array('payroll_period_id'=>$period->id,'company_id'=>$companyId))->order_by('id')->get('k_payrolls')->result_array()));
|
||||
if(!hash_equals((string)$period->calculation_hash,$current))throw new BusinessException('Hasil payroll berubah setelah perhitungan. Silakan Hitung Ulang sebelum review.');
|
||||
}
|
||||
|
||||
private function hasRestrictedPayroll($periodId,$companyId){return$this->CI->db->from('k_payrolls p')->join('k_employees e','e.id=p.employee_id AND e.company_id=p.company_id')->where(array('p.payroll_period_id'=>(int)$periodId,'p.company_id'=>(int)$companyId,'e.salary_restricted'=>1))->count_all_results()>0;}
|
||||
}
|
||||
|
||||
@@ -7,14 +7,61 @@ class ProfessionalReportService
|
||||
private function filters(array$f){return array(' j.company_id=? AND j.status IN(\'posted\',\'reversed\') AND j.tanggal BETWEEN ? AND ?',array($this->CI->companycontext->id(),$f['from'],$f['to']));}
|
||||
public function trialBalance(array$f){list($w,$b)=$this->filters($f);return$this->CI->db->query("SELECT a.id account_id,a.kode_akun,a.nama_akun,a.tipe,COALESCE(SUM(d.debit),0) debit,COALESCE(SUM(d.kredit),0) credit,COALESCE(SUM(d.debit-d.kredit),0) balance FROM accounts a LEFT JOIN journal_details d ON d.account_id=a.id LEFT JOIN journals j ON j.id=d.journal_id AND j.company_id=a.company_id WHERE a.company_id=? AND (j.id IS NULL OR ($w)) GROUP BY a.id ORDER BY a.priority ASC,a.kode_akun ASC,a.id ASC",array_merge(array($this->CI->companycontext->id()),$b))->result_array();}
|
||||
public function statement($type,array$f,$priorityOrder=true){list($w,$b)=$this->filters($f);$types=$type==='profit_loss'?array('revenue','expense'):array('asset','liability','equity');$marks=implode(',',array_fill(0,count($types),'?'));$company=$this->CI->companycontext->id();$order=$priorityOrder?'a.priority ASC,a.kode_akun ASC,a.id ASC':'a.kode_akun ASC';$sql="SELECT a.id account_id,a.kode_akun,a.nama_akun,a.tipe,COALESCE(x.amount,0) amount FROM accounts a LEFT JOIN (SELECT d.account_id,SUM(IF(ac.tipe IN('asset','expense'),d.debit-d.kredit,d.kredit-d.debit)) amount FROM journal_details d JOIN journals j ON j.id=d.journal_id JOIN accounts ac ON ac.id=d.account_id WHERE $w GROUP BY d.account_id)x ON x.account_id=a.id WHERE a.company_id=? AND a.tipe IN($marks) ORDER BY $order";return$this->CI->db->query($sql,array_merge($b,array($company),$types))->result_array();}
|
||||
public function ledger(array$f){list($w,$b)=$this->filters($f);$sql="SELECT j.id journal_id,j.tanggal,j.no_ref,j.keterangan,j.ref_type,j.ref_id,a.kode_akun,a.nama_akun,d.debit,d.kredit FROM journal_details d JOIN journals j ON j.id=d.journal_id JOIN accounts a ON a.id=d.account_id WHERE $w";if(!empty($f['account_id'])){$sql.=' AND d.account_id=?';$b[]=(int)$f['account_id'];}$sql.=' ORDER BY j.tanggal,j.id,d.id';return$this->CI->db->query($sql,$b)->result_array();}
|
||||
public function cashFlow(array$f){list($w,$b)=$this->filters($f);return$this->CI->db->query("SELECT CASE WHEN j.ref_type IN('invoice','customer_payment','supplier_invoice','supplier_payment','payroll','payroll_payment') THEN 'operating' WHEN j.ref_type LIKE 'asset_%' THEN 'investing' ELSE 'financing_or_other' END section,SUM(IF(a.sub_tipe='kas',d.debit-d.kredit,0)) amount FROM journal_details d JOIN journals j ON j.id=d.journal_id JOIN accounts a ON a.id=d.account_id WHERE $w GROUP BY section",$b)->result_array();}
|
||||
public function ledger(array$f)
|
||||
{
|
||||
list($w,$b)=$this->filters($f);
|
||||
$sql="SELECT d.id detail_id,j.id journal_id,j.tanggal,j.no_ref,j.keterangan,j.ref_type,j.ref_id,a.id account_id,a.kode_akun,a.nama_akun,a.tipe,d.debit,d.kredit FROM journal_details d JOIN journals j ON j.id=d.journal_id JOIN accounts a ON a.id=d.account_id WHERE $w";
|
||||
if(!empty($f['account_id'])){$sql.=' AND d.account_id=?';$b[]=(int)$f['account_id'];}
|
||||
$sql.=' AND (d.debit<>0 OR d.kredit<>0) ORDER BY a.priority ASC,a.kode_akun ASC,j.tanggal ASC,j.id ASC,d.id ASC';
|
||||
$rows=$this->CI->db->query($sql,$b)->result_array();
|
||||
$opening=$this->ledgerOpening($f);
|
||||
$running=array();
|
||||
foreach($rows as&$row){$id=(int)$row['account_id'];if(!isset($running[$id]))$running[$id]=(float)($opening[$id]??0);$normal=in_array($row['tipe'],array('asset','expense'),true)?1:-1;$row['saldo_awal']=(float)($opening[$id]??0);$running[$id]+=$normal*((float)$row['debit']-(float)$row['kredit']);$row['saldo_berjalan']=round($running[$id],2);}
|
||||
unset($row);return$rows;
|
||||
}
|
||||
public function ledgerSummary(array$f)
|
||||
{
|
||||
$company=(int)$this->CI->companycontext->id();$opening=$this->ledgerOpening($f);
|
||||
$sql="SELECT a.id account_id,a.kode_akun,a.nama_akun,a.tipe,a.priority,COALESCE(SUM(CASE WHEN j.id IS NOT NULL THEN d.debit ELSE 0 END),0) total_debit,COALESCE(SUM(CASE WHEN j.id IS NOT NULL THEN d.kredit ELSE 0 END),0) total_kredit FROM accounts a LEFT JOIN journal_details d ON d.account_id=a.id LEFT JOIN journals j ON j.id=d.journal_id AND j.company_id=? AND j.status IN('posted','reversed') AND j.tanggal BETWEEN ? AND ? WHERE a.company_id=?";
|
||||
$bind=array($company,$f['from'],$f['to'],$company);if(!empty($f['account_id'])){$sql.=' AND a.id=?';$bind[]=(int)$f['account_id'];}$sql.=' GROUP BY a.id ORDER BY a.priority ASC,a.kode_akun ASC';
|
||||
$rows=$this->CI->db->query($sql,$bind)->result_array();$result=array();foreach($rows as$row){$id=(int)$row['account_id'];$row['saldo_awal']=(float)($opening[$id]??0);$normal=in_array($row['tipe'],array('asset','expense'),true)?1:-1;$row['saldo_akhir']=round($row['saldo_awal']+$normal*((float)$row['total_debit']-(float)$row['total_kredit']),2);if(abs($row['saldo_awal'])>.009||abs((float)$row['total_debit'])>.009||abs((float)$row['total_kredit'])>.009)$result[]=$row;}return$result;
|
||||
}
|
||||
private function ledgerOpening(array$f)
|
||||
{
|
||||
$company=(int)$this->CI->companycontext->id();$sql="SELECT d.account_id,a.tipe,SUM(d.debit) debit,SUM(d.kredit) kredit FROM journal_details d JOIN journals j ON j.id=d.journal_id JOIN accounts a ON a.id=d.account_id WHERE j.company_id=? AND j.status IN('posted','reversed') AND j.tanggal<?";$bind=array($company,$f['from']);if(!empty($f['account_id'])){$sql.=' AND d.account_id=?';$bind[]=(int)$f['account_id'];}$sql.=' GROUP BY d.account_id,a.tipe';$out=array();foreach($this->CI->db->query($sql,$bind)->result_array()as$row){$normal=in_array($row['tipe'],array('asset','expense'),true)?1:-1;$out[(int)$row['account_id']]=$normal*((float)$row['debit']-(float)$row['kredit']);}return$out;
|
||||
}
|
||||
public function cashFlow(array$f)
|
||||
{
|
||||
$company=(int)$this->CI->companycontext->id();$cashWhere="(a.sub_tipe='kas' OR EXISTS(SELECT 1 FROM cash_accounts ca WHERE ca.gl_account_id=a.id AND (ca.company_id=? OR ca.company_id IS NULL)))";
|
||||
$opening=$this->cashOpening($f);
|
||||
$sql="SELECT d.id detail_id,j.id journal_id,j.tanggal,j.no_ref,j.keterangan,j.ref_type,j.ref_id,a.kode_akun,a.nama_akun,d.debit kas_masuk,d.kredit kas_keluar FROM journal_details d JOIN journals j ON j.id=d.journal_id JOIN accounts a ON a.id=d.account_id WHERE j.company_id=? AND j.status IN('posted','reversed') AND j.tanggal BETWEEN ? AND ? AND $cashWhere AND (d.debit<>0 OR d.kredit<>0)";$bind=array($company,$f['from'],$f['to'],$company);if(!empty($f['account_id'])){$sql.=' AND a.id=?';$bind[]=(int)$f['account_id'];}$sql.=' ORDER BY j.tanggal,j.id,d.id';$rows=$this->CI->db->query($sql,$bind)->result_array();$running=$opening;
|
||||
foreach($rows as&$row){$row['section']=$this->cashSection((string)$row['ref_type']);$row['aktivitas']=$this->cashSectionLabel($row['section']);$row['akun']=$row['kode_akun'].' - '.$row['nama_akun'];$running+=(float)$row['kas_masuk']-(float)$row['kas_keluar'];$row['saldo_berjalan']=round($running,2);}unset($row);return$rows;
|
||||
}
|
||||
public function cashFlowSummary(array$f,array$rows=null)
|
||||
{
|
||||
if($rows===null)$rows=$this->cashFlow($f);$opening=$this->cashOpening($f);$summary=array('operating'=>0,'investing'=>0,'financing'=>0,'opening_balance'=>$opening,'increase_decrease'=>0,'closing_balance'=>$opening,'cash_in'=>0,'cash_out'=>0);foreach($rows as$row){$net=(float)$row['kas_masuk']-(float)$row['kas_keluar'];$summary[$row['section']]+=$net;$summary['cash_in']+=(float)$row['kas_masuk'];$summary['cash_out']+=(float)$row['kas_keluar'];}$summary['increase_decrease']=$summary['operating']+$summary['investing']+$summary['financing'];$summary['closing_balance']=$opening+$summary['increase_decrease'];return$summary;
|
||||
}
|
||||
private function cashOpening(array$f){$company=(int)$this->CI->companycontext->id();$cashWhere="(a.sub_tipe='kas' OR EXISTS(SELECT 1 FROM cash_accounts ca WHERE ca.gl_account_id=a.id AND (ca.company_id=? OR ca.company_id IS NULL)))";$sql="SELECT COALESCE(SUM(d.debit-d.kredit),0) amount FROM journal_details d JOIN journals j ON j.id=d.journal_id JOIN accounts a ON a.id=d.account_id WHERE j.company_id=? AND j.status IN('posted','reversed') AND j.tanggal<? AND $cashWhere";$bind=array($company,$f['from'],$company);if(!empty($f['account_id'])){$sql.=' AND a.id=?';$bind[]=(int)$f['account_id'];}return(float)$this->CI->db->query($sql,$bind)->row()->amount;}
|
||||
private function cashSection($refType){if(preg_match('/asset|fixed_asset|capitalization|depreciation/i',$refType))return'investing';if(preg_match('/capital|equity|dividend|loan|financing|customer_saving|deposit/i',$refType))return'financing';return'operating';}
|
||||
private function cashSectionLabel($section){return array('operating'=>'Aktivitas Operasi','investing'=>'Aktivitas Investasi','financing'=>'Aktivitas Pendanaan')[$section]??'Aktivitas Operasi';}
|
||||
public function equity(array$f){list($w,$b)=$this->filters($f);return$this->CI->db->query("SELECT a.kode_akun,a.nama_akun,SUM(d.kredit-d.debit) amount FROM journal_details d JOIN journals j ON j.id=d.journal_id JOIN accounts a ON a.id=d.account_id WHERE $w AND a.tipe='equity' GROUP BY a.id ORDER BY a.kode_akun",$b)->result_array();}
|
||||
public function actualBudget(array$f){$this->CI->load->library('BudgetService');$source=$this->CI->budgetservice->accountReport(array('from'=>substr($f['from'],0,7),'to'=>substr($f['to'],0,7),'account_id'=>(int)($f['account_id']??0)));$rows=array();foreach($source as$r)$rows[]=array('kode_akun'=>$r['kode_akun'],'nama_akun'=>$r['nama_akun'],'actual'=>$r['actual'],'budget'=>$r['budget'],'variance'=>$r['actual']-$r['budget']);return$rows;}
|
||||
public function comparative(array$f){$current=$this->statement('profit_loss',$f,false);$pm=array_merge($f,array('from'=>date('Y-m-d',strtotime($f['from'].' -1 month')),'to'=>date('Y-m-d',strtotime($f['to'].' -1 month'))));$py=array_merge($f,array('from'=>date('Y-m-d',strtotime($f['from'].' -1 year')),'to'=>date('Y-m-d',strtotime($f['to'].' -1 year'))));$idx=array();foreach($this->statement('profit_loss',$pm,false)as$r)$idx[$r['account_id']]['previous_month']=$r['amount'];foreach($this->statement('profit_loss',$py,false)as$r)$idx[$r['account_id']]['previous_year']=$r['amount'];foreach($current as&$r){$r['previous_month']=isset($idx[$r['account_id']]['previous_month'])?$idx[$r['account_id']]['previous_month']:0;$r['previous_year']=isset($idx[$r['account_id']]['previous_year'])?$idx[$r['account_id']]['previous_year']:0;$r['variance_month']=$r['amount']-$r['previous_month'];$r['variance_year']=$r['amount']-$r['previous_year'];}return$current;}
|
||||
public function purchaseReturns(array$f){return$this->CI->db->select('r.id record_id,r.return_no,r.return_date,s.supplier_code,s.name supplier,po.po_no,r.problem_category,r.requested_resolution,r.status,r.amount,r.journal_id')->from('purchase_returns r')->join('suppliers s','s.id=r.supplier_id')->join('purchase_orders po','po.id=r.purchase_order_id','left')->where('r.company_id',$this->CI->companycontext->id())->where('r.return_date >=',$f['from'])->where('r.return_date <=',$f['to'])->order_by('r.return_date')->order_by('r.id')->get()->result_array();}
|
||||
public function supplierRefunds(array$f,$outstanding=false){$this->CI->db->select('r.id record_id,r.refund_no,r.claim_date,r.expected_date,s.supplier_code,s.name supplier,po.po_no,si.internal_no invoice_supplier,r.source_type,r.status,r.claim_amount,r.received_amount,r.balance,r.entitlement_journal_id journal_id')->from('supplier_refund_claims r')->join('suppliers s','s.id=r.supplier_id')->join('purchase_orders po','po.id=r.purchase_order_id','left')->join('supplier_invoices si','si.id=r.supplier_invoice_id','left')->where('r.company_id',$this->CI->companycontext->id())->where('r.claim_date >=',$f['from'])->where('r.claim_date <=',$f['to']);if($outstanding)$this->CI->db->where('r.balance >',0)->where_not_in('r.status',array('draft','rejected','cancelled','reversed','reconciled'));return$this->CI->db->order_by('r.claim_date')->order_by('r.id')->get()->result_array();}
|
||||
public function supplierDebitNotes(array$f){return$this->CI->db->select('d.id record_id,d.debit_note_no,d.note_date,s.supplier_code,s.name supplier,po.po_no,si.internal_no invoice_supplier,d.correction_type,d.status,d.base_amount,d.tax_amount,d.amount,d.journal_id')->from('supplier_debit_notes d')->join('suppliers s','s.id=d.supplier_id')->join('purchase_orders po','po.id=d.purchase_order_id','left')->join('supplier_invoices si','si.id=d.supplier_invoice_id','left')->where('d.company_id',$this->CI->companycontext->id())->where('d.note_date >=',$f['from'])->where('d.note_date <=',$f['to'])->order_by('d.note_date')->order_by('d.id')->get()->result_array();}
|
||||
public function salesReport($code,array$f){$c=$this->CI->companycontext->id();if($code==='sales_summary')return$this->CI->db->select('i.id record_id,i.no_invoice,i.tanggal,c.nama customer,i.invoice_type,i.recognition_policy,i.status,i.total,i.total_bayar,i.sisa_piutang')->from('invoices i')->join('customers c','c.id=i.customer_id')->where(array('i.company_id'=>$c,'i.workflow_status'=>'posted'))->where('i.tanggal >=',$f['from'])->where('i.tanggal <=',$f['to'])->order_by('i.tanggal')->get()->result_array();if($code==='sales_by_customer')return$this->CI->db->select('c.id customer_id,c.nama customer,COUNT(i.id) invoice_count,SUM(i.total) sales,SUM(i.total_bayar) paid,SUM(i.sisa_piutang) balance',false)->from('invoices i')->join('customers c','c.id=i.customer_id')->where(array('i.company_id'=>$c,'i.workflow_status'=>'posted'))->where('i.tanggal >=',$f['from'])->where('i.tanggal <=',$f['to'])->group_by('c.id')->order_by('sales','DESC')->get()->result_array();if($code==='sales_by_item')return$this->CI->db->select('d.items_id item_id,it.kode_detail AS kode_barang,it.nama_barang,SUM(d.qty) qty,SUM(d.net_amount) net_sales,SUM(d.tax_amount) tax,SUM(d.subtotal) total',false)->from('invoice_details d')->join('invoices i','i.id=d.invoice_id')->join('items it','it.id=d.items_id')->where(array('i.company_id'=>$c,'i.workflow_status'=>'posted'))->where('i.tanggal >=',$f['from'])->where('i.tanggal <=',$f['to'])->group_by('d.items_id')->order_by('net_sales','DESC')->get()->result_array();if($code==='sales_by_barcode')return$this->CI->db->select('i.id record_id,i.no_invoice,i.tanggal,it.kode_detail AS kode_barang,it.nama_barang,b.barcode,b.serial_number,lb.qty,sd.delivery_no')->from('invoice_line_barcodes lb')->join('invoices i','i.id=lb.invoice_id')->join('item_barcodes b','b.id=lb.barcode_id')->join('items it','it.id=b.item_id')->join('sales_deliveries sd','sd.id=lb.sales_delivery_id','left')->where('lb.company_id',$c)->where('i.tanggal >=',$f['from'])->where('i.tanggal <=',$f['to'])->order_by('i.tanggal')->get()->result_array();if($code==='running_invoices')return$this->CI->db->select('i.id record_id,i.no_invoice,c.nama customer,i.period_start,i.period_end,i.delivery_status,i.total,i.workflow_status')->from('invoices i')->join('customers c','c.id=i.customer_id')->where(array('i.company_id'=>$c,'i.invoice_type'=>'running','i.workflow_status'=>'draft'))->order_by('i.period_end')->get()->result_array();if($code==='sales_returns')return$this->CI->db->select('r.id record_id,r.return_no,r.return_date,c.nama customer,i.no_invoice,d.delivery_no,r.resolution,r.status,SUM(l.net_amount+l.tax_amount) amount',false)->from('sales_returns r')->join('customers c','c.id=r.customer_id')->join('invoices i','i.id=r.invoice_id','left')->join('sales_deliveries d','d.id=r.sales_delivery_id','left')->join('sales_return_lines l','l.sales_return_id=r.id','left')->where('r.company_id',$c)->where('r.return_date >=',$f['from'])->where('r.return_date <=',$f['to'])->group_by('r.id')->order_by('r.return_date')->get()->result_array();if($code==='revenue_recognition')return$this->CI->db->select('i.id record_id,i.no_invoice,c.nama customer,i.recognition_policy,i.subtotal_before_tax invoice_net,i.recognized_revenue,i.subtotal_before_tax-i.recognized_revenue deferred_revenue,i.recognized_cogs')->from('invoices i')->join('customers c','c.id=i.customer_id')->where(array('i.company_id'=>$c,'i.workflow_status'=>'posted'))->where('i.tanggal >=',$f['from'])->where('i.tanggal <=',$f['to'])->order_by('i.tanggal')->get()->result_array();if($code==='unbilled_deliveries')return$this->CI->db->select('d.id record_id,d.delivery_no,d.delivery_date,c.nama customer,d.status')->from('sales_deliveries d')->join('customers c','c.id=d.customer_id')->where('d.company_id',$c)->where('d.invoice_id IS NULL',null,false)->where('d.delivery_date >=',$f['from'])->where('d.delivery_date <=',$f['to'])->get()->result_array();if($code==='receivable_reconciliation'){$ar=$this->CI->load->library('AccountMappingService')?:null;$account=$this->CI->accountmappingservice->get('accounts_receivable');$sub=(float)$this->CI->db->select('COALESCE(SUM(sisa_piutang),0) v',false)->where(array('company_id'=>$c,'workflow_status'=>'posted'))->get('invoices')->row()->v;$gl=(float)$this->CI->db->select('COALESCE(SUM(d.debit-d.kredit),0) v',false)->from('journal_details d')->join('journals j','j.id=d.journal_id')->where(array('j.company_id'=>$c,'j.status'=>'posted','d.account_id'=>$account))->get()->row()->v;return array(array('subledger_piutang'=>$sub,'buku_besar_piutang'=>$gl,'selisih'=>$sub-$gl,'status'=>abs($sub-$gl)<.01?'Balance':'Perlu direkonsiliasi'));}throw new BusinessException('Jenis laporan penjualan tidak valid.');}
|
||||
public function salesReport($code,array$f){$c=$this->CI->companycontext->id();if($code==='revenue_recognition')return$this->revenueRecognition($f);if($code==='receivable_reconciliation')return$this->receivableReconciliation($f);if($code==='sales_summary')return$this->CI->db->select('i.id record_id,i.no_invoice,i.tanggal,c.nama customer,i.invoice_type,i.recognition_policy,i.status,i.total,i.total_bayar,i.sisa_piutang')->from('invoices i')->join('customers c','c.id=i.customer_id')->where(array('i.company_id'=>$c,'i.workflow_status'=>'posted'))->where('i.tanggal >=',$f['from'])->where('i.tanggal <=',$f['to'])->order_by('i.tanggal')->get()->result_array();if($code==='sales_by_customer')return$this->CI->db->select('c.id customer_id,c.nama customer,COUNT(i.id) invoice_count,SUM(i.total) sales,SUM(i.total_bayar) paid,SUM(i.sisa_piutang) balance',false)->from('invoices i')->join('customers c','c.id=i.customer_id')->where(array('i.company_id'=>$c,'i.workflow_status'=>'posted'))->where('i.tanggal >=',$f['from'])->where('i.tanggal <=',$f['to'])->group_by('c.id')->order_by('sales','DESC')->get()->result_array();if($code==='sales_by_item')return$this->CI->db->select('d.items_id item_id,it.kode_detail AS kode_barang,it.nama_barang,SUM(d.qty) qty,SUM(d.net_amount) net_sales,SUM(d.tax_amount) tax,SUM(d.subtotal) total',false)->from('invoice_details d')->join('invoices i','i.id=d.invoice_id')->join('items it','it.id=d.items_id')->where(array('i.company_id'=>$c,'i.workflow_status'=>'posted'))->where('i.tanggal >=',$f['from'])->where('i.tanggal <=',$f['to'])->group_by('d.items_id')->order_by('net_sales','DESC')->get()->result_array();if($code==='sales_by_barcode')return$this->CI->db->select('i.id record_id,i.no_invoice,i.tanggal,it.kode_detail AS kode_barang,it.nama_barang,b.barcode,b.serial_number,lb.qty,sd.delivery_no')->from('invoice_line_barcodes lb')->join('invoices i','i.id=lb.invoice_id')->join('item_barcodes b','b.id=lb.barcode_id')->join('items it','it.id=b.item_id')->join('sales_deliveries sd','sd.id=lb.sales_delivery_id','left')->where('lb.company_id',$c)->where('i.tanggal >=',$f['from'])->where('i.tanggal <=',$f['to'])->order_by('i.tanggal')->get()->result_array();if($code==='running_invoices')return$this->CI->db->select('i.id record_id,i.no_invoice,c.nama customer,i.period_start,i.period_end,i.delivery_status,i.total,i.workflow_status')->from('invoices i')->join('customers c','c.id=i.customer_id')->where(array('i.company_id'=>$c,'i.invoice_type'=>'running','i.workflow_status'=>'draft'))->order_by('i.period_end')->get()->result_array();if($code==='sales_returns')return$this->CI->db->select('r.id record_id,r.return_no,r.return_date,c.nama customer,i.no_invoice,d.delivery_no,r.resolution,r.status,SUM(l.net_amount+l.tax_amount) amount',false)->from('sales_returns r')->join('customers c','c.id=r.customer_id')->join('invoices i','i.id=r.invoice_id','left')->join('sales_deliveries d','d.id=r.sales_delivery_id','left')->join('sales_return_lines l','l.sales_return_id=r.id','left')->where('r.company_id',$c)->where('r.return_date >=',$f['from'])->where('r.return_date <=',$f['to'])->group_by('r.id')->order_by('r.return_date')->get()->result_array();if($code==='unbilled_deliveries')return$this->CI->db->select('d.id record_id,d.delivery_no,d.delivery_date,c.nama customer,d.status')->from('sales_deliveries d')->join('customers c','c.id=d.customer_id')->where('d.company_id',$c)->where('d.invoice_id IS NULL',null,false)->where('d.delivery_date >=',$f['from'])->where('d.delivery_date <=',$f['to'])->get()->result_array();throw new BusinessException('Jenis laporan penjualan tidak valid.');}
|
||||
private function revenueRecognition(array$f)
|
||||
{
|
||||
$company=(int)$this->CI->companycontext->id();$sql="SELECT i.id record_id,i.no_invoice,i.tanggal,c.nama customer,CASE WHEN i.recognition_policy='accrual' THEN 'Diakui saat posted' ELSE 'Proporsional setelah dibayar' END recognition_policy,COALESCE(i.subtotal_before_tax,0) invoice_net,COALESCE(i.recognized_revenue,0) recognized_revenue,GREATEST(COALESCE(i.subtotal_before_tax,0)-COALESCE(i.recognized_revenue,0),0) deferred_revenue,COALESCE(i.recognized_cogs,0) recognized_cogs FROM invoices i JOIN customers c ON c.id=i.customer_id AND c.company_id=i.company_id WHERE i.company_id=? AND i.workflow_status='posted' AND i.tanggal BETWEEN ? AND ? AND i.deleted_at IS NULL ORDER BY i.tanggal,i.id";return$this->CI->db->query($sql,array($company,$f['from'],$f['to']))->result_array();
|
||||
}
|
||||
private function receivableReconciliation(array$f)
|
||||
{
|
||||
$company=(int)$this->CI->companycontext->id();$to=$f['to'];$sql="SELECT i.id record_id,c.nama customer,i.no_invoice,i.tanggal invoice_date,i.jatuh_tempo due_date,i.total receivable_amount,COALESCE(p.paid,0) payments,COALESCE(n.credit_amount,0) credit_notes,COALESCE(w.writeoff_amount,0) writeoffs,GREATEST(i.total-COALESCE(p.paid,0)-COALESCE(n.credit_amount,0)-COALESCE(w.writeoff_amount,0),0) remaining_receivable,CASE WHEN GREATEST(i.total-COALESCE(p.paid,0)-COALESCE(n.credit_amount,0)-COALESCE(w.writeoff_amount,0),0)<=0.01 THEN 'Lunas' WHEN COALESCE(p.paid,0)+COALESCE(n.credit_amount,0)+COALESCE(w.writeoff_amount,0)>0 THEN 'Partial' WHEN i.jatuh_tempo<? THEN 'Jatuh Tempo' ELSE 'Belum Lunas' END status,CONCAT_WS(' | ',NULLIF(p.payment_refs,''),NULLIF(n.credit_refs,''),NULLIF(w.writeoff_refs,'')) transaction_references,0 reconciliation_adjustment FROM invoices i JOIN customers c ON c.id=i.customer_id AND c.company_id=i.company_id LEFT JOIN(SELECT pa.invoice_id,SUM(pa.amount) paid,GROUP_CONCAT(DISTINCT p.payment_no ORDER BY p.tanggal,p.id SEPARATOR ', ') payment_refs FROM payment_allocations pa JOIN payments p ON p.id=pa.payment_id WHERE p.status='posted' AND p.tanggal<=? GROUP BY pa.invoice_id)p ON p.invoice_id=i.id LEFT JOIN(SELECT invoice_id,SUM(amount) credit_amount,GROUP_CONCAT(credit_note_no ORDER BY credit_date,id SEPARATOR ', ') credit_refs FROM credit_notes WHERE status='posted' AND credit_date<=? GROUP BY invoice_id)n ON n.invoice_id=i.id LEFT JOIN(SELECT invoice_id,SUM(amount) writeoff_amount,GROUP_CONCAT(writeoff_no ORDER BY writeoff_date,id SEPARATOR ', ') writeoff_refs FROM receivable_writeoffs WHERE status IN('approved','posted') AND writeoff_date<=? GROUP BY invoice_id)w ON w.invoice_id=i.id WHERE i.company_id=? AND i.workflow_status='posted' AND i.tanggal<=? AND i.deleted_at IS NULL ORDER BY c.nama,i.tanggal,i.id";$rows=$this->CI->db->query($sql,array($to,$to,$to,$to,$company,$to))->result_array();$summary=$this->receivableSummary($f,$rows);if(abs($summary['difference'])>.01)$rows[]=array('record_id'=>0,'customer'=>'Saldo GL belum terpetakan','no_invoice'=>'REKONSILIASI-GL','invoice_date'=>$to,'due_date'=>null,'receivable_amount'=>0,'payments'=>0,'credit_notes'=>0,'writeoffs'=>0,'remaining_receivable'=>$summary['difference'],'status'=>'Perlu ditelusuri','transaction_references'=>'Selisih saldo Buku Besar terhadap subledger invoice sampai '.$to,'reconciliation_adjustment'=>1);return$rows;
|
||||
}
|
||||
public function receivableSummary(array$f,array$rows=null)
|
||||
{
|
||||
if($rows===null)$rows=$this->receivableReconciliation($f);$subledger=0;foreach($rows as$row)if(empty($row['reconciliation_adjustment']))$subledger+=(float)$row['remaining_receivable'];$this->CI->load->library('AccountMappingService');$account=(int)$this->CI->accountmappingservice->get('accounts_receivable');$gl=(float)$this->CI->db->query("SELECT COALESCE(SUM(d.debit-d.kredit),0) amount FROM journal_details d JOIN journals j ON j.id=d.journal_id WHERE j.company_id=? AND j.status IN('posted','reversed') AND j.tanggal<=? AND d.account_id=?",array((int)$this->CI->companycontext->id(),$f['to'],$account))->row()->amount;return array('subledger'=>$subledger,'general_ledger'=>$gl,'difference'=>round($gl-$subledger,2),'balanced'=>abs($gl-$subledger)<.01);
|
||||
}
|
||||
public function run($code,array$f){if($code==='trial_balance')return$this->trialBalance($f);if(in_array($code,array('profit_loss','balance_sheet'),true))return$this->statement($code,$f);if(in_array($code,array('general_ledger','journal_register','account_detail'),true))return$this->ledger($f);if($code==='cash_flow')return$this->cashFlow($f);if($code==='changes_equity')return$this->equity($f);if($code==='actual_budget')return$this->actualBudget($f);if($code==='comparative')return$this->comparative($f);if($code==='purchase_returns')return$this->purchaseReturns($f);if($code==='supplier_refunds')return$this->supplierRefunds($f);if($code==='outstanding_supplier_refunds')return$this->supplierRefunds($f,true);if($code==='supplier_debit_notes')return$this->supplierDebitNotes($f);if(in_array($code,array('sales_summary','sales_by_customer','sales_by_item','sales_by_barcode','running_invoices','sales_returns','revenue_recognition','unbilled_deliveries','receivable_reconciliation'),true))return$this->salesReport($code,$f);throw new BusinessException('Jenis laporan tidak valid.');}
|
||||
}
|
||||
|
||||
@@ -29,8 +29,17 @@ class PurchaseService
|
||||
return array($amount,$accountId);
|
||||
}
|
||||
|
||||
private function assertPurchasePaymentSchema()
|
||||
{
|
||||
$missing=array();
|
||||
foreach(array('bank_charge_amount','bank_charge_account_id','total_cash_out')as$column)if(!$this->CI->db->field_exists($column,'supplier_payments'))$missing[]=$column;
|
||||
if(!$this->CI->db->table_exists('purchase_payment_sources'))$missing[]='purchase_payment_sources';
|
||||
if($missing)throw new BusinessException('Struktur database pembayaran pembelian belum diperbarui ('.implode(', ',$missing).'). Jalankan: php index.php migrate latest');
|
||||
}
|
||||
|
||||
public function prepayOrderWithBankCharge($poId,$date,array$sources,$reference,$notes,$user,$bankCharge=0,$bankChargeAccount=0)
|
||||
{
|
||||
$this->assertPurchasePaymentSchema();
|
||||
return$this->CI->transactionservice->run(function()use($poId,$date,$sources,$reference,$notes,$user,$bankCharge,$bankChargeAccount){
|
||||
$po=$this->CI->db->query('SELECT * FROM purchase_orders WHERE id=? FOR UPDATE',array((int)$poId))->row();
|
||||
if(!$po||!in_array($po->status,array('approved','partially_received'),true))throw new BusinessException('PO belum disetujui atau sudah selesai.');
|
||||
@@ -57,6 +66,7 @@ class PurchaseService
|
||||
|
||||
public function settleSupplierInvoiceMultiWithBankCharge($id,array$sources,$paymentDate,$reference,$user,$bankCharge=0,$bankChargeAccount=0)
|
||||
{
|
||||
$this->assertPurchasePaymentSchema();
|
||||
$bill=$this->CI->db->get_where('supplier_invoices',array('id'=>(int)$id))->row();if(!$bill)throw new BusinessException('Invoice supplier tidak ditemukan.');
|
||||
$po=$this->CI->db->select('po.*')->from('supplier_invoice_lines sil')->join('purchase_order_lines pol','pol.id=sil.po_line_id')->join('purchase_orders po','po.id=pol.purchase_order_id')->where('sil.supplier_invoice_id',$id)->limit(1)->get()->row();
|
||||
$alreadyPaid=$po&&in_array($po->payment_timing,array('prepaid','on_receipt'),true)&&$po->paid_amount>=$bill->total-.001;
|
||||
@@ -88,6 +98,7 @@ class PurchaseService
|
||||
|
||||
public function paymentMultiWithBankCharge($supplierId,$date,array$sources,array$allocations,$allowAdvance,$user,$reference,$bankCharge=0,$bankChargeAccount=0)
|
||||
{
|
||||
$this->assertPurchasePaymentSchema();
|
||||
return$this->CI->transactionservice->run(function()use($supplierId,$date,$sources,$allocations,$allowAdvance,$user,$reference,$bankCharge,$bankChargeAccount){
|
||||
$supplier=$this->CI->db->get_where('suppliers',array('id'=>(int)$supplierId))->row();if(!$supplier)throw new BusinessException('Supplier tidak ditemukan.');
|
||||
list($valid,$gross)=$this->purchasePaymentSources($sources);list($charge,$chargeAccount)=$this->purchaseBankCharge($bankCharge,$bankChargeAccount);
|
||||
|
||||
@@ -295,7 +295,10 @@ class SalesService
|
||||
$invoice=$this->invoiceForEditor($invoiceId);$start=max(0,(int)$start);$length=max(1,min(100,(int)$length));$search=trim((string)$search);
|
||||
$base=function()use($invoiceId,$search){$this->CI->db->from('invoice_details d')->join('items it','it.id=d.items_id','left')->join('accounts a','a.id=d.account_id','left')->join('accounts ca','ca.id=d.counter_account_id','left')->where(array('d.invoice_id'=>(int)$invoiceId,'d.company_id'=>$this->company()));if($search!=='')$this->CI->db->group_start()->like('d.nama_item',$search)->or_like('d.specification',$search)->or_like('d.keterangan',$search)->or_like('it.kode_detail',$search)->or_like('a.nama_akun',$search)->or_like('ca.nama_akun',$search)->group_end();};
|
||||
$base();$filtered=(int)$this->CI->db->count_all_results();$total=(int)$this->CI->db->where(array('invoice_id'=>(int)$invoiceId,'company_id'=>$this->company()))->count_all_results('invoice_details');
|
||||
$base();$rows=$this->CI->db->select("d.*,it.kode_detail item_code,a.kode_akun,a.nama_akun,ca.kode_akun counter_kode_akun,ca.nama_akun counter_nama_akun,(SELECT GROUP_CONCAT(CONCAT(ib.barcode,IF(NULLIF(ib.serial_number,'') IS NULL,'',CONCAT(' / ',ib.serial_number))) ORDER BY ilb.id SEPARATOR '||') FROM invoice_line_barcodes ilb JOIN item_barcodes ib ON ib.id=ilb.barcode_id WHERE ilb.invoice_detail_id=d.id) barcode_labels",false)->order_by('d.service_date','ASC')->order_by('d.id','ASC')->limit($length,$start)->get()->result();
|
||||
$base();$rows=$this->CI->db->select("d.*,it.kode_detail item_code,it.harga_jual item_sale_price,kb.tracking_type,kb.unit item_unit,w.nama warehouse_name,a.kode_akun,a.nama_akun,ca.kode_akun counter_kode_akun,ca.nama_akun counter_nama_akun,(SELECT GROUP_CONCAT(CONCAT(ib.barcode,IF(NULLIF(ib.serial_number,'') IS NULL,'',CONCAT(' / ',ib.serial_number))) ORDER BY ilb.id SEPARATOR '||') FROM invoice_line_barcodes ilb JOIN item_barcodes ib ON ib.id=ilb.barcode_id WHERE ilb.invoice_detail_id=d.id) barcode_labels",false)->join('kode_barang kb','kb.id=it.kode_id','left')->join('warehouses w','w.id=d.warehouse_id','left')->order_by('d.service_date','ASC')->order_by('d.id','ASC')->limit($length,$start)->get()->result();
|
||||
$lineIds=array_map(function($row){return(int)$row->id;},$rows);$allocations=array();
|
||||
if($lineIds){$bars=$this->CI->db->select('ilb.invoice_detail_id,ilb.barcode_id id,ib.barcode,ib.serial_number,ilb.qty,(ib.qty_sisa-COALESCE(ib.reserved_qty,0)-COALESCE(ib.return_reserved_qty,0)+ilb.qty) available,kb.tracking_type',false)->from('invoice_line_barcodes ilb')->join('item_barcodes ib','ib.id=ilb.barcode_id')->join('items it','it.id=ib.item_id')->join('kode_barang kb','kb.id=it.kode_id','left')->where('ilb.company_id',$this->company())->where_in('ilb.invoice_detail_id',$lineIds)->order_by('ilb.id')->get()->result();foreach($bars as$bar)$allocations[(int)$bar->invoice_detail_id][]=$bar;}
|
||||
foreach($rows as$row)$row->barcode_allocations=$allocations[(int)$row->id]??array();
|
||||
return array('invoice'=>$invoice,'rows'=>$rows,'recordsTotal'=>$total,'recordsFiltered'=>$filtered,'summary'=>array('line_count'=>$total,'subtotal'=>(float)$invoice->subtotal_before_tax,'discount'=>(float)$invoice->discount_total,'tax'=>(float)$invoice->tax_total,'total'=>(float)$invoice->total));
|
||||
}
|
||||
public function updateInvoiceHeader($id,array$data,$user)
|
||||
@@ -326,26 +329,51 @@ class SalesService
|
||||
$this->CI->db->where(array('invoice_detail_id'=>(int)$lineId,'company_id'=>$this->company()))->delete('invoice_line_barcodes');
|
||||
}
|
||||
|
||||
public function updateNonInventoryLine($invoiceId,$lineId,array$data,$user)
|
||||
public function updateInvoiceLine($invoiceId,$lineId,array$data,$user)
|
||||
{
|
||||
return $this->CI->transactionservice->run(function()use($invoiceId,$lineId,$data,$user){
|
||||
$invoice=$this->lockInvoice($invoiceId,'draft');$this->assertDraftOwner($invoice,$user);
|
||||
$line=$this->CI->db->query('SELECT * FROM invoice_details WHERE id=? AND invoice_id=? AND company_id=? FOR UPDATE',array((int)$lineId,$invoice->id,$this->company()))->row();
|
||||
if(!$line||!in_array($line->line_type,array('service','savings'),true))throw new BusinessException('Hanya item Jasa/Nonbarang atau Tabungan pada Draft yang dapat diedit.');
|
||||
$type=$line->line_type;$description=trim((string)($data['description']??''));if($description==='')throw new BusinessException('Deskripsi item wajib diisi.');
|
||||
if(!$line||!in_array($line->line_type,array('inventory','service','savings'),true))throw new BusinessException('Item invoice ini tidak dapat diedit.');
|
||||
$type=$line->line_type;if(isset($data['line_type'])&&$data['line_type']!==''&&$data['line_type']!==$type)throw new BusinessException('Jenis item tidak dapat diubah saat edit. Hapus lalu tambahkan kembali bila jenis transaksi berubah.');
|
||||
if($type==='inventory')$this->assertInventoryInvoiceLineEditable($line);
|
||||
$description=$type==='inventory'?'':trim((string)($data['description']??''));if($type!=='inventory'&&$description==='')throw new BusinessException('Deskripsi item wajib diisi.');
|
||||
$account=$this->tenant('accounts',$data['account_id']??0,$type==='savings'?'Akun simpanan customer':'Akun pendapatan');
|
||||
if(isset($account->is_active)&&!(int)$account->is_active||isset($account->allow_posting)&&!(int)$account->allow_posting)throw new BusinessException('Akun item tidak aktif atau tidak dapat diposting.');
|
||||
$counter=null;if($type==='service'&&(string)$account->tipe!=='revenue')throw new BusinessException('Jasa harus menggunakan akun pendapatan.');
|
||||
$mayUseHidden=function_exists('is_master_admin_user')&&is_master_admin_user();if((isset($account->is_active)&&(int)$account->is_active!==1)||(isset($account->allow_posting)&&(int)$account->allow_posting!==1)||(!$mayUseHidden&&isset($account->is_hidden)&&(int)$account->is_hidden===1))throw new BusinessException('Akun item tidak aktif atau tidak dapat diposting.');
|
||||
$counter=null;if(in_array($type,array('inventory','service'),true)&&(string)$account->tipe!=='revenue')throw new BusinessException('Barang dan jasa harus menggunakan akun pendapatan.');
|
||||
if($type==='savings'&&(string)$account->tipe!=='liability')throw new BusinessException('Tabungan harus menggunakan akun Passiva/Kewajiban.');
|
||||
$qty=$type==='savings'?1:$this->qty($data['qty']??0);$price=$this->money($data['unit_price']??0);$discount=$type==='savings'?0:max(0,$this->money($data['discount_amount']??0));if($discount>$qty*$price)throw new BusinessException('Diskon tidak boleh melebihi nilai bruto item.');
|
||||
$tax=$type==='service'?max(0,(float)str_replace(',','.',(string)($data['tax_rate']??0))):0;$net=max(0,$qty*$price-$discount);$taxAmount=round($net*$tax/100,2);$service=$data['service_date']??$line->service_date;if(!preg_match('/^\d{4}-\d{2}-\d{2}$/',(string)$service))throw new BusinessException('Tanggal transaksi item tidak valid.');
|
||||
$update=$this->columns('invoice_details',array('service_date'=>$service,'tanggal'=>$service,'account_id'=>$account->id,'counter_account_id'=>$counter?$counter->id:null,'nama_item'=>$description,'unit'=>trim((string)($data['unit']??$line->unit)),'qty'=>$qty,'harga'=>$price,'discount_amount'=>$discount,'tax_rate'=>$tax,'tax_amount'=>$taxAmount,'net_amount'=>$net,'subtotal'=>$net+$taxAmount,'subtotal_asli'=>$net+$taxAmount,'keterangan'=>trim((string)($data['notes']??''))));
|
||||
$item=null;$warehouse=null;$kb=null;if($type==='inventory'){$item=$this->tenant('items',$data['item_id']??0,'Barang');$warehouse=$this->tenant('warehouses',$data['warehouse_id']??0,'Gudang');if($item->status!=='active')throw new BusinessException('Barang '.$item->nama_barang.' belum aktif.');$kb=$this->CI->db->get_where('kode_barang',array('id'=>$item->kode_id))->row();}
|
||||
$qty=$type==='savings'?1:$this->qty($data['qty']??0);$price=$this->money($data['unit_price']??($item?$item->harga_jual:0));$discount=$type==='savings'?0:max(0,$this->money($data['discount_amount']??0));if($discount>$qty*$price)throw new BusinessException('Diskon tidak boleh melebihi nilai bruto item.');
|
||||
$tax=$type==='savings'?0:max(0,(float)str_replace(',','.',(string)($data['tax_rate']??0)));$net=max(0,$qty*$price-$discount);$taxAmount=round($net*$tax/100,2);$service=$data['service_date']??$line->service_date;if(!preg_match('/^\d{4}-\d{2}-\d{2}$/',(string)$service))throw new BusinessException('Tanggal transaksi item tidak valid.');
|
||||
$requestedBarcodes=(array)($data['barcodes']??array());if($type==='inventory'&&!$requestedBarcodes)throw new BusinessException('Pilih minimal satu barcode untuk barang gudang.');
|
||||
$beforeBars=$this->CI->db->where(array('invoice_detail_id'=>$line->id,'company_id'=>$this->company()))->order_by('id')->get('invoice_line_barcodes')->result_array();$before=array('line'=>$line,'barcodes'=>$beforeBars);
|
||||
$lineJournalMode=true;$otherLines=$this->CI->db->select('id,line_type,subtotal,total_hpp_barang')->where(array('invoice_id'=>$invoice->id,'company_id'=>$this->company()))->where('id !=',$line->id)->get('invoice_details')->result();foreach($otherLines as$other)if($other->line_type!=='savings'&&((float)$other->subtotal>0||(float)$other->total_hpp_barang>0)){$otherJournal=$this->initialLineJournal($other->id);if(!$otherJournal||$otherJournal->is_reversed||$otherJournal->status!=='posted'){$lineJournalMode=false;break;}}
|
||||
$this->reverseInvoiceLineInitialJournal($line,$user,'Item Draft invoice '.$invoice->no_invoice.' diedit');
|
||||
$this->CI->db->where(array('id'=>$line->id,'invoice_id'=>$invoice->id,'company_id'=>$this->company()))->update('invoice_details',$update);$updated=$this->CI->db->get_where('invoice_details',array('id'=>$line->id,'company_id'=>$this->company()))->row();$customer=$this->tenant('customers',$invoice->customer_id,'Customer');if($lineJournalMode)$this->postInvoiceLineInitialJournal($invoice,$line->id,$updated,null,array(),$customer,$user);$this->refreshInvoiceTotals($invoice->id);$this->CI->auditservice->record('sales','invoice_detail',$line->id,'update',$line,$update,$user);return$line->id;
|
||||
$cleanBarcodes=array();if($type==='inventory'){$this->detachDraftDeliveryLine($line);$this->releaseInvoiceLineBarcodes($line->id);$seen=array();$barcodeQty=0;foreach($requestedBarcodes as$requested){$barcodeId=(int)($requested['id']??$requested['barcode_id']??0);if(!$barcodeId||isset($seen[$barcodeId]))throw new BusinessException('Barcode tidak boleh dipilih dua kali pada satu item.');$seen[$barcodeId]=true;$barcodeQtyValue=$this->qty($requested['qty']??1);$barcode=$this->CI->db->query("SELECT * FROM item_barcodes WHERE id=? AND item_id=? AND warehouse_id=? AND status IN('available','reserved') FOR UPDATE",array($barcodeId,$item->id,$warehouse->id))->row();if(!$barcode)throw new BusinessException('Barcode barang '.$item->nama_barang.' tidak tersedia pada gudang terpilih.');if($this->CI->db->where(array('invoice_id'=>$invoice->id,'barcode_id'=>$barcodeId))->count_all_results('invoice_line_barcodes'))throw new BusinessException('Barcode '.$barcode->barcode.' sudah dipakai item lain pada invoice ini.');if($kb&&strtoupper((string)$kb->tracking_type)==='UNIT'){$used=(int)$this->CI->db->select('COUNT(*) total',false)->from('invoice_line_barcodes used')->join('invoice_details used_detail','used_detail.id=used.invoice_detail_id AND used_detail.stock_posted_at IS NULL')->join('invoices used_invoice','used_invoice.id=used.invoice_id')->where(array('used.barcode_id'=>$barcode->id,'used_invoice.company_id'=>$this->company()))->where('used_invoice.deleted_at IS NULL',null,false)->where("COALESCE(used_invoice.status,'') <> 'cancelled'",null,false)->get()->row()->total;if($used>0)throw new BusinessException('Barcode/Serial '.$barcode->barcode.' sedang digunakan invoice lain.');if(abs($barcodeQtyValue-1)>.0001)throw new BusinessException('Barcode UNIT harus memiliki qty 1.');}$available=(float)$barcode->qty_sisa-(float)$barcode->reserved_qty-(float)($barcode->return_reserved_qty??0);if($barcodeQtyValue>$available+.0001)throw new BusinessException('Qty barcode '.$barcode->barcode.' melebihi stok tersedia.');$reservedAfter=(float)$barcode->reserved_qty+$barcodeQtyValue;$barcodeUpdate=array('reserved_qty'=>$reservedAfter,'version'=>(int)$barcode->version+1);if($kb&&strtoupper((string)$kb->tracking_type)==='UNIT')$barcodeUpdate['status']='reserved';$this->CI->db->where(array('id'=>$barcode->id,'version'=>$barcode->version))->update('item_barcodes',$barcodeUpdate);if($this->CI->db->affected_rows()!==1)throw new BusinessException('Barcode sedang diproses pengguna lain. Silakan ulangi.');$barcode->reserved_qty=$reservedAfter;$cleanBarcodes[]=array('row'=>$barcode,'qty'=>$barcodeQtyValue);$barcodeQty+=$barcodeQtyValue;}if(abs($barcodeQty-$qty)>.0001)throw new BusinessException('Total qty barcode harus sama dengan qty barang '.$item->nama_barang.'.');}
|
||||
$update=$this->columns('invoice_details',array('service_date'=>$service,'tanggal'=>$service,'account_id'=>$account->id,'counter_account_id'=>$counter?$counter->id:null,'items_id'=>$item?$item->id:null,'warehouse_id'=>$warehouse?$warehouse->id:null,'delivery_line_id'=>null,'nama_item'=>$type==='inventory'?$item->nama_barang:$description,'unit'=>$kb?$kb->unit:trim((string)($data['unit']??$line->unit)),'qty'=>$qty,'harga'=>$price,'discount_amount'=>$discount,'tax_rate'=>$tax,'tax_amount'=>$taxAmount,'net_amount'=>$net,'subtotal'=>$net+$taxAmount,'subtotal_asli'=>$net+$taxAmount,'total_hpp_barang'=>$item?round($qty*(float)$item->harga_beli,2):0,'keterangan'=>trim((string)($data['notes']??''))));
|
||||
$this->CI->db->where(array('id'=>$line->id,'invoice_id'=>$invoice->id,'company_id'=>$this->company()))->update('invoice_details',$update);if($type==='inventory')foreach($cleanBarcodes as$bar)$this->CI->db->insert('invoice_line_barcodes',array('company_id'=>$this->company(),'invoice_id'=>$invoice->id,'invoice_detail_id'=>$line->id,'sales_delivery_id'=>null,'sales_delivery_line_id'=>null,'barcode_id'=>$bar['row']->id,'qty'=>$bar['qty'],'created_at'=>date('Y-m-d H:i:s')));
|
||||
$updated=$this->CI->db->get_where('invoice_details',array('id'=>$line->id,'company_id'=>$this->company()))->row();$customer=$this->tenant('customers',$invoice->customer_id,'Customer');if($lineJournalMode)$this->postInvoiceLineInitialJournal($invoice,$line->id,$updated,$item,$cleanBarcodes,$customer,$user);$this->refreshInvoiceTotals($invoice->id);$this->updateInvoiceDeliveryStatus($invoice->id);$afterBars=$this->CI->db->where(array('invoice_detail_id'=>$line->id,'company_id'=>$this->company()))->order_by('id')->get('invoice_line_barcodes')->result_array();$this->CI->auditservice->record('sales','invoice_detail',$line->id,'update',$before,array('line'=>$updated,'barcodes'=>$afterBars),$user);return$line->id;
|
||||
});
|
||||
}
|
||||
|
||||
/** Kompatibilitas pemanggil lama; seluruh validasi sekarang terpusat. */
|
||||
public function updateNonInventoryLine($invoiceId,$lineId,array$data,$user){return$this->updateInvoiceLine($invoiceId,$lineId,$data,$user);}
|
||||
|
||||
private function assertInventoryInvoiceLineEditable($line)
|
||||
{
|
||||
if(!empty($line->stock_posted_at)||!empty($line->stock_log_id))throw new BusinessException('Barang sudah memiliki mutasi stok dan tidak dapat diedit. Gunakan retur atau koreksi persediaan.');
|
||||
$deliveryLineId=(int)$line->delivery_line_id;if($deliveryLineId){$delivery=$this->CI->db->select('l.stock_posted_at,d.status')->from('sales_delivery_lines l')->join('sales_deliveries d','d.id=l.sales_delivery_id')->where(array('l.id'=>$deliveryLineId,'d.company_id'=>$this->company()))->get()->row();if(!$delivery||$delivery->status!=='draft'||!empty($delivery->stock_posted_at))throw new BusinessException('Surat Jalan barang sudah diproses. Gunakan retur/koreksi agar stok dan audit trail tetap benar.');}
|
||||
$stockCount=(int)$this->CI->db->query("SELECT COUNT(*) total FROM stock_logs WHERE ((ref_type='invoice_details' AND ref_id=?) OR (? > 0 AND ref_type='sales_delivery' AND ref_id=?))",array((int)$line->id,$deliveryLineId,$deliveryLineId))->row()->total;
|
||||
$movementCount=(int)$this->CI->db->query("SELECT COUNT(*) total FROM item_movements WHERE ((reference_type IN('invoice','invoice_details') AND reference_id=?) OR (? > 0 AND reference_type='sales_delivery' AND reference_id=?))",array((int)$line->id,$deliveryLineId,$deliveryLineId))->row()->total;
|
||||
$ledgerCount=(int)$this->CI->db->query("SELECT COUNT(*) total FROM inventory_ledger WHERE ((document_type IN('invoice','invoice_details') AND document_line_id=?) OR (? > 0 AND document_type='sales_delivery' AND document_line_id=?))",array((int)$line->id,$deliveryLineId,$deliveryLineId))->row()->total;
|
||||
if($stockCount||$movementCount||$ledgerCount)throw new BusinessException('Barang sudah terhubung dengan stock log atau item movement. Edit dihentikan; gunakan retur/koreksi persediaan.');
|
||||
}
|
||||
|
||||
private function detachDraftDeliveryLine($line)
|
||||
{
|
||||
if(!(int)$line->delivery_line_id)return;$deliveryLine=$this->CI->db->select('l.sales_delivery_id,l.stock_posted_at,d.status')->from('sales_delivery_lines l')->join('sales_deliveries d','d.id=l.sales_delivery_id')->where(array('l.id'=>(int)$line->delivery_line_id,'d.company_id'=>$this->company()))->get()->row();if(!$deliveryLine||$deliveryLine->status!=='draft'||!empty($deliveryLine->stock_posted_at))throw new BusinessException('Item tidak dapat dilepas dari Surat Jalan yang sudah diproses.');$this->CI->db->where('sales_delivery_line_id',(int)$line->delivery_line_id)->delete('sales_delivery_barcodes');$this->CI->db->where('id',(int)$line->delivery_line_id)->delete('sales_delivery_lines');if(!$this->CI->db->where('sales_delivery_id',(int)$deliveryLine->sales_delivery_id)->count_all_results('sales_delivery_lines'))$this->CI->db->where(array('id'=>(int)$deliveryLine->sales_delivery_id,'company_id'=>$this->company()))->update('sales_deliveries',array('status'=>'cancelled'));
|
||||
}
|
||||
|
||||
public function updateDelivery($id,array$data,array$attachments,$user)
|
||||
{
|
||||
return$this->CI->transactionservice->run(function()use($id,$data,$attachments,$user){$d=$this->CI->db->query('SELECT * FROM sales_deliveries WHERE id=? AND company_id=? FOR UPDATE',array((int)$id,$this->company()))->row();if(!$d||$d->status!=='draft')throw new BusinessException('Hanya Surat Jalan Draft yang dapat dilengkapi.');$date=$data['delivery_date']??$d->delivery_date;if(!preg_match('/^\d{4}-\d{2}-\d{2}$/',$date))throw new BusinessException('Tanggal pengiriman tidak valid.');$update=array('delivery_date'=>$date,'address'=>trim((string)($data['address']??'')),'driver_name'=>trim((string)($data['driver_name']??'')),'vehicle_no'=>trim((string)($data['vehicle_no']??'')),'courier'=>trim((string)($data['courier']??'')),'tracking_no'=>trim((string)($data['tracking_no']??'')),'recipient_name'=>trim((string)($data['recipient_name']??'')),'notes'=>trim((string)($data['notes']??'')),'version'=>(int)$d->version+1);$this->CI->db->where('id',$d->id)->update('sales_deliveries',$update);foreach($attachments as$a)$this->insertAttachment('sales_delivery',$d->id,$a,$user);$this->CI->auditservice->record('sales','sales_delivery',$d->id,'update',$d,$update,$user);return$d->id;});
|
||||
|
||||
Reference in New Issue
Block a user