엔진은 이미 됐음 — run() 이 조건 칸을 fill() 로 채우고 static() 이 검사에서 뺌.
표본으로 확인한 뒤 22줄 가운데 20줄을 고르기 조건으로 옮김(MT 19 · 고무링 1).
못 옮긴 2줄 — VR관(자재 단위 「본」 대 호표 「m」 환산 미확정) ·
커플링밴드(자재품목 표기 두 갈래라 규격으로 접속티와 못 가름).
검사 넓힘 — check_logic 이 조건 칸 {이름} 이 입력·중간인지 봄 ·
check_pick 이 구분 자체가 {입력} 이면 건너뜀(옛 코드는 「후보 0」 오탐).
앞뒤 견주기 — 시험 계산 통과 1,291 · 멈춤 63 그대로 · 금액 달라진 로직 0(1,354 전수) ·
check_master 전부 같음 · 로직의 MT 직접 참조 19 → 0 · MP 160 → 158.
768 lines
32 KiB
Python
768 lines
32 KiB
Python
# -*- 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
|
||
|
||
# 식이 부르는 요소 그룹 — 일위대가 조합(`master_combo`)은 식에 안 들어와 여기 두지 않음
|
||
# (조합은 `/rows`·`/files` 같은 요소 길이 아니라 제 길 `/combos`·`/combo` 로만 다룸)
|
||
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} · 고르기 조건은 종류 「재료」 만")
|
||
# 조건 칸의 {이름} 은 계산 때 채워짐 — 그 이름이 실제 입력·중간인지만 봄
|
||
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,
|
||
PICK_KEYS,
|
||
REGION,
|
||
SLOTS,
|
||
TARIFF,
|
||
candidates,
|
||
check_pick,
|
||
check_unit,
|
||
static,
|
||
cond_text,
|
||
element,
|
||
pick,
|
||
)
|