Compare commits

..

No commits in common. "ceb8c97531d7320046ae5253fb63022e7a8b289d" and "3d1e24d641e536260d937691a8fe3783771d8720" have entirely different histories.

3 changed files with 134 additions and 325 deletions

View File

@ -25,161 +25,147 @@ export const getRawLogs = async (ctx: Context) => {
}
};
// Helper function to generate an array of dates between two boundaries
const getDaysArray = (start: string, end: string): string[] => {
const arr: string[] = [];
const dt = new Date(start);
const endDt = new Date(end);
while (dt <= endDt) {
arr.push(new Date(dt).toISOString().split('T')[0]);
dt.setDate(dt.getDate() + 1);
}
return arr;
};
/**
* Timezone-safe Daily Processing Engine
*/
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.body = { success: false, message: "No dates found to process." };
return;
}
const amsConnection = await amsDb.getConnection();
let totalProcessedRecords = 0;
try {
await amsConnection.beginTransaction();
const body = ctx.request.hasBody ? await ctx.request.body.json() : {};
const { work_date } = body;
// 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
WHERE DATE(attendance_time) = ?
ORDER BY employee_code, attendance_time ASC`,
[targetDate]
);
if (rawLogs.length === 0) continue;
// Group raw logs by employee_code for this specific day
const groupedLogs = rawLogs.reduce((acc: any, log: any) => {
if (!acc[log.employee_code]) acc[log.employee_code] = [];
acc[log.employee_code].push(log.att_time);
return acc;
}, {});
// 3. Process calculations per employee for this targetDate
for (const empCode in groupedLogs) {
// Look up employee metadata from EMS
const [empRows]: any = await emsDb.execute(
`SELECT employee_id, is_active FROM employees WHERE employee_code = ?`,
[empCode]
);
if (empRows.length === 0 || !empRows[0].is_active) {
console.log(`[AMS Worker] Skipping unmapped or inactive code: ${empCode}`);
continue;
}
const employeeId = empRows[0].employee_id;
const punches = groupedLogs[empCode];
// Fetch shift details (Defaulting to shift_id 1 for evaluation)
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 };
let check_in = null;
let check_out = null;
let worked_hours = 0.0;
let check_in_status = "ON_TIME";
let final_status = "ABSENT";
if (punches.length >= 2) {
check_in = punches[0];
check_out = punches[punches.length - 1];
const firstPunchMs = new Date(check_in.replace(' ', 'T')).getTime();
const lastPunchMs = new Date(check_out.replace(' ', 'T')).getTime();
worked_hours = Math.round(((lastPunchMs - firstPunchMs) / (1000 * 60 * 60)) * 100) / 100;
const rawCheckInTimeStr = check_in.split(' ')[1];
const [punchH, punchM, punchS] = rawCheckInTimeStr.split(':').map(Number);
const [shiftH, shiftM, shiftS] = shift.start_time.split(':').map(Number);
if ((punchH * 3600 + punchM * 60 + punchS) > (shiftH * 3600 + shiftM * 60 + shift.grace_period_minutes * 60)) {
check_in_status = "LATE";
}
if (worked_hours >= 7.0) final_status = "FULL_DAY";
else if (worked_hours >= 4.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";
}
// 4. Upsert calculations atomically into the processed daily ledger
await amsConnection.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 (?, 1, 1, ?, ?, ?, ?, ?, ?)
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)`,
[employeeId, targetDate, check_in, check_out, worked_hours, check_in_status, final_status]
);
totalProcessedRecords++;
}
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++;
}
await amsConnection.commit();
ctx.response.status = 200;
ctx.response.body = {
success: true,
message: `Successfully processed ${totalProcessedRecords} logs across ${datesToProcess.length} days.`
message: `Successfully processed attendance for ${processedCount} employees on ${work_date}.`,
};
} catch (error) {
await amsConnection.rollback();
console.error("Bulk processing failed:", error);
console.error("Processing Engine Error:", error);
ctx.response.status = 500;
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
} finally {
amsConnection.release();
ctx.response.body = { success: false, message: "Internal Server Processing Error." };
}
};

View File

@ -1,173 +0,0 @@
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,6 +1,5 @@
import { Router } from "@oak/oak";
import { getRawLogs, processDailyAttendance } from "./controllers/attendance.controller.ts";
import {createRegularizationRequest, reviewRegularizationRequest} from "./controllers/regularization.controller.ts"
const router = new Router();
const apiV1 = new Router();
@ -8,9 +7,6 @@ const apiV1 = new Router();
apiV1.get("/attendance/logs", getRawLogs);
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());
export default router;