refactor(b09): 라우터 쪼개기 — 산출 조건 문(GET/PUT /estimation/factors)을 Router_Factors 로 · Router.py 842 → 537줄
- 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
@@ -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_Rounding import OutputPlace, round_at
|
||||||
from B09_Estimation.B09_Estimation_UnitPrice import (
|
from B09_Estimation.B09_Estimation_UnitPrice import (
|
||||||
build_summary,
|
build_summary,
|
||||||
cached_build,
|
|
||||||
detail_of,
|
detail_of,
|
||||||
direct_cost_from_quantities,
|
direct_cost_from_quantities,
|
||||||
list_unit_prices,
|
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}")
|
@router.get("/{project_id}/estimation/unit-prices/{code}")
|
||||||
async def get_unit_price_detail(project_id: UUID, code: str) -> JSONResponse:
|
async def get_unit_price_detail(project_id: UUID, code: str) -> JSONResponse:
|
||||||
"""일위대가 **본표** — 「무엇으로 이루어졌나」. 줄마다 원천·파고들기 표시가 붙는다."""
|
"""일위대가 **본표** — 「무엇으로 이루어졌나」. 줄마다 원천·파고들기 표시가 붙는다."""
|
||||||
|
|||||||
@@ -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": "산출 조건을 저장하지 못했습니다."},
|
||||||
|
)
|
||||||
@@ -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 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_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_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_audit import note_api_call, record_call_burst
|
||||||
from common_util.common_util_auth import (
|
from common_util.common_util_auth import (
|
||||||
require_company,
|
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_estimation_router, dependencies=protected_with_company)
|
||||||
app.include_router(b09_cost_sheet_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_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)
|
app.include_router(dev_unlock_router, dependencies=protected_with_company)
|
||||||
|
|||||||
@@ -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 as router
|
||||||
|
from B09_Estimation import B09_Estimation_Router_Factors as factors
|
||||||
|
|
||||||
project_id = uuid4()
|
project_id = uuid4()
|
||||||
monkeypatch.setattr(router, "_project_root_of", _fake_root(str(tmp_path)))
|
monkeypatch.setattr(router, "_project_root_of", _fake_root(str(tmp_path)))
|
||||||
|
|
||||||
def _put(value: str):
|
def _put(value: str):
|
||||||
body = router.FactorChoiceBody(misc_material_percent=value)
|
body = factors.FactorChoiceBody(misc_material_percent=value)
|
||||||
got = asyncio.run(router.put_factor_choices(project_id, body))
|
got = asyncio.run(factors.put_factor_choices(project_id, body))
|
||||||
return got.status_code, json.loads(got.body.decode())
|
return got.status_code, json.loads(got.body.decode())
|
||||||
|
|
||||||
from common_util.common_util_project_settings import estimation_settings
|
from common_util.common_util_project_settings import estimation_settings
|
||||||
|
|||||||
Reference in New Issue
Block a user