별표2 (5)(가) 열셋째. 근거는 줄마다 이미 있었고 묶는 자리만 없었음. 원가계산에 「산출기초」 탭으로 세움. 넷으로 접음 — ① 어느 판으로 계산했나(데이터 기준일·지문) ② 무엇을 골랐나(고른 값만) ③ 공종마다 무엇을 근거로 했나(줄 문구 그대로) ④ 못 채운 자리(0 으로 안 때운 자리). - 판 목록은 장부(_manifest.json)를 그대로 읽음 — 목록을 따로 적으면 한쪽만 고쳐짐. - ⚠ 여기서 값을 다시 계산하지 않음 — 금액 칸이 아예 없음(두 벌 방지, 시험으로 못 박음). ⇒ 「설계서 구성」의 산출기초가 반쪽 → 있음. 법이 정한 13 중 9 가 서고, 우리가 더 낼 것은 공사설명서 하나(서식·설계하중 표기는 사용자에게 받아야 함). 곁들여: 자재총괄의 「미분류」를 「미정(발주기관 결정)」으로 고침 — 발주기관이 정할 자리인데 우리가 못 만든 것처럼 읽히던 문구(V-14). 값·판정은 그대로. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
380 lines
19 KiB
Python
380 lines
19 KiB
Python
"""자재 총괄표 — 할증이 붙는 **유일한** 자리 (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",
|
||
),
|
||
}
|