From 3e1dc294b25357068ba652f4e660426d92666771 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 11:54:10 +0900 Subject: [PATCH] =?UTF-8?q?feat(795-4):=20B09=20=EC=82=AC=EB=B3=B8=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=20=E2=80=94=20=EB=A7=8C=EB=93=A0=20=EB=95=8C?= =?UTF-8?q?=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=C2=B7=20[=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=20=EA=B0=B1=EC=8B=A0]=20=EA=B0=92=EB=A7=8C=20=C2=B7=20[?= =?UTF-8?q?=EC=9E=AC=EC=83=9D=EC=84=B1]=20=EB=94=B0=EB=A1=9C=20=C2=B7=20?= =?UTF-8?q?=EC=8B=9C=EC=A0=90=20=EC=84=9E=EC=9E=84=20=EA=B3=A0=EC=B9=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 구조물 · 서고 사본 단가 묶음 = M02 일위대가 · SC · SK 로직 사본 줄 먼저(`collect(over)`) · M01 에서 지워진 키 = `사본_남은줄.json` - 단가 책 새 줄([저장] · 첫 굳힘) = 연결 사본 `단가.json` 자원 줄(구조물 지정 때 값 · `Linked.frozen_rows`) · 없는 자원만 지금 M01 + 책 `now_keys` · 입력 `now` · 알림 한 줄 - POST …/estimation/prices 기본 = 단가 갱신(값만 · 사본 재적용 없음) · job 재생성 = 옛 [업데이트] · 미리 보기 단가 갱신 = 바뀐 자원 · 총괄 · 대가 합 전 → 후 - B09 화면 단추 [업데이트] → [단가 갱신] · 미리 보기 창 [재생성] → 재생성 미리 보기 → [적용] · 안내 글 둘 이름 맞춤 - 옮기기 도구(보기만) — 프로젝트 층 못셈 14 = 「못셈(옛 SP 참조)」 갈래 · B09 구조물 · 서고 사본 대조(그대로 · 재생성 금액) - 새 시험 test_795_4_b09_copy(8) · 낡은 시험 고침: test_203_1 · test_296 = 단추 이름 「업데이트」 · 호버 글 · 몸 `{ user }` 를 박아 둔 것 → 795-4 이름 · job 덧칸으로 Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Linked.py | 31 ++- B09_Estimation/B09_Estimation_Prices.py | 64 ++++-- B09_Estimation/B09_Estimation_Refresh.py | 48 ++++- B09_Estimation/B09_Estimation_Router.py | 58 ++++-- B09_Estimation/B09_Estimation_UI_Check.ts | 128 ++++++++---- B09_Estimation/B09_Estimation_UI_Shell.ts | 7 +- B09_Estimation/B09_Estimation_UI_Tabs_Base.ts | 4 +- .../B09_Estimation_UI_Tabs_Sangeun_Sheet.ts | 2 +- M02_MasterTemplete/M02_Copy_Migrate.py | 72 ++++++- M02_MasterTemplete/M02_Item_Prices.py | 27 ++- M02_MasterTemplete/M02_Item_Source.py | 18 +- resources/tester/test_203_1_b09_data_check.py | 8 +- .../test_296_b09_update_skip_deleted.py | 3 +- resources/tester/test_795_4_b09_copy.py | 189 ++++++++++++++++++ 14 files changed, 560 insertions(+), 99 deletions(-) create mode 100644 resources/tester/test_795_4_b09_copy.py diff --git a/B09_Estimation/B09_Estimation_Linked.py b/B09_Estimation/B09_Estimation_Linked.py index 8f33276ac..dff2cacb4 100644 --- a/B09_Estimation/B09_Estimation_Linked.py +++ b/B09_Estimation/B09_Estimation_Linked.py @@ -90,9 +90,8 @@ def _sk_line(doc: dict[str, Any]) -> dict[str, Any]: return {**one, "켜쌓기율": doc.get("켜쌓기율"), "고름": picks or None} -def linked_resource_keys(project_root: str | Path, keep: set[str] | None = None) -> list[str]: - """연결된 사본 전부가 부른 자원 키 — 구조물 id 차례 → 설계내역서 서고 사본(204-2) · - 겹침 없음 · `keep` = 화면 캐시 줄이 잇는 서고 사본만(296 · 지운 줄의 사본 뺌).""" +def _folders(project_root: str | Path, keep: set[str] | None = None) -> list[Path]: + """연결된 구조물 사본(구조물 id 차례) → 설계내역서 서고 사본(204-2 · `keep` 만).""" from B09_Estimation.B09_Estimation_Bill_Library import copies base = structures_dir(project_root) @@ -101,9 +100,31 @@ def linked_resource_keys(project_root: str | Path, keep: set[str] | None = None) for _owner, linked in sorted(linked_snapshots(project_root).items()) if "일위대가" in linked.get("부분", []) ] + return [*folders, *(f for f, _ in copies(project_root) if keep is None or f.name in keep)] + + +def frozen_rows(project_root: str | Path) -> dict[str, dict[str, Any]]: + """사본 단가 기준 시점 자원 줄(795-4) — 연결 사본 `단가.json` 의 노임 · 자재 · 기계 · 유가전력 · + 환율 줄 `{키: 줄}`(만든 때 값 · 같은 키 = 앞 사본 것).""" + from M02_MasterTemplete.M02_Sangeun_Copy import RES + + out: dict[str, dict[str, Any]] = {} + for folder in _folders(project_root): + bundle = read_json(folder / PRICE_FILE) or {} + for name, data in bundle.items(): + if name.startswith("_") or not isinstance(data, dict): + continue + for row in data.get("줄") or data.get("표") or []: + if str(row.get("키"))[:2] in RES: + out.setdefault(str(row["키"]), row) + return out + + +def linked_resource_keys(project_root: str | Path, keep: set[str] | None = None) -> list[str]: + """연결된 사본 전부가 부른 자원 키 — 구조물 id 차례 → 설계내역서 서고 사본(204-2) · + 겹침 없음 · `keep` = 화면 캐시 줄이 잇는 서고 사본만(296 · 지운 줄의 사본 뺌).""" out: list[str] = [] - libs = [f for f, _ in copies(project_root) if keep is None or f.name in keep] - for folder in [*folders, *libs]: + for folder in _folders(project_root, keep): 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 5fcdf94e7..0dc5d823a 100644 --- a/B09_Estimation/B09_Estimation_Prices.py +++ b/B09_Estimation/B09_Estimation_Prices.py @@ -1,6 +1,8 @@ """B09 원가 단가 한 벌 · 수요 모음(PLAN 144) — 서버 단독(M01 을 읽는 곳은 여기 하나). -원가 단가 = 프로젝트 한 벌 — 원가산출에서 M01 단가를 한 번 복사해 굳힘(`price_book.json`) · +원가 단가 = 프로젝트 한 벌 — 원가산출에서 한 번 굳힘(`price_book.json`) · + 값 = 사본 단가 기준 시점(795-4 · 구조물 지정 때 `단가.json` 자원 줄 · 없는 자원만 지금 M01 + + `now_keys`) · [단가 갱신] 때만 새로 · 대가 · 산근도 이 단가로 셈(한 직종 · 한 자재 = 한 단가). 줄 = 수요 따라(사용자 승인 ① · 10-9) — 엑셀 목록을 기본으로 깔지 않음: @@ -44,6 +46,20 @@ def _master(): return m01.loaded()[1] +def basis(frozen: dict[str, dict] | None = None): + """값 원천 — 사본 단가 기준 시점 줄(795-4 · `Linked.frozen_rows`) 먼저 · 없는 키 = 지금 M01.""" + if not frozen: + return _master() + from M02_MasterTemplete import M02_Sangeun_Copy as skc + + return skc.Copy(_master(), skc._rows({"로직": list(frozen.values())})) + + +def now_keys(frozen: dict[str, dict] | None, keys: list[str]) -> list[str]: + """사본 기준 시점에 없는 자원(795-4) — 지금 M01 값으로 넣은 키 · `frozen` None = 안 셈.""" + return [] if frozen is None else [k for k in keys if k not in frozen] + + def _row(master, key: str) -> dict[str, Any] | None: return master.index.get(key[:2], {}).get(key) @@ -182,10 +198,13 @@ def _convert(master, key: str) -> dict[str, Any]: return material_input(row) -def refresh_book(keys: list[str], book: dict[str, Any] | None = None) -> dict[str, Any]: +def refresh_book( + keys: list[str], book: dict[str, Any] | None = None, frozen: dict[str, dict] | None = None +) -> dict[str, Any]: """[단가 갱신] — 수요 키 전부를 지금 M01 값으로 새로 굳힘 · - M01 원본이 지워진 키(296) = 옛 책 줄 그대로(지우지도 바꾸지도 않음).""" - master = _master() + M01 원본이 지워진 키(296) = 옛 책 줄 그대로(지우지도 바꾸지도 않음) · + `frozen` = 첫 굳힘(795-4) — 사본 단가 기준 시점 값 · 없는 자원 = 지금 값(`now_keys`).""" + master = basis(frozen) want = demand(master, keys) rows = { key: _convert(master, key) @@ -199,6 +218,7 @@ def refresh_book(keys: list[str], book: dict[str, Any] | None = None) -> dict[st "driver_factor": driver_factor(master), "fx": fx_inputs(master), "labor_edition": labor_editions(keys), # 703-3 미리 보기 「노임 판 전 → 후」 + "now_keys": now_keys(frozen, list(rows)), } @@ -241,17 +261,22 @@ def book_stale(book: dict[str, Any] | None, keys: list[str]) -> str | None: return " · ".join(p for p in parts if p) or None -def extend_book(book: dict[str, Any] | None, keys: list[str]) -> dict[str, Any]: - """[저장] — 책에 없는 새 줄만 더함(있는 줄 · 계수 · 환율은 안 바꿈) · 책이 없으면 새로 굳힘.""" +def extend_book( + book: dict[str, Any] | None, keys: list[str], frozen: dict[str, dict] | None = None +) -> dict[str, Any]: + """[저장] — 책에 없는 새 줄만 더함(있는 줄 · 계수 · 환율은 안 바꿈) · 책이 없으면 새로 굳힘 · + 새 줄 값 = 사본 단가 기준 시점(795-4 · `frozen`) · 없는 자원만 지금 M01(`now_keys`).""" if book is None: - return refresh_book(keys) - master = _master() + return refresh_book(keys, None, frozen or {}) + master = basis(frozen) want = demand(master, keys) rows = dict(book["rows"]) - for key in [*want["labor"], *want["machines"], *want["materials"]]: - if key not in rows: - rows[key] = _convert(master, key) - return {**book, "rows": rows} + added = [ + key for key in [*want["labor"], *want["machines"], *want["materials"]] if key not in rows + ] + rows.update({key: _convert(master, key) for key in added}) + late = [*(book.get("now_keys") or []), *now_keys(frozen or {}, added)] + return {**book, "rows": rows, "now_keys": list(dict.fromkeys(late))} def b08_material_rows(b08_sheets: dict[str, Any] | None) -> list[dict[str, Any]]: @@ -280,16 +305,24 @@ def b08_material_rows(b08_sheets: dict[str, Any] | None) -> list[dict[str, Any]] def build_inputs( - book: dict[str, Any] | None, keys: list[str], b08_sheets: dict[str, Any] | None = None + book: dict[str, Any] | None, + keys: list[str], + b08_sheets: dict[str, Any] | None = None, + frozen: dict[str, dict] | None = None, ) -> dict[str, Any]: - """표 셈 입력 — 줄 = 수요 · 값 = 굳힌 책(없는 줄은 지금 M01 값 · `fresh` 표시).""" - master = _master() + """표 셈 입력 — 줄 = 수요 · 값 = 굳힌 책(없는 줄 = [저장] 때 넣을 값 · `fresh` 표시 · + 795-4 사본 단가 기준 시점 `frozen` · 그것도 없으면 지금 M01 · `now` 표시) · + `now` = 사본 기준 시점에 없는 자원 키(책 `now_keys` + 안 굳힌 줄).""" + master = basis(frozen) want = demand(master, keys) saved = (book or {}).get("rows") or {} + late = [k for k in (book or {}).get("now_keys") or [] if k in saved] def pick(key: str) -> dict[str, Any]: if key in saved: return saved[key] + if frozen is not None and key not in frozen: + late.append(key) return {**_convert(master, key), "fresh": True} factor = (book or {}).get("driver_factor") if book else driver_factor(master) @@ -309,4 +342,5 @@ def build_inputs( "driver_factor": factor, "fx": (book or {}).get("fx") if book else fx_inputs(master), "missing": want["missing"], + "now": list(dict.fromkeys(late)), # 위 줄들 pick 뒤 — 사전 차례대로 셈 } diff --git a/B09_Estimation/B09_Estimation_Refresh.py b/B09_Estimation/B09_Estimation_Refresh.py index af191bd16..b9effe7ee 100644 --- a/B09_Estimation/B09_Estimation_Refresh.py +++ b/B09_Estimation/B09_Estimation_Refresh.py @@ -1,4 +1,6 @@ -"""B09 [업데이트] 한 곳(PLAN 703-3) — 단가 책 다시 굳힘 + 원본이 바뀐 구조물 사본 [재적용]을 함께. +"""B09 [단가 갱신] · [재생성](PLAN 795-4 · 옛 [업데이트] 703-3) — 사본은 만든 때 그대로 · +[단가 갱신](기본) = 단가 책만 지금 M01 값 · 미리 보기 `price_preview` = 바뀐 자원 · 총괄 전 → 후 · +[재생성](사용자가 따로) = 아래 옛 [업데이트] — 단가 책 다시 굳힘 + 원본이 바뀐 구조물 사본 [재적용]. 미리 보기(`preview`) = 바뀌는 단가 줄(새 줄 + 바뀐 줄 · 지금 수요) · 다시 가져올 구조물 사본 (연결된 것 중 원본이 바뀐 것만 · 원본 없음 = 그대로) · 지킬 고친 칸(덮개 칸 수 · 702-4 — @@ -12,6 +14,7 @@ M02 단가산출(SK · 719-2) — 구조물 · 서고 사본의 굳힌 SK 를 from __future__ import annotations +import json from pathlib import Path from typing import Any @@ -23,6 +26,49 @@ from M02_MasterTemplete import M02_Item_Sangeun as item_sk from M02_MasterTemplete.M02_Item_Snapshot import MANIFEST_NAME, linked_snapshots, structures_dir from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, build_source, read_json +REGEN = "재생성" # POST …/estimation/prices · preview 몸 `job`(795-4) + + +def _value(row: dict[str, Any] | None) -> Any: + return None if row is None else row.get("price", row.get("prices")) + + +def price_preview( + old: dict[str, Any] | None, new: dict[str, Any], before: dict, after: dict, cells: int +) -> dict[str, Any]: + """[단가 갱신] 미리 보기(795-4) — {할일, 바뀐자원 [{키, 이름, 전, 후}], 계수, 환율, 전, 후 + (총괄 grand · 빈 줄 있으면 None), 대가 {전, 후}(일위대가 블록 합), 고친칸, 노임판} · + 사본(로직)은 안 바뀜.""" + diff = prices.book_diff(old, new) + was = json.loads(json.dumps((old or {}).get("rows") or {}, ensure_ascii=False)) + now = json.loads(json.dumps(new["rows"], ensure_ascii=False)) + rows = [ + {"키": key, "이름": row.get("name") or "", "전": _value(was.get(key)), "후": _value(row)} + for key, row in now.items() + if was.get(key) != row + ] + + def grand(sheets: dict) -> Any: + return (((sheets.get("total") or {}).get("subtotals")) or {}).get("grand") + + def daega(sheets: dict) -> float: + return sum(float(b.get("sum") or 0) for b in sheets.get("daega_blocks") or []) + + return { + "할일": "단가갱신", + "바뀐자원": rows, + "계수": diff["factor"], + "환율": diff["fx"], + "전": grand(before), + "후": grand(after), + "대가": {"전": daega(before), "후": daega(after)}, + "고친칸": cells, + "노임판": { + "전": edition_text((old or {}).get("labor_edition")), + "후": edition_text(new.get("labor_edition")), + }, + } + def changed_copies(root: str | Path) -> list[dict[str, Any]]: """연결된 구조물 사본 중 원본(층 · 마스터)이 바뀐 것 `[{폴더, 이름}]`.""" diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index abc2306a0..340457c5f 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -10,14 +10,14 @@ [초기화](요구 298-2 C1 · C3 · C4 · C5 · 301 · 본문 없음) — 사용자 값 전부(내역 · 자재 · 원가 · 공기) · 단가 책 · `estimation` 설정 구획 지움 · 서고 사본 정리 · 견적서 파일 남김 → 답 = GET 꼴(기준 = 지금 B08 저장본 + 지금 M01 단가) · 단계 = [저장]과 같음 - POST /api/projects/{id}/estimation/prices {user?} - [업데이트] — 원본이 바뀐 구조물 사본 재적용(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/prices {user?, job?} + [단가 갱신](795-4 · 기본) — 수요 단가 전부를 지금 M01 값으로 새로 굳힘 · 사본(로직) 그대로 · + job 「재생성」 = 원본이 바뀐 구조물 사본 재적용(703-3 · `B09_Estimation_Refresh`) 먼저 · + user = 화면 캐시(296 · 지운 줄 · 그 사본은 수요에서 뺌 · 저장은 안 함) · + M01 원본이 지워진 줄 = 굳힌 값 그대로 · 덮개 그대로 · 답 notes 첫 줄 = 바뀐 수 + POST /api/projects/{id}/estimation/prices/preview {user?, job?} + 미리 보기(쓰지 않음) — 단가 갱신 = 바뀐 자원 · 총괄 금액 전 → 후(795-4) · + 재생성 = 바뀌는 단가 줄 · 다시 가져올 사본 · 지킬 고친 칸 · 노임 판(703-3) POST /api/projects/{id}/estimation/calc {user} 조작 중 서버 셈(702-3 · 단가연결 즉시 금액) — 화면 캐시로 GET 꼴 셈 · 저장 안 함 POST /api/projects/{id}/estimation/logic-preview {code, given, rate, picks} @@ -75,6 +75,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_Paste as daega_paste from B09_Estimation import B09_Estimation_Daega_Personal as personal +from B09_Estimation import B09_Estimation_Linked as linked from B09_Estimation import B09_Estimation_Past as past from B09_Estimation import B09_Estimation_Prices as prices from B09_Estimation import B09_Estimation_Refresh as refresh @@ -234,10 +235,12 @@ async def payload( draft: dict[str, Any] | None = None, reuse_b08: bool = False, keys: list[str] | None = None, + book: dict[str, Any] | None = None, ) -> dict[str, Any]: - """셈 답 한 벌 — keys = 방금 센 단가 수요(같은 저장 값 · [저장]) · 마스터 자국은 한 번만.""" + """셈 답 한 벌 — keys = 방금 센 단가 수요(같은 저장 값 · [저장]) · 마스터 자국은 한 번만 · + book = 안 쓴 단가 책(795-4 미리 보기 · 없으면 저장본).""" with m01_cache.steady(): - return await _payload(project_id, root, draft, reuse_b08, keys) + return await _payload(project_id, root, draft, reuse_b08, keys, book) async def _payload( @@ -246,6 +249,7 @@ async def _payload( draft: dict[str, Any] | None, reuse_b08: bool, keys: list[str] | None, + book: dict[str, Any] | None = None, ) -> dict[str, Any]: notes: list[str] = [] b08 = await _b08_for(project_id, notes, reuse_b08) @@ -254,8 +258,11 @@ async def _payload( notes.append( "연결된 구조물 조합 사본 없음 — 노무 · 경비 · 중기 단가 줄은 사본이 연결되면 생김" ) - book = await asyncio.to_thread(store.read_book, root) - inputs = await asyncio.to_thread(prices.build_inputs, book, keys, b08["materials"]) + book = book or await asyncio.to_thread(store.read_book, root) + frozen = await asyncio.to_thread(linked.frozen_rows, root) # 795-4 사본 단가 기준 시점 + inputs = await asyncio.to_thread(prices.build_inputs, book, keys, b08["materials"], frozen) + if late := inputs["now"]: + notes.append(f"사본 기준 시점에 없는 자원 {len(late)} — 지금 M01 값: {' · '.join(late)}") hauls = await asyncio.to_thread(haul_routes, root) # 702-3 draft = 화면 캐시로 셈(저장 안 함) · 없으면 저장 값 user = await asyncio.to_thread(store.read_user, root) if draft is None else draft @@ -309,7 +316,8 @@ async def _save(project_id: UUID, root: Path, body: dict[str, Any]) -> dict[str, await asyncio.to_thread(library.prune, root, calc) # 204-2 줄이 안 잇는 서고 사본 keys = await _demand(root) book = await asyncio.to_thread(store.read_book, root) - book = await asyncio.to_thread(prices.extend_book, book, keys) + frozen = await asyncio.to_thread(linked.frozen_rows, root) # 795-4 새 줄 = 사본 기준 시점 + book = await asyncio.to_thread(prices.extend_book, book, keys, frozen) await asyncio.to_thread(store.write_book, root, book) # [저장](B1) 단계 표시는 셈과 함께 · 수요 = 방금 센 것(저장 값 · 사본 그대로) out, _ = await asyncio.gather( @@ -352,12 +360,15 @@ async def refresh_prices( body: dict[str, Any] | None = Body(None), session: dict[str, Any] = Depends(verify_session), ) -> dict[str, Any]: - """[업데이트] 한 곳(703-3) — 원본이 바뀐 구조물 사본 재적용 → 단가 책 다시 굳힘 · - 덮개 그대로.""" + """[단가 갱신](795-4) — 단가 책 다시 굳힘 · 사본 · 덮개 그대로 · + job 「재생성」 = 원본이 바뀐 구조물 사본 재적용(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)) + regen = (body or {}).get("job") == refresh.REGEN + again = {"다시": [], "막힘": []} + if regen: + 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) @@ -366,9 +377,9 @@ async def refresh_prices( diff = prices.book_diff(old, book) kept = draft or out.get("user") or {} cells = refresh.cover_cells(kept.get("daega_cover")) + refresh.cover_cells(kept.get("sg_cover")) + head = f"재생성 — 구조물 사본 {len(again['다시'])} 다시 가져옴 · " if regen else "단가 갱신 — " out["notes"] = [ - f"업데이트 — 단가 줄 {diff['new'] + diff['changed']} 바뀜 · 구조물 사본 " - f"{len(again['다시'])} 다시 가져옴 · 고친 칸 {cells} 그대로", + f"{head}단가 줄 {diff['new'] + diff['changed']} 바뀜 · 고친 칸 {cells} 그대로", *(f"재적용 못 함 {why}" for why in again["막힘"]), *(out.get("notes") or []), ] @@ -379,14 +390,21 @@ async def refresh_prices( async def refresh_preview( project_id: UUID, body: dict[str, Any] | None = Body(None) ) -> dict[str, Any]: - """[업데이트] 미리 보기(703-3) — 바뀌는 단가 줄 · 다시 가져올 사본 · 지킬 고친 칸 · 노임 판.""" + """미리 보기 — 단가 갱신(795-4) = 바뀐 자원 · 총괄 금액 전 → 후 · + 재생성(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")) + if (body or {}).get("job") == refresh.REGEN: + return await asyncio.to_thread(refresh.preview, root, book, keys, user.get("daega_cover")) + new = await asyncio.to_thread(prices.refresh_book, keys, book) + before = await payload(project_id, root, draft, True, keys) + after = await payload(project_id, root, draft, True, keys, new) + cells = refresh.cover_cells(user.get("daega_cover")) + refresh.cover_cells(user.get("sg_cover")) + return refresh.price_preview(book, new, before["sheets"], after["sheets"], cells) @router.post("/{project_id}/estimation/calc") diff --git a/B09_Estimation/B09_Estimation_UI_Check.ts b/B09_Estimation/B09_Estimation_UI_Check.ts index ed0d17dda..03d936f98 100644 --- a/B09_Estimation/B09_Estimation_UI_Check.ts +++ b/B09_Estimation/B09_Estimation_UI_Check.ts @@ -1,12 +1,13 @@ /* ============================================================================= * B09_Estimation_UI_Check.ts - * B09 좌측 「데이터 점검」 묶음(PLAN 203-1) — [업데이트] · 연결 점검(옛 「연결 점검」 탭 내용). + * B09 좌측 「데이터 점검」 묶음(PLAN 203-1) — [단가 갱신] · 연결 점검(옛 「연결 점검」 탭 내용). * - * [업데이트] = 단가를 지금 M01 값으로 새로 굳힘 · 서버가 낡았다고 보면(`payload.stale` 까닭 한 줄) + * [단가 갱신](795-4 · 옛 [업데이트]) = 자원 단가만 지금 M01 값 · 서버가 낡았다고 보면(`payload.stale` 까닭 한 줄) * 단추에 점 배지(`is-pending`) · 굳힌 시각 · 까닭 = 공용 호버 - * 미리 보기 고리(703-3) = [업데이트] 누름을 먼저 잡아 POST …/estimation/prices/preview(화면 캐시) → - * 「바뀌는 단가 줄 N · 다시 가져올 구조물 사본 K · 고친 칸 M 지킴 · 노임 판 전 → 후」 창 → [적용] = - * 원래 단추 일(서버가 사본 재적용 + 단가 다시 굳힘 · 덮개 그대로) · 닫기 = 안 함 + * 미리 보기 고리 = 누름을 먼저 잡아 POST …/estimation/prices/preview(화면 캐시) → + * 「총괄 금액 전 → 후 · 바뀐 자원」 창 → [적용] = 원래 단추 일(단가만 · 사본 · 덮개 그대로) · + * 창의 [재생성] = 재생성 미리 보기(703-3 · 다시 가져올 사본 · 고친 칸 · 노임 판) → [적용] = + * `job: 재생성`(사본 재적용 + 단가) · 닫기 = 안 함 * 연결 점검 = 「빈 줄 n」(설계내역서 금액 모르는 줄 · 안내 · 집계표와 같은 수 · 701-2) 목록 + * 그 밖 확인 목록(일위대가 · 단가산출 · 재료 등 · 탭 · 키마다 하나)(누르면 그 탭 그 줄로 · 까닭 = 공용 호버) + 서버 알림 · M01 에 없는 키 * ========================================================================== */ @@ -62,15 +63,15 @@ export function checkItems( return { items, others, notes }; } -/** [업데이트] 호버 글 — 굳힌 시각 + 낡은 까닭 */ +/** [단가 갱신] 호버 글 — 굳힌 시각 + 낡은 까닭 */ 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) */ +/** [재생성] 미리 보기 글 줄(703-3 옛 [업데이트]) */ export function previewLines(got: Row): string[] { const copies: Row[] = got.사본 || []; const sks: Row[] = got.단가산출 || []; @@ -87,46 +88,91 @@ export function previewLines(got: Row): string[] { ]; } -/** [업데이트] 누름 앞 미리 보기 — [적용] 이면 원래 단추 일 · 닫기 = 안 함 */ +const won = (v: unknown): string => (v == null ? "—" : Number(v).toLocaleString("ko-KR")); + +/** 자원 단가 값 — 노임 · 기계 = 수 · 자재 = 출처별 값 */ +function priceText(v: unknown): string { + if (v == null) return "새 줄"; + if (typeof v !== "object") return won(v); + const got = Object.entries(v as Row).filter(([, x]) => x != null); + return got.length ? got.map(([k, x]) => `${k} ${won(x)}`).join(" / ") : "—"; +} + +/** [단가 갱신] 미리 보기 글 줄(795-4) — 총괄 금액 전 → 후 · 바뀐 자원 */ +export function priceLines(got: Row): string[] { + const rows: Row[] = got.바뀐자원 || []; + const edition = got.노임판 || {}; + const shown = 30; + return [ + `총괄 금액 ${won(got.전)} → ${won(got.후)} 원 · 대가 합 ${won(got.대가?.전)} → ${won(got.대가?.후)} 원`, + `바뀐 자원 ${rows.length}${got.계수 ? " · 운전사 계수 바뀜" : ""}${got.환율 ? " · 환율 바뀜" : ""}`, + ...rows + .slice(0, shown) + .map((r) => `· ${r.이름 || r.키} ${priceText(r.전)} → ${priceText(r.후)}`), + ...(rows.length > shown ? [`… 외 ${rows.length - shown}`] : []), + `고친 칸 ${got.고친칸 ?? 0} · 구조물 사본(로직) 그대로 — 로직까지 새로는 [재생성]`, + `노임 판 ${edition.전 || "—"} → ${edition.후 || "—"}`, + ]; +} + +const REGEN = "재생성"; + +/** [단가 갱신] 누름 앞 미리 보기 — [적용] 이면 원래 단추 일(`data-b09-job` = 할일) · 닫기 = 안 함 · + * [재생성] = 재생성 미리 보기 → [적용] = 사본 재적용 + 단가(795-4 · 사용자가 따로 누를 때만) */ function attachPreview(refresh: HTMLElement, payload: () => Row | null): void { let pass = false; + const open = (job?: string): void => { + 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(job ? { user, job } : { user }), + }) + .then(async (response) => { + const got = (await response.json()) as Row; + if (!response.ok) throw new Error(String(got?.detail ?? response.status)); + openModal({ + title: job ? "재생성 미리 보기" : "단가 갱신 미리 보기", + closeLabel: "닫기", + width: "40rem", + mount: (body, close, head) => { + const apply = createButton({ + label: "적용", + variant: "filled", + onClick: () => { + close(); + pass = true; + if (job) refresh.dataset.b09Job = job; + refresh.click(); + delete refresh.dataset.b09Job; + }, + }); + apply.dataset.b09RefreshApply = ""; + if (!job) { + const regen = createButton({ + label: REGEN, + variant: "ghost", + onClick: () => (close(), open(REGEN)), + }); + regen.dataset.b09Regen = ""; + head.append(regen); + } + head.append(apply); + const lines = job ? previewLines(got) : priceLines(got); + body.append(...lines.map((line) => createText(line))); + }, + }); + }) + .catch((error) => showToast(`미리 보기 못 함 — ${String(error)}`, "error")); + }; 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")); + open(); }, true, ); diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 5e6334b8f..3321656c3 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -7,7 +7,7 @@ * 짝 계산(`…_Calc_*.ts`)으로 바로 다시 셈 · 새 산근(로직 연결) = 뒤에서 서버 셈(702-3 · `…_UI_Live`) · * [저장](702-3 · 왼쪽 아래 [초기화] 옆) = PUT → 서버 정본으로 다시 셈 · 저장 안 하고 단계 옮김 · [>] = 물음(저장 · 버림 · 머묾) * [>] = 물음 → POST …/estimation/complete(단계 6 완료) → `DRAFT.next`(인쇄 미리보기 · 초안) · 실패면 머묾 - * [초기화](요구 298-2) = 확인 창 → POST …/estimation/reset(사용자 값 · 단가 책 지움 · 견적서 남김) → 셈 값 · [업데이트] = POST …/prices + * [초기화](요구 298-2) = 확인 창 → POST …/estimation/reset(사용자 값 · 단가 책 지움 · 견적서 남김) → 셈 값 · [단가 갱신] = POST …/prices(795-4) * 호표 칸 누름 = 그 탭 그 줄로 이동 · 자동 저장 없음 * ========================================================================== */ @@ -372,15 +372,16 @@ export function renderB09Estimation(root: HTMLElement): void { : Promise.resolve(true); DRAFT.save = saveChanged; // 초안 [인쇄 미리보기] = 저장 뒤 이동(506-3) DRAFT.blocked = () => (payload ? unfinished(sheets) : null); // 701-3 인쇄 · 확정 = 빈 줄 0 + // 795-4 [단가 갱신] = 값만 · 미리 보기 창 [재생성] = `data-b09-job`(B09_Estimation_UI_Check) const refresh = createButton({ - label: "업데이트", + label: "단가 갱신", variant: "ghost", onClick: () => void run( "/estimation/prices", "POST", "단가를 지금 M01 값으로 새로 굳혔습니다.", - { user }, + { user, ...(refresh.dataset.b09Job ? { job: refresh.dataset.b09Job } : {}) }, true, ), }); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts index 52c23735d..458fbe168 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts @@ -55,14 +55,14 @@ export const buildLabor: TabBuild = ({ sheets, payload }) => priceList( sheets.labor || [], "단가(원/인)", - `이 공사 단가 책(${madeText(payload)}) 기준 · 새 노임은 [업데이트]`, + `이 공사 단가 책(${madeText(payload)}) 기준 · 새 노임은 [단가 갱신]`, ); export const buildExpense: TabBuild = ({ sheets, payload }) => priceList( sheets.expense || [], "기준가(천원)", - `이 공사 단가 책(${madeText(payload)}) 기준 · 새 노임은 [업데이트]`, + `이 공사 단가 책(${madeText(payload)}) 기준 · 새 노임은 [단가 갱신]`, ); function madeText(payload: Row): string { diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun_Sheet.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun_Sheet.ts index 35b3ac136..35096f463 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun_Sheet.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun_Sheet.ts @@ -287,7 +287,7 @@ function blockCard(ctx: TabContext, block: Row, noOf: Record): H ? [ edit, createText( - `품 · 단가 칸을 두 번 눌러 고침 · 색 칸 = ${TAG}(이 공사만 · [업데이트] 뒤에도 남음) · 우클릭 [${BACK}으로] · [저장] 때 서버가 다시 셈`, + `품 · 단가 칸을 두 번 눌러 고침 · 색 칸 = ${TAG}(이 공사만 · [단가 갱신] · [재생성] 뒤에도 남음) · 우클릭 [${BACK}으로] · [저장] 때 서버가 다시 셈`, "muted", ), ] diff --git a/M02_MasterTemplete/M02_Copy_Migrate.py b/M02_MasterTemplete/M02_Copy_Migrate.py index 8b68fc802..9c220e72f 100644 --- a/M02_MasterTemplete/M02_Copy_Migrate.py +++ b/M02_MasterTemplete/M02_Copy_Migrate.py @@ -5,7 +5,11 @@ SK = `calc_doc` 기준(노 · 재 · 경 · 계) · SC · 일위대가 = `Labor_Doc.calc` 기준합 · 반영합. 같음 = 파일에 `사본` 칸만 더해 씀(그 밖 칸 그대로) · 다름 · 못 셈 = 안 씀(목록) · 닿을 것 없음(바탕 참조 SK · 참조 · 손 단가 줄만) = 그대로. -프로젝트 층 일위대가 = 세기 · 대조만(B09 사본 규칙 795-4 와 함께 옮김). +프로젝트 층 일위대가 = 세기 · 대조만(795-4 · 쓰지 않음) — 옛 SP 참조(프로젝트 층은 굳힌 + 사본만 읽음 · 마스터 SP 를 바로 가리키는 옛 꼴) = 「못셈(옛 SP 참조)」 갈래. +B09 사본(795-4 · 보기만) = 구조물 사본 · 서고 사본 — 만든 때 그대로 셈(옮길 것 없음 · + 상태 「그대로」) · + `재생성` = 지금 원본으로 다시 가져오면 금액(바뀌면 `재생성다름`) · 못 셈 = 까닭. 되돌릴 길 = 쓰기 전 파일 그대로 `--backup` 폴더에(층 자리 그대로) · `--restore` = 다시 덮음. ./venv/Scripts/python.exe -m M02_MasterTemplete.M02_Copy_Migrate # 보기만(표) @@ -100,6 +104,65 @@ def _backup_path(path: Path, backup: Path) -> Path: raise ValueError(f"층 밖 파일 「{path}」") +OLD_SP = "위 층만 가리킴" # 프로젝트 층 문서가 마스터 SP 를 바로 가리킴(굳힌 사본 아님) +LOCAL = ("입력값.json", "고른자재.json", "면적.json") # 재생성 대조 때 사본 값 그대로 + + +def _b09_files() -> list[tuple[str, str, Path]]: + """(어디, 종류, 폴더) — 프로젝트 구조물 사본 · B09 서고 사본.""" + root = layers.storage_root() + out = [] + for kind, pattern in ( + ("구조물사본", "*/*/*/templates/structures/*/manifest.json"), + ("서고사본", "*/*/*/B09_Estimation/v1/library/*/manifest.json"), + ): + for p in sorted(root.glob(pattern)): + at = p.parents[3] if kind == "구조물사본" else p.parents[4] + out.append((f"B09 {at.name}", kind, p.parent)) + return out + + +def _copy_money(files: dict[str, Any]) -> Any: + from M02_MasterTemplete import M02_Item_Prices as prices + + labor = files.get("일위대가.json") + if not isinstance(labor, dict): + return None + got = prices.calc( + labor, + files.get("단가.json") or {}, + files.get("입력값.json") or {}, + files.get("고른자재.json") or {}, + files.get("면적.json"), + ) + return _round({k: got.get(k) for k in ("기준합", "반영합")}) + + +def b09_check(kind: str, folder: Path) -> dict[str, Any]: + """B09 사본 하나(보기만) — {상태: 그대로 | 필요없음 | 못셈, 전, 재생성?, 재생성다름?, 까닭?}.""" + from B09_Estimation import B09_Estimation_Bill_Library as lib + from M02_MasterTemplete.M02_Item_Source import build_source, read_json + + own = {p.name: read_json(p) for p in folder.glob("*.json")} + manifest = own.get("manifest.json") or {} + try: + before = _copy_money(own) + except Exception as e: # noqa: BLE001 — 못 셈 = 목록 + return {"상태": "못셈", "까닭": str(e) or type(e).__name__} + if before is None: + return {"상태": "필요없음"} + try: + if kind == "서고사본": + fresh, _ = lib._build(*lib._source_of(manifest)) + lib._apply(fresh, str(manifest.get("종류")), manifest.get("값") or {}) + else: + fresh = build_source(manifest["원본"]) + after = _copy_money({**fresh, **{n: own[n] for n in LOCAL if n in own}}) + except Exception as e: # noqa: BLE001 — 원본 없음 · 못 셈 = 재생성 대조만 빠짐 + return {"상태": "그대로", "전": before, "까닭": f"재생성 대조 못 함 — {e}"} + return {"상태": "그대로", "전": before, "재생성": after, "재생성다름": after != before} + + def migrate(apply: bool = False, backup: Path | None = None) -> list[dict[str, Any]]: """모든 층 — 문서마다 {어디, 종류, 키, 상태, 씀, …} · `apply` = 같음만 씀(프로젝트 층 빼고).""" if apply and backup is None: @@ -107,6 +170,8 @@ def migrate(apply: bool = False, backup: Path | None = None) -> list[dict[str, A out = [] for where_, kind, path, project in _files(): got = check(kind, path) + if project and got["상태"] == "못셈" and OLD_SP in got.get("까닭", ""): + got["상태"] = "못셈(옛 SP 참조)" copy = got.pop("사본", None) row = {"어디": where_, "종류": kind, "키": path.stem, **got, "씀": False} if apply and copy and not project: @@ -116,6 +181,9 @@ def migrate(apply: bool = False, backup: Path | None = None) -> list[dict[str, A atomic_write_json(path, {**json.loads(path.read_bytes()), skc.KEY: copy}) row["씀"] = True out.append(row) + for where_, kind, folder in _b09_files(): # 795-4 B09 사본 = 보기만 + out.append({"어디": where_, "종류": kind, "키": folder.name, **b09_check(kind, folder)}) + out[-1]["씀"] = False return out @@ -149,6 +217,6 @@ if __name__ == "__main__": # 브레인 · 사용자 승인 뒤만 --apply at = Path(args[args.index("--backup") + 1]) if "--backup" in args else None rows = migrate("--apply" in args, at) for one in rows: - if one["상태"] not in ("있음", "필요없음", "같음"): + if one["상태"] not in ("있음", "필요없음", "같음") or one.get("재생성다름"): print(json.dumps(one, ensure_ascii=False, default=str)) print(json.dumps(table(rows), ensure_ascii=False, indent=1)) diff --git a/M02_MasterTemplete/M02_Item_Prices.py b/M02_MasterTemplete/M02_Item_Prices.py index a74ea02db..859b53c3d 100644 --- a/M02_MasterTemplete/M02_Item_Prices.py +++ b/M02_MasterTemplete/M02_Item_Prices.py @@ -225,12 +225,25 @@ class _Collector: self.pick(row, item["요소"], item.get("단위")) +#: M01 에서 지워진 사본 줄(795-4) 자리 — `Master` 가 그룹 아닌 「줄」 로 읽음 +COPY_FILE = "사본_남은줄.json" + + def collect( - logic_keys: list[str], materials: list[str] | None = None, extra: list[dict] | None = None + logic_keys: list[str], + materials: list[str] | None = None, + extra: list[dict] | None = None, + over: list[dict] | None = None, ) -> dict[str, Any]: """담은 로직 + 자재(MT)의 닫힌 단가 묶음 — `{파일 이름: 파일(줄은 모은 것만 · 차례 그대로)}` · - `extra` = 마스터 밖 로직 줄(굳힌 단가산출 719-1) — 그 줄이 닿는 것만 모음(줄은 안 담음).""" + `extra` = 마스터 밖 로직 줄(굳힌 단가산출 719-1) — 그 줄이 닿는 것만 모음(줄은 안 담음) · + `over` = M02 로직 사본 줄(795-4) — 같은 키는 M01 줄 대신 · M01 에 없는 키 = `COPY_FILE`.""" files, whole = master() + mine = {str(r.get("키")): r for r in over or []} + if mine: + from M02_MasterTemplete import M02_Sangeun_Copy as skc + + whole = skc.Copy(whole, skc._rows({"로직": list(mine.values())})) collector = _Collector(whole) for key in logic_keys: collector.logic(key) @@ -239,11 +252,19 @@ def collect( for code in materials or []: collector.add(code) out: dict[str, Any] = {} + seen: set[str] = set() for name, data in files.items(): slot = m01.items_key(data) - rows = [row for row in data.get(slot) or [] if str(row.get("키")) in collector.keys] + rows = [ + mine.get(str(row.get("키")), row) + for row in data.get(slot) or [] + if str(row.get("키")) in collector.keys + ] + seen.update(str(row.get("키")) for row in rows) if rows: out[name] = {**{k: v for k, v in data.items() if k != slot}, slot: rows} + if left := [r for k, r in mine.items() if k in collector.keys and k not in seen]: + out[COPY_FILE] = {"그룹": "사본", "줄": left} if collector.missing: out["_빠진"] = sorted(set(collector.missing)) return out diff --git a/M02_MasterTemplete/M02_Item_Source.py b/M02_MasterTemplete/M02_Item_Source.py index 489ad8ea0..6ac0aa147 100644 --- a/M02_MasterTemplete/M02_Item_Source.py +++ b/M02_MasterTemplete/M02_Item_Source.py @@ -180,8 +180,11 @@ def price_bundle(labor: dict[str, Any] | None, root: Path) -> dict[str, Any]: materials += fixed.link_keys(doc) # 705-1 고정 줄이 잇는 노임 · 자재 · 기계 materials += sk.materials(copies) wanted = logic_keys + [code for code in materials if code] + over = copy_rows([doc, *copies.values()]) # M02 로직 사본(795-4) = 만든 때 줄 먼저 bundle = ( - prices.to_json(prices.collect(logic_keys, materials, sk_rows)) if wanted or sk_rows else {} + prices.to_json(prices.collect(logic_keys, materials, sk_rows, over)) + if wanted or sk_rows + else {} ) if sk_rows: bundle[sk.FILE] = {"그룹": "로직", "원문": "자체", "줄": sk_rows} @@ -190,3 +193,16 @@ def price_bundle(labor: dict[str, Any] | None, root: Path) -> dict[str, Any]: ): # 개인 자재(148-3 · 사전조합 것도 175-2) — 원본 목록이 바뀌어도 사본 그대로 bundle[own.BUNDLE] = mine return bundle + + +def copy_rows(docs: list[Any]) -> list[dict[str, Any]]: + """문서 · 굳힌 참조사본(SK 는 `원본`)의 로직 사본 줄(795-4) — 키 겹침 = 앞 문서 것.""" + from M02_MasterTemplete import M02_Sangeun_Copy as skc + + out: dict[str, dict] = {} + for doc in docs: + for one in (doc, doc.get("원본") if isinstance(doc, dict) else None): + if skc.has(one): + for row in [*one[skc.KEY]["로직"], *(one[skc.KEY].get("단가") or [])]: + out.setdefault(str(row.get("키")), row) + return list(out.values()) diff --git a/resources/tester/test_203_1_b09_data_check.py b/resources/tester/test_203_1_b09_data_check.py index ecf77a12a..a8151b084 100644 --- a/resources/tester/test_203_1_b09_data_check.py +++ b/resources/tester/test_203_1_b09_data_check.py @@ -26,8 +26,8 @@ def test_묶음_이름_단추_글_설명_없음(): assert ( 'createPanelCard("내역 점검", true)' in SHELL and "원가 단가" not in SHELL ) # 269 공용 카드 - assert 'label: "업데이트",' in SHELL and "단가 갱신" not in SHELL - # 옛 굳힌 시각 안내 글(made) 없음 · 묶음 = 제목 · [업데이트] · 연결 점검 + assert 'label: "단가 갱신",' in SHELL and 'label: "업데이트"' not in SHELL # 795-4 이름 + # 옛 굳힌 시각 안내 글(made) 없음 · 묶음 = 제목 · [단가 갱신] · 연결 점검 assert "made.textContent" not in SHELL and "b09-estimation__hint" not in SHELL assert "section.append(refresh, check.list);" in SHELL # 제목 = 공용 카드(269) assert "check.draw(payload, sheets);" in SHELL @@ -108,9 +108,9 @@ console.log(JSON.stringify({ a, b, moves, tipRefresh, tipRow, tipBlank, selector assert out["moves"] == [{"tab": "material", "key": "MT9"}] assert out["tipRow"] == "대가 원천 없음" and out["selector"] == "[data-i]" assert "단가 굳힘 2026-10-01 09:00:00" in out["tipRefresh"] - assert "업데이트 필요 — M01 에서 바뀐 단가 2" in out["tipRefresh"] + assert "단가 갱신 가능 — M01 에서 바뀐 단가 2" in out["tipRefresh"] assert b["texts"] == ["금액 빈 줄 0"] and b["rows"] == [] - assert out["tipEmpty"].startswith("단가 아직 안 굳힘") and "업데이트 필요" in out["tipEmpty"] + assert out["tipEmpty"].startswith("단가 아직 안 굳힘") and "단가 갱신 가능" in out["tipEmpty"] def test_배지_조건_서버_book_stale(): diff --git a/resources/tester/test_296_b09_update_skip_deleted.py b/resources/tester/test_296_b09_update_skip_deleted.py index 2809d83e2..a1ff00df5 100644 --- a/resources/tester/test_296_b09_update_skip_deleted.py +++ b/resources/tester/test_296_b09_update_skip_deleted.py @@ -85,6 +85,7 @@ def test_M01_원본_없는_항목_값_그대로_낡음_까닭_빠짐(): 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 "{ user, ...(refresh.dataset.b09Job" in shell[at : at + 200] # 795-4 할일 덧칸 + assert "\n true," in shell[at : at + 200] assert 'if (!keep) clearState("b09-estimation", projectId);' in shell assert "apply(next, !keep);" in shell diff --git a/resources/tester/test_795_4_b09_copy.py b/resources/tester/test_795_4_b09_copy.py new file mode 100644 index 000000000..6fc05597a --- /dev/null +++ b/resources/tester/test_795_4_b09_copy.py @@ -0,0 +1,189 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""795-4 프로젝트(B09) 사본 규칙 — 만든 때 그대로 · [단가 갱신] 값만 · [재생성] 따로 · +시점 섞임 고침(임시 storage · M01 은 메모리 덮개 · 정본 안 고침). + +① 구조물 사본 단가 묶음 = M02 일위대가 사본 줄(만든 때) · 뒤에 M01 로직 · 노임이 바뀌어도 + 사본 금액 그대로 · M01 에서 지워진 키 = 사본 줄 그대로 ② 사본 없는 옛 문서 = 옛 길(지금 M01) +③ [저장] 새 단가 줄 = 사본 기준 시점 값 · 없는 자원만 지금 값 + 표지 · 옛 책 줄 그대로 +④ 연결 사본 `단가.json` 자원 줄 = 기준 시점 원천 ⑤ [단가 갱신] = 값만(사본 재적용 없음) · + [재생성] = 따로 누를 때만 ⑥ 미리 보기 꼴. +""" + +from __future__ import annotations + +import asyncio +import copy +import json +from uuid import uuid4 + +from B09_Estimation import B09_Estimation_Linked as linked +from B09_Estimation import B09_Estimation_Prices as prices +from B09_Estimation import B09_Estimation_Refresh as refresh +from B09_Estimation import B09_Estimation_Router as router +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Item_Prices as item_prices +from M02_MasterTemplete import M02_Item_Source as source +from M02_MasterTemplete import M02_Sangeun_Copy as skc +from resources.tester.test_145_2_b09_labor_calc import _project, _snap +from resources.tester.test_795_1_sk_copy import _changed, _fake, _live +from resources.tester.test_795_2_sc_copy import LB, PCT, _double, _labor +from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 + +MAN = "LB000002" + + +def _json(value): + return json.loads(json.dumps(value, ensure_ascii=False, default=str)) + + +def _m01(monkeypatch, rows=(), drop=()): + """M01 바꿈 흉내 — 색인(`_fake`) + 파일 묶음(단가 묶음이 읽음) 둘 다 · 정본 안 고침.""" + files = m01.loaded()[0] + fake = _fake(monkeypatch, rows, drop) + new = {str(r["키"]): r for r in rows} + out = {} + for name, data in files.items(): + slot = m01.items_key(data) + got = [new.get(str(r.get("키")), r) for r in data.get(slot) or []] + out[name] = {**data, slot: [r for r in got if str(r.get("키")) not in drop]} + monkeypatch.setattr(m01, "loaded", lambda: (out, fake)) + + +def _amount(labor_rows, bundle): + got = item_prices.calc(labor_rows, bundle, {}, {}) + return _json(got["반영합"]) + + +def test_bundle_is_m02_copy_then_frozen(world, monkeypatch): + """① 사본 묶음 = M02 사본 줄 · M01 로직 · 노임 바뀜 · 지워짐에도 금액 그대로.""" + me = world["personal"] + doc = _labor(me, [{"로직": PCT, "수량": {"손": 2}}]) + assert skc.has(doc) + rows = source.labor_doc_rows(doc) + was = _amount(rows, source.price_bundle(rows, me)) # 지금 M01 = 사본 만든 때 + bundle = source.price_bundle(rows, me) + _m01(monkeypatch, [_changed(_live().logic(PCT)), _double(LB)]) + assert _amount(rows, bundle) == was # 굳힌 묶음 = M01 안 읽음 + late = source.price_bundle(source.labor_doc_rows(doc), me) # 구조물 지정이 M01 바뀐 뒤 + assert _amount(rows, late) == was # M02 사본 = 만든 때 + old = {k: v for k, v in doc.items() if k != skc.KEY} # ② 사본 없는 옛 문서 = 지금 M01 + old_rows = source.labor_doc_rows(old) + assert _amount(old_rows, source.price_bundle(old_rows, me)) != was + + +def test_deleted_m01_key_kept_in_copy(world, monkeypatch): + """① M01 에서 지워진 로직 키 = 사본 줄 그대로(`사본_남은줄.json`) · 같은 금액.""" + me = world["personal"] + doc = _labor(me, [{"로직": PCT, "수량": {"손": 1}}]) + rows = source.labor_doc_rows(doc) + was = _amount(rows, source.price_bundle(rows, me)) + _m01(monkeypatch, drop=[PCT]) + late = source.price_bundle(rows, me) + assert [r["키"] for r in late[item_prices.COPY_FILE]["줄"]] == [PCT] + assert _amount(rows, late) == was + + +def test_copy_rows_order_and_sk_origin(): + """사본 줄 모음 — 앞 문서 먼저 · 굳힌 SK 는 `원본` 사본.""" + a = {skc.KEY: {"로직": [{"키": "GC1", "v": 1}], "단가": [{"키": "LB1", "v": 1}]}} + b = {"원본": {skc.KEY: {"로직": [{"키": "GC1", "v": 2}, {"키": "GC2"}], "단가": []}}} + got = source.copy_rows([a, b, {"x": 1}, None]) + assert [(r["키"], r.get("v")) for r in got] == [("GC1", 1), ("LB1", 1), ("GC2", None)] + assert item_prices.collect(["GF000219"], over=[]) == item_prices.collect(["GF000219"]) + + +def _row(key, price): + row = copy.deepcopy(prices._row(_live(), key)) + row["값"] = price + return item_prices.to_json(row) + + +def test_book_new_rows_use_copy_basis(): + """③ [저장] 새 줄 = 사본 기준 시점 값 · 없는 자원 = 지금 값 + 표지 · 옛 책 줄 그대로.""" + frozen = {LB: _row(LB, 111)} + first = _json(prices.extend_book(None, [LB], frozen)) + assert first["rows"][LB]["price"] == 111 and first["now_keys"] == [] + both = _json(prices.extend_book(first, [LB, MAN], frozen)) + assert both["rows"][LB] == first["rows"][LB] + assert both["rows"][MAN]["price"] == float(prices._row(_live(), MAN)["값"]) + assert both["now_keys"] == [MAN] + inputs = prices.build_inputs(None, [LB, MAN], None, frozen) + assert [r["price"] for r in inputs["labor"]] == [111, both["rows"][MAN]["price"]] + assert inputs["now"] == [MAN] and prices.build_inputs(both, [LB, MAN])["now"] == [MAN] + old = _json(prices.refresh_book([LB, MAN])) # 옛 책(사본 규칙 전) = 줄 · 값 그대로 + again = _json(prices.extend_book(old, [LB, MAN], frozen)) + assert again["rows"] == old["rows"] and again["now_keys"] == [] + fresh = _json(prices.refresh_book([LB, MAN], both)) # [단가 갱신] = 지금 값 · 표지 없음 + assert fresh["rows"][LB]["price"] != 111 and fresh["now_keys"] == [] + + +def test_frozen_rows_from_linked_copy(tmp_path): + """④ 연결 사본 `단가.json` 자원 줄 = 기준 시점 원천(자원 표만).""" + root = _project(tmp_path / "proj", [(10.0, "SS000052")]) + _snap(root, "pipe@10.0", "SS000052", [{"로직": "GF000219", "수량": {"손": 1}}], {}) + got = linked.frozen_rows(root) + assert got and all(k[:2] in skc.RES for k in got) + bundle = json.loads( + (linked.structures_dir(root) / "pipe@10.0" / "단가.json").read_text("utf-8") + ) + inside = {r["키"]: r for d in bundle.values() if isinstance(d, dict) for r in d.get("줄") or []} + assert all(inside[k] == row for k, row in got.items()) + + +def _router(monkeypatch, tmp_path, calls): + async def root(_): + return tmp_path + + async def demand(*_): + return [LB] + + async def payload(*_a, **_k): + return {"user": {}, "notes": [], "sheets": {}} + + monkeypatch.setattr(router, "_project_root", root) + monkeypatch.setattr(router, "_demand", demand) + monkeypatch.setattr(router, "payload", payload) + monkeypatch.setattr(router.store, "read_book", lambda _r: None) + monkeypatch.setattr(router.store, "write_book", lambda _r, b: calls.append(("book", b))) + monkeypatch.setattr( + router.refresh, + "reapply_changed", + lambda *_a: calls.append("regen") or {"다시": ["A"], "막힘": []}, + ) + + +def test_price_refresh_default_regen_separate(monkeypatch, tmp_path): + """⑤ [단가 갱신] = 값만(사본 재적용 없음) · [재생성] = job 줄 때만.""" + calls = [] + _router(monkeypatch, tmp_path, calls) + out = asyncio.run(router.refresh_prices(uuid4(), {"user": {}}, {})) + assert "regen" not in calls and calls[-1][0] == "book" + assert out["notes"][0].startswith("단가 갱신 — ") + out = asyncio.run(router.refresh_prices(uuid4(), {"user": {}, "job": refresh.REGEN}, {})) + assert calls.count("regen") == 1 + assert out["notes"][0].startswith("재생성 — 구조물 사본 1 다시 가져옴") + + +def test_price_preview_shape(): + """⑥ 미리 보기 — 바뀐 자원(전 · 후) · 총괄 전 → 후 · 사본 말 없음.""" + old = _json(prices.refresh_book([LB, MAN])) + old["rows"][LB] = {**old["rows"][LB], "price": 1} + new = _json(prices.refresh_book([LB, MAN], old)) + sheets = lambda g: {"total": {"subtotals": {"grand": g}}} # noqa: E731 + got = refresh.price_preview(old, new, sheets(10), sheets(20), 3) + assert got["할일"] == "단가갱신" and (got["전"], got["후"], got["고친칸"]) == (10, 20, 3) + assert [(r["키"], r["전"]) for r in got["바뀐자원"]] == [(LB, 1)] + assert got["바뀐자원"][0]["후"] == new["rows"][LB]["price"] + + +def test_migrate_view_b09_copy(tmp_path): + """옮기기 도구 B09 대조(보기만) — 구조물 사본 = 「그대로」 · 전 금액 · 파일 안 바꿈.""" + from M02_MasterTemplete import M02_Copy_Migrate as mig + + root = _project(tmp_path / "proj", [(10.0, "SS000052")]) + _snap(root, "pipe@10.0", "SS000052", [{"로직": "GF000219", "수량": {"손": 1}}], {}) + folder = linked.structures_dir(root) / "pipe@10.0" + before = {p.name: p.read_bytes() for p in folder.iterdir()} + got = mig.b09_check("구조물사본", folder) + assert got["상태"] == "그대로" and got["전"]["기준합"]["계"] > 0 + assert {p.name: p.read_bytes() for p in folder.iterdir()} == before