summaryrefslogtreecommitdiff
path: root/lib/vendor-document-list/sync-service.ts
diff options
context:
space:
mode:
Diffstat (limited to 'lib/vendor-document-list/sync-service.ts')
-rw-r--r--lib/vendor-document-list/sync-service.ts491
1 files changed, 491 insertions, 0 deletions
diff --git a/lib/vendor-document-list/sync-service.ts b/lib/vendor-document-list/sync-service.ts
new file mode 100644
index 00000000..6978c1cc
--- /dev/null
+++ b/lib/vendor-document-list/sync-service.ts
@@ -0,0 +1,491 @@
+// lib/sync-service.ts
+import db from "@/db/db"
+import {
+ syncConfigs,
+ changeLogs,
+ syncBatches,
+ syncStatusView,
+ type SyncConfig,
+ type ChangeLog,
+ type SyncBatch
+} from "@/db/schema/vendorDocu"
+import { documents, revisions, documentAttachments } from "@/db/schema/vendorDocu"
+import { eq, and, lt, desc, sql, inArray } from "drizzle-orm"
+import { toast } from "sonner"
+
+export interface SyncableEntity {
+ entityType: 'document' | 'revision' | 'attachment'
+ entityId: number
+ action: 'CREATE' | 'UPDATE' | 'DELETE'
+ data: any
+ metadata?: Record<string, any>
+}
+
+export interface SyncResult {
+ batchId: number
+ success: boolean
+ successCount: number
+ failureCount: number
+ errors?: string[]
+}
+
+class SyncService {
+
+ /**
+ * 변경사항을 change_logs에 기록
+ */
+ async logChange(
+ contractId: number,
+ entityType: 'document' | 'revision' | 'attachment',
+ entityId: number,
+ action: 'CREATE' | 'UPDATE' | 'DELETE',
+ newValues?: any,
+ oldValues?: any,
+ userId?: number,
+ userName?: string
+ ) {
+ try {
+ const changedFields = this.detectChangedFields(oldValues, newValues)
+
+ await db.insert(changeLogs).values({
+ contractId,
+ entityType,
+ entityId,
+ action,
+ changedFields,
+ oldValues,
+ newValues,
+ userId,
+ userName,
+ targetSystems: ['SHI'], // 기본적으로 SHI로 동기화
+ })
+
+ console.log(`Change logged: ${entityType}/${entityId} - ${action}`)
+ } catch (error) {
+ console.error('Failed to log change:', error)
+ throw error
+ }
+ }
+
+ /**
+ * 변경된 필드 감지
+ */
+ private detectChangedFields(oldValues: any, newValues: any): Record<string, any> | null {
+ if (!oldValues || !newValues) return null
+
+ const changes: Record<string, any> = {}
+
+ for (const [key, newValue] of Object.entries(newValues)) {
+ if (JSON.stringify(oldValues[key]) !== JSON.stringify(newValue)) {
+ changes[key] = {
+ from: oldValues[key],
+ to: newValue
+ }
+ }
+ }
+
+ return Object.keys(changes).length > 0 ? changes : null
+ }
+
+ /**
+ * 계약별 동기화 설정 조회
+ */
+ async getSyncConfig(contractId: number, targetSystem: string = 'SHI'): Promise<SyncConfig | null> {
+ const [config] = await db
+ .select()
+ .from(syncConfigs)
+ .where(and(
+ eq(syncConfigs.contractId, contractId),
+ eq(syncConfigs.targetSystem, targetSystem)
+ ))
+ .limit(1)
+
+ return config || null
+ }
+
+ /**
+ * 동기화 설정 생성/업데이트
+ */
+ async upsertSyncConfig(config: Partial<SyncConfig> & {
+ contractId: number
+ targetSystem: string
+ endpointUrl: string
+ }) {
+ const existing = await this.getSyncConfig(config.contractId, config.targetSystem)
+
+ if (existing) {
+ await db
+ .update(syncConfigs)
+ .set({ ...config, updatedAt: new Date() })
+ .where(eq(syncConfigs.id, existing.id))
+ } else {
+ await db.insert(syncConfigs).values(config)
+ }
+ }
+
+ /**
+ * 동기화할 변경사항 조회 (증분)
+ */
+ async getPendingChanges(
+ contractId: number,
+ targetSystem: string = 'SHI',
+ limit: number = 100
+ ): Promise<ChangeLog[]> {
+ return await db
+ .select()
+ .from(changeLogs)
+ .where(and(
+ eq(changeLogs.contractId, contractId),
+ eq(changeLogs.isSynced, false),
+ lt(changeLogs.syncAttempts, 3), // 최대 3회 재시도
+ sql`(${changeLogs.targetSystems} IS NULL OR ${changeLogs.targetSystems} @> ${JSON.stringify([targetSystem])})`
+ ))
+ .orderBy(changeLogs.createdAt)
+ .limit(limit)
+ }
+
+ /**
+ * 동기화 배치 생성
+ */
+ async createSyncBatch(
+ contractId: number,
+ targetSystem: string,
+ changeLogIds: number[]
+ ): Promise<number> {
+ const [batch] = await db
+ .insert(syncBatches)
+ .values({
+ contractId,
+ targetSystem,
+ batchSize: changeLogIds.length,
+ changeLogIds,
+ status: 'PENDING'
+ })
+ .returning({ id: syncBatches.id })
+
+ return batch.id
+ }
+
+ /**
+ * 메인 동기화 실행 함수
+ */
+ async syncToExternalSystem(
+ contractId: number,
+ targetSystem: string = 'SHI',
+ manualTrigger: boolean = false
+ ): Promise<SyncResult> {
+ try {
+ // 1. 동기화 설정 확인
+ const config = await this.getSyncConfig(contractId, targetSystem)
+ if (!config || !config.syncEnabled) {
+ throw new Error(`Sync not enabled for contract ${contractId} to ${targetSystem}`)
+ }
+
+ // 2. 대기 중인 변경사항 조회
+ const pendingChanges = await this.getPendingChanges(
+ contractId,
+ targetSystem,
+ config.maxBatchSize || 100
+ )
+
+ if (pendingChanges.length === 0) {
+ return {
+ batchId: 0,
+ success: true,
+ successCount: 0,
+ failureCount: 0
+ }
+ }
+
+ // 3. 배치 생성
+ const batchId = await this.createSyncBatch(
+ contractId,
+ targetSystem,
+ pendingChanges.map(c => c.id)
+ )
+
+ // 4. 배치 상태를 PROCESSING으로 업데이트
+ await db
+ .update(syncBatches)
+ .set({
+ status: 'PROCESSING',
+ startedAt: new Date(),
+ updatedAt: new Date()
+ })
+ .where(eq(syncBatches.id, batchId))
+
+ // 5. 실제 데이터 동기화 수행
+ const syncResult = await this.performSync(config, pendingChanges)
+
+ // 6. 배치 상태 업데이트
+ await db
+ .update(syncBatches)
+ .set({
+ status: syncResult.success ? 'SUCCESS' : (syncResult.successCount > 0 ? 'PARTIAL' : 'FAILED'),
+ completedAt: new Date(),
+ successCount: syncResult.successCount,
+ failureCount: syncResult.failureCount,
+ errorMessage: syncResult.errors?.join('; '),
+ updatedAt: new Date()
+ })
+ .where(eq(syncBatches.id, batchId))
+
+ // 7. 성공한 변경사항들을 동기화 완료로 표시
+ if (syncResult.successCount > 0) {
+ const successfulChangeIds = pendingChanges
+ .slice(0, syncResult.successCount)
+ .map(c => c.id)
+
+ await db
+ .update(changeLogs)
+ .set({
+ isSynced: true,
+ syncedAt: new Date()
+ })
+ .where(inArray(changeLogs.id, successfulChangeIds))
+ }
+
+ // 8. 실패한 변경사항들의 재시도 횟수 증가
+ if (syncResult.failureCount > 0) {
+ const failedChangeIds = pendingChanges
+ .slice(syncResult.successCount)
+ .map(c => c.id)
+
+ await db
+ .update(changeLogs)
+ .set({
+ syncAttempts: sql`${changeLogs.syncAttempts} + 1`,
+ lastSyncError: syncResult.errors?.[0] || 'Unknown error'
+ })
+ .where(inArray(changeLogs.id, failedChangeIds))
+ }
+
+ // 9. 동기화 설정의 마지막 동기화 시간 업데이트
+ await db
+ .update(syncConfigs)
+ .set({
+ lastSyncAttempt: new Date(),
+ ...(syncResult.success && { lastSuccessfulSync: new Date() }),
+ updatedAt: new Date()
+ })
+ .where(eq(syncConfigs.id, config.id))
+
+ return {
+ batchId,
+ success: syncResult.success,
+ successCount: syncResult.successCount,
+ failureCount: syncResult.failureCount,
+ errors: syncResult.errors
+ }
+
+ } catch (error) {
+ console.error('Sync failed:', error)
+ throw error
+ }
+ }
+
+ /**
+ * 실제 외부 시스템으로 데이터 전송
+ */
+ private async performSync(
+ config: SyncConfig,
+ changes: ChangeLog[]
+ ): Promise<{ success: boolean; successCount: number; failureCount: number; errors?: string[] }> {
+ const errors: string[] = []
+ let successCount = 0
+ let failureCount = 0
+
+ try {
+ // 변경사항을 외부 시스템 형태로 변환
+ const syncData = await this.transformChangesForExternalSystem(changes)
+
+ // 외부 API 호출
+ const response = await fetch(config.endpointUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${config.authToken}`,
+ 'X-API-Version': config.apiVersion || 'v1'
+ },
+ body: JSON.stringify({
+ contractId: changes[0]?.contractId,
+ changes: syncData,
+ batchSize: changes.length,
+ timestamp: new Date().toISOString()
+ })
+ })
+
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`HTTP ${response.status}: ${errorText}`)
+ }
+
+ const result = await response.json()
+
+ // 응답에 따라 성공/실패 카운트 처리
+ if (result.success) {
+ successCount = changes.length
+ } else if (result.partialSuccess) {
+ successCount = result.successCount || 0
+ failureCount = changes.length - successCount
+ if (result.errors) {
+ errors.push(...result.errors)
+ }
+ } else {
+ failureCount = changes.length
+ if (result.error) {
+ errors.push(result.error)
+ }
+ }
+
+ } catch (error) {
+ console.error('External sync failed:', error)
+ failureCount = changes.length
+ errors.push(error instanceof Error ? error.message : 'Unknown error')
+ }
+
+ return {
+ success: failureCount === 0,
+ successCount,
+ failureCount,
+ errors: errors.length > 0 ? errors : undefined
+ }
+ }
+
+ /**
+ * 변경사항을 외부 시스템 형태로 변환
+ */
+ private async transformChangesForExternalSystem(changes: ChangeLog[]): Promise<SyncableEntity[]> {
+ const syncData: SyncableEntity[] = []
+
+ for (const change of changes) {
+ try {
+ let entityData = null
+
+ // 엔티티 타입별로 현재 데이터 조회
+ switch (change.entityType) {
+ case 'document':
+ if (change.action !== 'DELETE') {
+ const [document] = await db
+ .select()
+ .from(documents)
+ .where(eq(documents.id, change.entityId))
+ .limit(1)
+ entityData = document
+ }
+ break
+
+ case 'revision':
+ if (change.action !== 'DELETE') {
+ const [revision] = await db
+ .select()
+ .from(revisions)
+ .where(eq(revisions.id, change.entityId))
+ .limit(1)
+ entityData = revision
+ }
+ break
+
+ case 'attachment':
+ if (change.action !== 'DELETE') {
+ const [attachment] = await db
+ .select()
+ .from(documentAttachments)
+ .where(eq(documentAttachments.id, change.entityId))
+ .limit(1)
+ entityData = attachment
+ }
+ break
+ }
+
+ syncData.push({
+ entityType: change.entityType as any,
+ entityId: change.entityId,
+ action: change.action as any,
+ data: entityData || change.oldValues, // DELETE의 경우 oldValues 사용
+ metadata: {
+ changeId: change.id,
+ changedAt: change.createdAt,
+ changedBy: change.userName,
+ changedFields: change.changedFields
+ }
+ })
+
+ } catch (error) {
+ console.error(`Failed to transform change ${change.id}:`, error)
+ }
+ }
+
+ return syncData
+ }
+
+ /**
+ * 동기화 상태 조회
+ */
+ async getSyncStatus(contractId: number, targetSystem: string = 'SHI') {
+ const [status] = await db
+ .select()
+ .from(syncStatusView)
+ .where(and(
+ eq(syncStatusView.contractId, contractId),
+ eq(syncStatusView.targetSystem, targetSystem)
+ ))
+ .limit(1)
+
+ return status
+ }
+
+ /**
+ * 최근 동기화 배치 목록 조회
+ */
+ async getRecentSyncBatches(contractId: number, targetSystem: string = 'SHI', limit: number = 10) {
+ return await db
+ .select()
+ .from(syncBatches)
+ .where(and(
+ eq(syncBatches.contractId, contractId),
+ eq(syncBatches.targetSystem, targetSystem)
+ ))
+ .orderBy(desc(syncBatches.createdAt))
+ .limit(limit)
+ }
+}
+
+export const syncService = new SyncService()
+
+// 편의 함수들
+export async function logDocumentChange(
+ contractId: number,
+ documentId: number,
+ action: 'CREATE' | 'UPDATE' | 'DELETE',
+ newValues?: any,
+ oldValues?: any,
+ userId?: number,
+ userName?: string
+) {
+ return syncService.logChange(contractId, 'document', documentId, action, newValues, oldValues, userId, userName)
+}
+
+export async function logRevisionChange(
+ contractId: number,
+ revisionId: number,
+ action: 'CREATE' | 'UPDATE' | 'DELETE',
+ newValues?: any,
+ oldValues?: any,
+ userId?: number,
+ userName?: string
+) {
+ return syncService.logChange(contractId, 'revision', revisionId, action, newValues, oldValues, userId, userName)
+}
+
+export async function logAttachmentChange(
+ contractId: number,
+ attachmentId: number,
+ action: 'CREATE' | 'UPDATE' | 'DELETE',
+ newValues?: any,
+ oldValues?: any,
+ userId?: number,
+ userName?: string
+) {
+ return syncService.logChange(contractId, 'attachment', attachmentId, action, newValues, oldValues, userId, userName)
+} \ No newline at end of file