diff --git a/backend-node/src/controllers/smartFactoryLogController.ts b/backend-node/src/controllers/smartFactoryLogController.ts index 9809b97f..80158d41 100644 --- a/backend-node/src/controllers/smartFactoryLogController.ts +++ b/backend-node/src/controllers/smartFactoryLogController.ts @@ -8,7 +8,6 @@ import { logger } from "../utils/logger"; import { encryptionService } from "../services/encryptionService"; import { sendSmartFactoryLog, - runScheduleNow, getTodayPlanStatus, planDailySends, } from "../utils/smartFactoryLog"; @@ -255,7 +254,7 @@ export const upsertSchedule = async ( res: Response ): Promise => { try { - const { companyCode, isActive, timeStart, timeEnd, excludeWeekend, excludeHolidays } = req.body; + const { companyCode, isActive, timeStart, timeEnd, excludeWeekend, excludeHolidays, dailyCount } = req.body; if (!companyCode) { res.status(400).json({ success: false, message: "회사코드는 필수입니다." }); @@ -263,11 +262,11 @@ export const upsertSchedule = async ( } await query( - `INSERT INTO smart_factory_schedule (company_code, is_active, time_start, time_end, exclude_weekend, exclude_holidays, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, NOW()) + `INSERT INTO smart_factory_schedule (company_code, is_active, time_start, time_end, exclude_weekend, exclude_holidays, daily_count, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) ON CONFLICT (company_code) DO UPDATE SET is_active = $2, time_start = $3, time_end = $4, - exclude_weekend = $5, exclude_holidays = $6, updated_at = NOW()`, + exclude_weekend = $5, exclude_holidays = $6, daily_count = $7, updated_at = NOW()`, [ companyCode, isActive ?? false, @@ -275,6 +274,7 @@ export const upsertSchedule = async ( timeEnd || "17:30", excludeWeekend ?? true, excludeHolidays ?? true, + Math.max(1, Math.min(3, dailyCount || 1)), ] ); @@ -308,23 +308,6 @@ export const deleteSchedule = async ( /** * POST /api/admin/smart-factory-log/schedules/:companyCode/run-now */ -export const runScheduleNowHandler = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const { companyCode } = req.params; - const result = await runScheduleNow(companyCode); - res.json({ success: true, data: result }); - } catch (error) { - logger.error("즉시 실행 실패:", error); - res.status(500).json({ - success: false, - message: error instanceof Error ? error.message : "즉시 실행 실패", - }); - } -}; - /** * GET /api/admin/smart-factory-log/schedules/today-plan */ diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index 6527d39e..cd31c8a4 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -38,7 +38,6 @@ import { getSchedules, upsertSchedule, deleteSchedule, - runScheduleNowHandler, getTodayPlanHandler, getHolidays, addHoliday, @@ -110,7 +109,7 @@ router.get("/smart-factory-log/schedules", requireSuperAdmin, getSchedules); router.get("/smart-factory-log/schedules/today-plan", requireSuperAdmin, getTodayPlanHandler); router.post("/smart-factory-log/schedules", requireSuperAdmin, upsertSchedule); router.delete("/smart-factory-log/schedules/:companyCode", requireSuperAdmin, deleteSchedule); -router.post("/smart-factory-log/schedules/:companyCode/run-now", requireSuperAdmin, runScheduleNowHandler); + // 스마트공장 공휴일 관리 (최고관리자 전용) router.get("/smart-factory-log/holidays", requireSuperAdmin, getHolidays); diff --git a/backend-node/src/utils/smartFactoryLog.ts b/backend-node/src/utils/smartFactoryLog.ts index 8a88bb99..2b1116b5 100644 --- a/backend-node/src/utils/smartFactoryLog.ts +++ b/backend-node/src/utils/smartFactoryLog.ts @@ -16,7 +16,8 @@ interface ScheduledEntry { userId: string; userName: string; companyCode: string; - scheduledTime: Date; // 초 단위까지 배정된 시각 + scheduledTime: Date; + useType: "접속" | "종료"; sent: boolean; } @@ -165,8 +166,9 @@ export async function planDailySends(): Promise { time_end: string; exclude_weekend: boolean; exclude_holidays: boolean; + daily_count: number; }>( - "SELECT company_code, time_start, time_end, exclude_weekend, exclude_holidays FROM smart_factory_schedule WHERE is_active = true" + "SELECT company_code, time_start, time_end, exclude_weekend, exclude_holidays, daily_count FROM smart_factory_schedule WHERE is_active = true" ); if (schedules.length === 0) return; @@ -175,7 +177,7 @@ export async function planDailySends(): Promise { await refreshHolidayCache(); for (const schedule of schedules) { - const { company_code, time_start, time_end, exclude_weekend, exclude_holidays } = schedule; + const { company_code, time_start, time_end, exclude_weekend, exclude_holidays, daily_count } = schedule; // 주말 체크 if (exclude_weekend && (dayOfWeek === 0 || dayOfWeek === 6)) { @@ -227,11 +229,12 @@ export async function planDailySends(): Promise { continue; } - // 랜덤 시각 배정 (초 단위) - const entries = assignRandomTimes(attendees, today, time_start, time_end, company_code); + // 접속/종료 쌍 + 다회 시각 배정 + const entries = assignSessionPairs(attendees, today, time_start, time_end, company_code, daily_count); dailyPlan.set(company_code, entries); - logger.info(`스마트공장 스케줄 ${company_code}: ${entries.length}/${pendingUsers.length}명 계획 생성 (${time_start}~${time_end})`); + const sessionCount = entries.filter((e) => e.useType === "접속").length; + logger.info(`스마트공장 스케줄 ${company_code}: ${attendees.length}명 × 최대${daily_count}회 = ${sessionCount}세션 계획 (${time_start}~${time_end})`); } } @@ -263,7 +266,7 @@ async function executeScheduledSends(): Promise { userId: entry.userId, userName: entry.userName, remoteAddr: randomIp, - useType: "접속", + useType: entry.useType, companyCode: entry.companyCode, logTime: entry.scheduledTime, }); @@ -277,71 +280,6 @@ async function executeScheduledSends(): Promise { } } -/** - * 수동 즉시 실행 (관리자 테스트용) - */ -export async function runScheduleNow(companyCode: string): Promise<{ total: number; sent: number; skipped: number }> { - const schedule = await query<{ - time_start: string; - time_end: string; - }>( - "SELECT time_start, time_end FROM smart_factory_schedule WHERE company_code = $1 AND is_active = true", - [companyCode] - ); - - if (schedule.length === 0) { - throw new Error("활성 스케줄이 없습니다."); - } - - // API 키 확인 - const apiKey = await getApiKey(companyCode); - if (!apiKey) { - throw new Error("API 키가 설정되지 않았습니다. API 키 관리에서 먼저 등록해주세요."); - } - - const { time_start, time_end } = schedule[0]; - const today = new Date(); - - // 사용자 조회 - const users = await query<{ user_id: string; user_name: string }>( - "SELECT user_id, user_name FROM user_info WHERE company_code = $1 AND (status = 'active' OR status IS NULL)", - [companyCode] - ); - - // 오늘 이미 전송된 사용자 제외 - const todayStr = formatDate(today); - const alreadySent = await query<{ user_id: string }>( - "SELECT DISTINCT user_id FROM smart_factory_log WHERE company_code = $1 AND send_status = 'SUCCESS' AND created_at >= $2::date AND created_at < ($2::date + 1)", - [companyCode, todayStr] - ); - const alreadySentSet = new Set(alreadySent.map((r) => r.user_id)); - const pendingUsers = users.filter((u) => !alreadySentSet.has(u.user_id)); - - let sent = 0; - for (const user of pendingUsers) { - // 시간 범위 내 랜덤 시각 생성 - const randomTime = generateRandomTime(today, time_start, time_end); - const randomIp = `192.168.0.${Math.floor(Math.random() * 254) + 1}`; - - try { - await sendSmartFactoryLog({ - userId: user.user_id, - userName: user.user_name, - remoteAddr: randomIp, - useType: "접속", - companyCode, - logTime: randomTime, - }); - sent++; - } catch (e) { - logger.error(`스마트공장 즉시 전송 실패: ${user.user_id}`, e); - } - - await sleep(300); - } - - return { total: users.length, sent, skipped: alreadySentSet.size }; -} /** * 오늘 실행 계획 현황 반환 @@ -367,13 +305,17 @@ export function getTodayPlanStatus(): Array<{ // ─── 내부 함수 ─── -/** 시간 범위 내에서 사용자들에게 랜덤 시각(초 단위) 배정 */ -function assignRandomTimes( +/** + * 사용자별 접속/종료 쌍을 생성 + * dailyCount: 최대 접속 횟수 (사용자별 1~dailyCount 랜덤) + */ +function assignSessionPairs( users: Array<{ user_id: string; user_name: string }>, today: Date, timeStart: string, timeEnd: string, - companyCode: string + companyCode: string, + dailyCount: number ): ScheduledEntry[] { const [startH, startM] = timeStart.split(":").map(Number); const [endH, endM] = timeEnd.split(":").map(Number); @@ -383,49 +325,68 @@ function assignRandomTimes( if (totalSec <= 0) return []; - const slotSize = totalSec / users.length; + const allEntries: ScheduledEntry[] = []; + const maxCount = Math.max(1, Math.min(3, dailyCount)); - const entries: ScheduledEntry[] = users.map((user, idx) => { - // 각 슬롯 내에서 랜덤 오프셋 (초 단위) - const slotStart = startSec + Math.floor(slotSize * idx); - const randomOffset = Math.floor(Math.random() * slotSize); - const assignedSec = Math.min(slotStart + randomOffset, endSec - 1); + for (const user of users) { + // 사용자별 1 ~ maxCount 사이 랜덤 횟수 + const count = Math.floor(Math.random() * maxCount) + 1; + // 시간대를 횟수로 균등 분할 + const slotSec = Math.floor(totalSec / count); - const h = Math.floor(assignedSec / 3600); - const m = Math.floor((assignedSec % 3600) / 60); - const s = assignedSec % 60; + for (let i = 0; i < count; i++) { + const slotStart = startSec + slotSec * i; + const slotEnd = i < count - 1 ? slotStart + slotSec : endSec; - const scheduledTime = new Date(today); - scheduledTime.setHours(h, m, s, Math.floor(Math.random() * 1000)); + // 접속 시각: 슬롯 전반부에서 랜덤 + const loginWindow = Math.floor((slotEnd - slotStart) * 0.4); // 슬롯의 앞 40% + const loginSec = slotStart + Math.floor(Math.random() * Math.max(loginWindow, 60)); + const clampedLoginSec = Math.min(loginSec, endSec - 120); // 최소 2분 여유 - return { - userId: user.user_id, - userName: user.user_name, - companyCode, - scheduledTime, - sent: false, - }; - }); + // 종료 시각: 접속 후 30분~2시간 사이 랜덤 + const minSession = 30 * 60; // 30분 + const maxSession = 120 * 60; // 2시간 + const sessionLen = minSession + Math.floor(Math.random() * (maxSession - minSession)); + const logoutSec = Math.min(clampedLoginSec + sessionLen, endSec - 1); + + // 접속과 종료 시각이 너무 가까우면(2분 미만) 스킵 + if (logoutSec - clampedLoginSec < 120) continue; + + const loginTime = secToDate(today, clampedLoginSec); + const logoutTime = secToDate(today, logoutSec); + + allEntries.push({ + userId: user.user_id, + userName: user.user_name, + companyCode, + scheduledTime: loginTime, + useType: "접속", + sent: false, + }); + + allEntries.push({ + userId: user.user_id, + userName: user.user_name, + companyCode, + scheduledTime: logoutTime, + useType: "종료", + sent: false, + }); + } + } // 시각순 정렬 - return entries.sort((a, b) => a.scheduledTime.getTime() - b.scheduledTime.getTime()); + return allEntries.sort((a, b) => a.scheduledTime.getTime() - b.scheduledTime.getTime()); } -/** 단일 랜덤 시각 생성 (즉시 실행용) */ -function generateRandomTime(today: Date, timeStart: string, timeEnd: string): Date { - const [startH, startM] = timeStart.split(":").map(Number); - const [endH, endM] = timeEnd.split(":").map(Number); - const startSec = startH * 3600 + startM * 60; - const endSec = endH * 3600 + endM * 60; - const randomSec = startSec + Math.floor(Math.random() * (endSec - startSec)); - - const h = Math.floor(randomSec / 3600); - const m = Math.floor((randomSec % 3600) / 60); - const s = randomSec % 60; - - const time = new Date(today); - time.setHours(h, m, s, Math.floor(Math.random() * 1000)); - return time; +/** 초(하루 내)를 Date로 변환 */ +function secToDate(today: Date, sec: number): Date { + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + const s = sec % 60; + const d = new Date(today); + d.setHours(h, m, s, Math.floor(Math.random() * 1000)); + return d; } /** 공휴일 캐시 갱신 */ diff --git a/frontend/app/(main)/admin/smart-factory-log/page.tsx b/frontend/app/(main)/admin/smart-factory-log/page.tsx index 053a8a03..6624d061 100644 --- a/frontend/app/(main)/admin/smart-factory-log/page.tsx +++ b/frontend/app/(main)/admin/smart-factory-log/page.tsx @@ -104,6 +104,7 @@ export default function SmartFactoryLogPage() { timeEnd: "17:30", excludeWeekend: true, excludeHolidays: true, + dailyCount: 1, }); const [apiKeys, setApiKeys] = useState([]); const [editingKey, setEditingKey] = useState({ companyCode: "", apiKey: "" }); @@ -232,7 +233,7 @@ export default function SmartFactoryLogPage() { await upsertSchedule({ companyCode: s.company_code, isActive: !s.is_active, timeStart: s.time_start, timeEnd: s.time_end, - excludeWeekend: s.exclude_weekend, excludeHolidays: s.exclude_holidays, + excludeWeekend: s.exclude_weekend, excludeHolidays: s.exclude_holidays, dailyCount: s.daily_count || 1, }); fetchSchedules(); } catch (e) { console.error("스케줄 토글 실패:", e); } @@ -549,7 +550,7 @@ export default function SmartFactoryLogPage() { 회사별 자동 전송 스케줄