산림품셈 10-4 · 건설품셈 8-1-3 은 회당 단가 산출식만 주고 대수·횟수를 정하지 않음(전수 확인). 지어내지 않고 설계 입력으로 닫음. · parse_trips — 비거나 0 이면 없음. 기본값을 두지 않음(1 회로 안 때움) · transport_amount — 회수 × 회당 단가. 회수가 없으면 None(0 원으로 안 채움) · TRIPS_NOTE — 「원문에 공식 없음 · 설계 입력 · 비우면 금액이 안 섬」 사유 · 도는 자리 넷 — 저장(PUT estimation/factors) · 산출 조건 ② · 산출 조건 화면(회당 N원 × M회) · 비면 transport_notes 에 사유가 붙어 못 채운 자리로 뜸 · 화면에 있던 「회수는 여기서 안 정합니다」 안내는 칸으로 바뀌어 걷음 잴 시험 먼저 빨강 확인한 뒤 고침. 전체 시험 1976 통과 · 28 건너뜀 · 1 xfail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
178 lines
7.5 KiB
Python
178 lines
7.5 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
|
||
from B09_Estimation.B09_Estimation_ResourceAxis_UnitBasis import similar_occupation_note
|
||
|
||
_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", "수송 도로 구분"),
|
||
("transport_trips", "기계 수송 회수(대수 × 왕복)"),
|
||
):
|
||
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
|
||
own = build.book.details.get(code, [])
|
||
# Q 식 문구는 층 차례 뒤 D(단가산출) 줄에 삶 — 그 일위대가가 부르는 D 의 줄 문구도 함께(명세 3장).
|
||
basis = [
|
||
detail
|
||
for ref in own
|
||
if ref.ref_code.startswith("D-")
|
||
for detail in build.book.details.get(ref.ref_code, [])
|
||
]
|
||
notes = []
|
||
for detail in [*own, *basis]:
|
||
text = detail.note.strip() if detail.note else ""
|
||
if text and text not in notes:
|
||
notes.append(text)
|
||
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))
|
||
similar = similar_occupation_note(work_item_code)
|
||
if similar and similar not in notes:
|
||
notes.append(similar)
|
||
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)}"
|
||
),
|
||
}
|