update HR dan Payroll
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
|
||||
class Migration_Add_user_profile_and_login_security extends CI_Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
$columns=array(
|
||||
'email'=>array('type'=>'VARCHAR','constraint'=>150,'null'=>true,'after'=>'nama'),
|
||||
'profile_photo'=>array('type'=>'VARCHAR','constraint'=>255,'null'=>true,'after'=>'email'),
|
||||
'session_duration_hours'=>array('type'=>'SMALLINT','constraint'=>5,'unsigned'=>true,'default'=>24,'after'=>'profile_photo'),
|
||||
'max_active_devices'=>array('type'=>'TINYINT','constraint'=>3,'unsigned'=>true,'default'=>1,'after'=>'session_duration_hours'),
|
||||
'profile_updated_at'=>array('type'=>'DATETIME','null'=>true,'after'=>'max_active_devices'),
|
||||
);
|
||||
foreach($columns as$name=>$definition)if(!$this->db->field_exists($name,'users'))$this->dbforge->add_column('users',array($name=>$definition));
|
||||
|
||||
if(!$this->db->table_exists('user_login_sessions')){
|
||||
$this->db->query("CREATE TABLE `user_login_sessions` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT NOT NULL,
|
||||
`login_key` CHAR(64) NOT NULL,
|
||||
`session_id_hash` CHAR(64) NOT NULL,
|
||||
`login_at` DATETIME NOT NULL,
|
||||
`expires_at` DATETIME NOT NULL,
|
||||
`last_seen_at` DATETIME NOT NULL,
|
||||
`ip_address` VARCHAR(45) NULL,
|
||||
`user_agent` VARCHAR(512) NULL,
|
||||
`device_type` VARCHAR(30) NULL,
|
||||
`platform` VARCHAR(80) NULL,
|
||||
`browser` VARCHAR(80) NULL,
|
||||
`status` ENUM('active','logged_out','expired','revoked') NOT NULL DEFAULT 'active',
|
||||
`ended_at` DATETIME NULL,
|
||||
`end_reason` VARCHAR(255) NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_user_login_key` (`login_key`),
|
||||
KEY `idx_user_login_active` (`user_id`,`status`,`expires_at`),
|
||||
KEY `idx_user_session_hash` (`session_id_hash`),
|
||||
KEY `idx_user_last_seen` (`user_id`,`last_seen_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||
}
|
||||
|
||||
if(!$this->db->table_exists('user_login_attempts')){
|
||||
$this->db->query("CREATE TABLE `user_login_attempts` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT NULL,
|
||||
`username` VARCHAR(100) NULL,
|
||||
`attempted_at` DATETIME NOT NULL,
|
||||
`ip_address` VARCHAR(45) NULL,
|
||||
`user_agent` VARCHAR(512) NULL,
|
||||
`device_type` VARCHAR(30) NULL,
|
||||
`platform` VARCHAR(80) NULL,
|
||||
`browser` VARCHAR(80) NULL,
|
||||
`result` ENUM('success','invalid_credentials','device_limit') NOT NULL,
|
||||
`reason` VARCHAR(255) NULL,
|
||||
`active_session_count` SMALLINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`device_limit` SMALLINT UNSIGNED NOT NULL DEFAULT 1,
|
||||
`login_session_id` BIGINT UNSIGNED NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_login_attempt_user_time` (`user_id`,`attempted_at`),
|
||||
KEY `idx_login_attempt_result_time` (`result`,`attempted_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||
}
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
throw new RuntimeException('Riwayat keamanan login dan data profil dipertahankan.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
|
||||
/** Menambahkan workflow pengajuan cuti tanpa menghapus data cuti lama. */
|
||||
class Migration_Finalize_leave_request_workflow extends CI_Migration
|
||||
{
|
||||
private function addColumn($name, array $definition)
|
||||
{
|
||||
if (!$this->db->field_exists($name, 'k_leave_requests')) {
|
||||
$this->load->dbforge();
|
||||
$this->dbforge->add_column('k_leave_requests', array($name => $definition));
|
||||
}
|
||||
}
|
||||
|
||||
private function hasIndex($name)
|
||||
{
|
||||
$row = $this->db->query(
|
||||
"SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='k_leave_requests' AND index_name=? LIMIT 1",
|
||||
array($name)
|
||||
)->row();
|
||||
return (bool) $row;
|
||||
}
|
||||
|
||||
public function up()
|
||||
{
|
||||
if (!$this->db->table_exists('k_leave_requests')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addColumn('company_id', array('type'=>'BIGINT','constraint'=>20,'unsigned'=>true,'null'=>true,'after'=>'id'));
|
||||
$this->addColumn('request_no', array('type'=>'VARCHAR','constraint'=>40,'null'=>true,'after'=>'company_id'));
|
||||
$this->addColumn('workflow_status', array('type'=>'ENUM','constraint'=>array('draft','submitted','approved','rejected','cancelled'),'default'=>'draft','after'=>'approval_status'));
|
||||
$this->addColumn('approval_request_id', array('type'=>'BIGINT','constraint'=>20,'unsigned'=>true,'null'=>true,'after'=>'workflow_status'));
|
||||
$this->addColumn('created_by', array('type'=>'INT','constraint'=>11,'null'=>true,'after'=>'approval_request_id'));
|
||||
$this->addColumn('submitted_by', array('type'=>'INT','constraint'=>11,'null'=>true,'after'=>'created_by'));
|
||||
$this->addColumn('submitted_at', array('type'=>'DATETIME','null'=>true,'after'=>'submitted_by'));
|
||||
$this->addColumn('rejected_by', array('type'=>'INT','constraint'=>11,'null'=>true,'after'=>'approved_at'));
|
||||
$this->addColumn('rejected_at', array('type'=>'DATETIME','null'=>true,'after'=>'rejected_by'));
|
||||
$this->addColumn('rejection_reason', array('type'=>'TEXT','null'=>true,'after'=>'rejected_at'));
|
||||
$this->addColumn('cancelled_by', array('type'=>'INT','constraint'=>11,'null'=>true,'after'=>'rejection_reason'));
|
||||
$this->addColumn('cancelled_at', array('type'=>'DATETIME','null'=>true,'after'=>'cancelled_by'));
|
||||
$this->addColumn('updated_by', array('type'=>'INT','constraint'=>11,'null'=>true,'after'=>'cancelled_at'));
|
||||
$this->addColumn('updated_at', array('type'=>'DATETIME','null'=>true,'after'=>'updated_by'));
|
||||
$this->addColumn('version', array('type'=>'INT','constraint'=>11,'default'=>1,'after'=>'updated_at'));
|
||||
|
||||
$this->db->query("UPDATE k_leave_requests lr JOIN k_employees e ON e.id=lr.employee_id SET lr.company_id=e.company_id WHERE lr.company_id IS NULL");
|
||||
$this->db->query("UPDATE k_leave_requests SET request_no=CONCAT('LV-',DATE_FORMAT(COALESCE(created_at,NOW()),'%Y%m%d'),'-',LPAD(id,6,'0')) WHERE request_no IS NULL OR request_no='' ");
|
||||
$this->db->query("UPDATE k_leave_requests SET workflow_status=CASE approval_status WHEN 'approved' THEN 'approved' WHEN 'rejected' THEN 'rejected' ELSE 'draft' END");
|
||||
$this->db->query("ALTER TABLE k_leave_requests MODIFY request_no VARCHAR(40) NOT NULL");
|
||||
|
||||
if (!$this->hasIndex('uq_leave_company_no')) {
|
||||
$this->db->query('CREATE UNIQUE INDEX uq_leave_company_no ON k_leave_requests(company_id,request_no)');
|
||||
}
|
||||
if (!$this->hasIndex('idx_leave_company_workflow')) {
|
||||
$this->db->query('CREATE INDEX idx_leave_company_workflow ON k_leave_requests(company_id,workflow_status,start_date)');
|
||||
}
|
||||
|
||||
if ($this->db->table_exists('approval_workflows') && !$this->db->where('code','LEAVE_REQUEST_DEFAULT')->count_all_results('approval_workflows')) {
|
||||
$role = $this->db->where('nama_role','HR & Payroll')->get('roles')->row();
|
||||
if (!$role) {
|
||||
$role = $this->db->where('nama_role','Admin')->get('roles')->row();
|
||||
}
|
||||
$this->db->insert('approval_workflows', array(
|
||||
'code'=>'LEAVE_REQUEST_DEFAULT', 'module'=>'hr', 'entity_type'=>'leave_request',
|
||||
'name'=>'Persetujuan Cuti & Izin', 'require_separation'=>1,
|
||||
'is_active'=>1, 'created_at'=>date('Y-m-d H:i:s')
|
||||
));
|
||||
$workflowId = (int) $this->db->insert_id();
|
||||
$this->db->insert('approval_workflow_steps', array(
|
||||
'workflow_id'=>$workflowId, 'step_order'=>1, 'name'=>'Persetujuan HR',
|
||||
'approver_role_id'=>$role ? (int)$role->id : null, 'approver_user_id'=>null
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
throw new RuntimeException('Workflow cuti dipertahankan untuk menjaga histori persetujuan.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
|
||||
/** Memastikan approver HR dapat membuka Kotak Persetujuan. */
|
||||
class Migration_Grant_hr_leave_approval_access extends CI_Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
if(!$this->db->table_exists('roles'))return;
|
||||
$role=$this->db->where('nama_role','HR & Payroll')->get('roles')->row();
|
||||
if(!$role)return;
|
||||
$permissions=json_decode((string)$role->permissions,true);
|
||||
if(!is_array($permissions))$permissions=array();
|
||||
$permissions['approvals']=array_values(array_unique(array_merge((array)($permissions['approvals']??array()),array('can_view','can_approve'))));
|
||||
$update=array('permissions'=>json_encode($permissions));
|
||||
if($this->db->field_exists('updated_at','roles'))$update['updated_at']=date('Y-m-d H:i:s');
|
||||
$this->db->where('id',$role->id)->update('roles',$update);
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
throw new RuntimeException('Hak akses approval HR dipertahankan agar workflow cuti tetap dapat diproses.');
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
|
||||
class Migration_Finalize_hr_permissions_and_manual_attendance extends CI_Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
if (!$this->db->field_exists('salary_restricted', 'k_employees')) {
|
||||
$this->db->query("ALTER TABLE `k_employees` ADD `salary_restricted` TINYINT(1) NOT NULL DEFAULT 0 AFTER `basic_salary`");
|
||||
}
|
||||
|
||||
$this->db->query("CREATE TABLE IF NOT EXISTS `manual_attendance_requests` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`company_id` BIGINT UNSIGNED NOT NULL,
|
||||
`request_no` VARCHAR(50) NOT NULL,
|
||||
`employee_id` BIGINT NOT NULL,
|
||||
`attendance_date` DATE NOT NULL,
|
||||
`attendance_time` TIME NOT NULL,
|
||||
`attendance_action` ENUM('check_in','check_out') NOT NULL,
|
||||
`reason` TEXT NOT NULL,
|
||||
`status` ENUM('draft','submitted','approved','rejected','cancelled') NOT NULL DEFAULT 'draft',
|
||||
`approval_request_id` BIGINT UNSIGNED NULL,
|
||||
`created_by` INT 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,
|
||||
`applied_attendance_id` BIGINT NULL,
|
||||
`created_at` DATETIME NOT NULL,
|
||||
`updated_at` DATETIME NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_manual_attendance_no` (`company_id`,`request_no`),
|
||||
KEY `idx_manual_attendance_queue` (`company_id`,`status`,`attendance_date`),
|
||||
KEY `idx_manual_attendance_employee` (`company_id`,`employee_id`,`attendance_date`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||
|
||||
$admin = $this->db->where('nama_role', 'Admin')->get('roles')->row();
|
||||
if (!$this->db->where('code', 'MANUAL_ATTENDANCE')->count_all_results('approval_workflows')) {
|
||||
$this->db->insert('approval_workflows', array(
|
||||
'code'=>'MANUAL_ATTENDANCE', 'module'=>'hr', 'entity_type'=>'manual_attendance',
|
||||
'name'=>'Persetujuan Absensi Manual', 'require_separation'=>1, 'is_active'=>1,
|
||||
'created_at'=>date('Y-m-d H:i:s')
|
||||
));
|
||||
$workflowId = (int) $this->db->insert_id();
|
||||
$approver = $this->db->where('nama_role', 'HR & Payroll')->get('roles')->row();
|
||||
if (!$approver) $approver = $admin;
|
||||
if ($approver) {
|
||||
$this->db->insert('approval_workflow_steps', array(
|
||||
'workflow_id'=>$workflowId, 'step_order'=>1, 'name'=>'Persetujuan HR',
|
||||
'approver_role_id'=>$approver->id
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
$featureMap = array(
|
||||
'hr_organization'=>array('can_view','can_create','can_update','can_delete'),
|
||||
'hr_employees'=>array('can_view','can_create','can_update','can_delete','can_export','can_import'),
|
||||
'hr_schedules'=>array('can_view','can_create','can_update','can_delete','can_post','can_export'),
|
||||
'hr_attendance'=>array('can_view','can_create','can_update','can_submit','can_approve','can_post','can_export'),
|
||||
'hr_leave'=>array('can_view','can_create','can_update','can_submit','can_approve','can_export'),
|
||||
'payroll'=>array('can_view','can_create','can_update','can_approve','can_post','can_export')
|
||||
);
|
||||
foreach ($this->db->get('roles')->result() as $role) {
|
||||
if (!empty($role->is_super_admin)) continue;
|
||||
$permissions = json_decode($role->permissions, true) ?: array();
|
||||
$legacy = isset($permissions['employees']) && is_array($permissions['employees']) ? $permissions['employees'] : array();
|
||||
if ($legacy) {
|
||||
foreach ($featureMap as $feature=>$allowed) {
|
||||
$copy = array_values(array_intersect($legacy, $allowed));
|
||||
if ($copy && !isset($permissions[$feature])) $permissions[$feature] = $copy;
|
||||
}
|
||||
}
|
||||
if (in_array($role->nama_role, array('Finance & Treasury','Keuangan'), true) && !isset($permissions['payroll'])) {
|
||||
$permissions['payroll'] = array('can_view','can_create','can_update','can_post','can_export');
|
||||
}
|
||||
$this->db->where('id', $role->id)->update('roles', array('permissions'=>json_encode($permissions)));
|
||||
}
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
throw new RuntimeException('Gunakan backup untuk rollback finalisasi HR & Payroll.');
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
|
||||
class Migration_Seed_payroll_for_all_companies extends CI_Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
$source=$this->db->select_min('company_id','company_id')->get('payroll_components')->row();
|
||||
if($source&&$source->company_id){
|
||||
$this->db->query("INSERT INTO payroll_components (company_id,code,name,component_type,category,calculation_method,formula_expression,fixed_amount,default_value,taxable,prorated,expense_account_id,liability_account_id,is_active,effective_from,effective_to,created_at)
|
||||
SELECT co.id,p.code,p.name,p.component_type,p.category,p.calculation_method,p.formula_expression,p.fixed_amount,p.default_value,p.taxable,p.prorated,NULL,NULL,p.is_active,p.effective_from,p.effective_to,NOW()
|
||||
FROM companies co JOIN payroll_components p ON p.company_id=?
|
||||
WHERE NOT EXISTS(SELECT 1 FROM payroll_components x WHERE x.company_id=co.id AND x.code=p.code)",array((int)$source->company_id));
|
||||
}
|
||||
$this->db->query("INSERT INTO employee_payroll_components (company_id,employee_id,component_id,calculation_method,amount_override,effective_from,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'),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)");
|
||||
$this->db->query("INSERT INTO payroll_attendance_rules (company_id,rule_name,condition_type,calculation_method,tolerance_minutes,rate_value,effective_from,is_active,notes,created_at)
|
||||
SELECT c.id,'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')");
|
||||
$this->db->query("CREATE TABLE IF NOT EXISTS payroll_settings (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,company_id BIGINT UNSIGNED NOT NULL,
|
||||
separation_of_duties TINYINT(1) NOT NULL DEFAULT 1,default_payment_day TINYINT UNSIGNED NULL,
|
||||
bank_export_note VARCHAR(255) NULL,created_by INT NULL,created_at DATETIME NOT NULL,updated_at DATETIME NULL,
|
||||
PRIMARY KEY(id),UNIQUE KEY uq_payroll_settings_company(company_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||
$this->db->query("INSERT INTO payroll_settings(company_id,separation_of_duties,created_at) SELECT id,1,NOW() FROM companies c WHERE NOT EXISTS(SELECT 1 FROM payroll_settings s WHERE s.company_id=c.id)");
|
||||
}
|
||||
public function down(){throw new RuntimeException('Gunakan backup untuk rollback payroll multi-company.');}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
|
||||
class Migration_Scope_cash_accounts_for_payroll extends CI_Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
if(!$this->db->field_exists('company_id','cash_accounts')){
|
||||
$this->db->query("ALTER TABLE cash_accounts ADD company_id BIGINT UNSIGNED NULL AFTER id");
|
||||
}
|
||||
if($this->db->table_exists('accounts')&&$this->db->field_exists('company_id','accounts')){
|
||||
$this->db->query("UPDATE cash_accounts c JOIN accounts a ON a.id=c.gl_account_id SET c.company_id=a.company_id WHERE c.company_id IS NULL AND a.company_id IS NOT NULL");
|
||||
}
|
||||
$company=$this->db->select_min('id','id')->get('companies')->row();
|
||||
if($company)$this->db->where('company_id IS NULL',null,false)->update('cash_accounts',array('company_id'=>(int)$company->id));
|
||||
if(!$this->db->query("SHOW INDEX FROM cash_accounts WHERE Key_name='idx_cash_accounts_company_active'")->row()){
|
||||
$this->db->query("ALTER TABLE cash_accounts ADD KEY idx_cash_accounts_company_active(company_id,is_active,name)");
|
||||
}
|
||||
}
|
||||
|
||||
public function down(){throw new RuntimeException('Gunakan backup untuk rollback company scope cash account.');}
|
||||
}
|
||||
Reference in New Issue
Block a user