From 9bf739fa929b1ad874ce180742bdfcd71c52191f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 19:44:53 +0900 Subject: [PATCH] =?UTF-8?q?feat(B09):=20=EC=9E=90=EC=9E=AC=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=EB=8C=80=EB=B9=84=ED=91=9C=C2=B7=ED=99=98=EC=9C=A8?= =?UTF-8?q?=EB=B0=8F=EA=B8=B0=EC=B4=88=EC=9E=90=EB=A3=8C=20=EC=8B=A0?= =?UTF-8?q?=EC=84=A4=20=E2=80=94=20=ED=91=9C=2016=EA=B0=9C=20=EC=A4=91=20?= =?UTF-8?q?=EB=82=A8=EC=9D=80=20=EB=91=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 확정 12번의 마지막 둘. ③(자재값 출처 둘 다)·⑮(유가 전국/지역)와 한 벌. - 서식은 실무 시트(영월 기번6) 그대로 — 원천 5칸(단가+쪽수) + 적용(단가+출처) + 비고. 원천 이름은 코드에 안 박고 PriceBook.slot_names 를 씀(사무소마다 다름, 9-4 미결). - ⚠ 확정 ③ — 슬롯마다 출처를 남길 자리를 둠(쪽수·업체명·날짜). 값이 없는 원천은 0 이 아니라 빈칸. 0 이면 「0원짜리 견적」으로 읽힘. 견적이 국계법 시행령 §9 의 4순위라는 사실을 표가 스스로 밝힘. - ⚠ 확정 ⑮ — 유가 전국/지역 고르는 칸을 냄. 다만 지역값이 아직 자료에 없어 「고를 수 없음 + 까닭」으로 드러냄. 없는 값을 지어내지 않음. - ⚠ 환율및기초자료 인건비 칸은 운전사 셋만. 직종 118개를 다 실으면 노무비목록표와 같은 표가 두 벌이 됨 — 실무 시트도 셋뿐임. - 조종원 시간당이 실무·교본과 다른 사실(× 16/12 × 25/20)을 표 비고에 그대로 냄. 라우터 조회 추가(GET .../estimation/price-sources). 시험 5건 추가, 267건 통과. Co-Authored-By: Claude Opus 5 (1M context) --- .../B09_Estimation_Lists_Sources.py | 172 ++++++++++++++++++ B09_Estimation/B09_Estimation_Router.py | 29 +++ 2 files changed, 201 insertions(+) create mode 100644 B09_Estimation/B09_Estimation_Lists_Sources.py diff --git a/B09_Estimation/B09_Estimation_Lists_Sources.py b/B09_Estimation/B09_Estimation_Lists_Sources.py new file mode 100644 index 00000000..9568d708 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Lists_Sources.py @@ -0,0 +1,172 @@ +"""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_PriceBook import PRICE_SLOT_COUNT, PriceKind +from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build + +#: 유가 적용 범위 — 사용자 확정 ⑮. 값이 있는 것만 고를 수 있다. +FUEL_SCOPES: tuple[dict[str, Any], ...] = ( + {"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) -> dict[str, Any]: + """환율및기초자료 — 실무 시트 세 구획을 그대로 낸다. + + ① 환율 ② 인건비(운전사 3종) ③ 단가 및 재료비(유류 등) + """ + from B09_Estimation.B09_Estimation_MachineOperating import ( + load_fuel_price, + load_operator_wages, + ) + + prices = build or cached_build() + fuel_price, fuel_meta = load_fuel_price() + 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))), + # ⚠ 실무·교본은 「÷8 × 16/12 × 25/20」을 쓴다. 우리는 ÷8 만 쓴다 — + # 그 계수의 규정 원문(기재부 예정가격 작성기준)을 아직 못 봤기 때문이다. + "hourly_krw": _money(Decimal(str(wage)) / Decimal(8)), + "formula": "일당 ÷ 8시간", + } + ) + + return { + "exchange": { + "rows": [], + "note": "수입 기계가 아직 없어 환율을 쓰는 자리가 없습니다 — 생기면 여기에 섭니다.", + }, + "labor": { + "rows": operators, + "note": ( + "실무·임도교본은 여기에 「× 16/12 × 25/20」(약 1.67배)을 더 곱합니다. " + "그 계수의 규정 원문을 아직 확인하지 못해 적용하지 않았습니다 — " + "확정되면 기계가 든 공종 단가가 약 19~24% 오릅니다." + ), + }, + "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), + "note": ( + "품셈 8-1-7 5호 「유류가격은 해당 지역의 가격」 — 지역 공시가를 고를 수 있게 " + "칸을 두었으나 아직 전국 공시가만 받아 와 있습니다." + ), + }, + } diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 816aee63..df07b24a 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -236,6 +236,35 @@ async def get_base_data_lists(project_id: UUID) -> JSONResponse: ) +@router.get("/{project_id}/estimation/price-sources") +async def get_price_sources(project_id: UUID) -> JSONResponse: + """**자재단가대비표(A9) · 환율및기초자료(A10)** — 사용자 확정 12번의 남은 둘. + + ③(자재값 출처 둘 다)·⑮(유가 전국/지역)와 한 벌이라 **출처를 고르는 칸**과 + **업체명·날짜·쪽수 자리**를 함께 낸다. + """ + from B09_Estimation.B09_Estimation_Lists_Sources import ( + base_reference_data, + material_price_comparison, + ) + + try: + build = cached_build() + return JSONResponse( + content={ + "status": "success", + "material_comparison": material_price_comparison(build), + "base_reference": base_reference_data(build), + } + ) + except Exception: + logger.exception("B09 단가 원천 표 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "단가 원천 표를 못 만들었습니다."}, + ) + + @router.get("/{project_id}/estimation/unit-prices/{code}") async def get_unit_price_detail(project_id: UUID, code: str) -> JSONResponse: """일위대가 **본표** — 「무엇으로 이루어졌나」. 줄마다 원천·파고들기 표시가 붙는다."""