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

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

View File

@@ -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 키 삭제 실패" });
}
};

View File

@@ -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("🔄 스마트공장 로그 테이블 마이그레이션 시작...");

View File

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

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