Merge pull request 'flowExecutionService 트랜잭션 처리 개선 및 데이터 변경 추적 로직 수정' (#259) from common/feat/dashboard-map into main

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/259
This commit is contained in:
hyeonsu
2025-12-09 11:16:36 +09:00
5 changed files with 156 additions and 123 deletions

View File

@@ -72,6 +72,11 @@ export class FlowDataMoveService {
// 내부 DB 처리 (기존 로직) // 내부 DB 처리 (기존 로직)
return await db.transaction(async (client) => { return await db.transaction(async (client) => {
try { try {
// 트랜잭션 세션 변수 설정 (트리거에서 changed_by 기록용)
await client.query("SELECT set_config('app.user_id', $1, true)", [
userId || "system",
]);
// 1. 단계 정보 조회 // 1. 단계 정보 조회
const fromStep = await this.flowStepService.findById(fromStepId); const fromStep = await this.flowStepService.findById(fromStepId);
const toStep = await this.flowStepService.findById(toStepId); const toStep = await this.flowStepService.findById(toStepId);
@@ -684,6 +689,14 @@ export class FlowDataMoveService {
dbConnectionId, dbConnectionId,
async (externalClient, dbType) => { async (externalClient, dbType) => {
try { try {
// 외부 DB가 PostgreSQL인 경우에만 세션 변수 설정 시도
if (dbType.toLowerCase() === "postgresql") {
await externalClient.query(
"SELECT set_config('app.user_id', $1, true)",
[userId || "system"]
);
}
// 1. 단계 정보 조회 (내부 DB에서) // 1. 단계 정보 조회 (내부 DB에서)
const fromStep = await this.flowStepService.findById(fromStepId); const fromStep = await this.flowStepService.findById(fromStepId);
const toStep = await this.flowStepService.findById(toStepId); const toStep = await this.flowStepService.findById(toStepId);

View File

@@ -298,7 +298,9 @@ export class FlowExecutionService {
// 4. Primary Key 컬럼 결정 (기본값: id) // 4. Primary Key 컬럼 결정 (기본값: id)
const primaryKeyColumn = flowDef.primaryKey || "id"; const primaryKeyColumn = flowDef.primaryKey || "id";
console.log(`🔍 [updateStepData] Updating table: ${tableName}, PK: ${primaryKeyColumn}=${recordId}`); console.log(
`🔍 [updateStepData] Updating table: ${tableName}, PK: ${primaryKeyColumn}=${recordId}`
);
// 5. SET 절 생성 // 5. SET 절 생성
const updateColumns = Object.keys(updateData); const updateColumns = Object.keys(updateData);
@@ -309,74 +311,86 @@ export class FlowExecutionService {
// 6. 외부 DB vs 내부 DB 구분 // 6. 외부 DB vs 내부 DB 구분
if (flowDef.dbSourceType === "external" && flowDef.dbConnectionId) { if (flowDef.dbSourceType === "external" && flowDef.dbConnectionId) {
// 외부 DB 업데이트 // 외부 DB 업데이트
console.log("✅ [updateStepData] Using EXTERNAL DB:", flowDef.dbConnectionId); console.log(
"✅ [updateStepData] Using EXTERNAL DB:",
flowDef.dbConnectionId
);
// 외부 DB 연결 정보 조회 // 외부 DB 연결 정보 조회
const connectionResult = await db.query( const connectionResult = await db.query(
"SELECT * FROM external_db_connection WHERE id = $1", "SELECT * FROM external_db_connection WHERE id = $1",
[flowDef.dbConnectionId] [flowDef.dbConnectionId]
); );
if (connectionResult.length === 0) { if (connectionResult.length === 0) {
throw new Error(`External DB connection not found: ${flowDef.dbConnectionId}`); throw new Error(
`External DB connection not found: ${flowDef.dbConnectionId}`
);
} }
const connection = connectionResult[0]; const connection = connectionResult[0];
const dbType = connection.db_type?.toLowerCase(); const dbType = connection.db_type?.toLowerCase();
// DB 타입에 따른 placeholder 및 쿼리 생성 // DB 타입에 따른 placeholder 및 쿼리 생성
let setClause: string; let setClause: string;
let params: any[]; let params: any[];
if (dbType === "mysql" || dbType === "mariadb") { if (dbType === "mysql" || dbType === "mariadb") {
// MySQL/MariaDB: ? placeholder // MySQL/MariaDB: ? placeholder
setClause = updateColumns.map((col) => `\`${col}\` = ?`).join(", "); setClause = updateColumns.map((col) => `\`${col}\` = ?`).join(", ");
params = [...Object.values(updateData), recordId]; params = [...Object.values(updateData), recordId];
} else if (dbType === "mssql") { } else if (dbType === "mssql") {
// MSSQL: @p1, @p2 placeholder // MSSQL: @p1, @p2 placeholder
setClause = updateColumns.map((col, idx) => `[${col}] = @p${idx + 1}`).join(", "); setClause = updateColumns
.map((col, idx) => `[${col}] = @p${idx + 1}`)
.join(", ");
params = [...Object.values(updateData), recordId]; params = [...Object.values(updateData), recordId];
} else { } else {
// PostgreSQL: $1, $2 placeholder // PostgreSQL: $1, $2 placeholder
setClause = updateColumns.map((col, idx) => `"${col}" = $${idx + 1}`).join(", "); setClause = updateColumns
.map((col, idx) => `"${col}" = $${idx + 1}`)
.join(", ");
params = [...Object.values(updateData), recordId]; params = [...Object.values(updateData), recordId];
} }
const updateQuery = `UPDATE ${tableName} SET ${setClause} WHERE ${primaryKeyColumn} = ${dbType === "mysql" || dbType === "mariadb" ? "?" : dbType === "mssql" ? `@p${params.length}` : `$${params.length}`}`; const updateQuery = `UPDATE ${tableName} SET ${setClause} WHERE ${primaryKeyColumn} = ${dbType === "mysql" || dbType === "mariadb" ? "?" : dbType === "mssql" ? `@p${params.length}` : `$${params.length}`}`;
console.log(`📝 [updateStepData] Query: ${updateQuery}`); console.log(`📝 [updateStepData] Query: ${updateQuery}`);
console.log(`📝 [updateStepData] Params:`, params); console.log(`📝 [updateStepData] Params:`, params);
await executeExternalQuery(flowDef.dbConnectionId, updateQuery, params); await executeExternalQuery(flowDef.dbConnectionId, updateQuery, params);
} else { } else {
// 내부 DB 업데이트 // 내부 DB 업데이트
console.log("✅ [updateStepData] Using INTERNAL DB"); console.log("✅ [updateStepData] Using INTERNAL DB");
const setClause = updateColumns.map((col, idx) => `"${col}" = $${idx + 1}`).join(", "); const setClause = updateColumns
.map((col, idx) => `"${col}" = $${idx + 1}`)
.join(", ");
const params = [...Object.values(updateData), recordId]; const params = [...Object.values(updateData), recordId];
const updateQuery = `UPDATE "${tableName}" SET ${setClause} WHERE "${primaryKeyColumn}" = $${params.length}`; const updateQuery = `UPDATE "${tableName}" SET ${setClause} WHERE "${primaryKeyColumn}" = $${params.length}`;
console.log(`📝 [updateStepData] Query: ${updateQuery}`); console.log(`📝 [updateStepData] Query: ${updateQuery}`);
console.log(`📝 [updateStepData] Params:`, params); console.log(`📝 [updateStepData] Params:`, params);
// 트랜잭션으로 감싸서 사용자 ID 세션 변수 설정 후 업데이트 실행 // 트랜잭션으로 감싸서 사용자 ID 세션 변수 설정 후 업데이트 실행
// (트리거에서 changed_by를 기록하기 위함) // (트리거에서 changed_by를 기록하기 위함)
await db.query("BEGIN"); await db.transaction(async (client) => {
try { // 안전한 파라미터 바인딩 방식 사용
await db.query(`SET LOCAL app.user_id = '${userId}'`); await client.query("SELECT set_config('app.user_id', $1, true)", [
await db.query(updateQuery, params); userId,
await db.query("COMMIT"); ]);
} catch (txError) { await client.query(updateQuery, params);
await db.query("ROLLBACK"); });
throw txError;
}
} }
console.log(`✅ [updateStepData] Data updated successfully: ${tableName}.${primaryKeyColumn}=${recordId}`, { console.log(
updatedFields: updateColumns, `✅ [updateStepData] Data updated successfully: ${tableName}.${primaryKeyColumn}=${recordId}`,
userId, {
}); updatedFields: updateColumns,
userId,
}
);
return { success: true }; return { success: true };
} catch (error: any) { } catch (error: any) {

View File

@@ -175,6 +175,12 @@ export class NodeFlowExecutionService {
try { try {
result = await transaction(async (client) => { result = await transaction(async (client) => {
// 🔥 사용자 ID 세션 변수 설정 (트리거용)
const userId = context.buttonContext?.userId || "system";
await client.query("SELECT set_config('app.user_id', $1, true)", [
userId,
]);
// 트랜잭션 내에서 레벨별 실행 // 트랜잭션 내에서 레벨별 실행
for (const level of levels) { for (const level of levels) {
await this.executeLevel(level, nodes, edges, context, client); await this.executeLevel(level, nodes, edges, context, client);

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogDescription } from "@/components/ui/dialog"; import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogDescription } from "@/components/ui/resizable-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"; import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
@@ -130,11 +130,11 @@ export function FlowDataListModal({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[80vh] max-w-4xl flex-col overflow-hidden"> <DialogContent className="flex max-h-[80vh] max-w-4xl flex-col overflow-hidden">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <ResizableDialogTitle className="flex items-center gap-2">
{stepName} {stepName}
<Badge variant="secondary">{data.length}</Badge> <Badge variant="secondary">{data.length}</Badge>
</DialogTitle> </ResizableDialogTitle>
<DialogDescription> </DialogDescription> <DialogDescription> </ResizableDialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">

View File

@@ -1669,53 +1669,53 @@ export function FlowWidget({
> >
<Filter className="mr-1 h-3 w-3" /> <Filter className="mr-1 h-3 w-3" />
</Button> </Button>
)} )}
</div> </div>
{/* 필터/그룹 설정 버튼 */} {/* 필터/그룹 설정 버튼 */}
<div className="flex items-center gap-1 border-r border-border pr-2"> <div className="flex items-center gap-1 border-r border-border pr-2">
<Button <Button
variant={searchFilterColumns.size > 0 ? "default" : "ghost"} variant={searchFilterColumns.size > 0 ? "default" : "ghost"}
size="sm" size="sm"
onClick={() => { onClick={() => {
if (!isPreviewMode) { if (!isPreviewMode) {
setIsFilterSettingOpen(true); setIsFilterSettingOpen(true);
} }
}} }}
disabled={isPreviewMode} disabled={isPreviewMode}
className="h-7 text-xs" className="h-7 text-xs"
title="검색 필터 설정" title="검색 필터 설정"
> >
<Filter className="mr-1 h-3 w-3" /> <Filter className="mr-1 h-3 w-3" />
{searchFilterColumns.size > 0 && ( {searchFilterColumns.size > 0 && (
<span className="ml-1 rounded-full bg-white/20 px-1.5 text-[10px]"> <span className="ml-1 rounded-full bg-white/20 px-1.5 text-[10px]">
{searchFilterColumns.size} {searchFilterColumns.size}
</span> </span>
)} )}
</Button> </Button>
<Button <Button
variant={groupByColumns.length > 0 ? "default" : "ghost"} variant={groupByColumns.length > 0 ? "default" : "ghost"}
size="sm" size="sm"
onClick={() => { onClick={() => {
if (!isPreviewMode) { if (!isPreviewMode) {
setIsGroupSettingOpen(true); setIsGroupSettingOpen(true);
} }
}} }}
disabled={isPreviewMode} disabled={isPreviewMode}
className="h-7 text-xs" className="h-7 text-xs"
title="그룹 설정" title="그룹 설정"
> >
<Layers className="mr-1 h-3 w-3" /> <Layers className="mr-1 h-3 w-3" />
{groupByColumns.length > 0 && ( {groupByColumns.length > 0 && (
<span className="ml-1 rounded-full bg-white/20 px-1.5 text-[10px]"> <span className="ml-1 rounded-full bg-white/20 px-1.5 text-[10px]">
{groupByColumns.length} {groupByColumns.length}
</span> </span>
)} )}
</Button> </Button>
</div> </div>
{/* 새로고침 */} {/* 새로고침 */}
<div className="ml-auto flex items-center gap-1"> <div className="ml-auto flex items-center gap-1">
@@ -1731,7 +1731,7 @@ export function FlowWidget({
</Button> </Button>
</div> </div>
</div> </div>
{/* 검색 필터 입력 영역 */} {/* 검색 필터 입력 영역 */}
{searchFilterColumns.size > 0 && ( {searchFilterColumns.size > 0 && (
@@ -1859,20 +1859,20 @@ export function FlowWidget({
{groupByColumns.length > 0 && groupedData.length > 0 ? ( {groupByColumns.length > 0 && groupedData.length > 0 ? (
// 그룹화된 렌더링 (기존 방식 유지) // 그룹화된 렌더링 (기존 방식 유지)
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table noWrapper> <Table noWrapper>
<TableHeader className="sticky top-0 z-30 bg-background shadow-sm"> <TableHeader className="sticky top-0 z-30 bg-background shadow-sm">
<TableRow className="hover:bg-muted/50"> <TableRow className="hover:bg-muted/50">
{allowDataMove && ( {allowDataMove && (
<TableHead className="bg-background sticky left-0 z-40 w-12 border-b px-6 py-3 text-center"> <TableHead className="bg-background sticky left-0 z-40 w-12 border-b px-6 py-3 text-center">
<Checkbox <Checkbox
checked={selectedRows.size === stepData.length && stepData.length > 0} checked={selectedRows.size === stepData.length && stepData.length > 0}
onCheckedChange={toggleAllRows} onCheckedChange={toggleAllRows}
/> />
</TableHead> </TableHead>
)} )}
{stepDataColumns.map((col) => ( {stepDataColumns.map((col) => (
<TableHead <TableHead
key={col} key={col}
className="bg-background border-b px-6 py-3 text-sm font-semibold whitespace-nowrap cursor-pointer hover:bg-muted/50" className="bg-background border-b px-6 py-3 text-sm font-semibold whitespace-nowrap cursor-pointer hover:bg-muted/50"
onClick={() => handleSort(col)} onClick={() => handleSort(col)}
> >
@@ -1884,68 +1884,68 @@ export function FlowWidget({
</span> </span>
)} )}
</div> </div>
</TableHead> </TableHead>
))} ))}
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{groupedData.flatMap((group) => { {groupedData.flatMap((group) => {
const isCollapsed = collapsedGroups.has(group.groupKey); const isCollapsed = collapsedGroups.has(group.groupKey);
const groupRows = [ const groupRows = [
<TableRow key={`group-${group.groupKey}`}> <TableRow key={`group-${group.groupKey}`}>
<TableCell <TableCell
colSpan={stepDataColumns.length + (allowDataMove ? 1 : 0)} colSpan={stepDataColumns.length + (allowDataMove ? 1 : 0)}
className="bg-muted/50 border-b" className="bg-muted/50 border-b"
>
<div
className="flex items-center gap-3 p-2 cursor-pointer hover:bg-muted"
onClick={() => toggleGroupCollapse(group.groupKey)}
> >
<div {isCollapsed ? (
className="flex items-center gap-3 p-2 cursor-pointer hover:bg-muted" <ChevronRight className="h-4 w-4 flex-shrink-0" />
onClick={() => toggleGroupCollapse(group.groupKey)} ) : (
> <ChevronDown className="h-4 w-4 flex-shrink-0" />
{isCollapsed ? ( )}
<ChevronRight className="h-4 w-4 flex-shrink-0" /> <span className="font-medium text-sm flex-1">{group.groupKey}</span>
) : ( <span className="text-muted-foreground text-xs">({group.count})</span>
<ChevronDown className="h-4 w-4 flex-shrink-0" /> </div>
)} </TableCell>
<span className="font-medium text-sm flex-1">{group.groupKey}</span> </TableRow>,
<span className="text-muted-foreground text-xs">({group.count})</span> ];
</div>
</TableCell>
</TableRow>,
];
if (!isCollapsed) { if (!isCollapsed) {
const dataRows = group.items.map((row, itemIndex) => { const dataRows = group.items.map((row, itemIndex) => {
const actualIndex = sortedDisplayData.indexOf(row); const actualIndex = sortedDisplayData.indexOf(row);
return ( return (
<TableRow <TableRow
key={`${group.groupKey}-${itemIndex}`} key={`${group.groupKey}-${itemIndex}`}
className={`h-16 transition-colors hover:bg-muted/50 ${selectedRows.has(actualIndex) ? "bg-primary/5" : ""}`} className={`h-16 transition-colors hover:bg-muted/50 ${selectedRows.has(actualIndex) ? "bg-primary/5" : ""}`}
> >
{allowDataMove && ( {allowDataMove && (
<TableCell className="bg-background sticky left-0 z-10 border-b px-6 py-3 text-center"> <TableCell className="bg-background sticky left-0 z-10 border-b px-6 py-3 text-center">
<Checkbox <Checkbox
checked={selectedRows.has(actualIndex)} checked={selectedRows.has(actualIndex)}
onCheckedChange={() => toggleRowSelection(actualIndex)} onCheckedChange={() => toggleRowSelection(actualIndex)}
/> />
</TableCell> </TableCell>
)} )}
{stepDataColumns.map((col) => ( {stepDataColumns.map((col) => (
<TableCell key={col} className="h-16 border-b px-6 py-3 text-sm whitespace-nowrap"> <TableCell key={col} className="h-16 border-b px-6 py-3 text-sm whitespace-nowrap">
{formatValue(row[col])} {formatValue(row[col])}
</TableCell> </TableCell>
))} ))}
</TableRow> </TableRow>
); );
}); });
groupRows.push(...dataRows); groupRows.push(...dataRows);
} }
return groupRows; return groupRows;
})} })}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
) : ( ) : (
// 일반 렌더링 - SingleTableWithSticky 사용 // 일반 렌더링 - SingleTableWithSticky 사용
<SingleTableWithSticky <SingleTableWithSticky
visibleColumns={tableColumns} visibleColumns={tableColumns}