Files
Aislo/B09_Estimation/B09_Estimation_BasisSheet.py
T
eomsangdonandClaude Opus 5 1400b7b761 feat(b09): 기계 수송비 회수(대수 × 왕복) 설계 입력 칸 — 원문에 공식이 없어 사유와 함께 엶
산림품셈 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
2026-09-14 20:13:25 +09:00

178 lines
7.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)}"
),
}