revert(b08): 수량 프로토타입 폐기 — 셸 상태로 원복
사용자 판단: 수량 산출서 양식 아님 (2026-08-31). 내일 재작업 예정. B08_Quantity_Proto.py 삭제, 라우터·UI 페이지 이관 전 상태로 복원. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,592 +0,0 @@
|
||||
"""B08 수량 산출 프로토타입 (처분 가능 — disposable).
|
||||
|
||||
사용자 평가용 시안이다. 이 파일 + `B08_Quantity_Router.py`의 include 2줄이 전부라
|
||||
마음에 안 들면 그대로 지우면 된다. 정본을 읽기만 하고 어디에도 쓰지 않는다.
|
||||
|
||||
페이지: GET /api/projects/quantity-proto/page — 프로젝트 목록
|
||||
GET /api/projects/quantity-proto/page/{id} — 수량 산출 보고서
|
||||
|
||||
구조물 출처(정본 3곳, CLAUDE.md 5장):
|
||||
① pipe_points.json — 계곡 통과 시설(배수관·BOX암거·물넘이·세월교·독립 기슭막이)
|
||||
② structures.json — 일반 구조물(C·D·E군)
|
||||
③ 횡단 설계 patch(DB) — 기준벽 조작·다단 벽(extra_wall_counts·extra_spans)
|
||||
|
||||
원단위 출처: 실무 관측 **참조 전용** — 채용 미확정(개발 단계 사용자 협의 대상).
|
||||
· 울진1 수량집계 13종 (resources/knowledge/original/실무문서/_종합비교/04_임도구조물_원단위.md)
|
||||
· 울진소광 구조도 숨김탭 (resources/knowledge/original/실무문서/_원단위라이브러리_울진소광.md)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from B01_Dashboard.B01_Dashboard_Repository import list_all_projects
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_Profile.B05_Profile_Structures_Repository import load_structures
|
||||
from B06_Section.B06_Section_Repository import (
|
||||
get_confirmed_route_context,
|
||||
get_cross_section_designs,
|
||||
get_longitudinal_section,
|
||||
)
|
||||
from common_util.common_util_drainage_pipes import parse_pipe_points
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
proto_router = APIRouter()
|
||||
|
||||
# ---------------------------------------------------------------- 타입 이름표
|
||||
|
||||
_FACILITY_NAMES = {
|
||||
"pipe": "배수관",
|
||||
"box_culvert": "BOX암거",
|
||||
"ford_pavement": "물넘이포장",
|
||||
"ford_bridge": "세월교",
|
||||
"revetment": "기슭막이(독립)",
|
||||
}
|
||||
|
||||
_REGISTRY_PATH = Path(__file__).parent.parent / "B05_Profile" / "B05_Profile_Structure_Types.json"
|
||||
_type_names: dict[str, str] | None = None
|
||||
|
||||
|
||||
def _type_name(type_id: str) -> str:
|
||||
global _type_names
|
||||
if _type_names is None:
|
||||
try:
|
||||
payload = json.loads(_REGISTRY_PATH.read_text(encoding="utf-8"))
|
||||
_type_names = {t["type_id"]: t["name"] for t in payload.get("types", [])}
|
||||
except (OSError, ValueError, KeyError):
|
||||
_type_names = {}
|
||||
return _type_names.get(type_id, type_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 원단위 라이브러리
|
||||
# ⚠ 참조 전용(과거 공사지 관측값). 프로그램 기본값 아님 — 채용은 사용자 협의로 확정.
|
||||
# items: {공종명(단위): 단위수량}
|
||||
|
||||
_SRC_ULJIN1 = "울진1 수량집계"
|
||||
_SRC_SOGWANG = "울진소광 구조도 숨김탭"
|
||||
|
||||
UNIT_RATES: dict[tuple[str, str], dict[str, Any]] = {
|
||||
("basin", "Ø800"): {
|
||||
"label": "집수정 Ø800",
|
||||
"per": "개소",
|
||||
"source": _SRC_SOGWANG,
|
||||
"items": {
|
||||
"터파기(㎥)": 10.64,
|
||||
"되메우기(㎥)": 6.44,
|
||||
"잔토처리(㎥)": 4.20,
|
||||
"콘크리트(㎥)": 2.84,
|
||||
"이형철근 D13(kg)": 4.78,
|
||||
"면목 A25(m)": 12.67,
|
||||
"거푸집(㎡)": 21.28,
|
||||
},
|
||||
},
|
||||
("erosion_check", "찰쌓기·평균"): {
|
||||
"label": "돌골막이(찰쌓기) — 임도 실측 6개소 평균",
|
||||
"per": "개소",
|
||||
"source": _SRC_SOGWANG,
|
||||
"note": "관측 높이 2.5~3.0m는 사방기술교본 '2m 이내' 초과 — 판정 없이 기록(지식DB 주석 그대로)",
|
||||
"items": {
|
||||
"돌쌓기(㎡)": 19.62,
|
||||
"돌붙임(㎡)": 2.63,
|
||||
"야면석(ton)": 19.58,
|
||||
"고임돌(㎥)": 3.34,
|
||||
"막자갈(㎥)": 10.54,
|
||||
"채움콘크리트(㎥)": 4.45,
|
||||
"물구멍(m)": 4.91,
|
||||
"바닥파기(㎥)": 8.61,
|
||||
"되메우기(㎥)": 5.87,
|
||||
"잔토처리(㎥)": 2.74,
|
||||
},
|
||||
},
|
||||
("retaining_wall", "반중력식 H=2.0"): {
|
||||
"label": "반중력식옹벽 H=2.0",
|
||||
"per": "m",
|
||||
"source": _SRC_SOGWANG,
|
||||
"items": {
|
||||
"콘크리트(㎥)": 1.35,
|
||||
"버림콘크리트(㎥)": 0.15,
|
||||
"거푸집-유로폼(㎡)": 3.20,
|
||||
"거푸집-기초(㎡)": 0.60,
|
||||
"물빼기 Ø50(m)": 0.32,
|
||||
"이형철근 D13(kg)": 13.45,
|
||||
"이형철근 D16(kg)": 30.42,
|
||||
},
|
||||
},
|
||||
("revet_wet", "H=1.5"): {
|
||||
"label": "돌기슭막이 찰쌓기 H=1.5",
|
||||
"per": "m",
|
||||
"source": _SRC_ULJIN1,
|
||||
"items": {
|
||||
"콘크리트(㎥)": 0.31,
|
||||
"모르터(㎥)": 0.014,
|
||||
"돌쌓기(㎡)": 1.57,
|
||||
"석재(ton)": 0.91,
|
||||
"터파기(㎥)": 1.55,
|
||||
"되메우기(㎥)": 0.30,
|
||||
"잔토처리(㎥)": 1.25,
|
||||
},
|
||||
},
|
||||
("revet_wet", "H=2.0"): {
|
||||
"label": "돌기슭막이 찰쌓기 H=2.0",
|
||||
"per": "m",
|
||||
"source": _SRC_ULJIN1,
|
||||
"items": {
|
||||
"콘크리트(㎥)": 0.42,
|
||||
"모르터(㎥)": 0.019,
|
||||
"돌쌓기(㎡)": 2.09,
|
||||
"석재(ton)": 0.91,
|
||||
"터파기(㎥)": 2.06,
|
||||
"되메우기(㎥)": 0.40,
|
||||
"잔토처리(㎥)": 1.66,
|
||||
},
|
||||
},
|
||||
("revet_wet", "H=2.5"): {
|
||||
"label": "돌기슭막이 찰쌓기 H=2.5",
|
||||
"per": "m",
|
||||
"source": _SRC_ULJIN1,
|
||||
"items": {
|
||||
"콘크리트(㎥)": 0.52,
|
||||
"모르터(㎥)": 0.023,
|
||||
"돌쌓기(㎡)": 2.61,
|
||||
"석재(ton)": 0.91,
|
||||
"터파기(㎥)": 2.58,
|
||||
"되메우기(㎥)": 0.50,
|
||||
"잔토처리(㎥)": 2.08,
|
||||
},
|
||||
},
|
||||
("completion_sign", "화강암 300×200×600"): {
|
||||
"label": "임도준공판 (화강암 300×200×600)",
|
||||
"per": "개소",
|
||||
"source": _SRC_SOGWANG,
|
||||
"items": {
|
||||
"화강암(㎥)": 0.036,
|
||||
"기초콘크리트(㎥)": 0.014,
|
||||
"거푸집-유로폼(㎡)": 0.35,
|
||||
"터파기(㎥)": 0.105,
|
||||
"되메우기(㎥)": 0.08,
|
||||
"잔토처리(㎥)": 0.025,
|
||||
},
|
||||
},
|
||||
# 참고 표기용 — 관 부설 연장이 미산정이라 rollup에는 적용하지 않는다.
|
||||
("pipe_ref", "Ø800"): {
|
||||
"label": "관공 Ø800 (참고 — 연장 미산정으로 미적용)",
|
||||
"per": "m",
|
||||
"source": _SRC_ULJIN1,
|
||||
"items": {"터파기(㎥)": 4.5, "되메우기(㎥)": 3.99, "잔토처리(㎥)": 2.93},
|
||||
},
|
||||
("pipe_ref", "Ø1000"): {
|
||||
"label": "관공 Ø1000 (참고 — 연장 미산정으로 미적용)",
|
||||
"per": "m",
|
||||
"source": _SRC_ULJIN1,
|
||||
"items": {"터파기(㎥)": 5.6, "되메우기(㎥)": 4.81, "잔토처리(㎥)": 3.83},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 데이터 수집
|
||||
|
||||
|
||||
async def _collect(project_id: UUID) -> dict[str, Any]:
|
||||
"""정본 3곳을 읽어 화면 조립에 필요한 원자료만 모은다 (쓰기 없음)."""
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
route_context = await get_confirmed_route_context(connection, project_id)
|
||||
route_id = int(route_context["route_id"]) if route_context else None
|
||||
confirmed = False
|
||||
designs: list[dict[str, Any]] = []
|
||||
if route_id is not None:
|
||||
longitudinal = await get_longitudinal_section(connection, project_id, route_id)
|
||||
confirmed = bool(longitudinal and longitudinal.get("status") == "CONFIRMED")
|
||||
rows = await get_cross_section_designs(connection, route_id)
|
||||
designs = [row for row in rows if isinstance(row.get("design"), dict)]
|
||||
|
||||
root = Path(resolve_stored_project_path(stored_path)).resolve()
|
||||
|
||||
pipe_file = root / "B04_PreProcess" / "drainage" / "edits" / "pipe_points.json"
|
||||
pipes: list[Any] = []
|
||||
pipe_file_exists = pipe_file.is_file()
|
||||
if pipe_file_exists:
|
||||
try:
|
||||
document = json.loads(pipe_file.read_text(encoding="utf-8"))
|
||||
pipes = parse_pipe_points(document.get("points"))
|
||||
except (OSError, ValueError):
|
||||
logger.warning("수량 프로토: pipe_points.json 읽기 실패 — %s", pipe_file)
|
||||
|
||||
revision, structures = load_structures(str(root))
|
||||
|
||||
return {
|
||||
"route_id": route_id,
|
||||
"confirmed": confirmed,
|
||||
"designs": designs,
|
||||
"pipes": pipes,
|
||||
"pipe_file_exists": pipe_file_exists,
|
||||
"structures": structures,
|
||||
"structures_revision": revision,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 집계·매칭
|
||||
|
||||
|
||||
def _pipe_spec(point: Any) -> str:
|
||||
options = point.options or {}
|
||||
if point.facility == "pipe":
|
||||
kind = options.get("pipe_kind", "파형강관")
|
||||
dia = options.get("pipe_diameter_mm", 1000)
|
||||
return f"{kind} Ø{dia}"
|
||||
if point.facility == "box_culvert":
|
||||
return str(options.get("box_size", "규격 미지정"))
|
||||
return "—"
|
||||
|
||||
|
||||
def _span_text(point: Any) -> str:
|
||||
if point.start_m is not None and point.end_m is not None:
|
||||
return f"{point.start_m:.2f}~{point.end_m:.2f} (L={point.end_m - point.start_m:.2f}m)"
|
||||
return "미지정(폭 0)"
|
||||
|
||||
|
||||
def _structure_position(instance: Any) -> str:
|
||||
if instance.placement == "interval" and instance.start_m is not None:
|
||||
end = instance.end_m if instance.end_m is not None else instance.start_m
|
||||
return f"{instance.start_m:.2f}~{end:.2f} (L={end - instance.start_m:.2f}m)"
|
||||
if instance.chainage_m is not None:
|
||||
return f"{instance.chainage_m:.2f}m"
|
||||
return "—"
|
||||
|
||||
|
||||
def _wall_rows(designs: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""횡단 patch에서 벽 조작·다단 벽 흔적이 있는 측점만 표로 만든다."""
|
||||
rows: list[dict[str, Any]] = []
|
||||
for row in designs:
|
||||
design = row["design"]
|
||||
adjust = design.get("revet_adjust") or {}
|
||||
counts = design.get("extra_wall_counts") or {}
|
||||
spans = design.get("extra_spans") or {}
|
||||
if not adjust and not any(counts.values() if isinstance(counts, dict) else []):
|
||||
continue
|
||||
walls = []
|
||||
for key, patch in adjust.items() if isinstance(adjust, dict) else []:
|
||||
if not isinstance(patch, dict):
|
||||
continue
|
||||
height = patch.get("h")
|
||||
span = spans.get(key) if isinstance(spans, dict) else None
|
||||
length = span.get("length_m") if isinstance(span, dict) else None
|
||||
walls.append(
|
||||
{
|
||||
"key": key,
|
||||
"height": f"{height:.2f}m" if isinstance(height, (int, float)) else "자동",
|
||||
"material": patch.get("m") or "자동",
|
||||
"length": f"{length:.2f}m" if isinstance(length, (int, float)) else "—",
|
||||
}
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"chainage": float(row.get("chainage_m", 0)),
|
||||
"counts": counts if isinstance(counts, dict) else {},
|
||||
"walls": walls,
|
||||
}
|
||||
)
|
||||
rows.sort(key=lambda r: r["chainage"])
|
||||
return rows
|
||||
|
||||
|
||||
def _rollup(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""규격별 집계 → 원단위 매칭 → 공종별 합계. 매칭 불가는 사유와 함께 보류 목록으로."""
|
||||
matched: list[dict[str, Any]] = [] # 산출근거 행
|
||||
deferred: list[dict[str, Any]] = [] # 보류 행
|
||||
totals: dict[str, float] = defaultdict(float)
|
||||
|
||||
def apply(rate_key: tuple[str, str], quantity: float, positions: str) -> None:
|
||||
rate = UNIT_RATES[rate_key]
|
||||
lines = []
|
||||
for item, unit_qty in rate["items"].items():
|
||||
value = unit_qty * quantity
|
||||
totals[item] += value
|
||||
lines.append({"item": item, "unit_qty": unit_qty, "value": value})
|
||||
matched.append(
|
||||
{
|
||||
"label": rate["label"],
|
||||
"per": rate["per"],
|
||||
"quantity": quantity,
|
||||
"source": rate["source"],
|
||||
"note": rate.get("note", ""),
|
||||
"positions": positions,
|
||||
"lines": lines,
|
||||
}
|
||||
)
|
||||
|
||||
# ① 배수 시설 — 관 본체는 연장 미산정으로 보류, 유입 집수정만 개소당 적용
|
||||
basin_positions: list[str] = []
|
||||
pipe_counts: dict[str, int] = defaultdict(int)
|
||||
for point in data["pipes"]:
|
||||
facility = _FACILITY_NAMES.get(point.facility, point.facility)
|
||||
options = point.options or {}
|
||||
if point.facility == "pipe":
|
||||
pipe_counts[_pipe_spec(point)] += 1
|
||||
if options.get("inlet_type") == "집수정":
|
||||
basin_positions.append(f"{point.chainage_m:.2f}m")
|
||||
else:
|
||||
deferred.append(
|
||||
{
|
||||
"name": f"{facility} @{point.chainage_m:.2f}m",
|
||||
"reason": "원단위 미확보 (상세 치수·산식은 B06/구조도 확정 후)",
|
||||
}
|
||||
)
|
||||
if basin_positions:
|
||||
apply(("basin", "Ø800"), len(basin_positions), " · ".join(basin_positions))
|
||||
for spec, count in sorted(pipe_counts.items()):
|
||||
deferred.append(
|
||||
{
|
||||
"name": f"배수관 {spec} × {count}개소",
|
||||
"reason": "관 부설 연장 미산정 (B06 횡단 세트 연장 필요) — 관공 m당 원단위는 라이브러리 참고행",
|
||||
}
|
||||
)
|
||||
|
||||
# ② 일반 구조물 (structures.json)
|
||||
for instance in data["structures"]:
|
||||
name = _type_name(instance.type_id)
|
||||
position = _structure_position(instance)
|
||||
if instance.type_id == "erosion_check":
|
||||
apply(("erosion_check", "찰쌓기·평균"), 1, position)
|
||||
continue
|
||||
if instance.type_id == "completion_sign":
|
||||
apply(("completion_sign", "화강암 300×200×600"), 1, position)
|
||||
continue
|
||||
if instance.type_id == "retaining_wall" and instance.placement == "interval":
|
||||
length = (instance.end_m or 0) - (instance.start_m or 0)
|
||||
if length > 0:
|
||||
apply(("retaining_wall", "반중력식 H=2.0"), length, position)
|
||||
continue
|
||||
deferred.append(
|
||||
{"name": f"{name} @{position}", "reason": "원단위 미확보 또는 규격(치수) 미확정"}
|
||||
)
|
||||
|
||||
# ③ 횡단 벽 패치 — 높이·연장이 자동 계산 몫이라 전량 보류 (일람으로만 제시)
|
||||
wall_station_count = len(_wall_rows(data["designs"]))
|
||||
if wall_station_count:
|
||||
deferred.append(
|
||||
{
|
||||
"name": f"기준벽·다단 기슭막이 — 조작 측점 {wall_station_count}곳",
|
||||
"reason": "벽 높이(자동)·연장이 B06 엔진 산출 몫 — 규격 확정 후 돌기슭막이 H별 m당 원단위 적용 예정",
|
||||
}
|
||||
)
|
||||
|
||||
return {"matched": matched, "deferred": deferred, "totals": dict(totals)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- HTML
|
||||
|
||||
|
||||
_CSS = """
|
||||
body{font-family:'Malgun Gothic',sans-serif;margin:24px;background:#f5f6f8;color:#222}
|
||||
h1{font-size:20px} h2{font-size:16px;margin-top:28px;border-left:4px solid #4f8ef7;padding-left:8px}
|
||||
table{border-collapse:collapse;background:#fff;margin:8px 0;font-size:13px}
|
||||
th,td{border:1px solid #ccd;padding:4px 10px;text-align:left}
|
||||
th{background:#eef2fa} td.num{text-align:right;font-variant-numeric:tabular-nums}
|
||||
.banner{background:#fff3cd;border:1px solid #e0c060;padding:10px 14px;border-radius:6px;font-size:13px}
|
||||
.muted{color:#777;font-size:12px} .warn{color:#a55} .src{color:#476;font-size:12px}
|
||||
a{color:#3366cc}
|
||||
"""
|
||||
|
||||
|
||||
def _fmt(value: float) -> str:
|
||||
return f"{value:,.2f}"
|
||||
|
||||
|
||||
def _page(title: str, body: str) -> HTMLResponse:
|
||||
return HTMLResponse(
|
||||
"<!doctype html><html lang='ko'><head><meta charset='utf-8'>"
|
||||
f"<title>{title}</title><style>{_CSS}</style></head><body>{body}</body></html>"
|
||||
)
|
||||
|
||||
|
||||
def _render_report(project: dict[str, Any] | None, project_id: UUID, data: dict[str, Any]) -> str:
|
||||
rollup = _rollup(data)
|
||||
name = (project or {}).get("name") or str(project_id)
|
||||
parts: list[str] = [f"<h1>수량 산출 프로토타입 — {name}</h1>"]
|
||||
parts.append(
|
||||
"<div class='banner'>⚠ <b>평가용 시안.</b> 원단위는 실무 관측 <b>참조 전용</b>"
|
||||
"(울진1 수량집계·울진소광 구조도 숨김탭) — 프로그램 기본값 아님, 채용은 사용자 협의로 확정. "
|
||||
"수치 표기는 소수 2자리 고정(품셈 1-2-2 종목별 단위·소수 규칙은 본 구현 시 반영).</div>"
|
||||
)
|
||||
|
||||
# 데이터 원천 상태
|
||||
parts.append("<h2>데이터 원천</h2><table><tr><th>정본</th><th>상태</th></tr>")
|
||||
parts.append(
|
||||
f"<tr><td>pipe_points.json (배수·독립 기슭막이)</td><td>{'있음 — ' + str(len(data['pipes'])) + '건' if data['pipe_file_exists'] else '없음'}</td></tr>"
|
||||
)
|
||||
parts.append(
|
||||
f"<tr><td>structures.json (일반 구조물, rev {data['structures_revision']})</td><td>{len(data['structures'])}건</td></tr>"
|
||||
)
|
||||
patch_state = (
|
||||
f"확정 노선 route_id={data['route_id']} — 설계 저장 측점 {len(data['designs'])}곳"
|
||||
if data["route_id"] is not None
|
||||
else "확정 노선 없음 — 횡단 벽 패치 제외"
|
||||
)
|
||||
if data["route_id"] is not None and not data["confirmed"]:
|
||||
patch_state += " <span class='warn'>(B06 종단 미확정)</span>"
|
||||
parts.append(f"<tr><td>횡단 설계 patch (DB)</td><td>{patch_state}</td></tr></table>")
|
||||
|
||||
# ① 배수 시설 일람
|
||||
parts.append("<h2>① 배수 시설 일람 (pipe_points.json)</h2>")
|
||||
if data["pipes"]:
|
||||
parts.append(
|
||||
"<table><tr><th>측점</th><th>시설</th><th>규격</th><th>부속 구간(전~후)</th><th>유입구</th><th>출처</th></tr>"
|
||||
)
|
||||
for point in sorted(data["pipes"], key=lambda p: p.chainage_m):
|
||||
options = point.options or {}
|
||||
inlet = options.get("inlet_type", "기슭막이(기본)") if point.facility == "pipe" else "—"
|
||||
parts.append(
|
||||
f"<tr><td>{point.chainage_m:.2f}m</td><td>{_FACILITY_NAMES.get(point.facility, point.facility)}</td>"
|
||||
f"<td>{_pipe_spec(point)}</td><td>{_span_text(point)}</td><td>{inlet}</td><td>{point.source}</td></tr>"
|
||||
)
|
||||
parts.append("</table>")
|
||||
else:
|
||||
parts.append("<p class='muted'>배치된 배수 시설이 없다.</p>")
|
||||
|
||||
# ② 일반 구조물 일람
|
||||
parts.append("<h2>② 일반 구조물 일람 (structures.json)</h2>")
|
||||
if data["structures"]:
|
||||
parts.append(
|
||||
"<table><tr><th>타입</th><th>위치</th><th>옵션</th><th>배치</th><th>상태</th></tr>"
|
||||
)
|
||||
for instance in data["structures"]:
|
||||
option_text = ", ".join(f"{k}={v}" for k, v in (instance.options or {}).items()) or "—"
|
||||
parts.append(
|
||||
f"<tr><td>{_type_name(instance.type_id)}</td><td>{_structure_position(instance)}</td>"
|
||||
f"<td>{option_text}</td><td>{instance.placement_source}</td><td>{instance.status}</td></tr>"
|
||||
)
|
||||
parts.append("</table>")
|
||||
else:
|
||||
parts.append("<p class='muted'>배치된 일반 구조물이 없다.</p>")
|
||||
|
||||
# ③ 횡단 벽 패치 일람
|
||||
parts.append("<h2>③ 기준벽·다단 기슭막이 (횡단 설계 patch)</h2>")
|
||||
wall_rows = _wall_rows(data["designs"])
|
||||
if wall_rows:
|
||||
parts.append(
|
||||
"<table><tr><th>측점</th><th>다단(유출/계류)</th><th>벽 키</th><th>높이</th><th>재질</th><th>단별 구간</th></tr>"
|
||||
)
|
||||
for row in wall_rows:
|
||||
counts = row["counts"]
|
||||
count_text = f"{counts.get('outlet', 0)} / {counts.get('basin', 0)}"
|
||||
if not row["walls"]:
|
||||
parts.append(
|
||||
f"<tr><td>{row['chainage']:.2f}m</td><td>{count_text}</td><td colspan='4' class='muted'>벽 조작값 없음(단 수만 지정)</td></tr>"
|
||||
)
|
||||
for index, wall in enumerate(row["walls"]):
|
||||
lead = (
|
||||
(
|
||||
f"<td rowspan='{len(row['walls'])}'>{row['chainage']:.2f}m</td>"
|
||||
f"<td rowspan='{len(row['walls'])}'>{count_text}</td>"
|
||||
)
|
||||
if index == 0
|
||||
else ""
|
||||
)
|
||||
parts.append(
|
||||
f"<tr>{lead}<td>{wall['key']}</td><td>{wall['height']}</td>"
|
||||
f"<td>{wall['material']}</td><td>{wall['length']}</td></tr>"
|
||||
)
|
||||
parts.append("</table>")
|
||||
parts.append(
|
||||
"<p class='muted'>벽 좌우·상하·높이·형태는 횡단 설계 patch, 다단 단별 구간값은 extra_spans 정본 (CLAUDE.md 5장).</p>"
|
||||
)
|
||||
else:
|
||||
parts.append("<p class='muted'>벽 조작·다단 지정 측점이 없다.</p>")
|
||||
|
||||
# ④ 산출근거 (원단위 × 수량)
|
||||
parts.append("<h2>④ 산출근거 — 원단위 × 수량</h2>")
|
||||
if rollup["matched"]:
|
||||
for entry in rollup["matched"]:
|
||||
parts.append(
|
||||
f"<p><b>{entry['label']}</b> × {_fmt(entry['quantity'])}{entry['per']}"
|
||||
f" <span class='src'>[출처: {entry['source']} — 참조 전용]</span><br>"
|
||||
f"<span class='muted'>위치: {entry['positions']}</span>"
|
||||
+ (f"<br><span class='warn'>{entry['note']}</span>" if entry["note"] else "")
|
||||
+ "</p>"
|
||||
)
|
||||
parts.append(
|
||||
"<table><tr><th>공종</th><th>단위수량</th><th>수량</th><th>산출값</th></tr>"
|
||||
)
|
||||
for line in entry["lines"]:
|
||||
parts.append(
|
||||
f"<tr><td>{line['item']}</td><td class='num'>{line['unit_qty']}</td>"
|
||||
f"<td class='num'>{_fmt(entry['quantity'])}</td><td class='num'>{_fmt(line['value'])}</td></tr>"
|
||||
)
|
||||
parts.append("</table>")
|
||||
else:
|
||||
parts.append("<p class='muted'>원단위 매칭 가능한 구조물이 없다.</p>")
|
||||
|
||||
# ⑤ 공종별 합계
|
||||
parts.append("<h2>⑤ 공종별 수량 합계</h2>")
|
||||
if rollup["totals"]:
|
||||
parts.append("<table><tr><th>공종</th><th>합계</th></tr>")
|
||||
for item, value in sorted(rollup["totals"].items()):
|
||||
parts.append(f"<tr><td>{item}</td><td class='num'>{_fmt(value)}</td></tr>")
|
||||
parts.append("</table>")
|
||||
else:
|
||||
parts.append("<p class='muted'>합산할 항목이 없다.</p>")
|
||||
|
||||
# ⑥ 산출 보류
|
||||
parts.append("<h2>⑥ 산출 보류 (사유)</h2>")
|
||||
if rollup["deferred"]:
|
||||
parts.append("<table><tr><th>대상</th><th>사유</th></tr>")
|
||||
for row in rollup["deferred"]:
|
||||
parts.append(f"<tr><td>{row['name']}</td><td>{row['reason']}</td></tr>")
|
||||
parts.append("</table>")
|
||||
else:
|
||||
parts.append("<p class='muted'>보류 항목 없음.</p>")
|
||||
|
||||
parts.append(
|
||||
"<p class='muted'>근거 문서: resources/knowledge/original/실무문서/_원단위라이브러리_울진소광.md · "
|
||||
"_종합비교/04_임도구조물_원단위.md · technical_info/01_임도/04_수량분석정보/ (수량산출_일반·구조물_수량·배수공_수량)</p>"
|
||||
)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 라우트
|
||||
|
||||
|
||||
@proto_router.get("/quantity-proto/page", response_class=HTMLResponse)
|
||||
async def quantity_proto_index() -> HTMLResponse:
|
||||
"""프로젝트 선택 페이지."""
|
||||
projects = await list_all_projects()
|
||||
rows = "".join(
|
||||
f"<tr><td><a href='/api/projects/quantity-proto/page/{p['id']}'>{p.get('name') or p['id']}</a></td>"
|
||||
f"<td>{p.get('region') or '—'}</td><td>{p.get('status') or '—'}</td><td>{p.get('updated_at') or ''}</td></tr>"
|
||||
for p in projects
|
||||
)
|
||||
body = (
|
||||
"<h1>수량 산출 프로토타입 — 프로젝트 선택</h1>"
|
||||
"<div class='banner'>평가용 시안. 정본을 읽기만 한다.</div>"
|
||||
f"<table><tr><th>프로젝트</th><th>지역</th><th>상태</th><th>수정</th></tr>{rows}</table>"
|
||||
if projects
|
||||
else "<h1>수량 산출 프로토타입</h1><p>프로젝트가 없다.</p>"
|
||||
)
|
||||
return _page("수량 산출 프로토타입", body)
|
||||
|
||||
|
||||
@proto_router.get("/quantity-proto/page/{project_id}", response_class=HTMLResponse)
|
||||
async def quantity_proto_report(project_id: UUID) -> HTMLResponse:
|
||||
"""프로젝트 1건의 수량 산출 보고서."""
|
||||
try:
|
||||
data = await _collect(project_id)
|
||||
except FileNotFoundError as error:
|
||||
return _page(
|
||||
"수량 산출 프로토타입", f"<h1>수량 산출 프로토타입</h1><p class='warn'>{error}</p>"
|
||||
)
|
||||
projects = await list_all_projects()
|
||||
project = next((p for p in projects if str(p.get("id")) == str(project_id)), None)
|
||||
body = _render_report(project, project_id, data)
|
||||
body += "<p><a href='/api/projects/quantity-proto/page'>← 프로젝트 선택으로</a></p>"
|
||||
return _page("수량 산출 프로토타입", body)
|
||||
@@ -17,11 +17,6 @@ from config.config_db import get_db_pool
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B08 Quantity"])
|
||||
|
||||
# 수량 산출 프로토타입 (처분 가능) — B08_Quantity_Proto.py 삭제 시 아래 2줄도 함께 지운다.
|
||||
from B08_Quantity.B08_Quantity_Proto import proto_router # noqa: E402
|
||||
|
||||
router.include_router(proto_router)
|
||||
|
||||
|
||||
@router.post("/{project_id}/quantity/confirm")
|
||||
async def confirm_quantity(project_id: UUID) -> JSONResponse:
|
||||
|
||||
@@ -2,29 +2,23 @@
|
||||
* B08_Quantity_UI_Page.ts
|
||||
* 로그인 후 08: 5차 워크플로우 (수량 산출)
|
||||
*
|
||||
* ⚠️ 프로토타입 단계 — 본문은 서버 렌더 수량 보고서(B08_Quantity_Proto.py)를
|
||||
* iframe으로 싣는다. 평가 후 폐기/본구현 판단 (처분 가능 구성).
|
||||
* ⚠️ 본문 준비 중 — 워크플로우 셸 + 좌측 [확정] 버튼만 구성.
|
||||
* 확정 = 워크플로 stage 5(QUANTITY) 완료 처리 후 B09 설계도서로 이동.
|
||||
* 수량 본문(B06 종횡단 기반 산출)은 후속 계획에서 구현한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, showToast } from "@ui/ui_template_elements";
|
||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
import {
|
||||
fetchWorkflowState,
|
||||
goToWorkflowStage,
|
||||
WORKFLOW_STEP_ROUTES,
|
||||
type WorkflowState,
|
||||
} from "../A00_Common/b_workflow_nav";
|
||||
import { renderPendingWorkflow, workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
|
||||
|
||||
/** locale 헬퍼 */
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
/** stage 5(QUANTITY) 완료 요청. */
|
||||
/** stage 5(QUANTITY) 완료 요청 — 본문 미구현 상태의 유일한 백엔드 연동. */
|
||||
async function confirmQuantityStage(projectId: string): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/confirm`,
|
||||
@@ -35,15 +29,14 @@ async function confirmQuantityStage(projectId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 좌측 패널: 프로토 안내 + 하단 [확정] 액션 행 (다른 워크플로우 페이지와 동일 배치). */
|
||||
/** 좌측 패널: 준비 중 안내 + 하단 [확정] 액션 행 (다른 워크플로우 페이지와 동일 배치). */
|
||||
function buildQuantitySidePanel(projectId: string | null): HTMLElement {
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "b08-quantity__panel";
|
||||
|
||||
const note = document.createElement("p");
|
||||
note.className = "b08-quantity__pending-note";
|
||||
note.textContent =
|
||||
"수량 산출 프로토타입 — 정본 일람과 원단위(참조 전용) 산출근거를 표시합니다.";
|
||||
note.textContent = L("B08_Quantity_Side_Pending");
|
||||
panel.append(note);
|
||||
|
||||
const confirmButton = createButton({
|
||||
@@ -74,47 +67,15 @@ function buildQuantitySidePanel(projectId: string | null): HTMLElement {
|
||||
return panel;
|
||||
}
|
||||
|
||||
/** 본문: 서버 렌더 수량 보고서 iframe (프로젝트 미선택 시 목록 페이지). */
|
||||
function buildQuantityMain(projectId: string | null): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.style.cssText = "height:100%;min-height:480px;display:flex;";
|
||||
const frame = document.createElement("iframe");
|
||||
frame.title = "수량 산출 프로토타입";
|
||||
frame.style.cssText = "flex:1;width:100%;border:0;background:#fff;";
|
||||
frame.src = projectId
|
||||
? `${API_BASE_URL}/projects/quantity-proto/page/${encodeURIComponent(projectId)}`
|
||||
: `${API_BASE_URL}/projects/quantity-proto/page`;
|
||||
wrap.append(frame);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
* 페이지 진입점
|
||||
* -------------------------------------------------------------------------- */
|
||||
export async function renderB08Quantity(root: HTMLElement): Promise<void> {
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
let workflowState: WorkflowState | undefined;
|
||||
if (projectId) {
|
||||
try {
|
||||
workflowState = await fetchWorkflowState(projectId);
|
||||
} catch {
|
||||
/* 조회 실패 시 stages 미전달 → 전체 이동 허용 */
|
||||
}
|
||||
}
|
||||
const layout = createWorkflowLayout({
|
||||
await renderPendingWorkflow(root, {
|
||||
title: L("B08_Quantity_Title"),
|
||||
steps: workflowSteps(),
|
||||
activeStep: 5,
|
||||
leftPanel: buildQuantitySidePanel(projectId),
|
||||
mainContent: buildQuantityMain(projectId),
|
||||
stages: workflowState?.stages,
|
||||
currentStage: workflowState?.current_stage,
|
||||
routes: WORKFLOW_STEP_ROUTES,
|
||||
onStepClick: (stepIndex) => {
|
||||
if (projectId)
|
||||
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
|
||||
},
|
||||
});
|
||||
layout.root.classList.add("b-scaffold-wf");
|
||||
root.append(layout.root);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user