Compare commits
19 Commits
65e0f601a0
...
1b69e9f14c
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b69e9f14c | |||
| 6187ec4afc | |||
| 1355f2e9cb | |||
| 1572456e70 | |||
| 52b2e40762 | |||
| 596577c20b | |||
| ceb8c97531 | |||
| 3d1e24d641 | |||
| 96ec15e95c | |||
| e0fdcb062a | |||
| e5fbe92dc5 | |||
| a24dd95d2a | |||
| 7a8ab80a84 | |||
| 0e61b32467 | |||
| d054934338 | |||
| eb97317b1c | |||
| aeed6868fd | |||
| 85c9b01bb2 | |||
| 4e189d7e52 |
519
ams-service/controllers/attendance.controller.ts
Normal file
519
ams-service/controllers/attendance.controller.ts
Normal file
@ -0,0 +1,519 @@
|
|||||||
|
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" };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
// 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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await amsConnection.commit();
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message: `Successfully processed ${totalProcessedRecords} logs across ${datesToProcess.length} days.`
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
await amsConnection.rollback();
|
||||||
|
console.error("Bulk processing failed:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
||||||
|
} finally {
|
||||||
|
amsConnection.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEmployeeSummary = async (ctx: Context) => {
|
||||||
|
const params = ctx.request.url.searchParams;
|
||||||
|
const employeeId = params.get("employee_id");
|
||||||
|
const startDate = params.get("start_date");
|
||||||
|
const endDate = params.get("end_date");
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
if (!employeeId || !startDate || !endDate) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: false,
|
||||||
|
message: "Missing required query parameters. Expected: employee_id, start_date, and end_date."
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch the individual breakdown rows for the period
|
||||||
|
const [rows]: any = await amsDb.execute(
|
||||||
|
`SELECT
|
||||||
|
attendance_id,
|
||||||
|
DATE_FORMAT(work_date, '%Y-%m-%d') as work_date,
|
||||||
|
DATE_FORMAT(check_in, '%H:%i:%s') as check_in,
|
||||||
|
DATE_FORMAT(check_out, '%H:%i:%s') as check_out,
|
||||||
|
worked_hours,
|
||||||
|
check_in_status,
|
||||||
|
final_status
|
||||||
|
FROM processed_daily_attendance
|
||||||
|
WHERE employee_id = ? AND work_date BETWEEN ? AND ?
|
||||||
|
ORDER BY work_date DESC`,
|
||||||
|
[employeeId, startDate, endDate]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dynamic metrics aggregation block
|
||||||
|
const summaryMetrics = rows.reduce(
|
||||||
|
(acc: any, row: any) => {
|
||||||
|
acc.total_days_tracked++;
|
||||||
|
acc.total_hours_worked += Number(row.worked_hours || 0);
|
||||||
|
|
||||||
|
// Count up categorical operational states
|
||||||
|
if (row.final_status === "FULL_DAY") acc.full_days++;
|
||||||
|
else if (row.final_status === "HALF_DAY") acc.half_days++;
|
||||||
|
else if (row.final_status === "ABSENT") acc.absences++;
|
||||||
|
else if (row.final_status === "MISPUNCH") acc.mispunches++;
|
||||||
|
|
||||||
|
if (row.check_in_status === "LATE") acc.late_arrivals++;
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
total_days_tracked: 0,
|
||||||
|
total_hours_worked: 0,
|
||||||
|
full_days: 0,
|
||||||
|
half_days: 0,
|
||||||
|
absences: 0,
|
||||||
|
mispunches: 0,
|
||||||
|
late_arrivals: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Format final working duration to a neat 2-decimal scale
|
||||||
|
summaryMetrics.total_hours_worked = Math.round(summaryMetrics.total_hours_worked * 100) / 100;
|
||||||
|
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
metrics: summaryMetrics,
|
||||||
|
history: rows
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch employee summary:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDailyReport = async (ctx: Context) => {
|
||||||
|
const params = ctx.request.url.searchParams;
|
||||||
|
const targetDate = params.get("date");
|
||||||
|
|
||||||
|
if (!targetDate) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { success: false, message: "Missing required 'date' query parameter (YYYY-MM-DD)." };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Grab all calculated attendance states for this date from AMS
|
||||||
|
const [attendanceRows]: any = await amsDb.execute(
|
||||||
|
`SELECT employee_id, attendance_id,
|
||||||
|
DATE_FORMAT(check_in, '%H:%i:%s') as check_in,
|
||||||
|
DATE_FORMAT(check_out, '%H:%i:%s') as check_out,
|
||||||
|
worked_hours, check_in_status, final_status
|
||||||
|
FROM processed_daily_attendance
|
||||||
|
WHERE work_date = ?`,
|
||||||
|
[targetDate]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Fetch active corporate names and metadata mappings from EMS
|
||||||
|
const [employeeRows]: any = await emsDb.execute(
|
||||||
|
`SELECT e.employee_id, e.employee_code, p.first_name, p.last_name
|
||||||
|
FROM employees e
|
||||||
|
INNER JOIN partners p ON e.partner_id = p.partner_id
|
||||||
|
WHERE e.is_active = TRUE`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Map the attendance records by employee_id for O(1) matching speed
|
||||||
|
const attendanceMap = attendanceRows.reduce((acc: any, row: any) => {
|
||||||
|
acc[row.employee_id] = row;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// Summary metrics counter block for the Admin dashboard cards
|
||||||
|
const summary = {
|
||||||
|
total_active_workforce: employeeRows.length,
|
||||||
|
present: 0,
|
||||||
|
late: 0,
|
||||||
|
mispunches: 0,
|
||||||
|
absent: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Seamlessly blend employee profiles with attendance states
|
||||||
|
const roster = employeeRows.map((emp: any) => {
|
||||||
|
const attendance = attendanceMap[emp.employee_id];
|
||||||
|
|
||||||
|
let status = "ABSENT";
|
||||||
|
let check_in = null;
|
||||||
|
let check_out = null;
|
||||||
|
let worked_hours = "0.00";
|
||||||
|
let punctuality = "N/A";
|
||||||
|
let attendance_id = null;
|
||||||
|
|
||||||
|
if (attendance) {
|
||||||
|
status = attendance.final_status;
|
||||||
|
check_in = attendance.check_in;
|
||||||
|
check_out = attendance.check_out;
|
||||||
|
worked_hours = attendance.worked_hours;
|
||||||
|
punctuality = attendance.check_in_status;
|
||||||
|
attendance_id = attendance.attendance_id;
|
||||||
|
|
||||||
|
// Process summary counter evaluations
|
||||||
|
if (status === "FULL_DAY" || status === "HALF_DAY") summary.present++;
|
||||||
|
if (status === "MISPUNCH") summary.mispunches++;
|
||||||
|
if (punctuality === "LATE") summary.late++;
|
||||||
|
} else {
|
||||||
|
summary.absent++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
employee_id: emp.employee_id,
|
||||||
|
employee_code: emp.employee_code,
|
||||||
|
full_name: `${emp.first_name} ${emp.last_name}`,
|
||||||
|
attendance_id,
|
||||||
|
status,
|
||||||
|
check_in,
|
||||||
|
check_out,
|
||||||
|
worked_hours,
|
||||||
|
punctuality
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
date: targetDate,
|
||||||
|
summary,
|
||||||
|
roster
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to generate daily admin report:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAdminRangeReport = async (ctx: Context) => {
|
||||||
|
const params = ctx.request.url.searchParams;
|
||||||
|
const fromDate = params.get("from_date");
|
||||||
|
const toDate = params.get("to_date");
|
||||||
|
const branchId = params.get("branch_id"); // Optional filter
|
||||||
|
const departmentId = params.get("department_id"); // Optional filter
|
||||||
|
|
||||||
|
if (!fromDate || !toDate) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { success: false, message: "Missing required date boundaries: from_date and to_date (YYYY-MM-DD)." };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Dynamically build the EMS query based on branch or department selection
|
||||||
|
let emsQuery = `
|
||||||
|
SELECT e.employee_id, e.employee_code, p.first_name, p.last_name, b.branch_name, d.name as department_name
|
||||||
|
FROM employees e
|
||||||
|
INNER JOIN partners p ON e.partner_id = p.partner_id
|
||||||
|
INNER JOIN branches b ON e.branch_id = b.branch_id
|
||||||
|
INNER JOIN contracts c ON e.employee_id = c.employee_id
|
||||||
|
INNER JOIN departments d ON c.department_id = d.department_id
|
||||||
|
WHERE e.is_active = TRUE AND c.status = 'ACTIVE'
|
||||||
|
`;
|
||||||
|
const emsParams: any[] = [];
|
||||||
|
|
||||||
|
if (branchId) {
|
||||||
|
emsQuery += ` AND e.branch_id = ?`;
|
||||||
|
emsParams.push(branchId);
|
||||||
|
}
|
||||||
|
if (departmentId) {
|
||||||
|
emsQuery += ` AND c.department_id = ?`;
|
||||||
|
emsParams.push(departmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [employees]: any = await emsDb.execute(emsQuery, emsParams);
|
||||||
|
|
||||||
|
if (employees.length === 0) {
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = { success: true, summary: {}, report: [] };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const employeeIds = employees.map((e: any) => e.employee_id);
|
||||||
|
|
||||||
|
// 2. Query AMS for all processed ledger entries matching these exact employees in the date range
|
||||||
|
// Using a parameterized string hack for the IN clause since Deno MySQL handles arrays explicitly
|
||||||
|
const placeholders = employeeIds.map(() => "?").join(",");
|
||||||
|
const [attendanceRows]: any = await amsDb.execute(
|
||||||
|
`SELECT
|
||||||
|
employee_id,
|
||||||
|
DATE_FORMAT(work_date, '%Y-%m-%d') as work_date,
|
||||||
|
DATE_FORMAT(check_in, '%H:%i:%s') as check_in,
|
||||||
|
DATE_FORMAT(check_out, '%H:%i:%s') as check_out,
|
||||||
|
worked_hours, check_in_status, final_status
|
||||||
|
FROM processed_daily_attendance
|
||||||
|
WHERE work_date BETWEEN ? AND ? AND employee_id IN (${placeholders})
|
||||||
|
ORDER BY work_date ASC`,
|
||||||
|
[fromDate, toDate, ...employeeIds]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group logs by employee_id for structural assembly
|
||||||
|
const attendanceGrouped = attendanceRows.reduce((acc: any, row: any) => {
|
||||||
|
if (!acc[row.employee_id]) acc[row.employee_id] = [];
|
||||||
|
acc[row.employee_id].push(row);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// Overall global counters for the Admin's upper summary ribbon card metrics
|
||||||
|
const globalSummary = { total_records_evaluated: attendanceRows.length, full_days: 0, half_days: 0, late_instances: 0, mispunches: 0 };
|
||||||
|
|
||||||
|
const report = employees.map((emp: any) => {
|
||||||
|
const logs = attendanceGrouped[emp.employee_id] || [];
|
||||||
|
|
||||||
|
// Calculate individual metrics summary for this specific employee across the range
|
||||||
|
const individualMetrics = logs.reduce((acc: any, log: any) => {
|
||||||
|
if (log.final_status === "FULL_DAY") { acc.full_days++; globalSummary.full_days++; }
|
||||||
|
else if (log.final_status === "HALF_DAY") { acc.half_days++; globalSummary.half_days++; }
|
||||||
|
else if (log.final_status === "MISPUNCH") { acc.mispunches++; globalSummary.mispunches++; }
|
||||||
|
|
||||||
|
if (log.check_in_status === "LATE") { acc.late_arrivals++; globalSummary.late_instances++; }
|
||||||
|
return acc;
|
||||||
|
}, { full_days: 0, half_days: 0, mispunches: 0, late_arrivals: 0 });
|
||||||
|
|
||||||
|
return {
|
||||||
|
...emp,
|
||||||
|
range_metrics: individualMetrics,
|
||||||
|
attendance_history: logs
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = { success: true, date_range: { from: fromDate, to: toDate }, global_summary: globalSummary, report };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Admin range report compilation failed:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSingleEmployeeRangeReport = async (ctx: Context) => {
|
||||||
|
const params = ctx.request.url.searchParams;
|
||||||
|
const employeeId = params.get("employee_id");
|
||||||
|
const fromDate = params.get("from_date");
|
||||||
|
const toDate = params.get("to_date");
|
||||||
|
|
||||||
|
if (!employeeId || !fromDate || !toDate) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { success: false, message: "Missing query parameters. Required: employee_id, from_date, to_date." };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Grab profile data
|
||||||
|
const [empProfile]: any = await emsDb.execute(
|
||||||
|
`SELECT e.employee_id, e.employee_code, p.first_name, p.last_name
|
||||||
|
FROM employees e INNER JOIN partners p ON e.partner_id = p.partner_id WHERE e.employee_id = ?`,
|
||||||
|
[employeeId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (empProfile.length === 0) {
|
||||||
|
ctx.response.status = 404;
|
||||||
|
ctx.response.body = { success: false, message: "Employee profile not found." };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grab attendance logs for date-to-date query window
|
||||||
|
const [logs]: any = await amsDb.execute(
|
||||||
|
`SELECT
|
||||||
|
attendance_id,
|
||||||
|
DATE_FORMAT(work_date, '%Y-%m-%d') as work_date,
|
||||||
|
DATE_FORMAT(check_in, '%H:%i:%s') as check_in,
|
||||||
|
DATE_FORMAT(check_out, '%H:%i:%s') as check_out,
|
||||||
|
worked_hours, check_in_status, final_status
|
||||||
|
FROM processed_daily_attendance
|
||||||
|
WHERE employee_id = ? AND work_date BETWEEN ? AND ?
|
||||||
|
ORDER BY work_date ASC`,
|
||||||
|
[employeeId, fromDate, toDate]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
employee: empProfile[0],
|
||||||
|
range: { from: fromDate, to: toDate },
|
||||||
|
total_days: logs.length,
|
||||||
|
history: logs
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Single employee range query failed:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
||||||
|
}
|
||||||
|
};
|
||||||
173
ams-service/controllers/regularization.controller.ts
Normal file
173
ams-service/controllers/regularization.controller.ts
Normal 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();
|
||||||
|
}
|
||||||
|
};
|
||||||
16
ams-service/main.ts
Normal file
16
ams-service/main.ts
Normal 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 });
|
||||||
28
ams-service/routes.ts
Normal file
28
ams-service/routes.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { Router } from "@oak/oak";
|
||||||
|
import {
|
||||||
|
getRawLogs,
|
||||||
|
processDailyAttendance,
|
||||||
|
getEmployeeSummary,
|
||||||
|
getDailyReport,
|
||||||
|
getAdminRangeReport,
|
||||||
|
getSingleEmployeeRangeReport} from "./controllers/attendance.controller.ts";
|
||||||
|
import {createRegularizationRequest, reviewRegularizationRequest} from "./controllers/regularization.controller.ts"
|
||||||
|
|
||||||
|
const router = new Router();
|
||||||
|
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);
|
||||||
|
|
||||||
|
apiV1.get("/attendance/my-summary", getEmployeeSummary);
|
||||||
|
|
||||||
|
apiV1.get("/attendance/daily-report", getDailyReport);
|
||||||
|
apiV1.get("/attendance/admin-report", getAdminRangeReport);
|
||||||
|
apiV1.get("/attendance/employee-range-report", getSingleEmployeeRangeReport);
|
||||||
|
|
||||||
|
router.use("/api/v1", apiV1.routes(), apiV1.allowedMethods());
|
||||||
|
|
||||||
|
export default router;
|
||||||
@ -1,9 +1,9 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
ems_mysql:
|
hrms_db:
|
||||||
image: docker.io/library/mysql:8.4
|
image: docker.io/library/mysql:8.4
|
||||||
container_name: hrms_ems_db
|
container_name: hrms_db
|
||||||
environment:
|
environment:
|
||||||
MYSQL_ROOT_PASSWORD: root
|
MYSQL_ROOT_PASSWORD: root
|
||||||
MYSQL_DATABASE: hrms_ems
|
MYSQL_DATABASE: hrms_ems
|
||||||
@ -12,9 +12,9 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3306:3306"
|
- "3306:3306"
|
||||||
volumes:
|
volumes:
|
||||||
- ./init-db:/docker-entrypoint-initdb.d:Z # <-- Added :Z here
|
- ./init-db:/docker-entrypoint-initdb.d:Z
|
||||||
- mysql_ems_data:/var/lib/mysql
|
- hrms_db_data:/var/lib/mysql
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mysql_ems_data:
|
hrms_db_data:
|
||||||
2018
ems-service/AllStaff.json
Normal file
2018
ems-service/AllStaff.json
Normal file
File diff suppressed because it is too large
Load Diff
98
ems-service/controllers/contract.controller.ts
Normal file
98
ems-service/controllers/contract.controller.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import { getDbPool } from "../../shared/db.ts"
|
||||||
|
|
||||||
|
const pool = getDbPool("hrms_ems");
|
||||||
|
|
||||||
|
export const getEmployeeContracts = async (ctx: any) => {
|
||||||
|
const employeeId = ctx.params.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
c.contract_id,
|
||||||
|
c.work_email,
|
||||||
|
c.date_joining,
|
||||||
|
c.probation_days,
|
||||||
|
c.status,
|
||||||
|
c.salary_structure_id,
|
||||||
|
d.name AS department,
|
||||||
|
j.title AS designation
|
||||||
|
FROM contracts c
|
||||||
|
JOIN employees e ON c.employee_id = e.employee_id
|
||||||
|
JOIN departments d ON c.department_id = d.department_id
|
||||||
|
JOIN job_positions j ON c.job_id = j.job_id
|
||||||
|
WHERE c.employee_id = ?
|
||||||
|
ORDER BY c.date_joining DESC`,
|
||||||
|
[employeeId]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
count: (rows as any[]).length,
|
||||||
|
data: rows
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: false,
|
||||||
|
error: (error as Error).message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createContract = async (ctx: any) => {
|
||||||
|
const body = ctx.request.body;
|
||||||
|
|
||||||
|
if (body.type() !== "json") {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { success: false, error: "Invalid JSON body" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await body.json();
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
|
||||||
|
// 1. Expire any currently active contracts for this specific employee
|
||||||
|
await connection.execute(
|
||||||
|
`UPDATE contracts SET status = 'EXPIRED' WHERE employee_id = ? AND status = 'ACTIVE'`,
|
||||||
|
[data.employeeId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Insert the brand new active contract
|
||||||
|
const [result] = await connection.execute(
|
||||||
|
`INSERT INTO contracts (employee_id, department_id, job_id, date_joining, probation_days, status, salary_structure_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
data.employeeId,
|
||||||
|
data.departmentId,
|
||||||
|
data.jobId,
|
||||||
|
data.dateJoining,
|
||||||
|
data.probationDays,
|
||||||
|
'ACTIVE',
|
||||||
|
data.salaryStructureId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
await connection.commit();
|
||||||
|
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message: "New contract executed successfully. Previous contracts expired.",
|
||||||
|
contract_id: (result as any).insertId
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
await connection.rollback();
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: false,
|
||||||
|
error: "Transaction failed: " + (error as Error).message
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
346
ems-service/controllers/employee.controller.ts
Normal file
346
ems-service/controllers/employee.controller.ts
Normal file
@ -0,0 +1,346 @@
|
|||||||
|
import { getDbPool } from "../../shared/db.ts";
|
||||||
|
import { generateNextCode } from "../../shared/sequence.ts";
|
||||||
|
|
||||||
|
const pool = getDbPool("hrms_ems");
|
||||||
|
|
||||||
|
export const getEmployees = async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
// FIX: Filter by ACTIVE contract to prevent duplicate rows for employees with past contracts
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
e.employee_id,
|
||||||
|
e.employee_code,
|
||||||
|
p.first_name,
|
||||||
|
p.last_name,
|
||||||
|
d.name AS department,
|
||||||
|
j.title AS designation,
|
||||||
|
c.work_email
|
||||||
|
FROM employees e
|
||||||
|
JOIN partners p ON e.partner_id = p.partner_id
|
||||||
|
JOIN contracts c ON e.employee_id = c.employee_id AND c.status = 'ACTIVE'
|
||||||
|
JOIN departments d ON c.department_id = d.department_id
|
||||||
|
JOIN job_positions j ON c.job_id = j.job_id
|
||||||
|
WHERE e.is_active = true`,
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
count: (rows as any[]).length,
|
||||||
|
data: rows,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: false,
|
||||||
|
error: (error as Error).message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createEmployee = async (ctx: any) => {
|
||||||
|
const body = ctx.request.body;
|
||||||
|
if (body.type() !== "json") {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { success: false, error: "Invalid JSON body" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await body.json();
|
||||||
|
let empCode = data.employeeCode;
|
||||||
|
|
||||||
|
if (!empCode || empCode.trim() === "") {
|
||||||
|
empCode = await generateNextCode("EMPLOYEE_MAIN", "CLRI", 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
|
||||||
|
const [partnerResult] = await connection.execute(
|
||||||
|
`INSERT INTO partners (first_name, last_name, dob, gender, personal_email, personal_phone)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
data.firstName,
|
||||||
|
data.lastName,
|
||||||
|
data.dob,
|
||||||
|
data.gender,
|
||||||
|
data.personalEmail,
|
||||||
|
data.personalPhone,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const partnerId = (partnerResult as any).insertId;
|
||||||
|
|
||||||
|
await connection.execute(
|
||||||
|
`INSERT INTO addresses (partner_id, address_type, door_number, landmark, address_line, pincode, district, state)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
partnerId,
|
||||||
|
data.address.type,
|
||||||
|
data.address.doorNumber,
|
||||||
|
data.address.landmark,
|
||||||
|
data.address.line,
|
||||||
|
data.address.pincode,
|
||||||
|
data.address.district,
|
||||||
|
data.address.state,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [employeeResult] = await connection.execute(
|
||||||
|
`INSERT INTO employees (employee_code, partner_id, company_id, branch_id, is_active)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[empCode, partnerId, data.companyId, data.branchId, true],
|
||||||
|
);
|
||||||
|
const employeeId = (employeeResult as any).insertId;
|
||||||
|
|
||||||
|
await connection.execute(
|
||||||
|
`INSERT INTO contracts (employee_id, department_id, job_id, work_email, date_joining, probation_days, status, salary_structure_id, reporting_to_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
employeeId,
|
||||||
|
data.departmentId,
|
||||||
|
data.jobId,
|
||||||
|
data.work_email,
|
||||||
|
data.dateJoining,
|
||||||
|
data.probationDays,
|
||||||
|
"ACTIVE",
|
||||||
|
data.salaryStructureId,
|
||||||
|
data.reportingToId || null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await connection.commit();
|
||||||
|
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message: "Employee created successfully",
|
||||||
|
employee_id: employeeId,
|
||||||
|
employee_code: empCode,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await connection.rollback();
|
||||||
|
let errorMessage = (error as Error).message;
|
||||||
|
if (errorMessage.includes("Duplicate entry")) {
|
||||||
|
ctx.response.status = 409;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"A unique constraint failed. The Employee Code, Personal Email, or Phone number already exists.",
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: false,
|
||||||
|
error: "Transaction failed: " + errorMessage,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEmployeeById = async (ctx: any) => {
|
||||||
|
const id = ctx.params.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Fetch core employee, partner, contract, and manager details
|
||||||
|
const [empRows] = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
e.employee_id,
|
||||||
|
e.employee_code,
|
||||||
|
e.is_active,
|
||||||
|
p.first_name,
|
||||||
|
p.last_name,
|
||||||
|
p.dob,
|
||||||
|
p.gender,
|
||||||
|
p.personal_email,
|
||||||
|
p.personal_phone,
|
||||||
|
c.work_email,
|
||||||
|
c.date_joining,
|
||||||
|
c.probation_days,
|
||||||
|
c.status AS contract_status,
|
||||||
|
d.name AS department,
|
||||||
|
d.department_id,
|
||||||
|
j.title AS designation,
|
||||||
|
j.job_id,
|
||||||
|
mgr_p.first_name AS manager_first_name,
|
||||||
|
mgr_p.last_name AS manager_last_name,
|
||||||
|
mgr_e.employee_code AS manager_employee_code
|
||||||
|
FROM employees e
|
||||||
|
JOIN partners p ON e.partner_id = p.partner_id
|
||||||
|
JOIN contracts c ON e.employee_id = c.employee_id AND c.status = 'ACTIVE'
|
||||||
|
JOIN departments d ON c.department_id = d.department_id
|
||||||
|
JOIN job_positions j ON c.job_id = j.job_id
|
||||||
|
LEFT JOIN employees mgr_e ON c.reporting_to_id = mgr_e.employee_id
|
||||||
|
LEFT JOIN partners mgr_p ON mgr_e.partner_id = mgr_p.partner_id
|
||||||
|
WHERE e.employee_id = ?`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const empData = empRows as any[];
|
||||||
|
|
||||||
|
if (empData.length === 0) {
|
||||||
|
ctx.response.status = 404;
|
||||||
|
ctx.response.body = { success: false, message: "Employee not found" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const employee = empData[0];
|
||||||
|
|
||||||
|
// 2. Fetch all addresses for this partner separately to return as an array
|
||||||
|
const [addrRows] = await pool.query(
|
||||||
|
`SELECT address_type, door_number, landmark, address_line, pincode, district, state
|
||||||
|
FROM addresses
|
||||||
|
WHERE partner_id = (SELECT partner_id FROM employees WHERE employee_id = ?)`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Combine into a single structured JSON response
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
employee_id: employee.employee_id,
|
||||||
|
employee_code: employee.employee_code,
|
||||||
|
is_active: employee.is_active,
|
||||||
|
first_name: employee.first_name,
|
||||||
|
last_name: employee.last_name,
|
||||||
|
dob: employee.dob,
|
||||||
|
gender: employee.gender,
|
||||||
|
personal_email: employee.personal_email,
|
||||||
|
personal_phone: employee.personal_phone,
|
||||||
|
contract: {
|
||||||
|
work_email: employee.work_email,
|
||||||
|
date_joining: employee.date_joining,
|
||||||
|
probation_days: employee.probation_days,
|
||||||
|
status: employee.contract_status,
|
||||||
|
department: employee.department,
|
||||||
|
department_id: employee.department_id,
|
||||||
|
designation: employee.designation,
|
||||||
|
job_id: employee.job_id,
|
||||||
|
manager: employee.manager_employee_code
|
||||||
|
? {
|
||||||
|
employee_code: employee.manager_employee_code,
|
||||||
|
first_name: employee.manager_first_name,
|
||||||
|
last_name: employee.manager_last_name,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
addresses: addrRows, // Array of all addresses (PERMANENT, CURRENT, EMERGENCY)
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateEmployee = async (ctx: any) => {
|
||||||
|
const id = ctx.params.id;
|
||||||
|
const body = ctx.request.body;
|
||||||
|
|
||||||
|
if (body.type() !== "json") {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { success: false, error: "Invalid JSON body" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await body.json();
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
|
||||||
|
const [employeeRows] = await connection.execute(
|
||||||
|
`SELECT partner_id FROM employees WHERE employee_id = ?`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const employees = employeeRows as any[];
|
||||||
|
if (employees.length === 0) {
|
||||||
|
ctx.response.status = 404;
|
||||||
|
ctx.response.body = { success: false, message: "Employee not found" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const partnerId = employees[0].partner_id;
|
||||||
|
|
||||||
|
await connection.execute(
|
||||||
|
`UPDATE partners
|
||||||
|
SET first_name = ?, last_name = ?, personal_email = ?, personal_phone = ?
|
||||||
|
WHERE partner_id = ?`,
|
||||||
|
[
|
||||||
|
data.firstName,
|
||||||
|
data.lastName,
|
||||||
|
data.personalEmail,
|
||||||
|
data.personalPhone,
|
||||||
|
partnerId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await connection.execute(
|
||||||
|
`UPDATE addresses
|
||||||
|
SET door_number = ?, landmark = ?, address_line = ?, pincode = ?, district = ?, state = ?
|
||||||
|
WHERE partner_id = ? AND address_type = ?`,
|
||||||
|
[
|
||||||
|
data.address.doorNumber,
|
||||||
|
data.address.landmark,
|
||||||
|
data.address.line,
|
||||||
|
data.address.pincode,
|
||||||
|
data.address.district,
|
||||||
|
data.address.state,
|
||||||
|
partnerId,
|
||||||
|
data.address.type,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await connection.commit();
|
||||||
|
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message: "Employee profile updated successfully",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await connection.rollback();
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: false,
|
||||||
|
error: "Update failed: " + (error as Error).message,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteEmployee = async (ctx: any) => {
|
||||||
|
const id = ctx.params.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [result] = await pool.execute(
|
||||||
|
`UPDATE employees SET is_active = false WHERE employee_id = ?`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateResult = result as any;
|
||||||
|
|
||||||
|
if (updateResult.affectedRows === 0) {
|
||||||
|
ctx.response.status = 404;
|
||||||
|
ctx.response.body = { success: false, message: "Employee not found" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message: "Employee account deactivated successfully",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: false,
|
||||||
|
error: "Deactivation failed: " + (error as Error).message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
443
ems-service/controllers/lookup.controller.ts
Normal file
443
ems-service/controllers/lookup.controller.ts
Normal file
@ -0,0 +1,443 @@
|
|||||||
|
import { getDbPool } from "../../shared/db.ts"
|
||||||
|
import {generateNextCode} from "../../shared/sequence.ts"
|
||||||
|
|
||||||
|
const pool = getDbPool("hrms_ems");
|
||||||
|
|
||||||
|
export const getCompanies = async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query(`SELECT company_id, name FROM companies`);
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = { success: true, data: rows };
|
||||||
|
} catch (error) {
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBranches = async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query(`SELECT branch_id, branch_name,code, company_id FROM branches`);
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = { success: true, data: rows };
|
||||||
|
} catch (error) {
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDepartments = async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query(`SELECT department_id, name, parent_id FROM departments`);
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = { success: true, data: rows };
|
||||||
|
} catch (error) {
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getJobs = async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query(`SELECT job_id, title, department_id FROM job_positions`);
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = { success: true, data: rows };
|
||||||
|
} catch (error) {
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// COMPANIES MANAGEMENT
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
export const createCompany = async (ctx: any) => {
|
||||||
|
const body = ctx.request.body;
|
||||||
|
if (body.type() !== "json") {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { success: false, error: "Invalid JSON body" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await body.json();
|
||||||
|
try {
|
||||||
|
const [result] = await pool.execute(
|
||||||
|
`INSERT INTO companies (name, parent_id) VALUES (?, ?)`,
|
||||||
|
[data.name, data.parentId || null]
|
||||||
|
);
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = { success: true, message: "Company created", company_id: (result as any).insertId };
|
||||||
|
} catch (error) {
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateCompany = async (ctx: any) => {
|
||||||
|
const id = ctx.params.id;
|
||||||
|
const body = ctx.request.body;
|
||||||
|
if (body.type() !== "json") {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { success: false, error: "Invalid JSON body" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await body.json();
|
||||||
|
try {
|
||||||
|
const [result] = await pool.execute(
|
||||||
|
`UPDATE companies SET name = ?, parent_id = ? WHERE company_id = ?`,
|
||||||
|
[data.name, data.parentId || null, id]
|
||||||
|
);
|
||||||
|
if ((result as any).affectedRows === 0) {
|
||||||
|
ctx.response.status = 404;
|
||||||
|
ctx.response.body = { success: false, message: "Company not found" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = { success: true, message: "Company updated successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteCompany = async (ctx: any) => {
|
||||||
|
const id = ctx.params.id;
|
||||||
|
try {
|
||||||
|
const [result] = await pool.execute(`DELETE FROM companies WHERE company_id = ?`, [id]);
|
||||||
|
if ((result as any).affectedRows === 0) {
|
||||||
|
ctx.response.status = 404;
|
||||||
|
ctx.response.body = { success: false, message: "Company not found" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = { success: true, message: "Company deleted successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
let errorMessage = (error as Error).message;
|
||||||
|
// Catch ON DELETE RESTRICT from employees table
|
||||||
|
if (errorMessage.includes("foreign key constraint fails")) {
|
||||||
|
errorMessage = "Cannot delete company. It is currently assigned to one or more employees.";
|
||||||
|
}
|
||||||
|
ctx.response.status = 409;
|
||||||
|
ctx.response.body = { success: false, error: errorMessage };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// BRANCHES MANAGEMENT
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
export const createBranch = async (ctx: any) => {
|
||||||
|
const body = ctx.request.body;
|
||||||
|
if (body.type() !== "json") {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { success: false, error: "Invalid JSON body" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await body.json();
|
||||||
|
let branchCode = data.code;
|
||||||
|
|
||||||
|
// If no code is provided, automatically generate a sequential one
|
||||||
|
if (!branchCode || branchCode.trim() === "") {
|
||||||
|
// Get first 3 letters (e.g., "Bengaluru" -> "BEN")
|
||||||
|
const shortName = data.branchName ? data.branchName.substring(0, 3).toUpperCase() : 'BRN';
|
||||||
|
|
||||||
|
// Generate: Sequence ID 'BRANCH_BEN', Prefix 'BEN-', Padding 3 -> Output: 'BEN-001'
|
||||||
|
branchCode = await generateNextCode(`BRANCH_${shortName}`, `${shortName}-`, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [result] = await pool.execute(
|
||||||
|
`INSERT INTO branches (company_id, branch_name, code) VALUES (?, ?, ?)`,
|
||||||
|
[data.companyId, data.branchName, branchCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message: "Branch created successfully",
|
||||||
|
branch_id: (result as any).insertId,
|
||||||
|
code: branchCode
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
let errorMessage = (error as Error).message;
|
||||||
|
if (errorMessage.includes("Duplicate entry")) {
|
||||||
|
ctx.response.status = 409;
|
||||||
|
ctx.response.body = { success: false, error: `The branch code '${branchCode}' already exists.` };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: errorMessage };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateBranch = async (ctx: any) => {
|
||||||
|
const id = ctx.params.id;
|
||||||
|
const body = ctx.request.body;
|
||||||
|
if (body.type() !== "json") {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { success: false, error: "Invalid JSON body" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await body.json();
|
||||||
|
try {
|
||||||
|
const [result] = await pool.execute(
|
||||||
|
`UPDATE branches SET company_id = ?, branch_name = ?, code = ? WHERE branch_id = ?`,
|
||||||
|
[data.companyId, data.branchName, data.code, id]
|
||||||
|
);
|
||||||
|
if ((result as any).affectedRows === 0) {
|
||||||
|
ctx.response.status = 404;
|
||||||
|
ctx.response.body = { success: false, message: "Branch not found" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = { success: true, message: "Branch updated successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteBranch = async (ctx: any) => {
|
||||||
|
const id = ctx.params.id;
|
||||||
|
try {
|
||||||
|
const [result] = await pool.execute(`DELETE FROM branches WHERE branch_id = ?`, [id]);
|
||||||
|
if ((result as any).affectedRows === 0) {
|
||||||
|
ctx.response.status = 404;
|
||||||
|
ctx.response.body = { success: false, message: "Branch not found" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = { success: true, message: "Branch deleted successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
let errorMessage = (error as Error).message;
|
||||||
|
// Catch ON DELETE RESTRICT from employees table
|
||||||
|
if (errorMessage.includes("foreign key constraint fails")) {
|
||||||
|
errorMessage = "Cannot delete branch. It is currently assigned to one or more employees.";
|
||||||
|
}
|
||||||
|
ctx.response.status = 409;
|
||||||
|
ctx.response.body = { success: false, error: errorMessage };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// DEPARTMENTS MANAGEMENT
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const createDepartment = async (ctx: any) => {
|
||||||
|
const body = ctx.request.body;
|
||||||
|
if (body.type() !== "json") {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { success: false, error: "Invalid JSON body" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await body.json();
|
||||||
|
|
||||||
|
if (!data.companyId || !data.branchId || !data.name) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { success: false, error: "companyId, branchId, and name are required." };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
|
||||||
|
// 1. Insert the department
|
||||||
|
const [result] = await connection.execute(
|
||||||
|
`INSERT INTO departments (company_id, branch_id, name, parent_id) VALUES (?, ?, ?, ?)`,
|
||||||
|
[data.companyId, data.branchId, data.name, data.parentId ?? null]
|
||||||
|
);
|
||||||
|
const departmentId = (result as any).insertId;
|
||||||
|
|
||||||
|
// 2. If a manager is provided, add them to the junction table
|
||||||
|
if (data.managerId) {
|
||||||
|
await connection.execute(
|
||||||
|
`INSERT INTO department_managers (department_id, employee_id) VALUES (?, ?)`,
|
||||||
|
[departmentId, data.managerId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection.commit();
|
||||||
|
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message: "Department created",
|
||||||
|
department_id: departmentId
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await connection.rollback();
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: (error as Error).message };
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateDepartment = async (ctx: any) => {
|
||||||
|
const id = ctx.params.id;
|
||||||
|
const body = ctx.request.body;
|
||||||
|
if (body.type() !== "json") {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { success: false, error: "Invalid JSON body" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await body.json();
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
|
||||||
|
// 1. Update core department details
|
||||||
|
const [result] = await connection.execute(
|
||||||
|
`UPDATE departments SET company_id = ?, branch_id = ?, name = ?, parent_id = ? WHERE department_id = ?`,
|
||||||
|
[data.companyId ?? null, data.branchId ?? null, data.name ?? null, data.parentId ?? null, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ((result as any).affectedRows === 0) {
|
||||||
|
ctx.response.status = 404;
|
||||||
|
ctx.response.body = { success: false, message: "Department not found" };
|
||||||
|
await connection.rollback();
|
||||||
|
connection.release();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Manage reassignment if a new manager is passed in the update
|
||||||
|
if (data.managerId) {
|
||||||
|
// For simplicity in this update endpoint, we overwrite the existing managers.
|
||||||
|
// You can create a dedicated POST /departments/:id/managers endpoint later for multi-manager logic.
|
||||||
|
await connection.execute(`DELETE FROM department_managers WHERE department_id = ?`, [id]);
|
||||||
|
await connection.execute(
|
||||||
|
`INSERT INTO department_managers (department_id, employee_id) VALUES (?, ?)`,
|
||||||
|
[id, data.managerId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection.commit();
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = { success: true, message: "Department updated successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
await connection.rollback();
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: (error as Error).message };
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const deleteDepartment = async (ctx: any) => {
|
||||||
|
const id = ctx.params.id;
|
||||||
|
try {
|
||||||
|
const [result] = await pool.execute(`DELETE FROM departments WHERE department_id = ?`, [id]);
|
||||||
|
if ((result as any).affectedRows === 0) {
|
||||||
|
ctx.response.status = 404;
|
||||||
|
ctx.response.body = { success: false, message: "Department not found" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = { success: true, message: "Department deleted successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
let errorMessage = (error as Error).message;
|
||||||
|
// Catch ON DELETE RESTRICT from contracts table
|
||||||
|
if (errorMessage.includes("foreign key constraint fails")) {
|
||||||
|
errorMessage = "Cannot delete department. There are active or historical contracts tied to this department.";
|
||||||
|
}
|
||||||
|
ctx.response.status = 409;
|
||||||
|
ctx.response.body = { success: false, error: errorMessage };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// JOB POSITIONS MANAGEMENT
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
export const createJob = async (ctx: any) => {
|
||||||
|
const body = ctx.request.body;
|
||||||
|
if (body.type() !== "json") {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { success: false, error: "Invalid JSON body" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await body.json();
|
||||||
|
|
||||||
|
if (!data.departmentId || !data.title) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { success: false, error: "departmentId and title are required fields." };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [result] = await pool.execute(
|
||||||
|
`INSERT INTO job_positions (department_id, title, description) VALUES (?, ?, ?)`,
|
||||||
|
[data.departmentId, data.title, data.description ?? null]
|
||||||
|
);
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = { success: true, message: "Job created", job_id: (result as any).insertId };
|
||||||
|
} catch (error) {
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateJob = async (ctx: any) => {
|
||||||
|
const id = ctx.params.id;
|
||||||
|
const body = ctx.request.body;
|
||||||
|
if (body.type() !== "json") {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { success: false, error: "Invalid JSON body" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await body.json();
|
||||||
|
try {
|
||||||
|
const [result] = await pool.execute(
|
||||||
|
`UPDATE job_positions SET department_id = ?, title = ?, description = ? WHERE job_id = ?`,
|
||||||
|
[data.departmentId ?? null, data.title ?? null, data.description ?? null, id]
|
||||||
|
);
|
||||||
|
if ((result as any).affectedRows === 0) {
|
||||||
|
ctx.response.status = 404;
|
||||||
|
ctx.response.body = { success: false, message: "Job not found" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = { success: true, message: "Job updated successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Note: deleteJob function remains the same as before.
|
||||||
|
|
||||||
|
export const deleteJob = async (ctx: any) => {
|
||||||
|
const id = ctx.params.id;
|
||||||
|
try {
|
||||||
|
const [result] = await pool.execute(`DELETE FROM job_positions WHERE job_id = ?`, [id]);
|
||||||
|
if ((result as any).affectedRows === 0) {
|
||||||
|
ctx.response.status = 404;
|
||||||
|
ctx.response.body = { success: false, message: "Job not found" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = { success: true, message: "Job deleted successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
let errorMessage = (error as Error).message;
|
||||||
|
// Catch ON DELETE RESTRICT from contracts table
|
||||||
|
if (errorMessage.includes("foreign key constraint fails")) {
|
||||||
|
errorMessage = "Cannot delete job position. It is currently linked to one or more employee contracts.";
|
||||||
|
}
|
||||||
|
ctx.response.status = 409;
|
||||||
|
ctx.response.body = { success: false, error: errorMessage };
|
||||||
|
}
|
||||||
|
};
|
||||||
173
ems-service/controllers/system.controller.ts
Normal file
173
ems-service/controllers/system.controller.ts
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
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 {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
|
||||||
|
for (const item of body) {
|
||||||
|
const { emp_code, name, designation, department, email } = item;
|
||||||
|
|
||||||
|
const nameParts = name.trim().split(" ");
|
||||||
|
const firstName = nameParts[0] || "Employee";
|
||||||
|
const lastName = nameParts.slice(1).join(" ") || "LNU";
|
||||||
|
|
||||||
|
// 1. Insert into partners
|
||||||
|
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 department exists (FIXED: Added branch_id to satisfy NOT NULL constraint)
|
||||||
|
const [deptResult]: any = await connection.execute(
|
||||||
|
`INSERT INTO departments (company_id, branch_id, name)
|
||||||
|
VALUES (1, 1, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE department_id = LAST_INSERT_ID(department_id)`,
|
||||||
|
[department || "General"]
|
||||||
|
);
|
||||||
|
const departmentId = deptResult.insertId;
|
||||||
|
|
||||||
|
// 3. Ensure job position exists (FIXED: Linked to department_id instead of company_id)
|
||||||
|
const [jobResult]: any = await connection.execute(
|
||||||
|
`INSERT INTO job_positions (department_id, title)
|
||||||
|
VALUES (?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE job_id = LAST_INSERT_ID(job_id)`,
|
||||||
|
[departmentId, designation || "Trainee"]
|
||||||
|
);
|
||||||
|
const jobId = jobResult.insertId;
|
||||||
|
|
||||||
|
// 4. Create core Employee record
|
||||||
|
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 operational Contract
|
||||||
|
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++;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: errorMessage };
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const bulkMapHierarchy = async (ctx: Context) => {
|
||||||
|
const body = ctx.request.hasBody ? await ctx.request.body.json() : {};
|
||||||
|
const { employeeHierarchy = [], departmentHierarchy = [] } = body;
|
||||||
|
|
||||||
|
if (employeeHierarchy.length === 0 && departmentHierarchy.length === 0) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { success: false, message: "Provide hierarchy mapping arrays." };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = await emsPool.getConnection();
|
||||||
|
let contractsUpdated = 0;
|
||||||
|
let departmentsUpdated = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
|
||||||
|
// 1. Map Employees to their Managers (Contracts table)
|
||||||
|
for (const mapping of employeeHierarchy) {
|
||||||
|
const { emp_code, manager_code } = mapping;
|
||||||
|
if (!emp_code || !manager_code) continue;
|
||||||
|
|
||||||
|
const [result]: any = await connection.execute(
|
||||||
|
`UPDATE contracts c
|
||||||
|
JOIN employees e ON c.employee_id = e.employee_id
|
||||||
|
JOIN employees m ON m.employee_code = ?
|
||||||
|
SET c.reporting_to_id = m.employee_id
|
||||||
|
WHERE e.employee_code = ? AND c.status = 'ACTIVE'`,
|
||||||
|
[manager_code, emp_code]
|
||||||
|
);
|
||||||
|
if (result.affectedRows > 0) contractsUpdated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Map Departments to Managers and Parent Departments
|
||||||
|
for (const dept of departmentHierarchy) {
|
||||||
|
const { department_name, manager_code, parent_department_name } = dept;
|
||||||
|
if (!department_name) continue;
|
||||||
|
|
||||||
|
// FIXED: Use department_managers junction table instead of departments.manager_id
|
||||||
|
if (manager_code) {
|
||||||
|
await connection.execute(
|
||||||
|
`INSERT IGNORE INTO department_managers (department_id, employee_id)
|
||||||
|
SELECT d.department_id, m.employee_id
|
||||||
|
FROM departments d
|
||||||
|
JOIN employees m ON m.employee_code = ?
|
||||||
|
WHERE d.name = ?`,
|
||||||
|
[manager_code, department_name]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Parent Department Hierarchy
|
||||||
|
if (parent_department_name) {
|
||||||
|
await connection.execute(
|
||||||
|
`UPDATE departments d
|
||||||
|
JOIN departments p ON p.name = ?
|
||||||
|
SET d.parent_id = p.department_id
|
||||||
|
WHERE d.name = ?`,
|
||||||
|
[parent_department_name, department_name]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
departmentsUpdated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection.commit();
|
||||||
|
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message: "Hierarchy mapping completed successfully.",
|
||||||
|
metrics: { contractsUpdated, departmentsProcessed: departmentsUpdated }
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await connection.rollback();
|
||||||
|
console.error("Hierarchy mapping failed:", error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: errorMessage };
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
82
ems-service/generate_mapping.ts
Normal file
82
ems-service/generate_mapping.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
// generate_mapping.ts
|
||||||
|
import data from "./AllStaff.json" with { type: "json" };
|
||||||
|
|
||||||
|
// 1. Helper to clean up messy department strings
|
||||||
|
const normalizeDept = (dept: string) => {
|
||||||
|
if (dept.toLowerCase() === "finance" || dept === "FInance") return "Finance";
|
||||||
|
if (dept.toLowerCase() === "digital marketing") return "Digital Marketing";
|
||||||
|
return dept.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Helper to determine leadership rank based on designation
|
||||||
|
const getRank = (designation: string) => {
|
||||||
|
const title = designation.toLowerCase();
|
||||||
|
if (title.includes("cfo") || title.includes("vice president") || title.includes("co- founder")) return 100;
|
||||||
|
if (title.includes("senior manager")) return 90;
|
||||||
|
if (title.includes("manager") && !title.includes("assistant")) return 80;
|
||||||
|
if (title.includes("assistant manager")) return 70;
|
||||||
|
if (title.includes("team leader")) return 60;
|
||||||
|
return 0; // Regular employee
|
||||||
|
};
|
||||||
|
|
||||||
|
const departmentsMap = new Map();
|
||||||
|
const employeeHierarchy: any[] = [];
|
||||||
|
const departmentHierarchy: any[] = [];
|
||||||
|
|
||||||
|
// 3. First Pass: Group employees by department and find the leader
|
||||||
|
for (const emp of data) {
|
||||||
|
const deptName = normalizeDept(emp.department);
|
||||||
|
const rank = getRank(emp.designation);
|
||||||
|
|
||||||
|
if (!departmentsMap.has(deptName)) {
|
||||||
|
departmentsMap.set(deptName, { name: deptName, leaderCode: null, highestRank: -1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentDept = departmentsMap.get(deptName);
|
||||||
|
|
||||||
|
// If this employee has a higher rank than the current department leader, replace them
|
||||||
|
if (rank > currentDept.highestRank) {
|
||||||
|
currentDept.leaderCode = emp.emp_code;
|
||||||
|
currentDept.highestRank = rank;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Second Pass: Build the mappings
|
||||||
|
for (const emp of data) {
|
||||||
|
const deptName = normalizeDept(emp.department);
|
||||||
|
const deptInfo = departmentsMap.get(deptName);
|
||||||
|
|
||||||
|
// Only map the employee if they aren't the leader themselves and a leader exists
|
||||||
|
let managerCode = deptInfo.leaderCode;
|
||||||
|
if (managerCode === emp.emp_code) {
|
||||||
|
managerCode = null; // The top boss doesn't report to themselves in this scope
|
||||||
|
}
|
||||||
|
|
||||||
|
employeeHierarchy.push({
|
||||||
|
emp_code: emp.emp_code,
|
||||||
|
manager_code: managerCode
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Build Department Hierarchy
|
||||||
|
for (const [deptName, info] of departmentsMap.entries()) {
|
||||||
|
departmentHierarchy.push({
|
||||||
|
department_name: deptName,
|
||||||
|
manager_code: info.leaderCode,
|
||||||
|
parent_department_name: null // You can manually tweak parent/child relationships later
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Write the final JSON payload
|
||||||
|
const output = {
|
||||||
|
departmentHierarchy,
|
||||||
|
employeeHierarchy
|
||||||
|
};
|
||||||
|
|
||||||
|
await Deno.writeTextFile(
|
||||||
|
"./hierarchy_mapping.json",
|
||||||
|
JSON.stringify(output, null, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Successfully mapped ${employeeHierarchy.length} employees across ${departmentsMap.size} departments!`);
|
||||||
|
console.log("File saved to ./hierarchy_mapping.json");
|
||||||
1094
ems-service/hierarchy_mapping.json
Normal file
1094
ems-service/hierarchy_mapping.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,41 +1,9 @@
|
|||||||
import { Application, Router } from "@oak/oak";
|
import { Application } from "@oak/oak";
|
||||||
import pool from "../shared/db.ts";
|
import router from "./routes.ts";
|
||||||
|
|
||||||
const app = new Application();
|
const app = new Application();
|
||||||
const router = new Router();
|
|
||||||
|
|
||||||
// Define the endpoint Sufail will call from the Vite frontend
|
|
||||||
router.get("/api/employees", async (ctx) => {
|
|
||||||
try {
|
|
||||||
const [rows] = await pool.query(
|
|
||||||
`SELECT
|
|
||||||
e.employee_code,
|
|
||||||
p.first_name,
|
|
||||||
p.last_name,
|
|
||||||
d.name AS department,
|
|
||||||
j.title AS designation,
|
|
||||||
c.work_email
|
|
||||||
FROM employees e
|
|
||||||
JOIN partners p ON e.partner_id = p.partner_id
|
|
||||||
JOIN contracts c ON e.employee_id = c.employee_id
|
|
||||||
JOIN departments d ON c.department_id = d.department_id
|
|
||||||
JOIN job_positions j ON c.job_id = j.job_id`
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.response.body = {
|
|
||||||
success: true,
|
|
||||||
count: (rows as any[]).length,
|
|
||||||
data: rows
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: false,
|
|
||||||
error: (error as Error).message
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
// Register the routes and allowed methods from routes.ts
|
||||||
app.use(router.routes());
|
app.use(router.routes());
|
||||||
app.use(router.allowedMethods());
|
app.use(router.allowedMethods());
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,100 @@
|
|||||||
|
import { Router } from "@oak/oak";
|
||||||
|
import {
|
||||||
|
getEmployees,
|
||||||
|
createEmployee,
|
||||||
|
getEmployeeById,
|
||||||
|
updateEmployee,
|
||||||
|
deleteEmployee
|
||||||
|
} from "./controllers/employee.controller.ts";
|
||||||
|
import {
|
||||||
|
getCompanies, createCompany, updateCompany, deleteCompany,
|
||||||
|
getBranches, createBranch, updateBranch, deleteBranch,
|
||||||
|
getDepartments, createDepartment, updateDepartment, deleteDepartment,
|
||||||
|
getJobs, createJob, updateJob, deleteJob
|
||||||
|
} from "./controllers/lookup.controller.ts";
|
||||||
|
import {
|
||||||
|
getEmployeeContracts,
|
||||||
|
createContract
|
||||||
|
} from "./controllers/contract.controller.ts";
|
||||||
|
import {
|
||||||
|
bulkSeedEmployees,
|
||||||
|
bulkMapHierarchy
|
||||||
|
} from "./controllers/system.controller.ts";
|
||||||
|
|
||||||
|
// Shared authentication and authorization middleware
|
||||||
|
import { requireAuth, requireRole, AppRole } from "../shared/auth.ts";
|
||||||
|
|
||||||
|
const router = new Router();
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EMPLOYEE CORE DOMAIN
|
||||||
|
// Manages the corporate identity (employees) and personal data (partners).
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Retrieve a list of all active employees. Visible to management and administrators.
|
||||||
|
router.get("/api/v1/employees", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN, AppRole.MANAGER]), getEmployees);
|
||||||
|
|
||||||
|
// Retrieve a single employee's comprehensive profile (identity, address, contract). Accessible to any authenticated user.
|
||||||
|
router.get("/api/v1/employees/:id", requireAuth, getEmployeeById);
|
||||||
|
|
||||||
|
// Create a new employee profile and initial contract. Restricted to administrators.
|
||||||
|
router.post("/api/v1/employees", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createEmployee);
|
||||||
|
|
||||||
|
// Update an existing employee's personal or address data. Restricted to administrators.
|
||||||
|
router.put("/api/v1/employees/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), updateEmployee);
|
||||||
|
|
||||||
|
// Soft-delete (deactivate) an employee account. Restricted to administrators.
|
||||||
|
router.delete("/api/v1/employees/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), deleteEmployee);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CONTRACT & ASSIGNMENT DOMAIN
|
||||||
|
// Manages operational assignments, reporting hierarchies, and job roles.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Retrieve the contract history for an individual employee.
|
||||||
|
router.get("/api/v1/employees/:id/contracts", requireAuth, getEmployeeContracts);
|
||||||
|
|
||||||
|
// Expire current active contracts and execute a new employment agreement.
|
||||||
|
router.post("/api/v1/contracts", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createContract);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ORGANIZATIONAL LOOKUP DOMAIN
|
||||||
|
// Read-only reference endpoints for the frontend UI to populate select options.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Fetch legal entities and parent group structures.
|
||||||
|
router.get("/api/v1/companies", requireAuth, getCompanies);
|
||||||
|
router.post("/api/v1/companies", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createCompany);
|
||||||
|
router.put("/api/v1/companies/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), updateCompany);
|
||||||
|
router.delete("/api/v1/companies/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), deleteCompany);
|
||||||
|
|
||||||
|
// Fetch structural branch offices and physical locations.
|
||||||
|
router.get("/api/v1/branches", requireAuth, getBranches);
|
||||||
|
router.post("/api/v1/branches", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createBranch);
|
||||||
|
router.put("/api/v1/branches/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), updateBranch);
|
||||||
|
router.delete("/api/v1/branches/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), deleteBranch);
|
||||||
|
|
||||||
|
// List corporate departments and organizational chart reporting lines.
|
||||||
|
router.get("/api/v1/departments", requireAuth, getDepartments);
|
||||||
|
router.post("/api/v1/departments", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createDepartment);
|
||||||
|
router.put("/api/v1/departments/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), updateDepartment);
|
||||||
|
router.delete("/api/v1/departments/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), deleteDepartment);
|
||||||
|
|
||||||
|
// List company designations and employment titles.
|
||||||
|
router.get("/api/v1/jobs", requireAuth, getJobs);
|
||||||
|
router.post("/api/v1/jobs", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createJob);
|
||||||
|
router.put("/api/v1/jobs/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), updateJob);
|
||||||
|
router.delete("/api/v1/jobs/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), deleteJob);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SYSTEM & MIGRATION DOMAIN
|
||||||
|
// High-risk administrative endpoints for bulk data execution.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Process raw bulk data to seed initial employee/partner structures into the DB.
|
||||||
|
router.post("/api/v1/system/seed-employees", requireAuth, requireRole([AppRole.SUPER_ADMIN]), bulkSeedEmployees);
|
||||||
|
|
||||||
|
// Execute mapping script to establish reporting_to_id and manager_id links.
|
||||||
|
router.post("/api/v1/system/map-hierarchy", requireAuth, requireRole([AppRole.SUPER_ADMIN]), bulkMapHierarchy);
|
||||||
|
|
||||||
|
export default router;
|
||||||
69
ems-service/routes.ts.bkp
Normal file
69
ems-service/routes.ts.bkp
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { Router } from "@oak/oak";
|
||||||
|
import {
|
||||||
|
getEmployees,
|
||||||
|
createEmployee,
|
||||||
|
getEmployeeById,
|
||||||
|
updateEmployee,
|
||||||
|
deleteEmployee
|
||||||
|
} from "./controllers/employee.controller.ts";
|
||||||
|
import {
|
||||||
|
getCompanies,
|
||||||
|
getBranches,
|
||||||
|
getDepartments,
|
||||||
|
getJobs
|
||||||
|
} from "./controllers/lookup.controller.ts";
|
||||||
|
import {
|
||||||
|
getEmployeeContracts,
|
||||||
|
createContract
|
||||||
|
} from "./controllers/contract.controller.ts";
|
||||||
|
|
||||||
|
import {
|
||||||
|
bulkSeedEmployees,
|
||||||
|
bulkMapHierarchy
|
||||||
|
} from "./controllers/system.controller.ts"
|
||||||
|
|
||||||
|
const router = new Router();
|
||||||
|
|
||||||
|
// --- Employee Core Endpoints ---
|
||||||
|
|
||||||
|
// Retrieve all employees
|
||||||
|
router.get("/api/v1/employees", getEmployees);
|
||||||
|
|
||||||
|
// Retrieve a single employee's comprehensive profile
|
||||||
|
router.get("/api/v1/employees/:id", getEmployeeById);
|
||||||
|
|
||||||
|
// Create a new employee
|
||||||
|
router.post("/api/v1/employees", createEmployee);
|
||||||
|
|
||||||
|
// Update an existing employee
|
||||||
|
router.put("/api/v1/employees/:id", updateEmployee);
|
||||||
|
|
||||||
|
// Deactivate an employee
|
||||||
|
router.delete("/api/v1/employees/:id", deleteEmployee);
|
||||||
|
|
||||||
|
// --- Contract Endpoints ---
|
||||||
|
|
||||||
|
//Retrieve contract history for an individual employee
|
||||||
|
router.get("/api/v1/employees/:id/contracts", getEmployeeContracts);
|
||||||
|
|
||||||
|
//Terminate, transition, or execute a new employment agreement.
|
||||||
|
router.post("/api/v1/contracts", createContract);
|
||||||
|
|
||||||
|
// --- Organizational Lookup Endpoints ---
|
||||||
|
|
||||||
|
// Fetch legal entities and parent group structures.
|
||||||
|
router.get("/api/v1/companies", getCompanies);
|
||||||
|
|
||||||
|
//Fetch structural branch offices
|
||||||
|
router.get("/api/v1/branches", getBranches);
|
||||||
|
|
||||||
|
//List corporate departments and organizational chart reporting lines
|
||||||
|
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);
|
||||||
|
router.post("/api/v1/system/map-hierarchy", bulkMapHierarchy);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@ -15,7 +15,7 @@ CREATE TABLE branches (
|
|||||||
branch_id INT AUTO_INCREMENT PRIMARY KEY,
|
branch_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
company_id INT NOT NULL,
|
company_id INT NOT NULL,
|
||||||
branch_name VARCHAR(100) NOT NULL,
|
branch_name VARCHAR(100) NOT NULL,
|
||||||
code VARCHAR(20) NOT NULL UNIQUE, -- e.g., 'BLR-HQ'
|
code VARCHAR(20) NOT NULL UNIQUE,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (company_id) REFERENCES companies(company_id) ON DELETE CASCADE
|
FOREIGN KEY (company_id) REFERENCES companies(company_id) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@ -51,26 +51,27 @@ CREATE TABLE addresses (
|
|||||||
CREATE TABLE departments (
|
CREATE TABLE departments (
|
||||||
department_id INT AUTO_INCREMENT PRIMARY KEY,
|
department_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
company_id INT NOT NULL,
|
company_id INT NOT NULL,
|
||||||
|
branch_id INT NOT NULL,
|
||||||
name VARCHAR(50) NOT NULL,
|
name VARCHAR(50) NOT NULL,
|
||||||
parent_id INT NULL,
|
parent_id INT NULL,
|
||||||
manager_id INT NULL, -- Logical Reference to employee_id
|
|
||||||
FOREIGN KEY (company_id) REFERENCES companies(company_id) ON DELETE CASCADE,
|
FOREIGN KEY (company_id) REFERENCES companies(company_id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (branch_id) REFERENCES branches(branch_id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (parent_id) REFERENCES departments(department_id) ON DELETE SET NULL
|
FOREIGN KEY (parent_id) REFERENCES departments(department_id) ON DELETE SET NULL
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- 6. Functional Designations
|
-- 6. Functional Designations
|
||||||
CREATE TABLE job_positions (
|
CREATE TABLE job_positions (
|
||||||
job_id INT AUTO_INCREMENT PRIMARY KEY,
|
job_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
company_id INT NOT NULL,
|
department_id INT NOT NULL,
|
||||||
title VARCHAR(50) NOT NULL,
|
title VARCHAR(50) NOT NULL,
|
||||||
description TEXT NULL,
|
description TEXT NULL,
|
||||||
FOREIGN KEY (company_id) REFERENCES companies(company_id) ON DELETE CASCADE
|
FOREIGN KEY (department_id) REFERENCES departments(department_id) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- 7. Master Employee Mapping Engine
|
-- 7. Master Employee Mapping Engine
|
||||||
CREATE TABLE employees (
|
CREATE TABLE employees (
|
||||||
employee_id INT AUTO_INCREMENT PRIMARY KEY,
|
employee_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
employee_code VARCHAR(20) NOT NULL UNIQUE, -- Matches your Excel Base Column
|
employee_code VARCHAR(20) NOT NULL UNIQUE,
|
||||||
partner_id INT NOT NULL,
|
partner_id INT NOT NULL,
|
||||||
company_id INT NOT NULL,
|
company_id INT NOT NULL,
|
||||||
branch_id INT NOT NULL,
|
branch_id INT NOT NULL,
|
||||||
@ -87,13 +88,30 @@ CREATE TABLE contracts (
|
|||||||
employee_id INT NOT NULL,
|
employee_id INT NOT NULL,
|
||||||
department_id INT NOT NULL,
|
department_id INT NOT NULL,
|
||||||
job_id INT NOT NULL,
|
job_id INT NOT NULL,
|
||||||
reporting_to_id INT NULL, -- Logical Reference to employee_id
|
reporting_to_id INT NULL,
|
||||||
work_email VARCHAR(100) NOT NULL UNIQUE,
|
work_email VARCHAR(100) NOT NULL UNIQUE,
|
||||||
date_joining DATE NOT NULL,
|
date_joining DATE NOT NULL,
|
||||||
probation_days INT DEFAULT 90,
|
probation_days INT DEFAULT 90,
|
||||||
status ENUM('DRAFT', 'ACTIVE', 'EXPIRED', 'TERMINATED') DEFAULT 'DRAFT',
|
status ENUM('DRAFT', 'ACTIVE', 'EXPIRED', 'TERMINATED') DEFAULT 'DRAFT',
|
||||||
salary_structure_id INT NOT NULL, -- Logical Reference to PMS Microservice database
|
salary_structure_id INT NOT NULL,
|
||||||
FOREIGN KEY (employee_id) REFERENCES employees(employee_id) ON DELETE CASCADE,
|
FOREIGN KEY (employee_id) REFERENCES employees(employee_id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (department_id) REFERENCES departments(department_id) ON DELETE RESTRICT,
|
FOREIGN KEY (department_id) REFERENCES departments(department_id) ON DELETE RESTRICT,
|
||||||
FOREIGN KEY (job_id) REFERENCES job_positions(job_id) ON DELETE RESTRICT
|
FOREIGN KEY (job_id) REFERENCES job_positions(job_id) ON DELETE RESTRICT
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 9. Department Managers (Junction Table)
|
||||||
|
CREATE TABLE department_managers (
|
||||||
|
department_id INT NOT NULL,
|
||||||
|
employee_id INT NOT NULL,
|
||||||
|
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (department_id, employee_id),
|
||||||
|
FOREIGN KEY (department_id) REFERENCES departments(department_id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (employee_id) REFERENCES employees(employee_id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 10. System Sequences
|
||||||
|
CREATE TABLE system_sequences (
|
||||||
|
sequence_id VARCHAR(50) PRIMARY KEY,
|
||||||
|
prefix VARCHAR(10) NOT NULL,
|
||||||
|
current_value INT NOT NULL DEFAULT 0
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
63
init-db/03-init-ams.sql
Normal file
63
init-db/03-init-ams.sql
Normal 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;
|
||||||
51
shared/auth.ts
Normal file
51
shared/auth.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
// shared/auth.ts
|
||||||
|
import { Context, Next } from "@oak/oak";
|
||||||
|
|
||||||
|
export enum AppRole {
|
||||||
|
SUPER_ADMIN = "SUPER_ADMIN",
|
||||||
|
ADMIN = "ADMIN",
|
||||||
|
MANAGER = "MANAGER",
|
||||||
|
EMPLOYEE = "EMPLOYEE",
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Authentication Middleware (Who are you?)
|
||||||
|
export const requireAuth = async (ctx: Context, next: Next) => {
|
||||||
|
// Mocking the authorization for now.
|
||||||
|
// Later, we will extract the JWT from ctx.request.headers.get("Authorization")
|
||||||
|
// and verify it with your SSO provider here.
|
||||||
|
|
||||||
|
const isAuthenticated = true; // Simulating a successful login
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
ctx.response.status = 401;
|
||||||
|
ctx.response.body = { success: false, message: "Missing or invalid token" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Injecting a mock user state so the next middleware can read it
|
||||||
|
ctx.state.user = {
|
||||||
|
employee_id: 135, // Example ID
|
||||||
|
role: AppRole.SUPER_ADMIN, // Change this to test different access levels
|
||||||
|
branch_id: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Authorization Middleware (What are you allowed to do?)
|
||||||
|
export const requireRole = (allowedRoles: AppRole[]) => {
|
||||||
|
return async (ctx: Context, next: Next) => {
|
||||||
|
const userRole = ctx.state.user?.role;
|
||||||
|
|
||||||
|
if (!userRole || !allowedRoles.includes(userRole)) {
|
||||||
|
ctx.response.status = 403;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: false,
|
||||||
|
message: "Access Denied: You do not have the required permissions."
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
};
|
||||||
19
shared/db.ts
19
shared/db.ts
@ -1,16 +1,23 @@
|
|||||||
import mysql from "mysql2/promise";
|
import mysql from "mysql2/promise";
|
||||||
|
|
||||||
// Create a connection pool using the credentials from docker-compose.yml
|
const dbConfig = {
|
||||||
const pool = mysql.createPool({
|
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
port: 3306,
|
port: 3306,
|
||||||
user: "admin",
|
user: "admin",
|
||||||
password: "admin123",
|
password: "admin123",
|
||||||
database: "hrms_ems",
|
|
||||||
waitForConnections: true,
|
waitForConnections: true,
|
||||||
connectionLimit: 10,
|
connectionLimit: 10,
|
||||||
queueLimit: 0,
|
queueLimit: 0,
|
||||||
});
|
};
|
||||||
|
|
||||||
// Export the pool to be used across your EMS, AMS, and LMS services
|
const pools: Record<string, mysql.Pool> = {};
|
||||||
export default pool;
|
|
||||||
|
export const getDbPool = (databaseName: string): mysql.Pool => {
|
||||||
|
if (!pools[databaseName]) {
|
||||||
|
pools[databaseName] = mysql.createPool({
|
||||||
|
...dbConfig,
|
||||||
|
database: databaseName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return pools[databaseName];
|
||||||
|
};
|
||||||
50
shared/sequence.ts
Normal file
50
shared/sequence.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
// shared/sequence.ts
|
||||||
|
import { getDbPool } from "./db.ts";
|
||||||
|
|
||||||
|
const pool = getDbPool("hrms_ems");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a sequential code for a given entity.
|
||||||
|
* @param sequenceId Unique identifier for the counter (e.g., 'EMPLOYEE', 'BRANCH_BEN')
|
||||||
|
* @param prefix The string to prepend to the number (e.g., 'CLRI', 'BEN-')
|
||||||
|
* @param padding How many digits the number should be (e.g., 3 -> '001')
|
||||||
|
*/
|
||||||
|
export const generateNextCode = async (
|
||||||
|
sequenceId: string,
|
||||||
|
prefix: string,
|
||||||
|
padding: number = 3
|
||||||
|
): Promise<string> => {
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
|
||||||
|
// 1. Insert a new counter if it doesn't exist, OR increment the existing one atomically
|
||||||
|
await connection.execute(
|
||||||
|
`INSERT INTO system_sequences (sequence_id, prefix, current_value)
|
||||||
|
VALUES (?, ?, 1)
|
||||||
|
ON DUPLICATE KEY UPDATE current_value = current_value + 1, prefix = ?`,
|
||||||
|
[sequenceId, prefix, prefix]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Safely retrieve the updated value
|
||||||
|
const [rows] = await connection.execute(
|
||||||
|
`SELECT prefix, current_value FROM system_sequences WHERE sequence_id = ?`,
|
||||||
|
[sequenceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await connection.commit();
|
||||||
|
|
||||||
|
const data = (rows as any[])[0];
|
||||||
|
|
||||||
|
// 3. Format the result (e.g., prefix "CLRI" + value 1 + padding 3 = "CLRI001")
|
||||||
|
const paddedValue = data.current_value.toString().padStart(padding, '0');
|
||||||
|
return `${data.prefix}${paddedValue}`;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
await connection.rollback();
|
||||||
|
throw new Error(`Failed to generate sequence for ${sequenceId}: ${(error as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user