From b02c51db71deb75fa5b4e8aac25db4c667947d26 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 03:24:53 +0900 Subject: [PATCH] =?UTF-8?q?refactor(b09):=20=EB=9D=BC=EC=9A=B0=ED=84=B0=20?= =?UTF-8?q?=EC=AA=BC=EA=B0=9C=EA=B8=B0=20=E2=80=94=20=EC=82=B0=EC=B6=9C=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=EB=AC=B8(GET/PUT=20/estimation/factors)?= =?UTF-8?q?=EC=9D=84=20Router=5FFactors=20=EB=A1=9C=20=C2=B7=20Router.py?= =?UTF-8?q?=20842=20=E2=86=92=20537=EC=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - get_bill · _project_root_of · _build_for 는 Router.py 에 그대로(랩탑_메인 CostSheet import 그대로) - 새 파일은 본 라우터의 _project_root_of·_build_for 를 부를 때마다 빌려 씀 — 시험이 본 라우터 것을 바꿔 끼움 - 검증: 시험 1666 통과 · 골든셋 초록 · 재시작 뒤 B09 문 열하나(factors·base-data·price-sources·basis-sheet·machine-expense· design-doc-index·bill·unit-prices·edits·cost-sheet·PUT factors) 전부 200 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- B09_Estimation/B09_Estimation_Router.py | 305 ---------------- .../B09_Estimation_Router_Factors.py | 334 ++++++++++++++++++ main.py | 2 + resources/tester/test_b09_misc_material.py | 5 +- 4 files changed, 339 insertions(+), 307 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_Router_Factors.py diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 4acd130f..ae2d34c0 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -36,7 +36,6 @@ from B09_Estimation.B09_Estimation_Statutory import STATUTORY_ITEMS from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at from B09_Estimation.B09_Estimation_UnitPrice import ( build_summary, - cached_build, detail_of, direct_cost_from_quantities, list_unit_prices, @@ -410,310 +409,6 @@ async def get_design_doc_index(project_id: UUID) -> JSONResponse: ) -@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": "산출 조건을 저장하지 못했습니다."}, - ) - - @router.get("/{project_id}/estimation/unit-prices/{code}") async def get_unit_price_detail(project_id: UUID, code: str) -> JSONResponse: """일위대가 **본표** — 「무엇으로 이루어졌나」. 줄마다 원천·파고들기 표시가 붙는다.""" diff --git a/B09_Estimation/B09_Estimation_Router_Factors.py b/B09_Estimation/B09_Estimation_Router_Factors.py new file mode 100644 index 00000000..bc11c3b8 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Router_Factors.py @@ -0,0 +1,334 @@ +"""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": "산출 조건을 저장하지 못했습니다."}, + ) diff --git a/main.py b/main.py index 2fb67a94..7d2edba6 100644 --- a/main.py +++ b/main.py @@ -67,6 +67,7 @@ from B08_Quantity.B08_Quantity_Router_StructureSheet import router as b08_struct from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router from B09_Estimation.B09_Estimation_Router_CostSheet import router as b09_cost_sheet_router from B09_Estimation.B09_Estimation_Router_Edits import router as b09_edits_router +from B09_Estimation.B09_Estimation_Router_Factors import router as b09_factors_router from common_util.common_util_audit import note_api_call, record_call_burst from common_util.common_util_auth import ( require_company, @@ -640,6 +641,7 @@ app.include_router(b08_structure_sheet_router, dependencies=protected_with_compa app.include_router(b09_estimation_router, dependencies=protected_with_company) app.include_router(b09_cost_sheet_router, dependencies=protected_with_company) app.include_router(b09_edits_router, dependencies=protected_with_company) +app.include_router(b09_factors_router, dependencies=protected_with_company) # 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근). # 그 위에 서버가 환경까지 한 번 더 본다. app.include_router(dev_unlock_router, dependencies=protected_with_company) diff --git a/resources/tester/test_b09_misc_material.py b/resources/tester/test_b09_misc_material.py index 978be03a..903e32c3 100644 --- a/resources/tester/test_b09_misc_material.py +++ b/resources/tester/test_b09_misc_material.py @@ -129,13 +129,14 @@ def test_칸을_저장했다_지웠다_할_수_있다(tmp_path, monkeypatch) -> 돌아갈 수 없다. """ from B09_Estimation import B09_Estimation_Router as router + from B09_Estimation import B09_Estimation_Router_Factors as factors project_id = uuid4() monkeypatch.setattr(router, "_project_root_of", _fake_root(str(tmp_path))) def _put(value: str): - body = router.FactorChoiceBody(misc_material_percent=value) - got = asyncio.run(router.put_factor_choices(project_id, body)) + body = factors.FactorChoiceBody(misc_material_percent=value) + got = asyncio.run(factors.put_factor_choices(project_id, body)) return got.status_code, json.loads(got.body.decode()) from common_util.common_util_project_settings import estimation_settings