Files
2026-09-11 16:03:00 +07:00

664 lines
88 KiB
PHP

<?php
defined('BASEPATH') OR exit('No direct script access allowed');
require_once APPPATH.'exceptions/BusinessException.php';
/**
* Domain service untuk Retur Pembelian, Barang Pengganti, Refund Supplier,
* dan Debit Note. Semua perubahan stok/accounting dilakukan di sini agar
* endpoint, UI, dan proses ulang tidak dapat menciptakan mutasi ganda.
*/
class PurchaseAdjustmentService
{
private $CI;
public function __construct()
{
$this->CI =& get_instance();
$this->CI->load->library(array(
'TransactionService','NumberingService','ApprovalService','AuditService',
'PostingService','AccountMappingService','FiscalPeriodService','JournalWorkflowService'
));
$this->CI->load->model('InventoryModel', 'adjustmentInventoryModel');
}
public function returnSources($supplierId = 0)
{
$company = $this->companyId();
$this->CI->db->select(
"grl.id receipt_line_id,gr.id goods_receipt_id,gr.receipt_no,gr.receipt_date,".
"po.id purchase_order_id,po.po_no,po.supplier_id,s.supplier_code,s.name supplier_name,".
"pol.id po_line_id,pol.description,pol.tracking_type,pol.need_serial_number,".
"grl.item_id,grl.warehouse_id,grl.qty received_qty,w.nama warehouse_name,".
"k.kode_barang,k.nama item_name,COALESCE(i.harga_beli,pol.unit_price) unit_cost,".
"COALESCE((SELECT SUM(x.qty) FROM purchase_return_lines x JOIN purchase_returns rh ON rh.id=x.purchase_return_id WHERE x.receipt_line_id=grl.id AND rh.status NOT IN('draft','rejected','cancelled','reversed')),0) committed_return_qty,".
"GREATEST(0,grl.qty-COALESCE((SELECT SUM(x.qty) FROM purchase_return_lines x JOIN purchase_returns rh ON rh.id=x.purchase_return_id WHERE x.receipt_line_id=grl.id AND rh.status NOT IN('draft','rejected','cancelled','reversed')),0)) returnable_qty",
false
)->from('goods_receipt_lines grl')
->join('goods_receipts gr','gr.id=grl.goods_receipt_id')
->join('purchase_order_lines pol','pol.id=grl.po_line_id')
->join('purchase_orders po','po.id=pol.purchase_order_id')
->join('suppliers s','s.id=po.supplier_id')
->join('warehouses w','w.id=grl.warehouse_id','left')
->join('items i','i.id=grl.item_id','left')
->join('kode_barang k','k.id=pol.kode_barang_id','left')
->where('gr.status','posted')
->having('returnable_qty >', 0);
if ($supplierId) $this->CI->db->where('po.supplier_id', (int)$supplierId);
if ($this->CI->db->field_exists('company_id','purchase_orders')) $this->CI->db->where('po.company_id', $company);
return $this->CI->db->order_by('gr.receipt_date','DESC')->order_by('gr.id','DESC')->get()->result_array();
}
public function barcodesForReceiptLine($receiptLineId)
{
return $this->CI->db->select('ib.id,ib.barcode,ib.serial_number,ib.qty_sisa,ib.return_reserved_qty,ib.status,pol.tracking_type')
->from('item_barcodes ib')
->join('goods_receipt_lines grl','grl.id=ib.source_receipt_line_id')
->join('purchase_order_lines pol','pol.id=grl.po_line_id')
->join('purchase_orders po','po.id=pol.purchase_order_id')
->where('ib.source_receipt_line_id',(int)$receiptLineId)
->where('po.company_id',$this->companyId())
->where('ib.qty_sisa > ib.return_reserved_qty', null, false)
->where_in('ib.status', array('available','pending','return_reserved'))
->order_by('ib.barcode')->get()->result_array();
}
public function createReturn(array $data, array $inputLines, $user)
{
return $this->CI->transactionservice->run(function () use ($data, $inputLines, $user) {
$date = $this->validDate($data['return_date'] ?? null, 'Tanggal pengajuan retur');
$reason = trim((string)($data['reason'] ?? ''));
if ($reason === '') throw new BusinessException('Alasan retur wajib diisi.');
$category = $this->enum($data['problem_category'] ?? '', array('damaged','wrong_item','specification','over_delivery','po_mismatch','other'), 'other');
$resolution = $this->enum($data['requested_resolution'] ?? '', array('replacement','debit_note','refund','undecided'), 'undecided');
$idempotency = trim((string)($data['idempotency_key'] ?? ''));
if ($idempotency === '') throw new BusinessException('Token transaksi retur tidak tersedia. Muat ulang form lalu coba lagi.');
$existing = $this->CI->db->get_where('purchase_returns', array('idempotency_key'=>$idempotency))->row();
if ($existing) return (int)$existing->id;
if (!$inputLines) throw new BusinessException('Minimal satu barang wajib dipilih untuk retur.');
$normalized = array(); $supplierId = 0; $primaryPo = 0; $primaryReceipt = 0; $amount = 0;
foreach ($inputLines as $input) {
$line = $this->sourceLine((int)($input['receipt_line_id'] ?? 0), false);
if (!$line) throw new BusinessException('Salah satu detail penerimaan tidak ditemukan.');
if (!$supplierId) $supplierId = (int)$line->supplier_id;
if ($supplierId !== (int)$line->supplier_id) throw new BusinessException('Satu batch retur hanya boleh berisi barang dari satu supplier.');
$primaryPo = $primaryPo ?: (int)$line->purchase_order_id;
$primaryReceipt = $primaryReceipt ?: (int)$line->goods_receipt_id;
$barcodeId = (int)($input['barcode_id'] ?? 0);
$qty = round((float)($input['qty'] ?? 0), 4);
$barcode = $this->validateBarcode($line, $barcodeId, $qty, false);
$committed = $this->committedReturnQty($line->receipt_line_id);
$returnable = round((float)$line->received_qty - $committed, 4);
if ($qty <= 0 || $qty > $returnable + .0001) throw new BusinessException('Qty retur '.$line->description.' melebihi saldo penerimaan yang dapat diretur.');
$unitCost = round((float)$line->unit_cost, 4);
$base = round($qty * $unitCost, 2);
$tax = $this->returnTaxForLine($line->receipt_line_id, $qty);
$normalized[] = array('source'=>$line,'barcode'=>$barcode,'qty'=>$qty,'committed'=>$committed,'returnable'=>$returnable,'unit_cost'=>$unitCost,'tax'=>$tax,'base'=>$base);
$amount += $base + $tax;
}
$number = $this->CI->numberingservice->next('purchase_return', $date);
$this->CI->db->insert('purchase_returns', array(
'company_id'=>$this->companyId(),'return_no'=>$number,'supplier_id'=>$supplierId,
'purchase_order_id'=>$primaryPo,'goods_receipt_id'=>$primaryReceipt,'return_date'=>$date,
'amount'=>round($amount,2),'reason'=>$reason,'problem_category'=>$category,
'requested_resolution'=>$resolution,'notes'=>trim((string)($data['notes'] ?? '')) ?: null,
'status'=>'draft','idempotency_key'=>$idempotency,'created_by'=>(int)$user,'created_at'=>date('Y-m-d H:i:s')
));
$returnId = (int)$this->CI->db->insert_id();
foreach ($normalized as $row) {
$line = $row['source']; $barcode = $row['barcode'];
$this->CI->db->insert('purchase_return_lines', array(
'company_id'=>$this->companyId(),'purchase_return_id'=>$returnId,
'purchase_order_id'=>$line->purchase_order_id,'goods_receipt_id'=>$line->goods_receipt_id,
'receipt_line_id'=>$line->receipt_line_id,'item_id'=>$line->item_id,
'barcode_id'=>$barcode ? $barcode->id : null,'barcode_snapshot'=>$barcode ? $barcode->barcode : null,
'serial_snapshot'=>$barcode ? $barcode->serial_number : null,'warehouse_id'=>$line->warehouse_id,
'description'=>$line->description,'tracking_type'=>$line->tracking_type,
'received_qty'=>$line->received_qty,'previous_return_qty'=>$row['committed'],
'returnable_qty'=>$row['returnable'],'qty'=>$row['qty'],'unit_cost'=>$row['unit_cost'],
'tax_amount'=>$row['tax'],'total_amount'=>$row['base']+$row['tax'],
'requested_resolution'=>$resolution,'status'=>'draft'
));
}
$this->history($returnId, null, 'draft', $user, 'Draft pengajuan retur dibuat.');
$this->CI->auditservice->record('purchase_return','purchase_return',$returnId,'create',null,array('return_no'=>$number,'supplier_id'=>$supplierId,'line_count'=>count($normalized),'amount'=>$amount),$user);
return $returnId;
});
}
public function submitReturn($returnId, $user)
{
return $this->CI->transactionservice->run(function () use ($returnId, $user) {
$header = $this->lockReturn($returnId, array('draft'));
if ((int)$header->created_by !== (int)$user && !$this->isMasterAdmin()) throw new BusinessException('Hanya pembuat draft yang dapat mengajukan retur.');
if (!$this->hasAttachment('purchase_return', $returnId)) throw new BusinessException('Lampiran bukti pengajuan retur wajib tersedia.');
$lines = $this->CI->db->where('purchase_return_id',$returnId)->get('purchase_return_lines')->result();
if (!$lines) throw new BusinessException('Pengajuan retur belum memiliki barang.');
foreach ($lines as $line) $this->assertReturnLineAvailable($line, false);
$this->CI->approvalservice->create('purchase_return','purchase_return',$returnId,$header->amount,$user,array('return'=>$header,'lines'=>$lines));
$this->CI->db->where('id',$returnId)->update('purchase_returns',array('status'=>'submitted','submitted_by'=>(int)$user,'submitted_at'=>date('Y-m-d H:i:s'),'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s')));
$this->history($returnId, 'draft', 'submitted', $user, 'Pengajuan retur dikirim untuk persetujuan.');
return $returnId;
});
}
public function approveReturn($returnId, $user, $roleId)
{
return $this->CI->transactionservice->run(function () use ($returnId, $user, $roleId) {
$header = $this->lockReturn($returnId, array('submitted'));
$request = $this->CI->approvalservice->pendingForEntity('purchase_return',$returnId);
if (!$request) throw new BusinessException('Permintaan persetujuan retur tidak ditemukan.');
$approval = $this->CI->approvalservice->act($request->id,'approved',$user,$roleId);
if ($approval->status !== 'approved') return $returnId;
$this->CI->db->where('id',$returnId)->update('purchase_returns',array('status'=>'approved','approved_by'=>(int)$user,'approved_at'=>date('Y-m-d H:i:s')));
$this->history($returnId, 'submitted', 'approved', $user, 'Retur disetujui; sistem melanjutkan reservasi barang.');
foreach ($this->CI->db->where('purchase_return_id',$returnId)->get('purchase_return_lines')->result() as $line) {
$this->assertReturnLineAvailable($line, true);
if ($line->barcode_id) {
$barcode = $this->CI->db->query('SELECT * FROM item_barcodes WHERE id=? FOR UPDATE',array((int)$line->barcode_id))->row();
$newReserved = round((float)$barcode->return_reserved_qty + (float)$line->qty, 4);
$update = array('return_reserved_qty'=>$newReserved);
if ($line->tracking_type === 'UNIT' || $newReserved >= (float)$barcode->qty_sisa-.0001) $update['status']='return_reserved';
$this->CI->db->where('id',$barcode->id)->update('item_barcodes',$update);
}
$this->CI->db->where('id',$line->id)->update('purchase_return_lines',array('reserved_qty'=>$line->qty,'status'=>'reserved'));
}
$this->CI->db->where('id',$returnId)->update('purchase_returns',array('status'=>'reserved','reserved_by'=>(int)$user,'reserved_at'=>date('Y-m-d H:i:s'),'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s')));
$this->history($returnId, 'approved', 'reserved', $user, 'Barcode/batch telah direservasi khusus untuk retur; stok belum berkurang.');
$this->CI->auditservice->record('purchase_return','purchase_return',$returnId,'approve_and_reserve',$header,array('status'=>'reserved'),$user);
return $returnId;
});
}
public function rejectReturn($returnId, $reason, $user, $roleId)
{
return $this->CI->transactionservice->run(function () use ($returnId, $reason, $user, $roleId) {
$header = $this->lockReturn($returnId, array('submitted'));
$reason = trim((string)$reason);
if ($reason === '') throw new BusinessException('Alasan penolakan wajib diisi.');
$request = $this->CI->approvalservice->pendingForEntity('purchase_return',$returnId);
if (!$request) throw new BusinessException('Permintaan persetujuan retur tidak ditemukan.');
$this->CI->approvalservice->act($request->id,'rejected',$user,$roleId,$reason);
$this->CI->db->where('id',$returnId)->update('purchase_returns',array('status'=>'rejected','rejected_by'=>(int)$user,'rejected_at'=>date('Y-m-d H:i:s'),'rejection_reason'=>$reason));
$this->history($returnId, 'submitted', 'rejected', $user, $reason);
$this->CI->auditservice->record('purchase_return','purchase_return',$returnId,'reject',$header,array('status'=>'rejected','reason'=>$reason),$user);
return $returnId;
});
}
public function postReturnShipment($returnId, array $shipment, $user)
{
return $this->CI->transactionservice->run(function () use ($returnId, $shipment, $user) {
$header = $this->lockReturn($returnId, array('reserved'));
$date = $this->validDate($shipment['shipment_date'] ?? null, 'Tanggal pengiriman retur');
$this->CI->fiscalperiodservice->assertOpen($date);
$key = trim((string)($shipment['idempotency_key'] ?? ''));
if ($key === '') throw new BusinessException('Token posting pengiriman tidak tersedia.');
$existing = $this->CI->db->get_where('purchase_return_shipments',array('idempotency_key'=>$key))->row();
if ($existing) return (int)$existing->id;
if (!$this->hasAttachment('purchase_return_shipment', $returnId) && empty($shipment['attachment_pending'])) throw new BusinessException('Bukti pengiriman retur wajib tersedia.');
$supplier = $this->CI->db->get_where('suppliers',array('id'=>$header->supplier_id))->row();
$number = $this->CI->numberingservice->next('purchase_return_shipment',$date);
$this->CI->db->insert('purchase_return_shipments',array('company_id'=>$this->companyId(),'shipment_no'=>$number,'purchase_return_id'=>$returnId,'shipment_date'=>$date,'courier'=>trim((string)($shipment['courier']??''))?:null,'tracking_no'=>trim((string)($shipment['tracking_no']??''))?:null,'notes'=>trim((string)($shipment['notes']??''))?:null,'status'=>'posted','idempotency_key'=>$key,'posted_by'=>(int)$user,'posted_at'=>date('Y-m-d H:i:s'),'created_at'=>date('Y-m-d H:i:s')));
$shipmentId = (int)$this->CI->db->insert_id();
$accounting = array('inventory'=>0.0,'tax'=>0.0,'payable'=>0.0,'refund'=>0.0,'posted_inventory'=>false,'invoice_ids'=>array());
$lineNames = array();
foreach ($this->CI->db->where('purchase_return_id',$returnId)->order_by('id')->get('purchase_return_lines')->result() as $line) {
if ($line->status !== 'reserved' || (float)$line->reserved_qty < (float)$line->qty-.0001) throw new BusinessException('Semua barang retur harus berstatus reserved sebelum dikirim.');
$source = $this->sourceLine($line->receipt_line_id, true);
$barcode = $line->barcode_id ? $this->CI->db->query('SELECT * FROM item_barcodes WHERE id=? FOR UPDATE',array((int)$line->barcode_id))->row() : null;
if (!$barcode || (float)$barcode->qty_sisa < (float)$line->qty-.0001 || (float)$barcode->return_reserved_qty < (float)$line->qty-.0001) throw new BusinessException('Barcode/batch '.$line->barcode_snapshot.' tidak lagi tersedia untuk retur.');
$remaining = round((float)$barcode->qty_sisa-(float)$line->qty,4);
$reserved = max(0,round((float)$barcode->return_reserved_qty-(float)$line->qty,4));
$barcodeStatus = $remaining <= .0001 ? 'returned' : 'available';
$this->CI->db->where('id',$barcode->id)->update('item_barcodes',array('qty_sisa'=>$remaining,'return_reserved_qty'=>$reserved,'status'=>$barcodeStatus,'version'=>(int)$barcode->version+1));
$detail = '[AUTO] Pengeluaran retur '.$header->return_no.' / '.$number.' ke '.($supplier?$supplier->supplier_code.' - '.$supplier->name:'Supplier #'.$header->supplier_id).'; PO '.$source->po_no.'; penerimaan '.$source->receipt_no.'; barang '.$source->item_code.' - '.$source->item_name.'; barcode '.$barcode->barcode.($barcode->serial_number?'; SN '.$barcode->serial_number:'').'; qty '.number_format($line->qty,4,'.','').'; alasan '.$header->reason.'; diproses user #'.$user;
$stockKey = 'PURCHASE-RETURN-STOCK-'.$line->id;
$this->CI->db->insert('stock_logs',array('item_id'=>$line->item_id,'barcode_id'=>$barcode->id,'warehouse_id'=>$line->warehouse_id,'qty'=>$line->qty,'tipe'=>'keluar','activity'=>'supplier_return','keterangan'=>substr($detail,0,255),'ref_type'=>'purchase_return','ref_id'=>$returnId,'idempotency_key'=>$stockKey,'unit_cost'=>$line->unit_cost));
$stockLogId = (int)$this->CI->db->insert_id();
$this->CI->db->insert('item_movements',array('item_id'=>$line->item_id,'barcode_id'=>$barcode->id,'qty'=>$line->qty,'from_type'=>'warehouse','from_id'=>$line->warehouse_id,'to_type'=>'supplier','to_id'=>$header->supplier_id,'movement_type'=>'purchase_return','reference_type'=>'purchase_return_line','reference_id'=>$line->id,'notes'=>substr($detail,0,255),'created_at'=>date('Y-m-d H:i:s')));
$movementId = (int)$this->CI->db->insert_id();
$ledgerId = $this->writeLedger('purchase_return',$returnId,$line->id,$line->item_id,$line->warehouse_id,$barcode->id,'out',$line->qty,$line->unit_cost,$date,'PURCHASE-RETURN-LEDGER-'.$line->id);
$this->CI->db->where('id',$line->id)->update('purchase_return_lines',array('shipped_qty'=>$line->qty,'stock_log_id'=>$stockLogId,'movement_id'=>$movementId,'ledger_id'=>$ledgerId,'status'=>'shipped'));
$this->CI->db->insert('purchase_return_shipment_lines',array('purchase_return_shipment_id'=>$shipmentId,'purchase_return_line_id'=>$line->id,'qty'=>$line->qty,'created_at'=>date('Y-m-d H:i:s')));
$this->CI->adjustmentInventoryModel->synchronizeStock($line->item_id);
$lineNames[] = $source->item_code.' '.$source->item_name.' x '.$line->qty;
$this->collectReturnAccounting($line, $source, $accounting, $user);
}
$journalId = $this->postReturnAccounting($header,$supplier,$date,$lineNames,$accounting,$user);
$this->CI->db->where('id',$returnId)->update('purchase_returns',array('status'=>'waiting_supplier','journal_id'=>$journalId,'shipped_by'=>(int)$user,'shipped_at'=>date('Y-m-d H:i:s'),'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s')));
$this->history($returnId, 'reserved', 'shipped', $user, 'Barang dikeluarkan dari gudang melalui '.$number.'.');
$this->history($returnId, 'shipped', 'waiting_supplier', $user, 'Menunggu penggantian barang, debit note, atau refund dari supplier.');
$this->CI->auditservice->record('purchase_return','purchase_return',$returnId,'post_shipment',array('status'=>'reserved'),array('status'=>'waiting_supplier','shipment_no'=>$number,'journal_id'=>$journalId),$user);
return $shipmentId;
});
}
/** Membatalkan dokumen yang belum posted tanpa menghapus histori. */
public function cancelReturn($returnId, $reason, $user)
{
return $this->CI->transactionservice->run(function () use ($returnId, $reason, $user) {
$reason = trim((string) $reason);
if ($reason === '') throw new BusinessException('Alasan pembatalan retur wajib diisi.');
$header = $this->lockReturn($returnId, array('draft','submitted','rejected','reserved'));
if ($header->status === 'draft' && (int)$header->created_by !== (int)$user && !$this->isMasterAdmin()) {
throw new BusinessException('Draft retur hanya dapat dibatalkan oleh pembuatnya.');
}
if ($header->status === 'submitted') {
$request = $this->CI->approvalservice->pendingForEntity('purchase_return', $returnId);
if ($request) $this->CI->db->where('id', $request->id)->update('approval_requests', array('status'=>'cancelled','completed_at'=>date('Y-m-d H:i:s')));
}
if ($header->status === 'reserved') {
foreach ($this->CI->db->where('purchase_return_id',$returnId)->get('purchase_return_lines')->result() as $line) {
if (!$line->barcode_id) continue;
$barcode = $this->CI->db->query('SELECT * FROM item_barcodes WHERE id=? FOR UPDATE', array((int)$line->barcode_id))->row();
if (!$barcode) continue;
$reserved = max(0, round((float)$barcode->return_reserved_qty-(float)$line->reserved_qty, 4));
$status = $barcode->status;
if ($status === 'return_reserved') $status = empty($barcode->activated_at) ? 'pending' : 'available';
$this->CI->db->where('id',$barcode->id)->update('item_barcodes',array('return_reserved_qty'=>$reserved,'status'=>$status,'version'=>(int)$barcode->version+1));
}
}
$this->CI->db->where('purchase_return_id',$returnId)->where_in('status',array('draft','reserved'))->update('purchase_return_lines',array('status'=>'draft','reserved_qty'=>0));
$this->CI->db->where('id',$returnId)->update('purchase_returns',array('status'=>'cancelled','cancelled_by'=>(int)$user,'cancelled_at'=>date('Y-m-d H:i:s'),'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s')));
$this->history($returnId,$header->status,'cancelled',$user,$reason);
$this->CI->auditservice->record('purchase_return','purchase_return',$returnId,'cancel',$header,array('status'=>'cancelled','reason'=>$reason),$user);
return $returnId;
});
}
/**
* Reversal formal untuk retur yang sudah keluar. Dokumen turunan yang telah
* menerima penyelesaian harus direversal lebih dahulu agar jejak subledger
* tidak terputus.
*/
public function reverseReturn($returnId, $date, $reason, $user)
{
return $this->CI->transactionservice->run(function () use ($returnId, $date, $reason, $user) {
$reason = trim((string) $reason);
if ($reason === '') throw new BusinessException('Alasan reversal retur wajib diisi.');
$date = $this->validDate($date, 'Tanggal reversal retur');
$this->CI->fiscalperiodservice->assertOpen($date);
$header = $this->lockReturn($returnId, array('shipped','waiting_supplier','partially_resolved'));
$replacementCount = $this->CI->db->where('purchase_return_id',$returnId)->where_not_in('status',array('cancelled','reversed'))->count_all_results('purchase_replacements');
$receivedRefund = $this->CI->db->select('COUNT(rr.id) total',false)->from('supplier_refund_receipts rr')->join('supplier_refund_claims rc','rc.id=rr.supplier_refund_claim_id')->where('rc.purchase_return_id',$returnId)->where('rr.status !=','reversed')->get()->row();
if ($replacementCount || ($receivedRefund && (int)$receivedRefund->total > 0)) {
throw new BusinessException('Retur sudah memiliki barang pengganti atau dana refund. Reverse dokumen penyelesaian tersebut lebih dahulu.');
}
$supplier = $this->CI->db->get_where('suppliers',array('id'=>$header->supplier_id))->row();
foreach ($this->CI->db->where('purchase_return_id',$returnId)->get('purchase_return_lines')->result() as $line) {
if ((float)$line->shipped_qty <= 0) continue;
$barcode = $this->CI->db->query('SELECT * FROM item_barcodes WHERE id=? FOR UPDATE',array((int)$line->barcode_id))->row();
if (!$barcode) throw new BusinessException('Barcode sumber reversal retur tidak ditemukan.');
$newQty = round((float)$barcode->qty_sisa+(float)$line->shipped_qty,4);
$newStatus = empty($barcode->activated_at) ? 'pending' : 'available';
$this->CI->db->where('id',$barcode->id)->update('item_barcodes',array('qty_sisa'=>$newQty,'return_reserved_qty'=>0,'status'=>$newStatus,'version'=>(int)$barcode->version+1));
$detail='[AUTO] Reversal retur '.$header->return_no.'; barang '.$line->description.'; barcode '.$line->barcode_snapshot.($line->serial_snapshot?'; SN '.$line->serial_snapshot:'').'; qty '.number_format($line->shipped_qty,4,'.','').'; alasan '.$reason.'; diproses user #'.$user;
$this->CI->db->insert('stock_logs',array('item_id'=>$line->item_id,'barcode_id'=>$barcode->id,'warehouse_id'=>$line->warehouse_id,'qty'=>$line->shipped_qty,'tipe'=>'masuk','activity'=>'return_reversal','keterangan'=>substr($detail,0,255),'ref_type'=>'purchase_return_reversal','ref_id'=>$returnId,'idempotency_key'=>'PURCHASE-RETURN-REVERSAL-STOCK-'.$line->id,'unit_cost'=>$line->unit_cost));
$this->CI->db->insert('item_movements',array('item_id'=>$line->item_id,'barcode_id'=>$barcode->id,'qty'=>$line->shipped_qty,'from_type'=>'supplier','from_id'=>$header->supplier_id,'to_type'=>'warehouse','to_id'=>$line->warehouse_id,'movement_type'=>'purchase_return_reversal','reference_type'=>'purchase_return_line','reference_id'=>$line->id,'notes'=>substr($detail,0,255),'created_at'=>date('Y-m-d H:i:s')));
$this->writeLedger('purchase_return_reversal',$returnId,$line->id,$line->item_id,$line->warehouse_id,$barcode->id,'in',$line->shipped_qty,$line->unit_cost,$date,'PURCHASE-RETURN-REVERSAL-LEDGER-'.$line->id);
$this->CI->db->where('id',$line->id)->update('purchase_return_lines',array('status'=>'reversed'));
$this->CI->adjustmentInventoryModel->synchronizeStock($line->item_id);
}
$reversalJournalId = null;
if (!empty($header->journal_id)) {
$reversal = $this->CI->journalworkflowservice->reverse((int)$header->journal_id,$date,$user,'Reversal retur '.$header->return_no.': '.$reason);
$reversalJournalId = (int)$reversal['reversal_id'];
}
foreach ($this->CI->db->where('purchase_return_id',$returnId)->where_in('status',array('posted','applied'))->get('supplier_debit_notes')->result() as $note) {
$refundClaim = $this->CI->db->where('source_type','debit_note')->where('purchase_return_id',$returnId)->where_not_in('status',array('rejected','cancelled','reversed'))->get('supplier_refund_claims')->row();
$refundPart = $refundClaim ? (float)$refundClaim->claim_amount : 0;
$applied = max(0, round((float)$note->amount-$refundPart,2));
if ($note->supplier_invoice_id && $applied > 0) {
$invoice = $this->CI->db->query('SELECT * FROM supplier_invoices WHERE id=? FOR UPDATE',array((int)$note->supplier_invoice_id))->row();
if ($invoice) {
$balance = min((float)$invoice->total,round((float)$invoice->balance+$applied,2));
$this->CI->db->where('id',$invoice->id)->update('supplier_invoices',array('balance'=>$balance,'status'=>$balance>.001?'partial':'paid'));
}
}
$this->CI->db->where('id',$note->id)->update('supplier_debit_notes',array('status'=>'reversed','reversal_journal_id'=>$reversalJournalId,'reversal_reason'=>$reason,'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s')));
$this->debitNoteHistory($note->id,$note->status,'reversed',$user,$reason);
}
foreach ($this->CI->db->where('purchase_return_id',$returnId)->where_not_in('status',array('rejected','cancelled','reversed'))->get('supplier_refund_claims')->result() as $claim) {
if ((float)$claim->received_amount > .001) throw new BusinessException('Dana refund sudah diterima dan wajib direversal lebih dahulu.');
$this->CI->db->where('id',$claim->id)->update('supplier_refund_claims',array('status'=>'reversed','reversal_journal_id'=>$reversalJournalId,'reversal_reason'=>$reason,'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s')));
$this->refundHistory($claim->id,$claim->status,'reversed',$user,$reason);
}
$this->CI->db->where('purchase_return_id',$returnId)->where('status !=','reversed')->update('purchase_return_shipments',array('status'=>'reversed'));
$this->CI->db->where('id',$returnId)->update('purchase_returns',array('status'=>'reversed','reversal_journal_id'=>$reversalJournalId,'reversal_reason'=>$reason,'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s')));
$this->history($returnId,$header->status,'reversed',$user,$reason);
$this->CI->auditservice->record('purchase_return','purchase_return',$returnId,'reverse',$header,array('status'=>'reversed','reversal_journal_id'=>$reversalJournalId,'reason'=>$reason),$user);
return $returnId;
});
}
public function receiveReplacement($returnId, array $data, array $quantities, array $serials, $user)
{
return $this->CI->transactionservice->run(function () use ($returnId, $data, $quantities, $serials, $user) {
$header = $this->lockReturn($returnId, array('shipped','waiting_supplier','partially_resolved'));
$date = $this->validDate($data['receipt_date'] ?? null, 'Tanggal penerimaan pengganti');
$key = trim((string)($data['idempotency_key'] ?? ''));
if ($key === '') throw new BusinessException('Token penerimaan barang pengganti tidak tersedia.');
$existing = $this->CI->db->get_where('purchase_replacements',array('idempotency_key'=>$key))->row();
if ($existing) return (int)$existing->id;
if (!$this->hasAttachment('purchase_replacement', $returnId) && empty($data['attachment_pending'])) throw new BusinessException('Bukti penerimaan barang pengganti wajib tersedia.');
$number = $this->CI->numberingservice->next('purchase_replacement',$date);
$warehouseId = (int)($data['warehouse_id'] ?? 0);
$this->CI->db->where('id',$warehouseId);if($this->CI->db->field_exists('company_id','warehouses'))$this->CI->db->where('company_id',$this->companyId());
if (!$this->CI->db->get('warehouses')->row()) throw new BusinessException('Gudang penerimaan pengganti tidak valid atau berasal dari perusahaan lain.');
$this->CI->db->insert('purchase_replacements',array('company_id'=>$this->companyId(),'replacement_no'=>$number,'purchase_return_id'=>$returnId,'supplier_id'=>$header->supplier_id,'receipt_date'=>$date,'supplier_delivery_no'=>trim((string)($data['supplier_delivery_no']??''))?:null,'warehouse_id'=>$warehouseId,'status'=>'posted','idempotency_key'=>$key,'received_by'=>(int)$user,'posted_at'=>date('Y-m-d H:i:s'),'created_at'=>date('Y-m-d H:i:s')));
$replacementId = (int)$this->CI->db->insert_id(); $receivedAny = false;
foreach ($this->CI->db->where('purchase_return_id',$returnId)->order_by('id')->get('purchase_return_lines')->result() as $line) {
$qty = round((float)($quantities[$line->id] ?? 0),4);
if ($qty <= 0) continue;
$remaining = round((float)$line->shipped_qty-(float)$line->replacement_received_qty,4);
if ($qty > $remaining+.0001) throw new BusinessException('Qty pengganti '.$line->description.' melebihi saldo retur.');
$source = $this->sourceLine($line->receipt_line_id, true);
$stockKey='PURCHASE-REPLACEMENT-STOCK-'.$replacementId.'-'.$line->id;
$detail='[AUTO] Penerimaan barang pengganti '.$number.' untuk retur '.$header->return_no.'; PO '.$source->po_no.'; barang '.$source->item_code.' - '.$source->item_name.'; qty '.number_format($qty,4,'.','').'; tanpa pembentukan hutang baru; diterima user #'.$user;
$this->CI->db->insert('stock_logs',array('item_id'=>$line->item_id,'warehouse_id'=>$warehouseId,'qty'=>$qty,'tipe'=>'masuk','activity'=>'purchase','keterangan'=>substr($detail,0,255),'ref_type'=>'purchase_replacement','ref_id'=>$replacementId,'idempotency_key'=>$stockKey,'unit_cost'=>$line->unit_cost));
$stockLogId=(int)$this->CI->db->insert_id();
$ledgerId=$this->writeLedger('purchase_replacement',$replacementId,$line->id,$line->item_id,$warehouseId,null,'in',$qty,$line->unit_cost,$date,'PURCHASE-REPLACEMENT-LEDGER-'.$replacementId.'-'.$line->id);
$list = preg_split('/[\r\n,;]+/', trim((string)($serials[$line->id] ?? '')), -1, PREG_SPLIT_NO_EMPTY);
if ($source->tracking_type==='UNIT' && $source->need_serial_number && count($list) > $qty+.0001) throw new BusinessException('Jumlah serial number pengganti melebihi qty yang diterima.');
$barcodeIds=array();
if ($source->tracking_type==='UNIT') {
for ($i=0;$i<(int)$qty;$i++) $barcodeIds[]=$this->createReplacementBarcode($number,$line,$warehouseId,1,$source->need_serial_number?($list[$i]??null):null,$source->need_serial_number,$user,$i+1);
} else {
$barcodeIds[]=$this->createReplacementBarcode($number,$line,$warehouseId,$qty,null,false,$user,1);
}
foreach($barcodeIds as $barcodeId)$this->CI->db->insert('item_movements',array('item_id'=>$line->item_id,'barcode_id'=>$barcodeId,'qty'=>$source->tracking_type==='UNIT'?1:$qty,'from_type'=>'supplier','from_id'=>$header->supplier_id,'to_type'=>'warehouse','to_id'=>$warehouseId,'movement_type'=>'purchase_replacement','reference_type'=>'purchase_replacement_line','reference_id'=>$line->id,'notes'=>substr($detail,0,255),'created_at'=>date('Y-m-d H:i:s')));
$movementId=(int)$this->CI->db->insert_id();
$this->CI->db->insert('purchase_replacement_lines',array('purchase_replacement_id'=>$replacementId,'purchase_return_line_id'=>$line->id,'item_id'=>$line->item_id,'warehouse_id'=>$warehouseId,'barcode_id'=>count($barcodeIds)===1?$barcodeIds[0]:null,'qty'=>$qty,'unit_cost'=>$line->unit_cost,'stock_log_id'=>$stockLogId,'movement_id'=>$movementId,'ledger_id'=>$ledgerId,'created_at'=>date('Y-m-d H:i:s')));
$newReceived=round((float)$line->replacement_received_qty+$qty,4);$lineStatus=$newReceived>=(float)$line->shipped_qty-.0001?'resolved':'partially_resolved';
$this->CI->db->where('id',$line->id)->update('purchase_return_lines',array('replacement_received_qty'=>$newReceived,'status'=>$lineStatus));
if ($line->barcode_id && $lineStatus==='resolved') $this->CI->db->where('id',$line->barcode_id)->where('status','returned')->update('item_barcodes',array('status'=>'replaced'));
$this->CI->adjustmentInventoryModel->synchronizeStock($line->item_id);$receivedAny=true;
}
if (!$receivedAny) throw new BusinessException('Minimal satu qty barang pengganti wajib diisi.');
$open=(int)$this->CI->db->where('purchase_return_id',$returnId)->where('status !=','resolved')->count_all_results('purchase_return_lines');
$newStatus=$open?'partially_resolved':'resolved';
$this->CI->db->where('id',$replacementId)->update('purchase_replacements',array('status'=>$open?'partially_received':'received'));
$this->CI->db->where('id',$returnId)->update('purchase_returns',array('status'=>$newStatus,'resolved_by'=>$open?null:(int)$user,'resolved_at'=>$open?null:date('Y-m-d H:i:s'),'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s')));
$this->history($returnId, $header->status, $newStatus, $user, 'Penerimaan barang pengganti '.$number.' diposting tanpa hutang baru.');
$this->CI->auditservice->record('purchase_return','purchase_replacement',$replacementId,'post',null,array('return_no'=>$header->return_no,'status'=>$newStatus),$user);
return $replacementId;
});
}
public function createRefund(array $data, array $allocations, $user)
{
return $this->CI->transactionservice->run(function () use ($data,$allocations,$user) {
$date=$this->validDate($data['claim_date']??null,'Tanggal pengajuan refund');
$reason=trim((string)($data['reason']??''));if($reason==='')throw new BusinessException('Alasan refund wajib diisi.');
$key=trim((string)($data['idempotency_key']??''));if($key==='')throw new BusinessException('Token pengajuan refund tidak tersedia.');
$existing=$this->CI->db->get_where('supplier_refund_claims',array('idempotency_key'=>$key))->row();if($existing)return(int)$existing->id;
$valid=array();$supplierId=0;$eligibleTotal=0;$claimTotal=0;$poRequested=array();$primary=array('purchase_order_id'=>null,'supplier_invoice_id'=>null,'purchase_return_id'=>null);
foreach($allocations as$a){$sourceType=$this->enum($a['source_type']??'',array('purchase_return','supplier_payment','purchase_order','supplier_invoice','supplier_debit_note'),'');$sourceId=(int)($a['source_id']??0);$amount=round((float)($a['amount']??0),2);if(!$sourceType||!$sourceId||$amount<=0)continue;$source=$this->refundSource($sourceType,$sourceId);if(!$source)throw new BusinessException('Dokumen sumber refund tidak valid.');if(!$supplierId)$supplierId=(int)$source['supplier_id'];if($supplierId!==(int)$source['supplier_id'])throw new BusinessException('Satu batch refund hanya boleh untuk satu supplier.');$used=$this->allocatedRefundAmount($sourceType,$sourceId);$eligible=max(0,round((float)$source['eligible_amount']-$used,2));if($amount>$eligible+.001)throw new BusinessException('Nilai refund melebihi hak refund dokumen '.$source['number'].'.');$valid[]=array('source_type'=>$sourceType,'source_id'=>$sourceId,'amount'=>$amount,'eligible'=>$eligible,'source'=>$source);$eligibleTotal+=$eligible;$claimTotal+=$amount;if(!empty($source['purchase_order_id']))$poRequested[(int)$source['purchase_order_id']]=round(($poRequested[(int)$source['purchase_order_id']]??0)+$amount,2);if(isset($primary[$sourceType.'_id']))$primary[$sourceType.'_id']=$sourceId;if($sourceType==='purchase_return')$primary['purchase_return_id']=$sourceId;if(!empty($source['purchase_order_id']))$primary['purchase_order_id']=$source['purchase_order_id'];if(!empty($source['supplier_invoice_id']))$primary['supplier_invoice_id']=$source['supplier_invoice_id'];}
if(!$valid)throw new BusinessException('Pilih minimal satu dokumen sumber dan nilai refund.');
foreach($poRequested as$poId=>$requested){$po=$this->CI->db->query('SELECT * FROM purchase_orders WHERE id=? FOR UPDATE',array($poId))->row();if(!$po||(int)$po->company_id!==$this->companyId())throw new BusinessException('PO sumber refund tidak valid.');$committed=(float)$this->CI->db->select('COALESCE(SUM(claim_amount),0) total',false)->where('purchase_order_id',$poId)->where_not_in('status',array('rejected','cancelled','reversed'))->get('supplier_refund_claims')->row()->total;$poAvailable=max(0,round((float)$po->paid_amount-(float)$po->transit_cleared_amount-$committed,2));if($requested>$poAvailable+.001)throw new BusinessException('Total klaim refund untuk PO '.$po->po_no.' melebihi hak refund yang belum digunakan sebesar Rp '.number_format($poAvailable,2,',','.').'.');}
$number=$this->CI->numberingservice->next('supplier_refund',$date);$sourceCategory=$this->enum($data['source_category']??'',array('prepayment','short_delivery','paid_return','cancelled_order','overpayment','cashback','debit_note','other'),'other');
$this->CI->db->insert('supplier_refund_claims',array_merge(array('company_id'=>$this->companyId(),'refund_no'=>$number,'supplier_id'=>$supplierId,'claim_date'=>$date,'expected_date'=>!empty($data['expected_date'])?$data['expected_date']:null,'reason'=>$reason,'source_type'=>$sourceCategory,'eligible_amount'=>$eligibleTotal,'claim_amount'=>$claimTotal,'received_amount'=>0,'balance'=>$claimTotal,'target_account_id'=>!empty($data['target_account_id'])?(int)$data['target_account_id']:null,'status'=>'draft','idempotency_key'=>$key,'created_by'=>(int)$user,'created_at'=>date('Y-m-d H:i:s')),$primary));
$id=(int)$this->CI->db->insert_id();foreach($valid as$v)$this->CI->db->insert('supplier_refund_allocations',array('supplier_refund_claim_id'=>$id,'source_type'=>$v['source_type'],'source_id'=>$v['source_id'],'eligible_amount'=>$v['eligible'],'allocated_amount'=>$v['amount'],'created_at'=>date('Y-m-d H:i:s')));
$this->refundHistory($id,null,'draft',$user,'Draft klaim refund supplier dibuat.');
$this->CI->auditservice->record('purchase_refund','supplier_refund',$id,'create',null,array('refund_no'=>$number,'supplier_id'=>$supplierId,'amount'=>$claimTotal),$user);return$id;
});
}
public function submitRefund($id,$user)
{
return $this->CI->transactionservice->run(function()use($id,$user){$claim=$this->lockClaim($id,array('draft'));if((int)$claim->created_by!==(int)$user&&!$this->isMasterAdmin())throw new BusinessException('Hanya pembuat draft yang dapat mengajukan refund.');if(!$this->hasAttachment('supplier_refund',$id))throw new BusinessException('Lampiran pengajuan refund wajib tersedia.');$this->CI->approvalservice->create('purchase_refund','supplier_refund',$id,$claim->claim_amount,$user,$claim);$this->CI->db->where('id',$id)->update('supplier_refund_claims',array('status'=>'submitted','submitted_by'=>(int)$user,'submitted_at'=>date('Y-m-d H:i:s')));$this->refundHistory($id,'draft','submitted',$user,'Klaim refund diajukan untuk persetujuan Finance.');return$id;});
}
public function approveRefund($id,$user,$roleId)
{
return $this->CI->transactionservice->run(function()use($id,$user,$roleId){$claim=$this->lockClaim($id,array('submitted'));$request=$this->CI->approvalservice->pendingForEntity('supplier_refund',$id);if(!$request)throw new BusinessException('Permintaan persetujuan refund tidak ditemukan.');$approval=$this->CI->approvalservice->act($request->id,'approved',$user,$roleId);if($approval->status!=='approved')return$id;$journal=null;if(!$claim->entitlement_journal_id){$this->CI->fiscalperiodservice->assertOpen($claim->claim_date);$supplier=$this->CI->db->get_where('suppliers',array('id'=>$claim->supplier_id))->row();$credit=$this->refundCreditAccount($claim);$desc='[AUTO] Pembentukan piutang refund '.$claim->refund_no.' kepada '.($supplier?$supplier->supplier_code.' - '.$supplier->name:'supplier #'.$claim->supplier_id).'; sumber '.$claim->source_type.'; nilai Rp '.number_format($claim->claim_amount,2,',','.').'; alasan '.$claim->reason.'; diproses user #'.$user;$journal=$this->CI->postingservice->post(array('tanggal'=>$claim->claim_date,'no_ref'=>$claim->refund_no,'keterangan'=>$desc,'ref_type'=>'supplier_refund_claim','ref_id'=>$id,'created_by'=>$user),array(array('account_id'=>$this->CI->accountmappingservice->get('supplier_refund_receivable'),'debit'=>$claim->claim_amount,'kredit'=>0),array('account_id'=>$credit,'debit'=>0,'kredit'=>$claim->claim_amount)),false,true);}$this->CI->db->where('id',$id)->update('supplier_refund_claims',array('status'=>'waiting_funds','approved_by'=>(int)$user,'approved_at'=>date('Y-m-d H:i:s'),'entitlement_journal_id'=>$journal?:$claim->entitlement_journal_id));$this->refundHistory($id,'submitted','approved',$user,'Klaim refund disetujui.');$this->refundHistory($id,'approved','waiting_funds',$user,'Menunggu dana dari supplier.');$this->CI->auditservice->record('purchase_refund','supplier_refund',$id,'approve',$claim,array('status'=>'waiting_funds','journal_id'=>$journal),$user);return$id;});
}
public function rejectRefund($id,$reason,$user,$roleId)
{
return $this->CI->transactionservice->run(function()use($id,$reason,$user,$roleId){$claim=$this->lockClaim($id,array('submitted'));$reason=trim((string)$reason);if($reason==='')throw new BusinessException('Alasan penolakan wajib diisi.');$request=$this->CI->approvalservice->pendingForEntity('supplier_refund',$id);if(!$request)throw new BusinessException('Permintaan persetujuan refund tidak ditemukan.');$this->CI->approvalservice->act($request->id,'rejected',$user,$roleId,$reason);$this->CI->db->where('id',$id)->update('supplier_refund_claims',array('status'=>'rejected','rejected_by'=>(int)$user,'rejected_at'=>date('Y-m-d H:i:s'),'rejection_reason'=>$reason));$this->refundHistory($id,'submitted','rejected',$user,$reason);return$id;});
}
public function receiveRefund($claimId,array$data,$user)
{
return $this->CI->transactionservice->run(function()use($claimId,$data,$user){$claim=$this->lockClaim($claimId,array('waiting_funds','partial'));$date=$this->validDate($data['receipt_date']??null,'Tanggal penerimaan refund');$amount=round((float)($data['amount']??0),2);if($amount<=0||$amount>(float)$claim->balance+.001)throw new BusinessException('Nilai penerimaan refund tidak valid atau melebihi sisa klaim.');$account=(int)($data['bank_account_id']??0);if(!$this->cashAccount($account))throw new BusinessException('Akun Kas/Bank penerima refund tidak valid.');$reference=trim((string)($data['reference_no']??''));if($reference==='')throw new BusinessException('Referensi bank penerimaan refund wajib diisi.');$key=trim((string)($data['idempotency_key']??''));if($key==='')throw new BusinessException('Token penerimaan refund tidak tersedia.');$existing=$this->CI->db->get_where('supplier_refund_receipts',array('idempotency_key'=>$key))->row();if($existing)return(int)$existing->id;if(!$this->hasAttachment('supplier_refund_receipt',$claimId)&&empty($data['attachment_pending']))throw new BusinessException('Bukti penerimaan dana refund wajib tersedia.');$this->CI->fiscalperiodservice->assertOpen($date);$supplier=$this->CI->db->get_where('suppliers',array('id'=>$claim->supplier_id))->row();$number=$this->CI->numberingservice->next('supplier_refund_receipt',$date);$desc='[AUTO] Penerimaan dana refund '.$number.' untuk klaim '.$claim->refund_no.' dari '.($supplier?$supplier->supplier_code.' - '.$supplier->name:'supplier #'.$claim->supplier_id).'; referensi bank '.$reference.'; nilai Rp '.number_format($amount,2,',','.').'; diproses user #'.$user;$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$number,'keterangan'=>$desc,'ref_type'=>'supplier_refund_receipt','ref_id'=>$claimId,'created_by'=>$user),array(array('account_id'=>$account,'debit'=>$amount,'kredit'=>0),array('account_id'=>$this->CI->accountmappingservice->get('supplier_refund_receivable'),'debit'=>0,'kredit'=>$amount)),false,false);$this->CI->db->insert('supplier_refund_receipts',array('company_id'=>$this->companyId(),'receipt_no'=>$number,'supplier_refund_claim_id'=>$claimId,'receipt_date'=>$date,'bank_account_id'=>$account,'amount'=>$amount,'reference_no'=>$reference,'notes'=>trim((string)($data['notes']??''))?:null,'status'=>'posted','idempotency_key'=>$key,'journal_id'=>$journal,'received_by'=>(int)$user,'created_at'=>date('Y-m-d H:i:s')));$receiptId=(int)$this->CI->db->insert_id();$received=round((float)$claim->received_amount+$amount,2);$balance=max(0,round((float)$claim->claim_amount-$received,2));$status=$balance<=.001?'received':'partial';$this->CI->db->where('id',$claimId)->update('supplier_refund_claims',array('received_amount'=>$received,'balance'=>$balance,'status'=>$status,'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s')));$this->refundHistory($claimId,$claim->status,$status,$user,'Penerimaan dana '.$number.' sebesar Rp '.number_format($amount,2,',','.').'; referensi '.$reference.'.');$this->CI->auditservice->record('purchase_refund','supplier_refund_receipt',$receiptId,'post',null,array('refund_no'=>$claim->refund_no,'amount'=>$amount,'balance'=>$balance,'journal_id'=>$journal),$user);return$receiptId;});
}
public function reconcileRefund($id,$user)
{
return $this->CI->transactionservice->run(function()use($id,$user){$claim=$this->lockClaim($id,array('received'));if((float)$claim->balance>.001)throw new BusinessException('Refund belum diterima seluruhnya.');$this->CI->db->where('id',$id)->update('supplier_refund_claims',array('status'=>'reconciled','reconciled_by'=>(int)$user,'reconciled_at'=>date('Y-m-d H:i:s')));$this->refundHistory($id,'received','reconciled',$user,'Refund telah dicocokkan dengan mutasi Kas/Bank dan diselesaikan.');$this->CI->auditservice->record('purchase_refund','supplier_refund',$id,'reconcile',$claim,array('status'=>'reconciled'),$user);return$id;});
}
public function cancelRefund($id,$reason,$user)
{
return $this->CI->transactionservice->run(function()use($id,$reason,$user){
$reason=trim((string)$reason);if($reason==='')throw new BusinessException('Alasan pembatalan refund wajib diisi.');
$claim=$this->lockClaim($id,array('draft','submitted','rejected'));
if($claim->status==='draft'&&(int)$claim->created_by!==(int)$user&&!$this->isMasterAdmin())throw new BusinessException('Draft refund hanya dapat dibatalkan oleh pembuatnya.');
if($claim->status==='submitted'){$request=$this->CI->approvalservice->pendingForEntity('supplier_refund',$id);if($request)$this->CI->db->where('id',$request->id)->update('approval_requests',array('status'=>'cancelled','completed_at'=>date('Y-m-d H:i:s')));}
$this->CI->db->where('id',$id)->update('supplier_refund_claims',array('status'=>'cancelled','cancelled_by'=>(int)$user,'cancelled_at'=>date('Y-m-d H:i:s'),'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s')));
$this->refundHistory($id,$claim->status,'cancelled',$user,$reason);
$this->CI->auditservice->record('purchase_refund','supplier_refund',$id,'cancel',$claim,array('status'=>'cancelled','reason'=>$reason),$user);return$id;
});
}
public function reverseRefundReceipt($receiptId,$date,$reason,$user)
{
return $this->CI->transactionservice->run(function()use($receiptId,$date,$reason,$user){
$reason=trim((string)$reason);if($reason==='')throw new BusinessException('Alasan reversal penerimaan refund wajib diisi.');
$date=$this->validDate($date,'Tanggal reversal refund');$this->CI->fiscalperiodservice->assertOpen($date);
$receipt=$this->CI->db->query('SELECT * FROM supplier_refund_receipts WHERE id=? FOR UPDATE',array((int)$receiptId))->row();
if(!$receipt||!in_array($receipt->status,array('posted','reconciled'),true))throw new BusinessException('Bukti penerimaan refund tidak dapat direversal.');
if((int)$receipt->company_id!==$this->companyId())throw new BusinessException('Dokumen refund berasal dari perusahaan lain.',array(),403);
$claim=$this->lockClaim($receipt->supplier_refund_claim_id,array('partial','received','reconciled'));
$reversal=$this->CI->journalworkflowservice->reverse((int)$receipt->journal_id,$date,$user,'Reversal penerimaan refund '.$receipt->receipt_no.': '.$reason);
$received=max(0,round((float)$claim->received_amount-(float)$receipt->amount,2));$balance=max(0,round((float)$claim->claim_amount-$received,2));$status=$received>.001?'partial':'waiting_funds';
$this->CI->db->where('id',$receipt->id)->update('supplier_refund_receipts',array('status'=>'reversed','reversal_journal_id'=>(int)$reversal['reversal_id']));
$this->CI->db->where('id',$claim->id)->update('supplier_refund_claims',array('received_amount'=>$received,'balance'=>$balance,'status'=>$status,'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s')));
$this->refundHistory($claim->id,$claim->status,$status,$user,'Penerimaan '.$receipt->receipt_no.' direversal: '.$reason);
$this->CI->auditservice->record('purchase_refund','supplier_refund_receipt',$receipt->id,'reverse',$receipt,array('status'=>'reversed','reversal_journal_id'=>(int)$reversal['reversal_id'],'reason'=>$reason),$user);return$receipt->id;
});
}
public function createDebitNote(array$data,$user)
{
return $this->CI->transactionservice->run(function()use($data,$user){
$invoice=$this->CI->db->query('SELECT * FROM supplier_invoices WHERE id=? FOR UPDATE',array((int)($data['supplier_invoice_id']??0)))->row();
if(!$invoice||!in_array($invoice->status,array('partial','paid'),true))throw new BusinessException('Invoice Supplier tidak dapat dikoreksi dengan Debit Note.');
if($this->CI->db->field_exists('company_id','supplier_invoices')&&(int)$invoice->company_id!==$this->companyId())throw new BusinessException('Invoice Supplier berasal dari perusahaan lain.',array(),403);
$date=$this->validDate($data['note_date']??null,'Tanggal Debit Note');$base=round((float)($data['base_amount']??0),2);$tax=round((float)($data['tax_amount']??0),2);$total=$base+$tax;
if($base<=0||$tax<0||$total<=0)throw new BusinessException('Nilai Debit Note tidak valid.');
$reason=trim((string)($data['reason']??''));if($reason==='')throw new BusinessException('Alasan Debit Note wajib diisi.');
$key=trim((string)($data['idempotency_key']??''));if($key==='')throw new BusinessException('Token Debit Note tidak tersedia.');
$existing=$this->CI->db->get_where('supplier_debit_notes',array('idempotency_key'=>$key))->row();if($existing)return(int)$existing->id;
$used=$this->CI->db->select('COALESCE(SUM(amount),0) total',false)->where('supplier_invoice_id',$invoice->id)->where_not_in('status',array('cancelled','reversed'))->get('supplier_debit_notes')->row();
$available=max(0,round((float)$invoice->total-(float)($used?$used->total:0),2));
if($total>$available+.001)throw new BusinessException('Total Debit Note melebihi nilai invoice yang masih dapat dikoreksi, yaitu Rp '.number_format($available,2,',','.').'.');
$po=$this->CI->db->select('pol.purchase_order_id')->from('supplier_invoice_lines sil')->join('purchase_order_lines pol','pol.id=sil.po_line_id')->where('sil.supplier_invoice_id',$invoice->id)->limit(1)->get()->row();
$returnId=!empty($data['purchase_return_id'])?(int)$data['purchase_return_id']:null;
if($returnId){$return=$this->CI->db->get_where('purchase_returns',array('id'=>$returnId,'company_id'=>$this->companyId(),'supplier_id'=>$invoice->supplier_id))->row();if(!$return||!in_array($return->status,array('shipped','waiting_supplier','partially_resolved','resolved'),true)||($po&&(int)$return->purchase_order_id!==(int)$po->purchase_order_id))throw new BusinessException('Retur sumber Debit Note tidak valid atau tidak terkait dengan invoice yang dipilih.');}
$number=$this->CI->numberingservice->next('supplier_debit_note',$date);
$this->CI->db->insert('supplier_debit_notes',array('company_id'=>$this->companyId(),'debit_note_no'=>$number,'supplier_id'=>$invoice->supplier_id,'supplier_invoice_id'=>$invoice->id,'purchase_order_id'=>$po?$po->purchase_order_id:null,'purchase_return_id'=>$returnId,'note_date'=>$date,'correction_type'=>$this->enum($data['correction_type']??'',array('return','quantity','price','discount','tax','chargeback','billing_error','other'),'other'),'base_amount'=>$base,'tax_amount'=>$tax,'amount'=>$total,'reason'=>$reason,'idempotency_key'=>$key,'status'=>'draft','created_by'=>(int)$user,'created_at'=>date('Y-m-d H:i:s')));
$id=(int)$this->CI->db->insert_id();
$this->CI->db->insert('supplier_debit_note_lines',array('supplier_debit_note_id'=>$id,'description'=>$reason,'qty'=>0,'base_amount'=>$base,'tax_amount'=>$tax,'total_amount'=>$total,'created_at'=>date('Y-m-d H:i:s')));
$this->debitNoteHistory($id,null,'draft',$user,'Draft Debit Note dibuat untuk invoice '.$invoice->internal_no.'.');
$this->CI->auditservice->record('supplier_debit_note','supplier_debit_note',$id,'create',null,array('debit_note_no'=>$number,'invoice'=>$invoice->internal_no,'amount'=>$total),$user);return$id;
});
}
public function postDebitNote($id,$user)
{
return $this->CI->transactionservice->run(function()use($id,$user){
$note=$this->CI->db->query('SELECT * FROM supplier_debit_notes WHERE id=? FOR UPDATE',array((int)$id))->row();if(!$note||$note->status!=='draft')throw new BusinessException('Debit Note harus berstatus draft.');
if((int)$note->company_id!==$this->companyId())throw new BusinessException('Debit Note berasal dari perusahaan lain.',array(),403);
if(!$this->hasAttachment('supplier_debit_note',$id))throw new BusinessException('Lampiran Debit Note wajib tersedia.');
$invoice=$this->CI->db->query('SELECT * FROM supplier_invoices WHERE id=? FOR UPDATE',array((int)$note->supplier_invoice_id))->row();if(!$invoice||!in_array($invoice->status,array('partial','paid'),true))throw new BusinessException('Invoice Supplier tidak dapat dikoreksi.');
$used=$this->CI->db->select('COALESCE(SUM(amount),0) total',false)->where('supplier_invoice_id',$invoice->id)->where('id !=',$note->id)->where_in('status',array('posted','applied'))->get('supplier_debit_notes')->row();
if((float)$note->amount>(float)$invoice->total-(float)($used?$used->total:0)+.001)throw new BusinessException('Nilai invoice yang dapat dikoreksi telah digunakan Debit Note lain. Muat ulang data.');
$this->CI->fiscalperiodservice->assertOpen($note->note_date);$applied=min((float)$note->amount,max(0,(float)$invoice->balance));$refund=round((float)$note->amount-$applied,2);$supplier=$this->CI->db->get_where('suppliers',array('id'=>$note->supplier_id))->row();
$desc='[AUTO] Debit Note '.$note->debit_note_no.' untuk invoice '.$invoice->internal_no.' / '.$invoice->supplier_invoice_no.'; supplier '.($supplier?$supplier->supplier_code.' - '.$supplier->name:'#'.$note->supplier_id).'; pokok Rp '.number_format($note->base_amount,2,',','.').'; pajak Rp '.number_format($note->tax_amount,2,',','.').'; pengurang hutang Rp '.number_format($applied,2,',','.').'; piutang refund Rp '.number_format($refund,2,',','.').'; alasan '.$note->reason.'; diproses user #'.$user;
$entries=array();if($applied>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('accounts_payable'),'debit'=>$applied,'kredit'=>0);if($refund>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('supplier_refund_receivable'),'debit'=>$refund,'kredit'=>0);if((float)$note->base_amount>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('purchase_return_adjustment'),'debit'=>0,'kredit'=>$note->base_amount);if((float)$note->tax_amount>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('purchase_input_tax'),'debit'=>0,'kredit'=>$note->tax_amount);
$journal=$this->CI->postingservice->post(array('tanggal'=>$note->note_date,'no_ref'=>$note->debit_note_no,'keterangan'=>$desc,'ref_type'=>'supplier_debit_note','ref_id'=>$id,'created_by'=>$user),$entries,false,true);
$balance=max(0,round((float)$invoice->balance-$applied,2));$this->CI->db->where('id',$invoice->id)->update('supplier_invoices',array('balance'=>$balance,'status'=>$balance<=.001?'paid':'partial'));
$this->CI->db->where('id',$id)->update('supplier_debit_notes',array('status'=>'applied','journal_id'=>$journal,'posted_by'=>(int)$user,'posted_at'=>date('Y-m-d H:i:s')));
$this->debitNoteHistory($id,'draft','posted',$user,'Debit Note diposting ke jurnal #'.$journal.'.');$this->debitNoteHistory($id,'posted','applied',$user,'Saldo invoice berkurang Rp '.number_format($applied,2,',','.').($refund>0?'; Rp '.number_format($refund,2,',','.').' menjadi Piutang Refund Supplier.':'.'));
if($refund>0)$this->createAutomaticRefundClaim($note->supplier_id,$note->purchase_order_id,$invoice->id,$note->purchase_return_id,'debit_note',$id,$refund,$note->note_date,'Penyelesaian Debit Note '.$note->debit_note_no.' pada invoice yang telah dibayar.',$journal,$user);
$this->CI->auditservice->record('supplier_debit_note','supplier_debit_note',$id,'post',$note,array('status'=>'applied','journal_id'=>$journal,'invoice_balance'=>$balance,'refund_receivable'=>$refund),$user);return$id;
});
}
public function reverseDebitNote($id,$date,$reason,$user)
{
return $this->CI->transactionservice->run(function()use($id,$date,$reason,$user){
$reason=trim((string)$reason);if($reason==='')throw new BusinessException('Alasan reversal Debit Note wajib diisi.');$date=$this->validDate($date,'Tanggal reversal Debit Note');
$note=$this->CI->db->query('SELECT * FROM supplier_debit_notes WHERE id=? FOR UPDATE',array((int)$id))->row();
if(!$note||!in_array($note->status,array('posted','applied'),true))throw new BusinessException('Debit Note tidak dapat direversal.');
if((int)$note->company_id!==$this->companyId())throw new BusinessException('Debit Note berasal dari perusahaan lain.',array(),403);
$claim=$this->CI->db->where('source_type','debit_note')->where('supplier_invoice_id',$note->supplier_invoice_id)->where_not_in('status',array('rejected','cancelled','reversed'))->get('supplier_refund_claims')->row();
if($claim&&(float)$claim->received_amount>.001)throw new BusinessException('Dana penyelesaian Debit Note sudah diterima. Reverse penerimaan refund lebih dahulu.');
$reversal=$this->CI->journalworkflowservice->reverse((int)$note->journal_id,$date,$user,'Reversal Debit Note '.$note->debit_note_no.': '.$reason);
$refund=$claim?(float)$claim->claim_amount:0;$applied=max(0,round((float)$note->amount-$refund,2));
if($note->supplier_invoice_id&&$applied>0){$invoice=$this->CI->db->query('SELECT * FROM supplier_invoices WHERE id=? FOR UPDATE',array((int)$note->supplier_invoice_id))->row();if($invoice){$balance=min((float)$invoice->total,round((float)$invoice->balance+$applied,2));$this->CI->db->where('id',$invoice->id)->update('supplier_invoices',array('balance'=>$balance,'status'=>$balance>.001?'partial':'paid'));}}
if($claim){$this->CI->db->where('id',$claim->id)->update('supplier_refund_claims',array('status'=>'reversed','reversal_journal_id'=>(int)$reversal['reversal_id'],'reversal_reason'=>$reason,'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s')));$this->refundHistory($claim->id,$claim->status,'reversed',$user,'Debit Note sumber direversal: '.$reason);}
$this->CI->db->where('id',$id)->update('supplier_debit_notes',array('status'=>'reversed','reversal_journal_id'=>(int)$reversal['reversal_id'],'reversal_reason'=>$reason,'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s')));
$this->debitNoteHistory($id,$note->status,'reversed',$user,$reason);$this->CI->auditservice->record('supplier_debit_note','supplier_debit_note',$id,'reverse',$note,array('status'=>'reversed','reversal_journal_id'=>(int)$reversal['reversal_id'],'reason'=>$reason),$user);return$id;
});
}
public function document($type,$id)
{
$company=$this->companyId();$h=null;$lines=array();$history=array();
if($type==='purchase_return'||$type==='return_resolution'){
$type='purchase_return';
$h=$this->CI->db->select('pr.*,s.supplier_code,s.name supplier_name,po.po_no,u.nama created_name,su.nama submitted_name,au.nama approved_name,ru.nama resolved_name')->from('purchase_returns pr')->join('suppliers s','s.id=pr.supplier_id')->join('purchase_orders po','po.id=pr.purchase_order_id','left')->join('users u','u.id=pr.created_by','left')->join('users su','su.id=pr.submitted_by','left')->join('users au','au.id=pr.approved_by','left')->join('users ru','ru.id=pr.resolved_by','left')->where(array('pr.id'=>(int)$id,'pr.company_id'=>$company))->get()->row_array();
$lines=$this->CI->db->select('l.*,gr.receipt_no,k.kode_barang,k.nama item_name,w.nama warehouse_name')->from('purchase_return_lines l')->join('goods_receipts gr','gr.id=l.goods_receipt_id','left')->join('items i','i.id=l.item_id','left')->join('kode_barang k','k.id=i.kode_id','left')->join('warehouses w','w.id=l.warehouse_id','left')->where(array('l.purchase_return_id'=>(int)$id,'l.company_id'=>$company))->order_by('l.id')->get()->result_array();
$history=$this->CI->db->select('h.*,u.nama user_name')->from('purchase_return_status_history h')->join('users u','u.id=h.user_id','left')->where('h.purchase_return_id',(int)$id)->order_by('h.id')->get()->result_array();
} elseif($type==='purchase_return_shipment'){
$h=$this->CI->db->select('sh.*,pr.return_no,pr.reason,pr.problem_category,s.supplier_code,s.name supplier_name,po.po_no,u.nama posted_name')->from('purchase_return_shipments sh')->join('purchase_returns pr','pr.id=sh.purchase_return_id')->join('suppliers s','s.id=pr.supplier_id')->join('purchase_orders po','po.id=pr.purchase_order_id','left')->join('users u','u.id=sh.posted_by','left')->where(array('sh.id'=>(int)$id,'sh.company_id'=>$company))->get()->row_array();
$lines=$this->CI->db->select('sl.qty,rl.description,rl.unit_cost,rl.total_amount,rl.barcode_snapshot,rl.serial_snapshot,k.kode_barang,k.nama item_name,gr.receipt_no')->from('purchase_return_shipment_lines sl')->join('purchase_return_lines rl','rl.id=sl.purchase_return_line_id')->join('items i','i.id=rl.item_id','left')->join('kode_barang k','k.id=i.kode_id','left')->join('goods_receipts gr','gr.id=rl.goods_receipt_id','left')->where('sl.purchase_return_shipment_id',(int)$id)->get()->result_array();
if($h)$history=$this->CI->db->select('x.*,u.nama user_name')->from('purchase_return_status_history x')->join('users u','u.id=x.user_id','left')->where('x.purchase_return_id',(int)$h['purchase_return_id'])->order_by('x.id')->get()->result_array();
} elseif($type==='supplier_refund'){
$h=$this->CI->db->select('r.*,s.supplier_code,s.name supplier_name,po.po_no,si.internal_no,u.nama created_name,su.nama submitted_name,a.nama approved_name,ru.nama rejected_name')->from('supplier_refund_claims r')->join('suppliers s','s.id=r.supplier_id')->join('purchase_orders po','po.id=r.purchase_order_id','left')->join('supplier_invoices si','si.id=r.supplier_invoice_id','left')->join('users u','u.id=r.created_by','left')->join('users su','su.id=r.submitted_by','left')->join('users a','a.id=r.approved_by','left')->join('users ru','ru.id=r.rejected_by','left')->where(array('r.id'=>(int)$id,'r.company_id'=>$company))->get()->row_array();
$lines=$this->CI->db->where('supplier_refund_claim_id',(int)$id)->get('supplier_refund_allocations')->result_array();
$history=$this->CI->db->select('h.*,u.nama user_name')->from('supplier_refund_status_history h')->join('users u','u.id=h.user_id','left')->where('h.supplier_refund_claim_id',(int)$id)->order_by('h.id')->get()->result_array();
} elseif($type==='supplier_refund_receipt'){
$h=$this->CI->db->select('rr.*,rc.refund_no,rc.reason,s.supplier_code,s.name supplier_name,a.kode_akun,a.nama_akun,u.nama received_name')->from('supplier_refund_receipts rr')->join('supplier_refund_claims rc','rc.id=rr.supplier_refund_claim_id')->join('suppliers s','s.id=rc.supplier_id')->join('accounts a','a.id=rr.bank_account_id','left')->join('users u','u.id=rr.received_by','left')->where(array('rr.id'=>(int)$id,'rr.company_id'=>$company))->get()->row_array();
if($h)$history=$this->CI->db->select('x.*,u.nama user_name')->from('supplier_refund_status_history x')->join('users u','u.id=x.user_id','left')->where('x.supplier_refund_claim_id',(int)$h['supplier_refund_claim_id'])->order_by('x.id')->get()->result_array();
} elseif($type==='supplier_debit_note'){
$h=$this->CI->db->select('d.*,s.supplier_code,s.name supplier_name,si.internal_no,si.supplier_invoice_no,po.po_no,u.nama created_name,p.nama posted_name')->from('supplier_debit_notes d')->join('suppliers s','s.id=d.supplier_id')->join('supplier_invoices si','si.id=d.supplier_invoice_id','left')->join('purchase_orders po','po.id=d.purchase_order_id','left')->join('users u','u.id=d.created_by','left')->join('users p','p.id=d.posted_by','left')->where(array('d.id'=>(int)$id,'d.company_id'=>$company))->get()->row_array();
$lines=$this->CI->db->where('supplier_debit_note_id',(int)$id)->get('supplier_debit_note_lines')->result_array();
$history=$this->CI->db->select('h.*,u.nama user_name')->from('supplier_debit_note_status_history h')->join('users u','u.id=h.user_id','left')->where('h.supplier_debit_note_id',(int)$id)->order_by('h.id')->get()->result_array();
} elseif($type==='purchase_replacement'){
$h=$this->CI->db->select('r.*,pr.return_no,s.supplier_code,s.name supplier_name,w.nama warehouse_name,u.nama received_name')->from('purchase_replacements r')->join('purchase_returns pr','pr.id=r.purchase_return_id')->join('suppliers s','s.id=r.supplier_id')->join('warehouses w','w.id=r.warehouse_id','left')->join('users u','u.id=r.received_by','left')->where(array('r.id'=>(int)$id,'r.company_id'=>$company))->get()->row_array();
$lines=$this->CI->db->select('l.*,prl.description,k.kode_barang,k.nama item_name,ib.barcode,ib.serial_number')->from('purchase_replacement_lines l')->join('purchase_return_lines prl','prl.id=l.purchase_return_line_id')->join('items i','i.id=l.item_id','left')->join('kode_barang k','k.id=i.kode_id','left')->join('item_barcodes ib','ib.id=l.barcode_id','left')->where('l.purchase_replacement_id',(int)$id)->get()->result_array();
if($h)$history=$this->CI->db->select('x.*,u.nama user_name')->from('purchase_return_status_history x')->join('users u','u.id=x.user_id','left')->where('x.purchase_return_id',(int)$h['purchase_return_id'])->order_by('x.id')->get()->result_array();
} else return null;
if(!$h)return null;
$attachments=$this->CI->db->where(array('entity_type'=>$type,'entity_id'=>(int)$id))->order_by('id','DESC')->get('transaction_attachments')->result_array();
return array('type'=>$type,'header'=>$h,'lines'=>$lines,'history'=>$history,'attachments'=>$attachments);
}
private function collectReturnAccounting($line,$source,array &$totals,$user)
{
$invoiceLine=$this->CI->db->select('sil.*,si.id supplier_invoice_id,si.internal_no,si.status invoice_status,si.balance invoice_balance,si.paid_amount')
->from('supplier_invoice_lines sil')->join('supplier_invoices si','si.id=sil.supplier_invoice_id')
->where('sil.receipt_line_id',$line->receipt_line_id)->where_in('si.status',array('partial','paid'))->order_by('si.id')->limit(1)->get()->row();
$base=round((float)$line->qty*(float)$line->unit_cost,2);$tax=round((float)$line->tax_amount,2);$total=$base+$tax;
if($invoiceLine){$invoice=$this->CI->db->query('SELECT * FROM supplier_invoices WHERE id=? FOR UPDATE',array($invoiceLine->supplier_invoice_id))->row();$ap=min($total,max(0,(float)$invoice->balance));$refund=round($total-$ap,2);$totals['inventory']+=$base;$totals['tax']+=$tax;$totals['payable']+=$ap;$totals['refund']+=$refund;$totals['posted_inventory']=true;$totals['invoice_ids'][$invoice->id]=($totals['invoice_ids'][$invoice->id]??0)+$ap;$this->createAutomaticDebitNote($line,$source,$invoice,$base,$tax,$ap,$user);if($refund>0)$line->_automatic_refund=$refund;}
elseif(!empty($source->advance_journal_id)||(float)$source->po_paid_amount>0){$totals['inventory']+=$base;$totals['refund']+=$total;$totals['posted_inventory']=true;$line->_automatic_refund=$total;}
}
private function postReturnAccounting($header,$supplier,$date,array$lineNames,array$totals,$user)
{
if(!$totals['posted_inventory'])return null;
foreach($totals['invoice_ids']as$invoiceId=>$applied){$invoice=$this->CI->db->query('SELECT * FROM supplier_invoices WHERE id=? FOR UPDATE',array((int)$invoiceId))->row();$balance=max(0,round((float)$invoice->balance-$applied,2));$this->CI->db->where('id',$invoiceId)->update('supplier_invoices',array('balance'=>$balance,'status'=>$balance<=.001?'paid':'partial'));}
$desc='[AUTO] Retur pembelian '.$header->return_no.' kepada '.($supplier?$supplier->supplier_code.' - '.$supplier->name:'supplier #'.$header->supplier_id).'; PO #'.$header->purchase_order_id.'; barang '.implode(', ',$lineNames).'; pokok Rp '.number_format($totals['inventory'],2,',','.').'; pajak Rp '.number_format($totals['tax'],2,',','.').'; pengurang hutang Rp '.number_format($totals['payable'],2,',','.').'; piutang refund Rp '.number_format($totals['refund'],2,',','.').'; alasan '.$header->reason.'; diproses user #'.$user;
$entries=array();if($totals['payable']>.001)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('accounts_payable'),'debit'=>round($totals['payable'],2),'kredit'=>0);if($totals['refund']>.001)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('supplier_refund_receivable'),'debit'=>round($totals['refund'],2),'kredit'=>0);if($totals['inventory']>.001)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('inventory'),'debit'=>0,'kredit'=>round($totals['inventory'],2));if($totals['tax']>.001)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('purchase_input_tax'),'debit'=>0,'kredit'=>round($totals['tax'],2));
$debit=array_sum(array_column($entries,'debit'));$credit=array_sum(array_column($entries,'kredit'));$diff=round($debit-$credit,2);if(abs($diff)>.001){$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('purchase_return_adjustment'),'debit'=>$diff<0?abs($diff):0,'kredit'=>$diff>0?$diff:0);}
$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$header->return_no,'keterangan'=>$desc,'ref_type'=>'purchase_return','ref_id'=>$header->id,'created_by'=>$user),$entries,false,true);
$this->CI->db->where('purchase_return_id',$header->id)->where('status','applied')->update('supplier_debit_notes',array('journal_id'=>$journal,'posted_by'=>(int)$user,'posted_at'=>date('Y-m-d H:i:s')));
if($totals['refund']>.001)$this->createAutomaticRefundClaim($header->supplier_id,$header->purchase_order_id,null,$header->id,'purchase_return',$header->id,$totals['refund'],$date,'Refund atas retur pembelian '.$header->return_no,$journal,$user);
return$journal;
}
private function createAutomaticDebitNote($line,$source,$invoice,$base,$tax,$applied,$user)
{
if($applied<=.001)return null;$existing=$this->CI->db->where(array('purchase_return_id'=>$line->purchase_return_id,'supplier_invoice_id'=>$invoice->id))->get('supplier_debit_notes')->row();if($existing){$this->CI->db->where('id',$existing->id)->set('base_amount','base_amount+'.$this->CI->db->escape(min($base,$applied)),false)->set('tax_amount','tax_amount+'.$this->CI->db->escape(max(0,$applied-$base)),false)->set('amount','amount+'.$this->CI->db->escape($applied),false)->update('supplier_debit_notes');$id=$existing->id;}else{$number=$this->CI->numberingservice->next('supplier_debit_note',date('Y-m-d'));$this->CI->db->insert('supplier_debit_notes',array('company_id'=>$this->companyId(),'debit_note_no'=>$number,'supplier_id'=>$invoice->supplier_id,'supplier_invoice_id'=>$invoice->id,'purchase_order_id'=>$source->purchase_order_id,'purchase_return_id'=>$line->purchase_return_id,'note_date'=>date('Y-m-d'),'correction_type'=>'return','base_amount'=>min($base,$applied),'tax_amount'=>max(0,$applied-$base),'amount'=>$applied,'reason'=>'Otomatis dari retur pembelian','status'=>'applied','created_by'=>(int)$user,'created_at'=>date('Y-m-d H:i:s')));$id=$this->CI->db->insert_id();$this->debitNoteHistory($id,null,'applied',$user,'Debit Note otomatis dari retur pembelian setelah invoice.');}$this->CI->db->insert('supplier_debit_note_lines',array('supplier_debit_note_id'=>$id,'purchase_return_line_id'=>$line->id,'supplier_invoice_line_id'=>null,'item_id'=>$line->item_id,'description'=>$line->description,'qty'=>$line->qty,'base_amount'=>min($base,$applied),'tax_amount'=>max(0,$applied-$base),'total_amount'=>$applied,'created_at'=>date('Y-m-d H:i:s')));$this->CI->db->where('id',$line->id)->update('purchase_return_lines',array('debit_note_amount'=>$applied));return$id;
}
private function createAutomaticRefundClaim($supplierId,$poId,$invoiceId,$returnId,$sourceType,$sourceId,$amount,$date,$reason,$journal,$user)
{
$existing=$this->CI->db->where(array('purchase_return_id'=>$returnId,'status !='=>'reversed'))->get('supplier_refund_claims')->row();if($existing)return$existing->id;$number=$this->CI->numberingservice->next('supplier_refund',$date);$this->CI->db->insert('supplier_refund_claims',array('company_id'=>$this->companyId(),'refund_no'=>$number,'supplier_id'=>$supplierId,'purchase_order_id'=>$poId,'supplier_invoice_id'=>$invoiceId,'purchase_return_id'=>$returnId,'claim_date'=>$date,'reason'=>$reason,'source_type'=>$sourceType==='purchase_return'?'paid_return':'debit_note','eligible_amount'=>$amount,'claim_amount'=>$amount,'received_amount'=>0,'balance'=>$amount,'status'=>'waiting_funds','entitlement_journal_id'=>$journal,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s'),'approved_by'=>$user,'approved_at'=>date('Y-m-d H:i:s')));$id=$this->CI->db->insert_id();$this->CI->db->insert('supplier_refund_allocations',array('supplier_refund_claim_id'=>$id,'source_type'=>$sourceType,'source_id'=>$sourceId,'eligible_amount'=>$amount,'allocated_amount'=>$amount,'created_at'=>date('Y-m-d H:i:s')));$this->refundHistory($id,null,'waiting_funds',$user,'Piutang refund dibentuk otomatis dari dokumen '.$sourceType.' #'.$sourceId.'.');return$id;
}
private function sourceLine($id,$lock)
{
$sql="SELECT grl.id receipt_line_id,grl.goods_receipt_id,grl.item_id,grl.warehouse_id,grl.qty received_qty,grl.advance_journal_id,gr.receipt_no,gr.supplier_id,po.id purchase_order_id,po.po_no,po.paid_amount po_paid_amount,pol.description,COALESCE(pol.tracking_type,'QTY') tracking_type,COALESCE(pol.need_serial_number,0) need_serial_number,COALESCE(i.harga_beli,pol.unit_price) unit_cost,COALESCE(k.kode_barang,'-') item_code,COALESCE(k.nama,i.nama_barang,pol.description) item_name FROM goods_receipt_lines grl JOIN goods_receipts gr ON gr.id=grl.goods_receipt_id JOIN purchase_order_lines pol ON pol.id=grl.po_line_id JOIN purchase_orders po ON po.id=pol.purchase_order_id LEFT JOIN items i ON i.id=grl.item_id LEFT JOIN kode_barang k ON k.id=pol.kode_barang_id WHERE grl.id=? AND gr.status='posted' AND po.company_id=?".($lock?' FOR UPDATE':'');
return$this->CI->db->query($sql,array((int)$id,$this->companyId()))->row();
}
private function validateBarcode($line,$barcodeId,$qty,$lock)
{
if(!$barcodeId)throw new BusinessException('Barcode/batch barang '.$line->description.' wajib dipilih.');$sql='SELECT * FROM item_barcodes WHERE id=?'.($lock?' FOR UPDATE':'');$barcode=$this->CI->db->query($sql,array($barcodeId))->row();if(!$barcode||(int)$barcode->item_id!==(int)$line->item_id||(int)$barcode->source_receipt_line_id!==(int)$line->receipt_line_id)throw new BusinessException('Barcode/serial tidak berasal dari penerimaan yang dipilih.');if(!in_array($barcode->status,array('available','pending','return_reserved'),true))throw new BusinessException('Barcode '.$barcode->barcode.' tidak berada di gudang atau tidak dapat diretur.');$available=(float)$barcode->qty_sisa-(float)$barcode->return_reserved_qty;if($qty<=0||$qty>$available+.0001)throw new BusinessException('Qty retur melebihi saldo barcode '.$barcode->barcode.'.');if($line->tracking_type==='UNIT'&&abs($qty-1)>.0001)throw new BusinessException('Barang UNIT wajib diretur satu barcode/serial per baris.');return$barcode;
}
private function assertReturnLineAvailable($line,$lock)
{
$source=$this->sourceLine($line->receipt_line_id,$lock);if(!$source)throw new BusinessException('Dokumen penerimaan sumber tidak ditemukan.');$committed=$this->committedReturnQty($line->receipt_line_id,$line->purchase_return_id);if((float)$line->qty>(float)$source->received_qty-$committed+.0001)throw new BusinessException('Saldo retur '.$line->description.' telah digunakan dokumen lain.');return$this->validateBarcode($source,$line->barcode_id,$line->qty,$lock);
}
private function committedReturnQty($receiptLineId,$excludeReturnId=0)
{
$this->CI->db->select('COALESCE(SUM(l.qty),0) total',false)->from('purchase_return_lines l')->join('purchase_returns h','h.id=l.purchase_return_id')->where('l.receipt_line_id',(int)$receiptLineId)->where_not_in('h.status',array('draft','rejected','cancelled','reversed'));if($excludeReturnId)$this->CI->db->where('h.id !=',(int)$excludeReturnId);$row=$this->CI->db->get()->row();return$row?(float)$row->total:0;
}
private function returnTaxForLine($receiptLineId,$qty)
{
$row=$this->CI->db->select('sil.qty,sil.tax_amount')->from('supplier_invoice_lines sil')->join('supplier_invoices si','si.id=sil.supplier_invoice_id')->where('sil.receipt_line_id',(int)$receiptLineId)->where_in('si.status',array('partial','paid'))->order_by('si.id')->limit(1)->get()->row();return$row&&(float)$row->qty>0?round(((float)$row->tax_amount/(float)$row->qty)*$qty,2):0;
}
private function writeLedger($type,$documentId,$lineId,$itemId,$warehouseId,$barcodeId,$direction,$qty,$cost,$date,$key)
{
$existing=$this->CI->db->get_where('inventory_ledger',array('idempotency_key'=>$key))->row();if($existing)return(int)$existing->id;$this->CI->db->insert('inventory_ledger',array('item_id'=>$itemId,'warehouse_id'=>$warehouseId,'barcode_id'=>$barcodeId,'movement_date'=>$date,'direction'=>$direction,'qty'=>$qty,'unit_cost'=>$cost,'value'=>$qty*$cost,'document_type'=>$type,'document_id'=>$documentId,'document_line_id'=>$lineId,'idempotency_key'=>$key,'created_at'=>date('Y-m-d H:i:s')));return(int)$this->CI->db->insert_id();
}
private function createReplacementBarcode($number,$line,$warehouse,$qty,$serial,$pending,$user,$sequence)
{
$barcode=preg_replace('/[^A-Z0-9\-]/','',strtoupper($number.'-'.$line->id.'-'.str_pad($sequence,4,'0',STR_PAD_LEFT)));$this->CI->db->insert('item_barcodes',array('item_id'=>$line->item_id,'barcode'=>$barcode,'serial_number'=>$serial?:null,'warehouse_id'=>$warehouse,'qty_awal'=>$qty,'qty_sisa'=>$qty,'sale_price'=>0,'status'=>$pending?'pending':'available','activated_at'=>$pending?null:date('Y-m-d H:i:s'),'activated_by'=>$pending?null:(int)$user,'source_receipt_line_id'=>$line->receipt_line_id,'created_at'=>date('Y-m-d H:i:s')));return(int)$this->CI->db->insert_id();
}
private function refundSource($type,$id)
{
$company=$this->companyId();
if($type==='purchase_return'){$r=$this->CI->db->get_where('purchase_returns',array('id'=>$id,'company_id'=>$company))->row();if(!$r||!in_array($r->status,array('shipped','waiting_supplier','partially_resolved','resolved'),true))return null;return array('supplier_id'=>$r->supplier_id,'eligible_amount'=>$r->amount,'number'=>$r->return_no,'purchase_order_id'=>$r->purchase_order_id);}
if($type==='supplier_payment'){$r=$this->CI->db->select('p.*,po.transit_cleared_amount,po.paid_amount po_paid_amount,COALESCE((SELECT SUM(a.amount) FROM supplier_payment_allocations a WHERE a.supplier_payment_id=p.id),0) allocated_amount',false)->from('supplier_payments p')->join('purchase_orders po','po.id=p.purchase_order_id','left')->where(array('p.id'=>$id,'p.status'=>'posted','p.company_id'=>$company))->get()->row();if(!$r)return null;if($r->purchase_order_id){$later=(float)$this->CI->db->select('COALESCE(SUM(amount),0) total',false)->where(array('purchase_order_id'=>$r->purchase_order_id,'status'=>'posted'))->where('id >',$r->id)->get('supplier_payments')->row()->total;$eligible=min((float)$r->amount,max(0,(float)$r->po_paid_amount-(float)$r->transit_cleared_amount-$later));}else{$eligible=max(0,(float)$r->amount-(float)$r->allocated_amount);}return array('supplier_id'=>$r->supplier_id,'eligible_amount'=>$eligible,'number'=>$r->payment_no,'purchase_order_id'=>$r->purchase_order_id);}
if($type==='purchase_order'){$r=$this->CI->db->get_where('purchase_orders',array('id'=>$id,'company_id'=>$company))->row();if(!$r||in_array($r->status,array('cancelled','closed'),true))return null;return array('supplier_id'=>$r->supplier_id,'eligible_amount'=>max(0,$r->paid_amount-$r->transit_cleared_amount),'number'=>$r->po_no,'purchase_order_id'=>$r->id);}
if($type==='supplier_invoice'){$r=$this->CI->db->get_where('supplier_invoices',array('id'=>$id,'company_id'=>$company))->row();if(!$r||!in_array($r->status,array('partial','paid'),true))return null;return array('supplier_id'=>$r->supplier_id,'eligible_amount'=>max(0,(float)$r->paid_amount-(float)$r->total),'number'=>$r->internal_no,'supplier_invoice_id'=>$r->id);}
if($type==='supplier_debit_note'){$r=$this->CI->db->get_where('supplier_debit_notes',array('id'=>$id,'company_id'=>$company))->row();if(!$r||!in_array($r->status,array('posted','applied'),true))return null;return array('supplier_id'=>$r->supplier_id,'eligible_amount'=>$r->amount,'number'=>$r->debit_note_no,'supplier_invoice_id'=>$r->supplier_invoice_id,'purchase_order_id'=>$r->purchase_order_id);}
return null;
}
private function allocatedRefundAmount($type,$id){$row=$this->CI->db->select('COALESCE(SUM(a.allocated_amount),0) total',false)->from('supplier_refund_allocations a')->join('supplier_refund_claims c','c.id=a.supplier_refund_claim_id')->where(array('a.source_type'=>$type,'a.source_id'=>$id))->where_not_in('c.status',array('rejected','cancelled','reversed'))->get()->row();return$row?(float)$row->total:0;}
private function refundCreditAccount($claim){if(in_array($claim->source_type,array('prepayment','cancelled_order','overpayment'),true))return$this->CI->accountmappingservice->get('goods_in_transit');return$this->CI->accountmappingservice->get('purchase_return_adjustment');}
private function cashAccount($id){return$this->CI->db->where(array('id'=>(int)$id,'company_id'=>$this->companyId(),'is_active'=>1,'allow_posting'=>1))->group_start()->like('nama_akun','kas')->or_like('nama_akun','bank')->group_end()->get('accounts')->row();}
private function hasAttachment($type,$id){return(bool)$this->CI->db->where(array('entity_type'=>$type,'entity_id'=>(int)$id))->count_all_results('transaction_attachments');}
private function companyId(){return(int)$this->CI->companycontext->id();}
private function isMasterAdmin(){return$this->CI->session->userdata('role')==='Admin';}
private function validDate($date,$label){$parsed=DateTime::createFromFormat('Y-m-d',(string)$date);if(!$parsed||$parsed->format('Y-m-d')!==$date)throw new BusinessException($label.' tidak valid.');return$date;}
private function enum($value,array$allowed,$fallback){return in_array($value,$allowed,true)?$value:$fallback;}
private function lockReturn($id,array$status){$row=$this->CI->db->query('SELECT * FROM purchase_returns WHERE id=? FOR UPDATE',array((int)$id))->row();if(!$row||!in_array($row->status,$status,true))throw new BusinessException('Dokumen retur tidak ditemukan atau statusnya tidak mengizinkan tindakan ini.');if($this->CI->db->field_exists('company_id','purchase_returns')&&(int)$row->company_id!==$this->companyId())throw new BusinessException('Dokumen retur berasal dari perusahaan lain.',array(),403);return$row;}
private function lockClaim($id,array$status){$row=$this->CI->db->query('SELECT * FROM supplier_refund_claims WHERE id=? FOR UPDATE',array((int)$id))->row();if(!$row||!in_array($row->status,$status,true))throw new BusinessException('Dokumen refund tidak ditemukan atau statusnya tidak mengizinkan tindakan ini.');if((int)$row->company_id!==$this->companyId())throw new BusinessException('Dokumen refund berasal dari perusahaan lain.',array(),403);return$row;}
private function history($id,$from,$to,$user,$notes){$this->CI->db->insert('purchase_return_status_history',array('purchase_return_id'=>$id,'from_status'=>$from,'to_status'=>$to,'notes'=>$notes,'user_id'=>(int)$user,'ip_address'=>$this->CI->input->ip_address(),'session_id'=>session_id()?:null,'created_at'=>date('Y-m-d H:i:s')));}
private function refundHistory($id,$from,$to,$user,$notes){$this->CI->db->insert('supplier_refund_status_history',array('supplier_refund_claim_id'=>$id,'from_status'=>$from,'to_status'=>$to,'notes'=>$notes,'user_id'=>(int)$user,'ip_address'=>$this->CI->input->ip_address(),'session_id'=>session_id()?:null,'created_at'=>date('Y-m-d H:i:s')));}
private function debitNoteHistory($id,$from,$to,$user,$notes){$this->CI->db->insert('supplier_debit_note_status_history',array('supplier_debit_note_id'=>$id,'from_status'=>$from,'to_status'=>$to,'notes'=>$notes,'user_id'=>(int)$user,'ip_address'=>$this->CI->input->ip_address(),'session_id'=>session_id()?:null,'created_at'=>date('Y-m-d H:i:s')));}
}