- Implemented a new API endpoint to retrieve numbering rules based on table and column names, enhancing the flexibility of numbering rule management. - Added a new service method to handle the retrieval of numbering columns specific to a company, ensuring proper company code filtering. - Updated the frontend to load and display numbering columns, allowing users to select and manage numbering rules effectively. - Refactored existing logic to improve the handling of numbering rules, including fallback mechanisms for legacy data. These changes enhance the functionality and user experience in managing numbering rules within the application.
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();
|
|
|