# -*- 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()