From ff870b76678c1d7e1430fe811fe53067109f32d1 Mon Sep 17 00:00:00 2001 From: Wian Drs Date: Fri, 11 Sep 2026 18:06:17 +0700 Subject: [PATCH] update koneksi DB --- application/config/accounting.php | 2 +- application/config/config.php | 8 +- application/config/database.php | 10 +-- application/controllers/Accountingrepair.php | 2 +- application/controllers/AttendanceWebhook.php | 2 +- application/controllers/Companies.php | 68 +++++++++++++++-- application/controllers/Databasesync.php | 2 +- application/controllers/Dbcompare.php | 12 +-- application/controllers/Inventoryrepair.php | 6 +- application/controllers/Invoices.php | 12 ++- .../controllers/Journaldescriptionrepair.php | 2 +- application/controllers/Queueworker.php | 2 +- application/controllers/Sales.php | 4 +- application/libraries/AuditService.php | 2 +- application/libraries/CashBankService.php | 2 +- application/libraries/DatabaseSyncService.php | 23 +++--- .../libraries/JournalDescriptionService.php | 4 + application/libraries/NavigationService.php | 2 +- application/libraries/PayrollService.php | 2 +- .../ReceivableAdjustmentsService.php | 8 +- application/libraries/ReceivableService.php | 8 +- application/libraries/SalesService.php | 71 +++++++++++------- ...911000800_add_company_invoice_settings.php | 42 +++++++++++ application/views/companies/index.php | 28 ++++++- application/views/invoices/editor.php | 2 +- application/views/sales/invoice_pdf.php | 65 ++++++++++++---- assets/css/company-settings.css | 1 + assets/js/invoices-professional.js | 69 ++++++++++++++--- index.php | 44 ++++++++++- .../196a1400bb1f4fd04de9297f30d90798.jpg | Bin 0 -> 22383 bytes 30 files changed, 390 insertions(+), 115 deletions(-) create mode 100644 application/migrations/20260911000800_add_company_invoice_settings.php create mode 100644 assets/css/company-settings.css create mode 100644 uploads/accounting/company/196a1400bb1f4fd04de9297f30d90798.jpg diff --git a/application/config/accounting.php b/application/config/accounting.php index 4d73fe8..a64a43b 100644 --- a/application/config/accounting.php +++ b/application/config/accounting.php @@ -72,6 +72,6 @@ $config['document_numbering'] = array( ); $config['require_separate_journal_approver'] = filter_var( - getenv('WORKFLOW_REQUIRE_SEPARATE_APPROVER') ?: 'false', + app_env('WORKFLOW_REQUIRE_SEPARATE_APPROVER', 'false'), FILTER_VALIDATE_BOOLEAN ); diff --git a/application/config/config.php b/application/config/config.php index 16b0829..fee412e 100644 --- a/application/config/config.php +++ b/application/config/config.php @@ -23,7 +23,7 @@ defined('BASEPATH') OR exit('No direct script access allowed'); | a PHP script and you can easily do that on your own. | */ -$config['base_url'] = rtrim(getenv('APP_BASE_URL') ?: 'http://localhost/accounting_dev/', '/') . '/'; +$config['base_url'] = rtrim(app_env('APP_BASE_URL', 'http://localhost/accounting_dev/') ?: 'http://localhost/accounting_dev/', '/') . '/'; /* @@ -327,7 +327,7 @@ $config['cache_query_string'] = FALSE; | https://codeigniter.com/userguide3/libraries/encryption.html | */ -$config['encryption_key'] = getenv('APP_ENCRYPTION_KEY') ?: 'development-only-change-this-key'; +$config['encryption_key'] = app_env('APP_ENCRYPTION_KEY', 'development-only-change-this-key') ?: 'development-only-change-this-key'; if (ENVIRONMENT === 'production' && $config['encryption_key'] === 'development-only-change-this-key') { show_error('APP_ENCRYPTION_KEY wajib dikonfigurasi pada environment production.', 500); @@ -388,7 +388,7 @@ if (ENVIRONMENT === 'production' && $config['encryption_key'] === 'development-o | except for 'cookie_prefix' and 'cookie_httponly', which are ignored here. | */ -$config['sess_driver'] = getenv('SESSION_DRIVER') ?: 'database'; +$config['sess_driver'] = app_env('SESSION_DRIVER', 'database') ?: 'database'; $config['sess_cookie_name'] = 'accounting_session'; $config['sess_samesite'] = 'Lax'; $config['sess_expiration'] = 7200; @@ -416,7 +416,7 @@ $config['sess_regenerate_destroy'] = FALSE; $config['cookie_prefix'] = 'accounting_'; $config['cookie_domain'] = ''; $config['cookie_path'] = '/'; -$config['cookie_secure'] = filter_var(getenv('COOKIE_SECURE') ?: (ENVIRONMENT === 'production' ? 'true' : 'false'), FILTER_VALIDATE_BOOLEAN); +$config['cookie_secure'] = filter_var(app_env('COOKIE_SECURE', ENVIRONMENT === 'production' ? 'true' : 'false'), FILTER_VALIDATE_BOOLEAN); $config['cookie_httponly'] = TRUE; $config['cookie_samesite'] = 'Lax'; diff --git a/application/config/database.php b/application/config/database.php index f038532..ccf802a 100644 --- a/application/config/database.php +++ b/application/config/database.php @@ -75,11 +75,11 @@ $query_builder = TRUE; $db['default'] = array( 'dsn' => '', - 'hostname' => getenv('DB_HOST') ?: '127.0.0.1', - 'port' => (int) (getenv('DB_PORT') ?: 3306), - 'username' => getenv('DB_USERNAME') ?: '', - 'password' => getenv('DB_PASSWORD') ?: '', - 'database' => getenv('DB_DATABASE') ?: '', + 'hostname' => app_env('DB_HOST', '127.0.0.1') ?: '127.0.0.1', + 'port' => (int) (app_env('DB_PORT', 3306) ?: 3306), + 'username' => app_env('DB_USERNAME', '') ?: '', + 'password' => app_env('DB_PASSWORD', '') ?: '', + 'database' => app_env('DB_DATABASE', '') ?: '', 'dbdriver' => 'mysqli', // WAJIB: driver PostgreSQL 'dbprefix' => '', diff --git a/application/controllers/Accountingrepair.php b/application/controllers/Accountingrepair.php index 714a265..61f1d33 100644 --- a/application/controllers/Accountingrepair.php +++ b/application/controllers/Accountingrepair.php @@ -6,7 +6,7 @@ class Accountingrepair extends CI_Controller public function __construct() { parent::__construct(); if(!is_cli()) show_404(); $this->load->library('AuditService'); } public function duplicate_refs() { - if (strtolower((string)getenv('APP_ENV')) === 'production') { echo "Ditolak: repair ini hanya untuk development.\n"; return; } + if (strtolower((string)app_env('APP_ENV', ENVIRONMENT)) === 'production') { echo "Ditolak: repair ini hanya untuk development.\n"; return; } $groups=$this->db->select('no_ref, COUNT(*) total')->from('journals')->group_by('no_ref')->having('COUNT(*) >',1)->get()->result(); $changed=0; $this->db->trans_begin(); try { diff --git a/application/controllers/AttendanceWebhook.php b/application/controllers/AttendanceWebhook.php index f159db2..ecbd9e7 100644 --- a/application/controllers/AttendanceWebhook.php +++ b/application/controllers/AttendanceWebhook.php @@ -20,7 +20,7 @@ class AttendanceWebhook extends CI_Controller { show_error('Serial number perangkat tidak valid.', 400); } - $allowedSerials = array_filter(array_map('trim', explode(',', (string) getenv('ATTENDANCE_ALLOWED_SERIALS')))); + $allowedSerials = array_filter(array_map('trim', explode(',', (string) app_env('ATTENDANCE_ALLOWED_SERIALS', '')))); if (!empty($allowedSerials) && !in_array($sn, $allowedSerials, true)) { log_message('error', 'Attendance webhook ditolak untuk SN: ' . $sn); show_error('Perangkat tidak terdaftar.', 403); diff --git a/application/controllers/Companies.php b/application/controllers/Companies.php index 2390f53..741c096 100644 --- a/application/controllers/Companies.php +++ b/application/controllers/Companies.php @@ -1,11 +1,67 @@ load->database();$this->load->library(array('CompanyContext','MultiCurrencyService'));} - public function index(){$d=array('active_menu'=>'companies');$d['companies']=$this->db->get('companies')->result();$d['currencies']=$this->db->where('is_active',1)->get('currencies')->result();$d['rates']=$this->db->select('r.*,c.name company_name')->from('exchange_rates r')->join('companies c','c.id=r.company_id')->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');} - public function switch_company($id){$this->companycontext->assertAccess($id);$this->session->set_userdata('company_id',(int)$id);redirect('professionalreports');} - public function save(){$this->db->insert('companies',array('company_code'=>strtoupper($this->input->post('code',true)),'name'=>$this->input->post('name',true),'legal_name'=>$this->input->post('legal_name',true),'tax_number'=>$this->input->post('tax_number',true),'base_currency'=>$this->input->post('base_currency'),'is_active'=>1,'created_at'=>date('Y-m-d H:i:s')));$id=$this->db->insert_id();$this->db->insert('user_companies',array('user_id'=>$this->session->userdata('user_id'),'company_id'=>$id,'is_default'=>0,'can_consolidate'=>1));redirect('companies');} - public function rate(){$this->db->insert('exchange_rates',array('company_id'=>$this->companycontext->id(),'rate_date'=>$this->input->post('date'),'from_currency'=>$this->input->post('from_currency'),'to_currency'=>$this->input->post('to_currency'),'rate'=>$this->input->post('rate'),'source'=>$this->input->post('source',true),'created_by'=>$this->session->userdata('user_id'),'created_at'=>date('Y-m-d H:i:s')));redirect('companies');} - public function revalue(){try{$this->multicurrencyservice->revalue($this->input->post('date'),$this->session->userdata('user_id'));$this->session->set_flashdata('success','Revaluasi kurs berhasil diposting.');}catch(Throwable$e){$this->session->set_flashdata('error',$e->getMessage());}redirect('companies');} + public function __construct(){parent::__construct();$this->load->database();$this->load->library(array('CompanyContext','MultiCurrencyService'));} + + public function index() + { + $companyId=(int)$this->companycontext->id(); + $d=array('active_menu'=>'companies'); + $d['active_company']=$this->db->get_where('companies',array('id'=>$companyId))->row(); + $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['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); + } + + public function switch_company($id){$this->companycontext->assertAccess($id);$this->session->set_userdata('company_id',(int)$id);redirect('companies');} + + public function update() + { + $this->postOnly();$id=(int)$this->companycontext->id();$company=$this->db->get_where('companies',array('id'=>$id))->row();if(!$company)show_404(); + try{ + $name=trim((string)$this->input->post('name',true));$currency=strtoupper(trim((string)$this->input->post('base_currency',true)));if($name==='')throw new RuntimeException('Nama perusahaan wajib diisi.');if(!$this->db->get_where('currencies',array('code'=>$currency,'is_active'=>1))->row())throw new RuntimeException('Mata uang dasar tidak valid.'); + $logo=$this->uploadLogo((string)($company->logo_path??'')); + $this->db->where('id',$id)->update('companies',array('name'=>$name,'legal_name'=>trim((string)$this->input->post('legal_name',true)),'tax_number'=>trim((string)$this->input->post('tax_number',true)),'address'=>trim((string)$this->input->post('address',true)),'phone'=>trim((string)$this->input->post('phone',true)),'email'=>trim((string)$this->input->post('email',true)),'logo_path'=>$logo,'base_currency'=>$currency)); + $this->session->set_flashdata('success','Company Setting berhasil diperbarui.'); + }catch(Throwable$e){$this->session->set_flashdata('error',$e->getMessage());} + redirect('companies'); + } + + public function save() + { + $this->postOnly();try{$code=strtoupper(trim((string)$this->input->post('code',true)));$name=trim((string)$this->input->post('name',true));$currency=strtoupper(trim((string)$this->input->post('base_currency',true)));if($code===''||$name==='')throw new RuntimeException('Kode dan nama perusahaan wajib diisi.');if($this->db->get_where('companies',array('company_code'=>$code))->row())throw new RuntimeException('Kode perusahaan sudah digunakan.');$this->db->insert('companies',array('company_code'=>$code,'name'=>$name,'legal_name'=>trim((string)$this->input->post('legal_name',true)),'tax_number'=>trim((string)$this->input->post('tax_number',true)),'address'=>trim((string)$this->input->post('address',true)),'phone'=>trim((string)$this->input->post('phone',true)),'email'=>trim((string)$this->input->post('email',true)),'base_currency'=>$currency?:'IDR','is_active'=>1,'created_at'=>date('Y-m-d H:i:s')));$id=(int)$this->db->insert_id();$this->db->insert('user_companies',array('user_id'=>$this->session->userdata('user_id'),'company_id'=>$id,'is_default'=>0,'can_consolidate'=>1));$this->session->set_flashdata('success','Perusahaan baru berhasil ditambahkan.');}catch(Throwable$e){$this->session->set_flashdata('error',$e->getMessage());}redirect('companies'); + } + + 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'); + } + + 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'); + } + + public function save_currency() + { + $this->postOnly();try{$code=strtoupper(trim((string)$this->input->post('code',true)));$name=trim((string)$this->input->post('name',true));$symbol=trim((string)$this->input->post('symbol',true));$decimals=max(0,min(6,(int)$this->input->post('decimal_places')));if(!preg_match('/^[A-Z]{3}$/',$code)||$name==='')throw new RuntimeException('Kode mata uang harus tiga huruf dan nama wajib diisi.');$row=array('name'=>$name,'symbol'=>$symbol,'decimal_places'=>$decimals,'is_active'=>$this->input->post('is_active')?1:0);if($this->db->get_where('currencies',array('code'=>$code))->row())$this->db->where('code',$code)->update('currencies',$row);else{$row['code']=$code;$this->db->insert('currencies',$row);}$this->session->set_flashdata('success','Pengaturan mata uang berhasil disimpan.');}catch(Throwable$e){$this->session->set_flashdata('error',$e->getMessage());}redirect('companies'); + } + + public function rate() + { + $this->postOnly();try{$rate=(float)$this->input->post('rate');$from=strtoupper(trim((string)$this->input->post('from_currency',true)));$to=strtoupper(trim((string)$this->input->post('to_currency',true)));if($rate<=0||$from===''||$to===''||$from===$to)throw new RuntimeException('Pasangan mata uang dan nilai kurs tidak valid.');$this->db->insert('exchange_rates',array('company_id'=>$this->companycontext->id(),'rate_date'=>$this->input->post('date'),'from_currency'=>$from,'to_currency'=>$to,'rate'=>$rate,'source'=>$this->input->post('source',true),'created_by'=>$this->session->userdata('user_id'),'created_at'=>date('Y-m-d H:i:s')));$this->session->set_flashdata('success','Kurs berhasil disimpan.');}catch(Throwable$e){$this->session->set_flashdata('error',$e->getMessage());}redirect('companies'); + } + + public function revalue(){try{$this->multicurrencyservice->revalue($this->input->post('date'),$this->session->userdata('user_id'));$this->session->set_flashdata('success','Revaluasi kurs berhasil diposting.');}catch(Throwable$e){$this->session->set_flashdata('error',$e->getMessage());}redirect('companies');} + + private function uploadLogo($current) + { + 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);} } diff --git a/application/controllers/Databasesync.php b/application/controllers/Databasesync.php index 0c7f133..ca2d26a 100644 --- a/application/controllers/Databasesync.php +++ b/application/controllers/Databasesync.php @@ -16,7 +16,7 @@ class Databasesync extends CI_Controller if (!$this->input->is_cli_request()) { show_error('Database sync hanya dapat dijalankan melalui CLI.', 403, 'Akses ditolak'); } - if (!filter_var(getenv('DBSYNC_ENABLED') ?: 'false', FILTER_VALIDATE_BOOLEAN)) { + if (!filter_var(app_env('DBSYNC_ENABLED', 'false'), FILTER_VALIDATE_BOOLEAN)) { fwrite(STDERR, "DBSYNC_ENABLED belum diaktifkan.\n"); exit(2); } diff --git a/application/controllers/Dbcompare.php b/application/controllers/Dbcompare.php index 5b5e856..624be61 100644 --- a/application/controllers/Dbcompare.php +++ b/application/controllers/Dbcompare.php @@ -10,7 +10,7 @@ class Dbcompare extends MY_Admin_Controller { parent::__construct(); - if (!filter_var(getenv('DBCOMPARE_ENABLED') ?: 'false', FILTER_VALIDATE_BOOLEAN)) { + if (!filter_var(app_env('DBCOMPARE_ENABLED', 'false'), FILTER_VALIDATE_BOOLEAN)) { show_404(); } @@ -21,11 +21,11 @@ class Dbcompare extends MY_Admin_Controller private function readConnectionConfig($prefix) { $config = array( - 'host' => getenv($prefix . '_HOST'), - 'port' => (int) getenv($prefix . '_PORT'), - 'user' => getenv($prefix . '_USERNAME'), - 'pass' => getenv($prefix . '_PASSWORD'), - 'db' => getenv($prefix . '_DATABASE') + 'host' => app_env($prefix . '_HOST', ''), + 'port' => (int) app_env($prefix . '_PORT', 3306), + 'user' => app_env($prefix . '_USERNAME', ''), + 'pass' => app_env($prefix . '_PASSWORD', ''), + 'db' => app_env($prefix . '_DATABASE', '') ); foreach (array('host', 'port', 'user', 'pass', 'db') as $key) { diff --git a/application/controllers/Inventoryrepair.php b/application/controllers/Inventoryrepair.php index ce7fb0f..202ffeb 100644 --- a/application/controllers/Inventoryrepair.php +++ b/application/controllers/Inventoryrepair.php @@ -3,7 +3,7 @@ defined('BASEPATH') OR exit('No direct script access allowed'); class Inventoryrepair extends CI_Controller { public function __construct(){parent::__construct();if(!is_cli())show_404();$this->load->library('ProfessionalInventoryService');} - public function negative_opening(){if(strtolower((string)getenv('APP_ENV'))==='production'){echo"Ditolak: repair khusus Development.\n";return;}$rows=$this->db->query("SELECT item_id,warehouse_id,SUM(IF(direction='in',qty,-qty)) qty FROM inventory_ledger GROUP BY item_id,warehouse_id HAVING qty<-.0001")->result();$user=$this->db->select('users.id')->from('users')->join('roles','roles.id=users.role_id')->where('roles.nama_role','Admin')->order_by('users.id')->get()->row();if(!$user)throw new RuntimeException('User Admin tidak tersedia.');foreach($rows as$r){$item=$this->db->get_where('items',array('id'=>$r->item_id))->row();$key='DEV-NEGATIVE-OPENING-'.$r->item_id.'-'.$r->warehouse_id;$id=$this->professionalinventoryservice->createDocument(array('type'=>'opening','date'=>date('Y-m-d'),'from_warehouse_id'=>null,'to_warehouse_id'=>$r->warehouse_id,'item_id'=>$r->item_id,'barcode_id'=>null,'batch_id'=>null,'from_bin_id'=>null,'to_bin_id'=>null,'qty'=>abs($r->qty),'unit_cost'=>$item->harga_beli,'reason'=>'Koreksi saldo negatif legacy hasil backfill Tahap 7','idempotency_key'=>$key),$user->id);$doc=$this->db->get_where('stock_documents',array('id'=>$id))->row();if($doc->status==='draft')$this->professionalinventoryservice->submit($id,$user->id);$doc=$this->db->get_where('stock_documents',array('id'=>$id))->row();if($doc->status==='approved')$this->professionalinventoryservice->post($id,$user->id);echo'Corrected item '.$r->item_id.' warehouse '.$r->warehouse_id.' qty '.abs($r->qty)."\n";}} - public function reconcile_gl(){if(strtolower((string)getenv('APP_ENV'))==='production'){echo"Ditolak: repair khusus Development.\n";return;}$existing=$this->db->where('ref_type','inventory_reconciliation')->where('ref_code','DEV-STAGE7-BASELINE')->get('journals')->row();if($existing){echo"Baseline reconciliation sudah tersedia.\n";return;}$ledger=(float)$this->db->query("SELECT COALESCE(SUM(IF(direction='in',value,-value)),0) total FROM inventory_ledger")->row()->total;$inventory=$this->db->get_where('system_account_mappings',array('mapping_key'=>'inventory','is_active'=>1))->row()->account_id;$gl=(float)$this->db->query("SELECT COALESCE(SUM(d.debit-d.kredit),0) total FROM journal_details d JOIN journals j ON j.id=d.journal_id WHERE d.account_id=? AND j.status IN('posted','reversed')",array($inventory))->row()->total;$diff=round($ledger-$gl,2);if(abs($diff)<.01){echo"Ledger dan GL sudah balance.\n";return;}$user=$this->db->select('users.id')->from('users')->join('roles','roles.id=users.role_id')->where('roles.nama_role','Admin')->order_by('users.id')->get()->row();$other=$this->db->get_where('system_account_mappings',array('mapping_key'=>$diff>0?'inventory_adjustment_gain':'inventory_adjustment_loss','is_active'=>1))->row()->account_id;$this->load->library('PostingService');$lines=$diff>0?array(array('account_id'=>$inventory,'debit'=>$diff,'kredit'=>0),array('account_id'=>$other,'debit'=>0,'kredit'=>$diff)):array(array('account_id'=>$other,'debit'=>abs($diff),'kredit'=>0),array('account_id'=>$inventory,'debit'=>0,'kredit'=>abs($diff)));$id=$this->postingservice->post(array('tanggal'=>date('Y-m-d'),'no_ref'=>'INV-RECON-STAGE7-'.date('Ymd'),'keterangan'=>'Baseline rekonsiliasi inventory ledger legacy Tahap 7','ref_type'=>'inventory_reconciliation','ref_id'=>1,'created_by'=>$user->id),$lines,true,true);$this->db->where('id',$id)->update('journals',array('ref_code'=>'DEV-STAGE7-BASELINE'));$this->db->insert('inventory_reconciliations',array('as_of_date'=>date('Y-m-d'),'ledger_value'=>$ledger,'gl_value'=>$ledger,'difference'=>0,'status'=>'resolved','notes'=>'Development baseline; selisih awal '.number_format($diff,2,'.',''),'created_by'=>$user->id,'created_at'=>date('Y-m-d H:i:s')));echo'Jurnal rekonsiliasi dibuat: '.number_format($diff,2,'.','')."\n";} - public function sync_cache(){if(strtolower((string)getenv('APP_ENV'))==='production'){echo"Ditolak: repair khusus Development.\n";return;}$rows=$this->db->query("SELECT i.id,COALESCE(SUM(IF(l.direction='in',l.qty,-l.qty)),0) qty FROM items i LEFT JOIN inventory_ledger l ON l.item_id=i.id GROUP BY i.id")->result();$changed=0;foreach($rows as$r){$item=$this->db->get_where('items',array('id'=>$r->id))->row();if(abs((float)$item->stok-(float)$r->qty)>.0001){$this->db->where('id',$r->id)->update('items',array('stok'=>$r->qty));$changed++;}}echo"Selesai: {$changed} cache stok disinkronkan.\n";} + public function negative_opening(){if(strtolower((string)app_env('APP_ENV',ENVIRONMENT))==='production'){echo"Ditolak: repair khusus Development.\n";return;}$rows=$this->db->query("SELECT item_id,warehouse_id,SUM(IF(direction='in',qty,-qty)) qty FROM inventory_ledger GROUP BY item_id,warehouse_id HAVING qty<-.0001")->result();$user=$this->db->select('users.id')->from('users')->join('roles','roles.id=users.role_id')->where('roles.nama_role','Admin')->order_by('users.id')->get()->row();if(!$user)throw new RuntimeException('User Admin tidak tersedia.');foreach($rows as$r){$item=$this->db->get_where('items',array('id'=>$r->item_id))->row();$key='DEV-NEGATIVE-OPENING-'.$r->item_id.'-'.$r->warehouse_id;$id=$this->professionalinventoryservice->createDocument(array('type'=>'opening','date'=>date('Y-m-d'),'from_warehouse_id'=>null,'to_warehouse_id'=>$r->warehouse_id,'item_id'=>$r->item_id,'barcode_id'=>null,'batch_id'=>null,'from_bin_id'=>null,'to_bin_id'=>null,'qty'=>abs($r->qty),'unit_cost'=>$item->harga_beli,'reason'=>'Koreksi saldo negatif legacy hasil backfill Tahap 7','idempotency_key'=>$key),$user->id);$doc=$this->db->get_where('stock_documents',array('id'=>$id))->row();if($doc->status==='draft')$this->professionalinventoryservice->submit($id,$user->id);$doc=$this->db->get_where('stock_documents',array('id'=>$id))->row();if($doc->status==='approved')$this->professionalinventoryservice->post($id,$user->id);echo'Corrected item '.$r->item_id.' warehouse '.$r->warehouse_id.' qty '.abs($r->qty)."\n";}} + public function reconcile_gl(){if(strtolower((string)app_env('APP_ENV',ENVIRONMENT))==='production'){echo"Ditolak: repair khusus Development.\n";return;}$existing=$this->db->where('ref_type','inventory_reconciliation')->where('ref_code','DEV-STAGE7-BASELINE')->get('journals')->row();if($existing){echo"Baseline reconciliation sudah tersedia.\n";return;}$ledger=(float)$this->db->query("SELECT COALESCE(SUM(IF(direction='in',value,-value)),0) total FROM inventory_ledger")->row()->total;$inventory=$this->db->get_where('system_account_mappings',array('mapping_key'=>'inventory','is_active'=>1))->row()->account_id;$gl=(float)$this->db->query("SELECT COALESCE(SUM(d.debit-d.kredit),0) total FROM journal_details d JOIN journals j ON j.id=d.journal_id WHERE d.account_id=? AND j.status IN('posted','reversed')",array($inventory))->row()->total;$diff=round($ledger-$gl,2);if(abs($diff)<.01){echo"Ledger dan GL sudah balance.\n";return;}$user=$this->db->select('users.id')->from('users')->join('roles','roles.id=users.role_id')->where('roles.nama_role','Admin')->order_by('users.id')->get()->row();$other=$this->db->get_where('system_account_mappings',array('mapping_key'=>$diff>0?'inventory_adjustment_gain':'inventory_adjustment_loss','is_active'=>1))->row()->account_id;$this->load->library('PostingService');$lines=$diff>0?array(array('account_id'=>$inventory,'debit'=>$diff,'kredit'=>0),array('account_id'=>$other,'debit'=>0,'kredit'=>$diff)):array(array('account_id'=>$other,'debit'=>abs($diff),'kredit'=>0),array('account_id'=>$inventory,'debit'=>0,'kredit'=>abs($diff)));$id=$this->postingservice->post(array('tanggal'=>date('Y-m-d'),'no_ref'=>'INV-RECON-STAGE7-'.date('Ymd'),'keterangan'=>'Baseline rekonsiliasi inventory ledger legacy Tahap 7','ref_type'=>'inventory_reconciliation','ref_id'=>1,'created_by'=>$user->id),$lines,true,true);$this->db->where('id',$id)->update('journals',array('ref_code'=>'DEV-STAGE7-BASELINE'));$this->db->insert('inventory_reconciliations',array('as_of_date'=>date('Y-m-d'),'ledger_value'=>$ledger,'gl_value'=>$ledger,'difference'=>0,'status'=>'resolved','notes'=>'Development baseline; selisih awal '.number_format($diff,2,'.',''),'created_by'=>$user->id,'created_at'=>date('Y-m-d H:i:s')));echo'Jurnal rekonsiliasi dibuat: '.number_format($diff,2,'.','')."\n";} + public function sync_cache(){if(strtolower((string)app_env('APP_ENV',ENVIRONMENT))==='production'){echo"Ditolak: repair khusus Development.\n";return;}$rows=$this->db->query("SELECT i.id,COALESCE(SUM(IF(l.direction='in',l.qty,-l.qty)),0) qty FROM items i LEFT JOIN inventory_ledger l ON l.item_id=i.id GROUP BY i.id")->result();$changed=0;foreach($rows as$r){$item=$this->db->get_where('items',array('id'=>$r->id))->row();if(abs((float)$item->stok-(float)$r->qty)>.0001){$this->db->where('id',$r->id)->update('items',array('stok'=>$r->qty));$changed++;}}echo"Selesai: {$changed} cache stok disinkronkan.\n";} } diff --git a/application/controllers/Invoices.php b/application/controllers/Invoices.php index c5be185..90195d4 100644 --- a/application/controllers/Invoices.php +++ b/application/controllers/Invoices.php @@ -34,7 +34,7 @@ class Invoices extends MY_Controller { $draw=(int)$this->input->get_post('draw');$start=max(0,(int)$this->input->get_post('start'));$length=max(10,min(100,(int)($this->input->get_post('length')?:25)));$search=$this->input->get_post('search');$term=is_array($search)?trim((string)($search['value']??'')):trim((string)$search); $this->invoiceQuery(false,'');$recordsTotal=(int)$this->db->count_all_results();$this->invoiceQuery(true,$term);$recordsFiltered=(int)$this->db->count_all_results();$this->invoiceQuery(true,$term); - $rows=$this->db->select("i.id,i.no_invoice,i.tanggal,i.jatuh_tempo,i.recognition_policy,i.total,i.total_bayar,i.sisa_piutang,i.status,i.workflow_status,i.delivery_status,i.created_by,c.nama customer_name,u.nama creator_name,(SELECT dx.line_type FROM invoice_details dx WHERE dx.invoice_id=i.id AND dx.company_id=i.company_id ORDER BY dx.id LIMIT 1) invoice_purpose,(SELECT COUNT(*) FROM invoice_details dx WHERE dx.invoice_id=i.id AND dx.company_id=i.company_id AND dx.line_type='inventory') inventory_line_count,(SELECT COUNT(*) FROM invoice_details dx WHERE dx.invoice_id=i.id AND dx.company_id=i.company_id AND dx.line_type='inventory' AND dx.delivery_line_id IS NULL) pending_delivery_count",false)->order_by('i.id','DESC')->limit($length,$start)->get()->result(); + $rows=$this->db->select("i.id,i.no_invoice,i.tanggal,i.jatuh_tempo,i.recognition_policy,i.total,i.total_bayar,i.sisa_piutang,i.status,i.workflow_status,i.delivery_status,i.created_by,c.nama customer_name,u.nama creator_name,(CASE WHEN EXISTS(SELECT 1 FROM invoice_details ds WHERE ds.invoice_id=i.id AND ds.company_id=i.company_id AND ds.line_type='savings') AND EXISTS(SELECT 1 FROM invoice_details dx WHERE dx.invoice_id=i.id AND dx.company_id=i.company_id AND dx.line_type IN('inventory','service')) THEN 'mixed' WHEN EXISTS(SELECT 1 FROM invoice_details ds WHERE ds.invoice_id=i.id AND ds.company_id=i.company_id AND ds.line_type='savings') THEN 'savings' ELSE (SELECT dx.line_type FROM invoice_details dx WHERE dx.invoice_id=i.id AND dx.company_id=i.company_id ORDER BY dx.id LIMIT 1) END) invoice_purpose,(SELECT COUNT(*) FROM invoice_details dx WHERE dx.invoice_id=i.id AND dx.company_id=i.company_id AND dx.line_type='inventory') inventory_line_count,(SELECT COUNT(*) FROM invoice_details dx WHERE dx.invoice_id=i.id AND dx.company_id=i.company_id AND dx.line_type='inventory' AND dx.delivery_line_id IS NULL) pending_delivery_count",false)->order_by('i.id','DESC')->limit($length,$start)->get()->result(); return json_response(true,'Daftar invoice ditemukan.',array('draw'=>$draw,'recordsTotal'=>$recordsTotal,'recordsFiltered'=>$recordsFiltered,'data'=>$rows)); } private function invoiceQuery($filters,$term) @@ -110,7 +110,15 @@ class Invoices extends MY_Controller public function delete_draft($id){return$this->mutation('can_delete',function()use($id){return$this->salesservice->deleteDraftInvoice($id,$this->uid(),is_master_admin_user());},'Draft invoice dibatalkan dan seluruh reservasi dilepas.');} public function pdf($id) { - $this->requirePermission('invoices','can_export');$this->guardView($this->salesservice->invoiceForEditor($id));$data=$this->salesservice->invoiceDetail($id);$data['pdf_company']=$this->db->get_where('companies',array('id'=>$this->company()))->row();require_once FCPATH.'vendor/autoload.php';$html=$this->load->view('sales/invoice_pdf',$data,true);$pdf=new Dompdf\Dompdf();$pdf->loadHtml($html);$pdf->setPaper('A4','portrait');$pdf->render();$pdf->stream('Invoice-'.$data['invoice']->no_invoice.'.pdf',array('Attachment'=>false)); + $this->requirePermission('invoices','can_export');$this->guardView($this->salesservice->invoiceForEditor($id));$data=$this->salesservice->invoiceDetail($id);$includePrevious=(int)$this->input->get('include_previous')===1;$data['previous_invoices']=$includePrevious?$this->salesservice->previousOutstandingInvoices($id):array();$this->addPdfCompanySettings($data);require_once FCPATH.'vendor/autoload.php';$html=$this->load->view('sales/invoice_pdf',$data,true);$pdf=new Dompdf\Dompdf();$pdf->loadHtml($html);$pdf->setPaper('A4','portrait');$pdf->render();$pdf->stream('Invoice-'.$data['invoice']->no_invoice.'.pdf',array('Attachment'=>false)); + } + public function pdf_options($id) + { + $this->requirePermission('invoices','can_export');try{$this->guardView($this->salesservice->invoiceForEditor($id));$rows=$this->salesservice->previousOutstandingInvoices($id);$total=0;foreach($rows as$row)$total+=(float)$row->sisa_piutang;return json_response(true,'Pilihan PDF ditemukan.',array('count'=>count($rows),'total'=>$total));}catch(Throwable$e){return business_exception_response($e);} + } + private function addPdfCompanySettings(array&$data) + { + $data['pdf_company']=$this->db->get_where('companies',array('id'=>$this->company()))->row();$currency=!empty($data['invoice']->transaction_currency)?$data['invoice']->transaction_currency:($data['pdf_company']->base_currency??'IDR');$data['pdf_currency']=$this->db->get_where('currencies',array('code'=>$currency))->row();$data['pdf_bank_accounts']=$this->db->table_exists('company_bank_accounts')?$this->db->where(array('company_id'=>$this->company(),'is_active'=>1))->order_by('is_primary','DESC')->order_by('id','ASC')->get('company_bank_accounts')->result():array();$data['pdf_logo_data']='';$path=$data['pdf_company']->logo_path??'';if($path!==''&&is_file(FCPATH.$path)){$mime=function_exists('mime_content_type')?mime_content_type(FCPATH.$path):'image/png';$data['pdf_logo_data']='data:'.$mime.';base64,'.base64_encode(file_get_contents(FCPATH.$path));} } private function mutation($permission,callable$callback,$message) { diff --git a/application/controllers/Journaldescriptionrepair.php b/application/controllers/Journaldescriptionrepair.php index af8ceca..1ede601 100644 --- a/application/controllers/Journaldescriptionrepair.php +++ b/application/controllers/Journaldescriptionrepair.php @@ -3,6 +3,6 @@ defined('BASEPATH') OR exit('No direct script access allowed'); class Journaldescriptionrepair extends CI_Controller { public function __construct(){parent::__construct();if(!is_cli())show_404();$this->load->library(array('JournalDescriptionService','AuditService'));} - public function run(){if(strtolower((string)getenv('APP_ENV'))==='production'){echo "Ditolak: backfill ini khusus Development.\n";return;}$rows=$this->db->where("COALESCE(ref_type,'') NOT IN ('','manual_journal')",null,false)->not_like('keterangan','[AUTO]','after')->order_by('id')->get('journals')->result();$count=0;$this->db->trans_begin();try{foreach($rows as$j){$lines=$this->db->select('account_id,debit,kredit')->where('journal_id',$j->id)->get('journal_details')->result_array();if(!$lines)continue;$new=$this->journaldescriptionservice->build((array)$j,$lines);$this->db->where('id',$j->id)->update('journals',array('keterangan'=>$new));$this->auditservice->record('development_repair','journal',$j->id,'enrich_automatic_description',array('keterangan'=>$j->keterangan),array('keterangan'=>$new),null);$count++;}if(!$this->db->trans_status())throw new RuntimeException('Backfill gagal.');$this->db->trans_commit();echo "Selesai: {$count} keterangan jurnal otomatis diperkaya.\n";}catch(Throwable$e){$this->db->trans_rollback();echo 'Gagal: '.$e->getMessage()."\n";exit(1);}} + public function run(){if(strtolower((string)app_env('APP_ENV',ENVIRONMENT))==='production'){echo "Ditolak: backfill ini khusus Development.\n";return;}$rows=$this->db->where("COALESCE(ref_type,'') NOT IN ('','manual_journal')",null,false)->not_like('keterangan','[AUTO]','after')->order_by('id')->get('journals')->result();$count=0;$this->db->trans_begin();try{foreach($rows as$j){$lines=$this->db->select('account_id,debit,kredit')->where('journal_id',$j->id)->get('journal_details')->result_array();if(!$lines)continue;$new=$this->journaldescriptionservice->build((array)$j,$lines);$this->db->where('id',$j->id)->update('journals',array('keterangan'=>$new));$this->auditservice->record('development_repair','journal',$j->id,'enrich_automatic_description',array('keterangan'=>$j->keterangan),array('keterangan'=>$new),null);$count++;}if(!$this->db->trans_status())throw new RuntimeException('Backfill gagal.');$this->db->trans_commit();echo "Selesai: {$count} keterangan jurnal otomatis diperkaya.\n";}catch(Throwable$e){$this->db->trans_rollback();echo 'Gagal: '.$e->getMessage()."\n";exit(1);}} public function verify(){$missing=$this->db->where("COALESCE(ref_type,'') NOT IN ('','manual_journal')",null,false)->not_like('keterangan','[AUTO]','after')->count_all_results('journals');echo($missing?'[FAIL] ':'[OK] ').'automatic_descriptions_without_standard='.$missing."\n";if($missing)exit(1);} } diff --git a/application/controllers/Queueworker.php b/application/controllers/Queueworker.php index cbb18ff..22027af 100644 --- a/application/controllers/Queueworker.php +++ b/application/controllers/Queueworker.php @@ -7,7 +7,7 @@ class Queueworker extends CI_Controller private function handle($type,array$p,$job){ if($type==='asset_depreciation'){$this->load->library('FixedAssetService');$this->fixedassetservice->depreciate((int)$p['asset_id'],$p['through'],(int)$p['user_id'],true);return;} if($type==='payroll_calculation'){$this->load->library('PayrollService');$this->payrollservice->calculate($p['month'],(int)$p['user_id']);return;} - if($type==='email'||$type==='reminder'){$this->load->library('email');$this->email->from($p['from']??getenv('MAIL_FROM'),$p['from_name']??'Accounting');$this->email->to($p['to']);$this->email->subject($p['subject']);$this->email->message($p['message']);if(!$this->email->send(false))throw new RuntimeException('Email gagal dikirim: '.$this->email->print_debugger(array('headers')));return;} + if($type==='email'||$type==='reminder'){$this->load->library('email');$this->email->from($p['from']??app_env('MAIL_FROM',''),$p['from_name']??'Accounting');$this->email->to($p['to']);$this->email->subject($p['subject']);$this->email->message($p['message']);if(!$this->email->send(false))throw new RuntimeException('Email gagal dikirim: '.$this->email->print_debugger(array('headers')));return;} if($type!=='report_export')throw new RuntimeException('Handler job belum tersedia: '.$type);$filters=$p['filters'];$dir=FCPATH.'uploads/accounting/reports/';if(!is_dir($dir)&&!mkdir($dir,0750,true))throw new RuntimeException('Folder export tidak dapat dibuat.');$format=in_array($p['format'],array('pdf','xlsx','excel'),true)?$p['format']:'csv';$file='RPT-'.$job->id.'-'.date('YmdHis').'.'.($format==='pdf'?'html':'csv');$rows=$this->db->query("SELECT j.tanggal,j.no_ref,j.keterangan,a.kode_akun,a.nama_akun,d.debit,d.kredit FROM journal_details d JOIN journals j ON j.id=d.journal_id JOIN accounts a ON a.id=d.account_id WHERE j.company_id=? AND j.status IN('posted','reversed') AND j.tanggal BETWEEN ? AND ? ORDER BY j.tanggal,j.id,d.id",array((int)$job->company_id,$filters['from'],$filters['to']))->result_array();$h=fopen($dir.$file,'w');if(!$h)throw new RuntimeException('File export tidak dapat ditulis.');if($format==='pdf')fwrite($h,'

