**⚠ 일위대가 합계를 순공사비로 뭉쳐 넣으면 ⑤ 의 밑수가 전부 틀림**(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>
158 lines
6.4 KiB
Python
158 lines
6.4 KiB
Python
"""B09 원가계산 — 자재 카탈로그 (PLAN 9-3 · 9-4).
|
|
|
|
**관급과 사급을 처음부터 가른다.** 섞어 두면 나중에 못 가른다 — 관급은
|
|
**총원가 밖 별도 표기 + 조달수수료**라 계산 자리가 아예 다르다(PLAN 8-2 인계 6필드).
|
|
|
|
구분 이름은 두 창이 맞춘 것을 쓴다 (2026-09-07 확정):
|
|
- `supply_type` = `owner_supplied`(관급) / `contractor_supplied`(사급)
|
|
- `owner_supplied_install_by` = `contractor`(도급자설치) / `owner` / `None`
|
|
⚠ **모르면 `None` 으로 두고 「설치 주체 미지정」으로 드러낸다.** 안전관리비 대상액이
|
|
**관급 전액이 아니라 도급자설치분**을 쓰므로(PLAN 8-10), 잘못 찍으면 금액이 조용히
|
|
틀린다.
|
|
|
|
원천
|
|
- 관급 = `mat_price_public_2026-08-14.json` — 나라장터 **6,999건**.
|
|
`vat_basis: "부가가치세별도"` 라 **부가세 제외 단가**이고 원가에 그대로 쓴다.
|
|
⚠ 철근·레미콘·아스콘은 그 파일의 `excluded_named_groups` 로 **빠져 있다**.
|
|
- 사급 = **없다.** 유료 물가지 미결(No.18). **값을 지어내지 않고 공백으로 드러낸다.**
|
|
|
|
⚠ **자재 단가는 할증 전 값이다** (PLAN 8-7 ㉠). 할증은 자재총괄 한 곳에서만 붙인다.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from dataclasses import dataclass, field
|
|
from decimal import Decimal
|
|
from typing import Any
|
|
|
|
_CATALOG_SUBPATH = ("resources", "data_cost_input_value")
|
|
|
|
#: 두 창이 맞춘 구분 이름 — 값을 바꾸면 B08 자재총괄과 안 맞는다.
|
|
SUPPLY_OWNER = "owner_supplied"
|
|
SUPPLY_CONTRACTOR = "contractor_supplied"
|
|
INSTALL_BY_CONTRACTOR = "contractor"
|
|
INSTALL_BY_OWNER = "owner"
|
|
|
|
|
|
class MaterialCatalogError(LookupError):
|
|
"""자재 단가를 못 세운 경우. 0 으로 때우지 않는다."""
|
|
|
|
|
|
def _project_root() -> str:
|
|
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
|
|
def _read_json(file_name: str) -> dict[str, Any]:
|
|
with open(os.path.join(_project_root(), *_CATALOG_SUBPATH, file_name), encoding="utf-8") as h:
|
|
return json.load(h)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MaterialItem:
|
|
"""자재 한 줄. **단가는 할증 전·부가세 제외 값**이다."""
|
|
|
|
item_code: str
|
|
name: str
|
|
specification: str
|
|
unit: str
|
|
price_krw: Decimal
|
|
supply_type: str
|
|
#: 관급일 때만 뜻이 있다. `None` = **설치 주체 미지정**(안전관리비 대상액에 못 넣음).
|
|
owner_supplied_install_by: str | None = None
|
|
vat_excluded: bool = True
|
|
notice_date: str = ""
|
|
|
|
@property
|
|
def display_name(self) -> str:
|
|
return f"{self.name} {self.specification}".strip()
|
|
|
|
|
|
@dataclass
|
|
class MaterialCatalog:
|
|
"""자재 목록. **이름만으로는 못 고른다** — 같은 품명에 규격이 여럿이다."""
|
|
|
|
items: dict[str, MaterialItem] = field(default_factory=dict)
|
|
#: 채우지 못한 것 — 사급 미결·제외 품목. **빈칸이 아니라 목록으로 든다.**
|
|
gaps: list[str] = field(default_factory=list)
|
|
|
|
def by_name(self, name: str) -> list[MaterialItem]:
|
|
return [m for m in self.items.values() if m.name == name]
|
|
|
|
def resolve(self, name: str, specification: str) -> MaterialItem | None:
|
|
"""품명 + 규격으로 한 줄을 고른다. 규격이 없으면 **고르지 않는다**.
|
|
|
|
6,999건 중 같은 품명이 수십 개인 것이 흔하다 — 이름만 맞추면 엉뚱한 규격의
|
|
단가가 조용히 붙는다.
|
|
"""
|
|
found = self.by_name(name)
|
|
if not found:
|
|
return None
|
|
if len(found) == 1 and not specification:
|
|
return found[0]
|
|
narrowed = [m for m in found if m.specification == specification]
|
|
return narrowed[0] if len(narrowed) == 1 else None
|
|
|
|
def get(self, item_code: str) -> MaterialItem:
|
|
try:
|
|
return self.items[item_code]
|
|
except KeyError as exc:
|
|
raise MaterialCatalogError(f"자재 카탈로그에 없는 코드입니다: {item_code}") from exc
|
|
|
|
def count_by_supply(self) -> dict[str, int]:
|
|
counts: dict[str, int] = {}
|
|
for item in self.items.values():
|
|
counts[item.supply_type] = counts.get(item.supply_type, 0) + 1
|
|
return counts
|
|
|
|
|
|
def load_material_catalog(
|
|
public_file: str = "mat_price_public_2026-08-14.json",
|
|
) -> MaterialCatalog:
|
|
"""관급 자재를 읽고, 사급은 **없다는 사실을 목록으로** 남긴다."""
|
|
payload = _read_json(public_file)
|
|
catalog = MaterialCatalog()
|
|
|
|
for row in payload["variables"]["mat_price"]["records"]:
|
|
code = str(row["item_code"])
|
|
catalog.items[code] = MaterialItem(
|
|
item_code=code,
|
|
name=row.get("classification_name", ""),
|
|
specification=row.get("specification", ""),
|
|
unit=row.get("unit", ""),
|
|
price_krw=Decimal(str(row.get("price_krw", 0))),
|
|
supply_type=SUPPLY_OWNER,
|
|
# ⚠ 나라장터 자료에 설치 주체가 없다 — 지어내지 않고 미지정으로 둔다.
|
|
owner_supplied_install_by=None,
|
|
vat_excluded=row.get("vat_basis", "") == "부가가치세별도",
|
|
notice_date=str(row.get("notice_datetime", ""))[:10],
|
|
)
|
|
|
|
# 사급 — 원천이 아직 없다. **값을 지어내지 않는다.**
|
|
catalog.gaps.append(
|
|
"사급 자재 단가 없음 — 유료 물가지 미결(No.18). 6번 슬롯(적용 단가) 수동 입력으로 채웁니다."
|
|
)
|
|
for group in payload.get("excluded_named_groups", []):
|
|
catalog.gaps.append(f"관급 제외 품목: {group.get('group', '')} — {group.get('reason', '')}")
|
|
return catalog
|
|
|
|
|
|
def catalog_summary(catalog: MaterialCatalog) -> dict[str, Any]:
|
|
"""화면에 낼 요약 — **무엇이 없는지**를 함께 낸다."""
|
|
unspecified = [
|
|
m
|
|
for m in catalog.items.values()
|
|
if m.supply_type == SUPPLY_OWNER and m.owner_supplied_install_by is None
|
|
]
|
|
return {
|
|
"items": len(catalog.items),
|
|
"by_supply": catalog.count_by_supply(),
|
|
"owner_supplied_install_unspecified": len(unspecified),
|
|
"gaps": list(catalog.gaps),
|
|
"notes": [
|
|
"자재 단가는 할증 전·부가세 제외 값입니다 — 할증은 자재총괄에서 한 번만 붙습니다.",
|
|
"관급 자재의 설치 주체가 미지정이라 안전관리비 대상액에 자동으로 넣지 않습니다.",
|
|
],
|
|
}
|