diff --git a/B09_Estimation/B09_Estimation_KnownGaps.py b/B09_Estimation/B09_Estimation_KnownGaps.py index 36681467..30a62286 100644 --- a/B09_Estimation/B09_Estimation_KnownGaps.py +++ b/B09_Estimation/B09_Estimation_KnownGaps.py @@ -44,13 +44,13 @@ KNOWN_GAPS: dict[str, tuple[str, str]] = { "ⓘ 커플링밴드는 값이 표에 없지만 [주]① 이 「**1EA/6m**」를 줍니다 — 자재가 서면 " "m 당 1/6 EA 로 셀 수 있습니다(다만 「필요시 별도 산정」이라 조건이 하나 더 붙습니다).", ), - # 2026-09-14 브레인 — 조용히 틀린 값이 이미 서 있어 **표 모양 본작업보다 사유를 먼저** 닮. + # 2026-09-14 ⑤ 판정 Ⓓ·Ⓔ — 두 표를 갈래로 읽은 뒤 남는 [주] 둘. 값을 짓지 않고 말만 둠. "FP-09-19-01": ( - "값 불완전", - "⚠ 이 값은 **불완전**합니다 — 원문 9-19-1 은 표 둘" - "(① 절토면 고르기: 토질 6갈래 × 보통인부·공기압축기·소형브레이커·굴착기, 10㎡당" - " · ② 성토면 고르기: 인력 2갈래·기계 1갈래)인데 지금은 두 표가 섞여 보통인부 한 줄만" - " 섰습니다. 토질·절토/성토 갈래 읽기 전에는 이 값을 쓰지 말 것.", + "원문 [주]", + "ⓘ 절토면 암 갈래(연암·보통암ㆍ경암): [주]③ 「소형브레이커 조작 인력품은 착암공으로 한다」" + " — 표에 착암공 수량이 없어 줄을 안 세움(보통인부는 표 그대로). " + "ⓘ 절토면 풍화암: [주]② 「소형브레이커를 사용할 시는 연암 고르기 품을 **준용할 수 있다**」" + " — 선택지라 따로 갈래를 안 만듦(브레이커를 쓰면 연암 갈래를 고를 것).", ), "FP-12-25": ( "운반거리 미정", diff --git a/B09_Estimation/B09_Estimation_ResourceAxis.py b/B09_Estimation/B09_Estimation_ResourceAxis.py index f001cf5f..761ee7f0 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis.py @@ -440,6 +440,11 @@ def match_table( if match_unit_basis_table(node, table, catalog, result): return + # 사람이 모양을 읽어 둔 표(9-19-1 절토면·성토면 · 2026-09-14 판정) — 적어 둔 표만. + from B09_Estimation.B09_Estimation_ResourceAxis_JudgedTable import match_judged_table + if match_judged_table(node, table, catalog, result, basis_quantity, unit): + return + # ⚠ **자원이 열 머리에 오는 표가 따로 있다** (2026-09-08 발견, 39 표). # 「구 분 | 콘크리트공(인) | 보통인부(인)」처럼 **열이 자원**이고 행은 규격 갈래 # (무근·철근·소형구조물)다. 행을 자원으로 읽는 길로 보내면 통째로 못 맞춘다 — @@ -575,8 +580,8 @@ def match_table( # 지우고 있었음). 카탈로그에 있는 이름은 **정의상 자원**이다. # 범위 별칭 — 그 공종 범위 안에서만 카탈로그 쪽 이름으로 바꾼다(「화약공」 → 화약취급공). # 별칭이 코드를 적었으면 그 항목으로 곧장(규격이 조인 키인 기계가 다시 흐려지지 않게). - entry = scoped_alias_entry(catalog, name_cell, node["work_item_code"]) - name_cell = apply_scoped_alias(catalog, name_cell, node["work_item_code"]) + entry = scoped_alias_entry(catalog, name_cell, node["work_item_code"], value_cells) + name_cell = apply_scoped_alias(catalog, name_cell, node["work_item_code"], value_cells) entry = entry or _resolve_cell(catalog, name_cell, [name_cell, *value_cells]) if entry is None: if is_non_resource_label(name_cell): diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_Join.py b/B09_Estimation/B09_Estimation_ResourceAxis_Join.py index 8716e524..00d1cc53 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis_Join.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis_Join.py @@ -107,14 +107,30 @@ def load_scoped_aliases() -> list[dict[str, str]]: return load_aliases("resource") +#: 옆 칸 규격 — 「0.6㎥」·「10.3㎥/min」. 값「0.09」·식「(2.45+3.05)/2/10㎡」·단위「hr」 는 아님. +_RE_SIDE_SPEC = re.compile(r"^\d+(?:\.\d+)?(?:㎥|m3|㎥/min|㎥/분|ton|톤|㎾|kW|㎜|mm|㎝|cm|인치)$") + + +def _writes_spec(side_cells: list[str] | tuple[str, ...]) -> bool: + """옆 칸이 규격을 적었나 — 이름이 같아도 별칭이 그 규격을 덮으면 안 됨.""" + return any(_RE_SIDE_SPEC.match(_normalize(cell)) for cell in side_cells) + + def scoped_alias_entry( - catalog: ResourceCatalog, name_cell: str, work_item_code: str + catalog: ResourceCatalog, + name_cell: str, + work_item_code: str, + side_cells: list[str] | tuple[str, ...] = (), ) -> CatalogEntry | None: """범위 별칭이 가리키는 **항목 그 자체**(코드로 곧장) — 없으면 `None`. ⚠ 이름만 돌려주면 규격이 조인 키인 기계(소형브레이커 공압식 넷)는 다시 「규격 미정」 으로 흐려짐 — 별칭이 코드를 적었으면 그 코드가 곧 답(2026-09-14 · 9-19-3 소형브레이커). + ⚠ 별칭은 **빈 곳을 채움** — 옆 칸이 규격을 적었으면 안 덮음(같은 9-19-1 성토면 「굴착기 | + 0.6㎥」 가 절토면 [주]① 0.7 로 덮이던 자리 · 2026-09-14 판정 Ⓐ). """ + if _writes_spec(side_cells): + return None wanted = _normalize(name_cell) for row in catalog.scoped_aliases: if _normalize(row["from"]) != wanted or not in_scope(work_item_code, row["scope"]): @@ -123,8 +139,15 @@ def scoped_alias_entry( return None -def apply_scoped_alias(catalog: ResourceCatalog, name_cell: str, work_item_code: str) -> str: +def apply_scoped_alias( + catalog: ResourceCatalog, + name_cell: str, + work_item_code: str, + side_cells: list[str] | tuple[str, ...] = (), +) -> str: """범위 안이면 카탈로그 쪽 **이름**으로 바꾼다. 범위 밖이거나 대상 코드가 없으면 원문 그대로.""" + if _writes_spec(side_cells): + return name_cell # 이름만 갈면 옆 칸 규격으로 대상 계열(무한궤도)이 조용히 골라짐 wanted = _normalize(name_cell) for row in catalog.scoped_aliases: if _normalize(row["from"]) != wanted or not in_scope(work_item_code, row["scope"]): diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_JudgedTable.py b/B09_Estimation/B09_Estimation_ResourceAxis_JudgedTable.py new file mode 100644 index 00000000..c5318d07 --- /dev/null +++ b/B09_Estimation/B09_Estimation_ResourceAxis_JudgedTable.py @@ -0,0 +1,171 @@ +"""B09 원가계산 — **사람이 모양을 읽어 둔 표** (자원 축 보조 · 2026-09-14 ⑤ 브레인 판정 Ⓐ~Ⓔ). + +9-19-1 토사면 고르기(고시 2025-82 원문 L5407~5436)는 한 절에 표가 둘이고 둘 다 일반 길에 안 맞음. + + F0288 1. 절토면 고르기 자원 이름이 **첫 자료 줄**(「보통인부 (인)」 …) · 규격은 [주]① 에만 + · 「·」 = 그 토질엔 그 자원 없음 + F0289 2. 성토면 고르기 병합 첫 칸(시공) 탓에 둘째 줄이 한 칸 앞당겨 옴 + +⚠ 일반 보정으로 넓히지 않음 — 밀린 줄 고쳐 읽기는 목재틀흙막이 503만원 전례(`_Transposed` 머리말). + **적어 둔 표만** 읽고, 칸이 판정과 다르면 한 줄도 안 세우고 막음. +⚠ 규격은 범위 별칭(`data_aliases` scope FP-09-19-01 · [주]①)이 코드로 이음 — 칸이 규격을 적었으면 + 별칭이 안 덮음(`scoped_alias_entry`). 성토면 굴착기 0.6㎥ 는 무한궤도·타이어 둘이라 규격 미정(Ⓑ). +""" + +from __future__ import annotations + +import re +from decimal import Decimal +from typing import Any + +from B09_Estimation.B09_Estimation_ResourceAxis import ( + AxisResult, + ResourceCatalog, + ResourceRow, + UnmatchedRow, + _resolve_cell, + parse_amount, +) +from B09_Estimation.B09_Estimation_ResourceAxis_Join import scoped_alias_entry, unmatched_reason + +#: 표 번호 → (공종 · 모양 · 갈래 앞말 · 판정 근거). 여기 없는 표는 이 길로 안 읽음. +JUDGED_TABLES: dict[str, dict[str, Any]] = { + "F0288": { + "code": "FP-09-19-01", + "shape": "header_row", + "prefix": "절토면", + "why": "원문 L5414 「1. 절토면 고르기 (단위: 10㎡당)」 — 자원 머리가 첫 자료 줄", + }, + "F0289": { + "code": "FP-09-19-01", + "shape": "merged_first", + "prefix": "성토면", + # 원문 L5430 표 — 「인력시공」 칸이 두 줄 병합이라 둘째 줄(모래 또는 사질토)이 앞당겨 옴. + "merged_rows": (1,), + "why": "원문 L5430 「2. 성토면 고르기 (단위: 10㎡당)」 — 시공 칸 병합으로 둘째 줄 앞당김", + }, +} + +#: 「보통인부 (인)」 의 단위 꼬리 — 규격이 아님. +_UNIT_TAIL = re.compile(r"\s*[((](?:인|시간)[))]\s*$") +_ABSENT = frozenset({"·", "ㆍ", "-", "-", "–"}) + + +def _entry(catalog: ResourceCatalog, name_cell: str, code: str, side: tuple[str, ...] = ()): + return scoped_alias_entry(catalog, name_cell, code, side) or _resolve_cell( + catalog, name_cell, [name_cell, *side] + ) + + +def _row( + code: str, table: dict[str, Any], entry, amount: Decimal, unit: str, index: int, variant: str +): + return ResourceRow( + work_item_code=code, + pum_table_id=str(table.get("pum_table_id", "")), + pum_form=str(table.get("pum_form", "")), + resource_kind=entry.kind, + resource_code=entry.code, + resource_name=entry.name, + resource_spec=entry.spec, + amount=amount, + amount_unit=unit, + raw_row_index=index, + variant=variant, + ) + + +def match_judged_table( + node: dict[str, Any], + table: dict[str, Any], + catalog: ResourceCatalog, + result: AxisResult, + basis_quantity: Decimal | None, + unit: str, +) -> bool: + """적어 둔 표를 판정대로 읽음. 그 표가 아니면 `False`.""" + code = str(node.get("work_item_code") or "") + table_id = str(table.get("pum_table_id", "")) + judged = JUDGED_TABLES.get(table_id) + if judged is None or judged["code"] != code: + return False + rows = [[str(c).strip() for c in row] for row in (table.get("raw_row") or [])] + + def block(why: str) -> bool: + result.unmatched.append(UnmatchedRow(code, table_id, judged["prefix"], why)) + result.partial_items[code] = why + return True + + if basis_quantity in (None, 0): + return block("판정 표에 밑수가 없습니다") + if judged["shape"] == "header_row": + staged = _header_row(code, table, judged, rows, catalog, basis_quantity, unit) + else: + staged = _merged_first(code, table, judged, rows, catalog, basis_quantity, unit) + if isinstance(staged, str): + return block(f"{staged} — 판정({judged['why']})과 칸이 달라 안 읽음") + for item in staged: + if isinstance(item, UnmatchedRow): + result.unmatched.append(item) + else: + result.rows.append(item) + return True + + +def _header_row(code, table, judged, rows, catalog, basis, unit) -> list | str: + """첫 줄 = 자원 머리 · 이후 줄 = [토질, 값 …]. 머리 자원을 하나라도 못 풀면 표째 막음.""" + names = [_UNIT_TAIL.sub("", cell) for cell in (rows[0] if rows else []) if cell] + entries = [_entry(catalog, name, code) for name in names] + if len(names) < 2 or not all(entries): + missing = [name for name, entry in zip(names, entries) if entry is None] + return f"자원 머리 {missing or names} 을 못 풂" + staged: list = [] + for index, cells in enumerate(rows[1:], start=1): + values, tail = cells[1 : 1 + len(names)], cells[1 + len(names) :] + readable = all(v in _ABSENT or parse_amount(v) is not None for v in values) + if not cells[0] or len(values) != len(names) or any(tail) or not readable: + return f"{index}째 줄 「{cells[0] if cells else ''}」 값 칸" + variant = f"{judged['prefix']} · {cells[0]}" + for value, entry in zip(values, entries): + if value in _ABSENT: + continue # 그 토질엔 이 자원이 안 듦 + staged.append( + _row(code, table, entry, parse_amount(value) / basis, unit, index, variant) + ) + return staged + + +def _merged_first(code, table, judged, rows, catalog, basis, unit) -> list | str: + """[시공, 토질, 구분, 규격, 단위, 수량] — 병합 줄은 앞 줄 시공을 이어받아 한 칸 되돌림.""" + width = len(table.get("condition_note") or []) + if width != 6: + return f"머리 {width} 칸" + lines: list[list[str]] = [] + for index, cells in enumerate(rows): + if index in judged["merged_rows"]: + if not lines or cells[-1]: + return f"{index}째 줄 병합 칸" + cells = [lines[-1][0], *cells[:-1]] + lines.append(cells) + methods = [cells[0] for cells in lines] + staged: list = [] + for index, cells in enumerate(lines): + if len(cells) != width: + return f"{index}째 줄 칸 {len(cells)}" + method, soil, name, size, _unit_word, value = cells + amount = parse_amount(value) + if not method or not name or amount is None: + return f"{index}째 줄 「{method}」 값 칸" + short = method.removesuffix("시공") + variant = " · ".join( + [judged["prefix"], short, *([soil] if methods.count(method) > 1 else [])] + ) + entry = _entry(catalog, name, code, (size,) if size else ()) + if entry is None: + # 그 갈래만 안 섬(자원이 하나뿐) — 사유는 못 맞춘 줄로(Ⓑ 굴착기 0.6㎥ 형식 둘). + label = f"{name} {size}".strip() + reason = unmatched_reason(catalog, name) + staged.append(UnmatchedRow(code, str(table.get("pum_table_id", "")), label, reason)) + continue + staged.append(_row(code, table, entry, amount / basis, unit, index, variant)) + return staged diff --git a/resources/data_aliases/aliases_2026-01-01.json b/resources/data_aliases/aliases_2026-01-01.json index d1bfac04..035083df 100644 --- a/resources/data_aliases/aliases_2026-01-01.json +++ b/resources/data_aliases/aliases_2026-01-01.json @@ -19,6 +19,30 @@ "pum_edition": "2026-01-01", "basis": "9-19-3 비탈면 고르기(발파암) 표가 규격을 안 적음 — 그 값 「(2.45+3.05)/2/10㎡」 은 9-19-1 토사면 고르기 표(연암 2.45·보통암+경암 3.05)에서 왔고 9-19-1 [주]① 「소형브레이커는 1㎥/분」 → 소형브레이커(공압식) 1.0㎥/min(9-19-3 ← 9-19-1 표 ← [주]①, 값의 출처가 규격을 정함). 2026-09-14 브레인 승인" }, + { + "axis": "resource", + "from": "공기압축기", + "to": "5205-0035", + "scope": "FP-09-19-01", + "pum_edition": "2026-01-01", + "basis": "9-19-1 토사면 고르기 절토면 표(원문 L5414)가 자원 머리에 규격을 안 적음 — [주]① 「공기압축기는 3.5㎥/분, 소형브레이커는 1㎥/분, 굴착기는 0.7㎥를 기준한 것이다」 → 공기압축기(이동식) 3.5. 칸이 규격을 적은 줄(성토면 굴착기 0.6㎥)은 안 덮음. 2026-09-14 브레인 승인(⑤ Ⓐ)" + }, + { + "axis": "resource", + "from": "소형브레이커", + "to": "5210-0010", + "scope": "FP-09-19-01", + "pum_edition": "2026-01-01", + "basis": "9-19-1 토사면 고르기 절토면 표(원문 L5414)가 자원 머리에 규격을 안 적음 — [주]① 「공기압축기는 3.5㎥/분, 소형브레이커는 1㎥/분, 굴착기는 0.7㎥를 기준한 것이다」 → 소형브레이커(공압식) 1.0㎥/min. 칸이 규격을 적은 줄(성토면 굴착기 0.6㎥)은 안 덮음. 2026-09-14 브레인 승인(⑤ Ⓐ)" + }, + { + "axis": "resource", + "from": "굴착기", + "to": "0201-0070", + "scope": "FP-09-19-01", + "pum_edition": "2026-01-01", + "basis": "9-19-1 토사면 고르기 절토면 표(원문 L5414)가 자원 머리에 규격을 안 적음 — [주]① 「공기압축기는 3.5㎥/분, 소형브레이커는 1㎥/분, 굴착기는 0.7㎥를 기준한 것이다」 → 굴착기(무한궤도) 0.7(카탈로그 0.7 은 무한궤도뿐). 칸이 규격을 적은 줄(성토면 굴착기 0.6㎥)은 안 덮음. 2026-09-14 브레인 승인(⑤ Ⓐ)" + }, { "axis": "variant", "from": "리핑암", diff --git a/resources/tester/test_b09_table_reading_2.py b/resources/tester/test_b09_table_reading_2.py index a8dd2e5f..cc653034 100644 --- a/resources/tester/test_b09_table_reading_2.py +++ b/resources/tester/test_b09_table_reading_2.py @@ -61,13 +61,85 @@ def test_비탈면_고르기_발파암_소형브레이커는_1_0_규격으로_ def test_토사면_고르기는_두_표가_섞여_값이_불완전하다고_먼저_말함() -> None: - """⑤ 사유 먼저(2026-09-14 브레인) — 원문은 절토면·성토면 두 표인데 지금은 섞여 보통인부 한 줄만 - 섬(조용히 틀린 값). 표 모양 본작업 전에 누가 그 값을 믿고 쓰지 않게 비고·본표에 뜸.""" + """⑤ 사유 먼저(2026-09-14 브레인) — 원문은 절토면·성토면 두 표인데 섞여 보통인부 한 줄만 + 섰었음(조용히 틀린 값). ⑤ 본작업(같은 날)으로 두 표를 갈래로 읽어 「불완전」 사유와 섞인 + 본표(B-FP-09-19-01)가 함께 걷힘 — 갈래 없는 섞인 값이 다시 서면 빨강.""" + assert "불완전" not in known_gap_note("FP-09-19-01") + assert "B-FP-09-19-01" not in cached_build().book.titles + + +#: ⑤ 9-19-1 원문 L5414~5436 — (단위: 10㎡당)이라 ㎡당 = 표값 ÷ 10. +CUT = "B-FP-09-19-01#절토면·" # 갈래 키는 공백을 지움 +FILL = "B-FP-09-19-01#성토면·" + + +def _per_m2(code: str) -> dict[str, float]: + """자원 줄(비율 줄 「%」 뺌) → ㎡당 수량.""" from B09_Estimation.B09_Estimation_UnitPrice import detail_of + rows = detail_of(cached_build(), code)["rows"] + return {str(r["ref_code"]): float(r["quantity"]) for r in rows if r.get("unit") != "%"} + + +def test_토사면_고르기_절토면은_토질_여섯_갈래로_규격까지_이어_섬() -> None: + """[주]① 공기압축기 3.5㎥/분 · 소형브레이커 1㎥/분 · 굴착기 0.7㎥ — 범위 별칭이 코드로 이음 + (카탈로그 0.7 은 무한궤도뿐). 「·」 는 그 갈래에 없음(칸 자리만 지킴).""" + titles = cached_build().book.titles + cut = sorted(code for code in titles if code.startswith(CUT)) + assert len(cut) == 6, cut + sand = _per_m2(CUT + "모래ㆍ사질토ㆍ점토ㆍ점질토") + assert sand == {"1002": 0.005, "X-0201-0070": 0.015}, sand + soft_rock = _per_m2(CUT + "연암") + assert soft_rock == {"1002": 0.046, "X-5210-0010": 0.245}, soft_rock + # 공기압축기 3.5 는 코드까지 이어지나 기계 단가 층(손료) 미확보 — 9-19-3 과 같은 종전 자리 + from B09_Estimation.B09_Estimation_ResourceAxis import ( + build_resource_axis, + load_combined_catalog, + ) + + axis = build_resource_axis(load_work_item_master(), load_combined_catalog()) + rock = { + (r.resource_code, float(r.amount)) + for r in axis.rows + if r.work_item_code == "FP-09-19-01" and r.variant == "절토면 · 연암" + } + assert rock == {("1002", 0.046), ("5205-0035", 0.125), ("5210-0010", 0.245)}, rock + + +def test_토사면_고르기_성토면_인력_두_갈래_기계는_규격_미정으로_막힘() -> None: + """Ⓒ 둘째 줄이 병합 칸 탓에 한 칸 밀려 옴 — 사람 판정표로만 읽음(일반 보정 금지 · 목재틀흙막이 + 503만원 전례). Ⓑ 굴착기 0.6㎥ 는 무한궤도·타이어 둘이라 안 고름 — 그 갈래는 안 섬.""" + build = cached_build() + assert _per_m2(FILL + "인력·점토또는점질토") == {"1002": 0.019} + assert _per_m2(FILL + "인력·모래또는사질토") == {"1002": 0.017} + assert not [code for code in build.book.titles if code.startswith(FILL + "기계")] + left = build.unattached.get("FP-09-19-01", []) + assert any("굴착기" in label for label in left), left + + +def test_범위_별칭은_칸이_규격을_적었으면_덮지_않음() -> None: + """Ⓐ 별칭은 빈 곳을 채우는 것 — 같은 공종 성토면 「굴착기 0.6㎥」 가 0.7 로 덮이면 안 됨.""" + from B09_Estimation.B09_Estimation_ResourceAxis import load_combined_catalog + from B09_Estimation.B09_Estimation_ResourceAxis_Join import ( + apply_scoped_alias, + scoped_alias_entry, + ) + + catalog = load_combined_catalog() + assert scoped_alias_entry(catalog, "굴착기", "FP-09-19-01").code == "0201-0070" + side = ["0.6㎥", "시간", "0.09"] + assert scoped_alias_entry(catalog, "굴착기", "FP-09-19-01", side) is None + # 이름만 갈아도 옆 칸 0.6 으로 무한궤도가 조용히 골라짐 — 이름도 그대로 + assert apply_scoped_alias(catalog, "굴착기", "FP-09-19-01", side) == "굴착기" + # 값·식 칸은 규격이 아님 — 9-19-3 소형브레이커 줄(값 0.275 · 식)은 그대로 이음 + value = ["hr", "0.275", "(2.45+3.05)/2/10㎡"] + assert scoped_alias_entry(catalog, "소형브레이커", "FP-09-19-03", value).code == "5210-0010" + + +def test_토사면_고르기_원문_주는_값을_안_짓고_비고로() -> None: + """Ⓓ [주]③ 착암공은 수량이 없어 줄을 안 지음 · Ⓔ [주]② 풍화암 연암 준용은 선택이라 갈래 아님.""" note = known_gap_note("FP-09-19-01") - assert "절토면" in note and "성토면" in note and "불완전" in note - assert "불완전" in detail_of(cached_build(), "B-FP-09-19-01")["known_gap_note"] + assert "착암공" in note and "준용" in note, note def test_제근은_빌린_밑수가_확정이라_갈래가_서면_내역_금액이_섬() -> None: