Files
Aislo/resources/master_data/scripts/master_doc_calc.py
eomsangdonandClaude Opus 5.5 54fb55487c feat(802-3): M01 단가산출 기초 수식 문서 화면 — 왼쪽 기호 식 · 오른쪽 값 식 · 빈 문서 시작 · 연결 고르기
- 공용 화면 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>
2026-11-07 19:23:57 +09:00

547 lines
23 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 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