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 {
2026-03-05 17:22:30 +09:00
action? : string ;
tasks? : TaskBody [ ] ;
2026-03-03 15:30:07 +09:00
data : {
items? : Record < string , unknown > [ ] ;
fieldValues? : Record < string , unknown > ;
} ;
mappings ? : {
cardList? : MappingInfo | null ;
field? : MappingInfo | null ;
} ;
statusChanges? : StatusChangeRuleBody [ ] ;
2026-03-05 17:22:30 +09:00
cartChanges ? : {
toCreate? : Record < string , unknown > [ ] ;
toUpdate? : Record < string , unknown > [ ] ;
toDelete ? : ( string | number ) [ ] ;
} ;
}
interface TaskBody {
id : string ;
type : string ;
targetTable? : string ;
targetColumn? : string ;
operationType ? : "assign" | "add" | "subtract" | "multiply" | "divide" | "conditional" | "db-conditional" ;
valueSource ? : "fixed" | "linked" | "reference" ;
fixedValue? : string ;
sourceField? : string ;
referenceTable? : string ;
referenceColumn? : string ;
referenceJoinKey? : string ;
conditionalValue? : ConditionalValueRule ;
// db-conditional 전용 (DB 컬럼 간 비교 후 값 판정)
compareColumn? : string ;
compareOperator ? : "=" | "!=" | ">" | "<" | ">=" | "<=" ;
compareWith? : string ;
dbThenValue? : string ;
dbElseValue? : string ;
lookupMode ? : "auto" | "manual" ;
manualItemField? : string ;
manualPkColumn? : string ;
cartScreenId? : string ;
2026-03-03 15:30:07 +09:00
}
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 : "인증 정보가 없습니다." } ) ;
}
2026-03-05 17:22:30 +09:00
const { action , tasks , data , mappings , statusChanges , cartChanges } = req . body as ExecuteActionBody ;
2026-03-03 15:30:07 +09:00
const items = data ? . items ? ? [ ] ;
const fieldValues = data ? . fieldValues ? ? { } ;
logger . info ( "[pop/execute-action] 요청" , {
2026-03-05 17:22:30 +09:00
action : action ? ? "task-list" ,
2026-03-03 15:30:07 +09:00
companyCode ,
userId ,
itemCount : items.length ,
hasFieldValues : Object.keys ( fieldValues ) . length > 0 ,
hasMappings : ! ! mappings ,
statusChangeCount : statusChanges?.length ? ? 0 ,
2026-03-05 17:22:30 +09:00
taskCount : tasks?.length ? ? 0 ,
hasCartChanges : ! ! cartChanges ,
2026-03-03 15:30:07 +09:00
} ) ;
await client . query ( "BEGIN" ) ;
let processedCount = 0 ;
let insertedCount = 0 ;
2026-03-05 17:22:30 +09:00
let deletedCount = 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
2026-03-05 17:22:30 +09:00
// ======== v2: tasks 배열 기반 처리 ========
if ( tasks && tasks . length > 0 ) {
for ( const task of tasks ) {
switch ( task . type ) {
case "data-save" : {
// 매핑 기반 INSERT (기존 inbound-confirm 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 ) ;
}
}
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 ) ;
}
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 } ) ;
} catch ( err : any ) {
logger . error ( "[pop/execute-action] 채번 실패" , { ruleId : ag.numberingRuleId , error : err.message } ) ;
}
}
if ( columns . length > 1 ) {
const placeholders = values . map ( ( _ , i ) = > ` $ ${ i + 1 } ` ) . join ( ", " ) ;
await client . query (
` INSERT INTO " ${ cardMapping . targetTable } " ( ${ columns . join ( ", " ) } ) VALUES ( ${ placeholders } ) ` ,
values ,
) ;
insertedCount ++ ;
}
}
}
break ;
}
case "data-update" : {
if ( ! task . targetTable || ! task . targetColumn ) break ;
if ( ! isSafeIdentifier ( task . targetTable ) || ! isSafeIdentifier ( task . targetColumn ) ) break ;
const opType = task . operationType ? ? "assign" ;
const valSource = task . valueSource ? ? "fixed" ;
const lookupMode = task . lookupMode ? ? "auto" ;
let itemField : string ;
let pkColumn : string ;
if ( lookupMode === "manual" && task . manualItemField && task . manualPkColumn ) {
if ( ! isSafeIdentifier ( task . manualPkColumn ) ) break ;
itemField = task . manualItemField ;
pkColumn = task . manualPkColumn ;
} else if ( task . 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 ` ,
[ task . targetTable ] ,
) ;
pkColumn = pkResult . rows [ 0 ] ? . attname || "id" ;
}
const lookupValues = items . map ( ( item ) = > item [ itemField ] ? ? item [ itemField . replace ( /^__cart_/ , "" ) ] ) . filter ( Boolean ) ;
if ( lookupValues . length === 0 ) break ;
if ( opType === "conditional" && task . conditionalValue ) {
for ( let i = 0 ; i < lookupValues . length ; i ++ ) {
const item = items [ i ] ? ? { } ;
const resolved = resolveStatusValue ( "conditional" , task . fixedValue ? ? "" , task . conditionalValue , item ) ;
await client . query (
` UPDATE " ${ task . targetTable } " SET " ${ task . targetColumn } " = $ 1 WHERE company_code = $ 2 AND " ${ pkColumn } " = $ 3 ` ,
[ resolved , companyCode , lookupValues [ i ] ] ,
) ;
processedCount ++ ;
}
} else if ( opType === "db-conditional" ) {
// DB 컬럼 간 비교 후 값 판정 (CASE WHEN col_a >= col_b THEN '완료' ELSE '진행중')
if ( ! task . compareColumn || ! task . compareOperator || ! task . compareWith ) break ;
if ( ! isSafeIdentifier ( task . compareColumn ) || ! isSafeIdentifier ( task . compareWith ) ) break ;
const thenVal = task . dbThenValue ? ? "" ;
const elseVal = task . dbElseValue ? ? "" ;
const op = task . compareOperator ;
const validOps = [ "=" , "!=" , ">" , "<" , ">=" , "<=" ] ;
if ( ! validOps . includes ( op ) ) break ;
const caseSql = ` CASE WHEN COALESCE(" ${ task . compareColumn } "::numeric, 0) ${ op } COALESCE(" ${ task . compareWith } "::numeric, 0) THEN $ 1 ELSE $ 2 END ` ;
const placeholders = lookupValues . map ( ( _ , i ) = > ` $ ${ i + 4 } ` ) . join ( ", " ) ;
await client . query (
` UPDATE " ${ task . targetTable } " SET " ${ task . targetColumn } " = ${ caseSql } WHERE company_code = $ 3 AND " ${ pkColumn } " IN ( ${ placeholders } ) ` ,
[ thenVal , elseVal , companyCode , . . . lookupValues ] ,
) ;
processedCount += lookupValues . length ;
} else {
for ( let i = 0 ; i < lookupValues . length ; i ++ ) {
const item = items [ i ] ? ? { } ;
let value : unknown ;
if ( valSource === "linked" ) {
value = item [ task . sourceField ? ? "" ] ? ? null ;
} else {
value = task . fixedValue ? ? "" ;
}
let setSql : string ;
if ( opType === "add" ) {
setSql = ` " ${ task . targetColumn } " = COALESCE(" ${ task . targetColumn } "::numeric, 0) + $ 1::numeric ` ;
} else if ( opType === "subtract" ) {
setSql = ` " ${ task . targetColumn } " = COALESCE(" ${ task . targetColumn } "::numeric, 0) - $ 1::numeric ` ;
} else if ( opType === "multiply" ) {
setSql = ` " ${ task . targetColumn } " = COALESCE(" ${ task . targetColumn } "::numeric, 0) * $ 1::numeric ` ;
} else if ( opType === "divide" ) {
setSql = ` " ${ task . targetColumn } " = CASE WHEN $ 1::numeric = 0 THEN COALESCE(" ${ task . targetColumn } "::numeric, 0) ELSE COALESCE(" ${ task . targetColumn } "::numeric, 0) / $ 1::numeric END ` ;
} else {
setSql = ` " ${ task . targetColumn } " = $ 1 ` ;
}
await client . query (
` UPDATE " ${ task . targetTable } " SET ${ setSql } WHERE company_code = $ 2 AND " ${ pkColumn } " = $ 3 ` ,
[ value , companyCode , lookupValues [ i ] ] ,
) ;
processedCount ++ ;
}
}
logger . info ( "[pop/execute-action] data-update 실행" , {
table : task.targetTable ,
column : task.targetColumn ,
opType ,
count : lookupValues.length ,
} ) ;
break ;
}
case "data-delete" : {
if ( ! task . targetTable ) break ;
if ( ! isSafeIdentifier ( task . targetTable ) ) break ;
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 ` ,
[ task . targetTable ] ,
) ;
const pkCol = pkResult . rows [ 0 ] ? . attname || "id" ;
const deleteKeys = items . map ( ( item ) = > item [ pkCol ] ? ? item [ "id" ] ) . filter ( Boolean ) ;
if ( deleteKeys . length > 0 ) {
const placeholders = deleteKeys . map ( ( _ , i ) = > ` $ ${ i + 2 } ` ) . join ( ", " ) ;
await client . query (
` DELETE FROM " ${ task . targetTable } " WHERE company_code = $ 1 AND " ${ pkCol } " IN ( ${ placeholders } ) ` ,
[ companyCode , . . . deleteKeys ] ,
) ;
deletedCount += deleteKeys . length ;
}
break ;
}
case "cart-save" : {
// cartChanges 처리 (M-9에서 확장)
if ( ! cartChanges ) break ;
const { toCreate , toUpdate , toDelete } = cartChanges ;
if ( toCreate && toCreate . length > 0 ) {
for ( const item of toCreate ) {
const cols = Object . keys ( item ) . filter ( isSafeIdentifier ) ;
if ( cols . length === 0 ) continue ;
const allCols = [ "company_code" , . . . cols . map ( ( c ) = > ` " ${ c } " ` ) ] ;
const allVals = [ companyCode , . . . cols . map ( ( c ) = > item [ c ] ) ] ;
const placeholders = allVals . map ( ( _ , i ) = > ` $ ${ i + 1 } ` ) . join ( ", " ) ;
await client . query (
` INSERT INTO "cart_items" ( ${ allCols . join ( ", " ) } ) VALUES ( ${ placeholders } ) ` ,
allVals ,
) ;
insertedCount ++ ;
}
}
if ( toUpdate && toUpdate . length > 0 ) {
for ( const item of toUpdate ) {
const id = item . id ;
if ( ! id ) continue ;
const cols = Object . keys ( item ) . filter ( ( c ) = > c !== "id" && isSafeIdentifier ( c ) ) ;
if ( cols . length === 0 ) continue ;
const setClauses = cols . map ( ( c , i ) = > ` " ${ c } " = $ ${ i + 3 } ` ) . join ( ", " ) ;
await client . query (
` UPDATE "cart_items" SET ${ setClauses } WHERE id = $ 1 AND company_code = $ 2 ` ,
[ id , companyCode , . . . cols . map ( ( c ) = > item [ c ] ) ] ,
) ;
processedCount ++ ;
}
}
if ( toDelete && toDelete . length > 0 ) {
const placeholders = toDelete . map ( ( _ , i ) = > ` $ ${ i + 2 } ` ) . join ( ", " ) ;
await client . query (
` DELETE FROM "cart_items" WHERE company_code = $ 1 AND id IN ( ${ placeholders } ) ` ,
[ companyCode , . . . toDelete ] ,
) ;
deletedCount += toDelete . length ;
}
logger . info ( "[pop/execute-action] cart-save 실행" , {
created : toCreate?.length ? ? 0 ,
updated : toUpdate?.length ? ? 0 ,
deleted : toDelete?.length ? ? 0 ,
} ) ;
break ;
}
default :
logger . warn ( "[pop/execute-action] 프론트 전용 작업 타입, 백엔드 무시" , { type : task . type } ) ;
}
}
}
// ======== v1 레거시: action 기반 처리 ========
else if ( action === "inbound-confirm" ) {
2026-03-03 15:30:07 +09:00
// 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] 완료" , {
2026-03-05 17:22:30 +09:00
action : action ? ? "task-list" ,
2026-03-03 15:30:07 +09:00
companyCode ,
processedCount ,
insertedCount ,
2026-03-05 17:22:30 +09:00
deletedCount ,
2026-03-03 15:30:07 +09:00
} ) ;
return res . json ( {
success : true ,
2026-03-05 17:22:30 +09:00
message : ` ${ processedCount } 건 처리 ${ insertedCount > 0 ? ` , ${ insertedCount } 건 생성 ` : "" } ${ deletedCount > 0 ? ` , ${ deletedCount } 건 삭제 ` : "" } ` ,
data : { processedCount , insertedCount , deletedCount , 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 ;