Merge remote-tracking branch 'origin/dev' into main_laptop_1
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
"""B08 **구조물 집계표** — 측점별 구조물 한 줄 · 종류별 표 (PLAN 2장 · 사용자 확정 ④).
|
||||
|
||||
한 줄 = 측점 + 종류 + 실치수(제원 칸) + 개소·연장. 실무 「돌-골막이치수」 치수조서가
|
||||
실치수를 들고 구조도 탭이 끌어 쓰는 모양을 **한 탭으로 겸함**.
|
||||
|
||||
값이 어디서 오나 — 칸마다 출처를 붙임
|
||||
auto 정본에 적힌 값 — `structures.json`(B05) · `pipe_points.json`(계곡 통과 시설) ·
|
||||
관 연장은 B06 횡단 `design.pipe_length_m`
|
||||
user 이 표에서 사람이 고쳐 **정본에 적은** 값 — 산출 조건에 「손댄 칸」 표만 둠(브레인 판정)
|
||||
library 정본 칸이 비어 **양식 기본값**으로 선 칸(구조물도 양식 `vars.*.default`)
|
||||
empty 정본도 양식도 값이 없음 — 계산 쪽이 막히거나 기준값으로 돎
|
||||
⚠ 사용자 손 값은 덮개층이 아니라 **정본에 씀** — 도면·수량이 한 값(지침 5장 「쪼개지 않음」).
|
||||
⚠ 손댄 칸의 정본 값이 뒤에 B05 에서 바뀌면 그 칸은 자동으로 돌아가되 **조용히 말고** 줄에 알림.
|
||||
⚠ 값을 여기서 짓지 않음 — 읽어 줄 세우기만. 연장이 없는 관은 빈칸(0 으로 안 채움).
|
||||
⚠ 관 지점 정본 타입(`managed_by`)은 `structures.json` 에 있어도 안 셈 — `pipe_points.json` 이 주인.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
SOURCE_AUTO = "auto"
|
||||
SOURCE_USER = "user"
|
||||
SOURCE_LIBRARY = "library"
|
||||
SOURCE_EMPTY = "empty"
|
||||
#: 산출 조건 자리 — 손댄 칸 `{줄 id: {칸: {"value": 적은 값, "was": 고치기 전 정본 값}}}`.
|
||||
USER_CELLS_KEY = "structure_summary_user_cells"
|
||||
|
||||
#: 계곡 통과 시설 `facility` → 레지스트리 종류. 빈 값은 배관(`PipePoint` 기본).
|
||||
FACILITY_TYPES = {
|
||||
"pipe": "pipe",
|
||||
"box_culvert": "box_culvert",
|
||||
"ford_pavement": "ford_pavement",
|
||||
"ford_bridge": "ford_bridge",
|
||||
"revetment": "revetment",
|
||||
}
|
||||
|
||||
|
||||
def _blank(value: Any) -> bool:
|
||||
return value is None or value == ""
|
||||
|
||||
|
||||
def _number(value: Any) -> float | None:
|
||||
if isinstance(value, bool) or _blank(value):
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _defaults(template: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""양식 제원 칸 기본값 `{옵션 키: 기본값}` — 정본 칸이 비었을 때 계산이 쓰는 값."""
|
||||
return {
|
||||
str(spec["option"]): spec["default"]
|
||||
for spec in ((template or {}).get("vars") or {}).values()
|
||||
if spec.get("option") and "default" in spec and not _blank(spec["default"])
|
||||
}
|
||||
|
||||
|
||||
def _same(left: Any, right: Any) -> bool:
|
||||
"""정본 값과 적은 값이 같은가 — 숫자는 3.0 과 "3" 을 같게 봄."""
|
||||
a, b = _number(left), _number(right)
|
||||
return a == b if a is not None and b is not None else left == right
|
||||
|
||||
|
||||
def _cells(
|
||||
keys: Iterable[str],
|
||||
options: dict[str, Any],
|
||||
defaults: dict[str, Any],
|
||||
marks: dict[str, Any],
|
||||
) -> dict:
|
||||
cells = {}
|
||||
for key in keys:
|
||||
current = None if _blank(options.get(key)) else options[key]
|
||||
mark = marks.get(key)
|
||||
if mark is not None and not _same(current, mark.get("value")):
|
||||
# 사람이 적은 값을 B05 가 바꿈 — 자동으로 돌아가되 옛 값을 함께 실어 화면이 알림.
|
||||
cells[key] = {
|
||||
"value": current,
|
||||
"source": SOURCE_EMPTY if current is None else SOURCE_AUTO,
|
||||
"replaced_user_value": mark.get("value"),
|
||||
}
|
||||
elif mark is not None:
|
||||
cells[key] = {"value": current, "source": SOURCE_USER, "was": mark.get("was")}
|
||||
elif current is not None:
|
||||
cells[key] = {"value": options[key], "source": SOURCE_AUTO}
|
||||
elif key in defaults:
|
||||
cells[key] = {"value": defaults[key], "source": SOURCE_LIBRARY}
|
||||
else:
|
||||
cells[key] = {"value": None, "source": SOURCE_EMPTY}
|
||||
return cells
|
||||
|
||||
|
||||
def build_summary(
|
||||
structures: Iterable[dict[str, Any]],
|
||||
pipe_points: Iterable[dict[str, Any]],
|
||||
types: dict[str, Any],
|
||||
templates: dict[str, dict[str, Any]] | None = None,
|
||||
pipe_lengths: dict[float, float] | None = None,
|
||||
user_cells: dict[str, dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""종류별 표 목록(레지스트리 차례) — 표마다 칸 정의·줄·개소·연장 합·평균치수.
|
||||
|
||||
`types` 는 `structure_type_map()` · `templates` 는 종류별 양식(프로젝트에 박힌 것 → 기본) ·
|
||||
`pipe_lengths` 는 B06 횡단의 `{측점: 관 연장}`(`pipe_lengths_from_designs`) ·
|
||||
`user_cells` 는 이 표에서 손댄 칸(`USER_CELLS_KEY` 모양).
|
||||
"""
|
||||
# 관 길이 찾기는 배수관 물량과 **같은 규칙**(허용 0.5m) — 두 표가 같은 관에 다른 연장을 안 적게.
|
||||
from B08_Quantity.B08_Quantity_Engine_Pipe import _nearest
|
||||
|
||||
templates = templates or {}
|
||||
lengths = pipe_lengths or {}
|
||||
rows_by_type: dict[str, list[dict[str, Any]]] = {}
|
||||
notes: list[str] = []
|
||||
|
||||
def add(type_id: str, row: dict[str, Any]) -> None:
|
||||
definition = types.get(type_id)
|
||||
if definition is None:
|
||||
notes.append(f"{type_id}: 레지스트리에 없는 종류라 안 셈")
|
||||
return
|
||||
options = row.pop("options")
|
||||
keys = [field.key for field in definition.options]
|
||||
marks = (user_cells or {}).get(str(row["id"])) or {}
|
||||
row["cells"] = _cells(keys, options, _defaults(templates.get(type_id)), marks)
|
||||
replaced = [key for key, cell in row["cells"].items() if "replaced_user_value" in cell]
|
||||
if replaced:
|
||||
labels = {field.key: field.label for field in definition.options}
|
||||
row["replaced"] = [labels[key] for key in replaced]
|
||||
if definition.design_owner:
|
||||
row["note"] = f"{definition.design_owner}가 수량을 셈 — 여기선 자리만"
|
||||
elif definition.reference_only:
|
||||
row["note"] = "전문 상세설계 대상 — 배치까지만"
|
||||
rows_by_type.setdefault(type_id, []).append(row)
|
||||
|
||||
for item in structures:
|
||||
type_id = str(item.get("type_id") or "")
|
||||
definition = types.get(type_id)
|
||||
if definition is not None and definition.managed_by:
|
||||
continue # 관 지점 정본이 주인 — 옛 저장분이 남아 있어도 두 번 안 셈
|
||||
options = dict(item.get("options") or {})
|
||||
start, end = item.get("start_m"), item.get("end_m")
|
||||
span = abs(float(end) - float(start)) if start is not None and end is not None else None
|
||||
stated = _number(options.get("length_m"))
|
||||
add(
|
||||
type_id,
|
||||
{
|
||||
"id": item.get("structure_id"),
|
||||
"origin": "structures",
|
||||
"chainage_m": item.get("chainage_m"),
|
||||
"start_m": start,
|
||||
"end_m": end,
|
||||
"count": 1,
|
||||
# 원단위 전개와 같은 규칙 — 제원 연장이 있으면 그것, 없으면 시·종점 거리.
|
||||
"length_m": stated if stated else span,
|
||||
"length_basis": "제원 연장" if stated else ("시·종점" if span else ""),
|
||||
"memo": item.get("memo") or "",
|
||||
"options": options,
|
||||
},
|
||||
)
|
||||
|
||||
for point in pipe_points:
|
||||
facility = str(point.get("facility") or "pipe")
|
||||
type_id = FACILITY_TYPES.get(facility, facility)
|
||||
chainage = float(point.get("chainage_m") or 0.0)
|
||||
length = _nearest(lengths, chainage) if type_id == "pipe" else None
|
||||
add(
|
||||
type_id,
|
||||
{
|
||||
"id": f"pipe@{chainage:.3f}",
|
||||
"origin": "pipe_points",
|
||||
"chainage_m": chainage,
|
||||
"start_m": point.get("start_m"),
|
||||
"end_m": point.get("end_m"),
|
||||
"count": 1,
|
||||
"length_m": length,
|
||||
"length_basis": "B06 횡단 관 연장" if length else "",
|
||||
"memo": "",
|
||||
"options": dict(point.get("options") or {}),
|
||||
},
|
||||
)
|
||||
|
||||
tables = []
|
||||
for type_id, definition in types.items():
|
||||
rows = rows_by_type.get(type_id)
|
||||
if not rows:
|
||||
continue
|
||||
rows.sort(key=lambda row: float(row.get("chainage_m") or row.get("start_m") or 0.0))
|
||||
columns = [
|
||||
{"key": f.key, "label": f.label, "unit": f.unit or "", "input": f.input}
|
||||
for f in definition.options
|
||||
]
|
||||
averages = {}
|
||||
for column in columns:
|
||||
values = [_number(row["cells"][column["key"]]["value"]) for row in rows]
|
||||
present = [v for v in values if v is not None]
|
||||
if present and column["input"] == "number":
|
||||
averages[column["key"]] = sum(present) / len(present)
|
||||
replaced = sum(1 for row in rows if row.get("replaced"))
|
||||
if replaced:
|
||||
notes.append(
|
||||
f"{definition.name} {replaced}줄 — 이 표에서 고친 값을 B05 가 바꿔 "
|
||||
"자동값으로 돌아감"
|
||||
)
|
||||
measured = [row["length_m"] for row in rows if row["length_m"]]
|
||||
# 연장이 뜻 있는 표만 빈 연장을 셈 — 반사경·표지판 같은 점 시설은 연장이 없는 것이 정상.
|
||||
needs_length = definition.placement == "interval" or type_id == "pipe"
|
||||
tables.append(
|
||||
{
|
||||
"type_id": type_id,
|
||||
"name": definition.name,
|
||||
"group": definition.group,
|
||||
"placement": definition.placement,
|
||||
"columns": columns,
|
||||
"rows": rows,
|
||||
"count": len(rows),
|
||||
"length_total_m": sum(measured) if measured else None,
|
||||
"length_missing": len(rows) - len(measured) if needs_length else 0,
|
||||
"averages": averages,
|
||||
}
|
||||
)
|
||||
return {"tables": tables, "notes": notes}
|
||||
|
||||
|
||||
def pipe_lengths_from_designs(designs: Iterable[dict[str, Any]]) -> dict[float, float]:
|
||||
"""저장된 횡단 설계 → `{측점: 관 연장}` — 배수관 물량과 같은 읽기."""
|
||||
from B08_Quantity.B08_Quantity_Engine_Pipe import _length_by_chainage
|
||||
|
||||
return _length_by_chainage(list(designs or []), "pipe_length_m")
|
||||
@@ -194,6 +194,58 @@ async def get_material_summary(project_id: UUID) -> JSONResponse:
|
||||
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 []
|
||||
|
||||
|
||||
async def project_haul_inputs(project_id: UUID) -> dict[str, Any]:
|
||||
"""유토곡선(B06)이 받아야 할 **구조물 몫** — 채집석 공제 · 구조물 잔토.
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
type MaterialResponse,
|
||||
} from "./B08_Quantity_UI_MaterialGrid";
|
||||
import { renderStructureSheets } from "./B08_Quantity_UI_StructureSheet";
|
||||
import { renderStructureSummary } from "./B08_Quantity_UI_StructureSummary";
|
||||
|
||||
/** locale 헬퍼 */
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
@@ -831,6 +832,11 @@ function buildQuantityBody(
|
||||
},
|
||||
// 구조물도 — 제원 조합 하나 = 한 장(PLAN 3장). 스스로 받아 오므로 표를 넘기지 않음.
|
||||
// ⚠ 탭 차례 바꾸기·원단위 탭 흡수는 PLAN 5장 몫 — 여기서는 원단위 앞에 붙이기만 함.
|
||||
// 구조물 집계표 — 측점별 한 줄(PLAN 2장). 구조물도의 앞 사슬이라 그 바로 앞에 둠.
|
||||
{
|
||||
label: L("B08_Quantity_Tab_StructureSummary"),
|
||||
build: () => renderStructureSummary(projectId),
|
||||
},
|
||||
{ label: L("B08_Quantity_Tab_StructureSheet"), build: () => renderStructureSheets(projectId) },
|
||||
{
|
||||
label: L("B08_Quantity_Tab_UnitQuantity"),
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_UI_StructureSummary.ts
|
||||
* **구조물 집계표** 탭 — 측점별 구조물 한 줄 · 종류별 표 (PLAN 2장).
|
||||
*
|
||||
* ⚠ 값을 셈하지 않음 — 서버(`…/quantity/structure-summary`)가 정본을 읽어 세운 줄을 적기만.
|
||||
* ⚠ 칸마다 출처 표시 — 자동(정본) · 사용자(이 표에서 고침) · 라이브러리(양식 기본값) · 빈칸.
|
||||
* ⚠ 이 표에서 고친 값을 B05 가 바꿔 자동으로 돌아간 칸은 **조용히 넘기지 않고** 줄에 알림.
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { stationLabel } from "./B08_Quantity_UI_EarthworkGrid";
|
||||
import { el, num } from "./B08_Quantity_UI_StructureSheet_Formula";
|
||||
|
||||
type Source = "auto" | "user" | "library" | "empty";
|
||||
|
||||
interface SummaryCell {
|
||||
value: number | string | null;
|
||||
source: Source;
|
||||
was?: number | string | null;
|
||||
replaced_user_value?: number | string | null;
|
||||
}
|
||||
|
||||
interface SummaryRow {
|
||||
id: string;
|
||||
origin: "structures" | "pipe_points";
|
||||
chainage_m: number | null;
|
||||
start_m: number | null;
|
||||
end_m: number | null;
|
||||
count: number;
|
||||
length_m: number | null;
|
||||
length_basis: string;
|
||||
memo: string;
|
||||
note?: string;
|
||||
replaced?: string[];
|
||||
cells: Record<string, SummaryCell>;
|
||||
}
|
||||
|
||||
interface SummaryTable {
|
||||
type_id: string;
|
||||
name: string;
|
||||
group: string;
|
||||
placement: string;
|
||||
columns: { key: string; label: string; unit: string; input: string }[];
|
||||
rows: SummaryRow[];
|
||||
count: number;
|
||||
length_total_m: number | null;
|
||||
length_missing: number;
|
||||
averages: Record<string, number>;
|
||||
}
|
||||
|
||||
interface SummaryResponse {
|
||||
tables: SummaryTable[];
|
||||
notes: string[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const SOURCE_LABELS: Record<Source, string> = {
|
||||
auto: "자동 — 구조물 놓기(B05)·계곡 시설 정본 값",
|
||||
user: "사용자 — 이 표에서 고친 값(정본에 적힘)",
|
||||
library: "라이브러리 — 정본이 비어 양식 기본값으로 섬",
|
||||
empty: "빈칸 — 정본도 양식도 값 없음",
|
||||
};
|
||||
|
||||
const STYLE_ID = "b08-structure-summary-style";
|
||||
const CSS = `
|
||||
.b08-sum { display: flex; flex-direction: column; gap: 10px; min-height: 0; }
|
||||
.b08-sum__legend { display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; }
|
||||
.b08-sum__cell--auto { box-shadow: inset 3px 0 0 var(--color-border, #888); }
|
||||
.b08-sum__cell--user { box-shadow: inset 3px 0 0 var(--color-accent, #6c8ebf); }
|
||||
.b08-sum__cell--library { box-shadow: inset 3px 0 0 var(--color-success, #5cb85c); }
|
||||
.b08-sum__cell--empty { color: var(--color-text-muted, #999); }
|
||||
.b08-sum__cell--replaced { outline: 2px solid var(--color-danger, #d9534f); outline-offset: -2px; }
|
||||
.b08-sum .b08-grid__table td { white-space: nowrap; }
|
||||
`;
|
||||
|
||||
function injectStyles(): void {
|
||||
if (document.getElementById(STYLE_ID)) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = CSS;
|
||||
document.head.append(style);
|
||||
}
|
||||
|
||||
/** 칸 글 — 정수는 그대로(뒷길이 45), 소수는 둘째 자리까지. */
|
||||
function cellText(cell: SummaryCell | undefined): string {
|
||||
if (!cell || cell.value === null || cell.value === "") return "";
|
||||
const value = cell.value;
|
||||
return typeof value === "number" && !Number.isInteger(value) ? num(value, 2) : String(value);
|
||||
}
|
||||
|
||||
function station(row: SummaryRow): string {
|
||||
if (row.start_m !== null && row.end_m !== null) {
|
||||
return `${stationLabel(row.start_m)} ~ ${stationLabel(row.end_m)}`;
|
||||
}
|
||||
return row.chainage_m === null ? "" : stationLabel(row.chainage_m);
|
||||
}
|
||||
|
||||
function renderTable(table: SummaryTable): HTMLElement {
|
||||
const wrap = el("div", "b08-grid");
|
||||
const length =
|
||||
table.length_total_m === null ? "" : ` · 연장 합 ${num(table.length_total_m, 2)} m`;
|
||||
const missing = table.length_missing ? ` · 연장 빈 줄 ${table.length_missing}` : "";
|
||||
wrap.append(el("p", "b08-sheet__head", `${table.name} — ${table.count}개소${length}${missing}`));
|
||||
const scroller = el("div", "b08-grid__scroll");
|
||||
const grid = el("table", "b08-grid__table");
|
||||
const head = document.createElement("tr");
|
||||
for (const label of ["측점", "연장(m)", ...table.columns.map((c) => c.label), "비고"]) {
|
||||
head.append(el("th", "", label));
|
||||
}
|
||||
const thead = document.createElement("thead");
|
||||
thead.append(head);
|
||||
const tbody = document.createElement("tbody");
|
||||
for (const row of table.rows) {
|
||||
const tr = document.createElement("tr");
|
||||
const lengthCell = el("td", "", row.length_m === null ? "" : num(row.length_m, 2));
|
||||
lengthCell.title = row.length_basis || "연장 없음";
|
||||
tr.append(el("td", "", station(row)), lengthCell);
|
||||
for (const column of table.columns) {
|
||||
const cell = row.cells[column.key];
|
||||
const td = el("td", `b08-sum__cell--${cell?.source ?? "empty"}`, cellText(cell));
|
||||
td.title = cell ? SOURCE_LABELS[cell.source] : "";
|
||||
if (cell?.source === "user" && cell.was !== undefined && cell.was !== null) {
|
||||
td.title += ` · 고치기 전 ${cell.was}`;
|
||||
}
|
||||
if (cell?.replaced_user_value !== undefined) {
|
||||
td.classList.add("b08-sum__cell--replaced");
|
||||
td.title = `이 표에서 ${cell.replaced_user_value}(으)로 고쳤으나 B05 가 바꿔 자동값으로 돌아감`;
|
||||
}
|
||||
tr.append(td);
|
||||
}
|
||||
const notes = [
|
||||
row.replaced?.length ? `⚠ B05 가 바꿈: ${row.replaced.join("·")}` : "",
|
||||
row.note ?? "",
|
||||
row.memo,
|
||||
].filter(Boolean);
|
||||
tr.append(el("td", "", notes.join(" · ")));
|
||||
tbody.append(tr);
|
||||
}
|
||||
const average = document.createElement("tr");
|
||||
average.append(el("td", "", "평균치수"), el("td", "", ""));
|
||||
for (const column of table.columns) {
|
||||
const value = table.averages[column.key];
|
||||
average.append(el("td", "", value === undefined ? "" : num(value, 2)));
|
||||
}
|
||||
average.append(el("td", "", ""));
|
||||
tbody.append(average);
|
||||
grid.append(thead, tbody);
|
||||
scroller.append(grid);
|
||||
wrap.append(scroller);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/** 탭 본문 — 받는 동안 안내, 오면 종류별 표. */
|
||||
export function renderStructureSummary(projectId: string | null): HTMLElement {
|
||||
injectStyles();
|
||||
const root = el("div", "b08-sum");
|
||||
if (!projectId) {
|
||||
root.append(el("p", "b08-quantity__message", "프로젝트를 먼저 고를 것"));
|
||||
return root;
|
||||
}
|
||||
root.append(el("p", "b08-grid__caption", "구조물 집계표 불러오는 중…"));
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-summary`,
|
||||
{ credentials: "include" },
|
||||
);
|
||||
const payload = (await response.json().catch(() => ({}))) as SummaryResponse;
|
||||
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||
const legend = el("div", "b08-sum__legend");
|
||||
for (const source of ["auto", "user", "library", "empty"] as Source[]) {
|
||||
const chip = el("span", `b08-sum__cell--${source}`, ` ${SOURCE_LABELS[source]} `);
|
||||
legend.append(chip);
|
||||
}
|
||||
const total = payload.tables.reduce((sum, table) => sum + table.count, 0);
|
||||
root.replaceChildren(
|
||||
el(
|
||||
"p",
|
||||
"b08-sheet__head",
|
||||
`구조물 집계표 · ${payload.tables.length}종 · ${total}개소 (측점별 실치수 — 구조물도가 이 값을 씀)`,
|
||||
),
|
||||
legend,
|
||||
...payload.notes.map((note) => el("p", "b08-grid__caption b08-grid__caption--warn", note)),
|
||||
...(payload.tables.length
|
||||
? payload.tables.map(renderTable)
|
||||
: [el("p", "b08-grid__caption", "놓인 구조물이 없음")]),
|
||||
);
|
||||
} catch (error) {
|
||||
root.replaceChildren(
|
||||
el(
|
||||
"p",
|
||||
"b08-grid__caption b08-grid__caption--warn",
|
||||
`구조물 집계표를 불러오지 못함 — ${error instanceof Error ? error.message : ""}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
})();
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"""구조물 집계표 (2026-09-13, PLAN 2장).
|
||||
|
||||
겨누는 것
|
||||
① 한 줄 = 측점 + 종류 + 실치수 + 개소·연장 · 종류별 표 · 개소·연장 합 · 평균치수
|
||||
② 칸 출처 — 정본 값은 자동 · 빈 칸은 양식 기본값(라이브러리) · 둘 다 없으면 빈칸
|
||||
③ 계곡 통과 시설은 관 지점 정본에서 — 배수관과 세월교는 **다른 표**(브레인 판정) ·
|
||||
관 연장은 B06 횡단 값(0.5m 안) · 없으면 빈칸(0 아님)
|
||||
④ 손댄 칸은 「사용자」 · 그 값을 B05 가 바꾸면 자동으로 돌아가되 **알림이 남음**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import B08_Quantity.B08_Quantity_Router_Material as material_module # noqa: E402
|
||||
from B05_Profile.B05_Profile_Structures_Repository import save_structures # noqa: E402
|
||||
from B05_Profile.B05_Profile_Structures_Schema import ( # noqa: E402
|
||||
StructureInstance,
|
||||
structure_type_map,
|
||||
)
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureSummary import build_summary # noqa: E402
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import load_template # noqa: E402
|
||||
from common_util.common_util_drainage_pipes import pipe_points_path_in # noqa: E402
|
||||
|
||||
PROJECT_ID = "66666666-6666-6666-6666-666666666666"
|
||||
|
||||
WALLS = [
|
||||
{
|
||||
"structure_id": "w1",
|
||||
"type_id": "masonry_wet",
|
||||
"placement": "interval",
|
||||
"start_m": 100.0,
|
||||
"end_m": 110.0,
|
||||
"options": {"height_m": 2.5, "back_len_cm": 35},
|
||||
},
|
||||
{
|
||||
"structure_id": "w2",
|
||||
"type_id": "masonry_wet",
|
||||
"placement": "interval",
|
||||
"start_m": 40.0,
|
||||
"end_m": 46.0,
|
||||
"options": {"height_m": 1.5, "length_m": 5.0},
|
||||
},
|
||||
]
|
||||
POINTS = [
|
||||
{"chainage_m": 60.2, "options": {"pipe_kind": "흄관", "pipe_diameter_mm": 800}},
|
||||
{"chainage_m": 300.0},
|
||||
{"chainage_m": 200.0, "facility": "ford_bridge", "options": {"pipe_count": 2}},
|
||||
]
|
||||
|
||||
|
||||
def _tables(user_cells=None) -> dict:
|
||||
body = build_summary(
|
||||
WALLS,
|
||||
POINTS,
|
||||
structure_type_map(),
|
||||
{"masonry_wet": load_template("masonry_wet")},
|
||||
{60.0: 8.0},
|
||||
user_cells,
|
||||
)
|
||||
return {table["type_id"]: table for table in body["tables"]} | {"_notes": body["notes"]}
|
||||
|
||||
|
||||
def test_종류별_표에_측점_실치수_개소_연장이_선다() -> None:
|
||||
walls = _tables()["masonry_wet"]
|
||||
assert [row["id"] for row in walls["rows"]] == ["w2", "w1"] # 측점 차례
|
||||
w2, w1 = walls["rows"]
|
||||
assert w1["length_m"] == pytest.approx(10.0) and w1["length_basis"] == "시·종점"
|
||||
assert w2["length_m"] == pytest.approx(5.0) and w2["length_basis"] == "제원 연장"
|
||||
assert walls["count"] == 2 and walls["length_total_m"] == pytest.approx(15.0)
|
||||
assert walls["averages"]["height_m"] == pytest.approx(2.0)
|
||||
# 칸 출처 — 정본 값 · 양식 기본(뒷길이 45) · 둘 다 없음.
|
||||
assert w1["cells"]["back_len_cm"] == {"value": 35, "source": "auto"}
|
||||
assert w2["cells"]["back_len_cm"] == {"value": 45, "source": "library"}
|
||||
assert w2["cells"]["face_slope_ratio"] == {"value": None, "source": "empty"}
|
||||
|
||||
|
||||
def test_배수관과_세월교는_다른_표이고_관_연장은_B06_값() -> None:
|
||||
tables = _tables()
|
||||
pipes = tables["pipe"]
|
||||
assert [row["chainage_m"] for row in pipes["rows"]] == [60.2, 300.0]
|
||||
near, far = pipes["rows"]
|
||||
assert near["length_m"] == pytest.approx(8.0) and near["length_basis"] == "B06 횡단 관 연장"
|
||||
assert far["length_m"] is None and pipes["length_missing"] == 1 # 0 으로 안 채움
|
||||
assert near["cells"]["pipe_kind"]["value"] == "흄관"
|
||||
assert tables["ford_bridge"]["rows"][0]["cells"]["pipe_count"]["value"] == 2
|
||||
assert tables["ford_bridge"]["length_missing"] == 0 # 점 시설은 연장이 없는 것이 정상
|
||||
|
||||
|
||||
def test_손댄_칸은_사용자이고_B05_가_바꾸면_알림이_남는다() -> None:
|
||||
marks = {
|
||||
"w1": {"height_m": {"value": 2.5, "was": 2.0}, "back_len_cm": {"value": 55, "was": 35}}
|
||||
}
|
||||
tables = _tables(marks)
|
||||
w1 = tables["masonry_wet"]["rows"][1]
|
||||
assert w1["cells"]["height_m"] == {"value": 2.5, "source": "user", "was": 2.0}
|
||||
# 뒷길이는 55 로 적었는데 정본이 35 — B05 가 바꿈.
|
||||
assert w1["cells"]["back_len_cm"]["source"] == "auto"
|
||||
assert w1["cells"]["back_len_cm"]["replaced_user_value"] == 55
|
||||
assert w1["replaced"] == ["뒷길이"]
|
||||
assert any("자동값으로 돌아감" in note for note in tables["_notes"])
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||||
root = tmp_path / "project"
|
||||
root.mkdir()
|
||||
save_structures(
|
||||
str(root), [StructureInstance.model_validate(w) for w in WALLS], base_revision=0
|
||||
)
|
||||
path = pipe_points_path_in(root)
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text(json.dumps({"route_signature": "", "points": POINTS}), encoding="utf-8")
|
||||
|
||||
async def fake_connection(func, *args):
|
||||
return "stored"
|
||||
|
||||
async def designs(project_id):
|
||||
return [{"chainage_m": 60.0, "design": {"pipe_length_m": 8.0}}]
|
||||
|
||||
monkeypatch.setattr(material_module, "run_with_connection", fake_connection)
|
||||
monkeypatch.setattr(material_module, "resolve_stored_project_path", lambda _p: str(root))
|
||||
monkeypatch.setattr(material_module, "_designs", designs)
|
||||
app = FastAPI()
|
||||
app.include_router(material_module.router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_창구가_정본_둘과_횡단_관_연장을_읽어_표를_낸다(client: TestClient) -> None:
|
||||
response = client.get(f"/api/projects/{PROJECT_ID}/quantity/structure-summary")
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
names = [table["name"] for table in body["tables"]]
|
||||
assert "배수관" in names and "세월교" in names and "돌쌓기(찰)" in names
|
||||
pipes = next(table for table in body["tables"] if table["type_id"] == "pipe")
|
||||
assert pipes["rows"][0]["length_m"] == pytest.approx(8.0)
|
||||
assert body["revision"] == 1
|
||||
@@ -624,6 +624,7 @@ export const ui_locales_b2 = {
|
||||
],
|
||||
B08_Quantity_Tab_Summary: ["토공집계", "Earthwork Summary"],
|
||||
B08_Quantity_Tab_Haul: ["운반거리", "Haul Distance"],
|
||||
B08_Quantity_Tab_StructureSummary: ["구조물 집계표", "Structure Summary"],
|
||||
B08_Quantity_Tab_StructureSheet: ["구조물도", "Structure Sheets"],
|
||||
B08_Quantity_Tab_UnitQuantity: ["구조물 원단위", "Structure Unit Quantity"],
|
||||
B08_Quantity_Tab_Material: ["자재총괄", "Material Summary"],
|
||||
|
||||
Reference in New Issue
Block a user