feat(802-1): 단가산출 수식 문서 꼴 · 계산기(파이썬 · TS 짝) · 표지 · 꼴 문서
- 꼴: 변수표(기호 · 첨자 · 이름 · 단위 · 연결 = 숫자 · 선택 · 입력 · 요소 · 표 · 단가산출 · 식) + 식 줄 + 금액 줄 + 끝수 칸 — resources/master_data/_수식문서_틀.md - 풀이 · 글로 되돌림 · 기호 · 이름 바꿈 전파 · 정적 검사 = master_doc.py ↔ common_util_formula_doc.ts - 셈 = master_doc_calc.py ↔ common_util_formula_doc_calc.ts — 기호는 필요할 때 셈 · 정의 필요 · 순환 · 0 나눔 · 식 틀림 · 빈 문서 = 표지 + 금액 없음 · 값 없음 = 그 줄만 비움 · 묶음 소계 · 합계 · 줄 자리 · 끝전 큰나머지 · 계 끝수 · 조정 · 번지 · 단계 · 합밖 - M01 연결 풀이(표 찾기 · 요소 · 단가산출 문서) = master_doc_link.py ↔ common_util_formula_doc_table.ts(표 찾기) · 옛 엔진 셈 안 부름 - 옛 로직 변환 = master_doc_convert.py(802-2 빌더가 부름) - 시험 test_802_1_formula_doc 48(꼴 · 셈 · 지움 견딤 300 넘는 지운 문서 예외 0 · 표지 · 거울 파이썬 ↔ TS 60 문서) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,545 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""단가산출 수식 문서 — 꼴 · 풀이(파스) · 글로 되돌림 · 검사 (PLAN 802-1 · 요구 664 · 요구서_664).
|
||||
|
||||
꼴 글 = `resources/master_data/_수식문서_틀.md` · 셈 = `master_doc_calc`
|
||||
· 연결 풀이 = `master_doc_link`.
|
||||
파이썬 · TS 짝 — 짝 = `common_util/common_util_formula_doc.ts`(풀이 · 글 · 기호) · 거울 시험
|
||||
`resources/tester/test_802_1_formula_doc.py`. 고칠 때 둘을 같이 고침.
|
||||
|
||||
식 글: 기호(`Q_시공^표` · `C_{m 3}`) · 수 · '글' · + - × ÷(* / − 도 됨) · % · 괄호 · 비교
|
||||
· 그리고 · 또는 ·
|
||||
함수(만약 · 올림 · 버림 · 반올림 · 최소 · 최대) · 칸 꺼냄(`B_굴착기.경비` · `번지.A01.재료비` ·
|
||||
`단계.'소계'.계`). eval 없음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from decimal import Decimal
|
||||
|
||||
COSTS = ("노무비", "재료비", "경비")
|
||||
BUNDLE = (*COSTS, "계")
|
||||
FUNCS = ("만약", "올림", "버림", "반올림", "최소", "최대")
|
||||
MONEY_NAMES = (*COSTS, "번지", "단계") # 금액 줄에서만 쓰는 이름(앞 줄까지 누계 · 번지 · 단계)
|
||||
RESERVED = (*BUNDLE, "번지", "단계", "그리고", "또는", *FUNCS)
|
||||
LINKS = ("숫자", "선택", "입력", "요소", "표", "단가산출", "식")
|
||||
WAYS = ("버림", "반올림", "올림")
|
||||
ENDS = ("비목버림", "큰나머지")
|
||||
CUT = { # 기본 끝수 — 새 문서에 그대로 적어 둠(품셈 1-2-2 · 일위대가 0.1 원 · 계 1 원 · 미만 버림)
|
||||
"줄": {"방법": "버림", "자리": 1},
|
||||
"비목": {"방법": "버림", "자리": 0},
|
||||
"끝전": "비목버림",
|
||||
}
|
||||
PREC = {"또는": 1, "그리고": 2, "<": 3, "<=": 3, ">": 3, ">=": 3, "==": 3, "!=": 3}
|
||||
PREC |= {"+": 4, "-": 4, "*": 5, "/": 5}
|
||||
SHOW = {"*": "×", "/": "÷"}
|
||||
_ALIAS = {"×": "*", "÷": "/", "−": "-", "≤": "<=", "≥": ">=", "≠": "!="}
|
||||
|
||||
_BASE = r"[^\W\d_](?:[^\W_]|[′'])*"
|
||||
_RUN = r"(?:[^\W_]|[′'])+"
|
||||
_PART = r"(?:\{[^{}]*\}|" + _RUN + ")"
|
||||
ID = re.compile(_BASE + r"(?:_" + _PART + r")?(?:\^" + _PART + r")?")
|
||||
_NUM = re.compile(r"\d+(?:\.\d+)?")
|
||||
_FIELD = re.compile(r"(?:[^\W_]|[′'])+")
|
||||
HOLE = re.compile(r"\{([^{}]+)\}")
|
||||
|
||||
|
||||
class DocError(ValueError):
|
||||
"""식 틀림 — 글 · 자리."""
|
||||
|
||||
|
||||
# ── 기호 ──────────────────────────────────────────────────────────────
|
||||
def _part(text: str) -> str:
|
||||
return text if re.fullmatch(_RUN, text) else "{" + text + "}"
|
||||
|
||||
|
||||
def sym_id(sign: str, low: str = "", up: str = "") -> str:
|
||||
"""기호 · 아래 · 위 → 식 글 이름(`Q_시공^표`) — 첨자에 낱말 아닌 글이 들면 `{…}`."""
|
||||
out = str(sign or "")
|
||||
if low:
|
||||
out += "_" + _part(str(low))
|
||||
if up:
|
||||
out += "^" + _part(str(up))
|
||||
return out
|
||||
|
||||
|
||||
def split_id(text: str) -> tuple[str, str, str]:
|
||||
"""식 글 이름 → (기호, 아래, 위)."""
|
||||
m = re.fullmatch(r"(" + _BASE + r")(?:_(" + _PART + r"))?(?:\^(" + _PART + r"))?", text or "")
|
||||
if not m:
|
||||
return text, "", ""
|
||||
strip = lambda s: (s[1:-1] if s.startswith("{") else s) if s else "" # noqa: E731
|
||||
return m.group(1), strip(m.group(2) or ""), strip(m.group(3) or "")
|
||||
|
||||
|
||||
def var_id(var: dict) -> str:
|
||||
return sym_id(var.get("기호") or "", var.get("아래") or "", var.get("위") or "")
|
||||
|
||||
|
||||
def good_id(text: str) -> bool:
|
||||
return bool(ID.fullmatch(text or "")) and text not in RESERVED
|
||||
|
||||
|
||||
# ── 풀이 ──────────────────────────────────────────────────────────────
|
||||
class _Parser:
|
||||
def __init__(self, text: str):
|
||||
self.s, self.i = str(text), 0
|
||||
|
||||
def fail(self, why: str):
|
||||
raise DocError(f"{why} — 「{self.s}」 {self.i}자")
|
||||
|
||||
def ws(self):
|
||||
while self.i < len(self.s) and self.s[self.i].isspace():
|
||||
self.i += 1
|
||||
|
||||
def op(self, *words: str) -> str | None:
|
||||
self.ws()
|
||||
for w in words:
|
||||
if self.s.startswith(w, self.i):
|
||||
if w in ("그리고", "또는") and ID.match(self.s, self.i).end() != self.i + len(w):
|
||||
continue # 「그리고값」 같은 이름
|
||||
self.i += len(w)
|
||||
return _ALIAS.get(w, w)
|
||||
return None
|
||||
|
||||
def need(self, word: str):
|
||||
if not self.op(word):
|
||||
self.fail(f"「{word}」 없음")
|
||||
|
||||
def parse(self):
|
||||
self.ws()
|
||||
if self.i >= len(self.s):
|
||||
self.fail("빈 식")
|
||||
node = self.expr()
|
||||
self.ws()
|
||||
if self.i != len(self.s):
|
||||
self.fail("식 끝에 남은 글자")
|
||||
return node
|
||||
|
||||
def expr(self):
|
||||
node = self.both()
|
||||
while self.op("또는"):
|
||||
node = ("bin", "또는", node, self.both())
|
||||
return node
|
||||
|
||||
def both(self):
|
||||
node = self.compare()
|
||||
while self.op("그리고"):
|
||||
node = ("bin", "그리고", node, self.compare())
|
||||
return node
|
||||
|
||||
def compare(self):
|
||||
node = self.add()
|
||||
o = self.op("<=", ">=", "==", "!=", "≤", "≥", "≠", "<", ">")
|
||||
return ("bin", o, node, self.add()) if o else node
|
||||
|
||||
def add(self):
|
||||
node = self.mul()
|
||||
while o := self.op("+", "-", "−"):
|
||||
node = ("bin", o, node, self.mul())
|
||||
return node
|
||||
|
||||
def mul(self):
|
||||
node = self.unary()
|
||||
while o := self.op("*", "/", "×", "÷"):
|
||||
node = ("bin", o, node, self.unary())
|
||||
return node
|
||||
|
||||
def unary(self):
|
||||
if self.op("-", "−"):
|
||||
return ("neg", self.unary())
|
||||
node = self.primary()
|
||||
while True:
|
||||
self.ws()
|
||||
if self.s.startswith(".", self.i):
|
||||
self.i += 1
|
||||
node = ("field", node, self.field())
|
||||
elif self.s.startswith("%", self.i):
|
||||
self.i += 1
|
||||
node = ("pct", node)
|
||||
else:
|
||||
return node
|
||||
|
||||
def field(self) -> str:
|
||||
if self.s.startswith("'", self.i):
|
||||
return self.quoted()
|
||||
m = _FIELD.match(self.s, self.i)
|
||||
if not m:
|
||||
self.fail("칸 이름 없음")
|
||||
self.i = m.end()
|
||||
return m.group()
|
||||
|
||||
def quoted(self) -> str:
|
||||
end = self.s.find("'", self.i + 1)
|
||||
if end < 0:
|
||||
self.fail("작은따옴표 안 닫힘")
|
||||
text, self.i = self.s[self.i + 1 : end], end + 1
|
||||
return text
|
||||
|
||||
def primary(self):
|
||||
self.ws()
|
||||
if self.op("("):
|
||||
node = self.expr()
|
||||
self.need(")")
|
||||
return node
|
||||
if self.s.startswith("'", self.i):
|
||||
return ("str", self.quoted())
|
||||
m = _NUM.match(self.s, self.i)
|
||||
if m:
|
||||
self.i = m.end()
|
||||
return ("num", Decimal(m.group()))
|
||||
m = ID.match(self.s, self.i)
|
||||
if not m:
|
||||
self.fail("기호 · 수 없음")
|
||||
self.i = m.end()
|
||||
word = m.group()
|
||||
self.ws()
|
||||
if self.s.startswith("(", self.i):
|
||||
if word not in FUNCS:
|
||||
self.fail(f"모르는 함수 「{word}」")
|
||||
self.i += 1
|
||||
items = [self.expr()]
|
||||
while self.op(","):
|
||||
items.append(self.expr())
|
||||
self.need(")")
|
||||
return ("call", word, items)
|
||||
if word in ("그리고", "또는") or word in FUNCS:
|
||||
self.fail(f"「{word}」 자리 아님")
|
||||
return ("var", word)
|
||||
|
||||
|
||||
def parse(text) -> tuple:
|
||||
"""식 글 → 나무(튜플) · 틀리면 DocError."""
|
||||
if isinstance(text, (int, float, Decimal)) and not isinstance(text, bool):
|
||||
return ("num", Decimal(str(text)))
|
||||
return _Parser(str(text or "")).parse()
|
||||
|
||||
|
||||
def walk(node):
|
||||
yield node
|
||||
kind = node[0]
|
||||
if kind == "bin":
|
||||
yield from walk(node[2])
|
||||
yield from walk(node[3])
|
||||
elif kind in ("neg", "pct", "field"):
|
||||
yield from walk(node[1])
|
||||
elif kind == "call":
|
||||
for x in node[2]:
|
||||
yield from walk(x)
|
||||
|
||||
|
||||
def names(node) -> list[str]:
|
||||
"""식이 부르는 이름(차례 · 겹침 없이) — 칸 이름은 빼고."""
|
||||
out: list[str] = []
|
||||
for n in walk(node):
|
||||
if n[0] == "var" and n[1] not in out:
|
||||
out.append(n[1])
|
||||
return out
|
||||
|
||||
|
||||
# ── 글로 되돌림 ───────────────────────────────────────────────────────
|
||||
def num_text(d) -> str:
|
||||
d = Decimal(str(d))
|
||||
text = format(d, "f")
|
||||
return text
|
||||
|
||||
|
||||
def _prec(node) -> int:
|
||||
if node[0] == "bin":
|
||||
return PREC[node[1]]
|
||||
if node[0] == "neg":
|
||||
return 6
|
||||
return 9
|
||||
|
||||
|
||||
def text_of(node) -> str:
|
||||
"""나무 → 식 글(× ÷ · 나무 묶음 그대로 · 괄호는 꼭 필요한 만큼)."""
|
||||
kind = node[0]
|
||||
if kind == "num":
|
||||
return num_text(node[1])
|
||||
if kind == "str":
|
||||
return f"'{node[1]}'"
|
||||
if kind == "var":
|
||||
return node[1]
|
||||
if kind == "field":
|
||||
base = text_of(node[1])
|
||||
if _prec(node[1]) < 9:
|
||||
base = f"({base})"
|
||||
name = node[2] if _FIELD.fullmatch(node[2]) else f"'{node[2]}'"
|
||||
return f"{base}.{name}"
|
||||
if kind == "pct":
|
||||
inner = text_of(node[1])
|
||||
return f"({inner})%" if _prec(node[1]) < 9 else f"{inner}%"
|
||||
if kind == "neg":
|
||||
inner = text_of(node[1])
|
||||
return f"-({inner})" if node[1][0] == "bin" else f"-{inner}"
|
||||
if kind == "call":
|
||||
return f"{node[1]}({', '.join(text_of(x) for x in node[2])})"
|
||||
_, op, a, b = node
|
||||
mine = PREC[op]
|
||||
left, right = text_of(a), text_of(b)
|
||||
if _prec(a) < mine or (mine == 3 and _prec(a) == 3):
|
||||
left = f"({left})"
|
||||
if _prec(b) <= mine:
|
||||
right = f"({right})"
|
||||
return f"{left} {SHOW.get(op, op)} {right}"
|
||||
|
||||
|
||||
def tidy(text) -> str:
|
||||
"""식 글 다듬기(풀이 → 되돌림) — 못 풀면 그대로."""
|
||||
try:
|
||||
return text_of(parse(text))
|
||||
except DocError:
|
||||
return str(text)
|
||||
|
||||
|
||||
def _swap(node, old: str, new: str):
|
||||
kind = node[0]
|
||||
if kind == "var":
|
||||
return ("var", new) if node[1] == old else node
|
||||
if kind == "bin":
|
||||
return ("bin", node[1], _swap(node[2], old, new), _swap(node[3], old, new))
|
||||
if kind in ("neg", "pct"):
|
||||
return (kind, _swap(node[1], old, new))
|
||||
if kind == "field":
|
||||
return ("field", _swap(node[1], old, new), node[2])
|
||||
if kind == "call":
|
||||
return ("call", node[1], [_swap(x, old, new) for x in node[2]])
|
||||
return node
|
||||
|
||||
|
||||
def rename_text(text, old: str, new: str) -> str:
|
||||
"""식 글 안 이름 바꿈 — 못 푸는 글은 그대로."""
|
||||
if text is None or text == "":
|
||||
return text
|
||||
try:
|
||||
node = parse(text)
|
||||
except DocError:
|
||||
return text
|
||||
return text_of(_swap(node, old, new)) if old in names(node) else text
|
||||
|
||||
|
||||
def holes(text) -> list[str]:
|
||||
return HOLE.findall(str(text or ""))
|
||||
|
||||
|
||||
def link_texts(link: dict):
|
||||
"""연결 안 식 글 자리 — (칸, 열쇠, 글) · 조건 · 넘길 입력."""
|
||||
for slot in ("조건", "입력"):
|
||||
for k, v in (link.get(slot) or {}).items():
|
||||
yield slot, k, v
|
||||
|
||||
|
||||
def link_names(link: dict) -> list[str]:
|
||||
"""연결이 부르는 이름 — 키 · 재료 조건 안 `{이름}` · 지역 · 계약종별 · 조건 · 넘길 입력 식."""
|
||||
out: list[str] = []
|
||||
for text in (link.get("키"), link.get("표"), *(link.get("재료") or {}).values()):
|
||||
out += [h for h in holes(text) if h not in out]
|
||||
for slot in ("지역", "계약종별"):
|
||||
if link.get(slot) and link[slot] not in out:
|
||||
out.append(link[slot])
|
||||
for _, _, text in link_texts(link):
|
||||
try:
|
||||
out += [n for n in names(parse(text)) if n not in out]
|
||||
except DocError:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def rename(doc: dict, old: str, sign: str, low: str = "", up: str = "") -> dict:
|
||||
"""기호 · 첨자 바꿈 — 변수표 · 식 줄 머리 · 모든 식 · 연결 안 이름까지.
|
||||
제자리 고침 · 같은 것 돌려줌."""
|
||||
new = sym_id(sign, low, up)
|
||||
if not good_id(new):
|
||||
raise DocError(f"기호 꼴 아님 「{new}」")
|
||||
if new != old and new in defined(doc):
|
||||
raise DocError(f"같은 기호 있음 「{new}」")
|
||||
for var in doc.get("변수") or []:
|
||||
if var_id(var) == old:
|
||||
var["기호"], var["아래"], var["위"] = sign, low, up
|
||||
link = var.get("연결") or {}
|
||||
for key in ("키", "표"):
|
||||
if link.get(key):
|
||||
link[key] = HOLE.sub(
|
||||
lambda m: "{" + (new if m.group(1) == old else m.group(1)) + "}", link[key]
|
||||
)
|
||||
if isinstance(link.get("재료"), dict):
|
||||
link["재료"] = {
|
||||
k: HOLE.sub(
|
||||
lambda m: "{" + (new if m.group(1) == old else m.group(1)) + "}", str(v)
|
||||
)
|
||||
if isinstance(v, str)
|
||||
else v
|
||||
for k, v in link["재료"].items()
|
||||
}
|
||||
for slot in ("지역", "계약종별"):
|
||||
if link.get(slot) == old:
|
||||
link[slot] = new
|
||||
for slot in ("조건", "입력"):
|
||||
if isinstance(link.get(slot), dict):
|
||||
link[slot] = {k: rename_text(v, old, new) for k, v in link[slot].items()}
|
||||
for line in doc.get("식") or []:
|
||||
if line.get("기호") == old:
|
||||
line["기호"] = new
|
||||
line["식"] = rename_text(line.get("식"), old, new)
|
||||
for line in doc.get("금액") or []:
|
||||
if line.get("기호") == old:
|
||||
line["기호"] = new
|
||||
for k in ("수량", "단가"):
|
||||
line[k] = rename_text(line.get(k), old, new)
|
||||
if doc.get("결과") == old:
|
||||
doc["결과"] = new
|
||||
return doc
|
||||
|
||||
|
||||
# ── 정의 · 검사 ───────────────────────────────────────────────────────
|
||||
def defined(doc: dict) -> dict[str, list[str]]:
|
||||
"""이름 → 정의 자리 목록(「식」 · 「연결」 · 「금액」) — 둘 이상 = 겹침."""
|
||||
out: dict[str, list[str]] = {}
|
||||
for var in doc.get("변수") or []:
|
||||
if (var.get("연결") or {}).get("갈래", "식") != "식":
|
||||
out.setdefault(var_id(var), []).append("연결")
|
||||
for line in doc.get("식") or []:
|
||||
out.setdefault(str(line.get("기호") or ""), []).append("식")
|
||||
for line in doc.get("금액") or []:
|
||||
if line.get("기호"):
|
||||
out.setdefault(str(line["기호"]), []).append("금액")
|
||||
return out
|
||||
|
||||
|
||||
def _mark(kind: str, text: str, where: str = "", sign: str = "") -> dict:
|
||||
return {
|
||||
"갈래": kind,
|
||||
"글": text,
|
||||
**({"자리": where} if where else {}),
|
||||
**({"기호": sign} if sign else {}),
|
||||
}
|
||||
|
||||
|
||||
def check(doc: dict) -> list[dict]:
|
||||
"""정적 검사(셈 없음) — 식 틀림 · 정의 필요 · 겹침 · 순환 · 기호 꼴 · 연결 꼴.
|
||||
금액 줄 · 끝수 꼴도 봄."""
|
||||
out: list[dict] = []
|
||||
defs = defined(doc)
|
||||
money_syms = {str(x["기호"]) for x in doc.get("금액") or [] if x.get("기호")}
|
||||
for name, where in defs.items():
|
||||
if not good_id(name):
|
||||
out.append(_mark("기호 꼴", f"「{name}」 은 기호로 못 씀", sign=name))
|
||||
if len(where) > 1:
|
||||
out.append(
|
||||
_mark("겹침", f"「{name}」 정의가 {' · '.join(where)} 에 둘 이상", sign=name)
|
||||
)
|
||||
uses: dict[str, list[str]] = {}
|
||||
|
||||
def need(text, where: str, money: bool, head: str = "") -> list[str]:
|
||||
try:
|
||||
got = names(parse(text))
|
||||
except DocError as e:
|
||||
out.append(_mark("식 틀림", str(e), where, head))
|
||||
return []
|
||||
for n in got:
|
||||
if n in MONEY_NAMES and not money:
|
||||
out.append(_mark("금액 이름", f"「{n}」 은 금액 줄에서만", where, head))
|
||||
elif n not in defs and n not in BUNDLE and n not in MONEY_NAMES:
|
||||
out.append(_mark("정의 필요", f"「{n}」 정의 필요", where, n))
|
||||
elif n in money_syms and not money:
|
||||
out.append(
|
||||
_mark("금액 이름", f"「{n}」(금액 줄 기호)는 금액 줄에서만", where, head)
|
||||
)
|
||||
return got
|
||||
|
||||
for var in doc.get("변수") or []:
|
||||
link, me = var.get("연결") or {}, var_id(var)
|
||||
kind = link.get("갈래", "식")
|
||||
if kind not in LINKS:
|
||||
out.append(_mark("연결 꼴", f"「{me}」 연결 갈래 「{kind}」", sign=me))
|
||||
continue
|
||||
if kind == "식" and me not in defs:
|
||||
out.append(_mark("정의 필요", f"「{me}」 식 줄 없음", sign=me))
|
||||
got = []
|
||||
for slot, key, text in link_texts(link):
|
||||
got += need(text, f"{me} {slot} {key}", False, me)
|
||||
for h in link_names(link):
|
||||
if h not in got and h not in defs:
|
||||
out.append(_mark("정의 필요", f"「{h}」 정의 필요", me, h))
|
||||
uses[me] = link_names(link)
|
||||
if kind == "선택" and not isinstance(link.get("목록"), list):
|
||||
out.append(_mark("연결 꼴", f"「{me}」 선택 목록 없음", sign=me))
|
||||
if kind == "표" and not (link.get("표") and link.get("칸")):
|
||||
out.append(_mark("연결 꼴", f"「{me}」 표 · 칸 없음", sign=me))
|
||||
if kind == "요소" and not (link.get("키") or link.get("재료")):
|
||||
out.append(_mark("연결 꼴", f"「{me}」 요소 없음", sign=me))
|
||||
if kind == "단가산출" and not link.get("키"):
|
||||
out.append(_mark("연결 꼴", f"「{me}」 단가산출 없음", sign=me))
|
||||
for line in doc.get("식") or []:
|
||||
head = str(line.get("기호") or "")
|
||||
if not head:
|
||||
out.append(_mark("식 틀림", "식 줄 기호 없음"))
|
||||
uses[head] = uses.get(head, []) + need(line.get("식"), head, False, head)
|
||||
for at, line in enumerate(doc.get("금액") or []):
|
||||
where = f"금액 {line.get('이름') or at + 1}"
|
||||
if line.get("비목") not in (*COSTS, "묶음"):
|
||||
out.append(_mark("금액 줄", f"{where} 비목 「{line.get('비목')}」"))
|
||||
for k in ("수량", "단가"):
|
||||
if k == "단가" or line.get(k) not in (None, ""):
|
||||
need(line.get(k), f"{where} {k}", True)
|
||||
if line.get("자리") is not None and not _way(line["자리"]):
|
||||
out.append(_mark("끝수", f"{where} 자리 「{line['자리']}」"))
|
||||
cut = doc.get("끝수") or {}
|
||||
for k in ("줄", "비목", "계"):
|
||||
if cut.get(k) is not None and not _way(cut[k]):
|
||||
out.append(_mark("끝수", f"끝수 {k} 「{cut[k]}」"))
|
||||
if cut.get("끝전") not in (None, *ENDS):
|
||||
out.append(_mark("끝수", f"끝전 「{cut['끝전']}」 — {' · '.join(ENDS)}"))
|
||||
if doc.get("결과") and doc["결과"] not in defs:
|
||||
out.append(_mark("정의 필요", f"결과 「{doc['결과']}」 정의 필요", sign=doc["결과"]))
|
||||
for loop in loops(uses):
|
||||
out.append(_mark("순환", " → ".join([*loop, loop[0]]), sign=loop[0]))
|
||||
return out
|
||||
|
||||
|
||||
def _way(rule) -> bool:
|
||||
return (
|
||||
isinstance(rule, dict)
|
||||
and rule.get("방법") in WAYS
|
||||
and isinstance(rule.get("자리", 0), (int, Decimal))
|
||||
and 0 <= int(rule.get("자리", 0)) <= 6
|
||||
)
|
||||
|
||||
|
||||
def loops(uses: dict[str, list[str]]) -> list[list[str]]:
|
||||
"""돌고 도는 정의(이름 → 부르는 이름들) — 고리마다 한 번."""
|
||||
out, seen, done = [], set(), set()
|
||||
|
||||
def go(name: str, path: list[str]):
|
||||
if name in path:
|
||||
loop = path[path.index(name) :]
|
||||
key = frozenset(loop)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
out.append(loop)
|
||||
return
|
||||
if name in done or name not in uses:
|
||||
return
|
||||
for n in uses[name]:
|
||||
go(n, [*path, name])
|
||||
done.add(name)
|
||||
|
||||
for name in list(uses):
|
||||
go(name, [])
|
||||
return out
|
||||
|
||||
|
||||
def blank(key: str = "", name: str = "", unit: str = "원") -> dict:
|
||||
"""빈 문서 — 빈 화면에서 시작(변수 · 식 · 금액 없음 · 기본 끝수만 적어 둠)."""
|
||||
return {
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": key,
|
||||
"이름": name,
|
||||
"결과단위": unit,
|
||||
"변수": [],
|
||||
"식": [],
|
||||
"금액": [],
|
||||
"끝수": {k: (dict(v) if isinstance(v, dict) else v) for k, v in CUT.items()},
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
# -*- 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 = payload["키"]
|
||||
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
|
||||
@@ -0,0 +1,564 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""옛 M01 로직 줄 → 수식 문서 변환 (PLAN 802-2 · 요구서_664 1 장 3 · 꼴 `_수식문서_틀.md`).
|
||||
|
||||
빌더 `build_수식문서_802_2.py` 가 부름 · 다시 돌리면 같은 결과.
|
||||
- 기호 = 800-1 · 800-5 기호(수식 탭이 보이는 것
|
||||
· `master_equation.export` + `master_equation_sign.settle`) ·
|
||||
못 내보내는 로직은 같은 규칙(`master_symbol`)으로 · 한 문서 안 겹침 = 아래첨자 끝 숫자.
|
||||
- 입력 = 선택(고르기) · 입력 · 값 = 기본 옵션(`master_equation.given_of`) · 중간 = 식 줄
|
||||
· 한 마디 중간
|
||||
(표 찾기 · 요소 · 로직 부름 하나) = 그 기호에 바로 연결 · 표 찾기 · 요소
|
||||
· 로직 부름 = 기호 + 연결.
|
||||
- 호표 = 금액 줄(수량 × 단가 · 단가 = 요소 · 재료 고르기 · 단가산출 묶음)
|
||||
· 덧줄 = 금액 줄(단가 = 식) ·
|
||||
`줄.'이름'` · 앞 덧줄 이름 = 그 금액 줄 기호 · 끝수 결과 · 표시 = 식 안 반올림
|
||||
· 금액 끝수 = 문서 `끝수`.
|
||||
옛 엔진 셈은 안 함(기호 · 이름 보기만 export 를 씀).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from decimal import Decimal
|
||||
|
||||
import master_doc as md
|
||||
import master_equation as meq
|
||||
import master_equation_edit as mee
|
||||
import master_equation_sign as mes
|
||||
import master_formula as mf
|
||||
import master_material as mm
|
||||
import master_symbol as msym
|
||||
|
||||
_ERR = (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError, IndexError)
|
||||
SUBMAX = msym.SUBMAX
|
||||
|
||||
|
||||
def _harvest(master, key: str) -> dict:
|
||||
"""수식 탭 내보내기 기호 — (꼴, 갈래, 식 | 열쇠) → 마디 · 못 내보내면 빈 것."""
|
||||
try:
|
||||
ex = mes.settle(meq.export(master, key))
|
||||
except _ERR:
|
||||
return {}
|
||||
out: dict = {}
|
||||
|
||||
def visit(t):
|
||||
if isinstance(t, list):
|
||||
for x in t:
|
||||
visit(x)
|
||||
elif isinstance(t, dict):
|
||||
if t.get("꼴") in ("변수", "블록"):
|
||||
out.setdefault((t["꼴"], t.get("갈래"), str(t.get("식") or t.get("열쇠"))), t)
|
||||
for k, v in t.items():
|
||||
if k != "나무" and isinstance(v, (dict, list)):
|
||||
visit(v)
|
||||
|
||||
for line in ex.get("줄") or []:
|
||||
visit(line.get("이름식"))
|
||||
if isinstance(line.get("결과"), dict):
|
||||
head = line["결과"]
|
||||
out.setdefault(("머리", line.get("갈래"), str(head.get("열쇠"))), head)
|
||||
return out
|
||||
|
||||
|
||||
def _clean(text: str) -> str:
|
||||
"""기호 · 첨자 글 다듬기 — 낱말 아닌 글 뺌."""
|
||||
return re.sub(r"[^\w′'·]|_", "", str(text or ""))
|
||||
|
||||
|
||||
class _Reg:
|
||||
"""기호 자리 — 열쇠마다 (기호, 아래, 위) 제안 → 겹침 없는 이름."""
|
||||
|
||||
def __init__(self):
|
||||
self.props: dict[tuple, tuple[str, str, str]] = {}
|
||||
self.ids: dict[tuple, str] = {}
|
||||
self.used: set[str] = set()
|
||||
self.notes: list[str] = []
|
||||
|
||||
def want(self, key: tuple, parts: tuple[str, str, str]) -> None:
|
||||
self.props.setdefault(key, parts)
|
||||
|
||||
def settle(self) -> None:
|
||||
for key, (sign, low, up) in self.props.items():
|
||||
if key in self.ids:
|
||||
continue
|
||||
if not md.good_id(md.sym_id(sign, "", "")):
|
||||
fixed = _clean(sign)
|
||||
fixed = fixed if fixed and md.good_id(fixed) else "X"
|
||||
self.notes.append(f"기호 「{sign}」 → 「{fixed}」")
|
||||
sign = fixed
|
||||
n, base = 1, low
|
||||
while md.sym_id(sign, low, up) in self.used or not md.good_id(md.sym_id(sign, low, up)):
|
||||
n += 1
|
||||
low = (base or "")[: SUBMAX - len(str(n))] + str(n)
|
||||
if n > 99:
|
||||
raise ValueError(f"기호 못 가름 {key}")
|
||||
self.used.add(md.sym_id(sign, low, up))
|
||||
self.ids[key] = md.sym_id(sign, low, up)
|
||||
|
||||
def id(self, key: tuple) -> str:
|
||||
return self.ids.get(key, "?")
|
||||
|
||||
|
||||
class _Conv:
|
||||
def __init__(self, master, row: dict, given: dict):
|
||||
self.master, self.row, self.given = master, row, given
|
||||
self.key = str(row["키"])
|
||||
self.found = _harvest(master, self.key)
|
||||
self.table = {v["열쇠"]: v for v in meq.table(row, master)}
|
||||
self.reg = _Reg()
|
||||
self.links: dict[tuple, dict] = {} # 열쇠 → 변수 칸(이름 · 단위 · 연결)
|
||||
self.order: list[tuple] = []
|
||||
self.alias: dict[str, tuple] = {} # 한 마디 중간 식 → ('중간', 이름)
|
||||
self.names = {s["이름"] for s in row.get("입력") or []} | {
|
||||
s["이름"] for s in row.get("중간") or []
|
||||
}
|
||||
self.kinds = {s["이름"]: "입력" for s in row.get("입력") or []}
|
||||
self.kinds |= {s["이름"]: "중간" for s in row.get("중간") or []}
|
||||
self.notes: list[str] = []
|
||||
self.used_lines: set[tuple] = set() # 식이 부른 금액 줄 — ('합' | '덧', 이름)
|
||||
self.quiet = "" # 한 마디 중간 연결만 뜰 때(그 마디 식) — 기호 자리 안 잡음
|
||||
self.last: dict = {}
|
||||
|
||||
# 기호 제안
|
||||
def _look(self, kind: str, raw: str) -> dict | None:
|
||||
for k in (("변수", kind, raw), ("블록", None, raw), ("변수", "요소", raw)):
|
||||
if k in self.found:
|
||||
return self.found[k]
|
||||
return None
|
||||
|
||||
def _var(self, key: tuple, family: str, raw: str, name: str, link: dict, unit: str = "") -> str:
|
||||
if self.quiet and self.quiet == raw:
|
||||
self.quiet, self.last = "", link
|
||||
return "?"
|
||||
if key not in self.links:
|
||||
node = self._look(family, raw) or {}
|
||||
sign = str(node.get("기호") or "")
|
||||
if sign and node.get("기호") != node.get("열쇠"):
|
||||
parts = _merge(sign, str(node.get("아래") or ""), str(node.get("위") or ""))
|
||||
else:
|
||||
fam = {"블록": "결과"}.get(family, family)
|
||||
parts = msym.lookup(name or raw, fam if fam in msym.FAMILY else "요소")
|
||||
self.reg.want(key, parts)
|
||||
self.order.append(key)
|
||||
label = str(node.get("이름") or name or raw)
|
||||
self.links[key] = {
|
||||
"이름": label,
|
||||
"단위": str(node.get("단위") or unit or ""),
|
||||
"연결": link,
|
||||
}
|
||||
self.links[key]["연결"] = link # 두 번째 돌이 = 겹침 가른 이름으로 쓴 연결
|
||||
return self.reg.id(key)
|
||||
|
||||
def own(self, name: str) -> str:
|
||||
"""입력 · 중간 이름 → 기호."""
|
||||
if name not in self.names:
|
||||
self.notes.append(f"모르는 이름 「{name}」")
|
||||
return name if md.good_id(name) else "X_" + _clean(name)
|
||||
return self.reg.id((self.kinds[name], name))
|
||||
|
||||
def holes(self, text):
|
||||
if not isinstance(text, str):
|
||||
return text
|
||||
return md.HOLE.sub(lambda m: "{" + self.own(m.group(1)) + "}", text)
|
||||
|
||||
def _place(self) -> dict:
|
||||
out = {}
|
||||
if mm.REGION in self.kinds:
|
||||
out["지역"] = self.own(mm.REGION)
|
||||
if mm.TARIFF in self.kinds:
|
||||
out["계약종별"] = self.own(mm.TARIFF)
|
||||
return out
|
||||
|
||||
# 식
|
||||
def emit(self, node, money: dict | None = None):
|
||||
kind = node[0]
|
||||
if kind in ("num", "str"):
|
||||
return node
|
||||
if kind == "name":
|
||||
name = node[1]
|
||||
if money is not None and name in md.COSTS:
|
||||
return ("var", name)
|
||||
if money is not None and name in money:
|
||||
self.used_lines.add(money[name])
|
||||
return ("var", self.reg.id(money[name]))
|
||||
return ("var", self.own(name))
|
||||
if kind == "ref":
|
||||
return ("var", self.element(node[1]))
|
||||
if kind == "neg":
|
||||
return ("neg", self.emit(node[1], money))
|
||||
if kind == "bin":
|
||||
return ("bin", node[1], self.emit(node[2], money), self.emit(node[3], money))
|
||||
if kind == "call":
|
||||
return ("call", node[1], [self.emit(x, money) for x in node[2]])
|
||||
if kind == "find":
|
||||
return ("var", self.find(node, None))
|
||||
if kind == "logic":
|
||||
return ("var", self.block(node, None))
|
||||
base, col = node[1], node[2]
|
||||
if base[0] == "find":
|
||||
return ("var", self.find(base, col))
|
||||
if base[0] == "logic":
|
||||
return ("var", self.block(base, col))
|
||||
if base[0] == "ref":
|
||||
return ("var", self.element(base[1], col))
|
||||
if base[0] == "name" and base[1] == "줄" and money is not None:
|
||||
self.used_lines.add(("합", col))
|
||||
return ("var", self.reg.id(("합", col)))
|
||||
if base[0] == "field" and base[1][0] == "name" and base[1][1] in ("번지", "단계"):
|
||||
return ("field", ("field", ("var", base[1][1]), base[2]), col)
|
||||
return ("field", self.emit(base, money), col)
|
||||
|
||||
def text(self, raw, money: dict | None = None) -> str:
|
||||
return md.text_of(self.emit(mf.parse(raw), money))
|
||||
|
||||
# 연결 마디
|
||||
def find(self, node, col) -> str:
|
||||
raw = mee.raw_text(node if col is None else ("field", node, col))
|
||||
if raw in self.alias:
|
||||
return self.reg.id(self.alias[raw])
|
||||
link = {"갈래": "표", "표": self.holes(node[1])}
|
||||
link["조건"] = {k: md.text_of(self.emit(v)) for k, v in node[2]}
|
||||
if col is None:
|
||||
self.notes.append(f"칸 없는 찾기 「{raw}」")
|
||||
else:
|
||||
link["칸"] = col
|
||||
return self._var(("찾기", raw), "찾기", raw, col or raw, link)
|
||||
|
||||
def block(self, node, col) -> str:
|
||||
raw = mee.raw_text(node if col is None else ("field", node, col))
|
||||
if raw in self.alias:
|
||||
return self.reg.id(self.alias[raw])
|
||||
link = {"갈래": "단가산출", "키": self.holes(node[1])}
|
||||
link["입력"] = {k: md.text_of(self.emit(v)) for k, v in node[2]}
|
||||
if col is not None:
|
||||
link["칸"] = col
|
||||
try:
|
||||
sub = self.master.logic(mf.fill(node[1], {})) if "{" not in node[1] else {}
|
||||
except _ERR:
|
||||
sub = {}
|
||||
unit = str(sub.get("결과단위") or "")
|
||||
return self._var(("블록", raw), "블록", raw, str(sub.get("이름") or node[1]), link, unit)
|
||||
|
||||
def element(self, ref: str, col: str | None = None, unit: str | None = None) -> str:
|
||||
raw = ref if col is None else mee.raw_text(("field", ("ref", ref), col))
|
||||
if raw in self.alias and unit is None:
|
||||
return self.reg.id(self.alias[raw])
|
||||
link = {"갈래": "요소", "키": self.holes(ref)}
|
||||
if col is not None:
|
||||
link["칸"] = col
|
||||
if unit is not None:
|
||||
link["단위"] = unit
|
||||
link |= self._place()
|
||||
try:
|
||||
label = self.master.label(ref) if "{" not in ref else ref
|
||||
except _ERR:
|
||||
label = ref
|
||||
return self._var(("요소", raw, unit), "요소", raw, label, link)
|
||||
|
||||
def material(self, at: int, item: dict) -> str:
|
||||
cond = {k: self.holes(v) for k, v in item["요소"].items()}
|
||||
link = {"갈래": "요소", "재료": cond, "단위": item.get("단위") or ""} | self._place()
|
||||
name = str(item.get("이름") or "")
|
||||
return self._var(("재료", at), "요소", f"단가:{name}", f"{name}단가", link)
|
||||
|
||||
def lone(self, raw: str):
|
||||
"""한 마디 식(표 찾기 · 요소 · 로직 부름 하나) — 마디 · 아니면 None."""
|
||||
try:
|
||||
node = mf.parse(raw)
|
||||
except mf.FormulaError:
|
||||
return None
|
||||
if node[0] in ("ref", "logic"):
|
||||
return node
|
||||
if node[0] == "field" and node[1][0] in ("find", "logic", "ref"):
|
||||
return node
|
||||
return None
|
||||
|
||||
|
||||
def _merge(sign: str, low: str, up: str) -> tuple[str, str, str]:
|
||||
"""기호 칸에 첨자까지 든 것(「t_1」 · 「C_m」)을 가름."""
|
||||
if "_" in sign or "^" in sign:
|
||||
head, low0, up0 = md.split_id(sign)
|
||||
if head != sign:
|
||||
return head, low0 + low, up or up0
|
||||
return sign, low, up
|
||||
|
||||
|
||||
def _parts(var: dict) -> tuple[str, str, str]:
|
||||
return _merge(
|
||||
str(var.get("기호") or var["열쇠"]), str(var.get("아래") or ""), str(var.get("위") or "")
|
||||
)
|
||||
|
||||
|
||||
def _line_parts(c: _Conv, name: str) -> tuple[str, str, str]:
|
||||
head = c.found.get(("머리", "호표", name)) or c.found.get(("머리", "덧줄", name)) or {}
|
||||
if head.get("기호") and head.get("기호") != name:
|
||||
return str(head["기호"]), str(head.get("아래") or ""), str(head.get("위") or "")
|
||||
return msym.lookup(name, "합")
|
||||
|
||||
|
||||
def convert(master, row: dict, given: dict | None = None) -> tuple[dict, list[str]]:
|
||||
"""로직 줄 → (수식 문서, 알림). given = 기본 옵션(없으면 `given_of`)."""
|
||||
given = meq.given_of(master, row) if given is None else given
|
||||
c = _Conv(master, row, given)
|
||||
for spec in row.get("입력") or []:
|
||||
c.reg.want(
|
||||
("입력", spec["이름"]), _parts(c.table.get(spec["이름"], {"열쇠": spec["이름"]}))
|
||||
)
|
||||
for step in row.get("중간") or []:
|
||||
c.reg.want(
|
||||
("중간", step["이름"]), _parts(c.table.get(step["이름"], {"열쇠": step["이름"]}))
|
||||
)
|
||||
for item in row.get("호표") or []:
|
||||
c.reg.want(("합", str(item.get("이름") or "")), _line_parts(c, str(item.get("이름") or "")))
|
||||
for item in row.get("덧줄") or []:
|
||||
c.reg.want(("덧", str(item.get("이름") or "")), _line_parts(c, str(item.get("이름") or "")))
|
||||
lone_steps = {}
|
||||
for step in row.get("중간") or []:
|
||||
node = c.lone(step["식"])
|
||||
if node is not None and step.get("표시") is None:
|
||||
raw = mee.raw_text(node)
|
||||
if raw not in c.alias:
|
||||
c.alias[raw] = ("중간", step["이름"])
|
||||
lone_steps[step["이름"]] = node
|
||||
doc = None
|
||||
for _ in range(2): # 한 번 = 기호 모음 · 두 번 = 겹침 가른 이름으로 글
|
||||
c.reg.settle()
|
||||
doc = _build(c, row, given, lone_steps)
|
||||
c.notes += c.reg.notes
|
||||
return doc, sorted(set(c.notes))
|
||||
|
||||
|
||||
def _link_of(c: _Conv, node) -> dict:
|
||||
"""한 마디 중간의 연결 — 찾기 · 요소 · 로직 부름."""
|
||||
alias = dict(c.alias)
|
||||
c.alias, c.quiet, c.last = {}, mee.raw_text(node), {}
|
||||
try:
|
||||
c.emit(node)
|
||||
return dict(c.last)
|
||||
finally:
|
||||
c.alias, c.quiet = alias, ""
|
||||
|
||||
|
||||
def _build(c: _Conv, row: dict, given: dict, lone_steps: dict) -> dict:
|
||||
variables: list[dict] = []
|
||||
for spec in row.get("입력") or []:
|
||||
name = spec["이름"]
|
||||
look = c.table.get(name, {})
|
||||
value = given.get(name)
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
value = Decimal(str(value))
|
||||
if "고르기" in spec:
|
||||
link = {"갈래": "선택", "목록": list(spec["고르기"]), "값": value}
|
||||
else:
|
||||
link = {"갈래": "입력", "값": value}
|
||||
if spec.get("범위"):
|
||||
link["범위"] = list(spec["범위"])
|
||||
variables.append(_var_row(c.reg.id(("입력", name)), name, look, spec, link))
|
||||
lines: list[dict] = []
|
||||
for step in row.get("중간") or []:
|
||||
name = step["이름"]
|
||||
look = c.table.get(name, {})
|
||||
me = c.reg.id(("중간", name))
|
||||
if name in lone_steps:
|
||||
variables.append(_var_row(me, name, look, step, _link_of(c, lone_steps[name])))
|
||||
continue
|
||||
try:
|
||||
text = c.text(step["식"])
|
||||
except mf.FormulaError as e:
|
||||
c.notes.append(f"중간 「{name}」 식 못 읽음 — {e}")
|
||||
text = str(step["식"])
|
||||
if step.get("표시") is not None:
|
||||
text = f"반올림({text}, {int(step['표시'])})"
|
||||
variables.append(_var_row(me, name, look, step, {"갈래": "식"}))
|
||||
lines.append({"기호": me, "식": text})
|
||||
doc = {
|
||||
"꼴": "수식문서",
|
||||
"판": 1,
|
||||
"키": c.key,
|
||||
"이름": row.get("이름") or "",
|
||||
"결과단위": row.get("결과단위") or "",
|
||||
"출처": row.get("출처") or "",
|
||||
}
|
||||
money: list[dict] = []
|
||||
if "결과" in row:
|
||||
head = c.found.get(("머리", "결과", "결과")) or {}
|
||||
sign = head.get("기호") if msym.is_symbol(str(head.get("기호") or "")) else ""
|
||||
parts = (sign, str(head.get("아래") or ""), str(head.get("위") or "")) if sign else None
|
||||
parts = parts or msym.rule(str(row.get("결과단위") or ""), "결과")[:3]
|
||||
c.reg.want(("결과", "결과"), parts)
|
||||
text = c.text(row["결과"]["식"])
|
||||
rule = row.get("끝수")
|
||||
if isinstance(rule, dict) and rule.get("대상") == "결과":
|
||||
text = f"{rule['방법']}({text}, {int(rule.get('자리') or 0)})"
|
||||
me = c.reg.id(("결과", "결과"))
|
||||
variables.append(
|
||||
{
|
||||
"열쇠": "결과",
|
||||
**_split(me),
|
||||
"이름": "결과",
|
||||
"단위": row.get("결과단위") or "",
|
||||
"연결": {"갈래": "식"},
|
||||
}
|
||||
)
|
||||
lines.append({"기호": me, "식": text})
|
||||
doc["결과"] = me
|
||||
else:
|
||||
money = _money(c, row)
|
||||
for key in c.order:
|
||||
if key[0] in ("입력", "중간") or key in [("결과", "결과")]:
|
||||
continue
|
||||
if any(alias == key for alias in c.alias.values()):
|
||||
continue
|
||||
one = c.links[key]
|
||||
me = c.reg.id(key)
|
||||
variables.append(
|
||||
{
|
||||
"열쇠": me,
|
||||
**_split(me),
|
||||
"이름": one["이름"],
|
||||
"단위": one["단위"],
|
||||
"연결": one["연결"],
|
||||
}
|
||||
)
|
||||
doc |= {"변수": variables, "식": lines, "금액": money}
|
||||
cut = {k: (dict(v) if isinstance(v, dict) else v) for k, v in md.CUT.items()}
|
||||
rule = row.get("끝수")
|
||||
if isinstance(rule, dict) and rule.get("대상") == "계":
|
||||
cut["계"] = {"방법": rule["방법"], "자리": int(rule.get("자리") or 0)}
|
||||
if row.get("끝전"):
|
||||
cut["끝전"] = row["끝전"]
|
||||
doc["끝수"] = cut
|
||||
for k in ("단계", "조정"):
|
||||
if row.get(k):
|
||||
doc[k] = row[k]
|
||||
return doc
|
||||
|
||||
|
||||
def _split(me: str) -> dict:
|
||||
sign, low, up = md.split_id(me)
|
||||
return {"기호": sign, "아래": low, "위": up}
|
||||
|
||||
|
||||
def _var_row(me: str, name: str, look: dict, spec: dict, link: dict) -> dict:
|
||||
out = {
|
||||
"열쇠": name,
|
||||
**_split(me),
|
||||
"이름": look.get("이름") or name,
|
||||
"단위": spec.get("단위") or "",
|
||||
}
|
||||
for k in ("설명", "출처"):
|
||||
if spec.get(k):
|
||||
out[k] = spec[k]
|
||||
out["연결"] = link
|
||||
return out
|
||||
|
||||
|
||||
def _hand(c: _Conv, at: int, item: dict) -> str:
|
||||
"""손 값 줄 — 값묶음(노 · 재 · 경) 또는 손 단가 = 숫자 연결."""
|
||||
name = str(item.get("이름") or "")
|
||||
given = item.get("값묶음")
|
||||
if given:
|
||||
if set(given) < set(md.COSTS):
|
||||
c.notes.append(f"호표 「{name}」 값묶음 일부 — 빠진 비목 0")
|
||||
value = {k: Decimal(str(given.get(k) or 0)) for k in md.COSTS}
|
||||
else:
|
||||
value = Decimal(str(item["단가"]))
|
||||
link = {"갈래": "숫자", "값": value}
|
||||
return c._var(("손", at), "요소", f"단가:{name}", f"{name}단가", link)
|
||||
|
||||
|
||||
def _group_name(items: list[dict]) -> str:
|
||||
kinds = []
|
||||
for x in items:
|
||||
k = x.get("종류") or str(x.get("이름") or "")
|
||||
if k not in kinds:
|
||||
kinds.append(k)
|
||||
return " · ".join(kinds)
|
||||
|
||||
|
||||
def _money(c: _Conv, row: dict) -> list[dict]:
|
||||
items, adds = row.get("호표") or [], row.get("덧줄") or []
|
||||
out: list[dict] = []
|
||||
for at, item in enumerate(items):
|
||||
name = str(item.get("이름") or "")
|
||||
line = {"이름": name}
|
||||
if item.get("규격"):
|
||||
line["규격"] = item["규격"]
|
||||
line["단위"] = item.get("단위") or ""
|
||||
try:
|
||||
qty = c.text(item["수량"])
|
||||
except mf.FormulaError as e:
|
||||
c.notes.append(f"호표 「{name}」 수량 못 읽음 — {e}")
|
||||
qty = str(item["수량"])
|
||||
kind = item.get("종류")
|
||||
if item.get("값묶음") or item.get("단가") is not None: # M02 손 값(값묶음 · 손 단가)
|
||||
price, line["비목"] = _hand(c, at, item), item.get("비목") or "묶음"
|
||||
if line["비목"] != "묶음" and item.get("값묶음"):
|
||||
price = f"{price}.계"
|
||||
elif kind == "로직":
|
||||
node = mf.parse(str(item["요소"]))
|
||||
price = md.text_of(c.emit(node))
|
||||
bundle = node[0] == "logic"
|
||||
if "비목" in item:
|
||||
line["비목"] = item["비목"]
|
||||
if bundle:
|
||||
price = f"{price}.계"
|
||||
else:
|
||||
line["비목"] = "묶음"
|
||||
elif isinstance(item.get("요소"), dict):
|
||||
line["비목"] = item["비목"]
|
||||
price = c.material(at, item)
|
||||
else:
|
||||
line["비목"] = item["비목"]
|
||||
ref = str(
|
||||
item["요소"]
|
||||
) # 품셈재료(MP)만 줄 단위로 값이 갈림 — 그 밖은 식 속 요소와 한 기호
|
||||
price = c.element(
|
||||
ref, None, str(item.get("단위") or "") if ref[:2] == mm.LINKED else None
|
||||
)
|
||||
line |= {"수량": qty, "단가": price}
|
||||
for k in ("번지", "합밖", "자리", "출처"):
|
||||
if k in item:
|
||||
line[k] = item[k]
|
||||
out.append(line)
|
||||
seen: dict[str, tuple] = {}
|
||||
for add in adds:
|
||||
name = str(add.get("이름") or "")
|
||||
try:
|
||||
price = c.text(add["식"], dict(seen))
|
||||
except mf.FormulaError as e:
|
||||
c.notes.append(f"덧줄 「{name}」 식 못 읽음 — {e}")
|
||||
price = str(add["식"])
|
||||
line = {"이름": name, "단위": "식", "비목": add["비목"], "단가": price}
|
||||
for k in ("번지", "합밖", "자리", "출처"):
|
||||
if k in add:
|
||||
line[k] = add[k]
|
||||
out.append(line)
|
||||
seen[name] = ("덧", name)
|
||||
last = {
|
||||
str(x.get("이름") or ""): i for i, x in enumerate(items)
|
||||
} # 같은 이름 = 뒤 줄(옛 `줄` 묶음)
|
||||
for i, line in enumerate(out):
|
||||
key = ("합", line["이름"]) if i < len(items) else ("덧", line["이름"])
|
||||
if key in c.used_lines and (i >= len(items) or last[line["이름"]] == i):
|
||||
line["기호"] = c.reg.id(key)
|
||||
_groups(out, [*items, *adds])
|
||||
return out
|
||||
|
||||
|
||||
def _groups(out: list[dict], items: list[dict]) -> None:
|
||||
"""묶음 — 단가산출 부름 줄마다 한 묶음 · 그 사이 다른 줄은 이어진 것끼리 · 묶음 하나면 안 둠."""
|
||||
runs: list[tuple[str, list[int]]] = []
|
||||
for i, item in enumerate(items):
|
||||
if item.get("종류") == "로직":
|
||||
runs.append((str(item.get("이름") or ""), [i]))
|
||||
elif runs and runs[-1][0] == "" and items[runs[-1][1][-1]].get("종류") != "로직":
|
||||
runs[-1][1].append(i)
|
||||
else:
|
||||
runs.append(("", [i]))
|
||||
if len(runs) < 2:
|
||||
return
|
||||
for name, members in runs:
|
||||
label = name or _group_name([items[i] for i in members])
|
||||
for i in members:
|
||||
out[i]["묶음"] = label
|
||||
@@ -0,0 +1,229 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""수식 문서 연결 풀이 — M01 마스터(PLAN 802-1 · 꼴 `_수식문서_틀.md` 3 장).
|
||||
|
||||
`master_doc_calc.calc(doc, Links(...))` 가 부름. 셈 엔진(`master_formula.run`
|
||||
· `evaluate`)은 안 부름 —
|
||||
마스터 색인(`Master`) · 요소 값(준용 · 자재 고르기 · 단위 환산 = `master_material`
|
||||
· `master_pick`)만 씀.
|
||||
표 찾기(범위 규칙 · 여러 줄 가름)는 여기
|
||||
· TS 짝 = `common_util/common_util_formula_doc_calc.ts` `findRow`.
|
||||
수식 문서 저장 자리 = `resources/master_data/수식문서/수식_<옛 로직 파일 꼬리>.json`
|
||||
(로직 파일마다 한 벌).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import master_doc as md
|
||||
import master_doc_calc as mc
|
||||
import master_formula as mf
|
||||
import master_material as mm
|
||||
import master_pick as mp
|
||||
|
||||
FOLDER = Path(__file__).resolve().parents[1] / "수식문서"
|
||||
_ARITH_OK = ("num", "bin", "neg")
|
||||
|
||||
|
||||
def _arith(text: str):
|
||||
"""표 칸의 분수 · 식 글(「1/1.30」) → 수 · 아니면 글 그대로."""
|
||||
try:
|
||||
node = md.parse(text)
|
||||
except md.DocError:
|
||||
return text
|
||||
if any(n[0] not in _ARITH_OK for n in md.walk(node)):
|
||||
return text
|
||||
|
||||
def ev(n):
|
||||
if n[0] == "num":
|
||||
return n[1]
|
||||
if n[0] == "neg":
|
||||
return -ev(n[1])
|
||||
a, b = ev(n[2]), ev(n[3])
|
||||
return {"+": a + b, "-": a - b, "*": a * b}.get(n[1]) if n[1] != "/" else a / b
|
||||
|
||||
try:
|
||||
return ev(node)
|
||||
except ArithmeticError:
|
||||
return text
|
||||
|
||||
|
||||
def _cell(value, col: str):
|
||||
if value is None:
|
||||
raise mc.Gap(f"칸 「{col}」 값 없음 — 관리자가 채울 값")
|
||||
if isinstance(value, list):
|
||||
raise mc.Broken(f"칸 「{col}」 값이 범위 — 입력으로 받아야 함")
|
||||
if isinstance(value, str):
|
||||
return _arith(value)
|
||||
return value
|
||||
|
||||
|
||||
# ── 표 찾기 ───────────────────────────────────────────────────────────
|
||||
def _match(table: dict, row: dict, cond: str, want) -> bool:
|
||||
if cond not in row: # 빈 조건 칸 = 무엇이든 맞음
|
||||
return True
|
||||
have = row[cond]
|
||||
if table.get("조건", {}).get(cond) != "범위":
|
||||
return have == want
|
||||
low, high = have
|
||||
rule, *more = str(table.get("범위규칙") or "초과·이하").split(" · ")
|
||||
rule = rule.split(" (")[0]
|
||||
if rule in ("이상·이하",):
|
||||
return (low is None or want >= low) and (high is None or want <= high)
|
||||
if rule in ("초과·이하", "이하"):
|
||||
return (low is None or want > low) and (high is None or want <= high)
|
||||
if rule == "이상·미만":
|
||||
top = max(r[cond][1] for r in table["줄"] if r.get(cond) and r[cond][1] is not None)
|
||||
closed = any("이하 포함" in x for x in more) and high == top
|
||||
return (low is None or want >= low) and (
|
||||
high is None or want < high or (closed and want == high)
|
||||
)
|
||||
raise mc.Broken(f"모르는 범위규칙 「{table.get('범위규칙')}」")
|
||||
|
||||
|
||||
def find(table: dict, conds: dict, want: str | None = None) -> dict:
|
||||
"""조건에 맞는 한 줄 — 「이하」 · 「이상·이하」 는 위 끝이 가장 작은 줄.
|
||||
여럿이면 찾는 칸 있는 줄."""
|
||||
for cond in conds:
|
||||
if cond not in table.get("조건", {}):
|
||||
raise mc.Broken(f"표 {table.get('키')} 에 없는 조건 「{cond}」")
|
||||
try:
|
||||
hits = [r for r in table["줄"] if all(_match(table, r, c, v) for c, v in conds.items())]
|
||||
except TypeError:
|
||||
raise mc.Broken(f"표 {table.get('키')} 범위 조건에 수 아닌 값") from None
|
||||
if len(hits) > 1 and str(table.get("범위규칙") or "").split(" · ")[0] in ("이하", "이상·이하"):
|
||||
for c in conds:
|
||||
ups = [r[c][1] for r in hits if table["조건"].get(c) == "범위" and r.get(c)]
|
||||
if ups and None not in ups:
|
||||
hits = [r for r in hits if not r.get(c) or r[c][1] == min(ups)]
|
||||
if len(hits) > 1 and want:
|
||||
hits = [r for r in hits if want in r]
|
||||
if len(hits) != 1:
|
||||
what = " · ".join(f"{c} {mc.plain_text(v)}" for c, v in conds.items())
|
||||
where = f"표 {table.get('키')}({table.get('원문번호')})"
|
||||
raise mc.Gap(f"{where}에 {what} 인 줄이 " + ("없음" if not hits else f"{len(hits)}개"))
|
||||
return hits[0]
|
||||
|
||||
|
||||
# ── 연결 ──────────────────────────────────────────────────────────────
|
||||
class Links:
|
||||
"""M01 마스터 연결 — 표 · 요소 · 단가산출 문서(`docs` = {키: 문서} · 없으면 저장 폴더)."""
|
||||
|
||||
def __init__(self, master: mf.Master, docs: dict[str, dict] | None = None):
|
||||
self.master = master
|
||||
self.docs = load_all() if docs is None else docs
|
||||
|
||||
def _get(self, ref: str) -> dict:
|
||||
try:
|
||||
return self.master.get(ref)
|
||||
except mf.FormulaError as e:
|
||||
raise mc.Broken(str(e)) from None
|
||||
|
||||
def table(self, p: dict):
|
||||
table = self._get(p["표"])
|
||||
if "줄" not in table or "조건" not in table:
|
||||
raise mc.Broken(f"「{p['표']}」 표 아님")
|
||||
row = find(table, p.get("조건") or {}, p.get("칸"))
|
||||
if p["칸"] not in row: # 원문 「-」 = 그 줄에 값 칸 없음
|
||||
if p["칸"] not in (table.get("값칸") or {}) and not any(
|
||||
p["칸"] in r for r in table["줄"]
|
||||
):
|
||||
raise mc.Broken(f"표 {table.get('키')} 에 칸 「{p['칸']}」 없음")
|
||||
raise mc.Gap(f"표 {table.get('키')} 고른 줄에 「{p['칸']}」 값 없음")
|
||||
return _cell(row[p["칸"]], p["칸"])
|
||||
|
||||
def element(self, p: dict):
|
||||
env = {}
|
||||
if p.get("지역") is not None:
|
||||
env[mm.REGION] = p["지역"]
|
||||
if p.get("계약종별") is not None:
|
||||
env[mm.TARIFF] = p["계약종별"]
|
||||
if p.get("재료") is not None or p.get(
|
||||
"단위"
|
||||
): # 금액 줄 단가 — 고르기 · 단위 환산 · 값 없음 = 빈 줄
|
||||
ref = p.get("재료") if p.get("재료") is not None else p["키"]
|
||||
price, _, why, _ = mp.priced(self.master, {"단위": p.get("단위")}, ref, env, None)
|
||||
if price is None:
|
||||
raise mc.Gap(why or "단가 없음")
|
||||
if isinstance(price, (dict, list)):
|
||||
raise mc.Broken("단가가 묶음 · 범위")
|
||||
return price
|
||||
self._get(p["키"])
|
||||
try:
|
||||
value, _ = mm.element(self.master, p["키"], env)
|
||||
except mf.FormulaError as e:
|
||||
raise mc.Broken(str(e)) from None
|
||||
col = p.get("칸")
|
||||
if col:
|
||||
if not isinstance(value, dict):
|
||||
raise mc.Broken(f"「{p['키']}」 값에 칸 없음")
|
||||
if col not in value:
|
||||
raise mc.Broken(f"「{p['키']}」 칸 「{col}」 없음")
|
||||
return _cell(value[col], col)
|
||||
if isinstance(value, dict):
|
||||
raise mc.Broken(f"「{p['키']}」 값이 묶음 — 칸 필요")
|
||||
return _cell(value, self.master.label(p["키"]))
|
||||
|
||||
def doc(self, key: str) -> dict | None:
|
||||
if key in self.docs:
|
||||
return self.docs[key]
|
||||
try:
|
||||
row = self.master.get(key)
|
||||
except mf.FormulaError:
|
||||
return None
|
||||
return self.docs.get(str(row.get("키")))
|
||||
|
||||
|
||||
# ── 저장 ──────────────────────────────────────────────────────────────
|
||||
def file_of(logic_file: str) -> str:
|
||||
"""옛 로직 파일 이름 → 수식 문서 파일 이름(`로직_X.json` → `수식_X.json`)."""
|
||||
return "수식_" + logic_file.split("_", 1)[1]
|
||||
|
||||
|
||||
def load_file(path: Path) -> dict:
|
||||
return json.loads(path.read_text(encoding="utf-8"), parse_float=Decimal, parse_int=Decimal)
|
||||
|
||||
|
||||
def load_all(folder: Path = FOLDER) -> dict[str, dict]:
|
||||
"""저장 폴더 문서 전부 — {키: 문서}."""
|
||||
out: dict[str, dict] = {}
|
||||
for path in sorted(folder.glob("수식_*.json")):
|
||||
for doc in load_file(path).get("줄") or []:
|
||||
out[str(doc.get("키"))] = doc
|
||||
return out
|
||||
|
||||
|
||||
def _norm(v):
|
||||
if isinstance(v, Decimal):
|
||||
if v == v.to_integral_value() and "E" not in str(v) and "." not in str(v):
|
||||
return int(v)
|
||||
return float(v)
|
||||
if isinstance(v, dict):
|
||||
return {k: _norm(x) for k, x in v.items()}
|
||||
if isinstance(v, list):
|
||||
return [_norm(x) for x in v]
|
||||
return v
|
||||
|
||||
|
||||
def _enc(v, depth: int, pad: str) -> str:
|
||||
"""깊이 4(변수 · 식 · 금액 줄 하나)부터 한 줄."""
|
||||
one = json.dumps(v, ensure_ascii=False, separators=(", ", ": "))
|
||||
if depth >= 4 or not isinstance(v, (dict, list)) or not v:
|
||||
return one
|
||||
inner, cut = pad + " ", ",\n"
|
||||
if isinstance(v, list):
|
||||
body = cut.join(inner + _enc(x, depth + 1, inner) for x in v)
|
||||
return "[\n" + body + "\n" + pad + "]"
|
||||
body = cut.join(
|
||||
inner + json.dumps(k, ensure_ascii=False) + ": " + _enc(x, depth + 1, inner)
|
||||
for k, x in v.items()
|
||||
)
|
||||
return "{\n" + body + "\n" + pad + "}"
|
||||
|
||||
|
||||
def dump(data) -> str:
|
||||
"""저장 글 — UTF-8 · LF · 들여쓰기 2칸 · 변수 · 식 · 금액 줄 하나 = 한 줄.
|
||||
수는 군더더기 없이."""
|
||||
return _enc(_norm(data), 0, "") + "\n"
|
||||
Reference in New Issue
Block a user