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

1076 lines
33 KiB
TypeScript
Raw Normal View History

/**
*
*
* (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(NULLIF(ttc.column_label, c.column_name), 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();