Files
Aislo/B09_Estimation/B09_Estimation_Sangeun_Text.py
eomsangdonandClaude Opus 5.5 d8834c1d63 feat(792-4): B09 일위대가 대가 블록이 엔진 새 의미를 따름 — 다섯 길 같은 금액 · 가져온 식 SK 내역 = 엑셀
- 새 B09_Estimation_Labor_Rule: 새 의미 로직만 대가 블록 줄 `place`(줄 자리) · `out`(합밖 · 제외 단계) · 덧줄 = 수 하나 × 밑 합 꼴이면 `rows:` % 줄(줄.'이름' · 앞 덧줄 · 번지 · 단계 · 비목 합 · 793-2a 괄호 꼴) · 이름 없는 수 식 덧줄 = 고정 줄 · 블록 `rule` {끝전, 조정, 끝수} · 다른 꼴 = 빈 줄 그대로(틀린 값 대신 빈칸)
- Labor_Source: 품셈 블록에 표지 · 단가산출 블록이 줄을 바꿔도 표지 이어 받음(657 줄)
- Calc_Labor .py · .ts 짝: 줄 금액 `cut`(place) · `out` 줄은 비목 합 · part: 밑에서 뺌 · 블록 비목 합 `settle`(큰나머지 → 끝수 → 조정 → 원 버림 · master_cut.settle 차례) · 옛 블록 = 표지 없음 = 전과 같음
- 산근 글 로직 부르기 줄(중기 세 줄 · 하위 산근) 금액도 줄 자리 규칙(792-3 남은 것)
- test_792_3: 다섯째 길 B09 대가(SC → 굳힌 SK 수량 1 → 내역 단가 · 파이썬 = TS 거울) · 실무 봉화 D00102 · 북부청임도 18 을 793-2 길(임시 층)로 가져온 식 SK → 내역 단가 = 엑셀
- test_712_3 낡은 기대: 표 비율 0.78 → 단위 수 1,601 이상 · 0.76 — 782-1 · 782-2 · 782-3 계수 표 94 가 로직에 붙음(그중 단위 기준 25 · 시공능력 · 적용기준 계수 = 단위 기준 없음) · 712-3 때 2,023 중 1,601 그대로 · 마스터 자료 안 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:26:03 +09:00

420 lines
19 KiB
Python
Raw Permalink 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 B09_Estimation import B09_Estimation_Sangeun_Sum as ss
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, rule=None) -> tuple[str, float | None]:
money = None if price is None else ss._cut(float(price) * q, rule) # 줄 자리(792-3)
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]]:
"""로직 부르기 줄 — 기계경비 = 중기 노 · 재 · 경 세 줄 · 그 밖 = 하위 산근 한 줄 ·
금액 끝수 = 줄 `자리`(792-4 · `Sangeun_Sum.places` 와 같음)."""
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 mf.mb.is_machine(target): # 기계경비 판별 한 곳(712-1)
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, item.get("자리")
)
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: ss._cut(float(v) * q, item.get("자리")) 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 _full(amounts: list[dict]) -> list[dict]:
return [{**dict.fromkeys(sg.PARTS, 0.0), **a} for a in amounts]
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 = ss.sums(row, _full(amounts)) # 합밖 · 제외(792-3)
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()})
ss.scope(row, _full(amounts), env) # 번지 · 단계(792-3)
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 = ss.extra(row, extra, value, env) # 줄 자리(792-3)
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 == "로직":
merged: dict = {} # 줄 하나 = 금액 하나(중기 세 줄 · 792-3 합밖 · 번지 차례)
for text, money in _logic_lines(whole, item, env, qty_text, q, price_of):
out.append(text)
merged.update(money)
amounts.append(merged)
continue
if kind == "인력":
key = mf.fill(item["요소"], env) if isinstance(item["요소"], str) else ""
text, money = _line(name, price_of(key), qty_text, q, item.get("자리"))
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, item.get("자리"))
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 = ss.totals(row, _full(amounts)) or sg.block_totals(_full(amounts)) # 끝전 · 조정(792-3)
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