MEGAINFRA-WEBAPP/scripts/close-flagged-site-moves.ts

68 lines
5.1 KiB
TypeScript
Raw Normal View History

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