"""B08 자재 집계표 셋 길(PLAN 121-1) — 총괄자재 · 콘크리트소요자재 · 자재수량 집계표. GET /api/projects/{id}/quantity/materials 구조물 집계표(98 `structure_summary` 그대로 부름) + 저장된 사용자 값 → 세 표 PUT /api/projects/{id}/quantity/materials {overrides} [저장] — 사용자 값을 프로젝트 설정 quantity › material_sheets 에 · 서버가 정본으로 다시 셈 계산 = `B08_Quantity_Material_Calc.py`(화면 짝 `.ts` 와 같은 식) · 총괄 모래 · 자갈 · 시멘트 할증 = 좌측 패널 값(`surcharge_pct` · 저장은 옵션 길 PUT …/quantity/options) 기본값은 파일에 안 씀(읽을 때 채움). 단위수량 기본(143-4) = 연결된 사본 셈(`M02_Item_Copy.calc` = 층 셈 한 벌)의 일위대가 자재 · 로직 재료(`snapshot_units` · 서버 단독 — 브라우저는 받은 값으로 짝 계산). """ from __future__ import annotations import asyncio from pathlib import Path from typing import Any from uuid import UUID from fastapi import APIRouter, Body from B08_Quantity.B08_Quantity_Material_Calc import ( PANEL_KEY, _num, clean_overrides, compute, panel_surcharge, round6, ) from B08_Quantity.B08_Quantity_Router_Hydro import _project_root from B08_Quantity.B08_Quantity_Router_Summary import _combo, structure_summary from common_util.common_util_project_settings import quantity_settings, save_section from common_util.common_util_summary_item import GROUP_JOIN router = APIRouter(prefix="/api/projects", tags=["B08 Material"]) SETTINGS_KEY = "material_sheets" def _folder_units(root: str | Path, folder: str, meta: dict[str, dict]) -> dict[str, float]: """사본 하나 = 집계 항목 1 단위의 자재 `{키: 수량}` — 일위대가 자재(키 = MT 코드) + 로직 재료 (키 = 이름|단위 · 재료 수량 × 줄 수량 ÷ 배) · 줄 수량 빈칸 · 0 · 파형강관은 안 담음 · 셈 못 하면 빈 것. ponytail: 참조 줄(승격 · 사전조합) 안 재료는 안 셈 — 펼친 재료가 셈 답에 오면 그때.""" from M02_MasterTemplete import M02_Item_Copy as copies from M02_MasterTemplete.M02_Item_Source import PRICE_FILE, read_json try: got = copies.calc(root, folder) bundle = read_json(copies.folder_of(root, folder) / PRICE_FILE) or {} except (LookupError, ValueError): return {} rows = { row.get("키"): row for data in bundle.values() if isinstance(data, dict) for row in data.get("줄") or data.get("표") or [] if isinstance(row, dict) } out: dict[str, float] = {} def put(key: str, name: str, spec: str, unit: str, qty: float) -> None: if not qty or name.startswith("파형강관"): # 0 · 관 몫(관매설 → 파형강관 1 · 관경 열) return meta.setdefault(key, {"key": key, "name": name, "spec": spec, "unit": unit}) out[key] = out.get(key, 0.0) + qty for line in got.get("줄") or []: qty, times = _num(line.get("수량")), _num(line.get("배")) or 1.0 for mat in line.get("재료") or [] if qty is not None else []: if (per := _num(mat.get("수량"))) is not None: name, unit = str(mat.get("이름") or ""), str(mat.get("단위") or "") put(f"{name}|{unit}", name, "", unit, per * qty / times) for mat in got.get("자재") or []: code, qty = str(mat.get("코드") or ""), _num(mat.get("수량")) if code and qty is not None: row = rows.get(code) or {} put( code, str(row.get("이름") or code), str(row.get("규격") or ""), str(row.get("단위") or ""), qty, ) return out def snapshot_units(root: str | Path, summary: dict[str, Any]) -> dict[str, Any]: """연결된 사본 자재 → 집계표 열마다 단위수량 기본(143-4) — 칸마다(측점 · 시설) 그 칸을 낸 조합의 사본 자재 × 칸 값을 모아 열 계로 나눔(시설마다 사본이 달라도 계가 맞음) · 사본 없으면 안 담음.""" folders: dict[tuple[str, str | None], list[str]] = {} for owner_id, entry in (summary.get("linked") or {}).items(): owner = owner_id.partition(GROUP_JOIN)[0] folders.setdefault((owner, _combo(entry.get("항목"))), []).append(entry["폴더"]) meta: dict[str, dict] = {} per_folder: dict[str, dict[str, float]] = {} sums: dict[str, dict[str, float]] = {} for cid, value, owner, combos in summary.get("sources") or []: for folder in (f for combo in combos for f in folders.get((owner, combo), [])): if folder not in per_folder: per_folder[folder] = _folder_units(root, folder, meta) cell = sums.setdefault(cid, {}) for key, qty in per_folder[folder].items(): cell[key] = cell.get(key, 0.0) + value * qty total = summary.get("total") or {} units = { cid: {key: round6(qty / total[cid]) for key, qty in cells.items()} for cid, cells in sums.items() if cells and total.get(cid) } used = {key for cells in units.values() for key in cells} return {"materials": [m for key, m in meta.items() if key in used], "units": units} async def _sheets(project_id: UUID, root: str | Path, overrides: dict[str, Any]) -> dict[str, Any]: summary = await structure_summary(project_id) source = { "columns": summary["columns"], "total": summary["total"], "snapshot": await asyncio.to_thread(snapshot_units, root, summary), } panel = panel_surcharge(quantity_settings(root).get(PANEL_KEY)) sheets = compute(source, overrides, panel) notes = list(summary.get("notes") or []) if lost := sheets["quantity"]["unmatched"]: notes.append( f"사본 자재 {len(lost)}종 — 2공구 열 맞춤 없음(콘크리트소요 · 총괄로 안 끌어옴)" ) return { "source": source, "overrides": overrides, "surcharge_pct": panel, # 구조물 집계표 알림(「집수정 모양 기록 없음 n곳」 등) — 총괄 · 콘크리트 위 빈 까닭(131) "notes": notes, "sheets": sheets, } @router.get("/{project_id}/quantity/materials") async def material_sheets(project_id: UUID) -> dict[str, Any]: root = await _project_root(project_id) overrides = clean_overrides(quantity_settings(root).get(SETTINGS_KEY)) return await _sheets(project_id, root, overrides) @router.put("/{project_id}/quantity/materials") async def save_material_sheets( project_id: UUID, body: dict[str, Any] = Body(...) ) -> dict[str, Any]: root = await _project_root(project_id) overrides = clean_overrides(body.get("overrides")) save_section(root, "quantity", {SETTINGS_KEY: overrides}, replace_keys=(SETTINGS_KEY,)) return await _sheets(project_id, root, overrides)