From 40b974c0c01885e728764c894fd7e8474d6e1365 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 13:46:28 +0900 Subject: [PATCH] =?UTF-8?q?feat(B09=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=A0=90=EA=B2=80):=20[=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8]?= =?UTF-8?q?=20=ED=95=9C=20=EA=B3=B3=20=E2=80=94=20=EB=8B=A8=EA=B0=80=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EA=B5=B3=ED=9E=98=20+=20=EC=9B=90?= =?UTF-8?q?=EB=B3=B8=20=EB=B0=94=EB=80=90=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EC=82=AC=EB=B3=B8=20=EC=9E=AC=EC=A0=81=EC=9A=A9=20=C2=B7=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=20=EB=B3=B4=EA=B8=B0=20=EC=B0=BD=20(PLAN=207?= =?UTF-8?q?03-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST …/estimation/prices = 연결된 구조물 사본 중 원본이 바뀐 것만 재적용(B09_Estimation_Refresh · 막힌 사본 = 알림 · 나머지 함) → 수요 다시 → 단가 책 다시 굳힘 · 덮개 그대로 · 답 notes 첫 줄 「단가 줄 N 바뀜 · 사본 K · 고친 칸 M 그대로」 - POST …/estimation/prices/preview = 바뀌는 단가 줄(새 · 값 바뀜) · 다시 가져올 사본 · 지킬 고친 칸 · 노임 판 전 → 후(쓰지 않음) - 단가 책에 노임 판(labor_edition · 구분마다 M01 인력 판) · 책 견줌 book_diff(book_stale 도 같이 씀) - UI_Check = [업데이트] 누름을 먼저 잡아 미리 보기 창 → [적용] 이면 원래 단추 일 · 호버 글 - 시험 test_703_3_b09_refresh(미리 보기 수 = 적용 수 · 판 · 재적용 원본 바뀐 것만 · 덮개 그대로) Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Prices.py | 38 ++++++++--- B09_Estimation/B09_Estimation_Refresh.py | 72 ++++++++++++++++++++ B09_Estimation/B09_Estimation_Router.py | 45 ++++++++++-- B09_Estimation/B09_Estimation_UI_Check.ts | 69 ++++++++++++++++++- resources/tester/test_703_3_b09_refresh.py | 79 ++++++++++++++++++++++ 5 files changed, 289 insertions(+), 14 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_Refresh.py create mode 100644 resources/tester/test_703_3_b09_refresh.py diff --git a/B09_Estimation/B09_Estimation_Prices.py b/B09_Estimation/B09_Estimation_Prices.py index 8a13f11d3..5fcdf94e7 100644 --- a/B09_Estimation/B09_Estimation_Prices.py +++ b/B09_Estimation/B09_Estimation_Prices.py @@ -198,6 +198,31 @@ def refresh_book(keys: list[str], book: dict[str, Any] | None = None) -> dict[st "rows": rows, "driver_factor": driver_factor(master), "fx": fx_inputs(master), + "labor_edition": labor_editions(keys), # 703-3 미리 보기 「노임 판 전 → 후」 + } + + +def labor_editions(keys: list[str]) -> dict[str, str]: + """노임 판(703-3) — 수요 노무 줄 구분(건설업 · 산림 …)마다 M01 `인력.json` 그 구분 `판`.""" + files, master = m01.loaded() + groups = (files.get("인력.json") or {}).get("구분") or {} + out: dict[str, str] = {} + for key in demand(master, keys)["labor"]: + kind = str((_row(master, key) or {}).get("구분") or "") + if (groups.get(kind) or {}).get("판"): + out[kind] = str(groups[kind]["판"]) + return out + + +def book_diff(old: dict[str, Any] | None, new: dict[str, Any]) -> dict[str, int | bool]: + """두 책 견줌(저장 꼴 JSON) — 새 줄 · 바뀐 줄 수 · 운전사 계수 · 환율 바뀜(203-1 · 703-3).""" + new = json.loads(json.dumps(new, ensure_ascii=False)) + rows = (old or {}).get("rows") or {} + return { + "new": sum(key not in rows for key in new["rows"]), + "changed": sum(key in rows and rows[key] != row for key, row in new["rows"].items()), + "factor": (old or {}).get("driver_factor") != new["driver_factor"], + "fx": (old or {}).get("fx") != new["fx"], } @@ -206,15 +231,12 @@ def book_stale(book: dict[str, Any] | None, keys: list[str]) -> str | None: if book is None: return "단가 아직 안 굳힘" # 저장 꼴(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 = book_diff(book, refresh_book(keys, book)) parts = [ - f"새 단가 줄 {new}" if new else "", - f"M01 에서 바뀐 단가 {changed}" if changed else "", - "운전사 계수 바뀜" if book.get("driver_factor") != fresh["driver_factor"] else "", - "환율 바뀜" if book.get("fx") != fresh["fx"] else "", + f"새 단가 줄 {diff['new']}" if diff["new"] else "", + f"M01 에서 바뀐 단가 {diff['changed']}" if diff["changed"] else "", + "운전사 계수 바뀜" if diff["factor"] else "", + "환율 바뀜" if diff["fx"] else "", ] return " · ".join(p for p in parts if p) or None diff --git a/B09_Estimation/B09_Estimation_Refresh.py b/B09_Estimation/B09_Estimation_Refresh.py new file mode 100644 index 000000000..c29a75999 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Refresh.py @@ -0,0 +1,72 @@ +"""B09 [업데이트] 한 곳(PLAN 703-3) — 단가 책 다시 굳힘 + 원본이 바뀐 구조물 사본 [재적용]을 함께. + +미리 보기(`preview`) = 바뀌는 단가 줄(새 줄 + 바뀐 줄 · 지금 수요) · 다시 가져올 구조물 사본 + (연결된 것 중 원본이 바뀐 것만 · 원본 없음 = 그대로) · 지킬 고친 칸(덮개 칸 수 · 702-4 — + 재적용 · 새 판 뒤에도 남음) · 노임 판(굳힌 판 → 지금 M01 판 · 옛 책 = 굳힌 날) +적용(`reapply_changed` → 부르는 쪽이 수요 다시 → `prices.refresh_book`) — 사본 먼저(수요가 바뀜) · + 덮개(사용자 값)는 안 건드림 · 사본 하나가 막혀도 나머지는 함(막힌 것 = 알림). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from B09_Estimation import B09_Estimation_Prices as prices +from B09_Estimation.B09_Estimation_Calc_Labor import COPY +from M02_MasterTemplete import M02_Item_Copy as item_copy +from M02_MasterTemplete.M02_Item_Snapshot import linked_snapshots + + +def changed_copies(root: str | Path) -> list[dict[str, Any]]: + """연결된 구조물 사본 중 원본(층 · 마스터)이 바뀐 것 `[{폴더, 이름}]`.""" + linked = {one["폴더"] for one in linked_snapshots(root).values()} + return [ + {"폴더": it["폴더"], "이름": it["이름"] or it["폴더"]} + for it in item_copy.list_items(root) + if it["폴더"] in linked and it["마스터바뀜"] + ] + + +def cover_cells(cover: dict[str, Any] | None) -> int: + """고친 칸 수 — 덮개 칸(품 · 수량 · 단가) · 복사본 표지 뺌.""" + return sum( + len(cell) for lines in (cover or {}).values() for cid, cell in lines.items() if cid != COPY + ) + + +def edition_text(editions: dict[str, str] | None) -> str: + return " · ".join(f"{kind} {day[:7]}" for kind, day in sorted((editions or {}).items())) + + +def preview( + root: str | Path, book: dict[str, Any] | None, keys: list[str], cover: dict | None +) -> dict[str, Any]: + diff = prices.book_diff(book, prices.refresh_book(keys, book)) + before = (book or {}).get("labor_edition") + made = str((book or {}).get("made") or "")[:10] + return { + "단가줄": diff["new"] + diff["changed"], + "새줄": diff["new"], + "바뀐줄": diff["changed"], + "계수": diff["factor"], + "환율": diff["fx"], + "사본": changed_copies(root), + "고친칸": cover_cells(cover), + "노임판": { + "전": edition_text(before) if before else (f"굳힌 날 {made}" if made else "안 굳힘"), + "후": edition_text(prices.labor_editions(keys)), + }, + } + + +def reapply_changed(root: str | Path, who: dict[str, Any] | None = None) -> dict[str, list]: + """원본이 바뀐 연결 사본 재적용 — `{다시: [이름], 막힘: [이름 — 까닭]}`.""" + done, stuck = [], [] + for one in changed_copies(root): + try: + item_copy.reapply(root, one["폴더"], who) + done.append(one["이름"]) + except (LookupError, ValueError, KeyError, OSError) as error: + stuck.append(f"{one['이름']} — {error}") + return {"다시": done, "막힘": stuck} diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index d11c78572..09d09379b 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -11,8 +11,13 @@ 원가 · 공기) · 단가 책 · `estimation` 설정 구획 지움 · 서고 사본 정리 · 견적서 파일 남김 → 답 = GET 꼴(기준 = 지금 B08 저장본 + 지금 M01 단가) · 단계 = [저장]과 같음 POST /api/projects/{id}/estimation/prices {user?} - [단가 갱신] — 수요 단가 전부를 지금 M01 값으로 새로 굳힘 · user = 화면 캐시(296 · - 지운 줄 · 그 사본은 수요에서 뺌 · 저장은 안 함) · M01 원본이 지워진 줄 = 굳힌 값 그대로 + [업데이트] — 원본이 바뀐 구조물 사본 재적용(703-3 · `B09_Estimation_Refresh`) → + 수요 단가 전부를 지금 M01 값으로 새로 굳힘 · user = 화면 캐시(296 · 지운 줄 · 그 사본은 + 수요에서 뺌 · 저장은 안 함) · M01 원본이 지워진 줄 = 굳힌 값 그대로 · 덮개 그대로 · + 답 notes 첫 줄 = 바뀐 수 + POST /api/projects/{id}/estimation/prices/preview {user?} + [업데이트] 미리 보기(703-3) — 바뀌는 단가 줄 · 다시 가져올 사본 · 지킬 고친 칸 · + 노임 판(쓰지 않음) POST /api/projects/{id}/estimation/logic-preview {code, given, rate, picks} 「운반 단가」 모달 계산 절차(253-3) — 그 로직 한 산근을 프로젝트 단가로 풀어 글 줄 · 단가 · 저장 안 함(`render_sangeun_text` 를 `Sangeun.Solver` 로) · `blocks` = 칸 나눔(277-2) @@ -26,7 +31,8 @@ 「운반 단가」 모달(263-2) — 담은 로직 줄 입력 · 값(읽기만 · 권한 = library 길과 같음) POST /api/projects/{id}/estimation/daega/personal {cover, name?, overwrite?, user?} 「내 자료집에 저장」(703-2) — 펼친 일위대가 → 개인 층 사전조합 - (`B09_Estimation_Daega_Personal`) · user = 화면 캐시 덮개 · 같은 이름 = 409 {같은이름} → 덮어쓰기(overwrite = 키) · 새 이름 + (`B09_Estimation_Daega_Personal`) · user = 화면 캐시 덮개 · + 같은 이름 = 409 {같은이름} → 덮어쓰기(overwrite = 키) · 새 이름 운반 사본 연결 = 셈 때마다 거리로 사본 다시 고름(`Bill_Library.resolve_hauls` · `fix_keys`). 저장 자리 = 프로젝트 안 `B09_Estimation/v1/`(`B09_Estimation_Store`) · @@ -58,6 +64,7 @@ from B08_Quantity.B08_Quantity_Router_Milestone import site_info from B09_Estimation import B09_Estimation_Bill_Library as library from B09_Estimation import B09_Estimation_Daega_Personal as personal from B09_Estimation import B09_Estimation_Prices as prices +from B09_Estimation import B09_Estimation_Refresh as refresh from B09_Estimation import B09_Estimation_Sangeun as sangeun from B09_Estimation import B09_Estimation_Store as store from B09_Estimation.B09_Estimation_Bill_Items import bill_items, hopyo_prices @@ -265,16 +272,44 @@ async def estimation_reset(project_id: UUID) -> dict[str, Any]: @router.post("/{project_id}/estimation/prices") async def refresh_prices( - project_id: UUID, body: dict[str, Any] | None = Body(None) + project_id: UUID, + body: dict[str, Any] | None = Body(None), + session: dict[str, Any] = Depends(verify_session), ) -> dict[str, Any]: + """[업데이트] 한 곳(703-3) — 원본이 바뀐 구조물 사본 재적용 → 단가 책 다시 굳힘 · + 덮개 그대로.""" root = await _project_root(project_id) raw = (body or {}).get("user") draft = store.clean_user(raw) if isinstance(raw, dict) else None # 296 화면 캐시(저장 안 함) + again = await asyncio.to_thread(refresh.reapply_changed, root, importer(session)) 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, draft) + out = await payload(project_id, root, draft) + diff = prices.book_diff(old, book) + cells = refresh.cover_cells((draft or out.get("user") or {}).get("daega_cover")) + out["notes"] = [ + f"업데이트 — 단가 줄 {diff['new'] + diff['changed']} 바뀜 · 구조물 사본 " + f"{len(again['다시'])} 다시 가져옴 · 고친 칸 {cells} 그대로", + *(f"재적용 못 함 {why}" for why in again["막힘"]), + *(out.get("notes") or []), + ] + return out + + +@router.post("/{project_id}/estimation/prices/preview") +async def refresh_preview( + project_id: UUID, body: dict[str, Any] | None = Body(None) +) -> dict[str, Any]: + """[업데이트] 미리 보기(703-3) — 바뀌는 단가 줄 · 다시 가져올 사본 · 지킬 고친 칸 · 노임 판.""" + root = await _project_root(project_id) + raw = (body or {}).get("user") + draft = store.clean_user(raw) if isinstance(raw, dict) else None + keys = await _demand(root, draft) + book = await asyncio.to_thread(store.read_book, root) + user = draft if draft is not None else await asyncio.to_thread(store.read_user, root) + return await asyncio.to_thread(refresh.preview, root, book, keys, user.get("daega_cover")) class PreviewBody(BaseModel): diff --git a/B09_Estimation/B09_Estimation_UI_Check.ts b/B09_Estimation/B09_Estimation_UI_Check.ts index 07ce77bb7..19d56c11f 100644 --- a/B09_Estimation/B09_Estimation_UI_Check.ts +++ b/B09_Estimation/B09_Estimation_UI_Check.ts @@ -4,15 +4,23 @@ * * [업데이트] = 단가를 지금 M01 값으로 새로 굳힘 · 서버가 낡았다고 보면(`payload.stale` 까닭 한 줄) * 단추에 점 배지(`is-pending`) · 굳힌 시각 · 까닭 = 공용 호버 + * 미리 보기 고리(703-3) = [업데이트] 누름을 먼저 잡아 POST …/estimation/prices/preview(화면 캐시) → + * 「바뀌는 단가 줄 N · 다시 가져올 구조물 사본 K · 고친 칸 M 지킴 · 노임 판 전 → 후」 창 → [적용] = + * 원래 단추 일(서버가 사본 재적용 + 단가 다시 굳힘 · 덮개 그대로) · 닫기 = 안 함 * 연결 점검 = 「빈 줄 n」(설계내역서 금액 모르는 줄 · 안내 · 집계표와 같은 수 · 701-2) 목록 + * 그 밖 확인 목록(일위대가 · 단가산출 · 재료 등 · 탭 · 키마다 하나)(누르면 그 탭 그 줄로 · 까닭 = 공용 호버) + 서버 알림 · M01 에 없는 키 * ========================================================================== */ +import { API_BASE_URL } from "@config/config_frontend"; +import { createButton, showToast } from "@ui/ui_template_elements"; import { attachHoverInfo } from "@ui/ui_template_hover_info"; +import { openModal } from "@ui/ui_template_modal"; import { createBadge } from "@ui/ui_template_badge"; import { createText } from "@ui/ui_template_text"; import type { Row } from "./B09_Estimation_Calc_Base"; import { dateTimeText } from "../common_util/common_util_time"; +import { readState } from "../A00_Common/b_page_state"; +import { currentProjectId } from "../A00_Common/b_project_switch"; import { blankRows, tabName, type Link, type TabId } from "./B09_Estimation_UI_Tabs"; export interface CheckItem { @@ -59,7 +67,65 @@ export function refreshTip(payload: Row | null): string { const made = payload?.book?.made; const when = made ? `단가 굳힘 ${dateTimeText(String(made))}` : "단가 아직 안 굳힘"; const stale = payload?.stale; - return `${when} · 누르면 지금 M01 값으로 새로 굳힘${stale ? ` · 업데이트 필요 — ${stale}` : ""}`; + return `${when} · 누르면 미리 보기 → [적용] = 지금 M01 값으로 새로 굳힘 · 원본 바뀐 구조물 사본 재적용 · 고친 칸 그대로${stale ? ` · 업데이트 필요 — ${stale}` : ""}`; +} + +/** [업데이트] 미리 보기 글 줄(703-3) */ +export function previewLines(got: Row): string[] { + const copies: Row[] = got.사본 || []; + const edition = got.노임판 || {}; + return [ + `바뀌는 단가 줄 ${got.단가줄 ?? 0}(새 줄 ${got.새줄 ?? 0} · 값 바뀜 ${got.바뀐줄 ?? 0})` + + `${got.계수 ? " · 운전사 계수 바뀜" : ""}${got.환율 ? " · 환율 바뀜" : ""}`, + `다시 가져올 구조물 사본 ${copies.length}${copies.length ? ` — ${copies.map((c) => c.이름).join(" · ")}` : ""}`, + `고친 칸 ${got.고친칸 ?? 0} 지킴(일위대가 덮개 · 새 판 · 재적용 뒤에도 그대로)`, + `노임 판 ${edition.전 || "—"} → ${edition.후 || "—"}`, + ]; +} + +/** [업데이트] 누름 앞 미리 보기 — [적용] 이면 원래 단추 일 · 닫기 = 안 함 */ +function attachPreview(refresh: HTMLElement, payload: () => Row | null): void { + let pass = false; + refresh.addEventListener( + "click", + (event) => { + if (pass) return void (pass = false); + event.stopImmediatePropagation(); + const projectId = currentProjectId(); + const user = readState("b09-estimation", projectId) ?? payload()?.user ?? {}; + void fetch(`${API_BASE_URL}/projects/${projectId}/estimation/prices/preview`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ user }), + }) + .then(async (response) => { + const got = (await response.json()) as Row; + if (!response.ok) throw new Error(String(got?.detail ?? response.status)); + openModal({ + title: "업데이트 미리 보기", + closeLabel: "닫기", + width: "40rem", + mount: (body, close, head) => { + const apply = createButton({ + label: "적용", + variant: "filled", + onClick: () => { + close(); + pass = true; + refresh.click(); + }, + }); + apply.dataset.b09RefreshApply = ""; + head.append(apply); + body.append(...previewLines(got).map((line) => createText(line))); + }, + }); + }) + .catch((error) => showToast(`미리 보기 못 함 — ${String(error)}`, "error")); + }, + true, + ); } export interface CheckPanel { @@ -72,6 +138,7 @@ export function createCheckPanel(refresh: HTMLElement, goto: (link: Link) => voi let payload: Row | null = null; let items: CheckItem[] = []; attachHoverInfo(refresh, () => refreshTip(payload)); + attachPreview(refresh, () => payload); const box = document.createElement("div"); box.className = "b09-check"; attachHoverInfo(box, (row) => items[Number(row.dataset.i)]?.why, "[data-i]"); diff --git a/resources/tester/test_703_3_b09_refresh.py b/resources/tester/test_703_3_b09_refresh.py new file mode 100644 index 000000000..cd6bb5bc9 --- /dev/null +++ b/resources/tester/test_703_3_b09_refresh.py @@ -0,0 +1,79 @@ +"""703-3 B09 [업데이트] 한 곳 — 단가 책 다시 굳힘 + 원본이 바뀐 구조물 사본 재적용. + +- 미리 보기 수(`Refresh.preview`) = 적용 뒤 책 견줌(`Prices.book_diff`) 수 · 노임 판 전 → 후 · 고친 칸 수 +- 재적용 = 연결된 사본 중 원본이 바뀐 것만 · 하나가 막혀도 나머지 · 덮개(사용자 값) 안 건드림 +""" + +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_Prices as prices # noqa: E402 +from B09_Estimation import B09_Estimation_Refresh as refresh # noqa: E402 +from B09_Estimation import B09_Estimation_Store as store # noqa: E402 + +KEYS = ["LB000001", "LB000033"] # 작업반장(건설업) · 석공 + + +def test_책_견줌_미리보기_수_적용_수_같음(tmp_path, monkeypatch): + monkeypatch.setattr(refresh, "changed_copies", lambda root: []) + new = prices.refresh_book(KEYS) + assert new["labor_edition"] == prices.labor_editions(KEYS) and new["labor_edition"] + old = json.loads(json.dumps(new)) + old["rows"]["LB000001"] = {**old["rows"]["LB000001"], "price": 1} # 옛 판 값 + del old["rows"]["LB000033"] # 새 줄 + old["labor_edition"] = {"건설업": "2025-09-01"} + cover = { + "S:a": {"x#0": {"qty": 1, "labor": 2}}, + "K:b": {"@copy": {"from": "S:a"}, "y#0": {"qty": 3}}, + } + got = refresh.preview(tmp_path, old, KEYS, cover) + after = prices.refresh_book(KEYS, old) + diff = prices.book_diff(old, after) + assert (got["새줄"], got["바뀐줄"]) == (diff["new"], diff["changed"]) == (1, 1) + assert got["단가줄"] == 2 and got["고친칸"] == 3 and got["사본"] == [] + assert got["노임판"]["전"] == "건설업 2025-09" + assert got["노임판"]["후"].startswith("건설업 2026") + old.pop("labor_edition") + assert refresh.preview(tmp_path, old, KEYS, None)["노임판"]["전"].startswith("굳힌 날 ") + assert prices.book_diff(after, prices.refresh_book(KEYS, after)) == { + "new": 0, + "changed": 0, + "factor": False, + "fx": False, + } + assert prices.book_stale(after, KEYS) is None + + +def test_재적용_원본_바뀐_사본만_막혀도_나머지_덮개_그대로(tmp_path, monkeypatch): + cover = {"S:a": {"x#0": {"qty": 0.3}}} + store.write_user(tmp_path, {"daega_cover": cover}) + monkeypatch.setattr( + refresh, + "linked_snapshots", + lambda root: {"p1": {"폴더": "a"}, "p2": {"폴더": "b"}, "p3": {"폴더": "c"}}, + ) + items = [ + {"폴더": "a", "이름": "가", "마스터바뀜": True}, + {"폴더": "b", "이름": "나", "마스터바뀜": False}, # 원본 그대로 = 안 함 + {"폴더": "c", "이름": "다", "마스터바뀜": True}, + {"폴더": "z", "이름": "끊김", "마스터바뀜": True}, # 연결 안 됨 = 안 함 + ] + monkeypatch.setattr(refresh.item_copy, "list_items", lambda root: items) + calls = [] + + def fake(root, folder, who=None): + calls.append(folder) + if folder == "c": + raise LookupError("원본 층 없음") + + monkeypatch.setattr(refresh.item_copy, "reapply", fake) + assert [c["이름"] for c in refresh.changed_copies(tmp_path)] == ["가", "다"] + got = refresh.reapply_changed(tmp_path) + assert calls == ["a", "c"] + assert got == {"다시": ["가"], "막힘": ["다 — 원본 층 없음"]} + assert store.read_user(tmp_path)["daega_cover"] == cover