From cf396d93ff8c882eed5be13fffe7360cd8293354 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 12 Oct 2026 21:54:43 +0900 Subject: [PATCH] =?UTF-8?q?fix(b09):=20[=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8]=20=EB=95=8C=20=EC=A7=80=EC=9A=B4=20=EB=B3=B5?= =?UTF-8?q?=EC=82=AC=20=EC=A0=95=EB=B3=B4=20=EB=BA=8C=20=E2=80=94=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=BA=90=EC=8B=9C=20=EA=B8=B0=EC=A4=80=20?= =?UTF-8?q?=EC=88=98=EC=9A=94=20=C2=B7=20M01=20=EC=9B=90=EB=B3=B8=20?= =?UTF-8?q?=EC=97=86=EB=8A=94=20=EC=A4=84=20=EA=B5=B3=ED=9E=8C=20=EA=B0=92?= =?UTF-8?q?=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20(296-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [업데이트] = 화면 캐시(user)를 같이 보냄 · 캐시 그대로 둠(지운 줄 안 되살림 · 저장 안 함) - 서버 수요 = 캐시 줄이 잇는 서고 사본만(지운 줄의 사본 단가 줄 안 살아남) - M01 원본이 지워진 키 = 옛 책 줄 그대로 · 낡음 까닭에서 빠짐 - 시험 test_296_b09_update_skip_deleted Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf --- B09_Estimation/B09_Estimation_Linked.py | 7 +- B09_Estimation/B09_Estimation_Prices.py | 10 ++- B09_Estimation/B09_Estimation_Router.py | 41 +++++---- B09_Estimation/B09_Estimation_UI_Shell.ts | 21 ++++- .../test_296_b09_update_skip_deleted.py | 90 +++++++++++++++++++ 5 files changed, 143 insertions(+), 26 deletions(-) create mode 100644 resources/tester/test_296_b09_update_skip_deleted.py diff --git a/B09_Estimation/B09_Estimation_Linked.py b/B09_Estimation/B09_Estimation_Linked.py index 868e8e7e3..f1776fdaf 100644 --- a/B09_Estimation/B09_Estimation_Linked.py +++ b/B09_Estimation/B09_Estimation_Linked.py @@ -66,9 +66,9 @@ def used_keys(folder: Path) -> list[str]: return seen -def linked_resource_keys(project_root: str | Path) -> list[str]: +def linked_resource_keys(project_root: str | Path, keep: set[str] | None = None) -> list[str]: """연결된 사본 전부가 부른 자원 키 — 구조물 id 차례 → 설계내역서 서고 사본(204-2) · - 겹침 없음.""" + 겹침 없음 · `keep` = 화면 캐시 줄이 잇는 서고 사본만(296 · 지운 줄의 사본 뺌).""" from B09_Estimation.B09_Estimation_Bill_Library import copies base = structures_dir(project_root) @@ -78,7 +78,8 @@ def linked_resource_keys(project_root: str | Path) -> list[str]: if "일위대가" in linked.get("부분", []) ] out: list[str] = [] - for folder in [*folders, *(f for f, _ in copies(project_root))]: + libs = [f for f, _ in copies(project_root) if keep is None or f.name in keep] + for folder in [*folders, *libs]: for key in used_keys(folder): if key not in out: out.append(key) diff --git a/B09_Estimation/B09_Estimation_Prices.py b/B09_Estimation/B09_Estimation_Prices.py index e8dd8d916..d34bc262f 100644 --- a/B09_Estimation/B09_Estimation_Prices.py +++ b/B09_Estimation/B09_Estimation_Prices.py @@ -182,14 +182,17 @@ def _convert(master, key: str) -> dict[str, Any]: return material_input(row) -def refresh_book(keys: list[str]) -> dict[str, Any]: - """[단가 갱신] — 수요 키 전부를 지금 M01 값으로 새로 굳힘.""" +def refresh_book(keys: list[str], book: dict[str, Any] | None = None) -> dict[str, Any]: + """[단가 갱신] — 수요 키 전부를 지금 M01 값으로 새로 굳힘 · + M01 원본이 지워진 키(296) = 옛 책 줄 그대로(지우지도 바꾸지도 않음).""" master = _master() want = demand(master, keys) rows = { key: _convert(master, key) for key in [*want["labor"], *want["machines"], *want["materials"]] } + old = (book or {}).get("rows") or {} + rows.update({key: old[key] for key in want["missing"] if key in old}) return { "made": datetime.now().isoformat(timespec="seconds"), "rows": rows, @@ -202,7 +205,8 @@ def book_stale(book: dict[str, Any] | None, keys: list[str]) -> str | None: """203-1 [업데이트] 배지 — 지금 M01 로 새로 굳힌 책(`refresh_book`)과 견줌 · 다른 것 한 줄(같으면 None).""" if book is None: return "단가 아직 안 굳힘" - fresh = json.loads(json.dumps(refresh_book(keys), ensure_ascii=False)) # 저장 꼴(JSON)로 견줌 + # 저장 꼴(JSON)로 견줌 · M01 원본이 지워진 줄 = 옛 줄 그대로(낡음 까닭에서 빠짐 · 296) + fresh = json.loads(json.dumps(refresh_book(keys, book), ensure_ascii=False)) rows = book.get("rows") or {} new = sum(key not in rows for key in fresh["rows"]) changed = sum(key in rows and rows[key] != row for key, row in fresh["rows"].items()) diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 786470ddb..10e53f93e 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -4,8 +4,9 @@ 입력(굳힌 단가 · 수요 · B08 수량) + 사용자 값 → 표 전부 PUT /api/projects/{id}/estimation {user} [저장] — 사용자 값 · 책에 없는 새 단가 줄만 굳힘 → 서버가 정본으로 다시 셈 - POST /api/projects/{id}/estimation/prices - [단가 갱신] — 수요 단가 전부를 지금 M01 값으로 새로 굳힘 + POST /api/projects/{id}/estimation/prices {user?} + [단가 갱신] — 수요 단가 전부를 지금 M01 값으로 새로 굳힘 · user = 화면 캐시(296 · + 지운 줄 · 그 사본은 수요에서 뺌 · 저장은 안 함) · M01 원본이 지워진 줄 = 굳힌 값 그대로 POST /api/projects/{id}/estimation/logic-preview {code, given, rate, picks} 「운반 단가」 모달 계산 절차(253-3) — 그 로직 한 산근을 프로젝트 단가로 풀어 글 줄 · 단가 · 저장 안 함(`render_sangeun_text` 를 `Sangeun.Solver` 로) · `blocks` = 칸 나눔(277-2) @@ -138,26 +139,29 @@ def haul_user(rows: list[dict[str, Any]]) -> dict[str, Any]: } -def calc_user(root: Path) -> dict[str, Any]: - """셈 · 수요 · 사본 정리용 사용자 값 — 저장 값 + 거리이정표 운반 연결 · 거리(화면엔 안 보냄) · - 운반 사본 연결 = 지금 거리의 사본(263-2).""" - user = {**store.read_user(root), **haul_user(haul_routes(root))} +def calc_user(root: Path, draft: dict[str, Any] | None = None) -> dict[str, Any]: + """셈 · 수요 · 사본 정리용 사용자 값 — 저장 값(draft = 화면 캐시 · 296) + 거리이정표 운반 연결 · + 거리(화면엔 안 보냄) · 운반 사본 연결 = 지금 거리의 사본(263-2).""" + user = {**(store.read_user(root) if draft is None else draft), **haul_user(haul_routes(root))} return library.resolve_hauls(root, user) -async def _demand(root: Path) -> list[str]: +async def _demand(root: Path, draft: dict[str, Any] | None = None) -> list[str]: """단가 수요 키 — 연결된 사본 + 설계내역서 손 줄 로직(166-2) + - 거리이정표 운반 연결(251-5).""" - user = await asyncio.to_thread(calc_user, root) # 운반 값 사본 먼저(263-2) - keys = await asyncio.to_thread(linked_resource_keys, root) + 거리이정표 운반 연결(251-5) · draft = 화면 캐시(296 · 서고 사본 = 그 줄이 잇는 것만).""" + user = await asyncio.to_thread(calc_user, root, draft) # 운반 값 사본 먼저(263-2) + keep = None if draft is None else library.used_folders(user) + keys = await asyncio.to_thread(linked_resource_keys, root, keep) hand = await asyncio.to_thread(sangeun.hand_resource_keys, user) return [*keys, *(k for k in hand if k not in keys)] -async def payload(project_id: UUID, root: Path) -> dict[str, Any]: +async def payload( + project_id: UUID, root: Path, draft: dict[str, Any] | None = None +) -> dict[str, Any]: notes: list[str] = [] b08 = await _b08(project_id, notes) - keys = await _demand(root) + keys = await _demand(root, draft) if not keys: notes.append( "연결된 구조물 조합 사본 없음 — 노무 · 경비 · 중기 단가 줄은 사본이 연결되면 생김" @@ -213,12 +217,17 @@ async def save_estimation(project_id: UUID, body: dict[str, Any] = Body(...)) -> @router.post("/{project_id}/estimation/prices") -async def refresh_prices(project_id: UUID) -> dict[str, Any]: +async def refresh_prices( + project_id: UUID, body: dict[str, Any] | None = Body(None) +) -> dict[str, Any]: root = await _project_root(project_id) - keys = await _demand(root) - book = await asyncio.to_thread(prices.refresh_book, keys) + raw = (body or {}).get("user") + draft = store.clean_user(raw) if isinstance(raw, dict) else None # 296 화면 캐시(저장 안 함) + keys = await _demand(root, draft) + old = await asyncio.to_thread(store.read_book, root) + book = await asyncio.to_thread(prices.refresh_book, keys, old) await asyncio.to_thread(store.write_book, root, book) - return await payload(project_id, root) + return await payload(project_id, root, draft) class PreviewBody(BaseModel): diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 725235b36..1b331e139 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -312,7 +312,14 @@ export function renderB09Estimation(root: HTMLElement): void { show(current); }; - const run = async (path: string, method: string, done: string, init?: Row): Promise => { + // keep = 화면 캐시 그대로(296 [업데이트] — 지운 줄 · 고친 칸을 안 되살림) + const run = async ( + path: string, + method: string, + done: string, + init?: Row, + keep = false, + ): Promise => { if (!projectId) return; showLoadingOverlay(); try { @@ -320,8 +327,8 @@ export function renderB09Estimation(root: HTMLElement): void { method, ...(init ? { body: JSON.stringify(init) } : {}), }); - clearState("b09-estimation", projectId); - apply(next, true); + if (!keep) clearState("b09-estimation", projectId); + apply(next, !keep); showToast(done, "success"); } catch (error) { showToast(`하지 못했습니다 — ${String(error)}`, "error"); @@ -340,7 +347,13 @@ export function renderB09Estimation(root: HTMLElement): void { label: "업데이트", variant: "ghost", onClick: () => - void run("/estimation/prices", "POST", "단가를 지금 M01 값으로 새로 굳혔습니다."), + void run( + "/estimation/prices", + "POST", + "단가를 지금 M01 값으로 새로 굳혔습니다.", + { user }, + true, + ), }); refresh.dataset.b09Refresh = ""; const check = createCheckPanel(refresh, (link) => show(link.tab, link.key)); diff --git a/resources/tester/test_296_b09_update_skip_deleted.py b/resources/tester/test_296_b09_update_skip_deleted.py new file mode 100644 index 000000000..2809d83e2 --- /dev/null +++ b/resources/tester/test_296_b09_update_skip_deleted.py @@ -0,0 +1,90 @@ +"""296 B09 [업데이트] — 지워진 복사 정보는 업데이트에서 뺌(요구 190-4). + +- ① B09 에서 지운 복사 항목 = 화면 캐시(user)를 같이 보냄 → 그 줄이 안 잇는 서고 사본 = + 수요에서 뺌 · 책에 안 살아남 · 캐시 그대로(지운 줄 안 되살림) · 저장은 안 함 +- ② M01 원본이 지워진 키 = 옛 책 줄 그대로(지우지도 바꾸지도 않음) · 낡음 까닭에서 빠짐 +- 나머지 = 지금처럼 M01 값으로 새로 굳힘 · user 안 보내면 옛 길 그대로 +임시 층(견본 마스터 · 임시 storage) · 실제 마스터 · 프로젝트는 안 씀. +""" + +import asyncio +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B09_Estimation import B09_Estimation_Bill_Library as library # noqa: E402 +from B09_Estimation import B09_Estimation_Prices as prices # noqa: E402 +from B09_Estimation import B09_Estimation_Router as router # noqa: E402 +from B09_Estimation import B09_Estimation_Store as store_b09 # noqa: E402 +from B09_Estimation.B09_Estimation_Linked import linked_resource_keys # noqa: E402 +from resources.tester.test_204_2_b09_bill_library import COMBO, world # noqa: E402, F401 + +GONE = "LB999999" # M01 에 없는 키(원본 지워짐) + + +def _json(book): + return json.loads(json.dumps(book, ensure_ascii=False)) + + +def _refresh(monkeypatch, root, body): + seen = {} + + async def fake_root(_): + return root + + async def fake_payload(_, at, draft=None): + seen["draft"] = draft + return {} + + monkeypatch.setattr(router, "_project_root", fake_root) + monkeypatch.setattr(router, "payload", fake_payload) + asyncio.run(router.refresh_prices("00000000-0000-0000-0000-000000000001", body)) + return store_b09.read_book(root), seen["draft"] + + +def test_지운_복사_항목_업데이트_뒤_안_살아남(world, monkeypatch): # noqa: F811 + root = world["root"] + a = library.import_copy(root, "precombo", "system", None, world["key"])["폴더"] + b = library.import_copy(root, "combo", "system", None, COMBO)["폴더"] + keep_a = set(prices.refresh_book(linked_resource_keys(root, {a}))["rows"]) + every = set(prices.refresh_book(linked_resource_keys(root))["rows"]) + assert keep_a < every # b 사본만 부르는 줄이 있음 + # 화면 캐시 = b 줄을 지움(아직 [저장] 안 함 · 사본 폴더는 남음) + ref = {"tab": "dg", "key": "DG:x", "lib": a} + user = {"bill": {"v": 2, "rows": [{"key": "hand:1", "name": "줄", "qty": 1, "ref": ref}]}} + book, draft = _refresh(monkeypatch, root, {"user": user}) + assert set(book["rows"]) == keep_a # 지운 사본 단가 줄 안 살아남 + assert draft["bill"]["rows"][0]["ref"]["lib"] == a # 응답 셈도 캐시로 + assert not (store_b09.folder(root) / store_b09.USER).exists() # 저장 안 함 + assert {f.name for f, _ in library.copies(root)} == {a, b} # 사본 정리는 [저장] 몫 + assert prices.book_stale(_json(book), linked_resource_keys(root, {a})) is None + # user 안 보냄 = 옛 길(다 쓴 사본 전부) + book, draft = _refresh(monkeypatch, root, None) + assert set(book["rows"]) == every and draft is None + + +def test_M01_원본_없는_항목_값_그대로_낡음_까닭_빠짐(): + keys = ["EQ000004", GONE] # 불도저 · 조종원 · 경유(test_144 견본) + 원본 없는 키 + old = _json(prices.refresh_book(["EQ000004"])) + kept = {"key": GONE, "code": "9999", "name": "지워진 직종", "price": 123.0} + old["rows"][GONE] = kept + old["rows"]["LB000048"] = {**old["rows"]["LB000048"], "price": 1} # M01 에서 바뀐 단가 + assert prices.book_stale(old, keys) == "M01 에서 바뀐 단가 1" # 원본 없는 줄 = 까닭에 없음 + new = _json(prices.refresh_book(keys, old)) + assert new["rows"][GONE] == kept # 지우지도 바꾸지도 않음 + assert new["rows"]["LB000048"]["price"] not in (None, 1) # 나머지 = 새로 굳힘 + assert set(new["rows"]) == set(old["rows"]) + assert prices.book_stale(new, keys) is None + assert GONE not in prices.refresh_book(keys)["rows"] # 옛 책 없으면 줄 없음(옛 길) + + +def test_화면_업데이트_캐시_보냄_그대로_둠(): + shell = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8") + at = shell.index('"/estimation/prices",') + assert "{ user },\n true," in shell[at : at + 200] + assert 'if (!keep) clearState("b09-estimation", projectId);' in shell + assert "apply(next, !keep);" in shell