From 8fedd39d8fa02fd5a3d27f87a939accb9be69c23 Mon Sep 17 00:00:00 2001 From: Wian Drs Date: Tue, 15 Sep 2026 10:38:43 +0700 Subject: [PATCH] hr dan payroll --- application/controllers/Bukubesar.php | 57 +- application/controllers/Companies.php | 34 +- application/controllers/Dashboard.php | 5 +- application/controllers/Generatebuku.php | 30 +- application/controllers/Hrpayroll.php | 74 +- application/controllers/Invoices.php | 9 +- application/controllers/Jurnal.php | 33 +- application/controllers/Payrollcheck.php | 26 +- .../controllers/Professionalreports.php | 2 +- application/controllers/Roles.php | 2 +- application/controllers/Teknisi.php | 4 +- application/core/MY_Controller.php | 2 +- application/libraries/JournalService.php | 48 +- application/libraries/PDF_BukuBesar.php | 14 +- application/libraries/PayrollService.php | 27 +- .../libraries/ProfessionalReportService.php | 53 +- application/libraries/PurchaseService.php | 11 + application/libraries/SalesService.php | 48 +- ...914000200_expand_company_bank_accounts.php | 45 ++ ...ir_purchase_payment_bank_charge_schema.php | 36 + ...00_repair_supplier_invoice_status_enum.php | 28 + ...00_repair_system_account_company_scope.php | 49 ++ application/models/AccountModel.php | 12 +- application/models/JournalModel.php | 6 +- application/views/buku_besar/layout.php | 25 +- application/views/companies/index.php | 25 +- application/views/dashboard/activity_logs.php | 18 +- application/views/hr_payroll/index.php | 17 +- .../inventory_professional/dashboard.php | 4 +- application/views/items/draft_items.php | 2 +- application/views/jurnal/layout.php | 69 +- application/views/partials/header.php | 5 +- .../views/peralatan_teknisi/log_barang.php | 11 +- .../views/peralatan_teknisi/workbench.php | 4 +- .../views/professional_reports/index.php | 15 +- application/views/roles/index.php | 2 +- assets/css/app-final.css | 178 ++++- assets/css/dashboard-professional.css | 707 +++++++++++++----- assets/css/invoices-professional.css | 6 + assets/css/technician-equipment.css | 1 + assets/js/invoices-professional.js | 35 +- .../003a98d81ee5d724d83f73640cf50546.jpg | Bin 0 -> 687923 bytes .../c48b27cee4bdbceaf3be56bfb10319fd.jpg | Bin 0 -> 794992 bytes .../ceb40c77591662ba10d932aa050533a9.jpg | Bin 0 -> 631656 bytes .../dc76771943c63a76f36275bc5fda7739.jpg | Bin 0 -> 22383 bytes .../e0d0ae6c704d7d7aa6679261799f75bc.jpg | Bin 0 -> 794992 bytes .../f15339fe91282b061e76f4bf6214274a.jpg | Bin 0 -> 794992 bytes 47 files changed, 1409 insertions(+), 370 deletions(-) create mode 100644 application/migrations/20260914000200_expand_company_bank_accounts.php create mode 100644 application/migrations/20260914000300_repair_purchase_payment_bank_charge_schema.php create mode 100644 application/migrations/20260914000400_repair_supplier_invoice_status_enum.php create mode 100644 application/migrations/20260914000500_repair_system_account_company_scope.php create mode 100644 uploads/accounting/003a98d81ee5d724d83f73640cf50546.jpg create mode 100644 uploads/accounting/c48b27cee4bdbceaf3be56bfb10319fd.jpg create mode 100644 uploads/accounting/ceb40c77591662ba10d932aa050533a9.jpg create mode 100644 uploads/accounting/dc76771943c63a76f36275bc5fda7739.jpg create mode 100644 uploads/accounting/e0d0ae6c704d7d7aa6679261799f75bc.jpg create mode 100644 uploads/accounting/f15339fe91282b061e76f4bf6214274a.jpg diff --git a/application/controllers/Bukubesar.php b/application/controllers/Bukubesar.php index d5dfcd6..b3bbba2 100644 --- a/application/controllers/Bukubesar.php +++ b/application/controllers/Bukubesar.php @@ -32,6 +32,7 @@ class Bukubesar extends MY_Admin_Controller { public function get_accounts() { $data = $this->dm->setTable('accounts') + ->where(array('company_id'=>(int)$this->companycontext->id(),'is_active'=>1,'allow_posting'=>1)) ->order_by('kode_akun','ASC') ->get(); @@ -43,9 +44,26 @@ class Bukubesar extends MY_Admin_Controller { // ========================= public function get_data() { - $account_id = $this->input->get('account_id'); - $start = $this->input->get('start_date'); - $end = $this->input->get('end_date'); + $account_id = (int) $this->input->get('account_id'); + $start = (string) ($this->input->get('start_date') ?: date('Y-m-01')); + $end = (string) ($this->input->get('end_date') ?: date('Y-m-d')); + $companyId = (int) $this->companycontext->id(); + + if (!$account_id || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $start) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $end) || $end < $start) { + return $this->output->set_content_type('application/json')->set_status_header(422)->set_output(json_encode(array('status'=>false,'message'=>'Akun dan periode Buku Besar tidak valid.'))); + } + + $account = $this->db->get_where('accounts', array('id'=>$account_id,'company_id'=>$companyId))->row(); + if (!$account) { + return $this->output->set_content_type('application/json')->set_status_header(404)->set_output(json_encode(array('status'=>false,'message'=>'Akun tidak ditemukan pada perusahaan aktif.'))); + } + + $openingRow = $this->db->select('COALESCE(SUM(journal_details.debit),0) debit,COALESCE(SUM(journal_details.kredit),0) kredit', false) + ->from('journal_details')->join('journals','journals.id=journal_details.journal_id') + ->where(array('journals.company_id'=>$companyId,'journal_details.account_id'=>$account_id)) + ->where_in('journals.status', array('posted','reversed'))->where('journals.tanggal <', $start)->get()->row(); + $normalDebit = in_array($account->tipe, array('asset','expense'), true); + $opening = $normalDebit ? (float)$openingRow->debit-(float)$openingRow->kredit : (float)$openingRow->kredit-(float)$openingRow->debit; $this->db->select(' journals.tanggal, @@ -60,29 +78,26 @@ class Bukubesar extends MY_Admin_Controller { $this->db->join('journals','journals.id = journal_details.journal_id'); $this->db->join('accounts','accounts.id = journal_details.account_id'); $this->db->where_in('journals.status', array('posted', 'reversed')); - - if ($account_id) { - $this->db->where('journal_details.account_id', $account_id); - } - - if ($start && $end) { - $this->db->where('journals.tanggal >=', $start); - $this->db->where('journals.tanggal <=', $end); - } + $this->db->where('journals.company_id', $companyId); + $this->db->where('journal_details.account_id', $account_id); + $this->db->where('journals.tanggal >=', $start); + $this->db->where('journals.tanggal <=', $end); // 🔥 FILTER DEBIT/KREDIT TIDAK BOLEH 0 SEMUA $this->db->group_start(); $this->db->where('journal_details.debit >', 0); $this->db->or_where('journal_details.kredit >', 0); $this->db->group_end(); - $this->db->order_by('journals.tanggal','ASC'); + $this->db->order_by('journals.tanggal','ASC')->order_by('journals.id','ASC')->order_by('journal_details.id','ASC'); $rows = $this->db->get()->result(); // ========================= // HITUNG SALDO BERJALAN // ========================= - $saldo = 0; + $saldo = $opening; + $totalDebit = 0; + $totalKredit = 0; $result = []; foreach ($rows as $r) { @@ -94,6 +109,9 @@ class Bukubesar extends MY_Admin_Controller { $saldo += ($r->kredit - $r->debit); } + $totalDebit += (float) $r->debit; + $totalKredit += (float) $r->kredit; + $result[] = [ 'tanggal' => $r->tanggal, 'no_ref' => $r->no_ref, @@ -104,6 +122,15 @@ class Bukubesar extends MY_Admin_Controller { ]; } - echo json_encode($result); + return $this->output->set_content_type('application/json')->set_output(json_encode(array( + 'status'=>true, + 'data'=>$result, + 'summary'=>array( + 'saldo_awal'=>round($opening,2), + 'total_debit'=>round($totalDebit,2), + 'total_kredit'=>round($totalKredit,2), + 'saldo_akhir'=>round($saldo,2), + ), + ))); } } diff --git a/application/controllers/Companies.php b/application/controllers/Companies.php index 741c096..da72e02 100644 --- a/application/controllers/Companies.php +++ b/application/controllers/Companies.php @@ -3,7 +3,7 @@ defined('BASEPATH') OR exit('No direct script access allowed'); class Companies extends MY_Admin_Controller { - public function __construct(){parent::__construct();$this->load->database();$this->load->library(array('CompanyContext','MultiCurrencyService'));} + public function __construct(){parent::__construct();$this->load->database();$this->load->library(array('CompanyContext','MultiCurrencyService','AuditService'));} public function index() { @@ -13,7 +13,8 @@ class Companies extends MY_Admin_Controller $d['companies']=$this->db->order_by('name')->get('companies')->result(); $d['currencies']=$this->db->order_by('code')->get('currencies')->result(); $d['active_currencies']=$this->db->where('is_active',1)->order_by('code')->get('currencies')->result(); - $d['bank_accounts']=$this->db->table_exists('company_bank_accounts')?$this->db->where('company_id',$companyId)->order_by('is_primary','DESC')->order_by('id')->get('company_bank_accounts')->result():array(); + $d['bank_accounts']=$this->db->table_exists('company_bank_accounts')?$this->db->select('b.*,a.kode_akun,a.nama_akun')->from('company_bank_accounts b')->join('accounts a','a.id=b.gl_account_id AND a.company_id=b.company_id','left')->where('b.company_id',$companyId)->order_by('b.is_primary','DESC')->order_by('b.id')->get()->result():array(); + $d['bank_gl_accounts']=$this->db->where(array('company_id'=>$companyId,'tipe'=>'asset','is_active'=>1,'allow_posting'=>1))->order_by('kode_akun')->get('accounts')->result(); $d['rates']=$this->db->select('r.*,c.name company_name')->from('exchange_rates r')->join('companies c','c.id=r.company_id')->where('r.company_id',$companyId)->order_by('rate_date','DESC')->limit(100)->get()->result(); $this->load->view('partials/header',$d);$this->load->view('companies/index',$d);$this->load->view('partials/footer',$d); } @@ -39,12 +40,36 @@ class Companies extends MY_Admin_Controller public function save_bank() { - $this->postOnly();$company=(int)$this->companycontext->id();try{$id=(int)$this->input->post('id');$row=array('company_id'=>$company,'bank_name'=>trim((string)$this->input->post('bank_name',true)),'account_number'=>trim((string)$this->input->post('account_number',true)),'account_holder'=>trim((string)$this->input->post('account_holder',true)),'currency'=>strtoupper(trim((string)$this->input->post('currency',true))),'is_primary'=>$this->input->post('is_primary')?1:0,'is_active'=>1,'updated_at'=>date('Y-m-d H:i:s'));if($row['bank_name']===''||$row['account_number']===''||$row['account_holder']==='')throw new RuntimeException('Nama bank, nomor rekening, dan nama pemilik wajib diisi.');if($row['is_primary'])$this->db->where('company_id',$company)->update('company_bank_accounts',array('is_primary'=>0));if($id){if(!$this->db->get_where('company_bank_accounts',array('id'=>$id,'company_id'=>$company))->row())throw new RuntimeException('Rekening perusahaan tidak ditemukan.');$this->db->where(array('id'=>$id,'company_id'=>$company))->update('company_bank_accounts',$row);}else{$row['created_at']=date('Y-m-d H:i:s');$this->db->insert('company_bank_accounts',$row);}$this->session->set_flashdata('success','Rekening perusahaan berhasil disimpan.');}catch(Throwable$e){$this->session->set_flashdata('error',$e->getMessage());}redirect('companies'); + $this->postOnly();$company=(int)$this->companycontext->id();$transactionStarted=false; + try{ + $id=(int)$this->input->post('id');$type=(string)$this->input->post('account_type',true);$opened=(string)$this->input->post('opened_at',true);$maturity=(string)$this->input->post('maturity_date',true);$interest=max(0,round((float)$this->input->post('interest_rate'),4));$gl=(int)$this->input->post('gl_account_id'); + if(!in_array($type,array('savings','deposit'),true))throw new RuntimeException('Jenis rekening tidak valid.'); + if(!$this->validDate($opened))throw new RuntimeException('Tanggal pembukaan rekening wajib diisi dengan benar.'); + if($type==='deposit'&&!$this->validDate($maturity))throw new RuntimeException('Tanggal jatuh tempo wajib untuk rekening Deposito.'); + if($type==='savings')$maturity=null; + if($maturity!==null&&$maturity<$opened)throw new RuntimeException('Tanggal jatuh tempo tidak boleh sebelum tanggal pembukaan.'); + $coa=$this->db->get_where('accounts',array('id'=>$gl,'company_id'=>$company,'tipe'=>'asset','is_active'=>1,'allow_posting'=>1))->row();if(!$coa)throw new RuntimeException('Akun COA bank tidak valid atau bukan akun Aktiva aktif.'); + $row=array('company_id'=>$company,'bank_name'=>trim((string)$this->input->post('bank_name',true)),'account_number'=>trim((string)$this->input->post('account_number',true)),'account_holder'=>trim((string)$this->input->post('account_holder',true)),'product_name'=>trim((string)$this->input->post('product_name',true)),'account_type'=>$type,'interest_rate'=>$interest,'opened_at'=>$opened,'maturity_date'=>$maturity,'currency'=>strtoupper(trim((string)$this->input->post('currency',true))),'gl_account_id'=>$gl,'is_primary'=>$this->input->post('is_primary')?1:0,'updated_at'=>date('Y-m-d H:i:s')); + if($row['bank_name']===''||$row['account_number']===''||$row['account_holder']===''||$row['product_name']==='')throw new RuntimeException('Nama bank, nomor rekening, pemilik, dan produk bank wajib diisi.'); + if(!$this->db->get_where('currencies',array('code'=>$row['currency'],'is_active'=>1))->row())throw new RuntimeException('Mata uang rekening tidak valid.'); + $this->db->where(array('company_id'=>$company,'account_number'=>$row['account_number']));if($id)$this->db->where('id !=',$id);if($this->db->count_all_results('company_bank_accounts'))throw new RuntimeException('Nomor rekening sudah terdaftar pada perusahaan ini.'); + $before=null;$this->db->trans_begin();$transactionStarted=true; + if($row['is_primary'])$this->db->where('company_id',$company)->update('company_bank_accounts',array('is_primary'=>0,'updated_at'=>date('Y-m-d H:i:s'))); + if($id){$before=$this->db->get_where('company_bank_accounts',array('id'=>$id,'company_id'=>$company))->row();if(!$before)throw new RuntimeException('Rekening perusahaan tidak ditemukan.');$this->db->where(array('id'=>$id,'company_id'=>$company))->update('company_bank_accounts',$row);$bankId=$id;}else{$row['is_active']=1;$row['created_at']=date('Y-m-d H:i:s');$this->db->insert('company_bank_accounts',$row);$bankId=(int)$this->db->insert_id();} + $after=$this->db->get_where('company_bank_accounts',array('id'=>$bankId,'company_id'=>$company))->row();$this->auditservice->record('company','company_bank_account',$bankId,$id?'update':'create',$before,$after,(int)$this->session->userdata('user_id'));if(!$this->db->trans_status())throw new RuntimeException('Rekening perusahaan gagal disimpan.');$this->db->trans_commit();$transactionStarted=false;$this->session->set_flashdata('success','Rekening perusahaan berhasil disimpan.'); + }catch(Throwable$e){if($transactionStarted)$this->db->trans_rollback();$this->session->set_flashdata('error',$e->getMessage());} + redirect('companies#companyBanks'); } public function toggle_bank($id) { - $this->postOnly();$company=(int)$this->companycontext->id();$bank=$this->db->get_where('company_bank_accounts',array('id'=>(int)$id,'company_id'=>$company))->row();if($bank)$this->db->where(array('id'=>$bank->id,'company_id'=>$company))->update('company_bank_accounts',array('is_active'=>$bank->is_active?0:1,'is_primary'=>0,'updated_at'=>date('Y-m-d H:i:s')));$this->session->set_flashdata('success','Status rekening perusahaan diperbarui.');redirect('companies'); + $this->postOnly();$company=(int)$this->companycontext->id();$this->db->trans_begin(); + try{$bank=$this->db->query('SELECT * FROM company_bank_accounts WHERE id=? AND company_id=? FOR UPDATE',array((int)$id,$company))->row();if(!$bank)throw new RuntimeException('Rekening perusahaan tidak ditemukan.');$update=array('is_active'=>$bank->is_active?0:1,'is_primary'=>0,'updated_at'=>date('Y-m-d H:i:s'));$this->db->where(array('id'=>$bank->id,'company_id'=>$company))->update('company_bank_accounts',$update);$after=$this->db->get_where('company_bank_accounts',array('id'=>$bank->id,'company_id'=>$company))->row();$this->auditservice->record('company','company_bank_account',$bank->id,'change_status',$bank,$after,(int)$this->session->userdata('user_id'));if(!$this->db->trans_status())throw new RuntimeException('Status rekening perusahaan gagal diperbarui.');$this->db->trans_commit();$this->session->set_flashdata('success','Status rekening perusahaan diperbarui.');}catch(Throwable$e){$this->db->trans_rollback();$this->session->set_flashdata('error',$e->getMessage());}redirect('companies#companyBanks'); + } + + public function bank_export() + { + $company=(int)$this->companycontext->id();$this->db->select('b.bank_name,b.account_number,b.account_holder,b.product_name,b.account_type,b.interest_rate,b.opened_at,b.maturity_date,b.currency,b.is_active,a.kode_akun,a.nama_akun')->from('company_bank_accounts b')->join('accounts a','a.id=b.gl_account_id AND a.company_id=b.company_id','left')->where('b.company_id',$company);$bank=trim((string)$this->input->get('bank',true));$type=trim((string)$this->input->get('type',true));$status=$this->input->get('status');$from=trim((string)$this->input->get('maturity_from',true));$to=trim((string)$this->input->get('maturity_to',true));if($bank!=='')$this->db->like('b.bank_name',$bank);if(in_array($type,array('savings','deposit'),true))$this->db->where('b.account_type',$type);if($status==='0'||$status==='1')$this->db->where('b.is_active',(int)$status);if($this->validDate($from))$this->db->where('b.maturity_date >=',$from);if($this->validDate($to))$this->db->where('b.maturity_date <=',$to);$rows=$this->db->order_by('b.bank_name')->order_by('b.account_number')->get()->result_array();$this->output->set_content_type('text/csv')->set_header('Content-Disposition: attachment; filename="rekening-perusahaan-'.date('Ymd').'.csv"');$out=fopen('php://output','w');fputcsv($out,array('Nama Bank','Nomor Rekening','Pemilik','Produk','Jenis','Bunga (%)','Tanggal Pembukaan','Jatuh Tempo','Mata Uang','Status','Kode COA','Nama COA'));foreach($rows as$row){$row['account_type']=$row['account_type']==='deposit'?'Deposito':'Tabungan';$row['is_active']=$row['is_active']?'Aktif':'Nonaktif';fputcsv($out,array_values($row));}fclose($out); } public function save_currency() @@ -64,4 +89,5 @@ class Companies extends MY_Admin_Controller if(empty($_FILES['logo'])||$_FILES['logo']['error']===UPLOAD_ERR_NO_FILE)return$current;if($_FILES['logo']['error']!==UPLOAD_ERR_OK)throw new RuntimeException('Logo perusahaan gagal diunggah.');if((int)$_FILES['logo']['size']>2*1024*1024)throw new RuntimeException('Ukuran logo maksimal 2 MB.');$mime=(new finfo(FILEINFO_MIME_TYPE))->file($_FILES['logo']['tmp_name']);$allowed=array('image/jpeg'=>'jpg','image/png'=>'png','image/webp'=>'webp');if(!isset($allowed[$mime]))throw new RuntimeException('Logo hanya menerima JPG, PNG, atau WEBP.');$dir=FCPATH.'uploads/accounting/company/';if(!is_dir($dir)&&!mkdir($dir,0755,true))throw new RuntimeException('Folder logo perusahaan tidak dapat dibuat.');$relative='uploads/accounting/company/'.bin2hex(random_bytes(16)).'.'.$allowed[$mime];if(!move_uploaded_file($_FILES['logo']['tmp_name'],FCPATH.$relative))throw new RuntimeException('Logo perusahaan tidak dapat disimpan.');return$relative; } private function postOnly(){if(strtoupper($this->input->method())!=='POST')show_error('Metode tidak diizinkan.',405);} + private function validDate($value){$date=DateTime::createFromFormat('Y-m-d',(string)$value);return$date&&$date->format('Y-m-d')===$value;} } diff --git a/application/controllers/Dashboard.php b/application/controllers/Dashboard.php index 19cca35..04cc62b 100644 --- a/application/controllers/Dashboard.php +++ b/application/controllers/Dashboard.php @@ -54,7 +54,10 @@ class Dashboard extends MY_Controller public function log_activity() { - $data = array('active_menu' => 'dashboard'); + $data = array( + 'active_menu' => 'dashboard', + 'use_datatables' => true, + ); $this->load->view('partials/header', $data); $this->load->view('dashboard/activity_logs', $data); $this->load->view('partials/footer'); diff --git a/application/controllers/Generatebuku.php b/application/controllers/Generatebuku.php index e02423d..f8060f2 100644 --- a/application/controllers/Generatebuku.php +++ b/application/controllers/Generatebuku.php @@ -15,10 +15,17 @@ class Generatebuku extends MY_Controller public function generatepdf($account_id, $start, $end) { - if ($this->session->userdata('role') != 'Admin') { + if (!is_master_admin_user() && $this->session->userdata('role') != 'Admin') { show_error('Akses ditolak', 403); } + $startDate = DateTime::createFromFormat('Y-m-d', (string) $start); + $endDate = DateTime::createFromFormat('Y-m-d', (string) $end); + if (!$startDate || $startDate->format('Y-m-d') !== $start + || !$endDate || $endDate->format('Y-m-d') !== $end || $end < $start) { + show_error('Periode Buku Besar tidak valid.', 422); + } + if (ob_get_length()) { ob_end_clean(); } @@ -28,8 +35,12 @@ class Generatebuku extends MY_Controller // ============================================================= $akun = null; if (!empty($account_id)) { - $akun = $this->db->get_where('accounts', ['id' => (int) $account_id])->row(); + $akun = $this->db->get_where('accounts', ['id' => (int) $account_id, 'company_id'=>(int)$this->companycontext->id()])->row(); } + if (!$akun) show_error('Akun Buku Besar tidak ditemukan pada perusahaan aktif.', 404); + + $openingRow=$this->db->select('COALESCE(SUM(d.debit),0) debit,COALESCE(SUM(d.kredit),0) kredit',false)->from('journal_details d')->join('journals j','j.id=d.journal_id')->where(array('j.company_id'=>(int)$this->companycontext->id(),'d.account_id'=>(int)$account_id))->where_in('j.status',array('posted','reversed'))->where('j.tanggal <',$start)->get()->row(); + $saldoAwal=in_array($akun->tipe,array('asset','expense'),true)?(float)$openingRow->debit-(float)$openingRow->kredit:(float)$openingRow->kredit-(float)$openingRow->debit; // ============================================================= // QUERY DATA @@ -46,6 +57,7 @@ class Generatebuku extends MY_Controller $this->db->join('journals', 'journals.id = journal_details.journal_id'); $this->db->join('accounts', 'accounts.id = journal_details.account_id'); $this->db->where_in('journals.status', array('posted', 'reversed')); + $this->db->where('journals.company_id', (int)$this->companycontext->id()); if (!empty($account_id)) { $this->db->where('journal_details.account_id', (int) $account_id); @@ -61,14 +73,14 @@ class Generatebuku extends MY_Controller $this->db->or_where('journal_details.kredit >', 0); $this->db->group_end(); - $this->db->order_by('journals.tanggal', 'ASC'); + $this->db->order_by('journals.tanggal', 'ASC')->order_by('journals.id','ASC')->order_by('journal_details.id','ASC'); $rows = $this->db->get()->result(); // ============================================================= // HITUNG SALDO BERJALAN // ============================================================= - $saldo = 0; + $saldo = $saldoAwal; $result = []; foreach ($rows as $r) { @@ -94,7 +106,7 @@ class Generatebuku extends MY_Controller // ============================================================= // DATA PERUSAHAAN // ============================================================= - $company = $this->db->get('pengaturan')->row(); + $company = $this->db->get_where('companies',array('id'=>(int)$this->companycontext->id()))->row(); // ============================================================= // BUILD STRING INFO LAPORAN @@ -117,16 +129,16 @@ class Generatebuku extends MY_Controller $pdf->SetMargins(10, 10, 10); $pdf->setHeaderData( - $company->nama_perusahaan, - $company->alamat_perusahaan, - FCPATH . 'uploads/img/logo-ljn.png' + $company->name, + $company->address, + !empty($company->logo_path) ? FCPATH.$company->logo_path : '' ); $pdf->setInfoLaporan($namaAkun, $periodeStr); $pdf->AddPage('P', 'A4'); $pdf->BukuBesarTable($result); - $pdf->BukuBesarTotal($total_debit, $total_kredit, $saldo); + $pdf->BukuBesarTotal($saldoAwal, $total_debit, $total_kredit, $saldo); $pdf->Pembuat('Tito Ngudiana', 'Finance Department'); // ============================================================= diff --git a/application/controllers/Hrpayroll.php b/application/controllers/Hrpayroll.php index 0fa24cf..0e7bab7 100644 --- a/application/controllers/Hrpayroll.php +++ b/application/controllers/Hrpayroll.php @@ -12,13 +12,24 @@ class Hrpayroll extends MY_Controller private function uid(){return(int)$this->session->userdata('user_id');} private function company(){return(int)$this->session->userdata('company_id');} private function allSalary(){return is_master_admin_user()||check_permission('payroll','can_manage_all_salary');} - private function can($action){return is_master_admin_user()||check_permission('payroll',$action)||check_permission('employees',$action);} + private function canManageAllowances(){return is_master_admin_user()||check_permission('payroll','can_manage_allowances');} + private function can($action){return is_master_admin_user()||check_permission('payroll',$action);} private function go($ok,$message,$section='periods'){$this->session->set_flashdata($ok?'success':'error',$message);redirect('hrpayroll?section='.$section);} private function validDate($value){$date=DateTime::createFromFormat('Y-m-d',(string)$value);return$date&&$date->format('Y-m-d')===$value?$value:null;} private function jsonError(Throwable$e){return json_response(false,$e->getMessage(),array(),$e instanceof BusinessException?422:500);} + private function requireAction($action,$message){if(!$this->can($action))throw new BusinessException($message);} + private function requirePost(){if(strtoupper((string)$this->input->method())!=='POST')throw new BusinessException('Metode request tidak diizinkan.');} + private function isAdditionalComponent($component){if(!$component)return false;$code=strtoupper((string)$component->code);if($code==='BASIC')return$component->component_type!=='earning';return$component->component_type==='employer_cost'||$component->component_type==='earning';} + private function canManageComponent($component){return!$this->isAdditionalComponent($component)||$this->canManageAllowances();} + private function assertManageComponent($component){if(!$this->canManageComponent($component))throw new BusinessException('Tunjangan, bonus, lembur, THR, dan pendapatan tambahan hanya dapat dikelola dengan permission Kelola Tunjangan & Bonus.');} + private function invalidateDraftCalculations() + { + $this->db->where(array('company_id'=>$this->company(),'workflow_status'=>'draft'))->update('k_payroll_periods',array('calculated_at'=>null,'calculated_by'=>null,'calculation_hash'=>null,'updated_at'=>date('Y-m-d H:i:s'))); + } public function index() { + if(!$this->can('can_view'))show_error('Anda tidak memiliki izin melihat payroll.',403,'Akses ditolak'); $section=$this->input->get('section',true)?:'periods';if(!in_array($section,array('periods','salaries','settings','adjustments'),true))$section='periods'; $company=$this->company();$visible=$this->allSalary()?'1=1':'COALESCE(e.salary_restricted,0)=0'; $periods=$this->db->query("SELECT p.*,COUNT(CASE WHEN {$visible} THEN x.id END) employee_count,COALESCE(SUM(CASE WHEN {$visible} THEN x.gross_salary ELSE 0 END),0) gross,COALESCE(SUM(CASE WHEN {$visible} THEN x.total_deductions ELSE 0 END),0) deductions,COALESCE(SUM(CASE WHEN {$visible} THEN x.total_salary ELSE 0 END),0) net,l.status attendance_lock_status FROM k_payroll_periods p LEFT JOIN k_payrolls x ON x.payroll_period_id=p.id LEFT JOIN k_employees e ON e.id=x.employee_id LEFT JOIN payroll_attendance_locks l ON l.payroll_period_id=p.id AND l.company_id=p.company_id WHERE p.company_id=? GROUP BY p.id,l.status ORDER BY COALESCE(p.attendance_start_date,p.start_date) DESC",array($company))->result(); @@ -26,16 +37,19 @@ class Hrpayroll extends MY_Controller $employees=$this->db->select('e.id,e.employee_code,e.full_name,e.department_id,e.position_id,e.is_active,e.salary_restricted,d.department_name,p.position_name')->from('k_employees e')->join('k_departments d','d.id=e.department_id','left')->join('k_positions p','p.id=e.position_id','left')->where('e.company_id',$company)->order_by('e.full_name')->get()->result(); if(!$this->allSalary())$employees=array_values(array_filter($employees,function($e){return empty($e->salary_restricted);})); $salaryRows=$this->salarySummaries($employees,$components); - $positions=$this->db->select('p.id,p.code,p.position_name,p.department_id,d.department_name')->from('k_positions p')->join('k_departments d','d.id=p.department_id','left')->where('p.is_active',1)->order_by('p.position_name')->get()->result(); - $departments=$this->db->where('is_active',1)->order_by('department_name')->get('k_departments')->result(); - $positionAssignments=$this->db->select('x.*,p.position_name,c.code component_code,c.name component_name,c.component_type')->from('position_payroll_components x')->join('k_positions p','p.id=x.position_id')->join('payroll_components c','c.id=x.component_id')->where('x.company_id',$company)->order_by('p.position_name')->order_by('c.name')->get()->result(); - $rules=$this->db->select('r.*,c.code component_code,c.name component_name')->from('payroll_attendance_rules r')->join('payroll_components c','c.id=r.component_id','left')->where('r.company_id',$company)->order_by('r.condition_type')->order_by('r.effective_from','DESC')->get()->result(); - $adjustments=$this->db->select('a.*,p.period_name,p.workflow_status,e.employee_code,e.full_name,c.code component_code,c.name component_name')->from('payroll_period_adjustments a')->join('k_payroll_periods p','p.id=a.payroll_period_id')->join('k_employees e','e.id=a.employee_id')->join('payroll_components c','c.id=a.component_id')->where('a.company_id',$company)->where('a.is_active',1)->order_by('a.id','DESC')->get()->result(); + $positionQuery=$this->db->select('p.id,p.code,p.position_name,p.department_id,d.department_name')->from('k_positions p')->join('k_departments d','d.id=p.department_id','left')->where('p.is_active',1);if($this->db->field_exists('company_id','k_positions'))$positionQuery->where('p.company_id',$company);$positions=$positionQuery->order_by('p.position_name')->get()->result(); + $departmentQuery=$this->db->where('is_active',1);if($this->db->field_exists('company_id','k_departments'))$departmentQuery->where('company_id',$company);$departments=$departmentQuery->order_by('department_name')->get('k_departments')->result(); + $positionAssignments=$this->db->select('x.*,p.position_name,c.code component_code,c.name component_name,c.component_type')->from('position_payroll_components x')->join('k_positions p','p.id=x.position_id')->join('payroll_components c','c.id=x.component_id AND c.company_id=x.company_id')->where('x.company_id',$company)->order_by('p.position_name')->order_by('c.name')->get()->result(); + $rules=$this->db->select('r.*,c.code component_code,c.name component_name')->from('payroll_attendance_rules r')->join('payroll_components c','c.id=r.component_id AND c.company_id=r.company_id','left')->where('r.company_id',$company)->order_by('r.condition_type')->order_by('r.effective_from','DESC')->get()->result(); + $adjustments=$this->db->select('a.*,p.period_name,p.workflow_status,e.employee_code,e.full_name,c.code component_code,c.name component_name,c.component_type')->from('payroll_period_adjustments a')->join('k_payroll_periods p','p.id=a.payroll_period_id AND p.company_id=a.company_id')->join('k_employees e','e.id=a.employee_id AND e.company_id=a.company_id')->join('payroll_components c','c.id=a.component_id AND c.company_id=a.company_id')->where('a.company_id',$company)->where('a.is_active',1)->order_by('a.id','DESC')->get()->result(); if(!$this->allSalary())$adjustments=array_values(array_filter($adjustments,function($row)use($employees){foreach($employees as$employee)if($employee->id==$row->employee_id)return true;return false;})); + foreach($adjustments as$row)$row->can_manage=$this->canManageComponent($row); $cashQuery=$this->db->where('is_active',1);if($this->db->field_exists('company_id','cash_accounts'))$cashQuery->where('company_id',$company);$cash=$cashQuery->order_by('name')->get('cash_accounts')->result(); $accounts=$this->db->where(array('company_id'=>$company,'is_active'=>1))->order_by('kode_akun')->get('accounts')->result(); $settings=$this->db->get_where('payroll_settings',array('company_id'=>$company))->row(); - $data=array('active_menu'=>'hr_payroll','payroll_section'=>$section,'periods'=>$periods,'components'=>$components,'employees'=>$employees,'salary_rows'=>$salaryRows,'positions'=>$positions,'departments'=>$departments,'position_assignments'=>$positionAssignments,'attendance_rules'=>$rules,'adjustments'=>$adjustments,'cash_accounts'=>$cash,'accounts'=>$accounts,'payroll_settings'=>$settings,'can_manage_all_salary'=>$this->allSalary(),'permissions'=>array('create'=>$this->can('can_create'),'update'=>$this->can('can_update'),'approve'=>$this->can('can_approve'),'post'=>$this->can('can_post'),'export'=>$this->can('can_export'))); + $manageableComponents=array_values(array_filter($components,function($component){return$this->canManageComponent($component);})); + $adjustmentComponents=array_values(array_filter($manageableComponents,function($component){return$component->component_type==='deduction'||($this->canManageAllowances()&&$component->component_type==='earning'&&strtoupper((string)$component->code)!=='BASIC');})); + $data=array('active_menu'=>'hr_payroll','payroll_section'=>$section,'periods'=>$periods,'components'=>$components,'manageable_components'=>$manageableComponents,'adjustment_components'=>$adjustmentComponents,'employees'=>$employees,'salary_rows'=>$salaryRows,'positions'=>$positions,'departments'=>$departments,'position_assignments'=>$positionAssignments,'attendance_rules'=>$rules,'adjustments'=>$adjustments,'cash_accounts'=>$cash,'accounts'=>$accounts,'payroll_settings'=>$settings,'can_manage_all_salary'=>$this->allSalary(),'can_manage_allowances'=>$this->canManageAllowances(),'permissions'=>array('create'=>$this->can('can_create'),'update'=>$this->can('can_update'),'approve'=>$this->can('can_approve'),'post'=>$this->can('can_post'),'export'=>$this->can('can_export'))); $this->load->view('partials/header',$data);$this->load->view('hr_payroll/index',$data);$this->load->view('partials/footer'); } @@ -47,96 +61,96 @@ class Hrpayroll extends MY_Controller public function create_period() { - try{if(!$this->can('can_create'))throw new BusinessException('Anda tidak mempunyai permission membuat periode payroll.');$name=trim((string)$this->input->post('period_name',true));$start=$this->validDate($this->input->post('attendance_start_date'));$end=$this->validDate($this->input->post('attendance_end_date'));$payment=$this->validDate($this->input->post('payment_date'));if($name===''||!$start||!$end||!$payment)throw new BusinessException('Nama periode, rentang kehadiran, dan tanggal pembayaran wajib diisi.');if($end<$start)throw new BusinessException('Tanggal akhir kehadiran tidak boleh sebelum tanggal awal.');$overlap=(int)$this->db->query("SELECT COUNT(*) total FROM k_payroll_periods WHERE company_id=? AND workflow_status<>'cancelled' AND COALESCE(attendance_start_date,start_date)<=? AND COALESCE(attendance_end_date,end_date)>=?",array($this->company(),$end,$start))->row()->total;if($overlap)throw new BusinessException('Rentang kehadiran tumpang tindih dengan periode payroll lain.');$row=array('company_id'=>$this->company(),'period_name'=>$name,'start_date'=>$start,'end_date'=>$end,'attendance_start_date'=>$start,'attendance_end_date'=>$end,'payment_date'=>$payment,'notes'=>trim((string)$this->input->post('notes',true))?:null,'status'=>'draft','workflow_status'=>'draft','generated_at'=>date('Y-m-d H:i:s'));$this->db->insert('k_payroll_periods',$row);$id=(int)$this->db->insert_id();$this->auditservice->record('payroll','payroll_period',$id,'create',null,$row,$this->uid());$this->go(true,'Periode payroll berhasil dibuat.');}catch(Throwable$e){$this->go(false,$e->getMessage());} + try{$this->requirePost();$this->requireAction('can_create','Anda tidak mempunyai permission membuat periode payroll.');$name=trim((string)$this->input->post('period_name',true));$start=$this->validDate($this->input->post('attendance_start_date'));$end=$this->validDate($this->input->post('attendance_end_date'));$payment=$this->validDate($this->input->post('payment_date'));if($name===''||!$start||!$end||!$payment)throw new BusinessException('Nama periode, rentang kehadiran, dan tanggal pembayaran wajib diisi.');if($end<$start)throw new BusinessException('Tanggal akhir kehadiran tidak boleh sebelum tanggal awal.');$overlap=(int)$this->db->query("SELECT COUNT(*) total FROM k_payroll_periods WHERE company_id=? AND workflow_status<>'cancelled' AND COALESCE(attendance_start_date,start_date)<=? AND COALESCE(attendance_end_date,end_date)>=?",array($this->company(),$end,$start))->row()->total;if($overlap)throw new BusinessException('Rentang kehadiran tumpang tindih dengan periode payroll lain.');$row=array('company_id'=>$this->company(),'period_name'=>$name,'start_date'=>$start,'end_date'=>$end,'attendance_start_date'=>$start,'attendance_end_date'=>$end,'payment_date'=>$payment,'notes'=>trim((string)$this->input->post('notes',true))?:null,'status'=>'draft','workflow_status'=>'draft','generated_at'=>date('Y-m-d H:i:s'));$this->db->insert('k_payroll_periods',$row);$id=(int)$this->db->insert_id();$this->auditservice->record('payroll','payroll_period',$id,'create',null,$row,$this->uid());$this->go(true,'Periode payroll berhasil dibuat.');}catch(Throwable$e){$this->go(false,$e->getMessage());} } public function lock_attendance() { - try{$id=(int)$this->input->post('period_id');$period=$this->period($id);if($period->workflow_status!=='draft')throw new BusinessException('Kehadiran hanya dapat dikunci ketika payroll masih Draft.');$start=$period->attendance_start_date?:$period->start_date;$end=$period->attendance_end_date?:$period->end_date;$pending=array();if($this->db->table_exists('manual_attendance_requests')){$n=$this->db->where('company_id',$this->company())->where('status','submitted')->where('attendance_date >=',$start)->where('attendance_date <=',$end)->count_all_results('manual_attendance_requests');if($n)$pending[]=$n.' absensi manual';}if($this->db->table_exists('k_leave_requests')){$n=$this->db->where('company_id',$this->company())->where('workflow_status','submitted')->where('start_date <=',$end)->where('end_date >=',$start)->count_all_results('k_leave_requests');if($n)$pending[]=$n.' cuti/izin';}if($this->db->table_exists('attendance_corrections')){$n=$this->db->from('attendance_corrections c')->join('k_attendances a','a.id=c.attendance_id')->join('k_employees e','e.id=a.employee_id')->where(array('e.company_id'=>$this->company(),'c.status'=>'submitted'))->where('a.attendance_date >=',$start)->where('a.attendance_date <=',$end)->count_all_results();if($n)$pending[]=$n.' koreksi absensi';}if($pending)throw new BusinessException('Periode belum dapat dikunci karena masih ada '.implode(', ',$pending).' menunggu approval.');$existing=$this->db->get_where('payroll_attendance_locks',array('company_id'=>$this->company(),'payroll_period_id'=>$id))->row();$row=array('company_id'=>$this->company(),'payroll_period_id'=>$id,'start_date'=>$start,'end_date'=>$end,'status'=>'locked','locked_by'=>$this->uid(),'locked_at'=>date('Y-m-d H:i:s'),'notes'=>trim((string)$this->input->post('notes',true))?:null);if($existing)$this->db->where('id',$existing->id)->update('payroll_attendance_locks',$row);else$this->db->insert('payroll_attendance_locks',$row);$this->db->where(array('id'=>$id,'company_id'=>$this->company()))->update('k_payroll_periods',array('attendance_locked_by'=>$this->uid(),'attendance_locked_at'=>date('Y-m-d H:i:s'),'updated_at'=>date('Y-m-d H:i:s')));$this->auditservice->record('payroll','attendance_lock',$id,'lock',null,$row,$this->uid());$this->go(true,'Kehadiran '.$start.' sampai '.$end.' berhasil dikunci.');}catch(Throwable$e){$this->go(false,$e->getMessage());} + try{$this->requirePost();$this->requireAction('can_post','Anda tidak mempunyai permission mengunci kehadiran payroll.');$id=(int)$this->input->post('period_id');$period=$this->period($id);if($period->workflow_status!=='draft')throw new BusinessException('Kehadiran hanya dapat dikunci ketika payroll masih Draft.');$start=$period->attendance_start_date?:$period->start_date;$end=$period->attendance_end_date?:$period->end_date;$pending=array();if($this->db->table_exists('manual_attendance_requests')){$n=$this->db->where('company_id',$this->company())->where('status','submitted')->where('attendance_date >=',$start)->where('attendance_date <=',$end)->count_all_results('manual_attendance_requests');if($n)$pending[]=$n.' absensi manual';}if($this->db->table_exists('k_leave_requests')){$n=$this->db->where('company_id',$this->company())->where('workflow_status','submitted')->where('start_date <=',$end)->where('end_date >=',$start)->count_all_results('k_leave_requests');if($n)$pending[]=$n.' cuti/izin';}if($this->db->table_exists('attendance_corrections')){$n=$this->db->from('attendance_corrections c')->join('k_attendances a','a.id=c.attendance_id')->join('k_employees e','e.id=a.employee_id')->where(array('e.company_id'=>$this->company(),'c.status'=>'submitted'))->where('a.attendance_date >=',$start)->where('a.attendance_date <=',$end)->count_all_results();if($n)$pending[]=$n.' koreksi absensi';}if($pending)throw new BusinessException('Periode belum dapat dikunci karena masih ada '.implode(', ',$pending).' menunggu approval.');$existing=$this->db->get_where('payroll_attendance_locks',array('company_id'=>$this->company(),'payroll_period_id'=>$id))->row();$row=array('company_id'=>$this->company(),'payroll_period_id'=>$id,'start_date'=>$start,'end_date'=>$end,'status'=>'locked','locked_by'=>$this->uid(),'locked_at'=>date('Y-m-d H:i:s'),'notes'=>trim((string)$this->input->post('notes',true))?:null);$this->db->trans_begin();if($existing)$this->db->where('id',$existing->id)->update('payroll_attendance_locks',$row);else$this->db->insert('payroll_attendance_locks',$row);$this->db->where(array('id'=>$id,'company_id'=>$this->company()))->update('k_payroll_periods',array('attendance_locked_by'=>$this->uid(),'attendance_locked_at'=>date('Y-m-d H:i:s'),'calculated_at'=>null,'calculated_by'=>null,'calculation_hash'=>null,'updated_at'=>date('Y-m-d H:i:s')));$this->auditservice->record('payroll','attendance_lock',$id,'lock',null,$row,$this->uid());if($this->db->trans_status()===false)throw new RuntimeException('Kunci kehadiran gagal disimpan.');$this->db->trans_commit();$this->go(true,'Kehadiran '.$start.' sampai '.$end.' berhasil dikunci.');}catch(Throwable$e){if($this->db->trans_status()!==null)$this->db->trans_rollback();$this->go(false,$e->getMessage());} } - public function calculate(){try{$id=$this->payrollservice->calculate((int)$this->input->post('period_id'),$this->uid(),$this->allSalary(),$this->company());$this->go(true,'Draft payroll periode #'.$id.' berhasil dihitung.');}catch(Throwable$e){$this->go(false,$e->getMessage());}} - public function workflow($id,$action){try{$this->payrollservice->workflow($id,$action,$this->uid(),$this->company(),$this->allSalary());$this->go(true,'Tahap payroll berhasil diperbarui.');}catch(Throwable$e){$this->go(false,$e->getMessage());}} - public function post($id){try{$this->payrollservice->post($id,$this->input->post('date'),$this->uid(),$this->company(),$this->allSalary());$this->go(true,'Payroll berhasil diposting ke jurnal.');}catch(Throwable$e){$this->go(false,$e->getMessage());}} - public function pay($id){try{$this->payrollservice->pay($id,$this->input->post('date'),(int)$this->input->post('cash_account_id'),$this->uid(),$this->company(),$this->allSalary());$this->go(true,'Payroll berhasil dibayar.');}catch(Throwable$e){$this->go(false,$e->getMessage());}} + public function calculate(){try{$this->requirePost();$this->requireAction('can_create','Anda tidak mempunyai permission menghitung payroll.');$id=$this->payrollservice->calculate((int)$this->input->post('period_id'),$this->uid(),$this->allSalary(),$this->company());$this->go(true,'Draft payroll periode #'.$id.' berhasil dihitung.');}catch(Throwable$e){$this->go(false,$e->getMessage());}} + public function workflow($id,$action){try{$this->requirePost();$this->requireAction('can_approve','Anda tidak mempunyai permission approval payroll.');$this->payrollservice->workflow($id,$action,$this->uid(),$this->company(),$this->allSalary());$this->go(true,'Tahap payroll berhasil diperbarui.');}catch(Throwable$e){$this->go(false,$e->getMessage());}} + public function post($id){try{$this->requirePost();$this->requireAction('can_post','Anda tidak mempunyai permission posting payroll.');$date=$this->validDate($this->input->post('date'));if(!$date)throw new BusinessException('Tanggal posting tidak valid.');$this->payrollservice->post($id,$date,$this->uid(),$this->company(),$this->allSalary());$this->go(true,'Payroll berhasil diposting ke jurnal.');}catch(Throwable$e){$this->go(false,$e->getMessage());}} + public function pay($id){try{$this->requirePost();$this->requireAction('can_post','Anda tidak mempunyai permission membayar payroll.');$date=$this->validDate($this->input->post('date'));if(!$date)throw new BusinessException('Tanggal pembayaran tidak valid.');$this->payrollservice->pay($id,$date,(int)$this->input->post('cash_account_id'),$this->uid(),$this->company(),$this->allSalary());$this->go(true,'Payroll berhasil dibayar.');}catch(Throwable$e){$this->go(false,$e->getMessage());}} public function period_detail($id) { - $period=$this->period($id,false);if(!$period)return json_response(false,'Periode payroll tidak ditemukan.',array(),404);$q=$this->db->select('p.id,p.basic_salary,p.gross_salary,p.total_deductions,p.total_salary,p.total_present,p.total_alpha,p.total_overtime_hours,e.employee_code,e.full_name,e.salary_restricted')->from('k_payrolls p')->join('k_employees e','e.id=p.employee_id')->where(array('p.payroll_period_id'=>$period->id,'p.company_id'=>$this->company()));if(!$this->allSalary())$q->where('e.salary_restricted',0);return json_response(true,'Rincian payroll.',array('period'=>$period,'rows'=>$q->order_by('e.full_name')->get()->result())); + try{$this->requireAction('can_view','Anda tidak mempunyai permission melihat payroll.');$period=$this->period($id,false);if(!$period)return json_response(false,'Periode payroll tidak ditemukan.',array(),404);$q=$this->db->select('p.id,p.basic_salary,p.gross_salary,p.total_deductions,p.total_salary,p.total_present,p.total_alpha,p.total_overtime_hours,e.employee_code,e.full_name,e.salary_restricted')->from('k_payrolls p')->join('k_employees e','e.id=p.employee_id AND e.company_id=p.company_id')->where(array('p.payroll_period_id'=>$period->id,'p.company_id'=>$this->company()));if(!$this->allSalary())$q->where('e.salary_restricted',0);return json_response(true,'Rincian payroll.',array('period'=>$period,'rows'=>$q->order_by('e.full_name')->get()->result()));}catch(Throwable$e){return$this->jsonError($e);} } public function salary_detail($employeeId) { - try{$employee=$this->employee((int)$employeeId);if(!empty($employee->salary_restricted)&&!$this->allSalary())throw new BusinessException('Gaji karyawan ini bersifat terbatas.');$lines=$this->db->select('x.*,c.code component_code,c.name component_name,c.component_type')->from('employee_payroll_components x')->join('payroll_components c','c.id=x.component_id')->where(array('x.company_id'=>$this->company(),'x.employee_id'=>$employee->id))->order_by('x.effective_from','DESC')->get()->result();return json_response(true,'Struktur gaji.',array('employee'=>$employee,'lines'=>$lines));}catch(Throwable$e){return$this->jsonError($e);} + try{$this->requireAction('can_view','Anda tidak mempunyai permission melihat payroll.');$employee=$this->employee((int)$employeeId);if(!empty($employee->salary_restricted)&&!$this->allSalary())throw new BusinessException('Gaji karyawan ini bersifat terbatas.');$lines=$this->db->select('x.*,c.code component_code,c.name component_name,c.component_type')->from('employee_payroll_components x')->join('payroll_components c','c.id=x.component_id AND c.company_id=x.company_id')->where(array('x.company_id'=>$this->company(),'x.employee_id'=>$employee->id))->order_by('x.effective_from','DESC')->get()->result();foreach($lines as$line)$line->can_manage=$this->can('can_update')&&$this->canManageComponent((object)array('component_type'=>$line->component_type,'code'=>$line->component_code));return json_response(true,'Struktur gaji.',array('employee'=>$employee,'lines'=>$lines));}catch(Throwable$e){return$this->jsonError($e);} } public function save_salary_line() { - try{if(!$this->can('can_update'))throw new BusinessException('Anda tidak mempunyai permission mengubah struktur gaji.');$employee=$this->employee((int)$this->input->post('employee_id'));if(!empty($employee->salary_restricted)&&!$this->allSalary())throw new BusinessException('Gaji karyawan ini bersifat terbatas.');$row=$this->assignmentInput('employee',$employee->id);$duplicate=$this->db->get_where('employee_payroll_components',array('company_id'=>$this->company(),'employee_id'=>$employee->id,'component_id'=>$row['component_id'],'effective_from'=>$row['effective_from']))->row();if($duplicate)throw new BusinessException('Komponen dengan tanggal mulai yang sama sudah ada. Gunakan tanggal efektif baru agar histori tidak tertimpa.');$this->db->trans_begin();$previous=$this->db->where(array('company_id'=>$this->company(),'employee_id'=>$employee->id,'component_id'=>$row['component_id'],'is_active'=>1))->where('effective_from <',$row['effective_from'])->group_start()->where('effective_to IS NULL',null,false)->or_where('effective_to >=',$row['effective_from'])->group_end()->order_by('effective_from','DESC')->get('employee_payroll_components')->row();if($previous)$this->db->where('id',$previous->id)->update('employee_payroll_components',array('effective_to'=>date('Y-m-d',strtotime($row['effective_from'].' -1 day')),'updated_at'=>date('Y-m-d H:i:s')));$this->db->insert('employee_payroll_components',$row);$id=(int)$this->db->insert_id();if($row['component_id']===$this->basicComponentId())$this->db->where(array('id'=>$employee->id,'company_id'=>$this->company()))->update('k_employees',array('basic_salary'=>$row['amount_override']));$this->auditservice->record('payroll','employee_salary_component',$id,'create_version',$previous,$row,$this->uid());$this->db->trans_commit();return json_response(true,'Versi struktur gaji berhasil disimpan.');}catch(Throwable$e){$this->db->trans_rollback();return$this->jsonError($e);} + try{$this->requirePost();$this->requireAction('can_update','Anda tidak mempunyai permission mengubah struktur gaji.');$employee=$this->employee((int)$this->input->post('employee_id'));if(!empty($employee->salary_restricted)&&!$this->allSalary())throw new BusinessException('Gaji karyawan ini bersifat terbatas.');$row=$this->assignmentInput('employee',$employee->id);$duplicate=$this->db->get_where('employee_payroll_components',array('company_id'=>$this->company(),'employee_id'=>$employee->id,'component_id'=>$row['component_id'],'effective_from'=>$row['effective_from']))->row();if($duplicate)throw new BusinessException('Komponen dengan tanggal mulai yang sama sudah ada. Gunakan tanggal efektif baru agar histori tidak tertimpa.');$this->db->trans_begin();$previous=$this->db->where(array('company_id'=>$this->company(),'employee_id'=>$employee->id,'component_id'=>$row['component_id'],'is_active'=>1))->where('effective_from <',$row['effective_from'])->group_start()->where('effective_to IS NULL',null,false)->or_where('effective_to >=',$row['effective_from'])->group_end()->order_by('effective_from','DESC')->get('employee_payroll_components')->row();if($previous)$this->db->where('id',$previous->id)->update('employee_payroll_components',array('effective_to'=>date('Y-m-d',strtotime($row['effective_from'].' -1 day')),'updated_at'=>date('Y-m-d H:i:s')));$this->db->insert('employee_payroll_components',$row);$id=(int)$this->db->insert_id();if($row['component_id']===$this->basicComponentId())$this->db->where(array('id'=>$employee->id,'company_id'=>$this->company()))->update('k_employees',array('basic_salary'=>$row['amount_override']));$this->invalidateDraftCalculations();$this->auditservice->record('payroll','employee_salary_component',$id,'create_version',$previous,$row,$this->uid());if($this->db->trans_status()===false)throw new RuntimeException('Struktur gaji gagal disimpan.');$this->db->trans_commit();return json_response(true,'Versi struktur gaji berhasil disimpan. Draft payroll wajib dihitung ulang.');}catch(Throwable$e){$this->db->trans_rollback();return$this->jsonError($e);} } public function deactivate_salary_line($id) { - try{$line=$this->db->get_where('employee_payroll_components',array('id'=>(int)$id,'company_id'=>$this->company()))->row();if(!$line)throw new BusinessException('Komponen gaji tidak ditemukan.');$employee=$this->employee($line->employee_id);if(!empty($employee->salary_restricted)&&!$this->allSalary())throw new BusinessException('Gaji karyawan ini bersifat terbatas.');$update=array('is_active'=>0,'effective_to'=>date('Y-m-d'),'updated_at'=>date('Y-m-d H:i:s'));$this->db->where('id',$line->id)->update('employee_payroll_components',$update);$this->auditservice->record('payroll','employee_salary_component',$line->id,'deactivate',$line,$update,$this->uid());return json_response(true,'Komponen dinonaktifkan tanpa menghapus histori.');}catch(Throwable$e){return$this->jsonError($e);} + try{$this->requirePost();$this->requireAction('can_update','Anda tidak mempunyai permission mengubah struktur gaji.');$line=$this->db->select('x.*,c.code component_code,c.component_type')->from('employee_payroll_components x')->join('payroll_components c','c.id=x.component_id AND c.company_id=x.company_id')->where(array('x.id'=>(int)$id,'x.company_id'=>$this->company()))->get()->row();if(!$line)throw new BusinessException('Komponen gaji tidak ditemukan.');$this->assertManageComponent((object)array('component_type'=>$line->component_type,'code'=>$line->component_code));$employee=$this->employee($line->employee_id);if(!empty($employee->salary_restricted)&&!$this->allSalary())throw new BusinessException('Gaji karyawan ini bersifat terbatas.');if(!(int)$line->is_active)throw new BusinessException('Komponen gaji sudah nonaktif.');$update=array('is_active'=>0,'effective_to'=>date('Y-m-d'),'updated_at'=>date('Y-m-d H:i:s'));$this->db->trans_begin();$this->db->where('id',$line->id)->update('employee_payroll_components',$update);$this->invalidateDraftCalculations();$this->auditservice->record('payroll','employee_salary_component',$line->id,'deactivate',$line,$update,$this->uid());if($this->db->trans_status()===false)throw new RuntimeException('Komponen gaji gagal dinonaktifkan.');$this->db->trans_commit();return json_response(true,'Komponen dinonaktifkan tanpa menghapus histori. Draft payroll wajib dihitung ulang.');}catch(Throwable$e){$this->db->trans_rollback();return$this->jsonError($e);} } public function component() { - try{if(!$this->can('can_update'))throw new BusinessException('Anda tidak mempunyai permission mengelola komponen.');$id=(int)$this->input->post('id');$code=strtoupper(trim((string)$this->input->post('code',true)));$name=trim((string)$this->input->post('name',true));$type=$this->input->post('component_type');$method=$this->method($this->input->post('calculation_method'));$value=$this->decimal($this->input->post('default_value'));if($code===''||$name===''||!in_array($type,array('earning','deduction','employer_cost'),true))throw new BusinessException('Kode, nama, dan jenis komponen wajib diisi.');$formula=$method==='formula'?trim((string)$this->input->post('formula_expression',true))?:null:null;if($method==='formula')$this->validateFormula($formula);$row=array('company_id'=>$this->company(),'code'=>$code,'name'=>$name,'component_type'=>$type,'category'=>$this->input->post('category')?:'other','calculation_method'=>$method,'default_value'=>$value,'fixed_amount'=>$value,'formula_expression'=>$formula,'taxable'=>$this->input->post('taxable')?1:0,'prorated'=>$this->input->post('prorated')?1:0,'expense_account_id'=>$this->input->post('expense_account_id')?:null,'liability_account_id'=>$this->input->post('liability_account_id')?:null,'is_active'=>$this->input->post('is_active')?1:0,'effective_from'=>$this->validDate($this->input->post('effective_from'))?:date('Y-m-d'),'effective_to'=>$this->validDate($this->input->post('effective_to'))?:null,'updated_at'=>date('Y-m-d H:i:s'));$duplicate=$this->db->where(array('company_id'=>$this->company(),'code'=>$code))->where('id !=',$id)->count_all_results('payroll_components');if($duplicate)throw new BusinessException('Kode komponen sudah digunakan.');if($id){$before=$this->db->get_where('payroll_components',array('id'=>$id,'company_id'=>$this->company()))->row();if(!$before)throw new BusinessException('Komponen tidak ditemukan.');$this->db->where('id',$id)->update('payroll_components',$row);}else{$row['created_by']=$this->uid();$row['created_at']=date('Y-m-d H:i:s');$this->db->insert('payroll_components',$row);$id=(int)$this->db->insert_id();$before=null;}$this->auditservice->record('payroll','payroll_component',$id,$before?'update':'create',$before,$row,$this->uid());$this->go(true,'Komponen payroll berhasil disimpan.','settings');}catch(Throwable$e){$this->go(false,$e->getMessage(),'settings');} + try{$this->requirePost();$this->requireAction('can_update','Anda tidak mempunyai permission mengelola komponen.');$id=(int)$this->input->post('id');$before=$id?$this->db->get_where('payroll_components',array('id'=>$id,'company_id'=>$this->company()))->row():null;if($id&&!$before)throw new BusinessException('Komponen tidak ditemukan.');$code=strtoupper(trim((string)$this->input->post('code',true)));$name=trim((string)$this->input->post('name',true));$type=$this->input->post('component_type');$method=$this->method($this->input->post('calculation_method'));$value=$this->decimal($this->input->post('default_value'));if($code===''||$name===''||!in_array($type,array('earning','deduction','employer_cost'),true))throw new BusinessException('Kode, nama, dan jenis komponen wajib diisi.');$proposed=(object)array('code'=>$code,'component_type'=>$type);if(!$this->canManageAllowances()){if(!$before&&$type!=='deduction')throw new BusinessException('Tanpa permission Kelola Tunjangan & Bonus, komponen baru hanya boleh berupa potongan.');if($before){$this->assertManageComponent($before);$this->assertManageComponent($proposed);if(strtoupper((string)$before->code)==='BASIC'&&$code!=='BASIC')throw new BusinessException('Kode komponen Gaji Pokok tidak boleh diubah.');}}$formula=$method==='formula'?trim((string)$this->input->post('formula_expression',true))?:null:null;if($method==='formula')$this->validateFormula($formula);$row=array('company_id'=>$this->company(),'code'=>$code,'name'=>$name,'component_type'=>$type,'category'=>$this->input->post('category')?:'other','calculation_method'=>$method,'default_value'=>$value,'fixed_amount'=>$value,'formula_expression'=>$formula,'taxable'=>$this->input->post('taxable')?1:0,'prorated'=>$this->input->post('prorated')?1:0,'expense_account_id'=>$this->input->post('expense_account_id')?:null,'liability_account_id'=>$this->input->post('liability_account_id')?:null,'is_active'=>$this->input->post('is_active')?1:0,'effective_from'=>$this->validDate($this->input->post('effective_from'))?:date('Y-m-d'),'effective_to'=>$this->validDate($this->input->post('effective_to'))?:null,'updated_at'=>date('Y-m-d H:i:s'));$duplicate=$this->db->where(array('company_id'=>$this->company(),'code'=>$code))->where('id !=',$id)->count_all_results('payroll_components');if($duplicate)throw new BusinessException('Kode komponen sudah digunakan.');$this->db->trans_begin();if($id)$this->db->where(array('id'=>$id,'company_id'=>$this->company()))->update('payroll_components',$row);else{$row['created_by']=$this->uid();$row['created_at']=date('Y-m-d H:i:s');$this->db->insert('payroll_components',$row);$id=(int)$this->db->insert_id();}$this->invalidateDraftCalculations();$this->auditservice->record('payroll','payroll_component',$id,$before?'update':'create',$before,$row,$this->uid());if($this->db->trans_status()===false)throw new RuntimeException('Komponen payroll gagal disimpan.');$this->db->trans_commit();$this->go(true,'Komponen payroll berhasil disimpan. Draft payroll wajib dihitung ulang.','settings');}catch(Throwable$e){$this->db->trans_rollback();$this->go(false,$e->getMessage(),'settings');} } public function save_position_line() { - try{$positionId=(int)$this->input->post('position_id');if(!$positionId||!$this->db->where('id',$positionId)->count_all_results('k_positions'))throw new BusinessException('Posisi tidak ditemukan.');$row=$this->assignmentInput('position',$positionId);if($this->db->get_where('position_payroll_components',array('company_id'=>$this->company(),'position_id'=>$positionId,'component_id'=>$row['component_id'],'effective_from'=>$row['effective_from']))->row())throw new BusinessException('Template komponen dengan tanggal efektif yang sama sudah ada.');$this->db->insert('position_payroll_components',$row);$id=(int)$this->db->insert_id();$this->auditservice->record('payroll','position_salary_component',$id,'create',null,$row,$this->uid());$this->go(true,'Template posisi berhasil ditambahkan.','settings');}catch(Throwable$e){$this->go(false,$e->getMessage(),'settings');} + try{$this->requirePost();$this->requireAction('can_update','Anda tidak mempunyai permission mengubah template posisi.');$positionId=(int)$this->input->post('position_id');$positionQuery=$this->db->where('id',$positionId);if($this->db->field_exists('company_id','k_positions'))$positionQuery->where('company_id',$this->company());if(!$positionId||!$positionQuery->count_all_results('k_positions'))throw new BusinessException('Posisi tidak ditemukan pada perusahaan aktif.');$row=$this->assignmentInput('position',$positionId);if($this->db->get_where('position_payroll_components',array('company_id'=>$this->company(),'position_id'=>$positionId,'component_id'=>$row['component_id'],'effective_from'=>$row['effective_from']))->row())throw new BusinessException('Template komponen dengan tanggal efektif yang sama sudah ada.');$this->db->trans_begin();$previous=$this->db->where(array('company_id'=>$this->company(),'position_id'=>$positionId,'component_id'=>$row['component_id'],'is_active'=>1))->where('effective_from <',$row['effective_from'])->group_start()->where('effective_to IS NULL',null,false)->or_where('effective_to >=',$row['effective_from'])->group_end()->order_by('effective_from','DESC')->get('position_payroll_components')->row();if($previous)$this->db->where(array('id'=>$previous->id,'company_id'=>$this->company()))->update('position_payroll_components',array('effective_to'=>date('Y-m-d',strtotime($row['effective_from'].' -1 day')),'updated_at'=>date('Y-m-d H:i:s')));$this->db->insert('position_payroll_components',$row);$id=(int)$this->db->insert_id();$this->invalidateDraftCalculations();$this->auditservice->record('payroll','position_salary_component',$id,'create_version',$previous,$row,$this->uid());if($this->db->trans_status()===false)throw new RuntimeException('Template posisi gagal disimpan.');$this->db->trans_commit();$this->go(true,'Versi template posisi berhasil ditambahkan. Draft payroll wajib dihitung ulang.','settings');}catch(Throwable$e){$this->db->trans_rollback();$this->go(false,$e->getMessage(),'settings');} } public function save_attendance_rule() { - try{$condition=$this->input->post('condition_type');$method=$this->attendanceMethod($this->input->post('calculation_method'));$start=$this->validDate($this->input->post('effective_from'));if(!in_array($condition,array('alpha','late','early_leave','unpaid','missing_checkin','missing_checkout'),true)||!$start)throw new BusinessException('Kondisi dan tanggal efektif aturan tidak valid.');$componentId=$this->input->post('component_id')?(int)$this->input->post('component_id'):null;if($method!=='none'&&!$componentId)throw new BusinessException('Komponen potongan wajib dipilih bila aturan menghasilkan potongan.');$formula=$method==='formula'?trim((string)$this->input->post('formula_expression',true))?:null:null;if($method==='formula')$this->validateFormula($formula);$row=array('company_id'=>$this->company(),'component_id'=>$componentId,'rule_name'=>trim((string)$this->input->post('rule_name',true)),'condition_type'=>$condition,'calculation_method'=>$method,'tolerance_minutes'=>max(0,(int)$this->input->post('tolerance_minutes')),'rate_value'=>$this->decimal($this->input->post('rate_value')),'maximum_amount'=>$this->input->post('maximum_amount')!==''?$this->decimal($this->input->post('maximum_amount')):null,'formula_expression'=>$formula,'effective_from'=>$start,'effective_to'=>$this->validDate($this->input->post('effective_to'))?:null,'is_active'=>$this->input->post('is_active')?1:0,'notes'=>trim((string)$this->input->post('notes',true))?:null,'created_by'=>$this->uid(),'created_at'=>date('Y-m-d H:i:s'));if($row['rule_name']==='')throw new BusinessException('Nama aturan wajib diisi.');$this->db->insert('payroll_attendance_rules',$row);$id=(int)$this->db->insert_id();$this->auditservice->record('payroll','attendance_rule',$id,'create_version',null,$row,$this->uid());$this->go(true,'Versi aturan kehadiran berhasil disimpan.','settings');}catch(Throwable$e){$this->go(false,$e->getMessage(),'settings');} + try{$this->requirePost();$this->requireAction('can_update','Anda tidak mempunyai permission mengubah aturan kehadiran payroll.');$condition=$this->input->post('condition_type');$method=$this->attendanceMethod($this->input->post('calculation_method'));$start=$this->validDate($this->input->post('effective_from'));if(!in_array($condition,array('alpha','late','early_leave','unpaid','missing_checkin','missing_checkout'),true)||!$start)throw new BusinessException('Kondisi dan tanggal efektif aturan tidak valid.');$componentId=$this->input->post('component_id')?(int)$this->input->post('component_id'):null;if($method!=='none'&&!$componentId)throw new BusinessException('Komponen potongan wajib dipilih bila aturan menghasilkan potongan.');if($componentId){$component=$this->componentRow($componentId);if($component->component_type!=='deduction')throw new BusinessException('Aturan kehadiran hanya boleh menggunakan komponen potongan.');}if($method==='none')$componentId=null;$formula=$method==='formula'?trim((string)$this->input->post('formula_expression',true))?:null:null;if($method==='formula')$this->validateFormula($formula);$row=array('company_id'=>$this->company(),'component_id'=>$componentId,'rule_name'=>trim((string)$this->input->post('rule_name',true)),'condition_type'=>$condition,'calculation_method'=>$method,'tolerance_minutes'=>max(0,(int)$this->input->post('tolerance_minutes')),'rate_value'=>$this->decimal($this->input->post('rate_value')),'maximum_amount'=>$this->input->post('maximum_amount')!==''?$this->decimal($this->input->post('maximum_amount')):null,'formula_expression'=>$formula,'effective_from'=>$start,'effective_to'=>$this->validDate($this->input->post('effective_to'))?:null,'is_active'=>$this->input->post('is_active')?1:0,'notes'=>trim((string)$this->input->post('notes',true))?:null,'created_by'=>$this->uid(),'created_at'=>date('Y-m-d H:i:s'));if($row['rule_name']==='')throw new BusinessException('Nama aturan wajib diisi.');$this->db->trans_begin();$this->db->insert('payroll_attendance_rules',$row);$id=(int)$this->db->insert_id();$this->invalidateDraftCalculations();$this->auditservice->record('payroll','attendance_rule',$id,'create_version',null,$row,$this->uid());if($this->db->trans_status()===false)throw new RuntimeException('Aturan kehadiran gagal disimpan.');$this->db->trans_commit();$this->go(true,'Versi aturan kehadiran berhasil disimpan. Draft payroll wajib dihitung ulang.','settings');}catch(Throwable$e){$this->db->trans_rollback();$this->go(false,$e->getMessage(),'settings');} } public function save_adjustment() { - try{$period=$this->period((int)$this->input->post('payroll_period_id'),false);if($period->workflow_status!=='draft')throw new BusinessException('Penyesuaian hanya dapat dibuat pada payroll Draft.');$employee=$this->employee((int)$this->input->post('employee_id'));if(!empty($employee->salary_restricted)&&!$this->allSalary())throw new BusinessException('Gaji karyawan ini bersifat terbatas.');$component=$this->componentRow((int)$this->input->post('component_id'));$type=$this->input->post('adjustment_type');if(!in_array($type,array('earning','deduction'),true))throw new BusinessException('Jenis penyesuaian tidak valid.');$amount=$this->decimal($this->input->post('amount'));$notes=trim((string)$this->input->post('notes',true));if($amount<=0||$notes==='')throw new BusinessException('Nominal harus lebih dari nol dan keterangan wajib diisi.');$row=array('company_id'=>$this->company(),'payroll_period_id'=>$period->id,'employee_id'=>$employee->id,'component_id'=>$component->id,'adjustment_type'=>$type,'amount'=>$amount,'notes'=>$notes,'is_active'=>1,'created_by'=>$this->uid(),'created_at'=>date('Y-m-d H:i:s'));$this->db->insert('payroll_period_adjustments',$row);$id=(int)$this->db->insert_id();$this->auditservice->record('payroll','period_adjustment',$id,'create',null,$row,$this->uid());$this->go(true,'Penyesuaian periode berhasil ditambahkan.','adjustments');}catch(Throwable$e){$this->go(false,$e->getMessage(),'adjustments');} + try{$this->requirePost();$this->requireAction('can_create','Anda tidak mempunyai permission membuat penyesuaian payroll.');$period=$this->period((int)$this->input->post('payroll_period_id'),false);if(!$period)throw new BusinessException('Periode payroll tidak ditemukan.');if($period->workflow_status!=='draft')throw new BusinessException('Penyesuaian hanya dapat dibuat pada payroll Draft.');$employee=$this->employee((int)$this->input->post('employee_id'));if(!empty($employee->salary_restricted)&&!$this->allSalary())throw new BusinessException('Gaji karyawan ini bersifat terbatas.');$component=$this->componentRow((int)$this->input->post('component_id'));$type=$this->input->post('adjustment_type');if(!in_array($type,array('earning','deduction'),true))throw new BusinessException('Jenis penyesuaian tidak valid.');if($component->component_type!==$type)throw new BusinessException('Jenis penyesuaian harus sama dengan jenis komponen yang dipilih.');if($type==='earning'&&!$this->canManageAllowances())throw new BusinessException('Bonus dan pendapatan tambahan memerlukan permission Kelola Tunjangan & Bonus.');$amount=$this->decimal($this->input->post('amount'));$notes=trim((string)$this->input->post('notes',true));if($amount<=0||$notes==='')throw new BusinessException('Nominal harus lebih dari nol dan keterangan wajib diisi.');$row=array('company_id'=>$this->company(),'payroll_period_id'=>$period->id,'employee_id'=>$employee->id,'component_id'=>$component->id,'adjustment_type'=>$type,'amount'=>$amount,'notes'=>$notes,'is_active'=>1,'created_by'=>$this->uid(),'created_at'=>date('Y-m-d H:i:s'));$this->db->trans_begin();$this->db->insert('payroll_period_adjustments',$row);$id=(int)$this->db->insert_id();$this->invalidateDraftCalculations();$this->auditservice->record('payroll','period_adjustment',$id,'create',null,$row,$this->uid());if($this->db->trans_status()===false)throw new RuntimeException('Penyesuaian payroll gagal disimpan.');$this->db->trans_commit();$this->go(true,'Penyesuaian periode berhasil ditambahkan. Draft payroll wajib dihitung ulang.','adjustments');}catch(Throwable$e){$this->db->trans_rollback();$this->go(false,$e->getMessage(),'adjustments');} } public function deactivate_adjustment($id) { - try{$row=$this->db->select('a.*,p.workflow_status,e.salary_restricted')->from('payroll_period_adjustments a')->join('k_payroll_periods p','p.id=a.payroll_period_id')->join('k_employees e','e.id=a.employee_id')->where(array('a.id'=>(int)$id,'a.company_id'=>$this->company()))->get()->row();if(!$row)throw new BusinessException('Penyesuaian tidak ditemukan.');if($row->workflow_status!=='draft')throw new BusinessException('Penyesuaian payroll yang bukan Draft tidak dapat diubah.');if($row->salary_restricted&&!$this->allSalary())throw new BusinessException('Gaji karyawan ini bersifat terbatas.');$update=array('is_active'=>0,'updated_by'=>$this->uid(),'updated_at'=>date('Y-m-d H:i:s'));$this->db->where('id',$row->id)->update('payroll_period_adjustments',$update);$this->auditservice->record('payroll','period_adjustment',$row->id,'deactivate',$row,$update,$this->uid());$this->go(true,'Penyesuaian dinonaktifkan tanpa menghapus histori.','adjustments');}catch(Throwable$e){$this->go(false,$e->getMessage(),'adjustments');} + try{$this->requirePost();$this->requireAction('can_update','Anda tidak mempunyai permission mengubah penyesuaian payroll.');$row=$this->db->select('a.*,p.workflow_status,e.salary_restricted,c.code component_code,c.component_type')->from('payroll_period_adjustments a')->join('k_payroll_periods p','p.id=a.payroll_period_id AND p.company_id=a.company_id')->join('k_employees e','e.id=a.employee_id AND e.company_id=a.company_id')->join('payroll_components c','c.id=a.component_id AND c.company_id=a.company_id')->where(array('a.id'=>(int)$id,'a.company_id'=>$this->company()))->get()->row();if(!$row)throw new BusinessException('Penyesuaian tidak ditemukan.');if($row->workflow_status!=='draft')throw new BusinessException('Penyesuaian payroll yang bukan Draft tidak dapat diubah.');if($row->salary_restricted&&!$this->allSalary())throw new BusinessException('Gaji karyawan ini bersifat terbatas.');$this->assertManageComponent((object)array('component_type'=>$row->component_type,'code'=>$row->component_code));$update=array('is_active'=>0,'updated_by'=>$this->uid(),'updated_at'=>date('Y-m-d H:i:s'));$this->db->trans_begin();$this->db->where(array('id'=>$row->id,'company_id'=>$this->company()))->update('payroll_period_adjustments',$update);$this->invalidateDraftCalculations();$this->auditservice->record('payroll','period_adjustment',$row->id,'deactivate',$row,$update,$this->uid());if($this->db->trans_status()===false)throw new RuntimeException('Penyesuaian payroll gagal dinonaktifkan.');$this->db->trans_commit();$this->go(true,'Penyesuaian dinonaktifkan tanpa menghapus histori. Draft payroll wajib dihitung ulang.','adjustments');}catch(Throwable$e){$this->db->trans_rollback();$this->go(false,$e->getMessage(),'adjustments');} } public function save_settings() { - try{if(!$this->can('can_update'))throw new BusinessException('Anda tidak mempunyai permission mengubah pengaturan payroll.');$day=$this->input->post('default_payment_day');$day=$day===''?null:(int)$day;if($day!==null&&($day<1||$day>31))throw new BusinessException('Tanggal pembayaran default harus 1 sampai 31.');$before=$this->db->get_where('payroll_settings',array('company_id'=>$this->company()))->row();$row=array('separation_of_duties'=>$this->input->post('separation_of_duties')?1:0,'default_payment_day'=>$day,'bank_export_note'=>trim((string)$this->input->post('bank_export_note',true))?:null,'updated_at'=>date('Y-m-d H:i:s'));if($before)$this->db->where('id',$before->id)->update('payroll_settings',$row);else{$row['company_id']=$this->company();$row['created_by']=$this->uid();$row['created_at']=date('Y-m-d H:i:s');$this->db->insert('payroll_settings',$row);}$this->auditservice->record('payroll','payroll_settings',$this->company(),'update',$before,$row,$this->uid());$this->go(true,'Pengaturan umum payroll berhasil disimpan.','settings');}catch(Throwable$e){$this->go(false,$e->getMessage(),'settings');} + try{$this->requirePost();$this->requireAction('can_update','Anda tidak mempunyai permission mengubah pengaturan payroll.');if(!$this->canManageAllowances())throw new BusinessException('Pengaturan umum payroll hanya dapat diubah dengan permission Kelola Tunjangan & Bonus.');$day=$this->input->post('default_payment_day');$day=$day===''?null:(int)$day;if($day!==null&&($day<1||$day>31))throw new BusinessException('Tanggal pembayaran default harus 1 sampai 31.');$before=$this->db->get_where('payroll_settings',array('company_id'=>$this->company()))->row();$row=array('separation_of_duties'=>$this->input->post('separation_of_duties')?1:0,'default_payment_day'=>$day,'bank_export_note'=>trim((string)$this->input->post('bank_export_note',true))?:null,'updated_at'=>date('Y-m-d H:i:s'));$this->db->trans_begin();if($before)$this->db->where(array('id'=>$before->id,'company_id'=>$this->company()))->update('payroll_settings',$row);else{$row['company_id']=$this->company();$row['created_by']=$this->uid();$row['created_at']=date('Y-m-d H:i:s');$this->db->insert('payroll_settings',$row);}$this->auditservice->record('payroll','payroll_settings',$this->company(),'update',$before,$row,$this->uid());if($this->db->trans_status()===false)throw new RuntimeException('Pengaturan payroll gagal disimpan.');$this->db->trans_commit();$this->go(true,'Pengaturan umum payroll berhasil disimpan.','settings');}catch(Throwable$e){$this->db->trans_rollback();$this->go(false,$e->getMessage(),'settings');} } private function assignmentInput($subject,$subjectId) { - $component=$this->componentRow((int)$this->input->post('component_id'));$method=$this->method($this->input->post('calculation_method'));$amount=$this->decimal($this->input->post('amount_override'));$start=$this->validDate($this->input->post('effective_from'));$end=$this->validDate($this->input->post('effective_to'));if(!$start)throw new BusinessException('Tanggal mulai berlaku wajib diisi.');if($end&&$end<$start)throw new BusinessException('Tanggal selesai tidak boleh sebelum tanggal mulai.');$formula=$method==='formula'?trim((string)$this->input->post('formula_expression',true))?:null:null;if($method==='formula')$this->validateFormula($formula);$row=array('company_id'=>$this->company(),$subject.'_id'=>$subjectId,'component_id'=>$component->id,'calculation_method'=>$method,'amount_override'=>$amount,'formula_expression'=>$formula,'effective_from'=>$start,'effective_to'=>$end?:null,'is_active'=>1,'notes'=>trim((string)$this->input->post('notes',true))?:null,'created_by'=>$this->uid(),'created_at'=>date('Y-m-d H:i:s'));return$row; + $component=$this->componentRow((int)$this->input->post('component_id'));$this->assertManageComponent($component);if(!(int)$component->is_active)throw new BusinessException('Komponen payroll yang dipilih sudah nonaktif.');$method=$this->method($this->input->post('calculation_method'));$amount=$this->decimal($this->input->post('amount_override'));$start=$this->validDate($this->input->post('effective_from'));$end=$this->validDate($this->input->post('effective_to'));if(!$start)throw new BusinessException('Tanggal mulai berlaku wajib diisi.');if($end&&$end<$start)throw new BusinessException('Tanggal selesai tidak boleh sebelum tanggal mulai.');$formula=$method==='formula'?trim((string)$this->input->post('formula_expression',true))?:null:null;if($method==='formula')$this->validateFormula($formula);$row=array('company_id'=>$this->company(),$subject.'_id'=>$subjectId,'component_id'=>$component->id,'calculation_method'=>$method,'amount_override'=>$amount,'formula_expression'=>$formula,'effective_from'=>$start,'effective_to'=>$end?:null,'is_active'=>1,'notes'=>trim((string)$this->input->post('notes',true))?:null,'created_by'=>$this->uid(),'created_at'=>date('Y-m-d H:i:s'));return$row; } private function method($method){$allowed=array('fixed','per_present_day','per_absent_day','per_minute','per_hour','percentage_basic','formula');if(!in_array($method,$allowed,true))throw new BusinessException('Metode perhitungan tidak valid.');return$method;} private function attendanceMethod($method){$allowed=array('none','fixed','per_minute','per_hour','per_day','percentage_daily','formula');if(!in_array($method,$allowed,true))throw new BusinessException('Metode potongan tidak valid.');return$method;} private function validateFormula($formula){if(!$formula)throw new BusinessException('Formula wajib diisi.');$variables=array('basic_salary'=>1,'calendar_days'=>1,'work_days'=>1,'present_days'=>1,'alpha_days'=>1,'unpaid_days'=>1,'unpaid_days_total'=>1,'late_minutes'=>1,'early_leave_minutes'=>1,'missing_checkin'=>1,'missing_checkout'=>1,'overtime_hours'=>1,'overtime_rate'=>1,'daily_rate'=>1,'condition_quantity'=>1,'rule_rate'=>1);$this->formulaservice->calculate($formula,$variables);} private function decimal($value){$number=(float)str_replace(array('. ',','),array('','.'),(string)$value);if($number<0)throw new BusinessException('Nominal atau tarif tidak boleh negatif.');return round($number,2);} private function employee($id){$row=$this->db->select('e.*,d.department_name,p.position_name')->from('k_employees e')->join('k_departments d','d.id=e.department_id','left')->join('k_positions p','p.id=e.position_id','left')->where(array('e.id'=>$id,'e.company_id'=>$this->company()))->get()->row();if(!$row)throw new BusinessException('Karyawan tidak ditemukan pada perusahaan aktif.');return$row;} - private function componentRow($id){$row=$this->db->get_where('payroll_components',array('id'=>$id,'company_id'=>$this->company()))->row();if(!$row)throw new BusinessException('Komponen payroll tidak ditemukan.');return$row;} + private function componentRow($id){$row=$this->db->get_where('payroll_components',array('id'=>$id,'company_id'=>$this->company()))->row();if(!$row)throw new BusinessException('Komponen payroll tidak ditemukan.');if(!(int)$row->is_active)throw new BusinessException('Komponen payroll yang dipilih sudah nonaktif.');return$row;} private function basicComponentId(){$row=$this->db->get_where('payroll_components',array('company_id'=>$this->company(),'code'=>'BASIC'))->row();return$row?(int)$row->id:0;} private function period($id,$throw=true){$row=$this->db->get_where('k_payroll_periods',array('id'=>(int)$id,'company_id'=>$this->company()))->row();if(!$row&&$throw)throw new BusinessException('Periode payroll tidak ditemukan.');return$row;} public function slip($payrollId) { - $payroll=$this->db->select('x.*,e.full_name,e.employee_code,e.salary_restricted,r.period_name,r.workflow_status,r.attendance_start_date,r.attendance_end_date,r.payment_date')->from('k_payrolls x')->join('k_employees e','e.id=x.employee_id')->join('k_payroll_periods r','r.id=x.payroll_period_id')->where(array('x.id'=>(int)$payrollId,'x.company_id'=>$this->company(),'r.company_id'=>$this->company()))->get()->row();if(!$payroll||!in_array($payroll->workflow_status,array('final','posted','paid'),true))show_error('Slip belum tersedia.',422);if($payroll->salary_restricted&&!$this->allSalary())show_error('Gaji ini bersifat terbatas.',403);$lines=$this->db->where('payroll_id',$payrollId)->get('payroll_calculation_lines')->result();$this->load->view('hr_payroll/slip',array('payroll'=>$payroll,'lines'=>$lines)); + if(!$this->can('can_view'))show_error('Anda tidak memiliki izin melihat slip payroll.',403,'Akses ditolak');$payroll=$this->db->select('x.*,e.full_name,e.employee_code,e.salary_restricted,r.period_name,r.workflow_status,r.attendance_start_date,r.attendance_end_date,r.payment_date')->from('k_payrolls x')->join('k_employees e','e.id=x.employee_id AND e.company_id=x.company_id')->join('k_payroll_periods r','r.id=x.payroll_period_id AND r.company_id=x.company_id')->where(array('x.id'=>(int)$payrollId,'x.company_id'=>$this->company()))->get()->row();if(!$payroll||!in_array($payroll->workflow_status,array('final','posted','paid'),true))show_error('Slip belum tersedia.',422);if($payroll->salary_restricted&&!$this->allSalary())show_error('Gaji ini bersifat terbatas.',403);$lines=$this->db->where('payroll_id',$payrollId)->get('payroll_calculation_lines')->result();$this->load->view('hr_payroll/slip',array('payroll'=>$payroll,'lines'=>$lines)); } public function export_pdf($id) { - try{$period=$this->exportablePeriod($id);$rows=$this->payrollRows($id);$company=$this->db->get_where('companies',array('id'=>$this->company()))->row();$html=$this->load->view('hr_payroll/register_pdf',array('period'=>$period,'rows'=>$rows,'company'=>$company),true);$dompdf=new Dompdf\Dompdf();$dompdf->loadHtml($html);$dompdf->setPaper('A4','landscape');$dompdf->render();$dompdf->stream('payroll-'.$period->id.'.pdf',array('Attachment'=>true));}catch(Throwable$e){show_error($e->getMessage(),422);} + try{$this->requireAction('can_export','Anda tidak mempunyai permission export payroll.');$period=$this->exportablePeriod($id);$rows=$this->payrollRows($id);$company=$this->db->get_where('companies',array('id'=>$this->company()))->row();$html=$this->load->view('hr_payroll/register_pdf',array('period'=>$period,'rows'=>$rows,'company'=>$company),true);$dompdf=new Dompdf\Dompdf();$dompdf->loadHtml($html);$dompdf->setPaper('A4','landscape');$dompdf->render();$dompdf->stream('payroll-'.$period->id.'.pdf',array('Attachment'=>true));}catch(Throwable$e){show_error($e->getMessage(),$e instanceof BusinessException?422:500);} } public function export_excel($id,$bank='general') { - try{$period=$this->exportablePeriod($id);$bank=strtolower($bank);if(!in_array($bank,array('general','bri','bca'),true))throw new BusinessException('Format bank tidak tersedia.');$rows=$this->payrollRows($id,$bank==='general'?null:$bank);if(!$rows)throw new BusinessException('Tidak ada data payroll untuk format yang dipilih.');$spreadsheet=new PhpOffice\PhpSpreadsheet\Spreadsheet();$sheet=$spreadsheet->getActiveSheet();$sheet->setTitle($bank==='general'?'Payroll Register':strtoupper($bank).' Source');$headers=$bank==='general'?array('No','Kode Karyawan','Nama Karyawan','Bank','Nomor Rekening','Gaji Pokok','Bruto','Potongan','Neto','Tanggal Efektif'):array('No','Nomor Rekening','Nama Penerima','Nominal Neto','Berita Pembayaran','Kode Karyawan','Tanggal Efektif');$sheet->fromArray($headers,null,'A1');$line=2;foreach($rows as$i=>$row){$values=$bank==='general'?array($i+1,$row->employee_code,$row->full_name,$row->bank_name,$row->bank_account_number,(float)$row->basic_salary,(float)$row->gross_salary,(float)$row->total_deductions,(float)$row->total_salary,$period->payment_date):array($i+1,$row->bank_account_number,$row->bank_account_name?:$row->full_name,(float)$row->total_salary,'Gaji '.$period->period_name,$row->employee_code,$period->payment_date);$sheet->fromArray($values,null,'A'.$line++);$accountColumn=$bank==='general'?'E':'B';$sheet->setCellValueExplicit($accountColumn.($line-1),(string)$row->bank_account_number,PhpOffice\PhpSpreadsheet\Cell\DataType::TYPE_STRING);}$sheet->getStyle('A1:'.$sheet->getHighestColumn().'1')->getFont()->setBold(true);foreach(range('A',$sheet->getHighestColumn())as$column)$sheet->getColumnDimension($column)->setAutoSize(true);if($bank!=='general'){$guide=$spreadsheet->createSheet();$guide->setTitle('PETUNJUK');$guide->fromArray(array(array('File sumber '.strtoupper($bank)),array('Gunakan converter/checksum resmi portal bank perusahaan bila diwajibkan.'),array('Periksa nama, rekening, nominal, dan tanggal efektif sebelum otorisasi.')),null,'A1');$guide->getColumnDimension('A')->setWidth(100);}header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');header('Content-Disposition: attachment; filename="payroll-'.$bank.'-'.$period->id.'.xlsx"');header('Cache-Control: max-age=0');(new PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet))->save('php://output');exit;}catch(Throwable$e){show_error($e->getMessage(),422);} + try{$this->requireAction('can_export','Anda tidak mempunyai permission export payroll.');$period=$this->exportablePeriod($id);$bank=strtolower($bank);if(!in_array($bank,array('general','bri','bca'),true))throw new BusinessException('Format bank tidak tersedia.');$rows=$this->payrollRows($id,$bank==='general'?null:$bank);if(!$rows)throw new BusinessException('Tidak ada data payroll untuk format yang dipilih.');$spreadsheet=new PhpOffice\PhpSpreadsheet\Spreadsheet();$sheet=$spreadsheet->getActiveSheet();$sheet->setTitle($bank==='general'?'Payroll Register':strtoupper($bank).' Source');$headers=$bank==='general'?array('No','Kode Karyawan','Nama Karyawan','Bank','Nomor Rekening','Gaji Pokok','Bruto','Potongan','Neto','Tanggal Efektif'):array('No','Nomor Rekening','Nama Penerima','Nominal Neto','Berita Pembayaran','Kode Karyawan','Tanggal Efektif');$sheet->fromArray($headers,null,'A1');$line=2;foreach($rows as$i=>$row){$values=$bank==='general'?array($i+1,$row->employee_code,$row->full_name,$row->bank_name,$row->bank_account_number,(float)$row->basic_salary,(float)$row->gross_salary,(float)$row->total_deductions,(float)$row->total_salary,$period->payment_date):array($i+1,$row->bank_account_number,$row->bank_account_name?:$row->full_name,(float)$row->total_salary,'Gaji '.$period->period_name,$row->employee_code,$period->payment_date);$sheet->fromArray($values,null,'A'.$line++);$accountColumn=$bank==='general'?'E':'B';$sheet->setCellValueExplicit($accountColumn.($line-1),(string)$row->bank_account_number,PhpOffice\PhpSpreadsheet\Cell\DataType::TYPE_STRING);}$sheet->getStyle('A1:'.$sheet->getHighestColumn().'1')->getFont()->setBold(true);foreach(range('A',$sheet->getHighestColumn())as$column)$sheet->getColumnDimension($column)->setAutoSize(true);if($bank!=='general'){$guide=$spreadsheet->createSheet();$guide->setTitle('PETUNJUK');$guide->fromArray(array(array('File sumber '.strtoupper($bank)),array('Gunakan converter/checksum resmi portal bank perusahaan bila diwajibkan.'),array('Periksa nama, rekening, nominal, dan tanggal efektif sebelum otorisasi.')),null,'A1');$guide->getColumnDimension('A')->setWidth(100);}header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');header('Content-Disposition: attachment; filename="payroll-'.$bank.'-'.$period->id.'.xlsx"');header('Cache-Control: max-age=0');(new PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet))->save('php://output');exit;}catch(Throwable$e){show_error($e->getMessage(),$e instanceof BusinessException?422:500);} } private function exportablePeriod($id){$period=$this->period($id);if(!in_array($period->workflow_status,array('final','posted','paid'),true))throw new BusinessException('Export tersedia setelah payroll berstatus Final.');if(!$this->allSalary()&&$this->hasRestrictedPeriod($id))throw new BusinessException('Export periode ini memuat gaji terbatas.');return$period;} - private function hasRestrictedPeriod($id){return$this->db->from('k_payrolls x')->join('k_employees e','e.id=x.employee_id')->where(array('x.payroll_period_id'=>(int)$id,'x.company_id'=>$this->company(),'e.salary_restricted'=>1))->count_all_results()>0;} - private function payrollRows($id,$bank=null){$q=$this->db->select('x.*,e.employee_code,e.full_name,e.bank_name,e.bank_account_number,e.bank_account_name,e.salary_restricted')->from('k_payrolls x')->join('k_employees e','e.id=x.employee_id')->where(array('x.payroll_period_id'=>(int)$id,'x.company_id'=>$this->company()));if(!$this->allSalary())$q->where('e.salary_restricted',0);if($bank){$q->group_start()->like('e.bank_name',$bank);if($bank==='bri')$q->or_like('e.bank_name','Rakyat Indonesia');if($bank==='bca')$q->or_like('e.bank_name','Central Asia');$q->group_end();}$rows=$q->order_by('e.full_name')->get()->result();if($bank){$missing=array();foreach($rows as$row)if(trim((string)$row->bank_account_number)===''||trim((string)$row->bank_account_name)==='')$missing[]=$row->full_name;if($missing)throw new BusinessException('Data rekening belum lengkap: '.implode(', ',$missing).'.');}return$rows;} + private function hasRestrictedPeriod($id){return$this->db->from('k_payrolls x')->join('k_employees e','e.id=x.employee_id AND e.company_id=x.company_id')->where(array('x.payroll_period_id'=>(int)$id,'x.company_id'=>$this->company(),'e.salary_restricted'=>1))->count_all_results()>0;} + private function payrollRows($id,$bank=null){$q=$this->db->select('x.*,e.employee_code,e.full_name,e.bank_name,e.bank_account_number,e.bank_account_name,e.salary_restricted')->from('k_payrolls x')->join('k_employees e','e.id=x.employee_id AND e.company_id=x.company_id')->where(array('x.payroll_period_id'=>(int)$id,'x.company_id'=>$this->company()));if(!$this->allSalary())$q->where('e.salary_restricted',0);if($bank){$q->group_start()->like('e.bank_name',$bank);if($bank==='bri')$q->or_like('e.bank_name','Rakyat Indonesia');if($bank==='bca')$q->or_like('e.bank_name','Central Asia');$q->group_end();}$rows=$q->order_by('e.full_name')->get()->result();if($bank){$missing=array();foreach($rows as$row)if(trim((string)$row->bank_account_number)===''||trim((string)$row->bank_account_name)==='')$missing[]=$row->full_name;if($missing)throw new BusinessException('Data rekening belum lengkap: '.implode(', ',$missing).'.');}return$rows;} } diff --git a/application/controllers/Invoices.php b/application/controllers/Invoices.php index 98b0f4d..30f6c1b 100644 --- a/application/controllers/Invoices.php +++ b/application/controllers/Invoices.php @@ -65,9 +65,12 @@ class Invoices extends MY_Controller public function lookup_warehouses(){return$this->selectLookup('warehouses','nama',array('nama'),'Gudang ditemukan.');} public function lookup_barcodes() { - $q=trim((string)$this->input->get('q'));$item=(int)$this->input->get('item_id');$warehouse=(int)$this->input->get('warehouse_id');$exact=(int)$this->input->get('exact')===1;$page=max(1,(int)$this->input->get('page'));$limit=20; + $q=trim((string)$this->input->get('q'));$item=(int)$this->input->get('item_id');$warehouse=(int)$this->input->get('warehouse_id');$exact=(int)$this->input->get('exact')===1;$page=max(1,(int)$this->input->get('page'));$limit=20;$editingLine=(int)$this->input->get('editing_line_id'); if(!$exact&&(!$item||!$warehouse))return json_response(true,'Pilih gudang dan barang terlebih dahulu.',array('results'=>array(),'pagination'=>array('more'=>false))); - $this->db->select("b.id,CONCAT(b.barcode,IF(NULLIF(b.serial_number,'') IS NULL,'',CONCAT(' / ',b.serial_number)),' - tersedia ',FORMAT(b.qty_sisa-b.reserved_qty-COALESCE(b.return_reserved_qty,0),0,'id_ID')) text,b.barcode,b.serial_number,b.item_id,b.warehouse_id,(b.qty_sisa-b.reserved_qty-COALESCE(b.return_reserved_qty,0)) available,i.kode_detail,i.nama_barang,i.harga_jual,k.tracking_type,k.unit,w.nama warehouse_name",false)->from('item_barcodes b')->join('items i','i.id=b.item_id')->join('kode_barang k','k.id=i.kode_id','left')->join('warehouses w','w.id=b.warehouse_id')->where(array('i.company_id'=>$this->company(),'b.status'=>'available'))->where('(b.qty_sisa-b.reserved_qty-COALESCE(b.return_reserved_qty,0)) >',0,false)->where("(UPPER(COALESCE(k.tracking_type,'QTY'))<>'UNIT' OR NOT EXISTS(SELECT 1 FROM invoice_line_barcodes used JOIN invoice_details used_detail ON used_detail.id=used.invoice_detail_id AND used_detail.stock_posted_at IS NULL JOIN invoices used_invoice ON used_invoice.id=used.invoice_id WHERE used.barcode_id=b.id AND used_invoice.company_id=i.company_id AND used_invoice.deleted_at IS NULL AND COALESCE(used_invoice.status,'')<>'cancelled'))",null,false);if($item)$this->db->where('b.item_id',$item);if($warehouse)$this->db->where('b.warehouse_id',$warehouse);if($q!==''){$this->db->group_start();if($exact)$this->db->where('b.barcode',$q)->or_where('b.serial_number',$q);else$this->db->like('b.barcode',$q)->or_like('b.serial_number',$q);$this->db->group_end();}$rows=$this->db->order_by('b.id')->limit($limit+1,($page-1)*$limit)->get()->result();$more=count($rows)>$limit;if($more)array_pop($rows);return json_response(true,'Barcode ditemukan.',array('results'=>$rows,'pagination'=>array('more'=>$more))); + if($editingLine){$owned=$this->db->select('d.id,inv.created_by')->from('invoice_details d')->join('invoices inv','inv.id=d.invoice_id AND inv.company_id=d.company_id')->where(array('d.id'=>$editingLine,'d.company_id'=>$this->company(),'inv.workflow_status'=>'draft'))->get()->row();if(!$owned||(!is_master_admin_user()&&(int)$owned->created_by!==$this->uid()))$editingLine=0;} + $current="COALESCE((SELECT SUM(current_line.qty) FROM invoice_line_barcodes current_line WHERE current_line.invoice_detail_id=".$editingLine." AND current_line.company_id=i.company_id AND current_line.barcode_id=b.id),0)"; + $available="(b.qty_sisa-COALESCE(b.reserved_qty,0)-COALESCE(b.return_reserved_qty,0)+{$current})"; + $this->db->select("b.id,CONCAT(b.barcode,IF(NULLIF(b.serial_number,'') IS NULL,'',CONCAT(' / ',b.serial_number)),' - tersedia ',FORMAT({$available},0,'id_ID')) text,b.barcode,b.serial_number,b.item_id,b.warehouse_id,{$available} available,i.kode_detail,i.nama_barang,i.harga_jual,k.tracking_type,k.unit,w.nama warehouse_name",false)->from('item_barcodes b')->join('items i','i.id=b.item_id')->join('kode_barang k','k.id=i.kode_id','left')->join('warehouses w','w.id=b.warehouse_id')->where('i.company_id',$this->company())->where("(b.status='available' OR ({$editingLine}>0 AND {$current}>0))",null,false)->where("{$available} > 0",null,false)->where("(UPPER(COALESCE(k.tracking_type,'QTY'))<>'UNIT' OR NOT EXISTS(SELECT 1 FROM invoice_line_barcodes used JOIN invoice_details used_detail ON used_detail.id=used.invoice_detail_id AND used_detail.stock_posted_at IS NULL JOIN invoices used_invoice ON used_invoice.id=used.invoice_id WHERE used.barcode_id=b.id AND used_detail.id<>".$editingLine." AND used_invoice.company_id=i.company_id AND used_invoice.deleted_at IS NULL AND COALESCE(used_invoice.status,'')<>'cancelled'))",null,false);if($item)$this->db->where('b.item_id',$item);if($warehouse)$this->db->where('b.warehouse_id',$warehouse);if($q!==''){$this->db->group_start();if($exact)$this->db->where('b.barcode',$q)->or_where('b.serial_number',$q);else$this->db->like('b.barcode',$q)->or_like('b.serial_number',$q);$this->db->group_end();}$rows=$this->db->order_by('b.id')->limit($limit+1,($page-1)*$limit)->get()->result();$more=count($rows)>$limit;if($more)array_pop($rows);return json_response(true,'Barcode ditemukan.',array('results'=>$rows,'pagination'=>array('more'=>$more))); } private function selectLookup($table,$label,array$fields,$message) { @@ -104,7 +107,7 @@ class Invoices extends MY_Controller }, 'Semua item berhasil ditambahkan ke invoice.'); } public function remove_line($invoiceId,$lineId){return$this->mutation('can_update',function()use($invoiceId,$lineId){return$this->salesservice->removeInvoiceLine($invoiceId,$lineId,$this->uid());},'Item berhasil dihapus, reservasi dilepas, dan jurnal awal dibalik.');} - public function update_line($invoiceId,$lineId){return$this->mutation('can_update',function()use($invoiceId,$lineId){return$this->salesservice->updateNonInventoryLine($invoiceId,$lineId,$this->input->post(),$this->uid());},'Item nonbarang berhasil diperbarui.');} + public function update_line($invoiceId,$lineId){return$this->mutation('can_update',function()use($invoiceId,$lineId){$row=$this->input->post();$bars=json_decode((string)$this->input->post('barcodes'),true);$row['barcodes']=is_array($bars)?$bars:array();return$this->salesservice->updateInvoiceLine($invoiceId,$lineId,$row,$this->uid());},'Item invoice berhasil diperbarui, reservasi dan jurnal telah disinkronkan.');} public function generate_delivery($invoiceId){return$this->mutation('can_update',function()use($invoiceId){return$this->salesservice->generateDelivery($invoiceId,$this->uid());},'Surat Jalan berhasil dibuat sebagai Draft.');} public function submit($id){return$this->mutation('can_submit',function()use($id){return$this->salesservice->submitInvoice($id,$this->uid());},'Invoice berhasil diajukan.');} public function approve($id){return$this->mutation('can_approve',function()use($id){return$this->salesservice->approveAndPostInvoice($id,$this->uid(),$this->roleId());},'Invoice disetujui dan otomatis diposting.');} diff --git a/application/controllers/Jurnal.php b/application/controllers/Jurnal.php index 9b38165..4184fc8 100644 --- a/application/controllers/Jurnal.php +++ b/application/controllers/Jurnal.php @@ -38,6 +38,9 @@ class Jurnal extends MY_Controller { $start = $_POST['start']; $length = $_POST['length']; $search = $_POST['search']['value']; + $status = (string) $this->input->post('status', true); + $allowedStatuses = array('draft', 'submitted', 'approved', 'posted', 'rejected', 'reversed'); + if (!in_array($status, $allowedStatuses, true)) $status = ''; // ================= TOTAL ALL $totalAll = $this->db->where('company_id',$this->companycontext->id())->count_all_results('journals'); @@ -45,6 +48,10 @@ class Jurnal extends MY_Controller { // ================= QUERY FILTERED (HITUNG) $this->db->from('journals')->where('company_id',$this->companycontext->id()); + if ($status !== '') { + $this->db->where('status', $status); + } + if(!empty($search)){ $this->db->group_start(); $this->db->like('no_ref', $search); @@ -63,6 +70,9 @@ class Jurnal extends MY_Controller { journals.keterangan, journals.status, journals.is_reversed, + journals.ref_type, + journals.created_by, + journals.rejection_reason, COALESCE(SUM(journal_details.debit),0) as total_debit, COALESCE(SUM(journal_details.kredit),0) as total_kredit '); @@ -70,6 +80,10 @@ class Jurnal extends MY_Controller { $this->db->join('journal_details','journal_details.journal_id = journals.id','LEFT'); $this->db->where('journals.company_id',$this->companycontext->id()); + if ($status !== '') { + $this->db->where('journals.status', $status); + } + if(!empty($search)){ $this->db->group_start(); $this->db->like('journals.no_ref', $search); @@ -92,12 +106,17 @@ class Jurnal extends MY_Controller { foreach ($query as $row) { $no++; - $isAdmin = $this->session->userdata('role') === 'Admin'; + $isAdmin = is_master_admin_user() || $this->session->userdata('role') === 'Admin'; + $isOwner = (int)$row->created_by === (int)$this->session->userdata('user_id'); $aksi = ' '; if ($row->status === 'draft') { $aksi .= ' '; if ($isAdmin) $aksi .= ' '; } + if ($row->status === 'rejected' && ($isOwner || $isAdmin) && in_array((string)$row->ref_type, array('', 'manual_journal'), true)) { + $aksi .= ' '; + if ($isAdmin) $aksi .= ' '; + } if ($isAdmin && $row->status === 'submitted') { $aksi .= ' '; $aksi .= ' '; @@ -181,6 +200,18 @@ class Jurnal extends MY_Controller { } } + public function update_rejected($id) + { + if (strtoupper($this->input->method()) !== 'POST') return json_response(false, 'Metode request tidak diizinkan.', array(), 405); + try { + $accounts=(array)$this->input->post('account_id');$debit=(array)$this->input->post('debit');$kredit=(array)$this->input->post('kredit');$lines=array(); + foreach($accounts as$index=>$accountId){$d=isset($debit[$index])?$this->parseNominal($debit[$index]):0;$k=isset($kredit[$index])?$this->parseNominal($kredit[$index]):0;if($d<=0&&$k<=0)continue;$lines[]=array('account_id'=>(int)$accountId,'debit'=>$d,'kredit'=>$k);} + $journal=$this->journalservice->reviseRejected((int)$id,array('tanggal'=>(string)$this->input->post('tanggal',true),'keterangan'=>trim((string)$this->input->post('keterangan',true))),$lines,(int)$this->session->userdata('user_id')); + log_activity('Jurnal','revise_rejected','Mengoreksi jurnal ditolak '.$journal->no_ref.' menjadi Draft.','success'); + return json_response(true,'Koreksi jurnal berhasil disimpan sebagai Draft dan dapat diajukan kembali.',array('journal_id'=>$journal->id)); + } catch (Throwable $exception) { return business_exception_response($exception); } + } + /** Menerima nominal raw maupun berformat 20,000 / 20.000 / 20.000,50. */ private function parseNominal($value) { diff --git a/application/controllers/Payrollcheck.php b/application/controllers/Payrollcheck.php index aad3834..9fd02cd 100644 --- a/application/controllers/Payrollcheck.php +++ b/application/controllers/Payrollcheck.php @@ -4,6 +4,29 @@ defined('BASEPATH') OR exit('No direct script access allowed'); /** Pemeriksaan CLI terarah. Seluruh fixture selalu di-rollback. */ class Payrollcheck extends CI_Controller { + public function audit() + { + if(!$this->input->is_cli_request())show_404(); + $this->load->database(); + $checks=array( + 'Baris payroll lintas company / orphan'=>"SELECT COUNT(*) total FROM k_payrolls x LEFT JOIN k_payroll_periods p ON p.id=x.payroll_period_id LEFT JOIN k_employees e ON e.id=x.employee_id WHERE p.id IS NULL OR e.id IS NULL OR p.company_id<>x.company_id OR e.company_id<>x.company_id", + 'Rincian kalkulasi tanpa header payroll'=>"SELECT COUNT(*) total FROM payroll_calculation_lines l LEFT JOIN k_payrolls x ON x.id=l.payroll_id WHERE x.id IS NULL", + 'Penyesuaian dengan relasi/jenis tidak konsisten'=>"SELECT COUNT(*) total FROM payroll_period_adjustments a LEFT JOIN k_payroll_periods p ON p.id=a.payroll_period_id LEFT JOIN k_employees e ON e.id=a.employee_id LEFT JOIN payroll_components c ON c.id=a.component_id WHERE p.id IS NULL OR e.id IS NULL OR c.id IS NULL OR p.company_id<>a.company_id OR e.company_id<>a.company_id OR c.company_id<>a.company_id OR a.adjustment_type<>c.component_type", + 'Periode lanjut proses tanpa hasil hitung'=>"SELECT COUNT(*) total FROM k_payroll_periods WHERE workflow_status IN('reviewed','approved','final','posted','paid') AND (calculated_at IS NULL OR calculation_hash IS NULL)", + 'Periode posted/paid tanpa jurnal'=>"SELECT COUNT(*) total FROM k_payroll_periods p LEFT JOIN journals j ON j.id=p.journal_id WHERE p.workflow_status IN('posted','paid') AND j.id IS NULL", + 'Periode paid tanpa pembayaran'=>"SELECT COUNT(*) total FROM k_payroll_periods p LEFT JOIN payroll_payments y ON y.id=p.payment_transaction_id AND y.company_id=p.company_id WHERE p.workflow_status='paid' AND y.id IS NULL", + 'Rentang assignment karyawan tidak valid'=>"SELECT COUNT(*) total FROM employee_payroll_components WHERE effective_to IS NOT NULL AND effective_to"SELECT COUNT(*) total FROM position_payroll_components WHERE effective_to IS NOT NULL AND effective_to"SELECT COUNT(*) total FROM employee_payroll_components a JOIN employee_payroll_components b ON b.id>a.id AND b.company_id=a.company_id AND b.employee_id=a.employee_id AND b.component_id=a.component_id AND b.is_active=1 WHERE a.is_active=1 AND a.effective_from<=COALESCE(b.effective_to,'9999-12-31') AND b.effective_from<=COALESCE(a.effective_to,'9999-12-31')", + 'Template posisi aktif saling tumpang tindih'=>"SELECT COUNT(*) total FROM position_payroll_components a JOIN position_payroll_components b ON b.id>a.id AND b.company_id=a.company_id AND b.position_id=a.position_id AND b.component_id=a.component_id AND b.is_active=1 WHERE a.is_active=1 AND a.effective_from<=COALESCE(b.effective_to,'9999-12-31') AND b.effective_from<=COALESCE(a.effective_to,'9999-12-31')", + 'Assignment karyawan lintas company'=>"SELECT COUNT(*) total FROM employee_payroll_components x JOIN k_employees e ON e.id=x.employee_id JOIN payroll_components c ON c.id=x.component_id WHERE e.company_id<>x.company_id OR c.company_id<>x.company_id", + 'Template posisi lintas company'=>"SELECT COUNT(*) total FROM position_payroll_components x JOIN payroll_components c ON c.id=x.component_id WHERE c.company_id<>x.company_id", + 'Jurnal payroll tidak balance'=>"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('payroll','payroll_payment') GROUP BY j.id HAVING ABS(SUM(d.debit-d.kredit))>0.01) q" + ); + $issues=0;foreach($checks as$name=>$sql){$count=(int)$this->db->query($sql)->row()->total;echo($count?'ISSUE':'PASS').': '.$name.' = '.$count.PHP_EOL;$issues+=$count;} + if($issues){fwrite(STDERR,'FAIL: ditemukan '.$issues.' inkonsistensi payroll. Periksa baris ISSUE sebelum proses berikutnya.'.PHP_EOL);exit(1);}echo"PASS: audit integritas payroll tidak menemukan inkonsistensi.\n"; + } + public function exports() { if(!$this->input->is_cli_request())show_404(); @@ -35,6 +58,7 @@ class Payrollcheck extends CI_Controller $this->db->insert('k_attendances',array('employee_id'=>$employee->id,'attendance_date'=>'2098-09-01','attendance_status'=>'alpha','late_minutes'=>0,'early_leave_minutes'=>0)); $this->db->insert('k_attendances',array('employee_id'=>$employee->id,'attendance_date'=>'2098-09-02','attendance_status'=>'late','late_minutes'=>30,'early_leave_minutes'=>0)); $this->db->insert('payroll_attendance_locks',array('company_id'=>$company->id,'payroll_period_id'=>$periodId,'start_date'=>$start,'end_date'=>$end,'status'=>'locked','locked_by'=>$user->id,'locked_at'=>$now)); + $this->db->where('id',$employee->id)->update('k_employees',array('salary_restricted'=>1));$restrictedBlocked=false;try{$this->payrollservice->calculate($periodId,$user->id,false,$company->id);}catch(Throwable$expected){$restrictedBlocked=true;}if(!$restrictedBlocked)throw new RuntimeException('Perhitungan gaji terbatas tidak diblokir untuk user tanpa Manage All Gaji.');$this->db->where('id',$employee->id)->update('k_employees',array('salary_restricted'=>0,'is_active'=>0,'resign_date'=>'2098-09-01')); $this->payrollservice->calculate($periodId,$user->id,true,$company->id);$first=$this->db->select('COUNT(*) count')->from('payroll_calculation_lines l')->join('k_payrolls p','p.id=l.payroll_id')->where('p.payroll_period_id',$periodId)->get()->row(); $this->payrollservice->calculate($periodId,$user->id,true,$company->id);$second=$this->db->select('COUNT(*) count')->from('payroll_calculation_lines l')->join('k_payrolls p','p.id=l.payroll_id')->where('p.payroll_period_id',$periodId)->get()->row(); $codes=$this->db->select('c.name,l.amount')->from('payroll_calculation_lines l')->join('k_payrolls p','p.id=l.payroll_id')->join('payroll_components c','c.id=l.component_id')->where('p.payroll_period_id',$periodId)->where_in('c.id',$componentIds)->get()->result();$found=array();foreach($codes as$row)$found[$row->name]=(float)$row->amount; @@ -43,7 +67,7 @@ class Payrollcheck extends CI_Controller $snapshot=$this->db->select('calculation_snapshot')->get_where('k_payrolls',array('payroll_period_id'=>$periodId,'employee_id'=>$employee->id))->row();if(!$snapshot||strpos($snapshot->calculation_snapshot,$start)===false||strpos($snapshot->calculation_snapshot,$end)===false)throw new RuntimeException('Snapshot rentang fleksibel tidak tersimpan.'); $late=$this->db->where(array('company_id'=>$company->id,'condition_type'=>'late','calculation_method'=>'none'))->count_all_results('payroll_attendance_rules');if(!$late)throw new RuntimeException('Default terlambat tanpa potongan tidak tersedia.'); $beforeJournal=(int)$this->db->count_all('journals');$this->db->where('id',$periodId)->update('k_payroll_periods',array('workflow_status'=>'final'));if((int)$this->db->count_all('journals')!==$beforeJournal)throw new RuntimeException('Finalisasi membuat jurnal sebelum Posting.');$journalId=$this->payrollservice->post($periodId,'2026-09-13',$user->id,$company->id,true);$balance=$this->db->select('SUM(debit-kredit) difference',false)->get_where('journal_details',array('journal_id'=>$journalId))->row();if(!$balance||abs((float)$balance->difference)>.01)throw new RuntimeException('Jurnal payroll tidak balance.');$doubleBlocked=false;try{$this->payrollservice->post($periodId,'2026-09-13',$user->id,$company->id,true);}catch(Throwable$expected){$doubleBlocked=true;}if(!$doubleBlocked)throw new RuntimeException('Double posting tidak diblokir.'); - echo"PASS: periode 14 Agustus–13 September, assignment, alpha, bonus, snapshot, recalculate, jurnal balance, dan idempotency posting.\n"; + $cashQuery=$this->db->select('c.*')->from('cash_accounts c')->join('accounts a','a.id=c.gl_account_id')->where(array('c.is_active'=>1,'a.is_active'=>1,'a.allow_posting'=>1,'a.company_id'=>$company->id));if($this->db->field_exists('company_id','cash_accounts'))$cashQuery->where('c.company_id',$company->id);$cash=$cashQuery->order_by('c.id')->get()->row();if($cash){$paymentJournal=$this->payrollservice->pay($periodId,'2026-09-13',$cash->id,$user->id,$company->id,true);$paid=$this->db->get_where('k_payroll_periods',array('id'=>$periodId,'workflow_status'=>'paid'))->row();$payment=$this->db->get_where('payroll_payments',array('company_id'=>$company->id,'payroll_period_id'=>$periodId))->row();$paymentBalance=$this->db->select('SUM(debit-kredit) difference',false)->get_where('journal_details',array('journal_id'=>$paymentJournal))->row();if(!$paid||!$payment||!$paymentBalance||abs((float)$paymentBalance->difference)>.01)throw new RuntimeException('Pembayaran payroll atau jurnal pembayarannya tidak konsisten.');$doublePayBlocked=false;try{$this->payrollservice->pay($periodId,'2026-09-13',$cash->id,$user->id,$company->id,true);}catch(Throwable$expected){$doublePayBlocked=true;}if(!$doublePayBlocked)throw new RuntimeException('Double payment tidak diblokir.');echo"PASS: periode fleksibel, kalkulasi, posting, pembayaran, jurnal balance, dan idempotency tervalidasi.\n";}else echo"PASS: kalkulasi dan posting tervalidasi. SKIP pembayaran: Kas/Bank dengan akun GL aktif belum tersedia.\n"; }catch(Throwable$e){$this->db->trans_rollback();fwrite(STDERR,'FAIL: '.$e->getMessage().PHP_EOL);exit(1);} $this->db->trans_rollback();echo"ROLLBACK: seluruh fixture payroll dibatalkan.\n"; } diff --git a/application/controllers/Professionalreports.php b/application/controllers/Professionalreports.php index 81a5c48..8f91329 100644 --- a/application/controllers/Professionalreports.php +++ b/application/controllers/Professionalreports.php @@ -5,7 +5,7 @@ class Professionalreports extends MY_Controller public function __construct(){parent::__construct();$this->requirePermission('reports');$this->load->database();$this->load->library(array('ProfessionalReportService','CompanyContext','JobQueueService'));} private function filter(){$to=$this->input->get_post('to')?:date('Y-m-d');return array('from'=>$this->input->get_post('from')?:date('Y-m-01',strtotime($to)),'to'=>$to,'account_id'=>(int)$this->input->get_post('account_id'));} private function reportFilter($code,array$f){if($code==='balance_sheet')$f['from']='1900-01-01';return$f;} - public function index(){$code=$this->input->get('report')?:'trial_balance';$f=$this->filter();$reportFilters=$this->reportFilter($code,$f);$d=array('active_menu'=>'professional_reports','report'=>$code,'filters'=>$f,'rows'=>$this->professionalreportservice->run($code,$reportFilters),'profit_loss_rows'=>$code==='balance_sheet'?$this->professionalreportservice->run('profit_loss',$reportFilters):array());$d['accounts']=$this->db->where('company_id',$this->companycontext->id())->order_by('kode_akun')->get('accounts')->result();$d['company']=$this->db->get_where('companies',array('id'=>$this->companycontext->id()))->row();$this->load->view('partials/header',$d);$this->load->view('professional_reports/index',$d);$this->load->view('partials/footer');} + public function index(){$code=$this->input->get('report')?:'trial_balance';$f=$this->filter();$reportFilters=$this->reportFilter($code,$f);$rows=$this->professionalreportservice->run($code,$reportFilters);$summary=array();if($code==='cash_flow')$summary=$this->professionalreportservice->cashFlowSummary($reportFilters,$rows);elseif($code==='general_ledger')$summary=$this->professionalreportservice->ledgerSummary($reportFilters);elseif($code==='receivable_reconciliation')$summary=$this->professionalreportservice->receivableSummary($reportFilters,$rows);$d=array('active_menu'=>'professional_reports','report'=>$code,'filters'=>$f,'rows'=>$rows,'report_summary'=>$summary,'profit_loss_rows'=>$code==='balance_sheet'?$this->professionalreportservice->run('profit_loss',$reportFilters):array());$d['accounts']=$this->db->where('company_id',$this->companycontext->id())->order_by('kode_akun')->get('accounts')->result();$d['company']=$this->db->get_where('companies',array('id'=>$this->companycontext->id()))->row();$this->load->view('partials/header',$d);$this->load->view('professional_reports/index',$d);$this->load->view('partials/footer');} public function export($code){$f=$this->reportFilter($code,$this->filter());$rows=$this->professionalreportservice->run($code,$f);$this->output->set_content_type('text/csv')->set_header('Content-Disposition: attachment; filename="'.$code.'-'.$f['to'].'.csv"');$h=fopen('php://output','w');if($rows){fputcsv($h,array_keys($rows[0]));foreach($rows as$r)fputcsv($h,$r);}fclose($h);} public function save_filter(){$this->db->insert('saved_report_filters',array('user_id'=>$this->session->userdata('user_id'),'report_code'=>$this->input->post('report_code'),'name'=>$this->input->post('name',true),'filter_json'=>json_encode($this->filter()),'created_at'=>date('Y-m-d H:i:s'),'company_id'=>$this->companycontext->id()));redirect('professionalreports?report='.$this->input->post('report_code'));} public function snapshot($code){$f=$this->reportFilter($code,$this->filter());$rows=$this->professionalreportservice->run($code,$f);$hash=hash('sha256',json_encode($f));if(!$this->db->get_where('report_snapshots',array('report_code'=>$code,'period_end'=>$f['to'],'filter_hash'=>$hash,'company_id'=>$this->companycontext->id()))->row())$this->db->insert('report_snapshots',array('report_code'=>$code,'period_end'=>$f['to'],'filter_hash'=>$hash,'payload'=>json_encode($rows),'created_by'=>$this->session->userdata('user_id'),'created_at'=>date('Y-m-d H:i:s'),'company_id'=>$this->companycontext->id()));redirect('professionalreports?report='.$code.'&to='.$f['to']);} diff --git a/application/controllers/Roles.php b/application/controllers/Roles.php index 5d7f59d..dff5cf8 100644 --- a/application/controllers/Roles.php +++ b/application/controllers/Roles.php @@ -29,7 +29,7 @@ class Roles extends MY_Admin_Controller 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){$allowedActions=array_keys($this->actions);if($key==='payroll')$allowedActions[]='can_manage_all_salary';$selected=array_values(array_intersect((array)(isset($posted[$key])?$posted[$key]:array()),$allowedActions));if($selected){if(!in_array('can_view',$selected,true))array_unshift($selected,'can_view');$permissions[$key]=$selected;}} + foreach($this->features as$key=>$label){$allowedActions=array_keys($this->actions);if($key==='payroll')$allowedActions=array_merge($allowedActions,array('can_manage_all_salary','can_manage_allowances'));$selected=array_values(array_intersect((array)(isset($posted[$key])?$posted[$key]:array()),$allowedActions));if($key==='payroll'&&in_array('can_manage_allowances',$selected,true)&&!in_array('can_update',$selected,true))$selected[]='can_update';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();} diff --git a/application/controllers/Teknisi.php b/application/controllers/Teknisi.php index 0ee0f53..033be6f 100644 --- a/application/controllers/Teknisi.php +++ b/application/controllers/Teknisi.php @@ -480,7 +480,9 @@ class Teknisi extends MY_Controller { "customers" => $this->db->select('id,nama')->where('company_id',(int)$this->companycontext->id())->order_by('nama')->get('customers')->result(), "can_export" => is_master_admin_user() || check_permission('technician_equipment','can_export') || check_permission('items','can_export'), "equipment_permissions" => $this->equipmentPermissionFlags(), - "equipment_dashboard" => $this->scopedEquipmentDashboard() + "equipment_dashboard" => $this->scopedEquipmentDashboard(), + "initial_filters" => $this->logFilters(), + "active_panel" => "history" ]; $this->load->view('partials/header', $data); diff --git a/application/core/MY_Controller.php b/application/core/MY_Controller.php index e9bd1d9..1337431 100644 --- a/application/core/MY_Controller.php +++ b/application/core/MY_Controller.php @@ -56,7 +56,7 @@ class MY_Controller extends CI_Controller 'accountingperiods'=>'jurnal','professionalreports'=>'reports','budgets'=>'budgets', 'taxes'=>'taxes','roles'=>'setting','users'=>'setting','employees'=>array('hr_employees','employees'),'generateschedule'=>array('hr_schedules','employees'), 'attendancemonitoring'=>array('hr_attendance','employees'),'attendancemonthly'=>array('hr_attendance','employees'),'attendancmonthly'=>array('hr_attendance','employees'), - 'shifts'=>array('hr_schedules','employees'),'holidays'=>array('hr_schedules','employees'),'leaverequests'=>array('hr_leave','employees'),'hrpayroll'=>array('payroll','employees'),'payroll'=>array('payroll','employees'),'organizationmaster'=>array('hr_organization','employees') + 'shifts'=>array('hr_schedules','employees'),'holidays'=>array('hr_schedules','employees'),'leaverequests'=>array('hr_leave','employees'),'hrpayroll'=>'payroll','payroll'=>'payroll','organizationmaster'=>array('hr_organization','employees') ); if (!isset($features[$class])) return; $action = 'can_view'; diff --git a/application/libraries/JournalService.php b/application/libraries/JournalService.php index 1f01bcf..8a732ee 100644 --- a/application/libraries/JournalService.php +++ b/application/libraries/JournalService.php @@ -15,6 +15,7 @@ class JournalService $this->CI->load->library('TransactionService'); $this->CI->load->library('FiscalPeriodService'); $this->CI->load->library('JournalDescriptionService'); + $this->CI->load->library('AuditService'); if ($this->CI->db->table_exists('companies')) $this->CI->load->library('CompanyContext'); } @@ -57,7 +58,50 @@ class JournalService return array('debit' => round($debit, 2), 'kredit' => round($credit, 2)); } - private function validateAndNormalize(array $header, array $lines) + public function reviseRejected($journalId, array $header, array $lines, $userId) + { + return $this->CI->transactionservice->run(function () use ($journalId, $header, $lines, $userId) { + $journal = $this->CI->db->query( + 'SELECT * FROM journals WHERE id=? AND company_id=? FOR UPDATE', + array((int)$journalId, (int)$this->CI->companycontext->id()) + )->row(); + if (!$journal) throw new BusinessException('Jurnal tidak ditemukan.'); + if ($journal->status !== 'rejected') throw new BusinessException('Hanya jurnal yang ditolak yang dapat dikoreksi.'); + if (!in_array((string)$journal->ref_type, array('', 'manual_journal'), true)) throw new BusinessException('Jurnal otomatis tidak dapat diedit melalui Jurnal Umum.'); + if ((int)$journal->created_by !== (int)$userId && !(function_exists('is_master_admin_user') && is_master_admin_user()) && $this->CI->session->userdata('role') !== 'Admin') throw new BusinessException('Hanya pembuat jurnal atau Admin yang dapat melakukan koreksi.'); + + $header['no_ref'] = $journal->no_ref; + $header['ref_type'] = $journal->ref_type ?: 'manual_journal'; + $header['ref_id'] = $journal->ref_id; + $header['created_by'] = $journal->created_by; + $normalized = $this->validateAndNormalize($header, $lines, (int)$journal->id); + $before = array('journal'=>$journal, 'lines'=>$this->CI->journalModel->lines($journal->id)); + $update = array( + 'tanggal'=>$normalized['header']['tanggal'], + 'keterangan'=>$normalized['header']['keterangan'], + 'accounting_period_id'=>$normalized['header']['accounting_period_id'], + 'status'=>'draft', + 'submitted_at'=>null, + 'submitted_by'=>null, + 'approved_at'=>null, + 'approved_by'=>null, + 'updated_by'=>(int)$userId, + 'updated_at'=>date('Y-m-d H:i:s'), + ); + $this->CI->db->where('id', $journal->id)->update('journals', $update); + $this->CI->db->where('journal_id', $journal->id)->delete('journal_details'); + $details=array();foreach($normalized['lines']as$line){$line['journal_id']=$journal->id;$details[]=$line;} + $this->CI->journalModel->addLines($details); + $notes='Jurnal dikoreksi dan dikembalikan menjadi Draft.'; + if(trim((string)$journal->rejection_reason)!=='')$notes.=' Alasan penolakan sebelumnya: '.$journal->rejection_reason; + $this->CI->journalModel->addStatusHistory($journal->id,'rejected','draft',$userId,$notes); + $after=array('journal'=>$this->CI->journalModel->find($journal->id),'lines'=>$this->CI->journalModel->lines($journal->id)); + $this->CI->auditservice->record('journal','journal',$journal->id,'revise_rejected',$before,$after,$userId); + return $after['journal']; + }); + } + + private function validateAndNormalize(array $header, array $lines, $excludeJournalId = 0) { foreach (array('tanggal', 'no_ref', 'keterangan', 'created_by') as $required) { if (!isset($header[$required]) || trim((string) $header[$required]) === '') { @@ -69,7 +113,7 @@ class JournalService if (!$date || $date->format('Y-m-d') !== $header['tanggal']) { throw new BusinessException('Tanggal jurnal tidak valid.'); } - if ($this->CI->journalModel->referenceExists($header['no_ref'])) { + if ($this->CI->journalModel->referenceExists($header['no_ref'], $excludeJournalId)) { throw new BusinessException('Nomor referensi jurnal sudah digunakan.'); } diff --git a/application/libraries/PDF_BukuBesar.php b/application/libraries/PDF_BukuBesar.php index 1a74f8a..27ac06a 100644 --- a/application/libraries/PDF_BukuBesar.php +++ b/application/libraries/PDF_BukuBesar.php @@ -244,7 +244,7 @@ class PDF extends FPDF // BARIS TOTAL // ---------------------------------------------------------------- - public function BukuBesarTotal($totalDebit, $totalKredit, $saldo) + public function BukuBesarTotal($saldoAwal, $totalDebit, $totalKredit, $saldoAkhir) { $startX = ($this->GetPageWidth() - array_sum($this->colWidths)) / 2; $labelWidth = $this->colWidths[0] + $this->colWidths[1] + $this->colWidths[2]; @@ -253,10 +253,18 @@ class PDF extends FPDF $this->SetFillColor(255, 204, 102); $this->SetFont('Arial', 'B', 9); - $this->Cell($labelWidth, 8, 'TOTAL', 1, 0, 'C', true); + $this->Cell($labelWidth, 8, 'TOTAL MUTASI', 1, 0, 'C', true); $this->Cell($this->colWidths[3], 8, number_format($totalDebit, 2, ',', '.'), 1, 0, 'R', true); $this->Cell($this->colWidths[4], 8, number_format($totalKredit, 2, ',', '.'), 1, 0, 'R', true); - $this->Cell($this->colWidths[5], 8, number_format($saldo, 2, ',', '.'), 1, 1, 'R', true); + $this->Cell($this->colWidths[5], 8, number_format($saldoAkhir, 2, ',', '.'), 1, 1, 'R', true); + $this->SetX($startX); + $this->SetFillColor(245, 245, 245); + $this->Cell($labelWidth, 7, 'SALDO AWAL', 1, 0, 'R', true); + $this->Cell($this->colWidths[3]+$this->colWidths[4]+$this->colWidths[5], 7, number_format($saldoAwal,2,',','.'), 1, 1, 'R', true); + $this->SetX($startX); + $this->SetFillColor(255, 204, 102); + $this->Cell($labelWidth, 8, 'SALDO AKHIR', 1, 0, 'R', true); + $this->Cell($this->colWidths[3]+$this->colWidths[4]+$this->colWidths[5], 8, number_format($saldoAkhir,2,',','.'), 1, 1, 'R', true); } // ---------------------------------------------------------------- diff --git a/application/libraries/PayrollService.php b/application/libraries/PayrollService.php index 3ab9296..51e5ccc 100644 --- a/application/libraries/PayrollService.php +++ b/application/libraries/PayrollService.php @@ -27,8 +27,12 @@ class PayrollService $start=$period->attendance_start_date?:$period->start_date;$end=$period->attendance_end_date?:$period->end_date; if(!$db->get_where('payroll_attendance_locks',array('company_id'=>$companyId,'payroll_period_id'=>$period->id,'status'=>'locked'))->row())throw new BusinessException('Kehadiran periode ini harus dikunci sebelum payroll dihitung.'); $employmentEmployees=$db - ->where(array('company_id'=>$companyId,'is_active'=>1)) + ->where('company_id',$companyId) ->where('join_date <=',$end) + ->group_start() + ->where('is_active',1) + ->or_where('resign_date >=',$start) + ->group_end() ->group_start() ->where('resign_date IS NULL',null,false) ->or_where('resign_date','0000-00-00') @@ -37,9 +41,9 @@ class PayrollService ->order_by('full_name') ->get('k_employees') ->result(); + if(!$canManageAll){foreach($employmentEmployees as$employee)if(!empty($employee->salary_restricted))throw new BusinessException('Periode memuat gaji terbatas. Perhitungan penuh harus dijalankan oleh pengguna dengan permission Manage All Gaji.');} $this->removeIneligibleDraftPayrolls($period->id,$companyId,$employmentEmployees); $employees=$employmentEmployees; - if(!$canManageAll)$employees=array_values(array_filter($employees,function($employee){return empty($employee->salary_restricted);})); if(!$employees)throw new BusinessException('Tidak ada karyawan yang dapat dihitung dengan permission Anda.'); foreach($employees as$employee)$this->calculateEmployee($period,$employee,$start,$end,$companyId,$uid); $hash=hash('sha256',json_encode($db->select('id,total_salary,version')->where(array('payroll_period_id'=>$period->id,'company_id'=>$companyId))->order_by('id')->get('k_payrolls')->result_array())); @@ -122,17 +126,17 @@ class PayrollService { $db=$this->CI->db;$db->trans_begin();try{$period=$this->period($id,$companyId);$before=$period; if($action==='revert'){if(!in_array($period->workflow_status,array('reviewed','approved'),true))throw new BusinessException('Hanya payroll Reviewed atau Approved yang dapat dikembalikan ke Draft.');$update=array('workflow_status'=>'draft','reviewed_by'=>null,'reviewed_at'=>null,'approved_by'=>null,'approved_at'=>null,'updated_at'=>date('Y-m-d H:i:s'));} - else{$map=array('review'=>array('draft','reviewed'),'approve'=>array('reviewed','approved'),'finalize'=>array('approved','final'));if(!isset($map[$action])||$period->workflow_status!==$map[$action][0])throw new BusinessException('Tahap payroll tidak sesuai. Muat ulang halaman dan periksa status terbaru.');if(in_array($action,array('approve','finalize'),true)&&$this->hasRestrictedPayroll($id)&&!$canManageAll)throw new BusinessException('Periode memuat gaji terbatas dan memerlukan permission Manage All Gaji.');$settings=$db->get_where('payroll_settings',array('company_id'=>$companyId))->row();$separation=!$settings||(int)$settings->separation_of_duties===1;if($separation&&$action==='approve'&&(int)$period->reviewed_by===(int)$uid)throw new BusinessException('Reviewer dan approver harus pengguna yang berbeda.');if($separation&&$action==='finalize'&&((int)$period->reviewed_by===(int)$uid||(int)$period->approved_by===(int)$uid))throw new BusinessException('Finalizer harus berbeda dari reviewer dan approver.');$who=$action==='review'?'reviewed_by':($action==='approve'?'approved_by':'finalized_by');$when=$action==='review'?'reviewed_at':($action==='approve'?'approved_at':'finalized_at');$update=array('workflow_status'=>$map[$action][1],$who=>$uid,$when=>date('Y-m-d H:i:s'),'updated_at'=>date('Y-m-d H:i:s'));} + else{$map=array('review'=>array('draft','reviewed'),'approve'=>array('reviewed','approved'),'finalize'=>array('approved','final'));if(!isset($map[$action])||$period->workflow_status!==$map[$action][0])throw new BusinessException('Tahap payroll tidak sesuai. Muat ulang halaman dan periksa status terbaru.');if($action==='review')$this->assertCalculationReady($period,$companyId);if(in_array($action,array('review','approve','finalize'),true)&&$this->hasRestrictedPayroll($id,$companyId)&&!$canManageAll)throw new BusinessException('Periode memuat gaji terbatas dan memerlukan permission Manage All Gaji.');$settings=$db->get_where('payroll_settings',array('company_id'=>$companyId))->row();$separation=!$settings||(int)$settings->separation_of_duties===1;if($separation&&$action==='approve'&&(int)$period->reviewed_by===(int)$uid)throw new BusinessException('Reviewer dan approver harus pengguna yang berbeda.');if($separation&&$action==='finalize'&&((int)$period->reviewed_by===(int)$uid||(int)$period->approved_by===(int)$uid))throw new BusinessException('Finalizer harus berbeda dari reviewer dan approver.');$who=$action==='review'?'reviewed_by':($action==='approve'?'approved_by':'finalized_by');$when=$action==='review'?'reviewed_at':($action==='approve'?'approved_at':'finalized_at');$update=array('workflow_status'=>$map[$action][1],$who=>$uid,$when=>date('Y-m-d H:i:s'),'updated_at'=>date('Y-m-d H:i:s'));} $db->where(array('id'=>$id,'company_id'=>$companyId))->update('k_payroll_periods',$update);$this->CI->auditservice->record('payroll','payroll_period',$id,$action,$before,$update,$uid);if($db->trans_status()===false)throw new RuntimeException('Workflow payroll gagal disimpan.');$db->trans_commit(); }catch(Throwable$e){$db->trans_rollback();throw$e;} } public function post($id,$date,$uid,$companyId,$canManageAll) { - $db=$this->CI->db;$db->trans_begin();try{$period=$this->period($id,$companyId);if($period->workflow_status!=='final'||$period->journal_id)throw new BusinessException('Payroll belum Final atau sudah diposting.');if($this->hasRestrictedPayroll($id)&&!$canManageAll)throw new BusinessException('Posting payroll dengan gaji terbatas memerlukan permission Manage All Gaji.');$this->CI->fiscalperiodservice->assertOpen($date); + $db=$this->CI->db;$db->trans_begin();try{$period=$this->period($id,$companyId);if($period->workflow_status!=='final'||$period->journal_id)throw new BusinessException('Payroll belum Final atau sudah diposting.');if($this->hasRestrictedPayroll($id,$companyId)&&!$canManageAll)throw new BusinessException('Posting payroll dengan gaji terbatas memerlukan permission Manage All Gaji.');$this->CI->fiscalperiodservice->assertOpen($date); $rows=$db->select('l.component_code,l.component_name,l.component_type,l.expense_account_id,l.liability_account_id,SUM(l.amount) amount',false)->from('payroll_calculation_lines l')->join('k_payrolls x','x.id=l.payroll_id')->where(array('x.payroll_period_id'=>$id,'x.company_id'=>$companyId))->group_by(array('l.component_code','l.component_name','l.component_type','l.expense_account_id','l.liability_account_id'))->get()->result();$net=(float)$db->select('COALESCE(SUM(total_salary),0) total',false)->get_where('k_payrolls',array('payroll_period_id'=>$id,'company_id'=>$companyId))->row()->total;$journal=array();$missing=array(); foreach($rows as$row){try{$expense=$row->expense_account_id?(int)$row->expense_account_id:(int)$this->CI->accountmappingservice->get('payroll_expense');}catch(Throwable$e){$expense=0;}try{$liability=$row->liability_account_id?(int)$row->liability_account_id:(int)$this->CI->accountmappingservice->get($row->component_type==='deduction'?'payroll_tax_payable':'payroll_payable');}catch(Throwable$e){$liability=0;}if(in_array($row->component_type,array('earning','employer_cost'),true)&&!$expense)$missing[]=$row->component_name.' (akun beban)';if(in_array($row->component_type,array('deduction','employer_cost'),true)&&!$liability)$missing[]=$row->component_name.' (akun kewajiban)';if($row->component_type==='earning')$this->addJournal($journal,$expense,(float)$row->amount,0);elseif($row->component_type==='deduction')$this->addJournal($journal,$liability,0,(float)$row->amount);else{$this->addJournal($journal,$expense,(float)$row->amount,0);$this->addJournal($journal,$liability,0,(float)$row->amount);}} - try{$payable=(int)$this->CI->accountmappingservice->get('payroll_payable');}catch(Throwable$e){$payable=0;}if(!$payable)$missing[]='Hutang Gaji (payroll_payable)';else$this->addJournal($journal,$payable,0,$net);if($missing)throw new BusinessException('Mapping akun payroll belum lengkap: '.implode(', ',array_unique($missing)).'.');$debit=0;$credit=0;foreach($journal as$line){$debit+=$line['debit'];$credit+=$line['kredit'];}if(abs($debit-$credit)>.01)throw new BusinessException('Jurnal payroll tidak balance. Periksa mapping komponen.');$count=$db->where(array('payroll_period_id'=>$id,'company_id'=>$companyId))->count_all_results('k_payrolls');$journalId=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>'PAYROLL-'.$period->id,'keterangan'=>'Payroll '.$period->period_name.' untuk '.$count.' karyawan','ref_type'=>'payroll','ref_id'=>$id,'created_by'=>$uid),array_values($journal),false,true);$db->where(array('id'=>$id,'company_id'=>$companyId))->update('k_payroll_periods',array('workflow_status'=>'posted','journal_id'=>$journalId,'updated_at'=>date('Y-m-d H:i:s')));$this->CI->auditservice->record('payroll','payroll_period',$id,'post',null,array('journal_id'=>$journalId),$uid);$db->trans_commit();return$journalId; + if(!$rows||$net<=0)throw new BusinessException('Hasil payroll kosong atau total neto tidak valid. Hitung ulang payroll sebelum posting.');try{$payable=(int)$this->CI->accountmappingservice->get('payroll_payable');}catch(Throwable$e){$payable=0;}if(!$payable)$missing[]='Hutang Gaji (payroll_payable)';else$this->addJournal($journal,$payable,0,$net);if($missing)throw new BusinessException('Mapping akun payroll belum lengkap: '.implode(', ',array_unique($missing)).'.');$debit=0;$credit=0;foreach($journal as$line){$debit+=$line['debit'];$credit+=$line['kredit'];}if(abs($debit-$credit)>.01)throw new BusinessException('Jurnal payroll tidak balance. Periksa mapping komponen.');$count=$db->where(array('payroll_period_id'=>$id,'company_id'=>$companyId))->count_all_results('k_payrolls');$journalId=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>'PAYROLL-'.$period->id,'keterangan'=>'Payroll '.$period->period_name.' untuk '.$count.' karyawan','ref_type'=>'payroll','ref_id'=>$id,'created_by'=>$uid),array_values($journal),false,true);$db->where(array('id'=>$id,'company_id'=>$companyId))->update('k_payroll_periods',array('workflow_status'=>'posted','journal_id'=>$journalId,'updated_at'=>date('Y-m-d H:i:s')));$this->CI->auditservice->record('payroll','payroll_period',$id,'post',null,array('journal_id'=>$journalId),$uid);if($db->trans_status()===false)throw new RuntimeException('Posting payroll gagal disimpan.');$db->trans_commit();return$journalId; }catch(Throwable$e){$db->trans_rollback();throw$e;} } @@ -140,9 +144,18 @@ class PayrollService public function pay($id,$date,$cashAccountId,$uid,$companyId,$canManageAll) { - $db=$this->CI->db;$db->trans_begin();try{$period=$this->period($id,$companyId);if($period->workflow_status!=='posted')throw new BusinessException('Payroll belum diposting atau sudah dibayar.');if($this->hasRestrictedPayroll($id)&&!$canManageAll)throw new BusinessException('Pembayaran payroll dengan gaji terbatas memerlukan permission Manage All Gaji.');if($db->get_where('payroll_payments',array('company_id'=>$companyId,'payroll_period_id'=>$id))->row())throw new BusinessException('Pembayaran payroll sudah tersedia.');$cashQuery=$db->where(array('id'=>$cashAccountId,'is_active'=>1));if($db->field_exists('company_id','cash_accounts'))$cashQuery->where('company_id',$companyId);$cash=$cashQuery->get('cash_accounts')->row();if(!$cash)throw new BusinessException('Rekening pembayaran aktif tidak ditemukan.');$this->CI->fiscalperiodservice->assertOpen($date);$net=(float)$db->select('COALESCE(SUM(total_salary),0) total',false)->get_where('k_payrolls',array('payroll_period_id'=>$id,'company_id'=>$companyId))->row()->total;if($net<=0)throw new BusinessException('Total pembayaran payroll tidak valid.');$number='PRP-'.$companyId.'-'.date('YmdHis');$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$number,'keterangan'=>'Pembayaran payroll '.$period->period_name.' melalui '.$cash->name,'ref_type'=>'payroll_payment','ref_id'=>$id,'created_by'=>$uid),array(array('account_id'=>$this->CI->accountmappingservice->get('payroll_payable'),'debit'=>$net,'kredit'=>0),array('account_id'=>$cash->gl_account_id,'debit'=>0,'kredit'=>$net)),false,true);$db->insert('payroll_payments',array('company_id'=>$companyId,'payroll_period_id'=>$id,'payment_no'=>$number,'payment_date'=>$date,'cash_account_id'=>$cashAccountId,'total_amount'=>$net,'status'=>'posted','journal_id'=>$journal,'created_by'=>$uid,'approved_by'=>$uid,'posted_at'=>date('Y-m-d H:i:s')));$paymentId=(int)$db->insert_id();$db->where(array('id'=>$id,'company_id'=>$companyId))->update('k_payroll_periods',array('workflow_status'=>'paid','payment_transaction_id'=>$paymentId,'updated_at'=>date('Y-m-d H:i:s')));$this->CI->auditservice->record('payroll','payroll_period',$id,'pay',null,array('payment_id'=>$paymentId,'journal_id'=>$journal),$uid);$db->trans_commit();return$journal; + $db=$this->CI->db;$db->trans_begin();try{$period=$this->period($id,$companyId);if($period->workflow_status!=='posted')throw new BusinessException('Payroll belum diposting atau sudah dibayar.');if($this->hasRestrictedPayroll($id,$companyId)&&!$canManageAll)throw new BusinessException('Pembayaran payroll dengan gaji terbatas memerlukan permission Manage All Gaji.');if($db->get_where('payroll_payments',array('company_id'=>$companyId,'payroll_period_id'=>$id))->row())throw new BusinessException('Pembayaran payroll sudah tersedia.');$cashQuery=$db->where(array('id'=>$cashAccountId,'is_active'=>1));if($db->field_exists('company_id','cash_accounts'))$cashQuery->where('company_id',$companyId);$cash=$cashQuery->get('cash_accounts')->row();if(!$cash)throw new BusinessException('Rekening pembayaran aktif tidak ditemukan.');if(empty($cash->gl_account_id))throw new BusinessException('Rekening pembayaran belum terhubung ke akun buku besar.');$accountQuery=$db->where(array('id'=>(int)$cash->gl_account_id,'company_id'=>$companyId,'is_active'=>1));if($db->field_exists('allow_posting','accounts'))$accountQuery->where('allow_posting',1);if(!$accountQuery->count_all_results('accounts'))throw new BusinessException('Akun buku besar pada rekening pembayaran tidak aktif, tidak dapat diposting, atau berbeda perusahaan.');$this->CI->fiscalperiodservice->assertOpen($date);$net=(float)$db->select('COALESCE(SUM(total_salary),0) total',false)->get_where('k_payrolls',array('payroll_period_id'=>$id,'company_id'=>$companyId))->row()->total;if($net<=0)throw new BusinessException('Total pembayaran payroll tidak valid.');$number='PRP-'.$companyId.'-'.date('YmdHis').'-'.strtoupper(bin2hex(random_bytes(2)));$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$number,'keterangan'=>'Pembayaran payroll '.$period->period_name.' melalui '.$cash->name,'ref_type'=>'payroll_payment','ref_id'=>$id,'created_by'=>$uid),array(array('account_id'=>$this->CI->accountmappingservice->get('payroll_payable'),'debit'=>$net,'kredit'=>0),array('account_id'=>$cash->gl_account_id,'debit'=>0,'kredit'=>$net)),false,true);$db->insert('payroll_payments',array('company_id'=>$companyId,'payroll_period_id'=>$id,'payment_no'=>$number,'payment_date'=>$date,'cash_account_id'=>$cashAccountId,'total_amount'=>$net,'status'=>'posted','journal_id'=>$journal,'created_by'=>$uid,'approved_by'=>$uid,'posted_at'=>date('Y-m-d H:i:s')));$paymentId=(int)$db->insert_id();$db->where(array('id'=>$id,'company_id'=>$companyId))->update('k_payroll_periods',array('workflow_status'=>'paid','payment_transaction_id'=>$paymentId,'updated_at'=>date('Y-m-d H:i:s')));$this->CI->auditservice->record('payroll','payroll_period',$id,'pay',null,array('payment_id'=>$paymentId,'journal_id'=>$journal),$uid);if($db->trans_status()===false)throw new RuntimeException('Pembayaran payroll gagal disimpan.');$db->trans_commit();return$journal; }catch(Throwable$e){$db->trans_rollback();throw$e;} } - private function hasRestrictedPayroll($periodId){return$this->CI->db->from('k_payrolls p')->join('k_employees e','e.id=p.employee_id')->where('p.payroll_period_id',(int)$periodId)->where('e.salary_restricted',1)->count_all_results()>0;} + private function assertCalculationReady($period,$companyId) + { + if(empty($period->calculated_at)||empty($period->calculation_hash))throw new BusinessException('Payroll belum dihitung atau berubah setelah perhitungan. Silakan Hitung Ulang.'); + $actual=(int)$this->CI->db->where(array('company_id'=>$companyId,'payroll_period_id'=>$period->id))->count_all_results('k_payrolls'); + if($actual<1)throw new BusinessException('Rincian payroll masih kosong. Silakan Hitung Ulang.'); + $current=hash('sha256',json_encode($this->CI->db->select('id,total_salary,version')->where(array('payroll_period_id'=>$period->id,'company_id'=>$companyId))->order_by('id')->get('k_payrolls')->result_array())); + if(!hash_equals((string)$period->calculation_hash,$current))throw new BusinessException('Hasil payroll berubah setelah perhitungan. Silakan Hitung Ulang sebelum review.'); + } + + private function hasRestrictedPayroll($periodId,$companyId){return$this->CI->db->from('k_payrolls p')->join('k_employees e','e.id=p.employee_id AND e.company_id=p.company_id')->where(array('p.payroll_period_id'=>(int)$periodId,'p.company_id'=>(int)$companyId,'e.salary_restricted'=>1))->count_all_results()>0;} } diff --git a/application/libraries/ProfessionalReportService.php b/application/libraries/ProfessionalReportService.php index 0639a28..0703918 100644 --- a/application/libraries/ProfessionalReportService.php +++ b/application/libraries/ProfessionalReportService.php @@ -7,14 +7,61 @@ class ProfessionalReportService private function filters(array$f){return array(' j.company_id=? AND j.status IN(\'posted\',\'reversed\') AND j.tanggal BETWEEN ? AND ?',array($this->CI->companycontext->id(),$f['from'],$f['to']));} public function trialBalance(array$f){list($w,$b)=$this->filters($f);return$this->CI->db->query("SELECT a.id account_id,a.kode_akun,a.nama_akun,a.tipe,COALESCE(SUM(d.debit),0) debit,COALESCE(SUM(d.kredit),0) credit,COALESCE(SUM(d.debit-d.kredit),0) balance FROM accounts a LEFT JOIN journal_details d ON d.account_id=a.id LEFT JOIN journals j ON j.id=d.journal_id AND j.company_id=a.company_id WHERE a.company_id=? AND (j.id IS NULL OR ($w)) GROUP BY a.id ORDER BY a.priority ASC,a.kode_akun ASC,a.id ASC",array_merge(array($this->CI->companycontext->id()),$b))->result_array();} public function statement($type,array$f,$priorityOrder=true){list($w,$b)=$this->filters($f);$types=$type==='profit_loss'?array('revenue','expense'):array('asset','liability','equity');$marks=implode(',',array_fill(0,count($types),'?'));$company=$this->CI->companycontext->id();$order=$priorityOrder?'a.priority ASC,a.kode_akun ASC,a.id ASC':'a.kode_akun ASC';$sql="SELECT a.id account_id,a.kode_akun,a.nama_akun,a.tipe,COALESCE(x.amount,0) amount FROM accounts a LEFT JOIN (SELECT d.account_id,SUM(IF(ac.tipe IN('asset','expense'),d.debit-d.kredit,d.kredit-d.debit)) amount FROM journal_details d JOIN journals j ON j.id=d.journal_id JOIN accounts ac ON ac.id=d.account_id WHERE $w GROUP BY d.account_id)x ON x.account_id=a.id WHERE a.company_id=? AND a.tipe IN($marks) ORDER BY $order";return$this->CI->db->query($sql,array_merge($b,array($company),$types))->result_array();} - public function ledger(array$f){list($w,$b)=$this->filters($f);$sql="SELECT j.id journal_id,j.tanggal,j.no_ref,j.keterangan,j.ref_type,j.ref_id,a.kode_akun,a.nama_akun,d.debit,d.kredit FROM journal_details d JOIN journals j ON j.id=d.journal_id JOIN accounts a ON a.id=d.account_id WHERE $w";if(!empty($f['account_id'])){$sql.=' AND d.account_id=?';$b[]=(int)$f['account_id'];}$sql.=' ORDER BY j.tanggal,j.id,d.id';return$this->CI->db->query($sql,$b)->result_array();} - public function cashFlow(array$f){list($w,$b)=$this->filters($f);return$this->CI->db->query("SELECT CASE WHEN j.ref_type IN('invoice','customer_payment','supplier_invoice','supplier_payment','payroll','payroll_payment') THEN 'operating' WHEN j.ref_type LIKE 'asset_%' THEN 'investing' ELSE 'financing_or_other' END section,SUM(IF(a.sub_tipe='kas',d.debit-d.kredit,0)) amount FROM journal_details d JOIN journals j ON j.id=d.journal_id JOIN accounts a ON a.id=d.account_id WHERE $w GROUP BY section",$b)->result_array();} + public function ledger(array$f) + { + list($w,$b)=$this->filters($f); + $sql="SELECT d.id detail_id,j.id journal_id,j.tanggal,j.no_ref,j.keterangan,j.ref_type,j.ref_id,a.id account_id,a.kode_akun,a.nama_akun,a.tipe,d.debit,d.kredit FROM journal_details d JOIN journals j ON j.id=d.journal_id JOIN accounts a ON a.id=d.account_id WHERE $w"; + if(!empty($f['account_id'])){$sql.=' AND d.account_id=?';$b[]=(int)$f['account_id'];} + $sql.=' AND (d.debit<>0 OR d.kredit<>0) ORDER BY a.priority ASC,a.kode_akun ASC,j.tanggal ASC,j.id ASC,d.id ASC'; + $rows=$this->CI->db->query($sql,$b)->result_array(); + $opening=$this->ledgerOpening($f); + $running=array(); + foreach($rows as&$row){$id=(int)$row['account_id'];if(!isset($running[$id]))$running[$id]=(float)($opening[$id]??0);$normal=in_array($row['tipe'],array('asset','expense'),true)?1:-1;$row['saldo_awal']=(float)($opening[$id]??0);$running[$id]+=$normal*((float)$row['debit']-(float)$row['kredit']);$row['saldo_berjalan']=round($running[$id],2);} + unset($row);return$rows; + } + public function ledgerSummary(array$f) + { + $company=(int)$this->CI->companycontext->id();$opening=$this->ledgerOpening($f); + $sql="SELECT a.id account_id,a.kode_akun,a.nama_akun,a.tipe,a.priority,COALESCE(SUM(CASE WHEN j.id IS NOT NULL THEN d.debit ELSE 0 END),0) total_debit,COALESCE(SUM(CASE WHEN j.id IS NOT NULL THEN d.kredit ELSE 0 END),0) total_kredit FROM accounts a LEFT JOIN journal_details d ON d.account_id=a.id LEFT JOIN journals j ON j.id=d.journal_id AND j.company_id=? AND j.status IN('posted','reversed') AND j.tanggal BETWEEN ? AND ? WHERE a.company_id=?"; + $bind=array($company,$f['from'],$f['to'],$company);if(!empty($f['account_id'])){$sql.=' AND a.id=?';$bind[]=(int)$f['account_id'];}$sql.=' GROUP BY a.id ORDER BY a.priority ASC,a.kode_akun ASC'; + $rows=$this->CI->db->query($sql,$bind)->result_array();$result=array();foreach($rows as$row){$id=(int)$row['account_id'];$row['saldo_awal']=(float)($opening[$id]??0);$normal=in_array($row['tipe'],array('asset','expense'),true)?1:-1;$row['saldo_akhir']=round($row['saldo_awal']+$normal*((float)$row['total_debit']-(float)$row['total_kredit']),2);if(abs($row['saldo_awal'])>.009||abs((float)$row['total_debit'])>.009||abs((float)$row['total_kredit'])>.009)$result[]=$row;}return$result; + } + private function ledgerOpening(array$f) + { + $company=(int)$this->CI->companycontext->id();$sql="SELECT d.account_id,a.tipe,SUM(d.debit) debit,SUM(d.kredit) kredit FROM journal_details d JOIN journals j ON j.id=d.journal_id JOIN accounts a ON a.id=d.account_id WHERE j.company_id=? AND j.status IN('posted','reversed') AND j.tanggalCI->db->query($sql,$bind)->result_array()as$row){$normal=in_array($row['tipe'],array('asset','expense'),true)?1:-1;$out[(int)$row['account_id']]=$normal*((float)$row['debit']-(float)$row['kredit']);}return$out; + } + public function cashFlow(array$f) + { + $company=(int)$this->CI->companycontext->id();$cashWhere="(a.sub_tipe='kas' OR EXISTS(SELECT 1 FROM cash_accounts ca WHERE ca.gl_account_id=a.id AND (ca.company_id=? OR ca.company_id IS NULL)))"; + $opening=$this->cashOpening($f); + $sql="SELECT d.id detail_id,j.id journal_id,j.tanggal,j.no_ref,j.keterangan,j.ref_type,j.ref_id,a.kode_akun,a.nama_akun,d.debit kas_masuk,d.kredit kas_keluar FROM journal_details d JOIN journals j ON j.id=d.journal_id JOIN accounts a ON a.id=d.account_id WHERE j.company_id=? AND j.status IN('posted','reversed') AND j.tanggal BETWEEN ? AND ? AND $cashWhere AND (d.debit<>0 OR d.kredit<>0)";$bind=array($company,$f['from'],$f['to'],$company);if(!empty($f['account_id'])){$sql.=' AND a.id=?';$bind[]=(int)$f['account_id'];}$sql.=' ORDER BY j.tanggal,j.id,d.id';$rows=$this->CI->db->query($sql,$bind)->result_array();$running=$opening; + foreach($rows as&$row){$row['section']=$this->cashSection((string)$row['ref_type']);$row['aktivitas']=$this->cashSectionLabel($row['section']);$row['akun']=$row['kode_akun'].' - '.$row['nama_akun'];$running+=(float)$row['kas_masuk']-(float)$row['kas_keluar'];$row['saldo_berjalan']=round($running,2);}unset($row);return$rows; + } + public function cashFlowSummary(array$f,array$rows=null) + { + if($rows===null)$rows=$this->cashFlow($f);$opening=$this->cashOpening($f);$summary=array('operating'=>0,'investing'=>0,'financing'=>0,'opening_balance'=>$opening,'increase_decrease'=>0,'closing_balance'=>$opening,'cash_in'=>0,'cash_out'=>0);foreach($rows as$row){$net=(float)$row['kas_masuk']-(float)$row['kas_keluar'];$summary[$row['section']]+=$net;$summary['cash_in']+=(float)$row['kas_masuk'];$summary['cash_out']+=(float)$row['kas_keluar'];}$summary['increase_decrease']=$summary['operating']+$summary['investing']+$summary['financing'];$summary['closing_balance']=$opening+$summary['increase_decrease'];return$summary; + } + private function cashOpening(array$f){$company=(int)$this->CI->companycontext->id();$cashWhere="(a.sub_tipe='kas' OR EXISTS(SELECT 1 FROM cash_accounts ca WHERE ca.gl_account_id=a.id AND (ca.company_id=? OR ca.company_id IS NULL)))";$sql="SELECT COALESCE(SUM(d.debit-d.kredit),0) amount FROM journal_details d JOIN journals j ON j.id=d.journal_id JOIN accounts a ON a.id=d.account_id WHERE j.company_id=? AND j.status IN('posted','reversed') AND j.tanggalCI->db->query($sql,$bind)->row()->amount;} + private function cashSection($refType){if(preg_match('/asset|fixed_asset|capitalization|depreciation/i',$refType))return'investing';if(preg_match('/capital|equity|dividend|loan|financing|customer_saving|deposit/i',$refType))return'financing';return'operating';} + private function cashSectionLabel($section){return array('operating'=>'Aktivitas Operasi','investing'=>'Aktivitas Investasi','financing'=>'Aktivitas Pendanaan')[$section]??'Aktivitas Operasi';} public function equity(array$f){list($w,$b)=$this->filters($f);return$this->CI->db->query("SELECT a.kode_akun,a.nama_akun,SUM(d.kredit-d.debit) amount FROM journal_details d JOIN journals j ON j.id=d.journal_id JOIN accounts a ON a.id=d.account_id WHERE $w AND a.tipe='equity' GROUP BY a.id ORDER BY a.kode_akun",$b)->result_array();} public function actualBudget(array$f){$this->CI->load->library('BudgetService');$source=$this->CI->budgetservice->accountReport(array('from'=>substr($f['from'],0,7),'to'=>substr($f['to'],0,7),'account_id'=>(int)($f['account_id']??0)));$rows=array();foreach($source as$r)$rows[]=array('kode_akun'=>$r['kode_akun'],'nama_akun'=>$r['nama_akun'],'actual'=>$r['actual'],'budget'=>$r['budget'],'variance'=>$r['actual']-$r['budget']);return$rows;} public function comparative(array$f){$current=$this->statement('profit_loss',$f,false);$pm=array_merge($f,array('from'=>date('Y-m-d',strtotime($f['from'].' -1 month')),'to'=>date('Y-m-d',strtotime($f['to'].' -1 month'))));$py=array_merge($f,array('from'=>date('Y-m-d',strtotime($f['from'].' -1 year')),'to'=>date('Y-m-d',strtotime($f['to'].' -1 year'))));$idx=array();foreach($this->statement('profit_loss',$pm,false)as$r)$idx[$r['account_id']]['previous_month']=$r['amount'];foreach($this->statement('profit_loss',$py,false)as$r)$idx[$r['account_id']]['previous_year']=$r['amount'];foreach($current as&$r){$r['previous_month']=isset($idx[$r['account_id']]['previous_month'])?$idx[$r['account_id']]['previous_month']:0;$r['previous_year']=isset($idx[$r['account_id']]['previous_year'])?$idx[$r['account_id']]['previous_year']:0;$r['variance_month']=$r['amount']-$r['previous_month'];$r['variance_year']=$r['amount']-$r['previous_year'];}return$current;} public function purchaseReturns(array$f){return$this->CI->db->select('r.id record_id,r.return_no,r.return_date,s.supplier_code,s.name supplier,po.po_no,r.problem_category,r.requested_resolution,r.status,r.amount,r.journal_id')->from('purchase_returns r')->join('suppliers s','s.id=r.supplier_id')->join('purchase_orders po','po.id=r.purchase_order_id','left')->where('r.company_id',$this->CI->companycontext->id())->where('r.return_date >=',$f['from'])->where('r.return_date <=',$f['to'])->order_by('r.return_date')->order_by('r.id')->get()->result_array();} public function supplierRefunds(array$f,$outstanding=false){$this->CI->db->select('r.id record_id,r.refund_no,r.claim_date,r.expected_date,s.supplier_code,s.name supplier,po.po_no,si.internal_no invoice_supplier,r.source_type,r.status,r.claim_amount,r.received_amount,r.balance,r.entitlement_journal_id journal_id')->from('supplier_refund_claims r')->join('suppliers s','s.id=r.supplier_id')->join('purchase_orders po','po.id=r.purchase_order_id','left')->join('supplier_invoices si','si.id=r.supplier_invoice_id','left')->where('r.company_id',$this->CI->companycontext->id())->where('r.claim_date >=',$f['from'])->where('r.claim_date <=',$f['to']);if($outstanding)$this->CI->db->where('r.balance >',0)->where_not_in('r.status',array('draft','rejected','cancelled','reversed','reconciled'));return$this->CI->db->order_by('r.claim_date')->order_by('r.id')->get()->result_array();} public function supplierDebitNotes(array$f){return$this->CI->db->select('d.id record_id,d.debit_note_no,d.note_date,s.supplier_code,s.name supplier,po.po_no,si.internal_no invoice_supplier,d.correction_type,d.status,d.base_amount,d.tax_amount,d.amount,d.journal_id')->from('supplier_debit_notes d')->join('suppliers s','s.id=d.supplier_id')->join('purchase_orders po','po.id=d.purchase_order_id','left')->join('supplier_invoices si','si.id=d.supplier_invoice_id','left')->where('d.company_id',$this->CI->companycontext->id())->where('d.note_date >=',$f['from'])->where('d.note_date <=',$f['to'])->order_by('d.note_date')->order_by('d.id')->get()->result_array();} - public function salesReport($code,array$f){$c=$this->CI->companycontext->id();if($code==='sales_summary')return$this->CI->db->select('i.id record_id,i.no_invoice,i.tanggal,c.nama customer,i.invoice_type,i.recognition_policy,i.status,i.total,i.total_bayar,i.sisa_piutang')->from('invoices i')->join('customers c','c.id=i.customer_id')->where(array('i.company_id'=>$c,'i.workflow_status'=>'posted'))->where('i.tanggal >=',$f['from'])->where('i.tanggal <=',$f['to'])->order_by('i.tanggal')->get()->result_array();if($code==='sales_by_customer')return$this->CI->db->select('c.id customer_id,c.nama customer,COUNT(i.id) invoice_count,SUM(i.total) sales,SUM(i.total_bayar) paid,SUM(i.sisa_piutang) balance',false)->from('invoices i')->join('customers c','c.id=i.customer_id')->where(array('i.company_id'=>$c,'i.workflow_status'=>'posted'))->where('i.tanggal >=',$f['from'])->where('i.tanggal <=',$f['to'])->group_by('c.id')->order_by('sales','DESC')->get()->result_array();if($code==='sales_by_item')return$this->CI->db->select('d.items_id item_id,it.kode_detail AS kode_barang,it.nama_barang,SUM(d.qty) qty,SUM(d.net_amount) net_sales,SUM(d.tax_amount) tax,SUM(d.subtotal) total',false)->from('invoice_details d')->join('invoices i','i.id=d.invoice_id')->join('items it','it.id=d.items_id')->where(array('i.company_id'=>$c,'i.workflow_status'=>'posted'))->where('i.tanggal >=',$f['from'])->where('i.tanggal <=',$f['to'])->group_by('d.items_id')->order_by('net_sales','DESC')->get()->result_array();if($code==='sales_by_barcode')return$this->CI->db->select('i.id record_id,i.no_invoice,i.tanggal,it.kode_detail AS kode_barang,it.nama_barang,b.barcode,b.serial_number,lb.qty,sd.delivery_no')->from('invoice_line_barcodes lb')->join('invoices i','i.id=lb.invoice_id')->join('item_barcodes b','b.id=lb.barcode_id')->join('items it','it.id=b.item_id')->join('sales_deliveries sd','sd.id=lb.sales_delivery_id','left')->where('lb.company_id',$c)->where('i.tanggal >=',$f['from'])->where('i.tanggal <=',$f['to'])->order_by('i.tanggal')->get()->result_array();if($code==='running_invoices')return$this->CI->db->select('i.id record_id,i.no_invoice,c.nama customer,i.period_start,i.period_end,i.delivery_status,i.total,i.workflow_status')->from('invoices i')->join('customers c','c.id=i.customer_id')->where(array('i.company_id'=>$c,'i.invoice_type'=>'running','i.workflow_status'=>'draft'))->order_by('i.period_end')->get()->result_array();if($code==='sales_returns')return$this->CI->db->select('r.id record_id,r.return_no,r.return_date,c.nama customer,i.no_invoice,d.delivery_no,r.resolution,r.status,SUM(l.net_amount+l.tax_amount) amount',false)->from('sales_returns r')->join('customers c','c.id=r.customer_id')->join('invoices i','i.id=r.invoice_id','left')->join('sales_deliveries d','d.id=r.sales_delivery_id','left')->join('sales_return_lines l','l.sales_return_id=r.id','left')->where('r.company_id',$c)->where('r.return_date >=',$f['from'])->where('r.return_date <=',$f['to'])->group_by('r.id')->order_by('r.return_date')->get()->result_array();if($code==='revenue_recognition')return$this->CI->db->select('i.id record_id,i.no_invoice,c.nama customer,i.recognition_policy,i.subtotal_before_tax invoice_net,i.recognized_revenue,i.subtotal_before_tax-i.recognized_revenue deferred_revenue,i.recognized_cogs')->from('invoices i')->join('customers c','c.id=i.customer_id')->where(array('i.company_id'=>$c,'i.workflow_status'=>'posted'))->where('i.tanggal >=',$f['from'])->where('i.tanggal <=',$f['to'])->order_by('i.tanggal')->get()->result_array();if($code==='unbilled_deliveries')return$this->CI->db->select('d.id record_id,d.delivery_no,d.delivery_date,c.nama customer,d.status')->from('sales_deliveries d')->join('customers c','c.id=d.customer_id')->where('d.company_id',$c)->where('d.invoice_id IS NULL',null,false)->where('d.delivery_date >=',$f['from'])->where('d.delivery_date <=',$f['to'])->get()->result_array();if($code==='receivable_reconciliation'){$ar=$this->CI->load->library('AccountMappingService')?:null;$account=$this->CI->accountmappingservice->get('accounts_receivable');$sub=(float)$this->CI->db->select('COALESCE(SUM(sisa_piutang),0) v',false)->where(array('company_id'=>$c,'workflow_status'=>'posted'))->get('invoices')->row()->v;$gl=(float)$this->CI->db->select('COALESCE(SUM(d.debit-d.kredit),0) v',false)->from('journal_details d')->join('journals j','j.id=d.journal_id')->where(array('j.company_id'=>$c,'j.status'=>'posted','d.account_id'=>$account))->get()->row()->v;return array(array('subledger_piutang'=>$sub,'buku_besar_piutang'=>$gl,'selisih'=>$sub-$gl,'status'=>abs($sub-$gl)<.01?'Balance':'Perlu direkonsiliasi'));}throw new BusinessException('Jenis laporan penjualan tidak valid.');} + public function salesReport($code,array$f){$c=$this->CI->companycontext->id();if($code==='revenue_recognition')return$this->revenueRecognition($f);if($code==='receivable_reconciliation')return$this->receivableReconciliation($f);if($code==='sales_summary')return$this->CI->db->select('i.id record_id,i.no_invoice,i.tanggal,c.nama customer,i.invoice_type,i.recognition_policy,i.status,i.total,i.total_bayar,i.sisa_piutang')->from('invoices i')->join('customers c','c.id=i.customer_id')->where(array('i.company_id'=>$c,'i.workflow_status'=>'posted'))->where('i.tanggal >=',$f['from'])->where('i.tanggal <=',$f['to'])->order_by('i.tanggal')->get()->result_array();if($code==='sales_by_customer')return$this->CI->db->select('c.id customer_id,c.nama customer,COUNT(i.id) invoice_count,SUM(i.total) sales,SUM(i.total_bayar) paid,SUM(i.sisa_piutang) balance',false)->from('invoices i')->join('customers c','c.id=i.customer_id')->where(array('i.company_id'=>$c,'i.workflow_status'=>'posted'))->where('i.tanggal >=',$f['from'])->where('i.tanggal <=',$f['to'])->group_by('c.id')->order_by('sales','DESC')->get()->result_array();if($code==='sales_by_item')return$this->CI->db->select('d.items_id item_id,it.kode_detail AS kode_barang,it.nama_barang,SUM(d.qty) qty,SUM(d.net_amount) net_sales,SUM(d.tax_amount) tax,SUM(d.subtotal) total',false)->from('invoice_details d')->join('invoices i','i.id=d.invoice_id')->join('items it','it.id=d.items_id')->where(array('i.company_id'=>$c,'i.workflow_status'=>'posted'))->where('i.tanggal >=',$f['from'])->where('i.tanggal <=',$f['to'])->group_by('d.items_id')->order_by('net_sales','DESC')->get()->result_array();if($code==='sales_by_barcode')return$this->CI->db->select('i.id record_id,i.no_invoice,i.tanggal,it.kode_detail AS kode_barang,it.nama_barang,b.barcode,b.serial_number,lb.qty,sd.delivery_no')->from('invoice_line_barcodes lb')->join('invoices i','i.id=lb.invoice_id')->join('item_barcodes b','b.id=lb.barcode_id')->join('items it','it.id=b.item_id')->join('sales_deliveries sd','sd.id=lb.sales_delivery_id','left')->where('lb.company_id',$c)->where('i.tanggal >=',$f['from'])->where('i.tanggal <=',$f['to'])->order_by('i.tanggal')->get()->result_array();if($code==='running_invoices')return$this->CI->db->select('i.id record_id,i.no_invoice,c.nama customer,i.period_start,i.period_end,i.delivery_status,i.total,i.workflow_status')->from('invoices i')->join('customers c','c.id=i.customer_id')->where(array('i.company_id'=>$c,'i.invoice_type'=>'running','i.workflow_status'=>'draft'))->order_by('i.period_end')->get()->result_array();if($code==='sales_returns')return$this->CI->db->select('r.id record_id,r.return_no,r.return_date,c.nama customer,i.no_invoice,d.delivery_no,r.resolution,r.status,SUM(l.net_amount+l.tax_amount) amount',false)->from('sales_returns r')->join('customers c','c.id=r.customer_id')->join('invoices i','i.id=r.invoice_id','left')->join('sales_deliveries d','d.id=r.sales_delivery_id','left')->join('sales_return_lines l','l.sales_return_id=r.id','left')->where('r.company_id',$c)->where('r.return_date >=',$f['from'])->where('r.return_date <=',$f['to'])->group_by('r.id')->order_by('r.return_date')->get()->result_array();if($code==='unbilled_deliveries')return$this->CI->db->select('d.id record_id,d.delivery_no,d.delivery_date,c.nama customer,d.status')->from('sales_deliveries d')->join('customers c','c.id=d.customer_id')->where('d.company_id',$c)->where('d.invoice_id IS NULL',null,false)->where('d.delivery_date >=',$f['from'])->where('d.delivery_date <=',$f['to'])->get()->result_array();throw new BusinessException('Jenis laporan penjualan tidak valid.');} + private function revenueRecognition(array$f) + { + $company=(int)$this->CI->companycontext->id();$sql="SELECT i.id record_id,i.no_invoice,i.tanggal,c.nama customer,CASE WHEN i.recognition_policy='accrual' THEN 'Diakui saat posted' ELSE 'Proporsional setelah dibayar' END recognition_policy,COALESCE(i.subtotal_before_tax,0) invoice_net,COALESCE(i.recognized_revenue,0) recognized_revenue,GREATEST(COALESCE(i.subtotal_before_tax,0)-COALESCE(i.recognized_revenue,0),0) deferred_revenue,COALESCE(i.recognized_cogs,0) recognized_cogs FROM invoices i JOIN customers c ON c.id=i.customer_id AND c.company_id=i.company_id WHERE i.company_id=? AND i.workflow_status='posted' AND i.tanggal BETWEEN ? AND ? AND i.deleted_at IS NULL ORDER BY i.tanggal,i.id";return$this->CI->db->query($sql,array($company,$f['from'],$f['to']))->result_array(); + } + private function receivableReconciliation(array$f) + { + $company=(int)$this->CI->companycontext->id();$to=$f['to'];$sql="SELECT i.id record_id,c.nama customer,i.no_invoice,i.tanggal invoice_date,i.jatuh_tempo due_date,i.total receivable_amount,COALESCE(p.paid,0) payments,COALESCE(n.credit_amount,0) credit_notes,COALESCE(w.writeoff_amount,0) writeoffs,GREATEST(i.total-COALESCE(p.paid,0)-COALESCE(n.credit_amount,0)-COALESCE(w.writeoff_amount,0),0) remaining_receivable,CASE WHEN GREATEST(i.total-COALESCE(p.paid,0)-COALESCE(n.credit_amount,0)-COALESCE(w.writeoff_amount,0),0)<=0.01 THEN 'Lunas' WHEN COALESCE(p.paid,0)+COALESCE(n.credit_amount,0)+COALESCE(w.writeoff_amount,0)>0 THEN 'Partial' WHEN i.jatuh_tempoCI->db->query($sql,array($to,$to,$to,$to,$company,$to))->result_array();$summary=$this->receivableSummary($f,$rows);if(abs($summary['difference'])>.01)$rows[]=array('record_id'=>0,'customer'=>'Saldo GL belum terpetakan','no_invoice'=>'REKONSILIASI-GL','invoice_date'=>$to,'due_date'=>null,'receivable_amount'=>0,'payments'=>0,'credit_notes'=>0,'writeoffs'=>0,'remaining_receivable'=>$summary['difference'],'status'=>'Perlu ditelusuri','transaction_references'=>'Selisih saldo Buku Besar terhadap subledger invoice sampai '.$to,'reconciliation_adjustment'=>1);return$rows; + } + public function receivableSummary(array$f,array$rows=null) + { + if($rows===null)$rows=$this->receivableReconciliation($f);$subledger=0;foreach($rows as$row)if(empty($row['reconciliation_adjustment']))$subledger+=(float)$row['remaining_receivable'];$this->CI->load->library('AccountMappingService');$account=(int)$this->CI->accountmappingservice->get('accounts_receivable');$gl=(float)$this->CI->db->query("SELECT COALESCE(SUM(d.debit-d.kredit),0) amount FROM journal_details d JOIN journals j ON j.id=d.journal_id WHERE j.company_id=? AND j.status IN('posted','reversed') AND j.tanggal<=? AND d.account_id=?",array((int)$this->CI->companycontext->id(),$f['to'],$account))->row()->amount;return array('subledger'=>$subledger,'general_ledger'=>$gl,'difference'=>round($gl-$subledger,2),'balanced'=>abs($gl-$subledger)<.01); + } public function run($code,array$f){if($code==='trial_balance')return$this->trialBalance($f);if(in_array($code,array('profit_loss','balance_sheet'),true))return$this->statement($code,$f);if(in_array($code,array('general_ledger','journal_register','account_detail'),true))return$this->ledger($f);if($code==='cash_flow')return$this->cashFlow($f);if($code==='changes_equity')return$this->equity($f);if($code==='actual_budget')return$this->actualBudget($f);if($code==='comparative')return$this->comparative($f);if($code==='purchase_returns')return$this->purchaseReturns($f);if($code==='supplier_refunds')return$this->supplierRefunds($f);if($code==='outstanding_supplier_refunds')return$this->supplierRefunds($f,true);if($code==='supplier_debit_notes')return$this->supplierDebitNotes($f);if(in_array($code,array('sales_summary','sales_by_customer','sales_by_item','sales_by_barcode','running_invoices','sales_returns','revenue_recognition','unbilled_deliveries','receivable_reconciliation'),true))return$this->salesReport($code,$f);throw new BusinessException('Jenis laporan tidak valid.');} } diff --git a/application/libraries/PurchaseService.php b/application/libraries/PurchaseService.php index 9b53df3..c4fb0f5 100644 --- a/application/libraries/PurchaseService.php +++ b/application/libraries/PurchaseService.php @@ -29,8 +29,17 @@ class PurchaseService return array($amount,$accountId); } + private function assertPurchasePaymentSchema() + { + $missing=array(); + foreach(array('bank_charge_amount','bank_charge_account_id','total_cash_out')as$column)if(!$this->CI->db->field_exists($column,'supplier_payments'))$missing[]=$column; + if(!$this->CI->db->table_exists('purchase_payment_sources'))$missing[]='purchase_payment_sources'; + if($missing)throw new BusinessException('Struktur database pembayaran pembelian belum diperbarui ('.implode(', ',$missing).'). Jalankan: php index.php migrate latest'); + } + public function prepayOrderWithBankCharge($poId,$date,array$sources,$reference,$notes,$user,$bankCharge=0,$bankChargeAccount=0) { + $this->assertPurchasePaymentSchema(); return$this->CI->transactionservice->run(function()use($poId,$date,$sources,$reference,$notes,$user,$bankCharge,$bankChargeAccount){ $po=$this->CI->db->query('SELECT * FROM purchase_orders WHERE id=? FOR UPDATE',array((int)$poId))->row(); if(!$po||!in_array($po->status,array('approved','partially_received'),true))throw new BusinessException('PO belum disetujui atau sudah selesai.'); @@ -57,6 +66,7 @@ class PurchaseService public function settleSupplierInvoiceMultiWithBankCharge($id,array$sources,$paymentDate,$reference,$user,$bankCharge=0,$bankChargeAccount=0) { + $this->assertPurchasePaymentSchema(); $bill=$this->CI->db->get_where('supplier_invoices',array('id'=>(int)$id))->row();if(!$bill)throw new BusinessException('Invoice supplier tidak ditemukan.'); $po=$this->CI->db->select('po.*')->from('supplier_invoice_lines sil')->join('purchase_order_lines pol','pol.id=sil.po_line_id')->join('purchase_orders po','po.id=pol.purchase_order_id')->where('sil.supplier_invoice_id',$id)->limit(1)->get()->row(); $alreadyPaid=$po&&in_array($po->payment_timing,array('prepaid','on_receipt'),true)&&$po->paid_amount>=$bill->total-.001; @@ -88,6 +98,7 @@ class PurchaseService public function paymentMultiWithBankCharge($supplierId,$date,array$sources,array$allocations,$allowAdvance,$user,$reference,$bankCharge=0,$bankChargeAccount=0) { + $this->assertPurchasePaymentSchema(); return$this->CI->transactionservice->run(function()use($supplierId,$date,$sources,$allocations,$allowAdvance,$user,$reference,$bankCharge,$bankChargeAccount){ $supplier=$this->CI->db->get_where('suppliers',array('id'=>(int)$supplierId))->row();if(!$supplier)throw new BusinessException('Supplier tidak ditemukan.'); list($valid,$gross)=$this->purchasePaymentSources($sources);list($charge,$chargeAccount)=$this->purchaseBankCharge($bankCharge,$bankChargeAccount); diff --git a/application/libraries/SalesService.php b/application/libraries/SalesService.php index 9f8100d..1c4bfd3 100644 --- a/application/libraries/SalesService.php +++ b/application/libraries/SalesService.php @@ -295,7 +295,10 @@ class SalesService $invoice=$this->invoiceForEditor($invoiceId);$start=max(0,(int)$start);$length=max(1,min(100,(int)$length));$search=trim((string)$search); $base=function()use($invoiceId,$search){$this->CI->db->from('invoice_details d')->join('items it','it.id=d.items_id','left')->join('accounts a','a.id=d.account_id','left')->join('accounts ca','ca.id=d.counter_account_id','left')->where(array('d.invoice_id'=>(int)$invoiceId,'d.company_id'=>$this->company()));if($search!=='')$this->CI->db->group_start()->like('d.nama_item',$search)->or_like('d.specification',$search)->or_like('d.keterangan',$search)->or_like('it.kode_detail',$search)->or_like('a.nama_akun',$search)->or_like('ca.nama_akun',$search)->group_end();}; $base();$filtered=(int)$this->CI->db->count_all_results();$total=(int)$this->CI->db->where(array('invoice_id'=>(int)$invoiceId,'company_id'=>$this->company()))->count_all_results('invoice_details'); - $base();$rows=$this->CI->db->select("d.*,it.kode_detail item_code,a.kode_akun,a.nama_akun,ca.kode_akun counter_kode_akun,ca.nama_akun counter_nama_akun,(SELECT GROUP_CONCAT(CONCAT(ib.barcode,IF(NULLIF(ib.serial_number,'') IS NULL,'',CONCAT(' / ',ib.serial_number))) ORDER BY ilb.id SEPARATOR '||') FROM invoice_line_barcodes ilb JOIN item_barcodes ib ON ib.id=ilb.barcode_id WHERE ilb.invoice_detail_id=d.id) barcode_labels",false)->order_by('d.service_date','ASC')->order_by('d.id','ASC')->limit($length,$start)->get()->result(); + $base();$rows=$this->CI->db->select("d.*,it.kode_detail item_code,it.harga_jual item_sale_price,kb.tracking_type,kb.unit item_unit,w.nama warehouse_name,a.kode_akun,a.nama_akun,ca.kode_akun counter_kode_akun,ca.nama_akun counter_nama_akun,(SELECT GROUP_CONCAT(CONCAT(ib.barcode,IF(NULLIF(ib.serial_number,'') IS NULL,'',CONCAT(' / ',ib.serial_number))) ORDER BY ilb.id SEPARATOR '||') FROM invoice_line_barcodes ilb JOIN item_barcodes ib ON ib.id=ilb.barcode_id WHERE ilb.invoice_detail_id=d.id) barcode_labels",false)->join('kode_barang kb','kb.id=it.kode_id','left')->join('warehouses w','w.id=d.warehouse_id','left')->order_by('d.service_date','ASC')->order_by('d.id','ASC')->limit($length,$start)->get()->result(); + $lineIds=array_map(function($row){return(int)$row->id;},$rows);$allocations=array(); + if($lineIds){$bars=$this->CI->db->select('ilb.invoice_detail_id,ilb.barcode_id id,ib.barcode,ib.serial_number,ilb.qty,(ib.qty_sisa-COALESCE(ib.reserved_qty,0)-COALESCE(ib.return_reserved_qty,0)+ilb.qty) available,kb.tracking_type',false)->from('invoice_line_barcodes ilb')->join('item_barcodes ib','ib.id=ilb.barcode_id')->join('items it','it.id=ib.item_id')->join('kode_barang kb','kb.id=it.kode_id','left')->where('ilb.company_id',$this->company())->where_in('ilb.invoice_detail_id',$lineIds)->order_by('ilb.id')->get()->result();foreach($bars as$bar)$allocations[(int)$bar->invoice_detail_id][]=$bar;} + foreach($rows as$row)$row->barcode_allocations=$allocations[(int)$row->id]??array(); return array('invoice'=>$invoice,'rows'=>$rows,'recordsTotal'=>$total,'recordsFiltered'=>$filtered,'summary'=>array('line_count'=>$total,'subtotal'=>(float)$invoice->subtotal_before_tax,'discount'=>(float)$invoice->discount_total,'tax'=>(float)$invoice->tax_total,'total'=>(float)$invoice->total)); } public function updateInvoiceHeader($id,array$data,$user) @@ -326,26 +329,51 @@ class SalesService $this->CI->db->where(array('invoice_detail_id'=>(int)$lineId,'company_id'=>$this->company()))->delete('invoice_line_barcodes'); } - public function updateNonInventoryLine($invoiceId,$lineId,array$data,$user) + public function updateInvoiceLine($invoiceId,$lineId,array$data,$user) { return $this->CI->transactionservice->run(function()use($invoiceId,$lineId,$data,$user){ $invoice=$this->lockInvoice($invoiceId,'draft');$this->assertDraftOwner($invoice,$user); $line=$this->CI->db->query('SELECT * FROM invoice_details WHERE id=? AND invoice_id=? AND company_id=? FOR UPDATE',array((int)$lineId,$invoice->id,$this->company()))->row(); - if(!$line||!in_array($line->line_type,array('service','savings'),true))throw new BusinessException('Hanya item Jasa/Nonbarang atau Tabungan pada Draft yang dapat diedit.'); - $type=$line->line_type;$description=trim((string)($data['description']??''));if($description==='')throw new BusinessException('Deskripsi item wajib diisi.'); + if(!$line||!in_array($line->line_type,array('inventory','service','savings'),true))throw new BusinessException('Item invoice ini tidak dapat diedit.'); + $type=$line->line_type;if(isset($data['line_type'])&&$data['line_type']!==''&&$data['line_type']!==$type)throw new BusinessException('Jenis item tidak dapat diubah saat edit. Hapus lalu tambahkan kembali bila jenis transaksi berubah.'); + if($type==='inventory')$this->assertInventoryInvoiceLineEditable($line); + $description=$type==='inventory'?'':trim((string)($data['description']??''));if($type!=='inventory'&&$description==='')throw new BusinessException('Deskripsi item wajib diisi.'); $account=$this->tenant('accounts',$data['account_id']??0,$type==='savings'?'Akun simpanan customer':'Akun pendapatan'); - if(isset($account->is_active)&&!(int)$account->is_active||isset($account->allow_posting)&&!(int)$account->allow_posting)throw new BusinessException('Akun item tidak aktif atau tidak dapat diposting.'); - $counter=null;if($type==='service'&&(string)$account->tipe!=='revenue')throw new BusinessException('Jasa harus menggunakan akun pendapatan.'); + $mayUseHidden=function_exists('is_master_admin_user')&&is_master_admin_user();if((isset($account->is_active)&&(int)$account->is_active!==1)||(isset($account->allow_posting)&&(int)$account->allow_posting!==1)||(!$mayUseHidden&&isset($account->is_hidden)&&(int)$account->is_hidden===1))throw new BusinessException('Akun item tidak aktif atau tidak dapat diposting.'); + $counter=null;if(in_array($type,array('inventory','service'),true)&&(string)$account->tipe!=='revenue')throw new BusinessException('Barang dan jasa harus menggunakan akun pendapatan.'); if($type==='savings'&&(string)$account->tipe!=='liability')throw new BusinessException('Tabungan harus menggunakan akun Passiva/Kewajiban.'); - $qty=$type==='savings'?1:$this->qty($data['qty']??0);$price=$this->money($data['unit_price']??0);$discount=$type==='savings'?0:max(0,$this->money($data['discount_amount']??0));if($discount>$qty*$price)throw new BusinessException('Diskon tidak boleh melebihi nilai bruto item.'); - $tax=$type==='service'?max(0,(float)str_replace(',','.',(string)($data['tax_rate']??0))):0;$net=max(0,$qty*$price-$discount);$taxAmount=round($net*$tax/100,2);$service=$data['service_date']??$line->service_date;if(!preg_match('/^\d{4}-\d{2}-\d{2}$/',(string)$service))throw new BusinessException('Tanggal transaksi item tidak valid.'); - $update=$this->columns('invoice_details',array('service_date'=>$service,'tanggal'=>$service,'account_id'=>$account->id,'counter_account_id'=>$counter?$counter->id:null,'nama_item'=>$description,'unit'=>trim((string)($data['unit']??$line->unit)),'qty'=>$qty,'harga'=>$price,'discount_amount'=>$discount,'tax_rate'=>$tax,'tax_amount'=>$taxAmount,'net_amount'=>$net,'subtotal'=>$net+$taxAmount,'subtotal_asli'=>$net+$taxAmount,'keterangan'=>trim((string)($data['notes']??'')))); + $item=null;$warehouse=null;$kb=null;if($type==='inventory'){$item=$this->tenant('items',$data['item_id']??0,'Barang');$warehouse=$this->tenant('warehouses',$data['warehouse_id']??0,'Gudang');if($item->status!=='active')throw new BusinessException('Barang '.$item->nama_barang.' belum aktif.');$kb=$this->CI->db->get_where('kode_barang',array('id'=>$item->kode_id))->row();} + $qty=$type==='savings'?1:$this->qty($data['qty']??0);$price=$this->money($data['unit_price']??($item?$item->harga_jual:0));$discount=$type==='savings'?0:max(0,$this->money($data['discount_amount']??0));if($discount>$qty*$price)throw new BusinessException('Diskon tidak boleh melebihi nilai bruto item.'); + $tax=$type==='savings'?0:max(0,(float)str_replace(',','.',(string)($data['tax_rate']??0)));$net=max(0,$qty*$price-$discount);$taxAmount=round($net*$tax/100,2);$service=$data['service_date']??$line->service_date;if(!preg_match('/^\d{4}-\d{2}-\d{2}$/',(string)$service))throw new BusinessException('Tanggal transaksi item tidak valid.'); + $requestedBarcodes=(array)($data['barcodes']??array());if($type==='inventory'&&!$requestedBarcodes)throw new BusinessException('Pilih minimal satu barcode untuk barang gudang.'); + $beforeBars=$this->CI->db->where(array('invoice_detail_id'=>$line->id,'company_id'=>$this->company()))->order_by('id')->get('invoice_line_barcodes')->result_array();$before=array('line'=>$line,'barcodes'=>$beforeBars); $lineJournalMode=true;$otherLines=$this->CI->db->select('id,line_type,subtotal,total_hpp_barang')->where(array('invoice_id'=>$invoice->id,'company_id'=>$this->company()))->where('id !=',$line->id)->get('invoice_details')->result();foreach($otherLines as$other)if($other->line_type!=='savings'&&((float)$other->subtotal>0||(float)$other->total_hpp_barang>0)){$otherJournal=$this->initialLineJournal($other->id);if(!$otherJournal||$otherJournal->is_reversed||$otherJournal->status!=='posted'){$lineJournalMode=false;break;}} $this->reverseInvoiceLineInitialJournal($line,$user,'Item Draft invoice '.$invoice->no_invoice.' diedit'); - $this->CI->db->where(array('id'=>$line->id,'invoice_id'=>$invoice->id,'company_id'=>$this->company()))->update('invoice_details',$update);$updated=$this->CI->db->get_where('invoice_details',array('id'=>$line->id,'company_id'=>$this->company()))->row();$customer=$this->tenant('customers',$invoice->customer_id,'Customer');if($lineJournalMode)$this->postInvoiceLineInitialJournal($invoice,$line->id,$updated,null,array(),$customer,$user);$this->refreshInvoiceTotals($invoice->id);$this->CI->auditservice->record('sales','invoice_detail',$line->id,'update',$line,$update,$user);return$line->id; + $cleanBarcodes=array();if($type==='inventory'){$this->detachDraftDeliveryLine($line);$this->releaseInvoiceLineBarcodes($line->id);$seen=array();$barcodeQty=0;foreach($requestedBarcodes as$requested){$barcodeId=(int)($requested['id']??$requested['barcode_id']??0);if(!$barcodeId||isset($seen[$barcodeId]))throw new BusinessException('Barcode tidak boleh dipilih dua kali pada satu item.');$seen[$barcodeId]=true;$barcodeQtyValue=$this->qty($requested['qty']??1);$barcode=$this->CI->db->query("SELECT * FROM item_barcodes WHERE id=? AND item_id=? AND warehouse_id=? AND status IN('available','reserved') FOR UPDATE",array($barcodeId,$item->id,$warehouse->id))->row();if(!$barcode)throw new BusinessException('Barcode barang '.$item->nama_barang.' tidak tersedia pada gudang terpilih.');if($this->CI->db->where(array('invoice_id'=>$invoice->id,'barcode_id'=>$barcodeId))->count_all_results('invoice_line_barcodes'))throw new BusinessException('Barcode '.$barcode->barcode.' sudah dipakai item lain pada invoice ini.');if($kb&&strtoupper((string)$kb->tracking_type)==='UNIT'){$used=(int)$this->CI->db->select('COUNT(*) total',false)->from('invoice_line_barcodes used')->join('invoice_details used_detail','used_detail.id=used.invoice_detail_id AND used_detail.stock_posted_at IS NULL')->join('invoices used_invoice','used_invoice.id=used.invoice_id')->where(array('used.barcode_id'=>$barcode->id,'used_invoice.company_id'=>$this->company()))->where('used_invoice.deleted_at IS NULL',null,false)->where("COALESCE(used_invoice.status,'') <> 'cancelled'",null,false)->get()->row()->total;if($used>0)throw new BusinessException('Barcode/Serial '.$barcode->barcode.' sedang digunakan invoice lain.');if(abs($barcodeQtyValue-1)>.0001)throw new BusinessException('Barcode UNIT harus memiliki qty 1.');}$available=(float)$barcode->qty_sisa-(float)$barcode->reserved_qty-(float)($barcode->return_reserved_qty??0);if($barcodeQtyValue>$available+.0001)throw new BusinessException('Qty barcode '.$barcode->barcode.' melebihi stok tersedia.');$reservedAfter=(float)$barcode->reserved_qty+$barcodeQtyValue;$barcodeUpdate=array('reserved_qty'=>$reservedAfter,'version'=>(int)$barcode->version+1);if($kb&&strtoupper((string)$kb->tracking_type)==='UNIT')$barcodeUpdate['status']='reserved';$this->CI->db->where(array('id'=>$barcode->id,'version'=>$barcode->version))->update('item_barcodes',$barcodeUpdate);if($this->CI->db->affected_rows()!==1)throw new BusinessException('Barcode sedang diproses pengguna lain. Silakan ulangi.');$barcode->reserved_qty=$reservedAfter;$cleanBarcodes[]=array('row'=>$barcode,'qty'=>$barcodeQtyValue);$barcodeQty+=$barcodeQtyValue;}if(abs($barcodeQty-$qty)>.0001)throw new BusinessException('Total qty barcode harus sama dengan qty barang '.$item->nama_barang.'.');} + $update=$this->columns('invoice_details',array('service_date'=>$service,'tanggal'=>$service,'account_id'=>$account->id,'counter_account_id'=>$counter?$counter->id:null,'items_id'=>$item?$item->id:null,'warehouse_id'=>$warehouse?$warehouse->id:null,'delivery_line_id'=>null,'nama_item'=>$type==='inventory'?$item->nama_barang:$description,'unit'=>$kb?$kb->unit:trim((string)($data['unit']??$line->unit)),'qty'=>$qty,'harga'=>$price,'discount_amount'=>$discount,'tax_rate'=>$tax,'tax_amount'=>$taxAmount,'net_amount'=>$net,'subtotal'=>$net+$taxAmount,'subtotal_asli'=>$net+$taxAmount,'total_hpp_barang'=>$item?round($qty*(float)$item->harga_beli,2):0,'keterangan'=>trim((string)($data['notes']??'')))); + $this->CI->db->where(array('id'=>$line->id,'invoice_id'=>$invoice->id,'company_id'=>$this->company()))->update('invoice_details',$update);if($type==='inventory')foreach($cleanBarcodes as$bar)$this->CI->db->insert('invoice_line_barcodes',array('company_id'=>$this->company(),'invoice_id'=>$invoice->id,'invoice_detail_id'=>$line->id,'sales_delivery_id'=>null,'sales_delivery_line_id'=>null,'barcode_id'=>$bar['row']->id,'qty'=>$bar['qty'],'created_at'=>date('Y-m-d H:i:s'))); + $updated=$this->CI->db->get_where('invoice_details',array('id'=>$line->id,'company_id'=>$this->company()))->row();$customer=$this->tenant('customers',$invoice->customer_id,'Customer');if($lineJournalMode)$this->postInvoiceLineInitialJournal($invoice,$line->id,$updated,$item,$cleanBarcodes,$customer,$user);$this->refreshInvoiceTotals($invoice->id);$this->updateInvoiceDeliveryStatus($invoice->id);$afterBars=$this->CI->db->where(array('invoice_detail_id'=>$line->id,'company_id'=>$this->company()))->order_by('id')->get('invoice_line_barcodes')->result_array();$this->CI->auditservice->record('sales','invoice_detail',$line->id,'update',$before,array('line'=>$updated,'barcodes'=>$afterBars),$user);return$line->id; }); } + /** Kompatibilitas pemanggil lama; seluruh validasi sekarang terpusat. */ + public function updateNonInventoryLine($invoiceId,$lineId,array$data,$user){return$this->updateInvoiceLine($invoiceId,$lineId,$data,$user);} + + private function assertInventoryInvoiceLineEditable($line) + { + if(!empty($line->stock_posted_at)||!empty($line->stock_log_id))throw new BusinessException('Barang sudah memiliki mutasi stok dan tidak dapat diedit. Gunakan retur atau koreksi persediaan.'); + $deliveryLineId=(int)$line->delivery_line_id;if($deliveryLineId){$delivery=$this->CI->db->select('l.stock_posted_at,d.status')->from('sales_delivery_lines l')->join('sales_deliveries d','d.id=l.sales_delivery_id')->where(array('l.id'=>$deliveryLineId,'d.company_id'=>$this->company()))->get()->row();if(!$delivery||$delivery->status!=='draft'||!empty($delivery->stock_posted_at))throw new BusinessException('Surat Jalan barang sudah diproses. Gunakan retur/koreksi agar stok dan audit trail tetap benar.');} + $stockCount=(int)$this->CI->db->query("SELECT COUNT(*) total FROM stock_logs WHERE ((ref_type='invoice_details' AND ref_id=?) OR (? > 0 AND ref_type='sales_delivery' AND ref_id=?))",array((int)$line->id,$deliveryLineId,$deliveryLineId))->row()->total; + $movementCount=(int)$this->CI->db->query("SELECT COUNT(*) total FROM item_movements WHERE ((reference_type IN('invoice','invoice_details') AND reference_id=?) OR (? > 0 AND reference_type='sales_delivery' AND reference_id=?))",array((int)$line->id,$deliveryLineId,$deliveryLineId))->row()->total; + $ledgerCount=(int)$this->CI->db->query("SELECT COUNT(*) total FROM inventory_ledger WHERE ((document_type IN('invoice','invoice_details') AND document_line_id=?) OR (? > 0 AND document_type='sales_delivery' AND document_line_id=?))",array((int)$line->id,$deliveryLineId,$deliveryLineId))->row()->total; + if($stockCount||$movementCount||$ledgerCount)throw new BusinessException('Barang sudah terhubung dengan stock log atau item movement. Edit dihentikan; gunakan retur/koreksi persediaan.'); + } + + private function detachDraftDeliveryLine($line) + { + if(!(int)$line->delivery_line_id)return;$deliveryLine=$this->CI->db->select('l.sales_delivery_id,l.stock_posted_at,d.status')->from('sales_delivery_lines l')->join('sales_deliveries d','d.id=l.sales_delivery_id')->where(array('l.id'=>(int)$line->delivery_line_id,'d.company_id'=>$this->company()))->get()->row();if(!$deliveryLine||$deliveryLine->status!=='draft'||!empty($deliveryLine->stock_posted_at))throw new BusinessException('Item tidak dapat dilepas dari Surat Jalan yang sudah diproses.');$this->CI->db->where('sales_delivery_line_id',(int)$line->delivery_line_id)->delete('sales_delivery_barcodes');$this->CI->db->where('id',(int)$line->delivery_line_id)->delete('sales_delivery_lines');if(!$this->CI->db->where('sales_delivery_id',(int)$deliveryLine->sales_delivery_id)->count_all_results('sales_delivery_lines'))$this->CI->db->where(array('id'=>(int)$deliveryLine->sales_delivery_id,'company_id'=>$this->company()))->update('sales_deliveries',array('status'=>'cancelled')); + } + public function updateDelivery($id,array$data,array$attachments,$user) { return$this->CI->transactionservice->run(function()use($id,$data,$attachments,$user){$d=$this->CI->db->query('SELECT * FROM sales_deliveries WHERE id=? AND company_id=? FOR UPDATE',array((int)$id,$this->company()))->row();if(!$d||$d->status!=='draft')throw new BusinessException('Hanya Surat Jalan Draft yang dapat dilengkapi.');$date=$data['delivery_date']??$d->delivery_date;if(!preg_match('/^\d{4}-\d{2}-\d{2}$/',$date))throw new BusinessException('Tanggal pengiriman tidak valid.');$update=array('delivery_date'=>$date,'address'=>trim((string)($data['address']??'')),'driver_name'=>trim((string)($data['driver_name']??'')),'vehicle_no'=>trim((string)($data['vehicle_no']??'')),'courier'=>trim((string)($data['courier']??'')),'tracking_no'=>trim((string)($data['tracking_no']??'')),'recipient_name'=>trim((string)($data['recipient_name']??'')),'notes'=>trim((string)($data['notes']??'')),'version'=>(int)$d->version+1);$this->CI->db->where('id',$d->id)->update('sales_deliveries',$update);foreach($attachments as$a)$this->insertAttachment('sales_delivery',$d->id,$a,$user);$this->CI->auditservice->record('sales','sales_delivery',$d->id,'update',$d,$update,$user);return$d->id;}); diff --git a/application/migrations/20260914000200_expand_company_bank_accounts.php b/application/migrations/20260914000200_expand_company_bank_accounts.php new file mode 100644 index 0000000..d7375b4 --- /dev/null +++ b/application/migrations/20260914000200_expand_company_bank_accounts.php @@ -0,0 +1,45 @@ +db->field_exists($name, 'company_bank_accounts')) { + $this->db->query("ALTER TABLE `company_bank_accounts` ADD `{$name}` {$definition}"); + } + } + + public function up() + { + if (!$this->db->table_exists('company_bank_accounts')) return; + + $this->column('product_name', "VARCHAR(120) NULL AFTER `account_holder`"); + $this->column('account_type', "ENUM('savings','deposit') NOT NULL DEFAULT 'savings' AFTER `product_name`"); + $this->column('interest_rate', "DECIMAL(7,4) NOT NULL DEFAULT 0 AFTER `account_type`"); + $this->column('opened_at', "DATE NULL AFTER `interest_rate`"); + $this->column('maturity_date', "DATE NULL AFTER `opened_at`"); + $this->column('gl_account_id', "INT NULL AFTER `currency`"); + + if ($this->db->table_exists('cash_accounts')) { + $this->db->query("UPDATE company_bank_accounts b + JOIN cash_accounts ca ON ca.bank_account_no=b.account_number + AND (ca.company_id=b.company_id OR ca.company_id IS NULL) + JOIN accounts a ON a.id=ca.gl_account_id AND a.company_id=b.company_id + SET b.gl_account_id=ca.gl_account_id + WHERE b.gl_account_id IS NULL"); + } + + if (!$this->db->query("SHOW INDEX FROM company_bank_accounts WHERE Key_name='idx_company_bank_report'")->row()) { + $this->db->query("ALTER TABLE company_bank_accounts ADD KEY idx_company_bank_report(company_id,account_type,is_active,maturity_date)"); + } + if (!$this->db->query("SHOW INDEX FROM company_bank_accounts WHERE Key_name='idx_company_bank_gl'")->row()) { + $this->db->query("ALTER TABLE company_bank_accounts ADD KEY idx_company_bank_gl(company_id,gl_account_id)"); + } + } + + public function down() + { + throw new RuntimeException('Metadata rekening perusahaan dipertahankan untuk audit dan dokumen lama.'); + } +} diff --git a/application/migrations/20260914000300_repair_purchase_payment_bank_charge_schema.php b/application/migrations/20260914000300_repair_purchase_payment_bank_charge_schema.php new file mode 100644 index 0000000..63b00cc --- /dev/null +++ b/application/migrations/20260914000300_repair_purchase_payment_bank_charge_schema.php @@ -0,0 +1,36 @@ +db->table_exists('supplier_payments')) { + throw new RuntimeException('Tabel supplier_payments belum tersedia. Jalankan migrasi pembelian sebelumnya terlebih dahulu.'); + } + + if (!$this->db->field_exists('bank_charge_amount', 'supplier_payments')) { + $this->db->query('ALTER TABLE supplier_payments ADD bank_charge_amount DECIMAL(18,2) NOT NULL DEFAULT 0 AFTER amount'); + } + if (!$this->db->field_exists('bank_charge_account_id', 'supplier_payments')) { + $this->db->query('ALTER TABLE supplier_payments ADD bank_charge_account_id INT NULL AFTER bank_charge_amount'); + } + if (!$this->db->field_exists('total_cash_out', 'supplier_payments')) { + $this->db->query('ALTER TABLE supplier_payments ADD total_cash_out DECIMAL(18,2) NOT NULL DEFAULT 0 AFTER bank_charge_account_id'); + } + + // Pembayaran lama tidak mempunyai biaya bank, sehingga total kas + // keluar sama dengan nilai pokok pembayaran yang sudah tersimpan. + $this->db->query('UPDATE supplier_payments SET bank_charge_amount=0 WHERE bank_charge_amount IS NULL'); + $this->db->query('UPDATE supplier_payments SET total_cash_out=amount+bank_charge_amount WHERE total_cash_out IS NULL OR total_cash_out=0'); + } + + public function down() + { + throw new RuntimeException('Kolom pembayaran pembelian dipertahankan untuk menjaga histori dan audit transaksi.'); + } +} diff --git a/application/migrations/20260914000400_repair_supplier_invoice_status_enum.php b/application/migrations/20260914000400_repair_supplier_invoice_status_enum.php new file mode 100644 index 0000000..f2b27e6 --- /dev/null +++ b/application/migrations/20260914000400_repair_supplier_invoice_status_enum.php @@ -0,0 +1,28 @@ +db->table_exists('supplier_invoices')) { + throw new RuntimeException('Tabel supplier_invoices belum tersedia. Jalankan migrasi pembelian sebelumnya terlebih dahulu.'); + } + + // Gunakan ENUM transisi supaya seluruh nilai legacy tetap valid saat + // dikonversi ke workflow supplier invoice yang berlaku sekarang. + $this->db->query("ALTER TABLE supplier_invoices MODIFY status ENUM('draft','submitted','approved','verified','posted','partial','paid','cancelled','reversed') NOT NULL DEFAULT 'verified'"); + $this->db->query("UPDATE supplier_invoices SET status='verified' WHERE status IN('draft','submitted','approved')"); + $this->db->query("UPDATE supplier_invoices SET status=IF(COALESCE(balance,0)<=0,'paid','partial') WHERE status='posted'"); + $this->db->query("ALTER TABLE supplier_invoices MODIFY status ENUM('verified','partial','paid','cancelled','reversed') NOT NULL DEFAULT 'verified'"); + } + + public function down() + { + throw new RuntimeException('Status supplier invoice dipertahankan untuk menjaga histori dan workflow pembayaran.'); + } +} diff --git a/application/migrations/20260914000500_repair_system_account_company_scope.php b/application/migrations/20260914000500_repair_system_account_company_scope.php new file mode 100644 index 0000000..12ac731 --- /dev/null +++ b/application/migrations/20260914000500_repair_system_account_company_scope.php @@ -0,0 +1,49 @@ +db->table_exists('companies')) return; + + $company = $this->db->where('is_active', 1)->order_by('id')->get('companies')->row(); + if (!$company) { + throw new RuntimeException('Perusahaan aktif tidak ditemukan untuk memperbaiki scope akun sistem.'); + } + + $companyId = (int) $company->id; + if ($this->db->table_exists('accounts') && $this->db->field_exists('company_id', 'accounts')) { + $this->db->where('company_id IS NULL OR company_id=0', null, false) + ->update('accounts', array('company_id'=>$companyId)); + } + + if ($this->db->table_exists('system_account_mappings') && $this->db->field_exists('company_id', 'system_account_mappings')) { + $this->db->query( + 'UPDATE system_account_mappings m ' + .'JOIN accounts a ON a.id=m.account_id ' + .'SET m.company_id=COALESCE(NULLIF(a.company_id,0),?) ' + .'WHERE m.company_id IS NULL OR m.company_id=0', + array($companyId) + ); + } + + $mapping = $this->db->select('m.account_id,a.tipe,a.is_active,a.allow_posting,a.company_id') + ->from('system_account_mappings m') + ->join('accounts a', 'a.id=m.account_id') + ->where(array('m.mapping_key'=>'goods_in_transit','m.is_active'=>1)) + ->get()->row(); + if (!$mapping || $mapping->tipe !== 'asset' || !(int)$mapping->is_active || !(int)$mapping->allow_posting || !(int)$mapping->company_id) { + throw new RuntimeException('Mapping Barang Dalam Perjalanan belum menunjuk akun Aktiva aktif milik perusahaan.'); + } + } + + public function down() + { + throw new RuntimeException('Scope perusahaan pada akun sistem dipertahankan untuk integritas jurnal.'); + } +} diff --git a/application/models/AccountModel.php b/application/models/AccountModel.php index e381c81..f95ab65 100644 --- a/application/models/AccountModel.php +++ b/application/models/AccountModel.php @@ -9,13 +9,15 @@ class AccountModel extends CI_Model return array(); } - $rows = $this->db - ->select('id') + $this->db->select('id') ->where_in('id', array_values(array_unique($ids))) ->where('is_active', 1) - ->where('allow_posting', 1) - ->get('accounts') - ->result_array(); + ->where('allow_posting', 1); + if ($this->db->field_exists('company_id', 'accounts')) { + $companyId = isset($this->companycontext) ? (int)$this->companycontext->id() : (int)$this->session->userdata('company_id'); + $this->db->where('company_id', $companyId); + } + $rows = $this->db->get('accounts')->result_array(); return array_map('intval', array_column($rows, 'id')); } } diff --git a/application/models/JournalModel.php b/application/models/JournalModel.php index 46257af..131f093 100644 --- a/application/models/JournalModel.php +++ b/application/models/JournalModel.php @@ -19,9 +19,11 @@ class JournalModel extends CI_Model } } - public function referenceExists($reference) + public function referenceExists($reference, $excludeId = 0) { - return $this->db->where('no_ref', $reference)->count_all_results('journals') > 0; + $this->db->where('no_ref', $reference); + if ((int) $excludeId > 0) $this->db->where('id !=', (int) $excludeId); + return $this->db->count_all_results('journals') > 0; } public function sourceExists($type, $id) diff --git a/application/views/buku_besar/layout.php b/application/views/buku_besar/layout.php index 301fa5c..7f5fd76 100644 --- a/application/views/buku_besar/layout.php +++ b/application/views/buku_besar/layout.php @@ -15,12 +15,12 @@
- +
- +
@@ -32,6 +32,13 @@ +
+
Saldo Awal0
+
Total Debit0
+
Total Kredit0
+
Saldo Akhir0
+
+
@@ -62,6 +69,7 @@ $(function(){ opt += ``; }); $('#account_id').html(opt); + if(res.length) loadData(); },'json'); function loadData(){ @@ -78,7 +86,7 @@ $(function(){ let html = ''; - res.forEach(r=>{ + (res.data || []).forEach(r=>{ html += ` @@ -92,8 +100,13 @@ $(function(){ }); $('#tableLedger tbody').html(html); + Object.keys(res.summary || {}).forEach(function(key){ + $('[data-summary="'+key+'"]').text(Number(res.summary[key] || 0).toLocaleString('id-ID')); + }); - },'json'); + },'json').fail(function(xhr){ + Swal.fire('Error', xhr.responseJSON?.message || 'Data Buku Besar gagal dimuat.', 'error'); + }); } @@ -118,7 +131,7 @@ $(function(){ + String(now.getDate()).padStart(2, '0'); const account_id = $('#account_id').val(); - const start_date = $('#start_date').val() || '0000-00-00'; + const start_date = $('#start_date').val() || '1900-01-01'; const end_date = $('#end_date').val() || today; let btn = $(this); @@ -153,4 +166,4 @@ $(function(){ }); }); }); - \ No newline at end of file + diff --git a/application/views/companies/index.php b/application/views/companies/index.php index 262a3e5..34c34f7 100644 --- a/application/views/companies/index.php +++ b/application/views/companies/index.php @@ -1,7 +1,7 @@ security->get_csrf_token_name();$csrfHash=$this->security->get_csrf_hash(); ?>
-
ADMINISTRASI PERUSAHAAN

Company Setting

Kelola identitas dokumen, rekening pembayaran, perusahaan, dan mata uang dari satu tempat.

Perusahaan aktifcompany_code.' - '.$active_company->name)?>
+
ADMINISTRASI PERUSAHAAN

Company Setting

Kelola identitas dokumen, rekening pembayaran, perusahaan, dan mata uang dari satu tempat.

Perusahaan aktifcompany_code.' - '.$active_company->name)?>
session->flashdata('success')):?>
session->flashdata('success'))?>
session->flashdata('error')):?>
session->flashdata('error'))?>
@@ -25,3 +25,26 @@ $('.edit-currency').on('click',function(){var r=$(this).data('row'),f=document.getElementById('currencyForm');f.elements.code.value=r.code;f.elements.code.readOnly=true;f.elements.name.value=r.name;f.elements.symbol.value=r.symbol||'';f.elements.decimal_places.value=r.decimal_places;f.elements.is_active.checked=Number(r.is_active)===1;$('#cancelCurrencyEdit').removeClass('d-none');f.scrollIntoView({behavior:'smooth',block:'center'});});$('#cancelCurrencyEdit').on('click',resetCurrency); })(jQuery); + diff --git a/application/views/dashboard/activity_logs.php b/application/views/dashboard/activity_logs.php index 4ed1dc6..b8de410 100644 --- a/application/views/dashboard/activity_logs.php +++ b/application/views/dashboard/activity_logs.php @@ -30,9 +30,19 @@ \ No newline at end of file + diff --git a/application/views/hr_payroll/index.php b/application/views/hr_payroll/index.php index b3b79c2..f9a7681 100644 --- a/application/views/hr_payroll/index.php +++ b/application/views/hr_payroll/index.php @@ -8,6 +8,7 @@ $statusLabels=array('draft'=>'Draft','reviewed'=>'Sudah Direview','approved'=>'D ?> session->flashdata($flash)):?>
Tampilan ini tidak memuat nilai maupun total gaji terbatas. Akses tersebut memerlukan permission Manage All Gaji.
+
Akses pengelolaan dibatasi pada Gaji Pokok dan Potongan. Tunjangan, bonus, lembur, THR, dan pendapatan tambahan memerlukan permission Kelola Tunjangan & Bonus.
1Buat Periode2Lock Kehadiran3Hitung Draft4Review HR5Approve & Final6Posting Jurnal7Pembayaran
@@ -32,23 +33,31 @@ $statusLabels=array('draft'=>'Draft','reviewed'=>'Sudah Direview','approved'=>'D
Master KomponenKomponen tidak otomatis berlaku untuk semua karyawan.
${r.tanggal}
KodeKomponenJenisMetodeDefaultMappingStatus
code)?>name)?>component_type)?>calculation_method]??$c->calculation_method)?>Rp default_value??$c->fixed_amount,2,',','.')?>expense_account_id?:'mapping umum').' / '.($c->liability_account_id?:'mapping umum'))?>is_active?'Aktif':'Nonaktif'?>
-
Template PosisiOverride karyawan tetap memiliki prioritas di atas template posisi.
PosisiKomponenMetodeNilaiEfektifStatus
Belum ada template posisi.
position_name)?>component_code.' - '.$x->component_name)?>calculation_method]??$x->calculation_method)?>Rp amount_override,2,',','.')?>effective_from)?>effective_to)?' s.d. '.html_escape($x->effective_to):''?>is_active?'Aktif':'Nonaktif'?>
+
Template PosisiOverride karyawan tetap memiliki prioritas di atas template posisi.
PosisiKomponenMetodeNilaiEfektifStatus
Belum ada template posisi.
position_name)?>component_code.' - '.$x->component_name)?>calculation_method]??$x->calculation_method)?>Rp amount_override,2,',','.')?>effective_from)?>effective_to)?' s.d. '.html_escape($x->effective_to):''?>is_active?'Aktif':'Nonaktif'?>
Aturan KehadiranTerlambat secara default tidak dipotong. Cuti tahunan, sakit, izin, dan hari libur tidak dianggap alpa.
AturanKondisiMetodeToleransiTarifKomponenEfektif
rule_name)?>condition_type)?>calculation_method)?>tolerance_minutes)?> menitrate_value,2,',','.')?>component_name?:'Tidak memotong')?>effective_from)?>
Pengaturan Umum PayrollBerlaku untuk perusahaan aktif dan tidak mengubah histori payroll lama.
Sumber KehadiranRentang tanggal pada periode payroll
Prioritas KomponenOverride karyawan → template posisi → nilai default
PengamananSnapshot, separation of duties, idempotency jurnal

Akun jurnal utama dikelola melalui System Account Mapping. Akun khusus dapat dipilih pada masing-masing komponen.

-
Tambah Penyesuaian Satu PeriodeTidak membuat jurnal saat disimpan dan tidak berulang ke periode berikutnya.
-
Daftar Penyesuaian
PeriodeKaryawanKomponenJenisNominalKeteranganStatusAksi
Belum ada penyesuaian periode.
period_name)?>full_name)?>employee_code)?>component_name)?>adjustment_type==='earning'?'Pendapatan':'Potongan')?>Rp amount,2,',','.')?>notes)?>workflow_status]??$a->workflow_status)?>workflow_status==='draft'):?>
-
+
Tambah Penyesuaian Satu PeriodeTidak membuat jurnal saat disimpan dan tidak berulang ke periode berikutnya.
+
Daftar Penyesuaian
PeriodeKaryawanKomponenJenisNominalKeteranganStatusAksi
Belum ada penyesuaian periode.
period_name)?>full_name)?>employee_code)?>component_name)?>adjustment_type==='earning'?'Pendapatan':'Potongan')?>Rp amount,2,',','.')?>notes)?>workflow_status]??$a->workflow_status)?>can_manage)&&$a->workflow_status==='draft'):?>
-
- + diff --git a/application/views/partials/header.php b/application/views/partials/header.php index 71b4b64..7c272f1 100644 --- a/application/views/partials/header.php +++ b/application/views/partials/header.php @@ -10,6 +10,7 @@ $CI->config->load('ui_assets',true); $uiAssets=$CI->config->item('ui_assets'); $dataPages=isset($uiAssets['ui_data_pages'])?$uiAssets['ui_data_pages']:array(); $selectPages=isset($uiAssets['ui_select_pages'])?$uiAssets['ui_select_pages']:array(); +$loadDataTables=!empty($use_datatables)||in_array($active_menu,$dataPages,true); $userName=$this->session->userdata('nama')?:$this->session->userdata('username')?:'Pengguna'; $userRole=$this->session->userdata('role')?:'User'; $userPhoto=$this->session->userdata('profile_photo'); @@ -19,11 +20,11 @@ $userPhoto=$this->session->userdata('profile_photo'); <?=html_escape($page_title)?> · Accounting - + - + diff --git a/application/views/peralatan_teknisi/log_barang.php b/application/views/peralatan_teknisi/log_barang.php index a2f7b98..ccf8312 100644 --- a/application/views/peralatan_teknisi/log_barang.php +++ b/application/views/peralatan_teknisi/log_barang.php @@ -2,10 +2,14 @@ $inventory_title='Log Peralatan Teknisi'; $inventory_subtitle='Telusuri barang yang dibawa, dipasang pada customer, atau dikembalikan oleh teknisi.'; $perm=$equipment_permissions??array(); -$active_panel='history'; +$active_panel=$active_panel??'history'; +$initialFilters=$initial_filters??array(); +$initialStatus=(string)($initialFilters['status']??''); +$initialCondition=(string)($initialFilters['condition']??''); $this->load->view('inventory_professional/_module_nav',get_defined_vars()); ?> +
load->view('peralatan_teknisi/_module_nav',get_defined_vars()); ?>
Filter Riwayat Peralatan

Tanggal mencakup tanggal dibawa, dipasang, atau dikembalikan.

Kembali
@@ -14,14 +18,15 @@ $this->load->view('inventory_professional/_module_nav',get_defined_vars());
-
-
+
+
NoDokumenTeknisiBarangBarcode / SNPemilikQtyCustomerPosisiStatusKondisiKeteranganAksi
+