From 5dfae5e6b6b28b3ced09df735f5a36b543f4e867 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 28 Apr 2026 20:20:10 +0900 Subject: [PATCH] Refactor analytics report data retrieval and enhance equipment report logic - Updated the `getQualityReportData` function to improve item name retrieval logic by using `NULLIF` for better handling of empty values. - Refactored the `getEquipmentReportData` function to include company and date filters for equipment statistics, ensuring accurate data aggregation. - Enhanced the SQL queries for both quality and equipment reports to utilize lateral joins and improve performance. - Improved loading states in frontend components for inspection records and inspection results across multiple companies. This refactor enhances data accuracy and user experience in the analytics module. --- .../controllers/analyticsReportController.ts | 216 +++++++++++++++--- .../equipment/inspection-record/page.tsx | 4 +- .../quality/inspection-result/page.tsx | 4 +- .../equipment/inspection-record/page.tsx | 4 +- .../quality/inspection-result/page.tsx | 4 +- .../equipment/inspection-record/page.tsx | 4 +- .../quality/inspection-result/page.tsx | 4 +- .../equipment/inspection-record/page.tsx | 4 +- .../quality/inspection-result/page.tsx | 4 +- .../equipment/inspection-record/page.tsx | 4 +- .../quality/inspection-result/page.tsx | 4 +- .../equipment/inspection-record/page.tsx | 4 +- .../quality/inspection-result/page.tsx | 4 +- .../equipment/inspection-record/page.tsx | 4 +- .../quality/inspection-result/page.tsx | 4 +- .../components/admin/report/ReportEngine.tsx | 20 +- 16 files changed, 220 insertions(+), 72 deletions(-) diff --git a/backend-node/src/controllers/analyticsReportController.ts b/backend-node/src/controllers/analyticsReportController.ts index d5d843b1..126a8ffe 100644 --- a/backend-node/src/controllers/analyticsReportController.ts +++ b/backend-node/src/controllers/analyticsReportController.ts @@ -378,7 +378,7 @@ export async function getQualityReportData(req: any, res: Response): Promise= $${woprParams.length}::date`); + } + if (endDate) { + woprParams.push(endDate); + woprConds.push(`wopr.started_at::date <= $${woprParams.length}::date`); + } + const woprWhere = woprConds.join(" AND "); - const dataQuery = ` + // 1-A) wopr(가동시간) 집계 — equipment_code 또는 em.id 매칭 + const woprStatsQuery = ` SELECT - COALESCE(ei.updated_date, ei.created_date)::date::text as date, - ei.equipment_code, - COALESCE(ei.equipment_name, ei.equipment_code) as equipment, - COALESCE(ei.equipment_type, '미지정') as "equipType", - COALESCE(ei.location, '미지정') as line, - COALESCE(ui.user_name, ei.manager_id, '미지정') as manager, - ei.status, - CAST(COALESCE(NULLIF(ei.capacity_per_day::text, ''), '0') AS numeric) as "runTime", - 0 as "downTime", - 100 as "opRate", - 0 as "faultCnt", - 0 as "mtbf", - 0 as "mttr", - 0 as "maintCost", - CAST(COALESCE(NULLIF(ei.capacity_per_day::text, ''), '0') AS numeric) as "prodQty", - ei.company_code - FROM equipment_info ei - LEFT JOIN ( - SELECT DISTINCT ON (user_id) user_id, user_name FROM user_info - ) ui ON ei.manager_id = ui.user_id - ${whereClause} - ORDER BY equipment ASC + em.equipment_code, + em.company_code, + SUM(GREATEST( + CASE + WHEN NULLIF(wopr.completed_at, '') IS NOT NULL + AND NULLIF(wopr.started_at, '') IS NOT NULL + THEN EXTRACT(EPOCH FROM (wopr.completed_at::timestamp - wopr.started_at::timestamp)) / 3600.0 + ELSE 0 + END, 0)) AS run_hours, + SUM(CAST(COALESCE(NULLIF(wopr.total_paused_time, ''), '0') AS numeric)) / 3600.0 AS down_hours, + COUNT(*) FILTER ( + WHERE wopr.status IN ('fault','error','breakdown','수리','고장') + ) AS fault_cnt, + MAX(wopr.started_at) AS last_started + FROM equipment_mng em + JOIN work_order_process_result wopr + ON wopr.company_code = em.company_code + AND (wopr.equipment_code = em.equipment_code OR wopr.equipment_code = em.id::text) + WHERE ${woprWhere} + ${companyCode !== "*" ? `AND em.company_code = $1` : ""} + GROUP BY em.equipment_code, em.company_code `; + const woprStatsRows = await query(woprStatsQuery, woprParams); - const dataRows = await query(dataQuery, params); + // 1-B) production_record(생산량/불량) 집계 — wo_id → wi.equipment_id → em + // pr.production_date(date string) 기준 날짜 필터 + const prParams: any[] = []; + const prConds: string[] = []; + if (companyCode !== "*") { + prParams.push(companyCode); + prConds.push(`pr.company_code = $${prParams.length}`); + } + if (startDate) { + prParams.push(startDate); + prConds.push(`COALESCE(pr.production_date, pr.created_date::date::text) >= $${prParams.length}`); + } + if (endDate) { + prParams.push(endDate); + prConds.push(`COALESCE(pr.production_date, pr.created_date::date::text) <= $${prParams.length}`); + } + const prWhere = prConds.length > 0 ? `WHERE ${prConds.join(" AND ")}` : ""; + const prStatsQuery = ` + SELECT + em.equipment_code, + em.company_code, + SUM(CAST(COALESCE(NULLIF(pr.production_qty, ''), '0') AS numeric)) AS prod_qty, + SUM(CAST(COALESCE(NULLIF(pr.defect_qty, ''), '0') AS numeric)) AS defect_qty, + MAX(COALESCE(pr.production_date, pr.created_date::date::text)) AS last_prod_date + FROM equipment_mng em + JOIN work_instruction wi + ON wi.company_code = em.company_code + AND (wi.equipment_id = em.id::text OR wi.equipment_id = em.equipment_code) + JOIN production_record pr + ON pr.wo_id = wi.id AND pr.company_code = wi.company_code + ${prWhere} + GROUP BY em.equipment_code, em.company_code + `; + const prStatsRows = await query(prStatsQuery, prParams); - logger.info("설비 리포트 데이터 조회", { companyCode, rowCount: dataRows.length }); + // 두 집계를 회사+설비코드 키로 병합 + const statsByCode = new Map(); + for (const r of woprStatsRows) { + const k = `${r.company_code}::${r.equipment_code}`; + statsByCode.set(k, { ...(statsByCode.get(k) || {}), ...r }); + } + for (const r of prStatsRows) { + const k = `${r.company_code}::${r.equipment_code}`; + statsByCode.set(k, { ...(statsByCode.get(k) || {}), ...r }); + } + + // 2) 설비 마스터 조회 (전체) + 집계 매핑 + const equipQuery = ` + SELECT + ei.id::text AS em_id, + ei.equipment_code, + ei.equipment_name, + ei.equipment_type, + ei.installation_location, + ei.manufacturer, + ei.operation_status, + ei.created_date, + ei.updated_date, + ei.company_code + FROM equipment_mng ei + ${equipWhere} + ORDER BY ei.equipment_name ASC, ei.equipment_code ASC + `; + const equipRows = await query(equipQuery, equipParams); + + const dataRows = equipRows.map((e: any) => { + const s = statsByCode.get(`${e.company_code}::${e.equipment_code}`); + const runHours = Number(s?.run_hours ?? 0); + const downHours = Number(s?.down_hours ?? 0); + const totalHours = runHours + downHours; + const opRate = totalHours > 0 + ? Math.round((runHours / totalHours) * 1000) / 10 + : 0; + const lastDate = (s?.last_prod_date || s?.last_started || e.updated_date || e.created_date || "") + .toString().slice(0, 10); + return { + date: lastDate, + equipment_code: e.equipment_code, + equipment: (e.equipment_name && e.equipment_name.trim()) || e.equipment_code || "미지정", + equipType: (e.equipment_type && e.equipment_type.trim()) || "미지정", + line: (e.installation_location && e.installation_location.trim()) || "미지정", + manager: (e.manufacturer && e.manufacturer.trim()) || "미지정", + status: (e.operation_status && e.operation_status.trim()) || "미지정", + runTime: Math.round(runHours * 10) / 10, + downTime: Math.round(downHours * 10) / 10, + opRate, + faultCnt: Number(s?.fault_cnt ?? 0), + mtbf: 0, + mttr: 0, + maintCost: 0, + prodQty: Number(s?.prod_qty ?? 0), + defectQty: Number(s?.defect_qty ?? 0), + company_code: e.company_code, + }; + }); + + logger.info("설비 리포트 데이터 조회", { + companyCode, + rowCount: dataRows.length, + woprStatsCount: woprStatsRows.length, + prStatsCount: prStatsRows.length, + }); res.status(200).json({ success: true, @@ -488,9 +628,11 @@ export async function getEquipmentReportData(req: any, res: Response): Promise {/* 좌측: 점검기록 테이블 */} - -
+ +
{loading && records.length === 0 ? (
diff --git a/frontend/app/(main)/COMPANY_10/quality/inspection-result/page.tsx b/frontend/app/(main)/COMPANY_10/quality/inspection-result/page.tsx index 542b9c04..72550327 100644 --- a/frontend/app/(main)/COMPANY_10/quality/inspection-result/page.tsx +++ b/frontend/app/(main)/COMPANY_10/quality/inspection-result/page.tsx @@ -187,8 +187,8 @@ export default function InspectionResultPage() { {/* 분할 패널 */} {/* 좌측: 마스터 테이블 */} - -
+ +
{loading && masterData.length === 0 ? (
diff --git a/frontend/app/(main)/COMPANY_16/equipment/inspection-record/page.tsx b/frontend/app/(main)/COMPANY_16/equipment/inspection-record/page.tsx index 098b489b..066ec9a8 100644 --- a/frontend/app/(main)/COMPANY_16/equipment/inspection-record/page.tsx +++ b/frontend/app/(main)/COMPANY_16/equipment/inspection-record/page.tsx @@ -228,8 +228,8 @@ export default function EquipmentInspectionRecordPage() { {/* 분할 패널 */} {/* 좌측: 점검기록 테이블 */} - -
+ +
{loading && records.length === 0 ? (
diff --git a/frontend/app/(main)/COMPANY_16/quality/inspection-result/page.tsx b/frontend/app/(main)/COMPANY_16/quality/inspection-result/page.tsx index a38b7082..bd2a77e5 100644 --- a/frontend/app/(main)/COMPANY_16/quality/inspection-result/page.tsx +++ b/frontend/app/(main)/COMPANY_16/quality/inspection-result/page.tsx @@ -187,8 +187,8 @@ export default function InspectionResultPage() { {/* 분할 패널 */} {/* 좌측: 마스터 테이블 */} - -
+ +
{loading && masterData.length === 0 ? (
diff --git a/frontend/app/(main)/COMPANY_29/equipment/inspection-record/page.tsx b/frontend/app/(main)/COMPANY_29/equipment/inspection-record/page.tsx index 098b489b..066ec9a8 100644 --- a/frontend/app/(main)/COMPANY_29/equipment/inspection-record/page.tsx +++ b/frontend/app/(main)/COMPANY_29/equipment/inspection-record/page.tsx @@ -228,8 +228,8 @@ export default function EquipmentInspectionRecordPage() { {/* 분할 패널 */} {/* 좌측: 점검기록 테이블 */} - -
+ +
{loading && records.length === 0 ? (
diff --git a/frontend/app/(main)/COMPANY_29/quality/inspection-result/page.tsx b/frontend/app/(main)/COMPANY_29/quality/inspection-result/page.tsx index 542b9c04..72550327 100644 --- a/frontend/app/(main)/COMPANY_29/quality/inspection-result/page.tsx +++ b/frontend/app/(main)/COMPANY_29/quality/inspection-result/page.tsx @@ -187,8 +187,8 @@ export default function InspectionResultPage() { {/* 분할 패널 */} {/* 좌측: 마스터 테이블 */} - -
+ +
{loading && masterData.length === 0 ? (
diff --git a/frontend/app/(main)/COMPANY_30/equipment/inspection-record/page.tsx b/frontend/app/(main)/COMPANY_30/equipment/inspection-record/page.tsx index d9bc4fb1..28afef23 100644 --- a/frontend/app/(main)/COMPANY_30/equipment/inspection-record/page.tsx +++ b/frontend/app/(main)/COMPANY_30/equipment/inspection-record/page.tsx @@ -235,8 +235,8 @@ export default function EquipmentInspectionRecordPage() { {/* 분할 패널 */} {/* 좌측: 점검기록 테이블 */} - -
+ +
{loading && records.length === 0 ? (
diff --git a/frontend/app/(main)/COMPANY_30/quality/inspection-result/page.tsx b/frontend/app/(main)/COMPANY_30/quality/inspection-result/page.tsx index 02d270b6..316b1d95 100644 --- a/frontend/app/(main)/COMPANY_30/quality/inspection-result/page.tsx +++ b/frontend/app/(main)/COMPANY_30/quality/inspection-result/page.tsx @@ -203,8 +203,8 @@ export default function InspectionResultPage() { {/* 분할 패널 */} {/* 좌측: 마스터 테이블 */} - -
+ +
{loading && masterData.length === 0 ? (
diff --git a/frontend/app/(main)/COMPANY_7/equipment/inspection-record/page.tsx b/frontend/app/(main)/COMPANY_7/equipment/inspection-record/page.tsx index 098b489b..066ec9a8 100644 --- a/frontend/app/(main)/COMPANY_7/equipment/inspection-record/page.tsx +++ b/frontend/app/(main)/COMPANY_7/equipment/inspection-record/page.tsx @@ -228,8 +228,8 @@ export default function EquipmentInspectionRecordPage() { {/* 분할 패널 */} {/* 좌측: 점검기록 테이블 */} - -
+ +
{loading && records.length === 0 ? (
diff --git a/frontend/app/(main)/COMPANY_7/quality/inspection-result/page.tsx b/frontend/app/(main)/COMPANY_7/quality/inspection-result/page.tsx index 542b9c04..72550327 100644 --- a/frontend/app/(main)/COMPANY_7/quality/inspection-result/page.tsx +++ b/frontend/app/(main)/COMPANY_7/quality/inspection-result/page.tsx @@ -187,8 +187,8 @@ export default function InspectionResultPage() { {/* 분할 패널 */} {/* 좌측: 마스터 테이블 */} - -
+ +
{loading && masterData.length === 0 ? (
diff --git a/frontend/app/(main)/COMPANY_8/equipment/inspection-record/page.tsx b/frontend/app/(main)/COMPANY_8/equipment/inspection-record/page.tsx index 098b489b..066ec9a8 100644 --- a/frontend/app/(main)/COMPANY_8/equipment/inspection-record/page.tsx +++ b/frontend/app/(main)/COMPANY_8/equipment/inspection-record/page.tsx @@ -228,8 +228,8 @@ export default function EquipmentInspectionRecordPage() { {/* 분할 패널 */} {/* 좌측: 점검기록 테이블 */} - -
+ +
{loading && records.length === 0 ? (
diff --git a/frontend/app/(main)/COMPANY_8/quality/inspection-result/page.tsx b/frontend/app/(main)/COMPANY_8/quality/inspection-result/page.tsx index 542b9c04..72550327 100644 --- a/frontend/app/(main)/COMPANY_8/quality/inspection-result/page.tsx +++ b/frontend/app/(main)/COMPANY_8/quality/inspection-result/page.tsx @@ -187,8 +187,8 @@ export default function InspectionResultPage() { {/* 분할 패널 */} {/* 좌측: 마스터 테이블 */} - -
+ +
{loading && masterData.length === 0 ? (
diff --git a/frontend/app/(main)/COMPANY_9/equipment/inspection-record/page.tsx b/frontend/app/(main)/COMPANY_9/equipment/inspection-record/page.tsx index 6698ada4..195fb2ee 100644 --- a/frontend/app/(main)/COMPANY_9/equipment/inspection-record/page.tsx +++ b/frontend/app/(main)/COMPANY_9/equipment/inspection-record/page.tsx @@ -215,8 +215,8 @@ export default function EquipmentInspectionRecordPage() { {/* 분할 패널 */} {/* 좌측: 점검기록 테이블 */} - -
+ +
{loading && records.length === 0 ? (
diff --git a/frontend/app/(main)/COMPANY_9/quality/inspection-result/page.tsx b/frontend/app/(main)/COMPANY_9/quality/inspection-result/page.tsx index b9df6f13..e637acd9 100644 --- a/frontend/app/(main)/COMPANY_9/quality/inspection-result/page.tsx +++ b/frontend/app/(main)/COMPANY_9/quality/inspection-result/page.tsx @@ -187,8 +187,8 @@ export default function InspectionResultPage() { {/* 분할 패널 */} {/* 좌측: 마스터 테이블 */} - -
+ +
{loading && masterData.length === 0 ? (
diff --git a/frontend/components/admin/report/ReportEngine.tsx b/frontend/components/admin/report/ReportEngine.tsx index 4fcddf33..a29c3c0f 100644 --- a/frontend/components/admin/report/ReportEngine.tsx +++ b/frontend/components/admin/report/ReportEngine.tsx @@ -310,11 +310,15 @@ function getGroupKey(row: Record, groupBy: string): string { function aggregateValues( rows: Record[], metricId: string, - method: string + method: string, + metric?: ReportMetric, ): number { if (!rows.length) return 0; const vals = rows.map((r) => Number(r[metricId]) || 0); - switch (method) { + // 비율 메트릭(% 등 isRate)은 행 단위 합계가 의미 없음 → 산술평균으로 강제 + // (예: 100% 행이 N건이면 sum=N×100% 으로 비정상 누적되는 문제 방지) + const effectiveMethod = metric?.isRate && method === "sum" ? "avg" : method; + switch (effectiveMethod) { case "sum": return vals.reduce((a, b) => a + b, 0); case "avg": return Math.round((vals.reduce((a, b) => a + b, 0) / vals.length) * 10) / 10; case "max": return Math.max(...vals); @@ -957,10 +961,10 @@ export default function ReportEngine({ config }: ReportEngineProps) { // 각 라벨별 집계값을 한 번에 계산해서 저장 (렌더링 시 lookup 만) const values: Record = {}; for (const lb in groups) { - values[lb] = aggregateValues(groups[lb], metricId, cond.aggMethod); + values[lb] = aggregateValues(groups[lb], metricId, cond.aggMethod, m); } // 전체 합계 - const totalValue = aggregateValues(condData, metricId, cond.aggMethod); + const totalValue = aggregateValues(condData, metricId, cond.aggMethod, m); seriesList.push({ condId: cond.id, condName: cond.name, @@ -1744,7 +1748,7 @@ export default function ReportEngine({ config }: ReportEngineProps) { const m = config.metrics.find((x) => x.id === metricId); if (!m) return null; const condData = applyConditionFilters(rawData, cond.filters, filterFields); - const val = aggregateValues(condData, metricId, cond.aggMethod); + const val = aggregateValues(condData, metricId, cond.aggMethod, m); const color = COLORS[ci % COLORS.length]; return (
{analysisResult.series.map((s, si) => { const allRows = subLabels.flatMap((lb) => s.groups[lb] || []); + const m = config.metrics.find((x) => x.id === s.metricId); return ( - {formatNumber(aggregateValues(allRows, s.metricId, s.aggMethod))} + {formatNumber(aggregateValues(allRows, s.metricId, s.aggMethod, m))} ); })} @@ -2125,7 +2130,8 @@ export default function ReportEngine({ config }: ReportEngineProps) { let total: number; if (tableSearchQuery) { const allRows = displayLabels.flatMap((lb) => s.groups[lb] || []); - total = aggregateValues(allRows, s.metricId, s.aggMethod); + const m = config.metrics.find((x) => x.id === s.metricId); + total = aggregateValues(allRows, s.metricId, s.aggMethod, m); } else { total = s.totalValue; }