Files
Aislo/M02_MasterTemplete/M02_Material_Own.py
T
eomsangdonandClaude Opus 5.5 207be5bd71 feat(840-4): 내 재료 = 재료 DB(M01)에 없는 자재를 등록하는 자리 — 개인관리 › 자재 등록 칸(만들기 · 고치기 · 지우기) · M01 과 같은 이름 · 규격 · 단위 막음 · 단가산출 재료 고르기 「내 재료」 · 값 바꾸면 자동 따라옴
- 서버 `M02_Material_Own`: M01 자재품목과 이름 · 규격 · 단위 같음 = 409(빈칸 · 대소문자 · ㎏ = kg 무시) · 고치기 · 지우기(쓰는 곳 있으면 409 · 밑줄 폴더 보관본 뺌) · 목록 줄마다 쓰는 곳
- 셈 색인: 단가산출 사본 색인 · 사본 모으기 · 빈틈 채우기에 내 재료 줄을 내 기계처럼 얹음 → 개인 층 단가산출은 값 바뀌면 836-2 길로 따라옴 · 프로젝트 층 = 사본 값
- 화면: 개인관리 › 자재 위 「내 재료」 표 + 고침 칸(`M02_MyMaterial_View` · `M02_MyMaterial_Api`) · 아래 「값을 바로 적은 재료」 모아 보기는 읽기 그대로 · 찾아 넣기 재료 탭 M01 위 「내 재료」 묶음(줄 쌓기 개인 층)
- M01 재료를 본떠 값만 바꾼 내 재료를 만드는 길 = 없었음(걷을 것 없음)

낡은 시험 고침: test_m02_material_own 목록 줄에 「쓰는곳」 칸

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 16:40:08 +09:00

281 lines
12 KiB
Python

"""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"(?<![A-Za-z0-9]){PREFIX}\d{{6}}(?!\d)")
COLS = ("이름", "규격", "단위")
def _blank() -> 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)])