feat(B08): 구조물 집계표 배관 열 = 서버 칸 — 머리 구조물 · 형식 · 규격 3단 + 단위 · 계 줄 · 관 공 맨 윗 머리 조합 경로(98-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
This commit is contained in:
@@ -1,13 +1,16 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_UI_Summary.ts
|
||||
* 1탭 구조물 집계표(PLAN 61-1) — 줄 = 측점 · 열 = 저장된 구조물 조합 · 칸은 아직 비움.
|
||||
* 1탭 구조물 집계표(PLAN 61-1) — 줄 = 측점 · 열 = 저장된 구조물 조합 · 맨 아래 계 줄.
|
||||
* 표는 공용 많은 자료 표(`ui_template_data_table` · PLAN 70-2) — 머리 · 측점 열 고정.
|
||||
*
|
||||
* 구조물 = GET …/route/structures · 배관 = GET …/drainage/pipe-points (읽기만)
|
||||
* 머리 두 줄 = 조합 구분(M02 경로 첫 칸) · 조합 이름(나머지 경로) — `GET /api/m02/items?all=1`
|
||||
* 배관(횡단배수관) 열 · 칸 = GET …/quantity/summary(서버 단독 · PLAN 98) — 머리 구조물 · 형식 · 규격
|
||||
* 3단 + 단위 줄 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(있으면) · 다른 구조물 열은 조합 그대로
|
||||
* 줄을 누르면 그 측점의 구조물도 모달(PLAN 95-3 · `B08_Quantity_UI_Drawing` 은 누를 때 `import()`)
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { loadRows as loadPickerRows } from "@ui/ui_template_item_picker";
|
||||
import { pathOf, rowId, type PickerRow } from "@ui/ui_template_item_picker_data";
|
||||
@@ -15,7 +18,7 @@ import { showToast } from "@ui/ui_template_elements";
|
||||
import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { fetchLatestRoute, readRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch";
|
||||
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { createDataTable } from "@ui/ui_template_data_table";
|
||||
import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table";
|
||||
import { pipeStructureId } from "@ui/ui_template_item_picker_project";
|
||||
import { stationLabel } from "../common_util/common_util_station";
|
||||
import { summaryTooltip, type SummaryItem } from "../common_util/common_util_summary_item";
|
||||
@@ -50,12 +53,74 @@ function note(text: string): HTMLElement {
|
||||
/** 머리 글 길이에 맞춘 열 폭 — 칸은 비어 있어 글이 폭을 정함. */
|
||||
const widthOf = (label: string): number => Math.min(200, Math.max(96, label.length * 13 + 24));
|
||||
|
||||
/** 서버 배관 집계(PLAN 98-1) — 열 머리 `group`(구조물 · 형식 · 규격) + `label`(단위) · 열마다 조합 id. */
|
||||
interface PipeColumn {
|
||||
id: string;
|
||||
group: string[];
|
||||
label: string;
|
||||
combos: string[];
|
||||
}
|
||||
interface PipeSummary {
|
||||
columns: PipeColumn[];
|
||||
rows: Array<{ chainage_m: number; values: Record<string, number> }>;
|
||||
total: Record<string, number>;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
async function fetchPipeSummary(projectId: string): Promise<PipeSummary> {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/quantity/summary`, {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
const PIPE_TOP = "관 공";
|
||||
const stationKey = (station: number): number => Math.round(station * 100);
|
||||
/** 열 소수 자리 — 칸 · 계 값이 쓰는 만큼(최대 2). */
|
||||
const placesOf = (values: number[]): number =>
|
||||
Math.max(0, ...values.map((v) => (String(Math.round(v * 100) / 100).split(".")[1] ?? "").length));
|
||||
const longest = (texts: string[]): string =>
|
||||
texts.reduce((a, b) => (b.length > a.length ? b : a), "");
|
||||
|
||||
/** 배관 열 → 표 열 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(M02 에 있으면 · 조합 잇기 98). */
|
||||
function pipeColumns(summary: PipeSummary, comboPath: (id: string) => string | null): DataColumn[] {
|
||||
const top =
|
||||
summary.columns
|
||||
.filter((column) => column.group[0] === PIPE_TOP)
|
||||
.flatMap((column) => column.combos)
|
||||
.map(comboPath)
|
||||
.find((path) => !!path) ?? PIPE_TOP;
|
||||
return summary.columns.map((column) => {
|
||||
const group = column.group[0] === PIPE_TOP ? [top, ...column.group.slice(1)] : column.group;
|
||||
const values = [
|
||||
...summary.rows.map((row) => row.values[column.id]),
|
||||
summary.total[column.id],
|
||||
].filter((value): value is number => typeof value === "number");
|
||||
return {
|
||||
key: column.id,
|
||||
label: column.label,
|
||||
group,
|
||||
kind: "number",
|
||||
decimals: placesOf(values),
|
||||
title: [...group, column.label].join(" › "),
|
||||
width: widthOf(longest([...group.slice(1), column.label])),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildTable(
|
||||
stations: number[],
|
||||
columns: ComboColumn[],
|
||||
pipe: PipeSummary | null,
|
||||
comboPath: (id: string) => string | null,
|
||||
interval: number,
|
||||
onRowClick: (station: number, label: string) => void,
|
||||
): HTMLElement {
|
||||
const values = new Map((pipe?.rows ?? []).map((row) => [stationKey(row.chainage_m), row.values]));
|
||||
const total: DataRow | null = pipe?.columns.length
|
||||
? { station: L("B08_Quantity_Summary_Total"), ...pipe.total }
|
||||
: null;
|
||||
const table = createDataTable({
|
||||
columns: [
|
||||
{ key: "station", label: L("B08_Quantity_Summary_Station"), width: 96 },
|
||||
@@ -67,9 +132,14 @@ function buildTable(
|
||||
className: column.missing ? "is-missing" : undefined,
|
||||
width: widthOf(column.name),
|
||||
})),
|
||||
...(pipe ? pipeColumns(pipe, comboPath) : []),
|
||||
],
|
||||
// 칸은 아직 비움 — 사용자가 채울 자리라 「-」 대신 빈칸
|
||||
rows: stations.map((station) => ({ station: stationLabel(station, interval) })),
|
||||
// 조합 열 칸은 아직 비움 — 사용자가 채울 자리라 「-」 대신 빈칸 · 배관 칸 = 서버 값
|
||||
rows: stations.map((station) => ({
|
||||
station: stationLabel(station, interval),
|
||||
...values.get(stationKey(station)),
|
||||
})),
|
||||
total,
|
||||
onRowClick: (i) => onRowClick(stations[i], stationLabel(stations[i], interval)),
|
||||
frozen: 1,
|
||||
empty: "",
|
||||
@@ -99,7 +169,7 @@ async function openSheets(
|
||||
export async function renderStructureSummary(host: HTMLElement, projectId: string): Promise<void> {
|
||||
host.replaceChildren(note(L("B08_Quantity_Summary_Loading")));
|
||||
try {
|
||||
const [structures, pickerRows, pipes, interval] = await Promise.all([
|
||||
const [structures, pickerRows, pipes, interval, pipe] = await Promise.all([
|
||||
fetchStructures(projectId),
|
||||
loadPickerRows(),
|
||||
// 배수 분석 전이면 관이 없음 — 구조물만으로 그림
|
||||
@@ -108,6 +178,8 @@ export async function renderStructureSummary(host: HTMLElement, projectId: strin
|
||||
() => [],
|
||||
),
|
||||
stationInterval(projectId).catch(() => 20),
|
||||
// 배관 집계 실패 = 까닭 한 줄 · 나머지 표는 그대로
|
||||
fetchPipeSummary(projectId).catch((error: unknown) => String(error)),
|
||||
]);
|
||||
const m02 = new Map(pickerRows.map((row) => [rowId(row), row]));
|
||||
const describe = (item: SummaryItem): ComboLabel | null => {
|
||||
@@ -116,17 +188,31 @@ export async function renderStructureSummary(host: HTMLElement, projectId: strin
|
||||
const [group = "", ...rest] = pathOf(row);
|
||||
return { group, name: rest.join(" ") || group || row.key };
|
||||
};
|
||||
const things: PlacedItem[] = [
|
||||
...structures.structures,
|
||||
...pipes.map((pipe) => ({ ...pipe, structure_id: pipeStructureId(pipe.chainage_m) })),
|
||||
];
|
||||
const comboPath = (id: string): string | null => {
|
||||
const row = m02.get(id);
|
||||
return row ? pathOf(row).join(" ") : null;
|
||||
};
|
||||
const placed = pipes.map((point) => ({
|
||||
...point,
|
||||
structure_id: pipeStructureId(point.chainage_m),
|
||||
}));
|
||||
const things: PlacedItem[] = [...structures.structures, ...placed];
|
||||
const stations = summaryStations(things);
|
||||
const columns = summaryColumns(things, describe);
|
||||
// 배관은 서버 집계 열(98) — 조합 열은 다른 구조물 · 시설만(모달은 그대로 전부)
|
||||
const others = [
|
||||
...structures.structures,
|
||||
...placed.filter((point) => (point.facility ?? "pipe") !== "pipe"),
|
||||
];
|
||||
const columns = summaryColumns(others, describe);
|
||||
const summary = typeof pipe === "string" ? null : pipe;
|
||||
const parts: HTMLElement[] = [];
|
||||
if (!columns.length) parts.push(note(L("B08_Quantity_Summary_NoCombo")));
|
||||
if (typeof pipe === "string") parts.push(note(`${L("B08_Quantity_Summary_Failed")} — ${pipe}`));
|
||||
for (const text of summary?.notes ?? []) parts.push(note(text));
|
||||
if (!columns.length && !summary?.columns.length)
|
||||
parts.push(note(L("B08_Quantity_Summary_NoCombo")));
|
||||
parts.push(
|
||||
stations.length
|
||||
? buildTable(stations, columns, interval, (station, label) =>
|
||||
? buildTable(stations, columns, summary, comboPath, interval, (station, label) =>
|
||||
openSheets(projectId, label, combosAt(things, station), m02, describe),
|
||||
)
|
||||
: note(L("B08_Quantity_Summary_NoStation")),
|
||||
|
||||
@@ -213,3 +213,14 @@ def test_1본_길이는_프로젝트_층_변수(root, tmp_path, monkeypatch):
|
||||
def test_라우터_등록():
|
||||
text = (Path(__file__).resolve().parents[2] / "main.py").read_text(encoding="utf-8")
|
||||
assert "app.include_router(b08_summary_router, dependencies=protected_with_company)" in text
|
||||
|
||||
|
||||
def test_화면이_서버_칸을_그림():
|
||||
"""98-4 — 화면은 서버 배관 열 · 칸 · 계만 그림(계산 없음) · 배관 조합 열은 서버 열로 바뀜."""
|
||||
base = Path(__file__).resolve().parents[2]
|
||||
ui = (base / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8")
|
||||
assert "/quantity/summary" in ui and "pipeColumns(pipe, comboPath)" in ui
|
||||
assert '(point.facility ?? "pipe") !== "pipe"' in ui # 배관 조합 열 대신 서버 열
|
||||
assert "combosAt(things, station)" in ui # 모달은 배관 조합까지 그대로
|
||||
locale = (base / "ui_template/ui_template_locale_b4.ts").read_text(encoding="utf-8")
|
||||
assert 'B08_Quantity_Summary_Total: ["계", "Total"]' in locale
|
||||
|
||||
@@ -58,4 +58,5 @@ export const ui_locales_b4 = {
|
||||
"놓인 구조물 · 배관이 없습니다.",
|
||||
"No placed structures or pipes.",
|
||||
],
|
||||
B08_Quantity_Summary_Total: ["계", "Total"],
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user