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:
@@ -68,6 +68,155 @@ interface NumberingRuleConfig {
|
||||
}
|
||||
|
||||
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);
|
||||
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<void> {
|
||||
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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user