From 41d58cbb6298013ff3b36f6e56fc4ac8fae99550 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 11 Mar 2026 18:34:58 +0900 Subject: [PATCH] feat: implement smart factory log transmission and enhance rack structure patterns - Added a new utility function `sendSmartFactoryLog` to transmit user access logs to the smart factory logging service upon successful login, ensuring non-blocking behavior. - Integrated the smart factory log transmission into the `AuthController` to log user access asynchronously. - Introduced pattern utilities for generating location codes and names based on configurable patterns in the rack structure components, improving flexibility and maintainability. - Enhanced the `RackStructureConfigPanel` to allow users to define custom patterns for location codes and names, with real-time previews and a list of available variables. Made-with: Cursor --- .../src/controllers/authController.ts | 10 +- backend-node/src/utils/smartFactoryLog.ts | 71 ++++++++++++++ .../rack-structure/RackStructureComponent.tsx | 29 +++--- .../RackStructureConfigPanel.tsx | 98 ++++++++++++++++++- .../components/rack-structure/patternUtils.ts | 7 ++ .../RackStructureComponent.tsx | 29 +++--- .../RackStructureConfigPanel.tsx | 98 ++++++++++++++++++- .../v2-rack-structure/patternUtils.ts | 81 +++++++++++++++ 8 files changed, 396 insertions(+), 27 deletions(-) create mode 100644 backend-node/src/utils/smartFactoryLog.ts create mode 100644 frontend/lib/registry/components/rack-structure/patternUtils.ts create mode 100644 frontend/lib/registry/components/v2-rack-structure/patternUtils.ts diff --git a/backend-node/src/controllers/authController.ts b/backend-node/src/controllers/authController.ts index ebf3e8f5..2bb72876 100644 --- a/backend-node/src/controllers/authController.ts +++ b/backend-node/src/controllers/authController.ts @@ -6,6 +6,7 @@ import { AuthService } from "../services/authService"; import { JwtUtils } from "../utils/jwtUtils"; import { LoginRequest, UserInfo, ApiResponse, PersonBean } from "../types/auth"; import { logger } from "../utils/logger"; +import { sendSmartFactoryLog } from "../utils/smartFactoryLog"; export class AuthController { /** @@ -86,13 +87,20 @@ export class AuthController { logger.warn("메뉴 조회 중 오류 발생 (무시하고 계속):", menuError); } + // 스마트공장 활용 로그 전송 (비동기, 응답 블로킹 안 함) + sendSmartFactoryLog({ + userId: userInfo.userId, + remoteAddr, + useType: "접속", + }).catch(() => {}); + res.status(200).json({ success: true, message: "로그인 성공", data: { userInfo, token: loginResult.token, - firstMenuPath, // 첫 번째 접근 가능한 메뉴 경로 추가 + firstMenuPath, }, }); } else { diff --git a/backend-node/src/utils/smartFactoryLog.ts b/backend-node/src/utils/smartFactoryLog.ts new file mode 100644 index 00000000..ea8d9aec --- /dev/null +++ b/backend-node/src/utils/smartFactoryLog.ts @@ -0,0 +1,71 @@ +// 스마트공장 활용 로그 전송 유틸리티 +// https://log.smart-factory.kr 에 사용자 접속 로그를 전송 + +import axios from "axios"; +import { logger } from "./logger"; + +const SMART_FACTORY_LOG_URL = + "https://log.smart-factory.kr/apisvc/sendLogDataJSON.do"; + +/** + * 스마트공장 활용 로그 전송 + * 로그인 성공 시 비동기로 호출하여 응답을 블로킹하지 않음 + */ +export async function sendSmartFactoryLog(params: { + userId: string; + remoteAddr: string; + useType?: string; +}): Promise { + const apiKey = process.env.SMART_FACTORY_API_KEY; + + if (!apiKey) { + logger.warn( + "SMART_FACTORY_API_KEY 환경변수가 설정되지 않아 스마트공장 로그 전송을 건너뜁니다." + ); + return; + } + + try { + const now = new Date(); + const logDt = formatDateTime(now); + + const logData = { + crtfcKey: apiKey, + logDt, + useSe: params.useType || "접속", + sysUser: params.userId, + conectIp: params.remoteAddr, + dataUsgqty: "", + }; + + const encodedLogData = encodeURIComponent(JSON.stringify(logData)); + + const response = await axios.get(SMART_FACTORY_LOG_URL, { + params: { logData: encodedLogData }, + timeout: 5000, + }); + + logger.info("스마트공장 로그 전송 완료", { + userId: params.userId, + status: response.status, + }); + } catch (error) { + // 스마트공장 로그 전송 실패해도 로그인에 영향 없도록 에러만 기록 + logger.error("스마트공장 로그 전송 실패", { + userId: params.userId, + error: error instanceof Error ? error.message : error, + }); + } +} + +/** yyyy-MM-dd HH:mm:ss.SSS 형식 */ +function formatDateTime(date: Date): string { + const y = date.getFullYear(); + const M = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + const H = String(date.getHours()).padStart(2, "0"); + const m = String(date.getMinutes()).padStart(2, "0"); + const s = String(date.getSeconds()).padStart(2, "0"); + const ms = String(date.getMilliseconds()).padStart(3, "0"); + return `${y}-${M}-${d} ${H}:${m}:${s}.${ms}`; +} diff --git a/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx b/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx index 63a4288a..4c3d3112 100644 --- a/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx +++ b/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx @@ -20,6 +20,7 @@ import { GeneratedLocation, RackStructureContext, } from "./types"; +import { applyLocationPattern, DEFAULT_CODE_PATTERN, DEFAULT_NAME_PATTERN } from "./patternUtils"; // 기존 위치 데이터 타입 interface ExistingLocation { @@ -512,23 +513,27 @@ export const RackStructureComponent: React.FC = ({ return { totalLocations, totalRows, maxLevel }; }, [conditions]); - // 위치 코드 생성 + // 위치 코드 생성 (패턴 기반) const generateLocationCode = useCallback( (row: number, level: number): { code: string; name: string } => { - const warehouseCode = context?.warehouseCode || "WH001"; - const floor = context?.floor || "1"; - const zone = context?.zone || "A"; + const vars = { + warehouse: context?.warehouseCode || "WH001", + warehouseName: context?.warehouseName || "", + floor: context?.floor || "1", + zone: context?.zone || "A", + row, + level, + }; - // 코드 생성 (예: WH001-1층D구역-01-1) - const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`; + const codePattern = config.codePattern || DEFAULT_CODE_PATTERN; + const namePattern = config.namePattern || DEFAULT_NAME_PATTERN; - // 이름 생성 - zone에 이미 "구역"이 포함되어 있으면 그대로 사용 - const zoneName = zone.includes("구역") ? zone : `${zone}구역`; - const name = `${zoneName}-${row.toString().padStart(2, "0")}열-${level}단`; - - return { code, name }; + return { + code: applyLocationPattern(codePattern, vars), + name: applyLocationPattern(namePattern, vars), + }; }, - [context], + [context, config.codePattern, config.namePattern], ); // 미리보기 생성 diff --git a/frontend/lib/registry/components/rack-structure/RackStructureConfigPanel.tsx b/frontend/lib/registry/components/rack-structure/RackStructureConfigPanel.tsx index 17e1a781..ddaebfa2 100644 --- a/frontend/lib/registry/components/rack-structure/RackStructureConfigPanel.tsx +++ b/frontend/lib/registry/components/rack-structure/RackStructureConfigPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; @@ -12,6 +12,47 @@ import { SelectValue, } from "@/components/ui/select"; import { RackStructureComponentConfig, FieldMapping } from "./types"; +import { applyLocationPattern, DEFAULT_CODE_PATTERN, DEFAULT_NAME_PATTERN, PATTERN_VARIABLES } from "./patternUtils"; + +// 패턴 미리보기 서브 컴포넌트 +const PatternPreview: React.FC<{ + codePattern?: string; + namePattern?: string; +}> = ({ codePattern, namePattern }) => { + const sampleVars = { + warehouse: "WH002", + warehouseName: "2창고", + floor: "2층", + zone: "A구역", + row: 1, + level: 3, + }; + + const previewCode = useMemo( + () => applyLocationPattern(codePattern || DEFAULT_CODE_PATTERN, sampleVars), + [codePattern], + ); + const previewName = useMemo( + () => applyLocationPattern(namePattern || DEFAULT_NAME_PATTERN, sampleVars), + [namePattern], + ); + + return ( +
+
미리보기 (2창고 / 2층 / A구역 / 1열 / 3단)
+
+
+ 위치코드: + {previewCode} +
+
+ 위치명: + {previewName} +
+
+
+ ); +}; interface RackStructureConfigPanelProps { config: RackStructureComponentConfig; @@ -205,6 +246,61 @@ export const RackStructureConfigPanel: React.FC = + {/* 위치코드 패턴 설정 */} +
+
위치코드/위치명 패턴
+

+ 변수를 조합하여 위치코드와 위치명 생성 규칙을 설정하세요 +

+ + {/* 위치코드 패턴 */} +
+ + handleChange("codePattern", e.target.value || undefined)} + placeholder="{warehouse}-{floor}{zone}-{row:02}-{level}" + className="h-8 font-mono text-xs" + /> +

+ 비워두면 기본값: {"{warehouse}-{floor}{zone}-{row:02}-{level}"} +

+
+ + {/* 위치명 패턴 */} +
+ + handleChange("namePattern", e.target.value || undefined)} + placeholder="{zone}-{row:02}열-{level}단" + className="h-8 font-mono text-xs" + /> +

