Compare commits

..

2 Commits

Author SHA1 Message Date
3d1e24d641 Merge pull request 'feat(ams,ems): add ams service, timezone-safe processing engine, and dynamic employee seeding' (#6) from feature/ams-module into main
Reviewed-on: #6

Merge branch 'feature/ams-module' into main

Features and Fixes:
- Initialized the dynamic Attendance Management Service (AMS) module.
- Implemented a timezone-safe daily processing engine utilizing text-based database date extractions.
- Built a transactional, multi-table bulk-seeding API endpoint in the EMS module to parse and align flat employee payloads.
- Refactored the shared database connection infrastructure to dynamically yield generic MySQL pools for cross-service connectivity.
2026-07-07 11:30:08 +05:30
96ec15e95c feat(ams,ems): add ams service, timezone-safe processing engine, and dynamic employee seeding 2026-07-07 11:25:52 +05:30
11 changed files with 392 additions and 14 deletions

View File

@ -0,0 +1,171 @@
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." };
}
};

16
ams-service/main.ts Normal file
View File

@ -0,0 +1,16 @@
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 });

12
ams-service/routes.ts Normal file
View File

@ -0,0 +1,12 @@
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;

View File

@ -1,9 +1,9 @@
version: '3.8'
services:
ems_mysql:
hrms_db:
image: docker.io/library/mysql:8.4
container_name: hrms_ems_db
container_name: hrms_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 # <-- Added :Z here
- mysql_ems_data:/var/lib/mysql
- ./init-db:/docker-entrypoint-initdb.d:Z
- hrms_db_data:/var/lib/mysql
restart: unless-stopped
volumes:
mysql_ems_data:
hrms_db_data:

View File

@ -1,4 +1,6 @@
import pool from "../../shared/db.ts";
import { getDbPool } from "../../shared/db.ts"
const pool = getDbPool("hrms_ems");
export const getEmployeeContracts = async (ctx: any) => {
const employeeId = ctx.params.id;

View File

@ -1,4 +1,6 @@
import pool from "../../shared/db.ts";
import { getDbPool } from "../../shared/db.ts"
const pool = getDbPool("hrms_ems");
export const getEmployees = async (ctx: any) => {
try {

View File

@ -1,4 +1,6 @@
import pool from "../../shared/db.ts";
import { getDbPool } from "../../shared/db.ts"
const pool = getDbPool("hrms_ems");
export const getCompanies = async (ctx: any) => {
try {

View File

@ -0,0 +1,97 @@
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();
}
};

View File

@ -17,6 +17,10 @@ import {
createContract
} from "./controllers/contract.controller.ts";
import {
bulkSeedEmployees
} from "./controllers/system.controller.ts"
const router = new Router();
// --- Employee Core Endpoints ---
@ -58,4 +62,6 @@ 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;

63
init-db/03-init-ams.sql Normal file
View File

@ -0,0 +1,63 @@
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;

View File

@ -1,16 +1,23 @@
import mysql from "mysql2/promise";
// Create a connection pool using the credentials from docker-compose.yml
const pool = mysql.createPool({
const dbConfig = {
host: "127.0.0.1",
port: 3306,
user: "admin",
password: "admin123",
database: "hrms_ems",
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
});
};
// Export the pool to be used across your EMS, AMS, and LMS services
export default pool;
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];
};