The reader always read the slip date (and receiver, site name, customer VAT, supplier registration), but dn_batch_notes never had those fields, so they were dropped on save and every receipt was dated the moment it was posted. The Financial dashboard dated orders by upload date, so 24 of 41 POs (R7.08M) sat in no month and deliveries piled up in the month they were booked: 'R1.1M ordered, R2.4M delivered'. - scripts/pb-add-dn-dates.py: adds the five header fields, delivery_notes.posted_at and purchase_orders.order_date (applied). - scripts/dn-backfill-dates.py: filled 270 documents from the stored readings, moved 192 receipts to their slip date (booking time kept in posted_at) and read 36 PO order dates from the PDFs (applied; replaced values in docs/audits/2026-10-11-dates-backfill-backup.json). - Posting refuses a slip without a delivery date instead of using today; receipts record posted_at. The review page shows the date and locks Post with a plain message when it is missing. - PO intake reads 'PO Creation Date' from the PDF text (pdftotext). - Dashboard tile and monthly chart date orders by order_date only; an order without one is undated, never put on its upload month. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
187 lines
7.8 KiB
Python
187 lines
7.8 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Fill in the dates that were read but never kept.
|
|
|
|
1. dn_batch_notes: date, received_by_name, site_name_raw, customer_vat_no,
|
|
supplier_registration_raw, taken from the stored page readings exactly
|
|
as the pipeline aggregates them (first non-empty value over the
|
|
document's pages; a person's manual override wins).
|
|
2. delivery_notes: posted_at = the old date_received (when it was booked),
|
|
then date_received = the slip's delivery date.
|
|
3. purchase_orders: order_date = "PO Creation Date" printed on the PO PDF.
|
|
|
|
A slip date is only used when it is plausible for that upload: not after the
|
|
upload, and at most 180 days before it. Anything else (a misread year,
|
|
"2024-04" on a 2026 slip) is left empty and listed, never guessed.
|
|
|
|
Dry run by default: prints what it would change. --apply writes, after
|
|
saving every value it replaces to --backup (JSON).
|
|
|
|
python3 scripts/dn-backfill-dates.py
|
|
python3 scripts/dn-backfill-dates.py --apply --backup /path/backup.json
|
|
"""
|
|
import argparse
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import urllib.parse
|
|
import urllib.request
|
|
from datetime import date, datetime, timedelta
|
|
|
|
from pb_env import PB, http, superuser_token
|
|
|
|
HEADER = ["date", "received_by_name", "site_name_raw", "customer_vat_no", "supplier_registration_raw"]
|
|
PO_DATE = re.compile(r"(?:PO\s*Creation\s*Date|Order\s*Date|PO\s*Date)\s*:?\s*(\d{1,2})[/.-](\d{1,2})[/.-](\d{4})", re.I)
|
|
|
|
|
|
def fetch_all(token, coll, fields, flt=""):
|
|
out, page = [], 1
|
|
while True:
|
|
q = {"fields": fields, "perPage": 500, "page": page}
|
|
if flt:
|
|
q["filter"] = flt
|
|
code, body = http("GET", f"/api/collections/{coll}/records?" + urllib.parse.urlencode(q), token=token)
|
|
if code != 200:
|
|
sys.exit(f"GET {coll} failed: {code} {body}")
|
|
out += body["items"]
|
|
if page >= body["totalPages"]:
|
|
return out
|
|
page += 1
|
|
|
|
|
|
def iso_day(value):
|
|
if not isinstance(value, str):
|
|
return None
|
|
m = re.match(r"^(\d{4})-(\d{2})-(\d{2})", value.strip())
|
|
if not m:
|
|
return None
|
|
try:
|
|
return date(int(m[1]), int(m[2]), int(m[3]))
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def plausible(slip_day, uploaded):
|
|
if not slip_day or not uploaded:
|
|
return False
|
|
return uploaded - timedelta(days=180) <= slip_day <= uploaded + timedelta(days=1)
|
|
|
|
|
|
def po_order_date(token, files_token, po):
|
|
if not po.get("purchase_order_file") or not po["purchase_order_file"].lower().endswith(".pdf"):
|
|
return None
|
|
url = f"{PB}/api/files/{po['collectionId']}/{po['id']}/{urllib.parse.quote(po['purchase_order_file'])}?token={files_token}"
|
|
with tempfile.NamedTemporaryFile(suffix=".pdf") as f:
|
|
f.write(urllib.request.urlopen(url).read())
|
|
f.flush()
|
|
text = subprocess.run(["pdftotext", "-l", "2", f.name, "-"], capture_output=True, text=True).stdout
|
|
m = PO_DATE.search(text)
|
|
if not m:
|
|
return None
|
|
try:
|
|
return date(int(m[3]), int(m[2]), int(m[1]))
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--apply", action="store_true")
|
|
ap.add_argument("--backup", default="")
|
|
args = ap.parse_args()
|
|
if args.apply and not args.backup:
|
|
sys.exit("--apply needs --backup <file> for the values it replaces")
|
|
token = superuser_token()
|
|
|
|
batches = {b["id"]: b for b in fetch_all(token, "dn_batches", "id,uploaded_at,created")}
|
|
pages = fetch_all(token, "dn_batch_pages", "id,batch,global_page,extraction")
|
|
reading = {(p["batch"], p.get("global_page")): (p.get("extraction") or {}) for p in pages}
|
|
notes = fetch_all(token, "dn_batch_notes", "id,batch,pages,doc_number,manual_overrides," + ",".join(HEADER))
|
|
|
|
note_writes, note_day, rejected = [], {}, []
|
|
for n in notes:
|
|
b = batches.get(n["batch"]) or {}
|
|
uploaded = iso_day(b.get("uploaded_at") or b.get("created"))
|
|
reads = [reading.get((n["batch"], g), {}) for g in sorted(n.get("pages") or [])]
|
|
overrides = n.get("manual_overrides") or {}
|
|
values = {}
|
|
for key in HEADER:
|
|
o = overrides.get(key) if isinstance(overrides, dict) else None
|
|
v = o.strip() if isinstance(o, str) and o.strip() else next((r.get(key) for r in reads if isinstance(r.get(key), str) and r.get(key).strip()), "")
|
|
values[key] = (v or "").strip()
|
|
day = iso_day(values["date"])
|
|
if values["date"] and not plausible(day, uploaded):
|
|
rejected.append((n.get("doc_number"), values["date"], str(uploaded)))
|
|
values["date"] = ""
|
|
day = None
|
|
if day:
|
|
note_day[n["id"]] = day
|
|
change = {k: v for k, v in values.items() if v and v != (n.get(k) or "")}
|
|
if change:
|
|
note_writes.append((n, change))
|
|
|
|
receipts = fetch_all(token, "delivery_notes", "id,delivery_note_number,batch_note,date_received,posted_at")
|
|
receipt_writes, no_slip_date = [], []
|
|
for d in receipts:
|
|
change = {}
|
|
if not d.get("posted_at") and d.get("date_received"):
|
|
change["posted_at"] = d["date_received"]
|
|
day = note_day.get(d.get("batch_note") or "")
|
|
if day:
|
|
target = f"{day.isoformat()} 00:00:00.000Z"
|
|
if (d.get("date_received") or "")[:10] != day.isoformat():
|
|
change["date_received"] = target
|
|
else:
|
|
no_slip_date.append(d.get("delivery_note_number"))
|
|
if change:
|
|
receipt_writes.append((d, change))
|
|
|
|
pos = fetch_all(token, "purchase_orders", "id,collectionId,purchase_order_number,purchase_order_file,order_date")
|
|
code, ft = http("POST", "/api/files/token", token=token)
|
|
files_token = ft["token"]
|
|
po_writes, po_unread = [], []
|
|
for p in pos:
|
|
if p.get("order_date"):
|
|
continue
|
|
day = po_order_date(token, files_token, p)
|
|
if day:
|
|
po_writes.append((p, {"order_date": f"{day.isoformat()} 00:00:00.000Z"}))
|
|
else:
|
|
po_unread.append(p["purchase_order_number"])
|
|
|
|
print(f"delivery documents: {len(notes)}, to fill: {len(note_writes)}, with a slip date: {len(note_day)}")
|
|
print(f" slip dates rejected as implausible: {len(rejected)}")
|
|
for r in rejected:
|
|
print(f" {r[0]}: read {r[1]}, uploaded {r[2]}")
|
|
moved = sum(1 for _, c in receipt_writes if "date_received" in c)
|
|
print(f"booked receipts: {len(receipts)}, date_received moved to the slip date: {moved}, posted_at kept: {sum(1 for _, c in receipt_writes if 'posted_at' in c)}")
|
|
print(f" receipts with no usable slip date (left as they are): {len(no_slip_date)} {no_slip_date[:20]}")
|
|
print(f"purchase orders: {len(pos)}, order date read: {len(po_writes)}, not readable: {len(po_unread)} {po_unread}")
|
|
|
|
if not args.apply:
|
|
print("\nDry run. Nothing written.")
|
|
return
|
|
|
|
backup = {
|
|
"notes": [{"id": n["id"], **{k: n.get(k) or "" for k in HEADER}} for n, _ in note_writes],
|
|
"receipts": [{"id": d["id"], "date_received": d.get("date_received"), "posted_at": d.get("posted_at")} for d, _ in receipt_writes],
|
|
"purchase_orders": [{"id": p["id"], "order_date": p.get("order_date") or ""} for p, _ in po_writes],
|
|
"at": datetime.utcnow().isoformat() + "Z",
|
|
}
|
|
with open(args.backup, "w") as f:
|
|
json.dump(backup, f, indent=1)
|
|
print(f"backup of replaced values: {args.backup}")
|
|
for coll, writes in (("dn_batch_notes", note_writes), ("delivery_notes", receipt_writes), ("purchase_orders", po_writes)):
|
|
failed = 0
|
|
for rec, change in writes:
|
|
code, body = http("PATCH", f"/api/collections/{coll}/records/{rec['id']}", change, token)
|
|
if code != 200:
|
|
failed += 1
|
|
print(f" ! {coll} {rec['id']}: {code} {body}")
|
|
print(f" ✓ {coll}: {len(writes) - failed} updated, {failed} failed")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|