feat(B09): ⑤↔③ 연결 — 일위대가 3분할을 직접비로 접어 넣음 (뭉치지 않음)

**⚠ 일위대가 합계를 순공사비로 뭉쳐 넣으면 ⑤ 의 밑수가 전부 틀림**(8-9 규칙 2) —
산재·고용은 노무비, 건강·연금은 직접노무비, 기타경비는 재료비+노무비를 봄.
일위대가가 3분할을 이미 들고 있으므로 **성분별로 접어 넣음**.

- `direct_cost_from_quantities({공종코드: 수량})` → **직접비 3분할**.
  단가가 없는 공종은 **0 으로 안 때우고** `missing` 에 남김 — 수량이 있는데 단가가
  없으면 그 공종이 총액에서 조용히 빠짐.
- `cost_input_from_quantities()` → 성분이 그대로 `direct_material/labor/expense_krw` 로 감.
- **뭉치면 틀린다는 것을 수치로 보이는 시험**을 둠 — 같은 총액을 노무비 한 덩어리로
  넣으면 건강보험료가 커짐(`test_lumping_would_break_the_bases`).
- ⇒ ④예산내역서 없이도 **수량만 있으면 ⑤ 가 실물로 도는 경로**가 생김.

**자재 잠정(㉡) 화면 표시** — 「사급 자재 단가는 설계자가 직접 넣습니다(6번 슬롯
「적용 단가」) — 유료 물가지 미구독 … (잠정 — 구독하면 1~5번 슬롯에 꽂습니다)」.
화면 실측으로 3줄 다 뜨는 것 확인(일위대가 67건).
- 곁가지 — 화면은 평문이라 `**강조**` 별표가 그대로 보였음. 내보내기 직전에 벗김.

자체검증 — 신규 4건 포함 `pytest tmp/tests/ -q` **133 passed** · ruff 통과 ·
백엔드 재시작 후 화면 클릭 검증.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-07 23:45:56 +09:00
co-authored by Claude Opus 5
parent 0df6acb57d
commit 2faca7ac09
2 changed files with 98 additions and 13 deletions
@@ -151,7 +151,7 @@ def catalog_summary(catalog: MaterialCatalog) -> dict[str, Any]:
"owner_supplied_install_unspecified": len(unspecified),
"gaps": list(catalog.gaps),
"notes": [
"자재 단가는 **할증 전·부가세 제외** 값입니다 — 할증은 자재총괄에서 한 번만 붙습니다.",
"관급 자재의 **설치 주체가 미지정**이라 안전관리비 대상액에 자동으로 넣지 않습니다.",
"자재 단가는 할증 전·부가세 제외 값입니다 — 할증은 자재총괄에서 한 번만 붙습니다.",
"관급 자재의 설치 주체가 미지정이라 안전관리비 대상액에 자동으로 넣지 않습니다.",
],
}
+96 -11
View File
@@ -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,
)