- 서버 `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>
281 lines
12 KiB
Python
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)])
|