feat(pop): 컴포넌트 연결 단순화 + 상태 변경 규칙 UI 개선 + 조회 키 설정

컴포넌트 연결 단순화
- ConnectionEditor: 이벤트 연결 시 "어디로" Select 1개로 단순화
- useConnectionResolver: 호환 이벤트 자동 라우팅 (_auto 모드)
- connectionMeta에 category(event/filter/data) 필드 추가

상태 변경 규칙 UI 개선
- StatusChangeRule 타입 통합, 모든 버튼 프리셋에서 사용 가능
- TableCombobox/ColumnCombobox 공용 컴포넌트 추출 (pop-shared/)
- 테이블/컬럼 드롭다운, 고정값/조건부 값 설정 UI
- 입고확정 API 신규 (popActionRoutes.ts, 동적 상태 변경 처리)

조회 키 자동/수동 설정
- 대상 테이블 기반 자동 판단 (cart_items -> id, 그 외 -> row_key -> PK)
- 수동 모드: 카드 항목 필드와 대상 PK 컬럼을 직접 지정 가능
- PK 컬럼명 동적 표시 (isPrimaryKey 정보 활용)
This commit is contained in:
SeongHyun Kim
2026-03-03 15:30:07 +09:00
parent 220e05d2ae
commit e3ae8d273c
20 changed files with 2147 additions and 329 deletions
+2
View File
@@ -112,6 +112,7 @@ import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙
import entitySearchRoutes, { entityOptionsRouter } from "./routes/entitySearchRoutes"; // 엔티티 검색 및 옵션
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
@@ -238,6 +239,7 @@ app.use("/api/table-management", tableManagementRoutes);
app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능
app.use("/api/screen-management", screenManagementRoutes);
app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리
app.use("/api/pop", popActionRoutes); // POP 액션 실행
app.use("/api/common-codes", commonCodeRoutes);
app.use("/api/dynamic-form", dynamicFormRoutes);
app.use("/api/files", fileRoutes);
+280
View 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;