Allow manual worker times to override recorded scans
This commit is contained in:
parent
a388796e4d
commit
0d8e4b5566
4 changed files with 24 additions and 46 deletions
|
|
@ -100,6 +100,6 @@ it.each([
|
|||
expect(html).toContain("07:24:31");
|
||||
if (scans[0].scannedAt === "2026-10-08T05:24:23Z") {
|
||||
expect(html).toContain("07:24:23");
|
||||
expect(html).toContain("An earlier clock-out is already recorded");
|
||||
expect(html).toContain("Your entered times determine the corrected hours");
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ function CorrectionForm({ shift, onClose }: {
|
|||
const recordedOut = dayScans.find(scan => scan.direction === "out" &&
|
||||
Date.parse(scan.scannedAt) > scanAt &&
|
||||
(!nextIn || Date.parse(scan.scannedAt) < Date.parse(nextIn.scannedAt)));
|
||||
const previousOut = dayScans.filter(scan => scan.direction === "out" && Date.parse(scan.scannedAt) <= scanAt).at(-1);
|
||||
const [inDate, setInDate] = React.useState(() => shift ? workDate(shift.clockedInAt) : "");
|
||||
const [inTime, setInTime] = React.useState(() => shift ? localTimeHHmm(shift.clockedInAt) : "");
|
||||
const [outDate, setOutDate] = React.useState(() => recordedOut ? workDate(recordedOut.scannedAt) : scanDate);
|
||||
|
|
@ -109,7 +108,7 @@ function CorrectionForm({ shift, onClose }: {
|
|||
<DialogDescription>
|
||||
{shift?.employeeName} ({shift?.employeeNo}) was scanned in on{" "}
|
||||
{shift && new Date(shift.clockedInAt).toLocaleDateString("en-ZA", { timeZone: "Africa/Johannesburg" })} at {scanTime}.
|
||||
The original QR scan stays in the record.
|
||||
Your entered times determine the corrected hours, regardless of recorded scan times. The original QR scan stays in the record.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{dayScans.length > 0 && (
|
||||
|
|
@ -140,11 +139,7 @@ function CorrectionForm({ shift, onClose }: {
|
|||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{previousOut && (
|
||||
<p className="mt-2 text-xs text-amber-700 dark:text-amber-400">
|
||||
An earlier clock-out is already recorded. Moving arrival before it needs a review of those scans.
|
||||
</p>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)}
|
||||
{lateScan && (
|
||||
|
|
|
|||
|
|
@ -66,12 +66,12 @@ describe("missed scan-out correction", () => {
|
|||
expect(lockDelete).toHaveBeenCalledWith("lock1");
|
||||
});
|
||||
|
||||
it("rejects an out after the next IN without writing", async () => {
|
||||
it("allows a leaving override after the next recorded IN", async () => {
|
||||
const { pb, send } = fixture();
|
||||
await expect(correctMissedClockOut(pb as never, "oldin", {
|
||||
await correctMissedClockOut(pb as never, "oldin", {
|
||||
actualOutLocal: "2026-09-25T09:00", reason: "Missed exit scan", now,
|
||||
})).rejects.toMatchObject({ status: 409 });
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(send).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects a duplicate correction and leaves imported time untouched", async () => {
|
||||
|
|
@ -127,18 +127,21 @@ describe("corrected arrival when the morning scan was missed", () => {
|
|||
const now = new Date("2026-09-25T08:00:00Z");
|
||||
// The 17:05 scan was really the clock-out; the worker arrived at 07:00.
|
||||
|
||||
it("measures hours from the corrected arrival when leaving equals the scan time", async () => {
|
||||
it.each([
|
||||
{ leaving: "16:00", hours: 8.5 },
|
||||
{ leaving: "15:00", hours: 7.5 },
|
||||
])("uses corrected times when leaving $leaving is at or before the original scan", async ({ leaving, hours }) => {
|
||||
const { pb, created } = fixture();
|
||||
const result = await correctMissedClockOut(pb as never, "oldin", {
|
||||
actualInLocal: "2026-09-23T07:00",
|
||||
actualOutLocal: "2026-09-23T16:00",
|
||||
actualOutLocal: `2026-09-23T${leaving}`,
|
||||
reason: "Nobody scanned him in this morning",
|
||||
now,
|
||||
});
|
||||
// 07:00 -> 16:00 SAST is 9h raw; paid hours subtract the 30-minute lunch.
|
||||
expect(result.sessionHours).toBe(8.5);
|
||||
// Paid hours use the entered interval and subtract the 30-minute lunch.
|
||||
expect(result.sessionHours).toBe(hours);
|
||||
expect(created.find((row) => row.collection === "clock_missed_out_corrections")?.data)
|
||||
.toMatchObject({ actual_in_at: "2026-09-23T05:00:00.000Z", actual_out_at: "2026-09-23T14:00:00.000Z" });
|
||||
.toMatchObject({ actual_in_at: "2026-09-23T05:00:00.000Z", actual_out_at: leaving === "16:00" ? "2026-09-23T14:00:00.000Z" : "2026-09-23T13:00:00.000Z" });
|
||||
});
|
||||
|
||||
it("rejects an unparseable arrival", async () => {
|
||||
|
|
@ -157,32 +160,27 @@ describe("corrected arrival when the morning scan was missed", () => {
|
|||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an arrival at or before the worker's earlier clock-out that day", async () => {
|
||||
it("allows an arrival at or before the worker's earlier clock-out that day", async () => {
|
||||
const earlyOut = { id: "earlyout", employee: "emp1", site: "site1", direction: "out",
|
||||
scanned_at: "2026-09-23T10:00:00Z" }; // 12:00 SAST
|
||||
const { pb, send } = fixture({ events: [newIn, oldIn, earlyOut] });
|
||||
await expect(correctMissedClockOut(pb as never, "oldin", {
|
||||
actualInLocal: "2026-09-23T07:00", actualOutLocal: "2026-09-23T16:00", reason: "No morning scan", now,
|
||||
})).rejects.toMatchObject({
|
||||
status: 409, message: "Arrival overlaps the worker's earlier clock-out that day",
|
||||
});
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
})).resolves.toMatchObject({ sessionHours: 8.5 });
|
||||
expect(send).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects an arrival that lands before the LAST clock-out scanned that day", async () => {
|
||||
it("allows an arrival that lands before the LAST clock-out scanned that day", async () => {
|
||||
const outMorning = { id: "outam", employee: "emp1", site: "site1", direction: "out",
|
||||
scanned_at: "2026-09-23T08:00:00Z" }; // 10:00 SAST
|
||||
const outMidday = { id: "outpm", employee: "emp1", site: "site1", direction: "out",
|
||||
scanned_at: "2026-09-23T11:00:00Z" }; // 13:00 SAST
|
||||
// The 16:00 scan is the unresolved IN. The nearest earlier exit is
|
||||
// 13:00, so an 11:00 arrival overlaps it.
|
||||
// The manual arrival wins even when multiple earlier exits were scanned.
|
||||
const { pb, send } = fixture({ events: [newIn, oldIn, outMorning, outMidday] });
|
||||
await expect(correctMissedClockOut(pb as never, "oldin", {
|
||||
actualInLocal: "2026-09-23T11:00", actualOutLocal: "2026-09-23T16:00", reason: "No morning scan", now,
|
||||
})).rejects.toMatchObject({
|
||||
status: 409, message: "Arrival overlaps the worker's earlier clock-out that day",
|
||||
});
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
})).resolves.toMatchObject({ sessionHours: 5 });
|
||||
expect(send).toHaveBeenCalledOnce();
|
||||
|
||||
// After that 13:00 exit the arrival is fine.
|
||||
const allowed = fixture({ events: [newIn, oldIn, outMorning, outMidday] });
|
||||
|
|
|
|||
|
|
@ -116,23 +116,8 @@ export async function correctMissedClockOut(
|
|||
if (!unresolvedClockIns(events, now, corrected).some((event) => event.id === clockInEventId)) {
|
||||
throw new ClockCorrectionError("This scan-out has already been resolved or is no longer open", 409);
|
||||
}
|
||||
const nextIn = events.filter((event) => event.direction === "in" &&
|
||||
Date.parse(event.scanned_at) > scanAt)
|
||||
.sort((a, b) => a.scanned_at.localeCompare(b.scanned_at))[0];
|
||||
if (nextIn && actualOut.getTime() >= Date.parse(nextIn.scanned_at)) {
|
||||
throw new ClockCorrectionError("Leaving time overlaps the worker's next clock-in", 409);
|
||||
}
|
||||
// A corrected arrival sits inside the same shift as this scan, so it
|
||||
// must not rewind past the LAST clock-out the worker scanned that day.
|
||||
// Descending: the nearest earlier exit is the one that would be
|
||||
// overwritten by an arrival before it.
|
||||
const previousOut = events.filter((event) => event.direction === "out" &&
|
||||
workDate(event.scanned_at) === scanWorkDate &&
|
||||
Date.parse(event.scanned_at) <= scanAt)
|
||||
.sort((a, b) => b.scanned_at.localeCompare(a.scanned_at))[0];
|
||||
if (actualIn && previousOut && start <= Date.parse(previousOut.scanned_at)) {
|
||||
throw new ClockCorrectionError("Arrival overlaps the worker's earlier clock-out that day", 409);
|
||||
}
|
||||
// Manual times are authoritative; QR scans remain audit evidence and
|
||||
// do not constrain the corrected arrival or leaving time.
|
||||
const entries = fetchedEntries.filter((entry) => !entry.timesheet);
|
||||
if (entries.length > 1) {
|
||||
throw new ClockCorrectionError("More than one direct time entry exists for this day. Review it manually.", 409);
|
||||
|
|
|
|||
Loading…
Reference in a new issue