Files
Aislo/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py
T
eomsangdonandClaude Opus 5 89effeca07 feat(B09): 산출기초 — 줄에 달린 근거를 한 장으로 접음
별표2 (5)(가) 열셋째. 근거는 줄마다 이미 있었고 묶는 자리만 없었음.
원가계산에 「산출기초」 탭으로 세움. 넷으로 접음 —
① 어느 판으로 계산했나(데이터 기준일·지문) ② 무엇을 골랐나(고른 값만)
③ 공종마다 무엇을 근거로 했나(줄 문구 그대로) ④ 못 채운 자리(0 으로 안 때운 자리).

- 판 목록은 장부(_manifest.json)를 그대로 읽음 — 목록을 따로 적으면 한쪽만 고쳐짐.
- ⚠ 여기서 값을 다시 계산하지 않음 — 금액 칸이 아예 없음(두 벌 방지, 시험으로 못 박음).

⇒ 「설계서 구성」의 산출기초가 반쪽 → 있음. 법이 정한 13 중 9 가 서고,
  우리가 더 낼 것은 공사설명서 하나(서식·설계하중 표기는 사용자에게 받아야 함).

곁들여: 자재총괄의 「미분류」를 「미정(발주기관 결정)」으로 고침 — 발주기관이 정할
자리인데 우리가 못 만든 것처럼 읽히던 문구(V-14). 값·판정은 그대로.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 00:06:22 +09:00

380 lines
19 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`) 율을 붙이지 않고 비고에 까닭을 남긴다.
⚠ 관급구분은 **세 값**이다 — `owner_supplied` · `contractor_supplied` · `unknown`
`unknown` 은 「아직 안 정함」이고 **지어내지 않겠다는 뜻**이다. B09 는 이 줄을 관급자재대에도
도급 재료비에도 넣지 않고 `missing` 으로 뺀다(2026-09-07 계약에 명시).
⚠ 관급/사급은 **법이 아니라 발주 결정**이다
자재마다 정해진 값이 아니므로 지어내지 않는다. 프로젝트 설정
(`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
from common_util.common_util_quantity_spread import spread_by_unit
# ── 데이터 자리 ──────────────────────────────────────────────────────
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" # 아직 안 정함 — 화면에 드러낸다
#: ⚠ 「미분류」로만 적으면 **우리가 아직 못 만든 것처럼** 읽힌다 — 실제로는 **발주기관이
#: 정할 자리**라 우리가 지어내면 안 되는 값이다(2026-09-09 화면 확인에서 나옴).
#: 그래서 표기에 **누가 정하는지**를 함께 적는다. 값(`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 = "설치 주체 미지정"
#: ⚠ 할증 상태는 **세 갈래**다 (2026-09-07 3자 계약 정정).
#: 두 갈래(`True`/`False`)로 두면 「율을 못 찾아 안 붙인 것」이 「붙였다」로 나가고,
#: 나중에 진짜 율이 들어왔을 때 B09 가 한 번 더 붙인다. **깃발과 실제가 어긋나지 않을 것**이
#: 요건이므로 상태를 그대로 말한다.
SURCHARGE_APPLIED = "applied" # 한 줄이라도 실제로 붙음
SURCHARGE_NOT_APPLIED = "not_applied" # 붙일 줄이 없음(자재 자체가 없음)
SURCHARGE_RATE_UNAVAILABLE = "rate_unavailable" # 자재는 있는데 율을 못 찾음
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:
# ⚠ **왜 미확보인지**를 함께 적는다 — 「표에 이름이 없음」과 「이 방식엔 안 붙임」은
# 할 일이 다르다(2026-09-09 콘크리트에서 갈린 자리).
parts.append(NOTE_RATE_MISSING)
if self.basis:
parts.append(self.basis)
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 _surcharge_status(rows: list[MaterialRow]) -> str:
"""할증이 실제로 붙었는가 — 세 갈래로 답한다."""
if not rows:
return SURCHARGE_NOT_APPLIED
if any(row.surcharge_pct is not None and not row.surcharge_included for row in rows):
return SURCHARGE_APPLIED
return SURCHARGE_RATE_UNAVAILABLE
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 []
#: 할증표 이름과 우리 성분 이름이 **다른 자리** — 이름만 잇는다(값은 그대로).
#: ⚠ 리핑암↔파쇄암 때와 같은 처방이다. 이름을 바꾸면 다른 쪽(타설 줄·묶음 조각)이 어긋난다.
#: 철근: 품셈 1-3-1 의 「이형철근 3 %」는 **규격을 가리지 않는다** — D13·D16 이 같은 줄이다.
#: 콘크리트: **레미콘일 때만** 잇는다. 기계·인력 비빔은 시멘트·골재가 각각 할증되는 자리라
#: 레미콘 할증을 붙이면 틀린다 — 그때는 미확보로 두고 사유를 낸다.
REBAR_ALIASES = {"이형철근 D13": "이형철근", "이형철근 D16": "이형철근"}
CONCRETE_NAMES = ("콘크리트", "채움콘크리트", "버림콘크리트")
READY_MIXED = "ready_mixed"
CONCRETE_ALIAS = "레미콘"
CONCRETE_NOT_READY_NOTE = (
"타설 방식이 레미콘이 아니라 레미콘 할증을 붙이지 않음 — 비빔은 시멘트·골재가 각각 할증됨"
)
def surcharge_lookup_name(name: str, concrete_placing_method: str | None) -> tuple[str, str]:
"""(할증표에서 찾을 이름, 사유). 이름이 그대로면 사유는 빈 문자열이다."""
if name in REBAR_ALIASES:
return REBAR_ALIASES[name], "품셈 1-3-1 「이형철근」 — 규격을 가리지 않음"
if name in CONCRETE_NAMES:
if concrete_placing_method == READY_MIXED:
return CONCRETE_ALIAS, "타설 방식이 레미콘 — 할증표의 「레미콘」 줄로 봄"
# ⚠ **안 정한 것과 비빔을 가른다.** 안 정하면 타설 줄이 기본값(레디믹스트)으로 도는데
# 할증만 미확보로 두면 **같은 프로젝트에서 두 값이 어긋난다**(2026-09-09 실화면).
if not concrete_placing_method:
return CONCRETE_ALIAS, "타설 방식을 안 정해 기본값(레디믹스트)으로 봄 — 정하면 따라감"
return name, CONCRETE_NOT_READY_NOTE
return name, ""
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]] = (),
concrete_placing_method: str | None = None,
) -> 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
lookup, alias_note = surcharge_lookup_name(name, concrete_placing_method)
rate, basis = table.rate_for(lookup)
row.surcharge_pct = rate
row.basis = " · ".join(part for part in (basis, alias_note) if part)
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_status": _surcharge_status(ordered),
# 옛 두 갈래 깃발 — **실제로 붙었을 때만** 참이다(호환을 위해 남긴다).
"surcharge_applied": _surcharge_status(ordered) == SURCHARGE_APPLIED,
"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),
# 값의 크기가 말이 되나 — 자릿수 어긋남은 사람이 훑어야 보인다(단위별로 가른다).
"amount_spread": spread_by_unit(
[{"unit": row.unit, "amount": row.total_amount} for row in ordered],
value_key="amount",
),
}