Compare commits

..

2 Commits

Author SHA1 Message Date
ceb8c97531 Merge pull request 'feat(ams): implement bulk processing engine and dynamic regularization review loop' (#7) from feature/ams-module into main
Reviewed-on: #7

Closes historical processing execution bottlenecks. Delivers robust bulk data calculations, structural fixes for database type alignment, and an enterprise admin approval architecture for managing attendance modifications.
2026-07-07 13:30:10 +05:30
596577c20b feat(ams): implement bulk processing engine and dynamic regularization review loop 2026-07-07 13:37:39 +05:30
3 changed files with 312 additions and 121 deletions

View File

@ -25,115 +25,123 @@ export const getRawLogs = async (ctx: Context) => {
} }
}; };
/** // Helper function to generate an array of dates between two boundaries
* Timezone-safe Daily Processing Engine const getDaysArray = (start: string, end: string): string[] => {
*/ const arr: string[] = [];
export const processDailyAttendance = async (ctx: Context) => { const dt = new Date(start);
try { const endDt = new Date(end);
const body = ctx.request.hasBody ? await ctx.request.body.json() : {}; while (dt <= endDt) {
const { work_date } = body; arr.push(new Date(dt).toISOString().split('T')[0]);
dt.setDate(dt.getDate() + 1);
}
return arr;
};
if (!work_date) { export const processDailyAttendance = async (ctx: Context) => {
const body = ctx.request.hasBody ? await ctx.request.body.json() : {};
let datesToProcess: string[] = [];
// 1. Determine date execution scope
if (body.work_date) {
datesToProcess.push(body.work_date);
} else if (body.start_date && body.end_date) {
datesToProcess = getDaysArray(body.start_date, body.end_date);
} else {
// Safely pull unique dates using direct string formatting in SQL to avoid Deno timezone shifting bugs
const [dateRows]: any = await amsDb.execute(
`SELECT DISTINCT DATE_FORMAT(attendance_time, '%Y-%m-%d') as raw_date FROM attendance_raw_logs ORDER BY raw_date ASC`
);
// Explicitly guarantee we extract the exact date string format without hitting Deno Date constructor limits
datesToProcess = dateRows.map((r: any) => {
if (r.raw_date instanceof Date) {
// Fallback if the driver parses it into a Date object despite format rules
const offset = r.raw_date.getTimezoneOffset();
const localDate = new Date(r.raw_date.getTime() - (offset * 60 * 1000));
return localDate.toISOString().split('T')[0];
}
return String(r.raw_date); // Clean 'YYYY-MM-DD' string representation
});
}
if (datesToProcess.length === 0) {
ctx.response.status = 400; ctx.response.status = 400;
ctx.response.body = { success: false, message: "work_date (YYYY-MM-DD) is required." }; ctx.response.body = { success: false, message: "No dates found to process." };
return; return;
} }
// 1. Fetch raw logs as strings directly from the DB to preserve device-local times const amsConnection = await amsDb.getConnection();
const [rawLogs]: any = await amsDb.execute( let totalProcessedRecords = 0;
`SELECT employee_code, DATE_FORMAT(attendance_time, '%Y-%m-%d %H:%i:%s') as attendance_time
try {
await amsConnection.beginTransaction();
// Loop through every single date sequentially
for (const targetDate of datesToProcess) {
// 2. Fetch raw logs specifically grouped for this targetDate
const [rawLogs]: any = await amsConnection.execute(
`SELECT employee_code, DATE_FORMAT(attendance_time, '%Y-%m-%d %H:%i:%s') as att_time
FROM attendance_raw_logs FROM attendance_raw_logs
WHERE DATE(attendance_time) = ? WHERE DATE(attendance_time) = ?
ORDER BY employee_code, attendance_time ASC`, ORDER BY employee_code, attendance_time ASC`,
[work_date] [targetDate]
); );
if (rawLogs.length === 0) { if (rawLogs.length === 0) continue;
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 // Group raw logs by employee_code for this specific day
const groupedLogs = rawLogs.reduce((acc: any, log: any) => { const groupedLogs = rawLogs.reduce((acc: any, log: any) => {
if (!acc[log.employee_code]) acc[log.employee_code] = []; if (!acc[log.employee_code]) acc[log.employee_code] = [];
acc[log.employee_code].push(log.attendance_time); acc[log.employee_code].push(log.att_time);
return acc; return acc;
}, {}); }, {});
let processedCount = 0; // 3. Process calculations per employee for this targetDate
for (const empCode in groupedLogs) {
// 2. Process logs for each unique employee code // Look up employee metadata from EMS
for (const empCode of Object.keys(groupedLogs)) { const [empRows]: any = await emsDb.execute(
const punches: string[] = groupedLogs[empCode]; `SELECT employee_id, is_active FROM employees WHERE employee_code = ?`,
// 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] [empCode]
); );
if (empMetaData.length === 0) { if (empRows.length === 0 || !empRows[0].is_active) {
console.warn(`[AMS Worker] Skipping unmapped or inactive code: ${empCode}`); console.log(`[AMS Worker] Skipping unmapped or inactive code: ${empCode}`);
continue; continue;
} }
const { employee_id, company_id, branch_id } = empMetaData[0]; const employeeId = empRows[0].employee_id;
const punches = groupedLogs[empCode];
// Fetch operational shift profile rules using the Fallback pattern // Fetch shift details (Defaulting to shift_id 1 for evaluation)
const [shiftData]: any = await amsDb.execute( const [shiftRows]: any = await amsConnection.execute(
`SELECT start_time, end_time, grace_period_minutes, is_night_shift `SELECT start_time, grace_period_minutes FROM shifts WHERE shift_id = 1`
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 = shiftRows[0] || { start_time: "10:00:00", grace_period_minutes: 10 };
const shift = shiftData[0] || { start_time: "09:30:00", grace_period_minutes: 15, is_night_shift: false }; let check_in = null;
let check_out = null;
let check_in: string | null = null; let worked_hours = 0.0;
let check_out: string | null = null; let check_in_status = "ON_TIME";
let worked_hours = 0.00;
let check_in_status = "ABSENT";
let final_status = "ABSENT"; let final_status = "ABSENT";
if (punches.length >= 2) { if (punches.length >= 2) {
check_in = punches[0]; check_in = punches[0];
check_out = punches[punches.length - 1]; 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 firstPunchMs = new Date(check_in.replace(' ', 'T')).getTime();
const lastPunchMs = new Date(check_out.replace(' ', 'T')).getTime(); const lastPunchMs = new Date(check_out.replace(' ', 'T')).getTime();
const diffMs = lastPunchMs - firstPunchMs; worked_hours = Math.round(((lastPunchMs - firstPunchMs) / (1000 * 60 * 60)) * 100) / 100;
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 rawCheckInTimeStr = check_in.split(' ')[1];
const [punchH, punchM, punchS] = rawCheckInTimeStr.split(':').map(Number); const [punchH, punchM, punchS] = rawCheckInTimeStr.split(':').map(Number);
const [shiftH, shiftM, shiftS] = shift.start_time.split(':').map(Number); const [shiftH, shiftM, shiftS] = shift.start_time.split(':').map(Number);
const punchSeconds = (punchH * 3600) + (punchM * 60) + punchS; if ((punchH * 3600 + punchM * 60 + punchS) > (shiftH * 3600 + shiftM * 60 + shift.grace_period_minutes * 60)) {
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"; check_in_status = "LATE";
} else {
check_in_status = "ON_TIME";
} }
// Apply corporate duration rules if (worked_hours >= 7.0) final_status = "FULL_DAY";
// Evaluate Shift Duration Rules securely within ENUM compliance bounds else if (worked_hours >= 4.0) final_status = "HALF_DAY";
if (worked_hours >= 7.0) { else final_status = "ABSENT";
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) { } else if (punches.length === 1) {
check_in = punches[0]; check_in = punches[0];
@ -141,31 +149,37 @@ if (worked_hours >= 7.0) {
final_status = "MISPUNCH"; final_status = "MISPUNCH";
} }
// 3. Operational Upsert into the Processed Attendance Summary ledger // 4. Upsert calculations atomically into the processed daily ledger
await amsDb.execute( await amsConnection.execute(
`INSERT INTO processed_daily_attendance `INSERT INTO processed_daily_attendance
(employee_id, company_id, branch_id, work_date, check_in, check_out, worked_hours, check_in_status, final_status) (employee_id, company_id, branch_id, work_date, check_in, check_out, worked_hours, check_in_status, final_status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, 1, 1, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
check_in = VALUES(check_in), check_in = VALUES(check_in),
check_out = VALUES(check_out), check_out = VALUES(check_out),
worked_hours = VALUES(worked_hours), worked_hours = VALUES(worked_hours),
check_in_status = VALUES(check_in_status), check_in_status = VALUES(check_in_status),
final_status = VALUES(final_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] [employeeId, targetDate, check_in, check_out, worked_hours, check_in_status, final_status]
); );
processedCount++; totalProcessedRecords++;
}
} }
await amsConnection.commit();
ctx.response.status = 200; ctx.response.status = 200;
ctx.response.body = { ctx.response.body = {
success: true, success: true,
message: `Successfully processed attendance for ${processedCount} employees on ${work_date}.`, message: `Successfully processed ${totalProcessedRecords} logs across ${datesToProcess.length} days.`
}; };
} catch (error) { } catch (error) {
console.error("Processing Engine Error:", error); await amsConnection.rollback();
console.error("Bulk processing failed:", error);
ctx.response.status = 500; ctx.response.status = 500;
ctx.response.body = { success: false, message: "Internal Server Processing Error." }; ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
} finally {
amsConnection.release();
} }
}; };

View File

@ -0,0 +1,173 @@
import { Context } from "@oak/oak";
import { getDbPool } from "../../shared/db.ts";
const amsDb = getDbPool("hrms_ams");
export const createRegularizationRequest = async (ctx: Context) => {
if (!ctx.request.hasBody) {
ctx.response.status = 400;
ctx.response.body = { success: false, message: "Missing request body." };
return;
}
const body = await ctx.request.body.json();
const { attendance_id, employee_id, requested_check_in, requested_check_out, reason } = body;
// Basic validation
if (!attendance_id || !employee_id || !reason || !requested_check_in) {
ctx.response.status = 400;
ctx.response.body = { success: false, message: "Missing required fields: attendance_id, employee_id, requested_check_in, and reason." };
return;
}
try {
// Extract 'YYYY-MM-DD' safely from the requested_check_in string
const target_date = requested_check_in.split(' ')[0];
// Insert into the attendance_regularizations table including target_date
await amsDb.execute(
`INSERT INTO attendance_regularizations
(attendance_id, employee_id, target_date, requested_check_in, requested_check_out, reason, status)
VALUES (?, ?, ?, ?, ?, ?, 'PENDING')`,
[attendance_id, employee_id, target_date, requested_check_in, requested_check_out || null, reason]
);
ctx.response.status = 201;
ctx.response.body = {
success: true,
message: "Regularization request submitted successfully and is pending approval.",
};
} catch (error) {
console.error("Failed to submit regularization:", error);
ctx.response.status = 500;
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
}
};
export const reviewRegularizationRequest = async (ctx: Context) => {
if (!ctx.request.hasBody) {
ctx.response.status = 400;
ctx.response.body = { success: false, message: "Missing request body." };
return;
}
const body = await ctx.request.body.json();
const { regularization_id, action, reviewed_by_id } = body; // action can be 'APPROVED' or 'REJECTED'
if (!regularization_id || !reviewed_by_id || !["APPROVED", "REJECTED"].includes(action)) {
ctx.response.status = 400;
ctx.response.body = { success: false, message: "Invalid payload. Required: regularization_id, reviewed_by_id, action." };
return;
}
const amsConnection = await amsDb.getConnection();
try {
await amsConnection.beginTransaction();
// 1. Fetch the regularization request details
const [requestRows]: any = await amsConnection.execute(
`SELECT
attendance_id,
DATE_FORMAT(requested_check_in, '%Y-%m-%d %H:%i:%s') as requested_check_in,
DATE_FORMAT(requested_check_out, '%Y-%m-%d %H:%i:%s') as requested_check_out,
status
FROM attendance_regularizations WHERE regularization_id = ?`,
[regularization_id]
);
if (requestRows.length === 0) {
ctx.response.status = 404;
ctx.response.body = { success: false, message: "Regularization request not found." };
await amsConnection.rollback();
return;
}
const request = requestRows[0];
if (request.status !== "PENDING") {
ctx.response.status = 400;
ctx.response.body = { success: false, message: "This request has already been processed." };
await amsConnection.rollback();
return;
}
// 2. Update the regularization request record status and reviewer ID (Removed 'remarks')
await amsConnection.execute(
`UPDATE attendance_regularizations
SET status = ?, reviewed_by_id = ?
WHERE regularization_id = ?`,
[action, reviewed_by_id, regularization_id]
);
// 3. If APPROVED, dynamically overwrite the target day's calculated ledger row
if (action === "APPROVED") {
let worked_hours = 0.0;
let final_status = "FULL_DAY";
let check_in_status = "ON_TIME";
// Fetch shift details to accurately re-evaluate punctuality boundaries (Defaulting to shift_id 1)
const [shiftRows]: any = await amsConnection.execute(
`SELECT start_time, grace_period_minutes FROM shifts WHERE shift_id = 1`
);
const shift = shiftRows[0] || { start_time: "10:00:00", grace_period_minutes: 10 };
if (request.requested_check_in && request.requested_check_out) {
// --- A. Recalculate Working Duration Metrics ---
const checkInMs = new Date(request.requested_check_in.replace(' ', 'T')).getTime();
const checkOutMs = new Date(request.requested_check_out.replace(' ', 'T')).getTime();
worked_hours = Math.round(((checkOutMs - checkInMs) / (1000 * 60 * 60)) * 100) / 100;
if (worked_hours >= 7.0) final_status = "FULL_DAY";
else if (worked_hours >= 4.0) final_status = "HALF_DAY";
else final_status = "ABSENT";
// --- B. Dynamically Re-evaluate Punctuality Status (Preserves LATE flags) ---
const rawCheckInTimeStr = request.requested_check_in.split(' ')[1];
const [punchH, punchM, punchS] = rawCheckInTimeStr.split(':').map(Number);
const [shiftH, shiftM, shiftS] = shift.start_time.split(':').map(Number);
const punchTotalSeconds = punchH * 3600 + punchM * 60 + punchS;
const shiftCutoffSeconds = shiftH * 3600 + shiftM * 60 + (shift.grace_period_minutes * 60);
if (punchTotalSeconds > shiftCutoffSeconds) {
check_in_status = "LATE";
}
}
await amsConnection.execute(
`UPDATE processed_daily_attendance
SET check_in = ?,
check_out = ?,
worked_hours = ?,
final_status = ?,
check_in_status = ?
WHERE attendance_id = ?`,
[
request.requested_check_in,
request.requested_check_out,
worked_hours,
final_status,
check_in_status,
request.attendance_id
]
);
}
await amsConnection.commit();
ctx.response.status = 200;
ctx.response.body = {
success: true,
message: `Request has been successfully ${action.toLowerCase()} by admin ID ${reviewed_by_id}.`,
};
} catch (error) {
await amsConnection.rollback();
console.error("Admin review action failed:", error);
ctx.response.status = 500;
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
} finally {
amsConnection.release();
}
};

View File

@ -1,5 +1,6 @@
import { Router } from "@oak/oak"; import { Router } from "@oak/oak";
import { getRawLogs, processDailyAttendance } from "./controllers/attendance.controller.ts"; import { getRawLogs, processDailyAttendance } from "./controllers/attendance.controller.ts";
import {createRegularizationRequest, reviewRegularizationRequest} from "./controllers/regularization.controller.ts"
const router = new Router(); const router = new Router();
const apiV1 = new Router(); const apiV1 = new Router();
@ -7,6 +8,9 @@ const apiV1 = new Router();
apiV1.get("/attendance/logs", getRawLogs); apiV1.get("/attendance/logs", getRawLogs);
apiV1.post("/attendance/process-daily", processDailyAttendance); apiV1.post("/attendance/process-daily", processDailyAttendance);
apiV1.post("/attendance/regularize", createRegularizationRequest);
apiV1.post("/attendance/regularize/review", reviewRegularizationRequest);
router.use("/api/v1", apiV1.routes(), apiV1.allowedMethods()); router.use("/api/v1", apiV1.routes(), apiV1.allowedMethods());
export default router; export default router;