"""일위대가 규격 열(PLAN 704-4) — 문서 `규격` 칸 검사 · 열 끼우기 · 사본 굳히기 · 쓰는 사본 찾기. 규격 = `{이름: "H", 열: ["1.5", "2.0"], 기본: "2.0", 수량: {줄 id: {열: 수}}}` — 줄 id = 로직 키 · 참조 `ref_id`(`M02_Labor_Doc.line_id`) · 자재 코드. 규격 수량이 있는 줄 = 고른 열 값이 `{손}` 자리 · 없는 줄 · 빈 열 = 문서 `수량` 그대로(공통). 규격 없음 = 옛 문서(열 하나 · 셈 그대로 · 옮김 없음). 짝(CLAUDE.md 5장 ①): `at` ↔ `M02_MasterTemplete_Labor_Calc.ts` `atSpec` — 거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`. 사본(`M02_Item_Source.build_source`) = 설계가 고른 열(summary_item `spec`)을 `{손}` 으로 굳히고 `규격` 은 그 열 하나만 남김(`freeze`). """ from __future__ import annotations from decimal import Decimal, InvalidOperation from pathlib import Path from typing import Any from M02_MasterTemplete import M02_Labor_Ref as refs NAME_MAX = 20 # 규격 이름 · 열 이름 글자 수 상한 — 화면 칸과 같음 COLS_MAX = 20 class SpecError(ValueError): """규격 칸 꼴이 틀림 — 부르는 쪽이 400 으로.""" def _num(raw: Any) -> int | float | None: if raw is None or isinstance(raw, bool): return None try: got = Decimal(str(raw).replace(",", "").strip()) except InvalidOperation: return None if not got.is_finite(): return None return int(got) if got == got.to_integral_value() else float(got) def _text(raw: Any, what: str) -> str: text = str(raw).strip() if isinstance(raw, (str, int, float)) and raw is not True else "" if not text or len(text) > NAME_MAX: raise SpecError(f"규격 {what}은 1~{NAME_MAX}자 글") return text def line_key(one: dict) -> Any: """줄 id — 참조 줄 = `ref_id` · 로직 줄 = 로직 키 · 자재 줄 = 코드.""" if isinstance(one.get("참조"), dict): return refs.ref_id(one["참조"]) return one.get("로직") or one.get("코드") def clean(raw: Any, ids: set[Any]) -> dict[str, Any] | None: """받은 `규격` → 저장 꼴 — 열이 없으면 None(규격 없음) · 이름 · 열 이름이 비거나 겹치면 · 수량이 수 아니면 SpecError · 없는 줄 · 없는 열 수량은 버림(지운 줄).""" if raw is None: return None if not isinstance(raw, dict): raise SpecError("규격은 묶음") cols: list[str] = [] for one in raw.get("열") or []: col = _text(one, "열 이름") if col in cols: raise SpecError(f"같은 규격 열 두 번 「{col}」") cols.append(col) if not cols: return None if len(cols) > COLS_MAX: raise SpecError(f"규격 열은 {COLS_MAX}개까지") qty: dict[str, dict[str, int | float]] = {} for lid, row in (raw.get("수량") or {}).items(): if lid not in ids or not isinstance(row, dict): continue kept = {} for col, value in row.items(): if col not in cols or value in (None, ""): continue got = _num(value) if got is None: raise SpecError(f"규격 수량 「{lid} · {col}」 은 수") kept[col] = got if kept: qty[str(lid)] = kept return { "이름": _text(raw.get("이름"), "이름"), "열": cols, "기본": raw.get("기본") if raw.get("기본") in cols else cols[0], "수량": qty, } def column(doc: Any, col: Any = None) -> str | None: """쓸 열 — 고른 열이 규격에 있으면 그것 · 없으면 기본 · 규격 없음 = None.""" spec = doc.get("규격") if isinstance(doc, dict) else None if not isinstance(spec, dict) or not spec.get("열"): return None return col if col in spec["열"] else spec.get("기본") or spec["열"][0] def at(doc: dict, col: Any = None) -> dict: """열 끼우기 — 그 열 규격 수량이 있는 줄 · 자재의 `수량` 을 `{손: 값}` 으로 (규격 없음 = 그대로).""" use = column(doc, col) if use is None: return doc qty = doc["규격"].get("수량") or {} def put(one: Any) -> Any: if not isinstance(one, dict): return one value = (qty.get(line_key(one)) or {}).get(use) return {**one, "수량": {"손": value}} if value is not None else one return { **doc, "담은로직": [put(one) for one in doc.get("담은로직") or []], "자재": [put(one) for one in doc.get("자재") or []], } def given_of(result: dict) -> dict[str, Any]: """셈 답에서 칸 값 되살림 `{알약: 수량}` — 열마다 다시 셀 때 면적 칸 값 (문서 · 층을 다시 안 읽음).""" rows = [*(result.get("줄") or []), *(result.get("자재") or [])] return {r["알약"]: r["수량"] for r in rows if r.get("수량출처") == "칸" and r.get("알약")} def freeze(doc: dict, col: Any = None) -> dict: """사본 굳히기 — 고른 열(없거나 지운 열 = 기본)을 `{손}` 으로 끼우고 규격은 그 열 하나만.""" use = column(doc, col) if use is None: return doc got = {k: v for k, v in at(doc, use).items() if k != "계산값"} # 기본 열 계산값은 버림 return {**got, "규격": {"이름": doc["규격"].get("이름"), "열": [use], "기본": use, "수량": {}}} def users(layer_root: Path, keys: set[str], cols: set[str]) -> list[str]: """그 층 항목(`keys`) · 그 규격 열(`cols`)을 쓰는 프로젝트 사본 — `프로젝트 폴더/사본 폴더` 목록. ponytail: 저장소 프로젝트 사본 manifest 를 통째로 훑음 — 열 지우기 · 이름 바꾸기 [저장] 때만 · 느리면 사본 색인을 둠.""" from M02_MasterTemplete import M02_Template_Layers as layers from M02_MasterTemplete.M02_Item_Source import layer_root as root_of from M02_MasterTemplete.M02_Item_Source import read_json found = [] here = Path(layer_root).resolve() for path in layers.storage_root().glob("*/*/*/templates/structures/*/manifest.json"): origin = (read_json(path) or {}).get("원본") if not isinstance(origin, dict) or origin.get("key") not in keys: continue if origin.get("spec") not in cols: continue try: same = root_of(origin).resolve() == here except (KeyError, ValueError, TypeError): same = False if same: found.append(f"{path.parents[3].name}/{path.parent.name}") return found def guard(path: Path, new: Any, column: str) -> None: """[저장] 때 빠진 규격 열(지움 · 이름 바꿈)을 쓰는 프로젝트 사본이 있으면 409 — 지금 층(`store.root`) 그 열 항목(SS 키 · 옛 키) 기준.""" from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete.M02_Item_Source import read_json old = (read_json(path) or {}).get("규격") if path.is_file() else None gone = set((old or {}).get("열") or []) - set(((new or {}).get("규격") or {}).get("열") or []) if not gone: return rows = [r for r in store.read_combos().get("줄", []) if str(r.get("열")) == column] keys = {str(k) for r in rows for k in [r.get("키"), *(r.get("옛키") or [])] if k} used = users(store.root(), keys, gone) if used: raise store.StoreError( 409, f"규격 열 「{', '.join(sorted(gone))}」 을 쓰는 프로젝트 사본 {len(used)}개 — 지울 수 없음 " f"({', '.join(used[:3])}{' …' if len(used) > 3 else ''})", )