From b6e71c729daa85f0eb4155b9ffe873b1affd71aa Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 28 Sep 2026 18:46:12 +0900 Subject: [PATCH] =?UTF-8?q?feat(M01):=20=EC=A1=B0=ED=95=A9=20=EC=95=8C?= =?UTF-8?q?=EC=95=BD=20=ED=82=A4=20=E3=80=8C=EC=9D=B4=EB=A6=84|=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=E3=80=8D=20=C2=B7=20=EC=93=B0=EB=8A=94=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=AC=BC=20=EC=97=B4=EC=9D=B4=20=EC=9E=88=EC=9C=BC?= =?UTF-8?q?=EB=A9=B4=20=EC=A1=B0=ED=95=A9=20=EC=A7=80=EC=9A=B0=EA=B8=B0=20?= =?UTF-8?q?=EB=A7=89=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 알약 키를 첫 로직 코드에서 「이름|단위」 로 — 로직을 빼고 더하고 차례를 바꿔도 같은 물량이면 같은 키 - 조합 지우기 전 집계표 · 구조물집계표조합의 「일위대가」 칸을 훑어 쓰는 열이 있으면 409 + 구조물 이름들 - 시험 셋 더함(키 안 바뀜 · 쓰는 조합 못 지움 · 안 쓰는 조합 지워짐) Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RoyUwuTFiDJfvCfHpvKKzp --- M01_MasterData/M01_MasterData_Store_Combo.py | 31 +++++++- resources/tester/test_m01_combo_inputs.py | 79 +++++++++++++++++++- 2 files changed, 105 insertions(+), 5 deletions(-) diff --git a/M01_MasterData/M01_MasterData_Store_Combo.py b/M01_MasterData/M01_MasterData_Store_Combo.py index c340e0de..a9ff64d5 100644 --- a/M01_MasterData/M01_MasterData_Store_Combo.py +++ b/M01_MasterData/M01_MasterData_Store_Combo.py @@ -8,16 +8,21 @@ from __future__ import annotations +import json import re from datetime import date from pathlib import Path +from config import config_system from M01_MasterData import M01_MasterData_Store as store cm, mk, mcb = store.cm, store.mk, store.mcb FILE = mcb.FILE OWNERS = mcb.OWNERS +_MT = config_system.PROJECT_ROOT / "resources" / "master_template" +# 조합을 쓰는 열이 적힌 곳 — 집계표(정본) · 구조물집계표조합(SS 장부) · 시험이 바꿈 +USERS = (_MT / "table" / "구조물집계표.json", _MT / "구조물집계표조합.json") def _path() -> Path: @@ -150,7 +155,8 @@ def combo_inputs(key: str) -> dict: """조합 알약 — 담은 로직마다 결과단위 분모(원/㎥ → ㎥)가 물량 하나. 이름 = 로직 이름 끝 괄호 뗀 것(「(인력)」·「(장비)」 한 알약) · 같은 이름 · 단위면 합침 · - 키 = 그 알약 첫 로직 키 · `배` = 분모 앞 수(원/10㎡ → 10 · 1 이면 안 줌) · 돈 아닌 로직은 빠짐. + 키 = 「이름|단위」(로직을 빼고 더하고 차례를 바꿔도 같은 물량이면 같은 키) · + `배` = 분모 앞 수(원/10㎡ → 10 · 1 이면 안 줌) · 돈 아닌 로직은 빠짐. `조건` 은 자리만(설계 조건 고르기 · 범위는 알약에 안 넣음). """ row = _find(_read()[0], key) @@ -168,7 +174,9 @@ def combo_inputs(key: str) -> dict: # ponytail: 분모가 여럿(「원/㎡ · 원/㎥(단위당 따름)」)이면 「㎡ · ㎥」 한 글 — 입력이 고름 unit = " · ".join(dict.fromkeys(u for _, u in per)) name = _TAIL.sub("", str(got.get("이름") or code)) or code - pill = pills.setdefault((name, unit), {"키": code, "이름": name, "단위": unit, "로직": []}) + pill = pills.setdefault( + (name, unit), {"키": f"{name}|{unit}", "이름": name, "단위": unit, "로직": []} + ) pill["로직"].append(code) times = per[0][0] if len(per) == 1 and times and float(times) != 1: @@ -207,9 +215,28 @@ 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 장부를 함께 봄.""" + 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: + continue + head = col.get("머리") or [col.get(k) for k in ("종류", "분류1", "분류2", "분류3")] + cid = str(col.get("id") or col.get("열")) + names.setdefault(cid, " ".join(str(h) for h in head if h and str(h).strip()) or cid) + return list(names.values()) + + def combo_delete(key: str, version: str) -> dict: data, _ = _read() _find(data, key) + if users := combo_users(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/tester/test_m01_combo_inputs.py b/resources/tester/test_m01_combo_inputs.py index 0e7189d2..2ae1a2bc 100644 --- a/resources/tester/test_m01_combo_inputs.py +++ b/resources/tester/test_m01_combo_inputs.py @@ -4,16 +4,24 @@ - 물량 = 로직 결과단위 분모(원/㎥ → ㎥) · 설계 조건(고르기 · 범위)은 안 넣음(`조건` 은 빈 자리). - 이름 = 로직 이름 끝 괄호 뗀 것 · 같은 이름 · 단위면 한 알약(로직 여럿). - 원/10㎡ 는 단위 ㎡ · `배` 10 · 없는 조합 404. -정본 파일을 읽기만 함. + - 알약 키 = 「이름|단위」 — 로직을 빼고 · 더하고 · 차례를 바꿔도 같은 물량이면 같은 키. + - 조합 지우기 — 구조물 열이 「일위대가」 로 쓰면 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 @pytest.fixture(scope="module") @@ -36,13 +44,14 @@ def test_기슭막이_찰쌓기_인력_장비는_한_알약(client) -> None: assert len(got["물량"]) == 10 # 담은 로직 11 · 찰쌓기 둘이 합침 assert by[("돌쌓기 찰쌓기", "㎡")]["로직"] == ["GF000222", "GF000221"] assert by[("레디믹스트콘크리트 타설", "㎥")]["로직"] == ["GC000198"] - assert by[("유로폼 설치 및 해체", "㎡")]["키"] == "GC000214" + assert by[("유로폼 설치 및 해체", "㎡")]["키"] == "유로폼 설치 및 해체|㎡" assert set(got["물량"][0]) == {"키", "이름", "단위", "로직"} def test_원_10제곱미터는_배_10(client) -> None: got = _pills(client, "UA000031") - pill = next(p for p in got["물량"] if p["키"] == "GF000226") + pill = next(p for p in got["물량"] if p["로직"] == ["GF000226"]) + assert pill["키"] == f"{pill['이름']}|㎡" assert (pill["단위"], pill["배"]) == ("㎡", 10) @@ -53,3 +62,67 @@ def test_준공표지석_물량_여섯(client) -> None: def test_없는_조합은_404(client) -> None: assert client.get("/api/m01/combo/UA999999/inputs").status_code == 404 + + +@pytest.fixture +def own(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 _new(client: TestClient, name: str, codes: list[str]) -> str: + combo = {"이름": name, "담은로직": [{"로직": c, "메모": None} for c in codes]} + res = client.post("/api/m01/combo/new", json={"combo": combo}) + assert res.status_code == 200, res.text + return res.json()["key"] + + +def test_알약_키는_로직을_빼고_더하고_차례를_바꿔도_같다(own) -> None: + both = ["GF000222", "GF000221", "GC000198"] # 찰쌓기 인력 · 장비 + 타설 + full = _pills(own, _new(own, "전부", both))["물량"] + fewer = _pills(own, _new(own, "장비 뺌", ["GF000222", "GC000198"]))["물량"] + swapped = _pills(own, _new(own, "차례 바꿈", both[::-1]))["물량"] + keys = {p["키"] for p in full} + assert keys == {"돌쌓기 찰쌓기|㎡", "레디믹스트콘크리트 타설|㎥"} + assert {p["키"] for p in fewer} == keys + assert {p["키"] for p in swapped} == keys + assert len({p["키"] for p in full}) == len(full) # 키 겹침 없음 + + +def _use(sheet: Path, key: str) -> None: + cols = [ + {"id": "c5", "머리": ["돌기슭막이", "찰쌓기", "", ""], "일위대가": key}, + {"id": "c6", "머리": ["돌붙임", "", "", ""], "일위대가": "UA000009"}, + ] + sheet.write_text(json.dumps({"열": cols}, ensure_ascii=False), encoding="utf-8") + + +def test_쓰는_열이_있으면_조합을_못_지우고_구조물_이름을_알린다(own) -> None: + key = _new(own, "쓰는 조합", ["GF000222"]) + _use(combo_store.USERS[0], key) + version = own.get(f"/api/m01/combo?key={key}").json()["version"] + res = own.post("/api/m01/combo/delete", json={"key": key, "version": version}) + assert res.status_code == 409 + detail = res.json()["detail"] + assert detail["users"] == ["돌기슭막이 찰쌓기"] + assert "돌기슭막이 찰쌓기" in detail["errors"][0] + assert own.get(f"/api/m01/combo?key={key}").status_code == 200 # 그대로 남음 + + +def test_안_쓰는_조합은_지워진다(own) -> None: + key = _new(own, "안 쓰는 조합", ["GF000222"]) + _use(combo_store.USERS[0], "UA000009") # 다른 조합을 씀 + version = own.get(f"/api/m01/combo?key={key}").json()["version"] + assert ( + own.post("/api/m01/combo/delete", json={"key": key, "version": version}).status_code == 200 + ) + assert own.get(f"/api/m01/combo?key={key}").status_code == 404