Merge branch 'sub_laptop_2' of C:/Aislo_wt/sub_laptop_2 into main_laptop_1
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 뒤 — 사전 차례대로 셈
|
||||
}
|
||||
|
||||
@@ -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]]:
|
||||
"""연결된 구조물 사본 중 원본(층 · 마스터)이 바뀐 것 `[{폴더, 이름}]`."""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user