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
+121 -66
View File
@@ -2,12 +2,12 @@
"""마스터 데이터 검사 도구 (`resources/master_data/_틀.md`).
돌리기: ./venv/Scripts/python.exe resources/master_data/scripts/check_master.py [틀|본문|로직] [파일 …]
./venv/Scripts/python.exe resources/master_data/scripts/check_master.py 계산 "산림품셈:13-4-1 메쌓기" 뒷길이=35 돌=견치돌 …
./venv/Scripts/python.exe resources/master_data/scripts/check_master.py 계산 GF000123 뒷길이=35 돌=견치돌 …
(1) 틀 — 파일 이름 · 머리 칸 · 줄 칸 · 출처 모양
(1) 틀 — 파일 이름 · 머리 칸 · 줄 칸 · 출처 모양 · 키 모양·겹침 · 대장
(2) 본문 — 표형 요소의 출처 절을 본문 md 에서 찾아 수를 맞댐 · 결손(본문 표에 있고 요소에 없음)·허구(요소에 있고 본문 절에 없음)
(3) 로직 — 변수가 가리키는 요소·표·칸 · 찾기 조건 이름 · 로직 입력 · 돌고 도는 참조
(4) 계산 — 로직 열쇠와 입력값으로 호표 줄별 수량·단가·금액 · 비목 합 (`master_formula.py`)
(3) 로직 — 변수가 가리키는 요소·표·칸 · 찾기 조건 이름 · 로직 입력 · 돌고 도는 참조 · 끊긴 키
(4) 계산 — 로직 와 입력값으로 호표 줄별 수량·단가·금액 · 비목 합 (`master_formula.py`)
수 뽑기는 `resources/knowledge/original/_pipeline/pum_md_tool.py` 의 것을 씀.
"""
@@ -27,6 +27,7 @@ sys.path.insert(0, str(HERE))
sys.path.insert(0, str(ROOT / "resources/knowledge/original/_pipeline"))
import master_formula as mf # noqa: E402
import master_keys as mk # noqa: E402
from pum_md_tool import _NUM, md_lines # noqa: E402
BOOKS = (
@@ -55,17 +56,21 @@ DIVISIONS = {
"기계설비": "04_기계설비부문",
"유지관리": "05_유지관리부문",
}
_FILE = re.compile(rf"^({'|'.join(mf.GROUPS)})_({'|'.join(BOOKS)})(?:_(\d+장|[^_]+))?\.json$")
_FILE = re.compile(
rf"^(?:(인력|기계)|({'|'.join(mf.GROUPS)})_({'|'.join(BOOKS)})(?:_(\d\d)장_(.+))?)\.json$"
)
_SOURCE = re.compile(
rf"^(?:자체|(?:{'|'.join(b for b in BOOKS if b not in ('건설품셈', '자체'))})(?:\s\S.*)?|건설품셈 (?:{'|'.join(DIVISIONS)}) \S.*)$"
)
_IDENT = re.compile(r"\d+-\d+(?:-\d+)*")
ROW_KEYS = {
"요소": ("열쇠", "이름", "", "출처"),
"": ("열쇠", "이름", "기준", "출처", "조건", "값칸", "", ""),
"로직": ("열쇠", "이름", "결과단위", "출처", "소유", "입력", "중간", "끝수"),
"품셈재료": ("열쇠", "이름", "규격", "단위", "요구절", "연결"),
"요소": ("", "원문번호", "이름", "", "출처"),
"": ("", "원문번호", "이름", "기준", "출처", "조건", "값칸", "", ""),
"로직": ("", "원문번호", "이름", "결과단위", "출처", "소유", "입력", "중간", "끝수"),
"품셈재료": ("", "원문번호", "이름", "규격", "단위", "요구절", "연결"),
}
# 한 테이블 파일 — 줄의 갈래 칸 · 머리의 갈래 묶음
MERGED = {"인력": "조사", "기계": "세부분류"}
def load(names: list[str] | None = None, folder: Path = MASTER) -> dict[str, dict]:
@@ -86,14 +91,14 @@ def master(folder: Path = MASTER) -> mf.Master:
return mf.Master(load(folder=folder))
def check_saved(files: dict[str, dict], changed: list[str]) -> list[str]:
"""저장 전 검사(M01) — 고친 파일의 틀 + 모든 로직의 변수. `files` = 폴더 전부(고친 뒤)."""
def check_saved(files: dict[str, dict], changed: list[str], book: dict | None = None) -> list[str]:
"""저장 전 검사(M01) — 고친 파일의 틀 + 모든 로직의 변수 · 키. `files` = 폴더 전부(고친 뒤) · `book` = 키 대장."""
out = [x for name in changed for x in check_form(name, files[name])]
return out + check_logics(files, mf.Master(files))
return out + check_logics(files, mf.Master(files), book)
def calc(files: dict[str, dict], ref: str, given: dict) -> dict:
"""시험 계산(M01) — `ref` = 「원문:로직 열쇠」 · 멈추면 FormulaError."""
"""시험 계산(M01) — `ref` = 로직 키 · 멈추면 FormulaError."""
return mf.run(mf.Master(files), ref, given)
@@ -104,24 +109,43 @@ def _source_ok(source) -> bool:
return isinstance(source, str) and bool(_SOURCE.match(source))
def _division(name: str) -> str | None:
"""건설품셈 표형 요소·로직 파일의 부문 — 열쇠 앞말(「건축 1-1-1 계수」)."""
m = _FILE.match(name)
if not m or m.group(2) != "건설품셈" or not m.group(3):
return None
return next((d for d in DIVISIONS if m.group(3).startswith(d)), None)
def check_form(name: str, data: dict) -> list[str]:
def _head(name: str, data: dict, m) -> list[str]:
"""머리 — 그룹·원문·판 · 한 테이블은 갈래 묶음 · 장 파일은 차례(건설은 부문)."""
out = []
m = _FILE.match(name)
if not m:
out.append(f"{name} · 파일 이름이 「그룹_원문[_장].json」 아님")
elif (data.get("그룹"), data.get("원문")) != (m.group(1), m.group(2)):
if m.group(1):
slot = MERGED[m.group(1)]
if data.get("그룹") != m.group(1):
out.append(f"{name} · 머리 그룹이 파일 이름과 다름")
if not isinstance(data.get(slot), dict) or not data[slot]:
out.append(f"{name} · 머리 「{slot}」 묶음 없음")
return out
if (data.get("그룹"), data.get("원문")) != (m.group(2), m.group(3)):
out.append(f"{name} · 머리 그룹·원문이 파일 이름과 다름")
for head in ("그룹", "원문", ""):
if head not in data:
out.append(f"{name} · 머리 「{head}」 없음")
chapter = bool(m.group(4))
if (data.get("그룹") in (*mf.TABLE_GROUPS, "로직") and data.get("원문") != "자체") != chapter:
out.append(f"{name} · 파일 이름이 「그룹_원문_NN장_장 제목.json」 아님")
if chapter and not isinstance(data.get("차례"), Decimal):
out.append(f"{name} · 머리 「차례」 없음")
if chapter and (data.get("원문") == "건설품셈") != (data.get("부문") in DIVISIONS):
out.append(f"{name} · 머리 「부문」 「{data.get('부문')}")
return out
def check_form(name: str, data: dict) -> list[str]:
m = _FILE.match(name)
if not m:
return [
f"{name} · 파일 이름이 「그룹_원문[_NN장_장 제목].json」 · 「인력.json」 · 「기계.json」 아님"
]
out = _head(name, data, m)
try:
tid = mk.table_id(data.get("그룹"), data.get("원문"))
except KeyError:
return out + [f"{name} · 테이블ID 없는 그룹·원문"]
merged = MERGED.get(data.get("그룹"))
table = data.get("그룹") in mf.TABLE_GROUPS
kind = "" if table else "로직" if data.get("그룹") == "로직" else "요소"
if data.get("원문") == "품셈재료":
@@ -129,16 +153,16 @@ def check_form(name: str, data: dict) -> list[str]:
rows = data.get("" if table else "")
if not isinstance(rows, list):
return out + [f"{name} · 머리 「{'' if table else ''}」 목록 없음"]
seen = set()
division = _division(name) if kind != "요소" else None
division = data.get("부문") if kind != "요소" else None
for row in rows:
key = str(row.get("열쇠"))
where = f"{name} · {key}"
if key in seen:
out.append(f"{where} · 열쇠 겹침")
seen.add(key)
if division and not key.startswith(division + " "):
out.append(f"{where} · 열쇠 앞에 부문 「{division}」 없음")
key, number = str(row.get("")), str(row.get("원문번호"))
where = f"{name} · {key} {row.get('이름') or number}"
if not mk.KEY.fullmatch(key) or key[:2] != tid:
out.append(f"{where} · 키 모양이 「{tid}」 + 6자리 아님")
if merged and row.get(merged) not in data.get(merged, {}):
out.append(f"{where} · {merged}{row.get(merged)}」 이 머리에 없음")
if division and not number.startswith(division + " "):
out.append(f"{where} · 원문번호 앞에 부문 「{division}」 없음")
for need in ROW_KEYS[kind]:
if need not in row:
out.append(f"{where} · 칸 「{need}」 없음")
@@ -158,7 +182,7 @@ def check_form(name: str, data: dict) -> list[str]:
def _check_linked(where: str, row: dict) -> list[str]:
"""품셈재료 — 값 칸 없음 · 요구절은 출처 모양 · 연결 = 시중물가 열쇠 · 후보 조건 · null."""
"""품셈재료 — 값 칸 없음 · 요구절은 출처 모양 · 연결 = 시중물가 · 후보 조건 · null."""
out = [f"{where} · 값 칸을 두지 않음"] if "" in row else []
need = row.get("요구절")
if not (isinstance(need, list) and need and all(_source_ok(x) for x in need)):
@@ -176,28 +200,25 @@ def _check_linked(where: str, row: dict) -> list[str]:
def check_links(whole: mf.Master) -> list[str]:
"""품셈재료 연결 · 시중물가 조달 연결 · 인력 준용·통합 가리키는 줄."""
"""끊긴 키 — 품셈재료 연결 · 시중물가 조달 연결 · 인력 준용·통합·후보가 가리키는 줄."""
out = []
for key, row in whole.index.get(("재료", "품셈재료"), {}).items():
for key, row in whole.index.get("MP", {}).items():
link = row.get("연결")
if isinstance(link, str) and link not in whole.index.get(("재료", "시중물가"), {}):
out.append(f"재료:품셈재료:{key} · 연결 「{link}」 이 시중물가에 없음")
for (group, book), rows in whole.index.items():
for key, row in rows.items() if group == "인력" else ():
for col in ("준용", "통합"):
ref = row.get(col)
if ref in (None, ""):
continue
ref = str(ref) if ":" in str(ref) else f"인력:{book}:{ref}"
try:
whole.get(ref)
except mf.FormulaError:
out.append(f"인력:{book}:{key} · {col}{ref}」 없는 요소")
nara = whole.index.get(("재료", "나라장터자재"), {})
for key, row in whole.index.get(("재료", "시중물가"), {}).items():
if isinstance(link, str) and link not in whole.index.get("MT", {}):
out.append(f"{whole.label(key)} · 연결 「{link}」 이 시중물가에 없음")
for key, row in whole.index.get("LB", {}).items():
for col in ("준용", "통합"):
ref = row.get(col)
if ref not in (None, "") and str(ref) not in whole.index.get("LB", {}):
out.append(f"{whole.label(key)} · {col}{ref}」 없는 인력 키")
for ref in mk.KEY.findall(str(row.get("후보") or "")):
if ref not in whole.index.get("LB", {}):
out.append(f"{whole.label(key)} · 후보 「{ref}」 없는 인력 키")
nara = whole.index.get("MN", {})
for key, row in whole.index.get("MT", {}).items():
source = str(row[""]["조달"].get("출처") or "")
if source.startswith("나라장터:") and source.split(":", 1)[1] not in nara:
out.append(f"재료:시중물가:{key} · 조달 「{source}」 이 나라장터자재에 없음")
out.append(f"{whole.label(key)} · 조달 「{source}」 이 나라장터자재에 없음")
return out
@@ -302,7 +323,7 @@ def check_body(files: dict[str, dict]) -> list[str]:
out, by_section = [], {}
for name, data in files.items():
for table in (data.get("") or []) if data.get("그룹") in mf.TABLE_GROUPS else []:
where = f"{name} · {table.get('열쇠')}"
where = f"{name} · {table.get('')} {table.get('원문번호')}"
md, ident, text = section_of(str(table.get("출처", "")))
if not text:
out.append(f"{where} · 본문 절 못 찾음 「{table.get('출처')}")
@@ -319,6 +340,7 @@ def check_body(files: dict[str, dict]) -> list[str]:
slot = by_section.setdefault((md, ident), [text, set(), []])
slot[1] |= mine
slot[2].append(where)
_elements_into(files, by_section)
for (md, ident), (text, mine, tables) in by_section.items():
for (md2, ident2), (_, mine2, tables2) in by_section.items():
if md2 == md and ident2.startswith(ident + "-"):
@@ -333,30 +355,63 @@ def check_body(files: dict[str, dict]) -> list[str]:
return out
def _elements_into(files: dict[str, dict], by_section: dict) -> None:
"""요소 줄(기계 운전경비처럼 표 대신 요소로 올린 절)의 수도 그 절 결손 대조에 넣음."""
seen: dict[str, tuple] = {}
for data in files.values():
if data.get("그룹") in (*mf.TABLE_GROUPS, "로직"):
continue
for row in data.get("") or []:
source = row.get("출처")
for text in source.values() if isinstance(source, dict) else [source]:
if not isinstance(text, str) or not text.startswith(("건설품셈", "산림품셈")):
continue
if text not in seen:
seen[text] = section_of(text)[:2]
slot = by_section.get(seen[text])
if slot:
slot[1] |= _flat(row.get("")) | _flat(
[row.get(k) for k in ("이름", "규격", "원문번호")]
)
# ── (3) 로직 ──────────────────────────────────────────────────────────
def check_logics(files: dict[str, dict], whole: mf.Master) -> list[str]:
def check_logics(files: dict[str, dict], whole: mf.Master, book: dict | None = None) -> list[str]:
out, graph = [], {}
for name, data in files.items():
if data.get("그룹") != "로직":
continue
for row in data.get("", []):
found, calls = mf.check_logic(whole, data.get("원문"), row)
found, calls = mf.check_logic(whole, row)
out += [f"{name} · {x}" for x in found]
graph[f"{data.get('원문')}:{row.get('열쇠')}"] = calls
graph[str(row.get(""))] = calls
out += [f"돌고 도는 참조 · {''.join(loop)}" for loop in mf.find_loops(graph)]
return out + duplicate_keys(files) + check_links(whole)
return out + duplicate_keys(files, book) + check_links(whole)
def duplicate_keys(files: dict[str, dict]) -> list[str]:
"""같은 그룹·원문에 같은 열쇠 — 먼저 읽힌 파일 것만 찾기에 걸림"""
first, out = {}, []
def duplicate_keys(files: dict[str, dict], book: dict | None = None) -> list[str]:
"""키 겹침(전체) · 원문번호 겹침(테이블 안 — `ID:원문번호` 찾기가 먼저 것만 봄) · 대장 대조."""
book = mk.load_book() if book is None else book
first, numbers, out = {}, {}, []
for name, data in files.items():
rows = data.get("") if data.get("그룹") in mf.TABLE_GROUPS else data.get("")
for row in rows or []:
ref = f"{data.get('그룹')}:{data.get('원문')}:{row.get('열쇠')}"
if ref in first and first[ref] != name:
out.append(f"{name} · 열쇠 겹침 「{ref}」 — {first[ref]} 에도 있음")
first.setdefault(ref, name)
key, number = str(row.get("")), str(row.get("원문번호"))
if key in first:
out.append(f"{name} · 겹침 「{key}」 — {first[key]} 에도 있음")
first.setdefault(key, name)
if number and (key[:2], number) in numbers:
out.append(
f"{name} · {key} · 원문번호 겹침 「{number}」 — {numbers[key[:2], number]}"
)
numbers.setdefault((key[:2], number), key)
had = book[""].get(key)
if had is None:
out.append(f"{name} · {key} · 대장에 없는 키")
elif had["원문번호"] != number:
out.append(f"{name} · {key} · 대장 원문번호 「{had['원문번호']}」 와 다름")
elif int(key[2:]) >= book["다음"].get(key[:2], 0):
out.append(f"{name} · {key} · 대장 다음 번호보다 큼")
return out