A destination scan now ends the worker's previous site session at that moment (transfer OUT, hours booked at the origin) and opens the new one, so nothing is left for the office to review. Vehicle scans take a scanning site, move the unit there and log the reading against it. Adds a script to close already-flagged moves. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
67 lines
5.1 KiB
TypeScript
67 lines
5.1 KiB
TypeScript
/**
|
|
* Run with npx tsx --env-file=.env.local scripts/close-flagged-site-moves.ts [--apply]. Defaults to a read-only preview.
|
|
*
|
|
* Before 2026-10-21 a scan at a second site left the first site's session open and
|
|
* parked the worker in "Site moves awaiting office review". A scan at the new site
|
|
* now ends the old session itself. This closes the ones already flagged the same way:
|
|
* an OUT at the origin site at the moment of the destination scan (a transfer OUT,
|
|
* so no shift-end rounding), the origin's hours added to that day's timesheet entry,
|
|
* and the flag marked resolved. Workers whose origin day already holds uploaded or
|
|
* overlapping hours are listed and left alone.
|
|
*/
|
|
import { createHash } from "node:crypto";
|
|
import { pbAdmin } from "../src/lib/pb";
|
|
import { dateLogged, workDate } from "../src/lib/clock-day";
|
|
import { clockDayPaidMinutes } from "../src/lib/clock-session-hours";
|
|
import type { ClockEventRecord, ClockMissedOutCorrectionRecord, TimesheetEntryRecord } from "../src/lib/pb-types";
|
|
|
|
const idFor = (value: string) => createHash("sha256").update(value).digest("hex").slice(0, 15);
|
|
|
|
async function main() {
|
|
const args = process.argv.slice(2);
|
|
if (args.some((arg) => arg !== "--apply")) throw new Error("Only --apply is supported");
|
|
const apply = args.includes("--apply");
|
|
const pb = await pbAdmin();
|
|
const flagged = await pb.collection("clock_events").getFullList<ClockEventRecord>({
|
|
filter: `previous_unclosed_in!="" && office_resolved_at=""`,
|
|
sort: "scanned_at",
|
|
expand: "employee,site,previous_unclosed_in,previous_unclosed_in.site",
|
|
});
|
|
let closed = 0;
|
|
for (const dest of flagged) {
|
|
const origin = (dest.expand as { previous_unclosed_in?: ClockEventRecord & { expand?: { site?: { name?: string } } } } | undefined)?.previous_unclosed_in;
|
|
const who = `${dest.expand?.employee?.name ?? dest.employee} (${dest.expand?.employee?.employee_no ?? "?"})`;
|
|
if (!origin) { console.log(`SKIP ${who}: origin scan missing`); continue; }
|
|
const day = workDate(origin.scanned_at);
|
|
const corrections = await pb.collection("clock_missed_out_corrections").getFullList<ClockMissedOutCorrectionRecord>({ filter: `employee="${dest.employee}"` });
|
|
if (corrections.some((c) => c.clock_in_event === origin.id)) { console.log(`SKIP ${who}: origin already corrected by the office`); continue; }
|
|
const outId = idFor(`clock-transfer-out:${dest.employee}:${day}:${origin.id}`);
|
|
const outAt = new Date(Date.parse(dest.scanned_at) - 1).toISOString();
|
|
const events = await pb.collection("clock_events").getFullList<ClockEventRecord>({
|
|
filter: `employee="${dest.employee}" && scanned_at>="${new Date(Date.parse(dateLogged(day)) - 7_200_000).toISOString()}" && scanned_at<="${dest.scanned_at}"`,
|
|
sort: "scanned_at",
|
|
});
|
|
if (events.some((e) => e.id === outId)) { console.log(`SKIP ${who}: already closed`); continue; }
|
|
const before = clockDayPaidMinutes(events, corrections, day);
|
|
const after = clockDayPaidMinutes([...events, { ...origin, id: outId, direction: "out", scanned_at: outAt, is_transfer: true }], corrections, day);
|
|
const hours = ((after.get(origin.site) ?? 0) - (before.get(origin.site) ?? 0)) / 60;
|
|
const entryDate = dateLogged(day);
|
|
const entries = await pb.collection("timesheet_entries").getFullList<TimesheetEntryRecord>({ filter: `employee="${dest.employee}" && site="${origin.site}" && date_logged="${entryDate.replace("T", " ")}"` });
|
|
if (entries.length > 1 || entries.some((e) => e.timesheet)) { console.log(`SKIP ${who}: ${day} already holds uploaded or overlapping hours at the origin site`); continue; }
|
|
const entry = entries[0];
|
|
const entryId = entry?.id ?? idFor(`clock-direct:${dest.employee}:${origin.site}:${day}`);
|
|
const total = (Math.round((entry?.hours_worked ?? 0) * 60) + Math.round(hours * 60)) / 60;
|
|
console.log(`${apply ? "CLOSE" : "WOULD CLOSE"} ${who}: ${origin.expand?.site?.name ?? origin.site} -> ${dest.expand?.site?.name ?? dest.site}, ${day} ${hours.toFixed(2)} h at the origin (day total ${total.toFixed(2)} h)`);
|
|
closed++;
|
|
if (!apply) continue;
|
|
const batch = pb.createBatch();
|
|
if (entry) batch.collection("timesheet_entries").update(entry.id, { hours_worked: total });
|
|
else batch.collection("timesheet_entries").create({ id: entryId, employee: dest.employee, site: origin.site, date_logged: entryDate, hours_worked: total, import_key: `${dest.employee}:${origin.site}:${day}` });
|
|
batch.collection("clock_events").create({ id: outId, employee: dest.employee, site: origin.site, direction: "out", scanned_at: outAt, timesheet_entry: entryId, is_transfer: true });
|
|
batch.collection("clock_events").update(dest.id, { office_resolved_at: new Date().toISOString(), office_note: "Closed automatically: the scan at the new site ended the previous session." });
|
|
const results = await batch.send();
|
|
if (results.some((r) => r.status < 200 || r.status >= 300)) throw new Error(`Could not verify the close for ${who}`);
|
|
}
|
|
console.log(JSON.stringify({ flagged: flagged.length, closed, applied: apply }));
|
|
}
|
|
main().catch((error) => { console.error("Site-move close-out failed. Nothing further was changed.", error); process.exitCode = 1; });
|