feat(795-4): B09 사본 규칙 — 만든 때 그대로 · [단가 갱신] 값만 · [재생성] 따로 · 시점 섞임 고침

- 구조물 · 서고 사본 단가 묶음 = 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 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 11:54:10 +09:00
co-authored by Claude Opus 5.5
parent 16d7eede50
commit 3e1dc294b2
14 changed files with 560 additions and 99 deletions
+26 -5
View File
@@ -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)
+49 -15
View File
@@ -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 뒤 — 사전 차례대로 셈
}
+47 -1
View File
@@ -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]]:
"""연결된 구조물 사본 중 원본(층 · 마스터)이 바뀐 것 `[{폴더, 이름}]`."""
+38 -20
View File
@@ -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")
+87 -41
View File
@@ -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<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(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<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"));
open();
},
true,
);
+4 -3
View File
@@ -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,
),
});
@@ -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 {
@@ -287,7 +287,7 @@ function blockCard(ctx: TabContext, block: Row, noOf: Record<string, number>): H
? [
edit,
createText(
`품 · 단가 칸을 두 번 눌러 고침 · 색 칸 = ${TAG}(이 공사만 · [업데이트] 뒤에도 남음) · 우클릭 [${BACK}으로] · [저장] 때 서버가 다시 셈`,
`품 · 단가 칸을 두 번 눌러 고침 · 색 칸 = ${TAG}(이 공사만 · [단가 갱신] · [재생성] 뒤에도 남음) · 우클릭 [${BACK}으로] · [저장] 때 서버가 다시 셈`,
"muted",
),
]
+70 -2
View File
@@ -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))
+24 -3
View File
@@ -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
+17 -1
View File
@@ -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())
@@ -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():
@@ -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
+189
View File
@@ -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