From fc61a66287cf9c2a7387294cf0b9741b3b168e5a Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 7 Apr 2026 17:29:03 +0900 Subject: [PATCH] feat: Enhance smart factory schedule management - Added dailyCount parameter to the upsertSchedule function, allowing for the configuration of daily access limits for users. - Updated the smartFactoryLog interface to include daily_count, ensuring proper data handling for schedule management. - Removed the runScheduleNowHandler function from the smartFactoryLogController and adminRoutes, streamlining the API for schedule management. - Modified the frontend SmartFactoryLogPage to support dailyCount selection, improving user experience in managing schedules. These changes aim to enhance the flexibility and usability of the smart factory schedule management system, allowing for better control over user access and scheduling operations. --- .../controllers/smartFactoryLogController.ts | 27 +-- backend-node/src/routes/adminRoutes.ts | 3 +- backend-node/src/utils/smartFactoryLog.ts | 181 +++++++----------- .../(main)/admin/smart-factory-log/page.tsx | 23 ++- frontend/lib/api/smartFactoryLog.ts | 2 + frontend/next.config.mjs | 10 +- 6 files changed, 108 insertions(+), 138 deletions(-) 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() { 회사별 자동 전송 스케줄