diff --git a/B09_Estimation/B09_Estimation_Sangeun_Text.py b/B09_Estimation/B09_Estimation_Sangeun_Text.py new file mode 100644 index 000000000..6d40d7aac --- /dev/null +++ b/B09_Estimation/B09_Estimation_Sangeun_Text.py @@ -0,0 +1,264 @@ +"""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 diff --git a/resources/tester/test_155_5_sangeun_text.py b/resources/tester/test_155_5_sangeun_text.py new file mode 100644 index 000000000..249676ff7 --- /dev/null +++ b/resources/tester/test_155_5_sangeun_text.py @@ -0,0 +1,122 @@ +"""155-5 산근 텍스트 수식 — 엑셀 단가산출근거 꼴 글 줄(`B09_Estimation_Sangeun_Text`). + +- 엑셀 6호표 뒷채움: 「보통인부 : 165,545 * 0.018 = 2,979.8」 · 중기 노 · 재 · 경 세 줄 · + 「계 : 노무비 6,878 + 재료비 538 + 경비 912 = 8,328」(줄 ROUNDDOWN 1 · 계 ROUNDDOWN 0) +- 엑셀 7호표 막자갈채집: 작업량 Q = ROUND 2 중간 줄 · 「55,700 * 1 / 21.59 * 2 = 5,159.7」 +- 단가 없음 = 「단가 없음」 글 · 계 그 비목 빈칸 · 글 속 금액 = 산근 풀이(`Sangeun.Solver`) 줄 값 +""" + +import copy +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B09_Estimation import B09_Estimation_Sangeun as sg # noqa: E402 +from B09_Estimation import B09_Estimation_Sangeun_Text as st # noqa: E402 +from M02_MasterTemplete import M02_Item_Prices as item_prices # noqa: E402 + +BASE = "GF000218" # 기초다짐 및 뒷채움(기계) — 보통인부 + 굴착기 0.2㎥ 로직 줄 +GIVEN = {"규격": 150, "지역": "전국평균", "보정작업": "해당 없음"} +EXCEL_EQ = {"labor": 55700, "material": 7695, "expense": 13041} # 중기목록표 굴삭기 0.2㎥ + + +def _whole(edit=None): + """사본 단가 묶음 — `edit(로직 줄)` 로 GF000218 을 엑셀 견본 꼴로 바꿈(수량 · 중간 · 덧줄).""" + files = copy.deepcopy(item_prices.to_json(item_prices.collect([BASE]))) + if edit: + for data in files.values(): + for row in data.get("줄") or []: + if isinstance(row, dict) and row.get("키") == BASE: + edit(row) + return item_prices.load_prices(files) + + +def _excel(qty_labor: str, qty_eq: str, mids=(), extras=()): + def edit(row): + labor, eq = row["호표"][0], row["호표"][1] + row["호표"] = [{**labor, "수량": qty_labor}, {**eq, "수량": qty_eq}] + row["중간"] = [{"이름": n, "식": f} for n, f in mids] + row["덧줄"] = list(extras) + + return edit + + +def _prices(labor): + return lambda key: EXCEL_EQ if key.startswith("EQ") else labor.get(key) + + +def test_엑셀_6호표_뒷채움_같은_글(): + whole = _whole(_excel("0.018", "0.07")) + got = st.render_sangeun_text(whole, BASE, GIVEN, _prices({"LB000002": 165545})) + assert got[:3] == ["기초다짐 및 뒷채움(기계) / ㎥", "산림품셈 13-3-2 적용", "규격 : 150 mm"] + assert got[-5:] == [ + "보통인부 : 165,545 * 0.018 = 2,979.8", + "굴착기(무한궤도) 0.2㎥ 노무비 : 55,700 * 0.07 = 3,899.0", + "굴착기(무한궤도) 0.2㎥ 재료비 : 7,695 * 0.07 = 538.6", + "굴착기(무한궤도) 0.2㎥ 경비 : 13,041 * 0.07 = 912.8", + "계 : 노무비 6,878 + 재료비 538 + 경비 912 = 8,328", + ] + + +def test_엑셀_7호표_작업량_Q_중간_줄(): + whole = _whole(_excel("0", "1 / Q * 2", mids=[("Q", "21.594")])) + got = st.render_sangeun_text(whole, BASE, GIVEN, _prices({"LB000002": 165545})) + assert "Q = 21.594 = 21.59" in got # ROUND(식, 2) 뒤 쓰임 + assert "굴착기(무한궤도) 0.2㎥ 노무비 : 55,700 * 1 / 21.59 * 2 = 5,159.7" in got + + +def test_배_켜쌓기율_꼬리_단가_없음(): + whole = _whole(_excel("0.018", "0.07")) + got = st.render_sangeun_text(whole, BASE, GIVEN, _prices({}), times=10, rate=90) + assert "보통인부 : 단가 없음 * 0.018 / 10 * 90 % = 빈칸" in got + assert got[-1] == "계 : 노무비 빈칸 + 재료비 48 + 경비 82 = 빈칸" + + +def _solver_prices(whole, code, given): + """산근 풀이와 같은 단가 — 키마다 같은 수(노무 · 자재 1,000 · 중기 엑셀 값).""" + sheets = {"labor": [], "machine": [], "compare": []} + asked = [] + + def price_of(key): + asked.append(key) + return EXCEL_EQ if key.startswith("EQ") else 1000.0 + + st.render_sangeun_text(whole, code, given, price_of) + for key in dict.fromkeys(asked): + if key.startswith("EQ"): + sheets["machine"].append({"key": key, **EXCEL_EQ}) + elif key.startswith("LB"): + sheets["labor"].append({"key": key, "price": 1000.0}) + else: + sheets["compare"].append({"key": key, "applied": 1000.0}) + return sheets, price_of + + +def test_글_속_금액은_산근_풀이_값과_같음(): + for code, given in ((BASE, GIVEN), ("GF000288", {})): # 중기 · 노무 · % 덧줄 · 배 1,000 + whole = item_prices.load_prices(item_prices.to_json(item_prices.collect([code]))) + sheets, price_of = _solver_prices(whole, code, given) + block = sg.Solver(sheets).add(whole, {"code": code, "given": given}, "s1") + times = sg._unit(code)[1] # 결과단위 배(「원/1,000㎡」) — 산근 풀이와 같은 1 단위 + got = st.render_sangeun_text(whole, code, given, price_of, times=times) + parts = " + ".join(f"{st.PART_NAME[p]} {block[p]:,.0f}" for p in sg.PARTS) + assert got[-1] == f"계 : {parts} = {block['sum']:,.0f}", code + + +def test_자재_자리표시_키는_실제_키로(): + """「MO:휘발유:{지역}」 자리표시 = 대비표 키(MO…)로 물음 — 산근 풀이 흠(H4 · 155 데스크탑 보조가 + 고침)과 달리 단가가 나옴 · Q 중간 줄 · 「줄.'이름'」 % 덧줄.""" + whole = item_prices.load_prices(item_prices.to_json(item_prices.collect(["GF000062"]))) + asked = [] + + def price_of(key): + asked.append(key) + return 1000.0 + + got = st.render_sangeun_text(whole, "GF000062", {}, price_of) + assert not [k for k in asked if ":" in k] and any(k.startswith("MO") for k in asked) + assert "Q = 아키야윈치임업용윈치집재.수집량 = 8" in got and "일수 = 1 / Q = 0.125" in got + assert "휘발유(아키아윈치(휘발유)) : 1,000 * 6.5 * 0.125 = 812.5" in got