feat(792-3): run_line · B09 산근 셈이 엔진 새 의미를 따름 — 네 길 같은 금액

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 18:04:11 +09:00
co-authored by Claude Opus 5.5
parent d26f7a7e1a
commit a0bcf5b48a
10 changed files with 356 additions and 52 deletions
+9 -9
View File
@@ -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:
@@ -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())}
+16 -10
View File
@@ -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
)