- B09_Estimation_Edits: 고친 값 한 벌 — 기본 조립(cached_build) 복사본에 얹음(캐시 키에 고친 값) · 기본 벌은 그대로(골든셋 무관)
· 얹지 못한 값은 edit_skipped 로 남김 · 값 없는 슬롯은 받지 않음(422 + 까닭)
- 새 라우터 B09_Estimation_Router_Edits(GET/PUT /estimation/edits) — Router.py 는 _build_for 가 고친 값을 얹게만 바꿈
- 화면: 줄마다 채택 슬롯 고르개 · 일괄(변동없음/1~5 단가/최소단가) · 고친 줄 「사용자」 + ↺(지우면 계산값으로)
- 곁: 사용자 식 셈(B09_Estimation_Expression — ROUND·ROUNDDOWN·INT·SQRT, eval 없음)과 본표 편집 칸(UI_DetailEdit)을 먼저 둠 — 서버 편집 문이 서기 전까지 잠자 있음
- 검증: 시험 1656 통과 · ORCA 채택 저장 → 「사용자」·↺ → ↺ 뒤 고친 값 {} 로 복구 · 값 없는 슬롯 422 「경유: 1번 원천에 값이 없어」
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
115 lines
5.0 KiB
Python
115 lines
5.0 KiB
Python
"""B09 원가계산 — **사용자가 고친 값** 한 벌 (PLAN 12장 2차 · 2026-09-14 브레인 판정).
|
|
|
|
전부 **프로젝트 단위**다 — 라이브러리 층을 만들지 않는다(B09 는 그 프로젝트의 내역·단가라 다른
|
|
프로젝트로 가져갈 것이 없음. 양식·라이브러리는 B08 구조물도에만 있음).
|
|
|
|
저장 자리 산출 조건 `estimation.edits` — 한 파일 한 구획
|
|
얹는 자리 서버가 기본 조립(`cached_build`) 뒤 **복사본에** 고친 값을 얹고 다시 계산
|
|
(캐시 키에 고친 값이 들어감 — 브라우저 값을 받아 적지 않음)
|
|
되돌리기 고친 값을 지우면 계산값으로 돌아감(B08 집계표 ↺ 와 같은 꼴)
|
|
|
|
⚠ 기본 조립은 **안 바꾼다** — 고친 값이 없으면 `cached_build` 그 벌 그대로라 골든셋이 그대로 돈다.
|
|
⚠ 얹지 못한 고친 값(단가표에서 사라진 코드·값 없는 슬롯)은 조용히 버리지 않고 `edit_skipped` 에 남긴다.
|
|
|
|
구획
|
|
adopted_slots {자재 코드: 슬롯 번호 1~6} 자재단가대비표 채택 바꾸기(`wM_Boxa`)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import json
|
|
from functools import lru_cache
|
|
from typing import Any
|
|
|
|
from B09_Estimation.B09_Estimation_PriceBook import PRICE_SLOT_COUNT, PriceKind
|
|
from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build
|
|
|
|
EDITS_KEY = "edits"
|
|
#: 화면·근거 표기 — B08 구조물도 집계표와 같은 말.
|
|
USER_SOURCE = "user"
|
|
SECTIONS = ("adopted_slots",)
|
|
|
|
|
|
class EditError(ValueError):
|
|
"""고친 값을 받을 수 없음 — 저장하지 않고 까닭을 돌려줌."""
|
|
|
|
|
|
def normalize(raw: Any) -> dict[str, dict[str, Any]]:
|
|
"""저장본을 아는 구획만 남긴 모양으로 — 모르는 구획·빈 구획은 버림."""
|
|
edits: dict[str, dict[str, Any]] = {}
|
|
if not isinstance(raw, dict):
|
|
return edits
|
|
for section in SECTIONS:
|
|
values = raw.get(section)
|
|
if isinstance(values, dict) and values:
|
|
edits[section] = {str(key): value for key, value in values.items()}
|
|
return edits
|
|
|
|
|
|
def edits_key(raw: Any) -> str:
|
|
"""캐시 키 — 같은 고친 값이면 같은 글(차례 무관)."""
|
|
edits = normalize(raw)
|
|
return json.dumps(edits, sort_keys=True, ensure_ascii=False) if edits else ""
|
|
|
|
|
|
def _apply_adopted_slots(build: UnitPriceBuild, values: dict[str, Any]) -> None:
|
|
for code, slot in values.items():
|
|
title = build.book.titles.get(code)
|
|
number = int(slot) if str(slot).isdigit() else 0
|
|
if title is None or title.kind is not PriceKind.MATERIAL:
|
|
build.edit_skipped.append(f"채택 슬롯 {code} — 단가표에 없는 자재")
|
|
continue
|
|
if not 1 <= number <= PRICE_SLOT_COUNT or title.slots[number - 1] is None:
|
|
build.edit_skipped.append(f"채택 슬롯 {code} — {slot}번 슬롯에 값이 없음")
|
|
continue
|
|
title.adopted_slot = number
|
|
|
|
|
|
def apply_edits(build: UnitPriceBuild, edits: dict[str, dict[str, Any]]) -> UnitPriceBuild:
|
|
"""고친 값을 얹은 **새 벌** — 기본 벌(캐시)은 건드리지 않음."""
|
|
edited = copy.deepcopy(build)
|
|
edited.edit_skipped = []
|
|
_apply_adopted_slots(edited, edits.get("adopted_slots", {}))
|
|
return edited
|
|
|
|
|
|
@lru_cache(maxsize=8)
|
|
def edited_build(args: tuple, key: str) -> UnitPriceBuild:
|
|
"""프로젝트 조립 한 벌 — `args` 는 `cached_build` 인자 그대로, `key` 는 `edits_key`."""
|
|
base = cached_build(*args)
|
|
if not key:
|
|
return base
|
|
return apply_edits(base, json.loads(key))
|
|
|
|
|
|
def validate_change(build: UnitPriceBuild, section: str, key: str, value: Any) -> Any:
|
|
"""고친 값 하나를 받기 전 검사 — 받을 수 있는 모양으로 돌려주거나 `EditError`."""
|
|
if section not in SECTIONS:
|
|
raise EditError(f"모르는 구획: {section}")
|
|
if section == "adopted_slots":
|
|
title = build.book.titles.get(key)
|
|
if title is None or title.kind is not PriceKind.MATERIAL:
|
|
raise EditError(f"단가표에 없는 자재입니다: {key}")
|
|
number = int(value) if str(value).isdigit() else 0
|
|
if not 1 <= number <= PRICE_SLOT_COUNT or title.slots[number - 1] is None:
|
|
raise EditError(f"{title.name}: {value}번 원천에 값이 없어 채택할 수 없습니다")
|
|
return number
|
|
return value
|
|
|
|
|
|
def merge_changes(
|
|
stored: Any, build: UnitPriceBuild, changes: list[dict[str, Any]]
|
|
) -> dict[str, dict[str, Any]]:
|
|
"""바꿀 것 여럿을 한 번에 — `value` 가 `None` 이면 그 칸을 지움(↺ 계산값으로)."""
|
|
edits = normalize(stored)
|
|
for change in changes:
|
|
section, key = str(change.get("section") or ""), str(change.get("key") or "")
|
|
if not key:
|
|
raise EditError("고칠 칸이 비었습니다")
|
|
if change.get("value") is None:
|
|
edits.get(section, {}).pop(key, None)
|
|
continue
|
|
edits.setdefault(section, {})[key] = validate_change(build, section, key, change["value"])
|
|
return normalize(edits)
|