2026-01-09 11:21:16 +09:00
/ * *
* 마 스 터 - 디 테 일 엑 셀 처 리 서 비 스
*
* 분 할 패 널 화 면 의 마 스 터 - 디 테 일 구 조 를 자 동 감 지 하 고
* 엑 셀 다 운 로 드 / 업 로 드 시 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 ;
2026-01-12 13:53:57 +09:00
foreignKey? : string ;
leftColumn? : string ;
// 복합키 지원 (새로운 방식)
keys? : Array < {
leftColumn : string ;
rightColumn : string ;
} > ;
2026-01-09 11:21:16 +09:00
} ;
} ;
}
/ * *
* 엑 셀 다 운 로 드 결 과
* /
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 ;
2026-02-11 18:29:36 +09:00
detailUpdated : number ;
2026-01-09 11:21:16 +09:00
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 ;
}
}
/ * *
2026-01-28 11:24:25 +09:00
* table_type_columns에서 Entity 관 계 정 보 조 회
2026-01-09 11:21:16 +09:00
* 디 테 일 테 이 블 에 서 마 스 터 테 이 블 을 참 조 하 는 컬 럼 찾 기
* /
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
2026-01-28 11:24:25 +09:00
FROM table_type_columns
2026-01-09 11:21:16 +09:00
WHERE table_name = $1
AND input_type = 'entity'
AND reference_table = $2
2026-01-28 11:24:25 +09:00
AND company_code = '*'
2026-01-09 11:21:16 +09:00
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
2026-01-28 11:24:25 +09:00
FROM table_type_columns
WHERE table_name = $1 AND company_code = '*' ` ,
2026-01-09 11:21:16 +09:00
[ 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 정보가 있으면 우선 사용
2026-01-12 13:53:57 +09:00
// 🔥 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 ;
}
2026-01-09 11:21:16 +09:00
2026-01-28 11:24:25 +09:00
// 3. relation 정보가 없으면 table_type_columns에서 Entity 관계 조회
2026-01-09 11:21:16 +09:00
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 ;
2026-01-09 15:32:02 +09:00
// 조인 컬럼과 일반 컬럼 분리
// 조인 컬럼 형식: "테이블명.컬럼명" (예: customer_mng.customer_name)
const entityJoins : Array < {
refTable : string ;
refColumn : string ;
sourceColumn : string ;
alias : string ;
displayColumn : string ;
2026-02-10 11:38:02 +09:00
tableAlias : string ; // "m" (마스터) 또는 "d" (디테일) - JOIN 시 소스 테이블 구분
2026-01-09 15:32:02 +09:00
} > = [ ] ;
2026-01-09 11:21:16 +09:00
// SELECT 절 구성
2026-01-09 15:32:02 +09:00
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 ++ } ` ;
2026-01-28 11:24:25 +09:00
// table_type_columns에서 FK 컬럼 찾기
2026-01-09 15:32:02 +09:00
const fkColumn = await this . findForeignKeyColumn ( masterTable , refTable ) ;
if ( fkColumn ) {
entityJoins . push ( {
refTable ,
refColumn : fkColumn.referenceColumn ,
sourceColumn : fkColumn.sourceColumn ,
alias ,
displayColumn ,
2026-02-10 11:38:02 +09:00
tableAlias : "m" , // 마스터 테이블에서 조인
2026-01-09 15:32:02 +09:00
} ) ;
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 ++ } ` ;
2026-01-28 11:24:25 +09:00
// table_type_columns에서 FK 컬럼 찾기
2026-01-09 15:32:02 +09:00
const fkColumn = await this . findForeignKeyColumn ( detailTable , refTable ) ;
if ( fkColumn ) {
entityJoins . push ( {
refTable ,
refColumn : fkColumn.referenceColumn ,
sourceColumn : fkColumn.sourceColumn ,
alias ,
displayColumn ,
2026-02-10 11:38:02 +09:00
tableAlias : "d" , // 디테일 테이블에서 조인
2026-01-09 15:32:02 +09:00
} ) ;
selectParts . push ( ` ${ alias } ." ${ displayColumn } " AS " ${ col . name } " ` ) ;
} else {
selectParts . push ( ` NULL AS " ${ col . name } " ` ) ;
}
} else {
// 일반 컬럼
selectParts . push ( ` d." ${ col . name } " ` ) ;
}
}
const selectClause = selectParts . join ( ", " ) ;
2026-02-10 11:38:02 +09:00
// 엔티티 조인 절 구성 (마스터/디테일 테이블 alias 구분)
2026-01-09 15:32:02 +09:00
const entityJoinClauses = entityJoins . map ( ej = >
2026-02-10 11:38:02 +09:00
` LEFT JOIN " ${ ej . refTable } " ${ ej . alias } ON ${ ej . tableAlias } ." ${ ej . sourceColumn } " = ${ ej . alias } ." ${ ej . refColumn } " `
2026-01-09 15:32:02 +09:00
) . join ( "\n " ) ;
2026-01-09 11:21:16 +09:00
// 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 !== "" ) {
2026-01-09 15:32:02 +09:00
// 조인 컬럼인지 확인
if ( key . includes ( "." ) ) continue ;
2026-01-09 11:21:16 +09:00
// 마스터 테이블 컬럼인지 확인
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 " ) } `
: "" ;
2026-02-11 15:43:50 +09:00
// 디테일 테이블의 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 } " ` ;
2026-01-09 11:21:16 +09:00
// JOIN 쿼리 실행
const sql = `
SELECT $ { selectClause }
FROM "${masterTable}" m
LEFT JOIN "${detailTable}" d
ON m . "${masterKeyColumn}" = d . "${detailFkColumn}"
AND m . company_code = d . company_code
2026-01-09 15:32:02 +09:00
$ { entityJoinClauses }
2026-01-09 11:21:16 +09:00
$ { whereClause }
2026-02-11 15:43:50 +09:00
ORDER BY m . "${masterKeyColumn}" , $ { detailOrderColumn }
2026-01-09 11:21:16 +09:00
` ;
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 ;
}
}
2026-01-09 15:32:02 +09:00
/ * *
* 특 정 테 이 블 에 서 참 조 테 이 블 로 의 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
2026-01-28 11:24:25 +09:00
FROM table_type_columns
2026-01-09 15:32:02 +09:00
WHERE table_name = $1
AND reference_table = $2
AND input_type = 'entity'
2026-01-28 11:24:25 +09:00
AND company_code = '*'
2026-01-09 15:32:02 +09:00
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 ;
}
}
2026-02-11 15:43:50 +09:00
/ * *
* 특 정 테 이 블 의 특 정 컬 럼 이 채 번 타 입 인 지 확 인 하 고 , 채 번 규 칙 ID를 반 환
2026-03-09 14:10:08 +09:00
* numbering_rules 테 이 블 에 서 table_name + column_name + company_code로 직 접 조 회
2026-02-11 15:43:50 +09:00
* /
private async detectNumberingRuleForColumn (
tableName : string ,
columnName : string ,
companyCode? : string
) : Promise < { numberingRuleId : string } | null > {
try {
2026-03-09 14:10:08 +09:00
// 1. table_type_columns에서 numbering 타입인지 확인
2026-02-11 15:43:50 +09:00
const companyCondition = companyCode && companyCode !== "*"
? ` AND company_code IN ( $ 3, '*') `
: ` AND company_code = '*' ` ;
2026-03-09 14:10:08 +09:00
const ttcParams = companyCode && companyCode !== "*"
2026-02-11 15:43:50 +09:00
? [ tableName , columnName , companyCode ]
: [ tableName , columnName ] ;
2026-03-09 14:10:08 +09:00
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
2026-02-11 15:43:50 +09:00
WHERE table_name = $1 AND column_name = $2 $ { companyCondition }
2026-03-09 14:10:08 +09:00
AND input_type = 'numbering'
2026-02-11 15:43:50 +09:00
ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END ` ,
2026-03-09 14:10:08 +09:00
ttcParams
2026-02-11 15:43:50 +09:00
) ;
2026-03-09 14:10:08 +09:00
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 } ;
2026-02-11 15:43:50 +09:00
}
}
return null ;
} catch ( error ) {
logger . error ( ` 채번 컬럼 감지 실패: ${ tableName } . ${ columnName } ` , error ) ;
return null ;
}
}
2026-02-11 18:29:36 +09:00
/ * *
* 특 정 테 이 블 의 모 든 채 번 컬 럼 을 한 번 에 조 회
2026-03-09 14:10:08 +09:00
* numbering_rules 테 이 블 에 서 table_name + column_name으로 직 접 조 회
2026-02-11 18:29:36 +09:00
* @returns Map < columnName , numberingRuleId >
* /
private async detectAllNumberingColumns (
tableName : string ,
companyCode? : string
) : Promise < Map < string , string > > {
const numberingCols = new Map < string , string > ( ) ;
try {
2026-03-09 14:10:08 +09:00
// 1. table_type_columns에서 numbering 타입 컬럼 목록 조회
2026-02-11 18:29:36 +09:00
const companyCondition = companyCode && companyCode !== "*"
? ` AND company_code IN ( $ 2, '*') `
: ` AND company_code = '*' ` ;
const params = companyCode && companyCode !== "*"
? [ tableName , companyCode ]
: [ tableName ] ;
2026-03-09 14:10:08 +09:00
const ttcResult = await query < any > (
` SELECT DISTINCT column_name FROM table_type_columns
WHERE table_name = $1 AND input_type = 'numbering' $ { companyCondition } ` ,
2026-02-11 18:29:36 +09:00
params
) ;
2026-03-09 14:10:08 +09:00
// 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 ) ;
2026-02-11 18:29:36 +09:00
}
}
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 [ ] ;
}
}
2026-01-09 11:21:16 +09:00
/ * *
* 마 스 터 - 디 테 일 데 이 터 업 로 드 ( 엑 셀 업 로 드 용 )
*
* 처 리 로 직 :
2026-02-11 15:43:50 +09:00
* 1 . 마 스 터 키 컬 럼 이 채 번 타 입 인 지 확 인
* 2 - A . 채 번 인 경우 : 다른 마 스 터 컬 럼 값 으 로 그 룹 화 → 키 자 동 생 성 → INSERT
* 2 - B . 채 번 아 닌 경우 : 마스터 키 값 으 로 그 룹 화 → UPSERT
2026-02-11 18:29:36 +09:00
* 3 . 디 테 일 데 이 터 개 별 행 UPSERT ( 고 유 키 기 반 )
2026-01-09 11:21:16 +09:00
* /
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 ,
2026-02-11 18:29:36 +09:00
detailUpdated : 0 ,
2026-01-09 11:21:16 +09:00
detailDeleted : 0 ,
errors : [ ] ,
} ;
const pool = getPool ( ) ;
const client = await pool . connect ( ) ;
try {
await client . query ( "BEGIN" ) ;
const { masterTable , detailTable , masterKeyColumn , detailFkColumn , masterColumns , detailColumns } = relation ;
2026-02-11 15:43:50 +09:00
// 마스터/디테일 테이블의 실제 컬럼 존재 여부 확인 (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
} ) ;
// 데이터 그룹화
2026-01-09 11:21:16 +09:00
const groupedData = new Map < string , Record < string , any > [ ] > ( ) ;
2026-02-11 15:43:50 +09:00
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 ) ;
2026-01-09 11:21:16 +09:00
}
2026-02-11 15:43:50 +09:00
logger . info ( ` 일반 모드 그룹화 완료: ${ groupedData . size } 개 마스터 그룹 ` ) ;
2026-01-09 11:21:16 +09:00
}
2026-02-11 18:29:36 +09:00
// 디테일 테이블의 채번 컬럼 사전 감지 (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 ) ;
2026-02-11 15:43:50 +09:00
// 각 그룹 처리
for ( const [ groupKey , rows ] of groupedData . entries ( ) ) {
2026-01-09 11:21:16 +09:00
try {
2026-02-11 15:43:50 +09:00
// 마스터 키 결정 (채번이면 자동 생성, 아니면 그룹 키 자체가 마스터 키)
let masterKey : string ;
2026-02-11 18:29:36 +09:00
let existingMasterKey : string | null = null ;
2026-02-11 15:43:50 +09:00
2026-02-11 18:29:36 +09:00
// 마스터 데이터 추출 (첫 번째 행에서, 키 제외)
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 ] ;
}
}
2026-02-11 15:43:50 +09:00
if ( isAutoNumbering ) {
2026-02-11 18:29:36 +09:00
// 채번 모드: 동일한 마스터가 이미 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 } ` ) ;
}
2026-02-11 15:43:50 +09:00
} else {
masterKey = groupKey ;
}
2026-02-11 18:29:36 +09:00
// 마스터 데이터 조립
2026-01-09 11:21:16 +09:00
const masterData : Record < string , any > = { } ;
2026-02-11 15:43:50 +09:00
masterData [ masterKeyColumn ] = masterKey ;
2026-02-11 18:29:36 +09:00
Object . assign ( masterData , masterDataWithoutKey ) ;
2026-01-09 11:21:16 +09:00
2026-02-11 15:43:50 +09:00
// 회사 코드, 작성자 추가 (테이블에 해당 컬럼이 있을 때만)
if ( masterExistingCols . has ( "company_code" ) ) {
masterData . company_code = companyCode ;
}
if ( userId && masterExistingCols . has ( "writer" ) ) {
2026-01-09 11:21:16 +09:00
masterData . writer = userId ;
}
2026-02-11 18:29:36 +09:00
// 마스터 비-키 채번 컬럼 자동 생성 (매핑되지 않은 경우)
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 } ` ) ;
}
}
2026-02-11 15:43:50 +09:00
// 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 ,
} ;
} ;
2026-02-11 18:29:36 +09:00
if ( isAutoNumbering && ! existingMasterKey ) {
// 채번 모드 + 새 마스터: INSERT
2026-02-11 15:43:50 +09:00
const { sql , values } = buildInsertSQL ( masterTable , masterData , masterExistingCols ) ;
await client . query ( sql , values ) ;
result . masterInserted ++ ;
2026-02-11 18:29:36 +09:00
} else if ( ! isAutoNumbering ) {
2026-02-11 15:43:50 +09:00
// 일반 모드: UPSERT (있으면 UPDATE, 없으면 INSERT)
const existingMaster = await client . query (
` SELECT 1 FROM " ${ masterTable } " WHERE " ${ masterKeyColumn } " = $ 1 AND company_code = $ 2 ` ,
[ masterKey , companyCode ]
2026-01-09 11:21:16 +09:00
) ;
2026-02-11 15:43:50 +09:00
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 ++ ;
}
}
2026-01-09 11:21:16 +09:00
2026-02-11 18:29:36 +09:00
// 디테일 개별 행 UPSERT 처리
2026-01-09 11:21:16 +09:00
for ( const row of rows ) {
const detailData : Record < string , any > = { } ;
2026-02-11 15:43:50 +09:00
// FK 컬럼에 마스터 키 주입
2026-01-09 11:21:16 +09:00
detailData [ detailFkColumn ] = masterKey ;
2026-02-11 15:43:50 +09:00
if ( detailExistingCols . has ( "company_code" ) ) {
detailData . company_code = companyCode ;
}
if ( userId && detailExistingCols . has ( "writer" ) ) {
2026-01-09 11:21:16 +09:00
detailData . writer = userId ;
}
2026-02-11 18:29:36 +09:00
// 디테일 컬럼 데이터 추출 (분할 패널 설정 컬럼 기준)
2026-01-09 11:21:16 +09:00
for ( const col of detailColumns ) {
if ( row [ col . name ] !== undefined ) {
detailData [ col . name ] = row [ col . name ] ;
}
}
2026-02-11 18:29:36 +09:00
// 분할 패널에 없지만 엑셀에서 매핑된 디테일 컬럼도 포함
// (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 ++ ;
}
2026-01-09 11:21:16 +09:00
}
} catch ( error : any ) {
2026-02-11 15:43:50 +09:00
result . errors . push ( ` 그룹 처리 실패: ${ error . message } ` ) ;
logger . error ( ` 그룹 처리 실패: ` , error ) ;
2026-01-09 11:21:16 +09:00
}
}
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 ,
2026-02-11 18:29:36 +09:00
detailUpdated : result.detailUpdated ,
2026-01-09 11:21:16 +09:00
errors : result.errors.length ,
} ) ;
} catch ( error : any ) {
await client . query ( "ROLLBACK" ) ;
result . errors . push ( ` 트랜잭션 실패: ${ error . message } ` ) ;
logger . error ( ` 마스터-디테일 업로드 트랜잭션 실패: ` , error ) ;
} finally {
client . release ( ) ;
}
return result ;
}
2026-01-09 15:32:02 +09:00
/ * *
* 마 스 터 - 디 테 일 간 단 모 드 업 로 드
*
* 마 스 터 정 보 는 UI에서 선 택 하 고 , 엑 셀 은 디 테 일 데 이 터 만 포 함
* 채 번 규 칙 을 통 해 마 스 터 키 자 동 생 성
*
* @param screenId 화 면 ID
* @param detailData 디 테 일 데 이 터 배 열
* @param masterFieldValues UI에서 선 택 한 마 스 터 필 드 값
* @param numberingRuleId 채 번 규 칙 ID ( optional )
* @param companyCode 회 사 코 드
* @param userId 사 용 자 ID
2026-01-09 15:46:09 +09:00
* @param afterUploadFlowId 업 로 드 후 실 행 할 노 드 플 로 우 ID ( optional , 하 위 호 환 성 )
* @param afterUploadFlows 업 로 드 후 실 행 할 노 드 플 로 우 배 열 ( optional )
2026-01-09 15:32:02 +09:00
* /
async uploadSimple (
screenId : number ,
detailData : Record < string , any > [ ] ,
masterFieldValues : Record < string , any > ,
numberingRuleId : string | undefined ,
companyCode : string ,
2026-01-09 15:46:09 +09:00
userId : string ,
afterUploadFlowId? : string ,
afterUploadFlows? : Array < { flowId : string ; order : number } >
2026-01-09 15:32:02 +09:00
) : Promise < {
success : boolean ;
masterInserted : number ;
detailInserted : number ;
generatedKey : string ;
errors : string [ ] ;
2026-01-09 15:46:09 +09:00
controlResult? : any ;
2026-01-09 15:32:02 +09:00
} > {
2026-01-09 15:46:09 +09:00
const result : {
success : boolean ;
masterInserted : number ;
detailInserted : number ;
generatedKey : string ;
errors : string [ ] ;
controlResult? : any ;
} = {
2026-01-09 15:32:02 +09:00
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 } ` ) ;
2026-01-13 09:30:19 +09:00
// 4. 디테일 레코드들 생성 (삽입된 데이터 수집)
const insertedDetailRows : Record < string , any > [ ] = [ ] ;
2026-01-09 15:32:02 +09:00
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 ] ) ;
2026-01-13 09:30:19 +09:00
// RETURNING *로 삽입된 데이터 반환받기
const insertResult = await client . query (
2026-01-09 15:32:02 +09:00
` INSERT INTO " ${ detailTable } " ( ${ detailCols . map ( c = > ` " ${ c } " ` ) . join ( ", " ) } , created_date)
2026-01-13 09:30:19 +09:00
VALUES ( $ { detailPlaceholders . join ( ", " ) } , NOW ( ) )
RETURNING * ` ,
2026-01-09 15:32:02 +09:00
detailValues
) ;
2026-01-13 09:30:19 +09:00
if ( insertResult . rows && insertResult . rows [ 0 ] ) {
insertedDetailRows . push ( insertResult . rows [ 0 ] ) ;
}
2026-01-09 15:32:02 +09:00
result . detailInserted ++ ;
} catch ( error : any ) {
result . errors . push ( ` 디테일 행 처리 실패: ${ error . message } ` ) ;
logger . error ( ` 디테일 행 처리 실패: ` , error ) ;
}
}
2026-01-13 09:30:19 +09:00
logger . info ( ` 디테일 레코드 ${ insertedDetailRows . length } 건 삽입 완료 ` ) ;
2026-01-09 15:32:02 +09:00
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 ,
} ) ;
2026-01-09 15:46:09 +09:00
// 업로드 후 제어 실행 (단일 또는 다중)
const flowsToExecute = afterUploadFlows && afterUploadFlows . length > 0
? afterUploadFlows // 다중 제어
: afterUploadFlowId
? [ { flowId : afterUploadFlowId , order : 1 } ] // 단일 (하위 호환성)
: [ ] ;
if ( flowsToExecute . length > 0 && result . success ) {
try {
const { NodeFlowExecutionService } = await import ( "./nodeFlowExecutionService" ) ;
2026-01-13 09:30:19 +09:00
// 마스터 데이터 구성
2026-01-09 15:46:09 +09:00
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 } ` ) ;
2026-01-13 09:30:19 +09:00
logger . info ( ` 전달 데이터: 마스터 1건, 디테일 ${ insertedDetailRows . length } 건 ` ) ;
2026-01-09 15:46:09 +09:00
2026-01-13 09:30:19 +09:00
// 🆕 삽입된 디테일 데이터를 sourceData로 전달 (성능 최적화)
// - 전체 테이블 조회 대신 방금 INSERT한 데이터만 처리
// - tableSource 노드가 context-data 모드일 때 이 데이터를 사용
2026-01-09 15:46:09 +09:00
const controlResult = await NodeFlowExecutionService . executeFlow (
parseInt ( flow . flowId ) ,
{
2026-01-13 09:30:19 +09:00
sourceData : insertedDetailRows.length > 0 ? insertedDetailRows : [ masterData ] ,
dataSourceType : "excelUpload" , // 엑셀 업로드 데이터임을 명시
2026-01-09 15:46:09 +09:00
buttonId : "excel-upload-button" ,
screenId : screenId ,
userId : userId ,
companyCode : companyCode ,
formData : masterData ,
2026-01-13 09:30:19 +09:00
// 추가 컨텍스트: 마스터/디테일 정보
masterData : masterData ,
detailData : insertedDetailRows ,
masterTable : relation ! . masterTable ,
detailTable : relation ! . detailTable ,
masterKeyColumn : relation ! . masterKeyColumn ,
detailFkColumn : relation ! . detailFkColumn ,
2026-01-09 15:46:09 +09:00
}
) ;
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 } ` ,
} ;
}
}
2026-01-09 15:32:02 +09:00
} catch ( error : any ) {
await client . query ( "ROLLBACK" ) ;
result . errors . push ( ` 트랜잭션 실패: ${ error . message } ` ) ;
logger . error ( ` 마스터-디테일 간단 모드 업로드 실패: ` , error ) ;
} finally {
client . release ( ) ;
}
return result ;
}
/ * *
* 채 번 규 칙 으 로 번 호 생 성 ( 기 존 numberingRuleService 사 용 )
2026-01-19 18:21:30 +09:00
* @param client DB 클 라 이 언 트
* @param ruleId 규 칙 ID
* @param companyCode 회 사 코 드
* @param formData 폼 데 이 터 ( 날 짜 컬 럼 기 준 생 성 시 사 용 )
2026-01-09 15:32:02 +09:00
* /
private async generateNumberWithRule (
client : any ,
ruleId : string ,
2026-01-19 18:21:30 +09:00
companyCode : string ,
formData? : Record < string , any >
2026-01-09 15:32:02 +09:00
) : Promise < string > {
try {
// 기존 numberingRuleService를 사용하여 코드 할당
const { numberingRuleService } = await import ( "./numberingRuleService" ) ;
2026-01-19 18:21:30 +09:00
const generatedCode = await numberingRuleService . allocateCode ( ruleId , companyCode , formData ) ;
2026-01-09 15:32:02 +09:00
logger . info ( ` 채번 생성 (numberingRuleService): rule= ${ ruleId } , result= ${ generatedCode } ` ) ;
return generatedCode ;
} catch ( error : any ) {
logger . error ( ` 채번 생성 실패: rule= ${ ruleId } , error= ${ error . message } ` ) ;
throw error ;
}
}
2026-01-09 11:21:16 +09:00
}
export const masterDetailExcelService = new MasterDetailExcelService ( ) ;