diff --git a/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py b/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py index e558f59c..ba648d4e 100644 --- a/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py +++ b/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py @@ -648,8 +648,10 @@ def build() -> dict[str, Any]: basis_found += 1 if basis_quantity_is_grouped(basis_qty): basis_grouped += 1 - elif form in ("requirement", "productivity"): + elif form in ("requirement", "productivity") and not crew_table(table): # 참조·계수표는 곱할 값이 아니므로 목록에 넣지 않는다 — 잡음이 되면 안 본다. + # 작업조 표도 뺌 — 밑수가 시공량 열(1단위당 = 인원 ÷ 시공량 · B09 CrewOutput)이라 + # 「N㎡당」 문구가 없어도 곱셈이 안 틀림(12-38-3 설치·해체가 여기 걸려 막혀 있었음 · 2026-09-14 301). basis_missing.append( { "pum_table_id": table["table_id"], diff --git a/B08_Quantity/B08_Quantity_Build_WorkItemMaster_Variants.py b/B08_Quantity/B08_Quantity_Build_WorkItemMaster_Variants.py index 1585fbaa..4e22e560 100644 --- a/B08_Quantity/B08_Quantity_Build_WorkItemMaster_Variants.py +++ b/B08_Quantity/B08_Quantity_Build_WorkItemMaster_Variants.py @@ -11,7 +11,7 @@ ⚠ 못 가른 표는 **빈칸** — 자원을 못 맞춘 표 · 계수 · 참조 · 미판정 표에 갈래를 지어내지 않는다. 공종마다 `variant_keys` — 표들의 갈래 + 원문이 정했으나 자원 줄로 안 서는 갈래 -(불도저 운반 공식 갈래 · 합산형 단계 잎의 암질 행 · 9-4-1 [주]① 평균) · 합산형 부모는 단계 갈래. +(불도저 운반 공식 갈래 · 합산형 단계 잎의 암질 행 · 9-4-1 [주]① 평균 · 유로폼 12-38-2 부자재 요율 머리) · 합산형 부모는 단계 갈래. """ from __future__ import annotations @@ -28,6 +28,9 @@ def _add(labels: list[str], seen: set[str], label: str, normalize: Any) -> None: def attach_variant_keys(nodes: list[dict[str, Any]], edition: str) -> None: """표마다 `variant_key`, 공종마다 `variant_keys` 를 채운다(자리에서).""" + from B09_Estimation.B09_Estimation_Euroform import CODE as EUROFORM_CODE + from B09_Estimation.B09_Estimation_Euroform import RATE_TABLE as EUROFORM_RATE_TABLE + from B09_Estimation.B09_Estimation_Euroform import rates as euroform_rates from B09_Estimation.B09_Estimation_MachineProductivity_Dozer import extract_dozer_factors from B09_Estimation.B09_Estimation_ParentSteps import AVERAGE_BASIS, AVERAGE_VARIANT, rock_rows from B09_Estimation.B09_Estimation_ResourceAxis import build_resource_axis @@ -62,6 +65,9 @@ def attach_variant_keys(nodes: list[dict[str, Any]], edition: str) -> None: if code in steps: for rock, _, _ in rock_rows({"tables": [table]}): _add(labels, table_seen, rock, normalize) + if code == EUROFORM_CODE and table.get("pum_table_id") == EUROFORM_RATE_TABLE: + for head in euroform_rates({"tables": [table]}): # 부자재 요율 머리 갈래 + _add(labels, table_seen, head, normalize) table["variant_key"] = labels for label in labels: _add(keys, seen, label, normalize) diff --git a/B09_Estimation/B09_Estimation_Euroform.py b/B09_Estimation/B09_Estimation_Euroform.py new file mode 100644 index 00000000..258e4093 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Euroform.py @@ -0,0 +1,148 @@ +"""B09 원가계산 — **유로폼 사용수량** 12-38-2 (2026-09-14 브레인 301 판정 ①~⑥ · ③′). + +정본 = 산림 12-38-2(10㎡당 패널 0.89매 · 내부 패널 0.03매 · 부자재 주자재비 간단 24 · 보통 52 · 복잡 79% · +소모자재 5%) · 건설 공통 6-3-3 은 참고(고시 총칙 「타 부문과 유사한 공종은 본 품셈 우선」). +원문 「자재비는 거래형태 등을 고려하여 **임대료 또는 손료**로 산정」 — 둘을 나란히 주므로 설계자가 고름(제안값 없음). + + 고르는 자리 「자재 단가」 탭(화약류와 같은 통로) — 넣은 쪽으로 섬 + 손료 패널·내부 패널 단가 × 표 수량 곧장(실무 넷 — 봉화 「31,500 × 0.89 / 10」) · + 부자재·소모자재 = (패널 + 내부 패널) × %(봉화 「2,866.5 × 52%」) + 임대료 「유로폼 임대료 ㎡당」 한 줄(설계자가 임대기간 반영해 셈) · % 는 원문이 안 적어 안 걺 + 둘 다 · 반쯤 · 아무것도 — 안 섬 + 사유(부모 12-38 까지 그대로 올라감) + +⚠ 12-38-1 잔존율(12회 25%)은 곱하지 않음 — 표 수량이 이미 그 몫으로 보임. 곱하면 두 번 나눔(패널 몫 약 1/16). +⚠ 가드(이중계상 ③)에서 12-38-02 를 뺀 까닭은 `B09_Estimation_Guards.SURCHARGE_INCLUDED_ITEMS` 곁에. +""" + +from __future__ import annotations + +import re +from decimal import Decimal +from typing import Any + +CODE = "FP-12-38-02" +PANEL = "AR-M-10aba455" +INNER = "AR-M-5b89b294" +RENT = "AR-M-d00a6b1a" +QUANTITY_TABLE = "F0393" +RATE_TABLE = "F0394" + +CHOICE_MISSING = ( + "자재비 — 임대료 또는 손료 설계자 선택(12-38-2 「거래형태 등을 고려하여 임대료 또는 손료로 산정」)" + " · 「자재 단가」 탭에 패널·내부 패널 단가(손료) 또는 유로폼 임대료 ㎡당(임대료) 중 하나를 넣으면 섬" +) +CHOICE_BOTH = "자재비 — 손료(패널 단가)와 임대료가 둘 다 들어옴 · 하나만 넣을 것" +LOSS_HALF = "자재비 손료 — {name} 단가 없음 · 패널·내부 패널 둘 다 넣어야 섬" +REUSE_NOTE = ( + "12-38-2 표 수량 곧장(실무 넷 실증) · 12-38-1 잔존율은 안 곱함 — 표 수량이 이미 12회·잔존율 25% 몫으로" + " 보임(우리 역산: 10㎡ ÷ 0.72㎡ = 13.9매 × 0.75 ÷ 12 = 0.87 ≈ 0.89 · 원문이 적지는 않음) · 차 2.5% 는" + " [주]① 「할증 및 손율 포함」으로 봄(추정) · 「25회 10%」 는 표에 수량이 없어 안 세움" +) +RENT_NOTE = "설계자 임대료(12-38-2 「임대료는 시중 물가지 등을 참고하여 결정」) · 부자재·소모자재 % 는 원문이 임대료에 안 적어 안 걺" + +_RE_PERCENT = re.compile(r"(\d+(?:\.\d+)?)\s*%") + + +def _tight(text: Any) -> str: + return re.sub(r"\s", "", str(text or "")) + + +def _table(node: dict[str, Any], table_id: str) -> dict[str, Any]: + return next((t for t in node.get("tables") or [] if t.get("pum_table_id") == table_id), {}) + + +def rates(node: dict[str, Any]) -> dict[str, Decimal]: + """부자재 요율 표(F0394) — 머리 갈래(원문 표기 「간 단」…) → %. 마스터 갈래 키도 이것을 씀.""" + table = _table(node, RATE_TABLE) + heads = table.get("condition_note") or [] + for row in table.get("raw_row") or []: + found = {str(h): _RE_PERCENT.search(str(c)) for h, c in zip(heads[1:], row[1:])} + return {h: Decimal(m.group(1)) for h, m in found.items() if m} + return {} + + +def _quantities(node: dict[str, Any]) -> tuple[Decimal | None, Decimal | None, Decimal | None]: + """(패널 매/㎡, 내부 패널 매/㎡, 소모자재 %) — 표 F0393 10㎡당을 1㎡당으로.""" + from B09_Estimation.B09_Estimation_ResourceAxis import parse_amount + + table = _table(node, QUANTITY_TABLE) + per = Decimal(str(table.get("basis_quantity") or 0)) + panel = inner = consumable = None + for row in table.get("raw_row") or []: + name, value = _tight(row[0]), str(row[-1]) + if name == "패널" and parse_amount(value) and per: + panel = parse_amount(value) / per + elif name == "내부패널" and parse_amount(value) and per: + inner = parse_amount(value) / per + elif name.startswith("소모자재") and _RE_PERCENT.search(value): + consumable = Decimal(_RE_PERCENT.search(value).group(1)) + return panel, inner, consumable + + +def attach_euroform(build: Any, nodes_by_code: dict[str, dict[str, Any]]) -> None: + """「자재 단가」 칸 셋을 세우고, 넣은 쪽으로 12-38-02 를 세움 — 부모 합산(`attach_parent_steps`) 앞.""" + from B09_Estimation.B09_Estimation_PriceBook import PriceDetail, PriceKind, PriceTitle + + node = nodes_by_code.get(CODE) or {} + for material in (PANEL, INNER, RENT): + uses = build.material_uses.setdefault(material, []) + if CODE not in uses: + uses.append(CODE) + book = build.book + loss = [m for m in (PANEL, INNER) if m in book.titles] + panel, inner, consumable = _quantities(node) + table_rates = rates(node) + reason = "" + if loss and RENT in book.titles: + reason = CHOICE_BOTH + elif RENT in book.titles: + title_code = f"B-{CODE}" + book.add_title( + PriceTitle( + code=title_code, + kind=PriceKind.UNIT_PRICE, + name="유로폼 사용수량", + spec="임대료", + unit="㎡", + ) + ) + book.add_detail(PriceDetail(title_code, RENT, Decimal(1), note=RENT_NOTE)) + elif len(loss) == 2 and panel and inner and consumable is not None and table_rates: + for head, rate in table_rates.items(): + variant = _tight(head) + title_code = f"B-{CODE}#{variant}" + book.add_title( + PriceTitle( + code=title_code, + kind=PriceKind.UNIT_PRICE, + name=f"유로폼 사용수량 ({variant})", + spec=variant, + unit="㎡", + ) + ) + book.add_detail(PriceDetail(title_code, PANEL, panel, note=REUSE_NOTE)) + book.add_detail( + PriceDetail(title_code, INNER, inner, note="12-38-2 내부 패널 표 수량 곧장") + ) + for label, percent in ( + ("부자재(웨지핀·플랫타이·강관파이프·훅)", rate), + ("소모자재(박리제 등)", consumable), + ): + book.add_detail( + PriceDetail( + title_code, + title_code, + Decimal(0), + note=f"{label} — 주자재비(패널 + 내부 패널)의 {percent}% (12-38-2)", + percent_of_material=percent, + ) + ) + build.variants.setdefault(CODE, []).append(variant) + elif loss: + missing = "내부 패널" if PANEL in loss else "패널" + reason = LOSS_HALF.format(name=missing) + else: + reason = CHOICE_MISSING + if reason: + build.component_gaps[CODE] = reason + build.unattached[CODE] = [reason] diff --git a/B09_Estimation/B09_Estimation_Guards.py b/B09_Estimation/B09_Estimation_Guards.py index 14f30c2a..11576bf4 100644 --- a/B09_Estimation/B09_Estimation_Guards.py +++ b/B09_Estimation/B09_Estimation_Guards.py @@ -231,10 +231,13 @@ def check_handoff_boundaries(violations: list[str] | None) -> None: #: 품셈 [주]가 「재료량에 할증 포함」이라 적은 공종 — 그 재료가 일위대가 재료비로 붙으면 -#: **할증 뒤 값**이 들어가 자재총괄에서 한 번 더 붙는다(㉠). 원문 넷, 코드는 마스터가 붙인 자리. +#: **할증 뒤 값**이 들어가 자재총괄에서 한 번 더 붙는다(㉠). 원문 셋, 코드는 마스터가 붙인 자리. +#: ⚠ 유로폼 사용수량 12-38-02([주]① 「할증 및 손율이 포함」)는 **뺐음**(2026-09-14 브레인 301 ④) — +#: 이 가드의 전제 「자재총괄에서 할증이 한 번 더」가 안 섬: 패널은 B08 자재총괄에 줄이 없음(유로폼 ㎡ 갈 곳 +#: `unit_price`) · 손료 수량이라 할증 전 값이 원문에 없음 · 실무 넷도 일위대가에 곧장 붙임. +#: 유로폼·패널이 자재총괄로 가는 날 되돌릴 것 — `test_b09_euroform` 이 그 자리에서 빨강. SURCHARGE_INCLUDED_ITEMS: dict[str, str] = { "FP-12-02": "용적 배합 콘크리트 참고표 「재료량에는 할증률이 포함」(마스터가 12-2 에 붙임)", - "FP-12-38-02": "유로폼 사용수량 [주]① 「재료량에는 재료의 할증 및 손율이 포함」", "FP-13-11-04": "돌망태 사각형 [주]① 「자재비에는 재료의 할증을 포함」", "AX-WK-c0842a0d": "모르타르 배합 참고자료 ※ 「위 재료량은 할증이 포함된 것이다」", } diff --git a/B09_Estimation/B09_Estimation_ParentSteps.py b/B09_Estimation/B09_Estimation_ParentSteps.py index 934f5dc0..2faf58c1 100644 --- a/B09_Estimation/B09_Estimation_ParentSteps.py +++ b/B09_Estimation/B09_Estimation_ParentSteps.py @@ -186,7 +186,9 @@ def _assemble(build: Any, node: dict[str, Any], names: dict[str, str]) -> None: return per_step.append((step, weight, titles)) varying = [item for item in per_step if set(item[2]) - {""}] - if len(varying) > 1: + # 갈래가 같은 이름으로 여러 단계에 걸치면 갈래끼리 합산(유로폼 12-38 = 사용수량 + 설치·해체 + # 둘 다 간단·보통·복잡 · 2026-09-14 301). 갈래 벌이 다르면 짝을 못 지어 종전대로 막음. + if len({frozenset(set(ts) - {""}) for _, _, ts in varying}) > 1: build.component_gaps[parent] = "갈래가 두 단계 이상에 걸쳐 조립하지 않았습니다" return units = {build.book.titles[t].unit for _, _, ts in per_step for t in ts.values()} - {""} diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 24f5169f..835e85f4 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -1056,6 +1056,10 @@ def build_unit_prices( covered += machine_share if covered < Decimal(100): build.partial_ratio[work_item_code] = covered + # 유로폼 사용수량(12-38-2) — 임대료 또는 손료 설계자 단가로 섬(2026-09-14 301). 부모 합산 앞. + from B09_Estimation.B09_Estimation_Euroform import attach_euroform + + attach_euroform(build, nodes_by_code) # 단계 합산형 부모(9-4 암절취 = 암파쇄 + 집토) — 잎이 다 선 뒤, 조합 16% 바꿔 달기 전(명세 2장). from B09_Estimation.B09_Estimation_ParentSteps import attach_parent_steps diff --git a/resources/data_resource_catalog/resource_catalog_ext_2026-01-01.json b/resources/data_resource_catalog/resource_catalog_ext_2026-01-01.json index 0b71fbef..bff82f48 100644 --- a/resources/data_resource_catalog/resource_catalog_ext_2026-01-01.json +++ b/resources/data_resource_catalog/resource_catalog_ext_2026-01-01.json @@ -342,6 +342,45 @@ "M00118" ] } + }, + { + "code": "AR-M-10aba455", + "kind": "material", + "name": "유로폼 패널", + "spec": "600 x 1,200mm", + "unit": "매", + "source": { + "pum_edition": "2026-01-01", + "pum_table_ids": [ + "F0393" + ] + } + }, + { + "code": "AR-M-5b89b294", + "kind": "material", + "name": "유로폼 내부 패널", + "spec": "(200+200) x 1,200mm", + "unit": "매", + "source": { + "pum_edition": "2026-01-01", + "pum_table_ids": [ + "F0393" + ] + } + }, + { + "code": "AR-M-d00a6b1a", + "kind": "material", + "name": "유로폼 임대료", + "spec": "㎡당(임대기간 반영 · 설계자 셈)", + "unit": "㎡", + "source": { + "pum_edition": "2026-01-01", + "pum_table_ids": [ + "F0393" + ] + } } ] } diff --git a/resources/data_work_item_master/_manifest.json b/resources/data_work_item_master/_manifest.json index 4923d032..b991f144 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-14T19:15:46+09:00", + "generated_at": "2026-09-14T21:01:09+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": "183a2933a2bf6f06fb99c0b86dc4073ec42c12a95784b3dd279f5eee50ee088f", - "size_bytes": 838487 + "sha256": "915a1403611a9c713e5c59bff4422a2fe9f53d4168a44a9fada43d14b6c9d9d6", + "size_bytes": 838599 }, { "file": "form_undetermined_2026-01-01.json", @@ -22,8 +22,8 @@ }, { "file": "basis_missing_2026-01-01.json", - "sha256": "f850742268dcfb1bcc878e8b062ef44bc6a11d773074163ebb8ba1dec21a6d0d", - "size_bytes": 16790 + "sha256": "d57f26c736829ef40c50dc616d96358db785295d1aa0d5959aa7bc9eaa9be842", + "size_bytes": 15845 } ] } \ No newline at end of file diff --git a/resources/data_work_item_master/basis_missing_2026-01-01.json b/resources/data_work_item_master/basis_missing_2026-01-01.json index 1d66cb0e..6b7cdc49 100644 --- a/resources/data_work_item_master/basis_missing_2026-01-01.json +++ b/resources/data_work_item_master/basis_missing_2026-01-01.json @@ -172,12 +172,6 @@ "pum_form": "requirement", "line": 2689 }, - { - "pum_table_id": "F0132", - "section": "5-22-4. 평떼 시비", - "pum_form": "requirement", - "line": 2731 - }, { "pum_table_id": "F0143", "section": "5-27. 식재면 관리", @@ -190,12 +184,6 @@ "pum_form": "requirement", "line": 2919 }, - { - "pum_table_id": "F0145", - "section": "5-28-2. 방초매트 및 야자섬유매트 포장", - "pum_form": "requirement", - "line": 2929 - }, { "pum_table_id": "F0153", "section": "6-1. 비료주기", @@ -628,24 +616,6 @@ "pum_form": "requirement", "line": 6217 }, - { - "pum_table_id": "F0339", - "section": "12-7-1. 포장절단", - "pum_form": "requirement", - "line": 6254 - }, - { - "pum_table_id": "F0340", - "section": "12-7-2. 줄눈설치", - "pum_form": "requirement", - "line": 6269 - }, - { - "pum_table_id": "F0341", - "section": "12-8. 콘크리트 포장 거푸집", - "pum_form": "requirement", - "line": 6280 - }, { "pum_table_id": "F0350", "section": "12-12. 날개벽", @@ -676,12 +646,6 @@ "pum_form": "requirement", "line": 6775 }, - { - "pum_table_id": "F0395", - "section": "12-38-3. 설치 및 해체", - "pum_form": "requirement", - "line": 6943 - }, { "pum_table_id": "F0401", "section": "13-2-4. 야면석 채집(인력)", @@ -729,12 +693,6 @@ "section": "13-15-2. 목책 설치", "pum_form": "requirement", "line": 7745 - }, - { - "pum_table_id": "F0449", - "section": "13-16-2. 통기성매트", - "pum_form": "requirement", - "line": 7788 } ] } \ No newline at end of file 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 dde1989d..5388da5c 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-14T19:15:46+09:00", + "generated_at": "2026-09-14T21:01:09+09:00", "dataset_version": { "dataset_id": "pum_forest", "effective_date": "2026-01-01", @@ -23,7 +23,7 @@ "tables_orphan": 17, "form_undetermined": 14, "basis_found": 204, - "basis_missing": 122, + "basis_missing": 115, "basis_grouped": 48 }, "orphan_tables": [ @@ -35677,9 +35677,9 @@ ], "steps_basis": "품셈 12-38 유로폼 = 12-38-2 사용수량(자재) + 12-38-3 설치 및 해체(품) — 12-38-1 사용횟수는 금액 단계가 아니라 사용수량의 잔존율 조건", "variant_keys": [ - "복 잡", + "간 단", "보 통", - "간 단" + "복 잡" ] }, { @@ -35803,7 +35803,11 @@ "formula_rows": [], "special_glyphs": [], "capacity_formula_here": false, - "variant_key": [], + "variant_key": [ + "간 단", + "보 통", + "복 잡" + ], "condition_note": [ "구 분", "간 단", @@ -35820,7 +35824,11 @@ ] } ], - "variant_keys": [] + "variant_keys": [ + "간 단", + "보 통", + "복 잡" + ] }, { "work_item_code": "FP-12-38-03", diff --git a/resources/tester/test_b09_euroform.py b/resources/tester/test_b09_euroform.py new file mode 100644 index 00000000..7846f3b3 --- /dev/null +++ b/resources/tester/test_b09_euroform.py @@ -0,0 +1,124 @@ +"""유로폼 사용수량 12-38-2 — 2026-09-14 브레인 301 판정 ①~⑥ · ③′. + +① 산림 12-38-2 정본 ② 「임대료 또는 손료」 설계자 선택 · 제안값 없음 +③′ 손료 = 자재가 × 표 수량 곧장(실무 넷 — 봉화 「31,500 × 0.89 / 10」) · 12-38-1 잔존율은 안 곱함 +④ 이중계상 ③ 가드에서 12-38-02 만 뺌 — 패널이 B08 자재총괄에 줄이 없어서. 되살아날 자리를 시험으로 걺 +⑤ 고르는 자리 = 「자재 단가」 탭(넣은 쪽으로 섬 · 둘 다면 안 섬) ⑥ 임대료 갈래엔 % 안 걺 +""" + +from __future__ import annotations + +from decimal import Decimal + +from B09_Estimation.B09_Estimation_UnitPrice import cached_build, detail_of + +PANEL = "AR-M-10aba455" +INNER = "AR-M-5b89b294" +RENT = "AR-M-d00a6b1a" +LOSS = ((PANEL, "31500", "봉화 2024"), (INNER, "21000", "봉화 2024")) + + +def _titles(build, code: str) -> list[str]: + return [t for t in build.book.titles if t == f"B-{code}" or t.startswith(f"B-{code}#")] + + +def test_아무것도_안_넣으면_안_서고_설계자_선택_사유() -> None: + build = cached_build() + assert not _titles(build, "FP-12-38-02") + reason = build.component_gaps.get("FP-12-38-02", "") + assert "임대료 또는 손료" in reason, reason + assert "임대료 또는 손료" in build.component_gaps.get("FP-12-38", "") # 부모까지 까닭이 올라감 + for material in (PANEL, INNER, RENT): + assert "FP-12-38-02" in build.material_uses.get(material, []), material + + +def test_손료는_표_수량_곧장_부자재_소모자재는_주자재비의_퍼센트() -> None: + """봉화 2024 호표 「유로폼 설치 및 해체」 재료비 보통 4,500 · 간단 3,697 과 같은 값 — 실무 실증.""" + build = cached_build(material_prices=LOSS) + assert sorted(_titles(build, "FP-12-38-02")) == [ + "B-FP-12-38-02#간단", + "B-FP-12-38-02#보통", + "B-FP-12-38-02#복잡", + ] + detail = detail_of(build, "B-FP-12-38-02#보통")["rows"] + rows = {r["ref_code"]: Decimal(r["quantity"]) for r in detail if r.get("ref_code")} + assert rows[PANEL] == Decimal("0.089") and rows[INNER] == Decimal("0.003"), rows + assert build.book.resolve("B-FP-12-38-02#보통").material == Decimal(4500) + assert build.book.resolve("B-FP-12-38-02#간단").material == Decimal(3697) # 봉화 간단 3,697 + # 부모 12-38 = 사용수량 + 설치 및 해체 — 두 단계 갈래가 같으면 갈래끼리 합산 + parent = {r["ref_code"] for r in detail_of(build, "B-FP-12-38#보통")["rows"]} + assert parent == {"B-FP-12-38-02#보통", "B-FP-12-38-03#보통"}, parent + + +def test_작업조_표는_밑수가_시공량이라_밑수_없음이_아님() -> None: + """12-38-3 「(단위: 일 당) 형틀목공 4인 · 시공량 35㎡」 — 1㎡당 = 인원 ÷ 시공량(CrewOutput). + + 밑수 「N㎡당」 문구가 없다고 `basis_missing` 에 들어 설치·해체가 통째로 막혀 있었음(작업조 표 넷). + """ + build = cached_build() + crew = ("FP-05-28-02", "FP-12-07-02", "FP-12-38-03", "FP-13-16-02") + assert not [code for code in crew if code in build.basis_missing] + rows = { + r["name"]: Decimal(r["quantity"]) for r in detail_of(build, "B-FP-12-38-03#보통")["rows"] + } + assert rows["형틀목공"] == Decimal(4) / Decimal(35), rows + + +def test_잔존율은_안_곱하고_역산은_추정이라_밝힘() -> None: + build = cached_build(material_prices=LOSS) + note = next( + r["note"] + for r in detail_of(build, "B-FP-12-38-02#보통")["rows"] + if r.get("ref_code") == PANEL + ) + assert "우리 역산" in note and "추정" in note and "25회" in note, note + + +def test_임대료는_한_줄만_퍼센트_안_걺() -> None: + build = cached_build(material_prices=((RENT, "5000", "견적"),)) + assert _titles(build, "FP-12-38-02") == ["B-FP-12-38-02"] + rows = detail_of(build, "B-FP-12-38-02")["rows"] + assert [r["ref_code"] for r in rows] == [RENT], rows + assert build.book.resolve("B-FP-12-38-02").material == Decimal(5000) + assert "B-FP-12-38#보통" in build.book.titles + + +def test_둘_다_넣거나_한쪽만_반쯤_넣으면_안_섬() -> None: + both = cached_build(material_prices=(*LOSS, (RENT, "5000", "견적"))) + assert not _titles(both, "FP-12-38-02") + assert "둘 다" in both.component_gaps["FP-12-38-02"] + half = cached_build(material_prices=(LOSS[0],)) + assert not _titles(half, "FP-12-38-02") + assert "내부 패널" in half.component_gaps["FP-12-38-02"] + + +def test_유로폼이_자재총괄에_줄로_서면_가드를_되살릴_것() -> None: + """④ 12-38-02 를 가드에서 뺀 전제 — 패널·유로폼이 B08 자재총괄(`material`)에 줄이 없음. + + 누가 유로폼을 자재총괄로 보내는 날 할증이 두 번 붙음 → 여기서 빨강 · 가드에 12-38-02 를 되돌릴 것. + """ + import json + from pathlib import Path + + from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import DESTINATION + from B09_Estimation.B09_Estimation_Guards import SURCHARGE_INCLUDED_ITEMS + + assert "FP-12-38-02" not in SURCHARGE_INCLUDED_ITEMS + words = ("유로폼", "패널") + assert all(DESTINATION[n] != "material" for n in DESTINATION if any(w in n for w in words)) + root = Path(__file__).resolve().parents[2] / "resources" / "data_structure_unit" + for path in root.glob("*.json"): + text = path.read_text(encoding="utf-8") + + def walk(node): + if isinstance(node, dict): + name = str(node.get("name") or "") + if any(w in name for w in words): + assert node.get("destination") != "material", (path.name, node) + for value in node.values(): + walk(value) + elif isinstance(node, list): + for value in node: + walk(value) + + walk(json.loads(text)) diff --git a/resources/tester/test_b09_parent_steps.py b/resources/tester/test_b09_parent_steps.py index 9521de47..d659b250 100644 --- a/resources/tester/test_b09_parent_steps.py +++ b/resources/tester/test_b09_parent_steps.py @@ -57,7 +57,7 @@ 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 "FP-12-38-02" in build.component_gaps["FP-12-38"] # 사용수량 — 임대료·손료 단가 안 넣음 # 깎기 표엔 기계 이름이 없음 — [주] 원문의 기종으로 섬(부모에서 물려받지 않음) assert "[주]" in build.book.details["D-FP-09-05-02#경암"][0].note # Q 줄은 D 에 diff --git a/resources/tester/test_double_count_boundaries.py b/resources/tester/test_double_count_boundaries.py index 97a41721..07786c25 100644 --- a/resources/tester/test_double_count_boundaries.py +++ b/resources/tester/test_double_count_boundaries.py @@ -112,9 +112,10 @@ def test_자재_합계는_할증_한_번() -> None: def test_할증_포함_재료량_공종에_자재가_재료비로_붙으면_오류() -> None: book = PriceBook() - book.add_title(PriceTitle(code="M-시험", kind=PriceKind.MATERIAL, name="패널", unit="매")) - book.add_title(PriceTitle(code="B-FP-12-38-02", kind=PriceKind.UNIT_PRICE, name="사용수량")) - book.add_detail(PriceDetail("B-FP-12-38-02", "M-시험", Decimal("0.089"))) + # 유로폼 12-38-02 는 가드에서 뺐음(2026-09-14 301 ④ · 자재총괄에 줄 없음) — 돌망태 사각형으로 잼 + book.add_title(PriceTitle(code="M-시험", kind=PriceKind.MATERIAL, name="철망태", unit="㎥")) + book.add_title(PriceTitle(code="B-FP-13-11-04", kind=PriceKind.UNIT_PRICE, name="사각형")) + book.add_detail(PriceDetail("B-FP-13-11-04", "M-시험", Decimal("1.0"))) # 금액을 세우지 않고도 걸림 — 줄이 붙는 순간이 어긴 자리 with pytest.raises(DoubleCountError): check_materials_before_surcharge(book)