ERP-node/frontend/lib/v2-core/services/ScheduleGeneratorService.ts

335 lines
12 KiB
TypeScript

/**
* 스케줄 자동 생성 서비스
*
* 이벤트 버스 기반으로 스케줄 자동 생성을 처리합니다.
* - TABLE_SELECTION_CHANGE 이벤트로 선택 데이터 추적
* - SCHEDULE_GENERATE_REQUEST 이벤트로 생성 요청 처리
* - SCHEDULE_GENERATE_APPLY 이벤트로 적용 처리
*/
import { useState, useEffect, useCallback, useRef } from "react";
import { v2EventBus } from "../events/EventBus";
import { V2_EVENTS } from "../events/types";
import type { ScheduleType, V2ScheduleGenerateRequestEvent, V2ScheduleGenerateApplyEvent } from "../events/types";
import { apiClient } from "@/lib/api/client";
import { toast } from "sonner";
// ============================================================================
// 타입 정의
// ============================================================================
/** 스케줄 생성 설정 */
export interface ScheduleGenerationConfig {
// 스케줄 타입
scheduleType: ScheduleType;
// 소스 설정
source: {
tableName: string; // 소스 테이블명
groupByField: string; // 그룹화 기준 필드 (part_code)
quantityField: string; // 수량 필드 (order_qty, balance_qty)
dueDateField?: string; // 납기일 필드 (선택)
};
// 리소스 매핑 (타임라인 Y축)
resource: {
type: string; // 'ITEM', 'MACHINE', 'WORKER' 등
idField: string; // part_code, machine_code 등
nameField: string; // part_name, machine_name 등
};
// 생성 규칙
rules: {
leadTimeDays?: number; // 리드타임 (일)
dailyCapacity?: number; // 일일 생산능력
workingDays?: number[]; // 작업일 [1,2,3,4,5] = 월~금
considerStock?: boolean; // 재고 고려 여부
stockTableName?: string; // 재고 테이블명
stockQtyField?: string; // 재고 수량 필드
safetyStockField?: string; // 안전재고 필드
};
// 타겟 설정
target: {
tableName: string; // 스케줄 테이블명 (schedule_mng 또는 전용 테이블)
};
}
/** 미리보기 결과 */
export interface SchedulePreviewResult {
toCreate: any[];
toDelete: any[];
toUpdate: any[];
summary: {
createCount: number;
deleteCount: number;
updateCount: number;
totalQty: number;
};
}
/** 훅 반환 타입 */
export interface UseScheduleGeneratorReturn {
// 상태
isLoading: boolean;
showConfirmDialog: boolean;
previewResult: SchedulePreviewResult | null;
// 핸들러
handleConfirm: (confirmed: boolean) => void;
closeDialog: () => void;
}
// ============================================================================
// 유틸리티 함수
// ============================================================================
/** 기본 기간 계산 (현재 월) */
function getDefaultPeriod(): { start: string; end: string } {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1);
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
return {
start: start.toISOString().split("T")[0],
end: end.toISOString().split("T")[0],
};
}
// ============================================================================
// 스케줄 생성 서비스 훅
// ============================================================================
/**
* 스케줄 자동 생성 훅
*
* @param scheduleConfig 스케줄 생성 설정
* @returns 상태 및 핸들러
*
* @example
* ```tsx
* const config: ScheduleGenerationConfig = {
* scheduleType: "PRODUCTION",
* source: { tableName: "sales_order_mng", groupByField: "part_code", quantityField: "balance_qty" },
* resource: { type: "ITEM", idField: "part_code", nameField: "part_name" },
* rules: { leadTimeDays: 3, dailyCapacity: 100 },
* target: { tableName: "schedule_mng" },
* };
*
* const { showConfirmDialog, previewResult, handleConfirm } = useScheduleGenerator(config);
* ```
*/
export function useScheduleGenerator(scheduleConfig?: ScheduleGenerationConfig | null): UseScheduleGeneratorReturn {
// 상태
const [selectedData, setSelectedData] = useState<any[]>([]);
const [previewResult, setPreviewResult] = useState<SchedulePreviewResult | null>(null);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const currentRequestIdRef = useRef<string>("");
const currentConfigRef = useRef<ScheduleGenerationConfig | null>(null);
// 1. 테이블 선택 데이터 추적 (TABLE_SELECTION_CHANGE 이벤트 수신)
useEffect(() => {
const unsubscribe = v2EventBus.subscribe(V2_EVENTS.TABLE_SELECTION_CHANGE, (payload) => {
// scheduleConfig가 있으면 해당 테이블만, 없으면 모든 테이블의 선택 데이터 저장
if (scheduleConfig?.source?.tableName) {
if (payload.tableName === scheduleConfig.source.tableName) {
setSelectedData(payload.selectedRows);
console.log("[useScheduleGenerator] 선택 데이터 업데이트 (특정 테이블):", payload.selectedCount, "건");
}
} else {
// scheduleConfig가 없으면 모든 테이블의 선택 데이터를 저장
setSelectedData(payload.selectedRows);
console.log("[useScheduleGenerator] 선택 데이터 업데이트 (모든 테이블):", payload.selectedCount, "건");
}
});
return unsubscribe;
}, [scheduleConfig?.source?.tableName]);
// 2. 스케줄 생성 요청 처리 (SCHEDULE_GENERATE_REQUEST 수신)
useEffect(() => {
const unsubscribe = v2EventBus.subscribe(
V2_EVENTS.SCHEDULE_GENERATE_REQUEST,
async (payload: V2ScheduleGenerateRequestEvent) => {
console.log("[useScheduleGenerator] SCHEDULE_GENERATE_REQUEST 수신:", payload);
// 이벤트에서 config가 오면 사용, 없으면 기존 scheduleConfig 또는 기본 config 사용
const configToUse = (payload as any).config ||
scheduleConfig || {
// 기본 설정 (생산계획 화면용)
scheduleType: payload.scheduleType || "PRODUCTION",
source: {
tableName: "sales_order_mng",
groupByField: "part_code",
quantityField: "balance_qty",
dueDateField: "delivery_date", // 기준일 필드 (납기일)
},
resource: {
type: "ITEM",
idField: "part_code",
nameField: "part_name",
},
rules: {
leadTimeDays: 3,
dailyCapacity: 100,
},
target: {
tableName: "schedule_mng",
},
};
console.log("[useScheduleGenerator] 사용할 config:", configToUse);
// scheduleType이 지정되어 있고 config도 있는 경우, 타입 일치 확인
if (scheduleConfig && payload.scheduleType && payload.scheduleType !== scheduleConfig.scheduleType) {
console.log("[useScheduleGenerator] scheduleType 불일치, 무시");
return;
}
// sourceData: 이벤트 페이로드 > 상태 저장된 선택 데이터 > 빈 배열
const dataToUse = payload.sourceData || selectedData;
const periodToUse = payload.period || getDefaultPeriod();
console.log("[useScheduleGenerator] 사용할 sourceData:", dataToUse.length, "건");
console.log("[useScheduleGenerator] 사용할 period:", periodToUse);
currentRequestIdRef.current = payload.requestId;
currentConfigRef.current = configToUse;
setIsLoading(true);
toast.loading("스케줄 생성 중...", { id: "schedule-generate" });
try {
// 미리보기 API 호출
const response = await apiClient.post("/schedule/preview", {
config: configToUse,
scheduleType: payload.scheduleType,
sourceData: dataToUse,
period: periodToUse,
});
console.log("[useScheduleGenerator] 미리보기 응답:", response.data);
if (!response.data.success) {
toast.error(response.data.message || "미리보기 생성 실패", { id: "schedule-generate" });
v2EventBus.emit(V2_EVENTS.SCHEDULE_GENERATE_ERROR, {
requestId: payload.requestId,
error: response.data.message || "미리보기 생성 실패",
scheduleType: payload.scheduleType,
});
return;
}
setPreviewResult(response.data.preview);
setShowConfirmDialog(true);
toast.success("스케줄 미리보기가 생성되었습니다.", { id: "schedule-generate" });
// 미리보기 결과 이벤트 발송 (다른 컴포넌트가 필요할 수 있음)
v2EventBus.emit(V2_EVENTS.SCHEDULE_GENERATE_PREVIEW, {
requestId: payload.requestId,
scheduleType: payload.scheduleType,
preview: response.data.preview,
});
} catch (error: any) {
console.error("[ScheduleGeneratorService] 미리보기 오류:", error);
toast.error("스케줄 생성 중 오류가 발생했습니다.", { id: "schedule-generate" });
v2EventBus.emit(V2_EVENTS.SCHEDULE_GENERATE_ERROR, {
requestId: payload.requestId,
error: error.message,
scheduleType: payload.scheduleType,
});
} finally {
setIsLoading(false);
}
},
);
return unsubscribe;
}, [selectedData, scheduleConfig]);
// 3. 스케줄 적용 처리 (SCHEDULE_GENERATE_APPLY 수신)
useEffect(() => {
const unsubscribe = v2EventBus.subscribe(
V2_EVENTS.SCHEDULE_GENERATE_APPLY,
async (payload: V2ScheduleGenerateApplyEvent) => {
if (payload.requestId !== currentRequestIdRef.current) return;
if (!payload.confirmed) {
setShowConfirmDialog(false);
return;
}
// 저장된 config 또는 기존 scheduleConfig 사용
const configToUse = currentConfigRef.current || scheduleConfig;
setIsLoading(true);
toast.loading("스케줄 적용 중...", { id: "schedule-apply" });
try {
const response = await apiClient.post("/schedule/apply", {
config: configToUse,
preview: previewResult,
options: { deleteExisting: true, updateMode: "replace" },
});
if (!response.data.success) {
toast.error(response.data.message || "스케줄 적용 실패", { id: "schedule-apply" });
return;
}
// 완료 이벤트 발송
v2EventBus.emit(V2_EVENTS.SCHEDULE_GENERATE_COMPLETE, {
requestId: payload.requestId,
success: true,
applied: response.data.applied,
scheduleType: configToUse?.scheduleType || "PRODUCTION",
targetTableName: configToUse?.target?.tableName || "schedule_mng",
});
// 테이블 새로고침 이벤트 발송
v2EventBus.emit(V2_EVENTS.TABLE_REFRESH, {
tableName: configToUse?.target?.tableName || "schedule_mng",
});
toast.success(`${response.data.applied?.created || 0}건의 스케줄이 생성되었습니다.`, {
id: "schedule-apply",
});
setShowConfirmDialog(false);
setPreviewResult(null);
} catch (error: any) {
console.error("[ScheduleGeneratorService] 적용 오류:", error);
toast.error("스케줄 적용 중 오류가 발생했습니다.", { id: "schedule-apply" });
} finally {
setIsLoading(false);
}
},
);
return unsubscribe;
}, [previewResult, scheduleConfig]);
// 확인 다이얼로그 핸들러
const handleConfirm = useCallback((confirmed: boolean) => {
v2EventBus.emit(V2_EVENTS.SCHEDULE_GENERATE_APPLY, {
requestId: currentRequestIdRef.current,
confirmed,
});
}, []);
// 다이얼로그 닫기
const closeDialog = useCallback(() => {
setShowConfirmDialog(false);
setPreviewResult(null);
}, []);
return {
isLoading,
showConfirmDialog,
previewResult,
handleConfirm,
closeDialog,
};
}
// ============================================================================
// 스케줄 확인 다이얼로그 컴포넌트
// ============================================================================
export { ScheduleConfirmDialog } from "./ScheduleConfirmDialog";