diff --git a/B08_Quantity/B08_Quantity_UI_Summary.ts b/B08_Quantity/B08_Quantity_UI_Summary.ts index b1d954caa..3686f449c 100644 --- a/B08_Quantity/B08_Quantity_UI_Summary.ts +++ b/B08_Quantity/B08_Quantity_UI_Summary.ts @@ -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 }>; + total: Record; + notes: string[]; +} + +async function fetchPipeSummary(projectId: string): Promise { + 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 { 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")), diff --git a/resources/tester/test_98_b08_pipe_summary.py b/resources/tester/test_98_b08_pipe_summary.py index 9caf86083..be9f0c99a 100644 --- a/resources/tester/test_98_b08_pipe_summary.py +++ b/resources/tester/test_98_b08_pipe_summary.py @@ -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 diff --git a/ui_template/ui_template_locale_b4.ts b/ui_template/ui_template_locale_b4.ts index 010dbce23..8f2f5cab4 100644 --- a/ui_template/ui_template_locale_b4.ts +++ b/ui_template/ui_template_locale_b4.ts @@ -58,4 +58,5 @@ export const ui_locales_b4 = { "놓인 구조물 · 배관이 없습니다.", "No placed structures or pipes.", ], + B08_Quantity_Summary_Total: ["계", "Total"], } as const;