feat: Enhance audit logging with client IP tracking

- Integrated client IP address retrieval in the audit logging functionality across multiple controllers, including admin, common code, department, flow, screen, and table management.
- Updated the `auditLogService` to include a new method for obtaining the client's IP address, ensuring accurate logging of user actions.
- This enhancement improves traceability and accountability by capturing the source of requests, thereby strengthening the overall logging mechanism within the application.
This commit is contained in:
kjs 2026-03-04 15:02:27 +09:00
parent 459777d5f0
commit 96637a9cb6
12 changed files with 268 additions and 154 deletions

View File

@ -134,6 +134,8 @@ import { BatchSchedulerService } from "./services/batchSchedulerService";
const app = express(); const app = express();
app.set("trust proxy", true);
// 기본 미들웨어 // 기본 미들웨어
app.use( app.use(
helmet({ helmet({

View File

@ -10,7 +10,7 @@ import { EncryptUtil } from "../utils/encryptUtil";
import { FileSystemManager } from "../utils/fileSystemManager"; import { FileSystemManager } from "../utils/fileSystemManager";
import { validateBusinessNumber } from "../utils/businessNumberValidator"; import { validateBusinessNumber } from "../utils/businessNumberValidator";
import { MenuCopyService } from "../services/menuCopyService"; 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, resourceName: savedMenu.menu_name_kor,
summary: `메뉴 "${savedMenu.menu_name_kor}" 생성`, summary: `메뉴 "${savedMenu.menu_name_kor}" 생성`,
changes: { after: { menuNameKor: savedMenu.menu_name_kor, menuUrl: savedMenu.menu_url, status: savedMenu.status } }, 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, requestPath: req.originalUrl,
}); });
@ -1403,7 +1403,7 @@ export async function updateMenu(
before: { menuNameKor: currentMenu.menu_name_kor, menuUrl: currentMenu.menu_url, status: currentMenu.status }, 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 }, after: { menuNameKor: updatedMenu.menu_name_kor, menuUrl: updatedMenu.menu_url, status: updatedMenu.status },
}, },
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
@ -1596,7 +1596,7 @@ export async function deleteMenu(
resourceName: currentMenu.menu_name_kor, resourceName: currentMenu.menu_name_kor,
summary: `메뉴 "${currentMenu.menu_name_kor}" 삭제 (하위 ${childMenuIds.length}개 포함, 총 ${allMenuIdsToDelete.length}건)`, summary: `메뉴 "${currentMenu.menu_name_kor}" 삭제 (하위 ${childMenuIds.length}개 포함, 총 ${allMenuIdsToDelete.length}건)`,
changes: { before: { menuNameKor: currentMenu.menu_name_kor } }, changes: { before: { menuNameKor: currentMenu.menu_name_kor } },
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
@ -1772,7 +1772,7 @@ export async function deleteMenusBatch(
resourceType: "MENU", resourceType: "MENU",
summary: `메뉴 일괄 삭제: ${deletedCount}개 삭제, ${failedCount}개 실패`, summary: `메뉴 일괄 삭제: ${deletedCount}개 삭제, ${failedCount}개 실패`,
changes: { before: { deletedMenus, failedMenuIds } }, changes: { before: { deletedMenus, failedMenuIds } },
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
} }
@ -1883,7 +1883,7 @@ export async function toggleMenuStatus(
resourceName: currentMenu.menu_name_kor, resourceName: currentMenu.menu_name_kor,
summary: `메뉴 "${currentMenu.menu_name_kor}" 상태 변경: ${currentStatus}${newStatus}`, summary: `메뉴 "${currentMenu.menu_name_kor}" 상태 변경: ${currentStatus}${newStatus}`,
changes: { before: { status: currentStatus }, after: { status: newStatus }, fields: ["status"] }, changes: { before: { status: currentStatus }, after: { status: newStatus }, fields: ["status"] },
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
@ -2526,7 +2526,7 @@ export const changeUserStatus = async (
resourceName: currentUser.user_name, resourceName: currentUser.user_name,
summary: `사용자 "${currentUser.user_name}" 상태 변경: ${currentUser.status}${status}`, summary: `사용자 "${currentUser.user_name}" 상태 변경: ${currentUser.status}${status}`,
changes: { before: { status: currentUser.status }, after: { status }, fields: ["status"] }, changes: { before: { status: currentUser.status }, after: { status }, fields: ["status"] },
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
@ -2677,7 +2677,7 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => {
resourceName: userData.userName, resourceName: userData.userName,
summary: isExistingUser ? `사용자 "${userData.userName}" 정보 수정` : `사용자 "${userData.userName}" 등록`, summary: isExistingUser ? `사용자 "${userData.userName}" 정보 수정` : `사용자 "${userData.userName}" 등록`,
changes: { after: { userId: userData.userId, userName: userData.userName, deptName: userData.deptName, status: userData.status } }, 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, requestPath: req.originalUrl,
}); });
@ -2881,7 +2881,7 @@ export const createCompany = async (
resourceName: createdCompany.company_name, resourceName: createdCompany.company_name,
summary: `회사 "${createdCompany.company_name}" (${createdCompany.company_code}) 등록`, summary: `회사 "${createdCompany.company_name}" (${createdCompany.company_code}) 등록`,
changes: { after: { company_code: createdCompany.company_code, company_name: createdCompany.company_name } }, changes: { after: { company_code: createdCompany.company_code, company_name: createdCompany.company_name } },
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
@ -3127,7 +3127,7 @@ export const updateCompany = async (
before: { company_name: beforeCompany?.company_name, status: beforeCompany?.status }, before: { company_name: beforeCompany?.company_name, status: beforeCompany?.status },
after: { company_name: updatedCompany.company_name, status: updatedCompany.status }, after: { company_name: updatedCompany.company_name, status: updatedCompany.status },
}, },
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
@ -3202,7 +3202,7 @@ export const deleteCompany = async (
resourceName: deletedCompany.company_name, resourceName: deletedCompany.company_name,
summary: `회사 "${deletedCompany.company_name}" (${deletedCompany.company_code}) 삭제`, summary: `회사 "${deletedCompany.company_name}" (${deletedCompany.company_code}) 삭제`,
changes: { before: { company_code: deletedCompany.company_code, company_name: deletedCompany.company_name } }, changes: { before: { company_code: deletedCompany.company_code, company_name: deletedCompany.company_name } },
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
@ -3382,7 +3382,7 @@ export const updateProfile = async (
resourceName: updatedUser?.user_name || "", resourceName: updatedUser?.user_name || "",
summary: `프로필 수정 (${updateFields.length}개 항목)`, summary: `프로필 수정 (${updateFields.length}개 항목)`,
changes: { after: { userName, email, tel, cellPhone, locale } }, changes: { after: { userName, email, tel, cellPhone, locale } },
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
@ -3509,7 +3509,7 @@ export const resetUserPassword = async (
resourceName: currentUser.user_name, resourceName: currentUser.user_name,
summary: `사용자 "${currentUser.user_name}" 비밀번호 초기화`, summary: `사용자 "${currentUser.user_name}" 비밀번호 초기화`,
changes: { fields: ["user_password"] }, changes: { fields: ["user_password"] },
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
@ -3723,7 +3723,7 @@ export async function copyMenu(
resourceId: menuObjid, resourceId: menuObjid,
summary: `메뉴(${menuObjid}) → 회사 "${targetCompanyCode}"로 복사`, summary: `메뉴(${menuObjid}) → 회사 "${targetCompanyCode}"로 복사`,
changes: { after: { targetCompanyCode, menuObjid } }, changes: { after: { targetCompanyCode, menuObjid } },
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
@ -4051,7 +4051,7 @@ export const saveUserWithDept = async (
resourceName: userInfo.user_name, resourceName: userInfo.user_name,
summary: `사용자 "${userInfo.user_name}" ${isExistingUser ? "수정" : "등록"} (부서: ${mainDept?.dept_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 } }, 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, requestPath: req.originalUrl,
}); });

View File

@ -6,7 +6,7 @@ import {
} from "../services/commonCodeService"; } from "../services/commonCodeService";
import { AuthenticatedRequest } from "../types/auth"; import { AuthenticatedRequest } from "../types/auth";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { auditLogService } from "../services/auditLogService"; import { auditLogService, getClientIp } from "../services/auditLogService";
export class CommonCodeController { export class CommonCodeController {
private commonCodeService: CommonCodeService; private commonCodeService: CommonCodeService;
@ -172,7 +172,7 @@ export class CommonCodeController {
resourceId: category?.categoryCode, resourceId: category?.categoryCode,
resourceName: category?.categoryName || categoryData.categoryName, resourceName: category?.categoryName || categoryData.categoryName,
summary: `코드 카테고리 "${category?.categoryName || categoryData.categoryName}" 생성`, summary: `코드 카테고리 "${category?.categoryName || categoryData.categoryName}" 생성`,
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
@ -229,7 +229,7 @@ export class CommonCodeController {
resourceId: categoryCode, resourceId: categoryCode,
resourceName: category?.categoryName, resourceName: category?.categoryName,
summary: `코드 카테고리 "${categoryCode}" 수정`, summary: `코드 카테고리 "${categoryCode}" 수정`,
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
@ -277,7 +277,7 @@ export class CommonCodeController {
resourceType: "CODE_CATEGORY", resourceType: "CODE_CATEGORY",
resourceId: categoryCode, resourceId: categoryCode,
summary: `코드 카테고리 "${categoryCode}" 삭제`, summary: `코드 카테고리 "${categoryCode}" 삭제`,
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
@ -347,7 +347,7 @@ export class CommonCodeController {
resourceId: codeData.codeValue, resourceId: codeData.codeValue,
resourceName: codeData.codeName, resourceName: codeData.codeName,
summary: `코드 "${codeData.codeName}" (${categoryCode}) 생성`, summary: `코드 "${codeData.codeName}" (${categoryCode}) 생성`,
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });

View File

@ -9,7 +9,7 @@ import { DDLExecutionService } from "../services/ddlExecutionService";
import { DDLAuditLogger } from "../services/ddlAuditLogger"; import { DDLAuditLogger } from "../services/ddlAuditLogger";
import { CreateTableRequest, AddColumnRequest } from "../types/ddl"; import { CreateTableRequest, AddColumnRequest } from "../types/ddl";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { auditLogService } from "../services/auditLogService"; import { auditLogService, getClientIp } from "../services/auditLogService";
export class DDLController { export class DDLController {
/** /**
@ -70,7 +70,7 @@ export class DDLController {
tableName, tableName,
summary: `테이블 "${tableName}" 생성 (${columns.length}개 컬럼)`, summary: `테이블 "${tableName}" 생성 (${columns.length}개 컬럼)`,
changes: { after: { tableName, columnCount: columns.length, description } }, changes: { after: { tableName, columnCount: columns.length, description } },
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });

View File

@ -3,7 +3,7 @@ import { logger } from "../utils/logger";
import { AuthenticatedRequest } from "../types/auth"; import { AuthenticatedRequest } from "../types/auth";
import { ApiResponse } from "../types/common"; import { ApiResponse } from "../types/common";
import { query, queryOne } from "../database/db"; 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", tableName: "dept_info",
summary: `부서 "${dept_name.trim()}" 생성`, summary: `부서 "${dept_name.trim()}" 생성`,
changes: { after: { deptCode, deptName: dept_name.trim(), parentDeptCode: parent_dept_code } }, changes: { after: { deptCode, deptName: dept_name.trim(), parentDeptCode: parent_dept_code } },
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
@ -246,7 +246,7 @@ export async function updateDepartment(req: AuthenticatedRequest, res: Response)
tableName: "dept_info", tableName: "dept_info",
summary: `부서 "${dept_name.trim()}" 수정`, summary: `부서 "${dept_name.trim()}" 수정`,
changes: { after: { deptName: dept_name.trim(), parentDeptCode: parent_dept_code } }, changes: { after: { deptName: dept_name.trim(), parentDeptCode: parent_dept_code } },
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
@ -327,7 +327,7 @@ export async function deleteDepartment(req: AuthenticatedRequest, res: Response)
tableName: "dept_info", tableName: "dept_info",
summary: `부서 "${result[0].dept_name}" 삭제${memberCount > 0 ? ` (부서원 ${memberCount}명 제외)` : ""}`, summary: `부서 "${result[0].dept_name}" 삭제${memberCount > 0 ? ` (부서원 ${memberCount}명 제외)` : ""}`,
changes: { before: { deptCode, deptName: result[0].dept_name } }, changes: { before: { deptCode, deptName: result[0].dept_name } },
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });

View File

@ -10,7 +10,7 @@ import { FlowConnectionService } from "../services/flowConnectionService";
import { FlowExecutionService } from "../services/flowExecutionService"; import { FlowExecutionService } from "../services/flowExecutionService";
import { FlowDataMoveService } from "../services/flowDataMoveService"; import { FlowDataMoveService } from "../services/flowDataMoveService";
import { FlowProcedureService } from "../services/flowProcedureService"; import { FlowProcedureService } from "../services/flowProcedureService";
import { auditLogService } from "../services/auditLogService"; import { auditLogService, getClientIp } from "../services/auditLogService";
export class FlowController { export class FlowController {
private flowDefinitionService: FlowDefinitionService; private flowDefinitionService: FlowDefinitionService;
@ -102,7 +102,7 @@ export class FlowController {
resourceName: flowDef?.name || name, resourceName: flowDef?.name || name,
summary: `플로우 "${flowDef?.name || name}" 생성`, summary: `플로우 "${flowDef?.name || name}" 생성`,
changes: { after: { name, tableName } }, changes: { after: { name, tableName } },
ipAddress: (req as any).ip, ipAddress: getClientIp(req as any),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
@ -229,7 +229,7 @@ export class FlowController {
before: { name: beforeFlow?.name, description: beforeFlow?.description, isActive: beforeFlow?.isActive }, before: { name: beforeFlow?.name, description: beforeFlow?.description, isActive: beforeFlow?.isActive },
after: { name, description, isActive }, after: { name, description, isActive },
}, },
ipAddress: (req as any).ip, ipAddress: getClientIp(req as any),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
@ -272,7 +272,7 @@ export class FlowController {
resourceType: "FLOW", resourceType: "FLOW",
resourceId: String(flowId), resourceId: String(flowId),
summary: `플로우(ID:${flowId}) 삭제`, summary: `플로우(ID:${flowId}) 삭제`,
ipAddress: (req as any).ip, ipAddress: getClientIp(req as any),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
@ -372,7 +372,7 @@ export class FlowController {
resourceName: stepName, resourceName: stepName,
summary: `플로우 스텝 "${stepName}" 생성 (플로우 ID:${flowDefinitionId})`, summary: `플로우 스텝 "${stepName}" 생성 (플로우 ID:${flowDefinitionId})`,
changes: { after: { stepName, tableName, stepOrder } }, changes: { after: { stepName, tableName, stepOrder } },
ipAddress: (req as any).ip, ipAddress: getClientIp(req as any),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
@ -467,7 +467,7 @@ export class FlowController {
before: { stepName: beforeStep?.stepName, tableName: beforeStep?.tableName, stepOrder: beforeStep?.stepOrder }, before: { stepName: beforeStep?.stepName, tableName: beforeStep?.tableName, stepOrder: beforeStep?.stepOrder },
after: { stepName, tableName, stepOrder }, after: { stepName, tableName, stepOrder },
}, },
ipAddress: (req as any).ip, ipAddress: getClientIp(req as any),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
@ -524,7 +524,7 @@ export class FlowController {
resourceId: String(id), resourceId: String(id),
resourceName: existingStep?.stepName, resourceName: existingStep?.stepName,
summary: `플로우 스텝 "${existingStep?.stepName || id}" 삭제`, summary: `플로우 스텝 "${existingStep?.stepName || id}" 삭제`,
ipAddress: (req as any).ip, ipAddress: getClientIp(req as any),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
@ -623,7 +623,7 @@ export class FlowController {
resourceName: flowDef?.name || "", resourceName: flowDef?.name || "",
summary: `플로우 "${flowDef?.name}" 연결 생성 (${fromStep?.stepName}${toStep?.stepName})`, summary: `플로우 "${flowDef?.name}" 연결 생성 (${fromStep?.stepName}${toStep?.stepName})`,
changes: { after: { fromStepId, toStepId, label } }, changes: { after: { fromStepId, toStepId, label } },
ipAddress: (req as any).ip, ipAddress: getClientIp(req as any),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
@ -680,7 +680,7 @@ export class FlowController {
resourceId: String(existingConn?.flowDefinitionId || id), resourceId: String(existingConn?.flowDefinitionId || id),
summary: `플로우 연결 삭제 (ID: ${id})`, summary: `플로우 연결 삭제 (ID: ${id})`,
changes: { before: { connectionId: id } }, changes: { before: { connectionId: id } },
ipAddress: (req as any).ip, ipAddress: getClientIp(req as any),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });

View File

@ -9,7 +9,7 @@ import {
} from "../middleware/authMiddleware"; } from "../middleware/authMiddleware";
import { numberingRuleService } from "../services/numberingRuleService"; import { numberingRuleService } from "../services/numberingRuleService";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { auditLogService } from "../services/auditLogService"; import { auditLogService, getClientIp } from "../services/auditLogService";
const router = Router(); const router = Router();
@ -199,7 +199,7 @@ router.post(
resourceName: ruleConfig.ruleName, resourceName: ruleConfig.ruleName,
summary: `채번 규칙 "${ruleConfig.ruleName}" 생성`, summary: `채번 규칙 "${ruleConfig.ruleName}" 생성`,
changes: { after: { ruleName: ruleConfig.ruleName, prefix: ruleConfig.prefix } }, changes: { after: { ruleName: ruleConfig.ruleName, prefix: ruleConfig.prefix } },
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
@ -251,7 +251,7 @@ router.put(
before: { ruleName: beforeRule?.ruleName, prefix: beforeRule?.prefix }, before: { ruleName: beforeRule?.ruleName, prefix: beforeRule?.prefix },
after: updates, after: updates,
}, },
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
@ -289,7 +289,7 @@ router.delete(
resourceType: "NUMBERING_RULE", resourceType: "NUMBERING_RULE",
resourceId: ruleId, resourceId: ruleId,
summary: `채번 규칙(ID:${ruleId}) 삭제`, summary: `채번 규칙(ID:${ruleId}) 삭제`,
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });

View File

@ -8,7 +8,7 @@ import {
isCompanyAdmin, isCompanyAdmin,
canAccessCompanyData, canAccessCompanyData,
} from "../utils/permissionUtils"; } from "../utils/permissionUtils";
import { auditLogService } from "../services/auditLogService"; import { auditLogService, getClientIp } from "../services/auditLogService";
/** /**
* *
@ -190,7 +190,7 @@ export const createRoleGroup = async (
resourceName: authName, resourceName: authName,
summary: `권한 그룹 "${authName}" 생성`, summary: `권한 그룹 "${authName}" 생성`,
changes: { after: { authName, authCode, companyCode } }, changes: { after: { authName, authCode, companyCode } },
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
@ -271,7 +271,7 @@ export const updateRoleGroup = async (
before: { authName: existingRoleGroup.authName, authCode: existingRoleGroup.authCode, status: existingRoleGroup.status }, before: { authName: existingRoleGroup.authName, authCode: existingRoleGroup.authCode, status: existingRoleGroup.status },
after: { authName, authCode, status }, after: { authName, authCode, status },
}, },
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
@ -343,7 +343,7 @@ export const deleteRoleGroup = async (
resourceId: String(objid), resourceId: String(objid),
resourceName: existingRoleGroup.authName, resourceName: existingRoleGroup.authName,
summary: `권한 그룹 "${existingRoleGroup.authName}" 삭제`, summary: `권한 그룹 "${existingRoleGroup.authName}" 삭제`,
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });

View File

@ -1,7 +1,7 @@
import { Response } from "express"; import { Response } from "express";
import { screenManagementService } from "../services/screenManagementService"; import { screenManagementService } from "../services/screenManagementService";
import { AuthenticatedRequest } from "../types/auth"; import { AuthenticatedRequest } from "../types/auth";
import { auditLogService } from "../services/auditLogService"; import { auditLogService, getClientIp } from "../services/auditLogService";
// 화면 목록 조회 // 화면 목록 조회
export const getScreens = async (req: AuthenticatedRequest, res: Response) => { export const getScreens = async (req: AuthenticatedRequest, res: Response) => {
@ -120,7 +120,7 @@ export const createScreen = async (
resourceName: newScreen?.screenName || screenData.screenName, resourceName: newScreen?.screenName || screenData.screenName,
summary: `화면 "${newScreen?.screenName || screenData.screenName}" 생성`, summary: `화면 "${newScreen?.screenName || screenData.screenName}" 생성`,
changes: { after: { screenName: newScreen?.screenName, tableName: newScreen?.tableName } }, changes: { after: { screenName: newScreen?.screenName, tableName: newScreen?.tableName } },
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
@ -162,7 +162,7 @@ export const updateScreen = async (
before: { screenName: beforeScreen?.screenName, tableName: beforeScreen?.tableName, isActive: beforeScreen?.isActive }, before: { screenName: beforeScreen?.screenName, tableName: beforeScreen?.tableName, isActive: beforeScreen?.isActive },
after: { screenName: updateData.screenName, tableName: updateData.tableName, isActive: updateData.isActive }, after: { screenName: updateData.screenName, tableName: updateData.tableName, isActive: updateData.isActive },
}, },
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
@ -264,7 +264,7 @@ export const updateScreenInfo = async (
before: { screenName: beforeScreen?.screenName, tableName: beforeScreen?.tableName, description: beforeScreen?.description, isActive: beforeScreen?.isActive }, before: { screenName: beforeScreen?.screenName, tableName: beforeScreen?.tableName, description: beforeScreen?.description, isActive: beforeScreen?.isActive },
after: { screenName, tableName, description, isActive }, after: { screenName, tableName, description, isActive },
}, },
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
@ -330,7 +330,7 @@ export const deleteScreen = async (
resourceName: screenName, resourceName: screenName,
summary: `화면(ID:${id}, ${screenName}) 삭제 (사유: ${deleteReason || "없음"})`, summary: `화면(ID:${id}, ${screenName}) 삭제 (사유: ${deleteReason || "없음"})`,
changes: { before: { deleteReason, force } }, changes: { before: { deleteReason, force } },
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
@ -623,7 +623,7 @@ export const copyScreenWithModals = async (
resourceName: mainScreen?.screenName, resourceName: mainScreen?.screenName,
summary: `화면 일괄 복사 (메인 1개 + 모달 ${result.modalScreens.length}개, 원본 ID:${id})`, summary: `화면 일괄 복사 (메인 1개 + 모달 ${result.modalScreens.length}개, 원본 ID:${id})`,
changes: { after: { sourceScreenId: id, targetCompanyCode, mainScreenName: mainScreen?.screenName } }, changes: { after: { sourceScreenId: id, targetCompanyCode, mainScreenName: mainScreen?.screenName } },
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
@ -672,7 +672,7 @@ export const copyScreen = async (
resourceName: screenName, resourceName: screenName,
summary: `화면 "${screenName}" 복사 (원본 ID:${id})`, summary: `화면 "${screenName}" 복사 (원본 ID:${id})`,
changes: { after: { sourceScreenId: id, screenName, screenCode } }, changes: { after: { sourceScreenId: id, screenName, screenCode } },
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
@ -786,7 +786,7 @@ export const saveLayout = async (req: AuthenticatedRequest, res: Response) => {
resourceId: screenId, resourceId: screenId,
resourceName: screenInfo?.screenName || "", resourceName: screenInfo?.screenName || "",
summary: `화면(ID:${screenId}, ${screenInfo?.screenName || ""}) 레이아웃 저장`, summary: `화면(ID:${screenId}, ${screenInfo?.screenName || ""}) 레이아웃 저장`,
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
@ -877,7 +877,7 @@ export const saveLayoutV2 = async (req: AuthenticatedRequest, res: Response) =>
resourceId: screenId, resourceId: screenId,
resourceName: screenInfo?.screenName || "", resourceName: screenInfo?.screenName || "",
summary: `화면(ID:${screenId}, ${screenInfo?.screenName || ""}) V2 레이아웃 저장`, summary: `화면(ID:${screenId}, ${screenInfo?.screenName || ""}) V2 레이아웃 저장`,
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
@ -1064,7 +1064,7 @@ export const saveLayoutPop = async (req: AuthenticatedRequest, res: Response) =>
resourceId: screenId, resourceId: screenId,
resourceName: screenInfo?.screenName || "", resourceName: screenInfo?.screenName || "",
summary: `화면(ID:${screenId}, ${screenInfo?.screenName || ""}) POP 레이아웃 저장`, summary: `화면(ID:${screenId}, ${screenInfo?.screenName || ""}) POP 레이아웃 저장`,
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });

View File

@ -13,7 +13,7 @@ import {
ColumnSettingsResponse, ColumnSettingsResponse,
} from "../types/tableManagement"; } from "../types/tableManagement";
import { query } from "../database/db"; 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, tableName,
summary: `${tableName} 데이터 추가`, summary: `${tableName} 데이터 추가`,
changes: { after: data }, changes: { after: data },
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
@ -1127,7 +1127,7 @@ export async function editTableData(
tableName, tableName,
summary: `${tableName} 데이터 수정`, summary: `${tableName} 데이터 수정`,
changes: { before: changedBefore, after: changedAfter }, changes: { before: changedBefore, after: changedAfter },
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
} }
@ -1461,7 +1461,7 @@ export async function deleteTableData(
tableName, tableName,
summary: `${tableName} 데이터 삭제 (${deletedCount}건)`, summary: `${tableName} 데이터 삭제 (${deletedCount}건)`,
changes: { before: { deletedCount, items: deleteItems.length } }, changes: { before: { deletedCount, items: deleteItems.length } },
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });
@ -2355,7 +2355,7 @@ export async function multiTableSave(
tableName: mainTableName, tableName: mainTableName,
summary: `${mainTableName} 데이터 ${isUpdate ? "수정" : "생성"}${subTableResults.length > 0 ? ` (서브 테이블 ${subTableResults.length}건)` : ""}`, summary: `${mainTableName} 데이터 ${isUpdate ? "수정" : "생성"}${subTableResults.length > 0 ? ` (서브 테이블 ${subTableResults.length}건)` : ""}`,
changes: { after: mainData }, changes: { after: mainData },
ipAddress: (req as any).ip, ipAddress: getClientIp(req),
requestPath: req.originalUrl, requestPath: req.originalUrl,
}); });

View File

@ -1,6 +1,20 @@
import { Request } from "express";
import { query, pool } from "../database/db"; import { query, pool } from "../database/db";
import logger from "../utils/logger"; 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 = export type AuditAction =
| "CREATE" | "CREATE"
| "UPDATE" | "UPDATE"

View File

@ -68,6 +68,155 @@ interface NumberingRuleConfig {
} }
class NumberingRuleService { class NumberingRuleService {
/**
* (sequence) prefix_key
* , 001
*/
private async buildPrefixKey(
rule: NumberingRuleConfig,
formData?: Record<string, any>
): Promise<string> {
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<number> {
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<number> {
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); const rule = await this.getRuleById(ruleId, companyCode);
if (!rule) throw new Error("규칙을 찾을 수 없습니다"); 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 const parts = await Promise.all(rule.parts
.sort((a: any, b: any) => a.order - b.order) .sort((a: any, b: any) => a.order - b.order)
.map(async (part: any) => { .map(async (part: any) => {
if (part.generationMethod === "manual") { if (part.generationMethod === "manual") {
// 수동 입력 - 항상 ____ 마커 사용 (프론트엔드에서 편집 가능하게 처리)
// placeholder 텍스트는 프론트엔드에서 별도로 표시
return "____"; return "____";
} }
@ -941,9 +1097,8 @@ class NumberingRuleService {
switch (part.partType) { switch (part.partType) {
case "sequence": { case "sequence": {
// 순번 (다음 할당될 순번으로 미리보기, 실제 증가는 allocate 시)
const length = autoConfig.sequenceLength || 3; const length = autoConfig.sequenceLength || 3;
const nextSequence = (rule.currentSequence || 0) + 1; const nextSequence = currentSeq + 1;
return String(nextSequence).padStart(length, "0"); return String(nextSequence).padStart(length, "0");
} }
@ -1129,6 +1284,27 @@ class NumberingRuleService {
const rule = await this.getRuleById(ruleId, companyCode); const rule = await this.getRuleById(ruleId, companyCode);
if (!rule) throw new Error("규칙을 찾을 수 없습니다"); 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( const manualParts = rule.parts.filter(
(p: any) => p.generationMethod === "manual" (p: any) => p.generationMethod === "manual"
@ -1136,8 +1312,6 @@ class NumberingRuleService {
let extractedManualValues: string[] = []; let extractedManualValues: string[] = [];
if (manualParts.length > 0 && userInputCode) { if (manualParts.length > 0 && userInputCode) {
// 프리뷰 코드를 생성해서 ____ 위치 파악
// 🔧 category 파트도 처리하여 올바른 템플릿 생성
const previewParts = await Promise.all(rule.parts const previewParts = await Promise.all(rule.parts
.sort((a: any, b: any) => a.order - b.order) .sort((a: any, b: any) => a.order - b.order)
.map(async (part: any) => { .map(async (part: any) => {
@ -1148,19 +1322,18 @@ class NumberingRuleService {
switch (part.partType) { switch (part.partType) {
case "sequence": { case "sequence": {
const length = autoConfig.sequenceLength || 3; const length = autoConfig.sequenceLength || 3;
return "X".repeat(length); // 순번 자리 표시 return "X".repeat(length);
} }
case "text": case "text":
return autoConfig.textValue || ""; return autoConfig.textValue || "";
case "date": case "date":
return "DATEPART"; // 날짜 자리 표시 return "DATEPART";
case "category": { case "category": {
// 카테고리 파트: formData에서 실제 값을 가져와서 매핑된 형식 사용
const catKey2 = autoConfig.categoryKey; const catKey2 = autoConfig.categoryKey;
const catMappings2 = autoConfig.categoryMappings || []; const catMappings2 = autoConfig.categoryMappings || [];
if (!catKey2 || !formData) { if (!catKey2 || !formData) {
return "CATEGORY"; // 폴백 return "CATEGORY";
} }
const colName2 = catKey2.includes(".") const colName2 = catKey2.includes(".")
@ -1169,7 +1342,7 @@ class NumberingRuleService {
const selVal2 = formData[colName2]; const selVal2 = formData[colName2];
if (!selVal2) { if (!selVal2) {
return "CATEGORY"; // 폴백 return "CATEGORY";
} }
const selValStr2 = String(selVal2); const selValStr2 = String(selVal2);
@ -1180,7 +1353,6 @@ class NumberingRuleService {
return false; return false;
}); });
// valueCode → valueId 역변환 시도
if (!catMapping2) { if (!catMapping2) {
try { try {
const pool2 = getPool(); const pool2 = getPool();
@ -1211,8 +1383,6 @@ class NumberingRuleService {
const sortedPartsForTemplate = rule.parts.sort((a: any, b: any) => a.order - b.order); const sortedPartsForTemplate = rule.parts.sort((a: any, b: any) => a.order - b.order);
const previewTemplate = joinPartsWithSeparators(previewParts, sortedPartsForTemplate, rule.separator || ""); const previewTemplate = joinPartsWithSeparators(previewParts, sortedPartsForTemplate, rule.separator || "");
// 사용자 입력 코드에서 수동 입력 부분 추출
// 예: 템플릿 "R-____-XXX", 사용자입력 "R-MYVALUE-012" → "MYVALUE" 추출
const templateParts = previewTemplate.split("____"); const templateParts = previewTemplate.split("____");
if (templateParts.length > 1) { if (templateParts.length > 1) {
let remainingCode = userInputCode; let remainingCode = userInputCode;
@ -1220,14 +1390,11 @@ class NumberingRuleService {
const prefix = templateParts[i]; const prefix = templateParts[i];
const suffix = templateParts[i + 1]; const suffix = templateParts[i + 1];
// prefix 이후 부분 추출
if (prefix && remainingCode.startsWith(prefix)) { if (prefix && remainingCode.startsWith(prefix)) {
remainingCode = remainingCode.slice(prefix.length); remainingCode = remainingCode.slice(prefix.length);
} }
// suffix 이전까지가 수동 입력 값
if (suffix) { if (suffix) {
// suffix에서 순번(XXX)이나 날짜 부분을 제외한 실제 구분자 찾기
const suffixStart = suffix.replace(/X+|DATEPART/g, ""); const suffixStart = suffix.replace(/X+|DATEPART/g, "");
const manualEndIndex = suffixStart const manualEndIndex = suffixStart
? remainingCode.indexOf(suffixStart) ? remainingCode.indexOf(suffixStart)
@ -1254,7 +1421,6 @@ class NumberingRuleService {
.sort((a: any, b: any) => a.order - b.order) .sort((a: any, b: any) => a.order - b.order)
.map(async (part: any) => { .map(async (part: any) => {
if (part.generationMethod === "manual") { if (part.generationMethod === "manual") {
// 추출된 수동 입력 값 사용, 없으면 기본값 사용
const manualValue = const manualValue =
extractedManualValues[manualPartIndex] || extractedManualValues[manualPartIndex] ||
part.manualConfig?.value || part.manualConfig?.value ||
@ -1267,24 +1433,19 @@ class NumberingRuleService {
switch (part.partType) { switch (part.partType) {
case "sequence": { case "sequence": {
// 순번 (자동 증가 숫자 - 다음 번호 사용)
const length = autoConfig.sequenceLength || 3; const length = autoConfig.sequenceLength || 3;
const nextSequence = (rule.currentSequence || 0) + 1; return String(allocatedSequence).padStart(length, "0");
return String(nextSequence).padStart(length, "0");
} }
case "number": { case "number": {
// 숫자 (고정 자릿수)
const length = autoConfig.numberLength || 3; const length = autoConfig.numberLength || 3;
const value = autoConfig.numberValue || 1; const value = autoConfig.numberValue || 1;
return String(value).padStart(length, "0"); return String(value).padStart(length, "0");
} }
case "date": { case "date": {
// 날짜 (다양한 날짜 형식)
const dateFormat = autoConfig.dateFormat || "YYYYMMDD"; const dateFormat = autoConfig.dateFormat || "YYYYMMDD";
// 컬럼 기준 생성인 경우 폼 데이터에서 날짜 추출
if ( if (
autoConfig.useColumnValue && autoConfig.useColumnValue &&
autoConfig.sourceColumnName && autoConfig.sourceColumnName &&
@ -1292,80 +1453,42 @@ class NumberingRuleService {
) { ) {
const columnValue = formData[autoConfig.sourceColumnName]; const columnValue = formData[autoConfig.sourceColumnName];
if (columnValue) { if (columnValue) {
// 날짜 문자열 또는 Date 객체를 Date로 변환
const dateValue = const dateValue =
columnValue instanceof Date columnValue instanceof Date
? columnValue ? columnValue
: new Date(columnValue); : new Date(columnValue);
if (!isNaN(dateValue.getTime())) { if (!isNaN(dateValue.getTime())) {
logger.info("컬럼 기준 날짜 생성", {
sourceColumn: autoConfig.sourceColumnName,
columnValue,
parsedDate: dateValue.toISOString(),
});
return this.formatDate(dateValue, dateFormat); 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); return this.formatDate(new Date(), dateFormat);
} }
case "text": { case "text": {
// 텍스트 (고정 문자열)
return autoConfig.textValue || "TEXT"; return autoConfig.textValue || "TEXT";
} }
case "category": { case "category": {
// 카테고리 기반 코드 생성 (allocateCode용) const categoryKey = autoConfig.categoryKey;
const categoryKey = autoConfig.categoryKey; // 예: "item_info.material"
const categoryMappings = autoConfig.categoryMappings || []; const categoryMappings = autoConfig.categoryMappings || [];
if (!categoryKey || !formData) { if (!categoryKey || !formData) {
logger.warn("allocateCode: 카테고리 키 또는 폼 데이터 없음", {
categoryKey,
hasFormData: !!formData,
});
return ""; return "";
} }
// categoryKey에서 컬럼명 추출 (예: "item_info.material" -> "material")
const columnName = categoryKey.includes(".") const columnName = categoryKey.includes(".")
? categoryKey.split(".")[1] ? categoryKey.split(".")[1]
: categoryKey; : categoryKey;
// 폼 데이터에서 해당 컬럼의 값 가져오기
const selectedValue = formData[columnName]; const selectedValue = formData[columnName];
logger.info("allocateCode: 카테고리 파트 처리", {
categoryKey,
columnName,
selectedValue,
formDataKeys: Object.keys(formData),
mappingsCount: categoryMappings.length,
});
if (!selectedValue) { if (!selectedValue) {
logger.warn("allocateCode: 카테고리 값이 선택되지 않음", {
columnName,
formDataKeys: Object.keys(formData),
});
return ""; return "";
} }
// 카테고리 매핑에서 해당 값에 대한 형식 찾기
const selectedValueStr = String(selectedValue); const selectedValueStr = String(selectedValue);
let allocMapping = categoryMappings.find((m: any) => { let allocMapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === selectedValueStr) return true; if (m.categoryValueId?.toString() === selectedValueStr) return true;
@ -1374,7 +1497,6 @@ class NumberingRuleService {
return false; return false;
}); });
// valueCode → valueId 역변환 시도
if (!allocMapping) { if (!allocMapping) {
try { try {
const pool3 = getPool(); const pool3 = getPool();
@ -1391,37 +1513,18 @@ class NumberingRuleService {
if (m.categoryValueLabel === rlabel3) return true; if (m.categoryValueLabel === rlabel3) return true;
return false; return false;
}); });
if (allocMapping) {
logger.info("allocateCode: 카테고리 매핑 역변환 성공", {
valueCode: selectedValueStr, resolvedId: rid3, format: allocMapping.format,
});
}
} }
} catch { /* ignore */ } } catch { /* ignore */ }
} }
if (allocMapping) { if (allocMapping) {
logger.info("allocateCode: 카테고리 매핑 적용", {
selectedValue,
format: allocMapping.format,
categoryValueLabel: allocMapping.categoryValueLabel,
});
return allocMapping.format || ""; return allocMapping.format || "";
} }
logger.warn("allocateCode: 카테고리 매핑을 찾을 수 없음", {
selectedValue,
availableMappings: categoryMappings.map((m: any) => ({
id: m.categoryValueId,
code: m.categoryValueCode,
label: m.categoryValueLabel,
})),
});
return ""; return "";
} }
default: default:
logger.warn("알 수 없는 파트 타입", { partType: part.partType });
return ""; return "";
} }
})); }));
@ -1429,17 +1532,6 @@ class NumberingRuleService {
const sortedPartsForAlloc = rule.parts.sort((a: any, b: any) => a.order - b.order); const sortedPartsForAlloc = rule.parts.sort((a: any, b: any) => a.order - b.order);
const allocatedCode = joinPartsWithSeparators(parts, sortedPartsForAlloc, rule.separator || ""); 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"); await client.query("COMMIT");
logger.info("코드 할당 완료", { ruleId, allocatedCode, companyCode }); logger.info("코드 할당 완료", { ruleId, allocatedCode, companyCode });
return allocatedCode; return allocatedCode;
@ -1492,11 +1584,17 @@ class NumberingRuleService {
async resetSequence(ruleId: string, companyCode: string): Promise<void> { async resetSequence(ruleId: string, companyCode: string): Promise<void> {
const pool = getPool(); const pool = getPool();
// 새 테이블의 모든 prefix 순번 초기화
await pool.query( 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] [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 });
} }
/** /**