Files
Aislo/resources/master_data/scripts/master_formula_check.py
T

228 lines
10 KiB
Python
Raw 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.
# -*- 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