From a0bcf5b48af710b19fbd3d2443dbc8a1d77107f1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 5 Nov 2026 18:04:11 +0900 Subject: [PATCH] =?UTF-8?q?feat(792-3):=20run=5Fline=20=C2=B7=20B09=20?= =?UTF-8?q?=EC=82=B0=EA=B7=BC=20=EC=85=88=EC=9D=B4=20=EC=97=94=EC=A7=84=20?= =?UTF-8?q?=EC=83=88=20=EC=9D=98=EB=AF=B8=EB=A5=BC=20=EB=94=B0=EB=A6=84=20?= =?UTF-8?q?=E2=80=94=20=EB=84=A4=20=EA=B8=B8=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=EA=B8=88=EC=95=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - master_cut: 엔진 답 덧칸 `셈법`(새 칸 뗀 작은 로직) · `plain(got, gone)` 이 셈법 있으면 줄 자리 · 합밖 · 단계 · 블록 끝전 · 조정 그대로(켜쌓기율 · 별도계상 뒤 다시 버림이 큰나머지 · 조정을 덮던 흠) · `kept` - M02_Labor_Doc: run_line 답이 단계 · 셈법을 넘김 · drop_apart 가 plain 에 뺄 줄 차례를 줌(686 줄 · 늘지 않음) - 새 B09_Estimation_Sangeun_Sum: 산근 풀이 · 산근 글이 새 의미 로직만 엔진 규칙(표시 · 줄 자리 · 합밖 · 번지 · 단계 · 끝전 · 조정) · 옛 로직 = Q ROUND 2 · ROUNDDOWN 1 · 0 그대로 - B09 산근 글: 로직 줄(중기 세 줄) 금액을 줄 하나로 묶어 차례를 호표와 맞춤(합 같음) - 지킴 시험 test_792_3_engine_paths: 견본 넷(봉화 · 소광 · 영월 · 임도) + 단위 로직 = 엔진 · M02 SK · SC 한 줄 · B09 산근(글 계 포함) 같은 금액 · 다시 버림 셈법 지킴 - test_791_1: 견본 넷을 만들기 함수로 떼어 냄(같은 기대) - test_222_1 낡은 기대 셋 고침: 751-3 끝수 뒤 별도계상 뺀 기준 = 남은 줄 비목 합 원 버림(소수 없음) · 옛 기대는 「원 버린 기준 − 소수 재료 금액」이라 0.6 원 어긋남 - test_145_1: 작업량 Q 이름 규칙이 B09_Estimation_Sangeun_Sum 으로 옮김 Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Sangeun.py | 18 +-- B09_Estimation/B09_Estimation_Sangeun_Sum.py | 89 +++++++++++ B09_Estimation/B09_Estimation_Sangeun_Text.py | 26 +-- M02_MasterTemplete/M02_Labor_Doc.py | 5 +- resources/master_data/scripts/master_cut.py | 42 +++-- .../master_data/scripts/master_formula_sum.py | 11 ++ resources/tester/test_145_1_b09_sangeun.py | 5 +- resources/tester/test_222_1_m02_apart.py | 28 ++-- .../tester/test_791_1_engine_semantics.py | 36 ++++- resources/tester/test_792_3_engine_paths.py | 148 ++++++++++++++++++ 10 files changed, 356 insertions(+), 52 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_Sangeun_Sum.py create mode 100644 resources/tester/test_792_3_engine_paths.py diff --git a/B09_Estimation/B09_Estimation_Sangeun.py b/B09_Estimation/B09_Estimation_Sangeun.py index 0e90f6548..fa224c392 100644 --- a/B09_Estimation/B09_Estimation_Sangeun.py +++ b/B09_Estimation/B09_Estimation_Sangeun.py @@ -36,13 +36,13 @@ from __future__ import annotations import copy import hashlib import json -import re from decimal import Decimal from pathlib import Path from typing import Any from B08_Quantity.B08_Quantity_Haul_Cycle import fill_given -from B09_Estimation.B09_Estimation_Calc_Base import gap, num, round_half, rounddown, total +from B09_Estimation import B09_Estimation_Sangeun_Sum as ss +from B09_Estimation.B09_Estimation_Calc_Base import gap, num, rounddown, total from B09_Estimation.B09_Estimation_Store import ref_rows from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store_Combo as combo_store @@ -64,7 +64,6 @@ PARTS = ("labor", "material", "expense") COST_PART = {"노무비": "labor", "재료비": "material", "경비": "expense"} PCT_OF = {**COST_PART, "인건비": "people"} # % 덧줄 밑수 → 덮개 다시 셈(719-3) DEPTH = 4 # 하위 산근 깊이 상한 -_Q = re.compile(r"Q\d*") # 작업량 중간값 이름(산림품셈 09장 토공 · 07장 집재 …) NO_SOURCE = "조합 밖 토공 · 운반 · 사토 · 준비공은 산근 없음" EMPTY = "연결된 구조물 조합 사본에 단가산출 줄 없음 — 연결 없음" @@ -239,9 +238,7 @@ def _env(whole: Any, row: dict, given: dict) -> dict: except mf.EmptyInput as e: env[step["이름"]] = mf.Blank(str(e)) continue - if _Q.fullmatch(step["이름"]) and isinstance(value, (Decimal, int, float)): - value = Decimal(str(round_half(float(value), 2))) - env[step["이름"]] = value + env[step["이름"]] = ss.shown(row, step, value) # 표시 · Q ROUND 2(792-3) return env @@ -375,11 +372,12 @@ class Solver: with mf.mp.choosing(src.get("picks")): for n, item in enumerate(row.get("호표", [])): lines.append(self._line(whole, item, n, env, times, rate, depth)) + ss.places(row, lines) # 줄 자리(792-3) self._extras(whole, row, env, lines) _annotate(whole, row, env, lines) except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e: return self._stop(code, name, str(e) or type(e).__name__) - sums = block_totals(lines) + sums = ss.totals(row, lines) or block_totals(lines) # 끝전 · 조정(792-3) why = next((ln["why"] for ln in lines if ln.get("why")), None) head, foot = self._text(whole, src, times, lines) spec, spec_raw = spec_text(whole, src["given"]) @@ -513,7 +511,7 @@ class Solver: whole, row, heads + [{} for _ in row.get("덧줄", [])], dict(env) ) for extra, pct in zip(row.get("덧줄", []), pcts[len(heads) :]): - sums = {name: total([ln[p] for ln in lines]) for name, p in COST_PART.items()} + sums = ss.sums(row, lines) # 합밖 · 제외(792-3) part = COST_PART.get(extra.get("비목"), "material") line = {"kind": "extra", "name": extra["이름"], "spec": extra["식"], "qty": 1.0, "unit": "식", "ref": None, **dict.fromkeys(PARTS, 0.0)} # fmt: skip @@ -525,6 +523,7 @@ class Solver: lines.append({**line, part: None, "price": {}, "why": "밑수 빈칸"}) continue env.update({k: Decimal(str(v)) for k, v in sums.items()}) + ss.scope(row, lines, env) # 번지 · 단계(792-3) env["줄"] = { ln["name"]: Decimal(str(sum(ln[p] or 0 for p in PARTS))) for ln in lines @@ -535,7 +534,8 @@ class Solver: except (mf.FormulaError, ArithmeticError, TypeError, ValueError) as e: lines.append({**line, part: None, "price": {}, "why": str(e)}) continue - lines.append({**line, "price": {part: value}, part: rounddown(value, 1), "why": None}) + money = ss.extra(row, extra, value, env) # 줄 자리(792-3) + lines.append({**line, "price": {part: value}, part: money, "why": None}) def _annotate(whole: Any, row: dict, env: dict, lines: list[dict[str, Any]]) -> None: diff --git a/B09_Estimation/B09_Estimation_Sangeun_Sum.py b/B09_Estimation/B09_Estimation_Sangeun_Sum.py new file mode 100644 index 000000000..7d8eb85d5 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Sangeun_Sum.py @@ -0,0 +1,89 @@ +"""B09 산근 풀이 셈 의미(PLAN 792-3) — 791-1 엔진 새 의미(줄 자리 · 합밖 · 번지 · 단계 · 블록 끝전 · +결과 조정 · 변수 표시)를 쓰는 로직만 엔진 규칙(`master_cut` · `master_formula_sum`) 그대로 · +새 칸 없는 로직 = 옛 B09 규칙(작업량 Q = ROUND 2 · 줄 ROUNDDOWN 1 · 총계 비목마다 ROUNDDOWN 0). +쓰는 곳 = `B09_Estimation_Sangeun.Solver` · 지킴 시험 = `test_792_3_engine_paths.py`(엔진 · M02 SK · +SC · B09 산근 네 길 같은 금액). +""" + +from __future__ import annotations + +import importlib +import re +from decimal import Decimal +from typing import Any + +from B09_Estimation.B09_Estimation_Calc_Base import round_half, rounddown, total +from M01_MasterData import M01_MasterData_Store as _m01 # noqa: F401 — scripts 길을 둠 + +mcut = importlib.import_module("master_cut") +ms = importlib.import_module("master_formula_sum") + +PARTS = ("labor", "material", "expense") +COST_PART = {"노무비": "labor", "재료비": "material", "경비": "expense"} +_Q = re.compile(r"Q\d*") # 작업량 중간값 이름(산림품셈 09장 토공 · 07장 집재 …) + + +def shown(row: dict, step: dict, value: Any) -> Any: + """중간값 — `표시` 칸 · 새 의미 로직 = 엔진 그대로(`표시` 자리 반올림) · 옛 = Q ROUND 2.""" + if step.get("표시") is not None or ms.uses(row): + return ms.shown(step, value) + if _Q.fullmatch(step["이름"]) and isinstance(value, (Decimal, int, float)): + return Decimal(str(round_half(float(value), 2))) + return value + + +def _cut(value: float, rule: dict | None) -> float: + return float(mcut._line(value, rule)) if rule else rounddown(value, 1) + + +def places(row: dict, lines: list[dict]) -> None: + """호표 줄 `자리`(줄 자리) — 그 줄 금액만 단가 × 수량을 그 규칙으로 다시 버림(제자리).""" + for item, ln in zip(row.get("호표", []), lines): + rule = item.get("자리") + if not rule or ln.get("qty") is None: + continue + for part, price in (ln.get("price") or {}).items(): + if part in PARTS and price is not None: + ln[part] = _cut(price * ln["qty"], rule) + + +def _engine(lines: list[dict]) -> list[dict]: + """B09 줄 → 엔진 줄 꼴 `{비목}`(번지 · 단계 셈).""" + return [ + {"비목": {k: Decimal(str(ln.get(p) or 0)) for k, p in COST_PART.items()}} for ln in lines + ] + + +def sums(row: dict, lines: list[dict]) -> dict[str, float | None]: + """덧줄 밑 비목 합 — 새 의미 로직은 합밖 · 제외 단계 줄을 뺌.""" + skip = ms.skipped(row) if ms.uses(row) else set() + kept = [ln for n, ln in enumerate(lines) if n not in skip] + return {name: total([ln[p] for ln in kept]) for name, p in COST_PART.items()} + + +def scope(row: dict, lines: list[dict], env: dict) -> None: + """덧줄 식 이름 `번지` · `단계`(지금까지 줄) — 새 의미 로직만.""" + if ms.uses(row): + env.update(ms.scope(row, _engine(lines))) + + +def extra(row: dict, item: dict, value: float, env: dict) -> float: + """덧줄 금액 — 줄 `자리` 면 그 규칙 · 아니면 ROUNDDOWN 1 · + 새 의미 로직은 뒤 덧줄이 그 값을 부름.""" + money = _cut(value, item.get("자리")) + if ms.uses(row): + env[item["이름"]] = Decimal(str(money)) + return money + + +def totals(row: dict, lines: list[dict]) -> dict[str, float | None] | None: + """총계 — 새 의미 로직 = 합밖 · 제외 빼고 블록 끝전 · `끝수` · 조정(`master_cut.settle`) · + 옛 로직 · 빈칸 비목 있음 = None(옛 `block_totals`).""" + if not ms.uses(row): + return None + got = sums(row, lines) + if any(v is None for v in got.values()): + return None + costs = mcut.settle(row, {k: Decimal(str(v)) for k, v in got.items()}) + out = {COST_PART[k]: float(v) for k, v in costs.items()} + return {**out, "sum": sum(out.values())} diff --git a/B09_Estimation/B09_Estimation_Sangeun_Text.py b/B09_Estimation/B09_Estimation_Sangeun_Text.py index 53d112e4d..18d1e3c00 100644 --- a/B09_Estimation/B09_Estimation_Sangeun_Text.py +++ b/B09_Estimation/B09_Estimation_Sangeun_Text.py @@ -27,6 +27,7 @@ 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 @@ -58,8 +59,8 @@ def _qty_text(item: dict, env: dict, whole: Any, times: Decimal, rate: Decimal | 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) +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 @@ -308,21 +309,24 @@ def _pct_specs(whole, row, env) -> list[str]: 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 = { - name: sg.total([a.get(p, 0.0) for a in amounts]) for name, p in sg.COST_PART.items() - } + 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)) @@ -331,7 +335,7 @@ def _extra_lines(whole, row, env, amounts: list[dict]) -> list[tuple[str, dict]] out.append((f"{head}계산 못 함 — {e}", {part: None})) amounts.append({part: None}) continue - money = sg.rounddown(value, 1) + money = ss.extra(row, extra, value, env) # 줄 자리(792-3) out.append((f"{head}{shown} = {_money(money)}", {part: money})) amounts.append({part: money}) return out @@ -377,13 +381,15 @@ def render_sangeun_text( 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) - amounts.append(money) + 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) + text, money = _line(name, price_of(key), qty_text, q, item.get("자리")) out.append(text) amounts.append({"labor": money}) continue @@ -397,12 +403,12 @@ def render_sangeun_text( 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) + 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 = sg.block_totals([{**dict.fromkeys(sg.PARTS, 0.0), **a} for a in amounts]) + 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 ) diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index a4d8412df..0f98b6287 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -267,8 +267,7 @@ def drop_apart(got: dict[str, Any], shown: Any) -> dict[str, Any]: } if not out or not got.get("기준"): return got - kept = [x for i, x in enumerate(lines) if i not in out] # 기준 = 남은 줄 합(751-3 끝수) - got = mcut.plain({**got, "줄": kept}) + got = mcut.plain(got, out) # 기준 = 남은 줄 합(751-3 끝수 · 791-1 셈법) return {**got, "기준": _json(got["기준"])} @@ -559,7 +558,7 @@ def run_line( money = {k: got[k] for k in (*COSTS, "계")} mats = logic_mats(whole, logic, got["줄"], picks) lines = pct_extras(whole, logic, got["줄"], got.get("중간") or {}) - out = {"기준": money, "줄": lines, "재료": mats, "까닭": None} + out = {"기준": money, "줄": lines, "재료": mats, "까닭": None, **mcut.kept(got)} return out if raw or rate in (None, 100) else mcut.plain(out) # 켜쌓기율 곱 뒤 다시 버림 diff --git a/resources/master_data/scripts/master_cut.py b/resources/master_data/scripts/master_cut.py index b3919abec..6bf98c2bd 100644 --- a/resources/master_data/scripts/master_cut.py +++ b/resources/master_data/scripts/master_cut.py @@ -133,7 +133,7 @@ def engine(master, ref: str, got: dict, depth: int = 0) -> dict: # 중간 = 덧줄이 본 값(버린 줄 합 · 덧줄 값 · `줄`) — 시험 계산 중간 칸 return {**got, "줄": lines, **costs, "계": sum(costs.values()), "중간": env} costs = settle(logic, total) - more = {"단계": ms.steps(logic, lines)} + more = {"단계": ms.steps(logic, lines), "셈법": ms.rule(logic)} return {**got, "줄": lines, **costs, "계": sum(costs.values()), "중간": env, **more} @@ -150,17 +150,41 @@ def settle(logic: dict, total: dict) -> dict: return {k: down(v, 0) for k, v in costs.items()} -def plain(got: dict) -> dict: +def kept(got: dict) -> dict: + """`run_line` 이 엔진 답에서 그대로 넘기는 덧칸(792-3) — 단계 · 셈법.""" + return {k: got[k] for k in ("단계", "셈법") if k in got} + + +def plain(got: dict, gone=()) -> dict: """로직 줄 답(`run_line` 꼴 `{기준, 줄, 재료}`)을 줄만 다시 버림 — - 켜쌓기율 곱 · 별도계상 뺀 뒤 · 기준 = 남은 줄 비목 합 ROUNDDOWN 0.""" + 켜쌓기율 곱 · 별도계상 뺀 뒤(`gone` = 뺄 줄 차례) · 기준 = 남은 줄 비목 합 ROUNDDOWN 0 · + 답 `셈법`(791-1 새 의미 로직) = 줄 자리 · 합밖 · 단계 · 블록 끝전 · 조정 그대로(`settle`).""" if not got.get("기준"): return got + rule = got.get("셈법") + places = [x.get("자리") for x in ms.items(rule)] if rule else [] + skip = set(gone) | (ms.skipped(rule) if rule else set()) + + def at(n): + return places[n] if isinstance(n, int) and 0 <= n < len(places) else None + lines, total = [], dict.fromkeys(COSTS, Decimal(0)) - for line in got.get("줄") or []: - cut, money = _parts(dict(line.get("비목") or {})) + for n, line in enumerate(got.get("줄") or []): + cut, money = _parts(dict(line.get("비목") or {}), at(n)) for k, v in cut.items(): - total[k] += Decimal(str(v or 0)) + total[k] += 0 if n in skip else Decimal(str(v or 0)) lines.append({**line, "비목": cut, "금액": money}) - mats = [{**m, "금액": down(m.get("금액"), 1)} for m in got.get("재료") or []] - base = {k: down(v, 0) for k, v in total.items()} - return {**got, "줄": lines, "재료": mats, "기준": {**base, "계": sum(base.values())}} + mats = [{**m, "금액": _line(m.get("금액"), at(m.get("차례")))} for m in got.get("재료") or []] + kept = [x for n, x in enumerate(lines) if n not in gone] + if not rule: + base = {k: down(v, 0) for k, v in total.items()} + return {**got, "줄": kept, "재료": mats, "기준": {**base, "계": sum(base.values())}} + base = settle(rule, total) + steps = ms.steps(rule, [{} if n in gone else x for n, x in enumerate(lines)]) + return { + **got, + "줄": kept, + "재료": mats, + "기준": {**base, "계": sum(base.values())}, + "단계": steps, + } diff --git a/resources/master_data/scripts/master_formula_sum.py b/resources/master_data/scripts/master_formula_sum.py index 584967d4c..e0a11d373 100644 --- a/resources/master_data/scripts/master_formula_sum.py +++ b/resources/master_data/scripts/master_formula_sum.py @@ -33,6 +33,17 @@ def uses(row: dict) -> bool: return any(k in row for k in ROW_KEYS) or any(k in x for x in items(row) for k in LINE_KEYS) +def rule(row: dict) -> dict: + """셈법(792-3) — 새 의미 칸만 뗀 작은 로직 꼴(이 모듈 · `master_cut.settle` 이 읽음) · + 엔진 답 `셈법` 으로 실어 켜쌓기율 · 별도계상 뒤 다시 버림(`master_cut.plain`)이 씀.""" + keep = ("이름", *LINE_KEYS) + lines = { + k: [{x: one[x] for x in keep if x in one} for one in row.get(k, [])] + for k in ("호표", "덧줄") + } + return {**lines, **{k: row[k] for k in (*ROW_KEYS, "끝수") if k in row}} + + def shown(step: dict, value): """중간 `표시` = 값을 n 자리 반올림해 뒤 줄에 넘김(STmate `X = 식 =`).""" if step.get("표시") is None or not isinstance(value, Decimal): diff --git a/resources/tester/test_145_1_b09_sangeun.py b/resources/tester/test_145_1_b09_sangeun.py index 087203723..61ce40b1a 100644 --- a/resources/tester/test_145_1_b09_sangeun.py +++ b/resources/tester/test_145_1_b09_sangeun.py @@ -22,6 +22,7 @@ if str(ROOT) not in sys.path: from B09_Estimation import B09_Estimation_Prices as prices # noqa: E402 from B09_Estimation import B09_Estimation_Router as router_mod # noqa: E402 from B09_Estimation import B09_Estimation_Sangeun as sg # noqa: E402 +from B09_Estimation import B09_Estimation_Sangeun_Sum as ss # noqa: E402 from B09_Estimation.B09_Estimation_Calc_Base import compute_base # noqa: E402 from B09_Estimation.B09_Estimation_Linked import used_keys # noqa: E402 from M02_MasterTemplete import M02_Item_Prices as item_prices # noqa: E402 @@ -163,13 +164,13 @@ def test_작업량_Q는_소수_둘(): whole = m01.loaded()[1] for code, row in sorted((whole.index.get("GF") or {}).items()): - if not any(sg._Q.fullmatch(s["이름"]) for s in row.get("중간", [])): + if not any(ss._Q.fullmatch(s["이름"]) for s in row.get("중간", [])): continue try: env = sg._env(whole, row, {}) except Exception: # noqa: BLE001 — 입력이 필요한 로직은 건너뜀 continue - q = next(env[s["이름"]] for s in row["중간"] if sg._Q.fullmatch(s["이름"])) + q = next(env[s["이름"]] for s in row["중간"] if ss._Q.fullmatch(s["이름"])) if hasattr(q, "as_tuple"): assert q == round(q, 2), code return diff --git a/resources/tester/test_222_1_m02_apart.py b/resources/tester/test_222_1_m02_apart.py index 9d13b45f1..d83937571 100644 --- a/resources/tester/test_222_1_m02_apart.py +++ b/resources/tester/test_222_1_m02_apart.py @@ -25,6 +25,7 @@ SRC = os.path.join(HERE, "..", "..", "M02_MasterTemplete") MIX = "GF000156" # 기계비빔타설 — 호표 3 시멘트 · 4 모래 · 5 자갈 GIVEN = {"재료배합": "(A)", "골재최대치수": 40} MAT = "MT000008" +COSTS = ("노무비", "재료비", "경비") DIRECT = {"별도계상": "직접계산"} @@ -36,6 +37,15 @@ def _rows(got): return {x["이름"]: {**x, "수량": float(x["수량"])} for x in got["줄"]} +def _kept(raw, names=None): + """별도계상 뺀 기준(751-3 끝수) = 남은 줄 비목 합마다 원 버림 · 계 = 셋 합 · + `names` = 뺄 재료 이름(없으면 재료 전부).""" + gone = {m["차례"] for m in raw["재료"] if names is None or m["이름"] in names} + lines = [x for n, x in enumerate(raw["줄"]) if n not in gone] + out = {k: int(sum(Decimal(str(x["비목"].get(k) or 0)) for x in lines)) for k in COSTS} + return {**out, "계": sum(out.values())} + + # ── ① 단위 ────────────────────────────────────────────────────────── def test_단위중량_칸에_t_값이면_1000배_0이면_표_위끝(): wrong = labor.run_line(_whole(), MIX, {**GIVEN, "모래단위중량": 2, "자갈단위중량": 2.1}) @@ -60,16 +70,14 @@ def _calc(shown=None, mats=()): def test_로직_자재_기본_별도계상은_기준_줄에서_빠지고_재료엔_남음(): raw = labor.run_line(_whole(), MIX, GIVEN) - cut = sum(Decimal(str(m["금액"])) for m in raw["재료"]) one = _calc()["줄"][0] names = {x["이름"] for x in one["줄"]} assert not names & {"시멘트", "모래(건조상태)", "자갈 또는 부순돌"} assert {"콘크리트공", "보통인부"} <= names assert len(one["재료"]) == 3 # 자재목록(세 칸 고침)엔 남음 - want = Decimal(str(raw["기준"]["재료비"])) - cut - assert one["기준"]["재료비"] == pytest.approx(float(want)) - assert one["기준"]["계"] == pytest.approx(float(Decimal(str(raw["기준"]["계"])) - cut)) - assert one["기준"]["노무비"] == pytest.approx(float(raw["기준"]["노무비"])) + want = _kept(raw) + assert {k: one["기준"][k] for k in (*COSTS, "계")} == want + assert one["기준"]["노무비"] == raw["기준"]["노무비"] def test_직접계산은_그대로_섞이면_그_자재만_빠짐(): @@ -80,9 +88,8 @@ def test_직접계산은_그대로_섞이면_그_자재만_빠짐(): part = _calc({"3": DIRECT})["줄"][0] # 시멘트만 셈 names = {x["이름"] for x in part["줄"]} assert "시멘트" in names and "모래(건조상태)" not in names - cement = next(m for m in raw["재료"] if m["이름"] == "시멘트") - others = sum(Decimal(str(m["금액"])) for m in raw["재료"]) - Decimal(str(cement["금액"])) - assert part["기준"]["계"] == pytest.approx(float(Decimal(str(raw["기준"]["계"])) - others)) + others = {m["이름"] for m in raw["재료"]} - {"시멘트"} + assert part["기준"]["계"] == _kept(raw, others)["계"] def test_사용자_자재_별도계상은_합에서_빠지고_반영은_보임(): @@ -106,7 +113,6 @@ def test_사용자_자재_별도계상_파이썬과_TS_같은_답(mark): def test_사전조합_안_줄도_별도계상_뺌(): raw = labor.run_line(_whole(), MIX, GIVEN) - cut = sum(Decimal(str(m["금액"])) for m in raw["재료"]) doc = { "이름": "비빔 조합", "단위": "㎥", @@ -120,8 +126,8 @@ def test_사전조합_안_줄도_별도계상_뺌(): assert [x.get("자재") for x in got["안"]] == [None] pill = got["안"][0] times = Decimal(1) # 기계비빔타설 알약 배 1 - assert pill["기준"]["계"] * times == pytest.approx(Decimal(str(raw["기준"]["계"])) - cut) - assert got["기준"]["계"] == pytest.approx(Decimal(str(raw["기준"]["계"])) - cut) + assert pill["기준"]["계"] * times == _kept(raw)["계"] + assert got["기준"]["계"] == _kept(raw)["계"] def test_화면_별도계상_바꾸면_다시_셈(): diff --git a/resources/tester/test_791_1_engine_semantics.py b/resources/tester/test_791_1_engine_semantics.py index c4962d674..81c016c1b 100644 --- a/resources/tester/test_791_1_engine_semantics.py +++ b/resources/tester/test_791_1_engine_semantics.py @@ -324,7 +324,7 @@ def test_검사(): # ── ③ 실무 손 옮김 견본 넷 ───────────────────────────────────────────── -def test_봉화_D00102_합판거푸집_STmate_번지_율(): +def 봉화(): """봉화 단가산출근거 2909~2943 행 — `&M00081& * 1.03 * 0.327 :=A01` · `&PA01M:M& * 11'%'` · `&PA02L:S& * 1'%'` · `(=)` · `(==)` · 총계 45,652 · 34,119 · 11,192 · 341.""" row = _logic( @@ -358,16 +358,21 @@ def test_봉화_D00102_합판거푸집_STmate_번지_율(): {"LB990101": ("형틀목공", 274978), "LB990102": ("보통인부", 165545)}, {"MT990101": ("합판(내수)", "㎡", 11468), "MT990102": ("각재", "㎥", 500661)}, ) + return master, row, {"노무비": 34119, "재료비": 11192, "경비": 341, "계": 45652} + + +def test_봉화_D00102_합판거푸집_STmate_번지_율(): + master, row, want = 봉화() assert mf.check_logic(master, row)[0] == [] got = _calc(master, "GX990101") - assert _money(got) == {"노무비": 34119, "재료비": 11192, "경비": 341, "계": 45652} + assert _money(got) == want assert [x["금액"] for x in got["줄"]] == [ D(x) for x in ("3862.5", "6221.2", "27497.8", "6621.8", "1109.2", "341.1") ] assert [x["계"] for x in got["단계"]] == [D("11192.9"), D("34460.7"), D("45653.6")] -def test_울진소광_D01489_낙석방지책_STmate_L퍼센트(): +def 소광(): """울진소광 32 호표(D01489) — `'공구손료 :'&S01045& * 2'%'`(S01045 단위 L% = 처음부터 노무비 누계) · `L = Q/10 =` 표시 2 · 계 줄 420,734 · 182,223 · 230,359 · 8,152 (계약단가 87.745 % 는 B09 몫).""" @@ -412,15 +417,20 @@ def test_울진소광_D01489_낙석방지책_STmate_L퍼센트(): }, {"MT990103": ("낙석방지책", "경간", 227000)}, ) + return master, row, {"노무비": 182223, "재료비": 230359, "경비": 8152, "계": 420734} + + +def test_울진소광_D01489_낙석방지책_STmate_L퍼센트(): + master, row, want = 소광() assert mf.check_logic(master, row)[0] == [] got = _calc(master, "GX990102") - assert _money(got) == {"노무비": 182223, "재료비": 230359, "경비": 8152, "계": 420734} + assert _money(got) == want assert _line(got, "공구손료")["금액"] == D("3644.4") assert _step(got, "지주 계")["계"] == D("49824.2") assert _step(got, "합계")["계"] == D("420735.3") -def test_동부청영월_D00003_터파기_STmate_큰나머지_끝전(): +def 영월(): """동부청영월 단가산출근거 54~103 행(D00003) — `f = 1/1.3 =` · `E=…=` · `Q = 3600*q*K*f*E/Cm =`(표시 2) · `&X00205L/M/S& / Q * 0.9` · 계 원 버림 + 큰 나머지 5,471 · 4,764 · 310 · 397.""" @@ -446,16 +456,21 @@ def test_동부청영월_D00003_터파기_STmate_큰나머지_끝전(): 끝전="큰나머지", ) master = _master([row, _HOUR], {"LB990106": ("보통인부", 165545)}) + return master, row, {"노무비": 4764, "재료비": 310, "경비": 397, "계": 5471} + + +def test_동부청영월_D00003_터파기_STmate_큰나머지_끝전(): + master, row, want = 영월() assert mf.check_logic(master, row)[0] == [] got = _calc(master, "GX990103") assert got["중간"]["Q"] == D("52.39") - assert _money(got) == {"노무비": 4764, "재료비": 310, "경비": 397, "계": 5471} + assert _money(got) == want assert _step(got, "계")["계"] == D("5471.0") row["끝전"] = "비목버림" # 비목 원 버림이면 1 원 모자람 assert _calc(master, "GX990103")["계"] == 5470 -def test_북부청임도_18_문양거푸집_EST_줄_자리(): +def 임도(): """북부청임도 단가산출 18 번(문양거푸집) — `5,786.1 × 2% = 116`(그 줄만 반올림 0) · `인부소계 : 24,467.3` · 노무비 24,467 · 재료비 5,902 · 계 30,369.""" row = _logic( @@ -480,11 +495,16 @@ def test_북부청임도_18_문양거푸집_EST_줄_자리(): {"LB990107": ("형틀목공", 275790), "LB990108": ("보통인부", 172068)}, {"MT990104": ("문양거푸집(ABS코팅폼)", "㎡", 4166)}, ) + return master, row, {"노무비": 24467, "재료비": 5902, "경비": 0, "계": 30369} + + +def test_북부청임도_18_문양거푸집_EST_줄_자리(): + master, row, want = 임도() assert mf.check_logic(master, row)[0] == [] got = _calc(master, "GX990104") assert _line(got, "잡재료비")["금액"] == 116 assert _step(got, "인부소계")["계"] == D("24467.3") - assert _money(got) == {"노무비": 24467, "재료비": 5902, "경비": 0, "계": 30369} + assert _money(got) == want del row["덧줄"][0]["자리"] # 기본 0.1 버림이면 엑셀과 1 원 다름 assert _calc(master, "GX990104")["계"] == 30368 diff --git a/resources/tester/test_792_3_engine_paths.py b/resources/tester/test_792_3_engine_paths.py new file mode 100644 index 000000000..e3f95ada1 --- /dev/null +++ b/resources/tester/test_792_3_engine_paths.py @@ -0,0 +1,148 @@ +"""792-3 엔진 새 의미 네 길 지킴 시험 — 같은 로직 JSON 이 ① 엔진 직접(`master_cut.engine`) · +② M02 단가산출 SK 셈(`M02_Sangeun_Calc.run`) · ③ 일위대가 SC 한 줄(수량 1 · `M02_Labor_Doc.calc`) · +④ B09 산근 풀이(`B09_Estimation_Sangeun.Solver` · 단가 = 같은 값의 B09 목록표)에서 같은 금액. + +견본 = 791-1 실무 손 옮김 넷(봉화 D00102 · 울진소광 D01489 · 동부청영월 D00003 · 북부청임도 18 = +엑셀 금액) + 합밖 · 제외 · 조정 단위 로직. 켜쌓기율 · 별도계상 뒤 다시 버림(`master_cut.plain`)도 +엔진 셈법(줄 자리 · 끝전 · 조정)을 지킴. 옛 로직(새 칸 없음) 전후 같음 = `test_791_1` M01 지문. +""" + +import copy +import importlib +from decimal import Decimal + +import pytest + +import resources.tester.test_791_1_engine_semantics as t +from B09_Estimation import B09_Estimation_Sangeun as sg +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Sangeun_Calc as sc +from M02_MasterTemplete import M02_Sangeun_Doc as sd + +mcut = importlib.import_module("master_cut") +D = Decimal +COSTS = ("노무비", "재료비", "경비", "계") +PART = {"labor": "노무비", "material": "재료비", "expense": "경비", "sum": "계"} +EQ = "EQ990001" + + +def _units(): + """합밖 · 제외 단계 · 조정 · 큰나머지 · 줄 자리를 한 로직에(791-1 단위 견본 바탕).""" + master = t._base( + 덧줄=[ + {"이름": "공구손료", "식": "단계.'인부 소계'.노무비 * 3 / 100", "비목": "경비"}, + {"이름": "잡", "식": "번지.A01.재료비 * 1.5 / 100", "비목": "재료비"}, + {"이름": "덧", "식": "0.6", "비목": "노무비", "자리": {"방법": "올림", "자리": 0}}, + ], + 단계=[ + {"이름": "자재 소계", "단": 1, "줄": ["갑", "을"]}, + {"이름": "인부 소계", "단": 1, "줄": ["보통", "특별"]}, + {"이름": "경비 소계", "단": 1, "줄": ["공구손료"], "제외": True}, + ], + 끝전="큰나머지", + 조정={"배율": {"재료비": D("0.9")}}, + ) + row = master.logic("GX990001") + row["호표"][1]["합밖"] = True + return master, row, None + + +CASES = {"봉화": t.봉화, "소광": t.소광, "영월": t.영월, "임도": t.임도, "단위": _units} + + +def _key(row) -> str: + return row["키"] + + +def _engine(master, row) -> dict: + got = t._calc(master, _key(row)) + return {k: got[k] for k in COSTS} + + +def _sk(master, row) -> dict: + doc = {"키": "SK-990001", "이름": "시험", "단위": "식", "바탕": {"로직": _key(row)}} + got = sc.run(sd.clean(doc, None, master), master) + return {k: got["기준"][k] for k in COSTS} + + +def _sc(master, row) -> dict: + mats = { + str(n): {"별도계상": "직접계산"} for n, x in enumerate(row["호표"]) if x["종류"] == "재료" + } + line = {"로직": _key(row), "입력값": {}, "수량": {"손": 1}, "자재표시": mats} + got = labor.calc({"담은로직": [line], "자재": []}, {}, whole=master, pills=[]) + return {k: got["반영합"][k] for k in COSTS} + + +def _b09_master(master, row): + """B09 = 기계 줄 단가를 중기목록표에서 — 기계경비 로직이 부르는 EQ 키를 잡게 + 시간당 로직에 한 줄.""" + hour = copy.deepcopy(master.index["GX"].get("GX990009") or {}) + if hour: + hour["호표"] = [t._mat(EQ, "1", "기계", 비목="경비")] + master.index["GX"]["GX990009"] = hour + master.index["EQ"] = {EQ: {"키": EQ, "이름": "시험 기계", "값": D(0)}} + return master + + +def _b09(build, row) -> dict: + master, row, _ = build() + master = _b09_master(master, row) + files = master.index + machine = next((x["값묶음"] for x in row["호표"] if "값묶음" in x), None) + sheets = { + "labor": [{"key": k, "price": float(r["값"])} for k, r in files.get("LB", {}).items()], + "compare": [ + {"key": k, "applied": float(r["물가자료"])} for k, r in files.get("MT", {}).items() + ], + "machine": [{"key": EQ, **{p: float(machine[c]) for p, c in PART.items() if c in machine}}] + if machine + else [], + } + block = sg.Solver(sheets).add(master, {"code": _key(row), "given": {}}, None) + assert block["why"] is None, block["why"] + texts = [x for ln in block["lines"] for x in ln.get("text") or []] + assert not [x for x in texts if "못" in x or "빈칸" in x], texts # 산근 글도 같은 셈 + assert block["foot"].endswith(f"= {block['sum']:,.0f}"), block["foot"] + return {PART[p]: D(str(block[p])) for p in PART} + + +@pytest.mark.parametrize("name", list(CASES)) +def test_네_길_같은_금액(name): + build = CASES[name] + master, row, want = build() + one = _engine(master, row) + if want is not None: + assert one == want # 엑셀 금액 + assert _sk(*build()[:2]) == one + assert _sc(*build()[:2]) == one + assert _b09(build, row) == one + + +def test_단위_로직_새_의미가_금액을_바꿈(): + master, row, _ = _units() + got = _engine(master, row) + plain = copy.deepcopy(row) + for k in ("끝전", "조정", "단계"): + plain.pop(k) + for x in (*plain["호표"], *plain["덧줄"]): + for k in ("합밖", "자리", "번지"): + x.pop(k, None) + assert got != _engine(t._master([plain], t.LAB, t.MAT), plain) # 시험이 새 길을 거침 + + +@pytest.mark.parametrize("name", ["영월", "단위"]) +def test_다시_버림도_셈법_지킴(name): + """켜쌓기율 · 별도계상 뒤 다시 버림(`plain`) — 셈법이 있으면 + 줄 자리 · 합밖 · 끝전 · 조정 그대로.""" + master, row, _ = CASES[name]() + out = labor.run_line(master, _key(row), {}) + assert "셈법" in out and out["단계"] + again = mcut.plain(out) + assert again["기준"] == out["기준"] + assert [x["계"] for x in again["단계"]] == [x["계"] for x in out["단계"]] + old = mcut.plain({k: v for k, v in out.items() if k != "셈법"}) + if name == "영월": + assert ( + old["기준"]["계"] == 5470 and again["기준"]["계"] == 5471 + ) # 옛 다시 버림 = 1 원 모자람