CI=&get_instance(); $this->CI->load->library(array('CompanyContext','TransactionService','NumberingService','PostingService','AccountMappingService','FiscalPeriodService','AuditService','ApprovalService')); $this->CI->load->model('InventoryModel','salesInventoryModel'); } private function company(){return(int)$this->CI->companycontext->id();} public function money($v) { $v=trim(str_ireplace(array('Rp',' '),'',(string)$v));if($v==='')return 0.0; $comma=strrpos($v,',');$dot=strrpos($v,'.'); if($comma!==false&&$dot!==false)$v=$comma>$dot?str_replace(',','.',str_replace('.','',$v)):str_replace(',','',$v); elseif($comma!==false)$v=str_replace(',','.',str_replace('.','',$v)); elseif($dot!==false&&preg_match('/^\d{1,3}(\.\d{3})+(\.\d+)?$/',$v))$v=str_replace('.','',$v); if(!is_numeric($v))throw new BusinessException('Nominal tidak valid.');return round((float)$v,2); } private function qty($v){$v=(float)str_replace(',','.',(string)$v);if($v<=0)throw new BusinessException('Qty harus lebih dari nol.');return round($v,4);} private function tenant($table,$id,$label) { $row=$this->CI->db->get_where($table,array('id'=>(int)$id,'company_id'=>$this->company()))->row(); if(!$row)throw new BusinessException($label.' tidak ditemukan pada perusahaan aktif.');return$row; } private function addCompany($table,array$data){if($this->CI->db->field_exists('company_id',$table))$data['company_id']=$this->company();return$data;} public function dashboard() { $c=$this->company();$today=date('Y-m-d');$month=date('Y-m-01'); return array( 'month_sales'=>(float)$this->CI->db->select('COALESCE(SUM(total),0) total',false)->where('company_id',$c)->where('workflow_status','posted')->where('tanggal >=',$month)->get('invoices')->row()->total, 'open_invoice'=>(int)$this->CI->db->where('company_id',$c)->where_in('status',array('unpaid','partial'))->count_all_results('invoices'), 'overdue'=>(int)$this->CI->db->where('company_id',$c)->where_in('status',array('unpaid','partial'))->where('jatuh_tempo <',$today)->where('sisa_piutang >',0)->count_all_results('invoices'), 'draft'=>(int)$this->CI->db->where('company_id',$c)->where('deleted_at', null)->where_in('workflow_status',array('draft','submitted'))->count_all_results('invoices'), 'delivery'=>(int)$this->CI->db->where('company_id',$c)->where('status','draft')->count_all_results('sales_deliveries'), 'return'=>(int)$this->CI->db->where('company_id',$c)->where_in('status',array('draft','submitted','approved','received'))->count_all_results('sales_returns') ); } public function invoices($limit=100) { return$this->CI->db->select('i.*,c.nama customer_name,c.alamat customer_address,u.nama creator_name') ->from('invoices i')->join('customers c','c.id=i.customer_id')->join('users u','u.id=i.created_by','left') ->where('i.company_id',$this->company())->where('i.deleted_at IS NULL',null,false)->order_by('i.id','DESC')->limit((int)$limit)->get()->result(); } public function deliveries($limit=100) { return$this->CI->db->select('d.*,c.nama customer_name,i.no_invoice,w.nama warehouse_name,u.nama creator_name') ->from('sales_deliveries d')->join('customers c','c.id=d.customer_id')->join('invoices i','i.id=d.invoice_id','left')->join('warehouses w','w.id=d.warehouse_id','left')->join('users u','u.id=d.created_by','left') ->where('d.company_id',$this->company())->order_by('d.id','DESC')->limit((int)$limit)->get()->result(); } public function returns($limit=100) { return$this->CI->db->select('r.*,c.nama customer_name,i.no_invoice,d.delivery_no,u.nama creator_name') ->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('users u','u.id=r.created_by','left') ->where('r.company_id',$this->company())->order_by('r.id','DESC')->limit((int)$limit)->get()->result(); } public function createInvoiceHeader(array$header,$user) { return $this->CI->transactionservice->run(function()use($header,$user){return $this->insertInvoiceHeader($header,$user);}); } private function insertInvoiceHeader(array$header,$user) { $customer=$this->tenant('customers',$header['customer_id']??0,'Customer'); if(isset($customer->is_active)&&(int)$customer->is_active!==1)throw new BusinessException('Customer sudah tidak aktif.'); $date=$header['date']??date('Y-m-d');$due=$header['due_date']??$date; $start=!empty($header['period_start'])?$header['period_start']:$date;$end=!empty($header['period_end'])?$header['period_end']:$date; foreach(array($date,$due,$start,$end)as$value)if(!preg_match('/^\d{4}-\d{2}-\d{2}$/',(string)$value))throw new BusinessException('Tanggal atau periode invoice tidak valid.'); if($due<$date)throw new BusinessException('Jatuh tempo tidak boleh sebelum tanggal invoice.'); if($end<$start)throw new BusinessException('Periode akhir tidak boleh sebelum periode awal.'); $type=in_array($header['invoice_type']??'',array('one_time','running'),true)?$header['invoice_type']:'one_time'; $policy=in_array($header['recognition_policy']??'',array('accrual','on_payment'),true)?$header['recognition_policy']:'accrual'; $key=trim((string)($header['idempotency_key']??'')); if($key!==''){$existing=$this->CI->db->get_where('invoices',array('company_id'=>$this->company(),'idempotency_key'=>$key))->row();if($existing)return(int)$existing->id;} $sourceOrder=null;if(!empty($header['source_sales_order_id'])){$sourceOrder=$this->CI->db->get_where('sales_documents',array('id'=>(int)$header['source_sales_order_id'],'company_id'=>$this->company(),'customer_id'=>$customer->id,'document_type'=>'sales_order','status'=>'accepted'))->row();if(!$sourceOrder)throw new BusinessException('Sales Order sumber tidak valid atau belum diterima.');} $data=array('company_id'=>$this->company(),'no_invoice'=>$this->CI->numberingservice->next('invoice',$date),'customer_id'=>$customer->id,'tanggal'=>$date,'jatuh_tempo'=>$due,'total'=>0,'total_bayar'=>0,'sisa_piutang'=>0,'status'=>'draft','workflow_status'=>'draft','posted'=>0,'keterangan'=>trim((string)($header['notes']??'')),'invoice_type'=>$type,'period_start'=>$start,'period_end'=>$end,'recognition_policy'=>$policy,'source_sales_order_id'=>$sourceOrder?$sourceOrder->id:null,'delivery_status'=>'not_delivered','created_by'=>(int)$user,'created_at'=>date('Y-m-d H:i:s'),'idempotency_key'=>$key!==''?$key:null,'version'=>1); $this->CI->db->insert('invoices',$this->columns('invoices',$data));$id=(int)$this->CI->db->insert_id(); $this->CI->auditservice->record('sales','invoice',$id,'create_header',null,array('invoice_type'=>$type,'recognition_policy'=>$policy,'period_start'=>$start,'period_end'=>$end),$user);return$id; } public function createInvoice(array$header,array$rows,$user) { return$this->CI->transactionservice->run(function()use($header,$rows,$user){$id=$this->insertInvoiceHeader($header,$user);if($rows)$this->appendInvoiceLinesInternal($id,$rows,$user);return$id;}); } public function appendInvoiceLines($invoiceId,array$rows,$user) { return$this->CI->transactionservice->run(function()use($invoiceId,$rows,$user){$this->appendInvoiceLinesInternal($invoiceId,$rows,$user);return$invoiceId;}); } public function appendInvoiceLine($invoiceId,array$row,$user) { return$this->CI->transactionservice->run(function()use($invoiceId,$row,$user){$ids=$this->appendInvoiceLinesInternal($invoiceId,array($row),$user);return reset($ids);}); } public function appendInvoiceLinesBatch($invoiceId,array$rows,$batchDiscount,$user) { return $this->CI->transactionservice->run(function()use($invoiceId,$rows,$batchDiscount,$user){ if(!$rows)throw new BusinessException('Tambahkan minimal satu barang, jasa, atau tabungan.'); if(count($rows)>100)throw new BusinessException('Maksimal 100 item dalam satu kali penambahan.'); $grossValues=array();$totalGross=0.0; foreach($rows as $index=>&$row){ $qty=$this->qty($row['qty']??0); $priceValue=$row['unit_price']??''; if(($priceValue===''||$priceValue===null)&&(($row['line_type']??'inventory')==='inventory')){ $item=$this->tenant('items',$row['item_id']??0,'Barang baris '.($index+1)); $priceValue=$item->harga_jual; } $price=$this->money($priceValue); if($price<0)throw new BusinessException('Harga item ke-'.($index+1).' tidak boleh negatif.'); $row['qty']=$qty;$row['unit_price']=$price;$row['discount_amount']=0;$row['specification']=''; $grossValues[$index]=round($qty*$price,2);if(($row['line_type']??'inventory')!=='savings')$totalGross+=$grossValues[$index]; } unset($row); $discount=$this->money($batchDiscount); if($discount<0)throw new BusinessException('Diskon penambahan item tidak boleh negatif.'); if($discount>$totalGross+.001)throw new BusinessException('Diskon tidak boleh melebihi total bruto item yang ditambahkan.'); $positiveIndexes=array();foreach($grossValues as$index=>$gross)if($gross>0&&($rows[$index]['line_type']??'inventory')!=='savings')$positiveIndexes[]=$index; $lastPositive=$positiveIndexes?end($positiveIndexes):null;$allocated=0.0; foreach($rows as $index=>&$row){ if(($row['line_type']??'inventory')==='savings'||$discount<=0||$grossValues[$index]<=0){$lineDiscount=0.0;} elseif($index===$lastPositive){$lineDiscount=round($discount-$allocated,2);} else{$lineDiscount=round($discount*$grossValues[$index]/$totalGross,2);} $lineDiscount=max(0,min($grossValues[$index],$lineDiscount)); $row['discount_amount']=$lineDiscount;$allocated+=$lineDiscount; } unset($row); $ids=$this->appendInvoiceLinesInternal($invoiceId,$rows,$user); $this->CI->auditservice->record('sales','invoice',(int)$invoiceId,'append_line_batch',null,array('line_count'=>count($rows),'batch_discount'=>$discount,'line_ids'=>$ids),$user); return array('line_ids'=>$ids,'line_count'=>count($ids),'batch_discount'=>$discount); }); } public function deleteDraftInvoice($id,$user,$privileged=false) { return$this->CI->transactionservice->run(function()use($id,$user,$privileged){ $i=$this->lockInvoice($id,'draft');if(!$privileged&&(int)$i->created_by!==(int)$user)throw new BusinessException('Hanya pembuat Draft atau pengguna berizin hapus yang boleh membatalkan invoice.'); $postedDelivery=(int)$this->CI->db->where(array('invoice_id'=>$i->id,'company_id'=>$this->company()))->where_in('status',array('posted','partially_returned','returned'))->count_all_results('sales_deliveries');if($postedDelivery)throw new BusinessException('Draft tidak dapat dibatalkan karena Surat Jalan sudah diposting. Gunakan alur retur/koreksi agar stok dan jurnal tetap sinkron.'); foreach($this->CI->db->where(array('invoice_id'=>$i->id,'company_id'=>$this->company()))->get('invoice_details')->result()as$line){$this->reverseInvoiceLineInitialJournal($line,$user,'Draft invoice '.$i->no_invoice.' dibatalkan');$this->releaseInvoiceLineBarcodes($line->id);} $this->CI->db->where(array('invoice_id'=>$i->id,'company_id'=>$this->company(),'status'=>'draft'))->update('sales_deliveries',array('status'=>'cancelled')); $this->CI->db->where(array('id'=>$i->id,'company_id'=>$this->company()))->update('invoices',array('deleted_at'=>date('Y-m-d H:i:s'),'status'=>'cancelled','updated_by'=>$user,'updated_at'=>date('Y-m-d H:i:s'))); $this->CI->auditservice->record('sales','invoice',$i->id,'cancel_draft',$i,array('status'=>'cancelled'),$user);return$i->id; }); } private function appendInvoiceLinesInternal($invoiceId,array$rows,$user) { $invoice=$this->CI->db->query('SELECT * FROM invoices WHERE id=? AND company_id=? FOR UPDATE',array((int)$invoiceId,$this->company()))->row(); if(!$invoice||$invoice->workflow_status!=='draft')throw new BusinessException('Item hanya dapat ditambahkan pada invoice Draft/Berjalan.');$this->assertDraftOwner($invoice,$user); if(!$rows)throw new BusinessException('Tambahkan minimal satu barang, jasa, atau tabungan.');$inserted=array(); $customer=$this->tenant('customers',$invoice->customer_id,'Customer'); $existingLines=$this->CI->db->select('id,line_type,subtotal,total_hpp_barang')->where(array('invoice_id'=>$invoice->id,'company_id'=>$this->company()))->get('invoice_details')->result();$lineJournalMode=true;foreach($existingLines as$existingLine)if($existingLine->line_type!=='savings'&&((float)$existingLine->subtotal>0||(float)$existingLine->total_hpp_barang>0)){$existingJournal=$this->initialLineJournal($existingLine->id);if(!$existingJournal||$existingJournal->is_reversed||$existingJournal->status!=='posted'){$lineJournalMode=false;break;}} $existingTypes=$this->CI->db->select('DISTINCT line_type',false)->where(array('invoice_id'=>$invoice->id,'company_id'=>$this->company()))->get('invoice_details')->result_array(); foreach($existingTypes as$t)if($t['line_type']==='loan')throw new BusinessException('Invoice Pinjaman lama tidak dapat ditambah dengan jenis transaksi baru. Buat invoice baru untuk Barang, Jasa, atau Tabungan.'); foreach($rows as$n=>$row){ $requestedType=$row['line_type']??'inventory';if($requestedType==='loan')throw new BusinessException('Piutang/Pinjaman sudah tidak tersedia pada Invoice. Gunakan jenis Tabungan sesuai alur terbaru.'); $lineType=in_array($requestedType,array('inventory','service','savings'),true)?$requestedType:'inventory';$item=null;$warehouse=null;$kb=null;$key=trim((string)($row['idempotency_key']??'')); if($key!==''){$existing=$this->CI->db->get_where('invoice_details',array('company_id'=>$this->company(),'idempotency_key'=>$key))->row();if($existing){if((int)$existing->invoice_id!==(int)$invoice->id)throw new BusinessException('Kunci permintaan item sudah digunakan dokumen lain.');$inserted[]=(int)$existing->id;continue;}} $accountLabel=$lineType==='loan'?'Akun piutang':($lineType==='savings'?'Akun simpanan customer':'Akun pendapatan'); $account=$this->tenant('accounts',$row['account_id']??0,$accountLabel);$counterAccount=null; $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($accountLabel.' tidak aktif atau tidak dapat diposting.'); if(in_array($lineType,array('inventory','service'),true)&&(string)$account->tipe!=='revenue')throw new BusinessException('Barang dan jasa harus dialokasikan ke akun pendapatan.'); if($lineType==='loan'){ if((string)$account->tipe!=='asset')throw new BusinessException('Akun piutang pinjaman harus merupakan akun Aktiva.'); $counterAccount=$this->tenant('accounts',$row['counter_account_id']??0,'Akun sumber dana pinjaman'); if((string)$counterAccount->tipe!=='asset'||(isset($counterAccount->is_active)&&(int)$counterAccount->is_active!==1)||(isset($counterAccount->allow_posting)&&(int)$counterAccount->allow_posting!==1))throw new BusinessException('Akun sumber dana pinjaman harus merupakan akun Aktiva aktif yang dapat diposting.'); if((int)$counterAccount->id===(int)$account->id)throw new BusinessException('Akun piutang dan akun sumber dana pinjaman harus berbeda.'); } if($lineType==='savings'&&(string)$account->tipe!=='liability')throw new BusinessException('Akun tabungan customer harus merupakan akun Passiva/Kewajiban.'); if($lineType==='inventory'){$item=$this->tenant('items',$row['item_id']??0,'Barang baris '.($n+1));$warehouse=$this->tenant('warehouses',$row['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();} $q=$lineType==='savings'?1:$this->qty($row['qty']??0);$price=$this->money($row['unit_price']??($item?$item->harga_jual:0));$discount=$lineType==='savings'?0:max(0,$this->money($row['discount_amount']??0));$tax=$lineType==='savings'?0:max(0,(float)str_replace(',','.',(string)($row['tax_rate']??0))); if($discount>$q*$price)throw new BusinessException('Diskon tidak boleh melebihi nilai bruto baris.'); $base=max(0,$q*$price-$discount);$taxAmount=round($base*$tax/100,2);$total=$base+$taxAmount;$service=$row['service_date']??$invoice->tanggal; if(!preg_match('/^\d{4}-\d{2}-\d{2}$/',(string)$service))throw new BusinessException('Tanggal transaksi item tidak valid.'); $barcodes=(array)($row['barcodes']??array());$barcodeQty=0;$cleanBarcodes=array(); if($lineType==='inventory'){ $seenBarcodes=array(); foreach($barcodes as$b){ $bid=(int)($b['id']??$b['barcode_id']??0); if(!$bid||isset($seenBarcodes[$bid]))throw new BusinessException('Barcode tidak boleh dipilih dua kali pada satu baris.'); $seenBarcodes[$bid]=true; if($this->CI->db->where(array('invoice_id'=>$invoice->id,'barcode_id'=>$bid))->count_all_results('invoice_line_barcodes'))throw new BusinessException('Barcode sudah dipakai pada item lain di invoice ini.'); $bq=$this->qty($b['qty']??1);$barcode=$this->CI->db->query("SELECT * FROM item_barcodes WHERE id=? AND item_id=? AND warehouse_id=? AND status='available' FOR UPDATE",array($bid,$item->id,$warehouse->id))->row();if(!$barcode)throw new BusinessException('Barcode barang '.$item->nama_barang.' tidak tersedia di gudang terpilih.'); 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.' sudah digunakan pada invoice lain.');} $available=(float)$barcode->qty_sisa-(float)$barcode->reserved_qty-(float)($barcode->return_reserved_qty??0);if($bq>$available+.0001)throw new BusinessException('Qty barcode '.$barcode->barcode.' melebihi saldo tersedia.');$reservedAfter=(float)$barcode->reserved_qty+$bq;$reserveUpdate=array('reserved_qty'=>$reservedAfter,'version'=>(int)$barcode->version+1);if($kb&&strtoupper((string)$kb->tracking_type)==='UNIT')$reserveUpdate['status']='reserved';$this->CI->db->where(array('id'=>$barcode->id,'version'=>$barcode->version))->update('item_barcodes',$reserveUpdate);if($this->CI->db->affected_rows()!==1)throw new BusinessException('Barcode sedang dipilih pengguna lain. Silakan ulangi.');$barcode->reserved_qty=$reservedAfter;$barcode->version=(int)$barcode->version+1;$barcodeQty+=$bq;$cleanBarcodes[]=array('row'=>$barcode,'qty'=>$bq); } if(abs($barcodeQty-$q)>.0001)throw new BusinessException('Total qty barcode harus sama dengan qty barang '.$item->nama_barang.'.');if($kb&&$kb->tracking_type==='UNIT')foreach($cleanBarcodes as$b)if(abs($b['qty']-1)>.0001)throw new BusinessException('Barcode UNIT harus memiliki qty 1.'); } elseif(trim((string)($row['description']??''))==='')throw new BusinessException('Nama jasa atau rincian tagihan wajib diisi.'); $name=$lineType==='inventory'?$item->nama_barang:trim((string)$row['description']); $detail=array('company_id'=>$this->company(),'invoice_id'=>$invoice->id,'line_type'=>$lineType,'tanggal'=>$service,'service_date'=>$service,'account_id'=>$account->id,'counter_account_id'=>$counterAccount?$counterAccount->id:null,'items_id'=>$item?$item->id:null,'nama_item'=>$name,'specification'=>trim((string)($row['specification']??'')),'unit'=>$kb?$kb->unit:trim((string)($row['unit']??($lineType==='loan'?'Pinjaman':($lineType==='savings'?'Tabungan':'Jasa')))),'qty'=>$q,'harga'=>$price,'discount_amount'=>$discount,'tax_rate'=>$tax,'tax_amount'=>$taxAmount,'net_amount'=>$base,'subtotal'=>$total,'subtotal_asli'=>$total,'warehouse_id'=>$warehouse?$warehouse->id:null,'keterangan'=>trim((string)($row['notes']??'')),'total_hpp_barang'=>$item?round($q*(float)$item->harga_beli,2):0,'is_cicilan'=>0,'idempotency_key'=>$key!==''?$key:null); $this->CI->db->insert('invoice_details',$this->columns('invoice_details',$detail));$detailId=(int)$this->CI->db->insert_id();$inserted[]=$detailId; if($lineType==='inventory')foreach($cleanBarcodes as$b)$this->CI->db->insert('invoice_line_barcodes',array('company_id'=>$this->company(),'invoice_id'=>$invoice->id,'invoice_detail_id'=>$detailId,'sales_delivery_id'=>null,'sales_delivery_line_id'=>null,'barcode_id'=>$b['row']->id,'qty'=>$b['qty'],'created_at'=>date('Y-m-d H:i:s'))); $storedDetail=$this->CI->db->get_where('invoice_details',array('id'=>$detailId,'company_id'=>$this->company()))->row();if($lineJournalMode)$this->postInvoiceLineInitialJournal($invoice,$detailId,$storedDetail,$item,$cleanBarcodes,$customer,$user); } $this->refreshInvoiceTotals($invoice->id);$this->updateInvoiceDeliveryStatus($invoice->id);return$inserted; } /** * Jurnal awal dibuat per baris agar setiap perubahan Draft mempunyai jejak * sumber yang dapat dibalik tanpa menghapus histori jurnal. */ private function postInvoiceLineInitialJournal($invoice,$detailId,$line,$item,array$barcodes,$customer,$user) { if($line->line_type==='savings')return null; $entries=array(); if($line->line_type==='inventory'&&(float)$line->total_hpp_barang>0){ $entries[]=array('account_id'=>$this->CI->accountmappingservice->get('inventory_in_sales'),'debit'=>(float)$line->total_hpp_barang,'kredit'=>0); $entries[]=array('account_id'=>$this->CI->accountmappingservice->get('inventory'),'debit'=>0,'kredit'=>(float)$line->total_hpp_barang); } if((float)$line->subtotal>0){ $entries[]=array('account_id'=>$this->CI->accountmappingservice->get('accounts_receivable'),'debit'=>(float)$line->subtotal,'kredit'=>0); if((float)$line->net_amount>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('deferred_revenue'),'debit'=>0,'kredit'=>(float)$line->net_amount); if((float)$line->tax_amount>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('sales_tax_payable'),'debit'=>0,'kredit'=>(float)$line->tax_amount); } if(!$entries)return null; $qty=number_format((float)$line->qty,abs((float)$line->qty-round((float)$line->qty))<.0001?0:4,',','.'); if($line->line_type==='inventory'){ $codes=array();foreach($barcodes as$b)if(isset($b['row']->barcode))$codes[]=$b['row']->barcode; $unit=trim((string)$line->unit);$quantity=$qty.($unit!==''&&$unit!=='-'?' '.$unit:''); $description='Penjualan '.$item->nama_barang.' Sebanyak '.$quantity.' - Kode: '.($codes?implode(', ',$codes):'-').' - Kepada: '.$customer->nama; }else{ $description='Penjualan '.$line->nama_item.' Sebanyak '.$qty.' - Kepada: '.$customer->nama; if(trim((string)$line->keterangan)!=='')$description.=' - '.trim((string)$line->keterangan); } return$this->CI->postingservice->post(array('tanggal'=>$line->service_date,'no_ref'=>$this->CI->numberingservice->next('invoice_line_journal',$line->service_date),'keterangan'=>$description,'natural_description'=>true,'ref_type'=>'invoice_line','ref_id'=>(int)$detailId,'created_by'=>$user),$entries,false,false); } private function initialLineJournal($lineId,$lock=false) { $sql="SELECT * FROM journals WHERE company_id=? AND ref_type='invoice_line' AND ref_id=? ORDER BY id DESC LIMIT 1".($lock?' FOR UPDATE':''); return$this->CI->db->query($sql,array($this->company(),(int)$lineId))->row(); } private function reverseInvoiceLineInitialJournal($line,$user,$reason) { $journal=$this->initialLineJournal($line->id,true);if(!$journal||$journal->is_reversed||$journal->status==='reversed')return null; $entries=array();foreach($this->CI->db->where('journal_id',$journal->id)->order_by('id')->get('journal_details')->result()as$d)$entries[]=array('account_id'=>$d->account_id,'debit'=>(float)$d->kredit,'kredit'=>(float)$d->debit); if(!$entries)return null; $date=$line->service_date?:($line->tanggal?:date('Y-m-d')); $invoice=$this->CI->db->select('no_invoice')->get_where('invoices',array('id'=>$line->invoice_id,'company_id'=>$this->company()))->row(); $description='Pembatalan item pada Invoice '.($invoice?$invoice->no_invoice:'#'.$line->invoice_id).' karena '.$reason; $reversal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$this->CI->numberingservice->next('invoice_line_reversal',$date),'keterangan'=>$description,'natural_description'=>true,'ref_type'=>'invoice_line_reversal','ref_id'=>(int)$journal->id,'reversal_of'=>(int)$journal->id,'reversal_reason'=>$reason,'created_by'=>$user),$entries,false,true); $this->CI->db->where('id',$journal->id)->update('journals',array('status'=>'reversed','is_reversed'=>1,'updated_by'=>$user,'updated_at'=>date('Y-m-d H:i:s'),'reversal_reason'=>$reason)); return$reversal; } public function generateDelivery($invoiceId,$user) { return $this->CI->transactionservice->run(function()use($invoiceId,$user){ $invoice=$this->lockInvoice($invoiceId,'draft');$this->assertDraftOwner($invoice,$user); $lines=$this->CI->db->select('d.*,it.nama_barang,k.unit')->from('invoice_details d')->join('items it','it.id=d.items_id')->join('kode_barang k','k.id=it.kode_id','left')->where(array('d.invoice_id'=>$invoice->id,'d.company_id'=>$this->company(),'d.line_type'=>'inventory'))->where('d.delivery_line_id IS NULL',null,false)->order_by('d.service_date,d.warehouse_id,d.id')->get()->result(); if(!$lines)throw new BusinessException('Tidak ada barang invoice yang belum dibuatkan Surat Jalan.'); $groups=array(); foreach($lines as$l){ $warehouse=$this->tenant('warehouses',$l->warehouse_id,'Gudang');$key=$l->service_date.'|'.$warehouse->id; if(!isset($groups[$key]))$groups[$key]=array('date'=>$l->service_date,'warehouse'=>$warehouse,'lines'=>array()); $bars=$this->CI->db->select('ilb.*,ib.barcode,ib.serial_number')->from('invoice_line_barcodes ilb')->join('item_barcodes ib','ib.id=ilb.barcode_id')->where(array('ilb.invoice_detail_id'=>$l->id,'ilb.company_id'=>$this->company()))->where('ilb.sales_delivery_line_id IS NULL',null,false)->order_by('ilb.id')->get()->result(); $allocated=0;$clean=array();foreach($bars as$b){$allocated+=(float)$b->qty;$clean[]=array('allocation'=>$b,'row'=>(object)array('id'=>(int)$b->barcode_id),'qty'=>(float)$b->qty);} if(abs($allocated-(float)$l->qty)>.0001)throw new BusinessException('Alokasi barcode item '.$l->nama_barang.' tidak sama dengan qty invoice.'); $groups[$key]['lines'][]=array('detail_id'=>(int)$l->id,'item'=>(object)array('id'=>$l->items_id,'nama_barang'=>$l->nama_barang,'harga_beli'=>$l->total_hpp_barang/max(.0001,(float)$l->qty)),'kb'=>(object)array('unit'=>$l->unit),'qty'=>(float)$l->qty,'barcodes'=>$clean,'specification'=>$l->specification,'notes'=>$l->keterangan); } $deliveryIds=array();foreach($groups as$group)$deliveryIds[]=$this->createDeliveryForLines($invoice,$group,$user); $this->updateInvoiceDeliveryStatus($invoice->id);$this->CI->auditservice->record('sales','invoice',$invoice->id,'generate_delivery',null,array('delivery_ids'=>$deliveryIds),$user); return array('delivery_ids'=>$deliveryIds,'delivery_count'=>count($deliveryIds)); }); } private function createDeliveryForLines($invoice,array$group,$user) { $delivery=$this->CI->db->query("SELECT * FROM sales_deliveries WHERE company_id=? AND invoice_id=? AND delivery_date=? AND warehouse_id=? AND status='draft' ORDER BY id LIMIT 1 FOR UPDATE",array($this->company(),$invoice->id,$group['date'],$group['warehouse']->id))->row(); if(!$delivery){$this->CI->db->insert('sales_deliveries',array('company_id'=>$this->company(),'delivery_no'=>$this->CI->numberingservice->next('sales_delivery',$group['date']),'customer_id'=>$invoice->customer_id,'invoice_id'=>$invoice->id,'warehouse_id'=>$group['warehouse']->id,'delivery_date'=>$group['date'],'address'=>'','status'=>'draft','created_by'=>$user,'created_at'=>date('Y-m-d H:i:s')));$deliveryId=(int)$this->CI->db->insert_id();$lineNo=0;}else{$deliveryId=(int)$delivery->id;$max=$this->CI->db->select_max('line_no','max_line')->get_where('sales_delivery_lines',array('sales_delivery_id'=>$deliveryId))->row();$lineNo=(int)($max->max_line??0);} foreach($group['lines']as$line){$lineNo++;$this->CI->db->insert('sales_delivery_lines',array('company_id'=>$this->company(),'sales_delivery_id'=>$deliveryId,'line_no'=>$lineNo,'item_id'=>$line['item']->id,'invoice_detail_id'=>$line['detail_id'],'description'=>$line['item']->nama_barang,'specification'=>$line['specification'],'unit'=>$line['kb']?$line['kb']->unit:null,'qty'=>$line['qty'],'unit_cost'=>$line['item']->harga_beli,'total_cost'=>$line['qty']*$line['item']->harga_beli,'condition_notes'=>$line['notes'],'created_at'=>date('Y-m-d H:i:s')));$deliveryLine=(int)$this->CI->db->insert_id();$this->CI->db->where('id',$line['detail_id'])->update('invoice_details',array('delivery_line_id'=>$deliveryLine));foreach($line['barcodes']as$b){$this->CI->db->insert('sales_delivery_barcodes',array('company_id'=>$this->company(),'sales_delivery_id'=>$deliveryId,'sales_delivery_line_id'=>$deliveryLine,'item_id'=>$line['item']->id,'barcode_id'=>$b['row']->id,'qty'=>$b['qty'],'status'=>'allocated','created_at'=>date('Y-m-d H:i:s')));$this->CI->db->where(array('id'=>$b['allocation']->id,'invoice_detail_id'=>$line['detail_id']))->update('invoice_line_barcodes',array('sales_delivery_id'=>$deliveryId,'sales_delivery_line_id'=>$deliveryLine));}} return$deliveryId; } private function refreshInvoiceTotals($id) { $r=$this->CI->db->select('COALESCE(SUM(subtotal),0) total,COALESCE(SUM(net_amount),0) net,COALESCE(SUM(tax_amount),0) tax,COALESCE(SUM(discount_amount),0) discount,MIN(service_date) period_start,MAX(service_date) period_end',false)->get_where('invoice_details',array('invoice_id'=>(int)$id,'company_id'=>$this->company()))->row(); $invoice=$this->CI->db->select('tanggal')->get_where('invoices',array('id'=>(int)$id,'company_id'=>$this->company()))->row(); $start=$r->period_start?:$invoice->tanggal;$end=$r->period_end?:$invoice->tanggal; $this->CI->db->where(array('id'=>(int)$id,'company_id'=>$this->company()))->update('invoices',array('total'=>$r->total,'subtotal_before_tax'=>$r->net,'tax_total'=>$r->tax,'discount_total'=>$r->discount,'sisa_piutang'=>$r->total,'period_start'=>$start,'period_end'=>$end,'invoice_type'=>$start===$end?'one_time':'running')); } public function invoiceForEditor($id) { $invoice=$this->CI->db->select("i.*,c.nama customer_name,c.alamat customer_address,c.telp customer_phone,c.email customer_email,u.nama creator_name,ua.nama approver_name,(SELECT COUNT(*) FROM invoice_details dx WHERE dx.invoice_id=i.id AND dx.company_id=i.company_id) line_count,(SELECT COUNT(*) FROM invoice_details dx WHERE dx.invoice_id=i.id AND dx.company_id=i.company_id AND dx.line_type='inventory') inventory_line_count,(SELECT COUNT(*) FROM invoice_details dx WHERE dx.invoice_id=i.id AND dx.company_id=i.company_id AND dx.line_type='inventory' AND dx.delivery_line_id IS NULL) pending_delivery_count",false) ->from('invoices i')->join('customers c','c.id=i.customer_id')->join('users u','u.id=i.created_by','left')->join('users ua','ua.id=i.approved_by','left') ->where(array('i.id'=>(int)$id,'i.company_id'=>$this->company()))->where('i.deleted_at IS NULL',null,false)->get()->row(); if(!$invoice)throw new BusinessException('Invoice tidak ditemukan pada perusahaan aktif.');return$invoice; } public function invoiceLinesPage($invoiceId,$start=0,$length=25,$search='') { $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(); 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) { return$this->CI->transactionservice->run(function()use($id,$data,$user){$invoice=$this->lockInvoice($id,'draft');$this->assertDraftOwner($invoice,$user);$lineCount=(int)$this->CI->db->where(array('invoice_id'=>$invoice->id,'company_id'=>$this->company()))->count_all_results('invoice_details');if($lineCount)throw new BusinessException('Aturan invoice hanya dapat diubah sebelum item pertama ditambahkan.');$date=$data['date']??$invoice->tanggal;$due=$data['due_date']??$invoice->jatuh_tempo;$start=array_key_exists('period_start',$data)?$data['period_start']:$date;$end=array_key_exists('period_end',$data)?$data['period_end']:$date;if($due<$date||$end<$start)throw new BusinessException('Tanggal invoice atau jatuh tempo tidak valid.');$customer=$this->tenant('customers',$data['customer_id']??$invoice->customer_id,'Customer');$update=array('customer_id'=>$customer->id,'tanggal'=>$date,'jatuh_tempo'=>$due,'period_start'=>$start,'period_end'=>$end,'invoice_type'=>in_array($data['invoice_type']??'',array('one_time','running'),true)?$data['invoice_type']:'one_time','recognition_policy'=>in_array($data['recognition_policy']??'',array('accrual','on_payment'),true)?$data['recognition_policy']:$invoice->recognition_policy,'keterangan'=>trim((string)($data['notes']??$invoice->keterangan)),'updated_by'=>$user,'updated_at'=>date('Y-m-d H:i:s'),'version'=>(int)$invoice->version+1);$this->CI->db->where(array('id'=>$invoice->id,'company_id'=>$this->company()))->update('invoices',$update);$this->CI->auditservice->record('sales','invoice',$invoice->id,'update_header',$invoice,$update,$user);return$invoice->id;}); } public function removeInvoiceLine($invoiceId,$lineId,$user) { return$this->CI->transactionservice->run(function()use($invoiceId,$lineId,$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)throw new BusinessException('Baris invoice tidak ditemukan.'); $deliveryId=0;if($line->delivery_line_id){$deliveryLine=$this->CI->db->select('l.*,d.status delivery_status')->from('sales_delivery_lines l')->join('sales_deliveries d','d.id=l.sales_delivery_id')->where('l.id',$line->delivery_line_id)->get()->row();if(!$deliveryLine||$deliveryLine->delivery_status!=='draft'||$deliveryLine->stock_posted_at)throw new BusinessException('Item yang Surat Jalannya sudah diposting tidak dapat dihapus.');$deliveryId=(int)$deliveryLine->sales_delivery_id;} $this->reverseInvoiceLineInitialJournal($line,$user,'Item dihapus dari Draft invoice '.$invoice->no_invoice); $this->releaseInvoiceLineBarcodes($line->id); if($line->delivery_line_id){$this->CI->db->where('sales_delivery_line_id',$line->delivery_line_id)->delete('sales_delivery_barcodes');$this->CI->db->where('id',$line->delivery_line_id)->delete('sales_delivery_lines');if(!$this->CI->db->where('sales_delivery_id',$deliveryId)->count_all_results('sales_delivery_lines'))$this->CI->db->where('id',$deliveryId)->update('sales_deliveries',array('status'=>'cancelled'));} $this->CI->db->where('id',$line->id)->delete('invoice_details');$this->refreshInvoiceTotals($invoice->id);$this->updateInvoiceDeliveryStatus($invoice->id);$this->CI->auditservice->record('sales','invoice',$invoice->id,'remove_line',$line,null,$user);return$line->id; }); } private function releaseInvoiceLineBarcodes($lineId) { $bars=$this->CI->db->where(array('invoice_detail_id'=>(int)$lineId,'company_id'=>$this->company()))->get('invoice_line_barcodes')->result(); foreach($bars as$b){ $barcode=$this->CI->db->query('SELECT ib.* FROM item_barcodes ib JOIN items i ON i.id=ib.item_id WHERE ib.id=? AND i.company_id=? FOR UPDATE',array($b->barcode_id,$this->company()))->row(); if(!$barcode)throw new BusinessException('Barcode reservasi invoice tidak ditemukan. Pembatalan dihentikan agar stok dan jurnal tetap sinkron.'); $this->CI->db->where(array('id'=>$barcode->id,'version'=>$barcode->version))->update('item_barcodes',array('reserved_qty'=>max(0,(float)$barcode->reserved_qty-(float)$b->qty),'status'=>'available','version'=>(int)$barcode->version+1)); if($this->CI->db->affected_rows()!==1)throw new BusinessException('Barcode sedang berubah pada proses lain. Silakan ulangi pembatalan invoice.'); } $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) { 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.'); $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.'); 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']??'')))); $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; }); } 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;}); } public function postDelivery($id,$user) { return$this->CI->transactionservice->run(function()use($id,$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)throw new BusinessException('Surat Jalan tidak ditemukan.');if($d->status==='posted')return$d->id;if($d->status!=='draft')throw new BusinessException('Hanya Surat Jalan Draft yang dapat diposting.'); $this->CI->fiscalperiodservice->assertOpen($d->delivery_date);$lines=$this->CI->db->where(array('sales_delivery_id'=>$d->id,'company_id'=>$this->company()))->order_by('line_no')->get('sales_delivery_lines')->result();if(!$lines)throw new BusinessException('Surat Jalan belum mempunyai barang.'); $totalCost=0;$inventoryReclass=0; foreach($lines as$l){ if($l->stock_posted_at)continue;$bars=$this->CI->db->where('sales_delivery_line_id',$l->id)->get('sales_delivery_barcodes')->result();$allocated=0; foreach($bars as$b){$barcode=$this->CI->db->query('SELECT * FROM item_barcodes WHERE id=? AND item_id=? AND warehouse_id=? FOR UPDATE',array($b->barcode_id,$l->item_id,$d->warehouse_id))->row();if(!$barcode||!in_array($barcode->status,array('available','reserved'),true)||(float)$barcode->qty_sisa<(float)$b->qty-.0001||(float)$barcode->reserved_qty<(float)$b->qty-.0001)throw new BusinessException('Barcode pada Surat Jalan sudah tidak tersedia atau reservasinya tidak valid.');$left=(float)$barcode->qty_sisa-(float)$b->qty;$reserved=max(0,(float)$barcode->reserved_qty-(float)$b->qty);$this->CI->db->where(array('id'=>$barcode->id,'version'=>$barcode->version))->update('item_barcodes',array('qty_sisa'=>$left,'reserved_qty'=>$reserved,'status'=>$left<=.0001?'sold_out':($reserved>.0001?'reserved':'available'),'version'=>(int)$barcode->version+1));if($this->CI->db->affected_rows()!==1)throw new BusinessException('Barcode sedang diproses pengguna lain. Silakan ulangi.');$this->CI->db->where('id',$b->id)->update('sales_delivery_barcodes',array('status'=>'delivered'));$allocated+=(float)$b->qty;} if(abs($allocated-(float)$l->qty)>.0001)throw new BusinessException('Alokasi barcode Surat Jalan tidak sama dengan qty barang.'); $detailBefore=$this->CI->db->get_where('invoice_details',array('id'=>$l->invoice_detail_id,'company_id'=>$this->company()))->row();$initialJournal=$detailBefore?$this->initialLineJournal($detailBefore->id):null;$hadInitialJournal=$initialJournal&&!$initialJournal->is_reversed&&$initialJournal->status==='posted';$previousCost=$detailBefore?(float)$detailBefore->total_hpp_barang:0; $cost=$this->consumeCost($l->item_id,$d->warehouse_id,$l->qty);$totalCost+=$cost;$inventoryReclass+=$hadInitialJournal?($cost-$previousCost):$cost;$key='SALES-DELIVERY-LINE-'.$l->id;$desc='[AUTO] Surat Jalan '.$d->delivery_no.' | '.$l->description.' | Qty '.number_format($l->qty,0,',','.'); $ledger=array('item_id'=>$l->item_id,'warehouse_id'=>$d->warehouse_id,'movement_date'=>$d->delivery_date,'direction'=>'out','qty'=>$l->qty,'unit_cost'=>$l->qty>0?$cost/$l->qty:0,'value'=>$cost,'document_type'=>'sales_delivery','document_id'=>$d->id,'document_line_id'=>$l->id,'idempotency_key'=>$key,'created_at'=>date('Y-m-d H:i:s'));$ledger=$this->addCompany('inventory_ledger',$ledger);$this->CI->db->insert('inventory_ledger',$ledger); foreach($bars as$b){$log=$this->addCompany('stock_logs',array('item_id'=>$l->item_id,'warehouse_id'=>$d->warehouse_id,'barcode_id'=>$b->barcode_id,'qty'=>$b->qty,'tipe'=>'keluar','unit_cost'=>$l->qty>0?$cost/$l->qty:0,'keterangan'=>$desc,'ref_type'=>'sales_delivery','ref_id'=>$l->id,'idempotency_key'=>$key.'-B'.$b->id));$this->CI->db->insert('stock_logs',$log);$move=$this->addCompany('item_movements',array('item_id'=>$l->item_id,'barcode_id'=>$b->barcode_id,'qty'=>$b->qty,'from_type'=>'warehouse','from_id'=>$d->warehouse_id,'to_type'=>'customer','to_id'=>$d->customer_id,'movement_type'=>'sold_out','reference_type'=>'sales_delivery','reference_id'=>$l->id,'notes'=>$desc,'created_at'=>date('Y-m-d H:i:s')));$this->CI->db->insert('item_movements',$move);} $this->CI->salesInventoryModel->synchronizeStock($l->item_id);$this->CI->db->where('id',$l->id)->update('sales_delivery_lines',array('unit_cost'=>$l->qty>0?$cost/$l->qty:0,'total_cost'=>$cost,'stock_posted_at'=>date('Y-m-d H:i:s')));$this->CI->db->where('id',$l->invoice_detail_id)->update('invoice_details',array('total_hpp_barang'=>$cost,'stock_posted_at'=>date('Y-m-d H:i:s'))); } $journal=null;if(abs($inventoryReclass)>.001){$amount=abs(round($inventoryReclass,2));$entries=$inventoryReclass>0?array(array('account_id'=>$this->CI->accountmappingservice->get('inventory_in_sales'),'debit'=>$amount,'kredit'=>0),array('account_id'=>$this->CI->accountmappingservice->get('inventory'),'debit'=>0,'kredit'=>$amount)):array(array('account_id'=>$this->CI->accountmappingservice->get('inventory'),'debit'=>$amount,'kredit'=>0),array('account_id'=>$this->CI->accountmappingservice->get('inventory_in_sales'),'debit'=>0,'kredit'=>$amount));$deliveryCustomer=$this->CI->db->select('nama')->get_where('customers',array('id'=>$d->customer_id,'company_id'=>$this->company()))->row();$description='Penyesuaian harga modal pada pengiriman '.$d->delivery_no.' kepada '.($deliveryCustomer?$deliveryCustomer->nama:'Customer #'.$d->customer_id).' sebesar Rp '.number_format($amount,2,',','.');$journal=$this->CI->postingservice->post(array('tanggal'=>$d->delivery_date,'no_ref'=>$this->CI->numberingservice->next('sales_delivery_journal',$d->delivery_date),'keterangan'=>$description,'natural_description'=>true,'ref_type'=>'sales_delivery','ref_id'=>$d->id,'created_by'=>$user),$entries,false,true);} $this->CI->db->where('id',$d->id)->update('sales_deliveries',array('status'=>'posted','journal_id'=>$journal,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s'),'version'=>(int)$d->version+1));$this->updateInvoiceDeliveryStatus($d->invoice_id);$this->CI->auditservice->record('sales','sales_delivery',$d->id,'post',$d,array('journal_id'=>$journal,'cost'=>$totalCost),$user);return$d->id; }); } private function consumeCost($item,$warehouse,$qty) { $remaining=(float)$qty;$cost=0;$layers=$this->CI->db->query("SELECT * FROM inventory_cost_layers WHERE item_id=? AND warehouse_id=? AND status='open' AND remaining_qty>0 ORDER BY received_date,id FOR UPDATE",array((int)$item,(int)$warehouse))->result(); foreach($layers as$l){if($remaining<=.0001)break;$take=min($remaining,(float)$l->remaining_qty);$left=(float)$l->remaining_qty-$take;$cost+=$take*(float)$l->unit_cost;$this->CI->db->where('id',$l->id)->update('inventory_cost_layers',array('remaining_qty'=>$left,'status'=>$left<=.0001?'consumed':'open'));$remaining-=$take;} if($remaining>.0001)throw new BusinessException('Lapisan biaya persediaan tidak cukup untuk pengiriman.');return round($cost,2); } private function updateInvoiceDeliveryStatus($invoiceId) { if(!$invoiceId)return;$stockLines=(int)$this->CI->db->where(array('invoice_id'=>$invoiceId,'company_id'=>$this->company(),'line_type'=>'inventory'))->count_all_results('invoice_details');if(!$stockLines){$status='delivered';}else{$counts=$this->CI->db->select("SUM(status='posted') posted_count,COUNT(*) total_count",false)->where(array('invoice_id'=>$invoiceId,'company_id'=>$this->company()))->where_in('status',array('draft','posted','partially_returned','returned'))->get('sales_deliveries')->row();$status=!$counts||!$counts->posted_count?'not_delivered':($counts->posted_count==$counts->total_count?'delivered':'partial');}$this->CI->db->where(array('id'=>$invoiceId,'company_id'=>$this->company()))->update('invoices',array('delivery_status'=>$status)); } public function submitInvoice($id,$user) { return$this->CI->transactionservice->run(function()use($id,$user){$i=$this->lockInvoice($id,'draft');$this->assertDraftOwner($i,$user);$this->refreshInvoiceTotals($i->id);$this->updateInvoiceDeliveryStatus($i->id);$i=$this->lockInvoice($id,'draft');if((float)$i->total<=0)throw new BusinessException('Invoice belum mempunyai nilai.');$this->CI->approvalservice->create('invoice','invoice',$i->id,$i->total,$user,array('invoice_no'=>$i->no_invoice,'customer_id'=>$i->customer_id));$this->CI->db->where('id',$i->id)->update('invoices',array('workflow_status'=>'submitted','finalized_at'=>date('Y-m-d H:i:s'),'finalized_by'=>$user,'submitted_at'=>date('Y-m-d H:i:s'),'submitted_by'=>$user));$this->CI->auditservice->record('sales','invoice',$i->id,'submit',$i,array('status'=>'submitted','delivery_status'=>$i->delivery_status,'delivery_optional'=>true),$user);return$i->id;}); } public function rejectInvoice($id,$reason,$user,$roleId) { return$this->CI->transactionservice->run(function()use($id,$reason,$user,$roleId){$i=$this->lockInvoice($id,'submitted');if(trim($reason)==='')throw new BusinessException('Alasan penolakan wajib diisi.');$request=$this->CI->approvalservice->pendingForEntity('invoice',$i->id);if($request)$this->CI->approvalservice->act($request->id,'rejected',$user,$roleId,trim($reason));$this->CI->db->where('id',$i->id)->update('invoices',array('workflow_status'=>'draft','rejected_by'=>$user,'rejected_at'=>date('Y-m-d H:i:s'),'rejection_reason'=>trim($reason),'finalized_at'=>null,'finalized_by'=>null));$this->CI->auditservice->record('sales','invoice',$i->id,'reject',$i,array('status'=>'draft','reason'=>$reason),$user);return$i->id;}); } public function approveAndPostInvoice($id,$user,$roleId) { return$this->CI->transactionservice->run(function()use($id,$user,$roleId){$i=$this->lockInvoice($id,'submitted');$request=$this->CI->approvalservice->pendingForEntity('invoice',$i->id);if(!$request)throw new BusinessException('Permintaan approval invoice tidak ditemukan.');$approval=$this->CI->approvalservice->act($request->id,'approved',$user,$roleId);if($approval->status!=='approved')throw new BusinessException('Tahap approval invoice belum lengkap.');$this->CI->db->where('id',$i->id)->update('invoices',array('workflow_status'=>'approved','approved_by'=>$user,'approved_at'=>date('Y-m-d H:i:s')));return$this->postInvoiceInternal($i->id,$user);}); } private function postInvoiceInternal($id,$user) { $i=$this->lockInvoice($id,'approved');$this->CI->fiscalperiodservice->assertOpen($i->tanggal);if($i->journal_id)throw new BusinessException('Invoice sudah pernah diposting.');$lines=$this->CI->db->where(array('invoice_id'=>$i->id,'company_id'=>$this->company()))->get('invoice_details')->result();if(!$lines)throw new BusinessException('Invoice belum mempunyai item.'); $family=$this->lineFamily($lines[0]->line_type);$entries=array();$cogs=0;$revenueAmount=0;$hasSales=false;$hasSavings=false;$hasLoan=false;foreach($lines as$l){if(in_array($l->line_type,array('inventory','service'),true))$hasSales=true;elseif($l->line_type==='savings')$hasSavings=true;elseif($l->line_type==='loan')$hasLoan=true;} if($hasSavings&&!$hasSales&&!$hasLoan){ $this->CI->db->where('id',$i->id)->update('invoices',array('workflow_status'=>'posted','posted'=>1,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s'),'journal_id'=>null,'status'=>'unpaid','sisa_piutang'=>$i->total,'recognized_revenue'=>0,'recognized_cogs'=>0,'recognition_policy'=>'on_payment')); $this->CI->auditservice->record('sales','invoice',$i->id,'post',$i,array('journal_id'=>null,'purpose'=>'Tabungan; jurnal dibentuk saat pembayaran'),$user);return$i->id; } if($hasSales&&!$hasLoan){ $initialJournals=array();$journalizedLines=0;foreach($lines as$l){if($l->line_type==='savings'||((float)$l->subtotal<=0&&(float)$l->total_hpp_barang<=0)){$journalizedLines++;continue;}$lineJournal=$this->initialLineJournal($l->id);if($lineJournal&&!$lineJournal->is_reversed&&$lineJournal->status==='posted'){$initialJournals[]=$lineJournal;$journalizedLines++;}} if($journalizedLines===count($lines)&&$initialJournals){ foreach($lines as$l)if(in_array($l->line_type,array('inventory','service'),true)){$revenueAmount+=(float)$l->net_amount;$cogs+=(float)$l->total_hpp_barang;} $journal=(int)$initialJournals[0]->id;$this->CI->db->where('id',$i->id)->update('invoices',array('workflow_status'=>'posted','posted'=>1,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s'),'journal_id'=>$journal,'status'=>'unpaid','sisa_piutang'=>$i->total,'recognized_revenue'=>0,'recognized_cogs'=>0,'recognition_policy'=>'on_payment')); $this->CI->load->library('TaxService');$this->CI->taxservice->invoice($i->id,$journal);$this->CI->auditservice->record('sales','invoice',$i->id,'post',$i,array('journal_id'=>$journal,'purpose'=>'Pendapatan diakui proporsional saat pembayaran','line_journal_count'=>count($initialJournals)),$user);return$i->id; } } if($hasSales&&$hasSavings&&!$hasLoan){ $legacyEntries=array();$legacyReceivable=0;$legacyDeferred=0;$legacyTax=0;foreach($lines as$l){if(!in_array($l->line_type,array('inventory','service'),true))continue;$lineJournal=$this->initialLineJournal($l->id);if($lineJournal&&!$lineJournal->is_reversed&&$lineJournal->status==='posted')continue;$legacyReceivable+=(float)$l->subtotal;$legacyDeferred+=(float)$l->net_amount;$legacyTax+=(float)$l->tax_amount;} if($legacyReceivable<=0)throw new BusinessException('Komposisi jurnal invoice campuran tidak valid.');$legacyEntries[]=array('account_id'=>$this->CI->accountmappingservice->get('accounts_receivable'),'debit'=>$legacyReceivable,'kredit'=>0);if($legacyDeferred>0)$legacyEntries[]=array('account_id'=>$this->CI->accountmappingservice->get('deferred_revenue'),'debit'=>0,'kredit'=>$legacyDeferred);if($legacyTax>0)$legacyEntries[]=array('account_id'=>$this->CI->accountmappingservice->get('sales_tax_payable'),'debit'=>0,'kredit'=>$legacyTax); $legacyCustomer=$this->CI->db->select('nama')->get_where('customers',array('id'=>$i->customer_id,'company_id'=>$this->company()))->row();$description='Pencatatan komponen penjualan Invoice '.$i->no_invoice.' kepada '.($legacyCustomer?$legacyCustomer->nama:'Customer #'.$i->customer_id).' sebesar Rp '.number_format($legacyReceivable,2,',','.').'; komponen Tabungan dicatat saat pembayaran';$journal=$this->CI->postingservice->post(array('tanggal'=>$i->tanggal,'no_ref'=>$this->CI->numberingservice->next('invoice_journal',$i->tanggal),'keterangan'=>$description,'natural_description'=>true,'ref_type'=>'invoice','ref_id'=>$i->id,'created_by'=>$user),$legacyEntries,false,true);$this->CI->db->where('id',$i->id)->update('invoices',array('workflow_status'=>'posted','posted'=>1,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s'),'journal_id'=>$journal,'status'=>'unpaid','sisa_piutang'=>$i->total,'recognized_revenue'=>0,'recognized_cogs'=>0,'recognition_policy'=>'on_payment')); $this->CI->load->library('TaxService');$this->CI->taxservice->invoice($i->id,$journal);$this->CI->auditservice->record('sales','invoice',$i->id,'post',$i,array('journal_id'=>$journal,'purpose'=>'Invoice campuran Barang/Jasa dan Tabungan'),$user);return$i->id; } if($family==='financial'){ $debits=array();$credits=array();foreach($lines as$l){$value=(float)$l->subtotal;if($l->line_type==='loan'){$debits[(int)$l->account_id]=($debits[(int)$l->account_id]??0)+$value;$credits[(int)$l->counter_account_id]=($credits[(int)$l->counter_account_id]??0)+$value;}else{$ar=$this->CI->accountmappingservice->get('accounts_receivable');$debits[$ar]=($debits[$ar]??0)+$value;$credits[(int)$l->account_id]=($credits[(int)$l->account_id]??0)+$value;}} foreach($debits as$a=>$v)if($v>0)$entries[]=array('account_id'=>$a,'debit'=>$v,'kredit'=>0);foreach($credits as$a=>$v)if($v>0)$entries[]=array('account_id'=>$a,'debit'=>0,'kredit'=>$v); }else{ $receivable=$this->CI->accountmappingservice->get('accounts_receivable');$deferred=$this->CI->accountmappingservice->get('deferred_revenue');$entries[]=array('account_id'=>$receivable,'debit'=>$i->total,'kredit'=>0);$revenueCredits=array(); foreach($lines as$l){$revenueCredits[(int)$l->account_id]=($revenueCredits[(int)$l->account_id]??0)+(float)$l->net_amount;$revenueAmount+=(float)$l->net_amount;if((float)$l->tax_amount>0){$tax=$this->CI->accountmappingservice->get('sales_tax_payable');$entries[]=array('account_id'=>$tax,'debit'=>0,'kredit'=>(float)$l->tax_amount);}$cogs+=(float)$l->total_hpp_barang;} if($revenueAmount>0)$entries[]=array('account_id'=>$deferred,'debit'=>0,'kredit'=>$revenueAmount); if($i->recognition_policy==='accrual'&&$revenueAmount>0){$entries[]=array('account_id'=>$deferred,'debit'=>$revenueAmount,'kredit'=>0);foreach($revenueCredits as$a=>$v)if($v>0)$entries[]=array('account_id'=>$a,'debit'=>0,'kredit'=>$v);} if($i->recognition_policy==='accrual'&&$cogs>0){$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('cost_of_goods_sold'),'debit'=>$cogs,'kredit'=>0);$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('inventory_in_sales'),'debit'=>0,'kredit'=>$cogs);} } $customer=$this->CI->db->select('nama')->get_where('customers',array('id'=>$i->customer_id,'company_id'=>$this->company()))->row();$purpose=$family==='financial'?($lines[0]->line_type==='loan'?'tagihan pinjaman':'tagihan Tabungan'):($i->recognition_policy==='accrual'?'pendapatan yang diakui saat posting':'pendapatan yang diakui setelah pembayaran');$journalDescription='Pencatatan Invoice '.$i->no_invoice.' kepada '.($customer?$customer->nama:'Customer #'.$i->customer_id).' sebesar Rp '.number_format($i->total,2,',','.').' sebagai '.$purpose; $journal=$this->CI->postingservice->post(array('tanggal'=>$i->tanggal,'no_ref'=>$this->CI->numberingservice->next('invoice_journal',$i->tanggal),'keterangan'=>$journalDescription,'natural_description'=>true,'ref_type'=>'invoice','ref_id'=>$i->id,'created_by'=>$user),$entries,false,true); if($family==='financial'&&$lines[0]->line_type==='loan')foreach($credits as$accountId=>$value)$this->recordCashSubledger($accountId,$i->tanggal,'out',$value,'customer',$i->customer_id,'invoice_loan',$i->id,$journal,$journalDescription,$user); if($family==='sales'&&$i->recognition_policy==='accrual'){$this->CI->db->insert('revenue_recognition_ledger',array('company_id'=>$this->company(),'invoice_id'=>$i->id,'recognition_date'=>$i->tanggal,'revenue_amount'=>$revenueAmount,'cogs_amount'=>$cogs,'journal_id'=>$journal,'idempotency_key'=>'INVOICE-ACCRUAL-'.$i->id,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s')));} $this->CI->db->where('id',$i->id)->update('invoices',array('workflow_status'=>'posted','posted'=>1,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s'),'journal_id'=>$journal,'status'=>'unpaid','sisa_piutang'=>$i->total,'recognized_revenue'=>$family==='sales'&&$i->recognition_policy==='accrual'?$revenueAmount:0,'recognized_cogs'=>$family==='sales'&&$i->recognition_policy==='accrual'?$cogs:0));if($family==='sales'){$this->CI->load->library('TaxService');$this->CI->taxservice->invoice($i->id,$journal);}$this->CI->auditservice->record('sales','invoice',$i->id,'post',$i,array('journal_id'=>$journal,'purpose'=>$purpose),$user);return$i->id; } public function recordPayment($customerId,$date,array$sources,array$allocations,$allowAdvance,$user,$reference='',$notes='',array$attachments=array()) { return $this->CI->transactionservice->run(function()use($customerId,$date,$sources,$allocations,$allowAdvance,$user,$reference,$notes,$attachments){ $customer=$this->tenant('customers',$customerId,'Customer'); $this->CI->fiscalperiodservice->assertOpen($date); $sourceTotal=0;$entries=array();$sourceRows=array();$seenSources=array(); foreach($sources as$n=>$source){ $amount=$this->money($source['amount']??0);if($amount<=0)continue; $account=$this->tenant('accounts',$source['account_id']??0,'Akun penerimaan/pelunasan ke-'.($n+1)); if(isset($seenSources[$account->id]))throw new BusinessException('Akun penerimaan/pelunasan yang sama cukup dipilih satu kali.');$seenSources[$account->id]=true; if(!in_array((string)$account->tipe,array('asset','liability'),true))throw new BusinessException('Sumber pelunasan harus menggunakan akun Aktiva atau Passiva.');if(isset($account->allow_posting)&&!(int)$account->allow_posting)throw new BusinessException('Akun '.$account->nama_akun.' tidak dapat diposting.');if(isset($account->is_hidden)&&(int)$account->is_hidden===1&&!(function_exists('is_master_admin_user')&&is_master_admin_user()))throw new BusinessException('Akun '.$account->nama_akun.' tidak dapat digunakan oleh pengguna ini.'); $sourceTotal+=$amount;$sourceRows[]=array('account_id'=>$account->id,'amount'=>$amount);$entries[]=array('account_id'=>$account->id,'debit'=>$amount,'kredit'=>0); } if($sourceTotal<=0)throw new BusinessException('Tambahkan minimal satu akun penerimaan/pelunasan Aktiva atau Passiva.'); $allocated=0;$locked=array(); foreach($allocations as$invoiceId=>$raw){ $amount=$this->money($raw);if($amount<=0)continue; $invoice=$this->CI->db->query('SELECT * FROM invoices WHERE id=? AND company_id=? FOR UPDATE',array((int)$invoiceId,$this->company()))->row(); if(!$invoice||$invoice->workflow_status!=='posted'||(int)$invoice->customer_id!==(int)$customer->id)throw new BusinessException('Invoice alokasi tidak valid.'); if($amount>(float)$invoice->sisa_piutang+.001)throw new BusinessException('Alokasi melebihi sisa invoice '.$invoice->no_invoice.'.'); $allocated+=$amount;$locked[]=array($invoice,$amount); } if($allocated>$sourceTotal+.001)throw new BusinessException('Total alokasi invoice melebihi sumber pembayaran.'); $advance=round($sourceTotal-$allocated,2);if($advance>.001&&!$allowAdvance)throw new BusinessException('Terdapat kelebihan pembayaran. Aktifkan pencatatan uang muka pelanggan.'); $settlementCredits=array();$allocationDistributions=array();foreach($locked as$row){$distribution=$this->paymentReceivableDistribution($row[0],$row[1]);$allocationDistributions[(int)$row[0]->id]=$distribution;foreach($distribution as$accountId=>$value)$settlementCredits[$accountId]=($settlementCredits[$accountId]??0)+$value;}foreach($sourceRows as$s)if(isset($settlementCredits[$s['account_id']]))throw new BusinessException('Akun penerimaan/pelunasan tidak boleh sama dengan akun piutang atau simpanan yang sedang diproses.');foreach($settlementCredits as$accountId=>$value)if($value>0)$entries[]=array('account_id'=>$accountId,'debit'=>0,'kredit'=>$value); if($advance>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('customer_advances'),'debit'=>0,'kredit'=>$advance); $paymentNo=$this->CI->numberingservice->next('payment',$date); $invoiceNames=array_map(function($x){return$x[0]->no_invoice.' Rp '.number_format($x[1],2,',','.');},$locked); $savingsPayment=!empty($locked);foreach($locked as$row){$firstLine=$this->CI->db->select('line_type')->where(array('invoice_id'=>$row[0]->id,'company_id'=>$this->company()))->order_by('id')->limit(1)->get('invoice_details')->row();if(!$firstLine||$firstLine->line_type!=='savings'){$savingsPayment=false;break;}} if($savingsPayment){$paymentDescription='Tabungan '.$customer->nama;if(trim((string)$notes)!=='')$paymentDescription.=" - '".trim((string)$notes)."'";}else{$allocationText=$invoiceNames?' untuk '.implode(', ',$invoiceNames):' sebagai uang muka pelanggan';$paymentDescription='Penerimaan pembayaran '.$paymentNo.' dari '.$customer->nama.$allocationText.' dengan total Rp '.number_format($sourceTotal,2,',','.');if(trim((string)$reference)!=='')$paymentDescription.=' - Referensi: '.trim((string)$reference);if(trim((string)$notes)!=='')$paymentDescription.=' - '.trim((string)$notes);} $journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$paymentNo,'keterangan'=>$paymentDescription,'natural_description'=>true,'ref_type'=>'customer_payment','ref_id'=>null,'created_by'=>$user),$entries,false,false); $payment=$this->columns('payments',array('company_id'=>$this->company(),'invoice_id'=>count($locked)===1?$locked[0][0]->id:null,'tanggal'=>$date,'jumlah'=>$sourceTotal,'journal_id'=>$journal,'metode'=>count($sourceRows)>1?'multi_account':'manual','payment_no'=>$paymentNo,'customer_id'=>$customer->id,'status'=>'posted','reference_no'=>$reference,'notes'=>$notes,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s'))); $this->CI->db->insert('payments',$payment);$paymentId=(int)$this->CI->db->insert_id(); foreach($sourceRows as$s){$this->CI->db->insert('payment_sources',array('company_id'=>$this->company(),'payment_id'=>$paymentId,'account_id'=>$s['account_id'],'amount'=>$s['amount'],'created_at'=>date('Y-m-d H:i:s')));$this->recordCashSubledger($s['account_id'],$date,'in',$s['amount'],'customer',$customer->id,'customer_payment',$paymentId,$journal,'Penerimaan '.$paymentNo.' dari '.$customer->nama,$user);} foreach($locked as$row){ list($invoice,$amount)=$row;$this->CI->db->insert('payment_allocations',$this->columns('payment_allocations',array('company_id'=>$this->company(),'payment_id'=>$paymentId,'invoice_id'=>$invoice->id,'amount'=>$amount,'created_at'=>date('Y-m-d H:i:s')))); $paid=round((float)$invoice->total_bayar+$amount,2);$balance=max(0,round((float)$invoice->sisa_piutang-$amount,2)); $this->CI->db->where(array('id'=>$invoice->id,'company_id'=>$this->company()))->update('invoices',array('total_bayar'=>$paid,'sisa_piutang'=>$balance,'status'=>$balance<=.001?'paid':'partial')); $receivableAccount=$this->CI->accountmappingservice->get('accounts_receivable');$salesPayment=(float)($allocationDistributions[(int)$invoice->id][$receivableAccount]??0);if($salesPayment>0)$this->recognizeOnPayment($invoice,$paymentId,$salesPayment,$date,$user); } if($advance>0)$this->CI->db->insert('customer_advances',$this->columns('customer_advances',array('company_id'=>$this->company(),'advance_no'=>'ADV-'.$paymentNo,'customer_id'=>$customer->id,'payment_id'=>$paymentId,'original_amount'=>$advance,'available_amount'=>$advance,'status'=>'available','journal_id'=>$journal,'created_at'=>date('Y-m-d H:i:s')))); foreach($attachments as$a)$this->insertAttachment('customer_payment',$paymentId,$a,$user); $this->CI->db->where('id',$journal)->update('journals',array('ref_id'=>$paymentId)); $this->CI->auditservice->record('sales','payment',$paymentId,'post',null,array('payment_no'=>$paymentNo,'amount'=>$sourceTotal,'allocated'=>$allocated,'advance'=>$advance),$user); return array('payment_id'=>$paymentId,'payment_no'=>$paymentNo,'advance'=>$advance); }); } private function paymentReceivableDistribution($invoice,$amount) { $lines=$this->CI->db->select('line_type,account_id,subtotal')->where(array('invoice_id'=>$invoice->id,'company_id'=>$this->company()))->get('invoice_details')->result();$weights=array(); foreach($lines as$l){$account=in_array($l->line_type,array('loan','savings'),true)?(int)$l->account_id:$this->CI->accountmappingservice->get('accounts_receivable');$weights[$account]=($weights[$account]??0)+(float)$l->subtotal;} $total=array_sum($weights);if($total<=0)throw new BusinessException('Komposisi akun piutang invoice tidak valid.');$result=array();$used=0;$accounts=array_keys($weights);$last=end($accounts); foreach($weights as$account=>$weight){$part=(int)$account===(int)$last?round($amount-$used,2):round($amount*$weight/$total,2);$used+=$part;if($part>0)$result[(int)$account]=$part;} return$result; } private function recordCashSubledger($glAccountId,$date,$direction,$amount,$partyType,$partyId,$sourceType,$sourceId,$journalId,$description,$user) { if(!$this->CI->db->table_exists('cash_accounts')||!$this->CI->db->table_exists('cash_transactions'))return; // cash_accounts pada database lama belum memiliki company_id. Scope tenant // tetap dijaga melalui akun GL yang terhubung karena accounts.company_id // merupakan sumber kepemilikan akun yang digunakan saat pembayaran. $cash=$this->CI->db ->select('cash_accounts.*') ->from('cash_accounts') ->join('accounts','accounts.id=cash_accounts.gl_account_id') ->where('accounts.company_id',$this->company()) ->where('cash_accounts.gl_account_id',(int)$glAccountId) ->where('cash_accounts.is_active',1) ->limit(1) ->get() ->row(); if(!$cash)return; $key='C'.$this->company().'-'.strtoupper($sourceType).'-'.(int)$sourceId.'-'.(int)$cash->id.'-'.$direction;if($this->CI->db->where('idempotency_key',$key)->count_all_results('cash_transactions'))return; $row=$this->columns('cash_transactions',array('company_id'=>$this->company(),'transaction_no'=>$this->CI->numberingservice->next('cash_transaction',$date),'transaction_date'=>$date,'cash_account_id'=>$cash->id,'transaction_type'=>$direction==='in'?'receipt':'payment','direction'=>$direction,'amount'=>$amount,'counter_account_id'=>null,'party_type'=>$partyType,'party_id'=>$partyId,'reference_no'=>null,'description'=>'[AUTO] '.$description,'status'=>'posted','requires_approval'=>0,'source_type'=>$sourceType,'source_id'=>$sourceId,'journal_id'=>$journalId,'idempotency_key'=>$key,'created_by'=>$user,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s'),'created_at'=>date('Y-m-d H:i:s')));$this->CI->db->insert('cash_transactions',$row); } private function invoiceFamily($invoiceId) { $line=$this->CI->db->select('line_type')->where(array('invoice_id'=>(int)$invoiceId,'company_id'=>$this->company()))->order_by('id')->limit(1)->get('invoice_details')->row();return$this->lineFamily($line?$line->line_type:'inventory'); } private function lineFamily($type){return in_array($type,array('loan','savings'),true)?'financial':'sales';} private function recognizeOnPayment($invoice,$paymentId,$allocated,$date,$user) { $lines=$this->CI->db->where(array('invoice_id'=>$invoice->id,'company_id'=>$this->company()))->where_in('line_type',array('inventory','service'))->order_by('id')->get('invoice_details')->result();if(!$lines)return; $netTotal=0;$gross=0;foreach($lines as$l){$netTotal+=(float)$l->net_amount;$gross+=(float)$l->subtotal;}$netTotal=max(.01,$netTotal);$gross=max(.01,$gross); $remainingRevenue=max(0,$netTotal-(float)$invoice->recognized_revenue);$revenue=min($remainingRevenue,round($allocated*($netTotal/$gross),2)); $allCost=0;foreach($lines as$l)$allCost+=(float)$l->total_hpp_barang;$remainingCost=max(0,$allCost-(float)$invoice->recognized_cogs);$cost=min($remainingCost,round($allocated*($allCost/$gross),2)); if($revenue<=0&&$cost<=0)return; $entries=array();if($revenue>0){$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('deferred_revenue'),'debit'=>$revenue,'kredit'=>0);$left=$revenue;foreach($lines as$idx=>$l){$part=$idx===count($lines)-1?$left:round($revenue*((float)$l->net_amount/$netTotal),2);$left-=$part;if($part>0)$entries[]=array('account_id'=>$l->account_id,'debit'=>0,'kredit'=>$part);}} if($cost>0){$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('cost_of_goods_sold'),'debit'=>$cost,'kredit'=>0);$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('inventory_in_sales'),'debit'=>0,'kredit'=>$cost);} $recognitionDescription='Pengakuan pendapatan Invoice '.$invoice->no_invoice.' setelah pembayaran sebesar Rp '.number_format($revenue,2,',','.');if($cost>0)$recognitionDescription.=' dengan harga pokok Rp '.number_format($cost,2,',','.'); $journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$this->CI->numberingservice->next('revenue_recognition',$date),'keterangan'=>$recognitionDescription,'natural_description'=>true,'ref_type'=>'revenue_recognition','ref_id'=>$paymentId,'created_by'=>$user),$entries,false,false); $this->CI->db->insert('revenue_recognition_ledger',array('company_id'=>$this->company(),'invoice_id'=>$invoice->id,'payment_id'=>$paymentId,'recognition_date'=>$date,'revenue_amount'=>$revenue,'cogs_amount'=>$cost,'journal_id'=>$journal,'idempotency_key'=>'PAYMENT-RECOGNITION-'.$paymentId.'-'.$invoice->id,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s'))); $this->CI->db->where(array('id'=>$invoice->id,'company_id'=>$this->company()))->set('recognized_revenue','recognized_revenue+'.(float)$revenue,false)->set('recognized_cogs','recognized_cogs+'.(float)$cost,false)->update('invoices'); } public function invoiceDetail($id) { $invoice=$this->CI->db->select('i.*,c.nama customer_name,c.alamat customer_address,u.nama creator_name,ua.nama approver_name')->from('invoices i')->join('customers c','c.id=i.customer_id')->join('users u','u.id=i.created_by','left')->join('users ua','ua.id=i.approved_by','left')->where(array('i.id'=>(int)$id,'i.company_id'=>$this->company()))->get()->row();if(!$invoice)throw new BusinessException('Invoice tidak ditemukan.'); $lines=$this->CI->db->select('d.*,it.kode_detail item_code,a.kode_akun,a.nama_akun')->from('invoice_details d')->join('items it','it.id=d.items_id','left')->join('accounts a','a.id=d.account_id','left')->where(array('d.invoice_id'=>$invoice->id,'d.company_id'=>$this->company()))->order_by('d.service_date,d.id')->get()->result(); $barcodes=$this->CI->db->select('b.*,ib.barcode,ib.serial_number,sd.delivery_no,w.nama warehouse_name,it.kode_detail item_code,it.nama_barang,d.service_date')->from('invoice_line_barcodes b')->join('invoice_details d','d.id=b.invoice_detail_id')->join('item_barcodes ib','ib.id=b.barcode_id')->join('sales_deliveries sd','sd.id=b.sales_delivery_id','left')->join('warehouses w','w.id=ib.warehouse_id','left')->join('items it','it.id=ib.item_id','left')->where(array('b.invoice_id'=>$invoice->id,'b.company_id'=>$this->company()))->order_by('b.id')->get()->result(); $payments=$this->CI->db->select('p.*,pa.amount allocated')->from('payment_allocations pa')->join('payments p','p.id=pa.payment_id')->where('pa.invoice_id',$invoice->id)->order_by('p.tanggal,p.id')->get()->result();foreach($payments as$p)$p->attachments=$this->attachments(array('customer_payment'),$p->id); return array('invoice'=>$invoice,'lines'=>$lines,'barcodes'=>$barcodes,'deliveries'=>$this->CI->db->where(array('invoice_id'=>$invoice->id,'company_id'=>$this->company()))->get('sales_deliveries')->result(),'payments'=>$payments,'returns'=>$this->CI->db->where(array('invoice_id'=>$invoice->id,'company_id'=>$this->company()))->get('sales_returns')->result(),'attachments'=>$this->attachments(array('invoice'),$invoice->id)); } public function previousOutstandingInvoices($invoiceId) { $invoice=$this->invoiceForEditor($invoiceId); return $this->CI->db->select('id,no_invoice,tanggal,jatuh_tempo,total,total_bayar,sisa_piutang,status') ->from('invoices')->where(array('company_id'=>$this->company(),'customer_id'=>$invoice->customer_id,'workflow_status'=>'posted')) ->where('deleted_at IS NULL',null,false)->where('sisa_piutang >',0.001)->where('id !=',$invoice->id) ->group_start()->where('tanggal <',$invoice->tanggal)->or_group_start()->where('tanggal',$invoice->tanggal)->where('id <',$invoice->id)->group_end()->group_end() ->order_by('tanggal','ASC')->order_by('id','ASC')->get()->result(); } public function deliveryDetail($id) { $delivery=$this->CI->db->select('d.*,c.nama customer_name,c.alamat customer_address,i.no_invoice,w.nama warehouse_name,u.nama creator_name')->from('sales_deliveries d')->join('customers c','c.id=d.customer_id')->join('invoices i','i.id=d.invoice_id','left')->join('warehouses w','w.id=d.warehouse_id','left')->join('users u','u.id=d.created_by','left')->where(array('d.id'=>(int)$id,'d.company_id'=>$this->company()))->get()->row();if(!$delivery)throw new BusinessException('Surat Jalan tidak ditemukan.'); return array('delivery'=>$delivery,'lines'=>$this->CI->db->where(array('sales_delivery_id'=>$delivery->id,'company_id'=>$this->company()))->order_by('line_no')->get('sales_delivery_lines')->result(),'barcodes'=>$this->CI->db->select('b.*,ib.barcode,ib.serial_number')->from('sales_delivery_barcodes b')->join('item_barcodes ib','ib.id=b.barcode_id')->where(array('b.sales_delivery_id'=>$delivery->id,'b.company_id'=>$this->company()))->order_by('b.id')->get()->result(),'attachments'=>$this->attachments(array('sales_delivery'),$delivery->id)); } public function createReturn(array$header,array$rows,$user,array$attachments=array()) { return$this->CI->transactionservice->run(function()use($header,$rows,$user,$attachments){ $delivery=$this->CI->db->query('SELECT * FROM sales_deliveries WHERE id=? AND company_id=? FOR UPDATE',array((int)($header['sales_delivery_id']??0),$this->company()))->row();if(!$delivery||!in_array($delivery->status,array('posted','partially_returned'),true))throw new BusinessException('Surat Jalan tidak valid untuk retur.'); if(trim((string)($header['reason']??''))==='')throw new BusinessException('Alasan retur wajib diisi.');if(!$rows)throw new BusinessException('Pilih minimal satu barcode yang diretur.'); $resolution=in_array($header['resolution']??'',array('restock','quarantine','damaged','replacement','credit_note','refund'),true)?$header['resolution']:'credit_note';$date=$header['return_date']??date('Y-m-d'); $this->CI->db->insert('sales_returns',array('company_id'=>$this->company(),'return_no'=>$this->CI->numberingservice->next('sales_return',$date),'customer_id'=>$delivery->customer_id,'invoice_id'=>$delivery->invoice_id,'sales_delivery_id'=>$delivery->id,'return_date'=>$date,'reason'=>trim($header['reason']),'problem_category'=>trim((string)($header['problem_category']??'')),'resolution'=>$resolution,'status'=>'draft','notes'=>trim((string)($header['notes']??'')),'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s')));$returnId=(int)$this->CI->db->insert_id(); foreach($rows as$row){$deliveryBarcode=$this->CI->db->query('SELECT b.*,l.invoice_detail_id,l.unit_cost FROM sales_delivery_barcodes b JOIN sales_delivery_lines l ON l.id=b.sales_delivery_line_id WHERE b.id=? AND b.sales_delivery_id=? AND b.company_id=? FOR UPDATE',array((int)($row['sales_delivery_barcode_id']??0),$delivery->id,$this->company()))->row();$qty=$this->qty($row['qty']??0);if(!$deliveryBarcode||$qty>(float)$deliveryBarcode->qty-(float)$deliveryBarcode->returned_qty+.0001)throw new BusinessException('Qty retur melebihi barang yang pernah dikirim.');$detail=$this->CI->db->get_where('invoice_details',array('id'=>$deliveryBarcode->invoice_detail_id,'company_id'=>$this->company()))->row();$ratio=$qty/max(.0001,(float)$detail->qty);$condition=in_array($row['physical_condition']??'',array('saleable','quarantine','damaged','lost'),true)?$row['physical_condition']:'saleable';$this->CI->db->insert('sales_return_lines',array('company_id'=>$this->company(),'sales_return_id'=>$returnId,'sales_delivery_line_id'=>$deliveryBarcode->sales_delivery_line_id,'invoice_detail_id'=>$detail->id,'item_id'=>$deliveryBarcode->item_id,'qty'=>$qty,'unit_price'=>$detail->harga,'net_amount'=>round($detail->net_amount*$ratio,2),'tax_amount'=>round($detail->tax_amount*$ratio,2),'unit_cost'=>$deliveryBarcode->unit_cost,'physical_condition'=>$condition,'notes'=>trim((string)($row['notes']??'')),'created_at'=>date('Y-m-d H:i:s')));$lineId=(int)$this->CI->db->insert_id();$this->CI->db->insert('sales_return_barcodes',array('company_id'=>$this->company(),'sales_return_id'=>$returnId,'sales_return_line_id'=>$lineId,'sales_delivery_barcode_id'=>$deliveryBarcode->id,'barcode_id'=>$deliveryBarcode->barcode_id,'qty'=>$qty,'status'=>'requested','created_at'=>date('Y-m-d H:i:s')));} foreach($attachments as$a)$this->insertAttachment('sales_return',$returnId,$a,$user);$this->returnHistory($returnId,null,'draft','Retur dibuat',$user);return$returnId; }); } public function submitReturn($id,$user){return$this->returnState($id,'draft','submitted',$user,'Retur diajukan.');} public function approveReturn($id,$user){return$this->returnState($id,'submitted','approved',$user,'Retur disetujui.');} public function rejectReturn($id,$reason,$user){if(trim($reason)==='')throw new BusinessException('Alasan penolakan wajib diisi.');return$this->returnState($id,'submitted','rejected',$user,$reason);} public function receiveReturn($id,$user){return$this->returnState($id,'approved','received',$user,'Barang retur diterima dan diperiksa.');} private function returnState($id,$from,$to,$user,$notes){return$this->CI->transactionservice->run(function()use($id,$from,$to,$user,$notes){$r=$this->lockReturn($id,$from);$data=array('status'=>$to);$data[$to.'_by']=$user;$data[$to.'_at']=date('Y-m-d H:i:s');if($to==='rejected')$data['rejection_reason']=$notes;$this->CI->db->where('id',$r->id)->update('sales_returns',$data);$this->returnHistory($r->id,$from,$to,$notes,$user);return$r->id;});} public function postReturn($id,$user) { return$this->CI->transactionservice->run(function()use($id,$user){$r=$this->lockReturn($id,'received');$this->CI->fiscalperiodservice->assertOpen($r->return_date);$lines=$this->CI->db->where(array('sales_return_id'=>$r->id,'company_id'=>$this->company()))->get('sales_return_lines')->result();$net=0;$tax=0;$cost=0; foreach($lines as$l){$bars=$this->CI->db->where('sales_return_line_id',$l->id)->get('sales_return_barcodes')->result();foreach($bars as$b){$source=$this->CI->db->query('SELECT * FROM sales_delivery_barcodes WHERE id=? FOR UPDATE',array($b->sales_delivery_barcode_id))->row();if(!$source||(float)$source->returned_qty+(float)$b->qty>(float)$source->qty+.0001)throw new BusinessException('Barcode retur sudah diproses atau qty tidak valid.');$barcode=$this->CI->db->query('SELECT * FROM item_barcodes WHERE id=? FOR UPDATE',array($b->barcode_id))->row();$status=$l->physical_condition==='saleable'?'available':($l->physical_condition==='damaged'?'damaged':'quarantine');$this->CI->db->where('id',$barcode->id)->update('item_barcodes',array('qty_sisa'=>(float)$barcode->qty_sisa+(float)$b->qty,'status'=>$status,'version'=>(int)$barcode->version+1));$this->CI->db->where('id',$source->id)->update('sales_delivery_barcodes',array('returned_qty'=>(float)$source->returned_qty+(float)$b->qty,'status'=>(float)$source->returned_qty+(float)$b->qty>=(float)$source->qty?'returned':'partially_returned'));$this->CI->db->where('id',$b->id)->update('sales_return_barcodes',array('status'=>'posted'));} $value=round((float)$l->unit_cost*(float)$l->qty,2);$key='SALES-RETURN-LINE-'.$l->id;$warehouse=$this->CI->db->select('warehouse_id')->get_where('sales_deliveries',array('id'=>$r->sales_delivery_id))->row()->warehouse_id;$ledger=$this->addCompany('inventory_ledger',array('item_id'=>$l->item_id,'warehouse_id'=>$warehouse,'movement_date'=>$r->return_date,'direction'=>'in','qty'=>$l->qty,'unit_cost'=>$l->unit_cost,'value'=>$value,'document_type'=>'sales_return','document_id'=>$r->id,'document_line_id'=>$l->id,'idempotency_key'=>$key,'created_at'=>date('Y-m-d H:i:s')));$this->CI->db->insert('inventory_ledger',$ledger);if($l->physical_condition==='saleable')$this->CI->db->insert('inventory_cost_layers',$this->columns('inventory_cost_layers',array('company_id'=>$this->company(),'item_id'=>$l->item_id,'warehouse_id'=>$warehouse,'source_ledger_id'=>$this->CI->db->insert_id(),'received_date'=>$r->return_date,'original_qty'=>$l->qty,'remaining_qty'=>$l->qty,'unit_cost'=>$l->unit_cost,'status'=>'open')));$this->CI->db->insert('stock_logs',$this->addCompany('stock_logs',array('item_id'=>$l->item_id,'warehouse_id'=>$warehouse,'qty'=>$l->qty,'tipe'=>'masuk','unit_cost'=>$l->unit_cost,'keterangan'=>'[AUTO] Retur Penjualan '.$r->return_no.' | Kondisi '.$l->physical_condition,'ref_type'=>'sales_return','ref_id'=>$l->id,'idempotency_key'=>$key)));$this->CI->db->insert('item_movements',$this->addCompany('item_movements',array('item_id'=>$l->item_id,'qty'=>$l->qty,'from_type'=>'customer','from_id'=>$r->customer_id,'to_type'=>$l->physical_condition==='saleable'?'warehouse':'quarantine','to_id'=>$warehouse,'movement_type'=>'customer_return','reference_type'=>'sales_return','reference_id'=>$l->id,'notes'=>'[AUTO] Retur '.$r->return_no,'created_at'=>date('Y-m-d H:i:s'))));$this->CI->salesInventoryModel->synchronizeStock($l->item_id);$net+=(float)$l->net_amount;$tax+=(float)$l->tax_amount;$cost+=$value;$this->CI->db->where('id',$l->id)->update('sales_return_lines',array('posted_at'=>date('Y-m-d H:i:s')));} $invoice=$this->lockInvoice($r->invoice_id);$credit=min((float)$invoice->sisa_piutang,$net+$tax);$excess=max(0,$net+$tax-$credit);$entries=array();$revenueRatio=$invoice->recognition_policy==='accrual'?1:min(1,(float)$invoice->recognized_revenue/max(.01,(float)$invoice->subtotal_before_tax));$recognizedNet=round($net*$revenueRatio,2);$deferredNet=$net-$recognizedNet;if($recognizedNet>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('goods_revenue'),'debit'=>$recognizedNet,'kredit'=>0);if($deferredNet>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('deferred_revenue'),'debit'=>$deferredNet,'kredit'=>0);if($tax>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('sales_tax_payable'),'debit'=>$tax,'kredit'=>0);if($credit>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('accounts_receivable'),'debit'=>0,'kredit'=>$credit);if($excess>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('customer_advances'),'debit'=>0,'kredit'=>$excess);if($cost>0){$allInvoiceCost=(float)$this->CI->db->select('COALESCE(SUM(total_hpp_barang),0) v',false)->get_where('invoice_details',array('invoice_id'=>$invoice->id,'company_id'=>$this->company()))->row()->v;$costRatio=$invoice->recognition_policy==='accrual'?1:min(1,(float)$invoice->recognized_cogs/max(.01,$allInvoiceCost));$recognizedCost=round($cost*$costRatio,2);$deferredCost=$cost-$recognizedCost;$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('inventory'),'debit'=>$cost,'kredit'=>0);if($recognizedCost>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('cost_of_goods_sold'),'debit'=>0,'kredit'=>$recognizedCost);if($deferredCost>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('inventory_in_sales'),'debit'=>0,'kredit'=>$deferredCost);}$returnCustomer=$this->CI->db->select('nama')->get_where('customers',array('id'=>$r->customer_id,'company_id'=>$this->company()))->row();$returnDescription='Retur penjualan '.$r->return_no.' atas Invoice '.$invoice->no_invoice.' dari '.($returnCustomer?$returnCustomer->nama:'Customer #'.$r->customer_id).' sebesar Rp '.number_format($net+$tax,2,',','.');if(trim((string)$r->reason)!=='')$returnDescription.=' karena '.trim((string)$r->reason);$journal=$this->CI->postingservice->post(array('tanggal'=>$r->return_date,'no_ref'=>$r->return_no,'keterangan'=>$returnDescription,'natural_description'=>true,'ref_type'=>'sales_return','ref_id'=>$r->id,'created_by'=>$user),$entries,false,true); $balance=max(0,(float)$invoice->sisa_piutang-$credit);$this->CI->db->where('id',$invoice->id)->update('invoices',array('sisa_piutang'=>$balance,'status'=>$balance<=.001?'paid':'partial'));$this->CI->db->insert('credit_notes',$this->columns('credit_notes',array('company_id'=>$this->company(),'credit_note_no'=>'CN-'.$r->return_no,'invoice_id'=>$invoice->id,'customer_id'=>$r->customer_id,'credit_date'=>$r->return_date,'amount'=>$net+$tax,'reason'=>$r->reason,'status'=>'posted','journal_id'=>$journal,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s'))));$creditId=(int)$this->CI->db->insert_id();if($excess>0)$this->CI->db->insert('customer_advances',$this->columns('customer_advances',array('company_id'=>$this->company(),'advance_no'=>'ADV-'.$r->return_no,'customer_id'=>$r->customer_id,'payment_id'=>null,'original_amount'=>$excess,'available_amount'=>$excess,'status'=>'available','journal_id'=>$journal,'created_at'=>date('Y-m-d H:i:s'))));$this->CI->db->where('id',$r->id)->update('sales_returns',array('status'=>'completed','credit_note_id'=>$creditId,'journal_id'=>$journal,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s')));$this->returnHistory($r->id,'received','completed','Stok dan credit note diposting.',$user);return$r->id;}); } public function returnDetail($id){$r=$this->CI->db->select('r.*,c.nama customer_name,i.no_invoice,d.delivery_no')->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')->where(array('r.id'=>(int)$id,'r.company_id'=>$this->company()))->get()->row();if(!$r)throw new BusinessException('Retur tidak ditemukan.');return array('return'=>$r,'lines'=>$this->CI->db->select('l.*,it.kode_detail AS kode_barang,it.nama_barang')->from('sales_return_lines l')->join('items it','it.id=l.item_id')->where('l.sales_return_id',$r->id)->get()->result(),'barcodes'=>$this->CI->db->select('b.*,ib.barcode,ib.serial_number')->from('sales_return_barcodes b')->join('item_barcodes ib','ib.id=b.barcode_id')->where('b.sales_return_id',$r->id)->get()->result(),'history'=>$this->CI->db->select('h.*,u.nama user_name')->from('sales_return_history h')->join('users u','u.id=h.user_id','left')->where('h.sales_return_id',$r->id)->order_by('h.id')->get()->result(),'attachments'=>$this->attachments(array('sales_return'),$r->id));} private function lockReturn($id,$status=null){$r=$this->CI->db->query('SELECT * FROM sales_returns WHERE id=? AND company_id=? FOR UPDATE',array((int)$id,$this->company()))->row();if(!$r)throw new BusinessException('Retur tidak ditemukan.');if($status!==null&&$r->status!==$status)throw new BusinessException('Retur harus berstatus '.$status.'.');return$r;} private function returnHistory($id,$from,$to,$notes,$user){$this->CI->db->insert('sales_return_history',array('company_id'=>$this->company(),'sales_return_id'=>$id,'from_status'=>$from,'to_status'=>$to,'notes'=>$notes,'user_id'=>$user,'created_at'=>date('Y-m-d H:i:s')));} private function attachments(array$types,$entityId){return$this->CI->db->where('company_id',$this->company())->where_in('entity_type',$types)->where('entity_id',(int)$entityId)->order_by('id')->get('transaction_attachments')->result();} private function insertAttachment($type,$id,array$a,$user){$this->CI->db->insert('transaction_attachments',$this->columns('transaction_attachments',array('company_id'=>$this->company(),'module'=>'sales','entity_type'=>$type,'entity_id'=>$id,'original_name'=>$a['original_name'],'stored_name'=>$a['stored_name'],'mime_type'=>$a['mime_type'],'file_size'=>$a['file_size'],'sha256'=>$a['sha256'],'uploaded_by'=>$user,'created_at'=>date('Y-m-d H:i:s'))));} private function columns($table,array$data){$fields=array_flip($this->CI->db->list_fields($table));return array_intersect_key($data,$fields);} private function assertDraftOwner($invoice,$user){if(function_exists('is_master_admin_user')&&is_master_admin_user())return;if((int)$invoice->created_by!==(int)$user)throw new BusinessException('Draft invoice hanya dapat dikelola dan diajukan oleh pembuatnya.');} private function lockInvoice($id,$status=null){$i=$this->CI->db->query('SELECT * FROM invoices WHERE id=? AND company_id=? FOR UPDATE',array((int)$id,$this->company()))->row();if(!$i)throw new BusinessException('Invoice tidak ditemukan.');if($status!==null&&$i->workflow_status!==$status)throw new BusinessException('Invoice harus berstatus '.$status.'.');return$i;} }