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

Reviewed-on: jskim/vexplor_dev#39
This commit is contained in:
2026-04-28 11:20:32 +00:00
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;
}
}

View File

@@ -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" />

View File

@@ -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" />

View File

@@ -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" />

View File

@@ -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" />

View File

@@ -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" />

View File

@@ -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" />

View File

@@ -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" />

View File

@@ -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" />

View File

@@ -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" />

View File

@@ -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" />

View File

@@ -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" />

View File

@@ -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" />

View File

@@ -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" />

View File

@@ -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" />

View File

@@ -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;
}