Files
eomsangdonandClaude Opus 5.5 a0bcf5b48a 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>
2026-11-05 18:04:11 +09:00

279 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""로직 셈 의미 넓힘(PLAN 791-1 · `_틀.md` 7장 「셈 의미」) — 덧줄 고리 · 금액번지 · 소계 단계 ·
제외 · 결과금액 조정 · 변수 표시 반올림 · 검사.
`master_formula.run` 이 부름(덧줄 고리를 여기로 옮김) · 끝전(줄 자리 · 블록 끝전)은 `master_cut`.
새 칸을 안 쓰는 로직은 옛 셈 그대로 — 지킴 = M01 로직 지문(`test_791_1_engine_semantics.py`).
eval 없음 · 수는 Decimal.
"""
from __future__ import annotations
from decimal import ROUND_HALF_UP, Decimal
import master_formula as mf
COSTS = mf.COST_ITEMS
ROW_KEYS = ("단계", "끝전", "조정") # 로직 줄 칸
LINE_KEYS = ("번지", "합밖", "자리") # 호표 · 덧줄 칸
ENDS = ("비목버림", "큰나머지")
MAX_PLACE = 3 # 줄 자리 0~3
MAX_SHOW = 6 # 변수 표시 0~6
MAX_STEP = 5 # 소계 단 1~5(STmate `(=)`~`(=====)`)
ADDR, STEP = "번지", "단계" # 덧줄 식 이름 — `번지.A01.재료비` · `단계.'소계'.계`
def items(row: dict) -> list[dict]:
"""엔진 줄 차례 = 호표 하나씩 뒤 덧줄 하나씩."""
return [*row.get("호표", []), *row.get("덧줄", [])]
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):
return value
return mf._round(value, step["표시"], ROUND_HALF_UP)
def _add(acc: dict, money: dict | None) -> None:
for k, v in (money or {}).items():
if k in acc:
acc[k] += v or 0
def _bundle(acc: dict) -> dict:
return {**acc, "계": sum(acc[k] for k in COSTS)}
def _cover(row: dict) -> list[tuple[dict, set]]:
"""단계마다 (단계, 담은 줄 차례) — 단 1 = 제 `줄` · 단 k = 제 `줄` + 아직 안 담긴 아래 단 단계.
제외 단계는 위로 안 올라감."""
where = {x.get("이름"): i for i, x in enumerate(items(row)) if x.get("이름")}
out, open_ = [], []
for st in row.get(STEP) or []:
level, mine = int(st.get("단") or 1), {where[n] for n in st.get("줄") or [] if n in where}
keep = []
for low, got in open_:
if low < level:
mine |= got
else:
keep.append((low, got))
open_ = keep if st.get("제외") else [*keep, (level, mine)]
out.append((st, mine))
return out
def skipped(row: dict) -> set:
"""비목 합에서 빼는 줄 차례 — `합밖` 줄(번지엔 쌓음) · 제외 단계가 담은 줄."""
out = {i for i, x in enumerate(items(row)) if x.get("합밖")}
for st, mine in _cover(row):
if st.get("제외"):
out |= mine
return out
def total(row: dict, lines: list[dict]) -> dict:
"""비목 합 — 뺄 줄을 빼고 지금까지 줄."""
skip, acc = skipped(row), dict.fromkeys(COSTS, Decimal(0))
for i, line in enumerate(lines):
if i not in skip:
_add(acc, line.get("비목"))
return acc
def steps(row: dict, lines: list[dict]) -> list[dict]:
"""단계 값 — 지금까지 셈한 줄만(뒤 줄은 0)."""
out = []
for st, mine in _cover(row):
acc = dict.fromkeys(COSTS, Decimal(0))
for i in sorted(mine):
if i < len(lines):
_add(acc, lines[i].get("비목"))
out.append(
{"이름": st.get("이름"), "단": st.get("단"), **_bundle(acc)}
| ({"제외": True} if st.get("제외") else {})
)
return out
def scope(row: dict, lines: list[dict]) -> dict:
"""덧줄 식 이름 — `번지` {A01: 묶음} · `단계` {이름: 묶음} (지금까지 줄)."""
places = {}
for item, line in zip(items(row), lines):
if item.get(ADDR):
_add(places.setdefault(item[ADDR], dict.fromkeys(COSTS, Decimal(0))), line.get("비목"))
return {
ADDR: {k: _bundle(v) for k, v in places.items()},
STEP: {x["이름"]: {k: x[k] for k in (*COSTS, "계")} for x in steps(row, lines)},
}
def adjust(row: dict, sums: dict) -> dict:
"""결과금액 조정 — `배율` {비목: 수} 곱 → `옮김` 비목으로 계 전부(STmate 「경비로 적용」)."""
rule = row.get("조정")
if not rule:
return sums
out = dict(sums)
for k, rate in (rule.get("배율") or {}).items():
out[k] = out[k] * Decimal(str(rate))
if rule.get("옮김"):
whole = sum(out.values())
out = {k: whole if k == rule["옮김"] else Decimal(0) for k in COSTS}
return out
def extras(master, row: dict, lines: list[dict], env: dict, sums: dict, depth: int):
"""덧줄 셈(옛 `run` 고리 그대로) + 새 의미 → (비목 합, 덧칸 `{단계}`)."""
new = uses(row)
# 덧줄이 호표 줄 금액 하나를 빼고 더할 수 있게 — `줄.'호표 줄 이름'`(빈 줄은 뺌)
env["줄"] = {x["이름"]: x["금액"] for x in lines if x["이름"] and x["금액"] is not None}
blank = {x["이름"]: x["까닭"] for x in lines if x.get("까닭")}
for extra in row.get("덧줄", []):
if new:
sums = total(row, lines)
env.update(scope(row, lines))
env.update(sums)
try: # 덧줄도 값 빈 품목·빈 줄을 부르면 그 줄만 비움
value, why = mf.evaluate(mf.parse(extra["식"]), env, master, depth), None
env[extra["이름"]] = value
except mf.FormulaError as e:
gone = next((n for n in blank if n in extra["식"]), None)
value, why = None, f"「{gone}」 줄 비어 못 셈 — {blank[gone]}" if gone else str(e)
blank[extra["이름"]] = why
sums[extra["비목"]] += value or 0
lines.append(
{
"이름": extra["이름"],
"단위": "식",
"수량": Decimal(1),
"단가": value,
"금액": value,
"비목": {extra["비목"]: value},
**({"까닭": why} if why else {}),
}
)
if new:
sums = total(row, lines)
sums = {k: mf.cut(row, v, "계") for k, v in sums.items()} # 비목 합마다 끊고 계는 그 합
if not new:
return sums, {}
return adjust(row, sums), {STEP: steps(row, lines)}
# ── 검사 ──────────────────────────────────────────────────────────────
def _whole(v, low: int, high: int) -> bool:
return isinstance(v, (int, Decimal)) and v == int(v) and low <= int(v) <= high
def _refs(node):
"""식 안 `번지.X.칸` · `단계.'X'.칸` → (이름, X, 칸)."""
for part in mf.walk(node):
if part[0] == "field" and part[1][0] == "field" and part[1][1][0] == "name":
head = part[1][1][1]
if head in (ADDR, STEP):
yield head, part[1][2], part[2]
def check(row: dict, key: str) -> list[str]:
"""새 의미 칸 — 끝전 · 조정 · 줄 자리 · 번지 · 표시 · 단계 · 덧줄이 부르는 번지 · 단계 차례."""
out, lines = [], items(row)
end = row.get("끝전")
if end is not None and end not in ENDS:
out.append(f"{key} 끝전 「{end}」 — {' · '.join(ENDS)}")
rule = row.get("조정")
if rule is not None:
if not isinstance(rule, dict) or set(rule) - {"옮김", "배율"}:
out.append(f"{key} 조정 · 칸은 옮김 · 배율만")
else:
if rule.get("옮김") not in (None, *COSTS):
out.append(f"{key} 조정 옮김 「{rule['옮김']}」 — {' · '.join(COSTS)}")
for k, rate in (rule.get("배율") or {}).items():
if k not in COSTS or not isinstance(rate, (int, Decimal)) or rate < 0:
out.append(f"{key} 조정 배율 「{k}」={rate}")
for step in row.get("중간", []):
if step.get("표시") is not None and not _whole(step["표시"], 0, MAX_SHOW):
out.append(f"{key} 중간 {step['이름']} · 표시 「{step['표시']}」 — 0∼{MAX_SHOW} 정수")
names = [x.get("이름") for x in lines]
for item in lines:
where = f"{key} 줄 {item.get('이름', '')}"
place = item.get("자리")
if place is not None and (
not isinstance(place, dict)
or set(place) - {"방법", "자리"}
or place.get("방법") not in mf.ROUND_WAYS
or not _whole(place.get("자리", 0), 0, MAX_PLACE)
):
out.append(
f"{where} · 자리 「{place}」 — {{방법: 버림·반올림·올림, 자리: 0∼{MAX_PLACE}}}"
)
if ADDR in item and not (isinstance(item[ADDR], str) and mf._NAME.fullmatch(item[ADDR])):
out.append(f"{where} · 번지 「{item[ADDR]}」 — 글자 · 숫자만")
if "합밖" in item and not isinstance(item["합밖"], bool):
out.append(f"{where} · 합밖 「{item['합밖']}」 — 참 · 거짓")
out += _check_steps(row, key, names)
return out
def _check_steps(row: dict, key: str, names: list) -> list[str]:
out, seen, taken = [], set(), {}
for st in row.get(STEP) or []:
name = st.get("이름") if isinstance(st, dict) else None
where = f"{key} 단계 {name}"
if not isinstance(name, str) or not name.strip():
out.append(f"{key} 단계 · 이름 없음")
continue
if name in seen:
out.append(f"{where} · 같은 이름")
seen.add(name)
if set(st) - {"이름", "단", "줄", "제외"}:
out.append(f"{where} · 모르는 칸 {sorted(set(st) - {'이름', '단', '줄', '제외'})}")
if not _whole(st.get("단"), 1, MAX_STEP):
out.append(f"{where} · 단 「{st.get('단')}」 — 1∼{MAX_STEP} 정수")
for line in st.get("줄") or []:
if line not in names:
out.append(f"{where} · 없는 줄 「{line}」")
elif names.count(line) > 1:
out.append(f"{where} · 줄 이름 「{line}」 겹침 — 줄 이름을 가를 것")
elif line in taken:
out.append(f"{where} · 줄 「{line}」 이 단계 「{taken[line]}」 에도 있음")
taken.setdefault(line, name)
if out:
return out
cover = {st["이름"]: mine for st, mine in _cover(row)}
places = {}
for i, item in enumerate(items(row)):
if item.get(ADDR):
places.setdefault(item[ADDR], set()).add(i)
first = len(row.get("호표", []))
for n, extra in enumerate(row.get("덧줄", [])):
try:
node = mf.parse(extra["식"])
except mf.FormulaError:
continue # 못 읽는 식은 `check_logic` 이 알림
for head, name, slot in _refs(node):
where = f"{key} 덧줄 {extra.get('이름')}"
group = (places if head == ADDR else cover).get(name)
if group is None:
out.append(f"{where} · 없는 {head} 「{name}」")
elif any(i >= first + n for i in group):
out.append(f"{where} · {head} 「{name}」 에 이 줄이나 뒤 줄이 듦 — 단계 순서")
if slot not in mf.BUNDLE:
out.append(f"{where} · {head} 칸 「{slot}」 — {' · '.join(mf.BUNDLE)}")
return out