구조물 원단위의 `destination == "material"` 성분만 모아 자재별 합산 후 할증률을 한 번만 적용. 열은 순수량·할증률·합계 + 관급구분·설치주체·비고이며 금액은 없음(B09 경계). - 할증률은 코드가 아니라 데이터 — `resources/data_material_surcharge/` (품셈 1-3-1 재료 할증률 19종 + sha256 매니페스트). 실무 관측값은 `observed_practice` 로 분리(법대로 원칙). - 표에 없는 자재는 0 % 로 넘기지 않고 「할증률 미확보」로 표시. 이름 조회는 정확 일치 — 부분일치면 `막자갈` 이 `자갈` 할증을 뭄. - 이중계상 방어 ㉠ — 앞 단계 `surcharge_applied` 깃발을 실제로 읽어 경고. 자재총괄 응답은 `True`, 원단위표는 `False` 로 어느 쪽 값인지 명시. - 관급/사급 이름은 B09 와 동일(`owner_supplied`/`contractor_supplied`). 관급 줄에만 설치 주체(`install_by`)를 붙이고, 미지정은 기본값으로 때우지 않고 드러냄 — 안전관리비 대상액이 「도급자설치 관급금액」이라서임. - 라우터 `GET /quantity/material-summary` 신설, 화면에 「구조물 원단위」· 「자재총괄」 탭 추가. `design_owner` 가 붙은 타입(측구)은 중복 계상 방지로 제외. 검증 — 전용 테스트 23건 통과, 전체 회귀 473 passed(기존 B05 깨짐 1건 제외). 공용 브라우저 실조작으로 탭 5장·머리글·값 4줄·미확보 안내 확인. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
106 lines
4.5 KiB
Python
106 lines
4.5 KiB
Python
"""B08 구조물 원단위·자재총괄 조회 라우터 (일감 6·7 · PLAN 8-2·8-6·8-7).
|
|
|
|
값은 어디서 오나
|
|
치수 정본은 **`structures.json` 하나**다(B05 가 주인). B08 은 자기 치수표를 들지 않고
|
|
그 제원을 읽어 전개할 뿐이다 — 도면은 H=1.5 인데 수량은 옛 치수로 도는 사고를 막는다.
|
|
|
|
⚠ `design_owner` 가 붙은 타입은 건너뛴다
|
|
측구가 그렇다 — 횡단 설계가 이미 터파기 단면적까지 셈하므로 구조물로 또 세면 **같은 것을
|
|
두 번 계상**한다(레지스트리 주석, 2026-09-07 조사). 건너뛴 것은 숨기지 않고 응답에 적는다.
|
|
|
|
⚠ 할증은 자재총괄 한 곳뿐이다 (㉠)
|
|
원단위표는 할증 **전** 값(`surcharge_applied: False`)으로 오고, 자재총괄이 한 번 붙인다.
|
|
응답에 두 깃발이 다 실리므로 화면·B09 가 어느 쪽 값인지 헷갈릴 일이 없다.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
|
from B05_Profile.B05_Profile_Structures_Repository import load_structures
|
|
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
|
|
from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table as build_material_table
|
|
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table
|
|
from common_util.common_util_project_settings import quantity_settings
|
|
from common_util.common_util_storage import resolve_stored_project_path
|
|
from config.config_db import run_with_connection
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/projects", tags=["B08 Quantity"])
|
|
|
|
|
|
def _collect_structures(
|
|
project_root: str,
|
|
) -> tuple[list[dict[str, Any]], dict[str, str], list[str]]:
|
|
"""전개 대상 구조물·타입명·건너뛴 사유를 함께 낸다."""
|
|
_revision, items = load_structures(project_root)
|
|
types = structure_type_map()
|
|
targets: list[dict[str, Any]] = []
|
|
names: dict[str, str] = {}
|
|
skipped: list[str] = []
|
|
for item in items:
|
|
payload = item.model_dump()
|
|
type_id = str(payload.get("type_id") or "")
|
|
definition = types.get(type_id)
|
|
if definition is None:
|
|
skipped.append(f"{type_id}: 레지스트리에 없는 타입")
|
|
continue
|
|
names[type_id] = definition.name
|
|
if definition.design_owner:
|
|
skipped.append(
|
|
f"{definition.name}: {definition.design_owner} 가 이미 셈 — 중복 계상 방지"
|
|
)
|
|
continue
|
|
if definition.reference_only:
|
|
skipped.append(f"{definition.name}: 전문 상세설계 대상 — 배치까지만")
|
|
continue
|
|
targets.append(payload)
|
|
return targets, names, sorted(set(skipped))
|
|
|
|
|
|
@router.get("/{project_id}/quantity/material-summary")
|
|
async def get_material_summary(project_id: UUID) -> JSONResponse:
|
|
"""구조물 원단위와 자재총괄을 **한 응답**으로 낸다.
|
|
|
|
자재총괄은 원단위의 `material` 성분만 모은 것이라 따로 부르면 같은 전개를 두 번 돈다.
|
|
"""
|
|
try:
|
|
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
|
|
project_root = resolve_stored_project_path(stored_path)
|
|
except Exception:
|
|
logger.exception("B08 자재총괄 조회 실패(경로): project_id=%s", project_id)
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."},
|
|
)
|
|
|
|
try:
|
|
structures, names, skipped = _collect_structures(project_root)
|
|
except Exception:
|
|
logger.exception("B08 구조물 정본 읽기 실패: project_id=%s", project_id)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "구조물 정본을 읽지 못했습니다."},
|
|
)
|
|
|
|
unit_table = build_unit_table(structures, names)
|
|
settings = quantity_settings(project_root)
|
|
material_table = build_material_table(
|
|
unit_table,
|
|
supply_map=settings.get("material_supply") or {},
|
|
)
|
|
return JSONResponse(
|
|
content={
|
|
"unit_quantity": unit_table,
|
|
"material": material_table,
|
|
"skipped_structures": skipped,
|
|
"structure_count": len(structures),
|
|
}
|
|
)
|