diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Boundaries.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Boundaries.py index f1ee3631..9495cf5c 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Boundaries.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Boundaries.py @@ -38,11 +38,11 @@ def _pickers(structure: dict[str, Any], mapping: WorkItemMapping) -> list[tuple[ """이 구조물의 성분을 **이름으로 집는** 공종 줄 — (자리, 이름들). 집는 조건은 각 빌더와 같다.""" type_id = str(structure.get("type_id") or "") entry = mapping.for_structure(type_id) or {} - composite = mapping.composite_for(type_id) + composite = mapping.composite_for(type_id, structure) found: list[tuple[str, set[str]]] = [] if entry.get("billing_component"): found.append(("구조물 줄", {str(entry["billing_component"])})) - if composite and not entry.get("work_item_code"): + if composite: # 걸린 묶음은 곧장 잇는 코드보다 위(빌더와 같은 조건) for part in composite.get("parts") or []: if isinstance(part, dict): label = f"묶음 조각 「{part.get('name') or part.get('code')}」" diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py index 822d67b7..c18ff976 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py @@ -271,13 +271,20 @@ class WorkItemMapping: return row return None - def composite_for(self, type_id: str) -> dict[str, Any] | None: + def composite_for( + self, type_id: str, structure: dict[str, Any] | None = None + ) -> dict[str, Any] | None: """품셈에 그 이름의 공종이 없어 **여러 공종을 묶는** 자리인가. 빈 코드로 두면 「매핑을 못 찾은 줄」과 구별이 안 된다 — 묶음을 적어 구별한다. """ + options = (structure or {}).get("options") or {} for row in self.composite.get("items") or []: - if row.get("type_id") == type_id: + # `when` — 같은 종류라도 그 제원일 때만 묶음(콘크리트 집수정만 12-15 조립 · 09-14 ⑸). + when = row.get("when") or {} + if row.get("type_id") == type_id and all( + str(options.get(key) or "") == str(value) for key, value in when.items() + ): return row return None @@ -483,10 +490,12 @@ def rebar_complexity( continue if row.get("form") == form: if row.get("class"): - return str(row["class"]), f"품셈 12-3 [주]① 「{row.get('matched')}」" + basis = row.get("basis") or "품셈 12-3 [주]①" + return str(row["class"]), f"{basis} 「{row.get('matched')}」" return None, str(row.get("why") or "원문 예시에 없음") if fallback and fallback.get("class"): - return str(fallback["class"]), f"품셈 12-3 [주]① 「{fallback.get('matched')}」" + basis = fallback.get("basis") or "품셈 12-3 [주]①" # 그 공종 표가 직접 적으면 그 표(12-15) + return str(fallback["class"]), f"{basis} 「{fallback.get('matched')}」" from B08_Quantity.B08_Quantity_Wording import type_label detail = f"({form})" if form else "(형식이 아직 입력되지 않음)" diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py index db1d6ce0..f9c80c27 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py @@ -433,7 +433,11 @@ def _structure_rows( # m 수량에 곱해 **2.6배** 금액이 섰다(2026-09-08 실증). 어느 성분으로 세는지는 # 매핑이 말한다(`billing_component`) — 코드가 짐작하지 않는다. billing = _component_billing(structure, entry) - composite = mapping.composite_for(type_id) if code is None else None + composite = mapping.composite_for(type_id, structure) + if composite: + code = None # 묶음이 걸리면 곧장 잇는 코드보다 위(콘크리트 집수정 12-15 조립 · 09-14) + if composite.get("outside_note"): + class_basis = " · ".join(p for p in (class_basis, composite["outside_note"]) if p) kind = structure_kind(structure) if composite else None parts: list[dict[str, Any]] | None = None parts_missing: list[dict[str, Any]] = [] @@ -600,7 +604,7 @@ def _placing_rows( # 2026-09-08 B09 가 「철근이 겹치나」를 물어 그 김에 드러난 자리다 — # 철근은 안 겹치고(자재는 재료·묶음 조각은 품, 재료 0원) **타설이 겹쳤다.** # ⚠ 묶음이 아직 미확보라 지금은 값이 안 걸렸을 뿐, 묶음이 서는 날 두 번이 된다. - if mapping.composite_for(str(structure.get("type_id") or "")) or structure.get( + if mapping.composite_for(str(structure.get("type_id") or ""), structure) or structure.get( "unconfirmed" ): continue # 기본값으로 선 구조물도 — 금액에 안 듦 diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Trench.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Trench.py index 28cfe826..9a2bdbab 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Trench.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Trench.py @@ -166,7 +166,7 @@ def rubble_base_rows( total = 0.0 bases: list[str] = [] for structure in unit_quantity_table.get("structures") or []: - if mapping.composite_for(str(structure.get("type_id") or "")) or structure.get( + if mapping.composite_for(str(structure.get("type_id") or ""), structure) or structure.get( "unconfirmed" ): continue # 묶음 조각이 품음 · 기본값으로 선 구조물은 금액에 안 듦 diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_JudgedTable.py b/B09_Estimation/B09_Estimation_ResourceAxis_JudgedTable.py index 304f29ad..f5ccccdf 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis_JudgedTable.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis_JudgedTable.py @@ -55,7 +55,18 @@ JUDGED_TABLES: dict[str, dict[str, Any]] = { "prefix": "합판거푸집", "why": "원문 L6187 「기준수량(1회사용) · 사용횟수별기준수량에대한 비율(%) 재료별·노무비」", }, + "F0353": { + "code": "FP-12-15", + "shape": "remark_labor", + "prefix": "집수정", + # 구체콘크리트는 바로 아래 다짐기 줄과 한 갈래 — 다짐기가 안 풀리면 갈래를 안 세움(⑴). + "needs_machine": {"구체콘크리트": "다짐:봉상후렉시블(45mm)"}, + "why": "원문 L6460 「구체·버림콘크리트 ㎥ — 비고 칸 콘크리트공·보통인부 인/㎥」", + }, } +#: 비고 칸 인력 — 「콘크리트공0.24인/㎥, 보통인부 0.42인/㎥」. +_RE_REMARK_LABOR = re.compile(r"([가-힣]+)\s*(\d+(?:\.\d+)?)\s*인/㎥") +_RE_Q = re.compile(r"Q\s*=\s*(\d+(?:\.\d+)?)") #: 비율 줄 — 「1회사용시 2회사용시 …」 칸. _RE_USE_COUNT = re.compile(r"(\d+)회사용시") #: 값으로 안 읽는 줄 — 사용고재 평가기준(원문이 셈을 안 줌 · 사유는 `KNOWN_GAPS`) · 비고. @@ -117,6 +128,8 @@ def match_judged_table( staged = _header_row(code, table, judged, rows, catalog, basis_quantity, unit) elif judged["shape"] == "use_count": staged = _use_count(code, table, rows, catalog, basis_quantity, unit) + elif judged["shape"] == "remark_labor": + staged = _remark_labor(code, table, judged, rows, catalog) else: staged = _merged_first(code, table, judged, rows, catalog, basis_quantity, unit) if isinstance(staged, str): @@ -219,3 +232,39 @@ def _use_count(code, table, rows, catalog, basis, unit) -> list | str: amount = base * ratio / Decimal(100) / basis staged.append(_row(code, table, entry, amount, unit, index, f"{count}회")) return staged + + +def _remark_labor(code, table, judged, rows, catalog) -> list | str: + """㎥ 줄 비고 칸의 인력(인/㎥)으로 갈래 — 다짐기가 딸린 갈래는 그 기계가 풀려야 세움.""" + table_id = str(table.get("pum_table_id", "")) + by_name = {"".join(cells[0].split()): cells for cells in rows if cells} + staged: list = [] + for index, cells in enumerate(rows): + labors = _RE_REMARK_LABOR.findall(cells[-1] if cells else "") + if len(cells) < 3 or cells[2] != "㎥" or not labors: + continue + variant = cells[0].split("(")[0].strip() + pieces = [] + for name, amount in labors: + entry = _entry(catalog, name, code) + if entry is None: + return f"{variant} 인력 「{name}」" + pieces.append((entry, Decimal(amount))) + machine_name = judged.get("needs_machine", {}).get(variant) + if machine_name: + machine_cells = by_name.get("".join(machine_name.split())) or [] + found_q = _RE_Q.search(" ".join(machine_cells)) + entry = _entry(catalog, machine_name, code) if machine_cells else None + if entry is None or found_q is None: + reason = unmatched_reason(catalog, machine_name) + staged.append(UnmatchedRow(code, table_id, machine_name, reason)) + why = ( + f"다짐기 「{machine_name}」 가 안 풀려 갈래를 안 세움 — 인력만이면 조립 줄이" + " 조용히 싸짐(2026-09-14 ㉯ ⑴)" + ) + staged.append(UnmatchedRow(code, table_id, variant, why)) + continue + pieces.append((entry, Decimal(1) / Decimal(found_q.group(1)))) + for entry, amount in pieces: + staged.append(_row(code, table, entry, amount, "㎥", index, variant)) + return staged diff --git a/resources/data_rebar/rebar_complexity_2026-01-01.json b/resources/data_rebar/rebar_complexity_2026-01-01.json index acdced1a..12281192 100644 --- a/resources/data_rebar/rebar_complexity_2026-01-01.json +++ b/resources/data_rebar/rebar_complexity_2026-01-01.json @@ -51,9 +51,10 @@ }, { "type_id": "pipe_inlet_basin", - "class": "간단", - "matched": "간단한 기초", - "note": "관보호공 집수정 — 원문의 「간단한 기초」에 해당. ⚠ 벽체까지 간단으로 볼지는 확인 필요" + "class": "보통", + "matched": "철근가공조립(보통)", + "basis": "품셈 12-15 집수정 표", + "note": "그 공종 표가 직접 적은 「철근 | 철근가공조립(보통)」 이 정본 — 12-3 [주]① 「간단한 기초」 추정보다 위(2026-09-14 브레인 ㉯ ⑶ · 거푸집 4회와 같은 원칙). 앞서 간단으로 봤음." } ], "price_hint_krw_per_ton": { diff --git a/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json index 785214d5..44c7de30 100644 --- a/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json +++ b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json @@ -327,6 +327,53 @@ "composite": { "note": "품셈에 **그 이름의 공종이 없어** 여러 공종을 묶어 일위대가로 세우는 자리. 코드 하나로 못 적으므로 묶음을 적어 둔다 — 빈 코드로 두면 「매핑을 못 찾은 줄」과 구별이 안 된다.", "items": [ + { + "type_id": "pipe_inlet_basin", + "when": { + "inlet_basin_material": "콘크리트" + }, + "note": "2026-09-14 브레인 ㉯ ⑴~⑸ — 원문 L6460 12-15 집수정 표는 조립형(구체·버림 인력 · 다짐기 · 거푸집 합판4회 · 철근가공조립(보통)). 콘크리트 집수정만 묶음 · 돌집수정은 종전대로 12-15 곧장(⑸).", + "outside_note": "ⓘ 12-15 표의 집수정 뚜껑(스틸그레이팅 개)·설치비(재료비의 5%)는 조각 모양이 달라 묶음에 안 넣음(⑷ · 2026-09-14)", + "parts": [ + { + "code": "FP-12-15#구체콘크리트", + "name": "구체콘크리트", + "unit": "㎥", + "from_components": [ + "콘크리트" + ] + }, + { + "code": "FP-12-15#버림콘크리트", + "name": "버림콘크리트", + "unit": "㎥", + "from_components": [ + "버림콘크리트" + ], + "why": "□형 원단위(콘크리트 2.84㎥ = 벽 + 바닥기초)에 버림 성분이 없음 — 「설계에 없음 = 0」 으로 짓지 않고 막음(⑵)" + }, + { + "code": "FP-12-04", + "name": "합판거푸집", + "unit": "㎡", + "from_components": [ + "합판거푸집" + ], + "kind_suffix": "formwork_reuse" + }, + { + "code": "FP-12-03", + "name": "철근 현장가공 및 조립", + "unit": "ton", + "from_components": [ + "이형철근 D13", + "이형철근 D16" + ], + "unit_from": "kg", + "kind_suffix": "rebar_complexity" + } + ] + }, { "type_id": "retaining_wall", "parts": [ diff --git a/resources/data_work_item_master/_manifest.json b/resources/data_work_item_master/_manifest.json index 0f31fe5e..4923d032 100644 --- a/resources/data_work_item_master/_manifest.json +++ b/resources/data_work_item_master/_manifest.json @@ -1,7 +1,7 @@ { "schema_version": "1.0", "dataset_id": "data_work_item_master_manifest", - "generated_at": "2026-09-14T18:26:57+09:00", + "generated_at": "2026-09-14T19:15:46+09:00", "built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py", "source": { "dataset_id": "pum_forest", @@ -12,8 +12,8 @@ "files": [ { "file": "work_item_master_2026-01-01.json", - "sha256": "fa4d9bc80627d33a46b203f8910f9cc802c1249f2c6c8f09d2e818fce4332287", - "size_bytes": 838421 + "sha256": "183a2933a2bf6f06fb99c0b86dc4073ec42c12a95784b3dd279f5eee50ee088f", + "size_bytes": 838487 }, { "file": "form_undetermined_2026-01-01.json", diff --git a/resources/data_work_item_master/work_item_master_2026-01-01.json b/resources/data_work_item_master/work_item_master_2026-01-01.json index a01f45d0..dde1989d 100644 --- a/resources/data_work_item_master/work_item_master_2026-01-01.json +++ b/resources/data_work_item_master/work_item_master_2026-01-01.json @@ -3,7 +3,7 @@ "dataset_id": "work_item_master_forest", "effective_date": "2026-01-01", "pum_edition": "2026-01-01", - "generated_at": "2026-09-14T18:26:57+09:00", + "generated_at": "2026-09-14T19:15:46+09:00", "dataset_version": { "dataset_id": "pum_forest", "effective_date": "2026-01-01", @@ -33148,7 +33148,9 @@ "formula_rows": [], "special_glyphs": [], "capacity_formula_here": false, - "variant_key": [], + "variant_key": [ + "버림콘크리트" + ], "condition_note": [ "구 분", "규 격", @@ -33209,7 +33211,9 @@ ] } ], - "variant_keys": [] + "variant_keys": [ + "버림콘크리트" + ] }, { "work_item_code": "FP-12-16", diff --git a/resources/tester/test_b08_basin_composite.py b/resources/tester/test_b08_basin_composite.py new file mode 100644 index 00000000..02f939f1 --- /dev/null +++ b/resources/tester/test_b08_basin_composite.py @@ -0,0 +1,82 @@ +"""12-15 집수정 조립 — 2026-09-14 브레인 ㉯ 남은 몫 판정 ⑴~⑸. + +원문 L6460 12-15 표는 조립형(구체·버림 인력 비고 칸 · 다짐기 · 거푸집 합판4회 · +철근가공조립(보통) · 뚜껑 · 설치비 5%). B08 은 집수정을 12-15 에 곧장(개소) 이어 단가가 안 섰음. +⑴ 다짐기(카탈로그 없음) 빠진 구체 갈래는 안 세움 — 인력만이면 조립 줄이 조용히 싸짐 +⑵ □형 원단위에 버림 성분 없음 → 「성분 미확보」 로 막음(0 은 지어냄) +⑶ 철근 갈래는 12-15 표 「보통」(B08 추정 「간단」 걷음 · 4회와 같은 원칙) +⑷ 뚜껑·설치비는 조각 모양이 달라 조립 밖 · 사유만 +⑸ 돌집수정은 콘크리트 집수정 표가 아니라 종전대로 +목적: 금액을 못 세워도 **조용히 사라지던 것이 사유로 드러남**. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 + +CONCRETE = {"structure_id": "b1", "type_id": "pipe_inlet_basin", "start_m": 50.0, "end_m": 50.0, + "options": {"inlet_basin_form": "□형(기본형)", "inlet_basin_material": "콘크리트", + "pipe_diameter_mm": "800"}} # fmt: skip +STONE = {"structure_id": "b2", "type_id": "pipe_inlet_basin", "start_m": 60.0, "end_m": 60.0, + "options": {"inlet_basin_form": "돌집수정 ㄷ형"}} # fmt: skip + + +def _basin(structure: dict) -> dict: + unit = build_table([structure], {"pipe_inlet_basin": "집수정"}) + handoff = build_handoff(unit_quantity_table=unit) + return next(r for r in handoff["work_items"] if r["origin"] == "structure") + + +def test_버림_갈래는_서고_구체_갈래는_다짐기_없어_안_섬() -> None: + from B09_Estimation.B09_Estimation_UnitPrice import cached_build, detail_of + + build = cached_build() + assert "B-FP-12-15#구체콘크리트" not in build.book.titles + rows = detail_of(build, "B-FP-12-15#버림콘크리트")["rows"] + assert {r["ref_code"]: float(r["quantity"]) for r in rows} == {"1013": 0.15, "1002": 0.27} + left = " ".join(build.unattached.get("FP-12-15", [])) + assert "구체콘크리트" in left and "봉상후렉시블" in left, left + + +def test_콘크리트_집수정은_조립_조각으로_가고_막힌_까닭이_드러남() -> None: + row = _basin(CONCRETE) + assert row["work_item_code"] is None, row + codes = {p["code"]: p for p in row["composite_parts"]} + assert codes["FP-12-15#구체콘크리트"]["quantity"] > 0 + assert codes["FP-12-04#4회"]["quantity"] > 0 + assert "FP-12-03#보통" in codes, list(codes) # ⑶ 표가 적은 갈래 + reasons = " ".join(str(m.get("reason")) for m in row["composite_not_ready"] or []) + assert "FP-12-15#버림콘크리트" in {str(m.get("code")) for m in row["composite_not_ready"]} + assert reasons, row + assert "뚜껑" in row["spec_class_basis"] and "설치비" in row["spec_class_basis"] # ⑷ + + +def test_돌집수정은_종전대로_12_15_곧장() -> None: + row = _basin(STONE) + assert row["work_item_code"] == "FP-12-15" and not row["composite_parts"], row + + +def test_콘크리트_집수정_콘크리트는_타설_줄이_또_세지_않음() -> None: + unit = build_table([CONCRETE], {"pipe_inlet_basin": "집수정"}) + handoff = build_handoff(unit_quantity_table=unit) + placing = [ + r for r in handoff["work_items"] if str(r["work_item_code"] or "").startswith("FP-12-01") + ] + assert not placing, placing # 구체 조각이 셈 — 두 번 안 셈 + + +def test_내역_조립_줄은_금액_없이_사유() -> None: + from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill + + unit = build_table([CONCRETE], {"pipe_inlet_basin": "집수정"}) + bill = build_bill(build_handoff(unit_quantity_table=unit)) + line = next(r for r in bill.rows if r.code is None and "집수정" in r.name) + assert line.amount_krw is None and "묶음 조각" in line.note, line.note