fix(B08): 규준틀 밑수를 표 아래 [주] 에서 읽음 — 금액이 못 붙던 자리

B09 제보. 규준틀 둘(FP-11-02·FP-11-03)이 in_bill=true 로 서는데
「밑수 미확보」라 금액이 안 붙고 있었음. 개소 수는 이미 닫혔는데
그 표의 밑수 자체가 비어 있던 것.

「원문에 있었음」 아홉 번째 — 이번엔 표 **아래** [주] 였음
  [주]③ 「목재의 손율은 **1개소 사용당** 50%로 한다」
  뜻으로도 개소가 맞음 — [주]② 가 「본 품은 …한 비탈규준틀의 제작·도색·
  가설·철거를 포함한 것」이라 규준틀 하나를 말하고, [주]① 이 「20m마다 설치」

⚠ 아주 좁게 잡음 — 「N개소 **사용** 당」 표기는 품셈 전문에 딱 두 곳뿐이고
  둘 다 규준틀임. 「개소당 평균면적」 같은 흔한 말은 「사용」이 없어 안 걸림
  (넓히면 조림 2장이 통째로 걸림 — 실측 19건).
  다음 표(|)나 다음 절(###)을 만나면 멈춤 — 남의 [주] 를 물어 오면 조용히 틀림.

효과: 밑수 확보 183 → 185, 미확보 137 → 135.

시험 넷 — 규준틀에서 읽는 것 · 흔한 말에 안 걸리는 것 · 다음 절로 안 넘어가는 것 ·
다시 만든 마스터에 실제로 실렸는지 정본 대조.

시험: 685 passed · 24 skipped (B05 코리도 1건 기존 깨짐, 무관).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-08 08:35:27 +09:00
co-authored by Claude Opus 5
parent deddee6989
commit fc867d3220
4 changed files with 38 additions and 26 deletions
@@ -290,6 +290,16 @@ SOURCE_BASIS_RE = re.compile(
#: 표 위로 몇 줄까지 거슬러 볼 것인가. 더 올라가면 **앞 표의 밑수**를 잘못 물어 온다. #: 표 위로 몇 줄까지 거슬러 볼 것인가. 더 올라가면 **앞 표의 밑수**를 잘못 물어 온다.
SOURCE_LOOKBACK = 6 SOURCE_LOOKBACK = 6
#: 표 **아래 [주]** 에만 밑수가 적힌 자리 — 「목재의 손율은 **1개소 사용당** 50%로 한다」.
#: ⚠ **아주 좁게 잡는다.** 「N개소 **사용** 당」이라는 표기는 품셈 전문에 **딱 두 곳**뿐이고
#: (11-2 비탈 규준틀 · 11-3 수평 규준틀) 둘 다 개소로 세는 표다. 「개소당 평균면적」 같은
#: 흔한 말은 「사용」이 없어 안 걸린다 — 넓히면 조림 2장이 통째로 걸린다(실측 19건).
#: ⚠ 뜻으로도 개소가 맞다 — [주]② 가 「본 품은 …한 **비탈규준틀**의 제작·도색·가설·철거를
#: 포함한 것」이라 **규준틀 하나**를 말하고, [주]① 이 「20m마다 **설치**」라 센다.
SOURCE_BASIS_NOTE_RE = re.compile(r"([\d,]*\.?\d*)\s*개소\s*사용\s*당")
#: 표 아래로 몇 줄까지 볼 것인가. [주] 는 표 바로 밑에 붙는다.
SOURCE_NOTE_LOOKAHEAD = 8
def basis_from_source(lines: list[str], line_no: int) -> tuple[float | None, str | None]: def basis_from_source(lines: list[str], line_no: int) -> tuple[float | None, str | None]:
"""표 바로 위 본문에서 밑수를 읽는다. 못 찾으면 `(None, None)` — 1 로 단정하지 않는다. """표 바로 위 본문에서 밑수를 읽는다. 못 찾으면 `(None, None)` — 1 로 단정하지 않는다.
@@ -319,6 +329,20 @@ def basis_from_source(lines: list[str], line_no: int) -> tuple[float | None, str
except ValueError: except ValueError:
quantity = 1.0 quantity = 1.0
return quantity, unit return quantity, unit
# 표 **아래 [주]** 도 본다 — 위에 아무것도 없을 때만. 규준틀 둘이 그 자리다.
# ⚠ 다음 표(`|`)나 다음 절(`###`)을 만나면 멈춘다 — 남의 [주]를 물어 오면 조용히 틀린다.
for index in range(line_no, min(len(lines), line_no + SOURCE_NOTE_LOOKAHEAD)):
text = lines[index].strip()
if text.startswith("###"):
break
if m := SOURCE_BASIS_NOTE_RE.search(text):
raw = (m.group(1) or "").replace(",", "")
try:
quantity = float(raw) if raw else 1.0
except ValueError:
quantity = 1.0
return quantity, "개소"
return None, None return None, None
@@ -1,7 +1,7 @@
{ {
"schema_version": "1.0", "schema_version": "1.0",
"dataset_id": "data_work_item_master_manifest", "dataset_id": "data_work_item_master_manifest",
"generated_at": "2026-09-08T06:00:25+09:00", "generated_at": "2026-09-08T08:33:10+09:00",
"built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py", "built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py",
"source": { "source": {
"dataset_id": "pum_forest", "dataset_id": "pum_forest",
@@ -12,8 +12,8 @@
"files": [ "files": [
{ {
"file": "work_item_master_2026-01-01.json", "file": "work_item_master_2026-01-01.json",
"sha256": "4d89c1eec7929c39d5adb36547b03f4675205b10f6b629eeb2f246a1d9230dfd", "sha256": "3752af1e0328a2faf4947268e449e0bc602a0738cc6a29a78ed71d343cf2e583",
"size_bytes": 856818 "size_bytes": 856832
}, },
{ {
"file": "form_undetermined_2026-01-01.json", "file": "form_undetermined_2026-01-01.json",
@@ -22,8 +22,8 @@
}, },
{ {
"file": "basis_missing_2026-01-01.json", "file": "basis_missing_2026-01-01.json",
"sha256": "9d7b9ee8df65be1c944a2e0af9efe0742bd9a356b367fc4de8eec47b3ece2147", "sha256": "244851609c65ba8adbfc318668224cbf3a81032e9271cfda916de435d460dd92",
"size_bytes": 18627 "size_bytes": 18360
} }
] ]
} }
@@ -694,18 +694,6 @@
"pum_form": "requirement", "pum_form": "requirement",
"line": 5894 "line": 5894
}, },
{
"pum_table_id": "F0326",
"section": "11-2. 토공의 비탈 규준틀",
"pum_form": "requirement",
"line": 6043
},
{
"pum_table_id": "F0327",
"section": "11-3. 수평규준틀",
"pum_form": "requirement",
"line": 6056
},
{ {
"pum_table_id": "F0337", "pum_table_id": "F0337",
"section": "12-5. 문양거푸집(07m)", "section": "12-5. 문양거푸집(07m)",
@@ -2,7 +2,7 @@
"schema_version": "1.0", "schema_version": "1.0",
"dataset_id": "work_item_master_forest", "dataset_id": "work_item_master_forest",
"effective_date": "2026-01-01", "effective_date": "2026-01-01",
"generated_at": "2026-09-08T06:00:25+09:00", "generated_at": "2026-09-08T08:33:10+09:00",
"dataset_version": { "dataset_version": {
"dataset_id": "pum_forest", "dataset_id": "pum_forest",
"effective_date": "2026-01-01", "effective_date": "2026-01-01",
@@ -21,8 +21,8 @@
"tables_attached": 456, "tables_attached": 456,
"tables_orphan": 19, "tables_orphan": 19,
"form_undetermined": 77, "form_undetermined": 77,
"basis_found": 183, "basis_found": 185,
"basis_missing": 137, "basis_missing": 135,
"basis_grouped": 36 "basis_grouped": 36
}, },
"orphan_tables": [ "orphan_tables": [
@@ -34136,9 +34136,9 @@
"source_line": 6043, "source_line": 6043,
"pum_form": "requirement", "pum_form": "requirement",
"form_basis": "직종 표기((인)·인부·공)", "form_basis": "직종 표기((인)·인부·공)",
"basis_quantity": null, "basis_quantity": 1.0,
"basis_unit": null, "basis_unit": "개소",
"basis_source": null, "basis_source": "본문",
"resource_shares": {}, "resource_shares": {},
"partial_ratio": false, "partial_ratio": false,
"expression_cells": [], "expression_cells": [],
@@ -34185,9 +34185,9 @@
"source_line": 6056, "source_line": 6056,
"pum_form": "requirement", "pum_form": "requirement",
"form_basis": "직종 표기((인)·인부·공)", "form_basis": "직종 표기((인)·인부·공)",
"basis_quantity": null, "basis_quantity": 1.0,
"basis_unit": null, "basis_unit": "개소",
"basis_source": null, "basis_source": "본문",
"resource_shares": {}, "resource_shares": {},
"partial_ratio": false, "partial_ratio": false,
"expression_cells": [], "expression_cells": [],