연결된 사본 일위대가 로직 줄 중 승격 + 옛 SP 참조 줄 · 같은 로직 + 같은 입력값 = 산근 하나 · 서버 단독 풀이 · 대가 · 내역서가 읽는 길 sangeun_key · price_of · Linked.used_keys 빈 입력 = 자동값. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
378 lines
18 KiB
Python
378 lines
18 KiB
Python
"""B09 단가산출근거 · 단가산출근거목록표(PLAN 145-1) — 서버 단독(③ · 화면 짝 없음 · 저장 안 함).
|
||
|
||
산근 호표 = 연결된 사본(`linked_snapshots` · 143-1)의 일위대가 로직 줄 중
|
||
`승격: true` 인 줄(134-1 · 입력값 = 사본 `입력값.json` · 빈 입력 = 자동값) +
|
||
옛 단가승격 참조 줄(134-2 ③ 로 남은 SP · 입력값 = 굳힌 `참조사본`)
|
||
같은 로직 + 같은 입력값(+ 켜쌓기율 · 고른 자재) = 산근 하나(`sangeun_key`) ·
|
||
번호 = 첫 등장 차례 ·
|
||
로직 줄이 부르는 다른 돈 로직(기계경비 밖) = 하위 산근(그 산근도 목록에 · 산근 → 산근).
|
||
풀이 = 로직 호표 줄을 사본 단가 묶음(`단가.json`)으로 펼쳐 수량 · 자원 키만 얻고
|
||
(M01 엔진 식 그대로) ·
|
||
단가 = B09 프로젝트 한 벌 굳힌 단가(144 · `compute_base` 표) — 노무 = 노무비목록표 ·
|
||
중기 = 중기목록표 노 · 재 · 경 · 재료 = 자재단가대비표 적용 단가 ·
|
||
% 줄(덧줄) = 엔진 식에 B09 비목 합.
|
||
끝수 = 엑셀 자리 — 수량 = 결과단위 1 단위(÷ 배) ·
|
||
작업량 Q(중간 이름 Q · Q1 …) = ROUND(식, 2) 뒤 쓰임 ·
|
||
줄 금액 = ROUNDDOWN(단가 × 수량, 1) 비목마다 · 총계 = 비목마다 ROUNDDOWN(Σ, 0) · 합계 = 셋 합.
|
||
빈칸 규칙 = 단가 · 수량을 낸 원천이 없으면 그 칸 · 그 비목 총계 None(0 을 채우지 않음)
|
||
+ 「연결 없음」.
|
||
조합 밖 토공 · 운반 · 사토 산근 · 준비공 = 원천 없음(만들지 않음 · 목록 안내 한 줄).
|
||
대가(145-2) · 내역서(145-3)가 읽는 길 = `sangeun_key` + `price_of`.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
import re
|
||
from decimal import Decimal
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from B09_Estimation.B09_Estimation_Calc_Base import gap, num, round_half, rounddown, total
|
||
from M01_MasterData import M01_MasterData_Store as m01
|
||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||
from M02_MasterTemplete import M02_Item_Prices as item_prices
|
||
from M02_MasterTemplete.M02_Item_Snapshot import (
|
||
PICKS_FILE,
|
||
VALUES_FILE,
|
||
linked_snapshots,
|
||
structures_dir,
|
||
)
|
||
from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, PRICE_FILE, read_json
|
||
|
||
mf = item_prices.mf
|
||
PARTS = ("labor", "material", "expense")
|
||
COST_PART = {"노무비": "labor", "재료비": "material", "경비": "expense"}
|
||
DEPTH = 4 # 하위 산근 깊이 상한
|
||
_Q = re.compile(r"Q\d*") # 작업량 중간값 이름(산림품셈 09장 토공 · 07장 집재 …)
|
||
NO_SOURCE = "조합 밖 토공 · 운반 · 사토 산근 · 준비공 = 원천 없음(연결 없음 · 만들지 않음)"
|
||
EMPTY = "연결된 구조물 조합 사본에 단가승격 줄 없음 — 연결 없음"
|
||
|
||
|
||
# ── 산근 정체 ─────────────────────────────────────────────────────────
|
||
def _clean_given(given: Any) -> dict[str, Any]:
|
||
return {
|
||
str(k): v for k, v in sorted((given or {}).items()) if v not in (None, "") and k != "줄"
|
||
}
|
||
|
||
|
||
def sangeun_key(code: str, given: Any = None, rate: Any = None, picks: Any = None) -> str:
|
||
"""산근 이름표 — 같은 로직 + 같은 입력값(+ 켜쌓기율 · 고른 자재)이면 같은 글.
|
||
145-2 대가 · 145-3 내역서가 승격 줄에서 이 글을 만들어 `price_of` 로 단가를 읽음."""
|
||
body = {
|
||
"입력값": _clean_given(given),
|
||
"율": None if rate in (None, 100) else float(rate),
|
||
"고름": {str(k): v for k, v in sorted((picks or {}).items())},
|
||
}
|
||
text = json.dumps(body, ensure_ascii=False, sort_keys=True, default=str)
|
||
return f"SG:{code}:{hashlib.sha1(text.encode('utf-8')).hexdigest()[:10]}"
|
||
|
||
|
||
def price_of(sheets: dict[str, Any], key: str) -> dict[str, Any] | None:
|
||
"""산근 한 줄 단가 `{no, labor, material, expense, sum}`(1 단위 · 빈칸 = None) — 없으면 None."""
|
||
for row in sheets.get("sg_list") or []:
|
||
if row["key"] == key:
|
||
return {k: row[k] for k in ("no", *PARTS, "sum")}
|
||
return None
|
||
|
||
|
||
def _values(values: dict[str, Any], code: str) -> dict[str, Any]:
|
||
return {name: (one or {}).get("값") for name, one in (values.get(code) or {}).items()}
|
||
|
||
|
||
def rows_of(folder: Path) -> list[dict[str, Any]]:
|
||
"""사본 하나의 산근 원천 줄 — 승격 로직 줄 · 옛 SP 참조 줄(차례대로)."""
|
||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||
|
||
labor = read_json(folder / LABOR_FILE)
|
||
if not isinstance(labor, dict):
|
||
return []
|
||
values = read_json(folder / VALUES_FILE) or {}
|
||
picks = read_json(folder / PICKS_FILE) or {}
|
||
copies = labor.get("참조사본") or {}
|
||
out = []
|
||
for one in (labor.get("줄") or {}).get("담은로직") or []:
|
||
if not isinstance(one, dict):
|
||
continue
|
||
ref = one.get("참조")
|
||
if one.get("승격") is True and one.get("로직"):
|
||
code = str(one["로직"])
|
||
got = {
|
||
"code": code,
|
||
"given": _values(values, code),
|
||
"rate": one.get("켜쌓기율"),
|
||
"picks": picks.get(code) or one.get("고름"),
|
||
}
|
||
elif isinstance(ref, dict) and refs.kind_of(ref.get("키")) == "promote":
|
||
line = ((copies.get(refs.ref_id(ref)) or {}).get("담은로직") or [None])[0]
|
||
if not isinstance(line, dict) or not line.get("로직"):
|
||
continue
|
||
got = {
|
||
"code": str(line["로직"]),
|
||
"given": line.get("입력값") or {},
|
||
"rate": line.get("켜쌓기율"),
|
||
"picks": line.get("고름"),
|
||
}
|
||
else:
|
||
continue
|
||
out.append({**got, "given": _clean_given(got["given"])})
|
||
return out
|
||
|
||
|
||
# ── 풀이 ──────────────────────────────────────────────────────────────
|
||
class _Prices:
|
||
"""B09 표 단가 — 노무 · 중기(노 · 재 · 경) · 자재 적용 단가(키 → 값)."""
|
||
|
||
def __init__(self, sheets: dict[str, Any]) -> None:
|
||
self.labor = {r["key"]: num(r.get("price")) for r in sheets.get("labor") or []}
|
||
self.machine = {r["key"]: r for r in sheets.get("machine") or []}
|
||
self.material = {r["key"]: num(r.get("applied")) for r in sheets.get("compare") or []}
|
||
|
||
|
||
def _unit(code: str) -> tuple[str, Decimal]:
|
||
"""결과단위 1 단위 — (단위, 배)(「원/10㎡」 = ㎡ · 10)."""
|
||
pill = (combo_store.combo_pills({"담은로직": [{"로직": code}]})["물량"] or [{}])[0]
|
||
return str(pill.get("단위") or ""), Decimal(str(pill.get("배") or 1))
|
||
|
||
|
||
def _env(whole: Any, row: dict, given: dict) -> dict:
|
||
"""입력(빈 입력 = 자동값) + 중간값 · 작업량 Q = ROUND(식, 2)(엑셀 자리)."""
|
||
auto = {k: v for k, v in m01.mau.values(whole, row).items() if v is not None}
|
||
env = mf._inputs(row, {**auto, **m01._dec(given or {})})
|
||
for step in row.get("중간", []):
|
||
try:
|
||
value = mf.evaluate(mf.parse(step["식"]), env, whole, 0)
|
||
except mf.EmptyInput as e:
|
||
env[step["이름"]] = mf.Blank(str(e))
|
||
continue
|
||
if _Q.fullmatch(step["이름"]) and isinstance(value, (Decimal, int, float)):
|
||
value = Decimal(str(round_half(float(value), 2)))
|
||
env[step["이름"]] = value
|
||
return env
|
||
|
||
|
||
def _captured(whole: Any, node: Any, env: dict) -> list[str]:
|
||
"""식을 돌리며 부른 기계 키(EQ) — 기계경비 로직 줄의 기계."""
|
||
seen: list[str] = []
|
||
base = whole.get
|
||
|
||
def get(ref: str) -> dict[str, Any]:
|
||
row = base(ref)
|
||
key = str(row.get("키") or "")
|
||
if key.startswith("EQ") and key not in seen:
|
||
seen.append(key)
|
||
return row
|
||
|
||
whole.get = get
|
||
try:
|
||
mf.evaluate(node, env, whole, 1)
|
||
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError):
|
||
pass # 기계 단가를 못 셈 — 키만 있으면 됨(단가는 B09 중기목록표)
|
||
finally:
|
||
whole.get = base
|
||
return seen
|
||
|
||
|
||
def _money(price: float | None, qty: float | None) -> float | None:
|
||
"""줄 금액 = ROUNDDOWN(단가 × 수량, 1)(엑셀 자리)."""
|
||
return None if price is None or qty is None else rounddown(price * qty, 1)
|
||
|
||
|
||
def block_totals(lines: list[dict[str, Any]]) -> dict[str, float | None]:
|
||
"""총계 — 비목마다 ROUNDDOWN(Σ 줄 금액, 0) · 합계 = 셋 합 · 빈칸 하나면 그 비목 · 합계 빈칸."""
|
||
out: dict[str, float | None] = {}
|
||
for part in PARTS:
|
||
s = total([ln[part] for ln in lines])
|
||
out[part] = None if s is None else rounddown(s, 0)
|
||
return {**out, "sum": total([out[p] for p in PARTS])}
|
||
|
||
|
||
class Solver:
|
||
"""한 번 셈 — 산근 블록을 첫 등장 차례로 모음(하위 산근 포함)."""
|
||
|
||
def __init__(self, sheets: dict[str, Any]) -> None:
|
||
self.prices = _Prices(sheets)
|
||
self.blocks: dict[str, dict[str, Any]] = {}
|
||
|
||
def add(
|
||
self, whole: Any, src: dict[str, Any], owner: str | None, depth: int = 0
|
||
) -> dict[str, Any]:
|
||
key = sangeun_key(src["code"], src["given"], src.get("rate"), src.get("picks"))
|
||
if key not in self.blocks:
|
||
self.blocks[key] = {"key": key, "no": len(self.blocks) + 1, "uses": []} # 자리 먼저
|
||
self.blocks[key].update(self._solve(whole, src, depth))
|
||
if owner and owner not in self.blocks[key]["uses"]:
|
||
self.blocks[key]["uses"].append(owner)
|
||
return self.blocks[key]
|
||
|
||
def _solve(self, whole: Any, src: dict[str, Any], depth: int) -> dict[str, Any]:
|
||
code = src["code"]
|
||
try:
|
||
row = whole.logic(code)
|
||
except mf.FormulaError as e:
|
||
return self._stop(code, code, f"로직 없음 — {e}")
|
||
name = str(row.get("이름") or code)
|
||
if isinstance(row.get("흐름"), dict) or "결과" in row:
|
||
return self._stop(code, name, "돈 로직 아님(흐름 · 결과 로직) — 산근 풀이 꼴 없음")
|
||
unit, times = _unit(code)
|
||
rate = Decimal(str(src["rate"])) / 100 if src.get("rate") not in (None, 100) else None
|
||
lines: list[dict[str, Any]] = []
|
||
try:
|
||
env = _env(whole, row, src["given"])
|
||
with mf.mp.choosing(src.get("picks")):
|
||
for n, item in enumerate(row.get("호표", [])):
|
||
lines.append(self._line(whole, item, n, env, times, rate, depth))
|
||
self._extras(whole, row, env, lines)
|
||
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
|
||
return self._stop(code, name, str(e) or type(e).__name__)
|
||
sums = block_totals(lines)
|
||
why = next((ln["why"] for ln in lines if ln.get("why")), None)
|
||
return {
|
||
"logic": code,
|
||
"name": name,
|
||
"spec": ", ".join(f"{k}={v}" for k, v in src["given"].items()),
|
||
"unit": unit,
|
||
"source": row.get("출처") or "",
|
||
"lines": lines,
|
||
**sums,
|
||
"why": why,
|
||
}
|
||
|
||
@staticmethod
|
||
def _stop(code: str, name: str, why: str) -> dict[str, Any]:
|
||
return {"logic": code, "name": name, "spec": "", "unit": "", "source": "", "lines": [],
|
||
**dict.fromkeys(PARTS), "sum": None, "why": why} # fmt: skip
|
||
|
||
def _blank(self, item: dict, why: str) -> dict[str, Any]:
|
||
part = COST_PART.get(item.get("비목"), "material")
|
||
return {"kind": "blank", "name": item.get("이름", ""), "spec": "", "qty": None,
|
||
"unit": item.get("단위", ""), "ref": None, "price": {},
|
||
**{p: (None if p == part else 0.0) for p in PARTS}, "why": why} # fmt: skip
|
||
|
||
def _line(self, whole, item, n, env, times, rate, depth) -> dict[str, Any]:
|
||
try:
|
||
raw = mf.evaluate(mf.parse(item["수량"]), env, whole, 0)
|
||
except mf.EmptyInput as e:
|
||
return self._blank(item, str(e))
|
||
qty = Decimal(str(raw)) / times * (rate or 1)
|
||
q = float(qty)
|
||
base = {"name": item.get("이름", ""), "spec": "", "qty": q, "unit": item.get("단위", "")}
|
||
zero = dict.fromkeys(PARTS, 0.0)
|
||
kind = item.get("종류")
|
||
if kind == "로직":
|
||
return self._logic_line(whole, item, env, q, base, zero, depth)
|
||
if kind == "인력":
|
||
key = mf.fill(item["요소"], env) if isinstance(item["요소"], str) else ""
|
||
price = self.prices.labor.get(key)
|
||
why = None if price is not None else f"노무비목록표에 없음 「{key}」"
|
||
return {**base, **zero, "kind": "labor", "ref": {"tab": "labor", "key": key},
|
||
"price": {"labor": price}, "labor": _money(price, q), "why": why} # fmt: skip
|
||
ref = (
|
||
{k: mf.fill(v, env) if isinstance(v, str) else v for k, v in item["요소"].items()}
|
||
if isinstance(item["요소"], dict)
|
||
else mf.fill(item["요소"], env)
|
||
)
|
||
_price, source, _why, extra = mf.mp.priced(whole, item, ref, env, n)
|
||
goods = (extra or {}).get("품목") or {}
|
||
key = str(
|
||
goods.get("키") or (source or "").split(".")[0] or (ref if isinstance(ref, str) else "")
|
||
)
|
||
part = COST_PART.get(item.get("비목"), "material")
|
||
price = self.prices.material.get(key)
|
||
if q == 0:
|
||
price = price if price is not None else 0.0 # 안 쓰는 재료 줄 — 금액 0
|
||
why = None if price is not None else f"자재단가대비표에 없음 「{key}」"
|
||
spec = goods.get("규격") or ""
|
||
ref = {"tab": "compare", "key": key}
|
||
return {**base, **zero, "spec": spec, "kind": "material", "ref": ref,
|
||
"price": {part: price}, part: _money(price, q), "why": why} # fmt: skip
|
||
|
||
def _logic_line(self, whole, item, env, q, base, zero, depth) -> dict[str, Any]:
|
||
node = mf.parse(item["요소"])
|
||
called = node[1] if node and node[0] == "logic" else None
|
||
try:
|
||
target = whole.logic(called) if called else None
|
||
except mf.FormulaError:
|
||
target = None
|
||
if target and any(s.get("이름") == "기계" for s in target.get("입력", [])):
|
||
keys = _captured(whole, node, env)
|
||
m = self.prices.machine.get(keys[0]) if keys else None
|
||
price = {p: num((m or {}).get(p)) for p in PARTS}
|
||
why = None if m else f"중기목록표에 없음 「{keys[0] if keys else item.get('이름')}」"
|
||
amounts = {p: _money(price[p], q) if m else None for p in PARTS}
|
||
ref = {"tab": "machine", "key": keys[0] if keys else ""}
|
||
return {**base, "kind": "machine", "ref": ref,
|
||
"price": price, **amounts, "why": why} # fmt: skip
|
||
if not target or depth >= DEPTH:
|
||
return {**self._blank(item, "하위 로직을 못 펼침"), **base}
|
||
given = {name: mf.evaluate(expr, env, whole, 1) for name, expr in node[2]}
|
||
sub = self.add(whole, {"code": called, "given": _clean_given(given)}, None, depth + 1)
|
||
price = {p: sub.get(p) for p in PARTS}
|
||
why = None if sub.get("sum") is not None else f"하위 산근 {sub['no']} 빈칸"
|
||
return {**base, "kind": "sangeun", "ref": {"tab": "sg", "key": sub["key"]},
|
||
"price": price, **{p: _money(price[p], q) for p in PARTS}, "why": why} # fmt: skip
|
||
|
||
def _extras(self, whole, row, env, lines) -> None:
|
||
"""% 줄(덧줄) — 엔진 식 그대로 · 밑수 = 그때까지 B09 비목 합 · 줄 금액 = 이름별."""
|
||
for extra in row.get("덧줄", []):
|
||
sums = {name: total([ln[p] for ln in lines]) for name, p in COST_PART.items()}
|
||
part = COST_PART.get(extra.get("비목"), "material")
|
||
line = {"kind": "extra", "name": extra["이름"], "spec": extra["식"], "qty": 1.0,
|
||
"unit": "식", "ref": None, **dict.fromkeys(PARTS, 0.0)} # fmt: skip
|
||
if any(v is None for v in sums.values()):
|
||
lines.append({**line, part: None, "price": {}, "why": "밑수 빈칸"})
|
||
continue
|
||
env.update({k: Decimal(str(v)) for k, v in sums.items()})
|
||
env["줄"] = {
|
||
ln["name"]: Decimal(str(sum(ln[p] or 0 for p in PARTS)))
|
||
for ln in lines
|
||
if ln["name"]
|
||
}
|
||
try:
|
||
value = float(mf.evaluate(mf.parse(extra["식"]), env, whole, 0))
|
||
except (mf.FormulaError, ArithmeticError, TypeError, ValueError) as e:
|
||
lines.append({**line, part: None, "price": {}, "why": str(e)})
|
||
continue
|
||
lines.append({**line, "price": {part: value}, part: rounddown(value, 1), "why": None})
|
||
|
||
|
||
# ── 모음 ──────────────────────────────────────────────────────────────
|
||
def collect(entries: list[tuple[str, Path]], sheets: dict[str, Any]) -> dict[str, Any]:
|
||
"""`entries` = [(구조물 id, 사본 폴더)] → 블록 · 목록 · 「연결 없음」."""
|
||
solver = Solver(sheets)
|
||
for owner, folder in entries:
|
||
rows = rows_of(folder)
|
||
if not rows:
|
||
continue
|
||
whole = item_prices.load_prices(read_json(folder / PRICE_FILE) or {})
|
||
for src in rows:
|
||
solver.add(whole, src, owner)
|
||
blocks = sorted(solver.blocks.values(), key=lambda b: b["no"])
|
||
listing = [
|
||
{k: b[k] for k in ("no", "key", "logic", "name", "spec", "unit", "sum", *PARTS, "uses")}
|
||
for b in blocks
|
||
]
|
||
gaps = [
|
||
gap("sg", b["key"], f"산근 {b['no']} {b['name']}", b["why"] or "단가 빈칸")
|
||
for b in blocks
|
||
if b["sum"] is None
|
||
]
|
||
return {
|
||
"sg": blocks,
|
||
"sg_list": listing,
|
||
"sg_gaps": gaps,
|
||
"sg_notes": [NO_SOURCE] + ([] if blocks else [EMPTY]),
|
||
}
|
||
|
||
|
||
def build(project_root: str | Path, sheets: dict[str, Any]) -> dict[str, Any]:
|
||
"""연결된 사본 전부(구조물 id 차례)의 산근."""
|
||
base = structures_dir(project_root)
|
||
entries = [
|
||
(owner, base / linked["폴더"])
|
||
for owner, linked in sorted(linked_snapshots(project_root).items())
|
||
if "일위대가" in linked.get("부분", [])
|
||
]
|
||
return collect(entries, sheets)
|