feat(pop): 버튼 v2 통합 아키텍처 + data-update 연산 확장 (BLOCK M + N)
버튼 컴포넌트의 실행 경로를 프리셋별 파편화에서 단일 작업 목록(task-list) 패턴으로 통합하고, 부분입고 시나리오 지원을 위해 data-update 연산을 확장한다. [BLOCK M: 버튼 v2 통합 아키텍처] - ButtonTask 타입 체계 정의 (10종 작업 타입 + UpdateOperation) - PopButtonConfigV2 + migrateButtonConfig 자동 마이그레이션 - 설정 UI: 빠른 시작 + 외형 + 작업 목록 에디터 - executeTaskList 범용 실행 함수 (데이터 작업 일괄 백엔드 전달) - collect_data 프로토콜에 cartChanges 포함 - 백엔드 tasks 배열 기반 처리 (data-save/update/delete/cart-save) - useCartSync.getChanges() 추출 + 카드리스트 응답 포함 [BLOCK N: data-update 연산 확장] - UpdateOperationType에 multiply, divide, db-conditional 추가 - ButtonTask에 db-conditional 전용 필드 5개 추가 (compareColumn, compareOperator, compareWith, dbThenValue, dbElseValue) - 설정 UI: 드롭다운 3개 옵션 + DB 컬럼 비교 설정 폼 - 백엔드 SQL: multiply, divide(0-division 방어), db-conditional(CASE WHEN 배치 UPDATE) - 기존 add/subtract에 ::numeric 캐스팅 일관 적용
This commit is contained in:
parent
91c9dda6ae
commit
b2b0b575df
|
|
@ -62,7 +62,8 @@ interface StatusChangeRuleBody {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExecuteActionBody {
|
interface ExecuteActionBody {
|
||||||
action: string;
|
action?: string;
|
||||||
|
tasks?: TaskBody[];
|
||||||
data: {
|
data: {
|
||||||
items?: Record<string, unknown>[];
|
items?: Record<string, unknown>[];
|
||||||
fieldValues?: Record<string, unknown>;
|
fieldValues?: Record<string, unknown>;
|
||||||
|
|
@ -72,6 +73,36 @@ interface ExecuteActionBody {
|
||||||
field?: MappingInfo | null;
|
field?: MappingInfo | null;
|
||||||
};
|
};
|
||||||
statusChanges?: StatusChangeRuleBody[];
|
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(
|
function resolveStatusValue(
|
||||||
|
|
@ -114,27 +145,300 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { action, data, mappings, statusChanges } = req.body as ExecuteActionBody;
|
const { action, tasks, data, mappings, statusChanges, cartChanges } = req.body as ExecuteActionBody;
|
||||||
const items = data?.items ?? [];
|
const items = data?.items ?? [];
|
||||||
const fieldValues = data?.fieldValues ?? {};
|
const fieldValues = data?.fieldValues ?? {};
|
||||||
|
|
||||||
logger.info("[pop/execute-action] 요청", {
|
logger.info("[pop/execute-action] 요청", {
|
||||||
action,
|
action: action ?? "task-list",
|
||||||
companyCode,
|
companyCode,
|
||||||
userId,
|
userId,
|
||||||
itemCount: items.length,
|
itemCount: items.length,
|
||||||
hasFieldValues: Object.keys(fieldValues).length > 0,
|
hasFieldValues: Object.keys(fieldValues).length > 0,
|
||||||
hasMappings: !!mappings,
|
hasMappings: !!mappings,
|
||||||
statusChangeCount: statusChanges?.length ?? 0,
|
statusChangeCount: statusChanges?.length ?? 0,
|
||||||
|
taskCount: tasks?.length ?? 0,
|
||||||
|
hasCartChanges: !!cartChanges,
|
||||||
});
|
});
|
||||||
|
|
||||||
await client.query("BEGIN");
|
await client.query("BEGIN");
|
||||||
|
|
||||||
let processedCount = 0;
|
let processedCount = 0;
|
||||||
let insertedCount = 0;
|
let insertedCount = 0;
|
||||||
|
let deletedCount = 0;
|
||||||
const generatedCodes: Array<{ targetColumn: string; code: string; showResultModal?: boolean }> = [];
|
const generatedCodes: Array<{ targetColumn: string; code: string; showResultModal?: boolean }> = [];
|
||||||
|
|
||||||
if (action === "inbound-confirm") {
|
// ======== 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 (장바구니 데이터 -> 대상 테이블)
|
// 1. 매핑 기반 INSERT (장바구니 데이터 -> 대상 테이블)
|
||||||
const cardMapping = mappings?.cardList;
|
const cardMapping = mappings?.cardList;
|
||||||
const fieldMapping = mappings?.field;
|
const fieldMapping = mappings?.field;
|
||||||
|
|
@ -331,16 +635,17 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||||
await client.query("COMMIT");
|
await client.query("COMMIT");
|
||||||
|
|
||||||
logger.info("[pop/execute-action] 완료", {
|
logger.info("[pop/execute-action] 완료", {
|
||||||
action,
|
action: action ?? "task-list",
|
||||||
companyCode,
|
companyCode,
|
||||||
processedCount,
|
processedCount,
|
||||||
insertedCount,
|
insertedCount,
|
||||||
|
deletedCount,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: `${processedCount}건 처리 완료${insertedCount > 0 ? `, ${insertedCount}건 생성` : ""}`,
|
message: `${processedCount}건 처리${insertedCount > 0 ? `, ${insertedCount}건 생성` : ""}${deletedCount > 0 ? `, ${deletedCount}건 삭제` : ""}`,
|
||||||
data: { processedCount, insertedCount, generatedCodes },
|
data: { processedCount, insertedCount, deletedCount, generatedCodes },
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
await client.query("ROLLBACK");
|
await client.query("ROLLBACK");
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
* - 향후 pop-table 행 액션 등
|
* - 향후 pop-table 행 액션 등
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ButtonMainAction } from "@/lib/registry/pop-components/pop-button";
|
import type { ButtonMainAction, ButtonTask } from "@/lib/registry/pop-components/pop-button";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { dataApi } from "@/lib/api/data";
|
import { dataApi } from "@/lib/api/data";
|
||||||
|
|
||||||
|
|
@ -197,3 +197,156 @@ export async function executePopAction(
|
||||||
return { success: false, error: message };
|
return { success: false, error: message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// v2: 작업 목록 실행
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/** 수집된 데이터 구조 */
|
||||||
|
export interface CollectedPayload {
|
||||||
|
items?: Record<string, unknown>[];
|
||||||
|
fieldValues?: Record<string, unknown>;
|
||||||
|
mappings?: {
|
||||||
|
cardList?: Record<string, unknown> | null;
|
||||||
|
field?: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
cartChanges?: {
|
||||||
|
toCreate?: Record<string, unknown>[];
|
||||||
|
toUpdate?: Record<string, unknown>[];
|
||||||
|
toDelete?: (string | number)[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 작업 목록 실행 옵션 */
|
||||||
|
interface ExecuteTaskListOptions {
|
||||||
|
publish: PublishFn;
|
||||||
|
componentId: string;
|
||||||
|
collectedData?: CollectedPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 목록을 순차 실행한다.
|
||||||
|
* 데이터 관련 작업(data-save, data-update, data-delete, cart-save)은
|
||||||
|
* 하나의 API 호출로 묶어 백엔드에서 트랜잭션 처리한다.
|
||||||
|
* 나머지 작업(modal-open, navigate 등)은 프론트엔드에서 직접 처리한다.
|
||||||
|
*/
|
||||||
|
export async function executeTaskList(
|
||||||
|
tasks: ButtonTask[],
|
||||||
|
options: ExecuteTaskListOptions,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const { publish, componentId, collectedData } = options;
|
||||||
|
|
||||||
|
// 데이터 작업과 프론트 전용 작업 분리
|
||||||
|
const DATA_TASK_TYPES = new Set(["data-save", "data-update", "data-delete", "cart-save"]);
|
||||||
|
const dataTasks = tasks.filter((t) => DATA_TASK_TYPES.has(t.type));
|
||||||
|
const frontTasks = tasks.filter((t) => !DATA_TASK_TYPES.has(t.type));
|
||||||
|
|
||||||
|
let backendData: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 데이터 작업이 있으면 백엔드에 일괄 전송
|
||||||
|
if (dataTasks.length > 0) {
|
||||||
|
const result = await apiClient.post("/pop/execute-action", {
|
||||||
|
tasks: dataTasks,
|
||||||
|
data: {
|
||||||
|
items: collectedData?.items ?? [],
|
||||||
|
fieldValues: collectedData?.fieldValues ?? {},
|
||||||
|
},
|
||||||
|
mappings: collectedData?.mappings ?? {},
|
||||||
|
cartChanges: collectedData?.cartChanges,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.data?.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: result.data?.message || "데이터 작업 실행에 실패했습니다.",
|
||||||
|
data: result.data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
backendData = result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const innerData = (backendData as Record<string, unknown>)?.data as Record<string, unknown> | undefined;
|
||||||
|
const generatedCodes = innerData?.generatedCodes as
|
||||||
|
Array<{ targetColumn: string; code: string; showResultModal?: boolean }> | undefined;
|
||||||
|
const hasResultModal = generatedCodes?.some((g) => g.showResultModal);
|
||||||
|
|
||||||
|
// 2. 프론트엔드 전용 작업 순차 실행 (채번 모달이 있으면 navigate 보류)
|
||||||
|
const deferredNavigateTasks: ButtonTask[] = [];
|
||||||
|
for (const task of frontTasks) {
|
||||||
|
switch (task.type) {
|
||||||
|
case "modal-open":
|
||||||
|
publish("__pop_modal_open__", {
|
||||||
|
modalId: task.modalScreenId,
|
||||||
|
title: task.modalTitle,
|
||||||
|
mode: task.modalMode,
|
||||||
|
items: task.modalItems,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "navigate":
|
||||||
|
if (hasResultModal) {
|
||||||
|
deferredNavigateTasks.push(task);
|
||||||
|
} else if (task.targetScreenId) {
|
||||||
|
publish("__pop_navigate__", { screenId: task.targetScreenId, params: task.params });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "close-modal":
|
||||||
|
publish("__pop_close_modal__");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "refresh":
|
||||||
|
if (!hasResultModal) {
|
||||||
|
publish("__pop_refresh__");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "api-call": {
|
||||||
|
if (!task.apiEndpoint) break;
|
||||||
|
const method = (task.apiMethod || "POST").toUpperCase();
|
||||||
|
switch (method) {
|
||||||
|
case "GET":
|
||||||
|
await apiClient.get(task.apiEndpoint);
|
||||||
|
break;
|
||||||
|
case "PUT":
|
||||||
|
await apiClient.put(task.apiEndpoint);
|
||||||
|
break;
|
||||||
|
case "DELETE":
|
||||||
|
await apiClient.delete(task.apiEndpoint);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
await apiClient.post(task.apiEndpoint);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "custom-event":
|
||||||
|
if (task.eventName) {
|
||||||
|
publish(task.eventName, task.eventPayload ?? {});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 완료 이벤트
|
||||||
|
if (!hasResultModal) {
|
||||||
|
publish(`__comp_output__${componentId}__action_completed`, {
|
||||||
|
action: "task-list",
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
generatedCodes,
|
||||||
|
deferredTasks: deferredNavigateTasks,
|
||||||
|
...(backendData ?? {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : "작업 실행 중 오류가 발생했습니다.";
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,12 @@ import type {
|
||||||
|
|
||||||
// ===== 반환 타입 =====
|
// ===== 반환 타입 =====
|
||||||
|
|
||||||
|
export interface CartChanges {
|
||||||
|
toCreate: Record<string, unknown>[];
|
||||||
|
toUpdate: Record<string, unknown>[];
|
||||||
|
toDelete: (string | number)[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface UseCartSyncReturn {
|
export interface UseCartSyncReturn {
|
||||||
cartItems: CartItemWithId[];
|
cartItems: CartItemWithId[];
|
||||||
savedItems: CartItemWithId[];
|
savedItems: CartItemWithId[];
|
||||||
|
|
@ -48,6 +54,7 @@ export interface UseCartSyncReturn {
|
||||||
isItemInCart: (rowKey: string) => boolean;
|
isItemInCart: (rowKey: string) => boolean;
|
||||||
getCartItem: (rowKey: string) => CartItemWithId | undefined;
|
getCartItem: (rowKey: string) => CartItemWithId | undefined;
|
||||||
|
|
||||||
|
getChanges: (selectedColumns?: string[]) => CartChanges;
|
||||||
saveToDb: (selectedColumns?: string[]) => Promise<boolean>;
|
saveToDb: (selectedColumns?: string[]) => Promise<boolean>;
|
||||||
loadFromDb: () => Promise<void>;
|
loadFromDb: () => Promise<void>;
|
||||||
resetToSaved: () => void;
|
resetToSaved: () => void;
|
||||||
|
|
@ -252,6 +259,29 @@ export function useCartSync(
|
||||||
[cartItems],
|
[cartItems],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ----- diff 계산 (백엔드 전송용) -----
|
||||||
|
const getChanges = useCallback((selectedColumns?: string[]): CartChanges => {
|
||||||
|
const currentScreenId = screenIdRef.current;
|
||||||
|
|
||||||
|
const cartRowKeys = new Set(cartItems.map((i) => i.rowKey));
|
||||||
|
const toDeleteItems = savedItems.filter((s) => s.cartId && !cartRowKeys.has(s.rowKey));
|
||||||
|
const toCreateItems = cartItems.filter((c) => !c.cartId);
|
||||||
|
|
||||||
|
const savedMap = new Map(savedItems.map((s) => [s.rowKey, s]));
|
||||||
|
const toUpdateItems = cartItems.filter((c) => {
|
||||||
|
if (!c.cartId) return false;
|
||||||
|
const saved = savedMap.get(c.rowKey);
|
||||||
|
if (!saved) return false;
|
||||||
|
return c.quantity !== saved.quantity || c.packageUnit !== saved.packageUnit || c.status !== saved.status;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
toCreate: toCreateItems.map((item) => cartItemToDbRecord(item, currentScreenId, selectedColumns)),
|
||||||
|
toUpdate: toUpdateItems.map((item) => ({ id: item.cartId, ...cartItemToDbRecord(item, currentScreenId, selectedColumns) })),
|
||||||
|
toDelete: toDeleteItems.map((item) => item.cartId!),
|
||||||
|
};
|
||||||
|
}, [cartItems, savedItems]);
|
||||||
|
|
||||||
// ----- DB 저장 (일괄) -----
|
// ----- DB 저장 (일괄) -----
|
||||||
const saveToDb = useCallback(async (selectedColumns?: string[]): Promise<boolean> => {
|
const saveToDb = useCallback(async (selectedColumns?: string[]): Promise<boolean> => {
|
||||||
setSyncStatus("saving");
|
setSyncStatus("saving");
|
||||||
|
|
@ -324,6 +354,7 @@ export function useCartSync(
|
||||||
updateItemQuantity,
|
updateItemQuantity,
|
||||||
isItemInCart,
|
isItemInCart,
|
||||||
getCartItem,
|
getCartItem,
|
||||||
|
getChanges,
|
||||||
saveToDb,
|
saveToDb,
|
||||||
loadFromDb,
|
loadFromDb,
|
||||||
resetToSaved,
|
resetToSaved,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -702,11 +702,13 @@ export function PopCardListComponent({
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const cartChanges = cart.isDirty ? cart.getChanges() : undefined;
|
||||||
|
|
||||||
const response: CollectedDataResponse = {
|
const response: CollectedDataResponse = {
|
||||||
requestId: request?.requestId ?? "",
|
requestId: request?.requestId ?? "",
|
||||||
componentId: componentId,
|
componentId: componentId,
|
||||||
componentType: "pop-card-list",
|
componentType: "pop-card-list",
|
||||||
data: { items: selectedItems },
|
data: { items: selectedItems, cartChanges },
|
||||||
mapping,
|
mapping,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -714,7 +716,7 @@ export function PopCardListComponent({
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return unsub;
|
return unsub;
|
||||||
}, [componentId, subscribe, publish, isCartListMode, filteredRows, rows, selectedKeys]);
|
}, [componentId, subscribe, publish, isCartListMode, filteredRows, rows, selectedKeys, cart]);
|
||||||
|
|
||||||
// 장바구니 목록 모드: 선택 항목 이벤트 발행
|
// 장바구니 목록 모드: 선택 항목 이벤트 발행
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue