- Removed 424 duplicate invoice lines from re-running the import - Deleted JEISA000 stock code (it was actually your Account Ref) - Made create_line idempotent in import_to_pb.py so re-runs skip existing - Removed debug prints from extract_invoices.py
316 lines
12 KiB
Python
316 lines
12 KiB
Python
"""
|
|
Import parsed invoice data into PocketBase.
|
|
|
|
Reads data/review/all.json, upserts JEI_stock_codes and JEI_invoices,
|
|
uploads PDFs to JEI_invoices.pdf_file, and creates JEI_invoice_lines.
|
|
|
|
Idempotent: skips invoices whose invoice_no + document_type already exist.
|
|
|
|
Usage:
|
|
python scripts/import_to_pb.py [--dry-run]
|
|
"""
|
|
import os
|
|
import re
|
|
import sys
|
|
import json
|
|
import argparse
|
|
import urllib.request
|
|
import urllib.parse
|
|
from pathlib import Path
|
|
from urllib.error import HTTPError
|
|
from datetime import datetime
|
|
|
|
PB_URL = os.environ.get("PB_URL", "https://pb.agent.apphub.co.za")
|
|
PB_TOKEN = os.environ.get("PB_TOKEN", "")
|
|
if not PB_TOKEN:
|
|
env_path = Path(__file__).resolve().parent.parent / ".env.local"
|
|
if env_path.exists():
|
|
for line in open(env_path):
|
|
if line.startswith("PB_TOKEN="):
|
|
PB_TOKEN = line.strip().split("=", 1)[1]
|
|
if not PB_TOKEN:
|
|
sys.exit("ERROR: PB_TOKEN not set")
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
ALL_JSON = ROOT / "data" / "review" / "all.json"
|
|
INVOICES_DIR = ROOT / "invoices"
|
|
|
|
|
|
def pb_req(method: str, path: str, body=None, multipart=None, retries=3):
|
|
"""Make an API request with retry on transient errors."""
|
|
url = PB_URL + path
|
|
headers = {"Authorization": PB_TOKEN}
|
|
if multipart is None and body is not None:
|
|
headers["Content-Type"] = "application/json"
|
|
data = json.dumps(body).encode("utf-8")
|
|
elif multipart is not None:
|
|
boundary = "----PBIMPORT" + datetime.now().strftime("%H%M%S%f")
|
|
headers["Content-Type"] = f"multipart/form-data; boundary={boundary}"
|
|
data = b""
|
|
for k, v in (multipart or {}).items():
|
|
if isinstance(v, tuple):
|
|
fname, content, mime = v
|
|
data += f"--{boundary}\r\n".encode()
|
|
data += f'Content-Disposition: form-data; name="{k}"; filename="{fname}"\r\n'.encode()
|
|
data += f"Content-Type: {mime}\r\n\r\n".encode()
|
|
data += content
|
|
data += b"\r\n"
|
|
else:
|
|
data += f"--{boundary}\r\n".encode()
|
|
data += f'Content-Disposition: form-data; name="{k}"\r\n\r\n'.encode()
|
|
data += str(v).encode("utf-8")
|
|
data += b"\r\n"
|
|
data += f"--{boundary}--\r\n".encode()
|
|
else:
|
|
data = None
|
|
last_err = None
|
|
for attempt in range(retries):
|
|
req = urllib.request.Request(url, headers=headers, method=method, data=data)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=30) as r:
|
|
txt = r.read().decode("utf-8")
|
|
return json.loads(txt) if txt else None
|
|
except HTTPError as e:
|
|
body_txt = e.read().decode("utf-8")
|
|
try:
|
|
return None, json.loads(body_txt)
|
|
except Exception:
|
|
return None, body_txt
|
|
except Exception as e:
|
|
last_err = e
|
|
if attempt < retries - 1:
|
|
import time
|
|
time.sleep(2 ** attempt)
|
|
continue
|
|
raise last_err
|
|
|
|
|
|
def find_collection(name):
|
|
r = pb_req("GET", "/api/collections?perPage=200")
|
|
items = (r or {}).get("items", []) if isinstance(r, tuple) is False else []
|
|
if isinstance(r, dict):
|
|
items = r.get("items", [])
|
|
for c in items:
|
|
if c.get("name") == name:
|
|
return c
|
|
return None
|
|
|
|
|
|
def find_invoice(invoice_no: str, document_type: str):
|
|
q = f'invoice_no="{invoice_no}" && document_type="{document_type}"'
|
|
r = pb_req("GET", f"/api/collections/JEI_invoices/records?filter={urllib.parse.quote(q)}&perPage=1")
|
|
if isinstance(r, dict):
|
|
items = r.get("items", [])
|
|
return items[0] if items else None
|
|
return None
|
|
|
|
|
|
def find_stock_code(code: str):
|
|
q = f'code="{code}"'
|
|
r = pb_req("GET", f"/api/collections/JEI_stock_codes/records?filter={urllib.parse.quote(q)}&perPage=1")
|
|
if isinstance(r, dict):
|
|
items = r.get("items", [])
|
|
return items[0] if items else None
|
|
return None
|
|
|
|
|
|
def upsert_stock_code(rec: dict, dry_run: bool, sc_meta: dict | None = None) -> tuple[str, str]:
|
|
"""Returns (stock_code_id, action) where action is 'created', 'updated', or 'exists'.
|
|
|
|
sc_meta (optional) provides {first_seen_date, last_seen_date, description_goods}
|
|
derived from aggregating across all invoices.
|
|
"""
|
|
code = rec["stock_code"]
|
|
# sc_meta from extract_invoices.py is a list [first_seen, last_seen, description]
|
|
if isinstance(sc_meta, list) and len(sc_meta) >= 3:
|
|
first_seen = sc_meta[0]
|
|
last_seen = sc_meta[1]
|
|
description = sc_meta[2] or code
|
|
else:
|
|
first_seen = None
|
|
last_seen = None
|
|
description = rec.get("description") or code
|
|
|
|
existing = find_stock_code(code)
|
|
if existing:
|
|
# PATCH if any field is better than what's stored
|
|
needs_update = False
|
|
patch: dict = {}
|
|
old_desc = (existing.get("description_goods") or "").strip()
|
|
new_desc = (description or "").strip()
|
|
# Always update if: new is longer, OR old is just the code, OR old has artifact (trailing quote, etc.)
|
|
old_is_default = (old_desc == existing.get("code", "")) or not old_desc
|
|
old_has_artifact = old_desc.endswith('"') or old_desc.endswith('"') or " " in old_desc or old_desc.endswith(",")
|
|
new_is_better = (
|
|
(new_desc and old_is_default) or
|
|
(new_desc and len(new_desc) > len(old_desc) and not old_desc.startswith(new_desc[:20])) or
|
|
(new_desc and old_has_artifact and len(new_desc) >= len(old_desc) - 2)
|
|
)
|
|
if new_is_better and new_desc != old_desc:
|
|
patch["description_goods"] = new_desc
|
|
needs_update = True
|
|
if first_seen and not existing.get("first_seen_date"):
|
|
patch["first_seen_date"] = first_seen
|
|
needs_update = True
|
|
if last_seen and not existing.get("last_seen_date"):
|
|
patch["last_seen_date"] = last_seen
|
|
needs_update = True
|
|
if needs_update and not dry_run:
|
|
r = pb_req("PATCH", f"/api/collections/JEI_stock_codes/records/{existing['id']}", patch)
|
|
if isinstance(r, dict) and r.get("id"):
|
|
return r["id"], "updated"
|
|
return existing["id"], "exists"
|
|
|
|
body = {
|
|
"code": code,
|
|
"description_goods": description,
|
|
"first_seen_date": first_seen or None,
|
|
"last_seen_date": last_seen or None,
|
|
}
|
|
if dry_run:
|
|
return "DRY-" + code, "would-create"
|
|
r = pb_req("POST", "/api/collections/JEI_stock_codes/records", body)
|
|
if isinstance(r, dict) and r.get("id"):
|
|
return r["id"], "created"
|
|
print(f" STOCK CODE FAIL: {r}")
|
|
return None, "fail"
|
|
|
|
|
|
def create_invoice(rec: dict, dry_run: bool):
|
|
existing = find_invoice(rec["invoice_no"], rec["document_type"])
|
|
if existing:
|
|
return existing["id"], "exists"
|
|
|
|
body = {
|
|
"invoice_no": rec["invoice_no"],
|
|
"invoice_date": rec["invoice_date"],
|
|
"document_type": rec["document_type"],
|
|
"customer_order_no": rec.get("customer_order_no", ""),
|
|
"buyer_name_variant": rec.get("buyer_name_variant") or "JEI South Africa",
|
|
"supplier_name": rec.get("supplier_name", "JEI DRILLING & CUTTING SOLUTIONS LTD"),
|
|
"account_ref": rec.get("account_ref", ""),
|
|
"total_net": rec["total_net"],
|
|
"carriage_net": rec.get("carriage_net") or 0,
|
|
"invoice_total": rec["invoice_total"],
|
|
"deliver_to_name": rec.get("deliver_to_name", ""),
|
|
"deliver_to_address": rec.get("deliver_to_address", ""),
|
|
"source_filename": rec.get("source_filename", ""),
|
|
}
|
|
|
|
# If this is the first page of its source file, attach the PDF
|
|
if rec.get("is_source_first_page"):
|
|
pdf_path = INVOICES_DIR / rec["source_filename"]
|
|
if pdf_path.exists():
|
|
pdf_bytes = pdf_path.read_bytes()
|
|
r = pb_req("POST", "/api/collections/JEI_invoices/records",
|
|
multipart={**body, "pdf_file": (pdf_path.name, pdf_bytes, "application/pdf")})
|
|
else:
|
|
r = pb_req("POST", "/api/collections/JEI_invoices/records", body)
|
|
else:
|
|
r = pb_req("POST", "/api/collections/JEI_invoices/records", body)
|
|
|
|
if isinstance(r, dict) and r.get("id"):
|
|
return r["id"], "created"
|
|
print(f" INVOICE FAIL: {r}")
|
|
return None, "fail"
|
|
|
|
|
|
def find_line(invoice_id: str, line_no: int, stock_code_id: str):
|
|
q = f'invoice="{invoice_id}" && line_no="{line_no}" && stock_code="{stock_code_id}"'
|
|
r = pb_req("GET", f"/api/collections/JEI_invoice_lines/records?filter={urllib.parse.quote(q)}&perPage=1")
|
|
if isinstance(r, dict):
|
|
items = r.get("items", [])
|
|
return items[0] if items else None
|
|
return None
|
|
|
|
|
|
def create_line(line: dict, invoice_id: str, stock_code_id: str, line_no: int, dry_run: bool):
|
|
# Idempotency: skip if a line already exists for this (invoice, line_no, stock_code)
|
|
existing = find_line(invoice_id, line_no, stock_code_id)
|
|
if existing:
|
|
return existing["id"], "exists"
|
|
|
|
body = {
|
|
"invoice": invoice_id,
|
|
"line_no": line_no,
|
|
"stock_code": stock_code_id,
|
|
"quantity": line["quantity"],
|
|
"unit_price": line["unit_price"],
|
|
"net_amount": line["net_amount"],
|
|
"commodity_code": line.get("commodity_code", ""),
|
|
"country_of_origin": line.get("country_of_origin", ""),
|
|
}
|
|
if dry_run:
|
|
return "DRY-LINE", "would-create"
|
|
r = pb_req("POST", "/api/collections/JEI_invoice_lines/records", body)
|
|
if isinstance(r, dict) and r.get("id"):
|
|
return r["id"], "created"
|
|
return None, "fail"
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--dry-run", action="store_true")
|
|
args = ap.parse_args()
|
|
|
|
if not ALL_JSON.exists():
|
|
sys.exit(f"Run extract_invoices.py first. Missing: {ALL_JSON}")
|
|
|
|
with open(ALL_JSON) as f:
|
|
data = json.load(f)
|
|
if isinstance(data, dict) and "records" in data:
|
|
records = data["records"]
|
|
sc_meta_map = data.get("stock_code_dates", {})
|
|
else:
|
|
records = data
|
|
sc_meta_map = {}
|
|
|
|
print(f"Importing {len(records)} records (dry_run={args.dry_run})")
|
|
|
|
counts = {"invoices_created": 0, "invoices_exists": 0, "invoices_fail": 0,
|
|
"stock_codes_created": 0, "stock_codes_updated": 0, "stock_codes_exists": 0,
|
|
"lines_created": 0, "lines_fail": 0}
|
|
|
|
for rec in records:
|
|
if rec.get("total_net") is None or rec.get("invoice_total") is None:
|
|
print(f"SKIP {rec['invoice_no']}: missing total")
|
|
continue
|
|
if not rec.get("lines"):
|
|
print(f"SKIP {rec['invoice_no']}: no lines parsed")
|
|
continue
|
|
|
|
inv_id, inv_action = create_invoice(rec, args.dry_run)
|
|
if inv_action == "created":
|
|
counts["invoices_created"] += 1
|
|
elif inv_action == "exists":
|
|
counts["invoices_exists"] += 1
|
|
else:
|
|
counts["invoices_fail"] += 1
|
|
continue
|
|
|
|
for idx, line in enumerate(rec["lines"], start=1):
|
|
sc_meta = sc_meta_map.get(line["stock_code"])
|
|
sc_id, sc_action = upsert_stock_code(line, args.dry_run, sc_meta)
|
|
if sc_action == "created":
|
|
counts["stock_codes_created"] += 1
|
|
elif sc_action == "updated":
|
|
counts["stock_codes_updated"] += 1
|
|
elif sc_action == "exists":
|
|
counts["stock_codes_exists"] += 1
|
|
if sc_id is None:
|
|
counts["lines_fail"] += 1
|
|
continue
|
|
_, line_action = create_line(line, inv_id, sc_id, idx, args.dry_run)
|
|
if line_action == "created":
|
|
counts["lines_created"] += 1
|
|
else:
|
|
counts["lines_fail"] += 1
|
|
|
|
status = "OK" if inv_action == "created" else "EXISTS"
|
|
print(f"{status} {rec['invoice_no']} {rec['document_type']} - {len(rec['lines'])} lines")
|
|
|
|
print(f"\nDONE: {counts}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|