From b5f7f3c00c59bb3db727c0d27559089378beea1e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 13 Sep 2026 09:08:10 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(b09):=20=EC=95=BC=EB=A9=B4=EC=84=9D=20?= =?UTF-8?q?=EC=B1=84=EC=A7=91=20=E3=80=8C=EC=9D=B8=20=EB=B6=80=E3=80=8D?= =?UTF-8?q?=EB=A5=BC=20=EB=B3=B4=ED=86=B5=EC=9D=B8=EB=B6=80=EB=A1=9C=20?= =?UTF-8?q?=EC=A4=80=EC=9A=A9=20=E2=80=94=20=ED=99=95=EC=A0=95=208-1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 노임표에 없는 직종명을 유사 직종으로 바꿔 읽는 자리를 공종 단위로 둠(FP-13-02-04 한 곳). 산출기초에 준용 근거(재경원 회계 45101-45) 표시. 뒷길이 다섯 × 밑수 둘, 일위대가 10벌이 새로 섬. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DJffo4VwgTumVUM3kdbJzd --- B09_Estimation/B09_Estimation_BasisSheet.py | 4 ++ .../B09_Estimation_ResourceAxis_UnitBasis.py | 18 ++++++- .../tester/test_b09_similar_occupation.py | 48 +++++++++++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 resources/tester/test_b09_similar_occupation.py diff --git a/B09_Estimation/B09_Estimation_BasisSheet.py b/B09_Estimation/B09_Estimation_BasisSheet.py index d12d1a96..fe047296 100644 --- a/B09_Estimation/B09_Estimation_BasisSheet.py +++ b/B09_Estimation/B09_Estimation_BasisSheet.py @@ -22,6 +22,7 @@ import os from typing import Any from B09_Estimation.B09_Estimation_PriceBook import PriceKind +from B09_Estimation.B09_Estimation_ResourceAxis_UnitBasis import similar_occupation_note _MANIFEST = ("resources", "data_cost_input_value", "_manifest.json") _MASTER_DIR = ("resources", "data_work_item_master") @@ -118,6 +119,9 @@ def work_item_basis(build: Any) -> list[dict[str, Any]]: source = build.factor_sources.get(work_item_code) if source and source not in notes: notes.append(str(source)) + similar = similar_occupation_note(work_item_code) + if similar and similar not in notes: + notes.append(similar) if not notes: continue rows.append( diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_UnitBasis.py b/B09_Estimation/B09_Estimation_ResourceAxis_UnitBasis.py index f594434b..4a948397 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis_UnitBasis.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis_UnitBasis.py @@ -43,6 +43,21 @@ _RE_BASIS = re.compile(r"^(㎡|㎥|㏊|m|㎝|개|본|주|ton|톤|kg|㎏)\s*당$" #: 밑수 표기 → 그 갈래가 갖는 단위. _BASIS_UNIT = {"㏊": "ha", "톤": "ton", "㎏": "kg"} +#: 품셈이 **노임표에 없는 직종명**을 쓴 자리 → 유사 직종 +#: (2026-09-09 사용자 확정 8-1 「바꿔쓰기 허용」). +#: 근거 — 「미조사 직종은 유사 직종 단가에 준하여 적용」(재경원 회계 45101-45). +#: ⚠ **공종 단위로만 건다** — 「인부」를 전역으로 읽으면 확정 밖 공종까지 조용히 붙는다. +SIMILAR_OCCUPATION = {("FP-13-02-04", "인부"): "보통인부"} + + +def similar_occupation_note(work_item_code: str) -> str: + """그 공종이 유사 직종으로 바꿔 쓴 자리면 산출근거 문구, 아니면 빈 문자열.""" + return " · ".join( + f"품셈 「{written}」 → {used} 준용(유사 직종 · 재경원 회계 45101-45 · 사용자 확정 8-1)" + for (code, written), used in SIMILAR_OCCUPATION.items() + if code == work_item_code + ) + def _tight(text: str) -> str: return "".join(str(text or "").split()) @@ -93,7 +108,8 @@ def match_unit_basis_table( continue if basis_at > 0: # 이름이 앞에 붙은 줄 — 「인 부 | ㎡당 | 0.11 | …」. 이름을 여기서 갱신한다. - found = _resolve(catalog, cells[0]) + name_cell = SIMILAR_OCCUPATION.get((work_item_code, _tight(cells[0])), cells[0]) + found = _resolve(catalog, name_cell) if found is None: result.unmatched.append( UnmatchedRow( diff --git a/resources/tester/test_b09_similar_occupation.py b/resources/tester/test_b09_similar_occupation.py new file mode 100644 index 00000000..42a645ee --- /dev/null +++ b/resources/tester/test_b09_similar_occupation.py @@ -0,0 +1,48 @@ +"""유사 직종 바꿔쓰기 — 야면석 채집 「인 부」 → 보통인부 (2026-09-13 · 사용자 확정 8-1). + +⚠ 겨누는 것 셋 + ① 야면석 채집(13-2-4)의 「인 부」가 **못 붙은 줄에서 빠지고** 보통인부 노임으로 붙는다 + ② 바꿔쓰기는 **그 공종에만** 걸린다 — 전역 별칭이 아니다 + ③ 산출기초에 **준용 근거**가 함께 뜬다(조용히 바꾸지 않음) +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_BasisSheet import work_item_basis # noqa: E402 +from B09_Estimation.B09_Estimation_ResourceAxis_UnitBasis import ( # noqa: E402 + SIMILAR_OCCUPATION, + similar_occupation_note, +) +from B09_Estimation.B09_Estimation_UnitPrice import cached_build # noqa: E402 + +CODE = "FP-13-02-04" + + +def test_인부가_보통인부로_붙는다() -> None: + build = cached_build() + assert "인 부" not in build.unattached.get(CODE, []) + titles = [code for code in build.book.titles if code.startswith(f"B-{CODE}#")] + # 뒷길이 다섯 × 밑수 둘(㎡당·㎥당) + assert len(titles) == 10, titles + for code in titles: + names = {build.book.title(d.ref_code).name for d in build.book.details.get(code, [])} + assert "보통인부" in names, (code, names) + assert build.book.resolve(code).labor > 0 + + +def test_바꿔쓰기는_그_공종에만_걸린다() -> None: + assert {code for code, _ in SIMILAR_OCCUPATION} == {CODE} + assert similar_occupation_note("FP-12-10") == "" + + +def test_산출기초에_준용_근거가_뜬다() -> None: + rows = [row for row in work_item_basis(cached_build()) if row["code"].startswith(f"B-{CODE}#")] + assert rows + for row in rows: + assert any("보통인부 준용" in note and "45101-45" in note for note in row["notes"]), row From 2e6cfe5d3bce46b35fc590712afe3e3277fce294 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 13 Sep 2026 09:11:26 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(b08):=20=EB=8F=8C=20=EB=AC=B4=EA=B2=8C?= =?UTF-8?q?=20=EC=84=A4=EB=AA=85=20=EB=91=90=20=EA=B3=B3=EC=9D=84=20?= =?UTF-8?q?=ED=99=95=EC=A0=95=205=EC=B0=A8=207=EB=B2=88=EC=97=90=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=A4=20=E2=80=94=20=EA=B0=92=20=EB=B6=88=EB=B3=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stone_kind 표의 「사용자 확정 대기」 문구를 확정된 계산식 기준으로 고침. 관측표 종류 주석이 코드(안 고르면 계산식)와 반대로 적혀 있던 것 바로잡음. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DJffo4VwgTumVUM3kdbJzd --- B08_Quantity/B08_Quantity_Engine_UnitQuantity.py | 2 +- resources/data_masonry/stone_kind_2026-01-01.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index 03a94a90..f23a2dcd 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -93,7 +93,7 @@ STONE_WEIGHT_BASIS = ( #: ⚠ **잠정이다** — 확정 문구가 「나중에 실무자 협의 후 명확히」이므로 화면 근거에 적는다. STONE_PACKING_RATIO = 0.77 # 채움률 — 돌 사이 공극을 뺀 몫 STONE_DENSITY_TON_M3 = 2.65 # 석재 비중 -#: 계산식이 안 맞아 **관측표**를 쓰는 종류. 안 고른 경우도 이쪽(지금까지 쓰던 값이 그것). +#: 계산식이 안 맞아 **관측표**를 쓰는 종류. 안 고른 경우는 **계산식**으로 선다(`stone_masonry` 주석). STONE_WEIGHT_OBSERVED_KINDS = frozenset({"야면석·호박돌", "야면석"}) STONE_WEIGHT_FORMULA_BASIS = ( "⭐ 확정 5차 — 뒷길이 × 0.77(채움률) × 2.65(비중). ⚠ 잠정 — 실무자 협의 후 명확히 할 값" diff --git a/resources/data_masonry/stone_kind_2026-01-01.json b/resources/data_masonry/stone_kind_2026-01-01.json index 3821fa2a..77e6d8ad 100644 --- a/resources/data_masonry/stone_kind_2026-01-01.json +++ b/resources/data_masonry/stone_kind_2026-01-01.json @@ -147,7 +147,7 @@ "not_here": { "note": "이 표가 정하지 않는 것.", "items": [ - "돌중량(ton/㎡) — 우리 값 0.575/0.88/1.10 은 **실무 관측**(울진 라이브러리)이다. 품셈은 「돌의 중량은 형상·종류·부피를 고려하고 건설품셈 1-3-3 재료의 단위중량을 참고하여 계상한다」로만 두어 **표를 안 준다.** 돌 종류 축과 짝이 맞는지 사용자 확정 대기.", + "돌중량(ton/㎡) — 품셈은 「돌의 중량은 형상·종류·부피를 고려하고 건설품셈 1-3-3 재료의 단위중량을 참고하여 계상한다」로만 두어 **표를 안 준다.** ⭐ 사용자 확정 5차 큰 것 7(2026-09-09) — **계산식 `뒷길이 × 0.77 × 2.65` 이 기본**이고 야면석만 실무 관측(울진 라이브러리 0.575/0.88/1.10)으로 갈림. 식과 종류 갈래는 `B08_Quantity_Engine_UnitQuantity.stone_weight_per_m2` 한 벌. ⚠ 잠정 — 「실무자 협의 후 명확히」.", "전면면적이 정면적인지 비탈면적인지 — 2026 개정안 [주] 「시공량은 석재의 **전면면적**(㎡)을 기준한다」로 용어만 확인됐다." ] }, From 5e09a86104a51477e73b55e99abf55ec9f2086a4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 13 Sep 2026 09:13:13 +0900 Subject: [PATCH 3/3] =?UTF-8?q?test(b09):=20=EC=A4=80=EB=B9=84=EA=B3=B5=20?= =?UTF-8?q?=EC=A4=84=EC=9D=B4=20=EC=9D=B8=EA=B3=84=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=82=B4=EC=97=AD=EC=84=9C=EA=B9=8C=EC=A7=80=20=EC=95=88=20?= =?UTF-8?q?=EC=83=88=EB=8A=94=EC=A7=80=20=EC=9E=A0=EA=B8=88=20=E2=80=94=20?= =?UTF-8?q?V-12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B08 인계의 준비공·부대시설 줄이 B09 에서 내역 본체 또는 제외 줄로 반드시 서는지, 표토제거 2,359.035㎥ 가 수량째 본체에 서는지 마주 걸어 확인. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DJffo4VwgTumVUM3kdbJzd --- .../tester/test_b09_prep_handoff_bill.py | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 resources/tester/test_b09_prep_handoff_bill.py diff --git a/resources/tester/test_b09_prep_handoff_bill.py b/resources/tester/test_b09_prep_handoff_bill.py new file mode 100644 index 00000000..ab264508 --- /dev/null +++ b/resources/tester/test_b09_prep_handoff_bill.py @@ -0,0 +1,57 @@ +"""PLAN 7장 V-12 — 준비공 줄이 B08 인계에서 **B09 내역서까지 하나도 안 새나** (2026-09-13). + +「안 하면 표토제거 2,359.03㎥ 가 다시 통째로 사라짐」 자리. 보내는 쪽(B08)과 받는 쪽(B09)을 +**마주 걸어** 본다 — 한쪽만 보면 양쪽 다 조용히 틀린다(PLAN 7장 머리 규칙). + +⚠ 겨누는 것 둘 + ① 준비공·부대시설 줄은 **내역 줄 아니면 제외 줄**로 반드시 선다 — 어디에도 없는 줄이 0 + ② 금액이 서는 줄(`in_bill`)은 **수량째** 내역 본체에 선다 — 표토제거 2,359.035㎥ +⚠ 단가가 붙는지는 여기서 안 본다 — 표토 축(시트 9번)·임목축적 입력이 정해져야 서는 자리다. +""" + +from __future__ import annotations + +import sys +from decimal import Decimal +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_Preparation import build_table as build_prep # noqa: E402 +from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill # noqa: E402 + +SLOPE = { + "face_dressing_fill": 9000.0, + "face_dressing_cut": 6726.9, + "tree_removal_fill": 9000.0, + "tree_removal_cut": 6726.9, + "bench_cut_fill": 9000.0, +} + + +def _handoff_and_bill(): + handoff = build_handoff(preparation_table=build_prep(SLOPE, [], [], 0.15)) + return handoff, build_bill(handoff) + + +def test_준비공_줄은_내역_아니면_제외로_반드시_선다() -> None: + handoff, bill = _handoff_and_bill() + sent = [r for r in handoff["work_items"] if r["origin"] == "preparation"] + assert len(sent) >= 6 + body = [r.name for r in bill.rows if not r.is_group] + excluded = [r.name for r in bill.excluded] + for row in sent: + landed = body if row["in_bill"] else excluded + assert row["name"] in landed, (row["name"], row["in_bill"]) + + +def test_금액이_서는_줄은_수량째_본체에_선다() -> None: + handoff, bill = _handoff_and_bill() + topsoil = next(r for r in bill.rows if not r.is_group and r.name == "표토제거") + assert Decimal(str(topsoil.quantity)).quantize(Decimal("0.01")) == Decimal("2359.04") + in_bill = [ + r["name"] for r in handoff["work_items"] if r["origin"] == "preparation" and r["in_bill"] + ] + assert {"표토제거", "제근·뿌리다듬기", "뿌리 적재"} <= set(in_bill)