Files
Aislo/B08_Quantity/B08_Quantity_Router_Material.py
T
eomsangdonandClaude Opus 5.5 c6bb3cc5b6 feat(b08): 자재수량 → 총괄 · 콘크리트소요 · 채집석 공제 엑셀 흐름(160-3 · 4 · 5)
- 160-3 맞춤 없는 사본 자재 = 총괄 끝 줄(이름 · 규격 · 단위 그대로 · 알림에 이름 목록) · 맞춤 = 이름 · 규격 · 단위 · 기계 주연료 · 할증(ℓ) 뺌 · 참조 줄(승격 · 사전조합) 안 재료 셈 · 고른 품목 = MT 코드 · 파이썬 · TS 짝
- 160-4 콘크리트(기계) · 모르터 1:3 열 늘 보임(손 단위수량 → 콘크리트소요) · 엑셀 손값과 다른 배합 · 할증은 표 아래 알림 한 줄
- 160-5 채집석 공제(돌 채집 둘 · 고임돌 · 막자갈 합) = 총괄 줄 · 토공 잔토처리 = ROUND 0(잔토 − 공제) · 공제 줄 보임 · TS 짝
- 시험 test_160_3 · 4 · 5 새 · test_143_3 · 143_4 · 69 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sDZSjFvoiDjdGpgj3muc3
2026-10-09 22:53:18 +09:00

250 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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` · 서버 단독 — 브라우저는 받은 값으로 짝 계산) · 참조 줄(승격 · 사전조합)
안 재료도 셈 · 기계 주연료 · 할증(ℓ)은 뺌(원가 몫 · 160-3).
"""
from __future__ import annotations
import asyncio
import re
from decimal import Decimal
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 (
DEDUCT_KEY,
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"
#: 기계 주연료 · 할증 · 잡유(ℓ) — 기계경비(원가) 몫 · 자재 표에서 뺌(160-3).
# ponytail: 이름 글 맞춤 — 새 연료 이름이 나오면 여기에 더함
FUEL = re.compile(r"연료|경유|휘발유|중유|등유|잡유|gasoline", re.IGNORECASE)
def _fuel(name: str, unit: str) -> bool:
return unit in ("ℓ", "L") and FUEL.search(name) is not None
def _qty(value: Any) -> float | None:
return _num(float(value) if isinstance(value, Decimal) else value)
def _ref_mats(whole: Any, read: Any, ref: dict, trail: tuple[str, ...] = ()) -> list[tuple]:
"""참조 문서 1 단위 재료 `[(재료 줄, 수량)]` — 층 셈 펼침(`M02_Labor_Ref.Expand`)과 같은 길:
승격 = 원 로직 재료 ÷ 배 · 사전조합 = 안 줄 재료 × 손 수량(빈칸 1) · 로직 줄은 알약 배로 나눔 ·
순환 · 깊이 넘음 · 없는 문서 = 빈 것."""
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Labor_Ref as refs
rid = refs.ref_id(ref)
doc = read(ref)
if rid in trail or len(trail) >= refs.DEPTH or not isinstance(doc, dict):
return []
def run(one: dict, times: Any) -> list[tuple]:
got = labor.run_line(
whole,
str(one.get("로직") or ""),
one.get("입력값"),
one.get("켜쌓기율"),
one.get("고름"),
)
div = _qty(times) or 1.0
return [(m, q / div) for m in got.get("재료") or [] if (q := _qty(m.get("수량")))]
lines = [one for one in doc.get("담은로직") or [] if isinstance(one, dict)]
if refs.kind_of(ref["키"]) == "promote":
return run(lines[0], doc.get("배")) if lines else []
kept = doc.get("알약")
pills = kept if isinstance(kept, list) else combo_store.combo_pills({"담은로직": lines})["물량"]
by_logic = {code: pill for pill in pills for code in pill.get("로직") or []}
out: list[tuple] = []
for one in lines:
if isinstance(one.get("참조"), dict):
sub = _ref_mats(whole, read, one["참조"], (*trail, rid))
elif one.get("로직"):
sub = run(one, (by_logic.get(one["로직"]) or {}).get("배"))
else:
continue
hand = _qty((one.get("수량") or {}).get("손"))
out += [(m, q * (1.0 if hand is None else hand)) for m, q in sub]
return out
def _folder_units(root: str | Path, folder: str, meta: dict[str, dict]) -> dict[str, float]:
"""사본 하나 = 집계 항목 1 단위의 자재 `{키: 수량}` — 일위대가 자재(키 = MT 코드) + 로직 재료
(키 = 고른 품목 MT 코드 · 없으면 이름|단위 · 재료 수량 × 줄 수량 ÷ 배) + 참조 줄 안 재료
(160-3) · 줄 수량 빈칸 · 0 · 파형강관 · 기계 연료는 안 담음 · 셈 못 하면 빈 것."""
from M02_MasterTemplete import M02_Item_Copy as copies
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete.M02_Item_Prices import load_prices
from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, PRICE_FILE, read_json
try:
got = copies.calc(root, folder)
bundle = read_json(copies.folder_of(root, folder) / PRICE_FILE) or {}
labor = read_json(copies.folder_of(root, folder) / LABOR_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
def logic_mat(mat: dict, qty: float) -> None:
name, unit = str(mat.get("이름") or ""), str(mat.get("단위") or "")
if _fuel(name, unit):
return
goods = mat.get("품목")
code = str(goods.get("키") or "") if isinstance(goods, dict) else ""
if row := rows.get(code) if code else None: # 고른 품목 = 이름 · 규격 맞춤(160-3)
put(code, str(row.get("이름") or name), str(row.get("규격") or ""), unit, qty)
else:
put(f"{name}|{unit}", name, "", unit, qty)
frozen = (labor.get("참조사본") if isinstance(labor, dict) else None) or {}
whole = None # 사본 단가 — 참조 줄이 있을 때만 읽음
def read(ref: dict) -> Any:
return frozen.get(refs.ref_id(ref))
for line in got.get("줄") or []:
qty, times = _num(line.get("수량")), _num(line.get("배")) or 1.0
if qty is None:
continue
if isinstance(line.get("참조"), dict): # 참조 줄 = 배 1
whole = load_prices(bundle) if whole is None else whole
for mat, per in _ref_mats(whole, read, line["참조"]):
logic_mat(mat, per * qty)
continue
for mat in line.get("재료") or []:
if (per := _num(mat.get("수량"))) is not None:
logic_mat(mat, 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"]:
names = {m["key"]: m for m in sheets["quantity"]["materials"]}
listed = " · ".join(
" ".join(filter(None, (names[k]["name"], names[k]["spec"], names[k]["unit"])))
for k in lost
)
notes.append(f"2공구 열 맞춤 없음 {len(lost)}종(총괄 끝 줄 그대로) — {listed}")
return {
"source": source,
"overrides": overrides,
"surcharge_pct": panel,
# 구조물 집계표 알림(「집수정 모양 기록 없음 n곳」 등) — 총괄 · 콘크리트 위 빈 까닭(131)
"notes": notes,
"sheets": sheets,
}
async def picked_deduction(project_id: UUID, root: str | Path) -> float | None:
"""채집석 공제 ㎥(160-5) — 저장본 기준 총괄 「채집석 공제」 줄 값 · 토공 잔토처리에서 뺌."""
overrides = clean_overrides(quantity_settings(root).get(SETTINGS_KEY))
rows = (await _sheets(project_id, root, overrides))["sheets"]["material"]["rows"]
return next((row["amount"] for row in rows if row["key"] == DEDUCT_KEY), None)
@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)