- Integrated `TableManagementService` to validate unique constraints before insert, update, and upsert actions in various controllers, including `dataflowExecutionController`, `dynamicFormController`, and `tableManagementController`. - Improved error handling in `errorHandler` to provide detailed messages indicating which field has a unique constraint violation. - Updated the `formatPgError` utility to extract and display specific column labels for unique constraint violations, enhancing user feedback. - Adjusted the table schema retrieval to include company-specific nullable and unique constraints, ensuring accurate representation of database rules. These changes improve data integrity by preventing duplicate entries and enhance user experience through clearer error messages related to unique constraints.
1076 lines
33 KiB
TypeScript
1076 lines
33 KiB
TypeScript
/**
|
|
* 다중 테이블 엑셀 업로드 범용 서비스
|
|
*
|
|
* 하나의 플랫 엑셀 데이터를 계층적 다중 테이블(2~N개)에
|
|
* 트랜잭션으로 일괄 UPSERT하는 범용 엔진.
|
|
*
|
|
* 적용 사례:
|
|
* - 거래처: customer_mng → customer_item_mapping → customer_item_prices
|
|
* - 공급업체: supplier_mng → supplier_item_mapping → supplier_item_prices
|
|
*/
|
|
|
|
import { getPool } from "../database/db";
|
|
import { logger } from "../utils/logger";
|
|
|
|
// ================================
|
|
// 인터페이스 정의
|
|
// ================================
|
|
|
|
/** 테이블 계층 레벨 설정 */
|
|
export interface TableLevel {
|
|
tableName: string;
|
|
label: string;
|
|
parentFkColumn?: string;
|
|
parentRefColumn?: string;
|
|
upsertMode: "upsert" | "insert";
|
|
upsertKeyColumns?: string[];
|
|
columns: ColumnDef[];
|
|
}
|
|
|
|
/** 컬럼 정의 */
|
|
export interface ColumnDef {
|
|
dbColumn: string;
|
|
excelHeader: string;
|
|
required: boolean;
|
|
defaultValue?: any;
|
|
}
|
|
|
|
/** 업로드 모드 정의 */
|
|
export interface UploadMode {
|
|
id: string;
|
|
label: string;
|
|
description: string;
|
|
activeLevels: number[];
|
|
}
|
|
|
|
/** 테이블 체인 설정 (범용) */
|
|
export interface TableChainConfig {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
levels: TableLevel[];
|
|
uploadModes: UploadMode[];
|
|
}
|
|
|
|
/** 레벨별 업로드 결과 */
|
|
export interface LevelResult {
|
|
tableName: string;
|
|
inserted: number;
|
|
updated: number;
|
|
}
|
|
|
|
/** 전체 업로드 결과 */
|
|
export interface MultiTableUploadResult {
|
|
success: boolean;
|
|
results: LevelResult[];
|
|
totalRows: number;
|
|
errors: string[];
|
|
}
|
|
|
|
// ================================
|
|
// 서비스 클래스
|
|
// ================================
|
|
|
|
class MultiTableExcelService {
|
|
/**
|
|
* 다중 테이블 엑셀 업로드 실행
|
|
*
|
|
* @param config 테이블 체인 설정
|
|
* @param modeId 업로드 모드 ID
|
|
* @param rows 엑셀에서 파싱된 플랫 JSON 배열 (excelHeader 기준)
|
|
* @param companyCode 회사 코드
|
|
* @param userId 사용자 ID
|
|
*/
|
|
async uploadMultiTable(
|
|
config: TableChainConfig,
|
|
modeId: string,
|
|
rows: Record<string, any>[],
|
|
companyCode: string,
|
|
userId: string
|
|
): Promise<MultiTableUploadResult> {
|
|
const result: MultiTableUploadResult = {
|
|
success: false,
|
|
results: [],
|
|
totalRows: rows.length,
|
|
errors: [],
|
|
};
|
|
|
|
const mode = config.uploadModes.find((m) => m.id === modeId);
|
|
if (!mode) {
|
|
result.errors.push(`업로드 모드를 찾을 수 없습니다: ${modeId}`);
|
|
return result;
|
|
}
|
|
|
|
const activeLevels = mode.activeLevels
|
|
.map((i) => config.levels[i])
|
|
.filter(Boolean);
|
|
|
|
if (activeLevels.length === 0) {
|
|
result.errors.push("활성화된 테이블 레벨이 없습니다.");
|
|
return result;
|
|
}
|
|
|
|
// 레벨별 결과 초기화
|
|
for (const level of activeLevels) {
|
|
result.results.push({
|
|
tableName: level.tableName,
|
|
inserted: 0,
|
|
updated: 0,
|
|
});
|
|
}
|
|
|
|
const pool = getPool();
|
|
const client = await pool.connect();
|
|
|
|
try {
|
|
await client.query("BEGIN");
|
|
|
|
// 각 레벨의 실제 DB 컬럼 존재 여부 캐시
|
|
const existingColsCache = new Map<string, Set<string>>();
|
|
for (const level of activeLevels) {
|
|
const colsResult = await client.query(
|
|
`SELECT column_name FROM information_schema.columns
|
|
WHERE table_schema = 'public' AND table_name = $1`,
|
|
[level.tableName]
|
|
);
|
|
existingColsCache.set(
|
|
level.tableName,
|
|
new Set(colsResult.rows.map((r: any) => r.column_name))
|
|
);
|
|
}
|
|
|
|
// 엑셀 헤더 → DB 컬럼 매핑 테이블 구축 (활성 레벨만)
|
|
const headerToColumn = new Map<string, { levelIndex: number; dbColumn: string }>();
|
|
for (let i = 0; i < activeLevels.length; i++) {
|
|
for (const col of activeLevels[i].columns) {
|
|
headerToColumn.set(col.excelHeader, {
|
|
levelIndex: i,
|
|
dbColumn: col.dbColumn,
|
|
});
|
|
}
|
|
}
|
|
|
|
// 행 단위로 처리 (트랜잭션 내)
|
|
// 부모 ID 캐시: 각 레벨에서 upsertKey → 반환된 PK 매핑
|
|
const pkCaches: Map<string, string | number>[] = activeLevels.map(
|
|
() => new Map()
|
|
);
|
|
|
|
for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) {
|
|
const row = rows[rowIdx];
|
|
|
|
try {
|
|
let parentId: string | number | null = null;
|
|
let parentLevelData: Record<string, any> = {};
|
|
|
|
for (let lvlIdx = 0; lvlIdx < activeLevels.length; lvlIdx++) {
|
|
const level = activeLevels[lvlIdx];
|
|
const levelResult = result.results[lvlIdx];
|
|
const existingCols = existingColsCache.get(level.tableName)!;
|
|
|
|
const levelData: Record<string, any> = {};
|
|
for (const colDef of level.columns) {
|
|
const excelValue = row[colDef.excelHeader];
|
|
if (excelValue !== undefined && excelValue !== null && excelValue !== "") {
|
|
levelData[colDef.dbColumn] = excelValue;
|
|
} else if (colDef.defaultValue !== undefined) {
|
|
levelData[colDef.dbColumn] = colDef.defaultValue;
|
|
}
|
|
}
|
|
|
|
const hasAnyData = Object.keys(levelData).length > 0;
|
|
if (!hasAnyData && lvlIdx > 0) {
|
|
break;
|
|
}
|
|
|
|
const missingRequired = level.columns
|
|
.filter((c) => c.required && !levelData[c.dbColumn])
|
|
.map((c) => c.excelHeader);
|
|
|
|
if (missingRequired.length > 0) {
|
|
result.errors.push(
|
|
`[행 ${rowIdx + 1}] ${level.label} 필수 컬럼 누락: ${missingRequired.join(", ")}`
|
|
);
|
|
break;
|
|
}
|
|
|
|
// 부모 FK 주입: parentRefColumn이 'id'가 아닌 경우 부모 데이터에서 해당 컬럼 값 사용
|
|
if (lvlIdx > 0 && level.parentFkColumn && parentId !== null) {
|
|
if (
|
|
level.parentRefColumn &&
|
|
level.parentRefColumn !== "id" &&
|
|
parentLevelData[level.parentRefColumn] !== undefined &&
|
|
parentLevelData[level.parentRefColumn] !== null
|
|
) {
|
|
levelData[level.parentFkColumn] = String(
|
|
parentLevelData[level.parentRefColumn]
|
|
);
|
|
} else {
|
|
levelData[level.parentFkColumn] = String(parentId);
|
|
}
|
|
}
|
|
|
|
if (existingCols.has("company_code")) {
|
|
levelData.company_code = companyCode;
|
|
}
|
|
if (existingCols.has("writer")) {
|
|
levelData.writer = userId;
|
|
}
|
|
|
|
const upsertKey = level.upsertKeyColumns
|
|
? level.upsertKeyColumns.map((k) => String(levelData[k] ?? "")).join("|||")
|
|
: null;
|
|
|
|
let returnedId: string | number;
|
|
|
|
if (level.upsertMode === "upsert" && upsertKey) {
|
|
const cachedId = pkCaches[lvlIdx].get(upsertKey);
|
|
if (cachedId !== undefined) {
|
|
returnedId = cachedId;
|
|
} else {
|
|
returnedId = await this.upsertRow(
|
|
client,
|
|
level,
|
|
levelData,
|
|
existingCols,
|
|
companyCode,
|
|
levelResult
|
|
);
|
|
pkCaches[lvlIdx].set(upsertKey, returnedId);
|
|
}
|
|
} else {
|
|
returnedId = await this.insertRow(
|
|
client,
|
|
level.tableName,
|
|
levelData,
|
|
existingCols
|
|
);
|
|
levelResult.inserted++;
|
|
}
|
|
|
|
parentId = returnedId;
|
|
parentLevelData = { ...levelData };
|
|
}
|
|
} catch (error: any) {
|
|
result.errors.push(`[행 ${rowIdx + 1}] 처리 실패: ${error.message}`);
|
|
logger.error(`[행 ${rowIdx + 1}] 처리 실패:`, error);
|
|
}
|
|
}
|
|
|
|
await client.query("COMMIT");
|
|
result.success =
|
|
result.errors.length === 0 ||
|
|
result.results.some((r) => r.inserted + r.updated > 0);
|
|
|
|
logger.info("다중 테이블 엑셀 업로드 완료:", {
|
|
results: result.results,
|
|
errors: result.errors.length,
|
|
});
|
|
} catch (error: any) {
|
|
await client.query("ROLLBACK");
|
|
result.errors.push(`트랜잭션 실패: ${error.message}`);
|
|
logger.error("다중 테이블 엑셀 업로드 트랜잭션 실패:", error);
|
|
} finally {
|
|
client.release();
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* UPSERT 행 처리 (존재하면 UPDATE, 없으면 INSERT)
|
|
* @returns 해당 행의 PK (id)
|
|
*/
|
|
private async upsertRow(
|
|
client: any,
|
|
level: TableLevel,
|
|
data: Record<string, any>,
|
|
existingCols: Set<string>,
|
|
companyCode: string,
|
|
levelResult: LevelResult
|
|
): Promise<string | number> {
|
|
const { tableName, upsertKeyColumns } = level;
|
|
|
|
if (!upsertKeyColumns || upsertKeyColumns.length === 0) {
|
|
const id = await this.insertRow(client, tableName, data, existingCols);
|
|
levelResult.inserted++;
|
|
return id;
|
|
}
|
|
|
|
// UPSERT 키로 기존 행 조회
|
|
const whereClause = upsertKeyColumns
|
|
.map((col, i) => `"${col}" = $${i + 1}`)
|
|
.join(" AND ");
|
|
|
|
const companyIdx = upsertKeyColumns.length + 1;
|
|
const companyWhere = existingCols.has("company_code")
|
|
? ` AND company_code = $${companyIdx}`
|
|
: "";
|
|
|
|
const params = upsertKeyColumns.map((col) => data[col]);
|
|
if (existingCols.has("company_code")) {
|
|
params.push(companyCode);
|
|
}
|
|
|
|
const existing = await client.query(
|
|
`SELECT id FROM "${tableName}" WHERE ${whereClause}${companyWhere} LIMIT 1`,
|
|
params
|
|
);
|
|
|
|
if (existing.rows.length > 0) {
|
|
// UPDATE
|
|
const existingId = existing.rows[0].id;
|
|
const skipCols = new Set([
|
|
"id",
|
|
"company_code",
|
|
"created_date",
|
|
...upsertKeyColumns,
|
|
]);
|
|
const updateKeys = Object.keys(data).filter(
|
|
(k) => !skipCols.has(k) && existingCols.has(k)
|
|
);
|
|
|
|
if (updateKeys.length > 0) {
|
|
const setClauses = updateKeys.map((k, i) => `"${k}" = $${i + 1}`);
|
|
const setValues = updateKeys.map((k) => data[k]);
|
|
const updatedDateClause = existingCols.has("updated_date")
|
|
? `, updated_date = NOW()`
|
|
: "";
|
|
|
|
await client.query(
|
|
`UPDATE "${tableName}" SET ${setClauses.join(", ")}${updatedDateClause}
|
|
WHERE id = $${setValues.length + 1}`,
|
|
[...setValues, existingId]
|
|
);
|
|
}
|
|
levelResult.updated++;
|
|
return existingId;
|
|
} else {
|
|
// INSERT
|
|
const id = await this.insertRow(client, tableName, data, existingCols);
|
|
levelResult.inserted++;
|
|
return id;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 단순 INSERT 후 PK(id) 반환
|
|
*/
|
|
private async insertRow(
|
|
client: any,
|
|
tableName: string,
|
|
data: Record<string, any>,
|
|
existingCols: Set<string>
|
|
): Promise<string | number> {
|
|
// DB에 실제 존재하는 컬럼만 필터
|
|
const cols = Object.keys(data).filter(
|
|
(k) => existingCols.has(k) && k !== "id" && data[k] !== undefined
|
|
);
|
|
|
|
const hasCreatedDate = existingCols.has("created_date");
|
|
const colList = hasCreatedDate ? [...cols, "created_date"] : cols;
|
|
const placeholders = cols.map((_, i) => `$${i + 1}`);
|
|
const valList = hasCreatedDate ? [...placeholders, "NOW()"] : placeholders;
|
|
const values = cols.map((k) => data[k]);
|
|
|
|
const result = await client.query(
|
|
`INSERT INTO "${tableName}" (${colList.map((c) => `"${c}"`).join(", ")})
|
|
VALUES (${valList.join(", ")})
|
|
RETURNING id`,
|
|
values
|
|
);
|
|
|
|
return result.rows[0].id;
|
|
}
|
|
|
|
/**
|
|
* 모드에 맞는 템플릿 데이터 생성 (엑셀 다운로드용)
|
|
*/
|
|
generateTemplateData(
|
|
config: TableChainConfig,
|
|
modeId: string
|
|
): { headers: string[]; requiredHeaders: string[]; sampleRow: Record<string, string> } {
|
|
const mode = config.uploadModes.find((m) => m.id === modeId);
|
|
if (!mode) {
|
|
throw new Error(`업로드 모드를 찾을 수 없습니다: ${modeId}`);
|
|
}
|
|
|
|
const headers: string[] = [];
|
|
const requiredHeaders: string[] = [];
|
|
const sampleRow: Record<string, string> = {};
|
|
|
|
for (const levelIdx of mode.activeLevels) {
|
|
const level = config.levels[levelIdx];
|
|
if (!level) continue;
|
|
|
|
for (const col of level.columns) {
|
|
headers.push(col.excelHeader);
|
|
if (col.required) {
|
|
requiredHeaders.push(col.excelHeader);
|
|
}
|
|
sampleRow[col.excelHeader] = col.required ? `(필수)` : "";
|
|
}
|
|
}
|
|
|
|
return { headers, requiredHeaders, sampleRow };
|
|
}
|
|
|
|
// ================================
|
|
// 자동 감지
|
|
// ================================
|
|
|
|
// 엑셀 업로드에서 제외할 시스템 컬럼
|
|
private static SYSTEM_COLUMNS = new Set([
|
|
"id",
|
|
"company_code",
|
|
"writer",
|
|
"created_date",
|
|
"updated_date",
|
|
"created_at",
|
|
"updated_at",
|
|
]);
|
|
|
|
/**
|
|
* 루트 테이블에서 자식/손자 테이블을 자동 탐색하여
|
|
* TableChainConfig를 실시간으로 생성한다.
|
|
*
|
|
* @param screenId 화면 ID (선택). 제공 시 관련 화면의 테이블을 참고하여
|
|
* 다수 자식 중 올바른 체인을 선택한다.
|
|
*/
|
|
async autoDetectTableChain(
|
|
rootTableName: string,
|
|
companyCode: string,
|
|
screenId?: number
|
|
): Promise<TableChainConfig> {
|
|
const pool = getPool();
|
|
|
|
// 1) 루트 테이블이 존재하는지 확인
|
|
const tableCheck = await pool.query(
|
|
`SELECT 1 FROM information_schema.tables
|
|
WHERE table_schema = 'public' AND table_name = $1`,
|
|
[rootTableName]
|
|
);
|
|
if (tableCheck.rows.length === 0) {
|
|
throw new Error(`테이블이 존재하지 않습니다: ${rootTableName}`);
|
|
}
|
|
|
|
// 2) 화면 컨텍스트에서 관련 테이블 세트 + 보이는 컬럼 추출
|
|
let contextTables = new Set<string>();
|
|
let visibleColumnsMap = new Map<string, Set<string>>();
|
|
|
|
if (screenId) {
|
|
const ctx = await this.getScreenContext(pool, screenId, companyCode);
|
|
contextTables = ctx.contextTables;
|
|
visibleColumnsMap = ctx.visibleColumns;
|
|
}
|
|
|
|
logger.info("자동 감지 시작", {
|
|
rootTableName,
|
|
screenId,
|
|
contextTables: [...contextTables],
|
|
visibleColumnsPerTable: Object.fromEntries(
|
|
[...visibleColumnsMap].map(([k, v]) => [k, [...v]])
|
|
),
|
|
});
|
|
|
|
// 3) 루트부터 재귀적으로 자식 탐색 (최대 3레벨)
|
|
const levels: TableLevel[] = [];
|
|
await this.buildLevelChain(pool, rootTableName, companyCode, levels, 0, 3, contextTables);
|
|
|
|
// 3.5) 화면 레이아웃에서 보이는 컬럼으로 필터링
|
|
if (visibleColumnsMap.size > 0) {
|
|
for (const level of levels) {
|
|
const visibleCols = visibleColumnsMap.get(level.tableName);
|
|
if (visibleCols && visibleCols.size > 0) {
|
|
const before = level.columns.length;
|
|
level.columns = level.columns.filter((col) => visibleCols.has(col.dbColumn));
|
|
logger.info(`컬럼 필터링: ${level.tableName}`, {
|
|
before,
|
|
after: level.columns.length,
|
|
visibleCols: [...visibleCols],
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4) 업로드 모드 자동 생성
|
|
const uploadModes: UploadMode[] = [];
|
|
for (let depth = 0; depth < levels.length; depth++) {
|
|
const activeLevels = Array.from({ length: depth + 1 }, (_, i) => i);
|
|
const labels = activeLevels.map((i) => levels[i].label);
|
|
uploadModes.push({
|
|
id: `auto_mode_${depth}`,
|
|
label: labels.join(" + "),
|
|
description: `${labels.join(", ")} 일괄 등록`,
|
|
activeLevels,
|
|
});
|
|
}
|
|
|
|
// 5) 라벨 생성
|
|
const rootLabel = await this.getTableLabel(pool, rootTableName, companyCode);
|
|
|
|
return {
|
|
id: rootTableName,
|
|
name: rootLabel,
|
|
description: `${rootLabel} 다중 테이블 엑셀 업로드 (자동 감지)`,
|
|
levels,
|
|
uploadModes,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 재귀적으로 테이블 계층 빌드
|
|
* contextTables: 화면의 관련 테이블 세트 (자식 선택 시 우선순위에 사용)
|
|
*/
|
|
private async buildLevelChain(
|
|
pool: any,
|
|
tableName: string,
|
|
companyCode: string,
|
|
levels: TableLevel[],
|
|
depth: number,
|
|
maxDepth: number,
|
|
contextTables: Set<string>
|
|
): Promise<void> {
|
|
if (depth >= maxDepth) return;
|
|
|
|
const columns = await this.getTableColumns(pool, tableName, companyCode);
|
|
|
|
const { upsertMode, upsertKeyColumns } = await this.detectUpsertKeys(
|
|
pool,
|
|
tableName
|
|
);
|
|
|
|
const label = await this.getTableLabel(pool, tableName, companyCode);
|
|
|
|
let parentFkColumn: string | undefined;
|
|
let parentRefColumn: string | undefined;
|
|
if (depth > 0 && levels.length > 0) {
|
|
const parentTable = levels[depth - 1].tableName;
|
|
const fkInfo = await this.findFkColumn(pool, tableName, parentTable, companyCode);
|
|
if (fkInfo) {
|
|
parentFkColumn = fkInfo.fkColumn;
|
|
parentRefColumn = fkInfo.refColumn;
|
|
}
|
|
}
|
|
|
|
const level: TableLevel = {
|
|
tableName,
|
|
label,
|
|
upsertMode,
|
|
upsertKeyColumns: upsertMode === "upsert" ? upsertKeyColumns : undefined,
|
|
columns,
|
|
...(parentFkColumn ? { parentFkColumn, parentRefColumn } : {}),
|
|
};
|
|
|
|
levels.push(level);
|
|
|
|
const childTables = await this.findChildTables(pool, tableName, companyCode);
|
|
if (childTables.length > 0) {
|
|
const bestChild = this.pickBestChild(tableName, childTables, contextTables);
|
|
await this.buildLevelChain(pool, bestChild, companyCode, levels, depth + 1, maxDepth, contextTables);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 여러 자식 테이블 중 가장 관련성 높은 하나를 선택
|
|
*
|
|
* 우선순위:
|
|
* 1. 화면 컨텍스트 테이블에 포함된 자식 (같은 화면 관련 모달이 사용하는 테이블)
|
|
* 2. 부모 테이블명의 접두사를 공유하는 자식
|
|
* 3. 첫 번째 자식
|
|
*/
|
|
private pickBestChild(
|
|
parentTable: string,
|
|
children: string[],
|
|
contextTables: Set<string>
|
|
): string {
|
|
if (children.length === 1) return children[0];
|
|
|
|
// 1순위: 화면 컨텍스트 테이블에 있는 자식
|
|
if (contextTables.size > 0) {
|
|
const contextMatch = children.find((c) => contextTables.has(c));
|
|
if (contextMatch) {
|
|
logger.info(`pickBestChild: 화면 컨텍스트 매칭 - ${contextMatch}`, {
|
|
parentTable,
|
|
candidates: children,
|
|
});
|
|
return contextMatch;
|
|
}
|
|
}
|
|
|
|
// 2순위: 부모 테이블의 접두사 매칭 (예: customer_mng → customer_item_mapping)
|
|
const parentPrefix = parentTable.split("_")[0];
|
|
const prefixMatch = children.find((c) => c.startsWith(parentPrefix + "_"));
|
|
if (prefixMatch) return prefixMatch;
|
|
|
|
return children[0];
|
|
}
|
|
|
|
/**
|
|
* 화면 ID로부터 관련 화면 정보를 통합 추출:
|
|
* 1) contextTables: 자식 테이블 선택에 사용할 관련 테이블 세트
|
|
* 2) visibleColumns: 각 테이블별 화면에서 실제 사용 중인 컬럼 세트
|
|
*
|
|
* 추출 전략:
|
|
* A) 화면 레이아웃에서 모달 참조(targetScreenId, modalScreenId)를 재귀 추적
|
|
* B) 화면명 키워드로 관련 화면 검색
|
|
* C) 같은 테이블의 등록/수정 폼 검색
|
|
*/
|
|
private async getScreenContext(
|
|
pool: any,
|
|
screenId: number,
|
|
companyCode: string
|
|
): Promise<{
|
|
contextTables: Set<string>;
|
|
visibleColumns: Map<string, Set<string>>;
|
|
}> {
|
|
const emptyResult = {
|
|
contextTables: new Set<string>(),
|
|
visibleColumns: new Map<string, Set<string>>(),
|
|
};
|
|
|
|
const screenResult = await pool.query(
|
|
`SELECT screen_name, table_name FROM screen_definitions WHERE screen_id = $1`,
|
|
[screenId]
|
|
);
|
|
|
|
if (screenResult.rows.length === 0) return emptyResult;
|
|
|
|
const screenName: string = screenResult.rows[0].screen_name;
|
|
const rootTable: string = screenResult.rows[0].table_name;
|
|
const companyPrefix = screenName.split(/\s+/)[0] || "";
|
|
|
|
const contextTables = new Set<string>();
|
|
const collectedScreenIds = new Set<number>();
|
|
|
|
// ─── A) 화면명 키워드로 관련 화면 검색 (자식 테이블 선택용) ───
|
|
const suffixWords = new Set(["화면", "모달", "폼", "페이지"]);
|
|
const nameTokens = screenName.split(/\s+/);
|
|
const middleTokens = nameTokens.filter((t, i) => {
|
|
if (i === 0) return false;
|
|
if (suffixWords.has(t)) return false;
|
|
return t.length >= 2;
|
|
});
|
|
|
|
for (const token of middleTokens) {
|
|
const relatedResult = await pool.query(
|
|
`SELECT screen_id, table_name
|
|
FROM screen_definitions
|
|
WHERE screen_name LIKE $1
|
|
AND table_name IS NOT NULL`,
|
|
[`%${token}%`]
|
|
);
|
|
for (const row of relatedResult.rows) {
|
|
if (row.table_name && row.table_name !== rootTable) {
|
|
contextTables.add(row.table_name);
|
|
}
|
|
collectedScreenIds.add(row.screen_id);
|
|
}
|
|
}
|
|
|
|
// ─── B) 모달 참조 체인 재귀 추적 (컬럼 추출용) ───
|
|
await this.collectModalChain(pool, screenId, collectedScreenIds, 0, 4);
|
|
|
|
// ─── C) 수집된 테이블의 등록/수정/입력 폼도 포함 (컬럼 추출용) ───
|
|
const allTablesInChain = new Set<string>([rootTable]);
|
|
if (collectedScreenIds.size > 0) {
|
|
const idArr = [...collectedScreenIds];
|
|
const ph = idArr.map((_, i) => `$${i + 1}`).join(",");
|
|
const tableResult = await pool.query(
|
|
`SELECT DISTINCT table_name FROM screen_definitions WHERE screen_id IN (${ph}) AND table_name IS NOT NULL`,
|
|
idArr
|
|
);
|
|
for (const row of tableResult.rows) {
|
|
allTablesInChain.add(row.table_name);
|
|
}
|
|
}
|
|
|
|
for (const tbl of allTablesInChain) {
|
|
const formResult = await pool.query(
|
|
`SELECT sd.screen_id
|
|
FROM screen_definitions sd
|
|
JOIN screen_layouts_v3 lv ON sd.screen_id = lv.screen_id
|
|
WHERE sd.table_name = $1
|
|
AND sd.screen_name LIKE $2
|
|
AND (sd.screen_name LIKE '%등록%' OR sd.screen_name LIKE '%수정%' OR sd.screen_name LIKE '%입력%')`,
|
|
[tbl, `${companyPrefix}%`]
|
|
);
|
|
for (const row of formResult.rows) {
|
|
collectedScreenIds.add(row.screen_id);
|
|
}
|
|
}
|
|
|
|
// ─── 수집된 화면들의 layout_data에서 테이블별 컬럼 추출 ───
|
|
const visibleColumns = new Map<string, Set<string>>();
|
|
|
|
if (collectedScreenIds.size > 0) {
|
|
const screenIdArray = [...collectedScreenIds];
|
|
const placeholders = screenIdArray.map((_, i) => `$${i + 1}`).join(",");
|
|
|
|
const layoutResult = await pool.query(
|
|
`SELECT sd.screen_id, sd.table_name, lv.layout_data
|
|
FROM screen_definitions sd
|
|
JOIN screen_layouts_v3 lv ON sd.screen_id = lv.screen_id
|
|
WHERE sd.screen_id IN (${placeholders})
|
|
AND lv.layout_data IS NOT NULL`,
|
|
screenIdArray
|
|
);
|
|
|
|
for (const row of layoutResult.rows) {
|
|
const tableName = row.table_name;
|
|
if (!tableName) continue;
|
|
|
|
if (tableName !== rootTable) {
|
|
contextTables.add(tableName);
|
|
}
|
|
|
|
if (!visibleColumns.has(tableName)) {
|
|
visibleColumns.set(tableName, new Set());
|
|
}
|
|
const colSet = visibleColumns.get(tableName)!;
|
|
this.extractColumnsFromLayout(row.layout_data, colSet);
|
|
}
|
|
}
|
|
|
|
logger.info("화면 컨텍스트 추출", {
|
|
screenId,
|
|
screenName,
|
|
collectedScreenCount: collectedScreenIds.size,
|
|
contextTables: [...contextTables],
|
|
visibleColumnsPerTable: Object.fromEntries(
|
|
[...visibleColumns].map(([k, v]) => [k, [...v]])
|
|
),
|
|
});
|
|
|
|
return { contextTables, visibleColumns };
|
|
}
|
|
|
|
/**
|
|
* 화면 레이아웃에서 모달 참조(targetScreenId, modalScreenId)를 재귀적으로 추적
|
|
* 최대 depth까지 모달 → 서브모달 → ... 체인을 따라감
|
|
*/
|
|
private async collectModalChain(
|
|
pool: any,
|
|
screenId: number,
|
|
collected: Set<number>,
|
|
depth: number,
|
|
maxDepth: number
|
|
): Promise<void> {
|
|
if (depth >= maxDepth || collected.has(screenId)) return;
|
|
collected.add(screenId);
|
|
|
|
const layoutResult = await pool.query(
|
|
`SELECT lv.layout_data
|
|
FROM screen_layouts_v3 lv
|
|
WHERE lv.screen_id = $1 AND lv.layout_data IS NOT NULL`,
|
|
[screenId]
|
|
);
|
|
|
|
if (layoutResult.rows.length === 0) return;
|
|
|
|
const layoutData = layoutResult.rows[0].layout_data;
|
|
const referencedIds = new Set<number>();
|
|
this.extractModalReferences(layoutData, referencedIds);
|
|
|
|
for (const refId of referencedIds) {
|
|
await this.collectModalChain(pool, refId, collected, depth + 1, maxDepth);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* layout_data에서 targetScreenId, modalScreenId 값을 추출
|
|
*/
|
|
private extractModalReferences(obj: any, refs: Set<number>): void {
|
|
if (!obj || typeof obj !== "object") return;
|
|
|
|
if (Array.isArray(obj)) {
|
|
for (const item of obj) {
|
|
if (item && typeof item === "object") {
|
|
this.extractModalReferences(item, refs);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
for (const key of ["targetScreenId", "modalScreenId"]) {
|
|
const val = obj[key];
|
|
if (val !== undefined && val !== null) {
|
|
const num = Number(val);
|
|
if (!isNaN(num) && num > 0) {
|
|
refs.add(num);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const key of Object.keys(obj)) {
|
|
const val = obj[key];
|
|
if (val && typeof val === "object") {
|
|
this.extractModalReferences(val, refs);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* layout_data JSON에서 사용 중인 컬럼명을 재귀적으로 추출
|
|
* - columnName 속성 (v2-input, v2-select 등)
|
|
* - columns[].name 속성 (테이블 리스트, 분할 패널)
|
|
* - dot notation (supplier_mng.supplier_name) 은 JOIN이므로 제외
|
|
*/
|
|
private extractColumnsFromLayout(
|
|
layoutData: any,
|
|
colSet: Set<string>
|
|
): void {
|
|
if (!layoutData || typeof layoutData !== "object") return;
|
|
|
|
if (Array.isArray(layoutData)) {
|
|
for (const item of layoutData) {
|
|
if (item && typeof item === "object") {
|
|
this.extractColumnsFromLayout(item, colSet);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// columnName 속성 (폼 필드)
|
|
const cn = layoutData.columnName;
|
|
if (cn && typeof cn === "string" && cn.trim() && !cn.includes(".")) {
|
|
colSet.add(cn);
|
|
}
|
|
|
|
// columns 배열 (리스트/테이블)
|
|
const cols = layoutData.columns;
|
|
if (Array.isArray(cols)) {
|
|
for (const col of cols) {
|
|
if (col && typeof col === "object") {
|
|
const name = col.name;
|
|
if (name && typeof name === "string" && !name.includes(".")) {
|
|
colSet.add(name);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 재귀 탐색
|
|
for (const key of Object.keys(layoutData)) {
|
|
const val = layoutData[key];
|
|
if (val && typeof val === "object") {
|
|
this.extractColumnsFromLayout(val, colSet);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 특정 테이블을 참조하는 자식 테이블 목록 찾기
|
|
* 1차: table_type_columns의 reference_table
|
|
* 2차: pg_constraint FK
|
|
*/
|
|
private async findChildTables(
|
|
pool: any,
|
|
parentTable: string,
|
|
companyCode: string
|
|
): Promise<string[]> {
|
|
// 1) table_type_columns에서 이 테이블을 reference_table로 가진 다른 테이블 검색
|
|
const ttcResult = await pool.query(
|
|
`SELECT DISTINCT table_name
|
|
FROM table_type_columns
|
|
WHERE reference_table = $1
|
|
AND table_name != $1
|
|
AND company_code IN ($2, '*')
|
|
ORDER BY table_name`,
|
|
[parentTable, companyCode]
|
|
);
|
|
|
|
if (ttcResult.rows.length > 0) {
|
|
return ttcResult.rows.map((r: any) => r.table_name);
|
|
}
|
|
|
|
// 2) fallback: pg_constraint FK
|
|
const fkResult = await pool.query(
|
|
`SELECT DISTINCT c2.relname AS child_table
|
|
FROM pg_constraint con
|
|
JOIN pg_class c1 ON con.confrelid = c1.oid
|
|
JOIN pg_class c2 ON con.conrelid = c2.oid
|
|
JOIN pg_namespace ns ON c2.relnamespace = ns.oid
|
|
WHERE ns.nspname = 'public'
|
|
AND c1.relname = $1
|
|
AND con.contype = 'f'
|
|
AND c2.relname != $1
|
|
ORDER BY c2.relname`,
|
|
[parentTable]
|
|
);
|
|
|
|
return fkResult.rows.map((r: any) => r.child_table);
|
|
}
|
|
|
|
/**
|
|
* 자식 테이블에서 부모 테이블을 참조하는 FK 컬럼 찾기
|
|
*/
|
|
private async findFkColumn(
|
|
pool: any,
|
|
childTable: string,
|
|
parentTable: string,
|
|
companyCode: string
|
|
): Promise<{ fkColumn: string; refColumn: string } | null> {
|
|
// 1) table_type_columns
|
|
const ttcResult = await pool.query(
|
|
`SELECT column_name, reference_column
|
|
FROM table_type_columns
|
|
WHERE table_name = $1
|
|
AND reference_table = $2
|
|
AND company_code IN ($3, '*')
|
|
ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END
|
|
LIMIT 1`,
|
|
[childTable, parentTable, companyCode]
|
|
);
|
|
|
|
if (ttcResult.rows.length > 0) {
|
|
return {
|
|
fkColumn: ttcResult.rows[0].column_name,
|
|
refColumn: ttcResult.rows[0].reference_column || "id",
|
|
};
|
|
}
|
|
|
|
// 2) pg_constraint FK
|
|
const fkResult = await pool.query(
|
|
`SELECT a_child.attname AS fk_column,
|
|
a_parent.attname AS ref_column
|
|
FROM pg_constraint con
|
|
JOIN pg_class c_child ON con.conrelid = c_child.oid
|
|
JOIN pg_class c_parent ON con.confrelid = c_parent.oid
|
|
JOIN pg_namespace ns ON c_child.relnamespace = ns.oid
|
|
JOIN pg_attribute a_child ON a_child.attrelid = c_child.oid AND a_child.attnum = ANY(con.conkey)
|
|
JOIN pg_attribute a_parent ON a_parent.attrelid = c_parent.oid AND a_parent.attnum = ANY(con.confkey)
|
|
WHERE ns.nspname = 'public'
|
|
AND c_child.relname = $1
|
|
AND c_parent.relname = $2
|
|
AND con.contype = 'f'
|
|
LIMIT 1`,
|
|
[childTable, parentTable]
|
|
);
|
|
|
|
if (fkResult.rows.length > 0) {
|
|
return {
|
|
fkColumn: fkResult.rows[0].fk_column,
|
|
refColumn: fkResult.rows[0].ref_column,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 테이블의 컬럼 목록을 가져와 ColumnDef 배열로 변환
|
|
* 시스템 컬럼과 FK 컬럼은 제외
|
|
*/
|
|
private async getTableColumns(
|
|
pool: any,
|
|
tableName: string,
|
|
companyCode: string
|
|
): Promise<ColumnDef[]> {
|
|
const result = await pool.query(
|
|
`SELECT
|
|
c.column_name,
|
|
c.is_nullable AS db_is_nullable,
|
|
c.column_default,
|
|
COALESCE(ttc.column_label, cl.column_label) AS column_label,
|
|
COALESCE(ttc.reference_table, cl.reference_table) AS reference_table,
|
|
COALESCE(ttc.is_nullable, cl.is_nullable) AS ttc_is_nullable
|
|
FROM information_schema.columns c
|
|
LEFT JOIN table_type_columns cl
|
|
ON c.table_name = cl.table_name AND c.column_name = cl.column_name AND cl.company_code = '*'
|
|
LEFT JOIN table_type_columns ttc
|
|
ON c.table_name = ttc.table_name AND c.column_name = ttc.column_name AND ttc.company_code = $2
|
|
WHERE c.table_schema = 'public' AND c.table_name = $1
|
|
ORDER BY c.ordinal_position`,
|
|
[tableName, companyCode]
|
|
);
|
|
|
|
const columns: ColumnDef[] = [];
|
|
for (const row of result.rows) {
|
|
const colName: string = row.column_name;
|
|
|
|
// 시스템 컬럼 제외
|
|
if (MultiTableExcelService.SYSTEM_COLUMNS.has(colName)) continue;
|
|
|
|
// FK 컬럼 제외
|
|
if (row.reference_table && colName.endsWith("_id")) continue;
|
|
|
|
const hasDefault = row.column_default !== null;
|
|
const dbNullable = row.db_is_nullable === "YES";
|
|
const ttcNotNull = row.ttc_is_nullable === "N";
|
|
const isNullable = ttcNotNull ? false : dbNullable;
|
|
const isRequired = !isNullable && !hasDefault;
|
|
|
|
columns.push({
|
|
dbColumn: colName,
|
|
excelHeader: row.column_label || colName,
|
|
required: isRequired,
|
|
});
|
|
}
|
|
|
|
return columns;
|
|
}
|
|
|
|
/**
|
|
* UPSERT 키 감지: UNIQUE 제약조건 → 없으면 insert 모드
|
|
* company_code가 포함된 UNIQUE 제약조건에서 company_code를 제외한 컬럼 사용
|
|
*/
|
|
private async detectUpsertKeys(
|
|
pool: any,
|
|
tableName: string
|
|
): Promise<{ upsertMode: "upsert" | "insert"; upsertKeyColumns: string[] }> {
|
|
const result = await pool.query(
|
|
`SELECT con.conname,
|
|
array_agg(a.attname ORDER BY x.n) AS columns
|
|
FROM pg_constraint con
|
|
JOIN pg_class c ON con.conrelid = c.oid
|
|
JOIN pg_namespace ns ON c.relnamespace = ns.oid
|
|
CROSS JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS x(attnum, n)
|
|
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = x.attnum
|
|
WHERE ns.nspname = 'public'
|
|
AND c.relname = $1
|
|
AND con.contype = 'u'
|
|
GROUP BY con.conname
|
|
ORDER BY con.conname
|
|
LIMIT 1`,
|
|
[tableName]
|
|
);
|
|
|
|
if (result.rows.length > 0) {
|
|
let rawCols = result.rows[0].columns;
|
|
// pg 드라이버가 array_agg를 문자열로 반환할 수 있음
|
|
if (typeof rawCols === "string") {
|
|
rawCols = rawCols.replace(/[{}]/g, "").split(",").map((s: string) => s.trim());
|
|
}
|
|
const cols: string[] = (rawCols as string[]).filter(
|
|
(c: string) => c !== "company_code"
|
|
);
|
|
if (cols.length > 0) {
|
|
return { upsertMode: "upsert", upsertKeyColumns: cols };
|
|
}
|
|
}
|
|
|
|
return { upsertMode: "insert", upsertKeyColumns: [] };
|
|
}
|
|
|
|
/**
|
|
* 테이블의 한글 라벨 가져오기
|
|
*/
|
|
private async getTableLabel(
|
|
pool: any,
|
|
tableName: string,
|
|
companyCode: string
|
|
): Promise<string> {
|
|
const result = await pool.query(
|
|
`SELECT COALESCE(
|
|
(SELECT table_label FROM table_labels WHERE table_name = $1 LIMIT 1),
|
|
$1
|
|
) AS label`,
|
|
[tableName]
|
|
);
|
|
return result.rows[0]?.label || tableName;
|
|
}
|
|
}
|
|
|
|
export const multiTableExcelService = new MultiTableExcelService();
|