- 모든 요소·표·로직 줄의 열쇠 → 키(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
318 lines
13 KiB
Python
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()
|