/** * 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({ 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({ 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({ 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({ 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; });