# -*- coding: utf-8 -*- """마스터 로직 식 — 검사 (`master_formula` 에서 나눔 · 밖에서는 `master_formula` 로 부름). `master_formula` 끝에서만 읽힘 — 먼저 읽으면 서로 부르다 막힘. """ from __future__ import annotations import itertools from decimal import Decimal import master_keys as mk from master_formula import ( _HOLE, BUNDLE, COST_ITEMS, ROUND_ON, ROUND_WAYS, TABLE_IDS, FormulaError, fill, parse, walk, ) from master_material import REGION, TARIFF, check_pick # ── 검사 ────────────────────────────────────────────────────────────── def _choices(row: dict, name: str, master: Master): """이름이 가질 수 있는 값 — 고르기 입력, 또는 고르기 입력만 끼운 요소 칸인 중간. 모르면 None.""" for spec in row.get("입력", []): if spec["이름"] == name: return spec.get("고르기") for step in row.get("중간", []): if step["이름"] == name: node = parse(step["식"]) if node[0] == "field" and node[1][0] == "ref": out = set() for ref in _expand(row, node[1][1], master) or []: try: out.add(str(master.get(ref)["값"][node[2]])) except (FormulaError, KeyError, TypeError): pass return sorted(out) return None def _expand(row: dict, template: str, master: Master): """원문번호 틀의 {이름} 을 고를 수 있는 값으로 모두 끼움 — 못 정하면 None.""" holes = _HOLE.findall(template) options = [_choices(row, h, master) for h in holes] if any(o is None for o in options): return None return [ fill(template, {h: v for h, v in zip(holes, combo)}) for combo in itertools.product(*options) ] def _check_node(row, node, known: set, master: Master, where: str, out: list, calls: set): for part in walk(node): kind = part[0] if kind == "name" and part[1] not in known: out.append(f"{where} · 모르는 이름 「{part[1]}」") if kind in ("ref", "find", "logic"): for hole in _HOLE.findall(part[1]): if hole not in known: out.append(f"{where} · 원문번호 {{{hole}}} 가 입력·중간 아님") if kind == "ref": refs = _expand(row, part[1], master) or [] for ref in refs: try: link = master.get(ref).get("연결") except FormulaError as e: out.append(f"{where} · {e}") continue if isinstance(link, dict) and link.get("지역") == "입력" and REGION not in known: out.append(f"{where} · 「{master.label(ref)}」 는 입력 「{REGION}」 필요") if ( isinstance(link, dict) and link.get("계약종별") == "입력" and TARIFF not in known ): out.append(f"{where} · 「{master.label(ref)}」 는 입력 「{TARIFF}」 필요") if kind == "field" and part[1][0] == "ref": for ref in _expand(row, part[1][1], master) or []: try: value = master.get(ref).get("값") except FormulaError: continue if not isinstance(value, dict) or part[2] not in value: out.append(f"{where} · 「{master.label(ref)}」 에 칸 「{part[2]}」 없음") if kind == "find": if mk.id_of(part[1]) not in TABLE_IDS: out.append(f"{where} · 찾기 대상 「{part[1]}」 이 표 아님") continue try: table = master.get(part[1]) except FormulaError as e: out.append(f"{where} · {e}") continue for cond, _ in part[2]: if cond not in table.get("조건", {}): out.append(f"{where} · 「{master.label(part[1])}」 에 조건 「{cond}」 없음") if kind == "field" and part[1][0] == "find": try: table = master.get(part[1][1]) except FormulaError: continue if part[2] not in table.get("값칸", {}): out.append(f"{where} · 「{master.label(part[1][1])}」 에 값 칸 「{part[2]}」 없음") if kind == "logic": calls.add(part[1]) try: target = master.logic(part[1]) except FormulaError as e: out.append(f"{where} · {e}") continue names = {s["이름"] for s in target.get("입력", [])} given = {k for k, _ in part[2]} for extra in sorted(given - names): out.append(f"{where} · 「{master.label(part[1])}」 에 입력 「{extra}」 없음") for lack in sorted(names - given): out.append(f"{where} · 「{master.label(part[1])}」 입력 「{lack}」 안 줌") if kind == "field" and part[1][0] == "logic": try: target = master.logic(part[1][1]) except FormulaError: continue if "결과" in target: out.append( f"{where} · 돈 아닌 로직 「{master.label(part[1][1])}」 은 수 하나 — 칸 「{part[2]}」 못 씀" ) elif part[2] not in BUNDLE: out.append(f"{where} · 로직 결과 칸 「{part[2]}」 은 {BUNDLE} 가운데 하나여야 함") def check_logic(master: Master, row: dict) -> tuple[list[str], set]: """(문제 목록, 부르는 로직 키) — 변수 · 찾기 조건 · 로직 입력.""" out, calls, key = [], set(), f"{row.get('키')} {row.get('원문번호')}" known = {s["이름"] for s in row.get("입력", [])} def one(text, where, names): try: _check_node(row, parse(text), names, master, where, out, calls) except FormulaError as e: out.append(f"{where} · 식 못 읽음: {e}") for step in row.get("중간", []): one(step["식"], f"{key} 중간 {step['이름']}", known) known = known | {step["이름"]} if "결과" in row: one(row["결과"]["식"], f"{key} 결과", known) for item in row.get("호표", []): where = f"{key} 호표 {item.get('이름', '')}" one(item["수량"], where + " 수량", known) if item.get("종류") not in ("인력", "재료", "기계", "로직"): out.append(f"{where} · 종류 「{item.get('종류')}」") if item.get("비목") not in (*COST_ITEMS, None) or ( item.get("종류") != "로직" and "비목" not in item ): out.append(f"{where} · 비목 「{item.get('비목')}」") if isinstance(item["요소"], dict): # 재료 고르기 조건 if item.get("종류") != "재료": out.append(f"{where} · 고르기 조건은 종류 「재료」 만") # 조건 칸의 {이름} 은 계산 때 채워짐 — 그 이름이 실제 입력·중간인지만 봄 for value in item["요소"].values(): for hole in _HOLE.findall(value) if isinstance(value, str) else []: if hole not in known: out.append(f"{where} 요소 · 고르기 조건 {{{hole}}} 가 입력·중간 아님") out += check_pick(master, where + " 요소", item["요소"]) continue one(item["요소"], where + " 요소", known) group = mk.GROUP_OF.get(mk.id_of(str(item["요소"]))) if item.get("종류") != "로직" and group != item.get("종류"): out.append(f"{where} · 요소 그룹이 종류와 다름") names = known | set(COST_ITEMS) | {"줄"} # 「줄」 = 호표 줄 금액 묶음(덧줄에서만) for extra in row.get("덧줄", []): one(extra["식"], f"{key} 덧줄 {extra['이름']}", names) names = names | {extra["이름"]} if extra.get("비목") not in COST_ITEMS: out.append(f"{key} 덧줄 {extra['이름']} · 비목 「{extra.get('비목')}」") out += _check_cut(row, key) return out, calls def _check_cut(row: dict, key: str) -> list[str]: """끝수 — 묶음이면 칸·대상·방법·자리 · 글자는 설명(검사 안 함).""" rule = row.get("끝수") if rule is None or isinstance(rule, str): return [] if not isinstance(rule, dict): return [f"{key} 끝수 · 묶음도 글자도 아님"] out = [] unknown = set(rule) - {"대상", "자리", "방법", "출처", "비고"} if unknown: out.append(f"{key} 끝수 · 모르는 칸 {sorted(unknown)}") on = rule.get("대상") if on not in ROUND_ON: out.append(f"{key} 끝수 · 대상 「{on}」 — {' · '.join(ROUND_ON)}") elif ("결과" in row) != (on == "결과"): out.append(f"{key} 끝수 · 대상 「{on}」 이 로직 갈래와 안 맞음") if rule.get("방법") not in ROUND_WAYS: out.append(f"{key} 끝수 · 방법 「{rule.get('방법')}」 — {' · '.join(ROUND_WAYS)}") place = rule.get("자리", 0) if not isinstance(place, (int, Decimal)) or place != int(place) or not 0 <= int(place) <= 6: out.append(f"{key} 끝수 · 자리 「{place}」 — 0∼6 정수") return out def find_loops(graph: dict[str, set]) -> list[list[str]]: """로직 부르기의 돌고 도는 길.""" loops, state = [], {} def visit(node, path): state[node] = 1 for nxt in sorted(graph.get(node, ())): if state.get(nxt) == 1: loops.append(path[path.index(nxt) :] + [nxt]) elif nxt not in state: visit(nxt, path + [nxt]) state[node] = 2 for node in sorted(graph): if node not in state: visit(node, [node]) return loops