'.html_escape(json_encode($rows,JSON_PRETTY_PRINT)).'');elseif($rows){fputcsv($h,array_keys($rows[0]));foreach($rows as$r)fputcsv($h,$r);}fclose($h); + } +} diff --git a/application/controllers/Receivableattachments.php b/application/controllers/Receivableattachments.php new file mode 100644 index 0000000..9b62e80 --- /dev/null +++ b/application/controllers/Receivableattachments.php @@ -0,0 +1,7 @@ +input->post('entity_type');$id=(int)$this->input->post('entity_id');if(!in_array($type,array('invoice','payment','credit_note','refund'),true)||$id<1)return json_response(false,'Target lampiran tidak valid.',array(),422);$dir=FCPATH.'uploads/accounting/';if(!is_dir($dir)&&!mkdir($dir,0750,true))return json_response(false,'Folder lampiran tidak tersedia.',array(),500);$config=array('upload_path'=>$dir,'allowed_types'=>'pdf|jpg|jpeg|png|webp','max_size'=>5120,'encrypt_name'=>true);$this->load->library('upload',$config);if(!$this->upload->do_upload('attachment'))return json_response(false,strip_tags($this->upload->display_errors()),array(),422);$f=$this->upload->data();$this->db->insert('transaction_attachments',array('module'=>'receivable','entity_type'=>$type,'entity_id'=>$id,'original_name'=>$f['orig_name'],'stored_name'=>$f['file_name'],'mime_type'=>$f['file_type'],'file_size'=>$f['file_size']*1024,'sha256'=>hash_file('sha256',$f['full_path']),'uploaded_by'=>$this->session->userdata('user_id'),'created_at'=>date('Y-m-d H:i:s')));return json_response(true,'Lampiran berhasil disimpan.',array('id'=>$this->db->insert_id()));} + public function download($id){$f=$this->db->get_where('transaction_attachments',array('id'=>(int)$id))->row();if(!$f)show_404();$path=FCPATH.'uploads/accounting/'.$f->stored_name;if(!is_file($path))show_404();$this->load->helper('download');force_download($f->original_name,file_get_contents($path));} +} diff --git a/application/controllers/Receivablecheck.php b/application/controllers/Receivablecheck.php new file mode 100644 index 0000000..dfcdc63 --- /dev/null +++ b/application/controllers/Receivablecheck.php @@ -0,0 +1,19 @@ +db->table_exists($table)){echo "MISSING\n";continue;}foreach($this->db->field_data($table) as $f)echo $f->name.' | '.$f->type.' | '.$f->max_length."\n";} $rows=$this->db->select('status,posted,COUNT(*) total')->group_by(array('status','posted'))->get('invoices')->result();foreach($rows as $r)echo "invoice {$r->status} posted={$r->posted} count={$r->total}\n";echo 'payments='.$this->db->count_all('payments')."\n";} + public function accounts(){foreach($this->db->like('nama_akun','pajak')->or_like('nama_akun','piutang')->or_like('nama_akun','utang muka')->or_like('nama_akun','uang muka')->or_like('nama_akun','bad debt')->or_like('nama_akun','kerugian piutang')->order_by('kode_akun')->get('accounts')->result() as $a)echo $a->id.' | '.$a->kode_akun.' | '.$a->nama_akun."\n";} + public function integrity(){ + $checks=array( + 'duplicate_invoice_numbers'=>"SELECT COUNT(*) total FROM (SELECT no_invoice FROM invoices GROUP BY no_invoice HAVING COUNT(*)>1)x", + 'duplicate_payment_numbers'=>"SELECT COUNT(*) total FROM (SELECT payment_no FROM payments GROUP BY payment_no HAVING COUNT(*)>1)x", + 'overallocated_payments'=>"SELECT COUNT(*) total FROM (SELECT p.id FROM payments p JOIN payment_allocations a ON a.payment_id=p.id GROUP BY p.id,p.jumlah HAVING SUM(a.amount)>p.jumlah+0.01)x", + 'overpaid_invoices'=>"SELECT COUNT(*) total FROM invoices WHERE total_bayar>total+0.01 OR sisa_piutang<0", + 'duplicate_invoice_stock_logs'=>"SELECT COUNT(*) total FROM (SELECT ref_id FROM stock_logs WHERE ref_type='invoice_details' AND tipe='keluar' GROUP BY ref_id HAVING COUNT(*)>1)x", + 'unbalanced_stage5_journals'=>"SELECT COUNT(*) total FROM (SELECT j.id FROM journals j JOIN journal_details d ON d.journal_id=j.id WHERE j.ref_type IN('invoice','customer_payment','credit_note','customer_refund','receivable_writeoff','invoice_reversal') GROUP BY j.id HAVING ABS(SUM(d.debit)-SUM(d.kredit))>0.01)x" + );$failed=0;foreach($checks as$name=>$sql){$n=(int)$this->db->query($sql)->row()->total;echo ($n?'[FAIL] ':'[OK] ').$name.'='.$n."\n";if($n)$failed++;}foreach(array('accounts_receivable','sales_tax_payable','customer_advances','bad_debt_expense')as$key){$n=$this->db->where(array('mapping_key'=>$key,'is_active'=>1))->count_all_results('system_account_mappings');echo ($n?'[OK] ':'[FAIL] ').'mapping_'.$key."\n";if(!$n)$failed++;}if($failed)exit(1); + } + public function summary(){foreach(array('sales_documents','payment_allocations','customer_advances','credit_notes','customer_refunds','receivable_writeoffs','receivable_reminders','transaction_attachments')as$t)echo $t.'='.$this->db->count_all($t)."\n";foreach($this->db->select('workflow_status,status,COUNT(*) total')->group_by(array('workflow_status','status'))->get('invoices')->result()as$r)echo 'invoice_'.$r->workflow_status.'_'.$r->status.'='.$r->total."\n";foreach($this->db->select('code')->where_in('code',array('INVOICE_DEFAULT','CREDIT_NOTE_DEFAULT','WRITEOFF_DEFAULT'))->get('approval_workflows')->result()as$r)echo 'workflow='.$r->code."\n";} +} diff --git a/application/controllers/Receivablereminders.php b/application/controllers/Receivablereminders.php new file mode 100644 index 0000000..5a76767 --- /dev/null +++ b/application/controllers/Receivablereminders.php @@ -0,0 +1,6 @@ +input->method())!=='POST')show_404();$i=$this->db->select('invoices.*,customers.email,customers.telp')->from('invoices')->join('customers','customers.id=invoices.customer_id')->where('invoices.id',(int)$invoiceId)->get()->row();if(!$i||$i->sisa_piutang<=0)return json_response(false,'Invoice tidak memiliki saldo.',array(),422);$channel=$this->input->post('channel');if(!in_array($channel,array('email','whatsapp','phone','manual'),true))return json_response(false,'Channel tidak valid.',array(),422);$recipient=$channel==='email'?$i->email:$i->telp;$message='Pengingat invoice '.$i->no_invoice.' saldo Rp '.number_format($i->sisa_piutang,0,',','.').' jatuh tempo '.$i->jatuh_tempo;$this->db->insert('receivable_reminders',array('invoice_id'=>$i->id,'channel'=>$channel,'recipient'=>$recipient,'message'=>$message,'status'=>'queued','scheduled_at'=>$this->input->post('scheduled_at')?:date('Y-m-d H:i:s'),'created_by'=>$this->session->userdata('user_id'),'created_at'=>date('Y-m-d H:i:s')));return json_response(true,'Reminder masuk antrean. Pengiriman provider eksternal belum diaktifkan.',array('id'=>$this->db->insert_id()));} +} diff --git a/application/controllers/Receivables.php b/application/controllers/Receivables.php new file mode 100644 index 0000000..9fc220b --- /dev/null +++ b/application/controllers/Receivables.php @@ -0,0 +1,73 @@ +load->library(array('ReceivableService','ReceivableAdjustmentsService','SalesService'));} + private function uid(){return(int)$this->session->userdata('user_id');} + private function roleId(){$id=(int)$this->session->userdata('role_id');if(!$id){$u=$this->db->select('role_id')->get_where('users',array('id'=>$this->uid()))->row();$id=$u?(int)$u->role_id:0;}return$id;} + private function company(){return(int)$this->companycontext->id();} + + public function index() + { + $focusId=(int)$this->input->get('invoice_id');$focus=null; + if($focusId)$focus=$this->db->select('i.id,i.customer_id,i.no_invoice,i.sisa_piutang,c.nama customer_name')->from('invoices i')->join('customers c','c.id=i.customer_id')->where(array('i.id'=>$focusId,'i.company_id'=>$this->company(),'i.workflow_status'=>'posted'))->where('i.sisa_piutang >',0)->get()->row(); + $data=array('active_menu'=>'receivables','aging'=>$this->receivableservice->aging($this->input->get('as_of')?:date('Y-m-d')),'focus_invoice'=>$focus,'can_post'=>is_master_admin_user()||check_permission('invoices','can_post')); + $this->load->view('partials/header',$data);$this->load->view('receivables/index',$data);$this->load->view('partials/footer',$data); + } + + public function lookup_customers() + { + $q=trim((string)$this->input->get('q'));$page=max(1,(int)$this->input->get('page'));$limit=20; + $this->db->select('id,nama text,nama')->from('customers')->where(array('company_id'=>$this->company()));if($q!=='')$this->db->like('nama',$q);$rows=$this->db->order_by('nama')->limit($limit+1,($page-1)*$limit)->get()->result();$more=count($rows)>$limit;if($more)array_pop($rows); + return json_response(true,'Customer ditemukan.',array('results'=>$rows,'pagination'=>array('more'=>$more))); + } + + public function lookup_cash_accounts() + { + $q=trim((string)$this->input->get('q'));$page=max(1,(int)$this->input->get('page'));$limit=20; + $this->db->select("a.id,CONCAT(a.kode_akun,' - ',a.nama_akun,' (',IF(a.tipe='asset','Aktiva','Passiva'),')') text",false)->from('accounts a')->where(array('a.company_id'=>$this->company(),'a.is_active'=>1,'a.allow_posting'=>1))->where_in('a.tipe',array('asset','liability'));if(!is_master_admin_user())$this->db->where('a.is_hidden',0);if($q!=='')$this->db->group_start()->like('a.kode_akun',$q)->or_like('a.nama_akun',$q)->group_end();$rows=$this->db->order_by('a.tipe')->order_by('a.kode_akun')->limit($limit+1,($page-1)*$limit)->get()->result();$more=count($rows)>$limit;if($more)array_pop($rows); + return json_response(true,'Akun pelunasan Aktiva/Passiva ditemukan.',array('results'=>$rows,'pagination'=>array('more'=>$more))); + } + + public function open_invoices($customerId) + { + $rows=$this->db->select('id,no_invoice,tanggal,jatuh_tempo,total,total_bayar,sisa_piutang,invoice_type')->where(array('company_id'=>$this->company(),'customer_id'=>(int)$customerId,'workflow_status'=>'posted'))->where('sisa_piutang >',0)->order_by('jatuh_tempo')->order_by('id')->get('invoices')->result(); + return json_response(true,'Invoice terbuka ditemukan.',array('invoices'=>$rows)); + } + + public function payment_history($customerId) + { + $rows=$this->db->select("p.id,p.payment_no,p.tanggal,p.jumlah,p.reference_no,p.created_at,u.nama creator_name,GROUP_CONCAT(CONCAT(i.no_invoice,' · Rp ',FORMAT(pa.amount,2,'id_ID')) ORDER BY i.id SEPARATOR '||') allocations",false)->from('payments p')->join('payment_allocations pa','pa.payment_id=p.id','left')->join('invoices i','i.id=pa.invoice_id','left')->join('users u','u.id=p.created_by','left')->where(array('p.company_id'=>$this->company(),'p.customer_id'=>(int)$customerId,'p.status'=>'posted'))->group_by('p.id')->order_by('p.id','DESC')->limit(50)->get()->result(); + return json_response(true,'Riwayat pembayaran ditemukan.',array('payments'=>$rows)); + } + + public function submit_invoice($id){return$this->action(fn()=>$this->salesservice->submitInvoice($id,$this->uid()),'Invoice diajukan untuk approval.');} + public function approve_invoice($id){return$this->action(fn()=>$this->salesservice->approveAndPostInvoice($id,$this->uid(),$this->roleId()),'Invoice disetujui dan otomatis diposting.');} + public function post_invoice($id){return json_response(false,'Posting terpisah dinonaktifkan. Approval terakhir otomatis memposting invoice.',array('redirect'=>base_url('invoices/detail/'.(int)$id)),410);} + + public function payment() + { + $saved=array(); + try{ + $saved=$this->paymentUploads();$sources=json_decode((string)$this->input->post('sources'),true);$allocations=json_decode((string)$this->input->post('allocations_json'),true); + if(!is_array($sources))$sources=array(array('account_id'=>$this->input->post('cash_account_id'),'amount'=>$this->input->post('amount'))); + if(!is_array($allocations)){$allocations=array();foreach((array)$this->input->post('allocations')as$id=>$amount)$allocations[(int)$id]=$amount;} + $result=$this->salesservice->recordPayment($this->input->post('customer_id'),$this->input->post('date'),$sources,$allocations,(bool)$this->input->post('allow_advance'),$this->uid(),$this->input->post('reference_no',true),$this->input->post('notes',true),$saved); + return json_response(true,'Pembayaran berhasil diposting.',array('result'=>$result)); + }catch(Throwable$e){foreach($saved as$f)if(is_file(FCPATH.$f['stored_name']))@unlink(FCPATH.$f['stored_name']);return business_exception_response($e);} + } + + public function reverse_invoice($id){$this->requireAdmin();return$this->action(fn()=>$this->receivableservice->reverseInvoice($id,$this->input->post('date'),$this->input->post('reason',true),$this->uid()),'Invoice berhasil direversal.');} + public function writeoff($id){$this->requireAdmin();return$this->action(fn()=>$this->receivableservice->postWriteoff($id,$this->input->post('date'),$this->input->post('amount'),$this->input->post('reason',true),$this->uid()),'Write-off berhasil diposting.');} + public function credit_note($id){$this->requireAdmin();return$this->action(fn()=>$this->receivableadjustmentsservice->creditNote($id,$this->input->post('date'),$this->input->post('amount'),$this->input->post('reason',true),$this->uid()),'Credit note berhasil diposting.');} + public function refund($advanceId){$this->requireAdmin();return$this->action(fn()=>$this->receivableadjustmentsservice->refund($advanceId,$this->input->post('date'),$this->input->post('amount'),$this->input->post('bank_account_id'),$this->input->post('reason',true),$this->uid()),'Refund berhasil diposting.');} + public function statement($customerId){$from=$this->input->get('from')?:date('Y-01-01');$to=$this->input->get('to')?:date('Y-m-d');$data=$this->receivableservice->statement($customerId,$from,$to);$data+=array('active_menu'=>'receivables','from'=>$from,'to'=>$to);$this->load->view('partials/header',$data);$this->load->view('receivables/statement',$data);$this->load->view('partials/footer',$data);} + public function receipt($paymentId){redirect('sales/receipt_pdf/'.(int)$paymentId);} + + private function action($fn,$message){if(strtoupper($this->input->method())!=='POST')return json_response(false,'Metode tidak diizinkan.',array(),405);try{return json_response(true,$message,array('result'=>$fn()));}catch(Throwable$e){return business_exception_response($e);}} + private function paymentUploads() + { + $f=$_FILES['attachments']??null;if(!$f||empty($f['name']))throw new BusinessException('Bukti pembayaran gambar atau PDF wajib diunggah.');$names=is_array($f['name'])?$f['name']:array($f['name']);if(count($names)>10)throw new BusinessException('Maksimal 10 file.');$dir=FCPATH.'uploads/accounting/sales/';if(!is_dir($dir)&&!mkdir($dir,0755,true))throw new BusinessException('Folder lampiran tidak tersedia.');$allowed=array('image/jpeg'=>'jpg','image/png'=>'png','image/webp'=>'webp','image/gif'=>'gif','application/pdf'=>'pdf');$fi=new finfo(FILEINFO_MIME_TYPE);$out=array();foreach($names as$i=>$name){$tmp=is_array($f['tmp_name'])?$f['tmp_name'][$i]:$f['tmp_name'];$size=is_array($f['size'])?$f['size'][$i]:$f['size'];$err=is_array($f['error'])?$f['error'][$i]:$f['error'];$mime=$err===UPLOAD_ERR_OK?$fi->file($tmp):'';if(!isset($allowed[$mime])||$size>5*1024*1024)throw new BusinessException('Bukti hanya menerima JPG, PNG, WEBP, GIF, atau PDF maksimal 5 MB.');$stored=bin2hex(random_bytes(16)).'.'.$allowed[$mime];if(!move_uploaded_file($tmp,$dir.$stored))throw new BusinessException('Bukti tidak dapat disimpan.');$out[]=array('original_name'=>basename($name),'stored_name'=>'uploads/accounting/sales/'.$stored,'mime_type'=>$mime,'file_size'=>$size,'sha256'=>hash_file('sha256',$dir.$stored));}return$out; + } +} diff --git a/application/controllers/Reportworker.php b/application/controllers/Reportworker.php new file mode 100644 index 0000000..f4c6082 --- /dev/null +++ b/application/controllers/Reportworker.php @@ -0,0 +1,6 @@ +input->is_cli_request())show_404();$this->load->database();$jobs=$this->db->where('status','queued')->order_by('id')->limit(10)->get('report_export_jobs')->result();foreach($jobs as$j){$this->db->where('id',$j->id)->update('report_export_jobs',array('status'=>'processing','started_at'=>date('Y-m-d H:i:s')));try{$f=json_decode($j->filter_json,true);$where="j.company_id=".(int)$j->company_id." AND j.status IN('posted','reversed') AND j.tanggal BETWEEN ".$this->db->escape($f['from'])." AND ".$this->db->escape($f['to']);$rows=$this->db->query("SELECT j.tanggal,j.no_ref,j.keterangan,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 $where ORDER BY j.tanggal,j.id,d.id")->result_array();$dir=FCPATH.'uploads/accounting/reports/';if(!is_dir($dir))mkdir($dir,0750,true);$file=$j->job_no.'.'.($j->format==='pdf'?'html':'csv');$h=fopen($dir.$file,'w');if($j->format==='pdf'){fwrite($h,'
'.html_escape(json_encode($rows,JSON_PRETTY_PRINT)).'');}elseif($rows){fputcsv($h,array_keys($rows[0]));foreach($rows as$r)fputcsv($h,$r);}fclose($h);$this->db->where('id',$j->id)->update('report_export_jobs',array('status'=>'completed','file_name'=>$file,'completed_at'=>date('Y-m-d H:i:s')));}catch(Throwable$e){$this->db->where('id',$j->id)->update('report_export_jobs',array('status'=>'failed','error_message'=>$e->getMessage(),'completed_at'=>date('Y-m-d H:i:s')));}}echo count($jobs)." job diproses.\n";} +} diff --git a/application/controllers/Rolecheck.php b/application/controllers/Rolecheck.php new file mode 100644 index 0000000..2794d30 --- /dev/null +++ b/application/controllers/Rolecheck.php @@ -0,0 +1,6 @@ +input->is_cli_request())show_404();$view=$this->db->query('SHOW CREATE VIEW `v_users`')->row_array();echo(isset($view['Create View'])?$view['Create View']:'v_users tidak ditemukan').PHP_EOL;foreach($this->db->order_by('id')->get('roles')->result_array()as$r)echo json_encode($r,JSON_UNESCAPED_UNICODE).PHP_EOL;} +} diff --git a/application/controllers/Roles.php b/application/controllers/Roles.php new file mode 100644 index 0000000..6237041 --- /dev/null +++ b/application/controllers/Roles.php @@ -0,0 +1,45 @@ +config->load('permissions', true); + $cfg = $this->config->item('permissions'); + $this->features = $cfg['permission_features']; $this->actions = $cfg['permission_actions']; + } + public function index() + { + $data = array('active_menu'=>'roles','roles'=>$this->db->order_by('is_super_admin','DESC')->order_by('is_system','DESC')->order_by('nama_role')->get('roles')->result(),'features'=>$this->features,'actions'=>$this->actions); + $this->load->view('partials/header',$data);$this->load->view('roles/index',$data);$this->load->view('partials/footer'); + } + public function detail($id) + { + $role=$this->db->get_where('roles',array('id'=>(int)$id))->row();if(!$role)return json_response(false,'Role tidak ditemukan.',array(),404); + $role->permissions=json_decode($role->permissions,true)?:array();return json_response(true,'Role ditemukan.',array('result'=>(array)$role)); + } + public function save() + { + $id=(int)$this->input->post('id');$name=trim((string)$this->input->post('name',true));$description=trim((string)$this->input->post('description',true)); + if($name===''||$description==='')return json_response(false,'Nama dan penjelasan tanggung jawab role wajib diisi.',array(),422); + $existing=$id?$this->db->get_where('roles',array('id'=>$id))->row():null; + if($id&&!$existing)return json_response(false,'Role tidak ditemukan.',array(),404); + if($existing&&!empty($existing->is_super_admin))return json_response(false,'Permission Master Admin bersifat penuh dan tidak dapat dibatasi.',array(),422); + $duplicate=$this->db->where('nama_role',$name)->where('id !=',$id)->count_all_results('roles');if($duplicate)return json_response(false,'Nama role sudah digunakan.',array(),422); + $posted=(array)$this->input->post('permissions');$permissions=array(); + foreach($this->features as$key=>$label){$selected=array_values(array_intersect((array)(isset($posted[$key])?$posted[$key]:array()),array_keys($this->actions)));if($selected){if(!in_array('can_view',$selected,true))array_unshift($selected,'can_view');$permissions[$key]=$selected;}} + if(!$permissions)return json_response(false,'Role wajib mempunyai minimal satu permission.',array(),422); + $data=array('nama_role'=>$name,'description'=>$description,'permissions'=>json_encode($permissions),'is_active'=>$this->input->post('is_active')?1:0,'updated_at'=>date('Y-m-d H:i:s')); + if($id)$this->db->where('id',$id)->update('roles',$data);else{$data['is_system']=0;$data['is_super_admin']=0;$data['created_at']=date('Y-m-d H:i:s');$this->db->insert('roles',$data);$id=$this->db->insert_id();} + log_activity('roles',$existing?'update':'create',($existing?'Mengubah':'Membuat').' role '.$name,'success');return json_response(true,'Role dan permission berhasil disimpan.',array('id'=>$id)); + } + public function delete($id) + { + $role=$this->db->get_where('roles',array('id'=>(int)$id))->row();if(!$role)return json_response(false,'Role tidak ditemukan.',array(),404); + if($role->is_system||$role->is_super_admin)return json_response(false,'Role bawaan aplikasi tidak dapat dihapus.',array(),422); + if($this->db->where('role_id',$role->id)->count_all_results('users'))return json_response(false,'Role masih digunakan oleh pengguna.',array(),422); + $this->db->where('id',$role->id)->delete('roles');return json_response(true,'Role custom berhasil dihapus.'); + } +} diff --git a/application/controllers/Sales.php b/application/controllers/Sales.php new file mode 100644 index 0000000..9e104ec --- /dev/null +++ b/application/controllers/Sales.php @@ -0,0 +1,73 @@ +load->library('SalesService');} + private function uid(){return(int)$this->session->userdata('user_id');} + private function roleId(){return(int)$this->session->userdata('role_id');} + private function allowed($action){return is_master_admin_user()||check_permission('invoices',$action);} + public function index() + { + $sourceOrder=(int)$this->input->get('source_order');if($sourceOrder)redirect('invoices/create?source_order='.$sourceOrder); + $this->renderWorkspace('summary'); + } + public function deliveries(){return $this->renderWorkspace('delivery');} + public function returns(){return $this->renderWorkspace('return');} + private function renderWorkspace($section) + { + $active=$section==='delivery'?'sales_delivery':($section==='return'?'sales_return':'sales'); + $data=array('active_menu'=>$active,'section'=>$section,'summary'=>$this->salesservice->dashboard(),'deliveries'=>$this->salesservice->deliveries(),'returns'=>$this->salesservice->returns(),'accounts'=>array(),'current_user'=>$this->uid(),'source_order'=>0,'focus'=>(int)$this->input->get('focus'),'permissions'=>array('create'=>$this->allowed('can_create'),'update'=>$this->allowed('can_update'),'approve'=>$this->allowed('can_approve'),'post'=>$this->allowed('can_post'),'delete'=>$this->allowed('can_delete'),'export'=>$this->allowed('can_export'))); + $this->load->view('partials/header',$data);$this->load->view('sales/index',$data);$this->load->view('partials/footer',$data); + } + public function lookup_customers(){return$this->lookup('customers','nama',array('id','nama','alamat'));} + public function lookup_items() + { + $q=trim((string)$this->input->get('q'));$company=(int)$this->companycontext->id();$this->db->select('i.id,i.kode_detail AS kode_barang,i.nama_barang,i.harga_jual,i.harga_beli,i.kode_id,k.tracking_type,k.unit')->from('items i')->join('kode_barang k','k.id=i.kode_id','left')->where(array('i.company_id'=>$company,'i.status'=>'active'))->group_start()->like('i.kode_detail',$q)->or_like('i.nama_barang',$q)->group_end()->order_by('i.nama_barang')->limit(30);return json_response(true,'Barang ditemukan.',array('rows'=>$this->db->get()->result())); + } + public function lookup_barcodes() + { + $item=(int)$this->input->get('item_id');$warehouse=(int)$this->input->get('warehouse_id');$company=(int)$this->companycontext->id();$rows=$this->db->select('b.id,b.barcode,b.serial_number,b.qty_sisa,b.reserved_qty,b.return_reserved_qty')->from('item_barcodes b')->join('items i','i.id=b.item_id')->where(array('i.company_id'=>$company,'b.item_id'=>$item,'b.warehouse_id'=>$warehouse,'b.status'=>'available'))->where('(b.qty_sisa-b.reserved_qty-COALESCE(b.return_reserved_qty,0)) >',0,false)->order_by('b.id')->limit(100)->get()->result();return json_response(true,'Barcode tersedia.',array('rows'=>$rows)); + } + private function lookup($table,$label,array$fields){$q=trim((string)$this->input->get('q'));$this->db->select(implode(',',$fields))->where('company_id',(int)$this->companycontext->id())->like($label,$q)->order_by($label)->limit(30);if($this->db->field_exists('is_active',$table))$this->db->where('is_active',1);return json_response(true,'Data ditemukan.',array('rows'=>$this->db->get($table)->result()));} + public function warehouses(){return json_response(true,'Gudang ditemukan.',array('rows'=>$this->db->select('id,nama')->where('company_id',(int)$this->companycontext->id())->order_by('nama')->get('warehouses')->result()));} + public function create_invoice(){return$this->action(function(){return$this->salesservice->createInvoice($this->input->post(),$this->decodeRows(),$this->uid());},'Invoice berhasil disimpan sebagai Draft/Berjalan.');} + public function append_invoice($id){return$this->action(function()use($id){return$this->salesservice->appendInvoiceLines($id,$this->decodeRows(),$this->uid());},'Item berhasil ditambahkan ke invoice berjalan.');} + public function submit_invoice($id){return$this->action(function()use($id){return$this->salesservice->submitInvoice($id,$this->uid());},'Invoice diajukan untuk approval.');} + public function approve_invoice($id){return$this->action(function()use($id){return$this->salesservice->approveAndPostInvoice($id,$this->uid(),$this->roleId());},'Invoice disetujui dan otomatis diposting.');} + public function reject_invoice($id){return$this->action(function()use($id){return$this->salesservice->rejectInvoice($id,$this->input->post('reason',true),$this->uid(),$this->roleId());},'Invoice ditolak dan dikembalikan ke Draft.');} + public function delete_invoice($id){return$this->action(function()use($id){return$this->salesservice->deleteDraftInvoice($id,$this->uid(),is_master_admin_user());},'Draft invoice dibatalkan dan reservasi barcode dilepas.');} + public function post_delivery($id){return$this->action(function()use($id){return$this->salesservice->postDelivery($id,$this->uid());},'Surat Jalan diposting dan stok telah diperbarui.');} + public function update_delivery($id){$saved=array();try{$saved=$this->uploads('attachments',false);$result=$this->salesservice->updateDelivery($id,$this->input->post(),$saved,$this->uid());return json_response(true,'Data dan bukti pengiriman berhasil disimpan.',array('result'=>$result));}catch(Throwable$e){$this->cleanup($saved);return business_exception_response($e);}} + public function invoice_detail($id){return$this->jsonDetail(function()use($id){$this->guardInvoice($this->salesservice->invoiceForEditor($id));return$this->salesservice->invoiceDetail($id);});} + public function delivery_detail($id){return$this->jsonDetail(function()use($id){return$this->salesservice->deliveryDetail($id);});} + public function return_detail($id){return$this->jsonDetail(function()use($id){return$this->salesservice->returnDetail($id);});} + public function payment() + { + return json_response(false,'Alur pembayaran lama dinonaktifkan. Gunakan menu Piutang & Pembayaran.',array('redirect'=>base_url('receivables')),410); + } + public function create_return(){$saved=array();try{if(strtoupper($this->input->method())!=='POST')return json_response(false,'Metode tidak diizinkan.',array(),405);$saved=$this->uploads('attachments',true);$id=$this->salesservice->createReturn($this->input->post(),$this->decodeRows(),$this->uid(),$saved);return json_response(true,'Pengajuan retur berhasil disimpan.',array('result'=>$id));}catch(Throwable$e){$this->cleanup($saved);return business_exception_response($e);}} + public function submit_return($id){return$this->action(function()use($id){return$this->salesservice->submitReturn($id,$this->uid());},'Retur diajukan.');} + public function approve_return($id){return$this->action(function()use($id){return$this->salesservice->approveReturn($id,$this->uid());},'Retur disetujui.');} + public function reject_return($id){return$this->action(function()use($id){return$this->salesservice->rejectReturn($id,$this->input->post('reason',true),$this->uid());},'Retur ditolak.');} + public function receive_return($id){return$this->action(function()use($id){return$this->salesservice->receiveReturn($id,$this->uid());},'Barang retur diterima dan diperiksa.');} + public function post_return($id){return$this->action(function()use($id){return$this->salesservice->postReturn($id,$this->uid());},'Retur, stok, dan credit note berhasil diposting.');} + public function invoice_pdf($id){$this->guardInvoice($this->salesservice->invoiceForEditor($id));$data=$this->salesservice->invoiceDetail($id);$this->pdf('sales/invoice_pdf',$data,'Invoice-'.$data['invoice']->no_invoice.'.pdf');} + public function delivery_pdf($id){$data=$this->salesservice->deliveryDetail($id);$this->pdf('sales/delivery_pdf',$data,'Surat-Jalan-'.$data['delivery']->delivery_no.'.pdf');} + public function return_pdf($id){$data=$this->salesservice->returnDetail($id);$this->pdf('sales/return_pdf',$data,'Retur-'.$data['return']->return_no.'.pdf');} + public function receipt_pdf($id) + { + $company=(int)$this->companycontext->id();$data['payment']=$this->db->select('p.*,c.nama customer_name,c.alamat')->from('payments p')->join('customers c','c.id=p.customer_id')->where(array('p.id'=>(int)$id,'p.company_id'=>$company))->get()->row();if(!$data['payment'])show_404();$data['allocations']=$this->db->select('a.*,i.no_invoice')->from('payment_allocations a')->join('invoices i','i.id=a.invoice_id')->where(array('a.payment_id'=>(int)$id,'i.company_id'=>$company))->get()->result();$data['sources']=$this->db->select('s.*,a.kode_akun,a.nama_akun')->from('payment_sources s')->join('accounts a','a.id=s.account_id')->where(array('s.payment_id'=>(int)$id,'s.company_id'=>$company))->get()->result();$this->pdf('sales/receipt_pdf',$data,'Bukti-Penerimaan-'.$data['payment']->payment_no.'.pdf'); + } + private function decodeRows(){$rows=json_decode((string)$this->input->post('rows'),true);if(!is_array($rows))throw new BusinessException('Detail barang tidak valid.');return$rows;} + private function action($fn,$message){if(strtoupper($this->input->method())!=='POST')return json_response(false,'Metode tidak diizinkan.',array(),405);try{return json_response(true,$message,array('result'=>$fn()));}catch(Throwable$e){return business_exception_response($e);}} + private function jsonDetail($fn){try{return json_response(true,'Detail ditemukan.',$fn());}catch(Throwable$e){return business_exception_response($e);}} + private function pdf($view,$data,$name){$data['pdf_company']=$this->db->get_where('companies',array('id'=>(int)$this->companycontext->id()))->row();require_once FCPATH.'vendor/autoload.php';$html=$this->load->view($view,$data,true);$pdf=new Dompdf\Dompdf();$pdf->loadHtml($html);$pdf->setPaper('A4','portrait');$pdf->render();$pdf->stream($name,array('Attachment'=>false));} + private function uploads($field,$required=false) + { + if(empty($_FILES[$field])||empty($_FILES[$field]['name'])){if($required)throw new BusinessException('Bukti pembayaran gambar atau PDF wajib diunggah.');return array();}$files=$_FILES[$field];$names=is_array($files['name'])?$files['name']:array($files['name']);$saved=array();if(count($names)>10)throw new BusinessException('Maksimal 10 file.');$dir=FCPATH.'uploads/accounting/sales/';if(!is_dir($dir)&&!mkdir($dir,0755,true))throw new BusinessException('Folder lampiran tidak dapat dibuat.');$finfo=new finfo(FILEINFO_MIME_TYPE);$allowed=array('image/jpeg'=>'jpg','image/png'=>'png','image/webp'=>'webp','image/gif'=>'gif','application/pdf'=>'pdf'); + foreach($names as$i=>$name){$tmp=is_array($files['tmp_name'])?$files['tmp_name'][$i]:$files['tmp_name'];$size=is_array($files['size'])?$files['size'][$i]:$files['size'];$error=is_array($files['error'])?$files['error'][$i]:$files['error'];if($error!==UPLOAD_ERR_OK)throw new BusinessException('Salah satu bukti gagal diunggah.');$mime=$finfo->file($tmp);if(!isset($allowed[$mime]))throw new BusinessException('Lampiran hanya menerima JPG, PNG, WEBP, GIF, atau PDF.');if($size>5*1024*1024)throw new BusinessException('Ukuran setiap lampiran maksimal 5 MB.');$stored=bin2hex(random_bytes(16)).'.'.$allowed[$mime];if(!move_uploaded_file($tmp,$dir.$stored))throw new BusinessException('Lampiran tidak dapat disimpan.');$saved[]=array('original_name'=>basename($name),'stored_name'=>'uploads/accounting/sales/'.$stored,'mime_type'=>$mime,'file_size'=>$size,'sha256'=>hash_file('sha256',$dir.$stored));}return$saved; + } + private function cleanup(array$files){foreach($files as$f){$path=FCPATH.$f['stored_name'];if(is_file($path))@unlink($path);}} + private function guardInvoice($invoice){if($invoice->workflow_status==='draft'&&!is_master_admin_user()&&(int)$invoice->created_by!==$this->uid())throw new BusinessException('Draft invoice hanya dapat dilihat oleh pembuatnya.');} +} diff --git a/application/controllers/Salescheck.php b/application/controllers/Salescheck.php new file mode 100644 index 0000000..a79cb73 --- /dev/null +++ b/application/controllers/Salescheck.php @@ -0,0 +1,23 @@ +load->database();$errors=array();$checks=array(); + $required=array('invoices'=>array('company_id','invoice_type','recognition_policy','delivery_status','recognized_revenue','idempotency_key','version'),'invoice_details'=>array('company_id','service_date','delivery_line_id','net_amount','line_type','counter_account_id','idempotency_key'),'sales_deliveries'=>array('company_id','delivery_no','status'),'sales_delivery_barcodes'=>array('barcode_id','qty','returned_qty'),'invoice_line_barcodes'=>array('invoice_id','barcode_id'),'payment_sources'=>array('payment_id','account_id','amount'),'sales_returns'=>array('company_id','return_no','status'),'revenue_recognition_ledger'=>array('idempotency_key')); + foreach($required as$table=>$columns){if(!$this->db->table_exists($table)){$errors[]='Tabel '.$table.' belum ada.';continue;}$fields=$this->db->list_fields($table);foreach($columns as$c)if(!in_array($c,$fields,true))$errors[]='Kolom '.$table.'.'.$c.' belum ada.';}$checks['schema']=!$errors; + if($this->db->table_exists('sales_delivery_lines')){$r=$this->db->query("SELECT COUNT(*) total FROM sales_delivery_lines l JOIN sales_deliveries d ON d.id=l.sales_delivery_id WHERE d.status='posted' AND l.stock_posted_at IS NULL")->row();$checks['posted_delivery_has_stock']=(int)$r->total===0;if(!$checks['posted_delivery_has_stock'])$errors[]=$r->total.' baris Surat Jalan posted belum mempunyai posting stok.';} + if($this->db->table_exists('inventory_ledger')){$r=$this->db->query("SELECT COUNT(*) total FROM(SELECT idempotency_key FROM inventory_ledger WHERE document_type IN('sales_delivery','sales_return') GROUP BY idempotency_key HAVING COUNT(*)>1)x")->row();$checks['stock_idempotency']=(int)$r->total===0;if(!$checks['stock_idempotency'])$errors[]='Terdapat idempotency key stok penjualan ganda.';} + if($this->db->table_exists('invoices')){$r=$this->db->query("SELECT COUNT(*) total FROM invoices WHERE company_id IS NULL OR recognized_revenue>subtotal_before_tax+0.01 OR recognized_cogs<0")->row();$checks['invoice_tenant_recognition']=(int)$r->total===0;if(!$checks['invoice_tenant_recognition'])$errors[]=$r->total.' invoice tidak lolos validasi tenant/pengakuan.';} + if($this->db->table_exists('journals')&&$this->db->table_exists('journal_details')){$r=$this->db->query("SELECT COUNT(*) total FROM(SELECT j.id FROM journals j JOIN journal_details d ON d.journal_id=j.id WHERE j.ref_type IN('sales_delivery','invoice','customer_payment','revenue_recognition','sales_return') GROUP BY j.id HAVING ABS(SUM(d.debit)-SUM(d.kredit))>0.01)x")->row();$checks['sales_journals_balanced']=(int)$r->total===0;if(!$checks['sales_journals_balanced'])$errors[]='Terdapat jurnal penjualan tidak balance.';} + if($this->db->table_exists('invoice_line_barcodes')){$r=$this->db->query("SELECT COUNT(*) total FROM(SELECT invoice_id,barcode_id FROM invoice_line_barcodes GROUP BY invoice_id,barcode_id HAVING COUNT(*)>1)x")->row();$checks['barcode_once_per_invoice']=(int)$r->total===0;if(!$checks['barcode_once_per_invoice'])$errors[]='Terdapat barcode yang dialokasikan lebih dari sekali pada invoice yang sama.';} + if($this->db->table_exists('invoice_details')){$r=$this->db->query("SELECT COUNT(*) total FROM invoice_details WHERE line_type IN('service','loan','savings') AND delivery_line_id IS NOT NULL")->row();$checks['noninventory_without_delivery']=(int)$r->total===0;if(!$checks['noninventory_without_delivery'])$errors[]='Terdapat item nonbarang yang terhubung ke Surat Jalan.';} + if($this->db->table_exists('invoice_details')){$nullable=array();foreach($this->db->query("SHOW COLUMNS FROM invoice_details WHERE Field IN ('items_id','warehouse_id')")->result()as$c)$nullable[$c->Field]=$c->Null;$checks['service_columns_nullable']=($nullable['items_id']??'NO')==='YES'&&($nullable['warehouse_id']??'NO')==='YES';if(!$checks['service_columns_nullable'])$errors[]='Kolom barang/gudang invoice belum nullable sehingga baris jasa tidak dapat disimpan.';} + if($this->db->table_exists('payments')){$this->db->select("p.id,p.payment_no,p.tanggal,p.jumlah,p.reference_no,p.created_at,u.nama creator_name,GROUP_CONCAT(CONCAT(i.no_invoice,' · Rp ',FORMAT(pa.amount,2,'id_ID')) ORDER BY i.id SEPARATOR '||') allocations",false)->from('payments p')->join('payment_allocations pa','pa.payment_id=p.id','left')->join('invoices i','i.id=pa.invoice_id','left')->join('users u','u.id=p.created_by','left')->group_by('p.id')->limit(1)->get()->result();$checks['payment_history_query']=true;} + if($this->db->table_exists('accounts')){$revenue=(int)$this->db->where(array('tipe'=>'revenue','is_active'=>1,'allow_posting'=>1))->count_all_results('accounts');$checks['revenue_account_available']=$revenue>0;if(!$checks['revenue_account_available'])$errors[]='Belum ada akun pendapatan aktif yang dapat diposting.';} + if($this->db->table_exists('cash_accounts')){$cash=(int)$this->db->where('is_active',1)->count_all_results('cash_accounts');$checks['cash_account_available']=$cash>0;if(!$checks['cash_account_available'])$errors[]='Belum ada rekening Kas/Bank aktif untuk penerimaan customer.';} + $checks['pdf_views']=is_file(APPPATH.'views/sales/invoice_pdf.php')&&is_file(APPPATH.'views/sales/delivery_pdf.php')&&is_file(APPPATH.'views/sales/return_pdf.php')&&is_file(APPPATH.'views/sales/receipt_pdf.php');if(!$checks['pdf_views'])$errors[]='Salah satu template PDF belum tersedia.'; + echo json_encode(array('status'=>!$errors,'checks'=>$checks,'errors'=>$errors,'cleanup'=>'Tidak membuat data uji; tidak ada fixture yang tertinggal.'),JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES).PHP_EOL;if($errors)exit(1); + } +} diff --git a/application/controllers/Salesdocuments.php b/application/controllers/Salesdocuments.php new file mode 100644 index 0000000..1504db4 --- /dev/null +++ b/application/controllers/Salesdocuments.php @@ -0,0 +1,14 @@ +load->library(array('NumberingService','AuditService','TransactionService'));} + private function company(){return(int)$this->companycontext->id();}private function uid(){return(int)$this->session->userdata('user_id');} + public function index(){$data=array('active_menu'=>'sales_documents','rows'=>$this->db->select('s.*,c.nama')->from('sales_documents s')->join('customers c','c.id=s.customer_id')->where('s.company_id',$this->company())->order_by('s.document_date','DESC')->limit(200)->get()->result(),'permissions'=>array('create'=>is_master_admin_user()||check_permission('invoices','can_create'),'update'=>is_master_admin_user()||check_permission('invoices','can_update'),'approve'=>is_master_admin_user()||check_permission('invoices','can_approve'),'export'=>is_master_admin_user()||check_permission('invoices','can_export')));$this->load->view('partials/header',$data);$this->load->view('sales_documents/index',$data);$this->load->view('partials/footer',$data);} + public function detail($id){try{$d=$this->db->select('s.*,c.nama customer_name,c.alamat')->from('sales_documents s')->join('customers c','c.id=s.customer_id')->where(array('s.id'=>(int)$id,'s.company_id'=>$this->company()))->get()->row();if(!$d)throw new BusinessException('Dokumen penjualan tidak ditemukan.');$lines=$this->db->select('l.*,i.kode_detail AS kode_barang,i.nama_barang')->from('sales_document_lines l')->join('items i','i.id=l.item_id','left')->where(array('l.sales_document_id'=>$d->id,'l.company_id'=>$this->company()))->order_by('l.id')->get()->result();return json_response(true,'Detail ditemukan.',array('document'=>$d,'lines'=>$lines));}catch(Throwable$e){return business_exception_response($e);}} + public function save(){return$this->action(function(){$type=$this->input->post('document_type');if(!in_array($type,array('quotation','sales_order'),true))throw new BusinessException('Jenis dokumen tidak valid.');$customer=$this->db->get_where('customers',array('id'=>(int)$this->input->post('customer_id'),'company_id'=>$this->company()))->row();if(!$customer)throw new BusinessException('Customer tidak valid.');$rows=json_decode((string)$this->input->post('rows'),true);if(!is_array($rows)||!$rows)throw new BusinessException('Tambahkan minimal satu barang.');$date=$this->input->post('document_date');$subtotal=0;$taxTotal=0;$clean=array();foreach($rows as$n=>$row){$item=$this->db->get_where('items',array('id'=>(int)($row['item_id']??0),'company_id'=>$this->company(),'status'=>'active'))->row();$qty=round((float)str_replace(',','.',(string)($row['qty']??0)),4);$price=$this->money($row['unit_price']??0);$discount=max(0,$this->money($row['discount_amount']??0));$tax=max(0,(float)str_replace(',','.',(string)($row['tax_rate']??0)));if(!$item||$qty<=0||$discount>$qty*$price)throw new BusinessException('Detail barang baris '.($n+1).' tidak valid.');$base=round($qty*$price-$discount,2);$taxAmount=round($base*$tax/100,2);$subtotal+=$base;$taxTotal+=$taxAmount;$clean[]=array('item'=>$item,'qty'=>$qty,'price'=>$price,'discount'=>$discount,'tax'=>$tax,'tax_amount'=>$taxAmount,'total'=>$base+$taxAmount,'specification'=>trim((string)($row['specification']??'')));}$no=$this->numberingservice->next($type,$date);$this->db->insert('sales_documents',array('company_id'=>$this->company(),'document_type'=>$type,'document_no'=>$no,'customer_id'=>$customer->id,'document_date'=>$date,'valid_until'=>$this->input->post('valid_until')?:null,'status'=>'draft','subtotal'=>$subtotal,'tax_total'=>$taxTotal,'total'=>$subtotal+$taxTotal,'notes'=>$this->input->post('notes',true),'created_by'=>$this->uid(),'created_at'=>date('Y-m-d H:i:s')));$id=(int)$this->db->insert_id();foreach($clean as$x)$this->db->insert('sales_document_lines',array('company_id'=>$this->company(),'sales_document_id'=>$id,'item_id'=>$x['item']->id,'account_id'=>null,'description'=>$x['item']->nama_barang,'specification'=>$x['specification'],'unit'=>null,'qty'=>$x['qty'],'unit_price'=>$x['price'],'discount_amount'=>$x['discount'],'tax_rate'=>$x['tax'],'tax_amount'=>$x['tax_amount'],'line_total'=>$x['total']));$this->auditservice->record('sales','sales_document',$id,'create',null,array('document_no'=>$no,'line_count'=>count($clean),'total'=>$subtotal+$taxTotal),$this->uid());return$id;},'Dokumen penjualan berhasil disimpan.');} + public function workflow($id,$state){return$this->action(function()use($id,$state){$d=$this->db->query('SELECT * FROM sales_documents WHERE id=? AND company_id=? FOR UPDATE',array((int)$id,$this->company()))->row();if(!$d)throw new BusinessException('Dokumen tidak ditemukan.');$allowed=array('draft'=>array('sent','cancelled'),'sent'=>array('accepted','rejected'),'accepted'=>array('cancelled'));if(!in_array($state,$allowed[$d->status]??array(),true))throw new BusinessException('Perubahan status dokumen tidak diizinkan.');$this->db->where('id',$d->id)->update('sales_documents',array('status'=>$state,'updated_by'=>$this->uid(),'updated_at'=>date('Y-m-d H:i:s')));$this->auditservice->record('sales','sales_document',$d->id,'workflow',$d,array('status'=>$state),$this->uid());return$d->id;},'Status dokumen berhasil diperbarui.');} + public function convert($id){return$this->action(function()use($id){$d=$this->db->query('SELECT * FROM sales_documents WHERE id=? AND company_id=? FOR UPDATE',array((int)$id,$this->company()))->row();if(!$d||$d->document_type!=='quotation'||$d->status!=='accepted')throw new BusinessException('Hanya Penawaran Diterima yang dapat dijadikan Sales Order.');$existing=$this->db->get_where('sales_documents',array('source_quotation_id'=>$d->id,'company_id'=>$this->company()))->row();if($existing)return$existing->id;$no=$this->numberingservice->next('sales_order',date('Y-m-d'));$copy=(array)$d;unset($copy['id']);$copy['document_type']='sales_order';$copy['document_no']=$no;$copy['document_date']=date('Y-m-d');$copy['status']='draft';$copy['source_quotation_id']=$d->id;$copy['parent_document_id']=$d->id;$copy['created_by']=$this->uid();$copy['created_at']=date('Y-m-d H:i:s');$copy['updated_at']=null;$copy['updated_by']=null;$this->db->insert('sales_documents',$copy);$new=(int)$this->db->insert_id();foreach($this->db->get_where('sales_document_lines',array('sales_document_id'=>$d->id,'company_id'=>$this->company()))->result_array()as$l){unset($l['id']);$l['sales_document_id']=$new;$this->db->insert('sales_document_lines',$l);}$this->db->where('id',$d->id)->update('sales_documents',array('status'=>'converted','updated_at'=>date('Y-m-d H:i:s'),'updated_by'=>$this->uid()));return$new;},'Penawaran berhasil dikonversi menjadi Sales Order.');} + private function money($v){$v=trim(str_ireplace(array('Rp',' '),'',(string)$v));if(strpos($v,',')!==false)$v=str_replace(',','.',str_replace('.','',$v));elseif(preg_match('/^\d{1,3}(\.\d{3})+$/',$v))$v=str_replace('.','',$v);return round((float)$v,2);} + private function action($fn,$message){if(strtoupper($this->input->method())!=='POST')return json_response(false,'Metode tidak diizinkan.',array(),405);try{return json_response(true,$message,array('result'=>$this->transactionservice->run($fn)));}catch(Throwable$e){return business_exception_response($e);}} +} diff --git a/application/controllers/Scalabilitycheck.php b/application/controllers/Scalabilitycheck.php new file mode 100644 index 0000000..f442f55 --- /dev/null +++ b/application/controllers/Scalabilitycheck.php @@ -0,0 +1,6 @@ +input->is_cli_request())show_404();$iterations=min(100,max(1,(int)$iterations));$company=$this->db->order_by('id')->get('companies')->row();$queries=array('dashboard_summary'=>array("SELECT SUM(d.debit),SUM(d.kredit) FROM journal_details d JOIN journals j ON j.id=d.journal_id WHERE j.company_id=? AND j.status IN('posted','reversed')",array($company->id)),'open_receivables'=>array("SELECT i.id FROM invoices i WHERE i.company_id=? AND i.workflow_status='posted' AND i.sisa_piutang>0 ORDER BY i.jatuh_tempo LIMIT 50",array($company->id)),'general_ledger'=>array("SELECT d.id FROM journal_details d JOIN journals j ON j.id=d.journal_id WHERE j.company_id=? AND j.status IN('posted','reversed') ORDER BY j.tanggal DESC,j.id DESC LIMIT 100",array($company->id)));$timings=array();foreach($queries as$name=>$q){$samples=array();for($i=0;$i<$iterations;$i++){$s=microtime(true);$this->db->query($q[0],$q[1])->result();$samples[]=(microtime(true)-$s)*1000;}sort($samples);$timings[$name]=array('avg_ms'=>round(array_sum($samples)/count($samples),3),'p95_ms'=>round($samples[(int)floor((count($samples)-1)*.95)],3),'target_ms'=>2000,'pass'=>max($samples)<2000);}$volumes=array();foreach(array('journals','journal_details','invoices','supplier_invoices','inventory_ledger','activity_logs','app_jobs','app_dead_letters')as$t)$volumes[$t]=$this->db->table_exists($t)?(int)$this->db->count_all($t):null;$result=array('iterations'=>$iterations,'volumes'=>$volumes,'queries'=>$timings,'all_pass'=>!in_array(false,array_column($timings,'pass'),true));echo json_encode($result,JSON_PRETTY_PRINT).PHP_EOL;if(!$result['all_pass'])exit(1);} +} diff --git a/application/controllers/Scheduler.php b/application/controllers/Scheduler.php index be0d549..9e6f784 100644 --- a/application/controllers/Scheduler.php +++ b/application/controllers/Scheduler.php @@ -1,7 +1,7 @@ 'Shift berhasil dihapus' ]); } -} \ No newline at end of file +} diff --git a/application/controllers/Stage1011check.php b/application/controllers/Stage1011check.php new file mode 100644 index 0000000..42dc6ff --- /dev/null +++ b/application/controllers/Stage1011check.php @@ -0,0 +1,6 @@ +input->is_cli_request())show_404();$this->load->database();$checks=array('migration'=>(int)$this->db->get('migrations')->row()->version,'employees'=>(int)$this->db->count_all('k_employees'),'employment_history'=>(int)$this->db->count_all('employee_employment_history'),'history_missing'=>(int)$this->db->query('SELECT COUNT(*) n FROM k_employees e LEFT JOIN employee_employment_history h ON h.employee_id=e.id WHERE h.id IS NULL')->row()->n,'duplicate_raw_attendance'=>(int)$this->db->query('SELECT COUNT(*) n FROM(SELECT device_id,payload_hash FROM attendance_raw_logs GROUP BY device_id,payload_hash HAVING COUNT(*)>1)x')->row()->n,'locked_attendance'=>(int)$this->db->where('status','locked')->count_all_results('attendance_period_locks'),'payroll_components'=>(int)$this->db->count_all('payroll_components'),'final_payroll_without_hash'=>(int)$this->db->query("SELECT COUNT(*) n FROM k_payroll_periods WHERE workflow_status IN('final','posted','paid') AND calculation_hash IS NULL")->row()->n,'posted_payroll_without_journal'=>(int)$this->db->query("SELECT COUNT(*) n FROM k_payroll_periods WHERE workflow_status IN('posted','paid') AND journal_id IS NULL")->row()->n,'payroll_register_mismatch'=>(int)$this->db->query("SELECT COUNT(*) n FROM k_payrolls WHERE ABS(total_salary-(gross_salary-total_deductions))>.01")->row()->n,'tax_codes'=>(int)$this->db->count_all('tax_codes'),'tax_transactions'=>(int)$this->db->count_all('tax_transactions'),'duplicate_tax_keys'=>(int)$this->db->query('SELECT COUNT(*) n FROM(SELECT idempotency_key FROM tax_transactions GROUP BY idempotency_key HAVING COUNT(*)>1)x')->row()->n,'tax_without_source'=>(int)$this->db->query("SELECT COUNT(*) n FROM tax_transactions WHERE source_id IS NULL OR source_type='' ")->row()->n);echo json_encode($checks,JSON_PRETTY_PRINT).PHP_EOL;} +} diff --git a/application/controllers/Stage1214check.php b/application/controllers/Stage1214check.php new file mode 100644 index 0000000..6ec4024 --- /dev/null +++ b/application/controllers/Stage1214check.php @@ -0,0 +1,6 @@ +input->is_cli_request())show_404();$this->load->database();$critical=array('accounts','journals','journal_details','customers','invoices','suppliers','supplier_invoices','items','assets','cash_transactions','k_employees','k_payrolls','tax_transactions');$null=0;$detail=array();foreach($critical as$t){$n=(int)$this->db->where('company_id IS NULL',null,false)->count_all_results($t);$detail[$t]=$n;$null+=$n;}$q=array('migration'=>(int)$this->db->get('migrations')->row()->version,'companies'=>(int)$this->db->count_all('companies'),'user_without_company'=>(int)$this->db->query('SELECT COUNT(*)n FROM users u LEFT JOIN user_companies x ON x.user_id=u.id WHERE x.id IS NULL')->row()->n,'critical_rows_without_company'=>$null,'critical_detail'=>$detail,'journal_lines_without_company'=>(int)$this->db->where('company_id IS NULL',null,false)->count_all_results('journal_details'),'journal_lines_with_dimension'=>(int)$this->db->group_start()->where('branch_id IS NOT NULL',null,false)->or_where('department_id IS NOT NULL',null,false)->or_where('project_id IS NOT NULL',null,false)->or_where('cost_center_id IS NOT NULL',null,false)->group_end()->count_all_results('journal_details'),'unbalanced_by_company'=>(int)$this->db->query('SELECT COUNT(*)n FROM(SELECT j.company_id,j.id FROM journals j JOIN journal_details d ON d.journal_id=j.id GROUP BY j.company_id,j.id HAVING ABS(SUM(d.debit-d.kredit))>.01)x')->row()->n,'budgets'=>(int)$this->db->count_all('budgets'),'currencies'=>(int)$this->db->count_all('currencies'),'duplicate_rates'=>(int)$this->db->query('SELECT COUNT(*)n FROM(SELECT company_id,rate_date,from_currency,to_currency FROM exchange_rates GROUP BY company_id,rate_date,from_currency,to_currency HAVING COUNT(*)>1)x')->row()->n);echo json_encode($q,JSON_PRETTY_PRINT).PHP_EOL;} +} diff --git a/application/controllers/Stage15check.php b/application/controllers/Stage15check.php new file mode 100644 index 0000000..12d4d1e --- /dev/null +++ b/application/controllers/Stage15check.php @@ -0,0 +1,6 @@ +input->is_cli_request())show_404();$root=FCPATH;$header=file_get_contents(APPPATH.'views/partials/header.php');$footer=file_get_contents(APPPATH.'views/partials/footer.php');$nav=file_get_contents(APPPATH.'libraries/NavigationService.php');$sw=file_get_contents($root.'service-worker.js');$manifest=json_decode(file_get_contents($root.'manifest.webmanifest'),true);$iconsOk=true;foreach($manifest['icons']as$i){$path=$root.$i['src'];$size=is_file($path)?getimagesize($path):false;$expected=explode('x',$i['sizes']);if(!$size||$size[0]!=(int)$expected[0]||$size[1]!=(int)$expected[1])$iconsOk=false;}$checks=array('single_menu_partial'=>substr_count($header,"partials/navigation_items")===2,'desktop_mobile_same_source'=>substr_count($header,"compact('menu_items','active_menu')")===2,'dead_umum_removed'=>strpos($nav,"'url'=>'umum'")===false,'menu_search'=>strpos($header,'menu-filter')!==false,'quick_actions'=>strpos($header,'quickActions')!==false,'breadcrumb'=>strpos($header,'app-breadcrumb')!==false,'approval_badge'=>strpos($nav,'approvalBadge')!==false,'local_bootstrap'=>is_file($root.'assets/vendor/bootstrap.min.css')&&is_file($root.'assets/vendor/bootstrap.bundle.min.js'),'local_jquery'=>is_file($root.'assets/vendor/jquery.min.js'),'responsive_css'=>is_file($root.'assets/css/app-responsive.min.css'),'double_submit_guard'=>strpos(file_get_contents($root.'assets/js/app-ui.min.js'),"dataset.submitting")!==false,'offline_post_block'=>strpos(file_get_contents($root.'assets/js/app-ui.min.js'),"navigator.onLine")!==false,'static_only_cache'=>strpos($sw,"/assets/")!==false&&strpos($sw,"method!=='GET'")!==false,'pwa_icons_exact'=>$iconsOk,'manifest_standalone'=>isset($manifest['display'])&&$manifest['display']==='standalone','duplicate_login_swal'=>substr_count(file_get_contents(APPPATH.'views/auth/login.php'),'sweetalert2')<=1);$failed=array_keys(array_filter($checks,function($x){return!$x;}));echo json_encode(array('checks'=>$checks,'failed'=>$failed,'viewport_artifacts'=>array('360x800','390x844','768x1024','1366x768')),JSON_PRETTY_PRINT).PHP_EOL;if($failed)exit(1);} +} diff --git a/application/controllers/Stage16check.php b/application/controllers/Stage16check.php new file mode 100644 index 0000000..3f3fa46 --- /dev/null +++ b/application/controllers/Stage16check.php @@ -0,0 +1,6 @@ +input->is_cli_request())show_404();$this->load->library(array('PerformanceCacheService','JobQueueService'));$required=array('ci_sessions','app_cache','dashboard_summaries','app_jobs','app_dead_letters','database_health_metrics','activity_logs_archive');$missing=array_values(array_filter($required,function($t){return!$this->db->table_exists($t);}));$cache=$this->performancecacheservice->remember('stage16:check',60,function(){return array('ok'=>true,'at'=>date('c'));});$company=$this->db->order_by('id')->get('companies')->row();$payload=array('report_code'=>'journal_register','format'=>'csv','filters'=>array('from'=>date('Y-01-01'),'to'=>date('Y-m-d')),'requested_by'=>1);$first=$this->jobqueueservice->enqueue('report_export',$payload,'stage16:test:report','reports',5,3,$company->id);$second=$this->jobqueueservice->enqueue('report_export',$payload,'stage16:test:report','reports',5,3,$company->id);$job=$this->db->get_where('app_jobs',array('id'=>$first))->row();$result=array('missing_tables'=>$missing,'cache_ok'=>!empty($cache['ok']),'queue_idempotent'=>$first===$second,'job_id'=>$first,'job_status'=>$job->status,'db_session_driver'=>$this->config->item('sess_driver'),'status'=>!$missing&&!empty($cache['ok'])&&$first===$second?'ok':'failed');echo json_encode($result,JSON_PRETTY_PRINT).PHP_EOL;if($result['status']!=='ok')exit(1);} +} diff --git a/application/controllers/Stage17check.php b/application/controllers/Stage17check.php new file mode 100644 index 0000000..b14d578 --- /dev/null +++ b/application/controllers/Stage17check.php @@ -0,0 +1,103 @@ +input->is_cli_request()) show_error('QA check hanya tersedia melalui CLI.', 403); + } + + public function index() + { + $this->zero('journal_balance', "SELECT COUNT(*) n FROM (SELECT j.id FROM journals j JOIN journal_details d ON d.journal_id=j.id WHERE j.status IN('posted','reversed') GROUP BY j.id HAVING ABS(SUM(d.debit)-SUM(d.kredit))>.009) x", 'Jurnal posted/reversed harus balance.'); + $this->zero('posted_journal_has_lines', "SELECT COUNT(*) n FROM journals j LEFT JOIN journal_details d ON d.journal_id=j.id WHERE j.status IN('posted','reversed') GROUP BY j.id HAVING COUNT(d.id)<2", 'Setiap jurnal posted minimal memiliki dua baris.', true); + $this->zero('invoice_journal_trace', "SELECT COUNT(*) n FROM invoices i LEFT JOIN journals j ON j.id=i.journal_id WHERE i.workflow_status IN('posted','reversed') AND i.posted_by IS NOT NULL AND j.id IS NULL AND EXISTS(SELECT 1 FROM invoice_details d WHERE d.invoice_id=i.id AND d.line_type<>'savings')", 'Invoice penjualan posted harus memiliki jurnal sumber; invoice Tabungan baru dijurnal saat pembayaran.'); + $this->warning('legacy_invoice_without_journal', "SELECT COUNT(*) n FROM invoices WHERE workflow_status IN('posted','reversed') AND posted_by IS NULL AND journal_id IS NULL", 'Invoice legacy tanpa jurnal perlu direkonsiliasi sebelum cut-over production.'); + $this->zero('invoice_balance_formula', "SELECT COUNT(*) n FROM invoices WHERE workflow_status IN('posted','reversed') AND ABS(sisa_piutang-GREATEST(total-total_bayar,0))>.01", 'Saldo invoice harus sama dengan total dikurangi pembayaran.'); + $this->zero('payment_allocation_limit', "SELECT COUNT(*) n FROM payments p LEFT JOIN (SELECT payment_id,SUM(amount) allocated FROM payment_allocations GROUP BY payment_id)a ON a.payment_id=p.id WHERE COALESCE(a.allocated,0)>p.jumlah+.01", 'Alokasi pembayaran tidak boleh melebihi penerimaan.'); + $this->zero('payment_journal_trace', "SELECT COUNT(*) n FROM payments p LEFT JOIN journals j ON j.id=p.journal_id WHERE p.status='posted' AND p.payment_no NOT LIKE 'LEG-PAY-%' AND j.id IS NULL", 'Pembayaran baru posted harus memiliki jurnal.'); + $this->zero('purchase_payment_bank_charge_formula', "SELECT COUNT(*) n FROM supplier_payments WHERE ABS(total_cash_out-(amount+bank_charge_amount))>.01 OR bank_charge_amount<0 OR (bank_charge_amount>0 AND bank_charge_account_id IS NULL)", 'Total kas keluar pembelian harus sama dengan pokok pembayaran ditambah admin bank.'); + $this->zero('purchase_payment_source_total', "SELECT COUNT(*) n FROM supplier_payments p LEFT JOIN (SELECT supplier_payment_id,SUM(amount) source_total FROM purchase_payment_sources GROUP BY supplier_payment_id) s ON s.supplier_payment_id=p.id WHERE p.status='posted' AND ABS(COALESCE(s.source_total,p.amount)-p.total_cash_out)>.01", 'Total sumber Kas/Bank harus sama dengan total kas keluar pembayaran pembelian.'); + $this->zero('purchase_payment_bank_charge_mapping', "SELECT COUNT(*) n FROM (SELECT 1 marker) x WHERE NOT EXISTS(SELECT 1 FROM system_account_mappings m JOIN accounts a ON a.id=m.account_id WHERE m.mapping_key='bank_charge_expense' AND m.is_active=1 AND a.is_active=1 AND a.allow_posting=1 AND a.tipe='expense')", 'Mapping beban administrasi bank harus mengarah ke akun beban aktif.'); + $this->zero('purchase_return_draft_does_not_move_stock', "SELECT COUNT(*) n FROM stock_logs sl JOIN purchase_returns pr ON pr.id=sl.ref_id AND sl.ref_type='purchase_return' WHERE pr.status IN('draft','submitted','approved','reserved')", 'Draft, pengajuan, approval, dan reservasi retur tidak boleh mengurangi stok.'); + $this->zero('purchase_return_quantity_limit', "SELECT COUNT(*) n FROM (SELECT l.receipt_line_id,SUM(CASE WHEN h.status NOT IN('draft','rejected','cancelled','reversed') THEN l.qty ELSE 0 END) returned_qty,MAX(grl.qty) received_qty FROM purchase_return_lines l JOIN purchase_returns h ON h.id=l.purchase_return_id JOIN goods_receipt_lines grl ON grl.id=l.receipt_line_id GROUP BY l.receipt_line_id HAVING returned_qty>received_qty+.0001)x", 'Total retur tidak boleh melebihi kuantitas penerimaan.'); + $this->zero('purchase_return_post_idempotency', "SELECT COUNT(*) n FROM (SELECT purchase_return_line_id FROM purchase_return_shipment_lines GROUP BY purchase_return_line_id HAVING COUNT(*)>1)x", 'Satu baris retur tidak boleh diposting ke pengiriman lebih dari satu kali.'); + $this->zero('purchase_return_barcode_reservation', "SELECT COUNT(*) n FROM item_barcodes WHERE return_reserved_qty<0 OR return_reserved_qty>qty_sisa+.0001", 'Reservasi retur barcode tidak boleh negatif atau melebihi saldo barcode.'); + $this->zero('supplier_refund_balance_formula', "SELECT COUNT(*) n FROM supplier_refund_claims WHERE ABS(balance-GREATEST(claim_amount-received_amount,0))>.01 OR received_amount>claim_amount+.01", 'Saldo refund harus sama dengan nilai klaim dikurangi dana diterima.'); + $this->zero('supplier_refund_receipt_limit', "SELECT COUNT(*) n FROM (SELECT supplier_refund_claim_id,SUM(amount) received,MAX(c.claim_amount) claimed FROM supplier_refund_receipts r JOIN supplier_refund_claims c ON c.id=r.supplier_refund_claim_id WHERE r.status<>'reversed' GROUP BY supplier_refund_claim_id HAVING received>claimed+.01)x", 'Akumulasi penerimaan refund tidak boleh melebihi klaim.'); + $this->zero('purchase_adjustment_tenant_isolation', "SELECT COUNT(*) n FROM purchase_return_lines l JOIN purchase_returns h ON h.id=l.purchase_return_id WHERE COALESCE(l.company_id,0)<>COALESCE(h.company_id,0)", 'Header dan detail retur wajib berada pada perusahaan yang sama.'); + $this->zero('purchase_adjustment_mapping', "SELECT COUNT(*) n FROM (SELECT 'supplier_refund_receivable' k UNION ALL SELECT 'purchase_return_adjustment' UNION ALL SELECT 'purchase_price_variance') x WHERE NOT EXISTS(SELECT 1 FROM system_account_mappings m JOIN accounts a ON a.id=m.account_id WHERE m.mapping_key=x.k AND m.is_active=1 AND a.is_active=1 AND a.allow_posting=1)", 'Seluruh mapping akun Retur/Refund harus aktif dan dapat diposting.'); + $this->zero('purchase_adjustment_orphan_attachment', "SELECT COUNT(*) n FROM transaction_attachments a WHERE (a.entity_type='purchase_return' AND NOT EXISTS(SELECT 1 FROM purchase_returns x WHERE x.id=a.entity_id)) OR (a.entity_type='purchase_replacement' AND NOT EXISTS(SELECT 1 FROM purchase_replacements x WHERE x.id=a.entity_id)) OR (a.entity_type='supplier_refund' AND NOT EXISTS(SELECT 1 FROM supplier_refund_claims x WHERE x.id=a.entity_id)) OR (a.entity_type='supplier_refund_receipt' AND NOT EXISTS(SELECT 1 FROM supplier_refund_receipts x WHERE x.id=a.entity_id)) OR (a.entity_type='supplier_debit_note' AND NOT EXISTS(SELECT 1 FROM supplier_debit_notes x WHERE x.id=a.entity_id))", 'Lampiran penyesuaian pembelian wajib memiliki dokumen sumber.'); + $this->warning('legacy_payment_without_journal', "SELECT COUNT(*) n FROM payments WHERE status='posted' AND payment_no LIKE 'LEG-PAY-%' AND journal_id IS NULL", 'Pembayaran legacy tanpa jurnal perlu direkonsiliasi sebelum cut-over production.'); + $this->zero('credit_note_journal_trace', "SELECT COUNT(*) n FROM credit_notes c LEFT JOIN journals j ON j.id=c.journal_id WHERE c.status='posted' AND j.id IS NULL", 'Credit note posted harus memiliki jurnal reversal nilai.'); + $this->zero('inventory_negative_stock', "SELECT COUNT(*) n FROM (SELECT item_id,warehouse_id,SUM(IF(direction='in',qty,-qty)) qty FROM inventory_ledger GROUP BY item_id,warehouse_id HAVING qty<-.0001)x", 'Stock ledger tidak boleh negatif.'); + $this->zero('inventory_duplicate_idempotency', "SELECT COUNT(*) n FROM (SELECT idempotency_key FROM inventory_ledger GROUP BY idempotency_key HAVING COUNT(*)>1)x", 'Mutasi stok tidak boleh diproses dua kali.'); + $this->zero('inventory_source_trace', "SELECT COUNT(*) n FROM inventory_ledger WHERE document_type='' OR document_id IS NULL", 'Mutasi stok wajib memiliki dokumen sumber.'); + $this->zero('depreciation_duplicate', "SELECT COUNT(*) n FROM (SELECT asset_id,period FROM asset_depreciation_schedule GROUP BY asset_id,period HAVING COUNT(*)>1)x", 'Penyusutan bulanan harus idempotent.'); + $this->zero('depreciation_residual_limit', "SELECT COUNT(*) n FROM asset_depreciation_schedule s JOIN assets a ON a.id=s.asset_id WHERE s.closing_book_value
Directory access is forbidden.
- - + +Directory access is forbidden.
+ diff --git a/application/core/MY_Controller.php b/application/core/MY_Controller.php new file mode 100644 index 0000000..b0ba940 --- /dev/null +++ b/application/core/MY_Controller.php @@ -0,0 +1,162 @@ +session->userdata('logged_in')) { + $this->rejectUnauthenticatedRequest(); + } + if ($this->db->table_exists('companies')) { + $this->load->library('CompanyContext'); + $this->companycontext->id(); + } + $this->enforceRoutePermission(); + } + + /** Permission endpoint terpusat; menu tersembunyi bukan satu-satunya kontrol akses. */ + protected function enforceRoutePermission() + { + if ($this->session->userdata('role') === 'Admin' || is_master_admin_user()) return; + $class = strtolower((string)$this->router->fetch_class()); + $method = strtolower((string)$this->router->fetch_method()); + // Draft PR boleh dihapus oleh pembuatnya tanpa can_delete. Otorisasi pemilik + // dan status tetap wajib diperiksa dengan row lock di PurchaseService. + if ($class === 'purchases' && in_array($method, array('submit_request','delete_request'), true)) return; + // Pembuat Draft invoice boleh membatalkan miliknya sendiri; validasi pemilik + // dan status dilakukan lagi dengan row lock di SalesService. + if ($class === 'sales' && $method === 'delete_invoice') return; + $features = array( + 'dashboard'=>'dashboard','approvals'=>'approvals','customers'=>'invoices', + 'invoices'=>'invoices','receivables'=>'invoices','salesdocuments'=>'invoices','sales'=>'invoices', + 'suppliers'=>'purchases','purchases'=>'purchases','purchasedocuments'=>'purchases','payables'=>'purchases', + 'items'=>'items','warehouses'=>'items','kodebarang'=>'items','teknisi'=>'technician_equipment', + 'inventoryprofessional'=>'items','asset'=>'asset','fixedassets'=>'asset','lokasiasset'=>'asset', + 'cashbank'=>'cash_bank','jurnal'=>'jurnal','basejurnal'=>'jurnal','accounts'=>'jurnal', + 'accountingperiods'=>'jurnal','professionalreports'=>'reports','budgets'=>'budgets', + 'taxes'=>'taxes','roles'=>'setting','users'=>'setting','employees'=>'employees','generateschedule'=>'employees', + 'attendancemonitoring'=>'employees','attendancemonthly'=>'employees','attendancmonthly'=>'employees', + 'shifts'=>'employees','holidays'=>'employees','leaverequests'=>'employees','hrpayroll'=>'employees','organizationmaster'=>'employees' + ); + if (!isset($features[$class])) return; + $action = 'can_view'; + $isReadMethod = $method==='index'||$method==='list'||$method==='statement'||$method==='detail'||$method==='edit'||strpos($method,'get_')===0||strpos($method,'open_')===0; + if (strpos($method,'export')!==false || strpos($method,'pdf')!==false) { + $action='can_export'; + } elseif (!$isReadMethod && strtoupper($this->input->method()) !== 'GET') { + if (strpos($method,'export')!==false) $action='can_export'; + elseif (preg_match('/approve|reject|submit|workflow/',$method)) $action='can_approve'; + elseif (preg_match('/post|payment|pay|reconcile|final|lock|depreciat|revalue|dispos/',$method)) $action='can_post'; + elseif (preg_match('/delete|remove/',$method)) $action='can_delete'; + elseif (preg_match('/update|adjust|transfer|return|reverse|cancel/',$method)) $action='can_update'; + else $action='can_create'; + } + if ($class === 'inventoryprofessional') { + if ($method === 'submit') $action = 'can_create'; + if (in_array($method, array('save_warehouse','save_bin','save_batch','update_reservation'), true)) $action = 'can_update'; + if (in_array($method, array('reverse','reconciliation'), true)) $action = 'can_post'; + } + if ($class === 'fixedassets') { + if (in_array($method, array('data','detail','master_data','lookup_inventory','lookup_employees','lookup_journals','opname_detail','report_data','reconciliation','register','qr'), true)) $action = 'can_view'; + // save menentukan create/update dari ID di controller; parent cukup + // memastikan user dapat melihat modul sebelum pemeriksaan dinamis. + if ($method === 'save') $action = 'can_view'; + if (in_array($method, array('submit','opname_create'), true)) $action = 'can_create'; + if (in_array($method, array('approve','reject'), true)) $action = 'can_approve'; + if (in_array($method, array('capitalize','depreciate','mutate','reverse','opname_state'), true)) $action = 'can_post'; + if (in_array($method, array('reopen','transfer','responsibility','maintenance','save_category','save_location'), true)) $action = 'can_update'; + } + if ($class === 'budgets') { + if ($method === 'detail') $action = 'can_view'; + if ($method === 'save_entry') $action = 'can_view'; + if ($method === 'complete_reminder') $action = 'can_update'; + if ($method === 'workflow') { + $budgetAction = strtolower((string)$this->uri->segment(4)); + $action = $budgetAction === 'submit' ? 'can_create' : 'can_approve'; + } + if (in_array($method, array('save_lines','revise'), true)) $action = 'can_update'; + } + if ($class === 'sales') { + if (in_array($method, array('invoice_detail','delivery_detail','return_detail','lookup_customers','lookup_items','lookup_barcodes','warehouses'), true)) $action = 'can_view'; + if (in_array($method, array('append_invoice','delete_invoice','update_delivery'), true)) $action = 'can_update'; + if (in_array($method, array('submit_invoice','create_invoice','create_return','submit_return'), true)) $action = 'can_create'; + if (in_array($method, array('approve_invoice','reject_invoice','approve_return','reject_return'), true)) $action = 'can_approve'; + if (in_array($method, array('post_delivery','payment','receive_return','post_return'), true)) $action = 'can_post'; + } + if ($class === 'invoices') { + if (in_array($method, array('data','lines','lookup_customers','lookup_accounts','lookup_items','lookup_warehouses','lookup_barcodes','detail'), true)) $action = 'can_view'; + if ($method === 'create_header') $action = 'can_create'; + if (in_array($method, array('add_line','add_lines','remove_line','update_line','update_header','generate_delivery'), true)) $action = 'can_update'; + if ($method === 'submit') $action = 'can_submit'; + if (in_array($method, array('approve','reject'), true)) $action = 'can_approve'; + if ($method === 'delete_draft') $action = 'can_delete'; + } + if ($class === 'salesdocuments' && $method === 'workflow') { + $targetState = strtolower((string)$this->uri->segment(4)); + $action = $targetState === 'sent' ? 'can_create' : 'can_approve'; + } + if ($class === 'items' && in_array($method, array('save_serial_number','delete_serial_number'), true)) $action = 'can_update'; + // Teknisi melakukan validasi granular per endpoint di controllernya. + // Fallback items menjaga session lama tetap dapat dipakai sampai login ulang. + if ($class === 'teknisi' && (is_master_admin_user() || check_permission('technician_equipment','can_view') || check_permission('items','can_view'))) return; + $this->requirePermission($features[$class],$action); + } + + protected function rejectUnauthenticatedRequest() + { + if ($this->input->is_ajax_request()) { + $this->output + ->set_status_header(401) + ->set_content_type('application/json') + ->set_output(json_encode([ + 'status' => false, + 'message' => 'Sesi telah berakhir. Silakan login kembali.' + ])) + ->_display(); + exit; + } + + $this->session->set_flashdata('error', 'Silakan login untuk melanjutkan.'); + redirect('auth'); + exit; + } + + protected function requirePermission($feature, $action = 'can_view') + { + if (is_master_admin_user()) { + return true; + } + + if (!check_permission($feature, $action)) { + if ($this->input->is_ajax_request()) { + $this->output->set_status_header(403)->set_content_type('application/json','utf-8')->set_output(json_encode(array('status'=>false,'message'=>'Anda tidak memiliki izin untuk tindakan ini.')))->_display(); + exit; + } + show_error('Anda tidak memiliki izin untuk melakukan tindakan ini.', 403, 'Akses ditolak'); + } + + return true; + } + + protected function requireAdmin() + { + if (!is_master_admin_user()) { + show_error('Akses hanya tersedia untuk administrator.', 403, 'Akses ditolak'); + } + + return true; + } +} + +class MY_Admin_Controller extends MY_Controller +{ + public function __construct() + { + parent::__construct(); + $this->requireAdmin(); + } +} diff --git a/application/exceptions/BusinessException.php b/application/exceptions/BusinessException.php new file mode 100644 index 0000000..1ef5205 --- /dev/null +++ b/application/exceptions/BusinessException.php @@ -0,0 +1,18 @@ +context = $context; + } + + public function getContext() + { + return $this->context; + } +} diff --git a/application/helpers/activity_helper.php b/application/helpers/activity_helper.php index d754b15..04aea98 100644 --- a/application/helpers/activity_helper.php +++ b/application/helpers/activity_helper.php @@ -8,19 +8,17 @@ function log_activity($module, $action, $desc, $status = 'success') $ip = $CI->input->ip_address(); - if (!$ip || $ip == '0.0.0.0') { - $ip = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? - $_SERVER['REMOTE_ADDR'] ?? - 'UNKNOWN'; - } + if (!$ip || $ip == '0.0.0.0') { + $ip = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : 'UNKNOWN'; + } $CI->db->insert('activity_logs', [ 'user_id' => $CI->session->userdata('user_id') ?? 0, 'module' => strtolower($module), 'action' => strtolower($action), - 'description' => $desc, + 'description' => substr(strip_tags((string) $desc), 0, 1000), 'status' => strtolower($status), 'ip_address' => $ip, 'created_at' => date('Y-m-d H:i:s') ]); -} \ No newline at end of file +} diff --git a/application/helpers/permission_helper.php b/application/helpers/permission_helper.php index ba8a83c..88ec145 100644 --- a/application/helpers/permission_helper.php +++ b/application/helpers/permission_helper.php @@ -7,9 +7,35 @@ defined('BASEPATH') OR exit('No direct script access allowed'); * @param string $action => action yang dicek (misal: 'can_view', 'can_export') * @return bool */ -function check_permission($key, $action = 'can_view') { - $CI =& get_instance(); - $permissions = $CI->session->userdata('permissions'); +function is_master_admin_user() { + static $resolved = array(); + $CI =& get_instance(); + $roleId = (int) $CI->session->userdata('role_id'); + $roleName = strtolower(trim((string) $CI->session->userdata('role'))); + + if ((int) $CI->session->userdata('is_super_admin') === 1) { + return true; + } + // Kompatibilitas untuk session lama sebelum flag super-admin disimpan. + if (in_array($roleName, array('admin', 'master admin', 'master admin/admin'), true)) { + return true; + } + if ($roleId < 1 || !isset($CI->db) || !$CI->db->table_exists('roles')) { + return false; + } + if (!array_key_exists($roleId, $resolved)) { + $role = $CI->db->select('is_super_admin,is_active')->get_where('roles', array('id' => $roleId))->row(); + $resolved[$roleId] = $role && (int)$role->is_active === 1 && (int)$role->is_super_admin === 1; + } + return $resolved[$roleId]; +} + +function check_permission($key, $action = 'can_view') { + $CI =& get_instance(); + if (is_master_admin_user()) { + return true; + } + $permissions = $CI->session->userdata('permissions'); if (!$permissions || !is_array($permissions)) { return false; diff --git a/application/helpers/response_helper.php b/application/helpers/response_helper.php new file mode 100644 index 0000000..670a019 --- /dev/null +++ b/application/helpers/response_helper.php @@ -0,0 +1,34 @@ + (bool) $success, + 'message' => (string) $message + ), $data); + + return $CI->output + ->set_status_header((int) $httpStatus) + ->set_content_type('application/json', 'utf-8') + ->set_output(json_encode($payload)); +} + +function business_exception_response(Throwable $exception) +{ + $status = $exception instanceof BusinessException ? $exception->getCode() : 500; + if ($status < 400 || $status > 599) { + $status = 500; + } + + if (!($exception instanceof BusinessException)) { + log_message('error', get_class($exception) . ': ' . $exception->getMessage()); + } + + $message = $exception instanceof BusinessException + ? $exception->getMessage() + : 'Terjadi kesalahan saat memproses transaksi.'; + + return json_response(false, $message, array(), $status); +} diff --git a/application/libraries/AccountMappingService.php b/application/libraries/AccountMappingService.php new file mode 100644 index 0000000..4c03bfa --- /dev/null +++ b/application/libraries/AccountMappingService.php @@ -0,0 +1,50 @@ +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; + } +} diff --git a/application/libraries/ApprovalService.php b/application/libraries/ApprovalService.php new file mode 100644 index 0000000..456bc67 --- /dev/null +++ b/application/libraries/ApprovalService.php @@ -0,0 +1,89 @@ +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(); } +} diff --git a/application/libraries/AuditService.php b/application/libraries/AuditService.php new file mode 100644 index 0000000..1ae621d --- /dev/null +++ b/application/libraries/AuditService.php @@ -0,0 +1,35 @@ +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); + } +} diff --git a/application/libraries/BudgetService.php b/application/libraries/BudgetService.php new file mode 100644 index 0000000..812cecb --- /dev/null +++ b/application/libraries/BudgetService.php @@ -0,0 +1,125 @@ +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')));} +} diff --git a/application/libraries/CashBankService.php b/application/libraries/CashBankService.php new file mode 100644 index 0000000..de1cfd9 --- /dev/null +++ b/application/libraries/CashBankService.php @@ -0,0 +1,10 @@ +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;}} +} diff --git a/application/libraries/ChartOfAccountsService.php b/application/libraries/ChartOfAccountsService.php new file mode 100644 index 0000000..8e17003 --- /dev/null +++ b/application/libraries/ChartOfAccountsService.php @@ -0,0 +1,112 @@ +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; + } +} diff --git a/application/libraries/CompanyContext.php b/application/libraries/CompanyContext.php new file mode 100644 index 0000000..973d99e --- /dev/null +++ b/application/libraries/CompanyContext.php @@ -0,0 +1,9 @@ +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;} +} diff --git a/application/libraries/DatabaseSyncService.php b/application/libraries/DatabaseSyncService.php new file mode 100644 index 0000000..d3f3b5b --- /dev/null +++ b/application/libraries/DatabaseSyncService.php @@ -0,0 +1,490 @@ +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) . '`'; + } +} diff --git a/application/libraries/FiscalPeriodService.php b/application/libraries/FiscalPeriodService.php new file mode 100644 index 0000000..8161dcf --- /dev/null +++ b/application/libraries/FiscalPeriodService.php @@ -0,0 +1,149 @@ +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; + }); + } +} diff --git a/application/libraries/FixedAssetService.php b/application/libraries/FixedAssetService.php new file mode 100644 index 0000000..5bdce24 --- /dev/null +++ b/application/libraries/FixedAssetService.php @@ -0,0 +1,303 @@ +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(); + } +} diff --git a/application/libraries/FormulaService.php b/application/libraries/FormulaService.php new file mode 100644 index 0000000..af2939d --- /dev/null +++ b/application/libraries/FormulaService.php @@ -0,0 +1,6 @@ +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);} +} diff --git a/application/libraries/JobQueueService.php b/application/libraries/JobQueueService.php new file mode 100644 index 0000000..b2279dc --- /dev/null +++ b/application/libraries/JobQueueService.php @@ -0,0 +1,11 @@ +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));} +} diff --git a/application/libraries/JournalDescriptionService.php b/application/libraries/JournalDescriptionService.php new file mode 100644 index 0000000..a2c07a9 --- /dev/null +++ b/application/libraries/JournalDescriptionService.php @@ -0,0 +1,35 @@ +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; + } +} diff --git a/application/libraries/JournalService.php b/application/libraries/JournalService.php new file mode 100644 index 0000000..1f01bcf --- /dev/null +++ b/application/libraries/JournalService.php @@ -0,0 +1,122 @@ +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); + } +} diff --git a/application/libraries/JournalWorkflowService.php b/application/libraries/JournalWorkflowService.php new file mode 100644 index 0000000..359b9c9 --- /dev/null +++ b/application/libraries/JournalWorkflowService.php @@ -0,0 +1,170 @@ +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; + } +} diff --git a/application/libraries/MultiCurrencyService.php b/application/libraries/MultiCurrencyService.php new file mode 100644 index 0000000..5a92a9c --- /dev/null +++ b/application/libraries/MultiCurrencyService.php @@ -0,0 +1,8 @@ +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;}} +} diff --git a/application/libraries/NavigationService.php b/application/libraries/NavigationService.php new file mode 100644 index 0000000..125da53 --- /dev/null +++ b/application/libraries/NavigationService.php @@ -0,0 +1,39 @@ +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| Tahun Buku | No | Periode | Mulai | Selesai | Status | Aksi |
|---|