CI=&get_instance(); $this->CI->load->library(array('TransactionService','NumberingService','ApprovalService','AuditService','PostingService','AccountMappingService','FiscalPeriodService','CompanyContext')); $this->CI->load->model('InventoryModel','professionalInventoryModel'); } public function createDocument(array $data,$user) { return $this->CI->transactionservice->run(function()use($data,$user){ $company=$this->CI->companycontext->id(); $types=array('opening','in','out','transfer','adjustment','opname','sales_return','purchase_return','damaged','lost'); $type=trim((string)($data['type']??''));$date=$data['date']??date('Y-m-d'); if(!in_array($type,$types,true))throw new BusinessException('Jenis mutasi tidak valid.'); if(!$this->validDate($date))throw new BusinessException('Tanggal dokumen tidak valid.'); $key=trim((string)($data['idempotency_key']??''));if($key==='')throw new BusinessException('Kunci idempotensi tidak tersedia.'); $existing=$this->CI->db->get_where('stock_documents',array('company_id'=>$company,'idempotency_key'=>$key))->row();if($existing)return$existing->id; $from=$this->nullableId($data['from_warehouse_id']??null);$to=$this->nullableId($data['to_warehouse_id']??null); $this->validateWarehouses($type,$from,$to,$company); $lines=!empty($data['lines'])&&is_array($data['lines'])?$data['lines']:array($data); if(!$lines)throw new BusinessException('Minimal satu barang wajib diisi.'); $no=$this->CI->numberingservice->next('stock_document',$date); $this->CI->db->insert('stock_documents',array('company_id'=>$company,'document_no'=>$no,'document_type'=>$type,'document_date'=>$date,'from_warehouse_id'=>$from,'to_warehouse_id'=>$to,'status'=>'draft','reason'=>trim((string)($data['reason']??'')),'source_type'=>trim((string)($data['source_type']??''))?:null,'source_id'=>$this->nullableId($data['source_id']??null),'idempotency_key'=>$key,'created_by'=>(int)$user,'created_at'=>date('Y-m-d H:i:s'))); $id=$this->CI->db->insert_id();$lineNo=0; foreach($lines as$line){$lineNo++;$this->insertLine($id,$lineNo,$type,$from,$to,$line,$company,$data['reason']??'');} $this->history($id,null,'draft','Dokumen dibuat',$user); return$id; }); } public function submit($id,$user) { return$this->CI->transactionservice->run(function()use($id,$user){ $doc=$this->lockDoc($id,'draft');$needsApproval=in_array($doc->document_type,array('adjustment','opname','damaged','lost'),true); $next=$needsApproval?'submitted':'approved'; if($needsApproval){$amount=$this->documentAmount($id);$this->CI->approvalservice->create('inventory','stock_document',$id,$amount,$user,array('document'=>$doc,'lines'=>$this->CI->db->get_where('stock_document_lines',array('stock_document_id'=>$id))->result()));} $update=array('status'=>$next,'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')); if(!$needsApproval){$update['approved_by']=(int)$user;$update['approved_at']=date('Y-m-d H:i:s');} $this->CI->db->where('id',$id)->update('stock_documents',$update);$this->history($id,'draft',$next,$needsApproval?'Diajukan untuk persetujuan':'Disetujui otomatis sesuai jenis mutasi',$user);return$id; }); } public function approve($id,$user,$role) { return$this->CI->transactionservice->run(function()use($id,$user,$role){ $doc=$this->lockDoc($id,'submitted');$request=$this->CI->approvalservice->pendingForEntity('stock_document',$id);if(!$request)throw new BusinessException('Approval dokumen stok tidak ditemukan.'); $approval=$this->CI->approvalservice->act($request->id,'approved',$user,$role); if($approval->status==='approved'){$this->CI->db->where('id',$id)->update('stock_documents',array('status'=>'approved','approved_by'=>(int)$user,'approved_at'=>date('Y-m-d H:i:s'),'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s')));$this->history($id,'submitted','approved','Dokumen disetujui',$user);}return$id; }); } public function reject($id,$user,$role,$reason) { return$this->CI->transactionservice->run(function()use($id,$user,$role,$reason){ $reason=trim((string)$reason);if($reason==='')throw new BusinessException('Alasan penolakan wajib diisi.');$this->lockDoc($id,'submitted'); $request=$this->CI->approvalservice->pendingForEntity('stock_document',$id);if(!$request)throw new BusinessException('Approval dokumen stok tidak ditemukan.'); $this->CI->approvalservice->act($request->id,'rejected',$user,$role,$reason); $this->CI->db->where('id',$id)->update('stock_documents',array('status'=>'rejected','rejected_by'=>(int)$user,'rejected_at'=>date('Y-m-d H:i:s'),'rejection_reason'=>$reason,'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s')));$this->history($id,'submitted','rejected',$reason,$user);return$id; }); } public function post($id,$user) { return$this->CI->transactionservice->run(function()use($id,$user){$doc=$this->lockDoc($id,'approved');$this->applyDocument($doc,$user);return$id;}); } public function reverse($id,$date,$reason,$user) { return$this->CI->transactionservice->run(function()use($id,$date,$reason,$user){ $original=$this->lockDoc($id,'posted');$reason=trim((string)$reason);if($reason==='')throw new BusinessException('Alasan reversal wajib diisi.');if(!$this->validDate($date))throw new BusinessException('Tanggal reversal tidak valid.'); if($this->CI->db->where('reversal_of_id',$id)->where_in('status',array('approved','posted'))->get('stock_documents')->row())throw new BusinessException('Dokumen ini sudah mempunyai reversal.'); $type=$original->document_type;$from=$original->from_warehouse_id;$to=$original->to_warehouse_id; if($type==='transfer'){$tmp=$from;$from=$to;$to=$tmp;}elseif(in_array($type,array('opening','in','sales_return'),true))$type='out';elseif(in_array($type,array('out','purchase_return','damaged','lost'),true))$type='in';else$type='adjustment'; $no=$this->CI->numberingservice->next('stock_document',$date);$key='REV-STOCK-'.$id; $this->CI->db->insert('stock_documents',array('company_id'=>$original->company_id,'document_no'=>$no,'document_type'=>$type,'document_date'=>$date,'from_warehouse_id'=>$from,'to_warehouse_id'=>$to,'status'=>'approved','reason'=>'Reversal '.$original->document_no.': '.$reason,'source_type'=>'stock_document','source_id'=>$id,'idempotency_key'=>$key,'created_by'=>(int)$user,'submitted_by'=>(int)$user,'submitted_at'=>date('Y-m-d H:i:s'),'approved_by'=>(int)$user,'approved_at'=>date('Y-m-d H:i:s'),'reversal_of_id'=>$id,'created_at'=>date('Y-m-d H:i:s')));$reverseId=$this->CI->db->insert_id(); $lines=$this->CI->db->get_where('stock_document_lines',array('stock_document_id'=>$id))->result();$n=0; foreach($lines as$l){$n++;$direction=null;if($type==='adjustment'){$ledger=$this->CI->db->where(array('document_id'=>$id,'document_line_id'=>$l->id))->get('inventory_ledger')->row();$direction=$ledger&&$ledger->direction==='in'?'out':'in';}$this->CI->db->insert('stock_document_lines',array('stock_document_id'=>$reverseId,'line_no'=>$n,'item_id'=>$l->item_id,'barcode_id'=>$l->barcode_id,'batch_id'=>$l->batch_id,'from_bin_id'=>$original->document_type==='transfer'?$l->to_bin_id:$l->from_bin_id,'to_bin_id'=>$original->document_type==='transfer'?$l->from_bin_id:$l->to_bin_id,'qty'=>$original->document_type==='opname'?abs((float)$l->counted_qty-(float)$l->system_qty):$l->qty,'movement_direction'=>$direction,'unit_cost'=>$l->unit_cost,'reason'=>'Reversal baris #'.$l->id,'source_line_type'=>'stock_document_line','source_line_id'=>$l->id));} $this->history($reverseId,null,'approved','Dokumen reversal dibuat dari '.$original->document_no,$user);$reverse=$this->lockDoc($reverseId,'approved');$this->applyDocument($reverse,$user); $this->CI->db->where('id',$id)->update('stock_documents',array('status'=>'reversed','reversed_by'=>(int)$user,'reversed_at'=>date('Y-m-d H:i:s'),'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s')));$this->history($id,'posted','reversed','Dibalik oleh '.$no.': '.$reason,$user);return$reverseId; }); } public function reserve($item,$warehouse,$barcode,$qty,$sourceType,$sourceId,$expires,$user,$notes=null) { return$this->CI->transactionservice->run(function()use($item,$warehouse,$barcode,$qty,$sourceType,$sourceId,$expires,$user,$notes){ $company=$this->CI->companycontext->id();$item=(int)$item;$warehouse=(int)$warehouse;$barcode=$this->nullableId($barcode);$qty=(float)$qty;if($qty<=0)throw new BusinessException('Qty reservasi tidak valid.'); $this->assertItem($item,$company);$this->assertWarehouse($warehouse,$company);if($qty>$this->available($item,$warehouse,true)+.0001)throw new BusinessException('Stok tersedia tidak mencukupi reservasi.'); $sourceType=trim((string)$sourceType);$sourceId=(int)$sourceId;if($sourceType===''||$sourceId<=0)throw new BusinessException('Dokumen sumber reservasi wajib diisi.'); $existing=$this->CI->db->where(array('company_id'=>$company,'source_type'=>$sourceType,'source_id'=>$sourceId,'item_id'=>$item,'warehouse_id'=>$warehouse,'barcode_id'=>$barcode))->where_in('status',array('active','partially_released'))->get('stock_reservations')->row();if($existing)return$existing->id; if($barcode)$this->changeBarcodeReservation($barcode,$item,$warehouse,$qty); $no=$this->CI->numberingservice->next('stock_reservation',date('Y-m-d'));$this->CI->db->insert('stock_reservations',array('company_id'=>$company,'reservation_no'=>$no,'item_id'=>$item,'warehouse_id'=>$warehouse,'barcode_id'=>$barcode,'source_type'=>$sourceType,'source_id'=>$sourceId,'qty'=>$qty,'status'=>'active','expires_at'=>$expires?:null,'notes'=>$notes,'created_by'=>(int)$user,'created_at'=>date('Y-m-d H:i:s')));$id=$this->CI->db->insert_id();$this->reservationHistory($id,null,'active',$qty,'Reservasi dibuat',$user);return$id; }); } public function reservationAction($id,$action,$qty,$notes,$user) { return$this->CI->transactionservice->run(function()use($id,$action,$qty,$notes,$user){ $r=$this->CI->db->query('SELECT * FROM stock_reservations WHERE id=? AND company_id=? FOR UPDATE',array((int)$id,$this->CI->companycontext->id()))->row();if(!$r||!in_array($r->status,array('active','partially_released'),true))throw new BusinessException('Reservasi sudah tidak aktif.'); if(!in_array($action,array('release','consume','cancel'),true))throw new BusinessException('Tindakan reservasi tidak valid.');$remaining=(float)$r->qty-(float)$r->released_qty;$qty=$action==='cancel'?$remaining:(float)$qty;if($qty<=0||$qty>$remaining+.0001)throw new BusinessException('Qty penyelesaian reservasi tidak valid.'); if($r->barcode_id)$this->changeBarcodeReservation($r->barcode_id,$r->item_id,$r->warehouse_id,-$qty); $released=(float)$r->released_qty+$qty;$status=$released+0.0001>=(float)$r->qty?($action==='consume'?'consumed':($action==='cancel'?'cancelled':'released')):'partially_released'; $this->CI->db->where('id',$r->id)->update('stock_reservations',array('released_qty'=>$released,'status'=>$status,'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s'),'completed_at'=>in_array($status,array('released','consumed','cancelled'),true)?date('Y-m-d H:i:s'):null,'notes'=>trim((string)$notes)?:$r->notes));$this->reservationHistory($r->id,$r->status,$status,$qty,$notes,$user);return$r->id; }); } public function expireReservations($user=0) { $rows=$this->CI->db->where('company_id',$this->CI->companycontext->id())->where_in('status',array('active','partially_released'))->where('expires_at IS NOT NULL',null,false)->where('expires_at <',date('Y-m-d H:i:s'))->get('stock_reservations')->result();$count=0; foreach($rows as$r){try{$this->reservationAction($r->id,'cancel',0,'Kedaluwarsa otomatis',$user);$count++;}catch(Throwable$e){log_message('error','Gagal menutup reservasi #'.$r->id.': '.$e->getMessage());}}return$count; } public function balance($item,$warehouse,$lock=false) { $sql="SELECT COALESCE(SUM(IF(direction='in',qty,-qty)),0) qty FROM inventory_ledger WHERE item_id=? AND warehouse_id=?".($lock?' FOR UPDATE':'');return(float)$this->CI->db->query($sql,array((int)$item,(int)$warehouse))->row()->qty; } public function available($item,$warehouse,$lock=false) { $onHand=$this->balance($item,$warehouse,$lock);$r=(float)$this->CI->db->select('COALESCE(SUM(qty-released_qty),0) qty',false)->where(array('company_id'=>$this->CI->companycontext->id(),'item_id'=>(int)$item,'warehouse_id'=>(int)$warehouse))->where_in('status',array('active','partially_released'))->get('stock_reservations')->row()->qty;$ledgerAvailable=$onHand-$r; if($lock)$this->CI->db->query('SELECT id FROM item_barcodes WHERE item_id=? AND warehouse_id=? AND qty_sisa>0 FOR UPDATE',array((int)$item,(int)$warehouse)); $barcode=$this->CI->db->query("SELECT COUNT(*) row_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_qty FROM item_barcodes WHERE item_id=? AND warehouse_id=? AND qty_sisa>0",array((int)$item,(int)$warehouse))->row(); return$barcode&&(int)$barcode->row_count>0?min($ledgerAvailable,(float)$barcode->available_qty):$ledgerAvailable; } public function valuation($filters=array()){return$this->CI->professionalInventoryModel->valuation($this->CI->companycontext->id(),$filters);} private function insertLine($documentId,$lineNo,$type,$from,$to,array$line,$company,$defaultReason) { $itemId=(int)($line['item_id']??0);$item=$this->assertItem($itemId,$company);$catalog=$this->CI->db->get_where('kode_barang',array('id'=>$item->kode_id))->row();$qty=(float)($line['qty']??0);if($qty<=0)throw new BusinessException('Qty baris '.$lineNo.' harus lebih dari nol.');if(empty($item->allow_decimal)&&abs($qty-round($qty))>.0001)throw new BusinessException('Barang '.$item->nama_barang.' tidak mengizinkan qty pecahan.'); $barcode=$this->nullableId($line['barcode_id']??null);if($barcode){$b=$this->CI->db->get_where('item_barcodes',array('id'=>$barcode,'item_id'=>$itemId))->row();if(!$b)throw new BusinessException('Barcode baris '.$lineNo.' tidak sesuai barang.');} $unit=$catalog&&strtoupper((string)$catalog->tracking_type)==='UNIT';$outgoing=in_array($type,array('out','transfer','purchase_return','damaged','lost'),true)||($type==='adjustment'&&($line['movement_direction']??'')==='out');if($unit&&$outgoing&&!$barcode)throw new BusinessException('Barcode wajib dipilih untuk barang UNIT pada baris '.$lineNo.'.');if($unit&&$barcode&&abs($qty-1)>.0001)throw new BusinessException('Satu baris barcode UNIT harus berjumlah 1 pada baris '.$lineNo.'.'); $system=null;$counted=null;if($type==='opname'){$system=$this->balance($itemId,$from,true);$counted=$qty;} $direction=$line['movement_direction']??null;if($type==='adjustment'&&!in_array($direction,array('in','out'),true))throw new BusinessException('Arah penyesuaian baris '.$lineNo.' wajib dipilih.'); $this->CI->db->insert('stock_document_lines',array('stock_document_id'=>$documentId,'line_no'=>$lineNo,'item_id'=>$itemId,'barcode_id'=>$barcode,'batch_id'=>$this->nullableId($line['batch_id']??null),'from_bin_id'=>$this->nullableId($line['from_bin_id']??null),'to_bin_id'=>$this->nullableId($line['to_bin_id']??null),'qty'=>$qty,'movement_direction'=>$direction,'unit_cost'=>(float)($line['unit_cost']??0),'system_qty'=>$system,'counted_qty'=>$counted,'reason'=>trim((string)($line['reason']??$defaultReason)),'source_line_type'=>trim((string)($line['source_line_type']??''))?:null,'source_line_id'=>$this->nullableId($line['source_line_id']??null))); } private function applyDocument($doc,$user) { $this->CI->fiscalperiodservice->assertOpen($doc->document_date);$lines=$this->CI->db->get_where('stock_document_lines',array('stock_document_id'=>$doc->id))->result();if(!$lines)throw new BusinessException('Detail mutasi tidak ditemukan.');$journal=array();$items=array(); foreach($lines as$line){list($direction,$warehouse,$qty)=$this->movement($doc,$line);if($qty<=.0001){$items[$line->item_id]=true;continue;}$cost=$direction==='out'?$this->outCost($line->item_id,$warehouse,$qty):((float)$line->unit_cost?:$this->averageCost($line->item_id,$warehouse)); if($doc->document_type==='transfer'){$cost=$this->outCost($line->item_id,$doc->from_warehouse_id,$qty);$this->writeLedger($doc,$line,$doc->from_warehouse_id,'out',$qty,$cost,'OUT');$this->writeLedger($doc,$line,$doc->to_warehouse_id,'in',$qty,$cost,'IN');$this->moveBarcode($doc,$line,$doc->from_warehouse_id,$doc->to_warehouse_id,$qty,'transfer');$this->writeMovement($doc,$line,$qty,'transfer',$doc->from_warehouse_id,$doc->to_warehouse_id);} else{$this->writeLedger($doc,$line,$warehouse,$direction,$qty,$cost,strtoupper($direction));$this->moveBarcode($doc,$line,$direction==='out'?$warehouse:null,$direction==='in'?$warehouse:null,$qty,$direction);$this->collectJournal($journal,$doc,$qty*$cost,$direction);}$items[$line->item_id]=true; } $this->postJournal($doc,$journal,$user);foreach(array_keys($items)as$item)$this->CI->professionalInventoryModel->synchronizeStock($item); $this->CI->db->where('id',$doc->id)->update('stock_documents',array('status'=>'posted','posted_by'=>(int)$user,'posted_at'=>date('Y-m-d H:i:s'),'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s')));$this->history($doc->id,'approved','posted','Mutasi dan jurnal diposting',$user);$this->CI->auditservice->record('inventory','stock_document',$doc->id,'post',$doc,$this->CI->db->get_where('stock_documents',array('id'=>$doc->id))->row(),$user); } private function movement($doc,$line) { if($doc->document_type==='opname'){$current=$this->balance($line->item_id,$doc->from_warehouse_id,true);if(abs($current-(float)$line->system_qty)>.0001)throw new BusinessException('Stok berubah setelah snapshot opname pada baris '.$line->line_no.'. Buat ulang dokumen opname agar hasil tidak salah.');$delta=(float)$line->counted_qty-(float)$line->system_qty;return array($delta>=0?'in':'out',$doc->from_warehouse_id,abs($delta));} $direction=in_array($doc->document_type,array('out','purchase_return','damaged','lost'),true)?'out':'in';if($doc->document_type==='adjustment')$direction=$line->movement_direction;return array($direction,$direction==='in'?($doc->to_warehouse_id?:$doc->from_warehouse_id):$doc->from_warehouse_id,(float)$line->qty); } private function writeLedger($doc,$line,$warehouse,$direction,$qty,$cost,$suffix) { if($direction==='out'&&$this->available($line->item_id,$warehouse,true)<$qty-.0001)throw new BusinessException('Stok tersedia tidak cukup untuk '.$line->item_id.'; stok negatif ditolak.');$key=$doc->idempotency_key.'-'.$suffix.'-'.$line->id;if($this->CI->db->get_where('inventory_ledger',array('idempotency_key'=>$key))->row())return; $this->CI->db->insert('inventory_ledger',array('item_id'=>$line->item_id,'warehouse_id'=>$warehouse,'bin_id'=>$direction==='in'?$line->to_bin_id:$line->from_bin_id,'barcode_id'=>$line->barcode_id,'batch_id'=>$line->batch_id,'movement_date'=>$doc->document_date,'direction'=>$direction,'qty'=>$qty,'unit_cost'=>$cost,'value'=>$qty*$cost,'document_type'=>$doc->document_type,'document_id'=>$doc->id,'document_line_id'=>$line->id,'idempotency_key'=>$key,'created_at'=>date('Y-m-d H:i:s')));$ledger=$this->CI->db->insert_id(); $this->CI->db->insert('stock_logs',array('item_id'=>$line->item_id,'barcode_id'=>$line->barcode_id,'warehouse_id'=>$warehouse,'qty'=>$qty,'tipe'=>$direction==='in'?'masuk':'keluar','unit_cost'=>$cost,'keterangan'=>'[AUTO] '.strtoupper($doc->document_type).' '.$doc->document_no.' | '.$doc->reason.' | Baris '.$line->line_no,'ref_type'=>'stock_document','ref_id'=>$doc->id,'idempotency_key'=>$key)); if($direction==='in')$this->CI->db->insert('inventory_cost_layers',array('item_id'=>$line->item_id,'warehouse_id'=>$warehouse,'source_ledger_id'=>$ledger,'received_date'=>$doc->document_date,'original_qty'=>$qty,'remaining_qty'=>$qty,'unit_cost'=>$cost,'status'=>'open')); if($doc->document_type!=='transfer'){$movementType=$doc->document_type==='sales_return'?'customer_return':($doc->document_type==='purchase_return'?'purchase_return':'adjustment');$this->writeMovement($doc,$line,$qty,$movementType,$direction==='out'?$warehouse:0,$direction==='in'?$warehouse:0);} } private function writeMovement($doc,$line,$qty,$movementType,$from,$to) { if(!$this->CI->db->where(array('reference_type'=>'stock_document','reference_id'=>$doc->id,'item_id'=>$line->item_id,'barcode_id'=>$line->barcode_id,'qty'=>$qty,'movement_type'=>$movementType))->get('item_movements')->row())$this->CI->db->insert('item_movements',array('item_id'=>$line->item_id,'barcode_id'=>$line->barcode_id,'qty'=>$qty,'from_type'=>'warehouse','from_id'=>$from,'to_type'=>'warehouse','to_id'=>$to,'movement_type'=>$movementType,'reference_type'=>'stock_document','reference_id'=>$doc->id,'notes'=>'[AUTO] '.$doc->document_no.' | '.$doc->reason,'created_at'=>date('Y-m-d H:i:s'))); } private function outCost($item,$warehouse,$qty) { $row=$this->CI->db->select('costing_method')->get_where('items',array('id'=>$item))->row();$method=$row?$row->costing_method:'average';if($method==='average')return$this->averageCost($item,$warehouse);$remaining=$qty;$value=0; foreach($this->CI->db->query("SELECT * FROM inventory_cost_layers WHERE item_id=? AND warehouse_id=? AND status='open' AND remaining_qty>0 ORDER BY received_date,id FOR UPDATE",array($item,$warehouse))->result()as$l){$take=min($remaining,(float)$l->remaining_qty);$value+=$take*(float)$l->unit_cost;$left=(float)$l->remaining_qty-$take;$this->CI->db->where('id',$l->id)->update('inventory_cost_layers',array('remaining_qty'=>$left,'status'=>$left<=.0001?'consumed':'open'));$remaining-=$take;if($remaining<=.0001)break;}if($remaining>.0001)throw new BusinessException('Layer FIFO tidak mencukupi.');return$value/$qty; } private function averageCost($item,$warehouse) { $r=$this->CI->db->query("SELECT SUM(IF(direction='in',qty,-qty)) qty,SUM(IF(direction='in',value,-value)) value FROM inventory_ledger WHERE item_id=? AND warehouse_id=?",array($item,$warehouse))->row();if($r&&(float)$r->qty>0)return(float)$r->value/(float)$r->qty;$i=$this->CI->db->get_where('items',array('id'=>$item))->row();return$i?(float)$i->harga_beli:0; } private function moveBarcode($doc,$line,$from,$to,$qty,$mode) { if(!$line->barcode_id){if($mode==='in')$this->createIncomingBarcodes($doc,$line,$to,$qty);elseif(in_array($mode,array('out','transfer'),true))$this->moveUnspecifiedBarcodes($doc,$line,$from,$to,$qty,$mode);return;}$b=$this->CI->db->query('SELECT * FROM item_barcodes WHERE id=? FOR UPDATE',array($line->barcode_id))->row();if(!$b)throw new BusinessException('Barcode tidak ditemukan.');if($from&&(int)$b->warehouse_id!==(int)$from)throw new BusinessException('Barcode sudah berada di lokasi lain.');$available=(float)$b->qty_sisa-(float)$b->reserved_qty-(float)($b->return_reserved_qty??0);if(in_array($mode,array('out','transfer'),true)&&$available<$qty-.0001)throw new BusinessException('Qty barcode tidak cukup atau sedang direservasi.');if($mode==='transfer'&&((float)$b->reserved_qty>.0001||(float)($b->return_reserved_qty??0)>.0001))throw new BusinessException('Barcode yang sedang direservasi tidak dapat dipindahkan.'); if($mode==='transfer'&&$qty<(float)$b->qty_sisa-.0001){$this->CI->db->where(array('id'=>$b->id,'version'=>$b->version))->update('item_barcodes',array('qty_sisa'=>(float)$b->qty_sisa-$qty,'version'=>(int)$b->version+1));if($this->CI->db->affected_rows()!==1)throw new BusinessException('Barcode sedang diproses transaksi lain.');$copy=(array)$b;unset($copy['id']);$copy['barcode']=$b->barcode.'-T'.$doc->id.'-'.$line->line_no;$copy['warehouse_id']=$to;$copy['bin_id']=$line->to_bin_id;$copy['qty_awal']=$qty;$copy['qty_sisa']=$qty;$copy['reserved_qty']=0;$copy['return_reserved_qty']=0;$copy['version']=1;$copy['created_at']=date('Y-m-d H:i:s');$this->CI->db->insert('item_barcodes',$copy);return;} $data=array('version'=>(int)$b->version+1);if($to){$data['warehouse_id']=$to;$data['bin_id']=$line->to_bin_id;}if($mode==='out'){$data['qty_sisa']=(float)$b->qty_sisa-$qty;$terminal=$doc->document_type==='damaged'?'closed':($doc->document_type==='lost'?'closed':($doc->document_type==='purchase_return'?'returned':'sold_out'));$data['status']=$data['qty_sisa']<=.0001?$terminal:((float)$b->reserved_qty>.0001?'reserved':'available');}elseif($mode==='in'){$data['qty_sisa']=(float)$b->qty_sisa+$qty;$data['status']='available';}$this->CI->db->where(array('id'=>$b->id,'version'=>$b->version))->update('item_barcodes',$data);if($this->CI->db->affected_rows()!==1)throw new BusinessException('Barcode sedang diproses transaksi lain.'); } private function moveUnspecifiedBarcodes($doc,$line,$from,$to,$qty,$mode) { $remaining=$qty;$rows=$this->CI->db->query("SELECT * FROM item_barcodes WHERE item_id=? AND warehouse_id=? AND qty_sisa-reserved_qty-return_reserved_qty>0 AND status IN('available','reserved') ORDER BY id FOR UPDATE",array($line->item_id,$from))->result(); foreach($rows as$b){$available=(float)$b->qty_sisa-(float)$b->reserved_qty-(float)$b->return_reserved_qty;if($available<=.0001)continue;$take=min($remaining,$available);$copy=clone$line;$copy->barcode_id=$b->id;$this->moveBarcode($doc,$copy,$from,$to,$take,$mode);$remaining-=$take;if($remaining<=.0001)break;} if($remaining>.0001)throw new BusinessException('Barcode aktif tidak mencukupi qty mutasi.'); } private function createIncomingBarcodes($doc,$line,$warehouse,$qty) { $item=$this->CI->db->select('i.*,k.tracking_type,k.need_serial_number')->from('items i')->join('kode_barang k','k.id=i.kode_id','left')->where('i.id',$line->item_id)->get()->row();$unit=$item&&strtoupper((string)$item->tracking_type)==='UNIT';if($unit&&abs($qty-round($qty))>.0001)throw new BusinessException('Barang UNIT tidak menerima qty pecahan.');$count=$unit?(int)round($qty):1;$each=$unit?1:$qty; for($n=1;$n<=$count;$n++){$code=strtoupper(preg_replace('/[^A-Za-z0-9]/','',(string)$item->kode_detail)).'-'.$doc->id.'-'.$line->line_no.'-'.str_pad($n,3,'0',STR_PAD_LEFT);$pending=$item&&strtolower((string)$item->need_serial_number)==='true';$this->CI->db->insert('item_barcodes',array('item_id'=>$line->item_id,'barcode'=>$code,'warehouse_id'=>$warehouse,'qty_awal'=>$each,'qty_sisa'=>$each,'sale_price'=>$item->harga_jual,'status'=>$pending?'pending':'available','activated_at'=>$pending?null:date('Y-m-d H:i:s'),'activated_by'=>$pending?null:$doc->created_by,'batch_id'=>$line->batch_id,'bin_id'=>$line->to_bin_id,'version'=>1,'created_at'=>date('Y-m-d H:i:s')));} } private function collectJournal(array&$entries,$doc,$value,$direction) { if($value<=0||in_array($doc->document_type,array('transfer','sales_return','purchase_return'),true))return;$inventory=$this->CI->accountmappingservice->get('inventory');$other=$this->CI->accountmappingservice->get($direction==='in'?'inventory_adjustment_gain':'inventory_adjustment_loss');$lines=$direction==='in'?array(array($inventory,$value,0),array($other,0,$value)):array(array($other,$value,0),array($inventory,0,$value));foreach($lines as$l){if(!isset($entries[$l[0]]))$entries[$l[0]]=array('account_id'=>$l[0],'debit'=>0,'kredit'=>0);$entries[$l[0]]['debit']+=$l[1];$entries[$l[0]]['kredit']+=$l[2];} } private function postJournal($doc,array$entries,$user) { if(!$entries)return;$this->CI->postingservice->post(array('tanggal'=>$doc->document_date,'no_ref'=>'INV-'.$doc->document_no,'keterangan'=>'[AUTO] Mutasi persediaan '.$doc->document_no.' | Jenis '.strtoupper($doc->document_type).' | Alasan: '.$doc->reason,'ref_type'=>'stock_document','ref_id'=>$doc->id,'company_id'=>$doc->company_id,'created_by'=>$user),array_values($entries),false,true); } private function changeBarcodeReservation($barcodeId,$item,$warehouse,$delta) { $b=$this->CI->db->query('SELECT * FROM item_barcodes WHERE id=? FOR UPDATE',array((int)$barcodeId))->row();if(!$b||(int)$b->item_id!==(int)$item||(int)$b->warehouse_id!==(int)$warehouse)throw new BusinessException('Barcode reservasi tidak sesuai.');$new=(float)$b->reserved_qty+$delta;if($new<-.0001||$new>(float)$b->qty_sisa+.0001)throw new BusinessException('Qty reservasi barcode tidak valid.');$status=$new>.0001?'reserved':((float)$b->qty_sisa>.0001?'available':'sold_out');$this->CI->db->where(array('id'=>$b->id,'version'=>$b->version))->update('item_barcodes',array('reserved_qty'=>max(0,$new),'status'=>$status,'version'=>(int)$b->version+1));if($this->CI->db->affected_rows()!==1)throw new BusinessException('Barcode sedang diproses transaksi lain.'); } private function validateWarehouses($type,$from,$to,$company) { if(in_array($type,array('out','purchase_return','damaged','lost','adjustment','opname'),true)&&!$from)throw new BusinessException('Gudang asal wajib dipilih.');if(in_array($type,array('opening','in','sales_return'),true)&&!$to)throw new BusinessException('Gudang tujuan wajib dipilih.');if($type==='transfer'&&(!$from||!$to||$from===$to))throw new BusinessException('Gudang asal dan tujuan transfer wajib berbeda.');if($from)$this->assertWarehouse($from,$company);if($to)$this->assertWarehouse($to,$company); } private function assertWarehouse($id,$company){$w=$this->CI->db->get_where('warehouses',array('id'=>(int)$id,'company_id'=>(int)$company,'is_active'=>1))->row();if(!$w)throw new BusinessException('Gudang tidak valid atau tidak aktif.');return$w;} private function assertItem($id,$company){$i=$this->CI->db->get_where('items',array('id'=>(int)$id,'company_id'=>(int)$company))->row();if(!$i)throw new BusinessException('Barang tidak ditemukan pada perusahaan aktif.');return$i;} private function lockDoc($id,$status){$d=$this->CI->db->query('SELECT * FROM stock_documents WHERE id=? AND company_id=? FOR UPDATE',array((int)$id,$this->CI->companycontext->id()))->row();if(!$d)throw new BusinessException('Dokumen stok tidak ditemukan.');if($d->status!==$status)throw new BusinessException('Status dokumen harus '.$status.'.');return$d;} private function history($id,$from,$to,$notes,$user){$this->CI->db->insert('stock_document_status_history',array('stock_document_id'=>$id,'from_status'=>$from,'to_status'=>$to,'notes'=>$notes,'user_id'=>(int)$user,'created_at'=>date('Y-m-d H:i:s')));} private function reservationHistory($id,$from,$to,$qty,$notes,$user){$this->CI->db->insert('stock_reservation_status_history',array('stock_reservation_id'=>$id,'from_status'=>$from,'to_status'=>$to,'qty'=>$qty,'notes'=>$notes,'user_id'=>(int)$user,'created_at'=>date('Y-m-d H:i:s')));} private function documentAmount($id){$r=$this->CI->db->select('COALESCE(SUM(CASE WHEN counted_qty IS NOT NULL THEN ABS(counted_qty-system_qty)*unit_cost ELSE qty*unit_cost END),0) total',false)->get_where('stock_document_lines',array('stock_document_id'=>$id))->row();return$r?(float)$r->total:0;} private function nullableId($value){$value=(int)$value;return$value>0?$value:null;} private function validDate($value){$d=DateTime::createFromFormat('Y-m-d',(string)$value);return$d&&$d->format('Y-m-d')===$value;} }