feat(master_data): 키 개편 뒤따름 — _틀.md 새 규칙 · 빌더 잇기(adopt) · ESTX 비교 · 화면 계약

- _틀.md: 파일 이름(인력.json · 기계.json · NN장_장 제목) · 머리 부문·차례 · 키·테이블ID·대장 · 변수(키 · ID:원문번호 · 찾기(QF…) · 로직(GC…))
- master_keys: old_view(옛 이름으로 읽기) · adopt(빌더 옛 모양 → 키·한 테이블·장 이름 · 부문 앞말 · 참조 변환) · order_chapters(차례)
- 빌더 7개 끝에서 adopt · 노임·시중물가 빌더 다시 돌려 결과 같음(시중물가 머리 비고 글 한 줄만 「키」) · 기계·기계설비 7장은 사본 폴더에서 같음
- compare_estx: 인력.json 조사 · 기계.json 세부분류 · 로직 GC:원문번호 · 네 갈래 모두 돌아감
- _화면_계약.md: key = 키 · 새 줄 키는 서버가 대장 다음 번호 · 키 못 고침

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 02:09:04 +09:00
co-authored by Claude Opus 5
parent 6c9ac97b29
commit 32b19c547d
15 changed files with 395 additions and 79 deletions
@@ -23,6 +23,7 @@ from __future__ import annotations
import json
from pathlib import Path
import master_keys as mk
from _migrate_base import dump_master
ROOT = Path(__file__).resolve().parents[3]
@@ -57,7 +58,7 @@ def notice_day(row: dict) -> str:
def build() -> dict:
raw = json.loads(RAW.read_text(encoding="utf-8"))
master = json.loads(MASTER.read_text(encoding="utf-8"))
master = mk.old_view(MASTER.name)
records = master[""]
history: dict[str, list[dict]] = {}
@@ -99,3 +100,4 @@ if __name__ == "__main__":
master = result["master"]
dump_master(MASTER, {k: v for k, v in master.items() if k != ""}, master[""])
print(f"고친 줄 {result['repaired']} · 못 고친 줄 {result['unrecovered']}")
print("새 틀로 받음", mk.adopt())
@@ -3,7 +3,7 @@
돌리기: `./venv/Scripts/python.exe resources/master_data/scripts/_migrate_base.py`
옛 파일은 `old/` 에서 읽기만 함. 새 파일은 `resources/master_data/` 첫 층에 씀. 값은 바꾸지 않음.
`dump_master` 는 `_build_mat_price.py` 도 씀.
`dump_master` 는 `_build_mat_price.py` 도 씀. 한 번 돌린 옮기기 — 지금 틀(키 · 인력.json)은 뒤에 `_migrate_keys.py` 로 옮김.
"""
from __future__ import annotations
@@ -14,7 +14,7 @@ import re
from pathlib import Path
HERE = Path(__file__).resolve().parent
TARGET = HERE.parent / "계수_산림품셈_01장.json"
TARGET = HERE.parent / "계수_산림품셈_01장_적용기준.json" # 한 번 돌림 — 이미 들어감
BODY = HERE.parents[2] / "resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준"
SRC = "산림품셈 "
@@ -599,7 +599,7 @@ def main() -> None:
text = TARGET.read_bytes().decode("utf-8")
crlf = "\r\n" in text
text = text.replace("\r\n", "\n")
if '"열쇠": "1-2-2 ' in text:
if '"원문번호": "1-2-2 ' in text:
raise SystemExit("이미 추가됨")
A, T = from_1_02(), from_text()
front = [
@@ -2,6 +2,7 @@
본문 md(확정) 표를 읽어 기계 한 줄로 합침. 값은 md 에서만 · 계산·추정 없음.
실행: python resources/master_data/scripts/build_기계_건설품셈.py [--dry]
옛 모양(열쇠)으로 쓰고 `master_keys.adopt` 가 기계.json(키 · 세부분류 건설품셈)으로 받음.
"""
import json
@@ -10,6 +11,9 @@ import sys
from pathlib import Path
REPO = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(Path(__file__).parent))
import master_keys as mk # noqa: E402
DIR = (
REPO
/ "resources/knowledge/original/원가계산/건설공사_표준품셈/본문/01_공통부문/제08장_건설기계"
@@ -653,3 +657,4 @@ if __name__ == "__main__":
with open(OUT, "w", encoding="utf8", newline="\n") as f:
f.write(text)
print("", OUT)
print("새 틀로 받음", mk.adopt(OUT.parent))
@@ -12,6 +12,7 @@ import re
import sys
from pathlib import Path
import master_keys as mk
from _계수_공통2장 import lift_table
ROOT = Path(__file__).resolve().parents[3]
@@ -681,6 +682,7 @@ if __name__ == "__main__":
if dup:
raise SystemExit(f"열쇠 겹침 {dup}")
OUT.write_text(dump(tables), encoding="utf-8", newline="\n")
print("새 틀로 받음", mk.adopt()) # 열쇠 → 키 · 장 파일 이름
for f, (a, b, _) in count.items():
print(f"{f} · 옮긴 표 {a} · 계수 표 {b}")
print("합계", sum(a for a, _, _ in count.values()), sum(b for _, b, _ in count.values()))
@@ -12,6 +12,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import master_keys as mk # noqa: E402
from _md표_읽기 import ( # noqa: E402
LOSE,
PCT,
@@ -607,3 +608,4 @@ if __name__ == "__main__":
f()
write()
print(len(TABLES), "")
print("새 틀로 받음", mk.adopt()) # 열쇠 → 키 · 장 파일 이름
@@ -9,6 +9,7 @@ import re
import sys
import _토목_읽기 as base
import master_keys as mk
from _설비_계수 import rows_of
from _설비_읽기 import DIV, read_chapter
from _설비_표 import convert
@@ -58,3 +59,4 @@ if __name__ == "__main__":
for w in sys.argv[1:] or ["7", "8", "9", "10", "11", "12", "13"]:
o = build(int(w), f"소요량_건설품셈_기계설비{w}장.json")
print(w, len(o.tables), "")
print("새 틀로 받음", mk.adopt()) # 열쇠 → 키 · 장 파일 이름
@@ -8,6 +8,7 @@
import re
import sys
import master_keys as mk
from _md표_읽기 import N, R, join
from _토목_계수 import lift
from _토목_손 import SPECIAL
@@ -163,3 +164,4 @@ if __name__ == "__main__":
}[w]
o = build(ch[0], ch[1], SPECIAL.get(w, {}))
print(w, len(o.tables), "")
print("새 틀로 받음", mk.adopt()) # 열쇠 → 키 · 장 파일 이름
@@ -8,6 +8,7 @@
이음 = 원문 회차의 전체직종 평균을 그대로 이음(미공표직종_추적.md 4) · 산정값은 같은 md 5절과 대조.
쓰기: ./venv/Scripts/python.exe resources/master_data/scripts/build_인력_노임.py
모양(원문별 파일 · 열쇠)으로 쓰고 끝에 `master_keys.adopt` 인력.json( · 조사)으로 받음.
"""
import hashlib
@@ -19,6 +20,7 @@ from decimal import ROUND_HALF_UP, Decimal
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
import master_keys as mk # noqa: E402
from _migrate_base import dump_master # noqa: E402
ROOT = Path(__file__).resolve().parents[3]
@@ -289,14 +291,14 @@ def main():
assert {b: len(v[2]) for b, v in books.items()} == expect
# 이름 → ref (새 다섯 + 건설·제조 공표)
wage = json.loads((MD / "인력_건설노임.json").read_text(encoding="utf-8"))
make = json.loads((MD / "인력_제조노임.json").read_text(encoding="utf-8"))
wage = mk.old_view("인력_건설노임.json")
make = mk.old_view("인력_제조노임.json")
pool = {r["이름"]: f"인력:{b}:{r['열쇠']}" for b, (_, _, rows) in books.items() for r in rows}
old_pool = {r["이름"]: f"인력:{d['원문']}:{r['열쇠']}" for d in (wage, make) for r in d[""]}
merges = merge_list()
alias_path = MD / "인력_준용대상.json"
alias = json.loads(alias_path.read_text(encoding="utf-8"))
alias = mk.old_view(alias_path.name)
moved, kept = {}, []
for r in alias[""]:
name = r["이름"]
@@ -318,7 +320,7 @@ def main():
r["후보"] = "비슷한 이름: " + " · ".join(near)
kept.append(r)
alias["비고"] = (
"값을 확인 못 한 직종(공표 노임에 이름이 온전히 같은 직종 없음) — 이름·단위만 · 준용 = 관리자가 고르는 공표 직종 열쇠(`인력:원문:열쇠`)"
"값을 확인 못 한 직종(공표 노임에 이름이 온전히 같은 직종 없음) — 이름·단위만 · 준용 = 관리자가 고르는 공표 직종 키(`LB000123`)"
" · 통합 = 건설노임 원문 「통합·명칭변경 직종」 목록의 새 직종(엔진이 그 값을 씀) · 후보 = 비슷한 이름(값 안 씀) · 비고 = 이 직종을 요구한 품셈 절"
)
head = {k: v for k, v in alias.items() if k != ""}
@@ -345,6 +347,7 @@ def main():
)
estimate(wage)
print("새 틀로 받음", mk.adopt())
# ── 건설노임 미공표 산정 ─────────────────────────────────────────────
@@ -23,6 +23,7 @@ import unicodedata
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
import master_keys as mk # noqa: E402
from _migrate_base import dump_master # noqa: E402
from compare_estx import ESTX, ROOT, SLOTS, Estx, bare_spec, base_name # noqa: E402
@@ -169,12 +170,13 @@ def main():
"비고": [
"다산소프트 EST 2026-07 자재 · 개발용 · 배포 전 판권 구매",
"값 = 시중(물가지 다섯 · 면수) · 조달(값 · 출처 · 기준) — 기본가·채택값 없음 · 쓰는 곳이 값을 고르고 출처를 밝힘",
"조달 출처 「가격정보」 = ESTX 가격정보 칸의 조달청 관급 단가 · 「나라장터:<열쇠>」 = 재료_나라장터자재 줄로 이음(값 안 베낌)",
"조달 출처 「가격정보」 = ESTX 가격정보 칸의 조달청 관급 단가 · 「나라장터:<>」 = 재료_나라장터자재 줄로 이음(값 안 베낌)",
],
}
nara = json.loads(NARA.read_text(encoding="utf-8"))[""]
nara = mk.old_view(NARA.name)[""]
dump_master(OUT, head, build(rows, nara_links(rows, nara)))
check(rows)
print("새 틀로 받음", mk.adopt()) # 열쇠 → 키 · 나라장터 연결 → 키
if __name__ == "__main__":
+22 -19
View File
@@ -122,9 +122,10 @@ def labor(estx: Estx, files: dict):
heads = [r["Name"] for r in rows if r.get("Name") and not r.get("Unite")]
print("## 노임 — ESTX 머리줄", heads)
ours = {}
for fname, tag in (("인력_건설노임.json", "건설"), ("인력_제조노임.json", "제조")):
for r in files[fname][""]:
ours.setdefault((tag, norm(r["이름"])), r)
for survey, tag in (("건설업", "건설"), ("제조업", "제조")):
for r in files["인력.json"][""]:
if r["조사"] == survey:
ours.setdefault((tag, norm(r["이름"])), r)
theirs = {}
for r in rows:
if r.get("Unite") != "":
@@ -230,7 +231,7 @@ def material(estx: Estx, files: dict):
for r in files["재료_품셈재료.json"][""]:
if r.get("연결"):
continue
mine = {base_name(r["이름"]), base_name(r["열쇠"])}
mine = {base_name(r["이름"]), base_name(r["원문번호"])}
hits = [e for m in mine for e in by_name.get(m, [])]
if hits:
have.append((r, hits))
@@ -243,15 +244,15 @@ def material(estx: Estx, files: dict):
f"### 재료_품셈재료 못 이음 {len(have) + len(near) + len(none)} — ESTX 품명 같음 {len(have)} · 품명 끝 같음 {len(near)} · 없음 {len(none)}"
)
for r, hits in near:
print(f" 비슷 {r['열쇠']} | {sorted({e['Name'] for e in hits})[:4]}")
print(f" 비슷 {r['원문번호']} | {sorted({e['Name'] for e in hits})[:4]}")
for r, hits in have:
s = bare_spec(r["이름"], r.get("규격"))
same = [e for e in hits if s and spec_match(s, bare_spec(e["Name"], e.get("Size")))]
h = (same or hits)[0]
print(
f" 있음 {r['열쇠']} [{r.get('단위')}] | ESTX {len(hits)}줄 규격같음 {len(same)} | 예 {h['Name']} {h.get('Size')} {h.get('Unite')} {h['TC']:.0f}"
f" 있음 {r['원문번호']} [{r.get('단위')}] | ESTX {len(hits)}줄 규격같음 {len(same)} | 예 {h['Name']} {h.get('Size')} {h.get('Unite')} {h['TC']:.0f}"
)
print(" 없음", [r["열쇠"] for r, _ in none])
print(" 없음", [r["원문번호"] for r, _ in none])
# ── (3) 기계경비 ──────────────────────────────────────────────────────
@@ -284,7 +285,7 @@ def mach_key(name, size) -> str:
def machine_cost(master: mf.Master, mid: str, region="전국평균"):
"""로직으로 계산 — 고르기 밖 기계는 메모리에서만 고르기에 더함."""
for key in LOGICS:
row = master.logic("건설품셈:" + key)
row = master.logic("GC:" + key)
spec = next(i for i in row["입력"] if i["이름"] == "기계")
added = mid not in spec["고르기"]
if added:
@@ -292,7 +293,7 @@ def machine_cost(master: mf.Master, mid: str, region="전국평균"):
try:
given = {"기계": mid, "지역": region}
given = {k: v for k, v in given.items() if any(i["이름"] == k for i in row["입력"])}
return key, mf.run(master, "건설품셈:" + key, given)
return key, mf.run(master, "GC:" + key, given)
except (mf.FormulaError, ArithmeticError, KeyError, TypeError):
continue
finally:
@@ -306,17 +307,17 @@ def machine(estx: Estx, files: dict, master: mf.Master):
for r in estx.table("MachTitleDat"):
if r.get("Unite") == "hr" and r.get("TC"):
est.setdefault(mach_key(r.get("Name"), r.get("Size")), r)
ours = files["기계_건설품셈.json"][""]
ours = [m for m in files["기계.json"][""] if m["세부분류"] == "건설품셈"]
pairs = [(m, est[k]) for m in ours if (k := mach_key(m["이름"], m["규격"])) in est]
print(f"## 기계 — 우리 {len(ours)} · ESTX 시간당 {len(est)} · 기종·규격 짝 {len(pairs)}")
out = []
for m, e in pairs:
key, res = machine_cost(master, m["열쇠"])
key, res = machine_cost(master, m["원문번호"])
if not res:
continue
out.append((m, e, key, res))
print(f" 로직 계산됨 {len(out)}")
first = [x for x in out if x[0]["열쇠"].startswith(FIRST)]
first = [x for x in out if x[0]["원문번호"].startswith(FIRST)]
for label, group in (("임도 기계", first), ("전체", out)):
print(f"### {label}")
print(" 계:", spread([rate(e["TC"], res[""]) for m, e, k, res in group]))
@@ -340,18 +341,18 @@ def machine(estx: Estx, files: dict, master: mf.Master):
e.get("GC") and abs(rate(e["GC"], res["재료비"])) > 5
):
print(
f" {m['열쇠']} {m['이름']} {m['규격']} {k} | ESTX 재 {e.get('GC', 0):.0f}{e['JC']:.0f} | 우리 재 {res['재료비']:.0f}{res['경비']:.0f}"
f" {m['원문번호']} {m['이름']} {m['규격']} {k} | ESTX 재 {e.get('GC', 0):.0f}{e['JC']:.0f} | 우리 재 {res['재료비']:.0f}{res['경비']:.0f}"
)
for m, e, k, res in first:
print(
f" {m['열쇠']} {m['이름']} {m['규격']} | ESTX {e['TC']:.0f} (노 {e.get('NC', 0):.0f}{e.get('GC', 0):.0f}{e.get('JC', 0):.0f})"
f" {m['원문번호']} {m['이름']} {m['규격']} | ESTX {e['TC']:.0f} (노 {e.get('NC', 0):.0f}{e.get('GC', 0):.0f}{e.get('JC', 0):.0f})"
f" | 우리 {res['']:.0f} (노 {res['노무비']:.0f}{res['재료비']:.0f}{res['경비']:.0f}) {rate(e['TC'], res['']):+.1f}%"
)
return out
# ── (4) 일위대가 ──────────────────────────────────────────────────────
# (ESTX 제목 코드, 로직 열쇠, 입력[, 단위 환산 배수 — 우리 값 × 배수 = ESTX 단위])
# (ESTX 제목 코드, 로직 원문번호, 입력[, 단위 환산 배수 — 우리 값 × 배수 = ESTX 단위])
REGION = {"지역": "전국평균", "보정작업": "해당 없음"}
PAIRS = [
(
@@ -456,14 +457,15 @@ def with_estx_materials(master: mf.Master, estx: Estx, code: str):
"""ESTX 일위대가의 자재 단가를 품명으로 우리 빈 재료(값 없음)에만 임시로 넣은 사본 — 파일에 안 씀."""
temp = copy.copy(master)
temp.index = {k: dict(v) for k, v in master.index.items()}
temp.numbers = {k: dict(v) for k, v in master.numbers.items()}
items = [
d
for d in estx.details("HeadMainDat").get(code, [])
if str(d.get("Code", "")).startswith("M")
]
put = []
for book in (("재료", "자체"), ("재료", "나라장터자재")):
rows = temp.index.get(book, {})
for tid in ("MN",):
rows = temp.index.get(tid, {})
for key, row in list(rows.items()):
if row.get("") is not None:
continue
@@ -472,6 +474,7 @@ def with_estx_materials(master: mf.Master, estx: Estx, code: str):
)
if hit:
rows[key] = {**row, "": Decimal(str(hit["TU"]))}
temp.numbers[tid][str(row["원문번호"])] = rows[key]
put.append(f"{key}={hit['TU']:.0f}")
return temp, put
@@ -484,11 +487,11 @@ def unit_price(estx: Estx, master: mf.Master):
h = heads[code]
k = Decimal(scale[0]) if scale else Decimal(1)
temp, put = with_estx_materials(master, estx, code)
row = temp.logic("건설품셈:" + key)
row = temp.logic("GC:" + key)
given = {k: v for k, v in given.items() if any(i["이름"] == k for i in row.get("입력", []))}
given = {k: Decimal(str(v)) if isinstance(v, (int, float)) else v for k, v in given.items()}
try:
res = mf.run(temp, "건설품셈:" + key, given)
res = mf.run(temp, "GC:" + key, given)
res = {c: res[c] * k for c in ("", *mf.COST_ITEMS)}
except (mf.FormulaError, ArithmeticError, KeyError, TypeError) as e:
print(f" {code} {h['Name']} {h.get('Size')} | {key} | 멈춤: {str(e)[:120]}")
+260 -2
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import json
import re
from decimal import Decimal
from pathlib import Path
MASTER = Path(__file__).resolve().parent.parent
@@ -62,12 +63,18 @@ def save_book(book: dict, path: Path = BOOK) -> None:
path.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
def issue(book: dict, tid: str, number: str, file: str) -> str:
"""(테이블ID, 원문번호) 키 — 대장에 있으면 그 키 · 없으면(원문번호 빈 새 줄 포함) 다음 번호를 줌."""
def lookup(book: dict) -> dict:
"""(테이블ID, 원문번호) 키 — 대장에서 한 번 세워 둠(`finish` 가 걷음)."""
index = book.setdefault("_찾기", {})
if not index:
for key, v in book[""].items():
index[(key[:2], v["원문번호"])] = key
return index
def issue(book: dict, tid: str, number: str, file: str) -> str:
"""(테이블ID, 원문번호) 의 키 — 대장에 있으면 그 키 · 없으면 다음 번호(원문번호 빈 새 줄 포함)."""
index = lookup(book)
key = index.get((tid, number)) if number else None
if key:
book[""][key]["파일"] = file
@@ -97,3 +104,254 @@ def stamp(
key = row.pop("", None) or issue(book, tid, number, file)
out.append({"": key, "원문번호": number, **(extra or {}), **row})
return out
# ── 빌더 잇기 — 빌더는 옛 모양(「열쇠」 · 원문별 파일 이름)으로 쓰고 adopt 가 새 틀로 받음 ──
LABOR = {
"건설노임": "건설업",
"제조노임": "제조업",
"엔지니어링노임": "엔지니어링",
"측량노임": "측량",
"건설사업관리노임": "건설사업관리",
"SW노임": "SW",
"산림노임": "산림",
"준용대상": "준용대상",
}
MERGED = {
"인력": ("인력.json", "조사", LABOR),
"기계": ("기계.json", "세부분류", {"건설품셈": "건설품셈", "산림품셈": "산림품셈"}),
}
DIVISIONS = ("공통", "토목", "건축", "기계설비", "유지관리")
OLD_CHAPTER = re.compile(r"^(소요량|계수|로직)_(산림품셈|건설품셈)_(\D*)(\d+)장\.json$")
CHAPTER = re.compile(r"^(소요량|계수|로직)_(산림품셈|건설품셈|자체)_(\d\d)장_.+\.json$")
_BODY = MASTER.parents[1] / "resources/knowledge/original/원가계산"
_OLDREF = re.compile(r"(인력|재료|기계|소요량|계수|로직|환율|요율):([^:\s·]+):(.+?)(?=\\s|$)")
_STR = re.compile(r'"(?:[^"\\]|\\.)*"')
def title(book: str, division: str, number: int) -> str:
"""원문 장 제목 — 본문 폴더 이름 그대로(「제07장_지붕_및_홈통공사」 → 「지붕_및_홈통공사」)."""
base = _BODY / "산림_표준품셈/본문"
if book == "건설품셈":
index = DIVISIONS.index(division) + 1
base = _BODY / "건설공사_표준품셈/본문" / f"{index:02d}_{division}부문"
(hit,) = base.glob(f"{number:02d}장_*")
return hit.name.split("_", 1)[1]
def chapter_file(group: str, book: str, division: str, number: int) -> str:
return f"{group}_{book}_{number:02d}장_{title(book, division, number)}.json"
def _items(data: dict) -> str:
return "" if data.get("그룹") in ("소요량", "계수") else ""
def _read(path: Path, exact: bool = True) -> dict:
text = path.read_text(encoding="utf-8")
return json.loads(text, parse_float=Decimal, parse_int=Decimal) if exact else json.loads(text)
def old_view(name: str, folder: Path = MASTER) -> dict:
"""옛 파일 이름으로 읽기(빌더용 · 수는 float) — 줄의 「열쇠」 = 원문번호 · 키·갈래 칸 뺌.
준용·통합·연결 같은 참조는 그대로."""
m = re.match(r"^(인력|기계)_(.+)\.json$", name)
if m:
file, slot, labels = MERGED[m.group(1)]
data = _read(folder / file, exact=False)
label = labels[m.group(2)]
head = {"그룹": m.group(1), **data[slot][label]}
rows = [r for r in data[""] if r.get(slot) == label]
else:
c = OLD_CHAPTER.match(name)
path = folder / (chapter_file(c[1], c[2], c[3], int(c[4])) if c else name)
data = _read(path, exact=False)
head, rows, slot = (
{k: v for k, v in data.items() if k != _items(data)},
data[_items(data)],
"",
)
drop = ("", "원문번호", slot)
out = [{"열쇠": r["원문번호"], **{k: v for k, v in r.items() if k not in drop}} for r in rows]
return {**head, _items(head): out}
def _old_ref(book: dict, text: str) -> str:
"""옛 참조 `그룹:원문:열쇠` → 키(대장에 없으면 그대로 — check_master 가 잡음)."""
group, src, number = (text.split(":", 2) + ["", ""])[:3]
try:
tid = table_id(group, src)
except KeyError:
return text
return lookup(book).get((tid, number), text)
def _convert(book: dict, row: dict, names: dict) -> dict:
"""줄 속 옛 참조 — 준용·통합 · 후보 글(키 옆 이름) · 품셈재료 연결 · 시중물가 조달 나라장터 연결."""
for col in ("준용", "통합"):
value = row.get(col)
if isinstance(value, str) and ":" in value:
row[col] = _old_ref(book, value)
if isinstance(row.get("후보"), str):
def one(m):
key = _old_ref(book, m.group(0))
return f"{key} {names.get(key, m.group(3))}" if KEY.fullmatch(key) else key
row["후보"] = _OLDREF.sub(one, row["후보"])
link = row.get("연결")
if isinstance(link, str) and not KEY.fullmatch(link):
row["연결"] = lookup(book).get(("MT", link), link)
value = row.get("")
supply = value.get("조달") if isinstance(value, dict) else None
if isinstance(supply, dict) and str(supply.get("출처") or "").startswith("나라장터:"):
code = supply["출처"].split(":", 1)[1]
if not KEY.fullmatch(code): # 묶음은 새로 만듦 — 옛 줄과 나눠 쓰지 않게
source = "나라장터:" + lookup(book).get(("MN", code), code)
row[""] = {**value, "조달": {**supply, "출처": source}}
return row
def _store():
import sys
sys.path.insert(0, str(MASTER.parents[1]))
from M01_MasterData import M01_MasterData_Store as st
return st
def dump_rows(head: dict, rows: list[dict]) -> str:
"""요소 파일 — 머리는 들여쓰기 2칸 · 줄은 한 줄씩(길면 펼침)."""
st = _store()
out = ["{"] + [f" {st._atom(k)}: {st.dump(v, 2)}," for k, v in head.items()]
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 ""))
return "\n".join(out + [" ]", "}"]) + "\n"
def keep_text(text: str, old: dict, new: dict) -> str | None:
"""글 모양 그대로 — 「열쇠」 칸 · 바뀐 글 · 머리 부문·차례만 갈음 · 읽어 맞대어 다르면 None."""
rows_old, rows_new = old[_items(old)], new[_items(new)]
spots = list(re.finditer(r'"열쇠": ("(?:[^"\\]|\\.)*"|[-\d.]+)', text))
if len(spots) != len(rows_new):
return None
parts, at = [], 0
for m, row in zip(spots, rows_new):
number = json.dumps(row["원문번호"], ensure_ascii=False)
parts += [text[at : m.start()], f'"": "{row[""]}", "원문번호": {number}']
at = m.end()
text = "".join(parts) + text[at:]
swap = {}
for a_row, b_row in zip(rows_old, rows_new):
for k in b_row:
if k in a_row and a_row[k] != b_row[k]:
for a, b in zip(_strings(a_row[k]), _strings(b_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)
add = "".join(
f" {json.dumps(k, ensure_ascii=False)}: {_store()._atom(new[k])},\n"
for k in ("부문", "차례")
if k in new and k not in old
)
if add:
text = re.sub(r'(\n "원문": "[^"]*",\n)', lambda m: m.group(1) + add, text, count=1)
same = json.loads(text, parse_float=Decimal, parse_int=Decimal) == new
return text if same else None
def _strings(v):
if isinstance(v, str):
yield v
elif isinstance(v, dict):
for x in v.values():
yield from _strings(x)
elif isinstance(v, list):
for x in v:
yield from _strings(x)
def order_chapters(folder: Path = MASTER) -> None:
"""장 파일 머리 「차례」 — 그룹마다 산림 1~14장 → 건설 공통 · 토목 · 건축 · 기계설비 · 유지관리."""
found = {}
for path in folder.glob("*.json"):
m = CHAPTER.match(path.name)
if m:
data = _read(path)
division = data.get("부문") or ""
rank = (m[2] != "산림품셈", DIVISIONS.index(division) if division else -1, int(m[3]))
found.setdefault(m[1], []).append((rank, path.name, data.get("차례")))
for items in found.values():
for i, (_, name, had) in enumerate(sorted(items), start=1):
if had != i:
path = folder / name
text = re.sub(
r'"차례": \d+', f'"차례": {i}', path.read_text(encoding="utf-8"), count=1
)
path.write_text(text, encoding="utf-8", newline="\n")
def adopt(folder: Path = MASTER) -> list[str]:
"""빌더가 옛 모양으로 쓴 파일 → 새 틀(키 · 원문번호 · 한 테이블 · 장 파일 이름) · 옛 파일은 지움.
키는 대장의 (테이블ID, 원문번호) · 없으면 다음 번호. 로직은 키로 적어 넣으므로 받지 않음."""
book = load_book(folder / BOOK.name)
names = {}
if (folder / "인력.json").is_file():
names = {r[""]: r.get("이름") for r in _read(folder / "인력.json")[""]}
done = []
for path in sorted(folder.glob("*.json")):
if path.name.startswith("_"):
continue
data = _read(path)
rows = data.get(_items(data)) or []
if not rows or "열쇠" not in rows[0]:
continue
group, src = data["그룹"], data["원문"]
if group == "로직":
raise SystemExit(f"{path.name} · 로직은 키로 적음 — 옛 모양은 받지 않음")
c = OLD_CHAPTER.match(path.name)
target = chapter_file(c[1], c[2], c[3], int(c[4])) if c else path.name
target = MERGED[group][0] if group in MERGED else target
if c and c[3]: # 건설품셈 원문번호 앞에 부문(「기계설비 7-1-1 …」)
head = c[3] + " "
rows = [
r if str(r["열쇠"]).startswith(head) else {**r, "열쇠": head + r["열쇠"]}
for r in rows
]
data[_items(data)] = rows
stamped = [
_convert(book, r, names) for r in stamp(book, table_id(group, src), rows, target)
]
if group in MERGED:
file, slot, labels = MERGED[group]
label = labels[src]
whole = _read(folder / file) if (folder / file).is_file() else {"그룹": group}
heads = whole.setdefault(slot, {})
heads[label] = {k: v for k, v in data.items() if k not in ("그룹", "")}
stamped = [
{"": r[""], "원문번호": r["원문번호"], slot: label, **r} for r in stamped
]
keep = [r for r in whole.get("", []) if r.get(slot) != label]
rows_all = sorted(keep + stamped, key=lambda r: r[""])
text = dump_rows({k: v for k, v in whole.items() if k != ""}, rows_all)
else:
head = {"그룹": group, "원문": src}
if c and c[3]:
head["부문"] = c[3]
if c:
head["차례"] = Decimal(0) # order_chapters 가 채움
rest = {k: v for k, v in data.items() if k not in head}
new = {**head, **rest, _items(data): stamped}
old_text = path.read_text(encoding="utf-8")
text = keep_text(old_text, data, new) or _store().dump(new)
(folder / target).write_text(text, encoding="utf-8", newline="\n")
if target != path.name:
path.unlink()
done.append(target)
save_book(finish(book), folder / BOOK.name)
order_chapters(folder)
return done