Files
Aislo/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py
T
eomsangdonandClaude Opus 5 6a487fc6e3 feat(B08): 자재총괄 — 할증이 붙는 유일한 자리
구조물 원단위의 `destination == "material"` 성분만 모아 자재별 합산 후
할증률을 한 번만 적용. 열은 순수량·할증률·합계 + 관급구분·설치주체·비고이며
금액은 없음(B09 경계).

- 할증률은 코드가 아니라 데이터 — `resources/data_material_surcharge/`
  (품셈 1-3-1 재료 할증률 19종 + sha256 매니페스트). 실무 관측값은
  `observed_practice` 로 분리(법대로 원칙).
- 표에 없는 자재는 0 % 로 넘기지 않고 「할증률 미확보」로 표시.
  이름 조회는 정확 일치 — 부분일치면 `막자갈` 이 `자갈` 할증을 뭄.
- 이중계상 방어 ㉠ — 앞 단계 `surcharge_applied` 깃발을 실제로 읽어 경고.
  자재총괄 응답은 `True`, 원단위표는 `False` 로 어느 쪽 값인지 명시.
- 관급/사급 이름은 B09 와 동일(`owner_supplied`/`contractor_supplied`).
  관급 줄에만 설치 주체(`install_by`)를 붙이고, 미지정은 기본값으로
  때우지 않고 드러냄 — 안전관리비 대상액이 「도급자설치 관급금액」이라서임.
- 라우터 `GET /quantity/material-summary` 신설, 화면에 「구조물 원단위」·
  「자재총괄」 탭 추가. `design_owner` 가 붙은 타입(측구)은 중복 계상 방지로 제외.

검증 — 전용 테스트 23건 통과, 전체 회귀 473 passed(기존 B05 깨짐 1건 제외).
공용 브라우저 실조작으로 탭 5장·머리글·값 4줄·미확보 안내 확인.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 23:43:46 +09:00

