**단수 처리를 「출력 위치」에 바인딩** (`B09_Estimation_Rounding.py`) - `단수처리_규칙.md` §1 — 같은 수량×단가라도 **내역서 본체는 절사, 집계표는 반올림**. 함수가 항목이 아니라 **표 종류**에 붙음. 자리를 안 대고 부르는 길을 안 둠(기본값 없음). - ⚠ **집계표와 본체의 합이 어긋나는 것이 정상** — 「합계가 1원 틀린다」에 계산을 고치지 않도록 `summary_vs_body_gap()` 으로 차이를 값으로 드러내고 화면 문구를 상수로 둠. - 계산은 전정밀, 자르는 것은 표 그리는 자리에서. 값은 원문에서 복사 안 하고 문서를 가리킴. **단가 계층 — 표를 세 벌 만들지 않음** (`B09_Estimation_PriceBook.py`) - 「제목 + 상세」 한 쌍 + 종류 구분. 층은 `S 중기취득가 → X 시간당사용료 → B 일위대가 → D 단가산출`, 바닥은 `M 자재`·`L 노임`, `W 일식·견적`은 **단가 0**(무대처리, 8-7 ㉡). - 금액은 어느 층이든 **재료·노무·경비 3분할**, 합계 = 셋의 합. - **단가 슬롯 6개** — 번호 고정, **이름은 프로젝트 설정**. 기본 채택 6번(`JUKNM=6`). 슬롯별 **근거 쪽수**(STC `PG_` 열) 자리도 둠. - 안전장치: 채택 슬롯이 비면 **0 으로 안 때우고 멈춤** · 없는 코드는 `unmatched_codes()` 로 **목록으로 냄** · 참조 순환 감지. **프로젝트 스냅샷** (`B09_Estimation_Storage.py`) - 채택 단가를 `<project_root>/B09_Estimation/v1/` 에 사본으로. 기준자료가 갱신돼도 **옛 프로젝트 결과가 안 바뀜**. `dataset_version` 은 `dataset_id`+`effective_date`+ `sha256` **세 쪽**. 사본이 바뀌면 지문 불일치로 멈춤. 차수(당초·1~3차) 자리 비워 둠. 자체검증 — 신규 13건 포함 `pytest tmp/tests/ -q` **85 passed** · ruff 통과 · 파일 최대 534줄. 중기 실측값(`X00005` 96,829 = 노 55,700 + 재 18,001 + 경 23,128)으로 3분할 유지·층 쌓임 확인. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
216 lines
7.5 KiB
Python
216 lines
7.5 KiB
Python
"""B09 원가계산 — 프로젝트 저장 (PLAN 9-2).
|
|
|
|
**기준자료는 파일, 프로젝트가 채택한 단가는 사본**이다 (사용자 확정 2026-09-07).
|
|
|
|
- 기준자료(요율·품셈·노임·자재) = `resources/data_cost_input_value/` 의 버전 파일.
|
|
갱신해도 **옛 프로젝트 결과가 안 바뀌어야** 한다.
|
|
- 그래서 프로젝트는 **그때 쓴 단가를 사본으로** 영구저장소
|
|
`<project_root>/B09_Estimation/v1/` 에 남긴다. 3년 뒤 열어도 같은 금액이 나온다.
|
|
상용 프로그램도 같은 방식이다 — STmate 는 단가표(`COSTN`)·제비율표(`RATE`)를
|
|
**프로젝트 파일 안에 동봉**한다.
|
|
|
|
`dataset_version` 은 **세 쪽**을 다 적는다 — `dataset_id` · `effective_date` · `sha256`.
|
|
파일명만 적으면 같은 날짜로 재생성된 파일과 구분이 안 된다.
|
|
|
|
⚠ **차수 슬롯** — 실무 STC 는 단가를 `TAMT1~4`(당초·1차·2차·3차 변경) 네 벌로 들고
|
|
있고, 파일마다 살아 있는 슬롯이 다르다(계류보전 변경본은 3차). 지금 신설 설계에는
|
|
안 걸리지만 저장 구조를 정하는 중이라 **차수 자리를 비워 둔다**.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from dataclasses import dataclass
|
|
from decimal import Decimal
|
|
from typing import Any
|
|
|
|
from B09_Estimation.B09_Estimation_PriceBook import (
|
|
DEFAULT_SLOT_NAMES,
|
|
PriceBook,
|
|
PriceDetail,
|
|
PriceKind,
|
|
PriceTitle,
|
|
)
|
|
|
|
#: 영구저장소 안 B09 몫. `common_util_storage.PROJECT_STORAGE_LAYOUT_V2` 와 같은 자리.
|
|
SNAPSHOT_SUBPATH = ("B09_Estimation", "v1")
|
|
SNAPSHOT_FILE = "price_book.json"
|
|
MANIFEST_FILE = "_manifest.json"
|
|
SNAPSHOT_SCHEMA_VERSION = "1.0"
|
|
|
|
#: 차수 — 당초(1)만 쓴다. 변경설계가 붙으면 2·3·4 로 늘어난다.
|
|
DEFAULT_REVISION = 1
|
|
|
|
|
|
class SnapshotError(OSError):
|
|
"""스냅샷 읽기·쓰기 실패."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DatasetVersion:
|
|
"""어느 판 기준자료로 계산했나 — 세 쪽을 다 적는다."""
|
|
|
|
dataset_id: str
|
|
effective_date: str
|
|
sha256: str
|
|
|
|
def as_dict(self) -> dict[str, str]:
|
|
return {
|
|
"dataset_id": self.dataset_id,
|
|
"effective_date": self.effective_date,
|
|
"sha256": self.sha256,
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, raw: dict[str, str]) -> DatasetVersion:
|
|
return cls(
|
|
dataset_id=raw.get("dataset_id", ""),
|
|
effective_date=raw.get("effective_date", ""),
|
|
sha256=raw.get("sha256", ""),
|
|
)
|
|
|
|
|
|
def snapshot_dir(project_root: str) -> str:
|
|
path = os.path.abspath(os.path.join(project_root, *SNAPSHOT_SUBPATH))
|
|
root = os.path.abspath(project_root)
|
|
if os.path.commonpath((root, path)) != root:
|
|
raise SnapshotError("스냅샷 경로가 프로젝트 루트를 벗어났습니다.")
|
|
return path
|
|
|
|
|
|
def _sha256_of(text: str) -> str:
|
|
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _title_to_dict(title: PriceTitle) -> dict[str, Any]:
|
|
return {
|
|
"code": title.code,
|
|
"kind": title.kind.value,
|
|
"name": title.name,
|
|
"spec": title.spec,
|
|
"unit": title.unit,
|
|
"slots": [None if v is None else str(v) for v in title.slots],
|
|
"slot_pages": list(title.slot_pages),
|
|
"adopted_slot": title.adopted_slot,
|
|
}
|
|
|
|
|
|
def _title_from_dict(raw: dict[str, Any]) -> PriceTitle:
|
|
return PriceTitle(
|
|
code=raw["code"],
|
|
kind=PriceKind(raw["kind"]),
|
|
name=raw.get("name", ""),
|
|
spec=raw.get("spec", ""),
|
|
unit=raw.get("unit", ""),
|
|
slots=[None if v is None else Decimal(str(v)) for v in raw.get("slots", [])],
|
|
slot_pages=list(raw.get("slot_pages", [])),
|
|
adopted_slot=int(raw.get("adopted_slot", 6)),
|
|
)
|
|
|
|
|
|
def _detail_to_dict(detail: PriceDetail) -> dict[str, Any]:
|
|
return {
|
|
"parent_code": detail.parent_code,
|
|
"ref_code": detail.ref_code,
|
|
"quantity": str(detail.quantity),
|
|
"note": detail.note,
|
|
"percent_of_parent": (
|
|
None if detail.percent_of_parent is None else str(detail.percent_of_parent)
|
|
),
|
|
}
|
|
|
|
|
|
def _detail_from_dict(raw: dict[str, Any]) -> PriceDetail:
|
|
percent = raw.get("percent_of_parent")
|
|
return PriceDetail(
|
|
parent_code=raw["parent_code"],
|
|
ref_code=raw["ref_code"],
|
|
quantity=Decimal(str(raw.get("quantity", "0"))),
|
|
note=raw.get("note", ""),
|
|
percent_of_parent=None if percent is None else Decimal(str(percent)),
|
|
)
|
|
|
|
|
|
def save_price_book(
|
|
project_root: str,
|
|
book: PriceBook,
|
|
*,
|
|
dataset_versions: list[DatasetVersion],
|
|
revision: int = DEFAULT_REVISION,
|
|
) -> str:
|
|
"""프로젝트가 채택한 단가를 사본으로 남긴다. 저장한 파일 경로를 돌려준다.
|
|
|
|
기준자료가 갱신돼도 이 사본이 있어 **옛 프로젝트 결과가 안 바뀐다**.
|
|
"""
|
|
directory = snapshot_dir(project_root)
|
|
os.makedirs(directory, exist_ok=True)
|
|
|
|
payload = {
|
|
"schema_version": SNAPSHOT_SCHEMA_VERSION,
|
|
"revision": revision,
|
|
"slot_names": list(book.slot_names),
|
|
"titles": [_title_to_dict(t) for t in book.titles.values()],
|
|
"details": [_detail_to_dict(d) for rows in book.details.values() for d in rows],
|
|
}
|
|
text = json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)
|
|
|
|
path = os.path.join(directory, SNAPSHOT_FILE)
|
|
with open(path, "w", encoding="utf-8", newline="\n") as handle:
|
|
handle.write(text)
|
|
|
|
manifest = {
|
|
"schema_version": SNAPSHOT_SCHEMA_VERSION,
|
|
"revision": revision,
|
|
"files": [
|
|
{
|
|
"file": SNAPSHOT_FILE,
|
|
"sha256": _sha256_of(text),
|
|
"size_bytes": len(text.encode("utf-8")),
|
|
}
|
|
],
|
|
# 어느 판 기준자료로 계산했나 — 세 쪽을 다 적는다(9-2).
|
|
"dataset_versions": [v.as_dict() for v in dataset_versions],
|
|
}
|
|
with open(
|
|
os.path.join(directory, MANIFEST_FILE), "w", encoding="utf-8", newline="\n"
|
|
) as handle:
|
|
json.dump(manifest, handle, ensure_ascii=False, indent=2, sort_keys=True)
|
|
|
|
return path
|
|
|
|
|
|
def load_price_book(project_root: str) -> tuple[PriceBook, list[DatasetVersion]]:
|
|
"""사본을 그대로 되읽는다. 파일이 손상됐으면 조용히 넘기지 않고 멈춘다."""
|
|
directory = snapshot_dir(project_root)
|
|
path = os.path.join(directory, SNAPSHOT_FILE)
|
|
if not os.path.exists(path):
|
|
raise SnapshotError(f"단가 스냅샷이 없습니다: {path}")
|
|
|
|
with open(path, encoding="utf-8") as handle:
|
|
text = handle.read()
|
|
payload = json.loads(text)
|
|
|
|
manifest_path = os.path.join(directory, MANIFEST_FILE)
|
|
versions: list[DatasetVersion] = []
|
|
if os.path.exists(manifest_path):
|
|
with open(manifest_path, encoding="utf-8") as handle:
|
|
manifest = json.load(handle)
|
|
recorded = next(
|
|
(f.get("sha256") for f in manifest.get("files", []) if f.get("file") == SNAPSHOT_FILE),
|
|
None,
|
|
)
|
|
if recorded and recorded != _sha256_of(text):
|
|
raise SnapshotError(
|
|
f"단가 스냅샷 지문이 매니페스트와 다릅니다 — 파일이 바뀌었습니다: {path}"
|
|
)
|
|
versions = [DatasetVersion.from_dict(v) for v in manifest.get("dataset_versions", [])]
|
|
|
|
book = PriceBook(slot_names=tuple(payload.get("slot_names", DEFAULT_SLOT_NAMES)))
|
|
for raw in payload.get("titles", []):
|
|
book.add_title(_title_from_dict(raw))
|
|
for raw in payload.get("details", []):
|
|
book.add_detail(_detail_from_dict(raw))
|
|
return book, versions
|