diff --git a/B09_Estimation/B09_Estimation_MaterialCatalog.py b/B09_Estimation/B09_Estimation_MaterialCatalog.py index 8999ad19..8fe5721b 100644 --- a/B09_Estimation/B09_Estimation_MaterialCatalog.py +++ b/B09_Estimation/B09_Estimation_MaterialCatalog.py @@ -151,7 +151,7 @@ def catalog_summary(catalog: MaterialCatalog) -> dict[str, Any]: "owner_supplied_install_unspecified": len(unspecified), "gaps": list(catalog.gaps), "notes": [ - "자재 단가는 **할증 전·부가세 제외** 값입니다 — 할증은 자재총괄에서 한 번만 붙습니다.", - "관급 자재의 **설치 주체가 미지정**이라 안전관리비 대상액에 자동으로 넣지 않습니다.", + "자재 단가는 할증 전·부가세 제외 값입니다 — 할증은 자재총괄에서 한 번만 붙습니다.", + "관급 자재의 설치 주체가 미지정이라 안전관리비 대상액에 자동으로 넣지 않습니다.", ], } diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index bb1485e8..d4ae5643 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -17,6 +17,7 @@ from __future__ import annotations +import re from dataclasses import dataclass, field from decimal import Decimal from functools import lru_cache @@ -43,6 +44,7 @@ from B09_Estimation.B09_Estimation_ResourceAxis import ( ) from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at +_RE_EMPHASIS = re.compile(r"\*\*(.+?)\*\*") _ZERO = Decimal(0) #: 연료는 자재 카탈로그에 없어 합성 코드로 세운다 — 코드가 있어야 조인이 성립한다. FUEL_CODE_PREFIX = "M-FUEL-" @@ -278,6 +280,11 @@ def cached_build() -> UnitPriceBuild: return build_unit_prices() +def _plain(text: str) -> str: + """화면용 평문 — 마크다운 강조 표시를 벗긴다.""" + return _RE_EMPHASIS.sub(lambda match: match.group(1), text) + + def _status_notes() -> list[str]: """화면에 낼 「지금 무엇이 안 선 상태인가」. @@ -290,17 +297,22 @@ def _status_notes() -> list[str]: ) summary = catalog_summary(load_material_catalog()) + # 화면은 평문이라 마크다운 강조가 그대로 보인다 — 내보내기 직전에 벗긴다. return [ - f"관급 자재 {summary['items']:,}건을 붙였으나 **임도 자재는 거의 없습니다** — " - "나라장터 목록이 건축·설비 자재 중심이고, 시멘트·모래·자갈은 애초에 사급이며 " - "철근·레미콘·아스콘은 원천에서 빠져 있습니다.", - "**사급 자재 단가가 미결**이라 구조물 계열 일위대가가 아직 서지 않습니다 — " - "값을 지어내지 않고 6번 슬롯(적용 단가) 수동 입력으로 채웁니다.", - ( - f"관급 자재 **설치 주체가 미지정**" - f"({summary['owner_supplied_install_unspecified']:,}건)이라 " - "안전관리비 대상액에 자동으로 넣지 않습니다." - ), + _plain(note) + for note in [ + f"관급 자재 {summary['items']:,}건을 붙였으나 **임도 자재는 거의 없습니다** — " + "나라장터 목록이 건축·설비 자재 중심이고, 시멘트·모래·자갈은 애초에 사급이며 " + "철근·레미콘·아스콘은 원천에서 빠져 있습니다.", + "**사급 자재 단가는 설계자가 직접 넣습니다**(6번 슬롯 「적용 단가」) — " + "유료 물가지 미구독. 값을 지어내지 않으므로, 넣기 전까지 구조물 계열 " + "일위대가는 서지 않습니다. (잠정 — 물가지를 구독하면 1~5번 슬롯에 꽂습니다.)", + ( + f"관급 자재 **설치 주체가 미지정**" + f"({summary['owner_supplied_install_unspecified']:,}건)이라 " + "안전관리비 대상액에 자동으로 넣지 않습니다." + ), + ] ] @@ -322,7 +334,7 @@ def build_summary(build: UnitPriceBuild) -> dict: def _money_text(value: Decimal) -> str: - """화면에 낼 금액 — **일위대가 금액란은 0.1원 미만 버림**(품셈 1-2-2). + """화면에 낼 금액 — 일위대가 금액란은 0.1원 미만 버림(품셈 1-2-2). 계산은 전정밀로 두고 **표를 그리는 자리에서만** 자른다 (`B09_Estimation_Rounding` — 단수는 출력 위치에 붙는다). @@ -413,3 +425,76 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict: "precise_total": _money_text(money.total), "rows": rows, } + + +@dataclass +class DirectCostBreakdown: + """⑤ 공사원가계산서가 받는 **직접비 3분할**. + + ⚠ **일위대가 합계를 순공사비로 뭉쳐 넣으면 안 된다.** ⑤ 의 밑수는 항목마다 갈리고 + (산재·고용 = 노무비 / 건강·연금 = 직접노무비 / 기타경비 = 재료비+노무비 …), + 뭉쳐 넣으면 그 밑수가 전부 틀린다(PLAN 8-9 규칙 2). 일위대가는 3분할을 이미 + 들고 있으니 **성분별로 접어 넣는다.** + """ + + material: Decimal = _ZERO + labor: Decimal = _ZERO + expense: Decimal = _ZERO + #: 값을 못 세운 공종 — 수량이 있는데 단가가 없으면 여기 남는다(0 으로 안 때운다). + missing: list[str] = field(default_factory=list) + + @property + def total(self) -> Decimal: + return self.material + self.labor + self.expense + + +def direct_cost_from_quantities( + quantities: dict[str, Decimal], + build: UnitPriceBuild | None = None, +) -> DirectCostBreakdown: + """공종별 수량을 일위대가에 곱해 **직접비 3분할**을 만든다. + + `quantities` = `{공종코드: 수량}`. 공종코드는 `FP-09-21` 처럼 마스터 코드를 쓰거나 + `B-FP-09-21` 처럼 일위대가 코드를 그대로 써도 된다. + + 단가가 없는 공종은 **0 으로 안 때우고** `missing` 에 남긴다 — 수량이 있는데 단가가 + 없으면 그 공종이 총액에서 조용히 빠진다. + """ + book = (build or cached_build()).book + result = DirectCostBreakdown() + + for raw_code, quantity in quantities.items(): + code = raw_code if raw_code.startswith("B-") else f"B-{raw_code}" + if code not in book.titles: + result.missing.append(raw_code) + continue + unit_money = book.resolve(code) + line = unit_money.scaled(Decimal(str(quantity))) + result.material += line.material + result.labor += line.labor + result.expense += line.expense + return result + + +def cost_input_from_quantities( + quantities: dict[str, Decimal], + build: UnitPriceBuild | None = None, + **cost_input_kwargs, +): + """직접비 3분할을 ⑤ 엔진 입력으로 접어 넣는다. + + 성분이 그대로 `direct_material_krw`·`direct_labor_krw`·`direct_expense_krw` 로 간다 — + **뭉치지 않는다.** + """ + from B09_Estimation.B09_Estimation_Engine_Cost import CostInput + + breakdown = direct_cost_from_quantities(quantities, build) + return ( + CostInput( + direct_material_krw=breakdown.material, + direct_labor_krw=breakdown.labor, + direct_expense_krw=breakdown.expense, + **cost_input_kwargs, + ), + breakdown, + )