'.html_escape($p['report_code']).'

'.html_escape(json_encode($rows,JSON_PRETTY_PRINT)).'
');elseif($rows){fputcsv($h,array_keys($rows[0]));foreach($rows as$r)fputcsv($h,$r);}fclose($h); } } diff --git a/application/controllers/Sales.php b/application/controllers/Sales.php index 9e104ec..4787b03 100644 --- a/application/controllers/Sales.php +++ b/application/controllers/Sales.php @@ -52,7 +52,7 @@ class Sales extends MY_Controller public function reject_return($id){return$this->action(function()use($id){return$this->salesservice->rejectReturn($id,$this->input->post('reason',true),$this->uid());},'Retur ditolak.');} public function receive_return($id){return$this->action(function()use($id){return$this->salesservice->receiveReturn($id,$this->uid());},'Barang retur diterima dan diperiksa.');} public function post_return($id){return$this->action(function()use($id){return$this->salesservice->postReturn($id,$this->uid());},'Retur, stok, dan credit note berhasil diposting.');} - public function invoice_pdf($id){$this->guardInvoice($this->salesservice->invoiceForEditor($id));$data=$this->salesservice->invoiceDetail($id);$this->pdf('sales/invoice_pdf',$data,'Invoice-'.$data['invoice']->no_invoice.'.pdf');} + public function invoice_pdf($id){$this->guardInvoice($this->salesservice->invoiceForEditor($id));$data=$this->salesservice->invoiceDetail($id);$data['previous_invoices']=(int)$this->input->get('include_previous')===1?$this->salesservice->previousOutstandingInvoices($id):array();$this->pdf('sales/invoice_pdf',$data,'Invoice-'.$data['invoice']->no_invoice.'.pdf');} public function delivery_pdf($id){$data=$this->salesservice->deliveryDetail($id);$this->pdf('sales/delivery_pdf',$data,'Surat-Jalan-'.$data['delivery']->delivery_no.'.pdf');} public function return_pdf($id){$data=$this->salesservice->returnDetail($id);$this->pdf('sales/return_pdf',$data,'Retur-'.$data['return']->return_no.'.pdf');} public function receipt_pdf($id) @@ -62,7 +62,7 @@ class Sales extends MY_Controller private function decodeRows(){$rows=json_decode((string)$this->input->post('rows'),true);if(!is_array($rows))throw new BusinessException('Detail barang tidak valid.');return$rows;} private function action($fn,$message){if(strtoupper($this->input->method())!=='POST')return json_response(false,'Metode tidak diizinkan.',array(),405);try{return json_response(true,$message,array('result'=>$fn()));}catch(Throwable$e){return business_exception_response($e);}} private function jsonDetail($fn){try{return json_response(true,'Detail ditemukan.',$fn());}catch(Throwable$e){return business_exception_response($e);}} - private function pdf($view,$data,$name){$data['pdf_company']=$this->db->get_where('companies',array('id'=>(int)$this->companycontext->id()))->row();require_once FCPATH.'vendor/autoload.php';$html=$this->load->view($view,$data,true);$pdf=new Dompdf\Dompdf();$pdf->loadHtml($html);$pdf->setPaper('A4','portrait');$pdf->render();$pdf->stream($name,array('Attachment'=>false));} + private function pdf($view,$data,$name){$company=(int)$this->companycontext->id();$data['pdf_company']=$this->db->get_where('companies',array('id'=>$company))->row();$currency=!empty($data['invoice']->transaction_currency)?$data['invoice']->transaction_currency:($data['pdf_company']->base_currency??'IDR');$data['pdf_currency']=$this->db->get_where('currencies',array('code'=>$currency))->row();$data['pdf_bank_accounts']=$this->db->table_exists('company_bank_accounts')?$this->db->where(array('company_id'=>$company,'is_active'=>1))->order_by('is_primary','DESC')->order_by('id')->get('company_bank_accounts')->result():array();$data['pdf_logo_data']='';$path=$data['pdf_company']->logo_path??'';if($path!==''&&is_file(FCPATH.$path)){$mime=function_exists('mime_content_type')?mime_content_type(FCPATH.$path):'image/png';$data['pdf_logo_data']='data:'.$mime.';base64,'.base64_encode(file_get_contents(FCPATH.$path));}require_once FCPATH.'vendor/autoload.php';$html=$this->load->view($view,$data,true);$pdf=new Dompdf\Dompdf();$pdf->loadHtml($html);$pdf->setPaper('A4','portrait');$pdf->render();$pdf->stream($name,array('Attachment'=>false));} private function uploads($field,$required=false) { if(empty($_FILES[$field])||empty($_FILES[$field]['name'])){if($required)throw new BusinessException('Bukti pembayaran gambar atau PDF wajib diunggah.');return array();}$files=$_FILES[$field];$names=is_array($files['name'])?$files['name']:array($files['name']);$saved=array();if(count($names)>10)throw new BusinessException('Maksimal 10 file.');$dir=FCPATH.'uploads/accounting/sales/';if(!is_dir($dir)&&!mkdir($dir,0755,true))throw new BusinessException('Folder lampiran tidak dapat dibuat.');$finfo=new finfo(FILEINFO_MIME_TYPE);$allowed=array('image/jpeg'=>'jpg','image/png'=>'png','image/webp'=>'webp','image/gif'=>'gif','application/pdf'=>'pdf'); diff --git a/application/libraries/AuditService.php b/application/libraries/AuditService.php index 1ae621d..5dc0372 100644 --- a/application/libraries/AuditService.php +++ b/application/libraries/AuditService.php @@ -16,7 +16,7 @@ class AuditService 'user_id'=>$userId ? (int)$userId : null, 'ip_address'=>isset($this->CI->input) ? $this->CI->input->ip_address() : null, 'user_agent'=>isset($this->CI->input) ? substr((string)$this->CI->input->user_agent(),0,255) : null, - 'request_id'=>function_exists('getenv') ? (getenv('HTTP_X_REQUEST_ID') ?: null) : null, + 'request_id'=>app_env('HTTP_X_REQUEST_ID', null) ?: null, 'previous_hash'=>$previous ? $previous->record_hash : null, 'created_at'=>$created ); $data['record_hash'] = hash('sha256', ($data['previous_hash'] ?: '') . json_encode($data, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES)); diff --git a/application/libraries/CashBankService.php b/application/libraries/CashBankService.php index de1cfd9..a1d6fb6 100644 --- a/application/libraries/CashBankService.php +++ b/application/libraries/CashBankService.php @@ -3,7 +3,7 @@ defined('BASEPATH') OR exit('No direct script access allowed'); class CashBankService { private $CI;public function __construct(){$this->CI=&get_instance();$this->CI->load->library(array('PostingService','FiscalPeriodService'));} - public function post($data,$uid){$db=$this->CI->db;$db->trans_begin();try{$date=$data['transaction_date'];$this->CI->fiscalperiodservice->assertOpen($date);$account=$db->query('SELECT * FROM cash_accounts WHERE id=? AND is_active=1 FOR UPDATE',array($data['cash_account_id']))->row();if(!$account)throw new BusinessException('Rekening kas/bank tidak ditemukan.');$amount=(float)$data['amount'];if($amount<=0)throw new BusinessException('Jumlah harus lebih dari nol.');$key=trim($data['idempotency_key']);if($key===''||$db->get_where('cash_transactions',array('idempotency_key'=>$key))->row())throw new BusinessException('Kunci transaksi kosong atau sudah digunakan.');$threshold=(float)(getenv('CASH_APPROVAL_THRESHOLD')?:10000000);$requires=$data['direction']==='out'&&$amount>=$threshold;$status=$requires?'submitted':'posted';$no='CB-'.date('YmdHis').'-'.substr(sha1($key),0,6);$row=array('transaction_no'=>$no,'transaction_date'=>$date,'cash_account_id'=>$account->id,'transaction_type'=>$data['transaction_type'],'direction'=>$data['direction'],'amount'=>$amount,'counter_account_id'=>$data['counter_account_id'],'reference_no'=>$data['reference_no'],'description'=>$data['description'],'status'=>$status,'requires_approval'=>$requires?1:0,'idempotency_key'=>$key,'created_by'=>$uid,'created_at'=>date('Y-m-d H:i:s'));$db->insert('cash_transactions',$row);$id=$db->insert_id();if(!$requires)$this->journal($id,$account,$row,$uid);if(!$db->trans_status())throw new RuntimeException('Transaksi kas/bank gagal.');$db->trans_commit();return array('id'=>$id,'transaction_no'=>$no,'status'=>$status);}catch(Throwable$e){$db->trans_rollback();throw$e;}} + public function post($data,$uid){$db=$this->CI->db;$db->trans_begin();try{$date=$data['transaction_date'];$this->CI->fiscalperiodservice->assertOpen($date);$account=$db->query('SELECT * FROM cash_accounts WHERE id=? AND is_active=1 FOR UPDATE',array($data['cash_account_id']))->row();if(!$account)throw new BusinessException('Rekening kas/bank tidak ditemukan.');$amount=(float)$data['amount'];if($amount<=0)throw new BusinessException('Jumlah harus lebih dari nol.');$key=trim($data['idempotency_key']);if($key===''||$db->get_where('cash_transactions',array('idempotency_key'=>$key))->row())throw new BusinessException('Kunci transaksi kosong atau sudah digunakan.');$threshold=(float)(app_env('CASH_APPROVAL_THRESHOLD',10000000)?:10000000);$requires=$data['direction']==='out'&&$amount>=$threshold;$status=$requires?'submitted':'posted';$no='CB-'.date('YmdHis').'-'.substr(sha1($key),0,6);$row=array('transaction_no'=>$no,'transaction_date'=>$date,'cash_account_id'=>$account->id,'transaction_type'=>$data['transaction_type'],'direction'=>$data['direction'],'amount'=>$amount,'counter_account_id'=>$data['counter_account_id'],'reference_no'=>$data['reference_no'],'description'=>$data['description'],'status'=>$status,'requires_approval'=>$requires?1:0,'idempotency_key'=>$key,'created_by'=>$uid,'created_at'=>date('Y-m-d H:i:s'));$db->insert('cash_transactions',$row);$id=$db->insert_id();if(!$requires)$this->journal($id,$account,$row,$uid);if(!$db->trans_status())throw new RuntimeException('Transaksi kas/bank gagal.');$db->trans_commit();return array('id'=>$id,'transaction_no'=>$no,'status'=>$status);}catch(Throwable$e){$db->trans_rollback();throw$e;}} private function journal($id,$account,$row,$uid){$amount=$row['amount'];$lines=$row['direction']==='in'?array(array('account_id'=>$account->gl_account_id,'debit'=>$amount,'kredit'=>0),array('account_id'=>$row['counter_account_id'],'debit'=>0,'kredit'=>$amount)):array(array('account_id'=>$row['counter_account_id'],'debit'=>$amount,'kredit'=>0),array('account_id'=>$account->gl_account_id,'debit'=>0,'kredit'=>$amount));$jid=$this->CI->postingservice->post(array('tanggal'=>$row['transaction_date'],'no_ref'=>$row['transaction_no'],'keterangan'=>$row['description'].'; rekening '.$account->name.'; referensi '.($row['reference_no']?:'-'),'ref_type'=>'cash_transaction','ref_id'=>$id,'created_by'=>$uid),$lines,false,true);$this->CI->db->where('id',$id)->update('cash_transactions',array('journal_id'=>$jid,'posted_by'=>$uid,'posted_at'=>date('Y-m-d H:i:s'),'status'=>'posted'));return$jid;} public function approve($id,$uid){$db=$this->CI->db;$db->trans_begin();try{$tx=$db->query('SELECT * FROM cash_transactions WHERE id=? FOR UPDATE',array($id))->row();if(!$tx||$tx->status!=='submitted')throw new BusinessException('Transaksi tidak menunggu persetujuan.');if((int)$tx->created_by===(int)$uid)throw new BusinessException('Pembuat transaksi tidak boleh menyetujui pembayarannya sendiri.');$account=$db->get_where('cash_accounts',array('id'=>$tx->cash_account_id))->row();$row=(array)$tx;$this->journal($id,$account,$row,$uid);$db->where('id',$id)->update('cash_transactions',array('approved_by'=>$uid,'approved_at'=>date('Y-m-d H:i:s')));$db->trans_commit();}catch(Throwable$e){$db->trans_rollback();throw$e;}} public function transfer($from,$to,$date,$amount,$charge,$chargeAccount,$ref,$uid,$key){if($from==$to)throw new BusinessException('Rekening asal dan tujuan harus berbeda.');$db=$this->CI->db;$db->trans_begin();try{$a=$db->query('SELECT * FROM cash_accounts WHERE id IN(?,?) ORDER BY id FOR UPDATE',array($from,$to))->result();if(count($a)!==2)throw new BusinessException('Rekening transfer tidak valid.');$map=array();foreach($a as$x)$map[$x->id]=$x;$this->CI->fiscalperiodservice->assertOpen($date);$no='TRF-'.date('YmdHis');$lines=array(array('account_id'=>$map[$to]->gl_account_id,'debit'=>$amount,'kredit'=>0),array('account_id'=>$map[$from]->gl_account_id,'debit'=>0,'kredit'=>$amount+$charge));if($charge>0)$lines[]=array('account_id'=>$chargeAccount,'debit'=>$charge,'kredit'=>0);$jid=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$no,'keterangan'=>'Transfer antar rekening dari '.$map[$from]->name.' ke '.$map[$to]->name.'; referensi '.($ref?:'-'),'ref_type'=>'cash_transfer','ref_id'=>0,'created_by'=>$uid),$lines,false,true);foreach(array(array($from,'transfer_out','out',$amount+$charge),array($to,'transfer_in','in',$amount))as$i=>$x){$db->insert('cash_transactions',array('transaction_no'=>$no.'-'.($i?'IN':'OUT'),'transaction_date'=>$date,'cash_account_id'=>$x[0],'transaction_type'=>$x[1],'direction'=>$x[2],'amount'=>$x[3],'reference_no'=>$ref,'description'=>'Transfer antar rekening '.$no,'status'=>'posted','source_type'=>'cash_transfer','journal_id'=>$jid,'idempotency_key'=>$key.'-'.$i,'created_by'=>$uid,'posted_by'=>$uid,'posted_at'=>date('Y-m-d H:i:s'),'created_at'=>date('Y-m-d H:i:s')));}$db->insert('cash_transfers',array('transfer_no'=>$no,'transfer_date'=>$date,'from_cash_account_id'=>$from,'to_cash_account_id'=>$to,'amount'=>$amount,'bank_charge'=>$charge,'reference_no'=>$ref,'status'=>'posted','journal_id'=>$jid,'idempotency_key'=>$key,'created_by'=>$uid,'approved_by'=>$uid));$db->trans_commit();return$no;}catch(Throwable$e){$db->trans_rollback();throw$e;}} diff --git a/application/libraries/DatabaseSyncService.php b/application/libraries/DatabaseSyncService.php index d3f3b5b..3c6ecfb 100644 --- a/application/libraries/DatabaseSyncService.php +++ b/application/libraries/DatabaseSyncService.php @@ -70,14 +70,14 @@ class DatabaseSyncService ); } - $backup = trim((string) getenv('DBSYNC_BACKUP_FILE')); + $backup = trim((string) app_env('DBSYNC_BACKUP_FILE', '')); if ($backup === '') { $backup = $this->createSnapshot($source); } elseif (!is_file($backup) || !is_readable($backup)) { throw new RuntimeException('File backup tidak ditemukan atau tidak dapat dibaca.'); } - $expectedHash = strtolower(trim((string) getenv('DBSYNC_BACKUP_SHA256'))); + $expectedHash = strtolower(trim((string) app_env('DBSYNC_BACKUP_SHA256', ''))); $actualHash = hash_file('sha256', $backup); if ($expectedHash !== '' && !hash_equals($expectedHash, strtolower($actualHash))) { throw new RuntimeException('Checksum backup tidak sesuai. Target belum diubah.'); @@ -177,11 +177,11 @@ class DatabaseSyncService private function readConfig($prefix) { $config = array( - 'host' => trim((string) getenv($prefix . '_HOST')), - 'port' => (int) (getenv($prefix . '_PORT') ?: 3306), - 'username' => trim((string) getenv($prefix . '_USERNAME')), - 'password' => (string) getenv($prefix . '_PASSWORD'), - 'database' => trim((string) getenv($prefix . '_DATABASE')), + 'host' => trim((string) app_env($prefix . '_HOST', '')), + 'port' => (int) (app_env($prefix . '_PORT', 3306) ?: 3306), + 'username' => trim((string) app_env($prefix . '_USERNAME', '')), + 'password' => (string) app_env($prefix . '_PASSWORD', ''), + 'database' => trim((string) app_env($prefix . '_DATABASE', '')), ); foreach (array('host', 'port', 'username', 'password', 'database') as $key) { @@ -255,7 +255,7 @@ class DatabaseSyncService private function createSnapshot($source) { - $directory = trim((string) getenv('DBSYNC_BACKUP_DIRECTORY')); + $directory = trim((string) app_env('DBSYNC_BACKUP_DIRECTORY', '')); if ($directory === '') { $directory = dirname(FCPATH) . DIRECTORY_SEPARATOR . 'private_backups' . DIRECTORY_SEPARATOR . 'accounting_dev'; } @@ -397,7 +397,7 @@ class DatabaseSyncService private function importBackup($backup) { - $mysql = trim((string) getenv('DBSYNC_MYSQL_BIN')); + $mysql = trim((string) app_env('DBSYNC_MYSQL_BIN', '')); if ($mysql === '') { $mysql = 'C:\\xampp\\mysql\\bin\\mysql.exe'; } @@ -440,10 +440,7 @@ class DatabaseSyncService 1 => array('pipe', 'w'), 2 => array('pipe', 'w'), ); - $processEnvironment = getenv(); - if (!is_array($processEnvironment)) { - $processEnvironment = array(); - } + $processEnvironment = app_env_all(); foreach ($environment as $key => $value) { $processEnvironment[$key] = $value; } diff --git a/application/libraries/JournalDescriptionService.php b/application/libraries/JournalDescriptionService.php index a2c07a9..b2b49e2 100644 --- a/application/libraries/JournalDescriptionService.php +++ b/application/libraries/JournalDescriptionService.php @@ -6,6 +6,10 @@ class JournalDescriptionService public function __construct(){$this->CI=&get_instance();} public function build(array $header,array $lines) { + // Beberapa service sudah membentuk redaksi bisnis yang siap ditampilkan. + // Flag ini hanya melewati formatter global; flag tidak ikut disimpan ke DB + // karena JournalService menyaring field header sebelum insert. + if(!empty($header['natural_description']))return trim((string)$header['keterangan']); $type=isset($header['ref_type'])?(string)$header['ref_type']:''; if($type===''||$type==='manual_journal')return trim((string)$header['keterangan']); $total=0;foreach($lines as$line)$total+=isset($line['debit'])?(float)$line['debit']:0; diff --git a/application/libraries/NavigationService.php b/application/libraries/NavigationService.php index 125da53..a0abfad 100644 --- a/application/libraries/NavigationService.php +++ b/application/libraries/NavigationService.php @@ -16,7 +16,7 @@ class NavigationService array('key'=>'professional_reports','label'=>'Laporan Profesional','icon'=>'bi-bar-chart','url'=>'professionalreports','feature'=>'reports'), array('key'=>'budgets','label'=>'Budget','icon'=>'bi-bullseye','url'=>'budgets','feature'=>'budgets','badge'=>$this->budgetApprovalBadge(),'badge_class'=>'nav-badge-warning','badge_title'=>'Pengingat pembayaran budget yang perlu diperhatikan'), array('key'=>'hr','label'=>'HR & Payroll','icon'=>'bi-people','feature'=>'employees','children'=>array(array('key'=>'organization_master','label'=>'Departemen & Posisi','url'=>'organizationmaster'),array('key'=>'employees','label'=>'Data Karyawan','url'=>'employees'),array('key'=>'generate_schedule','label'=>'Jadwal Kerja','url'=>'generateschedule'),array('key'=>'attendance_monitoring','label'=>'Monitoring Absensi','url'=>'attendancemonitoring'),array('key'=>'attendance_monthly','label'=>'Absensi Bulanan','url'=>'attendancemonthly'),array('key'=>'shift','label'=>'Shift','url'=>'shifts'),array('key'=>'holidays','label'=>'Hari Libur','url'=>'holidays'),array('key'=>'leave_requests','label'=>'Cuti & Izin','url'=>'leaverequests'),array('key'=>'hr_payroll','label'=>'Payroll Control','url'=>'hrpayroll'))), - array('key'=>'companies','label'=>'Company & Currency','icon'=>'bi-buildings','url'=>'companies','feature'=>'companies','admin'=>true), + array('key'=>'companies','label'=>'Company Setting','icon'=>'bi-buildings','url'=>'companies','feature'=>'companies','admin'=>true), array('key'=>'settings','label'=>'Pengaturan','icon'=>'bi-gear','feature'=>'setting','admin'=>true,'children'=>array(array('key'=>'users','label'=>'Pengguna','url'=>'users'),array('key'=>'roles','label'=>'Role & Permission','url'=>'roles'))) );$out=array();foreach($items as$item){if(!empty($item['admin'])&&!is_master_admin_user())continue;if(!$this->allowed($item['feature']))continue;if(!empty($item['children'])){$children=array();foreach($item['children']as$c){if(!empty($c['admin'])&&!is_master_admin_user())continue;if(!empty($c['feature'])&&!$this->allowed($c['feature']))continue;$children[]=$c;}$item['children']=$children;if(!$children)continue;}$out[]=$item;}return$out; } diff --git a/application/libraries/PayrollService.php b/application/libraries/PayrollService.php index 1bcf2de..93ea8f6 100644 --- a/application/libraries/PayrollService.php +++ b/application/libraries/PayrollService.php @@ -4,7 +4,7 @@ class PayrollService { private $CI;public function __construct(){$this->CI=&get_instance();$this->CI->load->library(array('FormulaService','PostingService','FiscalPeriodService','AccountMappingService'));} private function period($id){$p=$this->CI->db->query('SELECT * FROM k_payroll_periods WHERE id=? FOR UPDATE',array($id))->row();if(!$p)throw new BusinessException('Periode payroll tidak ditemukan.');return$p;} - public function calculate($month,$uid){$start=$month.'-01';$end=date('Y-m-t',strtotime($start));$db=$this->CI->db;$db->trans_begin();try{$locked=$db->get_where('attendance_period_locks',array('period'=>$month,'status'=>'locked'))->row();if(!$locked)throw new BusinessException('Absensi bulan '.$month.' harus dikunci sebelum payroll dihitung.');$p=$db->get_where('k_payroll_periods',array('start_date'=>$start,'end_date'=>$end))->row();if($p&&$p->workflow_status!=='draft')throw new BusinessException('Periode payroll bukan draft.');if(!$p){$db->insert('k_payroll_periods',array('period_name'=>date('F Y',strtotime($start)),'start_date'=>$start,'end_date'=>$end,'status'=>'draft','workflow_status'=>'draft','generated_at'=>date('Y-m-d H:i:s')));$pid=$db->insert_id();}else$pid=$p->id;$components=$db->where('is_active',1)->get('payroll_components')->result();foreach($db->where('is_active',1)->get('k_employees')->result()as$e){$att=$db->query("SELECT COUNT(*) work_days,SUM(attendance_status IN('present','late')) present_days,SUM(attendance_status='alpha') unpaid_days,COALESCE(SUM(overtime_hours),0) overtime_hours FROM k_attendances WHERE employee_id=? AND attendance_date BETWEEN ? AND ?",array($e->id,$start,$end))->row();$vars=array('basic_salary'=>(float)$e->basic_salary,'calendar_days'=>(int)date('t',strtotime($start)),'work_days'=>(float)$att->work_days,'present_days'=>(float)$att->present_days,'unpaid_days'=>(float)$att->unpaid_days,'overtime_hours'=>(float)$att->overtime_hours,'overtime_rate'=>(float)(getenv('PAYROLL_OVERTIME_RATE')?:25000),'daily_rate'=>(float)$e->basic_salary/max(1,(int)date('t',strtotime($start))));$pay=$db->get_where('k_payrolls',array('payroll_period_id'=>$pid,'employee_id'=>$e->id))->row();$data=array('payroll_period_id'=>$pid,'employee_id'=>$e->id,'basic_salary'=>$e->basic_salary,'total_work_days'=>$att->work_days,'total_present'=>$att->present_days,'total_alpha'=>$att->unpaid_days,'total_overtime_hours'=>$att->overtime_hours);if($pay){$db->where('id',$pay->id)->update('k_payrolls',$data);$payid=$pay->id;$db->where('payroll_id',$payid)->delete('payroll_calculation_lines');}else{$db->insert('k_payrolls',$data);$payid=$db->insert_id();}$earn=$deduct=$employer=0;foreach($components as$c){$override=$db->where('employee_id',$e->id)->where('component_id',$c->id)->where('is_active',1)->where('effective_from <=',$end)->group_start()->where('effective_to IS NULL',null,false)->or_where('effective_to >=',$start)->group_end()->order_by('effective_from','DESC')->get('employee_payroll_components')->row();$amount=$override&&$override->amount_override!==null?(float)$override->amount_override:($c->fixed_amount!==null?(float)$c->fixed_amount:$this->CI->formulaservice->calculate($c->formula_expression,$vars));if($c->code==='BASIC'&&$c->prorated&&$e->join_date>$start&&$e->join_date<=$end)$amount=round($amount*(date('t',strtotime($start))-date('d',strtotime($e->join_date))+1)/date('t',strtotime($start)),2);if(abs($amount)<.01)continue;$db->insert('payroll_calculation_lines',array('payroll_id'=>$payid,'component_id'=>$c->id,'component_code'=>$c->code,'component_name'=>$c->name,'component_type'=>$c->component_type,'base_amount'=>$e->basic_salary,'amount'=>$amount,'formula_snapshot'=>$c->formula_expression,'expense_account_id'=>$c->expense_account_id,'liability_account_id'=>$c->liability_account_id));if($c->component_type==='earning')$earn+=$amount;elseif($c->component_type==='deduction')$deduct+=$amount;else$employer+=$amount;}$net=$earn-$deduct;$snap=json_encode(array('variables'=>$vars,'gross'=>$earn,'deductions'=>$deduct,'net'=>$net));$db->where('id',$payid)->update('k_payrolls',array('gross_salary'=>$earn,'total_deductions'=>$deduct,'total_salary'=>$net,'calculation_snapshot'=>$snap,'version'=>isset($pay->version)?$pay->version+1:1));}$hash=hash('sha256',json_encode($db->select('p.id,p.total_salary,p.version')->from('k_payrolls p')->where('payroll_period_id',$pid)->order_by('p.id')->get()->result_array()));$db->where('id',$pid)->update('k_payroll_periods',array('calculation_hash'=>$hash));$db->trans_commit();return$pid;}catch(Throwable$e){$db->trans_rollback();throw$e;}} + public function calculate($month,$uid){$start=$month.'-01';$end=date('Y-m-t',strtotime($start));$db=$this->CI->db;$db->trans_begin();try{$locked=$db->get_where('attendance_period_locks',array('period'=>$month,'status'=>'locked'))->row();if(!$locked)throw new BusinessException('Absensi bulan '.$month.' harus dikunci sebelum payroll dihitung.');$p=$db->get_where('k_payroll_periods',array('start_date'=>$start,'end_date'=>$end))->row();if($p&&$p->workflow_status!=='draft')throw new BusinessException('Periode payroll bukan draft.');if(!$p){$db->insert('k_payroll_periods',array('period_name'=>date('F Y',strtotime($start)),'start_date'=>$start,'end_date'=>$end,'status'=>'draft','workflow_status'=>'draft','generated_at'=>date('Y-m-d H:i:s')));$pid=$db->insert_id();}else$pid=$p->id;$components=$db->where('is_active',1)->get('payroll_components')->result();foreach($db->where('is_active',1)->get('k_employees')->result()as$e){$att=$db->query("SELECT COUNT(*) work_days,SUM(attendance_status IN('present','late')) present_days,SUM(attendance_status='alpha') unpaid_days,COALESCE(SUM(overtime_hours),0) overtime_hours FROM k_attendances WHERE employee_id=? AND attendance_date BETWEEN ? AND ?",array($e->id,$start,$end))->row();$vars=array('basic_salary'=>(float)$e->basic_salary,'calendar_days'=>(int)date('t',strtotime($start)),'work_days'=>(float)$att->work_days,'present_days'=>(float)$att->present_days,'unpaid_days'=>(float)$att->unpaid_days,'overtime_hours'=>(float)$att->overtime_hours,'overtime_rate'=>(float)(app_env('PAYROLL_OVERTIME_RATE',25000)?:25000),'daily_rate'=>(float)$e->basic_salary/max(1,(int)date('t',strtotime($start))));$pay=$db->get_where('k_payrolls',array('payroll_period_id'=>$pid,'employee_id'=>$e->id))->row();$data=array('payroll_period_id'=>$pid,'employee_id'=>$e->id,'basic_salary'=>$e->basic_salary,'total_work_days'=>$att->work_days,'total_present'=>$att->present_days,'total_alpha'=>$att->unpaid_days,'total_overtime_hours'=>$att->overtime_hours);if($pay){$db->where('id',$pay->id)->update('k_payrolls',$data);$payid=$pay->id;$db->where('payroll_id',$payid)->delete('payroll_calculation_lines');}else{$db->insert('k_payrolls',$data);$payid=$db->insert_id();}$earn=$deduct=$employer=0;foreach($components as$c){$override=$db->where('employee_id',$e->id)->where('component_id',$c->id)->where('is_active',1)->where('effective_from <=',$end)->group_start()->where('effective_to IS NULL',null,false)->or_where('effective_to >=',$start)->group_end()->order_by('effective_from','DESC')->get('employee_payroll_components')->row();$amount=$override&&$override->amount_override!==null?(float)$override->amount_override:($c->fixed_amount!==null?(float)$c->fixed_amount:$this->CI->formulaservice->calculate($c->formula_expression,$vars));if($c->code==='BASIC'&&$c->prorated&&$e->join_date>$start&&$e->join_date<=$end)$amount=round($amount*(date('t',strtotime($start))-date('d',strtotime($e->join_date))+1)/date('t',strtotime($start)),2);if(abs($amount)<.01)continue;$db->insert('payroll_calculation_lines',array('payroll_id'=>$payid,'component_id'=>$c->id,'component_code'=>$c->code,'component_name'=>$c->name,'component_type'=>$c->component_type,'base_amount'=>$e->basic_salary,'amount'=>$amount,'formula_snapshot'=>$c->formula_expression,'expense_account_id'=>$c->expense_account_id,'liability_account_id'=>$c->liability_account_id));if($c->component_type==='earning')$earn+=$amount;elseif($c->component_type==='deduction')$deduct+=$amount;else$employer+=$amount;}$net=$earn-$deduct;$snap=json_encode(array('variables'=>$vars,'gross'=>$earn,'deductions'=>$deduct,'net'=>$net));$db->where('id',$payid)->update('k_payrolls',array('gross_salary'=>$earn,'total_deductions'=>$deduct,'total_salary'=>$net,'calculation_snapshot'=>$snap,'version'=>isset($pay->version)?$pay->version+1:1));}$hash=hash('sha256',json_encode($db->select('p.id,p.total_salary,p.version')->from('k_payrolls p')->where('payroll_period_id',$pid)->order_by('p.id')->get()->result_array()));$db->where('id',$pid)->update('k_payroll_periods',array('calculation_hash'=>$hash));$db->trans_commit();return$pid;}catch(Throwable$e){$db->trans_rollback();throw$e;}} public function workflow($id,$action,$uid){$db=$this->CI->db;$db->trans_begin();try{$p=$this->period($id);$map=array('review'=>array('draft','reviewed'), 'approve'=>array('reviewed','approved'), 'finalize'=>array('approved','final'));if(!isset($map[$action])||$p->workflow_status!==$map[$action][0])throw new BusinessException('Transisi payroll tidak valid.');if($action!=='review'&&(int)($action==='approve'?$p->reviewed_by:$p->approved_by)===(int)$uid)throw new BusinessException('Reviewer/approver berikutnya harus user berbeda.');$up=array('workflow_status'=>$map[$action][1],$action==='review'?'reviewed_by':($action==='approve'?'approved_by':'finalized_by')=>$uid,$action==='review'?'reviewed_at':($action==='approve'?'approved_at':'finalized_at')=>date('Y-m-d H:i:s'));$db->where('id',$id)->update('k_payroll_periods',$up);$db->trans_commit();}catch(Throwable$e){$db->trans_rollback();throw$e;}} public function post($id,$date,$uid){$db=$this->CI->db;$db->trans_begin();try{$p=$this->period($id);if($p->workflow_status!=='final'||$p->journal_id)throw new BusinessException('Payroll belum final atau sudah diposting.');$this->CI->fiscalperiodservice->assertOpen($date);$gross=(float)$db->select('COALESCE(SUM(gross_salary),0)t',false)->get_where('k_payrolls',array('payroll_period_id'=>$id))->row()->t;$ded=(float)$db->select('COALESCE(SUM(total_deductions),0)t',false)->get_where('k_payrolls',array('payroll_period_id'=>$id))->row()->t;$net=$gross-$ded;$lines=array(array('account_id'=>$this->CI->accountmappingservice->get('payroll_expense'),'debit'=>$gross,'kredit'=>0));if($ded>0)$lines[]=array('account_id'=>$this->CI->accountmappingservice->get('payroll_tax_payable'),'debit'=>0,'kredit'=>$ded);$lines[]=array('account_id'=>$this->CI->accountmappingservice->get('payroll_payable'),'debit'=>0,'kredit'=>$net);$jid=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>'PAYROLL-'.str_replace('-','',$p->start_date),'keterangan'=>'Payroll register '.$p->period_name.'; karyawan '.$db->where('payroll_period_id',$id)->count_all_results('k_payrolls').'; bruto Rp '.number_format($gross,2,'.','').' neto Rp '.number_format($net,2,'.',''),'ref_type'=>'payroll','ref_id'=>$id,'created_by'=>$uid),$lines,false,true);$db->where('id',$id)->update('k_payroll_periods',array('workflow_status'=>'posted','journal_id'=>$jid));$db->trans_commit();return$jid;}catch(Throwable$e){$db->trans_rollback();throw$e;}} public function pay($id,$date,$cashAccountId,$uid){$db=$this->CI->db;$db->trans_begin();try{$p=$this->period($id);if($p->workflow_status!=='posted')throw new BusinessException('Payroll belum diposting atau sudah dibayar.');if($db->get_where('payroll_payments',array('payroll_period_id'=>$id))->row())throw new BusinessException('Pembayaran payroll sudah tersedia.');$cash=$db->query('SELECT * FROM cash_accounts WHERE id=? FOR UPDATE',array($cashAccountId))->row();if(!$cash)throw new BusinessException('Rekening pembayaran tidak ditemukan.');$this->CI->fiscalperiodservice->assertOpen($date);$net=(float)$db->select('COALESCE(SUM(total_salary),0)t',false)->get_where('k_payrolls',array('payroll_period_id'=>$id))->row()->t;$no='PRP-'.date('YmdHis');$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$no,'keterangan'=>'Pembayaran payroll '.$p->period_name.' melalui '.$cash->name.'; total neto Rp '.number_format($net,2,'.',''),'ref_type'=>'payroll_payment','ref_id'=>$id,'created_by'=>$uid),array(array('account_id'=>$this->CI->accountmappingservice->get('payroll_payable'),'debit'=>$net,'kredit'=>0),array('account_id'=>$cash->gl_account_id,'debit'=>0,'kredit'=>$net)),false,true);$db->insert('payroll_payments',array('payroll_period_id'=>$id,'payment_no'=>$no,'payment_date'=>$date,'cash_account_id'=>$cashAccountId,'total_amount'=>$net,'status'=>'posted','journal_id'=>$journal,'created_by'=>$uid,'approved_by'=>$uid,'posted_at'=>date('Y-m-d H:i:s')));$paymentId=$db->insert_id();$db->where('id',$id)->update('k_payroll_periods',array('workflow_status'=>'paid','payment_transaction_id'=>$paymentId));$db->trans_commit();return$journal;}catch(Throwable$e){$db->trans_rollback();throw$e;}} diff --git a/application/libraries/ReceivableAdjustmentsService.php b/application/libraries/ReceivableAdjustmentsService.php index 84debb0..5877746 100644 --- a/application/libraries/ReceivableAdjustmentsService.php +++ b/application/libraries/ReceivableAdjustmentsService.php @@ -20,7 +20,9 @@ class ReceivableAdjustmentsService foreach($details as $line){$part=$amount*((float)$line->subtotal/$base);$tax=$part*((float)$line->tax_amount/max(.01,(float)$line->subtotal));$net=$part-$tax;$entries[]=array('account_id'=>$line->account_id,'debit'=>$net,'kredit'=>0);$debit+=$net;if($tax>0){$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('sales_tax_payable'),'debit'=>$tax,'kredit'=>0);$debit+=$tax;}} $entries[]=array('account_id'=>$this->CI->accountmappingservice->get('accounts_receivable'),'debit'=>0,'kredit'=>$debit); $no=$this->CI->numberingservice->next('credit_note',$date); - $journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$no,'keterangan'=>'Credit Note '.$invoice->no_invoice,'ref_type'=>'credit_note','ref_id'=>$invoice->id,'created_by'=>$userId),$entries,false,true); + $customer=$this->CI->db->select('nama')->get_where('customers',array('id'=>$invoice->customer_id))->row(); + $description='Pengurangan tagihan Invoice '.$invoice->no_invoice.' untuk '.($customer?$customer->nama:'Customer #'.$invoice->customer_id).' sebesar Rp '.number_format($amount,2,',','.').' karena '.trim($reason); + $journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$no,'keterangan'=>$description,'natural_description'=>true,'ref_type'=>'credit_note','ref_id'=>$invoice->id,'created_by'=>$userId),$entries,false,true); $this->CI->db->insert('credit_notes',array('credit_note_no'=>$no,'invoice_id'=>$invoice->id,'customer_id'=>$invoice->customer_id,'credit_date'=>$date,'amount'=>$amount,'reason'=>$reason,'status'=>'posted','journal_id'=>$journal,'created_by'=>$userId,'created_at'=>date('Y-m-d H:i:s'))); $id=$this->CI->db->insert_id();$balance=max(0,(float)$invoice->sisa_piutang-$amount); $this->CI->db->where('id',$invoice->id)->update('invoices',array('sisa_piutang'=>$balance,'status'=>$balance<=0?'paid':'partial')); @@ -32,7 +34,9 @@ class ReceivableAdjustmentsService $advance=$this->CI->db->query('SELECT * FROM customer_advances WHERE id=? FOR UPDATE',array((int)$advanceId))->row();$amount=round((float)$amount,2); if(!$advance||$amount<=0||$amount>(float)$advance->available_amount)throw new BusinessException('Nilai refund tidak valid.');if(trim($reason)==='')throw new BusinessException('Alasan refund wajib diisi.'); $this->CI->fiscalperiodservice->assertOpen($date);$no=$this->CI->numberingservice->next('refund',$date); - $journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$no,'keterangan'=>'Refund '.$advance->advance_no,'ref_type'=>'customer_refund','ref_id'=>$advance->id,'created_by'=>$userId),array(array('account_id'=>$this->CI->accountmappingservice->get('customer_advances'),'debit'=>$amount,'kredit'=>0),array('account_id'=>(int)$bankAccountId,'debit'=>0,'kredit'=>$amount)),false,true); + $customer=$this->CI->db->select('nama')->get_where('customers',array('id'=>$advance->customer_id))->row(); + $description='Pengembalian dana '.$advance->advance_no.' kepada '.($customer?$customer->nama:'Customer #'.$advance->customer_id).' sebesar Rp '.number_format($amount,2,',','.').' karena '.trim($reason); + $journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$no,'keterangan'=>$description,'natural_description'=>true,'ref_type'=>'customer_refund','ref_id'=>$advance->id,'created_by'=>$userId),array(array('account_id'=>$this->CI->accountmappingservice->get('customer_advances'),'debit'=>$amount,'kredit'=>0),array('account_id'=>(int)$bankAccountId,'debit'=>0,'kredit'=>$amount)),false,true); $this->CI->db->insert('customer_refunds',array('refund_no'=>$no,'customer_id'=>$advance->customer_id,'advance_id'=>$advance->id,'refund_date'=>$date,'amount'=>$amount,'bank_account_id'=>$bankAccountId,'reason'=>$reason,'status'=>'posted','journal_id'=>$journal,'created_by'=>$userId,'created_at'=>date('Y-m-d H:i:s')));$id=$this->CI->db->insert_id();$available=(float)$advance->available_amount-$amount;$this->CI->db->where('id',$advance->id)->update('customer_advances',array('available_amount'=>$available,'status'=>$available<=0?'refunded':'partially_used'));$this->CI->auditservice->record('receivable','refund',$id,'post',null,array('advance_id'=>$advance->id,'amount'=>$amount),$userId);return$id; }); } diff --git a/application/libraries/ReceivableService.php b/application/libraries/ReceivableService.php index c53309e..498a8f0 100644 --- a/application/libraries/ReceivableService.php +++ b/application/libraries/ReceivableService.php @@ -9,13 +9,13 @@ class ReceivableService } public function submitInvoice($id,$userId){return $this->CI->transactionservice->run(function()use($id,$userId){$i=$this->invoiceLock($id,'draft');$this->validateInvoice($i);$this->assertCreditLimit($i);$amount=$this->invoiceTotal($id);$r=$this->CI->approvalservice->create('invoice','invoice',$id,$amount,$userId,array('invoice'=>$i,'lines'=>$this->lines($id)));$this->CI->db->where('id',$id)->update('invoices',array('workflow_status'=>'submitted','total'=>$amount,'sisa_piutang'=>$amount));$after=$this->CI->receivableInvoiceModel->find($id);$this->CI->auditservice->record('invoice','invoice',$id,'submit',$i,$after,$userId);return $r;});} public function approveInvoice($id,$userId,$roleId){return $this->CI->transactionservice->run(function()use($id,$userId,$roleId){$i=$this->invoiceLock($id,'submitted');$r=$this->CI->approvalservice->pendingForEntity('invoice',$id);if(!$r)throw new BusinessException('Approval invoice tidak ditemukan.');$a=$this->CI->approvalservice->act($r->id,'approved',$userId,$roleId);if($a->status==='approved')$this->CI->db->where('id',$id)->update('invoices',array('workflow_status'=>'approved'));$after=$this->CI->receivableInvoiceModel->find($id);$this->CI->auditservice->record('invoice','invoice',$id,'approve',$i,$after,$userId);return $after;});} - public function postInvoice($id,$userId){return $this->CI->transactionservice->run(function()use($id,$userId){$i=$this->invoiceLock($id,'approved');$this->CI->fiscalperiodservice->assertOpen($i->tanggal);if($i->journal_id)throw new BusinessException('Invoice sudah mempunyai jurnal.');$lines=$this->lines($id);$ar=$this->CI->accountmappingservice->get('accounts_receivable');$taxAccount=$this->CI->accountmappingservice->get('sales_tax_payable');$entries=array(array('account_id'=>$ar,'debit'=>$i->total,'kredit'=>0));$credits=array();$hpp=0;foreach($lines as $l){$net=(float)$l->subtotal-(float)$l->tax_amount;$credits[$l->account_id]=($credits[$l->account_id]??0)+$net;if((float)$l->tax_amount>0)$credits[$taxAccount]=($credits[$taxAccount]??0)+(float)$l->tax_amount;$hpp+=(float)$l->total_hpp_barang;}foreach($credits as $account=>$amount)$entries[]=array('account_id'=>$account,'debit'=>0,'kredit'=>$amount);if($hpp>0){$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('cost_of_goods_sold'),'debit'=>$hpp,'kredit'=>0);$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('inventory'),'debit'=>0,'kredit'=>$hpp);}$journal=$this->CI->postingservice->post(array('tanggal'=>$i->tanggal,'no_ref'=>$this->CI->numberingservice->next('invoice_journal',$i->tanggal),'keterangan'=>'Posting Invoice '.$i->no_invoice,'ref_type'=>'invoice','ref_id'=>$id,'created_by'=>$userId),$entries,false,true); + public function postInvoice($id,$userId){return $this->CI->transactionservice->run(function()use($id,$userId){$i=$this->invoiceLock($id,'approved');$this->CI->fiscalperiodservice->assertOpen($i->tanggal);if($i->journal_id)throw new BusinessException('Invoice sudah mempunyai jurnal.');$lines=$this->lines($id);$ar=$this->CI->accountmappingservice->get('accounts_receivable');$taxAccount=$this->CI->accountmappingservice->get('sales_tax_payable');$entries=array(array('account_id'=>$ar,'debit'=>$i->total,'kredit'=>0));$credits=array();$hpp=0;foreach($lines as $l){$net=(float)$l->subtotal-(float)$l->tax_amount;$credits[$l->account_id]=($credits[$l->account_id]??0)+$net;if((float)$l->tax_amount>0)$credits[$taxAccount]=($credits[$taxAccount]??0)+(float)$l->tax_amount;$hpp+=(float)$l->total_hpp_barang;}foreach($credits as $account=>$amount)$entries[]=array('account_id'=>$account,'debit'=>0,'kredit'=>$amount);if($hpp>0){$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('cost_of_goods_sold'),'debit'=>$hpp,'kredit'=>0);$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('inventory'),'debit'=>0,'kredit'=>$hpp);}$customer=$this->CI->db->select('nama')->get_where('customers',array('id'=>$i->customer_id))->row();$description='Pencatatan Invoice '.$i->no_invoice.' kepada '.($customer?$customer->nama:'Customer #'.$i->customer_id).' sebesar Rp '.number_format($i->total,2,',','.');$journal=$this->CI->postingservice->post(array('tanggal'=>$i->tanggal,'no_ref'=>$this->CI->numberingservice->next('invoice_journal',$i->tanggal),'keterangan'=>$description,'natural_description'=>true,'ref_type'=>'invoice','ref_id'=>$id,'created_by'=>$userId),$entries,false,true); foreach($lines as $line)$this->postStock($i,$line); $this->CI->db->where('id',$id)->update('invoices',array('workflow_status'=>'posted','posted'=>1,'posted_at'=>date('Y-m-d H:i:s'),'posted_by'=>$userId,'journal_id'=>$journal,'status'=>'unpaid','sisa_piutang'=>$i->total));$this->CI->load->library('TaxService');$this->CI->taxservice->invoice($id,$journal);$after=$this->CI->receivableInvoiceModel->find($id);$this->CI->auditservice->record('invoice','invoice',$id,'post',$i,$after,$userId);return $after;});} public function recordPayment($customerId,$date,$cashAccountId,$amount,array $allocations,$allowAdvance,$userId,$reference='',$notes=''){return $this->CI->transactionservice->run(function()use($customerId,$date,$cashAccountId,$amount,$allocations,$allowAdvance,$userId,$reference,$notes){$amount=round((float)$amount,2);if($amount<=0)throw new BusinessException('Nilai pembayaran harus lebih dari nol.');$this->CI->fiscalperiodservice->assertOpen($date);$allocated=0;$locked=array();foreach($allocations as $invoiceId=>$value){$value=round((float)$value,2);if($value<=0)continue;$i=$this->CI->db->query('SELECT * FROM invoices WHERE id=? FOR UPDATE',array((int)$invoiceId))->row();if(!$i||$i->workflow_status!=='posted'||(int)$i->customer_id!==(int)$customerId)throw new BusinessException('Invoice alokasi tidak valid.');if($value>(float)$i->sisa_piutang+0.001)throw new BusinessException('Alokasi melebihi saldo invoice '.$i->no_invoice);$allocated+=$value;$locked[]=array($i,$value);} - if($allocated>$amount+0.001)throw new BusinessException('Total alokasi melebihi pembayaran.');$advance=round($amount-$allocated,2);if($advance>0&&!$allowAdvance)throw new BusinessException('Pembayaran melebihi alokasi. Aktifkan mekanisme uang muka pelanggan.');$paymentNo=$this->CI->numberingservice->next('payment',$date);$entries=array(array('account_id'=>(int)$cashAccountId,'debit'=>$amount,'kredit'=>0));if($allocated>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('accounts_receivable'),'debit'=>0,'kredit'=>$allocated);if($advance>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('customer_advances'),'debit'=>0,'kredit'=>$advance);$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$paymentNo,'keterangan'=>'Penerimaan pelanggan #'.$customerId,'ref_type'=>'customer_payment','created_by'=>$userId),$entries,false,true);$this->CI->db->insert('payments',array('invoice_id'=>count($locked)===1?$locked[0][0]->id:null,'tanggal'=>$date,'jumlah'=>$amount,'journal_id'=>$journal,'metode'=>'manual','payment_no'=>$paymentNo,'customer_id'=>$customerId,'status'=>'posted','reference_no'=>$reference,'notes'=>$notes,'created_by'=>$userId));$pid=$this->CI->db->insert_id();foreach($locked as $row){list($i,$value)=$row;$this->CI->db->insert('payment_allocations',array('payment_id'=>$pid,'invoice_id'=>$i->id,'amount'=>$value,'created_at'=>date('Y-m-d H:i:s')));$paid=(float)$i->total_bayar+$value;$balance=max(0,(float)$i->total-$paid);$this->CI->db->where('id',$i->id)->update('invoices',array('total_bayar'=>$paid,'sisa_piutang'=>$balance,'status'=>$balance<=0?'paid':'partial'));}if($advance>0)$this->CI->db->insert('customer_advances',array('advance_no'=>'ADV-'.$paymentNo,'customer_id'=>$customerId,'payment_id'=>$pid,'original_amount'=>$advance,'available_amount'=>$advance,'status'=>'available','journal_id'=>$journal,'created_at'=>date('Y-m-d H:i:s')));$this->CI->auditservice->record('receivable','payment',$pid,'post',null,array('payment_no'=>$paymentNo,'amount'=>$amount,'allocated'=>$allocated,'advance'=>$advance),$userId);return array('payment_id'=>$pid,'payment_no'=>$paymentNo,'advance'=>$advance);});} - public function reverseInvoice($id,$date,$reason,$userId){return $this->CI->transactionservice->run(function()use($id,$date,$reason,$userId){$i=$this->invoiceLock($id,'posted');if(trim($reason)==='')throw new BusinessException('Alasan reversal wajib diisi.');if((float)$i->total_bayar>0)throw new BusinessException('Invoice yang telah dibayar harus diselesaikan dengan credit note/refund.');$this->CI->fiscalperiodservice->assertOpen($date);$entries=array();foreach($this->CI->db->where('journal_id',$i->journal_id)->get('journal_details')->result() as $l)$entries[]=array('account_id'=>$l->account_id,'debit'=>$l->kredit,'kredit'=>$l->debit);if(!$entries)throw new BusinessException('Jurnal invoice tidak ditemukan.');$rid=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$this->CI->numberingservice->next('reversal',$date),'keterangan'=>'Reversal invoice '.$i->no_invoice.': '.$reason,'ref_type'=>'invoice_reversal','ref_id'=>$id,'reversal_of'=>$i->journal_id,'reversal_reason'=>$reason,'created_by'=>$userId),$entries,false,true);foreach($this->lines($id)as$line)$this->reverseStock($i,$line,$date,$reason);$this->CI->db->where('id',$id)->update('invoices',array('workflow_status'=>'reversed','status'=>'cancelled','reversal_journal_id'=>$rid,'sisa_piutang'=>0));$this->CI->auditservice->record('invoice','invoice',$id,'reverse',$i,$this->CI->receivableInvoiceModel->find($id),$userId);return $rid;});} - public function postWriteoff($invoiceId,$date,$amount,$reason,$userId){return $this->CI->transactionservice->run(function()use($invoiceId,$date,$amount,$reason,$userId){$i=$this->CI->db->query('SELECT * FROM invoices WHERE id=? FOR UPDATE',array((int)$invoiceId))->row();$amount=round((float)$amount,2);if(!$i||$i->workflow_status!=='posted'||$amount<=0||$amount>(float)$i->sisa_piutang)throw new BusinessException('Nilai write-off tidak valid.');if(trim($reason)==='')throw new BusinessException('Alasan write-off wajib diisi.');$this->CI->fiscalperiodservice->assertOpen($date);$no=$this->CI->numberingservice->next('writeoff',$date);$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$no,'keterangan'=>'Write-off '.$i->no_invoice,'ref_type'=>'receivable_writeoff','ref_id'=>$invoiceId,'created_by'=>$userId),array(array('account_id'=>$this->CI->accountmappingservice->get('bad_debt_expense'),'debit'=>$amount,'kredit'=>0),array('account_id'=>$this->CI->accountmappingservice->get('accounts_receivable'),'debit'=>0,'kredit'=>$amount)),false,true);$this->CI->db->insert('receivable_writeoffs',array('writeoff_no'=>$no,'invoice_id'=>$invoiceId,'writeoff_date'=>$date,'amount'=>$amount,'reason'=>$reason,'status'=>'posted','journal_id'=>$journal,'created_by'=>$userId,'created_at'=>date('Y-m-d H:i:s')));$wid=$this->CI->db->insert_id();$balance=max(0,(float)$i->sisa_piutang-$amount);$this->CI->db->where('id',$i->id)->update('invoices',array('sisa_piutang'=>$balance,'status'=>$balance<=0?'paid':'partial'));$this->CI->auditservice->record('receivable','writeoff',$wid,'post',null,array('invoice_id'=>$invoiceId,'amount'=>$amount),$userId);return $wid;});} + if($allocated>$amount+0.001)throw new BusinessException('Total alokasi melebihi pembayaran.');$advance=round($amount-$allocated,2);if($advance>0&&!$allowAdvance)throw new BusinessException('Pembayaran melebihi alokasi. Aktifkan mekanisme uang muka pelanggan.');$paymentNo=$this->CI->numberingservice->next('payment',$date);$entries=array(array('account_id'=>(int)$cashAccountId,'debit'=>$amount,'kredit'=>0));if($allocated>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('accounts_receivable'),'debit'=>0,'kredit'=>$allocated);if($advance>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('customer_advances'),'debit'=>0,'kredit'=>$advance);$customer=$this->CI->db->select('nama')->get_where('customers',array('id'=>$customerId))->row();$invoiceNumbers=array_map(function($row){return$row[0]->no_invoice;},$locked);$description='Penerimaan pembayaran '.$paymentNo.' dari '.($customer?$customer->nama:'Customer #'.$customerId).($invoiceNumbers?' untuk Invoice '.implode(', ',$invoiceNumbers):' sebagai uang muka pelanggan').' sebesar Rp '.number_format($amount,2,',','.');if(trim((string)$reference)!=='')$description.=' - Referensi: '.trim((string)$reference);if(trim((string)$notes)!=='')$description.=' - '.trim((string)$notes);$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$paymentNo,'keterangan'=>$description,'natural_description'=>true,'ref_type'=>'customer_payment','created_by'=>$userId),$entries,false,true);$this->CI->db->insert('payments',array('invoice_id'=>count($locked)===1?$locked[0][0]->id:null,'tanggal'=>$date,'jumlah'=>$amount,'journal_id'=>$journal,'metode'=>'manual','payment_no'=>$paymentNo,'customer_id'=>$customerId,'status'=>'posted','reference_no'=>$reference,'notes'=>$notes,'created_by'=>$userId));$pid=$this->CI->db->insert_id();foreach($locked as $row){list($i,$value)=$row;$this->CI->db->insert('payment_allocations',array('payment_id'=>$pid,'invoice_id'=>$i->id,'amount'=>$value,'created_at'=>date('Y-m-d H:i:s')));$paid=(float)$i->total_bayar+$value;$balance=max(0,(float)$i->total-$paid);$this->CI->db->where('id',$i->id)->update('invoices',array('total_bayar'=>$paid,'sisa_piutang'=>$balance,'status'=>$balance<=0?'paid':'partial'));}if($advance>0)$this->CI->db->insert('customer_advances',array('advance_no'=>'ADV-'.$paymentNo,'customer_id'=>$customerId,'payment_id'=>$pid,'original_amount'=>$advance,'available_amount'=>$advance,'status'=>'available','journal_id'=>$journal,'created_at'=>date('Y-m-d H:i:s')));$this->CI->auditservice->record('receivable','payment',$pid,'post',null,array('payment_no'=>$paymentNo,'amount'=>$amount,'allocated'=>$allocated,'advance'=>$advance),$userId);return array('payment_id'=>$pid,'payment_no'=>$paymentNo,'advance'=>$advance);});} + public function reverseInvoice($id,$date,$reason,$userId){return $this->CI->transactionservice->run(function()use($id,$date,$reason,$userId){$i=$this->invoiceLock($id,'posted');if(trim($reason)==='')throw new BusinessException('Alasan reversal wajib diisi.');if((float)$i->total_bayar>0)throw new BusinessException('Invoice yang telah dibayar harus diselesaikan dengan credit note/refund.');$this->CI->fiscalperiodservice->assertOpen($date);$entries=array();foreach($this->CI->db->where('journal_id',$i->journal_id)->get('journal_details')->result() as $l)$entries[]=array('account_id'=>$l->account_id,'debit'=>$l->kredit,'kredit'=>$l->debit);if(!$entries)throw new BusinessException('Jurnal invoice tidak ditemukan.');$description='Pembatalan Invoice '.$i->no_invoice.' karena '.trim($reason);$rid=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$this->CI->numberingservice->next('reversal',$date),'keterangan'=>$description,'natural_description'=>true,'ref_type'=>'invoice_reversal','ref_id'=>$id,'reversal_of'=>$i->journal_id,'reversal_reason'=>$reason,'created_by'=>$userId),$entries,false,true);foreach($this->lines($id)as$line)$this->reverseStock($i,$line,$date,$reason);$this->CI->db->where('id',$id)->update('invoices',array('workflow_status'=>'reversed','status'=>'cancelled','reversal_journal_id'=>$rid,'sisa_piutang'=>0));$this->CI->auditservice->record('invoice','invoice',$id,'reverse',$i,$this->CI->receivableInvoiceModel->find($id),$userId);return $rid;});} + public function postWriteoff($invoiceId,$date,$amount,$reason,$userId){return $this->CI->transactionservice->run(function()use($invoiceId,$date,$amount,$reason,$userId){$i=$this->CI->db->query('SELECT * FROM invoices WHERE id=? FOR UPDATE',array((int)$invoiceId))->row();$amount=round((float)$amount,2);if(!$i||$i->workflow_status!=='posted'||$amount<=0||$amount>(float)$i->sisa_piutang)throw new BusinessException('Nilai write-off tidak valid.');if(trim($reason)==='')throw new BusinessException('Alasan write-off wajib diisi.');$this->CI->fiscalperiodservice->assertOpen($date);$no=$this->CI->numberingservice->next('writeoff',$date);$description='Penghapusan piutang Invoice '.$i->no_invoice.' sebesar Rp '.number_format($amount,2,',','.').' karena '.trim($reason);$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$no,'keterangan'=>$description,'natural_description'=>true,'ref_type'=>'receivable_writeoff','ref_id'=>$invoiceId,'created_by'=>$userId),array(array('account_id'=>$this->CI->accountmappingservice->get('bad_debt_expense'),'debit'=>$amount,'kredit'=>0),array('account_id'=>$this->CI->accountmappingservice->get('accounts_receivable'),'debit'=>0,'kredit'=>$amount)),false,true);$this->CI->db->insert('receivable_writeoffs',array('writeoff_no'=>$no,'invoice_id'=>$invoiceId,'writeoff_date'=>$date,'amount'=>$amount,'reason'=>$reason,'status'=>'posted','journal_id'=>$journal,'created_by'=>$userId,'created_at'=>date('Y-m-d H:i:s')));$wid=$this->CI->db->insert_id();$balance=max(0,(float)$i->sisa_piutang-$amount);$this->CI->db->where('id',$i->id)->update('invoices',array('sisa_piutang'=>$balance,'status'=>$balance<=0?'paid':'partial'));$this->CI->auditservice->record('receivable','writeoff',$wid,'post',null,array('invoice_id'=>$invoiceId,'amount'=>$amount),$userId);return $wid;});} public function aging($asOf=null){$asOf=$asOf?:date('Y-m-d');$company=$this->CI->companycontext->id();return $this->CI->db->query("SELECT i.customer_id,c.nama,SUM(CASE WHEN i.jatuh_tempo>=? THEN i.sisa_piutang ELSE 0 END) current_amount,SUM(CASE WHEN DATEDIFF(?,i.jatuh_tempo) BETWEEN 1 AND 30 THEN i.sisa_piutang ELSE 0 END) days_1_30,SUM(CASE WHEN DATEDIFF(?,i.jatuh_tempo) BETWEEN 31 AND 60 THEN i.sisa_piutang ELSE 0 END) days_31_60,SUM(CASE WHEN DATEDIFF(?,i.jatuh_tempo) BETWEEN 61 AND 90 THEN i.sisa_piutang ELSE 0 END) days_61_90,SUM(CASE WHEN DATEDIFF(?,i.jatuh_tempo)>90 THEN i.sisa_piutang ELSE 0 END) days_over_90,SUM(i.sisa_piutang) total FROM invoices i JOIN customers c ON c.id=i.customer_id WHERE i.company_id=? AND i.workflow_status='posted' AND i.sisa_piutang>0 AND i.tanggal<=? GROUP BY i.customer_id,c.nama ORDER BY c.nama",array($asOf,$asOf,$asOf,$asOf,$asOf,$company,$asOf))->result();} public function statement($customerId,$from,$to){$company=$this->CI->companycontext->id();$customer=$this->CI->db->get_where('customers',array('id'=>$customerId,'company_id'=>$company))->row();if(!$customer)throw new BusinessException('Customer tidak ditemukan pada perusahaan aktif.');$invoices=$this->CI->db->where(array('company_id'=>$company,'customer_id'=>$customerId))->where('tanggal >=',$from)->where('tanggal <=',$to)->where('workflow_status','posted')->order_by('tanggal')->get('invoices')->result();$payments=$this->CI->db->where(array('company_id'=>$company,'customer_id'=>$customerId))->where('tanggal >=',$from)->where('tanggal <=',$to)->where('status','posted')->order_by('tanggal')->get('payments')->result();return array('customer'=>$customer,'invoices'=>$invoices,'payments'=>$payments);} private function postStock($invoice,$line) diff --git a/application/libraries/SalesService.php b/application/libraries/SalesService.php index 2abf8f7..0cfc03c 100644 --- a/application/libraries/SalesService.php +++ b/application/libraries/SalesService.php @@ -111,7 +111,7 @@ class SalesService $price=$this->money($priceValue); if($price<0)throw new BusinessException('Harga item ke-'.($index+1).' tidak boleh negatif.'); $row['qty']=$qty;$row['unit_price']=$price;$row['discount_amount']=0;$row['specification']=''; - $grossValues[$index]=round($qty*$price,2);$totalGross+=$grossValues[$index]; + $grossValues[$index]=round($qty*$price,2);if(($row['line_type']??'inventory')!=='savings')$totalGross+=$grossValues[$index]; } unset($row); @@ -119,10 +119,10 @@ class SalesService if($discount<0)throw new BusinessException('Diskon penambahan item tidak boleh negatif.'); if($discount>$totalGross+.001)throw new BusinessException('Diskon tidak boleh melebihi total bruto item yang ditambahkan.'); - $positiveIndexes=array_keys(array_filter($grossValues,function($gross){return$gross>0;})); + $positiveIndexes=array();foreach($grossValues as$index=>$gross)if($gross>0&&($rows[$index]['line_type']??'inventory')!=='savings')$positiveIndexes[]=$index; $lastPositive=$positiveIndexes?end($positiveIndexes):null;$allocated=0.0; foreach($rows as $index=>&$row){ - if($discount<=0||$grossValues[$index]<=0){$lineDiscount=0.0;} + if(($row['line_type']??'inventory')==='savings'||$discount<=0||$grossValues[$index]<=0){$lineDiscount=0.0;} elseif($index===$lastPositive){$lineDiscount=round($discount-$allocated,2);} else{$lineDiscount=round($discount*$grossValues[$index]/$totalGross,2);} $lineDiscount=max(0,min($grossValues[$index],$lineDiscount)); @@ -154,10 +154,7 @@ class SalesService $customer=$this->tenant('customers',$invoice->customer_id,'Customer'); $existingLines=$this->CI->db->select('id,line_type,subtotal,total_hpp_barang')->where(array('invoice_id'=>$invoice->id,'company_id'=>$this->company()))->get('invoice_details')->result();$lineJournalMode=true;foreach($existingLines as$existingLine)if($existingLine->line_type!=='savings'&&((float)$existingLine->subtotal>0||(float)$existingLine->total_hpp_barang>0)){$existingJournal=$this->initialLineJournal($existingLine->id);if(!$existingJournal||$existingJournal->is_reversed||$existingJournal->status!=='posted'){$lineJournalMode=false;break;}} $existingTypes=$this->CI->db->select('DISTINCT line_type',false)->where(array('invoice_id'=>$invoice->id,'company_id'=>$this->company()))->get('invoice_details')->result_array(); - $families=array();foreach($existingTypes as$t)$families[$this->lineFamily($t['line_type'])]=true;foreach($rows as$r)$families[$this->lineFamily($r['line_type']??'inventory')]=true; - if(count($families)>1)throw new BusinessException('Invoice penjualan barang/jasa tidak dapat dicampur dengan invoice tabungan. Buat invoice terpisah agar jurnal dan pelunasannya jelas.'); - $financialTypes=array();foreach($existingTypes as$t)if($this->lineFamily($t['line_type'])==='financial')$financialTypes[$t['line_type']]=true;foreach($rows as$r)if($this->lineFamily($r['line_type']??'inventory')==='financial')$financialTypes[$r['line_type']]=true; - if(count($financialTypes)>1)throw new BusinessException('Data pinjaman lama dan tabungan tidak dapat dicampur pada invoice yang sama.'); + foreach($existingTypes as$t)if($t['line_type']==='loan')throw new BusinessException('Invoice Pinjaman lama tidak dapat ditambah dengan jenis transaksi baru. Buat invoice baru untuk Barang, Jasa, atau Tabungan.'); foreach($rows as$n=>$row){ $requestedType=$row['line_type']??'inventory';if($requestedType==='loan')throw new BusinessException('Piutang/Pinjaman sudah tidak tersedia pada Invoice. Gunakan jenis Tabungan sesuai alur terbaru.'); $lineType=in_array($requestedType,array('inventory','service','savings'),true)?$requestedType:'inventory';$item=null;$warehouse=null;$kb=null;$key=trim((string)($row['idempotency_key']??'')); @@ -222,11 +219,13 @@ class SalesService $qty=number_format((float)$line->qty,abs((float)$line->qty-round((float)$line->qty))<.0001?0:4,',','.'); if($line->line_type==='inventory'){ $codes=array();foreach($barcodes as$b)if(isset($b['row']->barcode))$codes[]=$b['row']->barcode; - $description="Penjualan '".$item->nama_barang."' Sebanyak '".$qty."' '".($line->unit?:'-')."' - Kode: '".($codes?implode(', ',$codes):'-')."' - Kepada: '".$customer->nama."'"; + $unit=trim((string)$line->unit);$quantity=$qty.($unit!==''&&$unit!=='-'?' '.$unit:''); + $description='Penjualan '.$item->nama_barang.' Sebanyak '.$quantity.' - Kode: '.($codes?implode(', ',$codes):'-').' - Kepada: '.$customer->nama; }else{ - $description="Penjualan '".$line->nama_item."' Sebanyak '".$qty."' - Kepada: '".$customer->nama."' - '".($line->keterangan?:'-')."'"; + $description='Penjualan '.$line->nama_item.' Sebanyak '.$qty.' - Kepada: '.$customer->nama; + if(trim((string)$line->keterangan)!=='')$description.=' - '.trim((string)$line->keterangan); } - return$this->CI->postingservice->post(array('tanggal'=>$line->service_date,'no_ref'=>$this->CI->numberingservice->next('invoice_line_journal',$line->service_date),'keterangan'=>$description,'ref_type'=>'invoice_line','ref_id'=>(int)$detailId,'created_by'=>$user),$entries,false,false); + return$this->CI->postingservice->post(array('tanggal'=>$line->service_date,'no_ref'=>$this->CI->numberingservice->next('invoice_line_journal',$line->service_date),'keterangan'=>$description,'natural_description'=>true,'ref_type'=>'invoice_line','ref_id'=>(int)$detailId,'created_by'=>$user),$entries,false,false); } private function initialLineJournal($lineId,$lock=false) @@ -241,7 +240,9 @@ class SalesService $entries=array();foreach($this->CI->db->where('journal_id',$journal->id)->order_by('id')->get('journal_details')->result()as$d)$entries[]=array('account_id'=>$d->account_id,'debit'=>(float)$d->kredit,'kredit'=>(float)$d->debit); if(!$entries)return null; $date=$line->service_date?:($line->tanggal?:date('Y-m-d')); - $reversal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$this->CI->numberingservice->next('invoice_line_reversal',$date),'keterangan'=>'Pembalikan item invoice: '.$reason,'ref_type'=>'invoice_line_reversal','ref_id'=>(int)$journal->id,'reversal_of'=>(int)$journal->id,'reversal_reason'=>$reason,'created_by'=>$user),$entries,false,true); + $invoice=$this->CI->db->select('no_invoice')->get_where('invoices',array('id'=>$line->invoice_id,'company_id'=>$this->company()))->row(); + $description='Pembatalan item pada Invoice '.($invoice?$invoice->no_invoice:'#'.$line->invoice_id).' karena '.$reason; + $reversal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$this->CI->numberingservice->next('invoice_line_reversal',$date),'keterangan'=>$description,'natural_description'=>true,'ref_type'=>'invoice_line_reversal','ref_id'=>(int)$journal->id,'reversal_of'=>(int)$journal->id,'reversal_reason'=>$reason,'created_by'=>$user),$entries,false,true); $this->CI->db->where('id',$journal->id)->update('journals',array('status'=>'reversed','is_reversed'=>1,'updated_by'=>$user,'updated_at'=>date('Y-m-d H:i:s'),'reversal_reason'=>$reason)); return$reversal; } @@ -360,7 +361,7 @@ class SalesService foreach($bars as$b){$log=$this->addCompany('stock_logs',array('item_id'=>$l->item_id,'warehouse_id'=>$d->warehouse_id,'barcode_id'=>$b->barcode_id,'qty'=>$b->qty,'tipe'=>'keluar','unit_cost'=>$l->qty>0?$cost/$l->qty:0,'keterangan'=>$desc,'ref_type'=>'sales_delivery','ref_id'=>$l->id,'idempotency_key'=>$key.'-B'.$b->id));$this->CI->db->insert('stock_logs',$log);$move=$this->addCompany('item_movements',array('item_id'=>$l->item_id,'barcode_id'=>$b->barcode_id,'qty'=>$b->qty,'from_type'=>'warehouse','from_id'=>$d->warehouse_id,'to_type'=>'customer','to_id'=>$d->customer_id,'movement_type'=>'sold_out','reference_type'=>'sales_delivery','reference_id'=>$l->id,'notes'=>$desc,'created_at'=>date('Y-m-d H:i:s')));$this->CI->db->insert('item_movements',$move);} $this->CI->salesInventoryModel->synchronizeStock($l->item_id);$this->CI->db->where('id',$l->id)->update('sales_delivery_lines',array('unit_cost'=>$l->qty>0?$cost/$l->qty:0,'total_cost'=>$cost,'stock_posted_at'=>date('Y-m-d H:i:s')));$this->CI->db->where('id',$l->invoice_detail_id)->update('invoice_details',array('total_hpp_barang'=>$cost,'stock_posted_at'=>date('Y-m-d H:i:s'))); } - $journal=null;if(abs($inventoryReclass)>.001){$amount=abs(round($inventoryReclass,2));$entries=$inventoryReclass>0?array(array('account_id'=>$this->CI->accountmappingservice->get('inventory_in_sales'),'debit'=>$amount,'kredit'=>0),array('account_id'=>$this->CI->accountmappingservice->get('inventory'),'debit'=>0,'kredit'=>$amount)):array(array('account_id'=>$this->CI->accountmappingservice->get('inventory'),'debit'=>$amount,'kredit'=>0),array('account_id'=>$this->CI->accountmappingservice->get('inventory_in_sales'),'debit'=>0,'kredit'=>$amount));$journal=$this->CI->postingservice->post(array('tanggal'=>$d->delivery_date,'no_ref'=>$this->CI->numberingservice->next('sales_delivery_journal',$d->delivery_date),'keterangan'=>'[AUTO] Penyesuaian biaya aktual pengiriman '.$d->delivery_no.' | Customer #'.$d->customer_id.' | Selisih Rp '.number_format($inventoryReclass,2,',','.'),'ref_type'=>'sales_delivery','ref_id'=>$d->id,'created_by'=>$user),$entries,false,true);} + $journal=null;if(abs($inventoryReclass)>.001){$amount=abs(round($inventoryReclass,2));$entries=$inventoryReclass>0?array(array('account_id'=>$this->CI->accountmappingservice->get('inventory_in_sales'),'debit'=>$amount,'kredit'=>0),array('account_id'=>$this->CI->accountmappingservice->get('inventory'),'debit'=>0,'kredit'=>$amount)):array(array('account_id'=>$this->CI->accountmappingservice->get('inventory'),'debit'=>$amount,'kredit'=>0),array('account_id'=>$this->CI->accountmappingservice->get('inventory_in_sales'),'debit'=>0,'kredit'=>$amount));$deliveryCustomer=$this->CI->db->select('nama')->get_where('customers',array('id'=>$d->customer_id,'company_id'=>$this->company()))->row();$description='Penyesuaian harga modal pada pengiriman '.$d->delivery_no.' kepada '.($deliveryCustomer?$deliveryCustomer->nama:'Customer #'.$d->customer_id).' sebesar Rp '.number_format($amount,2,',','.');$journal=$this->CI->postingservice->post(array('tanggal'=>$d->delivery_date,'no_ref'=>$this->CI->numberingservice->next('sales_delivery_journal',$d->delivery_date),'keterangan'=>$description,'natural_description'=>true,'ref_type'=>'sales_delivery','ref_id'=>$d->id,'created_by'=>$user),$entries,false,true);} $this->CI->db->where('id',$d->id)->update('sales_deliveries',array('status'=>'posted','journal_id'=>$journal,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s'),'version'=>(int)$d->version+1));$this->updateInvoiceDeliveryStatus($d->invoice_id);$this->CI->auditservice->record('sales','sales_delivery',$d->id,'post',$d,array('journal_id'=>$journal,'cost'=>$totalCost),$user);return$d->id; }); } @@ -390,19 +391,25 @@ class SalesService private function postInvoiceInternal($id,$user) { $i=$this->lockInvoice($id,'approved');$this->CI->fiscalperiodservice->assertOpen($i->tanggal);if($i->journal_id)throw new BusinessException('Invoice sudah pernah diposting.');if($i->delivery_status!=='delivered')throw new BusinessException('Invoice hanya dapat diposting setelah seluruh Surat Jalan diposting.');$lines=$this->CI->db->where(array('invoice_id'=>$i->id,'company_id'=>$this->company()))->get('invoice_details')->result();if(!$lines)throw new BusinessException('Invoice belum mempunyai item.'); - $family=$this->lineFamily($lines[0]->line_type);$entries=array();$cogs=0;$revenueAmount=0; - if($family==='financial'&&$lines[0]->line_type==='savings'){ + $family=$this->lineFamily($lines[0]->line_type);$entries=array();$cogs=0;$revenueAmount=0;$hasSales=false;$hasSavings=false;$hasLoan=false;foreach($lines as$l){if(in_array($l->line_type,array('inventory','service'),true))$hasSales=true;elseif($l->line_type==='savings')$hasSavings=true;elseif($l->line_type==='loan')$hasLoan=true;} + if($hasSavings&&!$hasSales&&!$hasLoan){ $this->CI->db->where('id',$i->id)->update('invoices',array('workflow_status'=>'posted','posted'=>1,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s'),'journal_id'=>null,'status'=>'unpaid','sisa_piutang'=>$i->total,'recognized_revenue'=>0,'recognized_cogs'=>0,'recognition_policy'=>'on_payment')); $this->CI->auditservice->record('sales','invoice',$i->id,'post',$i,array('journal_id'=>null,'purpose'=>'Tabungan; jurnal dibentuk saat pembayaran'),$user);return$i->id; } - if($family==='sales'){ - $initialJournals=array();$journalizedLines=0;foreach($lines as$l){if((float)$l->subtotal<=0&&(float)$l->total_hpp_barang<=0){$journalizedLines++;continue;}$lineJournal=$this->initialLineJournal($l->id);if($lineJournal&&!$lineJournal->is_reversed&&$lineJournal->status==='posted'){$initialJournals[]=$lineJournal;$journalizedLines++;}} + if($hasSales&&!$hasLoan){ + $initialJournals=array();$journalizedLines=0;foreach($lines as$l){if($l->line_type==='savings'||((float)$l->subtotal<=0&&(float)$l->total_hpp_barang<=0)){$journalizedLines++;continue;}$lineJournal=$this->initialLineJournal($l->id);if($lineJournal&&!$lineJournal->is_reversed&&$lineJournal->status==='posted'){$initialJournals[]=$lineJournal;$journalizedLines++;}} if($journalizedLines===count($lines)&&$initialJournals){ - foreach($lines as$l){$revenueAmount+=(float)$l->net_amount;$cogs+=(float)$l->total_hpp_barang;} + foreach($lines as$l)if(in_array($l->line_type,array('inventory','service'),true)){$revenueAmount+=(float)$l->net_amount;$cogs+=(float)$l->total_hpp_barang;} $journal=(int)$initialJournals[0]->id;$this->CI->db->where('id',$i->id)->update('invoices',array('workflow_status'=>'posted','posted'=>1,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s'),'journal_id'=>$journal,'status'=>'unpaid','sisa_piutang'=>$i->total,'recognized_revenue'=>0,'recognized_cogs'=>0,'recognition_policy'=>'on_payment')); $this->CI->load->library('TaxService');$this->CI->taxservice->invoice($i->id,$journal);$this->CI->auditservice->record('sales','invoice',$i->id,'post',$i,array('journal_id'=>$journal,'purpose'=>'Pendapatan diakui proporsional saat pembayaran','line_journal_count'=>count($initialJournals)),$user);return$i->id; } } + if($hasSales&&$hasSavings&&!$hasLoan){ + $legacyEntries=array();$legacyReceivable=0;$legacyDeferred=0;$legacyTax=0;foreach($lines as$l){if(!in_array($l->line_type,array('inventory','service'),true))continue;$lineJournal=$this->initialLineJournal($l->id);if($lineJournal&&!$lineJournal->is_reversed&&$lineJournal->status==='posted')continue;$legacyReceivable+=(float)$l->subtotal;$legacyDeferred+=(float)$l->net_amount;$legacyTax+=(float)$l->tax_amount;} + if($legacyReceivable<=0)throw new BusinessException('Komposisi jurnal invoice campuran tidak valid.');$legacyEntries[]=array('account_id'=>$this->CI->accountmappingservice->get('accounts_receivable'),'debit'=>$legacyReceivable,'kredit'=>0);if($legacyDeferred>0)$legacyEntries[]=array('account_id'=>$this->CI->accountmappingservice->get('deferred_revenue'),'debit'=>0,'kredit'=>$legacyDeferred);if($legacyTax>0)$legacyEntries[]=array('account_id'=>$this->CI->accountmappingservice->get('sales_tax_payable'),'debit'=>0,'kredit'=>$legacyTax); + $legacyCustomer=$this->CI->db->select('nama')->get_where('customers',array('id'=>$i->customer_id,'company_id'=>$this->company()))->row();$description='Pencatatan komponen penjualan Invoice '.$i->no_invoice.' kepada '.($legacyCustomer?$legacyCustomer->nama:'Customer #'.$i->customer_id).' sebesar Rp '.number_format($legacyReceivable,2,',','.').'; komponen Tabungan dicatat saat pembayaran';$journal=$this->CI->postingservice->post(array('tanggal'=>$i->tanggal,'no_ref'=>$this->CI->numberingservice->next('invoice_journal',$i->tanggal),'keterangan'=>$description,'natural_description'=>true,'ref_type'=>'invoice','ref_id'=>$i->id,'created_by'=>$user),$legacyEntries,false,true);$this->CI->db->where('id',$i->id)->update('invoices',array('workflow_status'=>'posted','posted'=>1,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s'),'journal_id'=>$journal,'status'=>'unpaid','sisa_piutang'=>$i->total,'recognized_revenue'=>0,'recognized_cogs'=>0,'recognition_policy'=>'on_payment')); + $this->CI->load->library('TaxService');$this->CI->taxservice->invoice($i->id,$journal);$this->CI->auditservice->record('sales','invoice',$i->id,'post',$i,array('journal_id'=>$journal,'purpose'=>'Invoice campuran Barang/Jasa dan Tabungan'),$user);return$i->id; + } if($family==='financial'){ $debits=array();$credits=array();foreach($lines as$l){$value=(float)$l->subtotal;if($l->line_type==='loan'){$debits[(int)$l->account_id]=($debits[(int)$l->account_id]??0)+$value;$credits[(int)$l->counter_account_id]=($credits[(int)$l->counter_account_id]??0)+$value;}else{$ar=$this->CI->accountmappingservice->get('accounts_receivable');$debits[$ar]=($debits[$ar]??0)+$value;$credits[(int)$l->account_id]=($credits[(int)$l->account_id]??0)+$value;}} foreach($debits as$a=>$v)if($v>0)$entries[]=array('account_id'=>$a,'debit'=>$v,'kredit'=>0);foreach($credits as$a=>$v)if($v>0)$entries[]=array('account_id'=>$a,'debit'=>0,'kredit'=>$v); @@ -413,8 +420,8 @@ class SalesService if($i->recognition_policy==='accrual'&&$revenueAmount>0){$entries[]=array('account_id'=>$deferred,'debit'=>$revenueAmount,'kredit'=>0);foreach($revenueCredits as$a=>$v)if($v>0)$entries[]=array('account_id'=>$a,'debit'=>0,'kredit'=>$v);} if($i->recognition_policy==='accrual'&&$cogs>0){$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('cost_of_goods_sold'),'debit'=>$cogs,'kredit'=>0);$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('inventory_in_sales'),'debit'=>0,'kredit'=>$cogs);} } - $customer=$this->CI->db->select('nama')->get_where('customers',array('id'=>$i->customer_id,'company_id'=>$this->company()))->row();$purpose=$family==='financial'?($lines[0]->line_type==='loan'?'Tagihan Pinjaman':'Tagihan Tabungan'):($i->recognition_policy==='accrual'?'Pendapatan diakui saat posting':'Pendapatan diakui proporsional setelah pembayaran');$journalDescription='[AUTO] Posting Invoice '.$i->no_invoice.' | Customer '.($customer?$customer->nama:'#'.$i->customer_id).' | Tujuan '.$purpose.' | Total Rp '.number_format($i->total,2,',','.').' | Referensi invoice #'.$i->id; - $journal=$this->CI->postingservice->post(array('tanggal'=>$i->tanggal,'no_ref'=>$this->CI->numberingservice->next('invoice_journal',$i->tanggal),'keterangan'=>$journalDescription,'ref_type'=>'invoice','ref_id'=>$i->id,'created_by'=>$user),$entries,false,true); + $customer=$this->CI->db->select('nama')->get_where('customers',array('id'=>$i->customer_id,'company_id'=>$this->company()))->row();$purpose=$family==='financial'?($lines[0]->line_type==='loan'?'tagihan pinjaman':'tagihan Tabungan'):($i->recognition_policy==='accrual'?'pendapatan yang diakui saat posting':'pendapatan yang diakui setelah pembayaran');$journalDescription='Pencatatan Invoice '.$i->no_invoice.' kepada '.($customer?$customer->nama:'Customer #'.$i->customer_id).' sebesar Rp '.number_format($i->total,2,',','.').' sebagai '.$purpose; + $journal=$this->CI->postingservice->post(array('tanggal'=>$i->tanggal,'no_ref'=>$this->CI->numberingservice->next('invoice_journal',$i->tanggal),'keterangan'=>$journalDescription,'natural_description'=>true,'ref_type'=>'invoice','ref_id'=>$i->id,'created_by'=>$user),$entries,false,true); if($family==='financial'&&$lines[0]->line_type==='loan')foreach($credits as$accountId=>$value)$this->recordCashSubledger($accountId,$i->tanggal,'out',$value,'customer',$i->customer_id,'invoice_loan',$i->id,$journal,$journalDescription,$user); if($family==='sales'&&$i->recognition_policy==='accrual'){$this->CI->db->insert('revenue_recognition_ledger',array('company_id'=>$this->company(),'invoice_id'=>$i->id,'recognition_date'=>$i->tanggal,'revenue_amount'=>$revenueAmount,'cogs_amount'=>$cogs,'journal_id'=>$journal,'idempotency_key'=>'INVOICE-ACCRUAL-'.$i->id,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s')));} $this->CI->db->where('id',$i->id)->update('invoices',array('workflow_status'=>'posted','posted'=>1,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s'),'journal_id'=>$journal,'status'=>'unpaid','sisa_piutang'=>$i->total,'recognized_revenue'=>$family==='sales'&&$i->recognition_policy==='accrual'?$revenueAmount:0,'recognized_cogs'=>$family==='sales'&&$i->recognition_policy==='accrual'?$cogs:0));if($family==='sales'){$this->CI->load->library('TaxService');$this->CI->taxservice->invoice($i->id,$journal);}$this->CI->auditservice->record('sales','invoice',$i->id,'post',$i,array('journal_id'=>$journal,'purpose'=>$purpose),$user);return$i->id; @@ -443,13 +450,13 @@ class SalesService } if($allocated>$sourceTotal+.001)throw new BusinessException('Total alokasi invoice melebihi sumber pembayaran.'); $advance=round($sourceTotal-$allocated,2);if($advance>.001&&!$allowAdvance)throw new BusinessException('Terdapat kelebihan pembayaran. Aktifkan pencatatan uang muka pelanggan.'); - $settlementCredits=array();foreach($locked as$row)foreach($this->paymentReceivableDistribution($row[0],$row[1])as$accountId=>$value)$settlementCredits[$accountId]=($settlementCredits[$accountId]??0)+$value;foreach($sourceRows as$s)if(isset($settlementCredits[$s['account_id']]))throw new BusinessException('Akun penerimaan/pelunasan tidak boleh sama dengan akun piutang yang sedang dilunasi.');foreach($settlementCredits as$accountId=>$value)if($value>0)$entries[]=array('account_id'=>$accountId,'debit'=>0,'kredit'=>$value); + $settlementCredits=array();$allocationDistributions=array();foreach($locked as$row){$distribution=$this->paymentReceivableDistribution($row[0],$row[1]);$allocationDistributions[(int)$row[0]->id]=$distribution;foreach($distribution as$accountId=>$value)$settlementCredits[$accountId]=($settlementCredits[$accountId]??0)+$value;}foreach($sourceRows as$s)if(isset($settlementCredits[$s['account_id']]))throw new BusinessException('Akun penerimaan/pelunasan tidak boleh sama dengan akun piutang atau simpanan yang sedang diproses.');foreach($settlementCredits as$accountId=>$value)if($value>0)$entries[]=array('account_id'=>$accountId,'debit'=>0,'kredit'=>$value); if($advance>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('customer_advances'),'debit'=>0,'kredit'=>$advance); $paymentNo=$this->CI->numberingservice->next('payment',$date); $invoiceNames=array_map(function($x){return$x[0]->no_invoice.' Rp '.number_format($x[1],2,',','.');},$locked); $savingsPayment=!empty($locked);foreach($locked as$row){$firstLine=$this->CI->db->select('line_type')->where(array('invoice_id'=>$row[0]->id,'company_id'=>$this->company()))->order_by('id')->limit(1)->get('invoice_details')->row();if(!$firstLine||$firstLine->line_type!=='savings'){$savingsPayment=false;break;}} - $paymentDescription=$savingsPayment?"Tabungan '".$customer->nama."' - '".(trim((string)$notes)?:'-')."'":'[AUTO] Penerimaan '.$paymentNo.' | Customer '.$customer->nama.' | Alokasi '.implode(', ',$invoiceNames).' | Referensi '.($reference?:'-').' | Total Rp '.number_format($sourceTotal,2,',','.'); - $journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$paymentNo,'keterangan'=>$paymentDescription,'ref_type'=>'customer_payment','ref_id'=>null,'created_by'=>$user),$entries,false,false); + if($savingsPayment){$paymentDescription='Tabungan '.$customer->nama;if(trim((string)$notes)!=='')$paymentDescription.=" - '".trim((string)$notes)."'";}else{$allocationText=$invoiceNames?' untuk '.implode(', ',$invoiceNames):' sebagai uang muka pelanggan';$paymentDescription='Penerimaan pembayaran '.$paymentNo.' dari '.$customer->nama.$allocationText.' dengan total Rp '.number_format($sourceTotal,2,',','.');if(trim((string)$reference)!=='')$paymentDescription.=' - Referensi: '.trim((string)$reference);if(trim((string)$notes)!=='')$paymentDescription.=' - '.trim((string)$notes);} + $journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$paymentNo,'keterangan'=>$paymentDescription,'natural_description'=>true,'ref_type'=>'customer_payment','ref_id'=>null,'created_by'=>$user),$entries,false,false); $payment=$this->columns('payments',array('company_id'=>$this->company(),'invoice_id'=>count($locked)===1?$locked[0][0]->id:null,'tanggal'=>$date,'jumlah'=>$sourceTotal,'journal_id'=>$journal,'metode'=>count($sourceRows)>1?'multi_account':'manual','payment_no'=>$paymentNo,'customer_id'=>$customer->id,'status'=>'posted','reference_no'=>$reference,'notes'=>$notes,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s'))); $this->CI->db->insert('payments',$payment);$paymentId=(int)$this->CI->db->insert_id(); foreach($sourceRows as$s){$this->CI->db->insert('payment_sources',array('company_id'=>$this->company(),'payment_id'=>$paymentId,'account_id'=>$s['account_id'],'amount'=>$s['amount'],'created_at'=>date('Y-m-d H:i:s')));$this->recordCashSubledger($s['account_id'],$date,'in',$s['amount'],'customer',$customer->id,'customer_payment',$paymentId,$journal,'Penerimaan '.$paymentNo.' dari '.$customer->nama,$user);} @@ -457,7 +464,7 @@ class SalesService list($invoice,$amount)=$row;$this->CI->db->insert('payment_allocations',$this->columns('payment_allocations',array('company_id'=>$this->company(),'payment_id'=>$paymentId,'invoice_id'=>$invoice->id,'amount'=>$amount,'created_at'=>date('Y-m-d H:i:s')))); $paid=round((float)$invoice->total_bayar+$amount,2);$balance=max(0,round((float)$invoice->sisa_piutang-$amount,2)); $this->CI->db->where(array('id'=>$invoice->id,'company_id'=>$this->company()))->update('invoices',array('total_bayar'=>$paid,'sisa_piutang'=>$balance,'status'=>$balance<=.001?'paid':'partial')); - if($this->invoiceFamily($invoice->id)==='sales')$this->recognizeOnPayment($invoice,$paymentId,$amount,$date,$user); + $receivableAccount=$this->CI->accountmappingservice->get('accounts_receivable');$salesPayment=(float)($allocationDistributions[(int)$invoice->id][$receivableAccount]??0);if($salesPayment>0)$this->recognizeOnPayment($invoice,$paymentId,$salesPayment,$date,$user); } if($advance>0)$this->CI->db->insert('customer_advances',$this->columns('customer_advances',array('company_id'=>$this->company(),'advance_no'=>'ADV-'.$paymentNo,'customer_id'=>$customer->id,'payment_id'=>$paymentId,'original_amount'=>$advance,'available_amount'=>$advance,'status'=>'available','journal_id'=>$journal,'created_at'=>date('Y-m-d H:i:s')))); foreach($attachments as$a)$this->insertAttachment('customer_payment',$paymentId,$a,$user); @@ -491,14 +498,15 @@ class SalesService private function recognizeOnPayment($invoice,$paymentId,$allocated,$date,$user) { - $netTotal=max(.01,(float)$invoice->subtotal_before_tax);$gross=max(.01,(float)$invoice->total); + $lines=$this->CI->db->where(array('invoice_id'=>$invoice->id,'company_id'=>$this->company()))->where_in('line_type',array('inventory','service'))->order_by('id')->get('invoice_details')->result();if(!$lines)return; + $netTotal=0;$gross=0;foreach($lines as$l){$netTotal+=(float)$l->net_amount;$gross+=(float)$l->subtotal;}$netTotal=max(.01,$netTotal);$gross=max(.01,$gross); $remainingRevenue=max(0,$netTotal-(float)$invoice->recognized_revenue);$revenue=min($remainingRevenue,round($allocated*($netTotal/$gross),2)); - $lines=$this->CI->db->where(array('invoice_id'=>$invoice->id,'company_id'=>$this->company()))->get('invoice_details')->result(); $allCost=0;foreach($lines as$l)$allCost+=(float)$l->total_hpp_barang;$remainingCost=max(0,$allCost-(float)$invoice->recognized_cogs);$cost=min($remainingCost,round($allocated*($allCost/$gross),2)); if($revenue<=0&&$cost<=0)return; $entries=array();if($revenue>0){$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('deferred_revenue'),'debit'=>$revenue,'kredit'=>0);$left=$revenue;foreach($lines as$idx=>$l){$part=$idx===count($lines)-1?$left:round($revenue*((float)$l->net_amount/$netTotal),2);$left-=$part;if($part>0)$entries[]=array('account_id'=>$l->account_id,'debit'=>0,'kredit'=>$part);}} if($cost>0){$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('cost_of_goods_sold'),'debit'=>$cost,'kredit'=>0);$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('inventory_in_sales'),'debit'=>0,'kredit'=>$cost);} - $journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$this->CI->numberingservice->next('revenue_recognition',$date),'keterangan'=>'[AUTO] Pengakuan pendapatan setelah pembayaran | Invoice '.$invoice->no_invoice.' | Pembayaran #'.$paymentId.' | Pendapatan Rp '.number_format($revenue,2,',','.').' | HPP Rp '.number_format($cost,2,',','.'),'ref_type'=>'revenue_recognition','ref_id'=>$paymentId,'created_by'=>$user),$entries,false,false); + $recognitionDescription='Pengakuan pendapatan Invoice '.$invoice->no_invoice.' setelah pembayaran sebesar Rp '.number_format($revenue,2,',','.');if($cost>0)$recognitionDescription.=' dengan harga pokok Rp '.number_format($cost,2,',','.'); + $journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$this->CI->numberingservice->next('revenue_recognition',$date),'keterangan'=>$recognitionDescription,'natural_description'=>true,'ref_type'=>'revenue_recognition','ref_id'=>$paymentId,'created_by'=>$user),$entries,false,false); $this->CI->db->insert('revenue_recognition_ledger',array('company_id'=>$this->company(),'invoice_id'=>$invoice->id,'payment_id'=>$paymentId,'recognition_date'=>$date,'revenue_amount'=>$revenue,'cogs_amount'=>$cost,'journal_id'=>$journal,'idempotency_key'=>'PAYMENT-RECOGNITION-'.$paymentId.'-'.$invoice->id,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s'))); $this->CI->db->where(array('id'=>$invoice->id,'company_id'=>$this->company()))->set('recognized_revenue','recognized_revenue+'.(float)$revenue,false)->set('recognized_cogs','recognized_cogs+'.(float)$cost,false)->update('invoices'); } @@ -510,6 +518,15 @@ class SalesService $payments=$this->CI->db->select('p.*,pa.amount allocated')->from('payment_allocations pa')->join('payments p','p.id=pa.payment_id')->where('pa.invoice_id',$invoice->id)->order_by('p.tanggal,p.id')->get()->result();foreach($payments as$p)$p->attachments=$this->attachments(array('customer_payment'),$p->id); return array('invoice'=>$invoice,'lines'=>$lines,'barcodes'=>$barcodes,'deliveries'=>$this->CI->db->where(array('invoice_id'=>$invoice->id,'company_id'=>$this->company()))->get('sales_deliveries')->result(),'payments'=>$payments,'returns'=>$this->CI->db->where(array('invoice_id'=>$invoice->id,'company_id'=>$this->company()))->get('sales_returns')->result(),'attachments'=>$this->attachments(array('invoice'),$invoice->id)); } + public function previousOutstandingInvoices($invoiceId) + { + $invoice=$this->invoiceForEditor($invoiceId); + return $this->CI->db->select('id,no_invoice,tanggal,jatuh_tempo,total,total_bayar,sisa_piutang,status') + ->from('invoices')->where(array('company_id'=>$this->company(),'customer_id'=>$invoice->customer_id,'workflow_status'=>'posted')) + ->where('deleted_at IS NULL',null,false)->where('sisa_piutang >',0.001)->where('id !=',$invoice->id) + ->group_start()->where('tanggal <',$invoice->tanggal)->or_group_start()->where('tanggal',$invoice->tanggal)->where('id <',$invoice->id)->group_end()->group_end() + ->order_by('tanggal','ASC')->order_by('id','ASC')->get()->result(); + } public function deliveryDetail($id) { $delivery=$this->CI->db->select('d.*,c.nama customer_name,c.alamat customer_address,i.no_invoice,w.nama warehouse_name,u.nama creator_name')->from('sales_deliveries d')->join('customers c','c.id=d.customer_id')->join('invoices i','i.id=d.invoice_id','left')->join('warehouses w','w.id=d.warehouse_id','left')->join('users u','u.id=d.created_by','left')->where(array('d.id'=>(int)$id,'d.company_id'=>$this->company()))->get()->row();if(!$delivery)throw new BusinessException('Surat Jalan tidak ditemukan.'); @@ -536,7 +553,7 @@ class SalesService return$this->CI->transactionservice->run(function()use($id,$user){$r=$this->lockReturn($id,'received');$this->CI->fiscalperiodservice->assertOpen($r->return_date);$lines=$this->CI->db->where(array('sales_return_id'=>$r->id,'company_id'=>$this->company()))->get('sales_return_lines')->result();$net=0;$tax=0;$cost=0; foreach($lines as$l){$bars=$this->CI->db->where('sales_return_line_id',$l->id)->get('sales_return_barcodes')->result();foreach($bars as$b){$source=$this->CI->db->query('SELECT * FROM sales_delivery_barcodes WHERE id=? FOR UPDATE',array($b->sales_delivery_barcode_id))->row();if(!$source||(float)$source->returned_qty+(float)$b->qty>(float)$source->qty+.0001)throw new BusinessException('Barcode retur sudah diproses atau qty tidak valid.');$barcode=$this->CI->db->query('SELECT * FROM item_barcodes WHERE id=? FOR UPDATE',array($b->barcode_id))->row();$status=$l->physical_condition==='saleable'?'available':($l->physical_condition==='damaged'?'damaged':'quarantine');$this->CI->db->where('id',$barcode->id)->update('item_barcodes',array('qty_sisa'=>(float)$barcode->qty_sisa+(float)$b->qty,'status'=>$status,'version'=>(int)$barcode->version+1));$this->CI->db->where('id',$source->id)->update('sales_delivery_barcodes',array('returned_qty'=>(float)$source->returned_qty+(float)$b->qty,'status'=>(float)$source->returned_qty+(float)$b->qty>=(float)$source->qty?'returned':'partially_returned'));$this->CI->db->where('id',$b->id)->update('sales_return_barcodes',array('status'=>'posted'));} $value=round((float)$l->unit_cost*(float)$l->qty,2);$key='SALES-RETURN-LINE-'.$l->id;$warehouse=$this->CI->db->select('warehouse_id')->get_where('sales_deliveries',array('id'=>$r->sales_delivery_id))->row()->warehouse_id;$ledger=$this->addCompany('inventory_ledger',array('item_id'=>$l->item_id,'warehouse_id'=>$warehouse,'movement_date'=>$r->return_date,'direction'=>'in','qty'=>$l->qty,'unit_cost'=>$l->unit_cost,'value'=>$value,'document_type'=>'sales_return','document_id'=>$r->id,'document_line_id'=>$l->id,'idempotency_key'=>$key,'created_at'=>date('Y-m-d H:i:s')));$this->CI->db->insert('inventory_ledger',$ledger);if($l->physical_condition==='saleable')$this->CI->db->insert('inventory_cost_layers',$this->columns('inventory_cost_layers',array('company_id'=>$this->company(),'item_id'=>$l->item_id,'warehouse_id'=>$warehouse,'source_ledger_id'=>$this->CI->db->insert_id(),'received_date'=>$r->return_date,'original_qty'=>$l->qty,'remaining_qty'=>$l->qty,'unit_cost'=>$l->unit_cost,'status'=>'open')));$this->CI->db->insert('stock_logs',$this->addCompany('stock_logs',array('item_id'=>$l->item_id,'warehouse_id'=>$warehouse,'qty'=>$l->qty,'tipe'=>'masuk','unit_cost'=>$l->unit_cost,'keterangan'=>'[AUTO] Retur Penjualan '.$r->return_no.' | Kondisi '.$l->physical_condition,'ref_type'=>'sales_return','ref_id'=>$l->id,'idempotency_key'=>$key)));$this->CI->db->insert('item_movements',$this->addCompany('item_movements',array('item_id'=>$l->item_id,'qty'=>$l->qty,'from_type'=>'customer','from_id'=>$r->customer_id,'to_type'=>$l->physical_condition==='saleable'?'warehouse':'quarantine','to_id'=>$warehouse,'movement_type'=>'customer_return','reference_type'=>'sales_return','reference_id'=>$l->id,'notes'=>'[AUTO] Retur '.$r->return_no,'created_at'=>date('Y-m-d H:i:s'))));$this->CI->salesInventoryModel->synchronizeStock($l->item_id);$net+=(float)$l->net_amount;$tax+=(float)$l->tax_amount;$cost+=$value;$this->CI->db->where('id',$l->id)->update('sales_return_lines',array('posted_at'=>date('Y-m-d H:i:s')));} - $invoice=$this->lockInvoice($r->invoice_id);$credit=min((float)$invoice->sisa_piutang,$net+$tax);$excess=max(0,$net+$tax-$credit);$entries=array();$revenueRatio=$invoice->recognition_policy==='accrual'?1:min(1,(float)$invoice->recognized_revenue/max(.01,(float)$invoice->subtotal_before_tax));$recognizedNet=round($net*$revenueRatio,2);$deferredNet=$net-$recognizedNet;if($recognizedNet>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('goods_revenue'),'debit'=>$recognizedNet,'kredit'=>0);if($deferredNet>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('deferred_revenue'),'debit'=>$deferredNet,'kredit'=>0);if($tax>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('sales_tax_payable'),'debit'=>$tax,'kredit'=>0);if($credit>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('accounts_receivable'),'debit'=>0,'kredit'=>$credit);if($excess>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('customer_advances'),'debit'=>0,'kredit'=>$excess);if($cost>0){$allInvoiceCost=(float)$this->CI->db->select('COALESCE(SUM(total_hpp_barang),0) v',false)->get_where('invoice_details',array('invoice_id'=>$invoice->id,'company_id'=>$this->company()))->row()->v;$costRatio=$invoice->recognition_policy==='accrual'?1:min(1,(float)$invoice->recognized_cogs/max(.01,$allInvoiceCost));$recognizedCost=round($cost*$costRatio,2);$deferredCost=$cost-$recognizedCost;$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('inventory'),'debit'=>$cost,'kredit'=>0);if($recognizedCost>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('cost_of_goods_sold'),'debit'=>0,'kredit'=>$recognizedCost);if($deferredCost>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('inventory_in_sales'),'debit'=>0,'kredit'=>$deferredCost);}$journal=$this->CI->postingservice->post(array('tanggal'=>$r->return_date,'no_ref'=>$r->return_no,'keterangan'=>'[AUTO] Retur Penjualan '.$r->return_no.' | Invoice '.$invoice->no_invoice.' | Customer #'.$r->customer_id.' | Nilai Rp '.number_format($net+$tax,2,',','.'),'ref_type'=>'sales_return','ref_id'=>$r->id,'created_by'=>$user),$entries,false,true); + $invoice=$this->lockInvoice($r->invoice_id);$credit=min((float)$invoice->sisa_piutang,$net+$tax);$excess=max(0,$net+$tax-$credit);$entries=array();$revenueRatio=$invoice->recognition_policy==='accrual'?1:min(1,(float)$invoice->recognized_revenue/max(.01,(float)$invoice->subtotal_before_tax));$recognizedNet=round($net*$revenueRatio,2);$deferredNet=$net-$recognizedNet;if($recognizedNet>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('goods_revenue'),'debit'=>$recognizedNet,'kredit'=>0);if($deferredNet>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('deferred_revenue'),'debit'=>$deferredNet,'kredit'=>0);if($tax>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('sales_tax_payable'),'debit'=>$tax,'kredit'=>0);if($credit>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('accounts_receivable'),'debit'=>0,'kredit'=>$credit);if($excess>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('customer_advances'),'debit'=>0,'kredit'=>$excess);if($cost>0){$allInvoiceCost=(float)$this->CI->db->select('COALESCE(SUM(total_hpp_barang),0) v',false)->get_where('invoice_details',array('invoice_id'=>$invoice->id,'company_id'=>$this->company()))->row()->v;$costRatio=$invoice->recognition_policy==='accrual'?1:min(1,(float)$invoice->recognized_cogs/max(.01,$allInvoiceCost));$recognizedCost=round($cost*$costRatio,2);$deferredCost=$cost-$recognizedCost;$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('inventory'),'debit'=>$cost,'kredit'=>0);if($recognizedCost>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('cost_of_goods_sold'),'debit'=>0,'kredit'=>$recognizedCost);if($deferredCost>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('inventory_in_sales'),'debit'=>0,'kredit'=>$deferredCost);}$returnCustomer=$this->CI->db->select('nama')->get_where('customers',array('id'=>$r->customer_id,'company_id'=>$this->company()))->row();$returnDescription='Retur penjualan '.$r->return_no.' atas Invoice '.$invoice->no_invoice.' dari '.($returnCustomer?$returnCustomer->nama:'Customer #'.$r->customer_id).' sebesar Rp '.number_format($net+$tax,2,',','.');if(trim((string)$r->reason)!=='')$returnDescription.=' karena '.trim((string)$r->reason);$journal=$this->CI->postingservice->post(array('tanggal'=>$r->return_date,'no_ref'=>$r->return_no,'keterangan'=>$returnDescription,'natural_description'=>true,'ref_type'=>'sales_return','ref_id'=>$r->id,'created_by'=>$user),$entries,false,true); $balance=max(0,(float)$invoice->sisa_piutang-$credit);$this->CI->db->where('id',$invoice->id)->update('invoices',array('sisa_piutang'=>$balance,'status'=>$balance<=.001?'paid':'partial'));$this->CI->db->insert('credit_notes',$this->columns('credit_notes',array('company_id'=>$this->company(),'credit_note_no'=>'CN-'.$r->return_no,'invoice_id'=>$invoice->id,'customer_id'=>$r->customer_id,'credit_date'=>$r->return_date,'amount'=>$net+$tax,'reason'=>$r->reason,'status'=>'posted','journal_id'=>$journal,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s'))));$creditId=(int)$this->CI->db->insert_id();if($excess>0)$this->CI->db->insert('customer_advances',$this->columns('customer_advances',array('company_id'=>$this->company(),'advance_no'=>'ADV-'.$r->return_no,'customer_id'=>$r->customer_id,'payment_id'=>null,'original_amount'=>$excess,'available_amount'=>$excess,'status'=>'available','journal_id'=>$journal,'created_at'=>date('Y-m-d H:i:s'))));$this->CI->db->where('id',$r->id)->update('sales_returns',array('status'=>'completed','credit_note_id'=>$creditId,'journal_id'=>$journal,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s')));$this->returnHistory($r->id,'received','completed','Stok dan credit note diposting.',$user);return$r->id;}); } public function returnDetail($id){$r=$this->CI->db->select('r.*,c.nama customer_name,i.no_invoice,d.delivery_no')->from('sales_returns r')->join('customers c','c.id=r.customer_id')->join('invoices i','i.id=r.invoice_id','left')->join('sales_deliveries d','d.id=r.sales_delivery_id','left')->where(array('r.id'=>(int)$id,'r.company_id'=>$this->company()))->get()->row();if(!$r)throw new BusinessException('Retur tidak ditemukan.');return array('return'=>$r,'lines'=>$this->CI->db->select('l.*,it.kode_detail AS kode_barang,it.nama_barang')->from('sales_return_lines l')->join('items it','it.id=l.item_id')->where('l.sales_return_id',$r->id)->get()->result(),'barcodes'=>$this->CI->db->select('b.*,ib.barcode,ib.serial_number')->from('sales_return_barcodes b')->join('item_barcodes ib','ib.id=b.barcode_id')->where('b.sales_return_id',$r->id)->get()->result(),'history'=>$this->CI->db->select('h.*,u.nama user_name')->from('sales_return_history h')->join('users u','u.id=h.user_id','left')->where('h.sales_return_id',$r->id)->order_by('h.id')->get()->result(),'attachments'=>$this->attachments(array('sales_return'),$r->id));} diff --git a/application/migrations/20260911000800_add_company_invoice_settings.php b/application/migrations/20260911000800_add_company_invoice_settings.php new file mode 100644 index 0000000..6250be5 --- /dev/null +++ b/application/migrations/20260911000800_add_company_invoice_settings.php @@ -0,0 +1,42 @@ +db->table_exists('companies')) return; + + $columns = array( + 'phone' => array('type' => 'VARCHAR', 'constraint' => 60, 'null' => true, 'after' => 'address'), + 'email' => array('type' => 'VARCHAR', 'constraint' => 120, 'null' => true, 'after' => 'phone'), + 'logo_path' => array('type' => 'VARCHAR', 'constraint' => 255, 'null' => true, 'after' => 'email'), + ); + foreach ($columns as $name => $definition) { + if (!$this->db->field_exists($name, 'companies')) $this->dbforge->add_column('companies', array($name => $definition)); + } + + if (!$this->db->table_exists('company_bank_accounts')) { + $this->db->query("CREATE TABLE `company_bank_accounts` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `company_id` BIGINT UNSIGNED NOT NULL, + `bank_name` VARCHAR(100) NOT NULL, + `account_number` VARCHAR(100) NOT NULL, + `account_holder` VARCHAR(150) NOT NULL, + `currency` CHAR(3) NOT NULL DEFAULT 'IDR', + `is_primary` TINYINT(1) NOT NULL DEFAULT 0, + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `created_at` DATETIME NOT NULL, + `updated_at` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_company_bank_account` (`company_id`,`account_number`), + KEY `idx_company_bank_active` (`company_id`,`is_active`,`is_primary`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + } + } + + public function down() + { + throw new RuntimeException('Pengaturan identitas dan rekening perusahaan dipertahankan.'); + } +} diff --git a/application/views/companies/index.php b/application/views/companies/index.php index cec9c3e..262a3e5 100644 --- a/application/views/companies/index.php +++ b/application/views/companies/index.php @@ -1 +1,27 @@ -

