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:
parent
68cf65e544
commit
d269c66b83
2 changed files with 76 additions and 3 deletions
33
README.md
33
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue