- 잡품을 연료 수량에 접지 않고 주연료비의 % 가산 행으로(STmate 잡품 단가 칸 = 연료 금액) - 시간당 중기사용료(X) 호표 — 줄 금액 0.1원 미만 · 성분 소계 원 미만 절사(명세 7장, 봉화 0.7㎥ 96,843) - 조종원 시간당 노임 자르는 자리를 프로젝트 설정 operator_wage_digits 로(기본 0, 화면은 뒤) - 골든셋 ④ 벌 — 실무 6건 중기사용료 시트 143 호표를 우리 단가표 층으로 되풀어 전수 일치 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
209 lines
9.6 KiB
Python
209 lines
9.6 KiB
Python
"""B09 원가계산 — **자재단가대비표(A9) · 환율및기초자료(A10)**.
|
||
|
||
사용자 확정 12번(내야 할 표 16개) 중 남은 둘. ③·⑮ 와 한 벌이다.
|
||
|
||
A9 자재단가대비표 코드·명칭·규격·단위 · **원천 5칸(단가+쪽수)** · 적용(단가+출처) · 비고
|
||
A10 환율및기초자료 ① 환율 ② 인건비(운전사 3종) ③ 단가 및 재료비
|
||
|
||
**서식은 지어내지 않았다** — 실무 내역서(영월 기번6) 같은 이름 시트를 그대로 옮겼다.
|
||
실측한 머리글:
|
||
|
||
코드번호 | 명칭 | 규격 | 단위 | 물가자료 | 물가정보 | 유통물가 | 거래가격등 | 기타단가 | 적용 | 비고
|
||
(각 칸이 「단가 · 페이지」 두 칸)
|
||
적용 칸의 「페이지」 자리에는 **출처 약호**가 온다 — 「기.가」·「유.물」·「견적」.
|
||
|
||
⚠ **원천 이름은 사무소마다 다르다**(PLAN 9-4 미결). 그래서 이름을 코드에 박지 않고
|
||
`PriceBook.slot_names` 를 그대로 쓴다 — 프로젝트가 고르면 표 머리도 따라 바뀐다.
|
||
|
||
⚠ **사용자 확정 ③ — 자재값 출처는 「둘 다」**(업체 견적·지역 상차가 **와** 물가지).
|
||
그래서 슬롯마다 **쪽수/업체명·날짜**를 함께 들 자리를 낸다. 값이 없으면 **빈칸으로 두고
|
||
지어내지 않는다** — 나중에 「왜 이 값인지」를 되짚어야 하기 때문이다.
|
||
|
||
⚠ **사용자 확정 ⑮ — 유가는 전국 공시가/지역 공시가를 고를 수 있어야 한다.**
|
||
지금 자료(`oil_*.json`)에는 **전국평균 하나뿐**이라(`scope: national_average`),
|
||
**고르는 칸은 내되 지역값은 지어내지 않는다.** 고를 수 있는 것만 보이고, 지역을 고르면
|
||
「아직 자료가 없다」고 밝힌다.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from decimal import Decimal
|
||
from typing import Any
|
||
|
||
from B09_Estimation.B09_Estimation_MachineCost import (
|
||
OPERATOR_ALLOWANCE_NOTICE,
|
||
hourly_operator_wage,
|
||
)
|
||
from B09_Estimation.B09_Estimation_PriceBook import PRICE_SLOT_COUNT, PriceKind
|
||
from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build
|
||
|
||
|
||
#: 유가 적용 범위 — 사용자 확정 ⑮. **값이 있는 것만 고를 수 있다.**
|
||
#: ⚠ 「지역 공시가」가 고를 수 있는지는 **코드가 아니라 판이 정한다** — 시도별 판이
|
||
#: 들어와 있으면 열리고, 없으면 까닭과 함께 닫힌다(2026-09-09 에 판이 들어옴).
|
||
def fuel_scopes() -> tuple[dict[str, Any], ...]:
|
||
from B09_Estimation.B09_Estimation_MachineOperating import load_regional_fuel_table
|
||
|
||
table, meta = load_regional_fuel_table()
|
||
if table:
|
||
return (
|
||
{"key": "national_average", "label": "전국 공시가", "available": True},
|
||
{
|
||
"key": "regional",
|
||
"label": "지역 공시가",
|
||
"available": True,
|
||
"why": (
|
||
"품셈 8-1-7 5호 「유류가격은 해당지역의 가격으로 한다」 — "
|
||
f"오피넷 시도별 판({meta.get('effective_date', '')}) 으로 섭니다."
|
||
),
|
||
},
|
||
)
|
||
return (
|
||
{"key": "national_average", "label": "전국 공시가", "available": True},
|
||
{
|
||
"key": "regional",
|
||
"label": "지역 공시가",
|
||
"available": False,
|
||
"why": (
|
||
"오피넷 지역별 값을 아직 안 받아 왔습니다 — 품셈 8-1-7 5호가 「유류가격은 "
|
||
"해당 지역의 가격」이라 규정하므로 자료를 받으면 고를 수 있게 됩니다."
|
||
),
|
||
},
|
||
)
|
||
|
||
|
||
def _money(value: Decimal | None) -> str | None:
|
||
return None if value is None else str(value)
|
||
|
||
|
||
def material_price_comparison(build: UnitPriceBuild | None = None) -> dict[str, Any]:
|
||
"""자재단가대비표 — **원천을 나란히 두고 채택한 것을 표시**한다 (실무 서식 그대로).
|
||
|
||
⚠ 값이 없는 슬롯은 **0 이 아니라 빈칸**이다. 0 을 넣으면 「0원짜리 견적」으로 읽힌다.
|
||
"""
|
||
prices = build or cached_build()
|
||
book = prices.book
|
||
rows: list[dict[str, Any]] = []
|
||
for code in sorted(book.titles):
|
||
title = book.titles[code]
|
||
if title.kind is not PriceKind.MATERIAL:
|
||
continue
|
||
slots: list[dict[str, Any]] = []
|
||
for index in range(PRICE_SLOT_COUNT):
|
||
value = title.slots[index] if index < len(title.slots) else None
|
||
page = title.slot_pages[index] if index < len(title.slot_pages) else None
|
||
slots.append(
|
||
{
|
||
"name": (
|
||
book.slot_names[index]
|
||
if index < len(book.slot_names)
|
||
else f"슬롯{index + 1}"
|
||
),
|
||
"price_krw": _money(value),
|
||
# 「페이지」 자리 — 물가지는 쪽수, 견적은 업체명·날짜가 온다(확정 ③).
|
||
"source_note": page or "",
|
||
"adopted": (index + 1) == title.adopted_slot,
|
||
}
|
||
)
|
||
try:
|
||
adopted: Decimal | None = title.adopted_price()
|
||
note = ""
|
||
except Exception as error: # 채택 슬롯이 비었다 — 지어내지 않는다
|
||
adopted, note = None, str(error)
|
||
rows.append(
|
||
{
|
||
"code": code,
|
||
"name": title.name,
|
||
"spec": title.spec,
|
||
"unit": title.unit,
|
||
"slots": slots,
|
||
"adopted_slot": title.adopted_slot,
|
||
"adopted_price_krw": _money(adopted),
|
||
"note": note,
|
||
}
|
||
)
|
||
return {
|
||
"slot_names": list(book.slot_names),
|
||
"rows": rows,
|
||
"notes": [
|
||
"원천을 나란히 두고 하나를 채택합니다 — 실무 자재단가대비표와 같은 서식입니다.",
|
||
"값이 없는 원천은 빈칸입니다. 0 으로 채우지 않습니다.",
|
||
"견적을 넣을 때는 업체명·견적일을 함께 남기십시오 — 국가계약법 시행령 §9 의 "
|
||
"4순위(앞의 셋으로 정할 수 없을 때)라 근거가 남아야 합니다.",
|
||
],
|
||
}
|
||
|
||
|
||
def base_reference_data(
|
||
build: UnitPriceBuild | None = None, fuel_region: str | None = None
|
||
) -> dict[str, Any]:
|
||
"""환율및기초자료 — 실무 시트 세 구획을 그대로 낸다.
|
||
|
||
① 환율 ② 인건비(운전사 3종) ③ 단가 및 재료비(유류 등)
|
||
|
||
⚠ `fuel_region`(시도코드) — 프로젝트가 고른 지역. **안 고르면 전국평균**이다.
|
||
"""
|
||
from B09_Estimation.B09_Estimation_MachineOperating import (
|
||
load_fuel_price,
|
||
load_operator_wages,
|
||
load_regional_fuel_table,
|
||
)
|
||
|
||
prices = build or cached_build()
|
||
fuel_price, fuel_meta = load_fuel_price(region=fuel_region)
|
||
wages = load_operator_wages()
|
||
|
||
# ⚠ **운전사 세 직종만** 싣는다. 실무 「환율및기초자료」 시트가 그 셋뿐이고
|
||
# (건설기계운전사·화물차운전사·일반기계운전사, 건설품셈 8-1-3 5~6호의 구분),
|
||
# 직종 전체를 실으면 **노무비목록표와 같은 표가 두 벌**이 된다.
|
||
operator_names = ("건설기계운전사", "화물차운전사", "일반기계운전사")
|
||
operators: list[dict[str, Any]] = []
|
||
for code, wage in sorted(wages.items()):
|
||
title = prices.book.titles.get(code)
|
||
if getattr(title, "name", "") not in operator_names:
|
||
continue
|
||
operators.append(
|
||
{
|
||
"code": code,
|
||
"name": getattr(title, "name", "") or code,
|
||
"day_wage_krw": _money(Decimal(str(wage))),
|
||
# 제수당·상여금·퇴직급여충당금 계수를 곱한 값 (2026-09-09 사용자 확정 ③).
|
||
# 근거·한계는 `MachineCost.OPERATOR_ALLOWANCE_FACTOR` 주석 한 곳에 모아 뒀다.
|
||
# 식 좌→우 순차 + 원 미만 절사(명세 7장 · STmate 18번 §2.1).
|
||
"hourly_krw": _money(
|
||
hourly_operator_wage(Decimal(str(wage)), digits=prices.operator_wage_digits)
|
||
),
|
||
"formula": "일당 ÷ 8시간 × 16/12 × 25/20",
|
||
}
|
||
)
|
||
|
||
return {
|
||
"exchange": {
|
||
"rows": [],
|
||
"note": "수입 기계가 아직 없어 환율을 쓰는 자리가 없습니다 — 생기면 여기에 섭니다.",
|
||
},
|
||
"labor": {
|
||
"rows": operators,
|
||
"note": OPERATOR_ALLOWANCE_NOTICE,
|
||
},
|
||
"fuel": {
|
||
"diesel_krw_per_l": _money(fuel_price),
|
||
"scope": fuel_meta.get("scope", ""),
|
||
"effective_date": fuel_meta.get("effective_date", ""),
|
||
"dataset_id": fuel_meta.get("dataset_id", ""),
|
||
"scopes": list(fuel_scopes()),
|
||
# 고를 수 있는 시도 — **판에 있는 것만**. 값을 함께 실어 고르기 전에 견줄 수 있게 한다.
|
||
"region": fuel_meta.get("region", ""),
|
||
"region_name": fuel_meta.get("region_name", ""),
|
||
"regions": [
|
||
{"code": code, "name": entry["name"], "diesel_krw_per_l": _money(entry["value"])}
|
||
for code, entry in sorted(load_regional_fuel_table()[0].items())
|
||
],
|
||
"note": (
|
||
"품셈 8-1-7 5호 「유류가격은 해당지역의 가격으로 한다」 — 지역을 고르면 "
|
||
"그 시도 공시가로 기계 연료비가 다시 섭니다. 안 고르면 전국 공시가입니다."
|
||
),
|
||
"region_missing": fuel_meta.get("region_missing", ""),
|
||
},
|
||
}
|