From ec94783db47f7a1b94a093ebf7195b97ae67cd77 Mon Sep 17 00:00:00 2001 From: Wian Drs Date: Sat, 12 Sep 2026 01:45:03 +0700 Subject: [PATCH] update di penjualan invoice --- .phpunit.result.cache | 2 +- application/controllers/Customers.php | 5 +- application/controllers/Invoices.php | 8 +- application/controllers/Items.php | 7 + application/controllers/Receivables.php | 2 +- application/controllers/Sales.php | 29 +- application/libraries/SalesService.php | 34 +- application/views/customers/layout.php | 15 +- application/views/invoices/editor.php | 4 +- application/views/invoices/index.php | 9 +- application/views/items/layout.php | 2 +- application/views/receivables/index.php | 2 +- application/views/sales/_section_nav.php | 26 + application/views/sales/index.php | 2 +- application/views/sales/receipt_pdf.php | 63 +- assets/css/invoices-professional.css | 5 + assets/js/invoices-professional.js | 36 +- .../401f080a254faf35718a41e704f96ed6.png | Bin 0 -> 653350 bytes .../7b3ecb17921b83e33fb5c19aebf432f3.png | Bin 0 -> 653350 bytes .../7fcad3149995374f18ff34a5bf3babb4.png | Bin 0 -> 49775 bytes .../a710d5aeb404ce196d29c5814c331497.png | Bin 0 -> 653350 bytes .../dompdf/lib/fonts/Times-Bold.afm.json | 569 ++++++++++++++++++ 22 files changed, 776 insertions(+), 44 deletions(-) create mode 100644 application/views/sales/_section_nav.php create mode 100644 uploads/accounting/sales/401f080a254faf35718a41e704f96ed6.png create mode 100644 uploads/accounting/sales/7b3ecb17921b83e33fb5c19aebf432f3.png create mode 100644 uploads/accounting/sales/7fcad3149995374f18ff34a5bf3babb4.png create mode 100644 uploads/accounting/sales/a710d5aeb404ce196d29c5814c331497.png create mode 100644 vendor/dompdf/dompdf/lib/fonts/Times-Bold.afm.json diff --git a/.phpunit.result.cache b/.phpunit.result.cache index c48b68d..dcb9717 100644 --- a/.phpunit.result.cache +++ b/.phpunit.result.cache @@ -1 +1 @@ -{"version":1,"defects":{"ApplicationContractTest::testInternalControllersUseAuthenticatedBaseController":3,"ApplicationContractTest::testResponsiveShellHasDesktopAndMobileRules":3,"ApplicationContractTest::testUiPreventsRepeatedSubmitAndHandlesAjaxFailure":4,"AccountingDatabaseTest::testAccountingInvariantsAgainstDevelopmentDatabase":3,"ApplicationContractTest::testPurchaseReturnsAreSeparatedAndIntegrated":3,"AccountingDatabaseTest::testPurchaseAdjustmentQueriesAndReportsRunAgainstActualSchema":3,"ApplicationContractTest::testMasterAdminHasFullAccessAndApprovalOverride":3},"times":{"ApplicationContractTest::testInternalControllersUseAuthenticatedBaseController":0.015,"ApplicationContractTest::testAjaxSessionExpiryReturnsJson401":0,"ApplicationContractTest::testResponsiveShellHasDesktopAndMobileRules":0.001,"ApplicationContractTest::testUiPreventsRepeatedSubmitAndHandlesAjaxFailure":0,"AccountingDatabaseTest::testAccountingInvariantsAgainstDevelopmentDatabase":0.9,"FormulaServiceTest::testPayrollFormulaHonorsPrecedenceAndVariables":0,"FormulaServiceTest::testProrataFormulaAndRounding":0,"FormulaServiceTest::testUnknownVariableIsZeroForBackwardCompatibleFormula":0,"FormulaServiceTest::testRejectsDivisionByZero":0.047,"FormulaServiceTest::testRejectsUnsafeTokens":0,"ApplicationContractTest::testMoneyInputsUseCentralParserBeforeAjax":0.001,"ApplicationContractTest::testMasterAdminHasFullAccessAndApprovalOverride":0,"ApplicationContractTest::testBalanceSheetAndProfitLossRemainInNavigation":0,"ApplicationContractTest::testProfessionalReportsHaveIndonesianFinancialStatements":0.001,"ApplicationContractTest::testPurchasePaymentsSeparatePrincipalAndBankCharge":0.001,"ApplicationContractTest::testPurchaseReturnsAreSeparatedAndIntegrated":0.005,"AccountingDatabaseTest::testPurchaseAdjustmentInvariantsAgainstDevelopmentDatabase":0.47,"ApplicationContractTest::testPurchaseAdjustmentsSupportReversalAuditAndProfessionalReports":0.001,"AccountingDatabaseTest::testPurchaseAdjustmentQueriesAndReportsRunAgainstActualSchema":0.615,"ApplicationContractTest::testProfessionalInventoryUsesOneLedgerAndSafeWorkflow":0.001,"AccountingDatabaseTest::testProfessionalInventoryRuntimeAgainstDevelopmentDatabase":0.603,"ApplicationContractTest::testInventoryFinalizationCoversRequiredOperationalScenarios":0.002,"ApplicationContractTest::testInventoryWriteEndpointsAndButtonsArePermissionAware":0.001,"AccountingDatabaseTest::testInventoryIntegrityAndNegativeStockGuardAgainstDevelopmentDatabase":0.611,"AccountingDatabaseTest::testInventoryReconciliationAlwaysProducesAuditableNumbers":0.224,"ApplicationContractTest::testFixedAssetsUseSingleProfessionalWorkflow":0.002,"AccountingDatabaseTest::testFixedAssetIntegrityAndRuntimeQueries":0.473}} \ No newline at end of file +{"version":1,"defects":{"ApplicationContractTest::testInternalControllersUseAuthenticatedBaseController":3,"ApplicationContractTest::testResponsiveShellHasDesktopAndMobileRules":3,"ApplicationContractTest::testUiPreventsRepeatedSubmitAndHandlesAjaxFailure":4,"AccountingDatabaseTest::testAccountingInvariantsAgainstDevelopmentDatabase":3,"ApplicationContractTest::testPurchaseReturnsAreSeparatedAndIntegrated":3,"AccountingDatabaseTest::testPurchaseAdjustmentQueriesAndReportsRunAgainstActualSchema":3,"ApplicationContractTest::testMasterAdminHasFullAccessAndApprovalOverride":3,"ApplicationContractTest::testFixedAssetsUseSingleProfessionalWorkflow":3},"times":{"ApplicationContractTest::testInternalControllersUseAuthenticatedBaseController":0.087,"ApplicationContractTest::testAjaxSessionExpiryReturnsJson401":0.056,"ApplicationContractTest::testResponsiveShellHasDesktopAndMobileRules":0.11,"ApplicationContractTest::testUiPreventsRepeatedSubmitAndHandlesAjaxFailure":0.001,"AccountingDatabaseTest::testAccountingInvariantsAgainstDevelopmentDatabase":0.9,"FormulaServiceTest::testPayrollFormulaHonorsPrecedenceAndVariables":0.013,"FormulaServiceTest::testProrataFormulaAndRounding":0,"FormulaServiceTest::testUnknownVariableIsZeroForBackwardCompatibleFormula":0,"FormulaServiceTest::testRejectsDivisionByZero":0.003,"FormulaServiceTest::testRejectsUnsafeTokens":0,"ApplicationContractTest::testMoneyInputsUseCentralParserBeforeAjax":0.157,"ApplicationContractTest::testMasterAdminHasFullAccessAndApprovalOverride":0.001,"ApplicationContractTest::testBalanceSheetAndProfitLossRemainInNavigation":0,"ApplicationContractTest::testProfessionalReportsHaveIndonesianFinancialStatements":0.001,"ApplicationContractTest::testPurchasePaymentsSeparatePrincipalAndBankCharge":0.002,"ApplicationContractTest::testPurchaseReturnsAreSeparatedAndIntegrated":0.003,"AccountingDatabaseTest::testPurchaseAdjustmentInvariantsAgainstDevelopmentDatabase":0.47,"ApplicationContractTest::testPurchaseAdjustmentsSupportReversalAuditAndProfessionalReports":0.002,"AccountingDatabaseTest::testPurchaseAdjustmentQueriesAndReportsRunAgainstActualSchema":0.615,"ApplicationContractTest::testProfessionalInventoryUsesOneLedgerAndSafeWorkflow":0.003,"AccountingDatabaseTest::testProfessionalInventoryRuntimeAgainstDevelopmentDatabase":0.603,"ApplicationContractTest::testInventoryFinalizationCoversRequiredOperationalScenarios":0.003,"ApplicationContractTest::testInventoryWriteEndpointsAndButtonsArePermissionAware":0.001,"AccountingDatabaseTest::testInventoryIntegrityAndNegativeStockGuardAgainstDevelopmentDatabase":0.611,"AccountingDatabaseTest::testInventoryReconciliationAlwaysProducesAuditableNumbers":0.224,"ApplicationContractTest::testFixedAssetsUseSingleProfessionalWorkflow":0.065,"AccountingDatabaseTest::testFixedAssetIntegrityAndRuntimeQueries":0.473}} \ No newline at end of file diff --git a/application/controllers/Customers.php b/application/controllers/Customers.php index 65f4ea2..7636416 100644 --- a/application/controllers/Customers.php +++ b/application/controllers/Customers.php @@ -7,6 +7,7 @@ class Customers extends MY_Controller { { parent::__construct(); $this->load->model('DynamicModel', 'dm'); + $this->load->library('SalesService'); if (!$this->session->userdata('logged_in')) { redirect('auth'); @@ -15,9 +16,9 @@ class Customers extends MY_Controller { public function index() { - $data = ["active_menu" => "customers"]; + $data = ["active_menu" => "customers", "summary" => $this->salesservice->dashboard()]; $this->load->view('partials/header', $data); - $this->load->view('customers/layout'); + $this->load->view('customers/layout', $data); $this->load->view('partials/footer'); } diff --git a/application/controllers/Invoices.php b/application/controllers/Invoices.php index 90195d4..98b0f4d 100644 --- a/application/controllers/Invoices.php +++ b/application/controllers/Invoices.php @@ -58,14 +58,16 @@ class Invoices extends MY_Controller } public function lookup_items() { - $q=trim((string)$this->input->get('q'));$page=max(1,(int)$this->input->get('page'));$limit=20;$this->db->select("i.id,CONCAT(i.kode_detail,' - ',i.nama_barang) text,i.kode_detail,i.nama_barang,i.harga_jual,i.stok,k.tracking_type,k.unit",false)->from('items i')->join('kode_barang k','k.id=i.kode_id','left')->where(array('i.company_id'=>$this->company(),'i.status'=>'active','i.stok >'=>'0'));if($q!=='')$this->db->group_start()->like('i.kode_detail',$q)->or_like('i.nama_barang',$q)->group_end();$rows=$this->db->order_by('i.nama_barang')->limit($limit+1,($page-1)*$limit)->get()->result();$more=count($rows)>$limit;if($more)array_pop($rows);return json_response(true,'Barang ditemukan.',array('results'=>$rows,'pagination'=>array('more'=>$more))); + $q=trim((string)$this->input->get('q'));$page=max(1,(int)$this->input->get('page'));$limit=20; + $available="(SELECT ib.item_id,SUM(CASE WHEN ib.status IN('available','reserved') THEN GREATEST(ib.qty_sisa-COALESCE(ib.reserved_qty,0)-COALESCE(ib.return_reserved_qty,0),0) ELSE 0 END) available_stock FROM item_barcodes ib GROUP BY ib.item_id) av"; + $this->db->select("i.id,CONCAT(i.kode_detail,' - ',i.nama_barang,' - tersedia ',FORMAT(COALESCE(av.available_stock,0),0,'id_ID')) text,i.kode_detail,i.nama_barang,i.harga_jual,COALESCE(av.available_stock,0) stok,k.tracking_type,k.unit",false)->from('items i')->join('kode_barang k','k.id=i.kode_id','left')->join($available,'av.item_id=i.id','inner',false)->where(array('i.company_id'=>$this->company(),'i.status'=>'active'))->where('av.available_stock >',0);if($q!=='')$this->db->group_start()->like('i.kode_detail',$q)->or_like('i.nama_barang',$q)->group_end();$rows=$this->db->order_by('i.nama_barang')->limit($limit+1,($page-1)*$limit)->get()->result();$more=count($rows)>$limit;if($more)array_pop($rows);return json_response(true,'Barang ditemukan.',array('results'=>$rows,'pagination'=>array('more'=>$more))); } public function lookup_warehouses(){return$this->selectLookup('warehouses','nama',array('nama'),'Gudang ditemukan.');} public function lookup_barcodes() { $q=trim((string)$this->input->get('q'));$item=(int)$this->input->get('item_id');$warehouse=(int)$this->input->get('warehouse_id');$exact=(int)$this->input->get('exact')===1;$page=max(1,(int)$this->input->get('page'));$limit=20; if(!$exact&&(!$item||!$warehouse))return json_response(true,'Pilih gudang dan barang terlebih dahulu.',array('results'=>array(),'pagination'=>array('more'=>false))); - $this->db->select("b.id,CONCAT(b.barcode,IF(NULLIF(b.serial_number,'') IS NULL,'',CONCAT(' / ',b.serial_number)),' - tersedia ',ROUND(b.qty_sisa-b.reserved_qty-COALESCE(b.return_reserved_qty,0),4)) text,b.barcode,b.serial_number,b.item_id,b.warehouse_id,(b.qty_sisa-b.reserved_qty-COALESCE(b.return_reserved_qty,0)) available,i.kode_detail,i.nama_barang,i.harga_jual,k.tracking_type,k.unit,w.nama warehouse_name",false)->from('item_barcodes b')->join('items i','i.id=b.item_id')->join('kode_barang k','k.id=i.kode_id','left')->join('warehouses w','w.id=b.warehouse_id')->where(array('i.company_id'=>$this->company(),'b.status'=>'available'))->where('(b.qty_sisa-b.reserved_qty-COALESCE(b.return_reserved_qty,0)) >',0,false);if($item)$this->db->where('b.item_id',$item);if($warehouse)$this->db->where('b.warehouse_id',$warehouse);if($q!==''){$this->db->group_start();if($exact)$this->db->where('b.barcode',$q)->or_where('b.serial_number',$q);else$this->db->like('b.barcode',$q)->or_like('b.serial_number',$q);$this->db->group_end();}$rows=$this->db->order_by('b.id')->limit($limit+1,($page-1)*$limit)->get()->result();$more=count($rows)>$limit;if($more)array_pop($rows);return json_response(true,'Barcode ditemukan.',array('results'=>$rows,'pagination'=>array('more'=>$more))); + $this->db->select("b.id,CONCAT(b.barcode,IF(NULLIF(b.serial_number,'') IS NULL,'',CONCAT(' / ',b.serial_number)),' - tersedia ',FORMAT(b.qty_sisa-b.reserved_qty-COALESCE(b.return_reserved_qty,0),0,'id_ID')) text,b.barcode,b.serial_number,b.item_id,b.warehouse_id,(b.qty_sisa-b.reserved_qty-COALESCE(b.return_reserved_qty,0)) available,i.kode_detail,i.nama_barang,i.harga_jual,k.tracking_type,k.unit,w.nama warehouse_name",false)->from('item_barcodes b')->join('items i','i.id=b.item_id')->join('kode_barang k','k.id=i.kode_id','left')->join('warehouses w','w.id=b.warehouse_id')->where(array('i.company_id'=>$this->company(),'b.status'=>'available'))->where('(b.qty_sisa-b.reserved_qty-COALESCE(b.return_reserved_qty,0)) >',0,false)->where("(UPPER(COALESCE(k.tracking_type,'QTY'))<>'UNIT' OR NOT EXISTS(SELECT 1 FROM invoice_line_barcodes used JOIN invoice_details used_detail ON used_detail.id=used.invoice_detail_id AND used_detail.stock_posted_at IS NULL JOIN invoices used_invoice ON used_invoice.id=used.invoice_id WHERE used.barcode_id=b.id AND used_invoice.company_id=i.company_id AND used_invoice.deleted_at IS NULL AND COALESCE(used_invoice.status,'')<>'cancelled'))",null,false);if($item)$this->db->where('b.item_id',$item);if($warehouse)$this->db->where('b.warehouse_id',$warehouse);if($q!==''){$this->db->group_start();if($exact)$this->db->where('b.barcode',$q)->or_where('b.serial_number',$q);else$this->db->like('b.barcode',$q)->or_like('b.serial_number',$q);$this->db->group_end();}$rows=$this->db->order_by('b.id')->limit($limit+1,($page-1)*$limit)->get()->result();$more=count($rows)>$limit;if($more)array_pop($rows);return json_response(true,'Barcode ditemukan.',array('results'=>$rows,'pagination'=>array('more'=>$more))); } private function selectLookup($table,$label,array$fields,$message) { @@ -107,7 +109,7 @@ class Invoices extends MY_Controller public function submit($id){return$this->mutation('can_submit',function()use($id){return$this->salesservice->submitInvoice($id,$this->uid());},'Invoice berhasil diajukan.');} public function approve($id){return$this->mutation('can_approve',function()use($id){return$this->salesservice->approveAndPostInvoice($id,$this->uid(),$this->roleId());},'Invoice disetujui dan otomatis diposting.');} public function reject($id){return$this->mutation('can_approve',function()use($id){return$this->salesservice->rejectInvoice($id,$this->input->post('reason',true),$this->uid(),$this->roleId());},'Invoice ditolak dan dikembalikan ke Draft.');} - public function delete_draft($id){return$this->mutation('can_delete',function()use($id){return$this->salesservice->deleteDraftInvoice($id,$this->uid(),is_master_admin_user());},'Draft invoice dibatalkan dan seluruh reservasi dilepas.');} + public function delete_draft($id){return$this->mutation('can_delete',function()use($id){return$this->salesservice->deleteDraftInvoice($id,$this->uid(),is_master_admin_user());},'Draft invoice dibatalkan, stok/barcode tersedia kembali, dan jurnal awal telah dibalik.');} public function pdf($id) { $this->requirePermission('invoices','can_export');$this->guardView($this->salesservice->invoiceForEditor($id));$data=$this->salesservice->invoiceDetail($id);$includePrevious=(int)$this->input->get('include_previous')===1;$data['previous_invoices']=$includePrevious?$this->salesservice->previousOutstandingInvoices($id):array();$this->addPdfCompanySettings($data);require_once FCPATH.'vendor/autoload.php';$html=$this->load->view('sales/invoice_pdf',$data,true);$pdf=new Dompdf\Dompdf();$pdf->loadHtml($html);$pdf->setPaper('A4','portrait');$pdf->render();$pdf->stream('Invoice-'.$data['invoice']->no_invoice.'.pdf',array('Attachment'=>false)); diff --git a/application/controllers/Items.php b/application/controllers/Items.php index ce31489..2a41bb4 100644 --- a/application/controllers/Items.php +++ b/application/controllers/Items.php @@ -693,6 +693,13 @@ class Items extends MY_Controller { return json_response(false, 'Barang tidak ditemukan.', array(), 404); } + if ($item->status === 'active') { + $availability = $this->db->query("SELECT COUNT(*) barcode_count,COALESCE(SUM(CASE WHEN status IN('available','reserved') THEN GREATEST(qty_sisa-COALESCE(reserved_qty,0)-COALESCE(return_reserved_qty,0),0) ELSE 0 END),0) available_stock,COALESCE(SUM(COALESCE(reserved_qty,0)+COALESCE(return_reserved_qty,0)),0) reserved_stock FROM item_barcodes WHERE item_id=?", array((int)$item->id))->row(); + $item->physical_stock = (float)$item->stok; + $item->available_stock = $availability && (int)$availability->barcode_count > 0 ? (float)$availability->available_stock : (float)$item->stok; + $item->reserved_stock = $availability ? (float)$availability->reserved_stock : 0; + } + $logs = $this->db ->select('stock_logs.*, warehouses.nama as gudang') ->join('warehouses','warehouses.id = stock_logs.warehouse_id','left') diff --git a/application/controllers/Receivables.php b/application/controllers/Receivables.php index 9fc220b..f891f1b 100644 --- a/application/controllers/Receivables.php +++ b/application/controllers/Receivables.php @@ -12,7 +12,7 @@ class Receivables extends MY_Controller { $focusId=(int)$this->input->get('invoice_id');$focus=null; if($focusId)$focus=$this->db->select('i.id,i.customer_id,i.no_invoice,i.sisa_piutang,c.nama customer_name')->from('invoices i')->join('customers c','c.id=i.customer_id')->where(array('i.id'=>$focusId,'i.company_id'=>$this->company(),'i.workflow_status'=>'posted'))->where('i.sisa_piutang >',0)->get()->row(); - $data=array('active_menu'=>'receivables','aging'=>$this->receivableservice->aging($this->input->get('as_of')?:date('Y-m-d')),'focus_invoice'=>$focus,'can_post'=>is_master_admin_user()||check_permission('invoices','can_post')); + $data=array('active_menu'=>'receivables','aging'=>$this->receivableservice->aging($this->input->get('as_of')?:date('Y-m-d')),'focus_invoice'=>$focus,'can_post'=>is_master_admin_user()||check_permission('invoices','can_post'),'summary'=>$this->salesservice->dashboard()); $this->load->view('partials/header',$data);$this->load->view('receivables/index',$data);$this->load->view('partials/footer',$data); } diff --git a/application/controllers/Sales.php b/application/controllers/Sales.php index 4787b03..fa0ef8c 100644 --- a/application/controllers/Sales.php +++ b/application/controllers/Sales.php @@ -57,7 +57,34 @@ class Sales extends MY_Controller public function return_pdf($id){$data=$this->salesservice->returnDetail($id);$this->pdf('sales/return_pdf',$data,'Retur-'.$data['return']->return_no.'.pdf');} public function receipt_pdf($id) { - $company=(int)$this->companycontext->id();$data['payment']=$this->db->select('p.*,c.nama customer_name,c.alamat')->from('payments p')->join('customers c','c.id=p.customer_id')->where(array('p.id'=>(int)$id,'p.company_id'=>$company))->get()->row();if(!$data['payment'])show_404();$data['allocations']=$this->db->select('a.*,i.no_invoice')->from('payment_allocations a')->join('invoices i','i.id=a.invoice_id')->where(array('a.payment_id'=>(int)$id,'i.company_id'=>$company))->get()->result();$data['sources']=$this->db->select('s.*,a.kode_akun,a.nama_akun')->from('payment_sources s')->join('accounts a','a.id=s.account_id')->where(array('s.payment_id'=>(int)$id,'s.company_id'=>$company))->get()->result();$this->pdf('sales/receipt_pdf',$data,'Bukti-Penerimaan-'.$data['payment']->payment_no.'.pdf'); + $company=(int)$this->companycontext->id(); + $data['payment']=$this->db + ->select('p.*,c.nama customer_name,c.alamat customer_address,c.telp customer_phone,c.email customer_email,u.nama creator_name') + ->from('payments p') + ->join('customers c','c.id=p.customer_id') + ->join('users u','u.id=p.created_by','left') + ->where(array('p.id'=>(int)$id,'p.company_id'=>$company)) + ->get() + ->row(); + if(!$data['payment'])show_404(); + $data['allocations']=$this->db + ->select('a.*,i.no_invoice,i.tanggal invoice_date,i.jatuh_tempo') + ->from('payment_allocations a') + ->join('invoices i','i.id=a.invoice_id') + ->where(array('a.payment_id'=>(int)$id,'i.company_id'=>$company)) + ->order_by('i.tanggal','ASC') + ->order_by('i.id','ASC') + ->get() + ->result(); + $data['sources']=$this->db + ->select('s.*,a.kode_akun,a.nama_akun') + ->from('payment_sources s') + ->join('accounts a','a.id=s.account_id') + ->where(array('s.payment_id'=>(int)$id,'s.company_id'=>$company)) + ->order_by('s.id','ASC') + ->get() + ->result(); + $this->pdf('sales/receipt_pdf',$data,'Bukti-Penerimaan-'.$data['payment']->payment_no.'.pdf'); } private function decodeRows(){$rows=json_decode((string)$this->input->post('rows'),true);if(!is_array($rows))throw new BusinessException('Detail barang tidak valid.');return$rows;} private function action($fn,$message){if(strtoupper($this->input->method())!=='POST')return json_response(false,'Metode tidak diizinkan.',array(),405);try{return json_response(true,$message,array('result'=>$fn()));}catch(Throwable$e){return business_exception_response($e);}} diff --git a/application/libraries/SalesService.php b/application/libraries/SalesService.php index 0cfc03c..9f8100d 100644 --- a/application/libraries/SalesService.php +++ b/application/libraries/SalesService.php @@ -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); } diff --git a/application/views/customers/layout.php b/application/views/customers/layout.php index 8ac84e1..1634030 100644 --- a/application/views/customers/layout.php +++ b/application/views/customers/layout.php @@ -1,4 +1,10 @@ -
+ +
+
+
SIKLUS PENJUALAN

Customer

Kelola identitas, termin pembayaran, dan kebijakan kredit customer.

+
+ load->view('sales/_section_nav',array('sales_section'=>'customer','sales_summary'=>$summary)); ?> +
@@ -24,9 +30,10 @@
-
- - +
+ + +