2026-03-03 15:30:07 +09:00
import { Router , Request , Response } from "express" ;
import { getPool } from "../database/db" ;
import logger from "../utils/logger" ;
import { authenticateToken } from "../middleware/authMiddleware" ;
2026-03-04 19:12:22 +09:00
import { numberingRuleService } from "../services/numberingRuleService" ;
2026-03-03 15:30:07 +09:00
const router = Router ( ) ;
// SQL 인젝션 방지: 테이블명/컬럼명 패턴 검증
const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/ ;
function isSafeIdentifier ( name : string ) : boolean {
return SAFE_IDENTIFIER . test ( name ) ;
}
2026-03-04 19:12:22 +09:00
interface AutoGenMappingInfo {
numberingRuleId : string ;
targetColumn : string ;
showResultModal? : boolean ;
}
2026-03-05 12:13:07 +09:00
interface HiddenMappingInfo {
valueSource : "json_extract" | "db_column" | "static" ;
targetColumn : string ;
staticValue? : string ;
sourceJsonColumn? : string ;
sourceJsonKey? : string ;
sourceDbColumn? : string ;
}
2026-03-03 15:30:07 +09:00
interface MappingInfo {
targetTable : string ;
columnMapping : Record < string , string > ;
2026-03-04 19:12:22 +09:00
autoGenMappings? : AutoGenMappingInfo [ ] ;
2026-03-05 12:13:07 +09:00
hiddenMappings? : HiddenMappingInfo [ ] ;
2026-03-03 15:30:07 +09:00
}
interface StatusConditionRule {
whenColumn : string ;
operator : string ;
whenValue : string ;
thenValue : string ;
}
interface ConditionalValueRule {
conditions : StatusConditionRule [ ] ;
defaultValue? : string ;
}
interface StatusChangeRuleBody {
targetTable : string ;
targetColumn : string ;
lookupMode ? : "auto" | "manual" ;
manualItemField? : string ;
manualPkColumn? : string ;
valueType : "fixed" | "conditional" ;
fixedValue? : string ;
conditionalValue? : ConditionalValueRule ;
// 하위호환: 기존 형식
value? : string ;
condition? : string ;
}
interface ExecuteActionBody {
action : string ;
data : {
items? : Record < string , unknown > [ ] ;
fieldValues? : Record < string , unknown > ;
} ;
mappings ? : {
cardList? : MappingInfo | null ;
field? : MappingInfo | null ;
} ;
statusChanges? : StatusChangeRuleBody [ ] ;
}
function resolveStatusValue (
valueType : string ,
fixedValue : string ,
conditionalValue : ConditionalValueRule | undefined ,
item : Record < string , unknown >
) : string {
if ( valueType !== "conditional" || ! conditionalValue ) return fixedValue ;
for ( const cond of conditionalValue . conditions ) {
const actual = String ( item [ cond . whenColumn ] ? ? "" ) ;
const expected = cond . whenValue ;
let match = false ;
switch ( cond . operator ) {
case "=" : match = actual === expected ; break ;
case "!=" : match = actual !== expected ; break ;
case ">" : match = parseFloat ( actual ) > parseFloat ( expected ) ; break ;
case "<" : match = parseFloat ( actual ) < parseFloat ( expected ) ; break ;
case ">=" : match = parseFloat ( actual ) >= parseFloat ( expected ) ; break ;
case "<=" : match = parseFloat ( actual ) <= parseFloat ( expected ) ; break ;
default : match = actual === expected ;
}
if ( match ) return cond . thenValue ;
}
return conditionalValue . defaultValue ? ? fixedValue ;
}
router . post ( "/execute-action" , authenticateToken , async ( req : Request , res : Response ) = > {
const pool = getPool ( ) ;
const client = await pool . connect ( ) ;
try {
const companyCode = ( req as any ) . user ? . companyCode ;
const userId = ( req as any ) . user ? . userId ;
if ( ! companyCode ) {
return res . status ( 401 ) . json ( { success : false , message : "인증 정보가 없습니다." } ) ;
}
const { action , data , mappings , statusChanges } = req . body as ExecuteActionBody ;
const items = data ? . items ? ? [ ] ;
const fieldValues = data ? . fieldValues ? ? { } ;
logger . info ( "[pop/execute-action] 요청" , {
action ,
companyCode ,
userId ,
itemCount : items.length ,
hasFieldValues : Object.keys ( fieldValues ) . length > 0 ,
hasMappings : ! ! mappings ,
statusChangeCount : statusChanges?.length ? ? 0 ,
} ) ;
await client . query ( "BEGIN" ) ;
let processedCount = 0 ;
let insertedCount = 0 ;
2026-03-05 12:13:07 +09:00
const generatedCodes : Array < { targetColumn : string ; code : string ; showResultModal? : boolean } > = [ ] ;
2026-03-03 15:30:07 +09:00
if ( action === "inbound-confirm" ) {
// 1. 매핑 기반 INSERT (장바구니 데이터 -> 대상 테이블)
const cardMapping = mappings ? . cardList ;
const fieldMapping = mappings ? . field ;
if ( cardMapping ? . targetTable && Object . keys ( cardMapping . columnMapping ) . length > 0 ) {
if ( ! isSafeIdentifier ( cardMapping . targetTable ) ) {
throw new Error ( ` 유효하지 않은 테이블명: ${ cardMapping . targetTable } ` ) ;
}
for ( const item of items ) {
const columns : string [ ] = [ "company_code" ] ;
const values : unknown [ ] = [ companyCode ] ;
for ( const [ sourceField , targetColumn ] of Object . entries ( cardMapping . columnMapping ) ) {
if ( ! isSafeIdentifier ( targetColumn ) ) continue ;
columns . push ( ` " ${ targetColumn } " ` ) ;
values . push ( item [ sourceField ] ? ? null ) ;
}
if ( fieldMapping ? . targetTable === cardMapping . targetTable ) {
for ( const [ sourceField , targetColumn ] of Object . entries ( fieldMapping . columnMapping ) ) {
if ( ! isSafeIdentifier ( targetColumn ) ) continue ;
if ( columns . includes ( ` " ${ targetColumn } " ` ) ) continue ;
columns . push ( ` " ${ targetColumn } " ` ) ;
values . push ( fieldValues [ sourceField ] ? ? null ) ;
}
}
2026-03-05 12:13:07 +09:00
// 숨은 필드 매핑 처리 (고정값 / JSON추출 / DB컬럼)
const allHidden = [
. . . ( fieldMapping ? . hiddenMappings ? ? [ ] ) ,
. . . ( cardMapping ? . hiddenMappings ? ? [ ] ) ,
] ;
for ( const hm of allHidden ) {
if ( ! hm . targetColumn || ! isSafeIdentifier ( hm . targetColumn ) ) continue ;
if ( columns . includes ( ` " ${ hm . targetColumn } " ` ) ) continue ;
let value : unknown = null ;
if ( hm . valueSource === "static" ) {
value = hm . staticValue ? ? null ;
} else if ( hm . valueSource === "json_extract" && hm . sourceJsonColumn && hm . sourceJsonKey ) {
const jsonCol = item [ hm . sourceJsonColumn ] ;
if ( typeof jsonCol === "object" && jsonCol !== null ) {
value = ( jsonCol as Record < string , unknown > ) [ hm . sourceJsonKey ] ? ? null ;
} else if ( typeof jsonCol === "string" ) {
try { value = JSON . parse ( jsonCol ) [ hm . sourceJsonKey ] ? ? null ; } catch { /* skip */ }
}
} else if ( hm . valueSource === "db_column" && hm . sourceDbColumn ) {
value = item [ hm . sourceDbColumn ] ? ? fieldValues [ hm . sourceDbColumn ] ? ? null ;
}
columns . push ( ` " ${ hm . targetColumn } " ` ) ;
values . push ( value ) ;
}
2026-03-04 19:12:22 +09:00
// 채번 규칙 실행: field + cardList의 autoGenMappings에서 코드 발급
const allAutoGen = [
. . . ( fieldMapping ? . autoGenMappings ? ? [ ] ) ,
. . . ( cardMapping ? . autoGenMappings ? ? [ ] ) ,
] ;
for ( const ag of allAutoGen ) {
if ( ! ag . numberingRuleId || ! ag . targetColumn ) continue ;
if ( ! isSafeIdentifier ( ag . targetColumn ) ) continue ;
if ( columns . includes ( ` " ${ ag . targetColumn } " ` ) ) continue ;
try {
const generatedCode = await numberingRuleService . allocateCode (
ag . numberingRuleId ,
companyCode ,
{ . . . fieldValues , . . . item } ,
) ;
columns . push ( ` " ${ ag . targetColumn } " ` ) ;
values . push ( generatedCode ) ;
generatedCodes . push ( { targetColumn : ag.targetColumn , code : generatedCode , showResultModal : ag.showResultModal ? ? false } ) ;
logger . info ( "[pop/execute-action] 채번 완료" , {
ruleId : ag.numberingRuleId ,
targetColumn : ag.targetColumn ,
generatedCode ,
} ) ;
} catch ( err : any ) {
logger . error ( "[pop/execute-action] 채번 실패" , {
ruleId : ag.numberingRuleId ,
error : err.message ,
} ) ;
}
}
2026-03-03 15:30:07 +09:00
if ( columns . length > 1 ) {
const placeholders = values . map ( ( _ , i ) = > ` $ ${ i + 1 } ` ) . join ( ", " ) ;
const sql = ` INSERT INTO " ${ cardMapping . targetTable } " ( ${ columns . join ( ", " ) } ) VALUES ( ${ placeholders } ) ` ;
logger . info ( "[pop/execute-action] INSERT 실행" , {
table : cardMapping.targetTable ,
columnCount : columns.length ,
} ) ;
await client . query ( sql , values ) ;
insertedCount ++ ;
}
}
}
if (
fieldMapping ? . targetTable &&
Object . keys ( fieldMapping . columnMapping ) . length > 0 &&
fieldMapping . targetTable !== cardMapping ? . targetTable
) {
if ( ! isSafeIdentifier ( fieldMapping . targetTable ) ) {
throw new Error ( ` 유효하지 않은 테이블명: ${ fieldMapping . targetTable } ` ) ;
}
const columns : string [ ] = [ "company_code" ] ;
const values : unknown [ ] = [ companyCode ] ;
for ( const [ sourceField , targetColumn ] of Object . entries ( fieldMapping . columnMapping ) ) {
if ( ! isSafeIdentifier ( targetColumn ) ) continue ;
columns . push ( ` " ${ targetColumn } " ` ) ;
values . push ( fieldValues [ sourceField ] ? ? null ) ;
}
if ( columns . length > 1 ) {
const placeholders = values . map ( ( _ , i ) = > ` $ ${ i + 1 } ` ) . join ( ", " ) ;
const sql = ` INSERT INTO " ${ fieldMapping . targetTable } " ( ${ columns . join ( ", " ) } ) VALUES ( ${ placeholders } ) ` ;
await client . query ( sql , values ) ;
}
}
// 2. 상태 변경 규칙 실행 (설정 기반)
if ( statusChanges && statusChanges . length > 0 ) {
for ( const rule of statusChanges ) {
if ( ! rule . targetTable || ! rule . targetColumn ) continue ;
if ( ! isSafeIdentifier ( rule . targetTable ) || ! isSafeIdentifier ( rule . targetColumn ) ) {
logger . warn ( "[pop/execute-action] 유효하지 않은 식별자, 건너뜀" , { table : rule.targetTable , column : rule.targetColumn } ) ;
continue ;
}
const valueType = rule . valueType ? ? "fixed" ;
const fixedValue = rule . fixedValue ? ? rule . value ? ? "" ;
const lookupMode = rule . lookupMode ? ? "auto" ;
// 조회 키 결정: 아이템 필드(itemField) -> 대상 테이블 PK 컬럼(pkColumn)
let itemField : string ;
let pkColumn : string ;
if ( lookupMode === "manual" && rule . manualItemField && rule . manualPkColumn ) {
if ( ! isSafeIdentifier ( rule . manualPkColumn ) ) {
logger . warn ( "[pop/execute-action] 수동 PK 컬럼 유효하지 않음" , { manualPkColumn : rule.manualPkColumn } ) ;
continue ;
}
itemField = rule . manualItemField ;
pkColumn = rule . manualPkColumn ;
logger . info ( "[pop/execute-action] 수동 조회 키" , { itemField , pkColumn , table : rule.targetTable } ) ;
} else if ( rule . targetTable === "cart_items" ) {
itemField = "__cart_id" ;
pkColumn = "id" ;
} else {
itemField = "__cart_row_key" ;
const pkResult = await client . query (
` SELECT a.attname FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) WHERE i.indrelid = $ 1::regclass AND i.indisprimary ` ,
[ rule . targetTable ]
) ;
pkColumn = pkResult . rows [ 0 ] ? . attname || "id" ;
}
const lookupValues = items . map ( ( item ) = > item [ itemField ] ? ? item [ itemField . replace ( /^__cart_/ , "" ) ] ) . filter ( Boolean ) ;
if ( lookupValues . length === 0 ) {
logger . warn ( "[pop/execute-action] 조회 키 값 없음, 건너뜀" , { table : rule.targetTable , itemField } ) ;
continue ;
}
if ( valueType === "fixed" ) {
const placeholders = lookupValues . map ( ( _ , i ) = > ` $ ${ i + 3 } ` ) . join ( ", " ) ;
const sql = ` UPDATE " ${ rule . targetTable } " SET " ${ rule . targetColumn } " = $ 1 WHERE company_code = $ 2 AND " ${ pkColumn } " IN ( ${ placeholders } ) ` ;
await client . query ( sql , [ fixedValue , companyCode , . . . lookupValues ] ) ;
processedCount += lookupValues . length ;
} else {
for ( let i = 0 ; i < lookupValues . length ; i ++ ) {
const item = items [ i ] ? ? { } ;
const resolvedValue = resolveStatusValue ( valueType , fixedValue , rule . conditionalValue , item ) ;
await client . query (
` UPDATE " ${ rule . targetTable } " SET " ${ rule . targetColumn } " = $ 1 WHERE company_code = $ 2 AND " ${ pkColumn } " = $ 3 ` ,
[ resolvedValue , companyCode , lookupValues [ i ] ]
) ;
processedCount ++ ;
}
}
logger . info ( "[pop/execute-action] 상태 변경 실행" , {
table : rule.targetTable , column : rule.targetColumn , lookupMode , itemField , pkColumn , count : lookupValues.length ,
} ) ;
}
}
}
await client . query ( "COMMIT" ) ;
logger . info ( "[pop/execute-action] 완료" , {
action ,
companyCode ,
processedCount ,
insertedCount ,
} ) ;
return res . json ( {
success : true ,
message : ` ${ processedCount } 건 처리 완료 ${ insertedCount > 0 ? ` , ${ insertedCount } 건 생성 ` : "" } ` ,
2026-03-04 19:12:22 +09:00
data : { processedCount , insertedCount , generatedCodes } ,
2026-03-03 15:30:07 +09:00
} ) ;
} catch ( error : any ) {
await client . query ( "ROLLBACK" ) ;
logger . error ( "[pop/execute-action] 오류:" , error ) ;
return res . status ( 500 ) . json ( {
success : false ,
message : error.message || "처리 중 오류가 발생했습니다." ,
} ) ;
} finally {
client . release ( ) ;
}
} ) ;
export default router ;