Refactor analytics report and production plan services for improved data handling
- Updated the `getQualityReportData` function to utilize `inspection_result_mng` for quality report generation, enhancing data accuracy by aggregating inspection results. - Refined date handling in the `getOrderSummary` function to improve filtering logic and ensure accurate stock calculations. - Implemented virtual scrolling in the `TimelineScheduler` component to optimize performance when rendering large datasets. These changes enhance data retrieval efficiency and user experience across analytics and production planning modules.
This commit is contained in:
@@ -367,57 +367,33 @@ export async function getQualityReportData(req: any, res: Response): Promise<voi
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
const cf = buildCompanyFilter(companyCode, "pr", idx);
|
||||
const cf = buildCompanyFilter(companyCode, "irm", idx);
|
||||
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
|
||||
|
||||
const df = buildDateFilter(startDate, endDate, "COALESCE(pr.production_date, pr.created_date::date::text)", idx);
|
||||
const df = buildDateFilter(startDate, endDate, "TO_CHAR(irm.inspection_date, 'YYYY-MM-DD')", idx);
|
||||
conditions.push(...df.conditions); params.push(...df.params); idx = df.nextIdx;
|
||||
|
||||
const whereClause = buildWhereClause(conditions);
|
||||
|
||||
// 품질 리포트는 실제 검사 결과(inspection_result_mng) 기반으로 집계.
|
||||
// 작업/생산실적이 아닌 검사 단위에서 합격/불량 수량을 산출.
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
COALESCE(pr.production_date, pr.created_date::date::text) as date,
|
||||
COALESCE(NULLIF(ii.item_name, ''), NULLIF(ii.item_number, ''), '미지정') as item,
|
||||
'일반검사' as "defectType",
|
||||
COALESCE(wi.routing, '미지정') as process,
|
||||
COALESCE(pr.worker_name, '미지정') as inspector,
|
||||
CAST(COALESCE(NULLIF(pr.production_qty, ''), '0') AS numeric) as "inspQty",
|
||||
CAST(COALESCE(NULLIF(pr.production_qty, ''), '0') AS numeric)
|
||||
- CAST(COALESCE(NULLIF(pr.defect_qty, ''), '0') AS numeric) as "passQty",
|
||||
CAST(COALESCE(NULLIF(pr.defect_qty, ''), '0') AS numeric) as "defectQty",
|
||||
0 as "reworkQty",
|
||||
0 as "scrapQty",
|
||||
0 as "claimCnt",
|
||||
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 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
|
||||
TO_CHAR(irm.inspection_date, 'YYYY-MM-DD') AS date,
|
||||
COALESCE(NULLIF(REGEXP_REPLACE(COALESCE(irm.item_name, ''), '^[[:space:]]+|[[:space:]]+$', '', 'g'), ''), NULLIF(irm.item_code, ''), '미지정') AS item,
|
||||
COALESCE(NULLIF(irm.inspection_type, ''), '일반검사') AS "defectType",
|
||||
COALESCE(NULLIF(irm.inspection_type, ''), '미지정') AS process,
|
||||
COALESCE(NULLIF(irm.inspector, ''), '미지정') AS inspector,
|
||||
COALESCE(irm.total_qty, 0) AS "inspQty",
|
||||
COALESCE(irm.good_qty, 0) AS "passQty",
|
||||
COALESCE(irm.bad_qty, 0) AS "defectQty",
|
||||
0 AS "reworkQty",
|
||||
0 AS "scrapQty",
|
||||
0 AS "claimCnt",
|
||||
irm.company_code
|
||||
FROM inspection_result_mng irm
|
||||
${whereClause}
|
||||
ORDER BY date DESC NULLS LAST
|
||||
ORDER BY irm.inspection_date DESC NULLS LAST
|
||||
`;
|
||||
|
||||
const dataRows = await query(dataQuery, params);
|
||||
@@ -430,11 +406,7 @@ export async function getQualityReportData(req: any, res: Response): Promise<voi
|
||||
rows: dataRows,
|
||||
filterOptions: {
|
||||
items: extractFilterSet(dataRows, "item"),
|
||||
defectTypes: [
|
||||
{ value: "외관불량", label: "외관불량" }, { value: "치수불량", label: "치수불량" },
|
||||
{ value: "기능불량", label: "기능불량" }, { value: "재질불량", label: "재질불량" },
|
||||
{ value: "일반검사", label: "일반검사" },
|
||||
],
|
||||
defectTypes: extractFilterSet(dataRows, "defectType"),
|
||||
processes: extractFilterSet(dataRows, "process"),
|
||||
inspectors: extractFilterSet(dataRows, "inspector"),
|
||||
},
|
||||
|
||||
@@ -131,7 +131,13 @@ export async function getOrderSummary(
|
||||
LEFT JOIN stock_info si ON os.item_code = si.item_code
|
||||
LEFT JOIN plan_info pi ON os.item_code = pi.item_code
|
||||
LEFT JOIN item_info_dedup ilt ON os.item_code = ilt.item_number
|
||||
${options?.excludePlanned ? "WHERE COALESCE(pi.existing_plan_qty, 0) + COALESCE(pi.in_progress_qty, 0) = 0" : ""}
|
||||
${options?.excludePlanned
|
||||
? ""
|
||||
: `WHERE GREATEST(
|
||||
os.total_balance_qty + COALESCE(si.safety_stock, 0) - COALESCE(si.current_stock, 0)
|
||||
- COALESCE(pi.existing_plan_qty, 0) - COALESCE(pi.in_progress_qty, 0),
|
||||
0
|
||||
) > 0`}
|
||||
ORDER BY os.item_code
|
||||
`;
|
||||
|
||||
@@ -141,10 +147,11 @@ export async function getOrderSummary(
|
||||
let pagedParams: any[] = params;
|
||||
|
||||
if (usePaging) {
|
||||
// total: 그룹 수 = order_summary CTE의 행 수
|
||||
// total: excludePlanned 필터까지 적용한 그룹 수
|
||||
// order_summary + plan_info CTE만 다시 만들어서 동일 필터로 COUNT
|
||||
const countQuery = `
|
||||
WITH all_orders AS (
|
||||
SELECT so.part_code, so.company_code
|
||||
SELECT so.part_code, so.company_code, COALESCE(so.balance_qty::numeric, 0) AS balance_qty
|
||||
FROM sales_order_mng so
|
||||
WHERE ${whereClause}
|
||||
AND so.part_code IS NOT NULL AND so.part_code != ''
|
||||
@@ -153,15 +160,43 @@ export async function getOrderSummary(
|
||||
WHERE sd.order_no = so.order_no AND sd.company_code = so.company_code
|
||||
)
|
||||
UNION ALL
|
||||
SELECT sd.part_code, sd.company_code
|
||||
SELECT sd.part_code, sd.company_code, COALESCE(sd.balance_qty::numeric, sd.qty::numeric - COALESCE(sd.ship_qty::numeric, 0), 0)
|
||||
FROM sales_order_detail sd
|
||||
INNER JOIN sales_order_mng so ON sd.order_no = so.order_no AND sd.company_code = so.company_code
|
||||
WHERE sd.company_code = $1
|
||||
AND sd.part_code IS NOT NULL AND sd.part_code != ''
|
||||
),
|
||||
os AS (
|
||||
SELECT part_code AS item_code, SUM(balance_qty) AS total_balance_qty
|
||||
FROM all_orders GROUP BY part_code
|
||||
),
|
||||
si AS (
|
||||
SELECT item_code,
|
||||
SUM(COALESCE(current_qty::numeric, 0)) AS current_stock,
|
||||
MAX(COALESCE(safety_qty::numeric, 0)) AS safety_stock
|
||||
FROM inventory_stock WHERE company_code = $1 GROUP BY item_code
|
||||
),
|
||||
pi AS (
|
||||
SELECT item_code,
|
||||
SUM(CASE WHEN status = 'planned' THEN COALESCE(plan_qty, 0) ELSE 0 END) AS existing_plan_qty,
|
||||
SUM(CASE WHEN status = 'in_progress' THEN COALESCE(plan_qty, 0) ELSE 0 END) AS in_progress_qty
|
||||
FROM production_plan_mng
|
||||
WHERE company_code = $1
|
||||
AND COALESCE(product_type, '완제품') = '완제품'
|
||||
AND status NOT IN ('completed', 'cancelled')
|
||||
GROUP BY item_code
|
||||
)
|
||||
SELECT COUNT(*)::int AS total FROM (
|
||||
SELECT DISTINCT part_code FROM all_orders
|
||||
) g;
|
||||
SELECT COUNT(*)::int AS total
|
||||
FROM os
|
||||
LEFT JOIN si ON os.item_code = si.item_code
|
||||
LEFT JOIN pi ON os.item_code = pi.item_code
|
||||
${options?.excludePlanned
|
||||
? ""
|
||||
: `WHERE GREATEST(
|
||||
os.total_balance_qty + COALESCE(si.safety_stock, 0) - COALESCE(si.current_stock, 0)
|
||||
- COALESCE(pi.existing_plan_qty, 0) - COALESCE(pi.in_progress_qty, 0),
|
||||
0
|
||||
) > 0`};
|
||||
`;
|
||||
const countRes = await pool.query(countQuery, params);
|
||||
total = countRes.rows[0]?.total ?? 0;
|
||||
|
||||
@@ -73,7 +73,7 @@ export default function KpiDailyProductionPage() {
|
||||
label,
|
||||
production_qty: Number(r.production_qty) || 0,
|
||||
work_hours: Number(r.work_hours) || 0,
|
||||
hourly: Math.round(hourly * 100) / 100,
|
||||
hourly: Math.floor(hourly * 100) / 100,
|
||||
};
|
||||
});
|
||||
}, [rows]);
|
||||
@@ -85,9 +85,9 @@ export default function KpiDailyProductionPage() {
|
||||
const hourly = totalHours > 0 ? totalQty / totalHours : 0;
|
||||
return {
|
||||
totalQty,
|
||||
totalHours: Math.round(totalHours * 100) / 100,
|
||||
totalHours: Math.floor(totalHours * 100) / 100,
|
||||
totalDefect,
|
||||
hourly: Math.round(hourly * 100) / 100,
|
||||
hourly: Math.floor(hourly * 100) / 100,
|
||||
};
|
||||
}, [rows]);
|
||||
|
||||
@@ -103,7 +103,7 @@ export default function KpiDailyProductionPage() {
|
||||
const wh = Number(row.work_hours) || 0;
|
||||
if (wh === 0) return "-";
|
||||
const v = Number(row.production_qty) / wh;
|
||||
return v.toFixed(2);
|
||||
return (Math.floor(v * 100) / 100).toFixed(2);
|
||||
} },
|
||||
], []);
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function KpiDailyProductionPage() {
|
||||
label,
|
||||
production_qty: Number(r.production_qty) || 0,
|
||||
work_hours: Number(r.work_hours) || 0,
|
||||
hourly: Math.round(hourly * 100) / 100,
|
||||
hourly: Math.floor(hourly * 100) / 100,
|
||||
};
|
||||
});
|
||||
}, [rows]);
|
||||
@@ -88,9 +88,9 @@ export default function KpiDailyProductionPage() {
|
||||
const hourly = totalHours > 0 ? totalQty / totalHours : 0;
|
||||
return {
|
||||
totalQty,
|
||||
totalHours: Math.round(totalHours * 100) / 100,
|
||||
totalHours: Math.floor(totalHours * 100) / 100,
|
||||
totalDefect,
|
||||
hourly: Math.round(hourly * 100) / 100,
|
||||
hourly: Math.floor(hourly * 100) / 100,
|
||||
};
|
||||
}, [rows]);
|
||||
|
||||
@@ -107,7 +107,7 @@ export default function KpiDailyProductionPage() {
|
||||
const wh = Number(row.work_hours) || 0;
|
||||
if (wh === 0) return "-";
|
||||
const v = Number(row.production_qty) / wh;
|
||||
return v.toFixed(2);
|
||||
return (Math.floor(v * 100) / 100).toFixed(2);
|
||||
} },
|
||||
], []);
|
||||
|
||||
|
||||
@@ -588,15 +588,25 @@ export default function ChunganSalesOrderPage() {
|
||||
// 통계 (전체 기준)
|
||||
const stats = totalStats;
|
||||
|
||||
// 우측: 선택된 수주 디테일 조회 (division 코드→라벨 변환)
|
||||
// 우측: 선택된 수주 디테일 조회 (division 코드→라벨 변환, 콤마 복합 지원)
|
||||
useEffect(() => {
|
||||
if (!selectedOrderNo) { setDetailItems([]); setDetailCheckedIds([]); return; }
|
||||
const resolveMulti = (col: string, raw: any) => {
|
||||
const s = String(raw ?? "");
|
||||
if (!s) return "";
|
||||
const opts = categoryOptions[col] || [];
|
||||
return s.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
.map((t) => opts.find((o) => o.code === t)?.label || t)
|
||||
.join(", ");
|
||||
};
|
||||
const items = allDetails
|
||||
.filter((d) => d.order_no === selectedOrderNo)
|
||||
.map((d) => ({
|
||||
...d,
|
||||
division: categoryOptions["item_division"]?.find((o) => o.code === d.division)?.label || d.division || "",
|
||||
type: categoryOptions["item_type"]?.find((o) => o.code === d.type)?.label || d.type || "",
|
||||
division: resolveMulti("item_division", d.division),
|
||||
type: resolveMulti("item_type", d.type),
|
||||
}));
|
||||
setDetailItems(items);
|
||||
setDetailCheckedIds([]);
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useCallback, useMemo, useEffect } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
@@ -333,6 +334,26 @@ export default function TimelineScheduler({
|
||||
return map;
|
||||
}, [eventsByResource, eventLanes]);
|
||||
|
||||
// ── 가상 스크롤 (리소스 행) ──
|
||||
// 대량 리소스(수천~만 단위) 환경에서 보이는 행만 DOM 마운트하여 성능 확보
|
||||
const barHeight = 24;
|
||||
const barGap = 2;
|
||||
const getRowHeight = useCallback((idx: number) => {
|
||||
const res = resources[idx];
|
||||
if (!res) return rowHeight;
|
||||
const laneCount = resourceLaneCounts.get(res.id) || 1;
|
||||
return Math.max(rowHeight, laneCount * (barHeight + barGap) + 12);
|
||||
}, [resources, resourceLaneCounts, rowHeight]);
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: resources.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: getRowHeight,
|
||||
overscan: 8,
|
||||
});
|
||||
const virtualItems = rowVirtualizer.getVirtualItems();
|
||||
const totalRowsHeight = rowVirtualizer.getTotalSize();
|
||||
|
||||
// ── 줌/네비게이션 핸들러 ──
|
||||
|
||||
const handleZoom = useCallback(
|
||||
@@ -623,9 +644,6 @@ export default function TimelineScheduler({
|
||||
// 데이터 0건이어도 네비게이션 컨트롤(이전/오늘/다음/줌)은 노출하여 이전 기간 탐색 가능하도록.
|
||||
const hasData = resources.length > 0 && events.length > 0;
|
||||
|
||||
const barHeight = 24;
|
||||
const barGap = 2;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* 컨트롤 바 */}
|
||||
@@ -712,27 +730,36 @@ export default function TimelineScheduler({
|
||||
>
|
||||
리소스
|
||||
</div>
|
||||
{/* 리소스 행 */}
|
||||
{resources.map((res) => {
|
||||
const laneCount = resourceLaneCounts.get(res.id) || 1;
|
||||
const h = Math.max(rowHeight, laneCount * (barHeight + barGap) + 12);
|
||||
return (
|
||||
<div
|
||||
key={res.id}
|
||||
className="border-b px-3 flex flex-col justify-center"
|
||||
style={{ height: h }}
|
||||
>
|
||||
<span className="text-xs font-semibold text-foreground truncate">
|
||||
{res.label}
|
||||
</span>
|
||||
{res.subLabel && (
|
||||
<span className="text-[10px] text-muted-foreground truncate">
|
||||
{res.subLabel}
|
||||
{/* 리소스 행 — 가상화 (보이는 행만 마운트) */}
|
||||
<div style={{ height: totalRowsHeight, position: "relative" }}>
|
||||
{virtualItems.map((vRow) => {
|
||||
const res = resources[vRow.index];
|
||||
if (!res) return null;
|
||||
return (
|
||||
<div
|
||||
key={res.id}
|
||||
className="border-b px-3 flex flex-col justify-center"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: vRow.size,
|
||||
transform: `translateY(${vRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
<span className="text-xs font-semibold text-foreground truncate">
|
||||
{res.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{res.subLabel && (
|
||||
<span className="text-[10px] text-muted-foreground truncate">
|
||||
{res.subLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측: 타임라인 그리드 */}
|
||||
@@ -788,14 +815,26 @@ export default function TimelineScheduler({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 리소스별 이벤트 행 */}
|
||||
{resources.map((res) => {
|
||||
{/* 리소스별 이벤트 행 — 가상화 (좌측 컬럼과 동일 인덱스/높이) */}
|
||||
<div style={{ height: totalRowsHeight, position: "relative" }}>
|
||||
{virtualItems.map((vRow) => {
|
||||
const res = resources[vRow.index];
|
||||
if (!res) return null;
|
||||
const resEvents = eventsByResource.get(res.id) || [];
|
||||
const laneCount = resourceLaneCounts.get(res.id) || 1;
|
||||
const h = Math.max(rowHeight, laneCount * (barHeight + barGap) + 12);
|
||||
|
||||
return (
|
||||
<div key={res.id} className="relative border-b" style={{ height: h }}>
|
||||
<div
|
||||
key={res.id}
|
||||
className="border-b"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: vRow.size,
|
||||
transform: `translateY(${vRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{/* 배경 그리드 */}
|
||||
<div className="absolute inset-0 flex pointer-events-none">
|
||||
{dates.map((date, idx) => (
|
||||
@@ -922,6 +961,7 @@ export default function TimelineScheduler({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user