노임표에 없는 직종명을 유사 직종으로 바꿔 읽는 자리를 공종 단위로 둠(FP-13-02-04 한 곳). 산출기초에 준용 근거(재경원 회계 45101-45) 표시. 뒷길이 다섯 × 밑수 둘, 일위대가 10벌이 새로 섬. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DJffo4VwgTumVUM3kdbJzd
169 lines
7.0 KiB
Python
169 lines
7.0 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", "수송 도로 구분"),
|
|
):
|
|
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))
|
|
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)}"
|
|
),
|
|
}
|