Files
Aislo/B08_Quantity/B08_Quantity_Router_Material.py
T
eomsangdonandClaude Opus 5 4f138794ab feat(B08): 콘크리트 타설 줄 인계 + 빈 단가산출서 서식 거르기
㉙ B09 2차 교차검토와 함께 나온 것.

콘크리트 타설 — placing_code() 가 시험에서만 불리던 자리를 실제로 이음
- build_handoff 가 구조물 종류별로 콘크리트 체적을 모아 타설 공종 줄을 냄
- 겹치지 않는 것을 확인함: 품셈 12-1-1 표가 직종·품만 주고 재료를 안 줌
  (서브 일위대가 B-FP-12-01-01#철근구조물 = 재료 0원 · 노무 65,826.48원)
  품은 이 줄, 재료는 자재 쪽으로 갈려 있음
- ⚠ 채움콘크리트는 뺌 — 돌쌓기 뒤채움이라 그 공종 품에 이미 들어 있을 수 있음
  (13-6 [주]① 이 큰돌쌓기는 품에 포함이라 못 박음, 13-4 는 확인 전)
- 방식을 안 정했으면 기본값으로 서되 placing_notes 로 알림
- 화면 문구를 「아직 반영 안 됨」에서 「방식에 따라 단가가 달라짐」으로 바꿈

빈 단가산출서 서식 — 품셈이 실어 둔 채워 넣으라고 둔 양식 8건
- 머리글 「단가산출서」가 정본 표지, 머리 두 줄 모양은 보조
- 값이 아예 없는 표라 공종으로 세우면 「값이 있는데 안 서는 자리」로 오해됨
- 실측: 견적 쪽 못 맞춘 자원 882 → 497, 밑수 미확보 160 → 137
- 자원 줄 418 · 단가 제목 330 은 그대로 — 정상 값은 안 지워짐

시험
- 서식 규칙 짝 시험 넷 (정상 표를 지우지 않는 것 · 다시 만든 마스터 대조 포함)
- 타설 줄 시험 넷 (방식 셋이 갈리는 것 · 콘크리트가 없으면 줄도 없는 것)
- 옛 시험 하나 정정 — 구조물 한 줄 규칙에 타설 줄이 걸리던 것

시험: 674 passed · 24 skipped (B05 코리도 1건 기존 깨짐, 무관) · tsc 오류 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 06:08:31 +09:00

190 lines
8.1 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_Handoff import build_handoff, summarize
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 (
concrete_placing_method,
quantity_settings,
rock_classes,
rock_method,
)
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 {},
)
# 묶음으로 서는 구조물의 조각을 화면에도 보인다 — 코드만으로는 사람이 검증 못 한다.
handoff = build_handoff(unit_quantity_table=unit_table)
composite = [
{
"name": row["name"],
"parts": row["composite_parts"],
"not_ready": row.get("composite_not_ready"),
}
for row in handoff["work_items"]
# 조각이 없어도(원단위 자체가 없어 못 세운 경우) 사유는 보여야 한다.
if row.get("composite_parts") or row.get("composite_not_ready")
]
return JSONResponse(
content={
"unit_quantity": unit_table,
"material": material_table,
"composite": composite,
"skipped_structures": skipped,
"structure_count": len(structures),
}
)
@router.get("/{project_id}/quantity/handoff")
async def get_handoff(project_id: UUID) -> JSONResponse:
"""B09 로 넘길 두 벌 — 작업 공종 축과 자재 축 (일감 9).
⚠ **한 벌로 합치지 않는다.** 내역 줄은 작업 공종이고 자재는 자재다.
자재에 공종코드를 붙이면 자재가 내역 줄로 오해된다(8-2 이중계상 함정).
⚠ 토공·운반은 토적표 라우터가 이미 만드는 표를 그대로 받는다 — 여기서 다시 계산하지
않는다. 같은 값을 두 벌로 짜지 않는다는 규칙(CLAUDE.md 5장)이 여기에도 걸린다.
"""
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": "프로젝트 저장 폴더를 찾지 못했습니다."},
)
structures, names, skipped = _collect_structures(project_root)
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 {}
)
# 토공·운반 표는 토적표 라우터의 것을 그대로 쓴다 — 여기서 다시 만들지 않는다.
earthwork = await _earthwork_tables(project_id)
handoff = build_handoff(
summary_table=earthwork.get("summary"),
haul_table=earthwork.get("haul"),
unit_quantity_table=unit_table,
material_table=material_table,
ground_class_set=settings.get("rock_class_set"),
ground_classes=rock_classes(settings),
ground_methods={name: rock_method(settings, name) for name in rock_classes(settings)},
# 타설 방식 — 안 정했으면 기본값으로 서되 그 사실을 `placing_notes` 가 알린다.
concrete_placing_method=concrete_placing_method(settings)[0],
)
handoff["summary"] = summarize(handoff)
handoff["skipped_structures"] = skipped
handoff["earthwork_available"] = bool(earthwork)
return JSONResponse(content=handoff)
async def _earthwork_tables(project_id: UUID) -> dict[str, Any]:
"""토적표 라우터가 만든 집계·운반 표를 얻는다. 노선이 없으면 빈 값."""
from B08_Quantity.B08_Quantity_Router_Earthwork import (
get_earthwork_table_for_current_route,
)
try:
response = await get_earthwork_table_for_current_route(project_id)
except Exception:
logger.exception("B08 인계 — 토적표 조회 실패: project_id=%s", project_id)
return {}
if response.status_code != 200:
return {}
import json as _json
return _json.loads(bytes(response.body).decode("utf-8"))