1324 lines
50 KiB
TypeScript
1324 lines
50 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;
|
|
detailUpdated: 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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* table_type_columns에서 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 table_type_columns
|
|
WHERE table_name = $1
|
|
AND input_type = 'entity'
|
|
AND reference_table = $2
|
|
AND company_code = '*'
|
|
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 table_type_columns
|
|
WHERE table_name = $1 AND company_code = '*'`,
|
|
[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 정보가 없으면 table_type_columns에서 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;
|
|
tableAlias: string; // "m" (마스터) 또는 "d" (디테일) - JOIN 시 소스 테이블 구분
|
|
}> = [];
|
|
|
|
// 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++}`;
|
|
|
|
// table_type_columns에서 FK 컬럼 찾기
|
|
const fkColumn = await this.findForeignKeyColumn(masterTable, refTable);
|
|
if (fkColumn) {
|
|
entityJoins.push({
|
|
refTable,
|
|
refColumn: fkColumn.referenceColumn,
|
|
sourceColumn: fkColumn.sourceColumn,
|
|
alias,
|
|
displayColumn,
|
|
tableAlias: "m", // 마스터 테이블에서 조인
|
|
});
|
|
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++}`;
|
|
|
|
// table_type_columns에서 FK 컬럼 찾기
|
|
const fkColumn = await this.findForeignKeyColumn(detailTable, refTable);
|
|
if (fkColumn) {
|
|
entityJoins.push({
|
|
refTable,
|
|
refColumn: fkColumn.referenceColumn,
|
|
sourceColumn: fkColumn.sourceColumn,
|
|
alias,
|
|
displayColumn,
|
|
tableAlias: "d", // 디테일 테이블에서 조인
|
|
});
|
|
selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`);
|
|
} else {
|
|
selectParts.push(`NULL AS "${col.name}"`);
|
|
}
|
|
} else {
|
|
// 일반 컬럼
|
|
selectParts.push(`d."${col.name}"`);
|
|
}
|
|
}
|
|
|
|
const selectClause = selectParts.join(", ");
|
|
|
|
// 엔티티 조인 절 구성 (마스터/디테일 테이블 alias 구분)
|
|
const entityJoinClauses = entityJoins.map(ej =>
|
|
`LEFT JOIN "${ej.refTable}" ${ej.alias} ON ${ej.tableAlias}."${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 ")}`
|
|
: "";
|
|
|
|
// 디테일 테이블의 id 컬럼 존재 여부 확인 (user_info 등 id가 없는 테이블 대응)
|
|
const detailIdCheck = await queryOne<{ exists: boolean }>(
|
|
`SELECT EXISTS (
|
|
SELECT 1 FROM information_schema.columns
|
|
WHERE table_name = $1 AND column_name = 'id'
|
|
) as exists`,
|
|
[detailTable]
|
|
);
|
|
const detailOrderColumn = detailIdCheck?.exists ? `d."id"` : `d."${detailFkColumn}"`;
|
|
|
|
// 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}", ${detailOrderColumn}
|
|
`;
|
|
|
|
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 table_type_columns
|
|
WHERE table_name = $1
|
|
AND reference_table = $2
|
|
AND input_type = 'entity'
|
|
AND company_code = '*'
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 특정 테이블의 특정 컬럼이 채번 타입인지 확인하고, 채번 규칙 ID를 반환
|
|
* numbering_rules 테이블에서 table_name + column_name + company_code로 직접 조회
|
|
*/
|
|
private async detectNumberingRuleForColumn(
|
|
tableName: string,
|
|
columnName: string,
|
|
companyCode?: string
|
|
): Promise<{ numberingRuleId: string } | null> {
|
|
try {
|
|
// 1. table_type_columns에서 numbering 타입인지 확인
|
|
const companyCondition = companyCode && companyCode !== "*"
|
|
? `AND company_code IN ($3, '*')`
|
|
: `AND company_code = '*'`;
|
|
const ttcParams = companyCode && companyCode !== "*"
|
|
? [tableName, columnName, companyCode]
|
|
: [tableName, columnName];
|
|
|
|
const ttcResult = await query<any>(
|
|
`SELECT input_type FROM table_type_columns
|
|
WHERE table_name = $1 AND column_name = $2 ${companyCondition}
|
|
AND input_type = 'numbering' LIMIT 1`,
|
|
ttcParams
|
|
);
|
|
|
|
if (ttcResult.length === 0) return null;
|
|
|
|
// 2. numbering_rules에서 table_name + column_name으로 규칙 조회
|
|
const ruleCompanyCondition = companyCode && companyCode !== "*"
|
|
? `AND company_code IN ($3, '*')`
|
|
: `AND company_code = '*'`;
|
|
const ruleParams = companyCode && companyCode !== "*"
|
|
? [tableName, columnName, companyCode]
|
|
: [tableName, columnName];
|
|
|
|
const ruleResult = await query<any>(
|
|
`SELECT rule_id FROM numbering_rules
|
|
WHERE table_name = $1 AND column_name = $2 ${ruleCompanyCondition}
|
|
ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END
|
|
LIMIT 1`,
|
|
ruleParams
|
|
);
|
|
|
|
if (ruleResult.length > 0) {
|
|
return { numberingRuleId: ruleResult[0].rule_id };
|
|
}
|
|
|
|
// 3. fallback: detail_settings.numberingRuleId (하위 호환)
|
|
const fallbackResult = await query<any>(
|
|
`SELECT detail_settings FROM table_type_columns
|
|
WHERE table_name = $1 AND column_name = $2 ${companyCondition}
|
|
AND input_type = 'numbering'
|
|
ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
|
|
ttcParams
|
|
);
|
|
|
|
for (const row of fallbackResult) {
|
|
const settings = typeof row.detail_settings === "string"
|
|
? JSON.parse(row.detail_settings || "{}")
|
|
: row.detail_settings;
|
|
if (settings?.numberingRuleId) {
|
|
return { numberingRuleId: settings.numberingRuleId };
|
|
}
|
|
}
|
|
|
|
return null;
|
|
} catch (error) {
|
|
logger.error(`채번 컬럼 감지 실패: ${tableName}.${columnName}`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 특정 테이블의 모든 채번 컬럼을 한 번에 조회
|
|
* numbering_rules 테이블에서 table_name + column_name으로 직접 조회
|
|
* @returns Map<columnName, numberingRuleId>
|
|
*/
|
|
private async detectAllNumberingColumns(
|
|
tableName: string,
|
|
companyCode?: string
|
|
): Promise<Map<string, string>> {
|
|
const numberingCols = new Map<string, string>();
|
|
try {
|
|
// 1. table_type_columns에서 numbering 타입 컬럼 목록 조회
|
|
const companyCondition = companyCode && companyCode !== "*"
|
|
? `AND company_code IN ($2, '*')`
|
|
: `AND company_code = '*'`;
|
|
const params = companyCode && companyCode !== "*"
|
|
? [tableName, companyCode]
|
|
: [tableName];
|
|
|
|
const ttcResult = await query<any>(
|
|
`SELECT DISTINCT column_name FROM table_type_columns
|
|
WHERE table_name = $1 AND input_type = 'numbering' ${companyCondition}`,
|
|
params
|
|
);
|
|
|
|
// 2. 각 컬럼에 대해 numbering_rules에서 규칙 조회
|
|
for (const row of ttcResult) {
|
|
const ruleResult = await query<any>(
|
|
`SELECT rule_id FROM numbering_rules
|
|
WHERE table_name = $1 AND column_name = $2 ${companyCondition}
|
|
ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END
|
|
LIMIT 1`,
|
|
companyCode && companyCode !== "*"
|
|
? [tableName, row.column_name, companyCode]
|
|
: [tableName, row.column_name]
|
|
);
|
|
|
|
if (ruleResult.length > 0) {
|
|
numberingCols.set(row.column_name, ruleResult[0].rule_id);
|
|
}
|
|
}
|
|
|
|
if (numberingCols.size > 0) {
|
|
logger.info(`테이블 ${tableName} 채번 컬럼 감지:`, Object.fromEntries(numberingCols));
|
|
}
|
|
} catch (error) {
|
|
logger.error(`테이블 ${tableName} 채번 컬럼 감지 실패:`, error);
|
|
}
|
|
return numberingCols;
|
|
}
|
|
|
|
/**
|
|
* 디테일 테이블의 고유 키 컬럼 감지 (UPSERT 매칭용)
|
|
* PK가 비즈니스 키이면 사용, auto-increment 'id'만이면 유니크 인덱스 탐색
|
|
* @returns 고유 키 컬럼 배열 (빈 배열이면 매칭 불가 → INSERT만 수행)
|
|
*/
|
|
private async detectUniqueKeyColumns(
|
|
client: any,
|
|
tableName: string
|
|
): Promise<string[]> {
|
|
try {
|
|
// 1. PK 컬럼 조회
|
|
const pkResult = await client.query(
|
|
`SELECT array_agg(a.attname ORDER BY x.n) AS columns
|
|
FROM pg_constraint c
|
|
JOIN pg_class t ON t.oid = c.conrelid
|
|
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
CROSS JOIN LATERAL unnest(c.conkey) WITH ORDINALITY AS x(attnum, n)
|
|
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum
|
|
WHERE n.nspname = 'public' AND t.relname = $1 AND c.contype = 'p'`,
|
|
[tableName]
|
|
);
|
|
|
|
if (pkResult.rows.length > 0 && pkResult.rows[0].columns) {
|
|
const pkCols: string[] = typeof pkResult.rows[0].columns === "string"
|
|
? pkResult.rows[0].columns.replace(/[{}]/g, "").split(",").map((s: string) => s.trim())
|
|
: pkResult.rows[0].columns;
|
|
|
|
// PK가 'id' 하나만 있으면 auto-increment이므로 사용 불가
|
|
if (!(pkCols.length === 1 && pkCols[0] === "id")) {
|
|
logger.info(`디테일 테이블 ${tableName} 고유 키 (PK): ${pkCols.join(", ")}`);
|
|
return pkCols;
|
|
}
|
|
}
|
|
|
|
// 2. PK가 'id'뿐이면 유니크 인덱스 탐색
|
|
const uqResult = await client.query(
|
|
`SELECT array_agg(a.attname ORDER BY x.n) AS columns
|
|
FROM pg_index ix
|
|
JOIN pg_class t ON t.oid = ix.indrelid
|
|
JOIN pg_class i ON i.oid = ix.indexrelid
|
|
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
CROSS JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS x(attnum, n)
|
|
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum
|
|
WHERE n.nspname = 'public' AND t.relname = $1
|
|
AND ix.indisunique = true AND ix.indisprimary = false
|
|
GROUP BY i.relname
|
|
LIMIT 1`,
|
|
[tableName]
|
|
);
|
|
|
|
if (uqResult.rows.length > 0 && uqResult.rows[0].columns) {
|
|
const uqCols: string[] = typeof uqResult.rows[0].columns === "string"
|
|
? uqResult.rows[0].columns.replace(/[{}]/g, "").split(",").map((s: string) => s.trim())
|
|
: uqResult.rows[0].columns;
|
|
logger.info(`디테일 테이블 ${tableName} 고유 키 (UNIQUE INDEX): ${uqCols.join(", ")}`);
|
|
return uqCols;
|
|
}
|
|
|
|
logger.info(`디테일 테이블 ${tableName} 고유 키 없음 → INSERT 전용`);
|
|
return [];
|
|
} catch (error) {
|
|
logger.error(`디테일 테이블 ${tableName} 고유 키 감지 실패:`, error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 마스터-디테일 데이터 업로드 (엑셀 업로드용)
|
|
*
|
|
* 처리 로직:
|
|
* 1. 마스터 키 컬럼이 채번 타입인지 확인
|
|
* 2-A. 채번인 경우: 다른 마스터 컬럼 값으로 그룹화 → 키 자동 생성 → INSERT
|
|
* 2-B. 채번 아닌 경우: 마스터 키 값으로 그룹화 → UPSERT
|
|
* 3. 디테일 데이터 개별 행 UPSERT (고유 키 기반)
|
|
*/
|
|
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,
|
|
detailUpdated: 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;
|
|
|
|
// 마스터/디테일 테이블의 실제 컬럼 존재 여부 확인 (writer, created_date 등 하드코딩 방지)
|
|
const masterColsResult = await client.query(
|
|
`SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1`,
|
|
[masterTable]
|
|
);
|
|
const masterExistingCols = new Set(masterColsResult.rows.map((r: any) => r.column_name));
|
|
|
|
const detailColsResult = await client.query(
|
|
`SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1`,
|
|
[detailTable]
|
|
);
|
|
const detailExistingCols = new Set(detailColsResult.rows.map((r: any) => r.column_name));
|
|
|
|
// 마스터 키 컬럼의 채번 규칙 자동 감지 (회사별 설정 우선)
|
|
const numberingInfo = await this.detectNumberingRuleForColumn(masterTable, masterKeyColumn, companyCode);
|
|
const isAutoNumbering = !!numberingInfo;
|
|
|
|
logger.info(`마스터 키 채번 감지:`, {
|
|
masterKeyColumn,
|
|
isAutoNumbering,
|
|
numberingRuleId: numberingInfo?.numberingRuleId
|
|
});
|
|
|
|
// 데이터 그룹화
|
|
const groupedData = new Map<string, Record<string, any>[]>();
|
|
|
|
if (isAutoNumbering) {
|
|
// 채번 모드: 마스터 키 제외한 다른 마스터 컬럼 값으로 그룹화
|
|
const otherMasterCols = masterColumns.filter(c => c.name !== masterKeyColumn).map(c => c.name);
|
|
|
|
for (const row of data) {
|
|
// 다른 마스터 컬럼 값들을 조합해 그룹 키 생성
|
|
const groupKey = otherMasterCols.map(col => row[col] ?? "").join("|||");
|
|
if (!groupedData.has(groupKey)) {
|
|
groupedData.set(groupKey, []);
|
|
}
|
|
groupedData.get(groupKey)!.push(row);
|
|
}
|
|
|
|
logger.info(`채번 모드 그룹화 완료: ${groupedData.size}개 그룹 (기준: ${otherMasterCols.join(", ")})`);
|
|
} else {
|
|
// 일반 모드: 마스터 키 값으로 그룹화
|
|
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}개 마스터 그룹`);
|
|
}
|
|
|
|
// 디테일 테이블의 채번 컬럼 사전 감지 (1회 쿼리로 모든 채번 컬럼 조회)
|
|
const detailNumberingCols = await this.detectAllNumberingColumns(detailTable, companyCode);
|
|
// 마스터 테이블의 비-키 채번 컬럼도 감지
|
|
const masterNumberingCols = await this.detectAllNumberingColumns(masterTable, companyCode);
|
|
|
|
// 디테일 테이블의 고유 키 컬럼 감지 (UPSERT 매칭용)
|
|
// PK가 비즈니스 키인 경우 사용, auto-increment 'id'만 있으면 유니크 인덱스 탐색
|
|
const detailUniqueKeyCols = await this.detectUniqueKeyColumns(client, detailTable);
|
|
|
|
// 각 그룹 처리
|
|
for (const [groupKey, rows] of groupedData.entries()) {
|
|
try {
|
|
// 마스터 키 결정 (채번이면 자동 생성, 아니면 그룹 키 자체가 마스터 키)
|
|
let masterKey: string;
|
|
let existingMasterKey: string | null = null;
|
|
|
|
// 마스터 데이터 추출 (첫 번째 행에서, 키 제외)
|
|
const masterDataWithoutKey: Record<string, any> = {};
|
|
for (const col of masterColumns) {
|
|
if (col.name === masterKeyColumn) continue;
|
|
if (rows[0][col.name] !== undefined) {
|
|
masterDataWithoutKey[col.name] = rows[0][col.name];
|
|
}
|
|
}
|
|
|
|
if (isAutoNumbering) {
|
|
// 채번 모드: 동일한 마스터가 이미 DB에 있는지 먼저 확인
|
|
// 마스터 키 제외한 다른 컬럼들로 매칭 (예: dept_name이 같은 부서가 있는지)
|
|
const matchCols = Object.keys(masterDataWithoutKey)
|
|
.filter(k => k !== "company_code" && k !== "writer" && k !== "created_date" && k !== "updated_date" && k !== "id"
|
|
&& masterDataWithoutKey[k] !== undefined && masterDataWithoutKey[k] !== null && masterDataWithoutKey[k] !== "");
|
|
|
|
if (matchCols.length > 0) {
|
|
const whereClause = matchCols.map((col, i) => `"${col}" = $${i + 1}`).join(" AND ");
|
|
const companyIdx = matchCols.length + 1;
|
|
const matchResult = await client.query(
|
|
`SELECT "${masterKeyColumn}" FROM "${masterTable}" WHERE ${whereClause} AND company_code = $${companyIdx} LIMIT 1`,
|
|
[...matchCols.map(k => masterDataWithoutKey[k]), companyCode]
|
|
);
|
|
if (matchResult.rows.length > 0) {
|
|
existingMasterKey = matchResult.rows[0][masterKeyColumn];
|
|
logger.info(`채번 모드: 기존 마스터 발견 → ${masterKeyColumn}=${existingMasterKey} (매칭: ${matchCols.map(c => `${c}=${masterDataWithoutKey[c]}`).join(", ")})`);
|
|
}
|
|
}
|
|
|
|
if (existingMasterKey) {
|
|
// 기존 마스터 사용 (UPDATE)
|
|
masterKey = existingMasterKey;
|
|
const updateKeys = matchCols.filter(k => k !== masterKeyColumn);
|
|
if (updateKeys.length > 0) {
|
|
const setClauses = updateKeys.map((k, i) => `"${k}" = $${i + 1}`);
|
|
const setValues = updateKeys.map(k => masterDataWithoutKey[k]);
|
|
const updatedDateClause = masterExistingCols.has("updated_date") ? `, updated_date = NOW()` : "";
|
|
await client.query(
|
|
`UPDATE "${masterTable}" SET ${setClauses.join(", ")}${updatedDateClause} WHERE "${masterKeyColumn}" = $${setValues.length + 1} AND company_code = $${setValues.length + 2}`,
|
|
[...setValues, masterKey, companyCode]
|
|
);
|
|
}
|
|
result.masterUpdated++;
|
|
} else {
|
|
// 새 마스터 생성 (채번)
|
|
masterKey = await this.generateNumberWithRule(client, numberingInfo!.numberingRuleId, companyCode);
|
|
logger.info(`채번 생성: ${masterKey}`);
|
|
}
|
|
} else {
|
|
masterKey = groupKey;
|
|
}
|
|
|
|
// 마스터 데이터 조립
|
|
const masterData: Record<string, any> = {};
|
|
masterData[masterKeyColumn] = masterKey;
|
|
Object.assign(masterData, masterDataWithoutKey);
|
|
|
|
// 회사 코드, 작성자 추가 (테이블에 해당 컬럼이 있을 때만)
|
|
if (masterExistingCols.has("company_code")) {
|
|
masterData.company_code = companyCode;
|
|
}
|
|
if (userId && masterExistingCols.has("writer")) {
|
|
masterData.writer = userId;
|
|
}
|
|
|
|
// 마스터 비-키 채번 컬럼 자동 생성 (매핑되지 않은 경우)
|
|
for (const [colName, ruleId] of masterNumberingCols) {
|
|
if (colName === masterKeyColumn) continue;
|
|
if (!masterData[colName] || masterData[colName] === "") {
|
|
const generatedValue = await this.generateNumberWithRule(client, ruleId, companyCode);
|
|
masterData[colName] = generatedValue;
|
|
logger.info(`마스터 채번 생성: ${masterTable}.${colName} = ${generatedValue}`);
|
|
}
|
|
}
|
|
|
|
// INSERT SQL 생성 헬퍼 (created_date 존재 시만 추가)
|
|
const buildInsertSQL = (table: string, data: Record<string, any>, existingCols: Set<string>) => {
|
|
const cols = Object.keys(data);
|
|
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]);
|
|
return {
|
|
sql: `INSERT INTO "${table}" (${colList.map(c => `"${c}"`).join(", ")}) VALUES (${valList.join(", ")})`,
|
|
values,
|
|
};
|
|
};
|
|
|
|
if (isAutoNumbering && !existingMasterKey) {
|
|
// 채번 모드 + 새 마스터: INSERT
|
|
const { sql, values } = buildInsertSQL(masterTable, masterData, masterExistingCols);
|
|
await client.query(sql, values);
|
|
result.masterInserted++;
|
|
} else if (!isAutoNumbering) {
|
|
// 일반 모드: UPSERT (있으면 UPDATE, 없으면 INSERT)
|
|
const existingMaster = await client.query(
|
|
`SELECT 1 FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`,
|
|
[masterKey, companyCode]
|
|
);
|
|
|
|
if (existingMaster.rows.length > 0) {
|
|
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) {
|
|
const updatedDateClause = masterExistingCols.has("updated_date") ? `, updated_date = NOW()` : "";
|
|
await client.query(
|
|
`UPDATE "${masterTable}"
|
|
SET ${updateCols.join(", ")}${updatedDateClause}
|
|
WHERE "${masterKeyColumn}" = $${updateValues.length + 1} AND company_code = $${updateValues.length + 2}`,
|
|
[...updateValues, masterKey, companyCode]
|
|
);
|
|
}
|
|
result.masterUpdated++;
|
|
} else {
|
|
const { sql, values } = buildInsertSQL(masterTable, masterData, masterExistingCols);
|
|
await client.query(sql, values);
|
|
result.masterInserted++;
|
|
}
|
|
|
|
}
|
|
|
|
// 디테일 개별 행 UPSERT 처리
|
|
for (const row of rows) {
|
|
const detailData: Record<string, any> = {};
|
|
|
|
// FK 컬럼에 마스터 키 주입
|
|
detailData[detailFkColumn] = masterKey;
|
|
if (detailExistingCols.has("company_code")) {
|
|
detailData.company_code = companyCode;
|
|
}
|
|
if (userId && detailExistingCols.has("writer")) {
|
|
detailData.writer = userId;
|
|
}
|
|
|
|
// 디테일 컬럼 데이터 추출 (분할 패널 설정 컬럼 기준)
|
|
for (const col of detailColumns) {
|
|
if (row[col.name] !== undefined) {
|
|
detailData[col.name] = row[col.name];
|
|
}
|
|
}
|
|
|
|
// 분할 패널에 없지만 엑셀에서 매핑된 디테일 컬럼도 포함
|
|
// (user_id 등 화면에 표시되지 않지만 NOT NULL인 컬럼 처리)
|
|
const detailColNames = new Set(detailColumns.map(c => c.name));
|
|
const skipCols = new Set([
|
|
detailFkColumn, masterKeyColumn,
|
|
"company_code", "writer", "created_date", "updated_date", "id",
|
|
]);
|
|
for (const key of Object.keys(row)) {
|
|
if (!detailColNames.has(key) && !skipCols.has(key) && detailExistingCols.has(key) && row[key] !== undefined && row[key] !== null && row[key] !== "") {
|
|
const isMasterCol = masterColumns.some(mc => mc.name === key);
|
|
if (!isMasterCol) {
|
|
detailData[key] = row[key];
|
|
}
|
|
}
|
|
}
|
|
|
|
// 디테일 채번 컬럼 자동 생성 (매핑되지 않은 채번 컬럼에 값 주입)
|
|
for (const [colName, ruleId] of detailNumberingCols) {
|
|
if (!detailData[colName] || detailData[colName] === "") {
|
|
const generatedValue = await this.generateNumberWithRule(client, ruleId, companyCode);
|
|
detailData[colName] = generatedValue;
|
|
logger.info(`디테일 채번 생성: ${detailTable}.${colName} = ${generatedValue}`);
|
|
}
|
|
}
|
|
|
|
// 고유 키 기반 UPSERT: 존재하면 UPDATE, 없으면 INSERT
|
|
const hasUniqueKey = detailUniqueKeyCols.length > 0;
|
|
const uniqueKeyValues = hasUniqueKey
|
|
? detailUniqueKeyCols.map(col => detailData[col])
|
|
: [];
|
|
// 고유 키 값이 모두 있어야 매칭 가능 (채번으로 생성된 값도 포함)
|
|
const canMatch = hasUniqueKey && uniqueKeyValues.every(v => v !== undefined && v !== null && v !== "");
|
|
|
|
if (canMatch) {
|
|
// 기존 행 존재 여부 확인
|
|
const whereClause = detailUniqueKeyCols
|
|
.map((col, i) => `"${col}" = $${i + 1}`)
|
|
.join(" AND ");
|
|
const companyParam = detailExistingCols.has("company_code")
|
|
? ` AND company_code = $${detailUniqueKeyCols.length + 1}`
|
|
: "";
|
|
const checkParams = detailExistingCols.has("company_code")
|
|
? [...uniqueKeyValues, companyCode]
|
|
: uniqueKeyValues;
|
|
|
|
const existingRow = await client.query(
|
|
`SELECT 1 FROM "${detailTable}" WHERE ${whereClause}${companyParam} LIMIT 1`,
|
|
checkParams
|
|
);
|
|
|
|
if (existingRow.rows.length > 0) {
|
|
// UPDATE: 고유 키와 시스템 컬럼 제외한 나머지 업데이트
|
|
const updateExclude = new Set([
|
|
...detailUniqueKeyCols, "id", "company_code", "created_date",
|
|
]);
|
|
const updateKeys = Object.keys(detailData).filter(k => !updateExclude.has(k));
|
|
|
|
if (updateKeys.length > 0) {
|
|
const setClauses = updateKeys.map((k, i) => `"${k}" = $${i + 1}`);
|
|
const setValues = updateKeys.map(k => detailData[k]);
|
|
const updatedDateClause = detailExistingCols.has("updated_date") ? `, updated_date = NOW()` : "";
|
|
|
|
const whereParams = detailUniqueKeyCols.map((col, i) => `"${col}" = $${setValues.length + i + 1}`);
|
|
const companyWhere = detailExistingCols.has("company_code")
|
|
? ` AND company_code = $${setValues.length + detailUniqueKeyCols.length + 1}`
|
|
: "";
|
|
const allValues = [
|
|
...setValues,
|
|
...uniqueKeyValues,
|
|
...(detailExistingCols.has("company_code") ? [companyCode] : []),
|
|
];
|
|
|
|
await client.query(
|
|
`UPDATE "${detailTable}" SET ${setClauses.join(", ")}${updatedDateClause} WHERE ${whereParams.join(" AND ")}${companyWhere}`,
|
|
allValues
|
|
);
|
|
result.detailUpdated = (result.detailUpdated || 0) + 1;
|
|
logger.info(`디테일 UPDATE: ${detailUniqueKeyCols.map((c, i) => `${c}=${uniqueKeyValues[i]}`).join(", ")}`);
|
|
}
|
|
} else {
|
|
// INSERT: 새로운 행
|
|
const { sql, values } = buildInsertSQL(detailTable, detailData, detailExistingCols);
|
|
await client.query(sql, values);
|
|
result.detailInserted++;
|
|
logger.info(`디테일 INSERT: ${detailUniqueKeyCols.map((c, i) => `${c}=${uniqueKeyValues[i]}`).join(", ")}`);
|
|
}
|
|
} else {
|
|
// 고유 키가 없거나 값이 없으면 INSERT 전용
|
|
const { sql, values } = buildInsertSQL(detailTable, detailData, detailExistingCols);
|
|
await client.query(sql, values);
|
|
result.detailInserted++;
|
|
}
|
|
}
|
|
} catch (error: any) {
|
|
result.errors.push(`그룹 처리 실패: ${error.message}`);
|
|
logger.error(`그룹 처리 실패:`, 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,
|
|
detailUpdated: result.detailUpdated,
|
|
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 사용)
|
|
* @param client DB 클라이언트
|
|
* @param ruleId 규칙 ID
|
|
* @param companyCode 회사 코드
|
|
* @param formData 폼 데이터 (날짜 컬럼 기준 생성 시 사용)
|
|
*/
|
|
private async generateNumberWithRule(
|
|
client: any,
|
|
ruleId: string,
|
|
companyCode: string,
|
|
formData?: Record<string, any>
|
|
): Promise<string> {
|
|
try {
|
|
// 기존 numberingRuleService를 사용하여 코드 할당
|
|
const { numberingRuleService } = await import("./numberingRuleService");
|
|
const generatedCode = await numberingRuleService.allocateCode(ruleId, companyCode, formData);
|
|
|
|
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();
|
|
|