diff --git a/README.md b/README.md index 5329c15..cd010e3 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,39 @@ 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. +## Choose the safe failure + +A BOQ line is often split across two rows: the label and the first part of the +description on one, the **figures on the next**. + +``` +r74 C=.02 The use of equipment referred to in item +r75 110.04.01 above L.Sum 1.00 =+G75*H75 +``` + +Leaving those halves as separate candidates caused two distinct faults: + +- **`.03 Excavation by hand…` put its rate (R764.75) onto `.02`'s row.** A wrong + figure, in the wrong place, that reads as a perfectly normal price. +- **`Carried forward` rows were treated as placeable lines.** They carry text + in the item column, so the real line above merged down into one and its rate + landed on a row that belongs to nothing. + +Both are fixed. The figures row now becomes the slot's row — that is where the +columns are — and keeps the joined description so matching still sees the whole +line. Continuation markers are never placement targets. + +This is why the totals look slightly worse and the file is actually better: + +| | written | unplaced | and the block in question | +|---|---|---|---| +| before | 196 | 22 | `.02` row carried **R764.75** — `.03`'s rate, silently | +| after | 191 | 24 | `.02` left **blank** and listed for placement; `.03` and `.04` correct | + +Four more lines need a human; one wrong figure on a tender is worth more than +four clicks. A blank is visible and safe. A wrong rate in a neighbouring line is +invisible and expensive. + ## A BOQ row is often two rows On the SANS 1200 template each line occupies **two** rows: the one carrying the diff --git a/boq_engine/template.py b/boq_engine/template.py index 495dbe3..ec991c3 100644 --- a/boq_engine/template.py +++ b/boq_engine/template.py @@ -54,6 +54,12 @@ HEADER_PATTERNS: dict[str, tuple[str, ...]] = { } TOTAL_RE = re.compile(r"^\s*total\b", re.I) +# "Carried forward" / "Brought forward" / "B/F" rows are section continuation +# markers, not BOQ lines. They sit in the ITEM column, so a naive data-row test +# made them placeable - and the real line above merged down into one, putting +# its rate on a row that belongs to nothing. +CARRY_RE = re.compile(r"^\s*(carried|brought|b/f)\b|\bforward\b|" + r"^\s*to\s+carry\b|^\s*cf\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) @@ -224,6 +230,8 @@ def _rebuild_rows(ws, lay: Layout, start: int | None = None) -> None: for c in run).strip() or None q = ws.cell(r, c_qty).value if c_qty else None label = f"{a or ''} {d or ''}".strip() + if label and CARRY_RE.match(label): + continue # a continuation marker, not a line if label and TOTAL_RE.match(label): lay.total_rows.append(r) continue @@ -333,6 +341,14 @@ class Slot: return _cellsafe(f"{self.number} {self.desc}") +# a description opening with a lettered sub-item label (a), b), c)) starts a +# NEW line rather than continuing the one above +LETTER_ONLY = re.compile(r"^\(?[a-z]\)\s", re.I) +# ".01", "1.2", "a)" - a description that OPENS with a reference starts a new +# line; only bare continuation text may be folded into the one above +STARTS_LINE = re.compile(r"^\(?[a-z]\)\s|^\.?\d+(?:\.\d+)*[\s.]", re.I) + + def read_slots(ws, lay: Layout) -> list[Slot]: """One slot per real line. @@ -366,14 +382,35 @@ def read_slots(ws, lay: Layout) -> list[Slot]: num = carried desc = _row_desc(ws, r, lay) + if CARRY_RE.match(f"{raw} {desc}".strip()): + continue # never a placement target priced = any(ws.cell(r, c).value not in (None, "") for c in (cu, cq, cr) if c) if not desc and not priced and not raw: continue # wholly empty spacer row - if out and not priced and not raw and desc: - # the wrapped tail of the line above + # A row carrying the FIGURES but no item number and no label of its own + # is the tail of the line above: the description wrapped onto this row + # while the unit and quantity stayed. They are one BOQ line, so the + # slot becomes this row - that is where the columns are - and keeps the + # joined description so matching still sees the whole line. + # + # A description opening "a)" / "b)" / "c)" is a NEW line and must never + # be absorbed, or sub-items get folded into the heading above them. + if (out and priced and not raw and desc and out[-1]._bare + and not LETTER_ONLY.match(desc)): + prev = out[-1] + prev.desc = f"{prev.desc} {desc}".strip() if prev.desc else desc + prev.row = r + prev._bare = False + prev._advanced = True + continue + + # bare continuation text only folds in when it is plainly a tail and + # the slot above has not already advanced to a rows-with-figures + if (out and not priced and not raw and desc + and not STARTS_LINE.match(desc) and not out[-1]._advanced): if out[-1].desc: out[-1].desc = f"{out[-1].desc} {desc}".strip() else: @@ -385,7 +422,10 @@ def read_slots(ws, lay: Layout) -> list[Slot]: != str(ws.cell(r, c).value or "").lstrip()), "") indent = (len(raw_desc) - len(raw_desc.lstrip()) if raw_desc != raw_desc.lstrip() else 0) - out.append(Slot(row=r, number=num, desc=desc, indent=indent)) + slot = Slot(row=r, number=num, desc=desc, indent=indent) + slot._bare = not priced # does this row carry figures of its own? + slot._advanced = False + out.append(slot) return out