From 7cf504a7c75652e1b7f22457c382f6bf32189701 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 15:15:30 +0900 Subject: [PATCH] =?UTF-8?q?feat(713-1):=20=EB=A1=9C=EC=A7=81=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B8=20=EA=B0=9C=EC=A0=95=20=ED=99=95=EC=9D=B8=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=E2=80=94=20/save=20=C2=B7=20=ED=9D=90=EB=A6=84=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=C2=B7=20=EC=A7=80=EC=9A=B0=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EB=8D=94=ED=95=98=EA=B8=B0=EA=B0=80=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=A0=95=EB=B3=B8=20=EC=A4=84=EC=9D=84=20=EB=B0=94?= =?UTF-8?q?=EA=BE=B8=EB=A9=B4=20=EA=B0=9C=EC=A0=95=ED=99=95=EC=9D=B8=20?= =?UTF-8?q?=EC=97=86=EC=9D=B4=EB=8A=94=20409(=EA=B0=9C=EC=A0=95=20=C2=B7?= =?UTF-8?q?=20=EA=B9=8C=EB=8B=AD)=20=C2=B7=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=EC=A4=84=20=EB=8B=A4=EC=8B=9C=20=EB=B3=B4=EB=83=84=20=C2=B7=20?= =?UTF-8?q?=EC=9A=94=EC=86=8C=20=C2=B7=20=EC=9E=90=EC=B2=B4=EB=8A=94=20?= =?UTF-8?q?=EB=8C=80=EC=83=81=20=EB=B0=96=20=C2=B7=20=EC=A4=84=20=EB=B0=94?= =?UTF-8?q?=ED=83=95=20=EC=A7=80=EB=AC=B8=20row=5Fprint(=EA=B3=84=EC=82=B0?= =?UTF-8?q?=20=EC=B9=B8)=20=C2=B7=20GET=20/logic=20=EC=A7=80=EB=AC=B8=20?= =?UTF-8?q?=C2=B7=20=EC=83=88=20=ED=8C=8C=EC=9D=BC=20Store=5FRevise?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 낡은 시험 고침: test_m01_api · test_m01_cache · test_m01_logic_flow_save · test_208_1_logic_alias · test_m01_logic_flow_project 가 임시 사본의 정본 로직 줄을 고쳐 저장 — 이제 개정 확인이 필요하므로 몸에 개정확인 true(뜻 그대로) Co-Authored-By: Claude Opus 5.5 --- M01_MasterData/M01_MasterData_Router.py | 8 +- M01_MasterData/M01_MasterData_Store_Flow.py | 7 +- M01_MasterData/M01_MasterData_Store_Revise.py | 81 ++++++++++++++ resources/tester/test_208_1_logic_alias.py | 3 +- resources/tester/test_713_1_revise.py | 103 ++++++++++++++++++ resources/tester/test_m01_api.py | 6 +- resources/tester/test_m01_cache.py | 11 +- .../tester/test_m01_logic_flow_project.py | 3 +- resources/tester/test_m01_logic_flow_save.py | 3 +- 9 files changed, 213 insertions(+), 12 deletions(-) create mode 100644 M01_MasterData/M01_MasterData_Store_Revise.py create mode 100644 resources/tester/test_713_1_revise.py diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index 273f709ad..d3d083af4 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -16,6 +16,7 @@ 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_Flow as flow from M01_MasterData import M01_MasterData_Store_Make as make +from M01_MasterData import M01_MasterData_Store_Revise as revise import master_basis as mbs # noqa: E402 (`store` 가 scripts 를 길에 둠) @@ -49,6 +50,7 @@ class FileChanges(BaseModel): class SaveBody(BaseModel): files: list[FileChanges] + 개정확인: bool = False # 로직 정본 줄 고침 = 「원문 개정 반영」 확인(713-1) · 없으면 409 class NewLogic(BaseModel): @@ -196,7 +198,9 @@ def get_logics( @router.get("/logic") def get_logic(key: str) -> dict: - return _call(store.logic, key) + """`지문` = 줄 바탕 지문(계산 칸 · `Store_Revise.row_print`) — M02 덮개 「M01 개정」 비교 값.""" + out = _call(store.logic, key) + return {**out, "지문": revise.row_print(out["logic"])} @router.get("/logic/source") @@ -275,7 +279,7 @@ def post_text(body: CalcBody) -> dict: @router.post("/save") def post_save(body: SaveBody) -> dict: """「흐름」 줄은 검사 · 서버 다시 셈 · 새 줄(키 없음)은 자체 GX — {files, added, warnings}.""" - return _call(flow.save, [f.model_dump() for f in body.files]) + return _call(flow.save, [f.model_dump() for f in body.files], body.개정확인) @router.get("/logic/flow") diff --git a/M01_MasterData/M01_MasterData_Store_Flow.py b/M01_MasterData/M01_MasterData_Store_Flow.py index c2d83eaf0..34e111133 100644 --- a/M01_MasterData/M01_MasterData_Store_Flow.py +++ b/M01_MasterData/M01_MasterData_Store_Flow.py @@ -21,6 +21,7 @@ from typing import Any from M01_MasterData import M01_MasterData_Store as store from M01_MasterData import M01_MasterData_Store_Make as make +from M01_MasterData import M01_MasterData_Store_Revise as revise from M02_MasterTemplete.DataFlow import M02_DF_Compute as dc from M02_MasterTemplete.DataFlow import M02_DF_Values as dv @@ -375,8 +376,10 @@ def _bare(row: dict) -> dict: return {"끝수": None, **kept, "입력": inputs, "출처": row.get("출처") or "자체"} -def save(batch: list[dict]) -> dict: - """`/save` — 흐름 줄은 검사 · 다시 셈 · 새 줄은 자체 GX · 그 밖은 `Store.save` 그대로.""" +def save(batch: list[dict], confirmed: bool = False) -> dict: + """`/save` — 흐름 줄은 검사 · 다시 셈 · 새 줄은 자체 GX · 그 밖은 `Store.save` 그대로 · + 로직 정본 줄 고침은 `confirmed`(「원문 개정 반영」) 없으면 409(`Store_Revise` · 713-1).""" + revise.guard(batch, confirmed) warns, fresh = [], False for part in batch: for ch in part.get("changes") or []: diff --git a/M01_MasterData/M01_MasterData_Store_Revise.py b/M01_MasterData/M01_MasterData_Store_Revise.py new file mode 100644 index 000000000..2d28fba21 --- /dev/null +++ b/M01_MasterData/M01_MasterData_Store_Revise.py @@ -0,0 +1,81 @@ +"""M01 로직 정본 개정 확인 (PLAN 713-1 · 요구서 내역12 4장 10 · 17). + +- 로직 정본 파일(`로직_*.json` · 자체 `로직_자체.json` 뺌) 줄을 실제로 바꾸는 `/save`(흐름 저장 · + 지우기 포함)는 「원문 개정 반영」 확인 값(`개정확인`)이 있어야 함 — 없으면 409 + 까닭. +- 요소 파일(인력 · 재료 · 표 …) · 자체 로직 · 같은 줄 그대로 다시 보냄은 대상 밖. +- 줄 바탕 지문(`row_print`) = 계산 칸만 정렬 JSON(수는 Decimal 정규 글) sha256 앞 16 자 — + M02 덮개가 담아 두고 「M01 개정 — 확인」 배지를 띄울 값. +- 판 칸 · 옛 줄 사본은 안 둠 — 정본 이력은 git. +""" + +from __future__ import annotations + +import hashlib +import json +from decimal import Decimal + +from M01_MasterData import M01_MasterData_Store as store + +OWN = "로직_자체.json" +PRINT_FIELDS = ("결과단위", "입력", "중간", "호표", "덧줄", "결과", "끝수", "흐름") +WHY = "로직 정본(품셈 원문) 줄을 바꿈 — 「원문 개정 반영」 확인 뒤 저장(개정확인)" + + +def is_canon(file: str) -> bool: + return file.startswith("로직_") and file.endswith(".json") and file != OWN + + +def _norm(v): + """수는 int · float · Decimal 어느 꼴로 와도 같은 글(「10」 · 「10.0」 → 「10」).""" + if isinstance(v, bool) or v is None or isinstance(v, str): + return v + if isinstance(v, (int, float, Decimal)): + return format(Decimal(str(v)).normalize(), "f") + if isinstance(v, list): + return [_norm(x) for x in v] + if isinstance(v, dict): + return {k: _norm(x) for k, x in v.items()} + return str(v) + + +def row_print(row: dict) -> str: + """줄 바탕 지문 — 계산 칸(`PRINT_FIELDS`)만 · 이름 · 별칭 · 비고 고침은 지문을 안 바꿈.""" + calc = {k: _norm(row[k]) for k in PRINT_FIELDS if row.get(k) is not None} + raw = json.dumps(calc, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16] + + +def changed(batch: list[dict]) -> list[dict]: + """정본 줄을 바꾸는 고침 — `[{file, key, op, 이름}]` · 같은 줄을 그대로 다시 보낸 edit 은 뺌.""" + out = [] + for part in batch: + file = str(part.get("file") or "") + if not is_canon(file): + continue + try: + rows = {str(r.get("키")): r for r in store.read(file)[0].get("줄") or []} + except store.StoreError: + continue # 없는 파일 — `store.save` 가 404 + for ch in part.get("changes") or []: + op, key, row = ch.get("op"), str(ch.get("key") or ""), ch.get("row") + old = rows.get(key) + if op == "edit" and old is not None and store._dec(row) == old: + continue + name = (row or {}).get("이름") if isinstance(row, dict) else None + out.append( + { + "file": file, + "key": key or None, + "op": op, + "이름": name or (old or {}).get("이름"), + } + ) + return out + + +def guard(batch: list[dict], confirmed: bool) -> None: + if confirmed: + return + hits = changed(batch) + if hits: + raise store.StoreError(409, {"개정": hits, "까닭": WHY}) diff --git a/resources/tester/test_208_1_logic_alias.py b/resources/tester/test_208_1_logic_alias.py index bf5ac9e66..c06cfe8f0 100644 --- a/resources/tester/test_208_1_logic_alias.py +++ b/resources/tester/test_208_1_logic_alias.py @@ -118,7 +118,8 @@ def _save(client: TestClient, row: dict) -> None: "version": one["version"], "changes": [{"op": "edit", "key": LOGIC_KEY, "row": row}], } - res = client.post("/api/m01/save", json={"files": [part]}) + body = {"files": [part], "개정확인": True} # 정본 줄 고침 = 개정 확인(713-1) + res = client.post("/api/m01/save", json=body) assert res.status_code == 200, res.text diff --git a/resources/tester/test_713_1_revise.py b/resources/tester/test_713_1_revise.py new file mode 100644 index 000000000..a802663b6 --- /dev/null +++ b/resources/tester/test_713_1_revise.py @@ -0,0 +1,103 @@ +"""713-1 지킴 — 로직 정본 줄 고침 · 지우기 · 더하기는 「원문 개정 반영」 확인(`개정확인`) 없이는 409 · +요소 · 같은 줄 다시 보냄은 그대로 · 줄 바탕 지문(`Store_Revise.row_print`). 임시 사본 폴더만 씀.""" + +from __future__ import annotations + +import shutil +from decimal import Decimal +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_Revise as revise + +REAL = store.FOLDER +KEY = "GC000091" # 공통 3-3-1 암발파 — 로직_건설품셈_03장_토공사.json + + +@pytest.fixture(scope="module") +def client(tmp_path_factory) -> TestClient: + folder = tmp_path_factory.mktemp("m01_revise") + for path in REAL.glob("*.json"): + if not path.name.startswith("_") or path.name == store.mk.BOOK.name: + shutil.copy(path, folder / path.name) + patch = pytest.MonkeyPatch() + patch.setattr(store, "FOLDER", folder) + app = FastAPI() + app.include_router(router_module.router) + yield TestClient(app) + patch.undo() + + +def _part(client: TestClient, op: str, row: dict | None = None, key: str | None = KEY) -> dict: + one = client.get("/api/m01/logic", params={"key": KEY}).json() + return { + "file": one["file"], + "version": one["version"], + "changes": [{"op": op, "key": key, "row": row}], + } + + +def _bytes(client: TestClient) -> bytes: + return ( + store.FOLDER / client.get("/api/m01/logic", params={"key": KEY}).json()["file"] + ).read_bytes() + + +def test_정본_고침_지움_더함_확인_없으면_409(client: TestClient) -> None: + base = client.get("/api/m01/logic", params={"key": KEY}).json()["logic"] + before = _bytes(client) + for op, row, key in ( + ("edit", {**base, "비고": "개정 시험"}, KEY), + ("delete", None, KEY), + ("add", {k: v for k, v in base.items() if k != "키"}, None), + ): + res = client.post("/api/m01/save", json={"files": [_part(client, op, row, key)]}) + assert res.status_code == 409, (op, res.text) + got = res.json()["detail"] + assert got["개정"][0]["op"] == op and "원문 개정 반영" in got["까닭"] + assert _bytes(client) == before # 확인 없이는 한 바이트도 안 바뀜 + + +def test_같은_줄_다시_보냄은_그대로(client: TestClient) -> None: + base = client.get("/api/m01/logic", params={"key": KEY}).json()["logic"] + res = client.post("/api/m01/save", json={"files": [_part(client, "edit", base)]}) + assert res.status_code == 200, res.text + + +def test_확인하면_저장_지문은_계산_칸만(client: TestClient) -> None: + one = client.get("/api/m01/logic", params={"key": KEY}).json() + base, print0 = one["logic"], one["지문"] + assert print0 == revise.row_print(base) + body = {"files": [_part(client, "edit", {**base, "비고": "개정 시험"})], "개정확인": True} + assert client.post("/api/m01/save", json=body).status_code == 200 + again = client.get("/api/m01/logic", params={"key": KEY}).json() + assert again["logic"]["비고"] == "개정 시험" and again["지문"] == print0 # 비고 = 지문 밖 + hop = [dict(x) for x in base["호표"]] + hop[0]["수량"] = f"({hop[0]['수량']}) * 2" + body = {"files": [_part(client, "edit", {**again["logic"], "호표": hop})], "개정확인": True} + assert client.post("/api/m01/save", json=body).status_code == 200, "계산 칸 고침" + assert client.get("/api/m01/logic", params={"key": KEY}).json()["지문"] != print0 + + +def test_요소_파일은_대상_밖(client: TestClient) -> None: + rows = client.get("/api/m01/rows", params={"file": "인력.json", "size": 1}).json() + row = {**rows["rows"][0], "비고": "요소 시험"} + part = { + "file": "인력.json", + "version": rows["version"], + "changes": [{"op": "edit", "key": row["키"], "row": row}], + } + assert client.post("/api/m01/save", json={"files": [part]}).status_code == 200 + + +def test_지문_수_꼴_같음() -> None: + a = {"이름": "가", "호표": [{"수량": Decimal("10.0")}], "끝수": 1} + b = {"이름": "나", "호표": [{"수량": 10}], "끝수": 1.0, "별칭": "x"} + assert revise.row_print(a) == revise.row_print(b) + assert revise.is_canon("로직_산림품셈_09장_토공.json") + assert not revise.is_canon("로직_자체.json") and not revise.is_canon("인력.json") diff --git a/resources/tester/test_m01_api.py b/resources/tester/test_m01_api.py index de50867eb..563ecb494 100644 --- a/resources/tester/test_m01_api.py +++ b/resources/tester/test_m01_api.py @@ -231,7 +231,7 @@ def test_더함_지움_로직_파일도_같은_길(client: TestClient) -> None: one = _get(client, "/api/m01/logic", key=LOGIC_KEY) copy = {**one["logic"], "키": "", "원문번호": LOGIC_NUM} add = {"file": one["file"], "version": one["version"], "changes": [{"op": "add", "row": copy}]} - res = client.post("/api/m01/save", json={"files": [add]}) + res = client.post("/api/m01/save", json={"files": [add], "개정확인": True}) # 713-1 assert res.status_code == 200, res.text book = json.loads((store.FOLDER / store.mk.BOOK.name).read_text(encoding="utf-8")) new = f"GC{book['다음']['GC'] - 1:06d}" # 대장의 다음 번호를 받음 @@ -242,7 +242,8 @@ def test_더함_지움_로직_파일도_같은_길(client: TestClient) -> None: "version": res.json()["files"][0]["version"], "changes": [{"op": "delete", "key": new}], } - assert client.post("/api/m01/save", json={"files": [drop]}).status_code == 200 + body = {"files": [drop], "개정확인": True} + assert client.post("/api/m01/save", json=body).status_code == 200 assert client.get("/api/m01/logic", params={"key": new}).status_code == 404 @@ -322,6 +323,7 @@ def test_저장은_고친_줄만_바꿈(client: TestClient) -> None: edited = {**one["logic"], "비고": "고침"} change = {"op": "edit", "key": LOGIC_KEY, "row": edited} body = {"files": [{"file": one["file"], "version": one["version"], "changes": [change]}]} + body["개정확인"] = True # 정본 줄 고침 = 개정 확인(713-1) assert client.post("/api/m01/save", json=body).status_code == 200 new = path.read_text(encoding="utf-8") at = old.index(f'"키": "{LOGIC_KEY}"') diff --git a/resources/tester/test_m01_cache.py b/resources/tester/test_m01_cache.py index 64f3e6f14..db8a107c8 100644 --- a/resources/tester/test_m01_cache.py +++ b/resources/tester/test_m01_cache.py @@ -94,7 +94,8 @@ def test_로직을_저장하면_목록에_바로_비친다(client: TestClient) - "version": one["version"], "changes": [{"op": "edit", "key": CALLER, "row": row}], } - ] + ], + "개정확인": True, # 정본 줄 고침 = 개정 확인(713-1) }, ) assert res.status_code == 200, res.text @@ -128,7 +129,8 @@ def _save(client: TestClient, key: str, spoil) -> object: "version": one["version"], "changes": [{"op": "edit", "key": key, "row": row}], } - ] + ], + "개정확인": True, # 정본 줄 고침 = 개정 확인(713-1) }, ) @@ -200,7 +202,10 @@ def _save_file(client: TestClient, file: str, changes: list[dict]): """정본 파일을 저장 길로 고침 — 요소·표 줄도 같은 길.""" return client.post( "/api/m01/save", - json={"files": [{"file": file, "version": store.read(file)[1], "changes": changes}]}, + json={ + "files": [{"file": file, "version": store.read(file)[1], "changes": changes}], + "개정확인": True, # 로직 정본 파일이면 개정 확인(713-1) · 요소 파일엔 상관없음 + }, ) diff --git a/resources/tester/test_m01_logic_flow_project.py b/resources/tester/test_m01_logic_flow_project.py index 5a4e75951..6dcdbc4f8 100644 --- a/resources/tester/test_m01_logic_flow_project.py +++ b/resources/tester/test_m01_logic_flow_project.py @@ -75,7 +75,8 @@ def test_조합에_담은_로직을_흐름으로_바꿔도_같음(tmp_path: Path flow.pop("key", None) change = {"op": "edit", "key": key, "row": {**got["logic"], "흐름": flow}} sflow.save( - [store._dec({"file": got["file"], "version": got["version"], "changes": [change]})] + [store._dec({"file": got["file"], "version": got["version"], "changes": [change]})], + True, # 정본 줄 고침 = 개정 확인(713-1) ) for key in KEYS: row = store.logic(key)["logic"] diff --git a/resources/tester/test_m01_logic_flow_save.py b/resources/tester/test_m01_logic_flow_save.py index 24571235e..cef39c2c7 100644 --- a/resources/tester/test_m01_logic_flow_save.py +++ b/resources/tester/test_m01_logic_flow_save.py @@ -100,7 +100,8 @@ def _save_flow(client: TestClient, key: str, flow: dict, want: int = 200) -> dic row = {**got["logic"], "흐름": flow} part = {"file": got["file"], "version": got["version"], "changes": [{"op": "edit", "key": key, "row": row}]} # fmt: skip - return _post(client, "/api/m01/save", {"files": [part]}, want) + body = {"files": [part], "개정확인": True} # 정본 줄 고침 = 개정 확인(713-1) + return _post(client, "/api/m01/save", body, want) def test_흐름_저장_다시_셈_다시_읽기(client: TestClient, folder: Path) -> None: