feat: 낙관적 잠금 + 소유자 기반 액션 제어 + 디자이너 설정 UI
동시 접수 충돌 방지(preCondition WHERE + 409 에러), 소유자 일치 시에만 버튼 활성화(owner-match showCondition), 본인 카드 우선 정렬(ownerSortColumn)을 구현하고 디자이너에서 설정할 수 있는 UI 3종을 추가한다. [백엔드] - popActionRoutes: TaskBody에 preCondition 추가, data-update WHERE 조건 삽입, rowCount=0 시 409 Conflict 반환 (isPreConditionFail) [프론트엔드 - 런타임] - types.ts: ActionPreCondition 인터페이스, owner-match 타입, ownerSortColumn 필드 - cell-renderers: evaluateShowCondition에 owner-match 분기 + currentUserId prop - PopCardListV2Component: useAuth 연동, preCondition 전달/409 처리, ownerSortColumn 기반 카드 정렬, currentUserId 하위 전달 [프론트엔드 - 디자이너 설정 UI] - PopCardListV2Config: showCondition 드롭다운에 "소유자 일치" 옵션 + 컬럼 선택, ImmediateActionEditor에 "사전 조건(중복 방지)" 토글 + 검증 컬럼/기대값/실패 메시지, TabActions에 "소유자 우선 정렬" 컬럼 드롭다운
This commit is contained in:
@@ -104,6 +104,11 @@ interface TaskBody {
|
||||
manualItemField?: string;
|
||||
manualPkColumn?: string;
|
||||
cartScreenId?: string;
|
||||
preCondition?: {
|
||||
column: string;
|
||||
expectedValue: string;
|
||||
failMessage?: string;
|
||||
};
|
||||
}
|
||||
|
||||
function resolveStatusValue(
|
||||
@@ -334,14 +339,30 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||
const item = items[i] ?? {};
|
||||
const resolved = resolveStatusValue("conditional", task.fixedValue ?? "", task.conditionalValue, item);
|
||||
const autoUpdated = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
|
||||
await client.query(
|
||||
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1${autoUpdated} WHERE company_code = $2 AND "${pkColumn}" = $3`,
|
||||
[resolved, companyCode, lookupValues[i]],
|
||||
let condWhere = `WHERE company_code = $2 AND "${pkColumn}" = $3`;
|
||||
const condParams: unknown[] = [resolved, companyCode, lookupValues[i]];
|
||||
if (task.preCondition?.column && task.preCondition?.expectedValue) {
|
||||
if (!isSafeIdentifier(task.preCondition.column)) throw new Error(`유효하지 않은 preCondition 컬럼명: ${task.preCondition.column}`);
|
||||
condWhere += ` AND "${task.preCondition.column}" = $4`;
|
||||
condParams.push(task.preCondition.expectedValue);
|
||||
}
|
||||
const condResult = await client.query(
|
||||
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1${autoUpdated} ${condWhere}`,
|
||||
condParams,
|
||||
);
|
||||
if (task.preCondition && condResult.rowCount === 0) {
|
||||
const err = new Error(task.preCondition.failMessage || "조건이 일치하지 않아 처리할 수 없습니다.");
|
||||
(err as any).isPreConditionFail = true;
|
||||
throw err;
|
||||
}
|
||||
processedCount++;
|
||||
}
|
||||
} else if (opType === "db-conditional") {
|
||||
// DB 컬럼 간 비교 후 값 판정 (CASE WHEN col_a >= col_b THEN '완료' ELSE '진행중')
|
||||
if (task.preCondition) {
|
||||
logger.warn("[pop/execute-action] db-conditional에는 preCondition 미지원, 무시됨", {
|
||||
taskId: task.id, preCondition: task.preCondition,
|
||||
});
|
||||
}
|
||||
if (!task.compareColumn || !task.compareOperator || !task.compareWith) break;
|
||||
if (!isSafeIdentifier(task.compareColumn) || !isSafeIdentifier(task.compareWith)) break;
|
||||
|
||||
@@ -392,10 +413,24 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||
}
|
||||
|
||||
const autoUpdatedDate = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
|
||||
await client.query(
|
||||
`UPDATE "${task.targetTable}" SET ${setSql}${autoUpdatedDate} WHERE company_code = $2 AND "${pkColumn}" = $3`,
|
||||
[value, companyCode, lookupValues[i]],
|
||||
let whereSql = `WHERE company_code = $2 AND "${pkColumn}" = $3`;
|
||||
const queryParams: unknown[] = [value, companyCode, lookupValues[i]];
|
||||
if (task.preCondition?.column && task.preCondition?.expectedValue) {
|
||||
if (!isSafeIdentifier(task.preCondition.column)) {
|
||||
throw new Error(`유효하지 않은 preCondition 컬럼명: ${task.preCondition.column}`);
|
||||
}
|
||||
whereSql += ` AND "${task.preCondition.column}" = $4`;
|
||||
queryParams.push(task.preCondition.expectedValue);
|
||||
}
|
||||
const updateResult = await client.query(
|
||||
`UPDATE "${task.targetTable}" SET ${setSql}${autoUpdatedDate} ${whereSql}`,
|
||||
queryParams,
|
||||
);
|
||||
if (task.preCondition && updateResult.rowCount === 0) {
|
||||
const err = new Error(task.preCondition.failMessage || "조건이 일치하지 않아 처리할 수 없습니다.");
|
||||
(err as any).isPreConditionFail = true;
|
||||
throw err;
|
||||
}
|
||||
processedCount++;
|
||||
}
|
||||
}
|
||||
@@ -746,6 +781,16 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||
});
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
|
||||
if (error.isPreConditionFail) {
|
||||
logger.warn("[pop/execute-action] preCondition 실패", { message: error.message });
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
errorCode: "PRE_CONDITION_FAIL",
|
||||
});
|
||||
}
|
||||
|
||||
logger.error("[pop/execute-action] 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
|
||||
Reference in New Issue
Block a user