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