Files
Aislo/resources/master_data/scripts/master_formula_sum.py
T
eomsangdonandClaude Opus 5.5 c05139a971 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>
2026-11-05 17:03:36 +09:00

268 lines
12 KiB
Python
Raw 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 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