Files
Aislo/resources/master_data/scripts/master_formula.py
T
eomsangdonandClaude Opus 5 aa86e02b8f feat(M01): 끝수 계산 붙임 · 절 목록·절 거르기·표 조건 값·소유 거름·자체 로직 API
- 엔진 — 로직 줄의 묶음 `끝수`{대상, 자리, 방법}를 계산 끝에 붙임(비목 합마다 끊고 계 = 그 합 · 돈 아닌 로직은 결과) · 글자 끝수는 설명 그대로 · 검사가 묶음 모양을 봄
- 서버 — `GET /sections`(원문 본문 절 목록 + 그 절의 표·로직 수) · `/tables` 의 `section`(13-4 가 13-40 에 안 걸림) · `GET /table/options`(조건 값만) · `/logics` 의 `owner`
- 자체 로직 — `POST /logic/new|copy|edit|delete` · 파일 `로직_자체.json` · 키 GX 서버 발급(원문번호 같아도 줄마다 다른 키) · 정본은 403 · 저장 안 한 초안은 `/calc` 에 `key` "" 로
- 저장소가 700줄을 넘어 쓰기 모양(`keep_shape`·`dump`)을 `M01_MasterData_Store_Shape.py` 로 가름
- 시험 `resources/tester/test_m01_make.py` 8건 통과 · 기존 M01 시험 25건 통과 · 일괄 시험 계산 1,291 그대로

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
2026-09-21 09:31:45 +09:00

