feat(master_data): 키 개편 — 테이블ID+6자리 키 · 인력·기계 한 테이블 · 장 파일 이름
- 모든 요소·표·로직 줄의 열쇠 → 키(LB000123 꼴) · 옛 열쇠·원문 번호는 원문번호 칸 · 대장 _키대장.json (키 36,840 · 다음 번호)
- 변수 적는 법 키로 — 로직 식·연결·준용·통합·후보·조달 연결 일괄 변환 · {이름} 낀 참조는 ID:원문번호
- 인력 8 파일 → 인력.json(줄마다 조사 · 머리 조사 묶음) · 기계 2 파일 → 기계.json(세부분류)
- 소요량·계수·로직 파일 이름 = 그룹_원문_NN장_장 제목 · 머리 부문·차례
- 엔진(값 찾기 · 알림에 키 옆 이름) · check_master(키 모양·겹침·대장·끊긴 키 · 기계 요소 줄도 본문 결손 대조) · M01 서버(새 줄 키는 대장 다음 번호 · 키 못 고침) · 화면 칸 이름만 맞춤
- 로직 일괄 시험 계산 1,351 줄 — 옮기기 전후 줄마다 결과·금액 같음
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
This commit is contained in:
@@ -11,8 +11,11 @@ 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 = ("만약", "올림", "버림", "반올림", "최소", "최대")
|
||||
@@ -123,8 +126,8 @@ class _Parser:
|
||||
return m.group()
|
||||
|
||||
def ref(self, head: bool = False) -> str:
|
||||
"""열쇠 글자 — 괄호 밖의 `,` · `)` · `.칸` · ` 연산자 ` 앞에서 멈춤(열쇠에 빈칸·괄호가 들어감).
|
||||
head = 찾기·로직 머리 — `,` · `)` 에서만 멈춤(표 이름의 `P.P마대` 같은 점)."""
|
||||
"""키·원문번호 글자 — 괄호 밖의 `,` · `)` · `.칸` · ` 연산자 ` 앞에서 멈춤(원문번호에 빈칸·괄호가 들어감).
|
||||
head = 찾기·로직 머리 — `,` · `)` 에서만 멈춤."""
|
||||
start, depth = self.i, 0
|
||||
while self.i < len(self.s):
|
||||
ch = self.s[self.i]
|
||||
@@ -139,7 +142,7 @@ class _Parser:
|
||||
elif head:
|
||||
pass
|
||||
elif depth == 0 and ch == "." and not self.s[self.i + 1 : self.i + 2].isdigit():
|
||||
# 「K.P」「T.S」 처럼 영문자 사이 점은 열쇠 글자
|
||||
# 「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
|
||||
@@ -176,11 +179,11 @@ class _Parser:
|
||||
word = self.name()
|
||||
if word == "찾기":
|
||||
self.need("(")
|
||||
head = self.name()
|
||||
self.need(":")
|
||||
return ("find", f"{head}:{self.ref(True)}", self.args())
|
||||
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("(")
|
||||
@@ -189,7 +192,9 @@ class _Parser:
|
||||
items.append(self.expr())
|
||||
self.need(")")
|
||||
return ("call", word, items)
|
||||
if word in GROUPS and self.s.startswith(":", self.i):
|
||||
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)
|
||||
@@ -222,36 +227,47 @@ def walk(node):
|
||||
|
||||
# ── 마스터 모음 ────────────────────────────────────────────────────────
|
||||
class Master:
|
||||
"""요소·표·로직 찾기 — `files` = {파일 이름: 읽은 JSON}(수는 Decimal)."""
|
||||
"""요소·표·로직 찾기 — `files` = {파일 이름: 읽은 JSON}(수는 Decimal).
|
||||
`ref` = 키(「LB000123」) · 「ID:원문번호」(`{이름}` 을 끼운 원문번호)."""
|
||||
|
||||
def __init__(self, files: dict[str, dict]):
|
||||
self.index: dict[tuple[str, str], dict[str, dict]] = {}
|
||||
self.index: dict[str, dict[str, dict]] = {} # 테이블ID → {키: 줄}
|
||||
self.numbers: dict[str, dict[str, dict]] = {} # 테이블ID → {원문번호: 줄}
|
||||
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)
|
||||
key = str(row.get("키"))
|
||||
self.index.setdefault(key[:2], {}).setdefault(key, row)
|
||||
if row.get("원문번호"):
|
||||
self.numbers.setdefault(key[:2], {}).setdefault(str(row["원문번호"]), row)
|
||||
|
||||
def get(self, ref: str) -> dict:
|
||||
group, book, key = (ref.split(":", 2) + ["", ""])[:3]
|
||||
row = self.index.get((group, book), {}).get(key)
|
||||
tid, _, number = ref.partition(":")
|
||||
if number:
|
||||
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) -> str:
|
||||
"""알림 글 — 키 옆에 이름."""
|
||||
try:
|
||||
row = self.get(ref)
|
||||
except FormulaError:
|
||||
return ref
|
||||
return f"{row.get('키')} {row.get('이름') or row.get('원문번호')}"
|
||||
|
||||
def value(self, ref: str):
|
||||
"""요소 값 · 출처 글 — 값이 비면 준용(관리자가 고른 열쇠) → 통합(원문 통합 직종) → 산정(원문 4-라) 차례.
|
||||
준용·통합 = 같은 원문 열쇠 또는 `그룹:원문:열쇠`."""
|
||||
"""요소 값 · 출처 글 — 값이 비면 준용(관리자가 고른 키) → 통합(원문 통합 직종 키) → 산정(원문 4-라) 차례."""
|
||||
row = self.get(ref)
|
||||
if row.get("값") is not None:
|
||||
return row.get("값"), None
|
||||
for col in ("준용", "통합"):
|
||||
alias = row.get(col)
|
||||
if alias not in (None, ""):
|
||||
alias = str(alias)
|
||||
target = self.get(
|
||||
alias if ":" in alias else ":".join(ref.split(":", 2)[:2] + [alias])
|
||||
)
|
||||
target = self.get(str(alias))
|
||||
return target.get("값"), f"{col}: {target.get('이름') or alias}"
|
||||
guess = row.get("산정")
|
||||
if isinstance(guess, dict) and guess.get("값") is not None:
|
||||
@@ -259,13 +275,15 @@ class Master:
|
||||
return None, None
|
||||
|
||||
def logic(self, ref: str) -> dict:
|
||||
return self.get(f"로직:{ref}")
|
||||
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)}}} 값 없음")
|
||||
raise FormulaError(f"원문번호의 {{{m.group(1)}}} 값 없음")
|
||||
value = env[m.group(1)]
|
||||
return format(value.normalize(), "f") if isinstance(value, Decimal) else str(value)
|
||||
|
||||
@@ -304,10 +322,14 @@ def _match(table: dict, row: dict, cond: str, want) -> bool:
|
||||
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"「{table['열쇠']}」 에 없는 조건 「{cond}」")
|
||||
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:
|
||||
@@ -317,7 +339,7 @@ def find(table: dict, conds: dict, want: str | None = None) -> dict:
|
||||
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)}개")
|
||||
raise FormulaError(f"「{_name(table)}」 {conds} 맞는 줄 {len(hits)}개")
|
||||
return hits[0]
|
||||
|
||||
|
||||
@@ -332,9 +354,11 @@ def evaluate(node, env: dict, master: Master, depth: int = 0):
|
||||
if kind == "ref":
|
||||
value = element(master, fill(node[1], env), env)[0]
|
||||
if value is None:
|
||||
raise FormulaError(f"「{node[1]}」 값 없음 — 관리자가 채울 값")
|
||||
raise FormulaError(f"「{master.label(fill(node[1], env))}」 값 없음 — 관리자가 채울 값")
|
||||
if isinstance(value, list):
|
||||
raise FormulaError(f"「{node[1]}」 값이 범위 — 입력으로 받아야 함")
|
||||
raise FormulaError(
|
||||
f"「{master.label(fill(node[1], env))}」 값이 범위 — 입력으로 받아야 함"
|
||||
)
|
||||
return value
|
||||
if kind == "field":
|
||||
if node[1][0] == "find":
|
||||
@@ -399,7 +423,7 @@ def _inputs(row: dict, given: dict) -> dict:
|
||||
for spec in row.get("입력", []):
|
||||
name = spec["이름"]
|
||||
if name not in given:
|
||||
raise FormulaError(f"「{row['열쇠']}」 입력 「{name}」 없음")
|
||||
raise FormulaError(f"「{_name(row)}」 입력 「{name}」 없음")
|
||||
value = given[name]
|
||||
if isinstance(value, str): # 화면이 글로 보낸 수 — 고르기 원래 값 · 아니면 수로
|
||||
if "고르기" in spec:
|
||||
@@ -420,7 +444,7 @@ def _inputs(row: dict, given: dict) -> dict:
|
||||
env[name] = value
|
||||
unknown = set(given) - set(env)
|
||||
if unknown:
|
||||
raise FormulaError(f"「{row['열쇠']}」 모르는 입력 {sorted(unknown)}")
|
||||
raise FormulaError(f"「{_name(row)}」 모르는 입력 {sorted(unknown)}")
|
||||
return env
|
||||
|
||||
|
||||
@@ -456,7 +480,7 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
|
||||
if price is None and qty == 0:
|
||||
price = Decimal(0) # 안 쓰는 재료 줄 — 값이 비어도 금액 0
|
||||
if price is None:
|
||||
raise FormulaError(f"「{ref}」 값 없음 — 관리자가 채울 값")
|
||||
raise FormulaError(f"「{master.label(ref)}」 값 없음 — 관리자가 채울 값")
|
||||
split = {item["비목"]: qty * price}
|
||||
for k, v in split.items():
|
||||
sums[k] += v
|
||||
@@ -510,7 +534,7 @@ def _choices(row: dict, name: str, master: Master):
|
||||
|
||||
|
||||
def _expand(row: dict, template: str, master: Master):
|
||||
"""열쇠 틀의 {이름} 을 고를 수 있는 값으로 모두 끼움 — 못 정하면 None."""
|
||||
"""원문번호 틀의 {이름} 을 고를 수 있는 값으로 모두 끼움 — 못 정하면 None."""
|
||||
holes = _HOLE.findall(template)
|
||||
options = [_choices(row, h, master) for h in holes]
|
||||
if any(o is None for o in options):
|
||||
@@ -529,7 +553,7 @@ def _check_node(row, node, known: set, master: Master, where: str, out: list, ca
|
||||
if kind in ("ref", "find", "logic"):
|
||||
for hole in _HOLE.findall(part[1]):
|
||||
if hole not in known:
|
||||
out.append(f"{where} · 열쇠 {{{hole}}} 가 입력·중간 아님")
|
||||
out.append(f"{where} · 원문번호 {{{hole}}} 가 입력·중간 아님")
|
||||
if kind == "ref":
|
||||
refs = _expand(row, part[1], master) or []
|
||||
for ref in refs:
|
||||
@@ -539,7 +563,7 @@ def _check_node(row, node, known: set, master: Master, where: str, out: list, ca
|
||||
out.append(f"{where} · {e}")
|
||||
continue
|
||||
if isinstance(link, dict) and link.get("지역") == "입력" and REGION not in known:
|
||||
out.append(f"{where} · 「{ref}」 는 입력 「{REGION}」 필요")
|
||||
out.append(f"{where} · 「{master.label(ref)}」 는 입력 「{REGION}」 필요")
|
||||
if kind == "field" and part[1][0] == "ref":
|
||||
for ref in _expand(row, part[1][1], master) or []:
|
||||
try:
|
||||
@@ -547,9 +571,9 @@ def _check_node(row, node, known: set, master: Master, where: str, out: list, ca
|
||||
except FormulaError:
|
||||
continue
|
||||
if not isinstance(value, dict) or part[2] not in value:
|
||||
out.append(f"{where} · 「{ref}」 에 칸 「{part[2]}」 없음")
|
||||
out.append(f"{where} · 「{master.label(ref)}」 에 칸 「{part[2]}」 없음")
|
||||
if kind == "find":
|
||||
if part[1].split(":")[0] not in TABLE_GROUPS:
|
||||
if mk.id_of(part[1]) not in TABLE_IDS:
|
||||
out.append(f"{where} · 찾기 대상 「{part[1]}」 이 표 아님")
|
||||
continue
|
||||
try:
|
||||
@@ -559,14 +583,14 @@ def _check_node(row, node, known: set, master: Master, where: str, out: list, ca
|
||||
continue
|
||||
for cond, _ in part[2]:
|
||||
if cond not in table.get("조건", {}):
|
||||
out.append(f"{where} · 「{part[1]}」 에 조건 「{cond}」 없음")
|
||||
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} · 「{part[1][1]}」 에 값 칸 「{part[2]}」 없음")
|
||||
out.append(f"{where} · 「{master.label(part[1][1])}」 에 값 칸 「{part[2]}」 없음")
|
||||
if kind == "logic":
|
||||
calls.add(part[1])
|
||||
try:
|
||||
@@ -577,9 +601,9 @@ def _check_node(row, node, known: set, master: Master, where: str, out: list, ca
|
||||
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}」 없음")
|
||||
out.append(f"{where} · 「{master.label(part[1])}」 에 입력 「{extra}」 없음")
|
||||
for lack in sorted(names - given):
|
||||
out.append(f"{where} · 「{part[1]}」 입력 「{lack}」 안 줌")
|
||||
out.append(f"{where} · 「{master.label(part[1])}」 입력 「{lack}」 안 줌")
|
||||
if kind == "field" and part[1][0] == "logic":
|
||||
try:
|
||||
target = master.logic(part[1][1])
|
||||
@@ -587,15 +611,15 @@ def _check_node(row, node, known: set, master: Master, where: str, out: list, ca
|
||||
continue
|
||||
if "결과" in target:
|
||||
out.append(
|
||||
f"{where} · 돈 아닌 로직 「{part[1][1]}」 은 수 하나 — 칸 「{part[2]}」 못 씀"
|
||||
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, book: str, row: dict) -> tuple[list[str], set]:
|
||||
"""(문제 목록, 부르는 로직 열쇠) — 변수 · 찾기 조건 · 로직 입력."""
|
||||
out, calls, key = [], set(), f"{book}:{row.get('열쇠')}"
|
||||
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):
|
||||
@@ -619,7 +643,8 @@ def check_logic(master: Master, book: str, row: dict) -> tuple[list[str], set]:
|
||||
):
|
||||
out.append(f"{where} · 비목 「{item.get('비목')}」")
|
||||
one(item["요소"], where + " 요소", known)
|
||||
if item.get("종류") != "로직" and not str(item["요소"]).startswith(f"{item.get('종류')}:"):
|
||||
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("덧줄", []):
|
||||
|
||||
Reference in New Issue
Block a user