summaryrefslogtreecommitdiff
path: root/lib/vendor-document-list/sync-service.ts
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-05-28 17:23:13 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-05-28 17:23:13 +0000
commit4bad21ef79fdda5f016e2012ba673d6ee6abb5fc (patch)
tree4a02504cc1e983d7bacdc01442df44f35865b37d /lib/vendor-document-list/sync-service.ts
parent36dd60ca6fce7712b35e6d7c1b9602710f442ada (diff)
(대표님) lib 파트 개발 0528
Diffstat (limited to 'lib/vendor-document-list/sync-service.ts')
-rw-r--r--lib/vendor-document-list/sync-service.ts847
1 files changed, 641 insertions, 206 deletions
diff --git a/lib/vendor-document-list/sync-service.ts b/lib/vendor-document-list/sync-service.ts
index 6978c1cc..1f2872c4 100644
--- a/lib/vendor-document-list/sync-service.ts
+++ b/lib/vendor-document-list/sync-service.ts
@@ -1,17 +1,13 @@
-// lib/sync-service.ts
+// lib/sync-service.ts (시스템별 분리 버전)
import db from "@/db/db"
-import {
- syncConfigs,
- changeLogs,
- syncBatches,
- syncStatusView,
- type SyncConfig,
+import {
+ changeLogs,
+ syncBatches,
type ChangeLog,
- type SyncBatch
+ 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'
@@ -27,10 +23,23 @@ export interface SyncResult {
successCount: number
failureCount: number
errors?: string[]
+ endpointResults?: Record<string, any>
}
class SyncService {
-
+ private readonly CHUNK_SIZE = 50
+
+
+
+ /**
+ * 동기화 활성화 여부 확인
+ */
+ private isSyncEnabled(targetSystem: string): boolean {
+ const upperSystem = targetSystem.toUpperCase()
+ const enabled = process.env[`SYNC_${upperSystem}_ENABLED`]
+ return enabled === 'true' || enabled === '1'
+ }
+
/**
* 변경사항을 change_logs에 기록
*/
@@ -42,11 +51,12 @@ class SyncService {
newValues?: any,
oldValues?: any,
userId?: number,
- userName?: string
+ userName?: string,
+ targetSystems: string[] = ["DOLCE", "SWP"]
) {
try {
const changedFields = this.detectChangedFields(oldValues, newValues)
-
+
await db.insert(changeLogs).values({
contractId,
entityType,
@@ -57,9 +67,9 @@ class SyncService {
newValues,
userId,
userName,
- targetSystems: ['SHI'], // 기본적으로 SHI로 동기화
+ targetSystems,
})
-
+
console.log(`Change logged: ${entityType}/${entityId} - ${action}`)
} catch (error) {
console.error('Failed to log change:', error)
@@ -72,9 +82,9 @@ class SyncService {
*/
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] = {
@@ -83,65 +93,45 @@ class SyncService {
}
}
}
-
- 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)
- }
+ return Object.keys(changes).length > 0 ? changes : null
}
/**
* 동기화할 변경사항 조회 (증분)
*/
async getPendingChanges(
- contractId: number,
- targetSystem: string = 'SHI',
- limit: number = 100
+ contractId: number,
+ targetSystem: string = 'DOLCE',
+ limit?: number
): Promise<ChangeLog[]> {
- return await db
+ const query = db
.select()
.from(changeLogs)
.where(and(
eq(changeLogs.contractId, contractId),
eq(changeLogs.isSynced, false),
- lt(changeLogs.syncAttempts, 3), // 최대 3회 재시도
+ lt(changeLogs.syncAttempts, 3),
sql`(${changeLogs.targetSystems} IS NULL OR ${changeLogs.targetSystems} @> ${JSON.stringify([targetSystem])})`
))
.orderBy(changeLogs.createdAt)
- .limit(limit)
+
+ if (limit) {
+ query.limit(limit)
+ }
+
+ return await query
+ }
+
+ /**
+ * 배열을 청크 단위로 분할
+ */
+ private chunkArray<T>(array: T[], chunkSize: number): T[][] {
+ const chunks: T[][] = []
+ for (let i = 0; i < array.length; i += chunkSize) {
+ chunks.push(array.slice(i, i + chunkSize))
+ }
+ return chunks
}
/**
@@ -162,31 +152,26 @@ class SyncService {
status: 'PENDING'
})
.returning({ id: syncBatches.id })
-
+
return batch.id
}
/**
- * 메인 동기화 실행 함수
+ * 메인 동기화 실행 함수 (청크 처리 포함)
*/
async syncToExternalSystem(
contractId: number,
- targetSystem: string = 'SHI',
+ targetSystem: string = 'DOLCE',
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}`)
+ // 1. 동기화 활성화 확인
+ if (!this.isSyncEnabled(targetSystem)) {
+ throw new Error(`Sync not enabled for ${targetSystem}`)
}
- // 2. 대기 중인 변경사항 조회
- const pendingChanges = await this.getPendingChanges(
- contractId,
- targetSystem,
- config.maxBatchSize || 100
- )
+ // 2. 대기 중인 변경사항 조회 (전체)
+ const pendingChanges = await this.getPendingChanges(contractId, targetSystem)
if (pendingChanges.length === 0) {
return {
@@ -207,75 +192,99 @@ class SyncService {
// 4. 배치 상태를 PROCESSING으로 업데이트
await db
.update(syncBatches)
- .set({
- status: 'PROCESSING',
+ .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))
+ // 5. 청크 단위로 동기화 수행 - 시스템별 분기
+ const chunks = this.chunkArray(pendingChanges, this.CHUNK_SIZE)
+ let totalSuccessCount = 0
+ let totalFailureCount = 0
+ const allErrors: string[] = []
+ const endpointResults: Record<string, any> = {}
+
+ for (let i = 0; i < chunks.length; i++) {
+ const chunk = chunks[i]
+ console.log(`Processing chunk ${i + 1}/${chunks.length} (${chunk.length} items) for ${targetSystem}`)
+
+ try {
+ let chunkResult;
+
+ // 시스템별로 다른 동기화 메서드 호출
+ switch (targetSystem.toUpperCase()) {
+ case 'DOLCE':
+ chunkResult = await this.performSyncDOLCE(chunk, contractId)
+ break
+ case 'SWP':
+ chunkResult = await this.performSyncSWP(chunk, contractId)
+ break
+ default:
+ throw new Error(`Unsupported target system: ${targetSystem}`)
+ }
+
+ totalSuccessCount += chunkResult.successCount
+ totalFailureCount += chunkResult.failureCount
+
+ if (chunkResult.errors) {
+ allErrors.push(...chunkResult.errors)
+ }
- // 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))
- }
+ // 엔드포인트별 결과 병합
+ Object.assign(endpointResults, chunkResult.endpointResults || {})
- // 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))
+ // 성공한 변경사항들을 동기화 완료로 표시
+ if (chunkResult.successCount > 0) {
+ const successfulChangeIds = chunk
+ .slice(0, chunkResult.successCount)
+ .map(c => c.id)
+
+ await this.markChangesAsSynced(successfulChangeIds)
+ }
+
+ // 실패한 변경사항들의 재시도 횟수 증가
+ if (chunkResult.failureCount > 0) {
+ const failedChangeIds = chunk
+ .slice(chunkResult.successCount)
+ .map(c => c.id)
+
+ await this.incrementSyncAttempts(failedChangeIds, chunkResult.errors?.[0])
+ }
+
+ } catch (error) {
+ console.error(`Chunk ${i + 1} failed for ${targetSystem}:`, error)
+ totalFailureCount += chunk.length
+ allErrors.push(`Chunk ${i + 1}: ${error instanceof Error ? error.message : 'Unknown error'}`)
+
+ // 전체 청크 실패 시 재시도 횟수 증가
+ await this.incrementSyncAttempts(chunk.map(c => c.id), error instanceof Error ? error.message : 'Unknown error')
+ }
}
- // 9. 동기화 설정의 마지막 동기화 시간 업데이트
+ const overallSuccess = totalFailureCount === 0
+
+ // 6. 배치 상태 업데이트
await db
- .update(syncConfigs)
+ .update(syncBatches)
.set({
- lastSyncAttempt: new Date(),
- ...(syncResult.success && { lastSuccessfulSync: new Date() }),
+ status: overallSuccess ? 'SUCCESS' : (totalSuccessCount > 0 ? 'PARTIAL' : 'FAILED'),
+ completedAt: new Date(),
+ successCount: totalSuccessCount,
+ failureCount: totalFailureCount,
+ errorMessage: allErrors.length > 0 ? allErrors.join('; ') : null,
updatedAt: new Date()
})
- .where(eq(syncConfigs.id, config.id))
+ .where(eq(syncBatches.id, batchId))
return {
batchId,
- success: syncResult.success,
- successCount: syncResult.successCount,
- failureCount: syncResult.failureCount,
- errors: syncResult.errors
+ success: overallSuccess,
+ successCount: totalSuccessCount,
+ failureCount: totalFailureCount,
+ errors: allErrors.length > 0 ? allErrors : undefined,
+ endpointResults
}
} catch (error) {
@@ -285,77 +294,275 @@ class SyncService {
}
/**
- * 실제 외부 시스템으로 데이터 전송
+ * DOLCE 시스템 전용 동기화 수행
*/
- private async performSync(
- config: SyncConfig,
- changes: ChangeLog[]
- ): Promise<{ success: boolean; successCount: number; failureCount: number; errors?: string[] }> {
+ private async performSyncDOLCE(
+ changes: ChangeLog[],
+ contractId: number
+ ): Promise<{ success: boolean; successCount: number; failureCount: number; errors?: string[]; endpointResults?: Record<string, any> }> {
const errors: string[] = []
- let successCount = 0
- let failureCount = 0
+ const endpointResults: Record<string, any> = {}
+ let overallSuccess = true
+
+ // 변경사항을 DOLCE 시스템 형태로 변환
+ const syncData = await this.transformChangesForDOLCE(changes)
+
+ // DOLCE 엔드포인트 호출들을 직접 정의
+ const endpointPromises = []
+
+ // 1. DOLCE 메인 엔드포인트
+ const mainUrl = process.env.SYNC_DOLCE_URL
+ if (mainUrl) {
+ endpointPromises.push(
+ (async () => {
+ try {
+ console.log(`Sending to DOLCE main: ${mainUrl}`)
+
+ const transformedData = {
+ contractId,
+ systemType: 'DOLCE',
+ changes: syncData,
+ batchSize: changes.length,
+ timestamp: new Date().toISOString(),
+ source: 'EVCP',
+ version: '1.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,
+ // 헤더 구성 (토큰이 있을 때만 Authorization 포함)
+ const headers: Record<string, string> = {
+ 'Content-Type': 'application/json',
+ 'X-API-Version': process.env.SYNC_DOLCE_VERSION || 'v1',
+ 'X-System': 'DOLCE'
+ }
+
+ if (process.env.SYNC_DOLCE_TOKEN) {
+ headers['Authorization'] = `Bearer ${process.env.SYNC_DOLCE_TOKEN}`
+ }
+
+ const response = await fetch(mainUrl, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify(transformedData)
+ })
+
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`DOLCE main: HTTP ${response.status} - ${errorText}`)
+ }
+
+ const result = await response.json()
+ endpointResults['dolce_main'] = result
+
+ console.log(`✅ DOLCE main sync successful`)
+ return { success: true, endpoint: 'dolce_main', result }
+
+ } catch (error) {
+ const errorMessage = `DOLCE main: ${error instanceof Error ? error.message : 'Unknown error'}`
+ errors.push(errorMessage)
+ overallSuccess = false
+
+ console.error(`❌ DOLCE main sync failed:`, error)
+ return { success: false, endpoint: 'dolce_main', error: errorMessage }
+ }
+ })()
+ )
+ }
+
+ // 2. DOLCE 문서 전용 엔드포인트 (선택사항)
+ const docUrl = process.env.SYNC_DOLCE_DOCUMENT_URL
+ if (docUrl) {
+ endpointPromises.push(
+ (async () => {
+ try {
+ console.log(`Sending to DOLCE documents: ${docUrl}`)
+
+ const documentData = {
+ documents: syncData.filter(item => item.entityType === 'document'),
+ source: 'EVCP_DOLCE',
+ timestamp: new Date().toISOString()
+ }
+
+ // 헤더 구성 (토큰이 있을 때만 Authorization 포함)
+ const headers: Record<string, string> = {
+ 'Content-Type': 'application/json'
+ }
+
+ if (process.env.SYNC_DOLCE_TOKEN) {
+ headers['Authorization'] = `Bearer ${process.env.SYNC_DOLCE_TOKEN}`
+ }
+
+ const response = await fetch(docUrl, {
+ method: 'PUT',
+ headers,
+ body: JSON.stringify(documentData)
+ })
+
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`DOLCE documents: HTTP ${response.status} - ${errorText}`)
+ }
+
+ const result = await response.json()
+ endpointResults['dolce_documents'] = result
+
+ console.log(`✅ DOLCE documents sync successful`)
+ return { success: true, endpoint: 'dolce_documents', result }
+
+ } catch (error) {
+ const errorMessage = `DOLCE documents: ${error instanceof Error ? error.message : 'Unknown error'}`
+ errors.push(errorMessage)
+ overallSuccess = false
+
+ console.error(`❌ DOLCE documents sync failed:`, error)
+ return { success: false, endpoint: 'dolce_documents', error: errorMessage }
+ }
+ })()
+ )
+ }
+
+ if (endpointPromises.length === 0) {
+ throw new Error('No DOLCE sync endpoints configured')
+ }
+
+ // 모든 엔드포인트 요청 완료 대기
+ const results = await Promise.allSettled(endpointPromises)
+
+ // 결과 집계
+ const successfulEndpoints = results.filter(r => r.status === 'fulfilled' && r.value.success).length
+ const totalEndpoints = endpointPromises.length
+
+ console.log(`DOLCE endpoint results: ${successfulEndpoints}/${totalEndpoints} successful`)
+
+ return {
+ success: overallSuccess && errors.length === 0,
+ successCount: overallSuccess ? changes.length : 0,
+ failureCount: overallSuccess ? 0 : changes.length,
+ errors: errors.length > 0 ? errors : undefined,
+ endpointResults
+ }
+ }
+
+ /**
+ * SWP 시스템 전용 동기화 수행
+ */
+ private async performSyncSWP(
+ changes: ChangeLog[],
+ contractId: number
+ ): Promise<{ success: boolean; successCount: number; failureCount: number; errors?: string[]; endpointResults?: Record<string, any> }> {
+ const errors: string[] = []
+ const endpointResults: Record<string, any> = {}
+ let overallSuccess = true
+
+ // 변경사항을 SWP 시스템 형태로 변환
+ const syncData = await this.transformChangesForSWP(changes)
+
+ // 1. SWP 메인 엔드포인트 (XML 전송)
+ const mainUrl = process.env.SYNC_SWP_URL
+ if (mainUrl) {
+ try {
+ console.log(`Sending to SWP main: ${mainUrl}`)
+
+ const transformedData = this.convertToXML({
+ contractId,
+ systemType: 'SWP',
changes: syncData,
batchSize: changes.length,
- timestamp: new Date().toISOString()
+ timestamp: new Date().toISOString(),
+ source: 'EVCP'
})
- })
- if (!response.ok) {
- const errorText = await response.text()
- throw new Error(`HTTP ${response.status}: ${errorText}`)
+ const response = await fetch(mainUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/xml',
+ 'Authorization': `Basic ${Buffer.from(`${process.env.SYNC_SWP_USER}:${process.env.SYNC_SWP_PASSWORD}`).toString('base64')}`,
+ 'X-System': 'SWP'
+ },
+ body: transformedData
+ })
+
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`SWP main: HTTP ${response.status} - ${errorText}`)
+ }
+
+ let result
+ const contentType = response.headers.get('content-type')
+ if (contentType?.includes('application/json')) {
+ result = await response.json()
+ } else {
+ result = await response.text()
+ }
+
+ endpointResults['swp_main'] = result
+ console.log(`✅ SWP main sync successful`)
+
+ } catch (error) {
+ const errorMessage = `SWP main: ${error instanceof Error ? error.message : 'Unknown error'}`
+ errors.push(errorMessage)
+ overallSuccess = false
+
+ console.error(`❌ SWP main sync failed:`, error)
}
+ }
- 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)
+ // 2. SWP 알림 엔드포인트 (선택사항)
+ const notificationUrl = process.env.SYNC_SWP_NOTIFICATION_URL
+ if (notificationUrl) {
+ try {
+ console.log(`Sending to SWP notification: ${notificationUrl}`)
+
+ const notificationData = {
+ event: 'swp_sync_notification',
+ itemCount: syncData.length,
+ syncTime: new Date().toISOString(),
+ system: 'SWP'
}
- } else {
- failureCount = changes.length
- if (result.error) {
- errors.push(result.error)
+
+ const response = await fetch(notificationUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(notificationData)
+ })
+
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`SWP notification: HTTP ${response.status} - ${errorText}`)
}
+
+ const result = await response.json()
+ endpointResults['swp_notification'] = result
+ console.log(`✅ SWP notification sync successful`)
+
+ } catch (error) {
+ const errorMessage = `SWP notification: ${error instanceof Error ? error.message : 'Unknown error'}`
+ errors.push(errorMessage)
+ // 알림은 실패해도 전체 동기화는 성공으로 처리
+ console.error(`❌ SWP notification sync failed:`, error)
}
+ }
- } catch (error) {
- console.error('External sync failed:', error)
- failureCount = changes.length
- errors.push(error instanceof Error ? error.message : 'Unknown error')
+ if (!mainUrl) {
+ throw new Error('No SWP main endpoint configured')
}
+ console.log(`SWP sync completed with ${errors.length} errors`)
+
return {
- success: failureCount === 0,
- successCount,
- failureCount,
- errors: errors.length > 0 ? errors : undefined
+ success: overallSuccess && errors.length === 0,
+ successCount: overallSuccess ? changes.length : 0,
+ failureCount: overallSuccess ? 0 : changes.length,
+ errors: errors.length > 0 ? errors : undefined,
+ endpointResults
}
}
/**
- * 변경사항을 외부 시스템 형태로 변환
+ * DOLCE 시스템용 데이터 변환
*/
- private async transformChangesForExternalSystem(changes: ChangeLog[]): Promise<SyncableEntity[]> {
+ private async transformChangesForDOLCE(changes: ChangeLog[]): Promise<SyncableEntity[]> {
const syncData: SyncableEntity[] = []
for (const change of changes) {
@@ -374,7 +581,7 @@ class SyncService {
entityData = document
}
break
-
+
case 'revision':
if (change.action !== 'DELETE') {
const [revision] = await db
@@ -385,7 +592,7 @@ class SyncService {
entityData = revision
}
break
-
+
case 'attachment':
if (change.action !== 'DELETE') {
const [attachment] = await db
@@ -398,21 +605,26 @@ class SyncService {
break
}
+ // DOLCE 특화 데이터 구조
syncData.push({
entityType: change.entityType as any,
entityId: change.entityId,
action: change.action as any,
- data: entityData || change.oldValues, // DELETE의 경우 oldValues 사용
+ data: entityData || change.oldValues,
metadata: {
changeId: change.id,
changedAt: change.createdAt,
changedBy: change.userName,
- changedFields: change.changedFields
+ changedFields: change.changedFields,
+ // DOLCE 전용 메타데이터
+ dolceVersion: '2.0',
+ processingPriority: change.entityType === 'revision' ? 'HIGH' : 'NORMAL',
+ requiresApproval: change.action === 'DELETE'
}
})
} catch (error) {
- console.error(`Failed to transform change ${change.id}:`, error)
+ console.error(`Failed to transform change ${change.id} for DOLCE:`, error)
}
}
@@ -420,40 +632,260 @@ class SyncService {
}
/**
- * 동기화 상태 조회
+ * SWP 시스템용 데이터 변환
*/
- async getSyncStatus(contractId: number, targetSystem: string = 'SHI') {
- const [status] = await db
- .select()
- .from(syncStatusView)
+ private async transformChangesForSWP(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
+ }
+
+ // SWP 특화 데이터 구조
+ syncData.push({
+ entityType: change.entityType as any,
+ entityId: change.entityId,
+ action: change.action as any,
+ data: entityData || change.oldValues,
+ metadata: {
+ changeId: change.id,
+ changedAt: change.createdAt,
+ changedBy: change.userName,
+ changedFields: change.changedFields,
+ // SWP 전용 메타데이터
+ swpFormat: 'legacy',
+ batchSequence: syncData.length + 1,
+ needsValidation: change.entityType === 'document',
+ legacyId: `SWP_${change.entityId}_${Date.now()}`
+ }
+ })
+
+ } catch (error) {
+ console.error(`Failed to transform change ${change.id} for SWP:`, error)
+ }
+ }
+
+ return syncData
+ }
+
+ /**
+ * 간단한 XML 변환 헬퍼 (SWP용)
+ */
+ private convertToXML(data: any): string {
+ const xmlHeader = '<?xml version="1.0" encoding="UTF-8"?>'
+ const xmlBody = `
+ <SyncRequest>
+ <ContractId>${data.contractId}</ContractId>
+ <SystemType>${data.systemType}</SystemType>
+ <BatchSize>${data.batchSize}</BatchSize>
+ <Timestamp>${data.timestamp}</Timestamp>
+ <Source>${data.source}</Source>
+ <Changes>
+ ${data.changes.map((change: SyncableEntity) => `
+ <Change>
+ <EntityType>${change.entityType}</EntityType>
+ <EntityId>${change.entityId}</EntityId>
+ <Action>${change.action}</Action>
+ <Data>${JSON.stringify(change.data)}</Data>
+ </Change>
+ `).join('')}
+ </Changes>
+ </SyncRequest>`
+
+ return xmlHeader + xmlBody
+ }
+
+ /**
+ * 성공한 변경사항들을 동기화 완료로 표시
+ */
+ private async markChangesAsSynced(changeIds: number[]) {
+ if (changeIds.length === 0) return
+
+ await db
+ .update(changeLogs)
+ .set({
+ isSynced: true,
+ syncedAt: new Date()
+ })
+ .where(inArray(changeLogs.id, changeIds))
+
+ // 리비전 상태 업데이트
+ const revisionChanges = await db
+ .select({ entityId: changeLogs.entityId })
+ .from(changeLogs)
.where(and(
- eq(syncStatusView.contractId, contractId),
- eq(syncStatusView.targetSystem, targetSystem)
+ inArray(changeLogs.id, changeIds),
+ eq(changeLogs.entityType, 'revision')
))
- .limit(1)
- return status
+ if (revisionChanges.length > 0) {
+ const revisionIds = revisionChanges.map(c => c.entityId)
+ await db.update(revisions)
+ .set({
+ revisionStatus: "SUBMITTED",
+ externalSentAt: new Date().toISOString().slice(0, 10)
+ })
+ .where(inArray(revisions.id, revisionIds))
+ }
+ }
+
+ /**
+ * 실패한 변경사항들의 재시도 횟수 증가
+ */
+ private async incrementSyncAttempts(changeIds: number[], errorMessage?: string) {
+ if (changeIds.length === 0) return
+
+ await db
+ .update(changeLogs)
+ .set({
+ syncAttempts: sql`${changeLogs.syncAttempts} + 1`,
+ lastSyncError: errorMessage || 'Unknown error'
+ })
+ .where(inArray(changeLogs.id, changeIds))
+ }
+
+ /**
+ * 동기화 상태 조회
+ */
+ async getSyncStatus(contractId: number, targetSystem: string = 'DOLCE') {
+ try {
+ // 대기 중인 변경사항 수 조회
+ const pendingCount = await db.$count(
+ changeLogs,
+ and(
+ eq(changeLogs.contractId, contractId),
+ eq(changeLogs.isSynced, false),
+ lt(changeLogs.syncAttempts, 3),
+ sql`(${changeLogs.targetSystems} IS NULL OR ${changeLogs.targetSystems} @> ${JSON.stringify([targetSystem])})`
+ )
+ )
+
+ // 동기화된 변경사항 수 조회
+ const syncedCount = await db.$count(
+ changeLogs,
+ and(
+ eq(changeLogs.contractId, contractId),
+ eq(changeLogs.isSynced, true),
+ sql`(${changeLogs.targetSystems} IS NULL OR ${changeLogs.targetSystems} @> ${JSON.stringify([targetSystem])})`
+ )
+ )
+
+ // 실패한 변경사항 수 조회
+ const failedCount = await db.$count(
+ changeLogs,
+ and(
+ eq(changeLogs.contractId, contractId),
+ eq(changeLogs.isSynced, false),
+ sql`${changeLogs.syncAttempts} >= 3`,
+ sql`(${changeLogs.targetSystems} IS NULL OR ${changeLogs.targetSystems} @> ${JSON.stringify([targetSystem])})`
+ )
+ )
+
+ // 마지막 성공한 배치 조회
+ const [lastSuccessfulBatch] = await db
+ .select()
+ .from(syncBatches)
+ .where(and(
+ eq(syncBatches.contractId, contractId),
+ eq(syncBatches.targetSystem, targetSystem),
+ eq(syncBatches.status, 'SUCCESS')
+ ))
+ .orderBy(desc(syncBatches.completedAt))
+ .limit(1)
+
+ return {
+ contractId,
+ targetSystem,
+ totalChanges: pendingCount + syncedCount + failedCount,
+ pendingChanges: pendingCount,
+ syncedChanges: syncedCount,
+ failedChanges: failedCount,
+ lastSyncAt: lastSuccessfulBatch?.completedAt?.toISOString() || null,
+ syncEnabled: this.isSyncEnabled(targetSystem)
+ }
+ } catch (error) {
+ console.error('Failed to get sync status:', error)
+ throw error
+ }
}
/**
* 최근 동기화 배치 목록 조회
*/
- 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)
+ async getRecentSyncBatches(contractId: number, targetSystem: string = 'DOLCE', limit: number = 10) {
+ try {
+ const batches = await db
+ .select()
+ .from(syncBatches)
+ .where(and(
+ eq(syncBatches.contractId, contractId),
+ eq(syncBatches.targetSystem, targetSystem)
+ ))
+ .orderBy(desc(syncBatches.createdAt))
+ .limit(limit)
+
+ // Date 객체를 문자열로 변환
+ return batches.map(batch => ({
+ id: Number(batch.id),
+ contractId: batch.contractId,
+ targetSystem: batch.targetSystem,
+ batchSize: batch.batchSize,
+ status: batch.status,
+ startedAt: batch.startedAt?.toISOString() || null,
+ completedAt: batch.completedAt?.toISOString() || null,
+ errorMessage: batch.errorMessage,
+ retryCount: batch.retryCount,
+ successCount: batch.successCount,
+ failureCount: batch.failureCount,
+ createdAt: batch.createdAt.toISOString(),
+ updatedAt: batch.updatedAt.toISOString()
+ }))
+ } catch (error) {
+ console.error('Failed to get sync batches:', error)
+ throw error
+ }
}
}
export const syncService = new SyncService()
-// 편의 함수들
+// 편의 함수들 (기본 타겟 시스템을 DOLCE로 변경)
export async function logDocumentChange(
contractId: number,
documentId: number,
@@ -461,9 +893,10 @@ export async function logDocumentChange(
newValues?: any,
oldValues?: any,
userId?: number,
- userName?: string
+ userName?: string,
+ targetSystems: string[] = ["DOLCE", "SWP"]
) {
- return syncService.logChange(contractId, 'document', documentId, action, newValues, oldValues, userId, userName)
+ return syncService.logChange(contractId, 'document', documentId, action, newValues, oldValues, userId, userName, targetSystems)
}
export async function logRevisionChange(
@@ -473,9 +906,10 @@ export async function logRevisionChange(
newValues?: any,
oldValues?: any,
userId?: number,
- userName?: string
+ userName?: string,
+ targetSystems: string[] = ["DOLCE", "SWP"]
) {
- return syncService.logChange(contractId, 'revision', revisionId, action, newValues, oldValues, userId, userName)
+ return syncService.logChange(contractId, 'revision', revisionId, action, newValues, oldValues, userId, userName, targetSystems)
}
export async function logAttachmentChange(
@@ -485,7 +919,8 @@ export async function logAttachmentChange(
newValues?: any,
oldValues?: any,
userId?: number,
- userName?: string
+ userName?: string,
+ targetSystems: string[] = ["DOLCE", "SWP"]
) {
- return syncService.logChange(contractId, 'attachment', attachmentId, action, newValues, oldValues, userId, userName)
+ return syncService.logChange(contractId, 'attachment', attachmentId, action, newValues, oldValues, userId, userName, targetSystems)
} \ No newline at end of file