+ 비워두면 기본값: {"{zone}-{row:02}열-{level}단"} +

+
+ + {/* 실시간 미리보기 */} + + + {/* 사용 가능한 변수 목록 */} +
+
사용 가능한 변수
+
+ {PATTERN_VARIABLES.map((v) => ( +
+ {v.token} + {v.description} +
+ ))} +
+
+
+ {/* 제한 설정 */}
제한 설정
diff --git a/frontend/lib/registry/components/rack-structure/patternUtils.ts b/frontend/lib/registry/components/rack-structure/patternUtils.ts new file mode 100644 index 00000000..b5139c0b --- /dev/null +++ b/frontend/lib/registry/components/rack-structure/patternUtils.ts @@ -0,0 +1,7 @@ +// rack-structure는 v2-rack-structure의 patternUtils를 재사용 +export { + applyLocationPattern, + DEFAULT_CODE_PATTERN, + DEFAULT_NAME_PATTERN, + PATTERN_VARIABLES, +} from "../v2-rack-structure/patternUtils"; diff --git a/frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx b/frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx index cef90668..cd107958 100644 --- a/frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx +++ b/frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx @@ -20,6 +20,7 @@ import { GeneratedLocation, RackStructureContext, } from "./types"; +import { applyLocationPattern, DEFAULT_CODE_PATTERN, DEFAULT_NAME_PATTERN } from "./patternUtils"; // 기존 위치 데이터 타입 interface ExistingLocation { @@ -493,23 +494,27 @@ export const RackStructureComponent: React.FC = ({ return { totalLocations, totalRows, maxLevel }; }, [conditions]); - // 위치 코드 생성 + // 위치 코드 생성 (패턴 기반) const generateLocationCode = useCallback( (row: number, level: number): { code: string; name: string } => { - const warehouseCode = context?.warehouseCode || "WH001"; - const floor = context?.floor || "1"; - const zone = context?.zone || "A"; + const vars = { + warehouse: context?.warehouseCode || "WH001", + warehouseName: context?.warehouseName || "", + floor: context?.floor || "1", + zone: context?.zone || "A", + row, + level, + }; - // 코드 생성 (예: WH001-1층D구역-01-1) - const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`; + const codePattern = config.codePattern || DEFAULT_CODE_PATTERN; + const namePattern = config.namePattern || DEFAULT_NAME_PATTERN; - // 이름 생성 - zone에 이미 "구역"이 포함되어 있으면 그대로 사용 - const zoneName = zone.includes("구역") ? zone : `${zone}구역`; - const name = `${zoneName}-${row.toString().padStart(2, "0")}열-${level}단`; - - return { code, name }; + return { + code: applyLocationPattern(codePattern, vars), + name: applyLocationPattern(namePattern, vars), + }; }, - [context], + [context, config.codePattern, config.namePattern], ); // 미리보기 생성 diff --git a/frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx b/frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx index 17e1a781..ddaebfa2 100644 --- a/frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; @@ -12,6 +12,47 @@ import { SelectValue, } from "@/components/ui/select"; import { RackStructureComponentConfig, FieldMapping } from "./types"; +import { applyLocationPattern, DEFAULT_CODE_PATTERN, DEFAULT_NAME_PATTERN, PATTERN_VARIABLES } from "./patternUtils"; + +// 패턴 미리보기 서브 컴포넌트 +const PatternPreview: React.FC<{ + codePattern?: string; + namePattern?: string; +}> = ({ codePattern, namePattern }) => { + const sampleVars = { + warehouse: "WH002", + warehouseName: "2창고", + floor: "2층", + zone: "A구역", + row: 1, + level: 3, + }; + + const previewCode = useMemo( + () => applyLocationPattern(codePattern || DEFAULT_CODE_PATTERN, sampleVars), + [codePattern], + ); + const previewName = useMemo( + () => applyLocationPattern(namePattern || DEFAULT_NAME_PATTERN, sampleVars), + [namePattern], + ); + + return ( +
+
미리보기 (2창고 / 2층 / A구역 / 1열 / 3단)
+
+
+ 위치코드: + {previewCode} +
+
+ 위치명: + {previewName} +
+
+
+ ); +}; interface RackStructureConfigPanelProps { config: RackStructureComponentConfig; @@ -205,6 +246,61 @@ export const RackStructureConfigPanel: React.FC =
+ {/* 위치코드 패턴 설정 */} +
+
위치코드/위치명 패턴
+

+ 변수를 조합하여 위치코드와 위치명 생성 규칙을 설정하세요 +

+ + {/* 위치코드 패턴 */} +
+ + handleChange("codePattern", e.target.value || undefined)} + placeholder="{warehouse}-{floor}{zone}-{row:02}-{level}" + className="h-8 font-mono text-xs" + /> +

