2026-10-01 00:32:13 +00:00
|
|
|
|
"""
|
|
|
|
|
|
Populate a CONSULTANT's BOQ workbook from our own Candy PDF export.
|
|
|
|
|
|
|
|
|
|
|
|
The direction matters and is easy to get backwards: Candy is *our* system, so
|
|
|
|
|
|
the PDF is the source of truth and is always the same shape. The consultant's
|
|
|
|
|
|
Excel is *theirs*, and changes from consultant to consultant. So the hard part
|
|
|
|
|
|
is not reading the document - it is writing into a workbook we did not design
|
|
|
|
|
|
without destroying what is already in it.
|
|
|
|
|
|
|
|
|
|
|
|
Three jobs:
|
|
|
|
|
|
|
|
|
|
|
|
1. LAYOUT DETECTION
|
|
|
|
|
|
Find the header row and work out which column is ITEM / DESCRIPTION /
|
|
|
|
|
|
UNIT / QTY / RATE / AMOUNT. A consultant's PAYMENT column (SABS / PSA
|
|
|
|
|
|
clause references) is detected too and then never touched.
|
|
|
|
|
|
|
|
|
|
|
|
2. MAPPING
|
|
|
|
|
|
Line the PDF's lines up with the workbook's rows. The two documents are
|
|
|
|
|
|
near 1:1 but not identical: the workbook truncates descriptions, inserts
|
|
|
|
|
|
spacer rows and adds "TOTAL CARRIED FORWARD" blocks, while the PDF wraps
|
|
|
|
|
|
descriptions across several lines. A sequence alignment over
|
|
|
|
|
|
(item number, description similarity) survives all of that.
|
|
|
|
|
|
|
|
|
|
|
|
3. PROOF
|
|
|
|
|
|
A population pass that cannot be checked is a guess. Every source line is
|
|
|
|
|
|
accounted for - written, already-matching, or reported as an exception -
|
|
|
|
|
|
and section totals are written as live formulas that must reproduce the
|
|
|
|
|
|
PDF's own printed subtotal.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import re
|
|
|
|
|
|
import shutil
|
|
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
from difflib import SequenceMatcher
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
from openpyxl import load_workbook
|
|
|
|
|
|
from openpyxl.utils import get_column_letter
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
# header synonyms
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
HEADER_PATTERNS: dict[str, tuple[str, ...]] = {
|
|
|
|
|
|
"item": ("item", "item no", "item no.", "item number", "no", "no."),
|
|
|
|
|
|
"description": ("description", "bill description", "desc", "particulars",
|
|
|
|
|
|
"description of work"),
|
|
|
|
|
|
"unit": ("unit", "units", "uom"),
|
|
|
|
|
|
"qty": ("qty", "quantity", "bill quantity", "quantities"),
|
|
|
|
|
|
"rate": ("rate", "unit rate", "unit price", "price"),
|
|
|
|
|
|
"amount": ("amount", "total", "value", "sum"),
|
|
|
|
|
|
"payment": ("payment", "payment clause", "clause", "spec", "specification"),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
TOTAL_RE = re.compile(r"^\s*total\b", re.I)
|
|
|
|
|
|
BROUGHT_RE = re.compile(r"brought\s+forward", re.I)
|
|
|
|
|
|
SUMMARY_RE = re.compile(r"to\s+(combined\s+)?summary", re.I)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _norm(s: Any) -> str:
|
|
|
|
|
|
return re.sub(r"\s+", " ", str(s or "")).strip().lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _cellsafe(v: Any) -> str:
|
|
|
|
|
|
"""Normalise a cell value to something comparable across both documents."""
|
|
|
|
|
|
s = _norm(v)
|
|
|
|
|
|
s = s.replace("ℓ", "l").replace("³", "3").replace("²", "2")
|
|
|
|
|
|
s = re.sub(r"[^a-z0-9./-]+", " ", s)
|
|
|
|
|
|
return re.sub(r"\s+", " ", s).strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
# layout
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class Layout:
|
|
|
|
|
|
sheet: str
|
|
|
|
|
|
header_row: int
|
|
|
|
|
|
cols: dict[str, int] = field(default_factory=dict)
|
|
|
|
|
|
data_rows: list[int] = field(default_factory=list)
|
|
|
|
|
|
total_rows: list[int] = field(default_factory=list)
|
|
|
|
|
|
notes: list[str] = field(default_factory=list)
|
2026-10-02 11:35:29 +00:00
|
|
|
|
# The description is not always one column. It occupies whatever sits
|
|
|
|
|
|
# between ITEM and UNIT, and on the SANS 1200 template it moves *within*
|
|
|
|
|
|
# a single sheet - the label "a)" lands in one column and the text in the
|
|
|
|
|
|
# next. So the description is a RUN, and a row's text is the join of them.
|
|
|
|
|
|
desc_cols: list[int] = field(default_factory=list)
|
2026-10-01 00:32:13 +00:00
|
|
|
|
|
|
|
|
|
|
def col(self, name: str) -> int | None:
|
|
|
|
|
|
return self.cols.get(name)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-02 11:35:29 +00:00
|
|
|
|
def _score_header_row(ws, r: int, ncols: int, span: int = 1) -> dict[str, int]:
|
|
|
|
|
|
"""Map columns to names using a header that may wrap over `span` rows.
|
|
|
|
|
|
|
|
|
|
|
|
Templates split a label across two lines to fit a narrow column -
|
|
|
|
|
|
"QUAN-" over "TITY". Reading either row alone finds no QUANTITY at all, so
|
|
|
|
|
|
the quantity column is simply missed and never written. Joining the rows
|
|
|
|
|
|
recovers the word.
|
|
|
|
|
|
"""
|
|
|
|
|
|
cols: dict[str, int] = {}
|
|
|
|
|
|
for c in range(1, ncols + 1):
|
|
|
|
|
|
parts = [str(ws.cell(r + k, c).value or "").strip()
|
|
|
|
|
|
for k in range(span)]
|
|
|
|
|
|
# A hyphen here is a column-wrap artefact ("QUAN-" / "TITY"), never part
|
|
|
|
|
|
# of the word. Left in, the joined label reads "quan-tity" and matches
|
|
|
|
|
|
# nothing - which is how the quantity column went missing on 49 sheets.
|
|
|
|
|
|
label = _cellsafe("".join(parts).replace("-", ""))
|
|
|
|
|
|
if not label:
|
|
|
|
|
|
continue
|
|
|
|
|
|
for name, pats in HEADER_PATTERNS.items():
|
|
|
|
|
|
if name in cols:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if label in pats or any(p in label for p in pats):
|
|
|
|
|
|
cols[name] = c
|
|
|
|
|
|
break
|
|
|
|
|
|
return cols
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-01 00:32:13 +00:00
|
|
|
|
def detect_layout(ws) -> Layout:
|
2026-10-02 11:35:29 +00:00
|
|
|
|
"""Find the header row and map the columns.
|
|
|
|
|
|
|
|
|
|
|
|
Each candidate row is scored twice - read alone, and joined with the row
|
|
|
|
|
|
below it - because a wrapped label only reads correctly when the two halves
|
|
|
|
|
|
are put back together.
|
|
|
|
|
|
"""
|
|
|
|
|
|
ncols = min(ws.max_column, 30)
|
|
|
|
|
|
cands: list[tuple[int, int, int, dict[str, int]]] = []
|
2026-10-01 00:32:13 +00:00
|
|
|
|
for r in range(1, min(ws.max_row, 25) + 1):
|
2026-10-02 11:35:29 +00:00
|
|
|
|
for span in (1, 2):
|
|
|
|
|
|
if r + span - 1 > ws.max_row:
|
2026-10-01 00:32:13 +00:00
|
|
|
|
continue
|
2026-10-02 11:35:29 +00:00
|
|
|
|
cols = _score_header_row(ws, r, ncols, span)
|
|
|
|
|
|
if len(cols) >= 4:
|
|
|
|
|
|
cands.append((len(cols), r, span, cols))
|
|
|
|
|
|
# most roles found wins; a tie goes to the LOWER row, because the genuine
|
|
|
|
|
|
# header block is the last one before the data starts. Getting this wrong
|
|
|
|
|
|
# cost the quantity column on 49 of 51 sheets.
|
|
|
|
|
|
best = max(cands, key=lambda t: (t[0], t[1], t[2])) if cands else None
|
2026-10-01 00:32:13 +00:00
|
|
|
|
|
|
|
|
|
|
if best is None:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"Could not find a header row in sheet '{ws.title}' - "
|
|
|
|
|
|
f"expected 4+ of {', '.join(HEADER_PATTERNS)} in the first 25 rows"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-10-02 11:35:29 +00:00
|
|
|
|
_, header_row, span, cols = best
|
2026-10-01 00:32:13 +00:00
|
|
|
|
lay = Layout(sheet=ws.title, header_row=header_row, cols=cols)
|
2026-10-02 11:35:29 +00:00
|
|
|
|
if span > 1:
|
|
|
|
|
|
lay.notes.append(f"header wraps over rows {header_row}"
|
|
|
|
|
|
f"-{header_row + span - 1}")
|
2026-10-01 00:32:13 +00:00
|
|
|
|
|
|
|
|
|
|
c_item = cols.get("item")
|
|
|
|
|
|
c_desc = cols.get("description")
|
|
|
|
|
|
c_qty = cols.get("qty")
|
|
|
|
|
|
|
2026-10-02 11:35:29 +00:00
|
|
|
|
for r in range(header_row + span, ws.max_row + 1):
|
2026-10-01 00:32:13 +00:00
|
|
|
|
a = ws.cell(r, c_item).value if c_item else None
|
|
|
|
|
|
d = ws.cell(r, c_desc).value if c_desc else None
|
|
|
|
|
|
q = ws.cell(r, c_qty).value if c_qty else None
|
|
|
|
|
|
label = f"{a or ''} {d or ''}".strip()
|
|
|
|
|
|
|
|
|
|
|
|
if label and TOTAL_RE.match(label):
|
|
|
|
|
|
lay.total_rows.append(r)
|
|
|
|
|
|
continue
|
|
|
|
|
|
# a real data row: something in item, description or qty
|
|
|
|
|
|
if any(x not in (None, "") for x in (a, d)) or q is not None:
|
|
|
|
|
|
lay.data_rows.append(r)
|
|
|
|
|
|
|
|
|
|
|
|
for name in ("item", "description", "unit", "qty", "rate", "amount"):
|
|
|
|
|
|
if name not in cols:
|
|
|
|
|
|
lay.notes.append(f"template has no '{name}' column - that value will not be written")
|
|
|
|
|
|
if "payment" in cols:
|
|
|
|
|
|
lay.notes.append("PAYMENT column detected and will be left untouched")
|
2026-10-02 11:35:29 +00:00
|
|
|
|
|
|
|
|
|
|
_verify_against_data(ws, lay)
|
|
|
|
|
|
_derive_desc_run(lay)
|
2026-10-01 00:32:13 +00:00
|
|
|
|
return lay
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-02 11:35:29 +00:00
|
|
|
|
def _derive_desc_run(lay: Layout) -> None:
|
|
|
|
|
|
"""Everything between the item column and the unit column is description."""
|
|
|
|
|
|
lo = lay.cols.get("item")
|
|
|
|
|
|
hi = lay.cols.get("unit")
|
|
|
|
|
|
if not lo or not hi or hi <= lo:
|
|
|
|
|
|
lay.desc_cols = [lay.cols["description"]] if "description" in lay.cols else []
|
|
|
|
|
|
return
|
|
|
|
|
|
run = [c for c in range(lo + 1, hi) if c not in lay.cols.values()]
|
|
|
|
|
|
# plus the claimed description column even if it sits outside the run
|
|
|
|
|
|
d = lay.cols.get("description")
|
|
|
|
|
|
if d and d not in run and d not in (lo, hi):
|
|
|
|
|
|
run.append(d)
|
|
|
|
|
|
lay.desc_cols = sorted(set(run))
|
|
|
|
|
|
if len(lay.desc_cols) > 1:
|
|
|
|
|
|
lay.notes.append(
|
|
|
|
|
|
f"description spans columns "
|
|
|
|
|
|
f"{[get_column_letter(c) for c in lay.desc_cols]}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _verify_against_data(ws, lay: Layout) -> None:
|
|
|
|
|
|
"""Correct the header-derived column map using what the data actually holds.
|
|
|
|
|
|
|
|
|
|
|
|
A header row is a claim; the cells below it are the fact. On the SANS 1200
|
|
|
|
|
|
template the header advertises DESCRIPTION in column E while the text sits
|
|
|
|
|
|
in D, and a wrong description column quietly wrecks every line match.
|
|
|
|
|
|
"""
|
|
|
|
|
|
sample = lay.data_rows[3:60]
|
|
|
|
|
|
if len(sample) < 5:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
def longest_text(col: int) -> tuple[int, int]:
|
|
|
|
|
|
"""(count of substantial strings, longest) in a column over the sample."""
|
|
|
|
|
|
n = longest = 0
|
|
|
|
|
|
for r in sample:
|
|
|
|
|
|
v = ws.cell(r, col).value
|
|
|
|
|
|
if isinstance(v, str) and len(v.strip()) > 6:
|
|
|
|
|
|
n += 1
|
|
|
|
|
|
longest = max(longest, len(v.strip()))
|
|
|
|
|
|
return n, longest
|
|
|
|
|
|
|
|
|
|
|
|
# --- description: the header may be off by one --------------------------
|
|
|
|
|
|
d = lay.cols.get("description")
|
|
|
|
|
|
if d:
|
|
|
|
|
|
have, _ = longest_text(d)
|
|
|
|
|
|
best_col, best_score = d, 0.0
|
|
|
|
|
|
for cand in (d - 1, d + 1):
|
|
|
|
|
|
if cand < 1 or cand in lay.cols.values():
|
|
|
|
|
|
continue
|
|
|
|
|
|
cnt, longest = longest_text(cand)
|
|
|
|
|
|
# a real description column: nearly every sampled row carries long
|
|
|
|
|
|
# text. Score it, and only switch if it clearly beats the claim.
|
|
|
|
|
|
score = (cnt / max(1, len(sample))) * (1 if longest > 25 else 0.3)
|
|
|
|
|
|
if score > best_score:
|
|
|
|
|
|
best_col, best_score = cand, score
|
|
|
|
|
|
if best_col != d and best_score > 0.6 and have < len(sample) * 0.2:
|
|
|
|
|
|
lay.notes.append(
|
|
|
|
|
|
f"header claims DESCRIPTION in col {d} but the text is in "
|
|
|
|
|
|
f"col {best_col} - using {get_column_letter(best_col)}")
|
|
|
|
|
|
lay.cols["description"] = best_col
|
|
|
|
|
|
|
|
|
|
|
|
# --- unit: short, mostly-textual values sitting beside the quantity -----
|
|
|
|
|
|
u = lay.cols.get("unit")
|
|
|
|
|
|
if u:
|
|
|
|
|
|
def unitish(col: int) -> int:
|
|
|
|
|
|
n = 0
|
|
|
|
|
|
for r in sample:
|
|
|
|
|
|
v = ws.cell(r, col).value
|
|
|
|
|
|
if isinstance(v, str) and 1 < len(v.strip()) < 12:
|
|
|
|
|
|
n += 1
|
|
|
|
|
|
return n
|
|
|
|
|
|
|
|
|
|
|
|
have = unitish(u)
|
|
|
|
|
|
if have < len(sample) * 0.3:
|
|
|
|
|
|
for cand in (u - 1, u + 1):
|
|
|
|
|
|
if cand < 1 or cand in lay.cols.values():
|
|
|
|
|
|
continue
|
|
|
|
|
|
if unitish(cand) >= len(sample) * 0.5:
|
|
|
|
|
|
lay.notes.append(
|
|
|
|
|
|
f"unit column shifted {u} -> {cand} to match the data")
|
|
|
|
|
|
lay.cols["unit"] = cand
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-01 00:32:13 +00:00
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
# matching
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class Slot:
|
|
|
|
|
|
row: int
|
|
|
|
|
|
number: str = ""
|
|
|
|
|
|
desc: str = ""
|
|
|
|
|
|
indent: int = 0
|
|
|
|
|
|
unit: Any = None
|
|
|
|
|
|
qty: Any = None
|
|
|
|
|
|
rate: Any = None
|
|
|
|
|
|
amount: Any = None
|
|
|
|
|
|
_sheet: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def key(self) -> str:
|
|
|
|
|
|
return _cellsafe(f"{self.number} {self.desc}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def read_slots(ws, lay: Layout) -> list[Slot]:
|
2026-10-02 11:35:29 +00:00
|
|
|
|
ci = lay.col("item")
|
|
|
|
|
|
run = lay.desc_cols or ([lay.col("description")] if lay.col("description") else [])
|
2026-10-01 00:32:13 +00:00
|
|
|
|
out: list[Slot] = []
|
2026-10-02 11:35:29 +00:00
|
|
|
|
# A spreadsheet implies its hierarchy: once "110.01" is printed, the blank
|
|
|
|
|
|
# cells below it still belong to 110.01. Without carrying that down, two
|
|
|
|
|
|
# thirds of the rows read as unnumbered and nothing can match them.
|
|
|
|
|
|
carried = ""
|
2026-10-01 00:32:13 +00:00
|
|
|
|
for r in lay.data_rows:
|
|
|
|
|
|
num = str(ws.cell(r, ci).value or "").strip() if ci else ""
|
2026-10-02 11:35:29 +00:00
|
|
|
|
if num:
|
|
|
|
|
|
carried = num
|
|
|
|
|
|
else:
|
|
|
|
|
|
num = carried
|
|
|
|
|
|
parts = [str(ws.cell(r, c).value or "").strip() for c in run]
|
|
|
|
|
|
desc = " ".join(p for p in parts if p).strip()
|
2026-10-01 00:32:13 +00:00
|
|
|
|
if not num and not desc:
|
|
|
|
|
|
continue
|
|
|
|
|
|
# indent level from the leading whitespace the consultant preserved
|
2026-10-02 11:35:29 +00:00
|
|
|
|
raw = next((str(ws.cell(r, c).value or "") for c in run
|
|
|
|
|
|
if str(ws.cell(r, c).value or "") !=
|
|
|
|
|
|
str(ws.cell(r, c).value or "").lstrip()), "")
|
|
|
|
|
|
indent = len(raw) - len(raw.lstrip()) if raw else 0
|
2026-10-01 00:32:13 +00:00
|
|
|
|
out.append(Slot(row=r, number=num, desc=desc, indent=indent))
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _sim(a: Slot, num: str, desc: str) -> float:
|
|
|
|
|
|
"""How well does a source line match a template slot?
|
|
|
|
|
|
|
|
|
|
|
|
Item number is the strong signal, but most of a BOQ is unnumbered a)/b)/c)
|
|
|
|
|
|
sub-lines where the number is blank on BOTH sides. Scoring those as "no
|
|
|
|
|
|
evidence" pushed every sub-line under the threshold, so description has to
|
|
|
|
|
|
carry full weight when neither side has a number.
|
|
|
|
|
|
"""
|
|
|
|
|
|
tnum, snum = _cellsafe(a.number), _cellsafe(num)
|
|
|
|
|
|
tdesc = f"{tnum} {_cellsafe(a.desc)}".strip()
|
|
|
|
|
|
sdesc = f"{snum} {_cellsafe(desc)}".strip()
|
|
|
|
|
|
|
|
|
|
|
|
if tnum and snum:
|
|
|
|
|
|
if tnum == snum:
|
|
|
|
|
|
num_score = 1.0
|
|
|
|
|
|
elif tnum in snum or snum in tnum:
|
|
|
|
|
|
num_score = 0.75
|
|
|
|
|
|
else:
|
2026-10-02 11:35:29 +00:00
|
|
|
|
num_score = 0.0 # two different numbers: real evidence against
|
2026-10-01 00:32:13 +00:00
|
|
|
|
else:
|
2026-10-02 11:35:29 +00:00
|
|
|
|
# A missing number is NEUTRAL, not a mismatch. Most of a BOQ is
|
|
|
|
|
|
# unnumbered a)/b) sub-lines, and a template carries a number down into
|
|
|
|
|
|
# them - scoring that as disagreement made every sub-line unmatchable.
|
|
|
|
|
|
num_score = 0.5
|
2026-10-01 00:32:13 +00:00
|
|
|
|
|
|
|
|
|
|
d = SequenceMatcher(None, tdesc, sdesc).ratio()
|
|
|
|
|
|
return round(num_score * 0.6 + d * 0.4, 4)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def align(slots: list[Slot], lines: list[dict], threshold: float = 0.45
|
|
|
|
|
|
) -> list[tuple[dict, Slot | None, float]]:
|
|
|
|
|
|
"""Sequence-align source lines onto template slots.
|
|
|
|
|
|
|
|
|
|
|
|
A Needleman-Wunsch style DP rather than a greedy pass: a single
|
|
|
|
|
|
mis-detected row would otherwise shift every line after it, which is
|
|
|
|
|
|
exactly the silent-corruption failure this whole tool exists to prevent.
|
|
|
|
|
|
"""
|
|
|
|
|
|
n, m = len(lines), len(slots)
|
|
|
|
|
|
GAP = -0.35
|
|
|
|
|
|
|
|
|
|
|
|
def sc(i: int, j: int) -> float:
|
|
|
|
|
|
ln = lines[i]
|
|
|
|
|
|
return _sim(slots[j], ln.get("number", ""), ln.get("desc", ""))
|
|
|
|
|
|
|
|
|
|
|
|
dp = [[0.0] * (m + 1) for _ in range(n + 1)]
|
|
|
|
|
|
bt = [[0] * (m + 1) for _ in range(n + 1)]
|
|
|
|
|
|
for i in range(1, n + 1):
|
|
|
|
|
|
dp[i][0] = dp[i - 1][0] + GAP
|
|
|
|
|
|
bt[i][0] = 1
|
|
|
|
|
|
for j in range(1, m + 1):
|
|
|
|
|
|
dp[0][j] = dp[0][j - 1] + GAP
|
|
|
|
|
|
bt[0][j] = 2
|
|
|
|
|
|
for i in range(1, n + 1):
|
|
|
|
|
|
for j in range(1, m + 1):
|
|
|
|
|
|
diag = dp[i - 1][j - 1] + sc(i - 1, j - 1)
|
|
|
|
|
|
up = dp[i - 1][j] + GAP
|
|
|
|
|
|
left = dp[i][j - 1] + GAP
|
|
|
|
|
|
best = max(diag, up, left)
|
|
|
|
|
|
dp[i][j] = best
|
|
|
|
|
|
bt[i][j] = 0 if best == diag else (1 if best == up else 2)
|
|
|
|
|
|
|
|
|
|
|
|
out: list[tuple[dict, Slot | None, float]] = []
|
|
|
|
|
|
i, j = n, m
|
|
|
|
|
|
while i > 0 or j > 0:
|
|
|
|
|
|
mv = bt[i][j]
|
|
|
|
|
|
if mv == 0 and i > 0 and j > 0:
|
|
|
|
|
|
s = sc(i - 1, j - 1)
|
|
|
|
|
|
out.append((lines[i - 1], slots[j - 1] if s >= threshold else None, s))
|
|
|
|
|
|
i, j = i - 1, j - 1
|
|
|
|
|
|
elif mv == 1 and i > 0:
|
|
|
|
|
|
out.append((lines[i - 1], None, 0.0))
|
|
|
|
|
|
i -= 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
j -= 1
|
|
|
|
|
|
out.reverse()
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
# the population pass
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class Finding:
|
|
|
|
|
|
kind: str # 'written' | 'kept' | 'conflict' | 'unmapped' | 'total' | 'info'
|
|
|
|
|
|
sheet: str
|
|
|
|
|
|
row: int
|
|
|
|
|
|
label: str
|
|
|
|
|
|
detail: str
|
|
|
|
|
|
source: str = ""
|
|
|
|
|
|
# How much a human needs to care. This is data, not presentation - the UI
|
|
|
|
|
|
# maps it to a colour, so the classification lives here where it can be
|
|
|
|
|
|
# asserted in tests.
|
|
|
|
|
|
# 'decision' the two documents genuinely disagree; a person must pick
|
|
|
|
|
|
# 'source_gap' OUR pdf has no figure here, theirs is better
|
|
|
|
|
|
# 'source_defect' OUR pdf printed something corrupt
|
|
|
|
|
|
# 'note' worth knowing, no action implied
|
|
|
|
|
|
severity: str = "note"
|
|
|
|
|
|
# For a conflict the UI needs enough to offer a real choice: which cell,
|
|
|
|
|
|
# what is in it now, and what the source wants to put there.
|
|
|
|
|
|
field: str = "" # 'qty' | 'rate' | 'unit'
|
|
|
|
|
|
template_value: Any = None # what the consultant's file holds today
|
|
|
|
|
|
source_value: Any = None # what our Candy PDF holds
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-02 11:35:29 +00:00
|
|
|
|
class _FormulaProtected(Exception):
|
|
|
|
|
|
"""Raised internally when a cell must not be overwritten."""
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-01 00:32:13 +00:00
|
|
|
|
@dataclass
|
|
|
|
|
|
class Result:
|
|
|
|
|
|
written: int = 0
|
|
|
|
|
|
kept: int = 0
|
|
|
|
|
|
findings: list[Finding] = field(default_factory=list)
|
|
|
|
|
|
sheets: list[str] = field(default_factory=list)
|
|
|
|
|
|
layout_notes: list[str] = field(default_factory=list)
|
|
|
|
|
|
|
|
|
|
|
|
def add(self, *a, **kw) -> None:
|
|
|
|
|
|
self.findings.append(Finding(*a, **kw))
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def problems(self) -> list[Finding]:
|
|
|
|
|
|
return [f for f in self.findings
|
|
|
|
|
|
if f.kind in ("conflict", "unmapped", "info")]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
# source-side flattening: PDF tree -> the list we try to place
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def source_lines(tree: dict, *, with_prices: bool = True) -> list[dict]:
|
|
|
|
|
|
"""Every priced line in the Candy PDF, in document order."""
|
|
|
|
|
|
out: list[dict] = []
|
|
|
|
|
|
|
|
|
|
|
|
def walk(node: dict) -> None:
|
|
|
|
|
|
kind = node.get("kind")
|
|
|
|
|
|
if kind == "line":
|
|
|
|
|
|
if node.get("qty") is None and node.get("rate") is None:
|
|
|
|
|
|
return
|
2026-10-02 11:35:29 +00:00
|
|
|
|
# a row of dots is Candy's blank-for-the-tenderer placeholder, not
|
|
|
|
|
|
# a priced line
|
|
|
|
|
|
if re.fullmatch(r"[.\s]+", (node.get("desc") or "").strip()):
|
|
|
|
|
|
return
|
2026-10-01 00:32:13 +00:00
|
|
|
|
out.append({
|
|
|
|
|
|
"number": node.get("number", "") or "",
|
|
|
|
|
|
"desc": (node.get("desc") or "").strip(),
|
|
|
|
|
|
"unit": (node.get("unit") or "").strip(),
|
|
|
|
|
|
"qty": node.get("qty"),
|
|
|
|
|
|
"rate": node.get("rate"),
|
|
|
|
|
|
"amount": node.get("amount"),
|
|
|
|
|
|
"rate_only": bool(node.get("rate_only")),
|
|
|
|
|
|
"page": node.get("page", 0),
|
|
|
|
|
|
})
|
|
|
|
|
|
return
|
|
|
|
|
|
for c in node.get("children", []):
|
|
|
|
|
|
walk(c)
|
|
|
|
|
|
|
|
|
|
|
|
walk(tree)
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def section_totals(tree: dict) -> dict[str, float]:
|
|
|
|
|
|
"""Total of the priced lines beneath each section / schedule.
|
|
|
|
|
|
|
|
|
|
|
|
Deliberately a LEAF roll-up rather than the printed subtotals: the source
|
|
|
|
|
|
prints totals that swallow a neighbouring block (1.6 Temporary Works
|
|
|
|
|
|
includes 1.5 Protection of Services), so summing printed subtotals
|
|
|
|
|
|
double counts. Summing leaves reproduces the printed grand total exactly.
|
|
|
|
|
|
"""
|
|
|
|
|
|
totals: dict[str, float] = {}
|
|
|
|
|
|
|
|
|
|
|
|
def leaf_sum(node: dict) -> float:
|
|
|
|
|
|
if node.get("kind") == "line":
|
|
|
|
|
|
if node.get("rate_only") or node.get("amount") is None:
|
|
|
|
|
|
return 0.0
|
|
|
|
|
|
return float(node["amount"])
|
|
|
|
|
|
return sum(leaf_sum(c) for c in node.get("children", []))
|
|
|
|
|
|
|
|
|
|
|
|
def walk(node: dict) -> None:
|
|
|
|
|
|
if node.get("kind") in ("section", "schedule", "root"):
|
|
|
|
|
|
if node.get("kind") in ("section", "schedule"):
|
|
|
|
|
|
label = f"{node.get('number','')} {node.get('title','')}".strip()
|
|
|
|
|
|
totals[label] = round(leaf_sum(node), 2)
|
|
|
|
|
|
for c in node.get("children", []):
|
|
|
|
|
|
walk(c)
|
|
|
|
|
|
|
|
|
|
|
|
walk(tree)
|
|
|
|
|
|
return totals
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _norm_unit(u: str) -> str:
|
|
|
|
|
|
s = _norm(u).replace("ℓ", "l").replace("³", "3").replace("²", "2")
|
|
|
|
|
|
return s.rstrip(".")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
UNIT_ALIASES = {
|
|
|
|
|
|
"l": "l", "litre": "l", "litres": "l", "l": "l",
|
|
|
|
|
|
"m2": "m2", "m 2": "m2", "sqm": "m2",
|
|
|
|
|
|
"m3": "m3", "m 3": "m3", "cbm": "m3",
|
|
|
|
|
|
"no": "no", "no.": "no", "nr": "no", "number": "no",
|
|
|
|
|
|
"sum": "sum", "sum.": "sum", "ls": "sum",
|
|
|
|
|
|
"p.sum": "psum", "psum": "psum", "ps.sum": "psum", "pc.sum": "pcsum",
|
|
|
|
|
|
"month": "month", "months": "month", "mth": "month",
|
|
|
|
|
|
"t": "t", "tonne": "t", "tonnes": "t",
|
|
|
|
|
|
"m": "m", "m ": "m",
|
|
|
|
|
|
"%": "%", "pct": "%", "?": "?", "": "",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def units_compatible(a: str, b: str) -> bool:
|
|
|
|
|
|
na, nb = _norm_unit(a), _norm_unit(b)
|
|
|
|
|
|
if na == nb:
|
|
|
|
|
|
return True
|
|
|
|
|
|
ca, cb = UNIT_ALIASES.get(na), UNIT_ALIASES.get(nb)
|
|
|
|
|
|
return ca is not None and ca == cb
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-02 11:35:29 +00:00
|
|
|
|
def _section_of(number: str) -> str:
|
|
|
|
|
|
"""The leading segment of a reference: "110.01.01" -> "110".
|
|
|
|
|
|
|
|
|
|
|
|
SANS 1200 sheets are named for the section they cover, so this is what
|
|
|
|
|
|
locates a sheet. It is NOT a unique key on its own - the Candy PDF restarts
|
|
|
|
|
|
".01" under every repeated "110...." heading, so "110.01" names three
|
|
|
|
|
|
different lines in one document.
|
|
|
|
|
|
"""
|
|
|
|
|
|
n = _cellsafe(number)
|
|
|
|
|
|
return n.split(".")[0] if n else ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _relevant_pool(lines: list[dict],
|
|
|
|
|
|
slots_by_sheet: dict[str, list[Slot]]
|
|
|
|
|
|
) -> tuple[list[Slot], dict[str, int]]:
|
|
|
|
|
|
"""The slots worth aligning against, in workbook order.
|
|
|
|
|
|
|
|
|
|
|
|
Aligning against every sheet in the workbook looks fine on a 3-sheet
|
|
|
|
|
|
template and falls apart on a 53-sheet one: with 1314 candidate rows for
|
|
|
|
|
|
226 lines, the aligner starts cherry-picking scattered high scores instead
|
|
|
|
|
|
of finding the one coherent run. So the pool is cut down to the sheets that
|
|
|
|
|
|
actually own a section named in the source, which leaves 12 candidates
|
|
|
|
|
|
instead of 1314 and lets the sequence aligner work as designed.
|
|
|
|
|
|
"""
|
|
|
|
|
|
wanted = {_section_of(ln.get("number", "")) for ln in lines}
|
|
|
|
|
|
wanted.discard("")
|
|
|
|
|
|
|
|
|
|
|
|
pool: list[Slot] = []
|
|
|
|
|
|
counts: dict[str, int] = {}
|
|
|
|
|
|
for sheet, slots in slots_by_sheet.items():
|
|
|
|
|
|
secs = {_section_of(sl.number) for sl in slots}
|
|
|
|
|
|
secs.discard("")
|
|
|
|
|
|
if wanted and not (secs & wanted):
|
|
|
|
|
|
continue
|
|
|
|
|
|
pool.extend(slots)
|
|
|
|
|
|
counts[sheet] = len(slots)
|
|
|
|
|
|
return pool, counts
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-01 00:32:13 +00:00
|
|
|
|
def populate(consultant_xlsx: str, tree: dict, out_path: str, *,
|
|
|
|
|
|
sheet_hint: str | None = None,
|
|
|
|
|
|
overrides: dict | None = None) -> Result:
|
|
|
|
|
|
"""Write our Candy BOQ into a consultant's workbook, in place-safe fashion.
|
|
|
|
|
|
|
2026-10-02 11:35:29 +00:00
|
|
|
|
Lines are routed to a sheet by item number, then aligned WITHIN that sheet.
|
|
|
|
|
|
Nothing is ever written over a value the consultant already had, unless
|
|
|
|
|
|
they explicitly asked for it via `overrides`.
|
2026-10-01 00:32:13 +00:00
|
|
|
|
"""
|
|
|
|
|
|
overrides = overrides or {}
|
|
|
|
|
|
res = Result()
|
2026-10-02 11:35:29 +00:00
|
|
|
|
original = load_workbook(consultant_xlsx) # pristine copy for comparison
|
2026-10-01 00:32:13 +00:00
|
|
|
|
wb = load_workbook(consultant_xlsx) # formulas preserved
|
|
|
|
|
|
lines = source_lines(tree)
|
|
|
|
|
|
if not lines:
|
|
|
|
|
|
raise ValueError("no priced lines found in the source tree")
|
|
|
|
|
|
|
|
|
|
|
|
names = [sheet_hint] if sheet_hint else list(wb.sheetnames)
|
|
|
|
|
|
layouts: dict[str, Layout] = {}
|
2026-10-02 11:35:29 +00:00
|
|
|
|
slots_by_sheet: dict[str, list[Slot]] = {}
|
2026-10-01 00:32:13 +00:00
|
|
|
|
|
|
|
|
|
|
for name in names:
|
|
|
|
|
|
if name not in wb.sheetnames:
|
|
|
|
|
|
continue
|
|
|
|
|
|
ws = wb[name]
|
|
|
|
|
|
try:
|
|
|
|
|
|
lay = detect_layout(ws)
|
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
|
res.add("info", name, 0, "layout", str(exc))
|
|
|
|
|
|
continue
|
|
|
|
|
|
layouts[name] = lay
|
|
|
|
|
|
res.layout_notes.extend(f"[{name}] {n}" for n in lay.notes)
|
2026-10-02 11:35:29 +00:00
|
|
|
|
slots = read_slots(ws, lay)
|
|
|
|
|
|
for sl in slots:
|
|
|
|
|
|
sl._sheet = name
|
|
|
|
|
|
slots_by_sheet[name] = slots
|
2026-10-01 00:32:13 +00:00
|
|
|
|
|
2026-10-02 11:35:29 +00:00
|
|
|
|
if not slots_by_sheet:
|
2026-10-01 00:32:13 +00:00
|
|
|
|
raise ValueError("no BOQ-shaped sheet found in the workbook")
|
|
|
|
|
|
|
2026-10-02 11:35:29 +00:00
|
|
|
|
pool, counts = _relevant_pool(lines, slots_by_sheet)
|
|
|
|
|
|
res.layout_notes.append(
|
|
|
|
|
|
f"aligned against {len(pool)} candidate rows on "
|
|
|
|
|
|
f"{len(counts)} of {len(slots_by_sheet)} sheets")
|
|
|
|
|
|
if not pool:
|
|
|
|
|
|
raise ValueError("no template sheet shares a section with the source BOQ")
|
|
|
|
|
|
|
|
|
|
|
|
# one sequential alignment over the whole pool, exactly as the aligner is
|
|
|
|
|
|
# designed to work: the document order and the workbook order agree, and a
|
|
|
|
|
|
# single mis-scored row no longer shifts the rest
|
|
|
|
|
|
lay_of = {name: lay for name, lay in layouts.items()}
|
|
|
|
|
|
_fill_pool(wb, lay_of, pool, lines, overrides, res)
|
|
|
|
|
|
|
|
|
|
|
|
_write_totals(wb, layouts, tree, res)
|
|
|
|
|
|
_restore_formulas(original, wb, res)
|
|
|
|
|
|
wb.save(out_path)
|
|
|
|
|
|
return res
|
|
|
|
|
|
|
2026-10-01 00:32:13 +00:00
|
|
|
|
|
2026-10-02 11:35:29 +00:00
|
|
|
|
def _restore_formulas(original, out, res: Result) -> int:
|
|
|
|
|
|
"""Put back any formula the write pass disturbed. Returns how many.
|
|
|
|
|
|
|
|
|
|
|
|
A guard on each write is the right first line of defence, but there are a
|
|
|
|
|
|
dozen write paths and a new one will be added eventually. This is the
|
|
|
|
|
|
backstop that makes the promise checkable rather than merely intended: if
|
|
|
|
|
|
the consultant's workbook had a formula in a cell, it still has it in the
|
|
|
|
|
|
output. Everything else is rebuilt around that.
|
|
|
|
|
|
"""
|
|
|
|
|
|
restored = 0
|
|
|
|
|
|
for name in out.sheetnames:
|
|
|
|
|
|
if name not in original.sheetnames:
|
|
|
|
|
|
continue
|
|
|
|
|
|
src, dst = original[name], out[name]
|
|
|
|
|
|
for row in src.iter_rows():
|
|
|
|
|
|
for cell in row:
|
|
|
|
|
|
v = cell.value
|
|
|
|
|
|
if isinstance(v, str) and v.startswith("="):
|
|
|
|
|
|
if dst[cell.coordinate].value != v:
|
|
|
|
|
|
dst[cell.coordinate] = v
|
|
|
|
|
|
restored += 1
|
|
|
|
|
|
if restored:
|
|
|
|
|
|
res.add("info", "-", 0, "formulas restored",
|
|
|
|
|
|
f"{restored} cell(s) held a formula in your workbook and have "
|
|
|
|
|
|
f"been put back exactly as they were - your model is intact")
|
|
|
|
|
|
return restored
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fill_pool(wb, layouts: dict[str, Layout], pool: list[Slot],
|
|
|
|
|
|
lines: list[dict], overrides: dict, res: Result) -> None:
|
|
|
|
|
|
"""Align the whole source against the whole candidate pool, and write."""
|
|
|
|
|
|
pairs = align(pool, lines)
|
2026-10-01 00:32:13 +00:00
|
|
|
|
for ln, slot, score in pairs:
|
|
|
|
|
|
if slot is None:
|
|
|
|
|
|
res.add("unmapped", "-", 0,
|
|
|
|
|
|
f"{ln['number']} {ln['desc']}"[:70],
|
2026-10-02 11:35:29 +00:00
|
|
|
|
f"no row in the template for this line (p{ln['page']})",
|
2026-10-01 00:32:13 +00:00
|
|
|
|
source=f"{ln['qty']} {ln['unit']} @ {ln['rate']}",
|
|
|
|
|
|
severity="decision")
|
|
|
|
|
|
continue
|
2026-10-02 11:35:29 +00:00
|
|
|
|
name = slot._sheet
|
|
|
|
|
|
try:
|
|
|
|
|
|
_write_one(wb, name, layouts[name], ln, slot, overrides, res)
|
|
|
|
|
|
except _FormulaProtected:
|
|
|
|
|
|
continue # already recorded; nothing was written
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _write_one(wb, name: str, lay: Layout, ln: dict,
|
|
|
|
|
|
slot: Slot | None, overrides: dict, res: Result) -> None:
|
|
|
|
|
|
"""Write one aligned line. Returns rather than continuing."""
|
|
|
|
|
|
if slot is None:
|
|
|
|
|
|
res.add("unmapped", name, 0,
|
|
|
|
|
|
f"{ln['number']} {ln['desc']}"[:70],
|
|
|
|
|
|
f"no row in '{name}' for this line (p{ln['page']})",
|
|
|
|
|
|
source=f"{ln['qty']} {ln['unit']} @ {ln['rate']}",
|
|
|
|
|
|
severity="decision")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
ws = wb[name]
|
|
|
|
|
|
r = slot.row
|
|
|
|
|
|
c_unit, c_qty = lay.col("unit"), lay.col("qty")
|
|
|
|
|
|
c_rate, c_amt = lay.col("rate"), lay.col("amount")
|
|
|
|
|
|
label = f"{slot.number} {slot.desc}"[:70]
|
|
|
|
|
|
|
|
|
|
|
|
if c_qty and c_rate:
|
|
|
|
|
|
ql, rl = get_column_letter(c_qty), get_column_letter(c_rate)
|
|
|
|
|
|
amt_formula = (f'=IF(OR({ql}{{r}}="",{rl}{{r}}=""),"",'
|
|
|
|
|
|
f'{ql}{{r}}*{rl}{{r}})')
|
|
|
|
|
|
else:
|
|
|
|
|
|
amt_formula = None
|
2026-10-01 00:32:13 +00:00
|
|
|
|
|
2026-10-02 11:35:29 +00:00
|
|
|
|
# --- unit ---------------------------------------------------------------
|
|
|
|
|
|
if c_unit and ln["unit"]:
|
|
|
|
|
|
cur = ws.cell(r, c_unit).value
|
|
|
|
|
|
if cur in (None, ""):
|
|
|
|
|
|
if _writable(ws, r, c_unit, res, name, label, "unit"):
|
2026-10-01 00:32:13 +00:00
|
|
|
|
ws.cell(r, c_unit, ln["unit"])
|
2026-10-02 11:35:29 +00:00
|
|
|
|
elif not units_compatible(cur, ln["unit"]):
|
|
|
|
|
|
okey = (name, r, "unit")
|
|
|
|
|
|
if okey in overrides:
|
|
|
|
|
|
ws.cell(r, c_unit, overrides[okey])
|
|
|
|
|
|
res.add("conflict", name, r, label,
|
|
|
|
|
|
f"unit: set to {overrides[okey]!r} by the consultant",
|
|
|
|
|
|
source=str(ln["unit"]), severity="resolved",
|
|
|
|
|
|
field="unit", template_value=cur,
|
|
|
|
|
|
source_value=ln["unit"])
|
|
|
|
|
|
else:
|
|
|
|
|
|
note = (" -- Candy printed a corrupt unit character, so the "
|
|
|
|
|
|
"template's real unit was kept"
|
|
|
|
|
|
if _norm_unit(ln["unit"]) == "?" else
|
|
|
|
|
|
" -- left as the template has it")
|
|
|
|
|
|
res.add("conflict", name, r, label,
|
|
|
|
|
|
f"unit: template {cur!r} vs source {ln['unit']!r}{note}",
|
|
|
|
|
|
source=str(ln["unit"]), severity="source_defect",
|
|
|
|
|
|
field="unit", template_value=cur,
|
|
|
|
|
|
source_value=ln["unit"])
|
|
|
|
|
|
|
|
|
|
|
|
# --- quantity ------------------------------------------------------------
|
|
|
|
|
|
stop = False
|
|
|
|
|
|
if c_qty and ln["qty"] is not None:
|
|
|
|
|
|
cur = ws.cell(r, c_qty).value
|
|
|
|
|
|
okey = (name, r, "qty")
|
|
|
|
|
|
if okey in overrides:
|
|
|
|
|
|
ws.cell(r, c_qty, overrides[okey])
|
|
|
|
|
|
res.add("conflict", name, r, label,
|
|
|
|
|
|
f"quantity: set to {overrides[okey]} by the consultant",
|
|
|
|
|
|
source=str(ln["qty"]), severity="resolved", field="qty",
|
|
|
|
|
|
template_value=cur, source_value=ln["qty"])
|
|
|
|
|
|
elif cur in (None, ""):
|
|
|
|
|
|
if _writable(ws, r, c_qty, res, name, label, "qty"):
|
2026-10-01 00:32:13 +00:00
|
|
|
|
ws.cell(r, c_qty, ln["qty"])
|
2026-10-02 11:35:29 +00:00
|
|
|
|
elif isinstance(cur, (int, float)) and abs(float(cur) - ln["qty"]) > 1e-6:
|
|
|
|
|
|
if ln["rate_only"] or ln["qty"] == 0:
|
|
|
|
|
|
reason = ("source carries no quantity here (printed 'Rate "
|
|
|
|
|
|
"only'), so the template's own figure was kept")
|
|
|
|
|
|
sev = "source_gap"
|
2026-10-01 00:32:13 +00:00
|
|
|
|
else:
|
2026-10-02 11:35:29 +00:00
|
|
|
|
reason = "the two documents disagree; NEITHER was overwritten"
|
|
|
|
|
|
sev = "decision"
|
|
|
|
|
|
res.add("conflict", name, r, label,
|
|
|
|
|
|
f"quantity: template {cur} vs source {ln['qty']} - {reason}",
|
|
|
|
|
|
source=str(ln["qty"]), severity=sev, field="qty",
|
|
|
|
|
|
template_value=cur, source_value=ln["qty"])
|
|
|
|
|
|
else:
|
|
|
|
|
|
res.kept += 1
|
|
|
|
|
|
stop = True
|
|
|
|
|
|
if c_rate and ln["rate"] is not None and not ln["rate_only"]:
|
|
|
|
|
|
if ws.cell(r, c_rate).value in (None, ""):
|
|
|
|
|
|
if _writable(ws, r, c_rate, res, name, label, "rate"):
|
2026-10-01 00:32:13 +00:00
|
|
|
|
ws.cell(r, c_rate, ln["rate"])
|
2026-10-02 11:35:29 +00:00
|
|
|
|
if c_amt and amt_formula and _writable(
|
|
|
|
|
|
ws, r, c_amt, res, name, label, "amount"):
|
2026-10-01 00:32:13 +00:00
|
|
|
|
ws.cell(r, c_amt, amt_formula.format(r=r))
|
|
|
|
|
|
res.written += 1
|
|
|
|
|
|
|
2026-10-02 11:35:29 +00:00
|
|
|
|
if stop:
|
|
|
|
|
|
return
|
2026-10-01 00:32:13 +00:00
|
|
|
|
|
2026-10-02 11:35:29 +00:00
|
|
|
|
# --- rate and amount -----------------------------------------------------
|
|
|
|
|
|
res.written += 1
|
|
|
|
|
|
if c_rate and ln["rate"] is not None:
|
|
|
|
|
|
ws.cell(r, c_rate, ln["rate"])
|
|
|
|
|
|
if c_amt:
|
|
|
|
|
|
if ln["rate_only"]:
|
|
|
|
|
|
ws.cell(r, c_amt, "Rate only")
|
|
|
|
|
|
res.add("info", name, r, label,
|
|
|
|
|
|
"'Rate only' in source - rate written, amount as text",
|
|
|
|
|
|
source=str(ln["rate"]))
|
|
|
|
|
|
elif amt_formula:
|
|
|
|
|
|
ws.cell(r, c_amt, amt_formula.format(r=r))
|
|
|
|
|
|
elif c_amt and ln["rate_only"]:
|
|
|
|
|
|
ws.cell(r, c_amt, "Rate only")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _writable(ws, r: int, col: int, res: Result, sheet: str, label: str,
|
|
|
|
|
|
field: str) -> bool:
|
|
|
|
|
|
"""Refuse to overwrite a cell that holds a formula.
|
|
|
|
|
|
|
|
|
|
|
|
A consultant's workbook is a working model, not a data dump. Quantities
|
|
|
|
|
|
are derived ("=G35", "=CEILING.MATH(...)"), line amounts are computed, and
|
|
|
|
|
|
section totals are SUMs over the rows above. Writing a literal over any of
|
|
|
|
|
|
those destroys their work while looking like it succeeded.
|
|
|
|
|
|
"""
|
|
|
|
|
|
cur = ws.cell(r, col).value
|
|
|
|
|
|
if isinstance(cur, str) and cur.startswith("="):
|
|
|
|
|
|
res.add("conflict", sheet, r, label,
|
|
|
|
|
|
f"{field}: that cell holds a formula ({cur[:40]}) - your model "
|
|
|
|
|
|
f"was left alone and the source value was not written",
|
|
|
|
|
|
severity="decision", field=field, template_value=cur)
|
|
|
|
|
|
return False
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _guard_formula(ws, r: int, col: int, res: Result, sheet: str,
|
|
|
|
|
|
label: str) -> None:
|
|
|
|
|
|
"""Refuse to clobber a cell that holds a formula.
|
|
|
|
|
|
|
|
|
|
|
|
On a SANS 1200 template the quantities are DERIVED - "=G35",
|
|
|
|
|
|
"=CEILING.MATH(414.065+302.817,10)" - from a Measurement sheet. Writing
|
|
|
|
|
|
Candy's number over one of those destroys the engineer's model, and it
|
|
|
|
|
|
looks like it worked.
|
|
|
|
|
|
"""
|
|
|
|
|
|
cur = ws.cell(r, col).value
|
|
|
|
|
|
if isinstance(cur, str) and cur.startswith("="):
|
|
|
|
|
|
res.add("conflict", sheet, r, label,
|
|
|
|
|
|
f"that cell holds a FORMULA ({cur[:44]}) - your model's value "
|
|
|
|
|
|
f"was left in place, and the source quantity was not written",
|
|
|
|
|
|
severity="decision", field="qty",
|
|
|
|
|
|
template_value=cur, source_value=None)
|
|
|
|
|
|
raise _FormulaProtected()
|
2026-10-01 00:32:13 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
SUMMARY_REF = re.compile(
|
|
|
|
|
|
r"^=\s*'?([^'!]+)'?!\$?([A-Z]{1,2})\$?(\d+)\s*$")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def summary_values(tree: dict) -> list[tuple[str, float]]:
|
|
|
|
|
|
"""The totals a COMBINED SUMMARY row expects, in document order.
|
|
|
|
|
|
|
|
|
|
|
|
A consultant template's summary sheet points at specific cells such as
|
|
|
|
|
|
='SCHEDULE B - ROADS'!G66, and those cells are blank in the template - so
|
|
|
|
|
|
without this the consultant opens the file and sees zero. The values come
|
|
|
|
|
|
from the source in the same order the summary lists them: each schedule,
|
|
|
|
|
|
then each of its sections.
|
|
|
|
|
|
"""
|
|
|
|
|
|
def leaf(node: dict) -> float:
|
|
|
|
|
|
if node.get("kind") == "line":
|
|
|
|
|
|
if node.get("rate_only") or node.get("amount") is None:
|
|
|
|
|
|
return 0.0
|
|
|
|
|
|
return float(node["amount"])
|
|
|
|
|
|
return sum(leaf(c) for c in node.get("children", []))
|
|
|
|
|
|
|
|
|
|
|
|
out: list[tuple[str, float]] = []
|
|
|
|
|
|
for branch in tree.get("children", []):
|
|
|
|
|
|
if branch.get("kind") not in ("group", "schedule", "section"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
if branch.get("kind") != "schedule":
|
|
|
|
|
|
label = f"{branch.get('number','')} {branch.get('title','')}".strip()
|
|
|
|
|
|
val = branch.get("subtotal")
|
|
|
|
|
|
out.append((label, round(float(val), 2) if val is not None
|
|
|
|
|
|
else round(leaf(branch), 2)))
|
|
|
|
|
|
for sec in branch.get("children", []):
|
|
|
|
|
|
if sec.get("kind") != "section":
|
|
|
|
|
|
continue
|
|
|
|
|
|
label = f"{sec.get('number','')} {sec.get('title','')}".strip()
|
|
|
|
|
|
val = sec.get("subtotal")
|
|
|
|
|
|
out.append((label, round(float(val), 2) if val is not None
|
|
|
|
|
|
else round(leaf(sec), 2)))
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _write_totals(wb, layouts: dict[str, Layout], tree: dict,
|
|
|
|
|
|
res: Result) -> None:
|
|
|
|
|
|
"""Fill the cells the workbook's own summary depends on."""
|
|
|
|
|
|
values = summary_values(tree)
|
|
|
|
|
|
vi = 0
|
|
|
|
|
|
|
|
|
|
|
|
# 1. the summary sheet, if there is one
|
|
|
|
|
|
for name in wb.sheetnames:
|
|
|
|
|
|
if "summary" not in name.lower():
|
|
|
|
|
|
continue
|
|
|
|
|
|
ws = wb[name]
|
|
|
|
|
|
for r in range(1, min(ws.max_row, 120) + 1):
|
|
|
|
|
|
for c in range(1, min(ws.max_column, 12) + 1):
|
|
|
|
|
|
m = SUMMARY_REF.match(str(ws.cell(r, c).value or ""))
|
|
|
|
|
|
if not m or vi >= len(values):
|
|
|
|
|
|
continue
|
|
|
|
|
|
target_sheet, col_letter, row_no = m.group(1), m.group(2), m.group(3)
|
|
|
|
|
|
if target_sheet not in wb.sheetnames:
|
|
|
|
|
|
continue
|
|
|
|
|
|
label, val = values[vi]
|
|
|
|
|
|
vi += 1
|
|
|
|
|
|
tgt = wb[target_sheet]
|
2026-10-02 11:35:29 +00:00
|
|
|
|
existing = tgt[f"{col_letter}{row_no}"].value
|
|
|
|
|
|
if isinstance(existing, str) and existing.startswith("="):
|
|
|
|
|
|
res.add("info", target_sheet, int(row_no), label,
|
|
|
|
|
|
f"summary cell {col_letter}{row_no} already holds a "
|
|
|
|
|
|
f"formula ({existing[:34]}) - left as it is",
|
|
|
|
|
|
severity="note")
|
|
|
|
|
|
continue
|
2026-10-01 00:32:13 +00:00
|
|
|
|
tgt[f"{col_letter}{row_no}"] = val
|
|
|
|
|
|
tgt[f"{col_letter}{row_no}"].number_format = "#,##0.00"
|
|
|
|
|
|
res.add("total", name, r, label,
|
|
|
|
|
|
f"summary row -> {target_sheet}!{col_letter}{row_no} "
|
|
|
|
|
|
f"= R{val:,.2f}")
|
|
|
|
|
|
|
|
|
|
|
|
# 2. any remaining TOTAL CARRIED FORWARD rows get a live SUM over the data
|
|
|
|
|
|
# rows in their own block, so the workbook still adds up on its own
|
|
|
|
|
|
for name, lay in layouts.items():
|
|
|
|
|
|
ws = wb[name]
|
|
|
|
|
|
c_amt = lay.col("amount")
|
|
|
|
|
|
if not c_amt or not lay.total_rows:
|
|
|
|
|
|
continue
|
|
|
|
|
|
letter_amt = get_column_letter(c_amt)
|
|
|
|
|
|
for tr in lay.total_rows:
|
|
|
|
|
|
if ws.cell(tr, c_amt).value is not None:
|
|
|
|
|
|
continue # already written by the summary pass
|
|
|
|
|
|
prev = lay.header_row
|
|
|
|
|
|
for r2 in lay.total_rows:
|
|
|
|
|
|
if r2 >= tr:
|
|
|
|
|
|
break
|
|
|
|
|
|
prev = r2
|
|
|
|
|
|
rows = [r for r in lay.data_rows if prev < r < tr]
|
2026-10-02 11:35:29 +00:00
|
|
|
|
cur = ws.cell(tr, c_amt).value
|
|
|
|
|
|
if rows and not (isinstance(cur, str) and cur.startswith("=")):
|
2026-10-01 00:32:13 +00:00
|
|
|
|
ws.cell(tr, c_amt,
|
|
|
|
|
|
f"=SUM({letter_amt}{min(rows)}:{letter_amt}{max(rows)})"
|
|
|
|
|
|
).number_format = "#,##0.00"
|
|
|
|
|
|
res.add("total", name, lay.total_rows[0] if lay.total_rows else 0,
|
|
|
|
|
|
f"{len(lay.total_rows)} TOTAL row(s)",
|
|
|
|
|
|
"remaining TOTAL rows given a live SUM over their own block")
|