Update besar apk finance
This commit is contained in:
@@ -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.
|
||||
}
|
||||
}
|
||||
+35
@@ -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.
|
||||
}
|
||||
}
|
||||
+26
@@ -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.');}
|
||||
}
|
||||
+144
@@ -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.');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user