761 lines
31 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 -*-
"""마스터 로직 식 — 읽기 · 검사 · 시험 계산 (`_틀.md` 6~7장).
식은 틀 7장 범위만 읽음 — eval 쓰지 않음. 수는 Decimal(끝수 사사오입이 흔들리지 않게).
`check_master.py` 가 부름.
"""
from __future__ import annotations
import itertools
import re
from decimal import ROUND_DOWN, ROUND_HALF_UP, ROUND_UP, Decimal
import master_keys as mk
GROUPS = ("인력", "재료", "기계", "소요량", "계수", "환율", "요율", "로직")
TABLE_GROUPS = ("소요량", "계수")
TABLE_IDS = tuple(t for t in mk.IDS if mk.GROUP_OF[t] in TABLE_GROUPS)
COST_ITEMS = ("노무비", "재료비", "경비")
BUNDLE = (*COST_ITEMS, "계")
FUNCS = ("만약", "올림", "버림", "반올림", "최소", "최대")
_NAME = re.compile(r"[\w가-힣]+")
_NUMBER = re.compile(r"\d+(?:\.\d+)?")
_HOLE = re.compile(r"\{([^{}]+)\}")
_OPS = ("<=", ">=", "==", "!=", "<", ">")
_ARITH = re.compile(r"[\d.\s+\-*/()]*\d[\d.\s+\-*/()]*") # 분수·식 값 칸(「1/1.30」)
class FormulaError(ValueError):
pass
# ── 읽기 ──────────────────────────────────────────────────────────────
class _Parser:
def __init__(self, text: str):
self.s, self.i = text, 0
def fail(self, why: str):
raise FormulaError(f"{why} — 「{self.s}{self.i}자")
def ws(self):
while self.i < len(self.s) and self.s[self.i].isspace():
self.i += 1
def peek(self, word: str) -> bool:
self.ws()
return self.s.startswith(word, self.i)
def take(self, word: str) -> bool:
if self.peek(word):
self.i += len(word)
return True
return False
def need(self, word: str):
if not self.take(word):
self.fail(f"「{word}」 없음")
def parse(self):
node = self.expr()
self.ws()
if self.i != len(self.s):
self.fail("식 끝에 남은 글자")
return node
def expr(self):
node = self.both()
while self.take("또는"):
node = ("bin", "또는", node, self.both())
return node
def both(self):
node = self.compare()
while self.take("그리고"):
node = ("bin", "그리고", node, self.compare())
return node
def compare(self):
node = self.add()
for op in _OPS:
if self.take(op):
return ("bin", op, node, self.add())
return node
def add(self):
node = self.mul()
while True:
if self.take("+"):
node = ("bin", "+", node, self.mul())
elif self.take("-"):
node = ("bin", "-", node, self.mul())
else:
return node
def mul(self):
node = self.unary()
while True:
if self.take("*"):
node = ("bin", "*", node, self.unary())
elif self.take("/"):
node = ("bin", "/", node, self.unary())
else:
return node
def unary(self):
if self.take("-"):
return ("neg", self.unary())
node = self.primary()
while self.take("."):
node = ("field", node, self.quoted() if self.take("'") else self.name())
return node
def quoted(self) -> str:
end = self.s.find("'", self.i)
if end < 0:
self.fail("작은따옴표 안 닫힘")
text, self.i = self.s[self.i : end], end + 1
return text
def name(self) -> str:
self.ws()
m = _NAME.match(self.s, self.i)
if not m:
self.fail("이름 없음")
self.i = m.end()
return m.group()
def ref(self, head: bool = False) -> str:
"""키·원문번호 글자 — 괄호 밖의 `,` · `)` · `.칸` · ` 연산자 ` 앞에서 멈춤(원문번호에 빈칸·괄호가 들어감).
head = 찾기·로직 머리 — `,` · `)` 에서만 멈춤."""
start, depth = self.i, 0
while self.i < len(self.s):
ch = self.s[self.i]
if ch in "({":
depth += 1
elif ch in ")}":
if depth == 0:
break
depth -= 1
elif depth == 0 and ch == ",":
break
elif head:
pass
elif depth == 0 and ch == "." and not self.s[self.i + 1 : self.i + 2].isdigit():
# 「K.P」「T.S」 처럼 영문자 사이 점은 원문번호 글자
around = self.s[self.i - 1 : self.i + 2 : 2]
if not (len(around) == 2 and around.isascii() and around.isalpha()):
break
elif depth == 0 and re.match(r"\s[-+*/<>=!]", self.s[self.i : self.i + 2]):
break
self.i += 1
return self.s[start : self.i].strip()
def args(self) -> list[tuple[str, object]]:
out = []
while self.take(","):
key = self.quoted() if self.take("'") else self.name()
self.need("=")
out.append((key, self.expr()))
self.need(")")
return out
def primary(self):
self.ws()
if self.take("("):
node = self.expr()
self.need(")")
return node
if self.take("'"):
end = self.s.find("'", self.i)
if end < 0:
self.fail("작은따옴표 안 닫힘")
text, self.i = self.s[self.i : end], end + 1
return ("str", text)
m = _NUMBER.match(self.s, self.i)
if m:
self.i = m.end()
return ("num", Decimal(m.group()))
word = self.name()
if word == "찾기":
self.need("(")
self.ws()
return ("find", self.ref(True), self.args())
if word == "로직":
self.need("(")
self.ws()
return ("logic", self.ref(True), self.args())
if word in FUNCS:
self.need("(")
items = [self.expr()]
while self.take(","):
items.append(self.expr())
self.need(")")
return ("call", word, items)
if mk.KEY.fullmatch(word):
return ("ref", word)
if word in mk.IDS and self.s.startswith(":", self.i): # ID:원문번호 — {이름} 낀 원문번호
self.i += 1
return ("ref", f"{word}:{self.ref()}")
return ("name", word)
def parse(text) -> tuple:
if isinstance(text, (int, float, Decimal)):
return ("num", Decimal(str(text)))
return _Parser(str(text)).parse()
def walk(node):
"""식 나무의 모든 마디."""
yield node
kind = node[0]
if kind == "bin":
yield from walk(node[2])
yield from walk(node[3])
elif kind in ("neg",):
yield from walk(node[1])
elif kind == "field":
yield from walk(node[1])
elif kind == "call":
for item in node[2]:
yield from walk(item)
elif kind in ("find", "logic"):
for _, item in node[2]:
yield from walk(item)
# ── 마스터 모음 ────────────────────────────────────────────────────────
class Master:
"""요소·표·로직 찾기 — `files` = {파일 이름: 읽은 JSON}(수는 Decimal).
`ref` = 키(「LB000123」) · 「ID:원문번호」(`{이름}` 을 끼운 원문번호 · 끼운 값이 키여도 됨)."""
def __init__(self, files: dict[str, dict]):
self.index: dict[str, dict[str, dict]] = {} # 테이블ID → {키: 줄}
self.numbers: dict[str, dict[str, dict]] = {} # 테이블ID → {원문번호: 줄}
self.twice: set[tuple[str, str]] = set() # 한 절에 여럿 — 번호로 못 부름
for data in files.values():
rows = data.get("표") if data.get("그룹") in TABLE_GROUPS else data.get("줄")
for row in rows or []:
key = str(row.get("키"))
self.index.setdefault(key[:2], {}).setdefault(key, row)
if row.get("원문번호"):
number = str(row["원문번호"])
slot = self.numbers.setdefault(key[:2], {})
if number in slot:
self.twice.add((key[:2], number))
slot.setdefault(number, row)
def get(self, ref: str) -> dict:
tid, _, number = ref.partition(":")
if number and mk.KEY.fullmatch(number) and number[:2] == tid:
row = self.index.get(tid, {}).get(number) # 「ID:키」 — 고르기 값이 키인 자리
elif number:
if (tid, number) in self.twice:
raise FormulaError(f"원문번호 겹침 「{ref}」 — 키로 적을 것")
row = self.numbers.get(tid, {}).get(number)
else:
row = self.index.get(ref[:2], {}).get(ref)
if row is None:
raise FormulaError(f"없는 요소 「{ref}」")
return row
def label(self, ref) -> str:
"""알림 글 — 키 옆에 이름 · 고르기 조건은 조건 글."""
if isinstance(ref, dict):
return "재료 고르기 " + " · ".join(str(v) for v in ref.values() if v)
try:
row = self.get(ref)
except FormulaError:
return ref
return f"{row.get('키')} {row.get('이름') or row.get('원문번호')}"
def value(self, ref: str):
"""요소 값 · 출처 글 — 준용(관리자가 고른 키)이 있으면 그 요소 값 · 없으면 제 값."""
row = self.get(ref)
alias = row.get("준용")
if alias not in (None, ""):
target = self.get(str(alias))
name = target.get("이름") or alias
if row.get("상태") == "추정": # 값을 이미 준용 직종에서 채워 둠
return row.get("값"), f"추정(준용: {name})"
return target.get("값"), f"준용: {name}"
return row.get("값"), None
def logic(self, ref: str) -> dict:
if mk.GROUP_OF.get(mk.id_of(ref)) != "로직":
raise FormulaError(f"로직 아님 「{ref}」")
return self.get(ref)
def fill(template: str, env: dict) -> str:
def one(m):
if m.group(1) not in env:
raise FormulaError(f"원문번호의 {{{m.group(1)}}} 값 없음")
value = env[m.group(1)]
return format(value.normalize(), "f") if isinstance(value, Decimal) else str(value)
return _HOLE.sub(one, template)
# ── 계산 ──────────────────────────────────────────────────────────────
def _round(x: Decimal, places, mode) -> Decimal:
return x.quantize(Decimal(1).scaleb(-int(places)), rounding=mode)
def _match(table: dict, row: dict, cond: str, want) -> bool:
if cond not in row: # 빈 조건 칸은 무엇이든 맞음
return True
have = row[cond]
if table.get("조건", {}).get(cond) == "범위":
low, high = have
rule, *more = table.get("범위규칙", "초과·이하").split(" · ")
rule = rule.split(" (")[0] # 「이상·미만 (원문 …)」 — 괄호 설명은 뺌
if rule == "이상·이하":
return (low is None or want >= low) and (high is None or want <= high)
if rule == "초과·이하":
return (low is None or want > low) and (high is None or want <= high)
if rule == "이상·미만":
# 「마지막 칸(80~100)은 이하 포함」 — 가장 윗줄만 위 끝 포함
top = max(r[cond][1] for r in table["줄"] if r.get(cond) and r[cond][1] is not None)
closed = any("이하 포함" in x for x in more) and high == top
return (low is None or want >= low) and (
high is None or want < high or (closed and want == high)
)
if rule == "이하": # 칸마다 위 끝만 — 맞는 줄 가운데 위 끝이 가장 작은 줄(find)
return (low is None or want > low) and (high is None or want <= high)
if rule == "이상·이하": # 양 끝 포함 — 이웃 칸 끝이 겹치면 위 끝이 가장 작은 줄(find)
return (low is None or want >= low) and (high is None or want <= high)
raise FormulaError(f"모르는 범위규칙 「{table.get('범위규칙')}」")
return have == want
def _name(row: dict) -> str:
return f"{row.get('키')} {row.get('원문번호')}"
def find(table: dict, conds: dict, want: str | None = None) -> dict:
for cond in conds:
if cond not in table.get("조건", {}):
raise FormulaError(f"「{_name(table)}」 에 없는 조건 「{cond}」")
hits = [row for row in table["줄"] if all(_match(table, row, c, v) for c, v in conds.items())]
if len(hits) > 1 and table.get("범위규칙", "").split(" · ")[0] in ("이하", "이상·이하"):
for c in conds:
ups = [r[c][1] for r in hits if table["조건"].get(c) == "범위" and r.get(c)]
if ups and None not in ups:
hits = [r for r in hits if not r.get(c) or r[c][1] == min(ups)]
if len(hits) > 1 and want: # 여러 줄이면 찾는 값 칸이 있는 줄만
hits = [row for row in hits if want in row]
if len(hits) != 1:
raise FormulaError(f"「{_name(table)}{conds} 맞는 줄 {len(hits)}개")
return hits[0]
def evaluate(node, env: dict, master: Master, depth: int = 0):
kind = node[0]
if kind in ("num", "str"):
return node[1]
if kind == "name":
if node[1] not in env:
raise FormulaError(f"모르는 이름 「{node[1]}」")
return env[node[1]]
if kind == "ref":
value = element(master, fill(node[1], env), env)[0]
if value is None:
raise FormulaError(f"「{master.label(fill(node[1], env))}」 값 없음 — 관리자가 채울 값")
if isinstance(value, list):
raise FormulaError(
f"「{master.label(fill(node[1], env))}」 값이 범위 — 입력으로 받아야 함"
)
return value
if kind == "field":
if node[1][0] == "find":
table = master.get(fill(node[1][1], env))
conds = {k: evaluate(v, env, master, depth) for k, v in node[1][2]}
base = find(table, conds, node[2])
else:
base = evaluate(node[1], env, master, depth)
if not isinstance(base, dict) or node[2] not in base:
raise FormulaError(f"칸 「{node[2]}」 없음")
value = base[node[2]]
if isinstance(value, list):
raise FormulaError(f"칸 「{node[2]}」 값이 범위 — 입력으로 받아야 함")
if isinstance(value, str) and _ARITH.fullmatch(value):
return evaluate(parse(value), env, master, depth)
return value
if kind == "neg":
return -evaluate(node[1], env, master, depth)
if kind == "bin":
op, a = node[1], evaluate(node[2], env, master, depth)
if op == "그리고":
return a and evaluate(node[3], env, master, depth)
if op == "또는":
return a or evaluate(node[3], env, master, depth)
b = evaluate(node[3], env, master, depth)
return {
"+": lambda: a + b,
"-": lambda: a - b,
"*": lambda: a * b,
"/": lambda: a / b,
"<": lambda: a < b,
"<=": lambda: a <= b,
">": lambda: a > b,
">=": lambda: a >= b,
"==": lambda: a == b,
"!=": lambda: a != b,
}[op]()
if kind == "call":
name, items = node[1], node[2]
if name == "만약":
test = evaluate(items[0], env, master, depth)
return evaluate(items[1] if test else items[2], env, master, depth)
values = [evaluate(x, env, master, depth) for x in items]
if name in ("최소", "최대"):
return (min if name == "최소" else max)(values)
mode = {"올림": ROUND_UP, "버림": ROUND_DOWN, "반올림": ROUND_HALF_UP}[name]
return _round(values[0], values[1] if len(values) > 1 else 0, mode)
if kind == "find":
table = master.get(fill(node[1], env))
return find(table, {k: evaluate(v, env, master, depth) for k, v in node[2]})
if kind == "logic":
if depth > 20:
raise FormulaError("로직 부르기가 너무 깊음 — 돌고 도는 참조")
given = {k: evaluate(v, env, master, depth) for k, v in node[2]}
result = run(master, fill(node[1], env), given, depth + 1)
return result["결과"] if "결과" in result else {k: result[k] for k in BUNDLE}
raise FormulaError(f"모르는 마디 {kind}")
ROUND_WAYS = {"올림": ROUND_UP, "버림": ROUND_DOWN, "반올림": ROUND_HALF_UP}
ROUND_ON = ("계", "결과")
def cut(row: dict, value: Decimal, on: str) -> Decimal:
"""줄의 `끝수` 를 붙임 — 묶음 `{대상, 자리, 방법}` 만 · 글자 끝수는 원문 근거를 적은 설명."""
rule = row.get("끝수")
if not isinstance(rule, dict) or rule.get("대상") != on:
return value
way = ROUND_WAYS.get(str(rule.get("방법")))
if way is None:
raise FormulaError(f"「{_name(row)}」 끝수 방법 「{rule.get('방법')}」")
return _round(value, rule.get("자리") or 0, way)
def _inputs(row: dict, given: dict) -> dict:
env = {}
for spec in row.get("입력", []):
name = spec["이름"]
if name not in given:
raise FormulaError(f"「{_name(row)}」 입력 「{name}」 없음")
value = given[name]
if isinstance(value, str): # 화면이 글로 보낸 수 — 고르기 원래 값 · 아니면 수로
if "고르기" in spec:
value = next((o for o in spec["고르기"] if str(o) == value.strip()), value)
else:
try:
value = Decimal(value.strip())
except ArithmeticError:
pass
if isinstance(value, (int, float)):
value = Decimal(str(value))
if "고르기" in spec and value not in spec["고르기"]:
raise FormulaError(f"입력 「{name}」={value} 고르기 밖")
if "범위" in spec:
low, high = spec["범위"]
if not low <= value <= high:
raise FormulaError(f"입력 「{name}」={value} 범위 [{low}, {high}] 밖")
env[name] = value
unknown = set(given) - set(env)
if unknown:
raise FormulaError(f"「{_name(row)}」 모르는 입력 {sorted(unknown)}")
return env
def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
"""로직 한 줄 계산 — 돈 로직은 호표 줄 · 비목 합 · 계, 돈 아닌 로직은 결과 수 하나."""
row = master.logic(ref)
env = _inputs(row, given)
for step in row.get("중간", []):
env[step["이름"]] = evaluate(parse(step["식"]), env, master, depth)
if "결과" in row:
value = evaluate(parse(row["결과"]["식"]), env, master, depth)
return {"결과": cut(row, value, "결과"), "중간": env}
sums = dict.fromkeys(COST_ITEMS, Decimal(0))
lines = []
for item in row.get("호표", []):
qty = evaluate(parse(item["수량"]), env, master, depth)
source = None
if item["종류"] == "로직":
bundle = evaluate(parse(item["요소"]), env, master, depth)
price = bundle["계"]
split = (
{k: qty * bundle[k] for k in COST_ITEMS}
if "비목" not in item
else {item["비목"]: qty * price}
)
else:
# 고르기 조건 묶음은 그대로 넘김 — `element` 가 대표 줄·첫 줄로 풂
# 조건 값의 `{입력}`(관경 등)도 채움 — 규격이 입력 따라 바뀌는 관 줄
ref = (
{k: fill(v, env) if isinstance(v, str) else v for k, v in item["요소"].items()}
if isinstance(item["요소"], dict)
else fill(item["요소"], env)
)
try:
price, source = element(master, ref, env, item.get("단위"))
except FormulaError:
if qty != 0:
raise
price = None
if price is None and qty == 0:
price = Decimal(0) # 안 쓰는 재료 줄 — 값이 비어도 금액 0
if price is None:
raise FormulaError(f"「{master.label(ref)}」 값 없음 — 관리자가 채울 값")
split = {item["비목"]: qty * price}
for k, v in split.items():
sums[k] += v
lines.append(
{
"이름": item.get("이름", ""),
"단위": item.get("단위", ""),
"수량": qty,
"단가": price,
"금액": qty * price,
"비목": split,
**({"출처": source} if source else {}),
}
)
# 덧줄이 호표 줄 금액 하나를 빼고 더할 수 있게 — `줄.'호표 줄 이름'`
env["줄"] = {item["이름"]: item["금액"] for item in lines if item["이름"]}
for extra in row.get("덧줄", []):
env.update(sums)
value = evaluate(parse(extra["식"]), env, master, depth)
env[extra["이름"]] = value
sums[extra["비목"]] += value
lines.append(
{
"이름": extra["이름"],
"단위": "식",
"수량": Decimal(1),
"단가": value,
"금액": value,
"비목": {extra["비목"]: value},
}
)
sums = {k: cut(row, v, "계") for k, v in sums.items()} # 비목 합마다 끊고 계는 그 합
return {"줄": lines, **sums, "계": sum(sums.values()), "중간": 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} · 고르기 조건은 종류 「재료」 만")
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}」 — 06 정수")
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,
PICK_KEYS,
REGION,
SLOTS,
TARIFF,
candidates,
check_pick,
check_unit,
static,
cond_text,
element,
pick,
)