4813 lines
153 KiB
TypeScript
4813 lines
153 KiB
TypeScript
import { query, queryOne, transaction } from "../database/db";
|
|
import { logger } from "../utils/logger";
|
|
import { cache, CacheKeys } from "../utils/cache";
|
|
import {
|
|
TableInfo,
|
|
ColumnTypeInfo,
|
|
ColumnSettings,
|
|
TableLabels,
|
|
ColumnLabels,
|
|
EntityJoinResponse,
|
|
EntityJoinConfig,
|
|
} from "../types/tableManagement";
|
|
import { WebType } from "../types/unified-web-types";
|
|
import { entityJoinService } from "./entityJoinService";
|
|
import { referenceCacheService } from "./referenceCacheService";
|
|
|
|
// 🔧 Prisma 클라이언트 중복 생성 방지 - 기존 인스턴스 재사용
|
|
|
|
export class TableManagementService {
|
|
constructor() {}
|
|
|
|
/**
|
|
* 컬럼이 코드 타입인지 확인하고 코드 카테고리 반환
|
|
*/
|
|
private async getCodeTypeInfo(
|
|
tableName: string,
|
|
columnName: string
|
|
): Promise<{ isCodeType: boolean; codeCategory?: string }> {
|
|
try {
|
|
// column_labels 테이블에서 해당 컬럼의 input_type이 'code'인지 확인
|
|
const result = await query(
|
|
`SELECT input_type, code_category
|
|
FROM column_labels
|
|
WHERE table_name = $1
|
|
AND column_name = $2
|
|
AND input_type = 'code'`,
|
|
[tableName, columnName]
|
|
);
|
|
|
|
if (Array.isArray(result) && result.length > 0) {
|
|
const row = result[0] as any;
|
|
return {
|
|
isCodeType: true,
|
|
codeCategory: row.code_category,
|
|
};
|
|
}
|
|
|
|
return { isCodeType: false };
|
|
} catch (error) {
|
|
logger.warn(
|
|
`코드 타입 컬럼 확인 중 오류: ${tableName}.${columnName}`,
|
|
error
|
|
);
|
|
return { isCodeType: false };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 테이블 목록 조회 (PostgreSQL information_schema 활용)
|
|
* 메타데이터 조회는 Prisma로 변경 불가
|
|
*/
|
|
async getTableList(): Promise<TableInfo[]> {
|
|
try {
|
|
logger.info("테이블 목록 조회 시작");
|
|
|
|
// 캐시에서 먼저 확인
|
|
const cachedTables = cache.get<TableInfo[]>(CacheKeys.TABLE_LIST);
|
|
if (cachedTables) {
|
|
logger.info(`테이블 목록 캐시에서 조회: ${cachedTables.length}개`);
|
|
return cachedTables;
|
|
}
|
|
|
|
// information_schema는 여전히 $queryRaw 사용
|
|
const rawTables = await query<any>(
|
|
`SELECT
|
|
t.table_name as "tableName",
|
|
COALESCE(tl.table_label, t.table_name) as "displayName",
|
|
COALESCE(tl.description, '') as "description",
|
|
(SELECT COUNT(*) FROM information_schema.columns
|
|
WHERE table_name = t.table_name AND table_schema = 'public') as "columnCount"
|
|
FROM information_schema.tables t
|
|
LEFT JOIN table_labels tl ON t.table_name = tl.table_name
|
|
WHERE t.table_schema = 'public'
|
|
AND t.table_type = 'BASE TABLE'
|
|
AND t.table_name NOT LIKE 'pg_%'
|
|
AND t.table_name NOT LIKE 'sql_%'
|
|
ORDER BY t.table_name`
|
|
);
|
|
|
|
// BigInt를 Number로 변환하여 JSON 직렬화 문제 해결
|
|
const tables: TableInfo[] = rawTables.map((table) => ({
|
|
...table,
|
|
columnCount: Number(table.columnCount), // BigInt → Number 변환
|
|
}));
|
|
|
|
// 캐시에 저장 (10분 TTL)
|
|
cache.set(CacheKeys.TABLE_LIST, tables, 10 * 60 * 1000);
|
|
|
|
logger.info(`테이블 목록 조회 완료: ${tables.length}개`);
|
|
return tables;
|
|
} catch (error) {
|
|
logger.error("테이블 목록 조회 중 오류 발생:", error);
|
|
throw new Error(
|
|
`테이블 목록 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 테이블 컬럼 정보 조회 (페이지네이션 지원)
|
|
* 메타데이터 조회는 Prisma로 변경 불가
|
|
*/
|
|
async getColumnList(
|
|
tableName: string,
|
|
page: number = 1,
|
|
size: number = 50,
|
|
companyCode?: string // 🔥 회사 코드 추가
|
|
): Promise<{
|
|
columns: ColumnTypeInfo[];
|
|
total: number;
|
|
page: number;
|
|
size: number;
|
|
totalPages: number;
|
|
}> {
|
|
try {
|
|
logger.info(
|
|
`컬럼 정보 조회 시작: ${tableName} (page: ${page}, size: ${size}), company: ${companyCode}`
|
|
);
|
|
|
|
// 캐시 키 생성 (companyCode 포함)
|
|
const cacheKey =
|
|
CacheKeys.TABLE_COLUMNS(tableName, page, size) + `_${companyCode}`;
|
|
const countCacheKey = CacheKeys.TABLE_COLUMN_COUNT(tableName);
|
|
|
|
// 캐시에서 먼저 확인
|
|
const cachedResult = cache.get<{
|
|
columns: ColumnTypeInfo[];
|
|
total: number;
|
|
page: number;
|
|
size: number;
|
|
totalPages: number;
|
|
}>(cacheKey);
|
|
if (cachedResult) {
|
|
logger.info(
|
|
`컬럼 정보 캐시에서 조회: ${tableName}, ${cachedResult.columns.length}/${cachedResult.total}개`
|
|
);
|
|
|
|
// 디버깅: 캐시된 currency_code 확인
|
|
const cachedCurrency = cachedResult.columns.find(
|
|
(col: any) => col.columnName === "currency_code"
|
|
);
|
|
if (cachedCurrency) {
|
|
console.log(`💾 [캐시] currency_code:`, {
|
|
columnName: cachedCurrency.columnName,
|
|
inputType: cachedCurrency.inputType,
|
|
webType: cachedCurrency.webType,
|
|
});
|
|
}
|
|
|
|
return cachedResult;
|
|
}
|
|
|
|
// 전체 컬럼 수 조회 (캐시 확인)
|
|
let total = cache.get<number>(countCacheKey);
|
|
if (!total) {
|
|
const totalResult = await query<{ count: bigint }>(
|
|
`SELECT COUNT(*) as count
|
|
FROM information_schema.columns c
|
|
WHERE c.table_name = $1`,
|
|
[tableName]
|
|
);
|
|
total = Number(totalResult[0].count);
|
|
// 컬럼 수는 자주 변하지 않으므로 30분 캐시
|
|
cache.set(countCacheKey, total, 30 * 60 * 1000);
|
|
}
|
|
|
|
// 페이지네이션 적용한 컬럼 조회
|
|
const offset = (page - 1) * size;
|
|
|
|
// 🔥 company_code가 있으면 table_type_columns 조인하여 회사별 inputType 가져오기
|
|
const rawColumns = companyCode
|
|
? await query<any>(
|
|
`SELECT
|
|
c.column_name as "columnName",
|
|
COALESCE(cl.column_label, c.column_name) as "displayName",
|
|
c.data_type as "dataType",
|
|
c.data_type as "dbType",
|
|
COALESCE(cl.input_type, 'text') as "webType",
|
|
COALESCE(ttc.input_type, cl.input_type, 'direct') as "inputType",
|
|
ttc.input_type as "ttc_input_type",
|
|
cl.input_type as "cl_input_type",
|
|
COALESCE(ttc.detail_settings::text, cl.detail_settings, '') as "detailSettings",
|
|
COALESCE(cl.description, '') as "description",
|
|
c.is_nullable as "isNullable",
|
|
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey",
|
|
c.column_default as "defaultValue",
|
|
c.character_maximum_length as "maxLength",
|
|
c.numeric_precision as "numericPrecision",
|
|
c.numeric_scale as "numericScale",
|
|
cl.code_category as "codeCategory",
|
|
cl.code_value as "codeValue",
|
|
cl.reference_table as "referenceTable",
|
|
cl.reference_column as "referenceColumn",
|
|
cl.display_column as "displayColumn",
|
|
cl.display_order as "displayOrder",
|
|
cl.is_visible as "isVisible",
|
|
dcl.column_label as "displayColumnLabel"
|
|
FROM information_schema.columns c
|
|
LEFT JOIN column_labels cl ON c.table_name = cl.table_name AND c.column_name = cl.column_name
|
|
LEFT JOIN table_type_columns ttc ON c.table_name = ttc.table_name AND c.column_name = ttc.column_name AND ttc.company_code = $4
|
|
LEFT JOIN column_labels dcl ON cl.reference_table = dcl.table_name AND cl.display_column = dcl.column_name
|
|
LEFT JOIN (
|
|
SELECT kcu.column_name, kcu.table_name
|
|
FROM information_schema.table_constraints tc
|
|
JOIN information_schema.key_column_usage kcu
|
|
ON tc.constraint_name = kcu.constraint_name
|
|
AND tc.table_schema = kcu.table_schema
|
|
WHERE tc.constraint_type = 'PRIMARY KEY'
|
|
AND tc.table_name = $1
|
|
) pk ON c.column_name = pk.column_name AND c.table_name = pk.table_name
|
|
WHERE c.table_name = $1
|
|
ORDER BY c.ordinal_position
|
|
LIMIT $2 OFFSET $3`,
|
|
[tableName, size, offset, companyCode]
|
|
)
|
|
: await query<any>(
|
|
`SELECT
|
|
c.column_name as "columnName",
|
|
COALESCE(cl.column_label, c.column_name) as "displayName",
|
|
c.data_type as "dataType",
|
|
c.data_type as "dbType",
|
|
COALESCE(cl.input_type, 'text') as "webType",
|
|
COALESCE(cl.input_type, 'direct') as "inputType",
|
|
COALESCE(cl.detail_settings, '') as "detailSettings",
|
|
COALESCE(cl.description, '') as "description",
|
|
c.is_nullable as "isNullable",
|
|
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey",
|
|
c.column_default as "defaultValue",
|
|
c.character_maximum_length as "maxLength",
|
|
c.numeric_precision as "numericPrecision",
|
|
c.numeric_scale as "numericScale",
|
|
cl.code_category as "codeCategory",
|
|
cl.code_value as "codeValue",
|
|
cl.reference_table as "referenceTable",
|
|
cl.reference_column as "referenceColumn",
|
|
cl.display_column as "displayColumn",
|
|
cl.display_order as "displayOrder",
|
|
cl.is_visible as "isVisible",
|
|
dcl.column_label as "displayColumnLabel"
|
|
FROM information_schema.columns c
|
|
LEFT JOIN column_labels cl ON c.table_name = cl.table_name AND c.column_name = cl.column_name
|
|
LEFT JOIN column_labels dcl ON cl.reference_table = dcl.table_name AND cl.display_column = dcl.column_name
|
|
LEFT JOIN (
|
|
SELECT kcu.column_name, kcu.table_name
|
|
FROM information_schema.table_constraints tc
|
|
JOIN information_schema.key_column_usage kcu
|
|
ON tc.constraint_name = kcu.constraint_name
|
|
AND tc.table_schema = kcu.table_schema
|
|
WHERE tc.constraint_type = 'PRIMARY KEY'
|
|
AND tc.table_name = $1
|
|
) pk ON c.column_name = pk.column_name AND c.table_name = pk.table_name
|
|
WHERE c.table_name = $1
|
|
ORDER BY c.ordinal_position
|
|
LIMIT $2 OFFSET $3`,
|
|
[tableName, size, offset]
|
|
);
|
|
|
|
// 🆕 category_column_mapping 조회
|
|
const tableExistsResult = await query<any>(
|
|
`SELECT EXISTS (
|
|
SELECT FROM information_schema.tables
|
|
WHERE table_name = 'category_column_mapping'
|
|
) as table_exists`
|
|
);
|
|
const mappingTableExists = tableExistsResult[0]?.table_exists === true;
|
|
|
|
let categoryMappings: Map<string, number[]> = new Map();
|
|
if (mappingTableExists && companyCode) {
|
|
logger.info("📥 getColumnList: 카테고리 매핑 조회 시작", {
|
|
tableName,
|
|
companyCode,
|
|
});
|
|
|
|
const mappings = await query<any>(
|
|
`SELECT
|
|
logical_column_name as "columnName",
|
|
menu_objid as "menuObjid"
|
|
FROM category_column_mapping
|
|
WHERE table_name = $1
|
|
AND company_code = $2`,
|
|
[tableName, companyCode]
|
|
);
|
|
|
|
logger.info("✅ getColumnList: 카테고리 매핑 조회 완료", {
|
|
tableName,
|
|
companyCode,
|
|
mappingCount: mappings.length,
|
|
mappings: mappings,
|
|
});
|
|
|
|
mappings.forEach((m: any) => {
|
|
if (!categoryMappings.has(m.columnName)) {
|
|
categoryMappings.set(m.columnName, []);
|
|
}
|
|
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
|
|
});
|
|
|
|
logger.info("✅ getColumnList: categoryMappings Map 생성 완료", {
|
|
size: categoryMappings.size,
|
|
entries: Array.from(categoryMappings.entries()),
|
|
});
|
|
}
|
|
|
|
// BigInt를 Number로 변환하여 JSON 직렬화 문제 해결
|
|
const columns: ColumnTypeInfo[] = rawColumns.map((column) => {
|
|
const baseColumn = {
|
|
...column,
|
|
maxLength: column.maxLength ? Number(column.maxLength) : null,
|
|
numericPrecision: column.numericPrecision
|
|
? Number(column.numericPrecision)
|
|
: null,
|
|
numericScale: column.numericScale
|
|
? Number(column.numericScale)
|
|
: null,
|
|
displayOrder: column.displayOrder
|
|
? Number(column.displayOrder)
|
|
: null,
|
|
// webType은 사용자가 명시적으로 설정한 값을 그대로 사용
|
|
// (자동 추론은 column_labels에 없는 경우에만 SQL 쿼리의 COALESCE에서 처리됨)
|
|
webType: column.webType,
|
|
};
|
|
|
|
// 카테고리 타입인 경우 categoryMenus 추가
|
|
if (
|
|
column.inputType === "category" &&
|
|
categoryMappings.has(column.columnName)
|
|
) {
|
|
const menus = categoryMappings.get(column.columnName);
|
|
logger.info(
|
|
`✅ getColumnList: 컬럼 ${column.columnName}에 카테고리 메뉴 추가`,
|
|
{ menus }
|
|
);
|
|
return {
|
|
...baseColumn,
|
|
categoryMenus: menus,
|
|
};
|
|
}
|
|
|
|
return baseColumn;
|
|
});
|
|
|
|
const totalPages = Math.ceil(total / size);
|
|
|
|
const result = {
|
|
columns,
|
|
total,
|
|
page,
|
|
size,
|
|
totalPages,
|
|
};
|
|
|
|
// 캐시에 저장 (5분 TTL)
|
|
cache.set(cacheKey, result, 5 * 60 * 1000);
|
|
|
|
logger.info(
|
|
`컬럼 정보 조회 완료: ${tableName}, ${columns.length}/${total}개 (${page}/${totalPages} 페이지)`
|
|
);
|
|
return result;
|
|
} catch (error) {
|
|
logger.error(`컬럼 정보 조회 중 오류 발생: ${tableName}`, error);
|
|
throw new Error(
|
|
`컬럼 정보 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 테이블이 table_labels에 없으면 자동 추가
|
|
* Prisma ORM으로 변경
|
|
*/
|
|
async insertTableIfNotExists(tableName: string): Promise<void> {
|
|
try {
|
|
logger.info(`테이블 라벨 자동 추가 시작: ${tableName}`);
|
|
|
|
await query(
|
|
`INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
|
|
VALUES ($1, $2, $3, NOW(), NOW())
|
|
ON CONFLICT (table_name) DO NOTHING`,
|
|
[tableName, tableName, ""]
|
|
);
|
|
|
|
logger.info(`테이블 라벨 자동 추가 완료: ${tableName}`);
|
|
} catch (error) {
|
|
logger.error(`테이블 라벨 자동 추가 중 오류 발생: ${tableName}`, error);
|
|
throw new Error(
|
|
`테이블 라벨 자동 추가 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 테이블 라벨 업데이트
|
|
*/
|
|
async updateTableLabel(
|
|
tableName: string,
|
|
displayName: string,
|
|
description?: string
|
|
): Promise<void> {
|
|
try {
|
|
logger.info(`테이블 라벨 업데이트 시작: ${tableName}`);
|
|
|
|
// table_labels 테이블에 UPSERT
|
|
await query(
|
|
`INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
|
|
VALUES ($1, $2, $3, NOW(), NOW())
|
|
ON CONFLICT (table_name)
|
|
DO UPDATE SET
|
|
table_label = EXCLUDED.table_label,
|
|
description = EXCLUDED.description,
|
|
updated_date = NOW()`,
|
|
[tableName, displayName, description || ""]
|
|
);
|
|
|
|
// 캐시 무효화
|
|
cache.delete(CacheKeys.TABLE_LIST);
|
|
|
|
logger.info(`테이블 라벨 업데이트 완료: ${tableName}`);
|
|
} catch (error) {
|
|
logger.error("테이블 라벨 업데이트 중 오류 발생:", error);
|
|
throw new Error(
|
|
`테이블 라벨 업데이트 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 컬럼 설정 업데이트 (UPSERT 방식)
|
|
* Prisma ORM으로 변경
|
|
*/
|
|
async updateColumnSettings(
|
|
tableName: string,
|
|
columnName: string,
|
|
settings: ColumnSettings,
|
|
companyCode: string // 🔥 회사 코드 추가
|
|
): Promise<void> {
|
|
try {
|
|
logger.info(
|
|
`컬럼 설정 업데이트 시작: ${tableName}.${columnName}, company: ${companyCode}`
|
|
);
|
|
|
|
// 테이블이 table_labels에 없으면 자동 추가
|
|
await this.insertTableIfNotExists(tableName);
|
|
|
|
// column_labels 업데이트 또는 생성
|
|
await query(
|
|
`INSERT INTO column_labels (
|
|
table_name, column_name, column_label, input_type, detail_settings,
|
|
code_category, code_value, reference_table, reference_column,
|
|
display_column, display_order, is_visible, created_date, updated_date
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW(), NOW())
|
|
ON CONFLICT (table_name, column_name)
|
|
DO UPDATE SET
|
|
column_label = EXCLUDED.column_label,
|
|
input_type = EXCLUDED.input_type,
|
|
detail_settings = EXCLUDED.detail_settings,
|
|
code_category = EXCLUDED.code_category,
|
|
code_value = EXCLUDED.code_value,
|
|
reference_table = EXCLUDED.reference_table,
|
|
reference_column = EXCLUDED.reference_column,
|
|
display_column = EXCLUDED.display_column,
|
|
display_order = EXCLUDED.display_order,
|
|
is_visible = EXCLUDED.is_visible,
|
|
updated_date = NOW()`,
|
|
[
|
|
tableName,
|
|
columnName,
|
|
settings.columnLabel,
|
|
settings.inputType,
|
|
settings.detailSettings,
|
|
settings.codeCategory,
|
|
settings.codeValue,
|
|
settings.referenceTable,
|
|
settings.referenceColumn,
|
|
settings.displayColumn,
|
|
settings.displayOrder || 0,
|
|
settings.isVisible !== undefined ? settings.isVisible : true,
|
|
]
|
|
);
|
|
|
|
// 🔥 table_type_columns도 업데이트 (멀티테넌시 지원)
|
|
if (settings.inputType) {
|
|
// detailSettings가 문자열이면 파싱, 객체면 그대로 사용
|
|
let parsedDetailSettings: Record<string, any> | undefined = undefined;
|
|
if (settings.detailSettings) {
|
|
if (typeof settings.detailSettings === "string") {
|
|
try {
|
|
parsedDetailSettings = JSON.parse(settings.detailSettings);
|
|
} catch (e) {
|
|
logger.warn(
|
|
`detailSettings 파싱 실패, 그대로 사용: ${settings.detailSettings}`
|
|
);
|
|
}
|
|
} else if (typeof settings.detailSettings === "object") {
|
|
parsedDetailSettings = settings.detailSettings as Record<
|
|
string,
|
|
any
|
|
>;
|
|
}
|
|
}
|
|
|
|
await this.updateColumnInputType(
|
|
tableName,
|
|
columnName,
|
|
settings.inputType as string,
|
|
companyCode,
|
|
parsedDetailSettings
|
|
);
|
|
}
|
|
|
|
// 캐시 무효화 - 해당 테이블의 컬럼 캐시 삭제
|
|
cache.deleteByPattern(`table_columns:${tableName}:`);
|
|
cache.delete(CacheKeys.TABLE_COLUMN_COUNT(tableName));
|
|
|
|
logger.info(`컬럼 설정 업데이트 완료: ${tableName}.${columnName}`);
|
|
} catch (error) {
|
|
logger.error(
|
|
`컬럼 설정 업데이트 중 오류 발생: ${tableName}.${columnName}`,
|
|
error
|
|
);
|
|
throw new Error(
|
|
`컬럼 설정 업데이트 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 전체 컬럼 설정 일괄 업데이트
|
|
* Prisma 트랜잭션으로 변경
|
|
*/
|
|
async updateAllColumnSettings(
|
|
tableName: string,
|
|
columnSettings: ColumnSettings[],
|
|
companyCode: string // 🔥 회사 코드 추가
|
|
): Promise<void> {
|
|
try {
|
|
logger.info(
|
|
`전체 컬럼 설정 일괄 업데이트 시작: ${tableName}, ${columnSettings.length}개, company: ${companyCode}`
|
|
);
|
|
|
|
// Raw Query 트랜잭션 사용
|
|
await transaction(async (client) => {
|
|
// 테이블이 table_labels에 없으면 자동 추가
|
|
await this.insertTableIfNotExists(tableName);
|
|
|
|
// 각 컬럼 설정을 순차적으로 업데이트
|
|
for (const columnSetting of columnSettings) {
|
|
// columnName은 실제 DB 컬럼명을 유지해야 함
|
|
const columnName = columnSetting.columnName;
|
|
if (columnName) {
|
|
await this.updateColumnSettings(
|
|
tableName,
|
|
columnName,
|
|
columnSetting,
|
|
companyCode // 🔥 회사 코드 전달
|
|
);
|
|
} else {
|
|
logger.warn(
|
|
`컬럼명이 누락된 설정: ${JSON.stringify(columnSetting)}`
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
// 캐시 무효화 - 해당 테이블의 컬럼 캐시 삭제
|
|
cache.deleteByPattern(`table_columns:${tableName}:`);
|
|
cache.delete(CacheKeys.TABLE_COLUMN_COUNT(tableName));
|
|
|
|
logger.info(`전체 컬럼 설정 일괄 업데이트 완료: ${tableName}`);
|
|
} catch (error) {
|
|
logger.error(
|
|
`전체 컬럼 설정 일괄 업데이트 중 오류 발생: ${tableName}`,
|
|
error
|
|
);
|
|
throw new Error(
|
|
`전체 컬럼 설정 일괄 업데이트 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 테이블 라벨 정보 조회
|
|
* Prisma ORM으로 변경
|
|
*/
|
|
async getTableLabels(tableName: string): Promise<TableLabels | null> {
|
|
try {
|
|
logger.info(`테이블 라벨 정보 조회 시작: ${tableName}`);
|
|
|
|
const tableLabel = await queryOne<{
|
|
table_name: string;
|
|
table_label: string | null;
|
|
description: string | null;
|
|
created_date: Date | null;
|
|
updated_date: Date | null;
|
|
}>(
|
|
`SELECT table_name, table_label, description, created_date, updated_date
|
|
FROM table_labels
|
|
WHERE table_name = $1`,
|
|
[tableName]
|
|
);
|
|
|
|
if (!tableLabel) {
|
|
return null;
|
|
}
|
|
|
|
const result: TableLabels = {
|
|
tableName: tableLabel.table_name,
|
|
tableLabel: tableLabel.table_label || undefined,
|
|
description: tableLabel.description || undefined,
|
|
createdDate: tableLabel.created_date || undefined,
|
|
updatedDate: tableLabel.updated_date || undefined,
|
|
};
|
|
|
|
logger.info(`테이블 라벨 정보 조회 완료: ${tableName}`);
|
|
return result;
|
|
} catch (error) {
|
|
logger.error(`테이블 라벨 정보 조회 중 오류 발생: ${tableName}`, error);
|
|
throw new Error(
|
|
`테이블 라벨 정보 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 컬럼 라벨 정보 조회
|
|
* Prisma ORM으로 변경
|
|
*/
|
|
async getColumnLabels(
|
|
tableName: string,
|
|
columnName: string
|
|
): Promise<ColumnLabels | null> {
|
|
try {
|
|
logger.info(`컬럼 라벨 정보 조회 시작: ${tableName}.${columnName}`);
|
|
|
|
const columnLabel = await queryOne<{
|
|
id: number;
|
|
table_name: string;
|
|
column_name: string;
|
|
column_label: string | null;
|
|
input_type: string | null;
|
|
detail_settings: any;
|
|
description: string | null;
|
|
display_order: number | null;
|
|
is_visible: boolean | null;
|
|
code_category: string | null;
|
|
code_value: string | null;
|
|
reference_table: string | null;
|
|
reference_column: string | null;
|
|
created_date: Date | null;
|
|
updated_date: Date | null;
|
|
}>(
|
|
`SELECT id, table_name, column_name, column_label, input_type, detail_settings,
|
|
description, display_order, is_visible, code_category, code_value,
|
|
reference_table, reference_column, created_date, updated_date
|
|
FROM column_labels
|
|
WHERE table_name = $1 AND column_name = $2`,
|
|
[tableName, columnName]
|
|
);
|
|
|
|
if (!columnLabel) {
|
|
return null;
|
|
}
|
|
|
|
const result: ColumnLabels = {
|
|
id: columnLabel.id,
|
|
tableName: columnLabel.table_name || "",
|
|
columnName: columnLabel.column_name || "",
|
|
columnLabel: columnLabel.column_label || undefined,
|
|
webType: columnLabel.input_type || undefined,
|
|
detailSettings: columnLabel.detail_settings || undefined,
|
|
description: columnLabel.description || undefined,
|
|
displayOrder: columnLabel.display_order || undefined,
|
|
isVisible: columnLabel.is_visible || undefined,
|
|
codeCategory: columnLabel.code_category || undefined,
|
|
codeValue: columnLabel.code_value || undefined,
|
|
referenceTable: columnLabel.reference_table || undefined,
|
|
referenceColumn: columnLabel.reference_column || undefined,
|
|
createdDate: columnLabel.created_date || undefined,
|
|
updatedDate: columnLabel.updated_date || undefined,
|
|
};
|
|
|
|
logger.info(`컬럼 라벨 정보 조회 완료: ${tableName}.${columnName}`);
|
|
return result;
|
|
} catch (error) {
|
|
logger.error(
|
|
`컬럼 라벨 정보 조회 중 오류 발생: ${tableName}.${columnName}`,
|
|
error
|
|
);
|
|
throw new Error(
|
|
`컬럼 라벨 정보 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 컬럼 입력 타입 설정 (web_type → input_type 통합)
|
|
*/
|
|
async updateColumnWebType(
|
|
tableName: string,
|
|
columnName: string,
|
|
webType: string,
|
|
detailSettings?: Record<string, any>,
|
|
inputType?: string
|
|
): Promise<void> {
|
|
try {
|
|
logger.info(
|
|
`컬럼 입력 타입 설정 시작: ${tableName}.${columnName} = ${webType}`
|
|
);
|
|
|
|
// 웹 타입별 기본 상세 설정 생성
|
|
const defaultDetailSettings = this.generateDefaultDetailSettings(webType);
|
|
|
|
// 사용자 정의 설정과 기본 설정 병합
|
|
const finalDetailSettings = {
|
|
...defaultDetailSettings,
|
|
...detailSettings,
|
|
};
|
|
|
|
// column_labels UPSERT로 업데이트 또는 생성 (input_type만 사용)
|
|
await query(
|
|
`INSERT INTO column_labels (
|
|
table_name, column_name, input_type, detail_settings, created_date, updated_date
|
|
) VALUES ($1, $2, $3, $4, NOW(), NOW())
|
|
ON CONFLICT (table_name, column_name)
|
|
DO UPDATE SET
|
|
input_type = EXCLUDED.input_type,
|
|
detail_settings = EXCLUDED.detail_settings,
|
|
updated_date = NOW()`,
|
|
[tableName, columnName, webType, JSON.stringify(finalDetailSettings)]
|
|
);
|
|
logger.info(
|
|
`컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${webType}`
|
|
);
|
|
} catch (error) {
|
|
logger.error(
|
|
`컬럼 입력 타입 설정 중 오류 발생: ${tableName}.${columnName}`,
|
|
error
|
|
);
|
|
throw new Error(
|
|
`컬럼 입력 타입 설정 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 컬럼 입력 타입 설정 (새로운 시스템)
|
|
* @param companyCode - 회사 코드 (멀티테넌시)
|
|
*/
|
|
async updateColumnInputType(
|
|
tableName: string,
|
|
columnName: string,
|
|
inputType: string,
|
|
companyCode: string,
|
|
detailSettings?: Record<string, any>
|
|
): Promise<void> {
|
|
try {
|
|
logger.info(
|
|
`컬럼 입력 타입 설정 시작: ${tableName}.${columnName} = ${inputType}, company: ${companyCode}`
|
|
);
|
|
|
|
// 입력 타입별 기본 상세 설정 생성
|
|
const defaultDetailSettings =
|
|
this.generateDefaultInputTypeSettings(inputType);
|
|
|
|
// 사용자 정의 설정과 기본 설정 병합
|
|
const finalDetailSettings = {
|
|
...defaultDetailSettings,
|
|
...detailSettings,
|
|
};
|
|
|
|
// table_type_columns 테이블에서 업데이트 (company_code 추가)
|
|
await query(
|
|
`INSERT INTO table_type_columns (
|
|
table_name, column_name, input_type, detail_settings,
|
|
is_nullable, display_order, company_code, created_date, updated_date
|
|
) VALUES ($1, $2, $3, $4, 'Y', 0, $5, now(), now())
|
|
ON CONFLICT (table_name, column_name, company_code)
|
|
DO UPDATE SET
|
|
input_type = EXCLUDED.input_type,
|
|
detail_settings = EXCLUDED.detail_settings,
|
|
updated_date = now()`,
|
|
[
|
|
tableName,
|
|
columnName,
|
|
inputType,
|
|
JSON.stringify(finalDetailSettings),
|
|
companyCode,
|
|
]
|
|
);
|
|
|
|
// 🔥 해당 컬럼을 사용하는 화면 레이아웃의 widgetType도 업데이트
|
|
await this.syncScreenLayoutsInputType(
|
|
tableName,
|
|
columnName,
|
|
inputType,
|
|
companyCode
|
|
);
|
|
|
|
// 🔥 캐시 무효화: 해당 테이블의 컬럼 캐시 삭제
|
|
const cacheKeyPattern = `${CacheKeys.TABLE_COLUMNS(tableName, 1, 1000)}_${companyCode}`;
|
|
cache.delete(cacheKeyPattern);
|
|
cache.delete(CacheKeys.TABLE_COLUMN_COUNT(tableName));
|
|
|
|
logger.info(
|
|
`컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${inputType}, company: ${companyCode} (캐시 무효화 완료)`
|
|
);
|
|
} catch (error) {
|
|
logger.error(
|
|
`컬럼 입력 타입 설정 실패: ${tableName}.${columnName}`,
|
|
error
|
|
);
|
|
throw new Error(
|
|
`컬럼 입력 타입 설정 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 입력 타입에 해당하는 컴포넌트 ID 반환
|
|
* (프론트엔드 webTypeMapping.ts와 동일한 매핑)
|
|
*/
|
|
private getComponentIdFromInputType(inputType: string): string {
|
|
const mapping: Record<string, string> = {
|
|
// 텍스트 입력
|
|
text: "text-input",
|
|
email: "text-input",
|
|
password: "text-input",
|
|
tel: "text-input",
|
|
// 숫자 입력
|
|
number: "number-input",
|
|
decimal: "number-input",
|
|
// 날짜/시간
|
|
date: "date-input",
|
|
datetime: "date-input",
|
|
time: "date-input",
|
|
// 텍스트 영역
|
|
textarea: "textarea-basic",
|
|
// 선택
|
|
select: "select-basic",
|
|
dropdown: "select-basic",
|
|
// 체크박스/라디오
|
|
checkbox: "checkbox-basic",
|
|
radio: "radio-basic",
|
|
boolean: "toggle-switch",
|
|
// 파일
|
|
file: "file-upload",
|
|
// 이미지
|
|
image: "image-widget",
|
|
img: "image-widget",
|
|
picture: "image-widget",
|
|
photo: "image-widget",
|
|
// 버튼
|
|
button: "button-primary",
|
|
// 기타
|
|
label: "text-display",
|
|
code: "select-basic",
|
|
entity: "select-basic",
|
|
category: "select-basic",
|
|
};
|
|
|
|
return mapping[inputType] || "text-input";
|
|
}
|
|
|
|
/**
|
|
* 컬럼 입력 타입 변경 시 해당 컬럼을 사용하는 화면 레이아웃의 widgetType 및 componentType 동기화
|
|
* @param tableName - 테이블명
|
|
* @param columnName - 컬럼명
|
|
* @param inputType - 새로운 입력 타입
|
|
* @param companyCode - 회사 코드
|
|
*/
|
|
private async syncScreenLayoutsInputType(
|
|
tableName: string,
|
|
columnName: string,
|
|
inputType: string,
|
|
companyCode: string
|
|
): Promise<void> {
|
|
try {
|
|
// 해당 컬럼을 사용하는 화면 레이아웃 조회
|
|
const affectedLayouts = await query<{
|
|
layout_id: number;
|
|
screen_id: number;
|
|
component_id: string;
|
|
component_type: string;
|
|
properties: any;
|
|
}>(
|
|
`SELECT sl.layout_id, sl.screen_id, sl.component_id, sl.component_type, sl.properties
|
|
FROM screen_layouts sl
|
|
JOIN screen_definitions sd ON sl.screen_id = sd.screen_id
|
|
WHERE sl.properties->>'tableName' = $1
|
|
AND sl.properties->>'columnName' = $2
|
|
AND (sd.company_code = $3 OR $3 = '*')`,
|
|
[tableName, columnName, companyCode]
|
|
);
|
|
|
|
if (affectedLayouts.length === 0) {
|
|
logger.info(
|
|
`화면 레이아웃 동기화: ${tableName}.${columnName}을 사용하는 화면 없음`
|
|
);
|
|
return;
|
|
}
|
|
|
|
logger.info(
|
|
`화면 레이아웃 동기화 시작: ${affectedLayouts.length}개 컴포넌트 발견`
|
|
);
|
|
|
|
// 새로운 componentType 계산
|
|
const newComponentType = this.getComponentIdFromInputType(inputType);
|
|
|
|
// 각 레이아웃의 widgetType, componentType 업데이트
|
|
for (const layout of affectedLayouts) {
|
|
const updatedProperties = {
|
|
...layout.properties,
|
|
widgetType: inputType,
|
|
inputType: inputType,
|
|
// componentConfig 내부의 type도 업데이트
|
|
componentConfig: {
|
|
...layout.properties?.componentConfig,
|
|
type: newComponentType,
|
|
inputType: inputType,
|
|
},
|
|
};
|
|
|
|
await query(
|
|
`UPDATE screen_layouts
|
|
SET properties = $1, component_type = $2
|
|
WHERE layout_id = $3`,
|
|
[
|
|
JSON.stringify(updatedProperties),
|
|
newComponentType,
|
|
layout.layout_id,
|
|
]
|
|
);
|
|
|
|
logger.info(
|
|
`화면 레이아웃 업데이트: screen_id=${layout.screen_id}, component_id=${layout.component_id}, widgetType=${inputType}, componentType=${newComponentType}`
|
|
);
|
|
}
|
|
|
|
logger.info(
|
|
`화면 레이아웃 동기화 완료: ${affectedLayouts.length}개 컴포넌트 업데이트됨`
|
|
);
|
|
} catch (error) {
|
|
// 화면 레이아웃 동기화 실패는 치명적이지 않으므로 로그만 남기고 계속 진행
|
|
logger.warn(
|
|
`화면 레이아웃 동기화 실패 (무시됨): ${tableName}.${columnName}`,
|
|
error
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 입력 타입별 기본 상세 설정 생성
|
|
*/
|
|
private generateDefaultInputTypeSettings(
|
|
inputType: string
|
|
): Record<string, any> {
|
|
switch (inputType) {
|
|
case "text":
|
|
return {
|
|
maxLength: 500,
|
|
placeholder: "텍스트를 입력하세요",
|
|
};
|
|
|
|
case "number":
|
|
return {
|
|
min: 0,
|
|
step: 1,
|
|
placeholder: "숫자를 입력하세요",
|
|
};
|
|
|
|
case "date":
|
|
return {
|
|
format: "YYYY-MM-DD",
|
|
placeholder: "날짜를 선택하세요",
|
|
};
|
|
|
|
case "code":
|
|
return {
|
|
placeholder: "코드를 선택하세요",
|
|
searchable: true,
|
|
};
|
|
|
|
case "entity":
|
|
return {
|
|
placeholder: "항목을 선택하세요",
|
|
searchable: true,
|
|
};
|
|
|
|
case "select":
|
|
return {
|
|
placeholder: "선택하세요",
|
|
searchable: false,
|
|
};
|
|
|
|
case "checkbox":
|
|
return {
|
|
defaultChecked: false,
|
|
trueValue: "Y",
|
|
falseValue: "N",
|
|
};
|
|
|
|
case "radio":
|
|
return {
|
|
inline: false,
|
|
};
|
|
|
|
default:
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 웹 타입별 기본 상세 설정 생성 (레거시 지원)
|
|
* @deprecated generateDefaultInputTypeSettings 사용 권장
|
|
*/
|
|
private generateDefaultDetailSettings(webType: string): Record<string, any> {
|
|
switch (webType) {
|
|
case "text":
|
|
return {
|
|
maxLength: 255,
|
|
pattern: null,
|
|
placeholder: null,
|
|
};
|
|
|
|
case "number":
|
|
return {
|
|
min: null,
|
|
max: null,
|
|
step: 1,
|
|
precision: 2,
|
|
};
|
|
|
|
case "date":
|
|
return {
|
|
format: "YYYY-MM-DD",
|
|
minDate: null,
|
|
maxDate: null,
|
|
};
|
|
|
|
case "code":
|
|
return {
|
|
codeCategory: null,
|
|
displayFormat: "label",
|
|
searchable: true,
|
|
multiple: false,
|
|
};
|
|
|
|
case "entity":
|
|
return {
|
|
referenceTable: null,
|
|
referenceColumn: null,
|
|
searchable: true,
|
|
multiple: false,
|
|
};
|
|
|
|
case "textarea":
|
|
return {
|
|
rows: 3,
|
|
maxLength: 1000,
|
|
placeholder: null,
|
|
};
|
|
|
|
case "select":
|
|
return {
|
|
options: [],
|
|
multiple: false,
|
|
searchable: false,
|
|
};
|
|
|
|
case "checkbox":
|
|
return {
|
|
defaultChecked: false,
|
|
label: null,
|
|
};
|
|
|
|
case "radio":
|
|
return {
|
|
options: [],
|
|
inline: false,
|
|
};
|
|
|
|
case "file":
|
|
return {
|
|
accept: "*/*",
|
|
maxSize: 10485760, // 10MB
|
|
multiple: false,
|
|
};
|
|
|
|
default:
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 파일 데이터 보강 (attach_file_info에서 파일 정보 가져오기)
|
|
*/
|
|
private async enrichFileData(
|
|
data: any[],
|
|
fileColumns: string[],
|
|
tableName: string
|
|
): Promise<any[]> {
|
|
try {
|
|
logger.info(
|
|
`파일 데이터 보강 시작: ${tableName}, ${fileColumns.join(", ")}`
|
|
);
|
|
|
|
// 각 행의 파일 정보를 보강
|
|
const enrichedData = await Promise.all(
|
|
data.map(async (row) => {
|
|
const enrichedRow = { ...row };
|
|
|
|
// 각 파일 컬럼에 대해 처리
|
|
for (const fileColumn of fileColumns) {
|
|
const filePath = row[fileColumn];
|
|
if (filePath && typeof filePath === "string") {
|
|
// 🎯 컴포넌트별 파일 정보 조회
|
|
// 파일 경로에서 컴포넌트 ID 추출하거나 컬럼명 사용
|
|
const componentId =
|
|
this.extractComponentIdFromPath(filePath) || fileColumn;
|
|
const fileInfos = await this.getFileInfoByColumnAndTarget(
|
|
componentId,
|
|
row.id || row.objid || row.seq, // 기본키 값
|
|
tableName
|
|
);
|
|
|
|
if (fileInfos && fileInfos.length > 0) {
|
|
// 파일 정보를 JSON 형태로 저장
|
|
const totalSize = fileInfos.reduce(
|
|
(sum, file) => sum + (file.size || 0),
|
|
0
|
|
);
|
|
enrichedRow[fileColumn] = JSON.stringify({
|
|
files: fileInfos,
|
|
totalCount: fileInfos.length,
|
|
totalSize: totalSize,
|
|
});
|
|
} else {
|
|
// 파일이 없으면 빈 상태로 설정
|
|
enrichedRow[fileColumn] = JSON.stringify({
|
|
files: [],
|
|
totalCount: 0,
|
|
totalSize: 0,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return enrichedRow;
|
|
})
|
|
);
|
|
|
|
logger.info(`파일 데이터 보강 완료: ${enrichedData.length}개 행 처리`);
|
|
return enrichedData;
|
|
} catch (error) {
|
|
logger.error("파일 데이터 보강 실패:", error);
|
|
return data; // 실패 시 원본 데이터 반환
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 파일 경로에서 컴포넌트 ID 추출 (현재는 사용하지 않음)
|
|
*/
|
|
private extractComponentIdFromPath(filePath: string): string | null {
|
|
// 현재는 파일 경로에서 컴포넌트 ID를 추출할 수 없으므로 null 반환
|
|
// 추후 필요시 구현
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 컬럼별 파일 정보 조회 (컬럼명과 target_objid로 구분)
|
|
*/
|
|
private async getFileInfoByColumnAndTarget(
|
|
columnName: string,
|
|
targetObjid: any,
|
|
tableName: string
|
|
): Promise<any[]> {
|
|
try {
|
|
logger.info(
|
|
`컬럼별 파일 정보 조회: ${tableName}.${columnName}, target: ${targetObjid}`
|
|
);
|
|
|
|
// 🎯 컬럼명을 doc_type으로 사용하여 파일 구분
|
|
const fileInfos = await query<{
|
|
objid: string;
|
|
real_file_name: string;
|
|
file_size: number;
|
|
file_ext: string;
|
|
file_path: string;
|
|
doc_type: string;
|
|
doc_type_name: string;
|
|
regdate: Date;
|
|
writer: string;
|
|
}>(
|
|
`SELECT objid, real_file_name, file_size, file_ext, file_path,
|
|
doc_type, doc_type_name, regdate, writer
|
|
FROM attach_file_info
|
|
WHERE target_objid = $1 AND doc_type = $2 AND status = 'ACTIVE'
|
|
ORDER BY regdate DESC`,
|
|
[String(targetObjid), columnName]
|
|
);
|
|
|
|
// 파일 정보 포맷팅
|
|
return fileInfos.map((fileInfo) => ({
|
|
name: fileInfo.real_file_name,
|
|
size: Number(fileInfo.file_size) || 0,
|
|
path: fileInfo.file_path,
|
|
ext: fileInfo.file_ext,
|
|
objid: String(fileInfo.objid),
|
|
docType: fileInfo.doc_type,
|
|
docTypeName: fileInfo.doc_type_name,
|
|
regdate: fileInfo.regdate?.toISOString(),
|
|
writer: fileInfo.writer,
|
|
}));
|
|
} catch (error) {
|
|
logger.warn(`컬럼별 파일 정보 조회 실패: ${columnName}`, error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 파일 경로로 파일 정보 조회 (기존 메서드 - 호환성 유지)
|
|
*/
|
|
private async getFileInfoByPath(filePath: string): Promise<any | null> {
|
|
try {
|
|
const fileInfo = await queryOne<{
|
|
objid: string;
|
|
real_file_name: string;
|
|
file_size: number;
|
|
file_ext: string;
|
|
file_path: string;
|
|
doc_type: string;
|
|
doc_type_name: string;
|
|
regdate: Date;
|
|
writer: string;
|
|
}>(
|
|
`SELECT objid, real_file_name, file_size, file_ext, file_path,
|
|
doc_type, doc_type_name, regdate, writer
|
|
FROM attach_file_info
|
|
WHERE file_path = $1 AND status = 'ACTIVE'
|
|
LIMIT 1`,
|
|
[filePath]
|
|
);
|
|
|
|
if (!fileInfo) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
name: fileInfo.real_file_name,
|
|
path: fileInfo.file_path,
|
|
size: Number(fileInfo.file_size) || 0,
|
|
type: fileInfo.file_ext,
|
|
objid: fileInfo.objid.toString(),
|
|
docType: fileInfo.doc_type,
|
|
docTypeName: fileInfo.doc_type_name,
|
|
regdate: fileInfo.regdate?.toISOString(),
|
|
writer: fileInfo.writer,
|
|
};
|
|
} catch (error) {
|
|
logger.warn(`파일 정보 조회 실패: ${filePath}`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 파일 타입 컬럼 조회
|
|
*/
|
|
private async getFileTypeColumns(tableName: string): Promise<string[]> {
|
|
try {
|
|
const fileColumns = await query<{ column_name: string }>(
|
|
`SELECT column_name
|
|
FROM column_labels
|
|
WHERE table_name = $1 AND web_type = 'file'`,
|
|
[tableName]
|
|
);
|
|
|
|
const columnNames = fileColumns.map((col) => col.column_name);
|
|
logger.info(`파일 타입 컬럼 감지: ${tableName}`, columnNames);
|
|
return columnNames;
|
|
} catch (error) {
|
|
logger.warn(`파일 타입 컬럼 조회 실패: ${tableName}`, error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 고급 검색 조건 구성
|
|
*/
|
|
private async buildAdvancedSearchCondition(
|
|
tableName: string,
|
|
columnName: string,
|
|
value: any,
|
|
paramIndex: number
|
|
): Promise<{
|
|
whereClause: string;
|
|
values: any[];
|
|
paramCount: number;
|
|
} | null> {
|
|
try {
|
|
// 🆕 배열 값 처리 (다중 값 검색 - 분할패널 엔티티 타입에서 "2,3" 형태 지원)
|
|
// 좌측에서 "2"를 선택해도, 우측에서 "2,3"을 가진 행이 표시되도록 함
|
|
if (Array.isArray(value) && value.length > 0) {
|
|
// 배열의 각 값에 대해 OR 조건으로 검색
|
|
// 우측 컬럼에 "2,3" 같은 다중 값이 있을 수 있으므로
|
|
// 각 값을 LIKE 또는 = 조건으로 처리
|
|
const conditions: string[] = [];
|
|
const values: any[] = [];
|
|
|
|
value.forEach((v: any, idx: number) => {
|
|
const safeValue = String(v).trim();
|
|
// 정확히 일치하거나, 콤마로 구분된 값 중 하나로 포함
|
|
// 예: "2,3" 컬럼에서 "2"를 찾으려면:
|
|
// - 정확히 "2"
|
|
// - "2," 로 시작
|
|
// - ",2" 로 끝남
|
|
// - ",2," 중간에 포함
|
|
const paramBase = paramIndex + (idx * 4);
|
|
conditions.push(`(
|
|
${columnName}::text = $${paramBase} OR
|
|
${columnName}::text LIKE $${paramBase + 1} OR
|
|
${columnName}::text LIKE $${paramBase + 2} OR
|
|
${columnName}::text LIKE $${paramBase + 3}
|
|
)`);
|
|
values.push(safeValue, `${safeValue},%`, `%,${safeValue}`, `%,${safeValue},%`);
|
|
});
|
|
|
|
logger.info(`🔍 다중 값 배열 검색: ${columnName} IN [${value.join(", ")}]`);
|
|
return {
|
|
whereClause: `(${conditions.join(" OR ")})`,
|
|
values,
|
|
paramCount: values.length,
|
|
};
|
|
}
|
|
|
|
// 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위)
|
|
if (typeof value === "string" && value.includes("|")) {
|
|
const columnInfo = await this.getColumnWebTypeInfo(
|
|
tableName,
|
|
columnName
|
|
);
|
|
|
|
// 날짜 타입이면 날짜 범위로 처리
|
|
if (
|
|
columnInfo &&
|
|
(columnInfo.webType === "date" || columnInfo.webType === "datetime")
|
|
) {
|
|
return this.buildDateRangeCondition(columnName, value, paramIndex);
|
|
}
|
|
|
|
// 그 외 타입이면 다중선택(IN 조건)으로 처리
|
|
const multiValues = value
|
|
.split("|")
|
|
.filter((v: string) => v.trim() !== "");
|
|
if (multiValues.length > 0) {
|
|
const placeholders = multiValues
|
|
.map((_: string, idx: number) => `$${paramIndex + idx}`)
|
|
.join(", ");
|
|
logger.info(
|
|
`🔍 다중선택 필터 적용: ${columnName} IN (${multiValues.join(", ")})`
|
|
);
|
|
return {
|
|
whereClause: `${columnName}::text IN (${placeholders})`,
|
|
values: multiValues,
|
|
paramCount: multiValues.length,
|
|
};
|
|
}
|
|
}
|
|
|
|
// 🔧 날짜 범위 객체 {from, to} 체크
|
|
if (
|
|
typeof value === "object" &&
|
|
value !== null &&
|
|
("from" in value || "to" in value)
|
|
) {
|
|
// 날짜 범위 객체는 그대로 전달
|
|
const columnInfo = await this.getColumnWebTypeInfo(
|
|
tableName,
|
|
columnName
|
|
);
|
|
if (
|
|
columnInfo &&
|
|
(columnInfo.webType === "date" || columnInfo.webType === "datetime")
|
|
) {
|
|
return this.buildDateRangeCondition(columnName, value, paramIndex);
|
|
}
|
|
}
|
|
|
|
// 🔧 {value, operator} 형태의 필터 객체 처리
|
|
let actualValue = value;
|
|
let operator = "contains"; // 기본값
|
|
|
|
if (typeof value === "object" && value !== null && "value" in value) {
|
|
actualValue = value.value;
|
|
operator = value.operator || "contains";
|
|
|
|
logger.info("🔍 필터 객체 처리:", {
|
|
columnName,
|
|
originalValue: value,
|
|
actualValue,
|
|
operator,
|
|
});
|
|
}
|
|
|
|
// "__ALL__" 값이거나 빈 값이면 필터 조건을 적용하지 않음
|
|
if (
|
|
actualValue === "__ALL__" ||
|
|
actualValue === "" ||
|
|
actualValue === null ||
|
|
actualValue === undefined
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
// 컬럼 타입 정보 조회
|
|
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
|
|
logger.info(
|
|
`🔍 [buildAdvancedSearchCondition] ${tableName}.${columnName}`,
|
|
`webType=${columnInfo?.webType || "NULL"}`,
|
|
`inputType=${columnInfo?.inputType || "NULL"}`,
|
|
`actualValue=${JSON.stringify(actualValue)}`,
|
|
`operator=${operator}`
|
|
);
|
|
|
|
if (!columnInfo) {
|
|
// 컬럼 정보가 없으면 operator에 따른 기본 검색
|
|
switch (operator) {
|
|
case "equals":
|
|
return {
|
|
whereClause: `${columnName}::text = $${paramIndex}`,
|
|
values: [actualValue],
|
|
paramCount: 1,
|
|
};
|
|
case "contains":
|
|
default:
|
|
return {
|
|
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
|
values: [`%${actualValue}%`],
|
|
paramCount: 1,
|
|
};
|
|
}
|
|
}
|
|
|
|
const webType = columnInfo.webType;
|
|
|
|
// 웹타입별 검색 조건 구성
|
|
switch (webType) {
|
|
case "date":
|
|
case "datetime":
|
|
return this.buildDateRangeCondition(
|
|
columnName,
|
|
actualValue,
|
|
paramIndex
|
|
);
|
|
|
|
case "number":
|
|
case "decimal":
|
|
return this.buildNumberRangeCondition(
|
|
columnName,
|
|
actualValue,
|
|
paramIndex
|
|
);
|
|
|
|
case "code":
|
|
return await this.buildCodeSearchCondition(
|
|
tableName,
|
|
columnName,
|
|
actualValue,
|
|
paramIndex
|
|
);
|
|
|
|
case "entity":
|
|
return await this.buildEntitySearchCondition(
|
|
tableName,
|
|
columnName,
|
|
actualValue,
|
|
paramIndex,
|
|
operator // operator 전달 (equals면 직접 매칭)
|
|
);
|
|
|
|
default:
|
|
// 기본 문자열 검색 (actualValue 사용)
|
|
return {
|
|
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
|
values: [`%${actualValue}%`],
|
|
paramCount: 1,
|
|
};
|
|
}
|
|
} catch (error) {
|
|
logger.error(
|
|
`고급 검색 조건 구성 실패: ${tableName}.${columnName}`,
|
|
error
|
|
);
|
|
// 오류 시 기본 검색으로 폴백
|
|
let fallbackValue = value;
|
|
if (typeof value === "object" && value !== null && "value" in value) {
|
|
fallbackValue = value.value;
|
|
}
|
|
|
|
return {
|
|
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
|
values: [`%${fallbackValue}%`],
|
|
paramCount: 1,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 날짜 범위 검색 조건 구성
|
|
*/
|
|
private buildDateRangeCondition(
|
|
columnName: string,
|
|
value: any,
|
|
paramIndex: number
|
|
): {
|
|
whereClause: string;
|
|
values: any[];
|
|
paramCount: number;
|
|
} {
|
|
const conditions: string[] = [];
|
|
const values: any[] = [];
|
|
let paramCount = 0;
|
|
|
|
// 문자열 형식의 날짜 범위 파싱 ("YYYY-MM-DD|YYYY-MM-DD")
|
|
if (typeof value === "string" && value.includes("|")) {
|
|
const [fromStr, toStr] = value.split("|");
|
|
|
|
if (fromStr && fromStr.trim() !== "") {
|
|
// VARCHAR 컬럼을 DATE로 캐스팅하여 비교
|
|
conditions.push(
|
|
`${columnName}::date >= $${paramIndex + paramCount}::date`
|
|
);
|
|
values.push(fromStr.trim());
|
|
paramCount++;
|
|
}
|
|
if (toStr && toStr.trim() !== "") {
|
|
// 종료일은 해당 날짜의 23:59:59까지 포함
|
|
conditions.push(
|
|
`${columnName}::date <= $${paramIndex + paramCount}::date`
|
|
);
|
|
values.push(toStr.trim());
|
|
paramCount++;
|
|
}
|
|
}
|
|
// 객체 형식의 날짜 범위 ({from, to})
|
|
else if (typeof value === "object" && value !== null) {
|
|
if (value.from) {
|
|
// VARCHAR 컬럼을 DATE로 캐스팅하여 비교
|
|
conditions.push(
|
|
`${columnName}::date >= $${paramIndex + paramCount}::date`
|
|
);
|
|
values.push(value.from);
|
|
paramCount++;
|
|
}
|
|
if (value.to) {
|
|
// 종료일은 해당 날짜의 23:59:59까지 포함
|
|
conditions.push(
|
|
`${columnName}::date <= $${paramIndex + paramCount}::date`
|
|
);
|
|
values.push(value.to);
|
|
paramCount++;
|
|
}
|
|
}
|
|
// 단일 날짜 검색
|
|
else if (typeof value === "string" && value.trim() !== "") {
|
|
conditions.push(`${columnName}::date = $${paramIndex}::date`);
|
|
values.push(value);
|
|
paramCount = 1;
|
|
}
|
|
|
|
if (conditions.length === 0) {
|
|
return {
|
|
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
|
values: [`%${value}%`],
|
|
paramCount: 1,
|
|
};
|
|
}
|
|
|
|
return {
|
|
whereClause: `(${conditions.join(" AND ")})`,
|
|
values,
|
|
paramCount,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 숫자 범위 검색 조건 구성
|
|
*/
|
|
private buildNumberRangeCondition(
|
|
columnName: string,
|
|
value: any,
|
|
paramIndex: number
|
|
): {
|
|
whereClause: string;
|
|
values: any[];
|
|
paramCount: number;
|
|
} {
|
|
const conditions: string[] = [];
|
|
const values: any[] = [];
|
|
let paramCount = 0;
|
|
|
|
if (typeof value === "object" && value !== null) {
|
|
if (value.min !== undefined && value.min !== null && value.min !== "") {
|
|
conditions.push(
|
|
`${columnName}::numeric >= $${paramIndex + paramCount}`
|
|
);
|
|
values.push(parseFloat(value.min));
|
|
paramCount++;
|
|
}
|
|
if (value.max !== undefined && value.max !== null && value.max !== "") {
|
|
conditions.push(
|
|
`${columnName}::numeric <= $${paramIndex + paramCount}`
|
|
);
|
|
values.push(parseFloat(value.max));
|
|
paramCount++;
|
|
}
|
|
} else if (typeof value === "string" || typeof value === "number") {
|
|
// 정확한 값 검색
|
|
conditions.push(`${columnName}::numeric = $${paramIndex}`);
|
|
values.push(parseFloat(value.toString()));
|
|
paramCount = 1;
|
|
}
|
|
|
|
if (conditions.length === 0) {
|
|
return {
|
|
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
|
values: [`%${value}%`],
|
|
paramCount: 1,
|
|
};
|
|
}
|
|
|
|
return {
|
|
whereClause: `(${conditions.join(" AND ")})`,
|
|
values,
|
|
paramCount,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 코드 검색 조건 구성
|
|
*/
|
|
private async buildCodeSearchCondition(
|
|
tableName: string,
|
|
columnName: string,
|
|
value: any,
|
|
paramIndex: number
|
|
): Promise<{
|
|
whereClause: string;
|
|
values: any[];
|
|
paramCount: number;
|
|
}> {
|
|
try {
|
|
const codeTypeInfo = await this.getCodeTypeInfo(tableName, columnName);
|
|
|
|
if (!codeTypeInfo.isCodeType || !codeTypeInfo.codeCategory) {
|
|
// 코드 타입이 아니면 기본 검색
|
|
return {
|
|
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
|
values: [`%${value}%`],
|
|
paramCount: 1,
|
|
};
|
|
}
|
|
|
|
if (typeof value === "string" && value.trim() !== "") {
|
|
// 코드값 또는 코드명으로 검색
|
|
return {
|
|
whereClause: `(
|
|
${columnName}::text = $${paramIndex} OR
|
|
EXISTS (
|
|
SELECT 1 FROM code_info ci
|
|
WHERE ci.code_category = $${paramIndex + 1}
|
|
AND ci.code_value = ${columnName}
|
|
AND ci.code_name ILIKE $${paramIndex + 2}
|
|
)
|
|
)`,
|
|
values: [value, codeTypeInfo.codeCategory, `%${value}%`],
|
|
paramCount: 3,
|
|
};
|
|
} else {
|
|
// 정확한 코드값 매칭
|
|
return {
|
|
whereClause: `${columnName} = $${paramIndex}`,
|
|
values: [value],
|
|
paramCount: 1,
|
|
};
|
|
}
|
|
} catch (error) {
|
|
logger.error(
|
|
`코드 검색 조건 구성 실패: ${tableName}.${columnName}`,
|
|
error
|
|
);
|
|
return {
|
|
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
|
values: [`%${value}%`],
|
|
paramCount: 1,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 엔티티 검색 조건 구성
|
|
*/
|
|
private async buildEntitySearchCondition(
|
|
tableName: string,
|
|
columnName: string,
|
|
value: any,
|
|
paramIndex: number,
|
|
operator: string = "contains" // 연결 필터에서 "equals"로 전달되면 직접 매칭
|
|
): Promise<{
|
|
whereClause: string;
|
|
values: any[];
|
|
paramCount: number;
|
|
}> {
|
|
try {
|
|
const entityTypeInfo = await this.getEntityTypeInfo(
|
|
tableName,
|
|
columnName
|
|
);
|
|
|
|
// 배열 처리: IN 절 사용
|
|
if (Array.isArray(value)) {
|
|
if (value.length === 0) {
|
|
// 빈 배열이면 항상 false 조건
|
|
return {
|
|
whereClause: `1 = 0`,
|
|
values: [],
|
|
paramCount: 0,
|
|
};
|
|
}
|
|
|
|
// IN 절로 여러 값 검색
|
|
const placeholders = value
|
|
.map((_, idx) => `$${paramIndex + idx}`)
|
|
.join(", ");
|
|
return {
|
|
whereClause: `${columnName} IN (${placeholders})`,
|
|
values: value,
|
|
paramCount: value.length,
|
|
};
|
|
}
|
|
|
|
if (!entityTypeInfo.isEntityType || !entityTypeInfo.referenceTable) {
|
|
// 엔티티 타입이 아니면 기본 검색
|
|
return {
|
|
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
|
values: [`%${value}%`],
|
|
paramCount: 1,
|
|
};
|
|
}
|
|
|
|
if (typeof value === "string" && value.trim() !== "") {
|
|
// equals 연산자인 경우: 직접 값 매칭 (연결 필터에서 코드 값으로 필터링 시 사용)
|
|
if (operator === "equals") {
|
|
logger.info(
|
|
`🔍 [buildEntitySearchCondition] equals 연산자 - 직접 매칭: ${columnName} = ${value}`
|
|
);
|
|
return {
|
|
whereClause: `${columnName} = $${paramIndex}`,
|
|
values: [value],
|
|
paramCount: 1,
|
|
};
|
|
}
|
|
|
|
// contains 연산자 (기본): 참조 테이블의 표시 컬럼으로 검색
|
|
const referenceColumn = entityTypeInfo.referenceColumn || "id";
|
|
const referenceTable = entityTypeInfo.referenceTable;
|
|
|
|
// displayColumn이 비어있거나 "none"이면 참조 테이블에서 자동 감지 (entityJoinService와 동일한 로직)
|
|
let displayColumn = entityTypeInfo.displayColumn;
|
|
if (!displayColumn || displayColumn === "none" || displayColumn === "") {
|
|
displayColumn = await this.findDisplayColumnForTable(referenceTable, referenceColumn);
|
|
logger.info(
|
|
`🔍 [buildEntitySearchCondition] displayColumn 자동 감지: ${referenceTable} -> ${displayColumn}`
|
|
);
|
|
}
|
|
|
|
// 참조 테이블의 표시 컬럼으로 검색
|
|
return {
|
|
whereClause: `EXISTS (
|
|
SELECT 1 FROM ${referenceTable} ref
|
|
WHERE ref.${referenceColumn} = ${columnName}
|
|
AND ref.${displayColumn} ILIKE $${paramIndex}
|
|
)`,
|
|
values: [`%${value}%`],
|
|
paramCount: 1,
|
|
};
|
|
} else {
|
|
// 정확한 참조값 매칭
|
|
return {
|
|
whereClause: `${columnName} = $${paramIndex}`,
|
|
values: [value],
|
|
paramCount: 1,
|
|
};
|
|
}
|
|
} catch (error) {
|
|
logger.error(
|
|
`엔티티 검색 조건 구성 실패: ${tableName}.${columnName}`,
|
|
error
|
|
);
|
|
return {
|
|
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
|
values: [`%${value}%`],
|
|
paramCount: 1,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 참조 테이블에서 표시 컬럼 자동 감지 (entityJoinService와 동일한 우선순위)
|
|
* 우선순위: *_name > name > label/*_label > title > referenceColumn
|
|
*/
|
|
private async findDisplayColumnForTable(
|
|
tableName: string,
|
|
referenceColumn?: string
|
|
): Promise<string> {
|
|
try {
|
|
const result = await query<{ column_name: string }>(
|
|
`SELECT column_name
|
|
FROM information_schema.columns
|
|
WHERE table_name = $1
|
|
AND table_schema = 'public'
|
|
ORDER BY ordinal_position`,
|
|
[tableName]
|
|
);
|
|
|
|
const allColumns = result.map((r) => r.column_name);
|
|
|
|
// entityJoinService와 동일한 우선순위
|
|
// 1. *_name 컬럼 (item_name, customer_name, process_name 등) - company_name 제외
|
|
const nameColumn = allColumns.find(
|
|
(col) => col.endsWith("_name") && col !== "company_name"
|
|
);
|
|
if (nameColumn) {
|
|
return nameColumn;
|
|
}
|
|
|
|
// 2. name 컬럼
|
|
if (allColumns.includes("name")) {
|
|
return "name";
|
|
}
|
|
|
|
// 3. label 또는 *_label 컬럼
|
|
const labelColumn = allColumns.find(
|
|
(col) => col === "label" || col.endsWith("_label")
|
|
);
|
|
if (labelColumn) {
|
|
return labelColumn;
|
|
}
|
|
|
|
// 4. title 컬럼
|
|
if (allColumns.includes("title")) {
|
|
return "title";
|
|
}
|
|
|
|
// 5. 참조 컬럼 (referenceColumn)
|
|
if (referenceColumn && allColumns.includes(referenceColumn)) {
|
|
return referenceColumn;
|
|
}
|
|
|
|
// 6. 기본값: 첫 번째 비-id 컬럼 또는 id
|
|
return allColumns.find((col) => col !== "id") || "id";
|
|
} catch (error) {
|
|
logger.error(`표시 컬럼 감지 실패: ${tableName}`, error);
|
|
return referenceColumn || "id"; // 오류 시 기본값
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 불린 검색 조건 구성
|
|
*/
|
|
private buildBooleanCondition(
|
|
columnName: string,
|
|
value: any,
|
|
paramIndex: number
|
|
): {
|
|
whereClause: string;
|
|
values: any[];
|
|
paramCount: number;
|
|
} {
|
|
if (value === "true" || value === true) {
|
|
return {
|
|
whereClause: `${columnName} = true`,
|
|
values: [],
|
|
paramCount: 0,
|
|
};
|
|
} else if (value === "false" || value === false) {
|
|
return {
|
|
whereClause: `${columnName} = false`,
|
|
values: [],
|
|
paramCount: 0,
|
|
};
|
|
} else {
|
|
// 기본 검색
|
|
return {
|
|
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
|
values: [`%${value}%`],
|
|
paramCount: 1,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 컬럼 웹타입 정보 조회
|
|
*/
|
|
private async getColumnWebTypeInfo(
|
|
tableName: string,
|
|
columnName: string
|
|
): Promise<{
|
|
webType: string;
|
|
inputType?: string;
|
|
codeCategory?: string;
|
|
referenceTable?: string;
|
|
referenceColumn?: string;
|
|
displayColumn?: string;
|
|
} | null> {
|
|
try {
|
|
const result = await queryOne<{
|
|
web_type: string | null;
|
|
input_type: string | null;
|
|
code_category: string | null;
|
|
reference_table: string | null;
|
|
reference_column: string | null;
|
|
display_column: string | null;
|
|
}>(
|
|
`SELECT web_type, input_type, code_category, reference_table, reference_column, display_column
|
|
FROM column_labels
|
|
WHERE table_name = $1 AND column_name = $2
|
|
LIMIT 1`,
|
|
[tableName, columnName]
|
|
);
|
|
|
|
logger.info(
|
|
`🔍 [getColumnWebTypeInfo] ${tableName}.${columnName} 조회 결과:`,
|
|
{
|
|
found: !!result,
|
|
web_type: result?.web_type,
|
|
input_type: result?.input_type,
|
|
}
|
|
);
|
|
|
|
if (!result) {
|
|
logger.warn(
|
|
`⚠️ [getColumnWebTypeInfo] 컬럼 정보 없음: ${tableName}.${columnName}`
|
|
);
|
|
return null;
|
|
}
|
|
|
|
// web_type이 없으면 input_type을 사용 (레거시 호환)
|
|
const webType = result.web_type || result.input_type || "";
|
|
|
|
const columnInfo = {
|
|
webType: webType,
|
|
inputType: result.input_type || "",
|
|
codeCategory: result.code_category || undefined,
|
|
referenceTable: result.reference_table || undefined,
|
|
referenceColumn: result.reference_column || undefined,
|
|
displayColumn: result.display_column || undefined,
|
|
};
|
|
|
|
logger.info(
|
|
`✅ [getColumnWebTypeInfo] 반환값: webType=${columnInfo.webType}, inputType=${columnInfo.inputType}`
|
|
);
|
|
return columnInfo;
|
|
} catch (error) {
|
|
logger.error(
|
|
`컬럼 웹타입 정보 조회 실패: ${tableName}.${columnName}`,
|
|
error
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 엔티티 타입 정보 조회
|
|
*/
|
|
private async getEntityTypeInfo(
|
|
tableName: string,
|
|
columnName: string
|
|
): Promise<{
|
|
isEntityType: boolean;
|
|
referenceTable?: string;
|
|
referenceColumn?: string;
|
|
displayColumn?: string;
|
|
}> {
|
|
try {
|
|
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
|
|
|
|
if (!columnInfo || columnInfo.webType !== "entity") {
|
|
return { isEntityType: false };
|
|
}
|
|
|
|
return {
|
|
isEntityType: true,
|
|
referenceTable: columnInfo.referenceTable,
|
|
referenceColumn: columnInfo.referenceColumn,
|
|
displayColumn: columnInfo.displayColumn,
|
|
};
|
|
} catch (error) {
|
|
logger.error(
|
|
`엔티티 타입 정보 조회 실패: ${tableName}.${columnName}`,
|
|
error
|
|
);
|
|
return { isEntityType: false };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 테이블 데이터 조회 (페이징 + 검색)
|
|
*/
|
|
async getTableData(
|
|
tableName: string,
|
|
options: {
|
|
page: number;
|
|
size: number;
|
|
search?: Record<string, any>;
|
|
sortBy?: string;
|
|
sortOrder?: string;
|
|
companyCode?: string;
|
|
dataFilter?: any; // 🆕 DataFilterConfig
|
|
}
|
|
): Promise<{
|
|
data: any[];
|
|
total: number;
|
|
page: number;
|
|
size: number;
|
|
totalPages: number;
|
|
}> {
|
|
try {
|
|
const {
|
|
page,
|
|
size,
|
|
search = {},
|
|
sortBy,
|
|
sortOrder = "asc",
|
|
companyCode,
|
|
dataFilter,
|
|
} = options;
|
|
const offset = (page - 1) * size;
|
|
|
|
logger.info(`테이블 데이터 조회: ${tableName}`, options);
|
|
|
|
// 🎯 파일 타입 컬럼 감지 (비활성화됨 - 자동 파일 컬럼 생성 방지)
|
|
// const fileColumns = await this.getFileTypeColumns(tableName);
|
|
const fileColumns: string[] = []; // 자동 파일 컬럼 생성 비활성화
|
|
|
|
// WHERE 조건 구성
|
|
let whereConditions: string[] = [];
|
|
let searchValues: any[] = [];
|
|
let paramIndex = 1;
|
|
|
|
// 멀티테넌시 필터 추가 (company_code)
|
|
if (companyCode) {
|
|
whereConditions.push(`company_code = $${paramIndex}`);
|
|
searchValues.push(companyCode);
|
|
paramIndex++;
|
|
logger.info(
|
|
`🔒 멀티테넌시 필터 추가 (기본 조회): company_code = ${companyCode}`
|
|
);
|
|
}
|
|
|
|
if (search && Object.keys(search).length > 0) {
|
|
for (const [column, value] of Object.entries(search)) {
|
|
if (value !== null && value !== undefined && value !== "") {
|
|
// 🎯 추가 조인 컬럼들은 실제 테이블 컬럼이 아니므로 제외
|
|
const additionalJoinColumns = [
|
|
"company_code_status",
|
|
"writer_dept_code",
|
|
];
|
|
if (additionalJoinColumns.includes(column)) {
|
|
logger.info(
|
|
`🔍 추가 조인 컬럼 ${column} 검색 조건에서 제외 (실제 테이블 컬럼 아님)`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
// 🆕 조인 테이블 컬럼 (테이블명.컬럼명)은 기본 데이터 조회에서 제외
|
|
// Entity 조인 조회에서만 처리됨
|
|
if (column.includes(".")) {
|
|
logger.info(
|
|
`🔍 조인 테이블 컬럼 ${column} 기본 조회에서 제외 (Entity 조인에서 처리)`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
// 안전한 컬럼명 검증 (SQL 인젝션 방지)
|
|
const safeColumn = column.replace(/[^a-zA-Z0-9_]/g, "");
|
|
|
|
// 🎯 고급 필터 처리
|
|
const condition = await this.buildAdvancedSearchCondition(
|
|
tableName,
|
|
safeColumn,
|
|
value,
|
|
paramIndex
|
|
);
|
|
|
|
if (condition) {
|
|
whereConditions.push(condition.whereClause);
|
|
searchValues.push(...condition.values);
|
|
paramIndex += condition.paramCount;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 🆕 데이터 필터 적용
|
|
if (
|
|
dataFilter &&
|
|
dataFilter.enabled &&
|
|
dataFilter.filters &&
|
|
dataFilter.filters.length > 0
|
|
) {
|
|
const {
|
|
buildDataFilterWhereClause,
|
|
} = require("../utils/dataFilterUtil");
|
|
const { whereClause: filterWhere, params: filterParams } =
|
|
buildDataFilterWhereClause(dataFilter, paramIndex);
|
|
|
|
if (filterWhere) {
|
|
whereConditions.push(filterWhere);
|
|
searchValues.push(...filterParams);
|
|
paramIndex += filterParams.length;
|
|
|
|
logger.info(`🔍 데이터 필터 적용: ${filterWhere}`);
|
|
logger.info(`🔍 필터 파라미터:`, filterParams);
|
|
}
|
|
}
|
|
|
|
const whereClause =
|
|
whereConditions.length > 0
|
|
? `WHERE ${whereConditions.join(" AND ")}`
|
|
: "";
|
|
|
|
// ORDER BY 조건 구성
|
|
let orderClause = "";
|
|
if (sortBy) {
|
|
const safeSortBy = sortBy.replace(/[^a-zA-Z0-9_]/g, "");
|
|
const safeSortOrder =
|
|
sortOrder.toLowerCase() === "desc" ? "DESC" : "ASC";
|
|
orderClause = `ORDER BY ${safeSortBy} ${safeSortOrder}`;
|
|
}
|
|
|
|
// 안전한 테이블명 검증
|
|
const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, "");
|
|
|
|
// 전체 개수 조회
|
|
const countQuery = `SELECT COUNT(*) as count FROM ${safeTableName} ${whereClause}`;
|
|
const countResult = await query<any>(countQuery, searchValues);
|
|
const total = parseInt(countResult[0].count);
|
|
|
|
// 데이터 조회
|
|
const dataQuery = `
|
|
SELECT * FROM ${safeTableName}
|
|
${whereClause}
|
|
${orderClause}
|
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
|
`;
|
|
|
|
logger.info(`🔍 실행할 SQL: ${dataQuery}`);
|
|
logger.info(
|
|
`🔍 파라미터: ${JSON.stringify([...searchValues, size, offset])}`
|
|
);
|
|
|
|
let data = await query<any>(dataQuery, [...searchValues, size, offset]);
|
|
|
|
// 🎯 파일 컬럼이 있으면 파일 정보 보강
|
|
if (fileColumns.length > 0) {
|
|
data = await this.enrichFileData(data, fileColumns, safeTableName);
|
|
}
|
|
|
|
const totalPages = Math.ceil(total / size);
|
|
|
|
logger.info(
|
|
`테이블 데이터 조회 완료: ${tableName}, 총 ${total}건, ${data.length}개 반환`
|
|
);
|
|
|
|
return {
|
|
data,
|
|
total,
|
|
page,
|
|
size,
|
|
totalPages,
|
|
};
|
|
} catch (error) {
|
|
logger.error(`테이블 데이터 조회 오류: ${tableName}`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 현재 사용자 정보 조회 (JWT 토큰에서)
|
|
*/
|
|
private getCurrentUserFromRequest(req?: any): {
|
|
userId: string;
|
|
userName: string;
|
|
} {
|
|
// 실제 프로젝트에서는 req 객체에서 JWT 토큰을 파싱하여 사용자 정보를 가져올 수 있습니다
|
|
// 현재는 기본값을 반환
|
|
return {
|
|
userId: "system",
|
|
userName: "시스템 사용자",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 값을 PostgreSQL 타입에 맞게 변환
|
|
*/
|
|
private convertValueForPostgreSQL(value: any, dataType: string): any {
|
|
if (value === null || value === undefined || value === "") {
|
|
return null;
|
|
}
|
|
|
|
const lowerDataType = dataType.toLowerCase();
|
|
|
|
// 날짜/시간 타입 처리
|
|
if (
|
|
lowerDataType.includes("timestamp") ||
|
|
lowerDataType.includes("datetime")
|
|
) {
|
|
// YYYY-MM-DDTHH:mm:ss 형식을 PostgreSQL timestamp로 변환
|
|
if (typeof value === "string") {
|
|
try {
|
|
const date = new Date(value);
|
|
return date.toISOString();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
return value;
|
|
}
|
|
|
|
// 날짜 타입 처리
|
|
if (lowerDataType.includes("date")) {
|
|
if (typeof value === "string") {
|
|
try {
|
|
// YYYY-MM-DD 형식 유지
|
|
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
return value;
|
|
}
|
|
const date = new Date(value);
|
|
return date.toISOString().split("T")[0];
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
return value;
|
|
}
|
|
|
|
// 시간 타입 처리
|
|
if (lowerDataType.includes("time")) {
|
|
if (typeof value === "string") {
|
|
// HH:mm:ss 형식 유지
|
|
if (/^\d{2}:\d{2}:\d{2}$/.test(value)) {
|
|
return value;
|
|
}
|
|
}
|
|
return value;
|
|
}
|
|
|
|
// 숫자 타입 처리
|
|
if (
|
|
lowerDataType.includes("integer") ||
|
|
lowerDataType.includes("bigint") ||
|
|
lowerDataType.includes("serial")
|
|
) {
|
|
return parseInt(value) || null;
|
|
}
|
|
|
|
if (
|
|
lowerDataType.includes("numeric") ||
|
|
lowerDataType.includes("decimal") ||
|
|
lowerDataType.includes("real") ||
|
|
lowerDataType.includes("double")
|
|
) {
|
|
return parseFloat(value) || null;
|
|
}
|
|
|
|
// 불린 타입 처리
|
|
if (lowerDataType.includes("boolean")) {
|
|
if (typeof value === "string") {
|
|
return value.toLowerCase() === "true" || value === "1";
|
|
}
|
|
return Boolean(value);
|
|
}
|
|
|
|
// 기본적으로 문자열로 처리
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* 테이블에 데이터 추가
|
|
* @returns 무시된 컬럼 정보 (디버깅용)
|
|
*/
|
|
async addTableData(
|
|
tableName: string,
|
|
data: Record<string, any>
|
|
): Promise<{ skippedColumns: string[]; savedColumns: string[] }> {
|
|
try {
|
|
logger.info(`=== 테이블 데이터 추가 시작: ${tableName} ===`);
|
|
logger.info(`추가할 데이터:`, data);
|
|
|
|
// 테이블의 컬럼 정보 조회
|
|
const columnInfoQuery = `
|
|
SELECT column_name, data_type, is_nullable
|
|
FROM information_schema.columns
|
|
WHERE table_name = $1
|
|
ORDER BY ordinal_position
|
|
`;
|
|
|
|
const columnInfoResult = (await query(columnInfoQuery, [
|
|
tableName,
|
|
])) as any[];
|
|
const columnTypeMap = new Map<string, string>();
|
|
|
|
columnInfoResult.forEach((col: any) => {
|
|
columnTypeMap.set(col.column_name, col.data_type);
|
|
});
|
|
|
|
logger.info(`컬럼 타입 정보:`, Object.fromEntries(columnTypeMap));
|
|
|
|
// created_date 컬럼이 있고 값이 없으면 자동으로 현재 시간 추가
|
|
const hasCreatedDate = columnTypeMap.has("created_date");
|
|
if (hasCreatedDate && !data.created_date) {
|
|
data.created_date = new Date().toISOString();
|
|
logger.info(`created_date 자동 추가: ${data.created_date}`);
|
|
}
|
|
|
|
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼은 무시)
|
|
const skippedColumns: string[] = [];
|
|
const existingColumns = Object.keys(data).filter((col) => {
|
|
const exists = columnTypeMap.has(col);
|
|
if (!exists) {
|
|
skippedColumns.push(col);
|
|
}
|
|
return exists;
|
|
});
|
|
|
|
// 무시된 컬럼이 있으면 경고 로그 출력
|
|
if (skippedColumns.length > 0) {
|
|
logger.warn(
|
|
`⚠️ [${tableName}] 테이블에 존재하지 않는 컬럼 ${skippedColumns.length}개 무시됨: ${skippedColumns.join(", ")}`
|
|
);
|
|
logger.warn(
|
|
`⚠️ [${tableName}] 무시된 컬럼 상세:`,
|
|
skippedColumns.map((col) => ({ column: col, value: data[col] }))
|
|
);
|
|
}
|
|
|
|
if (existingColumns.length === 0) {
|
|
throw new Error(
|
|
`저장할 유효한 컬럼이 없습니다. 테이블: ${tableName}, 전달된 컬럼: ${Object.keys(data).join(", ")}`
|
|
);
|
|
}
|
|
|
|
logger.info(
|
|
`✅ [${tableName}] 저장될 컬럼 ${existingColumns.length}개: ${existingColumns.join(", ")}`
|
|
);
|
|
|
|
// 컬럼명과 값을 분리하고 타입에 맞게 변환 (존재하는 컬럼만)
|
|
const columns = existingColumns;
|
|
const values = columns.map((columnName) => {
|
|
const value = data[columnName];
|
|
const dataType = columnTypeMap.get(columnName) || "text";
|
|
const convertedValue = this.convertValueForPostgreSQL(value, dataType);
|
|
logger.info(
|
|
`컬럼 "${columnName}" (${dataType}): "${value}" → "${convertedValue}"`
|
|
);
|
|
return convertedValue;
|
|
});
|
|
|
|
// 동적 INSERT 쿼리 생성 (타입 캐스팅 포함)
|
|
const placeholders = columns
|
|
.map((col, index) => {
|
|
const dataType = columnTypeMap.get(col) || "text";
|
|
const lowerDataType = dataType.toLowerCase();
|
|
|
|
// PostgreSQL에서 직접 타입 캐스팅
|
|
if (
|
|
lowerDataType.includes("timestamp") ||
|
|
lowerDataType.includes("datetime")
|
|
) {
|
|
return `$${index + 1}::timestamp`;
|
|
} else if (lowerDataType.includes("date")) {
|
|
return `$${index + 1}::date`;
|
|
} else if (lowerDataType.includes("time")) {
|
|
return `$${index + 1}::time`;
|
|
} else if (
|
|
lowerDataType.includes("integer") ||
|
|
lowerDataType.includes("bigint") ||
|
|
lowerDataType.includes("serial")
|
|
) {
|
|
return `$${index + 1}::integer`;
|
|
} else if (
|
|
lowerDataType.includes("numeric") ||
|
|
lowerDataType.includes("decimal")
|
|
) {
|
|
return `$${index + 1}::numeric`;
|
|
} else if (lowerDataType.includes("boolean")) {
|
|
return `$${index + 1}::boolean`;
|
|
}
|
|
|
|
return `$${index + 1}`;
|
|
})
|
|
.join(", ");
|
|
const columnNames = columns.map((col) => `"${col}"`).join(", ");
|
|
|
|
const insertQuery = `
|
|
INSERT INTO "${tableName}" (${columnNames})
|
|
VALUES (${placeholders})
|
|
`;
|
|
|
|
logger.info(`실행할 쿼리: ${insertQuery}`);
|
|
logger.info(`쿼리 파라미터:`, values);
|
|
|
|
await query(insertQuery, values);
|
|
|
|
logger.info(`테이블 데이터 추가 완료: ${tableName}`);
|
|
|
|
// 무시된 컬럼과 저장된 컬럼 정보 반환
|
|
return {
|
|
skippedColumns,
|
|
savedColumns: existingColumns,
|
|
};
|
|
} catch (error) {
|
|
logger.error(`테이블 데이터 추가 오류: ${tableName}`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 테이블 데이터 수정
|
|
*/
|
|
async editTableData(
|
|
tableName: string,
|
|
originalData: Record<string, any>,
|
|
updatedData: Record<string, any>
|
|
): Promise<void> {
|
|
try {
|
|
logger.info(`=== 테이블 데이터 수정 시작: ${tableName} ===`);
|
|
logger.info(`원본 데이터:`, originalData);
|
|
logger.info(`수정할 데이터:`, updatedData);
|
|
|
|
// 테이블의 컬럼 정보 조회 (PRIMARY KEY 찾기용)
|
|
const columnInfoQuery = `
|
|
SELECT c.column_name, c.data_type, c.is_nullable,
|
|
CASE WHEN tc.constraint_type = 'PRIMARY KEY' THEN 'YES' ELSE 'NO' END as is_primary_key
|
|
FROM information_schema.columns c
|
|
LEFT JOIN information_schema.key_column_usage kcu ON c.column_name = kcu.column_name AND c.table_name = kcu.table_name
|
|
LEFT JOIN information_schema.table_constraints tc ON kcu.constraint_name = tc.constraint_name AND tc.table_name = c.table_name
|
|
WHERE c.table_name = $1
|
|
ORDER BY c.ordinal_position
|
|
`;
|
|
|
|
const columnInfoResult = (await query(columnInfoQuery, [
|
|
tableName,
|
|
])) as any[];
|
|
const columnTypeMap = new Map<string, string>();
|
|
const primaryKeys: string[] = [];
|
|
|
|
columnInfoResult.forEach((col: any) => {
|
|
columnTypeMap.set(col.column_name, col.data_type);
|
|
if (col.is_primary_key === "YES") {
|
|
primaryKeys.push(col.column_name);
|
|
}
|
|
});
|
|
|
|
logger.info(`컬럼 타입 정보:`, Object.fromEntries(columnTypeMap));
|
|
logger.info(`PRIMARY KEY 컬럼들:`, primaryKeys);
|
|
|
|
// updated_date 컬럼이 있으면 자동으로 현재 시간 추가
|
|
const hasUpdatedDate = columnTypeMap.has("updated_date");
|
|
if (hasUpdatedDate && !updatedData.updated_date) {
|
|
updatedData.updated_date = new Date().toISOString();
|
|
logger.info(`updated_date 자동 추가: ${updatedData.updated_date}`);
|
|
}
|
|
|
|
// SET 절 생성 (수정할 데이터) - 먼저 생성
|
|
// 🔧 테이블에 존재하는 컬럼만 UPDATE (가상 컬럼 제외)
|
|
const setConditions: string[] = [];
|
|
const setValues: any[] = [];
|
|
let paramIndex = 1;
|
|
const skippedColumns: string[] = [];
|
|
|
|
Object.keys(updatedData).forEach((column) => {
|
|
// 테이블에 존재하지 않는 컬럼은 스킵
|
|
if (!columnTypeMap.has(column)) {
|
|
skippedColumns.push(column);
|
|
return;
|
|
}
|
|
|
|
const dataType = columnTypeMap.get(column) || "text";
|
|
setConditions.push(
|
|
`"${column}" = $${paramIndex}::${this.getPostgreSQLType(dataType)}`
|
|
);
|
|
setValues.push(
|
|
this.convertValueForPostgreSQL(updatedData[column], dataType)
|
|
);
|
|
paramIndex++;
|
|
});
|
|
|
|
if (skippedColumns.length > 0) {
|
|
logger.info(`⚠️ 테이블에 존재하지 않는 컬럼 스킵: ${skippedColumns.join(", ")}`);
|
|
}
|
|
|
|
// WHERE 조건 생성 (PRIMARY KEY 우선, 없으면 모든 원본 데이터 사용)
|
|
let whereConditions: string[] = [];
|
|
let whereValues: any[] = [];
|
|
|
|
if (primaryKeys.length > 0) {
|
|
// PRIMARY KEY로 WHERE 조건 생성
|
|
primaryKeys.forEach((pkColumn) => {
|
|
if (originalData[pkColumn] !== undefined) {
|
|
const dataType = columnTypeMap.get(pkColumn) || "text";
|
|
whereConditions.push(
|
|
`"${pkColumn}" = $${paramIndex}::${this.getPostgreSQLType(dataType)}`
|
|
);
|
|
whereValues.push(
|
|
this.convertValueForPostgreSQL(originalData[pkColumn], dataType)
|
|
);
|
|
paramIndex++;
|
|
}
|
|
});
|
|
} else {
|
|
// PRIMARY KEY가 없으면 모든 원본 데이터로 WHERE 조건 생성
|
|
Object.keys(originalData).forEach((column) => {
|
|
const dataType = columnTypeMap.get(column) || "text";
|
|
whereConditions.push(
|
|
`"${column}" = $${paramIndex}::${this.getPostgreSQLType(dataType)}`
|
|
);
|
|
whereValues.push(
|
|
this.convertValueForPostgreSQL(originalData[column], dataType)
|
|
);
|
|
paramIndex++;
|
|
});
|
|
}
|
|
|
|
// UPDATE 쿼리 생성
|
|
const updateQuery = `
|
|
UPDATE "${tableName}"
|
|
SET ${setConditions.join(", ")}
|
|
WHERE ${whereConditions.join(" AND ")}
|
|
`;
|
|
|
|
const allValues = [...setValues, ...whereValues];
|
|
|
|
logger.info(`실행할 UPDATE 쿼리: ${updateQuery}`);
|
|
logger.info(`쿼리 파라미터:`, allValues);
|
|
|
|
const result = await query(updateQuery, allValues);
|
|
|
|
logger.info(`테이블 데이터 수정 완료: ${tableName}`, result);
|
|
} catch (error) {
|
|
logger.error(`테이블 데이터 수정 오류: ${tableName}`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* PostgreSQL 타입명 반환
|
|
*/
|
|
private getPostgreSQLType(dataType: string): string {
|
|
const lowerDataType = dataType.toLowerCase();
|
|
|
|
if (
|
|
lowerDataType.includes("timestamp") ||
|
|
lowerDataType.includes("datetime")
|
|
) {
|
|
return "timestamp";
|
|
} else if (lowerDataType.includes("date")) {
|
|
return "date";
|
|
} else if (lowerDataType.includes("time")) {
|
|
return "time";
|
|
} else if (
|
|
lowerDataType.includes("integer") ||
|
|
lowerDataType.includes("bigint") ||
|
|
lowerDataType.includes("serial")
|
|
) {
|
|
return "integer";
|
|
} else if (
|
|
lowerDataType.includes("numeric") ||
|
|
lowerDataType.includes("decimal")
|
|
) {
|
|
return "numeric";
|
|
} else if (lowerDataType.includes("boolean")) {
|
|
return "boolean";
|
|
}
|
|
|
|
return "text"; // 기본값
|
|
}
|
|
|
|
/**
|
|
* 테이블에서 데이터 삭제
|
|
*/
|
|
async deleteTableData(
|
|
tableName: string,
|
|
dataToDelete: Record<string, any>[]
|
|
): Promise<number> {
|
|
try {
|
|
logger.info(`테이블 데이터 삭제: ${tableName}`, dataToDelete);
|
|
|
|
if (!Array.isArray(dataToDelete) || dataToDelete.length === 0) {
|
|
throw new Error("삭제할 데이터가 없습니다.");
|
|
}
|
|
|
|
let deletedCount = 0;
|
|
|
|
// 테이블의 기본 키 컬럼 찾기 (정확한 식별을 위해)
|
|
const primaryKeyQuery = `
|
|
SELECT column_name
|
|
FROM information_schema.table_constraints tc
|
|
JOIN information_schema.key_column_usage kcu
|
|
ON tc.constraint_name = kcu.constraint_name
|
|
WHERE tc.table_name = $1
|
|
AND tc.constraint_type = 'PRIMARY KEY'
|
|
ORDER BY kcu.ordinal_position
|
|
`;
|
|
|
|
const primaryKeys = await query<{ column_name: string }>(
|
|
primaryKeyQuery,
|
|
[tableName]
|
|
);
|
|
|
|
if (primaryKeys.length === 0) {
|
|
// 기본 키가 없는 경우, 모든 컬럼으로 삭제 조건 생성
|
|
logger.warn(
|
|
`테이블 ${tableName}에 기본 키가 없습니다. 모든 컬럼으로 삭제 조건을 생성합니다.`
|
|
);
|
|
|
|
for (const rowData of dataToDelete) {
|
|
const conditions = Object.keys(rowData)
|
|
.map((key, index) => `"${key}" = $${index + 1}`)
|
|
.join(" AND ");
|
|
|
|
const values = Object.values(rowData);
|
|
|
|
const deleteQuery = `DELETE FROM "${tableName}" WHERE ${conditions}`;
|
|
|
|
const result = await query(deleteQuery, values);
|
|
deletedCount += Number(result);
|
|
}
|
|
} else {
|
|
// 기본 키를 사용한 삭제
|
|
const primaryKeyNames = primaryKeys.map((pk) => pk.column_name);
|
|
|
|
for (const rowData of dataToDelete) {
|
|
const conditions = primaryKeyNames
|
|
.map((key, index) => `"${key}" = $${index + 1}`)
|
|
.join(" AND ");
|
|
|
|
const values = primaryKeyNames.map((key) => rowData[key]);
|
|
|
|
// null 값이 있는 경우 스킵
|
|
if (values.some((val) => val === null || val === undefined)) {
|
|
logger.warn(`기본 키 값이 null인 행을 스킵합니다:`, rowData);
|
|
continue;
|
|
}
|
|
|
|
const deleteQuery = `DELETE FROM "${tableName}" WHERE ${conditions}`;
|
|
|
|
const result = await query(deleteQuery, values);
|
|
deletedCount += Number(result);
|
|
}
|
|
}
|
|
|
|
logger.info(
|
|
`테이블 데이터 삭제 완료: ${tableName}, ${deletedCount}건 삭제`
|
|
);
|
|
return deletedCount;
|
|
} catch (error) {
|
|
logger.error(`테이블 데이터 삭제 오류: ${tableName}`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// 🎯 Entity 조인 기능
|
|
// ========================================
|
|
|
|
/**
|
|
* Entity 조인이 포함된 데이터 조회
|
|
*/
|
|
async getTableDataWithEntityJoins(
|
|
tableName: string,
|
|
options: {
|
|
page: number;
|
|
size: number;
|
|
search?: Record<string, any>;
|
|
sortBy?: string;
|
|
sortOrder?: string;
|
|
enableEntityJoin?: boolean;
|
|
companyCode?: string; // 멀티테넌시 필터용
|
|
additionalJoinColumns?: Array<{
|
|
sourceTable: string;
|
|
sourceColumn: string;
|
|
joinAlias: string;
|
|
}>;
|
|
screenEntityConfigs?: Record<string, any>; // 화면별 엔티티 설정
|
|
dataFilter?: any; // 🆕 데이터 필터
|
|
excludeFilter?: {
|
|
enabled: boolean;
|
|
referenceTable: string;
|
|
referenceColumn: string;
|
|
sourceColumn: string;
|
|
filterColumn?: string;
|
|
filterValue?: any;
|
|
}; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
|
|
}
|
|
): Promise<EntityJoinResponse> {
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
logger.info(`Entity 조인 데이터 조회 시작: ${tableName}`);
|
|
|
|
// Entity 조인이 비활성화된 경우 기본 데이터 조회
|
|
if (!options.enableEntityJoin) {
|
|
const basicResult = await this.getTableData(tableName, options);
|
|
return {
|
|
data: basicResult.data,
|
|
total: basicResult.total,
|
|
page: options.page,
|
|
size: options.size,
|
|
totalPages: Math.ceil(basicResult.total / options.size),
|
|
};
|
|
}
|
|
|
|
// Entity 조인 설정 감지 (화면별 엔티티 설정 전달)
|
|
let joinConfigs = await entityJoinService.detectEntityJoins(
|
|
tableName,
|
|
options.screenEntityConfigs
|
|
);
|
|
|
|
logger.info(
|
|
`🔍 detectEntityJoins 결과: ${joinConfigs.length}개 조인 설정`
|
|
);
|
|
if (joinConfigs.length > 0) {
|
|
joinConfigs.forEach((config, index) => {
|
|
logger.info(
|
|
` 조인 ${index + 1}: ${config.sourceColumn} -> ${config.referenceTable} AS ${config.aliasColumn}`
|
|
);
|
|
});
|
|
}
|
|
|
|
// 추가 조인 컬럼 정보가 있으면 조인 설정에 추가
|
|
if (
|
|
options.additionalJoinColumns &&
|
|
options.additionalJoinColumns.length > 0
|
|
) {
|
|
logger.info(
|
|
`추가 조인 컬럼 처리: ${options.additionalJoinColumns.length}개`
|
|
);
|
|
logger.info(
|
|
"📋 전달받은 additionalJoinColumns:",
|
|
options.additionalJoinColumns
|
|
);
|
|
|
|
for (const additionalColumn of options.additionalJoinColumns) {
|
|
// 🔍 1차: sourceColumn을 기준으로 기존 조인 설정 찾기
|
|
let baseJoinConfig = joinConfigs.find(
|
|
(config) => config.sourceColumn === additionalColumn.sourceColumn
|
|
);
|
|
|
|
// 🔍 2차: referenceTable을 기준으로 찾기 (프론트엔드가 customer_mng.customer_name 같은 형식을 요청할 때)
|
|
// 실제 소스 컬럼이 partner_id인데 프론트엔드가 customer_id로 추론하는 경우 대응
|
|
if (!baseJoinConfig && (additionalColumn as any).referenceTable) {
|
|
baseJoinConfig = joinConfigs.find(
|
|
(config) => config.referenceTable === (additionalColumn as any).referenceTable
|
|
);
|
|
if (baseJoinConfig) {
|
|
logger.info(`🔄 referenceTable로 조인 설정 찾음: ${(additionalColumn as any).referenceTable} → ${baseJoinConfig.sourceColumn}`);
|
|
}
|
|
}
|
|
|
|
if (baseJoinConfig) {
|
|
// joinAlias에서 실제 컬럼명 추출
|
|
const sourceColumn = baseJoinConfig.sourceColumn; // 실제 소스 컬럼 (예: partner_id)
|
|
const originalJoinAlias = additionalColumn.joinAlias; // 프론트엔드가 보낸 별칭 (예: customer_id_customer_name)
|
|
|
|
// 🔄 프론트엔드가 잘못된 소스 컬럼으로 추론한 경우 처리
|
|
// customer_id_customer_name → customer_name 추출 (customer_id_ 부분 제거)
|
|
// 또는 partner_id_customer_name → customer_name 추출 (partner_id_ 부분 제거)
|
|
let actualColumnName: string;
|
|
|
|
// 프론트엔드가 보낸 joinAlias에서 실제 컬럼명 추출
|
|
const frontendSourceColumn = additionalColumn.sourceColumn; // 프론트엔드가 추론한 소스 컬럼 (customer_id)
|
|
if (originalJoinAlias.startsWith(`${frontendSourceColumn}_`)) {
|
|
// 프론트엔드가 추론한 소스 컬럼으로 시작하면 그 부분 제거
|
|
actualColumnName = originalJoinAlias.replace(`${frontendSourceColumn}_`, "");
|
|
} else if (originalJoinAlias.startsWith(`${sourceColumn}_`)) {
|
|
// 실제 소스 컬럼으로 시작하면 그 부분 제거
|
|
actualColumnName = originalJoinAlias.replace(`${sourceColumn}_`, "");
|
|
} else {
|
|
// 어느 것도 아니면 원본 사용
|
|
actualColumnName = originalJoinAlias;
|
|
}
|
|
|
|
// 🆕 올바른 joinAlias 재생성 (실제 소스 컬럼 기반)
|
|
const correctedJoinAlias = `${sourceColumn}_${actualColumnName}`;
|
|
|
|
logger.info(`🔍 조인 컬럼 상세 분석:`, {
|
|
sourceColumn,
|
|
frontendSourceColumn,
|
|
originalJoinAlias,
|
|
correctedJoinAlias,
|
|
actualColumnName,
|
|
referenceTable: (additionalColumn as any).referenceTable,
|
|
});
|
|
|
|
// 🚨 기본 Entity 조인과 중복되지 않도록 체크
|
|
const isBasicEntityJoin =
|
|
correctedJoinAlias === `${sourceColumn}_name`;
|
|
|
|
if (isBasicEntityJoin) {
|
|
logger.info(
|
|
`⚠️ 기본 Entity 조인과 중복: ${correctedJoinAlias} - 건너뜀`
|
|
);
|
|
continue; // 기본 Entity 조인과 중복되면 추가하지 않음
|
|
}
|
|
|
|
// 추가 조인 컬럼 설정 생성
|
|
const additionalJoinConfig: EntityJoinConfig = {
|
|
sourceTable: tableName,
|
|
sourceColumn: sourceColumn, // 실제 소스 컬럼 (partner_id)
|
|
referenceTable:
|
|
(additionalColumn as any).referenceTable ||
|
|
baseJoinConfig.referenceTable, // 참조 테이블 (customer_mng)
|
|
referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (customer_code)
|
|
displayColumns: [actualColumnName], // 표시할 컬럼들 (customer_name)
|
|
displayColumn: actualColumnName, // 하위 호환성
|
|
aliasColumn: correctedJoinAlias, // 수정된 별칭 (partner_id_customer_name)
|
|
separator: " - ", // 기본 구분자
|
|
};
|
|
|
|
joinConfigs.push(additionalJoinConfig);
|
|
logger.info(
|
|
`✅ 추가 조인 컬럼 설정 추가: ${additionalJoinConfig.aliasColumn} -> ${actualColumnName}`
|
|
);
|
|
logger.info(`🔍 추가된 조인 설정 상세:`, {
|
|
sourceTable: additionalJoinConfig.sourceTable,
|
|
sourceColumn: additionalJoinConfig.sourceColumn,
|
|
referenceTable: additionalJoinConfig.referenceTable,
|
|
displayColumns: additionalJoinConfig.displayColumns,
|
|
aliasColumn: additionalJoinConfig.aliasColumn,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// 최종 조인 설정 배열 로깅
|
|
logger.info(`🎯 최종 joinConfigs 배열 (${joinConfigs.length}개):`);
|
|
joinConfigs.forEach((config, index) => {
|
|
logger.info(
|
|
` ${index + 1}. ${config.sourceColumn} -> ${config.referenceTable} AS ${config.aliasColumn}`,
|
|
{
|
|
displayColumns: config.displayColumns,
|
|
displayColumn: config.displayColumn,
|
|
}
|
|
);
|
|
});
|
|
|
|
if (joinConfigs.length === 0) {
|
|
logger.info(`Entity 조인 설정이 없음: ${tableName}`);
|
|
const basicResult = await this.getTableData(tableName, options);
|
|
return {
|
|
data: basicResult.data,
|
|
total: basicResult.total,
|
|
page: options.page,
|
|
size: options.size,
|
|
totalPages: Math.ceil(basicResult.total / options.size),
|
|
};
|
|
}
|
|
|
|
// 조인 전략 결정 (테이블 크기 기반)
|
|
// 🚨 additionalJoinColumns가 있는 경우 강제로 full_join 사용 (캐시 안정성 보장)
|
|
let strategy: "full_join" | "cache_lookup" | "hybrid";
|
|
|
|
if (
|
|
options.additionalJoinColumns &&
|
|
options.additionalJoinColumns.length > 0
|
|
) {
|
|
strategy = "full_join";
|
|
console.log(
|
|
`🔧 additionalJoinColumns 감지: 강제로 full_join 전략 사용 (${options.additionalJoinColumns.length}개 추가 조인)`
|
|
);
|
|
} else {
|
|
strategy = await entityJoinService.determineJoinStrategy(joinConfigs);
|
|
}
|
|
|
|
console.log(
|
|
`🎯 선택된 조인 전략: ${strategy} (${joinConfigs.length}개 Entity 조인)`
|
|
);
|
|
|
|
// 테이블 컬럼 정보 조회
|
|
const columns = await this.getTableColumns(tableName);
|
|
const selectColumns = columns.data.map((col: any) => col.column_name);
|
|
|
|
// WHERE 절 구성
|
|
let whereClause = await this.buildWhereClause(tableName, options.search);
|
|
|
|
// 멀티테넌시 필터 추가 (company_code)
|
|
if (options.companyCode) {
|
|
const companyFilter = `main.company_code = '${options.companyCode.replace(/'/g, "''")}'`;
|
|
whereClause = whereClause
|
|
? `${whereClause} AND ${companyFilter}`
|
|
: companyFilter;
|
|
logger.info(
|
|
`🔒 멀티테넌시 필터 추가 (Entity 조인): company_code = ${options.companyCode}`
|
|
);
|
|
}
|
|
|
|
// 🆕 데이터 필터 적용 (Entity 조인) - 파라미터 바인딩 없이 직접 값 삽입
|
|
if (
|
|
options.dataFilter &&
|
|
options.dataFilter.enabled &&
|
|
options.dataFilter.filters &&
|
|
options.dataFilter.filters.length > 0
|
|
) {
|
|
const filterConditions: string[] = [];
|
|
|
|
for (const filter of options.dataFilter.filters) {
|
|
const { columnName, operator, value } = filter;
|
|
|
|
if (!columnName || value === undefined || value === null) {
|
|
continue;
|
|
}
|
|
|
|
const safeColumn = `main."${columnName}"`;
|
|
|
|
switch (operator) {
|
|
case "equals":
|
|
filterConditions.push(
|
|
`${safeColumn} = '${String(value).replace(/'/g, "''")}'`
|
|
);
|
|
break;
|
|
case "not_equals":
|
|
filterConditions.push(
|
|
`${safeColumn} != '${String(value).replace(/'/g, "''")}'`
|
|
);
|
|
break;
|
|
case "in":
|
|
if (Array.isArray(value) && value.length > 0) {
|
|
const values = value
|
|
.map((v) => `'${String(v).replace(/'/g, "''")}'`)
|
|
.join(", ");
|
|
filterConditions.push(`${safeColumn} IN (${values})`);
|
|
}
|
|
break;
|
|
case "not_in":
|
|
if (Array.isArray(value) && value.length > 0) {
|
|
const values = value
|
|
.map((v) => `'${String(v).replace(/'/g, "''")}'`)
|
|
.join(", ");
|
|
filterConditions.push(`${safeColumn} NOT IN (${values})`);
|
|
}
|
|
break;
|
|
case "contains":
|
|
filterConditions.push(
|
|
`${safeColumn} LIKE '%${String(value).replace(/'/g, "''")}%'`
|
|
);
|
|
break;
|
|
case "starts_with":
|
|
filterConditions.push(
|
|
`${safeColumn} LIKE '${String(value).replace(/'/g, "''")}%'`
|
|
);
|
|
break;
|
|
case "ends_with":
|
|
filterConditions.push(
|
|
`${safeColumn} LIKE '%${String(value).replace(/'/g, "''")}'`
|
|
);
|
|
break;
|
|
case "is_null":
|
|
filterConditions.push(`${safeColumn} IS NULL`);
|
|
break;
|
|
case "is_not_null":
|
|
filterConditions.push(`${safeColumn} IS NOT NULL`);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (filterConditions.length > 0) {
|
|
const logicalOperator =
|
|
options.dataFilter.matchType === "any" ? " OR " : " AND ";
|
|
const filterWhere = `(${filterConditions.join(logicalOperator)})`;
|
|
|
|
whereClause = whereClause
|
|
? `${whereClause} AND ${filterWhere}`
|
|
: filterWhere;
|
|
|
|
logger.info(`🔍 데이터 필터 적용 (Entity 조인): ${filterWhere}`);
|
|
}
|
|
}
|
|
|
|
// 🆕 제외 필터 적용 (다른 테이블에 이미 존재하는 데이터 제외)
|
|
if (options.excludeFilter && options.excludeFilter.enabled) {
|
|
const {
|
|
referenceTable,
|
|
referenceColumn,
|
|
sourceColumn,
|
|
filterColumn,
|
|
filterValue,
|
|
} = options.excludeFilter;
|
|
|
|
if (referenceTable && referenceColumn && sourceColumn) {
|
|
// 서브쿼리로 이미 존재하는 데이터 제외
|
|
let excludeSubquery = `main."${sourceColumn}" NOT IN (
|
|
SELECT "${referenceColumn}" FROM "${referenceTable}"
|
|
WHERE "${referenceColumn}" IS NOT NULL`;
|
|
|
|
// 추가 필터 조건이 있으면 적용 (예: 특정 거래처의 품목만 제외)
|
|
if (
|
|
filterColumn &&
|
|
filterValue !== undefined &&
|
|
filterValue !== null
|
|
) {
|
|
excludeSubquery += ` AND "${filterColumn}" = '${String(filterValue).replace(/'/g, "''")}'`;
|
|
}
|
|
|
|
excludeSubquery += ")";
|
|
|
|
whereClause = whereClause
|
|
? `${whereClause} AND ${excludeSubquery}`
|
|
: excludeSubquery;
|
|
|
|
logger.info(`🚫 제외 필터 적용 (Entity 조인):`, {
|
|
referenceTable,
|
|
referenceColumn,
|
|
sourceColumn,
|
|
filterColumn,
|
|
filterValue,
|
|
excludeSubquery,
|
|
});
|
|
}
|
|
}
|
|
|
|
// ORDER BY 절 구성
|
|
const orderBy = options.sortBy
|
|
? `main.${options.sortBy} ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
|
|
: "";
|
|
|
|
// 페이징 계산
|
|
const offset = (options.page - 1) * options.size;
|
|
|
|
if (strategy === "full_join") {
|
|
// SQL JOIN 방식
|
|
return await this.executeJoinQuery(
|
|
tableName,
|
|
joinConfigs,
|
|
selectColumns,
|
|
whereClause,
|
|
orderBy,
|
|
options.size,
|
|
offset,
|
|
startTime
|
|
);
|
|
} else if (strategy === "cache_lookup") {
|
|
// 캐시 룩업 방식
|
|
return await this.executeCachedLookup(
|
|
tableName,
|
|
joinConfigs,
|
|
options,
|
|
startTime
|
|
);
|
|
} else {
|
|
// 하이브리드 방식: 일부는 조인, 일부는 캐시
|
|
return await this.executeHybridJoin(
|
|
tableName,
|
|
joinConfigs,
|
|
selectColumns,
|
|
whereClause,
|
|
orderBy,
|
|
options.size,
|
|
offset,
|
|
startTime
|
|
);
|
|
}
|
|
} catch (error) {
|
|
logger.error(`Entity 조인 데이터 조회 실패: ${tableName}`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* SQL JOIN 방식으로 데이터 조회
|
|
*/
|
|
private async executeJoinQuery(
|
|
tableName: string,
|
|
joinConfigs: EntityJoinConfig[],
|
|
selectColumns: string[],
|
|
whereClause: string,
|
|
orderBy: string,
|
|
limit: number,
|
|
offset: number,
|
|
startTime: number
|
|
): Promise<EntityJoinResponse> {
|
|
try {
|
|
// 데이터 조회 쿼리
|
|
const dataQuery = entityJoinService.buildJoinQuery(
|
|
tableName,
|
|
joinConfigs,
|
|
selectColumns,
|
|
whereClause,
|
|
orderBy,
|
|
limit,
|
|
offset
|
|
).query;
|
|
|
|
// 카운트 쿼리
|
|
const countQuery = entityJoinService.buildCountQuery(
|
|
tableName,
|
|
joinConfigs,
|
|
whereClause
|
|
);
|
|
|
|
// ⚠️ SQL 쿼리 로깅 (디버깅용)
|
|
logger.info(`🔍 [executeJoinQuery] 실행할 SQL:\n${dataQuery}`);
|
|
|
|
// 병렬 실행
|
|
const [dataResult, countResult] = await Promise.all([
|
|
query(dataQuery),
|
|
query(countQuery),
|
|
]);
|
|
|
|
logger.info(
|
|
`✅ [executeJoinQuery] 조회 완료: ${dataResult?.length}개 행`
|
|
);
|
|
|
|
const data = Array.isArray(dataResult) ? dataResult : [];
|
|
const total =
|
|
Array.isArray(countResult) && countResult.length > 0
|
|
? Number((countResult[0] as any).total)
|
|
: 0;
|
|
|
|
const queryTime = Date.now() - startTime;
|
|
|
|
return {
|
|
data,
|
|
total,
|
|
page: Math.floor(offset / limit) + 1,
|
|
size: limit,
|
|
totalPages: Math.ceil(total / limit),
|
|
entityJoinInfo: {
|
|
joinConfigs,
|
|
strategy: "full_join",
|
|
performance: {
|
|
queryTime,
|
|
},
|
|
},
|
|
};
|
|
} catch (error) {
|
|
logger.error("SQL JOIN 쿼리 실행 실패", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 캐시 룩업 방식으로 데이터 조회
|
|
*/
|
|
private async executeCachedLookup(
|
|
tableName: string,
|
|
joinConfigs: EntityJoinConfig[],
|
|
options: {
|
|
page: number;
|
|
size: number;
|
|
search?: Record<string, any>;
|
|
sortBy?: string;
|
|
sortOrder?: string;
|
|
companyCode?: string;
|
|
},
|
|
startTime: number
|
|
): Promise<EntityJoinResponse> {
|
|
try {
|
|
// 캐시 데이터 미리 로드
|
|
for (const config of joinConfigs) {
|
|
const displayCol =
|
|
config.displayColumn ||
|
|
config.displayColumns?.[0] ||
|
|
config.referenceColumn;
|
|
logger.info(
|
|
`🔍 캐시 로드 - ${config.referenceTable}: keyCol=${config.referenceColumn}, displayCol=${displayCol}`
|
|
);
|
|
|
|
await referenceCacheService.getCachedReference(
|
|
config.referenceTable,
|
|
config.referenceColumn,
|
|
displayCol
|
|
);
|
|
}
|
|
|
|
// Entity 조인 컬럼 검색이 있는지 확인 (기본 조인 + 추가 조인 컬럼 모두 포함)
|
|
const allEntityColumns = [
|
|
...joinConfigs.map((config) => config.aliasColumn),
|
|
// 추가 조인 컬럼들도 포함 (writer_dept_code, company_code_status 등)
|
|
...joinConfigs.flatMap((config) => {
|
|
const additionalColumns = [];
|
|
// writer -> writer_dept_code 패턴
|
|
if (config.sourceColumn === "writer") {
|
|
additionalColumns.push("writer_dept_code");
|
|
}
|
|
// company_code -> company_code_status 패턴
|
|
if (config.sourceColumn === "company_code") {
|
|
additionalColumns.push("company_code_status");
|
|
}
|
|
return additionalColumns;
|
|
}),
|
|
];
|
|
|
|
// 🆕 테이블명.컬럼명 형식도 Entity 검색으로 인식
|
|
const hasJoinTableSearch =
|
|
options.search &&
|
|
Object.keys(options.search).some((key) => key.includes("."));
|
|
|
|
const hasEntitySearch =
|
|
options.search &&
|
|
(Object.keys(options.search).some((key) =>
|
|
allEntityColumns.includes(key)
|
|
) ||
|
|
hasJoinTableSearch);
|
|
|
|
if (hasEntitySearch) {
|
|
const entitySearchKeys = options.search
|
|
? Object.keys(options.search).filter(
|
|
(key) => allEntityColumns.includes(key) || key.includes(".")
|
|
)
|
|
: [];
|
|
logger.info(
|
|
`🔍 Entity 조인 컬럼 검색 감지: ${entitySearchKeys.join(", ")}`
|
|
);
|
|
}
|
|
|
|
let basicResult;
|
|
|
|
if (hasEntitySearch) {
|
|
// Entity 조인 컬럼으로 검색하는 경우 SQL JOIN 방식 사용
|
|
logger.info("🔍 Entity 조인 컬럼 검색 감지, SQL JOIN 방식으로 전환");
|
|
|
|
try {
|
|
// 테이블 컬럼 정보 조회
|
|
const columns = await this.getTableColumns(tableName);
|
|
const selectColumns = columns.data.map((col: any) => col.column_name);
|
|
|
|
// Entity 조인 컬럼 검색을 위한 WHERE 절 구성
|
|
const whereConditions: string[] = [];
|
|
const entitySearchColumns: string[] = [];
|
|
|
|
// Entity 조인 쿼리 생성하여 별칭 매핑 얻기
|
|
const joinQueryResult = entityJoinService.buildJoinQuery(
|
|
tableName,
|
|
joinConfigs,
|
|
selectColumns,
|
|
"", // WHERE 절은 나중에 추가
|
|
options.sortBy
|
|
? `main.${options.sortBy} ${options.sortOrder || "ASC"}`
|
|
: undefined,
|
|
options.size,
|
|
(options.page - 1) * options.size
|
|
);
|
|
|
|
const aliasMap = joinQueryResult.aliasMap;
|
|
logger.info(
|
|
`🔧 [검색] 별칭 매핑 사용: ${Array.from(aliasMap.entries())
|
|
.map(([table, alias]) => `${table}→${alias}`)
|
|
.join(", ")}`
|
|
);
|
|
|
|
if (options.search) {
|
|
for (const [key, value] of Object.entries(options.search)) {
|
|
// 검색값 추출 (객체 형태일 수 있음)
|
|
let searchValue = value;
|
|
if (
|
|
typeof value === "object" &&
|
|
value !== null &&
|
|
"value" in value
|
|
) {
|
|
searchValue = value.value;
|
|
}
|
|
|
|
// 빈 값이면 스킵
|
|
if (
|
|
searchValue === "__ALL__" ||
|
|
searchValue === "" ||
|
|
searchValue === null ||
|
|
searchValue === undefined
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const safeValue = String(searchValue).replace(/'/g, "''");
|
|
|
|
// 🆕 테이블명.컬럼명 형식 처리 (예: item_info.item_name)
|
|
if (key.includes(".")) {
|
|
const [refTable, refColumn] = key.split(".");
|
|
|
|
// aliasMap에서 별칭 찾기 (테이블명:소스컬럼 형식)
|
|
let foundAlias: string | undefined;
|
|
for (const [aliasKey, alias] of aliasMap.entries()) {
|
|
if (aliasKey.startsWith(`${refTable}:`)) {
|
|
foundAlias = alias;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (foundAlias) {
|
|
whereConditions.push(
|
|
`${foundAlias}.${refColumn}::text ILIKE '%${safeValue}%'`
|
|
);
|
|
entitySearchColumns.push(`${key} (${refTable}.${refColumn})`);
|
|
logger.info(
|
|
`🎯 조인 테이블 검색: ${key} → ${refTable}.${refColumn} LIKE '%${safeValue}%' (별칭: ${foundAlias})`
|
|
);
|
|
} else {
|
|
logger.warn(
|
|
`⚠️ 조인 테이블 검색 실패: ${key} - 별칭을 찾을 수 없음`
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const joinConfig = joinConfigs.find(
|
|
(config) => config.aliasColumn === key
|
|
);
|
|
|
|
if (joinConfig) {
|
|
// 기본 Entity 조인 컬럼인 경우: 조인된 테이블의 표시 컬럼에서 검색
|
|
const aliasKey = `${joinConfig.referenceTable}:${joinConfig.sourceColumn}`;
|
|
const alias = aliasMap.get(aliasKey);
|
|
whereConditions.push(
|
|
`${alias}.${joinConfig.displayColumn} ILIKE '%${safeValue}%'`
|
|
);
|
|
entitySearchColumns.push(
|
|
`${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})`
|
|
);
|
|
logger.info(
|
|
`🎯 Entity 조인 검색: ${key} → ${joinConfig.referenceTable}.${joinConfig.displayColumn} LIKE '%${safeValue}%' (별칭: ${alias})`
|
|
);
|
|
} else if (key === "writer_dept_code") {
|
|
// writer_dept_code: user_info.dept_code에서 검색
|
|
const userAliasKey = Array.from(aliasMap.keys()).find((k) =>
|
|
k.startsWith("user_info:")
|
|
);
|
|
const userAlias = userAliasKey
|
|
? aliasMap.get(userAliasKey)
|
|
: undefined;
|
|
if (userAlias) {
|
|
whereConditions.push(
|
|
`${userAlias}.dept_code ILIKE '%${safeValue}%'`
|
|
);
|
|
entitySearchColumns.push(`${key} (user_info.dept_code)`);
|
|
logger.info(
|
|
`🎯 추가 Entity 조인 검색: ${key} → user_info.dept_code LIKE '%${safeValue}%' (별칭: ${userAlias})`
|
|
);
|
|
}
|
|
} else if (key === "company_code_status") {
|
|
// company_code_status: company_info.status에서 검색
|
|
const companyAliasKey = Array.from(aliasMap.keys()).find((k) =>
|
|
k.startsWith("company_info:")
|
|
);
|
|
const companyAlias = companyAliasKey
|
|
? aliasMap.get(companyAliasKey)
|
|
: undefined;
|
|
if (companyAlias) {
|
|
whereConditions.push(
|
|
`${companyAlias}.status ILIKE '%${safeValue}%'`
|
|
);
|
|
entitySearchColumns.push(`${key} (company_info.status)`);
|
|
logger.info(
|
|
`🎯 추가 Entity 조인 검색: ${key} → company_info.status LIKE '%${safeValue}%' (별칭: ${companyAlias})`
|
|
);
|
|
}
|
|
} else {
|
|
// 일반 컬럼인 경우: 메인 테이블에서 검색
|
|
whereConditions.push(`main.${key} ILIKE '%${safeValue}%'`);
|
|
logger.info(
|
|
`🔍 일반 컬럼 검색: ${key} → main.${key} LIKE '%${safeValue}%'`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const whereClause = whereConditions.join(" AND ");
|
|
const orderBy = options.sortBy
|
|
? `main.${options.sortBy} ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
|
|
: "";
|
|
|
|
// 페이징 계산
|
|
const offset = (options.page - 1) * options.size;
|
|
|
|
// SQL JOIN 쿼리 실행
|
|
const joinResult = await this.executeJoinQuery(
|
|
tableName,
|
|
joinConfigs,
|
|
selectColumns,
|
|
whereClause,
|
|
orderBy,
|
|
options.size,
|
|
offset,
|
|
startTime
|
|
);
|
|
|
|
return joinResult;
|
|
} catch (joinError) {
|
|
logger.error(
|
|
`Entity 조인 검색 실패, 캐시 방식으로 폴백: ${tableName}`,
|
|
joinError
|
|
);
|
|
|
|
// Entity 조인 검색 실패 시 Entity 조인 컬럼을 제외한 검색 조건으로 캐시 방식 사용
|
|
const fallbackOptions = { ...options };
|
|
if (options.search) {
|
|
const filteredSearch: Record<string, any> = {};
|
|
|
|
// Entity 조인 컬럼을 제외한 검색 조건만 유지
|
|
for (const [key, value] of Object.entries(options.search)) {
|
|
const isEntityColumn = joinConfigs.some(
|
|
(config) => config.aliasColumn === key
|
|
);
|
|
if (!isEntityColumn) {
|
|
filteredSearch[key] = value;
|
|
}
|
|
}
|
|
|
|
fallbackOptions.search = filteredSearch;
|
|
logger.info(
|
|
`🔄 Entity 조인 에러 시 검색 조건 필터링: ${Object.keys(filteredSearch).join(", ")}`
|
|
);
|
|
}
|
|
|
|
basicResult = await this.getTableData(tableName, {
|
|
...fallbackOptions,
|
|
companyCode: options.companyCode,
|
|
});
|
|
}
|
|
} else {
|
|
// Entity 조인 컬럼 검색이 없는 경우 기존 캐시 방식 사용
|
|
basicResult = await this.getTableData(tableName, {
|
|
...options,
|
|
companyCode: options.companyCode,
|
|
});
|
|
}
|
|
|
|
// Entity 값들을 캐시에서 룩업하여 변환
|
|
const enhancedData = basicResult.data.map((row: any) => {
|
|
const enhancedRow = { ...row };
|
|
|
|
for (const config of joinConfigs) {
|
|
const sourceValue = row[config.sourceColumn];
|
|
if (sourceValue) {
|
|
const lookupValue = referenceCacheService.getLookupValue(
|
|
config.referenceTable,
|
|
config.referenceColumn,
|
|
config.displayColumn || config.displayColumns[0],
|
|
String(sourceValue)
|
|
);
|
|
|
|
// null이나 undefined인 경우 빈 문자열로 설정
|
|
enhancedRow[config.aliasColumn] = lookupValue || "";
|
|
} else {
|
|
// sourceValue가 없는 경우도 빈 문자열로 설정
|
|
enhancedRow[config.aliasColumn] = "";
|
|
}
|
|
}
|
|
|
|
return enhancedRow;
|
|
});
|
|
|
|
const queryTime = Date.now() - startTime;
|
|
const cacheHitRate = referenceCacheService.getOverallCacheHitRate();
|
|
|
|
return {
|
|
data: enhancedData,
|
|
total: basicResult.total,
|
|
page: options.page,
|
|
size: options.size,
|
|
totalPages: Math.ceil(basicResult.total / options.size),
|
|
entityJoinInfo: {
|
|
joinConfigs,
|
|
strategy: "cache_lookup",
|
|
performance: {
|
|
queryTime,
|
|
cacheHitRate,
|
|
},
|
|
},
|
|
};
|
|
} catch (error) {
|
|
logger.error("캐시 룩업 실행 실패", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* WHERE 절 구성 (고급 검색 지원)
|
|
*/
|
|
private async buildWhereClause(
|
|
tableName: string,
|
|
search?: Record<string, any>
|
|
): Promise<string> {
|
|
if (!search || Object.keys(search).length === 0) {
|
|
return "";
|
|
}
|
|
|
|
const conditions: string[] = [];
|
|
|
|
for (const [columnName, value] of Object.entries(search)) {
|
|
if (
|
|
value === undefined ||
|
|
value === null ||
|
|
value === "" ||
|
|
value === "__ALL__"
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
// 🆕 조인 테이블 컬럼 검색 처리 (예: item_info.item_name)
|
|
if (columnName.includes(".")) {
|
|
const [refTable, refColumn] = columnName.split(".");
|
|
|
|
// 검색값 추출
|
|
let searchValue = value;
|
|
if (typeof value === "object" && value !== null && "value" in value) {
|
|
searchValue = value.value;
|
|
}
|
|
|
|
if (
|
|
searchValue === "__ALL__" ||
|
|
searchValue === "" ||
|
|
searchValue === null
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
// 🔍 column_labels에서 해당 엔티티 설정 찾기
|
|
// 예: item_info 테이블을 참조하는 컬럼 찾기 (item_code → item_info)
|
|
const entityColumnResult = await query<{
|
|
column_name: string;
|
|
reference_table: string;
|
|
reference_column: string;
|
|
}>(
|
|
`SELECT column_name, reference_table, reference_column
|
|
FROM column_labels
|
|
WHERE table_name = $1
|
|
AND input_type = 'entity'
|
|
AND reference_table = $2
|
|
LIMIT 1`,
|
|
[tableName, refTable]
|
|
);
|
|
|
|
if (entityColumnResult.length > 0) {
|
|
// 조인 별칭 생성 (entityJoinService.ts와 동일한 패턴: 테이블명 앞 3글자)
|
|
const joinAlias = refTable.substring(0, 3);
|
|
|
|
// 조인 테이블 컬럼으로 검색 조건 생성
|
|
const safeValue = String(searchValue).replace(/'/g, "''");
|
|
const condition = `${joinAlias}.${refColumn}::text ILIKE '%${safeValue}%'`;
|
|
|
|
logger.info(`🔍 조인 테이블 검색 조건: ${condition}`);
|
|
conditions.push(condition);
|
|
} else {
|
|
logger.warn(
|
|
`⚠️ 조인 테이블 검색 실패: ${columnName} - 엔티티 설정을 찾을 수 없음`
|
|
);
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
// 고급 검색 조건 구성
|
|
const searchCondition = await this.buildAdvancedSearchCondition(
|
|
tableName,
|
|
columnName,
|
|
value,
|
|
1 // paramIndex는 실제로는 사용되지 않음 (직접 값 삽입)
|
|
);
|
|
|
|
if (searchCondition) {
|
|
// SQL 인젝션 방지를 위해 값을 직접 삽입하는 대신 안전한 방식 사용
|
|
let condition = searchCondition.whereClause;
|
|
|
|
// 파라미터를 실제 값으로 치환 (안전한 방식)
|
|
searchCondition.values.forEach((val, index) => {
|
|
const paramPlaceholder = `$${index + 1}`;
|
|
if (typeof val === "string") {
|
|
condition = condition.replace(
|
|
paramPlaceholder,
|
|
`'${val.replace(/'/g, "''")}'`
|
|
);
|
|
} else if (typeof val === "number") {
|
|
condition = condition.replace(paramPlaceholder, val.toString());
|
|
} else {
|
|
condition = condition.replace(
|
|
paramPlaceholder,
|
|
`'${String(val).replace(/'/g, "''")}'`
|
|
);
|
|
}
|
|
});
|
|
|
|
// main. 접두사 추가 (조인 쿼리용)
|
|
condition = condition.replace(
|
|
new RegExp(`\\b${columnName}\\b`, "g"),
|
|
`main.${columnName}`
|
|
);
|
|
conditions.push(condition);
|
|
}
|
|
} catch (error) {
|
|
logger.warn(`검색 조건 구성 실패: ${columnName}`, error);
|
|
// 폴백: 기본 문자열 검색
|
|
if (typeof value === "string") {
|
|
conditions.push(
|
|
`main.${columnName}::text ILIKE '%${value.replace(/'/g, "''")}%'`
|
|
);
|
|
} else {
|
|
conditions.push(
|
|
`main.${columnName} = '${String(value).replace(/'/g, "''")}'`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return conditions.length > 0 ? conditions.join(" AND ") : "";
|
|
}
|
|
|
|
/**
|
|
* 테이블의 컬럼 정보 조회
|
|
*/
|
|
async getTableColumns(tableName: string): Promise<{
|
|
data: Array<{ column_name: string; data_type: string }>;
|
|
}> {
|
|
try {
|
|
const columns = await query<{
|
|
column_name: string;
|
|
data_type: string;
|
|
}>(
|
|
`SELECT column_name, data_type
|
|
FROM information_schema.columns
|
|
WHERE table_name = $1
|
|
ORDER BY ordinal_position`,
|
|
[tableName]
|
|
);
|
|
|
|
return { data: columns };
|
|
} catch (error) {
|
|
logger.error(`테이블 컬럼 조회 실패: ${tableName}`, error);
|
|
throw new Error(
|
|
`테이블 컬럼 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 참조 테이블의 표시 컬럼 목록 조회
|
|
*/
|
|
async getReferenceTableColumns(tableName: string): Promise<
|
|
Array<{
|
|
columnName: string;
|
|
displayName: string;
|
|
dataType: string;
|
|
}>
|
|
> {
|
|
return await entityJoinService.getReferenceTableColumns(tableName);
|
|
}
|
|
|
|
/**
|
|
* 컬럼 라벨 정보 업데이트 (display_column 추가)
|
|
*/
|
|
async updateColumnLabel(
|
|
tableName: string,
|
|
columnName: string,
|
|
updates: Partial<ColumnLabels>
|
|
): Promise<void> {
|
|
try {
|
|
logger.info(`컬럼 라벨 업데이트: ${tableName}.${columnName}`);
|
|
|
|
await query(
|
|
`INSERT INTO column_labels (
|
|
table_name, column_name, column_label, web_type, detail_settings,
|
|
description, display_order, is_visible, code_category, code_value,
|
|
reference_table, reference_column, created_date, updated_date
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW(), NOW())
|
|
ON CONFLICT (table_name, column_name)
|
|
DO UPDATE SET
|
|
column_label = EXCLUDED.column_label,
|
|
web_type = EXCLUDED.web_type,
|
|
detail_settings = EXCLUDED.detail_settings,
|
|
description = EXCLUDED.description,
|
|
display_order = EXCLUDED.display_order,
|
|
is_visible = EXCLUDED.is_visible,
|
|
code_category = EXCLUDED.code_category,
|
|
code_value = EXCLUDED.code_value,
|
|
reference_table = EXCLUDED.reference_table,
|
|
reference_column = EXCLUDED.reference_column,
|
|
updated_date = NOW()`,
|
|
[
|
|
tableName,
|
|
columnName,
|
|
updates.columnLabel || columnName,
|
|
updates.webType || "text",
|
|
updates.detailSettings,
|
|
updates.description,
|
|
updates.displayOrder || 0,
|
|
updates.isVisible !== false,
|
|
updates.codeCategory,
|
|
updates.codeValue,
|
|
updates.referenceTable,
|
|
updates.referenceColumn,
|
|
]
|
|
);
|
|
|
|
logger.info(`컬럼 라벨 업데이트 완료: ${tableName}.${columnName}`);
|
|
} catch (error) {
|
|
logger.error(
|
|
`컬럼 라벨 업데이트 실패: ${tableName}.${columnName}`,
|
|
error
|
|
);
|
|
throw new Error(
|
|
`컬럼 라벨 업데이트 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
);
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// 🎯 하이브리드 조인 전략 구현
|
|
// ========================================
|
|
|
|
/**
|
|
* 하이브리드 조인 실행: 일부는 조인, 일부는 캐시 룩업
|
|
*/
|
|
private async executeHybridJoin(
|
|
tableName: string,
|
|
joinConfigs: EntityJoinConfig[],
|
|
selectColumns: string[],
|
|
whereClause: string,
|
|
orderBy: string,
|
|
limit: number,
|
|
offset: number,
|
|
startTime: number
|
|
): Promise<EntityJoinResponse> {
|
|
try {
|
|
logger.info(`🔀 하이브리드 조인 실행: ${tableName}`);
|
|
|
|
// 각 조인 설정을 캐시 가능 여부에 따라 분류
|
|
const { cacheableJoins, dbJoins } =
|
|
await this.categorizeJoins(joinConfigs);
|
|
|
|
console.log(
|
|
`📋 캐시 조인: ${cacheableJoins.length}개, DB 조인: ${dbJoins.length}개`
|
|
);
|
|
|
|
// DB 조인이 있는 경우: 조인 쿼리 실행 후 캐시 룩업 적용
|
|
if (dbJoins.length > 0) {
|
|
return await this.executeJoinThenCache(
|
|
tableName,
|
|
dbJoins,
|
|
cacheableJoins,
|
|
selectColumns,
|
|
whereClause,
|
|
orderBy,
|
|
limit,
|
|
offset,
|
|
startTime
|
|
);
|
|
}
|
|
// 모든 조인이 캐시 가능한 경우: 기본 쿼리 + 캐시 룩업
|
|
else {
|
|
// whereClause에서 company_code 추출 (멀티테넌시 필터)
|
|
const companyCodeMatch = whereClause.match(
|
|
/main\.company_code\s*=\s*'([^']+)'/
|
|
);
|
|
const companyCode = companyCodeMatch ? companyCodeMatch[1] : undefined;
|
|
|
|
return await this.executeCachedLookup(
|
|
tableName,
|
|
cacheableJoins,
|
|
{
|
|
page: Math.floor(offset / limit) + 1,
|
|
size: limit,
|
|
search: {},
|
|
companyCode,
|
|
},
|
|
startTime
|
|
);
|
|
}
|
|
} catch (error) {
|
|
logger.error("하이브리드 조인 실행 실패", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 조인 설정을 캐시 가능 여부에 따라 분류
|
|
*/
|
|
private async categorizeJoins(joinConfigs: EntityJoinConfig[]): Promise<{
|
|
cacheableJoins: EntityJoinConfig[];
|
|
dbJoins: EntityJoinConfig[];
|
|
}> {
|
|
const cacheableJoins: EntityJoinConfig[] = [];
|
|
const dbJoins: EntityJoinConfig[] = [];
|
|
|
|
// 🔒 멀티테넌시: 회사별 데이터 테이블은 캐시 사용 불가 (company_code 필터링 필요)
|
|
const companySpecificTables = [
|
|
"supplier_mng",
|
|
"customer_mng",
|
|
"item_info",
|
|
"dept_info",
|
|
// 필요시 추가
|
|
];
|
|
|
|
for (const config of joinConfigs) {
|
|
// table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인
|
|
if (config.referenceTable === "table_column_category_values") {
|
|
dbJoins.push(config);
|
|
console.log(`🔗 DB 조인 (특수 조건): ${config.referenceTable}`);
|
|
continue;
|
|
}
|
|
|
|
// 🔒 회사별 데이터 테이블은 캐시 사용 불가 (멀티테넌시)
|
|
if (companySpecificTables.includes(config.referenceTable)) {
|
|
dbJoins.push(config);
|
|
console.log(`🔗 DB 조인 (멀티테넌시): ${config.referenceTable}`);
|
|
continue;
|
|
}
|
|
|
|
// 캐시 가능성 확인
|
|
const cachedData = await referenceCacheService.getCachedReference(
|
|
config.referenceTable,
|
|
config.referenceColumn,
|
|
config.displayColumn || config.displayColumns[0]
|
|
);
|
|
|
|
if (cachedData && cachedData.size > 0) {
|
|
cacheableJoins.push(config);
|
|
console.log(
|
|
`📋 캐시 사용: ${config.referenceTable} (${cachedData.size}건)`
|
|
);
|
|
} else {
|
|
dbJoins.push(config);
|
|
console.log(`🔗 DB 조인: ${config.referenceTable}`);
|
|
}
|
|
}
|
|
|
|
return { cacheableJoins, dbJoins };
|
|
}
|
|
|
|
/**
|
|
* DB 조인 실행 후 캐시 룩업 적용
|
|
*/
|
|
private async executeJoinThenCache(
|
|
tableName: string,
|
|
dbJoins: EntityJoinConfig[],
|
|
cacheableJoins: EntityJoinConfig[],
|
|
selectColumns: string[],
|
|
whereClause: string,
|
|
orderBy: string,
|
|
limit: number,
|
|
offset: number,
|
|
startTime: number
|
|
): Promise<EntityJoinResponse> {
|
|
// 1. DB 조인 먼저 실행
|
|
const joinResult = await this.executeJoinQuery(
|
|
tableName,
|
|
dbJoins,
|
|
selectColumns,
|
|
whereClause,
|
|
orderBy,
|
|
limit,
|
|
offset,
|
|
startTime
|
|
);
|
|
|
|
// 2. 캐시 가능한 조인들을 결과에 추가 적용
|
|
if (cacheableJoins.length > 0) {
|
|
const enhancedData = await this.applyCacheLookupToData(
|
|
joinResult.data,
|
|
cacheableJoins
|
|
);
|
|
|
|
return {
|
|
...joinResult,
|
|
data: enhancedData,
|
|
entityJoinInfo: {
|
|
...joinResult.entityJoinInfo!,
|
|
strategy: "hybrid",
|
|
performance: {
|
|
...joinResult.entityJoinInfo!.performance,
|
|
cacheHitRate: await this.calculateCacheHitRate(cacheableJoins),
|
|
hybridBreakdown: {
|
|
dbJoins: dbJoins.length,
|
|
cacheJoins: cacheableJoins.length,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
return joinResult;
|
|
}
|
|
|
|
/**
|
|
* 데이터에 캐시 룩업 적용
|
|
*/
|
|
private async applyCacheLookupToData(
|
|
data: any[],
|
|
cacheableJoins: EntityJoinConfig[]
|
|
): Promise<any[]> {
|
|
const enhancedData = [...data];
|
|
|
|
for (const config of cacheableJoins) {
|
|
const cachedData = await referenceCacheService.getCachedReference(
|
|
config.referenceTable,
|
|
config.referenceColumn,
|
|
config.displayColumn || config.displayColumns[0]
|
|
);
|
|
|
|
if (cachedData) {
|
|
enhancedData.forEach((row) => {
|
|
const keyValue = row[config.sourceColumn];
|
|
if (keyValue) {
|
|
const lookupValue = cachedData.get(String(keyValue));
|
|
// null이나 undefined인 경우 빈 문자열로 설정
|
|
row[config.aliasColumn] = lookupValue || "";
|
|
} else {
|
|
// sourceValue가 없는 경우도 빈 문자열로 설정
|
|
row[config.aliasColumn] = "";
|
|
}
|
|
});
|
|
} else {
|
|
// 캐시가 없는 경우 모든 행에 빈 문자열 설정
|
|
enhancedData.forEach((row) => {
|
|
row[config.aliasColumn] = "";
|
|
});
|
|
}
|
|
}
|
|
|
|
return enhancedData;
|
|
}
|
|
|
|
/**
|
|
* 캐시 적중률 계산
|
|
*/
|
|
private async calculateCacheHitRate(
|
|
cacheableJoins: EntityJoinConfig[]
|
|
): Promise<number> {
|
|
if (cacheableJoins.length === 0) return 0;
|
|
|
|
let totalHitRate = 0;
|
|
for (const config of cacheableJoins) {
|
|
const hitRate = referenceCacheService.getCacheHitRate(
|
|
config.referenceTable,
|
|
config.referenceColumn,
|
|
config.displayColumn || config.displayColumns[0]
|
|
);
|
|
totalHitRate += hitRate;
|
|
}
|
|
|
|
return totalHitRate / cacheableJoins.length;
|
|
}
|
|
|
|
/**
|
|
* 테이블 스키마 정보 조회 (컬럼 존재 여부 검증용)
|
|
*/
|
|
async getTableSchema(tableName: string): Promise<ColumnTypeInfo[]> {
|
|
try {
|
|
logger.info(`테이블 스키마 정보 조회: ${tableName}`);
|
|
|
|
const rawColumns = await query<any>(
|
|
`SELECT
|
|
column_name as "columnName",
|
|
column_name as "displayName",
|
|
data_type as "dataType",
|
|
udt_name as "dbType",
|
|
is_nullable as "isNullable",
|
|
column_default as "defaultValue",
|
|
character_maximum_length as "maxLength",
|
|
numeric_precision as "numericPrecision",
|
|
numeric_scale as "numericScale",
|
|
CASE
|
|
WHEN column_name IN (
|
|
SELECT column_name FROM information_schema.key_column_usage
|
|
WHERE table_name = $1 AND constraint_name LIKE '%_pkey'
|
|
) THEN true
|
|
ELSE false
|
|
END as "isPrimaryKey"
|
|
FROM information_schema.columns
|
|
WHERE table_name = $1
|
|
AND table_schema = 'public'
|
|
ORDER BY ordinal_position`,
|
|
[tableName]
|
|
);
|
|
|
|
const columns: ColumnTypeInfo[] = rawColumns.map((col) => ({
|
|
tableName: tableName,
|
|
columnName: col.columnName,
|
|
displayName: col.displayName,
|
|
dataType: col.dataType,
|
|
dbType: col.dbType,
|
|
webType: "text", // 기본값
|
|
inputType: "direct",
|
|
detailSettings: "{}",
|
|
description: "", // 필수 필드 추가
|
|
isNullable: col.isNullable,
|
|
isPrimaryKey: col.isPrimaryKey,
|
|
defaultValue: col.defaultValue,
|
|
maxLength: col.maxLength ? Number(col.maxLength) : undefined,
|
|
numericPrecision: col.numericPrecision
|
|
? Number(col.numericPrecision)
|
|
: undefined,
|
|
numericScale: col.numericScale ? Number(col.numericScale) : undefined,
|
|
displayOrder: 0,
|
|
isVisible: true,
|
|
}));
|
|
|
|
logger.info(
|
|
`테이블 스키마 조회 완료: ${tableName}, ${columns.length}개 컬럼`
|
|
);
|
|
return columns;
|
|
} catch (error) {
|
|
logger.error(`테이블 스키마 조회 실패: ${tableName}`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 테이블 존재 여부 확인
|
|
*/
|
|
async checkTableExists(tableName: string): Promise<boolean> {
|
|
try {
|
|
logger.info(`테이블 존재 여부 확인: ${tableName}`);
|
|
|
|
const result = await query<any>(
|
|
`SELECT EXISTS (
|
|
SELECT 1 FROM information_schema.tables
|
|
WHERE table_name = $1
|
|
AND table_schema = 'public'
|
|
AND table_type = 'BASE TABLE'
|
|
) as "exists"`,
|
|
[tableName]
|
|
);
|
|
|
|
const exists = result[0]?.exists || false;
|
|
logger.info(`테이블 존재 여부: ${tableName} = ${exists}`);
|
|
return exists;
|
|
} catch (error) {
|
|
logger.error(`테이블 존재 여부 확인 실패: ${tableName}`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 컬럼 입력타입 정보 조회 (화면관리 연동용)
|
|
* @param companyCode - 회사 코드 (멀티테넌시)
|
|
*/
|
|
async getColumnInputTypes(
|
|
tableName: string,
|
|
companyCode: string
|
|
): Promise<ColumnTypeInfo[]> {
|
|
try {
|
|
logger.info(
|
|
`컬럼 입력타입 정보 조회: ${tableName}, company: ${companyCode}`
|
|
);
|
|
|
|
// table_type_columns에서 입력타입 정보 조회
|
|
// 회사별 설정 우선, 없으면 기본 설정(*) fallback
|
|
const rawInputTypes = await query<any>(
|
|
`SELECT DISTINCT ON (ttc.column_name)
|
|
ttc.column_name as "columnName",
|
|
COALESCE(cl.column_label, ttc.column_name) as "displayName",
|
|
ttc.input_type as "inputType",
|
|
COALESCE(ttc.detail_settings::jsonb, '{}'::jsonb) as "detailSettings",
|
|
ttc.is_nullable as "isNullable",
|
|
ic.data_type as "dataType",
|
|
ttc.company_code as "companyCode"
|
|
FROM table_type_columns ttc
|
|
LEFT JOIN column_labels cl
|
|
ON ttc.table_name = cl.table_name AND ttc.column_name = cl.column_name
|
|
LEFT JOIN information_schema.columns ic
|
|
ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name
|
|
WHERE ttc.table_name = $1
|
|
AND ttc.company_code IN ($2, '*')
|
|
ORDER BY ttc.column_name,
|
|
CASE WHEN ttc.company_code = $2 THEN 0 ELSE 1 END,
|
|
ttc.display_order`,
|
|
[tableName, companyCode]
|
|
);
|
|
|
|
// category_column_mapping 테이블 존재 여부 확인
|
|
const tableExistsResult = await query<any>(
|
|
`SELECT EXISTS (
|
|
SELECT FROM information_schema.tables
|
|
WHERE table_name = 'category_column_mapping'
|
|
) as table_exists`
|
|
);
|
|
const mappingTableExists = tableExistsResult[0]?.table_exists === true;
|
|
|
|
// 카테고리 컬럼의 경우, 매핑된 메뉴 목록 조회
|
|
// 회사별 설정 우선, 없으면 기본 설정(*) fallback
|
|
let categoryMappings: Map<string, number[]> = new Map();
|
|
if (mappingTableExists) {
|
|
logger.info("카테고리 매핑 조회 시작", { tableName, companyCode });
|
|
|
|
const mappings = await query<any>(
|
|
`SELECT DISTINCT ON (logical_column_name, menu_objid)
|
|
logical_column_name as "columnName",
|
|
menu_objid as "menuObjid"
|
|
FROM category_column_mapping
|
|
WHERE table_name = $1
|
|
AND company_code IN ($2, '*')
|
|
ORDER BY logical_column_name, menu_objid,
|
|
CASE WHEN company_code = $2 THEN 0 ELSE 1 END`,
|
|
[tableName, companyCode]
|
|
);
|
|
|
|
logger.info("카테고리 매핑 조회 완료", {
|
|
tableName,
|
|
companyCode,
|
|
mappingCount: mappings.length,
|
|
mappings: mappings,
|
|
});
|
|
|
|
mappings.forEach((m: any) => {
|
|
if (!categoryMappings.has(m.columnName)) {
|
|
categoryMappings.set(m.columnName, []);
|
|
}
|
|
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
|
|
});
|
|
|
|
logger.info("categoryMappings Map 생성 완료", {
|
|
size: categoryMappings.size,
|
|
entries: Array.from(categoryMappings.entries()),
|
|
});
|
|
} else {
|
|
logger.warn("category_column_mapping 테이블이 존재하지 않음");
|
|
}
|
|
|
|
const inputTypes: ColumnTypeInfo[] = rawInputTypes.map((col) => {
|
|
const baseInfo = {
|
|
tableName: tableName,
|
|
columnName: col.columnName,
|
|
displayName: col.displayName,
|
|
dataType: col.dataType || "varchar",
|
|
inputType: col.inputType,
|
|
detailSettings: col.detailSettings,
|
|
description: "", // 필수 필드 추가
|
|
isNullable: col.isNullable === "Y" ? "Y" : "N", // 🔥 FIX: string 타입으로 변환
|
|
isPrimaryKey: false,
|
|
displayOrder: 0,
|
|
isVisible: true,
|
|
};
|
|
|
|
// 카테고리 타입인 경우 categoryMenus 추가
|
|
if (
|
|
col.inputType === "category" &&
|
|
categoryMappings.has(col.columnName)
|
|
) {
|
|
const menus = categoryMappings.get(col.columnName);
|
|
logger.info(`✅ 컬럼 ${col.columnName}에 카테고리 메뉴 추가`, {
|
|
menus,
|
|
});
|
|
return {
|
|
...baseInfo,
|
|
categoryMenus: menus,
|
|
};
|
|
}
|
|
|
|
if (col.inputType === "category") {
|
|
logger.warn(`⚠️ 카테고리 컬럼 ${col.columnName}에 매핑 없음`);
|
|
}
|
|
|
|
return baseInfo;
|
|
});
|
|
|
|
logger.info(
|
|
`컬럼 입력타입 정보 조회 완료: ${tableName}, company: ${companyCode}, ${inputTypes.length}개 컬럼`
|
|
);
|
|
return inputTypes;
|
|
} catch (error) {
|
|
logger.error(
|
|
`컬럼 입력타입 정보 조회 실패: ${tableName}, company: ${companyCode}`,
|
|
error
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 레거시 지원: 컬럼 웹타입 정보 조회
|
|
* @deprecated getColumnInputTypes 사용 권장
|
|
*/
|
|
async getColumnWebTypes(
|
|
tableName: string,
|
|
companyCode: string
|
|
): Promise<ColumnTypeInfo[]> {
|
|
logger.warn(
|
|
`레거시 메서드 사용: getColumnWebTypes → getColumnInputTypes 사용 권장`
|
|
);
|
|
return this.getColumnInputTypes(tableName, companyCode); // 🔥 FIX: companyCode 파라미터 추가
|
|
}
|
|
|
|
/**
|
|
* 데이터베이스 연결 상태 확인
|
|
*/
|
|
async checkDatabaseConnection(): Promise<{
|
|
connected: boolean;
|
|
message: string;
|
|
}> {
|
|
try {
|
|
logger.info("데이터베이스 연결 상태 확인");
|
|
|
|
// 간단한 쿼리로 연결 테스트
|
|
const result = await query<any>(`SELECT 1 as "test"`);
|
|
|
|
if (result && result.length > 0) {
|
|
logger.info("데이터베이스 연결 성공");
|
|
return {
|
|
connected: true,
|
|
message: "데이터베이스에 성공적으로 연결되었습니다.",
|
|
};
|
|
} else {
|
|
logger.warn("데이터베이스 연결 응답 없음");
|
|
return {
|
|
connected: false,
|
|
message: "데이터베이스 연결 응답이 없습니다.",
|
|
};
|
|
}
|
|
} catch (error) {
|
|
logger.error("데이터베이스 연결 확인 실패:", error);
|
|
return {
|
|
connected: false,
|
|
message: `데이터베이스 연결 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 데이터 타입으로부터 웹타입 추론
|
|
*/
|
|
private inferWebType(dataType: string): WebType {
|
|
// 통합 타입 매핑에서 import
|
|
const { DB_TYPE_TO_WEB_TYPE } = require("../types/unified-web-types");
|
|
|
|
const lowerType = dataType.toLowerCase();
|
|
|
|
// 정확한 매핑 우선 확인
|
|
if (DB_TYPE_TO_WEB_TYPE[lowerType]) {
|
|
return DB_TYPE_TO_WEB_TYPE[lowerType];
|
|
}
|
|
|
|
// 부분 문자열 매칭 (더 정교한 규칙)
|
|
for (const [dbType, webType] of Object.entries(DB_TYPE_TO_WEB_TYPE)) {
|
|
if (
|
|
lowerType.includes(dbType.toLowerCase()) ||
|
|
dbType.toLowerCase().includes(lowerType)
|
|
) {
|
|
return webType as WebType;
|
|
}
|
|
}
|
|
|
|
// 추가 정밀 매핑
|
|
if (lowerType.includes("int") && !lowerType.includes("point")) {
|
|
return "number";
|
|
} else if (lowerType.includes("numeric") || lowerType.includes("decimal")) {
|
|
return "decimal";
|
|
} else if (
|
|
lowerType.includes("timestamp") ||
|
|
lowerType.includes("datetime")
|
|
) {
|
|
return "datetime";
|
|
} else if (lowerType.includes("date")) {
|
|
return "date";
|
|
} else if (lowerType.includes("time")) {
|
|
return "datetime";
|
|
} else if (lowerType.includes("bool")) {
|
|
return "checkbox";
|
|
} else if (
|
|
lowerType.includes("char") ||
|
|
lowerType.includes("text") ||
|
|
lowerType.includes("varchar")
|
|
) {
|
|
return lowerType.includes("text") ? "textarea" : "text";
|
|
}
|
|
|
|
// 기본값
|
|
return "text";
|
|
}
|
|
|
|
// ========================================
|
|
// 🎯 테이블 로그 시스템
|
|
// ========================================
|
|
|
|
/**
|
|
* 로그 테이블 생성
|
|
*/
|
|
async createLogTable(
|
|
tableName: string,
|
|
pkColumn: { columnName: string; dataType: string },
|
|
userId?: string
|
|
): Promise<void> {
|
|
try {
|
|
const logTableName = `${tableName}_log`;
|
|
const triggerFuncName = `${tableName}_log_trigger_func`;
|
|
const triggerName = `${tableName}_audit_trigger`;
|
|
|
|
logger.info(`로그 테이블 생성 시작: ${logTableName}`);
|
|
|
|
// 로그 테이블 DDL 생성
|
|
const logTableDDL = this.generateLogTableDDL(
|
|
logTableName,
|
|
tableName,
|
|
pkColumn.columnName,
|
|
pkColumn.dataType
|
|
);
|
|
|
|
// 트리거 함수 DDL 생성
|
|
const triggerFuncDDL = this.generateTriggerFunctionDDL(
|
|
triggerFuncName,
|
|
logTableName,
|
|
tableName,
|
|
pkColumn.columnName
|
|
);
|
|
|
|
// 트리거 DDL 생성
|
|
const triggerDDL = this.generateTriggerDDL(
|
|
triggerName,
|
|
tableName,
|
|
triggerFuncName
|
|
);
|
|
|
|
// 트랜잭션으로 실행
|
|
await transaction(async (client) => {
|
|
// 1. 로그 테이블 생성
|
|
await client.query(logTableDDL);
|
|
logger.info(`로그 테이블 생성 완료: ${logTableName}`);
|
|
|
|
// 2. 트리거 함수 생성
|
|
await client.query(triggerFuncDDL);
|
|
logger.info(`트리거 함수 생성 완료: ${triggerFuncName}`);
|
|
|
|
// 3. 트리거 생성
|
|
await client.query(triggerDDL);
|
|
logger.info(`트리거 생성 완료: ${triggerName}`);
|
|
|
|
// 4. 로그 설정 저장
|
|
await client.query(
|
|
`INSERT INTO table_log_config (
|
|
original_table_name, log_table_name, trigger_name,
|
|
trigger_function_name, created_by
|
|
) VALUES ($1, $2, $3, $4, $5)`,
|
|
[tableName, logTableName, triggerName, triggerFuncName, userId]
|
|
);
|
|
logger.info(`로그 설정 저장 완료: ${tableName}`);
|
|
});
|
|
|
|
logger.info(`로그 테이블 생성 완료: ${logTableName}`);
|
|
} catch (error) {
|
|
logger.error(`로그 테이블 생성 실패: ${tableName}`, error);
|
|
throw new Error(
|
|
`로그 테이블 생성 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 로그 테이블 DDL 생성
|
|
*/
|
|
private generateLogTableDDL(
|
|
logTableName: string,
|
|
originalTableName: string,
|
|
pkColumnName: string,
|
|
pkDataType: string
|
|
): string {
|
|
return `
|
|
CREATE TABLE ${logTableName} (
|
|
log_id SERIAL PRIMARY KEY,
|
|
operation_type VARCHAR(10) NOT NULL,
|
|
original_id VARCHAR(100),
|
|
changed_column VARCHAR(100),
|
|
old_value TEXT,
|
|
new_value TEXT,
|
|
changed_by VARCHAR(50),
|
|
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
ip_address VARCHAR(50),
|
|
user_agent TEXT,
|
|
full_row_before JSONB,
|
|
full_row_after JSONB
|
|
);
|
|
|
|
CREATE INDEX idx_${logTableName}_original_id ON ${logTableName}(original_id);
|
|
CREATE INDEX idx_${logTableName}_changed_at ON ${logTableName}(changed_at);
|
|
CREATE INDEX idx_${logTableName}_operation ON ${logTableName}(operation_type);
|
|
|
|
COMMENT ON TABLE ${logTableName} IS '${originalTableName} 테이블 변경 이력';
|
|
COMMENT ON COLUMN ${logTableName}.operation_type IS '작업 유형 (INSERT/UPDATE/DELETE)';
|
|
COMMENT ON COLUMN ${logTableName}.original_id IS '원본 테이블 PK 값';
|
|
COMMENT ON COLUMN ${logTableName}.changed_column IS '변경된 컬럼명';
|
|
COMMENT ON COLUMN ${logTableName}.old_value IS '변경 전 값';
|
|
COMMENT ON COLUMN ${logTableName}.new_value IS '변경 후 값';
|
|
COMMENT ON COLUMN ${logTableName}.changed_by IS '변경자 ID';
|
|
COMMENT ON COLUMN ${logTableName}.changed_at IS '변경 시각';
|
|
COMMENT ON COLUMN ${logTableName}.ip_address IS '변경 요청 IP';
|
|
COMMENT ON COLUMN ${logTableName}.full_row_before IS '변경 전 전체 행 (JSON)';
|
|
COMMENT ON COLUMN ${logTableName}.full_row_after IS '변경 후 전체 행 (JSON)';
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* 트리거 함수 DDL 생성
|
|
*/
|
|
private generateTriggerFunctionDDL(
|
|
funcName: string,
|
|
logTableName: string,
|
|
originalTableName: string,
|
|
pkColumnName: string
|
|
): string {
|
|
return `
|
|
CREATE OR REPLACE FUNCTION ${funcName}()
|
|
RETURNS TRIGGER AS $$
|
|
DECLARE
|
|
v_column_name TEXT;
|
|
v_old_value TEXT;
|
|
v_new_value TEXT;
|
|
v_user_id VARCHAR(50);
|
|
v_ip_address VARCHAR(50);
|
|
BEGIN
|
|
v_user_id := current_setting('app.user_id', TRUE);
|
|
v_ip_address := current_setting('app.ip_address', TRUE);
|
|
|
|
IF (TG_OP = 'INSERT') THEN
|
|
EXECUTE format(
|
|
'INSERT INTO ${logTableName} (operation_type, original_id, changed_by, ip_address, full_row_after)
|
|
VALUES ($1, ($2).%I, $3, $4, $5)',
|
|
'${pkColumnName}'
|
|
)
|
|
USING 'INSERT', NEW, v_user_id, v_ip_address, row_to_json(NEW)::jsonb;
|
|
RETURN NEW;
|
|
|
|
ELSIF (TG_OP = 'UPDATE') THEN
|
|
FOR v_column_name IN
|
|
SELECT column_name
|
|
FROM information_schema.columns
|
|
WHERE table_name = '${originalTableName}'
|
|
AND table_schema = 'public'
|
|
LOOP
|
|
EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name)
|
|
INTO v_old_value, v_new_value
|
|
USING OLD, NEW;
|
|
|
|
IF v_old_value IS DISTINCT FROM v_new_value THEN
|
|
EXECUTE format(
|
|
'INSERT INTO ${logTableName} (operation_type, original_id, changed_column, old_value, new_value, changed_by, ip_address, full_row_before, full_row_after)
|
|
VALUES ($1, ($2).%I, $3, $4, $5, $6, $7, $8, $9)',
|
|
'${pkColumnName}'
|
|
)
|
|
USING 'UPDATE', NEW, v_column_name, v_old_value, v_new_value, v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb;
|
|
END IF;
|
|
END LOOP;
|
|
RETURN NEW;
|
|
|
|
ELSIF (TG_OP = 'DELETE') THEN
|
|
EXECUTE format(
|
|
'INSERT INTO ${logTableName} (operation_type, original_id, changed_by, ip_address, full_row_before)
|
|
VALUES ($1, ($2).%I, $3, $4, $5)',
|
|
'${pkColumnName}'
|
|
)
|
|
USING 'DELETE', OLD, v_user_id, v_ip_address, row_to_json(OLD)::jsonb;
|
|
RETURN OLD;
|
|
END IF;
|
|
|
|
RETURN NULL;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* 트리거 DDL 생성
|
|
*/
|
|
private generateTriggerDDL(
|
|
triggerName: string,
|
|
tableName: string,
|
|
funcName: string
|
|
): string {
|
|
return `
|
|
CREATE TRIGGER ${triggerName}
|
|
AFTER INSERT OR UPDATE OR DELETE ON ${tableName}
|
|
FOR EACH ROW EXECUTE FUNCTION ${funcName}();
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* 로그 설정 조회
|
|
*/
|
|
async getLogConfig(tableName: string): Promise<{
|
|
originalTableName: string;
|
|
logTableName: string;
|
|
triggerName: string;
|
|
triggerFunctionName: string;
|
|
isActive: string;
|
|
createdAt: Date;
|
|
createdBy: string;
|
|
} | null> {
|
|
try {
|
|
logger.info(`로그 설정 조회: ${tableName}`);
|
|
|
|
const result = await queryOne<{
|
|
original_table_name: string;
|
|
log_table_name: string;
|
|
trigger_name: string;
|
|
trigger_function_name: string;
|
|
is_active: string;
|
|
created_at: Date;
|
|
created_by: string;
|
|
}>(
|
|
`SELECT
|
|
original_table_name, log_table_name, trigger_name,
|
|
trigger_function_name, is_active, created_at, created_by
|
|
FROM table_log_config
|
|
WHERE original_table_name = $1`,
|
|
[tableName]
|
|
);
|
|
|
|
if (!result) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
originalTableName: result.original_table_name,
|
|
logTableName: result.log_table_name,
|
|
triggerName: result.trigger_name,
|
|
triggerFunctionName: result.trigger_function_name,
|
|
isActive: result.is_active,
|
|
createdAt: result.created_at,
|
|
createdBy: result.created_by,
|
|
};
|
|
} catch (error) {
|
|
logger.error(`로그 설정 조회 실패: ${tableName}`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 로그 데이터 조회
|
|
*/
|
|
async getLogData(
|
|
tableName: string,
|
|
options: {
|
|
page: number;
|
|
size: number;
|
|
operationType?: string;
|
|
startDate?: string;
|
|
endDate?: string;
|
|
changedBy?: string;
|
|
originalId?: string;
|
|
}
|
|
): Promise<{
|
|
data: any[];
|
|
total: number;
|
|
page: number;
|
|
size: number;
|
|
totalPages: number;
|
|
}> {
|
|
try {
|
|
const logTableName = `${tableName}_log`;
|
|
const offset = (options.page - 1) * options.size;
|
|
|
|
logger.info(`로그 데이터 조회: ${logTableName}`, options);
|
|
|
|
// WHERE 조건 구성
|
|
const whereConditions: string[] = [];
|
|
const values: any[] = [];
|
|
let paramIndex = 1;
|
|
|
|
if (options.operationType) {
|
|
whereConditions.push(`operation_type = $${paramIndex}`);
|
|
values.push(options.operationType);
|
|
paramIndex++;
|
|
}
|
|
|
|
if (options.startDate) {
|
|
whereConditions.push(`changed_at >= $${paramIndex}::timestamp`);
|
|
values.push(options.startDate);
|
|
paramIndex++;
|
|
}
|
|
|
|
if (options.endDate) {
|
|
whereConditions.push(`changed_at <= $${paramIndex}::timestamp`);
|
|
values.push(options.endDate);
|
|
paramIndex++;
|
|
}
|
|
|
|
if (options.changedBy) {
|
|
whereConditions.push(`changed_by = $${paramIndex}`);
|
|
values.push(options.changedBy);
|
|
paramIndex++;
|
|
}
|
|
|
|
if (options.originalId) {
|
|
whereConditions.push(`original_id::text = $${paramIndex}`);
|
|
values.push(options.originalId);
|
|
paramIndex++;
|
|
}
|
|
|
|
const whereClause =
|
|
whereConditions.length > 0
|
|
? `WHERE ${whereConditions.join(" AND ")}`
|
|
: "";
|
|
|
|
// 전체 개수 조회
|
|
const countQuery = `SELECT COUNT(*) as count FROM ${logTableName} ${whereClause}`;
|
|
const countResult = await query<any>(countQuery, values);
|
|
const total = parseInt(countResult[0].count);
|
|
|
|
// 데이터 조회
|
|
const dataQuery = `
|
|
SELECT * FROM ${logTableName}
|
|
${whereClause}
|
|
ORDER BY changed_at DESC
|
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
|
`;
|
|
|
|
const data = await query<any>(dataQuery, [
|
|
...values,
|
|
options.size,
|
|
offset,
|
|
]);
|
|
|
|
const totalPages = Math.ceil(total / options.size);
|
|
|
|
logger.info(
|
|
`로그 데이터 조회 완료: ${logTableName}, 총 ${total}건, ${data.length}개 반환`
|
|
);
|
|
|
|
return {
|
|
data,
|
|
total,
|
|
page: options.page,
|
|
size: options.size,
|
|
totalPages,
|
|
};
|
|
} catch (error) {
|
|
logger.error(`로그 데이터 조회 실패: ${tableName}`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 로그 테이블 활성화/비활성화
|
|
*/
|
|
async toggleLogTable(tableName: string, isActive: boolean): Promise<void> {
|
|
try {
|
|
const logConfig = await this.getLogConfig(tableName);
|
|
if (!logConfig) {
|
|
throw new Error(`로그 설정을 찾을 수 없습니다: ${tableName}`);
|
|
}
|
|
|
|
logger.info(
|
|
`로그 테이블 ${isActive ? "활성화" : "비활성화"}: ${tableName}`
|
|
);
|
|
|
|
await transaction(async (client) => {
|
|
// 트리거 활성화/비활성화
|
|
if (isActive) {
|
|
await client.query(
|
|
`ALTER TABLE ${tableName} ENABLE TRIGGER ${logConfig.triggerName}`
|
|
);
|
|
} else {
|
|
await client.query(
|
|
`ALTER TABLE ${tableName} DISABLE TRIGGER ${logConfig.triggerName}`
|
|
);
|
|
}
|
|
|
|
// 설정 업데이트
|
|
await client.query(
|
|
`UPDATE table_log_config
|
|
SET is_active = $1, updated_at = NOW()
|
|
WHERE original_table_name = $2`,
|
|
[isActive ? "Y" : "N", tableName]
|
|
);
|
|
});
|
|
|
|
logger.info(
|
|
`로그 테이블 ${isActive ? "활성화" : "비활성화"} 완료: ${tableName}`
|
|
);
|
|
} catch (error) {
|
|
logger.error(
|
|
`로그 테이블 ${isActive ? "활성화" : "비활성화"} 실패: ${tableName}`,
|
|
error
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 테이블에 특정 컬럼이 존재하는지 확인
|
|
*/
|
|
async hasColumn(tableName: string, columnName: string): Promise<boolean> {
|
|
try {
|
|
const result = await query<any>(
|
|
`SELECT column_name
|
|
FROM information_schema.columns
|
|
WHERE table_name = $1 AND column_name = $2`,
|
|
[tableName, columnName]
|
|
);
|
|
return result.length > 0;
|
|
} catch (error) {
|
|
logger.error(
|
|
`컬럼 존재 여부 확인 실패: ${tableName}.${columnName}`,
|
|
error
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 두 테이블 간의 엔티티 관계 자동 감지
|
|
* column_labels에서 엔티티 타입 설정을 기반으로 테이블 간 관계를 찾습니다.
|
|
*
|
|
* @param leftTable 좌측 테이블명
|
|
* @param rightTable 우측 테이블명
|
|
* @returns 감지된 엔티티 관계 배열
|
|
*/
|
|
async detectTableEntityRelations(
|
|
leftTable: string,
|
|
rightTable: string
|
|
): Promise<Array<{
|
|
leftColumn: string;
|
|
rightColumn: string;
|
|
direction: "left_to_right" | "right_to_left";
|
|
inputType: string;
|
|
displayColumn?: string;
|
|
}>> {
|
|
try {
|
|
logger.info(`두 테이블 간 엔티티 관계 감지 시작: ${leftTable} <-> ${rightTable}`);
|
|
|
|
const relations: Array<{
|
|
leftColumn: string;
|
|
rightColumn: string;
|
|
direction: "left_to_right" | "right_to_left";
|
|
inputType: string;
|
|
displayColumn?: string;
|
|
}> = [];
|
|
|
|
// 1. 우측 테이블에서 좌측 테이블을 참조하는 엔티티 컬럼 찾기
|
|
// 예: right_table의 customer_id -> left_table(customer_mng)의 customer_code
|
|
const rightToLeftRels = await query<{
|
|
column_name: string;
|
|
reference_column: string;
|
|
input_type: string;
|
|
display_column: string | null;
|
|
}>(
|
|
`SELECT column_name, reference_column, input_type, display_column
|
|
FROM column_labels
|
|
WHERE table_name = $1
|
|
AND input_type IN ('entity', 'category')
|
|
AND reference_table = $2
|
|
AND reference_column IS NOT NULL
|
|
AND reference_column != ''`,
|
|
[rightTable, leftTable]
|
|
);
|
|
|
|
for (const rel of rightToLeftRels) {
|
|
relations.push({
|
|
leftColumn: rel.reference_column,
|
|
rightColumn: rel.column_name,
|
|
direction: "right_to_left",
|
|
inputType: rel.input_type,
|
|
displayColumn: rel.display_column || undefined,
|
|
});
|
|
}
|
|
|
|
// 2. 좌측 테이블에서 우측 테이블을 참조하는 엔티티 컬럼 찾기
|
|
// 예: left_table의 item_id -> right_table(item_info)의 item_number
|
|
const leftToRightRels = await query<{
|
|
column_name: string;
|
|
reference_column: string;
|
|
input_type: string;
|
|
display_column: string | null;
|
|
}>(
|
|
`SELECT column_name, reference_column, input_type, display_column
|
|
FROM column_labels
|
|
WHERE table_name = $1
|
|
AND input_type IN ('entity', 'category')
|
|
AND reference_table = $2
|
|
AND reference_column IS NOT NULL
|
|
AND reference_column != ''`,
|
|
[leftTable, rightTable]
|
|
);
|
|
|
|
for (const rel of leftToRightRels) {
|
|
relations.push({
|
|
leftColumn: rel.column_name,
|
|
rightColumn: rel.reference_column,
|
|
direction: "left_to_right",
|
|
inputType: rel.input_type,
|
|
displayColumn: rel.display_column || undefined,
|
|
});
|
|
}
|
|
|
|
logger.info(`엔티티 관계 감지 완료: ${relations.length}개 발견`);
|
|
relations.forEach((rel, idx) => {
|
|
logger.info(` ${idx + 1}. ${leftTable}.${rel.leftColumn} <-> ${rightTable}.${rel.rightColumn} (${rel.direction})`);
|
|
});
|
|
|
|
return relations;
|
|
} catch (error) {
|
|
logger.error(`엔티티 관계 감지 실패: ${leftTable} <-> ${rightTable}`, error);
|
|
return [];
|
|
}
|
|
}
|
|
}
|