- 저장 · 읽기 · 복사 때 SC 안 SK 참조 줄 안 펼침(Library_Flat flat_doc sk=False) · 셈 = SK 1 단위 × 수량 - [로직 추가] M01 기초 = 그 층 SK 사본 참조(POST sangeun/from-logic · 같은 내용 물음) · 다른 층 SK = 이 층 사본 뒤 참조(Labor_SkPick) - 프로젝트 · 셈 주입 = 옛 로직 줄 그대로 - 옮기기 M02_Labor_SkRow — 층 SC · 구조물 조합 로직 줄 → SK + 참조 한 행 · 면적 칸 잇기 옮김 · 줄마다 금액 같을 때만 · 보기 = 임시 사본 - 낡은 시험 고침: test_740_1 넷(SC 안 SK 줄 펼침 단언 → 참조 한 행) · test_723 AlsoSk(808-1 에서 걷힌 단추 단언) - 새 시험 test_811_1_labor_ref Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
290 lines
14 KiB
Python
290 lines
14 KiB
Python
"""일위대가 단가산출 줄 = SK 참조 한 행(PLAN 811-1 · 요구 707-1 · 681 · 698).
|
||
|
||
일위대가(SC `precombo`) · 구조물 조합 일위대가(`labor`)의 단가산출 줄 = `{참조: {층, 주인, SK}, 별칭, 메모, 수량}` 한 행 ·
|
||
저장 때 펼치지 않음(`M02_Library_Flat.flat_doc` SC = `sk=False`) · 셈 = 그 SK 1 단위 × 수량(`M02_Labor_Expand`) ·
|
||
줄 누름 = 그 SK 「수식」 모달(화면 `Labor_SkEdit`).
|
||
M01 로직 줄(옛 저장본 · 740-1 로 펼쳐 담은 줄) → 그 층 SK 문서(복사해 내 것 · 681):
|
||
몸 = `M02_Labor_Patch.to_sangeun`(바탕 로직 · 입력값 · 고름 · 자재표시 · 켜쌓기율 · 패치 덮개 · 뺀줄 · 더한줄) ·
|
||
이름 = 별칭 › 로직 이름 · 단위 = 바탕 기준단위(앞 수 뗌 · 원/10㎡ → ㎡ = 일위대가 로직 줄 ÷ 배와 같은 값) ·
|
||
자재표시 빈 재료 = 일위대가 기본(별도계상)을 적어 둠(SK 빈 칸 = 직접계산이라 금액이 바뀜) ·
|
||
사본 · 수식 = 그 일위대가 것을 실어 보냄(같은 값 묶음 · M01 이 바뀌었어도 금액 그대로)
|
||
옮기기(`migrate` · 한 번) = 층(마스터 · 회사 · 개인) SC · 구조물 조합 일위대가의 로직 줄 → SK + 참조 한 행 ·
|
||
같은 몸 · 같은 사본 = 한 층 안 SK 하나 공유 · 면적산출 칸 잇기(수량 빈 줄) = 옛 알약 키 → 참조 알약 키 ·
|
||
전후 금액(원 단위 · 줄마다) 같을 때만 씀 · 다르면 공유 없이 한 번 더 · 그래도 다르면 안 옮기고 목록 ·
|
||
프로젝트(B09 사본)는 손대지 않음 · 보기 = 임시 storage 사본에서 실제로 옮겨 봄(공유 storage 안 씀).
|
||
`python -m M02_MasterTemplete.M02_Labor_SkRow [--apply] [--backup <폴더>] [--xlsx <파일>]`
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import shutil
|
||
from contextlib import contextmanager
|
||
from decimal import Decimal
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||
|
||
KINDS = ("precombo", "labor")
|
||
CARRY = ("사본", "수식") # 일위대가 값 묶음 → 새 SK(같은 금액)
|
||
|
||
|
||
def _whole() -> Any:
|
||
from M01_MasterData import M01_MasterData_Store as m01
|
||
|
||
return m01.loaded()[1]
|
||
|
||
|
||
def logic_doc(one: dict, doc: dict, whole: Any) -> dict[str, Any]:
|
||
"""로직 줄 `one`(일위대가 `doc` 안) → SK 문서 몸(키 없음 · 저장은 부르는 쪽)."""
|
||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||
from M02_MasterTemplete import M02_Labor_Patch as patch
|
||
from M02_MasterTemplete import M02_Sangeun_Calc as calc
|
||
|
||
code = str(one["로직"])
|
||
row = whole.logic(code)
|
||
shown = dict(one.get("자재표시") or {})
|
||
for n, item in enumerate(row.get("호표") or []):
|
||
if item.get("종류") == "재료":
|
||
mine = shown.get(str(n))
|
||
shown[str(n)] = {**labor.marks(mine), "별도계상": labor.mark_of(mine, "별도계상")}
|
||
unit = calc.base_unit(row)
|
||
name = str(one.get("별칭") or row.get("이름") or code)
|
||
sk = patch.to_sangeun({**one, "자재표시": shown}, name, unit[1] if unit else "식", whole)
|
||
return {**sk, **{k: doc[k] for k in CARRY if doc.get(k)}}
|
||
|
||
|
||
def from_logic(code: Any, new: bool = False, use: str | None = None) -> dict[str, Any]:
|
||
"""[+ 줄] M01 기초 고름 — 지금 층에 SK 사본(같은 내용 = 409 물음 · 733-2) → `{이름: SK 키, 참조}`."""
|
||
from M02_MasterTemplete import M02_Library_Ask as ask
|
||
|
||
whole = _whole()
|
||
try:
|
||
whole.logic(str(code))
|
||
except Exception as e: # noqa: BLE001 — 없는 로직
|
||
raise store.StoreError(400, f"없는 기초 「{code}」") from e
|
||
sk = logic_doc({"로직": str(code), "입력값": {}}, {}, whole)
|
||
key = ask.ask("sangeun", sk, new, use) or refs.create("sangeun", sk)["이름"]
|
||
at = refs.here()
|
||
return {"이름": key, "참조": {"층": at[0], "주인": at[1], "키": key}}
|
||
|
||
|
||
def ref_line(one: dict, ref: refs.Ref) -> dict[str, Any]:
|
||
"""옛 로직 줄 → 참조 한 행(별칭 · 메모 · 수량 그대로)."""
|
||
return {"참조": ref, **{k: one.get(k) for k in ("별칭", "메모", "수량")}}
|
||
|
||
|
||
def _finger(sk: dict) -> str:
|
||
"""한 층 안 공유 열쇠 — 몸 + 사본 판(값 묶음이 같아야 같은 금액)."""
|
||
copy = sk.get("사본") or {}
|
||
body = {k: v for k, v in sk.items() if k not in CARRY}
|
||
tag = (copy.get("판"), copy.get("단가판")) if isinstance(copy, dict) else None
|
||
return json.dumps([body, tag], ensure_ascii=False, sort_keys=True, default=str)
|
||
|
||
|
||
def convert(
|
||
doc: dict, made: dict[str, str] | None, whole: Any
|
||
) -> tuple[dict, dict[str, str], list]:
|
||
"""지금 층(`store.at` 안) 문서의 로직 줄 → SK(만들거나 `made` 에서 공유) + 참조 한 행 ·
|
||
답 = (새 문서, {옛 알약 키: 새 알약 키}(수량 빈 줄), [만든 SK 키])."""
|
||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||
|
||
lines = [x for x in doc.get("담은로직") or [] if isinstance(x, dict)]
|
||
pills = combo_store.combo_pills({"담은로직": lines})["물량"]
|
||
by_logic = {code: p["키"] for p in pills for code in p.get("로직") or []}
|
||
at, out, moved, new = refs.here(), [], {}, []
|
||
for one in doc.get("담은로직") or []:
|
||
if not (isinstance(one, dict) and one.get("로직")):
|
||
out.append(one)
|
||
continue
|
||
sk = logic_doc(one, doc, whole)
|
||
key = (made or {}).get(_finger(sk))
|
||
if key is None:
|
||
key = refs.create("sangeun", sk)["이름"]
|
||
new.append(key)
|
||
if made is not None:
|
||
made[_finger(sk)] = key
|
||
ref = {"층": at[0], "주인": at[1], "키": key}
|
||
out.append(ref_line(one, ref))
|
||
if one.get("수량") is None and by_logic.get(one["로직"]):
|
||
moved.setdefault(by_logic[one["로직"]], []).append(refs.ref_id(ref))
|
||
return {**doc, "담은로직": out}, moved, new
|
||
|
||
|
||
def relink(area: dict, moved: dict[str, list[str]]) -> dict:
|
||
"""면적산출 칸 잇기 — 옛 로직 알약 키 칸을 새 참조 알약 키에도(같은 칸 · 옛 잇기는 뗌)."""
|
||
sheets = []
|
||
for sheet in area.get("시트") or []:
|
||
links = []
|
||
for link in sheet.get("연결") or []:
|
||
keys = moved.get(str(link.get("키"))) if isinstance(link, dict) else None
|
||
links += [{**link, "키": k} for k in keys] if keys else [link]
|
||
sheets.append({**sheet, "연결": links} if "연결" in sheet else sheet)
|
||
return {**area, "시트": sheets}
|
||
|
||
|
||
# ── 금액(전후 대조) ────────────────────────────────────────────────────
|
||
def _won(v: Any) -> Any:
|
||
return None if v is None else int(Decimal(str(v)))
|
||
|
||
|
||
def _unit(line: dict) -> Any:
|
||
"""줄 1 단위 값(기준 ÷ 배 · 0.01 원)."""
|
||
got = (line.get("기준") or {}).get("계")
|
||
times = Decimal(str(line.get("배") or 1))
|
||
return None if got is None else str((Decimal(str(got)) / times).quantize(Decimal("0.01")))
|
||
|
||
|
||
def money(kind: str, key: str, doc: dict) -> dict[str, Any]:
|
||
"""지금 층 문서 금액 — 조합 = 줄마다 반영 · 합(면적산출 칸 값) · SC = 1 단위(안 줄 저장 수량 ·
|
||
빈 수량 = 1)."""
|
||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||
from M02_MasterTemplete.M02_Labor_Expand import Expand, inner_pills
|
||
|
||
if kind == "labor": # 기준합 = 줄마다 단위가 달라(로직 줄 결과단위 · 참조 줄 1 단위) 안 견줌
|
||
got = labor.calc(doc)
|
||
rows = [[_won((x.get("반영") or {}).get("계")), _unit(x)] for x in got.get("줄") or []]
|
||
return {"줄": rows, "합": _won((got.get("반영합") or {}).get("계"))}
|
||
at = refs.here()
|
||
ref = {"층": at[0], "주인": at[1], "키": key}
|
||
given = {p["키"]: 1 for p in inner_pills(ref)}
|
||
out = {}
|
||
for name, use in (("저장", {}), ("하나", given)):
|
||
base = Expand(_whole(), refs.read_ref, use)(ref).get("기준") or {}
|
||
out[name] = _won(base.get("계"))
|
||
return out
|
||
|
||
|
||
def _path(kind: str, key: str) -> Path:
|
||
return store.root() / kind / f"{key}.json"
|
||
|
||
|
||
def _write(path: Path, doc: dict) -> None:
|
||
path.write_text(json.dumps(doc, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
|
||
|
||
def _one(kind: str, path: Path, made: dict[str, str] | None, whole: Any) -> dict[str, Any]:
|
||
"""문서 하나 옮김(지금 층에 씀) — 전후 금액 · 다르면 되돌림(만든 SK 지움 · 파일 · 면적 원래대로)."""
|
||
raw = path.read_bytes()
|
||
doc = json.loads(raw)
|
||
key = path.stem
|
||
before = money(kind, key, doc)
|
||
area_path = _path("area", str(doc.get("열") or key)) if kind == "labor" else None
|
||
area_raw = area_path.read_bytes() if area_path and area_path.is_file() else None
|
||
got, moved, new = convert(doc, made, whole)
|
||
path.write_text(json.dumps(got, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
if area_raw is not None and moved:
|
||
_write(area_path, relink(json.loads(area_raw), moved))
|
||
after = money(kind, key, got)
|
||
same = before == after
|
||
if not same: # 되돌림 — 이번에 만든 SK 지움(공유 기억도)
|
||
path.write_bytes(raw)
|
||
if area_raw is not None:
|
||
area_path.write_bytes(area_raw)
|
||
for sk in new:
|
||
_path("sangeun", sk).unlink(missing_ok=True)
|
||
if made is not None:
|
||
for f in [f for f, k in made.items() if k in new]:
|
||
made.pop(f)
|
||
sks = {r["키"] for r in refs.refs_in(got) if str(r.get("키"))[:2] == "SK"} if same else set()
|
||
return {
|
||
"줄": sum(1 for x in doc.get("담은로직") or [] if isinstance(x, dict) and x.get("로직")),
|
||
"SK": sorted(sks),
|
||
"새SK": new if same else [],
|
||
"알약": sum(len(v) for v in moved.values()) if area_path else 0,
|
||
"전": before,
|
||
"후": after,
|
||
"같음": same,
|
||
}
|
||
|
||
|
||
def run_here(whole: Any = None) -> list[dict[str, Any]]:
|
||
"""지금 설정 storage 의 층 문서 전부 옮김(쓰기) — 줄마다 결과."""
|
||
whole, out = whole or _whole(), []
|
||
shared: dict[tuple, dict[str, str]] = {}
|
||
for kind in KINDS:
|
||
for where, path in layers.layer_files(kind):
|
||
try:
|
||
doc = json.loads(path.read_bytes())
|
||
except (OSError, ValueError):
|
||
continue
|
||
if not any(isinstance(x, dict) and x.get("로직") for x in doc.get("담은로직") or []):
|
||
continue
|
||
folder = path.parent.parent
|
||
made = shared.setdefault(refs.where(folder), {})
|
||
with store.at(folder):
|
||
row = _one(kind, path, made, whole)
|
||
if not row["같음"]: # 공유 없이 한 번 더
|
||
row = {**_one(kind, path, None, whole), "공유없음": True}
|
||
out.append({"어디": where, "종류": kind, "키": path.stem, **row})
|
||
return out
|
||
|
||
|
||
@contextmanager
|
||
def _sandbox(root: Path):
|
||
"""층 폴더(마스터 · 회사 · 개인 templates)만 임시 storage 로 복사 → 설정을 그리로."""
|
||
from config import config_system
|
||
|
||
old = (config_system.STORAGE_BASE_DIR, layers.SYSTEM_ROOT, store.FOLDER)
|
||
src = layers.storage_root()
|
||
shutil.copytree(layers.system_dir(), root / "system")
|
||
for tpl in [*src.glob("*/templates"), *src.glob("*/*/templates")]:
|
||
shutil.copytree(tpl, root / "storage" / tpl.relative_to(src))
|
||
config_system.STORAGE_BASE_DIR = str(root / "storage")
|
||
layers.SYSTEM_ROOT = store.FOLDER = root / "system"
|
||
try:
|
||
yield
|
||
finally:
|
||
config_system.STORAGE_BASE_DIR, layers.SYSTEM_ROOT, store.FOLDER = old
|
||
|
||
|
||
def migrate(apply: bool = False, backup: Path | None = None, work: Path | None = None) -> list:
|
||
"""보기(`apply` False) = 임시 storage 에서 옮겨 봄 · 씀 = `backup` 에 층 폴더 사본을 떠 둔 뒤 실제 층."""
|
||
if not apply:
|
||
import tempfile
|
||
|
||
with tempfile.TemporaryDirectory(dir=work) as tmp, _sandbox(Path(tmp)):
|
||
return run_here()
|
||
if backup is None:
|
||
raise SystemExit("--apply 는 --backup <폴더> 와 함께")
|
||
src = layers.storage_root()
|
||
shutil.copytree(layers.system_dir(), backup / "system")
|
||
for tpl in [*src.glob("*/templates"), *src.glob("*/*/templates")]:
|
||
shutil.copytree(tpl, backup / "storage" / tpl.relative_to(src))
|
||
return run_here()
|
||
|
||
|
||
def to_xlsx(rows: list[dict], path: Path) -> None:
|
||
from openpyxl import Workbook
|
||
|
||
wb = Workbook()
|
||
ws = wb.active
|
||
ws.title = "옮기기"
|
||
ws.append(["어디", "종류", "키", "로직 줄", "참조 SK", "새 SK", "알약 잇기", "전", "후", "같음", "공유없음"]) # fmt: skip
|
||
for r in rows:
|
||
ws.append(
|
||
[r["어디"], r["종류"], r["키"], r["줄"], " ".join(r["SK"]), len(r["새SK"]), r["알약"]]
|
||
+ [json.dumps(r[k], ensure_ascii=False) for k in ("전", "후")]
|
||
+ ["같음" if r["같음"] else "다름", "예" if r.get("공유없음") else ""]
|
||
)
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
wb.save(path)
|
||
|
||
|
||
if __name__ == "__main__": # 브레인 — 보기 먼저 · `--apply --backup <폴더>` 는 승인 뒤
|
||
import sys
|
||
|
||
argv = sys.argv[1:]
|
||
opt = {a: argv[i + 1] for i, a in enumerate(argv[:-1]) if a in ("--backup", "--xlsx")}
|
||
got = migrate("--apply" in argv, Path(opt["--backup"]) if "--backup" in opt else None)
|
||
for one in got:
|
||
print(json.dumps(one, ensure_ascii=False, default=str))
|
||
lines, same = sum(r["줄"] for r in got), sum(r["줄"] for r in got if r["같음"])
|
||
sks = len({(r["어디"], k) for r in got for k in r["새SK"]})
|
||
print(f"문서 {len(got)} · 로직 줄 {lines} · 옮김 {same} · 새 SK {sks} · 다름 {sum(not r['같음'] for r in got)}") # fmt: skip
|
||
if "--xlsx" in opt:
|
||
to_xlsx(got, Path(opt["--xlsx"]))
|