From 3c2c82c78e661feced15bcc7503ae6607f5b0141 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 18:19:09 +0900 Subject: [PATCH] =?UTF-8?q?fix(M02=20=EA=B8=B0=EC=A4=80=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=C2=B7=EA=B3=84=EC=82=B0=EC=84=9C):=20=EB=81=9D?= =?UTF-8?q?=EC=88=98=20=3D=20B09=20=EC=82=B0=EA=B7=BC=20=EA=B7=9C=EC=B9=99?= =?UTF-8?q?(=EC=A4=84=EB=A7=88=EB=8B=A4=20=EB=B2=84=EB=A6=BC=20=C2=B7=20?= =?UTF-8?q?=EB=B9=84=EB=AA=A9=EB=A7=88=EB=8B=A4=20=EB=B2=84=EB=A6=BC)?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=A7=9E=EC=B6=A4=20=E2=80=94=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=EC=84=9C=205,654=20=EB=8C=80=20B09=205,651=20?= =?UTF-8?q?=EC=96=B4=EA=B8=8B=EB=82=A8=20(PLAN=20704-2=20=EB=94=B8?= =?UTF-8?q?=EB=A6=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 `M02_Logic_Settle` — 엔진 구조 그대로 줄 ROUNDDOWN 1 · 비목 ROUNDDOWN 0 · 작업량 Q ROUND 2 · 하위 로직 같은 규칙 - 기준 목록 금액 · 계산서 줄 · 소계 · 계가 같은 값 · 목록 저장본 꼴 번호 올림 · 머리 글에 지식DB 단수처리 근거 - 시험: 거울 test_704_settle(B09 _money · block_totals) · 옛 목록 시험은 끝수 차 3 원 안으로 고침 Co-Authored-By: Claude Sonnet 5.5 --- M02_MasterTemplete/M02_Logic_Settle.py | 115 ++++++++++++++++++++ M02_MasterTemplete/M02_Logic_Standard.py | 17 ++- resources/tester/test_704_logic_standard.py | 5 +- resources/tester/test_704_settle.py | 75 +++++++++++++ 4 files changed, 205 insertions(+), 7 deletions(-) create mode 100644 M02_MasterTemplete/M02_Logic_Settle.py create mode 100644 resources/tester/test_704_settle.py diff --git a/M02_MasterTemplete/M02_Logic_Settle.py b/M02_MasterTemplete/M02_Logic_Settle.py new file mode 100644 index 000000000..8900471e7 --- /dev/null +++ b/M02_MasterTemplete/M02_Logic_Settle.py @@ -0,0 +1,115 @@ +"""M02 기준 목록 · 계산서 끝수(PLAN 704-2 딸림) — B09 산근 끝수 규칙으로 셈(③ 서버 단독 · +`B09_Estimation_Sangeun` 과 규칙 짝 · 거울 시험 `test_704_settle`). + +줄 금액 = ROUNDDOWN(단가 × 수량, 1) 비목마다 · 비목 총계 = ROUNDDOWN(Σ 줄, 0) · 계 = 셋 합 · +작업량 Q(중간 이름 Q · Q1 …) = ROUND(식, 2) 뒤 쓰임 · % 덧줄 밑수 = 그때까지 줄 합(줄 끝수 뒤). +근거 = 지식DB `technical_info/01_임도/05_원가정보/단수처리_규칙.md`(행 금액 절사 · 관측 후보 규칙). +M01 엔진(`master_formula.run`)의 구조 그대로 — 끝수 자리만 다름(엔진은 줄 끝수 없이 계에서 한 번). +하위 로직(기계경비 포함)은 같은 규칙으로 다시 셈 · 흐름 · 결과 로직은 엔진 값 그대로. +""" + +from __future__ import annotations + +import re +from decimal import ROUND_DOWN, ROUND_HALF_UP, Decimal +from typing import Any + +from M01_MasterData import M01_MasterData_Store as m01 + +mf = m01.cm.mf +_Q = re.compile(r"Q\d*") + + +def _down(value: Any, places: int) -> Any: + return None if value is None else mf._round(Decimal(value), places, ROUND_DOWN) + + +def _bundle(whole: Any, item: dict, env: dict, depth: int) -> dict: + """로직 호표 줄이 부르는 하위 로직 — 같은 규칙으로 다시 셈(엔진 `logic` 마디 그대로).""" + node = mf.parse(item["요소"]) + if node[0] != "logic": + return mf.evaluate(node, env, whole, depth) + given = {k: mf.evaluate(v, env, whole, depth) for k, v in node[2]} + got = settle(whole, mf.fill(node[1], env), given, depth + 1) + return got["결과"] if "결과" in got else {k: got[k] for k in mf.BUNDLE} + + +def settle(whole: Any, ref: str, given: dict, depth: int = 0) -> dict: + """`master_formula.run` 과 같은 꼴 `{줄, 노무비, 재료비, 경비, 계, 중간}` — 끝수만 B09 규칙.""" + row = whole.logic(ref) + if isinstance(row.get("흐름"), dict) or "결과" in row: + return mf.run(whole, ref, given, depth) + env = mf._inputs(row, given) + for step in row.get("중간", []): + try: + value = mf.evaluate(mf.parse(step["식"]), env, whole, depth) + except mf.EmptyInput as e: + env[step["이름"]] = mf.Blank(str(e)) + continue + if _Q.fullmatch(step["이름"]) and isinstance(value, Decimal): + value = value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + env[step["이름"]] = value + lines: list[dict] = [] + for n, item in enumerate(row.get("호표", [])): + try: + qty = mf.evaluate(mf.parse(item["수량"]), env, whole, depth) + if item["종류"] == "로직": + bundle = _bundle(whole, item, env, depth) + price = bundle["계"] + split = ( + {k: qty * bundle[k] for k in mf.COST_ITEMS} + if "비목" not in item + else {item["비목"]: qty * price} + ) + else: + 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) + ) + if isinstance(ref_, str) and qty != 0: + whole.get(ref_) + price = mf.mp.priced(whole, item, ref_, env, n if depth == 0 else None)[0] + if price is None and qty == 0: + price = Decimal(0) + split = {item["비목"]: None if price is None else qty * price} + except mf.EmptyInput as e: + lines.append(mf._blank_line(item, str(e))) + continue + lines.append(_line(item["이름"], split)) + sums = {k: sum((x["비목"].get(k) or 0 for x in lines), Decimal(0)) for k in mf.COST_ITEMS} + env["줄"] = {x["이름"]: x["금액"] for x in lines if x["이름"] and x["금액"] is not None} + for extra in row.get("덧줄", []): + env.update(sums) + try: + value = mf.evaluate(mf.parse(extra["식"]), env, whole, depth) + env[extra["이름"]] = value + except mf.FormulaError: + value = None + sums[extra["비목"]] += _down(value, 1) or 0 + lines.append(_line(extra["이름"], {extra["비목"]: value})) + if value is not None: + env["줄"][extra["이름"]] = lines[-1]["금액"] + sums = {k: mf.cut(row, _down(v, 0), "계") for k, v in sums.items()} + return {"줄": lines, **sums, "계": sum(sums.values()), "중간": mf._shown(env)} + + +def _line(name: str, split: dict) -> dict: + cut = {k: _down(v, 1) for k, v in split.items()} + whole_sum = None if all(v is None for v in cut.values()) else sum(v or 0 for v in cut.values()) + return {"이름": name, "비목": cut, "금액": whole_sum} + + +def apply(groups: list[dict], engine: dict, done: dict) -> None: + """계산서 묶음의 줄 금액 · 소계를 `settle` 값으로 바꿈 — 줄 차례는 엔진과 같음.""" + seq: dict[str, list] = {k: [] for k in mf.COST_ITEMS} + for eng, mine in zip(engine["줄"], done["줄"]): + for part, _ in m01.mtx._shares(eng): + seq[part].append(mine["비목"].get(part)) + for group in groups: + for line, amount in zip(group["줄"], seq.get(group["비목"], [])): + line["금액"] = amount + group["소계"] = done[group["비목"]] diff --git a/M02_MasterTemplete/M02_Logic_Standard.py b/M02_MasterTemplete/M02_Logic_Standard.py index 71eaac2cb..bcfd69de3 100644 --- a/M02_MasterTemplete/M02_Logic_Standard.py +++ b/M02_MasterTemplete/M02_Logic_Standard.py @@ -5,12 +5,13 @@ 위 끝) · 그 밖 연속 값 입력은 견본 자동값(`master_auto.values`) 한 줄 · 지역 = 자동값(전국평균). 펼침 = 바탕(자동값 · 규격 축은 첫 대표 값) 한 줄 + 규격 축마다 값 하나씩 바꾼 줄(곱하지 않음 · 바꾼 줄이 0 원이면 표에 없는 조합으로 보고 뺌). -금액 = M01 엔진(`master_formula.run`) 「계」 — M01 시험 계산과 같은 셈 · 최신 판 노임(마스터 판). +금액 = M01 엔진 식 + B09 산근 끝수(줄마다 버림 · `M02_Logic_Settle` · 근거 지식DB +`technical_info/01_임도/05_원가정보/단수처리_규칙.md`) · 최신 판 노임(마스터 판). 목록은 서버가 뒤에서 한 번 셈(프로세스 `WORKERS` 개 · 수 분) → 프로세스 메모리 + 파생 저장본 (storage `_derived/m02_logic_standard/`) · 열쇠 = 마스터 내용 지문 + `SHAPE` · 같으면 안 셈 · 셈 도중 목록 = 빈 줄 `준비: false`(화면은 로직 줄 그대로 · 금액 빈칸). 실무 낱말(`WORDS`) = 별칭 칸에 덧붙여 찾기(공용 `matchLogic`)가 「석축」 → 돌쌓기를 찾음. -계산서 = `master_text.lines`(줄 「석공 | 268,908 * 0.1 = 26,891」 · 비목 소계 · 계 = 엔진 값). +계산서 = `master_text.lines`(줄 「석공 | 268,908 * 0.1 = 26,891」 · 비목 소계 · 계) + 같은 끝수 값. M01 은 읽기만 · 권한은 부르는 쪽 몫(로그인). """ @@ -27,9 +28,10 @@ from typing import Any from common_util.common_util_derived_store import derived_key, read_derived, write_derived from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs +from M02_MasterTemplete import M02_Logic_Settle as settle from M02_MasterTemplete import M02_Template_Layers as layers -SHAPE = 2 # 줄 꼴이 바뀌면 올림 — 옛 저장본을 안 씀 +SHAPE = 3 # 줄 꼴이 바뀌면 올림 — 옛 저장본을 안 씀 KIND = "m02_logic_standard" REGION = "지역" # 지역 = 자동값 한 벌(정함 8) QUIET = ("해당 없음", "없음") # 규격 글에서 뺄 값 @@ -168,7 +170,7 @@ def _money(whole: Any, logic: dict, given: dict) -> Decimal | None: if not str(logic.get("결과단위") or "").startswith("원"): return None try: - got = m01.cm.mf.run(whole, str(logic["키"]), m01.cm.mf.fill_empty(logic, given)) + got = settle.settle(whole, str(logic["키"]), m01.cm.mf.fill_empty(logic, given)) except (m01.cm.mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError): return None return got.get("계", got.get("결과")) @@ -325,7 +327,12 @@ def sheet(key: str, change: dict) -> dict: except m01.cm.mf.FormulaError as e: raise m01.StoreError(404, str(e)) from e given, ax = _given(whole, logic, change) - got = m01.mtx.lines(files, key, m01.cm.mf.fill_empty(logic, given)) + filled = m01.cm.mf.fill_empty(logic, given) + got = m01.mtx.lines(files, key, filled) + done = settle.settle(whole, key, filled) + if "결과" not in done: # 돈 로직 — 줄마다 버림(B09 산근 규칙) + settle.apply(got.get("groups") or [], m01.cm.mf.run(whole, key, filled), done) + got["계"] = done["계"] groups = [ { "비목": g["비목"], diff --git a/resources/tester/test_704_logic_standard.py b/resources/tester/test_704_logic_standard.py index 2cd394958..97a5f2191 100644 --- a/resources/tester/test_704_logic_standard.py +++ b/resources/tester/test_704_logic_standard.py @@ -34,13 +34,14 @@ def _given(key: str, change: dict) -> dict: @pytest.mark.parametrize("key", THREE) -def test_줄_금액은_M01_시험_계산(key: str) -> None: +def test_줄_금액은_M01_시험_계산에_끝수만_버림(key: str) -> None: rows = _rows(key) assert rows for row in rows: got = m01.calc(key, _given(key, row["바꿈"])) assert got["ok"], got - assert row["금액"] == pytest.approx(float(got["sums"]["계"]), abs=0.01), row["규격"] + gap = float(got["sums"]["계"]) - row["금액"] # 줄 · 비목 버림(B09 산근 규칙) — 최대 3 원 + assert -0.01 <= gap < 3.2, row["규격"] def test_대표_규격_펼침() -> None: diff --git a/resources/tester/test_704_settle.py b/resources/tester/test_704_settle.py new file mode 100644 index 000000000..7e85217bd --- /dev/null +++ b/resources/tester/test_704_settle.py @@ -0,0 +1,75 @@ +"""704-2 딸림 — 기준 목록 · 계산서 끝수 = B09 산근 규칙(줄마다 ROUNDDOWN 1 · 비목마다 ROUNDDOWN 0). + +거울: 엔진 줄(수량 · 단가)에 B09 `_money` · `block_totals` 를 그대로 씌운 값 = `settle` 값. +""" + +import re +import sys +from decimal import Decimal +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 M01_MasterData import M01_MasterData_Store as m01 # noqa: E402 +from M02_MasterTemplete import M02_Logic_Settle as st # noqa: E402 +from M02_MasterTemplete import M02_Logic_Standard as ls # noqa: E402 + +mf = m01.cm.mf +PART_OF = {"노무비": "labor", "재료비": "material", "경비": "expense"} + + +def _plain(row: dict) -> bool: + """로직 줄 · 덧줄 · 작업량 Q 가 없는 돈 로직 — 엔진 줄만으로 B09 값을 낼 수 있음.""" + return ( + str(row.get("결과단위") or "").startswith("원") + and not row.get("덧줄") + and not row.get("끝수") + and not any(i.get("종류") == "로직" for i in row.get("호표") or []) + and not any(re.fullmatch(r"Q\d*", s["이름"]) for s in row.get("중간") or []) + ) + + +def test_줄마다_버림_비목마다_버림_B09와_같음(): + files, whole = m01.loaded() + checked = 0 + for _, data in m01._logic_files(files): + for row in data.get("줄") or []: + if not _plain(row) or checked >= 60: + continue + base, _ = ls.axes(whole, row) + given = mf.fill_empty(row, base) + got = mf.run(whole, row["키"], given) + lines = [] + for line in got["줄"]: + one = dict.fromkeys(PART_OF.values(), 0.0) + for part, money in line["비목"].items(): + one[PART_OF[part]] = sg._money(1.0, float(money)) if money is not None else None + lines.append(one) + if any(v is None for one in lines for v in one.values()): + continue + want = sg.block_totals(lines) + mine = st.settle(whole, row["키"], given) + for name, part in PART_OF.items(): + assert float(mine[name]) == want[part], (row["키"], name) + assert float(mine["계"]) == want["sum"], row["키"] + checked += 1 + assert checked >= 30 + + +def test_계산서_줄_소계_계가_맞물림(): + files, whole = m01.loaded() + key = "GF000089" + got = ls.sheet(key, {}) + assert got["묶음"], key + for group in got["묶음"]: + lines = [Decimal(str(x["금액"] or 0)) for x in group["줄"]] + assert all(v == v.quantize(Decimal("0.1")) for v in lines), group["비목"] + assert group["소계"] == int(group["소계"]) + assert group["소계"] <= sum(lines) < group["소계"] + 1 + Decimal("0.05"), group["비목"] + assert got["계"] == sum(g["소계"] for g in got["묶음"]) + assert got["계"] == ls._money( + whole, whole.logic(key), ls._given(whole, whole.logic(key), {})[0] + )