Multi-company & Mata Uang

Perusahaan
Kurs Harian
TanggalPerusahaanMata uangKursSumber
rate_date?>company_name)?>from_currency?>/to_currency?>rate,8,',','.')?>source?:'-')?>
+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)?>
+ session->flashdata('success')):?>
session->flashdata('success'))?>
+ session->flashdata('error')):?>
session->flashdata('error'))?>
+ + + +
+
Identitas pada Dokumen

Informasi ini digunakan pada header PDF Invoice dan dokumen perusahaan.

logo_path)&&is_file(FCPATH.$active_company->logo_path)):?>LogoBelum ada logo
Maksimal 2 MB. Gunakan logo rasio mendekati persegi.
+ +
Tambah / Edit Rekening

Rekening aktif otomatis ditampilkan pada PDF Invoice.

Daftar Rekening Perusahaan

Rekening nonaktif disimpan dalam histori, tetapi tidak tampil di PDF.

Bank / RekeningMata UangStatusAksi
bank_name)?>account_number.' · a.n. '.$b->account_holder)?>currency)?>is_active?'Aktif':'Nonaktif'?>is_primary?'Utama':''?>
Belum ada rekening perusahaan.
+ +
Master Mata Uang

Atur kode, simbol, desimal, dan status mata uang.


