$value){ $value=round((float)$value,2);if($value<=0)continue; $row=$this->CI->db->where(array('id'=>(int)$account,'is_active'=>1,'allow_posting'=>1))->group_start()->like('nama_akun','kas')->or_like('nama_akun','bank')->group_end()->get('accounts')->row(); if(!$row)throw new BusinessException('Sumber Kas/Bank tidak valid.'); $valid[(int)$account]=($valid[(int)$account]??0)+$value;$gross+=$value; } if(!$valid||$gross<=0)throw new BusinessException('Minimal satu sumber Kas/Bank dan nominal wajib diisi.'); return array($valid,round($gross,2)); } private function purchaseBankCharge($amount,$accountId) { $amount=round((float)$amount,2);if($amount<0)throw new BusinessException('Biaya admin bank tidak boleh negatif.'); if($amount<=.001)return array(0,null); $accountId=(int)$accountId; if(!$accountId)$accountId=(int)$this->CI->accountmappingservice->get('bank_charge_expense'); $account=$this->CI->db->get_where('accounts',array('id'=>$accountId,'tipe'=>'expense','is_active'=>1,'allow_posting'=>1))->row(); if(!$account)throw new BusinessException('Akun Beban Administrasi Bank tidak valid.'); 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.'); if($po->payment_timing!=='prepaid'&&!($po->payment_timing==='installment'&&(int)$po->down_payment_required===1))throw new BusinessException('Menu Pembayaran hanya untuk pelunasan sebelum barang diterima atau DP yang diwajibkan PO. Cicilan reguler dibayar melalui Hutang & Pembayaran.'); list($valid,$gross)=$this->purchasePaymentSources($sources);list($charge,$chargeAccount)=$this->purchaseBankCharge($bankCharge,$bankChargeAccount); $principal=round($gross-$charge,2);if($principal<=0)throw new BusinessException('Total sumber Kas/Bank harus lebih besar daripada biaya admin bank.'); $required=round(($po->payment_timing==='installment'?$po->down_payment_amount:$po->total)-$po->paid_amount,2); if($required<=.001)throw new BusinessException('Pembayaran awal/DP untuk PO ini sudah lunas.'); if(abs($principal-$required)>.001)throw new BusinessException('Pokok pembayaran harus sama dengan '.($po->payment_timing==='installment'?'sisa DP':'total PO').' sebesar Rp '.number_format($required,2,',','.').'. Total sumber Kas/Bank termasuk admin harus Rp '.number_format($required+$charge,2,',','.').'.'); $this->CI->fiscalperiodservice->assertOpen($date);$no=$this->CI->numberingservice->next('supplier_payment',$date); $desc='[AUTO] '.($po->payment_timing==='installment'?'Pembayaran DP':'Pelunasan sebelum penerimaan').' PO '.$po->po_no.'; pokok Rp '.number_format($principal,2,',','.').'; admin bank Rp '.number_format($charge,2,',','.').'; total keluar Rp '.number_format($gross,2,',','.').'; referensi '.($reference?:'-').'; pokok dicatat sebagai Barang Dalam Perjalanan'; $entries=array(array('account_id'=>$this->CI->accountmappingservice->get('goods_in_transit'),'debit'=>$principal,'kredit'=>0,'keterangan'=>$desc)); if($charge>0)$entries[]=array('account_id'=>$chargeAccount,'debit'=>$charge,'kredit'=>0,'keterangan'=>$desc); foreach($valid as$account=>$value)$entries[]=array('account_id'=>$account,'debit'=>0,'kredit'=>$value,'keterangan'=>$desc); $journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$no,'keterangan'=>$desc,'ref_type'=>'purchase_prepayment','ref_id'=>$po->id,'created_by'=>$user),$entries,false,true); $payment=array('payment_no'=>$no,'supplier_id'=>$po->supplier_id,'purchase_order_id'=>$po->id,'payment_kind'=>'prepayment','payment_date'=>$date,'amount'=>$principal,'bank_charge_amount'=>$charge,'bank_charge_account_id'=>$chargeAccount,'total_cash_out'=>$gross,'bank_account_id'=>array_key_first($valid),'status'=>'posted','reference_no'=>$reference,'notes'=>$notes,'journal_id'=>$journal,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s')); if($this->CI->db->field_exists('company_id','supplier_payments'))$payment['company_id']=$this->CI->companycontext->id(); $this->CI->db->insert('supplier_payments',$payment);$paymentId=$this->CI->db->insert_id(); foreach($valid as$account=>$value)$this->CI->db->insert('purchase_payment_sources',array('supplier_payment_id'=>$paymentId,'account_id'=>$account,'amount'=>$value,'created_at'=>date('Y-m-d H:i:s'))); $paid=round($po->paid_amount+$principal,2);$this->CI->db->where('id',$po->id)->update('purchase_orders',array('paid_amount'=>$paid,'payment_status'=>$paid>=$po->total-.001?'paid':'partial')); return$paymentId; }); } 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; if($alreadyPaid||$bill->payment_plan==='credit'||$bill->payment_plan==='installment'){ if(round((float)$bankCharge,2)>0)throw new BusinessException('Biaya admin bank hanya diisi ketika terjadi pembayaran baru.'); return$this->settleSupplierInvoiceMulti($id,$sources,$paymentDate,$reference,$user); } list($valid,$gross)=$this->purchasePaymentSources($sources);list($charge,$chargeAccount)=$this->purchaseBankCharge($bankCharge,$bankChargeAccount); $principal=round($gross-$charge,2);$required=round((float)$bill->total,2); if($principal<=0||abs($principal-$required)>.001)throw new BusinessException('Pokok pembayaran invoice harus Rp '.number_format($required,2,',','.').'. Total sumber Kas/Bank termasuk admin harus Rp '.number_format($required+$charge,2,',','.').'.'); return$this->CI->transactionservice->run(function()use($id,$po,$valid,$gross,$charge,$chargeAccount,$principal,$paymentDate,$reference,$user){ $locked=$this->lock('supplier_invoices',$id,'verified');$date=$paymentDate?:$locked->invoice_date;$this->CI->fiscalperiodservice->assertOpen($date); $supplier=$this->CI->db->get_where('suppliers',array('id'=>$locked->supplier_id))->row();$no=$this->CI->numberingservice->next('supplier_payment',$date); $desc='[AUTO] Pembayaran langsung invoice supplier '.$locked->supplier_invoice_no.' ('.$locked->internal_no.') kepada '.($supplier?$supplier->supplier_code.' - '.$supplier->name:'supplier #'.$locked->supplier_id).'; pokok Rp '.number_format($principal,2,',','.').'; admin bank Rp '.number_format($charge,2,',','.').'; total keluar Rp '.number_format($gross,2,',','.').'; referensi '.($reference?:'-'); $entries=array();foreach($this->purchaseDebitGroups($id)as$account=>$value)$entries[]=array('account_id'=>$account,'debit'=>$value,'kredit'=>0,'keterangan'=>$desc); if($locked->tax_total>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('purchase_input_tax'),'debit'=>$locked->tax_total,'kredit'=>0,'keterangan'=>$desc); if($charge>0)$entries[]=array('account_id'=>$chargeAccount,'debit'=>$charge,'kredit'=>0,'keterangan'=>$desc); foreach($valid as$account=>$value)$entries[]=array('account_id'=>$account,'debit'=>0,'kredit'=>$value,'keterangan'=>$desc); $journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$no,'keterangan'=>$desc,'ref_type'=>'supplier_invoice_cash','ref_id'=>$id,'created_by'=>$user),$entries,false,true); $payment=array('payment_no'=>$no,'supplier_id'=>$locked->supplier_id,'purchase_order_id'=>$po?$po->id:null,'payment_date'=>$date,'amount'=>$principal,'bank_charge_amount'=>$charge,'bank_charge_account_id'=>$chargeAccount,'total_cash_out'=>$gross,'bank_account_id'=>array_key_first($valid),'status'=>'posted','reference_no'=>$reference,'journal_id'=>$journal,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s')); if($this->CI->db->field_exists('company_id','supplier_payments'))$payment['company_id']=$this->CI->companycontext->id(); $this->CI->db->insert('supplier_payments',$payment);$pid=$this->CI->db->insert_id(); foreach($valid as$account=>$value)$this->CI->db->insert('purchase_payment_sources',array('supplier_payment_id'=>$pid,'account_id'=>$account,'amount'=>$value,'created_at'=>date('Y-m-d H:i:s'))); $this->CI->db->insert('supplier_payment_allocations',array('supplier_payment_id'=>$pid,'supplier_invoice_id'=>$id,'amount'=>$principal,'created_at'=>date('Y-m-d H:i:s'))); $this->CI->db->where('id',$id)->update('supplier_invoices',array('status'=>'paid','paid_amount'=>$principal,'balance'=>0,'journal_id'=>$journal,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s'))); return array('invoice_id'=>$id,'payment_id'=>$pid,'payment_no'=>$no,'mode'=>'cash'); }); } 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); $principal=round($gross-$charge,2);if($principal<=0)throw new BusinessException('Total sumber Kas/Bank harus lebih besar daripada biaya admin bank.'); $allocated=0;$bills=array(); foreach($allocations as$id=>$value){$value=round((float)$value,2);if($value<=0)continue;$bill=$this->CI->db->query('SELECT * FROM supplier_invoices WHERE id=? FOR UPDATE',array((int)$id))->row();if(!$bill||(int)$bill->supplier_id!==(int)$supplierId||$bill->status!=='partial'||$value>$bill->balance+.001)throw new BusinessException('Alokasi tagihan supplier tidak valid atau melebihi saldo.');$allocated+=$value;$bills[]=array($bill,$value);} if(!$bills&&!$allowAdvance)throw new BusinessException('Pilih minimal satu tagihan yang akan dibayar.'); if($allocated>$principal+.001)throw new BusinessException('Total alokasi tagihan melebihi pokok pembayaran setelah biaya admin bank.'); $advance=round($principal-$allocated,2);if($advance>0&&!$allowAdvance)throw new BusinessException('Pokok pembayaran harus sama dengan alokasi tagihan, atau centang uang muka supplier.'); $this->CI->fiscalperiodservice->assertOpen($date);$no=$this->CI->numberingservice->next('supplier_payment',$date); $docs=array_map(function($row){return$row[0]->internal_no.' Rp '.number_format($row[1],2,',','.');},$bills); $desc='[AUTO] Pembayaran supplier '.$supplier->supplier_code.' - '.$supplier->name.'; tagihan '.($docs?implode(', ',$docs):'uang muka').'; pokok Rp '.number_format($principal,2,',','.').'; admin bank Rp '.number_format($charge,2,',','.').'; total keluar Rp '.number_format($gross,2,',','.').'; referensi '.($reference?:'-'); $entries=array();if($allocated>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('accounts_payable'),'debit'=>$allocated,'kredit'=>0,'keterangan'=>$desc);if($advance>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('supplier_advances'),'debit'=>$advance,'kredit'=>0,'keterangan'=>$desc);if($charge>0)$entries[]=array('account_id'=>$chargeAccount,'debit'=>$charge,'kredit'=>0,'keterangan'=>$desc);foreach($valid as$account=>$value)$entries[]=array('account_id'=>$account,'debit'=>0,'kredit'=>$value,'keterangan'=>$desc); $journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$no,'keterangan'=>$desc,'ref_type'=>'supplier_payment','created_by'=>$user),$entries,false,true); $payment=array('payment_no'=>$no,'supplier_id'=>$supplierId,'payment_date'=>$date,'amount'=>$principal,'bank_charge_amount'=>$charge,'bank_charge_account_id'=>$chargeAccount,'total_cash_out'=>$gross,'bank_account_id'=>array_key_first($valid),'status'=>'posted','reference_no'=>$reference,'journal_id'=>$journal,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s')); if($this->CI->db->field_exists('company_id','supplier_payments'))$payment['company_id']=$this->CI->companycontext->id(); $this->CI->db->insert('supplier_payments',$payment);$pid=$this->CI->db->insert_id();foreach($valid as$account=>$value)$this->CI->db->insert('purchase_payment_sources',array('supplier_payment_id'=>$pid,'account_id'=>$account,'amount'=>$value,'created_at'=>date('Y-m-d H:i:s'))); foreach($bills as$row){list($bill,$value)=$row;$this->CI->db->insert('supplier_payment_allocations',array('supplier_payment_id'=>$pid,'supplier_invoice_id'=>$bill->id,'amount'=>$value,'created_at'=>date('Y-m-d H:i:s')));$paid=$bill->paid_amount+$value;$balance=max(0,$bill->total-$paid);$this->CI->db->where('id',$bill->id)->update('supplier_invoices',array('paid_amount'=>$paid,'balance'=>$balance,'status'=>$balance<=.001?'paid':'partial'));$poLine=$this->CI->db->select('pol.purchase_order_id')->from('supplier_invoice_lines sil')->join('purchase_order_lines pol','pol.id=sil.po_line_id')->where('sil.supplier_invoice_id',$bill->id)->limit(1)->get()->row();if($poLine){$scheduleAllocation=$value;foreach($this->CI->db->where('purchase_order_id',$poLine->purchase_order_id)->where_in('status',array('unpaid','partial'))->order_by('due_date')->order_by('installment_no')->get('purchase_installment_schedules')->result()as$schedule){if($scheduleAllocation<=.001)break;$applied=min($scheduleAllocation,max(0,$schedule->amount-$schedule->paid_amount));if($applied<=0)continue;$schedulePaid=$schedule->paid_amount+$applied;$this->CI->db->where('id',$schedule->id)->update('purchase_installment_schedules',array('paid_amount'=>$schedulePaid,'status'=>$schedulePaid>=$schedule->amount-.001?'paid':'partial'));$scheduleAllocation-=$applied;}}} if($advance>0)$this->CI->db->insert('supplier_advances',array('advance_no'=>'SADV-'.$no,'supplier_id'=>$supplierId,'supplier_payment_id'=>$pid,'original_amount'=>$advance,'available_amount'=>$advance,'status'=>'available','journal_id'=>$journal,'created_at'=>date('Y-m-d H:i:s'))); return array('payment_id'=>$pid,'payment_no'=>$no,'amount'=>$principal,'bank_charge'=>$charge,'total_cash_out'=>$gross,'allocated'=>$allocated,'advance'=>$advance); }); } public function __construct(){$this->CI=&get_instance();$this->CI->load->library(array('TransactionService','NumberingService','ApprovalService','AuditService','PostingService','AccountMappingService','FiscalPeriodService'));$this->CI->load->model('InventoryModel','purchaseInventoryModel');} public function createCatalogRequest(array$d,$user){if(empty($d['department_id'])||trim($d['department'])==='')throw new BusinessException('Departemen wajib dipilih dari master.');$source=isset($d['lines'])&&is_array($d['lines'])?$d['lines']:array($d);if(!$source)throw new BusinessException('Minimal satu barang wajib ditambahkan.');$lines=array();foreach($source as$index=>$input){$catalog=$this->CI->db->get_where('kode_barang',array('id'=>(int)($input['kode_barang_id']??0)))->row();if(!$catalog)throw new BusinessException('Master Barang pada baris '.($index+1).' tidak ditemukan.');$tracking=strtoupper($catalog->tracking_type?:'UNIT');$qty=(float)($input['qty']??0);$per=$tracking==='UNIT'?1:(float)($input['qty_per_barcode']??0);$description=trim((string)($input['description']??''));$price=round((float)($input['estimated_price']??0),2);if($description===''||$qty<=0||$price<0)throw new BusinessException('Deskripsi, qty, dan estimasi harga baris '.($index+1).' tidak valid.');if($tracking==='QTY'&&$per<=0)throw new BusinessException('Isi per barcode wajib diisi pada baris '.($index+1).'.');if($tracking==='UNIT'&&abs($qty-round($qty))>.0001)throw new BusinessException('Qty barang UNIT pada baris '.($index+1).' harus bilangan bulat.');$lines[]=array('item_id'=>null,'kode_barang_id'=>$catalog->id,'description'=>$description,'qty'=>$qty,'estimated_price'=>$price,'tracking_type'=>$tracking,'qty_per_barcode'=>$per,'need_serial_number'=>strtolower($catalog->need_serial_number)==='true'?1:0);} $d['lines']=$lines;$id=$this->createRequest($d,$user);$this->CI->db->where('id',$id)->update('purchase_requests',array('department_id'=>(int)$d['department_id']));return$id;} public function createCatalogOrder($requestId,$supplierId,$date,$expected,$warehouseId,$description,$qty,$price,$taxRate,$user,array$options=array()){$line=$this->CI->db->where('purchase_request_id',(int)$requestId)->order_by('id')->get('purchase_request_lines')->row();if(!$line||!$line->kode_barang_id)throw new BusinessException('PR belum memiliki Master Kode Barang.');$request=$this->CI->db->get_where('purchase_requests',array('id'=>(int)$requestId))->row();$itemId=null;if($request&&$request->destination_type==='warehouse'){$item=$this->CI->db->where('company_id',$this->CI->companycontext->id())->where('kode_id',$line->kode_barang_id)->where('nama_barang',trim($description))->get('items')->row();if(!$item){$catalog=$this->CI->db->get_where('kode_barang',array('id'=>$line->kode_barang_id))->row();$this->CI->db->insert('items',array('company_id'=>$this->CI->companycontext->id(),'stok'=>0,'planned_qty'=>$qty,'planned_warehouse_id'=>$warehouseId,'planned_tracking_qty'=>$line->qty_per_barcode,'kode_detail'=>$catalog->kode_barang.'-'.date('ymdHis'),'kode_id'=>$catalog->id,'nama_barang'=>trim($description),'harga_beli'=>$price,'harga_jual'=>0,'tanggal_pembelian'=>$date,'status'=>'draft'));$itemId=$this->CI->db->insert_id();}else{$itemId=$item->id;}}$id=$this->createOrder($requestId,$supplierId,$date,$expected,$itemId,$warehouseId,$description,$qty,$price,$taxRate,$user,$options);$plan=in_array($options['payment_plan']??'',array('cash','installment','credit'),true)?$options['payment_plan']:'credit';$this->CI->db->where('id',$id)->update('purchase_orders',array('payment_plan'=>$plan));$this->CI->db->where('purchase_order_id',$id)->update('purchase_order_lines',array('kode_barang_id'=>$line->kode_barang_id,'tracking_type'=>$line->tracking_type,'qty_per_barcode'=>$line->qty_per_barcode,'need_serial_number'=>$line->need_serial_number));return$id;} public function createProfessionalOrder(array$d,$user){return$this->CI->transactionservice->run(function()use($d,$user){$request=$this->lock('purchase_requests',(int)$d['request_id'],'approved');$supplier=$this->CI->db->get_where('suppliers',array('id'=>(int)$d['supplier_id'],'is_active'=>1))->row();if(!$supplier)throw new BusinessException('Supplier tidak aktif atau tidak ditemukan.');$warehouse=(int)$d['warehouse_id'];if(!$warehouse||!$this->CI->db->get_where('warehouses',array('id'=>$warehouse))->row())throw new BusinessException('Gudang tujuan wajib dipilih.');$method=in_array($d['procurement_method'],array('online','direct','supplier'),true)?$d['procurement_method']:'supplier';$timing=in_array($d['payment_timing'],array('prepaid','on_receipt','installment','credit'),true)?$d['payment_timing']:'credit';if($method==='online'&&(trim($d['marketplace'])===''||trim($d['external_order_no'])===''))throw new BusinessException('Marketplace dan nomor pesanan wajib untuk pembelian online.');$requestLines=$this->CI->db->where('purchase_request_id',$request->id)->order_by('id')->get('purchase_request_lines')->result();if(!$requestLines)throw new BusinessException('PR tidak memiliki barang.');$subtotal=0;$taxTotal=0;$prepared=array();foreach($requestLines as$line){$price=round((float)($d['unit_prices'][$line->id]??$line->estimated_price),2);$taxRate=round((float)($d['tax_rates'][$line->id]??0),4);if($price<0||$taxRate<0)throw new BusinessException('Harga atau pajak barang tidak valid.');$base=(float)$line->qty*$price;$tax=$base*$taxRate/100;$subtotal+=$base;$taxTotal+=$tax;$prepared[]=array($line,$price,$taxRate,$tax,$base+$tax);}foreach(array('shipping_cost','insurance_cost','service_fee','discount_amount','cashback_amount')as$key)$d[$key]=max(0,round((float)$d[$key],2));$total=$subtotal+$taxTotal+$d['shipping_cost']+$d['insurance_cost']+$d['service_fee']-$d['discount_amount']-$d['cashback_amount'];if($total<0)throw new BusinessException('Total PO tidak boleh negatif.');$downPaymentRequired=$timing==='installment'&&!empty($d['down_payment_required']);$downPaymentAmount=$downPaymentRequired?round((float)$d['down_payment_amount'],2):0;if($downPaymentRequired&&($downPaymentAmount<=0||$downPaymentAmount>=$total))throw new BusinessException('Nilai DP harus lebih dari nol dan lebih kecil dari total PO.');$no=$this->CI->numberingservice->next('purchase_order',$d['date']);$this->CI->db->insert('purchase_orders',array('po_no'=>$no,'supplier_id'=>$supplier->id,'purchase_request_id'=>$request->id,'order_date'=>$d['date'],'expected_date'=>$d['expected_date']?:null,'procurement_method'=>$method,'payment_timing'=>$timing,'marketplace'=>$method==='online'?$d['marketplace']:null,'external_order_no'=>$d['external_order_no']?:null,'courier'=>$d['courier']?:null,'tracking_no'=>$d['tracking_no']?:null,'status'=>'draft','subtotal'=>$subtotal,'tax_total'=>$taxTotal,'total'=>$total,'shipping_cost'=>$d['shipping_cost'],'insurance_cost'=>$d['insurance_cost'],'service_fee'=>$d['service_fee'],'discount_amount'=>$d['discount_amount'],'cashback_amount'=>$d['cashback_amount'],'notes'=>$d['notes'],'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s'),'payment_plan'=>$timing==='installment'?'installment':($timing==='credit'?'credit':'cash'),'down_payment_required'=>$downPaymentRequired?1:0,'down_payment_amount'=>$downPaymentAmount));$poId=$this->CI->db->insert_id();foreach($prepared as$row){list($line,$price,$taxRate,$tax,$lineTotal)=$row;$item=$this->CI->db->where('kode_id',$line->kode_barang_id)->where('nama_barang',$line->description)->get('items')->row();if(!$item){$catalog=$this->CI->db->get_where('kode_barang',array('id'=>$line->kode_barang_id))->row();$this->CI->db->insert('items',array('stok'=>0,'planned_qty'=>$line->qty,'planned_warehouse_id'=>$warehouse,'planned_tracking_qty'=>$line->qty_per_barcode,'kode_detail'=>$catalog->kode_barang.'-'.date('ymdHis').'-'.$line->id,'kode_id'=>$catalog->id,'nama_barang'=>$line->description,'harga_beli'=>$price,'harga_jual'=>0,'tanggal_pembelian'=>$d['date'],'status'=>'draft'));$itemId=$this->CI->db->insert_id();}else$itemId=$item->id;$this->CI->db->insert('purchase_order_lines',array('purchase_order_id'=>$poId,'request_line_id'=>$line->id,'item_id'=>$itemId,'warehouse_id'=>$warehouse,'description'=>$line->description,'qty'=>$line->qty,'unit_price'=>$price,'tax_rate'=>$taxRate,'tax_amount'=>$tax,'line_total'=>$lineTotal,'destination_type'=>'warehouse','kode_barang_id'=>$line->kode_barang_id,'tracking_type'=>$line->tracking_type,'qty_per_barcode'=>$line->qty_per_barcode,'need_serial_number'=>$line->need_serial_number));$this->CI->db->where('id',$line->id)->set('ordered_qty','ordered_qty+'.$this->CI->db->escape($line->qty),false)->update('purchase_request_lines');}$this->CI->db->where('id',$request->id)->update('purchase_requests',array('status'=>'converted'));if($timing==='installment')$this->createInstallmentSchedule($poId,$total-$downPaymentAmount,(int)$d['installment_count'],$d['first_due_date']);$this->CI->auditservice->record('purchase','purchase_order',$poId,'create',null,array('po_no'=>$no,'method'=>$method,'payment_timing'=>$timing,'line_count'=>count($prepared),'total'=>$total),$user);return$poId;});} private function createInstallmentSchedule($poId,$total,$count,$firstDue){if($count<2||!$firstDue)throw new BusinessException('Jumlah cicilan minimal 2 dan tanggal cicilan pertama wajib diisi.');$base=floor(($total/$count)*100)/100;$allocated=0;for($i=1;$i<=$count;$i++){$amount=$i===$count?round($total-$allocated,2):$base;$this->CI->db->insert('purchase_installment_schedules',array('purchase_order_id'=>$poId,'installment_no'=>$i,'due_date'=>date('Y-m-d',strtotime($firstDue.' +'.($i-1).' month')),'amount'=>$amount,'created_at'=>date('Y-m-d H:i:s')));$allocated+=$amount;}} public function prepayOrder($poId,$date,array$sources,$reference,$notes,$user){return$this->CI->transactionservice->run(function()use($poId,$date,$sources,$reference,$notes,$user){$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.');if($po->payment_timing!=='prepaid'&&!($po->payment_timing==='installment'&&(int)$po->down_payment_required===1))throw new BusinessException('Menu Pembayaran hanya untuk pelunasan sebelum barang diterima atau DP yang diwajibkan PO. Cicilan reguler dibayar melalui Hutang & Pembayaran.');$total=0;$valid=array();foreach($sources as$account=>$amount){$amount=round((float)$amount,2);if($amount<=0)continue;$a=$this->CI->db->get_where('accounts',array('id'=>(int)$account,'is_active'=>1,'allow_posting'=>1))->row();if(!$a)throw new BusinessException('Akun sumber pembayaran tidak valid.');$total+=$amount;$valid[]=array((int)$account,$amount);}if(!$valid||$total<=0)throw new BusinessException('Minimal satu sumber pembayaran wajib diisi.');$required=round(($po->payment_timing==='installment'?$po->down_payment_amount:$po->total)-$po->paid_amount,2);if($required<=.001)throw new BusinessException('Pembayaran awal/DP untuk PO ini sudah lunas.');if(abs($total-$required)>.001)throw new BusinessException('Total pembayaran harus sama dengan '.($po->payment_timing==='installment'?'sisa DP':'total PO').' sebesar Rp '.number_format($required,2,',','.').'.');$this->CI->fiscalperiodservice->assertOpen($date);$no=$this->CI->numberingservice->next('supplier_payment',$date);$entries=array(array('account_id'=>$this->CI->accountmappingservice->get('goods_in_transit'),'debit'=>$total,'kredit'=>0));foreach($valid as$source)$entries[]=array('account_id'=>$source[0],'debit'=>0,'kredit'=>$source[1]);$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$no,'keterangan'=>'[AUTO] '.($po->payment_timing==='installment'?'Pembayaran DP':'Pelunasan sebelum penerimaan').' untuk PO '.$po->po_no.'; referensi '.($reference?:'-').'; dicatat sebagai Barang Dalam Perjalanan','ref_type'=>'purchase_prepayment','ref_id'=>$po->id,'created_by'=>$user),$entries,false,true);$this->CI->db->insert('supplier_payments',array('payment_no'=>$no,'supplier_id'=>$po->supplier_id,'purchase_order_id'=>$po->id,'payment_kind'=>'prepayment','payment_date'=>$date,'amount'=>$total,'bank_account_id'=>$valid[0][0],'status'=>'posted','reference_no'=>$reference,'notes'=>$notes,'journal_id'=>$journal,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s')));$paymentId=$this->CI->db->insert_id();foreach($valid as$source)$this->CI->db->insert('purchase_payment_sources',array('supplier_payment_id'=>$paymentId,'account_id'=>$source[0],'amount'=>$source[1],'created_at'=>date('Y-m-d H:i:s')));$paid=round($po->paid_amount+$total,2);$this->CI->db->where('id',$po->id)->update('purchase_orders',array('paid_amount'=>$paid,'payment_status'=>$paid>=$po->total-.001?'paid':'partial'));return$paymentId;});} public function refundOrder($poId,$date,$account,$amount,$reason,$user){return$this->CI->transactionservice->run(function()use($poId,$date,$account,$amount,$reason,$user){$po=$this->CI->db->query('SELECT * FROM purchase_orders WHERE id=? FOR UPDATE',array((int)$poId))->row();$amount=round((float)$amount,2);if(!$po||$amount<=0||$amount>$po->paid_amount-$po->transit_cleared_amount+.001)throw new BusinessException('Nilai refund melebihi pembayaran yang masih berada dalam perjalanan.');if(trim($reason)==='')throw new BusinessException('Alasan refund wajib diisi.');$no='RFP-'.date('YmdHis').'-'.$po->id;$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$no,'keterangan'=>'Refund pembelian PO '.$po->po_no.': '.$reason,'ref_type'=>'purchase_refund','ref_id'=>$po->id,'created_by'=>$user),array(array('account_id'=>$account,'debit'=>$amount,'kredit'=>0),array('account_id'=>$this->CI->accountmappingservice->get('goods_in_transit'),'debit'=>0,'kredit'=>$amount)),false,true);$this->CI->db->insert('purchase_refunds',array('refund_no'=>$no,'purchase_order_id'=>$po->id,'refund_date'=>$date,'account_id'=>$account,'amount'=>$amount,'reason'=>$reason,'journal_id'=>$journal,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s')));$paid=max(0,$po->paid_amount-$amount);$this->CI->db->where('id',$po->id)->update('purchase_orders',array('paid_amount'=>$paid,'payment_status'=>$paid<=.001?'refunded':'partial'));return$this->CI->db->insert_id();});} public function finalizeReceiptTracking($receiptId){return$this->CI->transactionservice->run(function()use($receiptId){ $lines=$this->CI->db->select('grl.*,gr.receipt_no,gr.supplier_id,pol.tracking_type,pol.qty_per_barcode,pol.need_serial_number,i.kode_detail,i.harga_beli,i.harga_jual,i.status item_status')->from('goods_receipt_lines grl')->join('goods_receipts gr','gr.id=grl.goods_receipt_id')->join('purchase_order_lines pol','pol.id=grl.po_line_id')->join('items i','i.id=grl.item_id','left')->where('grl.goods_receipt_id',$receiptId)->get()->result(); foreach($lines as$l){ if(!$l->item_id)continue;$tracking=$l->tracking_type?:'UNIT';$per=$tracking==='UNIT'?1:(float)$l->qty_per_barcode;if($per<=0)throw new BusinessException('Qty per barcode PO belum valid.'); $existing=$this->CI->db->where('source_receipt_line_id',$l->id)->count_all_results('item_barcodes'); $needsSerial=(int)$l->need_serial_number===1;$hasSalePrice=(float)$l->harga_jual>0&&(float)$l->harga_jual>=(float)$l->harga_beli;$barcodeStatus=(!$needsSerial&&$hasSalePrice)?'available':'pending'; if(!$existing){$remaining=(float)$l->qty;$index=1;while($remaining>.0001){$barcodeQty=min($per,$remaining);$code=strtoupper(preg_replace('/[^A-Z0-9_-]/i','',(string)$l->kode_detail)).'-GR'.$receiptId.'-'.$l->id.'-'.str_pad($index,3,'0',STR_PAD_LEFT);$this->CI->db->insert('item_barcodes',array('item_id'=>$l->item_id,'barcode'=>$code,'warehouse_id'=>$l->warehouse_id,'qty_awal'=>$barcodeQty,'qty_sisa'=>$barcodeQty,'status'=>$barcodeStatus,'source_receipt_line_id'=>$l->id,'created_at'=>date('Y-m-d H:i:s')));$remaining-=$barcodeQty;$index++;}} foreach($this->CI->db->where('source_receipt_line_id',$l->id)->order_by('id')->get('item_barcodes')->result()as$barcode){$movementExists=$this->CI->db->where(array('barcode_id'=>$barcode->id,'reference_type'=>'goods_receipt_line','reference_id'=>$l->id,'movement_type'=>'purchase'))->count_all_results('item_movements');if(!$movementExists)$this->CI->db->insert('item_movements',array('item_id'=>$l->item_id,'barcode_id'=>$barcode->id,'qty'=>$barcode->qty_awal,'from_type'=>'supplier','from_id'=>$l->supplier_id,'to_type'=>'warehouse','to_id'=>$l->warehouse_id,'movement_type'=>'purchase','reference_type'=>'goods_receipt_line','reference_id'=>$l->id,'notes'=>'Penerimaan barang '.$l->receipt_no.' dari supplier ke gudang','created_at'=>date('Y-m-d H:i:s')));} $itemStatus=($l->item_status==='active'||(!$needsSerial&&$hasSalePrice))?'active':'draft';$this->CI->db->where('id',$l->item_id)->update('items',array('status'=>$itemStatus)); }return$receiptId; });} public function syncInvoicePaymentPlan($invoiceId,$poId){$po=$this->CI->db->get_where('purchase_orders',array('id'=>$poId))->row();if($po)$this->CI->db->where('id',$invoiceId)->update('supplier_invoices',array('payment_plan'=>$po->payment_plan));return$invoiceId;} public function settleSupplierInvoiceMulti($id,array$sources,$paymentDate,$reference,$user){$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();if($po&&in_array($po->payment_timing,array('prepaid','on_receipt'),true)&&$po->paid_amount>=$bill->total-.001)return array('invoice_id'=>$this->settleSupplierInvoice($id,null,0,$paymentDate,$reference,$user),'payment_id'=>null,'mode'=>'reconcile');if($bill->payment_plan==='credit')return array('invoice_id'=>$this->postSupplierInvoice($id,$user),'payment_id'=>null,'mode'=>'payable');if($bill->payment_plan==='installment'){$invoiceId=$po&&$po->paid_amount>0?$this->settleSupplierInvoice($id,null,0,$paymentDate,$reference,$user):$this->postSupplierInvoice($id,$user);return array('invoice_id'=>$invoiceId,'payment_id'=>null,'mode'=>'installment_payable');}$valid=array();$amount=0;foreach($sources as$account=>$value){$value=round((float)$value,2);if($value<=0)continue;$a=$this->CI->db->where(array('id'=>(int)$account,'is_active'=>1,'allow_posting'=>1))->group_start()->like('nama_akun','kas')->or_like('nama_akun','bank')->group_end()->get('accounts')->row();if(!$a)throw new BusinessException('Sumber Kas/Bank tidak valid.');$valid[(int)$account]=($valid[(int)$account]??0)+$value;$amount+=$value;}if(!$valid)throw new BusinessException('Minimal satu sumber Kas/Bank wajib diisi.');$required=$bill->payment_plan==='installment'?$amount:(float)$bill->total;if($amount<=0||$amount>$bill->total+.001||($bill->payment_plan!=='installment'&&abs($amount-$bill->total)>.001))throw new BusinessException($bill->payment_plan==='installment'?'Total cicilan harus lebih dari nol dan tidak melebihi invoice.':'Total seluruh sumber Kas/Bank harus sama dengan total invoice.');if($bill->payment_plan==='installment'){$this->postSupplierInvoice($id,$user);$r=$this->paymentMulti($bill->supplier_id,$paymentDate,$valid,array($id=>$required),false,$user,$reference);return array('invoice_id'=>$id,'payment_id'=>$r['payment_id'],'payment_no'=>$r['payment_no'],'mode'=>'installment');}return$this->CI->transactionservice->run(function()use($id,$bill,$valid,$amount,$paymentDate,$reference,$user){$locked=$this->lock('supplier_invoices',$id,'verified');$this->CI->fiscalperiodservice->assertOpen($paymentDate?:$locked->invoice_date);$supplier=$this->CI->db->get_where('suppliers',array('id'=>$locked->supplier_id))->row();$no=$this->CI->numberingservice->next('supplier_payment',$paymentDate?:$locked->invoice_date);$desc='[AUTO] Pembayaran langsung invoice supplier '.$locked->supplier_invoice_no.' ('.$locked->internal_no.') kepada '.($supplier?$supplier->supplier_code.' - '.$supplier->name:'supplier #'.$locked->supplier_id).'; referensi '.($reference?:'-');$entries=array();foreach($this->purchaseDebitGroups($id)as$account=>$value)$entries[]=array('account_id'=>$account,'debit'=>$value,'kredit'=>0,'keterangan'=>$desc);if($locked->tax_total>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('purchase_input_tax'),'debit'=>$locked->tax_total,'kredit'=>0,'keterangan'=>$desc);foreach($valid as$account=>$value)$entries[]=array('account_id'=>$account,'debit'=>0,'kredit'=>$value,'keterangan'=>$desc);$journal=$this->CI->postingservice->post(array('tanggal'=>$paymentDate?:$locked->invoice_date,'no_ref'=>$no,'keterangan'=>$desc,'ref_type'=>'supplier_invoice_cash','ref_id'=>$id,'created_by'=>$user),$entries,false,true);$payment=array('payment_no'=>$no,'supplier_id'=>$locked->supplier_id,'payment_date'=>$paymentDate?:$locked->invoice_date,'amount'=>$amount,'bank_account_id'=>array_key_first($valid),'status'=>'posted','reference_no'=>$reference,'journal_id'=>$journal,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s'));if($this->CI->db->field_exists('company_id','supplier_payments'))$payment['company_id']=$this->CI->companycontext->id();$this->CI->db->insert('supplier_payments',$payment);$pid=$this->CI->db->insert_id();foreach($valid as$account=>$value)$this->CI->db->insert('purchase_payment_sources',array('supplier_payment_id'=>$pid,'account_id'=>$account,'amount'=>$value,'created_at'=>date('Y-m-d H:i:s')));$this->CI->db->insert('supplier_payment_allocations',array('supplier_payment_id'=>$pid,'supplier_invoice_id'=>$id,'amount'=>$amount,'created_at'=>date('Y-m-d H:i:s')));$this->CI->db->where('id',$id)->update('supplier_invoices',array('status'=>'paid','paid_amount'=>$amount,'balance'=>0,'journal_id'=>$journal,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s')));return array('invoice_id'=>$id,'payment_id'=>$pid,'payment_no'=>$no,'mode'=>'cash');});} public function settleSupplierInvoice($id,$bankAccount,$initialAmount,$paymentDate,$reference,$user){$bill=$this->CI->db->get_where('supplier_invoices',array('id'=>(int)$id))->row();if(!$bill)throw new BusinessException('Tagihan 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();if($po&&in_array($po->payment_timing,array('prepaid','on_receipt'),true)){if($po->paid_amount<$bill->total-.001)throw new BusinessException('Pembayaran awal belum mencukupi nilai invoice. Selesaikan selisih melalui rekonsiliasi/refund, bukan membayar ulang invoice ini.');return$this->CI->transactionservice->run(function()use($id,$bill,$po,$user){$this->lock('supplier_invoices',$id,'verified');$payment=$this->CI->db->where(array('purchase_order_id'=>$po->id,'payment_kind'=>'prepayment','status'=>'posted'))->order_by('id')->get('supplier_payments')->row();$this->CI->db->where('id',$id)->update('supplier_invoices',array('status'=>'paid','paid_amount'=>$bill->total,'balance'=>0,'journal_id'=>$payment?$payment->journal_id:null,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s')));if($payment)$this->CI->db->insert('supplier_payment_allocations',array('supplier_payment_id'=>$payment->id,'supplier_invoice_id'=>$id,'amount'=>$bill->total,'created_at'=>date('Y-m-d H:i:s')));return$id;});}if($po&&$po->payment_timing==='installment'&&$po->paid_amount>0)return$this->postInstallmentBalance($id,$bill,$po,$user);if($bill->payment_plan==='credit')return$this->postSupplierInvoice($id,$user);if(!$bankAccount)throw new BusinessException('Akun Kas/Bank wajib dipilih.');if($bill->payment_plan==='installment'){$journal=$this->postSupplierInvoice($id,$user);$amount=round((float)$initialAmount,2);if($amount<=0||$amount>$bill->total)throw new BusinessException('Nominal cicilan pertama harus lebih dari nol dan tidak melebihi tagihan.');$this->payment($bill->supplier_id,$paymentDate,$bankAccount,$amount,array($id=>$amount),false,$user,$reference);return$journal;}return$this->CI->transactionservice->run(function()use($id,$bankAccount,$paymentDate,$reference,$user){$bill=$this->lock('supplier_invoices',$id,'verified');$this->CI->fiscalperiodservice->assertOpen($bill->invoice_date);$entries=array();foreach($this->purchaseDebitGroups($id)as$account=>$amount)$entries[]=array('account_id'=>$account,'debit'=>$amount,'kredit'=>0);if($bill->tax_total>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('purchase_input_tax'),'debit'=>$bill->tax_total,'kredit'=>0);$entries[]=array('account_id'=>$bankAccount,'debit'=>0,'kredit'=>$bill->total);$journal=$this->CI->postingservice->post(array('tanggal'=>$paymentDate?:$bill->invoice_date,'no_ref'=>$bill->internal_no,'keterangan'=>'Pembelian tunai supplier '.$bill->supplier_invoice_no.'; pembayaran langsung tanpa membentuk saldo hutang','ref_type'=>'supplier_invoice_cash','ref_id'=>$id,'created_by'=>$user),$entries,false,true);$no=$this->CI->numberingservice->next('supplier_payment',$paymentDate?:$bill->invoice_date);$this->CI->db->insert('supplier_payments',array('payment_no'=>$no,'supplier_id'=>$bill->supplier_id,'payment_date'=>$paymentDate?:$bill->invoice_date,'amount'=>$bill->total,'bank_account_id'=>$bankAccount,'status'=>'posted','reference_no'=>$reference,'journal_id'=>$journal,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s')));$paymentId=$this->CI->db->insert_id();$this->CI->db->insert('supplier_payment_allocations',array('supplier_payment_id'=>$paymentId,'supplier_invoice_id'=>$id,'amount'=>$bill->total,'created_at'=>date('Y-m-d H:i:s')));$this->CI->db->where('id',$id)->update('supplier_invoices',array('status'=>'paid','paid_amount'=>$bill->total,'balance'=>0,'journal_id'=>$journal,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s')));return$journal;});} private function postInstallmentBalance($id,$bill,$po,$user){return$this->CI->transactionservice->run(function()use($id,$bill,$po,$user){$this->lock('supplier_invoices',$id,'verified');$remaining=round(max(0,$bill->total-$po->paid_amount),2);$journal=null;if($remaining>0){$journal=$this->CI->postingservice->post(array('tanggal'=>$bill->invoice_date,'no_ref'=>$bill->internal_no,'keterangan'=>'Pengakuan sisa tagihan cicilan '.$bill->supplier_invoice_no.' setelah pembayaran awal PO '.$po->po_no,'ref_type'=>'supplier_invoice','ref_id'=>$id,'created_by'=>$user),array(array('account_id'=>$this->CI->accountmappingservice->get('inventory'),'debit'=>$remaining,'kredit'=>0),array('account_id'=>$this->CI->accountmappingservice->get('accounts_payable'),'debit'=>0,'kredit'=>$remaining)),false,true);}$payments=$this->CI->db->where(array('purchase_order_id'=>$po->id,'payment_kind'=>'prepayment','status'=>'posted'))->get('supplier_payments')->result();$allocated=0;foreach($payments as$p){$value=min($p->amount,$bill->total-$allocated);if($value<=0)break;if(!$this->CI->db->get_where('supplier_payment_allocations',array('supplier_payment_id'=>$p->id,'supplier_invoice_id'=>$id))->row())$this->CI->db->insert('supplier_payment_allocations',array('supplier_payment_id'=>$p->id,'supplier_invoice_id'=>$id,'amount'=>$value,'created_at'=>date('Y-m-d H:i:s')));$allocated+=$value;}$this->CI->db->where('id',$id)->update('supplier_invoices',array('status'=>$remaining<=.001?'paid':'partial','paid_amount'=>$bill->total-$remaining,'balance'=>$remaining,'journal_id'=>$journal,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s')));return$id;});} private function purchaseDebitGroups($invoiceId) { $group=array(); foreach($this->CI->db->select('sil.qty,sil.unit_price,pol.destination_type,pol.asset_account_id')->from('supplier_invoice_lines sil')->join('purchase_order_lines pol','pol.id=sil.po_line_id')->where('sil.supplier_invoice_id',$invoiceId)->get()->result()as$l){ $account=$l->destination_type==='asset'?(int)$l->asset_account_id:$this->CI->accountmappingservice->get('inventory'); if(!$account)throw new BusinessException('Akun tujuan pembelian belum dikonfigurasi.'); $group[$account]=($group[$account]??0)+(float)$l->qty*(float)$l->unit_price; } $bill=$this->CI->db->get_where('supplier_invoices',array('id'=>(int)$invoiceId))->row(); if($bill&&$group){ $expectedDebit=round((float)$bill->total-(float)$bill->tax_total,2); $adjustment=round($expectedDebit-array_sum($group),2); if(abs($adjustment)>.001){$first=array_key_first($group);$group[$first]+=$adjustment;} } return$group; } public function syncReceiptLedger($receiptId){$lines=$this->CI->db->select('grl.*,gr.receipt_date,pol.unit_price,i.harga_beli landed_unit_cost')->from('goods_receipt_lines grl')->join('goods_receipts gr','gr.id=grl.goods_receipt_id')->join('purchase_order_lines pol','pol.id=grl.po_line_id')->join('items i','i.id=grl.item_id','left')->where('grl.goods_receipt_id',$receiptId)->where('grl.item_id IS NOT NULL',null,false)->get()->result();$items=array();foreach($lines as$l){$this->writePurchaseLedger('goods_receipt',$receiptId,$l->id,$l->item_id,$l->warehouse_id,'in',$l->qty,($l->landed_unit_cost?:$l->unit_price),$l->receipt_date);$items[(int)$l->item_id]=true;}foreach(array_keys($items)as$itemId)$this->CI->purchaseInventoryModel->synchronizeStock($itemId);return$receiptId;} public function clearTransitForReceipt($receiptId,$user){return$this->CI->transactionservice->run(function()use($receiptId,$user){$receipt=$this->CI->db->select('gr.*,po.po_no,po.total,po.subtotal,po.paid_amount,po.transit_cleared_amount')->from('goods_receipts gr')->join('purchase_orders po','po.id=gr.purchase_order_id')->where('gr.id',(int)$receiptId)->get()->row();if(!$receipt||$receipt->paid_amount<=0)return null;$base=(float)$this->CI->db->select('COALESCE(SUM(grl.qty*pol.unit_price),0) total',false)->from('goods_receipt_lines grl')->join('purchase_order_lines pol','pol.id=grl.po_line_id')->where('grl.goods_receipt_id',$receiptId)->get()->row()->total;$share=$receipt->subtotal>0?$base/$receipt->subtotal:0;$value=round(min($receipt->paid_amount-$receipt->transit_cleared_amount,$receipt->total*$share),2);if($value<=0)return null;$this->CI->fiscalperiodservice->assertOpen($receipt->receipt_date);$journal=$this->CI->postingservice->post(array('tanggal'=>$receipt->receipt_date,'no_ref'=>$receipt->receipt_no,'keterangan'=>'Penerimaan barang '.$receipt->receipt_no.' dari PO '.$receipt->po_no.'; pemindahan nilai Barang Dalam Perjalanan ke Persediaan','ref_type'=>'goods_receipt_transit','ref_id'=>$receiptId,'created_by'=>$user),array(array('account_id'=>$this->CI->accountmappingservice->get('inventory'),'debit'=>$value,'kredit'=>0),array('account_id'=>$this->CI->accountmappingservice->get('goods_in_transit'),'debit'=>0,'kredit'=>$value)),false,true);$this->CI->db->where('goods_receipt_id',$receiptId)->update('goods_receipt_lines',array('advance_journal_id'=>$journal));$this->CI->db->where('id',$receipt->purchase_order_id)->set('transit_cleared_amount','transit_cleared_amount+'.$this->CI->db->escape($value),false)->update('purchase_orders');return$journal;});} private function writePurchaseLedger($type,$documentId,$lineId,$itemId,$warehouseId,$direction,$qty,$cost,$date){$key='PURCHASE-'.strtoupper($type).'-'.$lineId;if($this->CI->db->get_where('inventory_ledger',array('idempotency_key'=>$key))->row())return;$this->CI->db->insert('inventory_ledger',array('item_id'=>$itemId,'warehouse_id'=>$warehouseId,'movement_date'=>$date,'direction'=>$direction,'qty'=>$qty,'unit_cost'=>$cost,'value'=>$qty*$cost,'document_type'=>$type,'document_id'=>$documentId,'document_line_id'=>$lineId,'idempotency_key'=>$key,'created_at'=>date('Y-m-d H:i:s')));$ledgerId=$this->CI->db->insert_id();if($direction==='in')$this->CI->db->insert('inventory_cost_layers',array('item_id'=>$itemId,'warehouse_id'=>$warehouseId,'source_ledger_id'=>$ledgerId,'received_date'=>$date,'original_qty'=>$qty,'remaining_qty'=>$qty,'unit_cost'=>$cost,'status'=>'open'));} public function purchaseReturnTracked($receiptLineId,$barcodeId,$date,$qty,$reason,$user){return$this->CI->transactionservice->run(function()use($receiptLineId,$barcodeId,$date,$qty,$reason,$user){$line=$this->CI->db->select('grl.*,pol.tracking_type,pol.unit_price')->from('goods_receipt_lines grl')->join('purchase_order_lines pol','pol.id=grl.po_line_id')->where('grl.id',(int)$receiptLineId)->get()->row();if(!$line||!$line->item_id)throw new BusinessException('Detail penerimaan tidak ditemukan.');$barcode=$this->CI->db->query('SELECT * FROM item_barcodes WHERE id=? FOR UPDATE',array((int)$barcodeId))->row();if(!$barcode||(int)$barcode->item_id!==(int)$line->item_id||(int)$barcode->source_receipt_line_id!==(int)$line->id)throw new BusinessException('Barcode/serial tidak berasal dari penerimaan yang dipilih.');$qty=(float)$qty;if($line->tracking_type==='UNIT'&&abs($qty-1)>.0001)throw new BusinessException('Retur barang UNIT dilakukan satu barcode/serial per transaksi.');if($qty<=0||$qty>(float)$barcode->qty_sisa+.0001)throw new BusinessException('Qty retur melebihi saldo barcode.');$id=$this->purchaseReturn($receiptLineId,$date,$qty,$reason,$user);$remaining=max(0,(float)$barcode->qty_sisa-$qty);$this->CI->db->where('id',$barcode->id)->update('item_barcodes',array('qty_sisa'=>$remaining,'status'=>$remaining<=.0001?'sold_out':'available'));$this->CI->db->where('purchase_return_id',$id)->where('receipt_line_id',$receiptLineId)->update('purchase_return_lines',array('barcode_id'=>$barcode->id));$returnLine=$this->CI->db->where('purchase_return_id',$id)->where('receipt_line_id',$receiptLineId)->get('purchase_return_lines')->row();$this->CI->db->where('ref_type','purchase_return')->where('ref_id',$id)->update('stock_logs',array('barcode_id'=>$barcode->id));$this->writePurchaseLedger('purchase_return',$id,$returnLine->id,$line->item_id,$line->warehouse_id,'out',$qty,$line->unit_price,$date);return$id;});} public function createRequest(array$d,$user){return$this->CI->transactionservice->run(function()use($d,$user){$lines=isset($d['lines'])&&is_array($d['lines'])?$d['lines']:array($d);if(trim($d['purpose'])===''||!$lines)throw new BusinessException('Tujuan dan minimal satu barang Purchase Request wajib diisi.');$self=!empty($d['applicant_is_creator']);$applicant=trim((string)($d['applicant_name']??''));if(!$self&&$applicant==='')throw new BusinessException('Nama pihak yang mengajukan wajib diisi.');if(mb_strlen($applicant)>150)throw new BusinessException('Nama pihak yang mengajukan maksimal 150 karakter.');$destination=in_array($d['destination_type'],array('warehouse','asset'),true)?$d['destination_type']:'warehouse';$no=$this->CI->numberingservice->next('purchase_request',$d['date']);$this->CI->db->insert('purchase_requests',array('request_no'=>$no,'request_date'=>$d['date'],'needed_date'=>$d['needed_date']?:null,'department'=>$d['department'],'purpose'=>$d['purpose'],'destination_type'=>$destination,'status'=>'draft','requested_by'=>$user,'applicant_is_creator'=>$self?1:0,'applicant_name'=>$self?null:$applicant,'created_at'=>date('Y-m-d H:i:s')));$id=$this->CI->db->insert_id();foreach($lines as$line){$this->CI->db->insert('purchase_request_lines',array('purchase_request_id'=>$id,'item_id'=>$line['item_id']?:null,'kode_barang_id'=>$line['kode_barang_id']??null,'description'=>$line['description'],'qty'=>$line['qty'],'estimated_price'=>$line['estimated_price'],'tracking_type'=>$line['tracking_type']??null,'qty_per_barcode'=>$line['qty_per_barcode']??null,'need_serial_number'=>$line['need_serial_number']??0));}$this->CI->auditservice->record('purchase','purchase_request',$id,'create',null,array('request_no'=>$no,'destination_type'=>$destination,'line_count'=>count($lines),'applicant_is_creator'=>$self,'applicant_name'=>$self?null:$applicant),$user);return$id;});} public function submitRequest($id,$user){return$this->CI->transactionservice->run(function()use($id,$user){$r=$this->lock('purchase_requests',$id,'draft');if((int)$r->requested_by!==(int)$user)throw new BusinessException('Hanya pembuat Purchase Request yang dapat mengajukan dokumen ini.',403);$lines=$this->CI->db->where('purchase_request_id',$id)->get('purchase_request_lines')->result();$amount=0;foreach($lines as$l)$amount+=(float)$l->qty*(float)$l->estimated_price;$this->CI->approvalservice->create('purchase','purchase_request',$id,$amount,$user,array('request'=>$r,'lines'=>$lines));$this->CI->db->where('id',$id)->update('purchase_requests',array('status'=>'submitted'));return$id;});} public function approveRequest($id,$user,$role){return$this->approveEntity('purchase_requests','purchase_request',$id,$user,$role);} public function deleteRequest($id,$user){return$this->CI->transactionservice->run(function()use($id,$user){$r=$this->CI->db->query('SELECT * FROM purchase_requests WHERE id=? FOR UPDATE',array((int)$id))->row();if(!$r)throw new BusinessException('Purchase Request tidak ditemukan.',404);if($r->status!=='draft')throw new BusinessException('Hanya Purchase Request berstatus draft yang dapat dihapus.');if((int)$r->requested_by!==(int)$user)throw new BusinessException('Hanya pembuat Purchase Request yang dapat menghapus draft ini.',403);$this->CI->auditservice->record('purchase','purchase_request',$r->id,'delete',array('request_no'=>$r->request_no,'status'=>$r->status),null,$user);$this->CI->db->where('purchase_request_id',$r->id)->delete('purchase_request_lines');$this->CI->db->where('id',$r->id)->delete('purchase_requests');return$r->id;});} public function createOrder($requestId,$supplierId,$date,$expected,$itemId,$warehouseId,$description,$qty,$price,$taxRate,$user,array$options=array()){return$this->CI->transactionservice->run(function()use($requestId,$supplierId,$date,$expected,$itemId,$warehouseId,$description,$qty,$price,$taxRate,$user,$options){$r=$this->lock('purchase_requests',$requestId,'approved');$destination=$r->destination_type?:'warehouse';if(!empty($options['destination_type'])&&$options['destination_type']!==$destination)throw new BusinessException('Tujuan PO harus sama dengan Purchase Request.');if($destination==='warehouse'&&(!$itemId||!$warehouseId))throw new BusinessException('Barang pending dan gudang wajib dipilih.');if($destination==='asset'&&(empty($options['asset_account_id'])||empty($options['asset_location_id'])||(int)$options['useful_life_months']<=0))throw new BusinessException('Akun, lokasi, dan masa manfaat aset wajib diisi.');$supplier=$this->CI->db->get_where('suppliers',array('id'=>$supplierId,'is_active'=>1))->row();if(!$supplier)throw new BusinessException('Supplier tidak aktif atau tidak ditemukan.');$base=$qty*$price;$tax=$base*$taxRate/100;$no=$this->CI->numberingservice->next('purchase_order',$date);$this->CI->db->insert('purchase_orders',array('po_no'=>$no,'supplier_id'=>$supplierId,'purchase_request_id'=>$requestId,'order_date'=>$date,'expected_date'=>$expected?:null,'status'=>'draft','subtotal'=>$base,'tax_total'=>$tax,'total'=>$base+$tax,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s')));$id=$this->CI->db->insert_id();$requestLine=$this->CI->db->where('purchase_request_id',$requestId)->order_by('id')->get('purchase_request_lines')->row();$this->CI->db->insert('purchase_order_lines',array('purchase_order_id'=>$id,'request_line_id'=>$requestLine?$requestLine->id:null,'item_id'=>$itemId?:($requestLine?$requestLine->item_id:null),'warehouse_id'=>$destination==='warehouse'?$warehouseId:null,'description'=>$description,'qty'=>$qty,'unit_price'=>$price,'tax_rate'=>$taxRate,'tax_amount'=>$tax,'line_total'=>$base+$tax,'destination_type'=>$destination,'asset_account_id'=>$destination==='asset'?$options['asset_account_id']:null,'asset_location_id'=>$destination==='asset'?$options['asset_location_id']:null,'useful_life_months'=>$destination==='asset'?$options['useful_life_months']:null,'residual_value'=>$destination==='asset'?$options['residual_value']:0));$this->CI->db->where('id',$requestId)->update('purchase_requests',array('status'=>'converted'));return$id;});} public function submitOrder($id,$user){return$this->CI->transactionservice->run(function()use($id,$user){$po=$this->lock('purchase_orders',$id,'draft');$lines=$this->CI->db->where('purchase_order_id',$id)->get('purchase_order_lines')->result();if(!$lines)throw new BusinessException('PO tidak memiliki barang.');$this->CI->approvalservice->create('purchase','purchase_order',$id,$po->total,$user,array('order'=>$po,'lines'=>$lines));$this->CI->db->where('id',$id)->update('purchase_orders',array('status'=>'submitted'));return$id;});} public function approveOrder($id,$user,$role){return$this->approveEntity('purchase_orders','purchase_order',$id,$user,$role);} public function rejectRequest($id,$user,$role,$reason){return$this->rejectEntity('purchase_requests','purchase_request',$id,$user,$role,$reason);} public function rejectOrder($id,$user,$role,$reason){return$this->rejectEntity('purchase_orders','purchase_order',$id,$user,$role,$reason);} public function updateDraftOrder($id,array$d,$user){return$this->CI->transactionservice->run(function()use($id,$d,$user){$po=$this->lock('purchase_orders',$id,'draft');$supplier=$this->CI->db->get_where('suppliers',array('id'=>(int)$d['supplier_id'],'is_active'=>1))->row();if(!$supplier)throw new BusinessException('Supplier tidak aktif atau tidak ditemukan.');$warehouse=(int)$d['warehouse_id'];if(!$this->CI->db->get_where('warehouses',array('id'=>$warehouse))->row())throw new BusinessException('Gudang tujuan tidak valid.');$method=in_array($d['procurement_method'],array('online','direct','supplier'),true)?$d['procurement_method']:'supplier';$timing=in_array($d['payment_timing'],array('prepaid','on_receipt','installment','credit'),true)?$d['payment_timing']:'credit';if($method==='online'&&(trim($d['marketplace'])===''||trim($d['external_order_no'])===''))throw new BusinessException('Marketplace dan nomor pesanan wajib untuk PO online.');$subtotal=0;$taxTotal=0;$lines=$this->CI->db->where('purchase_order_id',$id)->get('purchase_order_lines')->result();foreach($lines as$l){$price=round((float)($d['unit_prices'][$l->id]??$l->unit_price),2);$rate=round((float)($d['tax_rates'][$l->id]??$l->tax_rate),4);if($price<0||$rate<0)throw new BusinessException('Harga atau pajak tidak valid.');$base=$l->qty*$price;$tax=$base*$rate/100;$subtotal+=$base;$taxTotal+=$tax;$this->CI->db->where('id',$l->id)->update('purchase_order_lines',array('warehouse_id'=>$warehouse,'unit_price'=>$price,'tax_rate'=>$rate,'tax_amount'=>$tax,'line_total'=>$base+$tax));}foreach(array('shipping_cost','insurance_cost','service_fee','discount_amount','cashback_amount')as$key)$d[$key]=max(0,round((float)$d[$key],2));$total=$subtotal+$taxTotal+$d['shipping_cost']+$d['insurance_cost']+$d['service_fee']-$d['discount_amount']-$d['cashback_amount'];if($total<0)throw new BusinessException('Total PO tidak boleh negatif.');$downPaymentRequired=$timing==='installment'&&!empty($d['down_payment_required']);$downPaymentAmount=$downPaymentRequired?round((float)$d['down_payment_amount'],2):0;if($downPaymentRequired&&($downPaymentAmount<=0||$downPaymentAmount>=$total))throw new BusinessException('Nilai DP harus lebih dari nol dan lebih kecil dari total PO.');$this->CI->db->where('id',$id)->update('purchase_orders',array('supplier_id'=>$supplier->id,'order_date'=>$d['date'],'expected_date'=>$d['expected_date']?:null,'procurement_method'=>$method,'payment_timing'=>$timing,'payment_plan'=>$timing==='installment'?'installment':($timing==='credit'?'credit':'cash'),'down_payment_required'=>$downPaymentRequired?1:0,'down_payment_amount'=>$downPaymentAmount,'marketplace'=>$method==='online'?$d['marketplace']:null,'external_order_no'=>$d['external_order_no']?:null,'courier'=>$d['courier']?:null,'tracking_no'=>$d['tracking_no']?:null,'subtotal'=>$subtotal,'tax_total'=>$taxTotal,'total'=>$total,'shipping_cost'=>$d['shipping_cost'],'insurance_cost'=>$d['insurance_cost'],'service_fee'=>$d['service_fee'],'discount_amount'=>$d['discount_amount'],'cashback_amount'=>$d['cashback_amount'],'notes'=>$d['notes']));if($this->CI->db->table_exists('purchase_installment_schedules')){$this->CI->db->where('purchase_order_id',$id)->delete('purchase_installment_schedules');if($timing==='installment')$this->createInstallmentSchedule($id,$total-$downPaymentAmount,(int)$d['installment_count'],$d['first_due_date']);}$this->CI->auditservice->record('purchase','purchase_order',$id,'update',array('total'=>$po->total),array('total'=>$total,'method'=>$method,'payment_timing'=>$timing),$user);return$id;});} public function receive($poId,$date,$deliveryNo,array$quantities,$user){return$this->CI->transactionservice->run(function()use($poId,$date,$deliveryNo,$quantities,$user){$po=$this->CI->db->query('SELECT * FROM purchase_orders WHERE id=? FOR UPDATE',array($poId))->row();if(!$po||!in_array($po->status,array('approved','partially_received'),true))throw new BusinessException('PO belum disetujui atau sudah selesai.');$no=$this->CI->numberingservice->next('goods_receipt',$date);$this->CI->db->insert('goods_receipts',array('receipt_no'=>$no,'purchase_order_id'=>$poId,'supplier_id'=>$po->supplier_id,'receipt_date'=>$date,'supplier_delivery_no'=>$deliveryNo,'status'=>'posted','received_by'=>$user,'posted_at'=>date('Y-m-d H:i:s'),'created_at'=>date('Y-m-d H:i:s')));$rid=$this->CI->db->insert_id();$receivedAny=false;$poLines=$this->CI->db->select('pol.*,COALESCE(k.allocate_purchase_cost,0) allocate_purchase_cost',false)->from('purchase_order_lines pol')->join('kode_barang k','k.id=pol.kode_barang_id','left')->where('pol.purchase_order_id',$poId)->get()->result();$allocatableQty=0;foreach($poLines as$costLine)if((int)$costLine->allocate_purchase_cost===1)$allocatableQty+=(float)$costLine->qty;$netAdditional=(float)$po->shipping_cost+(float)$po->insurance_cost+(float)$po->service_fee-(float)$po->discount_amount-(float)$po->cashback_amount;$additionalPerUnit=$allocatableQty>0?$netAdditional/$allocatableQty:0;foreach($poLines as$l){$landedUnitCost=round(max(0,(float)$l->unit_price+((int)$l->allocate_purchase_cost===1?$additionalPerUnit:0)),2);if(!$l->item_id||!$this->CI->db->get_where('items',array('id'=>(int)$l->item_id))->row()){$catalog=$this->CI->db->get_where('kode_barang',array('id'=>(int)$l->kode_barang_id))->row();if(!$catalog)throw new BusinessException('Master Barang PO untuk '.$l->description.' tidak ditemukan.');$this->CI->db->insert('items',array('company_id'=>$this->CI->companycontext->id(),'stok'=>0,'planned_qty'=>$l->qty,'planned_warehouse_id'=>$l->warehouse_id,'planned_tracking_qty'=>$l->qty_per_barcode,'kode_detail'=>$catalog->kode_barang.'-GR-'.date('ymdHis').'-'.$l->id,'kode_id'=>$catalog->id,'nama_barang'=>$l->description,'harga_beli'=>$landedUnitCost,'harga_jual'=>0,'tanggal_pembelian'=>$date,'status'=>'draft'));$l->item_id=$this->CI->db->insert_id();$this->CI->db->where('id',$l->id)->update('purchase_order_lines',array('item_id'=>$l->item_id));}$qty=isset($quantities[$l->id])?(float)$quantities[$l->id]:0;$remaining=(float)$l->qty-(float)$l->received_qty;if($qty<=0)continue;if($qty>$remaining+0.0001)throw new BusinessException('Penerimaan melebihi sisa PO untuk '.$l->description);$stockLog=null;$assetId=null;if($l->destination_type==='asset'){$cost=$qty*(float)$l->unit_price;$life=max(1,(int)$l->useful_life_months);$residual=min((float)$l->residual_value,$cost);$this->CI->db->insert('assets',array('kode_asset'=>'AST-PO-'.$po->id.'-'.$l->id,'nama_asset'=>$l->description,'harga_per_unit'=>$l->unit_price,'nilai_perolehan'=>$cost,'tanggal_perolehan'=>$date,'capitalization_date'=>$date,'masa_manfaat'=>$life,'nilai_residu'=>$residual,'penyusutan_per_bulan'=>($cost-$residual)/$life,'nilai_buku'=>$cost,'sumber'=>'pembelian','source_type'=>'purchase','source_id'=>$l->id,'account_debit_id'=>$l->asset_account_id,'asset_account_id'=>$l->asset_account_id,'lokasi_asset_id'=>$l->asset_location_id,'keterangan'=>'Penerimaan aset dari '.$po->po_no,'qty'=>$qty,'status'=>'aktif','lifecycle_status'=>'active','qr_code'=>'ASSET:PO:'.$po->id.':'.$l->id));$assetId=$this->CI->db->insert_id();}else{$this->CI->db->insert('stock_logs',array('item_id'=>$l->item_id,'warehouse_id'=>$l->warehouse_id,'qty'=>$qty,'tipe'=>'masuk','unit_cost'=>$landedUnitCost,'keterangan'=>'[AUTO] Penerimaan '.$no.' dari PO '.$po->po_no,'ref_type'=>'goods_receipt','ref_id'=>$rid));$stockLog=$this->CI->db->insert_id();$this->CI->db->where('id',$l->item_id)->update('items',array('status'=>'draft','tanggal_pembelian'=>$date,'harga_beli'=>$landedUnitCost));}$this->CI->db->insert('goods_receipt_lines',array('goods_receipt_id'=>$rid,'po_line_id'=>$l->id,'item_id'=>$l->item_id,'warehouse_id'=>$l->warehouse_id,'qty'=>$qty,'stock_log_id'=>$stockLog,'asset_id'=>$assetId));$this->CI->db->where('id',$l->id)->set('received_qty','received_qty+'.$this->CI->db->escape($qty),false)->update('purchase_order_lines');$receivedAny=true;}if(!$receivedAny)throw new BusinessException('Minimal satu qty penerimaan harus diisi.');$remaining=$this->CI->db->where('purchase_order_id',$poId)->where('received_qty < qty',null,false)->count_all_results('purchase_order_lines');$this->CI->db->where('id',$poId)->update('purchase_orders',array('status'=>$remaining?'partially_received':'received'));$this->CI->auditservice->record('purchase','goods_receipt',$rid,'post',null,array('receipt_no'=>$no,'po_no'=>$po->po_no),$user);return$rid;});} public function createSupplierInvoice($poId,$receiptId,$supplierNo,$date,$user) { return $this->CI->transactionservice->run(function()use($poId,$receiptId,$supplierNo,$date,$user){ $po=$this->CI->db->query('SELECT * FROM purchase_orders WHERE id=? FOR UPDATE',array((int)$poId))->row(); $receipt=$this->CI->db->get_where('goods_receipts',array('id'=>(int)$receiptId,'purchase_order_id'=>(int)$poId,'status'=>'posted'))->row(); if(!$po||!$receipt||!in_array($po->status,array('partially_received','received'),true))throw new BusinessException('Invoice supplier hanya dapat dibuat dari penerimaan barang yang sudah diposting.'); $supplier=$this->CI->db->get_where('suppliers',array('id'=>$po->supplier_id))->row(); $supplierNo=trim((string)$supplierNo); if($supplierNo==='')throw new BusinessException('Nomor invoice/nota supplier wajib diisi.'); $this->CI->db->where('supplier_id',(int)$po->supplier_id)->where('supplier_invoice_no',$supplierNo); if($this->CI->db->field_exists('company_id','supplier_invoices'))$this->CI->db->where('company_id',(int)($po->company_id?:$this->CI->companycontext->id())); $duplicate=$this->CI->db->get('supplier_invoices')->row(); if($duplicate)throw new BusinessException('Nomor invoice supplier '.$supplierNo.' sudah tercatat dengan nomor internal '.$duplicate->internal_no.'. Buka detail invoice tersebut; jangan membuat invoice kedua.'); $receivedLines=$this->CI->db ->select("grl.*,pol.unit_price,pol.tax_rate,pol.qty ordered_qty,pol.received_qty total_received_qty,COALESCE((SELECT SUM(sil.qty) FROM supplier_invoice_lines sil WHERE sil.receipt_line_id=grl.id),0) receipt_invoiced_qty,COALESCE((SELECT SUM(prl.shipped_qty) FROM purchase_return_lines prl JOIN purchase_returns pr ON pr.id=prl.purchase_return_id WHERE prl.receipt_line_id=grl.id AND pr.status IN('shipped','waiting_supplier','partially_resolved','resolved')),0) receipt_returned_qty",false) ->from('goods_receipt_lines grl') ->join('goods_receipts gr','gr.id=grl.goods_receipt_id') ->join('purchase_order_lines pol','pol.id=grl.po_line_id') ->where('gr.purchase_order_id',(int)$poId) ->where('gr.status','posted') ->order_by('gr.receipt_date')->order_by('gr.id')->order_by('grl.id') ->get()->result(); $availableLines=array(); foreach($receivedLines as$l){$available=round((float)$l->qty-(float)$l->receipt_returned_qty-(float)$l->receipt_invoiced_qty,4);if($available>0)$availableLines[]=array($l,$available);} if(!$availableLines)throw new BusinessException('Tidak ada akumulasi penerimaan pada PO ini yang belum ditagihkan.'); // Retur yang resmi dikirim sebelum invoice pertama pada baris penerimaan // mengurangi nilai yang boleh ditagihkan. Retur setelah invoice ditangani // oleh Debit Note/Refund dan tidak boleh mengubah histori invoice tersebut. $preInvoiceReturn=$this->CI->db ->select('COALESCE(SUM(prl.shipped_qty*pol.unit_price*(1+(pol.tax_rate/100))),0) total',false) ->from('purchase_return_lines prl') ->join('purchase_returns pr','pr.id=prl.purchase_return_id') ->join('goods_receipt_lines grl0','grl0.id=prl.receipt_line_id') ->join('purchase_order_lines pol','pol.id=grl0.po_line_id') ->where('pol.purchase_order_id',(int)$poId) ->where_in('pr.status',array('shipped','waiting_supplier','partially_resolved','resolved')) ->where("NOT EXISTS (SELECT 1 FROM supplier_invoice_lines sil0 JOIN supplier_invoices si0 ON si0.id=sil0.supplier_invoice_id WHERE sil0.receipt_line_id=prl.receipt_line_id AND si0.created_at<=pr.shipped_at)",null,false) ->get()->row(); $preInvoiceReturnDeduction=round((float)($preInvoiceReturn?$preInvoiceReturn->total:0),2); $internal=$this->CI->numberingservice->next('supplier_invoice',$date); $due=date('Y-m-d',strtotime($date.' +'.(int)$supplier->payment_term_days.' days')); $invoice=array('supplier_invoice_no'=>$supplierNo,'internal_no'=>$internal,'supplier_id'=>$po->supplier_id,'invoice_date'=>$date,'due_date'=>$due,'status'=>'verified','created_by'=>$user,'created_at'=>date('Y-m-d H:i:s')); if($this->CI->db->field_exists('company_id','supplier_invoices'))$invoice['company_id']=(int)($po->company_id?:$this->CI->companycontext->id()); $this->CI->db->insert('supplier_invoices',$invoice); $id=$this->CI->db->insert_id();$subtotal=0;$tax=0; foreach($availableLines as$row){list($l,$qty)=$row;$base=$qty*(float)$l->unit_price;$lineTax=$base*(float)$l->tax_rate/100;$this->CI->db->insert('supplier_invoice_lines',array('supplier_invoice_id'=>$id,'po_line_id'=>$l->po_line_id,'receipt_line_id'=>$l->id,'item_id'=>$l->item_id,'qty'=>$qty,'unit_price'=>$l->unit_price,'tax_amount'=>$lineTax,'line_total'=>$base+$lineTax,'match_status'=>'matched'));$this->CI->db->where('id',$l->po_line_id)->set('invoiced_qty','invoiced_qty+'.$this->CI->db->escape($qty),false)->update('purchase_order_lines');$subtotal+=$base;$tax+=$lineTax;} // Invoice Supplier mewakili seluruh kewajiban PO, bukan hanya subtotal barang. // Dengan demikian ongkir, asuransi, layanan, diskon, dan cashback ikut ke saldo hutang. $total=max(0,round((float)$po->total-$preInvoiceReturnDeduction,2)); if($total+0.001<$subtotal+$tax)throw new BusinessException('Nilai invoice neto lebih kecil dari detail barang. Periksa retur, biaya tambahan, diskon, dan pajak PO.'); $this->CI->db->where('id',$id)->update('supplier_invoices',array('subtotal'=>$subtotal,'tax_total'=>$tax,'total'=>$total,'balance'=>$total)); $this->CI->auditservice->record('purchase','supplier_invoice',$id,'create_from_accumulated_receipts',null,array('po_no'=>$po->po_no,'anchor_receipt'=>$receipt->receipt_no,'receipt_line_count'=>count($availableLines),'pre_invoice_return_deduction'=>$preInvoiceReturnDeduction,'total'=>$total),$user); return$id; }); } public function verifySupplierInvoice($id,$user){return$this->CI->transactionservice->run(function()use($id,$user){$bill=$this->CI->db->query('SELECT * FROM supplier_invoices WHERE id=? FOR UPDATE',array((int)$id))->row();if(!$bill||$bill->status!=='verified')throw new BusinessException('Invoice Supplier tidak berada pada tahap finalisasi.');$unmatched=$this->CI->db->where('supplier_invoice_id',$id)->where('match_status !=','matched')->count_all_results('supplier_invoice_lines');if($unmatched)throw new BusinessException('Matching PO, penerimaan, dan invoice belum sesuai.');if(!$this->CI->db->where(array('entity_type'=>'supplier_invoice','entity_id'=>$id))->count_all_results('transaction_attachments'))throw new BusinessException('Lampiran invoice/nota supplier wajib tersedia.');$this->CI->auditservice->record('purchase','supplier_invoice',$id,'verify_matching',array('status'=>$bill->status),array('status'=>'verified'),$user);return$id;});} public function submitSupplierInvoice($id,$user){return$this->verifySupplierInvoice($id,$user);} public function assertPaymentEvidence($poId){$payments=$this->CI->db->where(array('purchase_order_id'=>(int)$poId,'payment_kind'=>'prepayment','status'=>'posted'))->get('supplier_payments')->result();foreach($payments as$p)if(!$this->CI->db->where(array('entity_type'=>'supplier_payment','entity_id'=>$p->id))->count_all_results('transaction_attachments'))throw new BusinessException('Bukti pembayaran '.$p->payment_no.' wajib diunggah sebelum penerimaan barang.');return true;} public function approveSupplierInvoice($id,$user,$role){return$this->approveEntity('supplier_invoices','supplier_invoice',$id,$user,$role);} public function postSupplierInvoice($id,$user){return$this->CI->transactionservice->run(function()use($id,$user){$bill=$this->lock('supplier_invoices',$id,'verified');$this->CI->fiscalperiodservice->assertOpen($bill->invoice_date);$group=array();foreach($this->CI->db->select('sil.qty,sil.unit_price,pol.destination_type,pol.asset_account_id')->from('supplier_invoice_lines sil')->join('purchase_order_lines pol','pol.id=sil.po_line_id')->where('sil.supplier_invoice_id',$id)->get()->result()as$l){$account=$l->destination_type==='asset'?(int)$l->asset_account_id:$this->CI->accountmappingservice->get('inventory');if(!$account)throw new BusinessException('Akun tujuan pembelian belum dikonfigurasi.');if(!isset($group[$account]))$group[$account]=0;$group[$account]+=(float)$l->qty*(float)$l->unit_price;}$expectedDebit=round((float)$bill->total-(float)$bill->tax_total,2);$costAdjustment=round($expectedDebit-array_sum($group),2);if(abs($costAdjustment)>.001&&$group){$firstAccount=array_key_first($group);$group[$firstAccount]+=$costAdjustment;}$entries=array();foreach($group as$account=>$amount)$entries[]=array('account_id'=>$account,'debit'=>$amount,'kredit'=>0);if($bill->tax_total>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('purchase_input_tax'),'debit'=>$bill->tax_total,'kredit'=>0);$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('accounts_payable'),'debit'=>0,'kredit'=>$bill->total);$journal=$this->CI->postingservice->post(array('tanggal'=>$bill->invoice_date,'no_ref'=>$bill->internal_no,'keterangan'=>'Tagihan supplier '.$bill->supplier_invoice_no.'; matching PO/penerimaan; tujuan '.(count($group)>1?'campuran':(isset($l)&&$l->destination_type==='asset'?'aset tetap':'persediaan')),'ref_type'=>'supplier_invoice','ref_id'=>$id,'created_by'=>$user),$entries,false,true);$this->CI->db->where('id',$id)->update('supplier_invoices',array('status'=>'partial','journal_id'=>$journal,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s')));$this->CI->load->library('TaxService');$this->CI->taxservice->supplierInvoice($id,$journal);return$id;});} public function paymentMulti($supplierId,$date,array$sources,array$allocations,$allowAdvance,$user,$reference){return$this->CI->transactionservice->run(function()use($supplierId,$date,$sources,$allocations,$allowAdvance,$user,$reference){$supplier=$this->CI->db->get_where('suppliers',array('id'=>(int)$supplierId))->row();if(!$supplier)throw new BusinessException('Supplier tidak ditemukan.');$valid=array();$amount=0;foreach($sources as$account=>$value){$value=round((float)$value,2);if($value<=0)continue;$a=$this->CI->db->where(array('id'=>(int)$account,'is_active'=>1,'allow_posting'=>1))->group_start()->like('nama_akun','kas')->or_like('nama_akun','bank')->group_end()->get('accounts')->row();if(!$a)throw new BusinessException('Sumber Kas/Bank tidak valid.');$valid[(int)$account]=($valid[(int)$account]??0)+$value;$amount+=$value;}if(!$valid||$amount<=0)throw new BusinessException('Minimal satu sumber Kas/Bank dan nominal wajib diisi.');$allocated=0;$bills=array();foreach($allocations as$id=>$value){$value=round((float)$value,2);if($value<=0)continue;$b=$this->CI->db->query('SELECT * FROM supplier_invoices WHERE id=? FOR UPDATE',array((int)$id))->row();if(!$b||(int)$b->supplier_id!==(int)$supplierId||$b->status!=='partial'||$value>$b->balance+.001)throw new BusinessException('Alokasi tagihan supplier tidak valid atau melebihi saldo.');$allocated+=$value;$bills[]=array($b,$value);}if(!$bills&&!$allowAdvance)throw new BusinessException('Pilih minimal satu tagihan yang akan dibayar.');if($allocated>$amount+.001)throw new BusinessException('Total alokasi tagihan melebihi total sumber pembayaran.');$advance=round($amount-$allocated,2);if($advance>0&&!$allowAdvance)throw new BusinessException('Total sumber dana harus sama dengan alokasi tagihan, atau centang uang muka supplier.');$this->CI->fiscalperiodservice->assertOpen($date);$no=$this->CI->numberingservice->next('supplier_payment',$date);$docs=array_map(function($r){return$r[0]->internal_no.' Rp '.number_format($r[1],2,',','.');},$bills);$desc='[AUTO] Pembayaran supplier '.$supplier->supplier_code.' - '.$supplier->name.'; tagihan '.($docs?implode(', ',$docs):'uang muka').'; referensi '.($reference?:'-');$entries=array();if($allocated>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('accounts_payable'),'debit'=>$allocated,'kredit'=>0,'keterangan'=>$desc);if($advance>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('supplier_advances'),'debit'=>$advance,'kredit'=>0,'keterangan'=>$desc);foreach($valid as$account=>$value)$entries[]=array('account_id'=>$account,'debit'=>0,'kredit'=>$value,'keterangan'=>$desc);$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$no,'keterangan'=>$desc,'ref_type'=>'supplier_payment','created_by'=>$user),$entries,false,true);$payment=array('payment_no'=>$no,'supplier_id'=>$supplierId,'payment_date'=>$date,'amount'=>$amount,'bank_account_id'=>array_key_first($valid),'status'=>'posted','reference_no'=>$reference,'journal_id'=>$journal,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s'));if($this->CI->db->field_exists('company_id','supplier_payments'))$payment['company_id']=$this->CI->companycontext->id();$this->CI->db->insert('supplier_payments',$payment);$pid=$this->CI->db->insert_id();foreach($valid as$account=>$value)$this->CI->db->insert('purchase_payment_sources',array('supplier_payment_id'=>$pid,'account_id'=>$account,'amount'=>$value,'created_at'=>date('Y-m-d H:i:s')));foreach($bills as$row){list($b,$value)=$row;$this->CI->db->insert('supplier_payment_allocations',array('supplier_payment_id'=>$pid,'supplier_invoice_id'=>$b->id,'amount'=>$value,'created_at'=>date('Y-m-d H:i:s')));$paid=$b->paid_amount+$value;$balance=max(0,$b->total-$paid);$this->CI->db->where('id',$b->id)->update('supplier_invoices',array('paid_amount'=>$paid,'balance'=>$balance,'status'=>$balance<=.001?'paid':'partial'));$poLine=$this->CI->db->select('pol.purchase_order_id')->from('supplier_invoice_lines sil')->join('purchase_order_lines pol','pol.id=sil.po_line_id')->where('sil.supplier_invoice_id',$b->id)->limit(1)->get()->row();if($poLine){$scheduleAllocation=$value;foreach($this->CI->db->where('purchase_order_id',$poLine->purchase_order_id)->where_in('status',array('unpaid','partial'))->order_by('due_date')->order_by('installment_no')->get('purchase_installment_schedules')->result()as$schedule){if($scheduleAllocation<=.001)break;$applied=min($scheduleAllocation,max(0,$schedule->amount-$schedule->paid_amount));if($applied<=0)continue;$schedulePaid=$schedule->paid_amount+$applied;$this->CI->db->where('id',$schedule->id)->update('purchase_installment_schedules',array('paid_amount'=>$schedulePaid,'status'=>$schedulePaid>=$schedule->amount-.001?'paid':'partial'));$scheduleAllocation-=$applied;}}}if($advance>0)$this->CI->db->insert('supplier_advances',array('advance_no'=>'SADV-'.$no,'supplier_id'=>$supplierId,'supplier_payment_id'=>$pid,'original_amount'=>$advance,'available_amount'=>$advance,'status'=>'available','journal_id'=>$journal,'created_at'=>date('Y-m-d H:i:s')));return array('payment_id'=>$pid,'payment_no'=>$no,'amount'=>$amount,'allocated'=>$allocated,'advance'=>$advance);});} public function payment($supplierId,$date,$bank,$amount,array$allocations,$allowAdvance,$user,$reference){return$this->paymentMulti($supplierId,$date,array((int)$bank=>(float)$amount),$allocations,$allowAdvance,$user,$reference);} public function purchaseReturn($receiptLineId,$date,$qty,$reason,$user){return$this->CI->transactionservice->run(function()use($receiptLineId,$date,$qty,$reason,$user){$line=$this->CI->db->query('SELECT grl.*,gr.supplier_id,gr.id receipt_id,pol.unit_price FROM goods_receipt_lines grl JOIN goods_receipts gr ON gr.id=grl.goods_receipt_id JOIN purchase_order_lines pol ON pol.id=grl.po_line_id WHERE grl.id=? FOR UPDATE',array((int)$receiptLineId))->row();$qty=(float)$qty;if(!$line||$qty<=0||$qty>(float)$line->qty)throw new BusinessException('Qty retur tidak valid.');if(trim($reason)==='')throw new BusinessException('Alasan retur wajib diisi.');$already=(float)$this->CI->db->select('COALESCE(SUM(prl.qty),0) total',false)->from('purchase_return_lines prl')->join('purchase_returns pr','pr.id=prl.purchase_return_id')->where('prl.receipt_line_id',$line->id)->where('pr.status','posted')->get()->row()->total;if($already+$qty>(float)$line->qty)throw new BusinessException('Total retur melebihi penerimaan.');$no=$this->CI->numberingservice->next('purchase_return',$date);$amount=$qty*$line->unit_price;$this->CI->db->insert('purchase_returns',array('return_no'=>$no,'supplier_id'=>$line->supplier_id,'goods_receipt_id'=>$line->receipt_id,'return_date'=>$date,'amount'=>$amount,'reason'=>$reason,'status'=>'posted','created_by'=>$user,'created_at'=>date('Y-m-d H:i:s')));$id=$this->CI->db->insert_id();$this->CI->db->insert('stock_logs',array('item_id'=>$line->item_id,'warehouse_id'=>$line->warehouse_id,'qty'=>$qty,'tipe'=>'keluar','keterangan'=>'[AUTO] Retur pembelian '.$no.' | '.$reason,'ref_type'=>'purchase_return','ref_id'=>$id));$log=$this->CI->db->insert_id();$this->CI->db->insert('purchase_return_lines',array('purchase_return_id'=>$id,'receipt_line_id'=>$line->id,'item_id'=>$line->item_id,'warehouse_id'=>$line->warehouse_id,'qty'=>$qty,'unit_cost'=>$line->unit_price,'stock_log_id'=>$log));$this->CI->purchaseInventoryModel->synchronizeStock($line->item_id);$this->CI->auditservice->record('purchase','purchase_return',$id,'post',null,array('return_no'=>$no,'qty'=>$qty,'amount'=>$amount),$user);return$id;});} public function debitNote($billId,$date,$amount,$reason,$user){return$this->CI->transactionservice->run(function()use($billId,$date,$amount,$reason,$user){$bill=$this->CI->db->query('SELECT * FROM supplier_invoices WHERE id=? FOR UPDATE',array((int)$billId))->row();$amount=round((float)$amount,2);if(!$bill||$bill->status!=='partial'||$amount<=0||$amount>$bill->balance)throw new BusinessException('Nilai debit note tidak valid.');if(trim($reason)==='')throw new BusinessException('Alasan debit note wajib diisi.');$this->CI->fiscalperiodservice->assertOpen($date);$no=$this->CI->numberingservice->next('supplier_debit_note',$date);$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$no,'keterangan'=>'Debit note tagihan '.$bill->supplier_invoice_no.': '.$reason,'ref_type'=>'supplier_debit_note','ref_id'=>$billId,'created_by'=>$user),array(array('account_id'=>$this->CI->accountmappingservice->get('accounts_payable'),'debit'=>$amount,'kredit'=>0),array('account_id'=>$this->CI->accountmappingservice->get('inventory'),'debit'=>0,'kredit'=>$amount)),false,true);$this->CI->db->insert('supplier_debit_notes',array('debit_note_no'=>$no,'supplier_id'=>$bill->supplier_id,'supplier_invoice_id'=>$billId,'note_date'=>$date,'amount'=>$amount,'reason'=>$reason,'status'=>'posted','journal_id'=>$journal,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s')));$id=$this->CI->db->insert_id();$balance=$bill->balance-$amount;$this->CI->db->where('id',$billId)->update('supplier_invoices',array('balance'=>$balance,'status'=>$balance<=0?'paid':'partial'));return$id;});} public function aging($date=null){$date=$date?:date('Y-m-d');$sql="SELECT s.id supplier_id,s.name,SUM(CASE WHEN i.due_date>=? THEN i.balance ELSE 0 END) current_amount,SUM(CASE WHEN DATEDIFF(?,i.due_date) BETWEEN 1 AND 30 THEN i.balance ELSE 0 END) days_1_30,SUM(CASE WHEN DATEDIFF(?,i.due_date) BETWEEN 31 AND 60 THEN i.balance ELSE 0 END) days_31_60,SUM(CASE WHEN DATEDIFF(?,i.due_date) BETWEEN 61 AND 90 THEN i.balance ELSE 0 END) days_61_90,SUM(CASE WHEN DATEDIFF(?,i.due_date)>90 THEN i.balance ELSE 0 END) over_90,SUM(i.balance) total FROM supplier_invoices i JOIN suppliers s ON s.id=i.supplier_id WHERE i.status='partial' AND i.balance>0";$params=array($date,$date,$date,$date,$date);if($this->CI->db->field_exists('company_id','supplier_invoices')){$sql.=' AND i.company_id=?';$params[]=$this->CI->companycontext->id();}$sql.=' GROUP BY s.id,s.name';return$this->CI->db->query($sql,$params)->result();} private function approveEntity($table,$type,$id,$user,$role){return$this->CI->transactionservice->run(function()use($table,$type,$id,$user,$role){$row=$this->lock($table,$id,'submitted');$request=$this->CI->approvalservice->pendingForEntity($type,$id);if(!$request)throw new BusinessException('Permintaan approval tidak ditemukan.');$a=$this->CI->approvalservice->act($request->id,'approved',$user,$role);if($a->status==='approved')$this->CI->db->where('id',$id)->update($table,array('status'=>'approved','approved_by'=>$user,'approved_at'=>date('Y-m-d H:i:s')));return$id;});} private function rejectEntity($table,$type,$id,$user,$role,$reason){return$this->CI->transactionservice->run(function()use($table,$type,$id,$user,$role,$reason){$this->lock($table,$id,'submitted');$request=$this->CI->approvalservice->pendingForEntity($type,$id);if(!$request)throw new BusinessException('Permintaan approval tidak ditemukan.');$this->CI->approvalservice->act($request->id,'rejected',$user,$role,$reason);$this->CI->db->where('id',$id)->update($table,array('status'=>'rejected'));return$id;});} private function lock($table,$id,$status){$row=$this->CI->db->query('SELECT * FROM '.$table.' WHERE id=? FOR UPDATE',array((int)$id))->row();if(!$row)throw new BusinessException('Dokumen tidak ditemukan.');if($row->status!==$status)throw new BusinessException('Status dokumen harus '.$status.'.');return$row;} }