wip(M01): 자재 조합 M01 길 초안 — 합치기 전 임시(sub4 자재조합 표 위에 얹을 예정)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RoyUwuTFiDJfvCfHpvKKzp
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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장.
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user