db->table_exists($table) && !$this->db->field_exists($column, $table)) { $this->db->query("ALTER TABLE `{$table}` ADD `{$column}` {$definition}"); } } private function indexExists($table, $index) { return (bool) $this->db->query("SHOW INDEX FROM `{$table}` WHERE Key_name=?", array($index))->row(); } private function index($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() { foreach (array( 'attendance_start_date'=>'DATE NULL AFTER `end_date`', 'attendance_end_date'=>'DATE NULL AFTER `attendance_start_date`', 'payment_date'=>'DATE NULL AFTER `attendance_end_date`', 'notes'=>'TEXT NULL AFTER `payment_date`', 'attendance_locked_by'=>'INT NULL AFTER `notes`', 'attendance_locked_at'=>'DATETIME NULL AFTER `attendance_locked_by`', 'calculated_by'=>'INT NULL AFTER `attendance_locked_at`', 'calculated_at'=>'DATETIME NULL AFTER `calculated_by`', 'calculation_version'=>'INT NOT NULL DEFAULT 0 AFTER `calculated_at`', 'updated_at'=>'DATETIME NULL AFTER `created_at`' ) as $column=>$definition) $this->column('k_payroll_periods', $column, $definition); $this->db->query("UPDATE k_payroll_periods SET attendance_start_date=COALESCE(attendance_start_date,start_date), attendance_end_date=COALESCE(attendance_end_date,end_date), payment_date=COALESCE(payment_date,end_date) WHERE attendance_start_date IS NULL OR attendance_end_date IS NULL OR payment_date IS NULL"); $this->index('k_payroll_periods', 'idx_payroll_period_range', '`company_id`,`attendance_start_date`,`attendance_end_date`,`workflow_status`'); foreach (array( 'period_start_date'=>'DATE NULL AFTER `calculation_snapshot`', 'period_end_date'=>'DATE NULL AFTER `period_start_date`', 'structure_version'=>'INT NOT NULL DEFAULT 1 AFTER `period_end_date`' ) as $column=>$definition) $this->column('k_payrolls', $column, $definition); $this->db->query("UPDATE k_payrolls x JOIN k_payroll_periods p ON p.id=x.payroll_period_id SET x.period_start_date=COALESCE(x.period_start_date,p.attendance_start_date,p.start_date),x.period_end_date=COALESCE(x.period_end_date,p.attendance_end_date,p.end_date)"); $this->index('k_payrolls', 'idx_payroll_company_period_employee', '`company_id`,`payroll_period_id`,`employee_id`'); foreach (array( 'company_id'=>'BIGINT UNSIGNED NULL FIRST', 'calculation_method'=>"VARCHAR(40) NOT NULL DEFAULT 'fixed' AFTER `category`", 'default_value'=>'DECIMAL(18,2) NULL AFTER `fixed_amount`', 'effective_from'=>'DATE NULL AFTER `is_active`', 'effective_to'=>'DATE NULL AFTER `effective_from`', 'created_by'=>'INT NULL AFTER `effective_to`', 'created_at'=>'DATETIME NULL AFTER `created_by`', 'updated_at'=>'DATETIME NULL AFTER `created_at`' ) as $column=>$definition) $this->column('payroll_components', $column, $definition); $firstCompany = $this->db->select_min('id', 'id')->get('companies')->row(); $firstCompanyId = $firstCompany ? (int) $firstCompany->id : 1; $this->db->query("UPDATE payroll_components SET company_id=? WHERE company_id IS NULL", array($firstCompanyId)); if ($this->indexExists('payroll_components', 'uq_payroll_component')) { $this->db->query('ALTER TABLE payroll_components DROP INDEX uq_payroll_component'); } $this->index('payroll_components', 'uq_payroll_component_company', '`company_id`,`code`', true); $this->index('payroll_components', 'idx_payroll_component_effective', '`company_id`,`is_active`,`effective_from`,`effective_to`'); $this->db->query("UPDATE payroll_components SET default_value=COALESCE(default_value,fixed_amount),created_at=COALESCE(created_at,NOW())"); foreach (array( 'company_id'=>'BIGINT UNSIGNED NULL FIRST', 'calculation_method'=>"VARCHAR(40) NOT NULL DEFAULT 'fixed' AFTER `component_id`", 'formula_expression'=>'VARCHAR(500) NULL AFTER `amount_override`', 'notes'=>'TEXT NULL AFTER `is_active`', 'created_by'=>'INT NULL AFTER `notes`', 'created_at'=>'DATETIME NULL AFTER `created_by`', 'updated_at'=>'DATETIME NULL AFTER `created_at`' ) as $column=>$definition) $this->column('employee_payroll_components', $column, $definition); $this->db->query("UPDATE employee_payroll_components x JOIN k_employees e ON e.id=x.employee_id SET x.company_id=e.company_id,x.created_at=COALESCE(x.created_at,NOW()) WHERE x.company_id IS NULL OR x.created_at IS NULL"); $this->index('employee_payroll_components', 'idx_employee_component_scope', '`company_id`,`employee_id`,`component_id`,`is_active`'); $this->index('employee_payroll_components', 'idx_employee_component_effective', '`company_id`,`effective_from`,`effective_to`'); $this->db->query("CREATE TABLE IF NOT EXISTS `position_payroll_components` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `company_id` BIGINT UNSIGNED NOT NULL, `position_id` BIGINT NOT NULL, `component_id` BIGINT UNSIGNED NOT NULL, `calculation_method` VARCHAR(40) NOT NULL DEFAULT 'fixed', `amount_override` DECIMAL(18,2) NULL, `formula_expression` VARCHAR(500) NULL, `effective_from` DATE NOT NULL, `effective_to` DATE NULL, `is_active` TINYINT(1) NOT NULL DEFAULT 1, `notes` TEXT NULL, `created_by` INT NULL, `created_at` DATETIME NOT NULL, `updated_at` DATETIME NULL, PRIMARY KEY (`id`), UNIQUE KEY `uq_position_component_version` (`company_id`,`position_id`,`component_id`,`effective_from`), KEY `idx_position_component_effective` (`company_id`,`position_id`,`is_active`,`effective_from`,`effective_to`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); $this->db->query("CREATE TABLE IF NOT EXISTS `payroll_attendance_rules` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `company_id` BIGINT UNSIGNED NOT NULL, `component_id` BIGINT UNSIGNED NULL, `rule_name` VARCHAR(120) NOT NULL, `condition_type` VARCHAR(40) NOT NULL, `calculation_method` VARCHAR(40) NOT NULL DEFAULT 'none', `tolerance_minutes` INT NOT NULL DEFAULT 0, `rate_value` DECIMAL(18,4) NOT NULL DEFAULT 0, `maximum_amount` DECIMAL(18,2) NULL, `formula_expression` VARCHAR(500) NULL, `effective_from` DATE NOT NULL, `effective_to` DATE NULL, `is_active` TINYINT(1) NOT NULL DEFAULT 1, `notes` TEXT NULL, `created_by` INT NULL, `created_at` DATETIME NOT NULL, `updated_at` DATETIME NULL, PRIMARY KEY (`id`), KEY `idx_attendance_rule_scope` (`company_id`,`condition_type`,`is_active`), KEY `idx_attendance_rule_effective` (`company_id`,`effective_from`,`effective_to`), KEY `idx_attendance_rule_component` (`component_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); $this->db->query("CREATE TABLE IF NOT EXISTS `payroll_attendance_locks` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `company_id` BIGINT UNSIGNED NOT NULL, `payroll_period_id` BIGINT NOT NULL, `start_date` DATE NOT NULL, `end_date` DATE NOT NULL, `status` ENUM('open','locked') NOT NULL DEFAULT 'open', `locked_by` INT NULL, `locked_at` DATETIME NULL, `notes` TEXT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uq_payroll_attendance_lock_period` (`company_id`,`payroll_period_id`), KEY `idx_payroll_attendance_lock_range` (`company_id`,`status`,`start_date`,`end_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); $this->db->query("CREATE TABLE IF NOT EXISTS `payroll_period_adjustments` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `company_id` BIGINT UNSIGNED NOT NULL, `payroll_period_id` BIGINT NOT NULL, `employee_id` BIGINT NOT NULL, `component_id` BIGINT UNSIGNED NOT NULL, `adjustment_type` ENUM('earning','deduction') NOT NULL, `amount` DECIMAL(18,2) NOT NULL, `notes` TEXT NOT NULL, `attachment_path` 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`), KEY `idx_period_adjustment_scope` (`company_id`,`payroll_period_id`,`employee_id`,`is_active`), KEY `idx_period_adjustment_component` (`component_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); foreach (array( 'calculation_method'=>"VARCHAR(40) NOT NULL DEFAULT 'fixed' AFTER `component_type`", 'source_snapshot'=>'LONGTEXT NULL AFTER `formula_snapshot`', 'attendance_rule_snapshot'=>'LONGTEXT NULL AFTER `source_snapshot`' ) as $column=>$definition) $this->column('payroll_calculation_lines', $column, $definition); $this->index('payroll_calculation_lines', 'idx_payroll_line_component', '`component_id`,`component_type`'); $this->column('payroll_payments', 'company_id', 'BIGINT UNSIGNED NULL FIRST'); $this->db->query("UPDATE payroll_payments x JOIN k_payroll_periods p ON p.id=x.payroll_period_id SET x.company_id=p.company_id WHERE x.company_id IS NULL"); $this->index('payroll_payments', 'idx_payroll_payment_company_status', '`company_id`,`status`,`payment_date`'); // Migrasi satu kali: gaji pokok lama hanya menjadi assignment bila karyawan belum punya struktur gaji. $this->db->query("INSERT INTO employee_payroll_components (company_id,employee_id,component_id,calculation_method,amount_override,effective_from,effective_to,is_active,notes,created_at) SELECT e.company_id,e.id,c.id,'fixed',GREATEST(COALESCE(e.basic_salary,0),0),COALESCE(e.join_date,'2000-01-01'),NULL,1,'Migrasi aman dari k_employees.basic_salary',NOW() FROM k_employees e JOIN payroll_components c ON c.company_id=e.company_id AND c.code='BASIC' WHERE e.company_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM employee_payroll_components x WHERE x.company_id=e.company_id AND x.employee_id=e.id)"); // Keterlambatan aman secara default: dicatat tetapi tidak otomatis memotong gaji. $this->db->query("INSERT INTO payroll_attendance_rules (company_id,component_id,rule_name,condition_type,calculation_method,tolerance_minutes,rate_value,effective_from,is_active,notes,created_at) SELECT c.id,NULL,'Keterlambatan tanpa potongan','late','none',0,0,'2000-01-01',1,'Default: keterlambatan tidak dipotong sampai aturan diubah.',NOW() FROM companies c WHERE NOT EXISTS (SELECT 1 FROM payroll_attendance_rules r WHERE r.company_id=c.id AND r.condition_type='late')"); // Trigger lama tetap dipertahankan sebagai kompatibilitas; rentang fleksibel ditambahkan. $rangeConditionNew = "EXISTS(SELECT 1 FROM payroll_attendance_locks l JOIN k_employees e ON e.id=NEW.employee_id WHERE l.company_id=e.company_id AND l.status='locked' AND NEW.attendance_date BETWEEN l.start_date AND l.end_date)"; $rangeConditionOld = "EXISTS(SELECT 1 FROM payroll_attendance_locks l JOIN k_employees e ON e.id=OLD.employee_id WHERE l.company_id=e.company_id AND l.status='locked' AND OLD.attendance_date BETWEEN l.start_date AND l.end_date)"; $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 {$rangeConditionNew} OR 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 {$rangeConditionOld} OR 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 {$rangeConditionOld} OR 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"); // Kolom lama sengaja tidak dihapus. k_employees.basic_salary hanya compatibility mirror/read-only fallback. } public function down() { throw new RuntimeException('Migration payroll bersifat data-safe; gunakan backup untuk rollback struktur.'); } }