1075 lines
33 KiB
TypeScript
1075 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,
|
||
|
|
c.column_default,
|
||
|
|
COALESCE(ttc.column_label, cl.column_label) AS column_label,
|
||
|
|
COALESCE(ttc.reference_table, cl.reference_table) AS reference_table
|
||
|
|
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 컬럼 제외 (reference_table이 있는 컬럼 = 다른 테이블의 PK를 참조)
|
||
|
|
// 단, 비즈니스적으로 의미 있는 FK는 남길 수 있으므로,
|
||
|
|
// _id로 끝나면서 reference_table이 있는 경우만 제외
|
||
|
|
if (row.reference_table && colName.endsWith("_id")) continue;
|
||
|
|
|
||
|
|
const hasDefault = row.column_default !== null;
|
||
|
|
const isNullable = row.is_nullable === "YES";
|
||
|
|
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();
|