"""M02 단가산출(SK) 사용자 식 칸(PLAN 792-1 · 요구서_652 1 장 3 · _분석_650-H3 E4) — 변수 줄 · 식 덮개 · 더한줄 수량식 · 이름 꼴 ↔ 엔진 식 · 검사(「덮개 = 숫자만」 원칙을 바꿈). 시험 `resources/tester/test_792_1_sangeun_formula.py` · 799-2 — 화면 짝(`Formula_Model.ts` · 시트 탭)은 걷음, 서버만 남음. 문서 덧칸(없으면 안 둠 — 옛 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