Files
Aislo/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py
T
eomsangdonandClaude Opus 5 81286400bd fix(b08,b09): 기본값으로 선 구조물은 줄만 서고 금액 밖 — 「미확정 N건 — 금액에 안 들어감」
- 브레인 판정: 값 없음을 감추면 조용히 빠지거나 조용히 부풂 — 없으면 없다고 보이기
- 관 지점 벽 치수를 등록부 기본값으로 채운 줄 = 미확정: 원단위 합·자재·터파기·되메우기·기초잡석·타설·잔토·채집석에서 뺌
- 인계 막힘 갈래 넷째 값 unconfirmed(계약 칸은 안 늘림) · B09 는 내역 제자리에 빈 금액 + 빨간 테두리 · 머리 뱃지
- 구조물도 그림에 표와 같은 기본값 사유 줄 · 「횡단도와 같은 값」 문구는 틀려 고침(B06 관 벽 높이는 관경 기준)
- 936be972 내역 184,291,506 → 124,424,547 · 미확정 18건

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 08:22:14 +09:00

413 lines
20 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
spec: str = "" # 규격 — 돌 종류·콘크리트 강도처럼 같은 이름을 가르는 값(명세 13장 Ⓒ)
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 supply_key(self) -> str:
"""관급구분 설정(`material_supply`)을 찾는 열쇠 — 이름 + 규격."""
return f"{self.name} {self.spec}".strip()
@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 _add(rows: dict[tuple[str, str, str], MaterialRow], item: dict[str, Any]) -> MaterialRow:
"""(이름·규격·단위)가 같은 줄에 더한다 — 규격이 다르면 다른 자재다(돌 종류·강도)."""
name = str(item.get("name") or "").strip()
spec = str(item.get("spec") or "").strip()
unit = str(item.get("unit") or "").strip()
row = rows.setdefault((name, spec, unit), MaterialRow(name=name, unit=unit, spec=spec))
row.net_amount += float(item.get("amount") or 0.0)
if item.get("surcharge_included"):
row.surcharge_included = True
return row
def _supply_setting(supply: dict[str, Any], row: MaterialRow) -> Any:
"""그 줄의 관급구분 설정 — 이름+규격이 먼저, 없으면 옛 열쇠.
⚠ 옛 열쇠 둘을 받는다 — 이름만(규격이 갈리기 전 「채움콘크리트」) · 규격만(돌 줄이
종류 이름으로 서던 때 「야면석·호박돌」, 2026-09-13 걷음).
안 받으면 사용자가 정한 값이 사라진다.
"""
for key in (row.supply_key, row.name, row.spec):
if key and key in supply:
return supply[key]
return None
#: 건너뛴 까닭 칸 — 치수가 없어 기본값으로 선 구조물(구조물 수로 셈).
UNCONFIRMED_SKIP = "미확정(기본값으로 선 구조물)"
def _collect(
unit_quantity_table: dict[str, Any],
) -> tuple[dict[tuple[str, str, str], MaterialRow], dict[str, int]]:
"""`destination == "material"` 만 모은다. 나머지는 세어서 보인다."""
rows: dict[tuple[str, str, str], MaterialRow] = {}
skipped: dict[str, int] = {}
for structure in unit_quantity_table.get("structures", []):
if structure.get("unconfirmed"):
# 기본값으로 선 구조물 — 자재에 안 듦(2026-09-14 브레인 판정 「없으면 없다고 보이기」).
skipped[UNCONFIRMED_SKIP] = skipped.get(UNCONFIRMED_SKIP, 0) + 1
continue
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
row = _add(rows, component)
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
row = _add(rows, item)
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 row in rows.values():
name = row.name
row.supply, row.install_by = _supply_of(_supply_setting(supply, row))
if row.supply == SUPPLY_UNKNOWN:
missing_supply.append(row.supply_key)
# ⚠ 설치 주체는 관급 줄에만 묻는다. 사급은 애초에 대상액 밖이라 비워 두는 것이 맞다.
if row.supply == SUPPLY_OWNER and row.install_by is None:
missing_install_by.append(row.supply_key)
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.spec, item.unit))
return {
"columns": [
"자재명",
"단위",
"순수량",
"할증률(%)",
"합계",
"관급구분",
"설치주체",
"비고",
],
"rows": [
{
"name": row.name,
"spec": row.spec,
# 관급구분을 고르는 열쇠 — 화면이 이 이름으로 설정에 적는다.
"supply_key": row.supply_key,
"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",
),
}