diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 9de5f66c..ae2424a0 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -144,6 +144,14 @@ import aiAssistantProxy from "./routes/aiAssistantProxy"; // AI 어시스턴트 import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력 import moldRoutes from "./routes/moldRoutes"; // 금형 관리 import shippingPlanRoutes from "./routes/shippingPlanRoutes"; // 출하계획 관리 +import shippingOrderRoutes from "./routes/shippingOrderRoutes"; // 출하지시 관리 +import workInstructionRoutes from "./routes/workInstructionRoutes"; // 작업지시 관리 +import salesReportRoutes from "./routes/salesReportRoutes"; // 영업 리포트 +import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석 리포트 (생산/재고/구매/품질/설비/금형) +import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN) +import materialStatusRoutes from "./routes/materialStatusRoutes"; // 자재현황 +import receivingRoutes from "./routes/receivingRoutes"; // 입고관리 +import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -316,6 +324,8 @@ app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리 app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회 app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리 app.use("/api/production", productionRoutes); // 생산계획 관리 +app.use("/api/material-status", materialStatusRoutes); // 자재현황 +app.use("/api/process-info", processInfoRoutes); // 공정정보관리 app.use("/api/roles", roleRoutes); // 권한 그룹 관리 app.use("/api/departments", departmentRoutes); // 부서 관리 app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리 @@ -337,6 +347,12 @@ app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작 app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력 app.use("/api/mold", moldRoutes); // 금형 관리 app.use("/api/shipping-plan", shippingPlanRoutes); // 출하계획 관리 +app.use("/api/shipping-order", shippingOrderRoutes); // 출하지시 관리 +app.use("/api/work-instruction", workInstructionRoutes); // 작업지시 관리 +app.use("/api/sales-report", salesReportRoutes); // 영업 리포트 +app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재고/구매/품질/설비/금형) +app.use("/api/design", designRoutes); // 설계 모듈 +app.use("/api/receiving", receivingRoutes); // 입고관리 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트) app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 diff --git a/backend-node/src/controllers/analyticsReportController.ts b/backend-node/src/controllers/analyticsReportController.ts new file mode 100644 index 00000000..d71eeb9a --- /dev/null +++ b/backend-node/src/controllers/analyticsReportController.ts @@ -0,0 +1,488 @@ +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(); + 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 { + 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, "wi", idx); + if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; } + + const df = buildDateFilter(startDate, endDate, "COALESCE(wi.start_date, wi.created_date::date::text)", idx); + conditions.push(...df.conditions); params.push(...df.params); idx = df.nextIdx; + + const whereClause = buildWhereClause(conditions); + + const dataQuery = ` + SELECT + COALESCE(wi.start_date, wi.created_date::date::text) as date, + COALESCE(wi.routing, '미지정') as process, + COALESCE(ei.equipment_name, wi.equipment_id, '미지정') as equipment, + COALESCE(ii.item_name, wi.item_id, '미지정') as item, + COALESCE(wi.worker, '미지정') as worker, + CAST(COALESCE(NULLIF(wi.qty, ''), '0') AS numeric) as "planQty", + COALESCE(pr.production_qty, 0) as "prodQty", + COALESCE(pr.defect_qty, 0) as "defectQty", + 0 as "runTime", + 0 as "downTime", + wi.status, + wi.company_code + FROM work_instruction wi + LEFT JOIN ( + SELECT wo_id, company_code, + SUM(CAST(COALESCE(NULLIF(production_qty, ''), '0') AS numeric)) as production_qty, + SUM(CAST(COALESCE(NULLIF(defect_qty, ''), '0') AS numeric)) as defect_qty + FROM production_record GROUP BY wo_id, company_code + ) pr ON wi.id = pr.wo_id AND wi.company_code = pr.company_code + LEFT JOIN ( + SELECT DISTINCT ON (equipment_code, company_code) + equipment_code, equipment_name, equipment_type, company_code + FROM equipment_info ORDER BY equipment_code, company_code, created_date DESC + ) ei ON wi.equipment_id = ei.equipment_code AND wi.company_code = ei.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 + ${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 { + 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 { + 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, "po", idx); + if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; } + + const df = buildDateFilter(startDate, endDate, "COALESCE(po.order_date, po.created_date::date::text)", idx); + conditions.push(...df.conditions); params.push(...df.params); idx = df.nextIdx; + + const whereClause = buildWhereClause(conditions); + + const dataQuery = ` + 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 + ${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: { + 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 { + 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, "pr", 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); + conditions.push(...df.conditions); params.push(...df.params); idx = df.nextIdx; + + const whereClause = buildWhereClause(conditions); + + const dataQuery = ` + SELECT + COALESCE(pr.production_date, pr.created_date::date::text) as date, + COALESCE(ii.item_name, wi.item_id, '미지정') 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 ( + 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 + ${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"), + defectTypes: [ + { value: "외관불량", label: "외관불량" }, { value: "치수불량", label: "치수불량" }, + { value: "기능불량", label: "기능불량" }, { value: "재질불량", label: "재질불량" }, + { value: "일반검사", label: "일반검사" }, + ], + 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 { + 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, "ei", idx); + if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; } + + const whereClause = buildWhereClause(conditions); + + const dataQuery = ` + 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 + `; + + const dataRows = await query(dataQuery, params); + + logger.info("설비 리포트 데이터 조회", { companyCode, rowCount: dataRows.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, + }, + }); + } catch (error: any) { + logger.error("설비 리포트 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: "설비 리포트 데이터 조회에 실패했습니다", error: error.message }); + } +} + +// ============================================ +// 금형 리포트 +// ============================================ +export async function getMoldReportData(req: any, res: Response): Promise { + 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 }); + } +} diff --git a/backend-node/src/controllers/batchManagementController.ts b/backend-node/src/controllers/batchManagementController.ts index bdd9e869..0845b1cb 100644 --- a/backend-node/src/controllers/batchManagementController.ts +++ b/backend-node/src/controllers/batchManagementController.ts @@ -126,29 +126,41 @@ export class BatchManagementController { */ static async createBatchConfig(req: AuthenticatedRequest, res: Response) { try { - const { batchName, description, cronSchedule, mappings, isActive } = - req.body; + const { + batchName, description, cronSchedule, mappings, isActive, + executionType, nodeFlowId, nodeFlowContext, + } = req.body; + const companyCode = req.user?.companyCode; - if ( - !batchName || - !cronSchedule || - !mappings || - !Array.isArray(mappings) - ) { + if (!batchName || !cronSchedule) { return res.status(400).json({ success: false, - message: - "필수 필드가 누락되었습니다. (batchName, cronSchedule, mappings)", + message: "필수 필드가 누락되었습니다. (batchName, cronSchedule)", }); } - const batchConfig = await BatchService.createBatchConfig({ - batchName, - description, - cronSchedule, - mappings, - isActive: isActive !== undefined ? isActive : true, - } as CreateBatchConfigRequest); + // 노드 플로우 타입은 매핑 없이 생성 가능 + if (executionType !== "node_flow" && (!mappings || !Array.isArray(mappings))) { + return res.status(400).json({ + success: false, + message: "매핑 타입은 mappings 배열이 필요합니다.", + }); + } + + const batchConfig = await BatchService.createBatchConfig( + { + batchName, + description, + cronSchedule, + mappings: mappings || [], + isActive: isActive === false || isActive === "N" ? "N" : "Y", + companyCode: companyCode || "", + executionType: executionType || "mapping", + nodeFlowId: nodeFlowId || null, + nodeFlowContext: nodeFlowContext || null, + } as CreateBatchConfigRequest, + req.user?.userId + ); return res.status(201).json({ success: true, @@ -768,4 +780,287 @@ export class BatchManagementController { }); } } + + /** + * 노드 플로우 목록 조회 (배치 설정에서 플로우 선택용) + * GET /api/batch-management/node-flows + */ + static async getNodeFlows(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + + let flowQuery: string; + let flowParams: any[] = []; + + if (companyCode === "*") { + flowQuery = ` + SELECT flow_id, flow_name, flow_description AS description, company_code, + COALESCE(jsonb_array_length( + CASE WHEN flow_data IS NOT NULL AND flow_data::text != '' + THEN (flow_data::jsonb -> 'nodes') + ELSE '[]'::jsonb END + ), 0) AS node_count + FROM node_flows + ORDER BY flow_name + `; + } else { + flowQuery = ` + SELECT flow_id, flow_name, flow_description AS description, company_code, + COALESCE(jsonb_array_length( + CASE WHEN flow_data IS NOT NULL AND flow_data::text != '' + THEN (flow_data::jsonb -> 'nodes') + ELSE '[]'::jsonb END + ), 0) AS node_count + FROM node_flows + WHERE company_code = $1 + ORDER BY flow_name + `; + flowParams = [companyCode]; + } + + const result = await query(flowQuery, flowParams); + return res.json({ success: true, data: result }); + } catch (error) { + console.error("노드 플로우 목록 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "노드 플로우 목록 조회 실패", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } + + /** + * 배치 대시보드 통계 조회 + * GET /api/batch-management/stats + * totalBatches, activeBatches, todayExecutions, todayFailures, prevDayExecutions, prevDayFailures + * 멀티테넌시: company_code 필터링 필수 + */ + static async getBatchStats(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + + // 전체/활성 배치 수 + let configQuery: string; + let configParams: any[] = []; + if (companyCode === "*") { + configQuery = ` + SELECT + COUNT(*)::int AS total, + COUNT(*) FILTER (WHERE is_active = 'Y')::int AS active + FROM batch_configs + `; + } else { + configQuery = ` + SELECT + COUNT(*)::int AS total, + COUNT(*) FILTER (WHERE is_active = 'Y')::int AS active + FROM batch_configs + WHERE company_code = $1 + `; + configParams = [companyCode]; + } + const configResult = await query<{ total: number; active: number }>( + configQuery, + configParams + ); + + // 오늘/어제 실행·실패 수 (KST 기준 날짜) + const logParams: any[] = []; + let logWhere = ""; + if (companyCode && companyCode !== "*") { + logWhere = " AND company_code = $1"; + logParams.push(companyCode); + } + const todayLogQuery = ` + SELECT + COUNT(*)::int AS today_executions, + COUNT(*) FILTER (WHERE execution_status = 'FAILED')::int AS today_failures + FROM batch_execution_logs + WHERE (start_time AT TIME ZONE 'Asia/Seoul')::date = (NOW() AT TIME ZONE 'Asia/Seoul')::date + ${logWhere} + `; + const prevDayLogQuery = ` + SELECT + COUNT(*)::int AS prev_executions, + COUNT(*) FILTER (WHERE execution_status = 'FAILED')::int AS prev_failures + FROM batch_execution_logs + WHERE (start_time AT TIME ZONE 'Asia/Seoul')::date = (NOW() AT TIME ZONE 'Asia/Seoul')::date - INTERVAL '1 day' + ${logWhere} + `; + const [todayResult, prevResult] = await Promise.all([ + query<{ today_executions: number; today_failures: number }>( + todayLogQuery, + logParams + ), + query<{ prev_executions: number; prev_failures: number }>( + prevDayLogQuery, + logParams + ), + ]); + + const config = configResult[0]; + const today = todayResult[0]; + const prev = prevResult[0]; + + return res.json({ + success: true, + data: { + totalBatches: config?.total ?? 0, + activeBatches: config?.active ?? 0, + todayExecutions: today?.today_executions ?? 0, + todayFailures: today?.today_failures ?? 0, + prevDayExecutions: prev?.prev_executions ?? 0, + prevDayFailures: prev?.prev_failures ?? 0, + }, + }); + } catch (error) { + console.error("배치 통계 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "배치 통계 조회 실패", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } + + /** + * 배치별 최근 24시간 스파크라인 (1시간 단위 집계) + * GET /api/batch-management/batch-configs/:id/sparkline + * 멀티테넌시: company_code 필터링 필수 + */ + static async getBatchSparkline(req: AuthenticatedRequest, res: Response) { + try { + const { id } = req.params; + const companyCode = req.user?.companyCode; + const batchId = Number(id); + if (!id || isNaN(batchId)) { + return res.status(400).json({ + success: false, + message: "올바른 배치 ID를 제공해주세요.", + }); + } + + const params: any[] = [batchId]; + let companyFilter = ""; + if (companyCode && companyCode !== "*") { + companyFilter = " AND bel.company_code = $2"; + params.push(companyCode); + } + + // KST 기준 최근 24시간 1시간 단위 슬롯 + 집계 (generate_series로 24개 보장) + const sparklineQuery = ` + WITH kst_slots AS ( + SELECT to_char(s, 'YYYY-MM-DD"T"HH24:00:00') AS hour + FROM generate_series( + (NOW() AT TIME ZONE 'Asia/Seoul') - INTERVAL '23 hours', + (NOW() AT TIME ZONE 'Asia/Seoul'), + INTERVAL '1 hour' + ) AS s + ), + agg AS ( + SELECT + to_char(date_trunc('hour', (bel.start_time AT TIME ZONE 'Asia/Seoul')) AT TIME ZONE 'Asia/Seoul', 'YYYY-MM-DD"T"HH24:00:00') AS hour, + COUNT(*) FILTER (WHERE bel.execution_status = 'SUCCESS')::int AS success, + COUNT(*) FILTER (WHERE bel.execution_status = 'FAILED')::int AS failed + FROM batch_execution_logs bel + WHERE bel.batch_config_id = $1 + AND bel.start_time >= (NOW() AT TIME ZONE 'Asia/Seoul') - INTERVAL '24 hours' + ${companyFilter} + GROUP BY date_trunc('hour', (bel.start_time AT TIME ZONE 'Asia/Seoul')) + ) + SELECT + k.hour, + COALESCE(a.success, 0) AS success, + COALESCE(a.failed, 0) AS failed + FROM kst_slots k + LEFT JOIN agg a ON k.hour = a.hour + ORDER BY k.hour + `; + const data = await query<{ + hour: string; + success: number; + failed: number; + }>(sparklineQuery, params); + + return res.json({ success: true, data }); + } catch (error) { + console.error("스파크라인 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "스파크라인 데이터 조회 실패", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } + + /** + * 배치별 최근 실행 로그 (최대 20건) + * GET /api/batch-management/batch-configs/:id/recent-logs + * 멀티테넌시: company_code 필터링 필수 + */ + static async getBatchRecentLogs(req: AuthenticatedRequest, res: Response) { + try { + const { id } = req.params; + const companyCode = req.user?.companyCode; + const batchId = Number(id); + const limit = Math.min(Number(req.query.limit) || 20, 20); + if (!id || isNaN(batchId)) { + return res.status(400).json({ + success: false, + message: "올바른 배치 ID를 제공해주세요.", + }); + } + + let logsQuery: string; + let logsParams: any[]; + if (companyCode === "*") { + logsQuery = ` + SELECT + id, + start_time AS started_at, + end_time AS finished_at, + execution_status AS status, + total_records, + success_records, + failed_records, + error_message, + duration_ms + FROM batch_execution_logs + WHERE batch_config_id = $1 + ORDER BY start_time DESC + LIMIT $2 + `; + logsParams = [batchId, limit]; + } else { + logsQuery = ` + SELECT + id, + start_time AS started_at, + end_time AS finished_at, + execution_status AS status, + total_records, + success_records, + failed_records, + error_message, + duration_ms + FROM batch_execution_logs + WHERE batch_config_id = $1 AND company_code = $2 + ORDER BY start_time DESC + LIMIT $3 + `; + logsParams = [batchId, companyCode, limit]; + } + + const result = await query(logsQuery, logsParams); + return res.json({ success: true, data: result }); + } catch (error) { + console.error("최근 실행 이력 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "최근 실행 이력 조회 실패", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } } diff --git a/backend-node/src/controllers/designController.ts b/backend-node/src/controllers/designController.ts new file mode 100644 index 00000000..320ce9d9 --- /dev/null +++ b/backend-node/src/controllers/designController.ts @@ -0,0 +1,946 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { query, getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +// 회사코드 필터 조건 생성 헬퍼 +function companyFilter(companyCode: string, paramIndex: number, alias?: string): { condition: string; param: string; nextIndex: number } { + const col = alias ? `${alias}.company_code` : "company_code"; + if (companyCode === "*") { + return { condition: "", param: "", nextIndex: paramIndex }; + } + return { condition: `${col} = $${paramIndex}`, param: companyCode, nextIndex: paramIndex + 1 }; +} + +// ============================================ +// 설계의뢰/설변요청 (DR/ECR) CRUD +// ============================================ + +export async function getDesignRequestList(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const { source_type, status, priority, search } = req.query; + + const conditions: string[] = []; + const params: any[] = []; + let pi = 1; + + if (companyCode !== "*") { + conditions.push(`r.company_code = $${pi}`); + params.push(companyCode); + pi++; + } + if (source_type) { conditions.push(`r.source_type = $${pi}`); params.push(source_type); pi++; } + if (status) { conditions.push(`r.status = $${pi}`); params.push(status); pi++; } + if (priority) { conditions.push(`r.priority = $${pi}`); params.push(priority); pi++; } + if (search) { + conditions.push(`(r.target_name ILIKE $${pi} OR r.request_no ILIKE $${pi} OR r.requester ILIKE $${pi})`); + params.push(`%${search}%`); + pi++; + } + + const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""; + const sql = ` + SELECT r.*, + COALESCE(json_agg(json_build_object('id', h.id, 'step', h.step, 'history_date', h.history_date, 'user_name', h.user_name, 'description', h.description)) FILTER (WHERE h.id IS NOT NULL), '[]') AS history, + COALESCE((SELECT json_agg(i.impact_type) FROM dsn_request_impact i WHERE i.request_id = r.id), '[]') AS impact + FROM dsn_design_request r + LEFT JOIN dsn_request_history h ON h.request_id = r.id + ${where} + GROUP BY r.id + ORDER BY r.created_date DESC + `; + const result = await query(sql, params); + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("설계의뢰 목록 조회 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function getDesignRequestDetail(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const { id } = req.params; + + const conditions = [`r.id = $1`]; + const params: any[] = [id]; + if (companyCode !== "*") { conditions.push(`r.company_code = $2`); params.push(companyCode); } + + const sql = ` + SELECT r.*, + COALESCE((SELECT json_agg(json_build_object('id', h.id, 'step', h.step, 'history_date', h.history_date, 'user_name', h.user_name, 'description', h.description) ORDER BY h.created_date) FROM dsn_request_history h WHERE h.request_id = r.id), '[]') AS history, + COALESCE((SELECT json_agg(i.impact_type) FROM dsn_request_impact i WHERE i.request_id = r.id), '[]') AS impact + FROM dsn_design_request r + WHERE ${conditions.join(" AND ")} + `; + const result = await query(sql, params); + if (!result.length) { res.status(404).json({ success: false, message: "의뢰를 찾을 수 없습니다." }); return; } + res.json({ success: true, data: result[0] }); + } catch (error: any) { + logger.error("설계의뢰 상세 조회 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createDesignRequest(req: AuthenticatedRequest, res: Response): Promise { + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const companyCode = req.user!.companyCode!; + const userId = req.user!.userId; + const { + request_no, source_type, request_date, due_date, priority, status, + target_name, customer, req_dept, requester, designer, order_no, + design_type, spec, change_type, drawing_no, urgency, reason, + content, apply_timing, review_memo, project_id, ecn_no, + impact, history, + } = req.body; + + const sql = ` + INSERT INTO dsn_design_request ( + request_no, source_type, request_date, due_date, priority, status, + target_name, customer, req_dept, requester, designer, order_no, + design_type, spec, change_type, drawing_no, urgency, reason, + content, apply_timing, review_memo, project_id, ecn_no, + writer, company_code + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25) + RETURNING * + `; + const result = await client.query(sql, [ + request_no, source_type || "dr", request_date, due_date, priority || "보통", status || "신규접수", + target_name, customer, req_dept, requester, designer, order_no, + design_type, spec, change_type, drawing_no, urgency || "보통", reason, + content, apply_timing, review_memo, project_id, ecn_no, + userId, companyCode, + ]); + + const requestId = result.rows[0].id; + + if (impact?.length) { + for (const imp of impact) { + await client.query( + `INSERT INTO dsn_request_impact (request_id, impact_type, writer, company_code) VALUES ($1,$2,$3,$4)`, + [requestId, imp, userId, companyCode] + ); + } + } + + if (history?.length) { + for (const h of history) { + await client.query( + `INSERT INTO dsn_request_history (request_id, step, history_date, user_name, description, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7)`, + [requestId, h.step, h.history_date, h.user_name, h.description, userId, companyCode] + ); + } + } + + await client.query("COMMIT"); + res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("설계의뢰 생성 오류", error); + res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +} + +export async function updateDesignRequest(req: AuthenticatedRequest, res: Response): Promise { + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const companyCode = req.user!.companyCode!; + const userId = req.user!.userId; + const { id } = req.params; + const { + request_no, source_type, request_date, due_date, priority, status, approval_step, + target_name, customer, req_dept, requester, designer, order_no, + design_type, spec, change_type, drawing_no, urgency, reason, + content, apply_timing, review_memo, project_id, ecn_no, + impact, history, + } = req.body; + + const conditions = [`id = $1`]; + const params: any[] = [id]; + let pi = 2; + if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; } + + const setClauses = []; + const setParams: any[] = []; + const fields: Record = { + request_no, source_type, request_date, due_date, priority, status, approval_step, + target_name, customer, req_dept, requester, designer, order_no, + design_type, spec, change_type, drawing_no, urgency, reason, + content, apply_timing, review_memo, project_id, ecn_no, + }; + for (const [key, val] of Object.entries(fields)) { + if (val !== undefined) { + setClauses.push(`${key} = $${pi}`); + setParams.push(val); + pi++; + } + } + setClauses.push(`updated_date = now()`); + + const sql = `UPDATE dsn_design_request SET ${setClauses.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`; + const result = await client.query(sql, [...params, ...setParams]); + if (!result.rowCount) { await client.query("ROLLBACK"); res.status(404).json({ success: false, message: "의뢰를 찾을 수 없습니다." }); return; } + + if (impact !== undefined) { + await client.query(`DELETE FROM dsn_request_impact WHERE request_id = $1`, [id]); + for (const imp of impact) { + await client.query( + `INSERT INTO dsn_request_impact (request_id, impact_type, writer, company_code) VALUES ($1,$2,$3,$4)`, + [id, imp, userId, companyCode] + ); + } + } + + if (history !== undefined) { + await client.query(`DELETE FROM dsn_request_history WHERE request_id = $1`, [id]); + for (const h of history) { + await client.query( + `INSERT INTO dsn_request_history (request_id, step, history_date, user_name, description, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7)`, + [id, h.step, h.history_date, h.user_name, h.description, userId, companyCode] + ); + } + } + + await client.query("COMMIT"); + res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("설계의뢰 수정 오류", error); + res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +} + +export async function deleteDesignRequest(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const { id } = req.params; + + const conditions = [`id = $1`]; + const params: any[] = [id]; + if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); } + + const sql = `DELETE FROM dsn_design_request WHERE ${conditions.join(" AND ")} RETURNING id`; + const result = await query(sql, params); + if (!result.length) { res.status(404).json({ success: false, message: "의뢰를 찾을 수 없습니다." }); return; } + res.json({ success: true }); + } catch (error: any) { + logger.error("설계의뢰 삭제 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +// 이력 추가 (단건) +export async function addRequestHistory(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const userId = req.user!.userId; + const { id } = req.params; + const { step, history_date, user_name, description } = req.body; + + const sql = `INSERT INTO dsn_request_history (request_id, step, history_date, user_name, description, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`; + const result = await query(sql, [id, step, history_date, user_name, description, userId, companyCode]); + res.json({ success: true, data: result[0] }); + } catch (error: any) { + logger.error("의뢰 이력 추가 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +// ============================================ +// 설계 프로젝트 CRUD +// ============================================ + +export async function getProjectList(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const { status, search } = req.query; + + const conditions: string[] = []; + const params: any[] = []; + let pi = 1; + + if (companyCode !== "*") { conditions.push(`p.company_code = $${pi}`); params.push(companyCode); pi++; } + if (status) { conditions.push(`p.status = $${pi}`); params.push(status); pi++; } + if (search) { + conditions.push(`(p.name ILIKE $${pi} OR p.project_no ILIKE $${pi} OR p.customer ILIKE $${pi})`); + params.push(`%${search}%`); + pi++; + } + + const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""; + const sql = ` + SELECT p.*, + COALESCE( + (SELECT json_agg(json_build_object( + 'id', t.id, 'name', t.name, 'category', t.category, 'assignee', t.assignee, + 'start_date', t.start_date, 'end_date', t.end_date, 'status', t.status, + 'progress', t.progress, 'priority', t.priority, 'remark', t.remark, 'sort_order', t.sort_order + ) ORDER BY t.sort_order, t.start_date) + FROM dsn_project_task t WHERE t.project_id = p.id), '[]' + ) AS tasks + FROM dsn_project p + ${where} + ORDER BY p.created_date DESC + `; + const result = await query(sql, params); + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("프로젝트 목록 조회 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function getProjectDetail(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const { id } = req.params; + + const conditions = [`p.id = $1`]; + const params: any[] = [id]; + if (companyCode !== "*") { conditions.push(`p.company_code = $2`); params.push(companyCode); } + + const sql = ` + SELECT p.*, + COALESCE( + (SELECT json_agg(json_build_object( + 'id', t.id, 'name', t.name, 'category', t.category, 'assignee', t.assignee, + 'start_date', t.start_date, 'end_date', t.end_date, 'status', t.status, + 'progress', t.progress, 'priority', t.priority, 'remark', t.remark, 'sort_order', t.sort_order + ) ORDER BY t.sort_order, t.start_date) + FROM dsn_project_task t WHERE t.project_id = p.id), '[]' + ) AS tasks + FROM dsn_project p + WHERE ${conditions.join(" AND ")} + `; + const result = await query(sql, params); + if (!result.length) { res.status(404).json({ success: false, message: "프로젝트를 찾을 수 없습니다." }); return; } + res.json({ success: true, data: result[0] }); + } catch (error: any) { + logger.error("프로젝트 상세 조회 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createProject(req: AuthenticatedRequest, res: Response): Promise { + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const companyCode = req.user!.companyCode!; + const userId = req.user!.userId; + const { project_no, name, status: pStatus, pm, customer, start_date, end_date, source_no, description, progress, parent_id, relation_type, tasks } = req.body; + + const result = await client.query( + `INSERT INTO dsn_project (project_no, name, status, pm, customer, start_date, end_date, source_no, description, progress, parent_id, relation_type, writer, company_code) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING *`, + [project_no, name, pStatus || "계획", pm, customer, start_date, end_date, source_no, description, progress || "0", parent_id, relation_type, userId, companyCode] + ); + + const projectId = result.rows[0].id; + if (tasks?.length) { + for (let i = 0; i < tasks.length; i++) { + const t = tasks[i]; + await client.query( + `INSERT INTO dsn_project_task (project_id, name, category, assignee, start_date, end_date, status, progress, priority, remark, sort_order, writer, company_code) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, + [projectId, t.name, t.category, t.assignee, t.start_date, t.end_date, t.status || "대기", t.progress || "0", t.priority || "보통", t.remark, String(i), userId, companyCode] + ); + } + } + + await client.query("COMMIT"); + res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("프로젝트 생성 오류", error); + res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +} + +export async function updateProject(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const { id } = req.params; + const { project_no, name, status: pStatus, pm, customer, start_date, end_date, source_no, description, progress, parent_id, relation_type } = req.body; + + const conditions = [`id = $1`]; + const params: any[] = [id]; + let pi = 2; + if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; } + + const sets: string[] = []; + const fields: Record = { project_no, name, status: pStatus, pm, customer, start_date, end_date, source_no, description, progress, parent_id, relation_type }; + for (const [key, val] of Object.entries(fields)) { + if (val !== undefined) { sets.push(`${key} = $${pi}`); params.push(val); pi++; } + } + sets.push(`updated_date = now()`); + + const result = await query(`UPDATE dsn_project SET ${sets.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`, params); + if (!result.length) { res.status(404).json({ success: false, message: "프로젝트를 찾을 수 없습니다." }); return; } + res.json({ success: true, data: result[0] }); + } catch (error: any) { + logger.error("프로젝트 수정 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function deleteProject(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const { id } = req.params; + const conditions = [`id = $1`]; + const params: any[] = [id]; + if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); } + + const result = await query(`DELETE FROM dsn_project WHERE ${conditions.join(" AND ")} RETURNING id`, params); + if (!result.length) { res.status(404).json({ success: false, message: "프로젝트를 찾을 수 없습니다." }); return; } + res.json({ success: true }); + } catch (error: any) { + logger.error("프로젝트 삭제 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +// ============================================ +// 프로젝트 태스크 CRUD +// ============================================ + +export async function getTasksByProject(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const { projectId } = req.params; + + const conditions = [`t.project_id = $1`]; + const params: any[] = [projectId]; + if (companyCode !== "*") { conditions.push(`t.company_code = $2`); params.push(companyCode); } + + const sql = ` + SELECT t.*, + COALESCE((SELECT json_agg(json_build_object('id', w.id, 'start_dt', w.start_dt, 'end_dt', w.end_dt, 'hours', w.hours, 'description', w.description, 'progress_before', w.progress_before, 'progress_after', w.progress_after, 'author', w.author, 'sub_item_id', w.sub_item_id) ORDER BY w.start_dt) FROM dsn_work_log w WHERE w.task_id = t.id), '[]') AS work_logs, + COALESCE((SELECT json_agg(json_build_object('id', i.id, 'title', i.title, 'status', i.status, 'priority', i.priority, 'description', i.description, 'registered_by', i.registered_by, 'registered_date', i.registered_date, 'resolved_date', i.resolved_date)) FROM dsn_task_issue i WHERE i.task_id = t.id), '[]') AS issues, + COALESCE((SELECT json_agg(json_build_object('id', s.id, 'name', s.name, 'weight', s.weight, 'progress', s.progress, 'status', s.status) ORDER BY s.created_date) FROM dsn_task_sub_item s WHERE s.task_id = t.id), '[]') AS sub_items + FROM dsn_project_task t + WHERE ${conditions.join(" AND ")} + ORDER BY t.sort_order, t.start_date + `; + const result = await query(sql, params); + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("태스크 목록 조회 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createTask(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const userId = req.user!.userId; + const { projectId } = req.params; + const { name, category, assignee, start_date, end_date, status, progress, priority, remark, sort_order } = req.body; + + const result = await query( + `INSERT INTO dsn_project_task (project_id, name, category, assignee, start_date, end_date, status, progress, priority, remark, sort_order, writer, company_code) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING *`, + [projectId, name, category, assignee, start_date, end_date, status || "대기", progress || "0", priority || "보통", remark, sort_order || "0", userId, companyCode] + ); + res.json({ success: true, data: result[0] }); + } catch (error: any) { + logger.error("태스크 생성 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function updateTask(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const { taskId } = req.params; + const { name, category, assignee, start_date, end_date, status, progress, priority, remark, sort_order } = req.body; + + const conditions = [`id = $1`]; + const params: any[] = [taskId]; + let pi = 2; + if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; } + + const sets: string[] = []; + const fields: Record = { name, category, assignee, start_date, end_date, status, progress, priority, remark, sort_order }; + for (const [key, val] of Object.entries(fields)) { + if (val !== undefined) { sets.push(`${key} = $${pi}`); params.push(val); pi++; } + } + sets.push(`updated_date = now()`); + + const result = await query(`UPDATE dsn_project_task SET ${sets.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`, params); + if (!result.length) { res.status(404).json({ success: false, message: "태스크를 찾을 수 없습니다." }); return; } + res.json({ success: true, data: result[0] }); + } catch (error: any) { + logger.error("태스크 수정 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function deleteTask(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const { taskId } = req.params; + const conditions = [`id = $1`]; + const params: any[] = [taskId]; + if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); } + + const result = await query(`DELETE FROM dsn_project_task WHERE ${conditions.join(" AND ")} RETURNING id`, params); + if (!result.length) { res.status(404).json({ success: false, message: "태스크를 찾을 수 없습니다." }); return; } + res.json({ success: true }); + } catch (error: any) { + logger.error("태스크 삭제 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +// ============================================ +// 작업일지 CRUD +// ============================================ + +export async function getWorkLogsByTask(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const { taskId } = req.params; + + const conditions = [`w.task_id = $1`]; + const params: any[] = [taskId]; + if (companyCode !== "*") { conditions.push(`w.company_code = $2`); params.push(companyCode); } + + const sql = ` + SELECT w.*, + COALESCE((SELECT json_agg(json_build_object('id', a.id, 'file_name', a.file_name, 'file_type', a.file_type, 'file_size', a.file_size)) FROM dsn_work_attachment a WHERE a.work_log_id = w.id), '[]') AS attachments, + COALESCE((SELECT json_agg(json_build_object('id', p.id, 'item', p.item, 'qty', p.qty, 'unit', p.unit, 'reason', p.reason, 'status', p.status)) FROM dsn_purchase_req p WHERE p.work_log_id = w.id), '[]') AS purchase_reqs, + COALESCE((SELECT json_agg(json_build_object( + 'id', c.id, 'to_user', c.to_user, 'to_dept', c.to_dept, 'title', c.title, 'description', c.description, 'status', c.status, 'due_date', c.due_date, + 'responses', COALESCE((SELECT json_agg(json_build_object('id', cr.id, 'response_date', cr.response_date, 'user_name', cr.user_name, 'content', cr.content)) FROM dsn_coop_response cr WHERE cr.coop_req_id = c.id), '[]') + )) FROM dsn_coop_req c WHERE c.work_log_id = w.id), '[]') AS coop_reqs + FROM dsn_work_log w + WHERE ${conditions.join(" AND ")} + ORDER BY w.start_dt DESC + `; + const result = await query(sql, params); + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("작업일지 조회 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createWorkLog(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const userId = req.user!.userId; + const { taskId } = req.params; + const { start_dt, end_dt, hours, description, progress_before, progress_after, author, sub_item_id } = req.body; + + const result = await query( + `INSERT INTO dsn_work_log (task_id, start_dt, end_dt, hours, description, progress_before, progress_after, author, sub_item_id, writer, company_code) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING *`, + [taskId, start_dt, end_dt, hours || "0", description, progress_before || "0", progress_after || "0", author, sub_item_id, userId, companyCode] + ); + res.json({ success: true, data: result[0] }); + } catch (error: any) { + logger.error("작업일지 생성 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function deleteWorkLog(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const { workLogId } = req.params; + const conditions = [`id = $1`]; + const params: any[] = [workLogId]; + if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); } + + const result = await query(`DELETE FROM dsn_work_log WHERE ${conditions.join(" AND ")} RETURNING id`, params); + if (!result.length) { res.status(404).json({ success: false, message: "작업일지를 찾을 수 없습니다." }); return; } + res.json({ success: true }); + } catch (error: any) { + logger.error("작업일지 삭제 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +// ============================================ +// 태스크 하위항목 CRUD +// ============================================ + +export async function createSubItem(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const userId = req.user!.userId; + const { taskId } = req.params; + const { name, weight, progress, status } = req.body; + + const result = await query( + `INSERT INTO dsn_task_sub_item (task_id, name, weight, progress, status, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`, + [taskId, name, weight || "0", progress || "0", status || "대기", userId, companyCode] + ); + res.json({ success: true, data: result[0] }); + } catch (error: any) { + logger.error("하위항목 생성 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function updateSubItem(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const { subItemId } = req.params; + const { name, weight, progress, status } = req.body; + + const conditions = [`id = $1`]; + const params: any[] = [subItemId]; + let pi = 2; + if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; } + + const sets: string[] = []; + const fields: Record = { name, weight, progress, status }; + for (const [key, val] of Object.entries(fields)) { + if (val !== undefined) { sets.push(`${key} = $${pi}`); params.push(val); pi++; } + } + sets.push(`updated_date = now()`); + + const result = await query(`UPDATE dsn_task_sub_item SET ${sets.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`, params); + if (!result.length) { res.status(404).json({ success: false, message: "하위항목을 찾을 수 없습니다." }); return; } + res.json({ success: true, data: result[0] }); + } catch (error: any) { + logger.error("하위항목 수정 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function deleteSubItem(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const { subItemId } = req.params; + const conditions = [`id = $1`]; + const params: any[] = [subItemId]; + if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); } + + const result = await query(`DELETE FROM dsn_task_sub_item WHERE ${conditions.join(" AND ")} RETURNING id`, params); + if (!result.length) { res.status(404).json({ success: false, message: "하위항목을 찾을 수 없습니다." }); return; } + res.json({ success: true }); + } catch (error: any) { + logger.error("하위항목 삭제 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +// ============================================ +// 태스크 이슈 CRUD +// ============================================ + +export async function createIssue(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const userId = req.user!.userId; + const { taskId } = req.params; + const { title, status, priority, description, registered_by, registered_date } = req.body; + + const result = await query( + `INSERT INTO dsn_task_issue (task_id, title, status, priority, description, registered_by, registered_date, writer, company_code) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *`, + [taskId, title, status || "등록", priority || "보통", description, registered_by, registered_date, userId, companyCode] + ); + res.json({ success: true, data: result[0] }); + } catch (error: any) { + logger.error("이슈 생성 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function updateIssue(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const { issueId } = req.params; + const { title, status, priority, description, resolved_date } = req.body; + + const conditions = [`id = $1`]; + const params: any[] = [issueId]; + let pi = 2; + if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; } + + const sets: string[] = []; + const fields: Record = { title, status, priority, description, resolved_date }; + for (const [key, val] of Object.entries(fields)) { + if (val !== undefined) { sets.push(`${key} = $${pi}`); params.push(val); pi++; } + } + sets.push(`updated_date = now()`); + + const result = await query(`UPDATE dsn_task_issue SET ${sets.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`, params); + if (!result.length) { res.status(404).json({ success: false, message: "이슈를 찾을 수 없습니다." }); return; } + res.json({ success: true, data: result[0] }); + } catch (error: any) { + logger.error("이슈 수정 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +// ============================================ +// ECN (설변통보) CRUD +// ============================================ + +export async function getEcnList(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const { status, search } = req.query; + + const conditions: string[] = []; + const params: any[] = []; + let pi = 1; + + if (companyCode !== "*") { conditions.push(`e.company_code = $${pi}`); params.push(companyCode); pi++; } + if (status) { conditions.push(`e.status = $${pi}`); params.push(status); pi++; } + if (search) { + conditions.push(`(e.ecn_no ILIKE $${pi} OR e.target ILIKE $${pi})`); + params.push(`%${search}%`); + pi++; + } + + const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""; + const sql = ` + SELECT e.*, + COALESCE((SELECT json_agg(json_build_object('id', h.id, 'status', h.status, 'history_date', h.history_date, 'user_name', h.user_name, 'description', h.description) ORDER BY h.created_date) FROM dsn_ecn_history h WHERE h.ecn_id = e.id), '[]') AS history, + COALESCE((SELECT json_agg(nd.dept_name) FROM dsn_ecn_notify_dept nd WHERE nd.ecn_id = e.id), '[]') AS notify_depts + FROM dsn_ecn e + ${where} + ORDER BY e.created_date DESC + `; + const result = await query(sql, params); + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("ECN 목록 조회 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createEcn(req: AuthenticatedRequest, res: Response): Promise { + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const companyCode = req.user!.companyCode!; + const userId = req.user!.userId; + const { ecn_no, ecr_id, ecn_date, apply_date, status, target, drawing_before, drawing_after, designer, before_content, after_content, reason, remark, notify_depts, history } = req.body; + + const result = await client.query( + `INSERT INTO dsn_ecn (ecn_no, ecr_id, ecn_date, apply_date, status, target, drawing_before, drawing_after, designer, before_content, after_content, reason, remark, writer, company_code) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING *`, + [ecn_no, ecr_id, ecn_date, apply_date, status || "ECN발행", target, drawing_before, drawing_after, designer, before_content, after_content, reason, remark, userId, companyCode] + ); + + const ecnId = result.rows[0].id; + + if (notify_depts?.length) { + for (const dept of notify_depts) { + await client.query(`INSERT INTO dsn_ecn_notify_dept (ecn_id, dept_name, writer, company_code) VALUES ($1,$2,$3,$4)`, [ecnId, dept, userId, companyCode]); + } + } + + if (history?.length) { + for (const h of history) { + await client.query( + `INSERT INTO dsn_ecn_history (ecn_id, status, history_date, user_name, description, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7)`, + [ecnId, h.status, h.history_date, h.user_name, h.description, userId, companyCode] + ); + } + } + + await client.query("COMMIT"); + res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("ECN 생성 오류", error); + res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +} + +export async function updateEcn(req: AuthenticatedRequest, res: Response): Promise { + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const companyCode = req.user!.companyCode!; + const userId = req.user!.userId; + const { id } = req.params; + const { ecn_no, ecn_date, apply_date, status, target, drawing_before, drawing_after, designer, before_content, after_content, reason, remark, notify_depts, history } = req.body; + + const conditions = [`id = $1`]; + const params: any[] = [id]; + let pi = 2; + if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; } + + const sets: string[] = []; + const fields: Record = { ecn_no, ecn_date, apply_date, status, target, drawing_before, drawing_after, designer, before_content, after_content, reason, remark }; + for (const [key, val] of Object.entries(fields)) { + if (val !== undefined) { sets.push(`${key} = $${pi}`); params.push(val); pi++; } + } + sets.push(`updated_date = now()`); + + const result = await client.query(`UPDATE dsn_ecn SET ${sets.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`, params); + if (!result.rowCount) { await client.query("ROLLBACK"); res.status(404).json({ success: false, message: "ECN을 찾을 수 없습니다." }); return; } + + if (notify_depts !== undefined) { + await client.query(`DELETE FROM dsn_ecn_notify_dept WHERE ecn_id = $1`, [id]); + for (const dept of notify_depts) { + await client.query(`INSERT INTO dsn_ecn_notify_dept (ecn_id, dept_name, writer, company_code) VALUES ($1,$2,$3,$4)`, [id, dept, userId, companyCode]); + } + } + if (history !== undefined) { + await client.query(`DELETE FROM dsn_ecn_history WHERE ecn_id = $1`, [id]); + for (const h of history) { + await client.query( + `INSERT INTO dsn_ecn_history (ecn_id, status, history_date, user_name, description, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7)`, + [id, h.status, h.history_date, h.user_name, h.description, userId, companyCode] + ); + } + } + + await client.query("COMMIT"); + res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("ECN 수정 오류", error); + res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +} + +export async function deleteEcn(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const { id } = req.params; + const conditions = [`id = $1`]; + const params: any[] = [id]; + if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); } + + const result = await query(`DELETE FROM dsn_ecn WHERE ${conditions.join(" AND ")} RETURNING id`, params); + if (!result.length) { res.status(404).json({ success: false, message: "ECN을 찾을 수 없습니다." }); return; } + res.json({ success: true }); + } catch (error: any) { + logger.error("ECN 삭제 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +// ============================================ +// 나의 업무 (My Work) - 로그인 사용자 기준 +// ============================================ + +export async function getMyWork(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const userName = req.user!.userName; + const { status, project_id } = req.query; + + const conditions = [`t.assignee = $1`]; + const params: any[] = [userName]; + let pi = 2; + + if (companyCode !== "*") { conditions.push(`t.company_code = $${pi}`); params.push(companyCode); pi++; } + if (status) { conditions.push(`t.status = $${pi}`); params.push(status); pi++; } + if (project_id) { conditions.push(`t.project_id = $${pi}`); params.push(project_id); pi++; } + + const sql = ` + SELECT t.*, + p.project_no, p.name AS project_name, p.customer AS project_customer, p.status AS project_status, + COALESCE((SELECT json_agg(json_build_object('id', s.id, 'name', s.name, 'weight', s.weight, 'progress', s.progress, 'status', s.status) ORDER BY s.created_date) FROM dsn_task_sub_item s WHERE s.task_id = t.id), '[]') AS sub_items, + COALESCE((SELECT json_agg(json_build_object( + 'id', w.id, 'start_dt', w.start_dt, 'end_dt', w.end_dt, 'hours', w.hours, 'description', w.description, 'sub_item_id', w.sub_item_id, + 'attachments', COALESCE((SELECT json_agg(json_build_object('id', a.id, 'file_name', a.file_name, 'file_type', a.file_type, 'file_size', a.file_size)) FROM dsn_work_attachment a WHERE a.work_log_id = w.id), '[]'), + 'purchase_reqs', COALESCE((SELECT json_agg(json_build_object('id', pr.id, 'item', pr.item, 'qty', pr.qty, 'unit', pr.unit, 'reason', pr.reason, 'status', pr.status)) FROM dsn_purchase_req pr WHERE pr.work_log_id = w.id), '[]'), + 'coop_reqs', COALESCE((SELECT json_agg(json_build_object( + 'id', c.id, 'to_user', c.to_user, 'to_dept', c.to_dept, 'title', c.title, 'description', c.description, 'status', c.status, 'due_date', c.due_date, + 'responses', COALESCE((SELECT json_agg(json_build_object('id', cr.id, 'response_date', cr.response_date, 'user_name', cr.user_name, 'content', cr.content)) FROM dsn_coop_response cr WHERE cr.coop_req_id = c.id), '[]') + )) FROM dsn_coop_req c WHERE c.work_log_id = w.id), '[]') + ) ORDER BY w.start_dt DESC) FROM dsn_work_log w WHERE w.task_id = t.id), '[]') AS work_logs + FROM dsn_project_task t + JOIN dsn_project p ON p.id = t.project_id + WHERE ${conditions.join(" AND ")} + ORDER BY + CASE t.status WHEN '진행중' THEN 1 WHEN '대기' THEN 2 WHEN '검토중' THEN 3 ELSE 4 END, + t.end_date ASC + `; + const result = await query(sql, params); + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("나의 업무 조회 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +// ============================================ +// 구매요청 / 협업요청 CRUD (my-work에서 사용) +// ============================================ + +export async function createPurchaseReq(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const userId = req.user!.userId; + const { workLogId } = req.params; + const { item, qty, unit, reason, status } = req.body; + + const result = await query( + `INSERT INTO dsn_purchase_req (work_log_id, item, qty, unit, reason, status, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`, + [workLogId, item, qty, unit, reason, status || "요청", userId, companyCode] + ); + res.json({ success: true, data: result[0] }); + } catch (error: any) { + logger.error("구매요청 생성 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createCoopReq(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const userId = req.user!.userId; + const { workLogId } = req.params; + const { to_user, to_dept, title, description, due_date } = req.body; + + const result = await query( + `INSERT INTO dsn_coop_req (work_log_id, to_user, to_dept, title, description, status, due_date, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *`, + [workLogId, to_user, to_dept, title, description, "요청", due_date, userId, companyCode] + ); + res.json({ success: true, data: result[0] }); + } catch (error: any) { + logger.error("협업요청 생성 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function addCoopResponse(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode!; + const userId = req.user!.userId; + const { coopReqId } = req.params; + const { response_date, user_name, content } = req.body; + + const result = await query( + `INSERT INTO dsn_coop_response (coop_req_id, response_date, user_name, content, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`, + [coopReqId, response_date, user_name, content, userId, companyCode] + ); + res.json({ success: true, data: result[0] }); + } catch (error: any) { + logger.error("협업응답 추가 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/controllers/materialStatusController.ts b/backend-node/src/controllers/materialStatusController.ts new file mode 100644 index 00000000..4b97fdb1 --- /dev/null +++ b/backend-node/src/controllers/materialStatusController.ts @@ -0,0 +1,352 @@ +/** + * 자재현황 컨트롤러 + * - 생산계획(작업지시) 조회 + * - 선택된 작업지시의 BOM 기반 자재소요량 + 재고 현황 조회 + * - 창고 목록 조회 + */ + +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { pool } from "../database/db"; +import { logger } from "../utils/logger"; + +// ─── 생산계획(작업지시) 조회 ─── + +export async function getWorkOrders( + req: AuthenticatedRequest, + res: Response +) { + try { + const companyCode = req.user!.companyCode; + const { dateFrom, dateTo, itemCode, itemName } = req.query; + + const conditions: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + if (companyCode === "*") { + logger.info("최고 관리자 전체 작업지시 조회"); + } else { + conditions.push(`p.company_code = $${paramIndex}`); + params.push(companyCode); + paramIndex++; + } + + if (dateFrom) { + conditions.push(`p.plan_date >= $${paramIndex}::date`); + params.push(dateFrom); + paramIndex++; + } + + if (dateTo) { + conditions.push(`p.plan_date <= $${paramIndex}::date`); + params.push(dateTo); + paramIndex++; + } + + if (itemCode) { + conditions.push(`p.item_code ILIKE $${paramIndex}`); + params.push(`%${itemCode}%`); + paramIndex++; + } + + if (itemName) { + conditions.push(`p.item_name ILIKE $${paramIndex}`); + params.push(`%${itemName}%`); + paramIndex++; + } + + const whereClause = + conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const query = ` + SELECT + p.id, + p.plan_no, + p.item_code, + p.item_name, + p.plan_qty, + p.completed_qty, + p.plan_date, + p.start_date, + p.end_date, + p.status, + p.work_order_no, + p.company_code + FROM production_plan_mng p + ${whereClause} + ORDER BY p.plan_date DESC, p.created_date DESC + `; + + const result = await pool.query(query, params); + + logger.info("작업지시 조회 완료", { + companyCode, + rowCount: result.rowCount, + }); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("작업지시 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 선택된 작업지시의 자재소요 + 재고 현황 조회 ─── + +export async function getMaterialStatus( + req: AuthenticatedRequest, + res: Response +) { + try { + const companyCode = req.user!.companyCode; + const { planIds, warehouseCode } = req.body; + + if (!planIds || !Array.isArray(planIds) || planIds.length === 0) { + return res + .status(400) + .json({ success: false, message: "작업지시를 선택해주세요." }); + } + + // 1) 선택된 작업지시의 품목코드 + 수량 조회 + const planPlaceholders = planIds + .map((_, i) => `$${i + 1}`) + .join(","); + let paramIndex = planIds.length + 1; + + const companyCondition = + companyCode === "*" ? "" : `AND p.company_code = $${paramIndex}`; + const planParams: any[] = [...planIds]; + if (companyCode !== "*") { + planParams.push(companyCode); + paramIndex++; + } + + const planQuery = ` + SELECT p.item_code, p.item_name, p.plan_qty + FROM production_plan_mng p + WHERE p.id IN (${planPlaceholders}) + ${companyCondition} + `; + + const planResult = await pool.query(planQuery, planParams); + + if (planResult.rowCount === 0) { + return res.json({ success: true, data: [] }); + } + + // 2) 해당 품목들의 BOM에서 필요 자재 목록 조회 + const itemCodes = planResult.rows.map((r: any) => r.item_code); + const planQtyMap: Record = {}; + for (const row of planResult.rows) { + const code = row.item_code; + planQtyMap[code] = (planQtyMap[code] || 0) + Number(row.plan_qty || 0); + } + + const itemPlaceholders = itemCodes.map((_: any, i: number) => `$${i + 1}`).join(","); + + // BOM 조인: bom -> bom_detail -> item_info (자재 정보) + const bomCompanyCondition = + companyCode === "*" ? "" : `AND b.company_code = $${itemCodes.length + 1}`; + const bomParams: any[] = [...itemCodes]; + if (companyCode !== "*") { + bomParams.push(companyCode); + } + + const bomQuery = ` + SELECT + b.item_code AS parent_item_code, + b.base_qty AS bom_base_qty, + bd.child_item_id, + bd.quantity AS bom_qty, + bd.unit AS bom_unit, + bd.loss_rate, + ii.item_name AS material_name, + ii.item_number AS material_code, + ii.unit AS material_unit + FROM bom b + JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code + LEFT JOIN item_info ii ON bd.child_item_id = ii.id AND b.company_code = ii.company_code + WHERE b.item_code IN (${itemPlaceholders}) + ${bomCompanyCondition} + ORDER BY b.item_code, bd.seq_no + `; + + const bomResult = await pool.query(bomQuery, bomParams); + + // 3) 자재별 필요수량 계산 + interface MaterialNeed { + childItemId: string; + materialCode: string; + materialName: string; + unit: string; + requiredQty: number; + } + + const materialMap: Record = {}; + + for (const bomRow of bomResult.rows) { + const parentQty = planQtyMap[bomRow.parent_item_code] || 0; + const baseQty = Number(bomRow.bom_base_qty) || 1; + const bomQty = Number(bomRow.bom_qty) || 0; + const lossRate = Number(bomRow.loss_rate) || 0; + + // 필요수량 = (생산수량 / BOM기준수량) * BOM자재수량 * (1 + 로스율/100) + const requiredQty = + (parentQty / baseQty) * bomQty * (1 + lossRate / 100); + + const key = bomRow.child_item_id; + if (materialMap[key]) { + materialMap[key].requiredQty += requiredQty; + } else { + materialMap[key] = { + childItemId: bomRow.child_item_id, + materialCode: + bomRow.material_code || bomRow.child_item_id, + materialName: bomRow.material_name || "알 수 없음", + unit: bomRow.bom_unit || bomRow.material_unit || "EA", + requiredQty, + }; + } + } + + const materialIds = Object.keys(materialMap); + + if (materialIds.length === 0) { + return res.json({ success: true, data: [] }); + } + + // 4) 재고 조회 (창고/위치별) + const stockPlaceholders = materialIds + .map((_, i) => `$${i + 1}`) + .join(","); + const stockParams: any[] = [...materialIds]; + let stockParamIdx = materialIds.length + 1; + + const stockConditions: string[] = [ + `s.item_code IN (${stockPlaceholders})`, + ]; + + if (companyCode !== "*") { + stockConditions.push(`s.company_code = $${stockParamIdx}`); + stockParams.push(companyCode); + stockParamIdx++; + } + + if (warehouseCode) { + stockConditions.push(`s.warehouse_code = $${stockParamIdx}`); + stockParams.push(warehouseCode); + stockParamIdx++; + } + + const stockQuery = ` + SELECT + s.item_code, + s.warehouse_code, + s.location_code, + COALESCE(CAST(s.current_qty AS NUMERIC), 0) AS current_qty + FROM inventory_stock s + WHERE ${stockConditions.join(" AND ")} + AND COALESCE(CAST(s.current_qty AS NUMERIC), 0) > 0 + ORDER BY s.item_code, s.warehouse_code, s.location_code + `; + + const stockResult = await pool.query(stockQuery, stockParams); + + // 5) 결과 조합 + // item_code 기준 재고 맵핑 (inventory_stock.item_code는 item_info.item_number 또는 item_info.id일 수 있음) + const stockByItem: Record< + string, + { location: string; warehouse: string; qty: number }[] + > = {}; + + for (const stockRow of stockResult.rows) { + const code = stockRow.item_code; + if (!stockByItem[code]) { + stockByItem[code] = []; + } + stockByItem[code].push({ + location: stockRow.location_code || "", + warehouse: stockRow.warehouse_code || "", + qty: Number(stockRow.current_qty), + }); + } + + const resultData = materialIds.map((id) => { + const material = materialMap[id]; + // inventory_stock의 item_code가 item_number 또는 child_item_id일 수 있음 + const locations = + stockByItem[material.materialCode] || + stockByItem[id] || + []; + + const totalCurrentQty = locations.reduce( + (sum, loc) => sum + loc.qty, + 0 + ); + + return { + code: material.materialCode, + name: material.materialName, + required: Math.round(material.requiredQty * 100) / 100, + current: totalCurrentQty, + unit: material.unit, + locations, + }; + }); + + logger.info("자재현황 조회 완료", { + companyCode, + planCount: planIds.length, + materialCount: resultData.length, + }); + + return res.json({ success: true, data: resultData }); + } catch (error: any) { + logger.error("자재현황 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 창고 목록 조회 ─── + +export async function getWarehouses( + req: AuthenticatedRequest, + res: Response +) { + try { + const companyCode = req.user!.companyCode; + + let query: string; + let params: any[]; + + if (companyCode === "*") { + query = ` + SELECT DISTINCT warehouse_code, warehouse_name, warehouse_type + FROM warehouse_info + ORDER BY warehouse_code + `; + params = []; + } else { + query = ` + SELECT DISTINCT warehouse_code, warehouse_name, warehouse_type + FROM warehouse_info + WHERE company_code = $1 + ORDER BY warehouse_code + `; + params = [companyCode]; + } + + const result = await pool.query(query, params); + + logger.info("창고 목록 조회 완료", { + companyCode, + rowCount: result.rowCount, + }); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("창고 목록 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/controllers/processInfoController.ts b/backend-node/src/controllers/processInfoController.ts new file mode 100644 index 00000000..a8d99fb1 --- /dev/null +++ b/backend-node/src/controllers/processInfoController.ts @@ -0,0 +1,463 @@ +/** + * 공정정보관리 컨트롤러 + * - 공정 마스터 CRUD + * - 공정별 설비 관리 + * - 품목별 라우팅 관리 + */ + +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { pool } from "../database/db"; +import { logger } from "../utils/logger"; + +// ═══════════════════════════════════════════ +// 공정 마스터 CRUD +// ═══════════════════════════════════════════ + +export async function getProcessList(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { processCode, processName, processType, useYn } = req.query; + + const conditions: string[] = []; + const params: any[] = []; + let idx = 1; + + if (companyCode !== "*") { + conditions.push(`company_code = $${idx++}`); + params.push(companyCode); + } + if (processCode) { + conditions.push(`process_code ILIKE $${idx++}`); + params.push(`%${processCode}%`); + } + if (processName) { + conditions.push(`process_name ILIKE $${idx++}`); + params.push(`%${processName}%`); + } + if (processType) { + conditions.push(`process_type = $${idx++}`); + params.push(processType); + } + if (useYn) { + conditions.push(`use_yn = $${idx++}`); + params.push(useYn); + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const result = await pool.query( + `SELECT * FROM process_mng ${where} ORDER BY process_code`, + params + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("공정 목록 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createProcess(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const writer = req.user!.userId; + const { process_name, process_type, standard_time, worker_count, use_yn } = req.body; + + // 공정코드 자동 채번: PROC-001, PROC-002, ... + const seqRes = await pool.query( + `SELECT process_code FROM process_mng WHERE company_code = $1 AND process_code LIKE 'PROC-%' ORDER BY process_code DESC LIMIT 1`, + [companyCode] + ); + let nextNum = 1; + if (seqRes.rowCount! > 0) { + const lastCode = seqRes.rows[0].process_code; + const numPart = parseInt(lastCode.replace("PROC-", ""), 10); + if (!isNaN(numPart)) nextNum = numPart + 1; + } + const processCode = `PROC-${String(nextNum).padStart(3, "0")}`; + + const result = await pool.query( + `INSERT INTO process_mng (id, company_code, process_code, process_name, process_type, standard_time, worker_count, use_yn, writer) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, + [companyCode, processCode, process_name, process_type, standard_time || "0", worker_count || "0", use_yn || "Y", writer] + ); + + return res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + logger.error("공정 등록 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function updateProcess(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + const { process_name, process_type, standard_time, worker_count, use_yn } = req.body; + + const result = await pool.query( + `UPDATE process_mng SET process_name=$1, process_type=$2, standard_time=$3, worker_count=$4, use_yn=$5, updated_date=NOW() + WHERE id=$6 AND company_code=$7 RETURNING *`, + [process_name, process_type, standard_time, worker_count, use_yn, id, companyCode] + ); + + if (result.rowCount === 0) { + return res.status(404).json({ success: false, message: "공정을 찾을 수 없습니다." }); + } + return res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + logger.error("공정 수정 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function deleteProcesses(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { ids } = req.body; + + if (!ids || !Array.isArray(ids) || ids.length === 0) { + return res.status(400).json({ success: false, message: "삭제할 공정을 선택해주세요." }); + } + + const placeholders = ids.map((_: any, i: number) => `$${i + 1}`).join(","); + // 설비 매핑도 삭제 + await pool.query( + `DELETE FROM process_equipment WHERE process_code IN (SELECT process_code FROM process_mng WHERE id IN (${placeholders}) AND company_code = $${ids.length + 1})`, + [...ids, companyCode] + ); + const result = await pool.query( + `DELETE FROM process_mng WHERE id IN (${placeholders}) AND company_code = $${ids.length + 1} RETURNING id`, + [...ids, companyCode] + ); + + return res.json({ success: true, deletedCount: result.rowCount }); + } catch (error: any) { + logger.error("공정 삭제 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ═══════════════════════════════════════════ +// 공정별 설비 관리 +// ═══════════════════════════════════════════ + +export async function getProcessEquipments(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { processCode } = req.params; + + const result = await pool.query( + `SELECT pe.*, ei.equipment_name + FROM process_equipment pe + LEFT JOIN equipment_info ei ON pe.equipment_code = ei.equipment_code AND pe.company_code = ei.company_code + WHERE pe.process_code = $1 AND pe.company_code = $2 + ORDER BY pe.equipment_code`, + [processCode, companyCode] + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("공정 설비 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function addProcessEquipment(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const writer = req.user!.userId; + const { process_code, equipment_code } = req.body; + + const dupCheck = await pool.query( + `SELECT id FROM process_equipment WHERE process_code=$1 AND equipment_code=$2 AND company_code=$3`, + [process_code, equipment_code, companyCode] + ); + if (dupCheck.rowCount! > 0) { + return res.status(400).json({ success: false, message: "이미 등록된 설비입니다." }); + } + + const result = await pool.query( + `INSERT INTO process_equipment (id, company_code, process_code, equipment_code, writer) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4) RETURNING *`, + [companyCode, process_code, equipment_code, writer] + ); + + return res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + logger.error("공정 설비 등록 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function removeProcessEquipment(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + await pool.query( + `DELETE FROM process_equipment WHERE id=$1 AND company_code=$2`, + [id, companyCode] + ); + + return res.json({ success: true }); + } catch (error: any) { + logger.error("공정 설비 제거 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function getEquipmentList(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const condition = companyCode === "*" ? "" : `WHERE company_code = $1`; + const params = companyCode === "*" ? [] : [companyCode]; + + const result = await pool.query( + `SELECT id, equipment_code, equipment_name FROM equipment_info ${condition} ORDER BY equipment_code`, + params + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("설비 목록 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ═══════════════════════════════════════════ +// 품목별 라우팅 관리 +// ═══════════════════════════════════════════ + +export async function getItemsForRouting(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { search } = req.query; + + const conditions: string[] = ["i.company_code = rv.company_code"]; + const params: any[] = []; + let idx = 1; + + if (companyCode !== "*") { + conditions.push(`i.company_code = $${idx++}`); + params.push(companyCode); + } + if (search) { + conditions.push(`(i.item_number ILIKE $${idx} OR i.item_name ILIKE $${idx})`); + params.push(`%${search}%`); + idx++; + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const result = await pool.query( + `SELECT DISTINCT i.id, i.item_number, i.item_name, i.size, i.unit, i.type + FROM item_info i + INNER JOIN item_routing_version rv ON rv.item_code = i.item_number AND rv.company_code = i.company_code + ${where} + ORDER BY i.item_number LIMIT 200`, + params + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("라우팅 등록 품목 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function searchAllItems(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { search } = req.query; + + const conditions: string[] = []; + const params: any[] = []; + let idx = 1; + + if (companyCode !== "*") { + conditions.push(`company_code = $${idx++}`); + params.push(companyCode); + } + if (search) { + conditions.push(`(item_number ILIKE $${idx} OR item_name ILIKE $${idx})`); + params.push(`%${search}%`); + idx++; + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const result = await pool.query( + `SELECT id, item_number, item_name, size, unit, type FROM item_info ${where} ORDER BY item_number LIMIT 200`, + params + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("전체 품목 검색 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function getRoutingVersions(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { itemCode } = req.params; + + const result = await pool.query( + `SELECT * FROM item_routing_version WHERE item_code=$1 AND company_code=$2 ORDER BY created_date`, + [itemCode, companyCode] + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("라우팅 버전 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createRoutingVersion(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const writer = req.user!.userId; + const { item_code, version_name, description, is_default } = req.body; + + if (is_default) { + await pool.query( + `UPDATE item_routing_version SET is_default=false WHERE item_code=$1 AND company_code=$2`, + [item_code, companyCode] + ); + } + + const result = await pool.query( + `INSERT INTO item_routing_version (id, company_code, item_code, version_name, description, is_default, writer) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6) RETURNING *`, + [companyCode, item_code, version_name, description || "", is_default || false, writer] + ); + + return res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + logger.error("라우팅 버전 생성 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function deleteRoutingVersion(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + await pool.query( + `DELETE FROM item_routing_detail WHERE routing_version_id=$1 AND company_code=$2`, + [id, companyCode] + ); + await pool.query( + `DELETE FROM item_routing_version WHERE id=$1 AND company_code=$2`, + [id, companyCode] + ); + + return res.json({ success: true }); + } catch (error: any) { + logger.error("라우팅 버전 삭제 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function getRoutingDetails(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { versionId } = req.params; + + const result = await pool.query( + `SELECT rd.*, pm.process_name + FROM item_routing_detail rd + LEFT JOIN process_mng pm ON rd.process_code = pm.process_code AND rd.company_code = pm.company_code + WHERE rd.routing_version_id=$1 AND rd.company_code=$2 + ORDER BY CAST(rd.seq_no AS INTEGER)`, + [versionId, companyCode] + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("라우팅 상세 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function saveRoutingDetails(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const writer = req.user!.userId; + const { versionId } = req.params; + const { details } = req.body; + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // 기존 상세 삭제 후 재입력 + await client.query( + `DELETE FROM item_routing_detail WHERE routing_version_id=$1 AND company_code=$2`, + [versionId, companyCode] + ); + + for (const d of details) { + await client.query( + `INSERT INTO item_routing_detail (id, company_code, routing_version_id, seq_no, process_code, is_required, is_fixed_order, work_type, standard_time, outsource_supplier, writer) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + [companyCode, versionId, d.seq_no, d.process_code, d.is_required || "Y", d.is_fixed_order || "Y", d.work_type || "내부", d.standard_time || "0", d.outsource_supplier || "", writer] + ); + } + + await client.query("COMMIT"); + return res.json({ success: true }); + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } + } catch (error: any) { + logger.error("라우팅 상세 저장 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ═══════════════════════════════════════════ +// BOM 구성 자재 조회 (품목코드 기반) +// ═══════════════════════════════════════════ + +export async function getBomMaterials(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { itemCode } = req.params; + + if (!itemCode) { + return res.status(400).json({ success: false, message: "itemCode는 필수입니다" }); + } + + const query = ` + SELECT + bd.id, + bd.child_item_id, + bd.quantity, + bd.unit as detail_unit, + bd.process_type, + i.item_name as child_item_name, + i.item_number as child_item_code, + i.type as child_item_type, + i.unit as item_unit + FROM bom b + JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code + LEFT JOIN item_info i ON bd.child_item_id = i.id AND bd.company_code = i.company_code + WHERE b.item_code = $1 AND b.company_code = $2 + ORDER BY bd.seq_no ASC, bd.created_date ASC + `; + + const result = await pool.query(query, [itemCode, companyCode]); + + logger.info("BOM 자재 조회 성공", { companyCode, itemCode, count: result.rowCount }); + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("BOM 자재 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/controllers/receivingController.ts b/backend-node/src/controllers/receivingController.ts new file mode 100644 index 00000000..132fcb3a --- /dev/null +++ b/backend-node/src/controllers/receivingController.ts @@ -0,0 +1,487 @@ +/** + * 입고관리 컨트롤러 + * + * 입고유형별 소스 테이블: + * - 구매입고 → purchase_order_mng (발주) + * - 반품입고 → shipment_instruction + shipment_instruction_detail (출하) + * - 기타입고 → item_info (품목) + */ + +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +// 입고 목록 조회 +export async function getList(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { + inbound_type, + inbound_status, + search_keyword, + date_from, + date_to, + } = req.query; + + const conditions: string[] = []; + const params: any[] = []; + let paramIdx = 1; + + if (companyCode === "*") { + // 최고 관리자: 전체 조회 + } else { + conditions.push(`im.company_code = $${paramIdx}`); + params.push(companyCode); + paramIdx++; + } + + if (inbound_type && inbound_type !== "all") { + conditions.push(`im.inbound_type = $${paramIdx}`); + params.push(inbound_type); + paramIdx++; + } + + if (inbound_status && inbound_status !== "all") { + conditions.push(`im.inbound_status = $${paramIdx}`); + params.push(inbound_status); + paramIdx++; + } + + if (search_keyword) { + conditions.push( + `(im.inbound_number ILIKE $${paramIdx} OR im.item_name ILIKE $${paramIdx} OR im.item_number ILIKE $${paramIdx} OR im.supplier_name ILIKE $${paramIdx} OR im.reference_number ILIKE $${paramIdx})` + ); + params.push(`%${search_keyword}%`); + paramIdx++; + } + + if (date_from) { + conditions.push(`im.inbound_date >= $${paramIdx}::date`); + params.push(date_from); + paramIdx++; + } + if (date_to) { + conditions.push(`im.inbound_date <= $${paramIdx}::date`); + params.push(date_to); + paramIdx++; + } + + const whereClause = + conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const query = ` + SELECT + im.*, + wh.warehouse_name + FROM inbound_mng im + LEFT JOIN warehouse_info wh + ON im.warehouse_code = wh.warehouse_code + AND im.company_code = wh.company_code + ${whereClause} + ORDER BY im.created_date DESC + `; + + const pool = getPool(); + const result = await pool.query(query, params); + + logger.info("입고 목록 조회", { + companyCode, + rowCount: result.rowCount, + }); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("입고 목록 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// 입고 등록 (다건) +export async function create(req: AuthenticatedRequest, res: Response) { + const pool = getPool(); + const client = await pool.connect(); + + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { items, inbound_number, inbound_date, warehouse_code, location_code, inspector, manager, memo } = req.body; + + if (!items || !Array.isArray(items) || items.length === 0) { + return res.status(400).json({ success: false, message: "입고 품목이 없습니다." }); + } + + await client.query("BEGIN"); + + const insertedRows: any[] = []; + + for (const item of items) { + const result = await client.query( + `INSERT INTO inbound_mng ( + company_code, inbound_number, inbound_type, inbound_date, + reference_number, supplier_code, supplier_name, + item_number, item_name, spec, material, unit, + inbound_qty, unit_price, total_amount, + lot_number, warehouse_code, location_code, + inbound_status, inspection_status, + inspector, manager, memo, + source_table, source_id, + created_date, created_by, writer, status + ) VALUES ( + $1, $2, $3, $4::date, + $5, $6, $7, + $8, $9, $10, $11, $12, + $13, $14, $15, + $16, $17, $18, + $19, $20, + $21, $22, $23, + $24, $25, + NOW(), $26, $26, '입고' + ) RETURNING *`, + [ + companyCode, + inbound_number || item.inbound_number, + item.inbound_type, + inbound_date || item.inbound_date, + item.reference_number || null, + item.supplier_code || null, + item.supplier_name || null, + item.item_number || null, + item.item_name || null, + item.spec || null, + item.material || null, + item.unit || "EA", + item.inbound_qty || 0, + item.unit_price || 0, + item.total_amount || 0, + item.lot_number || null, + warehouse_code || item.warehouse_code || null, + location_code || item.location_code || null, + item.inbound_status || "대기", + item.inspection_status || "대기", + inspector || item.inspector || null, + manager || item.manager || null, + memo || item.memo || null, + item.source_table || null, + item.source_id || null, + userId, + ] + ); + + insertedRows.push(result.rows[0]); + + // 구매입고인 경우 발주의 received_qty 업데이트 + if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_order_mng") { + await client.query( + `UPDATE purchase_order_mng + SET received_qty = CAST( + COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1 AS text + ), + remain_qty = CAST( + GREATEST(COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) + - COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) - $1, 0) AS text + ), + status = CASE + WHEN COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1 + >= COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) + THEN '입고완료' + ELSE '부분입고' + END, + updated_date = NOW() + WHERE id = $2 AND company_code = $3`, + [item.inbound_qty || 0, item.source_id, companyCode] + ); + } + } + + await client.query("COMMIT"); + + logger.info("입고 등록 완료", { + companyCode, + userId, + count: insertedRows.length, + inbound_number, + }); + + return res.json({ + success: true, + data: insertedRows, + message: `${insertedRows.length}건 입고 등록 완료`, + }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("입고 등록 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +} + +// 입고 수정 +export async function update(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { id } = req.params; + const { + inbound_date, inbound_qty, unit_price, total_amount, + lot_number, warehouse_code, location_code, + inbound_status, inspection_status, + inspector, manager: mgr, memo, + } = req.body; + + const pool = getPool(); + const result = await pool.query( + `UPDATE inbound_mng SET + inbound_date = COALESCE($1::date, inbound_date), + inbound_qty = COALESCE($2, inbound_qty), + unit_price = COALESCE($3, unit_price), + total_amount = COALESCE($4, total_amount), + lot_number = COALESCE($5, lot_number), + warehouse_code = COALESCE($6, warehouse_code), + location_code = COALESCE($7, location_code), + inbound_status = COALESCE($8, inbound_status), + inspection_status = COALESCE($9, inspection_status), + inspector = COALESCE($10, inspector), + manager = COALESCE($11, manager), + memo = COALESCE($12, memo), + updated_date = NOW(), + updated_by = $13 + WHERE id = $14 AND company_code = $15 + RETURNING *`, + [ + inbound_date, inbound_qty, unit_price, total_amount, + lot_number, warehouse_code, location_code, + inbound_status, inspection_status, + inspector, mgr, memo, + userId, id, companyCode, + ] + ); + + if (result.rowCount === 0) { + return res.status(404).json({ success: false, message: "입고 데이터를 찾을 수 없습니다." }); + } + + logger.info("입고 수정", { companyCode, userId, id }); + + return res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + logger.error("입고 수정 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// 입고 삭제 +export async function deleteReceiving(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + const pool = getPool(); + + const result = await pool.query( + `DELETE FROM inbound_mng WHERE id = $1 AND company_code = $2 RETURNING id`, + [id, companyCode] + ); + + if (result.rowCount === 0) { + return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." }); + } + + logger.info("입고 삭제", { companyCode, id }); + + return res.json({ success: true, message: "삭제 완료" }); + } catch (error: any) { + logger.error("입고 삭제 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// 구매입고용: 발주 데이터 조회 (미입고분) +export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { keyword } = req.query; + + const conditions: string[] = ["company_code = $1"]; + const params: any[] = [companyCode]; + let paramIdx = 2; + + // 잔량이 있는 것만 조회 + conditions.push( + `COALESCE(CAST(NULLIF(remain_qty, '') AS numeric), COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) - COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0)) > 0` + ); + conditions.push(`status NOT IN ('입고완료', '취소')`); + + if (keyword) { + conditions.push( + `(purchase_no ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx} OR item_code ILIKE $${paramIdx} OR supplier_name ILIKE $${paramIdx})` + ); + params.push(`%${keyword}%`); + paramIdx++; + } + + const pool = getPool(); + const result = await pool.query( + `SELECT + id, purchase_no, order_date, supplier_code, supplier_name, + item_code, item_name, spec, material, + COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) AS order_qty, + COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) AS received_qty, + COALESCE(CAST(NULLIF(remain_qty, '') AS numeric), + COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) + - COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + ) AS remain_qty, + COALESCE(CAST(NULLIF(unit_price, '') AS numeric), 0) AS unit_price, + status, due_date + FROM purchase_order_mng + WHERE ${conditions.join(" AND ")} + ORDER BY order_date DESC, purchase_no`, + params + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("발주 데이터 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// 반품입고용: 출하 데이터 조회 +export async function getShipments(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { keyword } = req.query; + + const conditions: string[] = ["si.company_code = $1"]; + const params: any[] = [companyCode]; + let paramIdx = 2; + + if (keyword) { + conditions.push( + `(si.instruction_no ILIKE $${paramIdx} OR sid.item_name ILIKE $${paramIdx} OR sid.item_code ILIKE $${paramIdx})` + ); + params.push(`%${keyword}%`); + paramIdx++; + } + + const pool = getPool(); + const result = await pool.query( + `SELECT + sid.id AS detail_id, + si.id AS instruction_id, + si.instruction_no, + si.instruction_date, + si.partner_id, + si.status AS instruction_status, + sid.item_code, + sid.item_name, + sid.spec, + sid.material, + COALESCE(sid.ship_qty, 0) AS ship_qty, + COALESCE(sid.order_qty, 0) AS order_qty, + sid.source_type + FROM shipment_instruction si + JOIN shipment_instruction_detail sid + ON si.id = sid.instruction_id + AND si.company_code = sid.company_code + WHERE ${conditions.join(" AND ")} + ORDER BY si.instruction_date DESC, si.instruction_no`, + params + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("출하 데이터 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// 기타입고용: 품목 데이터 조회 +export async function getItems(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { keyword } = req.query; + + const conditions: string[] = ["company_code = $1"]; + const params: any[] = [companyCode]; + let paramIdx = 2; + + if (keyword) { + conditions.push( + `(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})` + ); + params.push(`%${keyword}%`); + paramIdx++; + } + + const pool = getPool(); + const result = await pool.query( + `SELECT + id, item_number, item_name, size AS spec, material, unit, + COALESCE(CAST(NULLIF(standard_price, '') AS numeric), 0) AS standard_price + FROM item_info + WHERE ${conditions.join(" AND ")} + ORDER BY item_name`, + params + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("품목 데이터 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// 입고번호 자동생성 +export async function generateNumber(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const pool = getPool(); + + const today = new Date(); + const yyyy = today.getFullYear(); + const prefix = `RCV-${yyyy}-`; + + const result = await pool.query( + `SELECT inbound_number FROM inbound_mng + WHERE company_code = $1 AND inbound_number LIKE $2 + ORDER BY inbound_number DESC LIMIT 1`, + [companyCode, `${prefix}%`] + ); + + let seq = 1; + if (result.rows.length > 0) { + const lastNo = result.rows[0].inbound_number; + const lastSeq = parseInt(lastNo.replace(prefix, ""), 10); + if (!isNaN(lastSeq)) seq = lastSeq + 1; + } + + const newNumber = `${prefix}${String(seq).padStart(4, "0")}`; + + return res.json({ success: true, data: newNumber }); + } catch (error: any) { + logger.error("입고번호 생성 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// 창고 목록 조회 +export async function getWarehouses(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const pool = getPool(); + + const result = await pool.query( + `SELECT warehouse_code, warehouse_name, warehouse_type + FROM warehouse_info + WHERE company_code = $1 AND status != '삭제' + ORDER BY warehouse_name`, + [companyCode] + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("창고 목록 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/controllers/salesReportController.ts b/backend-node/src/controllers/salesReportController.ts new file mode 100644 index 00000000..9d4e4fff --- /dev/null +++ b/backend-node/src/controllers/salesReportController.ts @@ -0,0 +1,161 @@ +import { Response } from "express"; +import { query } from "../database/db"; +import { logger } from "../utils/logger"; + +/** + * 영업 리포트 컨트롤러 + * - 수주 데이터를 기반으로 집계/분석용 원본 데이터를 반환 + * - 프론트엔드에서 그룹핑/집계/필터링 처리 + */ +export async function getSalesReportData( + req: any, + res: Response +): Promise { + 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 paramIdx = 1; + + // 멀티테넌시: 최고관리자는 전체, 일반 회사는 자기 데이터만 + if (companyCode !== "*") { + conditions.push(`som.company_code = $${paramIdx}`); + params.push(companyCode); + paramIdx++; + } + + // 날짜 필터 (due_date 또는 order_date 기준) + if (startDate) { + conditions.push( + `COALESCE(sod.due_date, som.order_date::text, som.created_date::date::text) >= $${paramIdx}` + ); + params.push(startDate); + paramIdx++; + } + if (endDate) { + conditions.push( + `COALESCE(sod.due_date, som.order_date::text, som.created_date::date::text) <= $${paramIdx}` + ); + params.push(endDate); + paramIdx++; + } + + const whereClause = + conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const dataQuery = ` + SELECT + som.order_no, + COALESCE(sod.due_date, som.order_date::text, som.created_date::date::text) as date, + som.order_date, + som.partner_id, + COALESCE(cm.customer_name, som.partner_id, '미지정') as customer, + sod.part_code, + COALESCE(ii.item_name, sod.part_name, sod.part_code, '미지정') as item, + CAST(COALESCE(NULLIF(sod.qty, ''), '0') AS numeric) as "orderQty", + CAST(COALESCE(NULLIF(sod.ship_qty, ''), '0') AS numeric) as "shipQty", + CAST(COALESCE(NULLIF(sod.unit_price, ''), '0') AS numeric) as "unitPrice", + CAST(COALESCE(NULLIF(sod.amount, ''), '0') AS numeric) as "orderAmt", + 1 as "orderCount", + som.status, + som.company_code + FROM sales_order_mng som + JOIN sales_order_detail sod + ON som.order_no = sod.order_no + AND som.company_code = sod.company_code + LEFT JOIN customer_mng cm + ON som.partner_id = cm.customer_code + AND som.company_code = cm.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 sod.part_code = ii.item_number + AND sod.company_code = ii.company_code + ${whereClause} + ORDER BY date DESC NULLS LAST + `; + + // query()는 rows 배열을 직접 반환 + const dataRows = await query(dataQuery, params); + + // 필터 옵션 조회 (거래처, 품목, 상태) + const filterParams: any[] = []; + let filterWhere = ""; + + if (companyCode !== "*") { + filterWhere = `WHERE company_code = $1`; + filterParams.push(companyCode); + } + + const statusWhere = filterWhere + ? `${filterWhere} AND status IS NOT NULL` + : `WHERE status IS NOT NULL`; + + const [customersRows, statusRows] = await Promise.all([ + query( + `SELECT DISTINCT customer_code as value, customer_name as label + FROM customer_mng ${filterWhere} + ORDER BY customer_name`, + filterParams + ), + query( + `SELECT DISTINCT status as value, status as label + FROM sales_order_mng ${statusWhere} + ORDER BY status`, + filterParams + ), + ]); + + // 품목은 데이터에서 추출 (실제 수주에 사용된 품목만) + const itemSet = new Map(); + dataRows.forEach((row: any) => { + if (row.part_code && !itemSet.has(row.part_code)) { + itemSet.set(row.part_code, row.item); + } + }); + const items = Array.from(itemSet.entries()).map(([value, label]) => ({ + value, + label, + })); + + logger.info("영업 리포트 데이터 조회", { + companyCode, + rowCount: dataRows.length, + startDate, + endDate, + }); + + res.status(200).json({ + success: true, + data: { + rows: dataRows, + filterOptions: { + customers: customersRows, + items, + statuses: statusRows, + }, + totalCount: dataRows.length, + }, + }); + } catch (error: any) { + logger.error("영업 리포트 데이터 조회 실패", { + error: error.message, + stack: error.stack, + }); + res.status(500).json({ + success: false, + message: "영업 리포트 데이터 조회에 실패했습니다", + error: error.message, + }); + } +} diff --git a/backend-node/src/controllers/shippingOrderController.ts b/backend-node/src/controllers/shippingOrderController.ts new file mode 100644 index 00000000..d7795fcf --- /dev/null +++ b/backend-node/src/controllers/shippingOrderController.ts @@ -0,0 +1,482 @@ +/** + * 출하지시 컨트롤러 (shipment_instruction + shipment_instruction_detail) + */ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; +import { numberingRuleService } from "../services/numberingRuleService"; + +// ─── 출하지시 목록 조회 ─── +export async function getList(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { dateFrom, dateTo, status, customer, keyword } = req.query; + + const conditions: string[] = []; + const params: any[] = []; + let idx = 1; + + if (companyCode !== "*") { + conditions.push(`si.company_code = $${idx}`); + params.push(companyCode); + idx++; + } + if (dateFrom) { + conditions.push(`si.instruction_date >= $${idx}::date`); + params.push(dateFrom); + idx++; + } + if (dateTo) { + conditions.push(`si.instruction_date <= $${idx}::date`); + params.push(dateTo); + idx++; + } + if (status) { + conditions.push(`si.status = $${idx}`); + params.push(status); + idx++; + } + if (customer) { + conditions.push(`(c.customer_name ILIKE $${idx} OR si.partner_id ILIKE $${idx})`); + params.push(`%${customer}%`); + idx++; + } + if (keyword) { + conditions.push(`(si.instruction_no ILIKE $${idx} OR si.memo ILIKE $${idx})`); + params.push(`%${keyword}%`); + idx++; + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const query = ` + SELECT + si.*, + COALESCE(c.customer_name, si.partner_id, '') AS customer_name, + COALESCE( + json_agg( + json_build_object( + 'id', sid.id, + 'item_code', sid.item_code, + 'item_name', COALESCE(i.item_name, sid.item_name, sid.item_code), + 'spec', sid.spec, + 'material', sid.material, + 'order_qty', sid.order_qty, + 'plan_qty', sid.plan_qty, + 'ship_qty', sid.ship_qty, + 'source_type', sid.source_type, + 'shipment_plan_id', sid.shipment_plan_id, + 'sales_order_id', sid.sales_order_id, + 'detail_id', sid.detail_id + ) + ) FILTER (WHERE sid.id IS NOT NULL), + '[]' + ) AS items + FROM shipment_instruction si + LEFT JOIN customer_mng c + ON si.partner_id = c.customer_code AND si.company_code = c.company_code + LEFT JOIN shipment_instruction_detail sid + ON si.id = sid.instruction_id AND si.company_code = sid.company_code + LEFT JOIN LATERAL ( + SELECT item_name FROM item_info + WHERE item_number = sid.item_code AND company_code = si.company_code + LIMIT 1 + ) i ON true + ${where} + GROUP BY si.id, c.customer_name + ORDER BY si.created_date DESC + `; + + const pool = getPool(); + const result = await pool.query(query, params); + + logger.info("출하지시 목록 조회", { companyCode, count: result.rowCount }); + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("출하지시 목록 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 다음 출하지시번호 미리보기 ─── +export async function previewNextNo(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + let instructionNo: string; + + try { + const rule = await numberingRuleService.getNumberingRuleByColumn( + companyCode, "shipment_instruction", "instruction_no" + ); + if (rule) { + instructionNo = await numberingRuleService.previewCode( + rule.ruleId, companyCode, {} + ); + } else { + throw new Error("채번 규칙 없음"); + } + } catch { + const pool = getPool(); + const today = new Date().toISOString().split("T")[0].replace(/-/g, ""); + const seqRes = await pool.query( + `SELECT COUNT(*) + 1 AS seq FROM shipment_instruction WHERE company_code = $1 AND instruction_no LIKE $2`, + [companyCode, `SI-${today}-%`] + ); + const seq = String(seqRes.rows[0].seq).padStart(3, "0"); + instructionNo = `SI-${today}-${seq}`; + } + + return res.json({ success: true, instructionNo }); + } catch (error: any) { + logger.error("출하지시번호 미리보기 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 출하지시 저장 (신규/수정) ─── +export async function save(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { + id: editId, + instructionDate, + partnerId, + status: orderStatus, + memo, + carrierName, + vehicleNo, + driverName, + driverContact, + arrivalTime, + deliveryAddress, + items, + } = req.body; + + if (!instructionDate) { + return res.status(400).json({ success: false, message: "출하지시일은 필수입니다" }); + } + if (!items || items.length === 0) { + return res.status(400).json({ success: false, message: "품목을 선택해주세요" }); + } + + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + let instructionId: number; + let instructionNo: string; + + if (editId) { + // 수정 + const check = await client.query( + `SELECT id, instruction_no FROM shipment_instruction WHERE id = $1 AND company_code = $2`, + [editId, companyCode] + ); + if (check.rowCount === 0) { + throw new Error("출하지시를 찾을 수 없습니다"); + } + instructionId = editId; + instructionNo = check.rows[0].instruction_no; + + await client.query( + `UPDATE shipment_instruction SET + instruction_date = $1::date, partner_id = $2, status = $3, memo = $4, + carrier_name = $5, vehicle_no = $6, driver_name = $7, driver_contact = $8, + arrival_time = $9, delivery_address = $10, + updated_date = NOW(), updated_by = $11 + WHERE id = $12 AND company_code = $13`, + [ + instructionDate, partnerId, orderStatus || "READY", memo, + carrierName, vehicleNo, driverName, driverContact, + arrivalTime || null, deliveryAddress, + userId, editId, companyCode, + ] + ); + + // 기존 디테일 삭제 후 재삽입 + await client.query( + `DELETE FROM shipment_instruction_detail WHERE instruction_id = $1 AND company_code = $2`, + [editId, companyCode] + ); + } else { + // 신규 - 채번 규칙이 있으면 사용, 없으면 자체 생성 + try { + const rule = await numberingRuleService.getNumberingRuleByColumn( + companyCode, "shipment_instruction", "instruction_no" + ); + if (rule) { + instructionNo = await numberingRuleService.allocateCode( + rule.ruleId, companyCode, { instruction_date: instructionDate } + ); + logger.info("채번 규칙으로 출하지시번호 생성", { ruleId: rule.ruleId, instructionNo }); + } else { + throw new Error("채번 규칙 없음 - 폴백"); + } + } catch { + const today = new Date().toISOString().split("T")[0].replace(/-/g, ""); + const seqRes = await client.query( + `SELECT COUNT(*) + 1 AS seq FROM shipment_instruction WHERE company_code = $1 AND instruction_no LIKE $2`, + [companyCode, `SI-${today}-%`] + ); + const seq = String(seqRes.rows[0].seq).padStart(3, "0"); + instructionNo = `SI-${today}-${seq}`; + logger.info("폴백으로 출하지시번호 생성", { instructionNo }); + } + + const insertRes = await client.query( + `INSERT INTO shipment_instruction + (company_code, instruction_no, instruction_date, partner_id, status, memo, + carrier_name, vehicle_no, driver_name, driver_contact, arrival_time, delivery_address, + created_date, created_by) + VALUES ($1, $2, $3::date, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW(), $13) + RETURNING id`, + [ + companyCode, instructionNo, instructionDate, partnerId, + orderStatus || "READY", memo, + carrierName, vehicleNo, driverName, driverContact, + arrivalTime || null, deliveryAddress, userId, + ] + ); + instructionId = insertRes.rows[0].id; + } + + // 디테일 삽입 + for (const item of items) { + await client.query( + `INSERT INTO shipment_instruction_detail + (company_code, instruction_id, shipment_plan_id, sales_order_id, detail_id, + item_code, item_name, spec, material, order_qty, plan_qty, ship_qty, + source_type, created_date, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), $14)`, + [ + companyCode, instructionId, + item.shipmentPlanId || null, item.salesOrderId || null, item.detailId || null, + item.itemCode, item.itemName, item.spec, item.material, + item.orderQty || 0, item.planQty || 0, item.shipQty || 0, + item.sourceType || "shipmentPlan", userId, + ] + ); + } + + await client.query("COMMIT"); + + logger.info("출하지시 저장 완료", { companyCode, instructionId, instructionNo, itemCount: items.length }); + return res.json({ success: true, data: { id: instructionId, instructionNo } }); + } catch (txErr) { + await client.query("ROLLBACK"); + throw txErr; + } finally { + client.release(); + } + } catch (error: any) { + logger.error("출하지시 저장 실패", { error: error.message, stack: error.stack }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 출하지시 삭제 ─── +export async function remove(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { ids } = req.body; + + if (!ids || !Array.isArray(ids) || ids.length === 0) { + return res.status(400).json({ success: false, message: "삭제할 ID가 필요합니다" }); + } + + const pool = getPool(); + // CASCADE로 디테일도 자동 삭제 + const result = await pool.query( + `DELETE FROM shipment_instruction WHERE id = ANY($1::int[]) AND company_code = $2 RETURNING id`, + [ids, companyCode] + ); + + logger.info("출하지시 삭제", { companyCode, deletedCount: result.rowCount }); + return res.json({ success: true, deletedCount: result.rowCount }); + } catch (error: any) { + logger.error("출하지시 삭제 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 출하계획 목록 (모달 왼쪽 패널용) ─── +export async function getShipmentPlanSource(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { keyword, customer, page: pageStr, pageSize: pageSizeStr } = req.query; + const page = Math.max(1, parseInt(pageStr as string) || 1); + const pageSize = Math.min(100, Math.max(1, parseInt(pageSizeStr as string) || 20)); + const offset = (page - 1) * pageSize; + + const conditions = ["sp.company_code = $1", "sp.status = 'READY'"]; + const params: any[] = [companyCode]; + let idx = 2; + + if (keyword) { + conditions.push(`(COALESCE(d.part_code, m.part_code, '') ILIKE $${idx} OR COALESCE(i.item_name, d.part_name, m.part_name, '') ILIKE $${idx})`); + params.push(`%${keyword}%`); + idx++; + } + if (customer) { + conditions.push(`(c.customer_name ILIKE $${idx} OR COALESCE(m.partner_id, d.delivery_partner_code, '') ILIKE $${idx})`); + params.push(`%${customer}%`); + idx++; + } + + const whereClause = conditions.join(" AND "); + const fromClause = ` + FROM shipment_plan sp + LEFT JOIN sales_order_detail d ON sp.detail_id = d.id AND sp.company_code = d.company_code + LEFT JOIN sales_order_mng m ON sp.sales_order_id = m.id AND sp.company_code = m.company_code + LEFT JOIN LATERAL ( + SELECT item_name FROM item_info + WHERE item_number = COALESCE(d.part_code, m.part_code) AND company_code = sp.company_code + LIMIT 1 + ) i ON true + LEFT JOIN customer_mng c + ON COALESCE(m.partner_id, d.delivery_partner_code) = c.customer_code AND sp.company_code = c.company_code + WHERE ${whereClause} + `; + + const pool = getPool(); + const countResult = await pool.query(`SELECT COUNT(*) AS total ${fromClause}`, params); + const totalCount = parseInt(countResult.rows[0].total); + + const query = ` + SELECT + sp.id, sp.plan_qty, sp.plan_date, sp.status, sp.shipment_plan_no, + COALESCE(m.order_no, d.order_no, '') AS order_no, + COALESCE(d.part_code, m.part_code, '') AS item_code, + COALESCE(i.item_name, d.part_name, m.part_name, COALESCE(d.part_code, m.part_code, '')) AS item_name, + COALESCE(d.spec, m.spec, '') AS spec, + COALESCE(m.material, '') AS material, + COALESCE(c.customer_name, m.partner_id, d.delivery_partner_code, '') AS customer_name, + COALESCE(m.partner_id, d.delivery_partner_code, '') AS partner_code, + sp.detail_id, sp.sales_order_id + ${fromClause} + ORDER BY sp.created_date DESC + LIMIT $${idx} OFFSET $${idx + 1} + `; + params.push(pageSize, offset); + + const result = await pool.query(query, params); + return res.json({ success: true, data: result.rows, totalCount, page, pageSize }); + } catch (error: any) { + logger.error("출하계획 소스 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 수주 목록 (모달 왼쪽 패널용) ─── +export async function getSalesOrderSource(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { keyword, customer, page: pageStr, pageSize: pageSizeStr } = req.query; + const page = Math.max(1, parseInt(pageStr as string) || 1); + const pageSize = Math.min(100, Math.max(1, parseInt(pageSizeStr as string) || 20)); + const offset = (page - 1) * pageSize; + + const conditions = ["d.company_code = $1"]; + const params: any[] = [companyCode]; + let idx = 2; + + if (keyword) { + conditions.push(`(d.part_code ILIKE $${idx} OR COALESCE(i.item_name, d.part_name, d.part_code) ILIKE $${idx} OR d.order_no ILIKE $${idx})`); + params.push(`%${keyword}%`); + idx++; + } + if (customer) { + conditions.push(`(c.customer_name ILIKE $${idx} OR COALESCE(d.delivery_partner_code, m.partner_id, '') ILIKE $${idx})`); + params.push(`%${customer}%`); + idx++; + } + + const whereClause = conditions.join(" AND "); + const fromClause = ` + FROM sales_order_detail d + LEFT JOIN sales_order_mng m ON d.order_no = m.order_no AND d.company_code = m.company_code + LEFT JOIN LATERAL ( + SELECT item_name FROM item_info + WHERE item_number = d.part_code AND company_code = d.company_code + LIMIT 1 + ) i ON true + LEFT JOIN customer_mng c + ON COALESCE(d.delivery_partner_code, m.partner_id) = c.customer_code AND d.company_code = c.company_code + WHERE ${whereClause} + `; + + const pool = getPool(); + const countResult = await pool.query(`SELECT COUNT(*) AS total ${fromClause}`, params); + const totalCount = parseInt(countResult.rows[0].total); + + const query = ` + SELECT + d.id, d.order_no, d.part_code AS item_code, + COALESCE(i.item_name, d.part_name, d.part_code) AS item_name, + COALESCE(d.spec, '') AS spec, COALESCE(m.material, '') AS material, + COALESCE(NULLIF(d.qty,'')::numeric, 0) AS qty, + COALESCE(NULLIF(d.balance_qty,'')::numeric, 0) AS balance_qty, + COALESCE(c.customer_name, COALESCE(d.delivery_partner_code, m.partner_id, '')) AS customer_name, + COALESCE(d.delivery_partner_code, m.partner_id, '') AS partner_code, + m.id AS master_id + ${fromClause} + ORDER BY d.created_date DESC + LIMIT $${idx} OFFSET $${idx + 1} + `; + params.push(pageSize, offset); + + const result = await pool.query(query, params); + return res.json({ success: true, data: result.rows, totalCount, page, pageSize }); + } catch (error: any) { + logger.error("수주 소스 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 품목 목록 (모달 왼쪽 패널용) ─── +export async function getItemSource(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { keyword, page: pageStr, pageSize: pageSizeStr } = req.query; + const page = Math.max(1, parseInt(pageStr as string) || 1); + const pageSize = Math.min(100, Math.max(1, parseInt(pageSizeStr as string) || 20)); + const offset = (page - 1) * pageSize; + + const conditions = ["company_code = $1"]; + const params: any[] = [companyCode]; + let idx = 2; + + if (keyword) { + conditions.push(`(item_number ILIKE $${idx} OR item_name ILIKE $${idx})`); + params.push(`%${keyword}%`); + idx++; + } + + const whereClause = conditions.join(" AND "); + + const pool = getPool(); + const countResult = await pool.query(`SELECT COUNT(*) AS total FROM item_info WHERE ${whereClause}`, params); + const totalCount = parseInt(countResult.rows[0].total); + + const query = ` + SELECT + item_number AS item_code, item_name, + COALESCE(size, '') AS spec, COALESCE(material, '') AS material + FROM item_info + WHERE ${whereClause} + ORDER BY item_name + LIMIT $${idx} OFFSET $${idx + 1} + `; + params.push(pageSize, offset); + + const result = await pool.query(query, params); + return res.json({ success: true, data: result.rows, totalCount, page, pageSize }); + } catch (error: any) { + logger.error("품목 소스 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/controllers/shippingPlanController.ts b/backend-node/src/controllers/shippingPlanController.ts index e89e14c2..b56c3617 100644 --- a/backend-node/src/controllers/shippingPlanController.ts +++ b/backend-node/src/controllers/shippingPlanController.ts @@ -144,6 +144,218 @@ async function getNormalizedOrders( } } +// ─── 출하계획 목록 조회 (관리 화면용) ─── + +export async function getList(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { dateFrom, dateTo, status, customer, keyword } = req.query; + + const conditions: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + // 멀티테넌시 + if (companyCode === "*") { + // 최고 관리자: 전체 조회 + } else { + conditions.push(`sp.company_code = $${paramIndex}`); + params.push(companyCode); + paramIndex++; + } + + if (dateFrom) { + conditions.push(`sp.plan_date >= $${paramIndex}::date`); + params.push(dateFrom); + paramIndex++; + } + if (dateTo) { + conditions.push(`sp.plan_date <= $${paramIndex}::date`); + params.push(dateTo); + paramIndex++; + } + if (status) { + conditions.push(`sp.status = $${paramIndex}`); + params.push(status); + paramIndex++; + } + if (customer) { + conditions.push(`(c.customer_name ILIKE $${paramIndex} OR COALESCE(m.partner_id, d.delivery_partner_code, '') ILIKE $${paramIndex})`); + params.push(`%${customer}%`); + paramIndex++; + } + if (keyword) { + conditions.push(`( + COALESCE(m.order_no, d.order_no, '') ILIKE $${paramIndex} + OR COALESCE(d.part_code, m.part_code, '') ILIKE $${paramIndex} + OR COALESCE(i.item_name, d.part_name, m.part_name, '') ILIKE $${paramIndex} + OR sp.shipment_plan_no ILIKE $${paramIndex} + )`); + params.push(`%${keyword}%`); + paramIndex++; + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const query = ` + SELECT + sp.id, + sp.plan_date, + sp.plan_qty, + sp.status, + sp.memo, + sp.shipment_plan_no, + sp.created_date, + sp.created_by, + sp.detail_id, + sp.sales_order_id, + sp.remain_qty, + COALESCE(m.order_no, d.order_no, '') AS order_no, + COALESCE(d.part_code, m.part_code, '') AS part_code, + COALESCE(i.item_name, d.part_name, m.part_name, COALESCE(d.part_code, m.part_code, '')) AS part_name, + COALESCE(d.spec, m.spec, '') AS spec, + COALESCE(m.material, '') AS material, + COALESCE(c.customer_name, m.partner_id, d.delivery_partner_code, '') AS customer_name, + COALESCE(m.partner_id, d.delivery_partner_code, '') AS partner_code, + COALESCE(d.due_date, m.due_date::text, '') AS due_date, + COALESCE(NULLIF(d.qty,'')::numeric, m.order_qty, 0) AS order_qty, + COALESCE(NULLIF(d.ship_qty,'')::numeric, m.ship_qty, 0) AS shipped_qty + FROM shipment_plan sp + LEFT JOIN sales_order_detail d + ON sp.detail_id = d.id AND sp.company_code = d.company_code + LEFT JOIN sales_order_mng m + ON sp.sales_order_id = m.id AND sp.company_code = m.company_code + LEFT JOIN LATERAL ( + SELECT item_name FROM item_info + WHERE item_number = COALESCE(d.part_code, m.part_code) + AND company_code = sp.company_code + LIMIT 1 + ) i ON true + LEFT JOIN customer_mng c + ON COALESCE(m.partner_id, d.delivery_partner_code) = c.customer_code + AND sp.company_code = c.company_code + ${whereClause} + ORDER BY sp.created_date DESC + `; + + const pool = getPool(); + const result = await pool.query(query, params); + + logger.info("출하계획 목록 조회", { + companyCode, + rowCount: result.rowCount, + }); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("출하계획 목록 조회 실패", { + error: error.message, + stack: error.stack, + }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 출하계획 단건 수정 ─── + +export async function updatePlan(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { id } = req.params; + const { planQty, planDate, memo } = req.body; + + const pool = getPool(); + + const check = await pool.query( + `SELECT id, status FROM shipment_plan WHERE id = $1 AND company_code = $2`, + [id, companyCode] + ); + + if (check.rowCount === 0) { + return res.status(404).json({ success: false, message: "출하계획을 찾을 수 없습니다" }); + } + + const setClauses: string[] = []; + const updateParams: any[] = []; + let idx = 1; + + if (planQty !== undefined) { + setClauses.push(`plan_qty = $${idx}`); + updateParams.push(planQty); + idx++; + } + if (planDate !== undefined) { + setClauses.push(`plan_date = $${idx}::date`); + updateParams.push(planDate); + idx++; + } + if (memo !== undefined) { + setClauses.push(`memo = $${idx}`); + updateParams.push(memo); + idx++; + } + + setClauses.push(`updated_date = NOW()`); + setClauses.push(`updated_by = $${idx}`); + updateParams.push(userId); + idx++; + + updateParams.push(id); + updateParams.push(companyCode); + + const updateQuery = ` + UPDATE shipment_plan + SET ${setClauses.join(", ")} + WHERE id = $${idx - 1} AND company_code = $${idx} + RETURNING * + `; + + // 파라미터 인덱스 수정 + const finalParams: any[] = []; + let pIdx = 1; + const setClausesFinal: string[] = []; + + if (planQty !== undefined) { + setClausesFinal.push(`plan_qty = $${pIdx}`); + finalParams.push(planQty); + pIdx++; + } + if (planDate !== undefined) { + setClausesFinal.push(`plan_date = $${pIdx}::date`); + finalParams.push(planDate); + pIdx++; + } + if (memo !== undefined) { + setClausesFinal.push(`memo = $${pIdx}`); + finalParams.push(memo); + pIdx++; + } + setClausesFinal.push(`updated_date = NOW()`); + setClausesFinal.push(`updated_by = $${pIdx}`); + finalParams.push(userId); + pIdx++; + + finalParams.push(id); + finalParams.push(companyCode); + + const result = await pool.query( + `UPDATE shipment_plan + SET ${setClausesFinal.join(", ")} + WHERE id = $${pIdx} AND company_code = $${pIdx + 1} + RETURNING *`, + finalParams + ); + + logger.info("출하계획 수정", { companyCode, planId: id, userId }); + + return res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + logger.error("출하계획 수정 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + // ─── 품목별 집계 + 기존 출하계획 조회 ─── export async function getAggregate(req: AuthenticatedRequest, res: Response) { diff --git a/backend-node/src/controllers/workInstructionController.ts b/backend-node/src/controllers/workInstructionController.ts new file mode 100644 index 00000000..dfe685ff --- /dev/null +++ b/backend-node/src/controllers/workInstructionController.ts @@ -0,0 +1,650 @@ +/** + * 작업지시 컨트롤러 (work_instruction + work_instruction_detail) + */ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; +import { numberingRuleService } from "../services/numberingRuleService"; + +// ─── 작업지시 목록 조회 (detail 기준 행 반환) ─── +export async function getList(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { dateFrom, dateTo, status, progressStatus, keyword } = req.query; + + const conditions: string[] = []; + const params: any[] = []; + let idx = 1; + + if (companyCode !== "*") { + conditions.push(`wi.company_code = $${idx}`); + params.push(companyCode); + idx++; + } + if (dateFrom) { + conditions.push(`wi.start_date >= $${idx}`); + params.push(dateFrom); + idx++; + } + if (dateTo) { + conditions.push(`wi.end_date <= $${idx}`); + params.push(dateTo); + idx++; + } + if (status && status !== "all") { + conditions.push(`wi.status = $${idx}`); + params.push(status); + idx++; + } + if (progressStatus && progressStatus !== "all") { + conditions.push(`wi.progress_status = $${idx}`); + params.push(progressStatus); + idx++; + } + if (keyword) { + conditions.push(`(wi.work_instruction_no ILIKE $${idx} OR wi.worker ILIKE $${idx} OR COALESCE(itm.item_name,'') ILIKE $${idx} OR COALESCE(d.item_number,'') ILIKE $${idx})`); + params.push(`%${keyword}%`); + idx++; + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const query = ` + SELECT + wi.id AS wi_id, + wi.work_instruction_no, + wi.status, + wi.progress_status, + wi.qty AS total_qty, + wi.completed_qty, + wi.start_date, + wi.end_date, + wi.equipment_id, + wi.work_team, + wi.worker, + wi.remark AS wi_remark, + wi.created_date, + d.id AS detail_id, + d.item_number, + d.qty AS detail_qty, + d.remark AS detail_remark, + d.part_code, + d.source_table, + d.source_id, + COALESCE(itm.item_name, '') AS item_name, + COALESCE(itm.size, '') AS item_spec, + COALESCE(e.equipment_name, '') AS equipment_name, + COALESCE(e.equipment_code, '') AS equipment_code, + wi.routing AS routing_version_id, + COALESCE(rv.version_name, '') AS routing_name, + ROW_NUMBER() OVER (PARTITION BY wi.work_instruction_no ORDER BY d.created_date) AS detail_seq, + COUNT(*) OVER (PARTITION BY wi.work_instruction_no) AS detail_count + FROM work_instruction wi + INNER JOIN work_instruction_detail d + ON d.work_instruction_no = wi.work_instruction_no AND d.company_code = wi.company_code + LEFT JOIN LATERAL ( + SELECT item_name, size FROM item_info + WHERE item_number = d.item_number AND company_code = wi.company_code LIMIT 1 + ) itm ON true + LEFT JOIN equipment_mng e ON wi.equipment_id = e.id AND wi.company_code = e.company_code + LEFT JOIN item_routing_version rv ON wi.routing = rv.id AND rv.company_code = wi.company_code + ${whereClause} + ORDER BY wi.created_date DESC, d.created_date ASC + `; + + const pool = getPool(); + const result = await pool.query(query, params); + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("작업지시 목록 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 다음 작업지시번호 미리보기 ─── +export async function previewNextNo(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + let wiNo: string; + try { + const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, "work_instruction", "work_instruction_no"); + if (rule) { + wiNo = await numberingRuleService.previewCode(rule.ruleId, companyCode, {}); + } else { throw new Error("채번 규칙 없음"); } + } catch { + const pool = getPool(); + const today = new Date().toISOString().split("T")[0].replace(/-/g, ""); + const seqRes = await pool.query( + `SELECT COUNT(*) + 1 AS seq FROM work_instruction WHERE company_code = $1 AND work_instruction_no LIKE $2`, + [companyCode, `WI-${today}-%`] + ); + wiNo = `WI-${today}-${String(seqRes.rows[0].seq).padStart(3, "0")}`; + } + return res.json({ success: true, instructionNo: wiNo }); + } catch (error: any) { + logger.error("작업지시번호 미리보기 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 작업지시 저장 (신규/수정) ─── +export async function save(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { id: editId, status: wiStatus, progressStatus, reason, startDate, endDate, equipmentId, workTeam, worker, remark, items, routing: routingVersionId } = req.body; + + if (!items || items.length === 0) { + return res.status(400).json({ success: false, message: "품목을 선택해주세요" }); + } + + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + let wiId: string; + let wiNo: string; + + if (editId) { + const check = await client.query(`SELECT id, work_instruction_no FROM work_instruction WHERE id = $1 AND company_code = $2`, [editId, companyCode]); + if (check.rowCount === 0) throw new Error("작업지시를 찾을 수 없습니다"); + wiId = editId; + wiNo = check.rows[0].work_instruction_no; + await client.query( + `UPDATE work_instruction SET status=$1, progress_status=$2, reason=$3, start_date=$4, end_date=$5, equipment_id=$6, work_team=$7, worker=$8, remark=$9, routing=$10, updated_date=NOW(), writer=$11 WHERE id=$12 AND company_code=$13`, + [wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", routingVersionId||null, userId, editId, companyCode] + ); + await client.query(`DELETE FROM work_instruction_detail WHERE work_instruction_no=$1 AND company_code=$2`, [wiNo, companyCode]); + } else { + try { + const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, "work_instruction", "work_instruction_no"); + if (rule) { wiNo = await numberingRuleService.allocateCode(rule.ruleId, companyCode, {}); } + else { throw new Error("채번 규칙 없음 - 폴백"); } + } catch { + const today = new Date().toISOString().split("T")[0].replace(/-/g, ""); + const seqRes = await client.query(`SELECT COUNT(*)+1 AS seq FROM work_instruction WHERE company_code=$1 AND work_instruction_no LIKE $2`, [companyCode, `WI-${today}-%`]); + wiNo = `WI-${today}-${String(seqRes.rows[0].seq).padStart(3, "0")}`; + } + const insertRes = await client.query( + `INSERT INTO work_instruction (id,company_code,work_instruction_no,status,progress_status,reason,start_date,end_date,equipment_id,work_team,worker,remark,routing,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,NOW(),$13) RETURNING id`, + [companyCode, wiNo, wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", routingVersionId||null, userId] + ); + wiId = insertRes.rows[0].id; + } + + for (const item of items) { + await client.query( + `INSERT INTO work_instruction_detail (id,company_code,work_instruction_no,item_number,qty,remark,source_table,source_id,part_code,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,NOW(),$9)`, + [companyCode, wiNo, item.itemNumber||item.itemCode||"", item.qty||"0", item.remark||"", item.sourceTable||"", item.sourceId||"", item.partCode||item.itemNumber||item.itemCode||"", userId] + ); + } + + await client.query("COMMIT"); + return res.json({ success: true, data: { id: wiId, workInstructionNo: wiNo } }); + } catch (txErr) { await client.query("ROLLBACK"); throw txErr; } + finally { client.release(); } + } catch (error: any) { + logger.error("작업지시 저장 실패", { error: error.message, stack: error.stack }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 작업지시 삭제 ─── +export async function remove(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { ids } = req.body; + if (!ids || ids.length === 0) return res.status(400).json({ success: false, message: "삭제할 항목을 선택해주세요" }); + + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const wiNos = await client.query(`SELECT work_instruction_no FROM work_instruction WHERE id=ANY($1) AND company_code=$2`, [ids, companyCode]); + for (const row of wiNos.rows) { + await client.query(`DELETE FROM work_instruction_detail WHERE work_instruction_no=$1 AND company_code=$2`, [row.work_instruction_no, companyCode]); + } + const result = await client.query(`DELETE FROM work_instruction WHERE id=ANY($1) AND company_code=$2`, [ids, companyCode]); + await client.query("COMMIT"); + return res.json({ success: true, deletedCount: result.rowCount }); + } catch (txErr) { await client.query("ROLLBACK"); throw txErr; } + finally { client.release(); } + } catch (error: any) { + logger.error("작업지시 삭제 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 품목 소스 (페이징) ─── +export async function getItemSource(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { keyword, page: ps, pageSize: pss } = req.query; + const page = Math.max(1, parseInt(ps as string) || 1); + const pageSize = Math.min(100, Math.max(1, parseInt(pss as string) || 20)); + const offset = (page - 1) * pageSize; + + const conds = ["company_code = $1"]; const params: any[] = [companyCode]; let idx = 2; + if (keyword) { conds.push(`(item_number ILIKE $${idx} OR item_name ILIKE $${idx})`); params.push(`%${keyword}%`); idx++; } + const w = conds.join(" AND "); + const pool = getPool(); + const cnt = await pool.query(`SELECT COUNT(*) AS total FROM item_info WHERE ${w}`, params); + params.push(pageSize, offset); + const rows = await pool.query(`SELECT id, item_number AS item_code, item_name, COALESCE(size,'') AS spec FROM item_info WHERE ${w} ORDER BY item_name LIMIT $${idx} OFFSET $${idx+1}`, params); + return res.json({ success: true, data: rows.rows, totalCount: parseInt(cnt.rows[0].total), page, pageSize }); + } catch (error: any) { return res.status(500).json({ success: false, message: error.message }); } +} + +// ─── 수주 소스 (페이징) ─── +export async function getSalesOrderSource(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { keyword, page: ps, pageSize: pss } = req.query; + const page = Math.max(1, parseInt(ps as string) || 1); + const pageSize = Math.min(100, Math.max(1, parseInt(pss as string) || 20)); + const offset = (page - 1) * pageSize; + + const conds = ["d.company_code = $1"]; const params: any[] = [companyCode]; let idx = 2; + if (keyword) { conds.push(`(d.part_code ILIKE $${idx} OR COALESCE(i.item_name, d.part_name, d.part_code) ILIKE $${idx} OR d.order_no ILIKE $${idx})`); params.push(`%${keyword}%`); idx++; } + const fromClause = `FROM sales_order_detail d LEFT JOIN LATERAL (SELECT item_name FROM item_info WHERE item_number = d.part_code AND company_code = d.company_code LIMIT 1) i ON true WHERE ${conds.join(" AND ")}`; + const pool = getPool(); + const cnt = await pool.query(`SELECT COUNT(*) AS total ${fromClause}`, params); + params.push(pageSize, offset); + const rows = await pool.query(`SELECT d.id, d.order_no, d.part_code AS item_code, COALESCE(i.item_name, d.part_name, d.part_code) AS item_name, COALESCE(d.spec,'') AS spec, COALESCE(NULLIF(d.qty,'')::numeric,0) AS qty, d.due_date ${fromClause} ORDER BY d.created_date DESC LIMIT $${idx} OFFSET $${idx+1}`, params); + return res.json({ success: true, data: rows.rows, totalCount: parseInt(cnt.rows[0].total), page, pageSize }); + } catch (error: any) { return res.status(500).json({ success: false, message: error.message }); } +} + +// ─── 생산계획 소스 (페이징) ─── +export async function getProductionPlanSource(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { keyword, page: ps, pageSize: pss } = req.query; + const page = Math.max(1, parseInt(ps as string) || 1); + const pageSize = Math.min(100, Math.max(1, parseInt(pss as string) || 20)); + const offset = (page - 1) * pageSize; + + const conds = ["p.company_code = $1"]; const params: any[] = [companyCode]; let idx = 2; + if (keyword) { conds.push(`(p.plan_no ILIKE $${idx} OR p.item_code ILIKE $${idx} OR COALESCE(p.item_name,'') ILIKE $${idx})`); params.push(`%${keyword}%`); idx++; } + const w = conds.join(" AND "); + const pool = getPool(); + const cnt = await pool.query(`SELECT COUNT(*) AS total FROM production_plan_mng p WHERE ${w}`, params); + params.push(pageSize, offset); + const rows = await pool.query(`SELECT p.id, p.plan_no, p.item_code, COALESCE(p.item_name,'') AS item_name, COALESCE(p.plan_qty,0) AS plan_qty, p.start_date, p.end_date, p.status, COALESCE(p.equipment_name,'') AS equipment_name FROM production_plan_mng p WHERE ${w} ORDER BY p.created_date DESC LIMIT $${idx} OFFSET $${idx+1}`, params); + return res.json({ success: true, data: rows.rows, totalCount: parseInt(cnt.rows[0].total), page, pageSize }); + } catch (error: any) { return res.status(500).json({ success: false, message: error.message }); } +} + +// ─── 사원 목록 (작업자 Select용) ─── +export async function getEmployeeList(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const pool = getPool(); + let query: string; + let params: any[]; + if (companyCode !== "*") { + query = `SELECT user_id, user_name, dept_name FROM user_info WHERE company_code = $1 AND company_code != '*' ORDER BY user_name`; + params = [companyCode]; + } else { + query = `SELECT user_id, user_name, dept_name, company_code FROM user_info WHERE company_code != '*' ORDER BY user_name`; + params = []; + } + const result = await pool.query(query, params); + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("사원 목록 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 설비 목록 (Select용) ─── +export async function getEquipmentList(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const pool = getPool(); + const cond = companyCode !== "*" ? "WHERE company_code = $1" : ""; + const params = companyCode !== "*" ? [companyCode] : []; + const result = await pool.query(`SELECT id, equipment_code, equipment_name FROM equipment_mng ${cond} ORDER BY equipment_name`, params); + return res.json({ success: true, data: result.rows }); + } catch (error: any) { return res.status(500).json({ success: false, message: error.message }); } +} + +// ─── 품목의 라우팅 버전 + 공정 조회 ─── +export async function getRoutingVersions(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { itemCode } = req.params; + const pool = getPool(); + + const versionsResult = await pool.query( + `SELECT id, version_name, description, created_date, COALESCE(is_default, false) AS is_default + FROM item_routing_version + WHERE item_code = $1 AND company_code = $2 + ORDER BY is_default DESC, created_date DESC`, + [itemCode, companyCode] + ); + + const routings = []; + for (const version of versionsResult.rows) { + const detailsResult = await pool.query( + `SELECT rd.id AS routing_detail_id, rd.seq_no, rd.process_code, + rd.is_required, rd.work_type, + COALESCE(p.process_name, rd.process_code) AS process_name + FROM item_routing_detail rd + LEFT JOIN process_mng p ON p.process_code = rd.process_code AND p.company_code = rd.company_code + WHERE rd.routing_version_id = $1 AND rd.company_code = $2 + ORDER BY rd.seq_no::integer`, + [version.id, companyCode] + ); + routings.push({ ...version, processes: detailsResult.rows }); + } + + return res.json({ success: true, data: routings }); + } catch (error: any) { + logger.error("라우팅 버전 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 작업지시 라우팅 변경 ─── +export async function updateRouting(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { wiNo } = req.params; + const { routingVersionId } = req.body; + const pool = getPool(); + + await pool.query( + `UPDATE work_instruction SET routing = $1, updated_date = NOW() WHERE work_instruction_no = $2 AND company_code = $3`, + [routingVersionId || null, wiNo, companyCode] + ); + + return res.json({ success: true }); + } catch (error: any) { + logger.error("라우팅 변경 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 작업지시 전용 공정작업기준 조회 ─── +export async function getWorkStandard(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { wiNo } = req.params; + const { routingVersionId } = req.query; + const pool = getPool(); + + if (!routingVersionId) { + return res.status(400).json({ success: false, message: "routingVersionId 필요" }); + } + + // 라우팅 디테일(공정) 목록 조회 + const processesResult = await pool.query( + `SELECT rd.id AS routing_detail_id, rd.seq_no, rd.process_code, + COALESCE(p.process_name, rd.process_code) AS process_name + FROM item_routing_detail rd + LEFT JOIN process_mng p ON p.process_code = rd.process_code AND p.company_code = rd.company_code + WHERE rd.routing_version_id = $1 AND rd.company_code = $2 + ORDER BY rd.seq_no::integer`, + [routingVersionId, companyCode] + ); + + // 커스텀 작업기준이 있는지 확인 + const customCheck = await pool.query( + `SELECT COUNT(*) AS cnt FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`, + [wiNo, companyCode] + ); + const hasCustom = parseInt(customCheck.rows[0].cnt) > 0; + + const processes = []; + for (const proc of processesResult.rows) { + let workItems; + + if (hasCustom) { + // 커스텀 버전에서 조회 + const wiResult = await pool.query( + `SELECT wi.id, wi.routing_detail_id, wi.work_phase, wi.title, wi.is_required, wi.sort_order, wi.description, + (SELECT COUNT(*) FROM wi_process_work_item_detail d WHERE d.wi_work_item_id = wi.id AND d.company_code = wi.company_code)::integer AS detail_count + FROM wi_process_work_item wi + WHERE wi.work_instruction_no = $1 AND wi.routing_detail_id = $2 AND wi.company_code = $3 + ORDER BY wi.work_phase, wi.sort_order`, + [wiNo, proc.routing_detail_id, companyCode] + ); + workItems = wiResult.rows; + + // 각 work_item의 상세도 로드 + for (const wi of workItems) { + const detailsResult = await pool.query( + `SELECT id, wi_work_item_id AS work_item_id, detail_type, content, is_required, sort_order, remark, + inspection_code, inspection_method, unit, lower_limit, upper_limit, + duration_minutes, input_type, lookup_target, display_fields + FROM wi_process_work_item_detail + WHERE wi_work_item_id = $1 AND company_code = $2 + ORDER BY sort_order`, + [wi.id, companyCode] + ); + wi.details = detailsResult.rows; + } + } else { + // 원본에서 조회 + const origResult = await pool.query( + `SELECT wi.id, wi.routing_detail_id, wi.work_phase, wi.title, wi.is_required, wi.sort_order, wi.description, + (SELECT COUNT(*) FROM process_work_item_detail d WHERE d.work_item_id = wi.id AND d.company_code = wi.company_code)::integer AS detail_count + FROM process_work_item wi + WHERE wi.routing_detail_id = $1 AND wi.company_code = $2 + ORDER BY wi.work_phase, wi.sort_order`, + [proc.routing_detail_id, companyCode] + ); + workItems = origResult.rows; + + for (const wi of workItems) { + const detailsResult = await pool.query( + `SELECT id, work_item_id, detail_type, content, is_required, sort_order, remark, + inspection_code, inspection_method, unit, lower_limit, upper_limit, + duration_minutes, input_type, lookup_target, display_fields + FROM process_work_item_detail + WHERE work_item_id = $1 AND company_code = $2 + ORDER BY sort_order`, + [wi.id, companyCode] + ); + wi.details = detailsResult.rows; + } + } + + processes.push({ + ...proc, + workItems, + }); + } + + return res.json({ success: true, data: { processes, isCustom: hasCustom } }); + } catch (error: any) { + logger.error("작업지시 공정작업기준 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 원본 공정작업기준 -> 작업지시 전용 복사 ─── +export async function copyWorkStandard(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { wiNo } = req.params; + const { routingVersionId } = req.body; + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + // 기존 커스텀 데이터 삭제 + const existingItems = await client.query( + `SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`, + [wiNo, companyCode] + ); + for (const row of existingItems.rows) { + await client.query( + `DELETE FROM wi_process_work_item_detail WHERE wi_work_item_id = $1 AND company_code = $2`, + [row.id, companyCode] + ); + } + await client.query( + `DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`, + [wiNo, companyCode] + ); + + // 라우팅 디테일 목록 조회 + const routingDetails = await client.query( + `SELECT id FROM item_routing_detail WHERE routing_version_id = $1 AND company_code = $2`, + [routingVersionId, companyCode] + ); + + // 각 공정(routing_detail)별 원본 작업항목 복사 + for (const rd of routingDetails.rows) { + const origItems = await client.query( + `SELECT * FROM process_work_item WHERE routing_detail_id = $1 AND company_code = $2`, + [rd.id, companyCode] + ); + + for (const origItem of origItems.rows) { + const newItemResult = await client.query( + `INSERT INTO wi_process_work_item (company_code, work_instruction_no, routing_detail_id, work_phase, title, is_required, sort_order, description, source_work_item_id, writer) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`, + [companyCode, wiNo, rd.id, origItem.work_phase, origItem.title, origItem.is_required, origItem.sort_order, origItem.description, origItem.id, userId] + ); + const newItemId = newItemResult.rows[0].id; + + // 상세 복사 + const origDetails = await client.query( + `SELECT * FROM process_work_item_detail WHERE work_item_id = $1 AND company_code = $2`, + [origItem.id, companyCode] + ); + + for (const origDetail of origDetails.rows) { + await client.query( + `INSERT INTO wi_process_work_item_detail (company_code, wi_work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, writer) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`, + [companyCode, newItemId, origDetail.detail_type, origDetail.content, origDetail.is_required, origDetail.sort_order, origDetail.remark, origDetail.inspection_code, origDetail.inspection_method, origDetail.unit, origDetail.lower_limit, origDetail.upper_limit, origDetail.duration_minutes, origDetail.input_type, origDetail.lookup_target, origDetail.display_fields, userId] + ); + } + } + } + + await client.query("COMMIT"); + logger.info("공정작업기준 복사 완료", { companyCode, wiNo, routingVersionId }); + return res.json({ success: true }); + } catch (txErr) { + await client.query("ROLLBACK"); + throw txErr; + } finally { + client.release(); + } + } catch (error: any) { + logger.error("공정작업기준 복사 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 작업지시 전용 공정작업기준 저장 (일괄) ─── +export async function saveWorkStandard(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { wiNo } = req.params; + const { routingDetailId, workItems } = req.body; + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + // 해당 공정의 기존 커스텀 데이터 삭제 + const existing = await client.query( + `SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND routing_detail_id = $2 AND company_code = $3`, + [wiNo, routingDetailId, companyCode] + ); + for (const row of existing.rows) { + await client.query( + `DELETE FROM wi_process_work_item_detail WHERE wi_work_item_id = $1 AND company_code = $2`, + [row.id, companyCode] + ); + } + await client.query( + `DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND routing_detail_id = $2 AND company_code = $3`, + [wiNo, routingDetailId, companyCode] + ); + + // 새 데이터 삽입 + for (const wi of workItems) { + const wiResult = await client.query( + `INSERT INTO wi_process_work_item (company_code, work_instruction_no, routing_detail_id, work_phase, title, is_required, sort_order, description, source_work_item_id, writer) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`, + [companyCode, wiNo, routingDetailId, wi.work_phase, wi.title, wi.is_required, wi.sort_order, wi.description || null, wi.source_work_item_id || null, userId] + ); + const newId = wiResult.rows[0].id; + + if (wi.details && Array.isArray(wi.details)) { + for (const d of wi.details) { + await client.query( + `INSERT INTO wi_process_work_item_detail (company_code, wi_work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, writer) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`, + [companyCode, newId, d.detail_type, d.content, d.is_required, d.sort_order, d.remark || null, d.inspection_code || null, d.inspection_method || null, d.unit || null, d.lower_limit || null, d.upper_limit || null, d.duration_minutes || null, d.input_type || null, d.lookup_target || null, d.display_fields || null, userId] + ); + } + } + } + + await client.query("COMMIT"); + logger.info("작업지시 공정작업기준 저장 완료", { companyCode, wiNo, routingDetailId }); + return res.json({ success: true }); + } catch (txErr) { + await client.query("ROLLBACK"); + throw txErr; + } finally { + client.release(); + } + } catch (error: any) { + logger.error("작업지시 공정작업기준 저장 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 작업지시 전용 커스텀 데이터 삭제 (원본으로 초기화) ─── +export async function resetWorkStandard(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { wiNo } = req.params; + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + const items = await client.query( + `SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`, + [wiNo, companyCode] + ); + for (const row of items.rows) { + await client.query( + `DELETE FROM wi_process_work_item_detail WHERE wi_work_item_id = $1 AND company_code = $2`, + [row.id, companyCode] + ); + } + await client.query( + `DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`, + [wiNo, companyCode] + ); + await client.query("COMMIT"); + logger.info("작업지시 공정작업기준 초기화", { companyCode, wiNo }); + return res.json({ success: true }); + } catch (txErr) { + await client.query("ROLLBACK"); + throw txErr; + } finally { + client.release(); + } + } catch (error: any) { + logger.error("작업지시 공정작업기준 초기화 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/routes/analyticsReportRoutes.ts b/backend-node/src/routes/analyticsReportRoutes.ts new file mode 100644 index 00000000..518de596 --- /dev/null +++ b/backend-node/src/routes/analyticsReportRoutes.ts @@ -0,0 +1,23 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { + getProductionReportData, + getInventoryReportData, + getPurchaseReportData, + getQualityReportData, + getEquipmentReportData, + getMoldReportData, +} from "../controllers/analyticsReportController"; + +const router = Router(); + +router.use(authenticateToken); + +router.get("/production/data", getProductionReportData); +router.get("/inventory/data", getInventoryReportData); +router.get("/purchase/data", getPurchaseReportData); +router.get("/quality/data", getQualityReportData); +router.get("/equipment/data", getEquipmentReportData); +router.get("/mold/data", getMoldReportData); + +export default router; diff --git a/backend-node/src/routes/batchManagementRoutes.ts b/backend-node/src/routes/batchManagementRoutes.ts index 50ee1ea0..372113c0 100644 --- a/backend-node/src/routes/batchManagementRoutes.ts +++ b/backend-node/src/routes/batchManagementRoutes.ts @@ -7,6 +7,19 @@ import { authenticateToken } from "../middleware/authMiddleware"; const router = Router(); +/** + * GET /api/batch-management/stats + * 배치 대시보드 통계 (전체/활성 배치 수, 오늘·어제 실행/실패 수) + * 반드시 /batch-configs 보다 위에 등록 (/:id로 잡히지 않도록) + */ +router.get("/stats", authenticateToken, BatchManagementController.getBatchStats); + +/** + * GET /api/batch-management/node-flows + * 배치 설정에서 노드 플로우 선택용 목록 조회 + */ +router.get("/node-flows", authenticateToken, BatchManagementController.getNodeFlows); + /** * GET /api/batch-management/connections * 사용 가능한 커넥션 목록 조회 @@ -55,6 +68,18 @@ router.get("/batch-configs", authenticateToken, BatchManagementController.getBat */ router.get("/batch-configs/:id", authenticateToken, BatchManagementController.getBatchConfigById); +/** + * GET /api/batch-management/batch-configs/:id/sparkline + * 해당 배치 최근 24시간 1시간 단위 실행 집계 + */ +router.get("/batch-configs/:id/sparkline", authenticateToken, BatchManagementController.getBatchSparkline); + +/** + * GET /api/batch-management/batch-configs/:id/recent-logs + * 해당 배치 최근 실행 로그 (최대 20건) + */ +router.get("/batch-configs/:id/recent-logs", authenticateToken, BatchManagementController.getBatchRecentLogs); + /** * PUT /api/batch-management/batch-configs/:id * 배치 설정 업데이트 diff --git a/backend-node/src/routes/dataflow/node-flows.ts b/backend-node/src/routes/dataflow/node-flows.ts index 30fffd7b..4180f977 100644 --- a/backend-node/src/routes/dataflow/node-flows.ts +++ b/backend-node/src/routes/dataflow/node-flows.ts @@ -13,7 +13,54 @@ import { auditLogService, getClientIp } from "../../services/auditLogService"; const router = Router(); /** - * 플로우 목록 조회 + * flow_data에서 요약 정보 추출 + */ +function extractFlowSummary(flowData: any) { + try { + const parsed = typeof flowData === "string" ? JSON.parse(flowData) : flowData; + const nodes = parsed?.nodes || []; + const edges = parsed?.edges || []; + + const nodeTypes: Record = {}; + nodes.forEach((n: any) => { + const t = n.type || "unknown"; + nodeTypes[t] = (nodeTypes[t] || 0) + 1; + }); + + // 미니 토폴로지용 간소화된 좌표 (0~1 정규화) + let topology = null; + if (nodes.length > 0) { + const xs = nodes.map((n: any) => n.position?.x || 0); + const ys = nodes.map((n: any) => n.position?.y || 0); + const minX = Math.min(...xs), maxX = Math.max(...xs); + const minY = Math.min(...ys), maxY = Math.max(...ys); + const rangeX = maxX - minX || 1; + const rangeY = maxY - minY || 1; + + topology = { + nodes: nodes.map((n: any) => ({ + id: n.id, + type: n.type, + x: (((n.position?.x || 0) - minX) / rangeX), + y: (((n.position?.y || 0) - minY) / rangeY), + })), + edges: edges.map((e: any) => [e.source, e.target]), + }; + } + + return { + nodeCount: nodes.length, + edgeCount: edges.length, + nodeTypes, + topology, + }; + } catch { + return { nodeCount: 0, edgeCount: 0, nodeTypes: {}, topology: null }; + } +} + +/** + * 플로우 목록 조회 (summary 포함) */ router.get("/", async (req: AuthenticatedRequest, res: Response) => { try { @@ -24,6 +71,7 @@ router.get("/", async (req: AuthenticatedRequest, res: Response) => { flow_id as "flowId", flow_name as "flowName", flow_description as "flowDescription", + flow_data as "flowData", company_code as "companyCode", created_at as "createdAt", updated_at as "updatedAt" @@ -32,7 +80,6 @@ router.get("/", async (req: AuthenticatedRequest, res: Response) => { const params: any[] = []; - // 슈퍼 관리자가 아니면 회사별 필터링 if (userCompanyCode && userCompanyCode !== "*") { sqlQuery += ` WHERE company_code = $1`; params.push(userCompanyCode); @@ -42,9 +89,15 @@ router.get("/", async (req: AuthenticatedRequest, res: Response) => { const flows = await query(sqlQuery, params); + const flowsWithSummary = flows.map((flow: any) => { + const summary = extractFlowSummary(flow.flowData); + const { flowData, ...rest } = flow; + return { ...rest, summary }; + }); + return res.json({ success: true, - data: flows, + data: flowsWithSummary, }); } catch (error) { logger.error("플로우 목록 조회 실패:", error); diff --git a/backend-node/src/routes/designRoutes.ts b/backend-node/src/routes/designRoutes.ts new file mode 100644 index 00000000..fcbcc6c7 --- /dev/null +++ b/backend-node/src/routes/designRoutes.ts @@ -0,0 +1,67 @@ +import express from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { + getDesignRequestList, getDesignRequestDetail, createDesignRequest, updateDesignRequest, deleteDesignRequest, addRequestHistory, + getProjectList, getProjectDetail, createProject, updateProject, deleteProject, + getTasksByProject, createTask, updateTask, deleteTask, + getWorkLogsByTask, createWorkLog, deleteWorkLog, + createSubItem, updateSubItem, deleteSubItem, + createIssue, updateIssue, + getEcnList, createEcn, updateEcn, deleteEcn, + getMyWork, + createPurchaseReq, createCoopReq, addCoopResponse, +} from "../controllers/designController"; + +const router = express.Router(); +router.use(authenticateToken); + +// 설계의뢰/설변요청 (DR/ECR) +router.get("/requests", getDesignRequestList); +router.get("/requests/:id", getDesignRequestDetail); +router.post("/requests", createDesignRequest); +router.put("/requests/:id", updateDesignRequest); +router.delete("/requests/:id", deleteDesignRequest); +router.post("/requests/:id/history", addRequestHistory); + +// 설계 프로젝트 +router.get("/projects", getProjectList); +router.get("/projects/:id", getProjectDetail); +router.post("/projects", createProject); +router.put("/projects/:id", updateProject); +router.delete("/projects/:id", deleteProject); + +// 프로젝트 태스크 +router.get("/projects/:projectId/tasks", getTasksByProject); +router.post("/projects/:projectId/tasks", createTask); +router.put("/tasks/:taskId", updateTask); +router.delete("/tasks/:taskId", deleteTask); + +// 작업일지 +router.get("/tasks/:taskId/work-logs", getWorkLogsByTask); +router.post("/tasks/:taskId/work-logs", createWorkLog); +router.delete("/work-logs/:workLogId", deleteWorkLog); + +// 태스크 하위항목 +router.post("/tasks/:taskId/sub-items", createSubItem); +router.put("/sub-items/:subItemId", updateSubItem); +router.delete("/sub-items/:subItemId", deleteSubItem); + +// 태스크 이슈 +router.post("/tasks/:taskId/issues", createIssue); +router.put("/issues/:issueId", updateIssue); + +// ECN (설변통보) +router.get("/ecn", getEcnList); +router.post("/ecn", createEcn); +router.put("/ecn/:id", updateEcn); +router.delete("/ecn/:id", deleteEcn); + +// 나의 업무 +router.get("/my-work", getMyWork); + +// 구매요청 / 협업요청 +router.post("/work-logs/:workLogId/purchase-reqs", createPurchaseReq); +router.post("/work-logs/:workLogId/coop-reqs", createCoopReq); +router.post("/coop-reqs/:coopReqId/responses", addCoopResponse); + +export default router; diff --git a/backend-node/src/routes/materialStatusRoutes.ts b/backend-node/src/routes/materialStatusRoutes.ts new file mode 100644 index 00000000..e142093d --- /dev/null +++ b/backend-node/src/routes/materialStatusRoutes.ts @@ -0,0 +1,22 @@ +/** + * 자재현황 라우트 + */ + +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as materialStatusController from "../controllers/materialStatusController"; + +const router = Router(); + +router.use(authenticateToken); + +// 생산계획(작업지시) 목록 조회 +router.get("/work-orders", materialStatusController.getWorkOrders); + +// 자재소요 + 재고 현황 조회 (POST: planIds 배열 전달) +router.post("/materials", materialStatusController.getMaterialStatus); + +// 창고 목록 조회 +router.get("/warehouses", materialStatusController.getWarehouses); + +export default router; diff --git a/backend-node/src/routes/processInfoRoutes.ts b/backend-node/src/routes/processInfoRoutes.ts new file mode 100644 index 00000000..3507707e --- /dev/null +++ b/backend-node/src/routes/processInfoRoutes.ts @@ -0,0 +1,45 @@ +/** + * 공정정보관리 라우트 + */ + +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as ctrl from "../controllers/processInfoController"; + +const router = Router(); + +router.use(authenticateToken); + +// 공정 마스터 CRUD +router.get("/processes", ctrl.getProcessList); +router.post("/processes", ctrl.createProcess); +router.put("/processes/:id", ctrl.updateProcess); +router.post("/processes/delete", ctrl.deleteProcesses); + +// 공정별 설비 관리 +router.get("/processes/:processCode/equipments", ctrl.getProcessEquipments); +router.post("/process-equipments", ctrl.addProcessEquipment); +router.delete("/process-equipments/:id", ctrl.removeProcessEquipment); + +// 설비 목록 (드롭다운용) +router.get("/equipments", ctrl.getEquipmentList); + +// 품목 목록 (라우팅 등록된 품목만) +router.get("/items", ctrl.getItemsForRouting); + +// 전체 품목 검색 (등록 모달용) +router.get("/items/search-all", ctrl.searchAllItems); + +// 라우팅 버전 +router.get("/routing-versions/:itemCode", ctrl.getRoutingVersions); +router.post("/routing-versions", ctrl.createRoutingVersion); +router.delete("/routing-versions/:id", ctrl.deleteRoutingVersion); + +// 라우팅 상세 +router.get("/routing-details/:versionId", ctrl.getRoutingDetails); +router.put("/routing-details/:versionId", ctrl.saveRoutingDetails); + +// BOM 구성 자재 조회 +router.get("/bom-materials/:itemCode", ctrl.getBomMaterials); + +export default router; diff --git a/backend-node/src/routes/receivingRoutes.ts b/backend-node/src/routes/receivingRoutes.ts new file mode 100644 index 00000000..0b5a5c13 --- /dev/null +++ b/backend-node/src/routes/receivingRoutes.ts @@ -0,0 +1,40 @@ +/** + * 입고관리 라우트 + */ + +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as receivingController from "../controllers/receivingController"; + +const router = Router(); + +router.use(authenticateToken); + +// 입고 목록 조회 +router.get("/list", receivingController.getList); + +// 입고번호 자동생성 +router.get("/generate-number", receivingController.generateNumber); + +// 창고 목록 조회 +router.get("/warehouses", receivingController.getWarehouses); + +// 소스 데이터: 발주 (구매입고) +router.get("/source/purchase-orders", receivingController.getPurchaseOrders); + +// 소스 데이터: 출하 (반품입고) +router.get("/source/shipments", receivingController.getShipments); + +// 소스 데이터: 품목 (기타입고) +router.get("/source/items", receivingController.getItems); + +// 입고 등록 +router.post("/", receivingController.create); + +// 입고 수정 +router.put("/:id", receivingController.update); + +// 입고 삭제 +router.delete("/:id", receivingController.deleteReceiving); + +export default router; diff --git a/backend-node/src/routes/salesReportRoutes.ts b/backend-node/src/routes/salesReportRoutes.ts new file mode 100644 index 00000000..d5c7e565 --- /dev/null +++ b/backend-node/src/routes/salesReportRoutes.ts @@ -0,0 +1,12 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { getSalesReportData } from "../controllers/salesReportController"; + +const router = Router(); + +router.use(authenticateToken); + +// 영업 리포트 원본 데이터 조회 +router.get("/data", getSalesReportData); + +export default router; diff --git a/backend-node/src/routes/shippingOrderRoutes.ts b/backend-node/src/routes/shippingOrderRoutes.ts new file mode 100644 index 00000000..d22ee8be --- /dev/null +++ b/backend-node/src/routes/shippingOrderRoutes.ts @@ -0,0 +1,21 @@ +/** + * 출하지시 라우트 + */ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as shippingOrderController from "../controllers/shippingOrderController"; + +const router = Router(); +router.use(authenticateToken); + +router.get("/list", shippingOrderController.getList); +router.get("/preview-no", shippingOrderController.previewNextNo); +router.post("/save", shippingOrderController.save); +router.post("/delete", shippingOrderController.remove); + +// 모달 왼쪽 패널 데이터 소스 +router.get("/source/shipment-plan", shippingOrderController.getShipmentPlanSource); +router.get("/source/sales-order", shippingOrderController.getSalesOrderSource); +router.get("/source/item", shippingOrderController.getItemSource); + +export default router; diff --git a/backend-node/src/routes/shippingPlanRoutes.ts b/backend-node/src/routes/shippingPlanRoutes.ts index 16ff0050..2bd8e822 100644 --- a/backend-node/src/routes/shippingPlanRoutes.ts +++ b/backend-node/src/routes/shippingPlanRoutes.ts @@ -10,10 +10,16 @@ const router = Router(); router.use(authenticateToken); +// 출하계획 목록 조회 (관리 화면용) +router.get("/list", shippingPlanController.getList); + // 품목별 집계 + 기존 출하계획 조회 router.get("/aggregate", shippingPlanController.getAggregate); // 출하계획 일괄 저장 router.post("/batch", shippingPlanController.batchSave); +// 출하계획 단건 수정 +router.put("/:id", shippingPlanController.updatePlan); + export default router; diff --git a/backend-node/src/routes/workInstructionRoutes.ts b/backend-node/src/routes/workInstructionRoutes.ts new file mode 100644 index 00000000..a65f6f54 --- /dev/null +++ b/backend-node/src/routes/workInstructionRoutes.ts @@ -0,0 +1,26 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as ctrl from "../controllers/workInstructionController"; + +const router = Router(); +router.use(authenticateToken); + +router.get("/list", ctrl.getList); +router.get("/preview-no", ctrl.previewNextNo); +router.post("/save", ctrl.save); +router.post("/delete", ctrl.remove); +router.get("/source/item", ctrl.getItemSource); +router.get("/source/sales-order", ctrl.getSalesOrderSource); +router.get("/source/production-plan", ctrl.getProductionPlanSource); +router.get("/equipment", ctrl.getEquipmentList); +router.get("/employees", ctrl.getEmployeeList); + +// 라우팅 & 공정작업기준 +router.get("/:wiNo/routing-versions/:itemCode", ctrl.getRoutingVersions); +router.put("/:wiNo/routing", ctrl.updateRouting); +router.get("/:wiNo/work-standard", ctrl.getWorkStandard); +router.post("/:wiNo/work-standard/copy", ctrl.copyWorkStandard); +router.put("/:wiNo/work-standard/save", ctrl.saveWorkStandard); +router.delete("/:wiNo/work-standard/reset", ctrl.resetWorkStandard); + +export default router; diff --git a/backend-node/src/services/batchSchedulerService.ts b/backend-node/src/services/batchSchedulerService.ts index f6fe56a1..8feba9d9 100644 --- a/backend-node/src/services/batchSchedulerService.ts +++ b/backend-node/src/services/batchSchedulerService.ts @@ -122,20 +122,22 @@ export class BatchSchedulerService { } /** - * 배치 설정 실행 + * 배치 설정 실행 - execution_type에 따라 매핑 또는 노드 플로우 실행 */ static async executeBatchConfig(config: any) { const startTime = new Date(); let executionLog: any = null; try { - logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id})`); + logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id}, type: ${config.execution_type || "mapping"})`); - // 매핑 정보가 없으면 상세 조회로 다시 가져오기 - if (!config.batch_mappings || config.batch_mappings.length === 0) { - const fullConfig = await BatchService.getBatchConfigById(config.id); - if (fullConfig.success && fullConfig.data) { - config = fullConfig.data; + // 상세 조회 (매핑 또는 노드플로우 정보가 없을 수 있음) + if (!config.execution_type || config.execution_type === "mapping") { + if (!config.batch_mappings || config.batch_mappings.length === 0) { + const fullConfig = await BatchService.getBatchConfigById(config.id); + if (fullConfig.success && fullConfig.data) { + config = fullConfig.data; + } } } @@ -165,12 +167,17 @@ export class BatchSchedulerService { executionLog = executionLogResponse.data; - // 실제 배치 실행 로직 (수동 실행과 동일한 로직 사용) - const result = await this.executeBatchMappings(config); + let result: { totalRecords: number; successRecords: number; failedRecords: number }; + + if (config.execution_type === "node_flow") { + result = await this.executeNodeFlow(config); + } else { + result = await this.executeBatchMappings(config); + } // 실행 로그 업데이트 (성공) await BatchExecutionLogService.updateExecutionLog(executionLog.id, { - execution_status: "SUCCESS", + execution_status: result.failedRecords > 0 ? "PARTIAL" : "SUCCESS", end_time: new Date(), duration_ms: Date.now() - startTime.getTime(), total_records: result.totalRecords, @@ -182,12 +189,10 @@ export class BatchSchedulerService { `배치 실행 완료: ${config.batch_name} (처리된 레코드: ${result.totalRecords})` ); - // 성공 결과 반환 return result; } catch (error) { logger.error(`배치 실행 중 오류 발생: ${config.batch_name}`, error); - // 실행 로그 업데이트 (실패) if (executionLog) { await BatchExecutionLogService.updateExecutionLog(executionLog.id, { execution_status: "FAILED", @@ -198,7 +203,6 @@ export class BatchSchedulerService { }); } - // 실패 결과 반환 return { totalRecords: 0, successRecords: 0, @@ -207,6 +211,43 @@ export class BatchSchedulerService { } } + /** + * 노드 플로우 실행 - NodeFlowExecutionService에 위임 + */ + private static async executeNodeFlow(config: any) { + if (!config.node_flow_id) { + throw new Error("노드 플로우 ID가 설정되지 않았습니다."); + } + + const { NodeFlowExecutionService } = await import( + "./nodeFlowExecutionService" + ); + + const contextData: Record = { + companyCode: config.company_code, + batchConfigId: config.id, + batchName: config.batch_name, + executionSource: "batch_scheduler", + ...(config.node_flow_context || {}), + }; + + logger.info( + `노드 플로우 실행: flowId=${config.node_flow_id}, batch=${config.batch_name}` + ); + + const flowResult = await NodeFlowExecutionService.executeFlow( + config.node_flow_id, + contextData + ); + + // 노드 플로우 실행 결과를 배치 로그 형식으로 변환 + return { + totalRecords: flowResult.summary.total, + successRecords: flowResult.summary.success, + failedRecords: flowResult.summary.failed, + }; + } + /** * 배치 매핑 실행 (수동 실행과 동일한 로직) */ diff --git a/backend-node/src/services/batchService.ts b/backend-node/src/services/batchService.ts index 31ee2001..c8b6ecbe 100644 --- a/backend-node/src/services/batchService.ts +++ b/backend-node/src/services/batchService.ts @@ -72,9 +72,12 @@ export class BatchService { const total = parseInt(countResult[0].count); const totalPages = Math.ceil(total / limit); - // 목록 조회 + // 목록 조회 (최근 실행 정보 포함) const configs = await query( - `SELECT bc.* + `SELECT bc.*, + (SELECT bel.execution_status FROM batch_execution_logs bel WHERE bel.batch_config_id = bc.id ORDER BY bel.start_time DESC LIMIT 1) as last_status, + (SELECT bel.start_time FROM batch_execution_logs bel WHERE bel.batch_config_id = bc.id ORDER BY bel.start_time DESC LIMIT 1) as last_executed_at, + (SELECT bel.total_records FROM batch_execution_logs bel WHERE bel.batch_config_id = bc.id ORDER BY bel.start_time DESC LIMIT 1) as last_total_records FROM batch_configs bc ${whereClause} ORDER BY bc.created_date DESC @@ -82,9 +85,6 @@ export class BatchService { [...values, limit, offset] ); - // 매핑 정보 조회 (N+1 문제 해결을 위해 별도 쿼리 대신 여기서는 생략하고 상세 조회에서 처리) - // 하지만 목록에서도 간단한 정보는 필요할 수 있음 - return { success: true, data: configs as BatchConfig[], @@ -176,8 +176,8 @@ export class BatchService { // 배치 설정 생성 const batchConfigResult = await client.query( `INSERT INTO batch_configs - (batch_name, description, cron_schedule, is_active, company_code, save_mode, conflict_key, auth_service_name, data_array_path, created_by, created_date, updated_date) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW()) + (batch_name, description, cron_schedule, is_active, company_code, save_mode, conflict_key, auth_service_name, data_array_path, execution_type, node_flow_id, node_flow_context, created_by, created_date, updated_date) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), NOW()) RETURNING *`, [ data.batchName, @@ -189,6 +189,9 @@ export class BatchService { data.conflictKey || null, data.authServiceName || null, data.dataArrayPath || null, + data.executionType || "mapping", + data.nodeFlowId || null, + data.nodeFlowContext ? JSON.stringify(data.nodeFlowContext) : null, userId, ] ); @@ -332,6 +335,22 @@ export class BatchService { updateFields.push(`data_array_path = $${paramIndex++}`); updateValues.push(data.dataArrayPath || null); } + if (data.executionType !== undefined) { + updateFields.push(`execution_type = $${paramIndex++}`); + updateValues.push(data.executionType); + } + if (data.nodeFlowId !== undefined) { + updateFields.push(`node_flow_id = $${paramIndex++}`); + updateValues.push(data.nodeFlowId || null); + } + if (data.nodeFlowContext !== undefined) { + updateFields.push(`node_flow_context = $${paramIndex++}`); + updateValues.push( + data.nodeFlowContext + ? JSON.stringify(data.nodeFlowContext) + : null + ); + } // 배치 설정 업데이트 const batchConfigResult = await client.query( diff --git a/backend-node/src/types/batchTypes.ts b/backend-node/src/types/batchTypes.ts index a6404036..9933194b 100644 --- a/backend-node/src/types/batchTypes.ts +++ b/backend-node/src/types/batchTypes.ts @@ -79,6 +79,9 @@ export interface BatchMapping { created_date?: Date; } +// 배치 실행 타입: 기존 매핑 방식 또는 노드 플로우 실행 +export type BatchExecutionType = "mapping" | "node_flow"; + // 배치 설정 타입 export interface BatchConfig { id?: number; @@ -87,15 +90,21 @@ export interface BatchConfig { cron_schedule: string; is_active: "Y" | "N"; company_code?: string; - save_mode?: "INSERT" | "UPSERT"; // 저장 모드 (기본: INSERT) - conflict_key?: string; // UPSERT 시 충돌 기준 컬럼명 - auth_service_name?: string; // REST API 인증에 사용할 토큰 서비스명 - data_array_path?: string; // REST API 응답에서 데이터 배열 경로 (예: response, data.items) + save_mode?: "INSERT" | "UPSERT"; + conflict_key?: string; + auth_service_name?: string; + data_array_path?: string; + execution_type?: BatchExecutionType; + node_flow_id?: number; + node_flow_context?: Record; created_by?: string; created_date?: Date; updated_by?: string; updated_date?: Date; batch_mappings?: BatchMapping[]; + last_status?: string; + last_executed_at?: string; + last_total_records?: number; } export interface BatchConnectionInfo { @@ -149,7 +158,10 @@ export interface CreateBatchConfigRequest { saveMode?: "INSERT" | "UPSERT"; conflictKey?: string; authServiceName?: string; - dataArrayPath?: string; // REST API 응답에서 데이터 배열 경로 + dataArrayPath?: string; + executionType?: BatchExecutionType; + nodeFlowId?: number; + nodeFlowContext?: Record; mappings: BatchMappingRequest[]; } @@ -161,7 +173,10 @@ export interface UpdateBatchConfigRequest { saveMode?: "INSERT" | "UPSERT"; conflictKey?: string; authServiceName?: string; - dataArrayPath?: string; // REST API 응답에서 데이터 배열 경로 + dataArrayPath?: string; + executionType?: BatchExecutionType; + nodeFlowId?: number; + nodeFlowContext?: Record; mappings?: BatchMappingRequest[]; } diff --git a/docs/kjs/배치_노드플로우_연동_계획서.md b/docs/kjs/배치_노드플로우_연동_계획서.md new file mode 100644 index 00000000..97630229 --- /dev/null +++ b/docs/kjs/배치_노드플로우_연동_계획서.md @@ -0,0 +1,909 @@ +# 배치 스케줄러 + 노드 플로우 연동 계획서 + +## 1. 배경 및 목적 + +### 현재 상태 + +현재 시스템에는 두 개의 독립적인 실행 엔진이 있다: + +| 시스템 | 역할 | 트리거 방식 | +|--------|------|-------------| +| **배치 스케줄러** | Cron 기반 자동 실행 (데이터 복사만 가능) | 시간 기반 (node-cron) | +| **노드 플로우 엔진** | 조건/변환/INSERT/UPDATE/DELETE 등 복합 로직 | 버튼 클릭 (수동) | + +### 문제 + +- 배치는 **INSERT/UPSERT만** 가능하고, 조건 기반 UPDATE/DELETE를 못 함 +- 노드 플로우는 강력하지만 **수동 실행만** 가능 (버튼 클릭 필수) +- "퇴사일이 지나면 자동으로 퇴사 처리" 같은 **시간 기반 비즈니스 로직**을 구현할 수 없음 + +### 목표 + +배치 스케줄러가 노드 플로우를 자동 실행할 수 있도록 연동하여, +시간 기반 비즈니스 로직 자동화를 지원한다. + +``` +[배치 스케줄러] ──Cron 트리거──> [노드 플로우 실행 엔진] + │ │ + │ ├── 테이블 소스 조회 + │ ├── 조건 분기 + │ ├── UPDATE / DELETE / INSERT + │ ├── 이메일 발송 + │ └── 로깅 + │ + └── 실행 로그 기록 (batch_execution_logs) +``` + +--- + +## 2. 사용 시나리오 + +### 시나리오 A: 자동 퇴사 처리 + +``` +매일 자정 실행: + 1. user_info에서 퇴사일 <= NOW() AND 상태 != '퇴사' 인 사람 조회 + 2. 해당 사용자의 상태를 '퇴사'로 UPDATE + 3. 관리자에게 이메일 알림 발송 +``` + +### 시나리오 B: 월말 재고 마감 + +``` +매월 1일 00:00 실행: + 1. 전월 재고 데이터를 재고마감 테이블로 INSERT + 2. 이월 수량 계산 후 UPDATE +``` + +### 시나리오 C: 미납 알림 + +``` +매일 09:00 실행: + 1. 납기일이 지난 미납 주문 조회 + 2. 담당자에게 이메일 발송 + 3. 알림 로그 INSERT +``` + +### 시나리오 D: 외부 API 연동 자동화 + +``` +매시간 실행: + 1. 외부 REST API에서 데이터 조회 + 2. 조건 필터링 (변경된 데이터만) + 3. 내부 테이블에 UPSERT +``` + +--- + +## 3. 구현 범위 + +### 3.1 DB 변경 (batch_configs 테이블 확장) + +```sql +-- batch_configs 테이블에 컬럼 추가 +ALTER TABLE batch_configs + ADD COLUMN execution_type VARCHAR(20) DEFAULT 'mapping', + ADD COLUMN node_flow_id INTEGER DEFAULT NULL, + ADD COLUMN node_flow_context JSONB DEFAULT NULL; + +-- execution_type: 'mapping' (기존 데이터 복사) | 'node_flow' (노드 플로우 실행) +-- node_flow_id: node_flows 테이블의 flow_id (FK) +-- node_flow_context: 플로우 실행 시 전달할 컨텍스트 데이터 (선택) + +COMMENT ON COLUMN batch_configs.execution_type IS '실행 타입: mapping(기존 데이터 복사), node_flow(노드 플로우 실행)'; +COMMENT ON COLUMN batch_configs.node_flow_id IS '연결된 노드 플로우 ID (execution_type이 node_flow일 때 사용)'; +COMMENT ON COLUMN batch_configs.node_flow_context IS '플로우 실행 시 전달할 컨텍스트 데이터 (JSON)'; +``` + +기존 데이터에 영향 없음 (`DEFAULT 'mapping'`으로 하위 호환성 보장) + +### 3.2 백엔드 변경 + +#### BatchSchedulerService 수정 (핵심) + +`executeBatchConfig()` 메서드에서 `execution_type` 분기: + +``` +executeBatchConfig(config) + ├── config.execution_type === 'mapping' + │ └── 기존 executeBatchMappings() (변경 없음) + │ + └── config.execution_type === 'node_flow' + └── NodeFlowExecutionService.executeFlow() + ├── 노드 플로우 조회 + ├── 위상 정렬 + ├── 레벨별 실행 + └── 결과 반환 +``` + +수정 파일: +- `backend-node/src/services/batchSchedulerService.ts` + - `executeBatchConfig()` 에 node_flow 분기 추가 + - 노드 플로우 실행 결과를 배치 로그 형식으로 변환 + +#### 배치 설정 API 수정 + +수정 파일: +- `backend-node/src/types/batchTypes.ts` + - `BatchConfig` 인터페이스에 `execution_type`, `node_flow_id`, `node_flow_context` 추가 + - `CreateBatchConfigRequest`, `UpdateBatchConfigRequest` 에도 추가 +- `backend-node/src/services/batchService.ts` + - `createBatchConfig()` - 새 필드 INSERT + - `updateBatchConfig()` - 새 필드 UPDATE +- `backend-node/src/controllers/batchManagementController.ts` + - 생성/수정 시 새 필드 처리 + +#### 노드 플로우 목록 API (배치용) + +추가 파일/수정: +- `backend-node/src/routes/batchManagementRoutes.ts` + - `GET /api/batch-management/node-flows` 추가 (배치 설정 UI에서 플로우 선택용) + +### 3.3 프론트엔드 변경 + +#### 배치 생성/편집 UI 수정 + +수정 파일: +- `frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx` +- `frontend/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page.tsx` + +변경 내용: +- "실행 타입" 선택 추가 (기존 매핑 / 노드 플로우) +- 노드 플로우 선택 시: 플로우 드롭다운 표시 (기존 매핑 설정 숨김) +- 노드 플로우 선택 시: 컨텍스트 데이터 입력 (선택사항, JSON) + +``` +┌─────────────────────────────────────────┐ +│ 배치 설정 │ +├─────────────────────────────────────────┤ +│ 배치명: [자동 퇴사 처리 ] │ +│ 설명: [퇴사일 경과 사용자 자동 처리] │ +│ Cron: [0 0 * * * ] │ +│ │ +│ 실행 타입: ○ 데이터 매핑 ● 노드 플로우 │ +│ │ +│ ┌─ 노드 플로우 선택 ─────────────────┐ │ +│ │ [▾ 자동 퇴사 처리 플로우 ] │ │ +│ │ │ │ +│ │ 플로우 설명: user_info에서 퇴사일..│ │ +│ │ 노드 수: 4개 │ │ +│ └────────────────────────────────────┘ │ +│ │ +│ [취소] [저장] │ +└─────────────────────────────────────────┘ +``` + +#### 배치 목록 UI - Ops 대시보드 리디자인 + +현재 배치 목록은 단순 테이블인데, Vercel/Railway 스타일의 **운영 대시보드**로 전면 리디자인한다. +노드 플로우 연동과 함께 적용하면 새로운 실행 타입도 자연스럽게 표현 가능. + +디자인 컨셉: **"편집기"가 아닌 "운영 대시보드"** +- 데이터 타입 관리 = 컬럼 편집기 → 3패널(리스트/그리드/설정)이 적합 +- 배치 관리 = 운영 모니터링 → 테이블 + 인라인 상태 표시가 적합 +- 역할이 다르면 레이아웃도 달라야 함 + +--- + +##### 전체 레이아웃 + +``` +┌──────────────────────────────────────────────────────────────┐ +│ [헤더] 배치 관리 [새로고침] [새 배치] │ +│ └ 데이터 동기화 배치 작업을 모니터링하고 관리합니다 │ +├──────────────────────────────────────────────────────────────┤ +│ [통계 카드 4열 그리드] │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ 전체 배치 │ │ 활성 배치 │ │ 오늘 실행 │ │ 오늘 실패 │ │ +│ │ 8 │ │ 6 │ │ 142 │ │ 3 │ │ +│ │ +2 이번달│ │ 2 비활성 │ │+12% 전일 │ │+1 전일 │ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +├──────────────────────────────────────────────────────────────┤ +│ [툴바] │ +│ 🔍 검색... [전체|활성|비활성] [전체|DB-DB|API-DB|플로우] 총 8건 │ +├──────────────────────────────────────────────────────────────┤ +│ [테이블 헤더] │ +│ ● 배치 타입 스케줄 최근24h 마지막실행 │ +├──────────────────────────────────────────────────────────────┤ +│ ● 품목 마스터 동기화 DB→DB */30**** ▌▌▌▐▌▌▌ 14:30 ▶✎🗑 │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ [확장 상세 패널 - 클릭 시 토글] │ │ +│ │ 내러티브 + 파이프라인 + 매핑 + 설정 + 타임라인 │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ ● 거래처 ERP 연동 API→DB 0*/2*** ▌▌▌▌▌▌▌ 14:00 ▶✎🗑 │ +│ ◉ 재고 현황 수집 API→DB 06,18** ▌▌▐▌▌▌░ 실행중 ▶✎🗑 │ +│ ○ BOM 백업 DB→DB 0 3**0 ░░░░░░░ 비활성 ▶✎🗑 │ +│ ... │ +└──────────────────────────────────────────────────────────────┘ +``` + +--- + +##### 1. 페이지 헤더 + +``` +구조: flex, align-items: flex-end, justify-content: space-between +하단 보더: 1px solid border +하단 마진: 24px + +좌측: + - 제목: "배치 관리" (text-xl font-extrabold tracking-tight) + - 부제: "데이터 동기화 배치 작업을 모니터링하고 관리합니다" (text-xs text-muted-foreground) + +우측 버튼 그룹 (gap-2): + - [새로고침] 버튼: variant="outline", RefreshCw 아이콘 + - [새 배치] 버튼: variant="default" (primary), Plus 아이콘 +``` + +--- + +##### 2. 통계 카드 영역 + +``` +레이아웃: grid grid-cols-4 gap-3 +각 카드: rounded-xl border bg-card p-4 + +카드 구조: + ┌──────────────────────────┐ + │ [라벨] [아이콘] │ ← stat-top: flex justify-between + │ │ + │ 숫자값 (28px 모노 볼드) │ ← stat-val: font-mono text-3xl font-extrabold + │ │ + │ [변화량 배지] 기간 텍스트 │ ← stat-footer: flex items-center gap-1.5 + └──────────────────────────┘ + +4개 카드 상세: +┌─────────────┬────────────┬───────────────────────────────┐ +│ 카드 │ 아이콘 색상 │ 값 색상 │ +├─────────────┼────────────┼───────────────────────────────┤ +│ 전체 배치 │ indigo bg │ foreground (기본) │ +│ 활성 배치 │ green bg │ green (--success) │ +│ 오늘 실행 │ cyan bg │ cyan (--info 계열) │ +│ 오늘 실패 │ red bg │ red (--destructive) │ +└─────────────┴────────────┴───────────────────────────────┘ + +변화량 배지: + - 증가: green 배경 + green 텍스트, "+N" 또는 "+N%" + - 감소/악화: red 배경 + red 텍스트 + - 크기: text-[10px] font-bold px-1.5 py-0.5 rounded + +아이콘 박스: 28x28px rounded-lg, 배경색 투명도 10% +아이콘: lucide-react (LayoutGrid, CheckCircle, Activity, XCircle) +``` + +**데이터 소스:** +``` +GET /api/batch-management/stats +→ { + totalBatches: number, // batch_configs COUNT(*) + activeBatches: number, // batch_configs WHERE is_active='Y' + todayExecutions: number, // batch_execution_logs WHERE DATE(start_time)=TODAY + todayFailures: number, // batch_execution_logs WHERE DATE(start_time)=TODAY AND status='FAILED' + // 선택사항: 전일 대비 변화량 + prevDayExecutions?: number, + prevDayFailures?: number + } +``` + +--- + +##### 3. 툴바 + +``` +레이아웃: flex items-center gap-2.5 + +요소 1 - 검색: + - 위치: 좌측, flex-1 max-w-[320px] + - 구조: relative div + input + Search 아이콘(absolute left) + - input: h-9, rounded-lg, border, bg-card, text-xs + - placeholder: "배치 이름으로 검색..." + - focus: ring-2 ring-primary + +요소 2 - 상태 필터 (pill-group): + - 컨테이너: flex gap-0.5, bg-card, border, rounded-lg, p-0.5 + - 각 pill: text-[11px] font-semibold px-3 py-1.5 rounded-md + - 활성 pill: bg-primary/10 text-primary + - 비활성 pill: text-muted-foreground, hover시 밝아짐 + - 항목: [전체] [활성] [비활성] + +요소 3 - 타입 필터 (pill-group): + - 동일 스타일 + - 항목: [전체] [DB-DB] [API-DB] [노드 플로우] ← 노드 플로우는 신규 + +요소 4 - 건수 표시: + - 위치: ml-auto (우측 정렬) + - 텍스트: "총 N건" (text-[11px] text-muted-foreground, N은 font-bold) +``` + +--- + +##### 4. 배치 테이블 + +``` +컨테이너: border rounded-xl overflow-hidden bg-card + +테이블 헤더: + - 배경: bg-muted/50 + - 높이: 40px + - 글자: text-[10px] font-bold text-muted-foreground uppercase tracking-wider + - 그리드 컬럼: 44px 1fr 100px 130px 160px 100px 120px + - 컬럼: [LED] [배치] [타입] [스케줄] [최근 24h] [마지막 실행] [액션] +``` + +--- + +##### 5. 배치 테이블 행 (핵심) + +``` +그리드: 44px 1fr 100px 130px 160px 100px 120px +높이: min-height 60px +하단 보더: 1px solid border +hover: bg-card/80 (약간 밝아짐) +선택됨: bg-primary/10 + 좌측 3px primary 박스 섀도우 (inset) +클릭 시: 상세 패널 토글 + +[셀 1] LED 상태 표시: + ┌──────────────────────────────────────┐ + │ 원형 8x8px, 센터 정렬 │ + │ │ + │ 활성(on): green + box-shadow glow │ + │ 실행중(run): amber + 1.5s blink 애니 │ + │ 비활성(off): muted-foreground (회색) │ + │ 에러(err): red + box-shadow glow │ + └──────────────────────────────────────┘ + +[셀 2] 배치 정보: + ┌──────────────────────────────────────┐ + │ 배치명: text-[13px] font-bold │ + │ 설명: text-[10px] text-muted-fg │ + │ overflow ellipsis (1줄) │ + │ │ + │ 비활성 배치: 배치명도 muted 색상 │ + └──────────────────────────────────────┘ + +[셀 3] 타입 배지: + ┌──────────────────────────────────────┐ + │ inline-flex, text-[10px] font-bold │ + │ px-2 py-0.5 rounded-[5px] │ + │ │ + │ DB → DB: cyan 배경/텍스트 │ + │ API → DB: violet 배경/텍스트 │ + │ 노드 플로우: indigo 배경/텍스트 (신규) │ + └──────────────────────────────────────┘ + +[셀 4] Cron 스케줄: + ┌──────────────────────────────────────┐ + │ Cron 표현식: font-mono text-[11px] │ + │ font-medium │ + │ 한글 설명: text-[9px] text-muted │ + │ "매 30분", "매일 01:00" │ + │ │ + │ 비활성: muted 색상 │ + └──────────────────────────────────────┘ + + Cron → 한글 변환 예시: + - */30 * * * * → "매 30분" + - 0 */2 * * * → "매 2시간" + - 0 6,18 * * * → "06:00, 18:00" + - 0 1 * * * → "매일 01:00" + - 0 3 * * 0 → "매주 일 03:00" + - 0 0 1 * * → "매월 1일 00:00" + +[셀 5] 스파크라인 (최근 24h): + ┌──────────────────────────────────────┐ + │ flex, items-end, gap-[1px], h-6 │ + │ │ + │ 24개 바 (시간당 1개): │ + │ - 성공(ok): green, opacity 60% │ + │ - 실패(fail): red, opacity 80% │ + │ - 미실행(none): muted, opacity 15% │ + │ │ + │ 각 바: flex-1, min-w-[3px] │ + │ rounded-t-[1px] │ + │ 높이: 실행시간 비례 또는 고정 │ + │ hover: opacity 100% │ + └──────────────────────────────────────┘ + + 데이터: 최근 24시간을 1시간 단위로 슬라이싱 + 각 슬롯별 가장 최근 실행의 status 사용 + 높이: 성공=80~95%, 실패=20~40%, 미실행=5% + +[셀 6] 마지막 실행: + ┌──────────────────────────────────────┐ + │ 시간: font-mono text-[10px] │ + │ "14:30:00" │ + │ 경과: text-[9px] muted │ + │ "12분 전" │ + │ │ + │ 실행 중: amber 색상 "실행 중..." │ + │ 비활성: muted "-" + "비활성" │ + └──────────────────────────────────────┘ + +[셀 7] 액션 버튼: + ┌──────────────────────────────────────┐ + │ flex gap-1, justify-end │ + │ │ + │ 3개 아이콘 버튼 (28x28 rounded-md): │ + │ │ + │ [▶] 수동 실행 │ + │ hover: green 테두리+배경+아이콘 │ + │ 아이콘: Play (lucide) │ + │ │ + │ [✎] 편집 │ + │ hover: 기본 밝아짐 │ + │ 아이콘: Pencil (lucide) │ + │ │ + │ [🗑] 삭제 │ + │ hover: red 테두리+배경+아이콘 │ + │ 아이콘: Trash2 (lucide) │ + └──────────────────────────────────────┘ +``` + +--- + +##### 6. 행 확장 상세 패널 (클릭 시 토글) + +행을 클릭하면 아래로 펼쳐지는 상세 패널. 매핑 타입과 노드 플로우 타입에 따라 내용이 달라진다. + +``` +컨테이너: + - border (상단 border 없음, 행과 이어짐) + - rounded-b-xl + - bg-muted/30 (행보다 약간 어두운 배경) + - padding: 20px 24px + +내부 구조: + ┌────────────────────────────────────────────────────────────┐ + │ [내러티브 박스] │ + │ "ERP_SOURCE DB의 item_master 테이블에서 현재 DB의 │ + │ item_info 테이블로 12개 컬럼을 매 30분마다 동기화하고 │ + │ 있어요. 오늘 48회 실행, 마지막 실행은 12분 전이에요." │ + ├────────────────────────────────────────────────────────────┤ + │ [파이프라인 플로우 다이어그램] │ + │ │ + │ ┌─────────────┐ 12 컬럼 UPSERT ┌─────────────┐ │ + │ │ 🗄 DB아이콘 │ ─────────────────→ │ 🗄 DB아이콘 │ │ + │ │ ERP_SOURCE │ WHERE USE_YN='Y' │ 현재 DB │ │ + │ │ item_master │ │ item_info │ │ + │ └─────────────┘ └─────────────┘ │ + ├──────────────────────┬─────────────────────────────────────┤ + │ [좌측: 매핑 + 설정] │ [우측: 실행 이력 타임라인] │ + │ │ │ + │ --- 컬럼 매핑 (12) --- │ --- 실행 이력 (최근 5건) --- │ + │ ITEM_CD → item_code PK│ ● 14:30:00 [성공] 1,842건 3.2s │ + │ ITEM_NM → item_name │ │ │ + │ ITEM_SPEC → spec... │ ● 14:00:00 [성공] 1,840건 3.1s │ + │ UNIT_CD → unit_code │ │ │ + │ STD_PRICE → std_price │ ✕ 13:30:00 [실패] Timeout │ + │ + 7개 더 보기 │ │ │ + │ │ ● 13:00:00 [성공] 1,838건 2.9s │ + │ --- 설정 --- │ │ │ + │ 배치 크기: 500 │ ● 12:30:00 [성공] 1,835건 3.5s │ + │ 타임아웃: 30s │ │ + │ 실패 시: 3회 재시도 │ │ + │ 매칭 키: item_code │ │ + │ 모드: [UPSERT] │ │ + └──────────────────────┴─────────────────────────────────────┘ +``` + +**6-1. 내러티브 박스 (Toss 스타일 자연어 설명)** + +``` +스타일: + - rounded-lg + - 배경: linear-gradient(135deg, primary/6%, info/4%) + - 보더: 1px solid primary/8% + - padding: 12px 14px + - margin-bottom: 16px + +텍스트: text-[11px] text-muted-foreground leading-relaxed +강조 텍스트: + - 굵은 텍스트(b): foreground font-semibold + - 하이라이트(hl): primary font-bold + +매핑 타입 예시: + "ERP_SOURCE 데이터베이스의 item_master 테이블에서 현재 DB의 + item_info 테이블로 12개 컬럼을 매 30분마다 동기화하고 있어요. + 오늘 48회 실행, 마지막 실행은 12분 전이에요." + +노드 플로우 타입 예시: + "자동 퇴사 처리 노드 플로우를 매일 00:00에 실행하고 있어요. + user_info 테이블에서 퇴사일이 지난 사용자를 조회하여 + 상태를 '퇴사'로 변경합니다. 4개 노드로 구성되어 있어요." +``` + +**6-2. 파이프라인 플로우 다이어그램** + +``` +컨테이너: + - flex items-center + - rounded-lg border bg-card p-4 + - margin-bottom: 16px + +구조: [소스 노드] ──[커넥터]──> [타겟 노드] + +소스 노드 (pipe-node src): + - 배경: cyan/6%, 보더: cyan/12% + - 아이콘: 32x32 rounded-lg, cyan/12% 배경 + - DB 타입: Database 아이콘 (lucide) + - API 타입: Cloud 아이콘 (lucide) + violet 색상 + - 이름: text-xs font-bold cyan 색상 + - 부제: font-mono text-[10px] muted (테이블명/URL) + +커넥터 (pipe-connector): + - flex-1, flex-col items-center + - 상단 라벨: text-[9px] font-bold muted ("12 컬럼 UPSERT") + - 라인: width 100%, h-[2px], gradient(cyan → green) + - 라인 끝: 삼각형 화살표 (CSS ::after) + - 하단 라벨: text-[9px] font-bold muted ("WHERE USE_YN='Y'") + +타겟 노드 (pipe-node tgt): + - 배경: green/6%, 보더: green/12% + - 아이콘: green/12% 배경 + - 이름: text-xs font-bold green 색상 + - 부제: 테이블명 + +노드 플로우 타입일 때: + - 소스/타겟 대신 노드 플로우 요약 카드로 대체 + - 아이콘: Workflow 아이콘 (lucide) + indigo 색상 + - 이름: 플로우명 + - 부제: "N개 노드 | 조건 분기 포함" + - 노드 목록: 간략 리스트 (Source → Condition → Update → Email) +``` + +**6-3. 하단 2열 그리드** + +``` +레이아웃: grid grid-cols-2 gap-5 + +[좌측 컬럼] 매핑 + 설정: + + 섹션 1 - 컬럼 매핑: + 헤더: flex items-center gap-1.5 + - Link 아이콘 (lucide, 13px, muted) + - "컬럼 매핑" (text-[11px] font-bold muted) + - 건수 배지 (ml-auto, text-[9px] font-bold, primary/10% bg, primary 색) + + 매핑 행 (map-row): + - flex items-center gap-1.5 + - rounded-md border bg-card px-2.5 py-1.5 + - margin-bottom: 2px + + 구조: [소스 컬럼] → [타겟 컬럼] [태그] + 소스: font-mono font-semibold text-[11px] cyan + 화살표: "→" muted + 타겟: font-mono font-semibold text-[11px] green + 태그: text-[8px] font-bold px-1.5 py-0.5 rounded-sm + PK = green 배경 + dark 텍스트 + + 5개까지 표시 후 "+ N개 더 보기" 접기/펼치기 + + 노드 플로우 타입일 때: + 매핑 대신 "노드 구성" 섹션으로 대체 + 각 행: [노드 아이콘] [노드 타입] [노드 설명] + 예: 🔍 테이블 소스 | user_info 조회 + 🔀 조건 분기 | 퇴사일 <= NOW() + ✏️ UPDATE | status → '퇴사' + 📧 이메일 | 관리자 알림 + + 섹션 2 - 설정 (cprop 리스트): + 헤더: Settings 아이콘 + "설정" + + 각 행 (cprop): + - flex justify-between py-1.5 + - 하단 보더: 1px solid white/3% + - 키: text-[11px] muted + - 값: text-[11px] font-semibold, mono체는 font-mono text-[10px] + - 특수 값: UPSERT 배지 (green/10% bg, green 색, text-[10px] font-bold) + + 매핑 타입 설정: + - 배치 크기: 500 + - 타임아웃: 30s + - 실패 시 재시도: 3회 (green) + - 매칭 키: item_code (mono) + - 모드: [UPSERT] (배지) + + 노드 플로우 타입 설정: + - 플로우 ID: 42 + - 노드 수: 4개 + - 실행 타임아웃: 60s + - 컨텍스트: { ... } (mono, 접기 가능) + + +[우측 컬럼] 실행 이력 타임라인: + + 헤더: Clock 아이콘 + "실행 이력" + "최근 5건" 배지 (green) + + 타임라인 (timeline): + flex-col, gap-0 + + 각 항목 (tl-item): + - flex items-start gap-3 + - padding: 10px 0 + - 하단 보더: 1px solid white/3% + + 좌측 - 점+선 (tl-dot-wrap): + - flex-col items-center, width 16px + - 점 (tl-dot): 8x8 rounded-full + 성공(ok): green + 실패(fail): red + 실행중(run): amber + blink 애니메이션 + - 선 (tl-line): width 1px, bg border, min-h 12px + 마지막 항목에는 선 없음 + + 우측 - 내용 (tl-body): + - 시간: font-mono text-[10px] font-semibold + - 상태 배지: text-[9px] font-bold px-1.5 py-0.5 rounded + 성공: green/10% bg + green 색 + 실패: red/10% bg + red 색 + - 메시지: text-[10px] muted, margin-top 2px + 성공: "1,842건 처리 / 3.2s 소요" + 실패: "Connection timeout: ERP_SOURCE 응답 없음" +``` + +--- + +##### 7. 반응형 대응 + +``` +1024px 이하 (태블릿): + - 통계 카드: grid-cols-2 + - 테이블 그리드: 36px 1fr 80px 110px 120px 80px (액션 숨김) + - 상세 패널 2열 그리드 → 1열 + +640px 이하 (모바일): + - 컨테이너 padding: 16px + - 통계 카드: grid-cols-2 gap-2 + - 테이블 헤더: 숨김 + - 테이블 행: grid-cols-1, 카드형태 (padding 16px, gap 8px) +``` + +--- + +##### 8. 필요한 백엔드 API + +``` +1. GET /api/batch-management/stats + → { + totalBatches: number, + activeBatches: number, + todayExecutions: number, + todayFailures: number, + prevDayExecutions?: number, + prevDayFailures?: number + } + 쿼리: batch_configs COUNT + batch_execution_logs 오늘/어제 집계 + +2. GET /api/batch-management/batch-configs/:id/sparkline + → [{ hour: 0~23, status: 'success'|'failed'|'none', count: number }] + 쿼리: batch_execution_logs WHERE batch_config_id=$1 + AND start_time >= NOW() - INTERVAL '24 hours' + GROUP BY EXTRACT(HOUR FROM start_time) + +3. GET /api/batch-management/batch-configs/:id/recent-logs?limit=5 + → [{ start_time, end_time, execution_status, total_records, + success_records, failed_records, error_message, duration_ms }] + 쿼리: batch_execution_logs WHERE batch_config_id=$1 + ORDER BY start_time DESC LIMIT $2 + +4. GET /api/batch-management/batch-configs (기존 수정) + → 각 배치에 sparkline 요약 + last_execution 포함하여 반환 + 목록 페이지에서 개별 sparkline API를 N번 호출하지 않도록 + 한번에 가져오기 (LEFT JOIN + 서브쿼리) +``` + +--- + +## 4. 변경 파일 목록 + +### DB + +| 파일 | 변경 | 설명 | +|------|------|------| +| `db/migrations/XXXX_batch_node_flow_integration.sql` | 신규 | ALTER TABLE batch_configs | + +### 백엔드 + +| 파일 | 변경 | 설명 | +|------|------|------| +| `backend-node/src/services/batchSchedulerService.ts` | 수정 | executeBatchConfig에 node_flow 분기 | +| `backend-node/src/types/batchTypes.ts` | 수정 | BatchConfig 타입에 새 필드 추가 | +| `backend-node/src/services/batchService.ts` | 수정 | create/update에 새 필드 처리 | +| `backend-node/src/controllers/batchManagementController.ts` | 수정 | 새 필드 API + stats/sparkline/recent-logs API | +| `backend-node/src/routes/batchManagementRoutes.ts` | 수정 | node-flows/stats/sparkline 엔드포인트 추가 | + +### 프론트엔드 + +| 파일 | 변경 | 설명 | +|------|------|------| +| `frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx` | **리디자인** | Ops 대시보드 스타일로 전면 재작성 | +| `frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx` | 수정 | 실행 타입 선택 + 플로우 선택 | +| `frontend/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page.tsx` | 수정 | 실행 타입 선택 + 플로우 선택 | + +--- + +## 5. 핵심 구현 상세 + +### 5.1 BatchSchedulerService 변경 (가장 중요) + +```typescript +// batchSchedulerService.ts - executeBatchConfig 메서드 수정 + +static async executeBatchConfig(config: any) { + const startTime = new Date(); + let executionLog: any = null; + + try { + // ... 실행 로그 생성 (기존 코드 유지) ... + + let result; + + // 실행 타입에 따라 분기 + if (config.execution_type === 'node_flow' && config.node_flow_id) { + // 노드 플로우 실행 + result = await this.executeNodeFlow(config); + } else { + // 기존 매핑 실행 (하위 호환) + result = await this.executeBatchMappings(config); + } + + // ... 실행 로그 업데이트 (기존 코드 유지) ... + return result; + } catch (error) { + // ... 에러 처리 (기존 코드 유지) ... + } +} + +/** + * 노드 플로우 실행 (신규) + */ +private static async executeNodeFlow(config: any) { + const { NodeFlowExecutionService } = await import('./nodeFlowExecutionService'); + + const context = { + sourceData: [], + dataSourceType: 'none', + nodeResults: new Map(), + executionOrder: [], + buttonContext: { + buttonId: `batch_${config.id}`, + companyCode: config.company_code, + userId: config.created_by || 'batch_system', + formData: config.node_flow_context || {}, + }, + }; + + const flowResult = await NodeFlowExecutionService.executeFlow( + config.node_flow_id, + context + ); + + // 노드 플로우 결과를 배치 로그 형식으로 변환 + return { + totalRecords: flowResult.totalNodes || 0, + successRecords: flowResult.successNodes || 0, + failedRecords: flowResult.failedNodes || 0, + }; +} +``` + +### 5.2 실행 결과 매핑 + +노드 플로우 결과 → 배치 로그 변환: + +| 노드 플로우 결과 | 배치 로그 필드 | 설명 | +|------------------|---------------|------| +| 전체 노드 수 | total_records | 실행 대상 노드 수 | +| 성공 노드 수 | success_records | 성공적으로 실행된 노드 | +| 실패 노드 수 | failed_records | 실패한 노드 | +| 에러 메시지 | error_message | 첫 번째 실패 노드의 에러 | + +### 5.3 보안 고려사항 + +- 배치에서 실행되는 노드 플로우도 **company_code** 필터링 적용 +- 배치 설정의 company_code와 노드 플로우의 company_code가 일치해야 함 +- 최고 관리자(`*`)는 모든 플로우 실행 가능 +- 실행 로그에 `batch_system`으로 사용자 기록 + +--- + +## 6. 구현 순서 + +### Phase 1: DB + 백엔드 코어 (1일) + +1. 마이그레이션 SQL 작성 및 실행 +2. `batchTypes.ts` 타입 수정 +3. `batchService.ts` create/update 수정 +4. `batchSchedulerService.ts` 핵심 분기 로직 추가 +5. `batchManagementRoutes.ts` 노드 플로우 목록 API 추가 +6. 수동 실행 테스트 (`POST /batch-configs/:id/execute`) + +### Phase 2: 백엔드 대시보드 API (0.5일) + +1. `GET /api/batch-management/stats` - 전체/활성/오늘실행/오늘실패 집계 API +2. `GET /api/batch-management/batch-configs/:id/sparkline` - 최근 24h 실행 결과 (시간대별 성공/실패/미실행) +3. `GET /api/batch-management/batch-configs/:id/recent-logs?limit=5` - 최근 N건 실행 이력 +4. 기존 목록 API에 sparkline 요약 데이터 포함 옵션 추가 + +### Phase 3: 프론트엔드 - 배치 목록 Ops 대시보드 (1.5일) + +상세 UI 명세는 위 "3.3 배치 목록 UI - Ops 대시보드 리디자인" 섹션 참조. + +1. **페이지 헤더**: 제목 + 부제 + 새로고침/새배치 버튼 (명세 항목 1) +2. **통계 카드 영역**: 4개 카드 + stats API 연동 (명세 항목 2) +3. **툴바**: 검색 + 상태/타입 필터 pill-group + 건수 표시 (명세 항목 3) +4. **배치 테이블**: 7열 그리드 헤더 + 행 (명세 항목 4~5) +5. **행 확장 상세 패널**: 내러티브 + 파이프라인 + 매핑/플로우 + 설정 + 타임라인 (명세 항목 6) +6. **반응형**: 1024px/640px 브레이크포인트 (명세 항목 7) +7. 배치 생성/편집 모달에 실행 타입 선택 + 노드 플로우 드롭다운 + +### Phase 4: 테스트 및 검증 (0.5일) + +1. 테스트용 노드 플로우 생성 (간단한 UPDATE) +2. 배치 설정에 연결 +3. 수동 실행 테스트 +4. Cron 스케줄 자동 실행 테스트 +5. 실행 로그 확인 +6. 대시보드 통계/스파크라인 정확성 확인 + +--- + +## 7. 리스크 및 대응 + +### 7.1 노드 플로우 실행 시간 초과 + +- **리스크**: 복잡한 플로우가 오래 걸려서 다음 스케줄과 겹칠 수 있음 +- **대응**: 실행 중인 배치는 중복 실행 방지 (락 메커니즘) - Phase 2 이후 고려 + +### 7.2 노드 플로우 삭제 시 배치 깨짐 + +- **리스크**: 연결된 노드 플로우가 삭제되면 배치 실행 실패 +- **대응**: + - 플로우 존재 여부 체크 후 실행 + - 실패 시 로그에 "플로우를 찾을 수 없습니다" 기록 + - (향후) 플로우 삭제 시 연결된 배치가 있으면 경고 + +### 7.3 멀티 인스턴스 환경 + +- **리스크**: 서버가 여러 대일 때 같은 배치가 중복 실행 +- **대응**: 현재 단일 인스턴스 운영이므로 당장은 문제 없음. 향후 Redis 기반 분산 락 고려 + +--- + +## 8. 기대 효과 + +1. **시간 기반 비즈니스 자동화**: 수동 작업 없이 조건 충족 시 자동 처리 +2. **기존 인프라 재활용**: 검증된 배치 스케줄러(1,200+건 성공) + 강력한 노드 플로우 엔진 +3. **최소 코드 변경**: DB 컬럼 3개 + 백엔드 분기 1개 + 프론트 UI 확장 +4. **운영 가시성 극대화**: Ops 대시보드로 배치 상태/건강도를 한눈에 파악 (스파크라인, LED, 타임라인) +5. **확장성**: 향후 이벤트 트리거(데이터 변경 감지) 등으로 확장 가능 + +--- + +## 9. 설계 의도 - 왜 기존 화면과 다른 레이아웃인가 + +| 비교 항목 | 데이터 타입 관리 (편집기) | 배치 관리 (대시보드) | +|-----------|------------------------|-------------------| +| 역할 | 컬럼 메타데이터 편집 | 운영 상태 모니터링 | +| 레이아웃 | 3패널 (리스트/그리드/설정) | 테이블 + 인라인 모니터링 | +| 주요 행위 | 필드 추가/삭제/수정 | 상태 확인, 수동 실행, 이력 조회 | +| 시각적 요소 | 폼, 드래그앤드롭 | LED, 스파크라인, 타임라인 | +| 참고 UI | IDE, Figma 속성 패널 | Vercel Functions, Railway | + +### 디자인 키포인트 6가지 + +1. **스파크라인 = 건강 상태 한눈에**: Vercel의 Function 목록처럼 각 배치 행에 최근 24h 실행 결과를 미니 바 차트로 표현. 숫자 읽을 필요 없이 패턴으로 건강 상태 파악. + +2. **Expandable Row 패턴**: 3패널 대신 클릭하면 행이 확장되어 상세 정보 표시. 파이프라인 플로우 + 매핑 + 타임라인이 한 번에. Railway/GitHub Actions의 Job 상세 패턴. + +3. **LED 상태 표시**: 카드의 Badge(활성/비활성) 대신 LED 점으로 상태 표현. 초록=활성, 주황깜빡임=실행중, 회색=비활성. 운영실 모니터 느낌. + +4. **파이프라인 플로우 다이어그램**: 소스 → 화살표 → 타겟을 수평 파이프라인으로 시각화. DB-DB는 DB 아이콘 둘, API-DB는 클라우드+DB. 데이터 흐름이 직관적. + +5. **내러티브 박스**: 설정값을 나열하는 대신 자연어로 요약. "A에서 B로 N개 컬럼을 매 30분마다 동기화하고 있어요" 식. Toss 스타일 UX Writing. + +6. **타임라인 실행 이력**: 테이블 로그 대신 세로 타임라인(점+선). 성공/실패가 시각적으로 즉시 구분. 문제 발생 시점 빠르게 특정 가능. + +### 디자인 원본 + +HTML 프리뷰 파일: `_local/batch-management-v3-preview.html` (브라우저에서 열어 시각적 확인 가능) diff --git a/frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx b/frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx index a4e1095c..e8b90461 100644 --- a/frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx +++ b/frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx @@ -1,34 +1,101 @@ "use client"; -import React, { useState, useEffect } from "react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import React, { useState, useEffect, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Badge } from "@/components/ui/badge"; -import { ArrowLeft, Save, RefreshCw, ArrowRight, Trash2 } from "lucide-react"; +import { + ArrowLeft, Save, RefreshCw, Trash2, Search, + Database, Workflow, Clock, ChevronRight, +} from "lucide-react"; import { toast } from "sonner"; import { showErrorToast } from "@/lib/utils/toastUtils"; -import { useRouter } from "next/navigation"; +import { useTabStore } from "@/stores/tabStore"; import { BatchAPI, BatchMapping, ConnectionInfo, ColumnInfo, BatchMappingRequest, + type NodeFlowInfo, + type BatchExecutionType, } from "@/lib/api/batch"; +const SCHEDULE_PRESETS = [ + { label: "5분마다", cron: "*/5 * * * *", preview: "5분마다 실행돼요" }, + { label: "30분마다", cron: "*/30 * * * *", preview: "30분마다 실행돼요" }, + { label: "매시간", cron: "0 * * * *", preview: "매시간 정각에 실행돼요" }, + { label: "매일 오전 7시", cron: "0 7 * * *", preview: "매일 오전 7시에 실행돼요" }, + { label: "매일 오전 9시", cron: "0 9 * * *", preview: "매일 오전 9시에 실행돼요" }, + { label: "매일 자정", cron: "0 0 * * *", preview: "매일 밤 12시에 실행돼요" }, + { label: "매주 월요일", cron: "0 9 * * 1", preview: "매주 월요일 오전 9시에 실행돼요" }, + { label: "매월 1일", cron: "0 9 1 * *", preview: "매월 1일 오전 9시에 실행돼요" }, +]; + +function buildCustomCron(repeat: string, dow: string, hour: string, minute: string): string { + const h = hour; + const m = minute; + if (repeat === "daily") return `${m} ${h} * * *`; + if (repeat === "weekly") return `${m} ${h} * * ${dow}`; + if (repeat === "monthly") return `${m} ${h} 1 * *`; + return `${m} ${h} * * *`; +} + +function customCronPreview(repeat: string, dow: string, hour: string, minute: string): string { + const dowNames: Record = { "1": "월요일", "2": "화요일", "3": "수요일", "4": "목요일", "5": "금요일", "6": "토요일", "0": "일요일" }; + const h = Number(hour); + const ampm = h < 12 ? "오전" : "오후"; + const displayH = h === 0 ? 12 : h > 12 ? h - 12 : h; + const time = `${ampm} ${displayH}시${minute !== "0" ? ` ${minute}분` : ""}`; + if (repeat === "daily") return `매일 ${time}에 실행돼요`; + if (repeat === "weekly") return `매주 ${dowNames[dow] || dow} ${time}에 실행돼요`; + if (repeat === "monthly") return `매월 1일 ${time}에 실행돼요`; + return `매일 ${time}에 실행돼요`; +} + export default function BatchCreatePage() { - const router = useRouter(); - - // 기본 정보 + const { openTab } = useTabStore(); + + const [executionType, setExecutionType] = useState(() => { + if (typeof window !== "undefined") { + const stored = sessionStorage.getItem("batch_create_type"); + if (stored === "node_flow") { + sessionStorage.removeItem("batch_create_type"); + return "node_flow"; + } + sessionStorage.removeItem("batch_create_type"); + } + return "mapping"; + }); + const [nodeFlows, setNodeFlows] = useState([]); + const [selectedFlowId, setSelectedFlowId] = useState(null); + const [nodeFlowContext, setNodeFlowContext] = useState(""); + const [flowSearch, setFlowSearch] = useState(""); + const [batchName, setBatchName] = useState(""); - const [cronSchedule, setCronSchedule] = useState("0 12 * * *"); const [description, setDescription] = useState(""); - - // 커넥션 및 데이터 + + // 스케줄 관련 + const [scheduleMode, setScheduleMode] = useState<"preset" | "custom">("preset"); + const [selectedPresetIndex, setSelectedPresetIndex] = useState(3); // 매일 오전 7시 + const [customRepeat, setCustomRepeat] = useState("daily"); + const [customDow, setCustomDow] = useState("1"); + const [customHour, setCustomHour] = useState("9"); + const [customMinute, setCustomMinute] = useState("0"); + + const cronSchedule = useMemo(() => { + if (scheduleMode === "preset") return SCHEDULE_PRESETS[selectedPresetIndex].cron; + return buildCustomCron(customRepeat, customDow, customHour, customMinute); + }, [scheduleMode, selectedPresetIndex, customRepeat, customDow, customHour, customMinute]); + + const schedulePreview = useMemo(() => { + if (scheduleMode === "preset") return SCHEDULE_PRESETS[selectedPresetIndex].preview; + return customCronPreview(customRepeat, customDow, customHour, customMinute); + }, [scheduleMode, selectedPresetIndex, customRepeat, customDow, customHour, customMinute]); + const [connections, setConnections] = useState([]); const [fromConnection, setFromConnection] = useState(null); const [toConnection, setToConnection] = useState(null); @@ -38,19 +105,20 @@ export default function BatchCreatePage() { const [toTable, setToTable] = useState(""); const [fromColumns, setFromColumns] = useState([]); const [toColumns, setToColumns] = useState([]); - - // 매핑 상태 + const [selectedFromColumn, setSelectedFromColumn] = useState(null); const [mappings, setMappings] = useState([]); - - // 로딩 상태 + const [loading, setLoading] = useState(false); const [loadingConnections, setLoadingConnections] = useState(false); - // 커넥션 목록 로드 useEffect(() => { - loadConnections(); - }, []); + if (executionType === "node_flow") { + BatchAPI.getNodeFlows().then(setNodeFlows); + } + }, [executionType]); + + useEffect(() => { loadConnections(); }, []); const loadConnections = async () => { setLoadingConnections(true); @@ -59,487 +127,533 @@ export default function BatchCreatePage() { setConnections(Array.isArray(data) ? data : []); } catch (error) { console.error("커넥션 로드 실패:", error); - toast.error("커넥션 목록을 불러오는데 실패했습니다."); + toast.error("커넥션 목록을 불러올 수 없어요"); setConnections([]); } finally { setLoadingConnections(false); } }; - // FROM 커넥션 변경 const handleFromConnectionChange = async (connectionId: string) => { - if (connectionId === 'unknown') return; - - const connection = connections.find(conn => { - if (conn.type === 'internal') { - return connectionId === 'internal'; - } - return conn.id ? conn.id.toString() === connectionId : false; - }); - + if (connectionId === "unknown") return; + const connection = connections.find(conn => conn.type === "internal" ? connectionId === "internal" : conn.id?.toString() === connectionId); if (!connection) return; - setFromConnection(connection); - setFromTable(""); - setFromTables([]); - setFromColumns([]); - setSelectedFromColumn(null); - + setFromTable(""); setFromTables([]); setFromColumns([]); setSelectedFromColumn(null); try { const tables = await BatchAPI.getTablesFromConnection(connection); setFromTables(Array.isArray(tables) ? tables : []); - } catch (error) { - console.error("FROM 테이블 목록 로드 실패:", error); - toast.error("테이블 목록을 불러오는데 실패했습니다."); - } + } catch { toast.error("테이블 목록을 불러올 수 없어요"); } }; - // TO 커넥션 변경 const handleToConnectionChange = async (connectionId: string) => { - if (connectionId === 'unknown') return; - - const connection = connections.find(conn => { - if (conn.type === 'internal') { - return connectionId === 'internal'; - } - return conn.id ? conn.id.toString() === connectionId : false; - }); - + if (connectionId === "unknown") return; + const connection = connections.find(conn => conn.type === "internal" ? connectionId === "internal" : conn.id?.toString() === connectionId); if (!connection) return; - - setToConnection(connection); - setToTable(""); - setToTables([]); - setToColumns([]); - + setToConnection(connection); setToTable(""); setToTables([]); setToColumns([]); try { const tables = await BatchAPI.getTablesFromConnection(connection); setToTables(Array.isArray(tables) ? tables : []); - } catch (error) { - console.error("TO 테이블 목록 로드 실패:", error); - toast.error("테이블 목록을 불러오는데 실패했습니다."); - } + } catch { toast.error("테이블 목록을 불러올 수 없어요"); } }; - // FROM 테이블 변경 const handleFromTableChange = async (tableName: string) => { - setFromTable(tableName); - setFromColumns([]); - setSelectedFromColumn(null); - + setFromTable(tableName); setFromColumns([]); setSelectedFromColumn(null); if (!fromConnection || !tableName) return; - try { const columns = await BatchAPI.getTableColumns(fromConnection, tableName); setFromColumns(Array.isArray(columns) ? columns : []); } catch (error) { - console.error("FROM 컬럼 목록 로드 실패:", error); - showErrorToast("컬럼 목록을 불러오는 데 실패했습니다", error, { guidance: "테이블 정보를 확인해 주세요." }); + showErrorToast("컬럼 목록을 불러올 수 없어요", error, { guidance: "테이블 정보를 확인해 주세요." }); } }; - // TO 테이블 변경 const handleToTableChange = async (tableName: string) => { - setToTable(tableName); - setToColumns([]); - + setToTable(tableName); setToColumns([]); if (!toConnection || !tableName) return; - try { const columns = await BatchAPI.getTableColumns(toConnection, tableName); setToColumns(Array.isArray(columns) ? columns : []); - } catch (error) { - console.error("TO 컬럼 목록 로드 실패:", error); - toast.error("컬럼 목록을 불러오는데 실패했습니다."); - } + } catch { toast.error("컬럼 목록을 불러올 수 없어요"); } }; - // FROM 컬럼 선택 const handleFromColumnClick = (column: ColumnInfo) => { setSelectedFromColumn(column); - toast.info(`FROM 컬럼 선택됨: ${column.column_name}`); + toast.info(`FROM 컬럼 선택: ${column.column_name}`); }; - // TO 컬럼 선택 (매핑 생성) const handleToColumnClick = (toColumn: ColumnInfo) => { if (!selectedFromColumn || !fromConnection || !toConnection) { - toast.error("먼저 FROM 컬럼을 선택해주세요."); + toast.error("먼저 왼쪽(FROM)에서 컬럼을 선택해주세요"); return; } + const toKey = `${toConnection.type}:${toConnection.id || "internal"}:${toTable}:${toColumn.column_name}`; + const existingMapping = mappings.find(m => `${m.to_connection_type}:${m.to_connection_id || "internal"}:${m.to_table_name}:${m.to_column_name}` === toKey); + if (existingMapping) { toast.error("같은 대상 컬럼에 중복 매핑할 수 없어요"); return; } - // n:1 매핑 검사 - const toKey = `${toConnection.type}:${toConnection.id || 'internal'}:${toTable}:${toColumn.column_name}`; - const existingMapping = mappings.find(mapping => { - const existingToKey = `${mapping.to_connection_type}:${mapping.to_connection_id || 'internal'}:${mapping.to_table_name}:${mapping.to_column_name}`; - return existingToKey === toKey; - }); - - if (existingMapping) { - toast.error("동일한 TO 컬럼에 중복 매핑할 수 없습니다. (n:1 매핑 방지)"); - return; - } - - const newMapping: BatchMapping = { + setMappings([...mappings, { from_connection_type: fromConnection.type, - from_connection_id: fromConnection.id || null, + from_connection_id: fromConnection.id ?? undefined, from_table_name: fromTable, from_column_name: selectedFromColumn.column_name, - from_column_type: selectedFromColumn.data_type || '', + from_column_type: selectedFromColumn.data_type || "", to_connection_type: toConnection.type, - to_connection_id: toConnection.id || null, + to_connection_id: toConnection.id ?? undefined, to_table_name: toTable, to_column_name: toColumn.column_name, - to_column_type: toColumn.data_type || '', + to_column_type: toColumn.data_type || "", mapping_order: mappings.length + 1, - }; - - setMappings([...mappings, newMapping]); + }]); setSelectedFromColumn(null); - toast.success(`매핑 생성: ${selectedFromColumn.column_name} → ${toColumn.column_name}`); + toast.success(`매핑 완료: ${selectedFromColumn.column_name} → ${toColumn.column_name}`); }; - // 매핑 삭제 const removeMapping = (index: number) => { - const newMappings = mappings.filter((_, i) => i !== index); - const reorderedMappings = newMappings.map((mapping, i) => ({ - ...mapping, - mapping_order: i + 1 - })); - setMappings(reorderedMappings); - toast.success("매핑이 삭제되었습니다."); + setMappings(mappings.filter((_, i) => i !== index).map((m, i) => ({ ...m, mapping_order: i + 1 }))); + toast.success("매핑을 삭제했어요"); }; - // 배치 설정 저장 + const goBack = () => openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" }); + const saveBatchConfig = async () => { - if (!batchName.trim()) { - toast.error("배치명을 입력해주세요."); - return; - } - - if (!cronSchedule.trim()) { - toast.error("실행 스케줄을 입력해주세요."); - return; - } - - if (mappings.length === 0) { - toast.error("최소 하나 이상의 매핑을 추가해주세요."); + if (!batchName.trim()) { toast.error("배치 이름을 입력해주세요"); return; } + + if (executionType === "node_flow") { + if (!selectedFlowId) { toast.error("실행할 플로우를 선택해주세요"); return; } + let parsedContext: Record | undefined; + if (nodeFlowContext.trim()) { + try { parsedContext = JSON.parse(nodeFlowContext); } catch { toast.error("추가 데이터의 JSON 형식이 올바르지 않아요"); return; } + } + setLoading(true); + try { + await BatchAPI.createBatchConfig({ batchName, description: description || undefined, cronSchedule, mappings: [], isActive: true, executionType: "node_flow", nodeFlowId: selectedFlowId, nodeFlowContext: parsedContext }); + toast.success("배치를 저장했어요!"); + goBack(); + } catch (error) { showErrorToast("저장에 실패했어요", error, { guidance: "입력 내용을 확인하고 다시 시도해 주세요." }); } finally { setLoading(false); } return; } + if (mappings.length === 0) { toast.error("컬럼 매핑을 하나 이상 추가해주세요"); return; } setLoading(true); try { - const request = { - batchName: batchName, - description: description || undefined, - cronSchedule: cronSchedule, - mappings: mappings, - isActive: true - }; - - await BatchAPI.createBatchConfig(request); - toast.success("배치 설정이 성공적으로 저장되었습니다!"); - - // 목록 페이지로 이동 - router.push("/admin/batchmng"); - } catch (error) { - console.error("배치 설정 저장 실패:", error); - showErrorToast("배치 설정 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." }); - } finally { - setLoading(false); - } + await BatchAPI.createBatchConfig({ batchName, description: description || undefined, cronSchedule, mappings, isActive: true }); + toast.success("배치를 저장했어요!"); + goBack(); + } catch (error) { showErrorToast("저장에 실패했어요", error, { guidance: "입력 내용을 확인하고 다시 시도해 주세요." }); } finally { setLoading(false); } }; + const selectedFlow = nodeFlows.find(f => f.flow_id === selectedFlowId); + return ( -
+
{/* 헤더 */} -
-
- +
+ +
-

배치관리 매핑 시스템

-

새로운 배치 매핑을 생성합니다.

+

새 배치 등록

+

데이터를 자동으로 처리하는 배치를 만들어 보세요

+ +
+
+ + {/* 실행 방식 선택 */} +
+

어떤 방식으로 실행할까요?

+
+ +
{/* 기본 정보 */} - - - 기본 정보 - - -
-
- - setBatchName(e.target.value)} - placeholder="배치명을 입력하세요" - /> -
-
- - setCronSchedule(e.target.value)} - placeholder="0 12 * * * (매일 12시)" - /> -
+
+

기본 정보

+
+
+ + setBatchName(e.target.value)} placeholder="예: 매출 데이터 동기화" className="h-10 text-sm" /> +

어떤 작업인지 한눈에 알 수 있게 적어주세요

-
- -