feat(B09 데이터 점검): [업데이트] 한 곳 — 단가 다시 굳힘 + 원본 바뀐 구조물 사본 재적용 · 미리 보기 창 (PLAN 703-3)

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 13:46:28 +09:00
co-authored by Claude Opus 5.5
parent 1c1dc9c523
commit 40b974c0c0
5 changed files with 289 additions and 14 deletions
+30 -8
View File
@@ -198,6 +198,31 @@ def refresh_book(keys: list[str], book: dict[str, Any] | None = None) -> dict[st
"rows": rows, "rows": rows,
"driver_factor": driver_factor(master), "driver_factor": driver_factor(master),
"fx": fx_inputs(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: if book is None:
return "단가 아직 안 굳힘" return "단가 아직 안 굳힘"
# 저장 꼴(JSON)로 견줌 · M01 원본이 지워진 줄 = 옛 줄 그대로(낡음 까닭에서 빠짐 · 296) # 저장 꼴(JSON)로 견줌 · M01 원본이 지워진 줄 = 옛 줄 그대로(낡음 까닭에서 빠짐 · 296)
fresh = json.loads(json.dumps(refresh_book(keys, book), ensure_ascii=False)) diff = book_diff(book, refresh_book(keys, book))
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())
parts = [ parts = [
f"새 단가 줄 {new}" if new else "", f"새 단가 줄 {diff['new']}" if diff["new"] else "",
f"M01 에서 바뀐 단가 {changed}" if changed else "", f"M01 에서 바뀐 단가 {diff['changed']}" if diff["changed"] else "",
"운전사 계수 바뀜" if book.get("driver_factor") != fresh["driver_factor"] else "", "운전사 계수 바뀜" if diff["factor"] else "",
"환율 바뀜" if book.get("fx") != fresh["fx"] else "", "환율 바뀜" if diff["fx"] else "",
] ]
return " · ".join(p for p in parts if p) or None return " · ".join(p for p in parts if p) or None
+72
View File
@@ -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}
+40 -5
View File
@@ -11,8 +11,13 @@
원가 · 공기) · 단가 책 · `estimation` 설정 구획 지움 · 서고 사본 정리 · 견적서 파일 남김 → 원가 · 공기) · 단가 책 · `estimation` 설정 구획 지움 · 서고 사본 정리 · 견적서 파일 남김 →
답 = GET 꼴(기준 = 지금 B08 저장본 + 지금 M01 단가) · 단계 = [저장]과 같음 답 = GET 꼴(기준 = 지금 B08 저장본 + 지금 M01 단가) · 단계 = [저장]과 같음
POST /api/projects/{id}/estimation/prices {user?} POST /api/projects/{id}/estimation/prices {user?}
[단가 갱신] — 수요 단가 전부를 지금 M01 값으로 새로 굳힘 · user = 화면 캐시(296 · [업데이트] — 원본이 바뀐 구조물 사본 재적용(703-3 · `B09_Estimation_Refresh`) →
지운 줄 · 그 사본은 수요에서 뺌 · 저장은 안 함) · M01 원본이 지워진 줄 = 굳힌 값 그대로 수요 단가 전부를 지금 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} POST /api/projects/{id}/estimation/logic-preview {code, given, rate, picks}
「운반 단가」 모달 계산 절차(253-3) — 그 로직 한 산근을 프로젝트 단가로 풀어 글 줄 · 단가 · 「운반 단가」 모달 계산 절차(253-3) — 그 로직 한 산근을 프로젝트 단가로 풀어 글 줄 · 단가 ·
저장 안 함(`render_sangeun_text` 를 `Sangeun.Solver` 로) · `blocks` = 칸 나눔(277-2) 저장 안 함(`render_sangeun_text` 를 `Sangeun.Solver` 로) · `blocks` = 칸 나눔(277-2)
@@ -26,7 +31,8 @@
「운반 단가」 모달(263-2) — 담은 로직 줄 입력 · 값(읽기만 · 권한 = library 길과 같음) 「운반 단가」 모달(263-2) — 담은 로직 줄 입력 · 값(읽기만 · 권한 = library 길과 같음)
POST /api/projects/{id}/estimation/daega/personal {cover, name?, overwrite?, user?} POST /api/projects/{id}/estimation/daega/personal {cover, name?, overwrite?, user?}
「내 자료집에 저장」(703-2) — 펼친 일위대가 → 개인 층 사전조합 「내 자료집에 저장」(703-2) — 펼친 일위대가 → 개인 층 사전조합
(`B09_Estimation_Daega_Personal`) · user = 화면 캐시 덮개 · 같은 이름 = 409 {같은이름} → 덮어쓰기(overwrite = 키) · 새 이름 (`B09_Estimation_Daega_Personal`) · user = 화면 캐시 덮개 ·
같은 이름 = 409 {같은이름} → 덮어쓰기(overwrite = 키) · 새 이름
운반 사본 연결 = 셈 때마다 거리로 사본 다시 고름(`Bill_Library.resolve_hauls` · `fix_keys`). 운반 사본 연결 = 셈 때마다 거리로 사본 다시 고름(`Bill_Library.resolve_hauls` · `fix_keys`).
저장 자리 = 프로젝트 안 `B09_Estimation/v1/`(`B09_Estimation_Store`) · 저장 자리 = 프로젝트 안 `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_Bill_Library as library
from B09_Estimation import B09_Estimation_Daega_Personal as personal 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_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_Sangeun as sangeun
from B09_Estimation import B09_Estimation_Store as store from B09_Estimation import B09_Estimation_Store as store
from B09_Estimation.B09_Estimation_Bill_Items import bill_items, hopyo_prices 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") @router.post("/{project_id}/estimation/prices")
async def refresh_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]: ) -> dict[str, Any]:
"""[업데이트] 한 곳(703-3) — 원본이 바뀐 구조물 사본 재적용 → 단가 책 다시 굳힘 ·
덮개 그대로."""
root = await _project_root(project_id) root = await _project_root(project_id)
raw = (body or {}).get("user") raw = (body or {}).get("user")
draft = store.clean_user(raw) if isinstance(raw, dict) else None # 296 화면 캐시(저장 안 함) 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) keys = await _demand(root, draft)
old = await asyncio.to_thread(store.read_book, root) old = await asyncio.to_thread(store.read_book, root)
book = await asyncio.to_thread(prices.refresh_book, keys, old) book = await asyncio.to_thread(prices.refresh_book, keys, old)
await asyncio.to_thread(store.write_book, root, book) 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): class PreviewBody(BaseModel):
+68 -1
View File
@@ -4,15 +4,23 @@
* *
* [업데이트] = 단가를 지금 M01 값으로 새로 굳힘 · 서버가 낡았다고 보면(`payload.stale` 까닭 한 줄) * [업데이트] = 단가를 지금 M01 값으로 새로 굳힘 · 서버가 낡았다고 보면(`payload.stale` 까닭 한 줄)
* 단추에 점 배지(`is-pending`) · 굳힌 시각 · 까닭 = 공용 호버 * 단추에 점 배지(`is-pending`) · 굳힌 시각 · 까닭 = 공용 호버
* 미리 보기 고리(703-3) = [업데이트] 누름을 먼저 잡아 POST …/estimation/prices/preview(화면 캐시) →
* 「바뀌는 단가 줄 N · 다시 가져올 구조물 사본 K · 고친 칸 M 지킴 · 노임 판 전 → 후」 창 → [적용] =
* 원래 단추 일(서버가 사본 재적용 + 단가 다시 굳힘 · 덮개 그대로) · 닫기 = 안 함
* 연결 점검 = 「빈 줄 n」(설계내역서 금액 모르는 줄 · 안내 · 집계표와 같은 수 · 701-2) 목록 + * 연결 점검 = 「빈 줄 n」(설계내역서 금액 모르는 줄 · 안내 · 집계표와 같은 수 · 701-2) 목록 +
* 그 밖 확인 목록(일위대가 · 단가산출 · 재료 등 · 탭 · 키마다 하나)(누르면 그 탭 그 줄로 · 까닭 = 공용 호버) + 서버 알림 · M01 에 없는 키 * 그 밖 확인 목록(일위대가 · 단가산출 · 재료 등 · 탭 · 키마다 하나)(누르면 그 탭 그 줄로 · 까닭 = 공용 호버) + 서버 알림 · 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 { attachHoverInfo } from "@ui/ui_template_hover_info";
import { openModal } from "@ui/ui_template_modal";
import { createBadge } from "@ui/ui_template_badge"; import { createBadge } from "@ui/ui_template_badge";
import { createText } from "@ui/ui_template_text"; import { createText } from "@ui/ui_template_text";
import type { Row } from "./B09_Estimation_Calc_Base"; import type { Row } from "./B09_Estimation_Calc_Base";
import { dateTimeText } from "../common_util/common_util_time"; 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"; import { blankRows, tabName, type Link, type TabId } from "./B09_Estimation_UI_Tabs";
export interface CheckItem { export interface CheckItem {
@@ -59,7 +67,65 @@ export function refreshTip(payload: Row | null): string {
const made = payload?.book?.made; const made = payload?.book?.made;
const when = made ? `단가 굳힘 ${dateTimeText(String(made))}` : "단가 아직 안 굳힘"; const when = made ? `단가 굳힘 ${dateTimeText(String(made))}` : "단가 아직 안 굳힘";
const stale = payload?.stale; 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<Row>("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 { export interface CheckPanel {
@@ -72,6 +138,7 @@ export function createCheckPanel(refresh: HTMLElement, goto: (link: Link) => voi
let payload: Row | null = null; let payload: Row | null = null;
let items: CheckItem[] = []; let items: CheckItem[] = [];
attachHoverInfo(refresh, () => refreshTip(payload)); attachHoverInfo(refresh, () => refreshTip(payload));
attachPreview(refresh, () => payload);
const box = document.createElement("div"); const box = document.createElement("div");
box.className = "b09-check"; box.className = "b09-check";
attachHoverInfo(box, (row) => items[Number(row.dataset.i)]?.why, "[data-i]"); attachHoverInfo(box, (row) => items[Number(row.dataset.i)]?.why, "[data-i]");
@@ -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