update koneksi DB

This commit is contained in:
Wian Drs
2026-09-11 18:06:17 +07:00
parent 4ef15c22ae
commit ff870b7667
30 changed files with 390 additions and 115 deletions
+1 -1
View File
@@ -72,6 +72,6 @@ $config['document_numbering'] = array(
); );
$config['require_separate_journal_approver'] = filter_var( $config['require_separate_journal_approver'] = filter_var(
getenv('WORKFLOW_REQUIRE_SEPARATE_APPROVER') ?: 'false', app_env('WORKFLOW_REQUIRE_SEPARATE_APPROVER', 'false'),
FILTER_VALIDATE_BOOLEAN FILTER_VALIDATE_BOOLEAN
); );
+4 -4
View File
@@ -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. | 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 | 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') { if (ENVIRONMENT === 'production' && $config['encryption_key'] === 'development-only-change-this-key') {
show_error('APP_ENCRYPTION_KEY wajib dikonfigurasi pada environment production.', 500); 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. | 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_cookie_name'] = 'accounting_session';
$config['sess_samesite'] = 'Lax'; $config['sess_samesite'] = 'Lax';
$config['sess_expiration'] = 7200; $config['sess_expiration'] = 7200;
@@ -416,7 +416,7 @@ $config['sess_regenerate_destroy'] = FALSE;
$config['cookie_prefix'] = 'accounting_'; $config['cookie_prefix'] = 'accounting_';
$config['cookie_domain'] = ''; $config['cookie_domain'] = '';
$config['cookie_path'] = '/'; $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_httponly'] = TRUE;
$config['cookie_samesite'] = 'Lax'; $config['cookie_samesite'] = 'Lax';
+5 -5
View File
@@ -75,11 +75,11 @@ $query_builder = TRUE;
$db['default'] = array( $db['default'] = array(
'dsn' => '', 'dsn' => '',
'hostname' => getenv('DB_HOST') ?: '127.0.0.1', 'hostname' => app_env('DB_HOST', '127.0.0.1') ?: '127.0.0.1',
'port' => (int) (getenv('DB_PORT') ?: 3306), 'port' => (int) (app_env('DB_PORT', 3306) ?: 3306),
'username' => getenv('DB_USERNAME') ?: '', 'username' => app_env('DB_USERNAME', '') ?: '',
'password' => getenv('DB_PASSWORD') ?: '', 'password' => app_env('DB_PASSWORD', '') ?: '',
'database' => getenv('DB_DATABASE') ?: '', 'database' => app_env('DB_DATABASE', '') ?: '',
'dbdriver' => 'mysqli', // WAJIB: driver PostgreSQL 'dbdriver' => 'mysqli', // WAJIB: driver PostgreSQL
'dbprefix' => '', 'dbprefix' => '',
+1 -1
View File
@@ -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 __construct() { parent::__construct(); if(!is_cli()) show_404(); $this->load->library('AuditService'); }
public function duplicate_refs() 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(); $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(); $changed=0; $this->db->trans_begin();
try { try {
@@ -20,7 +20,7 @@ class AttendanceWebhook extends CI_Controller {
show_error('Serial number perangkat tidak valid.', 400); 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)) { if (!empty($allowedSerials) && !in_array($sn, $allowedSerials, true)) {
log_message('error', 'Attendance webhook ditolak untuk SN: ' . $sn); log_message('error', 'Attendance webhook ditolak untuk SN: ' . $sn);
show_error('Perangkat tidak terdaftar.', 403); show_error('Perangkat tidak terdaftar.', 403);
+62 -6
View File
@@ -1,11 +1,67 @@
<?php <?php
defined('BASEPATH') OR exit('No direct script access allowed'); defined('BASEPATH') OR exit('No direct script access allowed');
class Companies extends MY_Admin_Controller class Companies extends MY_Admin_Controller
{ {
public function __construct(){parent::__construct();$this->load->database();$this->load->library(array('CompanyContext','MultiCurrencyService'));} public function __construct(){parent::__construct();$this->load->database();$this->load->library(array('CompanyContext','MultiCurrencyService'));}
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 index()
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');} $companyId=(int)$this->companycontext->id();
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');} $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);}
} }
+1 -1
View File
@@ -16,7 +16,7 @@ class Databasesync extends CI_Controller
if (!$this->input->is_cli_request()) { if (!$this->input->is_cli_request()) {
show_error('Database sync hanya dapat dijalankan melalui CLI.', 403, 'Akses ditolak'); 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"); fwrite(STDERR, "DBSYNC_ENABLED belum diaktifkan.\n");
exit(2); exit(2);
} }
+6 -6
View File
@@ -10,7 +10,7 @@ class Dbcompare extends MY_Admin_Controller
{ {
parent::__construct(); 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(); show_404();
} }
@@ -21,11 +21,11 @@ class Dbcompare extends MY_Admin_Controller
private function readConnectionConfig($prefix) private function readConnectionConfig($prefix)
{ {
$config = array( $config = array(
'host' => getenv($prefix . '_HOST'), 'host' => app_env($prefix . '_HOST', ''),
'port' => (int) getenv($prefix . '_PORT'), 'port' => (int) app_env($prefix . '_PORT', 3306),
'user' => getenv($prefix . '_USERNAME'), 'user' => app_env($prefix . '_USERNAME', ''),
'pass' => getenv($prefix . '_PASSWORD'), 'pass' => app_env($prefix . '_PASSWORD', ''),
'db' => getenv($prefix . '_DATABASE') 'db' => app_env($prefix . '_DATABASE', '')
); );
foreach (array('host', 'port', 'user', 'pass', 'db') as $key) { foreach (array('host', 'port', 'user', 'pass', 'db') as $key) {
+3 -3
View File
@@ -3,7 +3,7 @@ defined('BASEPATH') OR exit('No direct script access allowed');
class Inventoryrepair extends CI_Controller class Inventoryrepair extends CI_Controller
{ {
public function __construct(){parent::__construct();if(!is_cli())show_404();$this->load->library('ProfessionalInventoryService');} 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 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)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 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)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 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";}
} }
+10 -2
View File
@@ -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); $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); $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)); return json_response(true,'Daftar invoice ditemukan.',array('draw'=>$draw,'recordsTotal'=>$recordsTotal,'recordsFiltered'=>$recordsFiltered,'data'=>$rows));
} }
private function invoiceQuery($filters,$term) 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 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) 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) private function mutation($permission,callable$callback,$message)
{ {
@@ -3,6 +3,6 @@ defined('BASEPATH') OR exit('No direct script access allowed');
class Journaldescriptionrepair extends CI_Controller class Journaldescriptionrepair extends CI_Controller
{ {
public function __construct(){parent::__construct();if(!is_cli())show_404();$this->load->library(array('JournalDescriptionService','AuditService'));} 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);} 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);}
} }
+1 -1
View File
@@ -7,7 +7,7 @@ class Queueworker extends CI_Controller
private function handle($type,array$p,$job){ 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==='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==='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><body><h2>'.html_escape($p['report_code']).'</h2><pre>'.html_escape(json_encode($rows,JSON_PRETTY_PRINT)).'</pre></body></html>');elseif($rows){fputcsv($h,array_keys($rows[0]));foreach($rows as$r)fputcsv($h,$r);}fclose($h); 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><body><h2>'.html_escape($p['report_code']).'</h2><pre>'.html_escape(json_encode($rows,JSON_PRETTY_PRINT)).'</pre></body></html>');elseif($rows){fputcsv($h,array_keys($rows[0]));foreach($rows as$r)fputcsv($h,$r);}fclose($h);
} }
} }
+2 -2
View File
@@ -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 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 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 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 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 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) 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 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 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 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) 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'); 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');
+1 -1
View File
@@ -16,7 +16,7 @@ class AuditService
'user_id'=>$userId ? (int)$userId : null, 'user_id'=>$userId ? (int)$userId : null,
'ip_address'=>isset($this->CI->input) ? $this->CI->input->ip_address() : 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, '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 '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)); $data['record_hash'] = hash('sha256', ($data['previous_hash'] ?: '') . json_encode($data, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES));
+1 -1
View File
@@ -3,7 +3,7 @@ defined('BASEPATH') OR exit('No direct script access allowed');
class CashBankService class CashBankService
{ {
private $CI;public function __construct(){$this->CI=&get_instance();$this->CI->load->library(array('PostingService','FiscalPeriodService'));} 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;} 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 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;}} 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;}}
+10 -13
View File
@@ -70,14 +70,14 @@ class DatabaseSyncService
); );
} }
$backup = trim((string) getenv('DBSYNC_BACKUP_FILE')); $backup = trim((string) app_env('DBSYNC_BACKUP_FILE', ''));
if ($backup === '') { if ($backup === '') {
$backup = $this->createSnapshot($source); $backup = $this->createSnapshot($source);
} elseif (!is_file($backup) || !is_readable($backup)) { } elseif (!is_file($backup) || !is_readable($backup)) {
throw new RuntimeException('File backup tidak ditemukan atau tidak dapat dibaca.'); 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); $actualHash = hash_file('sha256', $backup);
if ($expectedHash !== '' && !hash_equals($expectedHash, strtolower($actualHash))) { if ($expectedHash !== '' && !hash_equals($expectedHash, strtolower($actualHash))) {
throw new RuntimeException('Checksum backup tidak sesuai. Target belum diubah.'); throw new RuntimeException('Checksum backup tidak sesuai. Target belum diubah.');
@@ -177,11 +177,11 @@ class DatabaseSyncService
private function readConfig($prefix) private function readConfig($prefix)
{ {
$config = array( $config = array(
'host' => trim((string) getenv($prefix . '_HOST')), 'host' => trim((string) app_env($prefix . '_HOST', '')),
'port' => (int) (getenv($prefix . '_PORT') ?: 3306), 'port' => (int) (app_env($prefix . '_PORT', 3306) ?: 3306),
'username' => trim((string) getenv($prefix . '_USERNAME')), 'username' => trim((string) app_env($prefix . '_USERNAME', '')),
'password' => (string) getenv($prefix . '_PASSWORD'), 'password' => (string) app_env($prefix . '_PASSWORD', ''),
'database' => trim((string) getenv($prefix . '_DATABASE')), 'database' => trim((string) app_env($prefix . '_DATABASE', '')),
); );
foreach (array('host', 'port', 'username', 'password', 'database') as $key) { foreach (array('host', 'port', 'username', 'password', 'database') as $key) {
@@ -255,7 +255,7 @@ class DatabaseSyncService
private function createSnapshot($source) private function createSnapshot($source)
{ {
$directory = trim((string) getenv('DBSYNC_BACKUP_DIRECTORY')); $directory = trim((string) app_env('DBSYNC_BACKUP_DIRECTORY', ''));
if ($directory === '') { if ($directory === '') {
$directory = dirname(FCPATH) . DIRECTORY_SEPARATOR . 'private_backups' . DIRECTORY_SEPARATOR . 'accounting_dev'; $directory = dirname(FCPATH) . DIRECTORY_SEPARATOR . 'private_backups' . DIRECTORY_SEPARATOR . 'accounting_dev';
} }
@@ -397,7 +397,7 @@ class DatabaseSyncService
private function importBackup($backup) private function importBackup($backup)
{ {
$mysql = trim((string) getenv('DBSYNC_MYSQL_BIN')); $mysql = trim((string) app_env('DBSYNC_MYSQL_BIN', ''));
if ($mysql === '') { if ($mysql === '') {
$mysql = 'C:\\xampp\\mysql\\bin\\mysql.exe'; $mysql = 'C:\\xampp\\mysql\\bin\\mysql.exe';
} }
@@ -440,10 +440,7 @@ class DatabaseSyncService
1 => array('pipe', 'w'), 1 => array('pipe', 'w'),
2 => array('pipe', 'w'), 2 => array('pipe', 'w'),
); );
$processEnvironment = getenv(); $processEnvironment = app_env_all();
if (!is_array($processEnvironment)) {
$processEnvironment = array();
}
foreach ($environment as $key => $value) { foreach ($environment as $key => $value) {
$processEnvironment[$key] = $value; $processEnvironment[$key] = $value;
} }
@@ -6,6 +6,10 @@ class JournalDescriptionService
public function __construct(){$this->CI=&get_instance();} public function __construct(){$this->CI=&get_instance();}
public function build(array $header,array $lines) 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']:''; $type=isset($header['ref_type'])?(string)$header['ref_type']:'';
if($type===''||$type==='manual_journal')return trim((string)$header['keterangan']); if($type===''||$type==='manual_journal')return trim((string)$header['keterangan']);
$total=0;foreach($lines as$line)$total+=isset($line['debit'])?(float)$line['debit']:0; $total=0;foreach($lines as$line)$total+=isset($line['debit'])?(float)$line['debit']:0;
+1 -1
View File
@@ -16,7 +16,7 @@ class NavigationService
array('key'=>'professional_reports','label'=>'Laporan Profesional','icon'=>'bi-bar-chart','url'=>'professionalreports','feature'=>'reports'), 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'=>'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'=>'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'))) 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; );$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;
} }
+1 -1
View File
@@ -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 $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;} 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 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 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;}} 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;}}
@@ -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;}} 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); $entries[]=array('account_id'=>$this->CI->accountmappingservice->get('accounts_receivable'),'debit'=>0,'kredit'=>$debit);
$no=$this->CI->numberingservice->next('credit_note',$date); $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'))); $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); $id=$this->CI->db->insert_id();$balance=max(0,(float)$invoice->sisa_piutang-$amount);
$this->CI->db->where('id',$invoice->id)->update('invoices',array('sisa_piutang'=>$balance,'status'=>$balance<=0?'paid':'partial')); $this->CI->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); $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.'); 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); $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; $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;
}); });
} }
+4 -4
View File
@@ -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 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 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); 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;});} $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);} 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);});} 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.');$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 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);$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$no,'keterangan'=>'Write-off '.$i->no_invoice,'ref_type'=>'receivable_writeoff','ref_id'=>$invoiceId,'created_by'=>$userId),array(array('account_id'=>$this->CI->accountmappingservice->get('bad_debt_expense'),'debit'=>$amount,'kredit'=>0),array('account_id'=>$this->CI->accountmappingservice->get('accounts_receivable'),'debit'=>0,'kredit'=>$amount)),false,true);$this->CI->db->insert('receivable_writeoffs',array('writeoff_no'=>$no,'invoice_id'=>$invoiceId,'writeoff_date'=>$date,'amount'=>$amount,'reason'=>$reason,'status'=>'posted','journal_id'=>$journal,'created_by'=>$userId,'created_at'=>date('Y-m-d H:i:s')));$wid=$this->CI->db->insert_id();$balance=max(0,(float)$i->sisa_piutang-$amount);$this->CI->db->where('id',$i->id)->update('invoices',array('sisa_piutang'=>$balance,'status'=>$balance<=0?'paid':'partial'));$this->CI->auditservice->record('receivable','writeoff',$wid,'post',null,array('invoice_id'=>$invoiceId,'amount'=>$amount),$userId);return $wid;});} public function 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 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);} 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) private function postStock($invoice,$line)
+44 -27
View File
@@ -111,7 +111,7 @@ class SalesService
$price=$this->money($priceValue); $price=$this->money($priceValue);
if($price<0)throw new BusinessException('Harga item ke-'.($index+1).' tidak boleh negatif.'); 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']=''; $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); unset($row);
@@ -119,10 +119,10 @@ class SalesService
if($discount<0)throw new BusinessException('Diskon penambahan item tidak boleh negatif.'); 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.'); 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; $lastPositive=$positiveIndexes?end($positiveIndexes):null;$allocated=0.0;
foreach($rows as $index=>&$row){ 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);} elseif($index===$lastPositive){$lineDiscount=round($discount-$allocated,2);}
else{$lineDiscount=round($discount*$grossValues[$index]/$totalGross,2);} else{$lineDiscount=round($discount*$grossValues[$index]/$totalGross,2);}
$lineDiscount=max(0,min($grossValues[$index],$lineDiscount)); $lineDiscount=max(0,min($grossValues[$index],$lineDiscount));
@@ -154,10 +154,7 @@ class SalesService
$customer=$this->tenant('customers',$invoice->customer_id,'Customer'); $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;}} $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(); $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; 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.');
if(count($families)>1)throw new BusinessException('Invoice penjualan barang/jasa tidak dapat dicampur dengan invoice tabungan. Buat invoice terpisah agar jurnal dan pelunasannya jelas.');
$financialTypes=array();foreach($existingTypes as$t)if($this->lineFamily($t['line_type'])==='financial')$financialTypes[$t['line_type']]=true;foreach($rows as$r)if($this->lineFamily($r['line_type']??'inventory')==='financial')$financialTypes[$r['line_type']]=true;
if(count($financialTypes)>1)throw new BusinessException('Data pinjaman lama dan tabungan tidak dapat dicampur pada invoice yang sama.');
foreach($rows as$n=>$row){ 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.'); $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']??'')); $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,',','.'); $qty=number_format((float)$line->qty,abs((float)$line->qty-round((float)$line->qty))<.0001?0:4,',','.');
if($line->line_type==='inventory'){ if($line->line_type==='inventory'){
$codes=array();foreach($barcodes as$b)if(isset($b['row']->barcode))$codes[]=$b['row']->barcode; $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{ }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) 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); $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; if(!$entries)return null;
$date=$line->service_date?:($line->tanggal?:date('Y-m-d')); $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)); $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; 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);} 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'))); $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; $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) 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.'); $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; $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($family==='financial'&&$lines[0]->line_type==='savings'){ 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->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; $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'){ if($hasSales&&!$hasLoan){
$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++;}} $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){ 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')); $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; $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'){ 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;}} $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); 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'&&$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);} 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; $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,'ref_type'=>'invoice','ref_id'=>$i->id,'created_by'=>$user),$entries,false,true); $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==='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')));} 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; $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.'); 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.'); $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); if($advance>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('customer_advances'),'debit'=>0,'kredit'=>$advance);
$paymentNo=$this->CI->numberingservice->next('payment',$date); $paymentNo=$this->CI->numberingservice->next('payment',$date);
$invoiceNames=array_map(function($x){return$x[0]->no_invoice.' Rp '.number_format($x[1],2,',','.');},$locked); $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;}} $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,',','.'); 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,'ref_type'=>'customer_payment','ref_id'=>null,'created_by'=>$user),$entries,false,false); $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'))); $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(); $this->CI->db->insert('payments',$payment);$paymentId=(int)$this->CI->db->insert_id();
foreach($sourceRows as$s){$this->CI->db->insert('payment_sources',array('company_id'=>$this->company(),'payment_id'=>$paymentId,'account_id'=>$s['account_id'],'amount'=>$s['amount'],'created_at'=>date('Y-m-d H:i:s')));$this->recordCashSubledger($s['account_id'],$date,'in',$s['amount'],'customer',$customer->id,'customer_payment',$paymentId,$journal,'Penerimaan '.$paymentNo.' dari '.$customer->nama,$user);} foreach($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')))); 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)); $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')); $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')))); 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); 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) 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)); $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)); $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; 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);}} $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);} 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->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'); $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); $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)); 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) 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.'); $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; 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'));} 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')));} $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;}); $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));} 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));}
@@ -0,0 +1,42 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Add_company_invoice_settings extends CI_Migration
{
public function up()
{
if (!$this->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.');
}
}
+27 -1
View File
@@ -1 +1,27 @@
<div class="container-fluid py-3"><h4>Multi-company & Mata Uang</h4><div class="row g-3"><div class="col-lg-6"><div class="card"><div class="card-header">Perusahaan</div><div class="card-body"><form method="post" action="<?=base_url('companies/save')?>"><div class="row g-2"><div class="col-4"><input class="form-control" name="code" placeholder="Kode"></div><div class="col-8"><input class="form-control" name="name" placeholder="Nama"></div><div class="col-8"><input class="form-control" name="legal_name" placeholder="Nama legal"></div><div class="col-4"><select class="form-select" name="base_currency"><?php foreach($currencies as$c):?><option><?=$c->code?></option><?php endforeach?></select></div><div class="col-8"><input class="form-control" name="tax_number" placeholder="NPWP"></div><div class="col-4"><button class="btn btn-primary">Tambah</button></div></div></form><hr><?php foreach($companies as$c):?><a class="btn btn-outline-dark m-1" href="<?=base_url('companies/switch_company/'.$c->id)?>"><?=html_escape($c->company_code.' · '.$c->name)?></a><?php endforeach?></div></div></div><div class="col-lg-6"><div class="card"><div class="card-header">Kurs Harian</div><div class="card-body"><form method="post" action="<?=base_url('companies/rate')?>"><div class="row g-2"><div class="col-4"><input class="form-control" type="date" name="date" value="<?=date('Y-m-d')?>"></div><div class="col-2"><select class="form-select" name="from_currency"><?php foreach($currencies as$c):?><option><?=$c->code?></option><?php endforeach?></select></div><div class="col-2"><select class="form-select" name="to_currency"><?php foreach($currencies as$c):?><option><?=$c->code?></option><?php endforeach?></select></div><div class="col-4"><input class="form-control" type="number" step=".00000001" name="rate" placeholder="Kurs"></div><div class="col-8"><input class="form-control" name="source" placeholder="Sumber kurs"></div><div class="col-4"><button class="btn btn-success">Simpan Kurs</button></div></div></form></div></div></div></div><div class="card mt-3"><div class="table-responsive"><table class="table table-sm"><tr><th>Tanggal</th><th>Perusahaan</th><th>Mata uang</th><th>Kurs</th><th>Sumber</th></tr><?php foreach($rates as$r):?><tr><td><?=$r->rate_date?></td><td><?=html_escape($r->company_name)?></td><td><?=$r->from_currency?>/<?=$r->to_currency?></td><td><?=number_format($r->rate,8,',','.')?></td><td><?=html_escape($r->source?:'-')?></td></tr><?php endforeach?></table></div></div></div> <?php $csrfName=$this->security->get_csrf_token_name();$csrfHash=$this->security->get_csrf_hash(); ?>
<link rel="stylesheet" href="<?=base_url('assets/css/company-settings.css').'?v='.filemtime(FCPATH.'assets/css/company-settings.css')?>">
<section class="company-settings-workspace">
<header class="company-settings-hero"><div><span>ADMINISTRASI PERUSAHAAN</span><h3>Company Setting</h3><p>Kelola identitas dokumen, rekening pembayaran, perusahaan, dan mata uang dari satu tempat.</p></div><div class="active-company-chip"><small>Perusahaan aktif</small><b><?=html_escape($active_company->company_code.' - '.$active_company->name)?></b></div></header>
<?php if($this->session->flashdata('success')):?><div class="alert alert-success"><?=html_escape($this->session->flashdata('success'))?></div><?php endif?>
<?php if($this->session->flashdata('error')):?><div class="alert alert-danger"><?=html_escape($this->session->flashdata('error'))?></div><?php endif?>
<nav class="company-settings-nav nav nav-pills" role="tablist"><button class="nav-link active" data-bs-toggle="pill" data-bs-target="#companyIdentity" type="button"><i class="bi bi-building"></i> Identitas Perusahaan</button><button class="nav-link" data-bs-toggle="pill" data-bs-target="#companyBanks" type="button"><i class="bi bi-bank"></i> Rekening Invoice</button><button class="nav-link" data-bs-toggle="pill" data-bs-target="#companyCurrencies" type="button"><i class="bi bi-currency-exchange"></i> Currency & Kurs</button><button class="nav-link" data-bs-toggle="pill" data-bs-target="#companyList" type="button"><i class="bi bi-buildings"></i> Multi-company</button></nav>
<div class="tab-content">
<div class="tab-pane fade show active" id="companyIdentity"><div class="settings-card"><div class="settings-card-head"><div><h5>Identitas pada Dokumen</h5><p>Informasi ini digunakan pada header PDF Invoice dan dokumen perusahaan.</p></div></div><form method="post" enctype="multipart/form-data" action="<?=base_url('companies/update')?>"><input type="hidden" name="<?=$csrfName?>" value="<?=$csrfHash?>"><div class="identity-grid"><div class="logo-field"><div class="company-logo-preview"><?php if(!empty($active_company->logo_path)&&is_file(FCPATH.$active_company->logo_path)):?><img src="<?=base_url($active_company->logo_path)?>" alt="Logo"><?php else:?><i class="bi bi-image"></i><span>Belum ada logo</span><?php endif?></div><label class="form-label">Logo Perusahaan</label><input class="form-control" type="file" name="logo" accept=".jpg,.jpeg,.png,.webp"><small>Maksimal 2 MB. Gunakan logo rasio mendekati persegi.</small></div><div><div class="row g-3"><div class="col-md-6"><label class="form-label">Nama Perusahaan</label><input class="form-control" name="name" value="<?=html_escape($active_company->name)?>" required></div><div class="col-md-6"><label class="form-label">Nama Legal</label><input class="form-control" name="legal_name" value="<?=html_escape($active_company->legal_name)?>"></div><div class="col-md-4"><label class="form-label">Telepon</label><input class="form-control" name="phone" value="<?=html_escape($active_company->phone??'')?>"></div><div class="col-md-4"><label class="form-label">Email</label><input class="form-control" type="email" name="email" value="<?=html_escape($active_company->email??'')?>"></div><div class="col-md-4"><label class="form-label">Mata Uang Dasar</label><select class="form-select" name="base_currency" required><?php foreach($active_currencies as$c):?><option value="<?=$c->code?>" <?=$active_company->base_currency===$c->code?'selected':''?>><?=html_escape($c->code.' - '.$c->name)?></option><?php endforeach?></select></div><div class="col-md-6"><label class="form-label">NPWP</label><input class="form-control" name="tax_number" value="<?=html_escape($active_company->tax_number)?>"></div><div class="col-12"><label class="form-label">Alamat Perusahaan</label><textarea class="form-control" name="address" rows="3"><?=html_escape($active_company->address)?></textarea></div></div><div class="text-end mt-3"><button class="btn btn-primary"><i class="bi bi-save"></i> Simpan Company Setting</button></div></div></div></form></div></div>
<div class="tab-pane fade" id="companyBanks"><div class="settings-split"><div class="settings-card"><div class="settings-card-head"><div><h5>Tambah / Edit Rekening</h5><p>Rekening aktif otomatis ditampilkan pada PDF Invoice.</p></div></div><form id="bankAccountForm" method="post" action="<?=base_url('companies/save_bank')?>"><input type="hidden" name="<?=$csrfName?>" value="<?=$csrfHash?>"><input type="hidden" name="id" value=""><div class="mb-3"><label class="form-label">Nama Bank / Dompet</label><input class="form-control" name="bank_name" placeholder="Contoh: Bank BRI" required></div><div class="mb-3"><label class="form-label">Nomor Rekening</label><input class="form-control" name="account_number" required></div><div class="mb-3"><label class="form-label">Atas Nama</label><input class="form-control" name="account_holder" required></div><div class="row g-3"><div class="col-7"><label class="form-label">Mata Uang</label><select class="form-select" name="currency"><?php foreach($active_currencies as$c):?><option value="<?=$c->code?>"><?=$c->code?></option><?php endforeach?></select></div><div class="col-5 d-flex align-items-end"><label class="form-check mb-2"><input class="form-check-input" type="checkbox" name="is_primary" value="1"><span class="form-check-label">Rekening utama</span></label></div></div><div class="d-flex gap-2 mt-3"><button class="btn btn-primary"><i class="bi bi-save"></i> Simpan Rekening</button><button class="btn btn-light d-none" id="cancelBankEdit" type="button">Batal Edit</button></div></form></div><div class="settings-card"><div class="settings-card-head"><div><h5>Daftar Rekening Perusahaan</h5><p>Rekening nonaktif disimpan dalam histori, tetapi tidak tampil di PDF.</p></div></div><div class="table-responsive"><table class="table align-middle"><thead><tr><th>Bank / Rekening</th><th>Mata Uang</th><th>Status</th><th>Aksi</th></tr></thead><tbody><?php foreach($bank_accounts as$b):?><tr><td><b><?=html_escape($b->bank_name)?></b><small><?=html_escape($b->account_number.' · a.n. '.$b->account_holder)?></small></td><td><?=html_escape($b->currency)?></td><td><span class="badge <?=$b->is_active?'text-bg-success':'text-bg-secondary'?>"><?=$b->is_active?'Aktif':'Nonaktif'?></span><?=$b->is_primary?'<span class="badge text-bg-primary ms-1">Utama</span>':''?></td><td><div class="d-flex gap-1"><button class="btn btn-sm btn-outline-primary edit-bank" type="button" data-row='<?=html_escape(json_encode($b,JSON_HEX_APOS|JSON_HEX_QUOT))?>'>Edit</button><form method="post" action="<?=base_url('companies/toggle_bank/'.$b->id)?>"><input type="hidden" name="<?=$csrfName?>" value="<?=$csrfHash?>"><button class="btn btn-sm btn-outline-secondary"><?=$b->is_active?'Nonaktifkan':'Aktifkan'?></button></form></div></td></tr><?php endforeach?><?php if(!$bank_accounts):?><tr><td colspan="4" class="text-center text-muted py-4">Belum ada rekening perusahaan.</td></tr><?php endif?></tbody></table></div></div></div></div>
<div class="tab-pane fade" id="companyCurrencies"><div class="settings-split"><div class="settings-card"><div class="settings-card-head"><div><h5>Master Mata Uang</h5><p>Atur kode, simbol, desimal, dan status mata uang.</p></div></div><form id="currencyForm" method="post" action="<?=base_url('companies/save_currency')?>"><input type="hidden" name="<?=$csrfName?>" value="<?=$csrfHash?>"><div class="row g-3"><div class="col-4"><label class="form-label">Kode</label><input class="form-control text-uppercase" name="code" maxlength="3" required></div><div class="col-8"><label class="form-label">Nama</label><input class="form-control" name="name" required></div><div class="col-6"><label class="form-label">Simbol</label><input class="form-control" name="symbol"></div><div class="col-6"><label class="form-label">Jumlah Desimal</label><input class="form-control" type="number" min="0" max="6" name="decimal_places" value="2"></div><div class="col-12"><label class="form-check"><input class="form-check-input" type="checkbox" name="is_active" value="1" checked><span class="form-check-label">Mata uang aktif</span></label></div></div><div class="d-flex gap-2 mt-3"><button class="btn btn-primary">Simpan Mata Uang</button><button class="btn btn-light d-none" id="cancelCurrencyEdit" type="button">Batal Edit</button></div></form><hr><div class="currency-list"><?php foreach($currencies as$c):?><button class="currency-row edit-currency" type="button" data-row='<?=html_escape(json_encode($c,JSON_HEX_APOS|JSON_HEX_QUOT))?>'><span><b><?=$c->code?></b> <?=html_escape($c->name)?></span><span><?=html_escape($c->symbol?:'-')?> · <?=$c->decimal_places?> desimal · <?=$c->is_active?'Aktif':'Nonaktif'?></span></button><?php endforeach?></div></div><div class="settings-card"><div class="settings-card-head"><div><h5>Kurs Harian</h5><p>Simpan kurs untuk transaksi multi-currency perusahaan aktif.</p></div></div><form method="post" action="<?=base_url('companies/rate')?>"><input type="hidden" name="<?=$csrfName?>" value="<?=$csrfHash?>"><div class="row g-3"><div class="col-md-5"><label class="form-label">Tanggal</label><input class="form-control" type="date" name="date" value="<?=date('Y-m-d')?>" required></div><div class="col-md-3"><label class="form-label">Dari</label><select class="form-select" name="from_currency"><?php foreach($active_currencies as$c):?><option><?=$c->code?></option><?php endforeach?></select></div><div class="col-md-4"><label class="form-label">Ke</label><select class="form-select" name="to_currency"><?php foreach($active_currencies as$c):?><option><?=$c->code?></option><?php endforeach?></select></div><div class="col-md-6"><label class="form-label">Nilai Kurs</label><input class="form-control" type="number" min="0" step=".00000001" name="rate" required></div><div class="col-md-6"><label class="form-label">Sumber</label><input class="form-control" name="source"></div></div><button class="btn btn-success mt-3">Simpan Kurs</button></form><div class="table-responsive mt-4"><table class="table table-sm"><thead><tr><th>Tanggal</th><th>Mata Uang</th><th>Kurs</th><th>Sumber</th></tr></thead><tbody><?php foreach($rates as$r):?><tr><td><?=$r->rate_date?></td><td><?=$r->from_currency?> / <?=$r->to_currency?></td><td><?=number_format($r->rate,8,',','.')?></td><td><?=html_escape($r->source?:'-')?></td></tr><?php endforeach?></tbody></table></div></div></div></div>
<div class="tab-pane fade" id="companyList"><div class="settings-split"><div class="settings-card"><div class="settings-card-head"><div><h5>Perusahaan yang Dapat Diakses</h5><p>Pilih untuk mengganti konteks data aktif.</p></div></div><div class="company-switch-list"><?php foreach($companies as$c):?><a class="company-switch <?=$c->id==$active_company->id?'active':''?>" href="<?=base_url('companies/switch_company/'.$c->id)?>"><span><b><?=html_escape($c->name)?></b><small><?=html_escape($c->company_code.' · '.$c->base_currency)?></small></span><i class="bi bi-chevron-right"></i></a><?php endforeach?></div></div><div class="settings-card"><div class="settings-card-head"><div><h5>Tambah Perusahaan</h5><p>Membuat tenant perusahaan baru tanpa mengubah perusahaan aktif.</p></div></div><form method="post" action="<?=base_url('companies/save')?>"><input type="hidden" name="<?=$csrfName?>" value="<?=$csrfHash?>"><div class="row g-3"><div class="col-4"><label class="form-label">Kode</label><input class="form-control" name="code" required></div><div class="col-8"><label class="form-label">Nama</label><input class="form-control" name="name" required></div><div class="col-8"><label class="form-label">Nama Legal</label><input class="form-control" name="legal_name"></div><div class="col-4"><label class="form-label">Mata Uang</label><select class="form-select" name="base_currency"><?php foreach($active_currencies as$c):?><option><?=$c->code?></option><?php endforeach?></select></div><div class="col-6"><label class="form-label">Telepon</label><input class="form-control" name="phone"></div><div class="col-6"><label class="form-label">Email</label><input class="form-control" type="email" name="email"></div><div class="col-12"><label class="form-label">NPWP</label><input class="form-control" name="tax_number"></div><div class="col-12"><label class="form-label">Alamat</label><textarea class="form-control" name="address" rows="2"></textarea></div></div><button class="btn btn-primary mt-3">Tambah Perusahaan</button></form></div></div></div>
</div>
</section>
<script>
(function($){
function resetBank(){var f=document.getElementById('bankAccountForm');f.reset();f.elements.id.value='';$('#cancelBankEdit').addClass('d-none');}
$('.edit-bank').on('click',function(){var r=$(this).data('row'),f=document.getElementById('bankAccountForm');f.elements.id.value=r.id;f.elements.bank_name.value=r.bank_name;f.elements.account_number.value=r.account_number;f.elements.account_holder.value=r.account_holder;f.elements.currency.value=r.currency;f.elements.is_primary.checked=Number(r.is_primary)===1;$('#cancelBankEdit').removeClass('d-none');f.scrollIntoView({behavior:'smooth',block:'center'});});$('#cancelBankEdit').on('click',resetBank);
function resetCurrency(){var f=document.getElementById('currencyForm');f.reset();f.elements.code.readOnly=false;$('#cancelCurrencyEdit').addClass('d-none');}
$('.edit-currency').on('click',function(){var r=$(this).data('row'),f=document.getElementById('currencyForm');f.elements.code.value=r.code;f.elements.code.readOnly=true;f.elements.name.value=r.name;f.elements.symbol.value=r.symbol||'';f.elements.decimal_places.value=r.decimal_places;f.elements.is_active.checked=Number(r.is_active)===1;$('#cancelCurrencyEdit').removeClass('d-none');f.scrollIntoView({behavior:'smooth',block:'center'});});$('#cancelCurrencyEdit').on('click',resetCurrency);
})(jQuery);
</script>
+1 -1
View File
@@ -14,7 +14,7 @@ function invLabel($value){$labels=array('draft'=>'Draft','submitted'=>'Menunggu
<section class="invoice-workspace invoice-editor-page p-0"> <section class="invoice-workspace invoice-editor-page p-0">
<header class="invoice-hero"> <header class="invoice-hero">
<div><span><?=$creating?'INVOICE BARU':'KELOLA INVOICE'?></span><h5><?=$creating?'Buat Invoice':html_escape($invoice->no_invoice)?></h5><p><?=$creating?'Tentukan customer dan aturan penagihan. Item dapat ditambahkan setelah Draft tersimpan.':'Item dapat ditambahkan bertahap dan langsung tersimpan pada Draft invoice.'?></p></div> <div><span><?=$creating?'INVOICE BARU':'KELOLA INVOICE'?></span><h5><?=$creating?'Buat Invoice':html_escape($invoice->no_invoice)?></h5><p><?=$creating?'Tentukan customer dan aturan penagihan. Item dapat ditambahkan setelah Draft tersimpan.':'Item dapat ditambahkan bertahap dan langsung tersimpan pada Draft invoice.'?></p></div>
<div class="hero-actions"><a class="btn btn-light" href="<?=base_url('invoices')?>"><i class="bi bi-arrow-left"></i> Daftar Invoice</a><?php if($invoice&&$p['export']):?><button class="btn btn-outline-danger open-invoice-pdf" data-url="<?=base_url('invoices/pdf/'.$invoice->id)?>"><i class="bi bi-file-pdf"></i> PDF</button><?php endif?></div> <div class="hero-actions"><a class="btn btn-light" href="<?=base_url('invoices')?>"><i class="bi bi-arrow-left"></i> Daftar Invoice</a><?php if($invoice&&$p['export']):?><button class="btn btn-outline-danger open-invoice-pdf" data-id="<?=$invoice->id?>" data-url="<?=base_url('invoices/pdf/'.$invoice->id)?>"><i class="bi bi-file-pdf"></i> PDF</button><?php endif?></div>
</header> </header>
<?php if($invoice):?><div class="invoice-progress"><span class="done"><i class="bi bi-check-lg"></i> Header Draft</span><span class="<?=$invoice->delivery_status==='delivered'?'done':''?>"><i class="bi bi-truck"></i> <?=$invoice->inventory_line_count?'Pengiriman':'Tanpa Surat Jalan'?></span><span class="<?=in_array($invoice->workflow_status,array('submitted','approved','posted'),true)?'done':''?>"><i class="bi bi-send"></i> Diajukan</span><span class="<?=$invoice->workflow_status==='posted'?'done':''?>"><i class="bi bi-patch-check"></i> Approved & Posted</span></div><?php endif?> <?php if($invoice):?><div class="invoice-progress"><span class="done"><i class="bi bi-check-lg"></i> Header Draft</span><span class="<?=$invoice->delivery_status==='delivered'?'done':''?>"><i class="bi bi-truck"></i> <?=$invoice->inventory_line_count?'Pengiriman':'Tanpa Surat Jalan'?></span><span class="<?=in_array($invoice->workflow_status,array('submitted','approved','posted'),true)?'done':''?>"><i class="bi bi-send"></i> Diajukan</span><span class="<?=$invoice->workflow_status==='posted'?'done':''?>"><i class="bi bi-patch-check"></i> Approved & Posted</span></div><?php endif?>
+51 -14
View File
@@ -1,23 +1,60 @@
<?php <?php
$company=$pdf_company;$groups=array(); $company=$pdf_company;
$previous_invoices=isset($previous_invoices)?$previous_invoices:array();
$pdf_bank_accounts=isset($pdf_bank_accounts)?$pdf_bank_accounts:array();
$currencyCode=isset($pdf_currency->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){ foreach($lines as$l){
$detail=trim((string)($l->keterangan?:$l->specification)); $code=trim((string)($l->item_code??''));
$key=$l->service_date.'|'.$l->items_id.'|'.$l->harga.'|'.$l->tax_rate.'|'.$detail; $name=trim((string)$l->nama_item);
$label=trim(($l->item_code?$l->item_code.' - ':'').$l->nama_item); $key=$l->service_date.'|'.$l->line_type.'|'.$code.'|'.$name.'|'.$l->unit;
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); $label=trim(($code!==''?$code.' - ':'').$name);
$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; 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 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;
?> ?>
<!doctype html><html><head><meta charset="utf-8"><style> <!doctype html>
@page{margin:28px 34px}body{font-family:DejaVu Sans,sans-serif;font-size:10px;color:#20242c}h1{font-size:20px;margin:0}.muted{color:#667085}.head{width:100%;margin-bottom:18px}.head td{vertical-align:top}.right{text-align:right}table.grid{width:100%;border-collapse:collapse}table.grid th{background:#eef2f6;text-align:left;font-size:8px;letter-spacing:.04em}table.grid th,table.grid td{border:1px solid #d8dee8;padding:7px}.num{text-align:right}.summary{margin:14px 0 0 auto;width:44%}.summary td{padding:4px}.page{page-break-before:always}.sign{margin-top:40px;width:100%;text-align:center}.sign td{width:33%;height:80px;vertical-align:top}.repeat{display:table-header-group} <html><head><meta charset="utf-8"><style>
@page{margin:27px 32px}body{font-family:DejaVu Sans,sans-serif;font-size:9.5px;color:#20242c;line-height:1.42}h1{font-size:21px;margin:0}h2{font-size:15px;margin:0 0 4px}.muted{color:#667085}.head{width:100%;margin-bottom:16px}.head td{vertical-align:top}.brand{width:64%}.logo{float:left;width:62px;height:62px;object-fit:contain;margin-right:11px}.company-name{font-size:15px;font-weight:bold;margin:0 0 3px}.right{text-align:right}.invoice-number{font-size:11px;margin:2px 0 6px}.status{display:inline-block;padding:4px 9px;border-radius:11px;font-size:8px;font-weight:bold;color:#fff;background:#475467}.status-paid{background:#15803d}.status-partial{background:#d97706}.status-unpaid,.status-submitted{background:#dc2626}.status-draft{background:#64748b}.status-approved,.status-posted{background:#2563eb}.status-cancelled,.status-reversed{background:#475467}.info{width:100%;margin:8px 0 14px;border-collapse:collapse}.info td{width:50%;vertical-align:top;padding:10px;border:1px solid #d8dee8}.info-label{font-size:8px;color:#667085;text-transform:uppercase;letter-spacing:.06em;margin-bottom:3px}table.grid{width:100%;border-collapse:collapse}table.grid th{background:#eef2f6;text-align:left;font-size:7.5px;letter-spacing:.04em}table.grid th,table.grid td{border:1px solid #d8dee8;padding:6px}.num{text-align:right}.summary{margin:12px 0 0 auto;width:46%;border-collapse:collapse}.summary td{padding:3px 5px}.summary .grand td{padding-top:6px;border-top:1px solid #98a2b3;font-size:11px}.section-title{font-size:11px;font-weight:bold;margin-bottom:6px}.outstanding{margin-top:16px;padding-top:10px;border-top:1px solid #d8dee8}.outstanding-total{text-align:right;font-weight:bold;margin-top:6px}.bank-box{margin-top:14px;padding:9px 11px;border:1px solid #d8dee8;background:#f8fafc}.bank-row{margin-top:3px}.page{page-break-before:always}.sign{margin-top:35px;width:100%;text-align:center}.sign td{width:33%;height:78px;vertical-align:top}.repeat{display:table-header-group}.detail-note{max-width:175px}.empty{text-align:center;color:#667085;padding:15px!important}
</style></head><body> </style></head><body>
<table class="head"><tr><td><h1><?=html_escape($company->name??'Perusahaan')?></h1><div class="muted"><?=html_escape($company->address??'')?></div></td><td class="right"><h1>INVOICE</h1><b><?=html_escape($invoice->no_invoice)?></b><br><?=html_escape($invoice->tanggal)?></td></tr></table> <table class="head"><tr><td class="brand">
<table width="100%"><tr><td><b>Kepada</b><br><?=html_escape($invoice->customer_name)?><br><?=nl2br(html_escape($invoice->customer_address?:'-'))?></td><td class="right"><b>Periode</b><br><?=html_escape(($invoice->period_start?:$invoice->tanggal).' s.d. '.($invoice->period_end?:$invoice->tanggal))?><br><b>Jatuh tempo:</b> <?=html_escape($invoice->jatuh_tempo)?><br><b>Pengakuan:</b> <?=$invoice->recognition_policy==='accrual'?'Akrual':'Setelah pembayaran'?></td></tr></table><br> <?php if(!empty($pdf_logo_data)):?><img class="logo" src="<?=$pdf_logo_data?>"><?php endif?>
<table class="grid"><thead><tr><th>TANGGAL</th><th>BARANG / JASA & KETERANGAN</th><th>QTY</th><th class="num">HARGA</th><th class="num">DISKON</th><th class="num">PAJAK</th><th class="num">SUBTOTAL</th></tr></thead><tbody><?php foreach($groups as$g):?><tr><td><?=html_escape($g['date'])?></td><td><b><?=html_escape($g['item'])?></b><br><span class="muted"><?=html_escape($g['detail']?:'-')?></span></td><td><?=ipQty($g['qty']).' '.html_escape($g['unit'])?></td><td class="num"><?=ipMoney($g['price'])?></td><td class="num"><?=ipMoney($g['discount'])?></td><td class="num"><?=ipMoney($g['tax'])?></td><td class="num"><b><?=ipMoney($g['total'])?></b></td></tr><?php endforeach?></tbody></table> <div class="company-name"><?=html_escape($company->name??'Perusahaan')?></div>
<table class="summary"><tr><td>Subtotal bersih</td><td class="num"><?=ipMoney($invoice->subtotal_before_tax)?></td></tr><tr><td>Diskon</td><td class="num"><?=ipMoney($invoice->discount_total)?></td></tr><tr><td>Pajak</td><td class="num"><?=ipMoney($invoice->tax_total)?></td></tr><tr><td><b>Total</b></td><td class="num"><b>Rp <?=ipMoney($invoice->total)?></b></td></tr><tr><td>Sudah dibayar</td><td class="num"><?=ipMoney($invoice->total_bayar)?></td></tr><tr><td><b>Sisa piutang</b></td><td class="num"><b><?=ipMoney($invoice->sisa_piutang)?></b></td></tr></table> <div><?=nl2br(html_escape($company->address??'-'))?></div>
<div>Telp: <?=html_escape($company->phone??'-')?></div>
</td><td class="right"><h1>INVOICE</h1><div class="invoice-number"><b><?=html_escape($invoice->no_invoice)?></b><br><?=html_escape($invoice->tanggal)?></div><span class="status status-<?=html_escape($statusKey)?>"><?=html_escape($statusLabel)?></span></td></tr></table>
<table class="info"><tr><td><div class="info-label">Ditagihkan kepada</div><b><?=html_escape($invoice->customer_name)?></b><br><?=nl2br(html_escape($invoice->customer_address?:'-'))?></td><td><div class="info-label">Informasi Invoice</div><b>Tanggal Invoice:</b> <?=html_escape($invoice->tanggal)?><br><b>Tanggal Jatuh Tempo:</b> <?=html_escape($invoice->jatuh_tempo)?><br><b>Mata Uang:</b> <?=html_escape($currencyCode)?></td></tr></table>
<table class="grid"><thead><tr><th>TANGGAL</th><th>BARANG / JASA / TABUNGAN</th><th>QTY</th><th class="num">HARGA</th><th class="num">DISKON</th><th class="num">PAJAK</th><th class="num">SUBTOTAL</th></tr></thead><tbody>
<?php foreach($groups as$g):$average=$g['qty']!=0?$g['gross']/$g['qty']:0;?><tr><td><?=html_escape($g['date'])?></td><td><b><?=html_escape($g['item'])?></b></td><td><?=ipQty($g['qty']).' '.html_escape($g['unit'])?></td><td class="num"><?=ipMoney($average)?></td><td class="num"><?=ipMoney($g['discount'])?></td><td class="num"><?=ipMoney($g['tax'])?></td><td class="num"><b><?=ipMoney($g['total'])?></b></td></tr><?php endforeach?>
<?php if(!$groups):?><tr><td colspan="7" class="empty">Belum ada item pada invoice ini.</td></tr><?php endif?>
</tbody></table>
<table class="summary"><tr><td>Subtotal bersih</td><td class="num"><?=ipMoney($invoice->subtotal_before_tax)?></td></tr><tr><td>Diskon</td><td class="num"><?=ipMoney($invoice->discount_total)?></td></tr><tr><td>Pajak</td><td class="num"><?=ipMoney($invoice->tax_total)?></td></tr><tr class="grand"><td><b>Total Invoice</b></td><td class="num"><b><?=html_escape($currencySymbol)?> <?=ipMoney($invoice->total)?></b></td></tr><tr><td>Sudah dibayar</td><td class="num"><?=ipMoney($invoice->total_bayar)?></td></tr><tr><td><b>Sisa tagihan</b></td><td class="num"><b><?=ipMoney($invoice->sisa_piutang)?></b></td></tr></table>
<?php if($previous_invoices):?><div class="outstanding"><div class="section-title">Daftar Tagihan Sebelumnya yang Belum Lunas</div><table class="grid"><thead><tr><th>INVOICE</th><th>TANGGAL</th><th>JATUH TEMPO</th><th class="num">TOTAL</th><th class="num">TERBAYAR</th><th class="num">SISA TAGIHAN</th></tr></thead><tbody><?php foreach($previous_invoices as$old):?><tr><td><b><?=html_escape($old->no_invoice)?></b></td><td><?=html_escape($old->tanggal)?></td><td><?=html_escape($old->jatuh_tempo)?></td><td class="num"><?=ipMoney($old->total)?></td><td class="num"><?=ipMoney($old->total_bayar)?></td><td class="num"><b><?=ipMoney($old->sisa_piutang)?></b></td></tr><?php endforeach?></tbody></table><div class="outstanding-total">Total sisa tagihan <?=html_escape($invoice->customer_name)?>, termasuk invoice ini: <?=html_escape($currencySymbol)?> <?=ipMoney($customerOutstanding)?></div></div><?php endif?>
<?php if($pdf_bank_accounts):?><div class="bank-box"><b>Rekening Pembayaran Perusahaan</b><?php foreach($pdf_bank_accounts as$bank):?><div class="bank-row"><?=html_escape($bank->bank_name)?> · <?=html_escape($bank->account_number)?> · a.n. <?=html_escape($bank->account_holder)?> (<?=html_escape($bank->currency)?>)<?=$bank->is_primary?' · Utama':''?></div><?php endforeach?></div><?php endif?>
<p><b>Surat Jalan:</b> <?=html_escape(implode(', ',array_map(function($d){return$d->delivery_no;},$deliveries))?:'-')?><br><b>Catatan:</b> <?=html_escape($invoice->keterangan?:'-')?></p> <p><b>Surat Jalan:</b> <?=html_escape(implode(', ',array_map(function($d){return$d->delivery_no;},$deliveries))?:'-')?><br><b>Catatan:</b> <?=html_escape($invoice->keterangan?:'-')?></p>
<table class="sign"><tr><td>Dibuat oleh<br><br><br><b><?=html_escape($invoice->creator_name?:'-')?></b></td><td>Disetujui<br><br><br><b><?=html_escape($invoice->approver_name?:'-')?></b></td><td>Diterima Customer</td></tr></table> <table class="sign"><tr><td>Dibuat oleh<br><br><br><b><?=html_escape($invoice->creator_name?:'-')?></b></td><td>Disetujui<br><br><br><b><?=html_escape($invoice->approver_name?:'-')?></b></td><td>Diterima Customer</td></tr></table>
<div class="page"><h2>Lampiran Detail Barcode / Serial Number</h2><p class="muted">Lampiran invoice <?=html_escape($invoice->no_invoice)?> · <?=count($barcodes)?> baris barcode</p><table class="grid"><thead class="repeat"><tr><th>TANGGAL</th><th>SURAT JALAN</th><th>BARANG</th><th>BARCODE / SERIAL</th><th>QTY</th><th>GUDANG</th><th>STATUS</th></tr></thead><tbody><?php foreach($barcodes as$b):?><tr><td><?=html_escape($b->service_date)?></td><td><?=html_escape($b->delivery_no?:'-')?></td><td><?=html_escape(trim(($b->item_code?$b->item_code.' - ':'').$b->nama_barang))?></td><td><b><?=html_escape($b->barcode)?></b><br><?=html_escape($b->serial_number?:'-')?></td><td><?=ipQty($b->qty)?></td><td><?=html_escape($b->warehouse_name?:'-')?></td><td><?=in_array($invoice->workflow_status,array('posted','approved'),true)?'Terkirim':'Dialokasikan'?></td></tr><?php endforeach?><?php if(!$barcodes):?><tr><td colspan="7" style="text-align:center">Invoice ini tidak berisi barang gudang sehingga tidak memiliki detail barcode atau Surat Jalan.</td></tr><?php endif?></tbody></table></div>
<div class="page"><h2>Lampiran Detail</h2><p class="muted">Rincian seluruh item Invoice <?=html_escape($invoice->no_invoice)?>.</p><table class="grid"><thead class="repeat"><tr><th>TANGGAL</th><th>SURAT JALAN</th><th>BARANG / JASA / TABUNGAN</th><th>BARCODE / SERIAL</th><th>QTY</th><th>KETERANGAN</th></tr></thead><tbody>
<?php foreach($lines as$l):$lineBars=$barcodesByLine[(int)$l->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):?><tr><td><?=html_escape($l->service_date)?></td><td><?=html_escape($b->delivery_no?:'-')?></td><td><b><?=html_escape($label)?></b></td><td><b><?=html_escape($b->barcode)?></b><br><?=html_escape($b->serial_number?:'-')?></td><td><?=ipQty($b->qty).' '.html_escape($l->unit)?></td><td class="detail-note"><?=nl2br(html_escape($note?:'-'))?></td></tr><?php endforeach;else:?><tr><td><?=html_escape($l->service_date)?></td><td>-</td><td><b><?=html_escape($label)?></b></td><td>-</td><td><?=ipQty($l->qty).' '.html_escape($l->unit)?></td><td class="detail-note"><?=nl2br(html_escape($note?:'-'))?></td></tr><?php endif;endforeach?>
<?php if(!$lines):?><tr><td colspan="6" class="empty">Belum ada detail item pada invoice ini.</td></tr><?php endif?>
</tbody></table></div>
</body></html> </body></html>
+1
View File
@@ -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}}
+57 -12
View File
@@ -135,7 +135,9 @@
'"><i class="bi bi-truck"></i> Buat Surat Jalan</button>'; '"><i class="bi bi-truck"></i> Buat Surat Jalan</button>';
if (p.export) if (p.export)
html += html +=
'<button class="btn btn-sm btn-outline-danger open-invoice-pdf" data-url="' + '<button class="btn btn-sm btn-outline-danger open-invoice-pdf" data-id="' +
row.id +
'" data-url="' +
cfg.base + cfg.base +
"invoices/pdf/" + "invoices/pdf/" +
row.id + row.id +
@@ -230,6 +232,7 @@
render: function (d,t,r) { render: function (d,t,r) {
if(r.invoice_purpose==="loan")return "Tagihan Pinjaman"; if(r.invoice_purpose==="loan")return "Tagihan Pinjaman";
if(r.invoice_purpose==="savings")return "Tagihan Tabungan"; if(r.invoice_purpose==="savings")return "Tagihan Tabungan";
if(r.invoice_purpose==="mixed")return "Penjualan + Tabungan";
return esc( return esc(
r.recognition_policy === "accrual" r.recognition_policy === "accrual"
? "Diakui saat Posted" ? "Diakui saat Posted"
@@ -456,7 +459,7 @@
$(".invoice-batch-common").toggleClass("d-none", savings); $(".invoice-batch-common").toggleClass("d-none", savings);
$("#nonInventoryDescriptionLabel").html(savings ? 'Deskripsi <span class="text-danger">*</span>' : 'Nama Jasa / Deskripsi <span class="text-danger">*</span>'); $("#nonInventoryDescriptionLabel").html(savings ? 'Deskripsi <span class="text-danger">*</span>' : 'Nama Jasa / Deskripsi <span class="text-danger">*</span>');
$("#lineValueLabel").text(savings ? "Nominal" : "Harga Satuan / Nominal"); $("#lineValueLabel").text(savings ? "Nominal" : "Harga Satuan / Nominal");
if(savings){form.find("[name=tax_rate]").val("0");form.find("[name=qty]").val("1");form.find("[name=unit]").val("Tabungan");form.find("[name=batch_discount]").val("0");} if(savings){form.find("[name=tax_rate]").val("0");form.find("[name=qty]").val("1");form.find("[name=unit]").val("Tabungan");}
else if(!inventory)form.find("[name=unit]").val("Jasa"); else if(!inventory)form.find("[name=unit]").val("Jasa");
selectedItem = null; selectedItem = null;
selectedBars = []; selectedBars = [];
@@ -537,7 +540,7 @@
var date = form.find("[name=service_date]").val(), var date = form.find("[name=service_date]").val(),
discount = parseMoney(form.find("[name=batch_discount]").val()), discount = parseMoney(form.find("[name=batch_discount]").val()),
gross = batchLines.reduce(function (s, x) { gross = batchLines.reduce(function (s, x) {
return s + x.qty * x.unit_price; return s + (x.line_type === "savings" ? 0 : x.qty * x.unit_price);
}, 0); }, 0);
if (!date) return notify("Tanggal transaksi wajib diisi.", "warning"); if (!date) return notify("Tanggal transaksi wajib diisi.", "warning");
if (discount < 0 || discount > gross) if (discount < 0 || discount > gross)
@@ -663,7 +666,6 @@
} }
if(type === "savings")line.tax_rate=0; if(type === "savings")line.tax_rate=0;
if(Number(form.find("[name=editing_line_id]").val())>0)return updateExistingLine(line); if(Number(form.find("[name=editing_line_id]").val())>0)return updateExistingLine(line);
if(batchLines.length){var firstSavings=batchLines[0].line_type==="savings",currentSavings=line.line_type==="savings";if(firstSavings!==currentSavings)return notify("Barang/Jasa dan Tabungan harus dibuat pada invoice terpisah agar jurnalnya jelas.","warning");}
batchLines.push(line); batchLines.push(line);
resetComposer(); resetComposer();
renderBatchLines(); renderBatchLines();
@@ -831,23 +833,26 @@
function calcBatch() { function calcBatch() {
var form = $("#invoiceLineForm"); var form = $("#invoiceLineForm");
if (!form.length) return; if (!form.length) return;
var gross = batchLines.reduce(function (s, x) { var displayGross = batchLines.reduce(function (s, x) {
return s + x.qty * x.unit_price; return s + x.qty * x.unit_price;
}, 0), }, 0),
gross = batchLines.reduce(function (s, x) {
return s + (x.line_type === "savings" ? 0 : x.qty * x.unit_price);
}, 0),
discount = parseMoney(form.find("[name=batch_discount]").val()), discount = parseMoney(form.find("[name=batch_discount]").val()),
last = -1, last = -1,
used = 0, used = 0,
total = 0; total = 0;
batchLines.forEach(function (x, i) { batchLines.forEach(function (x, i) {
if (x.qty * x.unit_price > 0) last = i; if (x.line_type !== "savings" && x.qty * x.unit_price > 0) last = i;
}); });
batchLines.forEach(function (x, i) { batchLines.forEach(function (x, i) {
var share = allocatedDiscount(x, i, gross, discount, last, used); var share = x.line_type === "savings" ? 0 : allocatedDiscount(x, i, gross, discount, last, used);
used += share; used += share;
total += total +=
Math.max(0, x.qty * x.unit_price - share) * (1 + x.tax_rate / 100); Math.max(0, x.qty * x.unit_price - share) * (1 + x.tax_rate / 100);
}); });
$("#batchGrossTotal").text(money(gross)); $("#batchGrossTotal").text(money(displayGross));
$("#batchDiscountTotal").text(money(discount)); $("#batchDiscountTotal").text(money(discount));
$("#lineLiveTotal").text(money(total)); $("#lineLiveTotal").text(money(total));
var valid = batchLines.length > 0 && discount >= 0 && discount <= gross; var valid = batchLines.length > 0 && discount >= 0 && discount <= gross;
@@ -1227,11 +1232,51 @@
}); });
}); });
}); });
$(document).on("click", ".open-invoice-pdf", function () { function openInvoicePdf(url) {
$("#invoicePdfModal iframe").attr("src", $(this).data("url")); $("#invoicePdfModal iframe").attr("src", url);
bootstrap.Modal.getOrCreateInstance( bootstrap.Modal.getOrCreateInstance(
document.getElementById("invoicePdfModal"), document.getElementById("invoicePdfModal"),
).show(); ).show();
}
$(document).on("click", ".open-invoice-pdf", function () {
var button = $(this),
url = String(button.data("url") || ""),
id = Number(button.data("id") || url.split("/").filter(Boolean).pop());
if (!id || !url) return notify("Invoice tidak valid.", "error");
button.prop("disabled", true);
$.getJSON(cfg.base + "invoices/pdf_options/" + id)
.done(function (response) {
if (!response.status) return notify(response.message, "error");
var option = response.data || {};
if (Number(option.count || 0) < 1)
return openInvoicePdf(url + "?include_previous=0");
Swal.fire({
title: "Pilihan PDF Invoice",
html:
"Customer memiliki <b>" +
esc(option.count) +
" invoice sebelumnya</b> yang belum lunas dengan total " +
esc(money(option.total)) +
".",
icon: "question",
input: "checkbox",
inputValue: 0,
inputPlaceholder:
"Sertakan daftar tagihan sebelumnya pada PDF",
showCancelButton: true,
confirmButtonText: "Tampilkan PDF",
cancelButtonText: "Batal",
}).then(function (result) {
if (result.isConfirmed)
openInvoicePdf(
url + "?include_previous=" + (result.value ? "1" : "0"),
);
});
})
.fail(error)
.always(function () {
button.prop("disabled", false);
});
}); });
$("#invoicePdfModal").on("hidden.bs.modal", function () { $("#invoicePdfModal").on("hidden.bs.modal", function () {
$(this).find("iframe").attr("src", "about:blank"); $(this).find("iframe").attr("src", "about:blank");
+41 -3
View File
@@ -1,5 +1,5 @@
<?php <?php
// Muat environment lokal untuk development/XAMPP. File .env tidak boleh di-commit. // Muat environment aplikasi tanpa bergantung pada putenv(). File .env tidak boleh di-commit.
$localEnvFile = __DIR__ . DIRECTORY_SEPARATOR . '.env'; $localEnvFile = __DIR__ . DIRECTORY_SEPARATOR . '.env';
if (is_file($localEnvFile) && is_readable($localEnvFile)) { if (is_file($localEnvFile) && is_readable($localEnvFile)) {
foreach (file($localEnvFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $envLine) { foreach (file($localEnvFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $envLine) {
@@ -9,7 +9,7 @@ if (is_file($localEnvFile) && is_readable($localEnvFile)) {
} }
list($envName, $envValue) = array_map('trim', explode('=', $envLine, 2)); list($envName, $envValue) = array_map('trim', explode('=', $envLine, 2));
if (!preg_match('/^[A-Z][A-Z0-9_]*$/', $envName) || getenv($envName) !== false) { if (!preg_match('/^[A-Z][A-Z0-9_]*$/', $envName) || getenv($envName) !== false || array_key_exists($envName, $_ENV) || array_key_exists($envName, $_SERVER)) {
continue; continue;
} }
@@ -17,12 +17,50 @@ if (is_file($localEnvFile) && is_readable($localEnvFile)) {
$envValue = substr($envValue, 1, -1); $envValue = substr($envValue, 1, -1);
} }
putenv($envName . '=' . $envValue);
$_ENV[$envName] = $envValue; $_ENV[$envName] = $envValue;
$_SERVER[$envName] = $envValue; $_SERVER[$envName] = $envValue;
} }
} }
if (!function_exists('app_env')) {
/**
* Membaca environment server asli maupun nilai .env yang disimpan pada
* superglobal. Tidak memakai putenv(), sehingga kompatibel dengan hosting
* yang menonaktifkan fungsi tersebut.
*/
function app_env($name, $default = null)
{
$processValue = getenv($name);
if ($processValue !== false) {
return $processValue;
}
if (array_key_exists($name, $_ENV)) {
return $_ENV[$name];
}
if (array_key_exists($name, $_SERVER)) {
return $_SERVER[$name];
}
return $default;
}
}
if (!function_exists('app_env_all')) {
function app_env_all()
{
$process = getenv();
$environment = array();
foreach (array($_SERVER, $_ENV, is_array($process) ? $process : array()) as $source) {
foreach ($source as $name => $value) {
if (!is_string($name) || !preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $name) || !is_scalar($value)) {
continue;
}
$environment[$name] = (string) $value;
}
}
return $environment;
}
}
// Composer autoload // Composer autoload
$composer_autoload = __DIR__ . '/vendor/autoload.php'; $composer_autoload = __DIR__ . '/vendor/autoload.php';
if (file_exists($composer_autoload)) { if (file_exists($composer_autoload)) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB