Compare commits
No commits in common. "ceb8c97531d7320046ae5253fb63022e7a8b289d" and "3d1e24d641e536260d937691a8fe3783771d8720" have entirely different histories.
ceb8c97531
...
3d1e24d641
@ -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." };
|
||||
}
|
||||
};
|
||||
@ -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();
|
||||
}
|
||||
};
|
||||
@ -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;
|
||||
Loading…
x
Reference in New Issue
Block a user