diff --git a/docs/clock-daily-exceptions-rollout.md b/docs/clock-daily-exceptions-rollout.md index f06e200..b8ba615 100644 --- a/docs/clock-daily-exceptions-rollout.md +++ b/docs/clock-daily-exceptions-rollout.md @@ -41,3 +41,38 @@ If the app release must be rolled back, pause worker scans first. Keep new corre ## Final gate Do not apply the schema or deploy until this local diff, test/build results, backup, and the existing shared-PIN limitation have been reviewed. Production data remains untouched at this stage. + +## Paid hours rule + +QR sessions store PAID hours, not the raw elapsed span, because the company +pays and bills a standard 9-hour day. The rule lives in one pure module, +`src/lib/clock-paid-hours.ts`, and both the scan route and the missed-out +correction call it, so a corrected arrival lands on the same day a QR scan +would have recorded. All comparisons are in Africa/Johannesburg. + +- Paid start: an `IN` at or before 07:10 local pays from 07:00; anything + later pays from the actual scan instant (short day). +- Paid end: an `OUT` between 16:20 and 16:40 inclusive pays to 16:30; an + earlier exit is a short day, a later one is overtime. +- A span longer than 5 hours loses a 30-minute lunch. Paid hours are + rounded to 2 decimals. + +The same bands apply on weekends. Examples: 06:52 to 16:34 is 9.00, 07:10 +to 16:40 is 9.00, 07:15 to 16:30 is 8.75, 07:00 to 17:30 is 10.00, 07:00 +to 15:00 is 7.50, and a rain-shortened 07:00 to 09:50 is 2.83 because it is +under the lunch threshold. + +Because the timesheet matrix and the worker calendar read the stored hours, +this rule needs no change in either report. A paid 9-hour weekday shows as +a normal day in the calendar and renders `09:00` with worked status in the +matrix; the calendar day details now label the figure "Paid hours" while +still showing the raw scan times. + +`scripts/migrations/2026-10-09-recalc-clock-paid-hours.py` replays the rule +over existing scans so rows written before the change agree with new ones. +It is dry-run by default and only writes with `--apply` and +`--backup-confirmed` pointing at an existing directory. An entry is rewritten +only when its stored hours still match the recomputed raw total, so +hand-edited rows are skipped and reported rather than overwritten. Its +read-only dry run against production on 2026-10-09 reported 467 rows to +change, 29 unchanged and 0 skipped. diff --git a/scripts/migrations/2026-10-09-recalc-clock-paid-hours.py b/scripts/migrations/2026-10-09-recalc-clock-paid-hours.py new file mode 100644 index 0000000..ccd34f4 --- /dev/null +++ b/scripts/migrations/2026-10-09-recalc-clock-paid-hours.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +"""Rebuild direct (QR clock) timesheet_entries hours as PAID hours. + +Clock scans used to store the raw elapsed span (06:52 -> 16:34 = 9.7h). +The company pays a standard 9-hour day, so `src/lib/clock-paid-hours.ts` +now stores paid hours: an IN at or before 07:10 local pays from 07:00, an +OUT between 16:20 and 16:40 inclusive pays to 16:30, and a span past five +hours loses a 30-minute lunch. + +This script replays the same rule over the stored scan history so entries +written before the change agree with new ones. It only rewrites an entry +when its current hours still equal the raw recomputed total (within 0.02), +which leaves hand-edited rows alone; those are listed as skipped. + +Dry run by default. Only --apply --backup-confirmed PATH (an existing +directory) writes: the old hours of every changed row are written there as +JSON before anything is PATCHed. + +Credentials come from PB_URL / PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD, loaded +by scripts/pb_env.py from .env.local. +""" + +import argparse +from datetime import datetime +import json +import os +from pathlib import Path +import sys +from zoneinfo import ZoneInfo + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from pb_env import PB, superuser_token, http # noqa: E402 + +ZONE = ZoneInfo("Africa/Johannesburg") + +SHIFT_START = "07:00" +START_GRACE_MIN = 10 +SHIFT_END = "16:30" +END_GRACE_MIN = 10 +LUNCH_MIN = 30 +LUNCH_AFTER_HOURS = 5 + +# Stored hours_worked must still match the raw recomputation this closely +# to be rewritten; beyond that a person edited it by hand. +MATCH_TOLERANCE = 0.02 + + +def _minutes(value): + hour, minute = value.split(":") + return int(hour) * 60 + int(minute) + + +def _work_date(instant): + return instant.astimezone(ZONE).date() + + +def _at(date, clock): + hour, minute = clock.split(":") + return datetime(date.year, date.month, date.day, int(hour), int(minute), tzinfo=ZONE) + + +def _parse(iso): + return datetime.fromisoformat(iso.replace("Z", "+00:00")) + + +def paid_session_hours(in_iso, out_iso): + """Python twin of paidSessionHours() in src/lib/clock-paid-hours.ts.""" + in_at = _parse(in_iso) + out_at = _parse(out_iso) + + in_local = in_at.astimezone(ZONE) + out_local = out_at.astimezone(ZONE) + if in_local.hour * 60 + in_local.minute <= _minutes(SHIFT_START) + START_GRACE_MIN: + start = _at(in_local.date(), SHIFT_START) + else: + start = in_at + + end_minutes = out_local.hour * 60 + out_local.minute + if _minutes(SHIFT_END) - END_GRACE_MIN <= end_minutes <= _minutes(SHIFT_END) + END_GRACE_MIN: + end = _at(out_local.date(), SHIFT_END) + else: + end = out_at + + span = max(0.0, (end - start).total_seconds() / 60) + paid = span - LUNCH_MIN if span > LUNCH_AFTER_HOURS * 60 else span + return round(paid / 60, 2) + + +def _raw_session_hours(in_iso, out_iso): + return round((_parse(out_iso) - _parse(in_iso)).total_seconds() / 3600, 2) + + +def _fetch_all(token, collection, sort=None, page_size=500): + """Page a whole collection. sort is optional: some collections reject + the system `created` field, and callers that key rows by id do not care.""" + rows = [] + page = 1 + while True: + query = f"?page={page}&perPage={page_size}" + (f"&sort={sort}" if sort else "") + status, body = http("GET", f"/api/collections/{collection}/records{query}", token=token) + if status != 200: + raise RuntimeError(f"could not read {collection} ({status}: {body.get('message')})") + rows.extend(body.get("items", [])) + if page >= body.get("totalPages", 1): + return rows + page += 1 + + +def build_sessions(events, corrections): + """Map timesheet_entry id -> (raw_hours, paid_hours) from scan history.""" + totals = {} + + def add(entry_id, in_iso, out_iso): + if not entry_id: + return + raw, paid = totals.get(entry_id, (0.0, 0.0)) + totals[entry_id] = ( + round(raw + _raw_session_hours(in_iso, out_iso), 2), + round(paid + paid_session_hours(in_iso, out_iso), 2), + ) + + # An IN followed by the next OUT on the same work date. The OUT row + # carries the timesheet_entry it was booked against. + open_in = {} + for event in sorted(events, key=lambda row: row["scanned_at"]): + stamp = event["scanned_at"] + if event["direction"] == "in": + open_in[event["employee"]] = stamp + continue + employee = event["employee"] + start = open_in.pop(employee, None) + if not start or _work_date(_parse(start)) != _work_date(_parse(stamp)): + continue + add(event.get("timesheet_entry"), start, stamp) + + # A missed exit has no OUT row: the correction supplies the pair. + by_id = {event["id"]: event for event in events} + for correction in corrections: + clock_in = by_id.get(correction.get("clock_in_event")) + if not clock_in: + continue + start = correction.get("actual_in_at") or clock_in["scanned_at"] + add(correction.get("timesheet_entry"), start, correction["actual_out_at"]) + + return totals + + +def plan(token): + events = _fetch_all(token, "clock_events", "scanned_at") + corrections = _fetch_all(token, "clock_missed_out_corrections", "recorded_at") + sessions = build_sessions(events, corrections) + entries = {row["id"]: row for row in _fetch_all(token, "timesheet_entries")} + + changed, unchanged, skipped = [], [], [] + for entry_id, (raw, paid) in sorted(sessions.items()): + row = entries.get(entry_id) + if not row: + skipped.append((entry_id, "entry could not be read")) + continue + if row.get("timesheet"): + skipped.append((entry_id, "imported timesheet row")) + continue + current = round(float(row.get("hours_worked") or 0), 2) + if abs(current - raw) > MATCH_TOLERANCE: + skipped.append((entry_id, f"stored {current} differs from raw {raw} (hand edited)")) + continue + if abs(current - paid) <= MATCH_TOLERANCE: + unchanged.append((row, paid)) + continue + changed.append((row, paid)) + + return changed, unchanged, skipped + + +def report(changed, unchanged, skipped): + print(f"to change: {len(changed)}") + print(f"unchanged: {len(unchanged)}") + print(f"skipped: {len(skipped)}") + if skipped: + print("\nskipped rows") + for entry_id, reason in skipped: + print(f" {entry_id}: {reason}") + if changed: + print("\nsample of changes") + print(f"{'employee':<14}{'date':<12}{'current':>9}{'new':>9}") + for row, paid in changed[:20]: + print(f"{str(row.get('employee'))[:13]:<14}{str(row.get('date_logged'))[:10]:<12}" + f"{float(row.get('hours_worked') or 0):>9.2f}{paid:>9.2f}") + if len(changed) > 20: + print(f" ... and {len(changed) - 20} more") + + +def main(): + parser = argparse.ArgumentParser(description="Recalculate clock paid hours") + parser.add_argument("--apply", action="store_true") + parser.add_argument("--backup-confirmed", type=Path) + args = parser.parse_args() + if args.apply and (not args.backup_confirmed or not args.backup_confirmed.is_dir()): + parser.error("--apply requires --backup-confirmed to be an existing directory") + if not (os.environ.get("POCKETBASE_ADMIN_EMAIL") and os.environ.get("POCKETBASE_ADMIN_PASSWORD")): + print(f"No PocketBase admin credentials in the environment or .env.local ({PB}).") + print("Offline plan only. No writes.") + return 0 + print(f"Mode: {'APPLY' if args.apply else 'DRY RUN'}") + + token = superuser_token() + changed, unchanged, skipped = plan(token) + report(changed, unchanged, skipped) + if not args.apply: + print("\nNo writes.") + return 0 + + backup = args.backup_confirmed / "2026-10-09-recalc-clock-paid-hours.json" + backup.write_text(json.dumps( + [{"id": row["id"], "employee": row.get("employee"), + "date_logged": row.get("date_logged"), + "hours_worked": row.get("hours_worked")} for row, _ in changed], + indent=2), encoding="utf-8") + print(f"\nbackup written: {backup}") + + for row, paid in changed: + code, saved = http("PATCH", f"/api/collections/timesheet_entries/records/{row['id']}", + {"hours_worked": paid}, token=token) + if code != 200: + raise RuntimeError(f"update failed for {row['id']} ({code})") + if round(float(saved.get("hours_worked")), 2) != round(paid, 2): + raise RuntimeError(f"update could not be verified for {row['id']}") + print(f"updated {len(changed)} entries") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except RuntimeError as error: + print(f"ERROR: {error}", file=sys.stderr) + sys.exit(1) \ No newline at end of file diff --git a/scripts/migrations/test_2026_10_09_recalc_clock_paid_hours.py b/scripts/migrations/test_2026_10_09_recalc_clock_paid_hours.py new file mode 100644 index 0000000..94827a2 --- /dev/null +++ b/scripts/migrations/test_2026_10_09_recalc_clock_paid_hours.py @@ -0,0 +1,93 @@ +"""Pure rule checks; never connects to production.""" +import importlib.util +from datetime import datetime, timezone +from pathlib import Path +from zoneinfo import ZoneInfo +import unittest + + +script = Path(__file__).with_name("2026-10-09-recalc-clock-paid-hours.py") +spec = importlib.util.spec_from_file_location("recalc_clock_paid_hours", script) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + + +def at(local): + """UTC ISO for a Africa/Johannesburg wall time.""" + hour, minute = (int(part) for part in local.split(":")) + return (datetime(2026, 10, 6, hour, minute, tzinfo=ZoneInfo("Africa/Johannesburg")) + .astimezone(timezone.utc).isoformat().replace("+00:00", "Z")) + + +# The same cases the TypeScript test asserts, so the two ports cannot drift. +CASES = [ + ("06:52", "16:34", 9.00), + ("07:10", "16:40", 9.00), + ("06:30", "16:30", 9.00), + ("07:15", "16:30", 8.75), + ("07:00", "17:30", 10.00), + ("07:00", "15:00", 7.50), + ("07:00", "14:00", 6.50), + ("07:00", "09:50", 2.83), + ("06:55", "16:19", 8.82), + ("06:49", "16:30", 9.00), + ("06:50", "16:30", 9.00), + ("07:11", "16:30", 8.82), + ("07:00", "16:19", 8.82), + ("07:00", "16:20", 9.00), + ("07:00", "16:41", 9.18), + ("07:00", "12:00", 5.00), + ("07:00", "12:01", 4.52), +] + + +class PaidSessionHoursTests(unittest.TestCase): + def test_matches_the_typescript_examples(self): + for start, end, expected in CASES: + with self.subTest(session=f"{start}-{end}"): + self.assertAlmostEqual( + module.paid_session_hours(at(start), at(end)), expected, places=2) + + def test_reads_a_utc_scan_instant_as_sast(self): + self.assertEqual( + module.paid_session_hours("2026-10-06T04:52:00.000Z", "2026-10-06T14:34:00.000Z"), + 9.0) + + def test_returns_zero_when_the_end_precedes_the_paid_start(self): + self.assertEqual(module.paid_session_hours(at("07:15"), at("07:05")), 0) + + +class BuildSessionsTests(unittest.TestCase): + def test_pairs_an_in_with_the_next_out_and_sums_paid_hours(self): + events = [ + {"id": "e1", "employee": "emp1", "direction": "in", + "scanned_at": "2026-10-06T04:52:00.000Z"}, + {"id": "e2", "employee": "emp1", "direction": "out", + "scanned_at": "2026-10-06T14:34:00.000Z", "timesheet_entry": "entry1"}, + ] + raw, paid = module.build_sessions(events, [])["entry1"] + self.assertAlmostEqual(raw, 9.7, places=2) + self.assertEqual(paid, 9.0) + + def test_ignores_an_out_on_a_different_work_date(self): + events = [ + {"id": "e1", "employee": "emp1", "direction": "in", + "scanned_at": "2026-10-06T21:00:00.000Z"}, + {"id": "e2", "employee": "emp1", "direction": "out", + "scanned_at": "2026-10-06T22:00:00.000Z", "timesheet_entry": "entry1"}, + ] + self.assertEqual(module.build_sessions(events, []), {}) + + def test_uses_the_corrected_arrival_when_one_was_recorded(self): + events = [{"id": "e1", "employee": "emp1", "direction": "in", + "scanned_at": "2026-09-23T14:00:00.000Z"}] + corrections = [{"clock_in_event": "e1", "timesheet_entry": "entry1", + "actual_in_at": "2026-09-23T05:00:00.000Z", + "actual_out_at": "2026-09-23T14:00:00.000Z"}] + raw, paid = module.build_sessions(events, corrections)["entry1"] + self.assertAlmostEqual(raw, 9.0, places=2) + self.assertEqual(paid, 8.5) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/src/app/api/clock/scan/route.test.ts b/src/app/api/clock/scan/route.test.ts index 9ae25f1..c823448 100644 --- a/src/app/api/clock/scan/route.test.ts +++ b/src/app/api/clock/scan/route.test.ts @@ -59,7 +59,8 @@ describe("POST /api/clock/scan", () => { const response = await post({ employee_no: "MCW058" }); expect(response.status).toBe(200); expect((await response.json()).direction).toBe("out"); - expect(entriesCreate).toHaveBeenCalledWith(expect.objectContaining({ hours_worked: 8 })); + // 08:00 -> 16:00 SAST raw is 8h; paid drops the 30-minute lunch. + expect(entriesCreate).toHaveBeenCalledWith(expect.objectContaining({ hours_worked: 7.5 })); } finally { vi.useRealTimers(); } }); beforeEach(() => { @@ -159,7 +160,7 @@ describe("POST /api/clock/scan", () => { clockEventsCreate.mockResolvedValueOnce({ id: "evt2" }); vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-09-11T14:30:00.000Z")); // 8.5h after clock-in + vi.setSystemTime(new Date("2026-09-11T14:30:00.000Z")); // 08:00 -> 16:30 SAST const res = await post({ employee_no: "MCW058" }); const json = await res.json(); @@ -167,13 +168,14 @@ describe("POST /api/clock/scan", () => { vi.useRealTimers(); expect(json.direction).toBe("out"); - expect(json.sessionHours).toBe(8.5); - expect(json.dailyTotalHours).toBe(8.5); + // Raw span is 8.5h; paid hours subtract the 30-minute lunch. + expect(json.sessionHours).toBe(8); + expect(json.dailyTotalHours).toBe(8); expect(entriesCreate).toHaveBeenCalledWith({ employee: EMPLOYEE.id, site: EMPLOYEE.site, date_logged: "2026-09-11T00:00:00.000Z", - hours_worked: 8.5, + hours_worked: 8, }); expect(clockEventsCreate).toHaveBeenCalledWith( expect.objectContaining({ direction: "out", timesheet_entry: "entry1" }), diff --git a/src/app/api/clock/scan/route.ts b/src/app/api/clock/scan/route.ts index b42a7b1..c2be06d 100644 --- a/src/app/api/clock/scan/route.ts +++ b/src/app/api/clock/scan/route.ts @@ -1,6 +1,7 @@ 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"; @@ -66,9 +67,8 @@ export async function POST(request: Request) { return NextResponse.json({ direction: "in", employeeName: employee.name, at: now }); } - const sessionHours = round2( - (new Date(now).getTime() - new Date(lastEvent.scanned_at).getTime()) / 3_600_000, - ); + // 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", " "); diff --git a/src/app/dashboard/workforce/on-site/correction-dialog.tsx b/src/app/dashboard/workforce/on-site/correction-dialog.tsx index 9af05d7..e4ea9e3 100644 --- a/src/app/dashboard/workforce/on-site/correction-dialog.tsx +++ b/src/app/dashboard/workforce/on-site/correction-dialog.tsx @@ -4,6 +4,7 @@ import * as React from "react"; import { useRouter } from "next/navigation"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { format24HourInput, localOutInstant, localTimeHHmm, workDate } from "@/lib/clock-day"; +import { paidSessionHours } from "@/lib/clock-paid-hours"; import type { AttentionRow } from "./on-site-view"; import { formatDecimal } from "@/lib/utils"; import { useAuth } from "@/hooks/use-auth"; @@ -37,8 +38,12 @@ function CorrectionForm({ shift, onClose }: { try { const start = localOutInstant(`${inDate}T${inTime}`); const out = localOutInstant(`${outDate}T${outTime}`); - const value = (out.getTime() - start.getTime()) / 3_600_000; - if (Number.isFinite(value) && value >= 0) hours = Math.round(value * 100) / 100; + // A leaving time at or before the arrival is not a session yet; the + // operator still has to correct the entered times. + if (out.getTime() > start.getTime()) { + // Same rule the API stores, so the preview matches the saved value. + hours = paidSessionHours(start.toISOString(), out.toISOString()); + } } catch { // The form keeps its entered values for correction. } diff --git a/src/components/worker-month-calendar.tsx b/src/components/worker-month-calendar.tsx index 1379b26..9986dbf 100644 --- a/src/components/worker-month-calendar.tsx +++ b/src/components/worker-month-calendar.tsx @@ -73,8 +73,8 @@ export function WorkerMonthCalendar({ employeeId, timesheets, scans, notes, stan
Monday–Friday are expected workdays. Weekends stay grey unless hours or clock scans are recorded. Red means check the records, not confirmed absence. Tap a day for details.
{month>today.slice(0,7) ? "Rain history is available for completed days." : weather?.month!==month ? "Loading rain history…" : weather.error ?? "Rain markers use the site recorded for that day. Missing weather is unknown; rainfall does not confirm why a shift was short."} Weather: Open-Meteo
-