Files
Aislo/B09_Estimation/B09_Estimation_BasisSheet.py
eomsangdonandClaude Opus 5 89effeca07 feat(B09): 산출기초 — 줄에 달린 근거를 한 장으로 접음
별표2 (5)(가) 열셋째. 근거는 줄마다 이미 있었고 묶는 자리만 없었음.
원가계산에 「산출기초」 탭으로 세움. 넷으로 접음 —
① 어느 판으로 계산했나(데이터 기준일·지문) ② 무엇을 골랐나(고른 값만)
③ 공종마다 무엇을 근거로 했나(줄 문구 그대로) ④ 못 채운 자리(0 으로 안 때운 자리).

- 판 목록은 장부(_manifest.json)를 그대로 읽음 — 목록을 따로 적으면 한쪽만 고쳐짐.
- ⚠ 여기서 값을 다시 계산하지 않음 — 금액 칸이 아예 없음(두 벌 방지, 시험으로 못 박음).

⇒ 「설계서 구성」의 산출기초가 반쪽 → 있음. 법이 정한 13 중 9 가 서고,
  우리가 더 낼 것은 공사설명서 하나(서식·설계하중 표기는 사용자에게 받아야 함).

곁들여: 자재총괄의 「미분류」를 「미정(발주기관 결정)」으로 고침 — 발주기관이 정할
자리인데 우리가 못 만든 것처럼 읽히던 문구(V-14). 값·판정은 그대로.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 00:06:22 +09:00

165 lines
6.8 KiB
Python

