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