Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
235 lines
9.4 KiB
Python
235 lines
9.4 KiB
Python
"""B09 원가계산 — **자재 수동 단가** 한 벌 (PLAN 1장 Ⓐ · 2026-09-14 브레인 판정).
|
|
|
|
저장 자리 산출 조건 `estimation.material_prices`
|
|
{키: {price_krw, source, entered_at, name, spec, unit}}
|
|
키 자원 코드가 있으면 **코드**(`AR-M-…`) · 코드 없는 자재만 「이름 규격」
|
|
(명세 2장 「잇기는 코드로」 · 11장 「규격은 조인 키」)
|
|
⚠ 이름·규격·단위는 **보이기용** — 맞추는 데 안 씀. 이름이 고쳐져도 단가가 안 끊김
|
|
얹는 자리 조립 때 자재 제목(M)을 세움 — 6번 슬롯 = 수동 단가 · 쪽수 칸 = 출처
|
|
→ 자원 축 자재 줄이 그 제목에 붙어 일위대가 재료비가 섬
|
|
넣은 날 값·출처가 같으면 다시 저장해도 안 바뀜(구조물도 수동 단가 `save_manual` 과 같은 꼴)
|
|
미확정 수동 단가가 닿은 내역 줄마다 「미확정 N건」(구조물도 수동 단가·폐기물과 같은 통로)
|
|
|
|
⚠ 값이 없거나 0 이하인 칸은 받지 않음 — 0 원으로 채우면 「단가 없음」이 금액 0 으로 숨음.
|
|
⚠ 코드 없는 「이름 규격」 줄은 자재총괄 몫(다음 단계) — 조립에는 코드 줄만 얹음.
|
|
이름 줄에 나중에 코드가 붙으면 코드 키로 옮겨 붙일 자리 — 아직 안 만듦(브레인 판정).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
from decimal import Decimal, InvalidOperation
|
|
from functools import lru_cache
|
|
from typing import Any
|
|
|
|
from B09_Estimation.B09_Estimation_PriceBook import (
|
|
PRICE_SLOT_COUNT,
|
|
PriceBook,
|
|
PriceKind,
|
|
PriceTitle,
|
|
)
|
|
|
|
MATERIAL_PRICES_KEY = "material_prices"
|
|
#: 조립에 얹는 키 — 자원 목록 자재 코드(명세 §2 ③ 모양).
|
|
_RE_MATERIAL_CODE = re.compile(r"^AR-M-[0-9a-f]{8}$")
|
|
#: 출처를 안 적었을 때 쪽수 칸 글.
|
|
MANUAL_SOURCE = "수동 입력"
|
|
_TEXT_FIELDS = ("source", "name", "spec", "unit")
|
|
|
|
|
|
class MaterialPriceError(ValueError):
|
|
"""받을 수 없는 단가 — 저장하지 않고 까닭을 돌려줌."""
|
|
|
|
|
|
def _price(value: Any) -> Decimal | None:
|
|
try:
|
|
price = Decimal(str(value).replace(",", "").strip())
|
|
except (InvalidOperation, ValueError):
|
|
return None
|
|
return price if price.is_finite() and price > 0 else None
|
|
|
|
|
|
def is_code_key(key: str) -> bool:
|
|
"""조립에 얹는 코드 키인가 — 아니면 「이름 규격」 키."""
|
|
return bool(_RE_MATERIAL_CODE.match(key))
|
|
|
|
|
|
def normalize(raw: Any) -> dict[str, dict[str, str]]:
|
|
"""저장본을 받을 수 있는 줄만 — 값이 없거나 0 이하면 버림."""
|
|
prices: dict[str, dict[str, str]] = {}
|
|
if not isinstance(raw, dict):
|
|
return prices
|
|
for key, entry in raw.items():
|
|
price = _price(entry.get("price_krw")) if isinstance(entry, dict) else None
|
|
if not str(key).strip() or price is None:
|
|
continue
|
|
prices[str(key)] = {
|
|
"price_krw": str(price),
|
|
"entered_at": str(entry.get("entered_at") or ""),
|
|
**{field: str(entry.get(field) or "") for field in _TEXT_FIELDS},
|
|
}
|
|
return prices
|
|
|
|
|
|
def build_key(raw: Any) -> tuple[tuple[str, str, str], ...]:
|
|
"""조립 캐시 키 — 코드 키만 (키, 단가, 출처). 같은 값이면 같은 벌."""
|
|
return tuple(
|
|
sorted(
|
|
(key, entry["price_krw"], entry["source"])
|
|
for key, entry in normalize(raw).items()
|
|
if is_code_key(key)
|
|
)
|
|
)
|
|
|
|
|
|
def merge(stored: Any, changes: list[dict[str, Any]], today: str) -> dict[str, dict[str, str]]:
|
|
"""바꿀 것 여럿을 한 번에 — `price_krw` 가 비면 그 줄을 지움(단가 없음으로 돌아감)."""
|
|
prices = normalize(stored)
|
|
for change in changes:
|
|
key = str(change.get("key") or "").strip()
|
|
if not key:
|
|
raise MaterialPriceError("자재 키가 비었습니다")
|
|
raw_price = change.get("price_krw")
|
|
if raw_price is None or str(raw_price).strip() == "":
|
|
prices.pop(key, None)
|
|
continue
|
|
price = _price(raw_price)
|
|
if price is None:
|
|
raise MaterialPriceError(f"단가는 0 보다 큰 수여야 합니다: {raw_price}")
|
|
old = prices.get(key) or {}
|
|
source = str(change.get("source") or "").strip()
|
|
same = old.get("price_krw") == str(price) and old.get("source") == source
|
|
prices[key] = {
|
|
"price_krw": str(price),
|
|
"entered_at": old["entered_at"] if same and old.get("entered_at") else today,
|
|
"source": source,
|
|
**{
|
|
field: str(change.get(field) or old.get(field) or "")
|
|
for field in ("name", "spec", "unit")
|
|
},
|
|
}
|
|
return prices
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def material_catalog_rows() -> dict[str, dict[str, str]]:
|
|
"""자원 목록(`AR-M-`) 코드 → 이름·규격·단위 — 제목을 세우고 화면에 보일 글."""
|
|
from B09_Estimation.B09_Estimation_ResourceAxis_Join import EXT_CATALOG_FILE
|
|
|
|
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
path = os.path.join(root, "resources", "data_resource_catalog", EXT_CATALOG_FILE)
|
|
if not os.path.isfile(path):
|
|
return {}
|
|
with open(path, encoding="utf-8") as handle:
|
|
entries = json.load(handle).get("entries") or []
|
|
return {
|
|
str(row["code"]): {
|
|
"name": str(row.get("name") or ""),
|
|
"spec": str(row.get("spec") or ""),
|
|
"unit": str(row.get("unit") or ""),
|
|
}
|
|
for row in entries
|
|
if is_code_key(str(row.get("code") or ""))
|
|
}
|
|
|
|
|
|
def add_material_titles(
|
|
book: PriceBook, prices: tuple[tuple[str, str, str], ...]
|
|
) -> dict[str, str]:
|
|
"""코드 키 수동 단가로 자재 제목(M)을 세움 — 돌려주는 값 = 코드 → 출처(미확정 셈에 씀).
|
|
|
|
자원 목록에 없는 코드는 안 세움 — 화면 목록이 「단가표에서 사라진 코드」로 드러냄(`listing`).
|
|
"""
|
|
catalog = material_catalog_rows()
|
|
manual: dict[str, str] = {}
|
|
for code, price, source in prices:
|
|
row = catalog.get(code)
|
|
if row is None or code in book.titles:
|
|
continue
|
|
slots: list[Decimal | None] = [None] * PRICE_SLOT_COUNT
|
|
pages: list[str | None] = [None] * PRICE_SLOT_COUNT
|
|
slots[-1] = Decimal(price)
|
|
pages[-1] = source or MANUAL_SOURCE
|
|
book.add_title(
|
|
PriceTitle(
|
|
code=code,
|
|
kind=PriceKind.MATERIAL,
|
|
name=row["name"],
|
|
spec=row["spec"],
|
|
unit=row["unit"],
|
|
slots=slots,
|
|
slot_pages=pages,
|
|
)
|
|
)
|
|
manual[code] = source or MANUAL_SOURCE
|
|
return manual
|
|
|
|
|
|
def manual_count(book: PriceBook, code: str | None, manual: dict[str, str]) -> int:
|
|
"""그 단가가 밟는 **수동 단가 자재 수**(같은 자재는 한 번) — 내역 줄 「미확정 N건」."""
|
|
if not code or not manual:
|
|
return 0
|
|
found: set[str] = set()
|
|
seen: set[str] = set()
|
|
stack = [code]
|
|
while stack:
|
|
current = stack.pop()
|
|
if current in seen:
|
|
continue
|
|
seen.add(current)
|
|
if current in manual:
|
|
found.add(current)
|
|
stack.extend(detail.ref_code for detail in book.details.get(current, []))
|
|
return len(found)
|
|
|
|
|
|
def listing(
|
|
build: Any, stored: Any, materials: list[dict[str, Any]] | None = None
|
|
) -> list[dict[str, Any]]:
|
|
"""화면 줄 — 자원 축이 쓰는 자재(코드) + 자재총괄 자재(「이름 규격」) + 저장만 남은 줄.
|
|
|
|
`materials` — B08 인계 `materials`(자재총괄). 사급 줄만 단가 칸이 섬(관급은 도급 금액 밖).
|
|
⚠ 사라진 줄도 조용히 안 버림 — 「사라진 자재」로 보이고 사용자가 지움.
|
|
"""
|
|
prices = normalize(stored)
|
|
catalog = material_catalog_rows()
|
|
uses: dict[str, list[str]] = getattr(build, "material_uses", {}) or {}
|
|
rows: list[dict[str, Any]] = []
|
|
|
|
def add(key: str, shown: dict[str, Any], **extra: Any) -> None:
|
|
entry = prices.get(key) or {}
|
|
rows.append(
|
|
{
|
|
"key": key,
|
|
**{
|
|
field: shown.get(field) or entry.get(field, "")
|
|
for field in ("name", "spec", "unit")
|
|
},
|
|
"price_krw": entry.get("price_krw"),
|
|
"source": entry.get("source", ""),
|
|
"entered_at": entry.get("entered_at", ""),
|
|
"missing": False,
|
|
**extra,
|
|
}
|
|
)
|
|
|
|
for code in sorted(uses, key=lambda c: (catalog.get(c, {}).get("name", ""), c)):
|
|
add(code, catalog.get(code, {}), work_items=sorted(uses[code]), origin="unit_price")
|
|
for material in materials or []:
|
|
key = f"{material.get('material_name', '')} {material.get('spec') or ''}".strip()
|
|
add(
|
|
key,
|
|
{**material, "name": material.get("material_name")},
|
|
work_items=list(material.get("source_structure") or []),
|
|
origin="material_sheet",
|
|
supply_type=material.get("supply_type") or "",
|
|
quantity=str(material.get("total_amount")),
|
|
)
|
|
listed = {row["key"] for row in rows}
|
|
for key, entry in sorted(prices.items()):
|
|
if key not in listed:
|
|
rows.append({"key": key, **entry, "work_items": [], "missing": True})
|
|
return rows
|