Merge branch 'ksh-v2-work' into main
POP 화면 관리 기능 일괄 병합: - POP 컴포넌트 연결/상태변경 규칙/후속 액션 - POP 장바구니(CartList) 모드 + 멀티필드 입력 - POP 화면 복사 기능 (단일 + 카테고리 일괄) - POP 화면관리 UX 개선 (스크롤/접기) - PC/POP 화면 데이터 분리 (excludePop 필터) - .gitignore 미사용 항목 정리 충돌 1건 해결 (screenManagementRoutes.ts import 양쪽 통합)
This commit is contained in:
280
backend-node/src/routes/popActionRoutes.ts
Normal file
280
backend-node/src/routes/popActionRoutes.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import { getPool } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
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 MappingInfo {
|
||||
targetTable: string;
|
||||
columnMapping: Record<string, string>;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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}건 생성` : ""}`,
|
||||
data: { processedCount, insertedCount },
|
||||
});
|
||||
} 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;
|
||||
@@ -51,6 +51,8 @@ import {
|
||||
updateZone,
|
||||
deleteZone,
|
||||
addLayerToZone,
|
||||
analyzePopScreenLinks,
|
||||
deployPopScreens,
|
||||
} from "../controllers/screenManagementController";
|
||||
|
||||
const router = express.Router();
|
||||
@@ -145,4 +147,8 @@ router.post("/copy-table-type-columns", copyTableTypeColumns);
|
||||
// 연쇄관계 설정 복제
|
||||
router.post("/copy-cascading-relation", copyCascadingRelation);
|
||||
|
||||
// POP 화면 배포 (다른 회사로 복사)
|
||||
router.get("/screens/:screenId/pop-links", analyzePopScreenLinks);
|
||||
router.post("/deploy-pop-screens", deployPopScreens);
|
||||
|
||||
export default router;
|
||||
|
||||
Reference in New Issue
Block a user