diff --git a/B09_Estimation/B09_Estimation_Sangeun_Text.py b/B09_Estimation/B09_Estimation_Sangeun_Text.py index cd2deffa1..84c005b79 100644 --- a/B09_Estimation/B09_Estimation_Sangeun_Text.py +++ b/B09_Estimation/B09_Estimation_Sangeun_Text.py @@ -1,8 +1,13 @@ """B09 산근 한 항목 → 엑셀 단가산출근거 꼴 텍스트 수식 줄들(PLAN 155-5) — 서버 단독(③ · 순수 함수). 꼴 = 엑셀 단가산출근거 탭 산출내역 한 칸 글: - 머리 「이름 / 단위」 · 출처 「… 적용」 · 입력 「이름 : 값 단위」 · - 중간 「Q = 이름식 = 값」(작업량 Q = ROUND 2 — `Sangeun._env`) · + 머리 「이름 / 단위」 · 출처 「… 적용」 · 입력 「이름 : 설명 = 값 단위」(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`) · @@ -16,6 +21,7 @@ from __future__ import annotations +import re from collections.abc import Callable from decimal import Decimal from typing import Any @@ -23,6 +29,7 @@ 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 @@ -56,43 +63,193 @@ def _line(name: str, price: Any, qty_text: str, q: float) -> tuple[str, float | return f"{name} : {_price(price)} * {qty_text} = {_money(money)}", money -def _head(row: dict, env: dict, whole: Any, times: Decimal) -> list[str]: +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 - 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("중간", []): + 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 - shown = mtx.fmt(value) + 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: - named = mtx._names(step["식"], env, whole, name_of) + value = mf.cut(row, mf.evaluate(mf.parse(row["결과"]["식"]), env, whole, depth), "결과") except _ERRORS: - named = shown - out.append( - f"{step['이름']} = {shown}" if named == shown else f"{step['이름']} = {named} = {shown}" - ) + 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 mf_names(row: dict, whole: Any) -> dict: - """별칭 이름표(표 찾기 · 로직 부르기 → 짧은 이름) — 못 지으면 빈 표(값 꼴로 물러남).""" - try: - return mtx.mc.names(mtx.mc.aliases(row, whole)) - except _ERRORS: - return {} +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]: @@ -205,7 +362,7 @@ def render_sangeun_text( 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) + out = _head(row, env, whole, times, price_of) amounts: list[dict] = [] with mf.mp.choosing(picks): for n, item in enumerate(row.get("호표", [])): diff --git a/resources/tester/test_155_5_sangeun_text.py b/resources/tester/test_155_5_sangeun_text.py index 314a1902d..6b780ec3e 100644 --- a/resources/tester/test_155_5_sangeun_text.py +++ b/resources/tester/test_155_5_sangeun_text.py @@ -129,5 +129,6 @@ def test_자재_자리표시_키는_실제_키로(): 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 "Q = 아키야윈치(임업용 윈치) 집재(집재거리 = 50m 내외).수집량 = 8 ㎥" in got # 157-1 + assert "일수 = 1 / Q = 1 / 8 = 0.125" in got assert "휘발유(아키아윈치(휘발유)) : 1,000 * 6.5 * 0.125 = 812.5" in got diff --git a/resources/tester/test_157_1_sangeun_steps.py b/resources/tester/test_157_1_sangeun_steps.py new file mode 100644 index 000000000..65acadd77 --- /dev/null +++ b/resources/tester/test_157_1_sangeun_steps.py @@ -0,0 +1,121 @@ +"""157-1 산근 글 = M01 흐름도 함수 상자 빠짐없이 · 157-3 기계 번호 = 「이름 규격」(`Sangeun_Text`). + +- 중간 여럿 · 중간이 중간을 부름 = 「이름 = 이름식 = 값 넣은 식 = 값」(GF000064 일수) +- 찾기 = 「이름 = 표 이름(조건 = 값).칸 = 값 단위」 · 돈 아닌 하위 로직 = 이름 + 「└ 」 풀이 +- 돈 하위 로직 = 「로직 이름(산근 n호표)」 · 만약 = 걸린 갈래만 · 입력 = 「이름 : 설명 = 값 단위」 +- 함수 줄은 머리에만 — 호표 줄 · 계 = 산근 풀이(`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 + +EQ = {"labor": 55700, "material": 7695, "expense": 13041} + + +def _whole(codes, edit=None): + files = copy.deepcopy(item_prices.to_json(item_prices.collect(codes))) + if edit: + for data in files.values(): + for row in data.get("줄") or []: + if isinstance(row, dict) and row.get("키") == codes[0]: + edit(row) + return item_prices.load_prices(files) + + +def _price_of(key): + if key.startswith("SG:"): + return {"no": 3, "labor": 1000.0, "material": 0.0, "expense": 0.0, "sum": 1000.0} + return EQ if key.startswith("EQ") else 1000.0 + + +def test_중간이_중간을_부름_값_넣은_식까지(): + got = st.render_sangeun_text(_whole(["GF000064"]), "GF000064", {}, _price_of) + assert "일수 = 1 / Q * (1 + 할증 %) = 1 / 11.19 * (1 + 10 %) = 0.0983" in got + assert "할증 = 소형가선 집재 숲가꾸기 주 속 수(구분 = 총 소요인력 할증).적용 = 10" in got + assert "보통인부 수량 = 2 * 일수 = 2 * 0.0983 = 0.1966 인" in got # 호표 수량 함수 + + +def test_돈_아닌_하위_로직_풀이_찾기_결과식(): + got = st.render_sangeun_text(_whole(["GF000089"]), "GF000089", {}, _price_of) + at = got.index("작업량 = 토사깍기(기계 · 굴착기 0.7㎥) 시간당 작업량 = 39.293") + assert got[at + 1 : at + 8] == [ + "└ E입력 : 0.45", + "└ q = 토사깍기 장비(구분 = 무한궤도 굴착기).적용 = 0.7 ㎥", + "└ K = 토사깍기 기계(구분 = K).적용 = 0.9", + "└ f = 토사깍기 체적환산계수(구분 = f).적용 = 0.77", + "└ E = E입력 = 0.45", + "└ Cm = 토사깍기 기계(구분 = ㎝(sec)).적용 = 20", + "└ 결과 = 반올림(3,600 * q * K * f * E / Cm, 3) = " + "반올림(3,600 * 0.7 * 0.9 * 0.77 * 0.45 / 20, 3) = 39.293", + ] + assert "굴착기(무한궤도) 0.7㎥ 노무비 : 55,700 * 1 / 39.293 = 1,417.5" in got + + +def test_결과_로직_맨_위(): + got = st.render_sangeun_text(_whole(["GF000088"]), "GF000088", {}, _price_of) + assert ( + "결과 = 반올림(3,600 * q * K * f * E / Cm, 3) = " + "반올림(3,600 * 0.7 * 0.9 * 0.77 * 0.45 / 20, 3) = 39.293" + ) in got + + +def test_돈_하위_로직_산근_호표_번호(): + def edit(row): + row["중간"].append( + {"이름": "참고", "식": "로직(GF000123, 지역=지역, 보정작업=보정작업).노무비"} + ) + + got = st.render_sangeun_text(_whole(["GF000089", "GF000123"], edit), "GF000089", {}, _price_of) + line = next(t for t in got if t.startswith("참고 = ")) + assert line.startswith("참고 = 층따기(굴착기 0.7㎥)(산근 3호표).노무비 = ") + + +def test_만약_걸린_갈래_입력_설명_기계_이름(): + got = st.render_sangeun_text(_whole(["GF000297"]), "GF000297", {"흉고직경": 20}, _price_of) + assert "흉고직경 : 가슴높이(1.2m) 지름(표 구간 안 값) = 20 ㎝" in got + assert "굴착기 = 굴착기(무한궤도) 0.6㎥" in got # 157-3 원문번호 '0201-0060' 대신 + assert "기계 = 1" in got and not [t for t in got if "0201-" in t] + assert ( + "특별인부 수량 = 식재작업 교목식재(흉고직경)(흉고직경(㎝) = 20).특별인부 * (1 - 감 %)" + " = 0.56 * (1 - 0 %) = 0.56 인" + ) in got + + +def _solver(whole, code, given): + sheets = {"labor": [], "machine": [], "compare": []} + asked = [] + + def price_of(key): + asked.append(key) + return EQ if key.startswith("EQ") else None if key.startswith("SG:") else 1000.0 + + st.render_sangeun_text(whole, code, given, price_of) + for key in dict.fromkeys(k for k in asked if not k.startswith("SG:")): + if key.startswith("EQ"): + sheets["machine"].append({"key": key, **EQ}) + elif key.startswith("LB"): + sheets["labor"].append({"key": key, "price": 1000.0}) + else: + sheets["compare"].append({"key": key, "applied": 1000.0}) + return sg.Solver(sheets).add(whole, {"code": code, "given": given}, "s1") + + +def test_호표_줄_계는_산근_풀이와_같음(): + """함수 줄이 늘어도 꼬리(호표 줄 · 계)는 산근 풀이 줄에 그대로 얹힘 — 금액 그대로.""" + for code, given in (("GF000089", {}), ("GF000064", {}), ("GF000297", {"흉고직경": 20})): + whole = _whole([code]) + block = _solver(whole, code, given) + parts = " + ".join(f"{st.PART_NAME[p]} {block[p]:,.0f}" for p in sg.PARTS) + assert block["foot"] == f"계 : {parts} = {block['sum']:,.0f}", code + assert all(ln.get("text") for ln in block["lines"]), code + assert any(" 수량 = " in t or " = " in t for t in block["head"]), code + assert not [t for ln in block["lines"] for t in ln["text"] if " 수량 = " in t], code