ERP-node/backend-node/src/services/entityJoinService.ts

502 lines
16 KiB
TypeScript
Raw Normal View History

2025-09-16 15:13:00 +09:00
import { PrismaClient } from "@prisma/client";
import { logger } from "../utils/logger";
import {
EntityJoinConfig,
BatchLookupRequest,
BatchLookupResponse,
} from "../types/tableManagement";
2025-09-16 16:53:03 +09:00
import { referenceCacheService } from "./referenceCacheService";
2025-09-16 15:13:00 +09:00
const prisma = new PrismaClient();
/**
* Entity
* ID값을
*/
export class EntityJoinService {
/**
* Entity
* @param tableName
* @param screenEntityConfigs ()
2025-09-16 15:13:00 +09:00
*/
async detectEntityJoins(
tableName: string,
screenEntityConfigs?: Record<string, any>
): Promise<EntityJoinConfig[]> {
2025-09-16 15:13:00 +09:00
try {
logger.info(`Entity 컬럼 감지 시작: ${tableName}`);
// column_labels에서 entity 타입인 컬럼들 조회
const entityColumns = await prisma.column_labels.findMany({
where: {
table_name: tableName,
web_type: "entity",
reference_table: { not: null },
reference_column: { not: null },
},
select: {
column_name: true,
reference_table: true,
reference_column: true,
display_column: true,
},
});
const joinConfigs: EntityJoinConfig[] = [];
for (const column of entityColumns) {
if (
!column.column_name ||
!column.reference_table ||
!column.reference_column
) {
continue;
}
// 화면별 엔티티 설정이 있으면 우선 사용, 없으면 기본값 사용
const screenConfig = screenEntityConfigs?.[column.column_name];
let displayColumns: string[] = [];
let separator = " - ";
if (screenConfig && screenConfig.displayColumns) {
2025-09-23 17:43:24 +09:00
// 화면에서 설정된 표시 컬럼들 사용 (기본 테이블 + 조인 테이블 조합 지원)
displayColumns = screenConfig.displayColumns;
separator = screenConfig.separator || " - ";
2025-09-23 17:43:24 +09:00
console.log(`🎯 화면별 엔티티 설정 적용: ${column.column_name}`, {
displayColumns,
separator,
screenConfig,
});
2025-09-24 10:33:54 +09:00
} else if (column.display_column && column.display_column !== "none") {
// 기존 설정된 단일 표시 컬럼 사용 (none이 아닌 경우만)
displayColumns = [column.display_column];
} else {
2025-09-24 10:33:54 +09:00
// 조인 탭에서 보여줄 기본 표시 컬럼 설정
// dept_info 테이블의 경우 dept_name을 기본으로 사용
let defaultDisplayColumn = column.reference_column;
if (column.reference_table === "dept_info") {
defaultDisplayColumn = "dept_name";
} else if (column.reference_table === "company_info") {
defaultDisplayColumn = "company_name";
} else if (column.reference_table === "user_info") {
defaultDisplayColumn = "user_name";
}
displayColumns = [defaultDisplayColumn];
console.log(
2025-09-24 10:33:54 +09:00
`🔧 조인 탭용 기본 표시 컬럼 설정: ${column.column_name}${defaultDisplayColumn} (${column.reference_table})`
);
}
2025-09-16 15:13:00 +09:00
// 별칭 컬럼명 생성 (writer -> writer_name)
const aliasColumn = `${column.column_name}_name`;
const joinConfig: EntityJoinConfig = {
sourceTable: tableName,
sourceColumn: column.column_name,
referenceTable: column.reference_table,
referenceColumn: column.reference_column,
displayColumns: displayColumns,
displayColumn: displayColumns[0], // 하위 호환성
2025-09-16 15:13:00 +09:00
aliasColumn: aliasColumn,
separator: separator,
2025-09-16 15:13:00 +09:00
};
// 조인 설정 유효성 검증
if (await this.validateJoinConfig(joinConfig)) {
joinConfigs.push(joinConfig);
}
}
logger.info(`Entity 조인 설정 생성 완료: ${joinConfigs.length}`);
return joinConfigs;
} catch (error) {
logger.error(`Entity 조인 감지 실패: ${tableName}`, error);
return [];
}
}
/**
* Entity SQL
*/
buildJoinQuery(
tableName: string,
joinConfigs: EntityJoinConfig[],
selectColumns: string[],
whereClause: string = "",
orderBy: string = "",
limit?: number,
offset?: number
2025-09-17 11:15:34 +09:00
): { query: string; aliasMap: Map<string, string> } {
2025-09-16 15:13:00 +09:00
try {
2025-09-24 10:33:54 +09:00
// 기본 SELECT 컬럼들 (TEXT로 캐스팅하여 record 타입 오류 방지)
const baseColumns = selectColumns
.map((col) => `main.${col}::TEXT AS ${col}`)
.join(", ");
2025-09-16 15:13:00 +09:00
// Entity 조인 컬럼들 (COALESCE로 NULL을 빈 문자열로 처리)
2025-09-17 11:15:34 +09:00
// 별칭 매핑 생성 (JOIN 절과 동일한 로직)
const aliasMap = new Map<string, string>();
const usedAliasesForColumns = new Set<string>();
// joinConfigs를 참조 테이블별로 중복 제거하여 별칭 생성
const uniqueReferenceTableConfigs = joinConfigs.reduce((acc, config) => {
if (
!acc.some(
(existingConfig) =>
existingConfig.referenceTable === config.referenceTable
)
) {
acc.push(config);
}
return acc;
}, [] as EntityJoinConfig[]);
logger.info(
`🔧 별칭 생성 시작: ${uniqueReferenceTableConfigs.length}개 고유 테이블`
);
uniqueReferenceTableConfigs.forEach((config) => {
let baseAlias = config.referenceTable.substring(0, 3);
let alias = baseAlias;
let counter = 1;
while (usedAliasesForColumns.has(alias)) {
alias = `${baseAlias}${counter}`;
counter++;
}
usedAliasesForColumns.add(alias);
aliasMap.set(config.referenceTable, alias);
logger.info(`🔧 별칭 생성: ${config.referenceTable}${alias}`);
});
2025-09-16 15:13:00 +09:00
const joinColumns = joinConfigs
.map((config) => {
const alias = aliasMap.get(config.referenceTable);
const displayColumns = config.displayColumns || [
config.displayColumn,
];
const separator = config.separator || " - ";
if (displayColumns.length === 1) {
// 단일 컬럼인 경우
2025-09-23 17:43:24 +09:00
const col = displayColumns[0];
const isJoinTableColumn = [
"dept_name",
"dept_code",
"master_user_id",
"location_name",
"parent_dept_code",
"master_sabun",
"location",
"data_type",
].includes(col);
if (isJoinTableColumn) {
2025-09-24 10:33:54 +09:00
return `COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`;
2025-09-23 17:43:24 +09:00
} else {
2025-09-24 10:33:54 +09:00
return `COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`;
2025-09-23 17:43:24 +09:00
}
} else {
// 여러 컬럼인 경우 CONCAT으로 연결
2025-09-23 17:43:24 +09:00
// 기본 테이블과 조인 테이블의 컬럼을 구분해서 처리
const concatParts = displayColumns
2025-09-23 17:43:24 +09:00
.map((col) => {
// 조인 테이블의 컬럼인지 확인 (조인 테이블에 존재하는 컬럼만 조인 별칭 사용)
// 현재는 dept_info 테이블의 컬럼들을 확인
const isJoinTableColumn = [
"dept_name",
"dept_code",
"master_user_id",
"location_name",
"parent_dept_code",
"master_sabun",
"location",
"data_type",
].includes(col);
if (isJoinTableColumn) {
// 조인 테이블 컬럼은 조인 별칭 사용
2025-09-24 10:33:54 +09:00
return `COALESCE(${alias}.${col}::TEXT, '')`;
2025-09-23 17:43:24 +09:00
} else {
// 기본 테이블 컬럼은 main 별칭 사용
2025-09-24 10:33:54 +09:00
return `COALESCE(main.${col}::TEXT, '')`;
2025-09-23 17:43:24 +09:00
}
})
2025-09-24 10:33:54 +09:00
.join(` || '${separator}' || `);
2025-09-23 17:43:24 +09:00
2025-09-24 10:33:54 +09:00
return `(${concatParts}) AS ${config.aliasColumn}`;
}
})
2025-09-16 15:13:00 +09:00
.join(", ");
// SELECT 절 구성
const selectClause = joinColumns
? `${baseColumns}, ${joinColumns}`
: baseColumns;
// FROM 절 (메인 테이블)
const fromClause = `FROM ${tableName} main`;
2025-09-17 11:15:34 +09:00
// LEFT JOIN 절들 (위에서 생성한 별칭 매핑 사용, 중복 테이블 제거)
const joinClauses = uniqueReferenceTableConfigs
.map((config) => {
const alias = aliasMap.get(config.referenceTable);
2025-09-16 15:13:00 +09:00
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
})
.join("\n");
// WHERE 절
const whereSQL = whereClause ? `WHERE ${whereClause}` : "";
// ORDER BY 절
const orderSQL = orderBy ? `ORDER BY ${orderBy}` : "";
// LIMIT 및 OFFSET
let limitSQL = "";
if (limit !== undefined) {
limitSQL = `LIMIT ${limit}`;
if (offset !== undefined) {
limitSQL += ` OFFSET ${offset}`;
}
}
// 최종 쿼리 조합
const query = [
`SELECT ${selectClause}`,
fromClause,
joinClauses,
whereSQL,
orderSQL,
limitSQL,
]
.filter(Boolean)
.join("\n");
logger.debug(`생성된 Entity 조인 쿼리:`, query);
2025-09-17 11:15:34 +09:00
return {
query: query,
aliasMap: aliasMap,
};
2025-09-16 15:13:00 +09:00
} catch (error) {
logger.error("Entity 조인 쿼리 생성 실패", error);
throw error;
}
}
2025-09-16 16:53:03 +09:00
/**
* ( )
*/
async determineJoinStrategy(
joinConfigs: EntityJoinConfig[]
): Promise<"full_join" | "cache_lookup" | "hybrid"> {
try {
const strategies = await Promise.all(
joinConfigs.map(async (config) => {
2025-09-23 17:43:24 +09:00
// 여러 컬럼을 조합하는 경우 캐시 전략 사용 불가
if (config.displayColumns && config.displayColumns.length > 1) {
console.log(
`🎯 여러 컬럼 조합으로 인해 조인 전략 사용: ${config.sourceColumn}`,
config.displayColumns
);
return "join";
}
2025-09-16 16:53:03 +09:00
// 참조 테이블의 캐시 가능성 확인
const cachedData = await referenceCacheService.getCachedReference(
config.referenceTable,
config.referenceColumn,
config.displayColumn || config.displayColumns[0]
2025-09-16 16:53:03 +09:00
);
return cachedData ? "cache" : "join";
})
);
// 모두 캐시 가능한 경우
if (strategies.every((s) => s === "cache")) {
return "cache_lookup";
}
// 혼합인 경우
if (strategies.includes("cache") && strategies.includes("join")) {
return "hybrid";
}
// 기본은 조인
return "full_join";
} catch (error) {
logger.error("조인 전략 결정 실패", error);
return "full_join"; // 안전한 기본값
}
}
2025-09-16 15:13:00 +09:00
/**
*
*/
private async validateJoinConfig(config: EntityJoinConfig): Promise<boolean> {
try {
// 참조 테이블 존재 확인
const tableExists = await prisma.$queryRaw`
SELECT 1 FROM information_schema.tables
WHERE table_name = ${config.referenceTable}
LIMIT 1
`;
if (!Array.isArray(tableExists) || tableExists.length === 0) {
logger.warn(`참조 테이블이 존재하지 않음: ${config.referenceTable}`);
return false;
}
2025-09-24 10:33:54 +09:00
// 참조 컬럼 존재 확인 (displayColumns[0] 사용)
const displayColumn = config.displayColumns?.[0] || config.displayColumn;
if (!displayColumn) {
logger.warn(`표시 컬럼이 설정되지 않음: ${config.sourceColumn}`);
return false;
}
2025-09-16 15:13:00 +09:00
const columnExists = await prisma.$queryRaw`
SELECT 1 FROM information_schema.columns
WHERE table_name = ${config.referenceTable}
2025-09-24 10:33:54 +09:00
AND column_name = ${displayColumn}
2025-09-16 15:13:00 +09:00
LIMIT 1
`;
if (!Array.isArray(columnExists) || columnExists.length === 0) {
logger.warn(
2025-09-24 10:33:54 +09:00
`표시 컬럼이 존재하지 않음: ${config.referenceTable}.${displayColumn}`
2025-09-16 15:13:00 +09:00
);
return false;
}
return true;
} catch (error) {
logger.error("조인 설정 검증 실패", error);
return false;
}
}
/**
* ()
*/
buildCountQuery(
tableName: string,
joinConfigs: EntityJoinConfig[],
whereClause: string = ""
): string {
try {
2025-09-17 11:15:34 +09:00
// 별칭 매핑 생성 (buildJoinQuery와 동일한 로직)
const aliasMap = new Map<string, string>();
const usedAliases = new Set<string>();
// joinConfigs를 참조 테이블별로 중복 제거하여 별칭 생성
const uniqueReferenceTableConfigs = joinConfigs.reduce((acc, config) => {
if (
!acc.some(
(existingConfig) =>
existingConfig.referenceTable === config.referenceTable
)
) {
acc.push(config);
}
return acc;
}, [] as EntityJoinConfig[]);
uniqueReferenceTableConfigs.forEach((config) => {
let baseAlias = config.referenceTable.substring(0, 3);
let alias = baseAlias;
let counter = 1;
while (usedAliases.has(alias)) {
alias = `${baseAlias}${counter}`;
counter++;
}
usedAliases.add(alias);
aliasMap.set(config.referenceTable, alias);
});
2025-09-16 15:13:00 +09:00
// JOIN 절들 (COUNT에서는 SELECT 컬럼 불필요)
2025-09-17 11:15:34 +09:00
const joinClauses = uniqueReferenceTableConfigs
.map((config) => {
const alias = aliasMap.get(config.referenceTable);
2025-09-16 15:13:00 +09:00
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
})
.join("\n");
// WHERE 절
const whereSQL = whereClause ? `WHERE ${whereClause}` : "";
// COUNT 쿼리 조합
const query = [
`SELECT COUNT(*) as total`,
`FROM ${tableName} main`,
joinClauses,
whereSQL,
]
.filter(Boolean)
.join("\n");
return query;
} catch (error) {
logger.error("COUNT 쿼리 생성 실패", error);
throw error;
}
}
/**
* (UI용)
*/
async getReferenceTableColumns(tableName: string): Promise<
Array<{
columnName: string;
displayName: string;
dataType: string;
}>
> {
try {
// 1. 테이블의 기본 컬럼 정보 조회
2025-09-16 15:13:00 +09:00
const columns = (await prisma.$queryRaw`
SELECT
column_name,
data_type
FROM information_schema.columns
WHERE table_name = ${tableName}
AND data_type IN ('character varying', 'varchar', 'text', 'char')
ORDER BY ordinal_position
`) as Array<{
column_name: string;
data_type: string;
}>;
// 2. column_labels 테이블에서 라벨 정보 조회
const columnLabels = await prisma.column_labels.findMany({
where: { table_name: tableName },
select: {
column_name: true,
column_label: true,
},
});
// 3. 라벨 정보를 맵으로 변환
const labelMap = new Map<string, string>();
columnLabels.forEach((label) => {
if (label.column_name && label.column_label) {
labelMap.set(label.column_name, label.column_label);
}
});
// 4. 컬럼 정보와 라벨 정보 결합
2025-09-16 15:13:00 +09:00
return columns.map((col) => ({
columnName: col.column_name,
displayName: labelMap.get(col.column_name) || col.column_name, // 라벨이 있으면 사용, 없으면 컬럼명
2025-09-16 15:13:00 +09:00
dataType: col.data_type,
}));
} catch (error) {
logger.error(`참조 테이블 컬럼 조회 실패: ${tableName}`, error);
return [];
}
}
}
export const entityJoinService = new EntityJoinService();