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

4578 lines
144 KiB
TypeScript
Raw Normal View History

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";
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
// 🔧 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
/**
*
*/
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'인지 확인
const result = await query(
2025-10-14 11:48:04 +09:00
`SELECT input_type, code_category
FROM column_labels
WHERE table_name = $1
AND column_name = $2
2025-10-14 11:48:04 +09:00
AND input_type = 'code'`,
[tableName, columnName]
);
if (Array.isArray(result) && result.length > 0) {
const row = result[0] as any;
return {
isCodeType: true,
codeCategory: row.code_category,
};
}
return { isCodeType: false };
} catch (error) {
logger.warn(
`코드 타입 컬럼 확인 중 오류: ${tableName}.${columnName}`,
error
);
return { isCodeType: false };
}
}
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 사용
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_%'
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}`
);
// 디버깅: 캐시된 currency_code 확인
const cachedCurrency = cachedResult.columns.find(
(col: any) => col.columnName === "currency_code"
);
if (cachedCurrency) {
console.log(`💾 [캐시] currency_code:`, {
columnName: cachedCurrency.columnName,
inputType: cachedCurrency.inputType,
webType: cachedCurrency.webType,
});
}
2025-09-08 14:20:01 +09:00
return cachedResult;
}
// 전체 컬럼 수 조회 (캐시 확인)
let total = cache.get<number>(countCacheKey);
if (!total) {
const totalResult = await query<{ count: bigint }>(
`SELECT COUNT(*) as count
FROM information_schema.columns c
WHERE c.table_name = $1`,
[tableName]
);
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
2025-11-06 17:01:13 +09:00
? await query<any>(
`SELECT
c.column_name as "columnName",
COALESCE(cl.column_label, c.column_name) as "displayName",
c.data_type as "dataType",
c.data_type as "dbType",
COALESCE(cl.input_type, 'text') as "webType",
COALESCE(ttc.input_type, cl.input_type, 'direct') as "inputType",
ttc.input_type as "ttc_input_type",
cl.input_type as "cl_input_type",
2025-11-06 17:01:13 +09:00
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
// 🆕 category_column_mapping 조회
const tableExistsResult = await query<any>(
`SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'category_column_mapping'
) as table_exists`
);
const mappingTableExists = tableExistsResult[0]?.table_exists === true;
let categoryMappings: Map<string, number[]> = new Map();
if (mappingTableExists && companyCode) {
logger.info("📥 getColumnList: 카테고리 매핑 조회 시작", {
tableName,
companyCode,
});
const mappings = await query<any>(
`SELECT
logical_column_name as "columnName",
menu_objid as "menuObjid"
FROM category_column_mapping
WHERE table_name = $1
AND company_code = $2`,
[tableName, companyCode]
);
logger.info("✅ getColumnList: 카테고리 매핑 조회 완료", {
tableName,
companyCode,
mappingCount: mappings.length,
mappings: mappings,
});
mappings.forEach((m: any) => {
if (!categoryMappings.has(m.columnName)) {
categoryMappings.set(m.columnName, []);
}
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
});
logger.info("✅ getColumnList: categoryMappings Map 생성 완료", {
size: categoryMappings.size,
entries: Array.from(categoryMappings.entries()),
});
}
2025-09-01 15:37:49 +09:00
// BigInt를 Number로 변환하여 JSON 직렬화 문제 해결
const columns: ColumnTypeInfo[] = rawColumns.map((column) => {
const baseColumn = {
...column,
maxLength: column.maxLength ? Number(column.maxLength) : null,
numericPrecision: column.numericPrecision
? Number(column.numericPrecision)
: null,
numericScale: column.numericScale
? Number(column.numericScale)
: null,
displayOrder: column.displayOrder
? Number(column.displayOrder)
: null,
// webType은 사용자가 명시적으로 설정한 값을 그대로 사용
// (자동 추론은 column_labels에 없는 경우에만 SQL 쿼리의 COALESCE에서 처리됨)
webType: column.webType,
};
// 카테고리 타입인 경우 categoryMenus 추가
if (
column.inputType === "category" &&
categoryMappings.has(column.columnName)
) {
const menus = categoryMappings.get(column.columnName);
logger.info(
`✅ getColumnList: 컬럼 ${column.columnName}에 카테고리 메뉴 추가`,
{ menus }
);
return {
...baseColumn,
categoryMenus: menus,
};
}
return baseColumn;
});
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}`);
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
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 {
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 업데이트 또는 생성
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가 문자열이면 파싱, 객체면 그대로 사용
2025-11-07 10:22:49 +09:00
let parsedDetailSettings: Record<string, any> | undefined = undefined;
if (settings.detailSettings) {
if (typeof settings.detailSettings === "string") {
2025-11-07 10:22:49 +09:00
try {
parsedDetailSettings = JSON.parse(settings.detailSettings);
} catch (e) {
logger.warn(
`detailSettings 파싱 실패, 그대로 사용: ${settings.detailSettings}`
);
2025-11-07 10:22:49 +09:00
}
} else if (typeof settings.detailSettings === "object") {
parsedDetailSettings = settings.detailSettings as Record<
string,
any
>;
2025-11-06 17:01:13 +09:00
}
}
2025-11-06 17:01:13 +09:00
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
);
// 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));
logger.info(`전체 컬럼 설정 일괄 업데이트 완료: ${tableName}`);
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}`);
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}`);
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;
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,
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,
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만 사용)
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())
ON CONFLICT (table_name, column_name)
DO UPDATE SET
2025-10-14 11:48:04 +09:00
input_type = EXCLUDED.input_type,
detail_settings = EXCLUDED.detail_settings,
updated_date = NOW()`,
2025-10-14 11:48:04 +09:00
[tableName, columnName, webType, JSON.stringify(finalDetailSettings)]
);
logger.info(
2025-10-14 11:48:04 +09:00
`컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${webType}`
);
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 추가)
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
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-23 10:40:21 +09:00
// 🔥 해당 컬럼을 사용하는 화면 레이아웃의 widgetType도 업데이트
await this.syncScreenLayoutsInputType(
tableName,
columnName,
inputType,
companyCode
);
// 🔥 캐시 무효화: 해당 테이블의 컬럼 캐시 삭제
const cacheKeyPattern = `${CacheKeys.TABLE_COLUMNS(tableName, 1, 1000)}_${companyCode}`;
cache.delete(cacheKeyPattern);
cache.delete(CacheKeys.TABLE_COLUMN_COUNT(tableName));
2025-09-23 10:40:21 +09:00
logger.info(
`컬럼 입력 타입 설정 완료: ${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"}`
);
}
}
/**
* ID
* ( webTypeMapping.ts와 )
*/
private getComponentIdFromInputType(inputType: string): string {
const mapping: Record<string, string> = {
// 텍스트 입력
text: "text-input",
email: "text-input",
password: "text-input",
tel: "text-input",
// 숫자 입력
number: "number-input",
decimal: "number-input",
// 날짜/시간
date: "date-input",
datetime: "date-input",
time: "date-input",
// 텍스트 영역
textarea: "textarea-basic",
// 선택
select: "select-basic",
dropdown: "select-basic",
// 체크박스/라디오
checkbox: "checkbox-basic",
radio: "radio-basic",
boolean: "toggle-switch",
// 파일
file: "file-upload",
// 이미지
image: "image-widget",
img: "image-widget",
picture: "image-widget",
photo: "image-widget",
// 버튼
button: "button-primary",
// 기타
label: "text-display",
code: "select-basic",
entity: "select-basic",
category: "select-basic",
};
return mapping[inputType] || "text-input";
}
/**
* widgetType componentType
* @param tableName -
* @param columnName -
* @param inputType -
* @param companyCode -
*/
private async syncScreenLayoutsInputType(
tableName: string,
columnName: string,
inputType: string,
companyCode: string
): Promise<void> {
try {
// 해당 컬럼을 사용하는 화면 레이아웃 조회
const affectedLayouts = await query<{
layout_id: number;
screen_id: number;
component_id: string;
component_type: string;
properties: any;
}>(
`SELECT sl.layout_id, sl.screen_id, sl.component_id, sl.component_type, sl.properties
FROM screen_layouts sl
JOIN screen_definitions sd ON sl.screen_id = sd.screen_id
WHERE sl.properties->>'tableName' = $1
AND sl.properties->>'columnName' = $2
AND (sd.company_code = $3 OR $3 = '*')`,
[tableName, columnName, companyCode]
);
if (affectedLayouts.length === 0) {
logger.info(
`화면 레이아웃 동기화: ${tableName}.${columnName}을 사용하는 화면 없음`
);
return;
}
logger.info(
`화면 레이아웃 동기화 시작: ${affectedLayouts.length}개 컴포넌트 발견`
);
// 새로운 componentType 계산
const newComponentType = this.getComponentIdFromInputType(inputType);
// 각 레이아웃의 widgetType, componentType 업데이트
for (const layout of affectedLayouts) {
const updatedProperties = {
...layout.properties,
widgetType: inputType,
inputType: inputType,
// componentConfig 내부의 type도 업데이트
componentConfig: {
...layout.properties?.componentConfig,
type: newComponentType,
inputType: inputType,
},
};
await query(
`UPDATE screen_layouts
SET properties = $1, component_type = $2
WHERE layout_id = $3`,
[
JSON.stringify(updatedProperties),
newComponentType,
layout.layout_id,
]
);
logger.info(
`화면 레이아웃 업데이트: screen_id=${layout.screen_id}, component_id=${layout.component_id}, widgetType=${inputType}, componentType=${newComponentType}`
);
}
logger.info(
`화면 레이아웃 동기화 완료: ${affectedLayouts.length}개 컴포넌트 업데이트됨`
);
} catch (error) {
// 화면 레이아웃 동기화 실패는 치명적이지 않으므로 로그만 남기고 계속 진행
logger.warn(
`화면 레이아웃 동기화 실패 (무시됨): ${tableName}.${columnName}`,
error
);
}
}
2025-09-23 10:40:21 +09:00
/**
*
*/
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으로 사용하여 파일 구분
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 {
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 {
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
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 {
2025-12-01 10:36:57 +09:00
// 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위)
if (typeof value === "string" && value.includes("|")) {
const columnInfo = await this.getColumnWebTypeInfo(
tableName,
columnName
);
2025-12-01 10:36:57 +09:00
// 날짜 타입이면 날짜 범위로 처리
if (
columnInfo &&
(columnInfo.webType === "date" || columnInfo.webType === "datetime")
) {
return this.buildDateRangeCondition(columnName, value, paramIndex);
}
2025-12-01 10:36:57 +09:00
// 그 외 타입이면 다중선택(IN 조건)으로 처리
const multiValues = value
.split("|")
.filter((v: string) => v.trim() !== "");
2025-12-01 10:36:57 +09:00
if (multiValues.length > 0) {
const placeholders = multiValues
.map((_: string, idx: number) => `$${paramIndex + idx}`)
.join(", ");
logger.info(
`🔍 다중선택 필터 적용: ${columnName} IN (${multiValues.join(", ")})`
);
2025-12-01 10:36:57 +09:00
return {
whereClause: `${columnName}::text IN (${placeholders})`,
values: multiValues,
paramCount: multiValues.length,
};
}
}
// 🔧 날짜 범위 객체 {from, to} 체크
if (
typeof value === "object" &&
value !== null &&
("from" in value || "to" in value)
) {
// 날짜 범위 객체는 그대로 전달
const columnInfo = await this.getColumnWebTypeInfo(
tableName,
columnName
);
if (
columnInfo &&
(columnInfo.webType === "date" || columnInfo.webType === "datetime")
) {
return this.buildDateRangeCondition(columnName, value, paramIndex);
}
}
// 🔧 {value, operator} 형태의 필터 객체 처리
let actualValue = value;
let operator = "contains"; // 기본값
if (typeof value === "object" && value !== null && "value" in value) {
actualValue = value.value;
operator = value.operator || "contains";
logger.info("🔍 필터 객체 처리:", {
columnName,
originalValue: value,
actualValue,
operator,
});
}
2025-09-23 14:26:18 +09:00
// "__ALL__" 값이거나 빈 값이면 필터 조건을 적용하지 않음
if (
actualValue === "__ALL__" ||
actualValue === "" ||
actualValue === null ||
actualValue === undefined
2025-09-23 14:26:18 +09:00
) {
return null;
}
// 컬럼 타입 정보 조회
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
logger.info(
`🔍 [buildAdvancedSearchCondition] ${tableName}.${columnName}`,
`webType=${columnInfo?.webType || "NULL"}`,
`inputType=${columnInfo?.inputType || "NULL"}`,
`actualValue=${JSON.stringify(actualValue)}`,
`operator=${operator}`
);
2025-09-23 14:26:18 +09:00
if (!columnInfo) {
// 컬럼 정보가 없으면 operator에 따른 기본 검색
switch (operator) {
case "equals":
return {
whereClause: `${columnName}::text = $${paramIndex}`,
values: [actualValue],
paramCount: 1,
};
case "contains":
default:
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${actualValue}%`],
paramCount: 1,
};
}
2025-09-23 14:26:18 +09:00
}
const webType = columnInfo.webType;
// 웹타입별 검색 조건 구성
switch (webType) {
case "date":
case "datetime":
return this.buildDateRangeCondition(
columnName,
actualValue,
paramIndex
);
2025-09-23 14:26:18 +09:00
case "number":
case "decimal":
return this.buildNumberRangeCondition(
columnName,
actualValue,
paramIndex
);
2025-09-23 14:26:18 +09:00
case "code":
return await this.buildCodeSearchCondition(
tableName,
columnName,
actualValue,
2025-09-23 14:26:18 +09:00
paramIndex
);
case "entity":
return await this.buildEntitySearchCondition(
tableName,
columnName,
actualValue,
paramIndex,
operator // operator 전달 (equals면 직접 매칭)
2025-09-23 14:26:18 +09:00
);
default:
// 기본 문자열 검색 (actualValue 사용)
2025-09-23 14:26:18 +09:00
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${actualValue}%`],
2025-09-23 14:26:18 +09:00
paramCount: 1,
};
}
} catch (error) {
logger.error(
`고급 검색 조건 구성 실패: ${tableName}.${columnName}`,
error
);
// 오류 시 기본 검색으로 폴백
let fallbackValue = value;
if (typeof value === "object" && value !== null && "value" in value) {
fallbackValue = value.value;
}
2025-09-23 14:26:18 +09:00
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${fallbackValue}%`],
2025-09-23 14:26:18 +09:00
paramCount: 1,
};
}
}
/**
*
*/
private buildDateRangeCondition(
columnName: string,
value: any,
paramIndex: number
): {
whereClause: string;
values: any[];
paramCount: number;
} {
const conditions: string[] = [];
const values: any[] = [];
let paramCount = 0;
// 문자열 형식의 날짜 범위 파싱 ("YYYY-MM-DD|YYYY-MM-DD")
if (typeof value === "string" && value.includes("|")) {
const [fromStr, toStr] = value.split("|");
if (fromStr && fromStr.trim() !== "") {
// VARCHAR 컬럼을 DATE로 캐스팅하여 비교
conditions.push(
`${columnName}::date >= $${paramIndex + paramCount}::date`
);
values.push(fromStr.trim());
paramCount++;
}
if (toStr && toStr.trim() !== "") {
// 종료일은 해당 날짜의 23:59:59까지 포함
conditions.push(
`${columnName}::date <= $${paramIndex + paramCount}::date`
);
values.push(toStr.trim());
paramCount++;
}
}
// 객체 형식의 날짜 범위 ({from, to})
else if (typeof value === "object" && value !== null) {
2025-09-23 14:26:18 +09:00
if (value.from) {
// VARCHAR 컬럼을 DATE로 캐스팅하여 비교
conditions.push(
`${columnName}::date >= $${paramIndex + paramCount}::date`
);
2025-09-23 14:26:18 +09:00
values.push(value.from);
paramCount++;
}
if (value.to) {
// 종료일은 해당 날짜의 23:59:59까지 포함
conditions.push(
`${columnName}::date <= $${paramIndex + paramCount}::date`
);
2025-09-23 14:26:18 +09:00
values.push(value.to);
paramCount++;
}
}
// 단일 날짜 검색
else if (typeof value === "string" && value.trim() !== "") {
conditions.push(`${columnName}::date = $${paramIndex}::date`);
2025-09-23 14:26:18 +09:00
values.push(value);
paramCount = 1;
}
if (conditions.length === 0) {
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${value}%`],
paramCount: 1,
};
}
return {
whereClause: `(${conditions.join(" AND ")})`,
values,
paramCount,
};
}
/**
*
*/
private buildNumberRangeCondition(
columnName: string,
value: any,
paramIndex: number
): {
whereClause: string;
values: any[];
paramCount: number;
} {
const conditions: string[] = [];
const values: any[] = [];
let paramCount = 0;
if (typeof value === "object" && value !== null) {
if (value.min !== undefined && value.min !== null && value.min !== "") {
conditions.push(
`${columnName}::numeric >= $${paramIndex + paramCount}`
);
values.push(parseFloat(value.min));
paramCount++;
}
if (value.max !== undefined && value.max !== null && value.max !== "") {
conditions.push(
`${columnName}::numeric <= $${paramIndex + paramCount}`
);
values.push(parseFloat(value.max));
paramCount++;
}
} else if (typeof value === "string" || typeof value === "number") {
// 정확한 값 검색
conditions.push(`${columnName}::numeric = $${paramIndex}`);
values.push(parseFloat(value.toString()));
paramCount = 1;
}
if (conditions.length === 0) {
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${value}%`],
paramCount: 1,
};
}
return {
whereClause: `(${conditions.join(" AND ")})`,
values,
paramCount,
};
}
/**
*
*/
private async buildCodeSearchCondition(
tableName: string,
columnName: string,
value: any,
paramIndex: number
): Promise<{
whereClause: string;
values: any[];
paramCount: number;
}> {
try {
const codeTypeInfo = await this.getCodeTypeInfo(tableName, columnName);
if (!codeTypeInfo.isCodeType || !codeTypeInfo.codeCategory) {
// 코드 타입이 아니면 기본 검색
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${value}%`],
paramCount: 1,
};
}
if (typeof value === "string" && value.trim() !== "") {
// 코드값 또는 코드명으로 검색
return {
whereClause: `(
${columnName}::text = $${paramIndex} OR
EXISTS (
SELECT 1 FROM code_info ci
WHERE ci.code_category = $${paramIndex + 1}
AND ci.code_value = ${columnName}
AND ci.code_name ILIKE $${paramIndex + 2}
)
)`,
values: [value, codeTypeInfo.codeCategory, `%${value}%`],
paramCount: 3,
};
} else {
// 정확한 코드값 매칭
return {
whereClause: `${columnName} = $${paramIndex}`,
values: [value],
paramCount: 1,
};
}
} catch (error) {
logger.error(
`코드 검색 조건 구성 실패: ${tableName}.${columnName}`,
error
);
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${value}%`],
paramCount: 1,
};
}
}
/**
*
*/
private async buildEntitySearchCondition(
tableName: string,
columnName: string,
value: any,
paramIndex: number,
operator: string = "contains" // 연결 필터에서 "equals"로 전달되면 직접 매칭
2025-09-23 14:26:18 +09:00
): Promise<{
whereClause: string;
values: any[];
paramCount: number;
}> {
try {
const entityTypeInfo = await this.getEntityTypeInfo(
tableName,
columnName
);
// 배열 처리: IN 절 사용
if (Array.isArray(value)) {
if (value.length === 0) {
// 빈 배열이면 항상 false 조건
return {
whereClause: `1 = 0`,
values: [],
paramCount: 0,
};
}
// IN 절로 여러 값 검색
const placeholders = value
.map((_, idx) => `$${paramIndex + idx}`)
.join(", ");
return {
whereClause: `${columnName} IN (${placeholders})`,
values: value,
paramCount: value.length,
};
}
2025-09-23 14:26:18 +09:00
if (!entityTypeInfo.isEntityType || !entityTypeInfo.referenceTable) {
// 엔티티 타입이 아니면 기본 검색
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${value}%`],
paramCount: 1,
};
}
if (typeof value === "string" && value.trim() !== "") {
// equals 연산자인 경우: 직접 값 매칭 (연결 필터에서 코드 값으로 필터링 시 사용)
if (operator === "equals") {
logger.info(
`🔍 [buildEntitySearchCondition] equals 연산자 - 직접 매칭: ${columnName} = ${value}`
);
return {
whereClause: `${columnName} = $${paramIndex}`,
values: [value],
paramCount: 1,
};
}
// contains 연산자 (기본): 참조 테이블의 표시 컬럼으로 검색
2025-09-23 14:26:18 +09:00
const referenceColumn = entityTypeInfo.referenceColumn || "id";
const referenceTable = entityTypeInfo.referenceTable;
// displayColumn이 비어있거나 "none"이면 참조 테이블에서 자동 감지 (entityJoinService와 동일한 로직)
let displayColumn = entityTypeInfo.displayColumn;
if (!displayColumn || displayColumn === "none" || displayColumn === "") {
displayColumn = await this.findDisplayColumnForTable(referenceTable, referenceColumn);
logger.info(
`🔍 [buildEntitySearchCondition] displayColumn 자동 감지: ${referenceTable} -> ${displayColumn}`
);
}
2025-09-23 14:26:18 +09:00
// 참조 테이블의 표시 컬럼으로 검색
return {
whereClause: `EXISTS (
SELECT 1 FROM ${referenceTable} ref
2025-09-23 14:26:18 +09:00
WHERE ref.${referenceColumn} = ${columnName}
AND ref.${displayColumn} ILIKE $${paramIndex}
)`,
values: [`%${value}%`],
paramCount: 1,
};
} else {
// 정확한 참조값 매칭
return {
whereClause: `${columnName} = $${paramIndex}`,
values: [value],
paramCount: 1,
};
}
} catch (error) {
logger.error(
`엔티티 검색 조건 구성 실패: ${tableName}.${columnName}`,
error
);
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${value}%`],
paramCount: 1,
};
}
}
/**
* (entityJoinService와 )
* : *_name > name > label/*_label > title > referenceColumn
*/
private async findDisplayColumnForTable(
tableName: string,
referenceColumn?: string
): Promise<string> {
try {
const result = await query<{ column_name: string }>(
`SELECT column_name
FROM information_schema.columns
WHERE table_name = $1
AND table_schema = 'public'
ORDER BY ordinal_position`,
[tableName]
);
const allColumns = result.map((r) => r.column_name);
// entityJoinService와 동일한 우선순위
// 1. *_name 컬럼 (item_name, customer_name, process_name 등) - company_name 제외
const nameColumn = allColumns.find(
(col) => col.endsWith("_name") && col !== "company_name"
);
if (nameColumn) {
return nameColumn;
}
// 2. name 컬럼
if (allColumns.includes("name")) {
return "name";
}
// 3. label 또는 *_label 컬럼
const labelColumn = allColumns.find(
(col) => col === "label" || col.endsWith("_label")
);
if (labelColumn) {
return labelColumn;
}
// 4. title 컬럼
if (allColumns.includes("title")) {
return "title";
}
// 5. 참조 컬럼 (referenceColumn)
if (referenceColumn && allColumns.includes(referenceColumn)) {
return referenceColumn;
}
// 6. 기본값: 첫 번째 비-id 컬럼 또는 id
return allColumns.find((col) => col !== "id") || "id";
} catch (error) {
logger.error(`표시 컬럼 감지 실패: ${tableName}`, error);
return referenceColumn || "id"; // 오류 시 기본값
}
}
2025-09-23 14:26:18 +09:00
/**
*
*/
private buildBooleanCondition(
columnName: string,
value: any,
paramIndex: number
): {
whereClause: string;
values: any[];
paramCount: number;
} {
if (value === "true" || value === true) {
return {
whereClause: `${columnName} = true`,
values: [],
paramCount: 0,
};
} else if (value === "false" || value === false) {
return {
whereClause: `${columnName} = false`,
values: [],
paramCount: 0,
};
} else {
// 기본 검색
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${value}%`],
paramCount: 1,
};
}
}
/**
*
*/
private async getColumnWebTypeInfo(
tableName: string,
columnName: string
): Promise<{
webType: string;
inputType?: string;
2025-09-23 14:26:18 +09:00
codeCategory?: string;
referenceTable?: string;
referenceColumn?: string;
displayColumn?: string;
} | null> {
try {
const result = await queryOne<{
web_type: string | null;
input_type: string | null;
code_category: string | null;
reference_table: string | null;
reference_column: string | null;
display_column: string | null;
}>(
`SELECT web_type, input_type, code_category, reference_table, reference_column, display_column
FROM column_labels
WHERE table_name = $1 AND column_name = $2
LIMIT 1`,
[tableName, columnName]
);
2025-09-23 14:26:18 +09:00
logger.info(
`🔍 [getColumnWebTypeInfo] ${tableName}.${columnName} 조회 결과:`,
{
found: !!result,
web_type: result?.web_type,
input_type: result?.input_type,
}
);
2025-09-23 14:26:18 +09:00
if (!result) {
logger.warn(
`⚠️ [getColumnWebTypeInfo] 컬럼 정보 없음: ${tableName}.${columnName}`
);
2025-09-23 14:26:18 +09:00
return null;
}
// web_type이 없으면 input_type을 사용 (레거시 호환)
const webType = result.web_type || result.input_type || "";
const columnInfo = {
webType: webType,
inputType: result.input_type || "",
2025-09-23 14:26:18 +09:00
codeCategory: result.code_category || undefined,
referenceTable: result.reference_table || undefined,
referenceColumn: result.reference_column || undefined,
displayColumn: result.display_column || undefined,
};
logger.info(
`✅ [getColumnWebTypeInfo] 반환값: webType=${columnInfo.webType}, inputType=${columnInfo.inputType}`
);
return columnInfo;
2025-09-23 14:26:18 +09:00
} 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;
companyCode?: string;
dataFilter?: any; // 🆕 DataFilterConfig
2025-09-03 15:23:12 +09:00
}
): Promise<{
data: any[];
total: number;
page: number;
size: number;
totalPages: number;
}> {
try {
const {
page,
size,
search = {},
sortBy,
sortOrder = "asc",
companyCode,
dataFilter,
} = 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;
// 멀티테넌시 필터 추가 (company_code)
if (companyCode) {
whereConditions.push(`company_code = $${paramIndex}`);
searchValues.push(companyCode);
paramIndex++;
logger.info(
`🔒 멀티테넌시 필터 추가 (기본 조회): company_code = ${companyCode}`
);
}
2025-09-03 15:23:12 +09:00
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;
}
// 🆕 조인 테이블 컬럼 (테이블명.컬럼명)은 기본 데이터 조회에서 제외
// Entity 조인 조회에서만 처리됨
if (column.includes(".")) {
logger.info(
`🔍 조인 테이블 컬럼 ${column} 기본 조회에서 제외 (Entity 조인에서 처리)`
);
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-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
}
}
}
}
// 🆕 데이터 필터 적용
if (
dataFilter &&
dataFilter.enabled &&
dataFilter.filters &&
dataFilter.filters.length > 0
) {
const {
buildDataFilterWhereClause,
} = require("../utils/dataFilterUtil");
const { whereClause: filterWhere, params: filterParams } =
buildDataFilterWhereClause(dataFilter, paramIndex);
if (filterWhere) {
whereConditions.push(filterWhere);
searchValues.push(...filterParams);
paramIndex += filterParams.length;
logger.info(`🔍 데이터 필터 적용: ${filterWhere}`);
logger.info(`🔍 필터 파라미터:`, filterParams);
}
}
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}`;
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
logger.info(`🔍 실행할 SQL: ${dataQuery}`);
logger.info(
`🔍 파라미터: ${JSON.stringify([...searchValues, size, offset])}`
);
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
`;
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));
// created_date 컬럼이 있고 값이 없으면 자동으로 현재 시간 추가
const hasCreatedDate = columnTypeMap.has("created_date");
if (hasCreatedDate && !data.created_date) {
data.created_date = new Date().toISOString();
logger.info(`created_date 자동 추가: ${data.created_date}`);
}
2025-09-03 16:38:10 +09:00
// 컬럼명과 값을 분리하고 타입에 맞게 변환
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(", ");
const insertQuery = `
2025-09-03 16:38:10 +09:00
INSERT INTO "${tableName}" (${columnNames})
VALUES (${placeholders})
`;
logger.info(`실행할 쿼리: ${insertQuery}`);
2025-09-03 16:38:10 +09:00
logger.info(`쿼리 파라미터:`, values);
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
`;
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);
// updated_date 컬럼이 있으면 자동으로 현재 시간 추가
const hasUpdatedDate = columnTypeMap.has("updated_date");
if (hasUpdatedDate && !updatedData.updated_date) {
updatedData.updated_date = new Date().toISOString();
logger.info(`updated_date 자동 추가: ${updatedData.updated_date}`);
}
2025-09-03 16:38:10 +09:00
// 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 쿼리 생성
const updateQuery = `
2025-09-03 16:38:10 +09:00
UPDATE "${tableName}"
SET ${setConditions.join(", ")}
WHERE ${whereConditions.join(" AND ")}
`;
const allValues = [...setValues, ...whereValues];
logger.info(`실행할 UPDATE 쿼리: ${updateQuery}`);
2025-09-03 16:38:10 +09:00
logger.info(`쿼리 파라미터:`, allValues);
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
`;
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}`;
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}`;
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;
companyCode?: string; // 멀티테넌시 필터용
additionalJoinColumns?: Array<{
sourceTable: string;
sourceColumn: string;
joinAlias: string;
}>;
screenEntityConfigs?: Record<string, any>; // 화면별 엔티티 설정
dataFilter?: any; // 🆕 데이터 필터
2025-12-04 18:26:35 +09:00
excludeFilter?: {
enabled: boolean;
referenceTable: string;
referenceColumn: string;
sourceColumn: string;
filterColumn?: string;
filterValue?: 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),
};
}
// Entity 조인 설정 감지 (화면별 엔티티 설정 전달)
let joinConfigs = await entityJoinService.detectEntityJoins(
tableName,
options.screenEntityConfigs
);
logger.info(
`🔍 detectEntityJoins 결과: ${joinConfigs.length}개 조인 설정`
);
if (joinConfigs.length > 0) {
joinConfigs.forEach((config, index) => {
logger.info(
` 조인 ${index + 1}: ${config.sourceColumn} -> ${config.referenceTable} AS ${config.aliasColumn}`
);
});
}
// 추가 조인 컬럼 정보가 있으면 조인 설정에 추가
if (
options.additionalJoinColumns &&
options.additionalJoinColumns.length > 0
) {
logger.info(
`추가 조인 컬럼 처리: ${options.additionalJoinColumns.length}`
);
logger.info(
"📋 전달받은 additionalJoinColumns:",
options.additionalJoinColumns
);
for (const additionalColumn of options.additionalJoinColumns) {
2025-09-24 15:02:54 +09:00
// 🔍 sourceColumn을 기준으로 기존 조인 설정 찾기 (dept_code로 찾기)
const baseJoinConfig = joinConfigs.find(
2025-09-24 15:02:54 +09:00
(config) => config.sourceColumn === additionalColumn.sourceColumn
);
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
// 추가 조인 컬럼 설정 생성
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)
separator: " - ", // 기본 구분자
};
joinConfigs.push(additionalJoinConfig);
logger.info(
2025-09-24 15:02:54 +09:00
`✅ 추가 조인 컬럼 설정 추가: ${additionalJoinConfig.aliasColumn} -> ${actualColumnName}`
);
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 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 절 구성
let whereClause = await this.buildWhereClause(tableName, options.search);
2025-09-16 15:13:00 +09:00
// 멀티테넌시 필터 추가 (company_code)
if (options.companyCode) {
const companyFilter = `main.company_code = '${options.companyCode.replace(/'/g, "''")}'`;
whereClause = whereClause
? `${whereClause} AND ${companyFilter}`
: companyFilter;
logger.info(
`🔒 멀티테넌시 필터 추가 (Entity 조인): company_code = ${options.companyCode}`
);
}
// 🆕 데이터 필터 적용 (Entity 조인) - 파라미터 바인딩 없이 직접 값 삽입
if (
options.dataFilter &&
options.dataFilter.enabled &&
options.dataFilter.filters &&
options.dataFilter.filters.length > 0
) {
const filterConditions: string[] = [];
for (const filter of options.dataFilter.filters) {
const { columnName, operator, value } = filter;
if (!columnName || value === undefined || value === null) {
continue;
}
const safeColumn = `main."${columnName}"`;
switch (operator) {
case "equals":
filterConditions.push(
`${safeColumn} = '${String(value).replace(/'/g, "''")}'`
);
break;
case "not_equals":
filterConditions.push(
`${safeColumn} != '${String(value).replace(/'/g, "''")}'`
);
break;
case "in":
if (Array.isArray(value) && value.length > 0) {
const values = value
.map((v) => `'${String(v).replace(/'/g, "''")}'`)
.join(", ");
filterConditions.push(`${safeColumn} IN (${values})`);
}
break;
case "not_in":
if (Array.isArray(value) && value.length > 0) {
const values = value
.map((v) => `'${String(v).replace(/'/g, "''")}'`)
.join(", ");
filterConditions.push(`${safeColumn} NOT IN (${values})`);
}
break;
case "contains":
filterConditions.push(
`${safeColumn} LIKE '%${String(value).replace(/'/g, "''")}%'`
);
break;
case "starts_with":
filterConditions.push(
`${safeColumn} LIKE '${String(value).replace(/'/g, "''")}%'`
);
break;
case "ends_with":
filterConditions.push(
`${safeColumn} LIKE '%${String(value).replace(/'/g, "''")}'`
);
break;
case "is_null":
filterConditions.push(`${safeColumn} IS NULL`);
break;
case "is_not_null":
filterConditions.push(`${safeColumn} IS NOT NULL`);
break;
}
}
if (filterConditions.length > 0) {
const logicalOperator =
options.dataFilter.matchType === "any" ? " OR " : " AND ";
const filterWhere = `(${filterConditions.join(logicalOperator)})`;
whereClause = whereClause
? `${whereClause} AND ${filterWhere}`
: filterWhere;
logger.info(`🔍 데이터 필터 적용 (Entity 조인): ${filterWhere}`);
}
}
2025-12-04 18:26:35 +09:00
// 🆕 제외 필터 적용 (다른 테이블에 이미 존재하는 데이터 제외)
if (options.excludeFilter && options.excludeFilter.enabled) {
const {
referenceTable,
referenceColumn,
sourceColumn,
filterColumn,
filterValue,
} = options.excludeFilter;
if (referenceTable && referenceColumn && sourceColumn) {
// 서브쿼리로 이미 존재하는 데이터 제외
let excludeSubquery = `main."${sourceColumn}" NOT IN (
SELECT "${referenceColumn}" FROM "${referenceTable}"
WHERE "${referenceColumn}" IS NOT NULL`;
// 추가 필터 조건이 있으면 적용 (예: 특정 거래처의 품목만 제외)
if (
filterColumn &&
filterValue !== undefined &&
filterValue !== null
) {
2025-12-04 18:26:35 +09:00
excludeSubquery += ` AND "${filterColumn}" = '${String(filterValue).replace(/'/g, "''")}'`;
}
excludeSubquery += ")";
whereClause = whereClause
? `${whereClause} AND ${excludeSubquery}`
: excludeSubquery;
logger.info(`🚫 제외 필터 적용 (Entity 조인):`, {
referenceTable,
referenceColumn,
sourceColumn,
filterColumn,
filterValue,
excludeSubquery,
});
}
}
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
);
// ⚠️ SQL 쿼리 로깅 (디버깅용)
logger.info(`🔍 [executeJoinQuery] 실행할 SQL:\n${dataQuery}`);
2025-09-16 15:13:00 +09:00
// 병렬 실행
const [dataResult, countResult] = await Promise.all([
query(dataQuery),
query(countQuery),
2025-09-16 15:13:00 +09:00
]);
logger.info(
`✅ [executeJoinQuery] 조회 완료: ${dataResult?.length}개 행`
);
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;
companyCode?: string;
2025-09-16 15:13:00 +09:00
},
startTime: number
): Promise<EntityJoinResponse> {
try {
// 캐시 데이터 미리 로드
for (const config of joinConfigs) {
const displayCol =
config.displayColumn ||
config.displayColumns?.[0] ||
config.referenceColumn;
logger.info(
`🔍 캐시 로드 - ${config.referenceTable}: keyCol=${config.referenceColumn}, displayCol=${displayCol}`
);
2025-09-16 16:53:03 +09:00
await referenceCacheService.getCachedReference(
2025-09-16 15:13:00 +09:00
config.referenceTable,
config.referenceColumn,
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;
}),
];
// 🆕 테이블명.컬럼명 형식도 Entity 검색으로 인식
const hasJoinTableSearch =
options.search &&
Object.keys(options.search).some((key) => key.includes("."));
2025-09-17 11:15:34 +09:00
const hasEntitySearch =
options.search &&
(Object.keys(options.search).some((key) =>
2025-09-17 11:15:34 +09:00
allEntityColumns.includes(key)
) ||
hasJoinTableSearch);
2025-09-17 11:15:34 +09:00
if (hasEntitySearch) {
const entitySearchKeys = options.search
? Object.keys(options.search).filter(
(key) => allEntityColumns.includes(key) || key.includes(".")
2025-09-17 11:15:34 +09:00
)
: [];
logger.info(
`🔍 Entity 조인 컬럼 검색 감지: ${entitySearchKeys.join(", ")}`
);
}
let basicResult;
if (hasEntitySearch) {
// Entity 조인 컬럼으로 검색하는 경우 SQL JOIN 방식 사용
logger.info("🔍 Entity 조인 컬럼 검색 감지, SQL JOIN 방식으로 전환");
try {
// 테이블 컬럼 정보 조회
const columns = await this.getTableColumns(tableName);
const selectColumns = columns.data.map((col: any) => col.column_name);
// Entity 조인 컬럼 검색을 위한 WHERE 절 구성
const whereConditions: string[] = [];
const entitySearchColumns: string[] = [];
// Entity 조인 쿼리 생성하여 별칭 매핑 얻기
const joinQueryResult = entityJoinService.buildJoinQuery(
tableName,
joinConfigs,
selectColumns,
"", // WHERE 절은 나중에 추가
options.sortBy
? `main.${options.sortBy} ${options.sortOrder || "ASC"}`
: undefined,
options.size,
(options.page - 1) * options.size
);
const aliasMap = joinQueryResult.aliasMap;
logger.info(
`🔧 [검색] 별칭 매핑 사용: ${Array.from(aliasMap.entries())
.map(([table, alias]) => `${table}${alias}`)
.join(", ")}`
);
if (options.search) {
for (const [key, value] of Object.entries(options.search)) {
// 검색값 추출 (객체 형태일 수 있음)
let searchValue = value;
if (
typeof value === "object" &&
value !== null &&
"value" in value
) {
searchValue = value.value;
}
// 빈 값이면 스킵
if (
searchValue === "__ALL__" ||
searchValue === "" ||
searchValue === null ||
searchValue === undefined
) {
continue;
}
const safeValue = String(searchValue).replace(/'/g, "''");
// 🆕 테이블명.컬럼명 형식 처리 (예: item_info.item_name)
if (key.includes(".")) {
const [refTable, refColumn] = key.split(".");
// aliasMap에서 별칭 찾기 (테이블명:소스컬럼 형식)
let foundAlias: string | undefined;
for (const [aliasKey, alias] of aliasMap.entries()) {
if (aliasKey.startsWith(`${refTable}:`)) {
foundAlias = alias;
break;
}
}
if (foundAlias) {
whereConditions.push(
`${foundAlias}.${refColumn}::text ILIKE '%${safeValue}%'`
);
entitySearchColumns.push(`${key} (${refTable}.${refColumn})`);
logger.info(
`🎯 조인 테이블 검색: ${key}${refTable}.${refColumn} LIKE '%${safeValue}%' (별칭: ${foundAlias})`
);
} else {
logger.warn(
`⚠️ 조인 테이블 검색 실패: ${key} - 별칭을 찾을 수 없음`
);
}
continue;
}
2025-09-17 11:15:34 +09:00
const joinConfig = joinConfigs.find(
(config) => config.aliasColumn === key
);
if (joinConfig) {
// 기본 Entity 조인 컬럼인 경우: 조인된 테이블의 표시 컬럼에서 검색
const aliasKey = `${joinConfig.referenceTable}:${joinConfig.sourceColumn}`;
const alias = aliasMap.get(aliasKey);
2025-09-17 11:15:34 +09:00
whereConditions.push(
`${alias}.${joinConfig.displayColumn} ILIKE '%${safeValue}%'`
2025-09-17 11:15:34 +09:00
);
entitySearchColumns.push(
`${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})`
);
logger.info(
`🎯 Entity 조인 검색: ${key}${joinConfig.referenceTable}.${joinConfig.displayColumn} LIKE '%${safeValue}%' (별칭: ${alias})`
2025-09-17 11:15:34 +09:00
);
} else if (key === "writer_dept_code") {
// writer_dept_code: user_info.dept_code에서 검색
const userAliasKey = Array.from(aliasMap.keys()).find((k) =>
k.startsWith("user_info:")
2025-09-17 11:15:34 +09:00
);
const userAlias = userAliasKey
? aliasMap.get(userAliasKey)
: undefined;
if (userAlias) {
whereConditions.push(
`${userAlias}.dept_code ILIKE '%${safeValue}%'`
);
entitySearchColumns.push(`${key} (user_info.dept_code)`);
logger.info(
`🎯 추가 Entity 조인 검색: ${key} → user_info.dept_code LIKE '%${safeValue}%' (별칭: ${userAlias})`
);
}
2025-09-17 11:15:34 +09:00
} else if (key === "company_code_status") {
// company_code_status: company_info.status에서 검색
const companyAliasKey = Array.from(aliasMap.keys()).find((k) =>
k.startsWith("company_info:")
2025-09-17 11:15:34 +09:00
);
const companyAlias = companyAliasKey
? aliasMap.get(companyAliasKey)
: undefined;
if (companyAlias) {
whereConditions.push(
`${companyAlias}.status ILIKE '%${safeValue}%'`
);
entitySearchColumns.push(`${key} (company_info.status)`);
logger.info(
`🎯 추가 Entity 조인 검색: ${key} → company_info.status LIKE '%${safeValue}%' (별칭: ${companyAlias})`
);
}
2025-09-17 11:15:34 +09:00
} else {
// 일반 컬럼인 경우: 메인 테이블에서 검색
whereConditions.push(`main.${key} ILIKE '%${safeValue}%'`);
2025-09-17 11:15:34 +09:00
logger.info(
`🔍 일반 컬럼 검색: ${key} → main.${key} LIKE '%${safeValue}%'`
2025-09-17 11:15:34 +09:00
);
}
}
}
const whereClause = whereConditions.join(" AND ");
const orderBy = options.sortBy
? `main.${options.sortBy} ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
: "";
// 페이징 계산
const offset = (options.page - 1) * options.size;
// SQL JOIN 쿼리 실행
const joinResult = await this.executeJoinQuery(
tableName,
joinConfigs,
selectColumns,
whereClause,
orderBy,
options.size,
offset,
startTime
);
return joinResult;
} catch (joinError) {
logger.error(
`Entity 조인 검색 실패, 캐시 방식으로 폴백: ${tableName}`,
joinError
);
// Entity 조인 검색 실패 시 Entity 조인 컬럼을 제외한 검색 조건으로 캐시 방식 사용
const fallbackOptions = { ...options };
if (options.search) {
const filteredSearch: Record<string, any> = {};
// Entity 조인 컬럼을 제외한 검색 조건만 유지
for (const [key, value] of Object.entries(options.search)) {
const isEntityColumn = joinConfigs.some(
(config) => config.aliasColumn === key
);
if (!isEntityColumn) {
filteredSearch[key] = value;
}
}
fallbackOptions.search = filteredSearch;
logger.info(
`🔄 Entity 조인 에러 시 검색 조건 필터링: ${Object.keys(filteredSearch).join(", ")}`
);
}
basicResult = await this.getTableData(tableName, {
...fallbackOptions,
companyCode: options.companyCode,
});
2025-09-17 11:15:34 +09:00
}
} else {
// Entity 조인 컬럼 검색이 없는 경우 기존 캐시 방식 사용
basicResult = await this.getTableData(tableName, {
...options,
companyCode: options.companyCode,
});
2025-09-17 11:15:34 +09:00
}
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,
config.displayColumn || config.displayColumns[0],
2025-09-16 15:13:00 +09:00
String(sourceValue)
);
// 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 {
// 🆕 조인 테이블 컬럼 검색 처리 (예: item_info.item_name)
if (columnName.includes(".")) {
const [refTable, refColumn] = columnName.split(".");
// 검색값 추출
let searchValue = value;
if (typeof value === "object" && value !== null && "value" in value) {
searchValue = value.value;
}
if (
searchValue === "__ALL__" ||
searchValue === "" ||
searchValue === null
) {
continue;
}
// 🔍 column_labels에서 해당 엔티티 설정 찾기
// 예: item_info 테이블을 참조하는 컬럼 찾기 (item_code → item_info)
const entityColumnResult = await query<{
column_name: string;
reference_table: string;
reference_column: string;
}>(
`SELECT column_name, reference_table, reference_column
FROM column_labels
WHERE table_name = $1
AND input_type = 'entity'
AND reference_table = $2
LIMIT 1`,
[tableName, refTable]
);
if (entityColumnResult.length > 0) {
// 조인 별칭 생성 (entityJoinService.ts와 동일한 패턴: 테이블명 앞 3글자)
const joinAlias = refTable.substring(0, 3);
// 조인 테이블 컬럼으로 검색 조건 생성
const safeValue = String(searchValue).replace(/'/g, "''");
const condition = `${joinAlias}.${refColumn}::text ILIKE '%${safeValue}%'`;
logger.info(`🔍 조인 테이블 검색 조건: ${condition}`);
conditions.push(condition);
} else {
logger.warn(
`⚠️ 조인 테이블 검색 실패: ${columnName} - 엔티티 설정을 찾을 수 없음`
);
}
continue;
}
2025-09-23 14:26:18 +09:00
// 고급 검색 조건 구성
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 {
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}`);
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 {
// whereClause에서 company_code 추출 (멀티테넌시 필터)
const companyCodeMatch = whereClause.match(
/main\.company_code\s*=\s*'([^']+)'/
);
const companyCode = companyCodeMatch ? companyCodeMatch[1] : undefined;
2025-09-16 16:53:03 +09:00
return await this.executeCachedLookup(
tableName,
cacheableJoins,
{
page: Math.floor(offset / limit) + 1,
size: limit,
search: {},
companyCode,
},
2025-09-16 16:53:03 +09:00
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) {
// table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인
if (config.referenceTable === "table_column_category_values") {
dbJoins.push(config);
console.log(`🔗 DB 조인 (특수 조건): ${config.referenceTable}`);
continue;
}
2025-09-16 16:53:03 +09:00
// 캐시 가능성 확인
const cachedData = await referenceCacheService.getCachedReference(
config.referenceTable,
config.referenceColumn,
config.displayColumn || config.displayColumns[0]
2025-09-16 16:53:03 +09:00
);
if (cachedData && cachedData.size > 0) {
cacheableJoins.push(config);
console.log(
`📋 캐시 사용: ${config.referenceTable} (${cachedData.size}건)`
);
} else {
dbJoins.push(config);
console.log(`🔗 DB 조인: ${config.referenceTable}`);
}
}
return { cacheableJoins, dbJoins };
}
/**
* DB
*/
private async executeJoinThenCache(
tableName: string,
dbJoins: EntityJoinConfig[],
cacheableJoins: EntityJoinConfig[],
selectColumns: string[],
whereClause: string,
orderBy: string,
limit: number,
offset: number,
startTime: number
): Promise<EntityJoinResponse> {
// 1. DB 조인 먼저 실행
const joinResult = await this.executeJoinQuery(
tableName,
dbJoins,
selectColumns,
whereClause,
orderBy,
limit,
offset,
startTime
);
// 2. 캐시 가능한 조인들을 결과에 추가 적용
if (cacheableJoins.length > 0) {
const enhancedData = await this.applyCacheLookupToData(
joinResult.data,
cacheableJoins
);
return {
...joinResult,
data: enhancedData,
entityJoinInfo: {
...joinResult.entityJoinInfo!,
strategy: "hybrid",
performance: {
...joinResult.entityJoinInfo!.performance,
cacheHitRate: await this.calculateCacheHitRate(cacheableJoins),
hybridBreakdown: {
dbJoins: dbJoins.length,
cacheJoins: cacheableJoins.length,
},
},
},
};
}
return joinResult;
}
/**
*
*/
private async applyCacheLookupToData(
data: any[],
cacheableJoins: EntityJoinConfig[]
): Promise<any[]> {
const enhancedData = [...data];
for (const config of cacheableJoins) {
const cachedData = await referenceCacheService.getCachedReference(
config.referenceTable,
config.referenceColumn,
config.displayColumn || config.displayColumns[0]
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));
// null이나 undefined인 경우 빈 문자열로 설정
row[config.aliasColumn] = lookupValue || "";
} else {
// sourceValue가 없는 경우도 빈 문자열로 설정
row[config.aliasColumn] = "";
2025-09-16 16:53:03 +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,
config.displayColumn || config.displayColumns[0]
2025-09-16 16:53:03 +09:00
);
totalHitRate += hitRate;
}
return totalHitRate / cacheableJoins.length;
}
/**
* ( )
*/
async getTableSchema(tableName: string): Promise<ColumnTypeInfo[]> {
try {
logger.info(`테이블 스키마 정보 조회: ${tableName}`);
const rawColumns = await query<any>(
`SELECT
column_name as "columnName",
column_name as "displayName",
data_type as "dataType",
udt_name as "dbType",
is_nullable as "isNullable",
column_default as "defaultValue",
character_maximum_length as "maxLength",
numeric_precision as "numericPrecision",
numeric_scale as "numericScale",
CASE
WHEN column_name IN (
SELECT column_name FROM information_schema.key_column_usage
WHERE table_name = $1 AND constraint_name LIKE '%_pkey'
) THEN true
ELSE false
END as "isPrimaryKey"
FROM information_schema.columns
WHERE table_name = $1
AND table_schema = 'public'
ORDER BY ordinal_position`,
[tableName]
);
const columns: ColumnTypeInfo[] = rawColumns.map((col) => ({
tableName: tableName,
columnName: col.columnName,
displayName: col.displayName,
dataType: col.dataType,
dbType: col.dbType,
webType: "text", // 기본값
inputType: "direct",
detailSettings: "{}",
description: "", // 필수 필드 추가
isNullable: col.isNullable,
isPrimaryKey: col.isPrimaryKey,
defaultValue: col.defaultValue,
maxLength: col.maxLength ? Number(col.maxLength) : undefined,
numericPrecision: col.numericPrecision
? Number(col.numericPrecision)
: undefined,
numericScale: col.numericScale ? Number(col.numericScale) : undefined,
displayOrder: 0,
isVisible: true,
}));
logger.info(
`테이블 스키마 조회 완료: ${tableName}, ${columns.length}개 컬럼`
);
return columns;
} catch (error) {
logger.error(`테이블 스키마 조회 실패: ${tableName}`, error);
throw error;
}
}
/**
*
*/
async checkTableExists(tableName: string): Promise<boolean> {
try {
logger.info(`테이블 존재 여부 확인: ${tableName}`);
const result = await query<any>(
`SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = $1
AND table_schema = 'public'
AND table_type = 'BASE TABLE'
) as "exists"`,
[tableName]
);
const exists = result[0]?.exists || false;
logger.info(`테이블 존재 여부: ${tableName} = ${exists}`);
return exists;
} catch (error) {
logger.error(`테이블 존재 여부 확인 실패: ${tableName}`, error);
throw error;
}
}
/**
2025-09-23 10:40:21 +09:00
* ( )
2025-11-06 17:01:13 +09:00
* @param companyCode - ()
*/
2025-11-06 17:01:13 +09:00
async getColumnInputTypes(
tableName: string,
companyCode: string
): Promise<ColumnTypeInfo[]> {
try {
2025-11-06 17:01:13 +09:00
logger.info(
`컬럼 입력타입 정보 조회: ${tableName}, company: ${companyCode}`
);
2025-11-06 17:01:13 +09:00
// table_type_columns에서 입력타입 정보 조회 (company_code 필터링)
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
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]
);
// category_column_mapping 테이블 존재 여부 확인
const tableExistsResult = await query<any>(
`SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'category_column_mapping'
) as table_exists`
);
const mappingTableExists = tableExistsResult[0]?.table_exists === true;
// 카테고리 컬럼의 경우, 매핑된 메뉴 목록 조회
let categoryMappings: Map<string, number[]> = new Map();
if (mappingTableExists) {
logger.info("카테고리 매핑 조회 시작", { tableName, companyCode });
const mappings = await query<any>(
`SELECT
logical_column_name as "columnName",
menu_objid as "menuObjid"
FROM category_column_mapping
WHERE table_name = $1
AND company_code = $2`,
[tableName, companyCode]
);
logger.info("카테고리 매핑 조회 완료", {
tableName,
companyCode,
mappingCount: mappings.length,
mappings: mappings,
});
mappings.forEach((m: any) => {
if (!categoryMappings.has(m.columnName)) {
categoryMappings.set(m.columnName, []);
}
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
});
logger.info("categoryMappings Map 생성 완료", {
size: categoryMappings.size,
entries: Array.from(categoryMappings.entries()),
});
} else {
logger.warn("category_column_mapping 테이블이 존재하지 않음");
}
const inputTypes: ColumnTypeInfo[] = rawInputTypes.map((col) => {
const baseInfo = {
tableName: tableName,
columnName: col.columnName,
displayName: col.displayName,
dataType: col.dataType || "varchar",
inputType: col.inputType,
detailSettings: col.detailSettings,
description: "", // 필수 필드 추가
isNullable: col.isNullable === "Y" ? "Y" : "N", // 🔥 FIX: string 타입으로 변환
isPrimaryKey: false,
displayOrder: 0,
isVisible: true,
};
// 카테고리 타입인 경우 categoryMenus 추가
if (
col.inputType === "category" &&
categoryMappings.has(col.columnName)
) {
const menus = categoryMappings.get(col.columnName);
logger.info(`✅ 컬럼 ${col.columnName}에 카테고리 메뉴 추가`, {
menus,
});
return {
...baseInfo,
categoryMenus: menus,
};
}
if (col.inputType === "category") {
logger.warn(`⚠️ 카테고리 컬럼 ${col.columnName}에 매핑 없음`);
}
return baseInfo;
});
logger.info(
2025-11-06 17:01:13 +09:00
`컬럼 입력타입 정보 조회 완료: ${tableName}, company: ${companyCode}, ${inputTypes.length}개 컬럼`
);
2025-09-23 10:40:21 +09:00
return inputTypes;
} catch (error) {
2025-11-06 17:01:13 +09:00
logger.error(
`컬럼 입력타입 정보 조회 실패: ${tableName}, company: ${companyCode}`,
error
);
throw error;
}
}
2025-09-23 10:40:21 +09:00
/**
* 지원: 컬럼
* @deprecated getColumnInputTypes
*/
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
}
/**
*
*/
async checkDatabaseConnection(): Promise<{
connected: boolean;
message: string;
}> {
try {
logger.info("데이터베이스 연결 상태 확인");
// 간단한 쿼리로 연결 테스트
const result = await query<any>(`SELECT 1 as "test"`);
if (result && result.length > 0) {
logger.info("데이터베이스 연결 성공");
return {
connected: true,
message: "데이터베이스에 성공적으로 연결되었습니다.",
};
} else {
logger.warn("데이터베이스 연결 응답 없음");
return {
connected: false,
message: "데이터베이스 연결 응답이 없습니다.",
};
}
} catch (error) {
logger.error("데이터베이스 연결 확인 실패:", error);
return {
connected: false,
message: `데이터베이스 연결 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`,
};
}
}
/**
*
*/
private inferWebType(dataType: string): WebType {
// 통합 타입 매핑에서 import
const { DB_TYPE_TO_WEB_TYPE } = require("../types/unified-web-types");
const lowerType = dataType.toLowerCase();
// 정확한 매핑 우선 확인
if (DB_TYPE_TO_WEB_TYPE[lowerType]) {
return DB_TYPE_TO_WEB_TYPE[lowerType];
}
// 부분 문자열 매칭 (더 정교한 규칙)
for (const [dbType, webType] of Object.entries(DB_TYPE_TO_WEB_TYPE)) {
if (
lowerType.includes(dbType.toLowerCase()) ||
dbType.toLowerCase().includes(lowerType)
) {
return webType as WebType;
}
}
// 추가 정밀 매핑
if (lowerType.includes("int") && !lowerType.includes("point")) {
return "number";
} else if (lowerType.includes("numeric") || lowerType.includes("decimal")) {
return "decimal";
} else if (
lowerType.includes("timestamp") ||
lowerType.includes("datetime")
) {
return "datetime";
} else if (lowerType.includes("date")) {
return "date";
} else if (lowerType.includes("time")) {
return "datetime";
} else if (lowerType.includes("bool")) {
return "checkbox";
} else if (
lowerType.includes("char") ||
lowerType.includes("text") ||
lowerType.includes("varchar")
) {
return lowerType.includes("text") ? "textarea" : "text";
}
// 기본값
return "text";
}
// ========================================
// 🎯 테이블 로그 시스템
// ========================================
/**
*
*/
async createLogTable(
tableName: string,
pkColumn: { columnName: string; dataType: string },
userId?: string
): Promise<void> {
try {
const logTableName = `${tableName}_log`;
const triggerFuncName = `${tableName}_log_trigger_func`;
const triggerName = `${tableName}_audit_trigger`;
logger.info(`로그 테이블 생성 시작: ${logTableName}`);
// 로그 테이블 DDL 생성
const logTableDDL = this.generateLogTableDDL(
logTableName,
tableName,
pkColumn.columnName,
pkColumn.dataType
);
// 트리거 함수 DDL 생성
const triggerFuncDDL = this.generateTriggerFunctionDDL(
triggerFuncName,
logTableName,
tableName,
pkColumn.columnName
);
// 트리거 DDL 생성
const triggerDDL = this.generateTriggerDDL(
triggerName,
tableName,
triggerFuncName
);
// 트랜잭션으로 실행
await transaction(async (client) => {
// 1. 로그 테이블 생성
await client.query(logTableDDL);
logger.info(`로그 테이블 생성 완료: ${logTableName}`);
// 2. 트리거 함수 생성
await client.query(triggerFuncDDL);
logger.info(`트리거 함수 생성 완료: ${triggerFuncName}`);
// 3. 트리거 생성
await client.query(triggerDDL);
logger.info(`트리거 생성 완료: ${triggerName}`);
// 4. 로그 설정 저장
await client.query(
`INSERT INTO table_log_config (
original_table_name, log_table_name, trigger_name,
trigger_function_name, created_by
) VALUES ($1, $2, $3, $4, $5)`,
[tableName, logTableName, triggerName, triggerFuncName, userId]
);
logger.info(`로그 설정 저장 완료: ${tableName}`);
});
logger.info(`로그 테이블 생성 완료: ${logTableName}`);
} catch (error) {
logger.error(`로그 테이블 생성 실패: ${tableName}`, error);
throw new Error(
`로그 테이블 생성 실패: ${error instanceof Error ? error.message : "Unknown error"}`
);
}
}
/**
* DDL
*/
private generateLogTableDDL(
logTableName: string,
originalTableName: string,
pkColumnName: string,
pkDataType: string
): string {
return `
CREATE TABLE ${logTableName} (
log_id SERIAL PRIMARY KEY,
operation_type VARCHAR(10) NOT NULL,
original_id VARCHAR(100),
changed_column VARCHAR(100),
old_value TEXT,
new_value TEXT,
changed_by VARCHAR(50),
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ip_address VARCHAR(50),
user_agent TEXT,
full_row_before JSONB,
full_row_after JSONB
);
CREATE INDEX idx_${logTableName}_original_id ON ${logTableName}(original_id);
CREATE INDEX idx_${logTableName}_changed_at ON ${logTableName}(changed_at);
CREATE INDEX idx_${logTableName}_operation ON ${logTableName}(operation_type);
COMMENT ON TABLE ${logTableName} IS '${originalTableName} 테이블 변경 이력';
COMMENT ON COLUMN ${logTableName}.operation_type IS '작업 유형 (INSERT/UPDATE/DELETE)';
COMMENT ON COLUMN ${logTableName}.original_id IS '원본 테이블 PK 값';
COMMENT ON COLUMN ${logTableName}.changed_column IS '변경된 컬럼명';
COMMENT ON COLUMN ${logTableName}.old_value IS '변경 전 값';
COMMENT ON COLUMN ${logTableName}.new_value IS '변경 후 값';
COMMENT ON COLUMN ${logTableName}.changed_by IS '변경자 ID';
COMMENT ON COLUMN ${logTableName}.changed_at IS '변경 시각';
COMMENT ON COLUMN ${logTableName}.ip_address IS '변경 요청 IP';
COMMENT ON COLUMN ${logTableName}.full_row_before IS '변경 전 전체 행 (JSON)';
COMMENT ON COLUMN ${logTableName}.full_row_after IS '변경 후 전체 행 (JSON)';
`;
}
/**
* DDL
*/
private generateTriggerFunctionDDL(
funcName: string,
logTableName: string,
originalTableName: string,
pkColumnName: string
): string {
return `
CREATE OR REPLACE FUNCTION ${funcName}()
RETURNS TRIGGER AS $$
DECLARE
v_column_name TEXT;
v_old_value TEXT;
v_new_value TEXT;
v_user_id VARCHAR(50);
v_ip_address VARCHAR(50);
BEGIN
v_user_id := current_setting('app.user_id', TRUE);
v_ip_address := current_setting('app.ip_address', TRUE);
IF (TG_OP = 'INSERT') THEN
EXECUTE format(
'INSERT INTO ${logTableName} (operation_type, original_id, changed_by, ip_address, full_row_after)
VALUES ($1, ($2).%I, $3, $4, $5)',
'${pkColumnName}'
)
USING 'INSERT', NEW, v_user_id, v_ip_address, row_to_json(NEW)::jsonb;
RETURN NEW;
ELSIF (TG_OP = 'UPDATE') THEN
FOR v_column_name IN
SELECT column_name
FROM information_schema.columns
WHERE table_name = '${originalTableName}'
AND table_schema = 'public'
LOOP
EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name)
INTO v_old_value, v_new_value
USING OLD, NEW;
IF v_old_value IS DISTINCT FROM v_new_value THEN
EXECUTE format(
'INSERT INTO ${logTableName} (operation_type, original_id, changed_column, old_value, new_value, changed_by, ip_address, full_row_before, full_row_after)
VALUES ($1, ($2).%I, $3, $4, $5, $6, $7, $8, $9)',
'${pkColumnName}'
)
USING 'UPDATE', NEW, v_column_name, v_old_value, v_new_value, v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb;
END IF;
END LOOP;
RETURN NEW;
ELSIF (TG_OP = 'DELETE') THEN
EXECUTE format(
'INSERT INTO ${logTableName} (operation_type, original_id, changed_by, ip_address, full_row_before)
VALUES ($1, ($2).%I, $3, $4, $5)',
'${pkColumnName}'
)
USING 'DELETE', OLD, v_user_id, v_ip_address, row_to_json(OLD)::jsonb;
RETURN OLD;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
`;
}
/**
* DDL
*/
private generateTriggerDDL(
triggerName: string,
tableName: string,
funcName: string
): string {
return `
CREATE TRIGGER ${triggerName}
AFTER INSERT OR UPDATE OR DELETE ON ${tableName}
FOR EACH ROW EXECUTE FUNCTION ${funcName}();
`;
}
/**
*
*/
async getLogConfig(tableName: string): Promise<{
originalTableName: string;
logTableName: string;
triggerName: string;
triggerFunctionName: string;
isActive: string;
createdAt: Date;
createdBy: string;
} | null> {
try {
logger.info(`로그 설정 조회: ${tableName}`);
const result = await queryOne<{
original_table_name: string;
log_table_name: string;
trigger_name: string;
trigger_function_name: string;
is_active: string;
created_at: Date;
created_by: string;
}>(
`SELECT
original_table_name, log_table_name, trigger_name,
trigger_function_name, is_active, created_at, created_by
FROM table_log_config
WHERE original_table_name = $1`,
[tableName]
);
if (!result) {
return null;
}
return {
originalTableName: result.original_table_name,
logTableName: result.log_table_name,
triggerName: result.trigger_name,
triggerFunctionName: result.trigger_function_name,
isActive: result.is_active,
createdAt: result.created_at,
createdBy: result.created_by,
};
} catch (error) {
logger.error(`로그 설정 조회 실패: ${tableName}`, error);
throw error;
}
}
/**
*
*/
async getLogData(
tableName: string,
options: {
page: number;
size: number;
operationType?: string;
startDate?: string;
endDate?: string;
changedBy?: string;
originalId?: string;
}
): Promise<{
data: any[];
total: number;
page: number;
size: number;
totalPages: number;
}> {
try {
const logTableName = `${tableName}_log`;
const offset = (options.page - 1) * options.size;
logger.info(`로그 데이터 조회: ${logTableName}`, options);
// WHERE 조건 구성
const whereConditions: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (options.operationType) {
whereConditions.push(`operation_type = $${paramIndex}`);
values.push(options.operationType);
paramIndex++;
}
if (options.startDate) {
whereConditions.push(`changed_at >= $${paramIndex}::timestamp`);
values.push(options.startDate);
paramIndex++;
}
if (options.endDate) {
whereConditions.push(`changed_at <= $${paramIndex}::timestamp`);
values.push(options.endDate);
paramIndex++;
}
if (options.changedBy) {
whereConditions.push(`changed_by = $${paramIndex}`);
values.push(options.changedBy);
paramIndex++;
}
if (options.originalId) {
whereConditions.push(`original_id::text = $${paramIndex}`);
values.push(options.originalId);
paramIndex++;
}
const whereClause =
whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
// 전체 개수 조회
const countQuery = `SELECT COUNT(*) as count FROM ${logTableName} ${whereClause}`;
const countResult = await query<any>(countQuery, values);
const total = parseInt(countResult[0].count);
// 데이터 조회
const dataQuery = `
SELECT * FROM ${logTableName}
${whereClause}
ORDER BY changed_at DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
const data = await query<any>(dataQuery, [
...values,
options.size,
offset,
]);
const totalPages = Math.ceil(total / options.size);
logger.info(
`로그 데이터 조회 완료: ${logTableName}, 총 ${total}건, ${data.length}개 반환`
);
return {
data,
total,
page: options.page,
size: options.size,
totalPages,
};
} catch (error) {
logger.error(`로그 데이터 조회 실패: ${tableName}`, error);
throw error;
}
}
/**
* /
*/
async toggleLogTable(tableName: string, isActive: boolean): Promise<void> {
try {
const logConfig = await this.getLogConfig(tableName);
if (!logConfig) {
throw new Error(`로그 설정을 찾을 수 없습니다: ${tableName}`);
}
logger.info(
`로그 테이블 ${isActive ? "활성화" : "비활성화"}: ${tableName}`
);
await transaction(async (client) => {
// 트리거 활성화/비활성화
if (isActive) {
await client.query(
`ALTER TABLE ${tableName} ENABLE TRIGGER ${logConfig.triggerName}`
);
} else {
await client.query(
`ALTER TABLE ${tableName} DISABLE TRIGGER ${logConfig.triggerName}`
);
}
// 설정 업데이트
await client.query(
`UPDATE table_log_config
SET is_active = $1, updated_at = NOW()
WHERE original_table_name = $2`,
[isActive ? "Y" : "N", tableName]
);
});
logger.info(
`로그 테이블 ${isActive ? "활성화" : "비활성화"} 완료: ${tableName}`
);
} catch (error) {
logger.error(
`로그 테이블 ${isActive ? "활성화" : "비활성화"} 실패: ${tableName}`,
error
);
throw error;
}
}
/**
*
*/
async hasColumn(tableName: string, columnName: string): Promise<boolean> {
try {
const result = await query<any>(
`SELECT column_name
FROM information_schema.columns
WHERE table_name = $1 AND column_name = $2`,
[tableName, columnName]
);
return result.length > 0;
} catch (error) {
logger.error(
`컬럼 존재 여부 확인 실패: ${tableName}.${columnName}`,
error
);
return false;
}
}
2025-08-25 14:08:08 +09:00
}