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:
chpark
2026-05-07 09:48:28 +09:00
parent 36e232ba00
commit 97b333dd2e
30 changed files with 4452 additions and 799 deletions
+27
View File
@@ -171,6 +171,7 @@ import outboundRoutes from "./routes/outboundRoutes"; // 출고관리
import outsourcingOutboundRoutes from "./routes/outsourcingOutboundRoutes"; // 외주출고 import outsourcingOutboundRoutes from "./routes/outsourcingOutboundRoutes"; // 외주출고
import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리 import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리
import quoteRoutes from "./routes/quoteRoutes"; // 견적관리 import quoteRoutes from "./routes/quoteRoutes"; // 견적관리
import erpSyncRoutes from "./routes/erpSyncRoutes"; // ERP 마스터 동기화 (사원/부서/창고/거래처/계정과목)
import { BatchSchedulerService } from "./services/batchSchedulerService"; import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@@ -334,6 +335,7 @@ app.use("/api/batch-configs", batchRoutes);
app.use("/api/excel-mapping", excelMappingRoutes); // 엑셀 매핑 템플릿 app.use("/api/excel-mapping", excelMappingRoutes); // 엑셀 매핑 템플릿
app.use("/api/batch-management", batchManagementRoutes); app.use("/api/batch-management", batchManagementRoutes);
app.use("/api/batch-execution-logs", batchExecutionLogRoutes); app.use("/api/batch-execution-logs", batchExecutionLogRoutes);
app.use("/api/erp-sync", erpSyncRoutes); // ERP 마스터 동기화 (사원/부서/창고/거래처/계정과목)
// app.use("/api/db-type-categories", dbTypeCategoryRoutes); // 파일이 존재하지 않음 // app.use("/api/db-type-categories", dbTypeCategoryRoutes); // 파일이 존재하지 않음
app.use("/api/ddl", ddlRoutes); app.use("/api/ddl", ddlRoutes);
app.use("/api/entity-reference", entityReferenceRoutes); app.use("/api/entity-reference", entityReferenceRoutes);
@@ -493,6 +495,31 @@ async function initializeServices() {
logger.error(`❌ 마이그레이션 실패:`, error); 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 { try {
await BatchSchedulerService.initializeScheduler(); await BatchSchedulerService.initializeScheduler();
@@ -660,12 +660,13 @@ export class DashboardController {
saveToHistory: connection.save_to_history === "Y", saveToHistory: connection.save_to_history === "Y",
}; };
// 인증 헤더 생성 (DB 토큰) // 인증 헤더 생성 (DB 토큰, Wehago/Amaranth 등 — wehago는 urlPath 기반 서명)
const authHeaders = const authHeaders =
await ExternalRestApiConnectionService.getAuthHeaders( await ExternalRestApiConnectionService.getAuthHeaders(
connection.auth_type, connection.auth_type,
connection.auth_config, connection.auth_config,
connection.company_code connection.company_code,
requestConfig.url
); );
// 기존 헤더에 인증 헤더 병합 // 기존 헤더에 인증 헤더 병합
@@ -128,7 +128,7 @@ export class BatchManagementController {
try { try {
const { const {
batchName, description, cronSchedule, mappings, isActive, batchName, description, cronSchedule, mappings, isActive,
executionType, nodeFlowId, nodeFlowContext, executionType, nodeFlowId, nodeFlowContext, rowFilterConfig,
} = req.body; } = req.body;
const companyCode = req.user?.companyCode; const companyCode = req.user?.companyCode;
@@ -158,6 +158,7 @@ export class BatchManagementController {
executionType: executionType || "mapping", executionType: executionType || "mapping",
nodeFlowId: nodeFlowId || null, nodeFlowId: nodeFlowId || null,
nodeFlowContext: nodeFlowContext || null, nodeFlowContext: nodeFlowContext || null,
rowFilterConfig: rowFilterConfig || null,
} as CreateBatchConfigRequest, } as CreateBatchConfigRequest,
req.user?.userId req.user?.userId
); );
@@ -437,10 +438,74 @@ export class BatchManagementController {
requestBody, requestBody,
authServiceName, // DB에서 토큰 가져올 서비스명 authServiceName, // DB에서 토큰 가져올 서비스명
dataArrayPath, // 데이터 배열 경로 (예: response, data.items) dataArrayPath, // 데이터 배열 경로 (예: response, data.items)
restApiConnectionId, // 등록된 REST API 연결 사용 (Wehago/Amaranth 인증 자동 처리)
} = req.body; } = req.body;
// apiUrl, endpoint는 항상 필수 // 등록된 연결 모드면 연결의 base_url/endpoint를 기본값으로 사용
if (!apiUrl || !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({ return res.status(400).json({
success: false, success: false,
message: "API URL과 엔드포인트는 필수입니다.", 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 });
}
+27
View File
@@ -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) // FROM 데이터 조회 (DB 또는 REST API)
if (firstMapping.from_connection_type === "restapi") { 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( logger.info(
`REST API에서 데이터 조회: ${firstMapping.from_api_url}${firstMapping.from_table_name}` `REST API에서 데이터 조회: ${firstMapping.from_api_url}${firstMapping.from_table_name}`
); );
@@ -398,6 +485,7 @@ export class BatchSchedulerService {
} else { } else {
throw new Error(`REST API 데이터 조회 실패: ${apiResult.message}`); throw new Error(`REST API 데이터 조회 실패: ${apiResult.message}`);
} }
} // end of inline-mode else (등록된 연결 없을 때)
} else { } else {
// DB에서 데이터 조회 // DB에서 데이터 조회
const fromColumns = mappings.map((m: any) => m.from_column_name); 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; totalRecords += fromData.length;
// 컬럼 매핑 적용하여 TO 테이블 형식으로 변환 // 컬럼 매핑 적용하여 TO 테이블 형식으로 변환
@@ -431,6 +555,29 @@ export class BatchSchedulerService {
continue; 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 배치인지 확인 // DB → REST API 배치인지 확인
if ( if (
firstMapping.to_connection_type === "restapi" && firstMapping.to_connection_type === "restapi" &&
+43 -9
View File
@@ -73,11 +73,13 @@ export class BatchService {
const totalPages = Math.ceil(total / limit); const totalPages = Math.ceil(total / limit);
// 목록 조회 — LATERAL JOIN으로 batch_execution_logs 최신 1건을 단일 스캔으로 조회 (N+1 방지) // 목록 조회 — LATERAL JOIN으로 batch_execution_logs 최신 1건을 단일 스캔으로 조회 (N+1 방지)
// batch_mappings 는 JSON 집계로 함께 가져와서 프론트의 배지(DB↔API) 판단을 정확하게 만든다.
const configs = await query<any>( const configs = await query<any>(
`SELECT bc.*, `SELECT bc.*,
bel.execution_status as last_status, bel.execution_status as last_status,
bel.start_time as last_executed_at, 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 FROM batch_configs bc
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT execution_status, start_time, total_records SELECT execution_status, start_time, total_records
@@ -86,6 +88,27 @@ export class BatchService {
ORDER BY start_time DESC ORDER BY start_time DESC
LIMIT 1 LIMIT 1
) bel ON true ) 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} ${whereClause}
ORDER BY bc.created_date DESC ORDER BY bc.created_date DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`, LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
@@ -183,8 +206,8 @@ export class BatchService {
// 배치 설정 생성 // 배치 설정 생성
const batchConfigResult = await client.query( const batchConfigResult = await client.query(
`INSERT INTO batch_configs `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) (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, NOW(), NOW()) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, NOW(), NOW())
RETURNING *`, RETURNING *`,
[ [
data.batchName, data.batchName,
@@ -199,6 +222,7 @@ export class BatchService {
data.executionType || "mapping", data.executionType || "mapping",
data.nodeFlowId || null, data.nodeFlowId || null,
data.nodeFlowContext ? JSON.stringify(data.nodeFlowContext) : null, data.nodeFlowContext ? JSON.stringify(data.nodeFlowContext) : null,
data.rowFilterConfig ? JSON.stringify(data.rowFilterConfig) : null,
userId, 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_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, 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_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) 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, NOW()) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, NOW())
RETURNING *`, RETURNING *`,
[ [
batchConfig.id, batchConfig.id,
@@ -244,7 +268,8 @@ export class BatchService {
mapping.to_api_method, mapping.to_api_method,
mapping.to_api_body, mapping.to_api_body,
mapping.mapping_order || index + 1, 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, userId,
] ]
); );
@@ -358,6 +383,14 @@ export class BatchService {
: null : 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( const batchConfigResult = await client.query(
@@ -386,8 +419,8 @@ export class BatchService {
from_column_type, from_api_url, from_api_key, from_api_method, from_api_param_type, 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, 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_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) 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, NOW()) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, NOW())
RETURNING *`, RETURNING *`,
[ [
id, id,
@@ -415,7 +448,8 @@ export class BatchService {
mapping.to_api_method, mapping.to_api_method,
mapping.to_api_body, mapping.to_api_body,
mapping.mapping_order || index + 1, 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, userId,
] ]
); );
+226
View File
@@ -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})`
);
}
}
+539
View File
@@ -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( static async getAuthHeaders(
authType: AuthType, authType: AuthType,
authConfig: any, authConfig: any,
companyCode?: string companyCode?: string,
requestUrl?: string
): Promise<Record<string, string>> { ): Promise<Record<string, string>> {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
@@ -578,6 +584,44 @@ export class ExternalRestApiConnectionService {
if (authConfig.keyLocation === "header") { if (authConfig.keyLocation === "header") {
headers[authConfig.keyName] = authConfig.keyValue; 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; return headers;
@@ -596,15 +640,7 @@ export class ExternalRestApiConnectionService {
// 헤더 구성 // 헤더 구성
let headers = { ...testRequest.headers }; let headers = { ...testRequest.headers };
// 인증 헤더 생성 및 병합 // URL 먼저 구성 (wehago-sign 은 urlPath 기반이므로 URL 확정 후 헤더 생성)
const authHeaders = await this.getAuthHeaders(
testRequest.auth_type,
testRequest.auth_config,
userCompanyCode
);
headers = { ...headers, ...authHeaders };
// URL 구성
let url = testRequest.base_url; let url = testRequest.base_url;
if (testRequest.endpoint) { if (testRequest.endpoint) {
url = testRequest.endpoint.startsWith("/") url = testRequest.endpoint.startsWith("/")
@@ -623,6 +659,15 @@ export class ExternalRestApiConnectionService {
url = `${url}${separator}${testRequest.auth_config.keyName}=${testRequest.auth_config.keyValue}`; 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( logger.info(
`REST API 연결 테스트: ${testRequest.method || "GET"} ${url}` `REST API 연결 테스트: ${testRequest.method || "GET"} ${url}`
); );
@@ -824,18 +869,13 @@ export class ExternalRestApiConnectionService {
const encrypted = { ...authConfig }; const encrypted = { ...authConfig };
// 암호화 대상 필드 // 암호화 대상 필드
if (encrypted.keyValue) { if (encrypted.keyValue) encrypted.keyValue = this.encrypt(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.token) { if (encrypted.clientSecret) encrypted.clientSecret = this.encrypt(encrypted.clientSecret);
encrypted.token = this.encrypt(encrypted.token); // Wehago / Amaranth (RPS ERP)
} if (encrypted.accessToken) encrypted.accessToken = this.encrypt(encrypted.accessToken);
if (encrypted.password) { if (encrypted.hashKey) encrypted.hashKey = this.encrypt(encrypted.hashKey);
encrypted.password = this.encrypt(encrypted.password);
}
if (encrypted.clientSecret) {
encrypted.clientSecret = this.encrypt(encrypted.clientSecret);
}
return encrypted; return encrypted;
} }
@@ -848,23 +888,22 @@ export class ExternalRestApiConnectionService {
const decrypted = { ...authConfig }; const decrypted = { ...authConfig };
// 복호화 대상 필드 // 복호화 대상 필드 (각각 try/catch — 평문이 섞여있어도 부분적으로 복호화)
const tryDecrypt = (field: string) => {
if (decrypted[field]) {
try { try {
if (decrypted.keyValue) { decrypted[field] = this.decrypt(decrypted[field]);
decrypted.keyValue = this.decrypt(decrypted.keyValue); } 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; return decrypted;
} }
@@ -1096,6 +1135,7 @@ export class ExternalRestApiConnectionService {
"basic", "basic",
"oauth2", "oauth2",
"db-token", "db-token",
"wehago",
]; ];
if (!validAuthTypes.includes(data.auth_type)) { if (!validAuthTypes.includes(data.auth_type)) {
throw new Error("올바르지 않은 인증 타입입니다."); throw new Error("올바르지 않은 인증 타입입니다.");
+2
View File
@@ -162,6 +162,7 @@ export interface CreateBatchConfigRequest {
executionType?: BatchExecutionType; executionType?: BatchExecutionType;
nodeFlowId?: number; nodeFlowId?: number;
nodeFlowContext?: Record<string, any>; nodeFlowContext?: Record<string, any>;
rowFilterConfig?: { exclude?: Array<{ column: string; op?: string; value: any }> } | null;
mappings: BatchMappingRequest[]; mappings: BatchMappingRequest[];
} }
@@ -177,6 +178,7 @@ export interface UpdateBatchConfigRequest {
executionType?: BatchExecutionType; executionType?: BatchExecutionType;
nodeFlowId?: number; nodeFlowId?: number;
nodeFlowContext?: Record<string, any>; nodeFlowContext?: Record<string, any>;
rowFilterConfig?: { exclude?: Array<{ column: string; op?: string; value: any }> } | null;
mappings?: BatchMappingRequest[]; mappings?: BatchMappingRequest[];
} }
@@ -6,7 +6,8 @@ export type AuthType =
| "bearer" | "bearer"
| "basic" | "basic"
| "oauth2" | "oauth2"
| "db-token"; | "db-token"
| "wehago";
export interface ExternalRestApiConnection { export interface ExternalRestApiConnection {
id?: number; id?: number;
@@ -47,6 +48,12 @@ export interface ExternalRestApiConnection {
dbWhereValue?: string; dbWhereValue?: string;
dbHeaderName?: string; dbHeaderName?: string;
dbHeaderTemplate?: string; dbHeaderTemplate?: string;
// Wehago / Amaranth (RPS ERP) — 매 요청 HMAC-SHA256 서명
callerName?: string;
accessToken?: string;
hashKey?: string;
groupSeq?: string;
}; };
timeout?: number; timeout?: number;
retry_count?: number; retry_count?: number;
@@ -100,4 +107,5 @@ export const AUTH_TYPE_OPTIONS = [
{ value: "basic", label: "Basic Auth" }, { value: "basic", label: "Basic Auth" },
{ value: "oauth2", label: "OAuth 2.0" }, { value: "oauth2", label: "OAuth 2.0" },
{ value: "db-token", label: "DB 토큰" }, { value: "db-token", label: "DB 토큰" },
{ value: "wehago", label: "Wehago/Amaranth (아마란스)" },
]; ];
@@ -35,6 +35,7 @@ import {
} from "@/lib/api/batch"; } from "@/lib/api/batch";
import { ScrollToTop } from "@/components/common/ScrollToTop"; import { ScrollToTop } from "@/components/common/ScrollToTop";
import { useTabStore } from "@/stores/tabStore"; import { useTabStore } from "@/stores/tabStore";
import { Pagination } from "@/components/common/Pagination";
function cronToKorean(cron: string): string { function cronToKorean(cron: string): string {
const parts = cron.split(" "); const parts = cron.split(" ");
@@ -329,6 +330,10 @@ export default function BatchManagementPage() {
const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false); const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false);
const [togglingBatch, setTogglingBatch] = useState<number | null>(null); const [togglingBatch, setTogglingBatch] = useState<number | null>(null);
// 페이지네이션 상태
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(20);
const loadBatchConfigs = useCallback(async () => { const loadBatchConfigs = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
@@ -445,6 +450,11 @@ export default function BatchManagementPage() {
} }
}; };
// 검색/상태 필터 변경 시 1페이지로 리셋
useEffect(() => {
setCurrentPage(1);
}, [searchTerm, statusFilter]);
const filteredBatches = batchConfigs.filter((batch) => { const filteredBatches = batchConfigs.filter((batch) => {
if (searchTerm && !batch.batch_name.toLowerCase().includes(searchTerm.toLowerCase()) && !(batch.description || "").toLowerCase().includes(searchTerm.toLowerCase())) return false; 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; if (statusFilter === "active" && batch.is_active !== "Y") return false;
@@ -452,6 +462,14 @@ export default function BatchManagementPage() {
return true; 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 activeBatches = batchConfigs.filter(b => b.is_active === "Y").length;
const inactiveBatches = batchConfigs.length - activeBatches; const inactiveBatches = batchConfigs.length - activeBatches;
const execDiff = stats ? stats.todayExecutions - stats.prevDayExecutions : 0; const execDiff = stats ? stats.todayExecutions - stats.prevDayExecutions : 0;
@@ -459,7 +477,7 @@ export default function BatchManagementPage() {
return ( return (
<div className="h-full overflow-y-auto bg-background"> <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"> <div className="flex items-center justify-between">
@@ -542,7 +560,7 @@ export default function BatchManagementPage() {
</div> </div>
{/* 배치 리스트 */} {/* 배치 리스트 */}
<div className="space-y-1.5"> <div className="space-y-1">
{loading && batchConfigs.length === 0 && ( {loading && batchConfigs.length === 0 && (
<div className="flex h-40 items-center justify-center"> <div className="flex h-40 items-center justify-center">
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" /> <RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
@@ -556,7 +574,7 @@ export default function BatchManagementPage() {
</div> </div>
)} )}
{filteredBatches.map((batch) => { {pagedBatches.map((batch) => {
const batchId = batch.id!; const batchId = batch.id!;
const isExpanded = expandedBatch === batchId; const isExpanded = expandedBatch === batchId;
const isExecuting = executingBatch === batchId; const isExecuting = executingBatch === batchId;
@@ -572,51 +590,52 @@ export default function BatchManagementPage() {
return ( 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 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"> <div onClick={(e) => e.stopPropagation()} className="shrink-0">
<Switch <Switch
checked={isActive} checked={isActive}
onCheckedChange={() => toggleBatchActive(batchId, batch.is_active || "N")} onCheckedChange={() => toggleBatchActive(batchId, batch.is_active || "N")}
disabled={isToggling} disabled={isToggling}
className="scale-[0.7]" className="scale-[0.65]"
/> />
</div> </div>
{/* 배치 이름 + 설명 */} {/* 배치 이름 + 설명 (한 줄로 합침) */}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{batch.batch_name}</p> <div className="flex items-baseline gap-2">
<p className="truncate text-[11px] text-muted-foreground">{batch.description || ""}</p> <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> </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} {typeStyle.label}
</span> </span>
{/* 스케줄 */} {/* 스케줄 (한 줄로) */}
<div className="hidden shrink-0 text-right sm:block" style={{ minWidth: 90 }}> <div className="hidden shrink-0 text-right sm:block" style={{ minWidth: 110 }}>
<p className="text-[12px] font-medium">{cronToKorean(batch.cron_schedule)}</p> <p className="text-[11px] font-medium leading-tight">{cronToKorean(batch.cron_schedule)}</p>
<p className="text-[10px] text-muted-foreground"> {getNextExecution(batch.cron_schedule, isActive) && (
{getNextExecution(batch.cron_schedule, isActive) <p className="text-[10px] leading-tight text-muted-foreground">{getNextExecution(batch.cron_schedule, isActive)}</p>
? `다음: ${getNextExecution(batch.cron_schedule, isActive)}` )}
: ""}
</p>
</div> </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] || []} /> <Sparkline data={sparklineCache[batchId] || []} />
</div> </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 ? ( {isExecuting ? (
<p className="text-[11px] font-semibold text-amber-500"> ...</p> <span className="text-[11px] font-semibold text-amber-500"> </span>
) : lastAt ? ( ) : lastAt ? (
<> <>
<div className="flex items-center justify-end gap-1">
{isFailed ? ( {isFailed ? (
<AlertCircle className="h-3 w-3 text-destructive" /> <AlertCircle className="h-3 w-3 text-destructive" />
) : isSuccess ? ( ) : isSuccess ? (
@@ -625,39 +644,45 @@ export default function BatchManagementPage() {
<span className={`text-[11px] font-semibold ${isFailed ? "text-destructive" : "text-emerald-500"}`}> <span className={`text-[11px] font-semibold ${isFailed ? "text-destructive" : "text-emerald-500"}`}>
{isFailed ? "실패" : "성공"} {isFailed ? "실패" : "성공"}
</span> </span>
</div> <span className="text-[10px] text-muted-foreground">{timeAgo(lastAt)}</span>
<p className="text-[10px] text-muted-foreground">{timeAgo(lastAt)}</p>
</> </>
) : ( ) : (
<p className="text-[11px] text-muted-foreground">&mdash;</p> <span className="text-[11px] text-muted-foreground"></span>
)} )}
</div> </div>
{/* 액션 */} {/* 액션 */}
<div className="flex shrink-0 items-center gap-0.5"> <div className="flex shrink-0 items-center gap-0.5">
<button <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)} onClick={(e) => executeBatch(e, batchId)}
disabled={isExecuting} disabled={isExecuting}
title="지금 실행하기" 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>
<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" 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(); openTab({ type: "admin", title: `배치 편집 #${batchId}`, adminUrl: `/admin/automaticMng/batchmngList/edit/${batchId}` }); }} 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="수정하기" title="수정하기"
> >
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3 w-3" />
</button> </button>
<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)} onClick={(e) => deleteBatch(e, batchId, batch.batch_name)}
title="삭제하기" title="삭제하기"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3 w-3" />
</button> </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>
</div> </div>
@@ -681,6 +706,32 @@ export default function BatchManagementPage() {
})} })}
</div> </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 && ( {isBatchTypeModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm" onClick={() => setIsBatchTypeModalOpen(false)}> <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 { RestApiConnectionList } from "@/components/admin/RestApiConnectionList";
import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView"; import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView";
import { ScrollToTop } from "@/components/common/ScrollToTop"; import { ScrollToTop } from "@/components/common/ScrollToTop";
import { Pagination } from "@/components/common/Pagination";
type ConnectionTabType = "database" | "rest-api"; type ConnectionTabType = "database" | "rest-api";
@@ -69,6 +70,10 @@ export default function ExternalConnectionsPage() {
const [sqlModalOpen, setSqlModalOpen] = useState(false); const [sqlModalOpen, setSqlModalOpen] = useState(false);
const [selectedConnection, setSelectedConnection] = useState<ExternalDbConnection | null>(null); const [selectedConnection, setSelectedConnection] = useState<ExternalDbConnection | null>(null);
// 페이지네이션 상태
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(20);
// 데이터 로딩 // 데이터 로딩
const loadConnections = async () => { const loadConnections = async () => {
try { try {
@@ -121,6 +126,19 @@ export default function ExternalConnectionsPage() {
loadConnections(); loadConnections();
}, [searchTerm, dbTypeFilter, activeStatusFilter]); }, [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 = () => { const handleAddConnection = () => {
setEditingConnection(undefined); setEditingConnection(undefined);
setIsModalOpen(true); setIsModalOpen(true);
@@ -264,43 +282,43 @@ export default function ExternalConnectionsPage() {
]; ];
return ( return (
<div className="flex min-h-screen flex-col bg-background"> <div className="bg-background h-full overflow-y-auto">
<div className="space-y-6 p-6"> <div className="mx-auto w-full max-w-[1920px] space-y-3 px-4 py-3 sm:px-5 2xl:px-6">
{/* 페이지 헤더 */} {/* 페이지 헤더 — 컴팩트 */}
<div className="space-y-2 border-b pb-4"> <div className="flex items-baseline gap-2 border-b pb-2">
<h1 className="text-3xl font-bold tracking-tight"> </h1> <h1 className="text-lg font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> REST API </p> <span className="text-xs text-muted-foreground"> REST API </span>
</div> </div>
{/* 탭 */} {/* 탭 */}
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as ConnectionTabType)}> <Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as ConnectionTabType)}>
<TabsList className="grid w-full max-w-[400px] grid-cols-2"> <TabsList className="grid w-full max-w-[360px] grid-cols-2">
<TabsTrigger value="database" className="flex items-center gap-2"> <TabsTrigger value="database" className="flex items-center gap-1.5 text-xs">
<Database className="h-4 w-4" /> <Database className="h-3.5 w-3.5" />
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="rest-api" className="flex items-center gap-2"> <TabsTrigger value="rest-api" className="flex items-center gap-1.5 text-xs">
<Globe className="h-4 w-4" /> <Globe className="h-3.5 w-3.5" />
REST API REST API
</TabsTrigger> </TabsTrigger>
</TabsList> </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="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" /> <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
placeholder="연결명 또는 설명으로 검색..." placeholder="연결명 또는 설명으로 검색..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="h-10 pl-10 text-sm" className="h-9 pl-10 text-sm"
/> />
</div> </div>
<Select value={dbTypeFilter} onValueChange={setDbTypeFilter}> <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 타입" /> <SelectValue placeholder="DB 타입" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -312,7 +330,7 @@ export default function ExternalConnectionsPage() {
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={activeStatusFilter} onValueChange={setActiveStatusFilter}> <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="상태" /> <SelectValue placeholder="상태" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -323,8 +341,11 @@ export default function ExternalConnectionsPage() {
))} ))}
</SelectContent> </SelectContent>
</Select> </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"> </div>
<Button onClick={handleAddConnection} className="h-9 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</Button> </Button>
@@ -332,7 +353,7 @@ export default function ExternalConnectionsPage() {
{/* 연결 목록 - ResponsiveDataView */} {/* 연결 목록 - ResponsiveDataView */}
<ResponsiveDataView <ResponsiveDataView
data={connections} data={pagedConnections}
columns={columns} columns={columns}
keyExtractor={(c) => String(c.id || c.connection_name)} keyExtractor={(c) => String(c.id || c.connection_name)}
isLoading={loading} isLoading={loading}
@@ -382,6 +403,27 @@ export default function ExternalConnectionsPage() {
actionsWidth="180px" 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 && ( {isModalOpen && (
<ExternalDbConnectionModal <ExternalDbConnectionModal
@@ -436,7 +478,7 @@ export default function ExternalConnectionsPage() {
</TabsContent> </TabsContent>
{/* REST API 연결 탭 */} {/* REST API 연결 탭 */}
<TabsContent value="rest-api" className="space-y-6"> <TabsContent value="rest-api" className="space-y-3 mt-3">
<RestApiConnectionList /> <RestApiConnectionList />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
@@ -36,6 +36,7 @@ import { ScrollToTop } from "@/components/common/ScrollToTop";
import { ExternalDbConnectionAPI, ExternalDbConnection } from "@/lib/api/externalDbConnection"; import { ExternalDbConnectionAPI, ExternalDbConnection } from "@/lib/api/externalDbConnection";
import { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection"; import { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection";
import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView"; import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView";
import { Pagination } from "@/components/common/Pagination";
export default function FlowManagementPage() { export default function FlowManagementPage() {
const router = useRouter(); const router = useRouter();
@@ -515,6 +516,10 @@ export default function FlowManagementPage() {
// 검색 필터 상태 // 검색 필터 상태
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
// 페이지네이션 상태
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(20);
// 검색 필터링된 플로우 목록 // 검색 필터링된 플로우 목록
const filteredFlows = searchText const filteredFlows = searchText
? flows.filter( ? flows.filter(
@@ -525,6 +530,19 @@ export default function FlowManagementPage() {
) )
: flows; : 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 컬럼 정의 // ResponsiveDataView 컬럼 정의
const columns: RDVColumn<FlowDefinition>[] = [ const columns: RDVColumn<FlowDefinition>[] = [
{ {
@@ -588,43 +606,48 @@ export default function FlowManagementPage() {
]; ];
return ( return (
<div className="bg-background flex min-h-screen flex-col"> <div className="bg-background h-full overflow-y-auto">
<div className="space-y-6 p-4 sm:p-6"> <div className="mx-auto w-full max-w-[1920px] space-y-3 px-4 py-3 sm:px-5 2xl:px-6">
{/* 페이지 헤더 */} {/* 페이지 헤더 — 컴팩트하게 */}
<div className="space-y-2 border-b pb-4"> <div className="flex items-end justify-between gap-3 border-b pb-2">
<h1 className="text-3xl font-bold tracking-tight"> </h1> <div className="flex items-baseline gap-2">
<p className="text-muted-foreground text-sm"> </p> <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>
{/* 검색 툴바 (반응형) */} {/* 검색 툴바 */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-4"> <div className="relative w-full sm:w-[320px]">
<div className="relative w-full sm:w-[300px]"> <Search className="text-muted-foreground absolute top-1/2 left-3 h-3.5 w-3.5 -translate-y-1/2" />
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input <Input
placeholder="플로우명, 테이블, 설명으로 검색..." placeholder="플로우명, 테이블, 설명으로 검색..."
value={searchText} value={searchText}
onChange={(e) => setSearchText(e.target.value)} onChange={(e) => setSearchText(e.target.value)}
className="h-10 pl-10 text-sm" className="h-8 pl-9 text-xs"
/> />
</div> </div>
<div className="text-muted-foreground hidden text-sm sm:block"> <Button onClick={() => setIsCreateDialogOpen(true)} size="sm" className="h-8 gap-1.5 text-xs font-medium">
<span className="text-foreground font-semibold">{filteredFlows.length}</span> <Plus className="h-3.5 w-3.5" />
</div>
</div>
<Button onClick={() => setIsCreateDialogOpen(true)} className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
</Button> </Button>
</div> </div>
{/* 플로우 목록 (ResponsiveDataView) */} {/* 플로우 목록 (ResponsiveDataView) */}
<ResponsiveDataView<FlowDefinition> <ResponsiveDataView<FlowDefinition>
data={filteredFlows} data={pagedFlows}
columns={columns} columns={columns}
keyExtractor={(f) => String(f.id)} keyExtractor={(f) => String(f.id)}
isLoading={loading} isLoading={loading}
emptyMessage="생성된 플로우가 없습니다." emptyMessage="생성된 플로우가 없습니다."
skeletonCount={6} skeletonCount={Math.min(6, itemsPerPage)}
cardTitle={(f) => f.name} cardTitle={(f) => f.name}
cardSubtitle={(f) => f.description || "설명 없음"} cardSubtitle={(f) => f.description || "설명 없음"}
cardHeaderRight={(f) => cardHeaderRight={(f) =>
@@ -639,32 +662,53 @@ export default function FlowManagementPage() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="h-9 flex-1 gap-2 text-sm" className="h-7 flex-1 gap-1.5 text-xs"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleEdit(f.id); handleEdit(f.id);
}} }}
> >
<Edit2 className="h-4 w-4" /> <Edit2 className="h-3 w-3" />
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="h-9 w-9 p-0" className="h-7 w-7 p-0"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setSelectedFlow(f); setSelectedFlow(f);
setIsDeleteDialogOpen(true); setIsDeleteDialogOpen(true);
}} }}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-3 w-3" />
</Button> </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}> <Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]"> <DialogContent className="max-w-[95vw] sm:max-w-[500px]">
@@ -195,53 +195,54 @@ export default function MailDashboardPage() {
return ( return (
<div className="min-h-screen bg-background"> <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 justify-between bg-card rounded-lg border p-4 2xl:p-5">
<div className="flex items-center gap-4"> <div className="flex items-center gap-3">
<div className="p-4 bg-primary/10 rounded-lg"> <div className="p-2.5 bg-primary/10 rounded-lg">
<Mail className="w-8 h-8 text-primary" /> <Mail className="w-6 h-6 text-primary" />
</div> </div>
<div> <div>
<h1 className="text-3xl font-bold text-foreground mb-1"> </h1> <h1 className="text-xl 2xl:text-2xl font-bold text-foreground leading-tight"> </h1>
<p className="text-muted-foreground"> </p> <p className="text-xs 2xl:text-sm text-muted-foreground"> </p>
</div> </div>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-2">
<MailNotifications /> <MailNotifications />
<Button <Button
variant="outline" variant="outline"
size="lg" size="sm"
onClick={loadStats} onClick={loadStats}
disabled={loading} 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> </Button>
</div> </div>
</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) => ( {statCards.map((stat, index) => (
<Link key={index} href={stat.href}> <Link key={index} href={stat.href}>
<Card className="hover:shadow-md transition-all hover:scale-105 cursor-pointer"> <Card className="hover:shadow-md transition-all hover:scale-[1.02] cursor-pointer h-full">
<CardContent className="p-6"> <CardContent className="p-4">
<div className="flex items-start justify-between mb-4"> <div className="flex items-start justify-between mb-3">
<div className="flex-1"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium text-muted-foreground mb-3"> <p className="text-xs 2xl:text-sm font-medium text-muted-foreground mb-1.5">
{stat.title} {stat.title}
</p> </p>
<p className="text-4xl font-bold text-foreground"> <p className="text-2xl 2xl:text-3xl font-bold text-foreground leading-none">
{stat.value} {stat.value}
</p> </p>
</div> </div>
<div className="p-4 bg-muted rounded-lg"> <div className="p-2.5 bg-muted rounded-lg shrink-0">
<stat.icon className="w-7 h-7 text-muted-foreground" /> <stat.icon className="w-5 h-5 text-muted-foreground" />
</div> </div>
</div> </div>
{/* 진행 바 */} {/* 진행 바 */}
<div className="h-2 bg-muted rounded-full overflow-hidden"> <div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div <div
className="h-full bg-primary transition-all duration-1000" className="h-full bg-primary transition-all duration-1000"
style={{ width: `${Math.min((stat.value / 10) * 100, 100)}%` }} style={{ width: `${Math.min((stat.value / 10) * 100, 100)}%` }}
@@ -253,98 +254,88 @@ export default function MailDashboardPage() {
))} ))}
</div> </div>
{/* 이번 달 통계 */} {/* 본문: 이번 달 통계 + 시스템 상태 + 빠른 액세스 (FullHD에서 3열 그리드) */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3"> <div className="grid grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 gap-3">
<Card> <Card>
<CardHeader className="border-b"> <CardHeader className="border-b py-3 px-4">
<CardTitle className="text-lg flex items-center"> <CardTitle className="text-sm 2xl:text-base flex items-center">
<div className="p-2 bg-muted rounded-lg mr-3"> <div className="p-1.5 bg-muted rounded-md mr-2">
<Calendar className="w-5 h-5 text-foreground" /> <Calendar className="w-4 h-4 text-foreground" />
</div> </div>
<span> </span> <span> </span>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-6"> <CardContent className="p-3 2xl:p-4">
<div className="space-y-4"> <div className="space-y-2">
<div className="flex items-center justify-between p-4 bg-muted rounded-lg"> <div className="flex items-center justify-between px-3 py-2.5 bg-muted rounded-md">
<span className="text-sm font-medium text-muted-foreground"> </span> <span className="text-xs 2xl:text-sm font-medium text-muted-foreground"> </span>
<span className="text-2xl font-bold text-foreground">{stats.sentThisMonth} </span> <span className="text-lg 2xl:text-xl font-bold text-foreground">{stats.sentThisMonth} </span>
</div> </div>
<div className="flex items-center justify-between p-4 bg-muted rounded-lg"> <div className="flex items-center justify-between px-3 py-2.5 bg-muted rounded-md">
<span className="text-sm font-medium text-muted-foreground"></span> <span className="text-xs 2xl:text-sm font-medium text-muted-foreground"></span>
<span className="text-2xl font-bold text-foreground">{stats.successRate}%</span> <span className="text-lg 2xl:text-xl font-bold text-foreground">{stats.successRate}%</span>
</div> </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> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader className="border-b"> <CardHeader className="border-b py-3 px-4">
<CardTitle className="text-lg flex items-center"> <CardTitle className="text-sm 2xl:text-base flex items-center">
<div className="p-2 bg-muted rounded-lg mr-3"> <div className="p-1.5 bg-muted rounded-md mr-2">
<Mail className="w-5 h-5 text-foreground" /> <Mail className="w-4 h-4 text-foreground" />
</div> </div>
<span> </span> <span> </span>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-6"> <CardContent className="p-3 2xl:p-4">
<div className="space-y-4"> <div className="space-y-2">
<div className="flex items-center justify-between p-4 bg-muted rounded-lg"> <div className="flex items-center justify-between px-3 py-2.5 bg-muted rounded-md">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2">
<div className="w-3 h-3 bg-primary rounded-full animate-pulse"></div> <div className="w-2 h-2 bg-primary rounded-full animate-pulse"></div>
<span className="text-sm font-medium text-muted-foreground"> </span> <span className="text-xs 2xl:text-sm font-medium text-muted-foreground"> </span>
</div> </div>
<span className="text-sm font-bold text-foreground"> </span> <span className="text-xs 2xl:text-sm font-bold text-foreground"> </span>
</div> </div>
<div className="flex items-center justify-between p-4 bg-muted rounded-lg"> <div className="flex items-center justify-between px-3 py-2.5 bg-muted rounded-md">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2">
<div className="w-3 h-3 bg-primary rounded-full"></div> <div className="w-2 h-2 bg-primary rounded-full"></div>
<span className="text-sm font-medium text-muted-foreground"> </span> <span className="text-xs 2xl:text-sm font-medium text-muted-foreground"> </span>
</div> </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>
<div className="flex items-center justify-between p-4 bg-muted rounded-lg"> <div className="flex items-center justify-between px-3 py-2.5 bg-muted rounded-md">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2">
<div className="w-3 h-3 bg-primary rounded-full"></div> <div className="w-2 h-2 bg-primary rounded-full"></div>
<span className="text-sm font-medium text-muted-foreground"> 릿</span> <span className="text-xs 2xl:text-sm font-medium text-muted-foreground"> 릿</span>
</div> </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>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div>
{/* 빠른 액세스 */} {/* 빠른 액세스 — FullHD에서는 우측 컬럼에 함께 배치 */}
<Card> <Card className="2xl:row-span-1">
<CardHeader className="border-b"> <CardHeader className="border-b py-3 px-4">
<CardTitle className="text-lg"> </CardTitle> <CardTitle className="text-sm 2xl:text-base"> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-6"> <CardContent className="p-3">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-2 gap-2">
{quickLinks.map((link, index) => ( {quickLinks.map((link, index) => (
<a <a
key={index} key={index}
href={link.href} 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" 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-3 bg-muted rounded-lg group-hover:scale-105 transition-transform"> <div className="p-1.5 bg-muted rounded-md group-hover:scale-105 transition-transform shrink-0">
<link.icon className="w-6 h-6 text-muted-foreground" /> <link.icon className="w-4 h-4 text-muted-foreground" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-semibold text-foreground text-base mb-1">{link.title}</p> <p className="font-semibold text-foreground text-xs 2xl:text-sm leading-tight truncate">{link.title}</p>
<p className="text-sm text-muted-foreground truncate">{link.description}</p> <p className="text-[10px] 2xl:text-xs text-muted-foreground truncate">{link.description}</p>
</div> </div>
<ArrowRight className="w-5 h-5 text-muted-foreground group-hover:text-foreground group-hover:translate-x-1 transition-all" /> <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> </a>
))} ))}
</div> </div>
@@ -352,5 +343,6 @@ export default function MailDashboardPage() {
</Card> </Card>
</div> </div>
</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="basic">Basic Auth</SelectItem>
<SelectItem value="oauth2">OAuth 2.0</SelectItem> <SelectItem value="oauth2">OAuth 2.0</SelectItem>
<SelectItem value="db-token">DB </SelectItem> <SelectItem value="db-token">DB </SelectItem>
<SelectItem value="wehago">Wehago / Amaranth ()</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -281,6 +282,78 @@ export function AuthenticationConfig({
</div> </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" && ( {authType === "none" && (
<div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground"> <div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
API입니다. API입니다.
@@ -25,6 +25,7 @@ import {
ExternalRestApiConnectionFilter, ExternalRestApiConnectionFilter,
} from "@/lib/api/externalRestApiConnection"; } from "@/lib/api/externalRestApiConnection";
import { RestApiConnectionModal } from "./RestApiConnectionModal"; import { RestApiConnectionModal } from "./RestApiConnectionModal";
import { Pagination } from "@/components/common/Pagination";
// 인증 타입 라벨 // 인증 타입 라벨
const AUTH_TYPE_LABELS: Record<string, string> = { const AUTH_TYPE_LABELS: Record<string, string> = {
@@ -34,6 +35,7 @@ const AUTH_TYPE_LABELS: Record<string, string> = {
basic: "Basic Auth", basic: "Basic Auth",
oauth2: "OAuth 2.0", oauth2: "OAuth 2.0",
"db-token": "DB 토큰", "db-token": "DB 토큰",
wehago: "Wehago/Amaranth",
}; };
// 활성 상태 옵션 // 활성 상태 옵션
@@ -60,6 +62,10 @@ export function RestApiConnectionList() {
const [testingConnections, setTestingConnections] = useState<Set<number>>(new Set()); const [testingConnections, setTestingConnections] = useState<Set<number>>(new Set());
const [testResults, setTestResults] = useState<Map<number, boolean>>(new Map()); const [testResults, setTestResults] = useState<Map<number, boolean>>(new Map());
// 페이지네이션 상태
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(20);
// 데이터 로딩 // 데이터 로딩
const loadConnections = async () => { const loadConnections = async () => {
try { try {
@@ -101,6 +107,19 @@ export function RestApiConnectionList() {
loadConnections(); loadConnections();
}, [searchTerm, authTypeFilter, activeStatusFilter]); }, [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 = () => { const handleAddConnection = () => {
setEditingConnection(undefined); setEditingConnection(undefined);
@@ -217,24 +236,24 @@ export function RestApiConnectionList() {
}; };
return ( return (
<> <div className="flex flex-col gap-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-4 sm:flex-row sm:items-center"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
{/* 검색 */} {/* 검색 */}
<div className="relative w-full sm:w-[300px]"> <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 <Input
placeholder="연결명 또는 URL로 검색..." placeholder="연결명 또는 URL로 검색..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="h-10 pl-10 text-sm" className="h-8 pl-9 text-xs"
/> />
</div> </div>
{/* 인증 타입 필터 */} {/* 인증 타입 필터 */}
<Select value={authTypeFilter} onValueChange={setAuthTypeFilter}> <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="인증 타입" /> <SelectValue placeholder="인증 타입" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -248,7 +267,7 @@ export function RestApiConnectionList() {
{/* 활성 상태 필터 */} {/* 활성 상태 필터 */}
<Select value={activeStatusFilter} onValueChange={setActiveStatusFilter}> <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="상태" /> <SelectValue placeholder="상태" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -259,11 +278,21 @@ export function RestApiConnectionList() {
))} ))}
</SelectContent> </SelectContent>
</Select> </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> </div>
{/* 추가 버튼 */} {/* 추가 버튼 */}
<Button onClick={handleAddConnection} className="h-10 gap-2 text-sm font-medium"> <Button onClick={handleAddConnection} size="sm" className="h-8 gap-1.5 text-xs font-medium">
<Plus className="h-4 w-4" /> <Plus className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
@@ -279,62 +308,62 @@ export function RestApiConnectionList() {
</div> </div>
</div> </div>
) : ( ) : (
<div className="bg-card"> <div className="rounded-lg border bg-card">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="bg-background"> <TableRow className="bg-muted/50 hover:bg-muted/50">
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"></TableHead> <TableHead className="h-9 px-3 text-xs font-semibold"></TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"></TableHead> <TableHead className="h-9 px-3 text-xs font-semibold"></TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"> URL</TableHead> <TableHead className="h-9 px-3 text-xs font-semibold"> URL</TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"> </TableHead> <TableHead className="h-9 px-3 text-xs font-semibold"> </TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"> </TableHead> <TableHead className="h-9 px-3 text-xs font-semibold"></TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"></TableHead> <TableHead className="h-9 px-3 text-xs font-semibold"></TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"> </TableHead> <TableHead className="h-9 px-3 text-xs font-semibold"> </TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"> </TableHead> <TableHead className="h-9 px-3 text-xs font-semibold"> </TableHead>
<TableHead className="h-12 px-6 py-3 text-right text-sm font-semibold"></TableHead> <TableHead className="h-9 px-3 text-right text-xs font-semibold"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{connections.map((connection) => ( {pagedConnections.map((connection) => (
<TableRow key={connection.id} className="bg-background transition-colors hover:bg-muted/50"> <TableRow key={connection.id} className="transition-colors hover:bg-muted/50">
<TableCell className="h-16 px-6 py-3 text-sm"> <TableCell className="h-11 px-3 py-1.5 text-xs">
<div className="max-w-[200px]"> <div className="max-w-[200px]">
<div className="truncate font-medium" title={connection.connection_name}> <div className="truncate font-medium" title={connection.connection_name}>
{connection.connection_name} {connection.connection_name}
</div> </div>
{connection.description && ( {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} {connection.description}
</div> </div>
)} )}
</div> </div>
</TableCell> </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} {(connection as any).company_name || connection.company_code}
</TableCell> </TableCell>
<TableCell className="h-16 px-6 py-3 font-mono text-sm"> <TableCell className="h-11 px-3 py-1.5 font-mono text-xs">
<div className="max-w-[300px] truncate" title={connection.base_url}> <div className="max-w-[260px] truncate" title={connection.base_url}>
{connection.base_url} {connection.base_url}
</div> </div>
</TableCell> </TableCell>
<TableCell className="h-16 px-6 py-3 text-sm"> <TableCell className="h-11 px-3 py-1.5 text-xs">
<Badge variant="outline">{AUTH_TYPE_LABELS[connection.auth_type] || connection.auth_type}</Badge> <Badge variant="outline" className="text-[10px]">{AUTH_TYPE_LABELS[connection.auth_type] || connection.auth_type}</Badge>
</TableCell> </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} {Object.keys(connection.default_headers || {}).length}
</TableCell> </TableCell>
<TableCell className="h-16 px-6 py-3 text-sm"> <TableCell className="h-11 px-3 py-1.5 text-xs">
<Badge variant={connection.is_active === "Y" ? "default" : "secondary"}> <Badge variant={connection.is_active === "Y" ? "default" : "secondary"} className="text-[10px]">
{connection.is_active === "Y" ? "활성" : "비활성"} {connection.is_active === "Y" ? "활성" : "비활성"}
</Badge> </Badge>
</TableCell> </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 ? ( {connection.last_test_date ? (
<div> <div className="flex items-center gap-1.5">
<div>{new Date(connection.last_test_date).toLocaleDateString()}</div> <span className="text-[11px]">{new Date(connection.last_test_date).toLocaleDateString()}</span>
<Badge <Badge
variant={connection.last_test_result === "Y" ? "default" : "destructive"} variant={connection.last_test_result === "Y" ? "default" : "destructive"}
className="mt-1" className="text-[10px]"
> >
{connection.last_test_result === "Y" ? "성공" : "실패"} {connection.last_test_result === "Y" ? "성공" : "실패"}
</Badge> </Badge>
@@ -343,41 +372,41 @@ export function RestApiConnectionList() {
<span className="text-muted-foreground">-</span> <span className="text-muted-foreground">-</span>
)} )}
</TableCell> </TableCell>
<TableCell className="h-16 px-6 py-3 text-sm"> <TableCell className="h-11 px-3 py-1.5 text-xs">
<div className="flex items-center gap-2"> <div className="flex items-center gap-1.5">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => handleTestConnection(connection)} onClick={() => handleTestConnection(connection)}
disabled={testingConnections.has(connection.id!)} disabled={testingConnections.has(connection.id!)}
className="h-9 text-sm" className="h-7 px-2 text-xs"
> >
{testingConnections.has(connection.id!) ? "테스트 중..." : "테스트"} {testingConnections.has(connection.id!) ? "테스트 중..." : "테스트"}
</Button> </Button>
{testResults.has(connection.id!) && ( {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!) ? "성공" : "실패"} {testResults.get(connection.id!) ? "성공" : "실패"}
</Badge> </Badge>
)} )}
</div> </div>
</TableCell> </TableCell>
<TableCell className="h-16 px-6 py-3 text-right"> <TableCell className="h-11 px-3 py-1.5 text-right">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-1">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => handleEditConnection(connection)} 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>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => handleDeleteConnection(connection)} 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> </Button>
</div> </div>
</TableCell> </TableCell>
@@ -388,6 +417,29 @@ export function RestApiConnectionList() {
</div> </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 && ( {isModalOpen && (
<RestApiConnectionModal <RestApiConnectionModal
@@ -424,6 +476,6 @@ export function RestApiConnectionList() {
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</> </div>
); );
} }
@@ -83,8 +83,8 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
setIsActive(connection.is_active === "Y"); setIsActive(connection.is_active === "Y");
setSaveToHistory(connection.save_to_history === "Y"); setSaveToHistory(connection.save_to_history === "Y");
// 테스트 초기값 설정 // 테스트 초기값 설정 — 저장된 endpoint_path/default_body 를 그대로 채움 (비워두면 베이스URL 직접 호출 → 405)
setTestEndpoint(""); setTestEndpoint(connection.endpoint_path || "");
setTestMethod(connection.default_method || "GET"); setTestMethod(connection.default_method || "GET");
setTestBody(connection.default_body || ""); setTestBody(connection.default_body || "");
} else { } else {
@@ -129,14 +129,15 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
setTesting(true); setTesting(true);
setTestResult(null); setTestResult(null);
// 사용자가 테스트하려는 실제 외부 API URL 설정 // 테스트 엔드포인트 비어있으면 저장된 endpoint_path 사용 (비워두면 베이스URL 직접 호출 → 405 가능)
const fullUrl = testEndpoint ? `${baseUrl}${testEndpoint}` : baseUrl; const effectiveEndpoint = testEndpoint || endpointPath || "";
const fullUrl = effectiveEndpoint ? `${baseUrl}${effectiveEndpoint}` : baseUrl;
setTestRequestUrl(fullUrl); setTestRequestUrl(fullUrl);
try { try {
const testRequest: RestApiTestRequest = { const testRequest: RestApiTestRequest = {
base_url: baseUrl, base_url: baseUrl,
endpoint: testEndpoint || undefined, endpoint: effectiveEndpoint || undefined,
method: testMethod as any, method: testMethod as any,
headers: defaultHeaders, headers: defaultHeaders,
body: testBody ? JSON.parse(testBody) : undefined, 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.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>
)} )}
</div> </div>
@@ -114,7 +114,7 @@ export function ResponsiveDataView<T>({
<TableHead <TableHead
key={col.key} key={col.key}
style={col.width ? { width: col.width } : undefined} 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} {col.label}
</TableHead> </TableHead>
@@ -122,7 +122,7 @@ export function ResponsiveDataView<T>({
{renderActions && ( {renderActions && (
<TableHead <TableHead
style={{ width: actionsWidth || "120px" }} style={{ width: actionsWidth || "120px" }}
className="h-12 text-sm font-semibold" className="h-9 px-3 text-xs font-semibold"
> >
{actionsLabel || "작업"} {actionsLabel || "작업"}
</TableHead> </TableHead>
@@ -133,15 +133,15 @@ export function ResponsiveDataView<T>({
{Array.from({ length: skeletonCount }).map((_, rowIdx) => ( {Array.from({ length: skeletonCount }).map((_, rowIdx) => (
<TableRow key={rowIdx} className="border-b"> <TableRow key={rowIdx} className="border-b">
{columns.map((col) => ( {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" /> <div className="h-4 animate-pulse rounded bg-muted" />
</TableCell> </TableCell>
))} ))}
{renderActions && ( {renderActions && (
<TableCell className="h-16"> <TableCell className="h-11">
<div className="flex gap-2"> <div className="flex gap-1.5">
<div className="h-8 w-8 animate-pulse rounded bg-muted" /> <div className="h-7 w-7 animate-pulse rounded bg-muted" />
<div className="h-8 w-8 animate-pulse rounded bg-muted" /> <div className="h-7 w-7 animate-pulse rounded bg-muted" />
</div> </div>
</TableCell> </TableCell>
)} )}
@@ -217,7 +217,7 @@ export function ResponsiveDataView<T>({
<TableHead <TableHead
key={col.key} key={col.key}
style={col.width ? { width: col.width } : undefined} 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} {col.label}
</TableHead> </TableHead>
@@ -225,7 +225,7 @@ export function ResponsiveDataView<T>({
{renderActions && ( {renderActions && (
<TableHead <TableHead
style={{ width: actionsWidth || "120px" }} style={{ width: actionsWidth || "120px" }}
className="h-12 text-sm font-semibold" className="h-9 px-3 text-xs font-semibold"
> >
{actionsLabel || "작업"} {actionsLabel || "작업"}
</TableHead> </TableHead>
@@ -245,7 +245,7 @@ export function ResponsiveDataView<T>({
{columns.map((col) => ( {columns.map((col) => (
<TableCell <TableCell
key={col.key} 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
? col.render(getNestedValue(item, col.key), item, index) ? col.render(getNestedValue(item, col.key), item, index)
@@ -253,8 +253,8 @@ export function ResponsiveDataView<T>({
</TableCell> </TableCell>
))} ))}
{renderActions && ( {renderActions && (
<TableCell className="h-16 text-sm"> <TableCell className="h-11 px-3 py-1.5 text-xs">
<div className="flex gap-2">{renderActions(item)}</div> <div className="flex gap-1.5">{renderActions(item)}</div>
</TableCell> </TableCell>
)} )}
</TableRow> </TableRow>
@@ -357,12 +357,25 @@ export function AdminPageRenderer({ url }: AdminPageRendererProps) {
const companyCode = user?.companyCode || user?.company_code; const companyCode = user?.companyCode || user?.company_code;
const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, ""); 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로 붙여 경로 변환 // 회사별 커스텀 페이지: companyCode를 prefix로 붙여 경로 변환
const resolvedUrl = (companyCode && isCompanyPage(cleanUrl)) const resolvedUrl = (companyCode && isCompanyPage(cleanUrl))
? `/${companyCode}${cleanUrl}` ? `/${companyCode}${cleanUrl}`
: cleanUrl; : cleanUrl;
console.log("[AdminPageRenderer] 렌더링:", { url, cleanUrl, resolvedUrl, companyCode }); console.log("[AdminPageRenderer] 렌더링:", { url, cleanUrl, resolvedUrl, companyCode, queryParams });
// 화면 할당: /screens/[id] // 화면 할당: /screens/[id]
const screensIdMatch = cleanUrl.match(/^\/screens\/(\d+)$/); const screensIdMatch = cleanUrl.match(/^\/screens\/(\d+)$/);
@@ -392,7 +405,12 @@ export function AdminPageRenderer({ url }: AdminPageRendererProps) {
if (PageComponent) { if (PageComponent) {
console.log("[AdminPageRenderer] → 레지스트리 매칭:", resolvedUrl || cleanUrl); 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 시도 // 레지스트리에 없으면 동적 import 시도
@@ -400,7 +418,7 @@ export function AdminPageRenderer({ url }: AdminPageRendererProps) {
for (const { pattern, extractParams } of DYNAMIC_ADMIN_PATTERNS) { for (const { pattern, extractParams } of DYNAMIC_ADMIN_PATTERNS) {
const match = cleanUrl.match(pattern); const match = cleanUrl.match(pattern);
if (match) { if (match) {
const params = extractParams(match); const params = { ...extractParams(match), ...queryParams };
console.log("[AdminPageRenderer] → 동적 라우트 매칭:", cleanUrl, params); console.log("[AdminPageRenderer] → 동적 라우트 매칭:", cleanUrl, params);
return <DynamicAdminLoader url={cleanUrl} params={params} />; return <DynamicAdminLoader url={cleanUrl} params={params} />;
} }
@@ -408,5 +426,10 @@ export function AdminPageRenderer({ url }: AdminPageRendererProps) {
// 레지스트리/패턴에 없으면 DynamicAdminLoader가 자동 import 시도 // 레지스트리/패턴에 없으면 DynamicAdminLoader가 자동 import 시도
console.log("[AdminPageRenderer] → 자동 import 시도:", resolvedUrl); console.log("[AdminPageRenderer] → 자동 import 시도:", resolvedUrl);
return <DynamicAdminLoader url={resolvedUrl} />; return (
<DynamicAdminLoader
url={resolvedUrl}
params={Object.keys(queryParams).length > 0 ? queryParams : undefined}
/>
);
} }
+6
View File
@@ -128,6 +128,7 @@ class BatchManagementAPIClass {
requestBody?: string, requestBody?: string,
authServiceName?: string, // DB에서 토큰 가져올 서비스명 authServiceName?: string, // DB에서 토큰 가져올 서비스명
dataArrayPath?: string, // 데이터 배열 경로 (예: response, data.items) dataArrayPath?: string, // 데이터 배열 경로 (예: response, data.items)
restApiConnectionId?: number, // 등록된 REST API 연결 사용 시 — Wehago/Amaranth 인증 자동 적용
): Promise<{ ): Promise<{
fields: string[]; fields: string[];
samples: any[]; samples: any[];
@@ -142,6 +143,11 @@ class BatchManagementAPIClass {
requestBody, requestBody,
}; };
// 등록된 연결 ID — 백엔드가 이걸 받으면 ExternalRestApiConnectionService 경로로 호출 (Wehago 인증 처리)
if (restApiConnectionId) {
requestData.restApiConnectionId = restApiConnectionId;
}
// 파라미터 정보가 있으면 추가 // 파라미터 정보가 있으면 추가
if (paramInfo) { if (paramInfo) {
requestData.paramType = paramInfo.paramType; requestData.paramType = paramInfo.paramType;
+68
View File
@@ -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 || [],
};
},
};
+10 -1
View File
@@ -2,7 +2,7 @@
import { apiClient } from "./client"; 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 { export interface ExternalRestApiConnection {
id?: number; id?: number;
@@ -39,6 +39,14 @@ export interface ExternalRestApiConnection {
dbWhereValue?: string; dbWhereValue?: string;
dbHeaderName?: string; dbHeaderName?: string;
dbHeaderTemplate?: 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; timeout?: number;
retry_count?: number; retry_count?: number;
@@ -243,6 +251,7 @@ export class ExternalRestApiConnectionAPI {
{ value: "basic", label: "Basic Auth" }, { value: "basic", label: "Basic Auth" },
{ value: "oauth2", label: "OAuth 2.0" }, { value: "oauth2", label: "OAuth 2.0" },
{ value: "db-token", label: "DB 토큰" }, { value: "db-token", label: "DB 토큰" },
{ value: "wehago", label: "Wehago/Amaranth (아마란스)" },
]; ];
} }
} }
+1 -8
View File
@@ -1,7 +1,6 @@
"use client"; "use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState } from "react"; import { useState } from "react";
interface QueryProviderProps { interface QueryProviderProps {
@@ -31,12 +30,6 @@ export function QueryProvider({ children }: QueryProviderProps) {
}), }),
); );
return ( return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
<QueryClientProvider client={queryClient}>
{children}
{/* 개발 환경에서만 DevTools 표시 */}
{process.env.NODE_ENV === "development" && <ReactQueryDevtools initialIsOpen={false} />}
</QueryClientProvider>
);
} }