- 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.
682 lines
28 KiB
TypeScript
682 lines
28 KiB
TypeScript
import { Response } from "express";
|
|
import { query } from "../database/db";
|
|
import { logger } from "../utils/logger";
|
|
|
|
function buildCompanyFilter(companyCode: string, alias: string, paramIdx: number) {
|
|
if (companyCode === "*") return { condition: "", params: [] as any[], nextIdx: paramIdx };
|
|
return {
|
|
condition: `${alias}.company_code = $${paramIdx}`,
|
|
params: [companyCode],
|
|
nextIdx: paramIdx + 1,
|
|
};
|
|
}
|
|
|
|
function buildDateFilter(startDate: string | undefined, endDate: string | undefined, dateExpr: string, paramIdx: number) {
|
|
const conditions: string[] = [];
|
|
const params: any[] = [];
|
|
let idx = paramIdx;
|
|
|
|
if (startDate) {
|
|
conditions.push(`${dateExpr} >= $${idx}`);
|
|
params.push(startDate);
|
|
idx++;
|
|
}
|
|
if (endDate) {
|
|
conditions.push(`${dateExpr} <= $${idx}`);
|
|
params.push(endDate);
|
|
idx++;
|
|
}
|
|
|
|
return { conditions, params, nextIdx: idx };
|
|
}
|
|
|
|
function buildWhereClause(conditions: string[]): string {
|
|
return conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
}
|
|
|
|
function extractFilterSet(rows: any[], field: string, labelField?: string): { value: string; label: string }[] {
|
|
const set = new Map<string, string>();
|
|
rows.forEach((r: any) => {
|
|
const val = r[field];
|
|
if (val && val !== "미지정") set.set(val, r[labelField || field] || val);
|
|
});
|
|
return [...set.entries()].map(([value, label]) => ({ value, label }));
|
|
}
|
|
|
|
// ============================================
|
|
// 생산 리포트
|
|
// ============================================
|
|
export async function getProductionReportData(req: any, res: Response): Promise<void> {
|
|
try {
|
|
const companyCode = req.user?.companyCode;
|
|
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
|
|
|
|
const { startDate, endDate } = req.query;
|
|
const conditions: string[] = [];
|
|
const params: any[] = [];
|
|
let idx = 1;
|
|
|
|
const cf = buildCompanyFilter(companyCode, "wop", idx);
|
|
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
|
|
|
|
const dateExpr = "COALESCE(NULLIF(wopr.started_at, ''), wop.created_date::date::text)";
|
|
const df = buildDateFilter(startDate, endDate, dateExpr, idx);
|
|
conditions.push(...df.conditions); params.push(...df.params); idx = df.nextIdx;
|
|
|
|
const whereClause = buildWhereClause(conditions);
|
|
|
|
// 공정 메타(process_code/name/plan_qty)는 work_order_process,
|
|
// 실적(started_at/completed_at/good_qty/defect_qty/equipment_code 등)은 work_order_process_result에 있음
|
|
const dataQuery = `
|
|
SELECT
|
|
COALESCE(NULLIF(wopr.started_at, ''), wop.created_date::date::text) as date,
|
|
COALESCE(NULLIF(wop.process_name, ''), NULLIF(wop.process_code, ''), '미지정') as process,
|
|
COALESCE(NULLIF(em.equipment_name, ''), NULLIF(em.equipment_code, ''), '미지정') as equipment,
|
|
COALESCE(NULLIF(ii.item_name, ''), NULLIF(ii.item_number, ''), '미지정') as item,
|
|
COALESCE(NULLIF(wi.worker, ''), '미지정') as worker,
|
|
CAST(COALESCE(NULLIF(wop.plan_qty, ''), '0') AS numeric) as "planQty",
|
|
CAST(COALESCE(NULLIF(wopr.good_qty, ''), '0') AS numeric) as "prodQty",
|
|
CAST(COALESCE(NULLIF(wopr.defect_qty, ''), '0') AS numeric) as "defectQty",
|
|
CASE
|
|
WHEN NULLIF(wopr.started_at, '') IS NOT NULL
|
|
AND NULLIF(wopr.completed_at, '') IS NOT NULL
|
|
THEN GREATEST(
|
|
EXTRACT(EPOCH FROM (wopr.completed_at::timestamp - wopr.started_at::timestamp)) / 3600.0,
|
|
0
|
|
)
|
|
ELSE 0
|
|
END as "runTime",
|
|
CAST(COALESCE(NULLIF(wopr.total_paused_time, ''), '0') AS numeric) / 3600.0 as "downTime",
|
|
wopr.status,
|
|
wop.company_code
|
|
FROM work_order_process wop
|
|
LEFT JOIN work_order_process_result wopr
|
|
ON wopr.wop_id = wop.id AND wopr.company_code = wop.company_code
|
|
LEFT JOIN work_instruction wi
|
|
ON wop.wo_id = wi.id AND wop.company_code = wi.company_code
|
|
LEFT JOIN LATERAL (
|
|
SELECT equipment_code, equipment_name
|
|
FROM equipment_mng
|
|
WHERE company_code = wi.company_code
|
|
AND (id = wi.equipment_id OR equipment_code = wi.equipment_id
|
|
OR id = wopr.equipment_code OR equipment_code = wopr.equipment_code)
|
|
ORDER BY (id = wi.equipment_id OR id = wopr.equipment_code) DESC, created_date DESC
|
|
LIMIT 1
|
|
) em ON true
|
|
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
|
|
`;
|
|
|
|
const dataRows = await query(dataQuery, params);
|
|
|
|
logger.info("생산 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
|
|
|
|
res.status(200).json({
|
|
success: true,
|
|
data: {
|
|
rows: dataRows,
|
|
filterOptions: {
|
|
processes: extractFilterSet(dataRows, "process"),
|
|
equipment: extractFilterSet(dataRows, "equipment"),
|
|
items: extractFilterSet(dataRows, "item"),
|
|
workers: extractFilterSet(dataRows, "worker"),
|
|
},
|
|
totalCount: dataRows.length,
|
|
},
|
|
});
|
|
} catch (error: any) {
|
|
logger.error("생산 리포트 조회 실패", { error: error.message });
|
|
res.status(500).json({ success: false, message: "생산 리포트 데이터 조회에 실패했습니다", error: error.message });
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// 재고 리포트
|
|
// ============================================
|
|
export async function getInventoryReportData(req: any, res: Response): Promise<void> {
|
|
try {
|
|
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 cf = buildCompanyFilter(companyCode, "ist", idx);
|
|
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
|
|
|
|
const whereClause = buildWhereClause(conditions);
|
|
|
|
const dataQuery = `
|
|
SELECT
|
|
COALESCE(ist.updated_date, ist.created_date)::date::text as date,
|
|
ist.item_code,
|
|
COALESCE(ii.item_name, ist.item_code, '미지정') as item,
|
|
COALESCE(wi.warehouse_name, ist.warehouse_code, '미지정') as warehouse,
|
|
'일반' as category,
|
|
CAST(COALESCE(NULLIF(ist.current_qty::text, ''), '0') AS numeric) as "currentQty",
|
|
CAST(COALESCE(NULLIF(ist.safety_qty::text, ''), '0') AS numeric) as "safetyQty",
|
|
COALESCE(ih_in.in_qty, 0) as "inQty",
|
|
COALESCE(ih_out.out_qty, 0) as "outQty",
|
|
0 as "stockValue",
|
|
GREATEST(CAST(COALESCE(NULLIF(ist.safety_qty::text, ''), '0') AS numeric)
|
|
- CAST(COALESCE(NULLIF(ist.current_qty::text, ''), '0') AS numeric), 0) as "shortageQty",
|
|
CASE WHEN CAST(COALESCE(NULLIF(ist.current_qty::text, ''), '0') AS numeric) > 0
|
|
AND COALESCE(ih_out.out_qty, 0) > 0
|
|
THEN ROUND(COALESCE(ih_out.out_qty, 0)::numeric
|
|
/ CAST(COALESCE(NULLIF(ist.current_qty::text, ''), '1') AS numeric), 2)
|
|
ELSE 0 END as "turnover",
|
|
ist.company_code
|
|
FROM inventory_stock ist
|
|
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 ist.item_code = ii.item_number AND ist.company_code = ii.company_code
|
|
LEFT JOIN warehouse_info wi ON ist.warehouse_code = wi.warehouse_code
|
|
AND ist.company_code = wi.company_code
|
|
LEFT JOIN (
|
|
SELECT item_code, company_code,
|
|
SUM(CAST(COALESCE(NULLIF(quantity::text, ''), '0') AS numeric)) as in_qty
|
|
FROM inventory_history WHERE transaction_type = 'IN'
|
|
GROUP BY item_code, company_code
|
|
) ih_in ON ist.item_code = ih_in.item_code AND ist.company_code = ih_in.company_code
|
|
LEFT JOIN (
|
|
SELECT item_code, company_code,
|
|
SUM(CAST(COALESCE(NULLIF(quantity::text, ''), '0') AS numeric)) as out_qty
|
|
FROM inventory_history WHERE transaction_type = 'OUT'
|
|
GROUP BY item_code, company_code
|
|
) ih_out ON ist.item_code = ih_out.item_code AND ist.company_code = ih_out.company_code
|
|
${whereClause}
|
|
ORDER BY date DESC NULLS LAST
|
|
`;
|
|
|
|
const dataRows = await query(dataQuery, params);
|
|
|
|
logger.info("재고 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
|
|
|
|
res.status(200).json({
|
|
success: true,
|
|
data: {
|
|
rows: dataRows,
|
|
filterOptions: {
|
|
items: extractFilterSet(dataRows, "item"),
|
|
warehouses: extractFilterSet(dataRows, "warehouse"),
|
|
categories: [
|
|
{ value: "원자재", label: "원자재" }, { value: "부자재", label: "부자재" },
|
|
{ value: "반제품", label: "반제품" }, { value: "완제품", label: "완제품" },
|
|
{ value: "일반", label: "일반" },
|
|
],
|
|
},
|
|
totalCount: dataRows.length,
|
|
},
|
|
});
|
|
} catch (error: any) {
|
|
logger.error("재고 리포트 조회 실패", { error: error.message });
|
|
res.status(500).json({ success: false, message: "재고 리포트 데이터 조회에 실패했습니다", error: error.message });
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// 구매 리포트
|
|
// ============================================
|
|
export async function getPurchaseReportData(req: any, res: Response): Promise<void> {
|
|
try {
|
|
const companyCode = req.user?.companyCode;
|
|
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
|
|
|
|
const { startDate, endDate } = req.query;
|
|
const params: any[] = [];
|
|
let idx = 1;
|
|
|
|
// company_code 필터 파라미터 ($1 또는 없음)
|
|
const cf = buildCompanyFilter(companyCode, "po", idx);
|
|
let companyConditionDetail = "";
|
|
let companyConditionLegacy = "";
|
|
if (cf.condition) {
|
|
// purchase_detail 쪽: pd.company_code
|
|
companyConditionDetail = `pd.company_code = $${idx}`;
|
|
// purchase_order_mng 쪽: po.company_code
|
|
companyConditionLegacy = `po.company_code = $${idx}`;
|
|
// NOT EXISTS 내부에서도 동일 파라미터 재사용
|
|
params.push(...cf.params);
|
|
idx = cf.nextIdx;
|
|
}
|
|
|
|
// 날짜 필터는 외부 쿼리에서 적용
|
|
const outerConditions: string[] = [];
|
|
const df = buildDateFilter(startDate, endDate, "date", idx);
|
|
outerConditions.push(...df.conditions);
|
|
params.push(...df.params);
|
|
idx = df.nextIdx;
|
|
|
|
const outerWhereClause = buildWhereClause(outerConditions);
|
|
|
|
const dataQuery = `
|
|
WITH combined AS (
|
|
-- 신규: purchase_detail 기반 (헤더는 purchase_order_mng LEFT JOIN)
|
|
SELECT
|
|
COALESCE(po.order_date, po.created_date::date::text, pd.created_date::date::text) as date,
|
|
COALESCE(po.purchase_no, pd.purchase_no) as purchase_no,
|
|
COALESCE(pd.supplier_name, pd.supplier_code, po.supplier_name, po.supplier_code, '미지정') as supplier,
|
|
COALESCE(NULLIF(pd.item_name, ''), po.item_name, NULLIF(pd.item_code, ''), po.item_code, '미지정') as item,
|
|
COALESCE(NULLIF(pd.item_code, ''), po.item_code) as item_code,
|
|
COALESCE(po.manager, '미지정') as manager,
|
|
COALESCE(po.status, '') as status,
|
|
CAST(COALESCE(NULLIF(pd.order_qty, ''), '0') AS numeric) as "orderQty",
|
|
CAST(COALESCE(NULLIF(pd.received_qty, ''), NULLIF(po.received_qty, ''), '0') AS numeric) as "receiveQty",
|
|
CAST(COALESCE(NULLIF(pd.unit_price, ''), '0') AS numeric) as "unitPrice",
|
|
CAST(COALESCE(NULLIF(pd.order_qty, ''), '0') AS numeric)
|
|
* CAST(COALESCE(NULLIF(pd.unit_price, ''), '0') AS numeric) as "orderAmt",
|
|
CAST(COALESCE(NULLIF(pd.received_qty, ''), NULLIF(po.received_qty, ''), '0') AS numeric)
|
|
* CAST(COALESCE(NULLIF(pd.unit_price, ''), '0') AS numeric) as "receiveAmt",
|
|
1 as "orderCnt",
|
|
pd.company_code
|
|
FROM purchase_detail pd
|
|
LEFT JOIN purchase_order_mng po
|
|
ON pd.purchase_no = po.purchase_no AND pd.company_code = po.company_code
|
|
${companyConditionDetail ? `WHERE ${companyConditionDetail}` : ""}
|
|
|
|
UNION ALL
|
|
|
|
-- 레거시: purchase_detail에 없는 purchase_order_mng 데이터
|
|
SELECT
|
|
COALESCE(po.order_date, po.created_date::date::text) as date,
|
|
po.purchase_no,
|
|
COALESCE(po.supplier_name, po.supplier_code, '미지정') as supplier,
|
|
COALESCE(po.item_name, po.item_code, '미지정') as item,
|
|
po.item_code,
|
|
COALESCE(po.manager, '미지정') as manager,
|
|
po.status,
|
|
CAST(COALESCE(NULLIF(po.order_qty, ''), '0') AS numeric) as "orderQty",
|
|
CAST(COALESCE(NULLIF(po.received_qty, ''), '0') AS numeric) as "receiveQty",
|
|
CAST(COALESCE(NULLIF(po.unit_price, ''), '0') AS numeric) as "unitPrice",
|
|
CAST(COALESCE(NULLIF(po.amount, ''), '0') AS numeric) as "orderAmt",
|
|
CAST(COALESCE(NULLIF(po.received_qty, ''), '0') AS numeric)
|
|
* CAST(COALESCE(NULLIF(po.unit_price, ''), '0') AS numeric) as "receiveAmt",
|
|
1 as "orderCnt",
|
|
po.company_code
|
|
FROM purchase_order_mng po
|
|
WHERE ${companyConditionLegacy ? `${companyConditionLegacy} AND ` : ""}NOT EXISTS (
|
|
SELECT 1 FROM purchase_detail pd
|
|
WHERE pd.purchase_no = po.purchase_no AND pd.company_code = po.company_code
|
|
)
|
|
)
|
|
SELECT * FROM combined
|
|
${outerWhereClause}
|
|
ORDER BY date DESC NULLS LAST
|
|
`;
|
|
|
|
const dataRows = await query(dataQuery, params);
|
|
|
|
logger.info("구매 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
|
|
|
|
res.status(200).json({
|
|
success: true,
|
|
data: {
|
|
rows: dataRows,
|
|
filterOptions: {
|
|
suppliers: extractFilterSet(dataRows, "supplier"),
|
|
items: extractFilterSet(dataRows, "item"),
|
|
managers: extractFilterSet(dataRows, "manager"),
|
|
statuses: extractFilterSet(dataRows, "status"),
|
|
},
|
|
totalCount: dataRows.length,
|
|
},
|
|
});
|
|
} catch (error: any) {
|
|
logger.error("구매 리포트 조회 실패", { error: error.message });
|
|
res.status(500).json({ success: false, message: "구매 리포트 데이터 조회에 실패했습니다", error: error.message });
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// 품질 리포트
|
|
// ============================================
|
|
export async function getQualityReportData(req: any, res: Response): Promise<void> {
|
|
try {
|
|
const companyCode = req.user?.companyCode;
|
|
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
|
|
|
|
const { startDate, endDate } = req.query;
|
|
const conditions: string[] = [];
|
|
const params: any[] = [];
|
|
let idx = 1;
|
|
|
|
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, "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
|
|
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 irm.inspection_date DESC NULLS LAST
|
|
`;
|
|
|
|
const dataRows = await query(dataQuery, params);
|
|
|
|
logger.info("품질 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
|
|
|
|
res.status(200).json({
|
|
success: true,
|
|
data: {
|
|
rows: dataRows,
|
|
filterOptions: {
|
|
items: extractFilterSet(dataRows, "item"),
|
|
defectTypes: extractFilterSet(dataRows, "defectType"),
|
|
processes: extractFilterSet(dataRows, "process"),
|
|
inspectors: extractFilterSet(dataRows, "inspector"),
|
|
},
|
|
totalCount: dataRows.length,
|
|
},
|
|
});
|
|
} catch (error: any) {
|
|
logger.error("품질 리포트 조회 실패", { error: error.message });
|
|
res.status(500).json({ success: false, message: "품질 리포트 데이터 조회에 실패했습니다", error: error.message });
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// 설비 리포트
|
|
// ============================================
|
|
export async function getEquipmentReportData(req: any, res: Response): Promise<void> {
|
|
try {
|
|
const companyCode = req.user?.companyCode;
|
|
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
|
|
|
|
const { startDate, endDate } = req.query;
|
|
|
|
// 회사 필터
|
|
const equipParams: any[] = [];
|
|
let equipWhere = "";
|
|
if (companyCode !== "*") {
|
|
equipParams.push(companyCode);
|
|
equipWhere = `WHERE ei.company_code = $1`;
|
|
}
|
|
|
|
// 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 ");
|
|
|
|
// 1-A) wopr(가동시간) 집계 — equipment_code 또는 em.id 매칭
|
|
const woprStatsQuery = `
|
|
SELECT
|
|
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);
|
|
|
|
// 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);
|
|
|
|
// 두 집계를 회사+설비코드 키로 병합
|
|
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,
|
|
data: {
|
|
rows: dataRows,
|
|
filterOptions: {
|
|
equipment: extractFilterSet(dataRows, "equipment"),
|
|
equipTypes: extractFilterSet(dataRows, "equipType"),
|
|
lines: extractFilterSet(dataRows, "line"),
|
|
managers: extractFilterSet(dataRows, "manager"),
|
|
},
|
|
totalCount: dataRows.length,
|
|
},
|
|
});
|
|
return;
|
|
} catch (error: any) {
|
|
logger.error("설비 리포트 조회 실패", { error: error.message });
|
|
res.status(500).json({ success: false, message: "설비 리포트 데이터 조회에 실패했습니다", error: error.message });
|
|
return;
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// 금형 리포트
|
|
// ============================================
|
|
export async function getMoldReportData(req: any, res: Response): Promise<void> {
|
|
try {
|
|
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 cf = buildCompanyFilter(companyCode, "mm", idx);
|
|
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
|
|
|
|
const whereClause = buildWhereClause(conditions);
|
|
|
|
const dataQuery = `
|
|
SELECT
|
|
COALESCE(mm.updated_date, mm.created_date)::date::text as date,
|
|
mm.mold_code,
|
|
COALESCE(mm.mold_name, mm.mold_code) as mold,
|
|
COALESCE(mm.mold_type, mm.category, '미지정') as "moldType",
|
|
COALESCE(ii.item_name, '미지정') as item,
|
|
COALESCE(mm.manufacturer, '미지정') as maker,
|
|
mm.operation_status as status,
|
|
CAST(COALESCE(NULLIF(mm.shot_count::text, ''), '0') AS numeric) as "shotCnt",
|
|
CAST(COALESCE(NULLIF(mm.warranty_shot_count::text, ''), '0') AS numeric) as "guaranteeShot",
|
|
CASE WHEN CAST(COALESCE(NULLIF(mm.warranty_shot_count::text, ''), '0') AS numeric) > 0
|
|
THEN ROUND(
|
|
CAST(COALESCE(NULLIF(mm.shot_count::text, ''), '0') AS numeric) * 100.0
|
|
/ CAST(COALESCE(NULLIF(mm.warranty_shot_count::text, ''), '1') AS numeric), 1)
|
|
ELSE 0 END as "lifeRate",
|
|
0 as "repairCnt",
|
|
0 as "repairCost",
|
|
0 as "prodQty",
|
|
0 as "defectRate",
|
|
CAST(COALESCE(NULLIF(mm.cavity_count::text, ''), '0') AS numeric) as "cavityUse",
|
|
mm.company_code
|
|
FROM mold_mng mm
|
|
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 mm.mold_code = ii.item_number AND mm.company_code = ii.company_code
|
|
${whereClause}
|
|
ORDER BY mold ASC
|
|
`;
|
|
|
|
const dataRows = await query(dataQuery, params);
|
|
|
|
logger.info("금형 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
|
|
|
|
res.status(200).json({
|
|
success: true,
|
|
data: {
|
|
rows: dataRows,
|
|
filterOptions: {
|
|
molds: extractFilterSet(dataRows, "mold"),
|
|
moldTypes: extractFilterSet(dataRows, "moldType"),
|
|
items: extractFilterSet(dataRows, "item"),
|
|
makers: extractFilterSet(dataRows, "maker"),
|
|
},
|
|
totalCount: dataRows.length,
|
|
},
|
|
});
|
|
} catch (error: any) {
|
|
logger.error("금형 리포트 조회 실패", { error: error.message });
|
|
res.status(500).json({ success: false, message: "금형 리포트 데이터 조회에 실패했습니다", error: error.message });
|
|
}
|
|
}
|