Merge remote-tracking branch 'origin/main' into ksh

This commit is contained in:
SeongHyun Kim 2026-01-07 10:28:47 +09:00
commit 42d1a3fc5e
3 changed files with 162 additions and 15 deletions

View File

@ -2282,6 +2282,7 @@ export class NodeFlowExecutionService {
UPDATE ${targetTable}
SET ${setClauses.join(", ")}
WHERE ${updateWhereConditions}
RETURNING *
`;
logger.info(`🔄 UPDATE 실행:`, {
@ -2292,8 +2293,14 @@ export class NodeFlowExecutionService {
values: updateValues,
});
await txClient.query(updateSql, updateValues);
const updateResult = await txClient.query(updateSql, updateValues);
updatedCount++;
// 🆕 UPDATE 결과를 입력 데이터에 병합 (다음 노드에서 id 등 사용 가능)
if (updateResult.rows && updateResult.rows[0]) {
Object.assign(data, updateResult.rows[0]);
logger.info(` 📦 UPDATE 결과 병합: id=${updateResult.rows[0].id}`);
}
} else {
// 3-B. 없으면 INSERT
const columns: string[] = [];
@ -2340,6 +2347,7 @@ export class NodeFlowExecutionService {
const insertSql = `
INSERT INTO ${targetTable} (${columns.join(", ")})
VALUES (${placeholders})
RETURNING *
`;
logger.info(` INSERT 실행:`, {
@ -2348,8 +2356,14 @@ export class NodeFlowExecutionService {
conflictKeyValues,
});
await txClient.query(insertSql, values);
const insertResult = await txClient.query(insertSql, values);
insertedCount++;
// 🆕 INSERT 결과를 입력 데이터에 병합 (다음 노드에서 id 등 사용 가능)
if (insertResult.rows && insertResult.rows[0]) {
Object.assign(data, insertResult.rows[0]);
logger.info(` 📦 INSERT 결과 병합: id=${insertResult.rows[0].id}`);
}
}
}
@ -2357,11 +2371,10 @@ export class NodeFlowExecutionService {
`✅ UPSERT 완료 (내부 DB): ${targetTable}, INSERT ${insertedCount}건, UPDATE ${updatedCount}`
);
return {
insertedCount,
updatedCount,
totalCount: insertedCount + updatedCount,
};
// 🔥 다음 노드에 전달할 데이터 반환
// dataArray에는 Object.assign으로 UPSERT 결과(id 등)가 이미 병합되어 있음
// 카운트 정보도 함께 반환하여 기존 호환성 유지
return dataArray;
};
// 🔥 클라이언트가 전달되었으면 사용, 아니면 독립 트랜잭션 생성

View File

@ -2392,6 +2392,44 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
fetchLabels();
}, [columnUniqueValues, categoryLabelCache]);
// 🆕 데이터에서 CATEGORY_ 코드를 찾아 라벨 미리 로드 (테이블 셀 렌더링용)
useEffect(() => {
if (data.length === 0) return;
const categoryCodesToFetch = new Set<string>();
// 모든 데이터 행에서 CATEGORY_ 코드 수집
data.forEach((row) => {
Object.entries(row).forEach(([key, value]) => {
if (value && typeof value === "string") {
// 콤마로 구분된 다중 값도 처리
const codes = value.split(",").map((v) => v.trim());
codes.forEach((code) => {
if (code.startsWith("CATEGORY_") && !categoryLabelCache[code]) {
categoryCodesToFetch.add(code);
}
});
}
});
});
if (categoryCodesToFetch.size === 0) return;
// API로 라벨 조회
const fetchLabels = async () => {
try {
const response = await getCategoryLabelsByCodes(Array.from(categoryCodesToFetch));
if (response.success && response.data && Object.keys(response.data).length > 0) {
setCategoryLabelCache((prev) => ({ ...prev, ...response.data }));
}
} catch (error) {
console.error("CATEGORY_ 라벨 조회 실패:", error);
}
};
fetchLabels();
}, [data, categoryLabelCache]);
// 🆕 헤더 필터 토글
const toggleHeaderFilter = useCallback((columnName: string, value: string) => {
setHeaderFilters((prev) => {
@ -4548,10 +4586,36 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
case "boolean":
return value ? "예" : "아니오";
default:
return String(value);
// 🆕 CATEGORY_ 코드 자동 변환 (inputType이 category가 아니어도)
const strValue = String(value);
if (strValue.startsWith("CATEGORY_")) {
// rowData에서 _label 필드 찾기
if (rowData) {
const labelFieldCandidates = [
`${column.columnName}_label`,
`${column.columnName}_name`,
`${column.columnName}_value_label`,
];
for (const labelField of labelFieldCandidates) {
if (rowData[labelField] && rowData[labelField] !== "") {
return String(rowData[labelField]);
}
}
}
// categoryMappings에서 찾기
const mapping = categoryMappings[column.columnName];
if (mapping && mapping[strValue]) {
return mapping[strValue].label;
}
// categoryLabelCache에서 찾기 (필터용 캐시)
if (categoryLabelCache[strValue]) {
return categoryLabelCache[strValue];
}
}
return strValue;
}
},
[columnMeta, joinedColumnMeta, optimizedConvertCode, categoryMappings],
[columnMeta, joinedColumnMeta, optimizedConvertCode, categoryMappings, categoryLabelCache],
);
// ========================================

View File

@ -304,6 +304,9 @@ export interface ButtonActionContext {
selectedLeftData?: Record<string, any>;
refreshRightPanel?: () => void;
};
// 🆕 저장된 데이터 (저장 후 제어 실행 시 플로우에 전달)
savedData?: any;
}
/**
@ -1252,7 +1255,49 @@ export class ButtonActionExecutor {
// 🔥 저장 성공 후 연결된 제어 실행 (dataflowTiming이 'after'인 경우)
if (config.enableDataflowControl && config.dataflowConfig) {
console.log("🎯 저장 후 제어 실행 시작:", config.dataflowConfig);
await this.executeAfterSaveControl(config, context);
// 테이블 섹션 데이터 파싱 (comp_로 시작하는 필드에 JSON 배열이 있는 경우)
// 입고 화면 등에서 품목 목록이 comp_xxx 필드에 JSON 문자열로 저장됨
const formData: Record<string, any> = (saveResult.data || context.formData || {}) as Record<string, any>;
let parsedSectionData: any[] = [];
// comp_로 시작하는 필드에서 테이블 섹션 데이터 찾기
const compFieldKey = Object.keys(formData).find(key =>
key.startsWith("comp_") && typeof formData[key] === "string"
);
if (compFieldKey) {
try {
const sectionData = JSON.parse(formData[compFieldKey]);
if (Array.isArray(sectionData) && sectionData.length > 0) {
// 공통 필드와 섹션 데이터 병합
parsedSectionData = sectionData.map((item: any) => {
// 섹션 데이터에서 불필요한 내부 필드 제거
const { _isNewItem, _targetTable, _existingRecord, ...cleanItem } = item;
// 공통 필드(comp_ 필드 제외) + 섹션 아이템 병합
const commonFields: Record<string, any> = {};
Object.keys(formData).forEach(key => {
if (!key.startsWith("comp_") && !key.endsWith("_numberingRuleId")) {
commonFields[key] = formData[key];
}
});
return { ...commonFields, ...cleanItem };
});
console.log(`📦 [handleSave] 테이블 섹션 데이터 파싱 완료: ${parsedSectionData.length}`, parsedSectionData[0]);
}
} catch (parseError) {
console.warn("⚠️ [handleSave] 테이블 섹션 데이터 파싱 실패:", parseError);
}
}
// 저장된 데이터를 context에 추가하여 플로우에 전달
const contextWithSavedData = {
...context,
savedData: formData,
// 파싱된 섹션 데이터가 있으면 selectedRowsData로 전달
selectedRowsData: parsedSectionData.length > 0 ? parsedSectionData : context.selectedRowsData,
};
await this.executeAfterSaveControl(config, contextWithSavedData);
}
} else {
throw new Error("저장에 필요한 정보가 부족합니다. (테이블명 또는 화면ID 누락)");
@ -3644,8 +3689,20 @@ export class ButtonActionExecutor {
// 노드 플로우 실행 API
const { executeNodeFlow } = await import("@/lib/api/nodeFlows");
// 데이터 소스 준비
const sourceData: any = context.formData || {};
// 데이터 소스 준비: context-data 모드는 배열을 기대함
// 우선순위: selectedRowsData > savedData > formData
// - selectedRowsData: 테이블 섹션에서 저장된 하위 항목들 (item_code, inbound_qty 등 포함)
// - savedData: 저장 API 응답 데이터
// - formData: 폼에 입력된 데이터
let sourceData: any[];
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
sourceData = context.selectedRowsData;
console.log("📦 [다중제어] selectedRowsData 사용:", sourceData.length, "건");
} else {
const savedData = context.savedData || context.formData || {};
sourceData = Array.isArray(savedData) ? savedData : [savedData];
console.log("📦 [다중제어] savedData/formData 사용:", sourceData.length, "건");
}
let allSuccess = true;
const results: Array<{ flowId: number; flowName: string; success: boolean; message?: string }> = [];
@ -3752,8 +3809,20 @@ export class ButtonActionExecutor {
// 노드 플로우 실행 API 호출
const { executeNodeFlow } = await import("@/lib/api/nodeFlows");
// 데이터 소스 준비
const sourceData: any = context.formData || {};
// 데이터 소스 준비: context-data 모드는 배열을 기대함
// 우선순위: selectedRowsData > savedData > formData
// - selectedRowsData: 테이블 섹션에서 저장된 하위 항목들 (item_code, inbound_qty 등 포함)
// - savedData: 저장 API 응답 데이터
// - formData: 폼에 입력된 데이터
let sourceData: any[];
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
sourceData = context.selectedRowsData;
console.log("📦 [단일제어] selectedRowsData 사용:", sourceData.length, "건");
} else {
const savedData = context.savedData || context.formData || {};
sourceData = Array.isArray(savedData) ? savedData : [savedData];
console.log("📦 [단일제어] savedData/formData 사용:", sourceData.length, "건");
}
// repeat-screen-modal 데이터가 있으면 병합
const repeatScreenModalKeys = Object.keys(context.formData || {}).filter((key) =>
@ -3766,7 +3835,8 @@ export class ButtonActionExecutor {
console.log("📦 노드 플로우에 전달할 데이터:", {
flowId,
dataSourceType: controlDataSource,
sourceData,
sourceDataCount: sourceData.length,
sourceDataSample: sourceData[0],
});
const result = await executeNodeFlow(flowId, {