"""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`. 텍스트 수식(155-5) = `B09_Estimation_Sangeun_Text`(같은 B09 단가) — `head`(머리 · 입력 · 중간) · 줄 `text`(줄마다 · 중기 = 노 · 재 · 경 세 줄) · `foot`(계) · % 덧줄 = 규격 「노무비의 %」 · 수량 = 율 · 단위 %(149-4 `pct_extras`). """ 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 import M02_Labor_Doc as labor_doc from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs 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 spec_text(whole: Any, given: dict[str, Any]) -> tuple[str, str]: """규격 글(157-3) → (보일 글, 원문번호 글) — 기계 · 직종 번호 = 「이름 규격」(M02 85-1).""" def shown(name: str, value: Any) -> Any: if not isinstance(value, str): return value return labor_inputs._labels(whole, {"이름": name, "고르기": [value]}).get(value, value) raw = ", ".join(f"{k}={v}" for k, v in given.items()) return ", ".join(f"{k}={shown(k, v)}" for k, v in given.items()), raw 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) head, foot = self._text(whole, src, times, lines) spec, spec_raw = spec_text(whole, src["given"]) return { "logic": code, "name": name, "spec": spec, "spec_raw": spec_raw, "unit": unit, "source": row.get("출처") or "", "lines": lines, **sums, "why": why, "head": head, "foot": foot, } @staticmethod def _stop(code: str, name: str, why: str) -> dict[str, Any]: return {"logic": code, "name": name, "spec": "", "spec_raw": "", "unit": "", "source": "", "lines": [], **dict.fromkeys(PARTS), "sum": None, "why": why, "head": [], "foot": ""} # fmt: skip def price_of(self, key: str) -> Any: """글 짓기 단가(`Sangeun_Text` 꼴) — 노무 · 자재 = 수 · 중기 · 산근 = 비목 단가 묶음.""" if key in self.prices.labor: return self.prices.labor[key] if key in self.prices.material: return self.prices.material[key] if key in self.prices.machine: return {p: num(self.prices.machine[key].get(p)) for p in PARTS} block = self.blocks.get(key) return {k: block.get(k) for k in (*PARTS, "sum", "no")} if block else None def _text(self, whole, src, times, lines) -> tuple[list[str], str]: """텍스트 수식(155-5) → (머리 줄들, 계) · 줄마다 `text` 를 얹음(중기 = 세 줄 · 그 밖 한 줄). 줄 수가 안 맞으면 모두 머리에(값 칸과 어긋나게 얹지 않음).""" from B09_Estimation.B09_Estimation_Sangeun_Text import render_sangeun_text try: text = render_sangeun_text(whole, src["code"], src["given"], self.price_of, times=times, rate=src.get("rate"), picks=src.get("picks")) # fmt: skip except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError): return [], "" counts = [3 if ln["kind"] == "machine" and not ln.get("why") else 1 for ln in lines] start = len(text) - 1 - sum(counts) if start < 1: return text, "" at = start for ln, n in zip(lines, counts): ln["text"] = text[at : at + n] at += n return text[:start], text[-1] 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 비목 합 · 줄 금액 = 이름별 · 「X * Y / 100」 덧줄 = 규격 「노무비의 %」 · 수량 = 율 · 단위 %(149-4 `pct_extras`).""" heads = [{} for _ in row.get("호표", [])] pcts = labor_doc.pct_extras( whole, row, heads + [{} for _ in row.get("덧줄", [])], dict(env) ) for extra, pct in zip(row.get("덧줄", []), pcts[len(heads) :]): 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 "율" in pct: line.update(spec=pct.get("규격") or extra["식"], qty=float(pct["율"]), unit="%") 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}) # ── 모음 ────────────────────────────────────────────────────────────── LISTED = ("no", "key", "logic", "name", "spec", "spec_raw", "unit", "sum", *PARTS, "uses") 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 LISTED} 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)