Kurs Harian

Simpan kurs untuk transaksi multi-currency perusahaan aktif.

TanggalMata UangKursSumber
rate_date?>from_currency?> / to_currency?>rate,8,',','.')?>source?:'-')?>
+ +
Perusahaan yang Dapat Diakses

Pilih untuk mengganti konteks data aktif.

Tambah Perusahaan

Membuat tenant perusahaan baru tanpa mengubah perusahaan aktif.

+
+
+ diff --git a/application/views/invoices/editor.php b/application/views/invoices/editor.php index 21d6789..f5ed4c2 100644 --- a/application/views/invoices/editor.php +++ b/application/views/invoices/editor.php @@ -14,7 +14,7 @@ function invLabel($value){$labels=array('draft'=>'Draft','submitted'=>'Menunggu
no_invoice)?>

- +
Header Draft inventory_line_count?'Pengiriman':'Tanpa Surat Jalan'?> Diajukan Approved & Posted
diff --git a/application/views/sales/invoice_pdf.php b/application/views/sales/invoice_pdf.php index d09f15b..487a3f9 100644 --- a/application/views/sales/invoice_pdf.php +++ b/application/views/sales/invoice_pdf.php @@ -1,23 +1,60 @@ code)?$pdf_currency->code:($company->base_currency??'IDR'); +$currencySymbol=isset($pdf_currency->symbol)&&trim((string)$pdf_currency->symbol)!==''?$pdf_currency->symbol:$currencyCode; +$groups=array(); +$barcodesByLine=array(); +foreach($barcodes as$b)$barcodesByLine[(int)$b->invoice_detail_id][]=$b; foreach($lines as$l){ - $detail=trim((string)($l->keterangan?:$l->specification)); - $key=$l->service_date.'|'.$l->items_id.'|'.$l->harga.'|'.$l->tax_rate.'|'.$detail; - $label=trim(($l->item_code?$l->item_code.' - ':'').$l->nama_item); - if(!isset($groups[$key]))$groups[$key]=array('date'=>$l->service_date,'item'=>$label,'detail'=>$detail,'qty'=>0,'unit'=>$l->unit,'price'=>$l->harga,'discount'=>0,'tax'=>0,'total'=>0); - $groups[$key]['qty']+=(float)$l->qty;$groups[$key]['discount']+=(float)$l->discount_amount;$groups[$key]['tax']+=(float)$l->tax_amount;$groups[$key]['total']+=(float)$l->subtotal; + $code=trim((string)($l->item_code??'')); + $name=trim((string)$l->nama_item); + $key=$l->service_date.'|'.$l->line_type.'|'.$code.'|'.$name.'|'.$l->unit; + $label=trim(($code!==''?$code.' - ':'').$name); + if(!isset($groups[$key]))$groups[$key]=array('date'=>$l->service_date,'item'=>$label,'qty'=>0,'unit'=>$l->unit,'gross'=>0,'discount'=>0,'tax'=>0,'total'=>0); + $groups[$key]['qty']+=(float)$l->qty; + $groups[$key]['gross']+=(float)$l->harga*(float)$l->qty; + $groups[$key]['discount']+=(float)$l->discount_amount; + $groups[$key]['tax']+=(float)$l->tax_amount; + $groups[$key]['total']+=(float)$l->subtotal; } function ipMoney($v){return number_format((float)$v,2,',','.');} -function ipQty($v){return floor($v)==$v?number_format($v,0,',','.'):number_format($v,4,',','.');} +function ipQty($v){return floor((float)$v)==(float)$v?number_format((float)$v,0,',','.'):rtrim(rtrim(number_format((float)$v,4,',','.'),'0'),',');} +$statusKey=$invoice->workflow_status==='posted'?$invoice->status:$invoice->workflow_status; +$statusLabels=array('draft'=>'DRAFT','submitted'=>'MENUNGGU PERSETUJUAN','approved'=>'DISETUJUI','posted'=>'POSTED','unpaid'=>'BELUM LUNAS','partial'=>'DIBAYAR SEBAGIAN','paid'=>'LUNAS','cancelled'=>'DIBATALKAN','reversed'=>'DIBALIK'); +$statusLabel=$statusLabels[$statusKey]??strtoupper(str_replace('_',' ',$statusKey)); +$previousTotal=0;foreach($previous_invoices as$old)$previousTotal+=(float)$old->sisa_piutang;$customerOutstanding=$previousTotal+(float)$invoice->sisa_piutang; ?> - -

