Files
Aislo/B09_Estimation/B09_Estimation_Lists_Sources.py
T
eomsangdonandClaude Opus 5 3ce997fa69 feat(b09): 표 화면 넷 — 중기(목록표·중기사용료) · 자재단가대비표 · 집계표 넷 · 목록표 일곱
- 서버: 내역 응답에 집계표 넷(resources)·목록표(lists) — 내역이 쓴 자원을 처음 쓰인 차례로 되모음
  (묶음 줄·구조물도 호표 줄도 구성까지 풀어 셈) · 재료비 집계표에 자재대(사급·관급) 줄 이어 붙임
- 중기시간금액집계표: 단가 열 없이 합계·노무·재료·경비 — 성분마다 반올림한 뒤 합(골든셋 131/144, 합계 한 번 반올림 107/144)
- 재료·노무·경비 집계표: 반올림(수량 × 단가) 골든셋 279/279 — 시험 한 벌 더함
- 중기사용료 호표: 잡품 줄은 수량 칸 율(%) · 단가 칸 밑수(주연료비) · 금액 — 실무 표 그대로. 제잡비·공구손료 줄도 밑수를 단가 칸에
- 자재단가대비표: 슬롯 1~6 단가·페이지 · 채택 칸 굵게 · 최소단가 표시(서버 min_slot)
- 목록표 일곱(일위대가·단가산출근거·중기·재료비·노무비·경비·일식견적): 색인만 — 서버 번호·단가 그대로, 일위대가·산근·중기 줄은 누르면 들어감
- 옛 중기 탭 줄을 새 탭으로 바꿔 끼움(중기경비계산서는 옛 표를 아래에 이어 보임)
- 검증: ORCA — 중기 목록 9 · 굴착기 0.7 호표 합계 109,704 · 잡품 22 % × 21,418.1 = 4,711.9 · 노무 집계 벌목부 9,686,529 · 중기 집계 성분 합 · 시험 1638 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 01:56:02 +09:00

212 lines
9.9 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 원가계산 — **자재단가대비표(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)
# 최소단가 — 값이 선 원천 가운데 가장 싼 칸(STmate `wM_Boxa` 「최소단가」 표시 · 채택 규칙 17번 §5.5).
filled = [(value, i + 1) for i, value in enumerate(title.slots[:5]) if value]
rows.append(
{
"code": code,
"name": title.name,
"spec": title.spec,
"unit": title.unit,
"slots": slots,
"min_slot": min(filled)[1] if filled else None,
"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", ""),
},
}