no fall back

This commit is contained in:
Edison
2025-10-13 11:46:30 +08:00
parent 356d96a61f
commit 80d94f248e
+73 -114
View File
@@ -272,13 +272,8 @@ export default function(db) {
const [rows] = await db.execute(query, params); const [rows] = await db.execute(query, params);
// ---- Group events by worker/day ----
const workByDay = {}; const workByDay = {};
const fmtTS = (d) => {
const p = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
};
// group events
rows.forEach(row => { rows.forEach(row => {
const day = new Date(row.timestamp).toISOString().split('T')[0]; const day = new Date(row.timestamp).toISOString().split('T')[0];
if (!workByDay[row.worker_id]) { if (!workByDay[row.worker_id]) {
@@ -299,18 +294,29 @@ export default function(db) {
}); });
}); });
// build two rows per day: first clock_in (no hours) and last clock_out (with hours) // ---- Helpers for formatting ----
const fmtHM = (d) => {
const p = (n) => String(n).padStart(2, '0');
return `${p(d.getHours())}:${p(d.getMinutes())}`;
};
const dayName = (yyyyMmDd) => {
const [y, m, dd] = yyyyMmDd.split('-').map(Number);
const d = new Date(y, m - 1, dd);
return ['SUN','MON','TUE','WED','THU','FRI','SAT'][d.getDay()];
};
// ---- Build ONE row per worker/day (no double rows) ----
const csvData = []; const csvData = [];
const byWorkerForXlsx = new Map(); const byWorkerForXlsx = new Map(); // key = "username||full_name||department" → daily rows
for (const workerId in workByDay) { for (const workerId in workByDay) {
const w = workByDay[workerId]; const w = workByDay[workerId];
const perWorkerRows = []; const perWorkerRows = [];
for (const day of Object.keys(w.days).sort()) { for (const day of Object.keys(w.days).sort()) {
const events = w.days[day].slice().sort((a, b) => a.time - b.time); const events = w.days[day].slice().sort((a, b) => a.time - b.time);
// total hours across all in/out pairs
let totalSec = 0; let totalSec = 0;
let open = null; let open = null;
for (const e of events) { for (const e of events) {
@@ -321,132 +327,85 @@ export default function(db) {
const firstIn = events.find(e => e.type === 'clock_in') || null; const firstIn = events.find(e => e.type === 'clock_in') || null;
const lastOut = [...events].reverse().find(e => e.type === 'clock_out') || null; const lastOut = [...events].reverse().find(e => e.type === 'clock_out') || null;
if (firstIn) { const dailyRow = {
const row = { username: w.username,
username: w.username, full_name: w.full_name,
full_name: w.full_name, date: day,
department: w.department, day: dayName(day),
date: day, clock_in: firstIn ? fmtHM(firstIn.time) : '',
work_hours: '', clock_out: lastOut ? fmtHM(lastOut.time) : '',
event_type: 'clock_in', work_hours: (firstIn && lastOut) ? (totalSec / 3600).toFixed(2) : '',
timestamp: fmtTS(firstIn.time), qr_code_name: firstIn ? (firstIn.qr_code_name || 'Manual Entry') : ''
qr_code_name: firstIn.qr_code_name || 'Manual Entry' };
};
csvData.push(row);
perWorkerRows.push(row);
}
if (lastOut) { csvData.push(dailyRow);
const row = { perWorkerRows.push(dailyRow);
username: w.username,
full_name: w.full_name,
department: w.department,
date: day,
work_hours: (totalSec / 3600).toFixed(2),
event_type: 'clock_out',
timestamp: fmtTS(lastOut.time),
qr_code_name: lastOut.qr_code_name || 'Manual Entry'
};
csvData.push(row);
perWorkerRows.push(row);
}
} }
byWorkerForXlsx.set(`${w.username}||${w.full_name}||${w.department}`, perWorkerRows); byWorkerForXlsx.set(`${w.username}||${w.full_name}||${w.department}`, perWorkerRows);
} }
// ===== XLSX branch: grouped header + per-day summary columns ===== // ===== XLSX branch: grouped header + per-day summary columns =====
if (wantXlsx) { if (wantXlsx) {
const wb = new ExcelJS.Workbook(); const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet('Attendance'); const ws = wb.addWorksheet('Attendance');
// helper formatters ws.columns = [
const fmtHM = (d) => { { header: 'Date', key: 'date', width: 12 },
const p = (n) => String(n).padStart(2, '0'); { header: 'Day', key: 'day', width: 8 },
return `${p(d.getHours())}:${p(d.getMinutes())}`; { header: 'Clock In', key: 'clock_in', width: 10 },
}; { header: 'Clock Out', key: 'clock_out', width: 10 },
const dayName = (yyyyMmDd) => { { header: 'Work Hours', key: 'work_hours', width: 12 },
const [y, m, dd] = yyyyMmDd.split('-').map(Number); { header: 'QR Code', key: 'qr_code_name', width: 24 },
const d = new Date(y, m - 1, dd); ];
return ['SUN','MON','TUE','WED','THU','FRI','SAT'][d.getDay()];
};
ws.columns = [ for (const [key, rowsForWorker] of byWorkerForXlsx.entries()) {
{ header: 'Date', key: 'date', width: 12 }, const [username, full_name, dept] = key.split('||');
{ header: 'Day', key: 'day', width: 8 },
{ header: 'Clock In', key: 'clock_in', width: 10 },
{ header: 'Clock Out', key: 'clock_out', width: 10 },
{ header: 'Work Hours', key: 'work_hours', width: 12 },
{ header: 'QR Code', key: 'qr_code_name', width: 24 },
];
for (const workerId in workByDay) { if (ws.lastRow) ws.addRow([]);
const w = workByDay[workerId];
if (ws.lastRow) ws.addRow([]); // Bold merged group header: "username full_name Dept: X"
const titleRowIdx = (ws.lastRow ? ws.lastRow.number : 0) + 1;
ws.mergeCells(`A${titleRowIdx}:F${titleRowIdx}`);
const titleCell = ws.getCell(`A${titleRowIdx}`);
titleCell.value = dept ? `${username} ${full_name} Dept: ${dept}` : `${username} ${full_name}`;
titleCell.font = { bold: true, size: 12 };
titleCell.alignment = { horizontal: 'left', vertical: 'middle' };
// bold merged group header: "username full_name Dept: X" // Header row under the group
const titleRowIdx = (ws.lastRow ? ws.lastRow.number : 0) + 1; const hdr = ws.addRow({
ws.mergeCells(`A${titleRowIdx}:F${titleRowIdx}`); date: 'Date',
const titleCell = ws.getCell(`A${titleRowIdx}`); day: 'Day',
titleCell.value = w.department clock_in: 'Clock In',
? `${w.username} ${w.full_name} Dept: ${w.department}` clock_out: 'Clock Out',
: `${w.username} ${w.full_name}`; work_hours: 'Work Hours',
titleCell.font = { bold: true, size: 12 }; qr_code_name: 'QR Code',
titleCell.alignment = { horizontal: 'left', vertical: 'middle' }; });
hdr.font = { bold: true };
// header row under the group (styled) // Detail rows (one per day)
const hdr = ws.addRow({ for (const r of rowsForWorker) {
date: 'Date', ws.addRow(r);
day: 'Day', }
clock_in: 'Clock In',
clock_out: 'Clock Out',
work_hours: 'Work Hours',
qr_code_name: 'QR Code',
});
hdr.font = { bold: true };
// one row per day: first clock_in, last clock_out, total hours, QR from clock_in
for (const day of Object.keys(w.days).sort()) {
const events = w.days[day].slice().sort((a, b) => a.time - b.time);
// total hours across all in/out pairs
let totalSec = 0;
let open = null;
for (const e of events) {
if (e.type === 'clock_in' && open == null) open = e.time;
else if (e.type === 'clock_out' && open != null) { totalSec += (e.time - open) / 1000; open = null; }
} }
const firstIn = events.find(e => e.type === 'clock_in') || null; ws.eachRow(row => { row.alignment = { vertical: 'middle' }; });
const lastOut = [...events].reverse().find(e => e.type === 'clock_out') || null;
ws.addRow({ const buf = await wb.xlsx.writeBuffer();
date: day, res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
day: dayName(day), res.setHeader('Content-Disposition', `attachment; filename="work_hours_${startDate}_to_${endDate}.xlsx"`);
clock_in: firstIn ? fmtHM(firstIn.time) : '', return res.send(Buffer.from(buf));
clock_out: lastOut ? fmtHM(lastOut.time) : '',
work_hours: (firstIn && lastOut) ? (totalSec / 3600).toFixed(2) : '',
qr_code_name: firstIn ? (firstIn.qr_code_name || 'Manual Entry') : '',
});
} }
}
ws.eachRow(row => { row.alignment = { vertical: 'middle' }; }); // ===== CSV fallback: one row per day; include identity columns =====
const buf = await wb.xlsx.writeBuffer();
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename="work_hours_${startDate}_to_${endDate}.xlsx"`);
return res.send(Buffer.from(buf));
}
// CSV fallback (unchanged field order)
const { Parser } = await import('json2csv');
const json2csvParser = new Parser({ const json2csvParser = new Parser({
fields: ['username', 'full_name', 'date', 'work_hours', 'event_type', 'timestamp', 'qr_code_name'] fields: ['username','full_name','date','day','clock_in','clock_out','work_hours','qr_code_name']
}); });
const csv = json2csvParser.parse(csvData); const csv = json2csvParser.parse(csvData);
res.header('Content-Type', 'text/csv').attachment(`work_hours_${startDate}_to_${endDate}.csv`).send(csv); res
.header('Content-Type', 'text/csv')
.attachment(`work_hours_${startDate}_to_${endDate}.csv`)
.send(csv);
} catch (error) { } catch (error) {
console.error('Work hours export error:', error); console.error('Work hours export error:', error);