feat: 스케줄 자동 생성 기능 및 이벤트 발송 설정 추가
- 스케줄 자동 생성 관련 라우트를 추가하여 API 연동을 구현하였습니다. - 버튼 설정 패널에 이벤트 발송 옵션을 추가하여 사용자가 이벤트를 설정할 수 있도록 하였습니다. - 타임라인 스케줄러 컴포넌트에서 스케줄 데이터 필터링 및 선택된 품목에 따른 스케줄 로드 기능을 개선하였습니다. - 이벤트 버스를 통해 다른 컴포넌트와의 상호작용을 강화하였습니다. - 관련 문서 및 주석을 업데이트하여 새로운 기능에 대한 이해를 돕도록 하였습니다.
This commit is contained in:
@@ -64,6 +64,7 @@ import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 3D 필드
|
||||
import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제)
|
||||
import flowRoutes from "./routes/flowRoutes"; // 플로우 관리
|
||||
import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRoutes"; // 플로우 전용 외부 DB 연결
|
||||
import scheduleRoutes from "./routes/scheduleRoutes"; // 스케줄 자동 생성
|
||||
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
|
||||
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
|
||||
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
|
||||
@@ -246,6 +247,7 @@ app.use("/api/yard-layouts", yardLayoutRoutes); // 3D 필드
|
||||
app.use("/api/digital-twin", digitalTwinRoutes); // 디지털 트윈 (야드 관제)
|
||||
app.use("/api/flow-external-db", flowExternalDbConnectionRoutes); // 플로우 전용 외부 DB 연결
|
||||
app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 다른 라우트와 충돌 방지)
|
||||
app.use("/api/schedule", scheduleRoutes); // 스케줄 자동 생성
|
||||
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
|
||||
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
|
||||
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
|
||||
|
||||
223
backend-node/src/controllers/scheduleController.ts
Normal file
223
backend-node/src/controllers/scheduleController.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* 스케줄 자동 생성 컨트롤러
|
||||
*
|
||||
* 스케줄 미리보기, 적용, 조회 API를 제공합니다.
|
||||
*/
|
||||
|
||||
import { Request, Response } from "express";
|
||||
import { ScheduleService } from "../services/scheduleService";
|
||||
|
||||
export class ScheduleController {
|
||||
private scheduleService: ScheduleService;
|
||||
|
||||
constructor() {
|
||||
this.scheduleService = new ScheduleService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄 미리보기
|
||||
* POST /api/schedule/preview
|
||||
*
|
||||
* 선택한 소스 데이터를 기반으로 생성될 스케줄을 미리보기합니다.
|
||||
* 실제 저장은 하지 않습니다.
|
||||
*/
|
||||
preview = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { config, sourceData, period } = req.body;
|
||||
const userId = (req as any).user?.userId || "system";
|
||||
const companyCode = (req as any).user?.companyCode || "*";
|
||||
|
||||
console.log("[ScheduleController] preview 호출:", {
|
||||
scheduleType: config?.scheduleType,
|
||||
sourceDataCount: sourceData?.length,
|
||||
period,
|
||||
userId,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
// 필수 파라미터 검증
|
||||
if (!config || !config.scheduleType) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "스케줄 설정(config)이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sourceData || sourceData.length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "소스 데이터가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 미리보기 생성
|
||||
const preview = await this.scheduleService.generatePreview(
|
||||
config,
|
||||
sourceData,
|
||||
period,
|
||||
companyCode
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
preview,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("[ScheduleController] preview 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "스케줄 미리보기 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 스케줄 적용
|
||||
* POST /api/schedule/apply
|
||||
*
|
||||
* 미리보기 결과를 실제로 저장합니다.
|
||||
*/
|
||||
apply = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { config, preview, options } = req.body;
|
||||
const userId = (req as any).user?.userId || "system";
|
||||
const companyCode = (req as any).user?.companyCode || "*";
|
||||
|
||||
console.log("[ScheduleController] apply 호출:", {
|
||||
scheduleType: config?.scheduleType,
|
||||
createCount: preview?.summary?.createCount,
|
||||
deleteCount: preview?.summary?.deleteCount,
|
||||
options,
|
||||
userId,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
// 필수 파라미터 검증
|
||||
if (!config || !preview) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "설정(config)과 미리보기(preview)가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 적용
|
||||
const applied = await this.scheduleService.applySchedules(
|
||||
config,
|
||||
preview,
|
||||
options || { deleteExisting: true, updateMode: "replace" },
|
||||
companyCode,
|
||||
userId
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
applied,
|
||||
message: `${applied.created}건 생성, ${applied.deleted}건 삭제, ${applied.updated}건 수정되었습니다.`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("[ScheduleController] apply 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "스케줄 적용 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 스케줄 목록 조회
|
||||
* GET /api/schedule/list
|
||||
*
|
||||
* 타임라인 표시용 스케줄 목록을 조회합니다.
|
||||
*/
|
||||
list = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const {
|
||||
scheduleType,
|
||||
resourceType,
|
||||
resourceId,
|
||||
startDate,
|
||||
endDate,
|
||||
status,
|
||||
} = req.query;
|
||||
const companyCode = (req as any).user?.companyCode || "*";
|
||||
|
||||
console.log("[ScheduleController] list 호출:", {
|
||||
scheduleType,
|
||||
resourceType,
|
||||
resourceId,
|
||||
startDate,
|
||||
endDate,
|
||||
status,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
const result = await this.scheduleService.getScheduleList({
|
||||
scheduleType: scheduleType as string,
|
||||
resourceType: resourceType as string,
|
||||
resourceId: resourceId as string,
|
||||
startDate: startDate as string,
|
||||
endDate: endDate as string,
|
||||
status: status as string,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
total: result.total,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("[ScheduleController] list 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "스케줄 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 스케줄 삭제
|
||||
* DELETE /api/schedule/:scheduleId
|
||||
*/
|
||||
delete = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { scheduleId } = req.params;
|
||||
const userId = (req as any).user?.userId || "system";
|
||||
const companyCode = (req as any).user?.companyCode || "*";
|
||||
|
||||
console.log("[ScheduleController] delete 호출:", {
|
||||
scheduleId,
|
||||
userId,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
const result = await this.scheduleService.deleteSchedule(
|
||||
parseInt(scheduleId, 10),
|
||||
companyCode,
|
||||
userId
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: result.message || "스케줄을 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "스케줄이 삭제되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("[ScheduleController] delete 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "스케줄 삭제 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
33
backend-node/src/routes/scheduleRoutes.ts
Normal file
33
backend-node/src/routes/scheduleRoutes.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 스케줄 자동 생성 라우터
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import { ScheduleController } from "../controllers/scheduleController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = Router();
|
||||
const scheduleController = new ScheduleController();
|
||||
|
||||
// 모든 스케줄 라우트에 인증 미들웨어 적용
|
||||
router.use(authenticateToken);
|
||||
|
||||
// ==================== 스케줄 생성 ====================
|
||||
|
||||
// 스케줄 미리보기
|
||||
router.post("/preview", scheduleController.preview);
|
||||
|
||||
// 스케줄 적용
|
||||
router.post("/apply", scheduleController.apply);
|
||||
|
||||
// ==================== 스케줄 조회 ====================
|
||||
|
||||
// 스케줄 목록 조회
|
||||
router.get("/list", scheduleController.list);
|
||||
|
||||
// ==================== 스케줄 삭제 ====================
|
||||
|
||||
// 스케줄 삭제
|
||||
router.delete("/:scheduleId", scheduleController.delete);
|
||||
|
||||
export default router;
|
||||
520
backend-node/src/services/scheduleService.ts
Normal file
520
backend-node/src/services/scheduleService.ts
Normal file
@@ -0,0 +1,520 @@
|
||||
/**
|
||||
* 스케줄 자동 생성 서비스
|
||||
*
|
||||
* 스케줄 미리보기 생성, 적용, 조회 로직을 처리합니다.
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -289,29 +289,46 @@ export class TableManagementService {
|
||||
companyCode,
|
||||
});
|
||||
|
||||
const mappings = await query<any>(
|
||||
`SELECT
|
||||
logical_column_name as "columnName",
|
||||
menu_objid as "menuObjid"
|
||||
FROM category_column_mapping
|
||||
WHERE table_name = $1
|
||||
AND company_code = $2`,
|
||||
[tableName, companyCode]
|
||||
);
|
||||
try {
|
||||
// menu_objid 컬럼이 있는지 먼저 확인
|
||||
const columnCheck = await query<any>(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'category_column_mapping' AND column_name = 'menu_objid'`
|
||||
);
|
||||
|
||||
logger.info("✅ getColumnList: 카테고리 매핑 조회 완료", {
|
||||
tableName,
|
||||
companyCode,
|
||||
mappingCount: mappings.length,
|
||||
mappings: mappings,
|
||||
});
|
||||
if (columnCheck.length > 0) {
|
||||
// menu_objid 컬럼이 있는 경우
|
||||
const mappings = await query<any>(
|
||||
`SELECT
|
||||
logical_column_name as "columnName",
|
||||
menu_objid as "menuObjid"
|
||||
FROM category_column_mapping
|
||||
WHERE table_name = $1
|
||||
AND company_code = $2`,
|
||||
[tableName, companyCode]
|
||||
);
|
||||
|
||||
mappings.forEach((m: any) => {
|
||||
if (!categoryMappings.has(m.columnName)) {
|
||||
categoryMappings.set(m.columnName, []);
|
||||
logger.info("✅ getColumnList: 카테고리 매핑 조회 완료", {
|
||||
tableName,
|
||||
companyCode,
|
||||
mappingCount: mappings.length,
|
||||
});
|
||||
|
||||
mappings.forEach((m: any) => {
|
||||
if (!categoryMappings.has(m.columnName)) {
|
||||
categoryMappings.set(m.columnName, []);
|
||||
}
|
||||
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
|
||||
});
|
||||
} else {
|
||||
// menu_objid 컬럼이 없는 경우 - 매핑 없이 진행
|
||||
logger.info("⚠️ getColumnList: menu_objid 컬럼이 없음, 카테고리 매핑 스킵");
|
||||
}
|
||||
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
|
||||
});
|
||||
} catch (mappingError: any) {
|
||||
logger.warn("⚠️ getColumnList: 카테고리 매핑 조회 실패, 스킵", {
|
||||
error: mappingError.message,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("✅ getColumnList: categoryMappings Map 생성 완료", {
|
||||
size: categoryMappings.size,
|
||||
@@ -4163,31 +4180,46 @@ export class TableManagementService {
|
||||
if (mappingTableExists) {
|
||||
logger.info("카테고리 매핑 조회 시작", { tableName, companyCode });
|
||||
|
||||
const mappings = await query<any>(
|
||||
`SELECT DISTINCT ON (logical_column_name, menu_objid)
|
||||
logical_column_name as "columnName",
|
||||
menu_objid as "menuObjid"
|
||||
FROM category_column_mapping
|
||||
WHERE table_name = $1
|
||||
AND company_code IN ($2, '*')
|
||||
ORDER BY logical_column_name, menu_objid,
|
||||
CASE WHEN company_code = $2 THEN 0 ELSE 1 END`,
|
||||
[tableName, companyCode]
|
||||
);
|
||||
try {
|
||||
// menu_objid 컬럼이 있는지 먼저 확인
|
||||
const columnCheck = await query<any>(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'category_column_mapping' AND column_name = 'menu_objid'`
|
||||
);
|
||||
|
||||
logger.info("카테고리 매핑 조회 완료", {
|
||||
tableName,
|
||||
companyCode,
|
||||
mappingCount: mappings.length,
|
||||
mappings: mappings,
|
||||
});
|
||||
if (columnCheck.length > 0) {
|
||||
const mappings = await query<any>(
|
||||
`SELECT DISTINCT ON (logical_column_name, menu_objid)
|
||||
logical_column_name as "columnName",
|
||||
menu_objid as "menuObjid"
|
||||
FROM category_column_mapping
|
||||
WHERE table_name = $1
|
||||
AND company_code IN ($2, '*')
|
||||
ORDER BY logical_column_name, menu_objid,
|
||||
CASE WHEN company_code = $2 THEN 0 ELSE 1 END`,
|
||||
[tableName, companyCode]
|
||||
);
|
||||
|
||||
mappings.forEach((m: any) => {
|
||||
if (!categoryMappings.has(m.columnName)) {
|
||||
categoryMappings.set(m.columnName, []);
|
||||
logger.info("카테고리 매핑 조회 완료", {
|
||||
tableName,
|
||||
companyCode,
|
||||
mappingCount: mappings.length,
|
||||
});
|
||||
|
||||
mappings.forEach((m: any) => {
|
||||
if (!categoryMappings.has(m.columnName)) {
|
||||
categoryMappings.set(m.columnName, []);
|
||||
}
|
||||
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
|
||||
});
|
||||
} else {
|
||||
logger.info("⚠️ menu_objid 컬럼이 없음, 카테고리 매핑 스킵");
|
||||
}
|
||||
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
|
||||
});
|
||||
} catch (mappingError: any) {
|
||||
logger.warn("⚠️ 카테고리 매핑 조회 실패, 스킵", {
|
||||
error: mappingError.message,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("categoryMappings Map 생성 완료", {
|
||||
size: categoryMappings.size,
|
||||
|
||||
Reference in New Issue
Block a user