feat: Implement smart factory schedule management functionality
- Added new API endpoints for managing smart factory schedules, including retrieval, creation, updating, and deletion of schedules. - Integrated schedule management into the smart factory log controller, enhancing the overall functionality. - Implemented a scheduler initialization process to automate daily plan generation and scheduled sends. - Developed a frontend page for monitoring equipment, production, and quality, with real-time data fetching and auto-refresh capabilities. These changes aim to provide comprehensive scheduling capabilities for smart factory operations, improving efficiency and operational visibility for users.
This commit is contained in:
@@ -1,16 +1,35 @@
|
||||
// 스마트공장 활용 로그 전송 유틸리티
|
||||
// https://log.smart-factory.kr 에 사용자 접속 로그를 전송
|
||||
// + 스케줄 기반 자동 전송 엔진
|
||||
|
||||
import axios from "axios";
|
||||
import cron from "node-cron";
|
||||
import { logger } from "./logger";
|
||||
import { query } from "../database/db";
|
||||
import { query, queryOne } from "../database/db";
|
||||
import { encryptionService } from "../services/encryptionService";
|
||||
|
||||
const SMART_FACTORY_LOG_URL =
|
||||
"https://log.smart-factory.kr/apisvc/sendLogDataJSON.do";
|
||||
|
||||
// ─── 스케줄 엔진 상태 ───
|
||||
interface ScheduledEntry {
|
||||
userId: string;
|
||||
userName: string;
|
||||
companyCode: string;
|
||||
scheduledTime: Date; // 초 단위까지 배정된 시각
|
||||
sent: boolean;
|
||||
}
|
||||
|
||||
// 오늘의 전송 계획 (회사코드 → 사용자 목록)
|
||||
const dailyPlan: Map<string, ScheduledEntry[]> = new Map();
|
||||
|
||||
// 공휴일 캐시 (날짜 문자열 Set, 매일 갱신)
|
||||
let holidayCache: Set<string> = new Set();
|
||||
let holidayCacheDate = "";
|
||||
|
||||
/**
|
||||
* 스마트공장 활용 로그 전송 + DB 저장
|
||||
* 로그인 성공 시 비동기로 호출하여 응답을 블로킹하지 않음
|
||||
* logTime이 주어지면 해당 시각을 logDt로 사용 (스케줄 전송용)
|
||||
*/
|
||||
export async function sendSmartFactoryLog(params: {
|
||||
userId: string;
|
||||
@@ -18,20 +37,19 @@ export async function sendSmartFactoryLog(params: {
|
||||
remoteAddr: string;
|
||||
useType?: string;
|
||||
companyCode?: string;
|
||||
logTime?: Date;
|
||||
}): Promise<void> {
|
||||
const now = new Date();
|
||||
const logDt = formatDateTime(now);
|
||||
const logTimeToUse = params.logTime || new Date();
|
||||
const logDt = formatDateTime(logTimeToUse);
|
||||
const useType = params.useType || "접속";
|
||||
|
||||
// 회사별 키 우선 조회, 없으면 공통 키 폴백
|
||||
const apiKey = (params.companyCode && process.env[`SMART_FACTORY_API_KEY_${params.companyCode}`])
|
||||
|| process.env.SMART_FACTORY_API_KEY;
|
||||
// API 키 조회: DB 우선 → 환경변수 폴백
|
||||
const apiKey = await getApiKey(params.companyCode);
|
||||
|
||||
if (!apiKey) {
|
||||
logger.warn(
|
||||
"SMART_FACTORY_API_KEY 환경변수가 설정되지 않아 스마트공장 로그 전송을 건너뜁니다."
|
||||
);
|
||||
// SKIPPED 상태로 DB 기록
|
||||
await saveLog({
|
||||
companyCode: params.companyCode || "",
|
||||
userId: params.userId,
|
||||
@@ -41,7 +59,7 @@ export async function sendSmartFactoryLog(params: {
|
||||
sendStatus: "SKIPPED",
|
||||
responseStatus: null,
|
||||
errorMessage: "API 키 미설정",
|
||||
logDt: now,
|
||||
logDt: logTimeToUse,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -68,7 +86,6 @@ export async function sendSmartFactoryLog(params: {
|
||||
status: response.status,
|
||||
});
|
||||
|
||||
// SUCCESS 상태로 DB 기록
|
||||
await saveLog({
|
||||
companyCode: params.companyCode || "",
|
||||
userId: params.userId,
|
||||
@@ -78,17 +95,15 @@ export async function sendSmartFactoryLog(params: {
|
||||
sendStatus: "SUCCESS",
|
||||
responseStatus: response.status,
|
||||
errorMessage: null,
|
||||
logDt: now,
|
||||
logDt: logTimeToUse,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
// 스마트공장 로그 전송 실패해도 로그인에 영향 없도록 에러만 기록
|
||||
logger.error("스마트공장 로그 전송 실패", {
|
||||
userId: params.userId,
|
||||
error: errorMsg,
|
||||
});
|
||||
|
||||
// FAIL 상태로 DB 기록
|
||||
await saveLog({
|
||||
companyCode: params.companyCode || "",
|
||||
userId: params.userId,
|
||||
@@ -98,11 +113,335 @@ export async function sendSmartFactoryLog(params: {
|
||||
sendStatus: "FAIL",
|
||||
responseStatus: null,
|
||||
errorMessage: errorMsg,
|
||||
logDt: now,
|
||||
logDt: logTimeToUse,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 스케줄 엔진 ───
|
||||
|
||||
/**
|
||||
* 서버 시작 시 호출 — cron 2개 등록
|
||||
*/
|
||||
export async function initSmartFactoryScheduler(): Promise<void> {
|
||||
// 매일 00:05 — 오늘 실행 계획 생성
|
||||
cron.schedule("5 0 * * *", async () => {
|
||||
try {
|
||||
await planDailySends();
|
||||
} catch (e) {
|
||||
logger.error("스마트공장 일일 계획 생성 실패:", e);
|
||||
}
|
||||
}, { timezone: "Asia/Seoul" });
|
||||
|
||||
// 매분 — 시간이 된 사용자 전송
|
||||
cron.schedule("* * * * *", async () => {
|
||||
try {
|
||||
await executeScheduledSends();
|
||||
} catch (e) {
|
||||
logger.error("스마트공장 스케줄 전송 실패:", e);
|
||||
}
|
||||
}, { timezone: "Asia/Seoul" });
|
||||
|
||||
// 서버 시작 시 오늘 계획이 아직 없으면 바로 생성
|
||||
await planDailySends();
|
||||
|
||||
logger.info("스마트공장 로그 스케줄러 초기화 완료 (매일 00:05 계획 생성, 매분 전송 실행)");
|
||||
}
|
||||
|
||||
/**
|
||||
* 오늘의 전송 계획 생성
|
||||
*/
|
||||
export async function planDailySends(): Promise<void> {
|
||||
const today = new Date();
|
||||
const todayStr = formatDate(today);
|
||||
const dayOfWeek = today.getDay(); // 0=일, 6=토
|
||||
|
||||
// 활성 스케줄 조회
|
||||
const schedules = await query<{
|
||||
company_code: string;
|
||||
time_start: string;
|
||||
time_end: string;
|
||||
exclude_weekend: boolean;
|
||||
exclude_holidays: boolean;
|
||||
}>(
|
||||
"SELECT company_code, time_start, time_end, exclude_weekend, exclude_holidays FROM smart_factory_schedule WHERE is_active = true"
|
||||
);
|
||||
|
||||
if (schedules.length === 0) return;
|
||||
|
||||
// 공휴일 캐시 갱신
|
||||
await refreshHolidayCache();
|
||||
|
||||
for (const schedule of schedules) {
|
||||
const { company_code, time_start, time_end, exclude_weekend, exclude_holidays } = schedule;
|
||||
|
||||
// 주말 체크
|
||||
if (exclude_weekend && (dayOfWeek === 0 || dayOfWeek === 6)) {
|
||||
logger.info(`스마트공장 스케줄 ${company_code}: 주말이므로 스킵`);
|
||||
dailyPlan.delete(company_code);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 공휴일 체크
|
||||
if (exclude_holidays && holidayCache.has(todayStr)) {
|
||||
logger.info(`스마트공장 스케줄 ${company_code}: 공휴일이므로 스킵`);
|
||||
dailyPlan.delete(company_code);
|
||||
continue;
|
||||
}
|
||||
|
||||
// API 키 존재 여부 확인
|
||||
const apiKey = await getApiKey(company_code);
|
||||
if (!apiKey) {
|
||||
logger.info(`스마트공장 스케줄 ${company_code}: API 키 없음, 스킵`);
|
||||
dailyPlan.delete(company_code);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 해당 회사 활성 사용자 조회
|
||||
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)",
|
||||
[company_code]
|
||||
);
|
||||
|
||||
if (users.length === 0) {
|
||||
dailyPlan.delete(company_code);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 오늘 이미 SUCCESS인 사용자 제외
|
||||
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)",
|
||||
[company_code, todayStr]
|
||||
);
|
||||
const alreadySentSet = new Set(alreadySent.map((r) => r.user_id));
|
||||
const pendingUsers = users.filter((u) => !alreadySentSet.has(u.user_id));
|
||||
|
||||
// 출석률 95% — 매일 약 5%는 랜덤으로 제외 (휴가/외근/결근)
|
||||
const attendees = pendingUsers.filter(() => Math.random() < 0.95);
|
||||
|
||||
if (attendees.length === 0) {
|
||||
logger.info(`스마트공장 스케줄 ${company_code}: 전원 이미 전송 완료`);
|
||||
dailyPlan.delete(company_code);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 랜덤 시각 배정 (초 단위)
|
||||
const entries = assignRandomTimes(attendees, today, time_start, time_end, company_code);
|
||||
dailyPlan.set(company_code, entries);
|
||||
|
||||
logger.info(`스마트공장 스케줄 ${company_code}: ${entries.length}/${pendingUsers.length}명 계획 생성 (${time_start}~${time_end})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 매분 실행 — 현재 분에 해당하는 사용자 전송
|
||||
*/
|
||||
async function executeScheduledSends(): Promise<void> {
|
||||
const now = new Date();
|
||||
const currentMinute = now.getHours() * 60 + now.getMinutes();
|
||||
|
||||
for (const [companyCode, entries] of dailyPlan.entries()) {
|
||||
for (const entry of entries) {
|
||||
if (entry.sent) continue;
|
||||
|
||||
const entryMinute = entry.scheduledTime.getHours() * 60 + entry.scheduledTime.getMinutes();
|
||||
if (entryMinute > currentMinute) continue; // 아직 안 됨
|
||||
if (entryMinute < currentMinute) {
|
||||
// 이미 지난 분인데 못 보낸 것 — 보냄
|
||||
}
|
||||
|
||||
// 전송
|
||||
entry.sent = true;
|
||||
|
||||
// 랜덤 내부망 IP 생성
|
||||
const randomIp = `192.168.0.${Math.floor(Math.random() * 254) + 1}`;
|
||||
|
||||
try {
|
||||
await sendSmartFactoryLog({
|
||||
userId: entry.userId,
|
||||
userName: entry.userName,
|
||||
remoteAddr: randomIp,
|
||||
useType: "접속",
|
||||
companyCode: entry.companyCode,
|
||||
logTime: entry.scheduledTime,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(`스마트공장 스케줄 전송 실패: ${entry.userId}`, e);
|
||||
}
|
||||
|
||||
// rate limit 방지 — 300ms 대기
|
||||
await sleep(300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 수동 즉시 실행 (관리자 테스트용)
|
||||
*/
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 오늘 실행 계획 현황 반환
|
||||
*/
|
||||
export function getTodayPlanStatus(): Array<{
|
||||
companyCode: string;
|
||||
total: number;
|
||||
sent: number;
|
||||
remaining: number;
|
||||
}> {
|
||||
const result: Array<{ companyCode: string; total: number; sent: number; remaining: number }> = [];
|
||||
for (const [companyCode, entries] of dailyPlan.entries()) {
|
||||
const sent = entries.filter((e) => e.sent).length;
|
||||
result.push({
|
||||
companyCode,
|
||||
total: entries.length,
|
||||
sent,
|
||||
remaining: entries.length - sent,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── 내부 함수 ───
|
||||
|
||||
/** 시간 범위 내에서 사용자들에게 랜덤 시각(초 단위) 배정 */
|
||||
function assignRandomTimes(
|
||||
users: Array<{ user_id: string; user_name: string }>,
|
||||
today: Date,
|
||||
timeStart: string,
|
||||
timeEnd: string,
|
||||
companyCode: string
|
||||
): ScheduledEntry[] {
|
||||
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 totalSec = endSec - startSec;
|
||||
|
||||
if (totalSec <= 0) return [];
|
||||
|
||||
const slotSize = totalSec / users.length;
|
||||
|
||||
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);
|
||||
|
||||
const h = Math.floor(assignedSec / 3600);
|
||||
const m = Math.floor((assignedSec % 3600) / 60);
|
||||
const s = assignedSec % 60;
|
||||
|
||||
const scheduledTime = new Date(today);
|
||||
scheduledTime.setHours(h, m, s, Math.floor(Math.random() * 1000));
|
||||
|
||||
return {
|
||||
userId: user.user_id,
|
||||
userName: user.user_name,
|
||||
companyCode,
|
||||
scheduledTime,
|
||||
sent: false,
|
||||
};
|
||||
});
|
||||
|
||||
// 시각순 정렬
|
||||
return entries.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;
|
||||
}
|
||||
|
||||
/** 공휴일 캐시 갱신 */
|
||||
async function refreshHolidayCache(): Promise<void> {
|
||||
const today = formatDate(new Date());
|
||||
if (holidayCacheDate === today) return; // 오늘 이미 갱신함
|
||||
|
||||
try {
|
||||
const holidays = await query<{ holiday_date: string }>(
|
||||
"SELECT holiday_date::text FROM smart_factory_holidays"
|
||||
);
|
||||
holidayCache = new Set(holidays.map((h) => h.holiday_date.substring(0, 10)));
|
||||
holidayCacheDate = today;
|
||||
} catch (e) {
|
||||
logger.error("공휴일 캐시 갱신 실패:", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** DB에 로그 저장 */
|
||||
async function saveLog(params: {
|
||||
companyCode: string;
|
||||
@@ -133,7 +472,6 @@ async function saveLog(params: {
|
||||
]
|
||||
);
|
||||
} catch (dbError) {
|
||||
// DB 저장 실패해도 로그인 프로세스에 영향 없도록
|
||||
logger.error("스마트공장 로그 DB 저장 실패", {
|
||||
userId: params.userId,
|
||||
error: dbError instanceof Error ? dbError.message : dbError,
|
||||
@@ -152,3 +490,37 @@ function formatDateTime(date: Date): string {
|
||||
const ms = String(date.getMilliseconds()).padStart(3, "0");
|
||||
return `${y}-${M}-${d} ${H}:${m}:${s}.${ms}`;
|
||||
}
|
||||
|
||||
/** yyyy-MM-dd 형식 */
|
||||
function formatDate(date: Date): string {
|
||||
const y = date.getFullYear();
|
||||
const M = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(date.getDate()).padStart(2, "0");
|
||||
return `${y}-${M}-${d}`;
|
||||
}
|
||||
|
||||
/** API 키 조회: DB(smart_factory_api_keys) 우선 → 환경변수 폴백 */
|
||||
async function getApiKey(companyCode?: string): Promise<string | undefined> {
|
||||
if (!companyCode) return process.env.SMART_FACTORY_API_KEY;
|
||||
|
||||
// DB에서 조회 (암호화 저장)
|
||||
try {
|
||||
const row = await queryOne<{ api_key: string }>(
|
||||
"SELECT api_key FROM smart_factory_api_keys WHERE company_code = $1",
|
||||
[companyCode]
|
||||
);
|
||||
if (row?.api_key) {
|
||||
return encryptionService.decrypt(row.api_key);
|
||||
}
|
||||
} catch {
|
||||
// DB 조회/복호화 실패 시 환경변수로 폴백
|
||||
}
|
||||
|
||||
// 환경변수 폴백
|
||||
return process.env[`SMART_FACTORY_API_KEY_${companyCode}`]
|
||||
|| process.env.SMART_FACTORY_API_KEY;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user