"""B09 — **산출기초** 한 장 (별표2 (5)(가) 열셋째).
**무엇인가** — 새로 세는 장이 아니다. **이미 줄마다 달려 있는 근거를 한 장으로 접는** 자리다.
법이 설계서 맨 뒤에 「산출기초」를 두라 했는데, 우리는 근거를 **줄에만** 달고 있어
설계서로 묶을 때 그 문구들을 사람이 손으로 모아야 했다.
**넷으로 접는다**
① 어느 판으로 계산했나 — 품셈·요율·노임·유가·기계·자재 데이터의 판과 기준일
② 무엇을 골랐나 — 프로젝트가 고른 값(범위 계수·장비·유가 지역·수송·품 할증 …)
③ 공종마다 무엇을 근거로 했나 — 일위대가 줄에 달린 근거 문구 그대로
④ 못 채운 자리 — 값이 안 선 사유(0 으로 때우지 않은 자리)
⚠ **여기서 값을 다시 계산하지 않는다.** 계산은 일위대가·내역서가 하고 이 장은 **모으기만**
한다 — 두 벌로 세면 설계서와 내역서가 조용히 갈린다.
"""
from __future__ import annotations
import json
import os
from typing import Any
from B09_Estimation.B09_Estimation_PriceBook import PriceKind
_MANIFEST = ("resources", "data_cost_input_value", "_manifest.json")
_MASTER_DIR = ("resources", "data_work_item_master")
SHEET_NOTE = (
"산출기초는 새로 세는 장이 아니라 **줄에 달린 근거를 한 장으로 모은 것**입니다 —"
" 값은 일위대가·내역서가 낸 그대로입니다."
).replace("**", "")
def _project_root() -> str:
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _read_json(*parts: str) -> Any:
with open(os.path.join(_project_root(), *parts), encoding="utf-8") as handle:
return json.load(handle)
def dataset_versions() -> list[dict[str, str]]:
"""① 어느 판으로 계산했나 — 값층 데이터의 판과 기준일.
⚠ **장부(`_manifest.json`)를 그대로 읽는다** — 여기서 목록을 따로 적으면 판이 바뀔 때
한쪽만 고쳐진다.
"""
rows: list[dict[str, str]] = []
manifest = _read_json(*_MANIFEST)
for entry in manifest.get("files") or []:
rows.append(
{
"dataset_id": str(entry.get("dataset_id") or ""),
"file": str(entry.get("file") or ""),
"effective_date": str(entry.get("effective_date") or ""),
"sha256": str(entry.get("sha256") or "")[:12],
}
)
# 공종 마스터는 다른 폴더의 장부라 함께 싣는다 — 품셈 원문을 정규화한 판이다.
master_dir = os.path.join(_project_root(), *_MASTER_DIR)
if os.path.isdir(master_dir):
for name in sorted(os.listdir(master_dir)):
if name.startswith("work_item_master_") and name.endswith(".json"):
rows.append(
{
"dataset_id": "work_item_master",
"file": name,
"effective_date": name[len("work_item_master_") : -len(".json")],
"sha256": "",
}
)
return rows
def chosen_conditions(settings: dict[str, Any] | None) -> list[dict[str, str]]:
"""② 무엇을 골랐나 — **고른 것만** 싣는다(안 고른 칸은 기본값이라 줄이 안 선다)."""
picked = settings or {}
rows: list[dict[str, str]] = []
for key, label in (
("misc_material_percent", "공구손료·잡재료 (주재료비의 %)"),
("fuel_region", "유가 지역(시도코드)"),
("transport_distance_km", "기계 수송 거리(편도 ㎞)"),
("transport_road", "수송 도로 구분"),
):
value = str(picked.get(key) or "").strip()
if value:
rows.append({"item": label, "value": value})
for code, choice in sorted((picked.get("range_factor_choices") or {}).items()):
rows.append({"item": f"범위 계수 {code}", "value": str(choice)})
for code, choice in sorted((picked.get("machine_choices") or {}).items()):
rows.append({"item": f"장비 규격 {code}", "value": str(choice)})
from B09_Estimation.B09_Estimation_LaborSurcharge import parse_choices, total_percent
surcharge = parse_choices(picked.get("labor_surcharge"))
if surcharge:
percent, reasons = total_percent(surcharge)
rows.append({"item": f"품 할인·할증 합계 {percent:g}%", "value": " · ".join(reasons)})
return rows
def work_item_basis(build: Any) -> list[dict[str, Any]]:
"""③ 공종마다 무엇을 근거로 했나 — **줄에 달린 문구를 그대로** 모은다."""
rows: list[dict[str, Any]] = []
for code, title in sorted(build.book.titles.items()):
if title.kind is not PriceKind.UNIT_PRICE:
continue
notes = [
detail.note.strip()
for detail in build.book.details.get(code, [])
if detail.note and detail.note.strip()
]
work_item_code = code[2:].split("#")[0]
source = build.factor_sources.get(work_item_code)
if source and source not in notes:
notes.append(str(source))
if not notes:
continue
rows.append(
{
"code": code,
"name": title.name,
"unit": title.unit,
"notes": notes,
}
)
return rows
def open_gaps(build: Any) -> list[dict[str, str]]:
"""④ 못 채운 자리 — **0 으로 때우지 않은 자리**를 사유째 모은다."""
rows: list[dict[str, str]] = []
for code, reason in sorted(build.component_gaps.items()):
rows.append({"kind": "성분 못 채움", "code": code, "reason": str(reason)})
for code, section in sorted(build.basis_missing.items()):
rows.append({"kind": "밑수 미확보", "code": code, "reason": str(section)})
for note in build.transport_notes:
rows.append({"kind": "수송비", "code": "FP-10-04", "reason": str(note)})
for machine in build.incomplete_machines:
rows.append({"kind": "기계 층 미완성", "code": machine, "reason": "손료·운전경비 미확보"})
return rows
def basis_sheet(build: Any, settings: dict[str, Any] | None = None) -> dict[str, Any]:
"""산출기초 한 장 — 넷을 접어 낸다."""
versions = dataset_versions()
conditions = chosen_conditions(settings)
items = work_item_basis(build)
gaps = open_gaps(build)
return {
"note": SHEET_NOTE,
"dataset_versions": versions,
"chosen_conditions": conditions,
"work_items": items,
"gaps": gaps,
"summary": (
f"데이터 {len(versions)} 판 · 고른 값 {len(conditions)} · "
f"근거가 달린 공종 {len(items)} · 못 채운 자리 {len(gaps)}"
),
}