"""B09 원가계산 라우터 — **산출 조건**(품셈이 범위로 준 계수·장비 규격·공구손료·수송·품 할증·유가 지역) 읽기·저장. `B09_Estimation_Router.py` 가 700줄을 넘어(지침 4장) 떼어 냄(2026-09-14 브레인 차례). 문 이름·응답 모양은 그대로. ⚠ 프로젝트 폴더·조립은 본 라우터 것을 **부를 때마다** 빌려 씀 — 시험이 본 라우터의 `_project_root_of` 를 바꿔 끼움. """ from __future__ import annotations import logging from typing import Any from uuid import UUID from fastapi import APIRouter from fastapi.responses import JSONResponse from pydantic import BaseModel logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"]) async def _project_root_of(project_id: UUID) -> str | None: from B09_Estimation import B09_Estimation_Router as base return await base._project_root_of(project_id) async def _build_for(project_id: UUID): from B09_Estimation import B09_Estimation_Router as base return await base._build_for(project_id) @router.get("/{project_id}/estimation/factors") async def get_factor_choices(project_id: UUID) -> JSONResponse: """**산출 조건** — 품셈이 범위로 준 계수와 장비 규격 (사용자 확정 ① 딸림 지시). 「값을 코드에 박고 끝내지 말 것 · 화면에 칸으로 세우고 근거를 보이고 바꿀 수 있게」 라는 지시대로, **지금 값 · 고를 수 있는 것 · 왜 그 값인지**를 함께 낸다. """ from B09_Estimation.B09_Estimation_FactorChoices import ( BASIS_NOTES, DEFAULT_CHOICE, MACHINE_CHOICES, MACHINE_OPTION_CODES, machine_choices, scan_range_factors, ) from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog from B09_Estimation.B09_Estimation_UnitPrice import load_work_item_master from common_util.common_util_project_settings import estimation_settings try: root = await _project_root_of(project_id) settings = estimation_settings(root) if root else {} stored = settings.get("range_factor_choices") or {} ranges = [] for item in scan_range_factors(load_work_item_master()): choice = str(stored.get(item.key) or DEFAULT_CHOICE) ranges.append( { "key": item.key, "work_item_code": item.work_item_code, "work_item_name": item.work_item_name, "factor": item.factor, "raw_cell": item.raw_cell, "chosen": choice, "value": str(item.value_of(choice)), "is_default": choice == DEFAULT_CHOICE, "options": item.options(), "basis": BASIS_NOTES.get(item.key, []), } ) catalog = load_machine_catalog() picked = machine_choices(settings) machines = [] for code, entry in MACHINE_CHOICES.items(): options = [] for machine_code in MACHINE_OPTION_CODES: machine = catalog.machines.get(machine_code) if machine is None: continue options.append( { "key": machine_code, "label": f"{machine.name} {machine.specification}".strip(), } ) machines.append( { "work_item_code": code, "work_item_name": entry["work_item_name"], "chosen": picked.get(code, entry["default_code"]), "default": entry["default_code"], "is_default": picked.get(code) == entry["default_code"], "source": entry["source"], "options": options, "basis": entry["basis"], } ) from B09_Estimation.B09_Estimation_PriceBook import PriceKind from B09_Estimation.B09_Estimation_UnitPrice import ( MISC_MATERIAL_MAX_PERCENT, MISC_MATERIAL_MIN_PERCENT, ) from B09_Estimation.B09_Estimation_Transport import ASSUMPTION_TEXT as TRANSPORT_ASSUMPTION from B09_Estimation.B09_Estimation_Transport import BASIS_TEXT as TRANSPORT_BASIS from B09_Estimation.B09_Estimation_Transport import ROAD_CLASSES as TRANSPORT_ROADS from B09_Estimation.B09_Estimation_Transport import TRANSPORT_VARIANTS from B09_Estimation.B09_Estimation_Transport import WORK_ITEM_CODE as TRANSPORT_CODE # 「넣을 데가 있는가」 — 주재료비가 선 일위대가가 몇인지 세어 그대로 알린다. # 지금은 사급 자재 단가가 미결(확정 5차 큰 것 8)이라 0 이 정상이다. from B09_Estimation.B09_Estimation_LaborSurcharge import ( COMBINE_NOTE as LABOR_SURCHARGE_COMBINE, ) from B09_Estimation.B09_Estimation_LaborSurcharge import SEAT_NOTE as LABOR_SURCHARGE_SEAT from B09_Estimation.B09_Estimation_LaborSurcharge import load_series, parse_choices from B09_Estimation.B09_Estimation_LaborSurcharge import total_percent LABOR_SURCHARGE_SCOPE = ( "⚠ 26계열의 [주] 는 대개 조림·숲가꾸기·방제 작업을 지목합니다 — 임도 토공에 붙이라는" " 지시가 원문에 없으므로, 각 계열의 [주] 를 보고 그 작업일 때만 고르십시오." ) labor_surcharge_series = load_series() labor_surcharge_chosen = parse_choices(settings.get("labor_surcharge")) labor_surcharge_total, labor_surcharge_reasons = total_percent(labor_surcharge_chosen) prices = await _build_for(project_id) book = prices.book transport_notes = list(prices.transport_notes) transport_prices = {} for variant in TRANSPORT_VARIANTS: code = f"B-{TRANSPORT_CODE}#{variant['key']}" if code in book.titles: transport_prices[variant["key"]] = f"{book.resolve(code).total:,.0f}" with_material = sum( 1 for unit_code, unit_title in book.titles.items() if unit_title.kind is PriceKind.UNIT_PRICE and book.material_base(unit_code) > 0 ) return JSONResponse( content={ "status": "success", "ranges": ranges, "machines": machines, "misc_material": { "percent": str(settings.get("misc_material_percent") or ""), "min": str(MISC_MATERIAL_MIN_PERCENT), "max": str(MISC_MATERIAL_MAX_PERCENT), "basis": [ "산림품셈 1-2-6 — 「각 항목에 명시되어 있지 않는 잡재료 및 소모재료 등을" " 계상하고자 할 때에는 주재료비(재료비의 할증수량 제외)의 2~5%까지" " 별도 계상하되 산정 근거를 명시하여야 한다」", "⚠ 비워 두면 안 붙습니다 — 지금은 안 붙고 있는 상태입니다" " (사용자 확정 2026-09-09 「지금은 안 넣되 숫자 넣으면 되게 열어 둘 것」).", "⚠ 석공사·철골공사처럼 특수공구를 쓰는 자리는 건설품셈 1-2-6 이" " 「별도 계상」으로 두었습니다 — 일반 비율을 그대로 붙이면 안 되는" " 자리라, 그런 공종이 섞인 내역에서는 켜기 전에 살펴보십시오.", ], "base_items": with_material, "base_note": ( "" if with_material else "⚠ 지금은 일위대가에 주재료비가 선 공종이 하나도 없습니다" " — 자재는 자재대 표에서 따로 금액이 섭니다. 값을 넣어도 붙을 밑수가" " 없으므로, 사급 자재 단가가 서는 날 이 칸이 함께 살아납니다." ), }, "transport": { "distance_km": str(settings.get("transport_distance_km") or ""), "road": str(settings.get("transport_road") or ""), "roads": [ {"key": row["key"], "label": row["label"]} for row in TRANSPORT_ROADS ], "variants": [ { "key": variant["key"], "label": variant["label"], "unit_price_krw": transport_prices.get(variant["key"], ""), } for variant in TRANSPORT_VARIANTS ], "basis": [TRANSPORT_BASIS, TRANSPORT_ASSUMPTION], "notes": transport_notes, }, "labor_surcharge": { "chosen": labor_surcharge_chosen, "total_percent": f"{labor_surcharge_total:g}", "reasons": labor_surcharge_reasons, "series": [ { "key": item["key"], "title": item["title"], "section": item["section"], "source_note": item["source_note"], "options": [ { "key": option["key"], "label": f"{option['label']} · {option['percent']:g}%", } for option in item["options"] ], } for item in labor_surcharge_series ], "basis": [LABOR_SURCHARGE_SEAT, LABOR_SURCHARGE_COMBINE, LABOR_SURCHARGE_SCOPE], }, "notes": [ "고를 수 있는 것은 원문에 적힌 값뿐입니다 — 그 밖의 수는 만들지 않습니다.", "바꾸면 그 공종 단가가 바로 달라집니다. [저장]한 값은 이 프로젝트에만 걸립니다.", ], } ) except Exception: logger.exception("B09 산출 조건 조회 실패: project_id=%s", project_id) return JSONResponse( status_code=500, content={"status": "error", "message": "산출 조건을 못 불러왔습니다."}, ) class FactorChoiceBody(BaseModel): """고른 값 — 안 보낸 칸은 그대로 둔다.""" range_factor_choices: dict[str, str] | None = None machine_choices: dict[str, str] | None = None #: 공구손료·잡재료 비율 — **빈 문자열이면 안 붙는다**(칸을 도로 비우는 길). misc_material_percent: str | None = None #: 유가 지역(시도코드) — **빈 문자열이면 전국평균**으로 돌아간다. fuel_region: str | None = None #: 기계 수송 거리(㎞)·도로 구분 — **비면 수송비 줄이 안 선다.** transport_distance_km: str | None = None transport_road: str | None = None #: 품 할인·할증(1-4) — 계열코드 → 고른 행. **빈 값이면 그 계열을 끄는 것.** labor_surcharge: dict[str, str] | None = None @router.put("/{project_id}/estimation/factors") async def put_factor_choices(project_id: UUID, body: FactorChoiceBody) -> JSONResponse: """산출 조건을 이 프로젝트에 저장한다. **다른 구획은 손대지 않는다.**""" from B09_Estimation.B09_Estimation_FactorChoices import CHOICE_KEYS, MACHINE_OPTION_CODES from common_util.common_util_project_settings import save_section from common_util.common_util_project_settings import estimation_settings root = await _project_root_of(project_id) if root is None: return JSONResponse( status_code=404, content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, ) values: dict[str, Any] = {} if body.range_factor_choices is not None: # ⚠ 모르는 값은 안 받는다 — 원문에 없는 수가 설정으로 들어오면 그것이 임의 수치다. values["range_factor_choices"] = { str(key): str(value) for key, value in body.range_factor_choices.items() if str(value) in CHOICE_KEYS } if body.machine_choices is not None: values["machine_choices"] = { str(key): str(value) for key, value in body.machine_choices.items() if str(value) in MACHINE_OPTION_CODES } if body.misc_material_percent is not None: from B09_Estimation.B09_Estimation_UnitPrice import parse_misc_material_percent try: percent = parse_misc_material_percent(body.misc_material_percent) except ValueError as exc: # ⚠ 조용히 깎아 넣지 않는다 — 범위 밖 값을 상한으로 접으면 사용자가 넣은 값과 # 금액이 어긋난 채로 선다. return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) values["misc_material_percent"] = "" if percent is None else str(percent) if body.fuel_region is not None: from B09_Estimation.B09_Estimation_MachineOperating import load_regional_fuel_table region = str(body.fuel_region).strip() table, _ = load_regional_fuel_table() if region and region not in table: # ⚠ 판에 없는 지역을 받아 두면 조용히 전국평균으로 서고 사용자는 지역값인 줄 안다. return JSONResponse( status_code=400, content={ "status": "error", "message": f"유가 판에 없는 지역입니다: {region}", }, ) values["fuel_region"] = region if body.transport_distance_km is not None: from B09_Estimation.B09_Estimation_Transport import parse_distance_km try: distance = parse_distance_km(body.transport_distance_km) except ValueError as exc: return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) values["transport_distance_km"] = "" if distance is None else str(distance) if body.transport_road is not None: from B09_Estimation.B09_Estimation_Transport import road_class road = str(body.transport_road).strip() if road and road_class(road) is None: # ⚠ 원문 표에 없는 도로 구분을 받아 두면 속도를 못 골라 조용히 안 선다. return JSONResponse( status_code=400, content={"status": "error", "message": f"원문에 없는 도로 구분입니다: {road}"}, ) values["transport_road"] = road if body.labor_surcharge is not None: from B09_Estimation.B09_Estimation_LaborSurcharge import parse_choices # ⚠ **원문에 있는 선택지만** 받는다 — 없는 율이 설정으로 들어오면 그것이 임의 수치다. stored = dict(estimation_settings(root).get("labor_surcharge") or {}) for series_key, option_key in body.labor_surcharge.items(): if str(option_key).strip(): stored[str(series_key)] = str(option_key) else: stored.pop(str(series_key), None) # 빈 값 = 그 계열 끄기 values["labor_surcharge"] = parse_choices(stored) try: save_section(root, "estimation", values, replace_keys=tuple(values)) return JSONResponse(content={"status": "success", **values}) except Exception: logger.exception("B09 산출 조건 저장 실패: project_id=%s", project_id) return JSONResponse( status_code=500, content={"status": "error", "message": "산출 조건을 저장하지 못했습니다."}, )