Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
468 lines
24 KiB
Python
468 lines
24 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 = "품셈에 할증 포함 — 중복 적용 안 함"
|
||
#: ⚠ **안 정한 자재는 사급으로 선다**(2026-09-14 브레인 판정 ⓐ — 「기본 사급 · 관급은 프로젝트
|
||
#: 설정이 정함」 · 품셈 제1장 「관계규정이나 계약조건에 따른다」). 종전 `unknown` 은 어느 합계에도
|
||
#: 안 들어 자재비가 통째로 빠져 있었음. 명시적으로 `unknown` 을 고른 줄은 그대로 미정.
|
||
NOTE_SUPPLY_DEFAULT = "관급구분 기본 사급 — 관급이면 산출 조건에서 고름"
|
||
|
||
|
||
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)
|
||
#: 율이 없는 **까닭** — 자재 → 사유. 보류(조건이 다름)와 이름 없음을 줄에 드러냄.
|
||
#: ⚠ 「할증률 미확보」만 적으면 「아직 안 찾음」과 「찾았으나 일부러 안 걺」이 안 갈림.
|
||
missing_reasons: dict[str, str] = 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
|
||
}
|
||
reasons = {
|
||
str(item.get("material")): (
|
||
f"조건이 다름 — 사용자 확정 후 옮길 것 · 원문 「{item.get('listed_condition')}」"
|
||
f" {item.get('rate')}% · {item.get('why_not_applied')}"
|
||
)
|
||
for item in (payload.get("candidates_pending_user") or {}).get("items") or []
|
||
if item.get("material")
|
||
}
|
||
reasons.update(
|
||
{
|
||
str(name): "할증률표에 이름 없음(산림·건설 품셈 1-3-1 확인) — 임의로 안 정함"
|
||
for name in (payload.get("not_found") or {}).get("materials") or []
|
||
}
|
||
)
|
||
return SurchargeTable(
|
||
effective_date=str(payload.get("effective_date") or ""),
|
||
source=payload.get("source") or {},
|
||
rates=rates,
|
||
missing_reasons=reasons,
|
||
)
|
||
|
||
|
||
@dataclass
|
||
class MaterialRow:
|
||
"""총괄표 한 줄. 할증 **전·후를 둘 다** 들고 있는다(8-2 인계)."""
|
||
|
||
name: str
|
||
unit: str
|
||
spec: str = "" # 규격 — 돌 종류·콘크리트 강도처럼 같은 이름을 가르는 값(명세 13장 Ⓒ)
|
||
net_amount: float = 0.0 # 순수량 — 할증 전
|
||
surcharge_pct: float | None = None # None = 미확보
|
||
surcharge_cap: float | None = None # 할증률표 값(1-3-1 「이내」의 상한) — 고른 값의 천장
|
||
supply: str = SUPPLY_UNKNOWN
|
||
#: 산출 조건에 안 적어 **기본 사급**으로 선 줄 — 그 사실을 비고에 적음.
|
||
supply_default: bool = False
|
||
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)
|
||
if self.supply_default:
|
||
parts.append(NOTE_SUPPLY_DEFAULT)
|
||
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
|
||
|
||
|
||
def _chosen_rate(cap: float | None, raw: Any) -> tuple[float | None, str]:
|
||
"""(쓸 할증률, 사유) — 고른 값은 0 ~ 표값(상한)만. 표에 없으면 고른 값도 안 씀(임의 금지)."""
|
||
if raw is None or str(raw).strip() == "" or cap is None:
|
||
return cap, ""
|
||
try:
|
||
value = float(raw)
|
||
except (TypeError, ValueError):
|
||
return cap, f"고른 할증률 「{raw}」 을 수로 못 읽어 표값 {cap:g}% 씀"
|
||
if not 0 <= value <= cap:
|
||
return cap, f"고른 할증률 {value:g}% 는 표값 {cap:g}% 이내가 아니라 안 씀(1-3-1 「이내」)"
|
||
return value, f"산출 조건에서 고른 값 {value:g}% (표 상한 {cap:g}% 이내)"
|
||
|
||
|
||
#: 건너뛴 까닭 칸 — 치수가 없어 기본값으로 선 구조물(구조물 수로 셈).
|
||
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,
|
||
surcharge_overrides: dict[str, Any] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""화면·API 가 그대로 쓰는 모양.
|
||
|
||
`extra_materials` 는 구조물 전개 밖에서 오는 자재(떼·초류종자 등 사면 계열)를 받는 자리다.
|
||
모양은 원단위 성분과 같다(`name`·`unit`·`amount`·`destination`).
|
||
|
||
`surcharge_overrides` — `{이름 규격: %}` 산출 조건에서 고른 할증률. 품셈 1-3-1
|
||
「표의 값 **이내**」라 **0 ~ 표값만** 받음 · 표에 없는 자재·할증 포함 줄엔 안 걺
|
||
(넘으면 안 쓰고 사유).
|
||
"""
|
||
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] = []
|
||
chosen_rates = surcharge_overrides or {}
|
||
for row in rows.values():
|
||
name = row.name
|
||
setting = _supply_setting(supply, row)
|
||
row.supply, row.install_by = (
|
||
_supply_of(setting) if setting is not None else (SUPPLY_CONTRACTOR, None)
|
||
)
|
||
row.supply_default = setting is None
|
||
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_cap = rate
|
||
rate, chosen_note = _chosen_rate(rate, chosen_rates.get(row.supply_key))
|
||
row.surcharge_pct = rate
|
||
reason = table.missing_reasons.get(lookup, "") if rate is None else ""
|
||
row.basis = " · ".join(part for part in (basis, alias_note, chosen_note, reason) 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,
|
||
# 고를 수 있는 천장(표값) — `None` 이면 칸이 안 섬(표에 없거나 할증 포함 줄).
|
||
"surcharge_cap": None if row.surcharge_included else row.surcharge_cap,
|
||
"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",
|
||
),
|
||
}
|