909 lines
30 KiB
TypeScript
909 lines
30 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;
|
|
// 복합키 지원 (새로운 방식)
|
|
keys?: Array<{
|
|
leftColumn: string;
|
|
rightColumn: 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 정보가 있으면 우선 사용
|
|
// 🔥 keys 배열을 우선 사용 (새로운 복합키 지원 방식)
|
|
let masterKeyColumn: string | undefined;
|
|
let detailFkColumn: string | undefined;
|
|
|
|
const relationKeys = splitPanel.rightPanel.relation?.keys;
|
|
if (relationKeys && relationKeys.length > 0) {
|
|
// keys 배열에서 첫 번째 키 사용
|
|
masterKeyColumn = relationKeys[0].leftColumn;
|
|
detailFkColumn = relationKeys[0].rightColumn;
|
|
logger.info(`keys 배열에서 관계 정보 사용: ${masterKeyColumn} -> ${detailFkColumn}`);
|
|
} else {
|
|
// 하위 호환성: 기존 leftColumn/foreignKey 사용
|
|
masterKeyColumn = splitPanel.rightPanel.relation?.leftColumn;
|
|
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;
|
|
|
|
// 조인 컬럼과 일반 컬럼 분리
|
|
// 조인 컬럼 형식: "테이블명.컬럼명" (예: customer_mng.customer_name)
|
|
const entityJoins: Array<{
|
|
refTable: string;
|
|
refColumn: string;
|
|
sourceColumn: string;
|
|
alias: string;
|
|
displayColumn: string;
|
|
}> = [];
|
|
|
|
// SELECT 절 구성
|
|
const selectParts: string[] = [];
|
|
let aliasIndex = 0;
|
|
|
|
// 마스터 컬럼 처리
|
|
for (const col of masterColumns) {
|
|
if (col.name.includes(".")) {
|
|
// 조인 컬럼: 테이블명.컬럼명
|
|
const [refTable, displayColumn] = col.name.split(".");
|
|
const alias = `ej${aliasIndex++}`;
|
|
|
|
// column_labels에서 FK 컬럼 찾기
|
|
const fkColumn = await this.findForeignKeyColumn(masterTable, refTable);
|
|
if (fkColumn) {
|
|
entityJoins.push({
|
|
refTable,
|
|
refColumn: fkColumn.referenceColumn,
|
|
sourceColumn: fkColumn.sourceColumn,
|
|
alias,
|
|
displayColumn,
|
|
});
|
|
selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`);
|
|
} else {
|
|
// FK를 못 찾으면 NULL로 처리
|
|
selectParts.push(`NULL AS "${col.name}"`);
|
|
}
|
|
} else {
|
|
// 일반 컬럼
|
|
selectParts.push(`m."${col.name}"`);
|
|
}
|
|
}
|
|
|
|
// 디테일 컬럼 처리
|
|
for (const col of detailColumns) {
|
|
if (col.name.includes(".")) {
|
|
// 조인 컬럼: 테이블명.컬럼명
|
|
const [refTable, displayColumn] = col.name.split(".");
|
|
const alias = `ej${aliasIndex++}`;
|
|
|
|
// column_labels에서 FK 컬럼 찾기
|
|
const fkColumn = await this.findForeignKeyColumn(detailTable, refTable);
|
|
if (fkColumn) {
|
|
entityJoins.push({
|
|
refTable,
|
|
refColumn: fkColumn.referenceColumn,
|
|
sourceColumn: fkColumn.sourceColumn,
|
|
alias,
|
|
displayColumn,
|
|
});
|
|
selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`);
|
|
} else {
|
|
selectParts.push(`NULL AS "${col.name}"`);
|
|
}
|
|
} else {
|
|
// 일반 컬럼
|
|
selectParts.push(`d."${col.name}"`);
|
|
}
|
|
}
|
|
|
|
const selectClause = selectParts.join(", ");
|
|
|
|
// 엔티티 조인 절 구성
|
|
const entityJoinClauses = entityJoins.map(ej =>
|
|
`LEFT JOIN "${ej.refTable}" ${ej.alias} ON m."${ej.sourceColumn}" = ${ej.alias}."${ej.refColumn}"`
|
|
).join("\n ");
|
|
|
|
// 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 !== "") {
|
|
// 조인 컬럼인지 확인
|
|
if (key.includes(".")) continue;
|
|
// 마스터 테이블 컬럼인지 확인
|
|
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
|
|
${entityJoinClauses}
|
|
${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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 특정 테이블에서 참조 테이블로의 FK 컬럼 찾기
|
|
*/
|
|
private async findForeignKeyColumn(
|
|
sourceTable: string,
|
|
referenceTable: string
|
|
): Promise<{ sourceColumn: string; referenceColumn: string } | null> {
|
|
try {
|
|
const result = await query<{ column_name: string; reference_column: string }>(
|
|
`SELECT column_name, reference_column
|
|
FROM column_labels
|
|
WHERE table_name = $1
|
|
AND reference_table = $2
|
|
AND input_type = 'entity'
|
|
LIMIT 1`,
|
|
[sourceTable, referenceTable]
|
|
);
|
|
|
|
if (result.length > 0) {
|
|
return {
|
|
sourceColumn: result[0].column_name,
|
|
referenceColumn: result[0].reference_column,
|
|
};
|
|
}
|
|
return null;
|
|
} catch (error) {
|
|
logger.error(`FK 컬럼 조회 실패: ${sourceTable} -> ${referenceTable}`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 마스터-디테일 데이터 업로드 (엑셀 업로드용)
|
|
*
|
|
* 처리 로직:
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* 마스터-디테일 간단 모드 업로드
|
|
*
|
|
* 마스터 정보는 UI에서 선택하고, 엑셀은 디테일 데이터만 포함
|
|
* 채번 규칙을 통해 마스터 키 자동 생성
|
|
*
|
|
* @param screenId 화면 ID
|
|
* @param detailData 디테일 데이터 배열
|
|
* @param masterFieldValues UI에서 선택한 마스터 필드 값
|
|
* @param numberingRuleId 채번 규칙 ID (optional)
|
|
* @param companyCode 회사 코드
|
|
* @param userId 사용자 ID
|
|
* @param afterUploadFlowId 업로드 후 실행할 노드 플로우 ID (optional, 하위 호환성)
|
|
* @param afterUploadFlows 업로드 후 실행할 노드 플로우 배열 (optional)
|
|
*/
|
|
async uploadSimple(
|
|
screenId: number,
|
|
detailData: Record<string, any>[],
|
|
masterFieldValues: Record<string, any>,
|
|
numberingRuleId: string | undefined,
|
|
companyCode: string,
|
|
userId: string,
|
|
afterUploadFlowId?: string,
|
|
afterUploadFlows?: Array<{ flowId: string; order: number }>
|
|
): Promise<{
|
|
success: boolean;
|
|
masterInserted: number;
|
|
detailInserted: number;
|
|
generatedKey: string;
|
|
errors: string[];
|
|
controlResult?: any;
|
|
}> {
|
|
const result: {
|
|
success: boolean;
|
|
masterInserted: number;
|
|
detailInserted: number;
|
|
generatedKey: string;
|
|
errors: string[];
|
|
controlResult?: any;
|
|
} = {
|
|
success: false,
|
|
masterInserted: 0,
|
|
detailInserted: 0,
|
|
generatedKey: "",
|
|
errors: [] as string[],
|
|
};
|
|
|
|
const pool = getPool();
|
|
const client = await pool.connect();
|
|
|
|
try {
|
|
await client.query("BEGIN");
|
|
|
|
// 1. 마스터-디테일 관계 정보 조회
|
|
const relation = await this.getMasterDetailRelation(screenId);
|
|
if (!relation) {
|
|
throw new Error("마스터-디테일 관계 정보를 찾을 수 없습니다.");
|
|
}
|
|
|
|
const { masterTable, detailTable, masterKeyColumn, detailFkColumn } = relation;
|
|
|
|
// 2. 채번 처리
|
|
let generatedKey: string;
|
|
|
|
if (numberingRuleId) {
|
|
// 채번 규칙으로 키 생성
|
|
generatedKey = await this.generateNumberWithRule(client, numberingRuleId, companyCode);
|
|
} else {
|
|
// 채번 규칙 없으면 마스터 필드에서 키 값 사용
|
|
generatedKey = masterFieldValues[masterKeyColumn];
|
|
if (!generatedKey) {
|
|
throw new Error(`마스터 키(${masterKeyColumn}) 값이 필요합니다.`);
|
|
}
|
|
}
|
|
|
|
result.generatedKey = generatedKey;
|
|
logger.info(`채번 결과: ${generatedKey}`);
|
|
|
|
// 3. 마스터 레코드 생성
|
|
const masterData: Record<string, any> = {
|
|
...masterFieldValues,
|
|
[masterKeyColumn]: generatedKey,
|
|
company_code: companyCode,
|
|
writer: userId,
|
|
};
|
|
|
|
// 마스터 컬럼명 목록 구성
|
|
const masterCols = Object.keys(masterData).filter(k => masterData[k] !== undefined);
|
|
const masterPlaceholders = masterCols.map((_, i) => `$${i + 1}`);
|
|
const masterValues = masterCols.map(k => masterData[k]);
|
|
|
|
await client.query(
|
|
`INSERT INTO "${masterTable}" (${masterCols.map(c => `"${c}"`).join(", ")}, created_date)
|
|
VALUES (${masterPlaceholders.join(", ")}, NOW())`,
|
|
masterValues
|
|
);
|
|
result.masterInserted = 1;
|
|
logger.info(`마스터 레코드 생성: ${masterTable}, key=${generatedKey}`);
|
|
|
|
// 4. 디테일 레코드들 생성 (삽입된 데이터 수집)
|
|
const insertedDetailRows: Record<string, any>[] = [];
|
|
|
|
for (const row of detailData) {
|
|
try {
|
|
const detailRowData: Record<string, any> = {
|
|
...row,
|
|
[detailFkColumn]: generatedKey,
|
|
company_code: companyCode,
|
|
writer: userId,
|
|
};
|
|
|
|
// 빈 값 필터링 및 id 제외
|
|
const detailCols = Object.keys(detailRowData).filter(k =>
|
|
k !== "id" &&
|
|
detailRowData[k] !== undefined &&
|
|
detailRowData[k] !== null &&
|
|
detailRowData[k] !== ""
|
|
);
|
|
const detailPlaceholders = detailCols.map((_, i) => `$${i + 1}`);
|
|
const detailValues = detailCols.map(k => detailRowData[k]);
|
|
|
|
// RETURNING *로 삽입된 데이터 반환받기
|
|
const insertResult = await client.query(
|
|
`INSERT INTO "${detailTable}" (${detailCols.map(c => `"${c}"`).join(", ")}, created_date)
|
|
VALUES (${detailPlaceholders.join(", ")}, NOW())
|
|
RETURNING *`,
|
|
detailValues
|
|
);
|
|
|
|
if (insertResult.rows && insertResult.rows[0]) {
|
|
insertedDetailRows.push(insertResult.rows[0]);
|
|
}
|
|
|
|
result.detailInserted++;
|
|
} catch (error: any) {
|
|
result.errors.push(`디테일 행 처리 실패: ${error.message}`);
|
|
logger.error(`디테일 행 처리 실패:`, error);
|
|
}
|
|
}
|
|
|
|
logger.info(`디테일 레코드 ${insertedDetailRows.length}건 삽입 완료`);
|
|
|
|
await client.query("COMMIT");
|
|
result.success = result.errors.length === 0 || result.detailInserted > 0;
|
|
|
|
logger.info(`마스터-디테일 간단 모드 업로드 완료:`, {
|
|
masterInserted: result.masterInserted,
|
|
detailInserted: result.detailInserted,
|
|
generatedKey: result.generatedKey,
|
|
errors: result.errors.length,
|
|
});
|
|
|
|
// 업로드 후 제어 실행 (단일 또는 다중)
|
|
const flowsToExecute = afterUploadFlows && afterUploadFlows.length > 0
|
|
? afterUploadFlows // 다중 제어
|
|
: afterUploadFlowId
|
|
? [{ flowId: afterUploadFlowId, order: 1 }] // 단일 (하위 호환성)
|
|
: [];
|
|
|
|
if (flowsToExecute.length > 0 && result.success) {
|
|
try {
|
|
const { NodeFlowExecutionService } = await import("./nodeFlowExecutionService");
|
|
|
|
// 마스터 데이터 구성
|
|
const masterData = {
|
|
...masterFieldValues,
|
|
[relation!.masterKeyColumn]: result.generatedKey,
|
|
company_code: companyCode,
|
|
};
|
|
|
|
const controlResults: any[] = [];
|
|
|
|
// 순서대로 제어 실행
|
|
for (const flow of flowsToExecute.sort((a, b) => a.order - b.order)) {
|
|
logger.info(`업로드 후 제어 실행: flowId=${flow.flowId}, order=${flow.order}`);
|
|
logger.info(` 전달 데이터: 마스터 1건, 디테일 ${insertedDetailRows.length}건`);
|
|
|
|
// 🆕 삽입된 디테일 데이터를 sourceData로 전달 (성능 최적화)
|
|
// - 전체 테이블 조회 대신 방금 INSERT한 데이터만 처리
|
|
// - tableSource 노드가 context-data 모드일 때 이 데이터를 사용
|
|
const controlResult = await NodeFlowExecutionService.executeFlow(
|
|
parseInt(flow.flowId),
|
|
{
|
|
sourceData: insertedDetailRows.length > 0 ? insertedDetailRows : [masterData],
|
|
dataSourceType: "excelUpload", // 엑셀 업로드 데이터임을 명시
|
|
buttonId: "excel-upload-button",
|
|
screenId: screenId,
|
|
userId: userId,
|
|
companyCode: companyCode,
|
|
formData: masterData,
|
|
// 추가 컨텍스트: 마스터/디테일 정보
|
|
masterData: masterData,
|
|
detailData: insertedDetailRows,
|
|
masterTable: relation!.masterTable,
|
|
detailTable: relation!.detailTable,
|
|
masterKeyColumn: relation!.masterKeyColumn,
|
|
detailFkColumn: relation!.detailFkColumn,
|
|
}
|
|
);
|
|
|
|
controlResults.push({
|
|
flowId: flow.flowId,
|
|
order: flow.order,
|
|
success: controlResult.success,
|
|
message: controlResult.message,
|
|
executedNodes: controlResult.nodes?.length || 0,
|
|
});
|
|
}
|
|
|
|
result.controlResult = {
|
|
success: controlResults.every(r => r.success),
|
|
executedFlows: controlResults.length,
|
|
results: controlResults,
|
|
};
|
|
|
|
logger.info(`업로드 후 제어 실행 완료: ${controlResults.length}개 실행`, result.controlResult);
|
|
} catch (controlError: any) {
|
|
logger.error(`업로드 후 제어 실행 실패:`, controlError);
|
|
result.controlResult = {
|
|
success: false,
|
|
message: `제어 실행 실패: ${controlError.message}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
} catch (error: any) {
|
|
await client.query("ROLLBACK");
|
|
result.errors.push(`트랜잭션 실패: ${error.message}`);
|
|
logger.error(`마스터-디테일 간단 모드 업로드 실패:`, error);
|
|
} finally {
|
|
client.release();
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* 채번 규칙으로 번호 생성 (기존 numberingRuleService 사용)
|
|
*/
|
|
private async generateNumberWithRule(
|
|
client: any,
|
|
ruleId: string,
|
|
companyCode: string
|
|
): Promise<string> {
|
|
try {
|
|
// 기존 numberingRuleService를 사용하여 코드 할당
|
|
const { numberingRuleService } = await import("./numberingRuleService");
|
|
const generatedCode = await numberingRuleService.allocateCode(ruleId, companyCode);
|
|
|
|
logger.info(`채번 생성 (numberingRuleService): rule=${ruleId}, result=${generatedCode}`);
|
|
|
|
return generatedCode;
|
|
} catch (error: any) {
|
|
logger.error(`채번 생성 실패: rule=${ruleId}, error=${error.message}`);
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
export const masterDetailExcelService = new MasterDetailExcelService();
|
|
|