- 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.
578 lines
17 KiB
TypeScript
578 lines
17 KiB
TypeScript
// 스마트공장 활용 로그 조회 컨트롤러
|
|
// 최고관리자(*) 전용 — 회사별 필터링 가능
|
|
|
|
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 {
|
|
sendSmartFactoryLog,
|
|
getTodayPlanStatus,
|
|
planDailySends,
|
|
} from "../utils/smartFactoryLog";
|
|
|
|
/**
|
|
* GET /api/admin/smart-factory-log
|
|
* 스마트공장 로그 목록 조회
|
|
*/
|
|
export const getSmartFactoryLogs = async (
|
|
req: AuthenticatedRequest,
|
|
res: Response
|
|
): Promise<void> => {
|
|
try {
|
|
const {
|
|
companyCode,
|
|
userId,
|
|
sendStatus,
|
|
dateFrom,
|
|
dateTo,
|
|
search,
|
|
page = "1",
|
|
limit = "50",
|
|
} = req.query;
|
|
|
|
const whereConditions: string[] = [];
|
|
const queryParams: any[] = [];
|
|
let paramIndex = 1;
|
|
|
|
// 회사 필터
|
|
if (companyCode && companyCode !== "all") {
|
|
whereConditions.push(`sfl.company_code = $${paramIndex}`);
|
|
queryParams.push(companyCode);
|
|
paramIndex++;
|
|
}
|
|
|
|
// 사용자 필터
|
|
if (userId && (userId as string).trim()) {
|
|
whereConditions.push(`sfl.user_id ILIKE $${paramIndex}`);
|
|
queryParams.push(`%${(userId as string).trim()}%`);
|
|
paramIndex++;
|
|
}
|
|
|
|
// 전송 상태 필터
|
|
if (sendStatus && sendStatus !== "all") {
|
|
whereConditions.push(`sfl.send_status = $${paramIndex}`);
|
|
queryParams.push(sendStatus);
|
|
paramIndex++;
|
|
}
|
|
|
|
// 날짜 범위 필터
|
|
if (dateFrom) {
|
|
whereConditions.push(`sfl.created_at >= $${paramIndex}`);
|
|
queryParams.push(dateFrom);
|
|
paramIndex++;
|
|
}
|
|
if (dateTo) {
|
|
whereConditions.push(`sfl.created_at < ($${paramIndex}::date + 1)`);
|
|
queryParams.push(dateTo);
|
|
paramIndex++;
|
|
}
|
|
|
|
// 통합 검색
|
|
if (search && (search as string).trim()) {
|
|
whereConditions.push(
|
|
`(sfl.user_id ILIKE $${paramIndex} OR sfl.user_name ILIKE $${paramIndex} OR sfl.connect_ip ILIKE $${paramIndex} OR sfl.error_message ILIKE $${paramIndex})`
|
|
);
|
|
queryParams.push(`%${(search as string).trim()}%`);
|
|
paramIndex++;
|
|
}
|
|
|
|
const whereClause =
|
|
whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : "";
|
|
|
|
// 총 개수
|
|
const countResult = await queryOne<{ total: string }>(
|
|
`SELECT COUNT(*) as total FROM smart_factory_log sfl ${whereClause}`,
|
|
queryParams
|
|
);
|
|
const total = parseInt(countResult?.total || "0", 10);
|
|
|
|
// 페이지네이션
|
|
const pageNum = Math.max(1, parseInt(page as string, 10));
|
|
const limitNum = Math.min(100, Math.max(1, parseInt(limit as string, 10)));
|
|
const offset = (pageNum - 1) * limitNum;
|
|
|
|
// 데이터 조회 (회사명 JOIN)
|
|
const logs = await query<any>(
|
|
`SELECT sfl.*, cm.company_name
|
|
FROM smart_factory_log sfl
|
|
LEFT JOIN company_mng cm ON cm.company_code = sfl.company_code
|
|
${whereClause}
|
|
ORDER BY sfl.created_at DESC
|
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
|
[...queryParams, limitNum, offset]
|
|
);
|
|
|
|
res.status(200).json({
|
|
success: true,
|
|
data: logs,
|
|
total,
|
|
page: pageNum,
|
|
limit: limitNum,
|
|
});
|
|
} catch (error) {
|
|
logger.error("스마트공장 로그 조회 실패:", error);
|
|
res.status(500).json({
|
|
success: false,
|
|
message: "스마트공장 로그 조회 중 오류가 발생했습니다.",
|
|
error: {
|
|
code: "SERVER_ERROR",
|
|
details: error instanceof Error ? error.message : "알 수 없는 오류",
|
|
},
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* GET /api/admin/smart-factory-log/stats
|
|
* 스마트공장 로그 통계 (회사별 요약)
|
|
*/
|
|
export const getSmartFactoryLogStats = async (
|
|
req: AuthenticatedRequest,
|
|
res: Response
|
|
): Promise<void> => {
|
|
try {
|
|
const { companyCode, days = "30" } = req.query;
|
|
const daysNum = parseInt(days as string, 10) || 30;
|
|
|
|
const whereConditions: string[] = [
|
|
`sfl.created_at >= NOW() - INTERVAL '${daysNum} days'`,
|
|
];
|
|
const queryParams: any[] = [];
|
|
let paramIndex = 1;
|
|
|
|
if (companyCode && companyCode !== "all") {
|
|
whereConditions.push(`sfl.company_code = $${paramIndex}`);
|
|
queryParams.push(companyCode);
|
|
paramIndex++;
|
|
}
|
|
|
|
const whereClause = `WHERE ${whereConditions.join(" AND ")}`;
|
|
|
|
// 상태별 건수
|
|
const statusCounts = await query<{ send_status: string; count: string }>(
|
|
`SELECT send_status, COUNT(*) as count
|
|
FROM smart_factory_log sfl
|
|
${whereClause}
|
|
GROUP BY send_status`,
|
|
queryParams
|
|
);
|
|
|
|
// 회사별 건수
|
|
const companyCounts = await query<{
|
|
company_code: string;
|
|
company_name: string;
|
|
count: string;
|
|
}>(
|
|
`SELECT sfl.company_code, COALESCE(cm.company_name, sfl.company_code) as company_name, COUNT(*) as count
|
|
FROM smart_factory_log sfl
|
|
LEFT JOIN company_mng cm ON cm.company_code = sfl.company_code
|
|
${whereClause}
|
|
GROUP BY sfl.company_code, cm.company_name
|
|
ORDER BY count DESC`,
|
|
queryParams
|
|
);
|
|
|
|
// 일별 추이
|
|
const dailyCounts = await query<{ date: string; count: string }>(
|
|
`SELECT DATE(sfl.created_at) as date, COUNT(*) as count
|
|
FROM smart_factory_log sfl
|
|
${whereClause}
|
|
GROUP BY DATE(sfl.created_at)
|
|
ORDER BY date DESC
|
|
LIMIT ${daysNum}`,
|
|
queryParams
|
|
);
|
|
|
|
// 전체 건수
|
|
const totalResult = await queryOne<{ total: string }>(
|
|
`SELECT COUNT(*) as total FROM smart_factory_log sfl ${whereClause}`,
|
|
queryParams
|
|
);
|
|
|
|
res.status(200).json({
|
|
success: true,
|
|
data: {
|
|
total: parseInt(totalResult?.total || "0", 10),
|
|
statusCounts: statusCounts.map((r) => ({
|
|
status: r.send_status,
|
|
count: parseInt(r.count, 10),
|
|
})),
|
|
companyCounts: companyCounts.map((r) => ({
|
|
companyCode: r.company_code,
|
|
companyName: r.company_name,
|
|
count: parseInt(r.count, 10),
|
|
})),
|
|
dailyCounts: dailyCounts.map((r) => ({
|
|
date: r.date,
|
|
count: parseInt(r.count, 10),
|
|
})),
|
|
},
|
|
});
|
|
} catch (error) {
|
|
logger.error("스마트공장 로그 통계 조회 실패:", error);
|
|
res.status(500).json({
|
|
success: false,
|
|
message: "통계 조회 중 오류가 발생했습니다.",
|
|
error: {
|
|
code: "SERVER_ERROR",
|
|
details: error instanceof Error ? error.message : "알 수 없는 오류",
|
|
},
|
|
});
|
|
}
|
|
};
|
|
|
|
// ─── 스케줄 관리 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, dailyCount } = 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, 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, daily_count = $7, updated_at = NOW()`,
|
|
[
|
|
companyCode,
|
|
isActive ?? false,
|
|
timeStart || "08:30",
|
|
timeEnd || "17:30",
|
|
excludeWeekend ?? true,
|
|
excludeHolidays ?? true,
|
|
Math.max(1, Math.min(3, dailyCount || 1)),
|
|
]
|
|
);
|
|
|
|
// 스케줄 변경 시 오늘 계획 재생성
|
|
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
|
|
*/
|
|
/**
|
|
* 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 키 삭제 실패" });
|
|
}
|
|
};
|
|
|
|
// ─── 즉시 전송 ───
|
|
|
|
/**
|
|
* GET /api/admin/smart-factory-log/users/:companyCode
|
|
* 회사별 사용자 목록 조회 (즉시 전송 대상 선택용)
|
|
*/
|
|
export const getCompanyUsers = async (
|
|
req: AuthenticatedRequest,
|
|
res: Response
|
|
): Promise<void> => {
|
|
try {
|
|
const { companyCode } = req.params;
|
|
|
|
const users = await query<any>(
|
|
`SELECT user_id, user_name, dept_name
|
|
FROM user_info
|
|
WHERE company_code = $1 AND (status = 'active' OR status IS NULL)
|
|
ORDER BY user_name`,
|
|
[companyCode]
|
|
);
|
|
|
|
res.json({ success: true, data: users });
|
|
} catch (error) {
|
|
logger.error("사용자 목록 조회 실패:", error);
|
|
res.status(500).json({ success: false, message: "사용자 목록 조회 실패" });
|
|
}
|
|
};
|
|
|
|
/**
|
|
* POST /api/admin/smart-factory-log/send-now
|
|
* 선택한 사용자 즉시 전송
|
|
* body: { companyCode, userIds: string[], timeStart?, timeEnd? }
|
|
*/
|
|
export const sendNow = async (
|
|
req: AuthenticatedRequest,
|
|
res: Response
|
|
): Promise<void> => {
|
|
try {
|
|
const { companyCode, userIds } = req.body;
|
|
|
|
logger.info(`=== 즉시 전송 API 호출 === companyCode=${companyCode}, userIds=${JSON.stringify(userIds)}`);
|
|
|
|
if (!companyCode || !userIds || userIds.length === 0) {
|
|
res.status(400).json({ success: false, message: "회사코드와 사용자를 선택해주세요." });
|
|
return;
|
|
}
|
|
|
|
// 사용자 정보 조회
|
|
const users = await query<{ user_id: string; user_name: string }>(
|
|
`SELECT user_id, user_name FROM user_info WHERE company_code = $1 AND user_id = ANY($2)`,
|
|
[companyCode, userIds]
|
|
);
|
|
|
|
logger.info(`즉시 전송 대상: ${users.length}명 (조회된 사용자: ${users.map(u => u.user_id).join(", ")})`);
|
|
|
|
// 현재 시간으로 즉시 전송
|
|
let success = 0;
|
|
let fail = 0;
|
|
const remoteAddr = req.ip || "127.0.0.1";
|
|
|
|
for (const user of users) {
|
|
try {
|
|
logger.info(`즉시 전송 시작: ${user.user_id}`);
|
|
await sendSmartFactoryLog({
|
|
userId: user.user_id,
|
|
userName: user.user_name,
|
|
remoteAddr,
|
|
useType: "접속",
|
|
companyCode,
|
|
});
|
|
success++;
|
|
logger.info(`즉시 전송 성공: ${user.user_id}`);
|
|
} catch (e) {
|
|
fail++;
|
|
logger.error(`즉시 전송 실패: ${user.user_id}`, e);
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
data: { total: users.length, success, fail },
|
|
message: `${success}명 전송 완료${fail > 0 ? `, ${fail}명 실패` : ""}`,
|
|
});
|
|
} catch (error) {
|
|
logger.error("즉시 전송 실패:", error);
|
|
res.status(500).json({ success: false, message: "즉시 전송 실패" });
|
|
}
|
|
};
|