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:
2026-09-20 01:56:30 +09:00
co-authored by Claude Opus 5
parent 5bfdf65951
commit 6c9ac97b29
177 changed files with 126560 additions and 55788 deletions
+67 -42
View File
@@ -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("덧줄", []):