188 lines
7.8 KiB
Python
188 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()
|