feat: 스케줄 자동 생성 기능 및 이벤트 발송 설정 추가

- 스케줄 자동 생성 관련 라우트를 추가하여 API 연동을 구현하였습니다.
- 버튼 설정 패널에 이벤트 발송 옵션을 추가하여 사용자가 이벤트를 설정할 수 있도록 하였습니다.
- 타임라인 스케줄러 컴포넌트에서 스케줄 데이터 필터링 및 선택된 품목에 따른 스케줄 로드 기능을 개선하였습니다.
- 이벤트 버스를 통해 다른 컴포넌트와의 상호작용을 강화하였습니다.
- 관련 문서 및 주석을 업데이트하여 새로운 기능에 대한 이해를 돕도록 하였습니다.
This commit is contained in:
kjs
2026-02-03 09:34:25 +09:00
parent 61b67c3619
commit f845dadc5d
19 changed files with 2026 additions and 297 deletions

View File

@@ -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); // 권한 그룹 관리

View 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 || "스케줄 삭제 중 오류가 발생했습니다.",
});
}
};
}

View 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;

View 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;
}
}

View File

@@ -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,