Files
Aislo/B08_Quantity/B08_Quantity_Summary_Table.ts
T
eomsangdonandClaude Opus 5.5 5e25f418cc refactor(b08): 탭 표 자료 읽기 · 표 짜기를 화면 없는 파일로 꺼냄 — 구조물 집계표 · 자재 · 토공 · 수리 입력 · 거리이정표 · 화면은 같은 함수를 씀 · 동작 그대로 (302-3)
- 새 파일 = B08_Quantity_Summary_Table · Material_Tables · Earthwork_Tables · Hydro_Table · Milestone_Table
- 화면 UI 파일 = 불러오기 · 그리기만 · 옛 B08 시험 = 옮긴 글 자리 따라 읽는 파일 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-13 08:03:23 +09:00

246 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* B08_Quantity_Summary_Table.ts
* 구조물 집계표(PLAN 61-1) 자료 읽기 · 표 짜기 — 화면 없음(PLAN 302-3).
* 화면(`B08_Quantity_UI_Summary`) · 인쇄 미리보기(`B09_Draft_Print_Quantity`)가 같은 함수를 씀.
*
* `loadSummary` = 구조물 · 배관 · 측점 간격 · 배관 집계(서버) → 줄 측점 · 조합 열 · 사본 경로
* `summaryTable` = 공용 표 옵션(머리 · 측점 열 · 조합 열 · 배관 열 · 계 줄 · 램프 칸 class)
* 커플링 열 = 좌측 패널 1본 길이로 다시 셈(`view.live()` · 캐시 → 저장본 · PLAN 122)
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import type { DataColumn, DataRow, DataTableOptions } from "@ui/ui_template_data_table";
import { pipeStructureId } from "@ui/ui_template_item_picker_project";
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 { stationLabel } from "../common_util/common_util_station";
import { summaryTooltip, type SummaryItem } from "../common_util/common_util_summary_item";
import {
comboId,
linkedOnly,
pipeOwnerId,
summaryColumns,
summaryStations,
type ComboColumn,
type ComboLabel,
type LinkedSnapshot,
type PlacedItem,
type UnlinkedFacility,
unassignedText,
unlinkedAt,
widthOf,
} from "./B08_Quantity_Summary_Data";
import { recountCouplings, type PipeLength } from "./B08_Quantity_Options_Calc";
import { liveOnePipe } from "./B08_Quantity_UI_Options";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/** 측점 간격 — 노선 설정값 · 못 읽으면 20m. */
export async function stationInterval(projectId: string): Promise<number> {
const latest = readRouteLatestCache(projectId) ?? (await fetchLatestRoute(projectId));
return latest.route_params?.station_interval_m || 20;
}
/** 서버 배관 집계(PLAN 98-1) — 열 머리 `group`(구조물 · 형식 · 규격) + `label`(단위) · 열마다 조합 id. */
interface PipeColumn {
id: string;
group: string[];
label: string;
combos: string[];
}
export interface PipeSummary {
columns: PipeColumn[];
rows: Array<{ chainage_m: number; values: Record<string, number> }>;
total: Record<string, number>;
notes: string[];
/** 서버가 펼친 종류(`type_id` · 관 지점은 `facility`) — 조합 열에서 뺌 */
types: string[];
one_length_m: number | null;
pipes: PipeLength[];
/** 연결된 사본 `{구조물 id: 사본}`(143-1) */
linked: Record<string, LinkedSnapshot>;
/** 미연결 시설(154-1) */
unlinked: UnlinkedFacility[];
/** 모든 시설 칸(모달 「조합 연결」 · 156-1) */
facilities: UnlinkedFacility[];
}
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 = "관 공";
/** 미연결 램프 칸 class(`B08_Quantity_UI_Page.css` · 198-1) · 램프 자리 폭(12px 글 기준) */
export const LAMP_CLASS = "b08-summary__lamp";
/** 모두 연결 = 같은 램프 초록(247-1) */
const LINKED_CLASS = `${LAMP_CLASS} is-linked`;
const LAMP_W = 14;
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));
/** 배관 열 → 표 열 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(사본 경로 · 조합 잇기 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");
const decimals = placesOf(values);
const texts = values.map((value) =>
value.toLocaleString("en-US", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}),
);
return {
key: column.id,
label: column.label,
group,
kind: "number",
decimals,
title: [...group, column.label].join(" › "),
width: widthOf(column.label, ...texts),
};
});
}
/** 집계표 한 벌 — 화면 · 인쇄가 함께 씀 */
export interface SummaryView {
stations: number[];
/** 조합 열(횡단배수 = 서버 배관 열이라 뺌) */
columns: ComboColumn[];
/** 모달용 구조물 · 관 지점(사본 있는 것) */
things: PlacedItem[];
linked: Record<string, LinkedSnapshot>;
describe: (item: SummaryItem) => ComboLabel | null;
comboPath: (id: string) => string | null;
interval: number;
unlinked: UnlinkedFacility[];
saved: PipeSummary | null;
/** 배관 집계 실패 까닭 — 나머지 표는 그대로 */
error: string | null;
/** 지금 배관 집계 — 커플링 = 좌측 패널 1본 길이(캐시 → 저장본) */
live: () => PipeSummary | null;
}
export async function loadSummary(projectId: string): Promise<SummaryView> {
const [structures, pipes, interval, pipe] = await Promise.all([
fetchStructures(projectId),
// 배수 분석 전이면 관이 없음 — 구조물만으로 그림
fetchDetailPipePoints(projectId).then(
(response) => response.pipe_points,
() => [],
),
stationInterval(projectId).catch(() => 20),
// 배관 집계 실패 = 까닭 한 줄 · 나머지 표는 그대로
fetchPipeSummary(projectId).catch((error: unknown) => String(error)),
]);
const saved = typeof pipe === "string" ? null : pipe;
// 조합 이름 = 사본 manifest 경로(층 목록을 안 읽음 · 143-1)
const linked = saved?.linked ?? {};
const paths = new Map(
Object.values(linked).map((copy) => [comboId(copy.항목), copy.경로] as const),
);
const describe = (item: SummaryItem): ComboLabel | null => {
const [group = "", ...rest] = paths.get(comboId(item)) ?? [];
return group ? { group, name: rest.join(" ") || group } : null;
};
const comboPath = (id: string): string | null => paths.get(id)?.join(" ") || null;
// 관 지점 id = 서버가 낸 id 먼저(응답 측점은 둘째 자리 · id 는 셋째 자리 · 167-1)
const ids = [...Object.keys(linked), ...(saved?.facilities ?? []).map((f) => f.id)];
const placed = linkedOnly(
pipes.map((point) => ({
...point,
structure_id: pipeOwnerId(point.chainage_m, ids) ?? pipeStructureId(point.chainage_m),
})),
linked,
);
const mine = linkedOnly(structures.structures, linked);
const things: PlacedItem[] = [...mine, ...placed];
const unlinked = saved?.unlinked ?? [];
// 횡단배수는 서버 집계 열(98 · 120) — 조합 열은 다른 구조물 · 시설만(모달은 그대로 전부)
const served = new Set(saved?.types ?? []);
const others = [
...mine.filter((item) => !served.has(item.type_id)),
...placed.filter((point) => !served.has(point.facility ?? "pipe")),
];
return {
stations: summaryStations([...things, ...unlinked]),
columns: summaryColumns(others, describe),
things,
linked,
describe,
comboPath,
interval,
unlinked,
saved,
error: typeof pipe === "string" ? pipe : null,
live: () => saved && recountCouplings(saved, liveOnePipe(projectId, saved.one_length_m)),
};
}
/** 표 옵션 — 줄 측점 · 측점 칸 램프 class · labels = 측점 글 · missing = 줄마다 빈 조합 칸 글(호버) */
export function summaryTable(view: SummaryView): {
options: DataTableOptions;
labels: string[];
missing: string[];
} {
const { stations, columns, interval, unlinked, comboPath } = view;
const pipe = view.live();
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 labels = stations.map((station) => stationLabel(station, interval));
const stationHead = L("B08_Quantity_Summary_Station");
// 198-1 — 미연결 시설 측점 = 측점 칸 왼쪽 노랑 램프(값 빈칸 · 누르면 바로 연결 · 154) · 호버 = 빈 조합 칸(160-2)
const missing = stations.map((station) =>
unassignedText(unlinkedAt(unlinked, station), L("B08_Quantity_Link_Body")),
);
// 247-1 — 시설이 있고 빈 칸 없음 = 초록 램프
const linked = stations.map((station) => unlinkedAt(pipe?.facilities ?? [], station).length > 0);
const options: DataTableOptions = {
columns: [
{ key: "station", label: stationHead, width: widthOf(stationHead, ...labels) + LAMP_W },
...columns.map((column) => ({
key: column.id,
label: column.name,
group: [column.group || L("B08_Quantity_Summary_MissingGroup")],
title: summaryTooltip(column.item),
className: column.missing ? "is-missing" : undefined,
width: widthOf(column.name),
})),
...(pipe ? pipeColumns(pipe, comboPath) : []),
],
// 조합 열 칸은 아직 비움 — 사용자가 채울 자리라 「-」 대신 빈칸 · 배관 칸 = 서버 값
rows: stations.map((station, i) => ({
station: labels[i],
...values.get(stationKey(station)),
})),
total,
cellClass: (i, key) =>
key !== "station" ? null : missing[i] ? LAMP_CLASS : linked[i] ? LINKED_CLASS : null,
frozen: 1,
empty: "",
caption: L("B08_Quantity_Tab_StructureSummary"),
};
return { options, labels, missing };
}