From af465080e722fab09afe09f8a3681c584c557596 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 22 Oct 2026 19:53:26 +0900 Subject: [PATCH] =?UTF-8?q?refactor(512-3=20master=5Fformula):=20=EA=B2=80?= =?UTF-8?q?=EC=82=AC=20=EB=AC=B6=EC=9D=8C=EC=9D=84=20master=5Fformula=5Fch?= =?UTF-8?q?eck.py=20=EB=A1=9C=20=EB=82=98=EB=88=94=20=E2=80=94=20880=20?= =?UTF-8?q?=E2=86=92=20686=20=EC=A4=84=20=C2=B7=20=EB=B0=96=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=EC=9D=80=20master=5Fformula=20=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EA=B7=B8=EB=8C=80=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../master_data/scripts/master_formula.py | 210 +--------------- .../scripts/master_formula_check.py | 227 ++++++++++++++++++ 2 files changed, 235 insertions(+), 202 deletions(-) create mode 100644 resources/master_data/scripts/master_formula_check.py diff --git a/resources/master_data/scripts/master_formula.py b/resources/master_data/scripts/master_formula.py index 56b7e5a34..4fdda195a 100644 --- a/resources/master_data/scripts/master_formula.py +++ b/resources/master_data/scripts/master_formula.py @@ -660,208 +660,6 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict: return {"줄": lines, **sums, "계": sum(sums.values()), "중간": _shown(env)} -# ── 검사 ────────────────────────────────────────────────────────────── -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 - - # 품셈재료 단가 — 서로 부르는 모듈이라 끝에서 읽음 from master_material import ( # noqa: E402, F401 LINKED, @@ -878,3 +676,11 @@ from master_material import ( # noqa: E402, F401 pick, ) import master_pick as mp # noqa: E402 — 검색어 · 고름 · 단위 +from master_formula_check import ( # noqa: E402, F401 — 검사는 나눈 파일 · 이름은 여기서 그대로 + _check_cut, + _check_node, + _choices, + _expand, + check_logic, + find_loops, +) diff --git a/resources/master_data/scripts/master_formula_check.py b/resources/master_data/scripts/master_formula_check.py new file mode 100644 index 000000000..25aec63f8 --- /dev/null +++ b/resources/master_data/scripts/master_formula_check.py @@ -0,0 +1,227 @@ +# -*- 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