diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index ebf4c081..8228fe44 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -171,6 +171,7 @@ import outboundRoutes from "./routes/outboundRoutes"; // 출고관리 import outsourcingOutboundRoutes from "./routes/outsourcingOutboundRoutes"; // 외주출고 import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리 import quoteRoutes from "./routes/quoteRoutes"; // 견적관리 +import erpSyncRoutes from "./routes/erpSyncRoutes"; // ERP 마스터 동기화 (사원/부서/창고/거래처/계정과목) import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -334,6 +335,7 @@ app.use("/api/batch-configs", batchRoutes); app.use("/api/excel-mapping", excelMappingRoutes); // 엑셀 매핑 템플릿 app.use("/api/batch-management", batchManagementRoutes); app.use("/api/batch-execution-logs", batchExecutionLogRoutes); +app.use("/api/erp-sync", erpSyncRoutes); // ERP 마스터 동기화 (사원/부서/창고/거래처/계정과목) // app.use("/api/db-type-categories", dbTypeCategoryRoutes); // 파일이 존재하지 않음 app.use("/api/ddl", ddlRoutes); app.use("/api/entity-reference", entityReferenceRoutes); @@ -493,6 +495,31 @@ async function initializeServices() { logger.error(`❌ 마이그레이션 실패:`, error); } + // ERP 동기화 대상 테이블 점검 (account_code_info 신규, 누락 컬럼 보충) + try { + const { ensureErpTables } = await import("./services/erpTableMigration"); + await ensureErpTables(); + } catch (error) { + logger.error(`❌ ERP 테이블 점검 실패:`, error); + } + + // Amaranth (Wehago/RPS ERP) REST API 연결 프리셋 자동 시드 + // - 외부 REST API 연결 목록에 5개 마스터 동기화 엔드포인트(부서/사원/거래처/창고/계정과목)가 없으면 자동 등록 + try { + const { seedAmaranthPresets } = await import("./services/erpPresetSeedService"); + await seedAmaranthPresets(); + } catch (error) { + logger.error(`❌ Amaranth 프리셋 시드 실패:`, error); + } + + // Amaranth → 내부 DB 동기화 배치 5종 자동 시드 (위에서 만든 REST API 연결을 사용) + try { + const { seedAmaranthBatches } = await import("./services/erpBatchSeedService"); + await seedAmaranthBatches(); + } catch (error) { + logger.error(`❌ Amaranth 배치 시드 실패:`, error); + } + // 배치 스케줄러 초기화 try { await BatchSchedulerService.initializeScheduler(); diff --git a/backend-node/src/controllers/DashboardController.ts b/backend-node/src/controllers/DashboardController.ts index d1328bcd..49dc03bf 100644 --- a/backend-node/src/controllers/DashboardController.ts +++ b/backend-node/src/controllers/DashboardController.ts @@ -660,12 +660,13 @@ export class DashboardController { saveToHistory: connection.save_to_history === "Y", }; - // 인증 헤더 생성 (DB 토큰 등) + // 인증 헤더 생성 (DB 토큰, Wehago/Amaranth 등 — wehago는 urlPath 기반 서명) const authHeaders = await ExternalRestApiConnectionService.getAuthHeaders( connection.auth_type, connection.auth_config, - connection.company_code + connection.company_code, + requestConfig.url ); // 기존 헤더에 인증 헤더 병합 diff --git a/backend-node/src/controllers/batchManagementController.ts b/backend-node/src/controllers/batchManagementController.ts index ac023907..c86c826f 100644 --- a/backend-node/src/controllers/batchManagementController.ts +++ b/backend-node/src/controllers/batchManagementController.ts @@ -128,7 +128,7 @@ export class BatchManagementController { try { const { batchName, description, cronSchedule, mappings, isActive, - executionType, nodeFlowId, nodeFlowContext, + executionType, nodeFlowId, nodeFlowContext, rowFilterConfig, } = req.body; const companyCode = req.user?.companyCode; @@ -158,6 +158,7 @@ export class BatchManagementController { executionType: executionType || "mapping", nodeFlowId: nodeFlowId || null, nodeFlowContext: nodeFlowContext || null, + rowFilterConfig: rowFilterConfig || null, } as CreateBatchConfigRequest, req.user?.userId ); @@ -437,10 +438,74 @@ export class BatchManagementController { requestBody, authServiceName, // DB에서 토큰 가져올 서비스명 dataArrayPath, // 데이터 배열 경로 (예: response, data.items) + restApiConnectionId, // 등록된 REST API 연결 사용 (Wehago/Amaranth 인증 자동 처리) } = req.body; - // apiUrl, endpoint는 항상 필수 - if (!apiUrl || !endpoint) { + // 등록된 연결 모드면 연결의 base_url/endpoint를 기본값으로 사용 + let resolvedApiUrl = apiUrl; + let resolvedEndpoint = endpoint; + + if (restApiConnectionId) { + const { ExternalRestApiConnectionService } = await import( + "../services/externalRestApiConnectionService" + ); + const connResult = await ExternalRestApiConnectionService.getConnectionById( + Number(restApiConnectionId), + req.user?.companyCode + ); + if (!connResult.success || !connResult.data) { + return res.status(400).json({ + success: false, + message: "등록된 REST API 연결을 찾을 수 없습니다.", + }); + } + const conn = connResult.data; + + // 등록된 연결의 인증을 그대로 사용 (Wehago/Amaranth 포함) — testConnection이 처리 + const finalEndpoint = endpoint || conn.endpoint_path || ""; + const testRequest = { + id: conn.id, + base_url: conn.base_url, + endpoint: finalEndpoint, + method: method as any, + headers: conn.default_headers, + body: requestBody || conn.default_body, + auth_type: conn.auth_type, + auth_config: conn.auth_config, + timeout: 20000, + }; + const testResult = await ExternalRestApiConnectionService.testConnection( + testRequest as any, + req.user?.companyCode + ); + if (!testResult.success) { + return res.status(400).json({ + success: false, + message: testResult.message || "REST API 호출에 실패했습니다.", + }); + } + + // dataArrayPath 적용 + const getValueByPath = (obj: any, path: string): any => { + if (!path) return obj; + return path.split(".").reduce((acc, k) => (acc == null ? acc : acc[k]), obj); + }; + let extracted: any = testResult.response_data; + if (dataArrayPath) extracted = getValueByPath(extracted, dataArrayPath); + + const arr: any[] = Array.isArray(extracted) ? extracted : (extracted ? [extracted] : []); + const samples = arr.slice(0, 5); + const fields = samples.length > 0 && typeof samples[0] === "object" ? Object.keys(samples[0]) : []; + + return res.json({ + success: true, + data: { fields, samples, totalCount: arr.length }, + message: "미리보기 성공", + }); + } + + // apiUrl, endpoint는 항상 필수 (직접 입력 모드) + if (!resolvedApiUrl || !resolvedEndpoint) { return res.status(400).json({ success: false, message: "API URL과 엔드포인트는 필수입니다.", diff --git a/backend-node/src/controllers/erpSyncController.ts b/backend-node/src/controllers/erpSyncController.ts new file mode 100644 index 00000000..7b277e37 --- /dev/null +++ b/backend-node/src/controllers/erpSyncController.ts @@ -0,0 +1,154 @@ +/** + * ERP 마스터 동기화 컨트롤러 + * + * POST /api/erp-sync/employees { coCd, companyCode? } + * POST /api/erp-sync/departments { coCd, companyCode?, searchText? } + * POST /api/erp-sync/warehouses { coCd, companyCode?, baselocFg? } + * POST /api/erp-sync/customers { coCd, companyCode? } + * POST /api/erp-sync/account-codes { coCd, companyCode? } + * POST /api/erp-sync/all { coCd, companyCode? } ← 전체 일괄 + * + * 외부 시스템(타시스템)이 호출하면 외부 ERP에서 데이터를 가져와 내부 DB에 INSERT/UPDATE 합니다. + * 자동화 관리 > 배치 관리 화면의 "ERP 동기화" 버튼이 동일 엔드포인트를 호출합니다. + */ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { logger } from "../utils/logger"; +import { + syncEmployees, + syncDepartments, + syncWarehouses, + syncCustomers, + syncAccountCodes, + syncAll, + SyncResult, +} from "../services/erpSyncService"; +import { auditLogService, getClientIp } from "../services/auditLogService"; + +function resolveCompanyCode(req: AuthenticatedRequest): string { + const fromBody = (req.body?.companyCode || req.body?.company_code || "").toString().trim(); + if (fromBody) return fromBody; + const fromUser = req.user?.companyCode; + if (fromUser && fromUser !== "*") return fromUser; + // 단독 운영 회사 기본값 + return process.env.DEFAULT_COMPANY_CODE || "COMPANY_16"; +} + +function audit(req: AuthenticatedRequest, resource: string, summary: string, result: SyncResult | SyncResult[]) { + try { + auditLogService.log({ + companyCode: req.user?.companyCode || "", + userId: req.user?.userId || "system", + userName: req.user?.userName || "system", + action: "BATCH_UPDATE", + resourceType: "DATA", + resourceId: resource, + resourceName: resource, + tableName: resource, + summary, + changes: { after: result } as any, + ipAddress: getClientIp(req), + requestPath: req.originalUrl, + }); + } catch (e) { + // 감사 로그 실패는 동기화 결과에 영향 없음 + } +} + +export async function syncEmployeesHandler(req: AuthenticatedRequest, res: Response): Promise { + const coCd = (req.body?.coCd || req.body?.co_cd || "").toString().trim(); + if (!coCd) { + res.status(400).json({ success: false, message: "회사코드(coCd)는 필수입니다." }); + return; + } + const companyCode = resolveCompanyCode(req); + const executedBy = req.user?.userId || "system"; + logger.info("[erpSync] employees 시작", { coCd, companyCode }); + const result = await syncEmployees(coCd, companyCode, executedBy); + audit(req, "user_info", `사원 동기화: ${result.inserted}건 신규, ${result.updated}건 갱신`, result); + res.json({ success: result.errors.length === 0, data: result }); +} + +export async function syncDepartmentsHandler(req: AuthenticatedRequest, res: Response): Promise { + const coCd = (req.body?.coCd || req.body?.co_cd || "").toString().trim(); + if (!coCd) { + res.status(400).json({ success: false, message: "회사코드(coCd)는 필수입니다." }); + return; + } + const companyCode = resolveCompanyCode(req); + const searchText = (req.body?.searchText || "").toString().trim() || undefined; + const executedBy = req.user?.userId || "system"; + logger.info("[erpSync] departments 시작", { coCd, companyCode }); + const result = await syncDepartments(coCd, companyCode, searchText, executedBy); + audit(req, "dept_info", `부서 동기화: ${result.inserted}건 신규, ${result.updated}건 갱신`, result); + res.json({ success: result.errors.length === 0, data: result }); +} + +export async function syncWarehousesHandler(req: AuthenticatedRequest, res: Response): Promise { + const coCd = (req.body?.coCd || req.body?.co_cd || "").toString().trim(); + if (!coCd) { + res.status(400).json({ success: false, message: "회사코드(coCd)는 필수입니다." }); + return; + } + const companyCode = resolveCompanyCode(req); + const baselocFg = (req.body?.baselocFg || "").toString().trim() || undefined; + const executedBy = req.user?.userId || "system"; + logger.info("[erpSync] warehouses 시작", { coCd, companyCode }); + const result = await syncWarehouses(coCd, companyCode, baselocFg, executedBy); + audit(req, "warehouse_info", `창고 동기화: ${result.inserted}건 신규, ${result.updated}건 갱신`, result); + res.json({ success: result.errors.length === 0, data: result }); +} + +export async function syncCustomersHandler(req: AuthenticatedRequest, res: Response): Promise { + const coCd = (req.body?.coCd || req.body?.co_cd || "").toString().trim(); + if (!coCd) { + res.status(400).json({ success: false, message: "회사코드(coCd)는 필수입니다." }); + return; + } + const companyCode = resolveCompanyCode(req); + const executedBy = req.user?.userId || "system"; + logger.info("[erpSync] customers 시작", { coCd, companyCode }); + const result = await syncCustomers(coCd, companyCode, executedBy); + audit(req, "customer_mng", `거래처 동기화: ${result.inserted}건 신규, ${result.updated}건 갱신`, result); + res.json({ success: result.errors.length === 0, data: result }); +} + +export async function syncAccountCodesHandler(req: AuthenticatedRequest, res: Response): Promise { + const coCd = (req.body?.coCd || req.body?.co_cd || "").toString().trim(); + if (!coCd) { + res.status(400).json({ success: false, message: "회사코드(coCd)는 필수입니다." }); + return; + } + const companyCode = resolveCompanyCode(req); + const executedBy = req.user?.userId || "system"; + logger.info("[erpSync] accountCodes 시작", { coCd, companyCode }); + const result = await syncAccountCodes(coCd, companyCode, executedBy); + audit(req, "account_code_info", `계정과목 동기화: ${result.inserted}건 신규, ${result.updated}건 갱신`, result); + res.json({ success: result.errors.length === 0, data: result }); +} + +export async function syncAllHandler(req: AuthenticatedRequest, res: Response): Promise { + const coCd = (req.body?.coCd || req.body?.co_cd || "").toString().trim(); + if (!coCd) { + res.status(400).json({ success: false, message: "회사코드(coCd)는 필수입니다." }); + return; + } + const companyCode = resolveCompanyCode(req); + const executedBy = req.user?.userId || "system"; + logger.info("[erpSync] ALL 시작", { coCd, companyCode }); + const results = await syncAll(coCd, companyCode, executedBy); + const totals = results.reduce( + (acc, r) => { + acc.fetched += r.fetched; + acc.inserted += r.inserted; + acc.updated += r.updated; + acc.skipped += r.skipped; + acc.errorsCount += r.errors.length; + return acc; + }, + { fetched: 0, inserted: 0, updated: 0, skipped: 0, errorsCount: 0 } + ); + audit(req, "ALL", `전체 동기화: 신규 ${totals.inserted}건 / 갱신 ${totals.updated}건`, results); + const success = results.every((r) => r.errors.length === 0); + res.json({ success, totals, data: results }); +} diff --git a/backend-node/src/routes/erpSyncRoutes.ts b/backend-node/src/routes/erpSyncRoutes.ts new file mode 100644 index 00000000..dd808772 --- /dev/null +++ b/backend-node/src/routes/erpSyncRoutes.ts @@ -0,0 +1,27 @@ +/** + * ERP 마스터 동기화 라우트 + * 자동화 관리 > 배치 관리 화면에서 호출하는 ERP REST API → 내부 DB 적재 엔드포인트. + */ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { + syncEmployeesHandler, + syncDepartmentsHandler, + syncWarehousesHandler, + syncCustomersHandler, + syncAccountCodesHandler, + syncAllHandler, +} from "../controllers/erpSyncController"; + +const router = Router(); + +router.use(authenticateToken); + +router.post("/employees", syncEmployeesHandler); +router.post("/departments", syncDepartmentsHandler); +router.post("/warehouses", syncWarehousesHandler); +router.post("/customers", syncCustomersHandler); +router.post("/account-codes", syncAccountCodesHandler); +router.post("/all", syncAllHandler); + +export default router; diff --git a/backend-node/src/services/batchSchedulerService.ts b/backend-node/src/services/batchSchedulerService.ts index 8feba9d9..5e3dc314 100644 --- a/backend-node/src/services/batchSchedulerService.ts +++ b/backend-node/src/services/batchSchedulerService.ts @@ -299,7 +299,94 @@ export class BatchSchedulerService { // FROM 데이터 조회 (DB 또는 REST API) if (firstMapping.from_connection_type === "restapi") { - // REST API에서 데이터 조회 + // 등록된 REST API 연결 ID가 있으면 ExternalRestApiConnectionService 경유 호출 + // → Wehago/Amaranth 인증(매 요청 HMAC-SHA256) 자동 적용 + if (firstMapping.from_connection_id) { + logger.info( + `REST API(등록된 연결 #${firstMapping.from_connection_id})에서 데이터 조회: ${firstMapping.from_table_name}` + ); + const { ExternalRestApiConnectionService } = await import( + "./externalRestApiConnectionService" + ); + + const connResult = await ExternalRestApiConnectionService.getConnectionById( + firstMapping.from_connection_id, + config.company_code + ); + if (!connResult.success || !connResult.data) { + throw new Error( + `등록된 REST API 연결을 찾을 수 없습니다: id=${firstMapping.from_connection_id}` + ); + } + const conn = connResult.data; + + // 저장된 default_body 우선, 없으면 매핑에 저장된 body 사용 + const requestBody = + conn.default_body || + firstMapping.from_api_body || + undefined; + + const testResult = await ExternalRestApiConnectionService.testConnection( + { + id: conn.id, + base_url: conn.base_url, + endpoint: + firstMapping.from_table_name || conn.endpoint_path || "", + method: + (firstMapping.from_api_method as + | "GET" + | "POST" + | "PUT" + | "DELETE") || + (conn.default_method as any) || + "POST", + headers: conn.default_headers, + body: requestBody, + auth_type: conn.auth_type, + auth_config: conn.auth_config, + timeout: conn.timeout || 30000, + } as any, + config.company_code + ); + + if (!testResult.success) { + throw new Error( + `REST API 호출 실패: ${testResult.message || testResult.error_details}` + ); + } + + // dataArrayPath 적용 (config 또는 기본 "data") + const path = config.data_array_path || ""; + const getValueByPath = (obj: any, p: string): any => { + if (!p) return obj; + return p + .split(".") + .reduce((acc, k) => (acc == null ? acc : acc[k]), obj); + }; + let extracted: any = testResult.response_data; + if (path) extracted = getValueByPath(extracted, path); + + // 응답에서 배열 자동 탐색 (dataArrayPath 가 안 맞을 때 fallback) + if (!Array.isArray(extracted)) { + const findArr = (o: any, depth = 0): any[] | null => { + if (Array.isArray(o)) return o; + if (depth >= 4 || typeof o !== "object" || o === null) return null; + for (const v of Object.values(o)) { + const arr = findArr(v, depth + 1); + if (arr) return arr; + } + return null; + }; + extracted = findArr(testResult.response_data) || []; + } + + fromData = Array.isArray(extracted) ? extracted : []; + logger.info( + `REST API(등록된 연결) 데이터 ${fromData.length}건 조회 완료` + ); + // 다음 단계 (DB INSERT/UPSERT)로 진행 + } else { + // ── 등록된 연결이 없는 경우: 기존 인라인 URL/Key 흐름 ── logger.info( `REST API에서 데이터 조회: ${firstMapping.from_api_url}${firstMapping.from_table_name}` ); @@ -398,6 +485,7 @@ export class BatchSchedulerService { } else { throw new Error(`REST API 데이터 조회 실패: ${apiResult.message}`); } + } // end of inline-mode else (등록된 연결 없을 때) } else { // DB에서 데이터 조회 const fromColumns = mappings.map((m: any) => m.from_column_name); @@ -409,6 +497,42 @@ export class BatchSchedulerService { ); } + // ── 행 단위 제외 필터 적용 (config.row_filter_config) ── + // JSON: {"exclude":[{"column":"loginId","op":"eq","value":"wace"}, ...]} + // op 미지정 시 'eq'. 매칭되는 행은 동기화에서 완전 제외 (totalRecords 카운트도 제외). + if (config.row_filter_config) { + try { + const flt = typeof config.row_filter_config === "string" + ? JSON.parse(config.row_filter_config) + : config.row_filter_config; + const rules: Array<{ column: string; op?: string; value: any }> = + Array.isArray(flt?.exclude) ? flt.exclude : []; + if (rules.length > 0) { + const before = fromData.length; + const getValByPath = (obj: any, p: string): any => + !p ? undefined : p.includes(".") + ? p.split(".").reduce((a, k) => (a == null ? a : a[k]), obj) + : obj?.[p]; + fromData = fromData.filter((row) => { + for (const r of rules) { + const v = getValByPath(row, r.column); + const op = (r.op || "eq").toLowerCase(); + if (op === "eq" && String(v ?? "") === String(r.value ?? "")) return false; + if (op === "neq" && String(v ?? "") !== String(r.value ?? "")) return false; + if (op === "in" && Array.isArray(r.value) && r.value.map(String).includes(String(v ?? ""))) return false; + } + return true; + }); + const removed = before - fromData.length; + if (removed > 0) { + logger.info(`[row_filter] ${removed}건 제외 (남은 ${fromData.length}건) — rules=${JSON.stringify(rules)}`); + } + } + } catch (e: any) { + logger.warn(`[row_filter] JSON 파싱 실패: ${e?.message}`); + } + } + totalRecords += fromData.length; // 컬럼 매핑 적용하여 TO 테이블 형식으로 변환 @@ -431,6 +555,29 @@ export class BatchSchedulerService { continue; } + // 조건부 변환 (mapping_type='conditional') + // from_column_name = 평가할 API 필드명 + // mapping_config = JSON 문자열: { rules:[{when, then}], default } + if (mapping.mapping_type === "conditional") { + try { + const cfg = JSON.parse(mapping.mapping_config || "{}"); + const sourceVal = String(getValueByPath(row, mapping.from_column_name) ?? ""); + let resolved = cfg.default ?? null; + for (const rule of cfg.rules || []) { + if (String(rule.when ?? "") === sourceVal) { + resolved = rule.then; + break; + } + } + mappedRow[mapping.to_column_name] = resolved; + } catch (e) { + logger.warn( + `[conditional 매핑] JSON 파싱 실패 (mapping id=${mapping.id}): ${(e as any)?.message}` + ); + } + continue; + } + // DB → REST API 배치인지 확인 if ( firstMapping.to_connection_type === "restapi" && diff --git a/backend-node/src/services/batchService.ts b/backend-node/src/services/batchService.ts index 5a98a7c6..05ce56e4 100644 --- a/backend-node/src/services/batchService.ts +++ b/backend-node/src/services/batchService.ts @@ -73,11 +73,13 @@ export class BatchService { const totalPages = Math.ceil(total / limit); // 목록 조회 — LATERAL JOIN으로 batch_execution_logs 최신 1건을 단일 스캔으로 조회 (N+1 방지) + // batch_mappings 는 JSON 집계로 함께 가져와서 프론트의 배지(DB↔API) 판단을 정확하게 만든다. const configs = await query( `SELECT bc.*, bel.execution_status as last_status, bel.start_time as last_executed_at, - bel.total_records as last_total_records + bel.total_records as last_total_records, + COALESCE(bm.mappings, '[]'::json) as batch_mappings FROM batch_configs bc LEFT JOIN LATERAL ( SELECT execution_status, start_time, total_records @@ -86,6 +88,27 @@ export class BatchService { ORDER BY start_time DESC LIMIT 1 ) bel ON true + LEFT JOIN LATERAL ( + SELECT json_agg( + json_build_object( + 'id', bmp.id, + 'from_connection_type', bmp.from_connection_type, + 'from_connection_id', bmp.from_connection_id, + 'from_table_name', bmp.from_table_name, + 'from_column_name', bmp.from_column_name, + 'from_api_url', bmp.from_api_url, + 'from_api_method', bmp.from_api_method, + 'to_connection_type', bmp.to_connection_type, + 'to_table_name', bmp.to_table_name, + 'to_column_name', bmp.to_column_name, + 'mapping_order', bmp.mapping_order, + 'mapping_type', bmp.mapping_type, + 'mapping_config', bmp.mapping_config + ) ORDER BY bmp.mapping_order + ) AS mappings + FROM batch_mappings bmp + WHERE bmp.batch_config_id = bc.id + ) bm ON true ${whereClause} ORDER BY bc.created_date DESC LIMIT $${paramIndex++} OFFSET $${paramIndex++}`, @@ -182,9 +205,9 @@ export class BatchService { const result = await transaction(async (client) => { // 배치 설정 생성 const batchConfigResult = await client.query( - `INSERT INTO batch_configs - (batch_name, description, cron_schedule, is_active, company_code, save_mode, conflict_key, auth_service_name, data_array_path, execution_type, node_flow_id, node_flow_context, created_by, created_date, updated_date) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), NOW()) + `INSERT INTO batch_configs + (batch_name, description, cron_schedule, is_active, company_code, save_mode, conflict_key, auth_service_name, data_array_path, execution_type, node_flow_id, node_flow_context, row_filter_config, created_by, created_date, updated_date) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, NOW(), NOW()) RETURNING *`, [ data.batchName, @@ -199,6 +222,7 @@ export class BatchService { data.executionType || "mapping", data.nodeFlowId || null, data.nodeFlowContext ? JSON.stringify(data.nodeFlowContext) : null, + data.rowFilterConfig ? JSON.stringify(data.rowFilterConfig) : null, userId, ] ); @@ -215,8 +239,8 @@ export class BatchService { 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_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, mapping_type, 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, $25, $26, $27, NOW()) + to_api_url, to_api_key, to_api_method, to_api_body, mapping_order, mapping_type, mapping_config, 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, $25, $26, $27, $28, NOW()) RETURNING *`, [ batchConfig.id, @@ -244,7 +268,8 @@ export class BatchService { mapping.to_api_method, mapping.to_api_body, mapping.mapping_order || index + 1, - mapping.mapping_type || "direct", // 매핑 타입: direct 또는 fixed + mapping.mapping_type || "direct", // direct/fixed/conditional + (mapping as any).mapping_config || null, // 조건부 변환 JSON userId, ] ); @@ -358,6 +383,14 @@ export class BatchService { : null ); } + if (data.rowFilterConfig !== undefined) { + updateFields.push(`row_filter_config = $${paramIndex++}`); + updateValues.push( + data.rowFilterConfig + ? JSON.stringify(data.rowFilterConfig) + : null + ); + } // 배치 설정 업데이트 const batchConfigResult = await client.query( @@ -381,13 +414,13 @@ export class BatchService { for (let index = 0; index < data.mappings.length; index++) { const mapping = data.mappings[index]; const mappingResult = await client.query( - `INSERT INTO batch_mappings - (batch_config_id, company_code, from_connection_type, from_connection_id, from_table_name, from_column_name, + `INSERT INTO batch_mappings + (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_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, mapping_type, 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, $25, $26, $27, NOW()) + to_api_url, to_api_key, to_api_method, to_api_body, mapping_order, mapping_type, mapping_config, 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, $25, $26, $27, $28, NOW()) RETURNING *`, [ id, @@ -415,7 +448,8 @@ export class BatchService { mapping.to_api_method, mapping.to_api_body, mapping.mapping_order || index + 1, - mapping.mapping_type || "direct", // 매핑 타입: direct 또는 fixed + mapping.mapping_type || "direct", // direct/fixed/conditional + (mapping as any).mapping_config || null, // 조건부 변환 JSON userId, ] ); diff --git a/backend-node/src/services/erpApiClient.ts b/backend-node/src/services/erpApiClient.ts new file mode 100644 index 00000000..95f07f9e --- /dev/null +++ b/backend-node/src/services/erpApiClient.ts @@ -0,0 +1,226 @@ +/** + * RPS / Wehago ERP REST API 클라이언트 + * + * 참고 원본: /Users/chpark/wace_plm/src/com/pms/api (Java) + * - EmployeeApiClient (api16S05) + * - DepartmentApiClient (api16S10) + * - CustomerApiClient (api16S11) + * - WarehouseApiClient (api20A00S00801) + * - AccountCodeApiClient (api11A02) + * + * Wehago 인증 프로토콜: + * - callerName, Authorization(Bearer), transaction-id(32 hex), timestamp(unix sec), + * groupSeq, wehago-sign(HMAC-SHA256(accessToken+transactionId+timestamp+urlPath, HASH_KEY) → Base64) + */ +import crypto from "crypto"; +import https from "https"; +import http from "http"; +import { URL as NodeURL } from "url"; +import { logger } from "../utils/logger"; + +const DEFAULT_BASE_URL = process.env.ERP_BASE_URL || "https://erp.rps-korea.com"; +const CALLER_NAME = process.env.ERP_CALLER_NAME || "API_gcmsAmaranth40578"; +const ACCESS_TOKEN = process.env.ERP_ACCESS_TOKEN || "MN5KzKBWRAa92BPxDlRLl3GcsxeZXc"; +const HASH_KEY = process.env.ERP_HASH_KEY || "22519103205540290721741689643674301018832465"; +const GROUP_SEQ = process.env.ERP_GROUP_SEQ || "gcmsAmaranth40578"; + +// SSL 인증서 검증 우회 (Java 클라이언트와 동일 — 개발/내부망 호환) +const insecureAgent = new https.Agent({ rejectUnauthorized: false }); + +/** + * Node.js native http(s) 기반 POST 호출. + * - 자체 서명 SSL 인증서를 받아들여야 하므로 https.Agent({ rejectUnauthorized: false }) 사용. + * - 30xx 리다이렉트 시 동일 헤더로 한 번 추적. + */ +function postJson( + fullUrl: string, + headers: Record, + body: string +): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const u = new NodeURL(fullUrl); + const isHttps = u.protocol === "https:"; + const reqOptions: https.RequestOptions = { + method: "POST", + hostname: u.hostname, + port: u.port || (isHttps ? 443 : 80), + path: u.pathname + (u.search || ""), + headers: { ...headers, "Content-Length": Buffer.byteLength(body, "utf8").toString() }, + timeout: 30000, + ...(isHttps ? { agent: insecureAgent } : {}), + }; + + const req = (isHttps ? https : http).request(reqOptions, (res) => { + const status = res.statusCode || 0; + // 리다이렉트 + if ([301, 302, 303, 307, 308].includes(status) && res.headers.location) { + res.resume(); + const next = res.headers.location.startsWith("http") + ? res.headers.location + : new NodeURL(res.headers.location, fullUrl).toString(); + postJson(next, headers, body).then(resolve, reject); + return; + } + const chunks: Buffer[] = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => { + resolve({ status, body: Buffer.concat(chunks).toString("utf8") }); + }); + res.on("error", reject); + }); + + req.on("timeout", () => { + req.destroy(new Error("ERP API timeout (30s)")); + }); + req.on("error", reject); + req.write(body, "utf8"); + req.end(); + }); +} + +interface CallOptions { + baseUrl?: string; + apiPath: string; // ex) "/apiproxy/api16S05" + body: Record; +} + +function generateTransactionId(): string { + return crypto.randomBytes(16).toString("hex"); +} + +function generateWehagoSign(transactionId: string, timestamp: string, urlPath: string): string { + const value = ACCESS_TOKEN + transactionId + timestamp + urlPath; + return crypto.createHmac("sha256", HASH_KEY).update(value, "utf8").digest("base64"); +} + +async function callErpApi(options: CallOptions): Promise { + const baseUrl = (options.baseUrl || DEFAULT_BASE_URL).replace(/\/$/, ""); + const urlPath = options.apiPath; + const fullUrl = baseUrl + urlPath; + + const transactionId = generateTransactionId(); + const timestamp = String(Math.floor(Date.now() / 1000)); + const wehagoSign = generateWehagoSign(transactionId, timestamp, urlPath); + + const headers: Record = { + "Content-Type": "application/json; charset=UTF-8", + Accept: "application/json", + callerName: CALLER_NAME, + Authorization: `Bearer ${ACCESS_TOKEN}`, + "transaction-id": transactionId, + timestamp, + groupSeq: GROUP_SEQ, + "wehago-sign": wehagoSign, + }; + + const payload = JSON.stringify(options.body); + const { status, body: text } = await postJson(fullUrl, headers, payload); + + if (status < 200 || status >= 300) { + logger.error("[erpApiClient] 호출 실패", { + url: fullUrl, + status, + body: text?.slice(0, 500), + }); + throw new Error(`ERP API 호출 실패: HTTP ${status} - ${text}`); + } + + try { + return JSON.parse(text) as T; + } catch (e) { + logger.error("[erpApiClient] JSON 파싱 실패", { text: text?.slice(0, 500) }); + throw new Error("ERP API 응답이 JSON 형식이 아닙니다."); + } +} + +/** + * 사원 정보 조회 (api16S05) + * - extraColumns로 emalAdd / outemalAdd / emgcTel / tel / joinDt 추가 요청 + */ +export async function fetchEmployees(coCd: string, baseUrl?: string): Promise { + if (!coCd?.trim()) throw new Error("회사코드(coCd)는 필수입니다."); + return callErpApi({ + baseUrl, + apiPath: "/apiproxy/api16S05", + body: { + header: { groupSeq: GROUP_SEQ, empSeq: "", tId: "", pId: "" }, + body: { + coCd, + extraColumns: ["emalAdd", "outemalAdd", "emgcTel", "tel", "joinDt"], + }, + }, + }); +} + +/** + * 부서 정보 조회 (api16S10) + */ +export async function fetchDepartments( + coCd: string, + searchText?: string, + baseUrl?: string +): Promise { + if (!coCd?.trim()) throw new Error("회사코드(coCd)는 필수입니다."); + const body: Record = { coCd }; + if (searchText && searchText.trim()) body.searchText = searchText.trim(); + return callErpApi({ baseUrl, apiPath: "/apiproxy/api16S10", body }); +} + +/** + * 거래처 정보 조회 (api16S11) + */ +export async function fetchCustomers(coCd: string, baseUrl?: string): Promise { + if (!coCd?.trim()) throw new Error("회사코드(coCd)는 필수입니다."); + return callErpApi({ baseUrl, apiPath: "/apiproxy/api16S11", body: { coCd } }); +} + +/** + * 창고 정보 조회 (api20A00S00801) + * baselocFg: "0" 전체, "1" 기준위치만 (선택) + */ +export async function fetchWarehouses( + coCd: string, + baselocFg?: string, + baseUrl?: string +): Promise { + if (!coCd?.trim()) throw new Error("회사코드(coCd)는 필수입니다."); + const body: Record = { coCd }; + if (baselocFg && baselocFg.trim()) body.baselocFg = baselocFg; + return callErpApi({ baseUrl, apiPath: "/apiproxy/api20A00S00801", body }); +} + +/** + * 계정과목 정보 조회 (api11A02) + */ +export async function fetchAccountCodes(coCd: string, baseUrl?: string): Promise { + if (!coCd?.trim()) throw new Error("회사코드(coCd)는 필수입니다."); + return callErpApi({ baseUrl, apiPath: "/apiproxy/api11A02", body: { coCd } }); +} + +/** + * 응답에서 데이터 배열을 안전하게 추출. + * Wehago 응답은 보통 { result: { data: [...] } } | { data: [...] } | { body: { data: [...] } } | [...] + */ +export function extractRecords(response: any): any[] { + if (!response) return []; + if (Array.isArray(response)) return response; + const candidates = [ + response?.data, + response?.result?.data, + response?.body?.data, + response?.body?.result?.data, + response?.resultData, + response?.list, + response?.rows, + ]; + for (const c of candidates) { + if (Array.isArray(c)) return c; + } + // 객체 내 첫 번째 배열 속성 fallback + if (typeof response === "object") { + for (const v of Object.values(response)) { + if (Array.isArray(v)) return v; + } + } + return []; +} diff --git a/backend-node/src/services/erpBatchSeedService.ts b/backend-node/src/services/erpBatchSeedService.ts new file mode 100644 index 00000000..0e5108f2 --- /dev/null +++ b/backend-node/src/services/erpBatchSeedService.ts @@ -0,0 +1,429 @@ +/** + * Amaranth (Wehago/RPS ERP) → 내부 DB 동기화 배치 5종 자동 시드 + * + * 부팅 시 1회 실행. 외부 REST API 연결 5종(부서/사원/거래처/창고/계정과목)을 찾아서 + * 각 연결마다 batch_configs + batch_mappings 한 묶음씩 생성합니다. + * - 동일 batch_name 이 이미 존재하면 건너뜀 (idempotent) + * - from_connection_type = "restapi", from_connection_id = REST API 연결 ID + * → 실행 시 batchSchedulerService 가 Wehago HMAC 인증을 자동으로 적용합니다. + */ +import { getPool, transaction } from "../database/db"; +import { logger } from "../utils/logger"; + +const DEFAULT_COMPANY_CODE = process.env.DEFAULT_COMPANY_CODE || "COMPANY_16"; + +interface BatchPreset { + batch_name: string; + description: string; + cron_schedule: string; + // 매칭할 REST API 연결의 connection_name + connection_name: string; + // 응답 안에서 데이터 배열을 꺼낼 경로 (dataArrayPath) + data_array_path: string; + // 적재 대상 DB 테이블 (내부 DB) + to_table_name: string; + // 매핑: API 응답 필드 → DB 컬럼 + column_map: Array<{ from: string; to: string }>; + // UPSERT 충돌 기준 컬럼 (없으면 INSERT) + save_mode: "INSERT" | "UPSERT"; + conflict_key?: string; + // 행 단위 제외 필터 — Wehago 응답의 특정 행을 동기화에서 제외할 때 사용 + // 예) loginId="wace" 인 통합 ERP 계정은 내부 admin과 user_id 충돌 → 제외 + row_filter_config?: { exclude: Array<{ column: string; op?: string; value: any }> }; +} + +const PRESETS: BatchPreset[] = [ + { + batch_name: "Amaranth → 부서 동기화", + description: + "Wehago api16S10 → dept_info. 응답 실제 필드: deptCd / deptNm / parentDeptCd / deptLevel / orderSq / regDt / toDt 등.", + cron_schedule: "0 3 * * *", + connection_name: "Amaranth - 부서", + data_array_path: "resultData", + to_table_name: "dept_info", + save_mode: "UPSERT", + conflict_key: "dept_code", + column_map: [ + { from: "deptCd", to: "dept_code" }, + { from: "deptNm", to: "dept_name" }, + { from: "parentDeptCd", to: "parent_dept_code" }, + { from: "deptLevel", to: "dept_level" }, + { from: "orderSq", to: "sort_seq" }, + ], + }, + { + batch_name: "Amaranth → 사원(인사정보) 동기화", + description: + "Wehago api16S05 → user_info. 응답 실제 필드: empCd(사번), empSeq(시퀀스), loginId(로그인ID), korNm(한글명), hrsp/hcls(직책/직급) 등. sabun 기준 UPSERT.", + cron_schedule: "10 3 * * *", + connection_name: "Amaranth - 사원", + data_array_path: "resultData", + to_table_name: "user_info", + save_mode: "UPSERT", + conflict_key: "sabun", + // 통합 ERP 계정 (loginId='wace') 은 내부 관리자 user_id='wace' 와 충돌하므로 제외 + row_filter_config: { exclude: [{ column: "loginId", op: "eq", value: "wace" }] }, + column_map: [ + { from: "empCd", to: "sabun" }, // 사번 + { from: "loginId", to: "user_id" }, // 로그인 ID — 이미 api16S05 응답에 있음 + { from: "empSeq", to: "emp_seq" }, // Wehago 사원 시퀀스 + { from: "korNm", to: "user_name" }, + { from: "enlsNm", to: "user_name_eng" }, + { from: "deptCd", to: "dept_code" }, + { from: "deptNm", to: "dept_name" }, + { from: "hrspCd", to: "position_code" }, + { from: "hrspNm", to: "position_name" }, + { from: "hclsCd", to: "rank_code" }, + { from: "hclsNm", to: "rank_name" }, + { from: "emalAdd", to: "email" }, + { from: "outemalAdd", to: "out_email" }, + { from: "tel", to: "tel" }, + { from: "emgcTel", to: "cell_phone" }, + { from: "joinDt", to: "join_date" }, + { from: "rtrDt", to: "retire_date" }, + { from: "enrlFg", to: "work_status" }, + ], + }, + { + batch_name: "Amaranth → Wehago 사용자(loginId) 동기화", + description: + "Wehago api99u01A11 → user_info.user_id 보강. api16S05 에 loginId 가 없는 사용자가 있을 때만 보충 (백업용).", + cron_schedule: "20 3 * * *", + connection_name: "Amaranth - Wehago 사용자", + data_array_path: "resultData", + to_table_name: "user_info", + save_mode: "UPSERT", + conflict_key: "emp_seq", + column_map: [ + { from: "empSeq", to: "emp_seq" }, + { from: "loginId", to: "user_id" }, + ], + }, + { + batch_name: "Amaranth → 거래처 동기화", + description: + "Wehago api16S11 → customer_mng. 응답 실제 필드: trCd/trNm/trKrNm/trEnNm/ceoNm/tel/fax/email/zip/streceiveAddr1/2/useYn/trFg/nationCd/stemp* 등.", + cron_schedule: "25 3 * * *", + connection_name: "Amaranth - 거래처", + data_array_path: "resultData", + to_table_name: "customer_mng", + save_mode: "UPSERT", + conflict_key: "customer_code", + column_map: [ + { from: "trCd", to: "customer_code" }, + { from: "trNm", to: "customer_name" }, + { from: "trKrNm", to: "short_name" }, + { from: "ceoNm", to: "ceo_name" }, + { from: "tel", to: "tel" }, + { from: "fax", to: "fax_no" }, + { from: "email", to: "email" }, + { from: "zip", to: "zip_code" }, + { from: "streceiveAddr1", to: "address" }, + { from: "streceiveAddr2", to: "address_detail" }, + { from: "trFg", to: "customer_type" }, + { from: "nationCd", to: "nation_code" }, + { from: "stempEmpNm", to: "charge_name" }, + { from: "stempTel", to: "charge_tel" }, + { from: "stempEmail", to: "charge_email" }, + { from: "stempHp", to: "hp_no" }, + { from: "depositor", to: "account_owner" }, + { from: "useYn", to: "use_yn" }, + ], + }, + { + batch_name: "Amaranth → 창고 동기화", + description: + "Wehago api20A00S00801 → warehouse_info. 응답 실제 필드: baselocCd/baselocNm/baselocFg/baselocDc/inlocCd/inlocNm/outlocCd/outlocNm/useYn 등.", + cron_schedule: "30 3 * * *", + connection_name: "Amaranth - 창고", + data_array_path: "resultData", + to_table_name: "warehouse_info", + save_mode: "UPSERT", + conflict_key: "warehouse_code", + column_map: [ + { from: "baselocCd", to: "warehouse_code" }, + { from: "baselocNm", to: "warehouse_name" }, + { from: "baselocFg", to: "baseloc_fg" }, + { from: "useYn", to: "use_yn" }, + ], + }, + { + batch_name: "Amaranth → 계정과목 동기화", + description: + "Wehago api11A02 → account_code_info. 응답 실제 필드: acctCd/acctNm/acctNmk/groupCd/groupNm/drcrFg/subDisp/chFg/budFg/attrFg/racctCd 등.", + cron_schedule: "40 3 * * *", + connection_name: "Amaranth - 계정과목", + data_array_path: "resultData", + to_table_name: "account_code_info", + save_mode: "UPSERT", + conflict_key: "account_code", + column_map: [ + { from: "acctCd", to: "account_code" }, + { from: "acctNm", to: "account_name" }, + { from: "acctNmk", to: "account_short" }, + { from: "groupCd", to: "group_code" }, + { from: "groupNm", to: "group_name" }, + { from: "drcrFg", to: "dr_cr_fg" }, + { from: "subDisp", to: "sub_disp" }, + { from: "subDispNm", to: "sub_disp_name" }, + { from: "chFg", to: "ch_fg" }, + { from: "chFgNm", to: "ch_fg_name" }, + { from: "budFg", to: "bud_fg" }, + { from: "budFgNm", to: "bud_fg_name" }, + { from: "attrFg", to: "attr_fg" }, + { from: "attrFgNm", to: "attr_fg_name" }, + { from: "racctCd", to: "racct_code" }, + { from: "racctNm", to: "racct_name" }, + { from: "fillYn", to: "fill_yn" }, + { from: "extInputCd", to: "ext_input_cd" }, + ], + }, +]; + +export async function seedAmaranthBatches(): Promise { + const pool = getPool(); + + // 시퀀스 보정 (과거에 INSERT가 직접 들어가면서 어긋났을 수 있음) + for (const tbl of ["batch_mappings", "batch_configs", "batch_execution_logs"]) { + try { + await pool.query( + `SELECT setval(pg_get_serial_sequence($1, 'id'), + COALESCE((SELECT MAX(id) FROM ${tbl}), 1), + true)`, + [tbl] + ); + } catch (e: any) { + logger.warn(`[batchSeed] ${tbl} 시퀀스 보정 실패 (무시): ${e?.message}`); + } + } + + // 1) Amaranth REST API 연결 5종을 한 번에 조회 (connection_name → {id, base_url, endpoint, method, body}) + const connRows = await pool.query<{ + id: number; + connection_name: string; + base_url: string; + endpoint_path: string | null; + default_method: string | null; + default_request_body: string | null; + }>( + `SELECT id, connection_name, base_url, endpoint_path, default_method, default_request_body + FROM external_rest_api_connections + WHERE connection_name = ANY($1)`, + [PRESETS.map((p) => p.connection_name)] + ); + + const connByName = new Map(connRows.rows.map((c) => [c.connection_name, c])); + + let created = 0; + let topupCount = 0; + let skipped = 0; + let missing = 0; + + for (const preset of PRESETS) { + const conn = connByName.get(preset.connection_name); + if (!conn) { + logger.warn(`[batchSeed] REST API 연결 없음 (스킵): ${preset.connection_name}`); + missing++; + continue; + } + + // 이미 동일 이름의 배치가 있으면 → 정합성 보정 (conflict_key 갱신, 누락 매핑 보충, 폐기된 매핑 제거) + const existing = await pool.query<{ + id: number; + conflict_key: string | null; + save_mode: string | null; + data_array_path: string | null; + row_filter_config: string | null; + }>( + "SELECT id, conflict_key, save_mode, data_array_path, row_filter_config FROM batch_configs WHERE batch_name = $1 AND company_code = $2 LIMIT 1", + [preset.batch_name, DEFAULT_COMPANY_CODE] + ); + if ((existing.rowCount ?? 0) > 0) { + const existingRow = existing.rows[0]; + const existingId = existingRow.id; + + // ── 1) 배치 설정의 conflict_key / save_mode / data_array_path / row_filter_config 가 프리셋과 다르면 갱신 ── + const presetFilter = preset.row_filter_config + ? JSON.stringify(preset.row_filter_config) + : null; + // 기존 row_filter_config 가 비어있을 때만 프리셋 값을 주입 (사용자가 UI에서 설정한 값은 보존) + const shouldSeedFilter = !existingRow.row_filter_config && presetFilter; + const needsCfgUpdate = + existingRow.conflict_key !== (preset.conflict_key || null) || + existingRow.save_mode !== preset.save_mode || + existingRow.data_array_path !== preset.data_array_path || + shouldSeedFilter; + if (needsCfgUpdate) { + await pool.query( + `UPDATE batch_configs + SET conflict_key = $1, save_mode = $2, data_array_path = $3, + row_filter_config = COALESCE(row_filter_config, $4), + updated_date = NOW() + WHERE id = $5`, + [ + preset.conflict_key || null, + preset.save_mode, + preset.data_array_path, + presetFilter, + existingId, + ] + ); + logger.info( + `[batchSeed] 설정 갱신: ${preset.batch_name} (conflict_key=${preset.conflict_key}, save_mode=${preset.save_mode}${shouldSeedFilter ? ", row_filter 시드" : ""})` + ); + } + + // ── 2) 매핑 동기화 ── + // 2-0) 옛 매핑이 from_connection_id=null 인 경우 정합성 보정 — 등록된 연결 id로 통일 + // (그룹 키가 from_connection_id 인데 null/connId 가 섞이면 두 그룹으로 쪼개져서 일부 컬럼이 동기화 안됨) + await pool.query( + `UPDATE batch_mappings + SET from_connection_id = $1, + from_api_url = $2, + from_api_method = $3, + from_api_body = COALESCE(NULLIF(from_api_body, ''), $4), + from_table_name = $5 + WHERE batch_config_id = $6 + AND from_connection_type = 'restapi'`, + [ + conn.id, + conn.base_url, + conn.default_method || "POST", + conn.default_request_body || "", + conn.endpoint_path || "", + existingId, + ] + ); + + const mapRes = await pool.query<{ id: number; from_column_name: string; to_column_name: string; mapping_order: number }>( + "SELECT id, from_column_name, to_column_name, mapping_order FROM batch_mappings WHERE batch_config_id = $1", + [existingId] + ); + // 프리셋에 정의된 (from, to) 쌍 set + const presetPairs = new Set(preset.column_map.map((m) => `${m.from}=>${m.to}`)); + // 기존 매핑들의 (from, to) 키 → row id + const existingPairs = new Map(); + mapRes.rows.forEach((r) => { + existingPairs.set(`${r.from_column_name}=>${r.to_column_name}`, r.id); + }); + + // 폐기된 매핑(같은 to_column 인데 다른 from 사용 등) 삭제 — 프리셋에 없는 쌍 모두 + const obsoleteIds = mapRes.rows + .filter((r) => !presetPairs.has(`${r.from_column_name}=>${r.to_column_name}`)) + .map((r) => r.id); + if (obsoleteIds.length > 0) { + await pool.query(`DELETE FROM batch_mappings WHERE id = ANY($1::int[])`, [obsoleteIds]); + logger.info(`[batchSeed] 폐기 매핑 삭제: ${preset.batch_name} (-${obsoleteIds.length}개)`); + } + + // 누락 매핑 추가 + const maxOrder = mapRes.rows.reduce((acc, r) => Math.max(acc, r.mapping_order || 0), 0); + const missingMaps = preset.column_map.filter((m) => !existingPairs.has(`${m.from}=>${m.to}`)); + if (missingMaps.length > 0) { + try { + let order = maxOrder + 1; + for (const m of missingMaps) { + await pool.query( + `INSERT INTO batch_mappings + (batch_config_id, company_code, from_connection_type, from_connection_id, + from_table_name, from_column_name, + from_api_url, from_api_key, from_api_method, from_api_body, + to_connection_type, to_connection_id, to_table_name, to_column_name, + mapping_order, mapping_type, created_by, created_date) + VALUES ($1, $2, 'restapi', $3, $4, $5, $6, $7, $8, $9, + 'internal', NULL, $10, $11, $12, 'direct', 'system-seed', NOW())`, + [ + existingId, + DEFAULT_COMPANY_CODE, + conn.id, + conn.endpoint_path || "", + m.from, + conn.base_url, + "", + conn.default_method || "POST", + conn.default_request_body || "", + preset.to_table_name, + m.to, + order++, + ] + ); + } + topupCount += missingMaps.length; + logger.info( + `[batchSeed] 매핑 보충: ${preset.batch_name} (+${missingMaps.length}개)` + ); + } catch (e: any) { + logger.warn(`[batchSeed] 매핑 보충 실패: ${preset.batch_name} — ${e?.message}`); + } + } + + skipped++; + continue; + } + + try { + await transaction(async (client) => { + // 배치 설정 생성 + const cfgResult = await client.query( + `INSERT INTO batch_configs + (batch_name, description, cron_schedule, is_active, company_code, + save_mode, conflict_key, data_array_path, execution_type, row_filter_config, + created_by, created_date, updated_date) + VALUES ($1, $2, $3, 'Y', $4, $5, $6, $7, 'mapping', $8, 'system-seed', NOW(), NOW()) + RETURNING id`, + [ + preset.batch_name, + preset.description, + preset.cron_schedule, + DEFAULT_COMPANY_CODE, + preset.save_mode, + preset.conflict_key || null, + preset.data_array_path, + preset.row_filter_config ? JSON.stringify(preset.row_filter_config) : null, + ] + ); + const batchId = cfgResult.rows[0].id; + + // 매핑 INSERT — N개 컬럼 매핑 + let order = 1; + for (const m of preset.column_map) { + await client.query( + `INSERT INTO batch_mappings + (batch_config_id, company_code, from_connection_type, from_connection_id, + from_table_name, from_column_name, + from_api_url, from_api_key, from_api_method, from_api_body, + to_connection_type, to_connection_id, to_table_name, to_column_name, + mapping_order, mapping_type, created_by, created_date) + VALUES ($1, $2, 'restapi', $3, $4, $5, $6, $7, $8, $9, + 'internal', NULL, $10, $11, $12, 'direct', 'system-seed', NOW())`, + [ + batchId, + DEFAULT_COMPANY_CODE, + conn.id, // from_connection_id = REST API 연결 ID → 실행기에서 Wehago 인증 적용 + conn.endpoint_path || "", + m.from, + conn.base_url, + "", // from_api_key — 비워둠. 실행기가 connection_id 보고 connection의 인증을 사용 + conn.default_method || "POST", + conn.default_request_body || "", + preset.to_table_name, + m.to, + order++, + ] + ); + } + }); + + created++; + logger.info(`[batchSeed] 생성: ${preset.batch_name} (REST API 연결 #${conn.id})`); + } catch (e: any) { + logger.error(`[batchSeed] 실패: ${preset.batch_name} — ${e?.message}`); + } + } + + if (created > 0 || skipped > 0 || missing > 0 || topupCount > 0) { + logger.info( + `🌱 Amaranth 배치 시드 완료: 신규 ${created}개 / 이미 존재 ${skipped}개 / 매핑 보충 ${topupCount}개 / 연결 없음 ${missing}개 (회사: ${DEFAULT_COMPANY_CODE})` + ); + } +} diff --git a/backend-node/src/services/erpPresetSeedService.ts b/backend-node/src/services/erpPresetSeedService.ts new file mode 100644 index 00000000..fd4b5d25 --- /dev/null +++ b/backend-node/src/services/erpPresetSeedService.ts @@ -0,0 +1,158 @@ +/** + * Amaranth (Wehago/RPS ERP) REST API 연결 프리셋 자동 시드. + * + * 백엔드 부팅 시 1회 호출되어, 외부 REST API 연결 목록에 5개 마스터 동기화 엔드포인트가 + * 없으면 자동으로 등록합니다. 이미 존재하는 연결명은 건너뜁니다. + * + * 호출: app.ts 부팅 직후 (DB 풀 초기화 후) + */ +import { getPool } from "../database/db"; +import { ExternalRestApiConnectionService } from "./externalRestApiConnectionService"; +import { logger } from "../utils/logger"; + +const DEFAULT_COMPANY_CODE = process.env.DEFAULT_COMPANY_CODE || "COMPANY_16"; + +const AUTH_CONFIG = { + callerName: "API_gcmsAmaranth40578", + accessToken: "MN5KzKBWRAa92BPxDlRLl3GcsxeZXc", + hashKey: "22519103205540290721741689643674301018832465", + groupSeq: "gcmsAmaranth40578", +}; + +const COMMON_HEADERS = { + "Content-Type": "application/json; charset=UTF-8", + Accept: "application/json", +}; + +const PRESETS = [ + { + connection_name: "Amaranth - 부서", + description: "Wehago/Amaranth api16S10 — 부서 마스터 (dept_info)", + endpoint_path: "/apiproxy/api16S10", + default_body: JSON.stringify({ coCd: "1000" }, null, 2), + }, + { + connection_name: "Amaranth - 사원", + description: "Wehago/Amaranth api16S05 — 사원 마스터 (user_info, sabun 기반 UPSERT)", + endpoint_path: "/apiproxy/api16S05", + default_body: JSON.stringify( + { + header: { groupSeq: AUTH_CONFIG.groupSeq, empSeq: "", tId: "", pId: "" }, + body: { coCd: "1000", extraColumns: ["emalAdd", "outemalAdd", "emgcTel", "tel", "joinDt"] }, + }, + null, + 2 + ), + }, + { + connection_name: "Amaranth - Wehago 사용자", + description: "Wehago/Amaranth api99u01A11 — Wehago 사용자(loginId) 조회. 사원 동기화 후 emp_seq 기준으로 user_id 채움.", + endpoint_path: "/apiproxy/api99u01A11", + default_body: JSON.stringify( + { + header: { groupSeq: AUTH_CONFIG.groupSeq, tId: "", pId: "" }, + body: { langCode: "kr" }, // 전체 조회 (arrEmpSeq 없으면 전체) + }, + null, + 2 + ), + }, + { + connection_name: "Amaranth - 거래처", + description: "Wehago/Amaranth api16S11 — 거래처 마스터 (customer_mng)", + endpoint_path: "/apiproxy/api16S11", + default_body: JSON.stringify({ coCd: "1000" }, null, 2), + }, + { + connection_name: "Amaranth - 창고", + description: "Wehago/Amaranth api20A00S00801 — 창고/위치 마스터 (warehouse_info)", + endpoint_path: "/apiproxy/api20A00S00801", + default_body: JSON.stringify({ coCd: "1000", baselocFg: "0" }, null, 2), + }, + { + connection_name: "Amaranth - 계정과목", + description: "Wehago/Amaranth api11A02 — 계정과목 마스터 (account_code_info)", + endpoint_path: "/apiproxy/api11A02", + default_body: JSON.stringify({ coCd: "1000" }, null, 2), + }, +]; + +export async function seedAmaranthPresets(): Promise { + const pool = getPool(); + + // PK 시퀀스 보정 (이전 INSERT/마이그레이션으로 어긋났을 수 있음) + try { + await pool.query( + `SELECT setval(pg_get_serial_sequence('external_rest_api_connections', 'id'), + COALESCE((SELECT MAX(id) FROM external_rest_api_connections), 1), + true)` + ); + } catch (e: any) { + logger.warn(`[seed] external_rest_api_connections 시퀀스 보정 실패 (무시): ${e?.message}`); + } + + let created = 0; + let updated = 0; + let skipped = 0; + + for (const preset of PRESETS) { + try { + const existing = await pool.query<{ id: number; default_body: string | null }>( + "SELECT id, default_request_body AS default_body FROM external_rest_api_connections WHERE connection_name = $1 LIMIT 1", + [preset.connection_name] + ); + + if ((existing.rowCount ?? 0) > 0) { + // 기존 레코드의 default_body 가 비어있거나 빈 coCd 면 프리셋으로 갱신 (회사코드 1000 반영) + const row = existing.rows[0]; + const needsUpdate = + !row.default_body || + /"coCd"\s*:\s*""/.test(row.default_body) || + /"coCd"\s*:\s*null/.test(row.default_body); + + if (needsUpdate) { + await pool.query( + `UPDATE external_rest_api_connections + SET default_request_body = $1, updated_date = NOW(), updated_by = 'system-seed' + WHERE id = $2`, + [preset.default_body, row.id] + ); + updated++; + } else { + skipped++; + } + continue; + } + + const result = await ExternalRestApiConnectionService.createConnection({ + connection_name: preset.connection_name, + description: preset.description, + base_url: "https://erp.rps-korea.com", + endpoint_path: preset.endpoint_path, + default_headers: COMMON_HEADERS, + default_method: "POST", + default_body: preset.default_body, + auth_type: "wehago", + auth_config: { ...AUTH_CONFIG }, + timeout: 30000, + retry_count: 0, + retry_delay: 1000, + company_code: DEFAULT_COMPANY_CODE, + is_active: "Y", + created_by: "system-seed", + } as any); + + if (result.success) { + created++; + } + } catch (e: any) { + logger.warn(`[seed] Amaranth 프리셋 등록 실패 (${preset.connection_name}): ${e?.message}`); + } + } + + if (created > 0 || updated > 0 || skipped > 0) { + logger.info( + `🌱 Amaranth 프리셋 시드 완료: 신규 ${created}개 / 갱신 ${updated}개 / 동일 ${skipped}개 (회사: ${DEFAULT_COMPANY_CODE})` + ); + } +} diff --git a/backend-node/src/services/erpSyncService.ts b/backend-node/src/services/erpSyncService.ts new file mode 100644 index 00000000..0b4700a8 --- /dev/null +++ b/backend-node/src/services/erpSyncService.ts @@ -0,0 +1,539 @@ +/** + * ERP → 내부 DB 동기화 서비스 + * + * 외부 ERP(Wehago/RPS)에서 사원/부서/창고/거래처/계정과목 정보를 가져와 + * 내부 PostgreSQL 테이블(user_info / dept_info / warehouse_info / customer_mng / account_code_info)에 + * UPSERT(있으면 갱신, 없으면 삽입) 합니다. + * + * 배치 관리 화면(자동화 관리 > 배치 관리)에서 호출됩니다. + */ +import { + fetchEmployees, + fetchDepartments, + fetchWarehouses, + fetchCustomers, + fetchAccountCodes, + extractRecords, +} from "./erpApiClient"; +import { transaction, query, queryOne } from "../database/db"; +import { logger } from "../utils/logger"; + +async function writeSyncLog( + resource: string, + coCd: string, + companyCode: string, + result: SyncResult, + executedBy?: string +): Promise { + try { + await query( + `INSERT INTO erp_sync_log ( + resource, co_cd, company_code, + fetched_count, inserted_count, updated_count, skipped_count, + duration_ms, success, error_message, executed_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + [ + resource, + coCd, + companyCode, + result.fetched, + result.inserted, + result.updated, + result.skipped, + result.durationMs, + result.errors.length === 0, + result.errors.join("\n") || null, + executedBy || "system", + ] + ); + } catch (e: any) { + // 로그 테이블이 마이그레이션 전이면 INSERT 실패할 수 있음 — 동기화 결과 자체에 영향 없도록 무시 + logger.warn("[erpSync] erp_sync_log INSERT 실패 (무시)", { error: e?.message }); + } +} + +async function withSyncLog( + resource: string, + coCd: string, + companyCode: string, + executedBy: string | undefined, + fn: () => Promise +): Promise { + const r = await fn(); + await writeSyncLog(resource, coCd, companyCode, r, executedBy); + return r; +} + +export interface SyncResult { + resource: string; + fetched: number; + inserted: number; + updated: number; + skipped: number; + errors: string[]; + durationMs: number; +} + +function pick>(obj: T, keys: string[]): any { + for (const k of keys) { + const v = obj?.[k]; + if (v !== undefined && v !== null && String(v) !== "") return v; + } + return null; +} + +function asString(v: any): string | null { + if (v === undefined || v === null) return null; + const s = String(v).trim(); + return s === "" ? null : s; +} + +function asDate(v: any): string | null { + const s = asString(v); + if (!s) return null; + // "20231201" 형태 → "2023-12-01" + const ymd = /^(\d{4})(\d{2})(\d{2})$/.exec(s); + if (ymd) return `${ymd[1]}-${ymd[2]}-${ymd[3]}`; + return s; +} + +/** + * 사원(user_info) 동기화 + * - 외부 응답 필드(empSeq, empNm, deptCd, deptNm, posCd, posNm, emalAdd, tel, sabun ...) → 내부 컬럼 매핑 + */ +export async function syncEmployees( + coCd: string, + companyCode: string, + executedBy?: string +): Promise { + return withSyncLog("employees", coCd, companyCode, executedBy, () => doSyncEmployees(coCd, companyCode)); +} + +async function doSyncEmployees(coCd: string, companyCode: string): Promise { + const start = Date.now(); + const result: SyncResult = { + resource: "employees", + fetched: 0, + inserted: 0, + updated: 0, + skipped: 0, + errors: [], + durationMs: 0, + }; + + try { + const apiResponse = await fetchEmployees(coCd); + const records = extractRecords(apiResponse); + result.fetched = records.length; + + if (records.length === 0) { + logger.warn("[erpSync] employees 응답 데이터 없음", { coCd, sample: JSON.stringify(apiResponse).slice(0, 300) }); + result.durationMs = Date.now() - start; + return result; + } + + await transaction(async (client) => { + for (const r of records) { + const userId = asString(pick(r, ["empSeq", "userId", "user_id", "loginId"])); + const userName = asString(pick(r, ["empNm", "userName", "user_name", "name"])); + if (!userId || !userName) { + result.skipped++; + continue; + } + + const deptCode = asString(pick(r, ["deptCd", "deptCode", "dept_code"])); + const deptName = asString(pick(r, ["deptNm", "deptName", "dept_name"])); + const positionCode = asString(pick(r, ["posCd", "positionCode", "position_code", "rspofcCd"])); + const positionName = asString(pick(r, ["posNm", "positionName", "position_name", "rspofcNm"])); + const email = asString(pick(r, ["emalAdd", "outemalAdd", "email"])); + const tel = asString(pick(r, ["tel", "phone", "telNo"])); + const cellPhone = asString(pick(r, ["emgcTel", "cellPhone", "cell_phone", "hpNo", "mobile"])); + const sabun = asString(pick(r, ["sabun", "empNo", "emp_no"])); + + const existing = await client.query( + "SELECT user_id FROM user_info WHERE user_id = $1", + [userId] + ); + + if (existing.rowCount && existing.rowCount > 0) { + await client.query( + `UPDATE user_info SET + user_name = COALESCE($2, user_name), + dept_code = COALESCE($3, dept_code), + dept_name = COALESCE($4, dept_name), + position_code = COALESCE($5, position_code), + position_name = COALESCE($6, position_name), + email = COALESCE($7, email), + tel = COALESCE($8, tel), + cell_phone = COALESCE($9, cell_phone), + sabun = COALESCE($10, sabun), + company_code = COALESCE($11, company_code) + WHERE user_id = $1`, + [userId, userName, deptCode, deptName, positionCode, positionName, email, tel, cellPhone, sabun, companyCode] + ); + result.updated++; + } else { + await client.query( + `INSERT INTO user_info ( + user_id, user_name, user_password, + dept_code, dept_name, position_code, position_name, + email, tel, cell_phone, sabun, + company_code, status, regdate + ) VALUES ($1, $2, '', $3, $4, $5, $6, $7, $8, $9, $10, $11, 'active', NOW())`, + [userId, userName, deptCode, deptName, positionCode, positionName, email, tel, cellPhone, sabun, companyCode] + ); + result.inserted++; + } + } + }); + } catch (err: any) { + logger.error("[erpSync] employees 실패", { error: err?.message }); + result.errors.push(err?.message || String(err)); + } + + result.durationMs = Date.now() - start; + return result; +} + +/** + * 부서(dept_info) 동기화 + */ +export async function syncDepartments( + coCd: string, + companyCode: string, + searchText?: string, + executedBy?: string +): Promise { + return withSyncLog("departments", coCd, companyCode, executedBy, () => + doSyncDepartments(coCd, companyCode, searchText) + ); +} + +async function doSyncDepartments( + coCd: string, + companyCode: string, + searchText?: string +): Promise { + const start = Date.now(); + const result: SyncResult = { + resource: "departments", + fetched: 0, + inserted: 0, + updated: 0, + skipped: 0, + errors: [], + durationMs: 0, + }; + + try { + const apiResponse = await fetchDepartments(coCd, searchText); + const records = extractRecords(apiResponse); + result.fetched = records.length; + + const company = await queryOne( + "SELECT company_name FROM company_mng WHERE company_code = $1", + [companyCode] + ); + const companyName = company?.company_name || companyCode; + + await transaction(async (client) => { + for (const r of records) { + const deptCode = asString(pick(r, ["deptCd", "deptCode", "dept_code"])); + const deptName = asString(pick(r, ["deptNm", "deptName", "dept_name"])); + if (!deptCode || !deptName) { + result.skipped++; + continue; + } + const parentDeptCode = asString(pick(r, ["upDeptCd", "parentDeptCode", "parent_dept_code"])); + + const existing = await client.query( + "SELECT dept_code FROM dept_info WHERE dept_code = $1 AND company_code = $2", + [deptCode, companyCode] + ); + + if (existing.rowCount && existing.rowCount > 0) { + await client.query( + `UPDATE dept_info SET + dept_name = $1, + parent_dept_code = $2, + company_name = $3 + WHERE dept_code = $4 AND company_code = $5`, + [deptName, parentDeptCode, companyName, deptCode, companyCode] + ); + result.updated++; + } else { + await client.query( + `INSERT INTO dept_info ( + dept_code, dept_name, company_code, company_name, + parent_dept_code, status, regdate + ) VALUES ($1, $2, $3, $4, $5, 'active', NOW())`, + [deptCode, deptName, companyCode, companyName, parentDeptCode] + ); + result.inserted++; + } + } + }); + } catch (err: any) { + logger.error("[erpSync] departments 실패", { error: err?.message }); + result.errors.push(err?.message || String(err)); + } + + result.durationMs = Date.now() - start; + return result; +} + +/** + * 창고(warehouse_info) 동기화 + */ +export async function syncWarehouses( + coCd: string, + companyCode: string, + baselocFg?: string, + executedBy?: string +): Promise { + return withSyncLog("warehouses", coCd, companyCode, executedBy, () => + doSyncWarehouses(coCd, companyCode, baselocFg) + ); +} + +async function doSyncWarehouses( + coCd: string, + companyCode: string, + baselocFg?: string +): Promise { + const start = Date.now(); + const result: SyncResult = { + resource: "warehouses", + fetched: 0, + inserted: 0, + updated: 0, + skipped: 0, + errors: [], + durationMs: 0, + }; + + try { + const apiResponse = await fetchWarehouses(coCd, baselocFg); + const records = extractRecords(apiResponse); + result.fetched = records.length; + + await transaction(async (client) => { + for (const r of records) { + const warehouseCode = asString(pick(r, ["lctnCd", "warehouseCode", "warehouse_code", "whCd"])); + const warehouseName = asString(pick(r, ["lctnNm", "warehouseName", "warehouse_name", "whNm"])); + if (!warehouseCode || !warehouseName) { + result.skipped++; + continue; + } + const warehouseType = asString(pick(r, ["lctnFg", "warehouseType", "warehouse_type", "whFg"])); + + const existing = await client.query( + "SELECT warehouse_code FROM warehouse_info WHERE warehouse_code = $1 AND company_code = $2", + [warehouseCode, companyCode] + ); + + if (existing.rowCount && existing.rowCount > 0) { + await client.query( + `UPDATE warehouse_info SET + warehouse_name = $1, + warehouse_type = COALESCE($2, warehouse_type) + WHERE warehouse_code = $3 AND company_code = $4`, + [warehouseName, warehouseType, warehouseCode, companyCode] + ); + result.updated++; + } else { + await client.query( + `INSERT INTO warehouse_info ( + warehouse_code, warehouse_name, warehouse_type, + company_code, status, created_at + ) VALUES ($1, $2, $3, $4, 'active', NOW())`, + [warehouseCode, warehouseName, warehouseType, companyCode] + ); + result.inserted++; + } + } + }); + } catch (err: any) { + logger.error("[erpSync] warehouses 실패", { error: err?.message }); + result.errors.push(err?.message || String(err)); + } + + result.durationMs = Date.now() - start; + return result; +} + +/** + * 거래처(customer_mng) 동기화 + */ +export async function syncCustomers( + coCd: string, + companyCode: string, + executedBy?: string +): Promise { + return withSyncLog("customers", coCd, companyCode, executedBy, () => doSyncCustomers(coCd, companyCode)); +} + +async function doSyncCustomers(coCd: string, companyCode: string): Promise { + const start = Date.now(); + const result: SyncResult = { + resource: "customers", + fetched: 0, + inserted: 0, + updated: 0, + skipped: 0, + errors: [], + durationMs: 0, + }; + + try { + const apiResponse = await fetchCustomers(coCd); + const records = extractRecords(apiResponse); + result.fetched = records.length; + + await transaction(async (client) => { + for (const r of records) { + const customerCode = asString(pick(r, ["trCd", "customerCode", "customer_code", "cstmrCd"])); + const customerName = asString(pick(r, ["trNm", "customerName", "customer_name", "cstmrNm"])); + if (!customerCode || !customerName) { + result.skipped++; + continue; + } + const bizNo = asString(pick(r, ["bizNo", "businessNumber", "business_number", "bisnsRgstNo"])); + const ceoName = asString(pick(r, ["repNm", "ceoName", "ceo_name", "rprsvNm"])); + const tel = asString(pick(r, ["tel", "phone"])); + const address = asString(pick(r, ["addr", "address"])); + const customerType = asString(pick(r, ["trFg", "customerType", "customer_type"])); + + const existing = await client.query( + "SELECT customer_code FROM customer_mng WHERE customer_code = $1 AND company_code = $2", + [customerCode, companyCode] + ); + + if (existing.rowCount && existing.rowCount > 0) { + await client.query( + `UPDATE customer_mng SET + customer_name = $1, + business_number = COALESCE($2, business_number), + ceo_name = COALESCE($3, ceo_name), + tel = COALESCE($4, tel), + address = COALESCE($5, address), + customer_type = COALESCE($6, customer_type) + WHERE customer_code = $7 AND company_code = $8`, + [customerName, bizNo, ceoName, tel, address, customerType, customerCode, companyCode] + ); + result.updated++; + } else { + await client.query( + `INSERT INTO customer_mng ( + customer_code, customer_name, business_number, ceo_name, + tel, address, customer_type, company_code, status, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'active', NOW())`, + [customerCode, customerName, bizNo, ceoName, tel, address, customerType, companyCode] + ); + result.inserted++; + } + } + }); + } catch (err: any) { + logger.error("[erpSync] customers 실패", { error: err?.message }); + result.errors.push(err?.message || String(err)); + } + + result.durationMs = Date.now() - start; + return result; +} + +/** + * 계정과목(account_code_info) 동기화 + */ +export async function syncAccountCodes( + coCd: string, + companyCode: string, + executedBy?: string +): Promise { + return withSyncLog("account_codes", coCd, companyCode, executedBy, () => + doSyncAccountCodes(coCd, companyCode) + ); +} + +async function doSyncAccountCodes(coCd: string, companyCode: string): Promise { + const start = Date.now(); + const result: SyncResult = { + resource: "accountCodes", + fetched: 0, + inserted: 0, + updated: 0, + skipped: 0, + errors: [], + durationMs: 0, + }; + + try { + const apiResponse = await fetchAccountCodes(coCd); + const records = extractRecords(apiResponse); + result.fetched = records.length; + + await transaction(async (client) => { + for (const r of records) { + const acctCd = asString(pick(r, ["acctCd", "accountCode", "account_code"])); + const acctNm = asString(pick(r, ["acctNm", "accountName", "account_name"])); + if (!acctCd || !acctNm) { + result.skipped++; + continue; + } + const acctTy = asString(pick(r, ["acctTy", "accountType", "account_type"])); + const useYn = asString(pick(r, ["useYn", "use_yn"])) || "Y"; + + const existing = await client.query( + "SELECT account_code FROM account_code_info WHERE account_code = $1 AND company_code = $2", + [acctCd, companyCode] + ); + + if (existing.rowCount && existing.rowCount > 0) { + await client.query( + `UPDATE account_code_info SET + account_name = $1, + account_type = COALESCE($2, account_type), + use_yn = $3 + WHERE account_code = $4 AND company_code = $5`, + [acctNm, acctTy, useYn, acctCd, companyCode] + ); + result.updated++; + } else { + await client.query( + `INSERT INTO account_code_info ( + account_code, account_name, account_type, use_yn, + company_code, created_at + ) VALUES ($1, $2, $3, $4, $5, NOW())`, + [acctCd, acctNm, acctTy, useYn, companyCode] + ); + result.inserted++; + } + } + }); + } catch (err: any) { + logger.error("[erpSync] accountCodes 실패", { error: err?.message }); + result.errors.push(err?.message || String(err)); + } + + result.durationMs = Date.now() - start; + return result; +} + +/** + * 전체 마스터 일괄 동기화 (사원/부서/창고/거래처/계정과목) + * - 부서를 가장 먼저 동기화 (사원의 dept_code FK 가정). + */ +export async function syncAll( + coCd: string, + companyCode: string, + executedBy?: string +): Promise { + return [ + await syncDepartments(coCd, companyCode, undefined, executedBy), + await syncEmployees(coCd, companyCode, executedBy), + await syncWarehouses(coCd, companyCode, undefined, executedBy), + await syncCustomers(coCd, companyCode, executedBy), + await syncAccountCodes(coCd, companyCode, executedBy), + ]; +} diff --git a/backend-node/src/services/erpTableMigration.ts b/backend-node/src/services/erpTableMigration.ts new file mode 100644 index 00000000..7ff69d17 --- /dev/null +++ b/backend-node/src/services/erpTableMigration.ts @@ -0,0 +1,172 @@ +/** + * Amaranth 동기화 대상 테이블 보장 (idempotent migration). + * - dept_info / user_info / warehouse_info / customer_mng / account_code_info + * - 부팅 시 1회 실행 — 누락 컬럼은 ADD COLUMN IF NOT EXISTS, 신규 테이블은 CREATE TABLE IF NOT EXISTS + */ +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +const STATEMENTS: string[] = [ + // ── customer_mng 누락 컬럼 보충 (Wehago api16S11 응답 필드 전체 커버) ── + `ALTER TABLE customer_mng ADD COLUMN IF NOT EXISTS business_number VARCHAR(32)`, + `ALTER TABLE customer_mng ADD COLUMN IF NOT EXISTS corp_number VARCHAR(32)`, + `ALTER TABLE customer_mng ADD COLUMN IF NOT EXISTS ceo_name VARCHAR(120)`, + `ALTER TABLE customer_mng ADD COLUMN IF NOT EXISTS short_name VARCHAR(120)`, + `ALTER TABLE customer_mng ADD COLUMN IF NOT EXISTS biz_condition VARCHAR(120)`, + `ALTER TABLE customer_mng ADD COLUMN IF NOT EXISTS biz_item VARCHAR(120)`, + `ALTER TABLE customer_mng ADD COLUMN IF NOT EXISTS zip_code VARCHAR(20)`, + `ALTER TABLE customer_mng ADD COLUMN IF NOT EXISTS tel VARCHAR(40)`, + `ALTER TABLE customer_mng ADD COLUMN IF NOT EXISTS fax_no VARCHAR(40)`, + `ALTER TABLE customer_mng ADD COLUMN IF NOT EXISTS hp_no VARCHAR(40)`, + `ALTER TABLE customer_mng ADD COLUMN IF NOT EXISTS email VARCHAR(200)`, + `ALTER TABLE customer_mng ADD COLUMN IF NOT EXISTS address VARCHAR(500)`, + `ALTER TABLE customer_mng ADD COLUMN IF NOT EXISTS address_detail VARCHAR(500)`, + `ALTER TABLE customer_mng ADD COLUMN IF NOT EXISTS charge_name VARCHAR(120)`, + `ALTER TABLE customer_mng ADD COLUMN IF NOT EXISTS charge_tel VARCHAR(40)`, + `ALTER TABLE customer_mng ADD COLUMN IF NOT EXISTS charge_email VARCHAR(200)`, + `ALTER TABLE customer_mng ADD COLUMN IF NOT EXISTS customer_type VARCHAR(20)`, + `ALTER TABLE customer_mng ADD COLUMN IF NOT EXISTS bank_name VARCHAR(120)`, + `ALTER TABLE customer_mng ADD COLUMN IF NOT EXISTS bank_account VARCHAR(80)`, + `ALTER TABLE customer_mng ADD COLUMN IF NOT EXISTS account_owner VARCHAR(120)`, + `ALTER TABLE customer_mng ADD COLUMN IF NOT EXISTS nation_code VARCHAR(20)`, + `ALTER TABLE customer_mng ADD COLUMN IF NOT EXISTS currency_code VARCHAR(20)`, + `ALTER TABLE customer_mng ADD COLUMN IF NOT EXISTS use_yn VARCHAR(1) DEFAULT 'Y'`, + `ALTER TABLE customer_mng ADD COLUMN IF NOT EXISTS reg_date VARCHAR(20)`, + `ALTER TABLE customer_mng ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'active'`, + `ALTER TABLE customer_mng ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT NOW()`, + `ALTER TABLE customer_mng ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT NOW()`, + + // ── warehouse_info 누락 컬럼 보충 (Wehago api20A00S00801) ────── + `ALTER TABLE warehouse_info ADD COLUMN IF NOT EXISTS warehouse_type VARCHAR(20)`, + `ALTER TABLE warehouse_info ADD COLUMN IF NOT EXISTS warehouse_type_name VARCHAR(60)`, + `ALTER TABLE warehouse_info ADD COLUMN IF NOT EXISTS parent_loc_code VARCHAR(40)`, + `ALTER TABLE warehouse_info ADD COLUMN IF NOT EXISTS baseloc_fg VARCHAR(2)`, + `ALTER TABLE warehouse_info ADD COLUMN IF NOT EXISTS sort_seq INTEGER`, + `ALTER TABLE warehouse_info ADD COLUMN IF NOT EXISTS use_yn VARCHAR(1) DEFAULT 'Y'`, + `ALTER TABLE warehouse_info ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'active'`, + `ALTER TABLE warehouse_info ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT NOW()`, + + // ── dept_info 누락 컬럼 보충 (Wehago api16S10) ────────────── + `ALTER TABLE dept_info ADD COLUMN IF NOT EXISTS company_name VARCHAR(200)`, + `ALTER TABLE dept_info ADD COLUMN IF NOT EXISTS dept_short_name VARCHAR(120)`, + `ALTER TABLE dept_info ADD COLUMN IF NOT EXISTS dept_eng_name VARCHAR(200)`, + `ALTER TABLE dept_info ADD COLUMN IF NOT EXISTS dept_level INTEGER`, + `ALTER TABLE dept_info ADD COLUMN IF NOT EXISTS sort_seq INTEGER`, + `ALTER TABLE dept_info ADD COLUMN IF NOT EXISTS use_yn VARCHAR(1) DEFAULT 'Y'`, + `ALTER TABLE dept_info ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'active'`, + `ALTER TABLE dept_info ADD COLUMN IF NOT EXISTS regdate TIMESTAMP DEFAULT NOW()`, + + // ── user_info 누락 컬럼 보충 (Wehago api16S05) ───────────── + `ALTER TABLE user_info ADD COLUMN IF NOT EXISTS sabun VARCHAR(40)`, + `ALTER TABLE user_info ADD COLUMN IF NOT EXISTS emp_seq VARCHAR(40)`, // Wehago 사원 시퀀스 (api16S05/api99u01A11 매칭 키) + `ALTER TABLE user_info ADD COLUMN IF NOT EXISTS dept_name VARCHAR(200)`, + `ALTER TABLE user_info ADD COLUMN IF NOT EXISTS position_code VARCHAR(40)`, + `ALTER TABLE user_info ADD COLUMN IF NOT EXISTS position_name VARCHAR(120)`, + `ALTER TABLE user_info ADD COLUMN IF NOT EXISTS rank_code VARCHAR(40)`, + `ALTER TABLE user_info ADD COLUMN IF NOT EXISTS rank_name VARCHAR(120)`, + `ALTER TABLE user_info ADD COLUMN IF NOT EXISTS join_date VARCHAR(20)`, + `ALTER TABLE user_info ADD COLUMN IF NOT EXISTS retire_date VARCHAR(20)`, + `ALTER TABLE user_info ADD COLUMN IF NOT EXISTS work_status VARCHAR(20)`, + `ALTER TABLE user_info ADD COLUMN IF NOT EXISTS user_name_eng VARCHAR(200)`, + `ALTER TABLE user_info ADD COLUMN IF NOT EXISTS out_email VARCHAR(200)`, + `ALTER TABLE user_info ADD COLUMN IF NOT EXISTS gender_fg VARCHAR(2)`, + // user_info 의 sabun, emp_seq 에 UNIQUE 인덱스 — UPSERT(ON CONFLICT) 동작 보장 + // - 부분 인덱스(WHERE)는 ON CONFLICT 에서 매칭이 안 되므로 NON-PARTIAL UNIQUE 사용 + // - PostgreSQL UNIQUE 는 다중 NULL 허용 (기존 NULL 행들은 충돌하지 않음) + `DROP INDEX IF EXISTS uniq_user_info_sabun`, + `DROP INDEX IF EXISTS uniq_user_info_emp_seq`, + `CREATE UNIQUE INDEX IF NOT EXISTS uniq_user_info_sabun ON user_info (sabun)`, + `CREATE UNIQUE INDEX IF NOT EXISTS uniq_user_info_emp_seq ON user_info (emp_seq)`, + + // ── batch_mappings: 조건부 매핑(conditional) 설정 저장용 ────── + // mapping_config(JSON 문자열) — { rules:[{when, then}], default } + `ALTER TABLE batch_mappings ADD COLUMN IF NOT EXISTS mapping_config TEXT`, + + // ── 단일 컬럼 UNIQUE 인덱스 (ON CONFLICT 단일 키 매칭용) ───────── + // - 기존 PK 가 (col, company_code) 복합키여서 단일 col 만으론 ON CONFLICT 매칭이 안 됨 + // - 단일 회사 운영(COMPANY_16) 가정 + Amaranth 마스터 코드는 회사 간 교차되지 않음 + `CREATE UNIQUE INDEX IF NOT EXISTS uniq_dept_info_dept_code ON dept_info (dept_code)`, + `CREATE UNIQUE INDEX IF NOT EXISTS uniq_warehouse_info_warehouse_code ON warehouse_info (warehouse_code)`, + `CREATE UNIQUE INDEX IF NOT EXISTS uniq_customer_mng_customer_code ON customer_mng (customer_code)`, + `CREATE UNIQUE INDEX IF NOT EXISTS uniq_account_code_info_account_code ON account_code_info (account_code)`, + + // ── 계정과목 (신규 테이블) — Wehago api11A02 응답 필드 모두 커버 ── + `CREATE TABLE IF NOT EXISTS account_code_info ( + id BIGSERIAL PRIMARY KEY, + account_code VARCHAR(40) NOT NULL, + account_name VARCHAR(200) NOT NULL, + account_type VARCHAR(20), + use_yn VARCHAR(1) DEFAULT 'Y', + company_code VARCHAR(40) NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + UNIQUE (account_code, company_code) + )`, + // 누락 컬럼 보충 (재실행 안전) + `ALTER TABLE account_code_info ADD COLUMN IF NOT EXISTS group_seq VARCHAR(40)`, + `ALTER TABLE account_code_info ADD COLUMN IF NOT EXISTS group_code VARCHAR(40)`, + `ALTER TABLE account_code_info ADD COLUMN IF NOT EXISTS group_name VARCHAR(200)`, + `ALTER TABLE account_code_info ADD COLUMN IF NOT EXISTS dr_cr_fg VARCHAR(2)`, + `ALTER TABLE account_code_info ADD COLUMN IF NOT EXISTS sub_disp VARCHAR(20)`, + `ALTER TABLE account_code_info ADD COLUMN IF NOT EXISTS sub_disp_name VARCHAR(120)`, + `ALTER TABLE account_code_info ADD COLUMN IF NOT EXISTS ch_fg VARCHAR(2)`, + `ALTER TABLE account_code_info ADD COLUMN IF NOT EXISTS ch_fg_name VARCHAR(120)`, + `ALTER TABLE account_code_info ADD COLUMN IF NOT EXISTS account_short VARCHAR(120)`, + `ALTER TABLE account_code_info ADD COLUMN IF NOT EXISTS bud_fg VARCHAR(2)`, + `ALTER TABLE account_code_info ADD COLUMN IF NOT EXISTS bud_fg_name VARCHAR(120)`, + `ALTER TABLE account_code_info ADD COLUMN IF NOT EXISTS attr_fg VARCHAR(2)`, + `ALTER TABLE account_code_info ADD COLUMN IF NOT EXISTS attr_fg_name VARCHAR(120)`, + `ALTER TABLE account_code_info ADD COLUMN IF NOT EXISTS racct_code VARCHAR(40)`, + `ALTER TABLE account_code_info ADD COLUMN IF NOT EXISTS racct_name VARCHAR(200)`, + `ALTER TABLE account_code_info ADD COLUMN IF NOT EXISTS fill_yn VARCHAR(1)`, + `ALTER TABLE account_code_info ADD COLUMN IF NOT EXISTS ctrl_cds VARCHAR(120)`, + `ALTER TABLE account_code_info ADD COLUMN IF NOT EXISTS ext_input_cd VARCHAR(40)`, + `CREATE INDEX IF NOT EXISTS idx_account_code_info_company ON account_code_info (company_code)`, + + // ── ERP 동기화 실행 이력 (선택) ────────────────────────────── + `CREATE TABLE IF NOT EXISTS erp_sync_log ( + id BIGSERIAL PRIMARY KEY, + resource VARCHAR(40) NOT NULL, + co_cd VARCHAR(20), + company_code VARCHAR(40), + fetched_count INTEGER DEFAULT 0, + inserted_count INTEGER DEFAULT 0, + updated_count INTEGER DEFAULT 0, + skipped_count INTEGER DEFAULT 0, + duration_ms INTEGER DEFAULT 0, + success BOOLEAN DEFAULT TRUE, + error_message TEXT, + executed_by VARCHAR(80), + executed_at TIMESTAMP DEFAULT NOW() + )`, + `CREATE INDEX IF NOT EXISTS idx_erp_sync_log_resource ON erp_sync_log (resource, executed_at DESC)`, + + // ── batch_configs.row_filter_config: 행 단위 제외 필터 (JSON) ───── + // 예) {"exclude":[{"column":"loginId","op":"eq","value":"wace"}]} + // 배치 실행 시 fromData 에서 매칭되는 행을 동기화에서 제외합니다. + `ALTER TABLE batch_configs ADD COLUMN IF NOT EXISTS row_filter_config TEXT`, +]; + +export async function ensureErpTables(): Promise { + const pool = getPool(); + let success = 0; + let failed = 0; + + for (const stmt of STATEMENTS) { + try { + await pool.query(stmt); + success++; + } catch (e: any) { + // ALTER TABLE 대상이 아예 없는 경우엔 무시 + const msg = e?.message || ""; + if (msg.includes("does not exist")) { + logger.warn(`[erpTableMigration] 스킵 (대상 미존재): ${stmt.split("\n")[0].trim()}`); + continue; + } + failed++; + logger.error(`[erpTableMigration] 실패: ${msg}`); + } + } + + logger.info(`🔧 ERP 동기화 대상 테이블 점검 완료: 성공 ${success}건 / 실패 ${failed}건`); +} diff --git a/backend-node/src/services/externalRestApiConnectionService.ts b/backend-node/src/services/externalRestApiConnectionService.ts index 6f0b1239..6942eb73 100644 --- a/backend-node/src/services/externalRestApiConnectionService.ts +++ b/backend-node/src/services/externalRestApiConnectionService.ts @@ -486,11 +486,17 @@ export class ExternalRestApiConnectionService { /** * 인증 헤더 생성 + * + * @param authType 인증 타입 + * @param authConfig 인증 설정 + * @param companyCode 회사 코드 (db-token 모드에서 필요) + * @param requestUrl 실제 요청 URL — wehago 인증 모드에서 urlPath 추출용 */ static async getAuthHeaders( authType: AuthType, authConfig: any, - companyCode?: string + companyCode?: string, + requestUrl?: string ): Promise> { const headers: Record = {}; @@ -578,6 +584,44 @@ export class ExternalRestApiConnectionService { if (authConfig.keyLocation === "header") { headers[authConfig.keyName] = authConfig.keyValue; } + } else if (authType === "wehago" && authConfig) { + // Wehago / Amaranth (RPS ERP) 인증 + // - HMAC-SHA256(accessToken + transactionId + timestamp + urlPath, hashKey) → Base64 + // - 참고: /Users/chpark/wace_plm/src/com/pms/api 의 Java 클라이언트와 동일 프로토콜 + const callerName = authConfig.callerName; + const accessToken = authConfig.accessToken; + const hashKey = authConfig.hashKey; + const groupSeq = authConfig.groupSeq; + + if (!callerName || !accessToken || !hashKey || !groupSeq) { + throw new Error( + "Wehago/Amaranth 인증 설정이 올바르지 않습니다. callerName, accessToken, hashKey, groupSeq 모두 입력해주세요." + ); + } + + // urlPath 추출 (서명 대상) + let urlPath = ""; + try { + const u = new URL(requestUrl || "http://placeholder/"); + urlPath = (u.pathname || "") + (u.search || ""); + } catch { + urlPath = requestUrl || ""; + } + + const transactionId = crypto.randomBytes(16).toString("hex"); + const timestamp = String(Math.floor(Date.now() / 1000)); + const signValue = accessToken + transactionId + timestamp + urlPath; + const wehagoSign = crypto + .createHmac("sha256", hashKey) + .update(signValue, "utf8") + .digest("base64"); + + headers["callerName"] = callerName; + headers["Authorization"] = `Bearer ${accessToken}`; + headers["transaction-id"] = transactionId; + headers["timestamp"] = timestamp; + headers["groupSeq"] = groupSeq; + headers["wehago-sign"] = wehagoSign; } return headers; @@ -596,15 +640,7 @@ export class ExternalRestApiConnectionService { // 헤더 구성 let headers = { ...testRequest.headers }; - // 인증 헤더 생성 및 병합 - const authHeaders = await this.getAuthHeaders( - testRequest.auth_type, - testRequest.auth_config, - userCompanyCode - ); - headers = { ...headers, ...authHeaders }; - - // URL 구성 + // URL 먼저 구성 (wehago-sign 은 urlPath 기반이므로 URL 확정 후 헤더 생성) let url = testRequest.base_url; if (testRequest.endpoint) { url = testRequest.endpoint.startsWith("/") @@ -623,6 +659,15 @@ export class ExternalRestApiConnectionService { url = `${url}${separator}${testRequest.auth_config.keyName}=${testRequest.auth_config.keyValue}`; } + // 인증 헤더 생성 및 병합 (URL 확정 후) + const authHeaders = await this.getAuthHeaders( + testRequest.auth_type, + testRequest.auth_config, + userCompanyCode, + url + ); + headers = { ...headers, ...authHeaders }; + logger.info( `REST API 연결 테스트: ${testRequest.method || "GET"} ${url}` ); @@ -824,18 +869,13 @@ export class ExternalRestApiConnectionService { const encrypted = { ...authConfig }; // 암호화 대상 필드 - if (encrypted.keyValue) { - encrypted.keyValue = this.encrypt(encrypted.keyValue); - } - if (encrypted.token) { - encrypted.token = this.encrypt(encrypted.token); - } - if (encrypted.password) { - encrypted.password = this.encrypt(encrypted.password); - } - if (encrypted.clientSecret) { - encrypted.clientSecret = this.encrypt(encrypted.clientSecret); - } + if (encrypted.keyValue) encrypted.keyValue = this.encrypt(encrypted.keyValue); + if (encrypted.token) encrypted.token = this.encrypt(encrypted.token); + if (encrypted.password) encrypted.password = this.encrypt(encrypted.password); + if (encrypted.clientSecret) encrypted.clientSecret = this.encrypt(encrypted.clientSecret); + // Wehago / Amaranth (RPS ERP) + if (encrypted.accessToken) encrypted.accessToken = this.encrypt(encrypted.accessToken); + if (encrypted.hashKey) encrypted.hashKey = this.encrypt(encrypted.hashKey); return encrypted; } @@ -848,23 +888,22 @@ export class ExternalRestApiConnectionService { const decrypted = { ...authConfig }; - // 복호화 대상 필드 - try { - if (decrypted.keyValue) { - decrypted.keyValue = this.decrypt(decrypted.keyValue); + // 복호화 대상 필드 (각각 try/catch — 평문이 섞여있어도 부분적으로 복호화) + const tryDecrypt = (field: string) => { + if (decrypted[field]) { + try { + decrypted[field] = this.decrypt(decrypted[field]); + } catch { + // 평문 그대로 둔다 (마이그레이션 호환) + } } - if (decrypted.token) { - decrypted.token = this.decrypt(decrypted.token); - } - if (decrypted.password) { - decrypted.password = this.decrypt(decrypted.password); - } - if (decrypted.clientSecret) { - decrypted.clientSecret = this.decrypt(decrypted.clientSecret); - } - } catch (error) { - logger.warn("민감 정보 복호화 실패 (암호화되지 않은 데이터일 수 있음)"); - } + }; + tryDecrypt("keyValue"); + tryDecrypt("token"); + tryDecrypt("password"); + tryDecrypt("clientSecret"); + tryDecrypt("accessToken"); + tryDecrypt("hashKey"); return decrypted; } @@ -1096,6 +1135,7 @@ export class ExternalRestApiConnectionService { "basic", "oauth2", "db-token", + "wehago", ]; if (!validAuthTypes.includes(data.auth_type)) { throw new Error("올바르지 않은 인증 타입입니다."); diff --git a/backend-node/src/types/batchTypes.ts b/backend-node/src/types/batchTypes.ts index 9933194b..f6c3d7a6 100644 --- a/backend-node/src/types/batchTypes.ts +++ b/backend-node/src/types/batchTypes.ts @@ -162,6 +162,7 @@ export interface CreateBatchConfigRequest { executionType?: BatchExecutionType; nodeFlowId?: number; nodeFlowContext?: Record; + rowFilterConfig?: { exclude?: Array<{ column: string; op?: string; value: any }> } | null; mappings: BatchMappingRequest[]; } @@ -177,6 +178,7 @@ export interface UpdateBatchConfigRequest { executionType?: BatchExecutionType; nodeFlowId?: number; nodeFlowContext?: Record; + rowFilterConfig?: { exclude?: Array<{ column: string; op?: string; value: any }> } | null; mappings?: BatchMappingRequest[]; } diff --git a/backend-node/src/types/externalRestApiTypes.ts b/backend-node/src/types/externalRestApiTypes.ts index 416cbe6f..01e71623 100644 --- a/backend-node/src/types/externalRestApiTypes.ts +++ b/backend-node/src/types/externalRestApiTypes.ts @@ -6,7 +6,8 @@ export type AuthType = | "bearer" | "basic" | "oauth2" - | "db-token"; + | "db-token" + | "wehago"; export interface ExternalRestApiConnection { id?: number; @@ -47,6 +48,12 @@ export interface ExternalRestApiConnection { dbWhereValue?: string; dbHeaderName?: string; dbHeaderTemplate?: string; + + // Wehago / Amaranth (RPS ERP) — 매 요청 HMAC-SHA256 서명 + callerName?: string; + accessToken?: string; + hashKey?: string; + groupSeq?: string; }; timeout?: number; retry_count?: number; @@ -100,4 +107,5 @@ export const AUTH_TYPE_OPTIONS = [ { value: "basic", label: "Basic Auth" }, { value: "oauth2", label: "OAuth 2.0" }, { value: "db-token", label: "DB 토큰" }, + { value: "wehago", label: "Wehago/Amaranth (아마란스)" }, ]; diff --git a/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx b/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx index e64823b9..7e9c521a 100644 --- a/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx +++ b/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx @@ -35,6 +35,7 @@ import { } from "@/lib/api/batch"; import { ScrollToTop } from "@/components/common/ScrollToTop"; import { useTabStore } from "@/stores/tabStore"; +import { Pagination } from "@/components/common/Pagination"; function cronToKorean(cron: string): string { const parts = cron.split(" "); @@ -329,6 +330,10 @@ export default function BatchManagementPage() { const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false); const [togglingBatch, setTogglingBatch] = useState(null); + // 페이지네이션 상태 + const [currentPage, setCurrentPage] = useState(1); + const [itemsPerPage, setItemsPerPage] = useState(20); + const loadBatchConfigs = useCallback(async () => { setLoading(true); try { @@ -445,6 +450,11 @@ export default function BatchManagementPage() { } }; + // 검색/상태 필터 변경 시 1페이지로 리셋 + useEffect(() => { + setCurrentPage(1); + }, [searchTerm, statusFilter]); + const filteredBatches = batchConfigs.filter((batch) => { if (searchTerm && !batch.batch_name.toLowerCase().includes(searchTerm.toLowerCase()) && !(batch.description || "").toLowerCase().includes(searchTerm.toLowerCase())) return false; if (statusFilter === "active" && batch.is_active !== "Y") return false; @@ -452,6 +462,14 @@ export default function BatchManagementPage() { return true; }); + // 페이지네이션 계산 + const totalItems = filteredBatches.length; + const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage)); + const safePage = Math.min(currentPage, totalPages); + const startIdx = (safePage - 1) * itemsPerPage; + const endIdx = Math.min(startIdx + itemsPerPage, totalItems); + const pagedBatches = filteredBatches.slice(startIdx, endIdx); + const activeBatches = batchConfigs.filter(b => b.is_active === "Y").length; const inactiveBatches = batchConfigs.length - activeBatches; const execDiff = stats ? stats.todayExecutions - stats.prevDayExecutions : 0; @@ -459,7 +477,7 @@ export default function BatchManagementPage() { return (
-
+
{/* 헤더 */}
@@ -542,7 +560,7 @@ export default function BatchManagementPage() {
{/* 배치 리스트 */} -
+
{loading && batchConfigs.length === 0 && (
@@ -556,7 +574,7 @@ export default function BatchManagementPage() {
)} - {filteredBatches.map((batch) => { + {pagedBatches.map((batch) => { const batchId = batch.id!; const isExpanded = expandedBatch === batchId; const isExecuting = executingBatch === batchId; @@ -572,92 +590,99 @@ export default function BatchManagementPage() { return (
- {/* 행 */} -
handleRowClick(batchId)}> + {/* 행 — 컴팩트 */} +
handleRowClick(batchId)}> {/* 토글 */}
e.stopPropagation()} className="shrink-0"> toggleBatchActive(batchId, batch.is_active || "N")} disabled={isToggling} - className="scale-[0.7]" + className="scale-[0.65]" />
- {/* 배치 이름 + 설명 */} + {/* 배치 이름 + 설명 (한 줄로 합침) */}
-

{batch.batch_name}

-

{batch.description || ""}

+
+ {batch.batch_name} + {batch.description && ( + {batch.description} + )} +
{/* 타입 뱃지 */} - + {typeStyle.label} - {/* 스케줄 */} -
-

{cronToKorean(batch.cron_schedule)}

-

- {getNextExecution(batch.cron_schedule, isActive) - ? `다음: ${getNextExecution(batch.cron_schedule, isActive)}` - : ""} -

+ {/* 스케줄 (한 줄로) */} +
+

{cronToKorean(batch.cron_schedule)}

+ {getNextExecution(batch.cron_schedule, isActive) && ( +

{getNextExecution(batch.cron_schedule, isActive)}

+ )}
- {/* 인라인 미니 스파크라인 */} -
+ {/* 스파크라인 */} +
- {/* 마지막 실행 */} -
+ {/* 마지막 실행 — 한 줄로 */} +
{isExecuting ? ( -

실행 중...

+ 실행 중 ) : lastAt ? ( <> -
- {isFailed ? ( - - ) : isSuccess ? ( - - ) : null} - - {isFailed ? "실패" : "성공"} - -
-

{timeAgo(lastAt)}

+ {isFailed ? ( + + ) : isSuccess ? ( + + ) : null} + + {isFailed ? "실패" : "성공"} + + {timeAgo(lastAt)} ) : ( -

+ )}
{/* 액션 */}
- +
@@ -681,6 +706,32 @@ export default function BatchManagementPage() { })}
+ {/* 페이지네이션 — 항상 표시 */} + {!loading && ( +
+ { + setItemsPerPage(size); + setCurrentPage(1); + }} + showPageSizeSelector + pageSizeOptions={[10, 20, 50, 100]} + /> +
+ )} + + {/* 하단 여백 — 마지막 항목이 뷰포트 끝에 닿지 않도록 */} +
+ {/* 배치 타입 선택 모달 */} {isBatchTypeModalOpen && (
setIsBatchTypeModalOpen(false)}> diff --git a/frontend/app/(main)/admin/automaticMng/erpSync/page.tsx b/frontend/app/(main)/admin/automaticMng/erpSync/page.tsx new file mode 100644 index 00000000..ce7382f5 --- /dev/null +++ b/frontend/app/(main)/admin/automaticMng/erpSync/page.tsx @@ -0,0 +1,272 @@ +"use client"; + +/** + * ERP 마스터 동기화 (자동화 관리 > 배치 관리 > ERP 동기화) + * + * 외부 ERP(Wehago/RPS) REST API를 호출해 사원·부서·창고·거래처·계정과목 정보를 + * 내부 DB(user_info / dept_info / warehouse_info / customer_mng / account_code_info)에 + * UPSERT 합니다. 백엔드 라우트는 /api/erp-sync/*. + */ +import React, { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { + Building2, + Users, + Warehouse, + Briefcase, + BookOpen, + PlayCircle, + Loader2, + CheckCircle2, + AlertCircle, +} from "lucide-react"; +import { toast } from "sonner"; +import { ErpSyncAPI, type ErpSyncResult } from "@/lib/api/erpSync"; + +type ResourceKey = "departments" | "employees" | "warehouses" | "customers" | "account-codes"; + +const RESOURCE_DEFS: Array<{ + key: ResourceKey; + label: string; + table: string; + icon: React.ComponentType<{ className?: string }>; + description: string; +}> = [ + { + key: "departments", + label: "부서", + table: "dept_info", + icon: Building2, + description: "Wehago api16S10 — 회사의 부서 마스터를 가져와 dept_info 에 적재합니다.", + }, + { + key: "employees", + label: "사원", + table: "user_info", + icon: Users, + description: "Wehago api16S05 — 사원 목록(이메일/연락처 포함)을 user_info 에 적재합니다.", + }, + { + key: "warehouses", + label: "창고", + table: "warehouse_info", + icon: Warehouse, + description: "Wehago api20A00S00801 — 창고/위치 마스터를 warehouse_info 에 적재합니다.", + }, + { + key: "customers", + label: "거래처", + table: "customer_mng", + icon: Briefcase, + description: "Wehago api16S11 — 거래처 마스터를 customer_mng 에 적재합니다.", + }, + { + key: "account-codes", + label: "계정과목", + table: "account_code_info", + icon: BookOpen, + description: "Wehago api11A02 — 계정과목을 account_code_info 에 적재합니다.", + }, +]; + +export default function ErpSyncPage() { + const [coCd, setCoCd] = useState("2000"); + const [companyCode, setCompanyCode] = useState("COMPANY_16"); + const [busy, setBusy] = useState(null); + const [results, setResults] = useState>({}); + + const handleSyncOne = async (key: ResourceKey) => { + if (!coCd.trim()) { + toast.error("회사코드(coCd) 를 입력해 주세요."); + return; + } + setBusy(key); + try { + const result = await ErpSyncAPI.syncOne(key, { coCd: coCd.trim(), companyCode: companyCode.trim() }); + setResults((prev) => ({ ...prev, [key]: result })); + if (result.errors.length === 0) { + toast.success(`${labelOf(key)} 동기화 완료 — 신규 ${result.inserted}건 / 갱신 ${result.updated}건`); + } else { + toast.error(`${labelOf(key)} 동기화 실패 — ${result.errors[0]}`); + } + } catch (e: any) { + toast.error(e?.message || `${labelOf(key)} 동기화 중 오류`); + } finally { + setBusy(null); + } + }; + + const handleSyncAll = async () => { + if (!coCd.trim()) { + toast.error("회사코드(coCd) 를 입력해 주세요."); + return; + } + setBusy("all"); + try { + const { totals, results: list } = await ErpSyncAPI.syncAll({ + coCd: coCd.trim(), + companyCode: companyCode.trim(), + }); + const next: Record = {}; + for (const r of list) { + const k = r.resource === "accountCodes" ? "account-codes" : r.resource; + next[k] = r; + } + setResults((prev) => ({ ...prev, ...next })); + if (totals && totals.errorsCount === 0) { + toast.success(`전체 동기화 완료 — 신규 ${totals.inserted}건 / 갱신 ${totals.updated}건`); + } else { + toast.error(`전체 동기화 — 오류 ${totals?.errorsCount ?? 0}건 발생`); + } + } catch (e: any) { + toast.error(e?.message || "전체 동기화 중 오류"); + } finally { + setBusy(null); + } + }; + + return ( +
+
+
+
+

ERP 마스터 동기화

+

+ 외부 ERP에서 사원·부서·창고·거래처·계정과목을 가져와 내부 DB에 적재해요. +

+
+ +
+ + {/* 입력 영역 */} +
+
+
+ + setCoCd(e.target.value)} + placeholder="예: 2000" + className="h-9 text-sm" + /> +
+
+ + setCompanyCode(e.target.value)} + placeholder="예: COMPANY_16" + className="h-9 text-sm" + /> +
+
+
+ + {/* 리소스 카드 */} +
+ {RESOURCE_DEFS.map(({ key, label, table, icon: Icon, description }) => { + const r = results[key]; + const running = busy === key; + const success = r && r.errors.length === 0; + return ( +
+
+
+
+ +
+
+
{label}
+
{table}
+
+
+ +
+

{description}

+ + {r && ( +
+
+
+ {success ? ( + + ) : ( + + )} + + {success ? "성공" : "실패"} + +
+ + {(r.durationMs / 1000).toFixed(2)}초 + +
+
+ + 조회 {r.fetched} + + + 신규 {r.inserted} + + + 갱신 {r.updated} + + {r.skipped > 0 && ( + + 건너뜀 {r.skipped} + + )} +
+ {r.errors.length > 0 && ( +
+ {r.errors[0]} +
+ )} +
+ )} +
+ ); + })} +
+ +
+
ERP 인증/엔드포인트
+
    +
  • baseUrl: ERP_BASE_URL (기본 https://erp.rps-korea.com)
  • +
  • 인증: callerName + Bearer 토큰 + transaction-id + timestamp + groupSeq + wehago-sign(HMAC-SHA256)
  • +
  • 참고 원본: /Users/chpark/wace_plm/src/com/pms/api (Java)
  • +
+
+
+
+ ); +} + +function labelOf(k: ResourceKey): string { + return RESOURCE_DEFS.find((d) => d.key === k)?.label ?? k; +} diff --git a/frontend/app/(main)/admin/automaticMng/exconList/page.tsx b/frontend/app/(main)/admin/automaticMng/exconList/page.tsx index 89ecba46..b778a9fc 100644 --- a/frontend/app/(main)/admin/automaticMng/exconList/page.tsx +++ b/frontend/app/(main)/admin/automaticMng/exconList/page.tsx @@ -28,6 +28,7 @@ import { SqlQueryModal } from "@/components/admin/SqlQueryModal"; import { RestApiConnectionList } from "@/components/admin/RestApiConnectionList"; import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView"; import { ScrollToTop } from "@/components/common/ScrollToTop"; +import { Pagination } from "@/components/common/Pagination"; type ConnectionTabType = "database" | "rest-api"; @@ -69,6 +70,10 @@ export default function ExternalConnectionsPage() { const [sqlModalOpen, setSqlModalOpen] = useState(false); const [selectedConnection, setSelectedConnection] = useState(null); + // 페이지네이션 상태 + const [currentPage, setCurrentPage] = useState(1); + const [itemsPerPage, setItemsPerPage] = useState(20); + // 데이터 로딩 const loadConnections = async () => { try { @@ -121,6 +126,19 @@ export default function ExternalConnectionsPage() { loadConnections(); }, [searchTerm, dbTypeFilter, activeStatusFilter]); + // 필터 변경 시 1페이지로 리셋 + useEffect(() => { + setCurrentPage(1); + }, [searchTerm, dbTypeFilter, activeStatusFilter]); + + // 페이지네이션 계산 + const totalItems = connections.length; + const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage)); + const safePage = Math.min(currentPage, totalPages); + const startIdx = (safePage - 1) * itemsPerPage; + const endIdx = Math.min(startIdx + itemsPerPage, totalItems); + const pagedConnections = connections.slice(startIdx, endIdx); + const handleAddConnection = () => { setEditingConnection(undefined); setIsModalOpen(true); @@ -264,43 +282,43 @@ export default function ExternalConnectionsPage() { ]; return ( -
-
- {/* 페이지 헤더 */} -
-

외부 커넥션 관리

-

외부 데이터베이스 및 REST API 연결 정보를 관리합니다

+
+
+ {/* 페이지 헤더 — 컴팩트 */} +
+

외부 커넥션 관리

+ 외부 데이터베이스 및 REST API 연결 정보를 관리합니다
{/* 탭 */} setActiveTab(value as ConnectionTabType)}> - - - + + + 데이터베이스 연결 - - + + REST API 연결 {/* 데이터베이스 연결 탭 */} - + {/* 검색 및 필터 */} -
+
-
+
setSearchTerm(e.target.value)} - className="h-10 pl-10 text-sm" + className="h-9 pl-10 text-sm" />
+
+ 총 {totalItems} 건 +
- @@ -332,7 +353,7 @@ export default function ExternalConnectionsPage() { {/* 연결 목록 - ResponsiveDataView */} String(c.id || c.connection_name)} isLoading={loading} @@ -382,6 +403,27 @@ export default function ExternalConnectionsPage() { actionsWidth="180px" /> + {/* 페이지네이션 — 항상 표시 */} +
+ { + setItemsPerPage(size); + setCurrentPage(1); + }} + showPageSizeSelector + pageSizeOptions={[10, 20, 50, 100]} + /> +
+ {/* 연결 설정 모달 */} {isModalOpen && ( {/* REST API 연결 탭 */} - + diff --git a/frontend/app/(main)/admin/automaticMng/flowMgmtList/page.tsx b/frontend/app/(main)/admin/automaticMng/flowMgmtList/page.tsx index 9ade5843..e16e5be9 100644 --- a/frontend/app/(main)/admin/automaticMng/flowMgmtList/page.tsx +++ b/frontend/app/(main)/admin/automaticMng/flowMgmtList/page.tsx @@ -36,6 +36,7 @@ import { ScrollToTop } from "@/components/common/ScrollToTop"; import { ExternalDbConnectionAPI, ExternalDbConnection } from "@/lib/api/externalDbConnection"; import { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection"; import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView"; +import { Pagination } from "@/components/common/Pagination"; export default function FlowManagementPage() { const router = useRouter(); @@ -515,6 +516,10 @@ export default function FlowManagementPage() { // 검색 필터 상태 const [searchText, setSearchText] = useState(""); + // 페이지네이션 상태 + const [currentPage, setCurrentPage] = useState(1); + const [itemsPerPage, setItemsPerPage] = useState(20); + // 검색 필터링된 플로우 목록 const filteredFlows = searchText ? flows.filter( @@ -525,6 +530,19 @@ export default function FlowManagementPage() { ) : flows; + // 페이지네이션 계산 + const totalItems = filteredFlows.length; + const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage)); + const safePage = Math.min(currentPage, totalPages); + const startIdx = (safePage - 1) * itemsPerPage; + const endIdx = Math.min(startIdx + itemsPerPage, totalItems); + const pagedFlows = filteredFlows.slice(startIdx, endIdx); + + // 검색어 변경 시 1페이지로 리셋 + useEffect(() => { + setCurrentPage(1); + }, [searchText]); + // ResponsiveDataView 컬럼 정의 const columns: RDVColumn[] = [ { @@ -588,43 +606,48 @@ export default function FlowManagementPage() { ]; return ( -
-
- {/* 페이지 헤더 */} -
-

플로우 관리

-

업무 프로세스 플로우를 생성하고 관리합니다

+
+
+ {/* 페이지 헤더 — 컴팩트하게 */} +
+
+

플로우 관리

+ 업무 프로세스 플로우를 생성·관리합니다 +
+
+ 총 {totalItems} 건 + {totalPages > 1 && ( + + ({safePage} / {totalPages} 페이지) + + )} +
- {/* 검색 툴바 (반응형) */} -
-
-
- - setSearchText(e.target.value)} - className="h-10 pl-10 text-sm" - /> -
-
- 총 {filteredFlows.length} 건 -
+ {/* 검색 툴바 */} +
+
+ + setSearchText(e.target.value)} + className="h-8 pl-9 text-xs" + />
-
{/* 플로우 목록 (ResponsiveDataView) */} - data={filteredFlows} + data={pagedFlows} columns={columns} keyExtractor={(f) => String(f.id)} isLoading={loading} emptyMessage="생성된 플로우가 없습니다." - skeletonCount={6} + skeletonCount={Math.min(6, itemsPerPage)} cardTitle={(f) => f.name} cardSubtitle={(f) => f.description || "설명 없음"} cardHeaderRight={(f) => @@ -639,32 +662,53 @@ export default function FlowManagementPage() { )} - actionsWidth="160px" + actionsWidth="130px" /> + {/* 페이지네이션 — 항상 표시 */} +
+ { + setItemsPerPage(size); + setCurrentPage(1); + }} + showPageSizeSelector + pageSizeOptions={[10, 20, 50, 100]} + /> +
+ {/* 생성 다이얼로그 */} diff --git a/frontend/app/(main)/admin/automaticMng/mail/dashboardList/page.tsx b/frontend/app/(main)/admin/automaticMng/mail/dashboardList/page.tsx index a6d48424..4f75ded4 100644 --- a/frontend/app/(main)/admin/automaticMng/mail/dashboardList/page.tsx +++ b/frontend/app/(main)/admin/automaticMng/mail/dashboardList/page.tsx @@ -195,54 +195,55 @@ export default function MailDashboardPage() { return (
-
+
{/* 페이지 제목 */} -
-
-
- +
+
+
+
-

메일 관리 대시보드

-

메일 시스템의 전체 현황을 한눈에 확인하세요

+

메일 관리 대시보드

+

메일 시스템의 전체 현황을 한눈에 확인하세요

-
+
-
{/* 통계 카드 */} -
+
{statCards.map((stat, index) => ( - - -
-
-

+ + +

+
+

{stat.title}

-

+

{stat.value}

-
- +
+
{/* 진행 바 */} -
-
+
@@ -253,103 +254,94 @@ export default function MailDashboardPage() { ))}
- {/* 이번 달 통계 */} -
+ {/* 본문: 이번 달 통계 + 시스템 상태 + 빠른 액세스 (FullHD에서 3열 그리드) */} +
- - -
- + + +
+
이번 달 발송 통계
- -
-
- 총 발송 건수 - {stats.sentThisMonth} 건 + +
+
+ 총 발송 건수 + {stats.sentThisMonth} 건
-
- 성공률 - {stats.successRate}% +
+ 성공률 + {stats.successRate}%
- {/* 전월 대비 통계는 현재 불필요하여 주석처리 -
-
- - 전월 대비 -
- +12% -
- */}
- - -
- + + +
+
시스템 상태
- -
-
-
-
- 메일 서버 + +
+
+
+
+ 메일 서버
- 정상 작동 + 정상 작동
-
-
-
- 활성 계정 +
+
+
+ 활성 계정
- {stats.totalAccounts} 개 + {stats.totalAccounts} 개
-
-
-
- 사용 가능 템플릿 +
+
+
+ 사용 가능 템플릿
- {stats.totalTemplates} 개 + {stats.totalTemplates} 개
-
- {/* 빠른 액세스 */} - - - 빠른 액세스 - - -
- {quickLinks.map((link, index) => ( - -
- -
-
-

{link.title}

-

{link.description}

-
- -
- ))} -
-
-
+ {/* 빠른 액세스 — FullHD에서는 우측 컬럼에 함께 배치 */} + + + 빠른 액세스 + + +
+ {quickLinks.map((link, index) => ( + +
+ +
+
+

{link.title}

+

{link.description}

+
+ +
+ ))} +
+
+
+
); diff --git a/frontend/app/(main)/admin/batch-management-new/page.tsx b/frontend/app/(main)/admin/batch-management-new/page.tsx index 5b1fe16f..73383233 100644 --- a/frontend/app/(main)/admin/batch-management-new/page.tsx +++ b/frontend/app/(main)/admin/batch-management-new/page.tsx @@ -1,18 +1,23 @@ "use client"; import React, { useState, useEffect, useMemo, memo } from "react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { useTabStore } from "@/stores/tabStore"; +import { BatchAPI } from "@/lib/api/batch"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Trash2, Plus, ArrowLeft, Save, RefreshCw, Globe, Database, Eye } from "lucide-react"; +import { Trash2, Plus, ArrowLeft, Save, RefreshCw, Globe, Database, Eye, Link as LinkIcon } from "lucide-react"; import { toast } from "sonner"; import { showErrorToast } from "@/lib/utils/toastUtils"; import { BatchManagementAPI } from "@/lib/api/batchManagement"; +import { + ExternalRestApiConnectionAPI, + ExternalRestApiConnection, +} from "@/lib/api/externalRestApiConnection"; // 타입 정의 type BatchType = "db-to-restapi" | "restapi-to-db" | "restapi-to-restapi"; @@ -36,12 +41,25 @@ interface BatchColumnInfo { } // 통합 매핑 아이템 타입 +interface ConditionalRule { + when: string; + then: string; +} +interface ConditionalConfig { + rules: ConditionalRule[]; + default: string; +} interface MappingItem { id: string; dbColumn: string; - sourceType: "api" | "fixed"; + sourceType: "api" | "fixed" | "conditional"; apiField: string; fixedValue: string; + // 조건부 변환: source 필드값에 따라 다른 값을 저장 + // apiField 가 평가할 API 필드명 (예: dept_name) + // conditionalConfig.rules: [{ when: '영업부', then: '1' }, ...] + // conditionalConfig.default: 매칭 안되면 사용할 기본값 + conditionalConfig?: ConditionalConfig; } interface RestApiToDbMappingCardProps { @@ -61,9 +79,23 @@ interface DbToRestApiMappingCardProps { setToApiBody: (body: string) => void; } -export default function BatchManagementNewPage() { +interface BatchManagementNewPageProps { + adminParams?: { id?: string; edit?: string }; +} + +export default function BatchManagementNewPage(props: BatchManagementNewPageProps = {}) { const router = useRouter(); const { openTab } = useTabStore(); + // adminUrl 파싱: /admin/batch-management-new?edit=12 형태로 들어오면 query 추출 + const searchParams = useSearchParams(); + const editIdRaw = + props.adminParams?.edit ?? + props.adminParams?.id ?? + searchParams?.get("edit") ?? + null; + const editId = editIdRaw && !isNaN(Number(editIdRaw)) ? Number(editIdRaw) : null; + const isEditMode = editId !== null; + const [editLoading, setEditLoading] = useState(false); // 기본 상태 const [batchName, setBatchName] = useState(""); @@ -90,6 +122,10 @@ export default function BatchManagementNewPage() { const [fromApiBody, setFromApiBody] = useState(""); // Request Body (JSON) const [dataArrayPath, setDataArrayPath] = useState(""); // 데이터 배열 경로 (예: response, data.items) + // 등록된 REST API 연결 (외부 커넥션 관리에서 등록된 연결 선택) + const [registeredRestApis, setRegisteredRestApis] = useState([]); + const [selectedRestApiId, setSelectedRestApiId] = useState("manual"); // "manual" 또는 연결 id 문자열 + // REST API 파라미터 설정 const [apiParamType, setApiParamType] = useState<"none" | "url" | "query">("none"); const [apiParamName, setApiParamName] = useState(""); // 파라미터명 (예: userId, id) @@ -117,6 +153,11 @@ export default function BatchManagementNewPage() { const [fromApiData, setFromApiData] = useState([]); const [fromApiFields, setFromApiFields] = useState([]); + // 응답 원본 + 빠른 테스트 (DB 테이블 선택 전에 응답 형태 확인용) + const [rawResponse, setRawResponse] = useState(null); + const [rawResponseLoading, setRawResponseLoading] = useState(false); + const [rawResponseError, setRawResponseError] = useState(""); + // 통합 매핑 리스트 const [mappingList, setMappingList] = useState([]); @@ -124,6 +165,10 @@ export default function BatchManagementNewPage() { const [saveMode, setSaveMode] = useState<"INSERT" | "UPSERT">("INSERT"); const [conflictKey, setConflictKey] = useState(""); + // 행 단위 제외 필터 — 특정 API 필드 값에 해당하는 행을 동기화에서 제외 + // 예) loginId="wace" 인 통합 ERP 계정 제외 + const [rowFilterRules, setRowFilterRules] = useState>([]); + // 배치 타입 상태 const [batchType, setBatchType] = useState("restapi-to-db"); @@ -145,8 +190,398 @@ export default function BatchManagementNewPage() { useEffect(() => { loadConnections(); loadAuthServiceNames(); + loadRegisteredRestApis(); }, []); + // 등록된 REST API 연결 목록 로드 (외부 커넥션 관리) + const loadRegisteredRestApis = async () => { + try { + const list = await ExternalRestApiConnectionAPI.getConnections({ is_active: "Y" }); + setRegisteredRestApis(list); + } catch (error) { + console.error("등록된 REST API 연결 목록 로드 실패:", error); + } + }; + + // FROM API 응답 빠른 테스트 (DB 테이블 선택 전에 응답 구조 확인용) + // - 등록된 연결을 사용하면 testConnectionById, 직접 입력이면 testConnection 호출 + // - 응답 본문을 그대로 보여줘서 어떤 필드가 있는지 확인 → DB 매핑 시 사용 + const runQuickResponseTest = async () => { + setRawResponseError(""); + if (!fromApiUrl?.trim() || !fromEndpoint?.trim()) { + setRawResponseError("API 서버 URL과 엔드포인트를 입력하거나 등록된 연결을 선택해주세요."); + return; + } + setRawResponseLoading(true); + setRawResponse(null); + try { + let result: any; + if (selectedRestApiId !== "manual") { + // 등록된 연결: 백엔드가 인증(Wehago HMAC 등)을 처리 + result = await ExternalRestApiConnectionAPI.testConnectionById( + Number(selectedRestApiId), + fromEndpoint, + ); + } else { + // 직접 입력 모드 + let parsedBody: any = undefined; + if ( + (fromApiMethod === "POST" || fromApiMethod === "PUT" || fromApiMethod === "DELETE") && + fromApiBody + ) { + try { + parsedBody = JSON.parse(fromApiBody); + } catch { + parsedBody = fromApiBody; + } + } + result = await ExternalRestApiConnectionAPI.testConnection({ + base_url: fromApiUrl, + endpoint: fromEndpoint, + method: fromApiMethod as any, + headers: { "Content-Type": "application/json" }, + body: parsedBody, + auth_type: fromApiKey ? "bearer" : "none", + auth_config: fromApiKey + ? { token: fromApiKey.replace(/^Bearer\s+/i, "") } + : undefined, + timeout: 30000, + }); + } + + if (result.success) { + setRawResponse(result.response_data); + + // 응답에서 배열 자동 탐색해서 fromApiFields/fromApiData 도 채움 (매핑 카드 즉시 표시) + const findArr = (o: any, depth = 0): any[] | null => { + if (Array.isArray(o)) return o; + if (depth >= 4 || typeof o !== "object" || o === null) return null; + for (const v of Object.values(o)) { + const a = findArr(v, depth + 1); + if (a) return a; + } + return null; + }; + const arr = findArr(result.response_data); + if (arr && arr.length > 0 && typeof arr[0] === "object" && arr[0] !== null) { + const fields = Object.keys(arr[0]); + setFromApiFields(fields); + setFromApiData(arr); + toast.success( + `응답 수신 (${result.response_time ?? "-"}ms) — 배열 ${arr.length}건 / 필드 ${fields.length}개 추출. 아래에서 DB 컬럼과 매핑하세요.` + ); + } else if ( + result.response_data && + typeof result.response_data === "object" && + !Array.isArray(result.response_data) + ) { + // 단일 객체 응답 + const fields = Object.keys(result.response_data as any); + setFromApiFields(fields); + setFromApiData([result.response_data]); + toast.success(`응답 수신 (${result.response_time ?? "-"}ms) — 필드 ${fields.length}개 추출`); + } else { + toast.success(`응답 수신 (${result.response_time ?? "-"}ms)`); + } + } else { + setRawResponseError(result.message || result.error_details || "API 호출 실패"); + } + } catch (e: any) { + setRawResponseError(e?.message || "API 호출 중 오류"); + } finally { + setRawResponseLoading(false); + } + }; + + // Amaranth (Wehago/RPS ERP) 연결 → 권장 적재 대상 테이블 매핑 (편집 모드 외 신규 생성 시 자동 셋팅) + // 사용자 선택 즉시 TO 패널의 DB 커넥션(내부 DB) + 테이블 + 컬럼 + UPSERT/충돌키 + 매핑까지 자동 채운다 + const AMARANTH_TARGET_PRESETS: Record< + string, + { + table: string; + saveMode: "INSERT" | "UPSERT"; + conflictKey: string; + mappings: Array<{ from: string; to: string }>; + dataArrayPath: string; + } + > = { + "Amaranth - 부서": { + table: "dept_info", + saveMode: "UPSERT", + conflictKey: "dept_code", + dataArrayPath: "data", + mappings: [ + { from: "deptCd", to: "dept_code" }, + { from: "deptNm", to: "dept_name" }, + { from: "upDeptCd", to: "parent_dept_code" }, + { from: "deptShortNm", to: "dept_short_name" }, + { from: "deptEngNm", to: "dept_eng_name" }, + { from: "deptLvl", to: "dept_level" }, + { from: "sortSq", to: "sort_seq" }, + { from: "useYn", to: "use_yn" }, + ], + }, + "Amaranth - 사원": { + table: "user_info", + saveMode: "UPSERT", + conflictKey: "sabun", + dataArrayPath: "data", + mappings: [ + { from: "sabun", to: "sabun" }, + { from: "empSeq", to: "user_id" }, // user_info.user_id NOT NULL 충족 — loginId 배치가 나중에 덮어씀 + { from: "empSeq", to: "emp_seq" }, // loginId 배치의 매칭 키 + { from: "empNm", to: "user_name" }, + { from: "empNmEn", to: "user_name_eng" }, + { from: "deptCd", to: "dept_code" }, + { from: "deptNm", to: "dept_name" }, + { from: "posCd", to: "position_code" }, + { from: "posNm", to: "position_name" }, + { from: "rspofcCd", to: "rank_code" }, + { from: "rspofcNm", to: "rank_name" }, + { from: "emalAdd", to: "email" }, + { from: "outemalAdd", to: "out_email" }, + { from: "tel", to: "tel" }, + { from: "emgcTel", to: "cell_phone" }, + { from: "joinDt", to: "join_date" }, + { from: "retireDt", to: "retire_date" }, + { from: "wrkSttsCd", to: "work_status" }, + { from: "sexFg", to: "gender_fg" }, + ], + }, + "Amaranth - Wehago 사용자": { + table: "user_info", + saveMode: "UPSERT", + conflictKey: "emp_seq", + dataArrayPath: "body.data", + mappings: [ + { from: "empSeq", to: "emp_seq" }, + { from: "loginId", to: "user_id" }, + ], + }, + "Amaranth - 거래처": { + table: "customer_mng", + saveMode: "UPSERT", + conflictKey: "customer_code", + dataArrayPath: "data", + mappings: [ + { from: "trCd", to: "customer_code" }, + { from: "trNm", to: "customer_name" }, + { from: "trShortNm", to: "short_name" }, + { from: "bizNo", to: "business_number" }, + { from: "corpNo", to: "corp_number" }, + { from: "repNm", to: "ceo_name" }, + { from: "bizCondtn", to: "biz_condition" }, + { from: "bizItem", to: "biz_item" }, + { from: "zipCd", to: "zip_code" }, + { from: "addr", to: "address" }, + { from: "addrDtl", to: "address_detail" }, + { from: "tel", to: "tel" }, + { from: "faxNo", to: "fax_no" }, + { from: "hpNo", to: "hp_no" }, + { from: "emalAdd", to: "email" }, + { from: "chargeNm", to: "charge_name" }, + { from: "chargeTel", to: "charge_tel" }, + { from: "chargeEmal", to: "charge_email" }, + { from: "trFg", to: "customer_type" }, + { from: "bankNm", to: "bank_name" }, + { from: "bankAcct", to: "bank_account" }, + { from: "acctOwner", to: "account_owner" }, + { from: "natnCd", to: "nation_code" }, + { from: "currCd", to: "currency_code" }, + { from: "useYn", to: "use_yn" }, + { from: "regDt", to: "reg_date" }, + ], + }, + "Amaranth - 창고": { + table: "warehouse_info", + saveMode: "UPSERT", + conflictKey: "warehouse_code", + dataArrayPath: "data", + mappings: [ + { from: "lctnCd", to: "warehouse_code" }, + { from: "lctnNm", to: "warehouse_name" }, + { from: "lctnFg", to: "warehouse_type" }, + { from: "lctnFgNm", to: "warehouse_type_name" }, + { from: "upLctnCd", to: "parent_loc_code" }, + { from: "baselocFg", to: "baseloc_fg" }, + { from: "sortSq", to: "sort_seq" }, + { from: "useYn", to: "use_yn" }, + ], + }, + "Amaranth - 계정과목": { + table: "account_code_info", + saveMode: "UPSERT", + conflictKey: "account_code", + dataArrayPath: "data", + mappings: [ + { from: "acctCd", to: "account_code" }, + { from: "acctNm", to: "account_name" }, + { from: "acctTy", to: "account_type" }, + { from: "acctNmk", to: "account_short" }, + { from: "groupSeq", to: "group_seq" }, + { from: "groupCd", to: "group_code" }, + { from: "groupNm", to: "group_name" }, + { from: "drcrFg", to: "dr_cr_fg" }, + { from: "subDisp", to: "sub_disp" }, + { from: "subDispNm", to: "sub_disp_name" }, + { from: "chFg", to: "ch_fg" }, + { from: "chFgNm", to: "ch_fg_name" }, + { from: "budFg", to: "bud_fg" }, + { from: "budFgNm", to: "bud_fg_name" }, + { from: "attrFg", to: "attr_fg" }, + { from: "attrFgNm", to: "attr_fg_name" }, + { from: "racctCd", to: "racct_code" }, + { from: "racctNm", to: "racct_name" }, + { from: "fillYn", to: "fill_yn" }, + { from: "ctrlCds", to: "ctrl_cds" }, + { from: "extInputCd", to: "ext_input_cd" }, + { from: "useYn", to: "use_yn" }, + ], + }, + }; + + // 등록된 연결 선택 시 폼 자동 채우기 + API 호출 + 응답 필드 추출 (자동 매핑 준비) + const applyRegisteredRestApi = async (id: string) => { + setSelectedRestApiId(id); + if (id === "manual") return; + const conn = registeredRestApis.find((c) => String(c.id) === id); + if (!conn) return; + + setFromApiUrl(conn.base_url || ""); + setFromEndpoint(conn.endpoint_path || ""); + setFromApiMethod((conn.default_method as any) || "GET"); + setFromApiBody(conn.default_body || ""); + + // ── Amaranth 프리셋이면 TO DB 자동 셋팅 (편집 모드는 이미 자체 로직이 있으므로 스킵) ── + const preset = AMARANTH_TARGET_PRESETS[conn.connection_name]; + if (preset && !isEditMode) { + setDataArrayPath(preset.dataArrayPath); + setSaveMode(preset.saveMode); + setConflictKey(preset.conflictKey); + // 내부 DB 커넥션 자동 선택 + 테이블/컬럼 로드 + const internalConn = connections.find((c) => c.type === "internal"); + if (internalConn) { + setToConnection(internalConn); + try { + const tablesResult = await BatchManagementAPI.getTablesFromConnection( + "internal", + internalConn.id, + ); + const tableNames = Array.isArray(tablesResult) + ? tablesResult.map((t: any) => (typeof t === "string" ? t : t.table_name || String(t))) + : []; + setToTables(tableNames); + // 대상 테이블 자동 선택 + 컬럼 로드 + if (tableNames.includes(preset.table)) { + setToTable(preset.table); + try { + const cols = await BatchManagementAPI.getTableColumns( + "internal", + preset.table, + internalConn.id, + ); + if (cols && cols.length > 0) setToColumns(cols); + } catch (e) { + console.warn("[preset] 테이블 컬럼 로드 실패", e); + } + } else { + // 테이블이 없으면 백엔드에 자동 생성 요청 (관련 테이블이 마이그레이션돼 있어야 정상) + toast.warning( + `대상 테이블 '${preset.table}' 이 내부 DB에 없어요. 200_erp_sync_tables 마이그레이션을 실행했는지 확인해주세요.`, + ); + } + } catch (e) { + console.warn("[preset] 내부 DB 테이블 목록 로드 실패", e); + } + } + // 매핑 자동 구성 — 응답 호출 후에 채우기 위해 마킹 + } + + // 인증 토큰 자동 채움 (직접 입력 모드) + setAuthTokenMode("direct"); + setAuthServiceName(""); + if (conn.auth_type === "bearer" && conn.auth_config?.token) { + setFromApiKey(`Bearer ${conn.auth_config.token}`); + } else if (conn.auth_type === "api-key" && conn.auth_config?.keyValue) { + setFromApiKey(conn.auth_config.keyValue); + } else if (conn.auth_type === "wehago") { + // Wehago/Amaranth: 백엔드가 매 요청마다 wehago-sign(HMAC) 자동 부착하므로 토큰 입력 불필요 + setFromApiKey(""); + } else { + setFromApiKey(""); + } + + // 자동으로 API 호출 → 응답 본문 + 필드 추출하여 매핑 카드 즉시 활성화 + setRawResponseError(""); + setRawResponseLoading(true); + setRawResponse(null); + try { + const result = await ExternalRestApiConnectionAPI.testConnectionById( + Number(id), + conn.endpoint_path || undefined, + ); + + if (result.success) { + setRawResponse(result.response_data); + + const findArr = (o: any, depth = 0): any[] | null => { + if (Array.isArray(o)) return o; + if (depth >= 4 || typeof o !== "object" || o === null) return null; + for (const v of Object.values(o)) { + const a = findArr(v, depth + 1); + if (a) return a; + } + return null; + }; + const arr = findArr(result.response_data); + if (arr && arr.length > 0 && typeof arr[0] === "object" && arr[0] !== null) { + const fields = Object.keys(arr[0]); + setFromApiFields(fields); + setFromApiData(arr); + + // Amaranth 프리셋이면 매핑 목록도 자동 구성 (편집 모드는 자체 로직이 처리) + if (preset && !isEditMode) { + const autoMappings: MappingItem[] = preset.mappings + .filter((m) => fields.includes(m.from)) + .map((m, idx) => ({ + id: `auto-${idx}-${Date.now()}`, + dbColumn: m.to, + sourceType: "api" as const, + apiField: m.from, + fixedValue: "", + })); + if (autoMappings.length > 0) { + setMappingList(autoMappings); + } + } + + toast.success( + `'${conn.connection_name}' API 호출 완료 — 배열 ${arr.length}건 / 필드 ${fields.length}개 추출. ${preset && !isEditMode ? "매핑 자동 구성 완료." : "우측에서 DB 테이블을 선택하면 매핑 화면이 열려요."}`, + ); + } else if ( + result.response_data && + typeof result.response_data === "object" && + !Array.isArray(result.response_data) + ) { + const fields = Object.keys(result.response_data as any); + setFromApiFields(fields); + setFromApiData([result.response_data]); + toast.success(`'${conn.connection_name}' API 호출 완료 — 필드 ${fields.length}개 추출`); + } else { + toast.success(`'${conn.connection_name}' API 호출 완료 — 응답을 받았어요`); + } + } else { + setRawResponseError(result.message || result.error_details || "API 호출 실패"); + toast.error(`'${conn.connection_name}' API 호출 실패: ${result.message || ""}`); + } + } catch (e: any) { + setRawResponseError(e?.message || "API 호출 중 오류"); + toast.error(`API 호출 중 오류: ${e?.message ?? ""}`); + } finally { + setRawResponseLoading(false); + } + }; + // 인증 서비스명 목록 로드 const loadAuthServiceNames = async () => { try { @@ -158,7 +593,15 @@ export default function BatchManagementNewPage() { }; // 배치 타입 변경 시 상태 초기화 + // - 편집 모드에서는 사용자가 다른 타입으로 직접 바꾸지 않는 한 폼 데이터를 보존해야 하므로 + // 초기 진입 시 (editLoading 동안) 의 batchType 변화는 무시한다. + const [hasInitialized, setHasInitialized] = useState(false); useEffect(() => { + if (!hasInitialized) { + setHasInitialized(true); + return; + } + if (isEditMode) return; // 편집 모드는 데이터를 보존 // 공통 초기화 setMappingList([]); @@ -172,6 +615,7 @@ export default function BatchManagementNewPage() { setFromEndpoint(""); setFromApiData([]); setFromApiFields([]); + setSelectedRestApiId("manual"); // DB → REST API 관련 초기화 setFromConnection(null); @@ -198,6 +642,237 @@ export default function BatchManagementNewPage() { } }; + // ────────────────────────────────────────────────────────────────── + // 편집 모드 (?edit=N) — 기존 배치 설정 불러와 폼 자동 채움 + // - 연결 목록 + 등록된 REST API 목록 + DB 커넥션이 모두 로드된 뒤 실행 + // ────────────────────────────────────────────────────────────────── + useEffect(() => { + if (!isEditMode || !editId) return; + if (connections.length === 0) return; + if (registeredRestApis.length === 0) return; + + let cancelled = false; + (async () => { + setEditLoading(true); + try { + const cfg = await BatchAPI.getBatchConfig(editId); + if (cancelled || !cfg) return; + + // 기본 정보 + setBatchName(cfg.batch_name || ""); + setCronSchedule(cfg.cron_schedule || "0 12 * * *"); + setDescription(cfg.description || ""); + if ((cfg as any).save_mode) setSaveMode((cfg as any).save_mode); + if ((cfg as any).conflict_key) setConflictKey((cfg as any).conflict_key); + if ((cfg as any).data_array_path) setDataArrayPath((cfg as any).data_array_path); + // 행 단위 제외 필터 로드 + const rfRaw = (cfg as any).row_filter_config; + if (rfRaw) { + try { + const parsed = typeof rfRaw === "string" ? JSON.parse(rfRaw) : rfRaw; + const rules = Array.isArray(parsed?.exclude) ? parsed.exclude : []; + setRowFilterRules(rules.map((r: any) => ({ + column: String(r.column || ""), + op: (r.op === "neq" ? "neq" : "eq") as "eq" | "neq", + value: String(r.value ?? ""), + }))); + } catch {/* ignore */} + } + if ((cfg as any).auth_service_name) { + setAuthTokenMode("db"); + setAuthServiceName((cfg as any).auth_service_name); + } + + const mappings = cfg.batch_mappings || []; + if (mappings.length === 0) { + toast.warning("이 배치에는 매핑 정보가 없어요."); + return; + } + const first: any = mappings[0]; + + // FROM/TO 타입 결정 + const fromType = first.from_connection_type; + const toType = first.to_connection_type; + if (fromType === "restapi") { + setBatchType("restapi-to-db"); + + // 등록된 REST API 연결인 경우 → 자동 적용 (URL/엔드포인트/Body/인증) + if (first.from_connection_id) { + const conn = registeredRestApis.find((c) => c.id === first.from_connection_id); + if (conn) { + setSelectedRestApiId(String(conn.id)); + setFromApiUrl(conn.base_url || first.from_api_url || ""); + setFromEndpoint(conn.endpoint_path || first.from_table_name || ""); + setFromApiMethod((conn.default_method as any) || (first.from_api_method as any) || "POST"); + setFromApiBody(conn.default_body || first.from_api_body || ""); + setAuthTokenMode("direct"); + if (conn.auth_type !== "wehago") { + if (conn.auth_type === "bearer" && conn.auth_config?.token) { + setFromApiKey(`Bearer ${conn.auth_config.token}`); + } else if (conn.auth_type === "api-key" && conn.auth_config?.keyValue) { + setFromApiKey(conn.auth_config.keyValue); + } + } + } else { + // 등록 연결 ID 가 있는데 목록에 없는 경우 → 직접 입력 모드 fallback + setSelectedRestApiId("manual"); + setFromApiUrl(first.from_api_url || ""); + setFromEndpoint(first.from_table_name || ""); + setFromApiMethod((first.from_api_method as any) || "GET"); + setFromApiBody(first.from_api_body || ""); + setFromApiKey(first.from_api_key || ""); + } + } else { + // 직접 입력으로 저장된 경우 + setSelectedRestApiId("manual"); + setFromApiUrl(first.from_api_url || ""); + setFromEndpoint(first.from_table_name || ""); + setFromApiMethod((first.from_api_method as any) || "GET"); + setFromApiBody(first.from_api_body || ""); + setFromApiKey(first.from_api_key || ""); + } + + // TO: DB 테이블 자동 선택 + let toConn: BatchConnectionInfo | null = null; + if (toType === "internal") { + toConn = connections.find((c) => c.type === "internal") || null; + } else if (first.to_connection_id) { + toConn = connections.find((c) => c.id === first.to_connection_id) || null; + } + + if (toConn && first.to_table_name) { + // handleToConnectionChange + handleToTableChange 의 흐름을 직접 트리거 + setToConnection(toConn); + try { + const connectionType = toConn.type === "internal" ? "internal" : "external"; + const tablesResult = await BatchManagementAPI.getTablesFromConnection(connectionType, toConn.id); + const tableNames = Array.isArray(tablesResult) + ? tablesResult.map((t: any) => (typeof t === "string" ? t : t.table_name || String(t))) + : []; + if (!cancelled) setToTables(tableNames); + setToTable(first.to_table_name); + const cols = await BatchManagementAPI.getTableColumns(connectionType, first.to_table_name, toConn.id); + if (!cancelled && cols && cols.length > 0) setToColumns(cols); + } catch (e) { + console.error("[edit] TO 테이블/컬럼 로드 실패", e); + } + } + + // 매핑 리스트 복원 — direct/fixed/conditional 모두 처리 + const restoredMappings: MappingItem[] = mappings.map((m: any, idx: number) => { + const mt: string = m.mapping_type || "direct"; + const sourceType: MappingItem["sourceType"] = + mt === "fixed" ? "fixed" : mt === "conditional" ? "conditional" : "api"; + let conditionalConfig: ConditionalConfig | undefined; + if (sourceType === "conditional" && m.mapping_config) { + try { + const parsed = JSON.parse(m.mapping_config); + conditionalConfig = { + rules: Array.isArray(parsed.rules) ? parsed.rules : [], + default: parsed.default ?? "", + }; + } catch { + conditionalConfig = { rules: [], default: "" }; + } + } + return { + id: `edit-${idx}-${Date.now()}`, + dbColumn: m.to_column_name || "", + sourceType, + apiField: + sourceType === "fixed" ? "" : m.from_column_name || "", + fixedValue: sourceType === "fixed" ? m.from_column_name || "" : "", + conditionalConfig, + }; + }); + if (!cancelled) setMappingList(restoredMappings); + + // 등록된 연결이 있으면 자동으로 API 호출 → fromApiFields 채워서 매핑 드롭다운에 필드 노출 + if (first.from_connection_id) { + try { + const result = await ExternalRestApiConnectionAPI.testConnectionById( + first.from_connection_id, + first.from_table_name || undefined, + ); + if (!cancelled && result?.success) { + setRawResponse(result.response_data); + const findArr = (o: any, depth = 0): any[] | null => { + if (Array.isArray(o)) return o; + if (depth >= 4 || typeof o !== "object" || o === null) return null; + for (const v of Object.values(o)) { + const a = findArr(v, depth + 1); + if (a) return a; + } + return null; + }; + const arr = findArr(result.response_data); + if (arr && arr.length > 0 && typeof arr[0] === "object" && arr[0] !== null) { + setFromApiFields(Object.keys(arr[0])); + setFromApiData(arr); + } else { + // 응답이 배열이 아니어도 — 저장된 매핑의 from_column_name 들로 fromApiFields 를 채워서 드롭다운에 보이게 함 + const savedFields = Array.from( + new Set( + mappings + .filter((m: any) => m.mapping_type !== "fixed" && m.from_column_name) + .map((m: any) => m.from_column_name as string), + ), + ); + if (savedFields.length > 0) setFromApiFields(savedFields); + } + } else { + // 호출 실패해도 저장된 필드들로 fromApiFields 를 채워 매핑 드롭다운에 표시 + const savedFields = Array.from( + new Set( + mappings + .filter((m: any) => m.mapping_type !== "fixed" && m.from_column_name) + .map((m: any) => m.from_column_name as string), + ), + ); + if (savedFields.length > 0) setFromApiFields(savedFields); + } + } catch (e) { + console.error("[edit] API 자동 호출 실패", e); + const savedFields = Array.from( + new Set( + mappings + .filter((m: any) => m.mapping_type !== "fixed" && m.from_column_name) + .map((m: any) => m.from_column_name as string), + ), + ); + if (savedFields.length > 0) setFromApiFields(savedFields); + } + } else { + // 등록된 연결 없는 경우 — 저장된 from_column_name 들을 그대로 노출 + const savedFields = Array.from( + new Set( + mappings + .filter((m: any) => m.mapping_type !== "fixed" && m.from_column_name) + .map((m: any) => m.from_column_name as string), + ), + ); + if (savedFields.length > 0) setFromApiFields(savedFields); + } + + toast.success(`'${cfg.batch_name}' 편집 모드로 불러왔어요. 필요한 부분만 수정하세요.`); + } else { + // DB → REST API 등 다른 타입은 기존 편집 페이지로 라우팅 + toast.info("이 배치 타입은 기본 편집 화면에서 열어주세요."); + } + } catch (e: any) { + console.error("[edit] 배치 로드 실패", e); + toast.error("배치 정보를 불러올 수 없어요."); + } finally { + if (!cancelled) setEditLoading(false); + } + })(); + + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isEditMode, editId, connections.length, registeredRestApis.length]); + // TO 연결 변경 핸들러 const handleToConnectionChange = async (connectionValue: string) => { let connection: BatchConnectionInfo | null = null; @@ -335,22 +1010,23 @@ export default function BatchManagementNewPage() { // REST API 데이터 미리보기 const previewRestApiData = async () => { - // API URL, 엔드포인트는 항상 필수 - if (!fromApiUrl || !fromEndpoint) { - toast.error("API URL과 엔드포인트를 모두 입력해주세요."); - return; - } + // 등록된 REST API 연결이 선택된 경우엔 인증/URL/엔드포인트 검증을 백엔드가 처리하므로 스킵 + const useRegisteredConn = selectedRestApiId !== "manual"; - // 직접 입력 모드일 때만 토큰 검증 - if (authTokenMode === "direct" && !fromApiKey) { - toast.error("인증 토큰을 입력해주세요."); - return; - } - - // DB 선택 모드일 때 서비스명 검증 - if (authTokenMode === "db" && !authServiceName) { - toast.error("인증 토큰 서비스를 선택해주세요."); - return; + if (!useRegisteredConn) { + // 직접 입력 모드 — URL/엔드포인트/토큰 검증 + if (!fromApiUrl || !fromEndpoint) { + toast.error("API URL과 엔드포인트를 모두 입력해주세요."); + return; + } + if (authTokenMode === "direct" && !fromApiKey) { + toast.error("인증 토큰을 입력해주세요."); + return; + } + if (authTokenMode === "db" && !authServiceName) { + toast.error("인증 토큰 서비스를 선택해주세요."); + return; + } } try { @@ -374,6 +1050,8 @@ export default function BatchManagementNewPage() { authTokenMode === "db" ? authServiceName : undefined, // 데이터 배열 경로 전달 dataArrayPath || undefined, + // 등록된 연결 ID (Wehago/Amaranth 인증 자동 처리) + selectedRestApiId !== "manual" ? Number(selectedRestApiId) : undefined, ); if (result.fields && result.fields.length > 0) { @@ -409,10 +1087,21 @@ export default function BatchManagementNewPage() { // 배치 타입별 검증 및 저장 if (batchType === "restapi-to-db") { - // 유효한 매핑만 필터링 (DB 컬럼이 선택되고, API 필드 또는 고정값이 있는 것) - const validMappings = mappingList.filter( - (m) => m.dbColumn && (m.sourceType === "api" ? m.apiField : m.fixedValue), - ); + // 유효한 매핑만 필터링 + // api: dbColumn + apiField 둘 다 필요 + // fixed: dbColumn + fixedValue 둘 다 필요 + // conditional: dbColumn + apiField (평가할 API 필드) + 최소 1개 룰 또는 default 필요 + const validMappings = mappingList.filter((m) => { + if (!m.dbColumn) return false; + if (m.sourceType === "api") return !!m.apiField; + if (m.sourceType === "fixed") return m.fixedValue !== undefined && m.fixedValue !== null; + if (m.sourceType === "conditional") { + if (!m.apiField) return false; + const c = m.conditionalConfig; + return !!c && ((c.rules && c.rules.length > 0) || c.default !== undefined); + } + return false; + }); if (validMappings.length === 0) { toast.error("최소 하나의 매핑을 설정해주세요."); @@ -427,48 +1116,90 @@ export default function BatchManagementNewPage() { // 통합 매핑 리스트를 배치 매핑 형태로 변환 // 고정값 매핑도 동일한 from_connection_type을 사용해야 같은 그룹으로 처리됨 - const apiMappings = validMappings.map((mapping) => ({ - from_connection_type: "restapi" as const, // 고정값도 동일한 소스 타입 사용 - from_table_name: fromEndpoint, - from_column_name: mapping.sourceType === "api" ? mapping.apiField : mapping.fixedValue, - from_api_url: fromApiUrl, - from_api_key: authTokenMode === "direct" ? fromApiKey : "", - from_api_method: fromApiMethod, - from_api_body: - fromApiMethod === "POST" || fromApiMethod === "PUT" || fromApiMethod === "DELETE" ? fromApiBody : undefined, - 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: mapping.dbColumn, - mapping_type: mapping.sourceType === "fixed" ? ("fixed" as const) : ("direct" as const), - fixed_value: mapping.sourceType === "fixed" ? mapping.fixedValue : undefined, - })); + const apiMappings = validMappings.map((mapping) => { + // from_column_name: api/conditional 은 평가할 API 필드, fixed 는 고정값 + let fromColumnName: string; + if (mapping.sourceType === "fixed") fromColumnName = mapping.fixedValue; + else fromColumnName = mapping.apiField; // api & conditional 모두 apiField 사용 + const mappingType = + mapping.sourceType === "fixed" + ? ("fixed" as const) + : mapping.sourceType === "conditional" + ? ("conditional" as const) + : ("direct" as const); + return { + from_connection_type: "restapi" as const, + from_table_name: fromEndpoint, + from_column_name: fromColumnName, + from_api_url: fromApiUrl, + from_api_key: authTokenMode === "direct" ? fromApiKey : "", + from_api_method: fromApiMethod, + from_api_body: + fromApiMethod === "POST" || fromApiMethod === "PUT" || fromApiMethod === "DELETE" ? fromApiBody : undefined, + 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: mapping.dbColumn, + mapping_type: mappingType, + fixed_value: mapping.sourceType === "fixed" ? mapping.fixedValue : undefined, + // 조건부 룰을 mapping_config(JSON 문자열)에 저장 + mapping_config: + mapping.sourceType === "conditional" && mapping.conditionalConfig + ? JSON.stringify(mapping.conditionalConfig) + : undefined, + }; + }); // 실제 API 호출 try { - const result = await BatchManagementAPI.saveRestApiBatch({ - batchName, - batchType, - cronSchedule, - description, - apiMappings, - authServiceName: authTokenMode === "db" ? authServiceName : undefined, - dataArrayPath: dataArrayPath || undefined, - saveMode, - conflictKey: saveMode === "UPSERT" ? conflictKey : undefined, - }); - - if (result.success) { - toast.success(result.message || "REST API 배치 설정이 저장되었습니다."); + if (isEditMode && editId) { + // 편집 모드 — 기존 배치 업데이트 + await BatchAPI.updateBatchConfig(editId, { + batchName, + description, + cronSchedule, + isActive: "Y", + saveMode, + conflictKey: saveMode === "UPSERT" ? conflictKey : undefined, + authServiceName: authTokenMode === "db" ? authServiceName : undefined, + dataArrayPath: dataArrayPath || undefined, + rowFilterConfig: rowFilterRules.length > 0 + ? { exclude: rowFilterRules.filter(r => r.column && r.value !== "") } + : null, + mappings: apiMappings, + } as any); + toast.success("배치가 수정되었습니다."); setTimeout(() => { openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" }); - }, 1000); + }, 800); } else { - toast.error(result.message || "배치 저장에 실패했습니다."); + const result = await BatchManagementAPI.saveRestApiBatch({ + batchName, + batchType, + cronSchedule, + description, + apiMappings, + authServiceName: authTokenMode === "db" ? authServiceName : undefined, + dataArrayPath: dataArrayPath || undefined, + saveMode, + conflictKey: saveMode === "UPSERT" ? conflictKey : undefined, + rowFilterConfig: rowFilterRules.length > 0 + ? { exclude: rowFilterRules.filter(r => r.column && r.value !== "") } + : null, + } as any); + + if (result.success) { + toast.success(result.message || "REST API 배치 설정이 저장되었습니다."); + setTimeout(() => { + openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" }); + }, 1000); + } else { + toast.error(result.message || "배치 저장에 실패했습니다."); + } } } catch (error) { console.error("배치 저장 오류:", error); @@ -576,343 +1307,388 @@ export default function BatchManagementNewPage() { const goBack = () => openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" }); return ( -
- {/* 헤더 */} -
-
- -
-

고급 배치 생성

-

REST API / DB 간 데이터 동기화 배치를 설정합니다

+
+

+ {isEditMode ? `배치 편집 #${editId}` : "고급 배치 생성"} +

+

REST API / DB 간 데이터 동기화

+ {editLoading && ( + (불러오는 중...) + )}
+
+ + +
- {/* 배치 타입 선택 */} -
- {batchTypeOptions.map((option) => ( - + ))} +
+ + {/* 기본 정보 */} +
+
+
+ + setBatchName(e.target.value)} placeholder="배치명" className="h-7 text-xs" />
-
-
{option.label}
-
{option.description}
+
+ + setCronSchedule(e.target.value)} placeholder="0 12 * * *" className="h-7 font-mono text-xs" /> +
+
+ + setDescription(e.target.value)} placeholder="설명 (선택)" className="h-7 text-xs" />
- {batchType === option.value &&
} - - ))} -
- - {/* 기본 정보 */} -
-
- - 기본 정보 -
-
-
- - setBatchName(e.target.value)} placeholder="배치명을 입력하세요" className="h-9 text-sm" />
-
- - setCronSchedule(e.target.value)} placeholder="0 12 * * *" className="h-9 font-mono text-sm" /> -
-
-
- -