diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 536b8f46..32ab7332 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -134,6 +134,8 @@ import { BatchSchedulerService } from "./services/batchSchedulerService"; const app = express(); +app.set("trust proxy", true); + // 기본 미들웨어 app.use( helmet({ diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index e1190f6c..6cc62cc6 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -10,7 +10,7 @@ import { EncryptUtil } from "../utils/encryptUtil"; import { FileSystemManager } from "../utils/fileSystemManager"; import { validateBusinessNumber } from "../utils/businessNumberValidator"; import { MenuCopyService } from "../services/menuCopyService"; -import { auditLogService } from "../services/auditLogService"; +import { auditLogService, getClientIp } from "../services/auditLogService"; /** * 관리자 메뉴 목록 조회 @@ -1199,7 +1199,7 @@ export async function saveMenu( resourceName: savedMenu.menu_name_kor, summary: `메뉴 "${savedMenu.menu_name_kor}" 생성`, changes: { after: { menuNameKor: savedMenu.menu_name_kor, menuUrl: savedMenu.menu_url, status: savedMenu.status } }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); @@ -1403,7 +1403,7 @@ export async function updateMenu( before: { menuNameKor: currentMenu.menu_name_kor, menuUrl: currentMenu.menu_url, status: currentMenu.status }, after: { menuNameKor: updatedMenu.menu_name_kor, menuUrl: updatedMenu.menu_url, status: updatedMenu.status }, }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); @@ -1596,7 +1596,7 @@ export async function deleteMenu( resourceName: currentMenu.menu_name_kor, summary: `메뉴 "${currentMenu.menu_name_kor}" 삭제 (하위 ${childMenuIds.length}개 포함, 총 ${allMenuIdsToDelete.length}건)`, changes: { before: { menuNameKor: currentMenu.menu_name_kor } }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); @@ -1772,7 +1772,7 @@ export async function deleteMenusBatch( resourceType: "MENU", summary: `메뉴 일괄 삭제: ${deletedCount}개 삭제, ${failedCount}개 실패`, changes: { before: { deletedMenus, failedMenuIds } }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); } @@ -1883,7 +1883,7 @@ export async function toggleMenuStatus( resourceName: currentMenu.menu_name_kor, summary: `메뉴 "${currentMenu.menu_name_kor}" 상태 변경: ${currentStatus} → ${newStatus}`, changes: { before: { status: currentStatus }, after: { status: newStatus }, fields: ["status"] }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); @@ -2526,7 +2526,7 @@ export const changeUserStatus = async ( resourceName: currentUser.user_name, summary: `사용자 "${currentUser.user_name}" 상태 변경: ${currentUser.status} → ${status}`, changes: { before: { status: currentUser.status }, after: { status }, fields: ["status"] }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); @@ -2677,7 +2677,7 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => { resourceName: userData.userName, summary: isExistingUser ? `사용자 "${userData.userName}" 정보 수정` : `사용자 "${userData.userName}" 등록`, changes: { after: { userId: userData.userId, userName: userData.userName, deptName: userData.deptName, status: userData.status } }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); @@ -2881,7 +2881,7 @@ export const createCompany = async ( resourceName: createdCompany.company_name, summary: `회사 "${createdCompany.company_name}" (${createdCompany.company_code}) 등록`, changes: { after: { company_code: createdCompany.company_code, company_name: createdCompany.company_name } }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); @@ -3127,7 +3127,7 @@ export const updateCompany = async ( before: { company_name: beforeCompany?.company_name, status: beforeCompany?.status }, after: { company_name: updatedCompany.company_name, status: updatedCompany.status }, }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); @@ -3202,7 +3202,7 @@ export const deleteCompany = async ( resourceName: deletedCompany.company_name, summary: `회사 "${deletedCompany.company_name}" (${deletedCompany.company_code}) 삭제`, changes: { before: { company_code: deletedCompany.company_code, company_name: deletedCompany.company_name } }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); @@ -3382,7 +3382,7 @@ export const updateProfile = async ( resourceName: updatedUser?.user_name || "", summary: `프로필 수정 (${updateFields.length}개 항목)`, changes: { after: { userName, email, tel, cellPhone, locale } }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); @@ -3509,7 +3509,7 @@ export const resetUserPassword = async ( resourceName: currentUser.user_name, summary: `사용자 "${currentUser.user_name}" 비밀번호 초기화`, changes: { fields: ["user_password"] }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); @@ -3723,7 +3723,7 @@ export async function copyMenu( resourceId: menuObjid, summary: `메뉴(${menuObjid}) → 회사 "${targetCompanyCode}"로 복사`, changes: { after: { targetCompanyCode, menuObjid } }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); @@ -4051,7 +4051,7 @@ export const saveUserWithDept = async ( resourceName: userInfo.user_name, summary: `사용자 "${userInfo.user_name}" ${isExistingUser ? "수정" : "등록"} (부서: ${mainDept?.dept_name || "없음"})`, changes: { after: { userName: userInfo.user_name, email: userInfo.email, deptName: mainDept?.dept_name, status: userInfo.status } }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); diff --git a/backend-node/src/controllers/commonCodeController.ts b/backend-node/src/controllers/commonCodeController.ts index 4b361434..4b57b846 100644 --- a/backend-node/src/controllers/commonCodeController.ts +++ b/backend-node/src/controllers/commonCodeController.ts @@ -6,7 +6,7 @@ import { } from "../services/commonCodeService"; import { AuthenticatedRequest } from "../types/auth"; import { logger } from "../utils/logger"; -import { auditLogService } from "../services/auditLogService"; +import { auditLogService, getClientIp } from "../services/auditLogService"; export class CommonCodeController { private commonCodeService: CommonCodeService; @@ -172,7 +172,7 @@ export class CommonCodeController { resourceId: category?.categoryCode, resourceName: category?.categoryName || categoryData.categoryName, summary: `코드 카테고리 "${category?.categoryName || categoryData.categoryName}" 생성`, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); @@ -229,7 +229,7 @@ export class CommonCodeController { resourceId: categoryCode, resourceName: category?.categoryName, summary: `코드 카테고리 "${categoryCode}" 수정`, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); @@ -277,7 +277,7 @@ export class CommonCodeController { resourceType: "CODE_CATEGORY", resourceId: categoryCode, summary: `코드 카테고리 "${categoryCode}" 삭제`, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); @@ -347,7 +347,7 @@ export class CommonCodeController { resourceId: codeData.codeValue, resourceName: codeData.codeName, summary: `코드 "${codeData.codeName}" (${categoryCode}) 생성`, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); diff --git a/backend-node/src/controllers/ddlController.ts b/backend-node/src/controllers/ddlController.ts index 07f0b391..631b6360 100644 --- a/backend-node/src/controllers/ddlController.ts +++ b/backend-node/src/controllers/ddlController.ts @@ -9,7 +9,7 @@ import { DDLExecutionService } from "../services/ddlExecutionService"; import { DDLAuditLogger } from "../services/ddlAuditLogger"; import { CreateTableRequest, AddColumnRequest } from "../types/ddl"; import { logger } from "../utils/logger"; -import { auditLogService } from "../services/auditLogService"; +import { auditLogService, getClientIp } from "../services/auditLogService"; export class DDLController { /** @@ -70,7 +70,7 @@ export class DDLController { tableName, summary: `테이블 "${tableName}" 생성 (${columns.length}개 컬럼)`, changes: { after: { tableName, columnCount: columns.length, description } }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); diff --git a/backend-node/src/controllers/departmentController.ts b/backend-node/src/controllers/departmentController.ts index 43e56f7e..8238284f 100644 --- a/backend-node/src/controllers/departmentController.ts +++ b/backend-node/src/controllers/departmentController.ts @@ -3,7 +3,7 @@ import { logger } from "../utils/logger"; import { AuthenticatedRequest } from "../types/auth"; import { ApiResponse } from "../types/common"; import { query, queryOne } from "../database/db"; -import { auditLogService } from "../services/auditLogService"; +import { auditLogService, getClientIp } from "../services/auditLogService"; /** * 부서 목록 조회 (회사별) @@ -182,7 +182,7 @@ export async function createDepartment(req: AuthenticatedRequest, res: Response) tableName: "dept_info", summary: `부서 "${dept_name.trim()}" 생성`, changes: { after: { deptCode, deptName: dept_name.trim(), parentDeptCode: parent_dept_code } }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); @@ -246,7 +246,7 @@ export async function updateDepartment(req: AuthenticatedRequest, res: Response) tableName: "dept_info", summary: `부서 "${dept_name.trim()}" 수정`, changes: { after: { deptName: dept_name.trim(), parentDeptCode: parent_dept_code } }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); @@ -327,7 +327,7 @@ export async function deleteDepartment(req: AuthenticatedRequest, res: Response) tableName: "dept_info", summary: `부서 "${result[0].dept_name}" 삭제${memberCount > 0 ? ` (부서원 ${memberCount}명 제외)` : ""}`, changes: { before: { deptCode, deptName: result[0].dept_name } }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); diff --git a/backend-node/src/controllers/flowController.ts b/backend-node/src/controllers/flowController.ts index 180f17fd..62074dbf 100644 --- a/backend-node/src/controllers/flowController.ts +++ b/backend-node/src/controllers/flowController.ts @@ -10,7 +10,7 @@ import { FlowConnectionService } from "../services/flowConnectionService"; import { FlowExecutionService } from "../services/flowExecutionService"; import { FlowDataMoveService } from "../services/flowDataMoveService"; import { FlowProcedureService } from "../services/flowProcedureService"; -import { auditLogService } from "../services/auditLogService"; +import { auditLogService, getClientIp } from "../services/auditLogService"; export class FlowController { private flowDefinitionService: FlowDefinitionService; @@ -102,7 +102,7 @@ export class FlowController { resourceName: flowDef?.name || name, summary: `플로우 "${flowDef?.name || name}" 생성`, changes: { after: { name, tableName } }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req as any), requestPath: req.originalUrl, }); @@ -229,7 +229,7 @@ export class FlowController { before: { name: beforeFlow?.name, description: beforeFlow?.description, isActive: beforeFlow?.isActive }, after: { name, description, isActive }, }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req as any), requestPath: req.originalUrl, }); @@ -272,7 +272,7 @@ export class FlowController { resourceType: "FLOW", resourceId: String(flowId), summary: `플로우(ID:${flowId}) 삭제`, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req as any), requestPath: req.originalUrl, }); @@ -372,7 +372,7 @@ export class FlowController { resourceName: stepName, summary: `플로우 스텝 "${stepName}" 생성 (플로우 ID:${flowDefinitionId})`, changes: { after: { stepName, tableName, stepOrder } }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req as any), requestPath: req.originalUrl, }); @@ -467,7 +467,7 @@ export class FlowController { before: { stepName: beforeStep?.stepName, tableName: beforeStep?.tableName, stepOrder: beforeStep?.stepOrder }, after: { stepName, tableName, stepOrder }, }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req as any), requestPath: req.originalUrl, }); @@ -524,7 +524,7 @@ export class FlowController { resourceId: String(id), resourceName: existingStep?.stepName, summary: `플로우 스텝 "${existingStep?.stepName || id}" 삭제`, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req as any), requestPath: req.originalUrl, }); @@ -623,7 +623,7 @@ export class FlowController { resourceName: flowDef?.name || "", summary: `플로우 "${flowDef?.name}" 연결 생성 (${fromStep?.stepName} → ${toStep?.stepName})`, changes: { after: { fromStepId, toStepId, label } }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req as any), requestPath: req.originalUrl, }); @@ -680,7 +680,7 @@ export class FlowController { resourceId: String(existingConn?.flowDefinitionId || id), summary: `플로우 연결 삭제 (ID: ${id})`, changes: { before: { connectionId: id } }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req as any), requestPath: req.originalUrl, }); diff --git a/backend-node/src/controllers/numberingRuleController.ts b/backend-node/src/controllers/numberingRuleController.ts index fe71a617..68f12dde 100644 --- a/backend-node/src/controllers/numberingRuleController.ts +++ b/backend-node/src/controllers/numberingRuleController.ts @@ -9,7 +9,7 @@ import { } from "../middleware/authMiddleware"; import { numberingRuleService } from "../services/numberingRuleService"; import { logger } from "../utils/logger"; -import { auditLogService } from "../services/auditLogService"; +import { auditLogService, getClientIp } from "../services/auditLogService"; const router = Router(); @@ -199,7 +199,7 @@ router.post( resourceName: ruleConfig.ruleName, summary: `채번 규칙 "${ruleConfig.ruleName}" 생성`, changes: { after: { ruleName: ruleConfig.ruleName, prefix: ruleConfig.prefix } }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); @@ -251,7 +251,7 @@ router.put( before: { ruleName: beforeRule?.ruleName, prefix: beforeRule?.prefix }, after: updates, }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); @@ -289,7 +289,7 @@ router.delete( resourceType: "NUMBERING_RULE", resourceId: ruleId, summary: `채번 규칙(ID:${ruleId}) 삭제`, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); diff --git a/backend-node/src/controllers/roleController.ts b/backend-node/src/controllers/roleController.ts index c70149e2..06f72f31 100644 --- a/backend-node/src/controllers/roleController.ts +++ b/backend-node/src/controllers/roleController.ts @@ -8,7 +8,7 @@ import { isCompanyAdmin, canAccessCompanyData, } from "../utils/permissionUtils"; -import { auditLogService } from "../services/auditLogService"; +import { auditLogService, getClientIp } from "../services/auditLogService"; /** * 권한 그룹 목록 조회 @@ -190,7 +190,7 @@ export const createRoleGroup = async ( resourceName: authName, summary: `권한 그룹 "${authName}" 생성`, changes: { after: { authName, authCode, companyCode } }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); @@ -271,7 +271,7 @@ export const updateRoleGroup = async ( before: { authName: existingRoleGroup.authName, authCode: existingRoleGroup.authCode, status: existingRoleGroup.status }, after: { authName, authCode, status }, }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); @@ -343,7 +343,7 @@ export const deleteRoleGroup = async ( resourceId: String(objid), resourceName: existingRoleGroup.authName, summary: `권한 그룹 "${existingRoleGroup.authName}" 삭제`, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index 68f580f0..1827640c 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -1,7 +1,7 @@ import { Response } from "express"; import { screenManagementService } from "../services/screenManagementService"; import { AuthenticatedRequest } from "../types/auth"; -import { auditLogService } from "../services/auditLogService"; +import { auditLogService, getClientIp } from "../services/auditLogService"; // 화면 목록 조회 export const getScreens = async (req: AuthenticatedRequest, res: Response) => { @@ -120,7 +120,7 @@ export const createScreen = async ( resourceName: newScreen?.screenName || screenData.screenName, summary: `화면 "${newScreen?.screenName || screenData.screenName}" 생성`, changes: { after: { screenName: newScreen?.screenName, tableName: newScreen?.tableName } }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); @@ -162,7 +162,7 @@ export const updateScreen = async ( before: { screenName: beforeScreen?.screenName, tableName: beforeScreen?.tableName, isActive: beforeScreen?.isActive }, after: { screenName: updateData.screenName, tableName: updateData.tableName, isActive: updateData.isActive }, }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); @@ -264,7 +264,7 @@ export const updateScreenInfo = async ( before: { screenName: beforeScreen?.screenName, tableName: beforeScreen?.tableName, description: beforeScreen?.description, isActive: beforeScreen?.isActive }, after: { screenName, tableName, description, isActive }, }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); @@ -330,7 +330,7 @@ export const deleteScreen = async ( resourceName: screenName, summary: `화면(ID:${id}, ${screenName}) 삭제 (사유: ${deleteReason || "없음"})`, changes: { before: { deleteReason, force } }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); @@ -623,7 +623,7 @@ export const copyScreenWithModals = async ( resourceName: mainScreen?.screenName, summary: `화면 일괄 복사 (메인 1개 + 모달 ${result.modalScreens.length}개, 원본 ID:${id})`, changes: { after: { sourceScreenId: id, targetCompanyCode, mainScreenName: mainScreen?.screenName } }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); @@ -672,7 +672,7 @@ export const copyScreen = async ( resourceName: screenName, summary: `화면 "${screenName}" 복사 (원본 ID:${id})`, changes: { after: { sourceScreenId: id, screenName, screenCode } }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); @@ -786,7 +786,7 @@ export const saveLayout = async (req: AuthenticatedRequest, res: Response) => { resourceId: screenId, resourceName: screenInfo?.screenName || "", summary: `화면(ID:${screenId}, ${screenInfo?.screenName || ""}) 레이아웃 저장`, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); @@ -877,7 +877,7 @@ export const saveLayoutV2 = async (req: AuthenticatedRequest, res: Response) => resourceId: screenId, resourceName: screenInfo?.screenName || "", summary: `화면(ID:${screenId}, ${screenInfo?.screenName || ""}) V2 레이아웃 저장`, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); @@ -1064,7 +1064,7 @@ export const saveLayoutPop = async (req: AuthenticatedRequest, res: Response) => resourceId: screenId, resourceName: screenInfo?.screenName || "", summary: `화면(ID:${screenId}, ${screenInfo?.screenName || ""}) POP 레이아웃 저장`, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 68f5b33b..b8436176 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -13,7 +13,7 @@ import { ColumnSettingsResponse, } from "../types/tableManagement"; import { query } from "../database/db"; -import { auditLogService } from "../services/auditLogService"; +import { auditLogService, getClientIp } from "../services/auditLogService"; /** * 테이블 목록 조회 @@ -974,7 +974,7 @@ export async function addTableData( tableName, summary: `${tableName} 데이터 추가`, changes: { after: data }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); @@ -1127,7 +1127,7 @@ export async function editTableData( tableName, summary: `${tableName} 데이터 수정`, changes: { before: changedBefore, after: changedAfter }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); } @@ -1461,7 +1461,7 @@ export async function deleteTableData( tableName, summary: `${tableName} 데이터 삭제 (${deletedCount}건)`, changes: { before: { deletedCount, items: deleteItems.length } }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); @@ -2355,7 +2355,7 @@ export async function multiTableSave( tableName: mainTableName, summary: `${mainTableName} 데이터 ${isUpdate ? "수정" : "생성"}${subTableResults.length > 0 ? ` (서브 테이블 ${subTableResults.length}건)` : ""}`, changes: { after: mainData }, - ipAddress: (req as any).ip, + ipAddress: getClientIp(req), requestPath: req.originalUrl, }); diff --git a/backend-node/src/services/auditLogService.ts b/backend-node/src/services/auditLogService.ts index 67c3b72d..bc77be49 100644 --- a/backend-node/src/services/auditLogService.ts +++ b/backend-node/src/services/auditLogService.ts @@ -1,6 +1,20 @@ +import { Request } from "express"; import { query, pool } from "../database/db"; import logger from "../utils/logger"; +export function getClientIp(req: Request): string { + const forwarded = req.headers["x-forwarded-for"]; + if (forwarded) { + const first = Array.isArray(forwarded) ? forwarded[0] : forwarded.split(",")[0]; + return first.trim(); + } + const realIp = req.headers["x-real-ip"]; + if (realIp) { + return Array.isArray(realIp) ? realIp[0] : realIp; + } + return req.ip || req.socket?.remoteAddress || "unknown"; +} + export type AuditAction = | "CREATE" | "UPDATE" diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index 2888a1f3..6f6fe81c 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -68,6 +68,155 @@ interface NumberingRuleConfig { } class NumberingRuleService { + /** + * 순번(sequence) 파트를 제외한 나머지 파트 값들을 조합해 prefix_key 생성 + * 이 키가 같으면 같은 순번 계열, 다르면 001부터 재시작 + */ + private async buildPrefixKey( + rule: NumberingRuleConfig, + formData?: Record + ): Promise { + const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order); + const prefixParts: string[] = []; + + for (const part of sortedParts) { + if (part.partType === "sequence") continue; + + if (part.generationMethod === "manual") { + // 수동 입력 파트는 prefix에서 제외 (값이 매번 달라질 수 있으므로) + continue; + } + + const autoConfig = (part as any).autoConfig || {}; + + switch (part.partType) { + case "date": { + const dateFormat = autoConfig.dateFormat || "YYYYMMDD"; + if (autoConfig.useColumnValue && autoConfig.sourceColumnName && formData) { + const columnValue = formData[autoConfig.sourceColumnName]; + if (columnValue) { + const dateValue = columnValue instanceof Date ? columnValue : new Date(columnValue); + if (!isNaN(dateValue.getTime())) { + prefixParts.push(this.formatDate(dateValue, dateFormat)); + break; + } + } + } + prefixParts.push(this.formatDate(new Date(), dateFormat)); + break; + } + + case "text": { + prefixParts.push(autoConfig.textValue || "TEXT"); + break; + } + + case "number": { + const length = autoConfig.numberLength || 3; + const value = autoConfig.numberValue || 1; + prefixParts.push(String(value).padStart(length, "0")); + break; + } + + case "category": { + const categoryKey = autoConfig.categoryKey; + const categoryMappings = autoConfig.categoryMappings || []; + + if (!categoryKey || !formData) { + prefixParts.push(""); + break; + } + + const columnName = categoryKey.includes(".") + ? categoryKey.split(".")[1] + : categoryKey; + const selectedValue = formData[columnName]; + + if (!selectedValue) { + prefixParts.push(""); + break; + } + + const selectedValueStr = String(selectedValue); + let mapping = categoryMappings.find((m: any) => { + if (m.categoryValueId?.toString() === selectedValueStr) return true; + if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) return true; + if (m.categoryValueLabel === selectedValueStr) return true; + return false; + }); + + if (!mapping) { + try { + const pool = getPool(); + const [catTableName, catColumnName] = categoryKey.includes(".") + ? categoryKey.split(".") + : [categoryKey, categoryKey]; + const cvResult = await pool.query( + `SELECT value_id, value_label FROM category_values + WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`, + [catTableName, catColumnName, selectedValueStr] + ); + if (cvResult.rows.length > 0) { + const resolvedId = cvResult.rows[0].value_id; + const resolvedLabel = cvResult.rows[0].value_label; + mapping = categoryMappings.find((m: any) => { + if (m.categoryValueId?.toString() === String(resolvedId)) return true; + if (m.categoryValueLabel === resolvedLabel) return true; + return false; + }); + } + } catch { /* ignore */ } + } + + prefixParts.push(mapping?.format || selectedValueStr); + break; + } + + default: + break; + } + } + + return prefixParts.join("|"); + } + + /** + * prefix_key 기반으로 현재 순번 조회 (새 테이블 사용) + */ + private async getSequenceForPrefix( + client: any, + ruleId: string, + companyCode: string, + prefixKey: string + ): Promise { + const result = await client.query( + `SELECT current_sequence FROM numbering_rule_sequences + WHERE rule_id = $1 AND company_code = $2 AND prefix_key = $3`, + [ruleId, companyCode, prefixKey] + ); + return result.rows.length > 0 ? result.rows[0].current_sequence : 0; + } + + /** + * prefix_key 기반으로 순번 증가 (UPSERT) + */ + private async incrementSequenceForPrefix( + client: any, + ruleId: string, + companyCode: string, + prefixKey: string + ): Promise { + const result = await client.query( + `INSERT INTO numbering_rule_sequences (rule_id, company_code, prefix_key, current_sequence, last_allocated_at) + VALUES ($1, $2, $3, 1, NOW()) + ON CONFLICT (rule_id, company_code, prefix_key) + DO UPDATE SET current_sequence = numbering_rule_sequences.current_sequence + 1, + last_allocated_at = NOW() + RETURNING current_sequence`, + [ruleId, companyCode, prefixKey] + ); + return result.rows[0].current_sequence; + } /** * 규칙 목록 조회 (전체) */ @@ -928,12 +1077,19 @@ class NumberingRuleService { const rule = await this.getRuleById(ruleId, companyCode); if (!rule) throw new Error("규칙을 찾을 수 없습니다"); + // prefix_key 기반 순번 조회 + const prefixKey = await this.buildPrefixKey(rule, formData); + const pool = getPool(); + const currentSeq = await this.getSequenceForPrefix(pool, ruleId, companyCode, prefixKey); + + logger.info("미리보기: prefix_key 기반 순번 조회", { + ruleId, prefixKey, currentSeq, + }); + const parts = await Promise.all(rule.parts .sort((a: any, b: any) => a.order - b.order) .map(async (part: any) => { if (part.generationMethod === "manual") { - // 수동 입력 - 항상 ____ 마커 사용 (프론트엔드에서 편집 가능하게 처리) - // placeholder 텍스트는 프론트엔드에서 별도로 표시 return "____"; } @@ -941,9 +1097,8 @@ class NumberingRuleService { switch (part.partType) { case "sequence": { - // 순번 (다음 할당될 순번으로 미리보기, 실제 증가는 allocate 시) const length = autoConfig.sequenceLength || 3; - const nextSequence = (rule.currentSequence || 0) + 1; + const nextSequence = currentSeq + 1; return String(nextSequence).padStart(length, "0"); } @@ -1129,6 +1284,27 @@ class NumberingRuleService { const rule = await this.getRuleById(ruleId, companyCode); if (!rule) throw new Error("규칙을 찾을 수 없습니다"); + // prefix_key 기반 순번: 순번 이외 파트 조합으로 prefix 생성 + const prefixKey = await this.buildPrefixKey(rule, formData); + const hasSequence = rule.parts.some((p: any) => p.partType === "sequence"); + + // 순번이 있으면 prefix_key 기반으로 UPSERT하여 다음 순번 획득 + let allocatedSequence = 0; + if (hasSequence) { + allocatedSequence = await this.incrementSequenceForPrefix( + client, ruleId, companyCode, prefixKey + ); + // 호환성을 위해 기존 current_sequence도 업데이트 + await client.query( + "UPDATE numbering_rules SET current_sequence = current_sequence + 1 WHERE rule_id = $1 AND company_code = $2", + [ruleId, companyCode] + ); + } + + logger.info("allocateCode: prefix_key 기반 순번 할당", { + ruleId, prefixKey, allocatedSequence, + }); + // 수동 입력 파트가 있고, 사용자가 입력한 코드가 있으면 수동 입력 부분 추출 const manualParts = rule.parts.filter( (p: any) => p.generationMethod === "manual" @@ -1136,8 +1312,6 @@ class NumberingRuleService { let extractedManualValues: string[] = []; if (manualParts.length > 0 && userInputCode) { - // 프리뷰 코드를 생성해서 ____ 위치 파악 - // 🔧 category 파트도 처리하여 올바른 템플릿 생성 const previewParts = await Promise.all(rule.parts .sort((a: any, b: any) => a.order - b.order) .map(async (part: any) => { @@ -1148,19 +1322,18 @@ class NumberingRuleService { switch (part.partType) { case "sequence": { const length = autoConfig.sequenceLength || 3; - return "X".repeat(length); // 순번 자리 표시 + return "X".repeat(length); } case "text": return autoConfig.textValue || ""; case "date": - return "DATEPART"; // 날짜 자리 표시 + return "DATEPART"; case "category": { - // 카테고리 파트: formData에서 실제 값을 가져와서 매핑된 형식 사용 const catKey2 = autoConfig.categoryKey; const catMappings2 = autoConfig.categoryMappings || []; if (!catKey2 || !formData) { - return "CATEGORY"; // 폴백 + return "CATEGORY"; } const colName2 = catKey2.includes(".") @@ -1169,7 +1342,7 @@ class NumberingRuleService { const selVal2 = formData[colName2]; if (!selVal2) { - return "CATEGORY"; // 폴백 + return "CATEGORY"; } const selValStr2 = String(selVal2); @@ -1180,7 +1353,6 @@ class NumberingRuleService { return false; }); - // valueCode → valueId 역변환 시도 if (!catMapping2) { try { const pool2 = getPool(); @@ -1211,8 +1383,6 @@ class NumberingRuleService { const sortedPartsForTemplate = rule.parts.sort((a: any, b: any) => a.order - b.order); const previewTemplate = joinPartsWithSeparators(previewParts, sortedPartsForTemplate, rule.separator || ""); - // 사용자 입력 코드에서 수동 입력 부분 추출 - // 예: 템플릿 "R-____-XXX", 사용자입력 "R-MYVALUE-012" → "MYVALUE" 추출 const templateParts = previewTemplate.split("____"); if (templateParts.length > 1) { let remainingCode = userInputCode; @@ -1220,14 +1390,11 @@ class NumberingRuleService { const prefix = templateParts[i]; const suffix = templateParts[i + 1]; - // prefix 이후 부분 추출 if (prefix && remainingCode.startsWith(prefix)) { remainingCode = remainingCode.slice(prefix.length); } - // suffix 이전까지가 수동 입력 값 if (suffix) { - // suffix에서 순번(XXX)이나 날짜 부분을 제외한 실제 구분자 찾기 const suffixStart = suffix.replace(/X+|DATEPART/g, ""); const manualEndIndex = suffixStart ? remainingCode.indexOf(suffixStart) @@ -1254,7 +1421,6 @@ class NumberingRuleService { .sort((a: any, b: any) => a.order - b.order) .map(async (part: any) => { if (part.generationMethod === "manual") { - // 추출된 수동 입력 값 사용, 없으면 기본값 사용 const manualValue = extractedManualValues[manualPartIndex] || part.manualConfig?.value || @@ -1267,24 +1433,19 @@ class NumberingRuleService { switch (part.partType) { case "sequence": { - // 순번 (자동 증가 숫자 - 다음 번호 사용) const length = autoConfig.sequenceLength || 3; - const nextSequence = (rule.currentSequence || 0) + 1; - return String(nextSequence).padStart(length, "0"); + return String(allocatedSequence).padStart(length, "0"); } case "number": { - // 숫자 (고정 자릿수) const length = autoConfig.numberLength || 3; const value = autoConfig.numberValue || 1; return String(value).padStart(length, "0"); } case "date": { - // 날짜 (다양한 날짜 형식) const dateFormat = autoConfig.dateFormat || "YYYYMMDD"; - // 컬럼 기준 생성인 경우 폼 데이터에서 날짜 추출 if ( autoConfig.useColumnValue && autoConfig.sourceColumnName && @@ -1292,80 +1453,42 @@ class NumberingRuleService { ) { const columnValue = formData[autoConfig.sourceColumnName]; if (columnValue) { - // 날짜 문자열 또는 Date 객체를 Date로 변환 const dateValue = columnValue instanceof Date ? columnValue : new Date(columnValue); if (!isNaN(dateValue.getTime())) { - logger.info("컬럼 기준 날짜 생성", { - sourceColumn: autoConfig.sourceColumnName, - columnValue, - parsedDate: dateValue.toISOString(), - }); return this.formatDate(dateValue, dateFormat); - } else { - logger.warn("날짜 변환 실패, 현재 날짜 사용", { - sourceColumn: autoConfig.sourceColumnName, - columnValue, - }); } - } else { - logger.warn("소스 컬럼 값이 없음, 현재 날짜 사용", { - sourceColumn: autoConfig.sourceColumnName, - formDataKeys: Object.keys(formData), - }); } } - // 기본: 현재 날짜 사용 return this.formatDate(new Date(), dateFormat); } case "text": { - // 텍스트 (고정 문자열) return autoConfig.textValue || "TEXT"; } case "category": { - // 카테고리 기반 코드 생성 (allocateCode용) - const categoryKey = autoConfig.categoryKey; // 예: "item_info.material" + const categoryKey = autoConfig.categoryKey; const categoryMappings = autoConfig.categoryMappings || []; if (!categoryKey || !formData) { - logger.warn("allocateCode: 카테고리 키 또는 폼 데이터 없음", { - categoryKey, - hasFormData: !!formData, - }); return ""; } - // categoryKey에서 컬럼명 추출 (예: "item_info.material" -> "material") const columnName = categoryKey.includes(".") ? categoryKey.split(".")[1] : categoryKey; - // 폼 데이터에서 해당 컬럼의 값 가져오기 const selectedValue = formData[columnName]; - logger.info("allocateCode: 카테고리 파트 처리", { - categoryKey, - columnName, - selectedValue, - formDataKeys: Object.keys(formData), - mappingsCount: categoryMappings.length, - }); - if (!selectedValue) { - logger.warn("allocateCode: 카테고리 값이 선택되지 않음", { - columnName, - formDataKeys: Object.keys(formData), - }); return ""; } - // 카테고리 매핑에서 해당 값에 대한 형식 찾기 const selectedValueStr = String(selectedValue); let allocMapping = categoryMappings.find((m: any) => { if (m.categoryValueId?.toString() === selectedValueStr) return true; @@ -1374,7 +1497,6 @@ class NumberingRuleService { return false; }); - // valueCode → valueId 역변환 시도 if (!allocMapping) { try { const pool3 = getPool(); @@ -1391,37 +1513,18 @@ class NumberingRuleService { if (m.categoryValueLabel === rlabel3) return true; return false; }); - if (allocMapping) { - logger.info("allocateCode: 카테고리 매핑 역변환 성공", { - valueCode: selectedValueStr, resolvedId: rid3, format: allocMapping.format, - }); - } } } catch { /* ignore */ } } if (allocMapping) { - logger.info("allocateCode: 카테고리 매핑 적용", { - selectedValue, - format: allocMapping.format, - categoryValueLabel: allocMapping.categoryValueLabel, - }); return allocMapping.format || ""; } - logger.warn("allocateCode: 카테고리 매핑을 찾을 수 없음", { - selectedValue, - availableMappings: categoryMappings.map((m: any) => ({ - id: m.categoryValueId, - code: m.categoryValueCode, - label: m.categoryValueLabel, - })), - }); return ""; } default: - logger.warn("알 수 없는 파트 타입", { partType: part.partType }); return ""; } })); @@ -1429,17 +1532,6 @@ class NumberingRuleService { const sortedPartsForAlloc = rule.parts.sort((a: any, b: any) => a.order - b.order); const allocatedCode = joinPartsWithSeparators(parts, sortedPartsForAlloc, rule.separator || ""); - // 순번이 있는 경우에만 증가 - const hasSequence = rule.parts.some( - (p: any) => p.partType === "sequence" - ); - if (hasSequence) { - await client.query( - "UPDATE numbering_rules SET current_sequence = current_sequence + 1 WHERE rule_id = $1 AND company_code = $2", - [ruleId, companyCode] - ); - } - await client.query("COMMIT"); logger.info("코드 할당 완료", { ruleId, allocatedCode, companyCode }); return allocatedCode; @@ -1492,11 +1584,17 @@ class NumberingRuleService { async resetSequence(ruleId: string, companyCode: string): Promise { const pool = getPool(); + // 새 테이블의 모든 prefix 순번 초기화 await pool.query( - "UPDATE numbering_rules SET current_sequence = 1, updated_at = NOW() WHERE rule_id = $1 AND company_code = $2", + "DELETE FROM numbering_rule_sequences WHERE rule_id = $1 AND company_code = $2", [ruleId, companyCode] ); - logger.info("시퀀스 초기화 완료", { ruleId, companyCode }); + // 기존 테이블도 초기화 (호환성) + await pool.query( + "UPDATE numbering_rules SET current_sequence = 0, updated_at = NOW() WHERE rule_id = $1 AND company_code = $2", + [ruleId, companyCode] + ); + logger.info("시퀀스 초기화 완료 (prefix별 순번 포함)", { ruleId, companyCode }); } /**