# -*- 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 GROUPS = ("인력", "재료", "기계", "소요량", "계수", "환율", "요율", "로직") 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 = 찾기·로직 머리 — `,` · `)` 에서만 멈춤(표 이름의 `P.P마대` 같은 점).""" 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("(") head = self.name() self.need(":") return ("find", f"{head}:{self.ref(True)}", self.args()) if word == "로직": self.need("(") 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 word in GROUPS and self.s.startswith(":", self.i): 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).""" def __init__(self, files: dict[str, dict]): self.index: dict[tuple[str, str], dict[str, dict]] = {} for data in files.values(): rows = data.get("표") if data.get("그룹") in TABLE_GROUPS else data.get("줄") book = self.index.setdefault((data.get("그룹"), data.get("원문")), {}) for row in rows or []: book.setdefault(str(row.get("열쇠")), row) def get(self, ref: str) -> dict: group, book, key = (ref.split(":", 2) + ["", ""])[:3] row = self.index.get((group, book), {}).get(key) if row is None: raise FormulaError(f"없는 요소 「{ref}」") return row def logic(self, ref: str) -> dict: return self.get(f"로직:{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 find(table: dict, conds: dict, want: str | None = None) -> dict: for cond in conds: if cond not in table.get("조건", {}): raise FormulaError(f"「{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"「{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 = master.get(fill(node[1], env)).get("값") if value is None: raise FormulaError(f"「{node[1]}」 값 없음 — 관리자가 채울 값") if isinstance(value, list): raise FormulaError(f"「{node[1]}」 값이 범위 — 입력으로 받아야 함") 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}") def _inputs(row: dict, given: dict) -> dict: env = {} for spec in row.get("입력", []): name = spec["이름"] if name not in given: raise FormulaError(f"「{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"「{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: return {"결과": evaluate(parse(row["결과"]["식"]), env, master, depth), "중간": env} sums = dict.fromkeys(COST_ITEMS, Decimal(0)) lines = [] for item in row.get("호표", []): qty = evaluate(parse(item["수량"]), env, master, depth) 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: node = parse(item["요소"]) if node[0] == "field": # 값 묶음 요소의 칸 — 「재료:시중물가:M….채택」 price = master.get(fill(node[1][1], env))["값"][node[2]] else: price = master.get(fill(item["요소"], env))["값"] if price is None and qty == 0: price = Decimal(0) # 안 쓰는 재료 줄 — 값이 비어도 금액 0 if price is None: raise FormulaError(f"「{fill(item['요소'], env)}」 값 없음 — 관리자가 채울 값") split = {item["비목"]: qty * price} for k, v in split.items(): sums[k] += v lines.append( { "이름": item.get("이름", ""), "단위": item.get("단위", ""), "수량": qty, "단가": price, "금액": qty * price, "비목": split, } ) 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}, } ) 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: master.get(ref) except FormulaError as e: out.append(f"{where} · {e}") 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} · 「{ref}」 에 칸 「{part[2]}」 없음") if kind == "find": if part[1].split(":")[0] not in TABLE_GROUPS: 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} · 「{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} · 「{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} · 「{part[1]}」 에 입력 「{extra}」 없음") for lack in sorted(names - given): out.append(f"{where} · 「{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} · 돈 아닌 로직 「{part[1][1]}」 은 수 하나 — 칸 「{part[2]}」 못 씀" ) elif part[2] not in BUNDLE: out.append(f"{where} · 로직 결과 칸 「{part[2]}」 은 {BUNDLE} 가운데 하나여야 함") def check_logic(master: Master, book: str, row: dict) -> tuple[list[str], set]: """(문제 목록, 부르는 로직 열쇠) — 변수 · 찾기 조건 · 로직 입력.""" out, calls, key = [], set(), f"{book}:{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('비목')}」") one(item["요소"], where + " 요소", known) if item.get("종류") != "로직" and not str(item["요소"]).startswith(f"{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('비목')}」") return out, calls 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