Compare commits
2 Commits
69711f4e4b
...
150a40e2a8
| Author | SHA1 | Date |
|---|---|---|
|
|
150a40e2a8 | |
|
|
cea3aa53ae |
|
|
@ -1,10 +1,196 @@
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { dataService } from "../services/dataService";
|
import { dataService } from "../services/dataService";
|
||||||
|
import { masterDetailExcelService } from "../services/masterDetailExcelService";
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
import { AuthenticatedRequest } from "../types/auth";
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// 마스터-디테일 엑셀 API
|
||||||
|
// ================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 관계 정보 조회
|
||||||
|
* GET /api/data/master-detail/relation/:screenId
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/master-detail/relation/:screenId",
|
||||||
|
authenticateToken,
|
||||||
|
async (req: AuthenticatedRequest, res) => {
|
||||||
|
try {
|
||||||
|
const { screenId } = req.params;
|
||||||
|
|
||||||
|
if (!screenId || isNaN(parseInt(screenId))) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "유효한 screenId가 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔍 마스터-디테일 관계 조회: screenId=${screenId}`);
|
||||||
|
|
||||||
|
const relation = await masterDetailExcelService.getMasterDetailRelation(
|
||||||
|
parseInt(screenId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!relation) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: null,
|
||||||
|
message: "마스터-디테일 구조가 아닙니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ 마스터-디테일 관계 발견:`, {
|
||||||
|
masterTable: relation.masterTable,
|
||||||
|
detailTable: relation.detailTable,
|
||||||
|
joinKey: relation.masterKeyColumn,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: relation,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("마스터-디테일 관계 조회 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "마스터-디테일 관계 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 엑셀 다운로드 데이터 조회
|
||||||
|
* POST /api/data/master-detail/download
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/master-detail/download",
|
||||||
|
authenticateToken,
|
||||||
|
async (req: AuthenticatedRequest, res) => {
|
||||||
|
try {
|
||||||
|
const { screenId, filters } = req.body;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
if (!screenId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "screenId가 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📥 마스터-디테일 엑셀 다운로드: screenId=${screenId}`);
|
||||||
|
|
||||||
|
// 1. 마스터-디테일 관계 조회
|
||||||
|
const relation = await masterDetailExcelService.getMasterDetailRelation(
|
||||||
|
parseInt(screenId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!relation) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "마스터-디테일 구조가 아닙니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. JOIN 데이터 조회
|
||||||
|
const data = await masterDetailExcelService.getJoinedData(
|
||||||
|
relation,
|
||||||
|
companyCode,
|
||||||
|
filters
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`✅ 마스터-디테일 데이터 조회 완료: ${data.data.length}행`);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("마스터-디테일 다운로드 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "마스터-디테일 다운로드 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 엑셀 업로드
|
||||||
|
* POST /api/data/master-detail/upload
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/master-detail/upload",
|
||||||
|
authenticateToken,
|
||||||
|
async (req: AuthenticatedRequest, res) => {
|
||||||
|
try {
|
||||||
|
const { screenId, data } = req.body;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
|
||||||
|
if (!screenId || !data || !Array.isArray(data)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "screenId와 data 배열이 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📤 마스터-디테일 엑셀 업로드: screenId=${screenId}, rows=${data.length}`);
|
||||||
|
|
||||||
|
// 1. 마스터-디테일 관계 조회
|
||||||
|
const relation = await masterDetailExcelService.getMasterDetailRelation(
|
||||||
|
parseInt(screenId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!relation) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "마스터-디테일 구조가 아닙니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 데이터 업로드
|
||||||
|
const result = await masterDetailExcelService.uploadJoinedData(
|
||||||
|
relation,
|
||||||
|
data,
|
||||||
|
companyCode,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`✅ 마스터-디테일 업로드 완료:`, {
|
||||||
|
masterInserted: result.masterInserted,
|
||||||
|
masterUpdated: result.masterUpdated,
|
||||||
|
detailInserted: result.detailInserted,
|
||||||
|
errors: result.errors.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: result.success,
|
||||||
|
data: result,
|
||||||
|
message: result.success
|
||||||
|
? `마스터 ${result.masterInserted + result.masterUpdated}건, 디테일 ${result.detailInserted}건 처리되었습니다.`
|
||||||
|
: "업로드 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("마스터-디테일 업로드 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "마스터-디테일 업로드 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// 기존 데이터 API
|
||||||
|
// ================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 조인 데이터 조회 API (다른 라우트보다 먼저 정의)
|
* 조인 데이터 조회 API (다른 라우트보다 먼저 정의)
|
||||||
* GET /api/data/join?leftTable=...&rightTable=...&leftColumn=...&rightColumn=...&leftValue=...
|
* GET /api/data/join?leftTable=...&rightTable=...&leftColumn=...&rightColumn=...&leftValue=...
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,527 @@
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 엑셀 처리 서비스
|
||||||
|
*
|
||||||
|
* 분할 패널 화면의 마스터-디테일 구조를 자동 감지하고
|
||||||
|
* 엑셀 다운로드/업로드 시 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();
|
||||||
|
|
||||||
|
|
@ -42,6 +42,17 @@ export interface ExcelUploadModalProps {
|
||||||
keyColumn?: string;
|
keyColumn?: string;
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
// 마스터-디테일 지원
|
||||||
|
screenId?: number;
|
||||||
|
isMasterDetail?: boolean;
|
||||||
|
masterDetailRelation?: {
|
||||||
|
masterTable: string;
|
||||||
|
detailTable: string;
|
||||||
|
masterKeyColumn: string;
|
||||||
|
detailFkColumn: string;
|
||||||
|
masterColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
|
||||||
|
detailColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ColumnMapping {
|
interface ColumnMapping {
|
||||||
|
|
@ -57,6 +68,9 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
keyColumn,
|
keyColumn,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
userId = "guest",
|
userId = "guest",
|
||||||
|
screenId,
|
||||||
|
isMasterDetail = false,
|
||||||
|
masterDetailRelation,
|
||||||
}) => {
|
}) => {
|
||||||
const [currentStep, setCurrentStep] = useState(1);
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
|
|
||||||
|
|
@ -184,19 +198,71 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
|
|
||||||
const loadTableSchema = async () => {
|
const loadTableSchema = async () => {
|
||||||
try {
|
try {
|
||||||
console.log("🔍 테이블 스키마 로드 시작:", { tableName });
|
console.log("🔍 테이블 스키마 로드 시작:", { tableName, isMasterDetail });
|
||||||
|
|
||||||
|
let allColumns: TableColumn[] = [];
|
||||||
|
|
||||||
|
// 🆕 마스터-디테일 모드: 두 테이블의 컬럼 합치기
|
||||||
|
if (isMasterDetail && masterDetailRelation) {
|
||||||
|
const { masterTable, detailTable, detailFkColumn } = masterDetailRelation;
|
||||||
|
|
||||||
|
console.log("📊 마스터-디테일 스키마 로드:", { masterTable, detailTable });
|
||||||
|
|
||||||
|
// 마스터 테이블 스키마
|
||||||
|
const masterResponse = await getTableSchema(masterTable);
|
||||||
|
if (masterResponse.success && masterResponse.data) {
|
||||||
|
const masterCols = masterResponse.data.columns
|
||||||
|
.filter((col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase()))
|
||||||
|
.map((col) => ({
|
||||||
|
...col,
|
||||||
|
// 유니크 키를 위해 테이블명 접두사 추가
|
||||||
|
name: `${masterTable}.${col.name}`,
|
||||||
|
label: `[마스터] ${col.label || col.name}`,
|
||||||
|
originalName: col.name,
|
||||||
|
sourceTable: masterTable,
|
||||||
|
}));
|
||||||
|
allColumns = [...allColumns, ...masterCols];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 디테일 테이블 스키마 (FK 컬럼 제외)
|
||||||
|
const detailResponse = await getTableSchema(detailTable);
|
||||||
|
if (detailResponse.success && detailResponse.data) {
|
||||||
|
const detailCols = detailResponse.data.columns
|
||||||
|
.filter((col) =>
|
||||||
|
!AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase()) &&
|
||||||
|
col.name !== detailFkColumn // FK 컬럼 제외
|
||||||
|
)
|
||||||
|
.map((col) => ({
|
||||||
|
...col,
|
||||||
|
// 유니크 키를 위해 테이블명 접두사 추가
|
||||||
|
name: `${detailTable}.${col.name}`,
|
||||||
|
label: `[디테일] ${col.label || col.name}`,
|
||||||
|
originalName: col.name,
|
||||||
|
sourceTable: detailTable,
|
||||||
|
}));
|
||||||
|
allColumns = [...allColumns, ...detailCols];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ 마스터-디테일 컬럼 로드 완료:", allColumns.length);
|
||||||
|
} else {
|
||||||
|
// 기존 단일 테이블 모드
|
||||||
const response = await getTableSchema(tableName);
|
const response = await getTableSchema(tableName);
|
||||||
|
|
||||||
console.log("📊 테이블 스키마 응답:", response);
|
console.log("📊 테이블 스키마 응답:", response);
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
// 자동 생성 컬럼 제외
|
// 자동 생성 컬럼 제외
|
||||||
const filteredColumns = response.data.columns.filter(
|
allColumns = response.data.columns.filter(
|
||||||
(col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase())
|
(col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase())
|
||||||
);
|
);
|
||||||
console.log("✅ 시스템 컬럼 로드 완료 (자동 생성 컬럼 제외):", filteredColumns);
|
} else {
|
||||||
setSystemColumns(filteredColumns);
|
console.error("❌ 테이블 스키마 로드 실패:", response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ 시스템 컬럼 로드 완료 (자동 생성 컬럼 제외):", allColumns);
|
||||||
|
setSystemColumns(allColumns);
|
||||||
|
|
||||||
// 기존 매핑 템플릿 조회
|
// 기존 매핑 템플릿 조회
|
||||||
console.log("🔍 매핑 템플릿 조회 중...", { tableName, excelColumns });
|
console.log("🔍 매핑 템플릿 조회 중...", { tableName, excelColumns });
|
||||||
|
|
@ -226,9 +292,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
setColumnMappings(initialMappings);
|
setColumnMappings(initialMappings);
|
||||||
setIsAutoMappingLoaded(false);
|
setIsAutoMappingLoaded(false);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
console.error("❌ 테이블 스키마 로드 실패:", response);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 테이블 스키마 로드 실패:", error);
|
console.error("❌ 테이블 스키마 로드 실패:", error);
|
||||||
toast.error("테이블 스키마를 불러올 수 없습니다.");
|
toast.error("테이블 스키마를 불러올 수 없습니다.");
|
||||||
|
|
@ -239,18 +302,35 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
const handleAutoMapping = () => {
|
const handleAutoMapping = () => {
|
||||||
const newMappings = excelColumns.map((excelCol) => {
|
const newMappings = excelColumns.map((excelCol) => {
|
||||||
const normalizedExcelCol = excelCol.toLowerCase().trim();
|
const normalizedExcelCol = excelCol.toLowerCase().trim();
|
||||||
|
// [마스터], [디테일] 접두사 제거 후 비교
|
||||||
|
const cleanExcelCol = normalizedExcelCol.replace(/^\[(마스터|디테일)\]\s*/i, "");
|
||||||
|
|
||||||
// 1. 먼저 라벨로 매칭 시도
|
// 1. 먼저 라벨로 매칭 시도 (접두사 제거 후)
|
||||||
let matchedSystemCol = systemColumns.find(
|
let matchedSystemCol = systemColumns.find((sysCol) => {
|
||||||
(sysCol) =>
|
if (!sysCol.label) return false;
|
||||||
sysCol.label && sysCol.label.toLowerCase().trim() === normalizedExcelCol
|
// [마스터], [디테일] 접두사 제거 후 비교
|
||||||
);
|
const cleanLabel = sysCol.label.toLowerCase().trim().replace(/^\[(마스터|디테일)\]\s*/i, "");
|
||||||
|
return cleanLabel === normalizedExcelCol || cleanLabel === cleanExcelCol;
|
||||||
|
});
|
||||||
|
|
||||||
// 2. 라벨로 매칭되지 않으면 컬럼명으로 매칭 시도
|
// 2. 라벨로 매칭되지 않으면 컬럼명으로 매칭 시도
|
||||||
if (!matchedSystemCol) {
|
if (!matchedSystemCol) {
|
||||||
matchedSystemCol = systemColumns.find(
|
matchedSystemCol = systemColumns.find((sysCol) => {
|
||||||
(sysCol) => sysCol.name.toLowerCase().trim() === normalizedExcelCol
|
// 마스터-디테일 모드: originalName이 있으면 사용
|
||||||
);
|
const originalName = (sysCol as any).originalName;
|
||||||
|
const colName = originalName || sysCol.name;
|
||||||
|
return colName.toLowerCase().trim() === normalizedExcelCol || colName.toLowerCase().trim() === cleanExcelCol;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 여전히 매칭 안되면 전체 이름(테이블.컬럼)에서 컬럼 부분만 추출해서 비교
|
||||||
|
if (!matchedSystemCol) {
|
||||||
|
matchedSystemCol = systemColumns.find((sysCol) => {
|
||||||
|
// 테이블.컬럼 형식에서 컬럼만 추출
|
||||||
|
const nameParts = sysCol.name.split(".");
|
||||||
|
const colNameOnly = nameParts.length > 1 ? nameParts[1] : nameParts[0];
|
||||||
|
return colNameOnly.toLowerCase().trim() === normalizedExcelCol || colNameOnly.toLowerCase().trim() === cleanExcelCol;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -344,7 +424,12 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
const mappedRow: Record<string, any> = {};
|
const mappedRow: Record<string, any> = {};
|
||||||
columnMappings.forEach((mapping) => {
|
columnMappings.forEach((mapping) => {
|
||||||
if (mapping.systemColumn) {
|
if (mapping.systemColumn) {
|
||||||
mappedRow[mapping.systemColumn] = row[mapping.excelColumn];
|
// 마스터-디테일 모드: 테이블.컬럼 형식에서 컬럼명만 추출
|
||||||
|
let colName = mapping.systemColumn;
|
||||||
|
if (isMasterDetail && colName.includes(".")) {
|
||||||
|
colName = colName.split(".")[1];
|
||||||
|
}
|
||||||
|
mappedRow[colName] = row[mapping.excelColumn];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return mappedRow;
|
return mappedRow;
|
||||||
|
|
@ -364,6 +449,32 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
`📊 엑셀 업로드: 전체 ${mappedData.length}행 중 유효한 ${filteredData.length}행`
|
`📊 엑셀 업로드: 전체 ${mappedData.length}행 중 유효한 ${filteredData.length}행`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 🆕 마스터-디테일 모드 처리
|
||||||
|
if (isMasterDetail && screenId && masterDetailRelation) {
|
||||||
|
console.log("📊 마스터-디테일 업로드 모드:", masterDetailRelation);
|
||||||
|
|
||||||
|
const uploadResult = await DynamicFormApi.uploadMasterDetailData(
|
||||||
|
screenId,
|
||||||
|
filteredData
|
||||||
|
);
|
||||||
|
|
||||||
|
if (uploadResult.success && uploadResult.data) {
|
||||||
|
const { masterInserted, masterUpdated, detailInserted, errors } = uploadResult.data;
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
`마스터 ${masterInserted + masterUpdated}건, 디테일 ${detailInserted}건 처리되었습니다.` +
|
||||||
|
(errors.length > 0 ? ` (오류: ${errors.length}건)` : "")
|
||||||
|
);
|
||||||
|
|
||||||
|
// 매핑 템플릿 저장
|
||||||
|
await saveMappingTemplateInternal();
|
||||||
|
|
||||||
|
onSuccess?.();
|
||||||
|
} else {
|
||||||
|
toast.error(uploadResult.message || "마스터-디테일 업로드에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 기존 단일 테이블 업로드 로직
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
let failCount = 0;
|
let failCount = 0;
|
||||||
|
|
||||||
|
|
@ -388,7 +499,24 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
`${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}`
|
`${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// 매핑 템플릿 저장 (UPSERT - 자동 저장)
|
// 매핑 템플릿 저장
|
||||||
|
await saveMappingTemplateInternal();
|
||||||
|
|
||||||
|
onSuccess?.();
|
||||||
|
} else {
|
||||||
|
toast.error("업로드에 실패했습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 엑셀 업로드 실패:", error);
|
||||||
|
toast.error("엑셀 업로드 중 오류가 발생했습니다.");
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 매핑 템플릿 저장 헬퍼 함수
|
||||||
|
const saveMappingTemplateInternal = async () => {
|
||||||
try {
|
try {
|
||||||
const mappingsToSave: Record<string, string | null> = {};
|
const mappingsToSave: Record<string, string | null> = {};
|
||||||
columnMappings.forEach((mapping) => {
|
columnMappings.forEach((mapping) => {
|
||||||
|
|
@ -414,17 +542,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("⚠️ 매핑 템플릿 저장 중 오류:", error);
|
console.warn("⚠️ 매핑 템플릿 저장 중 오류:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
onSuccess?.();
|
|
||||||
} else {
|
|
||||||
toast.error("업로드에 실패했습니다.");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ 엑셀 업로드 실패:", error);
|
|
||||||
toast.error("엑셀 업로드 중 오류가 발생했습니다.");
|
|
||||||
} finally {
|
|
||||||
setIsUploading(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 모달 닫기 시 초기화
|
// 모달 닫기 시 초기화
|
||||||
|
|
@ -461,9 +578,21 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||||
<FileSpreadsheet className="h-5 w-5" />
|
<FileSpreadsheet className="h-5 w-5" />
|
||||||
엑셀 데이터 업로드
|
엑셀 데이터 업로드
|
||||||
|
{isMasterDetail && (
|
||||||
|
<span className="ml-2 rounded bg-blue-100 px-2 py-0.5 text-xs font-normal text-blue-700">
|
||||||
|
마스터-디테일
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-xs sm:text-sm">
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요.
|
{isMasterDetail && masterDetailRelation ? (
|
||||||
|
<>
|
||||||
|
마스터({masterDetailRelation.masterTable}) + 디테일({masterDetailRelation.detailTable}) 구조입니다.
|
||||||
|
마스터 데이터는 중복 입력 시 병합됩니다.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요."
|
||||||
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -556,6 +556,130 @@ export class DynamicFormApi {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// 마스터-디테일 엑셀 API
|
||||||
|
// ================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 관계 정보 조회
|
||||||
|
* @param screenId 화면 ID
|
||||||
|
* @returns 마스터-디테일 관계 정보 (null이면 마스터-디테일 구조 아님)
|
||||||
|
*/
|
||||||
|
static async getMasterDetailRelation(screenId: number): Promise<ApiResponse<MasterDetailRelation | null>> {
|
||||||
|
try {
|
||||||
|
console.log("🔍 마스터-디테일 관계 조회:", screenId);
|
||||||
|
|
||||||
|
const response = await apiClient.get(`/data/master-detail/relation/${screenId}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: response.data?.data || null,
|
||||||
|
message: response.data?.message || "조회 완료",
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ 마스터-디테일 관계 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
data: null,
|
||||||
|
message: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 엑셀 다운로드 데이터 조회
|
||||||
|
* @param screenId 화면 ID
|
||||||
|
* @param filters 필터 조건
|
||||||
|
* @returns JOIN된 플랫 데이터
|
||||||
|
*/
|
||||||
|
static async getMasterDetailDownloadData(
|
||||||
|
screenId: number,
|
||||||
|
filters?: Record<string, any>
|
||||||
|
): Promise<ApiResponse<MasterDetailDownloadData>> {
|
||||||
|
try {
|
||||||
|
console.log("📥 마스터-디테일 다운로드 데이터 조회:", { screenId, filters });
|
||||||
|
|
||||||
|
const response = await apiClient.post(`/data/master-detail/download`, {
|
||||||
|
screenId,
|
||||||
|
filters,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: response.data?.data,
|
||||||
|
message: "데이터 조회 완료",
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ 마스터-디테일 다운로드 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터-디테일 엑셀 업로드
|
||||||
|
* @param screenId 화면 ID
|
||||||
|
* @param data 엑셀에서 읽은 플랫 데이터
|
||||||
|
* @returns 업로드 결과
|
||||||
|
*/
|
||||||
|
static async uploadMasterDetailData(
|
||||||
|
screenId: number,
|
||||||
|
data: Record<string, any>[]
|
||||||
|
): Promise<ApiResponse<MasterDetailUploadResult>> {
|
||||||
|
try {
|
||||||
|
console.log("📤 마스터-디테일 업로드:", { screenId, rowCount: data.length });
|
||||||
|
|
||||||
|
const response = await apiClient.post(`/data/master-detail/upload`, {
|
||||||
|
screenId,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: response.data?.success,
|
||||||
|
data: response.data?.data,
|
||||||
|
message: response.data?.message,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ 마스터-디테일 업로드 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 마스터-디테일 관계 타입
|
||||||
|
export interface MasterDetailRelation {
|
||||||
|
masterTable: string;
|
||||||
|
detailTable: string;
|
||||||
|
masterKeyColumn: string;
|
||||||
|
detailFkColumn: string;
|
||||||
|
masterColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
|
||||||
|
detailColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 마스터-디테일 다운로드 데이터 타입
|
||||||
|
export interface MasterDetailDownloadData {
|
||||||
|
headers: string[];
|
||||||
|
columns: string[];
|
||||||
|
data: Record<string, any>[];
|
||||||
|
masterColumns: string[];
|
||||||
|
detailColumns: string[];
|
||||||
|
joinKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 마스터-디테일 업로드 결과 타입
|
||||||
|
export interface MasterDetailUploadResult {
|
||||||
|
success: boolean;
|
||||||
|
masterInserted: number;
|
||||||
|
masterUpdated: number;
|
||||||
|
detailInserted: number;
|
||||||
|
detailDeleted: number;
|
||||||
|
errors: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 편의를 위한 기본 export
|
// 편의를 위한 기본 export
|
||||||
|
|
|
||||||
|
|
@ -4481,8 +4481,67 @@ export class ButtonActionExecutor {
|
||||||
const { exportToExcel } = await import("@/lib/utils/excelExport");
|
const { exportToExcel } = await import("@/lib/utils/excelExport");
|
||||||
|
|
||||||
let dataToExport: any[] = [];
|
let dataToExport: any[] = [];
|
||||||
|
let visibleColumns: string[] | undefined = undefined;
|
||||||
|
let columnLabels: Record<string, string> | undefined = undefined;
|
||||||
|
|
||||||
// ✅ 항상 API 호출로 필터링된 전체 데이터 가져오기
|
// 🆕 마스터-디테일 구조 확인 및 처리
|
||||||
|
if (context.screenId) {
|
||||||
|
const { DynamicFormApi } = await import("@/lib/api/dynamicForm");
|
||||||
|
const relationResponse = await DynamicFormApi.getMasterDetailRelation(context.screenId);
|
||||||
|
|
||||||
|
if (relationResponse.success && relationResponse.data) {
|
||||||
|
// 마스터-디테일 구조인 경우 전용 API 사용
|
||||||
|
console.log("📊 마스터-디테일 엑셀 다운로드:", relationResponse.data);
|
||||||
|
|
||||||
|
const downloadResponse = await DynamicFormApi.getMasterDetailDownloadData(
|
||||||
|
context.screenId,
|
||||||
|
context.filterConditions
|
||||||
|
);
|
||||||
|
|
||||||
|
if (downloadResponse.success && downloadResponse.data) {
|
||||||
|
dataToExport = downloadResponse.data.data;
|
||||||
|
visibleColumns = downloadResponse.data.columns;
|
||||||
|
|
||||||
|
// 헤더와 컬럼 매핑
|
||||||
|
columnLabels = {};
|
||||||
|
downloadResponse.data.columns.forEach((col: string, index: number) => {
|
||||||
|
columnLabels![col] = downloadResponse.data.headers[index] || col;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ 마스터-디테일 데이터 조회 완료: ${dataToExport.length}행`);
|
||||||
|
} else {
|
||||||
|
toast.error("마스터-디테일 데이터 조회에 실패했습니다.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 마스터-디테일 데이터 변환 및 다운로드
|
||||||
|
if (visibleColumns && visibleColumns.length > 0 && dataToExport.length > 0) {
|
||||||
|
dataToExport = dataToExport.map((row: any) => {
|
||||||
|
const filteredRow: Record<string, any> = {};
|
||||||
|
visibleColumns!.forEach((columnName: string) => {
|
||||||
|
const label = columnLabels?.[columnName] || columnName;
|
||||||
|
filteredRow[label] = row[columnName];
|
||||||
|
});
|
||||||
|
return filteredRow;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일명 생성
|
||||||
|
let defaultFileName = relationResponse.data.masterTable || "데이터";
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const menuName = localStorage.getItem("currentMenuName");
|
||||||
|
if (menuName) defaultFileName = menuName;
|
||||||
|
}
|
||||||
|
const fileName = config.excelFileName || `${defaultFileName}_${new Date().toISOString().split("T")[0]}.xlsx`;
|
||||||
|
const sheetName = config.excelSheetName || "Sheet1";
|
||||||
|
|
||||||
|
await exportToExcel(dataToExport, fileName, sheetName, true);
|
||||||
|
toast.success(`${dataToExport.length}개 행이 다운로드되었습니다.`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 기존 로직: 단일 테이블 처리
|
||||||
if (context.tableName) {
|
if (context.tableName) {
|
||||||
const { tableDisplayStore } = await import("@/stores/tableDisplayStore");
|
const { tableDisplayStore } = await import("@/stores/tableDisplayStore");
|
||||||
const storedData = tableDisplayStore.getTableData(context.tableName);
|
const storedData = tableDisplayStore.getTableData(context.tableName);
|
||||||
|
|
@ -4574,8 +4633,7 @@ export class ButtonActionExecutor {
|
||||||
const includeHeaders = config.excelIncludeHeaders !== false;
|
const includeHeaders = config.excelIncludeHeaders !== false;
|
||||||
|
|
||||||
// 🎨 화면 레이아웃에서 테이블 리스트 컴포넌트의 컬럼 설정 가져오기
|
// 🎨 화면 레이아웃에서 테이블 리스트 컴포넌트의 컬럼 설정 가져오기
|
||||||
let visibleColumns: string[] | undefined = undefined;
|
// visibleColumns, columnLabels는 함수 상단에서 이미 선언됨
|
||||||
let columnLabels: Record<string, string> | undefined = undefined;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 화면 레이아웃 데이터 가져오기 (별도 API 사용)
|
// 화면 레이아웃 데이터 가져오기 (별도 API 사용)
|
||||||
|
|
@ -4776,8 +4834,24 @@ export class ButtonActionExecutor {
|
||||||
context,
|
context,
|
||||||
userId: context.userId,
|
userId: context.userId,
|
||||||
tableName: context.tableName,
|
tableName: context.tableName,
|
||||||
|
screenId: context.screenId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🆕 마스터-디테일 구조 확인
|
||||||
|
let isMasterDetail = false;
|
||||||
|
let masterDetailRelation: any = null;
|
||||||
|
|
||||||
|
if (context.screenId) {
|
||||||
|
const { DynamicFormApi } = await import("@/lib/api/dynamicForm");
|
||||||
|
const relationResponse = await DynamicFormApi.getMasterDetailRelation(context.screenId);
|
||||||
|
|
||||||
|
if (relationResponse.success && relationResponse.data) {
|
||||||
|
isMasterDetail = true;
|
||||||
|
masterDetailRelation = relationResponse.data;
|
||||||
|
console.log("📊 마스터-디테일 구조 감지:", masterDetailRelation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 동적 import로 모달 컴포넌트 로드
|
// 동적 import로 모달 컴포넌트 로드
|
||||||
const { ExcelUploadModal } = await import("@/components/common/ExcelUploadModal");
|
const { ExcelUploadModal } = await import("@/components/common/ExcelUploadModal");
|
||||||
const { createRoot } = await import("react-dom/client");
|
const { createRoot } = await import("react-dom/client");
|
||||||
|
|
@ -4820,6 +4894,10 @@ export class ButtonActionExecutor {
|
||||||
uploadMode: config.excelUploadMode || "insert",
|
uploadMode: config.excelUploadMode || "insert",
|
||||||
keyColumn: config.excelKeyColumn,
|
keyColumn: config.excelKeyColumn,
|
||||||
userId: context.userId,
|
userId: context.userId,
|
||||||
|
// 🆕 마스터-디테일 관련 props
|
||||||
|
screenId: context.screenId,
|
||||||
|
isMasterDetail,
|
||||||
|
masterDetailRelation,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// 성공 메시지는 ExcelUploadModal 내부에서 이미 표시됨
|
// 성공 메시지는 ExcelUploadModal 내부에서 이미 표시됨
|
||||||
context.onRefresh?.();
|
context.onRefresh?.();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue