663 lines
27 KiB
TypeScript
663 lines
27 KiB
TypeScript
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<string, string>;
|
|
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<string, unknown>[];
|
|
fieldValues?: Record<string, unknown>;
|
|
};
|
|
mappings?: {
|
|
cardList?: MappingInfo | null;
|
|
field?: MappingInfo | null;
|
|
};
|
|
statusChanges?: StatusChangeRuleBody[];
|
|
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;
|
|
}
|
|
|
|
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, 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<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") {
|
|
// 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<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);
|
|
}
|
|
|
|
// 채번 규칙 실행: 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;
|