/** * 스케줄 자동 생성 서비스 * * 스케줄 미리보기 생성, 적용, 조회 로직을 처리합니다. */ import { pool } from "../database/db"; // ============================================================================ // 타입 정의 // ============================================================================ export interface ScheduleGenerationConfig { scheduleType: "PRODUCTION" | "MAINTENANCE" | "SHIPPING" | "WORK_ASSIGN"; source: { tableName: string; groupByField: string; quantityField: string; dueDateField?: string; }; resource: { type: string; idField: string; nameField: string; }; rules: { leadTimeDays?: number; dailyCapacity?: number; workingDays?: number[]; considerStock?: boolean; stockTableName?: string; stockQtyField?: string; safetyStockField?: string; }; target: { tableName: string; }; } export interface SchedulePreview { toCreate: any[]; toDelete: any[]; toUpdate: any[]; summary: { createCount: number; deleteCount: number; updateCount: number; totalQty: number; }; } export interface ApplyOptions { deleteExisting: boolean; updateMode: "replace" | "merge"; } export interface ApplyResult { created: number; deleted: number; updated: number; } export interface ScheduleListQuery { scheduleType?: string; resourceType?: string; resourceId?: string; startDate?: string; endDate?: string; status?: string; companyCode: string; } // ============================================================================ // 서비스 클래스 // ============================================================================ export class ScheduleService { /** * 스케줄 미리보기 생성 */ async generatePreview( config: ScheduleGenerationConfig, sourceData: any[], period: { start: string; end: string } | undefined, companyCode: string ): Promise { console.log("[ScheduleService] generatePreview 시작:", { scheduleType: config.scheduleType, sourceDataCount: sourceData.length, period, companyCode, }); // 기본 기간 설정 (현재 월) const now = new Date(); const defaultPeriod = { start: new Date(now.getFullYear(), now.getMonth(), 1) .toISOString() .split("T")[0], end: new Date(now.getFullYear(), now.getMonth() + 1, 0) .toISOString() .split("T")[0], }; const effectivePeriod = period || defaultPeriod; // 1. 소스 데이터를 리소스별로 그룹화 const groupedData = this.groupByResource(sourceData, config); // 2. 각 리소스에 대해 스케줄 생성 const toCreate: any[] = []; let totalQty = 0; for (const [resourceId, items] of Object.entries(groupedData)) { const schedules = this.generateSchedulesForResource( resourceId, items as any[], config, effectivePeriod, companyCode ); toCreate.push(...schedules); totalQty += schedules.reduce( (sum, s) => sum + (s.plan_qty || 0), 0 ); } // 3. 기존 스케줄 조회 (삭제 대상) // 그룹 키에서 리소스 ID만 추출 ("리소스ID|날짜" 형식에서 "리소스ID"만) const resourceIds = [...new Set( Object.keys(groupedData).map((key) => key.split("|")[0]) )]; const toDelete = await this.getExistingSchedules( config.scheduleType, resourceIds, effectivePeriod, companyCode ); // 4. 미리보기 결과 생성 const preview: SchedulePreview = { toCreate, toDelete, toUpdate: [], // 현재는 Replace 모드만 지원 summary: { createCount: toCreate.length, deleteCount: toDelete.length, updateCount: 0, totalQty, }, }; console.log("[ScheduleService] generatePreview 완료:", preview.summary); return preview; } /** * 스케줄 적용 */ async applySchedules( config: ScheduleGenerationConfig, preview: SchedulePreview, options: ApplyOptions, companyCode: string, userId: string ): Promise { console.log("[ScheduleService] applySchedules 시작:", { createCount: preview.summary.createCount, deleteCount: preview.summary.deleteCount, options, companyCode, userId, }); const client = await pool.connect(); const result: ApplyResult = { created: 0, deleted: 0, updated: 0 }; try { await client.query("BEGIN"); // 1. 기존 스케줄 삭제 if (options.deleteExisting && preview.toDelete.length > 0) { const deleteIds = preview.toDelete.map((s) => s.schedule_id); await client.query( `DELETE FROM schedule_mng WHERE schedule_id = ANY($1) AND company_code = $2`, [deleteIds, companyCode] ); result.deleted = deleteIds.length; console.log("[ScheduleService] 스케줄 삭제 완료:", result.deleted); } // 2. 새 스케줄 생성 for (const schedule of preview.toCreate) { await client.query( `INSERT INTO schedule_mng ( company_code, schedule_type, schedule_name, resource_type, resource_id, resource_name, start_date, end_date, due_date, plan_qty, unit, status, priority, source_table, source_id, source_group_key, auto_generated, generated_at, generated_by, metadata, created_by, updated_by ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22 )`, [ companyCode, schedule.schedule_type, schedule.schedule_name, schedule.resource_type, schedule.resource_id, schedule.resource_name, schedule.start_date, schedule.end_date, schedule.due_date || null, schedule.plan_qty, schedule.unit || null, schedule.status || "PLANNED", schedule.priority || null, schedule.source_table || null, schedule.source_id || null, schedule.source_group_key || null, true, new Date(), userId, schedule.metadata ? JSON.stringify(schedule.metadata) : null, userId, userId, ] ); result.created++; } await client.query("COMMIT"); console.log("[ScheduleService] applySchedules 완료:", result); return result; } catch (error) { await client.query("ROLLBACK"); console.error("[ScheduleService] applySchedules 오류:", error); throw error; } finally { client.release(); } } /** * 스케줄 목록 조회 */ async getScheduleList( query: ScheduleListQuery ): Promise<{ data: any[]; total: number }> { const conditions: string[] = []; const params: any[] = []; let paramIndex = 1; // company_code 필터 if (query.companyCode !== "*") { conditions.push(`company_code = $${paramIndex++}`); params.push(query.companyCode); } // scheduleType 필터 if (query.scheduleType) { conditions.push(`schedule_type = $${paramIndex++}`); params.push(query.scheduleType); } // resourceType 필터 if (query.resourceType) { conditions.push(`resource_type = $${paramIndex++}`); params.push(query.resourceType); } // resourceId 필터 if (query.resourceId) { conditions.push(`resource_id = $${paramIndex++}`); params.push(query.resourceId); } // 기간 필터 if (query.startDate) { conditions.push(`end_date >= $${paramIndex++}`); params.push(query.startDate); } if (query.endDate) { conditions.push(`start_date <= $${paramIndex++}`); params.push(query.endDate); } // status 필터 if (query.status) { conditions.push(`status = $${paramIndex++}`); params.push(query.status); } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; const result = await pool.query( `SELECT * FROM schedule_mng ${whereClause} ORDER BY start_date, resource_id`, params ); return { data: result.rows, total: result.rows.length, }; } /** * 스케줄 삭제 */ async deleteSchedule( scheduleId: number, companyCode: string, userId: string ): Promise<{ success: boolean; message?: string }> { const result = await pool.query( `DELETE FROM schedule_mng WHERE schedule_id = $1 AND (company_code = $2 OR $2 = '*') RETURNING schedule_id`, [scheduleId, companyCode] ); if (result.rowCount === 0) { return { success: false, message: "스케줄을 찾을 수 없거나 권한이 없습니다.", }; } // 이력 기록 await pool.query( `INSERT INTO schedule_history (company_code, schedule_id, action, changed_by) VALUES ($1, $2, 'DELETE', $3)`, [companyCode, scheduleId, userId] ); return { success: true }; } // ============================================================================ // 헬퍼 메서드 // ============================================================================ /** * 소스 데이터를 리소스별로 그룹화 * - 기준일(dueDateField)이 설정된 경우: 리소스 + 기준일 조합으로 그룹화 * - 기준일이 없는 경우: 리소스별로만 그룹화 */ private groupByResource( sourceData: any[], config: ScheduleGenerationConfig ): Record { const grouped: Record = {}; const dueDateField = config.source.dueDateField; for (const item of sourceData) { const resourceId = item[config.resource.idField]; if (!resourceId) continue; // 그룹 키 생성: 기준일이 있으면 "리소스ID|기준일", 없으면 "리소스ID" let groupKey = resourceId; if (dueDateField && item[dueDateField]) { // 날짜를 YYYY-MM-DD 형식으로 정규화 const dueDate = new Date(item[dueDateField]).toISOString().split("T")[0]; groupKey = `${resourceId}|${dueDate}`; } if (!grouped[groupKey]) { grouped[groupKey] = []; } grouped[groupKey].push(item); } console.log("[ScheduleService] 그룹화 결과:", { groupCount: Object.keys(grouped).length, groups: Object.keys(grouped), dueDateField, }); return grouped; } /** * 리소스에 대한 스케줄 생성 * - groupKey 형식: "리소스ID" 또는 "리소스ID|기준일(YYYY-MM-DD)" */ private generateSchedulesForResource( groupKey: string, items: any[], config: ScheduleGenerationConfig, period: { start: string; end: string }, companyCode: string ): any[] { const schedules: any[] = []; // 그룹 키에서 리소스ID와 기준일 분리 const [resourceId, groupDueDate] = groupKey.split("|"); const resourceName = items[0]?.[config.resource.nameField] || resourceId; // 총 수량 계산 const totalQty = items.reduce((sum, item) => { return sum + (parseFloat(item[config.source.quantityField]) || 0); }, 0); if (totalQty <= 0) return schedules; // 스케줄 규칙 적용 const { leadTimeDays = 3, dailyCapacity = totalQty, workingDays = [1, 2, 3, 4, 5], } = config.rules; // 기준일(납기일/마감일) 결정 let dueDate: Date; if (groupDueDate) { // 그룹 키에 기준일이 포함된 경우 dueDate = new Date(groupDueDate); } else if (config.source.dueDateField) { // 아이템에서 기준일 찾기 (가장 빠른 날짜) let earliestDate: Date | null = null; for (const item of items) { const itemDueDate = item[config.source.dueDateField]; if (itemDueDate) { const date = new Date(itemDueDate); if (!earliestDate || date < earliestDate) { earliestDate = date; } } } dueDate = earliestDate || new Date(period.end); } else { // 기준일이 없으면 기간 종료일 사용 dueDate = new Date(period.end); } // 종료일 = 기준일 (납기일에 맞춰 완료) const endDate = new Date(dueDate); // 시작일 계산 (종료일에서 리드타임만큼 역산) const startDate = new Date(endDate); startDate.setDate(startDate.getDate() - leadTimeDays); // 스케줄명 생성 (기준일 포함) const dueDateStr = dueDate.toISOString().split("T")[0]; const scheduleName = groupDueDate ? `${resourceName} (${dueDateStr})` : `${resourceName} - ${config.scheduleType}`; // 스케줄 생성 schedules.push({ schedule_type: config.scheduleType, schedule_name: scheduleName, resource_type: config.resource.type, resource_id: resourceId, resource_name: resourceName, start_date: startDate.toISOString(), end_date: endDate.toISOString(), due_date: dueDate.toISOString(), plan_qty: totalQty, status: "PLANNED", source_table: config.source.tableName, source_id: items.map((i) => i.id || i.order_no || i.sales_order_no).join(","), source_group_key: resourceId, metadata: { sourceCount: items.length, dailyCapacity, leadTimeDays, workingDays, groupDueDate: groupDueDate || null, }, }); console.log("[ScheduleService] 스케줄 생성:", { groupKey, resourceId, resourceName, dueDate: dueDateStr, totalQty, startDate: startDate.toISOString().split("T")[0], endDate: endDate.toISOString().split("T")[0], }); return schedules; } /** * 기존 스케줄 조회 (삭제 대상) */ private async getExistingSchedules( scheduleType: string, resourceIds: string[], period: { start: string; end: string }, companyCode: string ): Promise { if (resourceIds.length === 0) return []; const result = await pool.query( `SELECT * FROM schedule_mng WHERE schedule_type = $1 AND resource_id = ANY($2) AND end_date >= $3 AND start_date <= $4 AND (company_code = $5 OR $5 = '*') AND auto_generated = true`, [scheduleType, resourceIds, period.start, period.end, companyCode] ); return result.rows; } }