Handle a structurally different consultant template

Second real pair (Central Towers: 20pp Candy PDF into a 53-sheet SANS 1200
workbook) exposed four failures. All fixed and verified against both pairs.

- item numbering: accept 1.1.1, 110...., .01 and rebuild the running counter
  Candy restarts under each repeated heading. 0% -> 86% of lines numbered.
- wrapped headers: join 'QUAN-' + 'TITY'. qty was missing on 49 of 51 sheets.
- description is a RUN between ITEM and UNIT, not one column: the header
  claims E, the data sits in D, and it moves within a single sheet.
- carry item numbers down blank cells, and score a missing number as neutral
  rather than as disagreement.

Mapping: 1/108 unmatched (Blueberry), 33/226 (Central Towers).

Safety: the consultant's workbook is never damaged. Per-write guard plus a
final pass that restores any formula disturbed - 0 of 3633 formulas clobbered.
Writing a literal over a derived quantity would have destroyed their model
while looking like it worked.
This commit is contained in:
MEGA INFRA 2026-10-02 13:35:29 +02:00
parent 0ab958ff6a
commit 6b0c4273e3
3 changed files with 602 additions and 159 deletions

View file

@ -159,13 +159,55 @@ app.py Streamlit UI
run.py CLI: PDF -> xlsx + csv run.py CLI: PDF -> xlsx + csv
``` ```
## How it handles a different template
Two real consultant workbooks have been run through it end to end:
| | Blueberry Square | Central Towers |
|---|---|---|
| source PDF | 11pp, 5 sections | 20pp, 18 sections |
| consultant workbook | 3 sheets, Candy-derived | **53 sheets, SANS 1200** |
| source total reproduced | R5,696,934.93 exact | R38,250,252.60 exact |
| lines written | 83 | 182 |
| already correct | 94 | 159 |
| **lines with no home** | **1 of 108** | **33 of 226** |
| consultant formulas destroyed | **0** | **0** |
What it takes to cope with a template nobody has seen before:
- **Item numbering.** Candy prints references three ways - `1.1.1`, `110....`,
and a leading-dot `.01`. All are recognised. A running counter is rebuilt per
section, because Candy restarts `.01` under every repeated heading while the
consultant's template numbers them sequentially.
- **Wrapped headers.** A narrow column splits `QUANTITY` into `QUAN-` / `TITY`.
Both header rows are read and joined, hyphen stripped, or the quantity column
is simply never found.
- **The header lies.** On the SANS 1200 template the header advertises
DESCRIPTION in column E while the text sits in D - and moves *within* one
sheet. So the description is treated as whatever run of columns sits between
ITEM and UNIT, not a single column.
- **Numbers are implied, not repeated.** A blank item cell still belongs to the
number printed above it, so that is carried down. Without it two thirds of
the rows read as unnumbered and nothing can match them.
- **The pool is cut to what matters.** Aligning against all 1314 rows of a
53-sheet workbook makes the aligner cherry-pick; it is restricted to the
sheets that own a section named in the source, leaving 499.
**The hard guarantee: the consultant's workbook is never damaged.** Every cell
that held a formula still holds it. A guard stops each write, and a final pass
re-reads the original and restores anything disturbed, so the promise is
checked rather than intended. On the SANS 1200 template, where quantities are
derived (`=G35`, `=CEILING.MATH(...)`), writing a literal over that would
destroy the engineer's model while looking like it worked.
## Known limits ## Known limits
- Central Towers coverage is **85%**, not 100%. The 33 unmatched lines are
reported rather than written somewhere plausible, which is the safe failure —
but it does mean that workbook is not finished.
- Column bands are tuned for A4 portrait Candy output. A different print layout - Column bands are tuned for A4 portrait Candy output. A different print layout
needs `BANDS` in `extract.py` re-measured. needs `BANDS` in `extract.py` re-measured.
- Only one consultant's file has been seen. Alignment is near 1:1 for this one; - The Central Towers workbook carries **external links** (`='[9]1200MF'!I56`).
a structurally different template may need its own profile tuning. Those are preserved but will not resolve without the linked file.
- Multi-sheet templates are handled one BOQ-shaped sheet at a time; a template
that splits one section across two sheets would need a different strategy.
- Description joins can drop a space where Candy's print engine split a word - Description joins can drop a space where Candy's print engine split a word
(`Fixed-ChargeItems`). Cosmetic. (`Fixed-ChargeItems`). Cosmetic.

