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:
2026-10-08 14:11:04 +09:00
co-authored by Claude Opus 5.5
parent 02225ed071
commit b43cda3ed8
3 changed files with 110 additions and 12 deletions
+98 -12
View File
@@ -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
+1
View File
@@ -58,4 +58,5 @@ export const ui_locales_b4 = {
"놓인 구조물 · 배관이 없습니다.",
"No placed structures or pipes.",
],
B08_Quantity_Summary_Total: ["계", "Total"],
} as const;