From 16d7eede5086f0fe708a548fec5635bd2cccf227 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 11:32:22 +0900 Subject: [PATCH] =?UTF-8?q?feat(795-3):=20=EC=98=9B=20SK=20=C2=B7=20SC=20?= =?UTF-8?q?=C2=B7=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=EC=82=AC?= =?UTF-8?q?=EB=B3=B8=20=EC=98=AE=EA=B8=B0=EA=B8=B0=20=EB=8F=84=EA=B5=AC=20?= =?UTF-8?q?=E2=80=94=20=EC=A0=84=ED=9B=84=20=EA=B8=88=EC=95=A1=20=EB=8C=80?= =?UTF-8?q?=EC=A1=B0=20=C2=B7=20=EA=B0=99=EC=9D=8C=EB=A7=8C=20=EC=94=80=20?= =?UTF-8?q?=C2=B7=20=EB=B0=B1=EC=97=85=20=C2=B7=20=EB=90=98=EB=8F=8C?= =?UTF-8?q?=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 M02_Copy_Migrate — 사본 없는 마스터 · 회사 · 개인 층 문서에 지금 M01 사본 찍어 대조(SK 기준 · SC · 일위대가 기준합 · 반영합) - 같음 = 사본 칸만 더해 씀 · 다름 · 못 셈 = 안 씀(목록) · 프로젝트 층 일위대가 = 세기 · 대조만(795-4) - --apply 는 --backup 폴더 필수 · --restore 로 다시 덮음 · 기본 = 보기만(표) - 실제 storage 보기만 결과 — 다름 0 · 층 문서 같음 41 · 필요없음 6 · 프로젝트 같음 70 · 못셈 14(옛 SP 참조) · 옮기지 않음 - 시험 test_795_3_migrate 3(임시 storage) Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Copy_Migrate.py | 154 +++++++++++++++++++++++++ resources/tester/test_795_3_migrate.py | 101 ++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 M02_MasterTemplete/M02_Copy_Migrate.py create mode 100644 resources/tester/test_795_3_migrate.py diff --git a/M02_MasterTemplete/M02_Copy_Migrate.py b/M02_MasterTemplete/M02_Copy_Migrate.py new file mode 100644 index 000000000..8b68fc802 --- /dev/null +++ b/M02_MasterTemplete/M02_Copy_Migrate.py @@ -0,0 +1,154 @@ +"""옛 SK · SC · 일위대가 → 로직 사본 찍어 넣기 — 한 번 돌리는 도구(PLAN 795-3 · 요구 655 · 656). + +대상 = 사본 없는 마스터 · 회사 · 개인 층 문서(`sangeun` · `precombo` · `labor`) — 지금 M01 로 사본을 +찍고(`M02_Sangeun_Copy.stamp` · `M02_Labor_Copy.stamp`) 전후 금액 대조: + SK = `calc_doc` 기준(노 · 재 · 경 · 계) · SC · 일위대가 = `Labor_Doc.calc` 기준합 · 반영합. +같음 = 파일에 `사본` 칸만 더해 씀(그 밖 칸 그대로) · 다름 · 못 셈 = 안 씀(목록) · +닿을 것 없음(바탕 참조 SK · 참조 · 손 단가 줄만) = 그대로. +프로젝트 층 일위대가 = 세기 · 대조만(B09 사본 규칙 795-4 와 함께 옮김). +되돌릴 길 = 쓰기 전 파일 그대로 `--backup` 폴더에(층 자리 그대로) · `--restore` = 다시 덮음. + + ./venv/Scripts/python.exe -m M02_MasterTemplete.M02_Copy_Migrate # 보기만(표) + ./venv/Scripts/python.exe -m M02_MasterTemplete.M02_Copy_Migrate --apply --backup tmp/795_3 + ./venv/Scripts/python.exe -m M02_MasterTemplete.M02_Copy_Migrate --restore tmp/795_3 +""" + +from __future__ import annotations + +import json +import shutil +import sys +from collections import Counter +from pathlib import Path +from typing import Any + +from common_util.common_util_json import atomic_write_json +from M02_MasterTemplete import M02_Labor_Copy as lc +from M02_MasterTemplete import M02_Sangeun_Copy as skc +from M02_MasterTemplete import M02_Template_Layers as layers + +KINDS = ("sangeun", "precombo", "labor") + + +def _anchors() -> dict[str, Path]: + return {"system": layers.system_dir(), "storage": layers.storage_root()} + + +def _files() -> list[tuple[str, str, Path, bool]]: + """(어디, 종류, 파일, 프로젝트?) — 층 문서 + 프로젝트 층 일위대가.""" + out = [(w, k, p, False) for k in KINDS for w, p in layers.layer_files(k)] + root = layers.storage_root() + for p in sorted(root.glob("*/*/*/templates/labor/[!.]*.json")): + out.append((f"프로젝트 {p.parts[-4]}", "labor", p, True)) + return out + + +def _round(value: Any) -> Any: + if isinstance(value, dict): + return {k: _round(v) for k, v in value.items()} + return None if value is None else round(float(value), 6) + + +def _money(kind: str, doc: dict, whole: Any) -> Any: + """그 문서 금액 — `whole` = 셈 색인(지금 M01 | 사본 덮개).""" + from M02_MasterTemplete import M02_Labor_Doc as labor + from M02_MasterTemplete import M02_Sangeun_Calc as calc + + if kind == "sangeun": + return _round(calc.run(doc, whole, None, line=False).get("기준")) + got = labor.calc(doc, None if kind == "labor" else {}, whole=whole) + return _round({k: got.get(k) for k in ("기준합", "반영합")}) + + +def _clean(kind: str, doc: dict, key: str) -> dict: + from M02_MasterTemplete import M02_Labor_Doc as labor + from M02_MasterTemplete import M02_Sangeun_Doc as sd + + plain = {k: v for k, v in doc.items() if k not in (skc.KEY, skc.JOB)} + if kind == "sangeun": + return sd.clean(plain, None, skc.whole_()) + return labor.clean({**plain, "열": doc.get("열") or key}) + + +def check(kind: str, path: Path) -> dict[str, Any]: + """문서 하나 — {상태: 있음 | 필요없음 | 같음 | 다름 | 못셈, 전, 후, 사본?, 까닭?}.""" + from M02_MasterTemplete import M02_MasterTemplete_Store as store + + doc = json.loads(path.read_bytes()) + if skc.has(doc): + return {"상태": "있음"} + folder = path.parent.parent + try: + with store.at(folder): + clean = _clean(kind, doc, path.stem) + copy = skc.stamp(clean) if kind == "sangeun" else lc.stamp(clean) + if copy is None: + return {"상태": "필요없음"} + live = skc.whole_() + before = _money(kind, clean, live) + after = _money(kind, clean, skc.over({skc.KEY: copy}, live)) + except Exception as e: # noqa: BLE001 — 못 셈 = 목록(통째로 멈추지 않음) + return {"상태": "못셈", "까닭": str(e) or type(e).__name__} + row = {"상태": "같음" if before == after else "다름", "전": before, "후": after} + return {**row, "사본": copy} if before == after else {**row, "까닭": "전후 금액 다름 — 안 옮김"} + + +def _backup_path(path: Path, backup: Path) -> Path: + for name, anchor in _anchors().items(): + if path.resolve().is_relative_to(anchor.resolve()): + return backup / name / path.resolve().relative_to(anchor.resolve()) + raise ValueError(f"층 밖 파일 「{path}」") + + +def migrate(apply: bool = False, backup: Path | None = None) -> list[dict[str, Any]]: + """모든 층 — 문서마다 {어디, 종류, 키, 상태, 씀, …} · `apply` = 같음만 씀(프로젝트 층 빼고).""" + if apply and backup is None: + raise ValueError("--apply 는 --backup 폴더가 있어야 함") + out = [] + for where_, kind, path, project in _files(): + got = check(kind, path) + copy = got.pop("사본", None) + row = {"어디": where_, "종류": kind, "키": path.stem, **got, "씀": False} + if apply and copy and not project: + keep = _backup_path(path, backup) + keep.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, keep) + atomic_write_json(path, {**json.loads(path.read_bytes()), skc.KEY: copy}) + row["씀"] = True + out.append(row) + return out + + +def restore(backup: Path) -> list[str]: + """`--backup` 폴더 파일을 제자리로 다시 덮음 — 덮은 파일 목록.""" + done = [] + for name, anchor in _anchors().items(): + for keep in sorted((backup / name).rglob("*.json")): + target = anchor / keep.relative_to(backup / name) + shutil.copy2(keep, target) + done.append(str(target)) + return done + + +def table(rows: list[dict[str, Any]]) -> dict[str, dict[str, int]]: + """층 갈래 × 종류 × 상태 개수.""" + count: Counter = Counter() + for r in rows: + count[(r["어디"].split(" ")[0], r["종류"], r["상태"])] += 1 + out: dict[str, dict[str, int]] = {} + for (layer, kind, state), n in sorted(count.items()): + out.setdefault(f"{layer} {kind}", {})[state] = n + return out + + +if __name__ == "__main__": # 브레인 · 사용자 승인 뒤만 --apply + args = sys.argv[1:] + if "--restore" in args: + print("\n".join(restore(Path(args[args.index("--restore") + 1])))) + raise SystemExit + at = Path(args[args.index("--backup") + 1]) if "--backup" in args else None + rows = migrate("--apply" in args, at) + for one in rows: + if one["상태"] not in ("있음", "필요없음", "같음"): + print(json.dumps(one, ensure_ascii=False, default=str)) + print(json.dumps(table(rows), ensure_ascii=False, indent=1)) diff --git a/resources/tester/test_795_3_migrate.py b/resources/tester/test_795_3_migrate.py new file mode 100644 index 000000000..f6b10c0f7 --- /dev/null +++ b/resources/tester/test_795_3_migrate.py @@ -0,0 +1,101 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""795-3 옛 SK · SC · 일위대가 → 로직 사본 옮기기 — 임시 storage 에서만. + +① 보기만 = 안 씀 · 전후 같음 ② 쓰기 = 사본 칸만 더함 · 다른 칸 그대로 · 금액 같음 · 백업 · 되돌림 +③ 전후 다르면 안 씀(목록) ④ 프로젝트 층 일위대가 = 세기 · 대조만. +""" + +from __future__ import annotations + +import copy +import json + +from M02_MasterTemplete import M02_Copy_Migrate as mig +from M02_MasterTemplete import M02_Labor_Copy as lc +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Sangeun_Copy as skc +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.test_795_1_sk_copy import _fake, _live +from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 + +PCT, LB = "GC001109", "LB000036" +LINE = {"로직": PCT, "수량": {"손": 1}} + + +def _old(path): + """사본 칸 뗀 옛 문서로 되씀 → 옛 내용.""" + raw = {k: v for k, v in json.loads(path.read_bytes()).items() if k != skc.KEY} + path.write_text(json.dumps(raw, ensure_ascii=False), encoding="utf-8") + return raw + + +def _make(me): + with store.at(me): + sk = refs.create( + "sangeun", {"이름": "sub2임시", "단위": "㎡", "바탕": {"로직": PCT}, "더한줄": []} + )["이름"] + sc = refs.create("precombo", {"이름": "sub2임시", "단위": "㎡", "담은로직": [LINE]})["이름"] + store.write("labor", "sub2임시", "", {"열": "sub2임시", "담은로직": [LINE], "자재": []}) + paths = [me / "sangeun" / f"{sk}.json", me / "precombo" / f"{sc}.json"] + paths.append(me / "labor" / "sub2임시.json") + return {p: _old(p) for p in paths} + + +def _mine(rows, olds): + keys = {p.stem for p in olds} + return {r["키"]: r for r in rows if r["어디"].startswith("개인") and r["키"] in keys} + + +def test_dry_then_apply_then_restore(world, tmp_path): + """① · ② 보기만 → 쓰기(사본만 더함 · 금액 같음) → 되돌림 = 처음 파일.""" + olds = _make(world["personal"]) + rows = _mine(mig.migrate(), olds) + assert {r["상태"] for r in rows.values()} == {"같음"} and len(rows) == 3 + assert not any(r["씀"] for r in rows.values()) + assert all(json.loads(p.read_bytes()) == raw for p, raw in olds.items()) + back = tmp_path / "back" + rows = _mine(mig.migrate(True, back), olds) + assert all(r["씀"] for r in rows.values()) + for path, raw in olds.items(): + now = json.loads(path.read_bytes()) + assert skc.has(now) and {k: v for k, v in now.items() if k != skc.KEY} == raw + again = _mine(mig.migrate(), olds) # 다시 돌림 = 이미 있음 + assert {r["상태"] for r in again.values()} == {"있음"} + assert {r["전"]["계"] for r in rows.values() if r["종류"] == "sangeun"} == { + r["후"]["계"] for r in rows.values() if r["종류"] == "sangeun" + } + mig.restore(back) + assert all(json.loads(p.read_bytes()) == raw for p, raw in olds.items()) + + +def test_different_amount_not_written(world, monkeypatch, tmp_path): + """③ 찍은 사본 금액이 지금과 다르면(노임 다른 판) 안 씀 · 까닭.""" + olds = _make(world["personal"]) + real = lc.stamp + + def stale(doc): # 노임 두 배 판으로 찍힌 사본 흉내 + with monkeypatch.context() as m: + row = copy.deepcopy(_live().get(LB)) + row["값"] = row["값"] * 2 + _fake(m, [row]) + return real(doc) + + monkeypatch.setattr(lc, "stamp", stale) + rows = _mine(mig.migrate(True, tmp_path / "back"), olds) + bad = [r for r in rows.values() if r["종류"] != "sangeun"] + assert {r["상태"] for r in bad} == {"다름"} and not any(r["씀"] for r in bad) + assert all(not skc.has(json.loads(p.read_bytes())) for p in olds if p.parent.name != "sangeun") + + +def test_project_labor_count_only(world, tmp_path): + """④ 프로젝트 층 일위대가 = 대조만 · 안 씀.""" + folder = layers.storage_root() / "7" / "42" / "p1" / "templates" / "labor" + folder.mkdir(parents=True) + path = folder / "x.json" + raw = {"열": "x", "담은로직": [LINE], "자재": []} + path.write_text(json.dumps(raw, ensure_ascii=False), encoding="utf-8") + rows = [r for r in mig.migrate(True, tmp_path / "back") if r["어디"].startswith("프로젝트")] + assert [(r["키"], r["상태"], r["씀"]) for r in rows] == [("x", "같음", False)] + assert json.loads(path.read_bytes()) == raw + assert mig.table(rows) == {"프로젝트 labor": {"같음": 1}}