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')));} }