- 공용 화면 A00_Common/formula_doc_*(M01 · 뒤에 M02 = 길 앞머리만 바꿈) — 옛 수식 나무 화면은 M01 에서 걷음(M02 는 그대로) - 보이는 수식 그 자리 고침(공용 editEq · 전체 고르기 = 한 줄 글) · 분수 · 첨자 · 만약 = 탄 갈래만 · 반올림 자리 숨김 · 값 식 단위 - 값 식 숫자 누름 = 그 자리 값 · 기호 값 누름 = 정의 줄 · 정의 영역 = 기호 : 이름 — 연결(키 · 값 · 단위 없음 · 탄 갈래로 닿는 기호만) - 새 기호 = 정의 필요 → 숫자 · 선택 · 입력 · 요소 · 표 · 단가산출 · 식 연결 · 기호 · 첨자 · 이름 고침 · 정의 지움 - 금액 = 경쟁사 꼴 이름 : 단가 × 수량 = 금액 · 묶음 하나 = 계 · 둘 이상 = 가. 이름 소계 + 합계 · 단가산출 연결 펼침 = 그 문서 식 · 값 식 - 셈 = 브라우저 계산기 즉시 · 연결 값 = 서버 기록 캐시(common_util_formula_doc_cache.ts ↔ master_doc_record.py) · 없는 연결만 뒤에서 미리 셈 - 서버 길 GET /api/m01/logic/doc · POST preview · save(서버가 정본으로 다시 셈 · 수식 문서 파일에 씀) · links · GET table (M01_MasterData_Doc.py) - 연결 대상 안 고른 연결(표 · 요소 · 단가산출) = 연결 끊김 표지(파이썬 · TS 짝 가드 — 서버 500 걷음) - 시험 test_802_3_doc_screen 11(읽기 · 빈 문서 · 저장 = 서버 다시 셈 · 관리자 아니면 막힘 · 지움 견딤 · 표지 갈래 · 후보) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
547 lines
23 KiB
Python
547 lines
23 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""단가산출 수식 문서 셈 (PLAN 802-1 · 요구서_664 · 꼴 `_수식문서_틀.md`).
|
||
|
||
수식 문서를 그대로 셈 — 옛 로직 엔진(`master_formula`)을 안 부름. 기호는 필요할 때 셈(차례 무관) ·
|
||
무엇을 지워도 예외 없음: 정의 필요 · 순환 · 0 나눔 · 식 틀림 = 표지 + 금액 「—」(None) ·
|
||
값 없음(빈 입력 · 표 줄 없음 · 요소 값 없음) = 그 기호를 쓰는 줄만 비움(까닭).
|
||
연결 풀이(표 · 요소 · 단가산출 문서 찾기)는 `links`(M01 = `master_doc_link.Links`).
|
||
파이썬 · TS 짝 — 짝 = `common_util/common_util_formula_doc_calc.ts` · 거울 시험
|
||
`resources/tester/test_802_1_formula_doc.py`. 수는 Decimal
|
||
· 금액 버림은 엑셀 ROUNDDOWN(작은 오차 무시).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from decimal import ROUND_DOWN, ROUND_HALF_UP, ROUND_UP, Decimal, InvalidOperation
|
||
|
||
import master_doc as md
|
||
|
||
COSTS, BUNDLE = md.COSTS, md.BUNDLE
|
||
EPS = 1e-7 # B09_Estimation_Calc_Base.EPS 와 같음
|
||
DEEP = 20 # 단가산출 부름 깊이
|
||
_QUANT = {"버림": ROUND_DOWN, "반올림": ROUND_HALF_UP, "올림": ROUND_UP}
|
||
|
||
|
||
class Gap(Exception):
|
||
"""연결 값 없음 — 그 기호를 쓰는 줄만 비움."""
|
||
|
||
|
||
class Broken(Exception):
|
||
"""연결 끊김(없는 키 · 칸 · 조건) — 셈 멈춤 · 금액 「—」."""
|
||
|
||
|
||
class Blank:
|
||
def __init__(self, why: str):
|
||
self.why = why
|
||
|
||
|
||
class Stop:
|
||
def __init__(self, why: str, kind: str = "멈춤", sign: str = ""):
|
||
self.why, self.kind, self.sign = why, kind, sign
|
||
|
||
|
||
def _bad(v) -> bool:
|
||
return isinstance(v, (Blank, Stop))
|
||
|
||
|
||
# ── 수 ────────────────────────────────────────────────────────────────
|
||
def rounddown(value: float, places: int = 0) -> float:
|
||
"""엑셀 ROUNDDOWN(B09 `rounddown` 과 같음)."""
|
||
a, scale = abs(value), 10**places
|
||
out = math.floor(a * scale + EPS) / scale
|
||
return -out if value < 0 else out
|
||
|
||
|
||
def cut(value, rule: dict | None, plain: bool = False):
|
||
"""끝수 한 번 — 버림 = 엑셀 ROUNDDOWN(금액 끝수).
|
||
plain = Decimal 자리 맞춤(식 함수 · 계 끝수)."""
|
||
if value is None or not rule:
|
||
return value
|
||
way, places = rule.get("방법"), int(rule.get("자리") or 0)
|
||
if way == "버림" and not plain:
|
||
return Decimal(str(rounddown(float(value), places)))
|
||
return Decimal(value).quantize(Decimal(1).scaleb(-places), rounding=_QUANT[way])
|
||
|
||
|
||
def plain_text(v) -> str:
|
||
"""구멍(`{기호}`)에 넣는 글 — 수는 군더더기 없이."""
|
||
return format(v.normalize(), "f") if isinstance(v, Decimal) else str(v)
|
||
|
||
|
||
def number(v):
|
||
if isinstance(v, bool):
|
||
return v
|
||
if isinstance(v, (int, float)):
|
||
return Decimal(str(v))
|
||
return v
|
||
|
||
|
||
# ── 셈 한 벌 ──────────────────────────────────────────────────────────
|
||
class _Run:
|
||
def __init__(self, doc: dict, links, given: dict | None, stack: tuple, memo: dict):
|
||
self.doc, self.links, self.stack, self.memo = doc, links, stack, memo
|
||
self.given = given or {}
|
||
self.vars = {md.var_id(v): v for v in doc.get("변수") or []}
|
||
self.lines = {str(x.get("기호") or ""): x for x in doc.get("식") or []}
|
||
self.values: dict[str, object] = {}
|
||
self.busy: list[str] = []
|
||
self.marks: list[dict] = []
|
||
self.money_syms = {str(x["기호"]) for x in doc.get("금액") or [] if x.get("기호")}
|
||
|
||
def mark(self, stop: Stop) -> Stop:
|
||
one = {"갈래": stop.kind, "글": stop.why, **({"기호": stop.sign} if stop.sign else {})}
|
||
if one not in self.marks:
|
||
self.marks.append(one)
|
||
return stop
|
||
|
||
# 이름 값
|
||
def get(self, name: str, scope: dict | None = None):
|
||
if scope is not None and name in scope:
|
||
return scope[name]
|
||
if name in md.MONEY_NAMES or name in self.money_syms:
|
||
return self.mark(Stop(f"「{name}」 은 금액 줄에서만", "금액 이름", name))
|
||
if name in self.values:
|
||
return self.values[name]
|
||
if name in self.busy:
|
||
loop = self.busy[self.busy.index(name) :]
|
||
return self.mark(Stop(" → ".join([*loop, name]), "순환", name))
|
||
self.busy.append(name)
|
||
try:
|
||
value = self._define(name)
|
||
finally:
|
||
self.busy.pop()
|
||
if isinstance(value, Stop):
|
||
self.mark(value)
|
||
self.values[name] = value
|
||
return value
|
||
|
||
def _define(self, name: str):
|
||
var, line = self.vars.get(name), self.lines.get(name)
|
||
kind = ((var or {}).get("연결") or {}).get("갈래", "식")
|
||
if line is not None and kind != "식":
|
||
return Stop(f"「{name}」 정의가 식 줄 · 연결 둘", "겹침", name)
|
||
if line is not None:
|
||
return self.expr(line.get("식"), name)
|
||
if var is None or kind == "식":
|
||
return Stop(f"「{name}」 정의 필요", "정의 필요", name)
|
||
return self.link(var, name)
|
||
|
||
def expr(self, text, where: str = "", scope: dict | None = None):
|
||
try:
|
||
node = md.parse(text)
|
||
except md.DocError as e:
|
||
return self.mark(Stop(str(e), "식 틀림", where))
|
||
return self.ev(node, scope, where)
|
||
|
||
# 식
|
||
def ev(self, node, scope, where):
|
||
kind = node[0]
|
||
if kind in ("num", "str"):
|
||
return node[1]
|
||
if kind == "var":
|
||
return self.get(node[1], scope)
|
||
if kind == "field":
|
||
base = self.ev(node[1], scope, where)
|
||
if _bad(base):
|
||
return base
|
||
if not isinstance(base, dict) or node[2] not in base:
|
||
return self.mark(Stop(f"칸 「{node[2]}」 없음", "칸 없음", where))
|
||
got = base[node[2]]
|
||
return Blank(f"칸 「{node[2]}」 비어 있음") if got is None else got
|
||
if kind == "neg":
|
||
a = self.ev(node[1], scope, where)
|
||
return a if _bad(a) else self.arith(lambda: -a, where)
|
||
if kind == "pct":
|
||
a = self.ev(node[1], scope, where)
|
||
return a if _bad(a) else self.arith(lambda: a / 100, where)
|
||
if kind == "call":
|
||
return self.call(node, scope, where)
|
||
op = node[1]
|
||
a = self.ev(node[2], scope, where)
|
||
if _bad(a):
|
||
return a
|
||
if op == "그리고" and not a or op == "또는" and a:
|
||
return a
|
||
b = self.ev(node[3], scope, where)
|
||
if _bad(b) or op in ("그리고", "또는"):
|
||
return b
|
||
if op == "/" and isinstance(b, Decimal) and b == 0:
|
||
return self.mark(Stop(f"{where} — 0 나눔", "0 나눔", where))
|
||
return self.arith(lambda: _BIN[op](a, b), where)
|
||
|
||
def arith(self, fn, where):
|
||
try:
|
||
got = fn()
|
||
except (TypeError, ArithmeticError):
|
||
return self.mark(Stop(f"{where} — 수 아닌 값을 셈", "셈 틀림", where))
|
||
return got
|
||
|
||
def call(self, node, scope, where):
|
||
name, items = node[1], node[2]
|
||
if name == "만약":
|
||
if len(items) != 3:
|
||
return self.mark(Stop("만약(조건, 참, 거짓)", "식 틀림", where))
|
||
test = self.ev(items[0], scope, where)
|
||
if _bad(test):
|
||
return test
|
||
return self.ev(items[1] if test else items[2], scope, where)
|
||
values = [self.ev(x, scope, where) for x in items]
|
||
bad = next((v for v in values if _bad(v)), None)
|
||
if bad is not None:
|
||
return bad
|
||
if name in ("최소", "최대"):
|
||
return self.arith(lambda: (min if name == "최소" else max)(values), where)
|
||
if len(values) > 2:
|
||
return self.mark(Stop(f"{name}(값, 자리)", "식 틀림", where))
|
||
places = values[1] if len(values) > 1 else 0
|
||
return self.arith(lambda: cut(values[0], {"방법": name, "자리": int(places)}, True), where)
|
||
|
||
# 연결
|
||
def fill(self, text, where: str):
|
||
out, bad = str(text), None
|
||
|
||
def one(m):
|
||
nonlocal bad
|
||
got = self.get(m.group(1))
|
||
if _bad(got):
|
||
bad = bad or got
|
||
return ""
|
||
return plain_text(got)
|
||
|
||
out = md.HOLE.sub(one, out)
|
||
return bad if bad is not None else out
|
||
|
||
def link(self, var: dict, name: str):
|
||
link = var.get("연결") or {}
|
||
kind = link.get("갈래")
|
||
own = var.get("열쇠")
|
||
if kind in ("숫자", "입력", "선택"):
|
||
given = own in self.given if own else False
|
||
value = number(self.given[own] if given else link.get("값"))
|
||
if kind == "숫자" and isinstance(value, dict): # 손 묶음(노 · 재 · 경 — 엑셀 값 그대로)
|
||
got = {k: number(value.get(k)) for k in COSTS}
|
||
if not all(isinstance(v, Decimal) for v in got.values()):
|
||
return Stop(f"「{name}」 묶음 값은 노무비 · 재료비 · 경비 수", "연결 꼴", name)
|
||
return {**got, "계": sum(got.values())}
|
||
if value is None or value == "":
|
||
return Blank(f"「{var.get('이름') or name}」 값 비어 있음")
|
||
if kind == "입력" and isinstance(value, str):
|
||
try:
|
||
value = Decimal(value.strip().replace(",", ""))
|
||
except InvalidOperation:
|
||
pass
|
||
if kind == "선택" and not given:
|
||
options = [number(x) for x in link.get("목록") or []]
|
||
if value not in options and plain_text(value) not in [
|
||
plain_text(x) for x in options
|
||
]:
|
||
return Stop(f"「{name}」 값 「{plain_text(value)}」 목록 밖", "목록 밖", name)
|
||
value = next((x for x in options if plain_text(x) == plain_text(value)), value)
|
||
low_high = link.get("범위")
|
||
if kind == "입력" and low_high and isinstance(value, Decimal):
|
||
low, high = (number(x) for x in low_high)
|
||
if (low is not None and value < low) or (high is not None and value > high):
|
||
return Stop(
|
||
f"「{name}」 {plain_text(value)} 범위 [{low}, {high}] 밖", "범위 밖", name
|
||
)
|
||
return value
|
||
payload, bad = {}, None
|
||
for key in ("키", "표"):
|
||
if link.get(key):
|
||
payload[key] = self.fill(link[key], name)
|
||
if isinstance(link.get("재료"), dict):
|
||
payload["재료"] = {
|
||
k: self.fill(v, name) if isinstance(v, str) else v for k, v in link["재료"].items()
|
||
}
|
||
for slot in ("지역", "계약종별"):
|
||
if link.get(slot):
|
||
payload[slot] = self.get(link[slot])
|
||
for slot in ("조건", "입력"):
|
||
if isinstance(link.get(slot), dict):
|
||
payload[slot] = {k: self.expr(v, name) for k, v in link[slot].items()}
|
||
for v in (payload.get("키"), payload.get("표"), *(payload.get("재료") or {}).values()):
|
||
bad = bad or (v if _bad(v) else None)
|
||
for v in (payload.get("지역"), payload.get("계약종별")):
|
||
bad = bad or (v if _bad(v) else None)
|
||
for v in [*(payload.get("조건") or {}).values(), *(payload.get("입력") or {}).values()]:
|
||
bad = bad or (v if _bad(v) else None)
|
||
if bad is not None:
|
||
return bad
|
||
for k in ("칸", "단위"):
|
||
if link.get(k):
|
||
payload[k] = link[k]
|
||
try:
|
||
if kind == "요소":
|
||
return number(self.links.element(payload))
|
||
if kind == "표":
|
||
return number(self.links.table(payload))
|
||
if kind == "단가산출":
|
||
return self.sub(payload, name)
|
||
except Gap as e:
|
||
return Blank(str(e))
|
||
except Broken as e:
|
||
return Stop(f"「{name}」 {e}", "연결 끊김", name)
|
||
return Stop(f"「{name}」 연결 갈래 「{kind}」", "연결 꼴", name)
|
||
|
||
def sub(self, payload: dict, name: str):
|
||
key = str(payload.get("키") or "")
|
||
if not key: # 고르는 중 — 표지(예외 아님) · 짝 TS sub
|
||
raise Broken("단가산출 고르지 않음")
|
||
if key in self.stack or len(self.stack) > DEEP:
|
||
return Stop(f"단가산출 부름 돌고 돎 — {' → '.join([*self.stack, key])}", "순환", name)
|
||
doc = self.links.doc(key)
|
||
if doc is None:
|
||
raise Broken(f"없는 단가산출 「{key}」")
|
||
given = payload.get("입력") or {}
|
||
memo_key = (key, tuple(sorted((k, plain_text(v)) for k, v in given.items())))
|
||
if memo_key not in self.memo:
|
||
self.memo[memo_key] = calc(doc, self.links, given, (*self.stack, key), self.memo)
|
||
got = self.memo[memo_key]
|
||
if not got["셈됨"]: # 값 없음(결과 빈 입력 · 표 줄 없음) = 빈 값 · 그 밖(표지) = 멈춤
|
||
why = got.get("까닭") or " · ".join(m["글"] for m in got["표지"])
|
||
if got.get("까닭"):
|
||
return Blank(f"단가산출 「{key}」 못 셈 — {why}")
|
||
return Stop(f"단가산출 「{key}」 못 셈 — {why}", "부름 멈춤", name)
|
||
value = got["결과"] if "결과" in got else {k: got[k] for k in BUNDLE}
|
||
col = payload.get("칸")
|
||
if col:
|
||
if not isinstance(value, dict) or col not in value:
|
||
raise Broken(f"칸 「{col}」 없음")
|
||
return value[col]
|
||
return value
|
||
|
||
|
||
_BIN = {
|
||
"+": lambda a, b: a + b,
|
||
"-": lambda a, b: a - b,
|
||
"*": lambda a, b: a * b,
|
||
"/": lambda a, b: a / b,
|
||
"<": lambda a, b: a < b,
|
||
"<=": lambda a, b: a <= b,
|
||
">": lambda a, b: a > b,
|
||
">=": lambda a, b: a >= b,
|
||
"==": lambda a, b: a == b,
|
||
"!=": lambda a, b: a != b,
|
||
}
|
||
|
||
|
||
# ── 금액 ──────────────────────────────────────────────────────────────
|
||
def _zero() -> dict:
|
||
return dict.fromkeys(COSTS, Decimal(0))
|
||
|
||
|
||
def _add(acc: dict, parts: dict) -> None:
|
||
for k, v in parts.items():
|
||
if k in acc and v is not None:
|
||
acc[k] += v
|
||
|
||
|
||
def _bundle(acc: dict) -> dict:
|
||
return {**acc, "계": sum(acc[k] for k in COSTS)}
|
||
|
||
|
||
def _cover(doc: dict) -> list[tuple[dict, set]]:
|
||
"""단계마다 (단계, 담은 줄 차례) — 단 1 = 제 줄 · 단 k = 제 줄 + 아직 안 담긴 아래 단 단계."""
|
||
where = {x.get("이름"): i for i, x in enumerate(doc.get("금액") or []) if x.get("이름")}
|
||
out, open_ = [], []
|
||
for st in doc.get("단계") 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 _steps(cover, outs: list[dict]) -> list[dict]:
|
||
rows = []
|
||
for st, mine in cover:
|
||
acc = _zero()
|
||
for i in sorted(mine):
|
||
if i < len(outs):
|
||
_add(acc, outs[i]["비목"])
|
||
rows.append(
|
||
{"이름": st.get("이름"), "단": st.get("단"), **_bundle(acc)}
|
||
| ({"제외": True} if st.get("제외") else {})
|
||
)
|
||
return rows
|
||
|
||
|
||
def _scope(doc: dict, outs: list[dict], running: dict, cover, syms: dict) -> dict:
|
||
places: dict[str, dict] = {}
|
||
for line, one in zip(doc.get("금액") or [], outs):
|
||
if line.get("번지"):
|
||
_add(places.setdefault(str(line["번지"]), _zero()), one["비목"])
|
||
return {
|
||
**running,
|
||
"번지": {k: _bundle(v) for k, v in places.items()},
|
||
"단계": {x["이름"]: {k: x[k] for k in BUNDLE} for x in _steps(cover, outs)},
|
||
**syms,
|
||
}
|
||
|
||
|
||
def _money(run: _Run, doc: dict, rules: dict):
|
||
"""금액 줄 차례대로 — 줄 비목마다 끝수 · 합밖 · 제외 단계 · 앞 줄 누계 · 번지 · 단계."""
|
||
lines = doc.get("금액") or []
|
||
cover = _cover(doc)
|
||
skip = {i for i, x in enumerate(lines) if x.get("합밖")}
|
||
for st, mine in cover:
|
||
if st.get("제외"):
|
||
skip |= mine
|
||
running, syms, outs, stopped = _zero(), {}, [], False
|
||
for i, line in enumerate(lines):
|
||
scope = _scope(doc, outs, dict(running), cover, syms)
|
||
where = str(line.get("이름") or f"금액 {i + 1}")
|
||
qty = (
|
||
run.expr(line["수량"], where, scope)
|
||
if line.get("수량") not in (None, "")
|
||
else Decimal(1)
|
||
)
|
||
price = run.expr(line.get("단가"), where, scope)
|
||
kind = line.get("비목")
|
||
parts, why = {}, None
|
||
if isinstance(qty, Stop) or isinstance(price, Stop):
|
||
stopped, why = True, (qty if isinstance(qty, Stop) else price).why
|
||
elif isinstance(qty, Blank):
|
||
why = qty.why
|
||
elif not isinstance(qty, Decimal):
|
||
stopped, why = True, run.mark(Stop(f"{where} 수량이 수 아님", "셈 틀림", where)).why
|
||
elif qty == 0:
|
||
parts = {k: Decimal(0) for k in (COSTS if kind == "묶음" else (kind,))}
|
||
elif isinstance(price, Blank):
|
||
why = price.why
|
||
elif kind == "묶음":
|
||
if isinstance(price, dict) and all(isinstance(price.get(k), Decimal) for k in COSTS):
|
||
parts = {k: qty * price[k] for k in COSTS}
|
||
else:
|
||
stopped, why = (
|
||
True,
|
||
run.mark(
|
||
Stop(f"{where} 묶음 줄 단가가 단가산출 묶음 아님", "셈 틀림", where)
|
||
).why,
|
||
)
|
||
elif kind in COSTS and isinstance(price, Decimal):
|
||
parts = {kind: qty * price}
|
||
else:
|
||
what = (
|
||
"묶음 값 — 칸(.계 · .노무비 …) 필요"
|
||
if isinstance(price, dict)
|
||
else "단가가 수 아님"
|
||
)
|
||
stopped, why = True, run.mark(Stop(f"{where} {what}", "셈 틀림", where)).why
|
||
rule = line.get("자리") or rules["줄"]
|
||
parts = {k: cut(v, rule) for k, v in parts.items()}
|
||
amount = sum(parts.values(), Decimal(0)) if parts else None
|
||
if i not in skip:
|
||
_add(running, parts)
|
||
if line.get("기호"):
|
||
syms[str(line["기호"])] = amount if amount is not None else Blank(why or "빈 줄")
|
||
outs.append(
|
||
{
|
||
"이름": line.get("이름") or "",
|
||
"비목": parts,
|
||
"수량": qty if isinstance(qty, Decimal) else None,
|
||
"단가": price if isinstance(price, (Decimal, dict)) else None,
|
||
"금액": amount,
|
||
**({"까닭": why} if why else {}),
|
||
**({"합밖": True} if i in skip else {}),
|
||
**({"묶음": line["묶음"]} if line.get("묶음") else {}),
|
||
}
|
||
)
|
||
return outs, running, stopped, cover
|
||
|
||
|
||
def _settle(doc: dict, rules: dict, total: dict) -> dict:
|
||
"""비목 합 끝수 → 끝전(큰나머지) → 계 끝수 → 조정 → 비목 끝수."""
|
||
costs = {k: cut(v, rules["비목"]) for k, v in total.items()}
|
||
if rules.get("끝전") == "큰나머지":
|
||
rest = int(cut(sum(total.values()), rules["비목"]) - 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
|
||
if rules.get("계"):
|
||
costs = {k: cut(v, rules["계"], True) for k, v in costs.items()}
|
||
adjust = doc.get("조정") or {}
|
||
for k, rate in (adjust.get("배율") or {}).items():
|
||
costs[k] = costs[k] * number(rate)
|
||
if adjust.get("옮김"):
|
||
whole = sum(costs.values())
|
||
costs = {k: whole if k == adjust["옮김"] else Decimal(0) for k in COSTS}
|
||
return {k: cut(v, rules["비목"]) for k, v in costs.items()}
|
||
|
||
|
||
def _groups(outs: list[dict]) -> list[dict]:
|
||
order: list[str] = []
|
||
acc: dict[str, dict] = {}
|
||
for one in outs:
|
||
name = str(one.get("묶음") or "")
|
||
if name not in acc:
|
||
order.append(name)
|
||
acc[name] = _zero()
|
||
if not one.get("합밖"):
|
||
_add(acc[name], one["비목"])
|
||
return [{"이름": n, **_bundle(acc[n])} for n in order]
|
||
|
||
|
||
def rules_of(doc: dict) -> dict:
|
||
"""끝수 — 문서 칸 › 기본(`md.CUT`) · 셈 답에 그대로 실음(숨은 규칙 없음)."""
|
||
got = dict(doc.get("끝수") or {})
|
||
return {
|
||
k: got.get(k) or md.CUT.get(k)
|
||
for k in ("줄", "비목", "끝전", "계")
|
||
if got.get(k) or md.CUT.get(k)
|
||
}
|
||
|
||
|
||
def calc(
|
||
doc: dict, links, given: dict | None = None, stack: tuple = (), memo: dict | None = None
|
||
) -> dict:
|
||
"""수식 문서 셈 → {값, 빈값, 줄, 노무비 · 재료비 · 경비 · 계 | 결과,
|
||
묶음, 머리, 단계?, 표지, 셈됨, 끝수}.
|
||
given = {변수 열쇠: 값}(단가산출 부름이 넘긴 입력) · 예외 없음."""
|
||
memo = {} if memo is None else memo
|
||
stack = stack or ((str(doc["키"]),) if doc.get("키") else ()) # 제 키 — 저를 부르면 순환
|
||
run = _Run(doc, links, given, stack, memo)
|
||
for one in md.check(doc):
|
||
if one["갈래"] in ("기호 꼴", "연결 꼴", "금액 줄", "끝수", "겹침"):
|
||
run.marks.append(one)
|
||
rules = rules_of(doc)
|
||
for name in [*run.lines, *run.vars]:
|
||
if name:
|
||
run.get(name)
|
||
out: dict = {"끝수": rules}
|
||
has_money = bool(doc.get("금액"))
|
||
if doc.get("결과"):
|
||
value = run.get(str(doc["결과"]))
|
||
ok = isinstance(value, Decimal)
|
||
out |= {"결과": value if ok else None}
|
||
if isinstance(value, Blank):
|
||
out["까닭"] = value.why
|
||
out["셈됨"] = ok
|
||
elif has_money:
|
||
outs, running, stopped, cover = _money(run, doc, rules)
|
||
stopped = stopped or any(m["갈래"] in ("겹침", "금액 줄", "끝수") for m in run.marks)
|
||
costs = None if stopped else _settle(doc, rules, running)
|
||
out |= {
|
||
"줄": outs,
|
||
**{k: (costs or {}).get(k) for k in COSTS},
|
||
"계": None if costs is None else sum(costs.values()),
|
||
"묶음": _groups(outs),
|
||
}
|
||
out["머리"] = "합계" if len(out["묶음"]) > 1 else "계"
|
||
if doc.get("단계"):
|
||
out["단계"] = _steps(cover, outs)
|
||
out["셈됨"] = costs is not None
|
||
else:
|
||
run.marks.append({"갈래": "빈 문서", "글": "금액 줄 · 결과 없음"})
|
||
out |= {"줄": [], **dict.fromkeys(BUNDLE), "묶음": [], "머리": "계", "셈됨": False}
|
||
out["값"] = {k: (None if _bad(v) else v) for k, v in run.values.items()}
|
||
out["빈값"] = {k: v.why for k, v in run.values.items() if isinstance(v, Blank)}
|
||
out["표지"] = run.marks
|
||
return out
|