Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
254 lines
11 KiB
Python
254 lines
11 KiB
Python
"""B09 산근 한 항목 → 엑셀 단가산출근거 꼴 텍스트 수식 줄들(PLAN 155-5) — 서버 단독(③ · 순수 함수).
|
|
|
|
꼴 = 엑셀 단가산출근거 탭 산출내역 한 칸 글:
|
|
머리 「이름 / 단위」 · 출처 「… 적용」 · 입력 「이름 : 값 단위」 ·
|
|
중간 「Q = 이름식 = 값」(작업량 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
|
|
|
|
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
|
|
|
|
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_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)
|
|
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
|