"""자재 총괄표 — 할증이 붙는 **유일한** 자리 (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", ), }