"""B09 산근 한 항목 → 엑셀 단가산출근거 꼴 텍스트 수식 줄들(PLAN 155-5) — 서버 단독(③ · 순수 함수). 꼴 = 엑셀 단가산출근거 탭 산출내역 한 칸 글: 머리 「이름 / 단위」 · 출처 「… 적용」 · 입력 「이름 : 값 단위」 · 중간 「Q = 이름식 = 값」(작업량 Q = ROUND 2 — `Sangeun._env`) · 줄 「보통인부 : 172,068 * 0.022 = 3,785.4」(단가 * 수량식 값 꼴[ / 배][ * 율 %] = 금액) · 중기 = 노무비 · 재료비 · 경비 세 줄 · 하위 산근 = 「산근 n호표 단가 * 수량」 · % 덧줄 = 「이름 : 노무비의 n% = 밑수 * n % = 금액」 · 끝 = 「계 : 노무비 + 재료비 + 경비 = 합」. 식 풀이 = 엔진 `master_text` 낮은 부품(render · _names · fmt) · 단가 = 인자 `price_of(key)`(B09 굳힌 단가 · 엔진 단가 글은 안 씀) · 단가 없음 = 「단가 없음」 글. 끝수 = 엑셀 자리 — 줄 ROUNDDOWN 1 · 계 비목마다 ROUNDDOWN 0(`Sangeun._money` · `block_totals`). `price_of(key)` — 인력 · 자재 키 → 수 | None · 기계(EQ) · 하위 산근(SG:) → {labor, material, expense [, sum, no]} | None. """ from __future__ import annotations from collections.abc import Callable from decimal import Decimal from typing import Any from B09_Estimation import B09_Estimation_Sangeun as sg from M01_MasterData import M01_MasterData_Store as m01 mf = sg.mf mtx = m01.mtx PART_NAME = {"labor": "노무비", "material": "재료비", "expense": "경비"} _ERRORS = (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) NO_PRICE = "단가 없음" PriceOf = Callable[[str], Any] def _money(value: float | None) -> str: return "빈칸" if value is None else f"{value:,.1f}" def _price(value: Any) -> str: return NO_PRICE if value is None else mtx.fmt_price(Decimal(str(value))) def _qty_text(item: dict, env: dict, whole: Any, times: Decimal, rate: Decimal | None) -> str: """수량식 값 꼴 — 덧셈 묶음이면 괄호 · 결과단위 배 「/ 배」 · 켜쌓기율 「* 율 %」.""" text = mtx._times(mtx.render(mf.parse(item["수량"]), env, whole), item, env, whole) if times != 1: text += f" / {mtx.fmt(times)}" if rate is not None: text += f" * {mtx.fmt(rate * 100)} %" return text def _line(name: str, price: Any, qty_text: str, q: float) -> tuple[str, float | None]: money = None if price is None else sg._money(float(price), q) return f"{name} : {_price(price)} * {qty_text} = {_money(money)}", money def _head(row: dict, env: dict, whole: Any, times: Decimal) -> list[str]: unit = str(row.get("결과단위") or "").removeprefix("원/") if times != 1: unit = unit.removeprefix(mtx.fmt(times)) out = [f"{row.get('이름') or row.get('키')} / {unit}"] if row.get("출처"): out.append(f"{row['출처']} 적용") for spec in row.get("입력", []): value = env.get(spec["이름"]) if value is None: continue shown = mtx.fmt(value) if isinstance(value, Decimal) else str(value) tail = f" {spec['단위']}" if spec.get("단위") else "" out.append(f"{spec['이름']} : {shown}{tail}") name_of = mf_names(row, whole) for step in row.get("중간", []): value = env.get(step["이름"]) if value is None or isinstance(value, mf.Blank): out.append(f"{step['이름']} = 값 없음") continue shown = mtx.fmt(value) try: named = mtx._names(step["식"], env, whole, name_of) except _ERRORS: named = shown out.append( f"{step['이름']} = {shown}" if named == shown else f"{step['이름']} = {named} = {shown}" ) return out def mf_names(row: dict, whole: Any) -> dict: """별칭 이름표(표 찾기 · 로직 부르기 → 짧은 이름) — 못 지으면 빈 표(값 꼴로 물러남).""" try: return mtx.mc.names(mtx.mc.aliases(row, whole)) except _ERRORS: return {} def _material_key(whole: Any, item: dict, ref: Any, env: dict, n: int) -> tuple[str, str]: """자재 줄 (대비표 키, 규격) — 자리표시 글(「MO:경유:전국평균」)은 그 요소의 실제 키로.""" _p, source, _w, 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 ":" in key: try: key = str(whole.get(key).get("키") or key) except _ERRORS: pass return key, str(goods.get("규격") or "") def _logic_lines(whole, item, env, qty_text, q, price_of) -> list[tuple[str, dict]]: """로직 부르기 줄 — 기계경비 = 중기 노 · 재 · 경 세 줄 · 그 밖 = 하위 산근 한 줄.""" 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 name = item.get("이름", "") if target and any(s.get("이름") == "기계" for s in target.get("입력", [])): keys = sg._captured(whole, node, env) got = price_of(keys[0]) if keys else None if not got: return [(f"{name} : {NO_PRICE} * {qty_text}", dict.fromkeys(sg.PARTS))] out = [] for part in sg.PARTS: text, money = _line(f"{name} {PART_NAME[part]}", got.get(part), qty_text, q) out.append((text, {part: money})) return out if not target: return [(f"{name} : 하위 로직을 못 펼침", dict.fromkeys(sg.PARTS))] given = {k: mf.evaluate(expr, env, whole, 1) for k, expr in node[2]} got = price_of(sg.sangeun_key(called, sg._clean_given(given))) or {} parts = {p: got.get(p) for p in sg.PARTS} if any(v is None for v in parts.values()): return [(f"{name} : {NO_PRICE} * {qty_text}", dict.fromkeys(sg.PARTS))] amounts = {p: sg._money(float(v), q) for p, v in parts.items()} head = f"산근 {got['no']}호표 " if got.get("no") else "" unit = sum(float(v) for v in parts.values()) text = f"{name} : {head}{_price(unit)} * {qty_text} = {_money(sum(amounts.values()))}" return [(text, amounts)] def _pct(extra: dict) -> str: """% 덧줄 이름 — 식 「X * Y / 100」 의 X 가 이름이면 「X의 Y%」 · 그 밖 빈 글.""" try: node = mf.parse(extra.get("식") or "") except mf.FormulaError: return "" if not (node[0] == "bin" and node[1] == "/" and node[3] == ("num", Decimal(100))): return "" inner = node[2] if inner[0] != "bin" or inner[1] != "*" or inner[3][0] != "num": return "" base = inner[2] if base[0] == "name": word = base[1] elif base[0] == "field" and base[1] == ("name", "줄"): word = base[2] else: return "" return f"{word}의 {mtx.fmt(inner[3][1])}%" def _extra_lines(whole, row, env, amounts: list[dict]) -> list[tuple[str, dict]]: """% 줄(덧줄) — 밑수 = 그때까지 B09 비목 합(`Sangeun._extras` 와 같음) · 금액 ROUNDDOWN 1.""" out: list[tuple[str, dict]] = [] for extra in row.get("덧줄", []): part = sg.COST_PART.get(extra.get("비목"), "material") sums = { name: sg.total([a.get(p, 0.0) for a in amounts]) for name, p in sg.COST_PART.items() } label = _pct(extra) head = f"{extra['이름']} : {label + ' = ' if label else ''}" if any(v is None for v in sums.values()): out.append((f"{head}밑수 빈칸", {part: None})) amounts.append({part: None}) continue env.update({k: Decimal(str(v)) for k, v in sums.items()}) try: node = mf.parse(extra["식"]) value = float(mf.evaluate(node, env, whole, 0)) shown = mtx.render(node, env, whole) except _ERRORS as e: out.append((f"{head}계산 못 함 — {e}", {part: None})) amounts.append({part: None}) continue money = sg.rounddown(value, 1) out.append((f"{head}{shown} = {_money(money)}", {part: money})) amounts.append({part: money}) return out def render_sangeun_text( whole: Any, logic_id: str, inputs: dict | None, price_of: PriceOf, *, times: Any = 1, rate: Any = None, picks: dict | None = None, ) -> list[str]: """산근 한 항목(로직 + 입력값 + 단가) → 엑셀 꼴 글 줄들. whole = 사본 단가 묶음(`item_prices.load_prices`) · inputs = 입력값(빈 칸 = 자동값) · price_of = B09 단가 조회 · times = 결과단위 배(「원/10㎡」 = 10) · rate = 켜쌓기율 %(100 · None = 없음) · picks = 고른 자재.""" try: row = whole.logic(logic_id) except mf.FormulaError as e: return [f"{logic_id} : 로직 없음 — {e}"] times = Decimal(str(times or 1)) scale = Decimal(str(rate)) / 100 if rate not in (None, 100) else None try: env = sg._env(whole, row, sg._clean_given(inputs)) except _ERRORS as e: return [f"{row.get('이름') or logic_id} : 입력을 못 읽음 — {e}"] out = _head(row, env, whole, times) amounts: list[dict] = [] with mf.mp.choosing(picks): for n, item in enumerate(row.get("호표", [])): name = item.get("이름", "") try: raw = mf.evaluate(mf.parse(item["수량"]), env, whole, 0) qty_text = _qty_text(item, env, whole, times, scale) except mf.EmptyInput as e: out.append(f"{name} : 수량 없음 — {e}") amounts.append({sg.COST_PART.get(item.get("비목"), "material"): None}) continue q = float(Decimal(str(raw)) / times * (scale or 1)) kind = item.get("종류") if kind == "로직": for text, money in _logic_lines(whole, item, env, qty_text, q, price_of): out.append(text) amounts.append(money) continue if kind == "인력": key = mf.fill(item["요소"], env) if isinstance(item["요소"], str) else "" text, money = _line(name, price_of(key), qty_text, q) out.append(text) amounts.append({"labor": money}) continue 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) ) key, spec = _material_key(whole, item, ref, env, n) price = price_of(key) if q == 0 and price is None: price = 0.0 # 안 쓰는 재료 줄 — 금액 0(`Sangeun._line` 과 같음) label = f"{name}({spec})" if spec else name text, money = _line(label, price, qty_text, q) out.append(text) amounts.append({sg.COST_PART.get(item.get("비목"), "material"): money}) for text, _money_part in _extra_lines(whole, row, env, amounts): out.append(text) sums = sg.block_totals([{**dict.fromkeys(sg.PARTS, 0.0), **a} for a in amounts]) parts = " + ".join( f"{PART_NAME[p]} {'빈칸' if sums[p] is None else f'{sums[p]:,.0f}'}" for p in sg.PARTS ) out.append(f"계 : {parts} = {'빈칸' if sums['sum'] is None else f'{sums["sum"]:,.0f}'}") return out