From a81bc844fbad9cd5985269403af1374500661756 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 13 Sep 2026 19:09:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(axis):=20=EB=B6=80=EB=AA=A8=20=EA=B3=B5?= =?UTF-8?q?=EC=A2=85=20=EB=AA=A8=EC=96=91=20parent=5Fmode=20=E2=80=94=20?= =?UTF-8?q?=EB=8B=A8=EA=B3=84=20=ED=95=A9=EC=82=B0=ED=98=95=20=EC=A1=B0?= =?UTF-8?q?=EB=A6=BD=EC=9C=BC=EB=A1=9C=20=ED=9D=99=EA=B9=8E=EA=B8=B0=20?= =?UTF-8?q?=EC=95=94=EC=A0=88=EC=B7=A8=20=EA=B8=88=EC=95=A1=EC=9D=B4=20?= =?UTF-8?q?=EC=84=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 마스터 부모 94곳에 parent_mode — 기본 choose_one, 합산형은 사람이 적은 셋(9-4·9-5·12-38)만 단계·가중치 - 9-4 암절취 = 암파쇄(연암·보통암·경암, 브레이커+굴착기 조합 1/Q) + 집토(굴착기 0.7 공식) 합으로 조립 - 표에 없는 암질은 9-4-1 [주]① 평균으로만 섬 · 9-5 발파암은 발파 단계(착암기 층 없음) 사유로 막힘 - B08 인계가 흙깎기 암 갈래 이름을 갈래 값으로 넘김 · 갈래 고르기형 부모를 가리키는 줄은 오류로 막음 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- .../B08_Quantity_Build_WorkItemMaster.py | 5 + ...8_Quantity_Build_WorkItemMaster_Parents.py | 70 ++++ .../B08_Quantity_Engine_Handoff_Rows.py | 5 +- .../B09_Estimation_BillOfQuantities_Rows.py | 52 ++- .../B09_Estimation_FactorChoices.py | 19 ++ B09_Estimation/B09_Estimation_ParentSteps.py | 218 ++++++++++++ B09_Estimation/B09_Estimation_UnitPrice.py | 9 + .../work_item_mapping_2026-01-01.json | 10 +- .../data_work_item_master/_manifest.json | 6 +- .../work_item_master_2026-01-01.json | 321 ++++++++++++------ resources/tester/test_b09_parent_steps.py | 93 +++++ 11 files changed, 700 insertions(+), 108 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_Build_WorkItemMaster_Parents.py create mode 100644 B09_Estimation/B09_Estimation_ParentSteps.py create mode 100644 resources/tester/test_b09_parent_steps.py diff --git a/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py b/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py index d152127e..e87898d8 100644 --- a/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py +++ b/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py @@ -690,6 +690,11 @@ def build() -> dict[str, Any]: node["tables"].append(entry) attached += 1 + # 부모 모양(명세 2장) — 기본 choose_one, 합산형은 사람이 적은 것만. + from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Parents import apply_parent_modes + + apply_parent_modes(nodes) + src_meta = { "dataset_id": data["dataset_id"], "effective_date": data["effective_date"], diff --git a/B08_Quantity/B08_Quantity_Build_WorkItemMaster_Parents.py b/B08_Quantity/B08_Quantity_Build_WorkItemMaster_Parents.py new file mode 100644 index 00000000..947f872e --- /dev/null +++ b/B08_Quantity/B08_Quantity_Build_WorkItemMaster_Parents.py @@ -0,0 +1,70 @@ +"""공종 마스터 — 부모 공종의 모양 `parent_mode` (명세 2장 · 2026-09-13 축 C Ⓐ). + +아래 절이 있는 공종(부모)은 두 모양이고 규칙이 다르다. + + choose_one 갈래 고르기형 — 잎 하나를 고른다. **부모에 금액을 붙이지 않는다.** (기본) + sum_steps 단계 합산형 — 내역 한 줄이 곧 부모이고, 일위대가는 **잎들의 합**으로 조립한다. + +⚠ **자동 판정 금지** — 표 모양으로 짐작하면 조용히 틀린다. 합산형은 아래에 **사람이 적은 것**만. +⚠ 가중치(`weight`)도 사람이 적는다 — 원문 절 제목이 준 비율만(「발파 10%」·「깎기(90%)」). +""" + +from __future__ import annotations + +from typing import Any + +PARENT_CHOOSE_ONE = "choose_one" +PARENT_SUM_STEPS = "sum_steps" + +#: 단계 합산형 부모 — (잎 코드, 가중치) 차례와 근거. ⚠ 여기 없는 부모는 전부 `choose_one`. +SUM_STEPS: dict[str, dict[str, Any]] = { + "FP-09-04": { + "steps": (("FP-09-04-01", "1"), ("FP-09-04-02", "1")), + "basis": ( + "품셈 9-4 암절취 = 9-4-1 암파쇄 + 9-4-2 집토 — 같은 ㎥ 를 깨고 모음" + "(지식DB 토공_수량 「암절취 (암파쇄ㆍ집토) | 9-4」)" + ), + }, + "FP-09-05": { + "steps": (("FP-09-05-01", "0.1"), ("FP-09-05-02", "0.9"), ("FP-09-05-03", "1")), + "basis": ( + "품셈 9-5 발파암 — 절 제목 「9-5-1 육상, 암석 절취(발파 10%)」·「9-5-2 깎기(90%)」·" + "「9-5-3 집토」(지식DB 토공_수량 「발파암 (절취 10 %ㆍ깎기 90 %ㆍ집토) | 9-5」)" + ), + }, + "FP-12-38": { + "steps": (("FP-12-38-02", "1"), ("FP-12-38-03", "1")), + "basis": ( + "품셈 12-38 유로폼 = 12-38-2 사용수량(자재) + 12-38-3 설치 및 해체(품) — " + "12-38-1 사용횟수는 금액 단계가 아니라 사용수량의 잔존율 조건" + ), + }, +} + + +def apply_parent_modes(nodes: list[dict[str, Any]]) -> None: + """부모 공종에 `parent_mode`(합산형이면 `steps`·`steps_basis` 까지)를 단다 — 자리에서. + + ⚠ 선언한 잎이 그 부모의 자식이 아니면 멈춘다 — 판이 바뀌어 절 번호가 옮겨 간 자리다. + """ + children: dict[str, list[str]] = {} + for node in nodes: + if node.get("parent_code"): + children.setdefault(node["parent_code"], []).append(node["work_item_code"]) + for node in nodes: + code = node["work_item_code"] + if code not in children: + continue + declared = SUM_STEPS.get(code) + if declared is None: + node["parent_mode"] = PARENT_CHOOSE_ONE + continue + strays = [step for step, _ in declared["steps"] if step not in children[code]] + if strays: + raise ValueError(f"{code} 합산 단계가 자식이 아닙니다: {strays}") + node["parent_mode"] = PARENT_SUM_STEPS + node["steps"] = [{"code": step, "weight": weight} for step, weight in declared["steps"]] + node["steps_basis"] = declared["basis"] + missing = [code for code in SUM_STEPS if code not in children] + if missing: + raise ValueError(f"합산형으로 적은 부모가 마스터에 없습니다: {missing}") diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py index 119297cc..1e219791 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py @@ -177,8 +177,11 @@ def _earthwork_rows( # 토공 줄은 대개 갈래 축이 없다 — 그래도 **칸은 둔다**(계약이 한 모양). # 매핑이 갈래를 적은 작업 갈래(잡관목제거 → 단목베기 「5m 미만」)만 값을 싣는다. "variant_axis": (entry or {}).get("variant_axis"), + # 암 갈래(연암·보통암·경암)는 줄 자신의 갈래를 넘김 — 9-4·9-5 단계 합산형(축 C Ⓐ). "variant_value": (entry or {}).get("variant_value") - or (variant_inputs or {}).get(str((entry or {}).get("variant_from") or "")) + or {**(variant_inputs or {}), "ground_class": ground}.get( + str((entry or {}).get("variant_from") or "") + ) or None, "secondary_axes": None, "spec_class": None, diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py index d1f97d03..a5c37a82 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py @@ -203,13 +203,28 @@ def _leaf_row( return row price_code = f"B-{node.code}" - if item.variant_value and price_code not in unit_prices.book.titles: + if price_code not in unit_prices.book.titles: # B08 은 **의미**(어느 공종·어느 제원)만 보내고 갈래 키는 우리가 만든다. # 못 맞추면 후보를 보이는 길로 내려간다 — 가까운 갈래를 임의로 고르지 않는다. - picked = find_variant_code(node.code, item.variant_value, unit_prices) + picked = ( + find_variant_code(node.code, item.variant_value, unit_prices) + if item.variant_value + else None + ) + default = unit_prices.default_variants.get(node.code) if picked is not None: price_code = picked row.spec = f"{row.spec} {item.variant_value}".strip() + elif default is not None: + # 표에 없거나 안 준 암질(풍화암·암) — **원문이 정한 갈래**로만 선다(9-4-1 [주]① 평균). + price_code = f"{price_code}#{default[0]}" + row.spec = f"{row.spec} {default[0]}".strip() + given = ( + f"「{item.variant_value}」은 표에 없는 갈래" + if item.variant_value + else "갈래 미지정" + ) + row.add_note("spec", f"ⓘ {given} — {default[1]}") if price_code not in unit_prices.book.titles: # 한 층 아래에 일위대가가 있으면 **후보로 보여준다** — 임의로 고르지 않는다 @@ -225,16 +240,35 @@ def _leaf_row( ) or code.startswith(f"{price_code}#") ) - if children: + mode = unit_prices.parent_modes.get(node.code) + if mode == "sum_steps" and node.code in unit_prices.component_gaps: + # 단계 합산형 — 부모가 곧 내역 줄인데 **어느 단계가 못 서서** 조립이 안 됐다. + gap = unit_prices.component_gaps[node.code] + row.add_note("unit_price_krw", f"단계 합산 단가를 못 세웠습니다 — {gap}") + reason = f"단계 합산 미완 — {gap}" + elif children and mode == "sum_steps": + names = ", ".join(c[2:] for c in children if c.startswith(f"{price_code}#")) + row.add_note( + "unit_price_krw", + f"갈래 「{item.variant_value or '미지정'}」에 맞는 단계 합산 단가가 없습니다" + f" — 있는 갈래: {names}", + ) + reason = f"단계 합산 갈래 미일치 — {item.variant_value or '미지정'}" + elif mode == "choose_one": + # ⚠ 갈래 고르기형 부모 — **부모에 금액을 붙이지 않는다**(명세 2장). 조용히 0 원이 되지 않게 막는다. + names = ", ".join(f"{c[2:]} {unit_prices.book.title(c).name}" for c in children) + row.add_note( + "unit_price_krw", + "갈래 고르기형 부모 공종이라 잎 하나를 골라야 합니다 — " + + (f"후보: {names}" if names else "잎 공종 일위대가도 아직 없습니다"), + ) + reason = f"갈래 고르기형 부모 — 잎 미선택(후보 {len(children)}건)" + elif children: names = ", ".join(f"{c[2:]} {unit_prices.book.title(c).name}" for c in children) row.add_note( "unit_price_krw", f"이 공종엔 일위대가가 없고 한 층 아래에 있습니다 — 후보: {names}", ) - # 관경이 표 밖이면 **무엇을 정해야 하는지**까지 가리킨다. - diameter_note = pipe_diameter_note(node.code, item.variant_value) - if diameter_note: - row.add_note("spec", diameter_note) reason = f"일위대가가 하위 공종에 있음(후보 {len(children)}건)" else: # ⚠ 「아직 안 만든 것」과 「성분이 빠져 못 세운 것」은 **할 일이 다르다**. @@ -249,6 +283,10 @@ def _leaf_row( "unit_price_krw", "일위대가가 아직 없습니다 — 금액을 0 으로 때우지 않습니다." ) reason = "일위대가 없음" + # 관경이 표 밖이면 **무엇을 정해야 하는지**까지 가리킨다. + diameter_note = pipe_diameter_note(node.code, item.variant_value) if children else "" + if diameter_note: + row.add_note("spec", diameter_note) result.missing.append( { "name": row.name, diff --git a/B09_Estimation/B09_Estimation_FactorChoices.py b/B09_Estimation/B09_Estimation_FactorChoices.py index ae1f464a..4d88ecc7 100644 --- a/B09_Estimation/B09_Estimation_FactorChoices.py +++ b/B09_Estimation/B09_Estimation_FactorChoices.py @@ -219,6 +219,25 @@ MACHINE_CHOICES: dict[str, dict[str, Any]] = { "(2026-09-09 밤에 드러남).", ], }, + # 암절취·발파암의 집토 — 흙깎기와 같이 기종이 [주] 에만 있다(2026-09-13 축 C Ⓐ, 단계 합산형 부모의 잎). + "FP-09-04-02": { + "work_item_name": "암절취 집토", + "default_code": "0201-0070", + "source": "note", + "basis": [ + "산림사업 표준품셈 9-4-2 [주]① 「장비는 무한궤도 굴착기(0.7㎥)를 적용한다」 — " + "표가 아니라 [주] 에 있어 공종 마스터가 못 싣는 값입니다.", + ], + }, + "FP-09-05-03": { + "work_item_name": "발파암 집토", + "default_code": "0201-0070", + "source": "note", + "basis": [ + "산림사업 표준품셈 9-5-3 [주]① 「장비는 무한궤도 굴착기(0.7㎥)를 적용한다」 — " + "표가 아니라 [주] 에 있어 공종 마스터가 못 싣는 값입니다.", + ], + }, "FP-09-18": { "work_item_name": "층따기", "default_code": "0201-0070", diff --git a/B09_Estimation/B09_Estimation_ParentSteps.py b/B09_Estimation/B09_Estimation_ParentSteps.py new file mode 100644 index 00000000..bb87f9a8 --- /dev/null +++ b/B09_Estimation/B09_Estimation_ParentSteps.py @@ -0,0 +1,218 @@ +"""B09 원가계산 — **단계 합산형 부모**를 잎들의 합으로 조립한다 (명세 2장 · 2026-09-13 축 C Ⓐ). + +공종 마스터가 부모마다 `parent_mode` 를 단다(`B08_Quantity_Build_WorkItemMaster_Parents`). + + choose_one 갈래 고르기형 — **여기서 안 세운다.** 내역 줄이 부모를 가리키면 오류로 드러냄 + sum_steps 단계 합산형 — B-<부모>[#갈래] = Σ B-<잎>[#갈래] × 가중치 + +⭐ 흙깎기(리핑암) = 9-4 암절취 = 9-4-1 암파쇄 + 9-4-2 집토 가 첫 사례다 — 부모에 일위대가 제목이 + 아예 없어 금액 0 이던 자리(명세 2장 실측). + +⚠ **암질 갈래** — 암파쇄(9-4-1)·깎기(9-5-2) 표는 연암·보통암·경암마다 작업능력 `Q`(㎥/hr)를 준다. + 갈래마다 따로 세우고, 부모도 그 갈래로 선다. 내역 줄의 암질이 표에 없으면(풍화암·암 등) + **원문이 평균을 정한 잎만** 평균으로 선다(9-4-1 [주]①). 9-5-2 는 그 [주]가 없어 막는다. +⚠ 모르는 값을 지어내지 않는다 — 단계 하나라도 못 서면 부모를 안 세우고 **어느 단계가 왜**를 남긴다. +""" + +from __future__ import annotations + +import re +from decimal import Decimal +from typing import Any + +from B09_Estimation.B09_Estimation_PriceBook import PriceDetail, PriceKind, PriceTitle + +ROCK_CLASSES = ("연암", "보통암", "경암") + +#: 암질을 표에 없는 이름으로 준 줄이 쓸 갈래 — **원문이 평균을 정한 잎만**. +AVERAGE_VARIANT = "평균" +AVERAGE_BASIS: dict[str, str] = { + "FP-09-04-01": "품셈 9-4-1 [주]① 「Q(시간당 작업량 ㎥/hr) =(5.0+3.4+2.6)/3」 — 세 암질 평균", +} + +#: 표에 기계 이름이 없는 잎 — [주]가 기종을 적은 자리(마스터가 [주]를 못 싣는다). 원문 그대로. +#: ⚠ 부모(9-5)에서 물려받을 기계는 없다 — 부모엔 표가 없고, 잎의 [주]가 스스로 적었다. +NOTE_MACHINES: dict[str, tuple[tuple[str, ...], str]] = { + "FP-09-05-02": ( + ("0201-0070", "0230-0007"), + "품셈 9-5-2 [주] 「장비는 대형브레이커와 무한궤도 굴착기(0.7㎥)를 적용한다」", + ), +} + + +def _tight(text: Any) -> str: + return re.sub(r"\s", "", str(text or "")) + + +def rock_rows(node: dict[str, Any]) -> list[tuple[str, Decimal, Decimal | None]]: + """(암질, 작업능력 Q, 치즐소모량) — 암질 이름 칸 **바로 뒤** 두 칸을 읽는다.""" + from B09_Estimation.B09_Estimation_MachineProductivity import parse_measure + + found: list[tuple[str, Decimal, Decimal | None]] = [] + for table in node.get("tables", []): + for row in table.get("raw_row") or []: + cells = [str(cell) for cell in row] + index = next((i for i, c in enumerate(cells) if _tight(c) in ROCK_CLASSES), None) + if index is None: + continue + tail = [parse_measure(c) for c in cells[index + 1 : index + 3]] + [None, None] + if tail[0] is not None and tail[0] > 0: + found.append((_tight(cells[index]), tail[0], tail[1])) + return found + + +def leaf_machines(node: dict[str, Any]) -> tuple[list[tuple[str, str]], str]: + """(기종 코드·이름 목록, 출처) — 표의 조합 칸(「대형브레이커+ 유압식백호우 …」)이 먼저.""" + from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog + from B09_Estimation.B09_Estimation_MachineProductivity import resolve_machine + from B09_Estimation.B09_Estimation_MachineProductivity_Reference import _machine_by_name + + catalog = load_machine_catalog() + for table in node.get("tables", []): + for row in table.get("raw_row") or []: + for cell in row: + parts = [part.strip() for part in str(cell).split("+")] + if len(parts) < 2: + continue + bodies = [resolve_machine(part) for part in parts] + body = next((b for b in bodies if b is not None), None) + if body is None: + continue + spec = str(catalog.machines[body[0]].specification) + picked = [b or _machine_by_name(p, spec) for b, p in zip(bodies, parts)] + if all(picked): + return [ + p for p in picked if p + ], f"표의 조합 칸 「{' '.join(str(cell).split())}」" + code = str(node.get("work_item_code") or "") + if code in NOTE_MACHINES: + codes, source = NOTE_MACHINES[code] + return [(c, catalog.machines[c].name) for c in codes if c in catalog.machines], source + return [], "" + + +def _add_rock_leaf(build: Any, node: dict[str, Any]) -> None: + """암질 갈래마다 잎 제목을 세운다 — 기계마다 1/Q 시간.""" + code = str(node["work_item_code"]) + rows = rock_rows(node) + if not rows or any(title.startswith(f"B-{code}#") for title in build.book.titles): + return + machines, source = leaf_machines(node) + missing = [f"X-{m}" for m, _ in machines if f"X-{m}" not in build.book.titles] + if not machines or missing: + build.component_gaps[code] = ( + f"작업능력 표의 기종을 못 골랐습니다 — {source or '표·[주]에 기종 없음'}" + + (f" (기계 단가 층 없음: {', '.join(missing)})" if missing else "") + ) + return + variants = [(rock, q, f"작업능력 Q = {q} ㎥/hr ({rock})") for rock, q, _ in rows] + if code in AVERAGE_BASIS: + average = sum((q for _, q, _ in rows), Decimal(0)) / Decimal(len(rows)) + variants.append( + (AVERAGE_VARIANT, average, f"Q = {average:.4f} ㎥/hr — {AVERAGE_BASIS[code]}") + ) + for variant, capacity, note in variants: + title_code = f"B-{code}#{variant}" + build.book.add_title( + PriceTitle( + code=title_code, + kind=PriceKind.UNIT_PRICE, + name=f"{node.get('name', code)} ({variant})", + spec=variant, + unit="㎥", + ) + ) + for machine_code, _name in machines: + build.book.add_detail( + PriceDetail( + title_code, + f"X-{machine_code}", + Decimal(1) / capacity, + note=f"{note} · {source}", + ) + ) + build.variants.setdefault(code, []).append(variant) + # 표 줄은 다 읽었다 — 남는 것은 치즐뿐(자재 카탈로그가 없어 금액에 안 붙는 알려진 미결). + build.unattached[code] = ["치즐소모량(본/hr) — 자재 단가 층 없음"] if rows[0][2] else [] + + +def _step_titles(build: Any, step: str) -> dict[str, str]: + """갈래 → 그 단계의 제목 코드. 갈래 없는 제목은 빈 문자열 키.""" + titles = {"": f"B-{step}"} if f"B-{step}" in build.book.titles else {} + for code in build.book.titles: + if code.startswith(f"B-{step}#"): + titles[code.split("#", 1)[1]] = code + return titles + + +def _assemble(build: Any, node: dict[str, Any], names: dict[str, str]) -> None: + parent = str(node["work_item_code"]) + steps = [(str(s["code"]), Decimal(str(s["weight"]))) for s in node.get("steps") or []] + per_step: list[tuple[str, Decimal, dict[str, str]]] = [] + for step, weight in steps: + label = f"단계 {step} {names.get(step, '')}".strip() + titles = _step_titles(build, step) + if not titles: + why = build.component_gaps.get(step) or "일위대가가 없습니다" + build.component_gaps[parent] = f"{label} 이 안 섰습니다 — {why}" + return + if step in build.partial_ratio or step in build.basis_missing: + why = build.component_gaps.get(step) or "일부 몫만 섰습니다" + build.component_gaps[parent] = f"{label} 이 일부만 섰습니다 — {why}" + return + per_step.append((step, weight, titles)) + varying = [item for item in per_step if set(item[2]) - {""}] + if len(varying) > 1: + build.component_gaps[parent] = "갈래가 두 단계 이상에 걸쳐 조립하지 않았습니다" + return + units = {build.book.titles[t].unit for _, _, ts in per_step for t in ts.values()} - {""} + if len(units) > 1: + build.component_gaps[parent] = f"단계 단위가 서로 다릅니다 — {', '.join(sorted(units))}" + return + variants = [v for v in varying[0][2] if v] if varying else [""] + for variant in variants: + title_code = f"B-{parent}" + (f"#{variant}" if variant else "") + build.book.add_title( + PriceTitle( + code=title_code, + kind=PriceKind.UNIT_PRICE, + name=f"{names.get(parent, parent)} ({variant})" + if variant + else names.get(parent, parent), + spec=variant or parent, + unit=next(iter(units), ""), + ) + ) + for step, weight, titles in per_step: + ref = titles.get(variant) or titles[""] + build.book.add_detail( + PriceDetail( + title_code, + ref, + weight, + note=f"단계 합산 × {weight} — {node.get('steps_basis', '')}", + ) + ) + if variant: + build.variants.setdefault(parent, []).append(variant) + if varying and AVERAGE_VARIANT in varying[0][2] and varying[0][0] in AVERAGE_BASIS: + build.default_variants[parent] = (AVERAGE_VARIANT, AVERAGE_BASIS[varying[0][0]]) + build.component_gaps.pop(parent, None) + + +def attach_parent_steps(build: Any, master: dict[str, Any]) -> None: + """부모 모양을 싣고, 합산형 부모를 조립한다 — `build_unit_prices` 끝(조합 16% 바꿔 달기 전).""" + nodes = master.get("work_items", []) + names = {str(n.get("work_item_code")): str(n.get("name", "")) for n in nodes} + by_code = {str(n.get("work_item_code")): n for n in nodes} + for node in nodes: + mode = node.get("parent_mode") + if mode: + build.parent_modes[str(node["work_item_code"])] = str(mode) + if mode != "sum_steps": + continue + for step in node.get("steps") or []: + leaf = by_code.get(str(step["code"])) + if leaf is not None: + _add_rock_leaf(build, leaf) + _assemble(build, node, names) diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 40487fa6..0207f461 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -129,6 +129,10 @@ class UnitPriceBuild: #: ⚠ 금액을 막지 않는다. 「표본이 얇다」는 사실을 화면이 말하게 하는 통로다 #: (지식DB `노임단가_적용 §2-3` 「단가 채택 시 플래그 유지 필요」, 2026-09-09에 이음). labor_reliability: dict[str, tuple[str, str, str]] = field(default_factory=dict) + #: 부모 공종 → 모양(`choose_one`·`sum_steps`) — 공종 마스터가 단 그대로(명세 2장). + parent_modes: dict[str, str] = field(default_factory=dict) + #: 합산형 부모 → (갈래, 근거) — 내역 줄의 갈래가 표에 없을 때 **원문이 정한** 갈래(9-4-1 [주]① 평균). + default_variants: dict[str, tuple[str, str]] = field(default_factory=dict) def _add_labor_titles(book: PriceBook, wages: dict[str, Decimal]) -> None: @@ -921,6 +925,11 @@ def build_unit_prices( covered += machine_share if covered < Decimal(100): build.partial_ratio[work_item_code] = covered + # 단계 합산형 부모(9-4 암절취 = 암파쇄 + 집토) — 잎이 다 선 뒤, 조합 16% 바꿔 달기 전(명세 2장). + from B09_Estimation.B09_Estimation_ParentSteps import attach_parent_steps + + attach_parent_steps(build, master) + # 기계 수송비 — 기계경비의 셋째 몫(손료·운전경비·**수송비**). 거리가 있어야 선다. from B09_Estimation.B09_Estimation_Transport import attach_transport 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 447b4533..c3da4294 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 @@ -28,7 +28,10 @@ "work_item_code": "FP-09-04", "basis_unit": "㎥", "basis_source": "산림사업 표준품셈(고시 2025-82) 원문 L4725·L4738 「Q(시간당 작업량 ㎥/hr)」·「Q=…= ㎥/시간」 — 절 머리에 「(단위: …)」가 없어 마스터가 못 채운 자리(2026-09-08 원문 대조).", - "master_name": "암절취" + "master_name": "암절취", + "variant_axis": "ground_class", + "variant_from": "ground_class", + "variant_note": "2026-09-13 축 C Ⓐ — 단계 합산형 부모(9-4·9-5). 암파쇄·깎기 표가 연암·보통암·경암마다 작업능력을 줘 암 갈래 이름을 갈래 값으로 넘김(표에 없는 이름의 처리는 B09 몫)." }, { "group": "흙깎기", @@ -36,7 +39,10 @@ "work_item_code": "FP-09-05", "basis_unit": "㎥", "basis_source": "산림사업 표준품셈(고시 2025-82) 원문 L4744 「(단위: ㎥당)」 · L4777 「Q=…= ㎥/시간」 — 절 머리에 「(단위: …)」가 없어 마스터가 못 채운 자리(2026-09-08 원문 대조).", - "master_name": "발파암" + "master_name": "발파암", + "variant_axis": "ground_class", + "variant_from": "ground_class", + "variant_note": "2026-09-13 축 C Ⓐ — 단계 합산형 부모(9-4·9-5). 암파쇄·깎기 표가 연암·보통암·경암마다 작업능력을 줘 암 갈래 이름을 갈래 값으로 넘김(표에 없는 이름의 처리는 B09 몫)." }, { "group": "측구터파기", diff --git a/resources/data_work_item_master/_manifest.json b/resources/data_work_item_master/_manifest.json index 78b517cd..85381040 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-13T17:55:11+09:00", + "generated_at": "2026-09-13T18:54:39+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": "43b6d48175847e1d4011aad4a0597313008f791ec7eba0429d39d279e3e1fcb3", - "size_bytes": 856831 + "sha256": "11c49ecde0a611307a82fd98b3625ee6577c01c2f77db6e1f515b41bf83ce685", + "size_bytes": 861050 }, { "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 c1300d30..e5c9082c 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 @@ -2,7 +2,7 @@ "schema_version": "1.0", "dataset_id": "work_item_master_forest", "effective_date": "2026-01-01", - "generated_at": "2026-09-13T17:55:11+09:00", + "generated_at": "2026-09-13T18:54:39+09:00", "dataset_version": { "dataset_id": "pum_forest", "effective_date": "2026-01-01", @@ -111,7 +111,8 @@ "level": 1, "parent_code": null, "sort_order": 256, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-01-01", @@ -120,7 +121,8 @@ "level": 2, "parent_code": "FP-01", "sort_order": 512, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-01-01-01", @@ -233,7 +235,8 @@ "level": 2, "parent_code": "FP-01", "sort_order": 1536, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-01-02-01", @@ -1790,7 +1793,8 @@ "level": 2, "parent_code": "FP-01", "sort_order": 3840, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-01-03-01", @@ -2167,7 +2171,8 @@ "level": 2, "parent_code": "FP-01", "sort_order": 4608, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-01-04-01", @@ -3985,7 +3990,8 @@ "level": 2, "parent_code": "FP-01", "sort_order": 12032, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-01-07-01", @@ -4143,7 +4149,8 @@ "level": 1, "parent_code": null, "sort_order": 13312, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-02-01", @@ -4519,7 +4526,8 @@ ] ] } - ] + ], + "parent_mode": "choose_one" }, { "work_item_code": "FP-02-01-01", @@ -6086,7 +6094,8 @@ ] ] } - ] + ], + "parent_mode": "choose_one" }, { "work_item_code": "FP-02-02-01", @@ -6747,7 +6756,8 @@ "level": 1, "parent_code": null, "sort_order": 20224, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-03-01", @@ -9812,7 +9822,8 @@ "level": 2, "parent_code": "FP-03", "sort_order": 21248, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-03-04-01", @@ -10231,7 +10242,8 @@ "level": 1, "parent_code": null, "sort_order": 23296, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-04-01", @@ -11718,7 +11730,8 @@ ] ] } - ] + ], + "parent_mode": "choose_one" }, { "work_item_code": "FP-04-01-01", @@ -12684,7 +12697,8 @@ ] ] } - ] + ], + "parent_mode": "choose_one" }, { "work_item_code": "FP-04-02-01", @@ -13144,7 +13158,8 @@ "level": 1, "parent_code": null, "sort_order": 26112, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-05-01", @@ -13153,7 +13168,8 @@ "level": 2, "parent_code": "FP-05", "sort_order": 26368, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-05-01-01", @@ -13860,7 +13876,8 @@ "level": 2, "parent_code": "FP-05", "sort_order": 28160, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-05-03-01", @@ -15328,7 +15345,8 @@ "level": 2, "parent_code": "FP-05", "sort_order": 32768, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-05-16-01", @@ -15488,7 +15506,8 @@ "level": 2, "parent_code": "FP-05", "sort_order": 33536, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-05-17-01", @@ -15716,7 +15735,8 @@ "level": 2, "parent_code": "FP-05", "sort_order": 34560, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-05-19-01", @@ -15964,7 +15984,8 @@ "level": 2, "parent_code": "FP-05", "sort_order": 35840, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-05-22-01", @@ -16142,7 +16163,8 @@ "level": 2, "parent_code": "FP-05", "sort_order": 37120, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-05-23-01", @@ -16257,7 +16279,8 @@ "level": 2, "parent_code": "FP-05", "sort_order": 38144, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-05-24-01", @@ -16789,7 +16812,8 @@ "level": 2, "parent_code": "FP-05", "sort_order": 39168, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-05-26-01", @@ -17050,7 +17074,8 @@ "level": 2, "parent_code": "FP-05", "sort_order": 40448, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-05-28-01", @@ -17475,7 +17500,8 @@ "level": 1, "parent_code": null, "sort_order": 42752, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-06-01", @@ -17539,7 +17565,8 @@ "level": 2, "parent_code": "FP-06", "sort_order": 43264, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-06-02-01", @@ -17788,7 +17815,8 @@ "level": 2, "parent_code": "FP-06", "sort_order": 44544, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-06-04-01", @@ -18286,7 +18314,8 @@ "level": 2, "parent_code": "FP-06", "sort_order": 46336, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-06-07-01", @@ -18529,7 +18558,8 @@ "level": 1, "parent_code": null, "sort_order": 47616, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-07-01", @@ -18538,7 +18568,8 @@ "level": 2, "parent_code": "FP-07", "sort_order": 47872, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-07-01-01", @@ -19103,7 +19134,8 @@ "level": 2, "parent_code": "FP-07", "sort_order": 49152, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-07-04-01", @@ -19305,7 +19337,8 @@ "level": 2, "parent_code": "FP-07", "sort_order": 49920, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-07-05-01", @@ -19585,7 +19618,8 @@ "level": 2, "parent_code": "FP-07", "sort_order": 50944, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-07-07-01", @@ -19817,7 +19851,8 @@ "level": 2, "parent_code": "FP-07", "sort_order": 51712, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-07-08-01", @@ -20102,7 +20137,8 @@ "level": 2, "parent_code": "FP-07", "sort_order": 52736, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-07-09-01", @@ -20945,7 +20981,8 @@ "level": 2, "parent_code": "FP-07", "sort_order": 54784, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-07-15-01", @@ -21370,7 +21407,8 @@ "level": 1, "parent_code": null, "sort_order": 56320, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-08-01", @@ -21379,7 +21417,8 @@ "level": 2, "parent_code": "FP-08", "sort_order": 56576, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-08-01-01", @@ -21563,7 +21602,8 @@ "level": 2, "parent_code": "FP-08", "sort_order": 57344, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-08-02-01", @@ -26590,7 +26630,8 @@ "level": 2, "parent_code": "FP-08", "sort_order": 59648, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-08-06-01", @@ -27436,7 +27477,8 @@ "level": 2, "parent_code": "FP-08", "sort_order": 60928, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-08-08-01", @@ -27762,7 +27804,8 @@ "level": 1, "parent_code": null, "sort_order": 62464, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-09-01", @@ -27823,7 +27866,8 @@ "level": 2, "parent_code": "FP-09", "sort_order": 63232, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-09-03-01", @@ -27949,7 +27993,19 @@ "level": 2, "parent_code": "FP-09", "sort_order": 64000, - "tables": [] + "tables": [], + "parent_mode": "sum_steps", + "steps": [ + { + "code": "FP-09-04-01", + "weight": "1" + }, + { + "code": "FP-09-04-02", + "weight": "1" + } + ], + "steps_basis": "품셈 9-4 암절취 = 9-4-1 암파쇄 + 9-4-2 집토 — 같은 ㎥ 를 깨고 모음(지식DB 토공_수량 「암절취 (암파쇄ㆍ집토) | 9-4」)" }, { "work_item_code": "FP-09-04-01", @@ -28089,7 +28145,23 @@ "level": 2, "parent_code": "FP-09", "sort_order": 64768, - "tables": [] + "tables": [], + "parent_mode": "sum_steps", + "steps": [ + { + "code": "FP-09-05-01", + "weight": "0.1" + }, + { + "code": "FP-09-05-02", + "weight": "0.9" + }, + { + "code": "FP-09-05-03", + "weight": "1" + } + ], + "steps_basis": "품셈 9-5 발파암 — 절 제목 「9-5-1 육상, 암석 절취(발파 10%)」·「9-5-2 깎기(90%)」·「9-5-3 집토」(지식DB 토공_수량 「발파암 (절취 10 %ㆍ깎기 90 %ㆍ집토) | 9-5」)" }, { "work_item_code": "FP-09-05-01", @@ -28340,7 +28412,8 @@ "level": 2, "parent_code": "FP-09", "sort_order": 65792, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-09-06-01", @@ -28394,7 +28467,8 @@ "level": 2, "parent_code": "FP-09", "sort_order": 66304, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-09-07-01", @@ -28501,7 +28575,8 @@ "level": 2, "parent_code": "FP-09", "sort_order": 67072, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-09-08-01", @@ -28731,7 +28806,8 @@ "level": 2, "parent_code": "FP-09", "sort_order": 68096, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-09-10-01", @@ -28832,7 +28908,8 @@ "level": 2, "parent_code": "FP-09", "sort_order": 68864, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-09-11-01", @@ -28936,7 +29013,8 @@ "level": 2, "parent_code": "FP-09", "sort_order": 69376, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-09-12-01", @@ -29242,7 +29320,8 @@ "level": 2, "parent_code": "FP-09", "sort_order": 70400, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-09-13-01", @@ -30742,7 +30821,8 @@ "level": 2, "parent_code": "FP-09", "sort_order": 75264, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-09-14-01", @@ -30913,7 +30993,8 @@ "level": 2, "parent_code": "FP-09", "sort_order": 76032, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-09-15-01", @@ -31086,7 +31167,8 @@ "level": 2, "parent_code": "FP-09", "sort_order": 76800, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-09-16-01", @@ -31303,7 +31385,8 @@ "level": 2, "parent_code": "FP-09", "sort_order": 77824, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-09-17-01", @@ -31483,7 +31566,8 @@ "level": 2, "parent_code": "FP-09", "sort_order": 78848, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-09-19-01", @@ -31767,7 +31851,8 @@ "level": 2, "parent_code": "FP-09", "sort_order": 79872, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-09-20-01", @@ -32032,7 +32117,8 @@ "level": 1, "parent_code": null, "sort_order": 81152, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-10-01", @@ -32139,7 +32225,8 @@ "level": 2, "parent_code": "FP-10", "sort_order": 81920, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-10-03-01", @@ -32452,7 +32539,8 @@ "level": 2, "parent_code": "FP-10", "sort_order": 83456, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-10-06-01", @@ -32703,7 +32791,8 @@ "level": 2, "parent_code": "FP-10", "sort_order": 84480, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-10-07-01", @@ -33141,7 +33230,8 @@ "level": 2, "parent_code": "FP-10", "sort_order": 85760, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-10-08-01", @@ -33351,7 +33441,8 @@ "level": 2, "parent_code": "FP-10", "sort_order": 86784, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-10-09-01", @@ -33471,7 +33562,8 @@ "level": 2, "parent_code": "FP-10", "sort_order": 87296, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-10-10-01", @@ -33727,7 +33819,8 @@ "level": 2, "parent_code": "FP-10", "sort_order": 88320, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-10-12-01", @@ -33890,7 +33983,8 @@ "level": 2, "parent_code": "FP-10", "sort_order": 89344, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-10-13-01", @@ -34054,7 +34148,8 @@ "level": 1, "parent_code": null, "sort_order": 90112, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-11-01", @@ -34310,7 +34405,8 @@ "level": 1, "parent_code": null, "sort_order": 91392, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-12-01", @@ -34319,7 +34415,8 @@ "level": 2, "parent_code": "FP-12", "sort_order": 91648, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-12-01-01", @@ -35377,7 +35474,8 @@ "level": 2, "parent_code": "FP-12", "sort_order": 93952, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-12-07-01", @@ -35571,7 +35669,8 @@ "level": 2, "parent_code": "FP-12", "sort_order": 94976, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-12-09-01", @@ -35938,7 +36037,8 @@ "level": 2, "parent_code": "FP-12", "sort_order": 96256, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-12-11-01", @@ -36919,7 +37019,8 @@ "level": 2, "parent_code": "FP-12", "sort_order": 98560, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-12-17-01", @@ -37523,7 +37624,8 @@ "level": 2, "parent_code": "FP-12", "sort_order": 101120, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-12-24-01", @@ -37845,7 +37947,8 @@ "level": 2, "parent_code": "FP-12", "sort_order": 102400, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-12-24-01", @@ -38211,7 +38314,8 @@ "level": 2, "parent_code": "FP-12", "sort_order": 103936, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-12-30-01", @@ -38574,7 +38678,8 @@ "level": 2, "parent_code": "FP-12", "sort_order": 105472, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-12-34-01", @@ -39018,7 +39123,19 @@ "level": 2, "parent_code": "FP-12", "sort_order": 107520, - "tables": [] + "tables": [], + "parent_mode": "sum_steps", + "steps": [ + { + "code": "FP-12-38-02", + "weight": "1" + }, + { + "code": "FP-12-38-03", + "weight": "1" + } + ], + "steps_basis": "품셈 12-38 유로폼 = 12-38-2 사용수량(자재) + 12-38-3 설치 및 해체(품) — 12-38-1 사용횟수는 금액 단계가 아니라 사용수량의 잔존율 조건" }, { "work_item_code": "FP-12-38-01", @@ -39280,7 +39397,8 @@ "level": 1, "parent_code": null, "sort_order": 108544, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-13-01", @@ -39298,7 +39416,8 @@ "level": 2, "parent_code": "FP-13", "sort_order": 109056, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-13-02-01", @@ -39785,7 +39904,8 @@ ] ] } - ] + ], + "parent_mode": "choose_one" }, { "work_item_code": "FP-13-03-01", @@ -39936,7 +40056,8 @@ "level": 2, "parent_code": "FP-13", "sort_order": 111104, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-13-04-01", @@ -40800,7 +40921,8 @@ "level": 2, "parent_code": "FP-13", "sort_order": 112896, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-13-05-01", @@ -40959,7 +41081,8 @@ "level": 2, "parent_code": "FP-13", "sort_order": 113664, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-13-06-01", @@ -41252,7 +41375,8 @@ "level": 2, "parent_code": "FP-13", "sort_order": 114688, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-13-07-01", @@ -41564,7 +41688,8 @@ "level": 2, "parent_code": "FP-13", "sort_order": 115968, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-13-10-01", @@ -41921,7 +42046,8 @@ "level": 2, "parent_code": "FP-13", "sort_order": 116736, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-13-11-01", @@ -42399,7 +42525,8 @@ "level": 2, "parent_code": "FP-13", "sort_order": 118016, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-13-12-01", @@ -42587,7 +42714,8 @@ "level": 2, "parent_code": "FP-13", "sort_order": 118784, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-13-13-01", @@ -42922,7 +43050,8 @@ "level": 2, "parent_code": "FP-13", "sort_order": 119808, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-13-15-01", @@ -43092,7 +43221,8 @@ "level": 2, "parent_code": "FP-13", "sort_order": 120832, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-13-16-01", @@ -43273,7 +43403,8 @@ "level": 1, "parent_code": null, "sort_order": 121600, - "tables": [] + "tables": [], + "parent_mode": "choose_one" }, { "work_item_code": "FP-14-01", diff --git a/resources/tester/test_b09_parent_steps.py b/resources/tester/test_b09_parent_steps.py new file mode 100644 index 00000000..b1931757 --- /dev/null +++ b/resources/tester/test_b09_parent_steps.py @@ -0,0 +1,93 @@ +"""부모 공종 모양 `parent_mode` (명세 2장 · 2026-09-13 축 C Ⓐ). + +지키는 것 + ① 마스터 — 부모는 기본 choose_one, 합산형(9-4·9-5·12-38)만 사람이 적은 단계·가중치를 든다 + ② 흙깎기 리핑암 = 9-4 암절취 = 암파쇄(암질별 1/Q) + 집토 — 부모 제목이 잎들의 합으로 선다 + ③ 내역 줄의 암 갈래가 그대로 갈래 값으로 가고, 표에 없는 암질은 9-4-1 [주]① 평균으로만 선다 + ④ 단계 하나라도 못 서면 부모를 안 세우고 사유 · 갈래 고르기형 부모를 가리키면 오류로 막는다 +""" + +from __future__ import annotations + +from decimal import Decimal +from functools import lru_cache + +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff +from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill +from B09_Estimation.B09_Estimation_ResourceAxis_Sources import load_work_item_master +from B09_Estimation.B09_Estimation_UnitPrice import build_unit_prices + + +@lru_cache(maxsize=1) +def _build(): + return build_unit_prices() + + +def _node(code: str) -> dict: + return next(n for n in load_work_item_master()["work_items"] if n["work_item_code"] == code) + + +def test_마스터_부모_모양은_기본_고르기_합산형만_단계를_든다(): + assert _node("FP-10-12")["parent_mode"] == "choose_one" + rock = _node("FP-09-05") + assert rock["parent_mode"] == "sum_steps" + assert [(s["code"], s["weight"]) for s in rock["steps"]] == [ + ("FP-09-05-01", "0.1"), + ("FP-09-05-02", "0.9"), + ("FP-09-05-03", "1"), + ] + assert "parent_mode" not in _node("FP-09-04-01") # 잎 + + +def test_암절취는_암파쇄와_집토의_합으로_선다(): + book = _build().book + leaf = book.details["B-FP-09-04-01#연암"] + assert {d.ref_code for d in leaf} == {"X-0201-0070#조합", "X-0230-0007"} # 조합 16 % + assert all(d.quantity == Decimal(1) / Decimal("5.0") for d in leaf) + whole = book.resolve("B-FP-09-04#연암").total + parts = book.resolve("B-FP-09-04-01#연암").total + book.resolve("B-FP-09-04-02").total + assert whole == parts > 0 + assert "B-FP-09-04#평균" in book.titles # [주]① 평균 + + +def test_단계가_못_서면_부모를_안_세우고_사유를_남긴다(): + build = _build() + assert not any(code.startswith("B-FP-09-05#") for code in build.book.titles) + assert "FP-09-05-01" in build.component_gaps["FP-09-05"] # 발파 착암기 층 없음 + assert "FP-12-38-02" in build.component_gaps["FP-12-38"] # 사용수량 미구현 + # 깎기 표엔 기계 이름이 없음 — [주] 원문의 기종으로 섬(부모에서 물려받지 않음) + assert "[주]" in build.book.details["B-FP-09-05-02#경암"][0].note + + +def _bill(rock: str, method: str = "ripping"): + handoff = build_handoff( + summary_table={ + "rows": [{"group": "흙깎기", "item": rock, "spec": "", "unit": "㎥", "amount": 10.0}] + }, + ground_methods={rock: method}, + ) + return handoff["work_items"][0], build_bill(handoff, build=_build()) + + +def test_암_갈래가_그대로_단가를_고른다(): + row, bill = _bill("보통암") + assert (row["work_item_code"], row["variant_value"]) == ("FP-09-04", "보통암") + priced = next(r for r in bill.rows if not r.is_group and r.name == "흙깎기") + assert priced.unit_price_krw and "보통암" in priced.spec + + +def test_표에_없는_암질은_주1_평균으로만_선다(): + _row, bill = _bill("풍화암") + priced = next(r for r in bill.rows if not r.is_group and r.name == "흙깎기") + assert priced.unit_price_krw and "[주]①" in priced.note + + +def test_발파암은_단계_사유로_막히고_고르기형_부모는_오류(): + _row, bill = _bill("경암", "blasting") + assert any("단계 합산 미완" in m["reason"] for m in bill.missing) + handoff = { + "work_items": [{**_row, "work_item_code": "FP-10-12", "variant_value": None}], + "materials": [], + } + blocked = build_bill(handoff, build=_build()) + assert any("갈래 고르기형 부모" in m["reason"] for m in blocked.missing)