"""M02 개인 자재(PLAN 148-2) — 개인 층 뿌리 한 파일 `개인자재.json`(종류 폴더 밖 · 양식 목록 · 프로젝트 복사 · 층 복사에 안 섞임 · `구조물집계표조합.json` 꼴). 꼴 = M01 자재 줄 꼴 `{그룹: 재료, 원문: 개인, 줄: [{키, 이름, 규격, 단위, 값}]}` — 사본 `단가.json` 에 그대로 넣으면 `mf.Master` 가 읽음(MT 아닌 키 = 줄 「값」 · `master_material.element`). 품번 = `PM` + 6자리 — 모든 사람 통틀어 한 줄 번호(모든 개인 층 파일 가장 큰 번호 + 1 · `store._LOCK` 안 · 화면이 보낸 키는 안 받음). 신규 자재는 개인 층에서만 (회사 · 마스터 층 = 마스터 자재만). 840-4 내 재료 = M01 재료 DB 에 없는 자재를 등록하는 자리 — M01 과 이름 · 규격 · 단위가 같으면 막음(409) · 고치기 · 지우기(쓰는 곳 있으면 막음) · 단가산출 셈 색인에 M01 재료처럼 얹음(`whole` · `M02_Sangeun_Copy.whole_`) → 프로젝트 층 밖 SK 는 값이 바뀌면 자동 따라옴(836-2 `M02_Sk_AutoRenew`) · 프로젝트 층 = 사본 값. """ from __future__ import annotations import json import re import unicodedata from pathlib import Path from typing import Any from common_util.common_util_json import atomic_write_json from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_Template_Layers as layers FILE = "개인자재.json" PREFIX = "PM" KEY = re.compile(rf"^{PREFIX}(\d{{6}})$") FIND = re.compile(rf"(? dict[str, Any]: return {"그룹": "재료", "원문": "개인", "줄": []} def read(folder: str | Path) -> dict[str, Any]: """층 폴더의 개인 자재 묶음 — 없거나 못 읽으면 빈 묶음.""" path = Path(folder) / FILE try: got = json.loads(path.read_bytes()) if path.is_file() else None except (OSError, ValueError): got = None return got if isinstance(got, dict) and isinstance(got.get("줄"), list) else _blank() def rows(folder: str | Path) -> list[dict[str, Any]]: return read(folder)["줄"] def _last() -> int: """모든 개인 층 파일의 가장 큰 PM 번호 — 사람 사이 번호 안 겹침.""" found = [ int(m.group(1)) for path in layers.storage_root().glob(f"*/*/{layers.TEMPLATES_DIRNAME}/{FILE}") for row in read(path.parent)["줄"] if (m := KEY.match(str(row.get("키") or ""))) ] return max(found, default=0) def _text(v: Any) -> str: return str(v).strip() if v is not None else "" def _value(v: Any) -> float | int | None: if v is None or (isinstance(v, str) and not v.strip()): return None try: got = float("nan") if isinstance(v, bool) else float(v) except (TypeError, ValueError): got = float("nan") if not 0 <= got < float("inf"): # NaN 도 여기서 걸림 raise store.StoreError(400, "단가는 0 이상 수 또는 빈 값") return int(got) if got.is_integer() else got def _norm(v: Any) -> str: """빈칸 · 대소문자 · 겹낱자(㎏ = kg · ㎥ = m3) 무시.""" return unicodedata.normalize("NFKC", " ".join(_text(v).split())).lower() def m01_same(row: dict[str, Any]) -> dict[str, Any] | None: """M01 재료 DB(자재품목 MT)에서 이름 · 규격 · 단위가 같은 줄(빈칸 · 대소문자 무시).""" from M01_MasterData import M01_MasterData_Store as m01 want = tuple(_norm(row.get(k)) for k in COLS) for r in (m01.loaded()[1].index.get("MT") or {}).values(): if tuple(_norm(r.get(k)) for k in COLS) == want: return r return None def _checked(body: dict[str, Any], others: list[dict[str, Any]]) -> dict[str, Any]: """품명 · 단위 빈 = 400 · 내 목록 같은 줄 · M01 재료 DB 같은 줄 = 409.""" row = {k: _text(body.get(k)) for k in COLS} if not row["이름"] or not row["단위"]: raise store.StoreError(400, "품명 · 단위는 꼭 적음") row["값"] = _value(body.get("값")) if any(all(_norm(x.get(k)) == _norm(row[k]) for k in COLS) for x in others): raise store.StoreError(409, f"이미 있는 자재 「{row['이름']} {row['규격']}」") if m01_same(row): raise store.StoreError( 409, f"재료 DB 에 있는 자재 「{row['이름']} {row['규격']}」 — 재료 DB 자재를 고르고 " "값은 단가산출 안 재료 줄에서 바꿈", ) return row def add(folder: str | Path, body: dict[str, Any]) -> dict[str, Any]: """새 개인 자재 한 줄 → 그 줄(키는 서버) · 품명 · 단위 빈 = 400 · 같은 품명 + 규격 + 단위(내 목록 · M01 재료 DB) = 409.""" with store._LOCK: doc = read(folder) row = _checked(body, doc["줄"]) got = {"키": f"{PREFIX}{_last() + 1:06d}", **row} doc["줄"].append(got) atomic_write_json(Path(folder) / FILE, doc) return got def update(folder: str | Path, key: str, body: dict[str, Any]) -> dict[str, Any]: """내 재료 한 줄 고침(키 그대로) — 없는 키 = 404 · 검사는 새로 만들기와 같음.""" with store._LOCK: doc = read(folder) at = next((i for i, r in enumerate(doc["줄"]) if r.get("키") == key), None) if at is None: raise store.StoreError(404, f"없는 내 재료 「{key}」") row = _checked(body, [r for i, r in enumerate(doc["줄"]) if i != at]) doc["줄"][at] = got = {"키": key, **row} atomic_write_json(Path(folder) / FILE, doc) return got def _aside(path: Path, folder: str | Path) -> bool: """밑줄 · 점 폴더(「_합친것」 등 파생 · 보관) — 쓰는 곳 아님.""" return any(x.startswith(("_", ".")) for x in path.relative_to(folder).parts[:-1]) def usage(folder: str | Path, keys: list[str]) -> dict[str, list[dict[str, Any]]]: """그 층에서 키마다 쓰는 문서(단가산출 · 일위대가 …) {키: [{종류, 키, 이름}]} — 파일은 한 번씩 읽음.""" out: dict[str, list[dict[str, Any]]] = {k: [] for k in keys} for path in sorted(Path(folder).rglob("*.json")): # 종류 폴더 · 그 밑 폴더 — 내 목록 파일 뺌 if path.name == FILE or _aside(path, folder): continue try: text = path.read_text(encoding="utf-8") except OSError: continue hit = [k for k in keys if k in text] if not hit: continue try: doc = json.loads(text) except ValueError: continue name = (doc.get("이름") if isinstance(doc, dict) else None) or path.stem for k in hit: out[k].append({"종류": path.parent.name, "키": path.stem, "이름": name}) return out def uses(folder: str | Path, key: str) -> list[dict[str, Any]]: return usage(folder, [key])[key] def remove(folder: str | Path, key: str) -> None: """내 재료 지움 — 쓰는 문서가 있으면 409(그 줄을 먼저 바꿈).""" with store._LOCK: doc = read(folder) if not any(r.get("키") == key for r in doc["줄"]): raise store.StoreError(404, f"없는 내 재료 「{key}」") if used := uses(folder, key): names = " · ".join(str(u["이름"]) for u in used[:3]) raise store.StoreError(409, f"쓰는 곳 {len(used)} — {names} · 그 줄을 먼저 바꿈") doc["줄"] = [r for r in doc["줄"] if r.get("키") != key] atomic_write_json(Path(folder) / FILE, doc) # ── 840-4 셈 색인(단가산출이 M01 재료처럼 읽음) ───────────────────────── _MEMO: dict[str, tuple] = {} def whole(base: Any) -> Any: """셈 색인 + 지금 층(개인) 내 재료 줄 — 파일 · 바탕이 그대로면 같은 객체 · 개인 층 아님 = 바탕 그대로.""" from decimal import Decimal from M02_MasterTemplete import M02_Sangeun_Copy as skc folder = store.root() path = folder / FILE try: st = path.stat() except OSError: return base if not _personal(folder): return base sig = (st.st_mtime_ns, st.st_size) got = _MEMO.get(str(path)) if got and got[0] == sig and got[1] is base: return got[2] raw = json.dumps(rows(folder), ensure_ascii=False) mine = [ r for r in json.loads(raw, parse_float=Decimal, parse_int=Decimal) if is_own(str(r.get("키"))) ] out = skc.Copy(base, mine, ("내재료", sig)) if mine else base if len(_MEMO) > 16: # ponytail: 통째 비움 · 층이 많아지면 LRU 로 _MEMO.clear() _MEMO[str(path)] = (sig, base, out) return out def keys_in(doc: Any) -> list[str]: """문서 안 내 재료 키(요소 이음 「PM…」 · 사본 빈틈 찍기).""" return sorted(set(FIND.findall(json.dumps(doc, ensure_ascii=False, default=str)))) def added(index: Any) -> list[dict]: """`whole` 이 얹은 내 재료 줄(사본 모으기 `collect` 가 마스터 밖 줄로 담음).""" return list((getattr(index, "index", {}).get(PREFIX) or {}).values()) # ── 148-3 — 저장 검사 · 셈 · 사본 · 층 사이 ───────────────────────────── BUNDLE = "재료_개인자재.json" # 사본 `단가.json` 안 이름 def is_own(code: str) -> bool: return bool(KEY.match(code)) def _personal(folder: Path) -> bool: from M02_MasterTemplete import M02_Labor_Ref as refs return refs.where(folder)[0] == "personal" def find(code: str, folder: str | Path | None = None) -> dict[str, Any] | None: """개인 자재 한 줄 — `folder`(없으면 지금 층)가 개인 층일 때만.""" folder = Path(folder) if folder is not None else store.root() if not is_own(code) or not _personal(folder): return None return next((r for r in rows(folder) if r.get("키") == code), None) def codes(doc: Any) -> list[str]: """일위대가 문서 사용자 자재의 개인 자재 코드.""" mats = doc.get("자재") if isinstance(doc, dict) else None return [c for m in mats or [] if isinstance(m, dict) and is_own(c := str(m.get("코드") or ""))] def bundle(wanted: list[str], folder: str | Path) -> dict[str, Any] | None: """사본 `단가.json` 에 넣을 개인 자재 묶음(원본 층 목록에서 쓰는 줄만) — 없으면 None.""" got = [r for r in rows(folder) if r.get("키") in set(wanted)] return {**_blank(), "줄": got} if got else None def adopt(found: list[dict[str, Any]], folder: str | Path | None = None) -> None: """남의 줄을 같은 키로 내 목록에 — 이미 있는 키는 그대로 · 개인 층 아니면 400.""" folder = Path(folder) if folder is not None else store.root() if not found: return if not _personal(folder): raise store.StoreError(400, "개인 자재는 개인 층에서만 — " + found[0].get("키", "")) with store._LOCK: doc = read(folder) have = {r.get("키") for r in doc["줄"]} new = [{k: r.get(k) for k in ("키", *COLS, "값")} for r in found if r.get("키") not in have] if new: doc["줄"] += new atomic_write_json(folder / FILE, doc) def carry(doc: Any, source: str | Path) -> None: """층 사이 복사(`Store._server`) — 원본 개인 층 자재를 받는 층(지금 층) 목록에 같은 키로.""" wanted = codes(doc) if wanted and Path(source) != store.root(): adopt([r for r in rows(source) if r.get("키") in set(wanted)])