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:
@@ -0,0 +1,317 @@
|
||||
# -*- 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()
|
||||
Reference in New Issue
Block a user