"""B09 산근 한 항목 → 엑셀 단가산출근거 꼴 텍스트 수식 줄들(PLAN 155-5) — 서버 단독(③ · 순수 함수). 꼴 = 엑셀 단가산출근거 탭 산출내역 한 칸 글: 머리 「이름 / 단위」 · 출처 「… 적용」 · 입력 「이름 : 설명 = 값 단위」(157-1 · 설명 없으면 「이름 : 값 단위」 · 기계 · 직종 번호 = 「이름 규격」 157-3 `M02_Labor_Inputs._labels`) · 함수(M01 흐름도 함수 상자 · 157-1) = 계산 차례대로 「이름 = 이름식 = 값 넣은 식 = 값」 — 중간 · 결과(돈 아닌 로직) · 호표 수량(이름식이 값 꼴과 다를 때 「이름 수량 = …」) · 찾기 = 「표 이름(조건 = 값).칸」 · 만약 = 걸린 갈래만 · 하위 로직 = 로직 이름(산근 n호표) · 돈 아닌 하위 로직은 그 풀이를 「└ 」 줄로 이어 씀(작업량 Q = ROUND 2 — `Sangeun._env`) · 함수 줄은 모두 머리(호표 줄 수 그대로) · 줄 「보통인부 : 172,068 * 0.022 = 3,785.4」(단가 * 수량식 값 꼴[ / 배][ * 율 %] = 금액) · 중기 = 노무비 · 재료비 · 경비 세 줄 · 하위 산근 = 「산근 n호표 단가 * 수량」 · % 덧줄 = 「이름 (노무비의 %) : 밑수 * n % = 금액」(규격 = 149-4 `pct_extras`) · 끝 = 「계 : 노무비 + 재료비 + 경비 = 합」. 식 풀이 = 엔진 `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 import re 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 from M02_MasterTemplete import M02_Labor_Doc as labor_doc from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs 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, price_of: PriceOf) -> 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['출처']} 적용") return out + _steps(whole, row, env, price_of, 0) def _word(whole: Any, name: str, value: Any) -> str: """값 글 — 수 = 보이는 자릿수 · 기계 · 직종 원문번호 = 「이름 규격」(157-3 · 85-1 도우미).""" if isinstance(value, Decimal): return mtx.fmt(value) if isinstance(value, str): return labor_inputs._labels(whole, {"이름": name, "고르기": [value]}).get(value, value) return str(value) def _shown(whole: Any, name: str, value: Any) -> str | None: if value is None or isinstance(value, mf.Blank): return "값 없음" return None if isinstance(value, dict) else _word(whole, name, value) def _marks(row: dict, whole: Any) -> list[dict]: """별칭(찾기 · 로직 부르기 → 짧은 이름 · `master_copy.aliases`) — 못 지으면 빈 목록.""" try: return mtx.mc.aliases(row, whole) except _ERRORS: return [] def _call_text(whole: Any, mark: dict, env: dict, price_of: PriceOf) -> str | None: """별칭 자리 글 — 찾기 = 「표 이름(조건 = 값)」 · 로직 = 「로직 이름(산근 n호표)」.""" try: node = mf.parse(mark["원문"]) ref = mf.fill(node[1], env) args = [(k, mf.evaluate(v, env, whole, 0)) for k, v in node[2]] except _ERRORS: return None if mark["종류"] == "찾기": conds = ", ".join(f"{k} = {_word(whole, k, v)}" for k, v in args) return f"{mark.get('밑이름') or mark['이름']}{f'({conds})' if conds else ''}" try: name = str(whole.logic(ref).get("이름") or ref) except mf.FormulaError: return None got = ( price_of(sg.sangeun_key(ref, sg._clean_given(dict(args)))) if sg.is_money(whole, ref) else None ) return f"{name}(산근 {got['no']}호표)" if isinstance(got, dict) and got.get("no") else name def _named(text: str, env: dict, whole: Any, marks: list[dict], price_of: PriceOf) -> str: """이름 꼴 식 — 별칭은 찾기 표 이름(조건) · 로직 이름으로 바꿔 씀.""" named = mtx._names(text, env, whole, mtx.mc.names(marks)) for mark in sorted(marks, key=lambda m: -len(m["이름"])): pattern = rf"(? str: """값 꼴 식 — 엔진 `render` 와 같되 반올림 · 최소 · 최대 부르기는 값 넣은 인자째 · 만약 = 걸린 갈래.""" if show[0] == "call" and show[1] != "만약": args = ", ".join(_valued(s, r, env, whole, depth) for s, r in zip(show[2], real[2])) return f"{show[1]}({args})" if show[0] == "call": at = 1 if mf.evaluate(real[2][0], env, whole, depth) else 2 return _valued(show[2][at], real[2][at], env, whole, depth) if show[0] == "bin": return mtx._bin(show, real, env, whole, depth, _valued) if show[0] == "neg": return "-" + _valued(show[1], real[1], env, whole, depth) return mtx._draw(show, real, env, whole, depth) def _formula(name, text, value, env, whole, marks, price_of, unit="") -> str: """함수 한 줄 「이름 = 이름식 = 값 넣은 식 = 값」 — 같은 글이 이어지면 한 번만 · 글 값(기계 원문번호 따위)은 따옴표 글을 빼고 「이름 규격」 만(157-3).""" parts = [name] literal = f"'{value}'" if isinstance(value, str) else None for make in ( lambda: _named(text, env, whole, marks, price_of), lambda: _valued(*[mf.parse(text)] * 2, env, whole), lambda: _shown(whole, name, value), ): try: got = make() except _ERRORS: got = None if got and got != parts[-1] and got != literal: parts.append(got) tail = f" {unit}" if unit and unit != "-" and isinstance(value, Decimal) else "" return " = ".join(parts) + tail def _expand(whole, mark: dict, env: dict, price_of: PriceOf, depth: int) -> list[str]: """돈 아닌 하위 로직 풀이 — 그 로직의 입력 · 중간 · 결과를 「└ 」 줄로(돈 로직 = 산근 호표).""" if mark["종류"] != "로직" or depth > 3: return [] try: node = mf.parse(mark["원문"]) ref = mf.fill(node[1], env) row = whole.logic(ref) if "결과" not in row or isinstance(row.get("흐름"), dict): return [] sub = mf._inputs(row, {k: mf.evaluate(v, env, whole, 0) for k, v in node[2]}) for step in row.get("중간", []): try: sub[step["이름"]] = mf.evaluate(mf.parse(step["식"]), sub, whole, depth + 1) except mf.EmptyInput as e: sub[step["이름"]] = mf.Blank(str(e)) except _ERRORS: return [] lead = " " * depth + "└ " return [lead + line for line in _steps(whole, row, sub, price_of, depth + 1)] def _steps(whole, row: dict, env: dict, price_of: PriceOf, depth: int) -> list[str]: """흐름도 함수 상자 차례 — 입력(약어 뜻) · 중간 · 결과 · 호표 수량(맨 위만) · 하위 로직 풀이.""" out: list[str] = [] for spec in row.get("입력", []): value = env.get(spec["이름"]) if value is None: continue tail = f" {spec['단위']}" if spec.get("단위") else "" about = f"{spec['설명']} = " if spec.get("설명") else "" out.append(f"{spec['이름']} : {about}{_word(whole, spec['이름'], value)}{tail}") marks = _marks(row, whole) done: set[str] = set() def subs(where: str) -> list[str]: lines = [] for mark in marks: if mark["이름"] not in done and where in mark.get("자리", []): done.add(mark["이름"]) lines += _expand(whole, mark, env, price_of, depth) return lines for n, step in enumerate(row.get("중간", [])): value = env.get(step["이름"]) if value is None or isinstance(value, mf.Blank): out.append(f"{step['이름']} = 값 없음") continue unit = step.get("단위") or _find_unit(step["식"], marks) out.append(_formula(step["이름"], step["식"], value, env, whole, marks, price_of, unit)) out += subs(f"중간[{n}].식") if "결과" in row: try: value = mf.cut(row, mf.evaluate(mf.parse(row["결과"]["식"]), env, whole, depth), "결과") except _ERRORS: value = None out.append(_formula("결과", row["결과"]["식"], value, env, whole, marks, price_of)) out += subs("결과.식") if depth: return out for n, item in enumerate(row.get("호표", [])): try: raw = mf.evaluate(mf.parse(item["수량"]), env, whole, 0) named = _named(item["수량"], env, whole, marks, price_of) valued = _valued(*[mf.parse(item["수량"])] * 2, env, whole) except _ERRORS: continue if named != valued: name = f"{item.get('이름', '')} 수량" out.append( _formula(name, item["수량"], raw, env, whole, marks, price_of, item.get("단위")) ) out += subs(f"호표[{n}].수량") return out def _find_unit(text: str, marks: list[dict]) -> str: """식이 표 찾기 한 칸뿐이면 그 칸 단위(별칭 `값칸`) · 그 밖 = 빈 글.""" for mark in marks: head = mark["원문"] if mark["종류"] == "찾기" and text.replace(" ", "").startswith(head.replace(" ", "") + "."): unit = (mark.get("값칸") or {}).get(text.rpartition(".")[2], "") return "" if unit in ("글", "구분 따름") else unit 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_specs(whole, row, env) -> list[str]: """% 덧줄마다 규격(「노무비의 %」 · 149-4 `M02_Labor_Doc.pct_extras`) · % 아닌 덧줄 = 빈 글.""" head = [{} for _ in row.get("호표", [])] tail = [{} for _ in row.get("덧줄", [])] got = labor_doc.pct_extras(whole, row, head + tail, dict(env))[len(head) :] return [str(one.get("규격") or "") for one in got] def _extra_lines(whole, row, env, amounts: list[dict]) -> list[tuple[str, dict]]: """% 줄(덧줄) — 밑수 = 그때까지 B09 비목 합(`Sangeun._extras` 와 같음) · 금액 ROUNDDOWN 1.""" out: list[tuple[str, dict]] = [] specs = _pct_specs(whole, row, env) for extra, spec in zip(row.get("덧줄", []), specs): 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() } head = f"{extra['이름']}{f' ({spec})' if spec 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, price_of) 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