MEGAINFRA-WEBAPP/src/app/api/clock/scan/route.ts
DEls 75b178dd33 feat(clock): store paid hours, not the raw scan span
A scan in up to 07:10 pays from 07:00; a scan out between 16:20 and
16:40 pays to 16:30; a session over five hours loses a 30-minute lunch.
A normal day is therefore exactly 9 hours (green on the calendar and
matrix); late arrivals, early exits and overtime use the real times.
Applied on clock-out, in missed-scan corrections and their preview.
The calendar keeps the raw scan times and labels hours "Paid hours".

Includes a dry-run-first script to recalculate existing QR entries; it
only rewrites rows still equal to their raw scan total.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:01:17 +02:00

119 lines
3.8 KiB
TypeScript

import { NextResponse } from "next/server";
import { pbAdmin } from "@/lib/pb";
import { dateLogged, workDate } from "@/lib/clock-day";
import { paidSessionHours } from "@/lib/clock-paid-hours";
import type { ClockEventRecord, EmployeeRecord, TimesheetEntryRecord } from "@/lib/pb-types";
import { requireApiUser, requireSiteAccess } from "@/lib/auth/guard";
import { isWorkerActive } from "@/lib/worker-status";
function escapePb(s: string): string {
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
function round2(n: number): number {
return Math.round(n * 100) / 100;
}
export async function POST(request: Request) {
const user = await requireApiUser("clock");
if (user instanceof Response) return user;
let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Body must be JSON" }, { status: 400 });
}
const employeeNo = typeof body.employee_no === "string" ? body.employee_no.trim() : "";
if (!employeeNo) {
return NextResponse.json({ error: "employee_no is required" }, { status: 400 });
}
const pb = await pbAdmin();
let employee: EmployeeRecord;
try {
employee = await pb
.collection("employees")
.getFirstListItem<EmployeeRecord>(`employee_no="${escapePb(employeeNo)}"`);
} catch {
return NextResponse.json({ error: "Unknown badge" }, { status: 404 });
}
const denied = requireSiteAccess(user, employee.site);
if (denied) return denied;
const now = new Date().toISOString();
let lastEvent: ClockEventRecord | null;
try {
lastEvent = await pb
.collection("clock_events")
.getFirstListItem<ClockEventRecord>(`employee="${employee.id}"`, { sort: "-scanned_at" });
} catch {
lastEvent = null;
}
// The QR request contract stays unchanged for already-installed phones.
// Only a same-local-day IN can be closed by today's scan.
if (!lastEvent || lastEvent.direction === "out" ||
workDate(lastEvent.scanned_at) !== workDate(now)) {
if (!isWorkerActive(employee)) return NextResponse.json({ error: "Worker is inactive. Reactivate them before clocking in." }, { status: 409 });
await pb.collection("clock_events").create({
employee: employee.id,
site: employee.site,
direction: "in",
scanned_at: now,
});
return NextResponse.json({ direction: "in", employeeName: employee.name, at: now });
}
// The company bills a standard day, so a scan stores PAID hours, not the raw span.
const sessionHours = paidSessionHours(lastEvent.scanned_at, now);
const entryDate = dateLogged(workDate(lastEvent.scanned_at));
const pbEntryDate = entryDate.replace("T", " ");
let entry: TimesheetEntryRecord | null;
try {
entry = await pb
.collection("timesheet_entries")
.getFirstListItem<TimesheetEntryRecord>(
`employee="${employee.id}" && date_logged="${pbEntryDate}" && timesheet=""`,
);
} catch {
entry = null;
}
let dailyTotalHours: number;
let entryId: string;
if (entry) {
dailyTotalHours = round2(entry.hours_worked + sessionHours);
const updated = await pb
.collection("timesheet_entries")
.update<TimesheetEntryRecord>(entry.id, { hours_worked: dailyTotalHours });
entryId = updated.id;
} else {
const created = await pb.collection("timesheet_entries").create<TimesheetEntryRecord>({
employee: employee.id,
site: employee.site,
date_logged: entryDate,
hours_worked: sessionHours,
});
entryId = created.id;
dailyTotalHours = sessionHours;
}
await pb.collection("clock_events").create({
employee: employee.id,
site: employee.site,
direction: "out",
scanned_at: now,
timesheet_entry: entryId,
});
return NextResponse.json({
direction: "out",
employeeName: employee.name,
sessionHours,
dailyTotalHours,
});
}