View file

@ -40,7 +40,11 @@ BANDS = {
"amount": (500.0, 575.0), "amount": (500.0, 575.0),
} }
ITEM_X_MAX = 60.0 ITEM_X_MAX = 68.0
# A second, narrower band just right of the item column. Some Candy layouts
# print the sub-item number indented - ".01", ".02" - which is otherwise
# indistinguishable from the start of a description and gets glued onto it.
SUBITEM_X_MAX = 84.0
DESC_X_MIN, DESC_X_MAX = 60.0, 296.0 DESC_X_MIN, DESC_X_MAX = 60.0, 296.0
UNIT_X_MIN, UNIT_X_MAX = 296.0, 380.0 UNIT_X_MIN, UNIT_X_MAX = 296.0, 380.0
@ -61,6 +65,53 @@ HEADER_TOKENS = {"item", "unit", "rate", "amount", "bill", "quantity"}
SECTION_RE = re.compile(r"^SECTION\s+(\d+)\s*[:\-]\s*(.+)$", re.I) SECTION_RE = re.compile(r"^SECTION\s+(\d+)\s*[:\-]\s*(.+)$", re.I)
ITEM_RE = re.compile(r"^\d{1,2}(?:\.{1,2}\d{1,2})*\.?$") ITEM_RE = re.compile(r"^\d{1,2}(?:\.{1,2}\d{1,2})*\.?$")
# The other numbering style Candy emits, used by the Bill 1200 BOQs:
# "110..." a section heading, trailing dots
# "110...." the same, deeper heading
# ".01" an item, LEADING dot, subordinate to the section above it
# "110.03.01" a fully qualified item
# Anything made only of digits and dots, short, is treated as a reference.
REFISH = re.compile(r"^[\d.]{1,12}$")
# A sub-item reference, strictly shaped. A leading dot (".01", ".1.2") or a
# fully qualified three-part code ("110.03.01"). A bare "1.5" does NOT match,
# so a description that opens with a measurement is not mistaken for an item.
SUBREF = re.compile(r"^\.[\d.]+$|^\d{2,3}(?:\.\d+){2,}$")
def _is_ref(tok: str) -> bool:
"""A bare section/bill reference: digits and dots only, at least 2 digits.
Strict on purpose. A loose version accepted "2.05p" - the version string
from the "Candy 2.05p" page footer - and it then propagated through the
whole document as if it were a section number.
"""
t = (tok or "").strip()
# A single digit is a valid section number ("1 SCHEDULE A : GENERAL"), and
# the item column is the only thing left of x=68, so there is no risk of
# picking up a quantity. Letters are what disqualify a token: "2.05p" from
# the "Candy 2.05p" footer is digits-and-dots-or-not, and REFISH is the
# rule that rejects it.
return bool(REFISH.match(t))
def _is_subref(tok: str) -> bool:
return bool(SUBREF.match((tok or "").strip()))
def _norm_ref(tok: str) -> str:
"""Normalise any of Candy's reference styles to a dotted, digit-anchored key.
"110...." -> "110" ".01" -> "01" "4..2.5" -> "4.2.5"
"""
t = (tok or "").strip().strip(".")
if not t:
return ""
# collapse runs of dots, then drop a leading dot
while ".." in t:
t = t.replace("..", ".")
return t.strip(".")
LETTER_RE = re.compile(r"^\(?([a-z])\)\s*(.*)$", re.S) LETTER_RE = re.compile(r"^\(?([a-z])\)\s*(.*)$", re.S)
PLACEHOLDER_RE = re.compile(r"^[.\s]{6,}$") PLACEHOLDER_RE = re.compile(r"^[.\s]{6,}$")
RATE_ONLY_RE = re.compile(r"rate\s*only", re.I) RATE_ONLY_RE = re.compile(r"rate\s*only", re.I)
@ -90,6 +141,7 @@ def _classify(x1: float) -> str | None:
@dataclass @dataclass
class Row: class Row:
item: str = "" item: str = ""
subitem: str = ""
desc: str = "" desc: str = ""
unit: str = "" unit: str = ""
qty: float | None = None qty: float | None = None
@ -117,13 +169,27 @@ def _page_rows(page, pno: int) -> list[Row]:
for key in sorted(buckets): for key in sorted(buckets):
words = sorted(buckets[key], key=lambda z: z["x0"]) words = sorted(buckets[key], key=lambda z: z["x0"])
r = Row(top=words[0]["top"], page=pno) r = Row(top=words[0]["top"], page=pno)
# --- pre-pass: lift the reference columns out before anything else.
# Doing this first means a reference can never be swallowed by a wider
# text band further down, which is what left every Bill 1200 line
# unnumbered.
rest: list = []
for w in words:
x0, txt = w["x0"], w["text"]
if x0 < ITEM_X_MAX and _is_ref(txt):
r.item = (r.item + " " + txt).strip()
elif (ITEM_X_MAX <= x0 < SUBITEM_X_MAX and not r.subitem
and _is_subref(txt)):
r.subitem = txt
else:
rest.append(w)
# --- pass 2: the priced columns, then the text
desc_parts: list[str] = [] desc_parts: list[str] = []
last_desc_x1 = None last_desc_x1 = None
for w in words: for w in rest:
x0, x1, txt = w["x0"], w["x1"], w["text"] x0, x1, txt = w["x0"], w["x1"], w["text"]
if x1 <= ITEM_X_MAX:
r.item = (r.item + " " + txt).strip()
continue
col = _classify(x1) col = _classify(x1)
if col == "qty" and r.qty is None: if col == "qty" and r.qty is None:
r.qty = _to_num(txt) r.qty = _to_num(txt)
@ -155,9 +221,70 @@ def _page_rows(page, pno: int) -> list[Row]:
return rows return rows
# ========================================================================== def _resolve_refs(rows: list[Row]) -> list[Row]:
# pass 2 - strip noise, merge continuations """Turn Candy's several reference styles into one comparable key.
# ==========================================================================
This is the fiddliest rule in the whole reader, and getting it wrong is
why a Bill 1200 BOQ used to come out with no usable numbers at all.
Candy prints, for section 110:
110.... GENERAL <- the section title
110.... Scheduled fixed-charge ... <- a group, numbered 110.01
.01 Fixed preliminary ... an item of that group
.02 Value-related ...
110.... Scheduled time-related ... <- the NEXT group, 110.02
.01 Time-related ... ".01" AGAIN
So ".01" is NOT unique - it restarts under every group, and three different
lines in one document are all ".01". The consultant's template numbers
those groups sequentially (110.01, 110.02, 110.03 ...), so that running
counter is what has to be reconstructed here. Without it, 110.01 names
three different lines and cannot be used as a key.
"""
section = ""
group_no = 0
seen_section_title = False
for r in rows:
head = _norm_ref(r.item)
sub = _norm_ref(r.subitem)
if head and not sub and "." not in head:
# a BARE heading - "110...." or "1" or "200". Only these carry the
# section/group logic. A dotted reference like "1.1.1" is a real
# item number and must be left exactly as printed, or the whole
# hierarchy above it is invented.
if head == section:
group_no += 1 # a group inside the section
r.item = f"{section}.{group_no:02d}"
seen_section_title = True
else:
if not seen_section_title and not section:
section = head # first heading = the title
group_no = 0
seen_section_title = True
else:
section = head # a new section entirely
group_no = 0
seen_section_title = False
r.item = head
r.subitem = ""
continue
if sub:
if "." not in sub and group_no:
r.item = f"{section}.{group_no:02d}.{sub}"
elif "." not in sub and section:
r.item = f"{section}.{sub}"
else:
r.item = sub
elif head:
r.item = head
r.subitem = ""
return rows
def _is_noise(r: Row) -> bool: def _is_noise(r: Row) -> bool:
blob = f"{r.item} {r.desc} {r.unit}".strip() blob = f"{r.item} {r.desc} {r.unit}".strip()
if r.has_price: if r.has_price:
@ -404,7 +531,7 @@ def _build(content: list[Row]) -> Group:
continue continue
# ---- numbered heading, optionally carrying a subtotal ------------ # ---- numbered heading, optionally carrying a subtotal ------------
if num and ITEM_RE.match(num): if num and (ITEM_RE.match(num) or re.fullmatch(r"[\d.]+", num)):
depth = t.depth(num) depth = t.depth(num)
subtotal = _to_num(r.amount_txt) if r.amount_txt else None subtotal = _to_num(r.amount_txt) if r.amount_txt else None
@ -479,6 +606,7 @@ def extract(pdf_path: str) -> dict:
rows.extend(_page_rows(page, pno)) rows.extend(_page_rows(page, pno))
rows = _drop_summary_page(rows) rows = _drop_summary_page(rows)
rows = _resolve_refs(rows)
clean = [r for r in rows if not r.is_empty and not _is_noise(r)] clean = [r for r in rows if not r.is_empty and not _is_noise(r)]
merged = _merge_continuations(clean) merged = _merge_continuations(clean)
return asdict(_build(merged)) return asdict(_build(merged))

View file

@ -81,28 +81,63 @@ class Layout:
data_rows: list[int] = field(default_factory=list) data_rows: list[int] = field(default_factory=list)
total_rows: list[int] = field(default_factory=list) total_rows: list[int] = field(default_factory=list)
notes: list[str] = field(default_factory=list) notes: list[str] = field(default_factory=list)
# 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)
def col(self, name: str) -> int | None: def col(self, name: str) -> int | None:
return self.cols.get(name) return self.cols.get(name)
def detect_layout(ws) -> Layout: def _score_header_row(ws, r: int, ncols: int, span: int = 1) -> dict[str, int]:
"""Find the header row and map the columns.""" """Map columns to names using a header that may wrap over `span` rows.
best: tuple[int, dict[str, int], int] | None = None
for r in range(1, min(ws.max_row, 25) + 1): Templates split a label across two lines to fit a narrow column -
cols: dict[str, int] = {} "QUAN-" over "TITY". Reading either row alone finds no QUANTITY at all, so
for c in range(1, min(ws.max_column, 30) + 1): the quantity column is simply missed and never written. Joining the rows
v = _cellsafe(ws.cell(r, c).value) recovers the word.
if not v or v in cols.values(): """
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 continue
for name, pats in HEADER_PATTERNS.items(): if label in pats or any(p in label for p in pats):
if name in cols: cols[name] = c
continue break
if v in pats or any(p in v for p in pats): return cols
cols[name] = c
break
if len(cols) >= 4 and (best is None or len(cols) > len(best[1])): def detect_layout(ws) -> Layout:
best = (r, cols, len(cols)) """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]]] = []
for r in range(1, min(ws.max_row, 25) + 1):
for span in (1, 2):
if r + span - 1 > ws.max_row:
continue
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
if best is None: if best is None:
raise ValueError( raise ValueError(
@ -110,14 +145,17 @@ def detect_layout(ws) -> Layout:
f"expected 4+ of {', '.join(HEADER_PATTERNS)} in the first 25 rows" f"expected 4+ of {', '.join(HEADER_PATTERNS)} in the first 25 rows"
) )
header_row, cols, _ = best _, header_row, span, cols = best
lay = Layout(sheet=ws.title, header_row=header_row, cols=cols) lay = Layout(sheet=ws.title, header_row=header_row, cols=cols)
if span > 1:
lay.notes.append(f"header wraps over rows {header_row}"
f"-{header_row + span - 1}")
c_item = cols.get("item") c_item = cols.get("item")
c_desc = cols.get("description") c_desc = cols.get("description")
c_qty = cols.get("qty") c_qty = cols.get("qty")
for r in range(header_row + 1, ws.max_row + 1): for r in range(header_row + span, ws.max_row + 1):
a = ws.cell(r, c_item).value if c_item else None a = ws.cell(r, c_item).value if c_item else None
d = ws.cell(r, c_desc).value if c_desc 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 q = ws.cell(r, c_qty).value if c_qty else None
@ -135,9 +173,95 @@ def detect_layout(ws) -> Layout:
lay.notes.append(f"template has no '{name}' column - that value will not be written") lay.notes.append(f"template has no '{name}' column - that value will not be written")
if "payment" in cols: if "payment" in cols:
lay.notes.append("PAYMENT column detected and will be left untouched") lay.notes.append("PAYMENT column detected and will be left untouched")
_verify_against_data(ws, lay)
_derive_desc_run(lay)
return lay return lay
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
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# matching # matching
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@ -159,16 +283,28 @@ class Slot:
def read_slots(ws, lay: Layout) -> list[Slot]: def read_slots(ws, lay: Layout) -> list[Slot]:
ci, cd = lay.col("item"), lay.col("description") ci = lay.col("item")
run = lay.desc_cols or ([lay.col("description")] if lay.col("description") else [])
out: list[Slot] = [] out: list[Slot] = []
# 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 = ""
for r in lay.data_rows: for r in lay.data_rows:
num = str(ws.cell(r, ci).value or "").strip() if ci else "" num = str(ws.cell(r, ci).value or "").strip() if ci else ""
desc = str(ws.cell(r, cd).value or "").strip() if cd else "" 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()
if not num and not desc: if not num and not desc:
continue continue
# indent level from the leading whitespace the consultant preserved # indent level from the leading whitespace the consultant preserved
raw = str(ws.cell(r, cd).value or "") if cd else "" raw = next((str(ws.cell(r, c).value or "") for c in run
indent = len(raw) - len(raw.lstrip()) if raw != raw.lstrip() else 0 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
out.append(Slot(row=r, number=num, desc=desc, indent=indent)) out.append(Slot(row=r, number=num, desc=desc, indent=indent))
return out return out
@ -191,11 +327,12 @@ def _sim(a: Slot, num: str, desc: str) -> float:
elif tnum in snum or snum in tnum: elif tnum in snum or snum in tnum:
num_score = 0.75 num_score = 0.75
else: else:
num_score = 0.0 num_score = 0.0 # two different numbers: real evidence against
elif not tnum and not snum:
num_score = 0.5 # neither numbered: description decides
else: else:
num_score = 0.0 # one side numbered, the other not # 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
d = SequenceMatcher(None, tdesc, sdesc).ratio() d = SequenceMatcher(None, tdesc, sdesc).ratio()
return round(num_score * 0.6 + d * 0.4, 4) return round(num_score * 0.6 + d * 0.4, 4)
@ -276,6 +413,10 @@ class Finding:
source_value: Any = None # what our Candy PDF holds source_value: Any = None # what our Candy PDF holds
class _FormulaProtected(Exception):
"""Raised internally when a cell must not be overwritten."""
@dataclass @dataclass
class Result: class Result:
written: int = 0 written: int = 0
@ -305,6 +446,10 @@ def source_lines(tree: dict, *, with_prices: bool = True) -> list[dict]:
if kind == "line": if kind == "line":
if node.get("qty") is None and node.get("rate") is None: if node.get("qty") is None and node.get("rate") is None:
return return
# 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
out.append({ out.append({
"number": node.get("number", "") or "", "number": node.get("number", "") or "",
"desc": (node.get("desc") or "").strip(), "desc": (node.get("desc") or "").strip(),
@ -379,24 +524,57 @@ def units_compatible(a: str, b: str) -> bool:
return ca is not None and ca == cb return ca is not None and ca == cb
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
def populate(consultant_xlsx: str, tree: dict, out_path: str, *, def populate(consultant_xlsx: str, tree: dict, out_path: str, *,
sheet_hint: str | None = None, sheet_hint: str | None = None,
overrides: dict | None = None) -> Result: overrides: dict | None = None) -> Result:
"""Write our Candy BOQ into a consultant's workbook, in place-safe fashion. """Write our Candy BOQ into a consultant's workbook, in place-safe fashion.
ONE alignment is run across every sheet, in workbook order, rather than Lines are routed to a sheet by item number, then aligned WITHIN that sheet.
per sheet. The Candy PDF runs Schedule A then Schedule B; the workbook Nothing is ever written over a value the consultant already had, unless
does too. Aligning each sheet separately against the whole line list they explicitly asked for it via `overrides`.
scatters Schedule B's lines into Schedule A's rows.
`overrides` lets the consultant settle a disagreement from inside the app:
keys are (sheet, row, field) and the value is what should be written.
A cell with an override is written rather than left alone, and the
conflict is downgraded to 'resolved' so it stops being flagged.
""" """
overrides = overrides or {} overrides = overrides or {}
res = Result() res = Result()
res = Result() original = load_workbook(consultant_xlsx) # pristine copy for comparison
wb = load_workbook(consultant_xlsx) # formulas preserved wb = load_workbook(consultant_xlsx) # formulas preserved
lines = source_lines(tree) lines = source_lines(tree)
if not lines: if not lines:
@ -404,7 +582,7 @@ def populate(consultant_xlsx: str, tree: dict, out_path: str, *,
names = [sheet_hint] if sheet_hint else list(wb.sheetnames) names = [sheet_hint] if sheet_hint else list(wb.sheetnames)
layouts: dict[str, Layout] = {} layouts: dict[str, Layout] = {}
combined: list[tuple[str, Slot]] = [] slots_by_sheet: dict[str, list[Slot]] = {}
for name in names: for name in names:
if name not in wb.sheetnames: if name not in wb.sheetnames:
@ -417,136 +595,223 @@ def populate(consultant_xlsx: str, tree: dict, out_path: str, *,
continue continue
layouts[name] = lay layouts[name] = lay
res.layout_notes.extend(f"[{name}] {n}" for n in lay.notes) res.layout_notes.extend(f"[{name}] {n}" for n in lay.notes)
for slot in read_slots(ws, lay): slots = read_slots(ws, lay)
slot._sheet = name for sl in slots:
combined.append((name, slot)) sl._sheet = name
slots_by_sheet[name] = slots
if not combined: if not slots_by_sheet:
raise ValueError("no BOQ-shaped sheet found in the workbook") raise ValueError("no BOQ-shaped sheet found in the workbook")
pairs = align([sl for _, sl in combined], lines) pool, counts = _relevant_pool(lines, slots_by_sheet)
res.sheets = list(layouts) 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
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)
for ln, slot, score in pairs: for ln, slot, score in pairs:
if slot is None: if slot is None:
res.add("unmapped", "-", 0, res.add("unmapped", "-", 0,
f"{ln['number']} {ln['desc']}"[:70], f"{ln['number']} {ln['desc']}"[:70],
f"source line (p{ln['page']}) has no row in any template sheet", f"no row in the template for this line (p{ln['page']})",
source=f"{ln['qty']} {ln['unit']} @ {ln['rate']}", source=f"{ln['qty']} {ln['unit']} @ {ln['rate']}",
severity="decision") severity="decision")
continue continue
name = slot._sheet
try:
_write_one(wb, name, layouts[name], ln, slot, overrides, res)
except _FormulaProtected:
continue # already recorded; nothing was written
name, r = slot._sheet, slot.row
ws = wb[name]
lay = layouts[name]
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]
# build the amount formula from the columns we actually detected - a def _write_one(wb, name: str, lay: Layout, ln: dict,
# consultant template puts them wherever it likes, and assuming D/E slot: Slot | None, overrides: dict, res: Result) -> None:
# silently multiplies UNIT x QTY """Write one aligned line. Returns rather than continuing."""
if c_qty and c_rate: if slot is None:
ql, rl = get_column_letter(c_qty), get_column_letter(c_rate) res.add("unmapped", name, 0,
amt_formula = (f'=IF(OR({ql}{{r}}="",{rl}{{r}}=""),"",' f"{ln['number']} {ln['desc']}"[:70],
f'{ql}{{r}}*{rl}{{r}})') f"no row in '{name}' for this line (p{ln['page']})",
else: source=f"{ln['qty']} {ln['unit']} @ {ln['rate']}",
amt_formula = None severity="decision")
return
# --- unit ------------------------------------------------------ ws = wb[name]
if c_unit and ln["unit"]: r = slot.row
cur = ws.cell(r, c_unit).value c_unit, c_qty = lay.col("unit"), lay.col("qty")
if cur in (None, ""): 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
# --- 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"):
ws.cell(r, c_unit, ln["unit"]) ws.cell(r, c_unit, ln["unit"])
elif not units_compatible(cur, ln["unit"]): elif not units_compatible(cur, ln["unit"]):
hint = "" okey = (name, r, "unit")
if _norm_unit(ln["unit"]) == "?": if okey in overrides:
hint = (" -- Candy printed a corrupt unit character; the " ws.cell(r, c_unit, overrides[okey])
"template's real unit was kept, not overwritten") res.add("conflict", name, r, label,
ws.cell(r, c_unit, cur) f"unit: set to {overrides[okey]!r} by the consultant",
elif _norm_unit(cur) == "?": source=str(ln["unit"]), severity="resolved",
hint = (" -- template unit is unreadable, source unit " field="unit", template_value=cur,
"written instead") source_value=ln["unit"])
else:
hint = " -- left as the template has it"
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:
res.add("conflict", name, r, label,
f"unit: template {cur!r} vs source {ln['unit']!r}{hint}",
source=str(ln["unit"]),
severity="source_defect",
field="unit", template_value=cur,
source_value=ln["unit"])
# --- quantity ---------------------------------------------------
if c_qty and ln["qty"] is not None:
cur = ws.cell(r, c_qty).value
if cur in (None, ""):
ws.cell(r, c_qty, ln["qty"])
elif isinstance(cur, (int, float)) and abs(float(cur) - ln["qty"]) > 1e-6:
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"])
else:
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"
else:
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"])
res.written += 1
else: else:
res.kept += 1 note = (" -- Candy printed a corrupt unit character, so the "
# already correct - still make sure a rate landed "template's real unit was kept"
if c_rate and ln["rate"] is not None and not ln["rate_only"]: if _norm_unit(ln["unit"]) == "?" else
if ws.cell(r, c_rate).value in (None, ""): " -- 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"):
ws.cell(r, c_qty, ln["qty"])
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"
else:
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"):
ws.cell(r, c_rate, ln["rate"]) ws.cell(r, c_rate, ln["rate"])
if c_amt: if c_amt and amt_formula and _writable(
ws, r, c_amt, res, name, label, "amount"):
ws.cell(r, c_amt, amt_formula.format(r=r)) ws.cell(r, c_amt, amt_formula.format(r=r))
res.written += 1 res.written += 1
continue
# --- rate + amount ---------------------------------------------- if stop:
if c_rate and ln["rate"] is not None: return
ws.cell(r, c_rate, ln["rate"])
if c_amt: # --- rate and amount -----------------------------------------------------
if ln["rate_only"]: res.written += 1
ws.cell(r, c_amt, "Rate only") if c_rate and ln["rate"] is not None:
res.add("info", name, r, label, ws.cell(r, c_rate, ln["rate"])
"'Rate only' in source - rate written, amount as text", if c_amt:
source=str(ln["rate"])) if ln["rate_only"]:
else:
# live formula so the consultant can re-rate and watch the
# total follow. Never a frozen value.
if amt_formula:
ws.cell(r, c_amt, amt_formula.format(r=r))
res.written += 1
elif c_rate and ln["rate_only"] and ln["amount"] is None:
if c_amt:
ws.cell(r, c_amt, "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")
_write_totals(wb, layouts, tree, res)
wb.save(out_path) def _writable(ws, r: int, col: int, res: Result, sheet: str, label: str,
return res 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()
SUMMARY_REF = re.compile( SUMMARY_REF = re.compile(
@ -610,6 +875,13 @@ def _write_totals(wb, layouts: dict[str, Layout], tree: dict,
label, val = values[vi] label, val = values[vi]
vi += 1 vi += 1
tgt = wb[target_sheet] tgt = wb[target_sheet]
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
tgt[f"{col_letter}{row_no}"] = val tgt[f"{col_letter}{row_no}"] = val
tgt[f"{col_letter}{row_no}"].number_format = "#,##0.00" tgt[f"{col_letter}{row_no}"].number_format = "#,##0.00"
res.add("total", name, r, label, res.add("total", name, r, label,
@ -633,7 +905,8 @@ def _write_totals(wb, layouts: dict[str, Layout], tree: dict,
break break
prev = r2 prev = r2
rows = [r for r in lay.data_rows if prev < r < tr] rows = [r for r in lay.data_rows if prev < r < tr]
if rows: cur = ws.cell(tr, c_amt).value
if rows and not (isinstance(cur, str) and cur.startswith("=")):
ws.cell(tr, c_amt, ws.cell(tr, c_amt,
f"=SUM({letter_amt}{min(rows)}:{letter_amt}{max(rows)})" f"=SUM({letter_amt}{min(rows)}:{letter_amt}{max(rows)})"
).number_format = "#,##0.00" ).number_format = "#,##0.00"