- 스냅샷 나눔: 원본 모으기(M02_Item_Source) · 맞추기(M02_Item_Snapshot) · 복사본 읽기 · 쓰기(M02_Item_Copy) · 단가 묶음 · 계산(M02_Item_Prices)
- 단가.json = 담은 로직 · 하위 로직 · 요소 · 준용 · 찾기 표 · 재료 고르기 후보 · 지역 · 계약종별 후보(마스터 파일 차례 그대로)
- 길 /api/projects/{id}/items(목록 · 미리 가져오기) · /{폴더}/{종류}(GET · PUT 판 409 · 면적 · 재료 서버 계산값) · /refresh · /calc
- 고친 복사본은 구조물에서 빠져도 안 지움 · 옛 복사본은 읽을 때 채움
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
250 lines
11 KiB
Python
250 lines
11 KiB
Python
"""집계 항목 복사본의 단가 묶음 `단가.json` · 복사본만으로 계산 (PLAN 25-1b · 25-2b · 25-2e).
|
|
|
|
단가 묶음 = 그 항목 일위대가 조합이 담은 로직을 계산할 때 닿을 수 있는 마스터 줄 전부(닫힌 묶음):
|
|
- 담은 로직 · 로직이 부르는 하위 로직(`로직(GC…)`) 끝까지
|
|
- 식이 가리키는 요소(인력 · 기계 · 재료 · 유가전력 · 요율 · 환율 …) · 준용 대상
|
|
- 찾기 표(`찾기(QC…)` · `찾기(QF…)` 소요량 · 계수)
|
|
- 재료 고르기 조건의 후보 자재품목 줄 · 품셈재료 연결 · 지역 · 계약종별 후보
|
|
원문번호 틀의 `{입력}` 은 그 입력의 고르기 값으로 모두 끼워 모음(고르기가 없으면 그 테이블 전부 ·
|
|
자재품목만은 너무 커서 뺌). 줄은 마스터 파일 차례 그대로 담음 — 고르기의 「첫 줄」 이 같게.
|
|
|
|
`단가.json` = `{파일 이름: {그룹 · 머리 칸 …, 줄|표: [줄]}}` — `master_formula.Master` 가
|
|
그대로 읽는 꼴.
|
|
계산(`calc`)은 이 묶음과 `입력값.json` · `고른자재.json` 만 씀 — 마스터 현재값을 읽지 않음.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from decimal import Decimal
|
|
from typing import Any
|
|
|
|
from M01_MasterData import M01_MasterData_Store as m01
|
|
|
|
mf = m01.cm.mf
|
|
mk = m01.mk
|
|
mcb = m01.mcb
|
|
mm = mf.mp.mm # 품셈재료 · 자재품목 후보(`master_material`)
|
|
|
|
#: 틀을 못 풀었을 때 통째로 담지 않는 테이블 — 자재품목(3만 줄)
|
|
_TOO_BIG = ("MT",)
|
|
|
|
|
|
def master() -> tuple[dict[str, dict], Any]:
|
|
"""(마스터 파일 묶음, 색인) — 읽기 전용(시험은 `m01.FOLDER` 를 사본으로 바꿈)."""
|
|
return m01.loaded()
|
|
|
|
|
|
def _texts(row: dict) -> list[str]:
|
|
out = [step["식"] for step in row.get("중간", [])]
|
|
if "결과" in row:
|
|
out.append(row["결과"]["식"])
|
|
for item in row.get("호표", []):
|
|
out.append(item["수량"])
|
|
if not isinstance(item.get("요소"), dict):
|
|
out.append(item["요소"])
|
|
out += [extra["식"] for extra in row.get("덧줄", [])]
|
|
return out
|
|
|
|
|
|
class _Collector:
|
|
def __init__(self, whole: Any):
|
|
self.whole = whole
|
|
self.keys: set[str] = set()
|
|
self.logics: set[str] = set()
|
|
self.missing: list[str] = []
|
|
|
|
def refs(self, row: dict, template: str) -> list[str]:
|
|
"""원문번호 틀 → 닿을 수 있는 요소 참조 — 고르기로 끼우고, 못 풀면 그 테이블 전부."""
|
|
if "{" not in template:
|
|
return [template]
|
|
expanded = mf._expand(row, template, self.whole)
|
|
if expanded is not None:
|
|
return expanded
|
|
tid = mk.id_of(template)
|
|
if not tid or tid in _TOO_BIG:
|
|
self.missing.append(f"{row.get('키')} 「{template}」 틀을 못 풂")
|
|
return []
|
|
return list(self.whole.index.get(tid, {}))
|
|
|
|
def add(self, ref: str) -> None:
|
|
try:
|
|
row = self.whole.get(ref)
|
|
except mf.FormulaError:
|
|
return # 없는 요소 — 계산이 같은 까닭으로 멈춤(마스터와 같게)
|
|
key = str(row.get("키"))
|
|
if key in self.keys:
|
|
return
|
|
self.keys.add(key)
|
|
if row.get("준용") not in (None, ""):
|
|
self.add(str(row["준용"]))
|
|
link = row.get("연결") if key[:2] == mf.LINKED else None
|
|
if isinstance(link, str):
|
|
self.add(link)
|
|
elif isinstance(link, dict):
|
|
self.widely(link, row.get("단위"))
|
|
|
|
def widely(self, cond: dict, unit: Any) -> None:
|
|
"""지역 · 계약종별 「입력」 조건 — 입력값과 상관없이 닿을 수 있는 후보 전부."""
|
|
want = mm.plain(cond.get("이름"))
|
|
if cond.get("계약종별") == "입력":
|
|
for key, one in self.whole.index.get(mm.PRICED, {}).items():
|
|
if one.get("구분") == "전력" and mm.plain(one.get("이름")).startswith(want):
|
|
self.keys.add(key)
|
|
return
|
|
spec, unit_p = mm.plain_spec(cond.get("규격")), mm.plain_unit(unit)
|
|
for root, _, s, u, key in mm._market_index(self.whole):
|
|
if root.endswith(want) and u == unit_p and (not spec or s == spec):
|
|
self.keys.add(key)
|
|
|
|
def pick(self, row: dict, cond: dict, unit: Any) -> None:
|
|
"""재료 고르기 조건 — 대표 줄 · 후보 줄(`{입력}` 은 고르기 값으로 끼움)."""
|
|
if cond.get("지역") == "입력" or cond.get("계약종별") == "입력":
|
|
self.widely(cond, unit)
|
|
return
|
|
holes = sorted(
|
|
{h for v in cond.values() if isinstance(v, str) for h in mf._HOLE.findall(v)}
|
|
)
|
|
variants = [cond]
|
|
if holes:
|
|
env_sets = mf._expand(row, "|".join(f"{{{h}}}" for h in holes), self.whole)
|
|
if env_sets is None:
|
|
variants = [mf.static(cond)]
|
|
else:
|
|
variants = []
|
|
for filled in env_sets:
|
|
env = dict(zip(holes, filled.split("|")))
|
|
variants.append(
|
|
{k: mf.fill(v, env) if isinstance(v, str) else v for k, v in cond.items()}
|
|
)
|
|
for one in variants:
|
|
if one.get("대표"):
|
|
self.add(str(one["대표"]))
|
|
if not one.get("구분") and not one.get("검색어"):
|
|
continue
|
|
for key in mf.candidates(self.whole, one, None, None):
|
|
self.add(key)
|
|
|
|
def logic(self, key: str) -> None:
|
|
if key in self.logics:
|
|
return
|
|
try:
|
|
row = self.whole.logic(key)
|
|
except mf.FormulaError as error:
|
|
self.missing.append(f"{key} · {error}")
|
|
return
|
|
self.logics.add(key)
|
|
self.keys.add(key)
|
|
for text in _texts(row):
|
|
try:
|
|
node = mf.parse(text)
|
|
except mf.FormulaError:
|
|
continue # 못 읽는 식 — 계산도 같은 까닭으로 멈춤
|
|
for part in mf.walk(node):
|
|
kind = part[0]
|
|
if kind == "ref" or kind == "find":
|
|
for ref in self.refs(row, part[1]):
|
|
self.add(ref)
|
|
elif kind == "logic":
|
|
for ref in self.refs(row, part[1]):
|
|
self.logic(ref)
|
|
for item in row.get("호표", []):
|
|
if isinstance(item.get("요소"), dict):
|
|
self.pick(row, item["요소"], item.get("단위"))
|
|
|
|
|
|
def collect(logic_keys: list[str]) -> dict[str, Any]:
|
|
"""담은 로직의 닫힌 단가 묶음 — `{파일 이름: 파일(줄은 모은 것만 · 차례 그대로)}`."""
|
|
files, whole = master()
|
|
collector = _Collector(whole)
|
|
for key in logic_keys:
|
|
collector.logic(key)
|
|
out: dict[str, Any] = {}
|
|
for name, data in files.items():
|
|
slot = m01.items_key(data)
|
|
rows = [row for row in data.get(slot) or [] if str(row.get("키")) in collector.keys]
|
|
if rows:
|
|
out[name] = {**{k: v for k, v in data.items() if k != slot}, slot: rows}
|
|
if collector.missing:
|
|
out["_빠진"] = sorted(set(collector.missing))
|
|
return out
|
|
|
|
|
|
def to_json(value: Any) -> Any:
|
|
"""Decimal 을 JSON 수로 — 정수는 int · 아니면 원래 자릿수 그대로(float 로 흔들지 않음)."""
|
|
return json.loads(json.dumps(value, ensure_ascii=False, default=_number))
|
|
|
|
|
|
def _number(value: Any) -> Any:
|
|
if isinstance(value, Decimal):
|
|
text = str(value) # 「14480.0」 은 소수 그대로 — 끝수 · 표기가 마스터와 같게
|
|
return int(value) if "." not in text and "E" not in text.upper() else float(value)
|
|
raise TypeError(type(value).__name__)
|
|
|
|
|
|
def load_prices(prices: dict[str, Any]) -> Any:
|
|
"""`단가.json` → 계산 색인(수는 Decimal · 마스터 적재와 같게)."""
|
|
raw = json.dumps({k: v for k, v in prices.items() if not k.startswith("_")}, ensure_ascii=False)
|
|
files = json.loads(raw, parse_float=Decimal, parse_int=Decimal)
|
|
return mf.Master(files)
|
|
|
|
|
|
# ── 계산(25-2e) ─────────────────────────────────────────────────────
|
|
|
|
|
|
def missing_inputs(snapshot: Any, labor: dict[str, Any], values: dict[str, Any]) -> list[dict]:
|
|
"""담은 로직마다 값이 없는(null · 안 적음) 입력 — `{로직, 입력}`."""
|
|
out = []
|
|
for key in mcb.logic_keys(labor.get("줄") or {}):
|
|
try:
|
|
row = snapshot.logic(key)
|
|
except mf.FormulaError:
|
|
continue
|
|
mine = values.get(key) or {}
|
|
for spec in row.get("입력", []):
|
|
got = mine.get(spec["이름"])
|
|
if not isinstance(got, dict) or got.get("값") in (None, ""):
|
|
out.append({"로직": key, "입력": spec["이름"]})
|
|
return out
|
|
|
|
|
|
def calc(
|
|
labor: dict[str, Any], prices: dict[str, Any], values: dict[str, Any], picks: dict[str, Any]
|
|
) -> dict[str, Any]:
|
|
"""복사본만으로 계산 — 입력이 빠진 로직은 안 돌리고 `멈춤` · 세 비목 · 계는 돈 로직 합."""
|
|
snapshot = load_prices(prices)
|
|
gaps = missing_inputs(snapshot, labor, values)
|
|
blocked = {gap["로직"] for gap in gaps}
|
|
sums = dict.fromkeys(mf.COST_ITEMS, Decimal(0))
|
|
lines, stuck = [], []
|
|
for at, key in enumerate(mcb.logic_keys(labor.get("줄") or {}), start=1):
|
|
head: dict[str, Any] = {"차례": at, "로직": key}
|
|
if key in blocked:
|
|
names = [gap["입력"] for gap in gaps if gap["로직"] == key]
|
|
lines.append({**head, "ok": False, "까닭": f"입력 없음 — {', '.join(names)}"})
|
|
stuck.append(key)
|
|
continue
|
|
given = {name: one.get("값") for name, one in (values.get(key) or {}).items()}
|
|
try:
|
|
with mf.mp.choosing((picks or {}).get(key)):
|
|
got = mf.run(snapshot, key, given)
|
|
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as error:
|
|
lines.append({**head, "ok": False, "까닭": str(error) or type(error).__name__})
|
|
stuck.append(key)
|
|
continue
|
|
if "결과" in got:
|
|
lines.append({**head, "ok": True, "갈래": "결과", "결과": got["결과"]})
|
|
continue
|
|
for name in mf.COST_ITEMS:
|
|
sums[name] += got[name]
|
|
lines.append(
|
|
{
|
|
**head,
|
|
"ok": True,
|
|
"갈래": "돈",
|
|
**{k: got[k] for k in mf.COST_ITEMS},
|
|
"계": got["계"],
|
|
}
|
|
)
|
|
return to_json({"줄": lines, **sums, "계": sum(sums.values()), "빠진입력": gaps, "멈춤": stuck})
|