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:
@@ -450,6 +450,7 @@ async function initializeServices() {
|
||||
runUserMailAccountsMigration,
|
||||
runMessengerMigration,
|
||||
runSmartFactoryLogMigration,
|
||||
runSmartFactoryScheduleMigration,
|
||||
} = await import("./database/runMigration");
|
||||
|
||||
await runDashboardMigration();
|
||||
@@ -459,6 +460,7 @@ async function initializeServices() {
|
||||
await runUserMailAccountsMigration();
|
||||
await runMessengerMigration();
|
||||
await runSmartFactoryLogMigration();
|
||||
await runSmartFactoryScheduleMigration();
|
||||
} catch (error) {
|
||||
logger.error(`❌ 마이그레이션 실패:`, error);
|
||||
}
|
||||
@@ -472,6 +474,11 @@ async function initializeServices() {
|
||||
const { CrawlService } = await import("./services/crawlService");
|
||||
await CrawlService.initializeScheduler();
|
||||
logger.info(`🕷️ 크롤링 스케줄러가 시작되었습니다.`);
|
||||
|
||||
// 스마트공장 로그 스케줄러 초기화
|
||||
const { initSmartFactoryScheduler } = await import("./utils/smartFactoryLog");
|
||||
await initSmartFactoryScheduler();
|
||||
logger.info(`🏭 스마트공장 로그 스케줄러가 시작되었습니다.`);
|
||||
} catch (error) {
|
||||
logger.error(`❌ 배치 스케줄러 초기화 실패:`, error);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,12 @@ import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../middleware/permissionMiddleware";
|
||||
import { query, queryOne } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import { encryptionService } from "../services/encryptionService";
|
||||
import {
|
||||
runScheduleNow,
|
||||
getTodayPlanStatus,
|
||||
planDailySends,
|
||||
} from "../utils/smartFactoryLog";
|
||||
|
||||
/**
|
||||
* GET /api/admin/smart-factory-log
|
||||
@@ -216,3 +222,283 @@ export const getSmartFactoryLogStats = async (
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ─── 스케줄 관리 API ───
|
||||
|
||||
/**
|
||||
* GET /api/admin/smart-factory-log/schedules
|
||||
*/
|
||||
export const getSchedules = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const schedules = await query<any>(
|
||||
`SELECT s.*, cm.company_name
|
||||
FROM smart_factory_schedule s
|
||||
LEFT JOIN company_mng cm ON cm.company_code = s.company_code
|
||||
ORDER BY s.company_code`
|
||||
);
|
||||
res.json({ success: true, data: schedules });
|
||||
} catch (error) {
|
||||
logger.error("스케줄 조회 실패:", error);
|
||||
res.status(500).json({ success: false, message: "스케줄 조회 실패" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/admin/smart-factory-log/schedules
|
||||
*/
|
||||
export const upsertSchedule = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { companyCode, isActive, timeStart, timeEnd, excludeWeekend, excludeHolidays } = req.body;
|
||||
|
||||
if (!companyCode) {
|
||||
res.status(400).json({ success: false, message: "회사코드는 필수입니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
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())
|
||||
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()`,
|
||||
[
|
||||
companyCode,
|
||||
isActive ?? false,
|
||||
timeStart || "08:30",
|
||||
timeEnd || "17:30",
|
||||
excludeWeekend ?? true,
|
||||
excludeHolidays ?? true,
|
||||
]
|
||||
);
|
||||
|
||||
// 스케줄 변경 시 오늘 계획 재생성
|
||||
await planDailySends();
|
||||
|
||||
res.json({ success: true, message: "스케줄이 저장되었습니다." });
|
||||
} catch (error) {
|
||||
logger.error("스케줄 저장 실패:", error);
|
||||
res.status(500).json({ success: false, message: "스케줄 저장 실패" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/smart-factory-log/schedules/:companyCode
|
||||
*/
|
||||
export const deleteSchedule = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { companyCode } = req.params;
|
||||
await query("DELETE FROM smart_factory_schedule WHERE company_code = $1", [companyCode]);
|
||||
res.json({ success: true, message: "스케줄이 삭제되었습니다." });
|
||||
} catch (error) {
|
||||
logger.error("스케줄 삭제 실패:", error);
|
||||
res.status(500).json({ success: false, message: "스케줄 삭제 실패" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/admin/smart-factory-log/schedules/:companyCode/run-now
|
||||
*/
|
||||
export const runScheduleNowHandler = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
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
|
||||
*/
|
||||
export const getTodayPlanHandler = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const plan = getTodayPlanStatus();
|
||||
res.json({ success: true, data: plan });
|
||||
} catch (error) {
|
||||
logger.error("오늘 계획 조회 실패:", error);
|
||||
res.status(500).json({ success: false, message: "오늘 계획 조회 실패" });
|
||||
}
|
||||
};
|
||||
|
||||
// ─── 공휴일 관리 API ───
|
||||
|
||||
/**
|
||||
* GET /api/admin/smart-factory-log/holidays
|
||||
*/
|
||||
export const getHolidays = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const holidays = await query<any>(
|
||||
"SELECT id, holiday_date, holiday_name, created_at FROM smart_factory_holidays ORDER BY holiday_date"
|
||||
);
|
||||
res.json({ success: true, data: holidays });
|
||||
} catch (error) {
|
||||
logger.error("공휴일 조회 실패:", error);
|
||||
res.status(500).json({ success: false, message: "공휴일 조회 실패" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/admin/smart-factory-log/holidays
|
||||
*/
|
||||
export const addHoliday = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { holidayDate, holidayName } = req.body;
|
||||
|
||||
if (!holidayDate || !holidayName) {
|
||||
res.status(400).json({ success: false, message: "날짜와 이름은 필수입니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
await query(
|
||||
"INSERT INTO smart_factory_holidays (holiday_date, holiday_name) VALUES ($1, $2) ON CONFLICT (holiday_date) DO UPDATE SET holiday_name = $2",
|
||||
[holidayDate, holidayName]
|
||||
);
|
||||
|
||||
res.json({ success: true, message: "공휴일이 추가되었습니다." });
|
||||
} catch (error) {
|
||||
logger.error("공휴일 추가 실패:", error);
|
||||
res.status(500).json({ success: false, message: "공휴일 추가 실패" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/smart-factory-log/holidays/:id
|
||||
*/
|
||||
export const deleteHoliday = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await query("DELETE FROM smart_factory_holidays WHERE id = $1", [id]);
|
||||
res.json({ success: true, message: "공휴일이 삭제되었습니다." });
|
||||
} catch (error) {
|
||||
logger.error("공휴일 삭제 실패:", error);
|
||||
res.status(500).json({ success: false, message: "공휴일 삭제 실패" });
|
||||
}
|
||||
};
|
||||
|
||||
// ─── API 키 관리 ───
|
||||
|
||||
/**
|
||||
* GET /api/admin/smart-factory-log/api-keys
|
||||
* 전체 회사 목록 + API 키 상태 (DB키 여부, 환경변수 여부)
|
||||
*/
|
||||
export const getApiKeys = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const companies = await query<any>(
|
||||
`SELECT cm.company_code, cm.company_name, ak.api_key
|
||||
FROM company_mng cm
|
||||
LEFT JOIN smart_factory_api_keys ak ON ak.company_code = cm.company_code
|
||||
WHERE cm.company_code != '*'
|
||||
ORDER BY cm.company_code`
|
||||
);
|
||||
|
||||
const result = companies.map((c: any) => {
|
||||
let dbKeyDecrypted: string | null = null;
|
||||
if (c.api_key) {
|
||||
try {
|
||||
dbKeyDecrypted = encryptionService.decrypt(c.api_key);
|
||||
} catch {
|
||||
dbKeyDecrypted = "(복호화 실패)";
|
||||
}
|
||||
}
|
||||
return {
|
||||
companyCode: c.company_code,
|
||||
companyName: c.company_name,
|
||||
hasDbKey: !!c.api_key,
|
||||
dbKey: dbKeyDecrypted,
|
||||
hasEnvKey: !!process.env[`SMART_FACTORY_API_KEY_${c.company_code}`],
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
logger.error("API 키 목록 조회 실패:", error);
|
||||
res.status(500).json({ success: false, message: "API 키 목록 조회 실패" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/admin/smart-factory-log/api-keys
|
||||
* API 키 저장 (암호화)
|
||||
*/
|
||||
export const saveApiKey = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { companyCode, apiKey } = req.body;
|
||||
|
||||
if (!companyCode || !apiKey) {
|
||||
res.status(400).json({ success: false, message: "회사코드와 API 키는 필수입니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const encrypted = encryptionService.encrypt(apiKey);
|
||||
|
||||
await query(
|
||||
`INSERT INTO smart_factory_api_keys (company_code, api_key, updated_at)
|
||||
VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (company_code) DO UPDATE SET api_key = $2, updated_at = NOW()`,
|
||||
[companyCode, encrypted]
|
||||
);
|
||||
|
||||
res.json({ success: true, message: "API 키가 저장되었습니다." });
|
||||
} catch (error) {
|
||||
logger.error("API 키 저장 실패:", error);
|
||||
res.status(500).json({ success: false, message: "API 키 저장 실패" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/smart-factory-log/api-keys/:companyCode
|
||||
* API 키 삭제 (환경변수 폴백으로 전환)
|
||||
*/
|
||||
export const deleteApiKey = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { companyCode } = req.params;
|
||||
|
||||
await query(
|
||||
"DELETE FROM smart_factory_api_keys WHERE company_code = $1",
|
||||
[companyCode]
|
||||
);
|
||||
|
||||
res.json({ success: true, message: "API 키가 삭제되었습니다." });
|
||||
} catch (error) {
|
||||
logger.error("API 키 삭제 실패:", error);
|
||||
res.status(500).json({ success: false, message: "API 키 삭제 실패" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -173,6 +173,35 @@ export async function runMessengerMigration() {
|
||||
/**
|
||||
* 스마트공장 활용 로그 테이블 마이그레이션
|
||||
*/
|
||||
/**
|
||||
* 스마트공장 스케줄 + 공휴일 테이블 마이그레이션
|
||||
*/
|
||||
export async function runSmartFactoryScheduleMigration() {
|
||||
try {
|
||||
console.log("🔄 스마트공장 스케줄 테이블 마이그레이션 시작...");
|
||||
|
||||
const sqlFilePath = path.join(
|
||||
__dirname,
|
||||
"../../db/migrations/201_create_smart_factory_schedule.sql"
|
||||
);
|
||||
|
||||
if (!fs.existsSync(sqlFilePath)) {
|
||||
console.log("⚠️ 마이그레이션 파일이 없습니다:", sqlFilePath);
|
||||
return;
|
||||
}
|
||||
|
||||
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
|
||||
await PostgreSQLService.query(sqlContent);
|
||||
|
||||
console.log("✅ 스마트공장 스케줄 테이블 마이그레이션 완료!");
|
||||
} catch (error) {
|
||||
console.error("❌ 스마트공장 스케줄 테이블 마이그레이션 실패:", error);
|
||||
if (error instanceof Error && error.message.includes("already exists")) {
|
||||
console.log("ℹ️ 테이블이 이미 존재합니다.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSmartFactoryLogMigration() {
|
||||
try {
|
||||
console.log("🔄 스마트공장 로그 테이블 마이그레이션 시작...");
|
||||
|
||||
@@ -35,6 +35,17 @@ import {
|
||||
import {
|
||||
getSmartFactoryLogs,
|
||||
getSmartFactoryLogStats,
|
||||
getSchedules,
|
||||
upsertSchedule,
|
||||
deleteSchedule,
|
||||
runScheduleNowHandler,
|
||||
getTodayPlanHandler,
|
||||
getHolidays,
|
||||
addHoliday,
|
||||
deleteHoliday,
|
||||
getApiKeys,
|
||||
saveApiKey,
|
||||
deleteApiKey,
|
||||
} from "../controllers/smartFactoryLogController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { requireSuperAdmin } from "../middleware/permissionMiddleware";
|
||||
@@ -92,4 +103,21 @@ router.get("/tables/:tableName/schema", getTableSchema);
|
||||
router.get("/smart-factory-log", requireSuperAdmin, getSmartFactoryLogs);
|
||||
router.get("/smart-factory-log/stats", requireSuperAdmin, getSmartFactoryLogStats);
|
||||
|
||||
// 스마트공장 스케줄 관리 (최고관리자 전용)
|
||||
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);
|
||||
router.post("/smart-factory-log/holidays", requireSuperAdmin, addHoliday);
|
||||
router.delete("/smart-factory-log/holidays/:id", requireSuperAdmin, deleteHoliday);
|
||||
|
||||
// 스마트공장 API 키 관리 (최고관리자 전용)
|
||||
router.get("/smart-factory-log/api-keys", requireSuperAdmin, getApiKeys);
|
||||
router.post("/smart-factory-log/api-keys", requireSuperAdmin, saveApiKey);
|
||||
router.delete("/smart-factory-log/api-keys/:companyCode", requireSuperAdmin, deleteApiKey);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -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