Files
Aislo/B09_Estimation/B09_Estimation_Sangeun_Text.py
T
eomsangdonandClaude Opus 5.5 b55a3ee026 feat(b09): 산근 글 = 흐름도 함수 빠짐없이 · 약어 뜻 · 기계 번호 = 이름 규격(157-1 · 157-3)
- 중간 · 결과 · 호표 수량 = 「이름 = 이름식 = 값 넣은 식 = 값」 · 반올림 따위 부르기는 인자째
- 찾기 = 「표 이름(조건 = 값).칸」 · 만약 = 걸린 갈래만 · 돈 하위 로직 = 이름(산근 n호표)
- 돈 아닌 하위 로직(시간당 작업량 따위) = 「└ 」 줄로 입력 · 중간 · 결과 풀이
- 입력 = 「이름 : 설명 = 값 단위」 · 기계 · 직종 원문번호 = 「이름 규격」(M02_Labor_Inputs._labels)
- 함수 줄은 머리에만 — 호표 줄 · 계 · 금액 그대로 · 시험 test_157_1 새로 · 155_5 Q 줄 글 갱신

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
2026-10-09 21:23:45 +09:00

411 lines
19 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"(?<![\w]){re.escape(mark['이름'])}(?![\w])"
shown = re.search(pattern, named) and _call_text(whole, mark, env, price_of)
if shown:
named = re.sub(pattern, lambda _m, s=shown: s, named)
return named
def _valued(show, real, env: dict, whole: Any, depth: int = 0) -> 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