Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { query, queryOne, transaction, getPool } from "../database/db";
|
||||
import { EventTriggerService } from "./eventTriggerService";
|
||||
import { DataflowControlService } from "./dataflowControlService";
|
||||
import tableCategoryValueService from "./tableCategoryValueService";
|
||||
|
||||
export interface FormDataResult {
|
||||
id: number;
|
||||
@@ -427,6 +428,24 @@ export class DynamicFormService {
|
||||
dataToInsert,
|
||||
});
|
||||
|
||||
// 카테고리 타입 컬럼의 라벨 값을 코드 값으로 변환 (엑셀 업로드 등 지원)
|
||||
console.log("🏷️ 카테고리 라벨→코드 변환 시작...");
|
||||
const companyCodeForCategory = company_code || "*";
|
||||
const { convertedData: categoryConvertedData, conversions } =
|
||||
await tableCategoryValueService.convertCategoryLabelsToCodesForData(
|
||||
tableName,
|
||||
companyCodeForCategory,
|
||||
dataToInsert
|
||||
);
|
||||
|
||||
if (conversions.length > 0) {
|
||||
console.log(`🏷️ 카테고리 라벨→코드 변환 완료: ${conversions.length}개`, conversions);
|
||||
// 변환된 데이터로 교체
|
||||
Object.assign(dataToInsert, categoryConvertedData);
|
||||
} else {
|
||||
console.log("🏷️ 카테고리 라벨→코드 변환 없음 (카테고리 컬럼 없거나 이미 코드 값)");
|
||||
}
|
||||
|
||||
// 테이블 컬럼 정보 조회하여 타입 변환 적용
|
||||
console.log("🔍 테이블 컬럼 정보 조회 중...");
|
||||
const columnInfo = await this.getTableColumnInfo(tableName);
|
||||
|
||||
283
backend-node/src/services/excelMappingService.ts
Normal file
283
backend-node/src/services/excelMappingService.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import crypto from "crypto";
|
||||
|
||||
export interface ExcelMappingTemplate {
|
||||
id?: number;
|
||||
tableName: string;
|
||||
excelColumns: string[];
|
||||
excelColumnsHash: string;
|
||||
columnMappings: Record<string, string | null>; // { "엑셀컬럼": "시스템컬럼" }
|
||||
companyCode: string;
|
||||
createdDate?: Date;
|
||||
updatedDate?: Date;
|
||||
}
|
||||
|
||||
class ExcelMappingService {
|
||||
/**
|
||||
* 엑셀 컬럼 목록으로 해시 생성
|
||||
* 정렬 후 MD5 해시 생성하여 동일한 컬럼 구조 식별
|
||||
*/
|
||||
generateColumnsHash(columns: string[]): string {
|
||||
// 컬럼 목록을 정렬하여 순서와 무관하게 동일한 해시 생성
|
||||
const sortedColumns = [...columns].sort();
|
||||
const columnsString = sortedColumns.join("|");
|
||||
return crypto.createHash("md5").update(columnsString).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 컬럼 구조로 매핑 템플릿 조회
|
||||
* 동일한 컬럼 구조가 있으면 기존 매핑 반환
|
||||
*/
|
||||
async findMappingByColumns(
|
||||
tableName: string,
|
||||
excelColumns: string[],
|
||||
companyCode: string
|
||||
): Promise<ExcelMappingTemplate | null> {
|
||||
try {
|
||||
const hash = this.generateColumnsHash(excelColumns);
|
||||
|
||||
logger.info("엑셀 매핑 템플릿 조회", {
|
||||
tableName,
|
||||
excelColumns,
|
||||
hash,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
// 회사별 매핑 먼저 조회, 없으면 공통(*) 매핑 조회
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
query = `
|
||||
SELECT
|
||||
id,
|
||||
table_name as "tableName",
|
||||
excel_columns as "excelColumns",
|
||||
excel_columns_hash as "excelColumnsHash",
|
||||
column_mappings as "columnMappings",
|
||||
company_code as "companyCode",
|
||||
created_date as "createdDate",
|
||||
updated_date as "updatedDate"
|
||||
FROM excel_mapping_template
|
||||
WHERE table_name = $1
|
||||
AND excel_columns_hash = $2
|
||||
ORDER BY updated_date DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
params = [tableName, hash];
|
||||
} else {
|
||||
query = `
|
||||
SELECT
|
||||
id,
|
||||
table_name as "tableName",
|
||||
excel_columns as "excelColumns",
|
||||
excel_columns_hash as "excelColumnsHash",
|
||||
column_mappings as "columnMappings",
|
||||
company_code as "companyCode",
|
||||
created_date as "createdDate",
|
||||
updated_date as "updatedDate"
|
||||
FROM excel_mapping_template
|
||||
WHERE table_name = $1
|
||||
AND excel_columns_hash = $2
|
||||
AND (company_code = $3 OR company_code = '*')
|
||||
ORDER BY
|
||||
CASE WHEN company_code = $3 THEN 0 ELSE 1 END,
|
||||
updated_date DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
params = [tableName, hash, companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
logger.info("기존 매핑 템플릿 발견", {
|
||||
id: result.rows[0].id,
|
||||
tableName,
|
||||
});
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
logger.info("매핑 템플릿 없음 - 새 구조", { tableName, hash });
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
logger.error(`매핑 템플릿 조회 실패: ${error.message}`, { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 매핑 템플릿 저장 (UPSERT)
|
||||
* 동일한 테이블+컬럼구조+회사코드가 있으면 업데이트, 없으면 삽입
|
||||
*/
|
||||
async saveMappingTemplate(
|
||||
tableName: string,
|
||||
excelColumns: string[],
|
||||
columnMappings: Record<string, string | null>,
|
||||
companyCode: string,
|
||||
userId?: string
|
||||
): Promise<ExcelMappingTemplate> {
|
||||
try {
|
||||
const hash = this.generateColumnsHash(excelColumns);
|
||||
|
||||
logger.info("엑셀 매핑 템플릿 저장 (UPSERT)", {
|
||||
tableName,
|
||||
excelColumns,
|
||||
hash,
|
||||
columnMappings,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
const query = `
|
||||
INSERT INTO excel_mapping_template (
|
||||
table_name,
|
||||
excel_columns,
|
||||
excel_columns_hash,
|
||||
column_mappings,
|
||||
company_code,
|
||||
created_date,
|
||||
updated_date
|
||||
) VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
|
||||
ON CONFLICT (table_name, excel_columns_hash, company_code)
|
||||
DO UPDATE SET
|
||||
column_mappings = EXCLUDED.column_mappings,
|
||||
updated_date = NOW()
|
||||
RETURNING
|
||||
id,
|
||||
table_name as "tableName",
|
||||
excel_columns as "excelColumns",
|
||||
excel_columns_hash as "excelColumnsHash",
|
||||
column_mappings as "columnMappings",
|
||||
company_code as "companyCode",
|
||||
created_date as "createdDate",
|
||||
updated_date as "updatedDate"
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [
|
||||
tableName,
|
||||
excelColumns,
|
||||
hash,
|
||||
JSON.stringify(columnMappings),
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
logger.info("매핑 템플릿 저장 완료", {
|
||||
id: result.rows[0].id,
|
||||
tableName,
|
||||
hash,
|
||||
});
|
||||
|
||||
return result.rows[0];
|
||||
} catch (error: any) {
|
||||
logger.error(`매핑 템플릿 저장 실패: ${error.message}`, { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블의 모든 매핑 템플릿 조회
|
||||
*/
|
||||
async getMappingTemplates(
|
||||
tableName: string,
|
||||
companyCode: string
|
||||
): Promise<ExcelMappingTemplate[]> {
|
||||
try {
|
||||
logger.info("테이블 매핑 템플릿 목록 조회", { tableName, companyCode });
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
query = `
|
||||
SELECT
|
||||
id,
|
||||
table_name as "tableName",
|
||||
excel_columns as "excelColumns",
|
||||
excel_columns_hash as "excelColumnsHash",
|
||||
column_mappings as "columnMappings",
|
||||
company_code as "companyCode",
|
||||
created_date as "createdDate",
|
||||
updated_date as "updatedDate"
|
||||
FROM excel_mapping_template
|
||||
WHERE table_name = $1
|
||||
ORDER BY updated_date DESC
|
||||
`;
|
||||
params = [tableName];
|
||||
} else {
|
||||
query = `
|
||||
SELECT
|
||||
id,
|
||||
table_name as "tableName",
|
||||
excel_columns as "excelColumns",
|
||||
excel_columns_hash as "excelColumnsHash",
|
||||
column_mappings as "columnMappings",
|
||||
company_code as "companyCode",
|
||||
created_date as "createdDate",
|
||||
updated_date as "updatedDate"
|
||||
FROM excel_mapping_template
|
||||
WHERE table_name = $1
|
||||
AND (company_code = $2 OR company_code = '*')
|
||||
ORDER BY updated_date DESC
|
||||
`;
|
||||
params = [tableName, companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
logger.info(`매핑 템플릿 ${result.rows.length}개 조회`, { tableName });
|
||||
|
||||
return result.rows;
|
||||
} catch (error: any) {
|
||||
logger.error(`매핑 템플릿 목록 조회 실패: ${error.message}`, { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 매핑 템플릿 삭제
|
||||
*/
|
||||
async deleteMappingTemplate(
|
||||
id: number,
|
||||
companyCode: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
logger.info("매핑 템플릿 삭제", { id, companyCode });
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
query = `DELETE FROM excel_mapping_template WHERE id = $1`;
|
||||
params = [id];
|
||||
} else {
|
||||
query = `DELETE FROM excel_mapping_template WHERE id = $1 AND company_code = $2`;
|
||||
params = [id, companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
if (result.rowCount && result.rowCount > 0) {
|
||||
logger.info("매핑 템플릿 삭제 완료", { id });
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.warn("삭제할 매핑 템플릿 없음", { id, companyCode });
|
||||
return false;
|
||||
} catch (error: any) {
|
||||
logger.error(`매핑 템플릿 삭제 실패: ${error.message}`, { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ExcelMappingService();
|
||||
|
||||
@@ -1398,6 +1398,220 @@ class TableCategoryValueService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블의 카테고리 타입 컬럼과 해당 값 매핑 조회 (라벨 → 코드 변환용)
|
||||
*
|
||||
* 엑셀 업로드 등에서 라벨 값을 코드 값으로 변환할 때 사용
|
||||
*
|
||||
* @param tableName - 테이블명
|
||||
* @param companyCode - 회사 코드
|
||||
* @returns { [columnName]: { [label]: code } } 형태의 매핑 객체
|
||||
*/
|
||||
async getCategoryLabelToCodeMapping(
|
||||
tableName: string,
|
||||
companyCode: string
|
||||
): Promise<Record<string, Record<string, string>>> {
|
||||
try {
|
||||
logger.info("카테고리 라벨→코드 매핑 조회", { tableName, companyCode });
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
// 1. 해당 테이블의 카테고리 타입 컬럼 조회
|
||||
const categoryColumnsQuery = `
|
||||
SELECT column_name
|
||||
FROM table_type_columns
|
||||
WHERE table_name = $1
|
||||
AND input_type = 'category'
|
||||
`;
|
||||
const categoryColumnsResult = await pool.query(categoryColumnsQuery, [tableName]);
|
||||
|
||||
if (categoryColumnsResult.rows.length === 0) {
|
||||
logger.info("카테고리 타입 컬럼 없음", { tableName });
|
||||
return {};
|
||||
}
|
||||
|
||||
const categoryColumns = categoryColumnsResult.rows.map(row => row.column_name);
|
||||
logger.info(`카테고리 컬럼 ${categoryColumns.length}개 발견`, { categoryColumns });
|
||||
|
||||
// 2. 각 카테고리 컬럼의 라벨→코드 매핑 조회
|
||||
const result: Record<string, Record<string, string>> = {};
|
||||
|
||||
for (const columnName of categoryColumns) {
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 카테고리 값 조회
|
||||
query = `
|
||||
SELECT value_code, value_label
|
||||
FROM table_column_category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
AND is_active = true
|
||||
`;
|
||||
params = [tableName, columnName];
|
||||
} else {
|
||||
// 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회
|
||||
query = `
|
||||
SELECT value_code, value_label
|
||||
FROM table_column_category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
AND is_active = true
|
||||
AND (company_code = $3 OR company_code = '*')
|
||||
`;
|
||||
params = [tableName, columnName, companyCode];
|
||||
}
|
||||
|
||||
const valuesResult = await pool.query(query, params);
|
||||
|
||||
// { [label]: code } 형태로 변환
|
||||
const labelToCodeMap: Record<string, string> = {};
|
||||
for (const row of valuesResult.rows) {
|
||||
// 라벨을 소문자로 변환하여 대소문자 구분 없이 매핑
|
||||
labelToCodeMap[row.value_label] = row.value_code;
|
||||
// 소문자 키도 추가 (대소문자 무시 검색용)
|
||||
labelToCodeMap[row.value_label.toLowerCase()] = row.value_code;
|
||||
}
|
||||
|
||||
if (Object.keys(labelToCodeMap).length > 0) {
|
||||
result[columnName] = labelToCodeMap;
|
||||
logger.info(`컬럼 ${columnName}의 라벨→코드 매핑 ${valuesResult.rows.length}개 조회`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`카테고리 라벨→코드 매핑 조회 완료`, {
|
||||
tableName,
|
||||
columnCount: Object.keys(result).length
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
logger.error(`카테고리 라벨→코드 매핑 조회 실패: ${error.message}`, { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터의 카테고리 라벨 값을 코드 값으로 변환
|
||||
*
|
||||
* 엑셀 업로드 등에서 사용자가 입력한 라벨 값을 DB 저장용 코드 값으로 변환
|
||||
*
|
||||
* @param tableName - 테이블명
|
||||
* @param companyCode - 회사 코드
|
||||
* @param data - 변환할 데이터 객체
|
||||
* @returns 라벨이 코드로 변환된 데이터 객체
|
||||
*/
|
||||
async convertCategoryLabelsToCodesForData(
|
||||
tableName: string,
|
||||
companyCode: string,
|
||||
data: Record<string, any>
|
||||
): Promise<{ convertedData: Record<string, any>; conversions: Array<{ column: string; label: string; code: string }> }> {
|
||||
try {
|
||||
// 라벨→코드 매핑 조회
|
||||
const labelToCodeMapping = await this.getCategoryLabelToCodeMapping(tableName, companyCode);
|
||||
|
||||
if (Object.keys(labelToCodeMapping).length === 0) {
|
||||
// 카테고리 컬럼 없음
|
||||
return { convertedData: data, conversions: [] };
|
||||
}
|
||||
|
||||
const convertedData = { ...data };
|
||||
const conversions: Array<{ column: string; label: string; code: string }> = [];
|
||||
|
||||
for (const [columnName, labelCodeMap] of Object.entries(labelToCodeMapping)) {
|
||||
const value = data[columnName];
|
||||
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
const stringValue = String(value).trim();
|
||||
|
||||
// 다중 값 확인 (쉼표로 구분된 경우)
|
||||
if (stringValue.includes(",")) {
|
||||
// 다중 카테고리 값 처리
|
||||
const labels = stringValue.split(",").map(s => s.trim()).filter(s => s !== "");
|
||||
const convertedCodes: string[] = [];
|
||||
let allConverted = true;
|
||||
|
||||
for (const label of labels) {
|
||||
// 정확한 라벨 매칭 시도
|
||||
let matchedCode = labelCodeMap[label];
|
||||
|
||||
// 대소문자 무시 매칭
|
||||
if (!matchedCode) {
|
||||
matchedCode = labelCodeMap[label.toLowerCase()];
|
||||
}
|
||||
|
||||
if (matchedCode) {
|
||||
convertedCodes.push(matchedCode);
|
||||
conversions.push({
|
||||
column: columnName,
|
||||
label: label,
|
||||
code: matchedCode,
|
||||
});
|
||||
logger.info(`카테고리 라벨→코드 변환 (다중): ${columnName} "${label}" → "${matchedCode}"`);
|
||||
} else {
|
||||
// 이미 코드값인지 확인
|
||||
const isAlreadyCode = Object.values(labelCodeMap).includes(label);
|
||||
if (isAlreadyCode) {
|
||||
// 이미 코드값이면 그대로 사용
|
||||
convertedCodes.push(label);
|
||||
} else {
|
||||
// 라벨도 코드도 아니면 원래 값 유지
|
||||
convertedCodes.push(label);
|
||||
allConverted = false;
|
||||
logger.warn(`카테고리 값 매핑 없음 (다중): ${columnName} = "${label}" (라벨도 코드도 아님)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 변환된 코드들을 쉼표로 합쳐서 저장
|
||||
convertedData[columnName] = convertedCodes.join(",");
|
||||
logger.info(`다중 카테고리 변환 완료: ${columnName} "${stringValue}" → "${convertedData[columnName]}"`);
|
||||
} else {
|
||||
// 단일 값 처리
|
||||
// 정확한 라벨 매칭 시도
|
||||
let matchedCode = labelCodeMap[stringValue];
|
||||
|
||||
// 대소문자 무시 매칭
|
||||
if (!matchedCode) {
|
||||
matchedCode = labelCodeMap[stringValue.toLowerCase()];
|
||||
}
|
||||
|
||||
if (matchedCode) {
|
||||
// 라벨 값을 코드 값으로 변환
|
||||
convertedData[columnName] = matchedCode;
|
||||
conversions.push({
|
||||
column: columnName,
|
||||
label: stringValue,
|
||||
code: matchedCode,
|
||||
});
|
||||
logger.info(`카테고리 라벨→코드 변환: ${columnName} "${stringValue}" → "${matchedCode}"`);
|
||||
} else {
|
||||
// 이미 코드값인지 확인 (역방향 확인)
|
||||
const isAlreadyCode = Object.values(labelCodeMap).includes(stringValue);
|
||||
if (!isAlreadyCode) {
|
||||
logger.warn(`카테고리 값 매핑 없음: ${columnName} = "${stringValue}" (라벨도 코드도 아님)`);
|
||||
}
|
||||
// 변환 없이 원래 값 유지
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`카테고리 라벨→코드 변환 완료`, {
|
||||
tableName,
|
||||
conversionCount: conversions.length,
|
||||
conversions,
|
||||
});
|
||||
|
||||
return { convertedData, conversions };
|
||||
} catch (error: any) {
|
||||
logger.error(`카테고리 라벨→코드 변환 실패: ${error.message}`, { error });
|
||||
// 실패 시 원본 데이터 반환
|
||||
return { convertedData: data, conversions: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new TableCategoryValueService();
|
||||
|
||||
Reference in New Issue
Block a user