Amaranth(Wehago) ERP REST API 연계 + 배치 시스템 강화
부팅 시 자동 시드: - 외부 REST API 연결 6종 (부서/사원/거래처/창고/계정과목/Wehago 사용자) - 매칭 배치 6개 + Wehago HMAC-SHA256 서명 자동 부착 (erpApiClient/erpPresetSeedService/erpBatchSeedService) - 동기화 대상 테이블/컬럼 보장 idempotent 마이그레이션 (erpTableMigration) 배치 기능 확장: - 조건부 매핑 (mapping_type='conditional') — when/then/default 규칙으로 값 변환 (예: enrlFg=J01→active) - 행 단위 제외 필터 (row_filter_config) — 특정 API 필드 값 가진 행 동기화 제외 (예: loginId=wace 통합 ERP 계정 제외) - save_mode/conflict_key 기반 UPSERT, data_array_path 응답 배열 추출 UI 정비: - 배치/플로우/메일/REST API 목록에 페이징 + FullHD 컴팩트 레이아웃 - 배치 편집 화면 한 화면 풀 활용 — TO 패널 가로 그리드, FROM 패널 등록 연결 한 줄 요약, 응답/JSON/파라미터 details 접힘 - ResponsiveDataView/AdminPageRenderer 쿼리 파라미터(?edit=N) 파싱 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
// 기존 헤더에 인증 헤더 병합
|
||||
|
||||
@@ -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과 엔드포인트는 필수입니다.",
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 });
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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" &&
|
||||
|
||||
@@ -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<any>(
|
||||
`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,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -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<string, string>,
|
||||
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<string, any>;
|
||||
}
|
||||
|
||||
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<T = any>(options: CallOptions): Promise<T> {
|
||||
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<string, string> = {
|
||||
"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<any> {
|
||||
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<any> {
|
||||
if (!coCd?.trim()) throw new Error("회사코드(coCd)는 필수입니다.");
|
||||
const body: Record<string, any> = { 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<any> {
|
||||
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<any> {
|
||||
if (!coCd?.trim()) throw new Error("회사코드(coCd)는 필수입니다.");
|
||||
const body: Record<string, any> = { 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<any> {
|
||||
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 [];
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<string, number>();
|
||||
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})`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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})`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<SyncResult>
|
||||
): Promise<SyncResult> {
|
||||
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<T extends Record<string, any>>(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<SyncResult> {
|
||||
return withSyncLog("employees", coCd, companyCode, executedBy, () => doSyncEmployees(coCd, companyCode));
|
||||
}
|
||||
|
||||
async function doSyncEmployees(coCd: string, companyCode: string): Promise<SyncResult> {
|
||||
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<SyncResult> {
|
||||
return withSyncLog("departments", coCd, companyCode, executedBy, () =>
|
||||
doSyncDepartments(coCd, companyCode, searchText)
|
||||
);
|
||||
}
|
||||
|
||||
async function doSyncDepartments(
|
||||
coCd: string,
|
||||
companyCode: string,
|
||||
searchText?: string
|
||||
): Promise<SyncResult> {
|
||||
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<any>(
|
||||
"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<SyncResult> {
|
||||
return withSyncLog("warehouses", coCd, companyCode, executedBy, () =>
|
||||
doSyncWarehouses(coCd, companyCode, baselocFg)
|
||||
);
|
||||
}
|
||||
|
||||
async function doSyncWarehouses(
|
||||
coCd: string,
|
||||
companyCode: string,
|
||||
baselocFg?: string
|
||||
): Promise<SyncResult> {
|
||||
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<SyncResult> {
|
||||
return withSyncLog("customers", coCd, companyCode, executedBy, () => doSyncCustomers(coCd, companyCode));
|
||||
}
|
||||
|
||||
async function doSyncCustomers(coCd: string, companyCode: string): Promise<SyncResult> {
|
||||
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<SyncResult> {
|
||||
return withSyncLog("account_codes", coCd, companyCode, executedBy, () =>
|
||||
doSyncAccountCodes(coCd, companyCode)
|
||||
);
|
||||
}
|
||||
|
||||
async function doSyncAccountCodes(coCd: string, companyCode: string): Promise<SyncResult> {
|
||||
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<SyncResult[]> {
|
||||
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),
|
||||
];
|
||||
}
|
||||
@@ -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<void> {
|
||||
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}건`);
|
||||
}
|
||||
@@ -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<Record<string, string>> {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
@@ -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("올바르지 않은 인증 타입입니다.");
|
||||
|
||||
@@ -162,6 +162,7 @@ export interface CreateBatchConfigRequest {
|
||||
executionType?: BatchExecutionType;
|
||||
nodeFlowId?: number;
|
||||
nodeFlowContext?: Record<string, any>;
|
||||
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<string, any>;
|
||||
rowFilterConfig?: { exclude?: Array<{ column: string; op?: string; value: any }> } | null;
|
||||
mappings?: BatchMappingRequest[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (아마란스)" },
|
||||
];
|
||||
|
||||
@@ -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<number | null>(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 (
|
||||
<div className="h-full overflow-y-auto bg-background">
|
||||
<div className="w-full space-y-4 px-4 py-6 sm:px-6">
|
||||
<div className="w-full space-y-3 px-3 py-3 sm:px-4 mx-auto max-w-[1920px]">
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -542,7 +560,7 @@ export default function BatchManagementPage() {
|
||||
</div>
|
||||
|
||||
{/* 배치 리스트 */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-1">
|
||||
{loading && batchConfigs.length === 0 && (
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
@@ -556,7 +574,7 @@ export default function BatchManagementPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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 (
|
||||
<div key={batchId} className={`overflow-hidden rounded-lg border transition-all ${isExpanded ? "ring-1 ring-primary/20" : "hover:border-muted-foreground/20"} ${!isActive ? "opacity-55" : ""}`}>
|
||||
{/* 행 */}
|
||||
<div className="flex cursor-pointer items-center gap-3 px-4 py-3.5 sm:gap-4" onClick={() => handleRowClick(batchId)}>
|
||||
{/* 행 — 컴팩트 */}
|
||||
<div className="flex cursor-pointer items-center gap-2 px-3 py-1.5 sm:gap-3" onClick={() => handleRowClick(batchId)}>
|
||||
{/* 토글 */}
|
||||
<div onClick={(e) => e.stopPropagation()} className="shrink-0">
|
||||
<Switch
|
||||
checked={isActive}
|
||||
onCheckedChange={() => toggleBatchActive(batchId, batch.is_active || "N")}
|
||||
disabled={isToggling}
|
||||
className="scale-[0.7]"
|
||||
className="scale-[0.65]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 배치 이름 + 설명 */}
|
||||
{/* 배치 이름 + 설명 (한 줄로 합침) */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold">{batch.batch_name}</p>
|
||||
<p className="truncate text-[11px] text-muted-foreground">{batch.description || ""}</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="truncate text-xs font-semibold">{batch.batch_name}</span>
|
||||
{batch.description && (
|
||||
<span className="truncate text-[10px] text-muted-foreground">{batch.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 타입 뱃지 */}
|
||||
<span className={`hidden shrink-0 rounded border px-2 py-0.5 text-[10px] font-semibold sm:inline-flex ${typeStyle.className}`}>
|
||||
<span className={`hidden shrink-0 rounded border px-1.5 py-0 text-[10px] font-semibold sm:inline-flex ${typeStyle.className}`}>
|
||||
{typeStyle.label}
|
||||
</span>
|
||||
|
||||
{/* 스케줄 */}
|
||||
<div className="hidden shrink-0 text-right sm:block" style={{ minWidth: 90 }}>
|
||||
<p className="text-[12px] font-medium">{cronToKorean(batch.cron_schedule)}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{getNextExecution(batch.cron_schedule, isActive)
|
||||
? `다음: ${getNextExecution(batch.cron_schedule, isActive)}`
|
||||
: ""}
|
||||
</p>
|
||||
{/* 스케줄 (한 줄로) */}
|
||||
<div className="hidden shrink-0 text-right sm:block" style={{ minWidth: 110 }}>
|
||||
<p className="text-[11px] font-medium leading-tight">{cronToKorean(batch.cron_schedule)}</p>
|
||||
{getNextExecution(batch.cron_schedule, isActive) && (
|
||||
<p className="text-[10px] leading-tight text-muted-foreground">{getNextExecution(batch.cron_schedule, isActive)}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 인라인 미니 스파크라인 */}
|
||||
<div className="hidden shrink-0 sm:block" style={{ width: 64 }}>
|
||||
{/* 스파크라인 */}
|
||||
<div className="hidden shrink-0 sm:block" style={{ width: 56 }}>
|
||||
<Sparkline data={sparklineCache[batchId] || []} />
|
||||
</div>
|
||||
|
||||
{/* 마지막 실행 */}
|
||||
<div className="hidden shrink-0 text-right sm:block" style={{ minWidth: 70 }}>
|
||||
{/* 마지막 실행 — 한 줄로 */}
|
||||
<div className="hidden shrink-0 text-right sm:flex sm:items-center sm:gap-1" style={{ minWidth: 80 }}>
|
||||
{isExecuting ? (
|
||||
<p className="text-[11px] font-semibold text-amber-500">실행 중...</p>
|
||||
<span className="text-[11px] font-semibold text-amber-500">실행 중</span>
|
||||
) : lastAt ? (
|
||||
<>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{isFailed ? (
|
||||
<AlertCircle className="h-3 w-3 text-destructive" />
|
||||
) : isSuccess ? (
|
||||
<CheckCircle className="h-3 w-3 text-emerald-500" />
|
||||
) : null}
|
||||
<span className={`text-[11px] font-semibold ${isFailed ? "text-destructive" : "text-emerald-500"}`}>
|
||||
{isFailed ? "실패" : "성공"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">{timeAgo(lastAt)}</p>
|
||||
{isFailed ? (
|
||||
<AlertCircle className="h-3 w-3 text-destructive" />
|
||||
) : isSuccess ? (
|
||||
<CheckCircle className="h-3 w-3 text-emerald-500" />
|
||||
) : null}
|
||||
<span className={`text-[11px] font-semibold ${isFailed ? "text-destructive" : "text-emerald-500"}`}>
|
||||
{isFailed ? "실패" : "성공"}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">{timeAgo(lastAt)}</span>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-[11px] text-muted-foreground">—</p>
|
||||
<span className="text-[11px] text-muted-foreground">—</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 액션 */}
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-emerald-500/10 hover:text-emerald-500"
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-emerald-500/10 hover:text-emerald-500"
|
||||
onClick={(e) => executeBatch(e, batchId)}
|
||||
disabled={isExecuting}
|
||||
title="지금 실행하기"
|
||||
>
|
||||
{isExecuting ? <RefreshCw className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
|
||||
{isExecuting ? <RefreshCw className="h-3 w-3 animate-spin" /> : <Play className="h-3 w-3" />}
|
||||
</button>
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onClick={(e) => { e.stopPropagation(); openTab({ type: "admin", title: `배치 편집 #${batchId}`, adminUrl: `/admin/automaticMng/batchmngList/edit/${batchId}` }); }}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const isApiToDb = batchType === "api-db";
|
||||
const url = isApiToDb
|
||||
? `/admin/batch-management-new?edit=${batchId}`
|
||||
: `/admin/automaticMng/batchmngList/edit/${batchId}`;
|
||||
openTab({ type: "admin", title: `배치 편집 #${batchId}`, adminUrl: url });
|
||||
}}
|
||||
title="수정하기"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={(e) => deleteBatch(e, batchId, batch.batch_name)}
|
||||
title="삭제하기"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
<ChevronDown className={`ml-0.5 h-3.5 w-3.5 text-muted-foreground transition-transform ${isExpanded ? "rotate-180" : ""}`} />
|
||||
<ChevronDown className={`ml-0.5 h-3 w-3 text-muted-foreground transition-transform ${isExpanded ? "rotate-180" : ""}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -681,6 +706,32 @@ export default function BatchManagementPage() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 — 항상 표시 */}
|
||||
{!loading && (
|
||||
<div className="rounded-lg border bg-card p-3 shadow-sm">
|
||||
<Pagination
|
||||
paginationInfo={{
|
||||
currentPage: safePage,
|
||||
totalPages,
|
||||
totalItems,
|
||||
itemsPerPage,
|
||||
startItem: totalItems === 0 ? 0 : startIdx + 1,
|
||||
endItem: endIdx,
|
||||
}}
|
||||
onPageChange={setCurrentPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setItemsPerPage(size);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
showPageSizeSelector
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 하단 여백 — 마지막 항목이 뷰포트 끝에 닿지 않도록 */}
|
||||
<div className="h-12" />
|
||||
|
||||
{/* 배치 타입 선택 모달 */}
|
||||
{isBatchTypeModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm" onClick={() => setIsBatchTypeModalOpen(false)}>
|
||||
|
||||
@@ -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<string>("2000");
|
||||
const [companyCode, setCompanyCode] = useState<string>("COMPANY_16");
|
||||
const [busy, setBusy] = useState<ResourceKey | "all" | null>(null);
|
||||
const [results, setResults] = useState<Record<string, ErpSyncResult>>({});
|
||||
|
||||
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<string, ErpSyncResult> = {};
|
||||
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 (
|
||||
<div className="h-full overflow-y-auto bg-background">
|
||||
<div className="w-full max-w-5xl space-y-6 px-4 py-6 sm:px-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold tracking-tight">ERP 마스터 동기화</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
외부 ERP에서 사원·부서·창고·거래처·계정과목을 가져와 내부 DB에 적재해요.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleSyncAll} disabled={busy !== null} className="h-9 gap-2">
|
||||
{busy === "all" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<PlayCircle className="h-4 w-4" />
|
||||
)}
|
||||
전체 동기화
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 입력 영역 */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="coCd" className="text-xs">
|
||||
회사코드(coCd) — ERP 4자리 회사코드
|
||||
</Label>
|
||||
<Input
|
||||
id="coCd"
|
||||
value={coCd}
|
||||
onChange={(e) => setCoCd(e.target.value)}
|
||||
placeholder="예: 2000"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="companyCode" className="text-xs">
|
||||
내부 회사코드(companyCode) — 적재 대상
|
||||
</Label>
|
||||
<Input
|
||||
id="companyCode"
|
||||
value={companyCode}
|
||||
onChange={(e) => setCompanyCode(e.target.value)}
|
||||
placeholder="예: COMPANY_16"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 리소스 카드 */}
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
{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 (
|
||||
<div key={key} className="flex flex-col gap-3 rounded-lg border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold">{label}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{table}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleSyncOne(key)}
|
||||
disabled={busy !== null}
|
||||
className="h-8 gap-1 text-xs"
|
||||
>
|
||||
{running ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <PlayCircle className="h-3.5 w-3.5" />}
|
||||
실행
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
|
||||
{r && (
|
||||
<div className="space-y-2 rounded-md border bg-muted/20 p-2 text-xs">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{success ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />
|
||||
) : (
|
||||
<AlertCircle className="h-3.5 w-3.5 text-destructive" />
|
||||
)}
|
||||
<span className="font-semibold">
|
||||
{success ? "성공" : "실패"}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{(r.durationMs / 1000).toFixed(2)}초
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
조회 {r.fetched}
|
||||
</Badge>
|
||||
<Badge className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/20 text-[10px]">
|
||||
신규 {r.inserted}
|
||||
</Badge>
|
||||
<Badge className="bg-blue-500/15 text-blue-700 hover:bg-blue-500/20 text-[10px]">
|
||||
갱신 {r.updated}
|
||||
</Badge>
|
||||
{r.skipped > 0 && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
건너뜀 {r.skipped}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{r.errors.length > 0 && (
|
||||
<div className="rounded bg-destructive/10 p-1.5 text-[11px] text-destructive">
|
||||
{r.errors[0]}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border-l-2 border-blue-500 bg-blue-500/5 p-3 text-xs text-muted-foreground">
|
||||
<div className="font-semibold text-foreground">ERP 인증/엔드포인트</div>
|
||||
<ul className="mt-1.5 list-inside list-disc space-y-1">
|
||||
<li>baseUrl: <code className="rounded bg-muted px-1">ERP_BASE_URL</code> (기본 https://erp.rps-korea.com)</li>
|
||||
<li>인증: callerName + Bearer 토큰 + transaction-id + timestamp + groupSeq + wehago-sign(HMAC-SHA256)</li>
|
||||
<li>참고 원본: <code className="rounded bg-muted px-1">/Users/chpark/wace_plm/src/com/pms/api</code> (Java)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function labelOf(k: ResourceKey): string {
|
||||
return RESOURCE_DEFS.find((d) => d.key === k)?.label ?? k;
|
||||
}
|
||||
@@ -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<ExternalDbConnection | null>(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 (
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
<div className="space-y-6 p-6">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="space-y-2 border-b pb-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight">외부 커넥션 관리</h1>
|
||||
<p className="text-sm text-muted-foreground">외부 데이터베이스 및 REST API 연결 정보를 관리합니다</p>
|
||||
<div className="bg-background h-full overflow-y-auto">
|
||||
<div className="mx-auto w-full max-w-[1920px] space-y-3 px-4 py-3 sm:px-5 2xl:px-6">
|
||||
{/* 페이지 헤더 — 컴팩트 */}
|
||||
<div className="flex items-baseline gap-2 border-b pb-2">
|
||||
<h1 className="text-lg font-bold tracking-tight">외부 커넥션 관리</h1>
|
||||
<span className="text-xs text-muted-foreground">외부 데이터베이스 및 REST API 연결 정보를 관리합니다</span>
|
||||
</div>
|
||||
|
||||
{/* 탭 */}
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as ConnectionTabType)}>
|
||||
<TabsList className="grid w-full max-w-[400px] grid-cols-2">
|
||||
<TabsTrigger value="database" className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4" />
|
||||
<TabsList className="grid w-full max-w-[360px] grid-cols-2">
|
||||
<TabsTrigger value="database" className="flex items-center gap-1.5 text-xs">
|
||||
<Database className="h-3.5 w-3.5" />
|
||||
데이터베이스 연결
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="rest-api" className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
<TabsTrigger value="rest-api" className="flex items-center gap-1.5 text-xs">
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
REST API 연결
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 데이터베이스 연결 탭 */}
|
||||
<TabsContent value="database" className="space-y-6">
|
||||
<TabsContent value="database" className="space-y-3 mt-3">
|
||||
{/* 검색 및 필터 */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative w-full sm:w-[300px]">
|
||||
<div className="relative w-full sm:w-[320px]">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="연결명 또는 설명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
className="h-9 pl-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Select value={dbTypeFilter} onValueChange={setDbTypeFilter}>
|
||||
<SelectTrigger className="h-10 w-full sm:w-[160px]">
|
||||
<SelectTrigger className="h-9 w-full sm:w-[160px]">
|
||||
<SelectValue placeholder="DB 타입" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -312,7 +330,7 @@ export default function ExternalConnectionsPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={activeStatusFilter} onValueChange={setActiveStatusFilter}>
|
||||
<SelectTrigger className="h-10 w-full sm:w-[120px]">
|
||||
<SelectTrigger className="h-9 w-full sm:w-[120px]">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -323,8 +341,11 @@ export default function ExternalConnectionsPage() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="text-muted-foreground hidden text-xs sm:block 2xl:text-sm">
|
||||
총 <span className="text-foreground font-semibold">{totalItems}</span> 건
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleAddConnection} className="h-10 gap-2 text-sm font-medium">
|
||||
<Button onClick={handleAddConnection} className="h-9 gap-2 text-sm font-medium">
|
||||
<Plus className="h-4 w-4" />
|
||||
새 연결 추가
|
||||
</Button>
|
||||
@@ -332,7 +353,7 @@ export default function ExternalConnectionsPage() {
|
||||
|
||||
{/* 연결 목록 - ResponsiveDataView */}
|
||||
<ResponsiveDataView
|
||||
data={connections}
|
||||
data={pagedConnections}
|
||||
columns={columns}
|
||||
keyExtractor={(c) => String(c.id || c.connection_name)}
|
||||
isLoading={loading}
|
||||
@@ -382,6 +403,27 @@ export default function ExternalConnectionsPage() {
|
||||
actionsWidth="180px"
|
||||
/>
|
||||
|
||||
{/* 페이지네이션 — 항상 표시 */}
|
||||
<div className="rounded-lg border bg-card p-3 shadow-sm">
|
||||
<Pagination
|
||||
paginationInfo={{
|
||||
currentPage: safePage,
|
||||
totalPages,
|
||||
totalItems,
|
||||
itemsPerPage,
|
||||
startItem: totalItems === 0 ? 0 : startIdx + 1,
|
||||
endItem: endIdx,
|
||||
}}
|
||||
onPageChange={setCurrentPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setItemsPerPage(size);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
showPageSizeSelector
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 연결 설정 모달 */}
|
||||
{isModalOpen && (
|
||||
<ExternalDbConnectionModal
|
||||
@@ -436,7 +478,7 @@ export default function ExternalConnectionsPage() {
|
||||
</TabsContent>
|
||||
|
||||
{/* REST API 연결 탭 */}
|
||||
<TabsContent value="rest-api" className="space-y-6">
|
||||
<TabsContent value="rest-api" className="space-y-3 mt-3">
|
||||
<RestApiConnectionList />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -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<FlowDefinition>[] = [
|
||||
{
|
||||
@@ -588,43 +606,48 @@ export default function FlowManagementPage() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen flex-col">
|
||||
<div className="space-y-6 p-4 sm:p-6">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="space-y-2 border-b pb-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight">플로우 관리</h1>
|
||||
<p className="text-muted-foreground text-sm">업무 프로세스 플로우를 생성하고 관리합니다</p>
|
||||
<div className="bg-background h-full overflow-y-auto">
|
||||
<div className="mx-auto w-full max-w-[1920px] space-y-3 px-4 py-3 sm:px-5 2xl:px-6">
|
||||
{/* 페이지 헤더 — 컴팩트하게 */}
|
||||
<div className="flex items-end justify-between gap-3 border-b pb-2">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h1 className="text-lg font-bold tracking-tight">플로우 관리</h1>
|
||||
<span className="text-xs text-muted-foreground">업무 프로세스 플로우를 생성·관리합니다</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
총 <span className="text-foreground font-semibold">{totalItems}</span> 건
|
||||
{totalPages > 1 && (
|
||||
<span className="ml-1.5">
|
||||
({safePage} / {totalPages} 페이지)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 툴바 (반응형) */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative w-full sm:w-[300px]">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="플로우명, 테이블, 설명으로 검색..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-muted-foreground hidden text-sm sm:block">
|
||||
총 <span className="text-foreground font-semibold">{filteredFlows.length}</span> 건
|
||||
</div>
|
||||
{/* 검색 툴바 */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="relative w-full sm:w-[320px]">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-3.5 w-3.5 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="플로우명, 테이블, 설명으로 검색..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="h-8 pl-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => setIsCreateDialogOpen(true)} className="h-10 gap-2 text-sm font-medium">
|
||||
<Plus className="h-4 w-4" />새 플로우 생성
|
||||
<Button onClick={() => setIsCreateDialogOpen(true)} size="sm" className="h-8 gap-1.5 text-xs font-medium">
|
||||
<Plus className="h-3.5 w-3.5" />새 플로우 생성
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 플로우 목록 (ResponsiveDataView) */}
|
||||
<ResponsiveDataView<FlowDefinition>
|
||||
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() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 flex-1 gap-2 text-sm"
|
||||
className="h-7 flex-1 gap-1.5 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(f.id);
|
||||
}}
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
<Edit2 className="h-3 w-3" />
|
||||
편집
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 w-9 p-0"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedFlow(f);
|
||||
setIsDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
actionsWidth="160px"
|
||||
actionsWidth="130px"
|
||||
/>
|
||||
|
||||
{/* 페이지네이션 — 항상 표시 */}
|
||||
<div className="rounded-lg border bg-card p-3 shadow-sm">
|
||||
<Pagination
|
||||
paginationInfo={{
|
||||
currentPage: safePage,
|
||||
totalPages,
|
||||
totalItems,
|
||||
itemsPerPage,
|
||||
startItem: totalItems === 0 ? 0 : startIdx + 1,
|
||||
endItem: endIdx,
|
||||
}}
|
||||
onPageChange={setCurrentPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setItemsPerPage(size);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
showPageSizeSelector
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 생성 다이얼로그 */}
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
|
||||
@@ -195,54 +195,55 @@ export default function MailDashboardPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="w-full px-3 py-3 space-y-3">
|
||||
<div className="mx-auto w-full max-w-[1920px] space-y-3 p-3 sm:p-4 2xl:p-5">
|
||||
{/* 페이지 제목 */}
|
||||
<div className="flex items-center justify-between bg-card rounded-lg border p-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-4 bg-primary/10 rounded-lg">
|
||||
<Mail className="w-8 h-8 text-primary" />
|
||||
<div className="flex items-center justify-between bg-card rounded-lg border p-4 2xl:p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 bg-primary/10 rounded-lg">
|
||||
<Mail className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground mb-1">메일 관리 대시보드</h1>
|
||||
<p className="text-muted-foreground">메일 시스템의 전체 현황을 한눈에 확인하세요</p>
|
||||
<h1 className="text-xl 2xl:text-2xl font-bold text-foreground leading-tight">메일 관리 대시보드</h1>
|
||||
<p className="text-xs 2xl:text-sm text-muted-foreground">메일 시스템의 전체 현황을 한눈에 확인하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex gap-2">
|
||||
<MailNotifications />
|
||||
<Button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
size="sm"
|
||||
onClick={loadStats}
|
||||
disabled={loading}
|
||||
className="h-9 gap-2"
|
||||
>
|
||||
<RefreshCw className={`w-5 h-5 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{statCards.map((stat, index) => (
|
||||
<Link key={index} href={stat.href}>
|
||||
<Card className="hover:shadow-md transition-all hover:scale-105 cursor-pointer">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-muted-foreground mb-3">
|
||||
<Card className="hover:shadow-md transition-all hover:scale-[1.02] cursor-pointer h-full">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs 2xl:text-sm font-medium text-muted-foreground mb-1.5">
|
||||
{stat.title}
|
||||
</p>
|
||||
<p className="text-4xl font-bold text-foreground">
|
||||
<p className="text-2xl 2xl:text-3xl font-bold text-foreground leading-none">
|
||||
{stat.value}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<stat.icon className="w-7 h-7 text-muted-foreground" />
|
||||
<div className="p-2.5 bg-muted rounded-lg shrink-0">
|
||||
<stat.icon className="w-5 h-5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
{/* 진행 바 */}
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-1000"
|
||||
style={{ width: `${Math.min((stat.value / 10) * 100, 100)}%` }}
|
||||
></div>
|
||||
@@ -253,103 +254,94 @@ export default function MailDashboardPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 이번 달 통계 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{/* 본문: 이번 달 통계 + 시스템 상태 + 빠른 액세스 (FullHD에서 3열 그리드) */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 gap-3">
|
||||
<Card>
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle className="text-lg flex items-center">
|
||||
<div className="p-2 bg-muted rounded-lg mr-3">
|
||||
<Calendar className="w-5 h-5 text-foreground" />
|
||||
<CardHeader className="border-b py-3 px-4">
|
||||
<CardTitle className="text-sm 2xl:text-base flex items-center">
|
||||
<div className="p-1.5 bg-muted rounded-md mr-2">
|
||||
<Calendar className="w-4 h-4 text-foreground" />
|
||||
</div>
|
||||
<span>이번 달 발송 통계</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
||||
<span className="text-sm font-medium text-muted-foreground">총 발송 건수</span>
|
||||
<span className="text-2xl font-bold text-foreground">{stats.sentThisMonth} 건</span>
|
||||
<CardContent className="p-3 2xl:p-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between px-3 py-2.5 bg-muted rounded-md">
|
||||
<span className="text-xs 2xl:text-sm font-medium text-muted-foreground">총 발송 건수</span>
|
||||
<span className="text-lg 2xl:text-xl font-bold text-foreground">{stats.sentThisMonth} 건</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
||||
<span className="text-sm font-medium text-muted-foreground">성공률</span>
|
||||
<span className="text-2xl font-bold text-foreground">{stats.successRate}%</span>
|
||||
<div className="flex items-center justify-between px-3 py-2.5 bg-muted rounded-md">
|
||||
<span className="text-xs 2xl:text-sm font-medium text-muted-foreground">성공률</span>
|
||||
<span className="text-lg 2xl:text-xl font-bold text-foreground">{stats.successRate}%</span>
|
||||
</div>
|
||||
{/* 전월 대비 통계는 현재 불필요하여 주석처리
|
||||
<div className="flex items-center justify-between pt-3 border-t">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
전월 대비
|
||||
</div>
|
||||
<span className="text-lg font-bold text-foreground">+12%</span>
|
||||
</div>
|
||||
*/}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle className="text-lg flex items-center">
|
||||
<div className="p-2 bg-muted rounded-lg mr-3">
|
||||
<Mail className="w-5 h-5 text-foreground" />
|
||||
<CardHeader className="border-b py-3 px-4">
|
||||
<CardTitle className="text-sm 2xl:text-base flex items-center">
|
||||
<div className="p-1.5 bg-muted rounded-md mr-2">
|
||||
<Mail className="w-4 h-4 text-foreground" />
|
||||
</div>
|
||||
<span>시스템 상태</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-3 h-3 bg-primary rounded-full animate-pulse"></div>
|
||||
<span className="text-sm font-medium text-muted-foreground">메일 서버</span>
|
||||
<CardContent className="p-3 2xl:p-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between px-3 py-2.5 bg-muted rounded-md">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-pulse"></div>
|
||||
<span className="text-xs 2xl:text-sm font-medium text-muted-foreground">메일 서버</span>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-foreground">정상 작동</span>
|
||||
<span className="text-xs 2xl:text-sm font-bold text-foreground">정상 작동</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-3 h-3 bg-primary rounded-full"></div>
|
||||
<span className="text-sm font-medium text-muted-foreground">활성 계정</span>
|
||||
<div className="flex items-center justify-between px-3 py-2.5 bg-muted rounded-md">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-primary rounded-full"></div>
|
||||
<span className="text-xs 2xl:text-sm font-medium text-muted-foreground">활성 계정</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-foreground">{stats.totalAccounts} 개</span>
|
||||
<span className="text-base 2xl:text-lg font-bold text-foreground">{stats.totalAccounts} 개</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-3 h-3 bg-primary rounded-full"></div>
|
||||
<span className="text-sm font-medium text-muted-foreground">사용 가능 템플릿</span>
|
||||
<div className="flex items-center justify-between px-3 py-2.5 bg-muted rounded-md">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-primary rounded-full"></div>
|
||||
<span className="text-xs 2xl:text-sm font-medium text-muted-foreground">사용 가능 템플릿</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-foreground">{stats.totalTemplates} 개</span>
|
||||
<span className="text-base 2xl:text-lg font-bold text-foreground">{stats.totalTemplates} 개</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 빠른 액세스 */}
|
||||
<Card>
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle className="text-lg">빠른 액세스</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{quickLinks.map((link, index) => (
|
||||
<a
|
||||
key={index}
|
||||
href={link.href}
|
||||
className="group flex items-center gap-4 p-5 rounded-lg border hover:border-primary/50 hover:shadow-md transition-all bg-card hover:bg-muted/50"
|
||||
>
|
||||
<div className="p-3 bg-muted rounded-lg group-hover:scale-105 transition-transform">
|
||||
<link.icon className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-foreground text-base mb-1">{link.title}</p>
|
||||
<p className="text-sm text-muted-foreground truncate">{link.description}</p>
|
||||
</div>
|
||||
<ArrowRight className="w-5 h-5 text-muted-foreground group-hover:text-foreground group-hover:translate-x-1 transition-all" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* 빠른 액세스 — FullHD에서는 우측 컬럼에 함께 배치 */}
|
||||
<Card className="2xl:row-span-1">
|
||||
<CardHeader className="border-b py-3 px-4">
|
||||
<CardTitle className="text-sm 2xl:text-base">빠른 액세스</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{quickLinks.map((link, index) => (
|
||||
<a
|
||||
key={index}
|
||||
href={link.href}
|
||||
className="group flex items-center gap-2 p-2.5 rounded-md border hover:border-primary/50 hover:shadow-sm transition-all bg-card hover:bg-muted/50"
|
||||
>
|
||||
<div className="p-1.5 bg-muted rounded-md group-hover:scale-105 transition-transform shrink-0">
|
||||
<link.icon className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-foreground text-xs 2xl:text-sm leading-tight truncate">{link.title}</p>
|
||||
<p className="text-[10px] 2xl:text-xs text-muted-foreground truncate">{link.description}</p>
|
||||
</div>
|
||||
<ArrowRight className="w-3.5 h-3.5 text-muted-foreground group-hover:text-foreground group-hover:translate-x-0.5 transition-all shrink-0" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,7 @@ export function AuthenticationConfig({
|
||||
<SelectItem value="basic">Basic Auth</SelectItem>
|
||||
<SelectItem value="oauth2">OAuth 2.0</SelectItem>
|
||||
<SelectItem value="db-token">DB 토큰</SelectItem>
|
||||
<SelectItem value="wehago">Wehago / Amaranth (아마란스)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -281,6 +282,78 @@ export function AuthenticationConfig({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authType === "wehago" && (
|
||||
<div className="space-y-4 rounded-md border bg-muted p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium">Wehago / Amaranth (아마란스) 설정</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onAuthConfigChange({
|
||||
callerName: "API_gcmsAmaranth40578",
|
||||
accessToken: "MN5KzKBWRAa92BPxDlRLl3GcsxeZXc",
|
||||
hashKey: "22519103205540290721741689643674301018832465",
|
||||
groupSeq: "gcmsAmaranth40578",
|
||||
})
|
||||
}
|
||||
className="text-xs text-primary underline-offset-2 hover:underline"
|
||||
>
|
||||
RPS Amaranth 기본값 채우기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="wehago-caller">callerName</Label>
|
||||
<Input
|
||||
id="wehago-caller"
|
||||
type="text"
|
||||
value={authConfig.callerName || ""}
|
||||
onChange={(e) => updateAuthConfig("callerName", e.target.value)}
|
||||
placeholder="예: API_gcmsAmaranth40578"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="wehago-token">accessToken</Label>
|
||||
<Input
|
||||
id="wehago-token"
|
||||
type="password"
|
||||
value={authConfig.accessToken || ""}
|
||||
onChange={(e) => updateAuthConfig("accessToken", e.target.value)}
|
||||
placeholder="Bearer 토큰으로 사용됩니다"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="wehago-hash-key">hashKey</Label>
|
||||
<Input
|
||||
id="wehago-hash-key"
|
||||
type="password"
|
||||
value={authConfig.hashKey || ""}
|
||||
onChange={(e) => updateAuthConfig("hashKey", e.target.value)}
|
||||
placeholder="HMAC-SHA256 서명 키"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="wehago-group-seq">groupSeq</Label>
|
||||
<Input
|
||||
id="wehago-group-seq"
|
||||
type="text"
|
||||
value={authConfig.groupSeq || ""}
|
||||
onChange={(e) => updateAuthConfig("groupSeq", e.target.value)}
|
||||
placeholder="예: gcmsAmaranth40578"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
* 매 요청마다 32자리 transaction-id, Unix timestamp, wehago-sign(HMAC-SHA256(accessToken+transactionId+timestamp+urlPath, hashKey) → Base64)을 자동 생성합니다.
|
||||
<br />
|
||||
* Wehago/RPS ERP API와 호환되는 인증 헤더(callerName, Authorization, transaction-id, timestamp, groupSeq, wehago-sign)가 자동 부착됩니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authType === "none" && (
|
||||
<div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
|
||||
인증이 필요하지 않은 공개 API입니다.
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
ExternalRestApiConnectionFilter,
|
||||
} from "@/lib/api/externalRestApiConnection";
|
||||
import { RestApiConnectionModal } from "./RestApiConnectionModal";
|
||||
import { Pagination } from "@/components/common/Pagination";
|
||||
|
||||
// 인증 타입 라벨
|
||||
const AUTH_TYPE_LABELS: Record<string, string> = {
|
||||
@@ -34,6 +35,7 @@ const AUTH_TYPE_LABELS: Record<string, string> = {
|
||||
basic: "Basic Auth",
|
||||
oauth2: "OAuth 2.0",
|
||||
"db-token": "DB 토큰",
|
||||
wehago: "Wehago/Amaranth",
|
||||
};
|
||||
|
||||
// 활성 상태 옵션
|
||||
@@ -60,6 +62,10 @@ export function RestApiConnectionList() {
|
||||
const [testingConnections, setTestingConnections] = useState<Set<number>>(new Set());
|
||||
const [testResults, setTestResults] = useState<Map<number, boolean>>(new Map());
|
||||
|
||||
// 페이지네이션 상태
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(20);
|
||||
|
||||
// 데이터 로딩
|
||||
const loadConnections = async () => {
|
||||
try {
|
||||
@@ -101,6 +107,19 @@ export function RestApiConnectionList() {
|
||||
loadConnections();
|
||||
}, [searchTerm, authTypeFilter, activeStatusFilter]);
|
||||
|
||||
// 필터 변경 시 1페이지로 리셋
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchTerm, authTypeFilter, 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);
|
||||
@@ -217,24 +236,24 @@ export function RestApiConnectionList() {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 검색 및 필터 */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* 검색 및 필터 — 컴팩트 */}
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
{/* 검색 */}
|
||||
<div className="relative w-full sm:w-[300px]">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-3.5 w-3.5 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="연결명 또는 URL로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
className="h-8 pl-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 인증 타입 필터 */}
|
||||
<Select value={authTypeFilter} onValueChange={setAuthTypeFilter}>
|
||||
<SelectTrigger className="h-10 w-full sm:w-[160px]">
|
||||
<SelectTrigger className="h-8 w-full text-xs sm:w-[160px]">
|
||||
<SelectValue placeholder="인증 타입" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -248,7 +267,7 @@ export function RestApiConnectionList() {
|
||||
|
||||
{/* 활성 상태 필터 */}
|
||||
<Select value={activeStatusFilter} onValueChange={setActiveStatusFilter}>
|
||||
<SelectTrigger className="h-10 w-full sm:w-[120px]">
|
||||
<SelectTrigger className="h-8 w-full text-xs sm:w-[120px]">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -259,11 +278,21 @@ export function RestApiConnectionList() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 총 건수 표시 */}
|
||||
<div className="text-muted-foreground hidden whitespace-nowrap text-xs sm:block">
|
||||
총 <span className="text-foreground font-semibold">{totalItems}</span> 건
|
||||
{totalPages > 1 && (
|
||||
<span className="ml-1.5">
|
||||
({safePage} / {totalPages} 페이지)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 추가 버튼 */}
|
||||
<Button onClick={handleAddConnection} className="h-10 gap-2 text-sm font-medium">
|
||||
<Plus className="h-4 w-4" />새 연결 추가
|
||||
<Button onClick={handleAddConnection} size="sm" className="h-8 gap-1.5 text-xs font-medium">
|
||||
<Plus className="h-3.5 w-3.5" />새 연결 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -279,62 +308,62 @@ export function RestApiConnectionList() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-card">
|
||||
<div className="rounded-lg border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-background">
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">연결명</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">회사</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">기본 URL</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">인증 타입</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">헤더 수</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">상태</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">마지막 테스트</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">연결 테스트</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-right text-sm font-semibold">작업</TableHead>
|
||||
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
||||
<TableHead className="h-9 px-3 text-xs font-semibold">연결명</TableHead>
|
||||
<TableHead className="h-9 px-3 text-xs font-semibold">회사</TableHead>
|
||||
<TableHead className="h-9 px-3 text-xs font-semibold">기본 URL</TableHead>
|
||||
<TableHead className="h-9 px-3 text-xs font-semibold">인증 타입</TableHead>
|
||||
<TableHead className="h-9 px-3 text-xs font-semibold">헤더</TableHead>
|
||||
<TableHead className="h-9 px-3 text-xs font-semibold">상태</TableHead>
|
||||
<TableHead className="h-9 px-3 text-xs font-semibold">마지막 테스트</TableHead>
|
||||
<TableHead className="h-9 px-3 text-xs font-semibold">연결 테스트</TableHead>
|
||||
<TableHead className="h-9 px-3 text-right text-xs font-semibold">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{connections.map((connection) => (
|
||||
<TableRow key={connection.id} className="bg-background transition-colors hover:bg-muted/50">
|
||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||
{pagedConnections.map((connection) => (
|
||||
<TableRow key={connection.id} className="transition-colors hover:bg-muted/50">
|
||||
<TableCell className="h-11 px-3 py-1.5 text-xs">
|
||||
<div className="max-w-[200px]">
|
||||
<div className="truncate font-medium" title={connection.connection_name}>
|
||||
{connection.connection_name}
|
||||
</div>
|
||||
{connection.description && (
|
||||
<div className="text-muted-foreground mt-1 truncate text-xs" title={connection.description}>
|
||||
<div className="text-muted-foreground truncate text-[10px]" title={connection.description}>
|
||||
{connection.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||
<TableCell className="h-11 px-3 py-1.5 text-xs">
|
||||
{(connection as any).company_name || connection.company_code}
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 font-mono text-sm">
|
||||
<div className="max-w-[300px] truncate" title={connection.base_url}>
|
||||
<TableCell className="h-11 px-3 py-1.5 font-mono text-xs">
|
||||
<div className="max-w-[260px] truncate" title={connection.base_url}>
|
||||
{connection.base_url}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||
<Badge variant="outline">{AUTH_TYPE_LABELS[connection.auth_type] || connection.auth_type}</Badge>
|
||||
<TableCell className="h-11 px-3 py-1.5 text-xs">
|
||||
<Badge variant="outline" className="text-[10px]">{AUTH_TYPE_LABELS[connection.auth_type] || connection.auth_type}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 text-center text-sm">
|
||||
<TableCell className="h-11 px-3 py-1.5 text-center text-xs">
|
||||
{Object.keys(connection.default_headers || {}).length}
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||
<Badge variant={connection.is_active === "Y" ? "default" : "secondary"}>
|
||||
<TableCell className="h-11 px-3 py-1.5 text-xs">
|
||||
<Badge variant={connection.is_active === "Y" ? "default" : "secondary"} className="text-[10px]">
|
||||
{connection.is_active === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||
<TableCell className="h-11 px-3 py-1.5 text-xs">
|
||||
{connection.last_test_date ? (
|
||||
<div>
|
||||
<div>{new Date(connection.last_test_date).toLocaleDateString()}</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px]">{new Date(connection.last_test_date).toLocaleDateString()}</span>
|
||||
<Badge
|
||||
variant={connection.last_test_result === "Y" ? "default" : "destructive"}
|
||||
className="mt-1"
|
||||
className="text-[10px]"
|
||||
>
|
||||
{connection.last_test_result === "Y" ? "성공" : "실패"}
|
||||
</Badge>
|
||||
@@ -343,41 +372,41 @@ export function RestApiConnectionList() {
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<TableCell className="h-11 px-3 py-1.5 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleTestConnection(connection)}
|
||||
disabled={testingConnections.has(connection.id!)}
|
||||
className="h-9 text-sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
{testingConnections.has(connection.id!) ? "테스트 중..." : "테스트"}
|
||||
</Button>
|
||||
{testResults.has(connection.id!) && (
|
||||
<Badge variant={testResults.get(connection.id!) ? "default" : "destructive"}>
|
||||
<Badge variant={testResults.get(connection.id!) ? "default" : "destructive"} className="text-[10px]">
|
||||
{testResults.get(connection.id!) ? "성공" : "실패"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<TableCell className="h-11 px-3 py-1.5 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEditConnection(connection)}
|
||||
className="h-8 w-8"
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteConnection(connection)}
|
||||
className="text-destructive hover:bg-destructive/10 h-8 w-8"
|
||||
className="text-destructive hover:bg-destructive/10 h-7 w-7"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -388,6 +417,29 @@ export function RestApiConnectionList() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 페이지네이션 — 0건이어도 페이지당 선택 UI는 보이도록 항상 렌더 */}
|
||||
{!loading && (
|
||||
<div className="rounded-lg border bg-card p-3 shadow-sm">
|
||||
<Pagination
|
||||
paginationInfo={{
|
||||
currentPage: safePage,
|
||||
totalPages,
|
||||
totalItems,
|
||||
itemsPerPage,
|
||||
startItem: totalItems === 0 ? 0 : startIdx + 1,
|
||||
endItem: endIdx,
|
||||
}}
|
||||
onPageChange={setCurrentPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setItemsPerPage(size);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
showPageSizeSelector
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 연결 설정 모달 */}
|
||||
{isModalOpen && (
|
||||
<RestApiConnectionModal
|
||||
@@ -424,6 +476,6 @@ export function RestApiConnectionList() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,8 +83,8 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||
setIsActive(connection.is_active === "Y");
|
||||
setSaveToHistory(connection.save_to_history === "Y");
|
||||
|
||||
// 테스트 초기값 설정
|
||||
setTestEndpoint("");
|
||||
// 테스트 초기값 설정 — 저장된 endpoint_path/default_body 를 그대로 채움 (비워두면 베이스URL 직접 호출 → 405)
|
||||
setTestEndpoint(connection.endpoint_path || "");
|
||||
setTestMethod(connection.default_method || "GET");
|
||||
setTestBody(connection.default_body || "");
|
||||
} else {
|
||||
@@ -129,14 +129,15 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
|
||||
// 사용자가 테스트하려는 실제 외부 API URL 설정
|
||||
const fullUrl = testEndpoint ? `${baseUrl}${testEndpoint}` : baseUrl;
|
||||
// 테스트 엔드포인트 비어있으면 저장된 endpoint_path 사용 (비워두면 베이스URL 직접 호출 → 405 가능)
|
||||
const effectiveEndpoint = testEndpoint || endpointPath || "";
|
||||
const fullUrl = effectiveEndpoint ? `${baseUrl}${effectiveEndpoint}` : baseUrl;
|
||||
setTestRequestUrl(fullUrl);
|
||||
|
||||
try {
|
||||
const testRequest: RestApiTestRequest = {
|
||||
base_url: baseUrl,
|
||||
endpoint: testEndpoint || undefined,
|
||||
endpoint: effectiveEndpoint || undefined,
|
||||
method: testMethod as any,
|
||||
headers: defaultHeaders,
|
||||
body: testBody ? JSON.parse(testBody) : undefined,
|
||||
@@ -583,6 +584,77 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||
)}
|
||||
|
||||
{testResult.error_details && <p className="mt-2 text-xs text-destructive">{testResult.error_details}</p>}
|
||||
|
||||
{/* 응답 본문 표시 — 배치 매핑 시 필드 확인용 */}
|
||||
{testResult.response_data !== undefined && testResult.response_data !== null && (
|
||||
<div className="mt-3">
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<div className="text-muted-foreground text-xs font-medium">응답 데이터 (Response Body)</div>
|
||||
{(() => {
|
||||
// 응답에서 배열을 자동 탐색해서 필드 수 표시
|
||||
const data = testResult.response_data as any;
|
||||
const findArray = (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 = findArray(v, depth + 1);
|
||||
if (arr) return arr;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const arr = findArray(data);
|
||||
const fields =
|
||||
arr && arr.length > 0 && typeof arr[0] === "object" && arr[0] !== null
|
||||
? Object.keys(arr[0])
|
||||
: data && typeof data === "object" && !Array.isArray(data)
|
||||
? Object.keys(data)
|
||||
: [];
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{arr ? `배열 ${arr.length}건 / ` : ""}필드 {fields.length}개
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<pre className="max-h-[320px] overflow-auto rounded bg-background/60 p-2 text-[11px] leading-relaxed font-mono">
|
||||
{(() => {
|
||||
try {
|
||||
return JSON.stringify(testResult.response_data, null, 2);
|
||||
} catch {
|
||||
return String(testResult.response_data);
|
||||
}
|
||||
})()}
|
||||
</pre>
|
||||
{/* 필드 미리보기 (배열의 첫 항목 기준) */}
|
||||
{(() => {
|
||||
const data = testResult.response_data as any;
|
||||
const findArray = (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 = findArray(v, depth + 1);
|
||||
if (arr) return arr;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const arr = findArray(data);
|
||||
if (!arr || arr.length === 0 || typeof arr[0] !== "object") return null;
|
||||
const fields = Object.keys(arr[0]);
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<div className="text-muted-foreground mb-1 text-xs font-medium">매핑 가능 필드 (DB 컬럼 매핑에 사용)</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{fields.map((f) => (
|
||||
<Badge key={f} variant="outline" className="font-mono text-[10px]">
|
||||
{f}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -114,7 +114,7 @@ export function ResponsiveDataView<T>({
|
||||
<TableHead
|
||||
key={col.key}
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
className="h-12 text-sm font-semibold"
|
||||
className="h-9 px-3 text-xs font-semibold"
|
||||
>
|
||||
{col.label}
|
||||
</TableHead>
|
||||
@@ -122,7 +122,7 @@ export function ResponsiveDataView<T>({
|
||||
{renderActions && (
|
||||
<TableHead
|
||||
style={{ width: actionsWidth || "120px" }}
|
||||
className="h-12 text-sm font-semibold"
|
||||
className="h-9 px-3 text-xs font-semibold"
|
||||
>
|
||||
{actionsLabel || "작업"}
|
||||
</TableHead>
|
||||
@@ -133,15 +133,15 @@ export function ResponsiveDataView<T>({
|
||||
{Array.from({ length: skeletonCount }).map((_, rowIdx) => (
|
||||
<TableRow key={rowIdx} className="border-b">
|
||||
{columns.map((col) => (
|
||||
<TableCell key={col.key} className="h-16">
|
||||
<TableCell key={col.key} className="h-11">
|
||||
<div className="h-4 animate-pulse rounded bg-muted" />
|
||||
</TableCell>
|
||||
))}
|
||||
{renderActions && (
|
||||
<TableCell className="h-16">
|
||||
<div className="flex gap-2">
|
||||
<div className="h-8 w-8 animate-pulse rounded bg-muted" />
|
||||
<div className="h-8 w-8 animate-pulse rounded bg-muted" />
|
||||
<TableCell className="h-11">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="h-7 w-7 animate-pulse rounded bg-muted" />
|
||||
<div className="h-7 w-7 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
@@ -217,7 +217,7 @@ export function ResponsiveDataView<T>({
|
||||
<TableHead
|
||||
key={col.key}
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
className="h-12 text-sm font-semibold"
|
||||
className="h-9 px-3 text-xs font-semibold"
|
||||
>
|
||||
{col.label}
|
||||
</TableHead>
|
||||
@@ -225,7 +225,7 @@ export function ResponsiveDataView<T>({
|
||||
{renderActions && (
|
||||
<TableHead
|
||||
style={{ width: actionsWidth || "120px" }}
|
||||
className="h-12 text-sm font-semibold"
|
||||
className="h-9 px-3 text-xs font-semibold"
|
||||
>
|
||||
{actionsLabel || "작업"}
|
||||
</TableHead>
|
||||
@@ -245,7 +245,7 @@ export function ResponsiveDataView<T>({
|
||||
{columns.map((col) => (
|
||||
<TableCell
|
||||
key={col.key}
|
||||
className={cn("h-16 text-sm", col.className)}
|
||||
className={cn("h-11 px-3 py-1.5 text-xs", col.className)}
|
||||
>
|
||||
{col.render
|
||||
? col.render(getNestedValue(item, col.key), item, index)
|
||||
@@ -253,8 +253,8 @@ export function ResponsiveDataView<T>({
|
||||
</TableCell>
|
||||
))}
|
||||
{renderActions && (
|
||||
<TableCell className="h-16 text-sm">
|
||||
<div className="flex gap-2">{renderActions(item)}</div>
|
||||
<TableCell className="h-11 px-3 py-1.5 text-xs">
|
||||
<div className="flex gap-1.5">{renderActions(item)}</div>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
|
||||
@@ -357,12 +357,25 @@ export function AdminPageRenderer({ url }: AdminPageRendererProps) {
|
||||
const companyCode = user?.companyCode || user?.company_code;
|
||||
const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, "");
|
||||
|
||||
// 쿼리스트링 파싱 → adminParams 으로 자식에게 전달 (편집 모드 ?edit=N 등)
|
||||
const queryParams = useMemo(() => {
|
||||
const params: Record<string, string> = {};
|
||||
const queryStr = url.split("?")[1]?.split("#")[0];
|
||||
if (queryStr) {
|
||||
queryStr.split("&").forEach((kv) => {
|
||||
const [k, v = ""] = kv.split("=");
|
||||
if (k) params[decodeURIComponent(k)] = decodeURIComponent(v);
|
||||
});
|
||||
}
|
||||
return params;
|
||||
}, [url]);
|
||||
|
||||
// 회사별 커스텀 페이지: companyCode를 prefix로 붙여 경로 변환
|
||||
const resolvedUrl = (companyCode && isCompanyPage(cleanUrl))
|
||||
? `/${companyCode}${cleanUrl}`
|
||||
: cleanUrl;
|
||||
|
||||
console.log("[AdminPageRenderer] 렌더링:", { url, cleanUrl, resolvedUrl, companyCode });
|
||||
console.log("[AdminPageRenderer] 렌더링:", { url, cleanUrl, resolvedUrl, companyCode, queryParams });
|
||||
|
||||
// 화면 할당: /screens/[id]
|
||||
const screensIdMatch = cleanUrl.match(/^\/screens\/(\d+)$/);
|
||||
@@ -392,7 +405,12 @@ export function AdminPageRenderer({ url }: AdminPageRendererProps) {
|
||||
|
||||
if (PageComponent) {
|
||||
console.log("[AdminPageRenderer] → 레지스트리 매칭:", resolvedUrl || cleanUrl);
|
||||
return <PageComponent />;
|
||||
// 쿼리 파라미터가 있으면 adminParams 로 전달 (편집 모드 ?edit=N 등)
|
||||
const PC = PageComponent as any;
|
||||
if (Object.keys(queryParams).length > 0) {
|
||||
return <PC adminParams={queryParams} />;
|
||||
}
|
||||
return <PC />;
|
||||
}
|
||||
|
||||
// 레지스트리에 없으면 동적 import 시도
|
||||
@@ -400,7 +418,7 @@ export function AdminPageRenderer({ url }: AdminPageRendererProps) {
|
||||
for (const { pattern, extractParams } of DYNAMIC_ADMIN_PATTERNS) {
|
||||
const match = cleanUrl.match(pattern);
|
||||
if (match) {
|
||||
const params = extractParams(match);
|
||||
const params = { ...extractParams(match), ...queryParams };
|
||||
console.log("[AdminPageRenderer] → 동적 라우트 매칭:", cleanUrl, params);
|
||||
return <DynamicAdminLoader url={cleanUrl} params={params} />;
|
||||
}
|
||||
@@ -408,5 +426,10 @@ export function AdminPageRenderer({ url }: AdminPageRendererProps) {
|
||||
|
||||
// 레지스트리/패턴에 없으면 DynamicAdminLoader가 자동 import 시도
|
||||
console.log("[AdminPageRenderer] → 자동 import 시도:", resolvedUrl);
|
||||
return <DynamicAdminLoader url={resolvedUrl} />;
|
||||
return (
|
||||
<DynamicAdminLoader
|
||||
url={resolvedUrl}
|
||||
params={Object.keys(queryParams).length > 0 ? queryParams : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -128,6 +128,7 @@ class BatchManagementAPIClass {
|
||||
requestBody?: string,
|
||||
authServiceName?: string, // DB에서 토큰 가져올 서비스명
|
||||
dataArrayPath?: string, // 데이터 배열 경로 (예: response, data.items)
|
||||
restApiConnectionId?: number, // 등록된 REST API 연결 사용 시 — Wehago/Amaranth 인증 자동 적용
|
||||
): Promise<{
|
||||
fields: string[];
|
||||
samples: any[];
|
||||
@@ -142,6 +143,11 @@ class BatchManagementAPIClass {
|
||||
requestBody,
|
||||
};
|
||||
|
||||
// 등록된 연결 ID — 백엔드가 이걸 받으면 ExternalRestApiConnectionService 경로로 호출 (Wehago 인증 처리)
|
||||
if (restApiConnectionId) {
|
||||
requestData.restApiConnectionId = restApiConnectionId;
|
||||
}
|
||||
|
||||
// 파라미터 정보가 있으면 추가
|
||||
if (paramInfo) {
|
||||
requestData.paramType = paramInfo.paramType;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// ERP 마스터 동기화 API 클라이언트
|
||||
// 자동화 관리 > 배치 관리 화면의 "ERP 동기화" 탭에서 호출
|
||||
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export interface ErpSyncResult {
|
||||
resource: string;
|
||||
fetched: number;
|
||||
inserted: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
errors: string[];
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export type ErpSyncResource =
|
||||
| "employees"
|
||||
| "departments"
|
||||
| "warehouses"
|
||||
| "customers"
|
||||
| "account-codes"
|
||||
| "all";
|
||||
|
||||
interface ErpSyncRequest {
|
||||
coCd: string;
|
||||
companyCode?: string;
|
||||
searchText?: string;
|
||||
baselocFg?: string;
|
||||
}
|
||||
|
||||
interface SingleResponse {
|
||||
success: boolean;
|
||||
data?: ErpSyncResult;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface AllResponse {
|
||||
success: boolean;
|
||||
totals?: {
|
||||
fetched: number;
|
||||
inserted: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
errorsCount: number;
|
||||
};
|
||||
data?: ErpSyncResult[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const BASE = "/erp-sync";
|
||||
|
||||
export const ErpSyncAPI = {
|
||||
async syncOne(resource: Exclude<ErpSyncResource, "all">, body: ErpSyncRequest): Promise<ErpSyncResult> {
|
||||
const res = await apiClient.post<SingleResponse>(`${BASE}/${resource}`, body);
|
||||
if (!res.data.success) {
|
||||
throw new Error(res.data.message || `${resource} 동기화 실패`);
|
||||
}
|
||||
return res.data.data!;
|
||||
},
|
||||
|
||||
async syncAll(body: ErpSyncRequest): Promise<{ totals: AllResponse["totals"]; results: ErpSyncResult[] }> {
|
||||
const res = await apiClient.post<AllResponse>(`${BASE}/all`, body);
|
||||
return {
|
||||
totals: res.data.totals,
|
||||
results: res.data.data || [],
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export type AuthType = "none" | "api-key" | "bearer" | "basic" | "oauth2" | "db-token";
|
||||
export type AuthType = "none" | "api-key" | "bearer" | "basic" | "oauth2" | "db-token" | "wehago";
|
||||
|
||||
export interface ExternalRestApiConnection {
|
||||
id?: number;
|
||||
@@ -39,6 +39,14 @@ export interface ExternalRestApiConnection {
|
||||
dbWhereValue?: string;
|
||||
dbHeaderName?: string;
|
||||
dbHeaderTemplate?: string;
|
||||
|
||||
// Wehago / Amaranth (RPS ERP) 인증 모드
|
||||
// - HMAC-SHA256(accessToken + transactionId + timestamp + urlPath, hashKey) → Base64
|
||||
// - 헤더: callerName, Authorization: Bearer <accessToken>, transaction-id, timestamp, groupSeq, wehago-sign
|
||||
callerName?: string;
|
||||
accessToken?: string;
|
||||
hashKey?: string;
|
||||
groupSeq?: string;
|
||||
};
|
||||
timeout?: number;
|
||||
retry_count?: number;
|
||||
@@ -243,6 +251,7 @@ export class ExternalRestApiConnectionAPI {
|
||||
{ value: "basic", label: "Basic Auth" },
|
||||
{ value: "oauth2", label: "OAuth 2.0" },
|
||||
{ value: "db-token", label: "DB 토큰" },
|
||||
{ value: "wehago", label: "Wehago/Amaranth (아마란스)" },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { useState } from "react";
|
||||
|
||||
interface QueryProviderProps {
|
||||
@@ -31,12 +30,6 @@ export function QueryProvider({ children }: QueryProviderProps) {
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
{/* 개발 환경에서만 DevTools 표시 */}
|
||||
{process.env.NODE_ENV === "development" && <ReactQueryDevtools initialIsOpen={false} />}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user