diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index a411a2cb..942a1ecd 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -15,6 +15,7 @@ from pydantic import BaseModel from M01_MasterData import M01_MasterData_Store as store from M01_MasterData import M01_MasterData_Store_Combo as combo_store from M01_MasterData import M01_MasterData_Store_Make as make +from M01_MasterData import M01_MasterData_Store_Matset as matset_store router = APIRouter(prefix="/api/m01", tags=["M01 MasterData"]) @@ -114,6 +115,20 @@ class PreviewCombo(BaseModel): inputs: dict[str, dict[str, Any]] = {} +class NewMatset(BaseModel): + """자재 조합 — 담은 자재 목록만(코드 · 이름 · 규격 · 단위 · 수량 · 단가는 안 둠).""" + + matset: dict[str, Any] + owner: str = matset_store.OWNERS[0] + + +class EditMatset(BaseModel): + key: str + version: str + matset: dict[str, Any] + owner: str = "" + + class DraftLogic(BaseModel): text: str # 줄로 적은 식 — 줄마다 「이름 = 식」 decided: dict[str, Any] = {} # 변수마다 정한 것 {이름: {무엇, 단위, 비목, 요소·찾기·값}} @@ -349,3 +364,29 @@ def post_combo_delete(body: DeleteCombo) -> dict: def post_combo_preview(body: PreviewCombo) -> dict: """담은 로직마다 값을 뽑아 비목별로 갈라 합계 — 저장하지 않음.""" return _call(combo_store.combo_preview, body.combo, body.inputs) + + +# ── 자재 조합(계약 9장 끝) ───────────────────────────────────────────── +@router.get("/matsets") +def get_matsets(q: str = "", owner: str = "") -> dict: + return {"matsets": _call(matset_store.matsets, q, owner)} + + +@router.get("/matset") +def get_matset(key: str) -> dict: + return _call(matset_store.matset, key) + + +@router.post("/matset/new") +def post_matset_new(body: NewMatset) -> dict: + return _call(matset_store.matset_new, body.matset, body.owner) + + +@router.post("/matset/edit") +def post_matset_edit(body: EditMatset) -> dict: + return _call(matset_store.matset_edit, body.key, body.version, body.matset, body.owner) + + +@router.post("/matset/delete") +def post_matset_delete(body: DeleteCombo) -> dict: + return _call(matset_store.matset_delete, body.key, body.version) diff --git a/M01_MasterData/M01_MasterData_Store_Combo.py b/M01_MasterData/M01_MasterData_Store_Combo.py index d9c18b69..3e98de46 100644 --- a/M01_MasterData/M01_MasterData_Store_Combo.py +++ b/M01_MasterData/M01_MasterData_Store_Combo.py @@ -218,15 +218,15 @@ def combo_edit(key: str, version: str, combo_: dict, owner: str = "") -> dict: return {**done, "combo": _find(store.read(FILE)[0], key)} -def combo_users(key: str) -> list[str]: - """이 조합을 「일위대가」 로 쓰는 구조물 열 이름들 — 집계표 · SS 장부를 함께 봄.""" +def users_of(field: str, key: str) -> list[str]: + """집계표 · SS 장부에서 열 칸 `field`(「일위대가」 · 「자재조합」)가 `key` 인 열 이름들.""" names: dict[str, str] = {} for path in USERS: if not path.is_file(): continue doc = json.loads(path.read_text(encoding="utf-8")) for col in doc.get("열") or doc.get("줄") or []: - if col.get("일위대가") != key: + if col.get(field) != key: continue head = col.get("머리") or [col.get(k) for k in ("종류", "분류1", "분류2", "분류3")] cid = str(col.get("id") or col.get("열")) @@ -234,6 +234,10 @@ def combo_users(key: str) -> list[str]: return list(names.values()) +def combo_users(key: str) -> list[str]: + return users_of("일위대가", key) + + def combo_delete(key: str, version: str) -> dict: data, _ = _read() _find(data, key) diff --git a/M01_MasterData/M01_MasterData_Store_Matset.py b/M01_MasterData/M01_MasterData_Store_Matset.py new file mode 100644 index 00000000..e3692168 --- /dev/null +++ b/M01_MasterData/M01_MasterData_Store_Matset.py @@ -0,0 +1,125 @@ +"""M01 자재 조합 저장소 — 목록 · 하나 읽기 · 만들기 · 고치기 · 지우기. + +계약: `_화면_계약.md` 9장 끝 · 파일 모양: `_틀.md` 11장 · 엔진: `scripts/master_matset.py`. +조합은 담은 자재 목록(코드 · 이름 · 규격 · 단위)만 지님 — 수량 · 단가는 두지 않음(재료산출 몫). +자리는 `자재조합.json` 한 파일 · 키는 `_키대장.json` 의 `UM` 다음 번호(서버가 줌). +""" + +from __future__ import annotations + +from datetime import date +from pathlib import Path + +from M01_MasterData import M01_MasterData_Store as store +from M01_MasterData import M01_MasterData_Store_Combo as combos + +cm = store.cm +msk = cm.msk + +FILE = msk.FILE +OWNERS = msk.OWNERS + + +def _path() -> Path: + return store.FOLDER / FILE + + +def _make_file() -> None: + """파일이 없으면 머리만 두고 만듦.""" + if not _path().is_file(): + head = {"그룹": msk.GROUP, "판": str(date.today().year), "줄": []} + _path().write_text(store.dump(head), encoding="utf-8", newline="\n") + + +def _read() -> tuple[dict, str]: + _make_file() + return store.read(FILE) + + +def _keys() -> list[str]: + return [str(r.get("키")) for r in _read()[0].get("줄") or []] + + +def _find(data: dict, key: str) -> dict: + for row in data.get("줄") or []: + if str(row.get("키")) == key: + return row + raise store.StoreError(404, f"없는 자재 조합 「{key}」") + + +def _clean(one: dict, owner: str, key: str | None = None) -> dict: + """서버가 잡는 칸 — 키 · 소유 · 열 한 벌(안 준 칸은 null · 자재 줄은 네 칸만).""" + if not isinstance(one, dict): + raise store.StoreError(400, "자재 조합 줄이 묶음 아님") + name = one.get("이름") + if not isinstance(name, str) or not name.strip(): + raise store.StoreError(400, "자재 조합 줄에 「이름」 이 없거나 글자 아님") + if owner not in OWNERS: + raise store.StoreError(400, f"모르는 소유 「{owner}」 — {' · '.join(OWNERS)}") + origin = one.get("본뜬키") + if origin is not None and (origin == key or origin not in _keys()): + raise store.StoreError(400, f"본뜬키 「{origin}」 — 비었거나 있는 다른 자재 조합 키여야 함") + got = one.get("담은자재") + if isinstance(got, list): # 화면이 덤을 실어도 자재 줄은 네 칸만 · 안 준 칸은 빈 글 + got = [{c: m.get(c, "") for c in msk.ITEM_COLS} if isinstance(m, dict) else m for m in got] + rest = {k: v for k, v in one.items() if k not in ("키", "원문번호", "소유")} + row = {**({"키": key} if key else {}), "소유": owner, **rest, "담은자재": got or []} + for col in msk.COLS: + row.setdefault(col, None) + return {k: row[k] for k in msk.COLS if k in row} + + +def _write(changes: list[dict], version: str | None = None) -> dict: + _make_file() + return store.save( + [{"file": FILE, "version": version or store.read(FILE)[1], "changes": changes}] + )[0] + + +def matsets(q: str = "", owner: str = "") -> list[dict]: + """목록 — 찾기(이름 · 키)와 소유로 거름 · 줄마다 자재 수.""" + out = [] + for _, row in msk.rows(store.loaded()[0]): + if owner and row.get("소유") != owner: + continue + if not store._hit(row, q): + continue + out.append( + {k: row.get(k) for k in msk.COLS if k != "담은자재"} + | {"file": FILE, "count": len(msk.items(row))} + ) + return out + + +def matset(key: str) -> dict: + data, version = _read() + return {"file": FILE, "version": version, "matset": _find(data, key)} + + +def matset_new(one: dict, owner: str = OWNERS[0]) -> dict: + """새 자재 조합 — 키는 서버가 `UM` 다음 번호로 줌.""" + before = set(_keys()) + done = _write([{"op": "add", "row": _clean(one, owner)}]) + key = next((k for k in reversed(_keys()) if k not in before), "") + return {**done, "key": key, "matset": _find(_read()[0], key) if key else {}} + + +def matset_edit(key: str, version: str, one: dict, owner: str = "") -> dict: + data, _ = _read() + was = _find(data, key) + if store.read(FILE)[1] != version: + raise store.StoreError(409, {"stale": [FILE]}) + fixed = _clean(one, owner or str(was.get("소유") or OWNERS[0]), key) + done = _write([{"op": "edit", "key": key, "row": fixed}], version) + return {**done, "matset": _find(_read()[0], key)} + + +def matset_delete(key: str, version: str) -> dict: + """지우기 — 구조물 열이 「자재조합」 칸으로 쓰면 409 + 구조물 이름들(지우지 않음).""" + _find(_read()[0], key) + if users := combos.users_of("자재조합", key): + msg = f"구조물 {len(users)} 곳이 쓰는 자재 조합이라 못 지움 — {' · '.join(users)}" + raise store.StoreError(409, {"errors": [msg], "users": users}) + if store.read(FILE)[1] != version: + raise store.StoreError(409, {"stale": [FILE]}) + return _write([{"op": "delete", "key": key}], version) diff --git a/resources/master_data/_화면_계약.md b/resources/master_data/_화면_계약.md index 2199db1d..78400935 100644 --- a/resources/master_data/_화면_계약.md +++ b/resources/master_data/_화면_계약.md @@ -196,6 +196,22 @@ - 못 도는 줄은 통째로 멈추지 않고 그 줄만 `ok` false · `까닭` · `멈춤` 에 한 줄 — 나머지 줄의 합계는 그대로 냄. - 답 — 200 · 400(틀린 몸 · 이름 없음 · 모르는 소유) · 404(없는 키) · 409(낡은 판본) · 422 `{detail: {errors: […]}}`(검사 걸림 — 없는 로직 키 · 같은 로직 두 번 · 빈 조합 · 조합을 담음). +### 자재 조합 + +재료산출에서 쓸 **자재 목록 묶음**(파일 `자재조합.json` · 키 `UM` · `_틀.md` 11장). 담은 자재 `{코드, 이름, 규격, 단위}` 만 지님 — 수량 · 단가 없음. 요소 그룹이 아님(`/groups` · `/rows` 에 안 끼고 제 길로만). + +| 길 | 받음 | 줌 | +| --------------------- | ------------------------------- | ------------------------------------------- | +| `GET /matsets` | `q?` `owner?` | `{matsets: […]}` — 줄마다 `count` (자재 수) | +| `GET /matset` | `key` | `{file, version, matset}` | +| `POST /matset/new` | `{matset, owner?}` | `{file, version, key, matset}` | +| `POST /matset/edit` | `{key, version, matset, owner?}` | `{file, version, matset}` | +| `POST /matset/delete` | `{key, version}` | `{file, version}` | + +- 답 — 200 · 400(이름 없음 · 모르는 소유 · 없는/자기 본뜬키) · 404 · 409(낡은 판본 · 지우기 막음) · 422(없는 자재 코드 · 같은 자재 두 번 · 자재 줄 모양). +- 지우기는 구조물집계표 · `구조물집계표조합` 의 「자재조합」 칸으로 쓰는 열이 있으면 409 `{detail: {errors: […], users: [구조물 이름…]}}` — 지우지 않음. +- M02 화면이 새로 만드는 조합도 같은 파일 · 같은 길이라 M01 목록에 같이 보임. + ## 10. 재료 · 기계 고르기 재료 줄은 키 대신 기본 검색어로 품목을 찾음 — 파일 모양 · 찾기 규칙 · 자동값 차례 · 단위 환산은 `_틀.md` 7장. diff --git a/resources/tester/test_m01_matset.py b/resources/tester/test_m01_matset.py new file mode 100644 index 00000000..58ff30cc --- /dev/null +++ b/resources/tester/test_m01_matset.py @@ -0,0 +1,107 @@ +"""M01 자재 조합(`/api/m01/matset…` · 파일 `자재조합.json` · 키 UM) — 담은 자재 목록만 지님. + +못박는 것 + - 새로 만들면 키를 서버가 `UM` 다음 번호로 줌 · 열 한 벌 · 담은 자재는 네 칸. + - 없는 자재 코드 · 같은 자재 두 번 · 이름 없음 · 없는 본뜬키는 막음. + - 고치기는 낡은 판본이면 409 · 지우기는 구조물 열이 「자재조합」 칸으로 쓰면 409 + 구조물 이름들. + - 정본은 읽기만 함(사본 폴더 · 사본 집계표). +""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from M01_MasterData import M01_MasterData_Router as router_module +from M01_MasterData import M01_MasterData_Store as store +from M01_MasterData import M01_MasterData_Store_Combo as combo_store + +ROOT = Path(__file__).resolve().parents[2] + + +@pytest.fixture +def client(tmp_path: Path, monkeypatch) -> TestClient: + for path in store.FOLDER.glob("*.json"): + if not path.name.startswith("_") or path.name == store.mk.BOOK.name: + shutil.copy(path, tmp_path / path.name) + monkeypatch.setattr(store, "FOLDER", tmp_path) + sheet = tmp_path / "집계표.json" + sheet.write_text(json.dumps({"열": []}), encoding="utf-8") + monkeypatch.setattr(combo_store, "USERS", (sheet,)) + app = FastAPI() + app.include_router(router_module.router) + return TestClient(app) + + +def _mat(client: TestClient) -> dict: + """자재품목에서 실제 줄 하나 — 자재 찾기 길 그대로.""" + got = client.get("/api/m01/pick", params={"kind": "price", "q": "합판", "limit": 2}).json() + one = got["items"][0] + return {"코드": one["ref"], "이름": one["이름"], "규격": one["규격"], "단위": one["단위"]} + + +def _new(client: TestClient, name: str, mats: list[dict], **more) -> dict: + body = {"matset": {"이름": name, "담은자재": mats, **more}} + return client.post("/api/m01/matset/new", json=body) + + +def test_정본_자재조합_파일은_틀대로다() -> None: + data = json.loads((ROOT / "resources/master_data/자재조합.json").read_text(encoding="utf-8")) + assert data["그룹"] == "자재조합" and isinstance(data["줄"], list) + assert store.cm.msk.check_form("자재조합.json", data) == [] + assert '"UM"' in (ROOT / "resources/master_data/_키대장.json").read_text(encoding="utf-8") + + +def test_새로_만들면_UM_키와_열_한_벌(client) -> None: + mat = _mat(client) + res = _new(client, "합판 자재", [{**mat, "덤": 1}]) + assert res.status_code == 200, res.text + made = res.json() + assert made["key"].startswith("UM") and len(made["key"]) == 8 + assert tuple(made["matset"]) == store.cm.msk.COLS + assert made["matset"]["담은자재"] == [mat] and made["matset"]["본뜬키"] is None + rows = client.get("/api/m01/matsets").json()["matsets"] + assert [(r["키"], r["count"]) for r in rows] == [(made["key"], 1)] + got = client.get("/api/m01/matset", params={"key": made["key"]}).json() + assert got["matset"]["이름"] == "합판 자재" + + +def test_없는_자재_두_번_담기_이름_본뜬키는_막는다(client) -> None: + mat = _mat(client) + assert _new(client, "없는 코드", [{**mat, "코드": "MT999999"}]).status_code == 422 + assert _new(client, "두 번", [mat, mat]).status_code == 422 + assert _new(client, " ", [mat]).status_code == 400 + assert _new(client, "없는 원본", [mat], 본뜬키="UM999999").status_code == 400 + ok = _new(client, "원본", [mat]).json()["key"] + assert _new(client, "본뜬 것", [mat], 본뜬키=ok).json()["matset"]["본뜬키"] == ok + assert _new(client, "원본", [mat]).status_code == 422 # 이름 겹침 + + +def test_고치기는_낡은_판본이면_409(client) -> None: + mat = _mat(client) + key = _new(client, "고칠 것", [mat]).json()["key"] + one = client.get("/api/m01/matset", params={"key": key}).json() + body = {"key": key, "version": one["version"], "matset": {**one["matset"], "이름": "고침"}} + assert client.post("/api/m01/matset/edit", json=body).status_code == 200 + assert client.post("/api/m01/matset/edit", json=body).status_code == 409 # 같은 옛 판본 + + +def test_쓰는_열이_있으면_못_지우고_없으면_지워진다(client) -> None: + mat = _mat(client) + key = _new(client, "쓰는 것", [mat]).json()["key"] + cols = [{"id": "c9", "머리": ["돌붙임", "찰붙임", "", ""], "자재조합": key}] + combo_store.USERS[0].write_text(json.dumps({"열": cols}, ensure_ascii=False), encoding="utf-8") + version = client.get("/api/m01/matset", params={"key": key}).json()["version"] + res = client.post("/api/m01/matset/delete", json={"key": key, "version": version}) + assert res.status_code == 409 and res.json()["detail"]["users"] == ["돌붙임 찰붙임"] + combo_store.USERS[0].write_text(json.dumps({"열": []}), encoding="utf-8") + assert ( + client.post("/api/m01/matset/delete", json={"key": key, "version": version}).status_code + == 200 + ) + assert client.get("/api/m01/matset", params={"key": key}).status_code == 404