528 lines
16 KiB
TypeScript
528 lines
16 KiB
TypeScript
|
|
/**
|
||
|
|
* 마스터-디테일 엑셀 처리 서비스
|
||
|
|
*
|
||
|
|
* 분할 패널 화면의 마스터-디테일 구조를 자동 감지하고
|
||
|
|
* 엑셀 다운로드/업로드 시 JOIN 및 그룹화 처리를 수행합니다.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { query, queryOne, transaction, getPool } from "../database/db";
|
||
|
|
import { logger } from "../utils/logger";
|
||
|
|
|
||
|
|
// ================================
|
||
|
|
// 인터페이스 정의
|
||
|
|
// ================================
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 마스터-디테일 관계 정보
|
||
|
|
*/
|
||
|
|
export interface MasterDetailRelation {
|
||
|
|
masterTable: string;
|
||
|
|
detailTable: string;
|
||
|
|
masterKeyColumn: string; // 마스터 테이블의 키 컬럼 (예: order_no)
|
||
|
|
detailFkColumn: string; // 디테일 테이블의 FK 컬럼 (예: order_no)
|
||
|
|
masterColumns: ColumnInfo[];
|
||
|
|
detailColumns: ColumnInfo[];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 컬럼 정보
|
||
|
|
*/
|
||
|
|
export interface ColumnInfo {
|
||
|
|
name: string;
|
||
|
|
label: string;
|
||
|
|
inputType: string;
|
||
|
|
isFromMaster: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 분할 패널 설정
|
||
|
|
*/
|
||
|
|
export interface SplitPanelConfig {
|
||
|
|
leftPanel: {
|
||
|
|
tableName: string;
|
||
|
|
columns: Array<{ name: string; label: string; width?: number }>;
|
||
|
|
};
|
||
|
|
rightPanel: {
|
||
|
|
tableName: string;
|
||
|
|
columns: Array<{ name: string; label: string; width?: number }>;
|
||
|
|
relation?: {
|
||
|
|
type: string;
|
||
|
|
foreignKey: string;
|
||
|
|
leftColumn: string;
|
||
|
|
};
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 엑셀 다운로드 결과
|
||
|
|
*/
|
||
|
|
export interface ExcelDownloadData {
|
||
|
|
headers: string[]; // 컬럼 라벨들
|
||
|
|
columns: string[]; // 컬럼명들
|
||
|
|
data: Record<string, any>[];
|
||
|
|
masterColumns: string[]; // 마스터 컬럼 목록
|
||
|
|
detailColumns: string[]; // 디테일 컬럼 목록
|
||
|
|
joinKey: string; // 조인 키
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 엑셀 업로드 결과
|
||
|
|
*/
|
||
|
|
export interface ExcelUploadResult {
|
||
|
|
success: boolean;
|
||
|
|
masterInserted: number;
|
||
|
|
masterUpdated: number;
|
||
|
|
detailInserted: number;
|
||
|
|
detailDeleted: number;
|
||
|
|
errors: string[];
|
||
|
|
}
|
||
|
|
|
||
|
|
// ================================
|
||
|
|
// 서비스 클래스
|
||
|
|
// ================================
|
||
|
|
|
||
|
|
class MasterDetailExcelService {
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 화면 ID로 분할 패널 설정 조회
|
||
|
|
*/
|
||
|
|
async getSplitPanelConfig(screenId: number): Promise<SplitPanelConfig | null> {
|
||
|
|
try {
|
||
|
|
logger.info(`분할 패널 설정 조회: screenId=${screenId}`);
|
||
|
|
|
||
|
|
// screen_layouts에서 split-panel-layout 컴포넌트 찾기
|
||
|
|
const result = await queryOne<any>(
|
||
|
|
`SELECT properties->>'componentConfig' as config
|
||
|
|
FROM screen_layouts
|
||
|
|
WHERE screen_id = $1
|
||
|
|
AND component_type = 'component'
|
||
|
|
AND properties->>'componentType' = 'split-panel-layout'
|
||
|
|
LIMIT 1`,
|
||
|
|
[screenId]
|
||
|
|
);
|
||
|
|
|
||
|
|
if (!result || !result.config) {
|
||
|
|
logger.info(`분할 패널 없음: screenId=${screenId}`);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const config = typeof result.config === "string"
|
||
|
|
? JSON.parse(result.config)
|
||
|
|
: result.config;
|
||
|
|
|
||
|
|
logger.info(`분할 패널 설정 발견:`, {
|
||
|
|
leftTable: config.leftPanel?.tableName,
|
||
|
|
rightTable: config.rightPanel?.tableName,
|
||
|
|
relation: config.rightPanel?.relation,
|
||
|
|
});
|
||
|
|
|
||
|
|
return {
|
||
|
|
leftPanel: config.leftPanel,
|
||
|
|
rightPanel: config.rightPanel,
|
||
|
|
};
|
||
|
|
} catch (error: any) {
|
||
|
|
logger.error(`분할 패널 설정 조회 실패: ${error.message}`);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* column_labels에서 Entity 관계 정보 조회
|
||
|
|
* 디테일 테이블에서 마스터 테이블을 참조하는 컬럼 찾기
|
||
|
|
*/
|
||
|
|
async getEntityRelation(
|
||
|
|
detailTable: string,
|
||
|
|
masterTable: string
|
||
|
|
): Promise<{ detailFkColumn: string; masterKeyColumn: string } | null> {
|
||
|
|
try {
|
||
|
|
logger.info(`Entity 관계 조회: ${detailTable} -> ${masterTable}`);
|
||
|
|
|
||
|
|
const result = await queryOne<any>(
|
||
|
|
`SELECT column_name, reference_column
|
||
|
|
FROM column_labels
|
||
|
|
WHERE table_name = $1
|
||
|
|
AND input_type = 'entity'
|
||
|
|
AND reference_table = $2
|
||
|
|
LIMIT 1`,
|
||
|
|
[detailTable, masterTable]
|
||
|
|
);
|
||
|
|
|
||
|
|
if (!result) {
|
||
|
|
logger.warn(`Entity 관계 없음: ${detailTable} -> ${masterTable}`);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
logger.info(`Entity 관계 발견: ${detailTable}.${result.column_name} -> ${masterTable}.${result.reference_column}`);
|
||
|
|
|
||
|
|
return {
|
||
|
|
detailFkColumn: result.column_name,
|
||
|
|
masterKeyColumn: result.reference_column,
|
||
|
|
};
|
||
|
|
} catch (error: any) {
|
||
|
|
logger.error(`Entity 관계 조회 실패: ${error.message}`);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 테이블의 컬럼 라벨 정보 조회
|
||
|
|
*/
|
||
|
|
async getColumnLabels(tableName: string): Promise<Map<string, string>> {
|
||
|
|
try {
|
||
|
|
const result = await query<any>(
|
||
|
|
`SELECT column_name, column_label
|
||
|
|
FROM column_labels
|
||
|
|
WHERE table_name = $1`,
|
||
|
|
[tableName]
|
||
|
|
);
|
||
|
|
|
||
|
|
const labelMap = new Map<string, string>();
|
||
|
|
for (const row of result) {
|
||
|
|
labelMap.set(row.column_name, row.column_label || row.column_name);
|
||
|
|
}
|
||
|
|
|
||
|
|
return labelMap;
|
||
|
|
} catch (error: any) {
|
||
|
|
logger.error(`컬럼 라벨 조회 실패: ${error.message}`);
|
||
|
|
return new Map();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 마스터-디테일 관계 정보 조합
|
||
|
|
*/
|
||
|
|
async getMasterDetailRelation(
|
||
|
|
screenId: number
|
||
|
|
): Promise<MasterDetailRelation | null> {
|
||
|
|
try {
|
||
|
|
// 1. 분할 패널 설정 조회
|
||
|
|
const splitPanel = await this.getSplitPanelConfig(screenId);
|
||
|
|
if (!splitPanel) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const masterTable = splitPanel.leftPanel.tableName;
|
||
|
|
const detailTable = splitPanel.rightPanel.tableName;
|
||
|
|
|
||
|
|
if (!masterTable || !detailTable) {
|
||
|
|
logger.warn("마스터 또는 디테일 테이블명 없음");
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 2. 분할 패널의 relation 정보가 있으면 우선 사용
|
||
|
|
let masterKeyColumn = splitPanel.rightPanel.relation?.leftColumn;
|
||
|
|
let detailFkColumn = splitPanel.rightPanel.relation?.foreignKey;
|
||
|
|
|
||
|
|
// 3. relation 정보가 없으면 column_labels에서 Entity 관계 조회
|
||
|
|
if (!masterKeyColumn || !detailFkColumn) {
|
||
|
|
const entityRelation = await this.getEntityRelation(detailTable, masterTable);
|
||
|
|
if (entityRelation) {
|
||
|
|
masterKeyColumn = entityRelation.masterKeyColumn;
|
||
|
|
detailFkColumn = entityRelation.detailFkColumn;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!masterKeyColumn || !detailFkColumn) {
|
||
|
|
logger.warn("조인 키 정보를 찾을 수 없음");
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 4. 컬럼 라벨 정보 조회
|
||
|
|
const masterLabels = await this.getColumnLabels(masterTable);
|
||
|
|
const detailLabels = await this.getColumnLabels(detailTable);
|
||
|
|
|
||
|
|
// 5. 마스터 컬럼 정보 구성
|
||
|
|
const masterColumns: ColumnInfo[] = splitPanel.leftPanel.columns.map(col => ({
|
||
|
|
name: col.name,
|
||
|
|
label: masterLabels.get(col.name) || col.label || col.name,
|
||
|
|
inputType: "text",
|
||
|
|
isFromMaster: true,
|
||
|
|
}));
|
||
|
|
|
||
|
|
// 6. 디테일 컬럼 정보 구성 (FK 컬럼 제외)
|
||
|
|
const detailColumns: ColumnInfo[] = splitPanel.rightPanel.columns
|
||
|
|
.filter(col => col.name !== detailFkColumn) // FK 컬럼 제외
|
||
|
|
.map(col => ({
|
||
|
|
name: col.name,
|
||
|
|
label: detailLabels.get(col.name) || col.label || col.name,
|
||
|
|
inputType: "text",
|
||
|
|
isFromMaster: false,
|
||
|
|
}));
|
||
|
|
|
||
|
|
logger.info(`마스터-디테일 관계 구성 완료:`, {
|
||
|
|
masterTable,
|
||
|
|
detailTable,
|
||
|
|
masterKeyColumn,
|
||
|
|
detailFkColumn,
|
||
|
|
masterColumnCount: masterColumns.length,
|
||
|
|
detailColumnCount: detailColumns.length,
|
||
|
|
});
|
||
|
|
|
||
|
|
return {
|
||
|
|
masterTable,
|
||
|
|
detailTable,
|
||
|
|
masterKeyColumn,
|
||
|
|
detailFkColumn,
|
||
|
|
masterColumns,
|
||
|
|
detailColumns,
|
||
|
|
};
|
||
|
|
} catch (error: any) {
|
||
|
|
logger.error(`마스터-디테일 관계 조회 실패: ${error.message}`);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 마스터-디테일 JOIN 데이터 조회 (엑셀 다운로드용)
|
||
|
|
*/
|
||
|
|
async getJoinedData(
|
||
|
|
relation: MasterDetailRelation,
|
||
|
|
companyCode: string,
|
||
|
|
filters?: Record<string, any>
|
||
|
|
): Promise<ExcelDownloadData> {
|
||
|
|
try {
|
||
|
|
const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation;
|
||
|
|
|
||
|
|
// SELECT 절 구성
|
||
|
|
const masterSelectCols = masterColumns.map(col => `m."${col.name}"`);
|
||
|
|
const detailSelectCols = detailColumns.map(col => `d."${col.name}"`);
|
||
|
|
const selectClause = [...masterSelectCols, ...detailSelectCols].join(", ");
|
||
|
|
|
||
|
|
// WHERE 절 구성
|
||
|
|
const whereConditions: string[] = [];
|
||
|
|
const params: any[] = [];
|
||
|
|
let paramIndex = 1;
|
||
|
|
|
||
|
|
// 회사 코드 필터 (최고 관리자 제외)
|
||
|
|
if (companyCode && companyCode !== "*") {
|
||
|
|
whereConditions.push(`m.company_code = $${paramIndex}`);
|
||
|
|
params.push(companyCode);
|
||
|
|
paramIndex++;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 추가 필터 적용
|
||
|
|
if (filters) {
|
||
|
|
for (const [key, value] of Object.entries(filters)) {
|
||
|
|
if (value !== undefined && value !== null && value !== "") {
|
||
|
|
// 마스터 테이블 컬럼인지 확인
|
||
|
|
const isMasterCol = masterColumns.some(c => c.name === key);
|
||
|
|
const tableAlias = isMasterCol ? "m" : "d";
|
||
|
|
whereConditions.push(`${tableAlias}."${key}" = $${paramIndex}`);
|
||
|
|
params.push(value);
|
||
|
|
paramIndex++;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const whereClause = whereConditions.length > 0
|
||
|
|
? `WHERE ${whereConditions.join(" AND ")}`
|
||
|
|
: "";
|
||
|
|
|
||
|
|
// JOIN 쿼리 실행
|
||
|
|
const sql = `
|
||
|
|
SELECT ${selectClause}
|
||
|
|
FROM "${masterTable}" m
|
||
|
|
LEFT JOIN "${detailTable}" d
|
||
|
|
ON m."${masterKeyColumn}" = d."${detailFkColumn}"
|
||
|
|
AND m.company_code = d.company_code
|
||
|
|
${whereClause}
|
||
|
|
ORDER BY m."${masterKeyColumn}", d.id
|
||
|
|
`;
|
||
|
|
|
||
|
|
logger.info(`마스터-디테일 JOIN 쿼리:`, { sql, params });
|
||
|
|
|
||
|
|
const data = await query<any>(sql, params);
|
||
|
|
|
||
|
|
// 헤더 및 컬럼 정보 구성
|
||
|
|
const headers = [...masterColumns.map(c => c.label), ...detailColumns.map(c => c.label)];
|
||
|
|
const columns = [...masterColumns.map(c => c.name), ...detailColumns.map(c => c.name)];
|
||
|
|
|
||
|
|
logger.info(`마스터-디테일 데이터 조회 완료: ${data.length}행`);
|
||
|
|
|
||
|
|
return {
|
||
|
|
headers,
|
||
|
|
columns,
|
||
|
|
data,
|
||
|
|
masterColumns: masterColumns.map(c => c.name),
|
||
|
|
detailColumns: detailColumns.map(c => c.name),
|
||
|
|
joinKey: masterKeyColumn,
|
||
|
|
};
|
||
|
|
} catch (error: any) {
|
||
|
|
logger.error(`마스터-디테일 데이터 조회 실패: ${error.message}`);
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 마스터-디테일 데이터 업로드 (엑셀 업로드용)
|
||
|
|
*
|
||
|
|
* 처리 로직:
|
||
|
|
* 1. 엑셀 데이터를 마스터 키로 그룹화
|
||
|
|
* 2. 각 그룹의 첫 번째 행에서 마스터 데이터 추출 → UPSERT
|
||
|
|
* 3. 해당 마스터 키의 기존 디테일 삭제
|
||
|
|
* 4. 새 디테일 데이터 INSERT
|
||
|
|
*/
|
||
|
|
async uploadJoinedData(
|
||
|
|
relation: MasterDetailRelation,
|
||
|
|
data: Record<string, any>[],
|
||
|
|
companyCode: string,
|
||
|
|
userId?: string
|
||
|
|
): Promise<ExcelUploadResult> {
|
||
|
|
const result: ExcelUploadResult = {
|
||
|
|
success: false,
|
||
|
|
masterInserted: 0,
|
||
|
|
masterUpdated: 0,
|
||
|
|
detailInserted: 0,
|
||
|
|
detailDeleted: 0,
|
||
|
|
errors: [],
|
||
|
|
};
|
||
|
|
|
||
|
|
const pool = getPool();
|
||
|
|
const client = await pool.connect();
|
||
|
|
|
||
|
|
try {
|
||
|
|
await client.query("BEGIN");
|
||
|
|
|
||
|
|
const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation;
|
||
|
|
|
||
|
|
// 1. 데이터를 마스터 키로 그룹화
|
||
|
|
const groupedData = new Map<string, Record<string, any>[]>();
|
||
|
|
|
||
|
|
for (const row of data) {
|
||
|
|
const masterKey = row[masterKeyColumn];
|
||
|
|
if (!masterKey) {
|
||
|
|
result.errors.push(`마스터 키(${masterKeyColumn}) 값이 없는 행이 있습니다.`);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!groupedData.has(masterKey)) {
|
||
|
|
groupedData.set(masterKey, []);
|
||
|
|
}
|
||
|
|
groupedData.get(masterKey)!.push(row);
|
||
|
|
}
|
||
|
|
|
||
|
|
logger.info(`데이터 그룹화 완료: ${groupedData.size}개 마스터 그룹`);
|
||
|
|
|
||
|
|
// 2. 각 그룹 처리
|
||
|
|
for (const [masterKey, rows] of groupedData.entries()) {
|
||
|
|
try {
|
||
|
|
// 2a. 마스터 데이터 추출 (첫 번째 행에서)
|
||
|
|
const masterData: Record<string, any> = {};
|
||
|
|
for (const col of masterColumns) {
|
||
|
|
if (rows[0][col.name] !== undefined) {
|
||
|
|
masterData[col.name] = rows[0][col.name];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 회사 코드, 작성자 추가
|
||
|
|
masterData.company_code = companyCode;
|
||
|
|
if (userId) {
|
||
|
|
masterData.writer = userId;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 2b. 마스터 UPSERT
|
||
|
|
const existingMaster = await client.query(
|
||
|
|
`SELECT id FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`,
|
||
|
|
[masterKey, companyCode]
|
||
|
|
);
|
||
|
|
|
||
|
|
if (existingMaster.rows.length > 0) {
|
||
|
|
// UPDATE
|
||
|
|
const updateCols = Object.keys(masterData)
|
||
|
|
.filter(k => k !== masterKeyColumn && k !== "id")
|
||
|
|
.map((k, i) => `"${k}" = $${i + 1}`);
|
||
|
|
const updateValues = Object.keys(masterData)
|
||
|
|
.filter(k => k !== masterKeyColumn && k !== "id")
|
||
|
|
.map(k => masterData[k]);
|
||
|
|
|
||
|
|
if (updateCols.length > 0) {
|
||
|
|
await client.query(
|
||
|
|
`UPDATE "${masterTable}"
|
||
|
|
SET ${updateCols.join(", ")}, updated_date = NOW()
|
||
|
|
WHERE "${masterKeyColumn}" = $${updateValues.length + 1} AND company_code = $${updateValues.length + 2}`,
|
||
|
|
[...updateValues, masterKey, companyCode]
|
||
|
|
);
|
||
|
|
}
|
||
|
|
result.masterUpdated++;
|
||
|
|
} else {
|
||
|
|
// INSERT
|
||
|
|
const insertCols = Object.keys(masterData);
|
||
|
|
const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`);
|
||
|
|
const insertValues = insertCols.map(k => masterData[k]);
|
||
|
|
|
||
|
|
await client.query(
|
||
|
|
`INSERT INTO "${masterTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date)
|
||
|
|
VALUES (${insertPlaceholders.join(", ")}, NOW())`,
|
||
|
|
insertValues
|
||
|
|
);
|
||
|
|
result.masterInserted++;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 2c. 기존 디테일 삭제
|
||
|
|
const deleteResult = await client.query(
|
||
|
|
`DELETE FROM "${detailTable}" WHERE "${detailFkColumn}" = $1 AND company_code = $2`,
|
||
|
|
[masterKey, companyCode]
|
||
|
|
);
|
||
|
|
result.detailDeleted += deleteResult.rowCount || 0;
|
||
|
|
|
||
|
|
// 2d. 새 디테일 INSERT
|
||
|
|
for (const row of rows) {
|
||
|
|
const detailData: Record<string, any> = {};
|
||
|
|
|
||
|
|
// FK 컬럼 추가
|
||
|
|
detailData[detailFkColumn] = masterKey;
|
||
|
|
detailData.company_code = companyCode;
|
||
|
|
if (userId) {
|
||
|
|
detailData.writer = userId;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 디테일 컬럼 데이터 추출
|
||
|
|
for (const col of detailColumns) {
|
||
|
|
if (row[col.name] !== undefined) {
|
||
|
|
detailData[col.name] = row[col.name];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const insertCols = Object.keys(detailData);
|
||
|
|
const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`);
|
||
|
|
const insertValues = insertCols.map(k => detailData[k]);
|
||
|
|
|
||
|
|
await client.query(
|
||
|
|
`INSERT INTO "${detailTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date)
|
||
|
|
VALUES (${insertPlaceholders.join(", ")}, NOW())`,
|
||
|
|
insertValues
|
||
|
|
);
|
||
|
|
result.detailInserted++;
|
||
|
|
}
|
||
|
|
} catch (error: any) {
|
||
|
|
result.errors.push(`마스터 키 ${masterKey} 처리 실패: ${error.message}`);
|
||
|
|
logger.error(`마스터 키 ${masterKey} 처리 실패:`, error);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
await client.query("COMMIT");
|
||
|
|
result.success = result.errors.length === 0 || result.masterInserted + result.masterUpdated > 0;
|
||
|
|
|
||
|
|
logger.info(`마스터-디테일 업로드 완료:`, {
|
||
|
|
masterInserted: result.masterInserted,
|
||
|
|
masterUpdated: result.masterUpdated,
|
||
|
|
detailInserted: result.detailInserted,
|
||
|
|
detailDeleted: result.detailDeleted,
|
||
|
|
errors: result.errors.length,
|
||
|
|
});
|
||
|
|
|
||
|
|
} catch (error: any) {
|
||
|
|
await client.query("ROLLBACK");
|
||
|
|
result.errors.push(`트랜잭션 실패: ${error.message}`);
|
||
|
|
logger.error(`마스터-디테일 업로드 트랜잭션 실패:`, error);
|
||
|
|
} finally {
|
||
|
|
client.release();
|
||
|
|
}
|
||
|
|
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export const masterDetailExcelService = new MasterDetailExcelService();
|
||
|
|
|