feat(791-1): 엔진 셈 의미 넓힘 — 금액번지 · 소계 단계 · 제외 · 줄 자리 · 블록 끝전 · 결과 조정 · 변수 표시

- 새 모듈 master_formula_sum.py — 덧줄 고리(master_formula.run 에서 옮김) · 번지 · 단계 · 합밖 · 조정 · 표시 · 검사
- master_cut.py — 줄 자리 {방법, 자리} · 블록 끝전(비목버림 | 큰나머지) · 결과 조정 · 답 단계 목록
- master_formula_check.py — 새 칸 검사(없는 번지 · 단계 순서 · 자리 범위 · 끝전 · 조정)
- _틀.md 7장 「셈 의미 넓힘」 · 8장 덧줄 이름
- 지킴 시험 test_791_1 — M01 로직 1,515 지문 전후 같음 · 새 의미 단위 · 실무 견본 넷(봉화 D00102 · 울진소광 D01489 · 동부청영월 D00003 · 북부청임도 18) = 엑셀
- master_formula.py 690 → 669 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 17:03:36 +09:00
co-authored by Claude Opus 5.5
parent 17b6ee5460
commit c05139a971
7 changed files with 2352 additions and 38 deletions
+42 -9
View File
@@ -22,6 +22,7 @@ from decimal import Decimal
from pathlib import Path
import master_formula as mf
import master_formula_sum as ms
_ROOT = str(Path(__file__).resolve().parents[3])
if _ROOT not in sys.path: # 스크립트 · 시험 길 — 서버는 이미 뿌리에서 돎
@@ -39,9 +40,19 @@ def down(value, places: int):
return Decimal(str(rounddown(float(value), places)))
def _parts(split: dict) -> tuple[dict, Decimal | None]:
"""줄 비목 → (비목마다 0.1 원 버림, 금액 = 그 합 · 모두 빈 값 = None)."""
cut = {k: down(v, 1) for k, v in split.items()}
def _line(value, rule: dict | None):
"""줄 값 끝수 — 줄 `자리` {방법, 자리}(791-1) · 없으면 0.1 원 버림."""
if value is None or not rule:
return down(value, 1)
way, places = rule.get("방법"), int(rule.get("자리") or 0)
if way == "버림":
return down(value, places)
return mf._round(Decimal(str(value)), places, mf.ROUND_WAYS[way])
def _parts(split: dict, rule: dict | None = None) -> tuple[dict, Decimal | None]:
"""줄 비목 → (비목마다 0.1 원 버림 · 줄 `자리` 면 그것, 금액 = 그 합 · 모두 빈 = None)."""
cut = {k: _line(v, rule) for k, v in split.items()}
kept = [v for v in cut.values() if v is not None]
return cut, (sum(kept, Decimal(0)) if kept else None)
@@ -84,6 +95,9 @@ def engine(master, ref: str, got: dict, depth: int = 0) -> dict:
if isinstance(logic.get("흐름"), dict):
return got
items, adds = logic.get("호표") or [], logic.get("덧줄") or []
rules = [x.get("자리") for x in (*items, *adds)] # 줄 자리(791-1)
new = ms.uses(logic)
skip = ms.skipped(logic) if new else set()
env = dict(got.get("중간") or {})
total = dict.fromkeys(COSTS, Decimal(0))
lines = []
@@ -100,21 +114,40 @@ def engine(master, ref: str, got: dict, depth: int = 0) -> dict:
elif n - len(items) < len(adds) and line.get("금액") is not None:
add = adds[n - len(items)]
env.update(total)
if new: # 덧줄이 부르는 번지 · 단계 = 버린 줄로
env.update(ms.scope(logic, lines))
try:
value = mf.evaluate(mf.parse(add["식"]), env, master, depth)
except _ERRORS:
value = line["금액"]
env[add["이름"]] = down(value, 1) # 뒤 덧줄 · 중간 칸 = 버린 값
env[add["이름"]] = _line(value, rules[n]) # 뒤 덧줄 · 중간 칸 = 버린 값
split = {add["비목"]: value}
cut, money = _parts(split)
cut, money = _parts(split, rules[n] if n < len(rules) else None)
for k, v in cut.items():
total[k] += v or 0
total[k] += 0 if n in skip else v or 0
if n >= len(items) and money is not None:
more = {"단가": money}
lines.append({**line, "비목": cut, "금액": money, **more})
costs = {k: mf.cut(logic, down(v, 0), "계") for k, v in total.items()}
# 중간 = 덧줄이 본 값(버린 줄 합 · 덧줄 값 · `줄`) — 시험 계산 중간 칸
return {**got, "줄": lines, **costs, "계": sum(costs.values()), "중간": env}
if not new:
costs = {k: mf.cut(logic, down(v, 0), "계") for k, v in total.items()}
# 중간 = 덧줄이 본 값(버린 줄 합 · 덧줄 값 · `줄`) — 시험 계산 중간 칸
return {**got, "줄": lines, **costs, "계": sum(costs.values()), "중간": env}
costs = settle(logic, total)
more = {"단계": ms.steps(logic, lines)}
return {**got, "줄": lines, **costs, "계": sum(costs.values()), "중간": env, **more}
def settle(logic: dict, total: dict) -> dict:
"""블록 끝전(791-1) — `끝전` 비목버림(비목마다 원 버림 · 옛 규칙) · 큰나머지(계 원 버림 뒤
모자란 원을 소수 큰 비목부터 · 같으면 경 › 재 › 노) → 로직 `끝수` 묶음 → `조정` → 원 버림."""
costs = {k: down(v, 0) for k, v in total.items()}
if logic.get("끝전") == "큰나머지":
rest = int(down(sum(total.values()), 0) - sum(costs.values()))
order = sorted(COSTS, key=lambda k: (costs[k] - total[k], COSTS[::-1].index(k)))
for k in order[:rest]:
costs[k] += 1
costs = ms.adjust(logic, {k: mf.cut(logic, v, "계") for k, v in costs.items()})
return {k: down(v, 0) for k, v in costs.items()}
def plain(got: dict) -> dict:
@@ -570,7 +570,8 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
env = _inputs(row, given, master)
for step in row.get("중간", []):
try:
env[step["이름"]] = evaluate(parse(step["식"]), env, master, depth)
value = evaluate(parse(step["식"]), env, master, depth)
env[step["이름"]] = ms.shown(step, value) # `표시` 자리 반올림(791-1)
except EmptyInput as e: # 못 구한 중간값 — 쓰는 줄만 비움
env[step["이름"]] = Blank(str(e))
if "결과" in row:
@@ -632,34 +633,11 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
**(extra or {}),
}
)
# 덧줄이 호표 줄 금액 하나를 빼고 더할 수 있게 — `줄.'호표 줄 이름'`(빈 줄은 뺌)
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("덧줄", []):
env.update(sums)
try: # 덧줄도 값 빈 품목·빈 줄을 부르면 그 줄만 비움
value, why = evaluate(parse(extra["식"]), env, master, depth), None
env[extra["이름"]] = value
except 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 {}),
}
)
sums = {k: cut(row, v, "계") for k, v in sums.items()} # 비목 합마다 끊고 계는 그 합
# 덧줄 · 비목 합 끊기 · 번지 · 단계 · 조정(791-1) = `master_formula_sum.extras`
sums, more = ms.extras(master, row, lines, env, sums, depth)
mb.tag(master, row, lines, env) # 줄마다 인력 · 재료 · 경비 · 하위 묶음(712-1)
mg.tag(master, row, lines, env) # 옵션으로 빠진 줄 `옵션꺼짐`(753-1)
return {"줄": lines, **sums, "계": sum(sums.values()), "중간": _shown(env)}
return {"줄": lines, **sums, "계": sum(sums.values()), "중간": _shown(env), **more}
# 품셈재료 단가 — 서로 부르는 모듈이라 끝에서 읽음
@@ -680,6 +658,7 @@ from master_material import ( # noqa: E402, F401
import master_pick as mp # noqa: E402 — 검색어 · 고름 · 단위
import master_bundle as mb # noqa: E402 — 인력 · 재료 · 경비 묶음
import master_gate as mg # noqa: E402 — 옵션으로 빠진 줄
import master_formula_sum as ms # noqa: E402 — 덧줄 · 번지 · 단계 · 조정 · 표시(791-1)
from master_formula_check import ( # noqa: E402, F401 — 검사는 나눈 파일 · 이름은 여기서 그대로
_check_cut,
_check_node,
@@ -9,6 +9,7 @@ from __future__ import annotations
import itertools
from decimal import Decimal
import master_formula_sum as ms
import master_keys as mk
from master_formula import (
_HOLE,
@@ -175,12 +176,14 @@ def check_logic(master: Master, row: dict) -> tuple[list[str], set]:
if item.get("종류") != "로직" and group != item.get("종류"):
out.append(f"{where} · 요소 그룹이 종류와 다름")
names = known | set(COST_ITEMS) | {"줄"} # 「줄」 = 호표 줄 금액 묶음(덧줄에서만)
if ms.uses(row): # 금액번지 · 소계 단계(791-1) — 덧줄에서만
names = names | {ms.ADDR, ms.STEP}
for extra in row.get("덧줄", []):
one(extra["식"], f"{key} 덧줄 {extra['이름']}", names)
names = names | {extra["이름"]}
if extra.get("비목") not in COST_ITEMS:
out.append(f"{key} 덧줄 {extra['이름']} · 비목 「{extra.get('비목')}」")
out += _check_cut(row, key)
out += _check_cut(row, key) + ms.check(row, key)
return out, calls
@@ -0,0 +1,267 @@
# -*- 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 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