Reads our Candy PDF (deterministically, no OCR or model needed) and populates the consultant's own workbook using their formulas, column layout and PAYMENT column. Nothing is ever silently overwritten: every disagreement is reported, can be settled inside the app, and regenerated. - extract Candy text PDF -> tree (x-position column detection) - template layout detect, sequence align, write, totals, overrides - validate qty x rate and children-vs-subtotal reconciliation - profiles per-consultant layout profiles, stored on a volume Shipped with a Dockerfile + docker-compose for Coolify.
76 lines
2.5 KiB
Python
76 lines
2.5 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
BOQ PDF -> Excel + CSV
|
|
|
|
python3 run.py tender.pdf # writes out/BOQ_<name>.xlsx + .csv
|
|
python3 run.py tender.pdf --report # also prints the validation report
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
from boq_engine.extract import extract
|
|
from boq_engine.populate import flatten, populate_template, to_csv
|
|
from boq_engine.validate import validate
|
|
|
|
|
|
def safe(name: str) -> str:
|
|
return re.sub(r"[^A-Za-z0-9]+", "_", name).strip("_")[:60]
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("pdf")
|
|
ap.add_argument("-o", "--outdir", default="out")
|
|
ap.add_argument("--contract", default="")
|
|
ap.add_argument("--title", default="BILL OF QUANTITIES")
|
|
ap.add_argument("--vat", type=float, default=15.0)
|
|
ap.add_argument("--report", action="store_true")
|
|
ap.add_argument("--json", dest="json_out")
|
|
args = ap.parse_args()
|
|
|
|
base = safe(os.path.splitext(os.path.basename(args.pdf))[0])
|
|
os.makedirs(args.outdir, exist_ok=True)
|
|
xlsx = os.path.join(args.outdir, f"{base}.xlsx")
|
|
csvp = os.path.join(args.outdir, f"{base}.csv")
|
|
jsonp = args.json_out or os.path.join(args.outdir, f"{base}.json")
|
|
|
|
print(f"reading {args.pdf}")
|
|
tree = extract(args.pdf)
|
|
rep = validate(tree)
|
|
|
|
stats = populate_template(tree, xlsx, title=args.title,
|
|
contract=args.contract, vat_pct=args.vat)
|
|
nrows = to_csv(tree, csvp)
|
|
with open(jsonp, "w", encoding="utf-8") as fh:
|
|
json.dump({"tree": tree, "validation": rep.as_dict()}, fh,
|
|
indent=1, default=str)
|
|
|
|
print(f"\n priced lines {stats['lines']}")
|
|
print(f" groups {stats['groups']}")
|
|
print(f" 'Rate only' {stats['rate_only']}")
|
|
print(f" odd units {stats['unknown_units']} (highlighted in the workbook)")
|
|
print(f" csv rows {nrows}")
|
|
print(f" validation {len(rep.errors)} error(s), {len(rep.warnings)} warning(s)")
|
|
|
|
if rep.errors or args.report:
|
|
print("\nVALIDATION")
|
|
for i in rep.issues:
|
|
if i.severity == "info" and not args.report:
|
|
continue
|
|
mark = {"error": "ERROR", "warn": "WARN ", "info": "INFO "}[i.severity]
|
|
print(f" {mark} {i.code}")
|
|
print(f" {i.where}")
|
|
print(f" {i.detail}")
|
|
|
|
print(f"\nwrote {xlsx}\nwrote {csvp}\nwrote {jsonp}")
|
|
return 1 if rep.errors else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|