2025-09-30 18:01:57 +09:00
|
|
|
import { query, queryOne, transaction } from "../database/db";
|
2025-08-25 14:08:08 +09:00
|
|
|
import { logger } from "../utils/logger";
|
2025-09-08 14:20:01 +09:00
|
|
|
import { cache, CacheKeys } from "../utils/cache";
|
2025-08-25 14:08:08 +09:00
|
|
|
import {
|
|
|
|
|
TableInfo,
|
|
|
|
|
ColumnTypeInfo,
|
|
|
|
|
ColumnSettings,
|
|
|
|
|
TableLabels,
|
|
|
|
|
ColumnLabels,
|
2025-09-16 15:13:00 +09:00
|
|
|
EntityJoinResponse,
|
|
|
|
|
EntityJoinConfig,
|
2025-08-25 14:08:08 +09:00
|
|
|
} from "../types/tableManagement";
|
2025-09-19 18:43:55 +09:00
|
|
|
import { WebType } from "../types/unified-web-types";
|
2025-09-16 15:13:00 +09:00
|
|
|
import { entityJoinService } from "./entityJoinService";
|
|
|
|
|
import { referenceCacheService } from "./referenceCacheService";
|
2025-08-25 14:08:08 +09:00
|
|
|
|
2025-09-26 01:28:51 +09:00
|
|
|
// 🔧 Prisma 클라이언트 중복 생성 방지 - 기존 인스턴스 재사용
|
2025-08-25 14:08:08 +09:00
|
|
|
|
2025-09-01 11:00:38 +09:00
|
|
|
export class TableManagementService {
|
|
|
|
|
constructor() {}
|
2025-08-25 14:08:08 +09:00
|
|
|
|
2025-09-19 12:19:34 +09:00
|
|
|
/**
|
|
|
|
|
* 컬럼이 코드 타입인지 확인하고 코드 카테고리 반환
|
|
|
|
|
*/
|
|
|
|
|
private async getCodeTypeInfo(
|
|
|
|
|
tableName: string,
|
|
|
|
|
columnName: string
|
|
|
|
|
): Promise<{ isCodeType: boolean; codeCategory?: string }> {
|
|
|
|
|
try {
|
2025-10-14 11:48:04 +09:00
|
|
|
// column_labels 테이블에서 해당 컬럼의 input_type이 'code'인지 확인
|
2025-09-30 18:01:57 +09:00
|
|
|
const result = await query(
|
2025-10-14 11:48:04 +09:00
|
|
|
`SELECT input_type, code_category
|
2025-09-30 18:01:57 +09:00
|
|
|
FROM column_labels
|
|
|
|
|
WHERE table_name = $1
|
|
|
|
|
AND column_name = $2
|
2025-10-14 11:48:04 +09:00
|
|
|
AND input_type = 'code'`,
|
2025-09-30 18:01:57 +09:00
|
|
|
[tableName, columnName]
|
|
|
|
|
);
|
2025-09-19 12:19:34 +09:00
|
|
|
|
|
|
|
|
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 };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-25 14:08:08 +09:00
|
|
|
/**
|
|
|
|
|
* 테이블 목록 조회 (PostgreSQL information_schema 활용)
|
2025-09-01 11:00:38 +09:00
|
|
|
* 메타데이터 조회는 Prisma로 변경 불가
|
2025-08-25 14:08:08 +09:00
|
|
|
*/
|
|
|
|
|
async getTableList(): Promise<TableInfo[]> {
|
|
|
|
|
try {
|
|
|
|
|
logger.info("테이블 목록 조회 시작");
|
|
|
|
|
|
2025-09-08 14:20:01 +09:00
|
|
|
// 캐시에서 먼저 확인
|
|
|
|
|
const cachedTables = cache.get<TableInfo[]>(CacheKeys.TABLE_LIST);
|
|
|
|
|
if (cachedTables) {
|
|
|
|
|
logger.info(`테이블 목록 캐시에서 조회: ${cachedTables.length}개`);
|
|
|
|
|
return cachedTables;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-01 11:00:38 +09:00
|
|
|
// information_schema는 여전히 $queryRaw 사용
|
2025-09-30 18:01:57 +09:00
|
|
|
const rawTables = await query<any>(
|
|
|
|
|
`SELECT
|
2025-08-25 14:08:08 +09:00
|
|
|
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_%'
|
2025-09-30 18:01:57 +09:00
|
|
|
ORDER BY t.table_name`
|
|
|
|
|
);
|
2025-08-25 14:08:08 +09:00
|
|
|
|
2025-09-01 15:37:49 +09:00
|
|
|
// BigInt를 Number로 변환하여 JSON 직렬화 문제 해결
|
|
|
|
|
const tables: TableInfo[] = rawTables.map((table) => ({
|
|
|
|
|
...table,
|
|
|
|
|
columnCount: Number(table.columnCount), // BigInt → Number 변환
|
|
|
|
|
}));
|
|
|
|
|
|
2025-09-08 14:20:01 +09:00
|
|
|
// 캐시에 저장 (10분 TTL)
|
|
|
|
|
cache.set(CacheKeys.TABLE_LIST, tables, 10 * 60 * 1000);
|
|
|
|
|
|
2025-09-01 11:00:38 +09:00
|
|
|
logger.info(`테이블 목록 조회 완료: ${tables.length}개`);
|
|
|
|
|
return tables;
|
2025-08-25 14:08:08 +09:00
|
|
|
} catch (error) {
|
|
|
|
|
logger.error("테이블 목록 조회 중 오류 발생:", error);
|
|
|
|
|
throw new Error(
|
|
|
|
|
`테이블 목록 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-09-08 14:20:01 +09:00
|
|
|
* 테이블 컬럼 정보 조회 (페이지네이션 지원)
|
2025-09-01 11:00:38 +09:00
|
|
|
* 메타데이터 조회는 Prisma로 변경 불가
|
2025-08-25 14:08:08 +09:00
|
|
|
*/
|
2025-09-08 14:20:01 +09:00
|
|
|
async getColumnList(
|
|
|
|
|
tableName: string,
|
|
|
|
|
page: number = 1,
|
2025-11-06 17:01:13 +09:00
|
|
|
size: number = 50,
|
|
|
|
|
companyCode?: string // 🔥 회사 코드 추가
|
2025-09-08 14:20:01 +09:00
|
|
|
): Promise<{
|
|
|
|
|
columns: ColumnTypeInfo[];
|
|
|
|
|
total: number;
|
|
|
|
|
page: number;
|
|
|
|
|
size: number;
|
|
|
|
|
totalPages: number;
|
|
|
|
|
}> {
|
2025-08-25 14:08:08 +09:00
|
|
|
try {
|
2025-09-08 14:20:01 +09:00
|
|
|
logger.info(
|
2025-11-06 17:01:13 +09:00
|
|
|
`컬럼 정보 조회 시작: ${tableName} (page: ${page}, size: ${size}), company: ${companyCode}`
|
2025-09-08 14:20:01 +09:00
|
|
|
);
|
2025-08-25 14:08:08 +09:00
|
|
|
|
2025-11-06 17:01:13 +09:00
|
|
|
// 캐시 키 생성 (companyCode 포함)
|
|
|
|
|
const cacheKey = CacheKeys.TABLE_COLUMNS(tableName, page, size) + `_${companyCode}`;
|
2025-09-08 14:20:01 +09:00
|
|
|
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}개`
|
|
|
|
|
);
|
|
|
|
|
return cachedResult;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 전체 컬럼 수 조회 (캐시 확인)
|
|
|
|
|
let total = cache.get<number>(countCacheKey);
|
|
|
|
|
if (!total) {
|
2025-09-30 18:01:57 +09:00
|
|
|
const totalResult = await query<{ count: bigint }>(
|
|
|
|
|
`SELECT COUNT(*) as count
|
|
|
|
|
FROM information_schema.columns c
|
|
|
|
|
WHERE c.table_name = $1`,
|
|
|
|
|
[tableName]
|
|
|
|
|
);
|
2025-09-08 14:20:01 +09:00
|
|
|
total = Number(totalResult[0].count);
|
|
|
|
|
// 컬럼 수는 자주 변하지 않으므로 30분 캐시
|
|
|
|
|
cache.set(countCacheKey, total, 30 * 60 * 1000);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 페이지네이션 적용한 컬럼 조회
|
|
|
|
|
const offset = (page - 1) * size;
|
2025-11-06 17:01:13 +09:00
|
|
|
|
|
|
|
|
// 🔥 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",
|
|
|
|
|
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]
|
|
|
|
|
);
|
2025-08-25 14:08:08 +09:00
|
|
|
|
2025-09-01 15:37:49 +09:00
|
|
|
// BigInt를 Number로 변환하여 JSON 직렬화 문제 해결
|
|
|
|
|
const columns: ColumnTypeInfo[] = rawColumns.map((column) => ({
|
|
|
|
|
...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,
|
2025-09-19 18:43:55 +09:00
|
|
|
// 자동 매핑: webType이 기본값('text')인 경우 DB 타입에 따라 자동 추론
|
|
|
|
|
webType:
|
|
|
|
|
column.webType === "text"
|
|
|
|
|
? this.inferWebType(column.dataType)
|
|
|
|
|
: column.webType,
|
2025-09-01 15:37:49 +09:00
|
|
|
}));
|
|
|
|
|
|
2025-09-08 14:20:01 +09:00
|
|
|
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;
|
2025-08-25 14:08:08 +09:00
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(`컬럼 정보 조회 중 오류 발생: ${tableName}`, error);
|
|
|
|
|
throw new Error(
|
|
|
|
|
`컬럼 정보 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 테이블이 table_labels에 없으면 자동 추가
|
2025-09-01 11:00:38 +09:00
|
|
|
* Prisma ORM으로 변경
|
2025-08-25 14:08:08 +09:00
|
|
|
*/
|
|
|
|
|
async insertTableIfNotExists(tableName: string): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
logger.info(`테이블 라벨 자동 추가 시작: ${tableName}`);
|
|
|
|
|
|
2025-09-30 18:01:57 +09:00
|
|
|
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, ""]
|
|
|
|
|
);
|
2025-08-25 14:08:08 +09:00
|
|
|
|
|
|
|
|
logger.info(`테이블 라벨 자동 추가 완료: ${tableName}`);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(`테이블 라벨 자동 추가 중 오류 발생: ${tableName}`, error);
|
|
|
|
|
throw new Error(
|
|
|
|
|
`테이블 라벨 자동 추가 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-08 14:20:01 +09:00
|
|
|
/**
|
|
|
|
|
* 테이블 라벨 업데이트
|
|
|
|
|
*/
|
|
|
|
|
async updateTableLabel(
|
|
|
|
|
tableName: string,
|
|
|
|
|
displayName: string,
|
|
|
|
|
description?: string
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
logger.info(`테이블 라벨 업데이트 시작: ${tableName}`);
|
|
|
|
|
|
|
|
|
|
// table_labels 테이블에 UPSERT
|
2025-09-30 18:01:57 +09:00
|
|
|
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 || ""]
|
|
|
|
|
);
|
2025-09-08 14:20:01 +09:00
|
|
|
|
|
|
|
|
// 캐시 무효화
|
|
|
|
|
cache.delete(CacheKeys.TABLE_LIST);
|
|
|
|
|
|
|
|
|
|
logger.info(`테이블 라벨 업데이트 완료: ${tableName}`);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error("테이블 라벨 업데이트 중 오류 발생:", error);
|
|
|
|
|
throw new Error(
|
|
|
|
|
`테이블 라벨 업데이트 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-25 14:08:08 +09:00
|
|
|
/**
|
|
|
|
|
* 컬럼 설정 업데이트 (UPSERT 방식)
|
2025-09-01 11:00:38 +09:00
|
|
|
* Prisma ORM으로 변경
|
2025-08-25 14:08:08 +09:00
|
|
|
*/
|
|
|
|
|
async updateColumnSettings(
|
|
|
|
|
tableName: string,
|
|
|
|
|
columnName: string,
|
2025-11-06 17:01:13 +09:00
|
|
|
settings: ColumnSettings,
|
|
|
|
|
companyCode: string // 🔥 회사 코드 추가
|
2025-08-25 14:08:08 +09:00
|
|
|
): Promise<void> {
|
|
|
|
|
try {
|
2025-11-06 17:01:13 +09:00
|
|
|
logger.info(`컬럼 설정 업데이트 시작: ${tableName}.${columnName}, company: ${companyCode}`);
|
2025-08-25 14:08:08 +09:00
|
|
|
|
|
|
|
|
// 테이블이 table_labels에 없으면 자동 추가
|
|
|
|
|
await this.insertTableIfNotExists(tableName);
|
|
|
|
|
|
2025-09-01 11:00:38 +09:00
|
|
|
// column_labels 업데이트 또는 생성
|
2025-09-30 18:01:57 +09:00
|
|
|
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,
|
|
|
|
|
]
|
|
|
|
|
);
|
2025-08-25 14:08:08 +09:00
|
|
|
|
2025-11-06 17:01:13 +09:00
|
|
|
// 🔥 table_type_columns도 업데이트 (멀티테넌시 지원)
|
|
|
|
|
if (settings.inputType) {
|
|
|
|
|
// detailSettings가 문자열이면 파싱, 객체면 그대로 사용
|
|
|
|
|
let parsedDetailSettings = settings.detailSettings;
|
|
|
|
|
if (typeof settings.detailSettings === 'string') {
|
|
|
|
|
try {
|
|
|
|
|
parsedDetailSettings = JSON.parse(settings.detailSettings);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
logger.warn(`detailSettings 파싱 실패, 그대로 사용: ${settings.detailSettings}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.updateColumnInputType(
|
|
|
|
|
tableName,
|
|
|
|
|
columnName,
|
2025-11-07 10:18:34 +09:00
|
|
|
settings.inputType as string,
|
2025-11-06 17:01:13 +09:00
|
|
|
companyCode,
|
|
|
|
|
parsedDetailSettings
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-15 15:38:48 +09:00
|
|
|
// 캐시 무효화 - 해당 테이블의 컬럼 캐시 삭제
|
|
|
|
|
cache.deleteByPattern(`table_columns:${tableName}:`);
|
|
|
|
|
cache.delete(CacheKeys.TABLE_COLUMN_COUNT(tableName));
|
|
|
|
|
|
2025-08-25 14:08:08 +09:00
|
|
|
logger.info(`컬럼 설정 업데이트 완료: ${tableName}.${columnName}`);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(
|
|
|
|
|
`컬럼 설정 업데이트 중 오류 발생: ${tableName}.${columnName}`,
|
|
|
|
|
error
|
|
|
|
|
);
|
|
|
|
|
throw new Error(
|
|
|
|
|
`컬럼 설정 업데이트 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 전체 컬럼 설정 일괄 업데이트
|
2025-09-01 11:00:38 +09:00
|
|
|
* Prisma 트랜잭션으로 변경
|
2025-08-25 14:08:08 +09:00
|
|
|
*/
|
|
|
|
|
async updateAllColumnSettings(
|
|
|
|
|
tableName: string,
|
2025-11-06 17:01:13 +09:00
|
|
|
columnSettings: ColumnSettings[],
|
|
|
|
|
companyCode: string // 🔥 회사 코드 추가
|
2025-08-25 14:08:08 +09:00
|
|
|
): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
logger.info(
|
2025-11-06 17:01:13 +09:00
|
|
|
`전체 컬럼 설정 일괄 업데이트 시작: ${tableName}, ${columnSettings.length}개, company: ${companyCode}`
|
2025-08-25 14:08:08 +09:00
|
|
|
);
|
|
|
|
|
|
2025-09-30 18:01:57 +09:00
|
|
|
// Raw Query 트랜잭션 사용
|
|
|
|
|
await transaction(async (client) => {
|
2025-08-25 14:08:08 +09:00
|
|
|
// 테이블이 table_labels에 없으면 자동 추가
|
|
|
|
|
await this.insertTableIfNotExists(tableName);
|
|
|
|
|
|
|
|
|
|
// 각 컬럼 설정을 순차적으로 업데이트
|
|
|
|
|
for (const columnSetting of columnSettings) {
|
2025-09-01 11:48:12 +09:00
|
|
|
// columnName은 실제 DB 컬럼명을 유지해야 함
|
|
|
|
|
const columnName = columnSetting.columnName;
|
2025-08-25 14:08:08 +09:00
|
|
|
if (columnName) {
|
|
|
|
|
await this.updateColumnSettings(
|
|
|
|
|
tableName,
|
|
|
|
|
columnName,
|
2025-11-06 17:01:13 +09:00
|
|
|
columnSetting,
|
|
|
|
|
companyCode // 🔥 회사 코드 전달
|
2025-08-25 14:08:08 +09:00
|
|
|
);
|
2025-09-01 11:48:12 +09:00
|
|
|
} else {
|
|
|
|
|
logger.warn(
|
|
|
|
|
`컬럼명이 누락된 설정: ${JSON.stringify(columnSetting)}`
|
|
|
|
|
);
|
2025-08-25 14:08:08 +09:00
|
|
|
}
|
|
|
|
|
}
|
2025-09-01 11:00:38 +09:00
|
|
|
});
|
2025-08-25 14:08:08 +09:00
|
|
|
|
2025-09-15 15:38:48 +09:00
|
|
|
// 캐시 무효화 - 해당 테이블의 컬럼 캐시 삭제
|
|
|
|
|
cache.deleteByPattern(`table_columns:${tableName}:`);
|
|
|
|
|
cache.delete(CacheKeys.TABLE_COLUMN_COUNT(tableName));
|
|
|
|
|
|
2025-11-06 17:01:13 +09:00
|
|
|
logger.info(`전체 컬럼 설정 일괄 업데이트 완료: ${tableName}, company: ${companyCode}`);
|
2025-08-25 14:08:08 +09:00
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(
|
|
|
|
|
`전체 컬럼 설정 일괄 업데이트 중 오류 발생: ${tableName}`,
|
|
|
|
|
error
|
|
|
|
|
);
|
|
|
|
|
throw new Error(
|
|
|
|
|
`전체 컬럼 설정 일괄 업데이트 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 테이블 라벨 정보 조회
|
2025-09-01 11:00:38 +09:00
|
|
|
* Prisma ORM으로 변경
|
2025-08-25 14:08:08 +09:00
|
|
|
*/
|
|
|
|
|
async getTableLabels(tableName: string): Promise<TableLabels | null> {
|
|
|
|
|
try {
|
|
|
|
|
logger.info(`테이블 라벨 정보 조회 시작: ${tableName}`);
|
|
|
|
|
|
2025-09-30 18:01:57 +09:00
|
|
|
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]
|
|
|
|
|
);
|
2025-09-01 11:00:38 +09:00
|
|
|
|
|
|
|
|
if (!tableLabel) {
|
2025-08-25 14:08:08 +09:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-01 11:00:38 +09:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
|
2025-08-25 14:08:08 +09:00
|
|
|
logger.info(`테이블 라벨 정보 조회 완료: ${tableName}`);
|
2025-09-01 11:00:38 +09:00
|
|
|
return result;
|
2025-08-25 14:08:08 +09:00
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(`테이블 라벨 정보 조회 중 오류 발생: ${tableName}`, error);
|
|
|
|
|
throw new Error(
|
|
|
|
|
`테이블 라벨 정보 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 컬럼 라벨 정보 조회
|
2025-09-01 11:00:38 +09:00
|
|
|
* Prisma ORM으로 변경
|
2025-08-25 14:08:08 +09:00
|
|
|
*/
|
|
|
|
|
async getColumnLabels(
|
|
|
|
|
tableName: string,
|
|
|
|
|
columnName: string
|
|
|
|
|
): Promise<ColumnLabels | null> {
|
|
|
|
|
try {
|
|
|
|
|
logger.info(`컬럼 라벨 정보 조회 시작: ${tableName}.${columnName}`);
|
|
|
|
|
|
2025-09-30 18:01:57 +09:00
|
|
|
const columnLabel = await queryOne<{
|
|
|
|
|
id: number;
|
|
|
|
|
table_name: string;
|
|
|
|
|
column_name: string;
|
|
|
|
|
column_label: string | null;
|
2025-10-14 11:48:04 +09:00
|
|
|
input_type: string | null;
|
2025-09-30 18:01:57 +09:00
|
|
|
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;
|
|
|
|
|
}>(
|
2025-10-14 11:48:04 +09:00
|
|
|
`SELECT id, table_name, column_name, column_label, input_type, detail_settings,
|
2025-09-30 18:01:57 +09:00
|
|
|
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]
|
|
|
|
|
);
|
2025-09-01 11:00:38 +09:00
|
|
|
|
|
|
|
|
if (!columnLabel) {
|
2025-08-25 14:08:08 +09:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-01 11:00:38 +09:00
|
|
|
const result: ColumnLabels = {
|
|
|
|
|
id: columnLabel.id,
|
|
|
|
|
tableName: columnLabel.table_name || "",
|
|
|
|
|
columnName: columnLabel.column_name || "",
|
|
|
|
|
columnLabel: columnLabel.column_label || undefined,
|
2025-10-14 11:48:04 +09:00
|
|
|
webType: columnLabel.input_type || undefined,
|
2025-09-01 11:00:38 +09:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
|
2025-08-25 14:08:08 +09:00
|
|
|
logger.info(`컬럼 라벨 정보 조회 완료: ${tableName}.${columnName}`);
|
2025-09-01 11:00:38 +09:00
|
|
|
return result;
|
2025-08-25 14:08:08 +09:00
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(
|
|
|
|
|
`컬럼 라벨 정보 조회 중 오류 발생: ${tableName}.${columnName}`,
|
|
|
|
|
error
|
|
|
|
|
);
|
|
|
|
|
throw new Error(
|
|
|
|
|
`컬럼 라벨 정보 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-09-01 11:48:12 +09:00
|
|
|
|
|
|
|
|
/**
|
2025-10-14 11:48:04 +09:00
|
|
|
* 컬럼 입력 타입 설정 (web_type → input_type 통합)
|
2025-09-01 11:48:12 +09:00
|
|
|
*/
|
|
|
|
|
async updateColumnWebType(
|
|
|
|
|
tableName: string,
|
|
|
|
|
columnName: string,
|
|
|
|
|
webType: string,
|
2025-09-04 14:23:35 +09:00
|
|
|
detailSettings?: Record<string, any>,
|
|
|
|
|
inputType?: string
|
2025-09-01 11:48:12 +09:00
|
|
|
): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
logger.info(
|
2025-10-14 11:48:04 +09:00
|
|
|
`컬럼 입력 타입 설정 시작: ${tableName}.${columnName} = ${webType}`
|
2025-09-01 11:48:12 +09:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 웹 타입별 기본 상세 설정 생성
|
|
|
|
|
const defaultDetailSettings = this.generateDefaultDetailSettings(webType);
|
|
|
|
|
|
|
|
|
|
// 사용자 정의 설정과 기본 설정 병합
|
|
|
|
|
const finalDetailSettings = {
|
|
|
|
|
...defaultDetailSettings,
|
|
|
|
|
...detailSettings,
|
|
|
|
|
};
|
|
|
|
|
|
2025-10-14 11:48:04 +09:00
|
|
|
// column_labels UPSERT로 업데이트 또는 생성 (input_type만 사용)
|
2025-09-30 18:01:57 +09:00
|
|
|
await query(
|
|
|
|
|
`INSERT INTO column_labels (
|
2025-10-14 11:48:04 +09:00
|
|
|
table_name, column_name, input_type, detail_settings, created_date, updated_date
|
|
|
|
|
) VALUES ($1, $2, $3, $4, NOW(), NOW())
|
2025-09-30 18:01:57 +09:00
|
|
|
ON CONFLICT (table_name, column_name)
|
|
|
|
|
DO UPDATE SET
|
2025-10-14 11:48:04 +09:00
|
|
|
input_type = EXCLUDED.input_type,
|
2025-09-30 18:01:57 +09:00
|
|
|
detail_settings = EXCLUDED.detail_settings,
|
|
|
|
|
updated_date = NOW()`,
|
2025-10-14 11:48:04 +09:00
|
|
|
[tableName, columnName, webType, JSON.stringify(finalDetailSettings)]
|
2025-09-30 18:01:57 +09:00
|
|
|
);
|
|
|
|
|
logger.info(
|
2025-10-14 11:48:04 +09:00
|
|
|
`컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${webType}`
|
2025-09-30 18:01:57 +09:00
|
|
|
);
|
2025-09-01 11:48:12 +09:00
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(
|
2025-10-14 11:48:04 +09:00
|
|
|
`컬럼 입력 타입 설정 중 오류 발생: ${tableName}.${columnName}`,
|
2025-09-01 11:48:12 +09:00
|
|
|
error
|
|
|
|
|
);
|
|
|
|
|
throw new Error(
|
2025-10-14 11:48:04 +09:00
|
|
|
`컬럼 입력 타입 설정 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
2025-09-01 11:48:12 +09:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-09-23 10:40:21 +09:00
|
|
|
* 컬럼 입력 타입 설정 (새로운 시스템)
|
2025-11-06 17:01:13 +09:00
|
|
|
* @param companyCode - 회사 코드 (멀티테넌시)
|
2025-09-23 10:40:21 +09:00
|
|
|
*/
|
|
|
|
|
async updateColumnInputType(
|
|
|
|
|
tableName: string,
|
|
|
|
|
columnName: string,
|
|
|
|
|
inputType: string,
|
2025-11-06 17:01:13 +09:00
|
|
|
companyCode: string,
|
2025-09-23 10:40:21 +09:00
|
|
|
detailSettings?: Record<string, any>
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
logger.info(
|
2025-11-06 17:01:13 +09:00
|
|
|
`컬럼 입력 타입 설정 시작: ${tableName}.${columnName} = ${inputType}, company: ${companyCode}`
|
2025-09-23 10:40:21 +09:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 입력 타입별 기본 상세 설정 생성
|
|
|
|
|
const defaultDetailSettings =
|
|
|
|
|
this.generateDefaultInputTypeSettings(inputType);
|
|
|
|
|
|
|
|
|
|
// 사용자 정의 설정과 기본 설정 병합
|
|
|
|
|
const finalDetailSettings = {
|
|
|
|
|
...defaultDetailSettings,
|
|
|
|
|
...detailSettings,
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-06 17:01:13 +09:00
|
|
|
// table_type_columns 테이블에서 업데이트 (company_code 추가)
|
2025-09-30 18:01:57 +09:00
|
|
|
await query(
|
|
|
|
|
`INSERT INTO table_type_columns (
|
2025-09-23 10:40:21 +09:00
|
|
|
table_name, column_name, input_type, detail_settings,
|
2025-11-06 17:01:13 +09:00
|
|
|
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)
|
2025-09-23 10:40:21 +09:00
|
|
|
DO UPDATE SET
|
2025-09-30 18:01:57 +09:00
|
|
|
input_type = EXCLUDED.input_type,
|
|
|
|
|
detail_settings = EXCLUDED.detail_settings,
|
|
|
|
|
updated_date = now()`,
|
2025-11-06 17:01:13 +09:00
|
|
|
[
|
|
|
|
|
tableName,
|
|
|
|
|
columnName,
|
|
|
|
|
inputType,
|
|
|
|
|
JSON.stringify(finalDetailSettings),
|
|
|
|
|
companyCode,
|
|
|
|
|
]
|
2025-09-30 18:01:57 +09:00
|
|
|
);
|
2025-09-23 10:40:21 +09:00
|
|
|
|
|
|
|
|
logger.info(
|
2025-11-06 17:01:13 +09:00
|
|
|
`컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${inputType}, company: ${companyCode}`
|
2025-09-23 10:40:21 +09:00
|
|
|
);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(
|
|
|
|
|
`컬럼 입력 타입 설정 실패: ${tableName}.${columnName}`,
|
|
|
|
|
error
|
|
|
|
|
);
|
|
|
|
|
throw new Error(
|
|
|
|
|
`컬럼 입력 타입 설정 실패: ${error instanceof Error ? error.message : "Unknown 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 사용 권장
|
2025-09-01 11:48:12 +09:00
|
|
|
*/
|
|
|
|
|
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 {};
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-09-03 15:23:12 +09:00
|
|
|
|
2025-09-05 21:52:19 +09:00
|
|
|
/**
|
|
|
|
|
* 파일 데이터 보강 (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") {
|
2025-09-08 10:02:30 +09:00
|
|
|
// 🎯 컴포넌트별 파일 정보 조회
|
|
|
|
|
// 파일 경로에서 컴포넌트 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) {
|
2025-09-05 21:52:19 +09:00
|
|
|
// 파일 정보를 JSON 형태로 저장
|
2025-09-08 10:02:30 +09:00
|
|
|
const totalSize = fileInfos.reduce(
|
|
|
|
|
(sum, file) => sum + (file.size || 0),
|
|
|
|
|
0
|
|
|
|
|
);
|
2025-09-05 21:52:19 +09:00
|
|
|
enrichedRow[fileColumn] = JSON.stringify({
|
2025-09-08 10:02:30 +09:00
|
|
|
files: fileInfos,
|
|
|
|
|
totalCount: fileInfos.length,
|
|
|
|
|
totalSize: totalSize,
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
// 파일이 없으면 빈 상태로 설정
|
2025-09-05 21:52:19 +09:00
|
|
|
enrichedRow[fileColumn] = JSON.stringify({
|
2025-09-08 10:02:30 +09:00
|
|
|
files: [],
|
|
|
|
|
totalCount: 0,
|
|
|
|
|
totalSize: 0,
|
2025-09-05 21:52:19 +09:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return enrichedRow;
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
logger.info(`파일 데이터 보강 완료: ${enrichedData.length}개 행 처리`);
|
|
|
|
|
return enrichedData;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error("파일 데이터 보강 실패:", error);
|
|
|
|
|
return data; // 실패 시 원본 데이터 반환
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-09-08 10:02:30 +09:00
|
|
|
* 파일 경로에서 컴포넌트 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으로 사용하여 파일 구분
|
2025-09-30 18:01:57 +09:00
|
|
|
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]
|
|
|
|
|
);
|
2025-09-08 10:02:30 +09:00
|
|
|
|
|
|
|
|
// 파일 정보 포맷팅
|
|
|
|
|
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 [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 파일 경로로 파일 정보 조회 (기존 메서드 - 호환성 유지)
|
2025-09-05 21:52:19 +09:00
|
|
|
*/
|
|
|
|
|
private async getFileInfoByPath(filePath: string): Promise<any | null> {
|
|
|
|
|
try {
|
2025-09-30 18:01:57 +09:00
|
|
|
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]
|
|
|
|
|
);
|
2025-09-05 21:52:19 +09:00
|
|
|
|
|
|
|
|
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 {
|
2025-09-30 18:01:57 +09:00
|
|
|
const fileColumns = await query<{ column_name: string }>(
|
|
|
|
|
`SELECT column_name
|
|
|
|
|
FROM column_labels
|
|
|
|
|
WHERE table_name = $1 AND web_type = 'file'`,
|
|
|
|
|
[tableName]
|
|
|
|
|
);
|
2025-09-05 21:52:19 +09:00
|
|
|
|
2025-09-30 18:01:57 +09:00
|
|
|
const columnNames = fileColumns.map((col) => col.column_name);
|
2025-09-05 21:52:19 +09:00
|
|
|
logger.info(`파일 타입 컬럼 감지: ${tableName}`, columnNames);
|
|
|
|
|
return columnNames;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.warn(`파일 타입 컬럼 조회 실패: ${tableName}`, error);
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-23 14:26:18 +09:00
|
|
|
/**
|
|
|
|
|
* 고급 검색 조건 구성
|
|
|
|
|
*/
|
|
|
|
|
private async buildAdvancedSearchCondition(
|
|
|
|
|
tableName: string,
|
|
|
|
|
columnName: string,
|
|
|
|
|
value: any,
|
|
|
|
|
paramIndex: number
|
|
|
|
|
): Promise<{
|
|
|
|
|
whereClause: string;
|
|
|
|
|
values: any[];
|
|
|
|
|
paramCount: number;
|
|
|
|
|
} | null> {
|
|
|
|
|
try {
|
|
|
|
|
// "__ALL__" 값이거나 빈 값이면 필터 조건을 적용하지 않음
|
|
|
|
|
if (
|
|
|
|
|
value === "__ALL__" ||
|
|
|
|
|
value === "" ||
|
|
|
|
|
value === null ||
|
|
|
|
|
value === undefined
|
|
|
|
|
) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 컬럼 타입 정보 조회
|
|
|
|
|
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
|
|
|
|
|
|
|
|
|
|
if (!columnInfo) {
|
|
|
|
|
// 컬럼 정보가 없으면 기본 문자열 검색
|
|
|
|
|
return {
|
|
|
|
|
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
|
|
|
|
values: [`%${value}%`],
|
|
|
|
|
paramCount: 1,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const webType = columnInfo.webType;
|
|
|
|
|
|
|
|
|
|
// 웹타입별 검색 조건 구성
|
|
|
|
|
switch (webType) {
|
|
|
|
|
case "date":
|
|
|
|
|
case "datetime":
|
|
|
|
|
return this.buildDateRangeCondition(columnName, value, paramIndex);
|
|
|
|
|
|
|
|
|
|
case "number":
|
|
|
|
|
case "decimal":
|
|
|
|
|
return this.buildNumberRangeCondition(columnName, value, paramIndex);
|
|
|
|
|
|
|
|
|
|
case "code":
|
|
|
|
|
return await this.buildCodeSearchCondition(
|
|
|
|
|
tableName,
|
|
|
|
|
columnName,
|
|
|
|
|
value,
|
|
|
|
|
paramIndex
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "entity":
|
|
|
|
|
return await this.buildEntitySearchCondition(
|
|
|
|
|
tableName,
|
|
|
|
|
columnName,
|
|
|
|
|
value,
|
|
|
|
|
paramIndex
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
// 기본 문자열 검색
|
|
|
|
|
return {
|
|
|
|
|
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
|
|
|
|
values: [`%${value}%`],
|
|
|
|
|
paramCount: 1,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(
|
|
|
|
|
`고급 검색 조건 구성 실패: ${tableName}.${columnName}`,
|
|
|
|
|
error
|
|
|
|
|
);
|
|
|
|
|
// 오류 시 기본 검색으로 폴백
|
|
|
|
|
return {
|
|
|
|
|
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
|
|
|
|
values: [`%${value}%`],
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
if (typeof value === "object" && value !== null) {
|
|
|
|
|
if (value.from) {
|
|
|
|
|
conditions.push(`${columnName} >= $${paramIndex + paramCount}`);
|
|
|
|
|
values.push(value.from);
|
|
|
|
|
paramCount++;
|
|
|
|
|
}
|
|
|
|
|
if (value.to) {
|
|
|
|
|
conditions.push(`${columnName} <= $${paramIndex + paramCount}`);
|
|
|
|
|
values.push(value.to);
|
|
|
|
|
paramCount++;
|
|
|
|
|
}
|
|
|
|
|
} else if (typeof value === "string" && value.trim() !== "") {
|
|
|
|
|
// 단일 날짜 검색 (해당 날짜의 데이터)
|
|
|
|
|
conditions.push(`DATE(${columnName}) = DATE($${paramIndex})`);
|
|
|
|
|
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
|
|
|
|
|
): Promise<{
|
|
|
|
|
whereClause: string;
|
|
|
|
|
values: any[];
|
|
|
|
|
paramCount: number;
|
|
|
|
|
}> {
|
|
|
|
|
try {
|
|
|
|
|
const entityTypeInfo = await this.getEntityTypeInfo(
|
|
|
|
|
tableName,
|
|
|
|
|
columnName
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (!entityTypeInfo.isEntityType || !entityTypeInfo.referenceTable) {
|
|
|
|
|
// 엔티티 타입이 아니면 기본 검색
|
|
|
|
|
return {
|
|
|
|
|
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
|
|
|
|
values: [`%${value}%`],
|
|
|
|
|
paramCount: 1,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (typeof value === "string" && value.trim() !== "") {
|
|
|
|
|
const displayColumn = entityTypeInfo.displayColumn || "name";
|
|
|
|
|
const referenceColumn = entityTypeInfo.referenceColumn || "id";
|
|
|
|
|
|
|
|
|
|
// 참조 테이블의 표시 컬럼으로 검색
|
|
|
|
|
return {
|
|
|
|
|
whereClause: `EXISTS (
|
|
|
|
|
SELECT 1 FROM ${entityTypeInfo.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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 불린 검색 조건 구성
|
|
|
|
|
*/
|
|
|
|
|
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;
|
|
|
|
|
codeCategory?: string;
|
|
|
|
|
referenceTable?: string;
|
|
|
|
|
referenceColumn?: string;
|
|
|
|
|
displayColumn?: string;
|
|
|
|
|
} | null> {
|
|
|
|
|
try {
|
2025-09-30 18:01:57 +09:00
|
|
|
const result = await queryOne<{
|
|
|
|
|
web_type: string | null;
|
|
|
|
|
code_category: string | null;
|
|
|
|
|
reference_table: string | null;
|
|
|
|
|
reference_column: string | null;
|
|
|
|
|
display_column: string | null;
|
|
|
|
|
}>(
|
|
|
|
|
`SELECT web_type, code_category, reference_table, reference_column, display_column
|
|
|
|
|
FROM column_labels
|
|
|
|
|
WHERE table_name = $1 AND column_name = $2
|
|
|
|
|
LIMIT 1`,
|
|
|
|
|
[tableName, columnName]
|
|
|
|
|
);
|
2025-09-23 14:26:18 +09:00
|
|
|
|
|
|
|
|
if (!result) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
webType: result.web_type || "",
|
|
|
|
|
codeCategory: result.code_category || undefined,
|
|
|
|
|
referenceTable: result.reference_table || undefined,
|
|
|
|
|
referenceColumn: result.reference_column || undefined,
|
|
|
|
|
displayColumn: result.display_column || undefined,
|
|
|
|
|
};
|
|
|
|
|
} 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 };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-03 15:23:12 +09:00
|
|
|
/**
|
|
|
|
|
* 테이블 데이터 조회 (페이징 + 검색)
|
|
|
|
|
*/
|
|
|
|
|
async getTableData(
|
2025-09-03 16:38:10 +09:00
|
|
|
tableName: string,
|
2025-09-03 15:23:12 +09:00
|
|
|
options: {
|
|
|
|
|
page: number;
|
|
|
|
|
size: number;
|
|
|
|
|
search?: Record<string, any>;
|
|
|
|
|
sortBy?: string;
|
|
|
|
|
sortOrder?: string;
|
|
|
|
|
}
|
|
|
|
|
): Promise<{
|
|
|
|
|
data: any[];
|
|
|
|
|
total: number;
|
|
|
|
|
page: number;
|
|
|
|
|
size: number;
|
|
|
|
|
totalPages: number;
|
|
|
|
|
}> {
|
|
|
|
|
try {
|
2025-09-03 16:38:10 +09:00
|
|
|
const { page, size, search = {}, sortBy, sortOrder = "asc" } = options;
|
2025-09-03 15:23:12 +09:00
|
|
|
const offset = (page - 1) * size;
|
|
|
|
|
|
|
|
|
|
logger.info(`테이블 데이터 조회: ${tableName}`, options);
|
|
|
|
|
|
2025-09-09 17:42:23 +09:00
|
|
|
// 🎯 파일 타입 컬럼 감지 (비활성화됨 - 자동 파일 컬럼 생성 방지)
|
|
|
|
|
// const fileColumns = await this.getFileTypeColumns(tableName);
|
|
|
|
|
const fileColumns: string[] = []; // 자동 파일 컬럼 생성 비활성화
|
2025-09-05 21:52:19 +09:00
|
|
|
|
2025-09-03 15:23:12 +09:00
|
|
|
// WHERE 조건 구성
|
|
|
|
|
let whereConditions: string[] = [];
|
|
|
|
|
let searchValues: any[] = [];
|
|
|
|
|
let paramIndex = 1;
|
|
|
|
|
|
|
|
|
|
if (search && Object.keys(search).length > 0) {
|
|
|
|
|
for (const [column, value] of Object.entries(search)) {
|
2025-09-03 16:38:10 +09:00
|
|
|
if (value !== null && value !== undefined && value !== "") {
|
2025-09-17 13:49:00 +09:00
|
|
|
// 🎯 추가 조인 컬럼들은 실제 테이블 컬럼이 아니므로 제외
|
|
|
|
|
const additionalJoinColumns = [
|
|
|
|
|
"company_code_status",
|
|
|
|
|
"writer_dept_code",
|
|
|
|
|
];
|
|
|
|
|
if (additionalJoinColumns.includes(column)) {
|
|
|
|
|
logger.info(
|
|
|
|
|
`🔍 추가 조인 컬럼 ${column} 검색 조건에서 제외 (실제 테이블 컬럼 아님)`
|
|
|
|
|
);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-03 15:23:12 +09:00
|
|
|
// 안전한 컬럼명 검증 (SQL 인젝션 방지)
|
2025-09-03 16:38:10 +09:00
|
|
|
const safeColumn = column.replace(/[^a-zA-Z0-9_]/g, "");
|
|
|
|
|
|
2025-09-23 14:26:18 +09:00
|
|
|
// 🎯 고급 필터 처리
|
|
|
|
|
const condition = await this.buildAdvancedSearchCondition(
|
|
|
|
|
tableName,
|
|
|
|
|
safeColumn,
|
|
|
|
|
value,
|
|
|
|
|
paramIndex
|
|
|
|
|
);
|
2025-09-19 12:19:34 +09:00
|
|
|
|
2025-09-23 14:26:18 +09:00
|
|
|
if (condition) {
|
|
|
|
|
whereConditions.push(condition.whereClause);
|
|
|
|
|
searchValues.push(...condition.values);
|
|
|
|
|
paramIndex += condition.paramCount;
|
2025-09-03 15:23:12 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-03 16:38:10 +09:00
|
|
|
const whereClause =
|
|
|
|
|
whereConditions.length > 0
|
|
|
|
|
? `WHERE ${whereConditions.join(" AND ")}`
|
|
|
|
|
: "";
|
2025-09-03 15:23:12 +09:00
|
|
|
|
|
|
|
|
// ORDER BY 조건 구성
|
2025-09-03 16:38:10 +09:00
|
|
|
let orderClause = "";
|
2025-09-03 15:23:12 +09:00
|
|
|
if (sortBy) {
|
2025-09-03 16:38:10 +09:00
|
|
|
const safeSortBy = sortBy.replace(/[^a-zA-Z0-9_]/g, "");
|
|
|
|
|
const safeSortOrder =
|
|
|
|
|
sortOrder.toLowerCase() === "desc" ? "DESC" : "ASC";
|
2025-09-03 15:23:12 +09:00
|
|
|
orderClause = `ORDER BY ${safeSortBy} ${safeSortOrder}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 안전한 테이블명 검증
|
2025-09-03 16:38:10 +09:00
|
|
|
const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, "");
|
2025-09-03 15:23:12 +09:00
|
|
|
|
|
|
|
|
// 전체 개수 조회
|
|
|
|
|
const countQuery = `SELECT COUNT(*) as count FROM ${safeTableName} ${whereClause}`;
|
2025-10-01 15:21:08 +09:00
|
|
|
const countResult = await query<any>(countQuery, searchValues);
|
2025-09-03 15:23:12 +09:00
|
|
|
const total = parseInt(countResult[0].count);
|
|
|
|
|
|
|
|
|
|
// 데이터 조회
|
|
|
|
|
const dataQuery = `
|
|
|
|
|
SELECT * FROM ${safeTableName}
|
|
|
|
|
${whereClause}
|
|
|
|
|
${orderClause}
|
|
|
|
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
|
|
|
|
`;
|
2025-09-03 16:38:10 +09:00
|
|
|
|
2025-11-04 09:41:58 +09:00
|
|
|
logger.info(`🔍 실행할 SQL: ${dataQuery}`);
|
|
|
|
|
logger.info(`🔍 파라미터: ${JSON.stringify([...searchValues, size, offset])}`);
|
|
|
|
|
|
2025-10-01 15:21:08 +09:00
|
|
|
let data = await query<any>(dataQuery, [...searchValues, size, offset]);
|
2025-09-03 15:23:12 +09:00
|
|
|
|
2025-09-05 21:52:19 +09:00
|
|
|
// 🎯 파일 컬럼이 있으면 파일 정보 보강
|
|
|
|
|
if (fileColumns.length > 0) {
|
|
|
|
|
data = await this.enrichFileData(data, fileColumns, safeTableName);
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-03 15:23:12 +09:00
|
|
|
const totalPages = Math.ceil(total / size);
|
|
|
|
|
|
2025-09-03 16:38:10 +09:00
|
|
|
logger.info(
|
|
|
|
|
`테이블 데이터 조회 완료: ${tableName}, 총 ${total}건, ${data.length}개 반환`
|
|
|
|
|
);
|
2025-09-03 15:23:12 +09:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
data,
|
|
|
|
|
total,
|
|
|
|
|
page,
|
|
|
|
|
size,
|
2025-09-03 16:38:10 +09:00
|
|
|
totalPages,
|
2025-09-03 15:23:12 +09:00
|
|
|
};
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(`테이블 데이터 조회 오류: ${tableName}`, error);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-09-03 16:38:10 +09:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 현재 사용자 정보 조회 (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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 테이블에 데이터 추가
|
|
|
|
|
*/
|
|
|
|
|
async addTableData(
|
|
|
|
|
tableName: string,
|
|
|
|
|
data: Record<string, any>
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
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
|
|
|
|
|
`;
|
|
|
|
|
|
2025-09-30 18:01:57 +09:00
|
|
|
const columnInfoResult = (await query(columnInfoQuery, [
|
|
|
|
|
tableName,
|
|
|
|
|
])) as any[];
|
2025-09-03 16:38:10 +09:00
|
|
|
const columnTypeMap = new Map<string, string>();
|
|
|
|
|
|
|
|
|
|
columnInfoResult.forEach((col: any) => {
|
|
|
|
|
columnTypeMap.set(col.column_name, col.data_type);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
logger.info(`컬럼 타입 정보:`, Object.fromEntries(columnTypeMap));
|
|
|
|
|
|
|
|
|
|
// 컬럼명과 값을 분리하고 타입에 맞게 변환
|
|
|
|
|
const columns = Object.keys(data);
|
|
|
|
|
const values = Object.values(data).map((value, index) => {
|
|
|
|
|
const columnName = columns[index];
|
|
|
|
|
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(", ");
|
|
|
|
|
|
2025-09-30 18:01:57 +09:00
|
|
|
const insertQuery = `
|
2025-09-03 16:38:10 +09:00
|
|
|
INSERT INTO "${tableName}" (${columnNames})
|
|
|
|
|
VALUES (${placeholders})
|
|
|
|
|
`;
|
|
|
|
|
|
2025-09-30 18:01:57 +09:00
|
|
|
logger.info(`실행할 쿼리: ${insertQuery}`);
|
2025-09-03 16:38:10 +09:00
|
|
|
logger.info(`쿼리 파라미터:`, values);
|
|
|
|
|
|
2025-09-30 18:01:57 +09:00
|
|
|
await query(insertQuery, values);
|
2025-09-03 16:38:10 +09:00
|
|
|
|
|
|
|
|
logger.info(`테이블 데이터 추가 완료: ${tableName}`);
|
|
|
|
|
} 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
|
|
|
|
|
`;
|
|
|
|
|
|
2025-09-30 18:01:57 +09:00
|
|
|
const columnInfoResult = (await query(columnInfoQuery, [
|
|
|
|
|
tableName,
|
|
|
|
|
])) as any[];
|
2025-09-03 16:38:10 +09:00
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
// SET 절 생성 (수정할 데이터) - 먼저 생성
|
|
|
|
|
const setConditions: string[] = [];
|
|
|
|
|
const setValues: any[] = [];
|
|
|
|
|
let paramIndex = 1;
|
|
|
|
|
|
|
|
|
|
Object.keys(updatedData).forEach((column) => {
|
|
|
|
|
const dataType = columnTypeMap.get(column) || "text";
|
|
|
|
|
setConditions.push(
|
|
|
|
|
`"${column}" = $${paramIndex}::${this.getPostgreSQLType(dataType)}`
|
|
|
|
|
);
|
|
|
|
|
setValues.push(
|
|
|
|
|
this.convertValueForPostgreSQL(updatedData[column], dataType)
|
|
|
|
|
);
|
|
|
|
|
paramIndex++;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 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 쿼리 생성
|
2025-09-30 18:01:57 +09:00
|
|
|
const updateQuery = `
|
2025-09-03 16:38:10 +09:00
|
|
|
UPDATE "${tableName}"
|
|
|
|
|
SET ${setConditions.join(", ")}
|
|
|
|
|
WHERE ${whereConditions.join(" AND ")}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const allValues = [...setValues, ...whereValues];
|
|
|
|
|
|
2025-09-30 18:01:57 +09:00
|
|
|
logger.info(`실행할 UPDATE 쿼리: ${updateQuery}`);
|
2025-09-03 16:38:10 +09:00
|
|
|
logger.info(`쿼리 파라미터:`, allValues);
|
|
|
|
|
|
2025-09-30 18:01:57 +09:00
|
|
|
const result = await query(updateQuery, allValues);
|
2025-09-03 16:38:10 +09:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
`;
|
|
|
|
|
|
2025-09-30 18:01:57 +09:00
|
|
|
const primaryKeys = await query<{ column_name: string }>(
|
|
|
|
|
primaryKeyQuery,
|
|
|
|
|
[tableName]
|
|
|
|
|
);
|
2025-09-03 16:38:10 +09:00
|
|
|
|
|
|
|
|
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}`;
|
|
|
|
|
|
2025-09-30 18:01:57 +09:00
|
|
|
const result = await query(deleteQuery, values);
|
2025-09-03 16:38:10 +09:00
|
|
|
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}`;
|
|
|
|
|
|
2025-09-30 18:01:57 +09:00
|
|
|
const result = await query(deleteQuery, values);
|
2025-09-03 16:38:10 +09:00
|
|
|
deletedCount += Number(result);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
`테이블 데이터 삭제 완료: ${tableName}, ${deletedCount}건 삭제`
|
|
|
|
|
);
|
|
|
|
|
return deletedCount;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(`테이블 데이터 삭제 오류: ${tableName}`, error);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-09-16 15:13:00 +09:00
|
|
|
|
|
|
|
|
// ========================================
|
|
|
|
|
// 🎯 Entity 조인 기능
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Entity 조인이 포함된 데이터 조회
|
|
|
|
|
*/
|
|
|
|
|
async getTableDataWithEntityJoins(
|
|
|
|
|
tableName: string,
|
|
|
|
|
options: {
|
|
|
|
|
page: number;
|
|
|
|
|
size: number;
|
|
|
|
|
search?: Record<string, any>;
|
|
|
|
|
sortBy?: string;
|
|
|
|
|
sortOrder?: string;
|
|
|
|
|
enableEntityJoin?: boolean;
|
2025-09-16 18:02:19 +09:00
|
|
|
additionalJoinColumns?: Array<{
|
|
|
|
|
sourceTable: string;
|
|
|
|
|
sourceColumn: string;
|
|
|
|
|
joinAlias: string;
|
|
|
|
|
}>;
|
2025-09-23 15:58:54 +09:00
|
|
|
screenEntityConfigs?: Record<string, any>; // 화면별 엔티티 설정
|
2025-09-16 15:13:00 +09:00
|
|
|
}
|
|
|
|
|
): 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),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-23 15:58:54 +09:00
|
|
|
// Entity 조인 설정 감지 (화면별 엔티티 설정 전달)
|
2025-09-23 16:23:36 +09:00
|
|
|
let joinConfigs = await entityJoinService.detectEntityJoins(
|
|
|
|
|
tableName,
|
|
|
|
|
options.screenEntityConfigs
|
|
|
|
|
);
|
2025-09-16 18:02:19 +09:00
|
|
|
|
2025-09-24 14:31:46 +09:00
|
|
|
logger.info(
|
|
|
|
|
`🔍 detectEntityJoins 결과: ${joinConfigs.length}개 조인 설정`
|
|
|
|
|
);
|
|
|
|
|
if (joinConfigs.length > 0) {
|
|
|
|
|
joinConfigs.forEach((config, index) => {
|
|
|
|
|
logger.info(
|
|
|
|
|
` 조인 ${index + 1}: ${config.sourceColumn} -> ${config.referenceTable} AS ${config.aliasColumn}`
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-16 18:02:19 +09:00
|
|
|
// 추가 조인 컬럼 정보가 있으면 조인 설정에 추가
|
|
|
|
|
if (
|
|
|
|
|
options.additionalJoinColumns &&
|
|
|
|
|
options.additionalJoinColumns.length > 0
|
|
|
|
|
) {
|
|
|
|
|
logger.info(
|
|
|
|
|
`추가 조인 컬럼 처리: ${options.additionalJoinColumns.length}개`
|
|
|
|
|
);
|
2025-09-24 14:31:46 +09:00
|
|
|
logger.info(
|
|
|
|
|
"📋 전달받은 additionalJoinColumns:",
|
|
|
|
|
options.additionalJoinColumns
|
|
|
|
|
);
|
2025-09-16 18:02:19 +09:00
|
|
|
|
|
|
|
|
for (const additionalColumn of options.additionalJoinColumns) {
|
2025-09-24 15:02:54 +09:00
|
|
|
// 🔍 sourceColumn을 기준으로 기존 조인 설정 찾기 (dept_code로 찾기)
|
2025-09-16 18:02:19 +09:00
|
|
|
const baseJoinConfig = joinConfigs.find(
|
2025-09-24 15:02:54 +09:00
|
|
|
(config) => config.sourceColumn === additionalColumn.sourceColumn
|
2025-09-16 18:02:19 +09:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (baseJoinConfig) {
|
2025-09-24 10:33:54 +09:00
|
|
|
// joinAlias에서 실제 컬럼명 추출 (예: dept_code_location_name -> location_name)
|
|
|
|
|
// sourceColumn을 제거한 나머지 부분이 실제 컬럼명
|
|
|
|
|
const sourceColumn = baseJoinConfig.sourceColumn; // dept_code
|
2025-09-24 15:02:54 +09:00
|
|
|
const joinAlias = additionalColumn.joinAlias; // dept_code_company_name
|
|
|
|
|
const actualColumnName = joinAlias.replace(`${sourceColumn}_`, ""); // company_name
|
|
|
|
|
|
|
|
|
|
logger.info(`🔍 조인 컬럼 상세 분석:`, {
|
|
|
|
|
sourceColumn,
|
|
|
|
|
joinAlias,
|
|
|
|
|
actualColumnName,
|
|
|
|
|
referenceTable: additionalColumn.sourceTable,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 🚨 기본 Entity 조인과 중복되지 않도록 체크
|
|
|
|
|
const isBasicEntityJoin =
|
|
|
|
|
additionalColumn.joinAlias ===
|
|
|
|
|
`${baseJoinConfig.sourceColumn}_name`;
|
|
|
|
|
|
|
|
|
|
if (isBasicEntityJoin) {
|
|
|
|
|
logger.info(
|
|
|
|
|
`⚠️ 기본 Entity 조인과 중복: ${additionalColumn.joinAlias} - 건너뜀`
|
|
|
|
|
);
|
|
|
|
|
continue; // 기본 Entity 조인과 중복되면 추가하지 않음
|
|
|
|
|
}
|
2025-09-24 10:33:54 +09:00
|
|
|
|
2025-09-16 18:02:19 +09:00
|
|
|
// 추가 조인 컬럼 설정 생성
|
|
|
|
|
const additionalJoinConfig: EntityJoinConfig = {
|
|
|
|
|
sourceTable: tableName,
|
2025-09-24 10:33:54 +09:00
|
|
|
sourceColumn: baseJoinConfig.sourceColumn, // 원본 컬럼 (dept_code)
|
2025-09-24 15:02:54 +09:00
|
|
|
referenceTable:
|
|
|
|
|
(additionalColumn as any).referenceTable ||
|
|
|
|
|
baseJoinConfig.referenceTable, // 참조 테이블 (dept_info)
|
2025-09-24 10:33:54 +09:00
|
|
|
referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (dept_code)
|
2025-09-24 15:02:54 +09:00
|
|
|
displayColumns: [actualColumnName], // 표시할 컬럼들 (company_name)
|
2025-09-24 10:33:54 +09:00
|
|
|
displayColumn: actualColumnName, // 하위 호환성
|
2025-09-24 15:02:54 +09:00
|
|
|
aliasColumn: additionalColumn.joinAlias, // 별칭 (dept_code_company_name)
|
2025-09-23 16:23:36 +09:00
|
|
|
separator: " - ", // 기본 구분자
|
2025-09-16 18:02:19 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
joinConfigs.push(additionalJoinConfig);
|
|
|
|
|
logger.info(
|
2025-09-24 15:02:54 +09:00
|
|
|
`✅ 추가 조인 컬럼 설정 추가: ${additionalJoinConfig.aliasColumn} -> ${actualColumnName}`
|
2025-09-16 18:02:19 +09:00
|
|
|
);
|
2025-09-24 15:02:54 +09:00
|
|
|
logger.info(`🔍 추가된 조인 설정 상세:`, {
|
|
|
|
|
sourceTable: additionalJoinConfig.sourceTable,
|
|
|
|
|
sourceColumn: additionalJoinConfig.sourceColumn,
|
|
|
|
|
referenceTable: additionalJoinConfig.referenceTable,
|
|
|
|
|
displayColumns: additionalJoinConfig.displayColumns,
|
|
|
|
|
aliasColumn: additionalJoinConfig.aliasColumn,
|
|
|
|
|
});
|
2025-09-16 18:02:19 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-09-16 15:13:00 +09:00
|
|
|
|
2025-09-24 15:02:54 +09:00
|
|
|
// 최종 조인 설정 배열 로깅
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2025-09-16 15:13:00 +09:00
|
|
|
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),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-16 16:53:03 +09:00
|
|
|
// 조인 전략 결정 (테이블 크기 기반)
|
2025-09-24 15:02:54 +09:00
|
|
|
// 🚨 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);
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-16 16:53:03 +09:00
|
|
|
console.log(
|
|
|
|
|
`🎯 선택된 조인 전략: ${strategy} (${joinConfigs.length}개 Entity 조인)`
|
|
|
|
|
);
|
2025-09-16 15:13:00 +09:00
|
|
|
|
|
|
|
|
// 테이블 컬럼 정보 조회
|
|
|
|
|
const columns = await this.getTableColumns(tableName);
|
|
|
|
|
const selectColumns = columns.data.map((col: any) => col.column_name);
|
|
|
|
|
|
|
|
|
|
// WHERE 절 구성
|
2025-09-23 14:26:18 +09:00
|
|
|
const whereClause = await this.buildWhereClause(
|
|
|
|
|
tableName,
|
|
|
|
|
options.search
|
|
|
|
|
);
|
2025-09-16 15:13:00 +09:00
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
);
|
2025-09-16 16:53:03 +09:00
|
|
|
} else if (strategy === "cache_lookup") {
|
2025-09-16 15:13:00 +09:00
|
|
|
// 캐시 룩업 방식
|
|
|
|
|
return await this.executeCachedLookup(
|
|
|
|
|
tableName,
|
|
|
|
|
joinConfigs,
|
|
|
|
|
options,
|
|
|
|
|
startTime
|
|
|
|
|
);
|
2025-09-16 16:53:03 +09:00
|
|
|
} else {
|
|
|
|
|
// 하이브리드 방식: 일부는 조인, 일부는 캐시
|
|
|
|
|
return await this.executeHybridJoin(
|
|
|
|
|
tableName,
|
|
|
|
|
joinConfigs,
|
|
|
|
|
selectColumns,
|
|
|
|
|
whereClause,
|
|
|
|
|
orderBy,
|
|
|
|
|
options.size,
|
|
|
|
|
offset,
|
|
|
|
|
startTime
|
|
|
|
|
);
|
2025-09-16 15:13:00 +09:00
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(`Entity 조인 데이터 조회 실패: ${tableName}`, error);
|
2025-09-17 11:15:34 +09:00
|
|
|
throw error;
|
2025-09-16 15:13:00 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
2025-09-17 11:15:34 +09:00
|
|
|
).query;
|
2025-09-16 15:13:00 +09:00
|
|
|
|
|
|
|
|
// 카운트 쿼리
|
|
|
|
|
const countQuery = entityJoinService.buildCountQuery(
|
|
|
|
|
tableName,
|
|
|
|
|
joinConfigs,
|
|
|
|
|
whereClause
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 병렬 실행
|
|
|
|
|
const [dataResult, countResult] = await Promise.all([
|
2025-09-30 18:01:57 +09:00
|
|
|
query(dataQuery),
|
|
|
|
|
query(countQuery),
|
2025-09-16 15:13:00 +09:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
},
|
|
|
|
|
startTime: number
|
|
|
|
|
): Promise<EntityJoinResponse> {
|
|
|
|
|
try {
|
|
|
|
|
// 캐시 데이터 미리 로드
|
|
|
|
|
for (const config of joinConfigs) {
|
2025-09-24 14:31:46 +09:00
|
|
|
const displayCol =
|
|
|
|
|
config.displayColumn ||
|
|
|
|
|
config.displayColumns?.[0] ||
|
|
|
|
|
config.referenceColumn;
|
|
|
|
|
logger.info(
|
|
|
|
|
`🔍 캐시 로드 - ${config.referenceTable}: keyCol=${config.referenceColumn}, displayCol=${displayCol}`
|
|
|
|
|
);
|
|
|
|
|
|
2025-09-16 16:53:03 +09:00
|
|
|
await referenceCacheService.getCachedReference(
|
2025-09-16 15:13:00 +09:00
|
|
|
config.referenceTable,
|
|
|
|
|
config.referenceColumn,
|
2025-09-24 14:31:46 +09:00
|
|
|
displayCol
|
2025-09-16 15:13:00 +09:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-17 11:15:34 +09:00
|
|
|
// 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;
|
|
|
|
|
}),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const hasEntitySearch =
|
|
|
|
|
options.search &&
|
|
|
|
|
Object.keys(options.search).some((key) =>
|
|
|
|
|
allEntityColumns.includes(key)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (hasEntitySearch) {
|
|
|
|
|
const entitySearchKeys = options.search
|
|
|
|
|
? Object.keys(options.search).filter((key) =>
|
|
|
|
|
allEntityColumns.includes(key)
|
|
|
|
|
)
|
|
|
|
|
: [];
|
|
|
|
|
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)) {
|
|
|
|
|
const joinConfig = joinConfigs.find(
|
|
|
|
|
(config) => config.aliasColumn === key
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (joinConfig) {
|
|
|
|
|
// 기본 Entity 조인 컬럼인 경우: 조인된 테이블의 표시 컬럼에서 검색
|
|
|
|
|
const alias = aliasMap.get(joinConfig.referenceTable);
|
|
|
|
|
whereConditions.push(
|
|
|
|
|
`${alias}.${joinConfig.displayColumn} ILIKE '%${value}%'`
|
|
|
|
|
);
|
|
|
|
|
entitySearchColumns.push(
|
|
|
|
|
`${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})`
|
|
|
|
|
);
|
|
|
|
|
logger.info(
|
|
|
|
|
`🎯 Entity 조인 검색: ${key} → ${joinConfig.referenceTable}.${joinConfig.displayColumn} LIKE '%${value}%' (별칭: ${alias})`
|
|
|
|
|
);
|
|
|
|
|
} else if (key === "writer_dept_code") {
|
|
|
|
|
// writer_dept_code: user_info.dept_code에서 검색
|
|
|
|
|
const userAlias = aliasMap.get("user_info");
|
|
|
|
|
whereConditions.push(
|
|
|
|
|
`${userAlias}.dept_code ILIKE '%${value}%'`
|
|
|
|
|
);
|
|
|
|
|
entitySearchColumns.push(`${key} (user_info.dept_code)`);
|
|
|
|
|
logger.info(
|
|
|
|
|
`🎯 추가 Entity 조인 검색: ${key} → user_info.dept_code LIKE '%${value}%' (별칭: ${userAlias})`
|
|
|
|
|
);
|
|
|
|
|
} else if (key === "company_code_status") {
|
|
|
|
|
// company_code_status: company_info.status에서 검색
|
|
|
|
|
const companyAlias = aliasMap.get("company_info");
|
|
|
|
|
whereConditions.push(
|
|
|
|
|
`${companyAlias}.status ILIKE '%${value}%'`
|
|
|
|
|
);
|
|
|
|
|
entitySearchColumns.push(`${key} (company_info.status)`);
|
|
|
|
|
logger.info(
|
|
|
|
|
`🎯 추가 Entity 조인 검색: ${key} → company_info.status LIKE '%${value}%' (별칭: ${companyAlias})`
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
// 일반 컬럼인 경우: 메인 테이블에서 검색
|
|
|
|
|
whereConditions.push(`main.${key} ILIKE '%${value}%'`);
|
|
|
|
|
logger.info(
|
|
|
|
|
`🔍 일반 컬럼 검색: ${key} → main.${key} LIKE '%${value}%'`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Entity 조인 컬럼 검색이 없는 경우 기존 캐시 방식 사용
|
|
|
|
|
basicResult = await this.getTableData(tableName, options);
|
|
|
|
|
}
|
2025-09-16 15:13:00 +09:00
|
|
|
|
|
|
|
|
// 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,
|
2025-09-23 16:23:36 +09:00
|
|
|
config.displayColumn || config.displayColumns[0],
|
2025-09-16 15:13:00 +09:00
|
|
|
String(sourceValue)
|
|
|
|
|
);
|
|
|
|
|
|
2025-09-16 18:02:19 +09:00
|
|
|
// null이나 undefined인 경우 빈 문자열로 설정
|
|
|
|
|
enhancedRow[config.aliasColumn] = lookupValue || "";
|
|
|
|
|
} else {
|
|
|
|
|
// sourceValue가 없는 경우도 빈 문자열로 설정
|
|
|
|
|
enhancedRow[config.aliasColumn] = "";
|
2025-09-16 15:13:00 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-09-23 14:26:18 +09:00
|
|
|
* WHERE 절 구성 (고급 검색 지원)
|
2025-09-16 15:13:00 +09:00
|
|
|
*/
|
2025-09-23 14:26:18 +09:00
|
|
|
private async buildWhereClause(
|
|
|
|
|
tableName: string,
|
|
|
|
|
search?: Record<string, any>
|
|
|
|
|
): Promise<string> {
|
2025-09-16 15:13:00 +09:00
|
|
|
if (!search || Object.keys(search).length === 0) {
|
|
|
|
|
return "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const conditions: string[] = [];
|
|
|
|
|
|
2025-09-23 14:26:18 +09:00
|
|
|
for (const [columnName, value] of Object.entries(search)) {
|
|
|
|
|
if (
|
|
|
|
|
value === undefined ||
|
|
|
|
|
value === null ||
|
|
|
|
|
value === "" ||
|
|
|
|
|
value === "__ALL__"
|
|
|
|
|
) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// 고급 검색 조건 구성
|
|
|
|
|
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);
|
|
|
|
|
// 폴백: 기본 문자열 검색
|
2025-09-16 15:13:00 +09:00
|
|
|
if (typeof value === "string") {
|
2025-09-23 14:26:18 +09:00
|
|
|
conditions.push(
|
|
|
|
|
`main.${columnName}::text ILIKE '%${value.replace(/'/g, "''")}%'`
|
|
|
|
|
);
|
2025-09-16 15:13:00 +09:00
|
|
|
} else {
|
2025-09-23 14:26:18 +09:00
|
|
|
conditions.push(
|
|
|
|
|
`main.${columnName} = '${String(value).replace(/'/g, "''")}'`
|
|
|
|
|
);
|
2025-09-16 15:13:00 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return conditions.length > 0 ? conditions.join(" AND ") : "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 테이블의 컬럼 정보 조회
|
|
|
|
|
*/
|
|
|
|
|
async getTableColumns(tableName: string): Promise<{
|
|
|
|
|
data: Array<{ column_name: string; data_type: string }>;
|
|
|
|
|
}> {
|
|
|
|
|
try {
|
2025-09-30 18:01:57 +09:00
|
|
|
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]
|
|
|
|
|
);
|
2025-09-16 15:13:00 +09:00
|
|
|
|
|
|
|
|
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}`);
|
|
|
|
|
|
2025-09-30 18:01:57 +09:00
|
|
|
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,
|
|
|
|
|
]
|
|
|
|
|
);
|
2025-09-16 15:13:00 +09:00
|
|
|
|
|
|
|
|
logger.info(`컬럼 라벨 업데이트 완료: ${tableName}.${columnName}`);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(
|
|
|
|
|
`컬럼 라벨 업데이트 실패: ${tableName}.${columnName}`,
|
|
|
|
|
error
|
|
|
|
|
);
|
|
|
|
|
throw new Error(
|
|
|
|
|
`컬럼 라벨 업데이트 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-09-16 16:53:03 +09:00
|
|
|
|
|
|
|
|
// ========================================
|
|
|
|
|
// 🎯 하이브리드 조인 전략 구현
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 하이브리드 조인 실행: 일부는 조인, 일부는 캐시 룩업
|
|
|
|
|
*/
|
|
|
|
|
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 {
|
|
|
|
|
return await this.executeCachedLookup(
|
|
|
|
|
tableName,
|
|
|
|
|
cacheableJoins,
|
|
|
|
|
{ page: Math.floor(offset / limit) + 1, size: limit, search: {} },
|
|
|
|
|
startTime
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error("하이브리드 조인 실행 실패", error);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 조인 설정을 캐시 가능 여부에 따라 분류
|
|
|
|
|
*/
|
|
|
|
|
private async categorizeJoins(joinConfigs: EntityJoinConfig[]): Promise<{
|
|
|
|
|
cacheableJoins: EntityJoinConfig[];
|
|
|
|
|
dbJoins: EntityJoinConfig[];
|
|
|
|
|
}> {
|
|
|
|
|
const cacheableJoins: EntityJoinConfig[] = [];
|
|
|
|
|
const dbJoins: EntityJoinConfig[] = [];
|
|
|
|
|
|
|
|
|
|
for (const config of joinConfigs) {
|
|
|
|
|
// 캐시 가능성 확인
|
|
|
|
|
const cachedData = await referenceCacheService.getCachedReference(
|
|
|
|
|
config.referenceTable,
|
|
|
|
|
config.referenceColumn,
|
2025-09-23 16:23:36 +09:00
|
|
|
config.displayColumn || config.displayColumns[0]
|
2025-09-16 16:53:03 +09:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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,
|
2025-09-23 16:23:36 +09:00
|
|
|
config.displayColumn || config.displayColumns[0]
|
2025-09-16 16:53:03 +09:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (cachedData) {
|
|
|
|
|
enhancedData.forEach((row) => {
|
|
|
|
|
const keyValue = row[config.sourceColumn];
|
|
|
|
|
if (keyValue) {
|
|
|
|
|
const lookupValue = cachedData.get(String(keyValue));
|
2025-09-16 18:02:19 +09:00
|
|
|
// null이나 undefined인 경우 빈 문자열로 설정
|
|
|
|
|
row[config.aliasColumn] = lookupValue || "";
|
|
|
|
|
} else {
|
|
|
|
|
// sourceValue가 없는 경우도 빈 문자열로 설정
|
|
|
|
|
row[config.aliasColumn] = "";
|
2025-09-16 16:53:03 +09:00
|
|
|
}
|
|
|
|
|
});
|
2025-09-16 18:02:19 +09:00
|
|
|
} else {
|
|
|
|
|
// 캐시가 없는 경우 모든 행에 빈 문자열 설정
|
|
|
|
|
enhancedData.forEach((row) => {
|
|
|
|
|
row[config.aliasColumn] = "";
|
|
|
|
|
});
|
2025-09-16 16:53:03 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
2025-09-23 16:23:36 +09:00
|
|
|
config.displayColumn || config.displayColumns[0]
|
2025-09-16 16:53:03 +09:00
|
|
|
);
|
|
|
|
|
totalHitRate += hitRate;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return totalHitRate / cacheableJoins.length;
|
|
|
|
|
}
|
2025-09-19 18:43:55 +09:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 테이블 스키마 정보 조회 (컬럼 존재 여부 검증용)
|
|
|
|
|
*/
|
|
|
|
|
async getTableSchema(tableName: string): Promise<ColumnTypeInfo[]> {
|
|
|
|
|
try {
|
|
|
|
|
logger.info(`테이블 스키마 정보 조회: ${tableName}`);
|
|
|
|
|
|
2025-09-30 18:01:57 +09:00
|
|
|
const rawColumns = await query<any>(
|
|
|
|
|
`SELECT
|
2025-09-19 18:43:55 +09:00
|
|
|
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
|
2025-09-30 18:01:57 +09:00
|
|
|
WHERE table_name = $1 AND constraint_name LIKE '%_pkey'
|
2025-09-19 18:43:55 +09:00
|
|
|
) THEN true
|
|
|
|
|
ELSE false
|
|
|
|
|
END as "isPrimaryKey"
|
|
|
|
|
FROM information_schema.columns
|
2025-09-30 18:01:57 +09:00
|
|
|
WHERE table_name = $1
|
2025-09-19 18:43:55 +09:00
|
|
|
AND table_schema = 'public'
|
2025-09-30 18:01:57 +09:00
|
|
|
ORDER BY ordinal_position`,
|
|
|
|
|
[tableName]
|
|
|
|
|
);
|
2025-09-19 18:43:55 +09:00
|
|
|
|
|
|
|
|
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}`);
|
|
|
|
|
|
2025-09-30 18:01:57 +09:00
|
|
|
const result = await query<any>(
|
|
|
|
|
`SELECT EXISTS (
|
2025-09-19 18:43:55 +09:00
|
|
|
SELECT 1 FROM information_schema.tables
|
2025-09-30 18:01:57 +09:00
|
|
|
WHERE table_name = $1
|
2025-09-19 18:43:55 +09:00
|
|
|
AND table_schema = 'public'
|
|
|
|
|
AND table_type = 'BASE TABLE'
|
2025-09-30 18:01:57 +09:00
|
|
|
) as "exists"`,
|
|
|
|
|
[tableName]
|
|
|
|
|
);
|
2025-09-19 18:43:55 +09:00
|
|
|
|
|
|
|
|
const exists = result[0]?.exists || false;
|
|
|
|
|
logger.info(`테이블 존재 여부: ${tableName} = ${exists}`);
|
|
|
|
|
return exists;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(`테이블 존재 여부 확인 실패: ${tableName}`, error);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-09-23 10:40:21 +09:00
|
|
|
* 컬럼 입력타입 정보 조회 (화면관리 연동용)
|
2025-11-06 17:01:13 +09:00
|
|
|
* @param companyCode - 회사 코드 (멀티테넌시)
|
2025-09-19 18:43:55 +09:00
|
|
|
*/
|
2025-11-06 17:01:13 +09:00
|
|
|
async getColumnInputTypes(
|
|
|
|
|
tableName: string,
|
|
|
|
|
companyCode: string
|
|
|
|
|
): Promise<ColumnTypeInfo[]> {
|
2025-09-19 18:43:55 +09:00
|
|
|
try {
|
2025-11-06 17:01:13 +09:00
|
|
|
logger.info(
|
|
|
|
|
`컬럼 입력타입 정보 조회: ${tableName}, company: ${companyCode}`
|
|
|
|
|
);
|
2025-09-19 18:43:55 +09:00
|
|
|
|
2025-11-06 17:01:13 +09:00
|
|
|
// table_type_columns에서 입력타입 정보 조회 (company_code 필터링)
|
2025-09-30 18:01:57 +09:00
|
|
|
const rawInputTypes = await query<any>(
|
|
|
|
|
`SELECT
|
2025-11-06 17:01:13 +09:00
|
|
|
ttc.column_name as "columnName",
|
|
|
|
|
COALESCE(cl.column_label, ttc.column_name) as "displayName",
|
|
|
|
|
ttc.input_type as "inputType",
|
2025-11-06 18:10:21 +09:00
|
|
|
COALESCE(ttc.detail_settings::jsonb, '{}'::jsonb) as "detailSettings",
|
2025-11-06 17:01:13 +09:00
|
|
|
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
|
2025-09-19 18:43:55 +09:00
|
|
|
LEFT JOIN information_schema.columns ic
|
2025-11-06 17:01:13 +09:00
|
|
|
ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name
|
|
|
|
|
WHERE ttc.table_name = $1
|
|
|
|
|
AND ttc.company_code = $2
|
|
|
|
|
ORDER BY ttc.display_order, ttc.column_name`,
|
|
|
|
|
[tableName, companyCode]
|
2025-09-30 18:01:57 +09:00
|
|
|
);
|
2025-09-19 18:43:55 +09:00
|
|
|
|
2025-09-23 10:40:21 +09:00
|
|
|
const inputTypes: ColumnTypeInfo[] = rawInputTypes.map((col) => ({
|
2025-09-19 18:43:55 +09:00
|
|
|
tableName: tableName,
|
|
|
|
|
columnName: col.columnName,
|
|
|
|
|
displayName: col.displayName,
|
2025-09-23 10:40:21 +09:00
|
|
|
dataType: col.dataType || "varchar",
|
|
|
|
|
inputType: col.inputType,
|
2025-09-19 18:43:55 +09:00
|
|
|
detailSettings: col.detailSettings,
|
|
|
|
|
description: "", // 필수 필드 추가
|
2025-11-06 17:01:13 +09:00
|
|
|
isNullable: col.isNullable === "Y" ? "Y" : "N", // 🔥 FIX: string 타입으로 변환
|
2025-09-19 18:43:55 +09:00
|
|
|
isPrimaryKey: false,
|
|
|
|
|
displayOrder: 0,
|
|
|
|
|
isVisible: true,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
logger.info(
|
2025-11-06 17:01:13 +09:00
|
|
|
`컬럼 입력타입 정보 조회 완료: ${tableName}, company: ${companyCode}, ${inputTypes.length}개 컬럼`
|
2025-09-19 18:43:55 +09:00
|
|
|
);
|
2025-09-23 10:40:21 +09:00
|
|
|
return inputTypes;
|
2025-09-19 18:43:55 +09:00
|
|
|
} catch (error) {
|
2025-11-06 17:01:13 +09:00
|
|
|
logger.error(
|
|
|
|
|
`컬럼 입력타입 정보 조회 실패: ${tableName}, company: ${companyCode}`,
|
|
|
|
|
error
|
|
|
|
|
);
|
2025-09-19 18:43:55 +09:00
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-23 10:40:21 +09:00
|
|
|
/**
|
|
|
|
|
* 레거시 지원: 컬럼 웹타입 정보 조회
|
|
|
|
|
* @deprecated getColumnInputTypes 사용 권장
|
|
|
|
|
*/
|
2025-11-06 17:01:13 +09:00
|
|
|
async getColumnWebTypes(tableName: string, companyCode: string): Promise<ColumnTypeInfo[]> {
|
2025-09-23 10:40:21 +09:00
|
|
|
logger.warn(
|
|
|
|
|
`레거시 메서드 사용: getColumnWebTypes → getColumnInputTypes 사용 권장`
|
|
|
|
|
);
|
2025-11-06 17:01:13 +09:00
|
|
|
return this.getColumnInputTypes(tableName, companyCode); // 🔥 FIX: companyCode 파라미터 추가
|
2025-09-23 10:40:21 +09:00
|
|
|
}
|
|
|
|
|
|
2025-09-19 18:43:55 +09:00
|
|
|
/**
|
|
|
|
|
* 데이터베이스 연결 상태 확인
|
|
|
|
|
*/
|
|
|
|
|
async checkDatabaseConnection(): Promise<{
|
|
|
|
|
connected: boolean;
|
|
|
|
|
message: string;
|
|
|
|
|
}> {
|
|
|
|
|
try {
|
|
|
|
|
logger.info("데이터베이스 연결 상태 확인");
|
|
|
|
|
|
|
|
|
|
// 간단한 쿼리로 연결 테스트
|
2025-09-30 18:01:57 +09:00
|
|
|
const result = await query<any>(`SELECT 1 as "test"`);
|
2025-09-19 18:43:55 +09:00
|
|
|
|
|
|
|
|
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";
|
|
|
|
|
}
|
2025-10-21 15:08:41 +09:00
|
|
|
|
|
|
|
|
// ========================================
|
|
|
|
|
// 🎯 테이블 로그 시스템
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 로그 테이블 생성
|
|
|
|
|
*/
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-08-25 14:08:08 +09:00
|
|
|
}
|