diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 1c6a9d9e..33d072d4 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -449,6 +449,7 @@ async function initializeServices() { runApprovalSystemMigration, runUserMailAccountsMigration, runMessengerMigration, + runSmartFactoryLogMigration, } = await import("./database/runMigration"); await runDashboardMigration(); @@ -457,6 +458,7 @@ async function initializeServices() { await runApprovalSystemMigration(); await runUserMailAccountsMigration(); await runMessengerMigration(); + await runSmartFactoryLogMigration(); } catch (error) { logger.error(`❌ 마이그레이션 실패:`, error); } diff --git a/backend-node/src/controllers/authController.ts b/backend-node/src/controllers/authController.ts index 4b40ce6e..4dcdc275 100644 --- a/backend-node/src/controllers/authController.ts +++ b/backend-node/src/controllers/authController.ts @@ -87,6 +87,7 @@ export class AuthController { // 스마트공장 활용 로그 전송 (비동기, 응답 블로킹 안 함) sendSmartFactoryLog({ userId: userInfo.userId, + userName: userInfo.userName, remoteAddr, useType: "접속", companyCode: userInfo.companyCode, diff --git a/backend-node/src/controllers/smartFactoryLogController.ts b/backend-node/src/controllers/smartFactoryLogController.ts new file mode 100644 index 00000000..ed4b353c --- /dev/null +++ b/backend-node/src/controllers/smartFactoryLogController.ts @@ -0,0 +1,218 @@ +// 스마트공장 활용 로그 조회 컨트롤러 +// 최고관리자(*) 전용 — 회사별 필터링 가능 + +import { Response } from "express"; +import { AuthenticatedRequest } from "../middleware/permissionMiddleware"; +import { query, queryOne } from "../database/db"; +import { logger } from "../utils/logger"; + +/** + * GET /api/admin/smart-factory-log + * 스마트공장 로그 목록 조회 + */ +export const getSmartFactoryLogs = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + 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( + `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 => { + 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 : "알 수 없는 오류", + }, + }); + } +}; diff --git a/backend-node/src/database/runMigration.ts b/backend-node/src/database/runMigration.ts index 73523b92..f915d962 100644 --- a/backend-node/src/database/runMigration.ts +++ b/backend-node/src/database/runMigration.ts @@ -170,6 +170,35 @@ export async function runMessengerMigration() { } } +/** + * 스마트공장 활용 로그 테이블 마이그레이션 + */ +export async function runSmartFactoryLogMigration() { + try { + console.log("🔄 스마트공장 로그 테이블 마이그레이션 시작..."); + + const sqlFilePath = path.join( + __dirname, + "../../db/migrations/200_create_smart_factory_log.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 runDtgManagementLogMigration() { try { console.log("🔄 DTG Management 이력 테이블 마이그레이션 시작..."); diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index 3a173cbe..d02aac7e 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -32,6 +32,10 @@ import { setUserLocale, getTableSchema, // 테이블 스키마 조회 } from "../controllers/adminController"; +import { + getSmartFactoryLogs, + getSmartFactoryLogStats, +} from "../controllers/smartFactoryLogController"; import { authenticateToken } from "../middleware/authMiddleware"; import { requireSuperAdmin } from "../middleware/permissionMiddleware"; @@ -84,4 +88,8 @@ router.post("/user-locale", setUserLocale); // 테이블 스키마 API (엑셀 업로드 컬럼 매핑용) router.get("/tables/:tableName/schema", getTableSchema); +// 스마트공장 활용 로그 API (최고관리자 전용) +router.get("/smart-factory-log", requireSuperAdmin, getSmartFactoryLogs); +router.get("/smart-factory-log/stats", requireSuperAdmin, getSmartFactoryLogStats); + export default router; diff --git a/backend-node/src/utils/smartFactoryLog.ts b/backend-node/src/utils/smartFactoryLog.ts index 18c5b573..e4fd89f0 100644 --- a/backend-node/src/utils/smartFactoryLog.ts +++ b/backend-node/src/utils/smartFactoryLog.ts @@ -3,20 +3,26 @@ import axios from "axios"; import { logger } from "./logger"; +import { query } from "../database/db"; const SMART_FACTORY_LOG_URL = "https://log.smart-factory.kr/apisvc/sendLogDataJSON.do"; /** - * 스마트공장 활용 로그 전송 + * 스마트공장 활용 로그 전송 + DB 저장 * 로그인 성공 시 비동기로 호출하여 응답을 블로킹하지 않음 */ export async function sendSmartFactoryLog(params: { userId: string; + userName?: string; remoteAddr: string; useType?: string; companyCode?: string; }): Promise { + const now = new Date(); + const logDt = formatDateTime(now); + const useType = params.useType || "접속"; + // 회사별 키 우선 조회, 없으면 공통 키 폴백 const apiKey = (params.companyCode && process.env[`SMART_FACTORY_API_KEY_${params.companyCode}`]) || process.env.SMART_FACTORY_API_KEY; @@ -25,17 +31,26 @@ export async function sendSmartFactoryLog(params: { logger.warn( "SMART_FACTORY_API_KEY 환경변수가 설정되지 않아 스마트공장 로그 전송을 건너뜁니다." ); + // SKIPPED 상태로 DB 기록 + await saveLog({ + companyCode: params.companyCode || "", + userId: params.userId, + userName: params.userName, + useType, + connectIp: params.remoteAddr, + sendStatus: "SKIPPED", + responseStatus: null, + errorMessage: "API 키 미설정", + logDt: now, + }); return; } try { - const now = new Date(); - const logDt = formatDateTime(now); - const logData = { crtfcKey: apiKey, logDt, - useSe: params.useType || "접속", + useSe: useType, sysUser: params.userId, conectIp: params.remoteAddr, dataUsgqty: "", @@ -52,11 +67,76 @@ export async function sendSmartFactoryLog(params: { userId: params.userId, status: response.status, }); + + // SUCCESS 상태로 DB 기록 + await saveLog({ + companyCode: params.companyCode || "", + userId: params.userId, + userName: params.userName, + useType, + connectIp: params.remoteAddr, + sendStatus: "SUCCESS", + responseStatus: response.status, + errorMessage: null, + logDt: now, + }); } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); // 스마트공장 로그 전송 실패해도 로그인에 영향 없도록 에러만 기록 logger.error("스마트공장 로그 전송 실패", { userId: params.userId, - error: error instanceof Error ? error.message : error, + error: errorMsg, + }); + + // FAIL 상태로 DB 기록 + await saveLog({ + companyCode: params.companyCode || "", + userId: params.userId, + userName: params.userName, + useType, + connectIp: params.remoteAddr, + sendStatus: "FAIL", + responseStatus: null, + errorMessage: errorMsg, + logDt: now, + }); + } +} + +/** DB에 로그 저장 */ +async function saveLog(params: { + companyCode: string; + userId: string; + userName?: string; + useType: string; + connectIp: string; + sendStatus: string; + responseStatus: number | null; + errorMessage: string | null; + logDt: Date; +}): Promise { + try { + await query( + `INSERT INTO smart_factory_log + (company_code, user_id, user_name, use_type, connect_ip, send_status, response_status, error_message, log_dt) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [ + params.companyCode, + params.userId, + params.userName || null, + params.useType, + params.connectIp, + params.sendStatus, + params.responseStatus, + params.errorMessage, + params.logDt, + ] + ); + } catch (dbError) { + // DB 저장 실패해도 로그인 프로세스에 영향 없도록 + logger.error("스마트공장 로그 DB 저장 실패", { + userId: params.userId, + error: dbError instanceof Error ? dbError.message : dbError, }); } } diff --git a/docker/prod/docker-compose.backend.prod.yml b/docker/prod/docker-compose.backend.prod.yml index 755475ff..8ab60253 100644 --- a/docker/prod/docker-compose.backend.prod.yml +++ b/docker/prod/docker-compose.backend.prod.yml @@ -23,8 +23,11 @@ services: - KMA_API_KEY=${KMA_API_KEY} - ITS_API_KEY=${ITS_API_KEY} - EXPRESSWAY_API_KEY=${EXPRESSWAY_API_KEY:-} - - SMART_FACTORY_API_KEY_COMPANY_10=${SMART_FACTORY_API_KEY_COMPANY_10:-} + - SMART_FACTORY_API_KEY_COMPANY_7=${SMART_FACTORY_API_KEY_COMPANY_7:-} + - SMART_FACTORY_API_KEY_COMPANY_8=${SMART_FACTORY_API_KEY_COMPANY_8:-} - SMART_FACTORY_API_KEY_COMPANY_9=${SMART_FACTORY_API_KEY_COMPANY_9:-} + - SMART_FACTORY_API_KEY_COMPANY_10=${SMART_FACTORY_API_KEY_COMPANY_10:-} + - SMART_FACTORY_API_KEY_COMPANY_16=${SMART_FACTORY_API_KEY_COMPANY_16:-} restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3001/health"] diff --git a/frontend/app/(main)/COMPANY_16/master-data/item-info/page.tsx b/frontend/app/(main)/COMPANY_16/master-data/item-info/page.tsx index 954c54c1..fec0f371 100644 --- a/frontend/app/(main)/COMPANY_16/master-data/item-info/page.tsx +++ b/frontend/app/(main)/COMPANY_16/master-data/item-info/page.tsx @@ -33,9 +33,12 @@ import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSea import { exportToExcel } from "@/lib/utils/excelExport"; import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, - Pencil, Copy, Settings2, + Pencil, Copy, Settings2, Check, ChevronsUpDown, } from "lucide-react"; import { Badge } from "@/components/ui/badge"; +import { ImageUpload } from "@/components/common/ImageUpload"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; @@ -44,19 +47,57 @@ import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { toast } from "sonner"; +// 검색 가능한 카테고리 콤보박스 +function CategoryCombobox({ options, value, onChange, placeholder }: { + options: { code: string; label: string }[]; + value: string; + onChange: (v: string) => void; + placeholder: string; +}) { + const [open, setOpen] = useState(false); + const selected = options.find((o) => o.code === value); + return ( + + + + + + + + + 검색 결과가 없어요 + + {options.map((opt) => ( + { onChange(opt.code); setOpen(false); }}> + + {opt.label} + + ))} + + + + + + ); +} + const TABLE_NAME = "item_info"; const GRID_COLUMNS = [ { key: "item_number", label: "품목코드" }, { key: "item_name", label: "품명" }, + { key: "image", label: "이미지", type: "image" }, { key: "division", label: "관리품목" }, { key: "type", label: "품목구분" }, { key: "size", label: "규격" }, { key: "unit", label: "단위" }, { key: "material", label: "재질" }, { key: "status", label: "상태" }, - { key: "selling_price", label: "판매가격", align: "right" as const }, - { key: "standard_price", label: "기준단가", align: "right" as const }, + { key: "selling_price", label: "판매가격", align: "right" as const, formatNumber: true }, + { key: "standard_price", label: "기준단가", align: "right" as const, formatNumber: true }, { key: "weight", label: "중량", align: "right" as const }, { key: "inventory_unit", label: "재고단위" }, { key: "user_type01", label: "대분류" }, @@ -83,6 +124,7 @@ const FORM_FIELDS = [ { key: "user_type01", label: "대분류", type: "category" }, { key: "user_type02", label: "중분류", type: "category" }, { key: "lead_time", label: "생산 리드타임(일)", type: "text", placeholder: "숫자 입력 (예: 7)" }, + { key: "image", label: "품목 이미지", type: "image" }, { key: "meno", label: "메모", type: "textarea" }, ]; @@ -163,6 +205,14 @@ export default function ItemInfoPage() { const raw = res.data?.data?.data || res.data?.data?.rows || []; const resolve = (col: string, code: string) => { if (!code) return ""; + // 쉼표 구분 다중값 지원 + if (code.includes(",")) { + return code.split(",").map((c) => { + const trimmed = c.trim(); + if (!trimmed || trimmed === "s") return ""; + return categoryOptions[col]?.find((o) => o.code === trimmed)?.label || trimmed; + }).filter(Boolean).join(", "); + } return categoryOptions[col]?.find((o) => o.code === code)?.label || code; }; const data = raw.map((r: any) => { @@ -360,7 +410,14 @@ export default function ItemInfoPage() { columns={ts.visibleColumns.map((col): EDataTableColumn => ({ key: col.key, label: col.label, - align: col.align as "left" | "center" | "right" | undefined, + align: (col as any).type === "image" ? "center" : col.align as "left" | "center" | "right" | undefined, + formatNumber: (col as any).formatNumber, + width: (col as any).type === "image" ? "w-[50px]" : undefined, + render: (col as any).type === "image" ? (val: any) => ( + val ? ( + { (e.target as HTMLImageElement).style.display = "none"; }} /> + ) :
+ ) : undefined, }))} data={ts.groupData(items)} loading={loading} @@ -387,28 +444,28 @@ export default function ItemInfoPage() { {FORM_FIELDS.map((field) => (
- {field.type === "category" ? ( - + onChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))} + tableName={TABLE_NAME} + recordId={formData.id || ""} + columnName={field.key} + height="h-32" + /> + ) : field.type === "category" ? ( + setFormData((prev) => ({ ...prev, [field.key]: v }))} + placeholder={`${field.label} 선택`} + /> ) : field.type === "textarea" ? (