+ 비워두면 기본값: {"{warehouse}-{floor}{zone}-{row:02}-{level}"} +

+
+ + {/* 위치명 패턴 */} +
+ + handleChange("namePattern", e.target.value || undefined)} + placeholder="{zone}-{row:02}열-{level}단" + className="h-8 font-mono text-xs" + /> +

+ 비워두면 기본값: {"{zone}-{row:02}열-{level}단"} +

+
+ + {/* 실시간 미리보기 */} + + + {/* 사용 가능한 변수 목록 */} +
+
사용 가능한 변수
+
+ {PATTERN_VARIABLES.map((v) => ( +
+ {v.token} + {v.description} +
+ ))} +
+
+
+ {/* 제한 설정 */}
제한 설정
diff --git a/frontend/lib/registry/components/v2-rack-structure/patternUtils.ts b/frontend/lib/registry/components/v2-rack-structure/patternUtils.ts new file mode 100644 index 00000000..d226db82 --- /dev/null +++ b/frontend/lib/registry/components/v2-rack-structure/patternUtils.ts @@ -0,0 +1,81 @@ +/** + * 위치코드/위치명 패턴 변환 유틸리티 + * + * 사용 가능한 변수: + * {warehouse} - 창고 코드 (예: WH002) + * {warehouseName} - 창고명 (예: 2창고) + * {floor} - 층 (예: 2층) + * {zone} - 구역 (예: A구역) + * {row} - 열 번호 (예: 1) + * {row:02} - 열 번호 2자리 (예: 01) + * {row:03} - 열 번호 3자리 (예: 001) + * {level} - 단 번호 (예: 1) + * {level:02} - 단 번호 2자리 (예: 01) + * {level:03} - 단 번호 3자리 (예: 001) + */ + +interface PatternVariables { + warehouse?: string; + warehouseName?: string; + floor?: string; + zone?: string; + row: number; + level: number; +} + +// 기본 패턴 (하드코딩 대체) +export const DEFAULT_CODE_PATTERN = "{warehouse}-{floor}{zone}-{row:02}-{level}"; +export const DEFAULT_NAME_PATTERN = "{zone}-{row:02}열-{level}단"; + +/** + * 패턴 문자열에서 변수를 치환하여 결과 문자열 반환 + */ +export function applyLocationPattern(pattern: string, vars: PatternVariables): string { + let result = pattern; + + // zone에 "구역" 포함 여부에 따른 처리 없이 있는 그대로 치환 + const simpleVars: Record = { + warehouse: vars.warehouse, + warehouseName: vars.warehouseName, + floor: vars.floor, + zone: vars.zone, + }; + + // 단순 문자열 변수 치환 + for (const [key, value] of Object.entries(simpleVars)) { + result = result.replace(new RegExp(`\\{${key}\\}`, "g"), value || ""); + } + + // 숫자 변수 (row, level) - zero-pad 지원 + const numericVars: Record = { + row: vars.row, + level: vars.level, + }; + + for (const [key, value] of Object.entries(numericVars)) { + // {row:02}, {level:03} 같은 zero-pad 패턴 + const padRegex = new RegExp(`\\{${key}:(\\d+)\\}`, "g"); + result = result.replace(padRegex, (_, padWidth) => { + return value.toString().padStart(parseInt(padWidth), "0"); + }); + + // {row}, {level} 같은 단순 패턴 + result = result.replace(new RegExp(`\\{${key}\\}`, "g"), value.toString()); + } + + return result; +} + +// 패턴에서 사용 가능한 변수 목록 +export const PATTERN_VARIABLES = [ + { token: "{warehouse}", description: "창고 코드", example: "WH002" }, + { token: "{warehouseName}", description: "창고명", example: "2창고" }, + { token: "{floor}", description: "층", example: "2층" }, + { token: "{zone}", description: "구역", example: "A구역" }, + { token: "{row}", description: "열 번호", example: "1" }, + { token: "{row:02}", description: "열 번호 (2자리)", example: "01" }, + { token: "{row:03}", description: "열 번호 (3자리)", example: "001" }, + { token: "{level}", description: "단 번호", example: "1" }, + { token: "{level:02}", description: "단 번호 (2자리)", example: "01" }, + { token: "{level:03}", description: "단 번호 (3자리)", example: "001" }, +];