- 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>
279 lines
12 KiB
Python
279 lines
12 KiB
Python
# -*- 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
|