307 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""자재 총괄표 — 할증이 붙는 **유일한** 자리 (B08 일감 7 · PLAN 8-2·8-3·8-7).
여기가 하는 일은 하나다
구조물 전개(`..._Engine_UnitQuantity`)가 낸 성분 가운데 **`destination == "material"`**
인 것만 모아, 자재별로 합치고 **할증률을 한 번** 붙인다. 열은 순수량·할증률·합계 셋이고
**금액은 없다**(금액은 B09 몫, 8-2 경계).
⚠⚠ ㉠ 이중계상 방어 — 할증은 여기 한 번뿐이다
원단위표(`surcharge_applied: False`)도 B09 일위대가 재료비도 **할증 전** 값이다.
두 곳에서 붙이면 자재가 두 번 부푼다. `verify_single_surcharge()` 가 입력 표의
깃발을 실제로 읽어 막는다 — 규칙이 주석에만 있으면 지켜지지 않는다.
⚠ `earthwork`·`unit_price` 는 여기 오지 않는다
터파기·되메우기는 토공집계로, 모르터·돌쌓기는 B09 일위대가로 간다. 섞이면 그게 곧
이중계상이다. 걸러 낸 성분은 버리지 않고 `skipped_by_destination` 으로 세어 보인다.
⚠ 할증률을 코드에 박지 않는다 (요율과 같은 취급)
값은 `resources/data_material_surcharge/material_surcharge_<판>.json` 에 있다.
표에 없는 자재는 **0 % 로 조용히 넘기지 않는다** — 「할증률 미확보」로 드러낸다.
0 % 로 넘기면 빠뜨린 것과 구별이 안 된다.
⚠ 품셈에 이미 할증이 포함된 항목은 제외한다 (품셈 1-3-1 단서)
「품셈 항목에 할증이 포함ㆍ표시된 경우 중복 적용 금지」. 성분이 그렇게 표시돼 오면
(`surcharge_included: True`) 율을 붙이지 않고 비고에 까닭을 남긴다.
⚠ 관급/사급은 **법이 아니라 발주 결정**이다
자재마다 정해진 값이 아니므로 지어내지 않는다. 프로젝트 설정
(`quantity.material_supply`)이 정한 것만 따르고, 안 정한 자재는 `unknown` 으로 남겨
화면에 드러낸다. 구분 이름은 B09 와 같은 낱말을 쓴다 — 다르면 인계에서 어긋난다.
관급 줄에는 **설치 주체**(`install_by`)가 하나 더 붙는다 — 안전관리비 대상액이
관급 전액이 아니라 「도급자설치 관급금액」이기 때문이다.
⚠ 자재 이름은 **정확히 일치**로만 찾는다
부분일치로 재면 `막자갈`(뒤채움)이 `자갈` 할증을 물게 된다 — 원단위 엔진에서 이미
한 번 겪은 자리다. 못 찾으면 지어내지 말고 「할증률 미확보」로 드러낸다.
⚠ 할증 전/후 값을 **둘 다** 남긴다 (8-2 인계 6필드)
하나만 넘기면 B09 가 어느 쪽인지 몰라 역산한다. `net_amount`(전) 와
`total_amount`(후) 를 나란히 둔다.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable
# ── 데이터 자리 ──────────────────────────────────────────────────────
DATASET_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_material_surcharge"
DATASET_PREFIX = "material_surcharge_"
#: 이 표가 받는 성분 갈래. 나머지는 각자 다른 표로 간다.
ACCEPTED_DESTINATION = "material"
#: 관급/사급 구분 — **데이터 값은 영문 키, 한글은 화면 표기용**(2026-09-07 B09 와 확정).
#: B09 원가 엔진의 `owner_supplied_material_krw`(⑤ 관급자재대)와 같은 낱말이라 그대로 이어진다.
SUPPLY_OWNER = "owner_supplied" # 관급 — 발주처 지급
SUPPLY_CONTRACTOR = "contractor_supplied" # 사급 — 도급자 구입
SUPPLY_UNKNOWN = "unknown" # 아직 안 정함 — 화면에 드러낸다
SUPPLY_LABELS = {SUPPLY_OWNER: "관급", SUPPLY_CONTRACTOR: "사급", SUPPLY_UNKNOWN: "미분류"}
#: ⚠ 관급 안의 **설치 주체** — 안전관리비 대상액은 관급 전액이 아니라 「도급자설치 관급금액」이다
#: (PLAN 8-10 대상액 정의). 관급/사급 두 갈래로만 두면 B09 가 ⑤를 못 세운다.
#: **관급 줄에만 붙이고 사급 줄은 비운다.** 모르면 기본값으로 때우지 않고 `None` 으로 둔다 —
#: 잘못 찍으면 안전관리비가 조용히 틀린다.
INSTALL_BY_CONTRACTOR = "contractor" # 도급자설치
INSTALL_BY_OWNER = "owner" # 관 직접설치
INSTALL_BY_LABELS = {INSTALL_BY_CONTRACTOR: "도급자설치", INSTALL_BY_OWNER: "관 직접설치"}
NOTE_INSTALL_BY_MISSING = "설치 주체 미지정"
NOTE_RATE_MISSING = "할증률 미확보"
NOTE_INCLUDED = "품셈에 할증 포함 — 중복 적용 안 함"
def _latest_dataset_path(directory: Path | None = None) -> Path | None:
folder = directory or DATASET_DIR
if not folder.is_dir():
return None
files = sorted(folder.glob(DATASET_PREFIX + "*.json"))
return files[-1] if files else None
@dataclass
class SurchargeTable:
"""할증률표 한 판. 조건이 갈리는 자재는 `alt_rate` 를 같이 들고 있는다."""
effective_date: str = ""
source: dict[str, Any] = field(default_factory=dict)
rates: dict[str, dict[str, Any]] = field(default_factory=dict)
def rate_for(self, material: str, condition: str | None = None) -> tuple[float | None, str]:
"""(할증률 %, 근거). 표에 없으면 `(None, "")` — **0 을 돌려주지 않는다.**"""
entry = self.rates.get(material.strip())
if entry is None:
return None, ""
alt_condition = entry.get("alt_condition")
if condition and alt_condition and condition == alt_condition:
return float(entry["alt_rate"]), material + "(" + str(alt_condition) + ")"
base_condition = entry.get("condition")
label = material + "(" + str(base_condition) + ")" if base_condition else material
return float(entry["rate"]), label
@property
def material_names(self) -> list[str]:
return sorted(self.rates)
def load_surcharge_table(path: Path | None = None) -> SurchargeTable:
"""할증률표를 읽는다. 파일이 없으면 **빈 표** — 전 자재가 「미확보」로 드러난다."""
target = path or _latest_dataset_path()
if target is None or not target.is_file():
return SurchargeTable()
payload = json.loads(target.read_text(encoding="utf-8"))
rates = {
str(row["material"]).strip(): row
for row in payload.get("rates_pct", [])
if row.get("material") is not None and row.get("rate") is not None
}
return SurchargeTable(
effective_date=str(payload.get("effective_date") or ""),
source=payload.get("source") or {},
rates=rates,
)
@dataclass
class MaterialRow:
"""총괄표 한 줄. 할증 **전·후를 둘 다** 들고 있는다(8-2 인계)."""
name: str
unit: str
net_amount: float = 0.0 # 순수량 — 할증 전
surcharge_pct: float | None = None # None = 미확보
supply: str = SUPPLY_UNKNOWN
install_by: str | None = None # 관급 줄에만 — 사급은 비워 둔다
surcharge_included: bool = False # 품셈에 이미 포함
basis: str = ""
sources: list[str] = field(default_factory=list)
@property
def total_amount(self) -> float:
"""합계 = 순수량 × (1 + 할증률). 미확보면 **순수량 그대로** 두고 비고로 알린다."""
if self.surcharge_included or self.surcharge_pct is None:
return self.net_amount
return self.net_amount * (1.0 + self.surcharge_pct / 100.0)
@property
def note(self) -> str:
parts: list[str] = []
if self.surcharge_included:
parts.append(NOTE_INCLUDED)
elif self.surcharge_pct is None:
parts.append(NOTE_RATE_MISSING)
elif self.basis:
parts.append(self.basis)
if self.supply == SUPPLY_OWNER and self.install_by is None:
parts.append(NOTE_INSTALL_BY_MISSING)
return " · ".join(parts)
def _supply_of(value: Any) -> tuple[str, str | None]:
"""설정 한 칸을 (관급구분, 설치주체) 로 읽는다.
설정은 두 모양을 받는다 — 구분만 적은 `"owner_supplied"` 와 설치 주체까지 적은
`{"supply": ..., "install_by": ...}`. 앞 모양으로 적힌 관급은 **설치 주체 미지정**이 되고
그대로 드러난다. 기본값으로 때우지 않는다 — 잘못 찍으면 안전관리비가 조용히 틀린다.
"""
if isinstance(value, dict):
supply = str(value.get("supply") or SUPPLY_UNKNOWN)
install_by = value.get("install_by")
install_by = str(install_by) if install_by else None
else:
supply = str(value) if value else SUPPLY_UNKNOWN
install_by = None
if supply != SUPPLY_OWNER:
install_by = None # 사급 줄은 비워 둔다
return supply, install_by
def verify_single_surcharge(unit_quantity_table: dict[str, Any] | None) -> list[str]:
"""⚠ 앞 단계가 이미 할증을 붙였으면 알린다 (㉠ 방어).
원단위표는 `surcharge_applied: False` 로 「할증 전」임을 못 박아 보낸다. 그 깃발이
참이면 여기서 또 붙일 수 없다 — **조용히 건너뛰지 않고 알린다**. 말없이 넘기면
어느 쪽이 적용됐는지 아무도 모른다.
"""
if not unit_quantity_table:
return []
if unit_quantity_table.get("surcharge_applied"):
return ["앞 단계(구조물 원단위)가 이미 할증을 붙였음 — 자재총괄에서 중복 적용 위험"]
return []
def _collect(
unit_quantity_table: dict[str, Any],
) -> tuple[dict[tuple[str, str], MaterialRow], dict[str, int]]:
"""`destination == "material"` 만 모은다. 나머지는 세어서 보인다."""
rows: dict[tuple[str, str], MaterialRow] = {}
skipped: dict[str, int] = {}
for structure in unit_quantity_table.get("structures", []):
label = str(structure.get("name") or structure.get("type_id") or "")
for component in structure.get("components", []):
destination = str(component.get("destination") or "") or "(없음)"
if destination != ACCEPTED_DESTINATION:
skipped[destination] = skipped.get(destination, 0) + 1
continue
name = str(component.get("name") or "").strip()
unit = str(component.get("unit") or "").strip()
row = rows.setdefault((name, unit), MaterialRow(name=name, unit=unit))
row.net_amount += float(component.get("amount") or 0.0)
if component.get("surcharge_included"):
row.surcharge_included = True
if label and label not in row.sources:
row.sources.append(label)
return rows, skipped
def build_table(
unit_quantity_table: dict[str, Any],
*,
surcharge_table: SurchargeTable | None = None,
supply_map: dict[str, Any] | None = None,
extra_materials: Iterable[dict[str, Any]] = (),
) -> dict[str, Any]:
"""화면·API 가 그대로 쓰는 모양.
`extra_materials` 는 구조물 전개 밖에서 오는 자재(떼·초류종자 등 사면 계열)를 받는 자리다.
모양은 원단위 성분과 같다(`name`·`unit`·`amount`·`destination`).
"""
table = surcharge_table or load_surcharge_table()
rows, skipped = _collect(unit_quantity_table)
for item in extra_materials:
if str(item.get("destination") or ACCEPTED_DESTINATION) != ACCEPTED_DESTINATION:
continue
name = str(item.get("name") or "").strip()
unit = str(item.get("unit") or "").strip()
row = rows.setdefault((name, unit), MaterialRow(name=name, unit=unit))
row.net_amount += float(item.get("amount") or 0.0)
if item.get("surcharge_included"):
row.surcharge_included = True
source = str(item.get("source") or "")
if source and source not in row.sources:
row.sources.append(source)
supply = supply_map or {}
missing_rate: list[str] = []
missing_supply: list[str] = []
missing_install_by: list[str] = []
for (name, _unit), row in rows.items():
row.supply, row.install_by = _supply_of(supply.get(name))
if row.supply == SUPPLY_UNKNOWN:
missing_supply.append(name)
# ⚠ 설치 주체는 관급 줄에만 묻는다. 사급은 애초에 대상액 밖이라 비워 두는 것이 맞다.
if row.supply == SUPPLY_OWNER and row.install_by is None:
missing_install_by.append(name)
if row.surcharge_included:
continue
rate, basis = table.rate_for(name)
row.surcharge_pct = rate
row.basis = basis
if rate is None:
missing_rate.append(name)
ordered = sorted(rows.values(), key=lambda item: (item.name, item.unit))
return {
"columns": [
"자재명",
"단위",
"순수량",
"할증률(%)",
"합계",
"관급구분",
"설치주체",
"비고",
],
"rows": [
{
"name": row.name,
"unit": row.unit,
"net_amount": row.net_amount,
"surcharge_pct": row.surcharge_pct,
"total_amount": row.total_amount,
"supply": row.supply,
"supply_label": SUPPLY_LABELS.get(row.supply, row.supply),
"install_by": row.install_by,
"install_by_label": INSTALL_BY_LABELS.get(row.install_by or "", ""),
"note": row.note,
"sources": row.sources,
}
for row in ordered
],
# 이 표가 할증을 붙인 곳임을 못 박는다 — B09 는 다시 붙이지 않는다(㉠).
"surcharge_applied": True,
"surcharge_dataset": {
"effective_date": table.effective_date,
"source": table.source,
},
"missing_rate_materials": sorted(set(missing_rate)),
"missing_supply_materials": sorted(set(missing_supply)),
"missing_install_by_materials": sorted(set(missing_install_by)),
"double_count_warnings": verify_single_surcharge(unit_quantity_table),
"skipped_by_destination": skipped,
"row_count": len(ordered),
}