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:
kjs
2026-04-07 14:16:26 +09:00
parent 9aa8ca136b
commit c3e973bb1a
13 changed files with 3222 additions and 389 deletions

View File

@@ -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));
}