Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1

This commit is contained in:
2026-11-01 15:21:05 +09:00
9 changed files with 213 additions and 12 deletions
+6 -2
View File
@@ -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")
+5 -2
View File
@@ -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 []:
@@ -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})
+2 -1
View File
@@ -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
+103
View File
@@ -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")
+4 -2
View File
@@ -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}"')
+8 -3
View File
@@ -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) · 요소 파일엔 상관없음
},
)
@@ -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"]
+2 -1
View File
@@ -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: