Compare commits
No commits in common. "3d1e24d641e536260d937691a8fe3783771d8720" and "e0fdcb062aced1ef63bc77aee299a182b8acbde5" have entirely different histories.
3d1e24d641
...
e0fdcb062a
@ -1,171 +0,0 @@
|
||||
import { Context } from "@oak/oak";
|
||||
import { getDbPool } from "../../shared/db.ts";
|
||||
|
||||
const amsDb = getDbPool("hrms_ams");
|
||||
const emsDb = getDbPool("hrms_ems");
|
||||
|
||||
/**
|
||||
* Fetch top 20 raw biometric records for debugging/auditing
|
||||
*/
|
||||
export const getRawLogs = async (ctx: Context) => {
|
||||
try {
|
||||
const [rows] = await amsDb.execute(
|
||||
"SELECT * FROM attendance_raw_logs ORDER BY attendance_time DESC LIMIT 20"
|
||||
);
|
||||
|
||||
ctx.response.status = 200;
|
||||
ctx.response.body = {
|
||||
success: true,
|
||||
data: rows,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("DB Error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { success: false, message: "Database query failed" };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Timezone-safe Daily Processing Engine
|
||||
*/
|
||||
export const processDailyAttendance = async (ctx: Context) => {
|
||||
try {
|
||||
const body = ctx.request.hasBody ? await ctx.request.body.json() : {};
|
||||
const { work_date } = body;
|
||||
|
||||
if (!work_date) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { success: false, message: "work_date (YYYY-MM-DD) is required." };
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Fetch raw logs as strings directly from the DB to preserve device-local times
|
||||
const [rawLogs]: any = await amsDb.execute(
|
||||
`SELECT employee_code, DATE_FORMAT(attendance_time, '%Y-%m-%d %H:%i:%s') as attendance_time
|
||||
FROM attendance_raw_logs
|
||||
WHERE DATE(attendance_time) = ?
|
||||
ORDER BY employee_code, attendance_time ASC`,
|
||||
[work_date]
|
||||
);
|
||||
|
||||
if (rawLogs.length === 0) {
|
||||
ctx.response.status = 200;
|
||||
ctx.response.body = { success: true, message: `No raw logs found for ${work_date}.` };
|
||||
return;
|
||||
}
|
||||
|
||||
// Group logs by employee_code as local string arrays
|
||||
const groupedLogs = rawLogs.reduce((acc: any, log: any) => {
|
||||
if (!acc[log.employee_code]) acc[log.employee_code] = [];
|
||||
acc[log.employee_code].push(log.attendance_time);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
let processedCount = 0;
|
||||
|
||||
// 2. Process logs for each unique employee code
|
||||
for (const empCode of Object.keys(groupedLogs)) {
|
||||
const punches: string[] = groupedLogs[empCode];
|
||||
|
||||
// Map external biometric string code to internal EMS corporate entities
|
||||
const [empMetaData]: any = await emsDb.execute(
|
||||
`SELECT employee_id, company_id, branch_id
|
||||
FROM employees
|
||||
WHERE employee_code = ? AND is_active = TRUE LIMIT 1`,
|
||||
[empCode]
|
||||
);
|
||||
|
||||
if (empMetaData.length === 0) {
|
||||
console.warn(`[AMS Worker] Skipping unmapped or inactive code: ${empCode}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { employee_id, company_id, branch_id } = empMetaData[0];
|
||||
|
||||
// Fetch operational shift profile rules using the Fallback pattern
|
||||
const [shiftData]: any = await amsDb.execute(
|
||||
`SELECT start_time, end_time, grace_period_minutes, is_night_shift
|
||||
FROM shifts
|
||||
WHERE company_id = ? AND (branch_id = ? OR branch_id IS NULL)
|
||||
ORDER BY branch_id DESC LIMIT 1`,
|
||||
[company_id, branch_id]
|
||||
);
|
||||
|
||||
const shift = shiftData[0] || { start_time: "09:30:00", grace_period_minutes: 15, is_night_shift: false };
|
||||
|
||||
let check_in: string | null = null;
|
||||
let check_out: string | null = null;
|
||||
let worked_hours = 0.00;
|
||||
let check_in_status = "ABSENT";
|
||||
let final_status = "ABSENT";
|
||||
|
||||
if (punches.length >= 2) {
|
||||
check_in = punches[0];
|
||||
check_out = punches[punches.length - 1];
|
||||
|
||||
// Safely parse internal local duration metrics via string substitution
|
||||
const firstPunchMs = new Date(check_in.replace(' ', 'T')).getTime();
|
||||
const lastPunchMs = new Date(check_out.replace(' ', 'T')).getTime();
|
||||
const diffMs = lastPunchMs - firstPunchMs;
|
||||
worked_hours = Math.round((diffMs / (1000 * 60 * 60)) * 100) / 100;
|
||||
|
||||
// Perform time calculations purely in total seconds to bypass UTC offset traps
|
||||
const rawCheckInTimeStr = check_in.split(' ')[1];
|
||||
const [punchH, punchM, punchS] = rawCheckInTimeStr.split(':').map(Number);
|
||||
const [shiftH, shiftM, shiftS] = shift.start_time.split(':').map(Number);
|
||||
|
||||
const punchSeconds = (punchH * 3600) + (punchM * 60) + punchS;
|
||||
const shiftSeconds = (shiftH * 3600) + (shiftM * 60) + shiftS;
|
||||
const graceSeconds = shift.grace_period_minutes * 60;
|
||||
|
||||
// Determine Punctuality Status for the Check-In ledger column
|
||||
if (punchSeconds > (shiftSeconds + graceSeconds)) {
|
||||
check_in_status = "LATE";
|
||||
} else {
|
||||
check_in_status = "ON_TIME";
|
||||
}
|
||||
|
||||
// Apply corporate duration rules
|
||||
// Evaluate Shift Duration Rules securely within ENUM compliance bounds
|
||||
if (worked_hours >= 7.0) {
|
||||
final_status = "FULL_DAY"; // Safely maps to the allowed database ENUM value
|
||||
} else if (worked_hours >= 4.0 && worked_hours < 7.0) {
|
||||
final_status = "HALF_DAY";
|
||||
} else {
|
||||
final_status = "ABSENT";
|
||||
}
|
||||
|
||||
} else if (punches.length === 1) {
|
||||
check_in = punches[0];
|
||||
check_in_status = "MISPUNCH";
|
||||
final_status = "MISPUNCH";
|
||||
}
|
||||
|
||||
// 3. Operational Upsert into the Processed Attendance Summary ledger
|
||||
await amsDb.execute(
|
||||
`INSERT INTO processed_daily_attendance
|
||||
(employee_id, company_id, branch_id, work_date, check_in, check_out, worked_hours, check_in_status, final_status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
check_in = VALUES(check_in),
|
||||
check_out = VALUES(check_out),
|
||||
worked_hours = VALUES(worked_hours),
|
||||
check_in_status = VALUES(check_in_status),
|
||||
final_status = VALUES(final_status)`,
|
||||
[employee_id, company_id, branch_id, work_date, check_in, check_out, worked_hours, check_in_status, final_status]
|
||||
);
|
||||
|
||||
processedCount++;
|
||||
}
|
||||
|
||||
ctx.response.status = 200;
|
||||
ctx.response.body = {
|
||||
success: true,
|
||||
message: `Successfully processed attendance for ${processedCount} employees on ${work_date}.`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Processing Engine Error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { success: false, message: "Internal Server Processing Error." };
|
||||
}
|
||||
};
|
||||
@ -1,16 +0,0 @@
|
||||
import { Application } from "@oak/oak";
|
||||
import router from "./routes.ts";
|
||||
|
||||
const app = new Application();
|
||||
const PORT = 8002; // Running AMS on 8002 to avoid conflict with EMS on 8001
|
||||
|
||||
app.use(async (ctx, next) => {
|
||||
console.log(`[AMS] ${ctx.request.method} ${ctx.request.url.pathname}`);
|
||||
await next();
|
||||
});
|
||||
|
||||
app.use(router.routes());
|
||||
app.use(router.allowedMethods());
|
||||
|
||||
console.log(`AMS Service running on http://localhost:${PORT}`);
|
||||
await app.listen({ port: PORT });
|
||||
@ -1,12 +0,0 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { getRawLogs, processDailyAttendance } from "./controllers/attendance.controller.ts";
|
||||
|
||||
const router = new Router();
|
||||
const apiV1 = new Router();
|
||||
|
||||
apiV1.get("/attendance/logs", getRawLogs);
|
||||
apiV1.post("/attendance/process-daily", processDailyAttendance);
|
||||
|
||||
router.use("/api/v1", apiV1.routes(), apiV1.allowedMethods());
|
||||
|
||||
export default router;
|
||||
@ -1,9 +1,9 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
hrms_db:
|
||||
ems_mysql:
|
||||
image: docker.io/library/mysql:8.4
|
||||
container_name: hrms_db
|
||||
container_name: hrms_ems_db
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
MYSQL_DATABASE: hrms_ems
|
||||
@ -12,9 +12,9 @@ services:
|
||||
ports:
|
||||
- "3306:3306"
|
||||
volumes:
|
||||
- ./init-db:/docker-entrypoint-initdb.d:Z
|
||||
- hrms_db_data:/var/lib/mysql
|
||||
- ./init-db:/docker-entrypoint-initdb.d:Z # <-- Added :Z here
|
||||
- mysql_ems_data:/var/lib/mysql
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
hrms_db_data:
|
||||
mysql_ems_data:
|
||||
@ -1,6 +1,4 @@
|
||||
import { getDbPool } from "../../shared/db.ts"
|
||||
|
||||
const pool = getDbPool("hrms_ems");
|
||||
import pool from "../../shared/db.ts";
|
||||
|
||||
export const getEmployeeContracts = async (ctx: any) => {
|
||||
const employeeId = ctx.params.id;
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
import { getDbPool } from "../../shared/db.ts"
|
||||
|
||||
const pool = getDbPool("hrms_ems");
|
||||
import pool from "../../shared/db.ts";
|
||||
|
||||
export const getEmployees = async (ctx: any) => {
|
||||
try {
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
import { getDbPool } from "../../shared/db.ts"
|
||||
|
||||
const pool = getDbPool("hrms_ems");
|
||||
import pool from "../../shared/db.ts";
|
||||
|
||||
export const getCompanies = async (ctx: any) => {
|
||||
try {
|
||||
|
||||
@ -1,97 +0,0 @@
|
||||
import { Context } from "@oak/oak";
|
||||
import { getDbPool } from "../../shared/db.ts";
|
||||
|
||||
const emsPool = getDbPool("hrms_ems");
|
||||
|
||||
export const bulkSeedEmployees = async (ctx: Context) => {
|
||||
const body = ctx.request.hasBody ? await ctx.request.body.json() : [];
|
||||
|
||||
if (!Array.isArray(body) || body.length === 0) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { success: false, message: "Provide an array of employee logs." };
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = await emsPool.getConnection();
|
||||
let insertedCount = 0;
|
||||
|
||||
try {
|
||||
// Start transaction to keep data clean
|
||||
await connection.beginTransaction();
|
||||
|
||||
for (const item of body) {
|
||||
const { emp_code, name, designation, department, email } = item;
|
||||
|
||||
// Split first and last name if possible, fallback if singular name
|
||||
const nameParts = name.trim().split(" ");
|
||||
const firstName = nameParts[0] || "Employee";
|
||||
const lastName = nameParts.slice(1).join(" ") || "LNU";
|
||||
|
||||
// 1. Insert into partners (identity layer) with fallback defaults
|
||||
const [partnerResult]: any = await connection.execute(
|
||||
`INSERT INTO partners (first_name, last_name, dob, gender, personal_email, personal_phone)
|
||||
VALUES (?, ?, '1995-01-01', 'Not Specified', ?, ?)
|
||||
ON DUPLICATE KEY UPDATE partner_id = LAST_INSERT_ID(partner_id)`,
|
||||
[firstName, lastName, `personal.${email}`, `MOCK_${emp_code}`]
|
||||
);
|
||||
const partnerId = partnerResult.insertId;
|
||||
|
||||
// 2. Ensure the department exists, dynamically fetch or create its ID
|
||||
const [deptResult]: any = await connection.execute(
|
||||
`INSERT INTO departments (company_id, name)
|
||||
VALUES (1, ?)
|
||||
ON DUPLICATE KEY UPDATE department_id = LAST_INSERT_ID(department_id)`,
|
||||
[department || "General"]
|
||||
);
|
||||
const departmentId = deptResult.insertId;
|
||||
|
||||
// 3. Ensure the job position exists, dynamically fetch or create its ID
|
||||
const [jobResult]: any = await connection.execute(
|
||||
`INSERT INTO job_positions (company_id, title)
|
||||
VALUES (1, ?)
|
||||
ON DUPLICATE KEY UPDATE job_id = LAST_INSERT_ID(job_id)`,
|
||||
[designation || "Trainee"]
|
||||
);
|
||||
const jobId = jobResult.insertId;
|
||||
|
||||
// 4. Create the core Employee record mapping to the KENT code
|
||||
const [empResult]: any = await connection.execute(
|
||||
`INSERT INTO employees (employee_code, partner_id, company_id, branch_id, is_active)
|
||||
VALUES (?, ?, 1, 1, TRUE)
|
||||
ON DUPLICATE KEY UPDATE employee_id = LAST_INSERT_ID(employee_id)`,
|
||||
[emp_code, partnerId]
|
||||
);
|
||||
const employeeId = empResult.insertId;
|
||||
|
||||
// 5. Establish the operational Contract record with the corporate work email
|
||||
await connection.execute(
|
||||
`INSERT INTO contracts (employee_id, department_id, job_id, work_email, date_joining, status, salary_structure_id)
|
||||
VALUES (?, ?, ?, ?, '2026-01-01', 'ACTIVE', 100)
|
||||
ON DUPLICATE KEY UPDATE work_email = VALUES(work_email)`,
|
||||
[employeeId, departmentId, jobId, email]
|
||||
);
|
||||
|
||||
insertedCount++;
|
||||
}
|
||||
|
||||
// Commit changes safely to EMS
|
||||
await connection.commit();
|
||||
|
||||
ctx.response.status = 200;
|
||||
ctx.response.body = {
|
||||
success: true,
|
||||
message: `Successfully seeded/aligned ${insertedCount} normalized employee profiles.`,
|
||||
};
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
console.error("Seeding failed, changes rolled back:", error);
|
||||
|
||||
// Fixed: Cast error to 'any' or fallback to a string to satisfy deno-ts(18046)
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
||||
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { success: false, error: errorMessage };
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
};
|
||||
@ -17,10 +17,6 @@ import {
|
||||
createContract
|
||||
} from "./controllers/contract.controller.ts";
|
||||
|
||||
import {
|
||||
bulkSeedEmployees
|
||||
} from "./controllers/system.controller.ts"
|
||||
|
||||
const router = new Router();
|
||||
|
||||
// --- Employee Core Endpoints ---
|
||||
@ -62,6 +58,4 @@ router.get("/api/v1/departments", getDepartments);
|
||||
//List company designations and employment titles.
|
||||
router.get("/api/v1/jobs", getJobs);
|
||||
|
||||
router.post("/api/v1/system/seed-employees", bulkSeedEmployees);
|
||||
|
||||
export default router;
|
||||
@ -1,63 +0,0 @@
|
||||
CREATE DATABASE IF NOT EXISTS hrms_ams;
|
||||
USE hrms_ams;
|
||||
|
||||
-- Grant access to the existing admin user
|
||||
GRANT ALL PRIVILEGES ON hrms_ams.* TO 'admin'@'%';
|
||||
FLUSH PRIVILEGES;
|
||||
|
||||
-- 1. Shift Schedules (Supports the Fallback Pattern)
|
||||
CREATE TABLE shifts (
|
||||
shift_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
company_id INT NOT NULL, -- Logical Reference from EMS
|
||||
branch_id INT NULL, -- Fallback Pattern: NULL means applies company-wide
|
||||
shift_name VARCHAR(50) NOT NULL,
|
||||
start_time TIME NOT NULL,
|
||||
end_time TIME NOT NULL,
|
||||
grace_period_minutes INT DEFAULT 15,
|
||||
is_night_shift BOOLEAN DEFAULT FALSE, -- Handles overnight calculations (e.g., 22:00 to 06:00)
|
||||
INDEX idx_fallback_lookup (company_id, branch_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 2. Raw Biometric Scan Sink (Ingested directly from KENT Device pipeline)
|
||||
CREATE TABLE attendance_raw_logs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
attendance_time TIMESTAMP NOT NULL,
|
||||
device_id VARCHAR(50) NOT NULL,
|
||||
device_name VARCHAR(100) NULL,
|
||||
employee_id INT NULL, -- Logical Reference from EMS
|
||||
employee_code VARCHAR(20) NOT NULL, -- Cached for quick biometric mapping checks
|
||||
employee_name VARCHAR(100) NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_emp_time (employee_id, attendance_time)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 3. Processed Attendance Summary (Populated nightly via automated Worker Engine)
|
||||
CREATE TABLE processed_daily_attendance (
|
||||
attendance_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
employee_id INT NOT NULL, -- Logical Reference from EMS
|
||||
company_id INT NOT NULL, -- Cached to accelerate scoped multi-branch analytics
|
||||
branch_id INT NOT NULL, -- Cached to accelerate scoped multi-branch analytics
|
||||
work_date DATE NOT NULL,
|
||||
check_in TIMESTAMP NULL,
|
||||
check_out TIMESTAMP NULL,
|
||||
worked_hours DECIMAL(5,2) DEFAULT 0.00,
|
||||
check_in_status ENUM('ON_TIME', 'LATE', 'MISPUNCH', 'ABSENT') NOT NULL,
|
||||
final_status ENUM('FULL_DAY', 'HALF_DAY', 'ABSENT', 'MISPUNCH') NOT NULL,
|
||||
UNIQUE KEY uq_emp_date (employee_id, work_date),
|
||||
INDEX idx_reporting_lookup (company_id, branch_id, work_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 4. Correction Workflow Requests
|
||||
CREATE TABLE attendance_regularizations (
|
||||
regularization_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
employee_id INT NOT NULL, -- Logical Reference (The Applicant)
|
||||
attendance_id INT NULL, -- Internal FK to processed ledger row
|
||||
regularization_type ENUM('MISPUNCH', 'WFH_REQUEST', 'ON_DUTY') NOT NULL,
|
||||
target_date DATE NOT NULL,
|
||||
requested_check_in TIMESTAMP NULL,
|
||||
requested_check_out TIMESTAMP NULL,
|
||||
reason TEXT NOT NULL,
|
||||
status ENUM('PENDING', 'APPROVED', 'REJECTED') DEFAULT 'PENDING',
|
||||
reviewed_by_id INT NOT NULL, -- Logical Reference to Manager's employee_id
|
||||
FOREIGN KEY (attendance_id) REFERENCES processed_daily_attendance(attendance_id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
19
shared/db.ts
19
shared/db.ts
@ -1,23 +1,16 @@
|
||||
import mysql from "mysql2/promise";
|
||||
|
||||
const dbConfig = {
|
||||
// Create a connection pool using the credentials from docker-compose.yml
|
||||
const pool = mysql.createPool({
|
||||
host: "127.0.0.1",
|
||||
port: 3306,
|
||||
user: "admin",
|
||||
password: "admin123",
|
||||
database: "hrms_ems",
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0,
|
||||
};
|
||||
});
|
||||
|
||||
const pools: Record<string, mysql.Pool> = {};
|
||||
|
||||
export const getDbPool = (databaseName: string): mysql.Pool => {
|
||||
if (!pools[databaseName]) {
|
||||
pools[databaseName] = mysql.createPool({
|
||||
...dbConfig,
|
||||
database: databaseName,
|
||||
});
|
||||
}
|
||||
return pools[databaseName];
|
||||
};
|
||||
// Export the pool to be used across your EMS, AMS, and LMS services
|
||||
export default pool;
|
||||
Loading…
x
Reference in New Issue
Block a user