name??'Perusahaan')?>

address??'')?>

INVOICE

no_invoice)?>
tanggal)?>
-
Kepada
customer_name)?>
customer_address?:'-'))?>
Periode
period_start?:$invoice->tanggal).' s.d. '.($invoice->period_end?:$invoice->tanggal))?>
Jatuh tempo: jatuh_tempo)?>
Pengakuan: recognition_policy==='accrual'?'Akrual':'Setelah pembayaran'?>

-
TANGGALBARANG / JASA & KETERANGANQTYHARGADISKONPAJAKSUBTOTAL

-
Subtotal bersihsubtotal_before_tax)?>
Diskondiscount_total)?>
Pajaktax_total)?>
TotalRp total)?>
Sudah dibayartotal_bayar)?>
Sisa piutangsisa_piutang)?>
+
+ +
name??'Perusahaan')?>
+
address??'-'))?>
+
Telp: phone??'-')?>
+

INVOICE

no_invoice)?>
tanggal)?>
+ +
Ditagihkan kepada
customer_name)?>
customer_address?:'-'))?>
Informasi Invoice
Tanggal Invoice: tanggal)?>
Tanggal Jatuh Tempo: jatuh_tempo)?>
Mata Uang:
+ + + + +
TANGGALBARANG / JASA / TABUNGANQTYHARGADISKONPAJAKSUBTOTAL
Belum ada item pada invoice ini.
+ +
Subtotal bersihsubtotal_before_tax)?>
Diskondiscount_total)?>
Pajaktax_total)?>
Total Invoice total)?>
Sudah dibayartotal_bayar)?>
Sisa tagihansisa_piutang)?>
+ +
Daftar Tagihan Sebelumnya yang Belum Lunas
INVOICETANGGALJATUH TEMPOTOTALTERBAYARSISA TAGIHAN
no_invoice)?>tanggal)?>jatuh_tempo)?>total)?>total_bayar)?>sisa_piutang)?>
Total sisa tagihan customer_name)?>, termasuk invoice ini:
+ +
Rekening Pembayaran Perusahaan
bank_name)?> · account_number)?> · a.n. account_holder)?> (currency)?>)is_primary?' · Utama':''?>
+

