Merge pull request 'jskim-node' (#39) from jskim-node into main
Some checks failed
Build and Push Images / build-and-push (push) Failing after 51s
Some checks failed
Build and Push Images / build-and-push (push) Failing after 51s
Reviewed-on: jskim/vexplor_dev#39
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -228,8 +228,8 @@ export default function EquipmentInspectionRecordPage() {
|
||||
{/* 분할 패널 */}
|
||||
<ResizablePanelGroup direction="horizontal" className="flex-1 min-h-0">
|
||||
{/* 좌측: 점검기록 테이블 */}
|
||||
<ResizablePanel defaultSize={60} minSize={35}>
|
||||
<div className="h-full overflow-auto">
|
||||
<ResizablePanel defaultSize={60} minSize={35} className="flex flex-col">
|
||||
<div className="flex-1 overflow-auto min-h-0">
|
||||
{loading && records.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Loader2 className="h-8 w-8 animate-spin mb-3" />
|
||||
|
||||
@@ -187,8 +187,8 @@ export default function InspectionResultPage() {
|
||||
{/* 분할 패널 */}
|
||||
<ResizablePanelGroup direction="horizontal" className="flex-1 min-h-0">
|
||||
{/* 좌측: 마스터 테이블 */}
|
||||
<ResizablePanel defaultSize={55} minSize={30}>
|
||||
<div className="h-full overflow-auto">
|
||||
<ResizablePanel defaultSize={55} minSize={30} className="flex flex-col">
|
||||
<div className="flex-1 overflow-auto min-h-0">
|
||||
{loading && masterData.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Loader2 className="h-8 w-8 animate-spin mb-3" />
|
||||
|
||||
@@ -228,8 +228,8 @@ export default function EquipmentInspectionRecordPage() {
|
||||
{/* 분할 패널 */}
|
||||
<ResizablePanelGroup direction="horizontal" className="flex-1 min-h-0">
|
||||
{/* 좌측: 점검기록 테이블 */}
|
||||
<ResizablePanel defaultSize={60} minSize={35}>
|
||||
<div className="h-full overflow-auto">
|
||||
<ResizablePanel defaultSize={60} minSize={35} className="flex flex-col">
|
||||
<div className="flex-1 overflow-auto min-h-0">
|
||||
{loading && records.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Loader2 className="h-8 w-8 animate-spin mb-3" />
|
||||
|
||||
@@ -187,8 +187,8 @@ export default function InspectionResultPage() {
|
||||
{/* 분할 패널 */}
|
||||
<ResizablePanelGroup direction="horizontal" className="flex-1 min-h-0">
|
||||
{/* 좌측: 마스터 테이블 */}
|
||||
<ResizablePanel defaultSize={55} minSize={30}>
|
||||
<div className="h-full overflow-auto">
|
||||
<ResizablePanel defaultSize={55} minSize={30} className="flex flex-col">
|
||||
<div className="flex-1 overflow-auto min-h-0">
|
||||
{loading && masterData.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Loader2 className="h-8 w-8 animate-spin mb-3" />
|
||||
|
||||
@@ -228,8 +228,8 @@ export default function EquipmentInspectionRecordPage() {
|
||||
{/* 분할 패널 */}
|
||||
<ResizablePanelGroup direction="horizontal" className="flex-1 min-h-0">
|
||||
{/* 좌측: 점검기록 테이블 */}
|
||||
<ResizablePanel defaultSize={60} minSize={35}>
|
||||
<div className="h-full overflow-auto">
|
||||
<ResizablePanel defaultSize={60} minSize={35} className="flex flex-col">
|
||||
<div className="flex-1 overflow-auto min-h-0">
|
||||
{loading && records.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Loader2 className="h-8 w-8 animate-spin mb-3" />
|
||||
|
||||
@@ -187,8 +187,8 @@ export default function InspectionResultPage() {
|
||||
{/* 분할 패널 */}
|
||||
<ResizablePanelGroup direction="horizontal" className="flex-1 min-h-0">
|
||||
{/* 좌측: 마스터 테이블 */}
|
||||
<ResizablePanel defaultSize={55} minSize={30}>
|
||||
<div className="h-full overflow-auto">
|
||||
<ResizablePanel defaultSize={55} minSize={30} className="flex flex-col">
|
||||
<div className="flex-1 overflow-auto min-h-0">
|
||||
{loading && masterData.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Loader2 className="h-8 w-8 animate-spin mb-3" />
|
||||
|
||||
@@ -235,8 +235,8 @@ export default function EquipmentInspectionRecordPage() {
|
||||
{/* 분할 패널 */}
|
||||
<ResizablePanelGroup direction="horizontal" className="flex-1 min-h-0">
|
||||
{/* 좌측: 점검기록 테이블 */}
|
||||
<ResizablePanel defaultSize={60} minSize={35}>
|
||||
<div className="h-full overflow-auto">
|
||||
<ResizablePanel defaultSize={60} minSize={35} className="flex flex-col">
|
||||
<div className="flex-1 overflow-auto min-h-0">
|
||||
{loading && records.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Loader2 className="h-8 w-8 animate-spin mb-3" />
|
||||
|
||||
@@ -203,8 +203,8 @@ export default function InspectionResultPage() {
|
||||
{/* 분할 패널 */}
|
||||
<ResizablePanelGroup direction="horizontal" className="flex-1 min-h-0">
|
||||
{/* 좌측: 마스터 테이블 */}
|
||||
<ResizablePanel defaultSize={55} minSize={30}>
|
||||
<div className="h-full overflow-auto">
|
||||
<ResizablePanel defaultSize={55} minSize={30} className="flex flex-col">
|
||||
<div className="flex-1 overflow-auto min-h-0">
|
||||
{loading && masterData.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Loader2 className="h-8 w-8 animate-spin mb-3" />
|
||||
|
||||
@@ -228,8 +228,8 @@ export default function EquipmentInspectionRecordPage() {
|
||||
{/* 분할 패널 */}
|
||||
<ResizablePanelGroup direction="horizontal" className="flex-1 min-h-0">
|
||||
{/* 좌측: 점검기록 테이블 */}
|
||||
<ResizablePanel defaultSize={60} minSize={35}>
|
||||
<div className="h-full overflow-auto">
|
||||
<ResizablePanel defaultSize={60} minSize={35} className="flex flex-col">
|
||||
<div className="flex-1 overflow-auto min-h-0">
|
||||
{loading && records.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Loader2 className="h-8 w-8 animate-spin mb-3" />
|
||||
|
||||
@@ -187,8 +187,8 @@ export default function InspectionResultPage() {
|
||||
{/* 분할 패널 */}
|
||||
<ResizablePanelGroup direction="horizontal" className="flex-1 min-h-0">
|
||||
{/* 좌측: 마스터 테이블 */}
|
||||
<ResizablePanel defaultSize={55} minSize={30}>
|
||||
<div className="h-full overflow-auto">
|
||||
<ResizablePanel defaultSize={55} minSize={30} className="flex flex-col">
|
||||
<div className="flex-1 overflow-auto min-h-0">
|
||||
{loading && masterData.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Loader2 className="h-8 w-8 animate-spin mb-3" />
|
||||
|
||||
@@ -228,8 +228,8 @@ export default function EquipmentInspectionRecordPage() {
|
||||
{/* 분할 패널 */}
|
||||
<ResizablePanelGroup direction="horizontal" className="flex-1 min-h-0">
|
||||
{/* 좌측: 점검기록 테이블 */}
|
||||
<ResizablePanel defaultSize={60} minSize={35}>
|
||||
<div className="h-full overflow-auto">
|
||||
<ResizablePanel defaultSize={60} minSize={35} className="flex flex-col">
|
||||
<div className="flex-1 overflow-auto min-h-0">
|
||||
{loading && records.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Loader2 className="h-8 w-8 animate-spin mb-3" />
|
||||
|
||||
@@ -187,8 +187,8 @@ export default function InspectionResultPage() {
|
||||
{/* 분할 패널 */}
|
||||
<ResizablePanelGroup direction="horizontal" className="flex-1 min-h-0">
|
||||
{/* 좌측: 마스터 테이블 */}
|
||||
<ResizablePanel defaultSize={55} minSize={30}>
|
||||
<div className="h-full overflow-auto">
|
||||
<ResizablePanel defaultSize={55} minSize={30} className="flex flex-col">
|
||||
<div className="flex-1 overflow-auto min-h-0">
|
||||
{loading && masterData.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Loader2 className="h-8 w-8 animate-spin mb-3" />
|
||||
|
||||
@@ -215,8 +215,8 @@ export default function EquipmentInspectionRecordPage() {
|
||||
{/* 분할 패널 */}
|
||||
<ResizablePanelGroup direction="horizontal" className="flex-1 min-h-0">
|
||||
{/* 좌측: 점검기록 테이블 */}
|
||||
<ResizablePanel defaultSize={60} minSize={35}>
|
||||
<div className="h-full overflow-auto">
|
||||
<ResizablePanel defaultSize={60} minSize={35} className="flex flex-col">
|
||||
<div className="flex-1 overflow-auto min-h-0">
|
||||
{loading && records.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Loader2 className="h-8 w-8 animate-spin mb-3" />
|
||||
|
||||
@@ -187,8 +187,8 @@ export default function InspectionResultPage() {
|
||||
{/* 분할 패널 */}
|
||||
<ResizablePanelGroup direction="horizontal" className="flex-1 min-h-0">
|
||||
{/* 좌측: 마스터 테이블 */}
|
||||
<ResizablePanel defaultSize={55} minSize={30}>
|
||||
<div className="h-full overflow-auto">
|
||||
<ResizablePanel defaultSize={55} minSize={30} className="flex flex-col">
|
||||
<div className="flex-1 overflow-auto min-h-0">
|
||||
{loading && masterData.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Loader2 className="h-8 w-8 animate-spin mb-3" />
|
||||
|
||||
@@ -310,11 +310,15 @@ function getGroupKey(row: Record<string, any>, groupBy: string): string {
|
||||
function aggregateValues(
|
||||
rows: Record<string, any>[],
|
||||
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<string, number> = {};
|
||||
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 (
|
||||
<div
|
||||
@@ -2074,9 +2078,10 @@ export default function ReportEngine({ config }: ReportEngineProps) {
|
||||
</td>
|
||||
{analysisResult.series.map((s, si) => {
|
||||
const allRows = subLabels.flatMap((lb) => s.groups[lb] || []);
|
||||
const m = config.metrics.find((x) => x.id === s.metricId);
|
||||
return (
|
||||
<td key={si} className="p-2 text-right tabular-nums">
|
||||
{formatNumber(aggregateValues(allRows, s.metricId, s.aggMethod))}
|
||||
{formatNumber(aggregateValues(allRows, s.metricId, s.aggMethod, m))}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user