Files
frontend/apps/sn/app/(map-layout)/map/components/pipeAnalysis/pipeAnalysisUtils.ts
T

734 lines
20 KiB
TypeScript

/** 관로분석 검색 조건, 집계 컬럼과 표시값 변환 유틸을 제공한다. */
"use client";
import type { SelectDataType } from "@common/business_components";
import type { IUserInfo } from "@/shared/store";
import { MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_USE_CODES } from "../common/table";
export const PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE =
"SN_PIPE_ANALYSIS_DETAIL_ROW_SELECT";
export type PipeAnalysisType = "YEAR" | "MATERIAL" | "YEAR_MATERIAL";
export type PipeAnalysisSummaryFilters = {
analysisType: PipeAnalysisType;
ctpvCd: string;
sggCd: string;
swtrmtZoneNm: string;
scopeRegionCode: string;
};
export type PipeAnalysisDetailFilters = {
ctpvCd: string;
sggCd: string;
swtrmtZoneNm: string;
useCd: string;
materialCode: string;
diameterFrom: string;
diameterTo: string;
};
export type PipeAnalysisMaterialColumn = {
key: string;
materialCode: string;
materialName: string;
};
export type PipeAnalysisSummaryRow = {
rowNo: number;
ctpvCd: string;
ctpvNm?: string;
sggCd: string;
sggNm?: string;
swtrmtZoneNm: string;
totalLengthKm: number;
elapsedYearBucket?: string;
elapsedYearLabel?: string;
metrics: Record<string, number>;
};
export type PipeAnalysisSummaryResponse = {
analysisType: PipeAnalysisType;
materialColumns: PipeAnalysisMaterialColumn[];
data: PipeAnalysisSummaryRow[];
};
export type PipeAnalysisDetailRow = {
rowNo: number;
fid: string;
lyrTypeNm: string;
fcltMngNo: string;
instMngNo: string;
mngInstCd: string;
ctpvCd: string;
ctpvNm?: string;
sggCd: string;
sggNm?: string;
swtrmtZoneNm: string;
instlYmd: string;
elapsedYears?: number;
useCd: string;
materialCode: string;
pdmtVl?: number | string;
prlgVl?: number | string;
};
export type PipeAnalysisZoneOption = {
value: string;
label: string;
ctpvCd: string;
sggCd: string;
};
export type PipeAnalysisScope = {
regionCode: string;
ctpvCd: string;
sggCd: string;
ctpvLocked: boolean;
sggLocked: boolean;
};
export type PipeAnalysisCellFilter = {
elapsedYearBucket?: string;
materialCode?: string;
};
export type PipeAnalysisSummaryViewRow = Record<string, unknown> & {
__isNoZone: boolean;
__isSummaryTotal: boolean;
__cellFilters: Record<string, PipeAnalysisCellFilter>;
__base: {
ctpvCd: string;
sggCd: string;
swtrmtZoneNm: string;
};
};
export type PipeAnalysisYearMaterialGroupRow = {
elapsedYearBucket: string;
elapsedYearLabel: string;
metrics: Record<string, number>;
cellFilters: Record<string, PipeAnalysisCellFilter>;
};
export type PipeAnalysisYearMaterialGroup = {
rowNo: number;
ctpvNm: string;
sggNm: string;
swtrmtZoneNm: string;
totalLengthKm: number;
base: {
ctpvCd: string;
sggCd: string;
swtrmtZoneNm: string;
};
rows: PipeAnalysisYearMaterialGroupRow[];
};
const materialMap = new Map(
MS_SWAGE_PPL_CODES.map((item) => [item.val.trim(), item.text]),
);
const useMap = new Map(
MS_SWAGE_PPL_USE_CODES.map((item) => [item.val.trim(), item.text]),
);
export const PIPE_ANALYSIS_TYPE_OPTIONS: Array<{
value: PipeAnalysisType;
label: string;
}> = [
{ value: "YEAR", label: "경과년도별" },
{ value: "MATERIAL", label: "재질별" },
{ value: "YEAR_MATERIAL", label: "경과년도&재질" },
];
export const PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS: PipeAnalysisSummaryFilters =
{
analysisType: "YEAR",
// 관로분석 최초 조회·초기화의 기본 시도는 부산광역시다. 계정 지역 제한은 별도 적용한다.
ctpvCd: "26",
sggCd: "0",
swtrmtZoneNm: "0",
scopeRegionCode: "",
};
export const PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS: PipeAnalysisDetailFilters = {
ctpvCd: "0",
sggCd: "0",
swtrmtZoneNm: "0",
useCd: "0",
materialCode: "0",
diameterFrom: "",
diameterTo: "",
};
export const PIPE_ANALYSIS_FIXED_YEAR_COLUMNS = [
{ key: "LT10", title: "10년 미만(km)" },
{ key: "Y10_20", title: "10년~20년(km)" },
{ key: "Y20_30", title: "20년~30년(km)" },
{ key: "GE30", title: "30년 이상(km)" },
{ key: "UNKNOWN", title: "확인 불가(km)" },
];
const elapsedYearBucketLabelMap: Record<string, string> = {
LT10: "10년 미만",
Y10_20: "10년~20년",
Y20_30: "20년~30년",
GE30: "30년 이상",
UNKNOWN: "확인 불가",
};
const YEAR_MATERIAL_BASE_BUCKETS = ["LT10", "Y10_20", "Y20_30", "GE30"];
export const PIPE_ANALYSIS_DETAIL_HEADLIST = [
{ id: "rowNo", title: "순번", widthPercent: 6 },
{ id: "ctpvNm", title: "시도", widthPercent: 9 },
{ id: "sggNm", title: "시군구", widthPercent: 10 },
{ id: "fcltMngNo", title: "시설관리번호", widthPercent: 15 },
{ id: "instMngNo", title: "기관관리번호", widthPercent: 15 },
{ id: "swtrmtZoneNm", title: "처리구역", widthPercent: 12 },
{ id: "instlYmd", title: "설치일자", widthPercent: 10 },
{ id: "useNm", title: "용도", widthPercent: 9 },
{ id: "pdmtVl", title: "관경(mm)", widthPercent: 8, align: "right" },
{ id: "prlgVl", title: "연장(m)", widthPercent: 8, align: "right" },
{ id: "materialName", title: "관재질", widthPercent: 12 },
];
export const buildPipeAnalysisScope = (
userInfo: IUserInfo | null,
): PipeAnalysisScope => {
const admin = userInfo?.adminRoleYn === "Y";
if (admin) {
return {
regionCode: "",
ctpvCd: "",
sggCd: "",
ctpvLocked: false,
sggLocked: false,
};
}
const digits = String(userInfo?.laeSeCd ?? "").replace(/[^0-9]/g, "");
if (digits.length >= 5) {
return {
regionCode: digits.slice(0, 5),
ctpvCd: digits.slice(0, 2),
sggCd: digits.slice(0, 5),
ctpvLocked: true,
sggLocked: true,
};
}
if (digits.length >= 2) {
return {
regionCode: digits.slice(0, 2),
ctpvCd: digits.slice(0, 2),
sggCd: "",
ctpvLocked: true,
sggLocked: false,
};
}
return {
regionCode: "",
ctpvCd: "",
sggCd: "",
ctpvLocked: false,
sggLocked: false,
};
};
export const applyScopeToSummaryFilters = (
filters: PipeAnalysisSummaryFilters,
scope: PipeAnalysisScope,
): PipeAnalysisSummaryFilters => {
const next = { ...filters, scopeRegionCode: scope.regionCode };
if (scope.ctpvCd) {
next.ctpvCd = scope.ctpvCd;
}
if (scope.sggCd) {
next.sggCd = scope.sggCd;
} else if (scope.ctpvCd && !next.sggCd.startsWith(scope.ctpvCd)) {
next.sggCd = "0";
}
return next;
};
export const applyScopeToDetailFilters = (
filters: PipeAnalysisDetailFilters,
scope: PipeAnalysisScope,
): PipeAnalysisDetailFilters => {
const next = { ...filters };
if (scope.ctpvCd) {
next.ctpvCd = scope.ctpvCd;
}
if (scope.sggCd) {
next.sggCd = scope.sggCd;
} else if (scope.ctpvCd && !next.sggCd.startsWith(scope.ctpvCd)) {
next.sggCd = "0";
}
return next;
};
export const buildPipeAnalysisSummaryHeadList = (
analysisType: PipeAnalysisType,
materialColumns: PipeAnalysisMaterialColumn[],
) => {
const common = [
{ id: "rowNo", title: "순번", widthPercent: 6 },
{ id: "ctpvNm", title: "시도", widthPercent: 10 },
{ id: "sggNm", title: "시군구", widthPercent: 10 },
{ id: "swtrmtZoneNm", title: "처리구역", widthPercent: 14 },
{
id: "totalLengthKm",
title: "합계(km)",
widthPercent: 10,
align: "right",
},
];
if (analysisType === "YEAR") {
return [
...common,
...PIPE_ANALYSIS_FIXED_YEAR_COLUMNS.map((column) => ({
id: column.key,
title: column.title,
widthPercent: 12,
align: "right" as const,
})),
];
}
const materialHeads = materialColumns.map((column) => ({
id: column.key,
title: resolveMaterialColumnName(column),
widthPercent: 12,
align: "right" as const,
}));
if (analysisType === "YEAR_MATERIAL") {
return [
...common,
{ id: "elapsedYearLabel", title: "경과년도", widthPercent: 12 },
...materialHeads,
];
}
return [...common, ...materialHeads];
};
export const buildPipeAnalysisSummaryViewRows = (
response: PipeAnalysisSummaryResponse,
ctpvOptions: SelectDataType[],
sggOptions: SelectDataType[],
): PipeAnalysisSummaryViewRow[] => {
const materialCodes = response.materialColumns.map((column) => column.key);
return response.data.map((row) => {
const isNoZone = String(row.swtrmtZoneNm ?? "").trim() === "NO_ZONE";
const isSummaryTotal =
!String(row.ctpvCd ?? "").trim() &&
String(row.swtrmtZoneNm ?? "").trim() === "-";
const viewRow: PipeAnalysisSummaryViewRow = {
rowNo: row.rowNo,
ctpvNm: isNoZone
? "전체"
: row.ctpvNm || resolveRegionName(row.ctpvCd, ctpvOptions, 2),
sggNm: isNoZone
? "-"
: row.sggNm || resolveRegionName(row.sggCd, sggOptions, 5),
swtrmtZoneNm: isNoZone ? "-" : row.swtrmtZoneNm || "-",
totalLengthKm: row.totalLengthKm ?? 0,
elapsedYearBucket: row.elapsedYearBucket ?? "",
elapsedYearLabel: row.elapsedYearLabel ?? "",
__isNoZone: isNoZone,
__isSummaryTotal: isSummaryTotal,
__cellFilters: {},
__base: {
ctpvCd: isNoZone ? "" : row.ctpvCd,
sggCd: isNoZone ? "" : row.sggCd,
swtrmtZoneNm: isNoZone ? "NO_ZONE" : row.swtrmtZoneNm,
},
};
if (isSummaryTotal) {
viewRow.ctpvNm = row.ctpvNm || viewRow.ctpvNm;
viewRow.sggNm = row.sggNm || "-";
viewRow.swtrmtZoneNm = "-";
viewRow.__base.ctpvCd = "";
viewRow.__base.sggCd = "";
viewRow.__base.swtrmtZoneNm = "";
}
if (response.analysisType === "YEAR") {
if (isNoZone || isSummaryTotal) {
PIPE_ANALYSIS_FIXED_YEAR_COLUMNS.forEach((column) => {
viewRow[column.key] = Number(row.metrics?.[column.key] ?? 0);
});
return viewRow;
}
PIPE_ANALYSIS_FIXED_YEAR_COLUMNS.forEach((column) => {
const value = Number(row.metrics?.[column.key] ?? 0);
viewRow[column.key] = value;
if (value > 0) {
viewRow.__cellFilters[column.key] = {
elapsedYearBucket: column.key,
};
}
});
return viewRow;
}
const columns =
response.analysisType === "YEAR_MATERIAL" ? materialCodes : materialCodes;
columns.forEach((columnKey) => {
const value = Number(row.metrics?.[columnKey] ?? 0);
viewRow[columnKey] = value;
if (!isNoZone && !isSummaryTotal && value > 0) {
viewRow.__cellFilters[columnKey] = {
elapsedYearBucket:
response.analysisType === "YEAR_MATERIAL"
? row.elapsedYearBucket
: undefined,
materialCode: columnKey === "OTHER" ? "" : columnKey,
};
}
});
return viewRow;
});
};
export const buildPipeAnalysisYearMaterialGroups = (
rows: PipeAnalysisSummaryViewRow[],
materialColumns: PipeAnalysisMaterialColumn[],
): PipeAnalysisYearMaterialGroup[] => {
const materialKeys = materialColumns.map((column) => column.key);
const groupMap = new Map<string, PipeAnalysisYearMaterialGroup>();
rows.forEach((row) => {
if (row.__isNoZone || row.__isSummaryTotal) {
return;
}
const groupKey = `${row.__base.ctpvCd}|${row.__base.sggCd}|${row.__base.swtrmtZoneNm}`;
const elapsedYearBucket = String(row.elapsedYearBucket ?? "").trim();
let group = groupMap.get(groupKey);
if (!group) {
group = {
rowNo: groupMap.size + 1,
ctpvNm: String(row.ctpvNm ?? ""),
sggNm: String(row.sggNm ?? ""),
swtrmtZoneNm: String(row.swtrmtZoneNm ?? ""),
totalLengthKm: Number(row.totalLengthKm ?? 0),
base: {
ctpvCd: row.__base.ctpvCd,
sggCd: row.__base.sggCd,
swtrmtZoneNm: row.__base.swtrmtZoneNm,
},
rows: [],
};
groupMap.set(groupKey, group);
}
const metrics: Record<string, number> = {};
materialKeys.forEach((materialKey) => {
metrics[materialKey] = Number(row[materialKey] ?? 0);
});
group.rows.push({
elapsedYearBucket,
elapsedYearLabel: String(
row.elapsedYearLabel ??
resolveElapsedYearBucketLabel(elapsedYearBucket),
),
metrics,
cellFilters: row.__cellFilters,
});
});
return Array.from(groupMap.values()).map((group) => {
const rowByBucket = new Map(
group.rows.map((row) => [row.elapsedYearBucket, row]),
);
const orderedBuckets = [...YEAR_MATERIAL_BASE_BUCKETS];
if (rowByBucket.has("UNKNOWN")) {
orderedBuckets.push("UNKNOWN");
}
const normalizedRows = orderedBuckets.map((bucket) => {
const existing = rowByBucket.get(bucket);
if (existing) {
return existing;
}
const emptyMetrics: Record<string, number> = {};
materialKeys.forEach((materialKey) => {
emptyMetrics[materialKey] = 0;
});
return {
elapsedYearBucket: bucket,
elapsedYearLabel: resolveElapsedYearBucketLabel(bucket),
metrics: emptyMetrics,
cellFilters: {},
};
});
return {
...group,
rows: normalizedRows,
};
});
};
export const toPipeAnalysisDetailViewRows = (
rows: PipeAnalysisDetailRow[],
ctpvOptions: SelectDataType[],
sggOptions: SelectDataType[],
) =>
rows.map((row) => ({
...row,
ctpvNm: row.ctpvNm || resolveRegionName(row.ctpvCd, ctpvOptions, 2),
sggNm: row.sggNm || resolveRegionName(row.sggCd, sggOptions, 5),
useNm: resolveUseName(row.useCd),
materialName: resolveMaterialName(row.materialCode),
}));
export const buildPipeAnalysisSummaryDownloadHeaders = (
analysisType: PipeAnalysisType,
materialColumns: PipeAnalysisMaterialColumn[],
) => {
const headers: Record<string, string> = {
rowNo: "순번",
ctpvNm: "시도",
sggNm: "시군구",
swtrmtZoneNm: "처리구역",
totalLengthKm: "합계(km)",
};
if (analysisType === "YEAR_MATERIAL") {
headers.elapsedYearLabel = "경과년도";
}
if (analysisType === "YEAR") {
PIPE_ANALYSIS_FIXED_YEAR_COLUMNS.forEach((column) => {
headers[column.key] = column.title;
});
return headers;
}
materialColumns.forEach((column) => {
headers[column.key] = resolveMaterialColumnName(column);
});
return headers;
};
export const PIPE_ANALYSIS_DETAIL_EXCEL_HEADERS = {
rowNo: "순번",
ctpvNm: "시도",
sggNm: "시군구",
fcltMngNo: "시설관리번호",
instMngNo: "기관관리번호",
swtrmtZoneNm: "처리구역",
instlYmd: "설치일자",
useNm: "용도",
pdmtVl: "관경(mm)",
prlgVl: "연장(m)",
materialName: "관재질",
};
export const resolveMaterialName = (materialCode?: string) => {
const code = String(materialCode ?? "").trim();
if (!code) return "-";
if (code === "OTHER") return "기타";
return materialMap.get(code) ?? code;
};
/**
* 관재질 라벨에서 영문 약어/코드를 걷어내고 엑셀 헤더용 한글명을 우선 추출한다.
* - `염화비닐관(PVC)` -> `염화비닐관`
* - `PCF관(강판보강 PE 나선형관)` -> `강판보강 나선형관`
*/
const toKoreanMaterialLabel = (value?: string) => {
const normalized = String(value ?? "").trim();
if (!normalized) return "";
const withoutParenthesis = normalized
.replace(/\s*\([^)]*\)\s*/g, " ")
.replace(/\s+/g, " ")
.trim();
const withoutAsciiToken = withoutParenthesis
.replace(/[A-Za-z0-9-]+/g, " ")
.replace(/\s+/g, " ")
.trim();
if (/[가-힣]/.test(withoutAsciiToken)) {
return withoutAsciiToken;
}
if (/[가-힣]/.test(withoutParenthesis)) {
return withoutParenthesis;
}
const parenthesisMatches = Array.from(normalized.matchAll(/\(([^)]*)\)/g))
.map((match) => String(match[1] ?? "").trim())
.filter(Boolean);
const koreanParenthesisText = parenthesisMatches.find((text) =>
/[가-힣]/.test(text),
);
if (koreanParenthesisText) {
const simplifiedParenthesisText = koreanParenthesisText
.replace(/[A-Za-z0-9-]+/g, " ")
.replace(/\s+/g, " ")
.trim();
return simplifiedParenthesisText || koreanParenthesisText;
}
return normalized;
};
export const resolveMaterialColumnName = (
column: PipeAnalysisMaterialColumn,
) => {
const rawName = String(column.materialName ?? "").trim();
const rawCode = String(column.materialCode ?? "").trim();
const rawKey = String(column.key ?? "").trim();
const mappedName = resolveMaterialName(rawCode || rawKey);
const candidates = [
toKoreanMaterialLabel(mappedName),
toKoreanMaterialLabel(rawName),
mappedName,
rawName,
rawCode,
rawKey,
].filter(Boolean);
return candidates[0] ?? "-";
};
export const resolveMaterialKoreanName = (materialCode?: string) => {
const resolved = resolveMaterialName(materialCode);
const koreanLabel = toKoreanMaterialLabel(resolved);
return koreanLabel || resolved;
};
export const resolveElapsedYearBucketLabel = (bucket?: string) => {
const normalized = String(bucket ?? "").trim();
return elapsedYearBucketLabelMap[normalized] ?? normalized;
};
export const resolvePipeAnalysisTypeLabel = (analysisType?: string) => {
const normalized = String(analysisType ?? "").trim();
return (
PIPE_ANALYSIS_TYPE_OPTIONS.find((option) => option.value === normalized)
?.label ?? normalized
);
};
export const resolveUseName = (useCode?: string) => {
const code = String(useCode ?? "").trim();
if (!code) return "-";
return useMap.get(code) ?? code;
};
export const resolveRegionName = (
code: string,
options: SelectDataType[],
prefixLength: number,
) => {
const normalized = String(code ?? "").trim();
if (!normalized) return "-";
const prefix = normalized.slice(0, prefixLength);
return (
options.find((item) => String(item.val).trim() === prefix)?.text ?? prefix
);
};
export const normalizeNumberInput = (value: string) =>
value.replace(/[^0-9]/g, "");
export const buildPipeAnalysisFileDate = () =>
new Date().toISOString().slice(0, 10);
export const fetchPipeAnalysisZoneOptions = async ({
ctpvCd,
sggCd,
sggNm,
scopeRegionCode,
}: {
ctpvCd: string;
sggCd: string;
sggNm?: string;
scopeRegionCode?: string;
}): Promise<PipeAnalysisZoneOption[]> => {
const filters: string[] = [];
const selectedSggCd = String(sggCd ?? "").trim();
const selectedSggNm = String(sggNm ?? "").trim();
if (sggCd && sggCd !== "0") {
// 처리구역 원천의 시군구코드가 잘못된 경우도 있어 시도 범위로 받은 뒤
// 기준코드 또는 시군구명 중 하나가 일치하는 처리구역만 남긴다.
const selectedCtpvCd =
ctpvCd && ctpvCd !== "0" ? ctpvCd : selectedSggCd.slice(0, 2);
filters.push(`ctpv_cd='${selectedCtpvCd}'`);
} else if (ctpvCd && ctpvCd !== "0") {
filters.push(`ctpv_cd='${ctpvCd}'`);
} else if (scopeRegionCode) {
if (scopeRegionCode.length >= 5) {
filters.push(`sgg_cd='${scopeRegionCode.slice(0, 5)}'`);
} else if (scopeRegionCode.length >= 2) {
filters.push(`ctpv_cd='${scopeRegionCode.slice(0, 2)}'`);
}
}
const params = new URLSearchParams({
service: "WFS",
version: "1.0.0",
request: "GetFeature",
typeName: "hasudo:tb_ms_swage_prcs_zone",
outputFormat: "application/json",
propertyName: "ctpv_cd,sgg_cd,sgg_nm,swtrmt_zone_nm",
});
if (filters.length > 0) {
params.set("CQL_FILTER", filters.join(" and "));
}
const response = await fetch(`/geoserver/hasudo/ows?${params.toString()}`);
if (!response.ok) {
throw new Error("처리구역 목록을 불러오지 못했습니다.");
}
const data = await response.json();
const features = Array.isArray(data?.features) ? data.features : [];
const dedupMap = new Map<string, PipeAnalysisZoneOption>();
features.forEach((feature: any) => {
const properties = feature?.properties ?? {};
const zoneName = String(properties.swtrmt_zone_nm ?? "").trim();
const featureSggCd = String(properties.sgg_cd ?? "").trim();
const featureSggNm = String(properties.sgg_nm ?? "").trim();
if (!zoneName) return;
if (
selectedSggCd &&
selectedSggCd !== "0" &&
featureSggCd !== selectedSggCd &&
(!selectedSggNm || featureSggNm !== selectedSggNm)
) {
return;
}
dedupMap.set(zoneName, {
value: zoneName,
label: zoneName,
ctpvCd: String(properties.ctpv_cd ?? "").trim(),
sggCd: selectedSggCd !== "0" ? selectedSggCd : featureSggCd,
});
});
return Array.from(dedupMap.values()).sort((left, right) =>
left.label.localeCompare(right.label, "ko"),
);
};