update HR dan Payroll

This commit is contained in:
Wian Drs
2026-09-14 11:14:25 +07:00
parent ec94783db4
commit 63993ee76e
50 changed files with 2432 additions and 1473 deletions
@@ -0,0 +1,207 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migration_Finalize_flexible_payroll 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, $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.');
}
}