update di penjualan invoice

This commit is contained in:
Wian Drs
2026-09-12 01:45:03 +07:00
parent e43bda8d6b
commit ec94783db4
22 changed files with 776 additions and 44 deletions
+28 -6
View File
@@ -184,7 +184,9 @@ class SalesService
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.');$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.');$this->CI->db->where(array('id'=>$barcode->id,'version'=>$barcode->version))->update('item_barcodes',array('reserved_qty'=>(float)$barcode->reserved_qty+$bq,'version'=>(int)$barcode->version+1));if($this->CI->db->affected_rows()!==1)throw new BusinessException('Barcode sedang dipilih pengguna lain. Silakan ulangi.');$barcode->reserved_qty=(float)$barcode->reserved_qty+$bq;$barcode->version=(int)$barcode->version+1;$barcodeQty+=$bq;$cleanBarcodes[]=array('row'=>$barcode,'qty'=>$bq);
$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.');
}
@@ -315,7 +317,12 @@ class SalesService
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 * FROM item_barcodes WHERE id=? FOR UPDATE',array($b->barcode_id))->row();if($barcode)$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));}
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');
}
@@ -353,7 +360,7 @@ class SalesService
$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||$barcode->status!=='available'||(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':'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;}
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,',','.');
@@ -378,7 +385,7 @@ class SalesService
public function submitInvoice($id,$user)
{
return$this->CI->transactionservice->run(function()use($id,$user){$i=$this->lockInvoice($id,'draft');$this->assertDraftOwner($i,$user);$open=(int)$this->CI->db->where(array('invoice_id'=>$i->id,'company_id'=>$this->company(),'status'=>'draft'))->count_all_results('sales_deliveries');if($open)throw new BusinessException('Seluruh Surat Jalan Draft harus diposting sebelum invoice diajukan.');$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.');if($i->delivery_status!=='delivered')throw new BusinessException('Pengiriman barang pada invoice belum selesai.');$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'),$user);return$i->id;});
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)
{
@@ -390,7 +397,7 @@ class SalesService
}
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.');if($i->delivery_status!=='delivered')throw new BusinessException('Invoice hanya dapat diposting setelah seluruh Surat Jalan 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.');
$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'));
@@ -484,7 +491,22 @@ class SalesService
private function recordCashSubledger($glAccountId,$date,$direction,$amount,$partyType,$partyId,$sourceType,$sourceId,$journalId,$description,$user)
{
if(!$this->CI->db->table_exists('cash_transactions'))return;$cash=$this->CI->db->get_where('cash_accounts',array('company_id'=>$this->company(),'gl_account_id'=>(int)$glAccountId,'is_active'=>1))->row();if(!$cash)return;
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);
}