Files
Aislo/B08_Quantity/B08_Quantity_Router_Material.py
T
2026-09-14 17:42:49 +09:00

636 lines
30 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 json
import logging
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, Field
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B06_Section.B06_Section_Repository import get_cross_section_designs
from B06_Section.B06_Section_Repository import get_workflow_route_context
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, load_mapping, summarize
from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table as build_material_table
from B08_Quantity.B08_Quantity_Provenance import quantity_provenance
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import project_templates
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import (
ground_types_from_designs,
section_modes_from_designs,
)
from common_util.common_util_project_settings import (
concrete_placing_method,
quantity_settings,
rock_classes,
rock_method,
)
from B08_Quantity.B08_Quantity_Engine_HaulInputs import haul_inputs
from B08_Quantity.B08_Quantity_Engine_Preparation import frame_material_rows
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_structure_lengths import structure_lengths
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,
designs: list[dict[str, Any]] | None = None,
) -> tuple[list[dict[str, Any]], dict[str, str], list[str]]:
"""전개 대상 구조물·타입명·건너뛴 사유를 함께 낸다.
⭐ 정본 둘을 다 읽음(A1, 2026-09-14) — `structures.json` + `pipe_points.json`(계곡 통과 시설).
앞서 뒤엣것을 안 읽어 집수정·날개벽·관 유입/유출 기슭막이·독립 기슭막이·물넘이포장이
원단위·인계·내역에 한 줄도 안 섰음.
⚠ 관 지점 종류(`managed_by`)가 `structures.json` 에 옛 저장분으로 남아 있어도 안 셈 —
관 지점 정본이 주인이라 두 번 세지 않음(구조물 집계표와 같은 규칙).
"""
from B08_Quantity.B08_Quantity_Engine_Pipe import extra_walls_from_designs, facility_structures
from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file
_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.managed_by:
skipped.append(
f"{definition.name}: 관 지점 정본이 주인 — 구조물 목록 옛 저장분은 안 셈"
)
continue
if definition.design_owner:
skipped.append(
f"{definition.name}: {definition.design_owner} 가 이미 셈 — 중복 계상 방지"
)
continue
if definition.reference_only:
skipped.append(f"{definition.name}: 전문 상세설계 대상 — 배치까지만")
continue
if definition.group == "B":
# ⚠ B군(종단배수)은 **연장표**로 간다 — `common_util_structure_lengths` 가
# 겹친 구간을 합쳐 주기 때문이다. 구조물별로 세면 겹친 구간을 두 번 센다.
# 여기서 빼지 않으면 **같은 시설이 두 줄로** 나간다.
continue
targets.append(payload)
points = read_pipe_points_file(pipe_points_path_in(Path(project_root)))
# 다단 추가 기슭막이(④) — 횡단 설계에 남은 「선 다단 벽 목록」도 줄로 세움.
extra_walls = extra_walls_from_designs(designs)
for row in facility_structures([point.as_dict() for point in points], extra_walls):
for type_id in (row["type_id"], row.get("attachment_parent_type")):
if type_id in types:
names[type_id] = types[type_id].name
targets.append(row)
return targets, names, sorted(set(skipped))
async def _ground_types(project_id: UUID) -> dict[float, str]:
"""측점별 지반 갈래(`soil`·`ripping_rock`·`blasting_rock`).
구조물터파기(품셈 9-13)의 **토질 축**이 이 값으로 갈린다. 단면유형과 같은 자리에서
오므로 읽는 방식도 같다 — 못 읽으면 빈 표로 두고 판정이 「못 가름」이 되게 한다.
"""
try:
context = await run_with_connection(get_workflow_route_context, project_id)
route_id = int((context or {}).get("route_id") or 0)
if not route_id:
return {}
designs = await run_with_connection(get_cross_section_designs, route_id)
except Exception:
logger.exception("B08 지반 갈래 조회 실패: project_id=%s", project_id)
return {}
return ground_types_from_designs(designs)
async def _section_modes(project_id: UUID) -> dict[float, str]:
"""측점별 단면유형(`left_cut` 등). 구조물이 **성토면인가 절토면인가**를 가릴 때 쓴다.
⚠ 새 저장 키를 만들지 않는다 — 이미 저장되는 `design.section_mode` 를 읽기만 한다.
못 읽으면 빈 표로 두고, 판정이 「가를 근거 없음」이 되게 한다(성토로 눅이지 않음).
"""
try:
context = await run_with_connection(get_workflow_route_context, project_id)
route_id = int((context or {}).get("route_id") or 0)
if not route_id:
return {}
designs = await run_with_connection(get_cross_section_designs, route_id)
except Exception:
logger.exception("B08 단면유형 조회 실패: project_id=%s", project_id)
return {}
return section_modes_from_designs(designs)
def material_table_for(
unit_table: dict[str, Any],
settings: dict[str, Any],
preparation_table: dict[str, Any] | None,
) -> dict[str, Any]:
"""자재총괄 **한 벌** — 화면(`material-summary`)·인계(`handoff`)가 같이 부름(브레인 판정).
⚠ 규준틀 재료는 **준비공 개소가 선 뒤에야** 서므로 준비공 표를 받아 여기서 얹음. 종전엔 인계만
얹어 화면 6줄 · 인계 9줄로 갈림 → 화면에서 규준틀 재료의 관급구분·할증을 못 만짐.
⚠ 화면 쪽에 따로 복사하지 말 것 — 두 벌이 되면 또 갈림.
⚠ 값은 **제안값(실무 관측)**이고 산출 조건에서 고칠 수 있음 — 그 사실이 줄 사유에 적힘.
"""
frame_rows = [
row
for row in ((preparation_table or {}).get("rows") or [])
if str(row.get("item") or "").endswith("규준틀")
]
return build_material_table(
unit_table,
supply_map=settings.get("material_supply") or {},
surcharge_overrides=settings.get("material_surcharge") or {},
# 콘크리트 할증은 **레미콘일 때만** 붙는다 — 방식이 이름을 가른다(확정 3차 ⑥).
concrete_placing_method=settings.get("concrete_placing_method"),
extra_materials=frame_material_rows(frame_rows, settings.get("frame_material") or {}),
)
@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, await _designs(project_id))
except Exception:
logger.exception("B08 구조물 정본 읽기 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "구조물 정본을 읽지 못했습니다."},
)
settings = quantity_settings(project_root)
unit_table = build_unit_table(
structures,
names,
await _section_modes(project_id),
await _ground_types(project_id),
settings.get("rubble_base_thickness_m"),
# 구조물도에서 고친 식 — 안 넘기면 원단위·자재총괄이 구조물도와 갈림(PLAN 3장 ⑤).
structure_formulas=settings.get("structure_formula_overrides"),
# 프로젝트에 박힌 양식(PLAN 4장) — 같은 까닭.
structure_templates=project_templates(project_root),
)
# 인계와 같은 한 벌 — 규준틀 재료가 준비공 표를 따라 서므로 토공 표를 받음.
earthwork = await _earthwork_tables(project_id)
material_table = material_table_for(unit_table, settings, earthwork.get("preparation"))
# 묶음으로 서는 구조물의 조각을 화면에도 보인다 — 코드만으로는 사람이 검증 못 한다.
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")
]
body: dict[str, Any] = {
"unit_quantity": unit_table,
"material": material_table,
"composite": composite,
"skipped_structures": skipped,
"structure_count": len(structures),
}
# 근거 사전(PLAN 8-36 ④) — ⚠ **개발환경에서만** 실린다. 운영에서는 `None` 이라
# 칸 자체가 안 생긴다 — 화면에서 숨기는 것이 아니라 안 보내는 것이 요점이다.
provenance = quantity_provenance()
if provenance is not None:
body["provenance"] = provenance
return JSONResponse(content=body)
@router.get("/{project_id}/quantity/structure-summary")
async def get_structure_summary(project_id: UUID) -> JSONResponse:
"""**구조물 집계표**(PLAN 2장) — 측점별 한 줄 · 종류별 표 · 칸마다 출처(자동·사용자·라이브러리).
⚠ 값을 셈하지 않음 — 정본 둘(`structures.json`·`pipe_points.json`)과 B06 관 연장을 읽기만.
"""
from B08_Quantity.B08_Quantity_Engine_StructureSummary import (
USER_CELLS_KEY,
build_summary,
pipe_lengths_from_designs,
)
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import template_of
from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file
try:
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
project_root = resolve_stored_project_path(stored_path)
revision, items = load_structures(project_root)
points = read_pipe_points_file(pipe_points_path_in(Path(project_root)))
except Exception:
logger.exception("B08 구조물 집계표 — 정본 읽기 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "구조물 정본을 읽지 못했습니다."},
)
types = structure_type_map()
embedded = project_templates(project_root)
templates = {type_id: template_of(type_id, embedded) or {} for type_id in types}
body = build_summary(
[item.model_dump() for item in items],
[point.as_dict() for point in points],
types,
templates,
pipe_lengths_from_designs(await _designs(project_id)),
quantity_settings(project_root).get(USER_CELLS_KEY) or {},
)
return JSONResponse(
content={"status": "success", "revision": revision, "project_id": str(project_id), **body}
)
async def _designs(project_id: UUID) -> list[dict[str, Any]]:
"""현재 노선의 저장된 횡단 설계. 못 읽으면 빈 목록 — 관 연장이 빈칸으로 섬(0 아님)."""
try:
context = await run_with_connection(get_workflow_route_context, project_id)
route_id = int((context or {}).get("route_id") or 0)
return await run_with_connection(get_cross_section_designs, route_id) if route_id else []
except Exception:
logger.exception("B08 구조물 집계표 — 횡단 설계 조회 실패: project_id=%s", project_id)
return []
class StructureSummaryEdit(BaseModel):
"""집계표 칸 하나 — 빈 값(null·"")은 그 칸을 지움."""
model_config = ConfigDict(extra="forbid")
id: str = Field(min_length=1, max_length=200)
key: str = Field(min_length=1, max_length=100)
value: float | str | None = None
class StructureSummaryEditRequest(BaseModel):
"""[저장] 한 번에 보내는 손 고침 — 읽어 간 구조물 판번호와 함께."""
model_config = ConfigDict(extra="forbid")
base_revision: int = Field(ge=0)
edits: list[StructureSummaryEdit] = Field(max_length=500)
def _summary_error(status: int, message: str) -> JSONResponse:
return JSONResponse(status_code=status, content={"status": "error", "message": message})
@router.put("/{project_id}/quantity/structure-summary")
async def put_structure_summary(
project_id: UUID, payload: StructureSummaryEditRequest
) -> JSONResponse:
"""집계표 손 고침을 **정본에 바로 씀**(브레인 판정) + 산출 조건에 「손댄 칸」 표.
⚠ 덮개층을 두지 않음 — `structures.json`·`pipe_points.json` 이 곧 도면·수량의 한 값(지침 5장).
⚠ 다 맞춰 본 뒤에 씀 — 한 칸이라도 틀리면 아무것도 안 씀(구조물 판번호가 어긋나도 409).
⛔ 놓기 칸(자리·길이·높이·관경)은 거절 — 시·종점과 한 벌이라 B05 몫.
"""
import asyncio
from B05_Profile.B05_Profile_Structures_Repository import (
StructureRevisionConflict,
save_structures,
)
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance
from B08_Quantity.B08_Quantity_Engine_StructureSummary import (
USER_CELLS_KEY,
apply_edits,
pipe_row_id,
prune_marks,
)
from common_util.common_util_drainage_pipes import pipe_points_path_in
from common_util.common_util_json import atomic_write_json
from common_util.common_util_project_settings import save_section
try:
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
project_root = resolve_stored_project_path(stored_path)
revision, items = load_structures(project_root)
path = pipe_points_path_in(Path(project_root))
document = json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {}
except Exception:
logger.exception("B08 구조물 집계표 저장 — 정본 읽기 실패: project_id=%s", project_id)
return _summary_error(500, "구조물 정본을 읽지 못했습니다.")
if revision != payload.base_revision:
return _summary_error(409, "그 사이 구조물이 바뀌었습니다 — 표를 다시 불러와 고칠 것.")
structures = [item.model_dump() for item in items]
points = [point for point in document.get("points") or [] if isinstance(point, dict)]
settings = quantity_settings(project_root)
try:
changed, marks = apply_edits(
structures,
points,
structure_type_map(),
[edit.model_dump() for edit in payload.edits],
settings.get(USER_CELLS_KEY) or {},
)
instances = [StructureInstance.model_validate(item) for item in structures]
except (LookupError, ValueError) as exc:
return _summary_error(422, str(exc))
new_revision = revision
pipe_ids = {pipe_row_id(point) for point in points}
try:
if any(row_id not in pipe_ids for row_id in changed):
new_revision = await asyncio.to_thread(
save_structures, project_root, instances, base_revision=revision
)
if any(row_id in pipe_ids for row_id in changed):
# 관 지점 파일은 옵션만 바꿔 **그대로** 씀 — 노선 지문·좌표·다른 칸은 손대지 않음.
await asyncio.to_thread(atomic_write_json, path, {**document, "points": points})
except StructureRevisionConflict:
return _summary_error(409, "그 사이 구조물이 바뀌었습니다 — 표를 다시 불러와 고칠 것.")
except ValueError as exc:
return _summary_error(422, str(exc))
await asyncio.to_thread(
save_section,
project_root,
"quantity",
{USER_CELLS_KEY: prune_marks(marks, structures, points)},
replace_keys=[USER_CELLS_KEY],
)
return JSONResponse(
content={"status": "success", "revision": new_revision, "changed_rows": len(changed)}
)
async def project_haul_inputs(project_id: UUID) -> dict[str, Any]:
"""유토곡선(B06)이 받아야 할 **구조물 몫** — 채집석 공제 · 구조물 잔토.
⚠ **B06 이 이 함수를 부르면 된다.** 두 값 다 B08 전개에서 나오는 것이라 저쪽이 다시
세면 같은 계산이 두 벌이 된다(CLAUDE.md 5장). 값은 **양수 ㎥** 이고 빼고 더하는 것은
받는 쪽 몫이다. 못 읽으면 빈 값(`None`) — 0 으로 눅이지 않는다.
"""
try:
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
project_root = resolve_stored_project_path(stored_path)
structures, names, _skipped = _collect_structures(project_root, await _designs(project_id))
settings = quantity_settings(project_root)
unit_table = build_unit_table(
structures,
names,
await _section_modes(project_id),
await _ground_types(project_id),
settings.get("rubble_base_thickness_m"),
structure_formulas=settings.get("structure_formula_overrides"),
structure_templates=project_templates(project_root),
)
except Exception:
logger.exception("B08 유토곡선 입력 조회 실패: project_id=%s", project_id)
return haul_inputs(None)
return haul_inputs(unit_table)
@router.get("/{project_id}/quantity/haul-inputs")
async def get_haul_inputs(project_id: UUID) -> JSONResponse:
"""같은 값을 화면·다른 창이 볼 수 있게 낸 자리. 계산은 위 함수 한 벌이다."""
return JSONResponse(content=await project_haul_inputs(project_id))
@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, await _designs(project_id))
settings = quantity_settings(project_root)
modes = await _section_modes(project_id)
unit_table = build_unit_table(
structures,
names,
modes,
await _ground_types(project_id),
settings.get("rubble_base_thickness_m"),
structure_formulas=settings.get("structure_formula_overrides"),
structure_templates=project_templates(project_root),
)
# 토공·운반 표는 토적표 라우터의 것을 그대로 쓴다 — 여기서 다시 만들지 않는다.
earthwork = await _earthwork_tables(project_id)
# 자재총괄 — 화면과 같은 한 벌(규준틀 재료 포함).
material_table = material_table_for(unit_table, settings, earthwork.get("preparation"))
handoff = build_handoff(
summary_table=earthwork.get("summary"),
haul_table=earthwork.get("haul"),
unit_quantity_table=unit_table,
material_table=material_table,
# 준비공·사방공 — 값이 서는 줄도, 못 내는 줄도 함께 넘긴다(빼면 빠진 줄이 안 보임).
preparation_table=earthwork.get("preparation"),
# B군 종단배수 — 겹침을 합친 연장. 그 규칙이 이미 그 함수에 있어 두 벌로 안 짠다.
length_table=[row for row in structure_lengths(project_root) if row.get("group") == "B"],
# 배수관 — 관 정본은 `pipe_points.json`, 연장은 측점 `design.pipe_length_m` 다.
pipe_table=_pipe_table(
project_root,
earthwork.get("pipe_lengths") or [],
earthwork.get("section_chainages") or [],
),
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],
# 층따기 길이 — 면적 × 이 값으로 ㎥ 를 낸다(확정 2차 ①). 안 넣었으면 막히고 사유가 감.
bench_cut_depth_m=settings.get("bench_cut_depth_m"),
# 용수 유무 — 기본 「육상」은 **통상값**이다(확정 3차 ④). 사유·화면에 그 사실이 뜬다.
structure_trench_water=settings.get("structure_trench_water"),
# 임목축적 등급 — 지장목제거 뿌리뽑기(제근 9-21)의 품 갈래. 안 넣으면 B09 가 후보를 보임.
stand_volume_class=settings.get("stand_volume_class"),
# 면고르기 갈래 — 절토면 토질 · 성토면 시공·토질. 비면 그 줄이 입력 사유로 막힘.
face_dressing_cut_class=settings.get("face_dressing_cut_class") or None,
face_dressing_fill_class=settings.get("face_dressing_fill_class") or None,
# 제근 굴착기 크기(0.2·0.7) — 비면 뿌리뽑기 줄이 입력 사유로 막힘(제안 0.7 은 칸 곁에만).
root_removal_excavator_m3=settings.get("root_removal_excavator_m3") or None,
# 구조물도 양식 일위대가로 셀 장 — 그 구조물은 호표 `AX-ST` 줄 하나로(PLAN 6장 ②).
priced_sheets=_priced_sheets(project_root, unit_table, modes, settings),
)
handoff["summary"] = summarize(handoff)
handoff["skipped_structures"] = skipped
handoff["earthwork_available"] = bool(earthwork)
return JSONResponse(content=handoff)
def _priced_sheets(
project_root: str,
unit_table: dict[str, Any],
section_modes: dict[float, str] | None,
settings: dict[str, Any],
) -> list[dict[str, Any]]:
"""양식 일위대가로 셀 구조물도 장 — 구조물도 탭과 **같은 접기**(`build_standard_sheets` →
`apply_templates`)로 세움. 인계와 내역 금액이 이 한 목록을 봄."""
from B08_Quantity.B08_Quantity_Engine_StructurePriceLink import priced_sheets
from B08_Quantity.B08_Quantity_Engine_StructureSheet import build_standard_sheets
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import apply_templates, template_of
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import ROWS_KEY
embedded = project_templates(project_root)
payload = build_standard_sheets(unit_table, section_modes)
apply_templates(payload, settings, embedded)
sheets = payload.get("sheets") or []
templates = {
type_id: template
for type_id in {str(sheet.get("type_id") or "") for sheet in sheets}
if (template := template_of(type_id, embedded))
}
return priced_sheets(sheets, templates, settings.get(ROWS_KEY))
async def structure_bill_prices(project_id: UUID, build: Any) -> dict[str, dict[str, Any]]:
"""B09 내역이 부름 — 구조물도 호표 금액. **B09 가 넘긴 단가표**로 B08 일위대가 엔진을 돌림.
⚠ 금액 정본은 B09 — 여기서 단가표를 따로 짓지 않음. 수동 단가(프로젝트 값)도 입력으로 씀.
⚠ 인계(`get_handoff`)와 **같은 전개**를 한 번 더 돎 — 두 요청이라 한 벌을 못 나눔.
"""
# ponytail: 인계와 전개를 두 번 돎 — 느려지면 인계 응답에 장 목록을 실어 한 번으로.
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import available_templates
from B08_Quantity.B08_Quantity_Engine_StructurePriceLink import price_sheets
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import MANUAL_KEY, ROWS_KEY, with_rows
from B09_Estimation.B09_Estimation_UnitPrice import find_variant_code
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
project_root = resolve_stored_project_path(stored_path)
structures, names, _skipped = _collect_structures(project_root, await _designs(project_id))
settings = quantity_settings(project_root)
modes = await _section_modes(project_id)
unit_table = build_unit_table(
structures,
names,
modes,
await _ground_types(project_id),
settings.get("rubble_base_thickness_m"),
structure_formulas=settings.get("structure_formula_overrides"),
structure_templates=project_templates(project_root),
)
entries = _priced_sheets(project_root, unit_table, modes, settings)
if not entries:
return {}
row_edits = settings.get(ROWS_KEY) or {}
library = [
with_rows(template, row_edits.get(template.get("type_id")))
for template in available_templates(project_root)
]
return price_sheets(
entries,
build.book,
lambda code, value: find_variant_code(code, value, build),
library,
settings.get(MANUAL_KEY) or {},
)
def _pipe_table(
project_root: str,
pipe_lengths: list[dict[str, Any]],
section_chainages: list[Any] | None = None,
) -> dict[str, Any]:
"""배수관 표 — 정본 셋을 읽어 잇는다. 못 읽으면 **빈 표**(줄이 안 서는 것이 정직하다).
⚠ 관 정본은 `structures.json` 이 아니라 `pipe_points.json` 이다
(레지스트리 `pipe` 타입이 `managed_by: pipe_points`).
"""
from B08_Quantity.B08_Quantity_Engine_Pipe import build_rows as build_pipe_rows
from common_util.common_util_drainage_pipes import pipe_points_path_in
path = pipe_points_path_in(Path(project_root))
if not path.is_file():
return {"rows": [], "notes": [], "pipe_count": 0, "ready_count": 0, "length_total_m": 0.0}
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
logger.exception("B08 관 지점 읽기 실패: %s", path)
return {
"rows": [],
"notes": ["관 지점 파일을 읽지 못했습니다"],
"pipe_count": 0,
"ready_count": 0,
"length_total_m": 0.0,
}
points = payload.get("points") or payload.get("items") or []
# 토적표 라우터가 실어 준 모양을 엔진이 읽는 모양으로 옮긴다.
designs = [
{"chainage_m": row.get("chainage_m"), "design": {"pipe_length_m": row.get("pipe_length_m")}}
for row in pipe_lengths
]
return build_pipe_rows(
points,
designs,
(load_mapping().pipe or {}),
[float(x) for x in (section_chainages or []) if x is not None],
)
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"))