Files
Aislo/M02_MasterTemplete/M02_Sangeun_Formula.py
eomsangdonandClaude Opus 5.5 bb39bacd26 feat(792-1): 단가산출 식 칸 TS 캐시 모델 · 지킴 시험 · 같은 내용 지문에 식 칸
- 새 logic/M02_Sangeun_Formula_Model.ts — setFormula · addVar · editVar · dropVar(서버 clean 이 받는 꼴)
- Const_Model restoreM01 · coverKinds 에 식덮개(서버 restore · kinds 거울)
- M02_Library_Same SK 지문에 상수 · 표값 · 식 덮개 · 변수줄(식만 다른 SK 를 같은 것 409 로 막던 흠 · 빈 칸 = 옛 지문 그대로)
- Formula 검사 길 0 나눔 알림이 못 읽는 식에서 멈추던 흠
- 새 test_792_1_sangeun_formula.py 13 — 할증율 변수 줄 51,501 · 합판거푸집 공구손료 식 51,802 · 수량식 · 이름 꼴 왕복 · 검사 까닭 · 상한 · 순환 · 옛 SK · 저장 400 · B09 굳힘 · TS

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:29:40 +09:00

620 lines
27 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.
"""M02 단가산출(SK) 사용자 식 칸(PLAN 792-1 · 요구서_652 1 장 3 · _분석_650-H3 E4) — 변수 줄 ·
식 덮개 · 더한줄 수량식 · 이름 꼴 ↔ 엔진 식 · 검사(「덮개 = 숫자만」 원칙을 바꿈).
짝 = `logic/M02_Sangeun_Formula_Model.ts`(캐시 고치기 — 서버 짝 함수 없음 · 꼴만 같음) ·
시험 `resources/tester/test_792_1_sangeun_formula.py`.
문서 덧칸(없으면 안 둠 — 옛 SK 그대로 · 저장 = 엔진 식 · 이름 꼴 글도 받아 서버가 번역):
변수줄 [{이름, 식, 표시?, 자리}] 엔진 중간 줄로 끼움 · 자리 = 그 뒤 중간 `mid:이름`
("" = 맨 위 · 없는 자리 = 중간 끝 + 짝잃음) · 표시 = 엔진 `표시`(0~6 반올림)
식덮개 {자리: 식} 중간 `mid:이름` 식 · 호표 cid 수량 · 덧줄 cid 식
(원래 식 = 바탕 · M01 바뀜 = 790-2 M01판)
더한줄[k].수량식 인력 · 재료 · 기계 · 참조 · 고정(율 줄 빼고) · `수량` = 보기 값
이름 꼴 — `×` `÷`(= `*` `/`) · `[노임 형틀목공]`(요소 · 겹치면 키 덧붙임) · `[별칭]`(바탕 줄
`찾기` · `로직` 부르기 = `master_copy.aliases` · 별칭표는 바탕 줄에서만 지음 = 사용자 고침에
안 흔들림) · 맨 이름 = 입력 · 중간 · 변수 · 덧줄 · 비목 · 엔진 날식도 그대로 받음.
셈 = `settle`(바탕 줄 + 식 칸 → 엔진 줄 · `M02_Sangeun_Const.formulas` 첫머리 → `patched` →
`_Over` 로 같은 엔진 한 벌 · B09 굳힘 `GX9#####` 도 그 줄) · 틀린 식 = 그 자리만 M01 식으로 +
짝잃음(셈 길) · 저장(`strict`) = 400.
보안 — eval 없음(엔진 파서만) · 식 길이 `LONG` · 괄호 깊이 `DEEP` · 변수 줄 `MANY` ·
로직 부르기 깊이 20(엔진).
"""
from __future__ import annotations
import functools
import json
import re
from typing import Any
from M02_MasterTemplete import M02_Sangeun_Doc as sd
FIELDS = ("변수줄", "식덮개")
LONG = 300 # 식 글 길이
DEEP = 12 # 괄호 깊이
MANY = 100 # 변수 줄 수
FIELD = {"중간": "식", "호표": "수량", "덧줄": "식"}
WORD = {
"LB": "노임",
"EQ": "기계",
"MT": "자재",
"MP": "자재",
"MO": "유가",
"FX": "환율",
"RT": "요율",
}
_VAR = re.compile(r"^[A-Za-z가-힣_][\w가-힣]{0,19}$")
_ADD = re.compile(r"^add:#(\d+)$")
_SPOT = re.compile(r"^(mid:.+|(labor|material|machine|logic|pct):.*#\d+)$")
def _eng() -> Any:
from M02_MasterTemplete import M02_Labor_Doc as labor
return labor.m01.cm.mf
def _whole() -> Any:
from M01_MasterData import M01_MasterData_Store as m01
return m01.loaded()[1]
# ── 꼴 검사(문서 · `sd.clean` 끝) ─────────────────────────────────────
def limits(text: str) -> list[str]:
"""식 글 상한 — 길이 · 괄호 깊이(파서 앞 · 깊은 괄호 = 서버 500 막음)."""
out, depth, top, quoted = [], 0, 0, False
if len(text) > LONG:
out.append(f"식이 {LONG} 자를 넘음")
for ch in text:
if ch == "'":
quoted = not quoted
elif not quoted and ch in "([":
depth += 1
top = max(top, depth)
elif not quoted and ch in ")]":
depth -= 1
if top > DEEP:
out.append(f"괄호가 {DEEP} 겹을 넘음")
return out
def _text(raw: Any, where: str) -> str:
got = str(raw if raw is not None else "").strip()
if not got:
raise sd.DocError(f"{where} · 식이 빔")
if bad := limits(got):
raise sd.DocError(f"{where} · {bad[0]}")
return got
def clean(raw: dict, out: dict) -> dict:
"""`sd.clean` 끝(790-2 `clean_more` 뒤) — 변수줄 · 식덮개 · 더한줄 수량식 꼴 · 상한 · 겹침."""
lines = raw.get("변수줄") or []
if not isinstance(lines, list) or len(lines) > MANY:
raise sd.DocError(f"변수줄 · {MANY} 줄까지 목록")
got, seen = [], set()
for at, one in enumerate(lines, 1):
one = one if isinstance(one, dict) else {}
name, where = str(one.get("이름") or "").strip(), f"변수줄 {at}째"
if not _VAR.match(name) or _eng().mk.KEY.fullmatch(name):
raise sd.DocError(f"{where} · 이름 꼴 아님 「{name}」(글자로 시작 · 20 자까지)")
if name in seen:
raise sd.DocError(f"{where} · 같은 이름 「{name}」")
seen.add(name)
line = {"이름": name, "식": _text(one.get("식"), f"{where} 「{name}」")}
if one.get("표시") not in (None, ""):
show = sd._num(one["표시"], f"{where} 표시")
if show != int(show) or not 0 <= show <= 6:
raise sd.DocError(f"{where} · 표시는 0~6 자리")
line["표시"] = int(show)
spot = str(one.get("자리") or "")
if spot and not spot.startswith("mid:"):
raise sd.DocError(f'{where} · 자리는 중간 `mid:이름` · 맨 위 ""')
got.append(line | {"자리": spot})
if got:
out["변수줄"] = got
covers = raw.get("식덮개") or {}
if not isinstance(covers, dict):
raise sd.DocError("식덮개 · 묶음 아님")
fx = {}
for spot, text in covers.items():
if not isinstance(spot, str) or not _SPOT.match(spot):
raise sd.DocError(f"식덮개 · 자리 꼴 아님 「{spot}」")
if spot in (out.get("상수덮개") or {}) or spot in (out.get("표값덮개") or {}):
raise sd.DocError(f"식덮개 「{spot}」 · 상수 · 표값 덮개와 같이 못 둠")
if "qty" in ((out.get("덮개") or {}).get(spot) or {}):
raise sd.DocError(f"식덮개 「{spot}」 · 품 덮개(qty)와 같이 못 둠 — 식에 넣을 것")
fx[spot] = _text(text, f"식덮개 「{spot}」")
if fx:
out["식덮개"] = fx
adds = raw.get("더한줄") or []
for k, one in enumerate(out.get("더한줄") or []):
text = adds[k].get("수량식") if k < len(adds) and isinstance(adds[k], dict) else None
if text in (None, ""):
continue
where = f"더한줄 {k + 1}째 수량식"
if sd.is_rate(one) or one.get("종류") == "로직":
raise sd.DocError(f"{where} · 율 줄 · 로직 줄은 수만")
one["수량식"] = _text(text, where)
return out
def has(doc: Any) -> bool:
"""식 칸을 쓰는 문서."""
if not isinstance(doc, dict):
return False
adds = [x for x in doc.get("더한줄") or [] if isinstance(x, dict)]
return any(doc.get(k) for k in FIELDS) or any(x.get("수량식") for x in adds)
# ── 이름 꼴 ↔ 엔진 식 ────────────────────────────────────────────────
@functools.lru_cache(maxsize=2)
def _labels(whole: Any) -> tuple[dict, dict]:
"""(키 → 이름 꼴 글, 글 → 키) — 같은 글이 여럿이면 글에 키를 덧붙임."""
base: dict[str, str] = {}
for tid, word in WORD.items():
for key, row in (whole.index.get(tid) or {}).items():
words = (word, str(row.get("이름") or ""), str(row.get("규격") or ""))
base[key] = re.sub(r"\s+", " ", re.sub(r"[\[\]]", "", " ".join(words))).strip()
count: dict[str, int] = {}
for label in base.values():
count[label] = count.get(label, 0) + 1
label_of = {k: v if count[v] == 1 else f"{v} {k}" for k, v in base.items()}
return label_of, {v: k for k, v in label_of.items()}
def _mc() -> Any:
_eng() # 엔진 길(scripts)이 sys.path 에 깔림
import master_copy
return master_copy
@functools.lru_cache(maxsize=512)
def _alias_of(raw: str) -> tuple[tuple[str, str], ...]:
"""바탕 줄 별칭표 ((이름, 원문)) — 줄 글마다 한 번."""
return tuple((a["이름"], a["원문"]) for a in _mc().aliases(json.loads(raw), _whole()))
def aliases(row: dict) -> dict[str, str]:
"""{별칭: 원문 부르기}(바탕 줄 · `master_copy.aliases`)."""
return dict(_alias_of(json.dumps(row, ensure_ascii=False, sort_keys=True, default=str)))
@functools.cache
def _catch() -> type:
"""엔진 파서 + 요소 키 · 부르기 글자 자리 기록."""
mf = _eng()
class Catch(mf._Parser):
def __init__(self, text: str) -> None:
super().__init__(text)
self.refs: list = []
self.calls: list = []
def primary(self): # noqa: ANN202
self.ws()
at = self.i
node = super().primary()
if node[0] == "ref" and mf.mk.KEY.fullmatch(node[1]):
self.refs.append((at, self.i, node[1]))
elif node[0] in ("find", "logic"):
self.calls.append((at, self.i))
return node
return Catch
def _spots(text: str) -> tuple[list, list] | None:
"""(요소 키 자리, 바깥 부르기 자리) · 못 읽음 = None."""
mf, p = _eng(), _catch()(text)
try:
p.parse()
except (mf.FormulaError, RecursionError):
return None
calls = [
c for c in p.calls if not any(o[0] <= c[0] and c[1] <= o[1] and o != c for o in p.calls)
]
return p.refs, calls
def to_name(text: str, row: dict) -> str:
"""엔진 식 → 이름 꼴(`*` → `×` · 요소 = `[노임 …]` · 바탕 별칭 부르기 = `[별칭]`)."""
got = _spots(text)
if got is None:
return text
refs, calls = got
label_of = _labels(_whole())[0]
raw_to = {re.sub(r"\s+", " ", v): k for k, v in aliases(row).items()}
cuts = []
for a, b in calls:
name = raw_to.get(re.sub(r"\s+", " ", text[a:b]))
if name:
cuts.append((a, b, f"[{name}]"))
for a, b, key in refs:
if key in label_of and not any(x <= a and b <= y for x, y in calls):
cuts.append((a, b, f"[{label_of[key]}]"))
out, at = [], 0
for a, b, word in sorted(cuts):
out += [_times(text[at:a]), word]
at = b
return "".join(out) + _times(text[at:])
def _times(part: str) -> str:
"""따옴표 밖 `*` → `×`."""
bits = part.split("'")
return "'".join(b.replace("*", "×") if i % 2 == 0 else b for i, b in enumerate(bits))
def to_engine(text: str, row: dict) -> tuple[str, list[str]]:
"""이름 꼴 → (엔진 식, 까닭) — `×` `÷` · `[…]` = 키 · 바탕 별칭 · 요소 글 · 맨 별칭 이름."""
names = aliases(row)
find = _labels(_whole())[1]
mk = _eng().mk
out, why, at, quoted = [], [], 0, False
while at < len(text):
ch = text[at]
if ch == "'":
quoted = not quoted
if quoted or ch not in "×÷[]":
out.append(ch)
at += 1
continue
if ch in "×÷":
out.append("*" if ch == "×" else "/")
at += 1
continue
end = text.find("]", at)
if ch == "]" or end < 0:
return text, ["대괄호 짝 안 맞음"]
word = re.sub(r"\s+", " ", text[at + 1 : end]).strip()
keys = mk.KEY.findall(word)
if word in names:
out.append(names[word])
elif word in find:
out.append(find[word])
elif keys and len(keys) == 1:
out.append(keys[0])
else:
why.append(f"없는 이름 「[{word}]」")
at = end + 1
got = "".join(out)
taken = _names(row)
bare = {k: v for k, v in names.items() if k not in taken}
return (_unmask(got, bare) if bare else got), why
def _unmask(text: str, raw_of: dict) -> str:
return _mc()._unmask(text, raw_of)
def _names(row: dict) -> set[str]:
return {str(x.get("이름")) for k in ("입력", "중간", "덧줄") for x in row.get(k) or []}
# ── 바탕 줄 + 식 칸 → 엔진 줄 ──────────────────────────────────────────
def _places(row: dict) -> dict[str, tuple[str, int]]:
from M02_MasterTemplete import M02_Sangeun_Const as const
return const.places(row)
def _bad(spot: str, why: str) -> dict:
return {"cid": spot, "까닭": f"식 — {why}"}
def settle(row: dict, doc: dict, strict: bool = False) -> tuple[dict, list[dict], dict]:
"""바탕 줄 + 식 칸 → (엔진 줄, 틀린 자리 [{cid, 까닭}], 엔진 식으로 바꾼 문서) · 틀린 자리 =
M01 식(변수 = 뺌 · 수량식 = 수) · `strict` = 하나라도 틀리면 `DocError`(저장)."""
if not has(doc):
return row, [], doc
where, bad, gone = _places(row), [], []
covers, lines, adds = {}, [], [dict(x) for x in doc.get("더한줄") or []]
for spot, text in (doc.get("식덮개") or {}).items():
got, why = to_engine(text, row)
if spot not in where:
gone.append(_bad(spot, "바탕에 없는 줄"))
elif why:
bad += [_bad(spot, w) for w in why]
else:
covers[spot] = got
taken, mf = _names(row), _eng()
for one in doc.get("변수줄") or []:
spot, (got, why) = f"mid:{one['이름']}", to_engine(one["식"], row)
if one["이름"] in taken or one["이름"] in _reserved():
bad.append(
_bad(spot, f"이름 「{one['이름']}」 이 이미 있음(입력 · 중간 · 덧줄 · 예약)")
)
elif why:
bad += [_bad(spot, w) for w in why]
else:
lines.append({**one, "식": got})
for k, one in enumerate(adds):
if one.get("수량식"):
got, why = to_engine(one["수량식"], row)
bad += [_bad(f"add:#{k}", w) for w in why]
if why:
one.pop("수량식")
else:
one["수량식"] = got
while True: # 틀린 자리를 빼면 다른 자리가 틀릴 수 있음 — 더 안 틀릴 때까지
out, lost = _build(row, covers, lines)
more = _check(out, covers, lines, adds, mf)
if not more:
break
bad += more
hit = {b["cid"] for b in more}
covers = {s: v for s, v in covers.items() if s not in hit}
lines = [x for x in lines if f"mid:{x['이름']}" not in hit]
for k in [int(_ADD.match(c)[1]) for c in hit if _ADD.match(c)]:
adds[k].pop("수량식", None)
if strict and bad: # 짝 잃음(바탕에 없는 줄 · 자리)은 알림만 — [M01 원래로] 로 걷음
raise sd.DocError(f"「{bad[0]['cid']}」 {bad[0]['까닭']}")
fixed = {k: v for k, v in doc.items() if k not in FIELDS} | {"더한줄": adds}
fixed |= ({"식덮개": covers} if covers else {}) | ({"변수줄": lines} if lines else {})
return out, bad + gone + lost, fixed
@functools.cache
def _reserved() -> frozenset:
mf = _eng()
return frozenset(
(*mf.FUNCS, *mf.COST_ITEMS, "찾기", "로직", "그리고", "또는", "계", "줄", "번지", "단계")
)
def _build(row: dict, covers: dict, lines: list[dict]) -> tuple[dict, list[dict]]:
"""식 덮개를 바탕 칸에 · 변수 줄을 자리 뒤에 끼움(같은 자리 = 넣은 차례)."""
out = {**row, **{k: list(row.get(k) or []) for k in FIELD}}
for spot, (part, at) in _places(row).items():
if spot in covers:
out[part][at] = {**out[part][at], FIELD[part]: covers[spot]}
mids, after, lost = out["중간"], {}, []
for one in lines:
spot = one["자리"]
names = [f"mid:{x.get('이름')}" for x in mids]
if spot and spot not in names:
lost.append(_bad(f"mid:{one['이름']}", f"자리 「{spot}」 없음 — 중간 끝에 둠"))
at = len(mids)
else:
at = after.get(spot, names.index(spot) + 1 if spot else 0)
step = {"이름": one["이름"], "식": one["식"], "변수": True}
mids.insert(at, step | ({"표시": one["표시"]} if "표시" in one else {}))
after = {k: v + (v >= at) for k, v in after.items()} | {spot: at + 1}
return out, lost
def _uses(text: str) -> set[str] | None:
mf = _eng()
try:
return {n[1] for n in mf.walk(mf.parse(text)) if n[0] == "name"}
except (mf.FormulaError, RecursionError):
return None
def _zero(node: tuple, mf: Any) -> bool:
return any(n[0] == "bin" and n[1] == "/" and n[3] == ("num", 0) for n in mf.walk(node))
def _check(row: dict, covers: dict, lines: list, adds: list, mf: Any) -> list[dict]:
"""식 칸 자리만 검사 — 식 · 0 나눔 · 이름(엔진 `_check_node`) · 아래 줄 · 순환."""
inputs = {str(x.get("이름")) for x in row.get("입력") or []}
mids = [str(x.get("이름")) for x in row["중간"]]
deps = {n: (_uses(str(x.get("식"))) or set()) & set(mids) for n, x in zip(mids, row["중간"])}
mine = set(covers) | {f"mid:{x['이름']}" for x in lines}
full = inputs | set(mids)
tail = full | set(mf.COST_ITEMS) | {"줄"} | ({"번지", "단계"} if mf.ms.uses(row) else set())
jobs = [
(f"mid:{n}", str(x["식"]), inputs | set(mids[:i]), n)
for i, (n, x) in enumerate(zip(mids, row["중간"]))
]
jobs = [j for j in jobs if j[0] in mine]
places = _places(row)
for spot, (part, at) in places.items():
if spot in covers and part != "중간":
names = (
tail | {str(x.get("이름")) for x in row["덧줄"][:at]} if part == "덧줄" else full
)
jobs.append((spot, covers[spot], names, None))
jobs += [(f"add:#{k}", x["수량식"], full, None) for k, x in enumerate(adds) if x.get("수량식")]
out = []
for spot, text, known, me in jobs:
try:
node = mf.parse(text)
except (mf.FormulaError, RecursionError) as e:
out.append(_bad(spot, str(e)))
continue
why = ["0 으로 나눔"] if _zero(node, mf) else []
used = {n[1] for n in mf.walk(node) if n[0] == "name"}
for n in sorted(used - known):
if n == me:
why.append(f"순환 — 「{n}」 이 자기 자신을 씀")
elif me and n in deps:
loop = _path(deps, n, me)
why.append(
f"순환 — {' → '.join([me, *loop])}"
if loop
else f"「{n}」 은 아래 줄 — 위로 옮길 것"
)
found: list[str] = []
mf._check_node(row, node, known | (used & set(mids)), _whole(), "", found, set())
why += [w.lstrip(" ·") for w in found]
out += [_bad(spot, w) for w in why]
return out
def _path(deps: dict, start: str, goal: str) -> list[str] | None:
"""`start` 에서 `goal` 로 가는 의존 길(있으면 순환)."""
seen, todo = {start}, [[start]]
while todo:
path = todo.pop()
if path[-1] == goal:
return path
for nxt in sorted(deps.get(path[-1], ())):
if nxt not in seen:
seen.add(nxt)
todo.append([*path, nxt])
return None
# ── 셈 답 덧칸(790-2 `시트`) ─────────────────────────────────────────
def sheet_more(spots: dict, base: dict, clean: dict, mids: dict) -> list[dict]:
"""790-2 시트 자리에 덧칸 — 식글(지금 식 이름 꼴) · 원식글 · 식덮개 · 식틀림 · 변수 줄 ·
더한줄 식글 · 돌려줌 = 틀린 식 · 짝 잃은 식 칸(시트 `짝잃음` 에 더함)."""
from M02_MasterTemplete import M02_Sangeun_Const as const
row, bad, fixed = settle(base, clean)
eff, covers = const._swapped(row, clean), fixed.get("식덮개") or {}
now, why = _places(eff), clean.get("까닭") or {}
wrong: dict[str, list[str]] = {}
for b in bad:
wrong.setdefault(b["cid"], []).append(b["까닭"])
for spot, one in spots.items():
if spot in now and one.get("갈래") != "더한줄":
part, at = now[spot]
one["원식글"] = to_name(str(one.get("식")), base)
one["식글"] = to_name(str(eff[part][at].get(FIELD[part])), base)
one["식덮개"] = spot in covers
hit = _ADD.match(spot)
adds = fixed.get("더한줄") or []
if hit and int(hit[1]) < len(adds) and adds[int(hit[1])].get("수량식"):
one["식"] = adds[int(hit[1])]["수량식"]
one["식글"] = to_name(one["식"], base)
if spot in wrong:
one["식틀림"] = wrong[spot]
good = {x["이름"]: x["식"] for x in fixed.get("변수줄") or []}
for line in clean.get("변수줄") or []:
spot, text = f"mid:{line['이름']}", good.get(line["이름"], line["식"])
spots[spot] = {
"갈래": "변수",
"이름": line["이름"],
"식": text,
"식글": to_name(text, base),
"표시": line.get("표시"),
"자리": line["자리"],
"원래": None,
"지금": mids.get(line["이름"]) if line["이름"] in good else None,
"덮개": [],
"까닭": why.get(spot),
"M01바뀜": False,
**({"식틀림": wrong[spot]} if spot in wrong else {}),
}
return bad
def name_table(base: dict, clean: dict) -> list[dict]:
"""식 칸에서 부를 이름 — 입력 · 중간 · 변수 · 덧줄 · 비목 · 별칭 · 자원 · 번지 · 단계."""
mf = _eng()
out = [{"글": str(x.get("이름")), "갈래": "입력"} for x in base.get("입력") or []]
out += [{"글": str(x.get("이름")), "갈래": "중간"} for x in base.get("중간") or []]
out += [{"글": x["이름"], "갈래": "변수"} for x in clean.get("변수줄") or []]
out += [{"글": str(x.get("이름")), "갈래": "덧줄"} for x in base.get("덧줄") or []]
out += [{"글": k, "갈래": "비목"} for k in mf.COST_ITEMS]
out += [{"글": f"[{k}]", "갈래": "별칭"} for k in aliases(base)]
label_of, seen = _labels(_whole())[0], set()
for part in FIELD:
for x in base.get(part) or []:
texts = [
str(x.get(FIELD[part])),
*([x["요소"]] if isinstance(x.get("요소"), str) else []),
]
for key in (k for t in texts for k in mf.mk.KEY.findall(t)):
if key in label_of and key not in seen:
seen.add(key)
out.append({"글": f"[{label_of[key]}]", "갈래": "자원"})
lines = [*(base.get("호표") or []), *(base.get("덧줄") or [])]
out += [{"글": f"번지.{x['번지']}", "갈래": "번지"} for x in lines if x.get("번지")]
out += [{"글": f"단계.'{x.get('이름')}'", "갈래": "단계"} for x in base.get("단계") or []]
return out
# ── 검사 길(시트가 칠 때마다 · 셈 없음) ──────────────────────────────
def check_doc(doc: Any, spot: str, text: str) -> dict[str, Any]:
"""식 글 하나 → {되나, 까닭, 알림, 식(엔진), 글(이름 꼴)} — 자리 = 중간 `mid:이름`(변수 줄
포함) · 호표 · 덧줄 cid · `add:#k` · 문서 = 화면 캐시(그 자리 옛 식은 빼고 봄)."""
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_Sangeun_Calc as calc
text = str(text or "").strip()
out: dict[str, Any] = {"되나": False, "까닭": [], "알림": [], "식": None, "글": None}
if not text:
return out | {"까닭": ["식이 빔"]}
if bad := limits(text):
return out | {"까닭": bad}
whole, doc = _whole(), _without(doc if isinstance(doc, dict) else {}, spot)
try:
clean_ = sd.clean(doc, None, whole)
base = calc._base_of(clean_, whole, refs.read_ref, ())["줄"]
except sd.DocError as e:
return out | {"까닭": [f"문서 · {e}"]}
got, why = to_engine(text, base)
if why:
return out | {"까닭": why}
out |= {"식": got, "글": to_name(got, base)}
trial, why = _put(clean_, base, spot, got)
if why:
return out | {"까닭": [why]}
bad = [b["까닭"].removeprefix("식 — ") for b in settle(base, trial)[1] if b["cid"] == spot]
return out | {"되나": not bad, "까닭": bad, "알림": _risks(got, base, trial)}
def _without(doc: dict, spot: str) -> dict:
"""그 자리 옛 식을 뺀 문서(옛 식이 틀려도 문서 검사가 안 막게)."""
out = dict(doc)
if isinstance(out.get("식덮개"), dict):
out["식덮개"] = {k: v for k, v in out["식덮개"].items() if k != spot}
if isinstance(out.get("변수줄"), list):
out["변수줄"] = [
{**x, "식": "0"} if isinstance(x, dict) and f"mid:{x.get('이름')}" == spot else x
for x in out["변수줄"]
]
hit = _ADD.match(spot)
if hit and isinstance(out.get("더한줄"), list) and int(hit[1]) < len(out["더한줄"]):
adds = list(out["더한줄"])
adds[int(hit[1])] = {k: v for k, v in dict(adds[int(hit[1])]).items() if k != "수량식"}
out["더한줄"] = adds
return out
def _put(doc: dict, base: dict, spot: str, text: str) -> tuple[dict, str | None]:
"""문서 그 자리에 식(엔진) — (새 문서, 못 넣는 까닭)."""
names = [x["이름"] for x in doc.get("변수줄") or []]
hit = _ADD.match(spot)
if spot.startswith("mid:") and spot[4:] in names:
lines = [{**x, "식": text} if x["이름"] == spot[4:] else x for x in doc["변수줄"]]
return {**doc, "변수줄": lines}, None
if spot in _places(base):
if spot in (doc.get("상수덮개") or {}) or spot in (doc.get("표값덮개") or {}):
return doc, "상수 · 표값 덮개가 있는 줄 — [M01 원래로] 뒤 식 고침"
return {**doc, "식덮개": {**(doc.get("식덮개") or {}), spot: text}}, None
adds = doc.get("더한줄") or []
if hit and int(hit[1]) < len(adds):
one = adds[int(hit[1])]
if sd.is_rate(one) or one.get("종류") == "로직":
return doc, "율 줄 · 로직 줄 수량은 수만"
adds = [{**x, "수량식": text} if k == int(hit[1]) else x for k, x in enumerate(adds)]
return {**doc, "더한줄": adds}, None
return doc, f"없는 자리 「{spot}」"
def _risks(text: str, base: dict, doc: dict) -> list[str]:
"""0 나눔 위험 — 나누는 쪽 이름이 입력값 0 · 고르기에 0."""
mf, out = _eng(), []
given = doc.get("입력값") or {}
try:
tree = mf.parse(text)
except (mf.FormulaError, RecursionError):
return out
picks = {str(x.get("이름")): x.get("고르기") or [] for x in base.get("입력") or []}
for node in mf.walk(tree):
if node[0] == "bin" and node[1] == "/":
for n in (x[1] for x in mf.walk(node[3]) if x[0] == "name"):
zero = str(given.get(n)) in ("0", "0.0") or any(
str(c) == "0" for c in picks.get(n, [])
)
if zero and f"「{n}」" not in " ".join(out):
out.append(f"「{n}」 가 0 이면 0 나눔")
return out