"""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)}" ), }