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()},
|
||||
}
|
||||
Reference in New Issue
Block a user