Files
Aislo/B09_Estimation/B09_Estimation_Sangeun.py
T
eomsangdonandClaude Opus 5.5 82a3cb5e7d feat(b09): 호표 갈래 뒤집기 — 일위대가화 줄 = 대가 · 단가산출 줄 = 산근(155-3 · 155-4)
- 일위대가목록 = 구조물 조합 + 사전조합 + 일위대가화(승격 · 옛 SP) 줄 · 단가산출 줄 = 산근 줄
- 산근 줄 수량 = 수량 그대로(배 ≠ 1 로직 금액 1/배 흠) · 대가 줄만 ÷ 배
- 품셈 블록 로직 줄 판정 = 산근 풀이와 같은 call_of(기계경비 = 중기 · 그 밖 = 하위 산근)
- 비고 「산근 n호표」(Calc_Labor 파이썬 · TS 짝) · 산근 줄 누름 = 단가산출근거 탭
- 산근목록 원천 = 단가산출 줄 + 사전조합 안 줄 + 일위대가화 줄 하위 로직(노무 · 자재 · 중기 줄 제외)
- 산근 자재 자리표시 키(MO:경유:…) = 자재단가대비표 키로
- 시험: 145-1 다섯 · 145-2 셋 새 갈래로(머리에 근거 한 줄) · 새 test_155_b09_sources

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
2026-10-09 19:20:39 +09:00

443 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""B09 단가산출근거 · 단가산출근거목록표(PLAN 145-1) — 서버 단독(③ · 화면 짝 없음 · 저장 안 함).
산근 호표(155-4 · 엑셀 꼴 · 145 정함 뒤집음) = 연결된 사본(`linked_snapshots` · 143-1)의
단가산출 줄(일위대가화 아닌 돈 로직 줄 · 입력값 = 사본 `입력값.json` · 빈 입력 = 자동값) +
사전조합(SC 참조 · 굳힌 `참조사본`) 안 단가산출 줄(입력값 = 그 문서 값) +
일위대가화 줄(승격 · 옛 SP)의 하위 로직 줄(기계경비 밖 · 입력 = 부르는 식 값)
— 노무 · 자재 · 중기 줄은 각 목록표(산근 아님) · 일위대가화 줄 자체 = 대가(145-2 블록)
같은 로직 + 같은 입력값(+ 켜쌓기율 · 고른 자재) = 산근 하나(`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, whole: Any = None) -> list[dict[str, Any]]:
"""사본 하나의 산근 원천 줄(차례대로) — 단가산출 줄 · 사전조합 안 단가산출 줄 ·
일위대가화 줄(승격 · 옛 SP)의 하위 로직 줄. 키 = 대가 블록 산근 줄 키(`Labor_Source`)와 같음."""
from M02_MasterTemplete import M02_Labor_Ref as refs
labor = read_json(folder / LABOR_FILE)
if not isinstance(labor, dict):
return []
if whole is None:
whole = item_prices.load_prices(read_json(folder / PRICE_FILE) or {})
values = read_json(folder / VALUES_FILE) or {}
picks = read_json(folder / PICKS_FILE) or {}
copies = labor.get("참조사본") or {}
out: list[dict[str, Any]] = []
def logic_line(code: str, given: Any, rate: Any, chosen: Any, promoted: bool) -> None:
if not is_money(whole, code):
return
if not promoted:
out.append({"code": code, "given": _clean_given(given), "rate": rate, "picks": chosen})
return
for call in line_calls(whole, code, given, chosen).values():
if call and call.get("code"):
out.append({"code": call["code"], "given": call["given"], "rate": None,
"picks": None}) # fmt: skip
def walk(lines: list, inner: bool, depth: int) -> None:
for one in lines:
if not isinstance(one, dict):
continue
ref = one.get("참조")
if isinstance(ref, dict):
doc = copies.get(refs.ref_id(ref))
if not isinstance(doc, dict) or depth >= refs.DEPTH:
continue
if refs.kind_of(ref.get("키")) == "promote": # 옛 SP = 일위대가화
first = (doc.get("담은로직") or [None])[0]
if isinstance(first, dict) and first.get("로직"):
logic_line(str(first["로직"]), first.get("입력값"),
first.get("켜쌓기율"), first.get("고름"), True) # fmt: skip
continue
walk(doc.get("담은로직") or [], True, depth + 1) # 사전조합 = 굳힌 값
continue
if not one.get("로직"):
continue
code = str(one["로직"])
given = one.get("입력값") if inner else _values(values, code)
chosen = one.get("고름") if inner else (picks.get(code) or one.get("고름"))
logic_line(code, given, one.get("켜쌓기율"), chosen, one.get("승격") is True)
walk((labor.get("줄") or {}).get("담은로직") or [], False, 0)
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 unit_of(code: str) -> str:
"""산근 1 단위 이름(「원/10㎡」 = ㎡)."""
return _unit(code)[0]
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 is_money(whole: Any, code: str) -> bool:
"""돈 로직(호표 줄 꼴)인지 — 없는 로직 · 흐름 · 결과 로직 = 아님."""
try:
row = whole.logic(code)
except mf.FormulaError:
return False
return not (isinstance(row.get("흐름"), dict) or "결과" in row)
def call_of(whole: Any, item: dict, env: dict) -> dict[str, Any] | None:
"""로직 호표 줄(종류 로직) → 부른 것 — 기계경비 로직 `{machine: EQ 키 | None}` ·
다른 돈 로직 `{code, given}`(하위 산근) · 못 펼치면 None.
대가 품셈 줄(`B09_Estimation_Labor_Source`)과 산근 풀이가 같은 판정을 씀."""
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 not target:
return None
if any(s.get("이름") == "기계" for s in target.get("입력", [])):
keys = _captured(whole, node, env)
return {"machine": keys[0] if keys else None}
given = {name: mf.evaluate(expr, env, whole, 1) for name, expr in node[2]}
return {"code": called, "given": _clean_given(given)}
def line_calls(whole: Any, code: str, given: Any, picks: Any = None) -> dict[int, dict | None]:
"""로직 하나의 로직 호표 줄마다 `call_of`(호표 차례 n → 부른 것) — 못 셈 줄 = None."""
try:
row = whole.logic(code)
env = _env(whole, row, _clean_given(given))
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError):
return {}
out: dict[int, dict | None] = {}
with mf.mp.choosing(picks):
for n, item in enumerate(row.get("호표", [])):
if item.get("종류") != "로직":
continue
try:
out[n] = call_of(whole, item, env)
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError):
out[n] = None
return out
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 "")
)
if key not in self.prices.material: # 자리표시 글(「MO:경유:전국평균」) = 대비표 키로
try:
key = str((whole.get(key) or {}).get("키") or key)
except (mf.FormulaError, KeyError, TypeError, ValueError):
pass
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]:
call = call_of(whole, item, env)
if call and "machine" in call:
keys = [call["machine"]] if call["machine"] else []
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 call or depth >= DEPTH:
return {**self._blank(item, "하위 로직을 못 펼침"), **base}
sub = self.add(whole, {"code": call["code"], "given": call["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:
whole = item_prices.load_prices(read_json(folder / PRICE_FILE) or {})
for src in rows_of(folder, whole):
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)