import { Router, Request, Response } from "express"; import { getPool } from "../database/db"; import logger from "../utils/logger"; import { authenticateToken } from "../middleware/authMiddleware"; import { numberingRuleService } from "../services/numberingRuleService"; const router = Router(); // SQL 인젝션 방지: 테이블명/컬럼명 패턴 검증 const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/; function isSafeIdentifier(name: string): boolean { return SAFE_IDENTIFIER.test(name); } interface AutoGenMappingInfo { numberingRuleId: string; targetColumn: string; showResultModal?: boolean; } interface HiddenMappingInfo { valueSource: "json_extract" | "db_column" | "static"; targetColumn: string; staticValue?: string; sourceJsonColumn?: string; sourceJsonKey?: string; sourceDbColumn?: string; } interface MappingInfo { targetTable: string; columnMapping: Record; autoGenMappings?: AutoGenMappingInfo[]; hiddenMappings?: HiddenMappingInfo[]; } 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; tasks?: TaskBody[]; data: { items?: Record[]; fieldValues?: Record; }; mappings?: { cardList?: MappingInfo | null; field?: MappingInfo | null; }; statusChanges?: StatusChangeRuleBody[]; cartChanges?: { toCreate?: Record[]; toUpdate?: Record[]; 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; } function resolveStatusValue( valueType: string, fixedValue: string, conditionalValue: ConditionalValueRule | undefined, item: Record ): 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, tasks, data, mappings, statusChanges, cartChanges } = req.body as ExecuteActionBody; const items = data?.items ?? []; const fieldValues = data?.fieldValues ?? {}; logger.info("[pop/execute-action] 요청", { action: action ?? "task-list", companyCode, userId, itemCount: items.length, hasFieldValues: Object.keys(fieldValues).length > 0, hasMappings: !!mappings, statusChangeCount: statusChanges?.length ?? 0, taskCount: tasks?.length ?? 0, hasCartChanges: !!cartChanges, }); await client.query("BEGIN"); let processedCount = 0; let insertedCount = 0; let deletedCount = 0; const generatedCodes: Array<{ targetColumn: string; code: string; showResultModal?: boolean }> = []; // ======== 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)[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") { // 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); } } // 숨은 필드 매핑 처리 (고정값 / 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)[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); } // 채번 규칙 실행: 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, }); } } 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: action ?? "task-list", companyCode, processedCount, insertedCount, deletedCount, }); return res.json({ success: true, message: `${processedCount}건 처리${insertedCount > 0 ? `, ${insertedCount}건 생성` : ""}${deletedCount > 0 ? `, ${deletedCount}건 삭제` : ""}`, data: { processedCount, insertedCount, deletedCount, generatedCodes }, }); } 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;