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.
This commit is contained in:
kjs
2026-04-28 20:20:10 +09:00
parent 562b0daa5c
commit 5dfae5e6b6
16 changed files with 220 additions and 72 deletions

View File

@@ -378,7 +378,7 @@ export async function getQualityReportData(req: any, res: Response): Promise<voi
const dataQuery = `
SELECT
COALESCE(pr.production_date, pr.created_date::date::text) as date,
COALESCE(ii.item_name, wi.item_id, '미지정') as item,
COALESCE(NULLIF(ii.item_name, ''), NULLIF(ii.item_number, ''), '미지정') as item,
'일반검사' as "defectType",
COALESCE(wi.routing, '미지정') as process,
COALESCE(pr.worker_name, '미지정') as inspector,
@@ -392,11 +392,30 @@ export async function getQualityReportData(req: any, res: Response): Promise<voi
pr.company_code
FROM production_record pr
LEFT JOIN work_instruction wi ON pr.wo_id = wi.id AND pr.company_code = wi.company_code
LEFT JOIN (
SELECT DISTINCT ON (item_number, company_code)
item_number, item_name, company_code
FROM item_info ORDER BY item_number, company_code, created_date DESC
) ii ON wi.item_id = ii.item_number AND wi.company_code = ii.company_code
LEFT JOIN LATERAL (
SELECT ii_inner.item_number, ii_inner.item_name
FROM item_info ii_inner
WHERE ii_inner.company_code = wi.company_code
AND (
(NULLIF(wi.item_id, '') IS NOT NULL
AND (ii_inner.id = wi.item_id OR ii_inner.item_number = wi.item_id))
OR ii_inner.item_number = (
SELECT wid.item_number
FROM work_instruction_detail wid
WHERE wid.work_instruction_id = wi.id
AND wid.company_code = wi.company_code
AND NULLIF(wid.item_number, '') IS NOT NULL
ORDER BY wid.created_date ASC
LIMIT 1
)
)
ORDER BY
CASE WHEN ii_inner.id = wi.item_id THEN 1
WHEN ii_inner.item_number = wi.item_id THEN 2
ELSE 3 END,
ii_inner.created_date DESC
LIMIT 1
) ii ON true
${whereClause}
ORDER BY date DESC NULLS LAST
`;
@@ -436,44 +455,165 @@ export async function getEquipmentReportData(req: any, res: Response): Promise<v
const companyCode = req.user?.companyCode;
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
const { startDate, endDate } = req.query;
const cf = buildCompanyFilter(companyCode, "ei", idx);
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
// 회사 필터
const equipParams: any[] = [];
let equipWhere = "";
if (companyCode !== "*") {
equipParams.push(companyCode);
equipWhere = `WHERE ei.company_code = $1`;
}
const whereClause = buildWhereClause(conditions);
// wopr(가동 실적) 집계: 회사 + 날짜 필터
const woprParams: any[] = [];
const woprConds: string[] = [
"NULLIF(wopr.started_at, '') IS NOT NULL",
"NULLIF(wopr.equipment_code, '') IS NOT NULL",
];
if (companyCode !== "*") {
woprParams.push(companyCode);
woprConds.push(`wopr.company_code = $${woprParams.length}`);
}
if (startDate) {
woprParams.push(startDate);
woprConds.push(`wopr.started_at::date >= $${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<string, any>();
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<v
totalCount: dataRows.length,
},
});
return;
} catch (error: any) {
logger.error("설비 리포트 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: "설비 리포트 데이터 조회에 실패했습니다", error: error.message });
return;
}
}