Files
Aislo/resources/master_data/scripts/_migrate_keys.py
T
eomsangdonandClaude Opus 5 6c9ac97b29 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
2026-09-20 01:56:30 +09:00

318 lines
13 KiB
Python

# -*- coding: utf-8 -*-
"""한 번 돌린 옮기기 — 열쇠 → 키(테이블ID + 6자리) · 인력·기계 한 테이블 · 장 파일 이름 (`_틀.md`).
옛 변수 `그룹:원문:열쇠` · `찾기(그룹:원문:열쇠, …)` · `로직(원문:열쇠, …)` → 키 · `{이름}` 낀 열쇠는 `ID:원문번호`.
표·로직·재료 파일은 글 모양 그대로 두고 바뀐 자리만 갈음 · 인력·기계는 새로 씀.
돌리기(옛 틀 파일 · 옛 master_formula(3f4fd66d) 일 때만 — 한 번 돌림): ./venv/Scripts/python.exe resources/master_data/scripts/_migrate_keys.py
"""
from __future__ import annotations
import json
import re
import sys
from decimal import Decimal
from pathlib import Path
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[2]
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(ROOT))
import master_formula as mf # noqa: E402 — 옛 틀 식 읽기
import master_keys as mk # noqa: E402
from M01_MasterData import M01_MasterData_Store as st # noqa: E402
MD = mk.MASTER
BODY = ROOT / "resources/knowledge/original/원가계산"
DIVS = ("공통", "토목", "건축", "기계설비", "유지관리")
DIV_DIR = {
"공통": "01_공통부문",
"토목": "02_토목부문",
"건축": "03_건축부문",
"기계설비": "04_기계설비부문",
"유지관리": "05_유지관리부문",
}
LABOR = {
"건설노임": "건설업",
"제조노임": "제조업",
"엔지니어링노임": "엔지니어링",
"측량노임": "측량",
"건설사업관리노임": "건설사업관리",
"SW노임": "SW",
"산림노임": "산림",
"준용대상": "준용대상",
}
MACHINE = {"건설품셈": "건설품셈", "산림품셈": "산림품셈"}
_CHAP = re.compile(r"^(소요량|계수|로직)_(산림품셈|건설품셈)_(\D*)(\d+)장\.json$")
_OLDREF = re.compile(r"(인력|재료|기계|소요량|계수|로직|환율|요율):([^:\s·]+):(.+?)(?=\s·\s|$)")
_STR = re.compile(r'"(?:[^"\\]|\\.)*"')
def title(book: str, div: str, num: int) -> str:
base = BODY / (
"산림_표준품셈/본문" if book == "산림품셈" else f"건설공사_표준품셈/본문/{DIV_DIR[div]}"
)
(hit,) = base.glob(f"제{num:02d}장_*")
return hit.name.split("_", 1)[1]
def chapter_order(name: str) -> tuple:
m = _CHAP.match(name)
book, div, num = m.group(2), m.group(3), int(m.group(4))
return (book != "산림품셈", DIVS.index(div) if div else -1, num)
class _Spans(mf._Parser):
"""옛 식 읽기 + 요소·표·로직 머리 글자 자리."""
def __init__(self, text: str):
super().__init__(text)
self.spans: list[tuple[int, int, str]] = []
def ref(self, head: bool = False) -> str:
at = self.i
text = super().ref(head)
raw = self.s[at : self.i]
start = at + len(raw) - len(raw.lstrip())
if start > 0 and self.s[start - 1] == ":": # 앞 그룹 이름까지
group = re.search(r"[\w가-힣]+:$", self.s[:start])
self.spans.append((group.start(), start + len(text), group.group() + text))
else: # 로직(원문:열쇠)
self.spans.append((start, start + len(text), "로직:" + text))
return text
class Migrate:
def __init__(self):
self.files = {
p.name: json.loads(
p.read_text(encoding="utf-8"), parse_float=Decimal, parse_int=Decimal
)
for p in sorted(MD.glob("*.json"))
if not p.name.startswith("_")
}
self.texts = {p.name: p.read_text(encoding="utf-8") for p in MD.glob("*.json")}
self.book = {"다음": {}, "키": {}}
self.keys: dict[tuple[str, str, str], str] = {} # (그룹, 원문, 열쇠) → 키
self.names: dict[str, str] = {}
self.new_name: dict[str, str] = {}
self.order: dict[str, int] = {}
self.missing: list[str] = []
# ── 키 주기 ──
def plan(self) -> None:
chapters = sorted((n for n in self.files if _CHAP.match(n)), key=chapter_order)
for group in ("소요량", "계수", "로직"):
mine = [n for n in chapters if n.startswith(group + "_")]
for i, name in enumerate(mine, start=1):
m = _CHAP.match(name)
book, div, num = m.group(2), m.group(3), int(m.group(4))
self.new_name[name] = f"{group}_{book}_{num:02d}장_{title(book, div, num)}.json"
self.order[name] = i
for name in self.files:
self.new_name.setdefault(name, name)
for b in LABOR:
self.new_name[f"인력_{b}.json"] = "인력.json"
for b in MACHINE:
self.new_name[f"기계_{b}.json"] = "기계.json"
first = [f"인력_{b}.json" for b in LABOR] + [f"기계_{b}.json" for b in MACHINE]
rest = [n for n in self.files if n not in first and not _CHAP.match(n)]
for name in first + rest + chapters:
data = self.files[name]
group, book = data["그룹"], data["원문"]
tid = mk.table_id(group, book)
for row in self.rows(data):
number = str(row["열쇠"])
key = mk.issue(self.book, tid, number, self.new_name[name])
assert (group, book, number) not in self.keys, (name, number)
self.keys[(group, book, number)] = key
self.names[key] = str(row.get("이름") or number)
@staticmethod
def rows(data: dict) -> list[dict]:
return data.get("표") if data["그룹"] in mf.TABLE_GROUPS else data.get("줄")
# ── 변수 갈음 ──
def ref(self, full: str) -> str:
group, book, number = (full.split(":", 2) + ["", ""])[:3]
tid = mk.table_id(group, book)
if "{" in number:
return f"{tid}:{number}"
key = self.keys.get((group, book, number))
if not key:
self.missing.append(full)
return full
return key
def formula(self, text):
if not isinstance(text, str):
return text
p = _Spans(text)
p.parse()
out = text
for start, end, full in sorted(p.spans, reverse=True):
out = out[:start] + self.ref(full) + out[end:]
return out
def link(self, value, group: str, book: str):
"""준용·통합 — `그룹:원문:열쇠` 또는 같은 원문 열쇠 → 키."""
if value in (None, ""):
return value
value = str(value)
return self.ref(value if ":" in value else f"{group}:{book}:{value}")
def hint(self, text):
"""후보 글 속 `인력:원문:열쇠` → 「키 이름」."""
if not isinstance(text, str):
return text
def one(m):
key = self.ref(m.group(0))
return f"{key} {self.names.get(key, '')}".strip()
return _OLDREF.sub(one, text)
def row(self, data: dict, row: dict) -> dict:
group, book = data["그룹"], data["원문"]
out = {"키": self.keys[(group, book, str(row["열쇠"]))], "원문번호": str(row["열쇠"])}
if group == "인력":
out["조사"] = LABOR[book]
if group == "기계":
out["세부분류"] = MACHINE[book]
for k, v in row.items():
if k == "열쇠":
continue
if group == "로직" and k in ("중간", "덧줄"):
v = [{**s, "식": self.formula(s["식"])} for s in v]
elif group == "로직" and k == "결과":
v = {**v, "식": self.formula(v["식"])}
elif group == "로직" and k == "호표":
v = [
{**h, "수량": self.formula(h["수량"]), "요소": self.formula(h["요소"])}
for h in v
]
elif k in ("준용", "통합"):
v = self.link(v, group, book)
elif k == "후보":
v = self.hint(v)
elif k == "연결" and isinstance(v, str):
v = self.ref(f"재료:시중물가:{v}")
elif k == "값" and book == "시중물가":
source = str(v["조달"].get("출처") or "")
if source.startswith("나라장터:"):
new = "나라장터:" + self.ref("재료:나라장터자재:" + source.split(":", 1)[1])
v = {**v, "조달": {**v["조달"], "출처": new}}
out[k] = v
return out
# ── 쓰기 ──
def head(self, name: str, data: dict) -> dict:
m = _CHAP.match(name)
out = {"그룹": data["그룹"], "원문": data["원문"]}
if m and m.group(3):
out["부문"] = m.group(3)
if m:
out["차례"] = Decimal(self.order[name])
return {**out, **{k: v for k, v in data.items() if k not in ("표", "줄")}}
def keep(self, name: str, data: dict, new: dict) -> str:
"""글 모양 그대로 — 「열쇠」 칸 · 바뀐 글 · 머리 두 칸만 갈음. 읽어 맞대어 다르면 dump."""
text = self.texts[name]
rows = self.rows(new)
spots = list(re.finditer(r'"열쇠": ("(?:[^"\\]|\\.)*"|[-\d.]+)', text))
if len(spots) == len(rows):
parts, at = [], 0
for m, row in zip(spots, rows):
parts += [
text[at : m.start()],
f'"키": "{row["키"]}", "원문번호": {json.dumps(row["원문번호"], ensure_ascii=False)}',
]
at = m.end()
text = "".join(parts) + text[at:]
swap = {}
for old, row in zip(self.rows(data), rows):
for k in row:
if k in old and old[k] != row[k]:
for a, b in zip(self.strings(old[k]), self.strings(row[k])):
if a != b:
swap[json.dumps(a, ensure_ascii=False)] = json.dumps(
b, ensure_ascii=False
)
text = _STR.sub(lambda m: swap.get(m.group(0), m.group(0)), text)
extra = {k: v for k, v in new.items() if k in ("부문", "차례")}
if extra:
add = "".join(
f" {json.dumps(k, ensure_ascii=False)}: {st._atom(v)},\n"
for k, v in extra.items()
)
text = re.sub(
r'(\n "원문": "[^"]*",\n)', lambda m: m.group(1) + add, text, count=1
)
if json.loads(text, parse_float=Decimal, parse_int=Decimal) == new:
return text
print(" 모양 못 지켜 새로 씀:", name)
return st.dump(new)
@classmethod
def strings(cls, v):
if isinstance(v, str):
yield v
elif isinstance(v, dict):
for x in v.values():
yield from cls.strings(x)
elif isinstance(v, list):
for x in v:
yield from cls.strings(x)
@staticmethod
def dump_rows(head: dict, rows: list[dict]) -> str:
out = ["{"]
for k, v in head.items():
out.append(f" {st._atom(k)}: {st.dump(v, 2)},")
out.append(' "줄": [')
for i, row in enumerate(rows):
line = st._flat(row)
body = line if len(line) <= 450 else st.dump(row, 4, expand=True)
out.append(" " + body + ("," if i < len(rows) - 1 else ""))
out += [" ]", "}"]
return "\n".join(out) + "\n"
def run(self) -> None:
self.plan()
written = {}
merged = {"인력": ({}, []), "기계": ({}, [])}
for name, data in self.files.items():
new_rows = [self.row(data, r) for r in self.rows(data)]
if data["그룹"] in merged:
heads, rows = merged[data["그룹"]]
label = LABOR[data["원문"]] if data["그룹"] == "인력" else MACHINE[data["원문"]]
heads[label] = {k: v for k, v in data.items() if k not in ("그룹", "줄")}
rows += new_rows
continue
new = {
**self.head(name, data),
("표" if data["그룹"] in mf.TABLE_GROUPS else "줄"): new_rows,
}
written[self.new_name[name]] = self.keep(name, data, new)
written["인력.json"] = self.dump_rows(
{"그룹": "인력", "조사": merged["인력"][0]}, merged["인력"][1]
)
written["기계.json"] = self.dump_rows(
{"그룹": "기계", "세부분류": merged["기계"][0]}, merged["기계"][1]
)
if self.missing:
print("못 찾은 변수", len(self.missing), sorted(set(self.missing))[:30])
raise SystemExit(1)
for name in self.files:
(MD / name).unlink()
for name, text in written.items():
(MD / name).write_text(text, encoding="utf-8", newline="\n")
mk.save_book(mk.finish(self.book))
print("파일", len(written), "· 키", len(self.book["키"]), dict(self.book["다음"]))
if __name__ == "__main__":
sys.stdout.reconfigure(encoding="utf-8")
Migrate().run()