summaryrefslogtreecommitdiff
path: root/lib/vendor-document-list
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
parent36dd60ca6fce7712b35e6d7c1b9602710f442ada (diff)
(대표님) lib 파트 개발 0528
Diffstat (limited to 'lib/vendor-document-list')
-rw-r--r--lib/vendor-document-list/sync-service.ts847
-rw-r--r--lib/vendor-document-list/table/enhanced-doc-table-toolbar-actions.tsx1
-rw-r--r--lib/vendor-document-list/table/enhanced-documents-table.tsx2
-rw-r--r--lib/vendor-document-list/table/revision-upload-dialog.tsx75
-rw-r--r--lib/vendor-document-list/table/send-to-shi-button.tsx16
-rw-r--r--lib/vendor-document-list/table/stage-revision-expanded-content.tsx12
6 files changed, 724 insertions, 229 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
diff --git a/lib/vendor-document-list/table/enhanced-doc-table-toolbar-actions.tsx b/lib/vendor-document-list/table/enhanced-doc-table-toolbar-actions.tsx
index f9d4d695..368b1e1c 100644
--- a/lib/vendor-document-list/table/enhanced-doc-table-toolbar-actions.tsx
+++ b/lib/vendor-document-list/table/enhanced-doc-table-toolbar-actions.tsx
@@ -91,6 +91,7 @@ export function EnhancedDocTableToolbarActions({
contractId={selectedPackageId}
documents={allDocuments}
onSyncComplete={handleSyncComplete}
+ projectType={projectType}
/>
{/* 일괄 업로드 다이얼로그 */}
diff --git a/lib/vendor-document-list/table/enhanced-documents-table.tsx b/lib/vendor-document-list/table/enhanced-documents-table.tsx
index 3b623193..d0f2991a 100644
--- a/lib/vendor-document-list/table/enhanced-documents-table.tsx
+++ b/lib/vendor-document-list/table/enhanced-documents-table.tsx
@@ -42,7 +42,7 @@ export function EnhancedDocumentsTable({
}: FinalIntegratedDocumentsTableProps) {
// 데이터 로딩
const [{ data, pageCount, total }] = React.use(promises)
-
+
// 상태 관리
const [rowAction, setRowAction] = React.useState<DataTableRowAction<EnhancedDocument> | null>(null)
const [expandedRows, setExpandedRows] = React.useState<Set<string>>(new Set())
diff --git a/lib/vendor-document-list/table/revision-upload-dialog.tsx b/lib/vendor-document-list/table/revision-upload-dialog.tsx
index ac58b974..546fa7a3 100644
--- a/lib/vendor-document-list/table/revision-upload-dialog.tsx
+++ b/lib/vendor-document-list/table/revision-upload-dialog.tsx
@@ -7,6 +7,7 @@ import { z } from "zod"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { useSession } from "next-auth/react"
+import { mutate } from "swr" // ✅ SWR mutate import 추가
import {
Dialog,
@@ -77,6 +78,11 @@ interface RevisionUploadDialogProps {
presetStage?: string
presetRevision?: string
mode?: 'new' | 'append'
+ onUploadComplete?: () => void // ✅ 업로드 완료 콜백 추가
+}
+
+function getTargetSystem(projectType: "ship" | "plant") {
+ return projectType === "ship" ? "DOLCE" : "SWP"
}
export function RevisionUploadDialog({
@@ -87,12 +93,19 @@ export function RevisionUploadDialog({
presetStage,
presetRevision,
mode = 'new',
+ onUploadComplete, // ✅ 추가된 prop
}: RevisionUploadDialogProps) {
+
+ const targetSystem = React.useMemo(
+ () => getTargetSystem(projectType),
+ [projectType]
+ )
+
const [selectedFiles, setSelectedFiles] = React.useState<File[]>([])
const [isUploading, setIsUploading] = React.useState(false)
const [uploadProgress, setUploadProgress] = React.useState(0)
const router = useRouter()
-
+
// ✅ next-auth session 가져오기
const { data: session } = useSession()
@@ -109,7 +122,7 @@ export function RevisionUploadDialog({
defaultValues: {
stage: presetStage || document?.currentStageName || "",
revision: presetRevision || "",
- uploaderName: session?.user?.name || "", // ✅ session.user.name 사용
+ uploaderName: session?.user?.name || "",
comment: "",
attachments: [],
},
@@ -146,6 +159,34 @@ export function RevisionUploadDialog({
form.setValue('attachments', updatedFiles, { shouldValidate: true })
}
+ // ✅ 캐시 갱신 함수
+ const refreshCaches = async () => {
+ try {
+ // 1. 서버 컴포넌트 캐시 갱신 (Enhanced Documents 등)
+ router.refresh()
+
+ // 2. SWR 캐시 갱신 (Sync Status)
+ if (document?.contractId) {
+ await mutate(`/api/sync/status/${document.contractId}/${targetSystem}`)
+ console.log('✅ Sync status cache refreshed')
+ }
+
+ // 3. 다른 관련 SWR 캐시들도 갱신 (필요시)
+ await mutate(key =>
+ typeof key === 'string' &&
+ key.includes('sync') &&
+ key.includes(String(document?.contractId))
+ )
+
+ // 4. 상위 컴포넌트 콜백 호출
+ onUploadComplete?.()
+
+ console.log('✅ All caches refreshed after upload')
+ } catch (error) {
+ console.error('❌ Cache refresh failed:', error)
+ }
+ }
+
// 업로드 처리
async function onSubmit(data: RevisionUploadSchema) {
if (!document) return
@@ -158,8 +199,9 @@ export function RevisionUploadDialog({
formData.append("documentId", String(document.documentId))
formData.append("stage", data.stage)
formData.append("revision", data.revision)
- formData.append("mode", mode) // 'new' 또는 'append'
-
+ formData.append("mode", mode)
+ formData.append("targetSystem", targetSystem)
+
if (data.uploaderName) {
formData.append("uploaderName", data.uploaderName)
}
@@ -175,7 +217,7 @@ export function RevisionUploadDialog({
// 진행률 업데이트 시뮬레이션
const updateProgress = (progress: number) => {
- setUploadProgress(Math.min(progress, 95)) // 95%까지만 진행률 표시
+ setUploadProgress(Math.min(progress, 95))
}
// 파일 크기에 따른 진행률 시뮬레이션
@@ -183,7 +225,7 @@ export function RevisionUploadDialog({
let uploadedSize = 0
const progressInterval = setInterval(() => {
- uploadedSize += totalSize * 0.1 // 10%씩 증가 시뮬레이션
+ uploadedSize += totalSize * 0.1
const progress = Math.min((uploadedSize / totalSize) * 100, 90)
updateProgress(progress)
}, 300)
@@ -211,10 +253,10 @@ export function RevisionUploadDialog({
console.log('✅ 업로드 성공:', result)
- // 잠시 대기 후 다이얼로그 닫기
- setTimeout(() => {
+ // ✅ 캐시 갱신 및 다이얼로그 닫기
+ setTimeout(async () => {
+ await refreshCaches()
handleDialogClose()
- router.refresh()
}, 1000)
} catch (error) {
@@ -222,7 +264,7 @@ export function RevisionUploadDialog({
toast.error(error instanceof Error ? error.message : "업로드 중 오류가 발생했습니다")
} finally {
setIsUploading(false)
- setTimeout(() => setUploadProgress(0), 2000) // 2초 후 진행률 리셋
+ setTimeout(() => setUploadProgress(0), 2000)
}
}
@@ -230,7 +272,7 @@ export function RevisionUploadDialog({
form.reset({
stage: presetStage || document?.currentStageName || "",
revision: presetRevision || "",
- uploaderName: session?.user?.name || "", // ✅ 다이얼로그 닫을 때도 session 값으로 리셋
+ uploaderName: session?.user?.name || "",
comment: "",
attachments: [],
})
@@ -257,13 +299,15 @@ export function RevisionUploadDialog({
<Badge variant={projectType === "ship" ? "default" : "secondary"}>
{projectType === "ship" ? "조선 프로젝트" : "플랜트 프로젝트"}
</Badge>
- {/* ✅ 현재 사용자 정보 표시 */}
+ {/* ✅ 타겟 시스템 표시 추가 */}
+ <Badge variant="outline" className="text-xs">
+ → {targetSystem}
+ </Badge>
{session?.user?.name && (
<Badge variant="outline" className="text-xs">
업로더: {session.user.name}
</Badge>
)}
- {/* ✅ 모드에 따른 정보 표시 */}
{mode === 'append' && presetRevision && (
<Badge variant="outline" className="text-xs">
리비전 {presetRevision}에 파일 추가
@@ -320,7 +364,6 @@ export function RevisionUploadDialog({
/>
</FormControl>
<FormMessage />
- {/* ✅ 모드에 따른 도움말 표시 */}
{mode === 'new' && presetRevision && (
<p className="text-xs text-gray-500">
자동으로 계산된 다음 리비전입니다.
@@ -346,7 +389,7 @@ export function RevisionUploadDialog({
<Input
{...field}
placeholder="업로더 이름을 입력하세요"
- className="bg-gray-50" // ✅ session 값이므로 읽기 전용 느낌으로 스타일링
+ className="bg-gray-50"
/>
</FormControl>
<FormMessage />
@@ -420,7 +463,7 @@ export function RevisionUploadDialog({
<FileListIcon />
<FileListInfo>
<FileListName>{file.name}</FileListName>
- <FileListSize>{prettyBytes(file.size)}</FileListSize>
+ <FileListSize>{file.size}</FileListSize>
</FileListInfo>
<FileListAction
onClick={() => removeFile(index)}
diff --git a/lib/vendor-document-list/table/send-to-shi-button.tsx b/lib/vendor-document-list/table/send-to-shi-button.tsx
index e0360144..1a27a794 100644
--- a/lib/vendor-document-list/table/send-to-shi-button.tsx
+++ b/lib/vendor-document-list/table/send-to-shi-button.tsx
@@ -28,22 +28,26 @@ interface SendToSHIButtonProps {
contractId: number
documents?: EnhancedDocument[]
onSyncComplete?: () => void
+ projectType: "ship" | "plant"
}
export function SendToSHIButton({
contractId,
documents = [],
- onSyncComplete
+ onSyncComplete,
+ projectType
}: SendToSHIButtonProps) {
const [isDialogOpen, setIsDialogOpen] = React.useState(false)
const [syncProgress, setSyncProgress] = React.useState(0)
+
+ const targetSystem = projectType === 'ship'?"DOLCE":"SWP"
const {
syncStatus,
isLoading: statusLoading,
error: statusError,
refetch: refetchStatus
- } = useSyncStatus(contractId, 'SHI')
+ } = useSyncStatus(contractId, targetSystem)
const {
triggerSync,
@@ -71,7 +75,7 @@ export function SendToSHIButton({
const result = await triggerSync({
contractId,
- targetSystem: 'SHI'
+ targetSystem
})
clearInterval(progressInterval)
@@ -152,10 +156,11 @@ export function SendToSHIButton({
<>
<Popover>
<PopoverTrigger asChild>
+ <div className="flex items-center gap-3">
<Button
variant="default"
size="sm"
- className="gap-2 relative bg-blue-600 hover:bg-blue-700"
+ className="flex items-center bg-blue-600 hover:bg-blue-700"
disabled={isSyncing || statusLoading}
>
{isSyncing ? (
@@ -167,12 +172,13 @@ export function SendToSHIButton({
{syncStatus?.pendingChanges > 0 && (
<Badge
variant="destructive"
- className="absolute -top-2 -right-2 h-5 w-5 p-0 text-xs flex items-center justify-center"
+ className="h-5 w-5 p-0 text-xs flex items-center justify-center"
>
{syncStatus.pendingChanges}
</Badge>
)}
</Button>
+ </div>
</PopoverTrigger>
<PopoverContent className="w-80">
diff --git a/lib/vendor-document-list/table/stage-revision-expanded-content.tsx b/lib/vendor-document-list/table/stage-revision-expanded-content.tsx
index c2395aa8..d9d53cc9 100644
--- a/lib/vendor-document-list/table/stage-revision-expanded-content.tsx
+++ b/lib/vendor-document-list/table/stage-revision-expanded-content.tsx
@@ -66,6 +66,7 @@ const getStatusText = (status: string) => {
case 'PLANNED': return '계획됨'
case 'IN_PROGRESS': return '진행중'
case 'SUBMITTED': return '제출됨'
+ case 'UPLOADED': return '등록됨'
case 'UNDER_REVIEW': return '검토중'
case 'APPROVED': return '승인됨'
case 'REJECTED': return '반려됨'
@@ -330,6 +331,8 @@ export const StageRevisionExpandedContent = ({
// 뷰에서 가져온 allStages 데이터를 바로 사용
const stagesWithRevisions = documentData.allStages || []
+ console.log(stagesWithRevisions)
+
if (stagesWithRevisions.length === 0) {
return (
<div className="p-6 text-center text-gray-500">
@@ -485,6 +488,7 @@ export const StageRevisionExpandedContent = ({
<TableHead className="w-16 py-1 px-2 text-xs">리비전</TableHead>
<TableHead className="w-20 py-1 px-2 text-xs">상태</TableHead>
<TableHead className="w-24 py-1 px-2 text-xs">업로더</TableHead>
+ <TableHead className="w-32 py-1 px-2 text-xs">등록일</TableHead>
<TableHead className="w-32 py-1 px-2 text-xs">제출일</TableHead>
<TableHead className="w-32 py-1 px-2 text-xs">승인/반려일</TableHead>
<TableHead className="min-w-[120px] py-1 px-2 text-xs">첨부파일</TableHead>
@@ -525,11 +529,17 @@ export const StageRevisionExpandedContent = ({
<span className="text-xs truncate max-w-[60px]">{revision.uploaderName || '-'}</span>
</div>
</TableCell>
+ {/* 제출일 */}
+ <TableCell className="py-1 px-2">
+ <span className="text-xs text-gray-600">
+ {revision.uploadedAt ? formatDate(revision.uploadedAt) : '-'}
+ </span>
+ </TableCell>
{/* 제출일 */}
<TableCell className="py-1 px-2">
<span className="text-xs text-gray-600">
- {revision.submittedDate ? formatDate(revision.submittedDate) : '-'}
+ {revision.externalSentDate ? formatDate(revision.externalSentDate) : '-'}
</span>
</TableCell>