Update besar apk finance
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
|
||||
require_once APPPATH . 'exceptions/BusinessException.php';
|
||||
|
||||
class AccountMappingService
|
||||
{
|
||||
private $CI;
|
||||
private $cache = array();
|
||||
private $tableAvailable;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->CI =& get_instance();
|
||||
$this->CI->config->load('accounting', true);
|
||||
$this->tableAvailable = $this->CI->db->table_exists('system_account_mappings');
|
||||
}
|
||||
|
||||
public function get($key, $required = true)
|
||||
{
|
||||
if (array_key_exists($key, $this->cache)) {
|
||||
return $this->cache[$key];
|
||||
}
|
||||
|
||||
$accountId = null;
|
||||
if ($this->tableAvailable) {
|
||||
$row = $this->CI->db
|
||||
->select('account_id')
|
||||
->where('mapping_key', $key)
|
||||
->where('is_active', 1)
|
||||
->get('system_account_mappings')
|
||||
->row();
|
||||
if ($row) {
|
||||
$accountId = (int) $row->account_id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$accountId) {
|
||||
$fallbacks = (array) $this->CI->config->item('system_accounts', 'accounting');
|
||||
$accountId = isset($fallbacks[$key]) ? (int) $fallbacks[$key] : null;
|
||||
}
|
||||
|
||||
if (!$accountId && $required) {
|
||||
throw new BusinessException('Mapping akun sistem "' . $key . '" belum dikonfigurasi.');
|
||||
}
|
||||
|
||||
$this->cache[$key] = $accountId;
|
||||
return $accountId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
require_once APPPATH.'exceptions/BusinessException.php';
|
||||
|
||||
class ApprovalService
|
||||
{
|
||||
private $CI;
|
||||
public function __construct() { $this->CI =& get_instance(); }
|
||||
|
||||
public function create($module, $entityType, $entityId, $amount, $userId, $snapshot)
|
||||
{
|
||||
$existing = $this->CI->db->where(array('entity_type'=>$entityType,'entity_id'=>(int)$entityId,'status'=>'pending'))->get('approval_requests')->row();
|
||||
if ($existing) return $existing;
|
||||
$workflow = $this->CI->db->where(array('module'=>$module,'entity_type'=>$entityType,'is_active'=>1))
|
||||
->group_start()->where('min_amount IS NULL', null, false)->or_where('min_amount <=', $amount)->group_end()
|
||||
->group_start()->where('max_amount IS NULL', null, false)->or_where('max_amount >=', $amount)->group_end()
|
||||
->order_by('min_amount','DESC')->get('approval_workflows')->row();
|
||||
if (!$workflow) throw new BusinessException('Workflow persetujuan belum dikonfigurasi.');
|
||||
$requestNo = 'APR-'.date('Ymd-His').'-'.$entityId.'-'.random_int(100,999);
|
||||
$this->CI->db->insert('approval_requests', array('request_no'=>$requestNo,'workflow_id'=>$workflow->id,'entity_type'=>$entityType,'entity_id'=>(int)$entityId,'amount'=>$amount,'requested_by'=>(int)$userId,'requested_at'=>date('Y-m-d H:i:s'),'current_step'=>1,'status'=>'pending','snapshot'=>json_encode($snapshot, JSON_UNESCAPED_UNICODE)));
|
||||
$id = $this->CI->db->insert_id();
|
||||
$this->CI->db->insert('approval_actions', array('request_id'=>$id,'step_order'=>0,'action'=>'submitted','user_id'=>(int)$userId,'created_at'=>date('Y-m-d H:i:s')));
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
public function act($requestId, $action, $userId, $roleId, $notes = null)
|
||||
{
|
||||
$request = $this->CI->db->query('SELECT * FROM approval_requests WHERE id=? FOR UPDATE', array((int)$requestId))->row();
|
||||
if (!$request || $request->status !== 'pending') throw new BusinessException('Permintaan approval tidak aktif.');
|
||||
$step = $this->CI->db->get_where('approval_workflow_steps', array('workflow_id'=>$request->workflow_id,'step_order'=>$request->current_step))->row();
|
||||
$role = $this->CI->db->get_where('roles', array('id'=>(int)$roleId))->row();
|
||||
$masterAdmin = $role && (!empty($role->is_super_admin) || $role->nama_role === 'Admin');
|
||||
if (!$step || (!$masterAdmin && (($step->approver_user_id && (int)$step->approver_user_id !== (int)$userId) || (!$step->approver_user_id && (int)$step->approver_role_id !== (int)$roleId)))) throw new BusinessException('Anda bukan approver pada tahap ini.', array(), 403);
|
||||
$workflow = $this->CI->db->get_where('approval_workflows', array('id'=>$request->workflow_id))->row();
|
||||
if (!$masterAdmin && $workflow->require_separation && (int)$request->requested_by === (int)$userId) throw new BusinessException('Pembuat transaksi tidak boleh menyetujui transaksinya sendiri.');
|
||||
if (!in_array($action,array('approved','rejected'),true)) throw new BusinessException('Tindakan approval tidak valid.');
|
||||
if ($action === 'rejected' && trim((string)$notes) === '') throw new BusinessException('Alasan penolakan wajib diisi.');
|
||||
$this->CI->db->insert('approval_actions', array('request_id'=>$request->id,'step_order'=>$request->current_step,'action'=>$action,'user_id'=>(int)$userId,'notes'=>$notes,'created_at'=>date('Y-m-d H:i:s')));
|
||||
$next = $this->CI->db->get_where('approval_workflow_steps',array('workflow_id'=>$request->workflow_id,'step_order'=>$request->current_step+1))->row();
|
||||
$final = $action === 'rejected' ? 'rejected' : ($next ? 'pending' : 'approved');
|
||||
$update = array('status'=>$final);
|
||||
if ($next) $update['current_step'] = $request->current_step+1;
|
||||
if ($final !== 'pending') $update['completed_at'] = date('Y-m-d H:i:s');
|
||||
$this->CI->db->where('id',$request->id)->update('approval_requests',$update);
|
||||
return $this->find($request->id);
|
||||
}
|
||||
|
||||
public function pendingFor($userId, $roleId)
|
||||
{
|
||||
$role=$this->CI->db->get_where('roles',array('id'=>(int)$roleId))->row();
|
||||
$masterAdmin=$role&&(!empty($role->is_super_admin)||$role->nama_role==='Admin');
|
||||
if($masterAdmin){$rows=$this->CI->db->select('approval_requests.*, approval_workflows.name workflow_name, approval_workflows.module workflow_module, users.nama requester_name')->from('approval_requests')->join('approval_workflows','approval_workflows.id=approval_requests.workflow_id')->join('users','users.id=approval_requests.requested_by','left')->where('approval_requests.status','pending')->order_by('approval_requests.requested_at','ASC')->get()->result();return $this->withDestinations($rows);}
|
||||
$rows=$this->CI->db->select('approval_requests.*, approval_workflows.name workflow_name, approval_workflows.module workflow_module, users.nama requester_name')
|
||||
->from('approval_requests')->join('approval_workflows','approval_workflows.id=approval_requests.workflow_id')
|
||||
->join('approval_workflow_steps','approval_workflow_steps.workflow_id=approval_requests.workflow_id AND approval_workflow_steps.step_order=approval_requests.current_step')
|
||||
->join('users','users.id=approval_requests.requested_by','left')->where('approval_requests.status','pending')
|
||||
->group_start()->where('approval_workflow_steps.approver_user_id',(int)$userId)->or_group_start()->where('approval_workflow_steps.approver_user_id IS NULL',null,false)->where('approval_workflow_steps.approver_role_id',(int)$roleId)->group_end()->group_end()
|
||||
->order_by('approval_requests.requested_at','ASC')->get()->result();
|
||||
return $this->withDestinations($rows);
|
||||
}
|
||||
private function withDestinations(array $rows)
|
||||
{
|
||||
foreach($rows as $row){$destination=$this->destinationFor($row->entity_type,$row->entity_id,$row->workflow_module);$row->destination_url=base_url($destination[0]);$row->destination_label=$destination[1];}
|
||||
return $rows;
|
||||
}
|
||||
private function destinationFor($type,$id,$module)
|
||||
{
|
||||
$id=(int)$id;
|
||||
$map=array(
|
||||
'journal'=>array('jurnal?focus='.$id,'Buka Jurnal'),
|
||||
'invoice'=>array('invoices/detail/'.$id,'Buka Invoice'),
|
||||
'purchase_request'=>array('purchases?step=request&focus='.$id,'Buka Purchase Request'),
|
||||
'purchase_order'=>array('purchases?step=order&focus='.$id,'Buka Purchase Order'),
|
||||
'purchase_return'=>array('purchases/returns?section=return&focus='.$id,'Buka Pengajuan Retur'),
|
||||
'supplier_refund'=>array('purchases/returns?section=refund&focus='.$id,'Buka Pengajuan Refund'),
|
||||
'supplier_invoice'=>array('purchases?step=invoice&focus='.$id,'Buka Tagihan Supplier'),
|
||||
'stock_document'=>array('inventoryprofessional?focus='.$id,'Buka Dokumen Persediaan'),
|
||||
'cash_transaction'=>array('cashbank?focus='.$id,'Buka Transaksi Kas/Bank'),
|
||||
'payroll_period'=>array('hrpayroll?focus='.$id,'Buka Payroll'),
|
||||
'attendance_correction'=>array('attendancemonitoring?focus='.$id,'Buka Koreksi Absensi'),
|
||||
'budget'=>array('budgets?focus='.$id,'Buka Budget')
|
||||
);
|
||||
if(isset($map[$type]))return $map[$type];
|
||||
$fallback=array('purchase'=>'purchases','payable'=>'purchases?step=invoice','inventory'=>'inventoryprofessional','invoice'=>'invoices','journal'=>'jurnal','cash_bank'=>'cashbank','payroll'=>'hrpayroll','hr'=>'attendancemonitoring','budget'=>'budgets');
|
||||
return array(isset($fallback[$module])?$fallback[$module]:'dashboard','Buka Dokumen');
|
||||
}
|
||||
public function find($id) { return $this->CI->db->get_where('approval_requests',array('id'=>(int)$id))->row(); }
|
||||
public function pendingForEntity($type,$id) { return $this->CI->db->where(array('entity_type'=>$type,'entity_id'=>(int)$id,'status'=>'pending'))->get('approval_requests')->row(); }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
|
||||
class AuditService
|
||||
{
|
||||
private $CI;
|
||||
public function __construct() { $this->CI =& get_instance(); }
|
||||
|
||||
public function record($module, $entityType, $entityId, $action, $before = null, $after = null, $userId = null)
|
||||
{
|
||||
$previous = $this->CI->db->select('record_hash')->order_by('id','DESC')->limit(1)->get('immutable_audit_logs')->row();
|
||||
$created = date('Y-m-d H:i:s');
|
||||
$data = array(
|
||||
'module'=>(string)$module, 'entity_type'=>(string)$entityType, 'entity_id'=>(string)$entityId,
|
||||
'action'=>(string)$action, 'before_data'=>$this->encode($before), 'after_data'=>$this->encode($after),
|
||||
'user_id'=>$userId ? (int)$userId : null,
|
||||
'ip_address'=>isset($this->CI->input) ? $this->CI->input->ip_address() : null,
|
||||
'user_agent'=>isset($this->CI->input) ? substr((string)$this->CI->input->user_agent(),0,255) : null,
|
||||
'request_id'=>function_exists('getenv') ? (getenv('HTTP_X_REQUEST_ID') ?: null) : null,
|
||||
'previous_hash'=>$previous ? $previous->record_hash : null, 'created_at'=>$created
|
||||
);
|
||||
$data['record_hash'] = hash('sha256', ($data['previous_hash'] ?: '') . json_encode($data, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES));
|
||||
if (!$this->CI->db->insert('immutable_audit_logs', $data)) throw new RuntimeException('Gagal menulis audit trail.');
|
||||
return (int)$this->CI->db->insert_id();
|
||||
}
|
||||
|
||||
private function encode($value)
|
||||
{
|
||||
if ($value === null) return null;
|
||||
$value = json_decode(json_encode($value), true);
|
||||
$walk = function (&$item, $key) { if (preg_match('/password|token|secret|authorization/i', (string)$key)) $item = '[REDACTED]'; };
|
||||
array_walk_recursive($value, $walk);
|
||||
return json_encode($value, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
require_once APPPATH.'exceptions/BusinessException.php';
|
||||
class BudgetService
|
||||
{
|
||||
private $CI;
|
||||
public function __construct(){$this->CI=&get_instance();$this->CI->load->library(array('CompanyContext','TransactionService'));}
|
||||
public function money($value){$value=trim(str_ireplace(array('Rp',' '),'',(string)$value));if($value==='')return 0.0;$comma=strrpos($value,',');$dot=strrpos($value,'.');if($comma!==false&&$dot!==false)$value=$comma>$dot?str_replace(',','.',str_replace('.','',$value)):str_replace(',','',$value);elseif($comma!==false){$d=strlen($value)-$comma-1;$value=$d>0&&$d<=2?str_replace(',','.',str_replace('.','',$value)):str_replace(',','',$value);}elseif($dot!==false&&strlen($value)-$dot-1===3)$value=str_replace('.','',$value);if(!is_numeric($value))throw new BusinessException('Nominal budget tidak valid.');return round((float)$value,2);}
|
||||
public function budgets(){return$this->CI->db->select('b.*,u.nama creator_name,ap.nama approver_name,(SELECT COALESCE(SUM(bl.amount),0) FROM budget_lines bl WHERE bl.budget_id=b.id) annual_total,(SELECT COUNT(DISTINCT bl.account_id) FROM budget_lines bl WHERE bl.budget_id=b.id) account_count',false)->from('budgets b')->join('users u','u.id=b.created_by','left')->join('users ap','ap.id=b.approved_by','left')->where('b.company_id',$this->CI->companycontext->id())->order_by('b.fiscal_year','DESC')->order_by('b.version_no','DESC')->get()->result();}
|
||||
public function find($id,$lock=false){return$this->CI->db->query('SELECT * FROM budgets WHERE id=? AND company_id=?'.($lock?' FOR UPDATE':''),array((int)$id,$this->CI->companycontext->id()))->row();}
|
||||
public function create(array$d,$user){$year=(int)($d['fiscal_year']??0);$name=trim((string)($d['name']??''));if($year<2000||$year>2200||$name==='')throw new BusinessException('Nama dan tahun budget wajib diisi dengan benar.');return$this->CI->transactionservice->run(function()use($year,$name,$d,$user){$data=array('budget_no'=>'BDG-'.$year.'-'.date('YmdHis').'-'.random_int(10,99),'fiscal_year'=>$year,'name'=>$name,'version_no'=>1,'revision_no'=>1,'status'=>'draft','control_mode'=>'none','description'=>trim((string)($d['description']??'')),'created_by'=>(int)$user,'created_at'=>date('Y-m-d H:i:s'),'company_id'=>$this->CI->companycontext->id());$this->CI->db->insert('budgets',$data);$id=(int)$this->CI->db->insert_id();$this->audit($id,'create',null,$data,null,$user);return$id;});}
|
||||
public function saveAnnualLines($budgetId,array$rows,$user){return$this->CI->transactionservice->run(function()use($budgetId,$rows,$user){$b=$this->find($budgetId,true);if(!$b||$b->status!=='draft')throw new BusinessException('Hanya budget Draft yang dapat diubah.');if((int)$b->created_by!==(int)$user&&!$this->isAdmin())throw new BusinessException('Budget Draft hanya dapat diubah pembuatnya.');$clean=array();$seen=array();foreach($rows as$i=>$row){$accountId=(int)($row['account_id']??0);if(!$accountId)continue;$account=$this->CI->db->get_where('accounts',array('id'=>$accountId,'company_id'=>$this->CI->companycontext->id(),'tipe'=>'expense','is_active'=>1,'allow_posting'=>1))->row();if(!$account)throw new BusinessException('Akun pengeluaran pada baris '.($i+1).' tidak valid.');if(isset($seen[$accountId]))throw new BusinessException('Satu akun hanya boleh muncul sekali dalam satu budget.');$seen[$accountId]=true;for($month=1;$month<=12;$month++){$amount=$this->money($row['month'][$month]??0);if($amount<0)throw new BusinessException('Budget tidak boleh negatif.');if($amount==0.0)continue;$clean[]=array('budget_id'=>(int)$budgetId,'period'=>sprintf('%04d-%02d',(int)$b->fiscal_year,$month),'account_id'=>$accountId,'amount'=>$amount,'notes'=>trim((string)($row['notes']??'')),'updated_by'=>(int)$user,'created_at'=>date('Y-m-d H:i:s'),'updated_at'=>date('Y-m-d H:i:s'),'company_id'=>$this->CI->companycontext->id());}}if(!$clean)throw new BusinessException('Isi minimal satu nominal budget bulanan.');$old=$this->CI->db->where('budget_id',$budgetId)->get('budget_lines')->result_array();$this->CI->db->where(array('budget_id'=>$budgetId,'company_id'=>$this->CI->companycontext->id()))->delete('budget_lines');$this->CI->db->insert_batch('budget_lines',$clean);$this->CI->db->where('id',$budgetId)->update('budgets',array('updated_by'=>$user,'updated_at'=>date('Y-m-d H:i:s')));$this->audit($budgetId,'save_lines',$old,array('account_count'=>count($seen),'annual_total'=>array_sum(array_column($clean,'amount'))),null,$user);return count($seen);});}
|
||||
public function workflow($id,$action,$user,$reason=''){return$this->CI->transactionservice->run(function()use($id,$action,$user,$reason){$b=$this->find($id,true);if(!$b)throw new BusinessException('Budget tidak ditemukan.');$now=date('Y-m-d H:i:s');$old=(array)$b;$up=array('updated_by'=>$user,'updated_at'=>$now);if($action==='submit'){if($b->status!=='draft')throw new BusinessException('Hanya budget Draft yang dapat diajukan.');if((int)$b->created_by!==(int)$user&&!$this->isAdmin())throw new BusinessException('Hanya pembuat budget yang dapat mengajukan.');if(!$this->CI->db->where('budget_id',$id)->count_all_results('budget_lines'))throw new BusinessException('Budget belum mempunyai rincian.');$up+=array('status'=>'submitted','submitted_by'=>$user,'submitted_at'=>$now,'rejection_reason'=>null);}elseif($action==='approve'){if($b->status!=='submitted')throw new BusinessException('Budget tidak sedang menunggu persetujuan.');if((int)$b->created_by===(int)$user&&!$this->isAdmin())throw new BusinessException('Pembuat tidak dapat menyetujui budget sendiri.');$this->CI->db->where(array('company_id'=>$b->company_id,'fiscal_year'=>$b->fiscal_year,'status'=>'active'))->where('id !=',$id)->update('budgets',array('status'=>'superseded','updated_at'=>$now,'updated_by'=>$user));$up+=array('status'=>'active','approved_by'=>$user,'approved_at'=>$now,'rejected_by'=>null,'rejected_at'=>null,'rejection_reason'=>null);}elseif($action==='reject'){if($b->status!=='submitted')throw new BusinessException('Budget tidak sedang menunggu persetujuan.');if(trim($reason)==='')throw new BusinessException('Alasan penolakan wajib diisi.');$up+=array('status'=>'rejected','rejected_by'=>$user,'rejected_at'=>$now,'rejection_reason'=>trim($reason));}else throw new BusinessException('Aksi budget tidak valid.');$this->CI->db->where(array('id'=>$id,'company_id'=>$b->company_id))->update('budgets',$up);if(!empty($b->parent_budget_id)){$revision=array('status'=>$action==='approve'?'approved':($action==='reject'?'rejected':'submitted'));if($action==='approve')$revision+=array('approved_by'=>$user,'approved_at'=>$now);$this->CI->db->where(array('budget_id'=>$b->parent_budget_id,'revision_no'=>$b->revision_no,'company_id'=>$b->company_id))->update('budget_revisions',$revision);}$this->audit($id,$action,$old,$up,$reason,$user);return$up['status'];});}
|
||||
public function revise($id,$reason,$user){$reason=trim((string)$reason);if($reason==='')throw new BusinessException('Alasan revisi wajib diisi.');return$this->CI->transactionservice->run(function()use($id,$reason,$user){$source=$this->find($id,true);if(!$source||!in_array($source->status,array('active','superseded'),true))throw new BusinessException('Hanya budget aktif yang dapat direvisi.');if($this->CI->db->where(array('parent_budget_id'=>$source->id,'status'=>'draft','company_id'=>$source->company_id))->count_all_results('budgets'))throw new BusinessException('Masih ada revisi Draft yang belum diselesaikan.');$version=(int)$this->CI->db->select_max('version_no','v')->where(array('company_id'=>$source->company_id,'fiscal_year'=>$source->fiscal_year))->get('budgets')->row()->v+1;$data=(array)$source;unset($data['id']);$data=array_merge($data,array('budget_no'=>'BDG-'.$source->fiscal_year.'-REV'.$version.'-'.date('His'),'version_no'=>$version,'revision_no'=>$version,'parent_budget_id'=>$source->id,'status'=>'draft','description'=>trim($source->description."\nRevisi: ".$reason),'submitted_by'=>null,'submitted_at'=>null,'approved_by'=>null,'approved_at'=>null,'rejected_by'=>null,'rejected_at'=>null,'rejection_reason'=>null,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s'),'updated_by'=>null,'updated_at'=>null));$this->CI->db->insert('budgets',$data);$newId=(int)$this->CI->db->insert_id();$lines=$this->CI->db->where(array('budget_id'=>$source->id,'company_id'=>$source->company_id))->get('budget_lines')->result_array();foreach($lines as&$line){unset($line['id']);$line['budget_id']=$newId;$line['created_at']=date('Y-m-d H:i:s');$line['updated_by']=$user;}if($lines)$this->CI->db->insert_batch('budget_lines',$lines);$this->CI->db->insert('budget_revisions',array('budget_id'=>$source->id,'revision_no'=>$version,'reason'=>$reason,'status'=>'draft','snapshot'=>json_encode(array('source_budget'=>(array)$source,'lines'=>$lines)),'requested_by'=>$user,'requested_at'=>date('Y-m-d H:i:s'),'company_id'=>$source->company_id));$this->audit($newId,'revise',array('source_budget_id'=>$source->id),$data,$reason,$user);return$newId;});}
|
||||
public function lines($id){return$this->CI->db->select('bl.*,a.kode_akun,a.nama_akun')->from('budget_lines bl')->join('accounts a','a.id=bl.account_id')->where(array('bl.budget_id'=>(int)$id,'bl.company_id'=>$this->CI->companycontext->id()))->order_by('a.kode_akun')->order_by('bl.period')->get()->result();}
|
||||
public function entries($accountId=0)
|
||||
{
|
||||
$this->CI->db->select('e.*,a.kode_akun,a.nama_akun,a.tipe,u.nama creator_name,uu.nama updater_name')
|
||||
->from('account_budget_entries e')->join('accounts a','a.id=e.account_id')
|
||||
->join('users u','u.id=e.created_by','left')->join('users uu','uu.id=e.updated_by','left')
|
||||
->where(array('e.company_id'=>$this->CI->companycontext->id(),'e.is_active'=>1));
|
||||
if((int)$accountId>0)$this->CI->db->where('e.account_id',(int)$accountId);
|
||||
$rows=$this->CI->db->order_by('e.period','DESC')->order_by('a.kode_akun')->order_by('e.title')->get()->result();
|
||||
$completed=array();if($rows&&$this->CI->db->table_exists('account_budget_reminder_completions'))foreach($this->CI->db->where(array('company_id'=>$this->CI->companycontext->id(),'period'=>date('Y-m')))->get('account_budget_reminder_completions')->result()as$c)$completed[$c->budget_entry_id]=$c;
|
||||
foreach($rows as$row)$this->decorateReminder($row,isset($completed[$row->id])?$completed[$row->id]:null);
|
||||
return$rows;
|
||||
}
|
||||
public function saveEntry(array$d,$user)
|
||||
{
|
||||
$id=(int)($d['id']??0);$accountId=(int)($d['account_id']??0);$period=trim((string)($d['period']??''));
|
||||
$type=in_array(($d['entry_type']??''),array('recurring','override'),true)?$d['entry_type']:'recurring';
|
||||
$title=trim((string)($d['title']??''));$reminder=!empty($d['reminder_enabled']);$reminderDay=$reminder?(int)($d['reminder_day']??0):null;
|
||||
$amount=$this->money($d['amount']??0);$company=$this->CI->companycontext->id();
|
||||
if($title==='')throw new BusinessException('Nama budget wajib diisi agar setiap kebutuhan mudah dibedakan.');
|
||||
if(!preg_match('/^\d{4}-(0[1-9]|1[0-2])$/',$period))throw new BusinessException('Bulan mulai budget tidak valid.');
|
||||
if($amount<0)throw new BusinessException('Nominal budget tidak boleh negatif.');
|
||||
if($reminder&&($reminderDay<1||$reminderDay>31))throw new BusinessException('Tanggal pengingat harus antara tanggal 1 sampai 31.');
|
||||
$account=$this->CI->db->get_where('accounts',array('id'=>$accountId,'company_id'=>$company,'is_active'=>1))->row();
|
||||
if(!$account)throw new BusinessException('Akun budget tidak valid atau sudah tidak aktif.');
|
||||
return$this->CI->transactionservice->run(function()use($id,$accountId,$title,$period,$type,$amount,$reminder,$reminderDay,$d,$user,$company){
|
||||
$old=$id?$this->CI->db->query('SELECT * FROM account_budget_entries WHERE id=? AND company_id=? FOR UPDATE',array($id,$company))->row():null;
|
||||
if($id&&!$old)throw new BusinessException('Budget yang akan diubah tidak ditemukan.');
|
||||
$data=array('company_id'=>$company,'account_id'=>$accountId,'title'=>$title,'period'=>$period,'entry_type'=>$type,'amount'=>$amount,'reminder_enabled'=>$reminder?1:0,'reminder_day'=>$reminder?$reminderDay:null,'notes'=>trim((string)($d['notes']??'')),'is_active'=>1,'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s'));
|
||||
if($id)$this->CI->db->where(array('id'=>$id,'company_id'=>$company))->update('account_budget_entries',$data);
|
||||
else{$data['created_by']=(int)$user;$data['created_at']=date('Y-m-d H:i:s');$this->CI->db->insert('account_budget_entries',$data);$id=(int)$this->CI->db->insert_id();}
|
||||
$this->auditEntry($id,$old?'update':'create',$old,$data,$user);return$id;
|
||||
});
|
||||
}
|
||||
public function currentReminders()
|
||||
{
|
||||
$rows=array_values(array_filter($this->entries(),function($row){return in_array($row->reminder_state,array('upcoming','due','overdue'),true);}));
|
||||
usort($rows,function($a,$b){return strcmp((string)$a->reminder_due_date,(string)$b->reminder_due_date);});return$rows;
|
||||
}
|
||||
public function completeReminder($id,$user)
|
||||
{
|
||||
$company=$this->CI->companycontext->id();$period=date('Y-m');
|
||||
return$this->CI->transactionservice->run(function()use($id,$user,$company,$period){
|
||||
$row=$this->CI->db->query('SELECT * FROM account_budget_entries WHERE id=? AND company_id=? AND is_active=1 FOR UPDATE',array((int)$id,$company))->row();
|
||||
if(!$row||!(int)$row->reminder_enabled)throw new BusinessException('Pengingat budget tidak ditemukan atau tidak aktif.');
|
||||
if(($row->entry_type==='recurring'&&$row->period>$period)||($row->entry_type==='override'&&$row->period!==$period))throw new BusinessException('Budget ini tidak berlaku pada bulan berjalan.');
|
||||
$exists=$this->CI->db->get_where('account_budget_reminder_completions',array('company_id'=>$company,'budget_entry_id'=>(int)$id,'period'=>$period))->row();
|
||||
if(!$exists)$this->CI->db->insert('account_budget_reminder_completions',array('company_id'=>$company,'budget_entry_id'=>(int)$id,'period'=>$period,'completed_by'=>(int)$user,'completed_at'=>date('Y-m-d H:i:s')));
|
||||
$this->auditEntry((int)$id,'complete_reminder',$row,array('period'=>$period,'completed_by'=>(int)$user),$user);return true;
|
||||
});
|
||||
}
|
||||
public function deleteEntry($id,$user)
|
||||
{
|
||||
$company=$this->CI->companycontext->id();
|
||||
return$this->CI->transactionservice->run(function()use($id,$user,$company){
|
||||
$row=$this->CI->db->query('SELECT * FROM account_budget_entries WHERE id=? AND company_id=? AND is_active=1 FOR UPDATE',array((int)$id,$company))->row();
|
||||
if(!$row)throw new BusinessException('Budget tidak ditemukan atau sudah dinonaktifkan.');
|
||||
$data=array('is_active'=>0,'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s'));
|
||||
$this->CI->db->where('id',(int)$id)->update('account_budget_entries',$data);$this->auditEntry((int)$id,'delete',$row,$data,$user);return true;
|
||||
});
|
||||
}
|
||||
public function report(array$f)
|
||||
{
|
||||
$from=substr((string)($f['from']??date('Y-m')),0,7);$to=substr((string)($f['to']??date('Y-m')),0,7);
|
||||
return$this->accountReport(array('from'=>$from,'to'=>$to,'account_id'=>(int)($f['account_id']??0)));
|
||||
}
|
||||
public function accountReport(array$f)
|
||||
{
|
||||
$company=$this->CI->companycontext->id();$from=$this->validPeriod($f['from']??date('Y-m'));$to=$this->validPeriod($f['to']??date('Y-m'));
|
||||
if($from>$to)throw new BusinessException('Periode awal tidak boleh melewati periode akhir.');
|
||||
$periods=$this->periodRange($from,$to);if(count($periods)>120)throw new BusinessException('Rentang laporan maksimal 10 tahun.');
|
||||
$this->CI->db->select('e.account_id,e.period,e.entry_type,e.amount,a.kode_akun,a.nama_akun,a.tipe')->from('account_budget_entries e')->join('accounts a','a.id=e.account_id')->where(array('e.company_id'=>$company,'e.is_active'=>1))->where('e.period <=',$to)->group_start()->where('e.entry_type','recurring')->or_group_start()->where('e.entry_type','override')->where('e.period >=',$from)->group_end()->group_end();
|
||||
if(!empty($f['account_id']))$this->CI->db->where('e.account_id',(int)$f['account_id']);
|
||||
$entries=$this->CI->db->order_by('e.period')->get()->result();$grouped=array();
|
||||
foreach($entries as$e){if(!isset($grouped[$e->account_id]))$grouped[$e->account_id]=array('account'=>$e,'recurring'=>array(),'override'=>array());if($e->entry_type==='recurring')$grouped[$e->account_id]['recurring'][]=array('period'=>$e->period,'amount'=>(float)$e->amount);else$grouped[$e->account_id]['override'][$e->period]=($grouped[$e->account_id]['override'][$e->period]??0)+(float)$e->amount;}
|
||||
if(!$grouped)return array();$ids=array_map('intval',array_keys($grouped));$idSql=implode(',',$ids);
|
||||
$fromDate=$from.'-01';$toDate=date('Y-m-t',strtotime($to.'-01'));
|
||||
$actualRows=$this->CI->db->query("SELECT jd.account_id,SUM(jd.debit) debit,SUM(jd.kredit) credit FROM journal_details jd JOIN journals j ON j.id=jd.journal_id WHERE j.company_id=? AND j.status IN('posted','reversed') AND j.tanggal BETWEEN ? AND ? AND jd.account_id IN($idSql) GROUP BY jd.account_id",array($company,$fromDate,$toDate))->result();
|
||||
$actual=array();foreach($actualRows as$x)$actual[$x->account_id]=array('debit'=>(float)$x->debit,'credit'=>(float)$x->credit);
|
||||
$commitment=$this->purchaseCommitments($ids,$fromDate,$toDate,$company);$rows=array();
|
||||
foreach($grouped as$accountId=>$g){$budget=0.0;foreach($periods as$p){foreach($g['recurring']as$schedule)if($schedule['period']<=$p)$budget+=$schedule['amount'];if(isset($g['override'][$p]))$budget+=$g['override'][$p];}
|
||||
$movement=$actual[$accountId]??array('debit'=>0,'credit'=>0);$normalDebit=in_array($g['account']->tipe,array('asset','expense'),true);$realized=$normalDebit?$movement['debit']-$movement['credit']:$movement['credit']-$movement['debit'];$committed=(float)($commitment[$accountId]??0);$usage=round($realized+$committed,2);$remaining=round($budget-$usage,2);$percentage=$budget>0?round($usage/$budget*100,2):($usage>0?100:0);
|
||||
$rows[]=array('account_id'=>(int)$accountId,'kode_akun'=>$g['account']->kode_akun,'nama_akun'=>$g['account']->nama_akun,'tipe'=>$g['account']->tipe,'budget'=>round($budget,2),'actual'=>round($realized,2),'commitment'=>round($committed,2),'usage'=>$usage,'remaining'=>$remaining,'percentage'=>$percentage,'indicator'=>$remaining<0?'over':($percentage>=80?'warning':'safe'));
|
||||
}
|
||||
usort($rows,function($a,$b){return strnatcasecmp($a['kode_akun'],$b['kode_akun']);});return$rows;
|
||||
}
|
||||
private function purchaseCommitments(array$accountIds,$from,$to,$company)
|
||||
{
|
||||
if(!$accountIds||!$this->CI->db->table_exists('purchase_orders')||!$this->CI->db->table_exists('purchase_order_lines')||!$this->CI->db->field_exists('budget_account_id','purchase_order_lines'))return array();
|
||||
$ids=implode(',',array_map('intval',$accountIds));$joins='';$where='';$params=array();
|
||||
if($this->CI->db->field_exists('company_id','purchase_orders')){$where=' AND po.company_id=?';$params[]=$company;}
|
||||
elseif($this->CI->db->table_exists('purchase_requests')&&$this->CI->db->field_exists('request_id','purchase_orders')&&$this->CI->db->field_exists('company_id','purchase_requests')){$joins=' LEFT JOIN purchase_requests pr ON pr.id=po.request_id';$where=' AND pr.company_id=?';$params[]=$company;}
|
||||
$invoiced='0';if($this->CI->db->table_exists('supplier_invoice_lines')&&$this->CI->db->table_exists('supplier_invoices')&&$this->CI->db->field_exists('po_line_id','supplier_invoice_lines'))$invoiced="COALESCE((SELECT SUM(sil.qty) FROM supplier_invoice_lines sil JOIN supplier_invoices si ON si.id=sil.supplier_invoice_id WHERE sil.po_line_id=pol.id AND si.status IN('verified','posted','partial','paid')),0)";
|
||||
$sql="SELECT pol.budget_account_id account_id,SUM(GREATEST(pol.qty-($invoiced),0)*pol.unit_price) amount FROM purchase_order_lines pol JOIN purchase_orders po ON po.id=pol.purchase_order_id $joins WHERE po.status IN('approved','partially_received','received') AND po.order_date BETWEEN ? AND ? $where AND pol.budget_account_id IN($ids) GROUP BY pol.budget_account_id";
|
||||
$params=array_merge(array($from,$to),$params);$result=array();foreach($this->CI->db->query($sql,$params)->result()as$row)$result[$row->account_id]=(float)$row->amount;return$result;
|
||||
}
|
||||
private function validPeriod($value){$value=substr(trim((string)$value),0,7);if(!preg_match('/^\d{4}-(0[1-9]|1[0-2])$/',$value))throw new BusinessException('Format periode budget tidak valid.');return$value;}
|
||||
private function periodRange($from,$to){$out=array();$cursor=new DateTime($from.'-01');$end=new DateTime($to.'-01');while($cursor<=$end){$out[]=$cursor->format('Y-m');$cursor->modify('+1 month');}return$out;}
|
||||
private function decorateReminder($row,$completion)
|
||||
{
|
||||
$row->reminder_state='none';$row->reminder_due_date=null;$row->reminder_days=null;$row->reminder_completed_at=$completion?$completion->completed_at:null;
|
||||
if(!(int)$row->reminder_enabled||!(int)$row->reminder_day)return;
|
||||
$period=date('Y-m');$applies=$row->entry_type==='recurring'?$row->period<=$period:$row->period===$period;if(!$applies){$row->reminder_state=$row->period>$period?'future':'inactive_period';return;}
|
||||
$day=min((int)$row->reminder_day,(int)date('t'));$due=sprintf('%s-%02d',$period,$day);$row->reminder_due_date=$due;
|
||||
if($completion){$row->reminder_state='completed';return;}
|
||||
$days=(int)floor((strtotime($due)-strtotime(date('Y-m-d')))/86400);$row->reminder_days=$days;$row->reminder_state=$days<0?'overdue':($days===0?'due':($days<=7?'upcoming':'scheduled'));
|
||||
}
|
||||
private function auditEntry($id,$action,$old,$new,$user){if(!$this->CI->db->table_exists('budget_audit_logs'))return;$this->CI->db->insert('budget_audit_logs',array('company_id'=>$this->CI->companycontext->id(),'budget_id'=>null,'entity_type'=>'account_budget_entry','entity_id'=>$id,'action'=>$action,'old_values'=>$old?json_encode((array)$old):null,'new_values'=>$new?json_encode((array)$new):null,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s')));}
|
||||
private function isAdmin(){return is_master_admin_user();}
|
||||
private function audit($id,$action,$old,$new,$reason,$user){if(!$this->CI->db->table_exists('budget_audit_logs'))return;$this->CI->db->insert('budget_audit_logs',array('company_id'=>$this->CI->companycontext->id(),'budget_id'=>$id,'entity_type'=>'budget','entity_id'=>$id,'action'=>$action,'old_values'=>$old===null?null:json_encode($old),'new_values'=>$new===null?null:json_encode($new),'reason'=>$reason?:null,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s')));}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
class CashBankService
|
||||
{
|
||||
private $CI;public function __construct(){$this->CI=&get_instance();$this->CI->load->library(array('PostingService','FiscalPeriodService'));}
|
||||
public function post($data,$uid){$db=$this->CI->db;$db->trans_begin();try{$date=$data['transaction_date'];$this->CI->fiscalperiodservice->assertOpen($date);$account=$db->query('SELECT * FROM cash_accounts WHERE id=? AND is_active=1 FOR UPDATE',array($data['cash_account_id']))->row();if(!$account)throw new BusinessException('Rekening kas/bank tidak ditemukan.');$amount=(float)$data['amount'];if($amount<=0)throw new BusinessException('Jumlah harus lebih dari nol.');$key=trim($data['idempotency_key']);if($key===''||$db->get_where('cash_transactions',array('idempotency_key'=>$key))->row())throw new BusinessException('Kunci transaksi kosong atau sudah digunakan.');$threshold=(float)(getenv('CASH_APPROVAL_THRESHOLD')?:10000000);$requires=$data['direction']==='out'&&$amount>=$threshold;$status=$requires?'submitted':'posted';$no='CB-'.date('YmdHis').'-'.substr(sha1($key),0,6);$row=array('transaction_no'=>$no,'transaction_date'=>$date,'cash_account_id'=>$account->id,'transaction_type'=>$data['transaction_type'],'direction'=>$data['direction'],'amount'=>$amount,'counter_account_id'=>$data['counter_account_id'],'reference_no'=>$data['reference_no'],'description'=>$data['description'],'status'=>$status,'requires_approval'=>$requires?1:0,'idempotency_key'=>$key,'created_by'=>$uid,'created_at'=>date('Y-m-d H:i:s'));$db->insert('cash_transactions',$row);$id=$db->insert_id();if(!$requires)$this->journal($id,$account,$row,$uid);if(!$db->trans_status())throw new RuntimeException('Transaksi kas/bank gagal.');$db->trans_commit();return array('id'=>$id,'transaction_no'=>$no,'status'=>$status);}catch(Throwable$e){$db->trans_rollback();throw$e;}}
|
||||
private function journal($id,$account,$row,$uid){$amount=$row['amount'];$lines=$row['direction']==='in'?array(array('account_id'=>$account->gl_account_id,'debit'=>$amount,'kredit'=>0),array('account_id'=>$row['counter_account_id'],'debit'=>0,'kredit'=>$amount)):array(array('account_id'=>$row['counter_account_id'],'debit'=>$amount,'kredit'=>0),array('account_id'=>$account->gl_account_id,'debit'=>0,'kredit'=>$amount));$jid=$this->CI->postingservice->post(array('tanggal'=>$row['transaction_date'],'no_ref'=>$row['transaction_no'],'keterangan'=>$row['description'].'; rekening '.$account->name.'; referensi '.($row['reference_no']?:'-'),'ref_type'=>'cash_transaction','ref_id'=>$id,'created_by'=>$uid),$lines,false,true);$this->CI->db->where('id',$id)->update('cash_transactions',array('journal_id'=>$jid,'posted_by'=>$uid,'posted_at'=>date('Y-m-d H:i:s'),'status'=>'posted'));return$jid;}
|
||||
public function approve($id,$uid){$db=$this->CI->db;$db->trans_begin();try{$tx=$db->query('SELECT * FROM cash_transactions WHERE id=? FOR UPDATE',array($id))->row();if(!$tx||$tx->status!=='submitted')throw new BusinessException('Transaksi tidak menunggu persetujuan.');if((int)$tx->created_by===(int)$uid)throw new BusinessException('Pembuat transaksi tidak boleh menyetujui pembayarannya sendiri.');$account=$db->get_where('cash_accounts',array('id'=>$tx->cash_account_id))->row();$row=(array)$tx;$this->journal($id,$account,$row,$uid);$db->where('id',$id)->update('cash_transactions',array('approved_by'=>$uid,'approved_at'=>date('Y-m-d H:i:s')));$db->trans_commit();}catch(Throwable$e){$db->trans_rollback();throw$e;}}
|
||||
public function transfer($from,$to,$date,$amount,$charge,$chargeAccount,$ref,$uid,$key){if($from==$to)throw new BusinessException('Rekening asal dan tujuan harus berbeda.');$db=$this->CI->db;$db->trans_begin();try{$a=$db->query('SELECT * FROM cash_accounts WHERE id IN(?,?) ORDER BY id FOR UPDATE',array($from,$to))->result();if(count($a)!==2)throw new BusinessException('Rekening transfer tidak valid.');$map=array();foreach($a as$x)$map[$x->id]=$x;$this->CI->fiscalperiodservice->assertOpen($date);$no='TRF-'.date('YmdHis');$lines=array(array('account_id'=>$map[$to]->gl_account_id,'debit'=>$amount,'kredit'=>0),array('account_id'=>$map[$from]->gl_account_id,'debit'=>0,'kredit'=>$amount+$charge));if($charge>0)$lines[]=array('account_id'=>$chargeAccount,'debit'=>$charge,'kredit'=>0);$jid=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$no,'keterangan'=>'Transfer antar rekening dari '.$map[$from]->name.' ke '.$map[$to]->name.'; referensi '.($ref?:'-'),'ref_type'=>'cash_transfer','ref_id'=>0,'created_by'=>$uid),$lines,false,true);foreach(array(array($from,'transfer_out','out',$amount+$charge),array($to,'transfer_in','in',$amount))as$i=>$x){$db->insert('cash_transactions',array('transaction_no'=>$no.'-'.($i?'IN':'OUT'),'transaction_date'=>$date,'cash_account_id'=>$x[0],'transaction_type'=>$x[1],'direction'=>$x[2],'amount'=>$x[3],'reference_no'=>$ref,'description'=>'Transfer antar rekening '.$no,'status'=>'posted','source_type'=>'cash_transfer','journal_id'=>$jid,'idempotency_key'=>$key.'-'.$i,'created_by'=>$uid,'posted_by'=>$uid,'posted_at'=>date('Y-m-d H:i:s'),'created_at'=>date('Y-m-d H:i:s')));}$db->insert('cash_transfers',array('transfer_no'=>$no,'transfer_date'=>$date,'from_cash_account_id'=>$from,'to_cash_account_id'=>$to,'amount'=>$amount,'bank_charge'=>$charge,'reference_no'=>$ref,'status'=>'posted','journal_id'=>$jid,'idempotency_key'=>$key,'created_by'=>$uid,'approved_by'=>$uid));$db->trans_commit();return$no;}catch(Throwable$e){$db->trans_rollback();throw$e;}}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
|
||||
require_once APPPATH . 'exceptions/BusinessException.php';
|
||||
|
||||
class ChartOfAccountsService
|
||||
{
|
||||
private $CI;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->CI =& get_instance();
|
||||
$this->CI->load->library('AuditService');
|
||||
}
|
||||
|
||||
public function save(array $input, $id = null, $userId = null)
|
||||
{
|
||||
$code = trim(isset($input['kode_akun']) ? $input['kode_akun'] : '');
|
||||
$name = trim(isset($input['nama_akun']) ? $input['nama_akun'] : '');
|
||||
$type = isset($input['tipe']) ? $input['tipe'] : '';
|
||||
$parentId = !empty($input['parent_id']) ? (int) $input['parent_id'] : null;
|
||||
$isHeader = !empty($input['is_header']) ? 1 : 0;
|
||||
$allowPosting = $isHeader ? 0 : (!empty($input['allow_posting']) ? 1 : 0);
|
||||
|
||||
if ($code === '' || $name === '') {
|
||||
throw new BusinessException('Kode dan nama akun wajib diisi.');
|
||||
}
|
||||
if (!preg_match('/^[0-9]{4}$/', $code)) {
|
||||
throw new BusinessException('Kode akun wajib terdiri dari tepat 4 angka.');
|
||||
}
|
||||
if (!in_array($type, array('asset', 'liability', 'equity', 'revenue', 'expense'), true)) {
|
||||
throw new BusinessException('Tipe akun tidak valid.');
|
||||
}
|
||||
$duplicateQuery = $this->CI->db->where('kode_akun', $code);
|
||||
if ($id) $duplicateQuery->where('id !=', (int) $id);
|
||||
if ($duplicateQuery->count_all_results('accounts') > 0) {
|
||||
throw new BusinessException('Kode akun sudah digunakan.');
|
||||
}
|
||||
if ($parentId) {
|
||||
if ($id && $parentId === (int) $id) {
|
||||
throw new BusinessException('Akun tidak dapat menjadi parent untuk dirinya sendiri.');
|
||||
}
|
||||
$parent = $this->CI->db->get_where('accounts', array('id' => $parentId))->row();
|
||||
if (!$parent) {
|
||||
throw new BusinessException('Parent akun tidak ditemukan.');
|
||||
}
|
||||
if ($id && $this->wouldCreateCycle((int) $id, $parentId)) {
|
||||
throw new BusinessException('Parent akun tersebut akan membentuk hierarki melingkar.');
|
||||
}
|
||||
}
|
||||
if ($id && $isHeader && $this->CI->db->where('account_id', (int) $id)->count_all_results('journal_details') > 0) {
|
||||
throw new BusinessException('Akun yang sudah mempunyai transaksi tidak dapat diubah menjadi akun header.');
|
||||
}
|
||||
|
||||
$data = array(
|
||||
'kode_akun' => $code,
|
||||
'nama_akun' => $name,
|
||||
'tipe' => $type,
|
||||
'posisi' => in_array($type, array('asset', 'expense'), true) ? 'debit' : 'kredit',
|
||||
'kategori' => in_array($type, array('asset', 'liability', 'equity'), true) ? 'neraca' : 'laba_rugi',
|
||||
'parent_id' => $parentId,
|
||||
'priority' => isset($input['priority']) && $input['priority'] !== '' ? (int) $input['priority'] : null,
|
||||
'is_header' => $isHeader,
|
||||
'allow_posting' => $allowPosting,
|
||||
'is_active' => isset($input['is_active']) ? (!empty($input['is_active']) ? 1 : 0) : 1,
|
||||
'is_hidden' => !empty($input['is_hidden']) ? 1 : 0,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
'updated_by' => $userId ? (int) $userId : null
|
||||
);
|
||||
|
||||
if ($id) {
|
||||
$before = $this->CI->db->get_where('accounts', array('id'=>(int)$id))->row();
|
||||
$this->CI->db->where('id', (int) $id)->update('accounts', $data);
|
||||
$this->CI->auditservice->record('chart_of_accounts','account',(int)$id,'update',$before,$this->CI->db->get_where('accounts',array('id'=>(int)$id))->row(),$userId);
|
||||
return (int) $id;
|
||||
}
|
||||
$this->CI->db->insert('accounts', $data);
|
||||
$newId=(int)$this->CI->db->insert_id();
|
||||
$this->CI->auditservice->record('chart_of_accounts','account',$newId,'create',null,$this->CI->db->get_where('accounts',array('id'=>$newId))->row(),$userId);
|
||||
return $newId;
|
||||
}
|
||||
|
||||
public function delete($id, $userId = null)
|
||||
{
|
||||
$id = (int) $id;
|
||||
if ($this->CI->db->where('parent_id', $id)->count_all_results('accounts') > 0) {
|
||||
throw new BusinessException('Akun memiliki child dan tidak dapat dihapus.');
|
||||
}
|
||||
if ($this->CI->db->where('account_id', $id)->count_all_results('journal_details') > 0) {
|
||||
throw new BusinessException('Akun sudah mempunyai transaksi dan tidak dapat dihapus. Nonaktifkan akun sebagai gantinya.');
|
||||
}
|
||||
if ($this->CI->db->where('account_id', $id)->count_all_results('system_account_mappings') > 0) {
|
||||
throw new BusinessException('Akun masih digunakan sebagai mapping akun sistem.');
|
||||
}
|
||||
$before=$this->CI->db->get_where('accounts',array('id'=>$id))->row();
|
||||
$result=$this->CI->db->where('id', $id)->delete('accounts');
|
||||
if($result) $this->CI->auditservice->record('chart_of_accounts','account',$id,'delete',$before,null,$userId);
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function wouldCreateCycle($accountId, $parentId)
|
||||
{
|
||||
$visited = array();
|
||||
while ($parentId) {
|
||||
if ($parentId === $accountId || isset($visited[$parentId])) return true;
|
||||
$visited[$parentId] = true;
|
||||
$row = $this->CI->db->select('parent_id')->get_where('accounts', array('id' => $parentId))->row();
|
||||
$parentId = $row && $row->parent_id ? (int) $row->parent_id : null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
class CompanyContext
|
||||
{
|
||||
private $CI,$id;public function __construct(){$this->CI=&get_instance();}
|
||||
public function id(){if($this->id)return$this->id;$uid=(int)$this->CI->session->userdata('user_id');$selected=(int)$this->CI->session->userdata('company_id');if($selected&&$this->CI->db->get_where('user_companies',array('user_id'=>$uid,'company_id'=>$selected))->row())return$this->id=$selected;$row=$this->CI->db->where('user_id',$uid)->order_by('is_default','DESC')->get('user_companies')->row();if(!$row)throw new BusinessException('User belum diberi akses perusahaan.');$this->CI->session->set_userdata('company_id',$row->company_id);return$this->id=(int)$row->company_id;}
|
||||
public function assertAccess($companyId){if(!$this->CI->db->get_where('user_companies',array('user_id'=>(int)$this->CI->session->userdata('user_id'),'company_id'=>(int)$companyId))->row())throw new BusinessException('Akses perusahaan ditolak.');return true;}
|
||||
public function scope($builder,$column='company_id'){$builder->where($column,$this->id());return$builder;}
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
|
||||
/**
|
||||
* Creates a consistent, one-way snapshot from a legacy database and upgrades
|
||||
* it in an empty target database. It never writes to the source database.
|
||||
*/
|
||||
class DatabaseSyncService
|
||||
{
|
||||
private $CI;
|
||||
private $sourceConfig;
|
||||
private $targetConfig;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->CI =& get_instance();
|
||||
$this->sourceConfig = $this->readConfig('DBSYNC_SOURCE');
|
||||
$this->targetConfig = $this->readConfig('DBSYNC_TARGET');
|
||||
|
||||
if ($this->sourceConfig['host'] === $this->targetConfig['host']
|
||||
&& $this->sourceConfig['port'] === $this->targetConfig['port']
|
||||
&& $this->sourceConfig['database'] === $this->targetConfig['database']) {
|
||||
throw new RuntimeException('Database sumber dan target tidak boleh sama.');
|
||||
}
|
||||
}
|
||||
|
||||
public function check()
|
||||
{
|
||||
list($source, $target) = $this->connectPair();
|
||||
try {
|
||||
$result = array(
|
||||
'status' => true,
|
||||
'source' => $this->databaseInfo($source, $this->sourceConfig['database']),
|
||||
'target' => $this->databaseInfo($target, $this->targetConfig['database']),
|
||||
);
|
||||
$result['target']['is_empty'] = $result['target']['objects'] === 0;
|
||||
return $result;
|
||||
} finally {
|
||||
$source->close();
|
||||
$target->close();
|
||||
}
|
||||
}
|
||||
|
||||
public function snapshot()
|
||||
{
|
||||
list($source, $target) = $this->connectPair();
|
||||
$target->close();
|
||||
try {
|
||||
$path = $this->createSnapshot($source);
|
||||
return array(
|
||||
'status' => true,
|
||||
'backup' => $path,
|
||||
'size' => filesize($path),
|
||||
'sha256' => hash_file('sha256', $path),
|
||||
);
|
||||
} finally {
|
||||
$source->close();
|
||||
}
|
||||
}
|
||||
|
||||
public function bootstrap()
|
||||
{
|
||||
// Both connections must succeed before the first target write.
|
||||
list($source, $target) = $this->connectPair();
|
||||
try {
|
||||
$targetInfo = $this->databaseInfo($target, $this->targetConfig['database']);
|
||||
if ($targetInfo['objects'] !== 0) {
|
||||
throw new RuntimeException(
|
||||
'Bootstrap ditolak: target tidak kosong (' . $targetInfo['objects'] . ' objek).'
|
||||
);
|
||||
}
|
||||
|
||||
$backup = trim((string) getenv('DBSYNC_BACKUP_FILE'));
|
||||
if ($backup === '') {
|
||||
$backup = $this->createSnapshot($source);
|
||||
} elseif (!is_file($backup) || !is_readable($backup)) {
|
||||
throw new RuntimeException('File backup tidak ditemukan atau tidak dapat dibaca.');
|
||||
}
|
||||
|
||||
$expectedHash = strtolower(trim((string) getenv('DBSYNC_BACKUP_SHA256')));
|
||||
$actualHash = hash_file('sha256', $backup);
|
||||
if ($expectedHash !== '' && !hash_equals($expectedHash, strtolower($actualHash))) {
|
||||
throw new RuntimeException('Checksum backup tidak sesuai. Target belum diubah.');
|
||||
}
|
||||
} finally {
|
||||
$source->close();
|
||||
$target->close();
|
||||
}
|
||||
|
||||
$sanitizedBackup = $this->sanitizeBackup($backup);
|
||||
try {
|
||||
$this->importBackup($sanitizedBackup);
|
||||
$this->runTargetMigrations();
|
||||
$verification = $this->verify();
|
||||
} finally {
|
||||
if (is_file($sanitizedBackup)) {
|
||||
@unlink($sanitizedBackup);
|
||||
}
|
||||
}
|
||||
|
||||
return array(
|
||||
'status' => true,
|
||||
'message' => 'Snapshot legacy berhasil dimuat dan struktur target berhasil dimodernisasi.',
|
||||
'backup' => $backup,
|
||||
'backup_sha256' => $actualHash,
|
||||
'verification' => $verification,
|
||||
);
|
||||
}
|
||||
|
||||
public function verify()
|
||||
{
|
||||
list($source, $target) = $this->connectPair();
|
||||
try {
|
||||
$required = array('accounts', 'users', 'journals', 'journal_details', 'items', 'item_barcodes');
|
||||
$missing = array();
|
||||
foreach ($required as $table) {
|
||||
if (!$this->tableExists($target, $this->targetConfig['database'], $table)) {
|
||||
$missing[] = $table;
|
||||
}
|
||||
}
|
||||
if ($missing) {
|
||||
throw new RuntimeException('Verifikasi gagal, tabel wajib tidak tersedia: ' . implode(', ', $missing));
|
||||
}
|
||||
|
||||
$latestFileVersion = $this->latestMigrationVersion();
|
||||
$migrationVersion = 0;
|
||||
if ($this->tableExists($target, $this->targetConfig['database'], 'migrations')) {
|
||||
$row = $target->query('SELECT COALESCE(MAX(version), 0) AS version FROM migrations')->fetch_assoc();
|
||||
$migrationVersion = (int) $row['version'];
|
||||
}
|
||||
|
||||
$invalidAccounts = (int) $target->query(
|
||||
"SELECT COUNT(*) AS total FROM accounts WHERE kode_akun IS NULL OR kode_akun NOT REGEXP '^[0-9]{4}$'"
|
||||
)->fetch_assoc()['total'];
|
||||
$journal = $target->query(
|
||||
'SELECT COALESCE(SUM(debit),0) AS debit, COALESCE(SUM(kredit),0) AS kredit FROM journal_details'
|
||||
)->fetch_assoc();
|
||||
$journalDifference = round((float) $journal['debit'] - (float) $journal['kredit'], 2);
|
||||
|
||||
$orphans = array(
|
||||
'journal_details' => $this->scalar($target,
|
||||
'SELECT COUNT(*) FROM journal_details d LEFT JOIN journals j ON j.id=d.journal_id WHERE j.id IS NULL'),
|
||||
'invoice_details' => $this->tableExists($target, $this->targetConfig['database'], 'invoice_details')
|
||||
? $this->scalar($target,
|
||||
'SELECT COUNT(*) FROM invoice_details d LEFT JOIN invoices i ON i.id=d.invoice_id WHERE i.id IS NULL')
|
||||
: 0,
|
||||
'item_barcodes' => $this->scalar($target,
|
||||
'SELECT COUNT(*) FROM item_barcodes b LEFT JOIN items i ON i.id=b.item_id WHERE i.id IS NULL'),
|
||||
);
|
||||
|
||||
$passed = $migrationVersion === $latestFileVersion
|
||||
&& $invalidAccounts === 0
|
||||
&& abs($journalDifference) < 0.01
|
||||
&& array_sum($orphans) === 0;
|
||||
|
||||
if (!$passed) {
|
||||
throw new RuntimeException('Verifikasi target gagal. Periksa hasil audit sebelum cutover.');
|
||||
}
|
||||
|
||||
return array(
|
||||
'status' => true,
|
||||
'migration_version' => $migrationVersion,
|
||||
'latest_migration_file' => $latestFileVersion,
|
||||
'account_codes_invalid' => $invalidAccounts,
|
||||
'journal_debit' => (float) $journal['debit'],
|
||||
'journal_credit' => (float) $journal['kredit'],
|
||||
'journal_difference' => $journalDifference,
|
||||
'orphans' => $orphans,
|
||||
'target' => $this->databaseInfo($target, $this->targetConfig['database']),
|
||||
);
|
||||
} finally {
|
||||
$source->close();
|
||||
$target->close();
|
||||
}
|
||||
}
|
||||
|
||||
private function readConfig($prefix)
|
||||
{
|
||||
$config = array(
|
||||
'host' => trim((string) getenv($prefix . '_HOST')),
|
||||
'port' => (int) (getenv($prefix . '_PORT') ?: 3306),
|
||||
'username' => trim((string) getenv($prefix . '_USERNAME')),
|
||||
'password' => (string) getenv($prefix . '_PASSWORD'),
|
||||
'database' => trim((string) getenv($prefix . '_DATABASE')),
|
||||
);
|
||||
|
||||
foreach (array('host', 'port', 'username', 'password', 'database') as $key) {
|
||||
if ($config[$key] === '' || $config[$key] === 0) {
|
||||
throw new RuntimeException('Konfigurasi ' . $prefix . '_' . strtoupper($key) . ' belum lengkap.');
|
||||
}
|
||||
}
|
||||
return $config;
|
||||
}
|
||||
|
||||
private function connectPair()
|
||||
{
|
||||
$source = $this->connect($this->sourceConfig, 'sumber');
|
||||
try {
|
||||
$target = $this->connect($this->targetConfig, 'target');
|
||||
} catch (Throwable $exception) {
|
||||
$source->close();
|
||||
throw $exception;
|
||||
}
|
||||
return array($source, $target);
|
||||
}
|
||||
|
||||
private function connect($config, $label)
|
||||
{
|
||||
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
|
||||
$connection = mysqli_init();
|
||||
$connection->options(MYSQLI_OPT_CONNECT_TIMEOUT, 10);
|
||||
try {
|
||||
$connection->real_connect(
|
||||
$config['host'],
|
||||
$config['username'],
|
||||
$config['password'],
|
||||
$config['database'],
|
||||
$config['port']
|
||||
);
|
||||
$connection->set_charset('utf8mb4');
|
||||
return $connection;
|
||||
} catch (Throwable $exception) {
|
||||
throw new RuntimeException('Koneksi database ' . $label . ' gagal. Proses dihentikan.', 0, $exception);
|
||||
}
|
||||
}
|
||||
|
||||
private function databaseInfo($connection, $database)
|
||||
{
|
||||
$statement = $connection->prepare(
|
||||
'SELECT COUNT(*) AS total FROM information_schema.tables WHERE table_schema=?'
|
||||
);
|
||||
$statement->bind_param('s', $database);
|
||||
$statement->execute();
|
||||
$objects = (int) $statement->get_result()->fetch_assoc()['total'];
|
||||
$statement->close();
|
||||
|
||||
return array(
|
||||
'database' => $database,
|
||||
'server_version' => $connection->server_info,
|
||||
'objects' => $objects,
|
||||
);
|
||||
}
|
||||
|
||||
private function tableExists($connection, $database, $table)
|
||||
{
|
||||
$statement = $connection->prepare(
|
||||
'SELECT COUNT(*) AS total FROM information_schema.tables WHERE table_schema=? AND table_name=?'
|
||||
);
|
||||
$statement->bind_param('ss', $database, $table);
|
||||
$statement->execute();
|
||||
$exists = (int) $statement->get_result()->fetch_assoc()['total'] > 0;
|
||||
$statement->close();
|
||||
return $exists;
|
||||
}
|
||||
|
||||
private function createSnapshot($source)
|
||||
{
|
||||
$directory = trim((string) getenv('DBSYNC_BACKUP_DIRECTORY'));
|
||||
if ($directory === '') {
|
||||
$directory = dirname(FCPATH) . DIRECTORY_SEPARATOR . 'private_backups' . DIRECTORY_SEPARATOR . 'accounting_dev';
|
||||
}
|
||||
if (!is_dir($directory) && !mkdir($directory, 0700, true)) {
|
||||
throw new RuntimeException('Folder backup tidak dapat dibuat.');
|
||||
}
|
||||
$path = rtrim($directory, '/\\') . DIRECTORY_SEPARATOR
|
||||
. $this->sourceConfig['database'] . '-cutover-' . date('Ymd-His') . '.sql';
|
||||
$file = fopen($path, 'xb');
|
||||
if (!$file) {
|
||||
throw new RuntimeException('File snapshot tidak dapat dibuat.');
|
||||
}
|
||||
|
||||
try {
|
||||
fwrite($file, "-- One-way database cutover snapshot\nSET NAMES utf8mb4;\nSET FOREIGN_KEY_CHECKS=0;\nSET UNIQUE_CHECKS=0;\n\n");
|
||||
$tables = array();
|
||||
$views = array();
|
||||
$result = $source->query('SHOW FULL TABLES');
|
||||
while ($row = $result->fetch_row()) {
|
||||
if (strtoupper($row[1]) === 'VIEW') {
|
||||
$views[] = $row[0];
|
||||
} else {
|
||||
$tables[] = $row[0];
|
||||
}
|
||||
}
|
||||
|
||||
$source->query('SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ');
|
||||
$source->query('START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY');
|
||||
foreach ($tables as $table) {
|
||||
$quotedTable = $this->identifier($table);
|
||||
$createRow = $source->query('SHOW CREATE TABLE ' . $quotedTable)->fetch_row();
|
||||
fwrite($file, 'DROP TABLE IF EXISTS ' . $quotedTable . ";\n" . $createRow[1] . ";\n\n");
|
||||
|
||||
$selectColumns = array();
|
||||
$columnResult = $source->query('SHOW FULL COLUMNS FROM ' . $quotedTable);
|
||||
while ($column = $columnResult->fetch_assoc()) {
|
||||
if (stripos((string) $column['Extra'], 'GENERATED') === false) {
|
||||
$selectColumns[] = $this->identifier($column['Field']);
|
||||
}
|
||||
}
|
||||
$rows = $source->query(
|
||||
'SELECT ' . implode(',', $selectColumns) . ' FROM ' . $quotedTable,
|
||||
MYSQLI_USE_RESULT
|
||||
);
|
||||
$columns = array();
|
||||
foreach ($rows->fetch_fields() as $field) {
|
||||
$columns[] = $this->identifier($field->name);
|
||||
}
|
||||
$batch = array();
|
||||
while ($row = $rows->fetch_row()) {
|
||||
$values = array();
|
||||
foreach ($row as $value) {
|
||||
$values[] = $value === null ? 'NULL' : "'" . $source->real_escape_string($value) . "'";
|
||||
}
|
||||
$batch[] = '(' . implode(',', $values) . ')';
|
||||
if (count($batch) >= 200) {
|
||||
$this->writeInsertBatch($file, $quotedTable, $columns, $batch);
|
||||
$batch = array();
|
||||
}
|
||||
}
|
||||
$rows->free();
|
||||
if ($batch) {
|
||||
$this->writeInsertBatch($file, $quotedTable, $columns, $batch);
|
||||
}
|
||||
fwrite($file, "\n");
|
||||
}
|
||||
$source->commit();
|
||||
|
||||
foreach ($views as $view) {
|
||||
$quotedView = $this->identifier($view);
|
||||
$create = $source->query('SHOW CREATE VIEW ' . $quotedView)->fetch_assoc();
|
||||
fwrite($file, 'DROP VIEW IF EXISTS ' . $quotedView . ";\n" . $create['Create View'] . ";\n\n");
|
||||
}
|
||||
fwrite($file, "SET UNIQUE_CHECKS=1;\nSET FOREIGN_KEY_CHECKS=1;\n");
|
||||
} catch (Throwable $exception) {
|
||||
@$source->rollback();
|
||||
fclose($file);
|
||||
@unlink($path);
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
fclose($file);
|
||||
@chmod($path, 0600);
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function writeInsertBatch($file, $table, $columns, $batch)
|
||||
{
|
||||
fwrite(
|
||||
$file,
|
||||
'INSERT INTO ' . $table . '(' . implode(',', $columns) . ") VALUES\n"
|
||||
. implode(",\n", $batch) . ";\n"
|
||||
);
|
||||
}
|
||||
|
||||
private function sanitizeBackup($sourcePath)
|
||||
{
|
||||
$targetPath = tempnam(sys_get_temp_dir(), 'accounting-cutover-');
|
||||
$input = fopen($sourcePath, 'rb');
|
||||
$output = fopen($targetPath, 'wb');
|
||||
if (!$input || !$output) {
|
||||
throw new RuntimeException('File sementara untuk import tidak dapat dibuat.');
|
||||
}
|
||||
$sqlModeInjected = false;
|
||||
$generatedScheduleInsert = false;
|
||||
while (($line = fgets($input)) !== false) {
|
||||
$line = preg_replace('/\\sDEFINER=`[^`]+`@`[^`]+`/i', '', $line);
|
||||
|
||||
// Older snapshots produced before this service included the
|
||||
// generated schedule_date column. MySQL only accepts DEFAULT for
|
||||
// generated values, so retain the row and let MySQL calculate it.
|
||||
if (stripos($line, 'INSERT INTO `k_employee_shift_schedules`') === 0
|
||||
&& stripos($line, '`schedule_date`') !== false) {
|
||||
$generatedScheduleInsert = true;
|
||||
} elseif ($generatedScheduleInsert && isset($line[0]) && $line[0] === '(') {
|
||||
$line = preg_replace(
|
||||
"/^(\\('[^']*','[^']*',(?:'[^']*'|NULL),'[^']*','[^']*'),(?:'[^']*'|NULL)(,)/",
|
||||
'$1,DEFAULT$2',
|
||||
$line
|
||||
);
|
||||
if (substr(rtrim($line), -1) === ';') {
|
||||
$generatedScheduleInsert = false;
|
||||
}
|
||||
}
|
||||
fwrite($output, $line);
|
||||
// Legacy MySQL ENUM columns contain empty-string values that are
|
||||
// valid in the old data but rejected by modern strict SQL mode.
|
||||
// Compatibility mode preserves those rows; later migrations map
|
||||
// them to the modern representation without deleting data.
|
||||
if (!$sqlModeInjected && preg_match('/^SET NAMES /i', trim($line))) {
|
||||
fwrite($output, "SET SESSION sql_mode='NO_ENGINE_SUBSTITUTION';\n");
|
||||
$sqlModeInjected = true;
|
||||
}
|
||||
}
|
||||
fclose($input);
|
||||
fclose($output);
|
||||
return $targetPath;
|
||||
}
|
||||
|
||||
private function importBackup($backup)
|
||||
{
|
||||
$mysql = trim((string) getenv('DBSYNC_MYSQL_BIN'));
|
||||
if ($mysql === '') {
|
||||
$mysql = 'C:\\xampp\\mysql\\bin\\mysql.exe';
|
||||
}
|
||||
if (!is_file($mysql)) {
|
||||
throw new RuntimeException('mysql client tidak ditemukan: ' . $mysql);
|
||||
}
|
||||
|
||||
$command = array(
|
||||
$mysql,
|
||||
'--host=' . $this->targetConfig['host'],
|
||||
'--port=' . $this->targetConfig['port'],
|
||||
'--user=' . $this->targetConfig['username'],
|
||||
'--default-character-set=utf8mb4',
|
||||
'--database=' . $this->targetConfig['database'],
|
||||
);
|
||||
$this->runProcess($command, $backup, array('MYSQL_PWD' => $this->targetConfig['password']));
|
||||
}
|
||||
|
||||
private function runTargetMigrations()
|
||||
{
|
||||
$environment = array(
|
||||
'DB_HOST' => $this->targetConfig['host'],
|
||||
'DB_PORT' => (string) $this->targetConfig['port'],
|
||||
'DB_USERNAME' => $this->targetConfig['username'],
|
||||
'DB_PASSWORD' => $this->targetConfig['password'],
|
||||
'DB_DATABASE' => $this->targetConfig['database'],
|
||||
);
|
||||
$this->runProcess(
|
||||
array(PHP_BINARY, FCPATH . 'index.php', 'migrate/latest'),
|
||||
null,
|
||||
$environment,
|
||||
FCPATH
|
||||
);
|
||||
}
|
||||
|
||||
private function runProcess($command, $stdinFile = null, $environment = array(), $workingDirectory = null)
|
||||
{
|
||||
$descriptors = array(
|
||||
0 => $stdinFile ? array('file', $stdinFile, 'rb') : array('pipe', 'r'),
|
||||
1 => array('pipe', 'w'),
|
||||
2 => array('pipe', 'w'),
|
||||
);
|
||||
$processEnvironment = getenv();
|
||||
if (!is_array($processEnvironment)) {
|
||||
$processEnvironment = array();
|
||||
}
|
||||
foreach ($environment as $key => $value) {
|
||||
$processEnvironment[$key] = $value;
|
||||
}
|
||||
|
||||
$process = proc_open($command, $descriptors, $pipes, $workingDirectory, $processEnvironment);
|
||||
if (!is_resource($process)) {
|
||||
throw new RuntimeException('Proses database tidak dapat dijalankan.');
|
||||
}
|
||||
if (!$stdinFile) {
|
||||
fclose($pipes[0]);
|
||||
}
|
||||
$stdout = stream_get_contents($pipes[1]);
|
||||
$stderr = stream_get_contents($pipes[2]);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
$exitCode = proc_close($process);
|
||||
if ($exitCode !== 0) {
|
||||
throw new RuntimeException(trim($stderr ?: $stdout ?: 'Proses database gagal.') . ' (exit ' . $exitCode . ')');
|
||||
}
|
||||
return trim($stdout);
|
||||
}
|
||||
|
||||
private function latestMigrationVersion()
|
||||
{
|
||||
$versions = array();
|
||||
foreach (glob(APPPATH . 'migrations' . DIRECTORY_SEPARATOR . '*.php') as $file) {
|
||||
if (preg_match('/^(\\d+)_/', basename($file), $match)) {
|
||||
$versions[] = (int) $match[1];
|
||||
}
|
||||
}
|
||||
return $versions ? max($versions) : 0;
|
||||
}
|
||||
|
||||
private function scalar($connection, $sql)
|
||||
{
|
||||
$row = $connection->query($sql)->fetch_row();
|
||||
return (int) $row[0];
|
||||
}
|
||||
|
||||
private function identifier($value)
|
||||
{
|
||||
return '`' . str_replace('`', '``', $value) . '`';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
|
||||
require_once APPPATH . 'exceptions/BusinessException.php';
|
||||
|
||||
class FiscalPeriodService
|
||||
{
|
||||
private $CI;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->CI =& get_instance();
|
||||
$this->CI->load->model('FiscalPeriodModel', 'fiscalPeriodModel');
|
||||
$this->CI->load->library('TransactionService');
|
||||
$this->CI->load->library('AuditService');
|
||||
}
|
||||
|
||||
public function periodForDate($date)
|
||||
{
|
||||
$parsed = DateTime::createFromFormat('Y-m-d', (string) $date);
|
||||
if (!$parsed || $parsed->format('Y-m-d') !== $date) {
|
||||
throw new BusinessException('Tanggal periode accounting tidak valid.');
|
||||
}
|
||||
|
||||
$period = $this->CI->fiscalPeriodModel->findByDate($date);
|
||||
if (!$period) {
|
||||
throw new BusinessException('Periode accounting untuk tanggal ' . $date . ' belum tersedia.');
|
||||
}
|
||||
return $period;
|
||||
}
|
||||
|
||||
public function assertOpen($date)
|
||||
{
|
||||
$period = $this->periodForDate($date);
|
||||
if ($period->status !== 'open') {
|
||||
throw new BusinessException('Periode ' . $period->name . ' berstatus ' . $period->status . ' dan tidak menerima transaksi.');
|
||||
}
|
||||
return $period;
|
||||
}
|
||||
|
||||
public function changeStatus($periodId, $targetStatus, $userId, $reason = '')
|
||||
{
|
||||
$allowed = array('open', 'soft_closed', 'closed');
|
||||
if (!in_array($targetStatus, $allowed, true)) {
|
||||
throw new BusinessException('Status periode tidak valid.');
|
||||
}
|
||||
|
||||
return $this->CI->transactionservice->run(function () use ($periodId, $targetStatus, $userId, $reason) {
|
||||
$period = $this->CI->db->query('SELECT * FROM accounting_periods WHERE id = ? FOR UPDATE', array((int) $periodId))->row();
|
||||
if (!$period) {
|
||||
throw new BusinessException('Periode accounting tidak ditemukan.', array(), 404);
|
||||
}
|
||||
if ($period->status === $targetStatus) {
|
||||
return $period;
|
||||
}
|
||||
|
||||
$transitions = array(
|
||||
'open' => array('soft_closed', 'closed'),
|
||||
'soft_closed' => array('open', 'closed'),
|
||||
'closed' => array('open')
|
||||
);
|
||||
if (!in_array($targetStatus, $transitions[$period->status], true)) {
|
||||
throw new BusinessException('Perubahan status periode tidak diizinkan.');
|
||||
}
|
||||
if ($targetStatus === 'open' && $period->status !== 'open' && trim($reason) === '') {
|
||||
throw new BusinessException('Alasan membuka kembali periode wajib diisi.');
|
||||
}
|
||||
if (in_array($targetStatus, array('soft_closed', 'closed'), true)) {
|
||||
$pending = $this->CI->db
|
||||
->where('tanggal >=', $period->start_date)
|
||||
->where('tanggal <=', $period->end_date)
|
||||
->where_in('status', array('draft', 'submitted', 'approved'))
|
||||
->count_all_results('journals');
|
||||
if ($pending > 0) {
|
||||
throw new BusinessException('Periode masih mempunyai ' . $pending . ' jurnal yang belum diposting atau ditolak.');
|
||||
}
|
||||
}
|
||||
|
||||
$update = array('status' => $targetStatus, 'updated_at' => date('Y-m-d H:i:s'));
|
||||
if ($targetStatus === 'closed') {
|
||||
$update['closed_at'] = date('Y-m-d H:i:s');
|
||||
$update['closed_by'] = (int) $userId;
|
||||
} elseif ($targetStatus === 'open') {
|
||||
$update['reopened_at'] = date('Y-m-d H:i:s');
|
||||
$update['reopened_by'] = (int) $userId;
|
||||
}
|
||||
$this->CI->db->where('id', $period->id)->update('accounting_periods', $update);
|
||||
$this->CI->db->insert('accounting_period_logs', array(
|
||||
'accounting_period_id' => $period->id,
|
||||
'from_status' => $period->status,
|
||||
'to_status' => $targetStatus,
|
||||
'reason' => trim($reason) ?: null,
|
||||
'changed_by' => (int) $userId,
|
||||
'changed_at' => date('Y-m-d H:i:s')
|
||||
));
|
||||
$notClosed = $this->CI->db
|
||||
->where('fiscal_year_id', $period->fiscal_year_id)
|
||||
->where('status !=', 'closed')
|
||||
->count_all_results('accounting_periods');
|
||||
$this->CI->db->where('id', $period->fiscal_year_id)->update('fiscal_years', array(
|
||||
'status' => $notClosed === 0 ? 'closed' : 'open'
|
||||
));
|
||||
$after=$this->CI->fiscalPeriodModel->find($period->id);
|
||||
$this->CI->auditservice->record('accounting_period','accounting_period',$period->id,'change_status',$period,$after,$userId);
|
||||
return $after;
|
||||
});
|
||||
}
|
||||
|
||||
public function createCalendarYear($year, $userId)
|
||||
{
|
||||
$year = (int) $year;
|
||||
if ($year < 2000 || $year > 2200) {
|
||||
throw new BusinessException('Tahun buku tidak valid.');
|
||||
}
|
||||
return $this->CI->transactionservice->run(function () use ($year, $userId) {
|
||||
$start = $year . '-01-01';
|
||||
$end = $year . '-12-31';
|
||||
$overlap = $this->CI->db
|
||||
->where('start_date <=', $end)
|
||||
->where('end_date >=', $start)
|
||||
->count_all_results('fiscal_years');
|
||||
if ($overlap > 0) {
|
||||
throw new BusinessException('Tahun buku tersebut sudah tersedia atau bertumpang tindih.');
|
||||
}
|
||||
$this->CI->db->insert('fiscal_years', array(
|
||||
'name' => 'Tahun Buku ' . $year,
|
||||
'start_date' => $start,
|
||||
'end_date' => $end,
|
||||
'status' => 'open',
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'created_by' => (int) $userId
|
||||
));
|
||||
$fiscalId = (int) $this->CI->db->insert_id();
|
||||
for ($month = 1; $month <= 12; $month++) {
|
||||
$periodStart = sprintf('%04d-%02d-01', $year, $month);
|
||||
$this->CI->db->insert('accounting_periods', array(
|
||||
'fiscal_year_id' => $fiscalId,
|
||||
'period_number' => $month,
|
||||
'name' => date('F Y', strtotime($periodStart)),
|
||||
'start_date' => $periodStart,
|
||||
'end_date' => date('Y-m-t', strtotime($periodStart)),
|
||||
'status' => 'open'
|
||||
));
|
||||
}
|
||||
$this->CI->auditservice->record('fiscal_year','fiscal_year',$fiscalId,'create',null,array('year'=>$year),$userId);
|
||||
return $fiscalId;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
|
||||
require_once APPPATH.'exceptions/BusinessException.php';
|
||||
|
||||
class FixedAssetService
|
||||
{
|
||||
private $CI;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->CI =& get_instance();
|
||||
$this->CI->load->library(array('PostingService','FiscalPeriodService','NumberingService','AccountMappingService','AuditService'));
|
||||
}
|
||||
|
||||
private function company()
|
||||
{
|
||||
return (int) $this->CI->session->userdata('company_id');
|
||||
}
|
||||
|
||||
private function transaction(callable $callback)
|
||||
{
|
||||
$db=$this->CI->db;$db->trans_begin();
|
||||
try {
|
||||
$result=$callback();
|
||||
if(!$db->trans_status())throw new RuntimeException('Transaksi aset gagal disimpan.');
|
||||
$db->trans_commit();return$result;
|
||||
} catch(Throwable $e) {
|
||||
$db->trans_rollback();throw$e;
|
||||
}
|
||||
}
|
||||
|
||||
private function asset($id, $forUpdate = true)
|
||||
{
|
||||
$sql='SELECT * FROM assets WHERE id=? AND company_id=?'.($forUpdate?' FOR UPDATE':'');
|
||||
$asset=$this->CI->db->query($sql,array((int)$id,$this->company()))->row();
|
||||
if(!$asset)throw new BusinessException('Aset tidak ditemukan.',array(),404);
|
||||
return$asset;
|
||||
}
|
||||
|
||||
private function account($id, $label)
|
||||
{
|
||||
$row=$this->CI->db->get_where('accounts',array('id'=>(int)$id,'company_id'=>$this->company()))->row();
|
||||
if(!$row)throw new BusinessException($label.' tidak valid untuk perusahaan aktif.');
|
||||
if(!(int)$row->is_active)throw new BusinessException($label.' '.$row->kode_akun.' - '.$row->nama_akun.' sudah nonaktif. Pilih akun aktif.');
|
||||
if(!(int)$row->allow_posting)throw new BusinessException($label.' '.$row->kode_akun.' - '.$row->nama_akun.' merupakan akun induk/non-posting. Pilih akun detail yang dapat menerima jurnal.');
|
||||
return$row;
|
||||
}
|
||||
|
||||
private function location($id)
|
||||
{
|
||||
$row=$this->CI->db->get_where('lokasi_asset',array('id'=>(int)$id,'company_id'=>$this->company(),'is_active'=>1))->row();
|
||||
if(!$row)throw new BusinessException('Lokasi aset tidak valid atau sudah nonaktif.');
|
||||
return$row;
|
||||
}
|
||||
|
||||
private function history($assetId,$from,$to,$notes,$userId)
|
||||
{
|
||||
$this->CI->db->insert('asset_workflow_history',array('company_id'=>$this->company(),'asset_id'=>(int)$assetId,'from_status'=>$from?:null,'to_status'=>$to,'notes'=>trim((string)$notes)?:null,'user_id'=>(int)$userId,'created_at'=>date('Y-m-d H:i:s')));
|
||||
}
|
||||
|
||||
private function event($asset,$type,$date,$amount,$before,$after,$notes,$journalId,$key,$userId,$from=null,$to=null,$reversalOf=null,$sourceType=null,$sourceId=null)
|
||||
{
|
||||
if($this->CI->db->get_where('asset_events',array('idempotency_key'=>$key))->row())throw new BusinessException('Transaksi aset yang sama sudah pernah diproses.');
|
||||
$this->CI->db->insert('asset_events',array(
|
||||
'company_id'=>$this->company(),'asset_id'=>$asset->id,'event_no'=>$this->CI->numberingservice->next('asset_event',$date),'event_type'=>$type,'event_date'=>$date,'amount'=>round((float)$amount,2),'book_value_before'=>round((float)$before,2),'book_value_after'=>round((float)$after,2),'from_location_id'=>$from?:null,'to_location_id'=>$to?:null,'notes'=>trim((string)$notes)?:null,'source_type'=>$sourceType?:$asset->source_type,'source_id'=>$sourceId?:$asset->source_id,'journal_id'=>$journalId?:null,'idempotency_key'=>$key,'reversal_of_event_id'=>$reversalOf?:null,'created_by'=>(int)$userId,'created_at'=>date('Y-m-d H:i:s')
|
||||
));
|
||||
return(int)$this->CI->db->insert_id();
|
||||
}
|
||||
|
||||
private function calculateMonthly($cost,$residual,$life,$method='straight_line')
|
||||
{
|
||||
$life=max(1,(int)$life);$base=max(0,(float)$cost-(float)$residual);
|
||||
if($method==='declining_balance')return round($base*(2/$life),2);
|
||||
return round($base/$life,2);
|
||||
}
|
||||
|
||||
private function validateDraft(array $data)
|
||||
{
|
||||
foreach(array('nama_asset','tanggal_perolehan','category_id','lokasi_asset_id','asset_account_id','accumulated_depreciation_account_id','depreciation_expense_account_id','masa_manfaat','idempotency_key')as$key)if(empty($data[$key]))throw new BusinessException('Data '.$key.' wajib diisi.');
|
||||
$date=DateTime::createFromFormat('Y-m-d',$data['tanggal_perolehan']);if(!$date||$date->format('Y-m-d')!==$data['tanggal_perolehan'])throw new BusinessException('Tanggal perolehan tidak valid.');
|
||||
if(!in_array($data['source_type'],array('manual','warehouse','purchase','opening','donation','other'),true))throw new BusinessException('Sumber perolehan tidak valid.');
|
||||
if(!in_array($data['source_journal_mode'],array('post_journal','linked_journal','no_journal'),true))throw new BusinessException('Perlakuan jurnal tidak valid.');
|
||||
if((float)$data['nilai_residu']<0||(int)$data['masa_manfaat']<1)throw new BusinessException('Nilai residu atau masa manfaat tidak valid.');
|
||||
$this->location($data['lokasi_asset_id']);
|
||||
$category=$this->CI->db->get_where('asset_categories',array('id'=>(int)$data['category_id'],'company_id'=>$this->company(),'is_active'=>1))->row();if(!$category)throw new BusinessException('Kategori aset tidak valid.');
|
||||
$this->account($data['asset_account_id'],'Akun aset');$this->account($data['accumulated_depreciation_account_id'],'Akun akumulasi penyusutan');$this->account($data['depreciation_expense_account_id'],'Akun beban penyusutan');
|
||||
if($data['source_type']==='warehouse'){
|
||||
if(empty($data['item_id'])||empty($data['barcode_id'])||(float)$data['qty']<=0)throw new BusinessException('Barang, barcode, dan qty gudang wajib diisi.');
|
||||
}else{
|
||||
if((float)$data['nilai_perolehan']<=0)throw new BusinessException('Nilai perolehan harus lebih dari nol.');
|
||||
if((float)$data['nilai_residu']>(float)$data['nilai_perolehan'])throw new BusinessException('Nilai residu tidak boleh melebihi nilai perolehan.');
|
||||
if($data['source_journal_mode']==='post_journal')$this->account($data['account_kredit_id'],'Akun lawan perolehan');
|
||||
if($data['source_journal_mode']==='linked_journal'){
|
||||
$journal=$this->CI->db->get_where('journals',array('id'=>(int)$data['source_journal_id'],'company_id'=>$this->company(),'status'=>'posted'))->row();if(!$journal)throw new BusinessException('Jurnal sumber tidak ditemukan atau belum posted.');
|
||||
if($this->CI->db->where('source_journal_id',(int)$journal->id)->where('workflow_status','capitalized')->count_all_results('assets')>0)throw new BusinessException('Jurnal sumber sudah digunakan oleh aset lain.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function warehouseValuation(array $data)
|
||||
{
|
||||
$barcode=$this->CI->db->query("SELECT b.*,i.company_id,i.status item_status,i.harga_beli FROM item_barcodes b JOIN items i ON i.id=b.item_id WHERE b.id=? AND b.item_id=? FOR UPDATE",array((int)$data['barcode_id'],(int)$data['item_id']))->row();
|
||||
$qty=(float)$data['qty'];
|
||||
if(!$barcode||(int)$barcode->company_id!==$this->company()||$barcode->item_status!=='active'||$barcode->status!=='available'||!$barcode->warehouse_id)throw new BusinessException('Barang/barcode gudang tidak tersedia.');
|
||||
if((int)$barcode->warehouse_id!==(int)$data['warehouse_id'])throw new BusinessException('Gudang barang tidak sesuai dengan barcode yang dipilih.');
|
||||
$available=(float)$barcode->qty_sisa-(float)$barcode->reserved_qty-(float)$barcode->return_reserved_qty;
|
||||
if($qty<=0||$qty>$available+.0001)throw new BusinessException('Qty aset melebihi stok barcode yang tersedia.');
|
||||
$balance=$this->CI->db->query("SELECT COALESCE(SUM(IF(direction='in',qty,-qty)),0) qty,COALESCE(SUM(IF(direction='in',value,-value)),0) value FROM inventory_ledger WHERE item_id=? AND warehouse_id=?",array((int)$barcode->item_id,(int)$barcode->warehouse_id))->row();
|
||||
$unitCost=(float)$balance->qty>0?(float)$balance->value/(float)$balance->qty:(float)$barcode->harga_beli;
|
||||
return array('unit_cost'=>round($unitCost,4),'cost'=>round($unitCost*$qty,2));
|
||||
}
|
||||
|
||||
public function createDraft(array $data,$userId)
|
||||
{
|
||||
return$this->transaction(function()use($data,$userId){
|
||||
$this->validateDraft($data);
|
||||
$key=preg_replace('/[^A-Za-z0-9_.-]/','',trim($data['idempotency_key']));if($key==='')throw new BusinessException('Kunci transaksi wajib tersedia.');
|
||||
$existing=$this->CI->db->get_where('assets',array('idempotency_key'=>$key))->row();if($existing)return(int)$existing->id;
|
||||
$number=$this->CI->numberingservice->next('fixed_asset',$data['tanggal_perolehan']);
|
||||
$valuation=$data['source_type']==='warehouse'?$this->warehouseValuation($data):null;$cost=$valuation?$valuation['cost']:round((float)$data['nilai_perolehan'],2);$unitCost=$valuation?$valuation['unit_cost']:$cost;$residual=round((float)$data['nilai_residu'],2);if($residual>$cost)throw new BusinessException('Nilai residu tidak boleh melebihi nilai perolehan.');$life=(int)$data['masa_manfaat'];$method=$data['depreciation_method']==='declining_balance'?'declining_balance':'straight_line';
|
||||
$row=array(
|
||||
'company_id'=>$this->company(),'document_no'=>$number,'kode_asset'=>$number,'nama_asset'=>trim($data['nama_asset']),'category_id'=>(int)$data['category_id'],'responsible_employee_id'=>!empty($data['responsible_employee_id'])?(int)$data['responsible_employee_id']:null,'source_type'=>$data['source_type'],'source_id'=>!empty($data['source_id'])?(int)$data['source_id']:null,'source_reference_no'=>trim((string)$data['source_reference_no'])?:null,'supplier_id'=>!empty($data['supplier_id'])?(int)$data['supplier_id']:null,'source_journal_mode'=>$data['source_journal_mode'],'source_journal_id'=>!empty($data['source_journal_id'])?(int)$data['source_journal_id']:null,'item_id'=>!empty($data['item_id'])?(int)$data['item_id']:null,'barcode_id'=>!empty($data['barcode_id'])?(int)$data['barcode_id']:null,'warehouse_id'=>!empty($data['warehouse_id'])?(int)$data['warehouse_id']:null,'bin_id'=>!empty($data['bin_id'])?(int)$data['bin_id']:null,'qty'=>$data['source_type']==='warehouse'?(float)$data['qty']:1,'harga_per_unit'=>$unitCost,'nilai_perolehan'=>$cost,'tanggal_perolehan'=>$data['tanggal_perolehan'],'capitalization_date'=>!empty($data['capitalization_date'])?$data['capitalization_date']:$data['tanggal_perolehan'],'masa_manfaat'=>$life,'nilai_residu'=>$residual,'penyusutan_per_bulan'=>$this->calculateMonthly($cost,$residual,$life,$method),'akumulasi_penyusutan'=>0,'nilai_buku'=>$cost,'sumber'=>$data['source_type']==='warehouse'?'gudang':'pembelian','account_debit_id'=>(int)$data['asset_account_id'],'account_kredit_id'=>!empty($data['account_kredit_id'])?(int)$data['account_kredit_id']:null,'asset_account_id'=>(int)$data['asset_account_id'],'accumulated_depreciation_account_id'=>(int)$data['accumulated_depreciation_account_id'],'depreciation_expense_account_id'=>(int)$data['depreciation_expense_account_id'],'lokasi_asset_id'=>(int)$data['lokasi_asset_id'],'department_id'=>!empty($data['department_id'])?(int)$data['department_id']:null,'project_id'=>!empty($data['project_id'])?(int)$data['project_id']:null,'cost_center_id'=>!empty($data['cost_center_id'])?(int)$data['cost_center_id']:null,'depreciation_method'=>$method,'metode'=>'garis_lurus','workflow_status'=>'draft','lifecycle_status'=>'draft','qr_code'=>'ASSET:'.$number,'idempotency_key'=>$key,'keterangan'=>trim((string)$data['keterangan']),'created_by'=>(int)$userId,'created_at'=>date('Y-m-d H:i:s'),'version'=>1
|
||||
);
|
||||
$this->CI->db->insert('assets',$row);$id=(int)$this->CI->db->insert_id();$this->history($id,null,'draft','Dokumen perolehan dibuat.',$userId);$this->CI->auditservice->record('fixed_asset','asset',$id,'create_draft',null,$row,$userId);return$id;
|
||||
});
|
||||
}
|
||||
|
||||
public function updateDraft($id,array$data,$userId)
|
||||
{
|
||||
return $this->transaction(function()use($id,$data,$userId){
|
||||
$asset=$this->asset($id);
|
||||
if($asset->workflow_status!=='draft')throw new BusinessException('Hanya draft yang dapat diubah.');
|
||||
$this->validateDraft($data);
|
||||
$before=clone$asset;
|
||||
$valuation=$data['source_type']==='warehouse'?$this->warehouseValuation($data):null;
|
||||
$cost=$valuation?$valuation['cost']:round((float)$data['nilai_perolehan'],2);
|
||||
$unitCost=$valuation?$valuation['unit_cost']:$cost;
|
||||
$residual=round((float)$data['nilai_residu'],2);
|
||||
if($residual>$cost)throw new BusinessException('Nilai residu tidak boleh melebihi nilai perolehan.');
|
||||
$method=$data['depreciation_method']==='declining_balance'?'declining_balance':'straight_line';
|
||||
$update=array(
|
||||
'nama_asset'=>trim($data['nama_asset']),
|
||||
'category_id'=>(int)$data['category_id'],
|
||||
'responsible_employee_id'=>!empty($data['responsible_employee_id'])?(int)$data['responsible_employee_id']:null,
|
||||
'source_type'=>$data['source_type'],
|
||||
'source_reference_no'=>trim((string)$data['source_reference_no'])?:null,
|
||||
'source_journal_mode'=>$data['source_journal_mode'],
|
||||
'source_journal_id'=>!empty($data['source_journal_id'])?(int)$data['source_journal_id']:null,
|
||||
'item_id'=>!empty($data['item_id'])?(int)$data['item_id']:null,
|
||||
'barcode_id'=>!empty($data['barcode_id'])?(int)$data['barcode_id']:null,
|
||||
'warehouse_id'=>!empty($data['warehouse_id'])?(int)$data['warehouse_id']:null,
|
||||
'bin_id'=>!empty($data['bin_id'])?(int)$data['bin_id']:null,
|
||||
'qty'=>$data['source_type']==='warehouse'?(float)$data['qty']:1,
|
||||
'nilai_perolehan'=>$cost,
|
||||
'harga_per_unit'=>$unitCost,
|
||||
'nilai_buku'=>$cost,
|
||||
'nilai_residu'=>$residual,
|
||||
'tanggal_perolehan'=>$data['tanggal_perolehan'],
|
||||
'capitalization_date'=>!empty($data['capitalization_date'])?$data['capitalization_date']:$data['tanggal_perolehan'],
|
||||
'masa_manfaat'=>(int)$data['masa_manfaat'],
|
||||
'penyusutan_per_bulan'=>$this->calculateMonthly($cost,$residual,$data['masa_manfaat'],$method),
|
||||
'depreciation_method'=>$method,
|
||||
'asset_account_id'=>(int)$data['asset_account_id'],
|
||||
'account_debit_id'=>(int)$data['asset_account_id'],
|
||||
'account_kredit_id'=>!empty($data['account_kredit_id'])?(int)$data['account_kredit_id']:null,
|
||||
'accumulated_depreciation_account_id'=>(int)$data['accumulated_depreciation_account_id'],
|
||||
'depreciation_expense_account_id'=>(int)$data['depreciation_expense_account_id'],
|
||||
'lokasi_asset_id'=>(int)$data['lokasi_asset_id'],
|
||||
'keterangan'=>trim((string)$data['keterangan']),
|
||||
'updated_by'=>(int)$userId,
|
||||
'updated_at'=>date('Y-m-d H:i:s'),
|
||||
'version'=>(int)$asset->version+1
|
||||
);
|
||||
$this->CI->db->where(array('id'=>$asset->id,'company_id'=>$this->company(),'version'=>$asset->version))->update('assets',$update);
|
||||
if($this->CI->db->affected_rows()!==1)throw new BusinessException('Draft telah berubah pada sesi lain. Muat ulang halaman.');
|
||||
$this->CI->auditservice->record('fixed_asset','asset',$asset->id,'update_draft',$before,$update,$userId);
|
||||
return$asset->id;
|
||||
});
|
||||
}
|
||||
|
||||
public function submit($id,$userId)
|
||||
{
|
||||
return$this->transaction(function()use($id,$userId){$a=$this->asset($id);if($a->workflow_status!=='draft')throw new BusinessException('Aset tidak berstatus draft.');$attachments=$this->CI->db->where(array('entity_type'=>'asset','entity_id'=>$a->id,'company_id'=>$this->company()))->count_all_results('transaction_attachments');if($attachments<1)throw new BusinessException('Minimal satu dokumen pendukung wajib diunggah sebelum pengajuan.');$this->CI->db->where('id',$a->id)->update('assets',array('workflow_status'=>'submitted','submitted_by'=>(int)$userId,'submitted_at'=>date('Y-m-d H:i:s'),'rejection_reason'=>null,'version'=>(int)$a->version+1));$this->history($a->id,'draft','submitted','Dokumen perolehan diajukan.',$userId);return$a->id;});
|
||||
}
|
||||
|
||||
public function approve($id,$userId,$isMaster=false)
|
||||
{
|
||||
return$this->transaction(function()use($id,$userId,$isMaster){$a=$this->asset($id);if($a->workflow_status!=='submitted')throw new BusinessException('Aset tidak menunggu persetujuan.');if(!$isMaster&&(int)$a->created_by===(int)$userId)throw new BusinessException('Pembuat dokumen tidak boleh menyetujui dokumennya sendiri.');$this->CI->db->where('id',$a->id)->update('assets',array('workflow_status'=>'approved','approved_by'=>(int)$userId,'approved_at'=>date('Y-m-d H:i:s'),'version'=>(int)$a->version+1));$this->history($a->id,'submitted','approved','Dokumen perolehan disetujui.',$userId);return$a->id;});
|
||||
}
|
||||
|
||||
public function reject($id,$reason,$userId,$isMaster=false)
|
||||
{
|
||||
return$this->transaction(function()use($id,$reason,$userId,$isMaster){$a=$this->asset($id);if($a->workflow_status!=='submitted')throw new BusinessException('Aset tidak menunggu persetujuan.');if(!$isMaster&&(int)$a->created_by===(int)$userId)throw new BusinessException('Pembuat dokumen tidak boleh menolak dokumennya sendiri.');if(trim($reason)==='')throw new BusinessException('Alasan penolakan wajib diisi.');$this->CI->db->where('id',$a->id)->update('assets',array('workflow_status'=>'rejected','rejected_by'=>(int)$userId,'rejected_at'=>date('Y-m-d H:i:s'),'rejection_reason'=>trim($reason),'version'=>(int)$a->version+1));$this->history($a->id,'submitted','rejected',$reason,$userId);return$a->id;});
|
||||
}
|
||||
|
||||
public function reopenRejected($id,$userId)
|
||||
{
|
||||
return$this->transaction(function()use($id,$userId){$a=$this->asset($id);if($a->workflow_status!=='rejected')throw new BusinessException('Dokumen tidak berstatus ditolak.');if((int)$a->created_by!==(int)$userId&&$this->CI->session->userdata('role')!=='Admin')throw new BusinessException('Hanya pembuat atau Master Admin yang dapat membuka kembali draft.');$this->CI->db->where('id',$a->id)->update('assets',array('workflow_status'=>'draft','version'=>(int)$a->version+1));$this->history($a->id,'rejected','draft','Dibuka kembali untuk diperbaiki.',$userId);return$a->id;});
|
||||
}
|
||||
|
||||
public function capitalize($id,$date,$userId)
|
||||
{
|
||||
return$this->transaction(function()use($id,$date,$userId){$a=$this->asset($id);if($a->workflow_status!=='approved')throw new BusinessException('Aset harus disetujui sebelum kapitalisasi.');$date=$date?:$a->capitalization_date;if(!$date)throw new BusinessException('Tanggal kapitalisasi wajib diisi.');$this->CI->fiscalperiodservice->assertOpen($date);$journalId=null;$cost=(float)$a->nilai_perolehan;$unitCost=(float)$a->harga_per_unit;$barcode=null;
|
||||
if($a->source_type==='warehouse'){
|
||||
$this->CI->fiscalperiodservice->assertOpen($date);
|
||||
$barcode=$this->CI->db->query("SELECT b.*,i.company_id,i.status item_status,i.kode_detail,i.nama_barang,k.tracking_type FROM item_barcodes b JOIN items i ON i.id=b.item_id LEFT JOIN kode_barang k ON k.id=i.kode_id WHERE b.id=? AND b.item_id=? FOR UPDATE",array((int)$a->barcode_id,(int)$a->item_id))->row();
|
||||
if(!$barcode||(int)$barcode->company_id!==$this->company()||$barcode->item_status!=='active'||$barcode->status!=='available'||!$barcode->warehouse_id||(float)$barcode->reserved_qty>0||(float)$barcode->return_reserved_qty>0)throw new BusinessException('Barang/barcode gudang tidak lagi tersedia.');
|
||||
$qty=(float)$a->qty;if(strtoupper((string)$barcode->tracking_type)==='UNIT'&&abs($qty-1)>.0001)throw new BusinessException('Barang UNIT wajib dikapitalisasi satu unit per aset.');if($qty<=0||$qty>(float)$barcode->qty_sisa)throw new BusinessException('Qty kapitalisasi melebihi saldo barcode.');
|
||||
$balance=$this->CI->db->query("SELECT COALESCE(SUM(IF(direction='in',qty,-qty)),0) qty,COALESCE(SUM(IF(direction='in',value,-value)),0) value FROM inventory_ledger WHERE item_id=? AND warehouse_id=? FOR UPDATE",array($a->item_id,$barcode->warehouse_id))->row();if((float)$balance->qty<$qty)throw new BusinessException('Saldo inventory ledger tidak mencukupi.');
|
||||
$unitCost=(float)$balance->qty>0?round((float)$balance->value/(float)$balance->qty,4):0;if($unitCost<=0)throw new BusinessException('Nilai biaya inventory ledger tidak tersedia.');$cost=round($unitCost*$qty,2);if((float)$a->nilai_residu>$cost)throw new BusinessException('Nilai residu melebihi nilai perolehan dari ledger.');
|
||||
$inventory=$this->account($this->CI->accountmappingservice->get('inventory'),'Akun persediaan');$assetAccount=$this->account($a->asset_account_id,'Akun aset');
|
||||
$description='[AUTO] Kapitalisasi persediaan menjadi aset | Dokumen '.$a->document_no.' | Aset '.$a->kode_asset.' - '.$a->nama_asset.' | Barang '.$barcode->kode_detail.' - '.$barcode->nama_barang.' | Barcode '.$barcode->barcode.' | Qty '.number_format($qty,4,',','.').' | Biaya ledger Rp '.number_format($cost,2,',','.').' | Tanggal '.$date.' | Alasan '.($a->keterangan?:'-');
|
||||
$journalId=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>'CAP-'.$a->document_no.'-V'.$a->version,'keterangan'=>$description,'ref_type'=>'asset_capitalization','ref_id'=>$a->id,'created_by'=>(int)$userId),array(array('account_id'=>$assetAccount->id,'debit'=>$cost,'kredit'=>0),array('account_id'=>$inventory->id,'debit'=>0,'kredit'=>$cost)),false,false);
|
||||
$ledgerKey='ASSET-CAP-'.$a->id.'-V'.$a->version;$this->CI->db->insert('inventory_ledger',array('item_id'=>$a->item_id,'warehouse_id'=>$barcode->warehouse_id,'bin_id'=>$barcode->bin_id?:null,'barcode_id'=>$barcode->id,'batch_id'=>$barcode->batch_id?:null,'movement_date'=>$date,'direction'=>'out','qty'=>$qty,'unit_cost'=>$unitCost,'value'=>$cost,'document_type'=>'asset_capitalization','document_id'=>$a->id,'document_line_id'=>$a->id,'idempotency_key'=>$ledgerKey,'created_at'=>date('Y-m-d H:i:s')));
|
||||
$this->CI->db->insert('stock_logs',array('company_id'=>$this->company(),'item_id'=>$a->item_id,'warehouse_id'=>$barcode->warehouse_id,'barcode_id'=>$barcode->id,'qty'=>$qty,'tipe'=>'keluar','unit_cost'=>$unitCost,'keterangan'=>$description,'ref_type'=>'asset_capitalization','ref_id'=>$a->id,'idempotency_key'=>$ledgerKey));
|
||||
$this->CI->db->insert('item_movements',array('item_id'=>$a->item_id,'barcode_id'=>$barcode->id,'qty'=>$qty,'from_type'=>'warehouse','from_id'=>$barcode->warehouse_id,'to_type'=>'asset','to_id'=>$a->id,'movement_type'=>'installation','notes'=>$description,'created_at'=>date('Y-m-d H:i:s')));
|
||||
$remaining=round((float)$barcode->qty_sisa-$qty,4);$this->CI->db->where(array('id'=>$barcode->id,'version'=>$barcode->version))->update('item_barcodes',array('qty_sisa'=>$remaining,'status'=>$remaining<=0?'installed':'available','version'=>(int)$barcode->version+1));if($this->CI->db->affected_rows()!==1)throw new BusinessException('Barcode berubah saat kapitalisasi.');$this->synchronizeItem($a->item_id);
|
||||
}else{
|
||||
if($a->source_journal_mode==='post_journal'){
|
||||
$this->CI->fiscalperiodservice->assertOpen($date);$assetAccount=$this->account($a->asset_account_id,'Akun aset');$counter=$this->account($a->account_kredit_id,'Akun lawan perolehan');$description='[AUTO] Kapitalisasi aset manual | Dokumen '.$a->document_no.' | Aset '.$a->kode_asset.' - '.$a->nama_asset.' | Sumber '.strtoupper($a->source_type).' | Referensi '.($a->source_reference_no?:'-').' | Nilai Rp '.number_format($cost,2,',','.').' | Tanggal '.$date.' | Alasan '.($a->keterangan?:'-');$journalId=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>'CAP-'.$a->document_no.'-V'.$a->version,'keterangan'=>$description,'ref_type'=>'asset_capitalization','ref_id'=>$a->id,'created_by'=>(int)$userId),array(array('account_id'=>$assetAccount->id,'debit'=>$cost,'kredit'=>0),array('account_id'=>$counter->id,'debit'=>0,'kredit'=>$cost)),false,false);
|
||||
}elseif($a->source_journal_mode==='linked_journal'){$source=$this->CI->db->get_where('journals',array('id'=>$a->source_journal_id,'company_id'=>$this->company(),'status'=>'posted'))->row();if(!$source)throw new BusinessException('Jurnal sumber tidak lagi valid.');$journalId=(int)$source->id;}
|
||||
}
|
||||
$monthly=$this->calculateMonthly($cost,$a->nilai_residu,$a->masa_manfaat,$a->depreciation_method);$update=array('workflow_status'=>'capitalized','lifecycle_status'=>'active','capitalization_date'=>$date,'warehouse_id'=>$barcode?$barcode->warehouse_id:$a->warehouse_id,'bin_id'=>$barcode?$barcode->bin_id:$a->bin_id,'harga_per_unit'=>$a->source_type==='warehouse'?$unitCost:$a->harga_per_unit,'nilai_perolehan'=>$cost,'nilai_buku'=>$cost,'penyusutan_per_bulan'=>$monthly,'acquisition_journal_id'=>$journalId,'capitalized_by'=>(int)$userId,'capitalized_at'=>date('Y-m-d H:i:s'),'version'=>(int)$a->version+1);$this->CI->db->where('id',$a->id)->update('assets',$update);$fresh=(object)array_merge((array)$a,$update);$eventJournal=$a->source_journal_mode==='linked_journal'?null:$journalId;$this->event($fresh,'capitalization',$date,$cost,0,$cost,'Kapitalisasi dokumen '.$a->document_no.': '.($a->keterangan?:'-'),$eventJournal,'ASSET-CAP-EVENT-'.$a->id.'-V'.$a->version,$userId,null,$a->lokasi_asset_id,null,$a->source_type,$a->source_id);$this->history($a->id,'approved','capitalized','Aset dikapitalisasi dan aktif.',$userId);return$a->id;
|
||||
});
|
||||
}
|
||||
|
||||
private function synchronizeItem($itemId)
|
||||
{
|
||||
$qty=(float)$this->CI->db->query("SELECT COALESCE(SUM(IF(direction='in',qty,-qty)),0) qty FROM inventory_ledger WHERE item_id=?",array((int)$itemId))->row()->qty;$this->CI->db->where('id',(int)$itemId)->update('items',array('stok'=>$qty));
|
||||
}
|
||||
|
||||
public function depreciate($id,$through,$userId,$catchup=true)
|
||||
{
|
||||
return$this->transaction(function()use($id,$through,$userId,$catchup){$asset=$this->asset($id);if($asset->workflow_status!=='capitalized'||$asset->lifecycle_status!=='active')throw new BusinessException('Hanya aset aktif yang dapat disusutkan.');$end=date('Y-m',strtotime($through));$last=$this->CI->db->where(array('asset_id'=>$asset->id,'company_id'=>$this->company(),'status'=>'posted'))->order_by('period','DESC')->limit(1)->get('asset_depreciation_schedule')->row();$start=$last?date('Y-m',strtotime($last->period.'-01 +1 month')):date('Y-m',strtotime($asset->capitalization_date.' +1 month'));$period=$catchup?$start:$end;$posted=array();while($period<=$end){if($this->CI->db->get_where('asset_depreciation_schedule',array('asset_id'=>$asset->id,'period'=>$period))->row()){$period=date('Y-m',strtotime($period.'-01 +1 month'));continue;}$date=date('Y-m-t',strtotime($period.'-01'));$this->CI->fiscalperiodservice->assertOpen($date);$asset=$this->asset($id);$before=(float)$asset->nilai_buku;$maximum=max(0,$before-(float)$asset->nilai_residu);if($maximum<=.001)break;$calculated=$asset->depreciation_method==='declining_balance'?round($before*(2/max(1,(int)$asset->masa_manfaat)),2):(float)$asset->penyusutan_per_bulan;$amount=min($calculated,$maximum);$acc=$this->account($asset->accumulated_depreciation_account_id,'Akun akumulasi penyusutan');$expense=$this->account($asset->depreciation_expense_account_id,'Akun beban penyusutan');$this->CI->db->insert('asset_depreciation_schedule',array('company_id'=>$this->company(),'asset_id'=>$asset->id,'period'=>$period,'due_date'=>$date,'opening_book_value'=>$before,'depreciation_amount'=>$amount,'closing_book_value'=>$before-$amount,'status'=>'scheduled'));$scheduleId=(int)$this->CI->db->insert_id();$description='[AUTO] Penyusutan aset | '.$asset->kode_asset.' - '.$asset->nama_asset.' | Periode '.$period.' | Metode '.$asset->depreciation_method.' | Nilai Rp '.number_format($amount,2,',','.').' | Nilai buku sebelum Rp '.number_format($before,2,',','.').' | Nilai buku sesudah Rp '.number_format($before-$amount,2,',','.');$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>'DEP-'.str_replace('-','',$period).'-'.$asset->id,'keterangan'=>$description,'ref_type'=>'asset_depreciation','ref_id'=>$scheduleId,'created_by'=>(int)$userId),array(array('account_id'=>$expense->id,'debit'=>$amount,'kredit'=>0),array('account_id'=>$acc->id,'debit'=>0,'kredit'=>$amount)),false,true);$after=$before-$amount;$event=$this->event($asset,'depreciation',$date,$amount,$before,$after,'Penyusutan periode '.$period.' metode '.$asset->depreciation_method,$journal,'ASSET-DEP-'.$asset->id.'-'.$period,$userId);$this->CI->db->where('id',$scheduleId)->update('asset_depreciation_schedule',array('status'=>'posted','journal_id'=>$journal,'asset_event_id'=>$event,'posted_at'=>date('Y-m-d H:i:s')));$this->CI->db->where('id',$asset->id)->update('assets',array('akumulasi_penyusutan'=>(float)$asset->akumulasi_penyusutan+$amount,'nilai_buku'=>$after,'lifecycle_status'=>$after<=(float)$asset->nilai_residu+.001?'fully_depreciated':'active','version'=>(int)$asset->version+1));$posted[]=$period;$period=date('Y-m',strtotime($period.'-01 +1 month'));}return$posted;});
|
||||
}
|
||||
|
||||
public function mutate($id,$type,$date,$amount,$accountId,$notes,$userId)
|
||||
{
|
||||
return$this->transaction(function()use($id,$type,$date,$amount,$accountId,$notes,$userId){$allowed=array('addition','impairment','revaluation','disposal','lost','damaged');if(!in_array($type,$allowed,true))throw new BusinessException('Jenis mutasi aset tidak valid.');if(trim($notes)==='')throw new BusinessException('Alasan/rincian mutasi wajib diisi.');$this->CI->fiscalperiodservice->assertOpen($date);$asset=$this->asset($id);if($asset->workflow_status!=='capitalized'||in_array($asset->lifecycle_status,array('disposed','lost','damaged'),true))throw new BusinessException('Aset tidak dapat menerima mutasi ini.');$amount=round((float)$amount,2);$before=(float)$asset->nilai_buku;$cost=(float)$asset->nilai_perolehan;$accum=(float)$asset->akumulasi_penyusutan;$assetAccount=$this->account($asset->asset_account_id,'Akun aset');$lines=array();$after=$before;$status=$asset->lifecycle_status;$update=array();
|
||||
if($type==='addition'){$counter=$this->account($accountId,'Akun lawan penambahan nilai');if($amount<=0)throw new BusinessException('Nilai penambahan harus lebih dari nol.');$after=$before+$amount;$lines=array(array('account_id'=>$assetAccount->id,'debit'=>$amount,'kredit'=>0),array('account_id'=>$counter->id,'debit'=>0,'kredit'=>$amount));$update['nilai_perolehan']=$cost+$amount;}
|
||||
elseif($type==='impairment'){$expense=$this->account($accountId,'Akun rugi impairment');$amount=min($amount,max(0,$before-(float)$asset->nilai_residu));if($amount<=0)throw new BusinessException('Nilai impairment tidak tersedia.');$after=$before-$amount;$lines=array(array('account_id'=>$expense->id,'debit'=>$amount,'kredit'=>0),array('account_id'=>$assetAccount->id,'debit'=>0,'kredit'=>$amount));$update['impairment_total']=(float)$asset->impairment_total+$amount;}
|
||||
elseif($type==='revaluation'){$counter=$this->account($accountId,'Akun surplus revaluasi');if($amount<=0)throw new BusinessException('Nilai revaluasi harus lebih dari nol.');$after=$before+$amount;$lines=array(array('account_id'=>$assetAccount->id,'debit'=>$amount,'kredit'=>0),array('account_id'=>$counter->id,'debit'=>0,'kredit'=>$amount));$update['revaluation_total']=(float)$asset->revaluation_total+$amount;$update['nilai_perolehan']=$cost+$amount;}
|
||||
else{$proceeds=$type==='disposal'?$amount:0;if($proceeds<0)throw new BusinessException('Nilai hasil pelepasan tidak valid.');$accAccount=$this->account($asset->accumulated_depreciation_account_id,'Akun akumulasi penyusutan');if($proceeds>0){$cash=$this->account($accountId,'Akun penerimaan pelepasan');$lines[]=array('account_id'=>$cash->id,'debit'=>$proceeds,'kredit'=>0);}if($accum>0)$lines[]=array('account_id'=>$accAccount->id,'debit'=>$accum,'kredit'=>0);$gainLoss=$proceeds-$before;if($gainLoss<0)$lines[]=array('account_id'=>$this->account($this->CI->accountmappingservice->get('asset_disposal_loss'),'Akun rugi pelepasan')->id,'debit'=>abs($gainLoss),'kredit'=>0);if($gainLoss>0)$lines[]=array('account_id'=>$this->account($this->CI->accountmappingservice->get('asset_disposal_gain'),'Akun laba pelepasan')->id,'debit'=>0,'kredit'=>$gainLoss);$lines[]=array('account_id'=>$assetAccount->id,'debit'=>0,'kredit'=>$cost);$after=0;$status=$type==='disposal'?'disposed':$type;$update['disposal_date']=$date;$update['disposal_proceeds']=$proceeds;$update['status']=$type==='disposal'?'dijual':'dihapus';}
|
||||
$key='ASSET-'.strtoupper($type).'-'.$asset->id.'-'.sha1($date.'|'.$amount.'|'.$notes.'|'.$asset->version);$description='[AUTO] '.strtoupper($type).' aset | '.$asset->kode_asset.' - '.$asset->nama_asset.' | Dokumen '.$asset->document_no.' | Tanggal '.$date.' | Nilai Rp '.number_format($amount,2,',','.').' | Nilai buku sebelum Rp '.number_format($before,2,',','.').' | Nilai buku sesudah Rp '.number_format($after,2,',','.').' | Alasan '.$notes;$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>'AST-'.strtoupper(substr($type,0,3)).'-'.$asset->id.'-'.date('YmdHis'),'keterangan'=>$description,'ref_type'=>'asset_'.$type,'ref_id'=>$asset->id,'created_by'=>(int)$userId),$lines,false,false);$this->event($asset,$type,$date,$amount,$before,$after,$notes,$journal,$key,$userId);$update=array_merge($update,array('nilai_buku'=>$after,'lifecycle_status'=>$status,'penyusutan_per_bulan'=>$this->calculateMonthly(isset($update['nilai_perolehan'])?$update['nilai_perolehan']:$cost,$asset->nilai_residu,$asset->masa_manfaat,$asset->depreciation_method),'version'=>(int)$asset->version+1));$this->CI->db->where('id',$asset->id)->update('assets',$update);return$journal;});
|
||||
}
|
||||
|
||||
public function transfer($id,$date,$toLocation,$notes,$userId)
|
||||
{
|
||||
return$this->transaction(function()use($id,$date,$toLocation,$notes,$userId){$asset=$this->asset($id);if($asset->workflow_status!=='capitalized'||!in_array($asset->lifecycle_status,array('active','fully_depreciated'),true))throw new BusinessException('Aset tidak aktif.');$to=$this->location($toLocation);if((int)$asset->lokasi_asset_id===(int)$to->id)throw new BusinessException('Lokasi tujuan sama dengan lokasi saat ini.');if(trim($notes)==='')throw new BusinessException('Alasan transfer wajib diisi.');$key='ASSET-TRANSFER-'.$asset->id.'-'.$date.'-'.$to->id.'-V'.$asset->version;$this->event($asset,'transfer',$date,0,$asset->nilai_buku,$asset->nilai_buku,$notes,null,$key,$userId,$asset->lokasi_asset_id,$to->id);$this->CI->db->where('id',$asset->id)->update('assets',array('lokasi_asset_id'=>$to->id,'version'=>(int)$asset->version+1,'updated_by'=>$userId,'updated_at'=>date('Y-m-d H:i:s')));return$asset->id;});
|
||||
}
|
||||
|
||||
public function changeResponsibility($id,$date,$employeeId,$notes,$userId)
|
||||
{
|
||||
return$this->transaction(function()use($id,$date,$employeeId,$notes,$userId){$asset=$this->asset($id);$employee=$this->CI->db->get_where('k_employees',array('id'=>(int)$employeeId,'company_id'=>$this->company(),'is_active'=>1))->row();if(!$employee)throw new BusinessException('Penanggung jawab tidak valid.');if(trim($notes)==='')throw new BusinessException('Alasan pergantian penanggung jawab wajib diisi.');$key='ASSET-RESP-'.$asset->id.'-'.$date.'-'.$employee->id.'-V'.$asset->version;$event=$this->event($asset,'responsibility_change',$date,0,$asset->nilai_buku,$asset->nilai_buku,$notes,null,$key,$userId);$this->CI->db->insert('asset_responsibility_history',array('company_id'=>$this->company(),'asset_id'=>$asset->id,'effective_date'=>$date,'from_employee_id'=>$asset->responsible_employee_id?:null,'to_employee_id'=>$employee->id,'notes'=>$notes,'asset_event_id'=>$event,'created_by'=>$userId,'created_at'=>date('Y-m-d H:i:s')));$this->CI->db->where('id',$asset->id)->update('assets',array('responsible_employee_id'=>$employee->id,'version'=>(int)$asset->version+1));return$asset->id;});
|
||||
}
|
||||
|
||||
public function maintenance($id,array$data,$userId)
|
||||
{
|
||||
return$this->transaction(function()use($id,$data,$userId){$asset=$this->asset($id);if($asset->workflow_status!=='capitalized')throw new BusinessException('Aset belum dikapitalisasi.');if(empty($data['date'])||trim((string)$data['description'])==='')throw new BusinessException('Tanggal dan deskripsi maintenance wajib diisi.');$key=preg_replace('/[^A-Za-z0-9_.-]/','',trim((string)$data['idempotency_key']));if($key===''||$this->CI->db->get_where('asset_maintenance',array('idempotency_key'=>$key))->row())throw new BusinessException('Kunci maintenance kosong atau sudah dipakai.');$number=$this->CI->numberingservice->next('asset_maintenance',$data['date']);$this->CI->db->insert('asset_maintenance',array('company_id'=>$this->company(),'asset_id'=>$asset->id,'maintenance_no'=>$number,'maintenance_date'=>$data['date'],'vendor'=>trim((string)$data['vendor'])?:null,'description'=>trim($data['description']),'cost'=>round((float)$data['cost'],2),'next_due_date'=>!empty($data['next_due_date'])?$data['next_due_date']:null,'status'=>'posted','idempotency_key'=>$key,'created_by'=>$userId,'created_at'=>date('Y-m-d H:i:s')));$maintenanceId=(int)$this->CI->db->insert_id();$event=$this->event($asset,'maintenance',$data['date'],$data['cost'],$asset->nilai_buku,$asset->nilai_buku,'Maintenance '.$number.': '.$data['description'],null,'ASSET-MAINT-EVENT-'.$maintenanceId,$userId,null,null,null,'asset_maintenance',$maintenanceId);$this->CI->db->where('id',$maintenanceId)->update('asset_maintenance',array('asset_event_id'=>$event));return$maintenanceId;});
|
||||
}
|
||||
|
||||
public function createOpname($locationId,$date,$notes,$key,$userId)
|
||||
{
|
||||
return$this->transaction(function()use($locationId,$date,$notes,$key,$userId){$location=$this->location($locationId);$key=preg_replace('/[^A-Za-z0-9_.-]/','',trim($key));if($key===''||$this->CI->db->get_where('asset_opnames',array('idempotency_key'=>$key))->row())throw new BusinessException('Kunci opname kosong atau sudah dipakai.');$no=$this->CI->numberingservice->next('asset_opname',$date);$this->CI->db->insert('asset_opnames',array('company_id'=>$this->company(),'opname_no'=>$no,'opname_date'=>$date,'location_id'=>$location->id,'status'=>'draft','notes'=>trim((string)$notes)?:null,'snapshot_at'=>date('Y-m-d H:i:s'),'idempotency_key'=>$key,'created_by'=>$userId));$id=(int)$this->CI->db->insert_id();$assets=$this->CI->db->where(array('company_id'=>$this->company(),'workflow_status'=>'capitalized','lokasi_asset_id'=>$location->id))->where_in('lifecycle_status',array('active','fully_depreciated'))->get('assets')->result();foreach($assets as$a)$this->CI->db->insert('asset_opname_lines',array('company_id'=>$this->company(),'asset_opname_id'=>$id,'asset_id'=>$a->id,'expected_status'=>$a->lifecycle_status,'actual_status'=>$a->lifecycle_status,'expected_location_id'=>$location->id,'actual_location_id'=>$location->id,'is_found'=>1));return$id;});
|
||||
}
|
||||
|
||||
public function saveOpnameLines($id,array$lines,$userId)
|
||||
{
|
||||
return$this->transaction(function()use($id,$lines,$userId){$opname=$this->CI->db->query('SELECT * FROM asset_opnames WHERE id=? AND company_id=? FOR UPDATE',array((int)$id,$this->company()))->row();if(!$opname||$opname->status!=='draft')throw new BusinessException('Hanya draft opname yang dapat diubah.');foreach($lines as$line){$existing=$this->CI->db->select('ol.*')->from('asset_opname_lines ol')->join('assets a','a.id=ol.asset_id')->where(array('ol.id'=>(int)$line['id'],'ol.asset_opname_id'=>$opname->id,'ol.company_id'=>$this->company(),'a.company_id'=>$this->company()))->get()->row();if(!$existing)throw new BusinessException('Baris opname tidak valid.');$status=in_array($line['actual_status'],array('active','fully_depreciated','lost','damaged'),true)?$line['actual_status']:$existing->expected_status;$this->CI->db->where('id',$existing->id)->update('asset_opname_lines',array('actual_status'=>$status,'actual_location_id'=>!empty($line['actual_location_id'])?(int)$line['actual_location_id']:null,'is_found'=>!empty($line['is_found'])?1:0,'notes'=>trim((string)$line['notes'])?:null,'checked_at'=>date('Y-m-d H:i:s'),'checked_by'=>$userId));}return$opname->id;});
|
||||
}
|
||||
|
||||
public function opnameState($id,$action,$reason,$userId,$isMaster=false)
|
||||
{
|
||||
return$this->transaction(function()use($id,$action,$reason,$userId,$isMaster){$o=$this->CI->db->query('SELECT * FROM asset_opnames WHERE id=? AND company_id=? FOR UPDATE',array((int)$id,$this->company()))->row();if(!$o)throw new BusinessException('Asset opname tidak ditemukan.');if($action==='submit'){if($o->status!=='draft')throw new BusinessException('Opname tidak berstatus draft.');if(!$this->CI->db->where(array('entity_type'=>'asset_opname','entity_id'=>$o->id,'company_id'=>$this->company()))->count_all_results('transaction_attachments'))throw new BusinessException('Bukti opname wajib diunggah sebelum diajukan.');$update=array('status'=>'submitted','submitted_by'=>$userId,'submitted_at'=>date('Y-m-d H:i:s'));}elseif($action==='approve'){if($o->status!=='submitted')throw new BusinessException('Opname tidak menunggu persetujuan.');if(!$isMaster&&(int)$o->created_by===(int)$userId)throw new BusinessException('Pembuat opname tidak boleh menyetujui sendiri.');$update=array('status'=>'approved','approved_by'=>$userId,'approved_at'=>date('Y-m-d H:i:s'));}elseif($action==='reject'){if($o->status!=='submitted'||trim($reason)==='')throw new BusinessException('Status atau alasan penolakan tidak valid.');$update=array('status'=>'rejected','rejected_by'=>$userId,'rejected_at'=>date('Y-m-d H:i:s'),'rejection_reason'=>trim($reason));}elseif($action==='post'){if($o->status!=='approved')throw new BusinessException('Opname harus disetujui sebelum posting.');$lines=$this->CI->db->where(array('asset_opname_id'=>$o->id,'company_id'=>$this->company()))->get('asset_opname_lines')->result();foreach($lines as$line){$asset=$this->asset($line->asset_id);$notes='Opname '.$o->opname_no.' | '.($line->is_found?'Ditemukan':'Tidak ditemukan').' | Status fisik '.$line->actual_status.' | '.($line->notes?:'-');$to=$line->actual_location_id?:$asset->lokasi_asset_id;$this->event($asset,'opname_adjustment',$o->opname_date,0,$asset->nilai_buku,$asset->nilai_buku,$notes,null,'ASSET-OPNAME-'.$o->id.'-'.$line->id,$userId,$asset->lokasi_asset_id,$to,null,'asset_opname',$o->id);if($line->is_found&&$to)$this->CI->db->where('id',$asset->id)->update('assets',array('lokasi_asset_id'=>$to,'version'=>(int)$asset->version+1));} $update=array('status'=>'posted','posted_by'=>$userId,'posted_at'=>date('Y-m-d H:i:s'));}else throw new BusinessException('Aksi opname tidak valid.');$this->CI->db->where('id',$o->id)->update('asset_opnames',$update);return$o->id;});
|
||||
}
|
||||
|
||||
public function reverseEvent($eventId,$date,$reason,$userId)
|
||||
{
|
||||
return$this->transaction(function()use($eventId,$date,$reason,$userId){if(trim($reason)==='')throw new BusinessException('Alasan reversal wajib diisi.');$this->CI->fiscalperiodservice->assertOpen($date);$event=$this->CI->db->query('SELECT e.* FROM asset_events e WHERE e.id=? AND e.company_id=? FOR UPDATE',array((int)$eventId,$this->company()))->row();if(!$event||$event->event_type==='reversal')throw new BusinessException('Event aset tidak valid untuk reversal.');if($this->CI->db->get_where('asset_events',array('reversal_of_event_id'=>$event->id))->row())throw new BusinessException('Event aset sudah pernah direversal.');$latest=$this->CI->db->where(array('asset_id'=>$event->asset_id,'company_id'=>$this->company()))->where('event_type !=','reversal')->order_by('event_date','DESC')->order_by('id','DESC')->limit(1)->get('asset_events')->row();if(!$latest||(int)$latest->id!==(int)$event->id)throw new BusinessException('Hanya event terakhir aset yang dapat direversal.');$asset=$this->asset($event->asset_id);$journal=null;if($event->journal_id){$original=$this->CI->db->get_where('journals',array('id'=>$event->journal_id,'company_id'=>$this->company()))->row();$details=$this->CI->db->where('journal_id',$event->journal_id)->get('journal_details')->result();if(!$original||!$details)throw new BusinessException('Jurnal event tidak ditemukan.');$lines=array();foreach($details as$d)$lines[]=array('account_id'=>$d->account_id,'debit'=>$d->kredit,'kredit'=>$d->debit);$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>'REV-AST-'.$event->id.'-'.date('YmdHis'),'keterangan'=>'[AUTO] Reversal event '.$event->event_no.' aset '.$asset->kode_asset.' - '.$asset->nama_asset.' | Alasan '.$reason,'ref_type'=>'asset_event_reversal','ref_id'=>$event->id,'reversal_of'=>$original->id,'reversal_reason'=>$reason,'created_by'=>$userId),$lines,false,true);$this->CI->db->where('id',$original->id)->update('journals',array('is_reversed'=>1,'status'=>'reversed'));}
|
||||
$update=array('nilai_buku'=>$event->book_value_before,'version'=>(int)$asset->version+1);
|
||||
if($event->event_type==='depreciation'){$update['akumulasi_penyusutan']=max(0,(float)$asset->akumulasi_penyusutan-(float)$event->amount);$update['lifecycle_status']='active';$this->CI->db->where('asset_event_id',$event->id)->update('asset_depreciation_schedule',array('status'=>'reversed','reversal_journal_id'=>$journal,'reversed_at'=>date('Y-m-d H:i:s')));}
|
||||
elseif($event->event_type==='transfer')$update['lokasi_asset_id']=$event->from_location_id;
|
||||
elseif($event->event_type==='responsibility_change'){$h=$this->CI->db->get_where('asset_responsibility_history',array('asset_event_id'=>$event->id))->row();if($h)$update['responsible_employee_id']=$h->from_employee_id;}
|
||||
elseif($event->event_type==='addition')$update['nilai_perolehan']=max(0,(float)$asset->nilai_perolehan-(float)$event->amount);
|
||||
elseif($event->event_type==='revaluation'){$update['nilai_perolehan']=max(0,(float)$asset->nilai_perolehan-(float)$event->amount);$update['revaluation_total']=max(0,(float)$asset->revaluation_total-(float)$event->amount);}
|
||||
elseif($event->event_type==='impairment')$update['impairment_total']=max(0,(float)$asset->impairment_total-(float)$event->amount);
|
||||
elseif(in_array($event->event_type,array('disposal','lost','damaged'),true)){$update['lifecycle_status']='active';$update['status']='aktif';$update['disposal_date']=null;$update['disposal_proceeds']=0;}
|
||||
elseif($event->event_type==='capitalization'){$update['workflow_status']='approved';$update['lifecycle_status']='draft';$update['acquisition_journal_id']=null;if($asset->source_type==='warehouse'){$barcode=$this->CI->db->query('SELECT * FROM item_barcodes WHERE id=? FOR UPDATE',array((int)$asset->barcode_id))->row();if(!$barcode)throw new BusinessException('Barcode sumber aset tidak ditemukan.');$key='ASSET-CAP-REV-'.$asset->id.'-V'.$asset->version;$this->CI->db->insert('inventory_ledger',array('item_id'=>$asset->item_id,'warehouse_id'=>$asset->warehouse_id,'bin_id'=>$asset->bin_id?:null,'barcode_id'=>$barcode->id,'batch_id'=>$barcode->batch_id?:null,'movement_date'=>$date,'direction'=>'in','qty'=>$asset->qty,'unit_cost'=>$asset->harga_per_unit,'value'=>$asset->nilai_perolehan,'document_type'=>'asset_capitalization_reversal','document_id'=>$asset->id,'document_line_id'=>$event->id,'idempotency_key'=>$key,'created_at'=>date('Y-m-d H:i:s')));$this->CI->db->insert('stock_logs',array('company_id'=>$this->company(),'item_id'=>$asset->item_id,'warehouse_id'=>$asset->warehouse_id,'barcode_id'=>$barcode->id,'qty'=>$asset->qty,'tipe'=>'masuk','unit_cost'=>$asset->harga_per_unit,'keterangan'=>'[AUTO] Reversal kapitalisasi aset '.$asset->kode_asset.' | '.$reason,'ref_type'=>'asset_capitalization_reversal','ref_id'=>$asset->id,'idempotency_key'=>$key));$this->CI->db->insert('item_movements',array('item_id'=>$asset->item_id,'barcode_id'=>$barcode->id,'qty'=>$asset->qty,'from_type'=>'asset','from_id'=>$asset->id,'to_type'=>'warehouse','to_id'=>$asset->warehouse_id,'movement_type'=>'uninstallation','notes'=>'Reversal kapitalisasi: '.$reason,'created_at'=>date('Y-m-d H:i:s')));$this->CI->db->where('id',$barcode->id)->update('item_barcodes',array('qty_sisa'=>(float)$barcode->qty_sisa+(float)$asset->qty,'status'=>'available','version'=>(int)$barcode->version+1));$this->synchronizeItem($asset->item_id);}}
|
||||
$this->CI->db->where('id',$asset->id)->update('assets',$update);$this->event($asset,'reversal',$date,$event->amount,$event->book_value_after,$event->book_value_before,'Reversal '.$event->event_no.': '.$reason,$journal,'ASSET-REV-'.$event->id,$userId,$event->to_location_id,$event->from_location_id,$event->id,'asset_event',$event->id);return$journal;
|
||||
});
|
||||
}
|
||||
|
||||
public function saveCategory(array$data,$userId)
|
||||
{
|
||||
$id=(int)($data['id']??0);$code=strtoupper(preg_replace('/[^A-Z0-9_-]/i','',trim($data['code'])));$name=trim($data['name']);if($code===''||$name==='')throw new BusinessException('Kode dan nama kategori wajib diisi.');$row=array('company_id'=>$this->company(),'code'=>$code,'name'=>$name,'default_useful_life_months'=>max(1,(int)$data['life']),'default_residual_percent'=>max(0,(float)$data['residual_percent']),'depreciation_method'=>$data['method']==='declining_balance'?'declining_balance':'straight_line','asset_account_id'=>(int)$data['asset_account_id']?:null,'accumulated_depreciation_account_id'=>(int)$data['accumulated_account_id']?:null,'depreciation_expense_account_id'=>(int)$data['expense_account_id']?:null,'is_active'=>!empty($data['is_active'])?1:0,'updated_at'=>date('Y-m-d H:i:s'),'updated_by'=>$userId);if($id){$this->CI->db->where(array('id'=>$id,'company_id'=>$this->company()))->update('asset_categories',$row);if(!$this->CI->db->affected_rows()&&!$this->CI->db->get_where('asset_categories',array('id'=>$id,'company_id'=>$this->company()))->row())throw new BusinessException('Kategori tidak ditemukan.');return$id;}$this->CI->db->insert('asset_categories',$row);return(int)$this->CI->db->insert_id();
|
||||
}
|
||||
|
||||
public function saveLocation(array$data,$userId)
|
||||
{
|
||||
$id=(int)($data['id']??0);$code=strtoupper(preg_replace('/[^A-Z0-9_-]/i','',trim($data['code'])));$name=trim($data['name']);if($code===''||$name==='')throw new BusinessException('Kode dan nama lokasi wajib diisi.');$row=array('company_id'=>$this->company(),'code'=>$code,'nama'=>$name,'address'=>trim((string)$data['address'])?:null,'responsible_employee_id'=>!empty($data['responsible_employee_id'])?(int)$data['responsible_employee_id']:null,'is_active'=>!empty($data['is_active'])?1:0,'updated_at'=>date('Y-m-d H:i:s'),'updated_by'=>$userId);if($id){$this->CI->db->where(array('id'=>$id,'company_id'=>$this->company()))->update('lokasi_asset',$row);if(!$this->CI->db->affected_rows()&&!$this->CI->db->get_where('lokasi_asset',array('id'=>$id,'company_id'=>$this->company()))->row())throw new BusinessException('Lokasi tidak ditemukan.');return$id;}$this->CI->db->insert('lokasi_asset',$row);return(int)$this->CI->db->insert_id();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
class FormulaService
|
||||
{
|
||||
public function calculate($expression,array$vars){$expression=trim((string)$expression);if($expression==='')return 0;preg_match_all('/[A-Za-z_][A-Za-z0-9_]*|\d+(?:\.\d+)?|[()+\-*\/]/',$expression,$m);$tokens=$m[0];if(preg_replace('/\s+/','',$expression)!==implode('',$tokens))throw new BusinessException('Formula mengandung token yang tidak diizinkan.');$out=array();$ops=array();$prec=array('+'=>1,'-'=>1,'*'=>2,'/'=>2);foreach($tokens as$t){if(is_numeric($t)||preg_match('/^[A-Za-z_]/',$t))$out[]=$t;elseif($t==='(')$ops[]=$t;elseif($t===')'){while($ops&&end($ops)!=='(')$out[]=array_pop($ops);if(!$ops)throw new BusinessException('Kurung formula tidak seimbang.');array_pop($ops);}else{while($ops&&end($ops)!=='('&&$prec[end($ops)]>=$prec[$t])$out[]=array_pop($ops);$ops[]=$t;}}while($ops){$x=array_pop($ops);if($x==='(')throw new BusinessException('Kurung formula tidak seimbang.');$out[]=$x;}$stack=array();foreach($out as$t){if(isset($prec[$t])){$b=array_pop($stack);$a=array_pop($stack);if($t==='/'&&abs($b)<.0000001)throw new BusinessException('Pembagian nol pada formula.');$stack[]=$t==='+'?$a+$b:($t==='-'?$a-$b:($t==='*'?$a*$b:$a/$b));}else$stack[]=is_numeric($t)?(float)$t:(isset($vars[$t])?(float)$vars[$t]:0);}if(count($stack)!==1)throw new BusinessException('Formula tidak valid.');return round($stack[0],2);}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
class JobQueueService
|
||||
{
|
||||
private $CI;public function __construct(){$this->CI=&get_instance();}
|
||||
public function enqueue($type,array$payload,$key,$queue='default',$priority=5,$max=3,$companyId=null){$row=$this->CI->db->get_where('app_jobs',array('idempotency_key'=>$key))->row();if($row)return(int)$row->id;$this->CI->db->insert('app_jobs',array('company_id'=>$companyId,'queue_name'=>$queue,'job_type'=>$type,'payload'=>json_encode($payload),'priority'=>$priority,'max_attempts'=>$max,'available_at'=>date('Y-m-d H:i:s'),'idempotency_key'=>$key,'created_at'=>date('Y-m-d H:i:s')));return(int)$this->CI->db->insert_id();}
|
||||
public function reserve($worker,$queue='default'){$db=$this->CI->db;$db->trans_begin();try{$job=$db->query("SELECT * FROM app_jobs WHERE queue_name=? AND status IN('queued','retry') AND available_at<=? ORDER BY priority ASC,id ASC LIMIT 1 FOR UPDATE",array($queue,date('Y-m-d H:i:s')))->row();if(!$job){$db->trans_commit();return null;}$db->where('id',$job->id)->where_in('status',array('queued','retry'))->update('app_jobs',array('status'=>'processing','attempts'=>$job->attempts+1,'locked_at'=>date('Y-m-d H:i:s'),'locked_by'=>$worker));if($db->affected_rows()!==1)throw new RuntimeException('Job telah diambil worker lain.');$db->trans_commit();$job->attempts++;return$job;}catch(Throwable$e){$db->trans_rollback();throw$e;}}
|
||||
public function recoverStale($seconds=900){$this->CI->db->where('status','processing')->where('locked_at <',date('Y-m-d H:i:s',time()-max(60,(int)$seconds)))->update('app_jobs',array('status'=>'retry','available_at'=>date('Y-m-d H:i:s'),'locked_at'=>null,'locked_by'=>null,'last_error'=>'Worker timeout; job dikembalikan ke antrean.'));return$this->CI->db->affected_rows();}
|
||||
public function complete($id){$this->CI->db->where('id',$id)->update('app_jobs',array('status'=>'completed','completed_at'=>date('Y-m-d H:i:s'),'locked_at'=>null,'locked_by'=>null));}
|
||||
public function fail($job,Throwable$error){$message=substr($error->getMessage(),0,4000);if((int)$job->attempts>=(int)$job->max_attempts){$this->CI->db->trans_start();$this->CI->db->insert('app_dead_letters',array('job_id'=>$job->id,'company_id'=>$job->company_id,'job_type'=>$job->job_type,'payload'=>$job->payload,'attempts'=>$job->attempts,'last_error'=>$message,'failed_at'=>date('Y-m-d H:i:s')));$this->CI->db->where('id',$job->id)->update('app_jobs',array('status'=>'dead','last_error'=>$message,'locked_at'=>null,'locked_by'=>null));$this->CI->db->trans_complete();return;}$delay=min(3600,pow(2,(int)$job->attempts)*30);$this->CI->db->where('id',$job->id)->update('app_jobs',array('status'=>'retry','last_error'=>$message,'available_at'=>date('Y-m-d H:i:s',time()+$delay),'locked_at'=>null,'locked_by'=>null));}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
class JournalDescriptionService
|
||||
{
|
||||
private $CI;
|
||||
public function __construct(){$this->CI=&get_instance();}
|
||||
public function build(array $header,array $lines)
|
||||
{
|
||||
$type=isset($header['ref_type'])?(string)$header['ref_type']:'';
|
||||
if($type===''||$type==='manual_journal')return trim((string)$header['keterangan']);
|
||||
$total=0;foreach($lines as$line)$total+=isset($line['debit'])?(float)$line['debit']:0;
|
||||
$labels=array('payroll'=>'Posting Payroll','payroll_payment'=>'Pembayaran Payroll','invoice'=>'Posting Invoice Penjualan','customer_payment'=>'Penerimaan Pembayaran Pelanggan','credit_note'=>'Credit Note Penjualan','customer_refund'=>'Refund Pelanggan','receivable_writeoff'=>'Penghapusan Piutang','invoice_reversal'=>'Reversal Invoice','journal_reversal'=>'Reversal Jurnal','stock_logs'=>'Mutasi Persediaan','invoice_details'=>'Penjualan Item Invoice','asset_acquisition'=>'Perolehan Aset','asset_depreciation'=>'Penyusutan Aset','asset_disposal'=>'Pelepasan/Penjualan Aset','asset_addition'=>'Penambahan Nilai Aset','asset_impairment'=>'Impairment Aset','asset_revaluation'=>'Revaluasi Aset','asset_lost'=>'Aset Hilang','asset_damaged'=>'Aset Rusak','cash_transaction'=>'Transaksi Kas/Bank','cash_transfer'=>'Transfer Antar Rekening','supplier_invoice'=>'Posting Tagihan Supplier','supplier_payment'=>'Pembayaran Supplier','supplier_opening_balance'=>'Saldo Awal Hutang Supplier','purchase_return'=>'Retur Pembelian','supplier_debit_note'=>'Debit Note Supplier');
|
||||
$parts=array('[AUTO] '.(isset($labels[$type])?$labels[$type]:ucwords(str_replace('_',' ',$type))));
|
||||
$document=$this->document($type,isset($header['ref_id'])?$header['ref_id']:null);
|
||||
if(!$document['number']&&!empty($header['no_ref']))$document['number']=$header['no_ref'];
|
||||
if($type==='customer_payment'&&!$document['party']&&preg_match('/#(\d+)/',(string)$header['keterangan'],$match)){$customer=$this->CI->db->select('nama')->get_where('customers',array('id'=>(int)$match[1]))->row();if($customer)$document['party']=$customer->nama;}
|
||||
if($type==='supplier_payment'&&!$document['party']&&preg_match('/#(\d+)/',(string)$header['keterangan'],$match)){$supplier=$this->CI->db->select('name')->get_where('suppliers',array('id'=>(int)$match[1]))->row();if($supplier)$document['party']=$supplier->name;}
|
||||
if($document['number'])$parts[]='Dokumen: '.$document['number'];
|
||||
if($document['party'])$parts[]='Pihak: '.$document['party'];
|
||||
$parts[]='Tanggal transaksi: '.$header['tanggal'];
|
||||
$parts[]='Nilai jurnal: Rp '.number_format($total,2,',','.');
|
||||
$parts[]='Sumber: '.$type.'#'.(isset($header['ref_id'])&&$header['ref_id']!==null?$header['ref_id']:'-');
|
||||
$original=trim((string)$header['keterangan']);if($original!==''&&stripos($original,'[AUTO]')!==0)$parts[]='Rincian: '.$original;
|
||||
return implode(' | ',$parts);
|
||||
}
|
||||
private function document($type,$id)
|
||||
{
|
||||
$result=array('number'=>null,'party'=>null);if(!$id)return$result;
|
||||
if(in_array($type,array('invoice','invoice_reversal','receivable_writeoff','credit_note'),true)){$row=$this->CI->db->select('invoices.no_invoice,customers.nama')->from('invoices')->join('customers','customers.id=invoices.customer_id','left')->where('invoices.id',(int)$id)->get()->row();if($row)return array('number'=>$row->no_invoice,'party'=>$row->nama);}
|
||||
if($type==='journal_reversal'){$row=$this->CI->db->select('no_ref')->get_where('journals',array('id'=>(int)$id))->row();if($row)$result['number']=$row->no_ref;}
|
||||
if($type==='supplier_invoice'){$row=$this->CI->db->select('supplier_invoices.internal_no,supplier_invoices.supplier_invoice_no,suppliers.name')->from('supplier_invoices')->join('suppliers','suppliers.id=supplier_invoices.supplier_id')->where('supplier_invoices.id',(int)$id)->get()->row();if($row)return array('number'=>$row->internal_no.'/'.$row->supplier_invoice_no,'party'=>$row->name);}
|
||||
if($type==='supplier_opening_balance'){$row=$this->CI->db->select('supplier_code,name')->get_where('suppliers',array('id'=>(int)$id))->row();if($row)return array('number'=>'OPEN-'.$row->supplier_code,'party'=>$row->name);}
|
||||
return$result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
|
||||
require_once APPPATH . 'exceptions/BusinessException.php';
|
||||
|
||||
class JournalService
|
||||
{
|
||||
private $CI;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->CI =& get_instance();
|
||||
$this->CI->load->model('JournalModel', 'journalModel');
|
||||
$this->CI->load->model('AccountModel', 'accountModel');
|
||||
$this->CI->load->library('TransactionService');
|
||||
$this->CI->load->library('FiscalPeriodService');
|
||||
$this->CI->load->library('JournalDescriptionService');
|
||||
if ($this->CI->db->table_exists('companies')) $this->CI->load->library('CompanyContext');
|
||||
}
|
||||
|
||||
public function create(array $header, array $lines, $manageTransaction = true)
|
||||
{
|
||||
$normalized = $this->validateAndNormalize($header, $lines);
|
||||
$operation = function () use ($normalized) {
|
||||
$journalId = $this->CI->journalModel->createHeader($normalized['header']);
|
||||
$details = array();
|
||||
foreach ($normalized['lines'] as $line) {
|
||||
$line['journal_id'] = $journalId;
|
||||
$details[] = $line;
|
||||
}
|
||||
$this->CI->journalModel->addLines($details);
|
||||
if ($this->CI->db->table_exists('journal_status_histories')) {
|
||||
$this->CI->journalModel->addStatusHistory(
|
||||
$journalId,
|
||||
null,
|
||||
$normalized['header']['status'],
|
||||
$normalized['header']['created_by'],
|
||||
'Jurnal dibuat'
|
||||
);
|
||||
}
|
||||
return $journalId;
|
||||
};
|
||||
|
||||
return $manageTransaction
|
||||
? $this->CI->transactionservice->run($operation)
|
||||
: $operation();
|
||||
}
|
||||
|
||||
public function totals(array $lines)
|
||||
{
|
||||
$debit = 0.0;
|
||||
$credit = 0.0;
|
||||
foreach ($lines as $line) {
|
||||
$debit += isset($line['debit']) ? (float) $line['debit'] : 0;
|
||||
$credit += isset($line['kredit']) ? (float) $line['kredit'] : 0;
|
||||
}
|
||||
return array('debit' => round($debit, 2), 'kredit' => round($credit, 2));
|
||||
}
|
||||
|
||||
private function validateAndNormalize(array $header, array $lines)
|
||||
{
|
||||
foreach (array('tanggal', 'no_ref', 'keterangan', 'created_by') as $required) {
|
||||
if (!isset($header[$required]) || trim((string) $header[$required]) === '') {
|
||||
throw new BusinessException('Data header jurnal "' . $required . '" wajib diisi.');
|
||||
}
|
||||
}
|
||||
|
||||
$date = DateTime::createFromFormat('Y-m-d', $header['tanggal']);
|
||||
if (!$date || $date->format('Y-m-d') !== $header['tanggal']) {
|
||||
throw new BusinessException('Tanggal jurnal tidak valid.');
|
||||
}
|
||||
if ($this->CI->journalModel->referenceExists($header['no_ref'])) {
|
||||
throw new BusinessException('Nomor referensi jurnal sudah digunakan.');
|
||||
}
|
||||
|
||||
$period = $this->CI->fiscalperiodservice->assertOpen($header['tanggal']);
|
||||
if (count($lines) < 2) {
|
||||
throw new BusinessException('Jurnal minimal mempunyai dua baris transaksi.');
|
||||
}
|
||||
|
||||
$normalized = array();
|
||||
$accountIds = array();
|
||||
foreach ($lines as $index => $line) {
|
||||
$accountId = isset($line['account_id']) ? (int) $line['account_id'] : 0;
|
||||
$debit = round(isset($line['debit']) ? (float) $line['debit'] : 0, 2);
|
||||
$credit = round(isset($line['kredit']) ? (float) $line['kredit'] : 0, 2);
|
||||
|
||||
if ($accountId <= 0 || $debit < 0 || $credit < 0 || ($debit > 0 && $credit > 0) || ($debit <= 0 && $credit <= 0)) {
|
||||
throw new BusinessException('Baris jurnal ke-' . ($index + 1) . ' tidak valid.');
|
||||
}
|
||||
|
||||
$detail=array('account_id'=>$accountId,'debit'=>$debit,'kredit'=>$credit);
|
||||
foreach(array('currency_amount','currency_code','exchange_rate')as$currencyField)if(isset($line[$currencyField])&&$line[$currencyField]!=='')$detail[$currencyField]=$line[$currencyField];
|
||||
if($this->CI->db->field_exists('company_id','journal_details'))$detail['company_id']=$this->CI->companycontext->id();
|
||||
$normalized[]=$detail;
|
||||
$accountIds[] = $accountId;
|
||||
}
|
||||
|
||||
$existingIds = $this->CI->accountModel->existingIds($accountIds);
|
||||
$missingIds = array_diff(array_unique($accountIds), $existingIds);
|
||||
if (!empty($missingIds)) {
|
||||
throw new BusinessException('Terdapat akun jurnal yang tidak ditemukan: ' . implode(', ', $missingIds));
|
||||
}
|
||||
|
||||
$totals = $this->totals($normalized);
|
||||
if ($totals['debit'] <= 0 || abs($totals['debit'] - $totals['kredit']) > 0.009) {
|
||||
throw new BusinessException('Total debit dan kredit jurnal harus seimbang.');
|
||||
}
|
||||
|
||||
$header['keterangan'] = $this->CI->journaldescriptionservice->build($header, $normalized);
|
||||
|
||||
$header['status'] = isset($header['status']) ? $header['status'] : 'draft';
|
||||
$header['accounting_period_id'] = (int) $period->id;
|
||||
$allowedHeader = array_intersect_key($header, array_flip(array(
|
||||
'tanggal', 'no_ref', 'keterangan', 'ref_type', 'ref_id', 'created_by',
|
||||
'status', 'accounting_period_id', 'posted_at', 'posted_by', 'reversal_of', 'reversal_reason','company_id','transaction_currency','exchange_rate','base_amount'
|
||||
)));
|
||||
if($this->CI->db->field_exists('company_id','journals'))$allowedHeader['company_id']=$this->CI->companycontext->id();
|
||||
|
||||
return array('header' => $allowedHeader, 'lines' => $normalized, 'totals' => $totals);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
|
||||
require_once APPPATH . 'exceptions/BusinessException.php';
|
||||
|
||||
class JournalWorkflowService
|
||||
{
|
||||
private $CI;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->CI =& get_instance();
|
||||
$this->CI->load->model('JournalModel', 'workflowJournalModel');
|
||||
$this->CI->load->library('TransactionService');
|
||||
$this->CI->load->library('FiscalPeriodService');
|
||||
$this->CI->load->library('NumberingService');
|
||||
$this->CI->load->library('PostingService');
|
||||
$this->CI->load->library('ApprovalService');
|
||||
$this->CI->load->library('AuditService');
|
||||
$this->CI->config->load('accounting', true);
|
||||
}
|
||||
|
||||
public function submit($journalId, $userId)
|
||||
{
|
||||
return $this->CI->transactionservice->run(function () use ($journalId, $userId) {
|
||||
$journal = $this->lockedJournal($journalId, 'draft');
|
||||
if ((int)$journal->created_by !== (int)$userId && $this->CI->session->userdata('role') !== 'Admin') throw new BusinessException('Anda hanya dapat mengajukan jurnal yang Anda buat.');
|
||||
$this->CI->fiscalperiodservice->assertOpen($journal->tanggal);
|
||||
$lines=$this->CI->workflowJournalModel->lines($journal->id); $amount=0;
|
||||
foreach($lines as $line) $amount+=(float)$line['debit'];
|
||||
$this->CI->approvalservice->create('journal','journal',$journal->id,$amount,$userId,array('journal'=>$journal,'lines'=>$lines));
|
||||
$this->CI->workflowJournalModel->updateStatus($journal->id,'submitted',array('submitted_at'=>date('Y-m-d H:i:s'),'submitted_by'=>(int)$userId));
|
||||
$this->CI->workflowJournalModel->addStatusHistory($journal->id,'draft','submitted',$userId);
|
||||
$after=$this->CI->workflowJournalModel->find($journal->id);
|
||||
$this->CI->auditservice->record('journal','journal',$journal->id,'submit',$journal,$after,$userId);
|
||||
return $after;
|
||||
});
|
||||
}
|
||||
|
||||
public function approve($journalId, $userId)
|
||||
{
|
||||
return $this->CI->transactionservice->run(function () use ($journalId, $userId) {
|
||||
$journal = $this->lockedJournal($journalId, 'submitted');
|
||||
$request=$this->CI->approvalservice->pendingForEntity('journal',$journal->id);
|
||||
if(!$request) throw new BusinessException('Permintaan approval jurnal tidak ditemukan.');
|
||||
$roleId=$this->roleId($userId);
|
||||
$approval=$this->CI->approvalservice->act($request->id,'approved',$userId,$roleId);
|
||||
if($approval->status!=='approved') return $journal;
|
||||
$this->CI->fiscalperiodservice->assertOpen($journal->tanggal);
|
||||
$now=date('Y-m-d H:i:s');
|
||||
$this->CI->workflowJournalModel->updateStatus($journal->id, 'posted', array(
|
||||
'approved_at'=>$now,'approved_by'=>(int)$userId,'posted_at'=>$now,'posted_by'=>(int)$userId
|
||||
));
|
||||
$this->CI->workflowJournalModel->addStatusHistory($journal->id, 'submitted', 'approved', $userId);
|
||||
$this->CI->workflowJournalModel->addStatusHistory($journal->id, 'approved', 'posted', $userId, 'Otomatis diposting setelah approval final.');
|
||||
$after=$this->CI->workflowJournalModel->find($journal->id);
|
||||
$this->CI->auditservice->record('journal','journal',$journal->id,'approve_and_post',$journal,$after,$userId);
|
||||
return $after;
|
||||
});
|
||||
}
|
||||
|
||||
public function post($journalId, $userId)
|
||||
{
|
||||
return $this->CI->transactionservice->run(function () use ($journalId, $userId) {
|
||||
$journal = $this->lockedJournal($journalId, 'approved');
|
||||
$this->CI->fiscalperiodservice->assertOpen($journal->tanggal);
|
||||
$this->CI->workflowJournalModel->updateStatus($journal->id, 'posted', array(
|
||||
'posted_at' => date('Y-m-d H:i:s'), 'posted_by' => (int) $userId
|
||||
));
|
||||
$this->CI->workflowJournalModel->addStatusHistory($journal->id, 'approved', 'posted', $userId);
|
||||
$after=$this->CI->workflowJournalModel->find($journal->id);
|
||||
$this->CI->auditservice->record('journal','journal',$journal->id,'post',$journal,$after,$userId);
|
||||
return $after;
|
||||
});
|
||||
}
|
||||
|
||||
public function reject($journalId, $userId, $reason)
|
||||
{
|
||||
$reason = trim((string) $reason);
|
||||
if ($reason === '') {
|
||||
throw new BusinessException('Alasan penolakan wajib diisi.');
|
||||
}
|
||||
return $this->CI->transactionservice->run(function () use ($journalId, $userId, $reason) {
|
||||
$journal = $this->CI->workflowJournalModel->findForUpdate($journalId);
|
||||
if (!$journal || !in_array($journal->status, array('submitted', 'approved'), true)) {
|
||||
throw new BusinessException('Jurnal tidak berada pada status yang dapat ditolak.');
|
||||
}
|
||||
if($journal->status==='submitted') {
|
||||
$request=$this->CI->approvalservice->pendingForEntity('journal',$journal->id);
|
||||
if($request) $this->CI->approvalservice->act($request->id,'rejected',$userId,$this->roleId($userId),$reason);
|
||||
}
|
||||
$this->CI->workflowJournalModel->updateStatus($journal->id, 'rejected', array(
|
||||
'rejected_at' => date('Y-m-d H:i:s'), 'rejected_by' => (int) $userId, 'rejection_reason' => $reason
|
||||
));
|
||||
$this->CI->workflowJournalModel->addStatusHistory($journal->id, $journal->status, 'rejected', $userId, $reason);
|
||||
$after=$this->CI->workflowJournalModel->find($journal->id);
|
||||
$this->CI->auditservice->record('journal','journal',$journal->id,'reject',$journal,$after,$userId);
|
||||
return $after;
|
||||
});
|
||||
}
|
||||
|
||||
public function reverse($journalId, $date, $userId, $reason)
|
||||
{
|
||||
$reason = trim((string) $reason);
|
||||
if ($reason === '') {
|
||||
throw new BusinessException('Alasan reversal wajib diisi.');
|
||||
}
|
||||
return $this->CI->transactionservice->run(function () use ($journalId, $date, $userId, $reason) {
|
||||
$journal = $this->lockedJournal($journalId, 'posted');
|
||||
if ((int) $journal->is_reversed === 1) {
|
||||
throw new BusinessException('Jurnal sudah pernah direversal.');
|
||||
}
|
||||
$this->CI->fiscalperiodservice->assertOpen($date);
|
||||
$lines = array();
|
||||
foreach ($this->CI->workflowJournalModel->lines($journal->id) as $line) {
|
||||
$lines[] = array('account_id' => $line['account_id'], 'debit' => $line['kredit'], 'kredit' => $line['debit']);
|
||||
}
|
||||
$number = $this->CI->numberingservice->next('reversal', $date);
|
||||
$reversalId = $this->CI->postingservice->post(array(
|
||||
'tanggal' => $date,
|
||||
'no_ref' => $number,
|
||||
'keterangan' => 'Reversal ' . $journal->no_ref . ': ' . $reason,
|
||||
'ref_type' => 'journal_reversal',
|
||||
'ref_id' => $journal->id,
|
||||
'reversal_of' => $journal->id,
|
||||
'reversal_reason' => $reason,
|
||||
'created_by' => (int) $userId
|
||||
), $lines, false, true);
|
||||
$this->CI->workflowJournalModel->updateStatus($journal->id, 'reversed', array(
|
||||
'is_reversed' => 1, 'reversal_reason' => $reason, 'updated_by' => (int) $userId
|
||||
));
|
||||
$this->CI->workflowJournalModel->addStatusHistory($journal->id, 'posted', 'reversed', $userId, $reason);
|
||||
$this->CI->auditservice->record('journal','journal',$journal->id,'reverse',$journal,$this->CI->workflowJournalModel->find($journal->id),$userId);
|
||||
return array('journal' => $this->CI->workflowJournalModel->find($journal->id), 'reversal_id' => $reversalId, 'reversal_no' => $number);
|
||||
});
|
||||
}
|
||||
|
||||
private function transition($journalId, $from, $to, $userId, array $extra)
|
||||
{
|
||||
return $this->CI->transactionservice->run(function () use ($journalId, $from, $to, $userId, $extra) {
|
||||
$journal = $this->lockedJournal($journalId, $from);
|
||||
if ($from === 'draft' && (int) $journal->created_by !== (int) $userId && $this->CI->session->userdata('role') !== 'Admin') {
|
||||
throw new BusinessException('Anda hanya dapat mengajukan jurnal yang Anda buat.');
|
||||
}
|
||||
$this->CI->fiscalperiodservice->assertOpen($journal->tanggal);
|
||||
$this->CI->workflowJournalModel->updateStatus($journal->id, $to, $extra);
|
||||
$this->CI->workflowJournalModel->addStatusHistory($journal->id, $from, $to, $userId);
|
||||
return $this->CI->workflowJournalModel->find($journal->id);
|
||||
});
|
||||
}
|
||||
|
||||
private function lockedJournal($journalId, $expectedStatus)
|
||||
{
|
||||
$journal = $this->CI->workflowJournalModel->findForUpdate($journalId);
|
||||
if (!$journal) {
|
||||
throw new BusinessException('Jurnal tidak ditemukan.', array(), 404);
|
||||
}
|
||||
if ($journal->status !== $expectedStatus) {
|
||||
throw new BusinessException('Jurnal harus berstatus ' . $expectedStatus . ' untuk tindakan ini.');
|
||||
}
|
||||
return $journal;
|
||||
}
|
||||
|
||||
private function roleId($userId)
|
||||
{
|
||||
$roleId=(int)$this->CI->session->userdata('role_id');
|
||||
if(!$roleId) { $user=$this->CI->db->select('role_id')->get_where('users',array('id'=>(int)$userId))->row(); $roleId=$user?(int)$user->role_id:0; }
|
||||
return $roleId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
class MultiCurrencyService
|
||||
{
|
||||
private $CI;public function __construct(){$this->CI=&get_instance();$this->CI->load->library(array('CompanyContext','PostingService','AccountMappingService','FiscalPeriodService'));}
|
||||
public function rate($currency,$date){$company=$this->CI->db->get_where('companies',array('id'=>$this->CI->companycontext->id()))->row();if($currency===$company->base_currency)return 1;$r=$this->CI->db->where('company_id',$company->id)->where('from_currency',$currency)->where('to_currency',$company->base_currency)->where('rate_date <=',$date)->order_by('rate_date','DESC')->get('exchange_rates')->row();if(!$r)throw new BusinessException('Kurs '.$currency.'/'.$company->base_currency.' belum tersedia pada '.$date);return(float)$r->rate;}
|
||||
public function revalue($date,$uid){$db=$this->CI->db;$db->trans_begin();try{$cid=$this->CI->companycontext->id();$this->CI->fiscalperiodservice->assertOpen($date);$db->insert('fx_revaluations',array('company_id'=>$cid,'revaluation_no'=>'FXR-'.date('YmdHis'),'revaluation_date'=>$date,'created_by'=>$uid));$id=$db->insert_id();$gain=$loss=0;$lines=array();$ar=$this->CI->accountmappingservice->get('accounts_receivable');foreach($db->where('company_id',$cid)->where('workflow_status','posted')->where('sisa_piutang >',0)->where('transaction_currency !=','IDR')->get('invoices')->result()as$i){$foreign=(float)($i->foreign_balance?:$i->foreign_total);if($foreign<=0)continue;$diff=round($foreign*$this->rate($i->transaction_currency,$date)-(float)$i->sisa_piutang,2);if(abs($diff)<.01)continue;$lines[]=array('account_id'=>$ar,'debit'=>$diff>0?$diff:0,'kredit'=>$diff<0?abs($diff):0);if($diff>0)$gain+=$diff;else$loss+=abs($diff);}$ap=$this->CI->accountmappingservice->get('accounts_payable');foreach($db->where('company_id',$cid)->where('status','partial')->where('balance >',0)->where('transaction_currency !=','IDR')->get('supplier_invoices')->result()as$i){$foreign=(float)($i->foreign_balance?:$i->foreign_total);if($foreign<=0)continue;$diff=round($foreign*$this->rate($i->transaction_currency,$date)-(float)$i->balance,2);if(abs($diff)<.01)continue;$lines[]=array('account_id'=>$ap,'debit'=>$diff<0?abs($diff):0,'kredit'=>$diff>0?$diff:0);if($diff>0)$loss+=$diff;else$gain+=abs($diff);}if(!$lines)throw new BusinessException('Tidak ada saldo valuta asing yang perlu direvaluasi.');if($loss>0)$lines[]=array('account_id'=>$this->CI->accountmappingservice->get('unrealized_fx_loss'),'debit'=>$loss,'kredit'=>0);if($gain>0)$lines[]=array('account_id'=>$this->CI->accountmappingservice->get('unrealized_fx_gain'),'debit'=>0,'kredit'=>$gain);$jid=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>'FXR-'.$id.'-'.str_replace('-','',$date),'keterangan'=>'Revaluasi saldo valuta asing pada '.$date,'ref_type'=>'fx_revaluation','ref_id'=>$id,'created_by'=>$uid),$lines,false,true);$db->where('id',$id)->update('fx_revaluations',array('status'=>'posted','gain_amount'=>$gain,'loss_amount'=>$loss,'journal_id'=>$jid,'posted_at'=>date('Y-m-d H:i:s')));$db->trans_commit();return$jid;}catch(Throwable$e){$db->trans_rollback();throw$e;}}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
class NavigationService
|
||||
{
|
||||
private $CI;private $pendingBadge=null;private $inventoryDocumentBadge=null;private $technicianBadge=null;private $payableBadge=null;private $payableBadgeClass='';private $returnBadge=null;private $returnBadgeClass='';private $budgetBadge=null;private $salesBadges=null;public function __construct(){$this->CI=&get_instance();}
|
||||
private function allowed($feature){if(is_master_admin_user())return true;foreach((array)$feature as$name)if(check_permission($name,'can_view'))return true;return false;}
|
||||
public function items(){
|
||||
$items=array(
|
||||
array('key'=>'dashboard','label'=>'Dashboard','icon'=>'bi-speedometer2','url'=>'','feature'=>'dashboard'),
|
||||
array('key'=>'approvals','label'=>'Approval','icon'=>'bi-inbox','url'=>'approvals','feature'=>'approvals','badge'=>$this->approvalBadge()),
|
||||
array('key'=>'sales_menu','label'=>'Penjualan','icon'=>'bi-receipt','feature'=>'invoices','badge'=>$this->salesBadge('all'),'badge_class'=>$this->salesBadge('overdue')?'nav-badge-danger':'nav-badge-warning','badge_title'=>'Invoice, piutang, pengiriman, atau retur membutuhkan tindakan','children'=>array(array('key'=>'sales','label'=>'Ringkasan Penjualan','url'=>'sales'),array('key'=>'sales_invoice','label'=>'Invoice','url'=>'invoices','badge'=>$this->salesBadge('draft')+$this->salesBadge('approval'),'badge_class'=>'nav-badge-warning'),array('key'=>'sales_delivery','label'=>'Pengiriman / Surat Jalan','url'=>'sales/deliveries','badge'=>$this->salesBadge('delivery'),'badge_class'=>'nav-badge-warning'),array('key'=>'receivables','label'=>'Piutang & Pembayaran','url'=>'receivables','badge'=>$this->salesBadge('receivable'),'badge_class'=>$this->salesBadge('overdue')?'nav-badge-danger':'nav-badge-warning'),array('key'=>'sales_return','label'=>'Retur Penjualan & Refund','url'=>'sales/returns','badge'=>$this->salesBadge('return'),'badge_class'=>'nav-badge-warning'),array('key'=>'customers','label'=>'Customer','url'=>'customers'))),
|
||||
array('key'=>'purchase','label'=>'Pembelian','icon'=>'bi-cart','feature'=>array('purchases','purchase_returns','purchase_refunds','supplier_debit_notes'),'badge'=>$this->purchaseBadge(),'badge_class'=>$this->purchaseBadgeClass(),'badge_title'=>'Tagihan dan dokumen Retur/Refund yang membutuhkan tindakan','children'=>array(array('key'=>'suppliers','label'=>'Master Supplier','url'=>'suppliers','feature'=>'purchases'),array('key'=>'purchases','label'=>'Purchase Workflow','url'=>'purchases','feature'=>'purchases'),array('key'=>'purchase_returns','label'=>'Retur & Refund','url'=>'purchases/returns','feature'=>array('purchase_returns','purchase_refunds','supplier_debit_notes'),'badge'=>$this->returnBadge(),'badge_class'=>$this->returnBadgeClass(),'badge_title'=>'Dokumen Retur/Refund yang membutuhkan tindakan'),array('key'=>'payables','label'=>'Hutang & Pembayaran','url'=>'payables','feature'=>'purchases','badge'=>$this->payableBadge(),'badge_class'=>$this->payableBadgeClass(),'badge_title'=>$this->payableBadgeTitle()))),
|
||||
array('key'=>'inventory','label'=>'Persediaan','icon'=>'bi-boxes','feature'=>array('items','technician_equipment'),'badge'=>$this->inventoryBadge(),'badge_class'=>($this->inventoryDocumentBadge()+$this->technicianActionBadge())>0?'nav-badge-danger':'nav-badge-warning','badge_title'=>'Barang pending, dokumen stok, atau peralatan teknisi membutuhkan tindakan','children'=>array(array('key'=>'inventory_overview','label'=>'Ringkasan Persediaan','url'=>'inventoryprofessional','feature'=>'items'),array('key'=>'draft_items','label'=>'Barang Pending','url'=>'items/draft_items','feature'=>'items','badge'=>$this->pendingItemBadge(),'badge_class'=>'nav-badge-warning'),array('key'=>'items','label'=>'Daftar Barang','url'=>'items','feature'=>'items'),array('key'=>'inventory_operations','label'=>'Operasional Stok','url'=>'inventoryprofessional/operations','feature'=>'items','badge'=>$this->inventoryDocumentBadge(),'badge_class'=>'nav-badge-danger','badge_title'=>'Dokumen stok menunggu persetujuan atau posting'),array('key'=>'inventory_locations','label'=>'Gudang & Lokasi Rak','url'=>'inventoryprofessional/locations','feature'=>'items'),array('key'=>'kode_barang','label'=>'Jenis / Kode Barang','url'=>'kodebarang','feature'=>'items'),array('key'=>'peralatan_teknisi','label'=>'Peralatan Teknisi','url'=>'teknisi','feature'=>array('technician_equipment','items'),'badge'=>$this->technicianActionBadge(),'badge_class'=>'nav-badge-danger','badge_title'=>'Barang menunggu pemeriksaan atau tindak lanjut'),array('key'=>'inventory_reports','label'=>'Laporan Persediaan','url'=>'inventoryprofessional/reports','feature'=>'items'))),
|
||||
array('key'=>'fixed_assets','label'=>'Aset Tetap','icon'=>'bi-building-gear','url'=>'fixedassets','feature'=>'asset'),
|
||||
array('key'=>'accounting','label'=>'Accounting','icon'=>'bi-journal-text','feature'=>'jurnal','children'=>array(array('key'=>'jurnal','label'=>'Jurnal Umum','url'=>'jurnal'),array('key'=>'accounts','label'=>'Daftar Akun','url'=>'accounts'),array('key'=>'basejurnal','label'=>'Base Jurnal','url'=>'basejurnal'),array('key'=>'accounting_periods','label'=>'Periode Accounting','url'=>'accountingperiods'))),
|
||||
array('key'=>'professional_reports','label'=>'Laporan Profesional','icon'=>'bi-bar-chart','url'=>'professionalreports','feature'=>'reports'),
|
||||
array('key'=>'budgets','label'=>'Budget','icon'=>'bi-bullseye','url'=>'budgets','feature'=>'budgets','badge'=>$this->budgetApprovalBadge(),'badge_class'=>'nav-badge-warning','badge_title'=>'Pengingat pembayaran budget yang perlu diperhatikan'),
|
||||
array('key'=>'hr','label'=>'HR & Payroll','icon'=>'bi-people','feature'=>'employees','children'=>array(array('key'=>'organization_master','label'=>'Departemen & Posisi','url'=>'organizationmaster'),array('key'=>'employees','label'=>'Data Karyawan','url'=>'employees'),array('key'=>'generate_schedule','label'=>'Jadwal Kerja','url'=>'generateschedule'),array('key'=>'attendance_monitoring','label'=>'Monitoring Absensi','url'=>'attendancemonitoring'),array('key'=>'attendance_monthly','label'=>'Absensi Bulanan','url'=>'attendancemonthly'),array('key'=>'shift','label'=>'Shift','url'=>'shifts'),array('key'=>'holidays','label'=>'Hari Libur','url'=>'holidays'),array('key'=>'leave_requests','label'=>'Cuti & Izin','url'=>'leaverequests'),array('key'=>'hr_payroll','label'=>'Payroll Control','url'=>'hrpayroll'))),
|
||||
array('key'=>'companies','label'=>'Company & Currency','icon'=>'bi-buildings','url'=>'companies','feature'=>'companies','admin'=>true),
|
||||
array('key'=>'settings','label'=>'Pengaturan','icon'=>'bi-gear','feature'=>'setting','admin'=>true,'children'=>array(array('key'=>'users','label'=>'Pengguna','url'=>'users'),array('key'=>'roles','label'=>'Role & Permission','url'=>'roles')))
|
||||
);$out=array();foreach($items as$item){if(!empty($item['admin'])&&!is_master_admin_user())continue;if(!$this->allowed($item['feature']))continue;if(!empty($item['children'])){$children=array();foreach($item['children']as$c){if(!empty($c['admin'])&&!is_master_admin_user())continue;if(!empty($c['feature'])&&!$this->allowed($c['feature']))continue;$children[]=$c;}$item['children']=$children;if(!$children)continue;}$out[]=$item;}return$out;
|
||||
}
|
||||
private function approvalBadge(){if(!$this->CI->db->table_exists('approval_requests'))return 0;return(int)$this->CI->db->where('status','pending')->count_all_results('approval_requests');}
|
||||
private function salesBadge($name){if($this->salesBadges===null){$c=(int)$this->CI->session->userdata('company_id');$this->salesBadges=array('draft'=>0,'approval'=>0,'delivery'=>0,'receivable'=>0,'overdue'=>0,'return'=>0);if($this->CI->db->table_exists('invoices')){$where=$this->CI->db->field_exists('company_id','invoices')?' AND company_id='.(int)$c:'';$r=$this->CI->db->query("SELECT SUM(workflow_status='draft') draft_count,SUM(workflow_status='submitted') approval_count,SUM(workflow_status='posted' AND sisa_piutang>0) receivable_count,SUM(workflow_status='posted' AND sisa_piutang>0 AND jatuh_tempo<CURDATE()) overdue_count FROM invoices WHERE deleted_at IS NULL".$where)->row();if($r){$this->salesBadges['draft']=(int)$r->draft_count;$this->salesBadges['approval']=(int)$r->approval_count;$this->salesBadges['receivable']=(int)$r->receivable_count;$this->salesBadges['overdue']=(int)$r->overdue_count;}}if($this->CI->db->table_exists('sales_deliveries')){$this->CI->db->where('status','draft');if($this->CI->db->field_exists('company_id','sales_deliveries'))$this->CI->db->where('company_id',$c);$this->salesBadges['delivery']=(int)$this->CI->db->count_all_results('sales_deliveries');}if($this->CI->db->table_exists('sales_returns')){$this->CI->db->where_in('status',array('draft','submitted','approved','received'));if($this->CI->db->field_exists('company_id','sales_returns'))$this->CI->db->where('company_id',$c);$this->salesBadges['return']=(int)$this->CI->db->count_all_results('sales_returns');}}if($name==='all')return$this->salesBadges['draft']+$this->salesBadges['approval']+$this->salesBadges['delivery']+$this->salesBadges['receivable']+$this->salesBadges['return'];return$this->salesBadges[$name]??0;}
|
||||
private function budgetApprovalBadge(){if($this->budgetBadge!==null)return$this->budgetBadge;if(!$this->allowed('budgets'))return$this->budgetBadge=0;$company=(int)$this->CI->session->userdata('company_id');$total=0;if($this->CI->db->table_exists('budgets')&&(is_master_admin_user()||check_permission('budgets','can_approve'))){$this->CI->db->where('status','submitted');if($this->CI->db->field_exists('company_id','budgets'))$this->CI->db->where('company_id',$company);$total+=(int)$this->CI->db->count_all_results('budgets');}if($this->CI->db->table_exists('account_budget_entries')&&$this->CI->db->table_exists('account_budget_reminder_completions')){$period=date('Y-m');$row=$this->CI->db->query("SELECT COUNT(*) total FROM account_budget_entries e WHERE e.company_id=? AND e.is_active=1 AND e.reminder_enabled=1 AND ((e.entry_type='recurring' AND e.period<=?) OR (e.entry_type='override' AND e.period=?)) AND LEAST(e.reminder_day,DAY(LAST_DAY(CURDATE())))<=LEAST(DAY(LAST_DAY(CURDATE())),DAY(CURDATE())+7) AND NOT EXISTS(SELECT 1 FROM account_budget_reminder_completions c WHERE c.company_id=e.company_id AND c.budget_entry_id=e.id AND c.period=?)",array($company,$period,$period,$period))->row();$total+=(int)($row?$row->total:0);}return$this->budgetBadge=$total;}
|
||||
private function pendingItemBadge(){if($this->pendingBadge!==null)return$this->pendingBadge;if(!$this->CI->db->table_exists('items'))return$this->pendingBadge=0;$company=(int)$this->CI->session->userdata('company_id');if($this->CI->db->table_exists('item_barcodes'))return$this->pendingBadge=(int)$this->CI->db->query("SELECT COUNT(DISTINCT i.id) total FROM items i LEFT JOIN item_barcodes ib ON ib.item_id=i.id AND ib.status='pending' WHERE i.company_id=? AND EXISTS(SELECT 1 FROM goods_receipt_lines grl WHERE grl.item_id=i.id) AND(i.status='draft' OR ib.id IS NOT NULL)",array($company))->row()->total;return$this->pendingBadge=(int)$this->CI->db->where(array('status'=>'draft','company_id'=>$company))->count_all_results('items');}
|
||||
private function inventoryDocumentBadge(){if($this->inventoryDocumentBadge!==null)return$this->inventoryDocumentBadge;if(!$this->CI->db->table_exists('stock_documents'))return$this->inventoryDocumentBadge=0;$this->CI->db->where_in('status',array('submitted','approved'));if($this->CI->db->field_exists('company_id','stock_documents'))$this->CI->db->where('company_id',(int)$this->CI->session->userdata('company_id'));return$this->inventoryDocumentBadge=(int)$this->CI->db->count_all_results('stock_documents');}
|
||||
private function technicianActionBadge(){if($this->technicianBadge!==null)return$this->technicianBadge;if(!$this->CI->db->table_exists('technician_custody_allocations'))return$this->technicianBadge=0;$this->CI->db->where('company_id',(int)$this->CI->session->userdata('company_id'))->where_in('condition_status',array('inspection_required','repair_required','damaged','lost'));return$this->technicianBadge=(int)$this->CI->db->count_all_results('technician_custody_allocations');}
|
||||
private function inventoryBadge(){return$this->pendingItemBadge()+$this->inventoryDocumentBadge()+$this->technicianActionBadge();}
|
||||
private function payableBadge(){if($this->payableBadge!==null)return$this->payableBadge;if(!$this->CI->db->table_exists('supplier_invoices'))return$this->payableBadge=0;$this->CI->db->select("COUNT(DISTINCT id) total,SUM(CASE WHEN due_date<=CURDATE() THEN 1 ELSE 0 END) overdue",false)->where('status','partial')->where('balance >',0);if($this->CI->db->field_exists('company_id','supplier_invoices'))$this->CI->db->where('company_id',(int)$this->CI->session->userdata('company_id'));$row=$this->CI->db->get('supplier_invoices')->row();$this->payableBadge=$row?(int)$row->total:0;$this->payableBadgeClass=$row&&(int)$row->overdue>0?'nav-badge-danger':($this->payableBadge>0?'nav-badge-warning':'');return$this->payableBadge;}
|
||||
private function payableBadgeClass(){$this->payableBadge();return$this->payableBadgeClass;}
|
||||
private function payableBadgeTitle(){$this->payableBadge();return$this->payableBadgeClass==='nav-badge-danger'?'Ada tagihan yang sudah jatuh tempo':'Tagihan belum lunas dan belum jatuh tempo';}
|
||||
private function returnBadge(){if($this->returnBadge!==null)return$this->returnBadge;if(!$this->CI->db->table_exists('purchase_returns')||!$this->CI->db->table_exists('supplier_refund_claims'))return$this->returnBadge=0;$company=(int)$this->CI->session->userdata('company_id');$returns=null;$refunds=null;$debit=0;if($this->allowed('purchase_returns')){$whereCompany=$this->CI->db->field_exists('company_id','purchase_returns')?' AND company_id='.(int)$company:'';$returns=$this->CI->db->query("SELECT COUNT(*) total,SUM(CASE WHEN created_at<DATE_SUB(NOW(),INTERVAL 3 DAY) THEN 1 ELSE 0 END) overdue FROM purchase_returns WHERE status IN('draft','submitted','approved','reserved','shipped','waiting_supplier','partially_resolved')".$whereCompany)->row();}if($this->allowed('purchase_refunds')){$refundWhere=$this->CI->db->field_exists('company_id','supplier_refund_claims')?' AND company_id='.(int)$company:'';$refunds=$this->CI->db->query("SELECT COUNT(*) total,SUM(CASE WHEN expected_date IS NOT NULL AND expected_date<CURDATE() THEN 1 ELSE 0 END) overdue FROM supplier_refund_claims WHERE status IN('draft','submitted','approved','waiting_funds','partial','received')".$refundWhere)->row();}if($this->allowed('supplier_debit_notes')&&$this->CI->db->table_exists('supplier_debit_notes')){$this->CI->db->where('status','draft');if($this->CI->db->field_exists('company_id','supplier_debit_notes'))$this->CI->db->where('company_id',$company);$debit=(int)$this->CI->db->count_all_results('supplier_debit_notes');}$this->returnBadge=(int)($returns?$returns->total:0)+(int)($refunds?$refunds->total:0)+$debit;$overdue=(int)($returns?$returns->overdue:0)+(int)($refunds?$refunds->overdue:0);$this->returnBadgeClass=$overdue>0?'nav-badge-danger':($this->returnBadge>0?'nav-badge-warning':'');return$this->returnBadge;}
|
||||
private function returnBadgeClass(){$this->returnBadge();return$this->returnBadgeClass;}
|
||||
private function purchaseBadge(){return($this->allowed('purchases')?$this->payableBadge():0)+$this->returnBadge();}
|
||||
private function purchaseBadgeClass(){return($this->allowed('purchases')&&$this->payableBadgeClass()==='nav-badge-danger')||$this->returnBadgeClass()==='nav-badge-danger'?'nav-badge-danger':($this->purchaseBadge()>0?'nav-badge-warning':'');}
|
||||
public function breadcrumb($active){foreach($this->items()as$i){if($i['key']===$active)return array($i['label']);foreach(isset($i['children'])?$i['children']:array()as$c)if($c['key']===$active)return array($i['label'],$c['label']);}return array(ucwords(str_replace('_',' ',$active)));}
|
||||
public function quickActions(){$defs=array(array('label'=>'Jurnal','icon'=>'bi-journal-plus','url'=>'jurnal','feature'=>'jurnal'),array('label'=>'Terima Pembayaran','icon'=>'bi-cash-coin','url'=>'receivables','feature'=>'invoices'),array('label'=>'Bayar Supplier','icon'=>'bi-wallet2','url'=>'payables','feature'=>'purchases'),array('label'=>'Scan QR','icon'=>'bi-qr-code-scan','url'=>'inventoryprofessional#scanner','feature'=>'items'));$out=array();foreach($defs as$d)if($this->allowed($d['feature']))$out[]=$d;return$out;}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
|
||||
require_once APPPATH . 'exceptions/BusinessException.php';
|
||||
|
||||
class NumberingService
|
||||
{
|
||||
private $CI;
|
||||
private $sequenceTableAvailable;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->CI =& get_instance();
|
||||
$this->CI->config->load('accounting', true);
|
||||
$this->CI->load->library('CompanyContext');
|
||||
$this->sequenceTableAvailable = $this->CI->db->table_exists('document_sequences');
|
||||
}
|
||||
|
||||
public function next($type, $date = null)
|
||||
{
|
||||
$date = $date ?: date('Y-m-d');
|
||||
$timestamp = strtotime($date);
|
||||
if ($timestamp === false) {
|
||||
throw new BusinessException('Tanggal penomoran dokumen tidak valid.');
|
||||
}
|
||||
|
||||
$settings = (array) $this->CI->config->item('document_numbering', 'accounting');
|
||||
if (!isset($settings[$type])) {
|
||||
throw new BusinessException('Jenis penomoran "' . $type . '" belum dikonfigurasi.');
|
||||
}
|
||||
|
||||
$prefix = $settings[$type]['prefix'] . '-' . date('Ymd', $timestamp) . '-';
|
||||
$padding = (int) $settings[$type]['padding'];
|
||||
$period = date('Ymd', $timestamp);
|
||||
|
||||
if ($this->sequenceTableAvailable) {
|
||||
$initialValue = $this->nextLegacyValue($type, $prefix);
|
||||
$number = $this->nextAtomicValue($type, $period, $prefix, $initialValue);
|
||||
} else {
|
||||
$number = $this->nextLegacyValue($type, $prefix);
|
||||
}
|
||||
|
||||
return $prefix . str_pad($number, $padding, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
private function nextAtomicValue($type, $period, $prefix, $initialValue)
|
||||
{
|
||||
$companyId = (int) $this->CI->companycontext->id();
|
||||
$sql = 'INSERT INTO document_sequences (company_id, document_type, period_key, prefix, current_value, updated_at) '
|
||||
. 'VALUES (?, ?, ?, ?, ?, NOW()) '
|
||||
. 'ON DUPLICATE KEY UPDATE prefix = VALUES(prefix), current_value = LAST_INSERT_ID(current_value + 1), updated_at = NOW()';
|
||||
|
||||
if (!$this->CI->db->query($sql, array($companyId, $type, $period, $prefix, $initialValue))) {
|
||||
throw new RuntimeException('Gagal mengambil nomor dokumen berikutnya.');
|
||||
}
|
||||
|
||||
if ($this->CI->db->affected_rows() === 1) {
|
||||
return (int) $initialValue;
|
||||
}
|
||||
|
||||
return (int) $this->CI->db->insert_id();
|
||||
}
|
||||
|
||||
private function nextLegacyValue($type, $prefix)
|
||||
{
|
||||
$source = $type === 'invoice' ? array('invoices', 'no_invoice') : array('journals', 'no_ref');
|
||||
$builder = $this->CI->db->select($source[1]);
|
||||
if ($this->CI->db->field_exists('company_id', $source[0])) $builder->where('company_id', (int)$this->CI->companycontext->id());
|
||||
$row = $builder->like($source[1], $prefix, 'after')
|
||||
->order_by($source[1], 'DESC')
|
||||
->limit(1)
|
||||
->get($source[0])
|
||||
->row();
|
||||
|
||||
if (!$row) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$value = $row->{$source[1]};
|
||||
return ((int) substr($value, strlen($prefix))) + 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
class PayrollService
|
||||
{
|
||||
private $CI;public function __construct(){$this->CI=&get_instance();$this->CI->load->library(array('FormulaService','PostingService','FiscalPeriodService','AccountMappingService'));}
|
||||
private function period($id){$p=$this->CI->db->query('SELECT * FROM k_payroll_periods WHERE id=? FOR UPDATE',array($id))->row();if(!$p)throw new BusinessException('Periode payroll tidak ditemukan.');return$p;}
|
||||
public function calculate($month,$uid){$start=$month.'-01';$end=date('Y-m-t',strtotime($start));$db=$this->CI->db;$db->trans_begin();try{$locked=$db->get_where('attendance_period_locks',array('period'=>$month,'status'=>'locked'))->row();if(!$locked)throw new BusinessException('Absensi bulan '.$month.' harus dikunci sebelum payroll dihitung.');$p=$db->get_where('k_payroll_periods',array('start_date'=>$start,'end_date'=>$end))->row();if($p&&$p->workflow_status!=='draft')throw new BusinessException('Periode payroll bukan draft.');if(!$p){$db->insert('k_payroll_periods',array('period_name'=>date('F Y',strtotime($start)),'start_date'=>$start,'end_date'=>$end,'status'=>'draft','workflow_status'=>'draft','generated_at'=>date('Y-m-d H:i:s')));$pid=$db->insert_id();}else$pid=$p->id;$components=$db->where('is_active',1)->get('payroll_components')->result();foreach($db->where('is_active',1)->get('k_employees')->result()as$e){$att=$db->query("SELECT COUNT(*) work_days,SUM(attendance_status IN('present','late')) present_days,SUM(attendance_status='alpha') unpaid_days,COALESCE(SUM(overtime_hours),0) overtime_hours FROM k_attendances WHERE employee_id=? AND attendance_date BETWEEN ? AND ?",array($e->id,$start,$end))->row();$vars=array('basic_salary'=>(float)$e->basic_salary,'calendar_days'=>(int)date('t',strtotime($start)),'work_days'=>(float)$att->work_days,'present_days'=>(float)$att->present_days,'unpaid_days'=>(float)$att->unpaid_days,'overtime_hours'=>(float)$att->overtime_hours,'overtime_rate'=>(float)(getenv('PAYROLL_OVERTIME_RATE')?:25000),'daily_rate'=>(float)$e->basic_salary/max(1,(int)date('t',strtotime($start))));$pay=$db->get_where('k_payrolls',array('payroll_period_id'=>$pid,'employee_id'=>$e->id))->row();$data=array('payroll_period_id'=>$pid,'employee_id'=>$e->id,'basic_salary'=>$e->basic_salary,'total_work_days'=>$att->work_days,'total_present'=>$att->present_days,'total_alpha'=>$att->unpaid_days,'total_overtime_hours'=>$att->overtime_hours);if($pay){$db->where('id',$pay->id)->update('k_payrolls',$data);$payid=$pay->id;$db->where('payroll_id',$payid)->delete('payroll_calculation_lines');}else{$db->insert('k_payrolls',$data);$payid=$db->insert_id();}$earn=$deduct=$employer=0;foreach($components as$c){$override=$db->where('employee_id',$e->id)->where('component_id',$c->id)->where('is_active',1)->where('effective_from <=',$end)->group_start()->where('effective_to IS NULL',null,false)->or_where('effective_to >=',$start)->group_end()->order_by('effective_from','DESC')->get('employee_payroll_components')->row();$amount=$override&&$override->amount_override!==null?(float)$override->amount_override:($c->fixed_amount!==null?(float)$c->fixed_amount:$this->CI->formulaservice->calculate($c->formula_expression,$vars));if($c->code==='BASIC'&&$c->prorated&&$e->join_date>$start&&$e->join_date<=$end)$amount=round($amount*(date('t',strtotime($start))-date('d',strtotime($e->join_date))+1)/date('t',strtotime($start)),2);if(abs($amount)<.01)continue;$db->insert('payroll_calculation_lines',array('payroll_id'=>$payid,'component_id'=>$c->id,'component_code'=>$c->code,'component_name'=>$c->name,'component_type'=>$c->component_type,'base_amount'=>$e->basic_salary,'amount'=>$amount,'formula_snapshot'=>$c->formula_expression,'expense_account_id'=>$c->expense_account_id,'liability_account_id'=>$c->liability_account_id));if($c->component_type==='earning')$earn+=$amount;elseif($c->component_type==='deduction')$deduct+=$amount;else$employer+=$amount;}$net=$earn-$deduct;$snap=json_encode(array('variables'=>$vars,'gross'=>$earn,'deductions'=>$deduct,'net'=>$net));$db->where('id',$payid)->update('k_payrolls',array('gross_salary'=>$earn,'total_deductions'=>$deduct,'total_salary'=>$net,'calculation_snapshot'=>$snap,'version'=>isset($pay->version)?$pay->version+1:1));}$hash=hash('sha256',json_encode($db->select('p.id,p.total_salary,p.version')->from('k_payrolls p')->where('payroll_period_id',$pid)->order_by('p.id')->get()->result_array()));$db->where('id',$pid)->update('k_payroll_periods',array('calculation_hash'=>$hash));$db->trans_commit();return$pid;}catch(Throwable$e){$db->trans_rollback();throw$e;}}
|
||||
public function workflow($id,$action,$uid){$db=$this->CI->db;$db->trans_begin();try{$p=$this->period($id);$map=array('review'=>array('draft','reviewed'), 'approve'=>array('reviewed','approved'), 'finalize'=>array('approved','final'));if(!isset($map[$action])||$p->workflow_status!==$map[$action][0])throw new BusinessException('Transisi payroll tidak valid.');if($action!=='review'&&(int)($action==='approve'?$p->reviewed_by:$p->approved_by)===(int)$uid)throw new BusinessException('Reviewer/approver berikutnya harus user berbeda.');$up=array('workflow_status'=>$map[$action][1],$action==='review'?'reviewed_by':($action==='approve'?'approved_by':'finalized_by')=>$uid,$action==='review'?'reviewed_at':($action==='approve'?'approved_at':'finalized_at')=>date('Y-m-d H:i:s'));$db->where('id',$id)->update('k_payroll_periods',$up);$db->trans_commit();}catch(Throwable$e){$db->trans_rollback();throw$e;}}
|
||||
public function post($id,$date,$uid){$db=$this->CI->db;$db->trans_begin();try{$p=$this->period($id);if($p->workflow_status!=='final'||$p->journal_id)throw new BusinessException('Payroll belum final atau sudah diposting.');$this->CI->fiscalperiodservice->assertOpen($date);$gross=(float)$db->select('COALESCE(SUM(gross_salary),0)t',false)->get_where('k_payrolls',array('payroll_period_id'=>$id))->row()->t;$ded=(float)$db->select('COALESCE(SUM(total_deductions),0)t',false)->get_where('k_payrolls',array('payroll_period_id'=>$id))->row()->t;$net=$gross-$ded;$lines=array(array('account_id'=>$this->CI->accountmappingservice->get('payroll_expense'),'debit'=>$gross,'kredit'=>0));if($ded>0)$lines[]=array('account_id'=>$this->CI->accountmappingservice->get('payroll_tax_payable'),'debit'=>0,'kredit'=>$ded);$lines[]=array('account_id'=>$this->CI->accountmappingservice->get('payroll_payable'),'debit'=>0,'kredit'=>$net);$jid=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>'PAYROLL-'.str_replace('-','',$p->start_date),'keterangan'=>'Payroll register '.$p->period_name.'; karyawan '.$db->where('payroll_period_id',$id)->count_all_results('k_payrolls').'; bruto Rp '.number_format($gross,2,'.','').' neto Rp '.number_format($net,2,'.',''),'ref_type'=>'payroll','ref_id'=>$id,'created_by'=>$uid),$lines,false,true);$db->where('id',$id)->update('k_payroll_periods',array('workflow_status'=>'posted','journal_id'=>$jid));$db->trans_commit();return$jid;}catch(Throwable$e){$db->trans_rollback();throw$e;}}
|
||||
public function pay($id,$date,$cashAccountId,$uid){$db=$this->CI->db;$db->trans_begin();try{$p=$this->period($id);if($p->workflow_status!=='posted')throw new BusinessException('Payroll belum diposting atau sudah dibayar.');if($db->get_where('payroll_payments',array('payroll_period_id'=>$id))->row())throw new BusinessException('Pembayaran payroll sudah tersedia.');$cash=$db->query('SELECT * FROM cash_accounts WHERE id=? FOR UPDATE',array($cashAccountId))->row();if(!$cash)throw new BusinessException('Rekening pembayaran tidak ditemukan.');$this->CI->fiscalperiodservice->assertOpen($date);$net=(float)$db->select('COALESCE(SUM(total_salary),0)t',false)->get_where('k_payrolls',array('payroll_period_id'=>$id))->row()->t;$no='PRP-'.date('YmdHis');$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$no,'keterangan'=>'Pembayaran payroll '.$p->period_name.' melalui '.$cash->name.'; total neto Rp '.number_format($net,2,'.',''),'ref_type'=>'payroll_payment','ref_id'=>$id,'created_by'=>$uid),array(array('account_id'=>$this->CI->accountmappingservice->get('payroll_payable'),'debit'=>$net,'kredit'=>0),array('account_id'=>$cash->gl_account_id,'debit'=>0,'kredit'=>$net)),false,true);$db->insert('payroll_payments',array('payroll_period_id'=>$id,'payment_no'=>$no,'payment_date'=>$date,'cash_account_id'=>$cashAccountId,'total_amount'=>$net,'status'=>'posted','journal_id'=>$journal,'created_by'=>$uid,'approved_by'=>$uid,'posted_at'=>date('Y-m-d H:i:s')));$paymentId=$db->insert_id();$db->where('id',$id)->update('k_payroll_periods',array('workflow_status'=>'paid','payment_transaction_id'=>$paymentId));$db->trans_commit();return$journal;}catch(Throwable$e){$db->trans_rollback();throw$e;}}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
class PerformanceCacheService
|
||||
{
|
||||
private $CI;public function __construct(){$this->CI=&get_instance();}
|
||||
public function remember($key,$ttl,callable$loader){$row=$this->CI->db->where('cache_key',$key)->where('expires_at >',date('Y-m-d H:i:s'))->get('app_cache')->row();if($row){$value=json_decode($row->payload,true);if(json_last_error()===JSON_ERROR_NONE)return$value;}$value=$loader();$data=array('payload'=>json_encode($value),'expires_at'=>date('Y-m-d H:i:s',time()+max(1,(int)$ttl)),'created_at'=>date('Y-m-d H:i:s'));if($this->CI->db->get_where('app_cache',array('cache_key'=>$key))->row())$this->CI->db->where('cache_key',$key)->update('app_cache',$data);else{$data['cache_key']=$key;$this->CI->db->insert('app_cache',$data);}return$value;}
|
||||
public function forgetPrefix($prefix){$this->CI->db->like('cache_key',$prefix,'after')->delete('app_cache');}
|
||||
public function purgeExpired(){return$this->CI->db->where('expires_at <',date('Y-m-d H:i:s'))->delete('app_cache');}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
|
||||
require_once APPPATH . 'exceptions/BusinessException.php';
|
||||
|
||||
class PostingService
|
||||
{
|
||||
private $CI;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->CI =& get_instance();
|
||||
$this->CI->load->library('JournalService');
|
||||
$this->CI->load->model('JournalModel', 'postingJournalModel');
|
||||
}
|
||||
|
||||
public function post(array $header, array $lines, $manageTransaction = true, $preventDuplicateSource = true)
|
||||
{
|
||||
if ($preventDuplicateSource && !empty($header['ref_type']) && isset($header['ref_id'])) {
|
||||
if ($this->CI->postingJournalModel->sourceExists($header['ref_type'], $header['ref_id'])) {
|
||||
throw new BusinessException('Transaksi sumber ini sudah pernah diposting ke jurnal.');
|
||||
}
|
||||
}
|
||||
|
||||
$header['status'] = 'posted';
|
||||
$header['posted_at'] = isset($header['posted_at']) ? $header['posted_at'] : date('Y-m-d H:i:s');
|
||||
$header['posted_by'] = isset($header['posted_by']) ? $header['posted_by'] : $header['created_by'];
|
||||
return $this->CI->journalservice->create($header, $lines, $manageTransaction);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
require_once APPPATH.'exceptions/BusinessException.php';
|
||||
|
||||
class ProfessionalInventoryService
|
||||
{
|
||||
private $CI;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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;}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
require_once APPPATH.'exceptions/BusinessException.php';
|
||||
class ProfessionalReportService
|
||||
{
|
||||
private $CI;public function __construct(){$this->CI=&get_instance();$this->CI->load->library('CompanyContext');}
|
||||
private function filters(array$f){return array(' j.company_id=? AND j.status IN(\'posted\',\'reversed\') AND j.tanggal BETWEEN ? AND ?',array($this->CI->companycontext->id(),$f['from'],$f['to']));}
|
||||
public function trialBalance(array$f){list($w,$b)=$this->filters($f);return$this->CI->db->query("SELECT a.id account_id,a.kode_akun,a.nama_akun,a.tipe,COALESCE(SUM(d.debit),0) debit,COALESCE(SUM(d.kredit),0) credit,COALESCE(SUM(d.debit-d.kredit),0) balance FROM accounts a LEFT JOIN journal_details d ON d.account_id=a.id LEFT JOIN journals j ON j.id=d.journal_id AND j.company_id=a.company_id WHERE a.company_id=? AND (j.id IS NULL OR ($w)) GROUP BY a.id ORDER BY a.priority ASC,a.kode_akun ASC,a.id ASC",array_merge(array($this->CI->companycontext->id()),$b))->result_array();}
|
||||
public function statement($type,array$f,$priorityOrder=true){list($w,$b)=$this->filters($f);$types=$type==='profit_loss'?array('revenue','expense'):array('asset','liability','equity');$marks=implode(',',array_fill(0,count($types),'?'));$company=$this->CI->companycontext->id();$order=$priorityOrder?'a.priority ASC,a.kode_akun ASC,a.id ASC':'a.kode_akun ASC';$sql="SELECT a.id account_id,a.kode_akun,a.nama_akun,a.tipe,COALESCE(x.amount,0) amount FROM accounts a LEFT JOIN (SELECT d.account_id,SUM(IF(ac.tipe IN('asset','expense'),d.debit-d.kredit,d.kredit-d.debit)) amount FROM journal_details d JOIN journals j ON j.id=d.journal_id JOIN accounts ac ON ac.id=d.account_id WHERE $w GROUP BY d.account_id)x ON x.account_id=a.id WHERE a.company_id=? AND a.tipe IN($marks) ORDER BY $order";return$this->CI->db->query($sql,array_merge($b,array($company),$types))->result_array();}
|
||||
public function ledger(array$f){list($w,$b)=$this->filters($f);$sql="SELECT j.id journal_id,j.tanggal,j.no_ref,j.keterangan,j.ref_type,j.ref_id,a.kode_akun,a.nama_akun,d.debit,d.kredit FROM journal_details d JOIN journals j ON j.id=d.journal_id JOIN accounts a ON a.id=d.account_id WHERE $w";if(!empty($f['account_id'])){$sql.=' AND d.account_id=?';$b[]=(int)$f['account_id'];}$sql.=' ORDER BY j.tanggal,j.id,d.id';return$this->CI->db->query($sql,$b)->result_array();}
|
||||
public function cashFlow(array$f){list($w,$b)=$this->filters($f);return$this->CI->db->query("SELECT CASE WHEN j.ref_type IN('invoice','customer_payment','supplier_invoice','supplier_payment','payroll','payroll_payment') THEN 'operating' WHEN j.ref_type LIKE 'asset_%' THEN 'investing' ELSE 'financing_or_other' END section,SUM(IF(a.sub_tipe='kas',d.debit-d.kredit,0)) amount FROM journal_details d JOIN journals j ON j.id=d.journal_id JOIN accounts a ON a.id=d.account_id WHERE $w GROUP BY section",$b)->result_array();}
|
||||
public function equity(array$f){list($w,$b)=$this->filters($f);return$this->CI->db->query("SELECT a.kode_akun,a.nama_akun,SUM(d.kredit-d.debit) amount FROM journal_details d JOIN journals j ON j.id=d.journal_id JOIN accounts a ON a.id=d.account_id WHERE $w AND a.tipe='equity' GROUP BY a.id ORDER BY a.kode_akun",$b)->result_array();}
|
||||
public function actualBudget(array$f){$this->CI->load->library('BudgetService');$source=$this->CI->budgetservice->accountReport(array('from'=>substr($f['from'],0,7),'to'=>substr($f['to'],0,7),'account_id'=>(int)($f['account_id']??0)));$rows=array();foreach($source as$r)$rows[]=array('kode_akun'=>$r['kode_akun'],'nama_akun'=>$r['nama_akun'],'actual'=>$r['actual'],'budget'=>$r['budget'],'variance'=>$r['actual']-$r['budget']);return$rows;}
|
||||
public function comparative(array$f){$current=$this->statement('profit_loss',$f,false);$pm=array_merge($f,array('from'=>date('Y-m-d',strtotime($f['from'].' -1 month')),'to'=>date('Y-m-d',strtotime($f['to'].' -1 month'))));$py=array_merge($f,array('from'=>date('Y-m-d',strtotime($f['from'].' -1 year')),'to'=>date('Y-m-d',strtotime($f['to'].' -1 year'))));$idx=array();foreach($this->statement('profit_loss',$pm,false)as$r)$idx[$r['account_id']]['previous_month']=$r['amount'];foreach($this->statement('profit_loss',$py,false)as$r)$idx[$r['account_id']]['previous_year']=$r['amount'];foreach($current as&$r){$r['previous_month']=isset($idx[$r['account_id']]['previous_month'])?$idx[$r['account_id']]['previous_month']:0;$r['previous_year']=isset($idx[$r['account_id']]['previous_year'])?$idx[$r['account_id']]['previous_year']:0;$r['variance_month']=$r['amount']-$r['previous_month'];$r['variance_year']=$r['amount']-$r['previous_year'];}return$current;}
|
||||
public function purchaseReturns(array$f){return$this->CI->db->select('r.id record_id,r.return_no,r.return_date,s.supplier_code,s.name supplier,po.po_no,r.problem_category,r.requested_resolution,r.status,r.amount,r.journal_id')->from('purchase_returns r')->join('suppliers s','s.id=r.supplier_id')->join('purchase_orders po','po.id=r.purchase_order_id','left')->where('r.company_id',$this->CI->companycontext->id())->where('r.return_date >=',$f['from'])->where('r.return_date <=',$f['to'])->order_by('r.return_date')->order_by('r.id')->get()->result_array();}
|
||||
public function supplierRefunds(array$f,$outstanding=false){$this->CI->db->select('r.id record_id,r.refund_no,r.claim_date,r.expected_date,s.supplier_code,s.name supplier,po.po_no,si.internal_no invoice_supplier,r.source_type,r.status,r.claim_amount,r.received_amount,r.balance,r.entitlement_journal_id journal_id')->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')->where('r.company_id',$this->CI->companycontext->id())->where('r.claim_date >=',$f['from'])->where('r.claim_date <=',$f['to']);if($outstanding)$this->CI->db->where('r.balance >',0)->where_not_in('r.status',array('draft','rejected','cancelled','reversed','reconciled'));return$this->CI->db->order_by('r.claim_date')->order_by('r.id')->get()->result_array();}
|
||||
public function supplierDebitNotes(array$f){return$this->CI->db->select('d.id record_id,d.debit_note_no,d.note_date,s.supplier_code,s.name supplier,po.po_no,si.internal_no invoice_supplier,d.correction_type,d.status,d.base_amount,d.tax_amount,d.amount,d.journal_id')->from('supplier_debit_notes d')->join('suppliers s','s.id=d.supplier_id')->join('purchase_orders po','po.id=d.purchase_order_id','left')->join('supplier_invoices si','si.id=d.supplier_invoice_id','left')->where('d.company_id',$this->CI->companycontext->id())->where('d.note_date >=',$f['from'])->where('d.note_date <=',$f['to'])->order_by('d.note_date')->order_by('d.id')->get()->result_array();}
|
||||
public function salesReport($code,array$f){$c=$this->CI->companycontext->id();if($code==='sales_summary')return$this->CI->db->select('i.id record_id,i.no_invoice,i.tanggal,c.nama customer,i.invoice_type,i.recognition_policy,i.status,i.total,i.total_bayar,i.sisa_piutang')->from('invoices i')->join('customers c','c.id=i.customer_id')->where(array('i.company_id'=>$c,'i.workflow_status'=>'posted'))->where('i.tanggal >=',$f['from'])->where('i.tanggal <=',$f['to'])->order_by('i.tanggal')->get()->result_array();if($code==='sales_by_customer')return$this->CI->db->select('c.id customer_id,c.nama customer,COUNT(i.id) invoice_count,SUM(i.total) sales,SUM(i.total_bayar) paid,SUM(i.sisa_piutang) balance',false)->from('invoices i')->join('customers c','c.id=i.customer_id')->where(array('i.company_id'=>$c,'i.workflow_status'=>'posted'))->where('i.tanggal >=',$f['from'])->where('i.tanggal <=',$f['to'])->group_by('c.id')->order_by('sales','DESC')->get()->result_array();if($code==='sales_by_item')return$this->CI->db->select('d.items_id item_id,it.kode_detail AS kode_barang,it.nama_barang,SUM(d.qty) qty,SUM(d.net_amount) net_sales,SUM(d.tax_amount) tax,SUM(d.subtotal) total',false)->from('invoice_details d')->join('invoices i','i.id=d.invoice_id')->join('items it','it.id=d.items_id')->where(array('i.company_id'=>$c,'i.workflow_status'=>'posted'))->where('i.tanggal >=',$f['from'])->where('i.tanggal <=',$f['to'])->group_by('d.items_id')->order_by('net_sales','DESC')->get()->result_array();if($code==='sales_by_barcode')return$this->CI->db->select('i.id record_id,i.no_invoice,i.tanggal,it.kode_detail AS kode_barang,it.nama_barang,b.barcode,b.serial_number,lb.qty,sd.delivery_no')->from('invoice_line_barcodes lb')->join('invoices i','i.id=lb.invoice_id')->join('item_barcodes b','b.id=lb.barcode_id')->join('items it','it.id=b.item_id')->join('sales_deliveries sd','sd.id=lb.sales_delivery_id','left')->where('lb.company_id',$c)->where('i.tanggal >=',$f['from'])->where('i.tanggal <=',$f['to'])->order_by('i.tanggal')->get()->result_array();if($code==='running_invoices')return$this->CI->db->select('i.id record_id,i.no_invoice,c.nama customer,i.period_start,i.period_end,i.delivery_status,i.total,i.workflow_status')->from('invoices i')->join('customers c','c.id=i.customer_id')->where(array('i.company_id'=>$c,'i.invoice_type'=>'running','i.workflow_status'=>'draft'))->order_by('i.period_end')->get()->result_array();if($code==='sales_returns')return$this->CI->db->select('r.id record_id,r.return_no,r.return_date,c.nama customer,i.no_invoice,d.delivery_no,r.resolution,r.status,SUM(l.net_amount+l.tax_amount) amount',false)->from('sales_returns r')->join('customers c','c.id=r.customer_id')->join('invoices i','i.id=r.invoice_id','left')->join('sales_deliveries d','d.id=r.sales_delivery_id','left')->join('sales_return_lines l','l.sales_return_id=r.id','left')->where('r.company_id',$c)->where('r.return_date >=',$f['from'])->where('r.return_date <=',$f['to'])->group_by('r.id')->order_by('r.return_date')->get()->result_array();if($code==='revenue_recognition')return$this->CI->db->select('i.id record_id,i.no_invoice,c.nama customer,i.recognition_policy,i.subtotal_before_tax invoice_net,i.recognized_revenue,i.subtotal_before_tax-i.recognized_revenue deferred_revenue,i.recognized_cogs')->from('invoices i')->join('customers c','c.id=i.customer_id')->where(array('i.company_id'=>$c,'i.workflow_status'=>'posted'))->where('i.tanggal >=',$f['from'])->where('i.tanggal <=',$f['to'])->order_by('i.tanggal')->get()->result_array();if($code==='unbilled_deliveries')return$this->CI->db->select('d.id record_id,d.delivery_no,d.delivery_date,c.nama customer,d.status')->from('sales_deliveries d')->join('customers c','c.id=d.customer_id')->where('d.company_id',$c)->where('d.invoice_id IS NULL',null,false)->where('d.delivery_date >=',$f['from'])->where('d.delivery_date <=',$f['to'])->get()->result_array();if($code==='receivable_reconciliation'){$ar=$this->CI->load->library('AccountMappingService')?:null;$account=$this->CI->accountmappingservice->get('accounts_receivable');$sub=(float)$this->CI->db->select('COALESCE(SUM(sisa_piutang),0) v',false)->where(array('company_id'=>$c,'workflow_status'=>'posted'))->get('invoices')->row()->v;$gl=(float)$this->CI->db->select('COALESCE(SUM(d.debit-d.kredit),0) v',false)->from('journal_details d')->join('journals j','j.id=d.journal_id')->where(array('j.company_id'=>$c,'j.status'=>'posted','d.account_id'=>$account))->get()->row()->v;return array(array('subledger_piutang'=>$sub,'buku_besar_piutang'=>$gl,'selisih'=>$sub-$gl,'status'=>abs($sub-$gl)<.01?'Balance':'Perlu direkonsiliasi'));}throw new BusinessException('Jenis laporan penjualan tidak valid.');}
|
||||
public function run($code,array$f){if($code==='trial_balance')return$this->trialBalance($f);if(in_array($code,array('profit_loss','balance_sheet'),true))return$this->statement($code,$f);if(in_array($code,array('general_ledger','journal_register','account_detail'),true))return$this->ledger($f);if($code==='cash_flow')return$this->cashFlow($f);if($code==='changes_equity')return$this->equity($f);if($code==='actual_budget')return$this->actualBudget($f);if($code==='comparative')return$this->comparative($f);if($code==='purchase_returns')return$this->purchaseReturns($f);if($code==='supplier_refunds')return$this->supplierRefunds($f);if($code==='outstanding_supplier_refunds')return$this->supplierRefunds($f,true);if($code==='supplier_debit_notes')return$this->supplierDebitNotes($f);if(in_array($code,array('sales_summary','sales_by_customer','sales_by_item','sales_by_barcode','running_invoices','sales_returns','revenue_recognition','unbilled_deliveries','receivable_reconciliation'),true))return$this->salesReport($code,$f);throw new BusinessException('Jenis laporan tidak valid.');}
|
||||
}
|
||||
@@ -0,0 +1,663 @@
|
||||
<?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')));}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
require_once APPPATH.'exceptions/BusinessException.php';
|
||||
class ReceivableAdjustmentsService
|
||||
{
|
||||
private $CI;
|
||||
public function __construct(){
|
||||
$this->CI=&get_instance();
|
||||
$this->CI->load->library(array('TransactionService','PostingService','NumberingService','AccountMappingService','FiscalPeriodService','AuditService'));
|
||||
}
|
||||
public function creditNote($invoiceId,$date,$amount,$reason,$userId){
|
||||
return $this->CI->transactionservice->run(function()use($invoiceId,$date,$amount,$reason,$userId){
|
||||
$invoice=$this->CI->db->query('SELECT * FROM invoices WHERE id=? FOR UPDATE',array((int)$invoiceId))->row();
|
||||
$amount=round((float)$amount,2);
|
||||
if(!$invoice||$invoice->workflow_status!=='posted'||$amount<=0||$amount>(float)$invoice->sisa_piutang)throw new BusinessException('Nilai credit note tidak valid.');
|
||||
if(trim($reason)==='')throw new BusinessException('Alasan credit note wajib diisi.');
|
||||
$this->CI->fiscalperiodservice->assertOpen($date);
|
||||
$details=$this->CI->db->where('invoice_id',$invoice->id)->get('invoice_details')->result();
|
||||
$entries=array();$debit=0;$base=max(.01,(float)$invoice->total);
|
||||
foreach($details as $line){$part=$amount*((float)$line->subtotal/$base);$tax=$part*((float)$line->tax_amount/max(.01,(float)$line->subtotal));$net=$part-$tax;$entries[]=array('account_id'=>$line->account_id,'debit'=>$net,'kredit'=>0);$debit+=$net;if($tax>0){$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('sales_tax_payable'),'debit'=>$tax,'kredit'=>0);$debit+=$tax;}}
|
||||
$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('accounts_receivable'),'debit'=>0,'kredit'=>$debit);
|
||||
$no=$this->CI->numberingservice->next('credit_note',$date);
|
||||
$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$no,'keterangan'=>'Credit Note '.$invoice->no_invoice,'ref_type'=>'credit_note','ref_id'=>$invoice->id,'created_by'=>$userId),$entries,false,true);
|
||||
$this->CI->db->insert('credit_notes',array('credit_note_no'=>$no,'invoice_id'=>$invoice->id,'customer_id'=>$invoice->customer_id,'credit_date'=>$date,'amount'=>$amount,'reason'=>$reason,'status'=>'posted','journal_id'=>$journal,'created_by'=>$userId,'created_at'=>date('Y-m-d H:i:s')));
|
||||
$id=$this->CI->db->insert_id();$balance=max(0,(float)$invoice->sisa_piutang-$amount);
|
||||
$this->CI->db->where('id',$invoice->id)->update('invoices',array('sisa_piutang'=>$balance,'status'=>$balance<=0?'paid':'partial'));
|
||||
$this->CI->auditservice->record('receivable','credit_note',$id,'post',null,array('invoice_id'=>$invoice->id,'amount'=>$amount),$userId);return$id;
|
||||
});
|
||||
}
|
||||
public function refund($advanceId,$date,$amount,$bankAccountId,$reason,$userId){
|
||||
return $this->CI->transactionservice->run(function()use($advanceId,$date,$amount,$bankAccountId,$reason,$userId){
|
||||
$advance=$this->CI->db->query('SELECT * FROM customer_advances WHERE id=? FOR UPDATE',array((int)$advanceId))->row();$amount=round((float)$amount,2);
|
||||
if(!$advance||$amount<=0||$amount>(float)$advance->available_amount)throw new BusinessException('Nilai refund tidak valid.');if(trim($reason)==='')throw new BusinessException('Alasan refund wajib diisi.');
|
||||
$this->CI->fiscalperiodservice->assertOpen($date);$no=$this->CI->numberingservice->next('refund',$date);
|
||||
$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$no,'keterangan'=>'Refund '.$advance->advance_no,'ref_type'=>'customer_refund','ref_id'=>$advance->id,'created_by'=>$userId),array(array('account_id'=>$this->CI->accountmappingservice->get('customer_advances'),'debit'=>$amount,'kredit'=>0),array('account_id'=>(int)$bankAccountId,'debit'=>0,'kredit'=>$amount)),false,true);
|
||||
$this->CI->db->insert('customer_refunds',array('refund_no'=>$no,'customer_id'=>$advance->customer_id,'advance_id'=>$advance->id,'refund_date'=>$date,'amount'=>$amount,'bank_account_id'=>$bankAccountId,'reason'=>$reason,'status'=>'posted','journal_id'=>$journal,'created_by'=>$userId,'created_at'=>date('Y-m-d H:i:s')));$id=$this->CI->db->insert_id();$available=(float)$advance->available_amount-$amount;$this->CI->db->where('id',$advance->id)->update('customer_advances',array('available_amount'=>$available,'status'=>$available<=0?'refunded':'partially_used'));$this->CI->auditservice->record('receivable','refund',$id,'post',null,array('advance_id'=>$advance->id,'amount'=>$amount),$userId);return$id;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
require_once APPPATH.'exceptions/BusinessException.php';
|
||||
class ReceivableService
|
||||
{
|
||||
private $CI;
|
||||
public function __construct(){
|
||||
$this->CI=&get_instance();$this->CI->load->library(array('TransactionService','ApprovalService','AuditService','PostingService','NumberingService','AccountMappingService','FiscalPeriodService','CompanyContext'));$this->CI->load->model('InvoiceModel','receivableInvoiceModel');$this->CI->load->model('InventoryModel','receivableInventoryModel');
|
||||
}
|
||||
public function submitInvoice($id,$userId){return $this->CI->transactionservice->run(function()use($id,$userId){$i=$this->invoiceLock($id,'draft');$this->validateInvoice($i);$this->assertCreditLimit($i);$amount=$this->invoiceTotal($id);$r=$this->CI->approvalservice->create('invoice','invoice',$id,$amount,$userId,array('invoice'=>$i,'lines'=>$this->lines($id)));$this->CI->db->where('id',$id)->update('invoices',array('workflow_status'=>'submitted','total'=>$amount,'sisa_piutang'=>$amount));$after=$this->CI->receivableInvoiceModel->find($id);$this->CI->auditservice->record('invoice','invoice',$id,'submit',$i,$after,$userId);return $r;});}
|
||||
public function approveInvoice($id,$userId,$roleId){return $this->CI->transactionservice->run(function()use($id,$userId,$roleId){$i=$this->invoiceLock($id,'submitted');$r=$this->CI->approvalservice->pendingForEntity('invoice',$id);if(!$r)throw new BusinessException('Approval invoice tidak ditemukan.');$a=$this->CI->approvalservice->act($r->id,'approved',$userId,$roleId);if($a->status==='approved')$this->CI->db->where('id',$id)->update('invoices',array('workflow_status'=>'approved'));$after=$this->CI->receivableInvoiceModel->find($id);$this->CI->auditservice->record('invoice','invoice',$id,'approve',$i,$after,$userId);return $after;});}
|
||||
public function postInvoice($id,$userId){return $this->CI->transactionservice->run(function()use($id,$userId){$i=$this->invoiceLock($id,'approved');$this->CI->fiscalperiodservice->assertOpen($i->tanggal);if($i->journal_id)throw new BusinessException('Invoice sudah mempunyai jurnal.');$lines=$this->lines($id);$ar=$this->CI->accountmappingservice->get('accounts_receivable');$taxAccount=$this->CI->accountmappingservice->get('sales_tax_payable');$entries=array(array('account_id'=>$ar,'debit'=>$i->total,'kredit'=>0));$credits=array();$hpp=0;foreach($lines as $l){$net=(float)$l->subtotal-(float)$l->tax_amount;$credits[$l->account_id]=($credits[$l->account_id]??0)+$net;if((float)$l->tax_amount>0)$credits[$taxAccount]=($credits[$taxAccount]??0)+(float)$l->tax_amount;$hpp+=(float)$l->total_hpp_barang;}foreach($credits as $account=>$amount)$entries[]=array('account_id'=>$account,'debit'=>0,'kredit'=>$amount);if($hpp>0){$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('cost_of_goods_sold'),'debit'=>$hpp,'kredit'=>0);$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('inventory'),'debit'=>0,'kredit'=>$hpp);}$journal=$this->CI->postingservice->post(array('tanggal'=>$i->tanggal,'no_ref'=>$this->CI->numberingservice->next('invoice_journal',$i->tanggal),'keterangan'=>'Posting Invoice '.$i->no_invoice,'ref_type'=>'invoice','ref_id'=>$id,'created_by'=>$userId),$entries,false,true);
|
||||
foreach($lines as $line)$this->postStock($i,$line);
|
||||
$this->CI->db->where('id',$id)->update('invoices',array('workflow_status'=>'posted','posted'=>1,'posted_at'=>date('Y-m-d H:i:s'),'posted_by'=>$userId,'journal_id'=>$journal,'status'=>'unpaid','sisa_piutang'=>$i->total));$this->CI->load->library('TaxService');$this->CI->taxservice->invoice($id,$journal);$after=$this->CI->receivableInvoiceModel->find($id);$this->CI->auditservice->record('invoice','invoice',$id,'post',$i,$after,$userId);return $after;});}
|
||||
public function recordPayment($customerId,$date,$cashAccountId,$amount,array $allocations,$allowAdvance,$userId,$reference='',$notes=''){return $this->CI->transactionservice->run(function()use($customerId,$date,$cashAccountId,$amount,$allocations,$allowAdvance,$userId,$reference,$notes){$amount=round((float)$amount,2);if($amount<=0)throw new BusinessException('Nilai pembayaran harus lebih dari nol.');$this->CI->fiscalperiodservice->assertOpen($date);$allocated=0;$locked=array();foreach($allocations as $invoiceId=>$value){$value=round((float)$value,2);if($value<=0)continue;$i=$this->CI->db->query('SELECT * FROM invoices WHERE id=? FOR UPDATE',array((int)$invoiceId))->row();if(!$i||$i->workflow_status!=='posted'||(int)$i->customer_id!==(int)$customerId)throw new BusinessException('Invoice alokasi tidak valid.');if($value>(float)$i->sisa_piutang+0.001)throw new BusinessException('Alokasi melebihi saldo invoice '.$i->no_invoice);$allocated+=$value;$locked[]=array($i,$value);}
|
||||
if($allocated>$amount+0.001)throw new BusinessException('Total alokasi melebihi pembayaran.');$advance=round($amount-$allocated,2);if($advance>0&&!$allowAdvance)throw new BusinessException('Pembayaran melebihi alokasi. Aktifkan mekanisme uang muka pelanggan.');$paymentNo=$this->CI->numberingservice->next('payment',$date);$entries=array(array('account_id'=>(int)$cashAccountId,'debit'=>$amount,'kredit'=>0));if($allocated>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('accounts_receivable'),'debit'=>0,'kredit'=>$allocated);if($advance>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('customer_advances'),'debit'=>0,'kredit'=>$advance);$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$paymentNo,'keterangan'=>'Penerimaan pelanggan #'.$customerId,'ref_type'=>'customer_payment','created_by'=>$userId),$entries,false,true);$this->CI->db->insert('payments',array('invoice_id'=>count($locked)===1?$locked[0][0]->id:null,'tanggal'=>$date,'jumlah'=>$amount,'journal_id'=>$journal,'metode'=>'manual','payment_no'=>$paymentNo,'customer_id'=>$customerId,'status'=>'posted','reference_no'=>$reference,'notes'=>$notes,'created_by'=>$userId));$pid=$this->CI->db->insert_id();foreach($locked as $row){list($i,$value)=$row;$this->CI->db->insert('payment_allocations',array('payment_id'=>$pid,'invoice_id'=>$i->id,'amount'=>$value,'created_at'=>date('Y-m-d H:i:s')));$paid=(float)$i->total_bayar+$value;$balance=max(0,(float)$i->total-$paid);$this->CI->db->where('id',$i->id)->update('invoices',array('total_bayar'=>$paid,'sisa_piutang'=>$balance,'status'=>$balance<=0?'paid':'partial'));}if($advance>0)$this->CI->db->insert('customer_advances',array('advance_no'=>'ADV-'.$paymentNo,'customer_id'=>$customerId,'payment_id'=>$pid,'original_amount'=>$advance,'available_amount'=>$advance,'status'=>'available','journal_id'=>$journal,'created_at'=>date('Y-m-d H:i:s')));$this->CI->auditservice->record('receivable','payment',$pid,'post',null,array('payment_no'=>$paymentNo,'amount'=>$amount,'allocated'=>$allocated,'advance'=>$advance),$userId);return array('payment_id'=>$pid,'payment_no'=>$paymentNo,'advance'=>$advance);});}
|
||||
public function reverseInvoice($id,$date,$reason,$userId){return $this->CI->transactionservice->run(function()use($id,$date,$reason,$userId){$i=$this->invoiceLock($id,'posted');if(trim($reason)==='')throw new BusinessException('Alasan reversal wajib diisi.');if((float)$i->total_bayar>0)throw new BusinessException('Invoice yang telah dibayar harus diselesaikan dengan credit note/refund.');$this->CI->fiscalperiodservice->assertOpen($date);$entries=array();foreach($this->CI->db->where('journal_id',$i->journal_id)->get('journal_details')->result() as $l)$entries[]=array('account_id'=>$l->account_id,'debit'=>$l->kredit,'kredit'=>$l->debit);if(!$entries)throw new BusinessException('Jurnal invoice tidak ditemukan.');$rid=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$this->CI->numberingservice->next('reversal',$date),'keterangan'=>'Reversal invoice '.$i->no_invoice.': '.$reason,'ref_type'=>'invoice_reversal','ref_id'=>$id,'reversal_of'=>$i->journal_id,'reversal_reason'=>$reason,'created_by'=>$userId),$entries,false,true);foreach($this->lines($id)as$line)$this->reverseStock($i,$line,$date,$reason);$this->CI->db->where('id',$id)->update('invoices',array('workflow_status'=>'reversed','status'=>'cancelled','reversal_journal_id'=>$rid,'sisa_piutang'=>0));$this->CI->auditservice->record('invoice','invoice',$id,'reverse',$i,$this->CI->receivableInvoiceModel->find($id),$userId);return $rid;});}
|
||||
public function postWriteoff($invoiceId,$date,$amount,$reason,$userId){return $this->CI->transactionservice->run(function()use($invoiceId,$date,$amount,$reason,$userId){$i=$this->CI->db->query('SELECT * FROM invoices WHERE id=? FOR UPDATE',array((int)$invoiceId))->row();$amount=round((float)$amount,2);if(!$i||$i->workflow_status!=='posted'||$amount<=0||$amount>(float)$i->sisa_piutang)throw new BusinessException('Nilai write-off tidak valid.');if(trim($reason)==='')throw new BusinessException('Alasan write-off wajib diisi.');$this->CI->fiscalperiodservice->assertOpen($date);$no=$this->CI->numberingservice->next('writeoff',$date);$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$no,'keterangan'=>'Write-off '.$i->no_invoice,'ref_type'=>'receivable_writeoff','ref_id'=>$invoiceId,'created_by'=>$userId),array(array('account_id'=>$this->CI->accountmappingservice->get('bad_debt_expense'),'debit'=>$amount,'kredit'=>0),array('account_id'=>$this->CI->accountmappingservice->get('accounts_receivable'),'debit'=>0,'kredit'=>$amount)),false,true);$this->CI->db->insert('receivable_writeoffs',array('writeoff_no'=>$no,'invoice_id'=>$invoiceId,'writeoff_date'=>$date,'amount'=>$amount,'reason'=>$reason,'status'=>'posted','journal_id'=>$journal,'created_by'=>$userId,'created_at'=>date('Y-m-d H:i:s')));$wid=$this->CI->db->insert_id();$balance=max(0,(float)$i->sisa_piutang-$amount);$this->CI->db->where('id',$i->id)->update('invoices',array('sisa_piutang'=>$balance,'status'=>$balance<=0?'paid':'partial'));$this->CI->auditservice->record('receivable','writeoff',$wid,'post',null,array('invoice_id'=>$invoiceId,'amount'=>$amount),$userId);return $wid;});}
|
||||
public function aging($asOf=null){$asOf=$asOf?:date('Y-m-d');$company=$this->CI->companycontext->id();return $this->CI->db->query("SELECT i.customer_id,c.nama,SUM(CASE WHEN i.jatuh_tempo>=? THEN i.sisa_piutang ELSE 0 END) current_amount,SUM(CASE WHEN DATEDIFF(?,i.jatuh_tempo) BETWEEN 1 AND 30 THEN i.sisa_piutang ELSE 0 END) days_1_30,SUM(CASE WHEN DATEDIFF(?,i.jatuh_tempo) BETWEEN 31 AND 60 THEN i.sisa_piutang ELSE 0 END) days_31_60,SUM(CASE WHEN DATEDIFF(?,i.jatuh_tempo) BETWEEN 61 AND 90 THEN i.sisa_piutang ELSE 0 END) days_61_90,SUM(CASE WHEN DATEDIFF(?,i.jatuh_tempo)>90 THEN i.sisa_piutang ELSE 0 END) days_over_90,SUM(i.sisa_piutang) total FROM invoices i JOIN customers c ON c.id=i.customer_id WHERE i.company_id=? AND i.workflow_status='posted' AND i.sisa_piutang>0 AND i.tanggal<=? GROUP BY i.customer_id,c.nama ORDER BY c.nama",array($asOf,$asOf,$asOf,$asOf,$asOf,$company,$asOf))->result();}
|
||||
public function statement($customerId,$from,$to){$company=$this->CI->companycontext->id();$customer=$this->CI->db->get_where('customers',array('id'=>$customerId,'company_id'=>$company))->row();if(!$customer)throw new BusinessException('Customer tidak ditemukan pada perusahaan aktif.');$invoices=$this->CI->db->where(array('company_id'=>$company,'customer_id'=>$customerId))->where('tanggal >=',$from)->where('tanggal <=',$to)->where('workflow_status','posted')->order_by('tanggal')->get('invoices')->result();$payments=$this->CI->db->where(array('company_id'=>$company,'customer_id'=>$customerId))->where('tanggal >=',$from)->where('tanggal <=',$to)->where('status','posted')->order_by('tanggal')->get('payments')->result();return array('customer'=>$customer,'invoices'=>$invoices,'payments'=>$payments);}
|
||||
private function postStock($invoice,$line)
|
||||
{
|
||||
if(!$line->items_id||$line->stock_posted_at)return;
|
||||
$item=$this->CI->db->select('i.company_id,k.tracking_type')->from('items i')->join('kode_barang k','k.id=i.kode_id','left')->where('i.id',(int)$line->items_id)->get()->row();if(!$item)throw new BusinessException('Master barang invoice tidak ditemukan.');if(isset($invoice->company_id)&&(int)$invoice->company_id>0&&(int)$item->company_id!==(int)$invoice->company_id)throw new BusinessException('Barang invoice berasal dari perusahaan lain.');
|
||||
$key='SALE-INVOICE-LINE-'.(int)$line->id;
|
||||
$ledger=$this->CI->db->get_where('inventory_ledger',array('idempotency_key'=>$key))->row();
|
||||
if($ledger){$this->CI->receivableInventoryModel->synchronizeStock($line->items_id);$this->CI->db->where('id',$line->id)->update('invoice_details',array('stock_posted_at'=>$ledger->created_at));return;}
|
||||
$available=(float)$this->CI->db->query("SELECT COALESCE(SUM(IF(direction='in',qty,-qty)),0) qty FROM inventory_ledger WHERE item_id=? AND warehouse_id=? FOR UPDATE",array((int)$line->items_id,(int)$line->warehouse_id))->row()->qty;$reserved=(float)$this->CI->db->select('COALESCE(SUM(qty-released_qty),0) qty',false)->where(array('item_id'=>(int)$line->items_id,'warehouse_id'=>(int)$line->warehouse_id))->where_in('status',array('active','partially_released'))->get('stock_reservations')->row()->qty;$available-=$reserved;
|
||||
if($available<(float)$line->qty-.0001)throw new BusinessException('Stok gudang tidak cukup untuk '.$line->nama_item.'. Tersedia '.number_format($available,0,',','.').'.');
|
||||
$hasBarcode=(int)$this->CI->db->where(array('item_id'=>(int)$line->items_id,'warehouse_id'=>(int)$line->warehouse_id))->where('qty_sisa >',0)->count_all_results('item_barcodes');if($hasBarcode&&!$line->barcode_id)throw new BusinessException('Barcode/SN wajib dipilih untuk '.$line->nama_item.' agar stok fisik dapat ditelusuri.');
|
||||
if($line->barcode_id){$barcode=$this->CI->db->query('SELECT * FROM item_barcodes WHERE id=? AND item_id=? AND warehouse_id=? FOR UPDATE',array((int)$line->barcode_id,(int)$line->items_id,(int)$line->warehouse_id))->row();$barcodeAvailable=$barcode?(float)$barcode->qty_sisa-(float)$barcode->reserved_qty-(float)($barcode->return_reserved_qty??0):0;if(!$barcode||$barcode->status!=='available'||$barcodeAvailable<(float)$line->qty-.0001)throw new BusinessException('Barcode '.$line->nama_item.' tidak tersedia, berada di lokasi lain, atau sedang direservasi.');$remaining=(float)$barcode->qty_sisa-(float)$line->qty;$this->CI->db->where(array('id'=>$barcode->id,'version'=>$barcode->version))->update('item_barcodes',array('qty_sisa'=>$remaining,'status'=>$remaining<=.0001?'sold_out':'available','version'=>(int)$barcode->version+1));if($this->CI->db->affected_rows()!==1)throw new BusinessException('Barcode sedang diproses transaksi lain. Silakan ulangi.');}
|
||||
$cost=(float)$line->qty>0?(float)$line->total_hpp_barang/(float)$line->qty:0;$description='[AUTO] Penjualan '.$invoice->no_invoice.' | '.$line->nama_item.' | Qty '.number_format((float)$line->qty,0,',','.');
|
||||
$this->CI->db->insert('inventory_ledger',array('item_id'=>$line->items_id,'warehouse_id'=>$line->warehouse_id,'barcode_id'=>$line->barcode_id,'movement_date'=>$invoice->tanggal,'direction'=>'out','qty'=>$line->qty,'unit_cost'=>$cost,'value'=>$cost*(float)$line->qty,'document_type'=>'invoice_details','document_id'=>$invoice->id,'document_line_id'=>$line->id,'idempotency_key'=>$key,'created_at'=>date('Y-m-d H:i:s')));
|
||||
$this->consumeCostLayers($line->items_id,$line->warehouse_id,$line->qty);
|
||||
$this->CI->db->insert('stock_logs',array('item_id'=>$line->items_id,'barcode_id'=>$line->barcode_id,'qty'=>$line->qty,'tipe'=>'keluar','unit_cost'=>$cost,'keterangan'=>$description,'warehouse_id'=>$line->warehouse_id,'ref_type'=>'invoice_details','ref_id'=>$line->id,'idempotency_key'=>$key));$stockLog=$this->CI->db->insert_id();
|
||||
$this->CI->db->insert('item_movements',array('item_id'=>$line->items_id,'barcode_id'=>$line->barcode_id,'qty'=>$line->qty,'from_type'=>'warehouse','from_id'=>$line->warehouse_id,'to_type'=>'invoice','to_id'=>$invoice->id,'movement_type'=>'sold_out','reference_type'=>'invoice_details','reference_id'=>$line->id,'notes'=>$description,'created_at'=>date('Y-m-d H:i:s')));
|
||||
$this->CI->receivableInventoryModel->synchronizeStock($line->items_id);
|
||||
$this->CI->db->where('id',$line->id)->update('invoice_details',array('stock_posted_at'=>date('Y-m-d H:i:s'),'stock_log_id'=>$stockLog));
|
||||
}
|
||||
private function consumeCostLayers($itemId,$warehouseId,$qty)
|
||||
{
|
||||
$remaining=(float)$qty;$layers=$this->CI->db->query("SELECT * FROM inventory_cost_layers WHERE item_id=? AND warehouse_id=? AND status='open' AND remaining_qty>0 ORDER BY received_date,id FOR UPDATE",array((int)$itemId,(int)$warehouseId))->result();
|
||||
foreach($layers as$layer){if($remaining<=.0001)break;$used=min($remaining,(float)$layer->remaining_qty);$left=(float)$layer->remaining_qty-$used;$this->CI->db->where('id',$layer->id)->update('inventory_cost_layers',array('remaining_qty'=>$left,'status'=>$left<=.0001?'closed':'open'));$remaining-=$used;}
|
||||
}
|
||||
private function reverseStock($invoice,$line,$date,$reason)
|
||||
{
|
||||
if(!$line->items_id||!$line->stock_posted_at)return;$key='SALE-INVOICE-REVERSAL-'.(int)$line->id;if($this->CI->db->get_where('inventory_ledger',array('idempotency_key'=>$key))->row())return;
|
||||
$original=$this->CI->db->get_where('inventory_ledger',array('idempotency_key'=>'SALE-INVOICE-LINE-'.(int)$line->id))->row();if(!$original)throw new BusinessException('Ledger stok penjualan untuk '.$line->nama_item.' tidak ditemukan; reversal dihentikan agar histori tidak rusak.');
|
||||
$description='[AUTO] Reversal stok invoice '.$invoice->no_invoice.' | '.$line->nama_item.' | Alasan '.$reason;$this->CI->db->insert('inventory_ledger',array('item_id'=>$line->items_id,'warehouse_id'=>$line->warehouse_id,'barcode_id'=>$line->barcode_id,'movement_date'=>$date,'direction'=>'in','qty'=>$line->qty,'unit_cost'=>$original->unit_cost,'value'=>$original->value,'document_type'=>'invoice_reversal','document_id'=>$invoice->id,'document_line_id'=>$line->id,'idempotency_key'=>$key,'created_at'=>date('Y-m-d H:i:s')));$ledgerId=$this->CI->db->insert_id();
|
||||
$this->CI->db->insert('inventory_cost_layers',array('item_id'=>$line->items_id,'warehouse_id'=>$line->warehouse_id,'source_ledger_id'=>$ledgerId,'received_date'=>$date,'original_qty'=>$line->qty,'remaining_qty'=>$line->qty,'unit_cost'=>$original->unit_cost,'status'=>'open'));
|
||||
$this->CI->db->insert('stock_logs',array('item_id'=>$line->items_id,'barcode_id'=>$line->barcode_id,'qty'=>$line->qty,'tipe'=>'masuk','unit_cost'=>$original->unit_cost,'keterangan'=>$description,'warehouse_id'=>$line->warehouse_id,'ref_type'=>'invoice_reversal','ref_id'=>$invoice->id,'idempotency_key'=>$key));
|
||||
$this->CI->db->insert('item_movements',array('item_id'=>$line->items_id,'barcode_id'=>$line->barcode_id,'qty'=>$line->qty,'from_type'=>'invoice','from_id'=>$invoice->id,'to_type'=>'warehouse','to_id'=>$line->warehouse_id,'movement_type'=>'customer_return','reference_type'=>'invoice_reversal','reference_id'=>$invoice->id,'notes'=>$description,'created_at'=>date('Y-m-d H:i:s')));
|
||||
if($line->barcode_id){$barcode=$this->CI->db->query('SELECT * FROM item_barcodes WHERE id=? FOR UPDATE',array($line->barcode_id))->row();if($barcode)$this->CI->db->where('id',$barcode->id)->update('item_barcodes',array('qty_sisa'=>(float)$barcode->qty_sisa+(float)$line->qty,'status'=>'available'));}$this->CI->receivableInventoryModel->synchronizeStock($line->items_id);
|
||||
}
|
||||
private function invoiceLock($id,$status){$i=$this->CI->db->query('SELECT * FROM invoices WHERE id=? AND company_id=? FOR UPDATE',array((int)$id,$this->CI->companycontext->id()))->row();if(!$i)throw new BusinessException('Invoice tidak ditemukan.',array(),404);if($i->workflow_status!==$status)throw new BusinessException('Invoice harus berstatus '.$status.'.');return $i;}
|
||||
private function lines($id){return $this->CI->db->where('invoice_id',$id)->order_by('id')->get('invoice_details')->result();}
|
||||
private function invoiceTotal($id){$r=$this->CI->db->select('COALESCE(SUM(subtotal),0) total',false)->get_where('invoice_details',array('invoice_id'=>$id))->row();return round((float)$r->total,2);}
|
||||
private function validateInvoice($i){if($i->jatuh_tempo<$i->tanggal)throw new BusinessException('Jatuh tempo tidak boleh sebelum tanggal invoice.');if(!$this->lines($i->id))throw new BusinessException('Invoice belum memiliki item.');}
|
||||
private function assertCreditLimit($i){$c=$this->CI->db->get_where('customers',array('id'=>$i->customer_id))->row();if(!$c)throw new BusinessException('Customer tidak ditemukan.');if($c->is_credit_blocked)throw new BusinessException('Kredit customer sedang diblokir.');if((float)$c->credit_limit>0){$out=(float)$this->CI->db->select('COALESCE(SUM(sisa_piutang),0) total',false)->where('customer_id',$c->id)->where('workflow_status','posted')->get('invoices')->row()->total;if($out+$this->invoiceTotal($i->id)>(float)$c->credit_limit)throw new BusinessException('Credit limit customer terlampaui.');}}
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
require_once APPPATH.'exceptions/BusinessException.php';
|
||||
|
||||
class SalesService
|
||||
{
|
||||
private $CI;
|
||||
public function __construct()
|
||||
{
|
||||
$this->CI=&get_instance();
|
||||
$this->CI->load->library(array('CompanyContext','TransactionService','NumberingService','PostingService','AccountMappingService','FiscalPeriodService','AuditService','ApprovalService'));
|
||||
$this->CI->load->model('InventoryModel','salesInventoryModel');
|
||||
}
|
||||
private function company(){return(int)$this->CI->companycontext->id();}
|
||||
public function money($v)
|
||||
{
|
||||
$v=trim(str_ireplace(array('Rp',' '),'',(string)$v));if($v==='')return 0.0;
|
||||
$comma=strrpos($v,',');$dot=strrpos($v,'.');
|
||||
if($comma!==false&&$dot!==false)$v=$comma>$dot?str_replace(',','.',str_replace('.','',$v)):str_replace(',','',$v);
|
||||
elseif($comma!==false)$v=str_replace(',','.',str_replace('.','',$v));
|
||||
elseif($dot!==false&&preg_match('/^\d{1,3}(\.\d{3})+(\.\d+)?$/',$v))$v=str_replace('.','',$v);
|
||||
if(!is_numeric($v))throw new BusinessException('Nominal tidak valid.');return round((float)$v,2);
|
||||
}
|
||||
private function qty($v){$v=(float)str_replace(',','.',(string)$v);if($v<=0)throw new BusinessException('Qty harus lebih dari nol.');return round($v,4);}
|
||||
private function tenant($table,$id,$label)
|
||||
{
|
||||
$row=$this->CI->db->get_where($table,array('id'=>(int)$id,'company_id'=>$this->company()))->row();
|
||||
if(!$row)throw new BusinessException($label.' tidak ditemukan pada perusahaan aktif.');return$row;
|
||||
}
|
||||
private function addCompany($table,array$data){if($this->CI->db->field_exists('company_id',$table))$data['company_id']=$this->company();return$data;}
|
||||
|
||||
public function dashboard()
|
||||
{
|
||||
$c=$this->company();$today=date('Y-m-d');$month=date('Y-m-01');
|
||||
return array(
|
||||
'month_sales'=>(float)$this->CI->db->select('COALESCE(SUM(total),0) total',false)->where('company_id',$c)->where('workflow_status','posted')->where('tanggal >=',$month)->get('invoices')->row()->total,
|
||||
'open_invoice'=>(int)$this->CI->db->where('company_id',$c)->where_in('status',array('unpaid','partial'))->count_all_results('invoices'),
|
||||
'overdue'=>(int)$this->CI->db->where('company_id',$c)->where_in('status',array('unpaid','partial'))->where('jatuh_tempo <',$today)->where('sisa_piutang >',0)->count_all_results('invoices'),
|
||||
'draft'=>(int)$this->CI->db->where('company_id',$c)->where('deleted_at', null)->where_in('workflow_status',array('draft','submitted'))->count_all_results('invoices'),
|
||||
'delivery'=>(int)$this->CI->db->where('company_id',$c)->where('status','draft')->count_all_results('sales_deliveries'),
|
||||
'return'=>(int)$this->CI->db->where('company_id',$c)->where_in('status',array('draft','submitted','approved','received'))->count_all_results('sales_returns')
|
||||
);
|
||||
}
|
||||
public function invoices($limit=100)
|
||||
{
|
||||
return$this->CI->db->select('i.*,c.nama customer_name,c.alamat customer_address,u.nama creator_name')
|
||||
->from('invoices i')->join('customers c','c.id=i.customer_id')->join('users u','u.id=i.created_by','left')
|
||||
->where('i.company_id',$this->company())->where('i.deleted_at IS NULL',null,false)->order_by('i.id','DESC')->limit((int)$limit)->get()->result();
|
||||
}
|
||||
public function deliveries($limit=100)
|
||||
{
|
||||
return$this->CI->db->select('d.*,c.nama customer_name,i.no_invoice,w.nama warehouse_name,u.nama creator_name')
|
||||
->from('sales_deliveries d')->join('customers c','c.id=d.customer_id')->join('invoices i','i.id=d.invoice_id','left')->join('warehouses w','w.id=d.warehouse_id','left')->join('users u','u.id=d.created_by','left')
|
||||
->where('d.company_id',$this->company())->order_by('d.id','DESC')->limit((int)$limit)->get()->result();
|
||||
}
|
||||
public function returns($limit=100)
|
||||
{
|
||||
return$this->CI->db->select('r.*,c.nama customer_name,i.no_invoice,d.delivery_no,u.nama creator_name')
|
||||
->from('sales_returns r')->join('customers c','c.id=r.customer_id')->join('invoices i','i.id=r.invoice_id','left')->join('sales_deliveries d','d.id=r.sales_delivery_id','left')->join('users u','u.id=r.created_by','left')
|
||||
->where('r.company_id',$this->company())->order_by('r.id','DESC')->limit((int)$limit)->get()->result();
|
||||
}
|
||||
|
||||
public function createInvoiceHeader(array$header,$user)
|
||||
{
|
||||
return $this->CI->transactionservice->run(function()use($header,$user){return $this->insertInvoiceHeader($header,$user);});
|
||||
}
|
||||
private function insertInvoiceHeader(array$header,$user)
|
||||
{
|
||||
$customer=$this->tenant('customers',$header['customer_id']??0,'Customer');
|
||||
if(isset($customer->is_active)&&(int)$customer->is_active!==1)throw new BusinessException('Customer sudah tidak aktif.');
|
||||
$date=$header['date']??date('Y-m-d');$due=$header['due_date']??$date;
|
||||
$start=!empty($header['period_start'])?$header['period_start']:$date;$end=!empty($header['period_end'])?$header['period_end']:$date;
|
||||
foreach(array($date,$due,$start,$end)as$value)if(!preg_match('/^\d{4}-\d{2}-\d{2}$/',(string)$value))throw new BusinessException('Tanggal atau periode invoice tidak valid.');
|
||||
if($due<$date)throw new BusinessException('Jatuh tempo tidak boleh sebelum tanggal invoice.');
|
||||
if($end<$start)throw new BusinessException('Periode akhir tidak boleh sebelum periode awal.');
|
||||
$type=in_array($header['invoice_type']??'',array('one_time','running'),true)?$header['invoice_type']:'one_time';
|
||||
$policy=in_array($header['recognition_policy']??'',array('accrual','on_payment'),true)?$header['recognition_policy']:'accrual';
|
||||
$key=trim((string)($header['idempotency_key']??''));
|
||||
if($key!==''){$existing=$this->CI->db->get_where('invoices',array('company_id'=>$this->company(),'idempotency_key'=>$key))->row();if($existing)return(int)$existing->id;}
|
||||
$sourceOrder=null;if(!empty($header['source_sales_order_id'])){$sourceOrder=$this->CI->db->get_where('sales_documents',array('id'=>(int)$header['source_sales_order_id'],'company_id'=>$this->company(),'customer_id'=>$customer->id,'document_type'=>'sales_order','status'=>'accepted'))->row();if(!$sourceOrder)throw new BusinessException('Sales Order sumber tidak valid atau belum diterima.');}
|
||||
$data=array('company_id'=>$this->company(),'no_invoice'=>$this->CI->numberingservice->next('invoice',$date),'customer_id'=>$customer->id,'tanggal'=>$date,'jatuh_tempo'=>$due,'total'=>0,'total_bayar'=>0,'sisa_piutang'=>0,'status'=>'draft','workflow_status'=>'draft','posted'=>0,'keterangan'=>trim((string)($header['notes']??'')),'invoice_type'=>$type,'period_start'=>$start,'period_end'=>$end,'recognition_policy'=>$policy,'source_sales_order_id'=>$sourceOrder?$sourceOrder->id:null,'delivery_status'=>'not_delivered','created_by'=>(int)$user,'created_at'=>date('Y-m-d H:i:s'),'idempotency_key'=>$key!==''?$key:null,'version'=>1);
|
||||
$this->CI->db->insert('invoices',$this->columns('invoices',$data));$id=(int)$this->CI->db->insert_id();
|
||||
$this->CI->auditservice->record('sales','invoice',$id,'create_header',null,array('invoice_type'=>$type,'recognition_policy'=>$policy,'period_start'=>$start,'period_end'=>$end),$user);return$id;
|
||||
}
|
||||
public function createInvoice(array$header,array$rows,$user)
|
||||
{
|
||||
return$this->CI->transactionservice->run(function()use($header,$rows,$user){$id=$this->insertInvoiceHeader($header,$user);if($rows)$this->appendInvoiceLinesInternal($id,$rows,$user);return$id;});
|
||||
}
|
||||
public function appendInvoiceLines($invoiceId,array$rows,$user)
|
||||
{
|
||||
return$this->CI->transactionservice->run(function()use($invoiceId,$rows,$user){$this->appendInvoiceLinesInternal($invoiceId,$rows,$user);return$invoiceId;});
|
||||
}
|
||||
public function appendInvoiceLine($invoiceId,array$row,$user)
|
||||
{
|
||||
return$this->CI->transactionservice->run(function()use($invoiceId,$row,$user){$ids=$this->appendInvoiceLinesInternal($invoiceId,array($row),$user);return reset($ids);});
|
||||
}
|
||||
public function appendInvoiceLinesBatch($invoiceId,array$rows,$batchDiscount,$user)
|
||||
{
|
||||
return $this->CI->transactionservice->run(function()use($invoiceId,$rows,$batchDiscount,$user){
|
||||
if(!$rows)throw new BusinessException('Tambahkan minimal satu barang, jasa, atau tabungan.');
|
||||
if(count($rows)>100)throw new BusinessException('Maksimal 100 item dalam satu kali penambahan.');
|
||||
|
||||
$grossValues=array();$totalGross=0.0;
|
||||
foreach($rows as $index=>&$row){
|
||||
$qty=$this->qty($row['qty']??0);
|
||||
$priceValue=$row['unit_price']??'';
|
||||
if(($priceValue===''||$priceValue===null)&&(($row['line_type']??'inventory')==='inventory')){
|
||||
$item=$this->tenant('items',$row['item_id']??0,'Barang baris '.($index+1));
|
||||
$priceValue=$item->harga_jual;
|
||||
}
|
||||
$price=$this->money($priceValue);
|
||||
if($price<0)throw new BusinessException('Harga item ke-'.($index+1).' tidak boleh negatif.');
|
||||
$row['qty']=$qty;$row['unit_price']=$price;$row['discount_amount']=0;$row['specification']='';
|
||||
$grossValues[$index]=round($qty*$price,2);$totalGross+=$grossValues[$index];
|
||||
}
|
||||
unset($row);
|
||||
|
||||
$discount=$this->money($batchDiscount);
|
||||
if($discount<0)throw new BusinessException('Diskon penambahan item tidak boleh negatif.');
|
||||
if($discount>$totalGross+.001)throw new BusinessException('Diskon tidak boleh melebihi total bruto item yang ditambahkan.');
|
||||
|
||||
$positiveIndexes=array_keys(array_filter($grossValues,function($gross){return$gross>0;}));
|
||||
$lastPositive=$positiveIndexes?end($positiveIndexes):null;$allocated=0.0;
|
||||
foreach($rows as $index=>&$row){
|
||||
if($discount<=0||$grossValues[$index]<=0){$lineDiscount=0.0;}
|
||||
elseif($index===$lastPositive){$lineDiscount=round($discount-$allocated,2);}
|
||||
else{$lineDiscount=round($discount*$grossValues[$index]/$totalGross,2);}
|
||||
$lineDiscount=max(0,min($grossValues[$index],$lineDiscount));
|
||||
$row['discount_amount']=$lineDiscount;$allocated+=$lineDiscount;
|
||||
}
|
||||
unset($row);
|
||||
|
||||
$ids=$this->appendInvoiceLinesInternal($invoiceId,$rows,$user);
|
||||
$this->CI->auditservice->record('sales','invoice',(int)$invoiceId,'append_line_batch',null,array('line_count'=>count($rows),'batch_discount'=>$discount,'line_ids'=>$ids),$user);
|
||||
return array('line_ids'=>$ids,'line_count'=>count($ids),'batch_discount'=>$discount);
|
||||
});
|
||||
}
|
||||
public function deleteDraftInvoice($id,$user,$privileged=false)
|
||||
{
|
||||
return$this->CI->transactionservice->run(function()use($id,$user,$privileged){
|
||||
$i=$this->lockInvoice($id,'draft');if(!$privileged&&(int)$i->created_by!==(int)$user)throw new BusinessException('Hanya pembuat Draft atau pengguna berizin hapus yang boleh membatalkan invoice.');
|
||||
$postedDelivery=(int)$this->CI->db->where(array('invoice_id'=>$i->id,'company_id'=>$this->company()))->where_in('status',array('posted','partially_returned','returned'))->count_all_results('sales_deliveries');if($postedDelivery)throw new BusinessException('Draft tidak dapat dibatalkan karena Surat Jalan sudah diposting. Gunakan alur retur/koreksi agar stok dan jurnal tetap sinkron.');
|
||||
foreach($this->CI->db->where(array('invoice_id'=>$i->id,'company_id'=>$this->company()))->get('invoice_details')->result()as$line){$this->reverseInvoiceLineInitialJournal($line,$user,'Draft invoice '.$i->no_invoice.' dibatalkan');$this->releaseInvoiceLineBarcodes($line->id);}
|
||||
$this->CI->db->where(array('invoice_id'=>$i->id,'company_id'=>$this->company(),'status'=>'draft'))->update('sales_deliveries',array('status'=>'cancelled'));
|
||||
$this->CI->db->where(array('id'=>$i->id,'company_id'=>$this->company()))->update('invoices',array('deleted_at'=>date('Y-m-d H:i:s'),'status'=>'cancelled','updated_by'=>$user,'updated_at'=>date('Y-m-d H:i:s')));
|
||||
$this->CI->auditservice->record('sales','invoice',$i->id,'cancel_draft',$i,array('status'=>'cancelled'),$user);return$i->id;
|
||||
});
|
||||
}
|
||||
private function appendInvoiceLinesInternal($invoiceId,array$rows,$user)
|
||||
{
|
||||
$invoice=$this->CI->db->query('SELECT * FROM invoices WHERE id=? AND company_id=? FOR UPDATE',array((int)$invoiceId,$this->company()))->row();
|
||||
if(!$invoice||$invoice->workflow_status!=='draft')throw new BusinessException('Item hanya dapat ditambahkan pada invoice Draft/Berjalan.');$this->assertDraftOwner($invoice,$user);
|
||||
if(!$rows)throw new BusinessException('Tambahkan minimal satu barang, jasa, atau tabungan.');$inserted=array();
|
||||
$customer=$this->tenant('customers',$invoice->customer_id,'Customer');
|
||||
$existingLines=$this->CI->db->select('id,line_type,subtotal,total_hpp_barang')->where(array('invoice_id'=>$invoice->id,'company_id'=>$this->company()))->get('invoice_details')->result();$lineJournalMode=true;foreach($existingLines as$existingLine)if($existingLine->line_type!=='savings'&&((float)$existingLine->subtotal>0||(float)$existingLine->total_hpp_barang>0)){$existingJournal=$this->initialLineJournal($existingLine->id);if(!$existingJournal||$existingJournal->is_reversed||$existingJournal->status!=='posted'){$lineJournalMode=false;break;}}
|
||||
$existingTypes=$this->CI->db->select('DISTINCT line_type',false)->where(array('invoice_id'=>$invoice->id,'company_id'=>$this->company()))->get('invoice_details')->result_array();
|
||||
$families=array();foreach($existingTypes as$t)$families[$this->lineFamily($t['line_type'])]=true;foreach($rows as$r)$families[$this->lineFamily($r['line_type']??'inventory')]=true;
|
||||
if(count($families)>1)throw new BusinessException('Invoice penjualan barang/jasa tidak dapat dicampur dengan invoice tabungan. Buat invoice terpisah agar jurnal dan pelunasannya jelas.');
|
||||
$financialTypes=array();foreach($existingTypes as$t)if($this->lineFamily($t['line_type'])==='financial')$financialTypes[$t['line_type']]=true;foreach($rows as$r)if($this->lineFamily($r['line_type']??'inventory')==='financial')$financialTypes[$r['line_type']]=true;
|
||||
if(count($financialTypes)>1)throw new BusinessException('Data pinjaman lama dan tabungan tidak dapat dicampur pada invoice yang sama.');
|
||||
foreach($rows as$n=>$row){
|
||||
$requestedType=$row['line_type']??'inventory';if($requestedType==='loan')throw new BusinessException('Piutang/Pinjaman sudah tidak tersedia pada Invoice. Gunakan jenis Tabungan sesuai alur terbaru.');
|
||||
$lineType=in_array($requestedType,array('inventory','service','savings'),true)?$requestedType:'inventory';$item=null;$warehouse=null;$kb=null;$key=trim((string)($row['idempotency_key']??''));
|
||||
if($key!==''){$existing=$this->CI->db->get_where('invoice_details',array('company_id'=>$this->company(),'idempotency_key'=>$key))->row();if($existing){if((int)$existing->invoice_id!==(int)$invoice->id)throw new BusinessException('Kunci permintaan item sudah digunakan dokumen lain.');$inserted[]=(int)$existing->id;continue;}}
|
||||
$accountLabel=$lineType==='loan'?'Akun piutang':($lineType==='savings'?'Akun simpanan customer':'Akun pendapatan');
|
||||
$account=$this->tenant('accounts',$row['account_id']??0,$accountLabel);$counterAccount=null;
|
||||
$mayUseHidden=function_exists('is_master_admin_user')&&is_master_admin_user();
|
||||
if((isset($account->is_active)&&(int)$account->is_active!==1)||(isset($account->allow_posting)&&(int)$account->allow_posting!==1)||(!$mayUseHidden&&isset($account->is_hidden)&&(int)$account->is_hidden===1))throw new BusinessException($accountLabel.' tidak aktif atau tidak dapat diposting.');
|
||||
if(in_array($lineType,array('inventory','service'),true)&&(string)$account->tipe!=='revenue')throw new BusinessException('Barang dan jasa harus dialokasikan ke akun pendapatan.');
|
||||
if($lineType==='loan'){
|
||||
if((string)$account->tipe!=='asset')throw new BusinessException('Akun piutang pinjaman harus merupakan akun Aktiva.');
|
||||
$counterAccount=$this->tenant('accounts',$row['counter_account_id']??0,'Akun sumber dana pinjaman');
|
||||
if((string)$counterAccount->tipe!=='asset'||(isset($counterAccount->is_active)&&(int)$counterAccount->is_active!==1)||(isset($counterAccount->allow_posting)&&(int)$counterAccount->allow_posting!==1))throw new BusinessException('Akun sumber dana pinjaman harus merupakan akun Aktiva aktif yang dapat diposting.');
|
||||
if((int)$counterAccount->id===(int)$account->id)throw new BusinessException('Akun piutang dan akun sumber dana pinjaman harus berbeda.');
|
||||
}
|
||||
if($lineType==='savings'&&(string)$account->tipe!=='liability')throw new BusinessException('Akun tabungan customer harus merupakan akun Passiva/Kewajiban.');
|
||||
if($lineType==='inventory'){$item=$this->tenant('items',$row['item_id']??0,'Barang baris '.($n+1));$warehouse=$this->tenant('warehouses',$row['warehouse_id']??0,'Gudang');if($item->status!=='active')throw new BusinessException('Barang '.$item->nama_barang.' belum aktif.');$kb=$this->CI->db->get_where('kode_barang',array('id'=>$item->kode_id))->row();}
|
||||
$q=$lineType==='savings'?1:$this->qty($row['qty']??0);$price=$this->money($row['unit_price']??($item?$item->harga_jual:0));$discount=$lineType==='savings'?0:max(0,$this->money($row['discount_amount']??0));$tax=$lineType==='savings'?0:max(0,(float)str_replace(',','.',(string)($row['tax_rate']??0)));
|
||||
if($discount>$q*$price)throw new BusinessException('Diskon tidak boleh melebihi nilai bruto baris.');
|
||||
$base=max(0,$q*$price-$discount);$taxAmount=round($base*$tax/100,2);$total=$base+$taxAmount;$service=$row['service_date']??$invoice->tanggal;
|
||||
if(!preg_match('/^\d{4}-\d{2}-\d{2}$/',(string)$service))throw new BusinessException('Tanggal transaksi item tidak valid.');
|
||||
$barcodes=(array)($row['barcodes']??array());$barcodeQty=0;$cleanBarcodes=array();
|
||||
if($lineType==='inventory'){
|
||||
$seenBarcodes=array();
|
||||
foreach($barcodes as$b){
|
||||
$bid=(int)($b['id']??$b['barcode_id']??0);
|
||||
if(!$bid||isset($seenBarcodes[$bid]))throw new BusinessException('Barcode tidak boleh dipilih dua kali pada satu baris.');
|
||||
$seenBarcodes[$bid]=true;
|
||||
if($this->CI->db->where(array('invoice_id'=>$invoice->id,'barcode_id'=>$bid))->count_all_results('invoice_line_barcodes'))throw new BusinessException('Barcode sudah dipakai pada item lain di invoice ini.');
|
||||
$bq=$this->qty($b['qty']??1);$barcode=$this->CI->db->query("SELECT * FROM item_barcodes WHERE id=? AND item_id=? AND warehouse_id=? AND status='available' FOR UPDATE",array($bid,$item->id,$warehouse->id))->row();if(!$barcode)throw new BusinessException('Barcode barang '.$item->nama_barang.' tidak tersedia di gudang terpilih.');$available=(float)$barcode->qty_sisa-(float)$barcode->reserved_qty-(float)($barcode->return_reserved_qty??0);if($bq>$available+.0001)throw new BusinessException('Qty barcode '.$barcode->barcode.' melebihi saldo tersedia.');$this->CI->db->where(array('id'=>$barcode->id,'version'=>$barcode->version))->update('item_barcodes',array('reserved_qty'=>(float)$barcode->reserved_qty+$bq,'version'=>(int)$barcode->version+1));if($this->CI->db->affected_rows()!==1)throw new BusinessException('Barcode sedang dipilih pengguna lain. Silakan ulangi.');$barcode->reserved_qty=(float)$barcode->reserved_qty+$bq;$barcode->version=(int)$barcode->version+1;$barcodeQty+=$bq;$cleanBarcodes[]=array('row'=>$barcode,'qty'=>$bq);
|
||||
}
|
||||
if(abs($barcodeQty-$q)>.0001)throw new BusinessException('Total qty barcode harus sama dengan qty barang '.$item->nama_barang.'.');if($kb&&$kb->tracking_type==='UNIT')foreach($cleanBarcodes as$b)if(abs($b['qty']-1)>.0001)throw new BusinessException('Barcode UNIT harus memiliki qty 1.');
|
||||
}
|
||||
elseif(trim((string)($row['description']??''))==='')throw new BusinessException('Nama jasa atau rincian tagihan wajib diisi.');
|
||||
$name=$lineType==='inventory'?$item->nama_barang:trim((string)$row['description']);
|
||||
$detail=array('company_id'=>$this->company(),'invoice_id'=>$invoice->id,'line_type'=>$lineType,'tanggal'=>$service,'service_date'=>$service,'account_id'=>$account->id,'counter_account_id'=>$counterAccount?$counterAccount->id:null,'items_id'=>$item?$item->id:null,'nama_item'=>$name,'specification'=>trim((string)($row['specification']??'')),'unit'=>$kb?$kb->unit:trim((string)($row['unit']??($lineType==='loan'?'Pinjaman':($lineType==='savings'?'Tabungan':'Jasa')))),'qty'=>$q,'harga'=>$price,'discount_amount'=>$discount,'tax_rate'=>$tax,'tax_amount'=>$taxAmount,'net_amount'=>$base,'subtotal'=>$total,'subtotal_asli'=>$total,'warehouse_id'=>$warehouse?$warehouse->id:null,'keterangan'=>trim((string)($row['notes']??'')),'total_hpp_barang'=>$item?round($q*(float)$item->harga_beli,2):0,'is_cicilan'=>0,'idempotency_key'=>$key!==''?$key:null);
|
||||
$this->CI->db->insert('invoice_details',$this->columns('invoice_details',$detail));$detailId=(int)$this->CI->db->insert_id();$inserted[]=$detailId;
|
||||
if($lineType==='inventory')foreach($cleanBarcodes as$b)$this->CI->db->insert('invoice_line_barcodes',array('company_id'=>$this->company(),'invoice_id'=>$invoice->id,'invoice_detail_id'=>$detailId,'sales_delivery_id'=>null,'sales_delivery_line_id'=>null,'barcode_id'=>$b['row']->id,'qty'=>$b['qty'],'created_at'=>date('Y-m-d H:i:s')));
|
||||
$storedDetail=$this->CI->db->get_where('invoice_details',array('id'=>$detailId,'company_id'=>$this->company()))->row();if($lineJournalMode)$this->postInvoiceLineInitialJournal($invoice,$detailId,$storedDetail,$item,$cleanBarcodes,$customer,$user);
|
||||
}
|
||||
$this->refreshInvoiceTotals($invoice->id);$this->updateInvoiceDeliveryStatus($invoice->id);return$inserted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Jurnal awal dibuat per baris agar setiap perubahan Draft mempunyai jejak
|
||||
* sumber yang dapat dibalik tanpa menghapus histori jurnal.
|
||||
*/
|
||||
private function postInvoiceLineInitialJournal($invoice,$detailId,$line,$item,array$barcodes,$customer,$user)
|
||||
{
|
||||
if($line->line_type==='savings')return null;
|
||||
$entries=array();
|
||||
if($line->line_type==='inventory'&&(float)$line->total_hpp_barang>0){
|
||||
$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('inventory_in_sales'),'debit'=>(float)$line->total_hpp_barang,'kredit'=>0);
|
||||
$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('inventory'),'debit'=>0,'kredit'=>(float)$line->total_hpp_barang);
|
||||
}
|
||||
if((float)$line->subtotal>0){
|
||||
$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('accounts_receivable'),'debit'=>(float)$line->subtotal,'kredit'=>0);
|
||||
if((float)$line->net_amount>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('deferred_revenue'),'debit'=>0,'kredit'=>(float)$line->net_amount);
|
||||
if((float)$line->tax_amount>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('sales_tax_payable'),'debit'=>0,'kredit'=>(float)$line->tax_amount);
|
||||
}
|
||||
if(!$entries)return null;
|
||||
$qty=number_format((float)$line->qty,abs((float)$line->qty-round((float)$line->qty))<.0001?0:4,',','.');
|
||||
if($line->line_type==='inventory'){
|
||||
$codes=array();foreach($barcodes as$b)if(isset($b['row']->barcode))$codes[]=$b['row']->barcode;
|
||||
$description="Penjualan '".$item->nama_barang."' Sebanyak '".$qty."' '".($line->unit?:'-')."' - Kode: '".($codes?implode(', ',$codes):'-')."' - Kepada: '".$customer->nama."'";
|
||||
}else{
|
||||
$description="Penjualan '".$line->nama_item."' Sebanyak '".$qty."' - Kepada: '".$customer->nama."' - '".($line->keterangan?:'-')."'";
|
||||
}
|
||||
return$this->CI->postingservice->post(array('tanggal'=>$line->service_date,'no_ref'=>$this->CI->numberingservice->next('invoice_line_journal',$line->service_date),'keterangan'=>$description,'ref_type'=>'invoice_line','ref_id'=>(int)$detailId,'created_by'=>$user),$entries,false,false);
|
||||
}
|
||||
|
||||
private function initialLineJournal($lineId,$lock=false)
|
||||
{
|
||||
$sql="SELECT * FROM journals WHERE company_id=? AND ref_type='invoice_line' AND ref_id=? ORDER BY id DESC LIMIT 1".($lock?' FOR UPDATE':'');
|
||||
return$this->CI->db->query($sql,array($this->company(),(int)$lineId))->row();
|
||||
}
|
||||
|
||||
private function reverseInvoiceLineInitialJournal($line,$user,$reason)
|
||||
{
|
||||
$journal=$this->initialLineJournal($line->id,true);if(!$journal||$journal->is_reversed||$journal->status==='reversed')return null;
|
||||
$entries=array();foreach($this->CI->db->where('journal_id',$journal->id)->order_by('id')->get('journal_details')->result()as$d)$entries[]=array('account_id'=>$d->account_id,'debit'=>(float)$d->kredit,'kredit'=>(float)$d->debit);
|
||||
if(!$entries)return null;
|
||||
$date=$line->service_date?:($line->tanggal?:date('Y-m-d'));
|
||||
$reversal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$this->CI->numberingservice->next('invoice_line_reversal',$date),'keterangan'=>'Pembalikan item invoice: '.$reason,'ref_type'=>'invoice_line_reversal','ref_id'=>(int)$journal->id,'reversal_of'=>(int)$journal->id,'reversal_reason'=>$reason,'created_by'=>$user),$entries,false,true);
|
||||
$this->CI->db->where('id',$journal->id)->update('journals',array('status'=>'reversed','is_reversed'=>1,'updated_by'=>$user,'updated_at'=>date('Y-m-d H:i:s'),'reversal_reason'=>$reason));
|
||||
return$reversal;
|
||||
}
|
||||
public function generateDelivery($invoiceId,$user)
|
||||
{
|
||||
return $this->CI->transactionservice->run(function()use($invoiceId,$user){
|
||||
$invoice=$this->lockInvoice($invoiceId,'draft');$this->assertDraftOwner($invoice,$user);
|
||||
$lines=$this->CI->db->select('d.*,it.nama_barang,k.unit')->from('invoice_details d')->join('items it','it.id=d.items_id')->join('kode_barang k','k.id=it.kode_id','left')->where(array('d.invoice_id'=>$invoice->id,'d.company_id'=>$this->company(),'d.line_type'=>'inventory'))->where('d.delivery_line_id IS NULL',null,false)->order_by('d.service_date,d.warehouse_id,d.id')->get()->result();
|
||||
if(!$lines)throw new BusinessException('Tidak ada barang invoice yang belum dibuatkan Surat Jalan.');
|
||||
$groups=array();
|
||||
foreach($lines as$l){
|
||||
$warehouse=$this->tenant('warehouses',$l->warehouse_id,'Gudang');$key=$l->service_date.'|'.$warehouse->id;
|
||||
if(!isset($groups[$key]))$groups[$key]=array('date'=>$l->service_date,'warehouse'=>$warehouse,'lines'=>array());
|
||||
$bars=$this->CI->db->select('ilb.*,ib.barcode,ib.serial_number')->from('invoice_line_barcodes ilb')->join('item_barcodes ib','ib.id=ilb.barcode_id')->where(array('ilb.invoice_detail_id'=>$l->id,'ilb.company_id'=>$this->company()))->where('ilb.sales_delivery_line_id IS NULL',null,false)->order_by('ilb.id')->get()->result();
|
||||
$allocated=0;$clean=array();foreach($bars as$b){$allocated+=(float)$b->qty;$clean[]=array('allocation'=>$b,'row'=>(object)array('id'=>(int)$b->barcode_id),'qty'=>(float)$b->qty);}
|
||||
if(abs($allocated-(float)$l->qty)>.0001)throw new BusinessException('Alokasi barcode item '.$l->nama_barang.' tidak sama dengan qty invoice.');
|
||||
$groups[$key]['lines'][]=array('detail_id'=>(int)$l->id,'item'=>(object)array('id'=>$l->items_id,'nama_barang'=>$l->nama_barang,'harga_beli'=>$l->total_hpp_barang/max(.0001,(float)$l->qty)),'kb'=>(object)array('unit'=>$l->unit),'qty'=>(float)$l->qty,'barcodes'=>$clean,'specification'=>$l->specification,'notes'=>$l->keterangan);
|
||||
}
|
||||
$deliveryIds=array();foreach($groups as$group)$deliveryIds[]=$this->createDeliveryForLines($invoice,$group,$user);
|
||||
$this->updateInvoiceDeliveryStatus($invoice->id);$this->CI->auditservice->record('sales','invoice',$invoice->id,'generate_delivery',null,array('delivery_ids'=>$deliveryIds),$user);
|
||||
return array('delivery_ids'=>$deliveryIds,'delivery_count'=>count($deliveryIds));
|
||||
});
|
||||
}
|
||||
|
||||
private function createDeliveryForLines($invoice,array$group,$user)
|
||||
{
|
||||
$delivery=$this->CI->db->query("SELECT * FROM sales_deliveries WHERE company_id=? AND invoice_id=? AND delivery_date=? AND warehouse_id=? AND status='draft' ORDER BY id LIMIT 1 FOR UPDATE",array($this->company(),$invoice->id,$group['date'],$group['warehouse']->id))->row();
|
||||
if(!$delivery){$this->CI->db->insert('sales_deliveries',array('company_id'=>$this->company(),'delivery_no'=>$this->CI->numberingservice->next('sales_delivery',$group['date']),'customer_id'=>$invoice->customer_id,'invoice_id'=>$invoice->id,'warehouse_id'=>$group['warehouse']->id,'delivery_date'=>$group['date'],'address'=>'','status'=>'draft','created_by'=>$user,'created_at'=>date('Y-m-d H:i:s')));$deliveryId=(int)$this->CI->db->insert_id();$lineNo=0;}else{$deliveryId=(int)$delivery->id;$max=$this->CI->db->select_max('line_no','max_line')->get_where('sales_delivery_lines',array('sales_delivery_id'=>$deliveryId))->row();$lineNo=(int)($max->max_line??0);}
|
||||
foreach($group['lines']as$line){$lineNo++;$this->CI->db->insert('sales_delivery_lines',array('company_id'=>$this->company(),'sales_delivery_id'=>$deliveryId,'line_no'=>$lineNo,'item_id'=>$line['item']->id,'invoice_detail_id'=>$line['detail_id'],'description'=>$line['item']->nama_barang,'specification'=>$line['specification'],'unit'=>$line['kb']?$line['kb']->unit:null,'qty'=>$line['qty'],'unit_cost'=>$line['item']->harga_beli,'total_cost'=>$line['qty']*$line['item']->harga_beli,'condition_notes'=>$line['notes'],'created_at'=>date('Y-m-d H:i:s')));$deliveryLine=(int)$this->CI->db->insert_id();$this->CI->db->where('id',$line['detail_id'])->update('invoice_details',array('delivery_line_id'=>$deliveryLine));foreach($line['barcodes']as$b){$this->CI->db->insert('sales_delivery_barcodes',array('company_id'=>$this->company(),'sales_delivery_id'=>$deliveryId,'sales_delivery_line_id'=>$deliveryLine,'item_id'=>$line['item']->id,'barcode_id'=>$b['row']->id,'qty'=>$b['qty'],'status'=>'allocated','created_at'=>date('Y-m-d H:i:s')));$this->CI->db->where(array('id'=>$b['allocation']->id,'invoice_detail_id'=>$line['detail_id']))->update('invoice_line_barcodes',array('sales_delivery_id'=>$deliveryId,'sales_delivery_line_id'=>$deliveryLine));}}
|
||||
return$deliveryId;
|
||||
}
|
||||
private function refreshInvoiceTotals($id)
|
||||
{
|
||||
$r=$this->CI->db->select('COALESCE(SUM(subtotal),0) total,COALESCE(SUM(net_amount),0) net,COALESCE(SUM(tax_amount),0) tax,COALESCE(SUM(discount_amount),0) discount,MIN(service_date) period_start,MAX(service_date) period_end',false)->get_where('invoice_details',array('invoice_id'=>(int)$id,'company_id'=>$this->company()))->row();
|
||||
$invoice=$this->CI->db->select('tanggal')->get_where('invoices',array('id'=>(int)$id,'company_id'=>$this->company()))->row();
|
||||
$start=$r->period_start?:$invoice->tanggal;$end=$r->period_end?:$invoice->tanggal;
|
||||
$this->CI->db->where(array('id'=>(int)$id,'company_id'=>$this->company()))->update('invoices',array('total'=>$r->total,'subtotal_before_tax'=>$r->net,'tax_total'=>$r->tax,'discount_total'=>$r->discount,'sisa_piutang'=>$r->total,'period_start'=>$start,'period_end'=>$end,'invoice_type'=>$start===$end?'one_time':'running'));
|
||||
}
|
||||
public function invoiceForEditor($id)
|
||||
{
|
||||
$invoice=$this->CI->db->select("i.*,c.nama customer_name,c.alamat customer_address,c.telp customer_phone,c.email customer_email,u.nama creator_name,ua.nama approver_name,(SELECT COUNT(*) FROM invoice_details dx WHERE dx.invoice_id=i.id AND dx.company_id=i.company_id) line_count,(SELECT COUNT(*) FROM invoice_details dx WHERE dx.invoice_id=i.id AND dx.company_id=i.company_id AND dx.line_type='inventory') inventory_line_count,(SELECT COUNT(*) FROM invoice_details dx WHERE dx.invoice_id=i.id AND dx.company_id=i.company_id AND dx.line_type='inventory' AND dx.delivery_line_id IS NULL) pending_delivery_count",false)
|
||||
->from('invoices i')->join('customers c','c.id=i.customer_id')->join('users u','u.id=i.created_by','left')->join('users ua','ua.id=i.approved_by','left')
|
||||
->where(array('i.id'=>(int)$id,'i.company_id'=>$this->company()))->where('i.deleted_at IS NULL',null,false)->get()->row();
|
||||
if(!$invoice)throw new BusinessException('Invoice tidak ditemukan pada perusahaan aktif.');return$invoice;
|
||||
}
|
||||
public function invoiceLinesPage($invoiceId,$start=0,$length=25,$search='')
|
||||
{
|
||||
$invoice=$this->invoiceForEditor($invoiceId);$start=max(0,(int)$start);$length=max(1,min(100,(int)$length));$search=trim((string)$search);
|
||||
$base=function()use($invoiceId,$search){$this->CI->db->from('invoice_details d')->join('items it','it.id=d.items_id','left')->join('accounts a','a.id=d.account_id','left')->join('accounts ca','ca.id=d.counter_account_id','left')->where(array('d.invoice_id'=>(int)$invoiceId,'d.company_id'=>$this->company()));if($search!=='')$this->CI->db->group_start()->like('d.nama_item',$search)->or_like('d.specification',$search)->or_like('d.keterangan',$search)->or_like('it.kode_detail',$search)->or_like('a.nama_akun',$search)->or_like('ca.nama_akun',$search)->group_end();};
|
||||
$base();$filtered=(int)$this->CI->db->count_all_results();$total=(int)$this->CI->db->where(array('invoice_id'=>(int)$invoiceId,'company_id'=>$this->company()))->count_all_results('invoice_details');
|
||||
$base();$rows=$this->CI->db->select("d.*,it.kode_detail item_code,a.kode_akun,a.nama_akun,ca.kode_akun counter_kode_akun,ca.nama_akun counter_nama_akun,(SELECT GROUP_CONCAT(CONCAT(ib.barcode,IF(NULLIF(ib.serial_number,'') IS NULL,'',CONCAT(' / ',ib.serial_number))) ORDER BY ilb.id SEPARATOR '||') FROM invoice_line_barcodes ilb JOIN item_barcodes ib ON ib.id=ilb.barcode_id WHERE ilb.invoice_detail_id=d.id) barcode_labels",false)->order_by('d.service_date','ASC')->order_by('d.id','ASC')->limit($length,$start)->get()->result();
|
||||
return array('invoice'=>$invoice,'rows'=>$rows,'recordsTotal'=>$total,'recordsFiltered'=>$filtered,'summary'=>array('line_count'=>$total,'subtotal'=>(float)$invoice->subtotal_before_tax,'discount'=>(float)$invoice->discount_total,'tax'=>(float)$invoice->tax_total,'total'=>(float)$invoice->total));
|
||||
}
|
||||
public function updateInvoiceHeader($id,array$data,$user)
|
||||
{
|
||||
return$this->CI->transactionservice->run(function()use($id,$data,$user){$invoice=$this->lockInvoice($id,'draft');$this->assertDraftOwner($invoice,$user);$lineCount=(int)$this->CI->db->where(array('invoice_id'=>$invoice->id,'company_id'=>$this->company()))->count_all_results('invoice_details');if($lineCount)throw new BusinessException('Aturan invoice hanya dapat diubah sebelum item pertama ditambahkan.');$date=$data['date']??$invoice->tanggal;$due=$data['due_date']??$invoice->jatuh_tempo;$start=array_key_exists('period_start',$data)?$data['period_start']:$date;$end=array_key_exists('period_end',$data)?$data['period_end']:$date;if($due<$date||$end<$start)throw new BusinessException('Tanggal invoice atau jatuh tempo tidak valid.');$customer=$this->tenant('customers',$data['customer_id']??$invoice->customer_id,'Customer');$update=array('customer_id'=>$customer->id,'tanggal'=>$date,'jatuh_tempo'=>$due,'period_start'=>$start,'period_end'=>$end,'invoice_type'=>in_array($data['invoice_type']??'',array('one_time','running'),true)?$data['invoice_type']:'one_time','recognition_policy'=>in_array($data['recognition_policy']??'',array('accrual','on_payment'),true)?$data['recognition_policy']:$invoice->recognition_policy,'keterangan'=>trim((string)($data['notes']??$invoice->keterangan)),'updated_by'=>$user,'updated_at'=>date('Y-m-d H:i:s'),'version'=>(int)$invoice->version+1);$this->CI->db->where(array('id'=>$invoice->id,'company_id'=>$this->company()))->update('invoices',$update);$this->CI->auditservice->record('sales','invoice',$invoice->id,'update_header',$invoice,$update,$user);return$invoice->id;});
|
||||
}
|
||||
public function removeInvoiceLine($invoiceId,$lineId,$user)
|
||||
{
|
||||
return$this->CI->transactionservice->run(function()use($invoiceId,$lineId,$user){
|
||||
$invoice=$this->lockInvoice($invoiceId,'draft');$this->assertDraftOwner($invoice,$user);$line=$this->CI->db->query('SELECT * FROM invoice_details WHERE id=? AND invoice_id=? AND company_id=? FOR UPDATE',array((int)$lineId,$invoice->id,$this->company()))->row();if(!$line)throw new BusinessException('Baris invoice tidak ditemukan.');
|
||||
$deliveryId=0;if($line->delivery_line_id){$deliveryLine=$this->CI->db->select('l.*,d.status delivery_status')->from('sales_delivery_lines l')->join('sales_deliveries d','d.id=l.sales_delivery_id')->where('l.id',$line->delivery_line_id)->get()->row();if(!$deliveryLine||$deliveryLine->delivery_status!=='draft'||$deliveryLine->stock_posted_at)throw new BusinessException('Item yang Surat Jalannya sudah diposting tidak dapat dihapus.');$deliveryId=(int)$deliveryLine->sales_delivery_id;}
|
||||
$this->reverseInvoiceLineInitialJournal($line,$user,'Item dihapus dari Draft invoice '.$invoice->no_invoice);
|
||||
$this->releaseInvoiceLineBarcodes($line->id);
|
||||
if($line->delivery_line_id){$this->CI->db->where('sales_delivery_line_id',$line->delivery_line_id)->delete('sales_delivery_barcodes');$this->CI->db->where('id',$line->delivery_line_id)->delete('sales_delivery_lines');if(!$this->CI->db->where('sales_delivery_id',$deliveryId)->count_all_results('sales_delivery_lines'))$this->CI->db->where('id',$deliveryId)->update('sales_deliveries',array('status'=>'cancelled'));}
|
||||
$this->CI->db->where('id',$line->id)->delete('invoice_details');$this->refreshInvoiceTotals($invoice->id);$this->updateInvoiceDeliveryStatus($invoice->id);$this->CI->auditservice->record('sales','invoice',$invoice->id,'remove_line',$line,null,$user);return$line->id;
|
||||
});
|
||||
}
|
||||
|
||||
private function releaseInvoiceLineBarcodes($lineId)
|
||||
{
|
||||
$bars=$this->CI->db->where(array('invoice_detail_id'=>(int)$lineId,'company_id'=>$this->company()))->get('invoice_line_barcodes')->result();
|
||||
foreach($bars as$b){$barcode=$this->CI->db->query('SELECT * FROM item_barcodes WHERE id=? FOR UPDATE',array($b->barcode_id))->row();if($barcode)$this->CI->db->where(array('id'=>$barcode->id,'version'=>$barcode->version))->update('item_barcodes',array('reserved_qty'=>max(0,(float)$barcode->reserved_qty-(float)$b->qty),'status'=>'available','version'=>(int)$barcode->version+1));}
|
||||
$this->CI->db->where(array('invoice_detail_id'=>(int)$lineId,'company_id'=>$this->company()))->delete('invoice_line_barcodes');
|
||||
}
|
||||
|
||||
public function updateNonInventoryLine($invoiceId,$lineId,array$data,$user)
|
||||
{
|
||||
return $this->CI->transactionservice->run(function()use($invoiceId,$lineId,$data,$user){
|
||||
$invoice=$this->lockInvoice($invoiceId,'draft');$this->assertDraftOwner($invoice,$user);
|
||||
$line=$this->CI->db->query('SELECT * FROM invoice_details WHERE id=? AND invoice_id=? AND company_id=? FOR UPDATE',array((int)$lineId,$invoice->id,$this->company()))->row();
|
||||
if(!$line||!in_array($line->line_type,array('service','savings'),true))throw new BusinessException('Hanya item Jasa/Nonbarang atau Tabungan pada Draft yang dapat diedit.');
|
||||
$type=$line->line_type;$description=trim((string)($data['description']??''));if($description==='')throw new BusinessException('Deskripsi item wajib diisi.');
|
||||
$account=$this->tenant('accounts',$data['account_id']??0,$type==='savings'?'Akun simpanan customer':'Akun pendapatan');
|
||||
if(isset($account->is_active)&&!(int)$account->is_active||isset($account->allow_posting)&&!(int)$account->allow_posting)throw new BusinessException('Akun item tidak aktif atau tidak dapat diposting.');
|
||||
$counter=null;if($type==='service'&&(string)$account->tipe!=='revenue')throw new BusinessException('Jasa harus menggunakan akun pendapatan.');
|
||||
if($type==='savings'&&(string)$account->tipe!=='liability')throw new BusinessException('Tabungan harus menggunakan akun Passiva/Kewajiban.');
|
||||
$qty=$type==='savings'?1:$this->qty($data['qty']??0);$price=$this->money($data['unit_price']??0);$discount=$type==='savings'?0:max(0,$this->money($data['discount_amount']??0));if($discount>$qty*$price)throw new BusinessException('Diskon tidak boleh melebihi nilai bruto item.');
|
||||
$tax=$type==='service'?max(0,(float)str_replace(',','.',(string)($data['tax_rate']??0))):0;$net=max(0,$qty*$price-$discount);$taxAmount=round($net*$tax/100,2);$service=$data['service_date']??$line->service_date;if(!preg_match('/^\d{4}-\d{2}-\d{2}$/',(string)$service))throw new BusinessException('Tanggal transaksi item tidak valid.');
|
||||
$update=$this->columns('invoice_details',array('service_date'=>$service,'tanggal'=>$service,'account_id'=>$account->id,'counter_account_id'=>$counter?$counter->id:null,'nama_item'=>$description,'unit'=>trim((string)($data['unit']??$line->unit)),'qty'=>$qty,'harga'=>$price,'discount_amount'=>$discount,'tax_rate'=>$tax,'tax_amount'=>$taxAmount,'net_amount'=>$net,'subtotal'=>$net+$taxAmount,'subtotal_asli'=>$net+$taxAmount,'keterangan'=>trim((string)($data['notes']??''))));
|
||||
$lineJournalMode=true;$otherLines=$this->CI->db->select('id,line_type,subtotal,total_hpp_barang')->where(array('invoice_id'=>$invoice->id,'company_id'=>$this->company()))->where('id !=',$line->id)->get('invoice_details')->result();foreach($otherLines as$other)if($other->line_type!=='savings'&&((float)$other->subtotal>0||(float)$other->total_hpp_barang>0)){$otherJournal=$this->initialLineJournal($other->id);if(!$otherJournal||$otherJournal->is_reversed||$otherJournal->status!=='posted'){$lineJournalMode=false;break;}}
|
||||
$this->reverseInvoiceLineInitialJournal($line,$user,'Item Draft invoice '.$invoice->no_invoice.' diedit');
|
||||
$this->CI->db->where(array('id'=>$line->id,'invoice_id'=>$invoice->id,'company_id'=>$this->company()))->update('invoice_details',$update);$updated=$this->CI->db->get_where('invoice_details',array('id'=>$line->id,'company_id'=>$this->company()))->row();$customer=$this->tenant('customers',$invoice->customer_id,'Customer');if($lineJournalMode)$this->postInvoiceLineInitialJournal($invoice,$line->id,$updated,null,array(),$customer,$user);$this->refreshInvoiceTotals($invoice->id);$this->CI->auditservice->record('sales','invoice_detail',$line->id,'update',$line,$update,$user);return$line->id;
|
||||
});
|
||||
}
|
||||
|
||||
public function updateDelivery($id,array$data,array$attachments,$user)
|
||||
{
|
||||
return$this->CI->transactionservice->run(function()use($id,$data,$attachments,$user){$d=$this->CI->db->query('SELECT * FROM sales_deliveries WHERE id=? AND company_id=? FOR UPDATE',array((int)$id,$this->company()))->row();if(!$d||$d->status!=='draft')throw new BusinessException('Hanya Surat Jalan Draft yang dapat dilengkapi.');$date=$data['delivery_date']??$d->delivery_date;if(!preg_match('/^\d{4}-\d{2}-\d{2}$/',$date))throw new BusinessException('Tanggal pengiriman tidak valid.');$update=array('delivery_date'=>$date,'address'=>trim((string)($data['address']??'')),'driver_name'=>trim((string)($data['driver_name']??'')),'vehicle_no'=>trim((string)($data['vehicle_no']??'')),'courier'=>trim((string)($data['courier']??'')),'tracking_no'=>trim((string)($data['tracking_no']??'')),'recipient_name'=>trim((string)($data['recipient_name']??'')),'notes'=>trim((string)($data['notes']??'')),'version'=>(int)$d->version+1);$this->CI->db->where('id',$d->id)->update('sales_deliveries',$update);foreach($attachments as$a)$this->insertAttachment('sales_delivery',$d->id,$a,$user);$this->CI->auditservice->record('sales','sales_delivery',$d->id,'update',$d,$update,$user);return$d->id;});
|
||||
}
|
||||
|
||||
public function postDelivery($id,$user)
|
||||
{
|
||||
return$this->CI->transactionservice->run(function()use($id,$user){
|
||||
$d=$this->CI->db->query('SELECT * FROM sales_deliveries WHERE id=? AND company_id=? FOR UPDATE',array((int)$id,$this->company()))->row();
|
||||
if(!$d)throw new BusinessException('Surat Jalan tidak ditemukan.');if($d->status==='posted')return$d->id;if($d->status!=='draft')throw new BusinessException('Hanya Surat Jalan Draft yang dapat diposting.');
|
||||
$this->CI->fiscalperiodservice->assertOpen($d->delivery_date);$lines=$this->CI->db->where(array('sales_delivery_id'=>$d->id,'company_id'=>$this->company()))->order_by('line_no')->get('sales_delivery_lines')->result();if(!$lines)throw new BusinessException('Surat Jalan belum mempunyai barang.');
|
||||
$totalCost=0;$inventoryReclass=0;
|
||||
foreach($lines as$l){
|
||||
if($l->stock_posted_at)continue;$bars=$this->CI->db->where('sales_delivery_line_id',$l->id)->get('sales_delivery_barcodes')->result();$allocated=0;
|
||||
foreach($bars as$b){$barcode=$this->CI->db->query('SELECT * FROM item_barcodes WHERE id=? AND item_id=? AND warehouse_id=? FOR UPDATE',array($b->barcode_id,$l->item_id,$d->warehouse_id))->row();if(!$barcode||$barcode->status!=='available'||(float)$barcode->qty_sisa<(float)$b->qty-.0001||(float)$barcode->reserved_qty<(float)$b->qty-.0001)throw new BusinessException('Barcode pada Surat Jalan sudah tidak tersedia atau reservasinya tidak valid.');$left=(float)$barcode->qty_sisa-(float)$b->qty;$reserved=max(0,(float)$barcode->reserved_qty-(float)$b->qty);$this->CI->db->where(array('id'=>$barcode->id,'version'=>$barcode->version))->update('item_barcodes',array('qty_sisa'=>$left,'reserved_qty'=>$reserved,'status'=>$left<=.0001?'sold_out':'available','version'=>(int)$barcode->version+1));if($this->CI->db->affected_rows()!==1)throw new BusinessException('Barcode sedang diproses pengguna lain. Silakan ulangi.');$this->CI->db->where('id',$b->id)->update('sales_delivery_barcodes',array('status'=>'delivered'));$allocated+=(float)$b->qty;}
|
||||
if(abs($allocated-(float)$l->qty)>.0001)throw new BusinessException('Alokasi barcode Surat Jalan tidak sama dengan qty barang.');
|
||||
$detailBefore=$this->CI->db->get_where('invoice_details',array('id'=>$l->invoice_detail_id,'company_id'=>$this->company()))->row();$initialJournal=$detailBefore?$this->initialLineJournal($detailBefore->id):null;$hadInitialJournal=$initialJournal&&!$initialJournal->is_reversed&&$initialJournal->status==='posted';$previousCost=$detailBefore?(float)$detailBefore->total_hpp_barang:0;
|
||||
$cost=$this->consumeCost($l->item_id,$d->warehouse_id,$l->qty);$totalCost+=$cost;$inventoryReclass+=$hadInitialJournal?($cost-$previousCost):$cost;$key='SALES-DELIVERY-LINE-'.$l->id;$desc='[AUTO] Surat Jalan '.$d->delivery_no.' | '.$l->description.' | Qty '.number_format($l->qty,0,',','.');
|
||||
$ledger=array('item_id'=>$l->item_id,'warehouse_id'=>$d->warehouse_id,'movement_date'=>$d->delivery_date,'direction'=>'out','qty'=>$l->qty,'unit_cost'=>$l->qty>0?$cost/$l->qty:0,'value'=>$cost,'document_type'=>'sales_delivery','document_id'=>$d->id,'document_line_id'=>$l->id,'idempotency_key'=>$key,'created_at'=>date('Y-m-d H:i:s'));$ledger=$this->addCompany('inventory_ledger',$ledger);$this->CI->db->insert('inventory_ledger',$ledger);
|
||||
foreach($bars as$b){$log=$this->addCompany('stock_logs',array('item_id'=>$l->item_id,'warehouse_id'=>$d->warehouse_id,'barcode_id'=>$b->barcode_id,'qty'=>$b->qty,'tipe'=>'keluar','unit_cost'=>$l->qty>0?$cost/$l->qty:0,'keterangan'=>$desc,'ref_type'=>'sales_delivery','ref_id'=>$l->id,'idempotency_key'=>$key.'-B'.$b->id));$this->CI->db->insert('stock_logs',$log);$move=$this->addCompany('item_movements',array('item_id'=>$l->item_id,'barcode_id'=>$b->barcode_id,'qty'=>$b->qty,'from_type'=>'warehouse','from_id'=>$d->warehouse_id,'to_type'=>'customer','to_id'=>$d->customer_id,'movement_type'=>'sold_out','reference_type'=>'sales_delivery','reference_id'=>$l->id,'notes'=>$desc,'created_at'=>date('Y-m-d H:i:s')));$this->CI->db->insert('item_movements',$move);}
|
||||
$this->CI->salesInventoryModel->synchronizeStock($l->item_id);$this->CI->db->where('id',$l->id)->update('sales_delivery_lines',array('unit_cost'=>$l->qty>0?$cost/$l->qty:0,'total_cost'=>$cost,'stock_posted_at'=>date('Y-m-d H:i:s')));$this->CI->db->where('id',$l->invoice_detail_id)->update('invoice_details',array('total_hpp_barang'=>$cost,'stock_posted_at'=>date('Y-m-d H:i:s')));
|
||||
}
|
||||
$journal=null;if(abs($inventoryReclass)>.001){$amount=abs(round($inventoryReclass,2));$entries=$inventoryReclass>0?array(array('account_id'=>$this->CI->accountmappingservice->get('inventory_in_sales'),'debit'=>$amount,'kredit'=>0),array('account_id'=>$this->CI->accountmappingservice->get('inventory'),'debit'=>0,'kredit'=>$amount)):array(array('account_id'=>$this->CI->accountmappingservice->get('inventory'),'debit'=>$amount,'kredit'=>0),array('account_id'=>$this->CI->accountmappingservice->get('inventory_in_sales'),'debit'=>0,'kredit'=>$amount));$journal=$this->CI->postingservice->post(array('tanggal'=>$d->delivery_date,'no_ref'=>$this->CI->numberingservice->next('sales_delivery_journal',$d->delivery_date),'keterangan'=>'[AUTO] Penyesuaian biaya aktual pengiriman '.$d->delivery_no.' | Customer #'.$d->customer_id.' | Selisih Rp '.number_format($inventoryReclass,2,',','.'),'ref_type'=>'sales_delivery','ref_id'=>$d->id,'created_by'=>$user),$entries,false,true);}
|
||||
$this->CI->db->where('id',$d->id)->update('sales_deliveries',array('status'=>'posted','journal_id'=>$journal,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s'),'version'=>(int)$d->version+1));$this->updateInvoiceDeliveryStatus($d->invoice_id);$this->CI->auditservice->record('sales','sales_delivery',$d->id,'post',$d,array('journal_id'=>$journal,'cost'=>$totalCost),$user);return$d->id;
|
||||
});
|
||||
}
|
||||
private function consumeCost($item,$warehouse,$qty)
|
||||
{
|
||||
$remaining=(float)$qty;$cost=0;$layers=$this->CI->db->query("SELECT * FROM inventory_cost_layers WHERE item_id=? AND warehouse_id=? AND status='open' AND remaining_qty>0 ORDER BY received_date,id FOR UPDATE",array((int)$item,(int)$warehouse))->result();
|
||||
foreach($layers as$l){if($remaining<=.0001)break;$take=min($remaining,(float)$l->remaining_qty);$left=(float)$l->remaining_qty-$take;$cost+=$take*(float)$l->unit_cost;$this->CI->db->where('id',$l->id)->update('inventory_cost_layers',array('remaining_qty'=>$left,'status'=>$left<=.0001?'consumed':'open'));$remaining-=$take;}
|
||||
if($remaining>.0001)throw new BusinessException('Lapisan biaya persediaan tidak cukup untuk pengiriman.');return round($cost,2);
|
||||
}
|
||||
private function updateInvoiceDeliveryStatus($invoiceId)
|
||||
{
|
||||
if(!$invoiceId)return;$stockLines=(int)$this->CI->db->where(array('invoice_id'=>$invoiceId,'company_id'=>$this->company(),'line_type'=>'inventory'))->count_all_results('invoice_details');if(!$stockLines){$status='delivered';}else{$counts=$this->CI->db->select("SUM(status='posted') posted_count,COUNT(*) total_count",false)->where(array('invoice_id'=>$invoiceId,'company_id'=>$this->company()))->where_in('status',array('draft','posted','partially_returned','returned'))->get('sales_deliveries')->row();$status=!$counts||!$counts->posted_count?'not_delivered':($counts->posted_count==$counts->total_count?'delivered':'partial');}$this->CI->db->where(array('id'=>$invoiceId,'company_id'=>$this->company()))->update('invoices',array('delivery_status'=>$status));
|
||||
}
|
||||
|
||||
public function submitInvoice($id,$user)
|
||||
{
|
||||
return$this->CI->transactionservice->run(function()use($id,$user){$i=$this->lockInvoice($id,'draft');$this->assertDraftOwner($i,$user);$open=(int)$this->CI->db->where(array('invoice_id'=>$i->id,'company_id'=>$this->company(),'status'=>'draft'))->count_all_results('sales_deliveries');if($open)throw new BusinessException('Seluruh Surat Jalan Draft harus diposting sebelum invoice diajukan.');$this->refreshInvoiceTotals($i->id);$this->updateInvoiceDeliveryStatus($i->id);$i=$this->lockInvoice($id,'draft');if((float)$i->total<=0)throw new BusinessException('Invoice belum mempunyai nilai.');if($i->delivery_status!=='delivered')throw new BusinessException('Pengiriman barang pada invoice belum selesai.');$this->CI->approvalservice->create('invoice','invoice',$i->id,$i->total,$user,array('invoice_no'=>$i->no_invoice,'customer_id'=>$i->customer_id));$this->CI->db->where('id',$i->id)->update('invoices',array('workflow_status'=>'submitted','finalized_at'=>date('Y-m-d H:i:s'),'finalized_by'=>$user,'submitted_at'=>date('Y-m-d H:i:s'),'submitted_by'=>$user));$this->CI->auditservice->record('sales','invoice',$i->id,'submit',$i,array('status'=>'submitted'),$user);return$i->id;});
|
||||
}
|
||||
public function rejectInvoice($id,$reason,$user,$roleId)
|
||||
{
|
||||
return$this->CI->transactionservice->run(function()use($id,$reason,$user,$roleId){$i=$this->lockInvoice($id,'submitted');if(trim($reason)==='')throw new BusinessException('Alasan penolakan wajib diisi.');$request=$this->CI->approvalservice->pendingForEntity('invoice',$i->id);if($request)$this->CI->approvalservice->act($request->id,'rejected',$user,$roleId,trim($reason));$this->CI->db->where('id',$i->id)->update('invoices',array('workflow_status'=>'draft','rejected_by'=>$user,'rejected_at'=>date('Y-m-d H:i:s'),'rejection_reason'=>trim($reason),'finalized_at'=>null,'finalized_by'=>null));$this->CI->auditservice->record('sales','invoice',$i->id,'reject',$i,array('status'=>'draft','reason'=>$reason),$user);return$i->id;});
|
||||
}
|
||||
public function approveAndPostInvoice($id,$user,$roleId)
|
||||
{
|
||||
return$this->CI->transactionservice->run(function()use($id,$user,$roleId){$i=$this->lockInvoice($id,'submitted');$request=$this->CI->approvalservice->pendingForEntity('invoice',$i->id);if(!$request)throw new BusinessException('Permintaan approval invoice tidak ditemukan.');$approval=$this->CI->approvalservice->act($request->id,'approved',$user,$roleId);if($approval->status!=='approved')throw new BusinessException('Tahap approval invoice belum lengkap.');$this->CI->db->where('id',$i->id)->update('invoices',array('workflow_status'=>'approved','approved_by'=>$user,'approved_at'=>date('Y-m-d H:i:s')));return$this->postInvoiceInternal($i->id,$user);});
|
||||
}
|
||||
private function postInvoiceInternal($id,$user)
|
||||
{
|
||||
$i=$this->lockInvoice($id,'approved');$this->CI->fiscalperiodservice->assertOpen($i->tanggal);if($i->journal_id)throw new BusinessException('Invoice sudah pernah diposting.');if($i->delivery_status!=='delivered')throw new BusinessException('Invoice hanya dapat diposting setelah seluruh Surat Jalan diposting.');$lines=$this->CI->db->where(array('invoice_id'=>$i->id,'company_id'=>$this->company()))->get('invoice_details')->result();if(!$lines)throw new BusinessException('Invoice belum mempunyai item.');
|
||||
$family=$this->lineFamily($lines[0]->line_type);$entries=array();$cogs=0;$revenueAmount=0;
|
||||
if($family==='financial'&&$lines[0]->line_type==='savings'){
|
||||
$this->CI->db->where('id',$i->id)->update('invoices',array('workflow_status'=>'posted','posted'=>1,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s'),'journal_id'=>null,'status'=>'unpaid','sisa_piutang'=>$i->total,'recognized_revenue'=>0,'recognized_cogs'=>0,'recognition_policy'=>'on_payment'));
|
||||
$this->CI->auditservice->record('sales','invoice',$i->id,'post',$i,array('journal_id'=>null,'purpose'=>'Tabungan; jurnal dibentuk saat pembayaran'),$user);return$i->id;
|
||||
}
|
||||
if($family==='sales'){
|
||||
$initialJournals=array();$journalizedLines=0;foreach($lines as$l){if((float)$l->subtotal<=0&&(float)$l->total_hpp_barang<=0){$journalizedLines++;continue;}$lineJournal=$this->initialLineJournal($l->id);if($lineJournal&&!$lineJournal->is_reversed&&$lineJournal->status==='posted'){$initialJournals[]=$lineJournal;$journalizedLines++;}}
|
||||
if($journalizedLines===count($lines)&&$initialJournals){
|
||||
foreach($lines as$l){$revenueAmount+=(float)$l->net_amount;$cogs+=(float)$l->total_hpp_barang;}
|
||||
$journal=(int)$initialJournals[0]->id;$this->CI->db->where('id',$i->id)->update('invoices',array('workflow_status'=>'posted','posted'=>1,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s'),'journal_id'=>$journal,'status'=>'unpaid','sisa_piutang'=>$i->total,'recognized_revenue'=>0,'recognized_cogs'=>0,'recognition_policy'=>'on_payment'));
|
||||
$this->CI->load->library('TaxService');$this->CI->taxservice->invoice($i->id,$journal);$this->CI->auditservice->record('sales','invoice',$i->id,'post',$i,array('journal_id'=>$journal,'purpose'=>'Pendapatan diakui proporsional saat pembayaran','line_journal_count'=>count($initialJournals)),$user);return$i->id;
|
||||
}
|
||||
}
|
||||
if($family==='financial'){
|
||||
$debits=array();$credits=array();foreach($lines as$l){$value=(float)$l->subtotal;if($l->line_type==='loan'){$debits[(int)$l->account_id]=($debits[(int)$l->account_id]??0)+$value;$credits[(int)$l->counter_account_id]=($credits[(int)$l->counter_account_id]??0)+$value;}else{$ar=$this->CI->accountmappingservice->get('accounts_receivable');$debits[$ar]=($debits[$ar]??0)+$value;$credits[(int)$l->account_id]=($credits[(int)$l->account_id]??0)+$value;}}
|
||||
foreach($debits as$a=>$v)if($v>0)$entries[]=array('account_id'=>$a,'debit'=>$v,'kredit'=>0);foreach($credits as$a=>$v)if($v>0)$entries[]=array('account_id'=>$a,'debit'=>0,'kredit'=>$v);
|
||||
}else{
|
||||
$receivable=$this->CI->accountmappingservice->get('accounts_receivable');$deferred=$this->CI->accountmappingservice->get('deferred_revenue');$entries[]=array('account_id'=>$receivable,'debit'=>$i->total,'kredit'=>0);$revenueCredits=array();
|
||||
foreach($lines as$l){$revenueCredits[(int)$l->account_id]=($revenueCredits[(int)$l->account_id]??0)+(float)$l->net_amount;$revenueAmount+=(float)$l->net_amount;if((float)$l->tax_amount>0){$tax=$this->CI->accountmappingservice->get('sales_tax_payable');$entries[]=array('account_id'=>$tax,'debit'=>0,'kredit'=>(float)$l->tax_amount);}$cogs+=(float)$l->total_hpp_barang;}
|
||||
if($revenueAmount>0)$entries[]=array('account_id'=>$deferred,'debit'=>0,'kredit'=>$revenueAmount);
|
||||
if($i->recognition_policy==='accrual'&&$revenueAmount>0){$entries[]=array('account_id'=>$deferred,'debit'=>$revenueAmount,'kredit'=>0);foreach($revenueCredits as$a=>$v)if($v>0)$entries[]=array('account_id'=>$a,'debit'=>0,'kredit'=>$v);}
|
||||
if($i->recognition_policy==='accrual'&&$cogs>0){$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('cost_of_goods_sold'),'debit'=>$cogs,'kredit'=>0);$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('inventory_in_sales'),'debit'=>0,'kredit'=>$cogs);}
|
||||
}
|
||||
$customer=$this->CI->db->select('nama')->get_where('customers',array('id'=>$i->customer_id,'company_id'=>$this->company()))->row();$purpose=$family==='financial'?($lines[0]->line_type==='loan'?'Tagihan Pinjaman':'Tagihan Tabungan'):($i->recognition_policy==='accrual'?'Pendapatan diakui saat posting':'Pendapatan diakui proporsional setelah pembayaran');$journalDescription='[AUTO] Posting Invoice '.$i->no_invoice.' | Customer '.($customer?$customer->nama:'#'.$i->customer_id).' | Tujuan '.$purpose.' | Total Rp '.number_format($i->total,2,',','.').' | Referensi invoice #'.$i->id;
|
||||
$journal=$this->CI->postingservice->post(array('tanggal'=>$i->tanggal,'no_ref'=>$this->CI->numberingservice->next('invoice_journal',$i->tanggal),'keterangan'=>$journalDescription,'ref_type'=>'invoice','ref_id'=>$i->id,'created_by'=>$user),$entries,false,true);
|
||||
if($family==='financial'&&$lines[0]->line_type==='loan')foreach($credits as$accountId=>$value)$this->recordCashSubledger($accountId,$i->tanggal,'out',$value,'customer',$i->customer_id,'invoice_loan',$i->id,$journal,$journalDescription,$user);
|
||||
if($family==='sales'&&$i->recognition_policy==='accrual'){$this->CI->db->insert('revenue_recognition_ledger',array('company_id'=>$this->company(),'invoice_id'=>$i->id,'recognition_date'=>$i->tanggal,'revenue_amount'=>$revenueAmount,'cogs_amount'=>$cogs,'journal_id'=>$journal,'idempotency_key'=>'INVOICE-ACCRUAL-'.$i->id,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s')));}
|
||||
$this->CI->db->where('id',$i->id)->update('invoices',array('workflow_status'=>'posted','posted'=>1,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s'),'journal_id'=>$journal,'status'=>'unpaid','sisa_piutang'=>$i->total,'recognized_revenue'=>$family==='sales'&&$i->recognition_policy==='accrual'?$revenueAmount:0,'recognized_cogs'=>$family==='sales'&&$i->recognition_policy==='accrual'?$cogs:0));if($family==='sales'){$this->CI->load->library('TaxService');$this->CI->taxservice->invoice($i->id,$journal);}$this->CI->auditservice->record('sales','invoice',$i->id,'post',$i,array('journal_id'=>$journal,'purpose'=>$purpose),$user);return$i->id;
|
||||
}
|
||||
public function recordPayment($customerId,$date,array$sources,array$allocations,$allowAdvance,$user,$reference='',$notes='',array$attachments=array())
|
||||
{
|
||||
return $this->CI->transactionservice->run(function()use($customerId,$date,$sources,$allocations,$allowAdvance,$user,$reference,$notes,$attachments){
|
||||
$customer=$this->tenant('customers',$customerId,'Customer');
|
||||
$this->CI->fiscalperiodservice->assertOpen($date);
|
||||
$sourceTotal=0;$entries=array();$sourceRows=array();$seenSources=array();
|
||||
foreach($sources as$n=>$source){
|
||||
$amount=$this->money($source['amount']??0);if($amount<=0)continue;
|
||||
$account=$this->tenant('accounts',$source['account_id']??0,'Akun penerimaan/pelunasan ke-'.($n+1));
|
||||
if(isset($seenSources[$account->id]))throw new BusinessException('Akun penerimaan/pelunasan yang sama cukup dipilih satu kali.');$seenSources[$account->id]=true;
|
||||
if(!in_array((string)$account->tipe,array('asset','liability'),true))throw new BusinessException('Sumber pelunasan harus menggunakan akun Aktiva atau Passiva.');if(isset($account->allow_posting)&&!(int)$account->allow_posting)throw new BusinessException('Akun '.$account->nama_akun.' tidak dapat diposting.');if(isset($account->is_hidden)&&(int)$account->is_hidden===1&&!(function_exists('is_master_admin_user')&&is_master_admin_user()))throw new BusinessException('Akun '.$account->nama_akun.' tidak dapat digunakan oleh pengguna ini.');
|
||||
$sourceTotal+=$amount;$sourceRows[]=array('account_id'=>$account->id,'amount'=>$amount);$entries[]=array('account_id'=>$account->id,'debit'=>$amount,'kredit'=>0);
|
||||
}
|
||||
if($sourceTotal<=0)throw new BusinessException('Tambahkan minimal satu akun penerimaan/pelunasan Aktiva atau Passiva.');
|
||||
$allocated=0;$locked=array();
|
||||
foreach($allocations as$invoiceId=>$raw){
|
||||
$amount=$this->money($raw);if($amount<=0)continue;
|
||||
$invoice=$this->CI->db->query('SELECT * FROM invoices WHERE id=? AND company_id=? FOR UPDATE',array((int)$invoiceId,$this->company()))->row();
|
||||
if(!$invoice||$invoice->workflow_status!=='posted'||(int)$invoice->customer_id!==(int)$customer->id)throw new BusinessException('Invoice alokasi tidak valid.');
|
||||
if($amount>(float)$invoice->sisa_piutang+.001)throw new BusinessException('Alokasi melebihi sisa invoice '.$invoice->no_invoice.'.');
|
||||
$allocated+=$amount;$locked[]=array($invoice,$amount);
|
||||
}
|
||||
if($allocated>$sourceTotal+.001)throw new BusinessException('Total alokasi invoice melebihi sumber pembayaran.');
|
||||
$advance=round($sourceTotal-$allocated,2);if($advance>.001&&!$allowAdvance)throw new BusinessException('Terdapat kelebihan pembayaran. Aktifkan pencatatan uang muka pelanggan.');
|
||||
$settlementCredits=array();foreach($locked as$row)foreach($this->paymentReceivableDistribution($row[0],$row[1])as$accountId=>$value)$settlementCredits[$accountId]=($settlementCredits[$accountId]??0)+$value;foreach($sourceRows as$s)if(isset($settlementCredits[$s['account_id']]))throw new BusinessException('Akun penerimaan/pelunasan tidak boleh sama dengan akun piutang yang sedang dilunasi.');foreach($settlementCredits as$accountId=>$value)if($value>0)$entries[]=array('account_id'=>$accountId,'debit'=>0,'kredit'=>$value);
|
||||
if($advance>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('customer_advances'),'debit'=>0,'kredit'=>$advance);
|
||||
$paymentNo=$this->CI->numberingservice->next('payment',$date);
|
||||
$invoiceNames=array_map(function($x){return$x[0]->no_invoice.' Rp '.number_format($x[1],2,',','.');},$locked);
|
||||
$savingsPayment=!empty($locked);foreach($locked as$row){$firstLine=$this->CI->db->select('line_type')->where(array('invoice_id'=>$row[0]->id,'company_id'=>$this->company()))->order_by('id')->limit(1)->get('invoice_details')->row();if(!$firstLine||$firstLine->line_type!=='savings'){$savingsPayment=false;break;}}
|
||||
$paymentDescription=$savingsPayment?"Tabungan '".$customer->nama."' - '".(trim((string)$notes)?:'-')."'":'[AUTO] Penerimaan '.$paymentNo.' | Customer '.$customer->nama.' | Alokasi '.implode(', ',$invoiceNames).' | Referensi '.($reference?:'-').' | Total Rp '.number_format($sourceTotal,2,',','.');
|
||||
$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$paymentNo,'keterangan'=>$paymentDescription,'ref_type'=>'customer_payment','ref_id'=>null,'created_by'=>$user),$entries,false,false);
|
||||
$payment=$this->columns('payments',array('company_id'=>$this->company(),'invoice_id'=>count($locked)===1?$locked[0][0]->id:null,'tanggal'=>$date,'jumlah'=>$sourceTotal,'journal_id'=>$journal,'metode'=>count($sourceRows)>1?'multi_account':'manual','payment_no'=>$paymentNo,'customer_id'=>$customer->id,'status'=>'posted','reference_no'=>$reference,'notes'=>$notes,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s')));
|
||||
$this->CI->db->insert('payments',$payment);$paymentId=(int)$this->CI->db->insert_id();
|
||||
foreach($sourceRows as$s){$this->CI->db->insert('payment_sources',array('company_id'=>$this->company(),'payment_id'=>$paymentId,'account_id'=>$s['account_id'],'amount'=>$s['amount'],'created_at'=>date('Y-m-d H:i:s')));$this->recordCashSubledger($s['account_id'],$date,'in',$s['amount'],'customer',$customer->id,'customer_payment',$paymentId,$journal,'Penerimaan '.$paymentNo.' dari '.$customer->nama,$user);}
|
||||
foreach($locked as$row){
|
||||
list($invoice,$amount)=$row;$this->CI->db->insert('payment_allocations',$this->columns('payment_allocations',array('company_id'=>$this->company(),'payment_id'=>$paymentId,'invoice_id'=>$invoice->id,'amount'=>$amount,'created_at'=>date('Y-m-d H:i:s'))));
|
||||
$paid=round((float)$invoice->total_bayar+$amount,2);$balance=max(0,round((float)$invoice->sisa_piutang-$amount,2));
|
||||
$this->CI->db->where(array('id'=>$invoice->id,'company_id'=>$this->company()))->update('invoices',array('total_bayar'=>$paid,'sisa_piutang'=>$balance,'status'=>$balance<=.001?'paid':'partial'));
|
||||
if($this->invoiceFamily($invoice->id)==='sales')$this->recognizeOnPayment($invoice,$paymentId,$amount,$date,$user);
|
||||
}
|
||||
if($advance>0)$this->CI->db->insert('customer_advances',$this->columns('customer_advances',array('company_id'=>$this->company(),'advance_no'=>'ADV-'.$paymentNo,'customer_id'=>$customer->id,'payment_id'=>$paymentId,'original_amount'=>$advance,'available_amount'=>$advance,'status'=>'available','journal_id'=>$journal,'created_at'=>date('Y-m-d H:i:s'))));
|
||||
foreach($attachments as$a)$this->insertAttachment('customer_payment',$paymentId,$a,$user);
|
||||
$this->CI->db->where('id',$journal)->update('journals',array('ref_id'=>$paymentId));
|
||||
$this->CI->auditservice->record('sales','payment',$paymentId,'post',null,array('payment_no'=>$paymentNo,'amount'=>$sourceTotal,'allocated'=>$allocated,'advance'=>$advance),$user);
|
||||
return array('payment_id'=>$paymentId,'payment_no'=>$paymentNo,'advance'=>$advance);
|
||||
});
|
||||
}
|
||||
private function paymentReceivableDistribution($invoice,$amount)
|
||||
{
|
||||
$lines=$this->CI->db->select('line_type,account_id,subtotal')->where(array('invoice_id'=>$invoice->id,'company_id'=>$this->company()))->get('invoice_details')->result();$weights=array();
|
||||
foreach($lines as$l){$account=in_array($l->line_type,array('loan','savings'),true)?(int)$l->account_id:$this->CI->accountmappingservice->get('accounts_receivable');$weights[$account]=($weights[$account]??0)+(float)$l->subtotal;}
|
||||
$total=array_sum($weights);if($total<=0)throw new BusinessException('Komposisi akun piutang invoice tidak valid.');$result=array();$used=0;$accounts=array_keys($weights);$last=end($accounts);
|
||||
foreach($weights as$account=>$weight){$part=(int)$account===(int)$last?round($amount-$used,2):round($amount*$weight/$total,2);$used+=$part;if($part>0)$result[(int)$account]=$part;}
|
||||
return$result;
|
||||
}
|
||||
|
||||
private function recordCashSubledger($glAccountId,$date,$direction,$amount,$partyType,$partyId,$sourceType,$sourceId,$journalId,$description,$user)
|
||||
{
|
||||
if(!$this->CI->db->table_exists('cash_transactions'))return;$cash=$this->CI->db->get_where('cash_accounts',array('company_id'=>$this->company(),'gl_account_id'=>(int)$glAccountId,'is_active'=>1))->row();if(!$cash)return;
|
||||
$key='C'.$this->company().'-'.strtoupper($sourceType).'-'.(int)$sourceId.'-'.(int)$cash->id.'-'.$direction;if($this->CI->db->where('idempotency_key',$key)->count_all_results('cash_transactions'))return;
|
||||
$row=$this->columns('cash_transactions',array('company_id'=>$this->company(),'transaction_no'=>$this->CI->numberingservice->next('cash_transaction',$date),'transaction_date'=>$date,'cash_account_id'=>$cash->id,'transaction_type'=>$direction==='in'?'receipt':'payment','direction'=>$direction,'amount'=>$amount,'counter_account_id'=>null,'party_type'=>$partyType,'party_id'=>$partyId,'reference_no'=>null,'description'=>'[AUTO] '.$description,'status'=>'posted','requires_approval'=>0,'source_type'=>$sourceType,'source_id'=>$sourceId,'journal_id'=>$journalId,'idempotency_key'=>$key,'created_by'=>$user,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s'),'created_at'=>date('Y-m-d H:i:s')));$this->CI->db->insert('cash_transactions',$row);
|
||||
}
|
||||
|
||||
private function invoiceFamily($invoiceId)
|
||||
{
|
||||
$line=$this->CI->db->select('line_type')->where(array('invoice_id'=>(int)$invoiceId,'company_id'=>$this->company()))->order_by('id')->limit(1)->get('invoice_details')->row();return$this->lineFamily($line?$line->line_type:'inventory');
|
||||
}
|
||||
|
||||
private function lineFamily($type){return in_array($type,array('loan','savings'),true)?'financial':'sales';}
|
||||
|
||||
private function recognizeOnPayment($invoice,$paymentId,$allocated,$date,$user)
|
||||
{
|
||||
$netTotal=max(.01,(float)$invoice->subtotal_before_tax);$gross=max(.01,(float)$invoice->total);
|
||||
$remainingRevenue=max(0,$netTotal-(float)$invoice->recognized_revenue);$revenue=min($remainingRevenue,round($allocated*($netTotal/$gross),2));
|
||||
$lines=$this->CI->db->where(array('invoice_id'=>$invoice->id,'company_id'=>$this->company()))->get('invoice_details')->result();
|
||||
$allCost=0;foreach($lines as$l)$allCost+=(float)$l->total_hpp_barang;$remainingCost=max(0,$allCost-(float)$invoice->recognized_cogs);$cost=min($remainingCost,round($allocated*($allCost/$gross),2));
|
||||
if($revenue<=0&&$cost<=0)return;
|
||||
$entries=array();if($revenue>0){$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('deferred_revenue'),'debit'=>$revenue,'kredit'=>0);$left=$revenue;foreach($lines as$idx=>$l){$part=$idx===count($lines)-1?$left:round($revenue*((float)$l->net_amount/$netTotal),2);$left-=$part;if($part>0)$entries[]=array('account_id'=>$l->account_id,'debit'=>0,'kredit'=>$part);}}
|
||||
if($cost>0){$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('cost_of_goods_sold'),'debit'=>$cost,'kredit'=>0);$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('inventory_in_sales'),'debit'=>0,'kredit'=>$cost);}
|
||||
$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$this->CI->numberingservice->next('revenue_recognition',$date),'keterangan'=>'[AUTO] Pengakuan pendapatan setelah pembayaran | Invoice '.$invoice->no_invoice.' | Pembayaran #'.$paymentId.' | Pendapatan Rp '.number_format($revenue,2,',','.').' | HPP Rp '.number_format($cost,2,',','.'),'ref_type'=>'revenue_recognition','ref_id'=>$paymentId,'created_by'=>$user),$entries,false,false);
|
||||
$this->CI->db->insert('revenue_recognition_ledger',array('company_id'=>$this->company(),'invoice_id'=>$invoice->id,'payment_id'=>$paymentId,'recognition_date'=>$date,'revenue_amount'=>$revenue,'cogs_amount'=>$cost,'journal_id'=>$journal,'idempotency_key'=>'PAYMENT-RECOGNITION-'.$paymentId.'-'.$invoice->id,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s')));
|
||||
$this->CI->db->where(array('id'=>$invoice->id,'company_id'=>$this->company()))->set('recognized_revenue','recognized_revenue+'.(float)$revenue,false)->set('recognized_cogs','recognized_cogs+'.(float)$cost,false)->update('invoices');
|
||||
}
|
||||
public function invoiceDetail($id)
|
||||
{
|
||||
$invoice=$this->CI->db->select('i.*,c.nama customer_name,c.alamat customer_address,u.nama creator_name,ua.nama approver_name')->from('invoices i')->join('customers c','c.id=i.customer_id')->join('users u','u.id=i.created_by','left')->join('users ua','ua.id=i.approved_by','left')->where(array('i.id'=>(int)$id,'i.company_id'=>$this->company()))->get()->row();if(!$invoice)throw new BusinessException('Invoice tidak ditemukan.');
|
||||
$lines=$this->CI->db->select('d.*,it.kode_detail item_code,a.kode_akun,a.nama_akun')->from('invoice_details d')->join('items it','it.id=d.items_id','left')->join('accounts a','a.id=d.account_id','left')->where(array('d.invoice_id'=>$invoice->id,'d.company_id'=>$this->company()))->order_by('d.service_date,d.id')->get()->result();
|
||||
$barcodes=$this->CI->db->select('b.*,ib.barcode,ib.serial_number,sd.delivery_no,w.nama warehouse_name,it.kode_detail item_code,it.nama_barang,d.service_date')->from('invoice_line_barcodes b')->join('invoice_details d','d.id=b.invoice_detail_id')->join('item_barcodes ib','ib.id=b.barcode_id')->join('sales_deliveries sd','sd.id=b.sales_delivery_id','left')->join('warehouses w','w.id=ib.warehouse_id','left')->join('items it','it.id=ib.item_id','left')->where(array('b.invoice_id'=>$invoice->id,'b.company_id'=>$this->company()))->order_by('b.id')->get()->result();
|
||||
$payments=$this->CI->db->select('p.*,pa.amount allocated')->from('payment_allocations pa')->join('payments p','p.id=pa.payment_id')->where('pa.invoice_id',$invoice->id)->order_by('p.tanggal,p.id')->get()->result();foreach($payments as$p)$p->attachments=$this->attachments(array('customer_payment'),$p->id);
|
||||
return array('invoice'=>$invoice,'lines'=>$lines,'barcodes'=>$barcodes,'deliveries'=>$this->CI->db->where(array('invoice_id'=>$invoice->id,'company_id'=>$this->company()))->get('sales_deliveries')->result(),'payments'=>$payments,'returns'=>$this->CI->db->where(array('invoice_id'=>$invoice->id,'company_id'=>$this->company()))->get('sales_returns')->result(),'attachments'=>$this->attachments(array('invoice'),$invoice->id));
|
||||
}
|
||||
public function deliveryDetail($id)
|
||||
{
|
||||
$delivery=$this->CI->db->select('d.*,c.nama customer_name,c.alamat customer_address,i.no_invoice,w.nama warehouse_name,u.nama creator_name')->from('sales_deliveries d')->join('customers c','c.id=d.customer_id')->join('invoices i','i.id=d.invoice_id','left')->join('warehouses w','w.id=d.warehouse_id','left')->join('users u','u.id=d.created_by','left')->where(array('d.id'=>(int)$id,'d.company_id'=>$this->company()))->get()->row();if(!$delivery)throw new BusinessException('Surat Jalan tidak ditemukan.');
|
||||
return array('delivery'=>$delivery,'lines'=>$this->CI->db->where(array('sales_delivery_id'=>$delivery->id,'company_id'=>$this->company()))->order_by('line_no')->get('sales_delivery_lines')->result(),'barcodes'=>$this->CI->db->select('b.*,ib.barcode,ib.serial_number')->from('sales_delivery_barcodes b')->join('item_barcodes ib','ib.id=b.barcode_id')->where(array('b.sales_delivery_id'=>$delivery->id,'b.company_id'=>$this->company()))->order_by('b.id')->get()->result(),'attachments'=>$this->attachments(array('sales_delivery'),$delivery->id));
|
||||
}
|
||||
public function createReturn(array$header,array$rows,$user,array$attachments=array())
|
||||
{
|
||||
return$this->CI->transactionservice->run(function()use($header,$rows,$user,$attachments){
|
||||
$delivery=$this->CI->db->query('SELECT * FROM sales_deliveries WHERE id=? AND company_id=? FOR UPDATE',array((int)($header['sales_delivery_id']??0),$this->company()))->row();if(!$delivery||!in_array($delivery->status,array('posted','partially_returned'),true))throw new BusinessException('Surat Jalan tidak valid untuk retur.');
|
||||
if(trim((string)($header['reason']??''))==='')throw new BusinessException('Alasan retur wajib diisi.');if(!$rows)throw new BusinessException('Pilih minimal satu barcode yang diretur.');
|
||||
$resolution=in_array($header['resolution']??'',array('restock','quarantine','damaged','replacement','credit_note','refund'),true)?$header['resolution']:'credit_note';$date=$header['return_date']??date('Y-m-d');
|
||||
$this->CI->db->insert('sales_returns',array('company_id'=>$this->company(),'return_no'=>$this->CI->numberingservice->next('sales_return',$date),'customer_id'=>$delivery->customer_id,'invoice_id'=>$delivery->invoice_id,'sales_delivery_id'=>$delivery->id,'return_date'=>$date,'reason'=>trim($header['reason']),'problem_category'=>trim((string)($header['problem_category']??'')),'resolution'=>$resolution,'status'=>'draft','notes'=>trim((string)($header['notes']??'')),'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s')));$returnId=(int)$this->CI->db->insert_id();
|
||||
foreach($rows as$row){$deliveryBarcode=$this->CI->db->query('SELECT b.*,l.invoice_detail_id,l.unit_cost FROM sales_delivery_barcodes b JOIN sales_delivery_lines l ON l.id=b.sales_delivery_line_id WHERE b.id=? AND b.sales_delivery_id=? AND b.company_id=? FOR UPDATE',array((int)($row['sales_delivery_barcode_id']??0),$delivery->id,$this->company()))->row();$qty=$this->qty($row['qty']??0);if(!$deliveryBarcode||$qty>(float)$deliveryBarcode->qty-(float)$deliveryBarcode->returned_qty+.0001)throw new BusinessException('Qty retur melebihi barang yang pernah dikirim.');$detail=$this->CI->db->get_where('invoice_details',array('id'=>$deliveryBarcode->invoice_detail_id,'company_id'=>$this->company()))->row();$ratio=$qty/max(.0001,(float)$detail->qty);$condition=in_array($row['physical_condition']??'',array('saleable','quarantine','damaged','lost'),true)?$row['physical_condition']:'saleable';$this->CI->db->insert('sales_return_lines',array('company_id'=>$this->company(),'sales_return_id'=>$returnId,'sales_delivery_line_id'=>$deliveryBarcode->sales_delivery_line_id,'invoice_detail_id'=>$detail->id,'item_id'=>$deliveryBarcode->item_id,'qty'=>$qty,'unit_price'=>$detail->harga,'net_amount'=>round($detail->net_amount*$ratio,2),'tax_amount'=>round($detail->tax_amount*$ratio,2),'unit_cost'=>$deliveryBarcode->unit_cost,'physical_condition'=>$condition,'notes'=>trim((string)($row['notes']??'')),'created_at'=>date('Y-m-d H:i:s')));$lineId=(int)$this->CI->db->insert_id();$this->CI->db->insert('sales_return_barcodes',array('company_id'=>$this->company(),'sales_return_id'=>$returnId,'sales_return_line_id'=>$lineId,'sales_delivery_barcode_id'=>$deliveryBarcode->id,'barcode_id'=>$deliveryBarcode->barcode_id,'qty'=>$qty,'status'=>'requested','created_at'=>date('Y-m-d H:i:s')));}
|
||||
foreach($attachments as$a)$this->insertAttachment('sales_return',$returnId,$a,$user);$this->returnHistory($returnId,null,'draft','Retur dibuat',$user);return$returnId;
|
||||
});
|
||||
}
|
||||
public function submitReturn($id,$user){return$this->returnState($id,'draft','submitted',$user,'Retur diajukan.');}
|
||||
public function approveReturn($id,$user){return$this->returnState($id,'submitted','approved',$user,'Retur disetujui.');}
|
||||
public function rejectReturn($id,$reason,$user){if(trim($reason)==='')throw new BusinessException('Alasan penolakan wajib diisi.');return$this->returnState($id,'submitted','rejected',$user,$reason);}
|
||||
public function receiveReturn($id,$user){return$this->returnState($id,'approved','received',$user,'Barang retur diterima dan diperiksa.');}
|
||||
private function returnState($id,$from,$to,$user,$notes){return$this->CI->transactionservice->run(function()use($id,$from,$to,$user,$notes){$r=$this->lockReturn($id,$from);$data=array('status'=>$to);$data[$to.'_by']=$user;$data[$to.'_at']=date('Y-m-d H:i:s');if($to==='rejected')$data['rejection_reason']=$notes;$this->CI->db->where('id',$r->id)->update('sales_returns',$data);$this->returnHistory($r->id,$from,$to,$notes,$user);return$r->id;});}
|
||||
public function postReturn($id,$user)
|
||||
{
|
||||
return$this->CI->transactionservice->run(function()use($id,$user){$r=$this->lockReturn($id,'received');$this->CI->fiscalperiodservice->assertOpen($r->return_date);$lines=$this->CI->db->where(array('sales_return_id'=>$r->id,'company_id'=>$this->company()))->get('sales_return_lines')->result();$net=0;$tax=0;$cost=0;
|
||||
foreach($lines as$l){$bars=$this->CI->db->where('sales_return_line_id',$l->id)->get('sales_return_barcodes')->result();foreach($bars as$b){$source=$this->CI->db->query('SELECT * FROM sales_delivery_barcodes WHERE id=? FOR UPDATE',array($b->sales_delivery_barcode_id))->row();if(!$source||(float)$source->returned_qty+(float)$b->qty>(float)$source->qty+.0001)throw new BusinessException('Barcode retur sudah diproses atau qty tidak valid.');$barcode=$this->CI->db->query('SELECT * FROM item_barcodes WHERE id=? FOR UPDATE',array($b->barcode_id))->row();$status=$l->physical_condition==='saleable'?'available':($l->physical_condition==='damaged'?'damaged':'quarantine');$this->CI->db->where('id',$barcode->id)->update('item_barcodes',array('qty_sisa'=>(float)$barcode->qty_sisa+(float)$b->qty,'status'=>$status,'version'=>(int)$barcode->version+1));$this->CI->db->where('id',$source->id)->update('sales_delivery_barcodes',array('returned_qty'=>(float)$source->returned_qty+(float)$b->qty,'status'=>(float)$source->returned_qty+(float)$b->qty>=(float)$source->qty?'returned':'partially_returned'));$this->CI->db->where('id',$b->id)->update('sales_return_barcodes',array('status'=>'posted'));}
|
||||
$value=round((float)$l->unit_cost*(float)$l->qty,2);$key='SALES-RETURN-LINE-'.$l->id;$warehouse=$this->CI->db->select('warehouse_id')->get_where('sales_deliveries',array('id'=>$r->sales_delivery_id))->row()->warehouse_id;$ledger=$this->addCompany('inventory_ledger',array('item_id'=>$l->item_id,'warehouse_id'=>$warehouse,'movement_date'=>$r->return_date,'direction'=>'in','qty'=>$l->qty,'unit_cost'=>$l->unit_cost,'value'=>$value,'document_type'=>'sales_return','document_id'=>$r->id,'document_line_id'=>$l->id,'idempotency_key'=>$key,'created_at'=>date('Y-m-d H:i:s')));$this->CI->db->insert('inventory_ledger',$ledger);if($l->physical_condition==='saleable')$this->CI->db->insert('inventory_cost_layers',$this->columns('inventory_cost_layers',array('company_id'=>$this->company(),'item_id'=>$l->item_id,'warehouse_id'=>$warehouse,'source_ledger_id'=>$this->CI->db->insert_id(),'received_date'=>$r->return_date,'original_qty'=>$l->qty,'remaining_qty'=>$l->qty,'unit_cost'=>$l->unit_cost,'status'=>'open')));$this->CI->db->insert('stock_logs',$this->addCompany('stock_logs',array('item_id'=>$l->item_id,'warehouse_id'=>$warehouse,'qty'=>$l->qty,'tipe'=>'masuk','unit_cost'=>$l->unit_cost,'keterangan'=>'[AUTO] Retur Penjualan '.$r->return_no.' | Kondisi '.$l->physical_condition,'ref_type'=>'sales_return','ref_id'=>$l->id,'idempotency_key'=>$key)));$this->CI->db->insert('item_movements',$this->addCompany('item_movements',array('item_id'=>$l->item_id,'qty'=>$l->qty,'from_type'=>'customer','from_id'=>$r->customer_id,'to_type'=>$l->physical_condition==='saleable'?'warehouse':'quarantine','to_id'=>$warehouse,'movement_type'=>'customer_return','reference_type'=>'sales_return','reference_id'=>$l->id,'notes'=>'[AUTO] Retur '.$r->return_no,'created_at'=>date('Y-m-d H:i:s'))));$this->CI->salesInventoryModel->synchronizeStock($l->item_id);$net+=(float)$l->net_amount;$tax+=(float)$l->tax_amount;$cost+=$value;$this->CI->db->where('id',$l->id)->update('sales_return_lines',array('posted_at'=>date('Y-m-d H:i:s')));}
|
||||
$invoice=$this->lockInvoice($r->invoice_id);$credit=min((float)$invoice->sisa_piutang,$net+$tax);$excess=max(0,$net+$tax-$credit);$entries=array();$revenueRatio=$invoice->recognition_policy==='accrual'?1:min(1,(float)$invoice->recognized_revenue/max(.01,(float)$invoice->subtotal_before_tax));$recognizedNet=round($net*$revenueRatio,2);$deferredNet=$net-$recognizedNet;if($recognizedNet>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('goods_revenue'),'debit'=>$recognizedNet,'kredit'=>0);if($deferredNet>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('deferred_revenue'),'debit'=>$deferredNet,'kredit'=>0);if($tax>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('sales_tax_payable'),'debit'=>$tax,'kredit'=>0);if($credit>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('accounts_receivable'),'debit'=>0,'kredit'=>$credit);if($excess>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('customer_advances'),'debit'=>0,'kredit'=>$excess);if($cost>0){$allInvoiceCost=(float)$this->CI->db->select('COALESCE(SUM(total_hpp_barang),0) v',false)->get_where('invoice_details',array('invoice_id'=>$invoice->id,'company_id'=>$this->company()))->row()->v;$costRatio=$invoice->recognition_policy==='accrual'?1:min(1,(float)$invoice->recognized_cogs/max(.01,$allInvoiceCost));$recognizedCost=round($cost*$costRatio,2);$deferredCost=$cost-$recognizedCost;$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('inventory'),'debit'=>$cost,'kredit'=>0);if($recognizedCost>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('cost_of_goods_sold'),'debit'=>0,'kredit'=>$recognizedCost);if($deferredCost>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('inventory_in_sales'),'debit'=>0,'kredit'=>$deferredCost);}$journal=$this->CI->postingservice->post(array('tanggal'=>$r->return_date,'no_ref'=>$r->return_no,'keterangan'=>'[AUTO] Retur Penjualan '.$r->return_no.' | Invoice '.$invoice->no_invoice.' | Customer #'.$r->customer_id.' | Nilai Rp '.number_format($net+$tax,2,',','.'),'ref_type'=>'sales_return','ref_id'=>$r->id,'created_by'=>$user),$entries,false,true);
|
||||
$balance=max(0,(float)$invoice->sisa_piutang-$credit);$this->CI->db->where('id',$invoice->id)->update('invoices',array('sisa_piutang'=>$balance,'status'=>$balance<=.001?'paid':'partial'));$this->CI->db->insert('credit_notes',$this->columns('credit_notes',array('company_id'=>$this->company(),'credit_note_no'=>'CN-'.$r->return_no,'invoice_id'=>$invoice->id,'customer_id'=>$r->customer_id,'credit_date'=>$r->return_date,'amount'=>$net+$tax,'reason'=>$r->reason,'status'=>'posted','journal_id'=>$journal,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s'))));$creditId=(int)$this->CI->db->insert_id();if($excess>0)$this->CI->db->insert('customer_advances',$this->columns('customer_advances',array('company_id'=>$this->company(),'advance_no'=>'ADV-'.$r->return_no,'customer_id'=>$r->customer_id,'payment_id'=>null,'original_amount'=>$excess,'available_amount'=>$excess,'status'=>'available','journal_id'=>$journal,'created_at'=>date('Y-m-d H:i:s'))));$this->CI->db->where('id',$r->id)->update('sales_returns',array('status'=>'completed','credit_note_id'=>$creditId,'journal_id'=>$journal,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s')));$this->returnHistory($r->id,'received','completed','Stok dan credit note diposting.',$user);return$r->id;});
|
||||
}
|
||||
public function returnDetail($id){$r=$this->CI->db->select('r.*,c.nama customer_name,i.no_invoice,d.delivery_no')->from('sales_returns r')->join('customers c','c.id=r.customer_id')->join('invoices i','i.id=r.invoice_id','left')->join('sales_deliveries d','d.id=r.sales_delivery_id','left')->where(array('r.id'=>(int)$id,'r.company_id'=>$this->company()))->get()->row();if(!$r)throw new BusinessException('Retur tidak ditemukan.');return array('return'=>$r,'lines'=>$this->CI->db->select('l.*,it.kode_detail AS kode_barang,it.nama_barang')->from('sales_return_lines l')->join('items it','it.id=l.item_id')->where('l.sales_return_id',$r->id)->get()->result(),'barcodes'=>$this->CI->db->select('b.*,ib.barcode,ib.serial_number')->from('sales_return_barcodes b')->join('item_barcodes ib','ib.id=b.barcode_id')->where('b.sales_return_id',$r->id)->get()->result(),'history'=>$this->CI->db->select('h.*,u.nama user_name')->from('sales_return_history h')->join('users u','u.id=h.user_id','left')->where('h.sales_return_id',$r->id)->order_by('h.id')->get()->result(),'attachments'=>$this->attachments(array('sales_return'),$r->id));}
|
||||
private function lockReturn($id,$status=null){$r=$this->CI->db->query('SELECT * FROM sales_returns WHERE id=? AND company_id=? FOR UPDATE',array((int)$id,$this->company()))->row();if(!$r)throw new BusinessException('Retur tidak ditemukan.');if($status!==null&&$r->status!==$status)throw new BusinessException('Retur harus berstatus '.$status.'.');return$r;}
|
||||
private function returnHistory($id,$from,$to,$notes,$user){$this->CI->db->insert('sales_return_history',array('company_id'=>$this->company(),'sales_return_id'=>$id,'from_status'=>$from,'to_status'=>$to,'notes'=>$notes,'user_id'=>$user,'created_at'=>date('Y-m-d H:i:s')));}
|
||||
private function attachments(array$types,$entityId){return$this->CI->db->where('company_id',$this->company())->where_in('entity_type',$types)->where('entity_id',(int)$entityId)->order_by('id')->get('transaction_attachments')->result();}
|
||||
private function insertAttachment($type,$id,array$a,$user){$this->CI->db->insert('transaction_attachments',$this->columns('transaction_attachments',array('company_id'=>$this->company(),'module'=>'sales','entity_type'=>$type,'entity_id'=>$id,'original_name'=>$a['original_name'],'stored_name'=>$a['stored_name'],'mime_type'=>$a['mime_type'],'file_size'=>$a['file_size'],'sha256'=>$a['sha256'],'uploaded_by'=>$user,'created_at'=>date('Y-m-d H:i:s'))));}
|
||||
private function columns($table,array$data){$fields=array_flip($this->CI->db->list_fields($table));return array_intersect_key($data,$fields);}
|
||||
private function assertDraftOwner($invoice,$user){if(function_exists('is_master_admin_user')&&is_master_admin_user())return;if((int)$invoice->created_by!==(int)$user)throw new BusinessException('Draft invoice hanya dapat dikelola dan diajukan oleh pembuatnya.');}
|
||||
private function lockInvoice($id,$status=null){$i=$this->CI->db->query('SELECT * FROM invoices WHERE id=? AND company_id=? FOR UPDATE',array((int)$id,$this->company()))->row();if(!$i)throw new BusinessException('Invoice tidak ditemukan.');if($status!==null&&$i->workflow_status!==$status)throw new BusinessException('Invoice harus berstatus '.$status.'.');return$i;}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
class TaxService
|
||||
{
|
||||
private $CI;public function __construct(){$this->CI=&get_instance();}
|
||||
private function code($type,$date){return$this->CI->db->where('tax_type',$type)->where('is_active',1)->where('effective_from <=',$date)->group_start()->where('effective_to IS NULL',null,false)->or_where('effective_to >=',$date)->group_end()->order_by('effective_from','DESC')->get('tax_codes')->row();}
|
||||
public function invoice($id,$journal){$i=$this->CI->db->get_where('invoices',array('id'=>$id))->row();$tax=$this->code('sales',$i->tanggal);if(!$tax)return;foreach($this->CI->db->get_where('invoice_details',array('invoice_id'=>$id))->result()as$l)if((float)$l->tax_amount>0&&!$this->CI->db->get_where('tax_transactions',array('idempotency_key'=>'INV-TAX-'.$l->id))->row())$this->CI->db->insert('tax_transactions',array('tax_date'=>$i->tanggal,'tax_code_id'=>$tax->id,'direction'=>'output','source_type'=>'invoice','source_id'=>$id,'source_line_id'=>$l->id,'counterparty_type'=>'customer','counterparty_id'=>$i->customer_id,'tax_base'=>(float)$l->subtotal-(float)$l->tax_amount,'tax_amount'=>$l->tax_amount,'status'=>'posted','journal_id'=>$journal,'idempotency_key'=>'INV-TAX-'.$l->id,'created_at'=>date('Y-m-d H:i:s')));}
|
||||
public function supplierInvoice($id,$journal){$i=$this->CI->db->get_where('supplier_invoices',array('id'=>$id))->row();$tax=$this->code('purchase',$i->invoice_date);if(!$tax)$tax=$this->code('sales',$i->invoice_date);if(!$tax)return;foreach($this->CI->db->get_where('supplier_invoice_lines',array('supplier_invoice_id'=>$id))->result()as$l)if((float)$l->tax_amount>0&&!$this->CI->db->get_where('tax_transactions',array('idempotency_key'=>'BILL-TAX-'.$l->id))->row())$this->CI->db->insert('tax_transactions',array('tax_date'=>$i->invoice_date,'tax_code_id'=>$tax->id,'direction'=>'input','source_type'=>'supplier_invoice','source_id'=>$id,'source_line_id'=>$l->id,'counterparty_type'=>'supplier','counterparty_id'=>$i->supplier_id,'tax_invoice_no'=>$i->supplier_invoice_no,'tax_base'=>(float)$l->line_total-(float)$l->tax_amount,'tax_amount'=>$l->tax_amount,'status'=>'posted','journal_id'=>$journal,'idempotency_key'=>'BILL-TAX-'.$l->id,'created_at'=>date('Y-m-d H:i:s')));}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
require_once APPPATH.'exceptions/BusinessException.php';
|
||||
|
||||
/** Operational custody subledger only. This service never writes journals,
|
||||
* stock_logs, inventory_ledger, prices, costs, or item master records. */
|
||||
class TechnicianEquipmentService
|
||||
{
|
||||
private $CI;
|
||||
public function __construct(){ $this->CI=&get_instance();$this->CI->load->library(array('CompanyContext','NumberingService')); }
|
||||
private function company(){return(int)$this->CI->companycontext->id();}
|
||||
private function now(){return date('Y-m-d H:i:s');}
|
||||
private function qty($value){$qty=round((float)$value,4);if($qty<=0)throw new BusinessException('Jumlah wajib lebih dari 0.');return$qty;}
|
||||
private function date($value){$date=trim((string)$value);$d=DateTime::createFromFormat('Y-m-d',$date);if(!$d||$d->format('Y-m-d')!==$date)throw new BusinessException('Tanggal transaksi tidak valid.');return$date;}
|
||||
private function key(array$row){return hash('sha256',implode('|',array($this->company(),$row['subject_type'],$row['subject_id'],$row['custodian_type'],(int)($row['custodian_id']??0),(int)($row['customer_id']??0),$row['usage_status'],$row['condition_status'])));}
|
||||
private function user($id){$r=$this->CI->db->where(array('id'=>(int)$id,'company_id'=>$this->company(),'is_active'=>1))->get('k_employees')->row();if(!$r)throw new BusinessException('Karyawan/teknisi tidak ditemukan pada perusahaan aktif.');return$r;}
|
||||
private function customer($id){$r=$this->CI->db->where(array('id'=>(int)$id,'company_id'=>$this->company()))->get('customers')->row();if(!$r)throw new BusinessException('Customer tidak ditemukan pada perusahaan aktif.');return$r;}
|
||||
private function warehouse($id){$r=$this->CI->db->where(array('id'=>(int)$id,'company_id'=>$this->company(),'is_active'=>1))->get('warehouses')->row();if(!$r)throw new BusinessException('Gudang tidak ditemukan pada perusahaan aktif.');return$r;}
|
||||
|
||||
public function dashboard()
|
||||
{
|
||||
$company=$this->company();$summary=array('carried'=>0,'installed'=>0,'inspection'=>0,'repair'=>0,'damaged'=>0,'lost'=>0);
|
||||
$rows=$this->CI->db->select("SUM(CASE WHEN custodian_type='technician' THEN qty ELSE 0 END) carried,SUM(CASE WHEN usage_status='installed' THEN qty ELSE 0 END) installed,SUM(CASE WHEN condition_status='inspection_required' THEN qty ELSE 0 END) inspection,SUM(CASE WHEN condition_status IN('repair_required','under_repair') THEN qty ELSE 0 END) repair,SUM(CASE WHEN condition_status='damaged' THEN qty ELSE 0 END) damaged,SUM(CASE WHEN condition_status='lost' THEN qty ELSE 0 END) lost",false)->where('company_id',$company)->get('technician_custody_allocations')->row();
|
||||
if($rows)foreach($summary as$key=>$value)$summary[$key]=(float)($rows->$key??0);$inspectionStats=$this->inspectionQueueStats();$summary['inspection']=(float)$inspectionStats['qty'];
|
||||
$technicians=$this->CI->db->select("e.id,e.employee_code,e.full_name,d.department_name,COALESCE(SUM(CASE WHEN a.custodian_type='technician' THEN a.qty ELSE 0 END),0) carried_qty,COALESCE(SUM(CASE WHEN a.usage_status='installed' THEN a.qty ELSE 0 END),0) installed_qty,COUNT(DISTINCT a.subject_type,a.subject_id) subject_count",false)->from('k_employees e')->join('k_departments d','d.id=e.department_id','left')->join('technician_custody_allocations a',"a.custodian_type='technician' AND a.custodian_id=e.id AND a.company_id=e.company_id",'left')->where(array('e.company_id'=>$company,'e.is_active'=>1))->group_start()->where_in('LOWER(d.department_name)',array('teknisi','it','admin','gudang'))->or_where('a.id IS NOT NULL',null,false)->group_end()->group_by('e.id')->order_by('e.full_name')->get()->result();
|
||||
return array('summary'=>$summary,'technicians'=>$technicians,'inspection_count'=>(int)$inspectionStats['row_count']);
|
||||
}
|
||||
|
||||
public function search($query,$mode='all',$technicianId=0,$limit=20)
|
||||
{
|
||||
$query=trim((string)$query);if($query==='')return array();$mode=in_array($mode,array('all','handover','transfer','return','customer'),true)?$mode:'all';$limit=max(1,min(30,(int)$limit));$like='%'.$this->CI->db->escape_like_str($query).'%';$company=$this->company();$rows=array();
|
||||
if(in_array($mode,array('all','handover'),true)){
|
||||
$sql="SELECT b.id subject_id,NULL allocation_id,'company_barcode' subject_type,b.barcode lookup_code,b.barcode,b.serial_number,i.kode_detail,i.nama_barang,COALESCE(k.tracking_type,'QTY') tracking_type,(b.qty_sisa-COALESCE(b.reserved_qty,0)-COALESCE(b.return_reserved_qty,0)) available_qty,'company' ownership_type,b.warehouse_id,w.nama warehouse_name,NULL technician_id,NULL technician_name,NULL customer_name FROM item_barcodes b JOIN items i ON i.id=b.item_id LEFT JOIN kode_barang k ON k.id=i.kode_id LEFT JOIN warehouses w ON w.id=b.warehouse_id WHERE i.company_id=? AND (b.barcode LIKE ? OR b.serial_number LIKE ? OR i.kode_detail LIKE ? OR i.nama_barang LIKE ?)";
|
||||
$params=array($company,$like,$like,$like,$like);if($mode==='handover')$sql.=" AND b.status='available' AND b.qty_sisa-COALESCE(b.reserved_qty,0)-COALESCE(b.return_reserved_qty,0)>.0001";if($mode==='all'&&(int)$technicianId>0){$employee=(int)$technicianId;$sql.=" AND (EXISTS(SELECT 1 FROM technician_custody_allocations sa WHERE sa.company_id=i.company_id AND sa.subject_type='company_barcode' AND sa.subject_id=b.id AND sa.custodian_type='technician' AND sa.custodian_id={$employee}) OR EXISTS(SELECT 1 FROM technician_equipment_document_lines sl JOIN technician_equipment_documents sd ON sd.id=sl.document_id WHERE sl.company_id=i.company_id AND sl.subject_type='company_barcode' AND sl.subject_id=b.id AND (sd.technician_id={$employee} OR sd.target_technician_id={$employee})))";}$sql.=" ORDER BY CASE WHEN b.barcode=? OR b.serial_number=? THEN 0 ELSE 1 END,i.nama_barang,b.id LIMIT ".$limit;$params[]=$query;$params[]=$query;$rows=$this->CI->db->query($sql,$params)->result_array();
|
||||
}
|
||||
if(in_array($mode,array('transfer','return'),true)){
|
||||
$sql="SELECT a.subject_id,a.id allocation_id,a.subject_type,a.barcode_snapshot lookup_code,a.barcode_snapshot barcode,COALESCE(b.serial_number,r.serial_number) serial_number,i.kode_detail,CASE WHEN a.subject_type='company_barcode' THEN i.nama_barang ELSE r.equipment_name END nama_barang,CASE WHEN a.subject_type='company_barcode' THEN COALESCE(k.tracking_type,'QTY') ELSE COALESCE(r.tracking_type,'UNIT') END tracking_type,a.qty available_qty,a.ownership_type,NULL warehouse_id,NULL warehouse_name,a.custodian_id technician_id,e.full_name technician_name,c.nama customer_name FROM technician_custody_allocations a LEFT JOIN items i ON i.id=a.item_id LEFT JOIN kode_barang k ON k.id=i.kode_id LEFT JOIN item_barcodes b ON b.id=a.barcode_id LEFT JOIN customer_equipment_registry r ON r.id=a.customer_equipment_id LEFT JOIN k_employees e ON e.id=a.custodian_id LEFT JOIN customers c ON c.id=a.customer_id WHERE a.company_id=? AND a.custodian_type='technician' AND a.qty>.0001";$params=array($company);if((int)$technicianId>0){$sql.=' AND a.custodian_id=?';$params[]=(int)$technicianId;}$sql.=" AND (a.barcode_snapshot LIKE ? OR b.serial_number LIKE ? OR r.serial_number LIKE ? OR i.kode_detail LIKE ? OR i.nama_barang LIKE ? OR r.equipment_name LIKE ?) ORDER BY CASE WHEN a.barcode_snapshot=? OR b.serial_number=? OR r.serial_number=? THEN 0 ELSE 1 END,nama_barang,a.id LIMIT ".$limit;array_push($params,$like,$like,$like,$like,$like,$like,$query,$query,$query);$rows=$this->CI->db->query($sql,$params)->result_array();
|
||||
}
|
||||
if(in_array($mode,array('all','customer'),true)){
|
||||
$left=max(1,$limit-count($rows));$sql="SELECT r.id subject_id,NULL allocation_id,'customer_equipment' subject_type,r.external_barcode lookup_code,r.external_barcode barcode,r.serial_number,NULL kode_detail,r.equipment_name nama_barang,r.tracking_type,r.total_qty available_qty,r.ownership_type,NULL warehouse_id,NULL warehouse_name,NULL technician_id,NULL technician_name,c.nama customer_name FROM customer_equipment_registry r LEFT JOIN customers c ON c.id=r.owner_customer_id WHERE r.company_id=? AND (r.external_barcode LIKE ? OR r.serial_number LIKE ? OR r.equipment_name LIKE ? OR r.brand LIKE ? OR r.model LIKE ?)";if((int)$technicianId>0){$employee=(int)$technicianId;$sql.=" AND (EXISTS(SELECT 1 FROM technician_custody_allocations sa WHERE sa.company_id=r.company_id AND sa.subject_type='customer_equipment' AND sa.subject_id=r.id AND sa.custodian_type='technician' AND sa.custodian_id={$employee}) OR EXISTS(SELECT 1 FROM technician_equipment_document_lines sl JOIN technician_equipment_documents sd ON sd.id=sl.document_id WHERE sl.company_id=r.company_id AND sl.subject_type='customer_equipment' AND sl.subject_id=r.id AND (sd.technician_id={$employee} OR sd.target_technician_id={$employee})))";}$sql.=" ORDER BY CASE WHEN r.external_barcode=? OR r.serial_number=? THEN 0 ELSE 1 END,r.equipment_name,r.id LIMIT ".$left;$external=$this->CI->db->query($sql,array($company,$like,$like,$like,$like,$like,$query,$query))->result_array();$rows=array_merge($rows,$external);
|
||||
}
|
||||
foreach($rows as&$row){$row['tracking_type']=strtoupper((string)$row['tracking_type'])==='UNIT'?'UNIT':'QTY';$row['display_name']=trim((string)($row['kode_detail']??'')).(($row['kode_detail']??'')!==null&&($row['kode_detail']??'')!==''?' - ':'').($row['nama_barang']??'-');$row['available_qty']=(float)$row['available_qty'];}unset($row);usort($rows,function($a,$b)use($query){$aExact=strcasecmp((string)$a['barcode'],$query)===0||strcasecmp((string)($a['serial_number']??''),$query)===0;$bExact=strcasecmp((string)$b['barcode'],$query)===0||strcasecmp((string)($b['serial_number']??''),$query)===0;return$aExact===$bExact?strcasecmp((string)$a['display_name'],(string)$b['display_name']):($aExact?-1:1);});return array_slice($rows,0,$limit);
|
||||
}
|
||||
|
||||
public function technicianHoldings($technicianId)
|
||||
{
|
||||
$technician=$this->user($technicianId);
|
||||
$rows=$this->CI->db->query("SELECT a.subject_id,a.id allocation_id,a.subject_type,a.barcode_snapshot lookup_code,a.barcode_snapshot barcode,COALESCE(b.serial_number,r.serial_number) serial_number,i.kode_detail,CASE WHEN a.subject_type='company_barcode' THEN i.nama_barang ELSE r.equipment_name END nama_barang,CASE WHEN a.subject_type='company_barcode' THEN COALESCE(k.tracking_type,'QTY') ELSE COALESCE(r.tracking_type,'UNIT') END tracking_type,a.qty available_qty,a.ownership_type,a.usage_status,a.condition_status,a.customer_id,c.nama customer_name,a.custodian_id technician_id,e.full_name technician_name FROM technician_custody_allocations a LEFT JOIN items i ON i.id=a.item_id LEFT JOIN kode_barang k ON k.id=i.kode_id LEFT JOIN item_barcodes b ON b.id=a.barcode_id LEFT JOIN customer_equipment_registry r ON r.id=a.customer_equipment_id LEFT JOIN k_employees e ON e.id=a.custodian_id LEFT JOIN customers c ON c.id=a.customer_id WHERE a.company_id=? AND a.custodian_type='technician' AND a.custodian_id=? AND a.qty>.0001 ORDER BY CASE a.usage_status WHEN 'carried' THEN 0 WHEN 'removed' THEN 1 ELSE 2 END,nama_barang,a.barcode_snapshot,a.id",array($this->company(),(int)$technician->id))->result_array();
|
||||
foreach($rows as&$row){$row['tracking_type']=strtoupper((string)$row['tracking_type'])==='UNIT'?'UNIT':'QTY';$row['display_name']=trim((string)($row['kode_detail']??'')).(($row['kode_detail']??'')!==null&&($row['kode_detail']??'')!==''?' - ':'').($row['nama_barang']??'-');$row['available_qty']=(float)$row['available_qty'];}unset($row);
|
||||
return$rows;
|
||||
}
|
||||
|
||||
public function scan($code)
|
||||
{
|
||||
$code=trim((string)$code);if($code==='')throw new BusinessException('Barcode atau serial wajib diisi.');$company=$this->company();
|
||||
$internal=$this->CI->db->select("b.id subject_id,b.id barcode_id,b.item_id,b.barcode,b.serial_number,b.warehouse_id,b.qty_sisa,b.reserved_qty,b.return_reserved_qty,b.status barcode_status,b.version,i.kode_detail,i.nama_barang,k.tracking_type,k.unit,w.nama warehouse_name,'company_barcode' subject_type,'company' ownership_type",false)->from('item_barcodes b')->join('items i','i.id=b.item_id')->join('kode_barang k','k.id=i.kode_id','left')->join('warehouses w','w.id=b.warehouse_id','left')->where('i.company_id',$company)->group_start()->where('b.barcode',$code)->or_where('b.serial_number',$code)->group_end()->limit(1)->get()->row_array();
|
||||
if($internal){$internal['tracking_type']=strtoupper((string)$internal['tracking_type'])==='UNIT'?'UNIT':'QTY';$internal['warehouse_available']=max(0,(float)$internal['qty_sisa']-(float)$internal['reserved_qty']-(float)$internal['return_reserved_qty']);$internal['display_name']=$internal['kode_detail'].' - '.$internal['nama_barang'];$internal['allocations']=$this->allocations('company_barcode',(int)$internal['subject_id']);$internal['actions']=$this->actions($internal);return$internal;}
|
||||
$external=$this->CI->db->select("r.*,r.id subject_id,r.external_barcode barcode,'customer_equipment' subject_type")->from('customer_equipment_registry r')->where('r.company_id',$company)->group_start()->where('r.external_barcode',$code)->or_where('r.serial_number',$code)->group_end()->limit(1)->get()->row_array();
|
||||
if(!$external)throw new BusinessException('Barcode atau serial tidak ditemukan.');$external['display_name']=$external['equipment_name'].($external['brand']?' - '.$external['brand']:'');$external['warehouse_available']=0;$external['allocations']=$this->allocations('customer_equipment',(int)$external['subject_id']);$external['actions']=$this->actions($external);return$external;
|
||||
}
|
||||
|
||||
public function allocations($subjectType=null,$subjectId=null,$technicianId=null)
|
||||
{
|
||||
$this->CI->db->select("a.*,i.kode_detail,i.nama_barang,b.serial_number,r.equipment_name,r.brand,r.model,r.serial_number external_serial,c.nama customer_name,e.full_name technician_name,w.nama warehouse_name,CASE WHEN a.subject_type='company_barcode' THEN COALESCE(k.tracking_type,'QTY') ELSE COALESCE(r.tracking_type,'UNIT') END tracking_type,CASE WHEN a.subject_type='company_barcode' THEN CONCAT(COALESCE(i.kode_detail,'-'),' - ',COALESCE(i.nama_barang,'-')) ELSE CONCAT(COALESCE(r.equipment_name,'-'),IF(r.brand IS NULL OR r.brand='','',CONCAT(' - ',r.brand))) END display_name",false)->from('technician_custody_allocations a')->join('items i','i.id=a.item_id','left')->join('kode_barang k','k.id=i.kode_id','left')->join('item_barcodes b','b.id=a.barcode_id','left')->join('customer_equipment_registry r','r.id=a.customer_equipment_id','left')->join('customers c','c.id=a.customer_id','left')->join('k_employees e',"e.id=a.custodian_id AND a.custodian_type='technician'",'left')->join('warehouses w',"w.id=a.custodian_id AND a.custodian_type IN('warehouse','quarantine','service_center')",'left')->where('a.company_id',$this->company())->where('a.qty >',0);
|
||||
if($subjectType)$this->CI->db->where(array('a.subject_type'=>$subjectType,'a.subject_id'=>(int)$subjectId));if($technicianId)$this->CI->db->group_start()->where(array('a.custodian_type'=>'technician','a.custodian_id'=>(int)$technicianId))->or_where(array('a.usage_status'=>'installed','a.custodian_type'=>'customer'))->where("EXISTS(SELECT 1 FROM technician_equipment_document_lines dl JOIN technician_equipment_documents dh ON dh.id=dl.document_id WHERE dl.subject_type=a.subject_type AND dl.subject_id=a.subject_id AND dh.technician_id=".(int)$technicianId.")",null,false)->group_end();
|
||||
return$this->CI->db->order_by('a.updated_at','DESC')->get()->result_array();
|
||||
}
|
||||
|
||||
private function actions(array$subject)
|
||||
{
|
||||
$actions=array();if($subject['subject_type']==='company_barcode'&&(float)$subject['warehouse_available']>0)$actions[]='handover';
|
||||
foreach($subject['allocations']as$a){if(in_array($a['custodian_type'],array('quarantine','service_center'),true)&&in_array($a['condition_status'],array('inspection_required','repair_required','under_repair','damaged'),true)){$actions[]='inspect';continue;}if($a['custodian_type']==='technician'){if(in_array($a['usage_status'],array('carried','removed','returned'),true)){$actions[]='install';$actions[]='transfer';$actions[]='return';if($subject['subject_type']==='customer_equipment')$actions[]='return_customer';}}elseif($a['custodian_type']==='customer'&&$a['usage_status']==='installed'){$actions[]='remove';$actions[]='replacement';}}
|
||||
return array_values(array_unique($actions));
|
||||
}
|
||||
|
||||
public function registerExternal(array$data,$userId)
|
||||
{
|
||||
$this->CI->db->trans_begin();try{$row=$this->createExternal($data,$userId);if($this->CI->db->trans_status()===false)throw new RuntimeException('Register barang customer gagal disimpan.');$this->CI->db->trans_commit();return$row;}catch(Throwable$e){$this->CI->db->trans_rollback();throw$e;}
|
||||
}
|
||||
private function createExternal(array$data,$userId)
|
||||
{
|
||||
$customer=$this->customer($data['owner_customer_id']??0);$barcode=strtoupper(trim((string)($data['external_barcode']??'')));$name=trim((string)($data['equipment_name']??''));if($barcode===''||$name==='')throw new BusinessException('Nama barang dan barcode eksternal wajib diisi.');$tracking=strtoupper((string)($data['tracking_type']??'UNIT'))==='QTY'?'QTY':'UNIT';$qty=$tracking==='UNIT'?1:$this->qty($data['qty']??0);$ownership=in_array(($data['ownership_type']??''),array('customer','transferred_non_inventory','unknown'),true)?$data['ownership_type']:'customer';
|
||||
$duplicate=$this->CI->db->where(array('company_id'=>$this->company(),'external_barcode'=>$barcode))->get('customer_equipment_registry')->row();if($duplicate)throw new BusinessException('Barcode barang customer sudah terdaftar.');$sourceDate=trim((string)($data['source_date']??date('Y-m-d')));$sourceDate=$this->date($sourceDate);$row=array('company_id'=>$this->company(),'owner_customer_id'=>$customer->id,'current_customer_id'=>$customer->id,'equipment_name'=>$name,'category'=>trim((string)($data['category']??''))?:null,'brand'=>trim((string)($data['brand']??''))?:null,'model'=>trim((string)($data['model']??''))?:null,'external_barcode'=>$barcode,'serial_number'=>trim((string)($data['serial_number']??''))?:null,'description'=>trim((string)($data['description']??''))?:null,'source_date'=>$sourceDate,'source_document_no'=>trim((string)($data['source_document_no']??''))?:null,'source_reason'=>trim((string)($data['source_reason']??''))?:null,'ownership_type'=>$ownership,'deployment_allowed'=>!empty($data['deployment_allowed'])?1:0,'authorization_reference'=>trim((string)($data['authorization_reference']??''))?:null,'tracking_type'=>$tracking,'total_qty'=>$qty,'custody_type'=>'customer','custody_id'=>$customer->id,'usage_status'=>'installed','condition_status'=>'good','lifecycle_status'=>'active','version'=>1,'registered_by'=>(int)$userId,'registered_at'=>$this->now());if($ownership==='customer'&&!empty($data['deployment_allowed'])&&empty($row['authorization_reference']))throw new BusinessException('Referensi otorisasi wajib diisi jika barang customer boleh digunakan kembali.');if($ownership==='transferred_non_inventory'&&(!$row['source_document_no']||!$row['source_reason']))throw new BusinessException('Dokumen sumber dan alasan penyerahan wajib diisi.');$this->CI->db->insert('customer_equipment_registry',$row);$id=(int)$this->CI->db->insert_id();$subject=array('subject_type'=>'customer_equipment','subject_id'=>$id,'item_id'=>null,'barcode_id'=>null,'customer_equipment_id'=>$id,'barcode_snapshot'=>$barcode,'ownership_type'=>$ownership);$this->addBalance($subject,'customer',$customer->id,$customer->id,'installed','good',$qty,null);return$this->CI->db->get_where('customer_equipment_registry',array('id'=>$id))->row_array();
|
||||
}
|
||||
|
||||
public function process(array$data,$userId)
|
||||
{
|
||||
$action=(string)($data['action']??'');if(!in_array($action,array('handover','install','remove','transfer','return','return_customer'),true))throw new BusinessException('Tindakan peralatan tidak valid.');$date=$this->date($data['date']??'');$notes=trim((string)($data['notes']??''));if($notes==='')throw new BusinessException('Keterangan transaksi wajib diisi.');$key=trim((string)($data['idempotency_key']??''));if($key==='')throw new BusinessException('Kunci transaksi tidak tersedia. Muat ulang halaman.');
|
||||
$this->CI->db->trans_begin();
|
||||
try{
|
||||
if($existing=$this->CI->db->get_where('technician_equipment_documents',array('company_id'=>$this->company(),'idempotency_key'=>$key))->row()){$this->CI->db->trans_commit();return array('document_id'=>(int)$existing->id,'document_no'=>$existing->document_no,'duplicate'=>true);}
|
||||
$doc=$this->createDocument($action,$date,$data,$userId,$key);
|
||||
if($action==='handover')$this->handover($doc,$data,$userId);
|
||||
else{
|
||||
$source=$this->lockAllocation($data['allocation_id']??0);$qty=$this->validatedQty($source,$data['qty']??0);
|
||||
if($action==='install'){if($source['custodian_type']!=='technician'||!in_array($source['usage_status'],array('carried','removed','returned'),true))throw new BusinessException('Hanya barang yang sedang dibawa teknisi yang dapat dipasang.');$customer=$this->customer($data['customer_id']??0);$this->assertReusableExternal($source,$customer->id);$this->move($doc,$source,'customer',$customer->id,$customer->id,'installed','good',$qty,'installation',$notes);}
|
||||
elseif($action==='remove'){if($source['custodian_type']!=='customer'||$source['usage_status']!=='installed')throw new BusinessException('Hanya barang terpasang yang dapat dilepas.');$technician=$this->user($data['technician_id']??$doc['technician_id']);$condition=in_array(($data['condition_status']??''),array('good','inspection_required','repair_required','damaged'),true)?$data['condition_status']:'inspection_required';$this->move($doc,$source,'technician',$technician->id,$source['customer_id'],'removed',$condition,$qty,'uninstallation',$notes);}
|
||||
elseif($action==='transfer'){$target=$this->user($data['target_technician_id']??0);if($source['custodian_type']!=='technician'||(int)$source['custodian_id']===(int)$target->id)throw new BusinessException('Teknisi tujuan transfer tidak valid.');$this->move($doc,$source,'technician',$target->id,$source['customer_id'],'carried',$source['condition_status'],$qty,'transfer',$notes);}
|
||||
elseif($action==='return')$this->returnFromTechnician($doc,$source,$qty,$data,$notes);
|
||||
elseif($action==='return_customer'){if($source['subject_type']!=='customer_equipment'||$source['custodian_type']!=='technician')throw new BusinessException('Tindakan ini hanya untuk barang customer yang sedang dibawa teknisi.');$customer=$this->customer($data['customer_id']??$source['customer_id']);$this->move($doc,$source,'customer',$customer->id,$customer->id,'returned',$source['condition_status'],$qty,'transfer',$notes);}
|
||||
}
|
||||
if($this->CI->db->trans_status()===false)throw new RuntimeException('Transaksi peralatan gagal disimpan.');$this->CI->db->trans_commit();return array('document_id'=>$doc['id'],'document_no'=>$doc['document_no']);
|
||||
}catch(Throwable$e){$this->CI->db->trans_rollback();throw$e;}
|
||||
}
|
||||
|
||||
public function processBatch(array$data,$userId)
|
||||
{
|
||||
$action=(string)($data['action']??'');if(!in_array($action,array('handover','transfer','return'),true))throw new BusinessException('Jenis perpindahan batch tidak valid.');$date=$this->date($data['date']??'');$notes=trim((string)($data['notes']??''));if($notes==='')$notes=$action==='handover'?'Serah terima barang batch':($action==='transfer'?'Transfer barang antar teknisi':'Pengembalian barang batch ke gudang');$key=trim((string)($data['idempotency_key']??''));$items=$data['items']??array();if($key===''||!is_array($items)||count($items)<1)throw new BusinessException('Daftar barang dan kunci transaksi wajib tersedia.');if(count($items)>200)throw new BusinessException('Maksimal 200 baris dalam satu transaksi batch.');
|
||||
$seen=array();foreach($items as$row){$identity=$action==='handover'?'S'.(int)($row['subject_id']??0):'A'.(int)($row['allocation_id']??0);if(isset($seen[$identity]))throw new BusinessException('Barang yang sama tercantum lebih dari satu kali. Gabungkan jumlahnya pada satu baris.');$seen[$identity]=true;}
|
||||
$technician=null;$target=null;$warehouse=null;if($action==='handover')$technician=$this->user($data['technician_id']??0);elseif($action==='transfer'){$technician=$this->user($data['technician_id']??0);$target=$this->user($data['target_technician_id']??0);if((int)$technician->id===(int)$target->id)throw new BusinessException('Teknisi asal dan tujuan harus berbeda.');}else{$technician=$this->user($data['technician_id']??0);if(!empty($data['warehouse_id']))$warehouse=$this->warehouse($data['warehouse_id']);}
|
||||
$this->CI->db->trans_begin();try{if($existing=$this->CI->db->get_where('technician_equipment_documents',array('company_id'=>$this->company(),'idempotency_key'=>$key))->row()){$this->CI->db->trans_commit();return array('document_id'=>(int)$existing->id,'document_no'=>$existing->document_no,'line_count'=>$this->CI->db->where('document_id',$existing->id)->count_all_results('technician_equipment_document_lines'),'duplicate'=>true);}$header=$data;$header['technician_id']=$technician->id;if($target)$header['target_technician_id']=$target->id;if($warehouse)$header['warehouse_id']=$warehouse->id;$header['notes']=$notes;$doc=$this->createDocument($action,$date,$header,$userId,$key);
|
||||
foreach($items as$index=>$row){$rowNotes=trim((string)($row['notes']??''));$lineNotes=$notes.($rowNotes!==''?' | '.$rowNotes:'');if($action==='handover'){$payload=array_merge($header,array('subject_id'=>(int)($row['subject_id']??0),'qty'=>$row['qty']??0,'notes'=>$lineNotes));$this->handover($doc,$payload,$userId);continue;}$source=$this->lockAllocation($row['allocation_id']??0);if($source['custodian_type']!=='technician'||(int)$source['custodian_id']!==(int)$technician->id)throw new BusinessException('Baris '.($index+1).' tidak lagi berada pada teknisi asal.');$qty=$this->validatedQty($source,$row['qty']??0);if($action==='transfer')$this->move($doc,$source,'technician',$target->id,$source['customer_id'],'carried',$source['condition_status'],$qty,'transfer',$lineNotes);else$this->returnFromTechnician($doc,$source,$qty,$data,$lineNotes,$warehouse);}
|
||||
if($this->CI->db->trans_status()===false)throw new RuntimeException('Transaksi batch gagal disimpan.');$this->CI->db->trans_commit();return array('document_id'=>$doc['id'],'document_no'=>$doc['document_no'],'line_count'=>count($items));}catch(Throwable$e){$this->CI->db->trans_rollback();throw$e;}
|
||||
}
|
||||
|
||||
private function returnFromTechnician(array$doc,array$source,$qty,array$data,$notes,$warehouse=null)
|
||||
{
|
||||
if($source['custodian_type']!=='technician')throw new BusinessException('Barang harus berada pada teknisi sebelum dikembalikan.');
|
||||
if($source['subject_type']==='company_barcode'||$source['ownership_type']==='transferred_non_inventory'){
|
||||
if(!$warehouse)$warehouse=$this->warehouse($data['warehouse_id']??0);
|
||||
if($source['subject_type']==='company_barcode')$this->move($doc,$source,'quarantine',$warehouse->id,$source['customer_id'],'returned','inspection_required',$qty,'transfer',$notes);
|
||||
else$this->move($doc,$source,'warehouse',$warehouse->id,$source['customer_id'],'returned',$source['condition_status'],$qty,'transfer',$notes,false);
|
||||
return;
|
||||
}
|
||||
$customerId=(int)($source['customer_id']??0);if($customerId<1&&$source['subject_type']==='customer_equipment'){$registry=$this->CI->db->select('owner_customer_id')->get_where('customer_equipment_registry',array('id'=>(int)$source['subject_id'],'company_id'=>$this->company()))->row();$customerId=(int)($registry->owner_customer_id??0);}
|
||||
$customer=$this->customer($customerId);$this->move($doc,$source,'customer',$customer->id,$customer->id,'returned',$source['condition_status'],$qty,'transfer',$notes);
|
||||
}
|
||||
|
||||
private function handover(array$doc,array$data,$userId)
|
||||
{
|
||||
$barcodeId=(int)($data['subject_id']??0);$barcode=$this->CI->db->query("SELECT b.*,i.company_id,i.nama_barang,COALESCE(k.tracking_type,'QTY') tracking_type FROM item_barcodes b JOIN items i ON i.id=b.item_id LEFT JOIN kode_barang k ON k.id=i.kode_id WHERE b.id=? AND i.company_id=? FOR UPDATE",array($barcodeId,$this->company()))->row();if(!$barcode)throw new BusinessException('Barcode gudang tidak ditemukan.');if((string)$barcode->status!=='available')throw new BusinessException('Barcode belum tersedia untuk diserahkan dari gudang.');$tracking=strtoupper((string)$barcode->tracking_type)==='UNIT'?'UNIT':'QTY';$qty=$tracking==='UNIT'?1:$this->qty($data['qty']??0);$available=(float)$barcode->qty_sisa-(float)$barcode->reserved_qty-(float)$barcode->return_reserved_qty;if($qty>$available+.0001)throw new BusinessException('Jumlah melebihi saldo barcode yang tersedia di gudang.');$technician=$this->user($data['technician_id']??0);$subject=array('subject_type'=>'company_barcode','subject_id'=>$barcode->id,'item_id'=>$barcode->item_id,'barcode_id'=>$barcode->id,'customer_equipment_id'=>null,'barcode_snapshot'=>$barcode->barcode,'ownership_type'=>'company');$this->addBalance($subject,'technician',$technician->id,null,'carried','good',$qty,$doc['id']);$reserved=round((float)$barcode->reserved_qty+$qty,4);$left=(float)$barcode->qty_sisa-$reserved-(float)$barcode->return_reserved_qty;$status=$left>.0001?'available':'installed';$this->CI->db->where(array('id'=>$barcode->id,'version'=>$barcode->version))->update('item_barcodes',array('reserved_qty'=>$reserved,'status'=>$status,'version'=>(int)$barcode->version+1));if($this->CI->db->affected_rows()!==1)throw new BusinessException('Barcode sedang diproses pengguna lain.');$notes=trim((string)$data['notes']);$movement=$this->movement($subject,$qty,'warehouse',$barcode->warehouse_id,'technician',$technician->id,'transfer',$doc['id'],'[AUTO] Serah terima '.$doc['document_no'].' | '.$notes);$this->line($doc,$subject,$tracking,'handover','warehouse',$barcode->warehouse_id,'technician',$technician->id,null,'warehouse','carried','good','good',$qty,$movement,$notes);
|
||||
}
|
||||
|
||||
public function replacement(array$data,$userId)
|
||||
{
|
||||
$date=$this->date($data['date']??'');$notes=trim((string)($data['notes']??''));if($notes===''||empty($data['customer_id'])||empty($data['technician_id']))throw new BusinessException('Customer, teknisi, tanggal, dan keterangan wajib diisi.');$customer=$this->customer($data['customer_id']);$this->user($data['technician_id']);$key=trim((string)($data['idempotency_key']??''));if($key==='')throw new BusinessException('Kunci transaksi tidak tersedia.');
|
||||
$this->CI->db->trans_begin();try{if($existing=$this->CI->db->get_where('technician_equipment_documents',array('company_id'=>$this->company(),'idempotency_key'=>$key))->row()){$this->CI->db->trans_commit();return array('document_id'=>(int)$existing->id,'document_no'=>$existing->document_no,'duplicate'=>true);}$doc=$this->createDocument('replacement',$date,$data,$userId,$key);$out=$this->lockAllocation($data['outgoing_allocation_id']??0);$outQty=$this->validatedQty($out,$data['outgoing_qty']??0);if($out['custodian_type']!=='technician')throw new BusinessException('Barang pengganti harus berada pada teknisi.');$this->move($doc,$out,'customer',$customer->id,$customer->id,'installed','good',$outQty,'installation','Barang pengganti: '.$notes);
|
||||
if(!empty($data['incoming_allocation_id'])){$old=$this->lockAllocation($data['incoming_allocation_id']);if($old['custodian_type']!=='customer'||(int)$old['customer_id']!==(int)$customer->id)throw new BusinessException('Barang lama tidak terpasang pada customer yang dipilih.');$inQty=$this->validatedQty($old,$data['incoming_qty']??0);}
|
||||
else{$external=$this->createExternal(array('owner_customer_id'=>$customer->id,'equipment_name'=>$data['old_equipment_name']??'','category'=>$data['old_category']??'','brand'=>$data['old_brand']??'','model'=>$data['old_model']??'','external_barcode'=>$data['old_barcode']??'','serial_number'=>$data['old_serial_number']??'','description'=>$data['old_description']??'','source_date'=>$date,'source_document_no'=>$data['work_order_no']??$doc['document_no'],'source_reason'=>$data['reason']??$notes,'ownership_type'=>$data['old_ownership_type']??'customer','deployment_allowed'=>!empty($data['old_deployment_allowed']),'authorization_reference'=>$data['old_authorization_reference']??'','tracking_type'=>$data['old_tracking_type']??'UNIT','qty'=>$data['incoming_qty']??1),$userId);$old=$this->lockAllocationBySubject('customer_equipment',$external['id'],'customer',$customer->id);$inQty=$this->validatedQty($old,$data['incoming_qty']??0);}
|
||||
$this->assertReusableExternal($out,$customer->id);$condition=in_array(($data['old_condition']??''),array('good','inspection_required','repair_required','damaged'),true)?$data['old_condition']:'inspection_required';$this->move($doc,$old,'technician',(int)$data['technician_id'],$customer->id,'removed',$condition,$inQty,'uninstallation','Barang lama dilepas: '.$notes);
|
||||
if($this->CI->db->trans_status()===false)throw new RuntimeException('Dokumen penggantian gagal disimpan.');$this->CI->db->trans_commit();return array('document_id'=>$doc['id'],'document_no'=>$doc['document_no']);}catch(Throwable$e){$this->CI->db->trans_rollback();throw$e;}
|
||||
}
|
||||
|
||||
public function inspect(array$data,$userId)
|
||||
{
|
||||
$date=$this->date($data['date']??'');$notes=trim((string)($data['notes']??''));if($notes==='')throw new BusinessException('Catatan hasil pemeriksaan wajib diisi.');$result=(string)($data['result_status']??'');if(!in_array($result,array('good','repair_required','under_repair','damaged','lost','return_customer','scrapped'),true))throw new BusinessException('Hasil pemeriksaan tidak valid.');$key=trim((string)($data['idempotency_key']??''));if($key==='')throw new BusinessException('Kunci transaksi tidak tersedia. Muat ulang halaman.');
|
||||
$this->CI->db->trans_begin();try{if($existing=$this->CI->db->get_where('technician_equipment_documents',array('company_id'=>$this->company(),'idempotency_key'=>$key))->row()){$this->CI->db->trans_commit();return array('document_id'=>$existing->id,'document_no'=>$existing->document_no,'duplicate'=>true);}$source=$this->lockAllocation($data['allocation_id']??0);$externalStorage=$source['subject_type']==='customer_equipment'&&in_array($source['custodian_type'],array('customer','warehouse'),true)&&$source['usage_status']==='returned';if(!in_array($source['condition_status'],array('inspection_required','repair_required','under_repair','damaged'),true)||(!in_array($source['custodian_type'],array('quarantine','service_center'),true)&&!$externalStorage))throw new BusinessException('Barang tidak berada pada antrean pemeriksaan/perbaikan.');$qty=$this->validatedQty($source,$data['qty']??0);$doc=$this->createDocument('inspection',$date,$data,$userId,$key);$this->CI->db->insert('technician_equipment_inspections',array('company_id'=>$this->company(),'document_id'=>$doc['id'],'source_allocation_id'=>$source['id'],'subject_type'=>$source['subject_type'],'subject_id'=>$source['subject_id'],'result_status'=>$result,'qty'=>$qty,'inspection_date'=>$date,'notes'=>$notes,'inspected_by'=>(int)$userId,'created_at'=>$this->now()));
|
||||
if($externalStorage){$condition=in_array($result,array('return_customer','good'),true)?'good':$result;$this->move($doc,$source,$source['custodian_type'],$source['custodian_id'],$source['customer_id'],'returned',$condition,$qty,'adjustment',$notes,false);if($result==='scrapped')$this->CI->db->where(array('id'=>$source['subject_id'],'company_id'=>$this->company()))->update('customer_equipment_registry',array('lifecycle_status'=>'disposed','updated_at'=>$this->now()));}
|
||||
elseif($result==='good'&&$source['subject_type']==='company_barcode'){$this->consume($source,$qty);$this->releaseInternal($source['barcode_id'],$qty,'available');$movement=$this->movement($this->subject($source),$qty,'warehouse',$source['custodian_id'],'warehouse',$source['custodian_id'],'adjustment',$doc['id'],'[AUTO] Lulus pemeriksaan '.$doc['document_no'].' | '.$notes);$this->line($doc,$source,$this->tracking($source),'inspection',$source['custodian_type'],$source['custodian_id'],'warehouse',$source['custodian_id'],$source['customer_id'],$source['usage_status'],'available',$source['condition_status'],'good',$qty,$movement,$notes);}
|
||||
elseif($result==='return_customer'||($result==='good'&&$source['subject_type']==='customer_equipment')){$customer=$this->customer($source['customer_id']);$this->move($doc,$source,'customer',$customer->id,$customer->id,'returned','good',$qty,'transfer',$notes);}
|
||||
else{$toType=$result==='under_repair'?'service_center':'quarantine';$condition=$result;$this->move($doc,$source,$toType,$source['custodian_id'],$source['customer_id'],'returned',$condition,$qty,'adjustment',$notes,false);if($source['subject_type']==='company_barcode')$this->refreshBarcodeStatus($source['barcode_id'],$result==='damaged'?'damaged':'quarantine');if($source['subject_type']==='customer_equipment'&&$result==='scrapped')$this->CI->db->where(array('id'=>$source['subject_id'],'company_id'=>$this->company()))->update('customer_equipment_registry',array('lifecycle_status'=>'disposed','updated_at'=>$this->now()));}
|
||||
if($source['subject_type']==='customer_equipment')$this->syncExternal($source['subject_id']);if($this->CI->db->trans_status()===false)throw new RuntimeException('Pemeriksaan gagal disimpan.');$this->CI->db->trans_commit();return array('document_id'=>$doc['id'],'document_no'=>$doc['document_no']);}catch(Throwable$e){$this->CI->db->trans_rollback();throw$e;}
|
||||
}
|
||||
|
||||
public function reverse(array$data,$userId)
|
||||
{
|
||||
$sourceId=(int)($data['document_id']??0);$reason=trim((string)($data['reason']??''));$date=$this->date($data['date']??date('Y-m-d'));$key=trim((string)($data['idempotency_key']??''));if($sourceId<1||$reason===''||$key==='')throw new BusinessException('Dokumen, tanggal, alasan koreksi, dan kunci transaksi wajib diisi.');
|
||||
$this->CI->db->trans_begin();try{
|
||||
if($existing=$this->CI->db->get_where('technician_equipment_documents',array('company_id'=>$this->company(),'idempotency_key'=>$key))->row()){$this->CI->db->trans_commit();return array('document_id'=>(int)$existing->id,'document_no'=>$existing->document_no,'duplicate'=>true);}
|
||||
$original=$this->CI->db->query('SELECT * FROM technician_equipment_documents WHERE id=? AND company_id=? FOR UPDATE',array($sourceId,$this->company()))->row_array();if(!$original)throw new BusinessException('Dokumen yang akan dikoreksi tidak ditemukan.');if($original['status']!=='posted')throw new BusinessException('Dokumen sudah pernah dikoreksi atau tidak dapat dibalik.');if($original['document_type']==='opening')throw new BusinessException('Saldo awal migrasi tidak dapat dikoreksi dari transaksi operasional.');
|
||||
$payload=array('customer_id'=>$original['customer_id'],'technician_id'=>$original['technician_id'],'target_technician_id'=>$original['target_technician_id'],'warehouse_id'=>$original['warehouse_id'],'reason'=>$reason,'notes'=>'[REVERSAL] '.$original['document_no'].' | '.$reason);$doc=$this->createDocument('correction',$date,$payload,$userId,$key);$this->CI->db->where('id',$doc['id'])->update('technician_equipment_documents',array('reversal_of_id'=>$sourceId));
|
||||
$lines=$this->CI->db->where(array('document_id'=>$sourceId,'company_id'=>$this->company()))->order_by('id','DESC')->get('technician_equipment_document_lines')->result_array();if(!$lines)throw new BusinessException('Detail dokumen sumber tidak ditemukan.');
|
||||
foreach($lines as$line){$subject=array('subject_type'=>$line['subject_type'],'subject_id'=>$line['subject_id'],'item_id'=>$line['item_id'],'barcode_id'=>$line['barcode_id'],'customer_equipment_id'=>$line['customer_equipment_id'],'barcode_snapshot'=>$line['barcode_snapshot'],'ownership_type'=>$line['ownership_type']);
|
||||
if($line['subject_type']==='company_barcode'&&$line['to_custodian_type']==='warehouse'){$this->reserveInternal($line['barcode_id'],$line['qty']);}
|
||||
else{$target=$this->lockMatchingAllocation($line['subject_type'],$line['subject_id'],$line['to_custodian_type'],$line['to_custodian_id'],$line['customer_id'],$line['to_usage_status'],$line['to_condition_status']);$this->consume($target,(float)$line['qty']);}
|
||||
if($line['subject_type']==='company_barcode'&&$line['from_custodian_type']==='warehouse')$this->releaseInternal($line['barcode_id'],(float)$line['qty'],'available');
|
||||
else$this->addBalance($subject,$line['from_custodian_type'],$line['from_custodian_id'],$line['customer_id'],$line['from_usage_status'],$line['from_condition_status'],(float)$line['qty'],$doc['id']);
|
||||
$movement=null;if($line['subject_type']==='company_barcode'){$from=in_array($line['to_custodian_type'],array('quarantine','service_center'),true)?'warehouse':$line['to_custodian_type'];$to=in_array($line['from_custodian_type'],array('quarantine','service_center'),true)?'warehouse':$line['from_custodian_type'];$movement=$this->movement($subject,(float)$line['qty'],$from,$line['to_custodian_id'],$to,$line['from_custodian_id'],'adjustment',$doc['id'],'[AUTO][REVERSAL] '.$original['document_no'].' | '.$reason);}
|
||||
$this->line($doc,$subject,$line['tracking_type'],'correction',$line['to_custodian_type'],$line['to_custodian_id'],$line['from_custodian_type'],$line['from_custodian_id'],$line['customer_id'],$line['to_usage_status'],$line['from_usage_status'],$line['to_condition_status'],$line['from_condition_status'],(float)$line['qty'],$movement,$reason);if($line['subject_type']==='customer_equipment')$this->syncExternal($line['subject_id']);
|
||||
}
|
||||
$this->CI->db->where(array('id'=>$sourceId,'status'=>'posted'))->update('technician_equipment_documents',array('status'=>'reversed','reversed_by'=>(int)$userId,'reversed_at'=>$this->now()));if($this->CI->db->affected_rows()!==1)throw new BusinessException('Status dokumen sumber telah berubah.');if($this->CI->db->trans_status()===false)throw new RuntimeException('Koreksi gagal disimpan.');$this->CI->db->trans_commit();return array('document_id'=>$doc['id'],'document_no'=>$doc['document_no']);
|
||||
}catch(Throwable$e){$this->CI->db->trans_rollback();throw$e;}
|
||||
}
|
||||
|
||||
public function inspectionQueueStats($technicianId=0)
|
||||
{
|
||||
$this->CI->db->select('COUNT(*) row_count,COALESCE(SUM(a.qty),0) qty',false)->from('technician_custody_allocations a')->join('technician_equipment_documents d','d.id=a.last_document_id','left')->where('a.company_id',$this->company())->group_start()->where_in('a.custodian_type',array('technician','quarantine','service_center'))->or_group_start()->where('a.subject_type','customer_equipment')->where_in('a.custodian_type',array('customer','warehouse'))->where('a.usage_status','returned')->group_end()->group_end()->where("(d.document_type IS NULL OR d.document_type <> 'inspection')",null,false)->where_in('a.condition_status',array('inspection_required','repair_required','under_repair','damaged'))->where('a.qty >',0);
|
||||
if((int)$technicianId>0){$employee=(int)$technicianId;$this->CI->db->where("COALESCE(CASE WHEN a.custodian_type='technician' THEN a.custodian_id END,d.technician_id)={$employee}",null,false);}$row=$this->CI->db->get()->row_array();return array('row_count'=>(int)($row['row_count']??0),'qty'=>(float)($row['qty']??0));
|
||||
}
|
||||
|
||||
public function inspectionQueue(array$filters=array())
|
||||
{
|
||||
$this->CI->db
|
||||
->select("a.*,d.document_date,COALESCE(CASE WHEN a.custodian_type='technician' THEN a.custodian_id END,d.technician_id) technician_id,e.full_name technician_name,CASE WHEN a.subject_type='company_barcode' THEN CONCAT(i.kode_detail,' - ',i.nama_barang) ELSE r.equipment_name END display_name,CASE WHEN a.subject_type='company_barcode' THEN COALESCE(k.tracking_type,'QTY') ELSE COALESCE(r.tracking_type,'UNIT') END tracking_type,COALESCE(b.serial_number,r.serial_number) serial_number,w.nama warehouse_name,c.nama customer_name,(a.custodian_type='technician') requires_return",false)
|
||||
->from('technician_custody_allocations a')
|
||||
->join('technician_equipment_documents d','d.id=a.last_document_id','left')
|
||||
->join('k_employees e',"e.id=COALESCE(CASE WHEN a.custodian_type='technician' THEN a.custodian_id END,d.technician_id)",'left')
|
||||
->join('items i','i.id=a.item_id','left')->join('kode_barang k','k.id=i.kode_id','left')
|
||||
->join('item_barcodes b','b.id=a.barcode_id','left')->join('customer_equipment_registry r','r.id=a.customer_equipment_id','left')
|
||||
->join('warehouses w',"w.id=a.custodian_id AND a.custodian_type IN('warehouse','quarantine','service_center')",'left')
|
||||
->join('customers c','c.id=a.customer_id','left')->where('a.company_id',$this->company())
|
||||
->group_start()->where_in('a.custodian_type',array('technician','quarantine','service_center'))->or_group_start()->where('a.subject_type','customer_equipment')->where_in('a.custodian_type',array('customer','warehouse'))->where('a.usage_status','returned')->group_end()->group_end()
|
||||
->where("(d.document_type IS NULL OR d.document_type <> 'inspection')",null,false)
|
||||
->where_in('a.condition_status',array('inspection_required','repair_required','under_repair','damaged'))->where('a.qty >',0);
|
||||
if(!empty($filters['date_from']))$this->CI->db->where('d.document_date >=',$filters['date_from']);
|
||||
if(!empty($filters['date_to']))$this->CI->db->where('d.document_date <=',$filters['date_to']);
|
||||
if(!empty($filters['technician_id'])){$technicianId=(int)$filters['technician_id'];$this->CI->db->where("COALESCE(CASE WHEN a.custodian_type='technician' THEN a.custodian_id END,d.technician_id)={$technicianId}",null,false);}
|
||||
if(!empty($filters['customer_id']))$this->CI->db->where('a.customer_id',(int)$filters['customer_id']);
|
||||
if(!empty($filters['ownership']))$this->CI->db->where('a.ownership_type',$filters['ownership']);
|
||||
if(!empty($filters['condition']))$this->CI->db->where('a.condition_status',$filters['condition']);
|
||||
if(!empty($filters['status']))$this->CI->db->where('a.usage_status',$filters['status']);
|
||||
return$this->CI->db->order_by('requires_return','DESC',false)->order_by('a.updated_at')->get()->result_array();
|
||||
}
|
||||
public function documents(array$filters=array()){$this->CI->db->select('d.*,c.nama customer_name,e.full_name technician_name,t.full_name target_technician_name,w.nama warehouse_name,u.nama creator_name,(SELECT COUNT(*) FROM technician_equipment_document_lines l WHERE l.document_id=d.id) line_count')->from('technician_equipment_documents d')->join('customers c','c.id=d.customer_id','left')->join('k_employees e','e.id=d.technician_id','left')->join('k_employees t','t.id=d.target_technician_id','left')->join('warehouses w','w.id=d.warehouse_id','left')->join('users u','u.id=d.created_by','left')->where('d.company_id',$this->company());if(!empty($filters['date_from']))$this->CI->db->where('d.document_date >=',$filters['date_from']);if(!empty($filters['date_to']))$this->CI->db->where('d.document_date <=',$filters['date_to']);foreach(array('customer_id','technician_id')as$key)if(!empty($filters[$key]))$this->CI->db->where('d.'.$key,(int)$filters[$key]);if(!empty($filters['document_type']))$this->CI->db->where('d.document_type',$filters['document_type']);if(!empty($filters['status']))$this->CI->db->where('d.status',$filters['status']);return$this->CI->db->order_by('d.document_date','DESC')->order_by('d.id','DESC')->limit(1000)->get()->result_array();}
|
||||
public function history(array$filters=array()){$this->CI->db->select("d.id document_id,d.document_no,d.document_type,d.document_date,d.status document_status,d.notes document_notes,d.created_at,d.idempotency_key,e.full_name technician_name,t.full_name target_technician_name,c.nama customer_name,u.nama creator_name,l.*,CASE WHEN l.subject_type='company_barcode' THEN CONCAT(COALESCE(i.kode_detail,'-'),' - ',COALESCE(i.nama_barang,'-')) ELSE COALESCE(r.equipment_name,'-') END display_name,COALESCE(b.serial_number,r.serial_number) serial_number",false)->from('technician_equipment_document_lines l')->join('technician_equipment_documents d','d.id=l.document_id')->join('k_employees e','e.id=d.technician_id','left')->join('k_employees t','t.id=d.target_technician_id','left')->join('customers c','c.id=l.customer_id','left')->join('users u','u.id=d.created_by','left')->join('items i','i.id=l.item_id','left')->join('item_barcodes b','b.id=l.barcode_id','left')->join('customer_equipment_registry r','r.id=l.customer_equipment_id','left')->where('d.company_id',$this->company());if(!empty($filters['date_from']))$this->CI->db->where('d.document_date >=',$filters['date_from']);if(!empty($filters['date_to']))$this->CI->db->where('d.document_date <=',$filters['date_to']);if(!empty($filters['technician_id']))$this->CI->db->group_start()->where('d.technician_id',(int)$filters['technician_id'])->or_where('d.target_technician_id',(int)$filters['technician_id'])->group_end();if(!empty($filters['customer_id']))$this->CI->db->where('l.customer_id',(int)$filters['customer_id']);if(!empty($filters['status']))$this->CI->db->where('l.to_usage_status',$filters['status']);if(!empty($filters['condition']))$this->CI->db->where('l.to_condition_status',$filters['condition']);if(!empty($filters['ownership']))$this->CI->db->where('l.ownership_type',$filters['ownership']);if(!empty($filters['query'])){$q=$this->CI->db->escape_like_str($filters['query']);$this->CI->db->where("(l.barcode_snapshot LIKE '%$q%' OR b.serial_number LIKE '%$q%' OR r.serial_number LIKE '%$q%' OR i.nama_barang LIKE '%$q%' OR r.equipment_name LIKE '%$q%')",null,false);}return$this->CI->db->order_by('d.document_date','DESC')->order_by('l.id','DESC')->limit(5000)->get()->result_array();}
|
||||
public function document($id){$header=$this->CI->db->select('d.*,c.nama customer_name,e.full_name technician_name,t.full_name target_technician_name,w.nama warehouse_name,u.nama creator_name')->from('technician_equipment_documents d')->join('customers c','c.id=d.customer_id','left')->join('k_employees e','e.id=d.technician_id','left')->join('k_employees t','t.id=d.target_technician_id','left')->join('warehouses w','w.id=d.warehouse_id','left')->join('users u','u.id=d.created_by','left')->where(array('d.id'=>(int)$id,'d.company_id'=>$this->company()))->get()->row_array();if(!$header)throw new BusinessException('Dokumen peralatan tidak ditemukan.');$header['lines']=$this->CI->db->select("l.*,CASE WHEN l.subject_type='company_barcode' THEN CONCAT(i.kode_detail,' - ',i.nama_barang) ELSE r.equipment_name END display_name,COALESCE(b.serial_number,r.serial_number) serial_number,c.nama customer_name",false)->from('technician_equipment_document_lines l')->join('items i','i.id=l.item_id','left')->join('item_barcodes b','b.id=l.barcode_id','left')->join('customer_equipment_registry r','r.id=l.customer_equipment_id','left')->join('customers c','c.id=l.customer_id','left')->where('l.document_id',(int)$id)->order_by('l.id')->get()->result_array();$header['attachments']=$this->CI->db->where(array('company_id'=>$this->company(),'entity_type'=>'technician_equipment_document','entity_id'=>(int)$id))->get('transaction_attachments')->result_array();return$header;}
|
||||
public function externalList($technicianId=null){$this->CI->db->select('r.*,c.nama owner_name,cc.nama current_customer_name')->from('customer_equipment_registry r')->join('customers c','c.id=r.owner_customer_id','left')->join('customers cc','cc.id=r.current_customer_id','left')->where('r.company_id',$this->company());if($technicianId){$technicianId=(int)$technicianId;$this->CI->db->where("(EXISTS(SELECT 1 FROM technician_custody_allocations a WHERE a.company_id=r.company_id AND a.subject_type='customer_equipment' AND a.subject_id=r.id AND a.custodian_type='technician' AND a.custodian_id={$technicianId}) OR EXISTS(SELECT 1 FROM technician_equipment_document_lines dl JOIN technician_equipment_documents dh ON dh.id=dl.document_id WHERE dl.company_id=r.company_id AND dl.subject_type='customer_equipment' AND dl.subject_id=r.id AND (dh.technician_id={$technicianId} OR dh.target_technician_id={$technicianId})))",null,false);}return$this->CI->db->order_by('r.id','DESC')->limit(1000)->get()->result_array();}
|
||||
|
||||
public function customerEquipmentSummary($technicianId=0)
|
||||
{
|
||||
$this->CI->db->select("c.id customer_id,c.nama customer_name,COUNT(DISTINCT CONCAT(a.subject_type,':',a.subject_id)) item_count,SUM(a.qty) total_qty,SUM(CASE WHEN a.ownership_type IN('company','transferred_non_inventory') THEN a.qty ELSE 0 END) company_qty,SUM(CASE WHEN a.ownership_type NOT IN('company','transferred_non_inventory') THEN a.qty ELSE 0 END) customer_qty,SUM(CASE WHEN a.usage_status='installed' THEN a.qty ELSE 0 END) installed_qty,SUM(CASE WHEN a.condition_status='good' THEN a.qty ELSE 0 END) good_qty",false)->from('technician_custody_allocations a')->join('customers c','c.id=a.customer_id')->where('a.company_id',$this->company())->where('a.customer_id IS NOT NULL',null,false)->where('a.qty >',0);
|
||||
if((int)$technicianId>0){$employee=(int)$technicianId;$this->CI->db->where("(a.custodian_type='technician' AND a.custodian_id={$employee} OR EXISTS(SELECT 1 FROM technician_equipment_document_lines sl JOIN technician_equipment_documents sd ON sd.id=sl.document_id WHERE sl.company_id=a.company_id AND sl.subject_type=a.subject_type AND sl.subject_id=a.subject_id AND sl.customer_id=a.customer_id AND (sd.technician_id={$employee} OR sd.target_technician_id={$employee})))",null,false);}
|
||||
return$this->CI->db->group_by(array('c.id','c.nama'))->order_by('c.nama')->get()->result_array();
|
||||
}
|
||||
|
||||
public function customerEquipmentRows($customerId,$status='all',$technicianId=0)
|
||||
{
|
||||
$status=in_array($status,array('all','installed','stored','technician','inspection','good','damaged'),true)?$status:'all';
|
||||
$this->CI->db->select("a.*,CASE WHEN a.subject_type='company_barcode' THEN CONCAT(COALESCE(i.kode_detail,'-'),' - ',COALESCE(i.nama_barang,'-')) ELSE COALESCE(r.equipment_name,'-') END display_name,CASE WHEN a.subject_type='company_barcode' THEN COALESCE(k.tracking_type,'QTY') ELSE COALESCE(r.tracking_type,'UNIT') END tracking_type,COALESCE(b.serial_number,r.serial_number) serial_number,c.nama customer_name,e.full_name technician_name,w.nama warehouse_name",false)->from('technician_custody_allocations a')->join('items i','i.id=a.item_id','left')->join('kode_barang k','k.id=i.kode_id','left')->join('item_barcodes b','b.id=a.barcode_id','left')->join('customer_equipment_registry r','r.id=a.customer_equipment_id','left')->join('customers c','c.id=a.customer_id','left')->join('k_employees e',"e.id=a.custodian_id AND a.custodian_type='technician'",'left')->join('warehouses w',"w.id=a.custodian_id AND a.custodian_type IN('warehouse','quarantine','service_center')",'left')->where(array('a.company_id'=>$this->company(),'a.customer_id'=>(int)$customerId))->where('a.qty >',0);
|
||||
if($status==='installed')$this->CI->db->where('a.usage_status','installed');elseif($status==='stored')$this->CI->db->where(array('a.custodian_type'=>'customer','a.usage_status'=>'returned'));elseif($status==='technician')$this->CI->db->where('a.custodian_type','technician');elseif($status==='inspection')$this->CI->db->where_in('a.condition_status',array('inspection_required','repair_required','under_repair'));elseif($status==='good')$this->CI->db->where('a.condition_status','good');elseif($status==='damaged')$this->CI->db->where_in('a.condition_status',array('damaged','lost','scrapped'));
|
||||
if((int)$technicianId>0){$employee=(int)$technicianId;$this->CI->db->where("(a.custodian_type='technician' AND a.custodian_id={$employee} OR EXISTS(SELECT 1 FROM technician_equipment_document_lines sl JOIN technician_equipment_documents sd ON sd.id=sl.document_id WHERE sl.company_id=a.company_id AND sl.subject_type=a.subject_type AND sl.subject_id=a.subject_id AND sl.customer_id=a.customer_id AND (sd.technician_id={$employee} OR sd.target_technician_id={$employee})))",null,false);}
|
||||
return$this->CI->db->order_by('display_name')->order_by('a.barcode_snapshot')->get()->result_array();
|
||||
}
|
||||
|
||||
public function customerEquipmentNotes($customerId,$technicianId=0)
|
||||
{
|
||||
$this->CI->db->select("d.id document_id,d.document_no,d.document_type,d.document_date,d.created_at,COALESCE(NULLIF(TRIM(l.notes),''),NULLIF(TRIM(d.notes),''),'-') note_text,CASE WHEN l.subject_type='company_barcode' THEN CONCAT(COALESCE(i.kode_detail,'-'),' - ',COALESCE(i.nama_barang,'-')) ELSE COALESCE(r.equipment_name,'-') END display_name,l.barcode_snapshot,COALESCE(b.serial_number,r.serial_number) serial_number,l.ownership_type,l.qty,u.nama actor_name,e.full_name technician_name",false)->from('technician_equipment_document_lines l')->join('technician_equipment_documents d','d.id=l.document_id')->join('items i','i.id=l.item_id','left')->join('item_barcodes b','b.id=l.barcode_id','left')->join('customer_equipment_registry r','r.id=l.customer_equipment_id','left')->join('users u','u.id=d.created_by','left')->join('k_employees e','e.id=d.technician_id','left')->where(array('l.company_id'=>$this->company(),'l.customer_id'=>(int)$customerId));
|
||||
if((int)$technicianId>0){$employee=(int)$technicianId;$this->CI->db->group_start()->where('d.technician_id',$employee)->or_where('d.target_technician_id',$employee)->group_end();}
|
||||
return$this->CI->db->order_by('d.document_date','DESC')->order_by('l.id','DESC')->limit(5000)->get()->result_array();
|
||||
}
|
||||
public function external($id){$row=$this->CI->db->select('r.*,c.nama owner_name,cc.nama current_customer_name,u.nama registered_name')->from('customer_equipment_registry r')->join('customers c','c.id=r.owner_customer_id','left')->join('customers cc','cc.id=r.current_customer_id','left')->join('users u','u.id=r.registered_by','left')->where(array('r.id'=>(int)$id,'r.company_id'=>$this->company()))->get()->row_array();if(!$row)throw new BusinessException('Barang customer tidak ditemukan.');$row['allocations']=$this->allocations('customer_equipment',(int)$id);$row['attachments']=$this->CI->db->where(array('company_id'=>$this->company(),'entity_type'=>'customer_equipment_registry','entity_id'=>(int)$id))->get('transaction_attachments')->result_array();return$row;}
|
||||
|
||||
private function createDocument($type,$date,array$data,$userId,$key){$number=$this->CI->numberingservice->next('technician_equipment',$date);$row=array('company_id'=>$this->company(),'document_no'=>$number,'document_type'=>$type,'document_date'=>$date,'status'=>'posted','customer_id'=>(int)($data['customer_id']??0)?:null,'technician_id'=>(int)($data['technician_id']??0)?:null,'target_technician_id'=>(int)($data['target_technician_id']??0)?:null,'warehouse_id'=>(int)($data['warehouse_id']??0)?:null,'work_order_no'=>trim((string)($data['work_order_no']??''))?:null,'installation_location'=>trim((string)($data['installation_location']??''))?:null,'reason'=>trim((string)($data['reason']??''))?:null,'notes'=>trim((string)($data['notes']??''))?:null,'idempotency_key'=>$key,'created_by'=>(int)$userId,'created_at'=>$this->now());$this->CI->db->insert('technician_equipment_documents',$row);$row['id']=(int)$this->CI->db->insert_id();return$row;}
|
||||
private function lockAllocation($id){$row=$this->CI->db->query('SELECT * FROM technician_custody_allocations WHERE id=? AND company_id=? FOR UPDATE',array((int)$id,$this->company()))->row_array();if(!$row||(float)$row['qty']<=0)throw new BusinessException('Saldo posisi barang tidak ditemukan atau sudah berubah.');return$row;}
|
||||
private function lockAllocationBySubject($type,$id,$custodian,$custodianId){$row=$this->CI->db->query('SELECT * FROM technician_custody_allocations WHERE company_id=? AND subject_type=? AND subject_id=? AND custodian_type=? AND custodian_id=? AND qty>0 ORDER BY id LIMIT 1 FOR UPDATE',array($this->company(),$type,(int)$id,$custodian,(int)$custodianId))->row_array();if(!$row)throw new BusinessException('Posisi barang tidak ditemukan.');return$row;}
|
||||
private function lockMatchingAllocation($type,$id,$custodian,$custodianId,$customer,$usage,$condition){$sql='SELECT * FROM technician_custody_allocations WHERE company_id=? AND subject_type=? AND subject_id=? AND custodian_type=? AND COALESCE(custodian_id,0)=? AND COALESCE(customer_id,0)=? AND usage_status=? AND condition_status=? AND qty>0 ORDER BY id LIMIT 1 FOR UPDATE';$row=$this->CI->db->query($sql,array($this->company(),$type,(int)$id,$custodian,(int)$custodianId,(int)$customer,$usage,$condition))->row_array();if(!$row)throw new BusinessException('Koreksi ditolak karena barang hasil transaksi sudah dipindahkan atau statusnya telah berubah.');return$row;}
|
||||
private function validatedQty(array$allocation,$input){$tracking=$this->tracking($allocation);$qty=$tracking==='UNIT'?1:$this->qty($input);if($qty>(float)$allocation['qty']+.0001)throw new BusinessException('Jumlah melebihi saldo pada posisi asal.');if($tracking==='UNIT'&&abs((float)$allocation['qty']-1)>.0001)throw new BusinessException('Saldo barang UNIT tidak valid.');return$qty;}
|
||||
private function tracking(array$subject){if($subject['subject_type']==='company_barcode'){$r=$this->CI->db->select("COALESCE(k.tracking_type,'QTY') tracking_type",false)->from('item_barcodes b')->join('items i','i.id=b.item_id')->join('kode_barang k','k.id=i.kode_id','left')->where('b.id',(int)$subject['barcode_id'])->get()->row();return$r&&strtoupper($r->tracking_type)==='UNIT'?'UNIT':'QTY';}$r=$this->CI->db->get_where('customer_equipment_registry',array('id'=>(int)$subject['subject_id'],'company_id'=>$this->company()))->row();return$r&&strtoupper($r->tracking_type)==='QTY'?'QTY':'UNIT';}
|
||||
private function subject(array$a){return array('subject_type'=>$a['subject_type'],'subject_id'=>(int)$a['subject_id'],'item_id'=>$a['item_id']?:null,'barcode_id'=>$a['barcode_id']?:null,'customer_equipment_id'=>$a['customer_equipment_id']?:null,'barcode_snapshot'=>$a['barcode_snapshot'],'ownership_type'=>$a['ownership_type']);}
|
||||
private function assertReusableExternal(array$source,$customerId){if($source['subject_type']!=='customer_equipment')return;$item=$this->CI->db->get_where('customer_equipment_registry',array('id'=>(int)$source['subject_id'],'company_id'=>$this->company()))->row();if(!$item)throw new BusinessException('Register barang customer tidak ditemukan.');if((int)$item->owner_customer_id!==$customerId&&(!(int)$item->deployment_allowed||trim((string)$item->authorization_reference)===''))throw new BusinessException('Barang milik customer tidak memiliki otorisasi untuk dipasang pada customer lain.');}
|
||||
private function addBalance(array$subject,$type,$id,$customer,$usage,$condition,$qty,$doc){$row=array_merge($subject,array('company_id'=>$this->company(),'custodian_type'=>$type,'custodian_id'=>$id?:null,'customer_id'=>$customer?:null,'usage_status'=>$usage,'condition_status'=>$condition,'qty'=>$qty,'version'=>1,'last_document_id'=>$doc?:null,'created_at'=>$this->now(),'updated_at'=>$this->now()));$row['balance_key']=$this->key($row);$existing=$this->CI->db->query('SELECT * FROM technician_custody_allocations WHERE balance_key=? FOR UPDATE',array($row['balance_key']))->row_array();if($existing){$this->CI->db->where(array('id'=>$existing['id'],'version'=>$existing['version']))->update('technician_custody_allocations',array('qty'=>(float)$existing['qty']+$qty,'version'=>(int)$existing['version']+1,'last_document_id'=>$doc?:$existing['last_document_id'],'updated_at'=>$this->now()));if($this->CI->db->affected_rows()!==1)throw new BusinessException('Saldo tujuan berubah saat transaksi. Silakan ulangi.');$row['id']=$existing['id'];}else{$this->CI->db->insert('technician_custody_allocations',$row);$row['id']=(int)$this->CI->db->insert_id();}return$row;}
|
||||
private function consume(array$a,$qty){$left=round((float)$a['qty']-$qty,4);if($left<-.0001)throw new BusinessException('Saldo posisi barang tidak mencukupi.');if($left<=.0001)$this->CI->db->where(array('id'=>$a['id'],'version'=>$a['version']))->delete('technician_custody_allocations');else$this->CI->db->where(array('id'=>$a['id'],'version'=>$a['version']))->update('technician_custody_allocations',array('qty'=>$left,'version'=>(int)$a['version']+1,'updated_at'=>$this->now()));if($this->CI->db->affected_rows()!==1)throw new BusinessException('Saldo barang berubah saat transaksi. Silakan ulangi.');}
|
||||
private function move(array$doc,array$source,$toType,$toId,$customer,$usage,$condition,$qty,$movementType,$notes,$writeMovement=true){$this->consume($source,$qty);$subject=$this->subject($source);$target=$this->addBalance($subject,$toType,$toId,$customer,$usage,$condition,$qty,$doc['id']);$movement=null;if($writeMovement&&$source['subject_type']==='company_barcode'){$fromType=in_array($source['custodian_type'],array('quarantine','service_center'),true)?'warehouse':$source['custodian_type'];$targetType=in_array($toType,array('quarantine','service_center'),true)?'warehouse':$toType;$movement=$this->movement($subject,$qty,$fromType,$source['custodian_id'],$targetType,$toId,$movementType,$doc['id'],'[AUTO] '.$doc['document_no'].' | '.$notes);}$this->line($doc,$subject,$this->tracking($subject),$doc['document_type'],$source['custodian_type'],$source['custodian_id'],$toType,$toId,$customer,$source['usage_status'],$usage,$source['condition_status'],$condition,$qty,$movement,$notes);if($source['subject_type']==='customer_equipment')$this->syncExternal($source['subject_id']);return$target;}
|
||||
private function movement(array$subject,$qty,$fromType,$fromId,$toType,$toId,$movementType,$docId,$notes){$allowed=array('warehouse','technician','customer','supplier','invoice','asset');if(!in_array($fromType,$allowed,true)||!in_array($toType,$allowed,true))return null;$row=array('item_id'=>$subject['item_id'],'barcode_id'=>$subject['barcode_id'],'qty'=>$qty,'from_type'=>$fromType,'from_id'=>$fromId?:null,'to_type'=>$toType,'to_id'=>$toId?:null,'movement_type'=>$movementType,'reference_type'=>'technician_equipment','reference_id'=>$docId,'notes'=>mb_substr($notes,0,255),'created_at'=>$this->now());if($this->CI->db->field_exists('company_id','item_movements'))$row['company_id']=$this->company();$this->CI->db->insert('item_movements',$row);return(int)$this->CI->db->insert_id();}
|
||||
private function line(array$doc,array$subject,$tracking,$action,$fromType,$fromId,$toType,$toId,$customer,$fromUsage,$toUsage,$fromCondition,$toCondition,$qty,$movement,$notes){$this->CI->db->insert('technician_equipment_document_lines',array('document_id'=>$doc['id'],'company_id'=>$this->company(),'subject_type'=>$subject['subject_type'],'subject_id'=>$subject['subject_id'],'item_id'=>$subject['item_id'],'barcode_id'=>$subject['barcode_id'],'customer_equipment_id'=>$subject['customer_equipment_id'],'barcode_snapshot'=>$subject['barcode_snapshot'],'ownership_type'=>$subject['ownership_type'],'tracking_type'=>$tracking,'action_type'=>$action,'from_custodian_type'=>$fromType,'from_custodian_id'=>$fromId?:null,'to_custodian_type'=>$toType,'to_custodian_id'=>$toId?:null,'customer_id'=>$customer?:null,'from_usage_status'=>$fromUsage,'to_usage_status'=>$toUsage,'from_condition_status'=>$fromCondition,'to_condition_status'=>$toCondition,'qty'=>$qty,'item_movement_id'=>$movement,'notes'=>$notes,'created_at'=>$this->now()));}
|
||||
private function releaseInternal($barcodeId,$qty,$preferred){$b=$this->CI->db->query('SELECT * FROM item_barcodes WHERE id=? FOR UPDATE',array((int)$barcodeId))->row();if(!$b)throw new BusinessException('Barcode perusahaan tidak ditemukan.');$reserved=max(0,round((float)$b->reserved_qty-$qty,4));$available=(float)$b->qty_sisa-$reserved-(float)$b->return_reserved_qty;$status=$available>.0001?$preferred:'installed';$this->CI->db->where(array('id'=>$b->id,'version'=>$b->version))->update('item_barcodes',array('reserved_qty'=>$reserved,'status'=>$status,'version'=>(int)$b->version+1));if($this->CI->db->affected_rows()!==1)throw new BusinessException('Barcode berubah saat pemeriksaan.');}
|
||||
private function reserveInternal($barcodeId,$qty){$b=$this->CI->db->query('SELECT * FROM item_barcodes WHERE id=? FOR UPDATE',array((int)$barcodeId))->row();if(!$b)throw new BusinessException('Barcode perusahaan tidak ditemukan.');$available=(float)$b->qty_sisa-(float)$b->reserved_qty-(float)$b->return_reserved_qty;if((float)$qty>$available+.0001)throw new BusinessException('Koreksi ditolak karena saldo gudang telah digunakan transaksi lain.');$reserved=round((float)$b->reserved_qty+(float)$qty,4);$status=((float)$b->qty_sisa-$reserved-(float)$b->return_reserved_qty)>.0001?'available':'installed';$this->CI->db->where(array('id'=>$b->id,'version'=>$b->version))->update('item_barcodes',array('reserved_qty'=>$reserved,'status'=>$status,'version'=>(int)$b->version+1));if($this->CI->db->affected_rows()!==1)throw new BusinessException('Barcode berubah saat koreksi.');}
|
||||
private function refreshBarcodeStatus($barcodeId,$fallback){$b=$this->CI->db->query('SELECT * FROM item_barcodes WHERE id=? FOR UPDATE',array((int)$barcodeId))->row();if(!$b)return;$available=(float)$b->qty_sisa-(float)$b->reserved_qty-(float)$b->return_reserved_qty;$this->CI->db->where(array('id'=>$b->id,'version'=>$b->version))->update('item_barcodes',array('status'=>$available>.0001?'available':$fallback,'version'=>(int)$b->version+1));}
|
||||
private function syncExternal($id){$alloc=$this->CI->db->where(array('company_id'=>$this->company(),'subject_type'=>'customer_equipment','subject_id'=>(int)$id))->where('qty >',0)->order_by('updated_at','DESC')->get('technician_custody_allocations')->result();if(!$alloc){$this->CI->db->where(array('id'=>(int)$id,'company_id'=>$this->company()))->set('lifecycle_status','inactive')->set('updated_at',$this->now())->set('version','version+1',false)->update('customer_equipment_registry');return;}$types=array_unique(array_map(function($a){return$a->custodian_type;},$alloc));$conditions=array_unique(array_map(function($a){return$a->condition_status;},$alloc));$first=$alloc[0];$condition=count($conditions)>1?'mixed':$first->condition_status;$lifecycle=$condition==='inspection_required'?'quarantined':(in_array($condition,array('repair_required','under_repair'),true)?'under_repair':(in_array($condition,array('scrapped'),true)?'disposed':(in_array($condition,array('damaged','lost'),true)?'inactive':'active')));$this->CI->db->where(array('id'=>(int)$id,'company_id'=>$this->company()))->set(array('custody_type'=>count($types)>1?'mixed':$first->custodian_type,'custody_id'=>count($types)>1?null:$first->custodian_id,'current_customer_id'=>$first->customer_id,'usage_status'=>count($types)>1?'mixed':$first->usage_status,'condition_status'=>$condition,'lifecycle_status'=>$lifecycle,'updated_at'=>$this->now()))->set('version','version+1',false)->update('customer_equipment_registry');}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
|
||||
class TransactionService
|
||||
{
|
||||
private $CI;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->CI =& get_instance();
|
||||
}
|
||||
|
||||
public function run(callable $callback)
|
||||
{
|
||||
$this->CI->db->trans_begin();
|
||||
|
||||
try {
|
||||
$result = $callback();
|
||||
if ($this->CI->db->trans_status() === false) {
|
||||
throw new RuntimeException('Operasi database gagal.');
|
||||
}
|
||||
$this->CI->db->trans_commit();
|
||||
return $result;
|
||||
} catch (Throwable $exception) {
|
||||
$this->CI->db->trans_rollback();
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user