fix(M02 기준 목록·계산서): 끝수 = B09 산근 규칙(줄마다 버림 · 비목마다 버림)으로 맞춤 — 계산서 5,654 대 B09 5,651 어긋남 (PLAN 704-2 딸림)

- 새 `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 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 18:19:09 +09:00
co-authored by Claude Sonnet 5.5
parent 8626b1a922
commit 3c2c82c78e
4 changed files with 205 additions and 7 deletions
+115
View File
@@ -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["비목"]]
+12 -5
View File
@@ -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["비목"],
+3 -2
View File
@@ -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:
+75
View File
@@ -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]
)