MEGAINFRA-WEBAPP/scripts/migrations/2026-09-25-backfill-clock-shifts.py

232 lines
10 KiB
Python

#!/usr/bin/env python3
"""Pair historical QR scans into shifts without rewriting events or payroll.
Dry-run is the default. Supply PB_URL, PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD
for a read-only live report. --apply additionally requires --backup-confirmed
pointing to an existing backup file; apply aborts if any ambiguity remains.
"""
import argparse
from collections import Counter, defaultdict
from datetime import datetime, timezone
from hashlib import sha256
import json
import os
from pathlib import Path
import sys
import urllib.error
import urllib.parse
import urllib.request
from zoneinfo import ZoneInfo
LOCAL_ZONE = ZoneInfo("Africa/Johannesburg")
def instant(value):
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
if parsed.tzinfo is None:
raise ValueError("scan instant has no timezone")
return parsed.astimezone(timezone.utc)
def local_date(value):
return instant(value).astimezone(LOCAL_ZONE).date().isoformat()
def anomaly(kind, *record_ids):
return {"kind": kind, "record_ids": list(record_ids)}
def pair_events(events):
by_employee = defaultdict(list)
for row in events:
by_employee[row["employee"]].append(row)
shifts = []
anomalies = []
for employee, rows in sorted(by_employee.items()):
rows = sorted(rows, key=lambda row: (instant(row["scanned_at"]), row["id"]))
active = None
employee_shifts = []
employee_anomalies = []
for row in rows:
direction = row["direction"]
if direction == "in":
if active is not None:
employee_anomalies.append(anomaly("consecutive_in", active["id"], row["id"]))
active = row
elif direction == "out":
if active is None:
employee_anomalies.append(anomaly("orphan_out", row["id"]))
continue
if instant(row["scanned_at"]) <= instant(active["scanned_at"]):
employee_anomalies.append(anomaly("overlap", active["id"], row["id"]))
active = None
continue
if row.get("site") != active.get("site"):
employee_anomalies.append(anomaly("site_mismatch", active["id"], row["id"]))
shift = {
"employee": employee, "site": active["site"],
"clock_in_event": active["id"],
"clock_in_at": instant(active["scanned_at"]).isoformat(),
"local_work_date": local_date(active["scanned_at"]),
"clock_out_event": row["id"],
"clock_out_at": instant(row["scanned_at"]).isoformat(),
"close_source": "scan",
"payroll_status": "synced" if row.get("timesheet_entry") else "conflict",
}
if row.get("timesheet_entry"):
shift["timesheet_entry"] = row["timesheet_entry"]
employee_shifts.append(shift)
active = None
else:
employee_anomalies.append(anomaly("invalid_direction", row["id"]))
if instant(row["scanned_at"]).date().isoformat() != local_date(row["scanned_at"]):
employee_anomalies.append(anomaly("utc_local_date_disagreement", row["id"]))
if active is not None:
employee_shifts.append({
"employee": employee, "site": active["site"],
"clock_in_event": active["id"],
"clock_in_at": instant(active["scanned_at"]).isoformat(),
"local_work_date": local_date(active["scanned_at"]),
"payroll_status": "open",
})
# Date differences are reported but the local work date is unambiguous.
severe = [item for item in employee_anomalies
if item["kind"] != "utc_local_date_disagreement"]
if not severe:
shifts.extend(employee_shifts)
anomalies.extend(employee_anomalies)
return shifts, anomalies
def audit_timesheets(shifts, entries):
anomalies = []
by_day = defaultdict(list)
by_id = {row["id"]: row for row in entries}
for row in entries:
if row.get("employee") and not row.get("timesheet"):
by_day[(row["employee"], local_date(row["date_logged"]))].append(row)
for key, rows in by_day.items():
if len(rows) > 1:
anomalies.append(anomaly("duplicate_direct_entries", *(row["id"] for row in rows)))
for shift in shifts:
if shift["payroll_status"] == "open":
continue
linked = shift.get("timesheet_entry")
if not linked:
anomalies.append(anomaly("ambiguous_timesheet_link", shift["clock_in_event"]))
continue
entry = by_id.get(linked)
if not entry or entry.get("timesheet") or entry.get("employee") != shift["employee"]:
anomalies.append(anomaly("ambiguous_timesheet_link", shift["clock_in_event"], linked))
elif local_date(entry["date_logged"]) != shift["local_work_date"]:
anomalies.append(anomaly("timesheet_date_mismatch", shift["clock_in_event"], linked))
return anomalies
def request(base, token, method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(base + path, data=data, method=method,
headers={"Authorization": token,
"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=30) as response:
content = response.read()
return json.loads(content) if content else None
except urllib.error.HTTPError as error:
if error.code == 404 and method == "GET":
return None
raise RuntimeError(f"{method} {path} failed ({error.code})") from None
def authenticate(base):
req = urllib.request.Request(
base + "/api/collections/_superusers/auth-with-password",
data=json.dumps({"identity": os.environ["PB_ADMIN_EMAIL"],
"password": os.environ["PB_ADMIN_PASSWORD"]}).encode(),
method="POST", headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=30) as response:
return json.load(response)["token"]
except urllib.error.HTTPError as error:
raise RuntimeError(f"authentication failed ({error.code})") from None
def all_records(base, token, collection):
rows = []
page = 1
while True:
query = urllib.parse.urlencode({"perPage": 200, "page": page})
response = request(base, token, "GET", f"/api/collections/{collection}/records?{query}")
rows.extend(response["items"])
if page >= response["totalPages"]:
return rows
page += 1
def shift_id(in_event_id):
return sha256(("clock-shift:" + in_event_id).encode()).hexdigest()[:15]
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--apply", action="store_true")
parser.add_argument("--backup-confirmed", type=Path)
args = parser.parse_args()
names = ("PB_URL", "PB_ADMIN_EMAIL", "PB_ADMIN_PASSWORD")
supplied = [name for name in names if os.environ.get(name)]
if args.apply:
if len(supplied) != 3:
parser.error("--apply requires all three PB environment variables")
if not args.backup_confirmed or not args.backup_confirmed.is_file():
parser.error("--apply requires --backup-confirmed with an existing backup file")
if supplied and len(supplied) != 3:
parser.error("set all three PB variables for a live read-only report")
if not supplied:
print("DRY RUN: configure PB_URL, PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD for read-only pairing report")
return 0
base = os.environ["PB_URL"].rstrip("/")
token = authenticate(base)
events = all_records(base, token, "clock_events")
entries = all_records(base, token, "timesheet_entries")
employees = {row["id"]: row for row in all_records(base, token, "employees")}
shifts, anomalies = pair_events(events)
anomalies += audit_timesheets(shifts, entries)
counts = Counter(row["direction"] for row in events)
statuses = Counter(row["payroll_status"] for row in shifts)
print(f"{'APPLY' if args.apply else 'DRY RUN'}: {len(events)} events; {counts['in']} ins; {counts['out']} outs")
print(f"Paired {len(shifts)} shifts; {statuses['open']} open; {statuses['synced']} linked closed; {statuses['conflict']} unlinked closed")
existing_collection = request(base, token, "GET", "/api/collections/clock_shifts")
existing_shifts = all_records(base, token, "clock_shifts") if existing_collection else []
existing_ins = {row["clock_in_event"] for row in existing_shifts}
new_shifts = [row for row in shifts if row["clock_in_event"] not in existing_ins]
print(f"Existing shifts {len(existing_shifts)}; new shifts {len(new_shifts)}")
old_open = [row for row in shifts if row["payroll_status"] == "open" and
row["local_work_date"] == "2026-09-23"]
if old_open:
print("23 September unresolved badges: " + ", ".join(sorted(
str(employees.get(row["employee"], {}).get("employee_no") or row["employee"])
for row in old_open)))
print(f"Anomalies: {len(anomalies)}")
for item in anomalies:
print(f" {item['kind']}: {', '.join(item['record_ids'])}")
if not args.apply:
print("No writes. Historical totals remain unchanged.")
return 0
if not existing_collection or anomalies:
raise RuntimeError("apply stopped: missing shift schema or unresolved anomalies")
for row in new_shifts:
request(base, token, "POST", "/api/collections/clock_shifts/records",
{"id": shift_id(row["clock_in_event"]), **row})
print(f"Created {len(new_shifts)} shifts. No clock events or timesheet entries changed.")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except (RuntimeError, ValueError) as error:
print(f"ERROR: {error}", file=sys.stderr)
sys.exit(1)