521 lines
14 KiB
TypeScript
521 lines
14 KiB
TypeScript
/**
|
|
* 스케줄 자동 생성 서비스
|
|
*
|
|
* 스케줄 미리보기 생성, 적용, 조회 로직을 처리합니다.
|
|
*/
|
|
|
|
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<SchedulePreview> {
|
|
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<ApplyResult> {
|
|
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<string, any[]> {
|
|
const grouped: Record<string, any[]> = {};
|
|
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<any[]> {
|
|
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;
|
|
}
|
|
}
|