Update besar apk finance

This commit is contained in:
Wian Drs
2026-09-11 16:03:00 +07:00
parent d42483b350
commit 4ef15c22ae
897 changed files with 98056 additions and 15979 deletions
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1,69 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Create_accounting_foundation extends CI_Migration
{
public function up()
{
if (!$this->db->table_exists('document_sequences')) {
$this->dbforge->add_field(array(
'id' => array('type' => 'BIGINT', 'unsigned' => true, 'auto_increment' => true),
'document_type' => array('type' => 'VARCHAR', 'constraint' => 50),
'period_key' => array('type' => 'VARCHAR', 'constraint' => 20),
'prefix' => array('type' => 'VARCHAR', 'constraint' => 50),
'current_value' => array('type' => 'BIGINT', 'unsigned' => true, 'default' => 0),
'updated_at' => array('type' => 'DATETIME', 'null' => true)
));
$this->dbforge->add_key('id', true);
$this->dbforge->create_table('document_sequences', true, array('ENGINE' => 'InnoDB'));
$this->db->query('ALTER TABLE document_sequences ADD UNIQUE KEY uq_document_sequence (document_type, period_key)');
}
if (!$this->db->table_exists('system_account_mappings')) {
$this->dbforge->add_field(array(
'id' => array('type' => 'BIGINT', 'unsigned' => true, 'auto_increment' => true),
'mapping_key' => array('type' => 'VARCHAR', 'constraint' => 100),
'account_id' => array('type' => 'BIGINT', 'unsigned' => true),
'description' => array('type' => 'VARCHAR', 'constraint' => 255, 'null' => true),
'is_active' => array('type' => 'TINYINT', 'constraint' => 1, 'default' => 1),
'created_at' => array('type' => 'DATETIME', 'null' => true),
'updated_at' => array('type' => 'DATETIME', 'null' => true)
));
$this->dbforge->add_key('id', true);
$this->dbforge->add_key('account_id');
$this->dbforge->create_table('system_account_mappings', true, array('ENGINE' => 'InnoDB'));
$this->db->query('ALTER TABLE system_account_mappings ADD UNIQUE KEY uq_system_account_mapping (mapping_key)');
}
$mappings = array(
array('inventory', 21, 'Persediaan'),
array('cost_of_goods_sold', 63, 'Harga pokok penjualan'),
array('accounts_receivable', 19, 'Piutang usaha'),
array('deferred_revenue', 78, 'Pendapatan diterima di muka'),
array('inventory_in_sales', 80, 'Persediaan dalam penjualan'),
array('goods_revenue', 62, 'Pendapatan barang'),
array('customer_savings', 70, 'Tabungan pelanggan'),
array('depreciation_expense', 54, 'Beban penyusutan aset')
);
foreach ($mappings as $mapping) {
$accountExists = $this->db->where('id', $mapping[1])->count_all_results('accounts') > 0;
$mappingExists = $this->db->where('mapping_key', $mapping[0])->count_all_results('system_account_mappings') > 0;
if ($accountExists && !$mappingExists) {
$this->db->insert('system_account_mappings', array(
'mapping_key' => $mapping[0],
'account_id' => $mapping[1],
'description' => $mapping[2],
'is_active' => 1,
'created_at' => date('Y-m-d H:i:s')
));
}
}
}
public function down()
{
$this->dbforge->drop_table('system_account_mappings', true);
$this->dbforge->drop_table('document_sequences', true);
}
}
@@ -0,0 +1,29 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Add_depreciation_account_mapping extends CI_Migration
{
public function up()
{
if (!$this->db->table_exists('system_account_mappings')) {
throw new RuntimeException('Jalankan migration accounting foundation terlebih dahulu.');
}
$exists = $this->db->where('mapping_key', 'depreciation_expense')->count_all_results('system_account_mappings') > 0;
$accountExists = $this->db->where('id', 54)->count_all_results('accounts') > 0;
if (!$exists && $accountExists) {
$this->db->insert('system_account_mappings', array(
'mapping_key' => 'depreciation_expense',
'account_id' => 54,
'description' => 'Beban penyusutan aset',
'is_active' => 1,
'created_at' => date('Y-m-d H:i:s')
));
}
}
public function down()
{
$this->db->where('mapping_key', 'depreciation_expense')->delete('system_account_mappings');
}
}
@@ -0,0 +1,180 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Create_fiscal_period_and_journal_workflow extends CI_Migration
{
public function up()
{
if (!$this->db->table_exists('fiscal_years')) {
$this->dbforge->add_field(array(
'id' => array('type' => 'INT', 'unsigned' => true, 'auto_increment' => true),
'name' => array('type' => 'VARCHAR', 'constraint' => 50),
'start_date' => array('type' => 'DATE'),
'end_date' => array('type' => 'DATE'),
'status' => array('type' => 'ENUM', 'constraint' => array('open', 'closed'), 'default' => 'open'),
'created_at' => array('type' => 'DATETIME', 'null' => true),
'created_by' => array('type' => 'INT', 'null' => true)
));
$this->dbforge->add_key('id', true);
$this->dbforge->create_table('fiscal_years', true, array('ENGINE' => 'InnoDB'));
$this->db->query('ALTER TABLE fiscal_years ADD UNIQUE KEY uq_fiscal_year_dates (start_date, end_date)');
}
if (!$this->db->table_exists('accounting_periods')) {
$this->dbforge->add_field(array(
'id' => array('type' => 'INT', 'unsigned' => true, 'auto_increment' => true),
'fiscal_year_id' => array('type' => 'INT', 'unsigned' => true),
'period_number' => array('type' => 'TINYINT', 'unsigned' => true),
'name' => array('type' => 'VARCHAR', 'constraint' => 50),
'start_date' => array('type' => 'DATE'),
'end_date' => array('type' => 'DATE'),
'status' => array('type' => 'ENUM', 'constraint' => array('open', 'soft_closed', 'closed'), 'default' => 'open'),
'closed_at' => array('type' => 'DATETIME', 'null' => true),
'closed_by' => array('type' => 'INT', 'null' => true),
'reopened_at' => array('type' => 'DATETIME', 'null' => true),
'reopened_by' => array('type' => 'INT', 'null' => true),
'updated_at' => array('type' => 'DATETIME', 'null' => true)
));
$this->dbforge->add_key('id', true);
$this->dbforge->add_key('fiscal_year_id');
$this->dbforge->add_key(array('start_date', 'end_date'));
$this->dbforge->create_table('accounting_periods', true, array('ENGINE' => 'InnoDB'));
$this->db->query('ALTER TABLE accounting_periods ADD UNIQUE KEY uq_accounting_period (fiscal_year_id, period_number)');
}
if (!$this->db->table_exists('accounting_period_logs')) {
$this->dbforge->add_field(array(
'id' => array('type' => 'BIGINT', 'unsigned' => true, 'auto_increment' => true),
'accounting_period_id' => array('type' => 'INT', 'unsigned' => true),
'from_status' => array('type' => 'VARCHAR', 'constraint' => 30, 'null' => true),
'to_status' => array('type' => 'VARCHAR', 'constraint' => 30),
'reason' => array('type' => 'VARCHAR', 'constraint' => 500, 'null' => true),
'changed_by' => array('type' => 'INT'),
'changed_at' => array('type' => 'DATETIME')
));
$this->dbforge->add_key('id', true);
$this->dbforge->add_key('accounting_period_id');
$this->dbforge->create_table('accounting_period_logs', true, array('ENGINE' => 'InnoDB'));
}
$journalFields = array(
'submitted_at' => array('type' => 'DATETIME', 'null' => true),
'submitted_by' => array('type' => 'INT', 'null' => true),
'approved_at' => array('type' => 'DATETIME', 'null' => true),
'approved_by' => array('type' => 'INT', 'null' => true),
'posted_at' => array('type' => 'DATETIME', 'null' => true),
'posted_by' => array('type' => 'INT', 'null' => true),
'rejected_at' => array('type' => 'DATETIME', 'null' => true),
'rejected_by' => array('type' => 'INT', 'null' => true),
'rejection_reason' => array('type' => 'VARCHAR', 'constraint' => 500, 'null' => true),
'reversal_reason' => array('type' => 'VARCHAR', 'constraint' => 500, 'null' => true),
'accounting_period_id' => array('type' => 'INT', 'unsigned' => true, 'null' => true)
);
foreach ($journalFields as $name => $definition) {
if (!$this->db->field_exists($name, 'journals')) {
$this->dbforge->add_column('journals', array($name => $definition));
}
}
// MySQL 8 menolak ALTER pada tabel yang masih mempunyai zero-date legacy.
// Baris tetap dipertahankan dan tanggalnya dipulihkan dari created_at.
$this->db->query("CREATE TABLE IF NOT EXISTS legacy_migration_adjustments(
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
entity_type VARCHAR(60) NOT NULL,
entity_id BIGINT UNSIGNED NOT NULL,
field_name VARCHAR(60) NOT NULL,
old_value VARCHAR(255) NULL,
new_value VARCHAR(255) NULL,
reason VARCHAR(255) NOT NULL,
adjusted_at DATETIME NOT NULL,
UNIQUE KEY uq_legacy_adjustment(entity_type,entity_id,field_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$this->db->query("INSERT IGNORE INTO legacy_migration_adjustments(entity_type,entity_id,field_name,old_value,new_value,reason,adjusted_at)
SELECT 'journal',id,'tanggal','0000-00-00',
IF(created_at IS NOT NULL AND created_at<>'0000-00-00 00:00:00',DATE(created_at),'2000-01-01'),
'Normalisasi zero-date sebelum modernisasi workflow jurnal',NOW()
FROM journals WHERE tanggal='0000-00-00'");
$this->db->query("UPDATE journals
SET tanggal=IF(created_at IS NOT NULL AND created_at<>'0000-00-00 00:00:00',DATE(created_at),'2000-01-01')
WHERE tanggal='0000-00-00'");
// Default posted menjaga kompatibilitas modul legacy. Jurnal manual baru
// secara eksplisit dibuat draft oleh JournalService.
$this->db->query("ALTER TABLE journals MODIFY status ENUM('draft','submitted','approved','posted','rejected','reversed') NOT NULL DEFAULT 'posted'");
$this->db->query("UPDATE journals SET status = 'posted', posted_at = COALESCE(posted_at, created_at), posted_by = COALESCE(posted_by, created_by) WHERE status = 'posted'");
$this->db->query('ALTER TABLE journals ADD INDEX idx_journals_status_date (status, tanggal)');
$this->db->query('ALTER TABLE journals ADD INDEX idx_journals_period (accounting_period_id)');
$this->db->query('ALTER TABLE journals ADD INDEX idx_journals_reversal (reversal_of)');
if (!$this->db->table_exists('journal_status_histories')) {
$this->dbforge->add_field(array(
'id' => array('type' => 'BIGINT', 'unsigned' => true, 'auto_increment' => true),
'journal_id' => array('type' => 'INT'),
'from_status' => array('type' => 'VARCHAR', 'constraint' => 30, 'null' => true),
'to_status' => array('type' => 'VARCHAR', 'constraint' => 30),
'notes' => array('type' => 'VARCHAR', 'constraint' => 500, 'null' => true),
'changed_by' => array('type' => 'INT'),
'changed_at' => array('type' => 'DATETIME')
));
$this->dbforge->add_key('id', true);
$this->dbforge->add_key('journal_id');
$this->dbforge->create_table('journal_status_histories', true, array('ENGINE' => 'InnoDB'));
}
$accountFields = array(
'is_header' => array('type' => 'TINYINT', 'constraint' => 1, 'default' => 0),
'allow_posting' => array('type' => 'TINYINT', 'constraint' => 1, 'default' => 1),
'updated_at' => array('type' => 'DATETIME', 'null' => true),
'updated_by' => array('type' => 'INT', 'null' => true)
);
foreach ($accountFields as $name => $definition) {
if (!$this->db->field_exists($name, 'accounts')) {
$this->dbforge->add_column('accounts', array($name => $definition));
}
}
$this->db->query('UPDATE accounts SET is_header = 1, allow_posting = 0 WHERE id IN (SELECT parent_id FROM (SELECT DISTINCT parent_id FROM accounts WHERE parent_id IS NOT NULL) p) AND id NOT IN (SELECT account_id FROM journal_details)');
$this->db->query('ALTER TABLE accounts ADD UNIQUE KEY uq_accounts_code (kode_akun)');
$years = $this->db->query('SELECT DISTINCT YEAR(tanggal) year_number FROM journals UNION SELECT YEAR(CURDATE())')->result();
foreach ($years as $yearRow) {
$year = (int) $yearRow->year_number;
if ($year < 2000 || $year > 2200) {
continue;
}
$start = $year . '-01-01';
$end = $year . '-12-31';
$fiscal = $this->db->get_where('fiscal_years', array('start_date' => $start, 'end_date' => $end))->row();
if (!$fiscal) {
$this->db->insert('fiscal_years', array('name' => 'Tahun Buku ' . $year, 'start_date' => $start, 'end_date' => $end, 'status' => 'open', 'created_at' => date('Y-m-d H:i:s')));
$fiscalId = (int) $this->db->insert_id();
} else {
$fiscalId = (int) $fiscal->id;
}
for ($month = 1; $month <= 12; $month++) {
$periodStart = sprintf('%04d-%02d-01', $year, $month);
$periodEnd = date('Y-m-t', strtotime($periodStart));
$exists = $this->db->get_where('accounting_periods', array('fiscal_year_id' => $fiscalId, 'period_number' => $month))->row();
if (!$exists) {
$this->db->insert('accounting_periods', array(
'fiscal_year_id' => $fiscalId,
'period_number' => $month,
'name' => date('F Y', strtotime($periodStart)),
'start_date' => $periodStart,
'end_date' => $periodEnd,
'status' => 'open'
));
}
}
}
$this->db->query('UPDATE journals j JOIN accounting_periods p ON j.tanggal BETWEEN p.start_date AND p.end_date SET j.accounting_period_id = p.id WHERE j.accounting_period_id IS NULL');
}
public function down()
{
// Workflow data is intentionally retained on rollback; destructive column drops are avoided.
$this->dbforge->drop_table('journal_status_histories', true);
$this->dbforge->drop_table('accounting_period_logs', true);
$this->dbforge->drop_table('accounting_periods', true);
$this->dbforge->drop_table('fiscal_years', true);
}
}
@@ -0,0 +1,15 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Preserve_legacy_posting_default extends CI_Migration
{
public function up()
{
$this->db->query("ALTER TABLE journals MODIFY status ENUM('draft','submitted','approved','posted','rejected','reversed') NOT NULL DEFAULT 'posted'");
}
public function down()
{
$this->db->query("ALTER TABLE journals MODIFY status ENUM('draft','submitted','approved','posted','rejected','reversed') NOT NULL DEFAULT 'draft'");
}
}
@@ -0,0 +1,16 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Preserve_historical_posting_accounts extends CI_Migration
{
public function up()
{
// Parent yang sudah pernah dipakai bertransaksi tetap merupakan akun posting.
$this->db->query('UPDATE accounts SET is_header = 0, allow_posting = 1 WHERE id IN (SELECT DISTINCT account_id FROM journal_details)');
}
public function down()
{
// Tidak mengembalikan inferensi lama karena dapat menonaktifkan akun transaksi aktif.
}
}
@@ -0,0 +1,46 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Enforce_period_on_legacy_journal_writes extends CI_Migration
{
public function up()
{
$this->db->query('DROP TRIGGER IF EXISTS trg_journals_period_before_insert');
$this->db->query("
CREATE TRIGGER trg_journals_period_before_insert
BEFORE INSERT ON journals
FOR EACH ROW
BEGIN
DECLARE resolved_period_id INT DEFAULT NULL;
DECLARE resolved_period_status VARCHAR(30) DEFAULT NULL;
SELECT id, status INTO resolved_period_id, resolved_period_status
FROM accounting_periods
WHERE NEW.tanggal BETWEEN start_date AND end_date
ORDER BY id ASC LIMIT 1;
IF resolved_period_id IS NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Periode accounting belum tersedia untuk tanggal jurnal';
END IF;
IF resolved_period_status <> 'open' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Periode accounting tidak terbuka';
END IF;
IF NEW.accounting_period_id IS NULL THEN
SET NEW.accounting_period_id = resolved_period_id;
END IF;
IF NEW.status = 'posted' THEN
SET NEW.posted_at = COALESCE(NEW.posted_at, NOW());
SET NEW.posted_by = COALESCE(NEW.posted_by, NEW.created_by);
END IF;
END
");
}
public function down()
{
$this->db->query('DROP TRIGGER IF EXISTS trg_journals_period_before_insert');
}
}
@@ -0,0 +1,22 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Seed_existing_journal_workflow_history extends CI_Migration
{
public function up()
{
$this->db->query("
INSERT INTO journal_status_histories
(journal_id, from_status, to_status, notes, changed_by, changed_at)
SELECT j.id, NULL, j.status, 'Status awal hasil migrasi Tahap 3', j.created_by, j.created_at
FROM journals j
LEFT JOIN journal_status_histories h ON h.journal_id = j.id
WHERE h.id IS NULL
");
}
public function down()
{
$this->db->where('notes', 'Status awal hasil migrasi Tahap 3')->delete('journal_status_histories');
}
}
@@ -0,0 +1,61 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Create_approval_and_audit_foundation extends CI_Migration
{
public function up()
{
$this->db->query("CREATE TABLE IF NOT EXISTS approval_workflows (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, code VARCHAR(50) NOT NULL, module VARCHAR(50) NOT NULL,
entity_type VARCHAR(50) NOT NULL, name VARCHAR(100) NOT NULL, min_amount DECIMAL(18,2) NULL,
max_amount DECIMAL(18,2) NULL, require_separation TINYINT(1) NOT NULL DEFAULT 1,
is_active TINYINT(1) NOT NULL DEFAULT 1, created_at DATETIME NOT NULL,
UNIQUE KEY uq_approval_workflow_code (code), KEY idx_approval_workflow_lookup (module,entity_type,is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$this->db->query("CREATE TABLE IF NOT EXISTS approval_workflow_steps (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, workflow_id INT UNSIGNED NOT NULL, step_order INT UNSIGNED NOT NULL,
name VARCHAR(100) NOT NULL, approver_role_id INT NULL, approver_user_id INT NULL,
UNIQUE KEY uq_approval_step (workflow_id,step_order), KEY idx_approval_step_role (approver_role_id),
CONSTRAINT fk_approval_step_workflow FOREIGN KEY (workflow_id) REFERENCES approval_workflows(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$this->db->query("CREATE TABLE IF NOT EXISTS approval_requests (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, request_no VARCHAR(60) NOT NULL, workflow_id INT UNSIGNED NOT NULL,
entity_type VARCHAR(50) NOT NULL, entity_id BIGINT UNSIGNED NOT NULL, amount DECIMAL(18,2) NOT NULL DEFAULT 0,
requested_by INT NULL, requested_at DATETIME NOT NULL, current_step INT UNSIGNED NOT NULL DEFAULT 1,
status ENUM('pending','approved','rejected','cancelled') NOT NULL DEFAULT 'pending', snapshot LONGTEXT NULL,
completed_at DATETIME NULL, UNIQUE KEY uq_approval_request_no (request_no),
KEY idx_approval_entity (entity_type,entity_id), KEY idx_approval_inbox (status,current_step),
CONSTRAINT fk_approval_request_workflow FOREIGN KEY (workflow_id) REFERENCES approval_workflows(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$this->db->query("CREATE TABLE IF NOT EXISTS approval_actions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, request_id BIGINT UNSIGNED NOT NULL, step_order INT UNSIGNED NOT NULL,
action ENUM('submitted','approved','rejected','cancelled') NOT NULL, user_id INT NULL, notes TEXT NULL, created_at DATETIME NOT NULL,
KEY idx_approval_action_request (request_id),
CONSTRAINT fk_approval_action_request FOREIGN KEY (request_id) REFERENCES approval_requests(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$this->db->query("CREATE TABLE IF NOT EXISTS immutable_audit_logs (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, module VARCHAR(50) NOT NULL, entity_type VARCHAR(50) NOT NULL,
entity_id VARCHAR(64) NOT NULL, action VARCHAR(50) NOT NULL, before_data LONGTEXT NULL, after_data LONGTEXT NULL,
user_id INT NULL, ip_address VARCHAR(45) NULL, user_agent VARCHAR(255) NULL, request_id VARCHAR(80) NULL,
previous_hash CHAR(64) NULL, record_hash CHAR(64) NOT NULL, created_at DATETIME NOT NULL,
KEY idx_audit_entity (entity_type,entity_id), KEY idx_audit_created (created_at), UNIQUE KEY uq_audit_hash (record_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$admin = $this->db->where('nama_role', 'Admin')->get('roles')->row();
if ($admin && !$this->db->where('code', 'JOURNAL_DEFAULT')->count_all_results('approval_workflows')) {
$this->db->insert('approval_workflows', array('code'=>'JOURNAL_DEFAULT','module'=>'journal','entity_type'=>'journal','name'=>'Persetujuan Jurnal','require_separation'=>1,'is_active'=>1,'created_at'=>date('Y-m-d H:i:s')));
$this->db->insert('approval_workflow_steps', array('workflow_id'=>$this->db->insert_id(),'step_order'=>1,'name'=>'Persetujuan Admin','approver_role_id'=>$admin->id));
}
$this->db->query("DROP TRIGGER IF EXISTS trg_immutable_audit_no_update");
$this->db->query("CREATE TRIGGER trg_immutable_audit_no_update BEFORE UPDATE ON immutable_audit_logs FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Audit log is immutable'");
$this->db->query("DROP TRIGGER IF EXISTS trg_immutable_audit_no_delete");
$this->db->query("CREATE TRIGGER trg_immutable_audit_no_delete BEFORE DELETE ON immutable_audit_logs FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Audit log is immutable'");
}
public function down()
{
$this->db->query('DROP TRIGGER IF EXISTS trg_immutable_audit_no_update');
$this->db->query('DROP TRIGGER IF EXISTS trg_immutable_audit_no_delete');
foreach (array('approval_actions','approval_requests','approval_workflow_steps','approval_workflows','immutable_audit_logs') as $table) $this->db->query('DROP TABLE IF EXISTS '.$table);
}
}
@@ -0,0 +1,36 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Enforce_unique_journal_reference extends CI_Migration
{
public function up()
{
$this->db->query("CREATE TABLE IF NOT EXISTS legacy_migration_adjustments(
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
entity_type VARCHAR(60) NOT NULL,
entity_id BIGINT UNSIGNED NOT NULL,
field_name VARCHAR(60) NOT NULL,
old_value VARCHAR(255) NULL,
new_value VARCHAR(255) NULL,
reason VARCHAR(255) NOT NULL,
adjusted_at DATETIME NOT NULL,
UNIQUE KEY uq_legacy_adjustment(entity_type,entity_id,field_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$fieldLength=100;foreach($this->db->field_data('journals')as$field)if($field->name==='no_ref'&&!empty($field->max_length)){$fieldLength=(int)$field->max_length;break;}
$duplicates=$this->db->query("SELECT j.id,j.no_ref
FROM journals j
JOIN(SELECT no_ref,MIN(id) retained_id,COUNT(*) total FROM journals GROUP BY no_ref HAVING COUNT(*)>1)d ON j.no_ref<=>d.no_ref
WHERE j.id<>d.retained_id ORDER BY j.id")->result();
foreach($duplicates as$journal){
$suffix='-J'.(int)$journal->id;$old=(string)$journal->no_ref;
$new=$old!==''?substr($old,0,max(0,$fieldLength-strlen($suffix))).$suffix:'LEGACY-J'.(int)$journal->id;
if($this->db->where('no_ref',$new)->where('id !=',(int)$journal->id)->count_all_results('journals'))$new='J'.str_pad((string)$journal->id,max(1,$fieldLength-1),'0',STR_PAD_LEFT);
$this->db->insert('legacy_migration_adjustments',array('entity_type'=>'journal','entity_id'=>(int)$journal->id,'field_name'=>'no_ref','old_value'=>$old,'new_value'=>$new,'reason'=>'Nomor referensi jurnal legacy duplikat; jurnal dipertahankan dan diberi identitas unik','adjusted_at'=>date('Y-m-d H:i:s')));
$this->db->where('id',(int)$journal->id)->update('journals',array('no_ref'=>$new));
}
$duplicate=$this->db->query('SELECT no_ref FROM journals GROUP BY no_ref HAVING COUNT(*) > 1 LIMIT 1')->row();
if($duplicate) throw new RuntimeException('Normalisasi no_ref jurnal duplikat belum menghasilkan nomor unik.');
$indexes=$this->db->query("SHOW INDEX FROM journals WHERE Key_name='uq_journals_no_ref'")->result();
if(!$indexes) $this->db->query('ALTER TABLE journals ADD UNIQUE KEY uq_journals_no_ref (no_ref)');
}
public function down() { $this->db->query('ALTER TABLE journals DROP INDEX uq_journals_no_ref'); }
}
@@ -0,0 +1,54 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Create_receivable_subledger extends CI_Migration
{
private function column($table,$column,$sql){if(!$this->db->field_exists($column,$table))$this->db->query("ALTER TABLE `$table` ADD `$column` $sql");}
public function up()
{
$dup=$this->db->query('SELECT no_invoice FROM invoices GROUP BY no_invoice HAVING COUNT(*)>1 LIMIT 1')->row();
if($dup)throw new RuntimeException('Nomor invoice duplikat: '.$dup->no_invoice.'. Rekonsiliasi sebelum Tahap 5.');
$this->column('customers','credit_limit','DECIMAL(18,2) NOT NULL DEFAULT 0');
$this->column('customers','payment_term_days','INT NOT NULL DEFAULT 30');
$this->column('customers','is_credit_blocked','TINYINT(1) NOT NULL DEFAULT 0');
$this->column('customers','tax_number','VARCHAR(50) NULL');
$this->column('invoices','workflow_status',"ENUM('draft','submitted','approved','posted','cancelled','reversed') NOT NULL DEFAULT 'draft'");
$this->column('invoices','subtotal_before_tax','DECIMAL(18,2) NOT NULL DEFAULT 0');
$this->column('invoices','tax_total','DECIMAL(18,2) NOT NULL DEFAULT 0');
$this->column('invoices','discount_total','DECIMAL(18,2) NOT NULL DEFAULT 0');
$this->column('invoices','currency','CHAR(3) NOT NULL DEFAULT \'IDR\'');
$this->column('invoices','posted_at','DATETIME NULL'); $this->column('invoices','posted_by','INT NULL');
$this->column('invoices','cancelled_at','DATETIME NULL'); $this->column('invoices','cancelled_by','INT NULL');
$this->column('invoices','reversal_journal_id','INT NULL'); $this->column('invoices','version','INT NOT NULL DEFAULT 1');
$this->column('invoice_details','tax_rate','DECIMAL(7,4) NOT NULL DEFAULT 0');
$this->column('invoice_details','tax_amount','DECIMAL(18,2) NOT NULL DEFAULT 0');
$this->column('invoice_details','discount_amount','DECIMAL(18,2) NOT NULL DEFAULT 0');
$this->column('invoice_details','stock_posted_at','DATETIME NULL');
$this->column('invoice_details','stock_log_id','INT NULL');
$indexes=$this->db->query("SHOW INDEX FROM invoices WHERE Key_name='uq_invoices_no_invoice'")->result();
if(!$indexes)$this->db->query('ALTER TABLE invoices ADD UNIQUE KEY uq_invoices_no_invoice(no_invoice)');
$this->db->query("CREATE TABLE IF NOT EXISTS sales_documents(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,document_type ENUM('quotation','sales_order') NOT NULL,document_no VARCHAR(60) NOT NULL,customer_id INT NOT NULL,document_date DATE NOT NULL,valid_until DATE NULL,status ENUM('draft','sent','accepted','rejected','cancelled','converted') NOT NULL DEFAULT 'draft',subtotal DECIMAL(18,2) NOT NULL DEFAULT 0,tax_total DECIMAL(18,2) NOT NULL DEFAULT 0,total DECIMAL(18,2) NOT NULL DEFAULT 0,notes TEXT NULL,converted_invoice_id INT NULL,created_by INT NULL,created_at DATETIME NOT NULL,updated_at DATETIME NULL,UNIQUE KEY uq_sales_document_no(document_no),KEY idx_sales_customer(customer_id,status)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$this->db->query("CREATE TABLE IF NOT EXISTS sales_document_lines(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,sales_document_id BIGINT UNSIGNED NOT NULL,item_id INT NULL,description VARCHAR(255) NOT NULL,qty DECIMAL(18,4) NOT NULL,unit_price DECIMAL(18,2) NOT NULL,discount_amount DECIMAL(18,2) NOT NULL DEFAULT 0,tax_rate DECIMAL(7,4) NOT NULL DEFAULT 0,tax_amount DECIMAL(18,2) NOT NULL DEFAULT 0,line_total DECIMAL(18,2) NOT NULL,CONSTRAINT fk_sales_line_document FOREIGN KEY(sales_document_id) REFERENCES sales_documents(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$this->column('payments','payment_no','VARCHAR(60) NULL'); $this->column('payments','customer_id','INT NULL');
$this->column('payments','status',"ENUM('draft','posted','cancelled','reversed') NOT NULL DEFAULT 'posted'");
$this->column('payments','reference_no','VARCHAR(100) NULL'); $this->column('payments','notes','TEXT NULL');
$this->column('payments','created_by','INT NULL'); $this->column('payments','reversal_journal_id','INT NULL');
$this->db->query("UPDATE payments p JOIN invoices i ON i.id=p.invoice_id SET p.customer_id=i.customer_id WHERE p.customer_id IS NULL");
$this->db->query("UPDATE payments SET payment_no=CONCAT('LEG-PAY-',LPAD(id,8,'0')) WHERE payment_no IS NULL OR payment_no=''");
$idx=$this->db->query("SHOW INDEX FROM payments WHERE Key_name='uq_payments_payment_no'")->result();if(!$idx)$this->db->query('ALTER TABLE payments ADD UNIQUE KEY uq_payments_payment_no(payment_no)');
$this->db->query("CREATE TABLE IF NOT EXISTS payment_allocations(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,payment_id INT NOT NULL,invoice_id INT NOT NULL,amount DECIMAL(18,2) NOT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_payment_invoice(payment_id,invoice_id),KEY idx_allocation_invoice(invoice_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$this->db->query("INSERT IGNORE INTO payment_allocations(payment_id,invoice_id,amount,created_at) SELECT id,invoice_id,jumlah,created_at FROM payments WHERE invoice_id IS NOT NULL AND jumlah>0");
$this->db->query("CREATE TABLE IF NOT EXISTS customer_advances(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,advance_no VARCHAR(60) NOT NULL,customer_id INT NOT NULL,payment_id INT NULL,original_amount DECIMAL(18,2) NOT NULL,available_amount DECIMAL(18,2) NOT NULL,status ENUM('available','partially_used','used','refunded') NOT NULL DEFAULT 'available',journal_id INT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_advance_no(advance_no),KEY idx_advance_customer(customer_id,status)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$this->db->query("CREATE TABLE IF NOT EXISTS credit_notes(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,credit_note_no VARCHAR(60) NOT NULL,invoice_id INT NOT NULL,customer_id INT NOT NULL,credit_date DATE NOT NULL,amount DECIMAL(18,2) NOT NULL,reason TEXT NOT NULL,status ENUM('draft','approved','posted','cancelled','reversed') NOT NULL DEFAULT 'draft',journal_id INT NULL,reversal_journal_id INT NULL,created_by INT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_credit_note_no(credit_note_no),KEY idx_credit_invoice(invoice_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$this->db->query("CREATE TABLE IF NOT EXISTS customer_refunds(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,refund_no VARCHAR(60) NOT NULL,customer_id INT NOT NULL,advance_id BIGINT UNSIGNED NULL,credit_note_id BIGINT UNSIGNED NULL,refund_date DATE NOT NULL,amount DECIMAL(18,2) NOT NULL,bank_account_id INT NOT NULL,reason TEXT NOT NULL,status ENUM('draft','posted','cancelled','reversed') NOT NULL DEFAULT 'draft',journal_id INT NULL,created_by INT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_refund_no(refund_no)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$this->db->query("CREATE TABLE IF NOT EXISTS receivable_writeoffs(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,writeoff_no VARCHAR(60) NOT NULL,invoice_id INT NOT NULL,writeoff_date DATE NOT NULL,amount DECIMAL(18,2) NOT NULL,reason TEXT NOT NULL,status ENUM('draft','approved','posted','reversed') NOT NULL DEFAULT 'draft',journal_id INT NULL,created_by INT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_writeoff_no(writeoff_no)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$this->db->query("CREATE TABLE IF NOT EXISTS receivable_reminders(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,invoice_id INT NOT NULL,channel ENUM('email','whatsapp','phone','manual') NOT NULL,recipient VARCHAR(150) NULL,message TEXT NULL,status ENUM('queued','sent','failed','cancelled') NOT NULL DEFAULT 'queued',scheduled_at DATETIME NULL,sent_at DATETIME NULL,created_by INT NULL,created_at DATETIME NOT NULL,KEY idx_reminder_queue(status,scheduled_at)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$this->db->query("CREATE TABLE IF NOT EXISTS transaction_attachments(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,module VARCHAR(50) NOT NULL,entity_type VARCHAR(50) NOT NULL,entity_id BIGINT UNSIGNED NOT NULL,original_name VARCHAR(255) NOT NULL,stored_name VARCHAR(255) NOT NULL,mime_type VARCHAR(100) NOT NULL,file_size BIGINT UNSIGNED NOT NULL,sha256 CHAR(64) NOT NULL,uploaded_by INT NULL,created_at DATETIME NOT NULL,KEY idx_attachment_entity(entity_type,entity_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$this->db->query("UPDATE invoices SET workflow_status=CASE WHEN status='draft' THEN 'draft' ELSE 'posted' END, subtotal_before_tax=total, posted_at=CASE WHEN status<>'draft' THEN created_at ELSE NULL END WHERE workflow_status='draft'");
$this->db->query("UPDATE invoice_details d SET stock_posted_at=COALESCE((SELECT MIN(s.created_at) FROM stock_logs s WHERE s.ref_type='invoice_details' AND s.ref_id=d.id),stock_posted_at),stock_log_id=COALESCE((SELECT MIN(s.id) FROM stock_logs s WHERE s.ref_type='invoice_details' AND s.ref_id=d.id),stock_log_id)");
$this->db->query("INSERT IGNORE INTO approval_workflows(code,module,entity_type,name,require_separation,is_active,created_at) VALUES('INVOICE_DEFAULT','invoice','invoice','Persetujuan Invoice',1,1,NOW()),('CREDIT_NOTE_DEFAULT','receivable','credit_note','Persetujuan Credit Note',1,1,NOW()),('WRITEOFF_DEFAULT','receivable','writeoff','Persetujuan Write-off Piutang',1,1,NOW())");
$admin=$this->db->where('nama_role','Admin')->get('roles')->row();if($admin){foreach(array('INVOICE_DEFAULT','CREDIT_NOTE_DEFAULT','WRITEOFF_DEFAULT') as $code){$w=$this->db->get_where('approval_workflows',array('code'=>$code))->row();if($w&&!$this->db->where('workflow_id',$w->id)->count_all_results('approval_workflow_steps'))$this->db->insert('approval_workflow_steps',array('workflow_id'=>$w->id,'step_order'=>1,'name'=>'Persetujuan Admin','approver_role_id'=>$admin->id));}}
}
public function down(){throw new RuntimeException('Migration Tahap 5 tidak mendukung down otomatis karena memuat backfill subledger. Gunakan backup.');}
}
@@ -0,0 +1,15 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Seed_receivable_control_accounts extends CI_Migration
{
public function up()
{
$defs=array(
array('stage5_tax_payable','2191','Utang Pajak Penjualan','liability','sales_tax_payable','Pajak keluaran invoice'),
array('stage5_customer_advance','2192','Uang Muka Pelanggan','liability','customer_advances','Kewajiban uang muka pelanggan'),
array('stage5_bad_debt','6191','Beban Kerugian Piutang','expense','bad_debt_expense','Beban write-off piutang')
);
foreach($defs as $d){$a=$this->db->get_where('accounts',array('kode_akun'=>$d[1]))->row();if(!$a){$this->db->insert('accounts',array('kode_akun'=>$d[1],'nama_akun'=>$d[2],'tipe'=>$d[3],'posisi'=>$d[3]==='expense'?'debit':'kredit','kategori'=>$d[3]==='expense'?'laba_rugi':'neraca','is_active'=>1,'is_header'=>0,'allow_posting'=>1));$id=$this->db->insert_id();}else$id=$a->id;if(!$this->db->where('mapping_key',$d[4])->count_all_results('system_account_mappings'))$this->db->insert('system_account_mappings',array('mapping_key'=>$d[4],'account_id'=>$id,'description'=>$d[5],'is_active'=>1,'created_at'=>date('Y-m-d H:i:s')));}
}
public function down(){foreach(array('sales_tax_payable','customer_advances','bad_debt_expense') as $k)$this->db->where('mapping_key',$k)->delete('system_account_mappings');}
}
@@ -0,0 +1,11 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Enforce_invoice_deletion_control extends CI_Migration
{
public function up(){
$this->db->query("ALTER TABLE invoices MODIFY status ENUM('draft','unpaid','partial','paid','cancelled') NOT NULL DEFAULT 'draft'");
$this->db->query('DROP TRIGGER IF EXISTS trg_invoices_no_delete_posted');
$this->db->query("CREATE TRIGGER trg_invoices_no_delete_posted BEFORE DELETE ON invoices FOR EACH ROW BEGIN IF OLD.workflow_status <> 'draft' OR OLD.posted=1 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Posted/submitted invoice cannot be deleted'; END IF; END");
}
public function down(){$this->db->query('DROP TRIGGER IF EXISTS trg_invoices_no_delete_posted');}
}
@@ -0,0 +1,10 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Enforce_automatic_journal_description extends CI_Migration
{
public function up(){
$this->db->query('DROP TRIGGER IF EXISTS trg_journals_auto_description');
$this->db->query("CREATE TRIGGER trg_journals_auto_description BEFORE INSERT ON journals FOR EACH ROW BEGIN IF COALESCE(NEW.ref_type,'') NOT IN('','manual_journal') AND COALESCE(NEW.keterangan,'') NOT LIKE '[AUTO]%' THEN SET NEW.keterangan=CONCAT('[AUTO] ',COALESCE(NULLIF(NEW.keterangan,''),'Jurnal otomatis'),' | Tanggal transaksi: ',DATE_FORMAT(NEW.tanggal,'%Y-%m-%d'),' | Sumber: ',NEW.ref_type,'#',COALESCE(CAST(NEW.ref_id AS CHAR),'-')); END IF; END");
}
public function down(){$this->db->query('DROP TRIGGER IF EXISTS trg_journals_auto_description');}
}
@@ -0,0 +1,27 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Create_purchase_and_payable_subledger extends CI_Migration
{
public function up(){
$sql=array(
"suppliers"=>"id INT AUTO_INCREMENT PRIMARY KEY,supplier_code VARCHAR(40) NOT NULL,name VARCHAR(150) NOT NULL,tax_number VARCHAR(50) NULL,address TEXT NULL,contact_person VARCHAR(100) NULL,phone VARCHAR(30) NULL,email VARCHAR(120) NULL,bank_name VARCHAR(100) NULL,bank_account_no VARCHAR(80) NULL,bank_account_name VARCHAR(120) NULL,payment_term_days INT NOT NULL DEFAULT 30,is_active TINYINT(1) NOT NULL DEFAULT 1,opening_balance DECIMAL(18,2) NOT NULL DEFAULT 0,opening_balance_date DATE NULL,created_by INT NULL,created_at DATETIME NOT NULL,updated_at DATETIME NULL,UNIQUE KEY uq_supplier_code(supplier_code),KEY idx_supplier_active(is_active)",
"purchase_requests"=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,request_no VARCHAR(60) NOT NULL,request_date DATE NOT NULL,needed_date DATE NULL,department VARCHAR(100) NULL,purpose TEXT NOT NULL,status ENUM('draft','submitted','approved','rejected','cancelled','converted') NOT NULL DEFAULT 'draft',requested_by INT NULL,approved_by INT NULL,approved_at DATETIME NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_purchase_request_no(request_no)",
"purchase_request_lines"=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,purchase_request_id BIGINT UNSIGNED NOT NULL,item_id INT NULL,description VARCHAR(255) NOT NULL,qty DECIMAL(18,4) NOT NULL,estimated_price DECIMAL(18,2) NOT NULL DEFAULT 0,ordered_qty DECIMAL(18,4) NOT NULL DEFAULT 0,CONSTRAINT fk_pr_line_header FOREIGN KEY(purchase_request_id) REFERENCES purchase_requests(id)",
"purchase_orders"=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,po_no VARCHAR(60) NOT NULL,supplier_id INT NOT NULL,purchase_request_id BIGINT UNSIGNED NULL,order_date DATE NOT NULL,expected_date DATE NULL,status ENUM('draft','submitted','approved','partially_received','received','closed','cancelled') NOT NULL DEFAULT 'draft',subtotal DECIMAL(18,2) NOT NULL DEFAULT 0,tax_total DECIMAL(18,2) NOT NULL DEFAULT 0,total DECIMAL(18,2) NOT NULL DEFAULT 0,notes TEXT NULL,created_by INT NULL,approved_by INT NULL,approved_at DATETIME NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_purchase_order_no(po_no),KEY idx_po_supplier(supplier_id,status)",
"purchase_order_lines"=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,purchase_order_id BIGINT UNSIGNED NOT NULL,request_line_id BIGINT UNSIGNED NULL,item_id INT NOT NULL,warehouse_id INT NOT NULL,description VARCHAR(255) NOT NULL,qty DECIMAL(18,4) NOT NULL,unit_price DECIMAL(18,2) NOT NULL,tax_rate DECIMAL(7,4) NOT NULL DEFAULT 0,tax_amount DECIMAL(18,2) NOT NULL DEFAULT 0,line_total DECIMAL(18,2) NOT NULL,received_qty DECIMAL(18,4) NOT NULL DEFAULT 0,invoiced_qty DECIMAL(18,4) NOT NULL DEFAULT 0,CONSTRAINT fk_po_line_header FOREIGN KEY(purchase_order_id) REFERENCES purchase_orders(id)",
"goods_receipts"=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,receipt_no VARCHAR(60) NOT NULL,purchase_order_id BIGINT UNSIGNED NOT NULL,supplier_id INT NOT NULL,receipt_date DATE NOT NULL,supplier_delivery_no VARCHAR(100) NULL,status ENUM('draft','posted','reversed') NOT NULL DEFAULT 'draft',notes TEXT NULL,received_by INT NULL,posted_at DATETIME NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_goods_receipt_no(receipt_no),KEY idx_receipt_po(purchase_order_id,status)",
"goods_receipt_lines"=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,goods_receipt_id BIGINT UNSIGNED NOT NULL,po_line_id BIGINT UNSIGNED NOT NULL,item_id INT NOT NULL,warehouse_id INT NOT NULL,qty DECIMAL(18,4) NOT NULL,stock_log_id INT NULL,UNIQUE KEY uq_receipt_po_line(goods_receipt_id,po_line_id),CONSTRAINT fk_receipt_line_header FOREIGN KEY(goods_receipt_id) REFERENCES goods_receipts(id)",
"supplier_invoices"=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,supplier_invoice_no VARCHAR(100) NOT NULL,internal_no VARCHAR(60) NOT NULL,supplier_id INT NOT NULL,invoice_date DATE NOT NULL,due_date DATE NOT NULL,status ENUM('draft','submitted','approved','posted','partial','paid','cancelled','reversed') NOT NULL DEFAULT 'draft',subtotal DECIMAL(18,2) NOT NULL DEFAULT 0,tax_total DECIMAL(18,2) NOT NULL DEFAULT 0,total DECIMAL(18,2) NOT NULL DEFAULT 0,paid_amount DECIMAL(18,2) NOT NULL DEFAULT 0,balance DECIMAL(18,2) NOT NULL DEFAULT 0,journal_id INT NULL,reversal_journal_id INT NULL,created_by INT NULL,approved_by INT NULL,approved_at DATETIME NULL,posted_by INT NULL,posted_at DATETIME NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_supplier_invoice(supplier_id,supplier_invoice_no),UNIQUE KEY uq_supplier_invoice_internal(internal_no),KEY idx_supplier_invoice_aging(supplier_id,due_date,status)",
"supplier_invoice_lines"=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,supplier_invoice_id BIGINT UNSIGNED NOT NULL,po_line_id BIGINT UNSIGNED NOT NULL,receipt_line_id BIGINT UNSIGNED NULL,item_id INT NOT NULL,qty DECIMAL(18,4) NOT NULL,unit_price DECIMAL(18,2) NOT NULL,tax_amount DECIMAL(18,2) NOT NULL DEFAULT 0,line_total DECIMAL(18,2) NOT NULL,match_status ENUM('matched','qty_variance','price_variance','unmatched') NOT NULL DEFAULT 'unmatched',CONSTRAINT fk_supplier_invoice_line FOREIGN KEY(supplier_invoice_id) REFERENCES supplier_invoices(id)",
"supplier_payments"=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,payment_no VARCHAR(60) NOT NULL,supplier_id INT NOT NULL,payment_date DATE NOT NULL,amount DECIMAL(18,2) NOT NULL,bank_account_id INT NOT NULL,status ENUM('posted','reversed','cancelled') NOT NULL DEFAULT 'posted',reference_no VARCHAR(100) NULL,notes TEXT NULL,journal_id INT NOT NULL,reversal_journal_id INT NULL,created_by INT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_supplier_payment_no(payment_no)",
"supplier_payment_allocations"=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,supplier_payment_id BIGINT UNSIGNED NOT NULL,supplier_invoice_id BIGINT UNSIGNED NOT NULL,amount DECIMAL(18,2) NOT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_supplier_payment_allocation(supplier_payment_id,supplier_invoice_id)",
"supplier_advances"=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,advance_no VARCHAR(60) NOT NULL,supplier_id INT NOT NULL,supplier_payment_id BIGINT UNSIGNED NULL,original_amount DECIMAL(18,2) NOT NULL,available_amount DECIMAL(18,2) NOT NULL,status ENUM('available','partially_used','used','refunded') NOT NULL DEFAULT 'available',journal_id INT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_supplier_advance_no(advance_no)",
"purchase_returns"=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,return_no VARCHAR(60) NOT NULL,supplier_id INT NOT NULL,goods_receipt_id BIGINT UNSIGNED NOT NULL,return_date DATE NOT NULL,amount DECIMAL(18,2) NOT NULL,reason TEXT NOT NULL,status ENUM('draft','posted','reversed') NOT NULL DEFAULT 'draft',journal_id INT NULL,created_by INT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_purchase_return_no(return_no)",
"purchase_return_lines"=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,purchase_return_id BIGINT UNSIGNED NOT NULL,receipt_line_id BIGINT UNSIGNED NOT NULL,item_id INT NOT NULL,warehouse_id INT NOT NULL,qty DECIMAL(18,4) NOT NULL,unit_cost DECIMAL(18,2) NOT NULL,stock_log_id INT NULL,CONSTRAINT fk_purchase_return_header FOREIGN KEY(purchase_return_id) REFERENCES purchase_returns(id)",
"supplier_debit_notes"=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,debit_note_no VARCHAR(60) NOT NULL,supplier_id INT NOT NULL,supplier_invoice_id BIGINT UNSIGNED NOT NULL,note_date DATE NOT NULL,amount DECIMAL(18,2) NOT NULL,reason TEXT NOT NULL,status ENUM('draft','posted','reversed') NOT NULL DEFAULT 'draft',journal_id INT NULL,created_by INT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_supplier_debit_note_no(debit_note_no)"
);foreach($sql as$t=>$fields)$this->db->query("CREATE TABLE IF NOT EXISTS `$t`($fields) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");$this->seedAccounts();$this->seedWorkflows();
}
private function seedAccounts(){foreach(array(array('accounts_payable',34,'Hutang usaha supplier'),array('purchase_input_tax',null,'Pajak masukan pembelian'),array('supplier_advances',null,'Uang muka supplier'))as$d){$id=$d[1];if(!$id){$code=$d[0]==='purchase_input_tax'?'1191':'1192';$name=$d[0]==='purchase_input_tax'?'Pajak Masukan':'Uang Muka Supplier';$a=$this->db->get_where('accounts',array('kode_akun'=>$code))->row();if(!$a){$this->db->insert('accounts',array('kode_akun'=>$code,'nama_akun'=>$name,'tipe'=>'asset','posisi'=>'debit','kategori'=>'neraca','is_active'=>1,'is_header'=>0,'allow_posting'=>1));$id=$this->db->insert_id();}else$id=$a->id;}if(!$this->db->where('mapping_key',$d[0])->count_all_results('system_account_mappings'))$this->db->insert('system_account_mappings',array('mapping_key'=>$d[0],'account_id'=>$id,'description'=>$d[2],'is_active'=>1,'created_at'=>date('Y-m-d H:i:s')));}}
private function seedWorkflows(){$admin=$this->db->where('nama_role','Admin')->get('roles')->row();foreach(array(array('PURCHASE_REQUEST_DEFAULT','purchase','purchase_request','Persetujuan Purchase Request'),array('PURCHASE_ORDER_DEFAULT','purchase','purchase_order','Persetujuan Purchase Order'),array('SUPPLIER_INVOICE_DEFAULT','payable','supplier_invoice','Persetujuan Tagihan Supplier'))as$w){if(!$this->db->where('code',$w[0])->count_all_results('approval_workflows')){$this->db->insert('approval_workflows',array('code'=>$w[0],'module'=>$w[1],'entity_type'=>$w[2],'name'=>$w[3],'require_separation'=>1,'is_active'=>1,'created_at'=>date('Y-m-d H:i:s')));$id=$this->db->insert_id();if($admin)$this->db->insert('approval_workflow_steps',array('workflow_id'=>$id,'step_order'=>1,'name'=>'Persetujuan Admin','approver_role_id'=>$admin->id));}}}
public function down(){throw new RuntimeException('Gunakan backup untuk rollback Tahap 6.');}
}
@@ -0,0 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Add_payable_opening_balance_account extends CI_Migration
{
public function up(){$a=$this->db->get_where('accounts',array('kode_akun'=>'3191'))->row();if(!$a){$this->db->insert('accounts',array('kode_akun'=>'3191','nama_akun'=>'Saldo Awal Hutang','tipe'=>'equity','posisi'=>'kredit','kategori'=>'neraca','is_active'=>1,'is_header'=>0,'allow_posting'=>1));$id=$this->db->insert_id();}else$id=$a->id;if(!$this->db->where('mapping_key','payable_opening_balance')->count_all_results('system_account_mappings'))$this->db->insert('system_account_mappings',array('mapping_key'=>'payable_opening_balance','account_id'=>$id,'description'=>'Lawan jurnal saldo awal hutang supplier','is_active'=>1,'created_at'=>date('Y-m-d H:i:s')));}
public function down(){$this->db->where('mapping_key','payable_opening_balance')->delete('system_account_mappings');}
}
@@ -0,0 +1,29 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Create_professional_inventory_ledger extends CI_Migration
{
private function col($t,$c,$sql){if(!$this->db->field_exists($c,$t))$this->db->query("ALTER TABLE `$t` ADD `$c` $sql");}
public function up(){
$this->db->query('ALTER TABLE stock_logs MODIFY qty DECIMAL(18,4) NOT NULL');$this->db->query('ALTER TABLE items MODIFY stok DECIMAL(18,4) NOT NULL DEFAULT 0');
$this->col('items','costing_method',"ENUM('average','fifo') NOT NULL DEFAULT 'average'");$this->col('items','minimum_stock','DECIMAL(18,4) NOT NULL DEFAULT 0');$this->col('items','reorder_point','DECIMAL(18,4) NOT NULL DEFAULT 0');$this->col('items','last_movement_at','DATETIME NULL');
$this->col('stock_logs','idempotency_key','VARCHAR(100) NULL');$this->col('stock_logs','unit_cost','DECIMAL(18,4) NULL');
$this->col('item_barcodes','reserved_qty','DECIMAL(18,4) NOT NULL DEFAULT 0');$this->col('item_barcodes','batch_id','BIGINT UNSIGNED NULL');$this->col('item_barcodes','bin_id','BIGINT UNSIGNED NULL');$this->col('item_barcodes','version','INT NOT NULL DEFAULT 1');
if($this->db->query("SELECT COUNT(*) total FROM(SELECT barcode FROM item_barcodes GROUP BY barcode HAVING COUNT(*)>1)x")->row()->total)throw new RuntimeException('Barcode duplikat ditemukan. Rekonsiliasi sebelum Tahap 7.');
if(!$this->db->query("SHOW INDEX FROM item_barcodes WHERE Key_name='uq_item_barcode'")->result())$this->db->query('ALTER TABLE item_barcodes ADD UNIQUE KEY uq_item_barcode(barcode)');
$tables=array(
'warehouse_bins'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,warehouse_id INT NOT NULL,code VARCHAR(50) NOT NULL,name VARCHAR(100) NULL,is_active TINYINT(1) NOT NULL DEFAULT 1,UNIQUE KEY uq_warehouse_bin(warehouse_id,code)",
'inventory_batches'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,item_id INT NOT NULL,batch_no VARCHAR(100) NOT NULL,manufactured_date DATE NULL,expiry_date DATE NULL,supplier_id INT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_item_batch(item_id,batch_no)",
'stock_documents'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,document_no VARCHAR(60) NOT NULL,document_type ENUM('opening','in','out','transfer','adjustment','opname','sales_return','purchase_return','damaged','lost') NOT NULL,document_date DATE NOT NULL,from_warehouse_id INT NULL,to_warehouse_id INT NULL,status ENUM('draft','submitted','approved','posted','rejected','reversed') NOT NULL DEFAULT 'draft',reason TEXT NULL,idempotency_key VARCHAR(100) NOT NULL,created_by INT NULL,approved_by INT NULL,approved_at DATETIME NULL,posted_by INT NULL,posted_at DATETIME NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_stock_document_no(document_no),UNIQUE KEY uq_stock_document_idempotency(idempotency_key)",
'stock_document_lines'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,stock_document_id BIGINT UNSIGNED NOT NULL,item_id INT NOT NULL,barcode_id INT NULL,batch_id BIGINT UNSIGNED NULL,from_bin_id BIGINT UNSIGNED NULL,to_bin_id BIGINT UNSIGNED NULL,qty DECIMAL(18,4) NOT NULL,unit_cost DECIMAL(18,4) NOT NULL DEFAULT 0,system_qty DECIMAL(18,4) NULL,counted_qty DECIMAL(18,4) NULL,reason VARCHAR(255) NULL,CONSTRAINT fk_stock_doc_line_header FOREIGN KEY(stock_document_id) REFERENCES stock_documents(id)",
'inventory_ledger'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,item_id INT NOT NULL,warehouse_id INT NOT NULL,bin_id BIGINT UNSIGNED NULL,barcode_id INT NULL,batch_id BIGINT UNSIGNED NULL,movement_date DATE NOT NULL,direction ENUM('in','out') NOT NULL,qty DECIMAL(18,4) NOT NULL,unit_cost DECIMAL(18,4) NOT NULL,value DECIMAL(18,4) NOT NULL,document_type VARCHAR(50) NOT NULL,document_id BIGINT UNSIGNED NOT NULL,document_line_id BIGINT UNSIGNED NULL,idempotency_key VARCHAR(120) NOT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_inventory_ledger_idempotency(idempotency_key),KEY idx_inventory_balance(item_id,warehouse_id,movement_date),KEY idx_inventory_barcode(barcode_id)",
'stock_reservations'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,reservation_no VARCHAR(60) NOT NULL,item_id INT NOT NULL,warehouse_id INT NOT NULL,barcode_id INT NULL,source_type VARCHAR(50) NOT NULL,source_id BIGINT UNSIGNED NOT NULL,qty DECIMAL(18,4) NOT NULL,released_qty DECIMAL(18,4) NOT NULL DEFAULT 0,status ENUM('active','partially_released','released','consumed','cancelled') NOT NULL DEFAULT 'active',expires_at DATETIME NULL,created_by INT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_reservation_no(reservation_no),UNIQUE KEY uq_reservation_source(source_type,source_id,item_id,warehouse_id,barcode_id)",
'inventory_cost_layers'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,item_id INT NOT NULL,warehouse_id INT NOT NULL,source_ledger_id BIGINT UNSIGNED NOT NULL,received_date DATE NOT NULL,original_qty DECIMAL(18,4) NOT NULL,remaining_qty DECIMAL(18,4) NOT NULL,unit_cost DECIMAL(18,4) NOT NULL,status ENUM('open','consumed') NOT NULL DEFAULT 'open',KEY idx_fifo_layer(item_id,warehouse_id,status,received_date)",
'inventory_reconciliations'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,as_of_date DATE NOT NULL,ledger_value DECIMAL(18,2) NOT NULL,gl_value DECIMAL(18,2) NOT NULL,difference DECIMAL(18,2) NOT NULL,status ENUM('matched','variance','resolved') NOT NULL,notes TEXT NULL,created_by INT NULL,created_at DATETIME NOT NULL"
);foreach($tables as$t=>$f)$this->db->query("CREATE TABLE IF NOT EXISTS `$t`($f) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$this->db->query("INSERT IGNORE INTO inventory_ledger(item_id,warehouse_id,barcode_id,movement_date,direction,qty,unit_cost,value,document_type,document_id,idempotency_key,created_at) SELECT s.item_id,COALESCE(s.warehouse_id,0),s.barcode_id,DATE(s.created_at),IF(s.tipe='masuk','in','out'),s.qty,COALESCE(NULLIF(s.unit_cost,0),i.harga_beli),s.qty*COALESCE(NULLIF(s.unit_cost,0),i.harga_beli),COALESCE(NULLIF(s.ref_type,''),'legacy_stock_log'),s.id,CONCAT('LEGACY-STOCK-',s.id),s.created_at FROM stock_logs s JOIN items i ON i.id=s.item_id");
$this->db->query("INSERT IGNORE INTO inventory_cost_layers(item_id,warehouse_id,source_ledger_id,received_date,original_qty,remaining_qty,unit_cost,status) SELECT l.item_id,l.warehouse_id,MIN(l.id),MIN(l.movement_date),GREATEST(SUM(IF(l.direction='in',l.qty,-l.qty)),0),GREATEST(SUM(IF(l.direction='in',l.qty,-l.qty)),0),COALESCE(SUM(IF(l.direction='in',l.value,0))/NULLIF(SUM(IF(l.direction='in',l.qty,0)),0),0),IF(SUM(IF(l.direction='in',l.qty,-l.qty))>0,'open','consumed') FROM inventory_ledger l GROUP BY l.item_id,l.warehouse_id");
$this->db->query('DROP TRIGGER IF EXISTS trg_inventory_ledger_no_update');$this->db->query("CREATE TRIGGER trg_inventory_ledger_no_update BEFORE UPDATE ON inventory_ledger FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Inventory ledger is immutable'");$this->db->query('DROP TRIGGER IF EXISTS trg_inventory_ledger_no_delete');$this->db->query("CREATE TRIGGER trg_inventory_ledger_no_delete BEFORE DELETE ON inventory_ledger FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Inventory ledger is immutable'");
$admin=$this->db->where('nama_role','Admin')->get('roles')->row();if(!$this->db->where('code','STOCK_VARIANCE_DEFAULT')->count_all_results('approval_workflows')){$this->db->insert('approval_workflows',array('code'=>'STOCK_VARIANCE_DEFAULT','module'=>'inventory','entity_type'=>'stock_document','name'=>'Persetujuan Selisih Stok','require_separation'=>1,'is_active'=>1,'created_at'=>date('Y-m-d H:i:s')));if($admin)$this->db->insert('approval_workflow_steps',array('workflow_id'=>$this->db->insert_id(),'step_order'=>1,'name'=>'Persetujuan Admin','approver_role_id'=>$admin->id));}
}
public function down(){throw new RuntimeException('Gunakan backup untuk rollback Tahap 7.');}
}
@@ -0,0 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Seed_inventory_adjustment_accounts extends CI_Migration
{
public function up(){foreach(array(array('inventory_adjustment_gain','4191','Pendapatan Selisih Persediaan','revenue'),array('inventory_adjustment_loss','6192','Beban Selisih Persediaan','expense'))as$d){$a=$this->db->get_where('accounts',array('kode_akun'=>$d[1]))->row();if(!$a){$this->db->insert('accounts',array('kode_akun'=>$d[1],'nama_akun'=>$d[2],'tipe'=>$d[3],'posisi'=>$d[3]==='expense'?'debit':'kredit','kategori'=>'laba_rugi','is_active'=>1,'is_header'=>0,'allow_posting'=>1));$id=$this->db->insert_id();}else$id=$a->id;if(!$this->db->where('mapping_key',$d[0])->count_all_results('system_account_mappings'))$this->db->insert('system_account_mappings',array('mapping_key'=>$d[0],'account_id'=>$id,'description'=>$d[2],'is_active'=>1,'created_at'=>date('Y-m-d H:i:s')));}}
public function down(){foreach(array('inventory_adjustment_gain','inventory_adjustment_loss')as$k)$this->db->where('mapping_key',$k)->delete('system_account_mappings');}
}
@@ -0,0 +1,13 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Enforce_inventory_concurrency_guards extends CI_Migration
{
public function up(){
if(!$this->db->query("SHOW INDEX FROM stock_logs WHERE Key_name='uq_stock_log_idempotency'")->result())$this->db->query('ALTER TABLE stock_logs ADD UNIQUE KEY uq_stock_log_idempotency(idempotency_key)');
$this->db->query('DROP TRIGGER IF EXISTS trg_stock_logs_prevent_negative');
$this->db->query("CREATE TRIGGER trg_stock_logs_prevent_negative BEFORE INSERT ON stock_logs FOR EACH ROW BEGIN DECLARE locked_item INT;DECLARE available_qty DECIMAL(18,4);IF NEW.tipe='keluar' THEN SELECT id INTO locked_item FROM items WHERE id=NEW.item_id FOR UPDATE;SELECT COALESCE(SUM(IF(tipe='masuk',qty,-qty)),0) INTO available_qty FROM stock_logs WHERE item_id=NEW.item_id AND (NEW.warehouse_id IS NULL OR warehouse_id=NEW.warehouse_id);IF available_qty<NEW.qty THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Negative stock is not allowed';END IF;END IF;END");
$this->db->query('DROP TRIGGER IF EXISTS trg_item_barcodes_qty_guard');
$this->db->query("CREATE TRIGGER trg_item_barcodes_qty_guard BEFORE UPDATE ON item_barcodes FOR EACH ROW BEGIN IF NEW.qty_sisa<0 OR NEW.reserved_qty<0 OR NEW.reserved_qty>NEW.qty_sisa THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Invalid or negative barcode quantity';END IF;END");
}
public function down(){$this->db->query('DROP TRIGGER IF EXISTS trg_stock_logs_prevent_negative');$this->db->query('DROP TRIGGER IF EXISTS trg_item_barcodes_qty_guard');$this->db->query('ALTER TABLE stock_logs DROP INDEX uq_stock_log_idempotency');}
}
@@ -0,0 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Backfill_inventory_movement_dates extends CI_Migration
{
public function up(){$this->db->query("UPDATE items i SET last_movement_at=(SELECT MAX(s.created_at) FROM stock_logs s WHERE s.item_id=i.id)");}
public function down(){}
}
@@ -0,0 +1,24 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Create_fixed_asset_subledger extends CI_Migration
{
private function col($t,$c,$sql){if(!$this->db->field_exists($c,$t))$this->db->query("ALTER TABLE `$t` ADD `$c` $sql");}
public function up(){
foreach(array('category_id'=>'BIGINT UNSIGNED NULL','responsible_user_id'=>'INT NULL','source_type'=>"ENUM('purchase','warehouse','manual') NOT NULL DEFAULT 'manual'",'source_id'=>'BIGINT UNSIGNED NULL','capitalization_date'=>'DATE NULL','depreciation_method'=>"ENUM('straight_line','declining_balance') NOT NULL DEFAULT 'straight_line'",'asset_account_id'=>'INT NULL','accumulated_depreciation_account_id'=>'INT NULL','depreciation_expense_account_id'=>'INT NULL','impairment_total'=>'DECIMAL(18,2) NOT NULL DEFAULT 0','revaluation_total'=>'DECIMAL(18,2) NOT NULL DEFAULT 0','disposal_date'=>'DATE NULL','disposal_proceeds'=>'DECIMAL(18,2) NOT NULL DEFAULT 0','lifecycle_status'=>"ENUM('draft','active','fully_depreciated','disposed','lost','damaged') NOT NULL DEFAULT 'active'",'qr_code'=>'VARCHAR(120) NULL','version'=>'INT NOT NULL DEFAULT 1') as$c=>$s)$this->col('assets',$c,$s);
$this->db->query("UPDATE assets SET capitalization_date=COALESCE(capitalization_date,tanggal_perolehan),source_type=IF(sumber='gudang','warehouse','manual'),asset_account_id=COALESCE(asset_account_id,account_debit_id),qr_code=COALESCE(qr_code,CONCAT('ASSET:',kode_asset)),lifecycle_status=IF(status='aktif','active','disposed')");
$tables=array(
'asset_categories'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,code VARCHAR(30) NOT NULL,name VARCHAR(120) NOT NULL,default_useful_life_months INT NOT NULL DEFAULT 48,default_residual_percent DECIMAL(7,4) NOT NULL DEFAULT 0,depreciation_method ENUM('straight_line','declining_balance') NOT NULL DEFAULT 'straight_line',asset_account_id INT NULL,accumulated_depreciation_account_id INT NULL,depreciation_expense_account_id INT NULL,is_active TINYINT(1) NOT NULL DEFAULT 1,UNIQUE KEY uq_asset_category_code(code)",
'asset_events'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,asset_id INT NOT NULL,event_no VARCHAR(60) NOT NULL,event_type ENUM('acquisition','capitalization','addition','transfer','maintenance','depreciation','impairment','revaluation','disposal','lost','damaged','opname_adjustment','reversal') NOT NULL,event_date DATE NOT NULL,amount DECIMAL(18,2) NOT NULL DEFAULT 0,book_value_before DECIMAL(18,2) NULL,book_value_after DECIMAL(18,2) NULL,from_location_id INT NULL,to_location_id INT NULL,notes TEXT NULL,source_type VARCHAR(50) NULL,source_id BIGINT UNSIGNED NULL,journal_id INT NULL,idempotency_key VARCHAR(120) NOT NULL,created_by INT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_asset_event_no(event_no),UNIQUE KEY uq_asset_event_idempotency(idempotency_key),KEY idx_asset_event(asset_id,event_date)",
'asset_depreciation_schedule'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,asset_id INT NOT NULL,period CHAR(7) NOT NULL,due_date DATE NOT NULL,opening_book_value DECIMAL(18,2) NOT NULL,depreciation_amount DECIMAL(18,2) NOT NULL,closing_book_value DECIMAL(18,2) NOT NULL,status ENUM('scheduled','posted','skipped','reversed') NOT NULL DEFAULT 'scheduled',journal_id INT NULL,asset_event_id BIGINT UNSIGNED NULL,posted_at DATETIME NULL,UNIQUE KEY uq_asset_depreciation_period(asset_id,period)",
'asset_maintenance'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,asset_id INT NOT NULL,maintenance_date DATE NOT NULL,vendor VARCHAR(150) NULL,description TEXT NOT NULL,cost DECIMAL(18,2) NOT NULL DEFAULT 0,next_due_date DATE NULL,attachment_id BIGINT UNSIGNED NULL,created_by INT NULL,created_at DATETIME NOT NULL",
'asset_opnames'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,opname_no VARCHAR(60) NOT NULL,opname_date DATE NOT NULL,location_id INT NULL,status ENUM('draft','submitted','approved','posted','rejected') NOT NULL DEFAULT 'draft',notes TEXT NULL,created_by INT NULL,approved_by INT NULL,approved_at DATETIME NULL,UNIQUE KEY uq_asset_opname_no(opname_no)",
'asset_opname_lines'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,asset_opname_id BIGINT UNSIGNED NOT NULL,asset_id INT NOT NULL,expected_status VARCHAR(30) NOT NULL,actual_status VARCHAR(30) NOT NULL,actual_location_id INT NULL,notes TEXT NULL,UNIQUE KEY uq_asset_opname_line(asset_opname_id,asset_id)",
'asset_reconciliations'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,as_of_date DATE NOT NULL,subledger_cost DECIMAL(18,2) NOT NULL,gl_cost DECIMAL(18,2) NOT NULL,subledger_accumulated DECIMAL(18,2) NOT NULL,gl_accumulated DECIMAL(18,2) NOT NULL,difference DECIMAL(18,2) NOT NULL,status ENUM('matched','variance','resolved') NOT NULL,notes TEXT NULL,created_by INT NULL,created_at DATETIME NOT NULL"
);foreach($tables as$t=>$s)$this->db->query("CREATE TABLE IF NOT EXISTS `$t`($s) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$this->db->query("INSERT IGNORE INTO asset_events(asset_id,event_no,event_type,event_date,amount,book_value_before,book_value_after,notes,source_type,source_id,idempotency_key,created_at) SELECT id,CONCAT('LEGACY-AST-',id),'acquisition',tanggal_perolehan,nilai_perolehan,0,nilai_perolehan,'Backfill aset legacy tanpa jurnal baru','legacy_asset',id,CONCAT('LEGACY-ASSET-',id),COALESCE(created_at,NOW()) FROM assets");
$this->db->query("INSERT IGNORE INTO asset_depreciation_schedule(asset_id,period,due_date,opening_book_value,depreciation_amount,closing_book_value,status) SELECT asset_id,DATE_FORMAT(tanggal_dibuat,'%Y-%m'),tanggal_dibuat,GREATEST(0,a.nilai_perolehan-SUM(m.nilai) OVER(PARTITION BY m.asset_id ORDER BY m.tanggal_dibuat,m.id)+m.nilai),m.nilai,GREATEST(a.nilai_residu,a.nilai_perolehan-SUM(m.nilai) OVER(PARTITION BY m.asset_id ORDER BY m.tanggal_dibuat,m.id)),'posted' FROM asset_mutations m JOIN assets a ON a.id=m.asset_id WHERE m.tipe='penyusutan'");
$this->db->query('DROP TRIGGER IF EXISTS trg_asset_event_no_update');$this->db->query("CREATE TRIGGER trg_asset_event_no_update BEFORE UPDATE ON asset_events FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Asset event is immutable'");
$this->db->query('DROP TRIGGER IF EXISTS trg_asset_event_no_delete');$this->db->query("CREATE TRIGGER trg_asset_event_no_delete BEFORE DELETE ON asset_events FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Asset event is immutable'");
}
public function down(){throw new RuntimeException('Gunakan backup untuk rollback Tahap 8.');}
}
@@ -0,0 +1,20 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Create_cash_bank_subledger extends CI_Migration
{
public function up(){
$tables=array(
'cash_accounts'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,code VARCHAR(30) NOT NULL,name VARCHAR(120) NOT NULL,account_type ENUM('cash','bank','petty_cash') NOT NULL,gl_account_id INT NOT NULL,bank_name VARCHAR(120) NULL,bank_account_no VARCHAR(80) NULL,bank_account_name VARCHAR(120) NULL,currency CHAR(3) NOT NULL DEFAULT 'IDR',opening_balance DECIMAL(18,2) NOT NULL DEFAULT 0,opening_date DATE NULL,is_active TINYINT(1) NOT NULL DEFAULT 1,created_at DATETIME NOT NULL,UNIQUE KEY uq_cash_account_code(code),UNIQUE KEY uq_cash_account_gl(gl_account_id)",
'cash_transactions'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,transaction_no VARCHAR(60) NOT NULL,transaction_date DATE NOT NULL,cash_account_id BIGINT UNSIGNED NOT NULL,transaction_type ENUM('opening','receipt','payment','transfer_in','transfer_out','petty_cash','cash_advance','advance_settlement','reimbursement','bank_charge','interest','adjustment') NOT NULL,direction ENUM('in','out') NOT NULL,amount DECIMAL(18,2) NOT NULL,counter_account_id INT NULL,party_type VARCHAR(30) NULL,party_id BIGINT UNSIGNED NULL,reference_no VARCHAR(120) NULL,description TEXT NOT NULL,status ENUM('draft','submitted','approved','posted','rejected','reversed') NOT NULL DEFAULT 'draft',requires_approval TINYINT(1) NOT NULL DEFAULT 0,source_type VARCHAR(50) NULL,source_id BIGINT UNSIGNED NULL,journal_id INT NULL,idempotency_key VARCHAR(120) NOT NULL,created_by INT NULL,approved_by INT NULL,approved_at DATETIME NULL,posted_by INT NULL,posted_at DATETIME NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_cash_transaction_no(transaction_no),UNIQUE KEY uq_cash_transaction_idempotency(idempotency_key),KEY idx_cash_ledger(cash_account_id,transaction_date,status)",
'cash_transfers'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,transfer_no VARCHAR(60) NOT NULL,transfer_date DATE NOT NULL,from_cash_account_id BIGINT UNSIGNED NOT NULL,to_cash_account_id BIGINT UNSIGNED NOT NULL,amount DECIMAL(18,2) NOT NULL,bank_charge DECIMAL(18,2) NOT NULL DEFAULT 0,reference_no VARCHAR(120) NULL,status ENUM('draft','submitted','approved','posted','rejected','reversed') NOT NULL DEFAULT 'draft',out_transaction_id BIGINT UNSIGNED NULL,in_transaction_id BIGINT UNSIGNED NULL,journal_id INT NULL,idempotency_key VARCHAR(120) NOT NULL,created_by INT NULL,approved_by INT NULL,UNIQUE KEY uq_cash_transfer_no(transfer_no),UNIQUE KEY uq_cash_transfer_key(idempotency_key)",
'bank_statement_imports'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,cash_account_id BIGINT UNSIGNED NOT NULL,file_name VARCHAR(255) NOT NULL,statement_from DATE NULL,statement_to DATE NULL,opening_balance DECIMAL(18,2) NULL,closing_balance DECIMAL(18,2) NULL,file_hash CHAR(64) NOT NULL,status ENUM('imported','processing','completed','cancelled') NOT NULL DEFAULT 'imported',created_by INT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_bank_import_hash(cash_account_id,file_hash)",
'bank_statement_lines'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,import_id BIGINT UNSIGNED NOT NULL,line_no INT NOT NULL,transaction_date DATE NOT NULL,description TEXT NULL,reference_no VARCHAR(120) NULL,debit DECIMAL(18,2) NOT NULL DEFAULT 0,credit DECIMAL(18,2) NOT NULL DEFAULT 0,balance DECIMAL(18,2) NULL,match_status ENUM('unmatched','suggested','matched','ignored') NOT NULL DEFAULT 'unmatched',matched_cash_transaction_id BIGINT UNSIGNED NULL,match_score DECIMAL(5,2) NULL,UNIQUE KEY uq_bank_import_line(import_id,line_no)",
'bank_reconciliations'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,reconciliation_no VARCHAR(60) NOT NULL,cash_account_id BIGINT UNSIGNED NOT NULL,period_end DATE NOT NULL,book_balance DECIMAL(18,2) NOT NULL,statement_balance DECIMAL(18,2) NOT NULL,outstanding_receipts DECIMAL(18,2) NOT NULL DEFAULT 0,outstanding_payments DECIMAL(18,2) NOT NULL DEFAULT 0,difference DECIMAL(18,2) NOT NULL,status ENUM('draft','balanced','variance','closed') NOT NULL DEFAULT 'draft',notes TEXT NULL,created_by INT NULL,closed_by INT NULL,closed_at DATETIME NULL,UNIQUE KEY uq_bank_reconciliation(reconciliation_no),UNIQUE KEY uq_bank_period(cash_account_id,period_end)",
'cash_forecasts'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,forecast_date DATE NOT NULL,cash_account_id BIGINT UNSIGNED NULL,direction ENUM('in','out') NOT NULL,amount DECIMAL(18,2) NOT NULL,probability DECIMAL(5,2) NOT NULL DEFAULT 100,description VARCHAR(255) NOT NULL,source_type VARCHAR(50) NULL,source_id BIGINT UNSIGNED NULL,status ENUM('planned','realized','cancelled') NOT NULL DEFAULT 'planned',created_by INT NULL,created_at DATETIME NOT NULL"
);foreach($tables as$t=>$s)$this->db->query("CREATE TABLE IF NOT EXISTS `$t`($s) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$this->db->query("INSERT IGNORE INTO cash_accounts(code,name,account_type,gl_account_id,created_at) SELECT CONCAT('GL-',kode_akun),nama_akun,IF(LOWER(nama_akun) LIKE '%bank%','bank',IF(LOWER(nama_akun) LIKE '%petty%','petty_cash','cash')),id,NOW() FROM accounts WHERE sub_tipe='kas'");
$admin=$this->db->where('nama_role','Admin')->get('roles')->row();if(!$this->db->where('code','CASH_PAYMENT_LARGE')->count_all_results('approval_workflows')){$this->db->insert('approval_workflows',array('code'=>'CASH_PAYMENT_LARGE','module'=>'cash_bank','entity_type'=>'cash_transaction','name'=>'Persetujuan Pembayaran Besar','require_separation'=>1,'is_active'=>1,'created_at'=>date('Y-m-d H:i:s')));if($admin)$this->db->insert('approval_workflow_steps',array('workflow_id'=>$this->db->insert_id(),'step_order'=>1,'name'=>'Persetujuan Admin','approver_role_id'=>$admin->id));}
$this->db->query('DROP TRIGGER IF EXISTS trg_cash_posted_no_delete');$this->db->query("CREATE TRIGGER trg_cash_posted_no_delete BEFORE DELETE ON cash_transactions FOR EACH ROW BEGIN IF OLD.status IN('posted','reversed') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Posted cash transaction cannot be deleted'; END IF; END");
}
public function down(){throw new RuntimeException('Gunakan backup untuk rollback Tahap 9.');}
}
@@ -0,0 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Seed_asset_disposal_accounts extends CI_Migration
{
public function up(){foreach(array(array('asset_disposal_gain','4192','Laba Pelepasan Aset','revenue'),array('asset_disposal_loss','6193','Rugi Pelepasan Aset','expense'))as$d){$a=$this->db->get_where('accounts',array('kode_akun'=>$d[1]))->row();if(!$a){$this->db->insert('accounts',array('kode_akun'=>$d[1],'nama_akun'=>$d[2],'tipe'=>$d[3],'posisi'=>$d[3]==='expense'?'debit':'kredit','kategori'=>'laba_rugi','is_active'=>1,'is_header'=>0,'allow_posting'=>1));$id=$this->db->insert_id();}else$id=$a->id;if(!$this->db->where('mapping_key',$d[0])->count_all_results('system_account_mappings'))$this->db->insert('system_account_mappings',array('mapping_key'=>$d[0],'account_id'=>$id,'description'=>$d[2],'is_active'=>1,'created_at'=>date('Y-m-d H:i:s')));}}
public function down(){throw new RuntimeException('Gunakan backup untuk rollback mapping Tahap 8.');}
}
@@ -0,0 +1,32 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Create_hr_payroll_professional extends CI_Migration
{
private function col($t,$c,$sql){if($this->db->table_exists($t)&&!$this->db->field_exists($c,$t))$this->db->query("ALTER TABLE `$t` ADD `$c` $sql");}
public function up(){
foreach(array('tax_status'=>'VARCHAR(20) NULL','tax_number'=>'VARCHAR(50) NULL','benefit_profile_id'=>'BIGINT UNSIGNED NULL','confidentiality_level'=>"ENUM('normal','restricted') NOT NULL DEFAULT 'restricted'",'version'=>'INT NOT NULL DEFAULT 1')as$c=>$s)$this->col('k_employees',$c,$s);
foreach(array('workflow_status'=>"ENUM('draft','reviewed','approved','final','posted','paid','cancelled') NOT NULL DEFAULT 'draft'",'reviewed_by'=>'INT NULL','reviewed_at'=>'DATETIME NULL','approved_by'=>'INT NULL','approved_at'=>'DATETIME NULL','finalized_by'=>'INT NULL','finalized_at'=>'DATETIME NULL','journal_id'=>'INT NULL','payment_transaction_id'=>'BIGINT UNSIGNED NULL','cancelled_by'=>'INT NULL','cancelled_at'=>'DATETIME NULL','cancellation_reason'=>'TEXT NULL','calculation_hash'=>'CHAR(64) NULL')as$c=>$s)$this->col('k_payroll_periods',$c,$s);
$this->col('k_payrolls','gross_salary','DECIMAL(18,2) NOT NULL DEFAULT 0');$this->col('k_payrolls','total_deductions','DECIMAL(18,2) NOT NULL DEFAULT 0');$this->col('k_payrolls','calculation_snapshot','LONGTEXT NULL');$this->col('k_payrolls','version','INT NOT NULL DEFAULT 1');
$tables=array(
'employee_employment_history'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,employee_id INT NOT NULL,effective_from DATE NOT NULL,effective_to DATE NULL,department_id INT NULL,position_id INT NULL,branch_id INT NULL,employment_status VARCHAR(30) NOT NULL,contract_no VARCHAR(80) NULL,contract_start DATE NULL,contract_end DATE NULL,basic_salary DECIMAL(18,2) NOT NULL DEFAULT 0,reason VARCHAR(255) NULL,created_by INT NULL,created_at DATETIME NOT NULL,KEY idx_employee_history(employee_id,effective_from)",
'employee_documents'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,employee_id INT NOT NULL,document_type VARCHAR(50) NOT NULL,document_no VARCHAR(100) NULL,expiry_date DATE NULL,original_name VARCHAR(255) NOT NULL,stored_name VARCHAR(255) NOT NULL,mime_type VARCHAR(100) NOT NULL,file_size BIGINT UNSIGNED NOT NULL,sha256 CHAR(64) NOT NULL,access_level ENUM('hr','payroll_admin') NOT NULL DEFAULT 'hr',uploaded_by INT NULL,created_at DATETIME NOT NULL,KEY idx_employee_document(employee_id,document_type)",
'attendance_devices'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,serial_no VARCHAR(100) NOT NULL,name VARCHAR(120) NULL,branch_id INT NULL,secret_hash CHAR(64) NULL,is_active TINYINT(1) NOT NULL DEFAULT 1,last_seen_at DATETIME NULL,UNIQUE KEY uq_attendance_device(serial_no)",
'attendance_raw_logs'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,device_id BIGINT UNSIGNED NOT NULL,employee_pin VARCHAR(80) NOT NULL,check_time DATETIME NOT NULL,event_type VARCHAR(30) NULL,raw_payload TEXT NULL,payload_hash CHAR(64) NOT NULL,validation_status ENUM('valid','invalid','duplicate','unknown_employee') NOT NULL DEFAULT 'valid',processed_attendance_id BIGINT UNSIGNED NULL,received_at DATETIME NOT NULL,UNIQUE KEY uq_attendance_raw(device_id,payload_hash),KEY idx_raw_employee_time(employee_pin,check_time)",
'attendance_corrections'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,attendance_id INT NOT NULL,requested_checkin DATETIME NULL,requested_checkout DATETIME NULL,requested_status VARCHAR(30) NULL,reason TEXT NOT NULL,status ENUM('submitted','approved','rejected','applied') NOT NULL DEFAULT 'submitted',requested_by INT NOT NULL,requested_at DATETIME NOT NULL,approved_by INT NULL,approved_at DATETIME NULL,applied_at DATETIME NULL,UNIQUE KEY uq_attendance_pending(attendance_id,status)",
'attendance_period_locks'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,period CHAR(7) NOT NULL,branch_id INT NULL,status ENUM('open','locked') NOT NULL DEFAULT 'open',locked_by INT NULL,locked_at DATETIME NULL,notes TEXT NULL,UNIQUE KEY uq_attendance_lock(period,branch_id)",
'overtime_requests'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,employee_id INT NOT NULL,overtime_date DATE NOT NULL,start_time TIME NOT NULL,end_time TIME NOT NULL,hours DECIMAL(8,2) NOT NULL,reason TEXT NOT NULL,status ENUM('submitted','approved','rejected','paid') NOT NULL DEFAULT 'submitted',requested_by INT NULL,approved_by INT NULL,approved_at DATETIME NULL,UNIQUE KEY uq_overtime_employee(employee_id,overtime_date,start_time)",
'payroll_components'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,code VARCHAR(40) NOT NULL,name VARCHAR(120) NOT NULL,component_type ENUM('earning','deduction','employer_cost') NOT NULL,category ENUM('basic','allowance','overtime','unpaid_leave','loan','bpjs','tax','thr','bonus','other') NOT NULL,formula_expression VARCHAR(500) NULL,fixed_amount DECIMAL(18,2) NULL,taxable TINYINT(1) NOT NULL DEFAULT 1,prorated TINYINT(1) NOT NULL DEFAULT 0,expense_account_id INT NULL,liability_account_id INT NULL,is_active TINYINT(1) NOT NULL DEFAULT 1,UNIQUE KEY uq_payroll_component(code)",
'employee_payroll_components'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,employee_id INT NOT NULL,component_id BIGINT UNSIGNED NOT NULL,amount_override DECIMAL(18,2) NULL,effective_from DATE NOT NULL,effective_to DATE NULL,is_active TINYINT(1) NOT NULL DEFAULT 1,UNIQUE KEY uq_employee_component(employee_id,component_id,effective_from)",
'payroll_calculation_lines'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,payroll_id INT NOT NULL,component_id BIGINT UNSIGNED NULL,component_code VARCHAR(40) NOT NULL,component_name VARCHAR(120) NOT NULL,component_type ENUM('earning','deduction','employer_cost') NOT NULL,base_amount DECIMAL(18,2) NOT NULL DEFAULT 0,rate DECIMAL(18,6) NULL,quantity DECIMAL(18,4) NULL,amount DECIMAL(18,2) NOT NULL,formula_snapshot VARCHAR(500) NULL,expense_account_id INT NULL,liability_account_id INT NULL,UNIQUE KEY uq_payroll_component_line(payroll_id,component_code)",
'employee_loans'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,loan_no VARCHAR(60) NOT NULL,employee_id INT NOT NULL,loan_date DATE NOT NULL,principal DECIMAL(18,2) NOT NULL,balance DECIMAL(18,2) NOT NULL,installment_amount DECIMAL(18,2) NOT NULL,status ENUM('active','paid','cancelled') NOT NULL DEFAULT 'active',receivable_account_id INT NULL,created_by INT NULL,UNIQUE KEY uq_employee_loan(loan_no)",
'payroll_payments'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,payroll_period_id INT NOT NULL,payment_no VARCHAR(60) NOT NULL,payment_date DATE NOT NULL,cash_account_id BIGINT UNSIGNED NOT NULL,total_amount DECIMAL(18,2) NOT NULL,status ENUM('draft','submitted','approved','posted','rejected','reversed') NOT NULL DEFAULT 'draft',journal_id INT NULL,created_by INT NULL,approved_by INT NULL,posted_at DATETIME NULL,UNIQUE KEY uq_payroll_payment_no(payment_no),UNIQUE KEY uq_payroll_period_payment(payroll_period_id)",
'payroll_cancellations'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,payroll_period_id INT NOT NULL,reason TEXT NOT NULL,reversal_journal_id INT NULL,status ENUM('submitted','approved','rejected','posted') NOT NULL DEFAULT 'submitted',requested_by INT NOT NULL,requested_at DATETIME NOT NULL,approved_by INT NULL,approved_at DATETIME NULL"
);foreach($tables as$t=>$s)$this->db->query("CREATE TABLE IF NOT EXISTS `$t`($s) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$this->db->query("INSERT IGNORE INTO employee_employment_history(employee_id,effective_from,department_id,position_id,branch_id,employment_status,basic_salary,reason,created_at) SELECT id,join_date,department_id,position_id,branch_id,employment_status,basic_salary,'Backfill data aktif Tahap 10',NOW() FROM k_employees");
foreach(array(array('BASIC','Gaji Pokok','earning','basic','basic_salary',null,1,1),array('OVERTIME','Lembur','earning','overtime','overtime_hours * overtime_rate',null,1,0),array('UNPAID_LEAVE','Cuti Tidak Dibayar','deduction','unpaid_leave','unpaid_days * daily_rate',null,0,0),array('THR','THR','earning','thr',null,null,1,0),array('BONUS','Bonus','earning','bonus',null,null,1,0),array('BPJS','BPJS Karyawan','deduction','bpjs',null,null,0,0),array('PPh21','PPh 21','deduction','tax',null,null,0,0),array('LOAN','Cicilan Pinjaman','deduction','loan',null,null,0,0))as$d)if(!$this->db->where('code',$d[0])->count_all_results('payroll_components'))$this->db->insert('payroll_components',array('code'=>$d[0],'name'=>$d[1],'component_type'=>$d[2],'category'=>$d[3],'formula_expression'=>$d[4],'fixed_amount'=>$d[5],'taxable'=>$d[6],'prorated'=>$d[7]));
$this->db->query("UPDATE k_payroll_periods SET workflow_status=IF(status='draft','draft',IF(status IN('final','closed'),'final','draft'))");
$this->db->query('DROP TRIGGER IF EXISTS trg_final_payroll_no_update');$this->db->query("CREATE TRIGGER trg_final_payroll_no_update BEFORE UPDATE ON k_payrolls FOR EACH ROW BEGIN DECLARE s VARCHAR(20); SELECT workflow_status INTO s FROM k_payroll_periods WHERE id=OLD.payroll_period_id; IF s IN('final','posted','paid') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Final payroll is locked'; END IF; END");
$admin=$this->db->where('nama_role','Admin')->get('roles')->row();foreach(array(array('ATTENDANCE_CORRECTION','hr','attendance_correction','Koreksi Absensi'),array('PAYROLL_FINAL','payroll','payroll_period','Finalisasi Payroll'))as$w)if(!$this->db->where('code',$w[0])->count_all_results('approval_workflows')){$this->db->insert('approval_workflows',array('code'=>$w[0],'module'=>$w[1],'entity_type'=>$w[2],'name'=>$w[3],'require_separation'=>1,'is_active'=>1,'created_at'=>date('Y-m-d H:i:s')));if($admin)$this->db->insert('approval_workflow_steps',array('workflow_id'=>$this->db->insert_id(),'step_order'=>1,'name'=>'Persetujuan Admin','approver_role_id'=>$admin->id));}
}
public function down(){throw new RuntimeException('Gunakan backup untuk rollback Tahap 10.');}
}
@@ -0,0 +1,18 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Create_configurable_tax_engine extends CI_Migration
{
public function up(){
$tables=array(
'tax_codes'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,code VARCHAR(30) NOT NULL,name VARCHAR(120) NOT NULL,tax_type ENUM('sales','purchase','withholding','payroll') NOT NULL,calculation_method ENUM('exclusive','inclusive') NOT NULL DEFAULT 'exclusive',rate DECIMAL(9,6) NOT NULL DEFAULT 0,formula_expression VARCHAR(500) NULL,input_account_id INT NULL,output_account_id INT NULL,withholding_payable_account_id INT NULL,withholding_receivable_account_id INT NULL,effective_from DATE NOT NULL,effective_to DATE NULL,is_active TINYINT(1) NOT NULL DEFAULT 1,UNIQUE KEY uq_tax_code_effective(code,effective_from)",
'tax_periods'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,period CHAR(7) NOT NULL,tax_type ENUM('sales','purchase','withholding','payroll','all') NOT NULL DEFAULT 'all',status ENUM('open','reviewed','filed','closed') NOT NULL DEFAULT 'open',filing_reference VARCHAR(100) NULL,filed_at DATETIME NULL,filed_by INT NULL,closed_at DATETIME NULL,closed_by INT NULL,notes TEXT NULL,UNIQUE KEY uq_tax_period(period,tax_type)",
'tax_transactions'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,tax_date DATE NOT NULL,tax_period_id BIGINT UNSIGNED NULL,tax_code_id BIGINT UNSIGNED NOT NULL,direction ENUM('input','output','withheld','withholding') NOT NULL,source_type VARCHAR(50) NOT NULL,source_id BIGINT UNSIGNED NOT NULL,source_line_id BIGINT UNSIGNED NULL,counterparty_type VARCHAR(30) NULL,counterparty_id BIGINT UNSIGNED NULL,tax_invoice_no VARCHAR(100) NULL,tax_base DECIMAL(18,2) NOT NULL,tax_amount DECIMAL(18,2) NOT NULL,status ENUM('draft','posted','reversed') NOT NULL DEFAULT 'posted',journal_id INT NULL,idempotency_key VARCHAR(140) NOT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_tax_transaction_key(idempotency_key),KEY idx_tax_register(tax_date,direction,tax_code_id)",
'tax_exports'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,tax_period_id BIGINT UNSIGNED NOT NULL,export_type VARCHAR(50) NOT NULL,file_name VARCHAR(255) NOT NULL,row_count INT NOT NULL,file_hash CHAR(64) NOT NULL,created_by INT NULL,created_at DATETIME NOT NULL"
);foreach($tables as$t=>$s)$this->db->query("CREATE TABLE IF NOT EXISTS `$t`($s) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$out=$this->db->where('mapping_key','sales_tax_payable')->get('system_account_mappings')->row();$in=$this->db->where('mapping_key','purchase_input_tax')->get('system_account_mappings')->row();$this->db->query("INSERT IGNORE INTO tax_codes(code,name,tax_type,calculation_method,rate,input_account_id,output_account_id,effective_from,is_active)VALUES('PPN','PPN','sales','exclusive',11,?,?,?,1)",array($in?$in->account_id:null,$out?$out->account_id:null,date('Y-m-01')));
$this->db->query("INSERT IGNORE INTO tax_transactions(tax_date,tax_code_id,direction,source_type,source_id,source_line_id,counterparty_type,counterparty_id,tax_invoice_no,tax_base,tax_amount,status,journal_id,idempotency_key,created_at) SELECT i.tanggal,t.id,'output','invoice',i.id,d.id,'customer',i.customer_id,NULL,(d.subtotal-d.tax_amount),d.tax_amount,'posted',i.journal_id,CONCAT('LEGACY-INV-TAX-',d.id),COALESCE(i.posted_at,i.created_at,NOW()) FROM invoice_details d JOIN invoices i ON i.id=d.invoice_id JOIN tax_codes t ON t.code='PPN' WHERE d.tax_amount>0 AND i.workflow_status='posted'");
$this->db->query("INSERT IGNORE INTO tax_transactions(tax_date,tax_code_id,direction,source_type,source_id,source_line_id,counterparty_type,counterparty_id,tax_invoice_no,tax_base,tax_amount,status,journal_id,idempotency_key,created_at) SELECT s.invoice_date,t.id,'input','supplier_invoice',s.id,l.id,'supplier',s.supplier_id,s.supplier_invoice_no,(l.line_total-l.tax_amount),l.tax_amount,'posted',s.journal_id,CONCAT('LEGACY-BILL-TAX-',l.id),COALESCE(s.posted_at,s.created_at,NOW()) FROM supplier_invoice_lines l JOIN supplier_invoices s ON s.id=l.supplier_invoice_id JOIN tax_codes t ON t.code='PPN' WHERE l.tax_amount>0 AND s.status IN('posted','partial','paid')");
$this->db->query('DROP TRIGGER IF EXISTS trg_closed_tax_period');$this->db->query("CREATE TRIGGER trg_closed_tax_period BEFORE INSERT ON tax_transactions FOR EACH ROW BEGIN DECLARE n INT; SELECT COUNT(*) INTO n FROM tax_periods WHERE period=DATE_FORMAT(NEW.tax_date,'%Y-%m') AND tax_type IN('all',(SELECT tax_type FROM tax_codes WHERE id=NEW.tax_code_id)) AND status IN('filed','closed'); IF n>0 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Tax period is filed or closed'; END IF; END");
}
public function down(){throw new RuntimeException('Gunakan backup untuk rollback Tahap 11.');}
}
@@ -0,0 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Seed_payroll_control_accounts extends CI_Migration
{
public function up(){foreach(array(array('payroll_expense','6201','Beban Gaji','expense','debit','laba_rugi'),array('payroll_payable','2201','Hutang Gaji','liability','kredit','neraca'),array('payroll_tax_payable','2202','Hutang Pajak Karyawan','liability','kredit','neraca'),array('payroll_bpjs_payable','2203','Hutang BPJS','liability','kredit','neraca'),array('employee_loan_receivable','1201','Piutang Karyawan','asset','debit','neraca'))as$d){$a=$this->db->get_where('accounts',array('kode_akun'=>$d[1]))->row();if(!$a){$this->db->insert('accounts',array('kode_akun'=>$d[1],'nama_akun'=>$d[2],'tipe'=>$d[3],'posisi'=>$d[4],'kategori'=>$d[5],'is_active'=>1,'is_header'=>0,'allow_posting'=>1));$id=$this->db->insert_id();}else$id=$a->id;if(!$this->db->where('mapping_key',$d[0])->count_all_results('system_account_mappings'))$this->db->insert('system_account_mappings',array('mapping_key'=>$d[0],'account_id'=>$id,'description'=>$d[2],'is_active'=>1,'created_at'=>date('Y-m-d H:i:s')));}}
public function down(){throw new RuntimeException('Gunakan backup untuk rollback mapping Tahap 10.');}
}
@@ -0,0 +1,14 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Enforce_hr_payroll_locks extends CI_Migration
{
public function up(){
$this->db->query('DROP TRIGGER IF EXISTS trg_attendance_locked_insert');$this->db->query("CREATE TRIGGER trg_attendance_locked_insert BEFORE INSERT ON k_attendances FOR EACH ROW BEGIN IF EXISTS(SELECT 1 FROM attendance_period_locks WHERE period=DATE_FORMAT(NEW.attendance_date,'%Y-%m') AND status='locked' AND branch_id IS NULL) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Attendance period is locked'; END IF; END");
$this->db->query('DROP TRIGGER IF EXISTS trg_attendance_locked_update');$this->db->query("CREATE TRIGGER trg_attendance_locked_update BEFORE UPDATE ON k_attendances FOR EACH ROW BEGIN IF EXISTS(SELECT 1 FROM attendance_period_locks WHERE period=DATE_FORMAT(OLD.attendance_date,'%Y-%m') AND status='locked' AND branch_id IS NULL) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Attendance period is locked'; END IF; END");
$this->db->query('DROP TRIGGER IF EXISTS trg_attendance_locked_delete');$this->db->query("CREATE TRIGGER trg_attendance_locked_delete BEFORE DELETE ON k_attendances FOR EACH ROW BEGIN IF EXISTS(SELECT 1 FROM attendance_period_locks WHERE period=DATE_FORMAT(OLD.attendance_date,'%Y-%m') AND status='locked' AND branch_id IS NULL) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Attendance period is locked'; END IF; END");
$this->db->query('DROP TRIGGER IF EXISTS trg_final_payroll_item_insert');$this->db->query("CREATE TRIGGER trg_final_payroll_item_insert BEFORE INSERT ON payroll_calculation_lines FOR EACH ROW BEGIN IF EXISTS(SELECT 1 FROM k_payrolls x JOIN k_payroll_periods p ON p.id=x.payroll_period_id WHERE x.id=NEW.payroll_id AND p.workflow_status IN('final','posted','paid')) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Final payroll detail is locked'; END IF; END");
$this->db->query('DROP TRIGGER IF EXISTS trg_final_payroll_item_update');$this->db->query("CREATE TRIGGER trg_final_payroll_item_update BEFORE UPDATE ON payroll_calculation_lines FOR EACH ROW BEGIN IF EXISTS(SELECT 1 FROM k_payrolls x JOIN k_payroll_periods p ON p.id=x.payroll_period_id WHERE x.id=OLD.payroll_id AND p.workflow_status IN('final','posted','paid')) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Final payroll detail is locked'; END IF; END");
$this->db->query('DROP TRIGGER IF EXISTS trg_final_payroll_item_delete');$this->db->query("CREATE TRIGGER trg_final_payroll_item_delete BEFORE DELETE ON payroll_calculation_lines FOR EACH ROW BEGIN IF EXISTS(SELECT 1 FROM k_payrolls x JOIN k_payroll_periods p ON p.id=x.payroll_period_id WHERE x.id=OLD.payroll_id AND p.workflow_status IN('final','posted','paid')) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Final payroll detail is locked'; END IF; END");
}
public function down(){throw new RuntimeException('Gunakan backup untuk rollback kontrol Tahap 10.');}
}
@@ -0,0 +1,12 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Reconcile_legacy_payroll_and_tax extends CI_Migration
{
public function up(){
$this->db->query("DELETE h1 FROM employee_employment_history h1 JOIN employee_employment_history h2 ON h2.employee_id=h1.employee_id AND h2.effective_from=h1.effective_from AND h2.reason='Backfill data aktif Tahap 10' AND h1.reason='Backfill data aktif Tahap 10' AND h2.id<h1.id");
if(!$this->db->query("SHOW INDEX FROM employee_employment_history WHERE Key_name='uq_employee_history_date'")->result())$this->db->query('ALTER TABLE employee_employment_history ADD UNIQUE KEY uq_employee_history_date(employee_id,effective_from)');
$this->db->query("UPDATE k_payrolls SET gross_salary=basic_salary+allowance_amount+bonus_amount+overtime_amount,total_deductions=deduction_amount+bpjs_kesehatan_amount+bpjs_ketenagakerjaan_amount+tax_amount+attendance_cut_amount WHERE calculation_snapshot IS NULL");
$in=$this->db->where('mapping_key','purchase_input_tax')->get('system_account_mappings')->row();$out=$this->db->where('mapping_key','sales_tax_payable')->get('system_account_mappings')->row();if(!$this->db->where('code','PPN-KELUARAN')->count_all_results('tax_codes'))$this->db->insert('tax_codes',array('code'=>'PPN-KELUARAN','name'=>'PPN Keluaran','tax_type'=>'sales','calculation_method'=>'exclusive','rate'=>11,'output_account_id'=>$out?$out->account_id:null,'effective_from'=>date('Y-m-01'),'is_active'=>1));if(!$this->db->where('code','PPN-MASUKAN')->count_all_results('tax_codes'))$this->db->insert('tax_codes',array('code'=>'PPN-MASUKAN','name'=>'PPN Masukan','tax_type'=>'purchase','calculation_method'=>'exclusive','rate'=>11,'input_account_id'=>$in?$in->account_id:null,'effective_from'=>date('Y-m-01'),'is_active'=>1));
}
public function down(){throw new RuntimeException('Gunakan backup untuk rollback rekonsiliasi Tahap 10–11.');}
}
@@ -0,0 +1,23 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Create_reporting_budget_dimensions extends CI_Migration
{
private function col($t,$c,$sql){if(!$this->db->field_exists($c,$t))$this->db->query("ALTER TABLE `$t` ADD `$c` $sql");}
public function up(){
$tables=array(
'business_dimensions'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,dimension_type ENUM('branch','department','project','cost_center','profit_center','salesperson') NOT NULL,code VARCHAR(40) NOT NULL,name VARCHAR(150) NOT NULL,parent_id BIGINT UNSIGNED NULL,is_active TINYINT(1) NOT NULL DEFAULT 1,created_at DATETIME NOT NULL,UNIQUE KEY uq_business_dimension(dimension_type,code)",
'budgets'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,budget_no VARCHAR(60) NOT NULL,fiscal_year INT NOT NULL,name VARCHAR(150) NOT NULL,version_no INT NOT NULL DEFAULT 1,status ENUM('draft','submitted','approved','active','superseded','rejected') NOT NULL DEFAULT 'draft',control_mode ENUM('none','warning','blocking') NOT NULL DEFAULT 'warning',created_by INT NULL,approved_by INT NULL,approved_at DATETIME NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_budget_no(budget_no)",
'budget_lines'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,budget_id BIGINT UNSIGNED NOT NULL,period CHAR(7) NOT NULL,account_id INT NOT NULL,branch_id BIGINT UNSIGNED NULL,department_id BIGINT UNSIGNED NULL,project_id BIGINT UNSIGNED NULL,cost_center_id BIGINT UNSIGNED NULL,profit_center_id BIGINT UNSIGNED NULL,amount DECIMAL(18,2) NOT NULL,notes VARCHAR(255) NULL,UNIQUE KEY uq_budget_line(budget_id,period,account_id,branch_id,department_id,project_id,cost_center_id,profit_center_id)",
'budget_revisions'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,budget_id BIGINT UNSIGNED NOT NULL,revision_no INT NOT NULL,reason TEXT NOT NULL,status ENUM('draft','submitted','approved','rejected') NOT NULL DEFAULT 'draft',snapshot LONGTEXT NOT NULL,requested_by INT NULL,requested_at DATETIME NOT NULL,approved_by INT NULL,approved_at DATETIME NULL,UNIQUE KEY uq_budget_revision(budget_id,revision_no)",
'saved_report_filters'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,user_id INT NOT NULL,report_code VARCHAR(60) NOT NULL,name VARCHAR(120) NOT NULL,filter_json LONGTEXT NOT NULL,is_default TINYINT(1) NOT NULL DEFAULT 0,created_at DATETIME NOT NULL,UNIQUE KEY uq_saved_report_filter(user_id,report_code,name)",
'report_export_jobs'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,job_no VARCHAR(60) NOT NULL,report_code VARCHAR(60) NOT NULL,format ENUM('csv','pdf') NOT NULL,filter_json LONGTEXT NOT NULL,status ENUM('queued','processing','completed','failed') NOT NULL DEFAULT 'queued',file_name VARCHAR(255) NULL,error_message TEXT NULL,requested_by INT NOT NULL,requested_at DATETIME NOT NULL,started_at DATETIME NULL,completed_at DATETIME NULL,UNIQUE KEY uq_report_job(job_no)",
'report_snapshots'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,report_code VARCHAR(60) NOT NULL,period_end DATE NOT NULL,accounting_period_id BIGINT UNSIGNED NULL,filter_hash CHAR(64) NOT NULL,payload LONGTEXT NOT NULL,total_debit DECIMAL(18,2) NULL,total_credit DECIMAL(18,2) NULL,created_by INT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_report_snapshot(report_code,period_end,filter_hash)",
'closing_checklists'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,accounting_period_id BIGINT UNSIGNED NOT NULL,check_code VARCHAR(60) NOT NULL,check_name VARCHAR(150) NOT NULL,status ENUM('pending','passed','failed','waived') NOT NULL DEFAULT 'pending',result_notes TEXT NULL,checked_by INT NULL,checked_at DATETIME NULL,UNIQUE KEY uq_closing_check(accounting_period_id,check_code)"
);foreach($tables as$t=>$s)$this->db->query("CREATE TABLE IF NOT EXISTS `$t`($s) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
foreach(array('branch_id','department_id','project_id','cost_center_id','profit_center_id','salesperson_id')as$c)$this->col('journal_details',$c,'BIGINT UNSIGNED NULL');
$this->col('purchase_order_lines','project_id','BIGINT UNSIGNED NULL');$this->col('purchase_order_lines','cost_center_id','BIGINT UNSIGNED NULL');
$this->db->query("INSERT IGNORE INTO business_dimensions(dimension_type,code,name,is_active,created_at) SELECT 'branch',CONCAT('BR-',id),branch_name,1,NOW() FROM k_branches");$this->db->query("INSERT IGNORE INTO business_dimensions(dimension_type,code,name,is_active,created_at) SELECT 'department',CONCAT('DEP-',id),department_name,is_active,NOW() FROM k_departments");
$admin=$this->db->where('nama_role','Admin')->get('roles')->row();if(!$this->db->where('code','BUDGET_DEFAULT')->count_all_results('approval_workflows')){$this->db->insert('approval_workflows',array('code'=>'BUDGET_DEFAULT','module'=>'budget','entity_type'=>'budget','name'=>'Persetujuan Budget','require_separation'=>1,'is_active'=>1,'created_at'=>date('Y-m-d H:i:s')));if($admin)$this->db->insert('approval_workflow_steps',array('workflow_id'=>$this->db->insert_id(),'step_order'=>1,'name'=>'Persetujuan Budget Admin','approver_role_id'=>$admin->id));}
}
public function down(){throw new RuntimeException('Gunakan backup untuk rollback Tahap 12–13.');}
}
@@ -0,0 +1,21 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Create_multicompany_currency extends CI_Migration
{
private function col($t,$c,$sql){if($this->db->table_exists($t)&&!$this->db->field_exists($c,$t))$this->db->query("ALTER TABLE `$t` ADD `$c` $sql");}
public function up(){
$tables=array(
'companies'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_code VARCHAR(30) NOT NULL,name VARCHAR(150) NOT NULL,legal_name VARCHAR(200) NULL,tax_number VARCHAR(60) NULL,address TEXT NULL,base_currency CHAR(3) NOT NULL DEFAULT 'IDR',is_active TINYINT(1) NOT NULL DEFAULT 1,created_at DATETIME NOT NULL,UNIQUE KEY uq_company_code(company_code)",
'user_companies'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,user_id INT NOT NULL,company_id BIGINT UNSIGNED NOT NULL,is_default TINYINT(1) NOT NULL DEFAULT 0,can_consolidate TINYINT(1) NOT NULL DEFAULT 0,UNIQUE KEY uq_user_company(user_id,company_id)",
'currencies'=>"code CHAR(3) PRIMARY KEY,name VARCHAR(80) NOT NULL,symbol VARCHAR(10) NULL,decimal_places TINYINT NOT NULL DEFAULT 2,is_active TINYINT(1) NOT NULL DEFAULT 1",
'exchange_rates'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NOT NULL,rate_date DATE NOT NULL,from_currency CHAR(3) NOT NULL,to_currency CHAR(3) NOT NULL,rate DECIMAL(20,8) NOT NULL,source VARCHAR(80) NULL,created_by INT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_exchange_rate(company_id,rate_date,from_currency,to_currency)",
'fx_revaluations'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NOT NULL,revaluation_no VARCHAR(60) NOT NULL,revaluation_date DATE NOT NULL,status ENUM('draft','posted','reversed') NOT NULL DEFAULT 'draft',gain_amount DECIMAL(18,2) NOT NULL DEFAULT 0,loss_amount DECIMAL(18,2) NOT NULL DEFAULT 0,journal_id INT NULL,created_by INT NULL,posted_at DATETIME NULL,UNIQUE KEY uq_fx_revaluation(company_id,revaluation_no)",
'intercompany_transactions'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,document_no VARCHAR(60) NOT NULL,from_company_id BIGINT UNSIGNED NOT NULL,to_company_id BIGINT UNSIGNED NOT NULL,transaction_date DATE NOT NULL,amount DECIMAL(18,2) NOT NULL,currency CHAR(3) NOT NULL,exchange_rate DECIMAL(20,8) NOT NULL,status ENUM('draft','posted','eliminated','reversed') NOT NULL DEFAULT 'draft',from_journal_id INT NULL,to_journal_id INT NULL,elimination_journal_id INT NULL,created_by INT NULL,UNIQUE KEY uq_intercompany_doc(document_no)"
);foreach($tables as$t=>$s)$this->db->query("CREATE TABLE IF NOT EXISTS `$t`($s) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
if(!$this->db->count_all('companies'))$this->db->insert('companies',array('company_code'=>'MAIN','name'=>'Perusahaan Utama','base_currency'=>'IDR','is_active'=>1,'created_at'=>date('Y-m-d H:i:s')));$company=$this->db->order_by('id')->get('companies')->row();$this->db->query("INSERT IGNORE INTO currencies(code,name,symbol,decimal_places,is_active)VALUES('IDR','Rupiah','Rp',2,1),('USD','US Dollar','$',2,1),('SGD','Singapore Dollar','S$',2,1),('EUR','Euro','EUR',2,1)");$this->db->query("INSERT IGNORE INTO user_companies(user_id,company_id,is_default,can_consolidate) SELECT id,".(int)$company->id.",1,IF(role_id=(SELECT id FROM roles WHERE nama_role='Admin' LIMIT 1),1,0) FROM users");
$critical=array('accounts','journals','journal_details','accounting_periods','fiscal_years','document_sequences','system_account_mappings','customers','invoices','payments','suppliers','purchase_orders','supplier_invoices','supplier_payments','items','warehouses','assets','cash_accounts','cash_transactions','k_employees','k_payroll_periods','k_payrolls','tax_codes','tax_transactions','budgets','business_dimensions');foreach($critical as$t)$this->col($t,'company_id','BIGINT UNSIGNED NULL');foreach($critical as$t)if($this->db->table_exists($t)&&$this->db->field_exists('company_id',$t))$this->db->where('company_id IS NULL',null,false)->update($t,array('company_id'=>$company->id));
foreach(array('journals','invoices','supplier_invoices','cash_transactions')as$t){$this->col($t,'transaction_currency',"CHAR(3) NOT NULL DEFAULT 'IDR'");$this->col($t,'exchange_rate','DECIMAL(20,8) NOT NULL DEFAULT 1');$this->col($t,'base_amount','DECIMAL(18,2) NULL');}
foreach(array('journal_details','payment_allocations','supplier_payment_allocations')as$t){$this->col($t,'currency_amount','DECIMAL(18,2) NULL');$this->col($t,'currency_code',"CHAR(3) NULL");$this->col($t,'exchange_rate','DECIMAL(20,8) NULL');}
}
public function down(){throw new RuntimeException('Gunakan backup untuk rollback Tahap 14.');}
}
@@ -0,0 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Seed_fx_accounts extends CI_Migration
{
public function up(){foreach(array(array('realized_fx_gain','4193','Laba Selisih Kurs Terealisasi','revenue'),array('realized_fx_loss','6194','Rugi Selisih Kurs Terealisasi','expense'),array('unrealized_fx_gain','4194','Laba Selisih Kurs Belum Terealisasi','revenue'),array('unrealized_fx_loss','6195','Rugi Selisih Kurs Belum Terealisasi','expense'))as$d){$a=$this->db->get_where('accounts',array('kode_akun'=>$d[1]))->row();if(!$a){$this->db->insert('accounts',array('kode_akun'=>$d[1],'nama_akun'=>$d[2],'tipe'=>$d[3],'posisi'=>$d[3]==='expense'?'debit':'kredit','kategori'=>'laba_rugi','is_active'=>1,'is_header'=>0,'allow_posting'=>1,'company_id'=>1));$id=$this->db->insert_id();}else$id=$a->id;if(!$this->db->where('mapping_key',$d[0])->count_all_results('system_account_mappings'))$this->db->insert('system_account_mappings',array('mapping_key'=>$d[0],'account_id'=>$id,'description'=>$d[2],'is_active'=>1,'created_at'=>date('Y-m-d H:i:s'),'company_id'=>1));}}
public function down(){throw new RuntimeException('Gunakan backup untuk rollback FX mapping.');}
}
@@ -0,0 +1,8 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Complete_tenant_reporting_scope extends CI_Migration
{
private function col($t,$c,$sql){if($this->db->table_exists($t)&&!$this->db->field_exists($c,$t))$this->db->query("ALTER TABLE `$t` ADD `$c` $sql");}
public function up(){$company=$this->db->order_by('id')->get('companies')->row();foreach(array('budget_lines','budget_revisions','saved_report_filters','report_export_jobs','report_snapshots','closing_checklists','exchange_rates','fx_revaluations','intercompany_transactions','user_companies','currencies')as$t){if($t!=='currencies'){$this->col($t,'company_id','BIGINT UNSIGNED NULL');if($this->db->field_exists('company_id',$t))$this->db->where('company_id IS NULL',null,false)->update($t,array('company_id'=>$company->id));}}$this->col('invoices','foreign_total','DECIMAL(18,2) NULL');$this->col('invoices','foreign_balance','DECIMAL(18,2) NULL');$this->col('supplier_invoices','foreign_total','DECIMAL(18,2) NULL');$this->col('supplier_invoices','foreign_balance','DECIMAL(18,2) NULL');}
public function down(){throw new RuntimeException('Gunakan backup untuk rollback tenant scope.');}
}
@@ -0,0 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Add_po_budget_account extends CI_Migration
{
public function up(){if(!$this->db->field_exists('budget_account_id','purchase_order_lines'))$this->db->query('ALTER TABLE purchase_order_lines ADD budget_account_id INT NULL');}
public function down(){throw new RuntimeException('Gunakan backup untuk rollback budget PO.');}
}
@@ -0,0 +1,49 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Repair_business_dimension_company_scope extends CI_Migration
{
public function up()
{
// Gunakan SHOW TABLES agar tidak terkena cache table_exists dari migration
// sebelumnya yang membuat tabel melalui raw CREATE TABLE pada request sama.
if (!$this->db->query("SHOW TABLES LIKE 'business_dimensions'")->row()) {
throw new RuntimeException('Tabel business_dimensions belum tersedia. Jalankan migration Tahap 13 terlebih dahulu.');
}
$company = $this->db->order_by('id', 'ASC')->get('companies')->row();
if (!$company) {
throw new RuntimeException('Master company belum tersedia. Jalankan migration Tahap 14 terlebih dahulu.');
}
if (!$this->db->field_exists('company_id', 'business_dimensions')) {
$this->db->query('ALTER TABLE `business_dimensions` ADD `company_id` BIGINT UNSIGNED NULL AFTER `id`');
}
$this->db->where('company_id IS NULL', null, false)
->update('business_dimensions', array('company_id' => (int) $company->id));
$indexes = $this->db->query('SHOW INDEX FROM `business_dimensions`')->result();
$indexNames = array();
foreach ($indexes as $index) {
$indexNames[$index->Key_name] = true;
}
if (isset($indexNames['uq_business_dimension'])) {
$this->db->query('ALTER TABLE `business_dimensions` DROP INDEX `uq_business_dimension`');
}
if (!isset($indexNames['uq_business_dimension_company'])) {
$this->db->query('ALTER TABLE `business_dimensions` ADD UNIQUE KEY `uq_business_dimension_company` (`company_id`,`dimension_type`,`code`)');
}
if (!isset($indexNames['idx_business_dimension_company_active'])) {
$this->db->query('ALTER TABLE `business_dimensions` ADD KEY `idx_business_dimension_company_active` (`company_id`,`is_active`)');
}
$this->db->query('ALTER TABLE `business_dimensions` MODIFY `company_id` BIGINT UNSIGNED NOT NULL');
}
public function down()
{
throw new RuntimeException('Gunakan backup database untuk rollback tenant scope.');
}
}
@@ -0,0 +1,40 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Repair_all_tenant_scope_columns extends CI_Migration
{
public function up()
{
$company = $this->db->order_by('id', 'ASC')->get('companies')->row();
if (!$company) throw new RuntimeException('Master company belum tersedia.');
$tables = array(
'accounts','journals','journal_details','accounting_periods','fiscal_years',
'document_sequences','system_account_mappings','customers','invoices','payments',
'suppliers','purchase_orders','supplier_invoices','supplier_payments','items',
'warehouses','assets','cash_accounts','cash_transactions','k_employees',
'k_payroll_periods','k_payrolls','tax_codes','tax_transactions','business_dimensions',
'budgets','budget_lines','budget_revisions','saved_report_filters',
'report_export_jobs','report_snapshots','closing_checklists','exchange_rates',
'fx_revaluations','intercompany_transactions','user_companies'
);
foreach ($tables as $table) {
if (!$this->db->table_exists($table)) continue;
if (!$this->db->field_exists('company_id', $table)) {
$this->db->query("ALTER TABLE `{$table}` ADD `company_id` BIGINT UNSIGNED NULL");
}
$this->db->where('company_id IS NULL', null, false)
->update($table, array('company_id' => (int) $company->id));
$index = 'idx_' . substr($table, 0, 40) . '_company';
$existing = $this->db->query("SHOW INDEX FROM `{$table}` WHERE Key_name=" . $this->db->escape($index))->row();
if (!$existing) $this->db->query("ALTER TABLE `{$table}` ADD KEY `{$index}` (`company_id`)");
}
}
public function down()
{
throw new RuntimeException('Gunakan backup database untuk rollback tenant scope.');
}
}
@@ -0,0 +1,29 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Create_professional_roles_permissions extends CI_Migration
{
private function rights($map){$out=array();foreach($map as$key=>$level){$actions=array('can_view');if($level==='write')$actions=array('can_view','can_create','can_update','can_export','can_import');if($level==='approve')$actions=array('can_view','can_create','can_update','can_approve','can_post','can_export','can_import');$out[$key]=$actions;}return json_encode($out);}
public function up(){
if(!$this->db->field_exists('permissions','roles'))$this->db->query("ALTER TABLE roles ADD permissions LONGTEXT NULL AFTER nama_role");
if(!$this->db->field_exists('description','roles'))$this->db->query("ALTER TABLE roles ADD description VARCHAR(255) NULL AFTER permissions");
if(!$this->db->field_exists('is_active','roles'))$this->db->query("ALTER TABLE roles ADD is_active TINYINT(1) NOT NULL DEFAULT 1 AFTER description");
$roles=array(
'Admin'=>array('Semua konfigurasi, transaksi dan administrasi pengguna.',array()),
'Accounting Manager'=>array('Kontrol accounting, approval, closing, laporan, budget dan pajak.',array('dashboard'=>'write','approvals'=>'approve','invoices'=>'approve','purchases'=>'approve','items'=>'view','asset'=>'approve','cash_bank'=>'approve','jurnal'=>'approve','reports'=>'write','budgets'=>'approve','taxes'=>'approve')),
'Finance & Treasury'=>array('Kas, bank, pembayaran, penerimaan dan posisi kas.',array('dashboard'=>'view','approvals'=>'approve','invoices'=>'write','purchases'=>'write','cash_bank'=>'approve','jurnal'=>'write','reports'=>'view')),
'AR / Sales Billing'=>array('Customer, penjualan, invoice, piutang dan penerimaan.',array('dashboard'=>'view','invoices'=>'approve','cash_bank'=>'view','jurnal'=>'view','reports'=>'view')),
'AP / Purchasing'=>array('Supplier, purchase workflow, hutang dan pembayaran supplier.',array('dashboard'=>'view','purchases'=>'approve','items'=>'view','cash_bank'=>'view','jurnal'=>'view','reports'=>'view')),
'Inventory & Warehouse'=>array('Persediaan, gudang, barcode, mutasi dan stock opname.',array('dashboard'=>'view','items'=>'approve','purchases'=>'view','asset'=>'view','reports'=>'view')),
'Asset Officer'=>array('Register aset, penyusutan, maintenance dan asset opname.',array('dashboard'=>'view','asset'=>'approve','items'=>'view','purchases'=>'view','jurnal'=>'view','reports'=>'view')),
'HR & Payroll'=>array('Data karyawan, absensi dan payroll.',array('dashboard'=>'view','employees'=>'approve','cash_bank'=>'view','jurnal'=>'view','reports'=>'view')),
'Tax Officer'=>array('Master pajak, transaksi pajak dan pelaporan pajak.',array('dashboard'=>'view','taxes'=>'approve','invoices'=>'view','purchases'=>'view','jurnal'=>'view','reports'=>'view')),
'Auditor / Read Only'=>array('Akses baca dan export tanpa mengubah transaksi.',array('dashboard'=>'view','approvals'=>'view','invoices'=>'view','purchases'=>'view','items'=>'view','asset'=>'view','cash_bank'=>'view','jurnal'=>'view','reports'=>'view','budgets'=>'view','taxes'=>'view','employees'=>'view')),
'Management Viewer'=>array('Dashboard, approval dan laporan manajemen.',array('dashboard'=>'view','approvals'=>'approve','reports'=>'view','budgets'=>'view')),
'Keuangan'=>array('Role legacy keuangan; gunakan Finance & Treasury untuk user baru.',array('dashboard'=>'view','invoices'=>'write','purchases'=>'write','cash_bank'=>'approve','jurnal'=>'write','reports'=>'view')),
'Gudang'=>array('Role legacy gudang; gunakan Inventory & Warehouse untuk user baru.',array('dashboard'=>'view','items'=>'approve','purchases'=>'view','asset'=>'view','reports'=>'view'))
);
foreach($roles as$name=>$definition){$data=array('permissions'=>$this->rights($definition[1]),'description'=>$definition[0],'is_active'=>1);$row=$this->db->get_where('roles',array('nama_role'=>$name))->row();if($row)$this->db->where('id',$row->id)->update('roles',$data);else{$data['nama_role']=$name;$this->db->insert('roles',$data);}}
$this->db->query("CREATE OR REPLACE SQL SECURITY INVOKER VIEW v_users AS SELECT u.id,u.username,u.password,u.role_id,u.created_at,u.nama,r.nama_role,COALESCE(r.permissions,'{}') permissions,r.is_active role_is_active FROM users u LEFT JOIN roles r ON r.id=u.role_id");
}
public function down(){throw new RuntimeException('Gunakan backup untuk rollback role dan permission.');}
}
@@ -0,0 +1,35 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Create_scalability_foundation extends CI_Migration
{
private function index($table,$name,$columns){if(!$this->db->table_exists($table))return;foreach($columns as$c)if(!$this->db->field_exists($c,$table))return;if(!$this->db->query("SHOW INDEX FROM `$table` WHERE Key_name=".$this->db->escape($name))->row())$this->db->query("ALTER TABLE `$table` ADD INDEX `$name` (`".implode('`,`',$columns)."`)");}
public function up(){
$tables=array(
'ci_sessions'=>"id VARCHAR(128) NOT NULL,ip_address VARCHAR(45) NOT NULL,timestamp INT UNSIGNED NOT NULL DEFAULT 0,data BLOB NOT NULL,PRIMARY KEY(id),KEY idx_ci_sessions_timestamp(timestamp)",
'dashboard_summaries'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NOT NULL,period CHAR(7) NOT NULL,total_revenue DECIMAL(18,2) NOT NULL DEFAULT 0,total_expense DECIMAL(18,2) NOT NULL DEFAULT 0,net_profit DECIMAL(18,2) NOT NULL DEFAULT 0,journal_count BIGINT UNSIGNED NOT NULL DEFAULT 0,computed_at DATETIME NOT NULL,expires_at DATETIME NOT NULL,UNIQUE KEY uq_dashboard_summary(company_id,period),KEY idx_dashboard_expiry(expires_at)",
'app_jobs'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NULL,queue_name VARCHAR(40) NOT NULL DEFAULT 'default',job_type VARCHAR(60) NOT NULL,payload LONGTEXT NOT NULL,status ENUM('queued','processing','completed','retry','dead') NOT NULL DEFAULT 'queued',priority TINYINT NOT NULL DEFAULT 5,attempts INT NOT NULL DEFAULT 0,max_attempts INT NOT NULL DEFAULT 3,available_at DATETIME NOT NULL,locked_at DATETIME NULL,locked_by VARCHAR(100) NULL,last_error TEXT NULL,idempotency_key VARCHAR(140) NOT NULL,created_at DATETIME NOT NULL,completed_at DATETIME NULL,UNIQUE KEY uq_app_job_key(idempotency_key),KEY idx_app_job_claim(status,queue_name,available_at,priority,id)",
'app_dead_letters'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,job_id BIGINT UNSIGNED NOT NULL,company_id BIGINT UNSIGNED NULL,job_type VARCHAR(60) NOT NULL,payload LONGTEXT NOT NULL,attempts INT NOT NULL,last_error TEXT NULL,failed_at DATETIME NOT NULL,KEY idx_dead_letter_date(failed_at),KEY idx_dead_letter_type(job_type)",
'database_health_metrics'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,checked_at DATETIME NOT NULL,connection_ms DECIMAL(12,3) NOT NULL,query_ms DECIMAL(12,3) NOT NULL,threads_connected INT NULL,threads_running INT NULL,slow_queries BIGINT NULL,db_size_mb DECIMAL(14,2) NULL,status ENUM('healthy','warning','critical') NOT NULL,message VARCHAR(255) NULL,KEY idx_db_health_date(checked_at,status)"
);foreach($tables as$t=>$sql)$this->db->query("CREATE TABLE IF NOT EXISTS `$t`($sql) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$indexes=array(
array('journals','idx_journal_company_status_date',array('company_id','status','tanggal','id')),
array('journal_details','idx_jd_company_account_journal',array('company_id','account_id','journal_id')),
array('invoices','idx_invoice_company_workflow_due',array('company_id','workflow_status','jatuh_tempo','customer_id')),
array('payments','idx_payment_company_customer_date',array('company_id','customer_id','tanggal')),
array('supplier_invoices','idx_bill_company_status_due',array('company_id','status','due_date','supplier_id')),
array('supplier_payments','idx_spay_company_supplier_date',array('company_id','supplier_id','payment_date')),
array('inventory_ledger','idx_ledger_item_wh_date',array('item_id','warehouse_id','movement_date','id')),
array('stock_documents','idx_stock_doc_status_date',array('status','document_date','item_id')),
array('items','idx_items_company_active_name',array('company_id','is_active','nama_barang')),
array('customers','idx_customer_company_name',array('company_id','nama')),
array('suppliers','idx_supplier_company_active_name',array('company_id','is_active','name')),
array('accounts','idx_account_company_active_code',array('company_id','is_active','kode_akun')),
array('k_employees','idx_employee_company_active_name',array('company_id','is_active','nama')),
array('activity_logs','idx_activity_created_user',array('created_at','user_id')),
array('approval_requests','idx_approval_status_created',array('status','created_at')),
array('report_export_jobs','idx_report_job_claim',array('status','requested_at','id'))
);foreach($indexes as$i)$this->index($i[0],$i[1],$i[2]);
foreach(array('report_export_jobs')as$t){if($this->db->table_exists($t)){if(!$this->db->field_exists('attempts',$t))$this->db->query("ALTER TABLE `$t` ADD attempts INT NOT NULL DEFAULT 0");if(!$this->db->field_exists('max_attempts',$t))$this->db->query("ALTER TABLE `$t` ADD max_attempts INT NOT NULL DEFAULT 3");if(!$this->db->field_exists('available_at',$t))$this->db->query("ALTER TABLE `$t` ADD available_at DATETIME NULL");}}
}
public function down(){throw new RuntimeException('Gunakan backup untuk rollback fondasi skalabilitas.');}
}
@@ -0,0 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Create_application_cache extends CI_Migration
{
public function up(){$this->db->query("CREATE TABLE IF NOT EXISTS app_cache(cache_key VARCHAR(190) PRIMARY KEY,payload LONGTEXT NOT NULL,expires_at DATETIME NOT NULL,created_at DATETIME NOT NULL,KEY idx_app_cache_expiry(expires_at)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");}
public function down(){throw new RuntimeException('Gunakan backup untuk rollback cache aplikasi.');}
}
@@ -0,0 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Create_activity_log_archive extends CI_Migration
{
public function up(){if($this->db->table_exists('activity_logs')&&!$this->db->table_exists('activity_logs_archive')){$this->db->query('CREATE TABLE activity_logs_archive LIKE activity_logs');$this->db->query('ALTER TABLE activity_logs_archive ADD archived_at DATETIME NULL, ADD KEY idx_activity_archive_date(archived_at)');}}
public function down(){throw new RuntimeException('Arsip log tidak dihapus otomatis.');}
}
@@ -0,0 +1,19 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Enhance_role_management extends CI_Migration
{
public function up()
{
if (!$this->db->field_exists('is_system', 'roles')) $this->db->query("ALTER TABLE roles ADD is_system TINYINT(1) NOT NULL DEFAULT 0 AFTER is_active");
if (!$this->db->field_exists('is_super_admin', 'roles')) $this->db->query("ALTER TABLE roles ADD is_super_admin TINYINT(1) NOT NULL DEFAULT 0 AFTER is_system");
if (!$this->db->field_exists('created_at', 'roles')) $this->db->query("ALTER TABLE roles ADD created_at DATETIME NULL AFTER is_super_admin");
if (!$this->db->field_exists('updated_at', 'roles')) $this->db->query("ALTER TABLE roles ADD updated_at DATETIME NULL AFTER created_at");
$this->db->set('is_system', 1)->update('roles');
$this->db->where('nama_role', 'Admin')->update('roles', array(
'is_super_admin' => 1,
'description' => 'Master Admin: akses penuh seluruh modul, transaksi, posting, approval, konfigurasi, pengguna dan role.',
'is_active' => 1
));
}
public function down() { throw new RuntimeException('Gunakan backup untuk rollback manajemen role.'); }
}
@@ -0,0 +1,20 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Unify_purchase_item_asset_flow extends CI_Migration
{
public function up(){
if(!$this->db->field_exists('destination_type','purchase_requests'))$this->db->query("ALTER TABLE purchase_requests ADD destination_type ENUM('warehouse','asset') NOT NULL DEFAULT 'warehouse' AFTER purpose");
foreach(array('destination_type'=>"ENUM('warehouse','asset') NOT NULL DEFAULT 'warehouse'",'asset_account_id'=>'INT NULL','asset_location_id'=>'INT NULL','useful_life_months'=>'INT NULL','residual_value'=>'DECIMAL(18,2) NOT NULL DEFAULT 0')as$c=>$s)if(!$this->db->field_exists($c,'purchase_order_lines'))$this->db->query("ALTER TABLE purchase_order_lines ADD `$c` $s");
$this->db->query('ALTER TABLE purchase_order_lines MODIFY warehouse_id INT NULL');
$this->db->query('ALTER TABLE purchase_order_lines MODIFY item_id INT NULL');
if(!$this->db->field_exists('asset_id','goods_receipt_lines'))$this->db->query('ALTER TABLE goods_receipt_lines ADD asset_id INT NULL AFTER stock_log_id');
$this->db->query('ALTER TABLE goods_receipt_lines MODIFY item_id INT NULL, MODIFY warehouse_id INT NULL');
$this->db->query('ALTER TABLE supplier_invoice_lines MODIFY item_id INT NULL');
if(!$this->db->field_exists('planned_qty','items'))$this->db->query('ALTER TABLE items ADD planned_qty DECIMAL(18,4) NOT NULL DEFAULT 0 AFTER stok');
if(!$this->db->field_exists('planned_warehouse_id','items'))$this->db->query('ALTER TABLE items ADD planned_warehouse_id INT NULL AFTER planned_qty');
if(!$this->db->field_exists('planned_tracking_qty','items'))$this->db->query('ALTER TABLE items ADD planned_tracking_qty DECIMAL(18,4) NULL AFTER planned_warehouse_id');
// Data lama yang sudah pernah menghasilkan stok bukan lagi barang pending.
$this->db->query("UPDATE items SET status='active' WHERE status='draft' AND stok > 0");
}
public function down(){throw new RuntimeException('Gunakan backup untuk rollback penyatuan workflow pembelian.');}
}
@@ -0,0 +1,13 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Align_purchase_catalog_and_payment extends CI_Migration
{
public function up(){
if(!$this->db->field_exists('kode_barang_id','purchase_request_lines'))$this->db->query('ALTER TABLE purchase_request_lines ADD kode_barang_id INT NULL AFTER item_id');
if(!$this->db->field_exists('kode_barang_id','purchase_order_lines'))$this->db->query('ALTER TABLE purchase_order_lines ADD kode_barang_id INT NULL AFTER item_id');
if(!$this->db->field_exists('payment_plan','purchase_orders'))$this->db->query("ALTER TABLE purchase_orders ADD payment_plan ENUM('cash','installment','credit') NOT NULL DEFAULT 'credit' AFTER expected_date");
$this->db->query('UPDATE purchase_request_lines prl JOIN items i ON i.id=prl.item_id SET prl.kode_barang_id=i.kode_id WHERE prl.kode_barang_id IS NULL');
$this->db->query('UPDATE purchase_order_lines pol JOIN items i ON i.id=pol.item_id SET pol.kode_barang_id=i.kode_id WHERE pol.kode_barang_id IS NULL');
}
public function down(){throw new RuntimeException('Gunakan backup untuk rollback penyelarasan katalog pembelian.');}
}
@@ -0,0 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Complete_department_position_master extends CI_Migration
{
public function up(){foreach(array('k_departments','k_positions')as$t){if(!$this->db->field_exists('code',$t))$this->db->query("ALTER TABLE `$t` ADD code VARCHAR(30) NULL AFTER id");if(!$this->db->field_exists('description',$t))$this->db->query("ALTER TABLE `$t` ADD description VARCHAR(255) NULL");if(!$this->db->field_exists('is_active',$t))$this->db->query("ALTER TABLE `$t` ADD is_active TINYINT(1) NOT NULL DEFAULT 1");}if(!$this->db->field_exists('department_id','k_positions'))$this->db->query('ALTER TABLE k_positions ADD department_id INT NULL AFTER position_name');if(!$this->db->field_exists('department_id','purchase_requests'))$this->db->query('ALTER TABLE purchase_requests ADD department_id INT NULL AFTER department');$this->db->query("UPDATE k_departments SET code=CONCAT('DEP-',LPAD(id,3,'0')) WHERE code IS NULL OR code=''");$this->db->query("UPDATE k_positions SET code=CONCAT('POS-',LPAD(id,3,'0')) WHERE code IS NULL OR code=''");$this->db->query("UPDATE purchase_requests pr JOIN k_departments d ON CONVERT(LOWER(TRIM(d.department_name)) USING utf8mb4) COLLATE utf8mb4_general_ci=CONVERT(LOWER(TRIM(pr.department)) USING utf8mb4) COLLATE utf8mb4_general_ci SET pr.department_id=d.id WHERE pr.department_id IS NULL");}
public function down(){throw new RuntimeException('Gunakan backup untuk rollback master organisasi.');}
}
@@ -0,0 +1,14 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Complete_purchase_tracking_settlement extends CI_Migration
{
public function up(){
foreach(array('purchase_request_lines','purchase_order_lines')as$t){if(!$this->db->field_exists('tracking_type',$t))$this->db->query("ALTER TABLE `$t` ADD tracking_type ENUM('UNIT','QTY') NULL");if(!$this->db->field_exists('qty_per_barcode',$t))$this->db->query("ALTER TABLE `$t` ADD qty_per_barcode DECIMAL(18,4) NULL");if(!$this->db->field_exists('need_serial_number',$t))$this->db->query("ALTER TABLE `$t` ADD need_serial_number TINYINT(1) NOT NULL DEFAULT 0");}
if(!$this->db->field_exists('payment_plan','supplier_invoices'))$this->db->query("ALTER TABLE supplier_invoices ADD payment_plan ENUM('cash','installment','credit') NOT NULL DEFAULT 'credit' AFTER due_date");
if(!$this->db->field_exists('source_receipt_line_id','item_barcodes'))$this->db->query('ALTER TABLE item_barcodes ADD source_receipt_line_id BIGINT UNSIGNED NULL');
if(!$this->db->field_exists('barcode_id','purchase_return_lines'))$this->db->query('ALTER TABLE purchase_return_lines ADD barcode_id INT NULL AFTER item_id');
$this->db->query("UPDATE purchase_request_lines l JOIN kode_barang k ON k.id=l.kode_barang_id SET l.tracking_type=k.tracking_type,l.need_serial_number=IF(k.need_serial_number='true',1,0),l.qty_per_barcode=IF(k.tracking_type='UNIT',1,NULL) WHERE l.tracking_type IS NULL");
$this->db->query("UPDATE purchase_order_lines l JOIN kode_barang k ON k.id=l.kode_barang_id SET l.tracking_type=k.tracking_type,l.need_serial_number=IF(k.need_serial_number='true',1,0),l.qty_per_barcode=IF(k.tracking_type='UNIT',1,NULL) WHERE l.tracking_type IS NULL");
}
public function down(){throw new RuntimeException('Gunakan backup untuk rollback workflow tracking pembelian.');}
}
@@ -0,0 +1,10 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Reconcile_purchase_stock_ledger extends CI_Migration
{
public function up(){
$this->db->query("INSERT IGNORE INTO inventory_ledger(item_id,warehouse_id,barcode_id,movement_date,direction,qty,unit_cost,value,document_type,document_id,idempotency_key,created_at) SELECT s.item_id,s.warehouse_id,s.barcode_id,DATE(s.created_at),IF(s.tipe='masuk','in','out'),s.qty,COALESCE(NULLIF(s.unit_cost,0),i.harga_beli),s.qty*COALESCE(NULLIF(s.unit_cost,0),i.harga_beli),s.ref_type,s.ref_id,CONCAT('PURCHASE-BACKFILL-STOCK-',s.id),s.created_at FROM stock_logs s JOIN items i ON i.id=s.item_id LEFT JOIN inventory_ledger l ON BINARY l.document_type=BINARY s.ref_type AND l.document_id=s.ref_id AND l.item_id=s.item_id AND l.warehouse_id=s.warehouse_id AND l.qty=s.qty AND l.direction=IF(s.tipe='masuk','in','out') WHERE s.ref_type IN('goods_receipt','purchase_return') AND l.id IS NULL");
$this->db->query("UPDATE items i SET i.stok=COALESCE((SELECT SUM(IF(l.direction='in',l.qty,-l.qty)) FROM inventory_ledger l WHERE l.item_id=i.id),0)");
}
public function down(){throw new RuntimeException('Rekonsiliasi ledger tidak boleh dihapus tanpa audit.');}
}
@@ -0,0 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Force_pr_to_warehouse extends CI_Migration
{
public function up(){$this->db->where_in('status',array('draft','submitted','approved'))->update('purchase_requests',array('destination_type'=>'warehouse'));}
public function down(){throw new RuntimeException('Klasifikasi PR lama tidak dapat dipulihkan otomatis.');}
}
@@ -0,0 +1,25 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Add_pr_applicant extends CI_Migration
{
public function up()
{
if (!$this->db->field_exists('applicant_is_creator', 'purchase_requests')) {
$this->db->query("ALTER TABLE purchase_requests ADD applicant_is_creator TINYINT(1) NOT NULL DEFAULT 1 AFTER requested_by");
}
if (!$this->db->field_exists('applicant_name', 'purchase_requests')) {
$this->db->query("ALTER TABLE purchase_requests ADD applicant_name VARCHAR(150) NULL AFTER applicant_is_creator");
}
}
public function down()
{
if ($this->db->field_exists('applicant_name', 'purchase_requests')) {
$this->db->query('ALTER TABLE purchase_requests DROP COLUMN applicant_name');
}
if ($this->db->field_exists('applicant_is_creator', 'purchase_requests')) {
$this->db->query('ALTER TABLE purchase_requests DROP COLUMN applicant_is_creator');
}
}
}
@@ -0,0 +1,24 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Professional_purchase_workflow extends CI_Migration
{
public function up(){
$columns=array(
'procurement_method'=>"ENUM('online','direct','supplier') NOT NULL DEFAULT 'supplier' AFTER expected_date",
'payment_timing'=>"ENUM('prepaid','on_receipt','installment','credit') NOT NULL DEFAULT 'credit' AFTER procurement_method",
'marketplace'=>"VARCHAR(80) NULL AFTER payment_timing",'external_order_no'=>"VARCHAR(120) NULL AFTER marketplace",'courier'=>"VARCHAR(80) NULL AFTER external_order_no",'tracking_no'=>"VARCHAR(120) NULL AFTER courier",
'shipping_cost'=>"DECIMAL(18,2) NOT NULL DEFAULT 0 AFTER total",'insurance_cost'=>"DECIMAL(18,2) NOT NULL DEFAULT 0 AFTER shipping_cost",'service_fee'=>"DECIMAL(18,2) NOT NULL DEFAULT 0 AFTER insurance_cost",'discount_amount'=>"DECIMAL(18,2) NOT NULL DEFAULT 0 AFTER service_fee",'cashback_amount'=>"DECIMAL(18,2) NOT NULL DEFAULT 0 AFTER discount_amount",
'payment_status'=>"ENUM('unpaid','partial','paid','refunded') NOT NULL DEFAULT 'unpaid' AFTER cashback_amount",'paid_amount'=>"DECIMAL(18,2) NOT NULL DEFAULT 0 AFTER payment_status",'transit_cleared_amount'=>"DECIMAL(18,2) NOT NULL DEFAULT 0 AFTER paid_amount"
);foreach($columns as$n=>$definition)if(!$this->db->field_exists($n,'purchase_orders'))$this->db->query("ALTER TABLE purchase_orders ADD `$n` $definition");
if(!$this->db->field_exists('purchase_order_id','supplier_payments'))$this->db->query('ALTER TABLE supplier_payments ADD purchase_order_id BIGINT UNSIGNED NULL AFTER supplier_id');
if(!$this->db->field_exists('payment_kind','supplier_payments'))$this->db->query("ALTER TABLE supplier_payments ADD payment_kind ENUM('invoice','prepayment','refund') NOT NULL DEFAULT 'invoice' AFTER purchase_order_id");
if(!$this->db->field_exists('advance_journal_id','goods_receipt_lines'))$this->db->query('ALTER TABLE goods_receipt_lines ADD advance_journal_id INT NULL AFTER stock_log_id');
$this->db->query("CREATE TABLE IF NOT EXISTS purchase_payment_sources(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,supplier_payment_id BIGINT UNSIGNED NOT NULL,account_id INT NOT NULL,amount DECIMAL(18,2) NOT NULL,created_at DATETIME NOT NULL,KEY idx_purchase_payment_source(supplier_payment_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$this->db->query("CREATE TABLE IF NOT EXISTS purchase_installment_schedules(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,purchase_order_id BIGINT UNSIGNED NOT NULL,installment_no INT NOT NULL,due_date DATE NOT NULL,amount DECIMAL(18,2) NOT NULL,paid_amount DECIMAL(18,2) NOT NULL DEFAULT 0,status ENUM('unpaid','partial','paid','cancelled') NOT NULL DEFAULT 'unpaid',created_at DATETIME NOT NULL,UNIQUE KEY uq_po_installment(purchase_order_id,installment_no),KEY idx_installment_due(status,due_date)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$this->db->query("CREATE TABLE IF NOT EXISTS purchase_refunds(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,refund_no VARCHAR(60) NOT NULL,purchase_order_id BIGINT UNSIGNED NOT NULL,supplier_payment_id BIGINT UNSIGNED NULL,refund_date DATE NOT NULL,account_id INT NOT NULL,amount DECIMAL(18,2) NOT NULL,reason TEXT NOT NULL,status ENUM('posted','reversed') NOT NULL DEFAULT 'posted',journal_id INT NOT NULL,created_by INT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_purchase_refund_no(refund_no)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$account=$this->db->get_where('accounts',array('kode_akun'=>'1193'))->row();if(!$account){$this->db->insert('accounts',array('kode_akun'=>'1193','nama_akun'=>'Barang Dalam Perjalanan','tipe'=>'asset','posisi'=>'debit','kategori'=>'neraca','is_active'=>1,'is_header'=>0,'allow_posting'=>1));$accountId=$this->db->insert_id();}else$accountId=$account->id;
if(!$this->db->where('mapping_key','goods_in_transit')->count_all_results('system_account_mappings'))$this->db->insert('system_account_mappings',array('mapping_key'=>'goods_in_transit','account_id'=>$accountId,'description'=>'Pembayaran barang sebelum diterima gudang','is_active'=>1,'created_at'=>date('Y-m-d H:i:s')));
}
public function down(){throw new RuntimeException('Gunakan backup untuk rollback workflow pembelian profesional.');}
}
@@ -0,0 +1,17 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Fix_purchase_installment_schedule_columns extends CI_Migration
{
public function up()
{
if(!$this->db->table_exists('purchase_installment_schedules')){
$this->db->query("CREATE TABLE IF NOT EXISTS purchase_installment_schedules(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,purchase_order_id BIGINT UNSIGNED NOT NULL,installment_no INT NOT NULL,due_date DATE NOT NULL,amount DECIMAL(18,2) NOT NULL,paid_amount DECIMAL(18,2) NOT NULL DEFAULT 0,status ENUM('unpaid','partial','paid','cancelled') NOT NULL DEFAULT 'unpaid',created_at DATETIME NOT NULL,UNIQUE KEY uq_po_installment(purchase_order_id,installment_no),KEY idx_installment_due(status,due_date)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
return;
}
if(!$this->db->field_exists('created_at','purchase_installment_schedules'))$this->db->query('ALTER TABLE purchase_installment_schedules ADD created_at DATETIME NULL AFTER status');
$this->db->query('UPDATE purchase_installment_schedules SET created_at=NOW() WHERE created_at IS NULL');
$this->db->query('ALTER TABLE purchase_installment_schedules MODIFY created_at DATETIME NOT NULL');
}
public function down(){if($this->db->field_exists('created_at','purchase_installment_schedules'))$this->db->query('ALTER TABLE purchase_installment_schedules DROP COLUMN created_at');}
}
@@ -0,0 +1,45 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Reconcile_goods_receipt_item_movements extends CI_Migration
{
public function up()
{
$this->db->query("INSERT INTO item_movements
(item_id,barcode_id,qty,from_type,from_id,to_type,to_id,movement_type,reference_type,reference_id,notes,created_at)
SELECT ib.item_id,ib.id,ib.qty_awal,'supplier',gr.supplier_id,'warehouse',ib.warehouse_id,
'purchase','goods_receipt_line',grl.id,
CONCAT('Penerimaan barang ',gr.receipt_no,' dari supplier ke gudang'),
COALESCE(gr.created_at,NOW())
FROM item_barcodes ib
JOIN goods_receipt_lines grl ON grl.id=ib.source_receipt_line_id
JOIN goods_receipts gr ON gr.id=grl.goods_receipt_id
LEFT JOIN item_movements im ON im.barcode_id=ib.id
AND im.reference_type='goods_receipt_line'
AND im.reference_id=grl.id
AND im.movement_type='purchase'
WHERE im.id IS NULL");
$this->db->query("UPDATE item_barcodes ib
JOIN goods_receipt_lines grl ON grl.id=ib.source_receipt_line_id
JOIN purchase_order_lines pol ON pol.id=grl.po_line_id
SET ib.status='pending'
WHERE pol.need_serial_number=1
AND (ib.serial_number IS NULL OR ib.serial_number='')
AND ib.qty_sisa>0");
$this->db->query("UPDATE items i SET i.status='draft'
WHERE EXISTS(
SELECT 1 FROM item_barcodes ib
JOIN goods_receipt_lines grl ON grl.id=ib.source_receipt_line_id
JOIN purchase_order_lines pol ON pol.id=grl.po_line_id
WHERE ib.item_id=i.id AND pol.need_serial_number=1
AND (ib.serial_number IS NULL OR ib.serial_number='') AND ib.qty_sisa>0
)");
}
public function down()
{
// Rekonsiliasi histori tidak dihapus agar audit trail penerimaan tetap utuh.
}
}
@@ -0,0 +1,37 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Expand_item_barcode_status extends CI_Migration
{
public function up()
{
if (!$this->db->table_exists('item_barcodes')) {
return;
}
// Database produksi lama hanya mengenal status barcode siap pakai.
// `pending` diperlukan untuk barang yang sudah diterima secara fisik,
// tetapi belum boleh dipakai/dijual sebelum serial number dilengkapi.
$this->db->query("ALTER TABLE item_barcodes
MODIFY status ENUM('pending','available','reserved','installed','sold_out')
NOT NULL DEFAULT 'available'");
if ($this->db->table_exists('goods_receipt_lines') &&
$this->db->table_exists('purchase_order_lines') &&
$this->db->field_exists('source_receipt_line_id', 'item_barcodes')) {
$this->db->query("UPDATE item_barcodes ib
JOIN goods_receipt_lines grl ON grl.id=ib.source_receipt_line_id
JOIN purchase_order_lines pol ON pol.id=grl.po_line_id
SET ib.status='pending'
WHERE pol.need_serial_number=1
AND (ib.serial_number IS NULL OR ib.serial_number='')
AND ib.qty_sisa>0");
}
}
public function down()
{
// Tidak dipersempit kembali karena dapat merusak barcode pending yang
// telah menjadi bagian audit trail penerimaan.
}
}
@@ -0,0 +1,47 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Recover_incomplete_receipt_tracking extends CI_Migration
{
public function up()
{
if (!$this->db->table_exists('goods_receipts') ||
!$this->db->table_exists('goods_receipt_lines')) {
return;
}
$this->load->library('PurchaseService');
$receipts = $this->db
->select('DISTINCT gr.id, gr.received_by', false)
->from('goods_receipts gr')
->join('goods_receipt_lines grl', 'grl.goods_receipt_id=gr.id')
->join('item_barcodes ib', 'ib.source_receipt_line_id=grl.id', 'left')
->where('gr.status', 'posted')
->where('grl.item_id IS NOT NULL', null, false)
->where('ib.id IS NULL', null, false)
->get()->result();
foreach ($receipts as $receipt) {
// Seluruh method bersifat idempotent: hanya bagian tracking/ledger
// yang belum terbentuk yang dilengkapi.
try {
$this->purchaseservice->finalizeReceiptTracking((int) $receipt->id);
$this->purchaseservice->syncReceiptLedger((int) $receipt->id);
$this->purchaseservice->clearTransitForReceipt(
(int) $receipt->id,
(int) $receipt->received_by
);
} catch (Throwable $error) {
// Histori lama yang belum memiliki konfigurasi tracking lengkap
// tidak boleh menggagalkan pemulihan penerimaan baru lainnya.
log_message('error', 'Pemulihan tracking penerimaan #'.
$receipt->id.' dilewati: '.$error->getMessage());
}
}
}
public function down()
{
// Data hasil pemulihan merupakan audit trail transaksi dan tidak dihapus.
}
}
@@ -0,0 +1,35 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Reconcile_receipt_transit_after_tracking_recovery extends CI_Migration
{
public function up()
{
$this->load->library('PurchaseService');
$receipts = $this->db->select('gr.id,gr.received_by')
->from('goods_receipts gr')
->join('purchase_orders po', 'po.id=gr.purchase_order_id')
->where('gr.status', 'posted')
->where('po.paid_amount > po.transit_cleared_amount', null, false)
->get()->result();
foreach ($receipts as $receipt) {
try {
// CompanyContext memperoleh tenant dari user transaksi.
$this->session->set_userdata('user_id', (int) $receipt->received_by);
$this->purchaseservice->clearTransitForReceipt(
(int) $receipt->id,
(int) $receipt->received_by
);
} catch (Throwable $error) {
log_message('error', 'Rekonsiliasi barang dalam perjalanan #'.
$receipt->id.' dilewati: '.$error->getMessage());
}
}
}
public function down()
{
// Jurnal rekonsiliasi merupakan audit trail dan tidak dihapus.
}
}
@@ -0,0 +1,26 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Add_purchase_cost_and_partial_barcode_release extends CI_Migration
{
public function up()
{
if (!$this->db->field_exists('allocate_purchase_cost', 'kode_barang')) {
$this->db->query("ALTER TABLE kode_barang ADD allocate_purchase_cost TINYINT(1) NOT NULL DEFAULT 0 AFTER need_serial_number");
}
if (!$this->db->field_exists('sale_price', 'item_barcodes')) {
$this->db->query("ALTER TABLE item_barcodes ADD sale_price DECIMAL(15,2) NULL AFTER qty_sisa");
}
if (!$this->db->field_exists('activated_at', 'item_barcodes')) {
$this->db->query("ALTER TABLE item_barcodes ADD activated_at DATETIME NULL AFTER status");
}
if (!$this->db->field_exists('activated_by', 'item_barcodes')) {
$this->db->query("ALTER TABLE item_barcodes ADD activated_by INT NULL AFTER activated_at");
}
}
public function down()
{
// Kolom dipertahankan untuk menjaga histori penentuan biaya dan harga jual.
}
}
@@ -0,0 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Finalize_purchase_approval_status extends CI_Migration
{
public function up(){if($this->db->table_exists('purchase_orders'))$this->db->query("ALTER TABLE purchase_orders MODIFY status ENUM('draft','submitted','approved','rejected','partially_received','received','closed','cancelled') NOT NULL DEFAULT 'draft'");}
public function down(){if($this->db->table_exists('purchase_orders')){$this->db->where('status','rejected')->update('purchase_orders',array('status'=>'cancelled'));$this->db->query("ALTER TABLE purchase_orders MODIFY status ENUM('draft','submitted','approved','partially_received','received','closed','cancelled') NOT NULL DEFAULT 'draft'");}}
}
@@ -0,0 +1,11 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Remove_supplier_invoice_approval extends CI_Migration
{
public function up(){
if($this->db->table_exists('approval_requests'))$this->db->query("UPDATE approval_requests SET status='rejected',completed_at=COALESCE(completed_at,NOW()) WHERE entity_type='supplier_invoice' AND status='pending'");
if($this->db->table_exists('approval_workflows'))$this->db->where('entity_type','supplier_invoice')->update('approval_workflows',array('is_active'=>0));
if($this->db->table_exists('supplier_invoices')&&$this->db->table_exists('transaction_attachments'))$this->db->query("UPDATE supplier_invoices si SET si.status='approved' WHERE si.status='submitted' AND EXISTS(SELECT 1 FROM transaction_attachments ta WHERE ta.entity_type='supplier_invoice' AND ta.entity_id=si.id) AND NOT EXISTS(SELECT 1 FROM supplier_invoice_lines sil WHERE sil.supplier_invoice_id=si.id AND sil.match_status<>'matched')");
}
public function down(){if($this->db->table_exists('approval_workflows'))$this->db->where('entity_type','supplier_invoice')->update('approval_workflows',array('is_active'=>1));}
}
@@ -0,0 +1,19 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Add_purchase_down_payment extends CI_Migration
{
public function up()
{
if(!$this->db->field_exists('down_payment_required','purchase_orders'))
$this->db->query("ALTER TABLE purchase_orders ADD down_payment_required TINYINT(1) NOT NULL DEFAULT 0 AFTER payment_plan");
if(!$this->db->field_exists('down_payment_amount','purchase_orders'))
$this->db->query("ALTER TABLE purchase_orders ADD down_payment_amount DECIMAL(18,2) NOT NULL DEFAULT 0 AFTER down_payment_required");
}
public function down()
{
if($this->db->field_exists('down_payment_amount','purchase_orders'))$this->db->query('ALTER TABLE purchase_orders DROP COLUMN down_payment_amount');
if($this->db->field_exists('down_payment_required','purchase_orders'))$this->db->query('ALTER TABLE purchase_orders DROP COLUMN down_payment_required');
}
}
@@ -0,0 +1,24 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Simplify_supplier_invoice_status extends CI_Migration
{
public function up()
{
if(!$this->db->table_exists('supplier_invoices'))return;
$this->db->query("ALTER TABLE supplier_invoices MODIFY status ENUM('draft','submitted','approved','verified','posted','partial','paid','cancelled','reversed') NOT NULL DEFAULT 'verified'");
$this->db->query("UPDATE supplier_invoices SET status='verified' WHERE status IN('draft','submitted','approved')");
$this->db->query("UPDATE supplier_invoices SET status='partial' WHERE status='posted' AND balance>0");
$this->db->query("UPDATE supplier_invoices SET status='paid',balance=0 WHERE status='posted' AND balance<=0");
$this->db->query("ALTER TABLE supplier_invoices MODIFY status ENUM('verified','partial','paid','cancelled','reversed') NOT NULL DEFAULT 'verified'");
}
public function down()
{
if(!$this->db->table_exists('supplier_invoices'))return;
$this->db->query("ALTER TABLE supplier_invoices MODIFY status ENUM('draft','submitted','approved','verified','posted','partial','paid','cancelled','reversed') NOT NULL DEFAULT 'draft'");
$this->db->query("UPDATE supplier_invoices SET status='approved' WHERE status='verified'");
$this->db->query("UPDATE supplier_invoices SET status='posted' WHERE status='partial' AND paid_amount<=0");
$this->db->query("ALTER TABLE supplier_invoices MODIFY status ENUM('draft','submitted','approved','posted','partial','paid','cancelled','reversed') NOT NULL DEFAULT 'draft'");
}
}
@@ -0,0 +1,40 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Align_open_supplier_invoices_to_po_total extends CI_Migration
{
public function up()
{
if(!$this->db->table_exists('supplier_invoices'))return;
$ap=$this->mapping('accounts_payable');
$inventory=$this->mapping('inventory');
$rows=$this->db->query("SELECT si.*,po.id po_id,po.total po_total,po.paid_amount po_paid FROM supplier_invoices si JOIN supplier_invoice_lines sil ON sil.supplier_invoice_id=si.id JOIN purchase_order_lines pol ON pol.id=sil.po_line_id JOIN purchase_orders po ON po.id=pol.purchase_order_id WHERE si.status IN('verified','partial') GROUP BY si.id")->result();
foreach($rows as$row){
$openCount=(int)$this->db->query("SELECT COUNT(DISTINCT si.id) total FROM supplier_invoices si JOIN supplier_invoice_lines sil ON sil.supplier_invoice_id=si.id JOIN purchase_order_lines pol ON pol.id=sil.po_line_id WHERE pol.purchase_order_id=? AND si.status IN('verified','partial')",array($row->po_id))->row()->total;
if($openCount!==1)continue;
$oldTotal=(float)$row->total;$newTotal=round((float)$row->po_total,2);$paid=min($newTotal,max((float)$row->paid_amount,(float)$row->po_paid));$balance=max(0,$newTotal-$paid);$delta=round($newTotal-$oldTotal,2);
if($row->status==='partial'&&$row->journal_id&&abs($delta)>.001&&$ap&&$inventory){
$apLine=$this->db->where(array('journal_id'=>$row->journal_id,'account_id'=>$ap))->order_by('id')->get('journal_details')->row();
$inventoryLine=$this->db->where(array('journal_id'=>$row->journal_id,'account_id'=>$inventory))->order_by('id')->get('journal_details')->row();
if($apLine&&$inventoryLine){
$this->db->where('id',$apLine->id)->update('journal_details',array('kredit'=>round((float)$apLine->kredit+$delta,2)));
$this->db->where('id',$inventoryLine->id)->update('journal_details',array('debit'=>round((float)$inventoryLine->debit+$delta,2)));
}
}
$status=$row->status==='verified'?'verified':($balance<=.001?'paid':'partial');
$this->db->where('id',$row->id)->update('supplier_invoices',array('total'=>$newTotal,'paid_amount'=>$paid,'balance'=>$balance,'status'=>$status));
}
}
private function mapping($key)
{
if(!$this->db->table_exists('system_account_mappings'))return 0;
$row=$this->db->where(array('mapping_key'=>$key,'is_active'=>1))->order_by('id','DESC')->get('system_account_mappings')->row();
return$row?(int)$row->account_id:0;
}
public function down()
{
// Nilai lama tidak dikembalikan karena migrasi ini memperbaiki saldo transaksi development.
}
}
@@ -0,0 +1,47 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Backfill_purchase_payable_company extends CI_Migration
{
public function up()
{
if (!$this->db->table_exists('companies')) return;
$company = $this->db->order_by('id')->get('companies')->row();
if (!$company) return;
$fallback = (int)$company->id;
if ($this->db->table_exists('purchase_orders') && $this->db->field_exists('company_id', 'purchase_orders')) {
$this->db->query(
'UPDATE purchase_orders po LEFT JOIN suppliers s ON s.id=po.supplier_id '.
'SET po.company_id=COALESCE(s.company_id,?) WHERE po.company_id IS NULL',
array($fallback)
);
}
if ($this->db->table_exists('supplier_invoices') && $this->db->field_exists('company_id', 'supplier_invoices')) {
$this->db->query(
'UPDATE supplier_invoices si '.
'LEFT JOIN supplier_invoice_lines sil ON sil.supplier_invoice_id=si.id '.
'LEFT JOIN purchase_order_lines pol ON pol.id=sil.po_line_id '.
'LEFT JOIN purchase_orders po ON po.id=pol.purchase_order_id '.
'LEFT JOIN suppliers s ON s.id=si.supplier_id '.
'SET si.company_id=COALESCE(po.company_id,s.company_id,?) '.
'WHERE si.company_id IS NULL',
array($fallback)
);
}
if ($this->db->table_exists('supplier_payments') && $this->db->field_exists('company_id', 'supplier_payments')) {
$this->db->query(
'UPDATE supplier_payments sp LEFT JOIN suppliers s ON s.id=sp.supplier_id '.
'SET sp.company_id=COALESCE(s.company_id,?) WHERE sp.company_id IS NULL',
array($fallback)
);
}
}
public function down()
{
// Backfill tenant tidak dibalik agar transaksi tidak kembali tanpa perusahaan.
}
}
@@ -0,0 +1,73 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Add_purchase_payment_bank_charge extends CI_Migration
{
public function up()
{
if ($this->db->table_exists('supplier_payments')) {
if (!$this->db->field_exists('bank_charge_amount', 'supplier_payments')) {
$this->db->query('ALTER TABLE supplier_payments ADD bank_charge_amount DECIMAL(18,2) NOT NULL DEFAULT 0 AFTER amount');
}
if (!$this->db->field_exists('bank_charge_account_id', 'supplier_payments')) {
$this->db->query('ALTER TABLE supplier_payments ADD bank_charge_account_id INT NULL AFTER bank_charge_amount');
}
if (!$this->db->field_exists('total_cash_out', 'supplier_payments')) {
$this->db->query('ALTER TABLE supplier_payments ADD total_cash_out DECIMAL(18,2) NOT NULL DEFAULT 0 AFTER bank_charge_account_id');
}
$this->db->query('UPDATE supplier_payments SET total_cash_out=amount WHERE total_cash_out=0');
}
if (!$this->db->table_exists('accounts') || !$this->db->table_exists('system_account_mappings')) return;
$account = $this->db->where('nama_akun', 'Beban Administrasi Bank')->order_by('id')->get('accounts')->row();
if (!$account) {
$code = '6298';
$occupied = $this->db->where('kode_akun', $code)->get('accounts')->row();
if ($occupied) $code = '6298A';
$data = array(
'kode_akun' => $code,
'nama_akun' => 'Beban Administrasi Bank',
'tipe' => 'expense',
'posisi' => 'debit',
'kategori' => 'laba_rugi',
'is_active' => 1,
'is_header' => 0,
'allow_posting' => 1,
);
if ($this->db->field_exists('company_id', 'accounts') && $this->db->table_exists('companies')) {
$company = $this->db->order_by('id')->get('companies')->row();
$data['company_id'] = $company ? (int)$company->id : null;
}
$this->db->insert('accounts', $data);
$account = (object)array('id'=>$this->db->insert_id());
}
if (!$this->db->where('mapping_key', 'bank_charge_expense')->count_all_results('system_account_mappings')) {
$mapping = array(
'mapping_key' => 'bank_charge_expense',
'account_id' => (int)$account->id,
'description' => 'Beban administrasi bank pada pembayaran pembelian',
'is_active' => 1,
'created_at' => date('Y-m-d H:i:s'),
);
if ($this->db->field_exists('company_id', 'system_account_mappings') && $this->db->field_exists('company_id', 'accounts')) {
$row = $this->db->select('company_id')->get_where('accounts', array('id'=>(int)$account->id))->row();
$mapping['company_id'] = $row ? $row->company_id : null;
}
$this->db->insert('system_account_mappings', $mapping);
}
}
public function down()
{
if ($this->db->table_exists('system_account_mappings')) {
$this->db->where('mapping_key', 'bank_charge_expense')->delete('system_account_mappings');
}
if ($this->db->table_exists('supplier_payments')) {
foreach (array('total_cash_out','bank_charge_account_id','bank_charge_amount') as $column) {
if ($this->db->field_exists($column, 'supplier_payments')) {
$this->db->query('ALTER TABLE supplier_payments DROP COLUMN '.$column);
}
}
}
}
}
@@ -0,0 +1,216 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
/**
* Memisahkan transaksi pengecualian pembelian dari workflow normal.
* Migration sengaja bersifat additive agar data retur legacy tetap dapat diaudit.
*/
class Migration_Finalize_purchase_returns_refunds extends CI_Migration
{
public function up()
{
$this->upgradeReturnHeaders();
$this->upgradeReturnLines();
$this->upgradeBarcodeAndMovements();
$this->createWorkflowTables();
$this->upgradeDebitNotes();
$this->seedAccountingMappings();
$this->seedWorkflowAndPermissions();
$this->seedLegacyHistory();
}
private function addColumn($table, $name, $definition)
{
if (!$this->db->field_exists($name, $table)) {
$this->db->query("ALTER TABLE `{$table}` ADD `{$name}` {$definition}");
}
}
private function addIndex($table, $name, $columns, $unique = false)
{
$exists = $this->db->query(
'SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name=? AND index_name=? LIMIT 1',
array($table, $name)
)->row();
if (!$exists) {
$this->db->query('ALTER TABLE `'.$table.'` ADD '.($unique ? 'UNIQUE ' : '').'KEY `'.$name.'` ('.$columns.')');
}
}
private function upgradeReturnHeaders()
{
$this->db->query("ALTER TABLE purchase_returns MODIFY goods_receipt_id BIGINT UNSIGNED NULL");
$this->db->query("ALTER TABLE purchase_returns MODIFY status ENUM('draft','submitted','approved','rejected','reserved','posted','shipped','waiting_supplier','partially_resolved','resolved','cancelled','reversed') NOT NULL DEFAULT 'draft'");
$this->db->query("UPDATE purchase_returns SET status='shipped' WHERE status='posted'");
$this->addColumn('purchase_returns', 'company_id', 'BIGINT UNSIGNED NULL AFTER id');
$this->addColumn('purchase_returns', 'purchase_order_id', 'BIGINT UNSIGNED NULL AFTER supplier_id');
$this->addColumn('purchase_returns', 'problem_category', "ENUM('damaged','wrong_item','specification','over_delivery','po_mismatch','other') NOT NULL DEFAULT 'other' AFTER reason");
$this->addColumn('purchase_returns', 'requested_resolution', "ENUM('replacement','debit_note','refund','undecided') NOT NULL DEFAULT 'undecided' AFTER problem_category");
$this->addColumn('purchase_returns', 'notes', 'TEXT NULL AFTER requested_resolution');
$this->addColumn('purchase_returns', 'idempotency_key', 'VARCHAR(120) NULL AFTER notes');
$this->addColumn('purchase_returns', 'submitted_by', 'INT NULL AFTER created_by');
$this->addColumn('purchase_returns', 'submitted_at', 'DATETIME NULL AFTER submitted_by');
$this->addColumn('purchase_returns', 'approved_by', 'INT NULL AFTER submitted_at');
$this->addColumn('purchase_returns', 'approved_at', 'DATETIME NULL AFTER approved_by');
$this->addColumn('purchase_returns', 'rejected_by', 'INT NULL AFTER approved_at');
$this->addColumn('purchase_returns', 'rejected_at', 'DATETIME NULL AFTER rejected_by');
$this->addColumn('purchase_returns', 'rejection_reason', 'TEXT NULL AFTER rejected_at');
$this->addColumn('purchase_returns', 'reserved_by', 'INT NULL AFTER rejection_reason');
$this->addColumn('purchase_returns', 'reserved_at', 'DATETIME NULL AFTER reserved_by');
$this->addColumn('purchase_returns', 'shipped_by', 'INT NULL AFTER reserved_at');
$this->addColumn('purchase_returns', 'shipped_at', 'DATETIME NULL AFTER shipped_by');
$this->addColumn('purchase_returns', 'resolved_by', 'INT NULL AFTER shipped_at');
$this->addColumn('purchase_returns', 'resolved_at', 'DATETIME NULL AFTER resolved_by');
$this->addColumn('purchase_returns', 'cancelled_by', 'INT NULL AFTER resolved_at');
$this->addColumn('purchase_returns', 'cancelled_at', 'DATETIME NULL AFTER cancelled_by');
$this->addColumn('purchase_returns', 'reversal_journal_id', 'INT NULL AFTER journal_id');
$this->addColumn('purchase_returns', 'updated_by', 'INT NULL AFTER created_at');
$this->addColumn('purchase_returns', 'updated_at', 'DATETIME NULL AFTER updated_by');
$this->addIndex('purchase_returns', 'uq_purchase_return_idempotency', '`idempotency_key`', true);
$this->addIndex('purchase_returns', 'idx_purchase_return_action', '`company_id`,`status`,`return_date`');
$this->addIndex('purchase_returns', 'idx_purchase_return_supplier', '`company_id`,`supplier_id`,`status`');
$this->addIndex('purchase_returns', 'idx_purchase_return_po', '`purchase_order_id`');
if ($this->db->field_exists('company_id', 'purchase_orders')) {
$this->db->query("UPDATE purchase_returns pr LEFT JOIN purchase_orders po ON po.id=pr.purchase_order_id SET pr.company_id=COALESCE(pr.company_id,po.company_id,(SELECT MIN(id) FROM companies)) WHERE pr.company_id IS NULL");
}
}
private function upgradeReturnLines()
{
$this->addColumn('purchase_return_lines', 'company_id', 'BIGINT UNSIGNED NULL AFTER id');
$this->addColumn('purchase_return_lines', 'purchase_order_id', 'BIGINT UNSIGNED NULL AFTER purchase_return_id');
$this->addColumn('purchase_return_lines', 'goods_receipt_id', 'BIGINT UNSIGNED NULL AFTER purchase_order_id');
$this->addColumn('purchase_return_lines', 'description', 'VARCHAR(255) NULL AFTER warehouse_id');
$this->addColumn('purchase_return_lines', 'tracking_type', "ENUM('UNIT','QTY') NOT NULL DEFAULT 'QTY' AFTER description");
$this->addColumn('purchase_return_lines', 'barcode_snapshot', 'VARCHAR(100) NULL AFTER barcode_id');
$this->addColumn('purchase_return_lines', 'serial_snapshot', 'VARCHAR(100) NULL AFTER barcode_snapshot');
$this->addColumn('purchase_return_lines', 'received_qty', 'DECIMAL(18,4) NOT NULL DEFAULT 0 AFTER serial_snapshot');
$this->addColumn('purchase_return_lines', 'previous_return_qty', 'DECIMAL(18,4) NOT NULL DEFAULT 0 AFTER received_qty');
$this->addColumn('purchase_return_lines', 'returnable_qty', 'DECIMAL(18,4) NOT NULL DEFAULT 0 AFTER previous_return_qty');
$this->addColumn('purchase_return_lines', 'reserved_qty', 'DECIMAL(18,4) NOT NULL DEFAULT 0 AFTER qty');
$this->addColumn('purchase_return_lines', 'shipped_qty', 'DECIMAL(18,4) NOT NULL DEFAULT 0 AFTER reserved_qty');
$this->addColumn('purchase_return_lines', 'tax_amount', 'DECIMAL(18,2) NOT NULL DEFAULT 0 AFTER unit_cost');
$this->addColumn('purchase_return_lines', 'total_amount', 'DECIMAL(18,2) NOT NULL DEFAULT 0 AFTER tax_amount');
$this->addColumn('purchase_return_lines', 'requested_resolution', "ENUM('replacement','debit_note','refund','undecided') NOT NULL DEFAULT 'undecided' AFTER total_amount");
$this->addColumn('purchase_return_lines', 'replacement_received_qty', 'DECIMAL(18,4) NOT NULL DEFAULT 0 AFTER requested_resolution');
$this->addColumn('purchase_return_lines', 'debit_note_amount', 'DECIMAL(18,2) NOT NULL DEFAULT 0 AFTER replacement_received_qty');
$this->addColumn('purchase_return_lines', 'refund_claim_amount', 'DECIMAL(18,2) NOT NULL DEFAULT 0 AFTER debit_note_amount');
$this->addColumn('purchase_return_lines', 'ledger_id', 'BIGINT UNSIGNED NULL AFTER stock_log_id');
$this->addColumn('purchase_return_lines', 'movement_id', 'INT NULL AFTER ledger_id');
$this->addColumn('purchase_return_lines', 'status', "ENUM('draft','reserved','shipped','partially_resolved','resolved','reversed') NOT NULL DEFAULT 'draft' AFTER movement_id");
$this->addIndex('purchase_return_lines', 'idx_return_line_source', '`receipt_line_id`,`status`');
$this->addIndex('purchase_return_lines', 'idx_return_line_barcode', '`barcode_id`,`status`');
$this->db->query("UPDATE purchase_return_lines l JOIN goods_receipt_lines grl ON grl.id=l.receipt_line_id JOIN goods_receipts gr ON gr.id=grl.goods_receipt_id JOIN purchase_order_lines pol ON pol.id=grl.po_line_id LEFT JOIN item_barcodes ib ON ib.id=l.barcode_id LEFT JOIN purchase_returns pr ON pr.id=l.purchase_return_id SET l.goods_receipt_id=gr.id,l.purchase_order_id=gr.purchase_order_id,l.company_id=pr.company_id,l.description=COALESCE(l.description,pol.description),l.tracking_type=COALESCE(pol.tracking_type,'QTY'),l.barcode_snapshot=ib.barcode,l.serial_snapshot=ib.serial_number,l.received_qty=grl.qty,l.returnable_qty=GREATEST(0,grl.qty-l.qty),l.total_amount=l.qty*l.unit_cost,l.shipped_qty=IF(pr.status IN('shipped','waiting_supplier','partially_resolved','resolved'),l.qty,0),l.status=IF(pr.status IN('shipped','waiting_supplier','partially_resolved','resolved'),'shipped','draft')");
}
private function upgradeBarcodeAndMovements()
{
$this->db->query("ALTER TABLE item_barcodes MODIFY status ENUM('pending','available','reserved','return_reserved','returned','replaced','closed','installed','sold_out') NOT NULL DEFAULT 'available'");
$this->addColumn('item_barcodes', 'return_reserved_qty', 'DECIMAL(18,4) NOT NULL DEFAULT 0 AFTER reserved_qty');
$this->db->query("ALTER TABLE item_movements MODIFY movement_type ENUM('purchase','transfer','installation','uninstallation','supplier_return','purchase_return','purchase_replacement','customer_return','adjustment','sold_out','asset') NOT NULL");
}
private function createWorkflowTables()
{
$queries = array(
"CREATE TABLE IF NOT EXISTS purchase_return_status_history(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,purchase_return_id BIGINT UNSIGNED NOT NULL,from_status VARCHAR(30) NULL,to_status VARCHAR(30) NOT NULL,notes TEXT NULL,user_id INT NULL,ip_address VARCHAR(45) NULL,session_id VARCHAR(128) NULL,created_at DATETIME NOT NULL,KEY idx_return_history(purchase_return_id,created_at),CONSTRAINT fk_return_history_header FOREIGN KEY(purchase_return_id) REFERENCES purchase_returns(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
"CREATE TABLE IF NOT EXISTS purchase_return_shipments(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NULL,shipment_no VARCHAR(60) NOT NULL,purchase_return_id BIGINT UNSIGNED NOT NULL,shipment_date DATE NOT NULL,courier VARCHAR(100) NULL,tracking_no VARCHAR(120) NULL,notes TEXT NULL,status ENUM('posted','received_by_supplier','cancelled','reversed') NOT NULL DEFAULT 'posted',idempotency_key VARCHAR(120) NOT NULL,posted_by INT NULL,posted_at DATETIME NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_return_shipment_no(shipment_no),UNIQUE KEY uq_return_shipment_idempotency(idempotency_key),KEY idx_return_shipment(purchase_return_id,status),CONSTRAINT fk_return_shipment_header FOREIGN KEY(purchase_return_id) REFERENCES purchase_returns(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
"CREATE TABLE IF NOT EXISTS purchase_return_shipment_lines(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,purchase_return_shipment_id BIGINT UNSIGNED NOT NULL,purchase_return_line_id BIGINT UNSIGNED NOT NULL,qty DECIMAL(18,4) NOT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_return_shipment_line(purchase_return_shipment_id,purchase_return_line_id),CONSTRAINT fk_return_shipment_line_header FOREIGN KEY(purchase_return_shipment_id) REFERENCES purchase_return_shipments(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
"CREATE TABLE IF NOT EXISTS purchase_replacements(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NULL,replacement_no VARCHAR(60) NOT NULL,purchase_return_id BIGINT UNSIGNED NOT NULL,supplier_id INT NOT NULL,receipt_date DATE NOT NULL,supplier_delivery_no VARCHAR(120) NULL,warehouse_id INT NOT NULL,status ENUM('posted','partially_received','received','cancelled','reversed') NOT NULL DEFAULT 'posted',idempotency_key VARCHAR(120) NOT NULL,received_by INT NULL,posted_at DATETIME NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_purchase_replacement_no(replacement_no),UNIQUE KEY uq_purchase_replacement_idempotency(idempotency_key),KEY idx_replacement_action(company_id,status,receipt_date),CONSTRAINT fk_replacement_return FOREIGN KEY(purchase_return_id) REFERENCES purchase_returns(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
"CREATE TABLE IF NOT EXISTS purchase_replacement_lines(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,purchase_replacement_id BIGINT UNSIGNED NOT NULL,purchase_return_line_id BIGINT UNSIGNED NOT NULL,item_id INT NOT NULL,warehouse_id INT NOT NULL,barcode_id INT NULL,qty DECIMAL(18,4) NOT NULL,unit_cost DECIMAL(18,4) NOT NULL,stock_log_id INT NULL,movement_id INT NULL,ledger_id BIGINT UNSIGNED NULL,created_at DATETIME NOT NULL,KEY idx_replacement_line_return(purchase_return_line_id),CONSTRAINT fk_replacement_line_header FOREIGN KEY(purchase_replacement_id) REFERENCES purchase_replacements(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
"CREATE TABLE IF NOT EXISTS supplier_refund_claims(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NULL,refund_no VARCHAR(60) NOT NULL,supplier_id INT NOT NULL,purchase_order_id BIGINT UNSIGNED NULL,supplier_invoice_id BIGINT UNSIGNED NULL,purchase_return_id BIGINT UNSIGNED NULL,claim_date DATE NOT NULL,expected_date DATE NULL,reason TEXT NOT NULL,source_type ENUM('prepayment','short_delivery','paid_return','cancelled_order','overpayment','cashback','debit_note','other') NOT NULL DEFAULT 'other',eligible_amount DECIMAL(18,2) NOT NULL,claim_amount DECIMAL(18,2) NOT NULL,received_amount DECIMAL(18,2) NOT NULL DEFAULT 0,balance DECIMAL(18,2) NOT NULL,target_account_id INT NULL,status ENUM('draft','submitted','approved','rejected','waiting_funds','partial','received','reconciled','cancelled','reversed') NOT NULL DEFAULT 'draft',idempotency_key VARCHAR(120) NULL,entitlement_journal_id INT NULL,reversal_journal_id INT NULL,created_by INT NULL,created_at DATETIME NOT NULL,submitted_by INT NULL,submitted_at DATETIME NULL,approved_by INT NULL,approved_at DATETIME NULL,rejected_by INT NULL,rejected_at DATETIME NULL,rejection_reason TEXT NULL,reconciled_by INT NULL,reconciled_at DATETIME NULL,updated_by INT NULL,updated_at DATETIME NULL,UNIQUE KEY uq_supplier_refund_no(refund_no),UNIQUE KEY uq_supplier_refund_idempotency(idempotency_key),KEY idx_refund_action(company_id,status,expected_date),KEY idx_refund_supplier(company_id,supplier_id,status)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
"CREATE TABLE IF NOT EXISTS supplier_refund_allocations(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,supplier_refund_claim_id BIGINT UNSIGNED NOT NULL,source_type VARCHAR(40) NOT NULL,source_id BIGINT UNSIGNED NOT NULL,eligible_amount DECIMAL(18,2) NOT NULL,allocated_amount DECIMAL(18,2) NOT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_refund_allocation(supplier_refund_claim_id,source_type,source_id),KEY idx_refund_allocation_source(source_type,source_id),CONSTRAINT fk_refund_allocation_header FOREIGN KEY(supplier_refund_claim_id) REFERENCES supplier_refund_claims(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
"CREATE TABLE IF NOT EXISTS supplier_refund_receipts(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NULL,receipt_no VARCHAR(60) NOT NULL,supplier_refund_claim_id BIGINT UNSIGNED NOT NULL,receipt_date DATE NOT NULL,bank_account_id INT NOT NULL,amount DECIMAL(18,2) NOT NULL,reference_no VARCHAR(120) NOT NULL,notes TEXT NULL,status ENUM('posted','reconciled','reversed') NOT NULL DEFAULT 'posted',idempotency_key VARCHAR(120) NOT NULL,journal_id INT NOT NULL,reversal_journal_id INT NULL,received_by INT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_supplier_refund_receipt_no(receipt_no),UNIQUE KEY uq_supplier_refund_receipt_idempotency(idempotency_key),KEY idx_refund_receipt_claim(supplier_refund_claim_id,status),CONSTRAINT fk_refund_receipt_header FOREIGN KEY(supplier_refund_claim_id) REFERENCES supplier_refund_claims(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
"CREATE TABLE IF NOT EXISTS supplier_debit_note_lines(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,supplier_debit_note_id BIGINT UNSIGNED NOT NULL,purchase_return_line_id BIGINT UNSIGNED NULL,supplier_invoice_line_id BIGINT UNSIGNED NULL,item_id INT NULL,description VARCHAR(255) NOT NULL,qty DECIMAL(18,4) NOT NULL DEFAULT 0,base_amount DECIMAL(18,2) NOT NULL,tax_amount DECIMAL(18,2) NOT NULL DEFAULT 0,total_amount DECIMAL(18,2) NOT NULL,created_at DATETIME NOT NULL,KEY idx_debit_note_line_header(supplier_debit_note_id),CONSTRAINT fk_debit_note_line_header FOREIGN KEY(supplier_debit_note_id) REFERENCES supplier_debit_notes(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
foreach ($queries as $sql) $this->db->query($sql);
}
private function upgradeDebitNotes()
{
$this->db->query("ALTER TABLE supplier_debit_notes MODIFY supplier_invoice_id BIGINT UNSIGNED NULL");
$this->db->query("ALTER TABLE supplier_debit_notes MODIFY status ENUM('draft','posted','applied','cancelled','reversed') NOT NULL DEFAULT 'draft'");
$this->addColumn('supplier_debit_notes', 'company_id', 'BIGINT UNSIGNED NULL AFTER id');
$this->addColumn('supplier_debit_notes', 'purchase_order_id', 'BIGINT UNSIGNED NULL AFTER supplier_invoice_id');
$this->addColumn('supplier_debit_notes', 'purchase_return_id', 'BIGINT UNSIGNED NULL AFTER purchase_order_id');
$this->addColumn('supplier_debit_notes', 'correction_type', "ENUM('return','quantity','price','discount','tax','chargeback','billing_error','other') NOT NULL DEFAULT 'other' AFTER note_date");
$this->addColumn('supplier_debit_notes', 'base_amount', 'DECIMAL(18,2) NOT NULL DEFAULT 0 AFTER correction_type');
$this->addColumn('supplier_debit_notes', 'tax_amount', 'DECIMAL(18,2) NOT NULL DEFAULT 0 AFTER base_amount');
$this->addColumn('supplier_debit_notes', 'idempotency_key', 'VARCHAR(120) NULL AFTER reason');
$this->addColumn('supplier_debit_notes', 'posted_by', 'INT NULL AFTER journal_id');
$this->addColumn('supplier_debit_notes', 'posted_at', 'DATETIME NULL AFTER posted_by');
$this->addColumn('supplier_debit_notes', 'reversal_journal_id', 'INT NULL AFTER posted_at');
$this->addColumn('supplier_debit_notes', 'updated_by', 'INT NULL AFTER created_at');
$this->addColumn('supplier_debit_notes', 'updated_at', 'DATETIME NULL AFTER updated_by');
$this->addIndex('supplier_debit_notes', 'uq_supplier_debit_note_idempotency', '`idempotency_key`', true);
$this->addIndex('supplier_debit_notes', 'idx_debit_note_action', '`company_id`,`status`,`note_date`');
}
private function seedAccountingMappings()
{
$definitions = array(
'supplier_refund_receivable' => array('1194', 'Piutang Refund Supplier', 'asset', 'debit', 'neraca', 'Piutang atas dana yang wajib dikembalikan supplier'),
'purchase_return_adjustment' => array('6297', 'Penyesuaian Retur Pembelian', 'expense', 'debit', 'laba_rugi', 'Akun lawan retur sebelum terbentuknya hutang'),
'purchase_price_variance' => array('6296', 'Selisih Harga Pembelian', 'expense', 'debit', 'laba_rugi', 'Selisih nilai pembelian dan penyelesaian supplier')
);
foreach ($definitions as $key => $definition) {
$mapping = $this->db->get_where('system_account_mappings', array('mapping_key'=>$key))->row();
if ($mapping) continue;
$account = $this->db->get_where('accounts', array('nama_akun'=>$definition[1]))->row();
if (!$account) {
$code = $definition[0];
if ($this->db->get_where('accounts', array('kode_akun'=>$code))->row()) $code .= '-RR';
$this->db->insert('accounts', array('kode_akun'=>$code,'nama_akun'=>$definition[1],'tipe'=>$definition[2],'posisi'=>$definition[3],'kategori'=>$definition[4],'is_active'=>1,'is_header'=>0,'allow_posting'=>1));
$account = (object) array('id'=>$this->db->insert_id());
}
$this->db->insert('system_account_mappings', array('mapping_key'=>$key,'account_id'=>$account->id,'description'=>$definition[5],'is_active'=>1,'created_at'=>date('Y-m-d H:i:s')));
}
}
private function seedWorkflowAndPermissions()
{
$role = $this->db->where_in('nama_role', array('Accounting Manager','Admin'))->order_by("FIELD(nama_role,'Accounting Manager','Admin')", '', false)->get('roles')->row();
foreach (array(
array('PURCHASE_RETURN_DEFAULT','purchase_return','purchase_return','Persetujuan Retur Barang Supplier','Persetujuan Retur Pembelian'),
array('SUPPLIER_REFUND_DEFAULT','purchase_refund','supplier_refund','Persetujuan Refund Dana Supplier','Persetujuan Refund Supplier')
) as $definition) {
$workflow = $this->db->get_where('approval_workflows', array('code'=>$definition[0]))->row();
if ($workflow) continue;
$this->db->insert('approval_workflows', array('code'=>$definition[0],'module'=>$definition[1],'entity_type'=>$definition[2],'name'=>$definition[3],'require_separation'=>1,'is_active'=>1,'created_at'=>date('Y-m-d H:i:s')));
$workflowId = $this->db->insert_id();
if ($role) $this->db->insert('approval_workflow_steps', array('workflow_id'=>$workflowId,'step_order'=>1,'name'=>$definition[4],'approver_role_id'=>$role->id));
}
$roleRights = array(
'Accounting Manager'=>array('purchase_returns'=>array('can_view','can_create','can_update','can_approve','can_post','can_export'),'purchase_refunds'=>array('can_view','can_create','can_update','can_approve','can_post','can_export'),'supplier_debit_notes'=>array('can_view','can_create','can_post','can_export')),
'Finance & Treasury'=>array('purchase_returns'=>array('can_view','can_export'),'purchase_refunds'=>array('can_view','can_create','can_update','can_approve','can_post','can_export'),'supplier_debit_notes'=>array('can_view','can_create','can_post','can_export')),
'AP / Purchasing'=>array('purchase_returns'=>array('can_view','can_create','can_update','can_export'),'purchase_refunds'=>array('can_view','can_create','can_update','can_export'),'supplier_debit_notes'=>array('can_view','can_create','can_export')),
'Inventory & Warehouse'=>array('purchase_returns'=>array('can_view','can_update','can_post','can_export'),'purchase_refunds'=>array('can_view'),'supplier_debit_notes'=>array('can_view')),
'Auditor / Read Only'=>array('purchase_returns'=>array('can_view','can_export'),'purchase_refunds'=>array('can_view','can_export'),'supplier_debit_notes'=>array('can_view','can_export')),
'Keuangan'=>array('purchase_returns'=>array('can_view','can_export'),'purchase_refunds'=>array('can_view','can_create','can_update','can_approve','can_post','can_export'),'supplier_debit_notes'=>array('can_view','can_create','can_post','can_export')),
'Gudang'=>array('purchase_returns'=>array('can_view','can_update','can_post','can_export'),'purchase_refunds'=>array('can_view'),'supplier_debit_notes'=>array('can_view'))
);
foreach ($roleRights as $roleName => $features) {
$role = $this->db->get_where('roles', array('nama_role'=>$roleName))->row();
if (!$role) continue;
$permissions = json_decode((string)$role->permissions, true);
if (!is_array($permissions)) $permissions = array();
foreach ($features as $feature => $rights) $permissions[$feature] = $rights;
$this->db->where('id', $role->id)->update('roles', array('permissions'=>json_encode($permissions)));
}
}
private function seedLegacyHistory()
{
$rows = $this->db->where('status', 'shipped')->get('purchase_returns')->result();
foreach ($rows as $row) {
if ($this->db->where('purchase_return_id', $row->id)->count_all_results('purchase_return_status_history')) continue;
$this->db->insert('purchase_return_status_history', array('purchase_return_id'=>$row->id,'from_status'=>'posted','to_status'=>'shipped','notes'=>'Migrasi histori retur legacy; stok telah berkurang sebelum modul workflow baru.','user_id'=>$row->created_by,'created_at'=>$row->created_at));
}
}
public function down()
{
throw new RuntimeException('Rollback otomatis dinonaktifkan karena migration mempertahankan histori transaksi pembelian.');
}
}
@@ -0,0 +1,93 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
/**
* Melengkapi audit perubahan status dan metadata reversal transaksi
* Retur/Refund. Migration additive agar histori dari migration sebelumnya
* tidak diubah atau dihapus.
*/
class Migration_Add_purchase_adjustment_reversal_audit extends CI_Migration
{
public function up()
{
$this->db->query("ALTER TABLE item_movements MODIFY movement_type ENUM('purchase','transfer','installation','uninstallation','supplier_return','purchase_return','purchase_return_reversal','purchase_replacement','customer_return','adjustment','sold_out','asset') NOT NULL");
$this->addColumn('purchase_returns', 'reversal_reason', 'TEXT NULL AFTER reversal_journal_id');
$this->addColumn('supplier_refund_claims', 'cancelled_by', 'INT NULL AFTER reconciled_at');
$this->addColumn('supplier_refund_claims', 'cancelled_at', 'DATETIME NULL AFTER cancelled_by');
$this->addColumn('supplier_refund_claims', 'reversal_reason', 'TEXT NULL AFTER reversal_journal_id');
$this->addColumn('supplier_debit_notes', 'cancelled_by', 'INT NULL AFTER reversal_journal_id');
$this->addColumn('supplier_debit_notes', 'cancelled_at', 'DATETIME NULL AFTER cancelled_by');
$this->addColumn('supplier_debit_notes', 'reversal_reason', 'TEXT NULL AFTER cancelled_at');
$this->db->query("CREATE TABLE IF NOT EXISTS supplier_refund_status_history (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
supplier_refund_claim_id BIGINT UNSIGNED NOT NULL,
from_status VARCHAR(30) NULL,
to_status VARCHAR(30) NOT NULL,
notes TEXT NULL,
user_id INT NULL,
ip_address VARCHAR(45) NULL,
session_id VARCHAR(128) NULL,
created_at DATETIME NOT NULL,
KEY idx_refund_status_history(supplier_refund_claim_id,created_at),
CONSTRAINT fk_refund_status_history_header FOREIGN KEY(supplier_refund_claim_id) REFERENCES supplier_refund_claims(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$this->db->query("CREATE TABLE IF NOT EXISTS supplier_debit_note_status_history (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
supplier_debit_note_id BIGINT UNSIGNED NOT NULL,
from_status VARCHAR(30) NULL,
to_status VARCHAR(30) NOT NULL,
notes TEXT NULL,
user_id INT NULL,
ip_address VARCHAR(45) NULL,
session_id VARCHAR(128) NULL,
created_at DATETIME NOT NULL,
KEY idx_debit_note_status_history(supplier_debit_note_id,created_at),
CONSTRAINT fk_debit_note_status_history_header FOREIGN KEY(supplier_debit_note_id) REFERENCES supplier_debit_notes(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$this->seedExistingHistory();
}
private function addColumn($table, $name, $definition)
{
if (!$this->db->field_exists($name, $table)) {
$this->db->query("ALTER TABLE `{$table}` ADD `{$name}` {$definition}");
}
}
private function seedExistingHistory()
{
$refunds = $this->db->get('supplier_refund_claims')->result();
foreach ($refunds as $row) {
if ($this->db->where('supplier_refund_claim_id', $row->id)->count_all_results('supplier_refund_status_history')) continue;
$this->db->insert('supplier_refund_status_history', array(
'supplier_refund_claim_id'=>$row->id,
'from_status'=>null,
'to_status'=>$row->status,
'notes'=>'Snapshot status awal saat aktivasi audit workflow Refund Supplier.',
'user_id'=>$row->created_by,
'created_at'=>$row->created_at ?: date('Y-m-d H:i:s')
));
}
$notes = $this->db->get('supplier_debit_notes')->result();
foreach ($notes as $row) {
if ($this->db->where('supplier_debit_note_id', $row->id)->count_all_results('supplier_debit_note_status_history')) continue;
$this->db->insert('supplier_debit_note_status_history', array(
'supplier_debit_note_id'=>$row->id,
'from_status'=>null,
'to_status'=>$row->status,
'notes'=>'Snapshot status awal saat aktivasi audit workflow Debit Note Supplier.',
'user_id'=>$row->created_by,
'created_at'=>$row->created_at ?: date('Y-m-d H:i:s')
));
}
}
public function down()
{
throw new RuntimeException('Rollback otomatis dinonaktifkan karena tabel menyimpan histori audit transaksi.');
}
}
@@ -0,0 +1,44 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
/**
* Reconcile receipt rows which were first backfilled from stock_logs and later
* posted again by the finalized PurchaseService. The ledger is immutable, so
* the duplicate backfill is neutralised with an auditable compensating row.
*/
class Migration_Reconcile_duplicate_purchase_receipt_ledger extends CI_Migration
{
public function up()
{
$this->db->query("INSERT IGNORE INTO inventory_ledger
(item_id,warehouse_id,bin_id,barcode_id,batch_id,movement_date,direction,qty,unit_cost,value,document_type,document_id,document_line_id,idempotency_key,created_at)
SELECT b.item_id,b.warehouse_id,b.bin_id,b.barcode_id,b.batch_id,b.movement_date,
'out',b.qty,b.unit_cost,b.value,'data_reconciliation',b.id,NULL,
CONCAT('REVERSE-DUPLICATE-PURCHASE-BACKFILL-',b.id),NOW()
FROM inventory_ledger b
WHERE b.direction='in'
AND b.document_type='goods_receipt'
AND b.idempotency_key LIKE 'PURCHASE-BACKFILL-STOCK-%'
AND EXISTS (
SELECT 1
FROM inventory_ledger p
WHERE p.document_type='goods_receipt'
AND p.document_id=b.document_id
AND p.item_id=b.item_id
AND p.warehouse_id=b.warehouse_id
AND p.direction=b.direction
AND ABS(p.qty-b.qty)<0.0001
AND p.idempotency_key LIKE 'PURCHASE-GOODS_RECEIPT-%'
)
AND NOT EXISTS (
SELECT 1
FROM inventory_ledger c
WHERE c.idempotency_key=CONCAT('REVERSE-DUPLICATE-PURCHASE-BACKFILL-',b.id)
)");
}
public function down()
{
// Inventory ledger bersifat immutable. Pemulihan harus melalui jurnal/mutasi koreksi baru.
}
}
@@ -0,0 +1,168 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
/**
* Finalisasi fondasi Persediaan.
*
* Migration bersifat additive. Data transaksi dan ledger lama tidak dihapus
* agar seluruh perubahan tetap dapat diaudit ketika diterapkan di production.
*/
class Migration_Finalize_professional_inventory extends CI_Migration
{
public function up()
{
$this->upgradeDocuments();
$this->upgradeMasters();
$this->upgradeReservations();
$this->createHistoryTables();
$this->addPerformanceIndexes();
$this->backfillCompany();
}
private function addColumn($table, $column, $definition)
{
// Do not use CI's schema cache during a long migration. A preceding
// ALTER can otherwise leave table_exists/field_exists stale.
if ($this->tableExists($table) && !$this->fieldExists($table, $column)) {
$this->db->query("ALTER TABLE `{$table}` ADD `{$column}` {$definition}");
}
}
private function addIndex($table, $name, array $columns, $unique = false)
{
if (!$this->tableExists($table)) return;
foreach ($columns as $column) if (!$this->fieldExists($table, $column)) return;
$exists = $this->db->query(
'SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name=? AND index_name=? LIMIT 1',
array($table, $name)
)->row();
if (!$exists) {
$quoted = '`'.implode('`,`', $columns).'`';
$this->db->query('ALTER TABLE `'.$table.'` ADD '.($unique ? 'UNIQUE ' : '').'KEY `'.$name.'` ('.$quoted.')');
}
}
private function tableExists($table)
{
return (bool) $this->db->query(
'SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name=? LIMIT 1',
array($table)
)->row();
}
private function fieldExists($table, $column)
{
return (bool) $this->db->query(
'SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name=? AND column_name=? LIMIT 1',
array($table, $column)
)->row();
}
private function upgradeDocuments()
{
$this->addColumn('stock_documents', 'company_id', 'BIGINT UNSIGNED NULL AFTER `id`');
$this->addColumn('stock_documents', 'source_type', 'VARCHAR(50) NULL AFTER `reason`');
$this->addColumn('stock_documents', 'source_id', 'BIGINT UNSIGNED NULL AFTER `source_type`');
$this->addColumn('stock_documents', 'submitted_by', 'INT NULL AFTER `created_by`');
$this->addColumn('stock_documents', 'submitted_at', 'DATETIME NULL AFTER `submitted_by`');
$this->addColumn('stock_documents', 'rejected_by', 'INT NULL AFTER `approved_at`');
$this->addColumn('stock_documents', 'rejected_at', 'DATETIME NULL AFTER `rejected_by`');
$this->addColumn('stock_documents', 'rejection_reason', 'TEXT NULL AFTER `rejected_at`');
$this->addColumn('stock_documents', 'reversal_of_id', 'BIGINT UNSIGNED NULL AFTER `rejection_reason`');
$this->addColumn('stock_documents', 'reversed_by', 'INT NULL AFTER `posted_at`');
$this->addColumn('stock_documents', 'reversed_at', 'DATETIME NULL AFTER `reversed_by`');
$this->addColumn('stock_documents', 'updated_by', 'INT NULL AFTER `created_at`');
$this->addColumn('stock_documents', 'updated_at', 'DATETIME NULL AFTER `updated_by`');
$this->addColumn('stock_document_lines', 'line_no', 'INT NOT NULL DEFAULT 1 AFTER `stock_document_id`');
$this->addColumn('stock_document_lines', 'movement_direction', "ENUM('in','out') NULL AFTER `qty`");
$this->addColumn('stock_document_lines', 'source_line_type', 'VARCHAR(50) NULL AFTER `reason`');
$this->addColumn('stock_document_lines', 'source_line_id', 'BIGINT UNSIGNED NULL AFTER `source_line_type`');
}
private function upgradeMasters()
{
$this->addColumn('warehouses', 'code', 'VARCHAR(30) NULL AFTER `company_id`');
$this->addColumn('warehouses', 'address', 'TEXT NULL AFTER `nama`');
$this->addColumn('warehouses', 'is_active', 'TINYINT(1) NOT NULL DEFAULT 1 AFTER `address`');
$this->addColumn('warehouse_bins', 'company_id', 'BIGINT UNSIGNED NULL AFTER `id`');
$this->addColumn('warehouse_bins', 'updated_at', 'DATETIME NULL AFTER `is_active`');
$this->addColumn('inventory_batches', 'company_id', 'BIGINT UNSIGNED NULL AFTER `id`');
$this->addColumn('inventory_batches', 'is_active', 'TINYINT(1) NOT NULL DEFAULT 1 AFTER `supplier_id`');
$this->addColumn('inventory_batches', 'updated_at', 'DATETIME NULL AFTER `created_at`');
$this->addColumn('kode_barang', 'company_id', 'BIGINT UNSIGNED NULL AFTER `id`');
$this->addColumn('kode_barang', 'is_active', 'TINYINT(1) NOT NULL DEFAULT 1');
}
private function upgradeReservations()
{
$this->addColumn('stock_reservations', 'company_id', 'BIGINT UNSIGNED NULL AFTER `id`');
$this->addColumn('stock_reservations', 'notes', 'TEXT NULL AFTER `expires_at`');
$this->addColumn('stock_reservations', 'updated_by', 'INT NULL AFTER `created_by`');
$this->addColumn('stock_reservations', 'updated_at', 'DATETIME NULL AFTER `created_at`');
$this->addColumn('stock_reservations', 'completed_at', 'DATETIME NULL AFTER `updated_at`');
$this->addColumn('inventory_reconciliations', 'company_id', 'BIGINT UNSIGNED NULL AFTER `id`');
$this->addColumn('inventory_reconciliations', 'stock_log_value', 'DECIMAL(18,2) NULL AFTER `ledger_value`');
$this->addColumn('inventory_reconciliations', 'barcode_variance_count', 'INT NOT NULL DEFAULT 0 AFTER `difference`');
$this->addColumn('inventory_reconciliations', 'cache_variance_count', 'INT NOT NULL DEFAULT 0 AFTER `barcode_variance_count`');
}
private function createHistoryTables()
{
$this->db->query("CREATE TABLE IF NOT EXISTS stock_document_status_history(
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
stock_document_id BIGINT UNSIGNED NOT NULL,
from_status VARCHAR(30) NULL,
to_status VARCHAR(30) NOT NULL,
notes TEXT NULL,
user_id INT NULL,
created_at DATETIME NOT NULL,
KEY idx_stock_document_history(stock_document_id,created_at),
CONSTRAINT fk_stock_document_history FOREIGN KEY(stock_document_id) REFERENCES stock_documents(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$this->db->query("CREATE TABLE IF NOT EXISTS stock_reservation_status_history(
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
stock_reservation_id BIGINT UNSIGNED NOT NULL,
from_status VARCHAR(30) NULL,
to_status VARCHAR(30) NOT NULL,
qty DECIMAL(18,4) NOT NULL DEFAULT 0,
notes TEXT NULL,
user_id INT NULL,
created_at DATETIME NOT NULL,
KEY idx_stock_reservation_history(stock_reservation_id,created_at),
CONSTRAINT fk_stock_reservation_history FOREIGN KEY(stock_reservation_id) REFERENCES stock_reservations(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
}
private function addPerformanceIndexes()
{
$this->addIndex('stock_documents', 'idx_stock_doc_company_action', array('company_id','status','document_date','id'));
$this->addIndex('stock_documents', 'idx_stock_doc_source', array('company_id','source_type','source_id'));
$this->addIndex('stock_documents', 'idx_stock_doc_reversal', array('reversal_of_id'));
$this->addIndex('stock_document_lines', 'idx_stock_doc_line_item', array('stock_document_id','item_id','id'));
$this->addIndex('stock_reservations', 'idx_stock_reservation_action', array('company_id','status','expires_at','id'));
$this->addIndex('warehouse_bins', 'idx_warehouse_bin_active', array('company_id','warehouse_id','is_active','code'));
$this->addIndex('inventory_batches', 'idx_inventory_batch_active', array('company_id','item_id','is_active','expiry_date'));
$this->addIndex('item_barcodes', 'idx_item_barcode_lookup', array('item_id','warehouse_id','status','id'));
$this->addIndex('stock_logs', 'idx_stock_log_item_wh_ref', array('item_id','warehouse_id','ref_type','ref_id'));
$this->addIndex('transaction_attachments', 'idx_attachment_module_entity', array('module','entity_type','entity_id','id'));
$this->addIndex('kode_barang', 'idx_item_code_company_active', array('company_id','is_active','kode_barang'));
}
private function backfillCompany()
{
$fallback = $this->db->select_min('id')->get('companies')->row();
$companyId = $fallback ? (int) $fallback->id : 1;
$this->db->query("UPDATE warehouse_bins b JOIN warehouses w ON w.id=b.warehouse_id SET b.company_id=COALESCE(b.company_id,w.company_id,?) WHERE b.company_id IS NULL", array($companyId));
$this->db->query("UPDATE inventory_batches b JOIN items i ON i.id=b.item_id SET b.company_id=COALESCE(b.company_id,i.company_id,?) WHERE b.company_id IS NULL", array($companyId));
$this->db->query("UPDATE stock_reservations r JOIN items i ON i.id=r.item_id SET r.company_id=COALESCE(r.company_id,i.company_id,?) WHERE r.company_id IS NULL", array($companyId));
$this->db->query("UPDATE stock_documents d LEFT JOIN stock_document_lines l ON l.stock_document_id=d.id LEFT JOIN items i ON i.id=l.item_id SET d.company_id=COALESCE(d.company_id,i.company_id,?) WHERE d.company_id IS NULL", array($companyId));
$this->db->query("UPDATE inventory_reconciliations SET company_id=? WHERE company_id IS NULL", array($companyId));
$this->db->query("UPDATE kode_barang k LEFT JOIN items i ON i.kode_id=k.id SET k.company_id=COALESCE(k.company_id,i.company_id,?) WHERE k.company_id IS NULL", array($companyId));
$this->db->query("UPDATE warehouses SET code=CONCAT('GD-',LPAD(id,3,'0')) WHERE code IS NULL OR code='' ");
}
public function down()
{
throw new RuntimeException('Rollback finalisasi Persediaan wajib menggunakan backup database.');
}
}
@@ -0,0 +1,18 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
/** Development-safe metadata repair. No quantity, barcode, journal, or ledger is deleted. */
class Migration_Reconcile_inventory_company_and_cost extends CI_Migration
{
public function up()
{
$company=$this->db->select_min('id')->get('companies')->row();$companyId=$company?(int)$company->id:1;
$this->db->query('UPDATE items SET company_id=? WHERE company_id IS NULL',array($companyId));
$this->db->query('UPDATE warehouses SET company_id=? WHERE company_id IS NULL',array($companyId));
$this->db->query("UPDATE stock_logs s JOIN inventory_ledger l ON BINARY l.idempotency_key=BINARY s.idempotency_key SET s.unit_cost=l.unit_cost WHERE(s.unit_cost IS NULL OR s.unit_cost=0) AND s.idempotency_key IS NOT NULL");
$this->db->query("UPDATE stock_logs s JOIN inventory_ledger l ON BINARY l.idempotency_key=BINARY CONCAT('LEGACY-STOCK-',s.id) SET s.unit_cost=l.unit_cost WHERE s.unit_cost IS NULL OR s.unit_cost=0");
$this->db->query("UPDATE stock_logs s JOIN items i ON i.id=s.item_id SET s.unit_cost=i.harga_beli WHERE(s.unit_cost IS NULL OR s.unit_cost=0) AND i.harga_beli>0");
$this->db->query("INSERT INTO stock_document_status_history(stock_document_id,from_status,to_status,notes,user_id,created_at) SELECT d.id,NULL,d.status,'Backfill status dokumen sebelum finalisasi Persediaan',d.created_by,d.created_at FROM stock_documents d WHERE NOT EXISTS(SELECT 1 FROM stock_document_status_history h WHERE h.stock_document_id=d.id)");
}
public function down(){throw new RuntimeException('Gunakan backup untuk mengembalikan metadata inventory legacy.');}
}
@@ -0,0 +1,30 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
/**
* Menyelaraskan metadata barang dalam penguasaan teknisi.
* Tidak mengubah qty ledger, stock log, harga, atau jurnal.
*/
class Migration_Harden_technician_inventory_custody extends CI_Migration
{
public function up()
{
if (!$this->db->table_exists('item_technician')) return;
if (!$this->db->field_exists('company_id', 'item_technician')) {
$this->db->query('ALTER TABLE item_technician ADD company_id BIGINT UNSIGNED NULL AFTER user_id');
}
$company=$this->db->select_min('id')->get('companies')->row();$companyId=$company?(int)$company->id:1;
$this->db->query('UPDATE item_technician it JOIN items i ON i.id=it.item_id SET it.company_id=COALESCE(it.company_id,i.company_id,?) WHERE it.company_id IS NULL',array($companyId));
$index=$this->db->query("SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='item_technician' AND index_name='idx_item_technician_company_status' LIMIT 1")->row();
if(!$index)$this->db->query('ALTER TABLE item_technician ADD KEY idx_item_technician_company_status(company_id,status,user_id,barcode)');
// Assignment aktif lama adalah bukti penguasaan. Status available hanya
// diselaraskan bila tidak mempunyai reservasi aktif atau reservasi retur.
$this->db->query("UPDATE item_barcodes ib JOIN item_technician it ON it.barcode=ib.barcode AND it.status='active' SET ib.status='installed',ib.version=COALESCE(ib.version,0)+1 WHERE ib.status='available' AND ib.qty_sisa>0 AND COALESCE(ib.reserved_qty,0)=0 AND COALESCE(ib.return_reserved_qty,0)=0");
}
public function down()
{
throw new RuntimeException('Gunakan backup untuk mengembalikan metadata penguasaan barang teknisi.');
}
}
@@ -0,0 +1,203 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Finalize_fixed_assets extends CI_Migration
{
private function column($table, $column, $definition)
{
if ($this->db->table_exists($table) && !$this->db->field_exists($column, $table)) {
$this->db->query("ALTER TABLE `{$table}` ADD `{$column}` {$definition}");
}
}
private function indexExists($table, $name)
{
return $this->db->query("SHOW INDEX FROM `{$table}` WHERE Key_name=?", array($name))->num_rows() > 0;
}
private function addIndex($table, $name, $columns, $unique = false)
{
if ($this->db->table_exists($table) && !$this->indexExists($table, $name)) {
$this->db->query('ALTER TABLE `'.$table.'` ADD '.($unique ? 'UNIQUE ' : '').'KEY `'.$name.'` ('.$columns.')');
}
}
public function up()
{
$this->db->query('DROP TRIGGER IF EXISTS trg_asset_event_no_update');
$this->db->query('DROP TRIGGER IF EXISTS trg_asset_event_no_delete');
$assetColumns = array(
'document_no' => 'VARCHAR(60) NULL',
'workflow_status' => "ENUM('draft','submitted','approved','rejected','capitalized') NOT NULL DEFAULT 'capitalized'",
'source_reference_no' => 'VARCHAR(120) NULL',
'supplier_id' => 'BIGINT UNSIGNED NULL',
'source_journal_mode' => "ENUM('post_journal','linked_journal','no_journal') NOT NULL DEFAULT 'no_journal'",
'source_journal_id' => 'INT NULL',
'acquisition_journal_id' => 'INT NULL',
'warehouse_id' => 'INT NULL',
'bin_id' => 'BIGINT UNSIGNED NULL',
'responsible_employee_id' => 'BIGINT NULL',
'department_id' => 'BIGINT NULL',
'project_id' => 'BIGINT UNSIGNED NULL',
'cost_center_id' => 'BIGINT UNSIGNED NULL',
'idempotency_key' => 'VARCHAR(120) NULL',
'created_by' => 'INT NULL',
'submitted_by' => 'INT NULL',
'submitted_at' => 'DATETIME NULL',
'approved_by' => 'INT NULL',
'approved_at' => 'DATETIME NULL',
'rejected_by' => 'INT NULL',
'rejected_at' => 'DATETIME NULL',
'rejection_reason' => 'VARCHAR(500) NULL',
'capitalized_by' => 'INT NULL',
'capitalized_at' => 'DATETIME NULL',
'updated_by' => 'INT NULL',
'updated_at' => 'DATETIME NULL'
);
foreach ($assetColumns as $column => $definition) $this->column('assets', $column, $definition);
$this->db->query("ALTER TABLE assets MODIFY source_type ENUM('purchase','warehouse','manual','opening','donation','other') NOT NULL DEFAULT 'manual'");
$this->db->query("UPDATE assets SET company_id=COALESCE(company_id,1),document_no=COALESCE(NULLIF(document_no,''),CONCAT('LEGACY-AST-',id)),workflow_status=COALESCE(NULLIF(workflow_status,''),'capitalized'),idempotency_key=COALESCE(NULLIF(idempotency_key,''),CONCAT('LEGACY-ASSET-',id)),created_by=COALESCE(created_by,1),capitalized_at=COALESCE(capitalized_at,created_at),capitalized_by=COALESCE(capitalized_by,created_by,1)");
$this->addIndex('assets', 'uq_asset_document_company', '`company_id`,`document_no`', true);
$this->addIndex('assets', 'uq_asset_idempotency', '`idempotency_key`', true);
$this->addIndex('assets', 'idx_asset_register_filter', '`company_id`,`workflow_status`,`lifecycle_status`,`category_id`,`lokasi_asset_id`,`tanggal_perolehan`');
$this->addIndex('assets', 'idx_asset_source_trace', '`company_id`,`source_type`,`source_id`');
$this->addIndex('assets', 'idx_asset_inventory_trace', '`item_id`,`barcode_id`,`warehouse_id`');
foreach (array(
'company_id' => 'BIGINT UNSIGNED NULL',
'updated_at' => 'DATETIME NULL',
'updated_by' => 'INT NULL'
) as $column => $definition) $this->column('asset_categories', $column, $definition);
if ($this->indexExists('asset_categories', 'uq_asset_category_code')) $this->db->query('ALTER TABLE asset_categories DROP INDEX uq_asset_category_code');
$this->db->query('UPDATE asset_categories SET company_id=COALESCE(company_id,1)');
$this->addIndex('asset_categories', 'uq_asset_category_company_code', '`company_id`,`code`', true);
$this->addIndex('asset_categories', 'idx_asset_category_active', '`company_id`,`is_active`,`name`');
foreach (array(
'company_id' => 'BIGINT UNSIGNED NULL',
'code' => 'VARCHAR(30) NULL',
'address' => 'TEXT NULL',
'responsible_employee_id' => 'BIGINT NULL',
'is_active' => 'TINYINT(1) NOT NULL DEFAULT 1',
'updated_at' => 'DATETIME NULL',
'updated_by' => 'INT NULL'
) as $column => $definition) $this->column('lokasi_asset', $column, $definition);
$this->db->query("UPDATE lokasi_asset SET company_id=COALESCE(company_id,1),code=COALESCE(NULLIF(code,''),CONCAT('LOC-',LPAD(id,4,'0'))),is_active=COALESCE(is_active,1)");
$this->addIndex('lokasi_asset', 'uq_asset_location_company_code', '`company_id`,`code`', true);
$this->addIndex('lokasi_asset', 'idx_asset_location_active', '`company_id`,`is_active`,`nama`');
foreach (array(
'company_id' => 'BIGINT UNSIGNED NULL',
'reversal_of_event_id' => 'BIGINT UNSIGNED NULL'
) as $column => $definition) $this->column('asset_events', $column, $definition);
$this->db->query("ALTER TABLE asset_events MODIFY event_type ENUM('acquisition','capitalization','addition','transfer','responsibility_change','maintenance','depreciation','impairment','revaluation','disposal','lost','damaged','opname_adjustment','reversal') NOT NULL");
$this->db->query('UPDATE asset_events e JOIN assets a ON a.id=e.asset_id SET e.company_id=COALESCE(e.company_id,a.company_id,1)');
$this->addIndex('asset_events', 'idx_asset_event_company_date', '`company_id`,`event_date`,`asset_id`');
$this->addIndex('asset_events', 'idx_asset_event_journal', '`journal_id`');
$this->addIndex('asset_events', 'idx_asset_event_reversal', '`reversal_of_event_id`');
$this->column('asset_depreciation_schedule', 'company_id', 'BIGINT UNSIGNED NULL');
$this->column('asset_depreciation_schedule', 'reversal_journal_id', 'INT NULL');
$this->column('asset_depreciation_schedule', 'reversed_at', 'DATETIME NULL');
$this->db->query('UPDATE asset_depreciation_schedule s JOIN assets a ON a.id=s.asset_id SET s.company_id=COALESCE(s.company_id,a.company_id,1)');
$this->addIndex('asset_depreciation_schedule', 'idx_asset_dep_company_period', '`company_id`,`period`,`status`,`asset_id`');
foreach (array(
'company_id' => 'BIGINT UNSIGNED NULL',
'maintenance_no' => 'VARCHAR(60) NULL',
'status' => "ENUM('posted','cancelled') NOT NULL DEFAULT 'posted'",
'journal_id' => 'INT NULL',
'asset_event_id' => 'BIGINT UNSIGNED NULL',
'idempotency_key' => 'VARCHAR(120) NULL'
) as $column => $definition) $this->column('asset_maintenance', $column, $definition);
$this->db->query("UPDATE asset_maintenance m JOIN assets a ON a.id=m.asset_id SET m.company_id=COALESCE(m.company_id,a.company_id,1),m.maintenance_no=COALESCE(NULLIF(m.maintenance_no,''),CONCAT('LEGACY-MNT-',m.id)),m.idempotency_key=COALESCE(NULLIF(m.idempotency_key,''),CONCAT('LEGACY-MNT-',m.id))");
$this->addIndex('asset_maintenance', 'uq_asset_maintenance_no', '`company_id`,`maintenance_no`', true);
$this->addIndex('asset_maintenance', 'uq_asset_maintenance_key', '`idempotency_key`', true);
$this->addIndex('asset_maintenance', 'idx_asset_maintenance_due', '`company_id`,`next_due_date`,`status`');
foreach (array(
'company_id' => 'BIGINT UNSIGNED NULL',
'snapshot_at' => 'DATETIME NULL',
'submitted_by' => 'INT NULL',
'submitted_at' => 'DATETIME NULL',
'rejected_by' => 'INT NULL',
'rejected_at' => 'DATETIME NULL',
'rejection_reason' => 'VARCHAR(500) NULL',
'posted_by' => 'INT NULL',
'posted_at' => 'DATETIME NULL',
'idempotency_key' => 'VARCHAR(120) NULL'
) as $column => $definition) $this->column('asset_opnames', $column, $definition);
$this->db->query("UPDATE asset_opnames SET company_id=COALESCE(company_id,1),snapshot_at=COALESCE(snapshot_at,NOW()),idempotency_key=COALESCE(NULLIF(idempotency_key,''),CONCAT('LEGACY-OPNAME-',id))");
$this->addIndex('asset_opnames', 'uq_asset_opname_key', '`idempotency_key`', true);
$this->addIndex('asset_opnames', 'idx_asset_opname_company_status', '`company_id`,`status`,`opname_date`');
foreach (array(
'company_id' => 'BIGINT UNSIGNED NULL',
'expected_location_id' => 'INT NULL',
'is_found' => 'TINYINT(1) NOT NULL DEFAULT 1',
'checked_at' => 'DATETIME NULL',
'checked_by' => 'INT NULL'
) as $column => $definition) $this->column('asset_opname_lines', $column, $definition);
$this->db->query('UPDATE asset_opname_lines l JOIN asset_opnames o ON o.id=l.asset_opname_id JOIN assets a ON a.id=l.asset_id SET l.company_id=COALESCE(l.company_id,o.company_id,a.company_id,1),l.expected_location_id=COALESCE(l.expected_location_id,a.lokasi_asset_id)');
$this->addIndex('asset_opname_lines', 'idx_asset_opname_line_company', '`company_id`,`asset_opname_id`,`asset_id`');
if ($this->db->table_exists('asset_reconciliations')) {
$this->column('asset_reconciliations', 'company_id', 'BIGINT UNSIGNED NULL');
$this->db->query('UPDATE asset_reconciliations SET company_id=COALESCE(company_id,1)');
$this->addIndex('asset_reconciliations', 'idx_asset_recon_company_date', '`company_id`,`as_of_date`,`status`');
}
$this->db->query("CREATE TABLE IF NOT EXISTS asset_workflow_history (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
company_id BIGINT UNSIGNED NOT NULL,
asset_id INT NOT NULL,
from_status VARCHAR(30) NULL,
to_status VARCHAR(30) NOT NULL,
notes VARCHAR(500) NULL,
user_id INT NULL,
created_at DATETIME NOT NULL,
KEY idx_asset_workflow (company_id,asset_id,id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$this->db->query("CREATE TABLE IF NOT EXISTS asset_responsibility_history (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
company_id BIGINT UNSIGNED NOT NULL,
asset_id INT NOT NULL,
effective_date DATE NOT NULL,
from_employee_id BIGINT NULL,
to_employee_id BIGINT NULL,
notes VARCHAR(500) NOT NULL,
asset_event_id BIGINT UNSIGNED NULL,
created_by INT NULL,
created_at DATETIME NOT NULL,
KEY idx_asset_responsibility (company_id,asset_id,effective_date,id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$this->column('transaction_attachments', 'company_id', 'BIGINT UNSIGNED NULL');
$this->db->query("UPDATE transaction_attachments t JOIN assets a ON t.entity_type='asset' AND t.entity_id=a.id SET t.company_id=COALESCE(t.company_id,a.company_id,1)");
$this->db->query("UPDATE transaction_attachments t JOIN asset_maintenance m ON t.entity_type='asset_maintenance' AND t.entity_id=m.id SET t.company_id=COALESCE(t.company_id,m.company_id,1)");
$this->db->query("UPDATE transaction_attachments t JOIN asset_opnames o ON t.entity_type='asset_opname' AND t.entity_id=o.id SET t.company_id=COALESCE(t.company_id,o.company_id,1)");
$this->addIndex('transaction_attachments', 'idx_attachment_company_entity', '`company_id`,`entity_type`,`entity_id`,`id`');
$this->db->query("CREATE TABLE IF NOT EXISTS asset_report_snapshots (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
company_id BIGINT UNSIGNED NOT NULL,
report_type VARCHAR(40) NOT NULL,
as_of_date DATE NOT NULL,
payload_json LONGTEXT NOT NULL,
created_by INT NULL,
created_at DATETIME NOT NULL,
KEY idx_asset_snapshot (company_id,report_type,as_of_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$this->db->query("INSERT IGNORE INTO asset_workflow_history(company_id,asset_id,from_status,to_status,notes,user_id,created_at) SELECT COALESCE(company_id,1),id,NULL,workflow_status,'Backfill status aset legacy',COALESCE(created_by,1),COALESCE(created_at,NOW()) FROM assets");
$this->db->query("CREATE TRIGGER trg_asset_event_no_update BEFORE UPDATE ON asset_events FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Asset event is immutable'");
$this->db->query("CREATE TRIGGER trg_asset_event_no_delete BEFORE DELETE ON asset_events FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Asset event is immutable'");
}
public function down()
{
throw new RuntimeException('Rollback finalisasi Aset Tetap wajib melalui restore backup database.');
}
}
@@ -0,0 +1,37 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Fixed_asset_decimal_values extends CI_Migration
{
public function up()
{
if (!$this->db->table_exists('assets')) return;
if ($this->db->table_exists('legacy_migration_adjustments')) {
$this->db->query("INSERT IGNORE INTO legacy_migration_adjustments(entity_type,entity_id,field_name,old_value,new_value,reason,adjusted_at)
SELECT 'asset',id,'qty',IF(qty IS NULL,'NULL',CAST(qty AS CHAR)),'1','Qty aset legacy kosong dinormalisasi menjadi satu unit',NOW()
FROM assets WHERE qty IS NULL OR qty<=0");
}
// Aset lama tanpa kolom qty berasal dari satu baris per aset, sehingga satu
// baris tersebut secara aman diperlakukan sebagai satu unit.
$this->db->query('UPDATE assets SET qty=1 WHERE qty IS NULL OR qty<=0');
$this->db->query('UPDATE assets SET harga_per_unit=COALESCE(nilai_perolehan,0) WHERE harga_per_unit IS NULL');
$this->db->query('UPDATE assets SET nilai_perolehan=COALESCE(harga_per_unit*qty,0) WHERE nilai_perolehan IS NULL');
$this->db->query('UPDATE assets SET nilai_residu=0 WHERE nilai_residu IS NULL');
$this->db->query('UPDATE assets SET penyusutan_per_bulan=0 WHERE penyusutan_per_bulan IS NULL');
$this->db->query('UPDATE assets SET akumulasi_penyusutan=0 WHERE akumulasi_penyusutan IS NULL');
$this->db->query('UPDATE assets SET nilai_buku=GREATEST(COALESCE(nilai_perolehan,0)-COALESCE(akumulasi_penyusutan,0),0) WHERE nilai_buku IS NULL');
$this->db->query("ALTER TABLE assets
MODIFY qty DECIMAL(18,4) NOT NULL DEFAULT 1,
MODIFY harga_per_unit DECIMAL(18,2) NOT NULL DEFAULT 0,
MODIFY nilai_perolehan DECIMAL(18,2) NOT NULL DEFAULT 0,
MODIFY nilai_residu DECIMAL(18,2) NOT NULL DEFAULT 0,
MODIFY penyusutan_per_bulan DECIMAL(18,2) NOT NULL DEFAULT 0,
MODIFY akumulasi_penyusutan DECIMAL(18,2) NOT NULL DEFAULT 0,
MODIFY nilai_buku DECIMAL(18,2) NOT NULL DEFAULT 0");
}
public function down()
{
// Presisi desimal tidak diturunkan kembali agar nilai accounting tidak rusak.
}
}
@@ -0,0 +1,30 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
/**
* Normalisasi akun aset lama yang ditandai sebagai header meskipun tidak
* mempunyai akun anak. Akun leaf tersebut memang digunakan sebagai tujuan
* jurnal kapitalisasi pada data development/legacy.
*/
class Migration_Repair_fixed_asset_posting_accounts extends CI_Migration
{
public function up()
{
if (!$this->db->table_exists('accounts')) return;
$this->db->query("UPDATE accounts parent
LEFT JOIN accounts child ON child.parent_id=parent.id
SET parent.is_header=0, parent.allow_posting=1
WHERE parent.tipe='asset'
AND parent.kode_akun IN ('114','115','116')
AND parent.is_active=1
AND parent.allow_posting=0
AND child.id IS NULL");
}
public function down()
{
// Tidak dikembalikan menjadi non-posting karena dapat membuat jurnal
// aset dan dokumen approved kembali tidak dapat diproses.
}
}
@@ -0,0 +1,66 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
/**
* Kategori aset lama menunjuk akun header sebagai akun harga perolehan.
* Buat akun detail/leaf agar kapitalisasi tetap mematuhi larangan posting
* langsung ke akun induk, lalu arahkan kategori dan dokumen belum final.
*/
class Migration_Create_fixed_asset_cost_leaf_accounts extends CI_Migration
{
public function up()
{
if (!$this->db->table_exists('asset_categories') || !$this->db->table_exists('accounts')) return;
$parents=$this->db->distinct()->select('a.*')->from('asset_categories c')
->join('accounts a','a.id=c.asset_account_id')
->where('a.allow_posting',0)->get()->result();
foreach($parents as$parent){
$leaf=$this->db->where(array(
'parent_id'=>(int)$parent->id,
'company_id'=>(int)$parent->company_id,
'is_active'=>1,
'allow_posting'=>1,
'is_kontra'=>0
))->order_by('id')->limit(1)->get('accounts')->row();
if(!$leaf){
$base=preg_replace('/[^A-Za-z0-9]/','',(string)$parent->kode_akun);
$suffix=1;
do{$code=$base.str_pad($suffix++,2,'0',STR_PAD_LEFT);$exists=$this->db->get_where('accounts',array('company_id'=>(int)$parent->company_id,'kode_akun'=>$code))->row();}while($exists&&$suffix<100);
if($exists)throw new RuntimeException('Tidak dapat membuat kode akun detail untuk '.$parent->kode_akun.'.');
$this->db->insert('accounts',array(
'kode_akun'=>$code,
'nama_akun'=>'Harga Perolehan '.$parent->nama_akun,
'tipe'=>$parent->tipe,
'posisi'=>'debit',
'kategori'=>$parent->kategori,
'is_kontra'=>0,
'is_active'=>1,
'parent_id'=>(int)$parent->id,
'sub_tipe'=>$parent->sub_tipe,
'priority'=>$parent->priority,
'is_header'=>0,
'allow_posting'=>1,
'company_id'=>(int)$parent->company_id,
'created_at'=>date('Y-m-d H:i:s')
));
$leaf=(object)array('id'=>(int)$this->db->insert_id());
}
$this->db->where(array('company_id'=>(int)$parent->company_id,'asset_account_id'=>(int)$parent->id))
->update('asset_categories',array('asset_account_id'=>(int)$leaf->id,'updated_at'=>date('Y-m-d H:i:s')));
if($this->db->table_exists('assets')){
$this->db->where(array('company_id'=>(int)$parent->company_id,'asset_account_id'=>(int)$parent->id))
->where_in('workflow_status',array('draft','submitted','approved'))
->update('assets',array('asset_account_id'=>(int)$leaf->id,'account_debit_id'=>(int)$leaf->id,'updated_at'=>date('Y-m-d H:i:s')));
}
}
}
public function down()
{
// Akun tidak dihapus agar histori dan referensi dokumen tetap utuh.
}
}
@@ -0,0 +1,35 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
/** Isi estimasi nilai draft aset gudang lama yang sebelumnya selalu disimpan nol. */
class Migration_Backfill_pending_warehouse_asset_values extends CI_Migration
{
public function up()
{
if(!$this->db->table_exists('assets')||!$this->db->table_exists('inventory_ledger'))return;
$this->db->query("UPDATE assets a
JOIN (
SELECT item_id,warehouse_id,
SUM(IF(direction='in',qty,-qty)) ledger_qty,
SUM(IF(direction='in',value,-value)) ledger_value
FROM inventory_ledger
GROUP BY item_id,warehouse_id
) l ON l.item_id=a.item_id AND l.warehouse_id=a.warehouse_id
SET a.harga_per_unit=ROUND(l.ledger_value/NULLIF(l.ledger_qty,0),4),
a.nilai_perolehan=ROUND((l.ledger_value/NULLIF(l.ledger_qty,0))*a.qty,2),
a.nilai_buku=ROUND((l.ledger_value/NULLIF(l.ledger_qty,0))*a.qty,2),
a.penyusutan_per_bulan=ROUND(CASE WHEN a.depreciation_method='declining_balance'
THEN ((l.ledger_value/NULLIF(l.ledger_qty,0))*a.qty)*(2/GREATEST(a.masa_manfaat,1))
ELSE GREATEST(((l.ledger_value/NULLIF(l.ledger_qty,0))*a.qty)-a.nilai_residu,0)/GREATEST(a.masa_manfaat,1) END,2),
a.updated_at=NOW()
WHERE a.source_type='warehouse'
AND a.workflow_status IN('draft','submitted','approved')
AND a.nilai_perolehan=0
AND l.ledger_qty>0");
}
public function down()
{
// Nilai hasil ledger tidak dikembalikan ke nol.
}
}
@@ -0,0 +1,24 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
/** Lengkapi tenant scope stock log yang dipakai Persediaan dan Aset Tetap. */
class Migration_Add_company_scope_to_stock_logs extends CI_Migration
{
public function up()
{
if(!$this->db->table_exists('stock_logs'))return;
if(!$this->db->field_exists('company_id','stock_logs')){
$this->db->query('ALTER TABLE stock_logs ADD company_id BIGINT UNSIGNED NULL AFTER id');
}
$fallback=$this->db->select_min('id')->get('companies')->row();
$companyId=$fallback?(int)$fallback->id:1;
$this->db->query('UPDATE stock_logs s LEFT JOIN items i ON i.id=s.item_id SET s.company_id=COALESCE(s.company_id,i.company_id,?) WHERE s.company_id IS NULL',array($companyId));
$index=$this->db->query("SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='stock_logs' AND index_name='idx_stock_log_company_item_wh' LIMIT 1")->row();
if(!$index)$this->db->query('ALTER TABLE stock_logs ADD KEY idx_stock_log_company_item_wh(company_id,item_id,warehouse_id,id)');
}
public function down()
{
// Tenant scope dan histori stock log tidak dihapus saat rollback.
}
}
@@ -0,0 +1,18 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
/** Metadata awal visibilitas akun; belum diterapkan ke permission maupun laporan. */
class Migration_Add_account_hidden_metadata extends CI_Migration
{
public function up()
{
if($this->db->table_exists('accounts')&&!$this->db->field_exists('is_hidden','accounts')){
$this->db->query('ALTER TABLE accounts ADD is_hidden TINYINT(1) NOT NULL DEFAULT 0 AFTER is_active');
}
}
public function down()
{
// Metadata tidak dihapus agar konfigurasi visibilitas tidak hilang.
}
}
@@ -0,0 +1,142 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Finalize_budget_dimensions extends CI_Migration
{
private function column($table, $column, $definition)
{
if ($this->tableExists($table) && !$this->fieldExists($table, $column)) {
$this->db->query("ALTER TABLE `{$table}` ADD `{$column}` {$definition}");
}
}
private function hasIndex($table, $name)
{
if (!$this->tableExists($table)) return false;
foreach ($this->db->query("SHOW INDEX FROM `{$table}`")->result() as $index) {
if ($index->Key_name === $name) return true;
}
return false;
}
private function tableExists($table)
{
return (bool) $this->db->query(
'SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name=? LIMIT 1',
array($table)
)->row();
}
private function fieldExists($table, $column)
{
return (bool) $this->db->query(
'SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name=? AND column_name=? LIMIT 1',
array($table, $column)
)->row();
}
public function up()
{
foreach (array(
'company_id' => 'BIGINT UNSIGNED NULL AFTER `id`',
'description' => 'TEXT NULL AFTER `control_mode`',
'revision_no' => 'INT NOT NULL DEFAULT 1 AFTER `version_no`',
'parent_budget_id' => 'BIGINT UNSIGNED NULL AFTER `revision_no`',
'submitted_by' => 'INT NULL AFTER `created_by`',
'submitted_at' => 'DATETIME NULL AFTER `submitted_by`',
'rejected_by' => 'INT NULL AFTER `approved_at`',
'rejected_at' => 'DATETIME NULL AFTER `rejected_by`',
'rejection_reason' => 'TEXT NULL AFTER `rejected_at`',
'updated_by' => 'INT NULL AFTER `created_at`',
'updated_at' => 'DATETIME NULL AFTER `updated_by`'
) as $column => $definition) $this->column('budgets', $column, $definition);
foreach (array(
'company_id' => 'BIGINT UNSIGNED NULL AFTER `id`',
'salesperson_id' => 'BIGINT UNSIGNED NULL AFTER `profit_center_id`',
'updated_by' => 'INT NULL AFTER `notes`',
'created_at' => 'DATETIME NULL AFTER `updated_by`',
'updated_at' => 'DATETIME NULL AFTER `created_at`'
) as $column => $definition) $this->column('budget_lines', $column, $definition);
foreach (array(
'company_id' => 'BIGINT UNSIGNED NULL AFTER `id`',
'description' => 'VARCHAR(255) NULL AFTER `name`',
'source_type' => 'VARCHAR(40) NULL AFTER `parent_id`',
'source_id' => 'BIGINT UNSIGNED NULL AFTER `source_type`',
'created_by' => 'INT NULL AFTER `is_active`',
'updated_by' => 'INT NULL AFTER `created_by`',
'updated_at' => 'DATETIME NULL AFTER `created_at`'
) as $column => $definition) $this->column('business_dimensions', $column, $definition);
foreach (array('require_branch','require_department','require_project','require_cost_center','require_profit_center') as $column) {
$this->column('accounts', $column, 'TINYINT(1) NOT NULL DEFAULT 0 AFTER `is_hidden`');
}
foreach (array('branch_id','department_id','profit_center_id','salesperson_id') as $column) {
$this->column('purchase_order_lines', $column, 'BIGINT UNSIGNED NULL AFTER `budget_account_id`');
}
if (!$this->tableExists('budget_commitments')) {
$this->db->query("CREATE TABLE `budget_commitments` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`company_id` BIGINT UNSIGNED NOT NULL,
`source_type` VARCHAR(50) NOT NULL,
`source_id` BIGINT UNSIGNED NOT NULL,
`source_line_id` BIGINT UNSIGNED NULL,
`account_id` INT NOT NULL,
`period` CHAR(7) NOT NULL,
`branch_id` BIGINT UNSIGNED NULL,
`department_id` BIGINT UNSIGNED NULL,
`project_id` BIGINT UNSIGNED NULL,
`cost_center_id` BIGINT UNSIGNED NULL,
`profit_center_id` BIGINT UNSIGNED NULL,
`salesperson_id` BIGINT UNSIGNED NULL,
`original_amount` DECIMAL(18,2) NOT NULL DEFAULT 0,
`realized_amount` DECIMAL(18,2) NOT NULL DEFAULT 0,
`released_amount` DECIMAL(18,2) NOT NULL DEFAULT 0,
`status` ENUM('active','partially_realized','realized','released') NOT NULL DEFAULT 'active',
`created_at` DATETIME NOT NULL,
`updated_at` DATETIME NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_budget_commitment_source` (`company_id`,`source_type`,`source_line_id`),
KEY `idx_budget_commitment_report` (`company_id`,`period`,`account_id`,`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
}
if (!$this->tableExists('budget_audit_logs')) {
$this->db->query("CREATE TABLE `budget_audit_logs` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`company_id` BIGINT UNSIGNED NOT NULL,
`budget_id` BIGINT UNSIGNED NULL,
`entity_type` VARCHAR(40) NOT NULL,
`entity_id` BIGINT UNSIGNED NULL,
`action` VARCHAR(50) NOT NULL,
`old_values` LONGTEXT NULL,
`new_values` LONGTEXT NULL,
`reason` TEXT NULL,
`created_by` INT NULL,
`created_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_budget_audit_entity` (`company_id`,`entity_type`,`entity_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
}
if (!$this->hasIndex('budgets', 'idx_budget_company_year_status'))
$this->db->query('ALTER TABLE `budgets` ADD KEY `idx_budget_company_year_status` (`company_id`,`fiscal_year`,`status`)');
if (!$this->hasIndex('budget_lines', 'idx_budget_line_report'))
$this->db->query('ALTER TABLE `budget_lines` ADD KEY `idx_budget_line_report` (`company_id`,`period`,`account_id`)');
if (!$this->hasIndex('business_dimensions', 'idx_dimension_lookup'))
$this->db->query('ALTER TABLE `business_dimensions` ADD KEY `idx_dimension_lookup` (`company_id`,`dimension_type`,`is_active`,`name`)');
$this->db->query('UPDATE `budgets` SET `revision_no`=`version_no` WHERE `revision_no`=1 AND `version_no`>1');
if ($this->tableExists('k_departments')) {
$this->db->query("UPDATE business_dimensions bd JOIN k_departments d ON bd.dimension_type='department' AND bd.code=CONCAT('DEP-',d.id) SET bd.source_type='k_departments',bd.source_id=d.id,bd.name=d.department_name,bd.description=d.description WHERE bd.source_id IS NULL");
}
}
public function down()
{
throw new RuntimeException('Gunakan backup database untuk rollback finalisasi Budget & Dimensi.');
}
}
@@ -0,0 +1,126 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Finalize_sales_invoice_workflow extends CI_Migration
{
private function column($table,$column,$definition)
{
if($this->db->table_exists($table)&&!$this->db->field_exists($column,$table))
$this->db->query("ALTER TABLE `{$table}` ADD `{$column}` {$definition}");
}
private function index($table,$name,$columns,$unique=false)
{
if(!$this->db->table_exists($table))return;
foreach($this->db->query("SHOW INDEX FROM `{$table}`")->result()as$i)if($i->Key_name===$name)return;
$this->db->query("ALTER TABLE `{$table}` ADD ".($unique?'UNIQUE ':'')."KEY `{$name}` ({$columns})");
}
public function up()
{
foreach(array(
'company_id'=>'BIGINT UNSIGNED NULL',
'invoice_type'=>"ENUM('one_time','running') NOT NULL DEFAULT 'one_time'",
'period_start'=>'DATE NULL','period_end'=>'DATE NULL',
'recognition_policy'=>"ENUM('accrual','on_payment') NOT NULL DEFAULT 'accrual'",
'source_sales_order_id'=>'BIGINT UNSIGNED NULL','delivery_status'=>"VARCHAR(30) NOT NULL DEFAULT 'not_delivered'",
'finalized_at'=>'DATETIME NULL','finalized_by'=>'INT NULL','recognized_revenue'=>'DECIMAL(18,2) NOT NULL DEFAULT 0',
'recognized_cogs'=>'DECIMAL(18,2) NOT NULL DEFAULT 0','updated_by'=>'INT NULL','updated_at'=>'DATETIME NULL'
)as$c=>$d)$this->column('invoices',$c,$d);
foreach(array(
'company_id'=>'BIGINT UNSIGNED NULL','service_date'=>'DATE NULL','specification'=>'VARCHAR(255) NULL',
'unit'=>'VARCHAR(30) NULL','delivery_line_id'=>'BIGINT UNSIGNED NULL','net_amount'=>'DECIMAL(18,2) NOT NULL DEFAULT 0',
'recognized_revenue'=>'DECIMAL(18,2) NOT NULL DEFAULT 0','recognized_cogs'=>'DECIMAL(18,2) NOT NULL DEFAULT 0'
)as$c=>$d)$this->column('invoice_details',$c,$d);
foreach(array('company_id'=>'BIGINT UNSIGNED NULL','parent_document_id'=>'BIGINT UNSIGNED NULL','source_quotation_id'=>'BIGINT UNSIGNED NULL','updated_by'=>'INT NULL')as$c=>$d)$this->column('sales_documents',$c,$d);
foreach(array('company_id'=>'BIGINT UNSIGNED NULL','account_id'=>'INT NULL','specification'=>'VARCHAR(255) NULL','unit'=>'VARCHAR(30) NULL','discount_amount'=>'DECIMAL(18,2) NOT NULL DEFAULT 0')as$c=>$d)$this->column('sales_document_lines',$c,$d);
if(!$this->db->table_exists('sales_deliveries'))$this->db->query("CREATE TABLE sales_deliveries(
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NOT NULL,delivery_no VARCHAR(60) NOT NULL,
customer_id INT NOT NULL,sales_order_id BIGINT UNSIGNED NULL,invoice_id INT NULL,warehouse_id INT NOT NULL,
delivery_date DATE NOT NULL,address TEXT NULL,driver_name VARCHAR(120) NULL,vehicle_no VARCHAR(60) NULL,
courier VARCHAR(120) NULL,tracking_no VARCHAR(120) NULL,recipient_name VARCHAR(120) NULL,received_at DATETIME NULL,
notes TEXT NULL,status ENUM('draft','posted','partially_returned','returned','cancelled','reversed') NOT NULL DEFAULT 'draft',
journal_id INT NULL,reversal_journal_id INT NULL,created_by INT NOT NULL,created_at DATETIME NOT NULL,
posted_by INT NULL,posted_at DATETIME NULL,reversed_by INT NULL,reversed_at DATETIME NULL,reversal_reason TEXT NULL,
version INT NOT NULL DEFAULT 1,UNIQUE KEY uq_sales_delivery_no(company_id,delivery_no),
KEY idx_sales_delivery_customer(company_id,customer_id,delivery_date),KEY idx_sales_delivery_invoice(company_id,invoice_id,status)
)ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
if(!$this->db->table_exists('sales_delivery_lines'))$this->db->query("CREATE TABLE sales_delivery_lines(
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NOT NULL,sales_delivery_id BIGINT UNSIGNED NOT NULL,
line_no INT NOT NULL,item_id INT NOT NULL,invoice_detail_id INT NULL,description VARCHAR(255) NOT NULL,
specification VARCHAR(255) NULL,unit VARCHAR(30) NULL,qty DECIMAL(18,4) NOT NULL,returned_qty DECIMAL(18,4) NOT NULL DEFAULT 0,
unit_cost DECIMAL(18,2) NOT NULL DEFAULT 0,total_cost DECIMAL(18,2) NOT NULL DEFAULT 0,condition_notes VARCHAR(255) NULL,
stock_posted_at DATETIME NULL,stock_log_id BIGINT UNSIGNED NULL,created_at DATETIME NOT NULL,
UNIQUE KEY uq_sales_delivery_line(sales_delivery_id,line_no),KEY idx_sales_delivery_item(company_id,item_id)
)ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
if(!$this->db->table_exists('sales_delivery_barcodes'))$this->db->query("CREATE TABLE sales_delivery_barcodes(
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NOT NULL,sales_delivery_id BIGINT UNSIGNED NOT NULL,
sales_delivery_line_id BIGINT UNSIGNED NOT NULL,item_id INT NOT NULL,barcode_id INT NOT NULL,qty DECIMAL(18,4) NOT NULL,
returned_qty DECIMAL(18,4) NOT NULL DEFAULT 0,status ENUM('allocated','delivered','partially_returned','returned','reversed') NOT NULL DEFAULT 'allocated',
created_at DATETIME NOT NULL,UNIQUE KEY uq_delivery_barcode_line(sales_delivery_line_id,barcode_id),
KEY idx_delivery_barcode_trace(company_id,barcode_id,status)
)ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
if(!$this->db->table_exists('invoice_line_barcodes'))$this->db->query("CREATE TABLE invoice_line_barcodes(
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NOT NULL,invoice_id INT NOT NULL,
invoice_detail_id INT NOT NULL,sales_delivery_id BIGINT UNSIGNED NULL,sales_delivery_line_id BIGINT UNSIGNED NULL,
barcode_id INT NOT NULL,qty DECIMAL(18,4) NOT NULL,created_at DATETIME NOT NULL,
UNIQUE KEY uq_invoice_line_barcode(invoice_detail_id,barcode_id,sales_delivery_line_id),
KEY idx_invoice_barcode_trace(company_id,barcode_id,invoice_id)
)ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
if(!$this->db->table_exists('revenue_recognition_ledger'))$this->db->query("CREATE TABLE revenue_recognition_ledger(
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NOT NULL,invoice_id INT NOT NULL,
invoice_detail_id INT NULL,payment_id INT NULL,recognition_date DATE NOT NULL,revenue_amount DECIMAL(18,2) NOT NULL DEFAULT 0,
cogs_amount DECIMAL(18,2) NOT NULL DEFAULT 0,journal_id INT NOT NULL,idempotency_key VARCHAR(150) NOT NULL,
created_by INT NOT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_revenue_recognition_key(company_id,idempotency_key),
KEY idx_revenue_recognition_invoice(company_id,invoice_id,recognition_date)
)ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
if(!$this->db->table_exists('payment_sources'))$this->db->query("CREATE TABLE payment_sources(
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NOT NULL,payment_id INT NOT NULL,
account_id INT NOT NULL,amount DECIMAL(18,2) NOT NULL,created_at DATETIME NOT NULL,
UNIQUE KEY uq_payment_source(payment_id,account_id),KEY idx_payment_source_company(company_id,account_id)
)ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
if(!$this->db->table_exists('sales_returns'))$this->db->query("CREATE TABLE sales_returns(
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NOT NULL,return_no VARCHAR(60) NOT NULL,
customer_id INT NOT NULL,invoice_id INT NULL,sales_delivery_id BIGINT UNSIGNED NULL,return_date DATE NOT NULL,
reason TEXT NOT NULL,problem_category VARCHAR(60) NULL,resolution ENUM('restock','quarantine','damaged','replacement','credit_note','refund') NOT NULL,
status ENUM('draft','submitted','approved','rejected','received','posted','completed','cancelled','reversed') NOT NULL DEFAULT 'draft',
notes TEXT NULL,credit_note_id BIGINT UNSIGNED NULL,refund_id BIGINT UNSIGNED NULL,journal_id INT NULL,reversal_journal_id INT NULL,
created_by INT NOT NULL,created_at DATETIME NOT NULL,submitted_by INT NULL,submitted_at DATETIME NULL,
approved_by INT NULL,approved_at DATETIME NULL,rejected_by INT NULL,rejected_at DATETIME NULL,rejection_reason TEXT NULL,
received_by INT NULL,received_at DATETIME NULL,posted_by INT NULL,posted_at DATETIME NULL,version INT NOT NULL DEFAULT 1,
UNIQUE KEY uq_sales_return_no(company_id,return_no),KEY idx_sales_return_action(company_id,status,return_date)
)ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
if(!$this->db->table_exists('sales_return_lines'))$this->db->query("CREATE TABLE sales_return_lines(
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NOT NULL,sales_return_id BIGINT UNSIGNED NOT NULL,
sales_delivery_line_id BIGINT UNSIGNED NOT NULL,invoice_detail_id INT NULL,item_id INT NOT NULL,qty DECIMAL(18,4) NOT NULL,
unit_price DECIMAL(18,2) NOT NULL DEFAULT 0,net_amount DECIMAL(18,2) NOT NULL DEFAULT 0,tax_amount DECIMAL(18,2) NOT NULL DEFAULT 0,
unit_cost DECIMAL(18,2) NOT NULL DEFAULT 0,physical_condition ENUM('saleable','quarantine','damaged','lost') NOT NULL DEFAULT 'saleable',
notes VARCHAR(255) NULL,posted_at DATETIME NULL,created_at DATETIME NOT NULL,
KEY idx_sales_return_line(sales_return_id,item_id)
)ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
if(!$this->db->table_exists('sales_return_barcodes'))$this->db->query("CREATE TABLE sales_return_barcodes(
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NOT NULL,sales_return_id BIGINT UNSIGNED NOT NULL,
sales_return_line_id BIGINT UNSIGNED NOT NULL,sales_delivery_barcode_id BIGINT UNSIGNED NOT NULL,barcode_id INT NOT NULL,
qty DECIMAL(18,4) NOT NULL,status ENUM('requested','received','posted','reversed') NOT NULL DEFAULT 'requested',created_at DATETIME NOT NULL,
UNIQUE KEY uq_sales_return_delivery_barcode(sales_return_id,sales_delivery_barcode_id),
KEY idx_sales_return_barcode(company_id,barcode_id,status)
)ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
if(!$this->db->table_exists('sales_return_history'))$this->db->query("CREATE TABLE sales_return_history(
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NOT NULL,sales_return_id BIGINT UNSIGNED NOT NULL,
from_status VARCHAR(30) NULL,to_status VARCHAR(30) NOT NULL,notes TEXT NULL,user_id INT NOT NULL,created_at DATETIME NOT NULL,
KEY idx_sales_return_history(sales_return_id,id)
)ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$companyRow=$this->db->select('id')->order_by('id')->limit(1)->get('companies')->row();
if(!$companyRow)throw new RuntimeException('Master company belum tersedia; migration Penjualan dihentikan agar backfill tenant tidak salah.');
$company=(int)$companyRow->id;
$this->db->query("UPDATE invoices i LEFT JOIN customers c ON c.id=i.customer_id SET i.company_id=COALESCE(i.company_id,c.company_id,?) WHERE i.company_id IS NULL",array($company));
$this->db->query("UPDATE invoice_details d JOIN invoices i ON i.id=d.invoice_id SET d.company_id=i.company_id,d.service_date=COALESCE(d.service_date,d.tanggal,i.tanggal),d.net_amount=CASE WHEN d.net_amount=0 THEN GREATEST(d.subtotal-COALESCE(d.tax_amount,0),0) ELSE d.net_amount END WHERE d.company_id IS NULL OR d.service_date IS NULL");
if($this->db->table_exists('sales_documents'))$this->db->query("UPDATE sales_documents s LEFT JOIN customers c ON c.id=s.customer_id SET s.company_id=COALESCE(s.company_id,c.company_id,?) WHERE s.company_id IS NULL",array($company));
if($this->db->table_exists('sales_document_lines'))$this->db->query("UPDATE sales_document_lines l JOIN sales_documents s ON s.id=l.sales_document_id SET l.company_id=s.company_id WHERE l.company_id IS NULL");
$this->index('invoices','idx_invoice_company_workflow','`company_id`,`workflow_status`,`status`,`tanggal`');
$this->index('invoices','idx_invoice_company_customer_due','`company_id`,`customer_id`,`jatuh_tempo`,`sisa_piutang`');
$this->index('invoice_details','idx_invoice_detail_company_date','`company_id`,`invoice_id`,`service_date`,`items_id`');
$this->index('payments','idx_payment_company_customer','`company_id`,`customer_id`,`tanggal`');
}
public function down(){throw new RuntimeException('Gunakan backup database untuk rollback finalisasi Penjualan.');}
}
@@ -0,0 +1,15 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Complete_sales_document_metadata extends CI_Migration
{
private function col($t,$c,$d){if($this->db->table_exists($t)&&!$this->db->field_exists($c,$t))$this->db->query("ALTER TABLE `$t` ADD `$c` $d");}
public function up(){
foreach(array('created_by'=>'INT NULL','created_at'=>'DATETIME NULL','submitted_by'=>'INT NULL','submitted_at'=>'DATETIME NULL','approved_by'=>'INT NULL','approved_at'=>'DATETIME NULL','rejected_by'=>'INT NULL','rejected_at'=>'DATETIME NULL','rejection_reason'=>'TEXT NULL')as$c=>$d)$this->col('invoices',$c,$d);
foreach(array('company_id'=>'BIGINT UNSIGNED NULL','updated_at'=>'DATETIME NULL')as$c=>$d)$this->col('transaction_attachments',$c,$d);
if($this->db->table_exists('transaction_attachments')&&$this->db->field_exists('company_id','transaction_attachments')){
$company=$this->db->select('id')->order_by('id')->limit(1)->get('companies')->row();
if($company)$this->db->where('company_id IS NULL',null,false)->update('transaction_attachments',array('company_id'=>$company->id));
}
}
public function down(){throw new RuntimeException('Gunakan backup database untuk rollback metadata Penjualan.');}
}
@@ -0,0 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Expand_sales_return_barcode_status extends CI_Migration
{
public function up(){if($this->db->table_exists('item_barcodes'))$this->db->query("ALTER TABLE item_barcodes MODIFY status ENUM('pending','available','reserved','return_reserved','returned','replaced','closed','installed','sold_out','quarantine','damaged') NOT NULL DEFAULT 'available'");}
public function down(){throw new RuntimeException('Status barcode retur tidak dapat dipersempit tanpa rekonsiliasi data.');}
}
@@ -0,0 +1,17 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Scope_document_numbering_by_company extends CI_Migration
{
private function dropSingleColumnUnique($table,$column)
{
if(!$this->db->table_exists($table))return;$indexes=array();foreach($this->db->query("SHOW INDEX FROM `$table`")->result()as$i){if(!$i->Non_unique&&$i->Key_name!=='PRIMARY')$indexes[$i->Key_name][]=$i->Column_name;}foreach($indexes as$name=>$columns)if($columns===array($column))$this->db->query("ALTER TABLE `$table` DROP INDEX `$name`");
}
private function addUnique($table,$name,$columns){if(!$this->db->table_exists($table))return;foreach($this->db->query("SHOW INDEX FROM `$table`")->result()as$i)if($i->Key_name===$name)return;$this->db->query("ALTER TABLE `$table` ADD UNIQUE KEY `$name` ($columns)");}
public function up()
{
if($this->db->table_exists('document_sequences')){$company=$this->db->select('id')->order_by('id')->limit(1)->get('companies')->row();if($company)$this->db->where('company_id IS NULL',null,false)->update('document_sequences',array('company_id'=>$company->id));$this->dropSingleColumnUnique('document_sequences','document_type');foreach($this->db->query("SHOW INDEX FROM document_sequences")->result()as$i)if($i->Key_name==='uq_document_sequence'){$this->db->query('ALTER TABLE document_sequences DROP INDEX uq_document_sequence');break;}$this->addUnique('document_sequences','uq_document_sequence_company','`company_id`,`document_type`,`period_key`');}
$this->dropSingleColumnUnique('invoices','no_invoice');$this->addUnique('invoices','uq_invoice_company_no','`company_id`,`no_invoice`');
$this->dropSingleColumnUnique('sales_documents','document_no');$this->addUnique('sales_documents','uq_sales_document_company_no','`company_id`,`document_no`');
}
public function down(){throw new RuntimeException('Gunakan backup database untuk rollback numbering multi-company.');}
}
@@ -0,0 +1,55 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Finalize_invoice_editor extends CI_Migration
{
private function column($table, $column, $definition)
{
if ($this->db->table_exists($table) && !$this->db->field_exists($column, $table)) {
$this->db->query("ALTER TABLE `{$table}` ADD `{$column}` {$definition}");
}
}
private function index($table, $name, $columns, $unique = false)
{
if (!$this->db->table_exists($table)) return;
foreach ($this->db->query("SHOW INDEX FROM `{$table}`")->result() as $index) {
if ($index->Key_name === $name) return;
}
$this->db->query("ALTER TABLE `{$table}` ADD ".($unique ? 'UNIQUE ' : '')."KEY `{$name}` ({$columns})");
}
public function up()
{
$this->column('invoices', 'idempotency_key', 'VARCHAR(100) NULL AFTER `source_sales_order_id`');
$this->column('invoices', 'version', 'INT NOT NULL DEFAULT 1');
$this->column('invoice_details', 'line_type', "ENUM('inventory','service') NOT NULL DEFAULT 'inventory' AFTER `invoice_id`");
$this->column('invoice_details', 'idempotency_key', 'VARCHAR(100) NULL');
if ($this->db->table_exists('invoice_details') && $this->db->field_exists('line_type', 'invoice_details')) {
$this->db->query("UPDATE invoice_details SET line_type=IF(items_id IS NULL,'service','inventory')");
}
$this->index('invoices', 'uq_invoice_editor_idempotency', '`company_id`,`idempotency_key`', true);
$this->index('invoices', 'idx_invoice_list_filters', '`company_id`,`workflow_status`,`status`,`invoice_type`,`tanggal`');
$this->index('invoice_details', 'uq_invoice_line_idempotency', '`company_id`,`idempotency_key`', true);
$this->index('invoice_details', 'idx_invoice_line_page', '`company_id`,`invoice_id`,`service_date`,`id`');
$this->index('sales_deliveries', 'idx_delivery_draft_group', '`company_id`,`invoice_id`,`delivery_date`,`warehouse_id`,`status`');
if ($this->db->table_exists('roles')) {
foreach ($this->db->select('id,permissions')->get('roles')->result() as $role) {
$permissions = json_decode((string)$role->permissions, true);
if (!is_array($permissions) || empty($permissions['invoices']) || !is_array($permissions['invoices'])) continue;
if ((in_array('can_create', $permissions['invoices'], true) || in_array('can_update', $permissions['invoices'], true)) && !in_array('can_submit', $permissions['invoices'], true)) {
$permissions['invoices'][] = 'can_submit';
$this->db->where('id', $role->id)->update('roles', array('permissions'=>json_encode($permissions)));
}
}
}
}
public function down()
{
throw new RuntimeException('Gunakan backup database untuk rollback finalisasi editor Invoice.');
}
}
@@ -0,0 +1,22 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Finalize_sales_payment_permissions extends CI_Migration
{
private function index($table,$name,$columns)
{
if(!$this->db->table_exists($table))return;foreach($this->db->query("SHOW INDEX FROM `{$table}`")->result()as$i)if($i->Key_name===$name)return;$this->db->query("ALTER TABLE `{$table}` ADD KEY `{$name}` ({$columns})");
}
public function up()
{
if($this->db->table_exists('roles'))foreach($this->db->select('id,permissions,is_super_admin')->get('roles')->result()as$role){
if((int)$role->is_super_admin===1)continue;$permissions=json_decode((string)$role->permissions,true);if(!is_array($permissions))continue;$invoice=$permissions['invoices']??array();$cash=$permissions['cash_bank']??array();if(!is_array($invoice)||!is_array($cash))continue;
if(in_array('can_view',$invoice,true)&&in_array('can_post',$cash,true)&&!in_array('can_post',$invoice,true)){$permissions['invoices'][]='can_post';$this->db->where('id',$role->id)->update('roles',array('permissions'=>json_encode($permissions),'updated_at'=>date('Y-m-d H:i:s')));}
}
$this->index('payments','idx_customer_payment_list','`company_id`,`customer_id`,`status`,`tanggal`,`id`');
if($this->db->table_exists('payment_allocations')){$fields=$this->db->list_fields('payment_allocations');$columns=in_array('company_id',$fields,true)?'`company_id`,`invoice_id`,`payment_id`':'`invoice_id`,`payment_id`';$this->index('payment_allocations','idx_payment_allocation_invoice',$columns);}
}
public function down(){throw new RuntimeException('Gunakan backup database untuk rollback permission pembayaran Penjualan.');}
}
@@ -0,0 +1,144 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Reconcile_legacy_active_inventory_cost_layers extends CI_Migration
{
public function up()
{
if (!$this->db->table_exists('items')
|| !$this->db->table_exists('item_barcodes')
|| !$this->db->table_exists('inventory_ledger')
|| !$this->db->table_exists('inventory_cost_layers')) {
return;
}
$this->db->trans_begin();
try {
/*
* Barcode aktif lama sudah mewakili stok fisik, tetapi sebagian dibuat
* sebelum inventory ledger dan lapisan HPP diterapkan. Rekonsiliasi ini
* hanya membentuk subledger persediaan yang hilang. Jurnal GL sengaja
* tidak dibuat agar saldo awal persediaan produksi tidak terduplikasi;
* rekonsiliasi akun persediaan tetap harus ditinjau saat deploy produksi.
*/
$rows = $this->db->query(
"SELECT p.item_id,
p.warehouse_id,
p.physical_qty,
p.opening_date,
COALESCE(l.ledger_qty,0) ledger_qty,
COALESCE(c.layer_qty,0) layer_qty,
COALESCE(NULLIF(c.layer_unit_cost,0),NULLIF(i.harga_beli,0),0) unit_cost
FROM (
SELECT item_id,
warehouse_id,
SUM(qty_sisa) physical_qty,
MIN(DATE(COALESCE(activated_at,created_at,NOW()))) opening_date
FROM item_barcodes
WHERE status='available' AND qty_sisa>0
GROUP BY item_id,warehouse_id
) p
JOIN items i ON i.id=p.item_id
LEFT JOIN (
SELECT item_id,warehouse_id,SUM(IF(direction='in',qty,-qty)) ledger_qty
FROM inventory_ledger
GROUP BY item_id,warehouse_id
) l ON l.item_id=p.item_id AND l.warehouse_id=p.warehouse_id
LEFT JOIN (
SELECT item_id,
warehouse_id,
SUM(IF(status='open',remaining_qty,0)) layer_qty,
SUM(IF(status='open',remaining_qty*unit_cost,0))
/ NULLIF(SUM(IF(status='open',remaining_qty,0)),0) layer_unit_cost
FROM inventory_cost_layers
GROUP BY item_id,warehouse_id
) c ON c.item_id=p.item_id AND c.warehouse_id=p.warehouse_id
WHERE p.physical_qty>COALESCE(l.ledger_qty,0)+0.0001
OR p.physical_qty>COALESCE(c.layer_qty,0)+0.0001"
)->result();
$affectedItems = array();
foreach ($rows as $row) {
$physicalQty = round((float) $row->physical_qty, 4);
$ledgerQty = round((float) $row->ledger_qty, 4);
$layerQty = round((float) $row->layer_qty, 4);
$unitCost = round((float) $row->unit_cost, 4);
if ($unitCost < 0) {
throw new RuntimeException('Harga beli stok lama tidak valid untuk item #' . (int) $row->item_id . '.');
}
$ledgerShortage = round(max(0, $physicalQty - $ledgerQty), 4);
$sourceLedgerId = 0;
if ($ledgerShortage > 0.0001) {
$key = 'LEGACY-ACTIVE-STOCK-' . (int) $row->item_id . '-' . (int) $row->warehouse_id . '-20260908';
$existing = $this->db->select('id')->where('idempotency_key', $key)->get('inventory_ledger')->row();
if ($existing) {
$sourceLedgerId = (int) $existing->id;
} else {
$this->db->insert('inventory_ledger', array(
'item_id' => (int) $row->item_id,
'warehouse_id' => (int) $row->warehouse_id,
'movement_date' => $row->opening_date ?: date('Y-m-d'),
'direction' => 'in',
'qty' => $ledgerShortage,
'unit_cost' => $unitCost,
'value' => round($ledgerShortage * $unitCost, 4),
'document_type' => 'legacy_opening_reconciliation',
'document_id' => (int) $row->item_id,
'document_line_id' => null,
'idempotency_key' => $key,
'created_at' => date('Y-m-d H:i:s')
));
$sourceLedgerId = (int) $this->db->insert_id();
}
}
$layerShortage = round(max(0, $physicalQty - $layerQty), 4);
if ($layerShortage > 0.0001) {
if (!$sourceLedgerId) {
$source = $this->db->select('id')
->where(array('item_id' => (int) $row->item_id, 'warehouse_id' => (int) $row->warehouse_id, 'direction' => 'in'))
->order_by('movement_date', 'ASC')->order_by('id', 'ASC')
->limit(1)->get('inventory_ledger')->row();
$sourceLedgerId = $source ? (int) $source->id : 0;
}
if (!$sourceLedgerId) {
throw new RuntimeException('Sumber ledger stok lama tidak ditemukan untuk item #' . (int) $row->item_id . '.');
}
$this->db->insert('inventory_cost_layers', array(
'item_id' => (int) $row->item_id,
'warehouse_id' => (int) $row->warehouse_id,
'source_ledger_id' => $sourceLedgerId,
'received_date' => $row->opening_date ?: date('Y-m-d'),
'original_qty' => $layerShortage,
'remaining_qty' => $layerShortage,
'unit_cost' => $unitCost,
'status' => 'open'
));
}
$affectedItems[(int) $row->item_id] = true;
}
foreach (array_keys($affectedItems) as $itemId) {
$balance = $this->db->query(
"SELECT COALESCE(SUM(IF(direction='in',qty,-qty)),0) qty FROM inventory_ledger WHERE item_id=?",
array($itemId)
)->row();
$this->db->where('id', $itemId)->update('items', array('stok' => max(0, (float) $balance->qty)));
}
if ($this->db->trans_status() === false) {
throw new RuntimeException('Rekonsiliasi lapisan biaya persediaan lama gagal.');
}
$this->db->trans_commit();
} catch (Throwable $e) {
$this->db->trans_rollback();
throw $e;
}
}
public function down()
{
throw new RuntimeException('Gunakan backup database untuk rollback rekonsiliasi stok lama.');
}
}
@@ -0,0 +1,88 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Reconcile_legacy_active_stock_logs extends CI_Migration
{
private function columns(array $data)
{
return array_intersect_key($data, array_flip($this->db->list_fields('stock_logs')));
}
public function up()
{
if (!$this->db->table_exists('items')
|| !$this->db->table_exists('item_barcodes')
|| !$this->db->table_exists('stock_logs')) {
return;
}
$rows = $this->db->query(
"SELECT p.item_id,
p.warehouse_id,
p.physical_qty,
COALESCE(s.log_qty,0) log_qty,
COALESCE(i.harga_beli,0) unit_cost,
i.company_id,
k.tracking_type
FROM (
SELECT item_id,warehouse_id,SUM(qty_sisa) physical_qty
FROM item_barcodes
WHERE status='available' AND qty_sisa>0
GROUP BY item_id,warehouse_id
) p
JOIN items i ON i.id=p.item_id
LEFT JOIN kode_barang k ON k.id=i.kode_id
LEFT JOIN (
SELECT item_id,warehouse_id,SUM(IF(tipe='masuk',qty,-qty)) log_qty
FROM stock_logs
GROUP BY item_id,warehouse_id
) s ON s.item_id=p.item_id AND s.warehouse_id=p.warehouse_id
WHERE p.physical_qty>COALESCE(s.log_qty,0)+0.0001"
)->result();
$this->db->trans_begin();
try {
foreach ($rows as $row) {
/*
* Gunakan total qty_sisa, bukan COUNT(barcode). Dengan demikian
* UNIT tetap 1 barcode = 1 barang, sedangkan QTY dapat menyimpan
* banyak barang pada satu barcode tanpa salah menghitung stok.
*/
$shortage = round((float) $row->physical_qty - (float) $row->log_qty, 4);
if ($shortage <= 0.0001) continue;
$key = 'LEGACY-ACTIVE-STOCK-LOG-' . (int) $row->item_id . '-' . (int) $row->warehouse_id . '-20260908';
if ($this->db->where('idempotency_key', $key)->count_all_results('stock_logs')) continue;
$tracking = strtoupper((string) $row->tracking_type) === 'UNIT' ? 'UNIT' : 'QTY';
$this->db->insert('stock_logs', $this->columns(array(
'company_id' => (int) $row->company_id,
'item_id' => (int) $row->item_id,
'warehouse_id' => (int) $row->warehouse_id,
'barcode_id' => null,
'qty' => $shortage,
'tipe' => 'masuk',
'unit_cost' => round((float) $row->unit_cost, 4),
'keterangan' => '[AUTO] Rekonsiliasi saldo awal stok aktif lama | Tipe tracking ' . $tracking . ' | Qty ' . number_format($shortage, 4, ',', '.') . ' | Sumber total qty_sisa barcode aktif',
'ref_type' => 'legacy_stock_reconciliation',
'ref_id' => (int) $row->item_id,
'idempotency_key' => $key,
'created_at' => date('Y-m-d H:i:s')
)));
}
if ($this->db->trans_status() === false) {
throw new RuntimeException('Rekonsiliasi stock log lama gagal.');
}
$this->db->trans_commit();
} catch (Throwable $e) {
$this->db->trans_rollback();
throw $e;
}
}
public function down()
{
throw new RuntimeException('Gunakan backup database untuk rollback rekonsiliasi stock log lama.');
}
}
@@ -0,0 +1,115 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Normalize_account_codes_to_four_digits extends CI_Migration
{
private function nextCode($oldCode, $type, array &$used)
{
if (preg_match('/^[0-9]{3}$/', $oldCode)) {
$preferred = $oldCode . '0';
if (!isset($used[$preferred])) {
$used[$preferred] = true;
return $preferred;
}
}
if (preg_match('/^([0-9]{3})/', $oldCode, $match)) {
foreach (array(1,2,3,4,5,6,7,8,9,0) as $suffix) {
$candidate = $match[1] . $suffix;
if (!isset($used[$candidate])) {
$used[$candidate] = true;
return $candidate;
}
}
}
$class = array('asset'=>'1','liability'=>'2','equity'=>'3','revenue'=>'4','expense'=>'6');
$first = isset($class[$type]) ? $class[$type] : '9';
for ($number = 0; $number <= 999; $number++) {
$candidate = $first . str_pad((string) $number, 3, '0', STR_PAD_LEFT);
if (!isset($used[$candidate])) {
$used[$candidate] = true;
return $candidate;
}
}
throw new RuntimeException('Tidak tersedia kode akun 4 digit untuk akun ' . $oldCode . '.');
}
public function up()
{
if (!$this->db->table_exists('accounts')) return;
$this->db->query("CREATE TABLE IF NOT EXISTS account_code_change_logs(
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
account_id INT NOT NULL,
company_id BIGINT UNSIGNED NULL,
old_code VARCHAR(20) NOT NULL,
new_code CHAR(4) NOT NULL,
changed_at DATETIME NOT NULL,
UNIQUE KEY uq_account_code_change(account_id,new_code),
KEY idx_account_code_change_old(old_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$accounts = $this->db->select('id,kode_akun,tipe' . ($this->db->field_exists('company_id','accounts') ? ',company_id' : ''))
->order_by('id','ASC')->get('accounts')->result();
$used = array();
foreach ($accounts as $account) {
$code = trim((string) $account->kode_akun);
if (preg_match('/^[0-9]{4}$/', $code)) $used[$code] = true;
}
$changes = array();
foreach ($accounts as $account) {
$oldCode = trim((string) $account->kode_akun);
if (preg_match('/^[0-9]{4}$/', $oldCode)) continue;
$changes[] = array(
'account' => $account,
'old_code' => $oldCode,
'new_code' => $this->nextCode($oldCode, (string) $account->tipe, $used)
);
}
$this->db->trans_begin();
try {
foreach ($changes as $change) {
$account = $change['account'];
$log = array(
'account_id' => (int) $account->id,
'company_id' => isset($account->company_id) ? $account->company_id : null,
'old_code' => $change['old_code'],
'new_code' => $change['new_code'],
'changed_at' => date('Y-m-d H:i:s')
);
if (!$this->db->where(array('account_id'=>(int)$account->id,'new_code'=>$change['new_code']))->count_all_results('account_code_change_logs')) {
$this->db->insert('account_code_change_logs', $log);
}
$this->db->where('id', (int) $account->id)->update('accounts', array('kode_akun'=>$change['new_code']));
if ($this->db->table_exists('cash_accounts') && $this->db->field_exists('code','cash_accounts')) {
$this->db->where('code', 'GL-' . $change['old_code'])
->update('cash_accounts', array('code'=>'GL-' . $change['new_code']));
}
}
if ($this->db->trans_status() === false) throw new RuntimeException('Normalisasi kode akun gagal.');
$this->db->trans_commit();
} catch (Throwable $exception) {
$this->db->trans_rollback();
throw $exception;
}
$invalid = (int) $this->db->query("SELECT COUNT(*) total FROM accounts WHERE TRIM(kode_akun) NOT REGEXP '^[0-9]{4}$'")->row()->total;
$duplicates = (int) $this->db->query("SELECT COUNT(*) total FROM(SELECT kode_akun FROM accounts GROUP BY kode_akun HAVING COUNT(*)>1)x")->row()->total;
if ($invalid || $duplicates) throw new RuntimeException('Validasi kode akun 4 digit gagal.');
$this->db->query('ALTER TABLE accounts MODIFY kode_akun VARCHAR(4) NOT NULL');
$this->db->query('DROP TRIGGER IF EXISTS trg_accounts_code_four_insert');
$this->db->query("CREATE TRIGGER trg_accounts_code_four_insert BEFORE INSERT ON accounts FOR EACH ROW BEGIN IF NEW.kode_akun IS NULL OR NEW.kode_akun NOT REGEXP '^[0-9]{4}$' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Kode akun wajib terdiri dari 4 angka'; END IF; END");
$this->db->query('DROP TRIGGER IF EXISTS trg_accounts_code_four_update');
$this->db->query("CREATE TRIGGER trg_accounts_code_four_update BEFORE UPDATE ON accounts FOR EACH ROW BEGIN IF NEW.kode_akun IS NULL OR NEW.kode_akun NOT REGEXP '^[0-9]{4}$' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Kode akun wajib terdiri dari 4 angka'; END IF; END");
}
public function down()
{
throw new RuntimeException('Gunakan account_code_change_logs dan backup database untuk rollback kode akun.');
}
}
@@ -0,0 +1,42 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Extend_invoice_financial_lines extends CI_Migration
{
private function column($table,$column,$definition)
{
if($this->db->table_exists($table)&&!$this->db->field_exists($column,$table))
$this->db->query("ALTER TABLE `{$table}` ADD `{$column}` {$definition}");
}
private function index($table,$name,$columns)
{
if(!$this->db->table_exists($table))return;
foreach($this->db->query("SHOW INDEX FROM `{$table}`")->result()as$i)if($i->Key_name===$name)return;
$this->db->query("ALTER TABLE `{$table}` ADD KEY `{$name}` ({$columns})");
}
public function up()
{
if($this->db->table_exists('invoice_details')&&$this->db->field_exists('line_type','invoice_details'))
$this->db->query("ALTER TABLE `invoice_details` MODIFY `line_type` ENUM('inventory','service','loan','savings') NOT NULL DEFAULT 'inventory'");
$this->column('invoice_details','counter_account_id','INT NULL AFTER `account_id`');
$this->index('invoice_details','idx_invoice_line_counter_account','`company_id`,`counter_account_id`');
if($this->db->table_exists('invoice_line_barcodes')){
foreach($this->db->query("SHOW INDEX FROM `invoice_line_barcodes`")->result()as$i){
if($i->Key_name==='uq_invoice_line_barcode'){
$this->db->query('ALTER TABLE `invoice_line_barcodes` DROP INDEX `uq_invoice_line_barcode`');
break;
}
}
$this->db->query('ALTER TABLE `invoice_line_barcodes` ADD UNIQUE KEY `uq_invoice_line_barcode` (`invoice_detail_id`,`barcode_id`)');
}
}
public function down()
{
throw new RuntimeException('Gunakan backup database untuk rollback perluasan kategori item invoice.');
}
}
@@ -0,0 +1,62 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Create_account_budget_entries extends CI_Migration
{
public function up()
{
if (!$this->db->table_exists('account_budget_entries')) {
$this->db->query("CREATE TABLE `account_budget_entries` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`company_id` BIGINT UNSIGNED NOT NULL,
`account_id` INT NOT NULL,
`period` CHAR(7) NOT NULL,
`entry_type` ENUM('recurring','override') NOT NULL DEFAULT 'recurring',
`amount` DECIMAL(18,2) NOT NULL DEFAULT 0,
`notes` VARCHAR(255) NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`created_by` INT NULL,
`created_at` DATETIME NOT NULL,
`updated_by` INT NULL,
`updated_at` DATETIME NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_account_budget_entry` (`company_id`,`account_id`,`period`,`entry_type`),
KEY `idx_account_budget_period` (`company_id`,`period`,`account_id`,`is_active`),
CONSTRAINT `fk_account_budget_account` FOREIGN KEY (`account_id`) REFERENCES `accounts` (`id`) ON UPDATE RESTRICT ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
}
// Pertahankan rincian budget aktif lama sebagai nilai khusus per bulan.
if ($this->db->table_exists('budgets') && $this->db->table_exists('budget_lines')) {
$this->db->query("INSERT IGNORE INTO account_budget_entries
(company_id,account_id,period,entry_type,amount,notes,is_active,created_by,created_at)
SELECT COALESCE(bl.company_id,b.company_id),bl.account_id,bl.period,'override',bl.amount,
CONCAT('Migrasi dari ',b.budget_no),1,b.created_by,COALESCE(bl.created_at,b.created_at,NOW())
FROM budget_lines bl
JOIN budgets b ON b.id=bl.budget_id
WHERE b.status IN('active','approved') AND COALESCE(bl.company_id,b.company_id) IS NOT NULL");
// Nilai bulan terakhir sampai bulan migrasi menjadi dasar berulang ke depan.
$currentPeriod = date('Y-m');
$this->db->query("INSERT IGNORE INTO account_budget_entries
(company_id,account_id,period,entry_type,amount,notes,is_active,created_by,created_at)
SELECT COALESCE(bl.company_id,b.company_id),bl.account_id,?,'recurring',bl.amount,
CONCAT('Budget berulang hasil migrasi ',b.budget_no),1,b.created_by,NOW()
FROM budget_lines bl
JOIN budgets b ON b.id=bl.budget_id
JOIN (
SELECT COALESCE(bl2.company_id,b2.company_id) company_id,bl2.account_id,MAX(bl2.period) period
FROM budget_lines bl2 JOIN budgets b2 ON b2.id=bl2.budget_id
WHERE b2.status IN('active','approved') AND bl2.period<=?
GROUP BY COALESCE(bl2.company_id,b2.company_id),bl2.account_id
) latest ON latest.company_id=COALESCE(bl.company_id,b.company_id)
AND latest.account_id=bl.account_id AND latest.period=bl.period
WHERE b.status IN('active','approved')", array($currentPeriod, $currentPeriod));
}
}
public function down()
{
throw new RuntimeException('Gunakan backup database untuk rollback data budget per akun.');
}
}
@@ -0,0 +1,53 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Expand_budget_schedules_and_reminders extends CI_Migration
{
private function hasIndex($table, $name)
{
foreach ($this->db->query("SHOW INDEX FROM `{$table}`")->result() as $index) {
if ($index->Key_name === $name) return true;
}
return false;
}
public function up()
{
if (!$this->db->field_exists('title', 'account_budget_entries'))
$this->db->query("ALTER TABLE `account_budget_entries` ADD `title` VARCHAR(150) NOT NULL DEFAULT 'Budget' AFTER `account_id`");
if (!$this->db->field_exists('reminder_enabled', 'account_budget_entries'))
$this->db->query("ALTER TABLE `account_budget_entries` ADD `reminder_enabled` TINYINT(1) NOT NULL DEFAULT 0 AFTER `amount`");
if (!$this->db->field_exists('reminder_day', 'account_budget_entries'))
$this->db->query("ALTER TABLE `account_budget_entries` ADD `reminder_day` TINYINT UNSIGNED NULL AFTER `reminder_enabled`");
$this->db->query("UPDATE account_budget_entries e JOIN accounts a ON a.id=e.account_id
SET e.title=CASE WHEN NULLIF(TRIM(e.notes),'') IS NOT NULL THEN LEFT(e.notes,150) ELSE CONCAT('Budget ',a.nama_akun) END
WHERE e.title='Budget' OR TRIM(e.title)=''");
// Satu akun dan periode boleh mempunyai banyak jadwal budget yang berbeda.
if ($this->hasIndex('account_budget_entries', 'uq_account_budget_entry'))
$this->db->query('ALTER TABLE `account_budget_entries` DROP INDEX `uq_account_budget_entry`');
if (!$this->hasIndex('account_budget_entries', 'idx_account_budget_schedule'))
$this->db->query('ALTER TABLE `account_budget_entries` ADD KEY `idx_account_budget_schedule` (`company_id`,`account_id`,`period`,`entry_type`,`is_active`)');
if (!$this->db->table_exists('account_budget_reminder_completions')) {
$this->db->query("CREATE TABLE `account_budget_reminder_completions` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`company_id` BIGINT UNSIGNED NOT NULL,
`budget_entry_id` BIGINT UNSIGNED NOT NULL,
`period` CHAR(7) NOT NULL,
`completed_by` INT NULL,
`completed_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_budget_reminder_completion` (`company_id`,`budget_entry_id`,`period`),
KEY `idx_budget_reminder_period` (`company_id`,`period`,`budget_entry_id`),
CONSTRAINT `fk_budget_reminder_entry` FOREIGN KEY (`budget_entry_id`) REFERENCES `account_budget_entries` (`id`) ON UPDATE RESTRICT ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
}
}
public function down()
{
throw new RuntimeException('Gunakan backup database untuk rollback jadwal dan pengingat budget.');
}
}
@@ -0,0 +1,40 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
/**
* Metadata pemakaian barang yang masih berada dalam penguasaan teknisi.
* Kolom status lama (active/inactive) tetap dipertahankan sebagai status
* serah-terima agar alur pengembalian yang sudah berjalan tidak berubah.
*/
class Migration_Complete_technician_item_usage extends CI_Migration
{
public function up()
{
if (!$this->db->table_exists('item_technician')) return;
$columns = array(
'usage_status' => "VARCHAR(30) NOT NULL DEFAULT 'carried' AFTER status",
'usage_date' => 'DATE NULL AFTER usage_status',
'customer_id' => 'BIGINT UNSIGNED NULL AFTER usage_date',
'usage_notes' => 'TEXT NULL AFTER customer_id',
'updated_by' => 'BIGINT UNSIGNED NULL AFTER usage_notes',
'updated_at' => 'DATETIME NULL AFTER updated_by'
);
foreach ($columns as $name => $definition) {
if (!$this->db->field_exists($name, 'item_technician')) {
$this->db->query('ALTER TABLE item_technician ADD `'.$name.'` '.$definition);
}
}
$this->db->query("UPDATE item_technician SET usage_status='carried' WHERE usage_status IS NULL OR usage_status='' OR usage_status NOT IN('carried','installed')");
$index = $this->db->query("SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='item_technician' AND index_name='idx_item_technician_usage' LIMIT 1")->row();
if (!$index) {
$this->db->query('ALTER TABLE item_technician ADD KEY idx_item_technician_usage(company_id,user_id,status,usage_status,customer_id,usage_date)');
}
}
public function down()
{
throw new RuntimeException('Gunakan backup untuk mengembalikan metadata penggunaan barang teknisi.');
}
}
@@ -0,0 +1,82 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
/**
* Menyimpan saldo status barang teknisi untuk mendukung barcode UNIT maupun QTY.
* item_technician tetap menjadi header penguasaan aktif agar alur lama kompatibel,
* sedangkan technician_item_status_logs menjadi audit trail yang tidak ditimpa.
*/
class Migration_Expand_technician_item_status_flow extends CI_Migration
{
public function up()
{
if (!$this->db->table_exists('item_technician')) return;
$columns = array(
'assigned_qty' => "DECIMAL(18,4) NOT NULL DEFAULT 0 AFTER usage_status",
'carried_qty' => "DECIMAL(18,4) NOT NULL DEFAULT 0 AFTER assigned_qty",
'installed_qty' => "DECIMAL(18,4) NOT NULL DEFAULT 0 AFTER carried_qty"
);
foreach ($columns as $name => $definition) {
if (!$this->db->field_exists($name, 'item_technician')) {
$this->db->query('ALTER TABLE item_technician ADD `'.$name.'` '.$definition);
}
}
$this->db->query(
"UPDATE item_technician it
JOIN item_barcodes ib ON ib.barcode=it.barcode
SET it.assigned_qty=CASE WHEN it.status='active' THEN GREATEST(ib.qty_sisa,0) ELSE 0 END,
it.carried_qty=CASE WHEN it.status='active' AND COALESCE(it.usage_status,'carried')<>'installed' THEN GREATEST(ib.qty_sisa,0) ELSE 0 END,
it.installed_qty=CASE WHEN it.status='active' AND it.usage_status='installed' THEN GREATEST(ib.qty_sisa,0) ELSE 0 END
WHERE it.assigned_qty=0 AND it.carried_qty=0 AND it.installed_qty=0"
);
$this->db->query(
"CREATE TABLE IF NOT EXISTS technician_item_status_logs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
company_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
item_id BIGINT UNSIGNED NOT NULL,
barcode_id BIGINT UNSIGNED NOT NULL,
barcode VARCHAR(191) NOT NULL,
from_status VARCHAR(30) NOT NULL,
to_status VARCHAR(30) NOT NULL,
qty DECIMAL(18,4) NOT NULL,
customer_id BIGINT UNSIGNED NULL,
event_date DATE NOT NULL,
notes TEXT NOT NULL,
attachment_id BIGINT UNSIGNED NULL,
idempotency_key VARCHAR(100) NULL,
created_by BIGINT UNSIGNED NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_technician_status_idempotency (company_id,idempotency_key),
KEY idx_technician_status_history (company_id,user_id,event_date),
KEY idx_technician_status_barcode (company_id,barcode_id,created_at),
KEY idx_technician_status_customer (company_id,customer_id,event_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
$hasInitial = $this->db->query("SELECT 1 FROM technician_item_status_logs LIMIT 1")->row();
if (!$hasInitial) {
$this->db->query(
"INSERT INTO technician_item_status_logs
(company_id,user_id,item_id,barcode_id,barcode,from_status,to_status,qty,customer_id,event_date,notes,created_by,created_at)
SELECT COALESCE(it.company_id,i.company_id,1),it.user_id,it.item_id,ib.id,it.barcode,'initial',
CASE WHEN it.status='inactive' THEN 'returned' ELSE COALESCE(it.usage_status,'carried') END,
CASE WHEN it.status='inactive' THEN 0 ELSE GREATEST(ib.qty_sisa,0) END,
it.customer_id,DATE(COALESCE(it.usage_date,it.created_at,NOW())),
'Saldo awal riwayat peralatan teknisi',it.updated_by,COALESCE(it.updated_at,it.created_at,NOW())
FROM item_technician it
JOIN item_barcodes ib ON ib.barcode=it.barcode
JOIN items i ON i.id=it.item_id"
);
}
}
public function down()
{
throw new RuntimeException('Gunakan backup untuk mengembalikan perluasan status peralatan teknisi.');
}
}
@@ -0,0 +1,96 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
/**
* Subledger operasional Peralatan Teknisi. Tidak membuat jurnal dan tidak
* mengubah qty/cost inventory. Barang perusahaan tetap menunjuk item_barcodes;
* barang customer disimpan pada register non-accounting yang terpisah.
*/
class Migration_Finalize_technician_equipment_operations extends CI_Migration
{
public function up()
{
$this->db->query("CREATE TABLE IF NOT EXISTS customer_equipment_registry(
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NOT NULL,
owner_customer_id BIGINT UNSIGNED NULL,current_customer_id BIGINT UNSIGNED NULL,
equipment_name VARCHAR(150) NOT NULL,category VARCHAR(100) NULL,brand VARCHAR(100) NULL,model VARCHAR(100) NULL,
external_barcode VARCHAR(120) NOT NULL,serial_number VARCHAR(120) NULL,description TEXT NULL,
ownership_type VARCHAR(40) NOT NULL DEFAULT 'customer',deployment_allowed TINYINT(1) NOT NULL DEFAULT 0,
authorization_reference VARCHAR(150) NULL,tracking_type VARCHAR(10) NOT NULL DEFAULT 'UNIT',total_qty DECIMAL(18,4) NOT NULL DEFAULT 1,
custody_type VARCHAR(30) NOT NULL DEFAULT 'customer',custody_id BIGINT UNSIGNED NULL,usage_status VARCHAR(30) NOT NULL DEFAULT 'installed',
condition_status VARCHAR(30) NOT NULL DEFAULT 'good',lifecycle_status VARCHAR(30) NOT NULL DEFAULT 'active',version INT NOT NULL DEFAULT 1,
registered_by BIGINT UNSIGNED NULL,registered_at DATETIME NOT NULL,updated_by BIGINT UNSIGNED NULL,updated_at DATETIME NULL,
UNIQUE KEY uq_customer_equipment_barcode(company_id,external_barcode),KEY idx_customer_equipment_owner(company_id,owner_customer_id,lifecycle_status),
KEY idx_customer_equipment_location(company_id,custody_type,custody_id,condition_status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$this->db->query("CREATE TABLE IF NOT EXISTS technician_equipment_documents(
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NOT NULL,document_no VARCHAR(70) NOT NULL,
document_type VARCHAR(40) NOT NULL,document_date DATE NOT NULL,status VARCHAR(20) NOT NULL DEFAULT 'posted',
customer_id BIGINT UNSIGNED NULL,technician_id BIGINT UNSIGNED NULL,target_technician_id BIGINT UNSIGNED NULL,warehouse_id BIGINT UNSIGNED NULL,
work_order_no VARCHAR(100) NULL,reason VARCHAR(255) NULL,notes TEXT NULL,reversal_of_id BIGINT UNSIGNED NULL,
idempotency_key VARCHAR(120) NOT NULL,created_by BIGINT UNSIGNED NOT NULL,created_at DATETIME NOT NULL,reversed_by BIGINT UNSIGNED NULL,reversed_at DATETIME NULL,
UNIQUE KEY uq_technician_document_no(company_id,document_no),UNIQUE KEY uq_technician_document_idem(company_id,idempotency_key),
KEY idx_technician_document(company_id,document_date,document_type,status),KEY idx_technician_document_people(company_id,technician_id,customer_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$this->db->query("CREATE TABLE IF NOT EXISTS technician_custody_allocations(
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NOT NULL,
subject_type VARCHAR(30) NOT NULL,subject_id BIGINT UNSIGNED NOT NULL,item_id BIGINT UNSIGNED NULL,barcode_id BIGINT UNSIGNED NULL,
customer_equipment_id BIGINT UNSIGNED NULL,barcode_snapshot VARCHAR(191) NOT NULL,ownership_type VARCHAR(40) NOT NULL,
custodian_type VARCHAR(30) NOT NULL,custodian_id BIGINT UNSIGNED NULL,customer_id BIGINT UNSIGNED NULL,
usage_status VARCHAR(30) NOT NULL,condition_status VARCHAR(30) NOT NULL DEFAULT 'good',qty DECIMAL(18,4) NOT NULL,
balance_key CHAR(64) NOT NULL,version INT NOT NULL DEFAULT 1,last_document_id BIGINT UNSIGNED NULL,created_at DATETIME NOT NULL,updated_at DATETIME NOT NULL,
UNIQUE KEY uq_technician_allocation(balance_key),KEY idx_technician_allocation_subject(company_id,subject_type,subject_id),
KEY idx_technician_allocation_holder(company_id,custodian_type,custodian_id,usage_status),
KEY idx_technician_allocation_customer(company_id,customer_id,condition_status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$this->db->query("CREATE TABLE IF NOT EXISTS technician_equipment_document_lines(
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,document_id BIGINT UNSIGNED NOT NULL,company_id BIGINT UNSIGNED NOT NULL,
subject_type VARCHAR(30) NOT NULL,subject_id BIGINT UNSIGNED NOT NULL,item_id BIGINT UNSIGNED NULL,barcode_id BIGINT UNSIGNED NULL,
customer_equipment_id BIGINT UNSIGNED NULL,barcode_snapshot VARCHAR(191) NOT NULL,ownership_type VARCHAR(40) NOT NULL,tracking_type VARCHAR(10) NOT NULL,
action_type VARCHAR(40) NOT NULL,from_custodian_type VARCHAR(30) NULL,from_custodian_id BIGINT UNSIGNED NULL,
to_custodian_type VARCHAR(30) NULL,to_custodian_id BIGINT UNSIGNED NULL,customer_id BIGINT UNSIGNED NULL,
from_usage_status VARCHAR(30) NULL,to_usage_status VARCHAR(30) NULL,from_condition_status VARCHAR(30) NULL,to_condition_status VARCHAR(30) NULL,
qty DECIMAL(18,4) NOT NULL,item_movement_id BIGINT UNSIGNED NULL,notes TEXT NULL,created_at DATETIME NOT NULL,
KEY idx_technician_line_document(document_id),KEY idx_technician_line_subject(company_id,subject_type,subject_id),
KEY idx_technician_line_movement(item_movement_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$this->db->query("CREATE TABLE IF NOT EXISTS technician_equipment_inspections(
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NOT NULL,document_id BIGINT UNSIGNED NOT NULL,
source_allocation_id BIGINT UNSIGNED NULL,subject_type VARCHAR(30) NOT NULL,subject_id BIGINT UNSIGNED NOT NULL,
result_status VARCHAR(30) NOT NULL,qty DECIMAL(18,4) NOT NULL,inspection_date DATE NOT NULL,notes TEXT NOT NULL,
inspected_by BIGINT UNSIGNED NOT NULL,created_at DATETIME NOT NULL,
KEY idx_technician_inspection_queue(company_id,result_status,inspection_date),KEY idx_technician_inspection_subject(company_id,subject_type,subject_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
if ($this->db->table_exists('item_technician') && $this->db->table_exists('item_barcodes')) {
$this->db->query("INSERT IGNORE INTO technician_custody_allocations
(company_id,subject_type,subject_id,item_id,barcode_id,customer_equipment_id,barcode_snapshot,ownership_type,custodian_type,custodian_id,customer_id,usage_status,condition_status,qty,balance_key,version,created_at,updated_at)
SELECT COALESCE(it.company_id,i.company_id,1),'company_barcode',ib.id,it.item_id,ib.id,NULL,it.barcode,'company','technician',it.user_id,NULL,'carried','good',it.carried_qty,
SHA2(CONCAT_WS('|',COALESCE(it.company_id,i.company_id,1),'company_barcode',ib.id,'technician',it.user_id,0,'carried','good'),256),1,COALESCE(it.created_at,NOW()),NOW()
FROM item_technician it JOIN item_barcodes ib ON ib.barcode=it.barcode JOIN items i ON i.id=it.item_id
WHERE it.status='active' AND it.carried_qty>0");
$this->db->query("INSERT IGNORE INTO technician_custody_allocations
(company_id,subject_type,subject_id,item_id,barcode_id,customer_equipment_id,barcode_snapshot,ownership_type,custodian_type,custodian_id,customer_id,usage_status,condition_status,qty,balance_key,version,created_at,updated_at)
SELECT COALESCE(it.company_id,i.company_id,1),'company_barcode',ib.id,it.item_id,ib.id,NULL,it.barcode,'company','customer',it.customer_id,it.customer_id,'installed','good',it.installed_qty,
SHA2(CONCAT_WS('|',COALESCE(it.company_id,i.company_id,1),'company_barcode',ib.id,'customer',COALESCE(it.customer_id,0),COALESCE(it.customer_id,0),'installed','good'),256),1,COALESCE(it.created_at,NOW()),NOW()
FROM item_technician it JOIN item_barcodes ib ON ib.barcode=it.barcode JOIN items i ON i.id=it.item_id
WHERE it.status='active' AND it.installed_qty>0 AND it.customer_id IS NOT NULL");
$this->db->query("UPDATE item_barcodes ib JOIN(SELECT barcode_id,SUM(qty) qty FROM technician_custody_allocations WHERE subject_type='company_barcode' GROUP BY barcode_id)x ON x.barcode_id=ib.id SET ib.reserved_qty=LEAST(ib.qty_sisa,GREATEST(ib.reserved_qty,x.qty)),ib.status=CASE WHEN ib.qty_sisa-GREATEST(ib.reserved_qty,x.qty)>.0001 THEN 'available' ELSE 'installed' END,ib.version=ib.version+1 WHERE ib.qty_sisa>0");
}
if ($this->db->table_exists('roles')) {
$roles=$this->db->get('roles')->result();
$features=array('technician_equipment','technician_handover','technician_installation','technician_transfer','technician_return','technician_inspection','technician_correction');
foreach($roles as$role){$permissions=json_decode((string)$role->permissions,true);if(!is_array($permissions)||!isset($permissions['items']))continue;foreach($features as$feature)if(!isset($permissions[$feature]))$permissions[$feature]=$permissions['items'];$this->db->where('id',$role->id)->update('roles',array('permissions'=>json_encode($permissions),'updated_at'=>date('Y-m-d H:i:s')));}
}
}
public function down()
{
throw new RuntimeException('Gunakan backup untuk rollback subledger Peralatan Teknisi.');
}
}
@@ -0,0 +1,16 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
/** Optional employee link required to enforce "technician sees own custody". */
class Migration_Link_application_users_to_employees extends CI_Migration
{
public function up()
{
if($this->db->table_exists('users')&&!$this->db->field_exists('employee_id','users')){
$this->load->dbforge();$this->dbforge->add_column('users',array('employee_id'=>array('type'=>'INT','constraint'=>11,'unsigned'=>true,'null'=>true,'after'=>'role_id')));
$this->db->query('CREATE INDEX idx_users_employee ON users(employee_id)');
}
if($this->db->table_exists('roles'))foreach($this->db->get('roles')->result()as$role){$permissions=json_decode((string)$role->permissions,true);if(!is_array($permissions))$permissions=array();$name=strtolower((string)$role->nama_role);if((int)($role->is_super_admin??0)===1||(strpos($name,'teknisi')===false&&isset($permissions['items'])&&in_array('can_view',(array)$permissions['items'],true))){if(!isset($permissions['technician_equipment_all']))$permissions['technician_equipment_all']=array('can_view');$this->db->where('id',$role->id)->update('roles',array('permissions'=>json_encode($permissions),'updated_at'=>date('Y-m-d H:i:s')));}}
}
public function down(){throw new RuntimeException('Kolom tautan karyawan dipertahankan untuk keamanan histori.');}
}
@@ -0,0 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Add_customer_equipment_source_fields extends CI_Migration
{
public function up(){if(!$this->db->table_exists('customer_equipment_registry'))return;$this->load->dbforge();$fields=array();if(!$this->db->field_exists('source_date','customer_equipment_registry'))$fields['source_date']=array('type'=>'DATE','null'=>true,'after'=>'description');if(!$this->db->field_exists('source_document_no','customer_equipment_registry'))$fields['source_document_no']=array('type'=>'VARCHAR','constraint'=>150,'null'=>true,'after'=>'source_date');if(!$this->db->field_exists('source_reason','customer_equipment_registry'))$fields['source_reason']=array('type'=>'VARCHAR','constraint'=>255,'null'=>true,'after'=>'source_document_no');if($fields)$this->dbforge->add_column('customer_equipment_registry',$fields);}
public function down(){throw new RuntimeException('Kolom sumber register dipertahankan untuk audit.');}
}
@@ -0,0 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Add_technician_document_location extends CI_Migration
{
public function up(){if($this->db->table_exists('technician_equipment_documents')&&!$this->db->field_exists('installation_location','technician_equipment_documents')){$this->load->dbforge();$this->dbforge->add_column('technician_equipment_documents',array('installation_location'=>array('type'=>'VARCHAR','constraint'=>255,'null'=>true,'after'=>'work_order_no')));}}
public function down(){throw new RuntimeException('Lokasi pemasangan dipertahankan untuk audit.');}
}
@@ -0,0 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Add_technician_condition_permission extends CI_Migration
{
public function up(){if(!$this->db->table_exists('roles'))return;foreach($this->db->get('roles')->result()as$role){$p=json_decode((string)$role->permissions,true);if(!is_array($p)||isset($p['technician_condition']))continue;$source=$p['technician_inspection']??($p['items']??array());$allowed=array_values(array_intersect((array)$source,array('can_view','can_update','can_post')));if($allowed){$p['technician_condition']=$allowed;$this->db->where('id',$role->id)->update('roles',array('permissions'=>json_encode($p),'updated_at'=>date('Y-m-d H:i:s')));}}}
public function down(){throw new RuntimeException('Permission kondisi dipertahankan.');}
}
@@ -0,0 +1,15 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
/** Documents migrated balances without recreating historical item_movements. */
class Migration_Document_technician_opening_balances extends CI_Migration
{
public function up()
{
if(!$this->db->table_exists('technician_custody_allocations')||!$this->db->table_exists('technician_equipment_documents'))return;$user=$this->db->select_min('id')->get('users')->row();$userId=(int)($user?$user->id:0);if($userId<1)return;
foreach($this->db->select('company_id')->where('last_document_id IS NULL',null,false)->group_by('company_id')->get('technician_custody_allocations')->result()as$group){$company=(int)$group->company_id;$key='MIGRATION-TECHNICIAN-OPENING-'.$company;$doc=$this->db->get_where('technician_equipment_documents',array('company_id'=>$company,'idempotency_key'=>$key))->row();if(!$doc){$row=array('company_id'=>$company,'document_no'=>'TEQ-OPEN-'.$company.'-20260911','document_type'=>'opening','document_date'=>'2026-09-11','status'=>'posted','notes'=>'Saldo awal migrasi dari item_technician. Tidak membuat ulang item_movements, stock_logs, inventory_ledger, atau jurnal.','idempotency_key'=>$key,'created_by'=>$userId,'created_at'=>date('Y-m-d H:i:s'));$this->db->insert('technician_equipment_documents',$row);$doc=(object)array_merge($row,array('id'=>(int)$this->db->insert_id()));}
$allocations=$this->db->where(array('company_id'=>$company,'last_document_id'=>null))->get('technician_custody_allocations')->result_array();foreach($allocations as$a){$exists=$this->db->where(array('document_id'=>$doc->id,'subject_type'=>$a['subject_type'],'subject_id'=>$a['subject_id'],'to_custodian_type'=>$a['custodian_type'],'to_custodian_id'=>$a['custodian_id'],'to_usage_status'=>$a['usage_status'],'to_condition_status'=>$a['condition_status']))->get('technician_equipment_document_lines')->row();if(!$exists)$this->db->insert('technician_equipment_document_lines',array('document_id'=>$doc->id,'company_id'=>$company,'subject_type'=>$a['subject_type'],'subject_id'=>$a['subject_id'],'item_id'=>$a['item_id'],'barcode_id'=>$a['barcode_id'],'customer_equipment_id'=>$a['customer_equipment_id'],'barcode_snapshot'=>$a['barcode_snapshot'],'ownership_type'=>$a['ownership_type'],'tracking_type'=>$this->trackingType($a),'action_type'=>'opening','from_custodian_type'=>null,'from_custodian_id'=>null,'to_custodian_type'=>$a['custodian_type'],'to_custodian_id'=>$a['custodian_id'],'customer_id'=>$a['customer_id'],'from_usage_status'=>null,'to_usage_status'=>$a['usage_status'],'from_condition_status'=>null,'to_condition_status'=>$a['condition_status'],'qty'=>$a['qty'],'item_movement_id'=>null,'notes'=>'Saldo awal migrasi; movement lama tidak digandakan.','created_at'=>date('Y-m-d H:i:s')));$this->db->where(array('id'=>$a['id'],'last_document_id'=>null))->update('technician_custody_allocations',array('last_document_id'=>$doc->id,'updated_at'=>date('Y-m-d H:i:s')));}}
}
private function trackingType(array$a){if($a['subject_type']==='customer_equipment'){$r=$this->db->select('tracking_type')->get_where('customer_equipment_registry',array('id'=>$a['subject_id']))->row();return$r?$r->tracking_type:'UNIT';}$r=$this->db->select("COALESCE(k.tracking_type,'QTY') tracking_type",false)->from('items i')->join('kode_barang k','k.id=i.kode_id','left')->where('i.id',$a['item_id'])->get()->row();return$r?$r->tracking_type:'QTY';}
public function down(){throw new RuntimeException('Dokumen saldo awal dipertahankan untuk audit.');}
}
@@ -0,0 +1,27 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Add_technician_attachment_permission extends CI_Migration
{
public function up()
{
if (!$this->db->table_exists('roles')) return;
foreach ($this->db->get('roles')->result() as $role) {
$permissions = json_decode((string) $role->permissions, true);
if (!is_array($permissions) || isset($permissions['technician_attachment'])) continue;
$source = $permissions['technician_equipment'] ?? ($permissions['items'] ?? array());
if (in_array('can_view', (array) $source, true)) {
$permissions['technician_attachment'] = array('can_view');
$this->db->where('id', $role->id)->update('roles', array(
'permissions' => json_encode($permissions),
'updated_at' => date('Y-m-d H:i:s')
));
}
}
}
public function down()
{
throw new RuntimeException('Permission lampiran peralatan teknisi dipertahankan.');
}
}