MEGAINFRA-WEBAPP/scripts/pb-add-fuel-import-dedup.py
DEls 0a7138f60c fix: harden workforce, plant and financials
Improve workforce scanning, site transfers and timesheet exports.
Audit plant imports, duplicate identities, rates and dashboard totals.
Repair delivery PO selection, periodic rechecks and receiving guards.
Align financial ledgers, supplier periods and mobile layouts.
Add the site map preview, shared theme controls and audit evidence.

Verify 3,455 application tests, six fuel export tests, TypeScript and
an optimized production build. Clean up a leaked test timer and keep
local worktrees and generated reports out of ESLint traversal.

Changed-file lint passes; repository-wide lint retains 33 existing
errors in unchanged files.
2026-10-19 13:18:31 +02:00

223 lines
9.1 KiB
Python

#!/usr/bin/env python3
"""Add fuel import identity, audit counts and a private import lock.
Dry-run is the default (schema reads only after authentication):
python3 scripts/pb-add-fuel-import-dedup.py
python3 scripts/pb-add-fuel-import-dedup.py --apply --backup /tmp/fuel-schema.json
Connection/credentials come from pb_env (.env.local or environment). This only
changes schema; it never updates fuel records or steals/deletes import locks.
"""
import argparse
import copy
import json
import os
import re
import sys
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from pb_env import PB, http, superuser_token
LOCKED = dict.fromkeys(("listRule", "viewRule", "createRule", "updateRule", "deleteRule"))
LOG_FIELDS = [
{"name": name, "type": kind, "required": False}
for name, kind in (
("source_system", "text"), ("source_id", "text"),
("source_logged_at", "date"), ("source_created_at", "date"),
("source_modified_at", "date"), ("import_key", "text"),
)
]
LOG_INDEXES = [
"CREATE UNIQUE INDEX `idx_fuel_logs_source_identity` ON `fuel_logs` "
"(`source_system`, `source_id`) WHERE `source_id` != ''",
"CREATE UNIQUE INDEX `idx_fuel_logs_import_key` ON `fuel_logs` "
"(`import_key`) WHERE `import_key` != ''",
]
LOCK_INDEX = "CREATE UNIQUE INDEX `idx_fuel_import_locks_scope` ON `fuel_import_locks` (`scope`)"
def definitions():
return {
"fuel_logs": {"fields": LOG_FIELDS, "indexes": LOG_INDEXES},
"fuel_sheets": {"fields": [
{"name": "rows_skipped", "type": "number", "required": False},
{"name": "rows_review", "type": "number", "required": False},
], "indexes": []},
"fuel_import_locks": {
"name": "FUEL_IMPORT_LOCKS", "type": "base",
"fields": [
{"name": "scope", "type": "text", "required": True, "min": 1},
{"name": "owner", "type": "text", "required": True, "min": 1},
],
"indexes": [LOCK_INDEX], **LOCKED,
},
}
def index_tokens(sql):
"""Compare SQL without confusing identifier quoting/case with string values."""
tokens = re.findall(r"'(?:''|[^'])*'|`[^`]*`|\"[^\"]*\"|\[[^\]]*\]|[a-zA-Z_][a-zA-Z_0-9]*|!=|<>|\S", sql)
normalized = []
for token in tokens:
if token.startswith("'"):
normalized.append(token)
elif token[0] in ('`', '"', '['):
normalized.append(token[1:-1].lower())
elif token == "<>":
normalized.append("!=")
elif token != ";":
normalized.append(token.lower())
if normalized[3:6] == ["if", "not", "exists"]:
del normalized[3:6]
elif normalized[2:5] == ["if", "not", "exists"]:
del normalized[2:5]
return normalized
def index_name(sql):
tokens = index_tokens(sql)
try:
return tokens[tokens.index("index") + 1]
except (ValueError, IndexError):
raise RuntimeError(f"Cannot identify existing index: {sql}") from None
def plan_collection(name, existing, wanted):
"""Return an additive payload; reject incompatible schema before any writes."""
if existing is None:
if name != "fuel_import_locks":
raise RuntimeError(f"Required collection {name} does not exist")
return copy.deepcopy(wanted)
if existing.get("type") != "base":
raise RuntimeError(f"Incompatible collection type: {name}")
fields = copy.deepcopy(existing.get("fields", []))
by_name = {field["name"]: field for field in fields}
missing = []
for field in wanted["fields"]:
actual = by_name.get(field["name"])
if actual is None:
missing.append(copy.deepcopy(field))
continue
for key, value in field.items():
# PB may omit default required=false/min=0 in exported schemas.
default = False if key == "required" else None
if actual.get(key, default) != value:
raise RuntimeError(f"Incompatible field: {name}.{field['name']}.{key}")
indexes = list(existing.get("indexes", []))
by_index = {index_name(sql): sql for sql in indexes}
missing_indexes = []
for sql in wanted["indexes"]:
actual = by_index.get(index_name(sql))
if actual is None:
missing_indexes.append(sql)
elif index_tokens(actual) != index_tokens(sql):
raise RuntimeError(f"Conflicting index definition: {index_name(sql)}")
if name == "fuel_import_locks":
for rule in LOCKED:
if existing.get(rule) is not None:
raise RuntimeError(f"fuel_import_locks must be superuser-only: {rule}")
changes = {}
if missing:
changes["fields"] = fields + missing
if missing_indexes:
changes["indexes"] = indexes + missing_indexes
return changes
def read_schema(name, token):
code, result = http("GET", f"/api/collections/{name}", token=token)
if code == 404:
return None
if code != 200:
raise RuntimeError(f"Cannot inspect {name}: HTTP {code}: {result}")
return result
def write_backup(path, schemas):
snapshot = {"pocketbase_url": PB, "saved_at": datetime.now(timezone.utc).isoformat(),
"collections": schemas}
if path:
# Never overwrite an earlier recovery copy; keep schema/rules private.
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
else:
fd, path = tempfile.mkstemp(prefix="mega-before-fuel-dedup-", suffix=".json")
with os.fdopen(fd, "w") as backup:
json.dump(snapshot, backup, indent=2)
backup.write("\n")
return str(Path(path).resolve())
def migrate(token, apply=False, backup=None):
wanted = definitions()
before = {name: read_schema(name, token) for name in wanted}
plans = {name: plan_collection(name, before[name], spec) for name, spec in wanted.items()}
for name, changes in plans.items():
action = "create" if before[name] is None else "add schema fields/indexes"
print(f"{name}: {action if changes else 'already compatible'}")
if changes:
existing_fields = {f["name"] for f in (before[name] or {}).get("fields", [])}
for field in changes.get("fields", []):
if field["name"] not in existing_fields:
print(f" + {field['name']}: {field['type']}")
existing_indexes = (before[name] or {}).get("indexes", [])
for sql in changes.get("indexes", []):
if sql not in existing_indexes:
print(f" + {sql}")
if not apply:
print("Dry-run: no schema or record writes. Use --apply to apply this plan.")
return
if not any(plans.values()):
print("Already compatible; no changes needed.")
return
backup_path = write_backup(backup, before)
print(f"Private schema backup: {backup_path}")
# Abort on concurrent schema changes instead of overwriting a newer array.
for name in wanted:
if read_schema(name, token) != before[name]:
raise RuntimeError(f"Schema changed during preflight: {name}; rerun migration")
for name, changes in plans.items():
if not changes:
continue
existing = before[name]
method = "PATCH" if existing else "POST"
path = f"/api/collections/{existing['id']}" if existing else "/api/collections"
code, result = http(method, path, changes, token)
if code != 200:
raise RuntimeError(f"{method} {name} failed: HTTP {code}: {result}; backup: {backup_path}")
for name, spec in wanted.items():
actual = read_schema(name, token)
if actual is None or plan_collection(name, actual, spec):
raise RuntimeError(f"Verification failed: {name}")
original = before[name]
if original:
actual_fields = {f["name"]: f for f in actual["fields"]}
for field in original.get("fields", []):
if any(actual_fields.get(field["name"], {}).get(k) != v for k, v in field.items()):
raise RuntimeError(f"Existing field changed: {name}.{field['name']}")
for rule in LOCKED:
if actual.get(rule) != original.get(rule):
raise RuntimeError(f"Existing access rule changed: {name}.{rule}")
actual_indexes = [index_tokens(sql) for sql in actual.get("indexes", [])]
if any(index_tokens(sql) not in actual_indexes for sql in original.get("indexes", [])):
raise RuntimeError(f"Existing indexes changed: {name}")
print("Verified fuel import schema. No fuel records or lock records changed.")
def main():
parser = argparse.ArgumentParser(description=__doc__)
mode = parser.add_mutually_exclusive_group()
mode.add_argument("--apply", action="store_true", help="Apply additive schema changes")
mode.add_argument("--dry-run", action="store_true", help="Read and print schema plan (default)")
parser.add_argument("--backup", help="New private JSON backup path (default: unique /tmp file)")
args = parser.parse_args()
migrate(superuser_token(), apply=args.apply, backup=args.backup)
if __name__ == "__main__":
try:
main()
except (OSError, RuntimeError) as exc:
sys.exit(str(exc))