From b23db36c783815e357a30c1f32a2ff7e603ef179 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 19:05:55 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(b08):=20=EC=98=B9=EB=B2=BD=20=EC=A1=B0?= =?UTF-8?q?=EB=A6=BD=20=EC=A1=B0=EA=B0=81=20=ED=95=A9=ED=8C=90=EA=B1=B0?= =?UTF-8?q?=ED=91=B8=EC=A7=91=EC=9D=B4=20=EC=82=AC=EC=9A=A9=ED=9A=9F?= =?UTF-8?q?=EC=88=98=20=EA=B0=88=EB=9E=98=EB=A1=9C(=E3=80=8CFP-12-04#3?= =?UTF-8?q?=ED=9A=8C=E3=80=8D=20=C2=B7=20=EC=84=B1=EB=B6=84=EC=9D=98=20reu?= =?UTF-8?q?se=5Fcount=20=EA=B7=B8=EB=8C=80=EB=A1=9C)=20=C2=B7=20=EC=A7=91?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EA=B1=B0=ED=91=B8=EC=A7=91=206=ED=9A=8C?= =?UTF-8?q?=20=E2=86=92=204=ED=9A=8C(12-15=20=ED=91=9C=20=E3=80=8C?= =?UTF-8?q?=ED=95=A9=ED=8C=904=ED=9A=8C=E3=80=8D=20=EC=A0=95=EB=B3=B8=20?= =?UTF-8?q?=C2=B7=20=EC=82=AC=EC=9C=A0=20=EB=AC=B8=EA=B5=AC=EB=8F=84=20?= =?UTF-8?q?=EA=B7=B8=20=ED=91=9C)=20=E2=80=94=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=9E=90=EC=9E=AC=20=ED=91=9C=C2=B7=EC=9D=B8=EA=B3=84=20?= =?UTF-8?q?=EC=84=B1=EB=B6=84=20=ED=95=9C=20=EB=B2=8C(=EB=B8=8C=EB=A0=88?= =?UTF-8?q?=EC=9D=B8=20=E3=89=AF=20=E2=91=A0=E2=91=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- B08_Quantity/B08_Quantity_Engine_Formwork.py | 6 ++- .../B08_Quantity_Engine_Handoff_Mapping.py | 24 ++++++++++ .../formwork_reuse_2026-01-01.json | 7 +-- .../work_item_mapping_2026-01-01.json | 3 +- .../tester/test_b08_formwork_reuse_link.py | 45 +++++++++++++++++++ 5 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 resources/tester/test_b08_formwork_reuse_link.py diff --git a/B08_Quantity/B08_Quantity_Engine_Formwork.py b/B08_Quantity/B08_Quantity_Engine_Formwork.py index 65c80556..bc4bc0d1 100644 --- a/B08_Quantity/B08_Quantity_Engine_Formwork.py +++ b/B08_Quantity/B08_Quantity_Engine_Formwork.py @@ -105,15 +105,17 @@ def annotate( component["reuse_note"] = NOTE_REUSE_MISSING continue count = entry.get("reuse_count") + # 근거 — 대개 1-7-1 분류 · 그 공종 표가 직접 적었으면 그 표(집수정 12-15 · 2026-09-14). + basis = str(entry.get("basis") or "품셈 1-7-1") for component in targets: component["reuse_count"] = count component["reuse_note"] = ( - f"품셈 1-7-1 {count}회 — 「{entry.get('matched_example')}」" + f"{basis} {count}회 — 「{entry.get('matched_example')}」" if count else NOTE_NOT_APPLICABLE ) if count: - notes.append(f"{structure.get('name') or type_id} 거푸집 {count}회 (품셈 1-7-1)") + notes.append(f"{structure.get('name') or type_id} 거푸집 {count}회 ({basis})") else: missing.append(type_id) return notes, sorted(set(missing)) diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py index 4d8fa446..822d67b7 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py @@ -382,6 +382,30 @@ def composite_quantities( entry["not_ready"] = True entry["why"] = why missing.append({"code": spec.get("code"), "reason": why}) + if suffix == "formwork_reuse": + # 12-4 사용횟수 갈래 — B08 이 성분에 단 횟수(`Formwork.annotate`) 그대로(한 벌 · 09-14). + counts = { + component.get("reuse_count") + for component in structure.get("components") or [] + if str(component.get("name") or "").strip() in found + } + count = next(iter(counts)) if len(counts) == 1 else None + if isinstance(count, int) and count > 0: + entry["kind"] = f"{count}회" + entry["kind_basis"] = next( + ( + str(component.get("reuse_note") or "") + for component in structure.get("components") or [] + if str(component.get("name") or "").strip() in found + ), + "", + ) + entry["code"] = f"{spec.get('code')}#{count}회" + elif found: + why = "거푸집 사용횟수가 없거나 둘 이상이라 12-4 갈래를 못 고름" + entry["not_ready"] = True + entry["why"] = why + missing.append({"code": spec.get("code"), "reason": why}) if suffix == "rebar_complexity": # 갈래는 원문이 정한다 — 화면·인계에 이름과 근거를 함께 실어 사람이 검증하게 한다. complexity, why = rebar_complexity( diff --git a/resources/data_formwork/formwork_reuse_2026-01-01.json b/resources/data_formwork/formwork_reuse_2026-01-01.json index a8cc0429..ebc76622 100644 --- a/resources/data_formwork/formwork_reuse_2026-01-01.json +++ b/resources/data_formwork/formwork_reuse_2026-01-01.json @@ -67,9 +67,10 @@ }, { "type_id": "pipe_inlet_basin", - "reuse_count": 6, - "matched_example": "보호공 기초", - "note": "관보호공 집수정 — 원문 6회 줄의 「호안 및 보호공의 기초」에 해당. ⚠ 벽체까지 6회로 볼지는 확인 필요" + "reuse_count": 4, + "matched_example": "거푸집 합판4회", + "note": "그 공종 표가 직접 적은 「거푸집 | 합판4회」 가 정본 — 1-7-1 분류(「호안 및 보호공의 기초」 6회)보다 위(2026-09-14 브레인 ㉯ ②). 앞서 6회로 봤음.", + "basis": "품셈 12-15 집수정 표" }, { "type_id": "ford_pavement", 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 037592ad..785214d5 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 @@ -346,7 +346,8 @@ "unit": "㎡", "from_components": [ "합판거푸집" - ] + ], + "kind_suffix": "formwork_reuse" }, { "code": "FP-12-38", diff --git a/resources/tester/test_b08_formwork_reuse_link.py b/resources/tester/test_b08_formwork_reuse_link.py new file mode 100644 index 00000000..0e6a4d3c --- /dev/null +++ b/resources/tester/test_b08_formwork_reuse_link.py @@ -0,0 +1,45 @@ +"""거푸집 사용횟수 → 12-4 갈래 잇기 · 집수정 4회 — 2026-09-14 브레인 ㉯ ①②. + +① 옹벽 조립 조각 「FP-12-04 합판거푸집」 에 꼬리가 없어 12-4 사용횟수 갈래(#1~6회)를 못 고름 → + B08 이 성분에 단 사용횟수(`reuse_count`, 품셈 1-7-1 옹벽 3회)를 꼬리로 붙임(값은 한 벌). +② 집수정은 **12-15 표가 직접 적은 「거푸집 합판4회」 가 정본** — 1-7-1 분류로 본 6회는 걷음. + 화면(자재 표 · 인계)과 표가 같은 값이어야 함(한 벌이 두 값으로 갈리지 않게). +""" + +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_Formwork import FORMWORK_NAMES # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 + +WALL = {"structure_id": "w1", "type_id": "retaining_wall", "start_m": 100.0, "end_m": 110.0, + "options": {"form": "반중력식", "height_m": 2.0, "length_m": 10.0}} # fmt: skip +BASIN = {"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 + + +def test_옹벽_조각_합판거푸집이_사용횟수_갈래로_이어져_단가가_섬() -> None: + from B09_Estimation.B09_Estimation_UnitPrice import cached_build + + unit = build_table([WALL], {"retaining_wall": "옹벽"}) + row = next( + r for r in build_handoff(unit_quantity_table=unit)["work_items"] if r["composite_parts"] + ) + part = next(p for p in row["composite_parts"] if str(p["code"]).startswith("FP-12-04")) + assert part["code"] == "FP-12-04#3회" and not part.get("not_ready"), part + assert "B-FP-12-04#3회" in cached_build().book.titles + + +def test_집수정_거푸집은_12_15_표의_4회_화면과_인계가_같은_값() -> None: + unit = build_table([BASIN], {"pipe_inlet_basin": "집수정"}) + forms = [c for s in unit["structures"] for c in s["components"] if c["name"] in FORMWORK_NAMES] + assert forms and all(c["reuse_count"] == 4 for c in forms), forms + assert all("12-15" in c["reuse_note"] for c in forms), forms From 5a016979c40673ade37187f80d342bec0e35b159 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 19:22:08 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(b09):=20=EC=95=88=EC=A0=84=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EB=B9=84=20=EC=A0=9C3=EC=A1=B0=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=20=ED=95=98=ED=95=9C=EC=9D=84=20=EC=97=94=EC=A7=84=EC=9D=B4=20?= =?UTF-8?q?=EC=9D=BD=EA=B2=8C=20=ED=95=A8=20=C2=B7=201.2=EB=B0=B0=20?= =?UTF-8?q?=EC=B0=A8=EB=A1=80=EC=99=80=20=EA=B4=80=EA=B8=89=20=C3=B71.1=20?= =?UTF-8?q?=EC=82=AC=EC=9C=A0=20=EB=AA=85=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3. 제3조(적용범위) 「총공사금액 2천만 원 이상」 — 요율 데이터의 minimum_total_construction_amount_krw 를 규모 기준액과 견주고 미달이면 줄 자체를 안 만듦 (A·B 곁줄도 안 섬 · 0 원으로 안 채움). 잴 시험 먼저 빨강 확인한 뒤 고침. ⚠ 견주는 값은 규모 기준액(추정가격 · 없으면 수렴한 총원가) — 고시의 「총공사금액」과 딱 같은 말은 아니나 계산 차례상 안전관리비 앞에 설 수 있는 값이 그것뿐. 보건관리자 문턱과 같은 축. 1. 1.2배 차례 = STmate 쪽 확정(브레인 판정) — 안 고른 갈래의 사유를 코드에 남김. 거창 2025 원본은 버림(밑수 × 율 × 1.2) 로 1원 위지만 STmate 출력이 아니라 채택하지 않음. 4. 관급 ÷1.1 = 실무 따름 — 근거를 정확히 적음. 고시 제4조① 단서는 「해당 재료비를 대상액에 포함」까지만이고 부가세를 말하지 않음 · ÷1.1 은 부가세 제외 환산으로 실무 6건 공통. 전체 시험 1952 통과 · 28 건너뜀 · 1 xfail. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA --- B09_Estimation/B09_Estimation_Engine_Cost.py | 5 ++- B09_Estimation/B09_Estimation_Statutory.py | 15 +++++++++ resources/tester/test_b09_safety_notice.py | 32 ++++++++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/B09_Estimation/B09_Estimation_Engine_Cost.py b/B09_Estimation/B09_Estimation_Engine_Cost.py index 47a46469..f58eb6ab 100644 --- a/B09_Estimation/B09_Estimation_Engine_Cost.py +++ b/B09_Estimation/B09_Estimation_Engine_Cost.py @@ -87,7 +87,10 @@ class CostInput: #: 안전관리비 대상액에 들어가는 **도급자설치 관급금액**. None 이면 관급 전액. owner_supplied_for_safety_krw: Decimal | None = None - #: 그 금액이 부가세 포함인가 — 포함이면 1.1 로 나눈다(규정: 부가세 제외 기준). + #: 그 금액이 부가세 포함인가 — 포함이면 1.1 로 나눈다. + #: ⚠ 근거는 **실무**다 — 고시(산업안전보건관리비 계상 및 사용기준) 제4조① 단서는 「해당 + #: 재료비를 **대상액에 포함**」까지만 적고 부가세를 말하지 않는다. ÷1.1 은 부가세 제외 + #: 환산이며, 실무 원가계산서 **6건이 모두** 「관급재/1.1」로 적었다(2026-09-14 전수 확인). owner_supplied_includes_vat: bool = True #: 규모 구간 판정에 쓸 **추정가격**. 주면 그 값으로 한 번만 판정한다. #: 없으면 직접공사비를 씨앗으로 **반복 수렴**한다 (`calculate_cost` 참조). diff --git a/B09_Estimation/B09_Estimation_Statutory.py b/B09_Estimation/B09_Estimation_Statutory.py index f8712276..0ef4d11c 100644 --- a/B09_Estimation/B09_Estimation_Statutory.py +++ b/B09_Estimation/B09_Estimation_Statutory.py @@ -199,10 +199,22 @@ def safety_management_cost( variable = dataset.variable("rate_safety_pct") brackets = variable["brackets"] + # 제3조(적용범위) — 「총공사금액 2천만 원 이상인 공사에 적용」. 하한은 요율 데이터가 든다. + # ⚠ 견주는 값은 **규모 기준액**(설계자가 준 추정가격 · 없으면 수렴한 총원가)이다. 고시의 + # 「총공사금액」과 딱 같은 말은 아니나(관급·부가세 자리가 다름) 계산 차례상 안전관리비 + # 앞에 설 수 있는 값이 그것뿐이라 같은 축으로 쓴다 — 보건관리자 문턱도 같은 축이다. + if not _threshold_met( + dataset, "rate_safety_pct", "minimum_total_construction_amount_krw", ctx.scale_reference + ): + return _ZERO # 대상 아님 — 줄 자체를 만들지 않는다(0 원으로 채우지 않음) + owner_supplied = data.owner_supplied_for_safety_krw if owner_supplied is None: owner_supplied = data.owner_supplied_material_krw if data.owner_supplied_includes_vat: + # 제4조① 단서는 「해당 재료비를 **대상액에 포함**」까지만 적고 부가세를 말하지 않는다. + # ÷1.1 은 **부가세 제외 환산**이며 근거는 실무다 — 실무 원가계산서 **6건이 모두** + # 「(직노+직재+간재+관급재/1.1) × 율」로 적었다(2026-09-14 골든셋 전수 확인). owner_supplied = owner_supplied / _VAT_DIVISOR base_with = ctx.material_cost + ctx.direct_labor_cost + owner_supplied @@ -255,6 +267,9 @@ def safety_management_cost( # 1.2배의 대상은 1·2호로 **산정이 끝난 금액**이다. 종전엔 1.2 를 곱한 뒤 한 번만 버려 # 영월 2024 B 줄이 20,330,639 로 원본(20,330,638)보다 1원 컸다(골든셋 실증). # A(배수 1)는 어느 차례로 해도 같은 값이다. + # ⚠ 안 고른 갈래 — 거창 2025 원본은 `버림(밑수 × 율 × 1.2)` 로 1원 위다. 그 서류는 + # 시트 이름·줄 차례가 달라 **STmate 출력이 아니며**, 우리 기준은 STmate 재현이라 + # 사유로만 남기고 채택하지 않는다(브레인 판정 2026-09-14). return floor_won(base * percent / _HUNDRED + flat) * multiplier, percent, flat raw_a, percent_a, flat_a = evaluate(base_with, Decimal(1), "안전관리비 A(관급 포함)") diff --git a/resources/tester/test_b09_safety_notice.py b/resources/tester/test_b09_safety_notice.py index 1efb0942..b749fe02 100644 --- a/resources/tester/test_b09_safety_notice.py +++ b/resources/tester/test_b09_safety_notice.py @@ -172,3 +172,35 @@ def test_대상액은_직재_간재_직노_그리고_발주자_제공_재료다( assert owner.line("safety_management_cost_a").base_amount_krw == base + 30_000_000 # 관급 제외 밑수(B)는 관급이 들어도 안 움직임. assert owner.line("safety_management_cost_b").base_amount_krw == base + + +def _scaled(estimated_price: Decimal) -> object: + """총공사금액(규모 기준액)만 갈아 끼워 돌림 — 대상액은 구간이 안 갈리게 작게 둠.""" + return calculate_cost( + CostInput( + direct_material_krw=Decimal(5_000_000), + direct_labor_krw=Decimal(5_000_000), + direct_expense_krw=Decimal(0), + enabled_items=("safety_management_cost",), + estimated_price_krw=estimated_price, + cut_basis="none", + ) + ) + + +def test_제3조_총공사금액_2천만원_미만이면_줄이_안_선다() -> None: + """제3조 — 「총공사금액 2천만 원 이상인 공사에 적용」. 하한은 요율 데이터가 들고 있음. + + ⚠ 우리가 견주는 값은 **규모 기준액**(설계자가 준 추정가격 · 없으면 수렴한 총원가)임. + 고시의 「총공사금액」과 딱 같은 말은 아니나(관급·부가세 자리가 다름) 계산 차례상 + 안전관리비 앞에 설 수 있는 값이 그것뿐이라 같은 축으로 씀 — 보건관리자 문턱도 같은 축. + """ + minimum = json.load(io.open(RATES, encoding="utf-8"))["variables"]["rate_safety_pct"].get( + "minimum_total_construction_amount_krw" + ) + assert minimum == 20_000_000, minimum + assert not _scaled(Decimal(minimum) - 1).has("safety_management_cost") + assert _scaled(Decimal(minimum)).has("safety_management_cost") + # 안 서면 A·B 곁줄도 안 선다 — 0 원으로 채우지 않음. + below = _scaled(Decimal(minimum) - 1) + assert not below.has("safety_management_cost_a") and not below.has("safety_management_cost_b") From 7c9f75ca12e3e864ff51f1406c90271bcff492ca Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 19:38:27 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(b08,b09):=2012-15=20=EC=A7=91=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=A1=B0=EB=A6=BD=20=E2=80=94=20=EC=BD=98=ED=81=AC?= =?UTF-8?q?=EB=A6=AC=ED=8A=B8=20=EC=A7=91=EC=88=98=EC=A0=95=EB=A7=8C=20?= =?UTF-8?q?=EB=AC=B6=EC=9D=8C(when)=20=C2=B7=20=EC=A1=B0=EA=B0=81=20?= =?UTF-8?q?=EA=B5=AC=EC=B2=B4=EC=BD=98=ED=81=AC=EB=A6=AC=ED=8A=B8=20?= =?UTF-8?q?=E3=8E=A5=20=C2=B7=20=EB=B2=84=EB=A6=BC=EC=BD=98=ED=81=AC?= =?UTF-8?q?=EB=A6=AC=ED=8A=B8(=EC=84=B1=EB=B6=84=20=EC=97=86=EC=9D=8C=20?= =?UTF-8?q?=EB=A7=89=ED=9E=98)=20=C2=B7=20=ED=95=A9=ED=8C=90=EA=B1=B0?= =?UTF-8?q?=ED=91=B8=EC=A7=91=20#4=ED=9A=8C=20=C2=B7=20=EC=B2=A0=EA=B7=BC?= =?UTF-8?q?=20#=EB=B3=B4=ED=86=B5(12-15=20=ED=91=9C=20=EC=A0=95=EB=B3=B8?= =?UTF-8?q?=20=C2=B7=20=EA=B0=84=EB=8B=A8=20=EC=B6=94=EC=A0=95=20=EA=B1=B7?= =?UTF-8?q?=EC=9D=8C)=20=C2=B7=20=ED=8C=90=EC=A0=95=ED=91=9C=20=EB=B9=84?= =?UTF-8?q?=EA=B3=A0=20=EC=B9=B8=20=EC=9D=B8=EB=A0=A5=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?#=EB=B2=84=EB=A6=BC=EC=BD=98=ED=81=AC=EB=A6=AC=ED=8A=B8(87,489?= =?UTF-8?q?=EC=9B=90/=E3=8E=A5)=20=C2=B7=20=EA=B5=AC=EC=B2=B4=EB=8A=94=20?= =?UTF-8?q?=EB=8B=A4=EC=A7=90=EA=B8=B0=20=EC=97=86=EC=96=B4=20=EC=95=88=20?= =?UTF-8?q?=EC=84=B8=EC=9A=B0=EA=B3=A0=20=EC=82=AC=EC=9C=A0=20=C2=B7=20?= =?UTF-8?q?=EB=9A=9C=EA=BB=91=C2=B7=EC=84=A4=EC=B9=98=EB=B9=84=20=EC=82=AC?= =?UTF-8?q?=EC=9C=A0=20=C2=B7=20=EB=8F=8C=EC=A7=91=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EC=A2=85=EC=A0=84=EB=8C=80=EB=A1=9C=20=C2=B7=20=EB=AC=B6?= =?UTF-8?q?=EC=9D=8C=EC=9D=80=20=ED=83=80=EC=84=A4=C2=B7=EC=9E=A1=EC=84=9D?= =?UTF-8?q?=20=EC=A4=84=EC=9D=B4=20=EB=98=90=20=EC=95=88=20=EC=85=88=20?= =?UTF-8?q?=C2=B7=20=EB=A7=88=EC=8A=A4=ED=84=B0=20=EC=9E=AC=EC=83=9D?= =?UTF-8?q?=EC=84=B1(=EB=B0=94=EB=80=90=20=EA=B3=B5=EC=A2=85=2012-15=20?= =?UTF-8?q?=ED=95=98=EB=82=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 금액은 아직 안 서고 막힌 까닭이 드러나는 일감(브레인 ㉯ ⑴~⑸) · 배정 프로젝트 변화 0 · 전체 1962 통과 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- .../B08_Quantity_Engine_Handoff_Boundaries.py | 4 +- .../B08_Quantity_Engine_Handoff_Mapping.py | 17 +++- .../B08_Quantity_Engine_Handoff_Rows.py | 8 +- .../B08_Quantity_Engine_Handoff_Trench.py | 2 +- ...B09_Estimation_ResourceAxis_JudgedTable.py | 49 +++++++++++ .../rebar_complexity_2026-01-01.json | 7 +- .../work_item_mapping_2026-01-01.json | 47 +++++++++++ .../data_work_item_master/_manifest.json | 6 +- .../work_item_master_2026-01-01.json | 10 ++- resources/tester/test_b08_basin_composite.py | 82 +++++++++++++++++++ 10 files changed, 214 insertions(+), 18 deletions(-) create mode 100644 resources/tester/test_b08_basin_composite.py 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