Stop a rate landing in the wrong line

The engineer found it: '.02 The use of equipment referred to in item
110.04.01' had no rate, while the row above it had been given R764.75 - which
is '.03 Excavation by hand's rate. A wrong figure, in the wrong line, that
reads as a normal price.

The line is 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

Those halves were separate candidates, so a neighbouring line could match the
wrong one. The figures row now becomes the slot's row - which is where the
columns are - while keeping the joined description so matching still sees the
whole line.

Also found while in there: 'Carried forward' rows carry text in the item
column, so they were being treated as placeable lines and the real line above
merged down into one. They are now never placement targets.

The counts look slightly worse and the file is actually better:

              written  unplaced   the block in question
  before        196       22    '.02' row carried R764.75 - wrong
  after         191       24    '.02' blank and listed; '.03' R764.75 and
                                     '.04' R25 000 both correct

Four more lines need a click. One wrong rate in a neighbouring line is worth
more than four clicks - a blank is visible and safe, a wrong price is not.

Blueberry is unchanged at 103 written / 1 unplaced, so the simpler template is
untouched. Zero formulas damaged on both, gate not blocked.
This commit is contained in:
MEGA INFRA 2026-10-04 02:20:01 +02:00
parent 68cf65e544
commit d269c66b83
2 changed files with 76 additions and 3 deletions

View file

@ -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

View file

@ -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