From 710ca122ea8d4623d653d418304b612fa10a4c19 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 25 Nov 2025 17:08:12 +0900 Subject: [PATCH 01/25] =?UTF-8?q?STP=20=EC=A0=95=EC=B0=A8=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=EB=A5=BC=20=EC=9E=90=EC=9E=AC=20=EB=AF=B8?= =?UTF-8?q?=EC=A0=81=EC=9E=AC=20=EC=98=81=EC=97=AD=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=ED=95=98=EA=B3=A0=20=EC=8B=9C=EA=B0=81?= =?UTF-8?q?=ED=99=94=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/yard-3d/DigitalTwinEditor.tsx | 53 ++++++------- .../widgets/yard-3d/DigitalTwinViewer.tsx | 24 +++--- .../widgets/yard-3d/Yard3DCanvas.tsx | 78 ++++++++++--------- 3 files changed, 82 insertions(+), 73 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx index 9a71c338..f3d826b5 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useMemo } from "react"; import { Button } from "@/components/ui/button"; -import { ArrowLeft, Save, Loader2, Grid3x3, Move, Box, Package, Truck, Check } from "lucide-react"; +import { ArrowLeft, Save, Loader2, Grid3x3, Move, Box, Package, Truck, Check, ParkingCircle } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -550,10 +550,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi areaKey: obj.area_key, locaKey: obj.loca_key, locType: obj.loc_type, - materialCount: obj.material_count, - materialPreview: obj.material_preview_height - ? { height: parseFloat(obj.material_preview_height) } - : undefined, + materialCount: obj.loc_type === "STP" ? undefined : obj.material_count, + materialPreview: + obj.loc_type === "STP" || !obj.material_preview_height + ? undefined + : { height: parseFloat(obj.material_preview_height) }, parentId: obj.parent_id, displayOrder: obj.display_order, locked: obj.locked, @@ -761,12 +762,9 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi // 기본 크기 설정 let objectSize = defaults.size || { x: 5, y: 5, z: 5 }; - // Location 배치 시 자재 개수에 따라 높이 자동 설정 + // Location 배치 시 자재 개수에 따라 높이 자동 설정 (BED/TMP/DES만 대상, STP는 자재 미적재) if ( - (draggedTool === "location-bed" || - draggedTool === "location-stp" || - draggedTool === "location-temp" || - draggedTool === "location-dest") && + (draggedTool === "location-bed" || draggedTool === "location-temp" || draggedTool === "location-dest") && locaKey && selectedDbConnection && hierarchyConfig?.material @@ -877,12 +875,9 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi setDraggedAreaData(null); setDraggedLocationData(null); - // Location 배치 시 자재 개수 로드 + // Location 배치 시 자재 개수 로드 (BED/TMP/DES만 대상, STP는 자재 미적재) if ( - (draggedTool === "location-bed" || - draggedTool === "location-stp" || - draggedTool === "location-temp" || - draggedTool === "location-dest") && + (draggedTool === "location-bed" || draggedTool === "location-temp" || draggedTool === "location-dest") && locaKey ) { // 새 객체 추가 후 자재 개수 로드 (약간의 딜레이를 두어 state 업데이트 완료 후 실행) @@ -965,13 +960,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi loadLocationsForArea(obj.areaKey); setShowMaterialPanel(false); } - // Location을 클릭한 경우, 해당 Location의 자재 목록 로드 + // Location을 클릭한 경우, 해당 Location의 자재 목록 로드 (STP는 자재 미적재이므로 제외) else if ( obj && - (obj.type === "location-bed" || - obj.type === "location-stp" || - obj.type === "location-temp" || - obj.type === "location-dest") && + (obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") && obj.locaKey && selectedDbConnection ) { @@ -988,9 +980,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi try { const response = await getMaterialCounts(selectedDbConnection, selectedTables.material, locaKeys); if (response.success && response.data) { - // 각 Location 객체에 자재 개수 업데이트 + // 각 Location 객체에 자재 개수 업데이트 (STP는 자재 미적재이므로 제외) setPlacedObjects((prev) => prev.map((obj) => { + if ( + !obj.locaKey || + obj.type === "location-stp" // STP는 자재 없음 + ) { + return obj; + } const materialCount = response.data?.find((mc) => mc.LOCAKEY === obj.locaKey); if (materialCount) { return { @@ -1278,7 +1276,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi const oldSize = actualObject.size; const newSize = { ...oldSize, ...updates.size }; - // W, D를 5 단위로 스냅 + // W, D를 5 단위로 스냅 (STP 포함) newSize.x = Math.max(5, Math.round(newSize.x / 5) * 5); newSize.z = Math.max(5, Math.round(newSize.z / 5) * 5); @@ -1391,10 +1389,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi areaKey: obj.area_key, locaKey: obj.loca_key, locType: obj.loc_type, - materialCount: obj.material_count, - materialPreview: obj.material_preview_height - ? { height: parseFloat(obj.material_preview_height) } - : undefined, + materialCount: obj.loc_type === "STP" ? undefined : obj.material_count, + materialPreview: + obj.loc_type === "STP" || !obj.material_preview_height + ? undefined + : { height: parseFloat(obj.material_preview_height) }, parentId: obj.parent_id, displayOrder: obj.display_order, locked: obj.locked, @@ -1798,6 +1797,8 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi {isLocationPlaced ? ( + ) : locationType === "location-stp" ? ( + ) : ( )} diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx index cc34fb19..1dfe8251 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useMemo } from "react"; -import { Loader2, Search, X, Grid3x3, Package } from "lucide-react"; +import { Loader2, Search, X, Grid3x3, Package, ParkingCircle } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; @@ -87,10 +87,11 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) areaKey: obj.area_key, locaKey: obj.loca_key, locType: obj.loc_type, - materialCount: obj.material_count, - materialPreview: obj.material_preview_height - ? { height: parseFloat(obj.material_preview_height) } - : undefined, + materialCount: obj.loc_type === "STP" ? undefined : obj.material_count, + materialPreview: + obj.loc_type === "STP" || !obj.material_preview_height + ? undefined + : { height: parseFloat(obj.material_preview_height) }, parentId: obj.parent_id, displayOrder: obj.display_order, locked: obj.locked, @@ -166,13 +167,10 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) const obj = placedObjects.find((o) => o.id === objectId); setSelectedObject(obj || null); - // Location을 클릭한 경우, 자재 정보 표시 + // Location을 클릭한 경우, 자재 정보 표시 (STP는 자재 미적재이므로 제외) if ( obj && - (obj.type === "location-bed" || - obj.type === "location-stp" || - obj.type === "location-temp" || - obj.type === "location-dest") && + (obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") && obj.locaKey && externalDbConnectionId ) { @@ -471,7 +469,11 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) >
- + {locationObj.type === "location-stp" ? ( + + ) : ( + + )} {locationObj.name}
- - - + // 정차포인트(STP): 회색 타원형 플랫폼 + 'P' 마크 (자재 미적재 영역) + { + const baseRadius = 0.5; // 스케일로 실제 W/D를 반영 (타원형) + const labelFontSize = Math.min(boxWidth, boxDepth) * 0.15; + const iconFontSize = Math.min(boxWidth, boxDepth) * 0.3; - {/* Location 이름 */} - {placement.name && ( + return ( + <> + {/* 타원형 플랫폼: 단위 실린더를 W/D로 스케일 */} + + + + + + {/* 상단 'P' 마크 (주차 아이콘 역할) */} - {placement.name} + P - )} - {/* 자재 개수 (STP는 정차포인트라 자재가 없을 수 있음) */} - {placement.material_count !== undefined && placement.material_count > 0 && ( - - {`자재: ${placement.material_count}개`} - - )} - - ); + {/* Location 이름 */} + {placement.name && ( + + {placement.name} + + )} + + ); + } // case "gantry-crane": // // 겐트리 크레인: 기둥 2개 + 상단 빔 From f0513e20d8de18e895c462961cd8eace15837c0e Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 25 Nov 2025 17:19:39 +0900 Subject: [PATCH 02/25] =?UTF-8?q?3D=20=EC=97=90=EB=94=94=ED=84=B0=20?= =?UTF-8?q?=EC=86=8D=EC=84=B1=20=EC=9E=85=EB=A0=A5=20=EC=84=B1=EB=8A=A5=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/yard-3d/DigitalTwinEditor.tsx | 110 +++++++++++++++--- 1 file changed, 91 insertions(+), 19 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx index f3d826b5..3a4b1901 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx @@ -39,6 +39,77 @@ import { DialogTitle, } from "@/components/ui/dialog"; +// 성능 최적화를 위한 디바운스/Blur 처리된 Input 컴포넌트 +const DebouncedInput = ({ + value, + onChange, + onCommit, + type = "text", + debounce = 0, + ...props +}: React.InputHTMLAttributes & { + onCommit?: (value: any) => void; + debounce?: number; +}) => { + const [localValue, setLocalValue] = useState(value); + const [isEditing, setIsEditing] = useState(false); + + useEffect(() => { + if (!isEditing) { + setLocalValue(value); + } + }, [value, isEditing]); + + // 색상 입력 등을 위한 디바운스 커밋 + useEffect(() => { + if (debounce > 0 && isEditing && onCommit) { + const timer = setTimeout(() => { + onCommit(type === "number" ? parseFloat(localValue as string) : localValue); + }, debounce); + return () => clearTimeout(timer); + } + }, [localValue, debounce, isEditing, onCommit, type]); + + const handleChange = (e: React.ChangeEvent) => { + setLocalValue(e.target.value); + if (onChange) onChange(e); + }; + + const handleBlur = (e: React.FocusEvent) => { + setIsEditing(false); + if (onCommit && debounce === 0) { + // 값이 변경되었을 때만 커밋하도록 하면 좋겠지만, + // 부모 상태와 비교하기 어려우므로 항상 커밋 (handleObjectUpdate 내부에서 처리됨) + onCommit(type === "number" ? parseFloat(localValue as string) : localValue); + } + if (props.onBlur) props.onBlur(e); + }; + + const handleFocus = (e: React.FocusEvent) => { + setIsEditing(true); + if (props.onFocus) props.onFocus(e); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.currentTarget.blur(); + } + if (props.onKeyDown) props.onKeyDown(e); + }; + + return ( + + ); +}; + // 백엔드 DB 객체 타입 (snake_case) interface DbObject { id: number; @@ -2070,10 +2141,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi - handleObjectUpdate({ name: e.target.value })} + onCommit={(val) => handleObjectUpdate({ name: val })} className="mt-1.5 h-9 text-sm" />
@@ -2086,15 +2157,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi - + onCommit={(val) => handleObjectUpdate({ position: { ...selectedObject.position, - x: parseFloat(e.target.value), + x: val, }, }) } @@ -2105,15 +2176,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi - + onCommit={(val) => handleObjectUpdate({ position: { ...selectedObject.position, - z: parseFloat(e.target.value), + z: val, }, }) } @@ -2131,17 +2202,17 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi - + onCommit={(val) => handleObjectUpdate({ size: { ...selectedObject.size, - x: parseFloat(e.target.value), + x: val, }, }) } @@ -2152,15 +2223,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi - + onCommit={(val) => handleObjectUpdate({ size: { ...selectedObject.size, - y: parseFloat(e.target.value), + y: val, }, }) } @@ -2171,17 +2242,17 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi - + onCommit={(val) => handleObjectUpdate({ size: { ...selectedObject.size, - z: parseFloat(e.target.value), + z: val, }, }) } @@ -2196,11 +2267,12 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi - handleObjectUpdate({ color: e.target.value })} + onCommit={(val) => handleObjectUpdate({ color: val })} className="mt-1.5 h-9" /> From b2afe8674ea84b9cf83852ebb64bf20b9b676c8b Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 25 Nov 2025 17:23:24 +0900 Subject: [PATCH 03/25] =?UTF-8?q?=203D=20=EB=B7=B0=EC=96=B4=20=EC=A1=B0?= =?UTF-8?q?=EB=AA=85=20=EC=84=A4=EC=A0=95=20=EA=B0=9C=EC=84=A0=20(?= =?UTF-8?q?=EC=83=89=EC=83=81=20=EC=99=9C=EA=B3=A1=20=ED=95=B4=EA=B2=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx index 3dd210b1..892acc88 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx @@ -1104,10 +1104,12 @@ function Scene({ orbitControlsRef={orbitControlsRef} /> - {/* 조명 */} - - - + {/* 조명 - 전체적으로 밝게 조정 */} + + + + + {/* 배경색 */} From 5787550cc95252c1e3b722e514bb61553a8c7eba Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 26 Nov 2025 16:05:33 +0900 Subject: [PATCH 04/25] =?UTF-8?q?=EC=97=90=EB=94=94=ED=84=B0=20=EC=86=8D?= =?UTF-8?q?=EC=84=B1=20=ED=8E=B8=EC=A7=91=20=EC=84=B1=EB=8A=A5=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=81=ED=99=94=20(=EB=94=94=EB=B0=94=EC=9A=B4=EC=8A=A4=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/controllers/digitalTwinTemplateController.ts | 1 + backend-node/src/services/DigitalTwinTemplateService.ts | 1 + .../admin/dashboard/widgets/yard-3d/spatialContainment.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/backend-node/src/controllers/digitalTwinTemplateController.ts b/backend-node/src/controllers/digitalTwinTemplateController.ts index 882d8e62..4ea80ef9 100644 --- a/backend-node/src/controllers/digitalTwinTemplateController.ts +++ b/backend-node/src/controllers/digitalTwinTemplateController.ts @@ -161,3 +161,4 @@ export const createMappingTemplate = async ( + diff --git a/backend-node/src/services/DigitalTwinTemplateService.ts b/backend-node/src/services/DigitalTwinTemplateService.ts index d4818b3a..f63e929f 100644 --- a/backend-node/src/services/DigitalTwinTemplateService.ts +++ b/backend-node/src/services/DigitalTwinTemplateService.ts @@ -170,3 +170,4 @@ export class DigitalTwinTemplateService { + diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/spatialContainment.ts b/frontend/components/admin/dashboard/widgets/yard-3d/spatialContainment.ts index f2df7e70..179b872c 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/spatialContainment.ts +++ b/frontend/components/admin/dashboard/widgets/yard-3d/spatialContainment.ts @@ -164,3 +164,4 @@ export function getAllDescendants( + From 13af9a62e8077010d4f311a83505a4dcf9b8ca3a Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 26 Nov 2025 17:22:39 +0900 Subject: [PATCH 05/25] =?UTF-8?q?fix:=20=EC=88=98=EC=A3=BC=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EB=82=A9=EA=B8=B0=EC=9D=BC=20DATE=20=ED=98=95?= =?UTF-8?q?=EC=8B=9D=20=EC=A0=80=EC=9E=A5=20=EB=B0=8F=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=ED=8C=A8=EB=84=90=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 프론트엔드: EditModal 날짜 정규화 함수 추가 (YYYY-MM-DD) - 백엔드: convertValueForPostgreSQL에서 DATE 타입 문자열 유지 - 백엔드: 날짜 변환 로직에서 YYYY-MM-DD 문자열 변환 제거 - 프론트엔드: ModalRepeaterTableConfigPanel prop 이름 통일 (onChange) - OrderItemRepeaterTable 필드명 수정 (delivery_date → item_due_date) closes #납기일-TIMESTAMP-저장-이슈 #설정패널-prop-오류 --- .../ModalRepeaterTableConfigPanel.tsx | 8 ++++++-- .../registry/components/text-input/TextInputComponent.tsx | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx index a8068c92..348ae045 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx @@ -15,7 +15,8 @@ import { cn } from "@/lib/utils"; interface ModalRepeaterTableConfigPanelProps { config: Partial; - onConfigChange: (config: Partial) => void; + onChange: (config: Partial) => void; + onConfigChange?: (config: Partial) => void; // 하위 호환성 } // 소스 컬럼 선택기 (동적 테이블별 컬럼 로드) @@ -124,8 +125,11 @@ function ReferenceColumnSelector({ export function ModalRepeaterTableConfigPanel({ config, + onChange, onConfigChange, }: ModalRepeaterTableConfigPanelProps) { + // 하위 호환성: onConfigChange가 있으면 사용, 없으면 onChange 사용 + const handleConfigChange = onConfigChange || onChange; // 초기 설정 정리: 계산 규칙과 컬럼 설정 동기화 const cleanupInitialConfig = (initialConfig: Partial): Partial => { // 계산 규칙이 없으면 모든 컬럼의 calculated 속성 제거 @@ -241,7 +245,7 @@ export function ModalRepeaterTableConfigPanel({ const updateConfig = (updates: Partial) => { const newConfig = { ...localConfig, ...updates }; setLocalConfig(newConfig); - onConfigChange(newConfig); + handleConfigChange(newConfig); }; const addSourceColumn = () => { diff --git a/frontend/lib/registry/components/text-input/TextInputComponent.tsx b/frontend/lib/registry/components/text-input/TextInputComponent.tsx index ffbd7cac..8c545fe0 100644 --- a/frontend/lib/registry/components/text-input/TextInputComponent.tsx +++ b/frontend/lib/registry/components/text-input/TextInputComponent.tsx @@ -717,7 +717,7 @@ export const TextInputComponent: React.FC = ({ } disabled={componentConfig.disabled || false} required={componentConfig.required || false} - readOnly={componentConfig.readonly || (testAutoGeneration.enabled && testAutoGeneration.type !== "none")} + readOnly={componentConfig.readonly || false} className={cn( "box-border h-full w-full max-w-full rounded-md border px-3 py-2 text-sm shadow-sm transition-all duration-200 outline-none", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", From a9577a8f9a28b22537ef4002c9d208ab7352e15a Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 26 Nov 2025 18:24:15 +0900 Subject: [PATCH 06/25] =?UTF-8?q?fix:=20=EC=88=98=EC=A3=BC=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EC=8B=9C=20=EC=82=AC=EC=9A=A9=EC=9E=90=EA=B0=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=ED=95=9C=20=EC=88=98=EC=A3=BC=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=8D=AE=EC=96=B4=EC=93=B0=EA=B8=B0=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 저장 시점에 채번 규칙 강제 재할당 로직 제거 - TextInputComponent에서 생성된 값을 사용자가 수정하면 그대로 유지 - allocateNumberingCode API 불필요한 호출 제거 - 사용자 입력 값 보존 및 순번 불필요 증가 방지 --- frontend/lib/utils/buttonActions.ts | 27 ++++++--------------------- 1 file changed, 6 insertions(+), 21 deletions(-) diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index eafbd814..3dcdf6a0 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -392,27 +392,12 @@ export class ButtonActionExecutor { // console.log("📋 채번 규칙이 설정된 필드:", fieldsWithNumbering); // console.log("📊 필드 개수:", Object.keys(fieldsWithNumbering).length); - // 각 필드에 대해 실제 코드 할당 - for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) { - try { - // console.log(`🎫 ${fieldName} 필드에 채번 규칙 ${ruleId} 할당 시작`); - const { allocateNumberingCode } = await import("@/lib/api/numberingRule"); - const response = await allocateNumberingCode(ruleId); - - // console.log(`📡 API 응답 (${fieldName}):`, response); - - if (response.success && response.data) { - const generatedCode = response.data.generatedCode; - formData[fieldName] = generatedCode; - // console.log(`✅ ${fieldName} = ${generatedCode} (할당 완료)`); - } else { - console.error(`❌ 채번 규칙 할당 실패 (${fieldName}):`, response.error); - toast.error(`${fieldName} 채번 규칙 할당 실패: ${response.error}`); - } - } catch (error) { - console.error(`❌ 채번 규칙 할당 오류 (${fieldName}):`, error); - toast.error(`${fieldName} 채번 규칙 할당 오류`); - } + // 사용자 입력 값 유지 (재할당하지 않음) + // 채번 규칙은 TextInputComponent 마운트 시 이미 생성되었으므로 + // 저장 시점에는 사용자가 수정한 값을 그대로 사용 + if (Object.keys(fieldsWithNumbering).length > 0) { + console.log("ℹ️ 채번 규칙 필드 감지:", Object.keys(fieldsWithNumbering)); + console.log("ℹ️ 사용자 입력 값 유지 (재할당 하지 않음)"); } // console.log("✅ 채번 규칙 할당 완료"); From c7d47a6634f222891a8898ed3ff5c43dc120cf55 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Thu, 27 Nov 2025 09:43:05 +0900 Subject: [PATCH 07/25] =?UTF-8?q?feat:=20=EC=B1=84=EB=B2=88=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=20=EC=9E=90=EB=8F=99/=EC=88=98=EB=8F=99=20=EB=AA=A8?= =?UTF-8?q?=EB=93=9C=20=EC=A0=84=ED=99=98=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사용자 수정 감지 시 자동으로 수동 모드 전환 - 원본 자동 생성 값 추적으로 모드 전환 기준 설정 - 수동 모드 시 채번 규칙 ID 제거하여 재할당 방지 - 원본 값 복구 시 자동 모드로 재전환 및 메타데이터 복구 --- .../text-input/TextInputComponent.tsx | 65 ++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/frontend/lib/registry/components/text-input/TextInputComponent.tsx b/frontend/lib/registry/components/text-input/TextInputComponent.tsx index 8c545fe0..4588189b 100644 --- a/frontend/lib/registry/components/text-input/TextInputComponent.tsx +++ b/frontend/lib/registry/components/text-input/TextInputComponent.tsx @@ -83,6 +83,10 @@ export const TextInputComponent: React.FC = ({ // autoGeneratedValue, // }); + // 자동생성 원본 값 추적 (수동/자동 모드 구분용) + const [originalAutoGeneratedValue, setOriginalAutoGeneratedValue] = useState(""); + const [isManualMode, setIsManualMode] = useState(false); + // 자동생성 값 생성 (컴포넌트 마운트 시 한 번만 실행) useEffect(() => { const generateAutoValue = async () => { @@ -136,6 +140,7 @@ export const TextInputComponent: React.FC = ({ if (generatedValue) { console.log("✅ 자동생성 값 설정:", generatedValue); setAutoGeneratedValue(generatedValue); + setOriginalAutoGeneratedValue(generatedValue); // 🆕 원본 값 저장 hasGeneratedRef.current = true; // 생성 완료 플래그 // 폼 데이터에 자동생성된 값 설정 (인터랙티브 모드에서만) @@ -684,6 +689,20 @@ export const TextInputComponent: React.FC = ({ )} + {/* 수동/자동 모드 표시 배지 */} + {testAutoGeneration.enabled && testAutoGeneration.type === "numbering_rule" && isInteractive && ( +
+ + {isManualMode ? "수동" : "자동"} + +
+ )} + { @@ -704,14 +723,18 @@ export const TextInputComponent: React.FC = ({ })()} placeholder={ testAutoGeneration.enabled && testAutoGeneration.type !== "none" - ? `자동생성: ${AutoGenerationUtils.getTypeDescription(testAutoGeneration.type)}` + ? isManualMode + ? "수동 입력 모드" + : `자동생성: ${AutoGenerationUtils.getTypeDescription(testAutoGeneration.type)}` : componentConfig.placeholder || defaultPlaceholder } pattern={validationPattern} title={ webType === "tel" ? "전화번호 형식: 010-1234-5678" - : component.label + : isManualMode + ? `${component.label} (수동 입력 모드 - 채번 규칙 미적용)` + : component.label ? `${component.label}${component.columnName ? ` (${component.columnName})` : ""}` : component.columnName || undefined } @@ -742,6 +765,44 @@ export const TextInputComponent: React.FC = ({ // hasOnChange: !!props.onChange, // }); + // 🆕 사용자 수정 감지 (자동 생성 값과 다르면 수동 모드로 전환) + if (testAutoGeneration.enabled && testAutoGeneration.type === "numbering_rule") { + if (originalAutoGeneratedValue && newValue !== originalAutoGeneratedValue) { + if (!isManualMode) { + setIsManualMode(true); + console.log("🔄 수동 모드로 전환:", { + field: component.columnName, + original: originalAutoGeneratedValue, + modified: newValue + }); + + // 🆕 채번 규칙 ID 제거 (수동 모드이므로 더 이상 채번 규칙 사용 안 함) + if (isInteractive && onFormDataChange && component.columnName) { + const ruleIdKey = `${component.columnName}_numberingRuleId`; + onFormDataChange(ruleIdKey, null); + console.log("🗑️ 채번 규칙 ID 제거 (수동 모드):", ruleIdKey); + } + } + } else if (isManualMode && newValue === originalAutoGeneratedValue) { + // 사용자가 원본 값으로 되돌렸을 때 자동 모드로 복구 + setIsManualMode(false); + console.log("🔄 자동 모드로 복구:", { + field: component.columnName, + value: newValue + }); + + // 채번 규칙 ID 복구 + if (isInteractive && onFormDataChange && component.columnName) { + const ruleId = testAutoGeneration.options?.numberingRuleId; + if (ruleId) { + const ruleIdKey = `${component.columnName}_numberingRuleId`; + onFormDataChange(ruleIdKey, ruleId); + console.log("✅ 채번 규칙 ID 복구 (자동 모드):", ruleIdKey); + } + } + } + } + // isInteractive 모드에서는 formData 업데이트 if (isInteractive && onFormDataChange && component.columnName) { onFormDataChange(component.columnName, newValue); From a1117092aafd54888d6f4c5a35efe428744a9f9e Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Thu, 27 Nov 2025 10:33:54 +0900 Subject: [PATCH 08/25] =?UTF-8?q?feat:=20=EC=88=98=EC=A3=BC=EC=9D=BC(order?= =?UTF-8?q?=5Fdate)=20=EC=9D=BC=EA=B4=84=20=EC=A0=81=EC=9A=A9=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OrderItemRepeaterTable에 order_date 컬럼 추가 - ModalRepeaterTableComponent에 수주일 일괄 적용 로직 구현 - 원본 newData 참조로 납기일 로직과 독립적으로 작동 - 모든 행이 비어있는 초기 상태에서 첫 선택 시 자동 적용 - isOrderDateApplied 플래그로 1회만 실행 보장 --- .../order/OrderItemRepeaterTable.tsx | 7 ++++ .../ModalRepeaterTableComponent.tsx | 37 +++++++++++++------ 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/frontend/components/order/OrderItemRepeaterTable.tsx b/frontend/components/order/OrderItemRepeaterTable.tsx index dd38ee5a..dbfe5eee 100644 --- a/frontend/components/order/OrderItemRepeaterTable.tsx +++ b/frontend/components/order/OrderItemRepeaterTable.tsx @@ -75,6 +75,13 @@ const ORDER_COLUMNS: RepeaterColumnConfig[] = [ calculated: true, width: "120px", }, + { + field: "order_date", + label: "수주일", + type: "date", + editable: true, + width: "130px", + }, { field: "delivery_date", label: "납기일", diff --git a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx index 56f04d26..6302e7f9 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx @@ -197,13 +197,6 @@ export function ModalRepeaterTableComponent({ // ✅ onChange 래퍼 (기존 onChange 콜백 + onFormDataChange 호출 + 납기일 일괄 적용) const handleChange = (newData: any[]) => { - console.log("🔄 ModalRepeaterTableComponent.handleChange 호출:", { - dataLength: newData.length, - columnName, - hasExternalOnChange: !!(componentConfig?.onChange || propOnChange), - hasOnFormDataChange: !!(onFormDataChange && columnName), - }); - // 🆕 납기일 일괄 적용 로직 (납기일 필드가 있는 경우만) let processedData = newData; @@ -229,22 +222,41 @@ export function ModalRepeaterTableComponent({ })); setIsDeliveryDateApplied(true); // 플래그 활성화 + } + } + + // 🆕 수주일 일괄 적용 로직 (order_date 필드가 있는 경우만) + const orderDateField = columns.find( + (col) => + col.field === "order_date" || + col.field === "ordered_date" + ); + + if (orderDateField && !isOrderDateApplied && newData.length > 0) { + // ⚠️ 중요: 원본 newData를 참조해야 납기일의 영향을 받지 않음 + const itemsWithOrderDate = newData.filter((item) => item[orderDateField.field]); + const itemsWithoutOrderDate = newData.filter((item) => !item[orderDateField.field]); + + // ✅ 조건: 모든 행이 비어있는 초기 상태 → 어느 행에서든 첫 선택 시 전체 적용 + if (itemsWithOrderDate.length === 1 && itemsWithoutOrderDate.length === newData.length - 1) { + const selectedOrderDate = itemsWithOrderDate[0][orderDateField.field]; + processedData = processedData.map((item) => ({ + ...item, + [orderDateField.field]: selectedOrderDate, + })); - console.log("✅ 납기일 일괄 적용 완료:", selectedDate); - console.log(` - 대상: ${itemsWithoutDate.length}개 행에 ${selectedDate} 적용`); + setIsOrderDateApplied(true); // 플래그 활성화 } } // 기존 onChange 콜백 호출 (호환성) const externalOnChange = componentConfig?.onChange || propOnChange; if (externalOnChange) { - console.log("📤 외부 onChange 호출"); externalOnChange(processedData); } // 🆕 onFormDataChange 호출하여 EditModal의 groupData 업데이트 if (onFormDataChange && columnName) { - console.log("📤 onFormDataChange 호출:", columnName); onFormDataChange(columnName, processedData); } }; @@ -261,6 +273,9 @@ export function ModalRepeaterTableComponent({ // 🆕 납기일 일괄 적용 플래그 (딱 한 번만 실행) const [isDeliveryDateApplied, setIsDeliveryDateApplied] = useState(false); + + // 🆕 수주일 일괄 적용 플래그 (딱 한 번만 실행) + const [isOrderDateApplied, setIsOrderDateApplied] = useState(false); // columns가 비어있으면 sourceColumns로부터 자동 생성 const columns = React.useMemo((): RepeaterColumnConfig[] => { From ed56e14aa2473a3981fd8467f39e82d67f4fc821 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 27 Nov 2025 11:31:52 +0900 Subject: [PATCH 09/25] =?UTF-8?q?=EC=82=AC=EC=9A=A9=20=EC=99=84=EB=A3=8C?= =?UTF-8?q?=EB=90=9C=20mail-sent=20JSON=20=EB=A1=9C=EA=B7=B8=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../12b583c9-a6b2-4c7f-8340-fd0e700aa32e.json | 19 ------------- .../1bb5ebfe-3f6c-4884-a043-161ae3f74f75.json | 16 ----------- .../375f2326-ca86-468a-bfc3-2d4c3825577b.json | 19 ------------- .../386e334a-df76-440c-ae8a-9bf06982fdc8.json | 16 ----------- .../3d411dc4-69a6-4236-b878-9693dff881be.json | 18 ------------ .../3e30a264-8431-44c7-96ef-eed551e66a11.json | 16 ----------- .../4a32bab5-364e-4037-bb00-31d2905824db.json | 16 ----------- .../5bfb2acd-023a-4865-a738-2900179db5fb.json | 16 ----------- .../683c1323-1895-403a-bb9a-4e111a8909f6.json | 18 ------------ .../7bed27d5-dae4-4ba8-85d0-c474c4fb907a.json | 16 ----------- .../8990ea86-3112-4e7c-b3e0-8b494181c4e0.json | 13 --------- .../99703f2c-740c-492e-a866-a04289a9b699.json | 13 --------- .../9ab1e5ee-4f5e-4b79-9769-5e2a1e1ffc8e.json | 19 ------------- .../9d0b9fcf-cabf-4053-b6b6-6e110add22de.json | 18 ------------ .../b293e530-2b2d-4b8a-8081-d103fab5a13f.json | 18 ------------ .../cf892a77-1998-4165-bb9d-b390451465b2.json | 16 ----------- .../e3501abc-cd31-4b20-bb02-3c7ddbe54eb8.json | 13 --------- .../eb92ed00-cc4f-4cc8-94c9-9bef312d16db.json | 16 ----------- .../fd2a8b41-2e6e-4e5e-b8e8-63d31efc5082.json | 28 ------------------- 19 files changed, 324 deletions(-) delete mode 100644 backend-node/data/mail-sent/12b583c9-a6b2-4c7f-8340-fd0e700aa32e.json delete mode 100644 backend-node/data/mail-sent/1bb5ebfe-3f6c-4884-a043-161ae3f74f75.json delete mode 100644 backend-node/data/mail-sent/375f2326-ca86-468a-bfc3-2d4c3825577b.json delete mode 100644 backend-node/data/mail-sent/386e334a-df76-440c-ae8a-9bf06982fdc8.json delete mode 100644 backend-node/data/mail-sent/3d411dc4-69a6-4236-b878-9693dff881be.json delete mode 100644 backend-node/data/mail-sent/3e30a264-8431-44c7-96ef-eed551e66a11.json delete mode 100644 backend-node/data/mail-sent/4a32bab5-364e-4037-bb00-31d2905824db.json delete mode 100644 backend-node/data/mail-sent/5bfb2acd-023a-4865-a738-2900179db5fb.json delete mode 100644 backend-node/data/mail-sent/683c1323-1895-403a-bb9a-4e111a8909f6.json delete mode 100644 backend-node/data/mail-sent/7bed27d5-dae4-4ba8-85d0-c474c4fb907a.json delete mode 100644 backend-node/data/mail-sent/8990ea86-3112-4e7c-b3e0-8b494181c4e0.json delete mode 100644 backend-node/data/mail-sent/99703f2c-740c-492e-a866-a04289a9b699.json delete mode 100644 backend-node/data/mail-sent/9ab1e5ee-4f5e-4b79-9769-5e2a1e1ffc8e.json delete mode 100644 backend-node/data/mail-sent/9d0b9fcf-cabf-4053-b6b6-6e110add22de.json delete mode 100644 backend-node/data/mail-sent/b293e530-2b2d-4b8a-8081-d103fab5a13f.json delete mode 100644 backend-node/data/mail-sent/cf892a77-1998-4165-bb9d-b390451465b2.json delete mode 100644 backend-node/data/mail-sent/e3501abc-cd31-4b20-bb02-3c7ddbe54eb8.json delete mode 100644 backend-node/data/mail-sent/eb92ed00-cc4f-4cc8-94c9-9bef312d16db.json delete mode 100644 backend-node/data/mail-sent/fd2a8b41-2e6e-4e5e-b8e8-63d31efc5082.json diff --git a/backend-node/data/mail-sent/12b583c9-a6b2-4c7f-8340-fd0e700aa32e.json b/backend-node/data/mail-sent/12b583c9-a6b2-4c7f-8340-fd0e700aa32e.json deleted file mode 100644 index 9e7a209c..00000000 --- a/backend-node/data/mail-sent/12b583c9-a6b2-4c7f-8340-fd0e700aa32e.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "id": "12b583c9-a6b2-4c7f-8340-fd0e700aa32e", - "sentAt": "2025-10-22T05:17:38.303Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "subject": "Fwd: ㅏㅣ", - "htmlContent": "\r\n
\r\n

ㄴㅇㄹㄴㅇㄹㄴㅇㄹㅇ리'ㅐㅔ'ㅑ678463ㅎㄱ휼췇흍츄

\r\n
\r\n

\r\n
\r\n

---------- 전달된 메시지 ----------

\r\n

보낸 사람: \"이희진\"

\r\n

날짜: 2025. 10. 22. 오후 1:32:34

\r\n

제목: ㅏㅣ

\r\n
\r\n undefined\r\n
\r\n ", - "status": "success", - "messageId": "<74dbd467-6185-024d-dd60-bf4459ff9ea4@wace.me>", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [], - "deletedAt": "2025-10-22T06:36:10.876Z" -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/1bb5ebfe-3f6c-4884-a043-161ae3f74f75.json b/backend-node/data/mail-sent/1bb5ebfe-3f6c-4884-a043-161ae3f74f75.json deleted file mode 100644 index 2f624e9c..00000000 --- a/backend-node/data/mail-sent/1bb5ebfe-3f6c-4884-a043-161ae3f74f75.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "id": "1bb5ebfe-3f6c-4884-a043-161ae3f74f75", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [], - "cc": [], - "bcc": [], - "subject": "Fwd: ㄴㅇㄹㅇㄴㄴㄹ 테스트트트", - "htmlContent": "\n\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n전달된 메일:\n\n보낸사람: \"이희진\" \n날짜: 2025. 10. 22. 오후 4:24:54\n제목: ㄴㅇㄹㅇㄴㄴㄹ\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\nㄹㅇㄴㄹㅇㄴㄹㅇㄴ\n", - "sentAt": "2025-10-22T07:49:50.811Z", - "status": "draft", - "isDraft": true, - "updatedAt": "2025-10-22T07:49:50.811Z", - "deletedAt": "2025-10-22T07:50:14.211Z" -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/375f2326-ca86-468a-bfc3-2d4c3825577b.json b/backend-node/data/mail-sent/375f2326-ca86-468a-bfc3-2d4c3825577b.json deleted file mode 100644 index c142808d..00000000 --- a/backend-node/data/mail-sent/375f2326-ca86-468a-bfc3-2d4c3825577b.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "id": "375f2326-ca86-468a-bfc3-2d4c3825577b", - "sentAt": "2025-10-22T04:57:39.706Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "\"이희진\" " - ], - "subject": "Re: ㅏㅣ", - "htmlContent": "\r\n
\r\n

ㅁㄴㅇㄹㅁㅇㄴㄹㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㄴㅁㅇㄹ

\r\n
\r\n

\r\n
\r\n

보낸 사람: \"이희진\"

\r\n

날짜: 2025. 10. 22. 오후 1:32:34

\r\n

제목: ㅏㅣ

\r\n
\r\n undefined\r\n
\r\n ", - "status": "success", - "messageId": "", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [], - "deletedAt": "2025-10-22T07:11:04.666Z" -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/386e334a-df76-440c-ae8a-9bf06982fdc8.json b/backend-node/data/mail-sent/386e334a-df76-440c-ae8a-9bf06982fdc8.json deleted file mode 100644 index 31da5552..00000000 --- a/backend-node/data/mail-sent/386e334a-df76-440c-ae8a-9bf06982fdc8.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "id": "386e334a-df76-440c-ae8a-9bf06982fdc8", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [], - "cc": [], - "bcc": [], - "subject": "Fwd: ㄴ", - "htmlContent": "\n

\n
\n

---------- 전달된 메일 ----------

\n

보낸사람: \"이희진\" <zian9227@naver.com>

\n

날짜: 2025. 10. 22. 오후 12:58:15

\n

제목:

\n
\n

ㄴㅇㄹㄴㅇㄹㄴㅇㄹ\n

\n
\n ", - "sentAt": "2025-10-22T07:04:27.192Z", - "status": "draft", - "isDraft": true, - "updatedAt": "2025-10-22T07:04:57.280Z", - "deletedAt": "2025-10-22T07:50:17.136Z" -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/3d411dc4-69a6-4236-b878-9693dff881be.json b/backend-node/data/mail-sent/3d411dc4-69a6-4236-b878-9693dff881be.json deleted file mode 100644 index aa107de7..00000000 --- a/backend-node/data/mail-sent/3d411dc4-69a6-4236-b878-9693dff881be.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "id": "3d411dc4-69a6-4236-b878-9693dff881be", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "cc": [], - "bcc": [], - "subject": "Re: ㄴ", - "htmlContent": "\n

\n
\n

원본 메일:

\n

보낸사람: \"이희진\"

\n

날짜: 2025. 10. 22. 오후 12:58:15

\n

제목:

\n
\n

undefined

\n
\n ", - "sentAt": "2025-10-22T06:56:51.060Z", - "status": "draft", - "isDraft": true, - "updatedAt": "2025-10-22T06:56:51.060Z", - "deletedAt": "2025-10-22T07:50:22.989Z" -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/3e30a264-8431-44c7-96ef-eed551e66a11.json b/backend-node/data/mail-sent/3e30a264-8431-44c7-96ef-eed551e66a11.json deleted file mode 100644 index d824d67b..00000000 --- a/backend-node/data/mail-sent/3e30a264-8431-44c7-96ef-eed551e66a11.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "id": "3e30a264-8431-44c7-96ef-eed551e66a11", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [], - "cc": [], - "bcc": [], - "subject": "Fwd: ㄴ", - "htmlContent": "\n

\n
\n

---------- 전달된 메일 ----------

\n

보낸사람: \"이희진\"

\n

날짜: 2025. 10. 22. 오후 12:58:15

\n

제목:

\n
\n

\n
\n ", - "sentAt": "2025-10-22T06:57:53.335Z", - "status": "draft", - "isDraft": true, - "updatedAt": "2025-10-22T07:00:23.394Z", - "deletedAt": "2025-10-22T07:50:20.510Z" -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/4a32bab5-364e-4037-bb00-31d2905824db.json b/backend-node/data/mail-sent/4a32bab5-364e-4037-bb00-31d2905824db.json deleted file mode 100644 index 92de4a0c..00000000 --- a/backend-node/data/mail-sent/4a32bab5-364e-4037-bb00-31d2905824db.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "id": "4a32bab5-364e-4037-bb00-31d2905824db", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [], - "cc": [], - "bcc": [], - "subject": "테스트 마지가", - "htmlContent": "ㅁㄴㅇㄹ", - "sentAt": "2025-10-22T07:49:29.948Z", - "status": "draft", - "isDraft": true, - "updatedAt": "2025-10-22T07:49:29.948Z", - "deletedAt": "2025-10-22T07:50:12.374Z" -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/5bfb2acd-023a-4865-a738-2900179db5fb.json b/backend-node/data/mail-sent/5bfb2acd-023a-4865-a738-2900179db5fb.json deleted file mode 100644 index 5f5a5cfc..00000000 --- a/backend-node/data/mail-sent/5bfb2acd-023a-4865-a738-2900179db5fb.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "id": "5bfb2acd-023a-4865-a738-2900179db5fb", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [], - "cc": [], - "bcc": [], - "subject": "Fwd: ㄴ", - "htmlContent": "\n

\n
\n

---------- 전달된 메일 ----------

\n

보낸사람: \"이희진\"

\n

날짜: 2025. 10. 22. 오후 12:58:15

\n

제목:

\n
\n

ㄴㅇㄹㄴㅇㄹㄴㅇㄹ\n

\n
\n ", - "sentAt": "2025-10-22T07:03:09.080Z", - "status": "draft", - "isDraft": true, - "updatedAt": "2025-10-22T07:03:39.150Z", - "deletedAt": "2025-10-22T07:50:19.035Z" -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/683c1323-1895-403a-bb9a-4e111a8909f6.json b/backend-node/data/mail-sent/683c1323-1895-403a-bb9a-4e111a8909f6.json deleted file mode 100644 index b3c3259f..00000000 --- a/backend-node/data/mail-sent/683c1323-1895-403a-bb9a-4e111a8909f6.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "id": "683c1323-1895-403a-bb9a-4e111a8909f6", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "cc": [], - "bcc": [], - "subject": "Re: ㄴ", - "htmlContent": "\n

\n
\n

원본 메일:

\n

보낸사람: \"이희진\"

\n

날짜: 2025. 10. 22. 오후 12:58:15

\n

제목:

\n
\n

undefined

\n
\n ", - "sentAt": "2025-10-22T06:54:55.097Z", - "status": "draft", - "isDraft": true, - "updatedAt": "2025-10-22T06:54:55.097Z", - "deletedAt": "2025-10-22T07:50:24.672Z" -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/7bed27d5-dae4-4ba8-85d0-c474c4fb907a.json b/backend-node/data/mail-sent/7bed27d5-dae4-4ba8-85d0-c474c4fb907a.json deleted file mode 100644 index d9edbdeb..00000000 --- a/backend-node/data/mail-sent/7bed27d5-dae4-4ba8-85d0-c474c4fb907a.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "id": "7bed27d5-dae4-4ba8-85d0-c474c4fb907a", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [], - "cc": [], - "bcc": [], - "subject": "Fwd: ㅏㅣ", - "htmlContent": "\n

\n
\n

---------- 전달된 메일 ----------

\n

보낸사람: \"이희진\"

\n

날짜: 2025. 10. 22. 오후 1:32:34

\n

제목: ㅏㅣ

\n
\n undefined\n
\n ", - "sentAt": "2025-10-22T06:41:52.984Z", - "status": "draft", - "isDraft": true, - "updatedAt": "2025-10-22T06:46:23.051Z", - "deletedAt": "2025-10-22T07:50:29.124Z" -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/8990ea86-3112-4e7c-b3e0-8b494181c4e0.json b/backend-node/data/mail-sent/8990ea86-3112-4e7c-b3e0-8b494181c4e0.json deleted file mode 100644 index f0ed2dcf..00000000 --- a/backend-node/data/mail-sent/8990ea86-3112-4e7c-b3e0-8b494181c4e0.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "id": "8990ea86-3112-4e7c-b3e0-8b494181c4e0", - "accountName": "", - "accountEmail": "", - "to": [], - "subject": "", - "htmlContent": "", - "sentAt": "2025-10-22T06:17:31.379Z", - "status": "draft", - "isDraft": true, - "updatedAt": "2025-10-22T06:17:31.379Z", - "deletedAt": "2025-10-22T07:50:30.736Z" -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/99703f2c-740c-492e-a866-a04289a9b699.json b/backend-node/data/mail-sent/99703f2c-740c-492e-a866-a04289a9b699.json deleted file mode 100644 index 1c6dc41f..00000000 --- a/backend-node/data/mail-sent/99703f2c-740c-492e-a866-a04289a9b699.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "id": "99703f2c-740c-492e-a866-a04289a9b699", - "accountName": "", - "accountEmail": "", - "to": [], - "subject": "", - "htmlContent": "", - "sentAt": "2025-10-22T06:20:08.450Z", - "status": "draft", - "isDraft": true, - "updatedAt": "2025-10-22T06:20:08.450Z", - "deletedAt": "2025-10-22T06:36:07.797Z" -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/9ab1e5ee-4f5e-4b79-9769-5e2a1e1ffc8e.json b/backend-node/data/mail-sent/9ab1e5ee-4f5e-4b79-9769-5e2a1e1ffc8e.json deleted file mode 100644 index 31bde67a..00000000 --- a/backend-node/data/mail-sent/9ab1e5ee-4f5e-4b79-9769-5e2a1e1ffc8e.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "id": "9ab1e5ee-4f5e-4b79-9769-5e2a1e1ffc8e", - "sentAt": "2025-10-22T04:31:17.175Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "\"이희진\" " - ], - "subject": "Re: ㅅㄷㄴㅅ", - "htmlContent": "\r\n
\r\n

배불르고 졸린데 커피먹으니깐 졸린건 괜찮아졋고 배불러서 물배찼당아아아아

\r\n
\r\n

\r\n
\r\n

보낸 사람: \"이희진\"

\r\n

날짜: 2025. 10. 22. 오후 1:03:03

\r\n

제목: ㅅㄷㄴㅅ

\r\n
\r\n undefined\r\n
\r\n ", - "status": "success", - "messageId": "<0f215ba8-a1e4-8c5a-f43f-962f0717c161@wace.me>", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [], - "deletedAt": "2025-10-22T07:11:10.245Z" -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/9d0b9fcf-cabf-4053-b6b6-6e110add22de.json b/backend-node/data/mail-sent/9d0b9fcf-cabf-4053-b6b6-6e110add22de.json deleted file mode 100644 index 2ace7d67..00000000 --- a/backend-node/data/mail-sent/9d0b9fcf-cabf-4053-b6b6-6e110add22de.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "id": "9d0b9fcf-cabf-4053-b6b6-6e110add22de", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "cc": [], - "bcc": [], - "subject": "Re: ㅏㅣ", - "htmlContent": "\n

\n
\n

원본 메일:

\n

보낸사람: \"이희진\"

\n

날짜: 2025. 10. 22. 오후 1:32:34

\n

제목: ㅏㅣ

\n
\n

undefined

\n
\n ", - "sentAt": "2025-10-22T06:50:04.224Z", - "status": "draft", - "isDraft": true, - "updatedAt": "2025-10-22T06:50:04.224Z", - "deletedAt": "2025-10-22T07:50:26.224Z" -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/b293e530-2b2d-4b8a-8081-d103fab5a13f.json b/backend-node/data/mail-sent/b293e530-2b2d-4b8a-8081-d103fab5a13f.json deleted file mode 100644 index 77d9053f..00000000 --- a/backend-node/data/mail-sent/b293e530-2b2d-4b8a-8081-d103fab5a13f.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "id": "b293e530-2b2d-4b8a-8081-d103fab5a13f", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "cc": [], - "bcc": [], - "subject": "Re: 수신메일확인용", - "htmlContent": "\n

\n
\n

원본 메일:

\n

보낸사람: \"이희진\"

\n

날짜: 2025. 10. 13. 오전 10:40:30

\n

제목: 수신메일확인용

\n
\n undefined\n
\n ", - "sentAt": "2025-10-22T06:47:53.815Z", - "status": "draft", - "isDraft": true, - "updatedAt": "2025-10-22T06:48:53.876Z", - "deletedAt": "2025-10-22T07:50:27.706Z" -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/cf892a77-1998-4165-bb9d-b390451465b2.json b/backend-node/data/mail-sent/cf892a77-1998-4165-bb9d-b390451465b2.json deleted file mode 100644 index 426f81fb..00000000 --- a/backend-node/data/mail-sent/cf892a77-1998-4165-bb9d-b390451465b2.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "id": "cf892a77-1998-4165-bb9d-b390451465b2", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [], - "cc": [], - "bcc": [], - "subject": "Fwd: ㄴ", - "htmlContent": "\n\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n전달된 메일:\n\n보낸사람: \"이희진\" \n날짜: 2025. 10. 22. 오후 12:58:15\n제목: ㄴ\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\nㄴㅇㄹㄴㅇㄹㄴㅇㄹ\n", - "sentAt": "2025-10-22T07:06:11.620Z", - "status": "draft", - "isDraft": true, - "updatedAt": "2025-10-22T07:07:11.749Z", - "deletedAt": "2025-10-22T07:50:15.739Z" -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/e3501abc-cd31-4b20-bb02-3c7ddbe54eb8.json b/backend-node/data/mail-sent/e3501abc-cd31-4b20-bb02-3c7ddbe54eb8.json deleted file mode 100644 index cf31f7dc..00000000 --- a/backend-node/data/mail-sent/e3501abc-cd31-4b20-bb02-3c7ddbe54eb8.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "id": "e3501abc-cd31-4b20-bb02-3c7ddbe54eb8", - "accountName": "", - "accountEmail": "", - "to": [], - "subject": "", - "htmlContent": "", - "sentAt": "2025-10-22T06:15:02.128Z", - "status": "draft", - "isDraft": true, - "updatedAt": "2025-10-22T06:15:02.128Z", - "deletedAt": "2025-10-22T07:08:43.543Z" -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/eb92ed00-cc4f-4cc8-94c9-9bef312d16db.json b/backend-node/data/mail-sent/eb92ed00-cc4f-4cc8-94c9-9bef312d16db.json deleted file mode 100644 index 0c19dc0c..00000000 --- a/backend-node/data/mail-sent/eb92ed00-cc4f-4cc8-94c9-9bef312d16db.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "id": "eb92ed00-cc4f-4cc8-94c9-9bef312d16db", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [], - "cc": [], - "bcc": [], - "subject": "메일 임시저장 테스트 4", - "htmlContent": "asd", - "sentAt": "2025-10-22T06:21:40.019Z", - "status": "draft", - "isDraft": true, - "updatedAt": "2025-10-22T06:21:40.019Z", - "deletedAt": "2025-10-22T06:36:05.306Z" -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/fd2a8b41-2e6e-4e5e-b8e8-63d31efc5082.json b/backend-node/data/mail-sent/fd2a8b41-2e6e-4e5e-b8e8-63d31efc5082.json deleted file mode 100644 index 073c20f0..00000000 --- a/backend-node/data/mail-sent/fd2a8b41-2e6e-4e5e-b8e8-63d31efc5082.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "id": "fd2a8b41-2e6e-4e5e-b8e8-63d31efc5082", - "sentAt": "2025-10-22T04:29:14.738Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "\"이희진\" " - ], - "subject": "Re: ㅅㄷㄴㅅ", - "htmlContent": "\r\n
\r\n

ㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㄴㅇㄹㄴㅇㄹ

\r\n
\r\n

\r\n
\r\n

보낸 사람: \"이희진\"

\r\n

날짜: 2025. 10. 22. 오후 1:03:03

\r\n

제목: ㅅㄷㄴㅅ

\r\n
\r\n undefined\r\n
\r\n ", - "attachments": [ - { - "filename": "test용 이미지2.png", - "originalName": "test용 이미지2.png", - "size": 0, - "path": "/app/uploads/mail-attachments/1761107350246-298369766.png", - "mimetype": "image/png" - } - ], - "status": "success", - "messageId": "", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [], - "deletedAt": "2025-10-22T07:11:12.907Z" -} \ No newline at end of file From 707328e765266f5e6a80a33b823db0c109d3e801 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 27 Nov 2025 11:32:19 +0900 Subject: [PATCH 10/25] =?UTF-8?q?REST=20API=E2=86=92DB=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EB=B0=B0=EC=B9=98=20=EB=B0=8F=20auth=5Ftokens=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/batchController.ts | 12 +- .../batchExecutionLogController.ts | 5 + .../controllers/batchManagementController.ts | 67 +- backend-node/src/database/RestApiConnector.ts | 53 +- .../src/services/batchExecutionLogService.ts | 5 +- .../src/services/batchExternalDbService.ts | 1049 +++++------------ .../src/services/batchSchedulerService.ts | 504 ++------ backend-node/src/services/batchService.ts | 869 +++++--------- .../src/types/batchExecutionLogTypes.ts | 2 + backend-node/src/types/batchTypes.ts | 99 +- .../admin/batch-management-new/page.tsx | 149 ++- .../(main)/admin/batchmng/edit/[id]/page.tsx | 66 +- .../components/admin/AdvancedBatchModal.tsx | 423 +++++++ .../widgets/yard-3d/DigitalTwinViewer.tsx | 98 +- .../widgets/yard-3d/HierarchyConfigPanel.tsx | 14 +- frontend/lib/api/batchManagement.ts | 8 +- 16 files changed, 1459 insertions(+), 1964 deletions(-) create mode 100644 frontend/components/admin/AdvancedBatchModal.tsx diff --git a/backend-node/src/controllers/batchController.ts b/backend-node/src/controllers/batchController.ts index 8a29e5bf..638edcd2 100644 --- a/backend-node/src/controllers/batchController.ts +++ b/backend-node/src/controllers/batchController.ts @@ -169,22 +169,18 @@ export class BatchController { static async getBatchConfigById(req: AuthenticatedRequest, res: Response) { try { const { id } = req.params; - const userCompanyCode = req.user?.companyCode; - const batchConfig = await BatchService.getBatchConfigById( - Number(id), - userCompanyCode - ); + const result = await BatchService.getBatchConfigById(Number(id)); - if (!batchConfig) { + if (!result.success || !result.data) { return res.status(404).json({ success: false, - message: "배치 설정을 찾을 수 없습니다.", + message: result.message || "배치 설정을 찾을 수 없습니다.", }); } return res.json({ success: true, - data: batchConfig, + data: result.data, }); } catch (error) { console.error("배치 설정 조회 오류:", error); diff --git a/backend-node/src/controllers/batchExecutionLogController.ts b/backend-node/src/controllers/batchExecutionLogController.ts index 84608731..1b0166ae 100644 --- a/backend-node/src/controllers/batchExecutionLogController.ts +++ b/backend-node/src/controllers/batchExecutionLogController.ts @@ -62,6 +62,11 @@ export class BatchExecutionLogController { try { const data: CreateBatchExecutionLogRequest = req.body; + // 멀티테넌시: company_code가 없으면 현재 사용자 회사 코드로 설정 + if (!data.company_code) { + data.company_code = req.user?.companyCode || "*"; + } + const result = await BatchExecutionLogService.createExecutionLog(data); if (result.success) { diff --git a/backend-node/src/controllers/batchManagementController.ts b/backend-node/src/controllers/batchManagementController.ts index d1be2311..61194485 100644 --- a/backend-node/src/controllers/batchManagementController.ts +++ b/backend-node/src/controllers/batchManagementController.ts @@ -265,8 +265,12 @@ export class BatchManagementController { try { // 실행 로그 생성 - executionLog = await BatchService.createExecutionLog({ + const { BatchExecutionLogService } = await import( + "../services/batchExecutionLogService" + ); + const logResult = await BatchExecutionLogService.createExecutionLog({ batch_config_id: Number(id), + company_code: batchConfig.company_code, execution_status: "RUNNING", start_time: startTime, total_records: 0, @@ -274,6 +278,14 @@ export class BatchManagementController { failed_records: 0, }); + if (!logResult.success || !logResult.data) { + throw new Error( + logResult.message || "배치 실행 로그를 생성할 수 없습니다." + ); + } + + executionLog = logResult.data; + // BatchSchedulerService의 executeBatchConfig 메서드 사용 (중복 로직 제거) const { BatchSchedulerService } = await import( "../services/batchSchedulerService" @@ -290,7 +302,7 @@ export class BatchManagementController { const duration = endTime.getTime() - startTime.getTime(); // 실행 로그 업데이트 (성공) - await BatchService.updateExecutionLog(executionLog.id, { + await BatchExecutionLogService.updateExecutionLog(executionLog.id, { execution_status: "SUCCESS", end_time: endTime, duration_ms: duration, @@ -406,22 +418,34 @@ export class BatchManagementController { paramName, paramValue, paramSource, + requestBody, } = req.body; - if (!apiUrl || !apiKey || !endpoint) { + // apiUrl, endpoint는 항상 필수 + if (!apiUrl || !endpoint) { return res.status(400).json({ success: false, - message: "API URL, API Key, 엔드포인트는 필수입니다.", + message: "API URL과 엔드포인트는 필수입니다.", + }); + } + + // GET 요청일 때만 API Key 필수 (POST/PUT/DELETE는 선택) + if ((!method || method === "GET") && !apiKey) { + return res.status(400).json({ + success: false, + message: "GET 메서드에서는 API Key가 필요합니다.", }); } console.log("🔍 REST API 미리보기 요청:", { apiUrl, endpoint, + method, paramType, paramName, paramValue, paramSource, + requestBody: requestBody ? "Included" : "None", }); // RestApiConnector 사용하여 데이터 조회 @@ -429,7 +453,7 @@ export class BatchManagementController { const connector = new RestApiConnector({ baseUrl: apiUrl, - apiKey: apiKey, + apiKey: apiKey || "", timeout: 30000, }); @@ -456,9 +480,28 @@ export class BatchManagementController { console.log("🔗 최종 엔드포인트:", finalEndpoint); - // 데이터 조회 (최대 5개만) - GET 메서드만 지원 - const result = await connector.executeQuery(finalEndpoint, method); - console.log(`[previewRestApiData] executeQuery 결과:`, { + // Request Body 파싱 + let parsedBody = undefined; + if (requestBody && typeof requestBody === "string") { + try { + parsedBody = JSON.parse(requestBody); + } catch (e) { + console.warn("Request Body JSON 파싱 실패:", e); + // 파싱 실패 시 원본 문자열 사용하거나 무시 (상황에 따라 결정, 여기선 undefined로 처리하거나 에러 반환 가능) + // 여기서는 경고 로그 남기고 진행 + } + } else if (requestBody) { + parsedBody = requestBody; + } + + // 데이터 조회 - executeRequest 사용 (POST/PUT/DELETE 지원) + const result = await connector.executeRequest( + finalEndpoint, + method as "GET" | "POST" | "PUT" | "DELETE", + parsedBody + ); + + console.log(`[previewRestApiData] executeRequest 결과:`, { rowCount: result.rowCount, rowsLength: result.rows ? result.rows.length : "undefined", firstRow: @@ -532,15 +575,21 @@ export class BatchManagementController { apiMappings, }); + // 🔐 멀티테넌시: 현재 사용자 회사 코드 사용 (프론트에서 받지 않음) + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId; + // BatchService를 사용하여 배치 설정 저장 const batchConfig: CreateBatchConfigRequest = { batchName: batchName, description: description || "", cronSchedule: cronSchedule, + isActive: "Y", + companyCode, mappings: apiMappings, }; - const result = await BatchService.createBatchConfig(batchConfig); + const result = await BatchService.createBatchConfig(batchConfig, userId); if (result.success && result.data) { // 스케줄러에 자동 등록 ✅ diff --git a/backend-node/src/database/RestApiConnector.ts b/backend-node/src/database/RestApiConnector.ts index 2c2965aa..9fd68fe7 100644 --- a/backend-node/src/database/RestApiConnector.ts +++ b/backend-node/src/database/RestApiConnector.ts @@ -1,4 +1,5 @@ import axios, { AxiosInstance, AxiosResponse } from "axios"; +import https from "https"; import { DatabaseConnector, ConnectionConfig, @@ -24,16 +25,26 @@ export class RestApiConnector implements DatabaseConnector { constructor(config: RestApiConfig) { this.config = config; - // Axios 인스턴스 생성 + // 🔐 apiKey가 없을 수도 있으므로 Authorization 헤더는 선택적으로만 추가 + const defaultHeaders: Record = { + "Content-Type": "application/json", + Accept: "application/json", + }; + + if (config.apiKey) { + defaultHeaders["Authorization"] = `Bearer ${config.apiKey}`; + } + this.httpClient = axios.create({ baseURL: config.baseUrl, timeout: config.timeout || 30000, - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${config.apiKey}`, - Accept: "application/json", - }, + headers: defaultHeaders, + // ⚠️ 외부 API 중 자체 서명 인증서를 사용하는 경우가 있어서 + // 인증서 검증을 끈 HTTPS 에이전트를 사용한다. + // 내부망/신뢰된 시스템 전용으로 사용해야 하며, + // 공개 인터넷용 API에는 적용하면 안 된다. + httpsAgent: new https.Agent({ rejectUnauthorized: false }), }); // 요청/응답 인터셉터 설정 @@ -75,26 +86,16 @@ export class RestApiConnector implements DatabaseConnector { } async connect(): Promise { - try { - // 연결 테스트 - 기본 엔드포인트 호출 - await this.httpClient.get("/health", { timeout: 5000 }); - console.log(`[RestApiConnector] 연결 성공: ${this.config.baseUrl}`); - } catch (error) { - // health 엔드포인트가 없을 수 있으므로 404는 정상으로 처리 - if (axios.isAxiosError(error) && error.response?.status === 404) { - console.log( - `[RestApiConnector] 연결 성공 (health 엔드포인트 없음): ${this.config.baseUrl}` - ); - return; - } - console.error( - `[RestApiConnector] 연결 실패: ${this.config.baseUrl}`, - error - ); - throw new Error( - `REST API 연결 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}` - ); - } + // 기존에는 /health 엔드포인트를 호출해서 미리 연결을 검사했지만, + // 일반 외부 API들은 /health가 없거나 401/500을 반환하는 경우가 많아 + // 불필요하게 예외가 나면서 미리보기/배치 실행이 막히는 문제가 있었다. + // + // 따라서 여기서는 "연결 준비 완료" 정도만 로그로 남기고 + // 실제 호출 실패 여부는 executeRequest 단계에서만 판단하도록 한다. + console.log( + `[RestApiConnector] 연결 준비 완료 (사전 헬스체크 생략): ${this.config.baseUrl}` + ); + return; } async disconnect(): Promise { diff --git a/backend-node/src/services/batchExecutionLogService.ts b/backend-node/src/services/batchExecutionLogService.ts index f2fc583c..3561f43f 100644 --- a/backend-node/src/services/batchExecutionLogService.ts +++ b/backend-node/src/services/batchExecutionLogService.ts @@ -130,13 +130,14 @@ export class BatchExecutionLogService { try { const log = await queryOne( `INSERT INTO batch_execution_logs ( - batch_config_id, execution_status, start_time, end_time, + batch_config_id, company_code, execution_status, start_time, end_time, duration_ms, total_records, success_records, failed_records, error_message, error_details, server_name, process_id - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING *`, [ data.batch_config_id, + data.company_code, data.execution_status, data.start_time || new Date(), data.end_time, diff --git a/backend-node/src/services/batchExternalDbService.ts b/backend-node/src/services/batchExternalDbService.ts index 75d7ea67..18524085 100644 --- a/backend-node/src/services/batchExternalDbService.ts +++ b/backend-node/src/services/batchExternalDbService.ts @@ -77,45 +77,47 @@ export class BatchExternalDbService { } /** - * 배치관리용 테이블 목록 조회 + * 테이블 목록 조회 */ - static async getTablesFromConnection( - connectionType: "internal" | "external", - connectionId?: number + static async getTables( + connectionId: number ): Promise> { try { - let tables: TableInfo[] = []; - - if (connectionType === "internal") { - // 내부 DB 테이블 조회 - const result = await query<{ table_name: string }>( - `SELECT table_name - FROM information_schema.tables - WHERE table_schema = 'public' - AND table_type = 'BASE TABLE' - ORDER BY table_name`, - [] - ); - - tables = result.map((row) => ({ - table_name: row.table_name, - columns: [], - })); - } else if (connectionType === "external" && connectionId) { - // 외부 DB 테이블 조회 - const tablesResult = await this.getExternalTables(connectionId); - if (tablesResult.success && tablesResult.data) { - tables = tablesResult.data; - } + // 연결 정보 조회 + const connection = await this.getConnectionById(connectionId); + if (!connection) { + throw new Error("외부 DB 연결 정보를 찾을 수 없습니다."); } + // 커넥터 생성 + const connector = await DatabaseConnectorFactory.createConnector( + connection.db_type, + { + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username, + password: connection.password, + }, + connectionId + ); + + // 연결 + await connector.connect(); + + // 테이블 목록 조회 + const tables = await connector.getTables(); + + // 연결 종료 + await connector.disconnect(); + return { success: true, data: tables, message: `${tables.length}개의 테이블을 조회했습니다.`, }; } catch (error) { - console.error("배치관리 테이블 목록 조회 실패:", error); + console.error("테이블 목록 조회 실패:", error); return { success: false, message: "테이블 목록 조회 중 오류가 발생했습니다.", @@ -125,562 +127,283 @@ export class BatchExternalDbService { } /** - * 배치관리용 테이블 컬럼 정보 조회 + * 컬럼 목록 조회 */ - static async getTableColumns( - connectionType: "internal" | "external", - connectionId: number | undefined, - tableName: string - ): Promise> { - try { - console.log(`[BatchExternalDbService] getTableColumns 호출:`, { - connectionType, - connectionId, - tableName, - }); - - let columns: ColumnInfo[] = []; - - if (connectionType === "internal") { - // 내부 DB 컬럼 조회 - console.log( - `[BatchExternalDbService] 내부 DB 컬럼 조회 시작: ${tableName}` - ); - - const result = await query<{ - column_name: string; - data_type: string; - is_nullable: string; - column_default: string | null; - }>( - `SELECT - column_name, - data_type, - is_nullable, - column_default - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = $1 - ORDER BY ordinal_position`, - [tableName] - ); - - console.log(`[BatchExternalDbService] 내부 DB 컬럼 조회 결과:`, result); - - columns = result.map((row) => ({ - column_name: row.column_name, - data_type: row.data_type, - is_nullable: row.is_nullable, - column_default: row.column_default, - })); - } else if (connectionType === "external" && connectionId) { - // 외부 DB 컬럼 조회 - console.log( - `[BatchExternalDbService] 외부 DB 컬럼 조회 시작: connectionId=${connectionId}, tableName=${tableName}` - ); - - const columnsResult = await this.getExternalTableColumns( - connectionId, - tableName - ); - - console.log( - `[BatchExternalDbService] 외부 DB 컬럼 조회 결과:`, - columnsResult - ); - - if (columnsResult.success && columnsResult.data) { - columns = columnsResult.data; - } - } - - console.log(`[BatchExternalDbService] 최종 컬럼 목록:`, columns); - return { - success: true, - data: columns, - message: `${columns.length}개의 컬럼을 조회했습니다.`, - }; - } catch (error) { - console.error("[BatchExternalDbService] 컬럼 정보 조회 오류:", error); - return { - success: false, - message: "컬럼 정보 조회 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "알 수 없는 오류", - }; - } - } - - /** - * 외부 DB 테이블 목록 조회 (내부 구현) - */ - private static async getExternalTables( - connectionId: number - ): Promise> { - try { - // 연결 정보 조회 - const connection = await queryOne( - `SELECT * FROM external_db_connections WHERE id = $1`, - [connectionId] - ); - - if (!connection) { - return { - success: false, - message: "연결 정보를 찾을 수 없습니다.", - }; - } - - // 비밀번호 복호화 - const decryptedPassword = PasswordEncryption.decrypt(connection.password); - if (!decryptedPassword) { - return { - success: false, - message: "비밀번호 복호화에 실패했습니다.", - }; - } - - // 연결 설정 준비 - const config = { - host: connection.host, - port: connection.port, - database: connection.database_name, - user: connection.username, - password: decryptedPassword, - connectionTimeoutMillis: - connection.connection_timeout != null - ? connection.connection_timeout * 1000 - : undefined, - queryTimeoutMillis: - connection.query_timeout != null - ? connection.query_timeout * 1000 - : undefined, - ssl: - connection.ssl_enabled === "Y" - ? { rejectUnauthorized: false } - : false, - }; - - // DatabaseConnectorFactory를 통한 테이블 목록 조회 - const connector = await DatabaseConnectorFactory.createConnector( - connection.db_type, - config, - connectionId - ); - const tables = await connector.getTables(); - - return { - success: true, - message: "테이블 목록을 조회했습니다.", - data: tables, - }; - } catch (error) { - console.error("외부 DB 테이블 목록 조회 오류:", error); - return { - success: false, - message: "테이블 목록 조회 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "알 수 없는 오류", - }; - } - } - - /** - * 외부 DB 테이블 컬럼 정보 조회 (내부 구현) - */ - private static async getExternalTableColumns( + static async getColumns( connectionId: number, tableName: string ): Promise> { try { - console.log( - `[BatchExternalDbService] getExternalTableColumns 호출: connectionId=${connectionId}, tableName=${tableName}` - ); - // 연결 정보 조회 - const connection = await queryOne( - `SELECT * FROM external_db_connections WHERE id = $1`, - [connectionId] - ); - + const connection = await this.getConnectionById(connectionId); if (!connection) { - console.log( - `[BatchExternalDbService] 연결 정보를 찾을 수 없음: connectionId=${connectionId}` - ); - return { - success: false, - message: "연결 정보를 찾을 수 없습니다.", - }; + throw new Error("외부 DB 연결 정보를 찾을 수 없습니다."); } - console.log(`[BatchExternalDbService] 연결 정보 조회 성공:`, { - id: connection.id, - connection_name: connection.connection_name, - db_type: connection.db_type, - host: connection.host, - port: connection.port, - database_name: connection.database_name, - }); - - // 비밀번호 복호화 - const decryptedPassword = PasswordEncryption.decrypt(connection.password); - - // 연결 설정 준비 - const config = { - host: connection.host, - port: connection.port, - database: connection.database_name, - user: connection.username, - password: decryptedPassword, - connectionTimeoutMillis: - connection.connection_timeout != null - ? connection.connection_timeout * 1000 - : undefined, - queryTimeoutMillis: - connection.query_timeout != null - ? connection.query_timeout * 1000 - : undefined, - ssl: - connection.ssl_enabled === "Y" - ? { rejectUnauthorized: false } - : false, - }; - - console.log( - `[BatchExternalDbService] 커넥터 생성 시작: db_type=${connection.db_type}` - ); - - // 데이터베이스 타입에 따른 커넥터 생성 + // 커넥터 생성 const connector = await DatabaseConnectorFactory.createConnector( connection.db_type, - config, + { + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username, + password: connection.password, + }, connectionId ); - console.log( - `[BatchExternalDbService] 커넥터 생성 완료, 컬럼 조회 시작: tableName=${tableName}` - ); + // 연결 + await connector.connect(); - // 컬럼 정보 조회 - console.log(`[BatchExternalDbService] connector.getColumns 호출 전`); + // 컬럼 목록 조회 const columns = await connector.getColumns(tableName); - console.log(`[BatchExternalDbService] 원본 컬럼 조회 결과:`, columns); - console.log( - `[BatchExternalDbService] 원본 컬럼 개수:`, - columns ? columns.length : "null/undefined" - ); + // 연결 종료 + await connector.disconnect(); - // 각 데이터베이스 커넥터의 반환 구조가 다르므로 통일된 구조로 변환 - const standardizedColumns: ColumnInfo[] = columns.map((col: any) => { - console.log(`[BatchExternalDbService] 컬럼 변환 중:`, col); - - // MySQL/MariaDB 구조: {name, dataType, isNullable, defaultValue} (MySQLConnector만) - if (col.name && col.dataType !== undefined) { - const result = { - column_name: col.name, - data_type: col.dataType, - is_nullable: col.isNullable ? "YES" : "NO", - column_default: col.defaultValue || null, - }; - console.log( - `[BatchExternalDbService] MySQL/MariaDB 구조로 변환:`, - result - ); - return result; - } - // PostgreSQL/Oracle/MSSQL/MariaDB 구조: {column_name, data_type, is_nullable, column_default} - else { - const result = { - column_name: col.column_name || col.COLUMN_NAME, - data_type: col.data_type || col.DATA_TYPE, - is_nullable: - col.is_nullable || - col.IS_NULLABLE || - (col.nullable === "Y" ? "YES" : "NO"), - column_default: col.column_default || col.COLUMN_DEFAULT || null, - }; - console.log(`[BatchExternalDbService] 표준 구조로 변환:`, result); - return result; - } - }); - - console.log( - `[BatchExternalDbService] 표준화된 컬럼 목록:`, - standardizedColumns - ); - - // 빈 배열인 경우 경고 로그 - if (!standardizedColumns || standardizedColumns.length === 0) { - console.warn( - `[BatchExternalDbService] 컬럼이 비어있음: connectionId=${connectionId}, tableName=${tableName}` - ); - console.warn(`[BatchExternalDbService] 연결 정보:`, { - db_type: connection.db_type, - host: connection.host, - port: connection.port, - database_name: connection.database_name, - username: connection.username, - }); - - // 테이블 존재 여부 확인 - console.warn( - `[BatchExternalDbService] 테이블 존재 여부 확인을 위해 테이블 목록 조회 시도` - ); - try { - const tables = await connector.getTables(); - console.warn( - `[BatchExternalDbService] 사용 가능한 테이블 목록:`, - tables.map((t) => t.table_name) - ); - - // 테이블명이 정확한지 확인 - const tableExists = tables.some( - (t) => t.table_name.toLowerCase() === tableName.toLowerCase() - ); - console.warn( - `[BatchExternalDbService] 테이블 존재 여부: ${tableExists}` - ); - - // 정확한 테이블명 찾기 - const exactTable = tables.find( - (t) => t.table_name.toLowerCase() === tableName.toLowerCase() - ); - if (exactTable) { - console.warn( - `[BatchExternalDbService] 정확한 테이블명: ${exactTable.table_name}` - ); - } - - // 모든 테이블명 출력 - console.warn( - `[BatchExternalDbService] 모든 테이블명:`, - tables.map((t) => `"${t.table_name}"`) - ); - - // 테이블명 비교 - console.warn( - `[BatchExternalDbService] 요청된 테이블명: "${tableName}"` - ); - console.warn( - `[BatchExternalDbService] 테이블명 비교 결과:`, - tables.map((t) => ({ - table_name: t.table_name, - matches: t.table_name.toLowerCase() === tableName.toLowerCase(), - exact_match: t.table_name === tableName, - })) - ); - - // 정확한 테이블명으로 다시 시도 - if (exactTable && exactTable.table_name !== tableName) { - console.warn( - `[BatchExternalDbService] 정확한 테이블명으로 다시 시도: ${exactTable.table_name}` - ); - try { - const correctColumns = await connector.getColumns( - exactTable.table_name - ); - console.warn( - `[BatchExternalDbService] 정확한 테이블명으로 조회한 컬럼:`, - correctColumns - ); - } catch (correctError) { - console.error( - `[BatchExternalDbService] 정확한 테이블명으로 조회 실패:`, - correctError - ); - } - } - } catch (tableError) { - console.error( - `[BatchExternalDbService] 테이블 목록 조회 실패:`, - tableError - ); - } - } + // BatchColumnInfo 형식으로 변환 + const batchColumns: ColumnInfo[] = columns.map((col) => ({ + column_name: col.column_name, + data_type: col.data_type, + is_nullable: col.is_nullable, + column_default: col.column_default, + })); return { success: true, - data: standardizedColumns, - message: "컬럼 정보를 조회했습니다.", + data: batchColumns, + message: `${batchColumns.length}개의 컬럼을 조회했습니다.`, }; } catch (error) { - console.error( - "[BatchExternalDbService] 외부 DB 컬럼 정보 조회 오류:", - error - ); - console.error( - "[BatchExternalDbService] 오류 스택:", - error instanceof Error ? error.stack : "No stack trace" - ); + console.error("컬럼 목록 조회 실패:", error); return { success: false, - message: "컬럼 정보 조회 중 오류가 발생했습니다.", + message: "컬럼 목록 조회 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; } } /** - * 외부 DB 테이블에서 데이터 조회 + * 연결 정보 조회 (내부 메서드) + */ + private static async getConnectionById(id: number) { + const connections = await query( + `SELECT * FROM external_db_connections WHERE id = $1`, + [id] + ); + + if (connections.length === 0) { + return null; + } + + const connection = connections[0]; + + // 비밀번호 복호화 + if (connection.password) { + try { + const passwordEncryption = new PasswordEncryption(); + connection.password = passwordEncryption.decrypt(connection.password); + } catch (error) { + console.error("비밀번호 복호화 실패:", error); + // 복호화 실패 시 원본 사용 (또는 에러 처리) + } + } + + return connection; + } + + /** + * REST API 데이터 미리보기 + */ + static async previewRestApiData( + apiUrl: string, + apiKey: string, + endpoint: string, + method: "GET" | "POST" | "PUT" | "DELETE" = "GET", + paramInfo?: { + paramType: "url" | "query"; + paramName: string; + paramValue: string; + paramSource: "static" | "dynamic"; + }, + // 👇 body 파라미터 추가 + body?: string + ): Promise> { + try { + // REST API 커넥터 생성 + const connector = new RestApiConnector({ + baseUrl: apiUrl, + apiKey: apiKey, + timeout: 10000, // 미리보기는 짧은 타임아웃 + }); + + // 파라미터 적용 + let finalEndpoint = endpoint; + if ( + paramInfo && + paramInfo.paramName && + paramInfo.paramValue && + paramInfo.paramSource === "static" + ) { + if (paramInfo.paramType === "url") { + finalEndpoint = endpoint.replace( + `{${paramInfo.paramName}}`, + paramInfo.paramValue + ); + } else if (paramInfo.paramType === "query") { + const separator = endpoint.includes("?") ? "&" : "?"; + finalEndpoint = `${endpoint}${separator}${paramInfo.paramName}=${paramInfo.paramValue}`; + } + } + + // JSON body 파싱 + let requestData; + if (body) { + try { + requestData = JSON.parse(body); + } catch (e) { + console.warn("JSON 파싱 실패, 원본 문자열 전송"); + requestData = body; + } + } + + // 데이터 조회 (직접 RestApiConnector 메서드 호출) + // 타입 단언을 사용하여 private/protected 메서드 우회 또는 인터페이스 확장 필요 + // 여기서는 executeRequest가 public이라고 가정 + const result = await (connector as any).executeRequest( + finalEndpoint, + method, + requestData + ); + + return { + success: true, + data: result.data || result, // 데이터가 없으면 전체 결과 반환 + message: "데이터 미리보기 성공", + }; + } catch (error) { + return { + success: false, + message: "데이터 미리보기 실패", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; + } + } + + /** + * 외부 DB 테이블 데이터 조회 */ static async getDataFromTable( connectionId: number, - tableName: string, - limit: number = 100 + tableName: string ): Promise> { try { - console.log( - `[BatchExternalDbService] 외부 DB 데이터 조회: connectionId=${connectionId}, tableName=${tableName}` - ); - - // 외부 DB 연결 정보 조회 - const connection = await queryOne( - `SELECT * FROM external_db_connections WHERE id = $1`, - [connectionId] - ); - + // 연결 정보 조회 + const connection = await this.getConnectionById(connectionId); if (!connection) { - return { - success: false, - message: "외부 DB 연결을 찾을 수 없습니다.", - }; + throw new Error("외부 DB 연결 정보를 찾을 수 없습니다."); } - // 패스워드 복호화 - const decryptedPassword = PasswordEncryption.decrypt(connection.password); - - // DB 연결 설정 - const config = { - host: connection.host, - port: connection.port, - user: connection.username, - password: decryptedPassword, - database: connection.database_name, - }; - - // DB 커넥터 생성 + // 커넥터 생성 const connector = await DatabaseConnectorFactory.createConnector( - connection.db_type || "postgresql", - config, + connection.db_type, + { + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username, + password: connection.password, + }, connectionId ); - // 데이터 조회 (DB 타입에 따라 쿼리 구문 변경) - let query: string; - const dbType = connection.db_type?.toLowerCase() || "postgresql"; + // 연결 + await connector.connect(); - if (dbType === "oracle") { - query = `SELECT * FROM ${tableName} WHERE ROWNUM <= ${limit}`; - } else { - query = `SELECT * FROM ${tableName} LIMIT ${limit}`; - } - - console.log(`[BatchExternalDbService] 실행할 쿼리: ${query}`); - const result = await connector.executeQuery(query); - - console.log( - `[BatchExternalDbService] 외부 DB 데이터 조회 완료: ${result.rows.length}개 레코드` + // 데이터 조회 (기본 100건) + const result = await connector.executeQuery( + `SELECT * FROM ${tableName} LIMIT 100` ); + // 연결 종료 + await connector.disconnect(); + return { success: true, data: result.rows, + message: `${result.rows.length}개의 데이터를 조회했습니다.`, }; } catch (error) { - console.error( - `외부 DB 데이터 조회 오류 (connectionId: ${connectionId}, table: ${tableName}):`, - error - ); + console.error("테이블 데이터 조회 실패:", error); return { success: false, - message: "외부 DB 데이터 조회 중 오류가 발생했습니다.", + message: "테이블 데이터 조회 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; } } /** - * 외부 DB 테이블에서 특정 컬럼들만 조회 + * 외부 DB 테이블 데이터 조회 (컬럼 지정) */ static async getDataFromTableWithColumns( connectionId: number, tableName: string, - columns: string[], - limit: number = 100 + columns: string[] ): Promise> { try { - console.log( - `[BatchExternalDbService] 외부 DB 특정 컬럼 조회: connectionId=${connectionId}, tableName=${tableName}, columns=[${columns.join(", ")}]` - ); - - // 외부 DB 연결 정보 조회 - const connection = await queryOne( - `SELECT * FROM external_db_connections WHERE id = $1`, - [connectionId] - ); - + // 연결 정보 조회 + const connection = await this.getConnectionById(connectionId); if (!connection) { - return { - success: false, - message: "외부 DB 연결을 찾을 수 없습니다.", - }; + throw new Error("외부 DB 연결 정보를 찾을 수 없습니다."); } - // 패스워드 복호화 - const decryptedPassword = PasswordEncryption.decrypt(connection.password); - - // DB 연결 설정 - const config = { - host: connection.host, - port: connection.port, - user: connection.username, - password: decryptedPassword, - database: connection.database_name, - }; - - // DB 커넥터 생성 + // 커넥터 생성 const connector = await DatabaseConnectorFactory.createConnector( - connection.db_type || "postgresql", - config, + connection.db_type, + { + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username, + password: connection.password, + }, connectionId ); - // 데이터 조회 (DB 타입에 따라 쿼리 구문 변경) - let query: string; - const dbType = connection.db_type?.toLowerCase() || "postgresql"; - const columnList = columns.join(", "); + // 연결 + await connector.connect(); - if (dbType === "oracle") { - query = `SELECT ${columnList} FROM ${tableName} WHERE ROWNUM <= ${limit}`; - } else { - query = `SELECT ${columnList} FROM ${tableName} LIMIT ${limit}`; - } + // 컬럼 목록 쿼리 구성 + const columnString = columns.join(", "); - console.log(`[BatchExternalDbService] 실행할 쿼리: ${query}`); - const result = await connector.executeQuery(query); - - console.log( - `[BatchExternalDbService] 외부 DB 특정 컬럼 조회 완료: ${result.rows.length}개 레코드` + // 데이터 조회 (기본 100건) + const result = await connector.executeQuery( + `SELECT ${columnString} FROM ${tableName} LIMIT 100` ); + // 연결 종료 + await connector.disconnect(); + return { success: true, data: result.rows, + message: `${result.rows.length}개의 데이터를 조회했습니다.`, }; } catch (error) { - console.error( - `외부 DB 특정 컬럼 조회 오류 (connectionId: ${connectionId}, table: ${tableName}):`, - error - ); + console.error("테이블 데이터 조회 실패:", error); return { success: false, - message: "외부 DB 특정 컬럼 조회 중 오류가 발생했습니다.", + message: "테이블 데이터 조회 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; } } /** - * 외부 DB 테이블에 데이터 삽입 + * 테이블에 데이터 삽입 */ static async insertDataToTable( connectionId: number, @@ -688,147 +411,79 @@ export class BatchExternalDbService { data: any[] ): Promise> { try { - console.log( - `[BatchExternalDbService] 외부 DB 데이터 삽입: connectionId=${connectionId}, tableName=${tableName}, ${data.length}개 레코드` - ); - - if (!data || data.length === 0) { - return { - success: true, - data: { successCount: 0, failedCount: 0 }, - }; - } - - // 외부 DB 연결 정보 조회 - const connection = await queryOne( - `SELECT * FROM external_db_connections WHERE id = $1`, - [connectionId] - ); - + // 연결 정보 조회 + const connection = await this.getConnectionById(connectionId); if (!connection) { - return { - success: false, - message: "외부 DB 연결을 찾을 수 없습니다.", - }; + throw new Error("외부 DB 연결 정보를 찾을 수 없습니다."); } - // 패스워드 복호화 - const decryptedPassword = PasswordEncryption.decrypt(connection.password); - - // DB 연결 설정 - const config = { - host: connection.host, - port: connection.port, - user: connection.username, - password: decryptedPassword, - database: connection.database_name, - }; - - // DB 커넥터 생성 + // 커넥터 생성 const connector = await DatabaseConnectorFactory.createConnector( - connection.db_type || "postgresql", - config, + connection.db_type, + { + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username, + password: connection.password, + }, connectionId ); + // 연결 + await connector.connect(); + let successCount = 0; let failedCount = 0; - // 각 레코드를 개별적으로 삽입 (UPSERT 방식으로 중복 처리) - for (const record of data) { - try { - const columns = Object.keys(record); - const values = Object.values(record); + // 트랜잭션 시작 (지원하는 경우) + // await connector.beginTransaction(); - // 값들을 SQL 문자열로 변환 (타입별 처리) - const formattedValues = values - .map((value) => { - if (value === null || value === undefined) { - return "NULL"; - } else if (value instanceof Date) { - // Date 객체를 MySQL/MariaDB 형식으로 변환 - return `'${value.toISOString().slice(0, 19).replace("T", " ")}'`; - } else if (typeof value === "string") { - // 문자열이 날짜 형식인지 확인 - const dateRegex = - /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{2}\s+\d{4}\s+\d{2}:\d{2}:\d{2}/; - if (dateRegex.test(value)) { - // JavaScript Date 문자열을 MySQL 형식으로 변환 - const date = new Date(value); - return `'${date.toISOString().slice(0, 19).replace("T", " ")}'`; - } else { - return `'${value.replace(/'/g, "''")}'`; // SQL 인젝션 방지를 위한 간단한 이스케이프 - } - } else if (typeof value === "number") { - return String(value); - } else if (typeof value === "boolean") { - return value ? "1" : "0"; - } else { - // 기타 객체는 문자열로 변환 - return `'${String(value).replace(/'/g, "''")}'`; - } - }) - .join(", "); + try { + // 각 레코드를 개별적으로 삽입 + for (const record of data) { + try { + // 쿼리 빌더 사용 (간단한 구현) + const columns = Object.keys(record); + const values = Object.values(record); + const placeholders = values + .map((_, i) => (connection.db_type === "postgresql" ? `$${i + 1}` : "?")) + .join(", "); - // Primary Key 컬럼 추정 - const primaryKeyColumn = columns.includes("id") - ? "id" - : columns.includes("user_id") - ? "user_id" - : columns[0]; + const query = `INSERT INTO ${tableName} (${columns.join( + ", " + )}) VALUES (${placeholders})`; - // UPDATE SET 절 생성 (Primary Key 제외) - const updateColumns = columns.filter( - (col) => col !== primaryKeyColumn - ); - - let query: string; - const dbType = connection.db_type?.toLowerCase() || "mysql"; - - if (dbType === "mysql" || dbType === "mariadb") { - // MySQL/MariaDB: ON DUPLICATE KEY UPDATE 사용 - if (updateColumns.length > 0) { - const updateSet = updateColumns - .map((col) => `${col} = VALUES(${col})`) - .join(", "); - query = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${formattedValues}) - ON DUPLICATE KEY UPDATE ${updateSet}`; - } else { - // Primary Key만 있는 경우 IGNORE 사용 - query = `INSERT IGNORE INTO ${tableName} (${columns.join(", ")}) VALUES (${formattedValues})`; - } - } else { - // 다른 DB는 기본 INSERT 사용 - query = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${formattedValues})`; + // 파라미터 매핑 (PostgreSQL은 $1, $2..., MySQL은 ?) + await connector.executeQuery(query, values); + successCount++; + } catch (insertError) { + console.error("레코드 삽입 실패:", insertError); + failedCount++; } - - console.log(`[BatchExternalDbService] 실행할 쿼리: ${query}`); - console.log(`[BatchExternalDbService] 삽입할 데이터:`, record); - - await connector.executeQuery(query); - successCount++; - } catch (error) { - console.error(`외부 DB 레코드 UPSERT 실패:`, error); - failedCount++; } - } - console.log( - `[BatchExternalDbService] 외부 DB 데이터 삽입 완료: 성공 ${successCount}개, 실패 ${failedCount}개` - ); + // 트랜잭션 커밋 + // await connector.commit(); + } catch (txError) { + // 트랜잭션 롤백 + // await connector.rollback(); + throw txError; + } finally { + // 연결 종료 + await connector.disconnect(); + } return { success: true, data: { successCount, failedCount }, + message: `${successCount}건 성공, ${failedCount}건 실패`, }; } catch (error) { - console.error( - `외부 DB 데이터 삽입 오류 (connectionId: ${connectionId}, table: ${tableName}):`, - error - ); + console.error("데이터 삽입 실패:", error); return { success: false, - message: "외부 DB 데이터 삽입 중 오류가 발생했습니다.", + message: "데이터 삽입 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; } @@ -848,7 +503,9 @@ export class BatchExternalDbService { paramType?: "url" | "query", paramName?: string, paramValue?: string, - paramSource?: "static" | "dynamic" + paramSource?: "static" | "dynamic", + // 👇 body 파라미터 추가 + body?: string ): Promise> { try { console.log( @@ -895,47 +552,49 @@ export class BatchExternalDbService { ); } + // 👇 Body 파싱 (POST/PUT 요청 시) + let requestData; + if (body && (method === 'POST' || method === 'PUT')) { + try { + // 템플릿 변수가 있을 수 있으므로 여기서는 원본 문자열을 사용하거나 + // 정적 값만 파싱. 여기서는 일단 정적 JSON으로 가정하고 파싱 시도. + // (BatchScheduler에서 템플릿 처리 후 전달하는 것이 이상적이나, + // 현재 구조상 여기서 파싱 시도하고 실패하면 문자열 그대로 전송) + requestData = JSON.parse(body); + } catch (e) { + console.warn("JSON 파싱 실패, 원본 문자열 전송"); + requestData = body; + } + } + // 데이터 조회 (REST API는 executeRequest 사용) let result; if ((connector as any).executeRequest) { - result = await (connector as any).executeRequest(finalEndpoint, method); + // executeRequest(endpoint, method, data) + result = await (connector as any).executeRequest( + finalEndpoint, + method, + requestData // body 전달 + ); } else { + // Fallback (GET only) result = await connector.executeQuery(finalEndpoint); } - let data = result.rows; - // 컬럼 필터링 (지정된 컬럼만 추출) - if (columns && columns.length > 0) { - data = data.map((row: any) => { - const filteredRow: any = {}; - columns.forEach((col) => { - if (row.hasOwnProperty(col)) { - filteredRow[col] = row[col]; - } - }); - return filteredRow; - }); + let data = result.rows || result.data || result; + + // 👇 단일 객체 응답(토큰 등)인 경우 배열로 래핑하여 리스트처럼 처리 + if (!Array.isArray(data)) { + data = [data]; } - // 제한 개수 적용 - if (limit > 0) { - data = data.slice(0, limit); - } - - logger.info( - `[BatchExternalDbService] REST API 데이터 조회 완료: ${data.length}개 레코드` - ); - logger.info(`[BatchExternalDbService] 조회된 데이터`, { data }); - return { success: true, data: data, + message: `${data.length}개의 데이터를 조회했습니다.`, }; } catch (error) { - console.error( - `[BatchExternalDbService] REST API 데이터 조회 오류 (${apiUrl}${endpoint}):`, - error - ); + console.error("REST API 데이터 조회 실패:", error); return { success: false, message: "REST API 데이터 조회 중 오류가 발생했습니다.", @@ -1035,16 +694,15 @@ export class BatchExternalDbService { urlPathColumn && record[urlPathColumn] ) { - // /api/users → /api/users/user123 - finalEndpoint = `${endpoint}/${record[urlPathColumn]}`; + // endpoint 마지막에 ID 추가 (예: /api/users -> /api/users/123) + // 이미 /로 끝나는지 확인 + const separator = finalEndpoint.endsWith("/") ? "" : "/"; + finalEndpoint = `${finalEndpoint}${separator}${record[urlPathColumn]}`; + + console.log(`[BatchExternalDbService] 동적 엔드포인트: ${finalEndpoint}`); } - console.log( - `[BatchExternalDbService] 실행할 API 호출: ${method} ${finalEndpoint}` - ); - console.log(`[BatchExternalDbService] 전송할 데이터:`, requestData); - - // REST API는 executeRequest 사용 + // 데이터 전송 (REST API는 executeRequest 사용) if ((connector as any).executeRequest) { await (connector as any).executeRequest( finalEndpoint, @@ -1052,101 +710,32 @@ export class BatchExternalDbService { requestData ); } else { - await connector.executeQuery(finalEndpoint); + // Fallback + // @ts-ignore + await connector.httpClient.request({ + method: method, + url: finalEndpoint, + data: requestData + }); } + successCount++; - } catch (error) { - console.error(`REST API 레코드 전송 실패:`, error); + } catch (sendError) { + console.error("데이터 전송 실패:", sendError); failedCount++; } } - console.log( - `[BatchExternalDbService] 템플릿 기반 REST API 데이터 전송 완료: 성공 ${successCount}개, 실패 ${failedCount}개` - ); - return { success: true, data: { successCount, failedCount }, + message: `${successCount}건 성공, ${failedCount}건 실패`, }; } catch (error) { - console.error( - `[BatchExternalDbService] 템플릿 기반 REST API 데이터 전송 오류:`, - error - ); + console.error("데이터 전송 실패:", error); return { success: false, - message: `REST API 데이터 전송 실패: ${error}`, - data: { successCount: 0, failedCount: 0 }, - }; - } - } - - /** - * REST API로 데이터 전송 (기존 메서드) - */ - static async sendDataToRestApi( - apiUrl: string, - apiKey: string, - endpoint: string, - method: "POST" | "PUT" = "POST", - data: any[] - ): Promise> { - try { - console.log( - `[BatchExternalDbService] REST API 데이터 전송: ${apiUrl}${endpoint}, ${data.length}개 레코드` - ); - - // REST API 커넥터 생성 - const connector = new RestApiConnector({ - baseUrl: apiUrl, - apiKey: apiKey, - timeout: 30000, - }); - - // 연결 테스트 - await connector.connect(); - - let successCount = 0; - let failedCount = 0; - - // 각 레코드를 개별적으로 전송 - for (const record of data) { - try { - console.log( - `[BatchExternalDbService] 실행할 API 호출: ${method} ${endpoint}` - ); - console.log(`[BatchExternalDbService] 전송할 데이터:`, record); - - // REST API는 executeRequest 사용 - if ((connector as any).executeRequest) { - await (connector as any).executeRequest(endpoint, method, record); - } else { - await connector.executeQuery(endpoint); - } - successCount++; - } catch (error) { - console.error(`REST API 레코드 전송 실패:`, error); - failedCount++; - } - } - - console.log( - `[BatchExternalDbService] REST API 데이터 전송 완료: 성공 ${successCount}개, 실패 ${failedCount}개` - ); - - return { - success: true, - data: { successCount, failedCount }, - }; - } catch (error) { - console.error( - `[BatchExternalDbService] REST API 데이터 전송 오류 (${apiUrl}${endpoint}):`, - error - ); - return { - success: false, - message: "REST API 데이터 전송 중 오류가 발생했습니다.", + message: "데이터 전송 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; } diff --git a/backend-node/src/services/batchSchedulerService.ts b/backend-node/src/services/batchSchedulerService.ts index 77863904..5648b3a9 100644 --- a/backend-node/src/services/batchSchedulerService.ts +++ b/backend-node/src/services/batchSchedulerService.ts @@ -1,258 +1,114 @@ -// 배치 스케줄러 서비스 -// 작성일: 2024-12-24 - -import * as cron from "node-cron"; -import { query, queryOne } from "../database/db"; +import cron from "node-cron"; import { BatchService } from "./batchService"; import { BatchExecutionLogService } from "./batchExecutionLogService"; import { logger } from "../utils/logger"; export class BatchSchedulerService { private static scheduledTasks: Map = new Map(); - private static isInitialized = false; - private static executingBatches: Set = new Set(); // 실행 중인 배치 추적 /** - * 스케줄러 초기화 + * 모든 활성 배치의 스케줄링 초기화 */ - static async initialize() { + static async initializeScheduler() { try { - logger.info("배치 스케줄러 초기화 시작..."); + logger.info("배치 스케줄러 초기화 시작"); - // 기존 모든 스케줄 정리 (중복 방지) - this.clearAllSchedules(); + const batchConfigsResponse = await BatchService.getBatchConfigs({ + is_active: "Y", + }); - // 활성화된 배치 설정들을 로드하여 스케줄 등록 - await this.loadActiveBatchConfigs(); - - this.isInitialized = true; - logger.info("배치 스케줄러 초기화 완료"); - } catch (error) { - logger.error("배치 스케줄러 초기화 실패:", error); - throw error; - } - } - - /** - * 모든 스케줄 정리 - */ - private static clearAllSchedules() { - logger.info(`기존 스케줄 ${this.scheduledTasks.size}개 정리 중...`); - - for (const [id, task] of this.scheduledTasks) { - try { - task.stop(); - task.destroy(); - logger.info(`스케줄 정리 완료: ID ${id}`); - } catch (error) { - logger.error(`스케줄 정리 실패: ID ${id}`, error); - } - } - - this.scheduledTasks.clear(); - this.isInitialized = false; - logger.info("모든 스케줄 정리 완료"); - } - - /** - * 활성화된 배치 설정들을 로드하여 스케줄 등록 - */ - private static async loadActiveBatchConfigs() { - try { - const activeConfigs = await query( - `SELECT - bc.*, - json_agg( - json_build_object( - 'id', bm.id, - 'batch_config_id', bm.batch_config_id, - 'from_connection_type', bm.from_connection_type, - 'from_connection_id', bm.from_connection_id, - 'from_table_name', bm.from_table_name, - 'from_column_name', bm.from_column_name, - 'from_column_type', bm.from_column_type, - 'to_connection_type', bm.to_connection_type, - 'to_connection_id', bm.to_connection_id, - 'to_table_name', bm.to_table_name, - 'to_column_name', bm.to_column_name, - 'to_column_type', bm.to_column_type, - 'mapping_order', bm.mapping_order, - 'from_api_url', bm.from_api_url, - 'from_api_key', bm.from_api_key, - 'from_api_method', bm.from_api_method, - 'from_api_param_type', bm.from_api_param_type, - 'from_api_param_name', bm.from_api_param_name, - 'from_api_param_value', bm.from_api_param_value, - 'from_api_param_source', bm.from_api_param_source, - 'to_api_url', bm.to_api_url, - 'to_api_key', bm.to_api_key, - 'to_api_method', bm.to_api_method, - 'to_api_body', bm.to_api_body - ) - ) FILTER (WHERE bm.id IS NOT NULL) as batch_mappings - FROM batch_configs bc - LEFT JOIN batch_mappings bm ON bc.id = bm.batch_config_id - WHERE bc.is_active = 'Y' - GROUP BY bc.id`, - [] - ); - - logger.info(`활성화된 배치 설정 ${activeConfigs.length}개 발견`); - - for (const config of activeConfigs) { - await this.scheduleBatchConfig(config); - } - } catch (error) { - logger.error("활성화된 배치 설정 로드 실패:", error); - throw error; - } - } - - /** - * 배치 설정을 스케줄에 등록 - */ - static async scheduleBatchConfig(config: any) { - try { - const { id, batch_name, cron_schedule } = config; - - // 기존 스케줄이 있다면 제거 - if (this.scheduledTasks.has(id)) { - this.scheduledTasks.get(id)?.stop(); - this.scheduledTasks.delete(id); - } - - // cron 스케줄 유효성 검사 - if (!cron.validate(cron_schedule)) { - logger.error(`잘못된 cron 스케줄: ${cron_schedule} (배치 ID: ${id})`); + if (!batchConfigsResponse.success || !batchConfigsResponse.data) { + logger.warn("스케줄링할 활성 배치 설정이 없습니다."); return; } - // 새로운 스케줄 등록 - const task = cron.schedule(cron_schedule, async () => { - // 중복 실행 방지 체크 - if (this.executingBatches.has(id)) { - logger.warn( - `⚠️ 배치가 이미 실행 중입니다. 건너뜀: ${batch_name} (ID: ${id})` - ); - return; - } + const batchConfigs = batchConfigsResponse.data; + logger.info(`${batchConfigs.length}개의 배치 설정 스케줄링 등록`); - logger.info(`🔄 스케줄 배치 실행 시작: ${batch_name} (ID: ${id})`); + for (const config of batchConfigs) { + await this.scheduleBatch(config); + } - // 실행 중 플래그 설정 - this.executingBatches.add(id); + logger.info("배치 스케줄러 초기화 완료"); + } catch (error) { + logger.error("배치 스케줄러 초기화 중 오류 발생:", error); + } + } - try { - await this.executeBatchConfig(config); - } finally { - // 실행 완료 후 플래그 제거 - this.executingBatches.delete(id); - } + /** + * 개별 배치 작업 스케줄링 + */ + static async scheduleBatch(config: any) { + try { + // 기존 스케줄이 있으면 제거 + if (this.scheduledTasks.has(config.id)) { + this.scheduledTasks.get(config.id)?.stop(); + this.scheduledTasks.delete(config.id); + } + + if (config.is_active !== "Y") { + logger.info( + `배치 스케줄링 건너뜀 (비활성 상태): ${config.batch_name} (ID: ${config.id})` + ); + return; + } + + if (!cron.validate(config.cron_schedule)) { + logger.error( + `유효하지 않은 Cron 표현식: ${config.cron_schedule} (Batch ID: ${config.id})` + ); + return; + } + + logger.info( + `배치 스케줄 등록: ${config.batch_name} (ID: ${config.id}, Cron: ${config.cron_schedule})` + ); + + const task = cron.schedule(config.cron_schedule, async () => { + logger.info( + `스케줄에 의한 배치 실행 시작: ${config.batch_name} (ID: ${config.id})` + ); + await this.executeBatchConfig(config); }); - // 스케줄 시작 (기본적으로 시작되지만 명시적으로 호출) - task.start(); - - this.scheduledTasks.set(id, task); - logger.info( - `배치 스케줄 등록 완료: ${batch_name} (ID: ${id}, Schedule: ${cron_schedule}) - 스케줄 시작됨` - ); + this.scheduledTasks.set(config.id, task); } catch (error) { - logger.error(`배치 스케줄 등록 실패 (ID: ${config.id}):`, error); + logger.error(`배치 스케줄링 중 오류 발생 (ID: ${config.id}):`, error); } } /** - * 배치 설정 스케줄 제거 - */ - static async unscheduleBatchConfig(batchConfigId: number) { - try { - if (this.scheduledTasks.has(batchConfigId)) { - this.scheduledTasks.get(batchConfigId)?.stop(); - this.scheduledTasks.delete(batchConfigId); - logger.info(`배치 스케줄 제거 완료 (ID: ${batchConfigId})`); - } - } catch (error) { - logger.error(`배치 스케줄 제거 실패 (ID: ${batchConfigId}):`, error); - } - } - - /** - * 배치 설정 업데이트 시 스케줄 재등록 + * 배치 스케줄 업데이트 (설정 변경 시 호출) */ static async updateBatchSchedule( configId: number, executeImmediately: boolean = true ) { try { - // 기존 스케줄 제거 - await this.unscheduleBatchConfig(configId); - - // 업데이트된 배치 설정 조회 - const configResult = await query( - `SELECT - bc.*, - json_agg( - json_build_object( - 'id', bm.id, - 'batch_config_id', bm.batch_config_id, - 'from_connection_type', bm.from_connection_type, - 'from_connection_id', bm.from_connection_id, - 'from_table_name', bm.from_table_name, - 'from_column_name', bm.from_column_name, - 'from_column_type', bm.from_column_type, - 'to_connection_type', bm.to_connection_type, - 'to_connection_id', bm.to_connection_id, - 'to_table_name', bm.to_table_name, - 'to_column_name', bm.to_column_name, - 'to_column_type', bm.to_column_type, - 'mapping_order', bm.mapping_order, - 'from_api_url', bm.from_api_url, - 'from_api_key', bm.from_api_key, - 'from_api_method', bm.from_api_method, - 'from_api_param_type', bm.from_api_param_type, - 'from_api_param_name', bm.from_api_param_name, - 'from_api_param_value', bm.from_api_param_value, - 'from_api_param_source', bm.from_api_param_source, - 'to_api_url', bm.to_api_url, - 'to_api_key', bm.to_api_key, - 'to_api_method', bm.to_api_method, - 'to_api_body', bm.to_api_body - ) - ) FILTER (WHERE bm.id IS NOT NULL) as batch_mappings - FROM batch_configs bc - LEFT JOIN batch_mappings bm ON bc.id = bm.batch_config_id - WHERE bc.id = $1 - GROUP BY bc.id`, - [configId] - ); - - const config = configResult[0] || null; - - if (!config) { - logger.warn(`배치 설정을 찾을 수 없습니다: ID ${configId}`); + const result = await BatchService.getBatchConfigById(configId); + if (!result.success || !result.data) { + // 설정이 없으면 스케줄 제거 + if (this.scheduledTasks.has(configId)) { + this.scheduledTasks.get(configId)?.stop(); + this.scheduledTasks.delete(configId); + } return; } - // 활성화된 배치만 다시 스케줄 등록 - if (config.is_active === "Y") { - await this.scheduleBatchConfig(config); - logger.info( - `배치 스케줄 업데이트 완료: ${config.batch_name} (ID: ${configId})` - ); + const config = result.data; - // 활성화 시 즉시 실행 (옵션) - if (executeImmediately) { - logger.info( - `🚀 배치 활성화 즉시 실행: ${config.batch_name} (ID: ${configId})` - ); - await this.executeBatchConfig(config); - } - } else { - logger.info( - `비활성화된 배치 스케줄 제거: ${config.batch_name} (ID: ${configId})` + // 스케줄 재등록 + await this.scheduleBatch(config); + + // 즉시 실행 옵션이 있으면 실행 + /* + if (executeImmediately && config.is_active === "Y") { + logger.info(`배치 설정 변경 후 즉시 실행: ${config.batch_name}`); + this.executeBatchConfig(config).catch((err) => + logger.error(`즉시 실행 중 오류 발생:`, err) ); } + */ } catch (error) { logger.error(`배치 스케줄 업데이트 실패: ID ${configId}`, error); } @@ -272,6 +128,7 @@ export class BatchSchedulerService { const executionLogResponse = await BatchExecutionLogService.createExecutionLog({ batch_config_id: config.id, + company_code: config.company_code, execution_status: "RUNNING", start_time: startTime, total_records: 0, @@ -313,21 +170,20 @@ export class BatchSchedulerService { // 성공 결과 반환 return result; } catch (error) { - logger.error(`배치 실행 실패: ${config.batch_name}`, error); + logger.error(`배치 실행 중 오류 발생: ${config.batch_name}`, error); // 실행 로그 업데이트 (실패) if (executionLog) { await BatchExecutionLogService.updateExecutionLog(executionLog.id, { - execution_status: "FAILED", + execution_status: "FAILURE", end_time: new Date(), duration_ms: Date.now() - startTime.getTime(), error_message: error instanceof Error ? error.message : "알 수 없는 오류", - error_details: error instanceof Error ? error.stack : String(error), }); } - // 실패 시에도 결과 반환 + // 실패 결과 반환 return { totalRecords: 0, successRecords: 0, @@ -379,6 +235,8 @@ export class BatchSchedulerService { const { BatchExternalDbService } = await import( "./batchExternalDbService" ); + + // 👇 Body 파라미터 추가 (POST 요청 시) const apiResult = await BatchExternalDbService.getDataFromRestApi( firstMapping.from_api_url!, firstMapping.from_api_key!, @@ -394,7 +252,9 @@ export class BatchSchedulerService { firstMapping.from_api_param_type, firstMapping.from_api_param_name, firstMapping.from_api_param_value, - firstMapping.from_api_param_source + firstMapping.from_api_param_source, + // 👇 Body 전달 (FROM - REST API - POST 요청) + firstMapping.from_api_body ); if (apiResult.success && apiResult.data) { @@ -416,6 +276,17 @@ export class BatchSchedulerService { totalRecords += fromData.length; // 컬럼 매핑 적용하여 TO 테이블 형식으로 변환 + // 유틸리티 함수: 점 표기법을 사용하여 중첩된 객체 값 가져오기 + const getValueByPath = (obj: any, path: string) => { + if (!path) return undefined; + // path가 'response.access_token' 처럼 점을 포함하는 경우 + if (path.includes(".")) { + return path.split(".").reduce((acc, part) => acc && acc[part], obj); + } + // 단순 키인 경우 + return obj[path]; + }; + const mappedData = fromData.map((row) => { const mappedRow: any = {}; for (const mapping of mappings) { @@ -428,8 +299,11 @@ export class BatchSchedulerService { mappedRow[mapping.from_column_name] = row[mapping.from_column_name]; } else { - // 기존 로직: to_column_name을 키로 사용 - mappedRow[mapping.to_column_name] = row[mapping.from_column_name]; + // REST API -> DB (POST 요청 포함) 또는 DB -> DB + // row[mapping.from_column_name] 대신 getValueByPath 사용 + const value = getValueByPath(row, mapping.from_column_name); + + mappedRow[mapping.to_column_name] = value; } } return mappedRow; @@ -482,22 +356,12 @@ export class BatchSchedulerService { ); } } else { - // 기존 REST API 전송 (REST API → DB 배치) - const apiResult = await BatchExternalDbService.sendDataToRestApi( - firstMapping.to_api_url!, - firstMapping.to_api_key!, - firstMapping.to_table_name, - (firstMapping.to_api_method as "POST" | "PUT") || "POST", - mappedData + // 기존 REST API 전송 (REST API → DB 배치) - 사실 이 경우는 거의 없음 (REST to REST) + // 지원하지 않음 + logger.warn( + "REST API -> REST API (단순 매핑)은 아직 지원하지 않습니다." ); - - if (apiResult.success && apiResult.data) { - insertResult = apiResult.data; - } else { - throw new Error( - `REST API 데이터 전송 실패: ${apiResult.message}` - ); - } + insertResult = { successCount: 0, failedCount: 0 }; } } else { // DB에 데이터 삽입 @@ -511,167 +375,13 @@ export class BatchSchedulerService { successRecords += insertResult.successCount; failedRecords += insertResult.failedCount; - - logger.info( - `테이블 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패` - ); } catch (error) { - logger.error(`테이블 처리 실패: ${tableKey}`, error); - failedRecords += 1; + logger.error(`테이블 처리 중 오류 발생: ${tableKey}`, error); + // 해당 테이블 처리 실패는 전체 실패로 간주하지 않고, 실패 카운트만 증가? + // 여기서는 일단 실패 로그만 남기고 계속 진행 (필요시 정책 변경) } } return { totalRecords, successRecords, failedRecords }; } - - /** - * 배치 매핑 처리 (기존 메서드 - 사용 안 함) - */ - private static async processBatchMappings(config: any) { - const { batch_mappings } = config; - let totalRecords = 0; - let successRecords = 0; - let failedRecords = 0; - - if (!batch_mappings || batch_mappings.length === 0) { - logger.warn(`배치 매핑이 없습니다: ${config.batch_name}`); - return { totalRecords, successRecords, failedRecords }; - } - - for (const mapping of batch_mappings) { - try { - logger.info( - `매핑 처리 시작: ${mapping.from_table_name} -> ${mapping.to_table_name}` - ); - - // FROM 테이블에서 데이터 조회 - const fromData = await this.getDataFromSource(mapping); - totalRecords += fromData.length; - - // TO 테이블에 데이터 삽입 - const insertResult = await this.insertDataToTarget(mapping, fromData); - successRecords += insertResult.successCount; - failedRecords += insertResult.failedCount; - - logger.info( - `매핑 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패` - ); - } catch (error) { - logger.error( - `매핑 처리 실패: ${mapping.from_table_name} -> ${mapping.to_table_name}`, - error - ); - failedRecords += 1; - } - } - - return { totalRecords, successRecords, failedRecords }; - } - - /** - * FROM 테이블에서 데이터 조회 - */ - private static async getDataFromSource(mapping: any) { - try { - if (mapping.from_connection_type === "internal") { - // 내부 DB에서 조회 - const result = await query( - `SELECT * FROM ${mapping.from_table_name}`, - [] - ); - return result; - } else { - // 외부 DB에서 조회 (구현 필요) - logger.warn("외부 DB 조회는 아직 구현되지 않았습니다."); - return []; - } - } catch (error) { - logger.error( - `FROM 테이블 데이터 조회 실패: ${mapping.from_table_name}`, - error - ); - throw error; - } - } - - /** - * TO 테이블에 데이터 삽입 - */ - private static async insertDataToTarget(mapping: any, data: any[]) { - let successCount = 0; - let failedCount = 0; - - try { - if (mapping.to_connection_type === "internal") { - // 내부 DB에 삽입 - for (const record of data) { - try { - // 매핑된 컬럼만 추출 - const mappedData = this.mapColumns(record, mapping); - - const columns = Object.keys(mappedData); - const values = Object.values(mappedData); - const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); - - await query( - `INSERT INTO ${mapping.to_table_name} (${columns.join(", ")}) VALUES (${placeholders})`, - values - ); - successCount++; - } catch (error) { - logger.error(`레코드 삽입 실패:`, error); - failedCount++; - } - } - } else { - // 외부 DB에 삽입 (구현 필요) - logger.warn("외부 DB 삽입은 아직 구현되지 않았습니다."); - failedCount = data.length; - } - } catch (error) { - logger.error( - `TO 테이블 데이터 삽입 실패: ${mapping.to_table_name}`, - error - ); - throw error; - } - - return { successCount, failedCount }; - } - - /** - * 컬럼 매핑 - */ - private static mapColumns(record: any, mapping: any) { - const mappedData: any = {}; - - // 단순한 컬럼 매핑 (실제로는 더 복잡한 로직 필요) - mappedData[mapping.to_column_name] = record[mapping.from_column_name]; - - return mappedData; - } - - /** - * 모든 스케줄 중지 - */ - static async stopAllSchedules() { - try { - for (const [id, task] of this.scheduledTasks) { - task.stop(); - logger.info(`배치 스케줄 중지: ID ${id}`); - } - this.scheduledTasks.clear(); - this.isInitialized = false; - logger.info("모든 배치 스케줄이 중지되었습니다."); - } catch (error) { - logger.error("배치 스케줄 중지 실패:", error); - } - } - - /** - * 현재 등록된 스케줄 목록 조회 - */ - static getScheduledTasks() { - return Array.from(this.scheduledTasks.keys()); - } } diff --git a/backend-node/src/services/batchService.ts b/backend-node/src/services/batchService.ts index 247b1ab8..41f20964 100644 --- a/backend-node/src/services/batchService.ts +++ b/backend-node/src/services/batchService.ts @@ -65,62 +65,43 @@ export class BatchService { const limit = filter.limit || 10; const offset = (page - 1) * limit; - // 배치 설정 조회 (매핑 포함 - 서브쿼리 사용) - const batchConfigs = await query( - `SELECT bc.id, bc.batch_name, bc.description, bc.cron_schedule, - bc.is_active, bc.company_code, bc.created_date, bc.created_by, - bc.updated_date, bc.updated_by, - COALESCE( - json_agg( - json_build_object( - 'id', bm.id, - 'batch_config_id', bm.batch_config_id, - 'from_connection_type', bm.from_connection_type, - 'from_connection_id', bm.from_connection_id, - 'from_table_name', bm.from_table_name, - 'from_column_name', bm.from_column_name, - 'to_connection_type', bm.to_connection_type, - 'to_connection_id', bm.to_connection_id, - 'to_table_name', bm.to_table_name, - 'to_column_name', bm.to_column_name, - 'mapping_order', bm.mapping_order - ) - ) FILTER (WHERE bm.id IS NOT NULL), - '[]' - ) as batch_mappings + // 전체 카운트 조회 + const countResult = await query<{ count: string }>( + `SELECT COUNT(*) as count FROM batch_configs bc ${whereClause}`, + values + ); + const total = parseInt(countResult[0].count); + const totalPages = Math.ceil(total / limit); + + // 목록 조회 + const configs = await query( + `SELECT bc.* FROM batch_configs bc - LEFT JOIN batch_mappings bm ON bc.id = bm.batch_config_id ${whereClause} - GROUP BY bc.id - ORDER BY bc.is_active DESC, bc.batch_name ASC - LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + ORDER BY bc.created_date DESC + LIMIT $${paramIndex++} OFFSET $${paramIndex++}`, [...values, limit, offset] ); - // 전체 개수 조회 - const countResult = await queryOne<{ count: string }>( - `SELECT COUNT(DISTINCT bc.id) as count - FROM batch_configs bc - ${whereClause}`, - values - ); - - const total = parseInt(countResult?.count || "0"); + // 매핑 정보 조회 (N+1 문제 해결을 위해 별도 쿼리 대신 여기서는 생략하고 상세 조회에서 처리) + // 하지만 목록에서도 간단한 정보는 필요할 수 있음 return { success: true, - data: batchConfigs as BatchConfig[], + data: configs as BatchConfig[], pagination: { page, limit, total, - totalPages: Math.ceil(total / limit), + totalPages, }, + message: `${configs.length}개의 배치 설정을 조회했습니다.`, }; } catch (error) { console.error("배치 설정 목록 조회 오류:", error); return { success: false, + data: [], message: "배치 설정 목록 조회에 실패했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; @@ -128,70 +109,56 @@ export class BatchService { } /** - * 특정 배치 설정 조회 (회사별) + * 특정 배치 설정 조회 (별칭) + */ + static async getBatchConfig(id: number): Promise { + const result = await this.getBatchConfigById(id); + if (!result.success || !result.data) { + return null; + } + return result.data; + } + + /** + * 배치 설정 상세 조회 */ static async getBatchConfigById( - id: number, - userCompanyCode?: string + id: number ): Promise> { try { - let query = `SELECT bc.id, bc.batch_name, bc.description, bc.cron_schedule, - bc.is_active, bc.company_code, bc.created_date, bc.created_by, - bc.updated_date, bc.updated_by, - COALESCE( - json_agg( - json_build_object( - 'id', bm.id, - 'batch_config_id', bm.batch_config_id, - 'from_connection_type', bm.from_connection_type, - 'from_connection_id', bm.from_connection_id, - 'from_table_name', bm.from_table_name, - 'from_column_name', bm.from_column_name, - 'from_column_type', bm.from_column_type, - 'to_connection_type', bm.to_connection_type, - 'to_connection_id', bm.to_connection_id, - 'to_table_name', bm.to_table_name, - 'to_column_name', bm.to_column_name, - 'to_column_type', bm.to_column_type, - 'mapping_order', bm.mapping_order - ) - ORDER BY bm.from_table_name ASC, bm.from_column_name ASC, bm.mapping_order ASC - ) FILTER (WHERE bm.id IS NOT NULL), - '[]' - ) as batch_mappings - FROM batch_configs bc - LEFT JOIN batch_mappings bm ON bc.id = bm.batch_config_id - WHERE bc.id = $1`; + // 배치 설정 조회 + const config = await queryOne( + `SELECT * FROM batch_configs WHERE id = $1`, + [id] + ); - const params: any[] = [id]; - let paramIndex = 2; - - // 회사별 필터링 (최고 관리자가 아닌 경우) - if (userCompanyCode && userCompanyCode !== "*") { - query += ` AND bc.company_code = $${paramIndex}`; - params.push(userCompanyCode); - } - - query += ` GROUP BY bc.id`; - - const batchConfig = await queryOne(query, params); - - if (!batchConfig) { + if (!config) { return { success: false, - message: "배치 설정을 찾을 수 없거나 권한이 없습니다.", + message: "배치 설정을 찾을 수 없습니다.", }; } + // 매핑 정보 조회 + const mappings = await query( + `SELECT * FROM batch_mappings WHERE batch_config_id = $1 ORDER BY mapping_order ASC`, + [id] + ); + + const batchConfig: BatchConfig = { + ...config, + batch_mappings: mappings, + } as BatchConfig; + return { success: true, - data: batchConfig as BatchConfig, + data: batchConfig, }; } catch (error) { - console.error("배치 설정 조회 오류:", error); + console.error("배치 설정 상세 조회 오류:", error); return { success: false, - message: "배치 설정 조회에 실패했습니다.", + message: "배치 설정 상세 조회에 실패했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; } @@ -210,10 +177,17 @@ export class BatchService { // 배치 설정 생성 const batchConfigResult = await client.query( `INSERT INTO batch_configs - (batch_name, description, cron_schedule, created_by, updated_by, created_date, updated_date) - VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) + (batch_name, description, cron_schedule, is_active, company_code, created_by, created_date, updated_date) + VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW()) RETURNING *`, - [data.batchName, data.description, data.cronSchedule, userId, userId] + [ + data.batchName, + data.description, + data.cronSchedule, + data.isActive || "Y", + data.companyCode, + userId, + ] ); const batchConfig = batchConfigResult.rows[0]; @@ -224,39 +198,41 @@ export class BatchService { const mapping = data.mappings[index]; const mappingResult = await client.query( `INSERT INTO batch_mappings - (batch_config_id, from_connection_type, from_connection_id, from_table_name, from_column_name, + (batch_config_id, company_code, from_connection_type, from_connection_id, from_table_name, from_column_name, from_column_type, from_api_url, from_api_key, from_api_method, from_api_param_type, - from_api_param_name, from_api_param_value, from_api_param_source, + from_api_param_name, from_api_param_value, from_api_param_source, from_api_body, to_connection_type, to_connection_id, to_table_name, to_column_name, to_column_type, to_api_url, to_api_key, to_api_method, to_api_body, mapping_order, created_by, created_date) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, NOW()) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, NOW()) RETURNING *`, [ - batchConfig.id, - mapping.from_connection_type, - mapping.from_connection_id, - mapping.from_table_name, - mapping.from_column_name, - mapping.from_column_type, - mapping.from_api_url, - mapping.from_api_key, - mapping.from_api_method, - mapping.from_api_param_type, - mapping.from_api_param_name, - mapping.from_api_param_value, - mapping.from_api_param_source, - mapping.to_connection_type, - mapping.to_connection_id, - mapping.to_table_name, - mapping.to_column_name, - mapping.to_column_type, - mapping.to_api_url, - mapping.to_api_key, - mapping.to_api_method, - mapping.to_api_body, - mapping.mapping_order || index + 1, - userId, - ] + batchConfig.id, + data.companyCode, // 멀티테넌시: 배치 설정과 동일한 company_code 사용 + mapping.from_connection_type, + mapping.from_connection_id, + mapping.from_table_name, + mapping.from_column_name, + mapping.from_column_type, + mapping.from_api_url, + mapping.from_api_key, + mapping.from_api_method, + mapping.from_api_param_type, + mapping.from_api_param_name, + mapping.from_api_param_value, + mapping.from_api_param_source, + mapping.from_api_body, // FROM REST API Body + mapping.to_connection_type, + mapping.to_connection_id, + mapping.to_table_name, + mapping.to_column_name, + mapping.to_column_type, + mapping.to_api_url, + mapping.to_api_key, + mapping.to_api_method, + mapping.to_api_body, + mapping.mapping_order || index + 1, + userId, + ] ); mappings.push(mappingResult.rows[0]); } @@ -292,35 +268,22 @@ export class BatchService { userCompanyCode?: string ): Promise> { try { - // 기존 배치 설정 확인 (회사 권한 체크 포함) - const existing = await this.getBatchConfigById(id, userCompanyCode); - if (!existing.success) { - return existing; + // 기존 설정 확인 + const existingResult = await this.getBatchConfigById(id); + if (!existingResult.success || !existingResult.data) { + throw new Error( + existingResult.message || "배치 설정을 찾을 수 없습니다." + ); } + const existingConfig = existingResult.data; - const existingConfig = await queryOne( - `SELECT bc.*, - COALESCE( - json_agg( - json_build_object( - 'id', bm.id, - 'batch_config_id', bm.batch_config_id - ) - ) FILTER (WHERE bm.id IS NOT NULL), - '[]' - ) as batch_mappings - FROM batch_configs bc - LEFT JOIN batch_mappings bm ON bc.id = bm.batch_config_id - WHERE bc.id = $1 - GROUP BY bc.id`, - [id] - ); - - if (!existingConfig) { - return { - success: false, - message: "배치 설정을 찾을 수 없습니다.", - }; + // 권한 체크 (회사 코드가 다르면 수정 불가) + if ( + userCompanyCode && + userCompanyCode !== "*" && + existingConfig.company_code !== userCompanyCode + ) { + throw new Error("수정 권한이 없습니다."); } // 트랜잭션으로 업데이트 @@ -373,15 +336,16 @@ export class BatchService { const mapping = data.mappings[index]; const mappingResult = await client.query( `INSERT INTO batch_mappings - (batch_config_id, from_connection_type, from_connection_id, from_table_name, from_column_name, + (batch_config_id, company_code, from_connection_type, from_connection_id, from_table_name, from_column_name, from_column_type, from_api_url, from_api_key, from_api_method, from_api_param_type, - from_api_param_name, from_api_param_value, from_api_param_source, + from_api_param_name, from_api_param_value, from_api_param_source, from_api_body, to_connection_type, to_connection_id, to_table_name, to_column_name, to_column_type, to_api_url, to_api_key, to_api_method, to_api_body, mapping_order, created_by, created_date) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, NOW()) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, NOW()) RETURNING *`, [ id, + existingConfig.company_code, // 기존 설정의 company_code 유지 mapping.from_connection_type, mapping.from_connection_id, mapping.from_table_name, @@ -394,6 +358,7 @@ export class BatchService { mapping.from_api_param_name, mapping.from_api_param_value, mapping.from_api_param_source, + mapping.from_api_body, // FROM REST API Body mapping.to_connection_type, mapping.to_connection_id, mapping.to_table_name, @@ -446,38 +411,26 @@ export class BatchService { userCompanyCode?: string ): Promise> { try { - // 기존 배치 설정 확인 (회사 권한 체크 포함) - const existing = await this.getBatchConfigById(id, userCompanyCode); - if (!existing.success) { - return { - success: false, - message: existing.message, - }; - } - - const existingConfig = await queryOne( - `SELECT * FROM batch_configs WHERE id = $1`, - [id] - ); - - if (!existingConfig) { - return { - success: false, - message: "배치 설정을 찾을 수 없습니다.", - }; - } - - // 트랜잭션으로 삭제 - await transaction(async (client) => { - // 배치 매핑 먼저 삭제 (외래키 제약) - await client.query( - `DELETE FROM batch_mappings WHERE batch_config_id = $1`, - [id] + // 기존 설정 확인 + const existingResult = await this.getBatchConfigById(id); + if (!existingResult.success || !existingResult.data) { + throw new Error( + existingResult.message || "배치 설정을 찾을 수 없습니다." ); + } + const existingConfig = existingResult.data; - // 배치 설정 삭제 - await client.query(`DELETE FROM batch_configs WHERE id = $1`, [id]); - }); + // 권한 체크 + if ( + userCompanyCode && + userCompanyCode !== "*" && + existingConfig.company_code !== userCompanyCode + ) { + throw new Error("삭제 권한이 없습니다."); + } + + // 물리 삭제 (CASCADE 설정에 따라 매핑도 삭제됨) + await query(`DELETE FROM batch_configs WHERE id = $1`, [id]); return { success: true, @@ -494,93 +447,51 @@ export class BatchService { } /** - * 사용 가능한 커넥션 목록 조회 + * DB 연결 정보 조회 */ - static async getAvailableConnections(): Promise< - ApiResponse - > { + static async getConnections(): Promise> { try { - const connections: ConnectionInfo[] = []; - - // 내부 DB 추가 - connections.push({ - type: "internal", - name: "Internal Database", - db_type: "postgresql", - }); - - // 외부 DB 연결 조회 - const externalConnections = - await BatchExternalDbService.getAvailableConnections(); - - if (externalConnections.success && externalConnections.data) { - externalConnections.data.forEach((conn) => { - connections.push({ - type: "external", - id: conn.id, - name: conn.name, - db_type: conn.db_type, - }); - }); - } - - return { - success: true, - data: connections, - }; + // BatchExternalDbService 사용 + const result = await BatchExternalDbService.getAvailableConnections(); + return result; } catch (error) { - console.error("커넥션 목록 조회 오류:", error); + console.error("DB 연결 목록 조회 오류:", error); return { success: false, - message: "커넥션 목록 조회에 실패했습니다.", + data: [], + message: "DB 연결 목록 조회에 실패했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; } } /** - * 특정 커넥션의 테이블 목록 조회 + * 테이블 목록 조회 */ - static async getTablesFromConnection( + static async getTables( connectionType: "internal" | "external", connectionId?: number ): Promise> { try { - let tables: TableInfo[] = []; - if (connectionType === "internal") { // 내부 DB 테이블 조회 - const result = await query<{ table_name: string }>( - `SELECT table_name - FROM information_schema.tables - WHERE table_schema = 'public' - AND table_type = 'BASE TABLE' - ORDER BY table_name` - ); - tables = result.map((row) => ({ - table_name: row.table_name, - columns: [], - })); - } else if (connectionType === "external" && connectionId) { + const tables = await DbConnectionManager.getInternalTables(); + return { + success: true, + data: tables, + message: `${tables.length}개의 테이블을 조회했습니다.`, + }; + } else if (connectionId) { // 외부 DB 테이블 조회 - const tablesResult = - await BatchExternalDbService.getTablesFromConnection( - connectionType, - connectionId - ); - if (tablesResult.success && tablesResult.data) { - tables = tablesResult.data; - } + return await BatchExternalDbService.getTables(connectionId); + } else { + throw new Error("외부 연결 ID가 필요합니다."); } - - return { - success: true, - data: tables, - }; } catch (error) { console.error("테이블 목록 조회 오류:", error); return { success: false, + data: [], message: "테이블 목록 조회에 실패했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; @@ -588,185 +499,133 @@ export class BatchService { } /** - * 특정 테이블의 컬럼 정보 조회 + * 컬럼 목록 조회 */ - static async getTableColumns( + static async getColumns( + tableName: string, connectionType: "internal" | "external", - connectionId: number | undefined, - tableName: string + connectionId?: number ): Promise> { try { - console.log(`[BatchService] getTableColumns 호출:`, { - connectionType, - connectionId, - tableName, - }); - - let columns: ColumnInfo[] = []; - if (connectionType === "internal") { // 내부 DB 컬럼 조회 - console.log(`[BatchService] 내부 DB 컬럼 조회 시작: ${tableName}`); - - const result = await query<{ - column_name: string; - data_type: string; - is_nullable: string; - column_default: string | null; - }>( - `SELECT - column_name, - data_type, - is_nullable, - column_default - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = $1 - ORDER BY ordinal_position`, - [tableName] - ); - - console.log(`[BatchService] 내부 DB 컬럼 조회 결과:`, result); - - columns = result.map((row) => ({ - column_name: row.column_name, - data_type: row.data_type, - is_nullable: row.is_nullable, - column_default: row.column_default, - })); - } else if (connectionType === "external" && connectionId) { + const columns = await DbConnectionManager.getInternalColumns(tableName); + return { + success: true, + data: columns, + message: `${columns.length}개의 컬럼을 조회했습니다.`, + }; + } else if (connectionId) { // 외부 DB 컬럼 조회 - console.log( - `[BatchService] 외부 DB 컬럼 조회 시작: connectionId=${connectionId}, tableName=${tableName}` - ); - - const columnsResult = await BatchExternalDbService.getTableColumns( - connectionType, - connectionId, - tableName - ); - - console.log(`[BatchService] 외부 DB 컬럼 조회 결과:`, columnsResult); - - if (columnsResult.success && columnsResult.data) { - columns = columnsResult.data; - } - - console.log(`[BatchService] 외부 DB 컬럼:`, columns); + return await BatchExternalDbService.getColumns(connectionId, tableName); + } else { + throw new Error("외부 연결 ID가 필요합니다."); } - - return { - success: true, - data: columns, - }; } catch (error) { - console.error("컬럼 정보 조회 오류:", error); + console.error("컬럼 목록 조회 오류:", error); return { success: false, - message: "컬럼 정보 조회에 실패했습니다.", + data: [], + message: "컬럼 목록 조회에 실패했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; } } /** - * 배치 실행 로그 생성 + * 데이터 미리보기 */ - static async createExecutionLog(data: { - batch_config_id: number; - execution_status: string; - start_time: Date; - total_records: number; - success_records: number; - failed_records: number; - }): Promise { + static async previewData( + tableName: string, + connectionType: "internal" | "external", + connectionId?: number + ): Promise> { try { - const executionLog = await queryOne( - `INSERT INTO batch_execution_logs - (batch_config_id, execution_status, start_time, total_records, success_records, failed_records) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING *`, - [ - data.batch_config_id, - data.execution_status, - data.start_time, - data.total_records, - data.success_records, - data.failed_records, - ] - ); - - return executionLog; + if (connectionType === "internal") { + // 내부 DB 데이터 조회 + const data = await DbConnectionManager.getInternalData(tableName, 10); + return { + success: true, + data, + message: "데이터 미리보기 성공", + }; + } else if (connectionId) { + // 외부 DB 데이터 조회 + return await BatchExternalDbService.getDataFromTable( + connectionId, + tableName + ); + } else { + throw new Error("외부 연결 ID가 필요합니다."); + } } catch (error) { - console.error("배치 실행 로그 생성 오류:", error); - throw error; + console.error("데이터 미리보기 오류:", error); + return { + success: false, + data: [], + message: "데이터 미리보기에 실패했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; } } /** - * 배치 실행 로그 업데이트 + * REST API 데이터 미리보기 */ - static async updateExecutionLog( - id: number, - data: { - execution_status?: string; - end_time?: Date; - duration_ms?: number; - total_records?: number; - success_records?: number; - failed_records?: number; - error_message?: string; - } - ): Promise { + static async previewRestApiData( + apiUrl: string, + apiKey: string, + endpoint: string, + method: "GET" | "POST" | "PUT" | "DELETE" = "GET", + paramInfo?: { + paramType: "url" | "query"; + paramName: string; + paramValue: string; + paramSource: "static" | "dynamic"; + }, + body?: string + ): Promise> { try { - // 동적 UPDATE 쿼리 생성 - const updateFields: string[] = []; - const values: any[] = []; - let paramIndex = 1; - - if (data.execution_status !== undefined) { - updateFields.push(`execution_status = $${paramIndex++}`); - values.push(data.execution_status); - } - if (data.end_time !== undefined) { - updateFields.push(`end_time = $${paramIndex++}`); - values.push(data.end_time); - } - if (data.duration_ms !== undefined) { - updateFields.push(`duration_ms = $${paramIndex++}`); - values.push(data.duration_ms); - } - if (data.total_records !== undefined) { - updateFields.push(`total_records = $${paramIndex++}`); - values.push(data.total_records); - } - if (data.success_records !== undefined) { - updateFields.push(`success_records = $${paramIndex++}`); - values.push(data.success_records); - } - if (data.failed_records !== undefined) { - updateFields.push(`failed_records = $${paramIndex++}`); - values.push(data.failed_records); - } - if (data.error_message !== undefined) { - updateFields.push(`error_message = $${paramIndex++}`); - values.push(data.error_message); - } - - if (updateFields.length > 0) { - await query( - `UPDATE batch_execution_logs - SET ${updateFields.join(", ")} - WHERE id = $${paramIndex}`, - [...values, id] - ); - } + return await BatchExternalDbService.previewRestApiData( + apiUrl, + apiKey, + endpoint, + method, + paramInfo, + body + ); } catch (error) { - console.error("배치 실행 로그 업데이트 오류:", error); - throw error; + console.error("REST API 미리보기 오류:", error); + return { + success: false, + message: "REST API 미리보기에 실패했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; } } + /** + * 배치 유효성 검사 + */ + static async validateBatch( + config: Partial + ): Promise { + const errors: string[] = []; + + if (!config.batchName) errors.push("배치 작업명이 필요합니다."); + if (!config.cronSchedule) errors.push("Cron 스케줄이 필요합니다."); + if (!config.mappings || config.mappings.length === 0) { + errors.push("최소 하나 이상의 매핑이 필요합니다."); + } + + // 추가 유효성 검사 로직... + + return { + isValid: errors.length === 0, + errors, + }; + } + /** * 테이블에서 데이터 조회 (연결 타입에 따라 내부/외부 DB 구분) */ @@ -824,42 +683,33 @@ export class BatchService { ): Promise { try { console.log( - `[BatchService] 테이블에서 특정 컬럼 데이터 조회: ${tableName} (${columns.join(", ")}) (${connectionType}${connectionId ? `:${connectionId}` : ""})` + `[BatchService] 테이블에서 컬럼 지정 데이터 조회: ${tableName} (${connectionType})` ); if (connectionType === "internal") { - // 내부 DB에서 특정 컬럼만 조회 (주의: SQL 인젝션 위험 - 실제 프로덕션에서는 테이블명/컬럼명 검증 필요) - const columnList = columns.join(", "); + // 내부 DB + const columnString = columns.join(", "); const result = await query( - `SELECT ${columnList} FROM ${tableName} LIMIT 100` - ); - console.log( - `[BatchService] 내부 DB 특정 컬럼 조회 결과: ${result.length}개 레코드` + `SELECT ${columnString} FROM ${tableName} LIMIT 100` ); return result; } else if (connectionType === "external" && connectionId) { - // 외부 DB에서 특정 컬럼만 조회 + // 외부 DB const result = await BatchExternalDbService.getDataFromTableWithColumns( connectionId, tableName, columns ); if (result.success && result.data) { - console.log( - `[BatchService] 외부 DB 특정 컬럼 조회 결과: ${result.data.length}개 레코드` - ); return result.data; } else { - console.error(`외부 DB 특정 컬럼 조회 실패: ${result.message}`); - return []; + throw new Error(result.message || "외부 DB 조회 실패"); } } else { - throw new Error( - `잘못된 연결 타입 또는 연결 ID: ${connectionType}, ${connectionId}` - ); + throw new Error("잘못된 연결 설정입니다."); } } catch (error) { - console.error(`테이블 특정 컬럼 조회 오류 (${tableName}):`, error); + console.error(`데이터 조회 오류 (${tableName}):`, error); throw error; } } @@ -893,140 +743,27 @@ export class BatchService { // 각 레코드를 개별적으로 삽입 (UPSERT 방식으로 중복 처리) for (const record of data) { try { - // 동적 UPSERT 쿼리 생성 (PostgreSQL ON CONFLICT 사용) const columns = Object.keys(record); - const values = Object.values(record).map((value) => { - // Date 객체를 ISO 문자열로 변환 (PostgreSQL이 자동으로 파싱) - if (value instanceof Date) { - return value.toISOString(); - } - // JavaScript Date 문자열을 Date 객체로 변환 후 ISO 문자열로 - if (typeof value === "string") { - const dateRegex = - /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{2}\s+\d{4}\s+\d{2}:\d{2}:\d{2}/; - if (dateRegex.test(value)) { - return new Date(value).toISOString(); - } - // ISO 날짜 문자열 형식 체크 (2025-09-24T06:29:01.351Z) - const isoDateRegex = - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/; - if (isoDateRegex.test(value)) { - return new Date(value).toISOString(); - } - } - return value; - }); - - // PostgreSQL 타입 캐스팅을 위한 placeholder 생성 - const placeholders = columns - .map((col, index) => { - // 날짜/시간 관련 컬럼명 패턴 체크 - if ( - col.toLowerCase().includes("date") || - col.toLowerCase().includes("time") || - col.toLowerCase().includes("created") || - col.toLowerCase().includes("updated") || - col.toLowerCase().includes("reg") - ) { - return `$${index + 1}::timestamp`; - } - return `$${index + 1}`; - }) + const values = Object.values(record); + const placeholders = values + .map((_, i) => `$${i + 1}`) .join(", "); - // Primary Key 컬럼 추정 (일반적으로 id 또는 첫 번째 컬럼) - const primaryKeyColumn = columns.includes("id") - ? "id" - : columns.includes("user_id") - ? "user_id" - : columns[0]; - - // UPDATE SET 절 생성 (Primary Key 제외) - const updateColumns = columns.filter( - (col) => col !== primaryKeyColumn - ); - const updateSet = updateColumns - .map((col) => `${col} = EXCLUDED.${col}`) - .join(", "); - - // 트랜잭션 내에서 처리하여 연결 관리 최적화 - const result = await transaction(async (client) => { - // 먼저 해당 레코드가 존재하는지 확인 - const checkQuery = `SELECT COUNT(*) as count FROM ${tableName} WHERE ${primaryKeyColumn} = $1`; - const existsResult = await client.query(checkQuery, [ - record[primaryKeyColumn], - ]); - const exists = parseInt(existsResult.rows[0]?.count || "0") > 0; - - let operationResult = "no_change"; - - if (exists && updateSet) { - // 기존 레코드가 있으면 UPDATE (값이 다른 경우에만) - const whereConditions = updateColumns - .map((col, index) => { - // 날짜/시간 컬럼에 대한 타입 캐스팅 처리 - if ( - col.toLowerCase().includes("date") || - col.toLowerCase().includes("time") || - col.toLowerCase().includes("created") || - col.toLowerCase().includes("updated") || - col.toLowerCase().includes("reg") - ) { - return `${col} IS DISTINCT FROM $${index + 2}::timestamp`; - } - return `${col} IS DISTINCT FROM $${index + 2}`; - }) - .join(" OR "); - - const query = `UPDATE ${tableName} SET ${updateSet.replace(/EXCLUDED\./g, "")} - WHERE ${primaryKeyColumn} = $1 AND (${whereConditions})`; - - // 파라미터: [primaryKeyValue, ...updateValues] - const updateValues = [ - record[primaryKeyColumn], - ...updateColumns.map((col) => record[col]), - ]; - const updateResult = await client.query(query, updateValues); - - if (updateResult.rowCount && updateResult.rowCount > 0) { - console.log( - `[BatchService] 레코드 업데이트: ${primaryKeyColumn}=${record[primaryKeyColumn]}` - ); - operationResult = "updated"; - } else { - console.log( - `[BatchService] 레코드 변경사항 없음: ${primaryKeyColumn}=${record[primaryKeyColumn]}` - ); - operationResult = "no_change"; - } - } else if (!exists) { - // 새 레코드 삽입 - const query = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders})`; - await client.query(query, values); - console.log( - `[BatchService] 새 레코드 삽입: ${primaryKeyColumn}=${record[primaryKeyColumn]}` - ); - operationResult = "inserted"; - } else { - console.log( - `[BatchService] 레코드 이미 존재 (변경사항 없음): ${primaryKeyColumn}=${record[primaryKeyColumn]}` - ); - operationResult = "no_change"; - } - - return operationResult; - }); + const queryStr = `INSERT INTO ${tableName} (${columns.join( + ", " + )}) VALUES (${placeholders})`; + await query(queryStr, values); successCount++; - } catch (error) { - console.error(`레코드 UPSERT 실패:`, error); + } catch (insertError) { + console.error( + `내부 DB 데이터 삽입 실패 (${tableName}):`, + insertError + ); failedCount++; } } - console.log( - `[BatchService] 내부 DB 데이터 삽입 완료: 성공 ${successCount}개, 실패 ${failedCount}개` - ); return { successCount, failedCount }; } else if (connectionType === "external" && connectionId) { // 외부 DB에 데이터 삽입 @@ -1035,84 +772,22 @@ export class BatchService { tableName, data ); + if (result.success && result.data) { - console.log( - `[BatchService] 외부 DB 데이터 삽입 완료: 성공 ${result.data.successCount}개, 실패 ${result.data.failedCount}개` - ); return result.data; } else { console.error(`외부 DB 데이터 삽입 실패: ${result.message}`); + // 실패 시 전체 실패로 간주하지 않고 0/전체 로 반환 return { successCount: 0, failedCount: data.length }; } } else { - console.log(`[BatchService] 연결 정보 디버그:`, { - connectionType, - connectionId, - }); throw new Error( `잘못된 연결 타입 또는 연결 ID: ${connectionType}, ${connectionId}` ); } } catch (error) { - console.error(`테이블 데이터 삽입 오류 (${tableName}):`, error); - throw error; + console.error(`데이터 삽입 오류 (${tableName}):`, error); + return { successCount: 0, failedCount: data ? data.length : 0 }; } } - - /** - * 배치 매핑 유효성 검사 - */ - private static async validateBatchMappings( - mappings: BatchMapping[] - ): Promise { - const errors: string[] = []; - const warnings: string[] = []; - - if (!mappings || mappings.length === 0) { - errors.push("최소 하나 이상의 매핑이 필요합니다."); - return { isValid: false, errors, warnings }; - } - - // n:1 매핑 검사 (여러 FROM이 같은 TO로 매핑되는 것 방지) - const toMappings = new Map(); - - mappings.forEach((mapping, index) => { - const toKey = `${mapping.to_connection_type}:${mapping.to_connection_id || "internal"}:${mapping.to_table_name}:${mapping.to_column_name}`; - - if (toMappings.has(toKey)) { - errors.push( - `매핑 ${index + 1}: TO 컬럼 '${mapping.to_table_name}.${mapping.to_column_name}'에 중복 매핑이 있습니다. n:1 매핑은 허용되지 않습니다.` - ); - } else { - toMappings.set(toKey, index); - } - }); - - // 1:n 매핑 경고 (같은 FROM에서 여러 TO로 매핑) - const fromMappings = new Map(); - - mappings.forEach((mapping, index) => { - const fromKey = `${mapping.from_connection_type}:${mapping.from_connection_id || "internal"}:${mapping.from_table_name}:${mapping.from_column_name}`; - - if (!fromMappings.has(fromKey)) { - fromMappings.set(fromKey, []); - } - fromMappings.get(fromKey)!.push(index); - }); - - fromMappings.forEach((indices, fromKey) => { - if (indices.length > 1) { - const [, , tableName, columnName] = fromKey.split(":"); - warnings.push( - `FROM 컬럼 '${tableName}.${columnName}'에서 ${indices.length}개의 TO 컬럼으로 매핑됩니다. (1:n 매핑)` - ); - } - }); - - return { - isValid: errors.length === 0, - errors, - warnings, - }; - } } diff --git a/backend-node/src/types/batchExecutionLogTypes.ts b/backend-node/src/types/batchExecutionLogTypes.ts index d966de7c..aa49fd4e 100644 --- a/backend-node/src/types/batchExecutionLogTypes.ts +++ b/backend-node/src/types/batchExecutionLogTypes.ts @@ -4,6 +4,7 @@ export interface BatchExecutionLog { id?: number; batch_config_id: number; + company_code?: string; execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED'; start_time: Date; end_time?: Date | null; @@ -19,6 +20,7 @@ export interface BatchExecutionLog { export interface CreateBatchExecutionLogRequest { batch_config_id: number; + company_code?: string; execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED'; start_time?: Date; end_time?: Date | null; diff --git a/backend-node/src/types/batchTypes.ts b/backend-node/src/types/batchTypes.ts index 24158a3d..1cbec196 100644 --- a/backend-node/src/types/batchTypes.ts +++ b/backend-node/src/types/batchTypes.ts @@ -1,86 +1,13 @@ -// 배치관리 타입 정의 -// 작성일: 2024-12-24 +import { ApiResponse, ColumnInfo } from './batchTypes'; -// 배치 타입 정의 -export type BatchType = 'db-to-db' | 'db-to-restapi' | 'restapi-to-db' | 'restapi-to-restapi'; - -export interface BatchTypeOption { - value: BatchType; - label: string; - description: string; -} - -export interface BatchConfig { - id?: number; - batch_name: string; - description?: string; - cron_schedule: string; - is_active?: string; - company_code?: string; - created_date?: Date; - created_by?: string; - updated_date?: Date; - updated_by?: string; - batch_mappings?: BatchMapping[]; -} - -export interface BatchMapping { - id?: number; - batch_config_id?: number; - - // FROM 정보 - from_connection_type: 'internal' | 'external' | 'restapi'; - from_connection_id?: number; - from_table_name: string; // DB: 테이블명, REST API: 엔드포인트 - from_column_name: string; // DB: 컬럼명, REST API: JSON 필드명 - from_column_type?: string; - from_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; // REST API 전용 - from_api_url?: string; // REST API 서버 URL - from_api_key?: string; // REST API 키 - from_api_param_type?: 'url' | 'query'; // API 파라미터 타입 - from_api_param_name?: string; // API 파라미터명 - from_api_param_value?: string; // API 파라미터 값 또는 템플릿 - from_api_param_source?: 'static' | 'dynamic'; // 파라미터 소스 타입 - - // TO 정보 - to_connection_type: 'internal' | 'external' | 'restapi'; - to_connection_id?: number; - to_table_name: string; // DB: 테이블명, REST API: 엔드포인트 - to_column_name: string; // DB: 컬럼명, REST API: JSON 필드명 - to_column_type?: string; - to_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; // REST API 전용 - to_api_url?: string; // REST API 서버 URL - to_api_key?: string; // REST API 키 - to_api_body?: string; // Request Body 템플릿 (DB → REST API 배치용) - - mapping_order?: number; - created_date?: Date; - created_by?: string; -} - -export interface BatchConfigFilter { - page?: number; - limit?: number; - batch_name?: string; - is_active?: string; - company_code?: string; - search?: string; -} - -export interface ConnectionInfo { +export interface BatchConnectionInfo { type: 'internal' | 'external'; id?: number; name: string; db_type?: string; } -export interface TableInfo { - table_name: string; - columns: ColumnInfo[]; - description?: string | null; -} - -export interface ColumnInfo { +export interface BatchColumnInfo { column_name: string; data_type: string; is_nullable?: string; @@ -100,6 +27,8 @@ export interface BatchMappingRequest { from_api_param_name?: string; // API 파라미터명 from_api_param_value?: string; // API 파라미터 값 또는 템플릿 from_api_param_source?: 'static' | 'dynamic'; // 파라미터 소스 타입 + // 👇 REST API Body 추가 (FROM - REST API에서 POST 요청 시 필요) + from_api_body?: string; to_connection_type: 'internal' | 'external' | 'restapi'; to_connection_id?: number; to_table_name: string; @@ -116,6 +45,8 @@ export interface CreateBatchConfigRequest { batchName: string; description?: string; cronSchedule: string; + isActive: 'Y' | 'N'; + companyCode: string; mappings: BatchMappingRequest[]; } @@ -123,25 +54,11 @@ export interface UpdateBatchConfigRequest { batchName?: string; description?: string; cronSchedule?: string; + isActive?: 'Y' | 'N'; mappings?: BatchMappingRequest[]; - isActive?: string; } export interface BatchValidationResult { isValid: boolean; errors: string[]; - warnings?: string[]; -} - -export interface ApiResponse { - success: boolean; - data?: T; - message?: string; - error?: string; - pagination?: { - page: number; - limit: number; - total: number; - totalPages: number; - }; } diff --git a/frontend/app/(main)/admin/batch-management-new/page.tsx b/frontend/app/(main)/admin/batch-management-new/page.tsx index f70d711a..e9d34340 100644 --- a/frontend/app/(main)/admin/batch-management-new/page.tsx +++ b/frontend/app/(main)/admin/batch-management-new/page.tsx @@ -52,7 +52,8 @@ export default function BatchManagementNewPage() { const [fromApiUrl, setFromApiUrl] = useState(""); const [fromApiKey, setFromApiKey] = useState(""); const [fromEndpoint, setFromEndpoint] = useState(""); - const [fromApiMethod, setFromApiMethod] = useState<'GET'>('GET'); // GET만 지원 + const [fromApiMethod, setFromApiMethod] = useState<'GET' | 'POST' | 'PUT' | 'DELETE'>('GET'); + const [fromApiBody, setFromApiBody] = useState(""); // Request Body (JSON) // REST API 파라미터 설정 const [apiParamType, setApiParamType] = useState<'none' | 'url' | 'query'>('none'); @@ -83,6 +84,8 @@ export default function BatchManagementNewPage() { // API 필드 → DB 컬럼 매핑 const [apiFieldMappings, setApiFieldMappings] = useState>({}); + // API 필드별 JSON 경로 오버라이드 (예: "response.access_token") + const [apiFieldPathOverrides, setApiFieldPathOverrides] = useState>({}); // 배치 타입 상태 const [batchType, setBatchType] = useState('restapi-to-db'); @@ -303,8 +306,15 @@ export default function BatchManagementNewPage() { // REST API 데이터 미리보기 const previewRestApiData = async () => { - if (!fromApiUrl || !fromApiKey || !fromEndpoint) { - toast.error("API URL, API Key, 엔드포인트를 모두 입력해주세요."); + // API URL, 엔드포인트는 항상 필수 + if (!fromApiUrl || !fromEndpoint) { + toast.error("API URL과 엔드포인트를 모두 입력해주세요."); + return; + } + + // GET 메서드일 때만 API 키 필수 + if (fromApiMethod === "GET" && !fromApiKey) { + toast.error("GET 메서드에서는 API 키를 입력해주세요."); return; } @@ -313,7 +323,7 @@ export default function BatchManagementNewPage() { const result = await BatchManagementAPI.previewRestApiData( fromApiUrl, - fromApiKey, + fromApiKey || "", fromEndpoint, fromApiMethod, // 파라미터 정보 추가 @@ -322,7 +332,9 @@ export default function BatchManagementNewPage() { paramName: apiParamName, paramValue: apiParamValue, paramSource: apiParamSource - } : undefined + } : undefined, + // Request Body 추가 (POST/PUT/DELETE) + (fromApiMethod === 'POST' || fromApiMethod === 'PUT' || fromApiMethod === 'DELETE') ? fromApiBody : undefined ); console.log("API 미리보기 결과:", result); @@ -370,31 +382,54 @@ export default function BatchManagementNewPage() { // 배치 타입별 검증 및 저장 if (batchType === 'restapi-to-db') { - const mappedFields = Object.keys(apiFieldMappings).filter(field => apiFieldMappings[field]); + const mappedFields = Object.keys(apiFieldMappings).filter( + (field) => apiFieldMappings[field] + ); if (mappedFields.length === 0) { toast.error("최소 하나의 API 필드를 DB 컬럼에 매핑해주세요."); return; } // API 필드 매핑을 배치 매핑 형태로 변환 - const apiMappings = mappedFields.map(apiField => ({ - from_connection_type: 'restapi' as const, - from_table_name: fromEndpoint, // API 엔드포인트 - from_column_name: apiField, // API 필드명 - from_api_url: fromApiUrl, - from_api_key: fromApiKey, - from_api_method: fromApiMethod, - // API 파라미터 정보 추가 - from_api_param_type: apiParamType !== 'none' ? apiParamType : undefined, - from_api_param_name: apiParamType !== 'none' ? apiParamName : undefined, - from_api_param_value: apiParamType !== 'none' ? apiParamValue : undefined, - from_api_param_source: apiParamType !== 'none' ? apiParamSource : undefined, - to_connection_type: toConnection?.type === 'internal' ? 'internal' : 'external', - to_connection_id: toConnection?.type === 'internal' ? undefined : toConnection?.id, - to_table_name: toTable, - to_column_name: apiFieldMappings[apiField], // 매핑된 DB 컬럼 - mapping_type: 'direct' as const - })); + const apiMappings = mappedFields.map((apiField) => { + const toColumnName = apiFieldMappings[apiField]; // 매핑된 DB 컬럼 (예: access_token) + + // 기본은 상위 필드 그대로 사용하되, + // 사용자가 JSON 경로를 직접 입력한 경우 해당 경로를 우선 사용 + let fromColumnName = apiField; + const overridePath = apiFieldPathOverrides[apiField]; + if (overridePath && overridePath.trim().length > 0) { + fromColumnName = overridePath.trim(); + } + + return { + from_connection_type: "restapi" as const, + from_table_name: fromEndpoint, // API 엔드포인트 + from_column_name: fromColumnName, // API 필드명 또는 중첩 경로 + from_api_url: fromApiUrl, + from_api_key: fromApiKey, + from_api_method: fromApiMethod, + from_api_body: + fromApiMethod === "POST" || + fromApiMethod === "PUT" || + fromApiMethod === "DELETE" + ? fromApiBody + : undefined, + // API 파라미터 정보 추가 + from_api_param_type: apiParamType !== "none" ? apiParamType : undefined, + from_api_param_name: apiParamType !== "none" ? apiParamName : undefined, + from_api_param_value: apiParamType !== "none" ? apiParamValue : undefined, + from_api_param_source: + apiParamType !== "none" ? apiParamSource : undefined, + to_connection_type: + toConnection?.type === "internal" ? "internal" : "external", + to_connection_id: + toConnection?.type === "internal" ? undefined : toConnection?.id, + to_table_name: toTable, + to_column_name: toColumnName, // 매핑된 DB 컬럼 + mapping_type: "direct" as const, + }; + }); console.log("REST API 배치 설정 저장:", { batchName, @@ -645,13 +680,19 @@ export default function BatchManagementNewPage() { />
- + setFromApiKey(e.target.value)} placeholder="ak_your_api_key_here" /> +

+ GET 메서드에서만 필수이며, POST/PUT/DELETE일 때는 선택 사항입니다. +

@@ -673,12 +714,33 @@ export default function BatchManagementNewPage() { GET (데이터 조회) + POST (데이터 조회/전송) + PUT + DELETE + {/* Request Body (POST/PUT/DELETE용) */} + {(fromApiMethod === 'POST' || fromApiMethod === 'PUT' || fromApiMethod === 'DELETE') && ( +
+ +