Surat Jalan: delivery_no;},$deliveries))?:'-')?>
Catatan: keterangan?:'-')?>

Dibuat oleh


creator_name?:'-')?>
Disetujui


approver_name?:'-')?>
Diterima Customer
-

Lampiran Detail Barcode / Serial Number

Lampiran invoice no_invoice)?> · baris barcode

TANGGALSURAT JALANBARANGBARCODE / SERIALQTYGUDANGSTATUS
service_date)?>delivery_no?:'-')?>item_code?$b->item_code.' - ':'').$b->nama_barang))?>barcode)?>
serial_number?:'-')?>
qty)?>warehouse_name?:'-')?>workflow_status,array('posted','approved'),true)?'Terkirim':'Dialokasikan'?>
Invoice ini tidak berisi barang gudang sehingga tidak memiliki detail barcode atau Surat Jalan.
+ +

Lampiran Detail

Rincian seluruh item Invoice no_invoice)?>.

+id]??array();$label=trim((!empty($l->item_code)?$l->item_code.' - ':'').$l->nama_item);$note=trim((string)($l->keterangan?:$l->specification));if($lineBars):foreach($lineBars as$b):?> + +
TANGGALSURAT JALANBARANG / JASA / TABUNGANBARCODE / SERIALQTYKETERANGAN
service_date)?>delivery_no?:'-')?>barcode)?>
serial_number?:'-')?>
qty).' '.html_escape($l->unit)?>
service_date)?>--qty).' '.html_escape($l->unit)?>
Belum ada detail item pada invoice ini.
diff --git a/assets/css/company-settings.css b/assets/css/company-settings.css new file mode 100644 index 0000000..ff7afdf --- /dev/null +++ b/assets/css/company-settings.css @@ -0,0 +1 @@ +.company-settings-workspace{padding:20px;background:#f4f6f9;min-height:calc(100vh - 70px);color:#182230}.company-settings-hero{display:flex;justify-content:space-between;gap:20px;align-items:center;padding:24px 28px;border-radius:18px;background:linear-gradient(125deg,#172b4d,#24456f 62%,#e77800);color:#fff;margin-bottom:16px}.company-settings-hero span{font-size:10px;letter-spacing:.18em;color:#fbbf77}.company-settings-hero h3{margin:4px 0}.company-settings-hero p{margin:0;color:#dbe7f5}.active-company-chip{min-width:260px;padding:11px 14px;border:1px solid rgba(255,255,255,.22);border-radius:12px;background:rgba(255,255,255,.1)}.active-company-chip small,.active-company-chip b{display:block}.company-settings-nav{display:flex;gap:6px;padding:8px;margin-bottom:15px;border:1px solid #e4e7ec;border-radius:14px;background:#fff;overflow-x:auto;flex-wrap:nowrap}.company-settings-nav .nav-link{white-space:nowrap;color:#344054;border-radius:9px}.company-settings-nav .nav-link.active{background:#172b4d}.settings-card{padding:20px;border:1px solid #e4e7ec;border-radius:14px;background:#fff;box-shadow:0 5px 18px rgba(16,24,40,.04)}.settings-card-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:18px;padding-bottom:12px;border-bottom:1px solid #eaecf0}.settings-card-head h5{margin:0}.settings-card-head p{margin:3px 0 0;color:#667085}.identity-grid{display:grid;grid-template-columns:240px minmax(0,1fr);gap:25px}.logo-field small{display:block;color:#667085;margin-top:6px}.company-logo-preview{display:flex;height:150px;margin-bottom:12px;align-items:center;justify-content:center;flex-direction:column;border:1px dashed #98a2b3;border-radius:12px;background:#f8fafc;color:#667085;overflow:hidden}.company-logo-preview img{width:100%;height:100%;object-fit:contain}.company-logo-preview i{font-size:32px}.settings-split{display:grid;grid-template-columns:minmax(300px,38%) minmax(0,62%);gap:15px}.table small{display:block;color:#667085}.currency-list,.company-switch-list{display:grid;gap:7px}.currency-row,.company-switch{display:flex;width:100%;justify-content:space-between;align-items:center;padding:11px;border:1px solid #e4e7ec;border-radius:9px;background:#fff;color:#344054;text-align:left;text-decoration:none}.currency-row:hover,.company-switch:hover{border-color:#84adff;background:#f5f8ff}.currency-row span:last-child{font-size:11px;color:#667085}.company-switch span,.company-switch small{display:block}.company-switch.active{border-color:#175cd3;background:#eff8ff}.form-label{font-size:12px;font-weight:600;color:#344054}@media(max-width:900px){.company-settings-hero{align-items:flex-start;flex-direction:column}.active-company-chip{min-width:0;width:100%}.identity-grid,.settings-split{grid-template-columns:1fr}.company-settings-workspace{padding:12px}} diff --git a/assets/js/invoices-professional.js b/assets/js/invoices-professional.js index 3008a12..ddd2013 100644 --- a/assets/js/invoices-professional.js +++ b/assets/js/invoices-professional.js @@ -135,7 +135,9 @@ '"> Buat Surat Jalan'; if (p.export) html += - '