summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-09-29 19:28:41 +0900
committerjoonhoekim <26rote@gmail.com>2025-09-29 19:28:41 +0900
commitc17b495c700dcfa040abc93a210727cbe72785f1 (patch)
treef7c6ebc45111d905c332e5aac3919f917e53ab85 /lib
parentedd6289cb0f5ce8701b4fb3a6c7fbdf4a6f8f6a2 (diff)
(김준회) SWP 파일 업로드 관련 구현
- \\60.100.91.61\SBox 경로를 mnt/swp-smb-dir/ 에 마운트해두어야 함
Diffstat (limited to 'lib')
-rw-r--r--lib/vendor-document-list/plant/shi-buyer-system-api.ts307
1 files changed, 197 insertions, 110 deletions
diff --git a/lib/vendor-document-list/plant/shi-buyer-system-api.ts b/lib/vendor-document-list/plant/shi-buyer-system-api.ts
index 80575810..14de10c2 100644
--- a/lib/vendor-document-list/plant/shi-buyer-system-api.ts
+++ b/lib/vendor-document-list/plant/shi-buyer-system-api.ts
@@ -1,7 +1,15 @@
// app/lib/shi-buyer-system-api.ts
-import db from "@/db/db"
-import { stageDocuments, stageIssueStages, contracts, vendors, projects, stageSubmissions, stageSubmissionAttachments } from "@/db/schema"
-import { eq, and, sql, ne, or, isNull, inArray } from "drizzle-orm"
+import db from '@/db/db'
+import {
+ stageDocuments,
+ stageIssueStages,
+ contracts,
+ vendors,
+ projects,
+ stageSubmissions,
+ stageSubmissionAttachments,
+} from '@/db/schema'
+import { eq, and, sql, ne, or, isNull, inArray } from 'drizzle-orm'
import fs from 'fs/promises'
import path from 'path'
@@ -94,6 +102,28 @@ interface InBoxFileInfo {
COMMENT: string
}
+// 파일 저장용 확장 인터페이스
+interface FileInfoWithBuffer extends InBoxFileInfo {
+ fileBuffer: Buffer;
+ attachment: {
+ id: number;
+ fileName: string;
+ mimeType?: string;
+ storagePath?: string;
+ storageUrl?: string;
+ };
+}
+
+// 경로 생성용 인터페이스 (유연한 타입)
+interface PathGenerationData {
+ PROJ_NO: string | number;
+ VNDR_CD: string | number | null | undefined;
+ SHI_DOC_NO: string | number;
+ REVISION_NO: string | number;
+ STAGE_NAME: string | number;
+ FILE_NAME: string | number;
+}
+
// SaveInBoxList API 응답 인터페이스
interface SaveInBoxListResponse {
SaveInBoxListResult: {
@@ -112,39 +142,97 @@ export class ShiBuyerSystemAPI {
private baseUrl = process.env.SWP_BASE_URL || 'http://60.100.99.217/DDP/Services/VNDRService.svc'
private ddcUrl = process.env.DDC_BASE_URL || 'http://60.100.99.217/DDC/Services/WebService.svc'
private localStoragePath = process.env.NAS_PATH || './uploads'
+ // SMB로 마운트한 SWP 업로드 경로 (/mnt/swp-smb-dir/ 경로이며, 네트워크 경로로는 \\60.100.91.61\SBox 경로임)
+ private swpMountDir = process.env.SWP_MONUT_DIR || '/mnt/swp-smb-dir/';
+
+ /**
+ * 타임스탬프를 YYYYMMDDhhmmss 형식으로 생성
+ */
+ private getTimestamp(): string {
+ const now = new Date();
+ return (
+ now.getFullYear().toString() +
+ (now.getMonth() + 1).toString().padStart(2, '0') +
+ now.getDate().toString().padStart(2, '0') +
+ now.getHours().toString().padStart(2, '0') +
+ now.getMinutes().toString().padStart(2, '0') +
+ now.getSeconds().toString().padStart(2, '0')
+ );
+ }
+
+ /**
+ * 파일명에서 이름과 확장자를 분리
+ */
+ private parseFileName(fileName: string): { name: string; extension: string } {
+ const lastDotIndex = fileName.lastIndexOf('.');
+ if (lastDotIndex === -1) {
+ return { name: fileName, extension: '' };
+ }
+ return {
+ name: fileName.substring(0, lastDotIndex),
+ extension: fileName.substring(lastDotIndex + 1),
+ };
+ }
+
+ /**
+ * SMB 마운트 경로에 맞는 파일 경로 생성
+ * /mnt/swp-smb-dir/{proj_no}/{cpyCd}/{YYYYMMDDhhmmss}/{[파일명]}{DOC_NO}_{REV}_{STAGE}.{extension}
+ */
+ private generateMountPath(fileInfo: PathGenerationData): string {
+ // 모든 값들을 문자열로 변환
+ const projNo: string = String(fileInfo.PROJ_NO);
+ const vndrCd: string = String(fileInfo.VNDR_CD || '');
+ const shiDocNo: string = String(fileInfo.SHI_DOC_NO);
+ const revisionNo: string = String(fileInfo.REVISION_NO);
+ const stageName: string = String(fileInfo.STAGE_NAME);
+ const fileName: string = String(fileInfo.FILE_NAME);
+
+ const timestamp = this.getTimestamp();
+ const { name: fileNameWithoutExt, extension } =
+ this.parseFileName(fileName);
+
+ // 새로운 파일명 생성: {[파일명]}{DOC_NO}_{REV}_{STAGE}.{extension}
+ const newFileName = `[${fileNameWithoutExt}]${shiDocNo}_${revisionNo}_${stageName}`;
+ const fullFileName = extension
+ ? `${newFileName}.${extension}`
+ : newFileName;
+
+ // 전체 경로 생성
+ return path.join(this.swpMountDir, projNo, vndrCd, timestamp, fullFileName);
+ }
async sendToSHI(contractId: number) {
try {
// 1. 전송할 문서 조회
const documents = await this.getDocumentsToSend(contractId)
-
+
if (documents.length === 0) {
return { success: false, message: "전송할 문서가 없습니다." }
}
// 2. 도서 정보 전송
await this.sendDocumentInfo(documents)
-
+
// 3. 스케줄 정보 전송
await this.sendScheduleInfo(documents)
-
+
// 4. 동기화 상태 업데이트
await this.updateSyncStatus(documents.map(d => d.documentId))
-
- return {
- success: true,
+
+ return {
+ success: true,
message: `${documents.length}개 문서가 성공적으로 전송되었습니다.`,
count: documents.length
}
} catch (error) {
console.error("SHI 전송 오류:", error)
-
+
// 에러 시 동기화 상태 업데이트
await this.updateSyncError(
- contractId,
+ contractId,
error instanceof Error ? error.message : "알 수 없는 오류"
)
-
+
throw error
}
}
@@ -182,7 +270,7 @@ export class ShiBuyerSystemAPI {
)
)
-
+
return result
}
@@ -229,7 +317,7 @@ export class ShiBuyerSystemAPI {
private async sendScheduleInfo(documents: any[]) {
const schedules: ShiScheduleInfo[] = []
-
+
for (const doc of documents) {
for (const stage of doc.stages) {
if (stage.plan_date) {
@@ -306,7 +394,7 @@ export class ShiBuyerSystemAPI {
)
)
}
-
+
async pullDocumentStatus(contractId: number) {
try {
const contract = await db.query.contracts.findFirst({
@@ -338,8 +426,8 @@ export class ShiBuyerSystemAPI {
})
if (!shiDocuments || shiDocuments.length === 0) {
- return {
- success: true,
+ return {
+ success: true,
message: "동기화할 문서가 없습니다.",
updatedCount: 0,
documents: []
@@ -362,7 +450,7 @@ export class ShiBuyerSystemAPI {
}
private async fetchDocumentsFromSHI(
- projectCode: string,
+ projectCode: string,
filters?: {
SHI_DOC_NO?: string
CATEGORY?: string
@@ -377,7 +465,7 @@ export class ShiBuyerSystemAPI {
}
): Promise<ShiDocumentResponse[]> {
const params = new URLSearchParams({ PROJ_NO: projectCode })
-
+
if (filters) {
Object.entries(filters).forEach(([key, value]) => {
if (value) params.append(key, value)
@@ -385,7 +473,7 @@ export class ShiBuyerSystemAPI {
}
const url = `${this.baseUrl}/GetDwgInfo?${params.toString()}`
-
+
const response = await fetch(url, {
method: 'GET',
headers: {
@@ -398,12 +486,12 @@ export class ShiBuyerSystemAPI {
}
const data: ShiApiResponse = await response.json()
-
+
return data.GetDwgInfoResult || []
}
private async updateLocalDocuments(
- projectCode: string,
+ projectCode: string,
shiDocuments: ShiDocumentResponse[]
) {
let updatedCount = 0
@@ -486,7 +574,7 @@ export class ShiBuyerSystemAPI {
})
.from(stageDocuments)
.where(eq(stageDocuments.contractId, contractId))
-
+
return documents
}
@@ -531,7 +619,7 @@ export class ShiBuyerSystemAPI {
try {
// 1. 제출 정보 조회 (프로젝트, 문서, 스테이지, 파일 정보 포함)
const submissionInfo = await this.getSubmissionFullInfo(submissionId)
-
+
if (!submissionInfo) {
throw new Error(`제출 정보를 찾을 수 없습니다: ${submissionId}`)
}
@@ -541,7 +629,7 @@ export class ShiBuyerSystemAPI {
// 3. 첨부파일들과 실제 파일 내용을 준비
const filesWithContent = await this.prepareFilesWithContent(submissionInfo)
-
+
if (filesWithContent.length === 0) {
await this.updateSubmissionSyncStatus(submissionId, 'synced', '전송할 파일이 없습니다')
return {
@@ -554,10 +642,13 @@ export class ShiBuyerSystemAPI {
// 4. SaveInBoxList API 호출하여 네트워크 경로 받기
const response = await this.sendToInBox(filesWithContent)
- // 5. 응답받은 네트워크 경로에 파일 저장
- if (response.SaveInBoxListResult.success && response.SaveInBoxListResult.files) {
- await this.saveFilesToNetworkPaths(filesWithContent, response.SaveInBoxListResult.files)
-
+ // 5. SMB 마운트 경로에 파일 저장
+ if (
+ response.SaveInBoxListResult.success &&
+ response.SaveInBoxListResult.files
+ ) {
+ await this.saveFilesToNetworkPaths(filesWithContent)
+
// 6. 동기화 결과 업데이트
await this.updateSubmissionSyncStatus(submissionId, 'synced', null, {
syncedFilesCount: filesWithContent.length,
@@ -581,11 +672,11 @@ export class ShiBuyerSystemAPI {
}
} catch (error) {
await this.updateSubmissionSyncStatus(
- submissionId,
- 'failed',
+ submissionId,
+ 'failed',
error instanceof Error ? error.message : '알 수 없는 오류'
)
-
+
throw error
}
}
@@ -632,29 +723,31 @@ export class ShiBuyerSystemAPI {
/**
* 파일 내용과 함께 InBox 파일 정보 준비
*/
- private async prepareFilesWithContent(submissionInfo: any): Promise<Array<InBoxFileInfo & { fileBuffer: Buffer, attachment: any }>> {
- const filesWithContent: Array<InBoxFileInfo & { fileBuffer: Buffer, attachment: any }> = []
-
+ private async prepareFilesWithContent(
+ submissionInfo: any
+ ): Promise<FileInfoWithBuffer[]> {
+ const filesWithContent: FileInfoWithBuffer[] = [];
+
for (const attachment of submissionInfo.attachments) {
try {
// 파일 경로 결정 (storagePath 또는 storageUrl 사용)
- const filePath = attachment.storagePath || attachment.storageUrl
-
+ const filePath = attachment.storagePath || attachment.storageUrl;
+
if (!filePath) {
- console.warn(`첨부파일 ${attachment.id}의 경로를 찾을 수 없습니다.`)
- continue
+ console.warn(`첨부파일 ${attachment.id}의 경로를 찾을 수 없습니다.`);
+ continue;
}
// 전체 경로 생성
- const fullPath = path.isAbsolute(filePath)
- ? filePath
- : path.join(this.localStoragePath, filePath)
+ const fullPath = path.isAbsolute(filePath)
+ ? filePath
+ : path.join(this.localStoragePath, filePath);
// 파일 읽기
- const fileBuffer = await fs.readFile(fullPath)
-
+ const fileBuffer = await fs.readFile(fullPath);
+
// 파일 정보 생성
- const fileInfo: InBoxFileInfo & { fileBuffer: Buffer, attachment: any } = {
+ const fileInfo: FileInfoWithBuffer = {
PROJ_NO: submissionInfo.project.code,
SHI_DOC_NO: submissionInfo.document.docNumber,
STAGE_NAME: submissionInfo.stage.stageName,
@@ -669,112 +762,103 @@ export class ShiBuyerSystemAPI {
STATUS: 'PENDING',
COMMENT: `Revision ${submissionInfo.submission.revisionNumber} - ${submissionInfo.stage.stageName}`,
fileBuffer: fileBuffer,
- attachment: attachment
- }
-
- filesWithContent.push(fileInfo)
+ attachment: attachment,
+ };
+
+ filesWithContent.push(fileInfo);
} catch (error) {
- console.error(`파일 읽기 실패: ${attachment.fileName}`, error)
+ console.error(`파일 읽기 실패: ${attachment.fileName}`, error);
// 파일 읽기 실패 시 계속 진행
- continue
+ continue;
}
}
- return filesWithContent
+ return filesWithContent;
}
/**
* SaveInBoxList API 호출 (파일 메타데이터만 전송)
*/
- private async sendToInBox(files: Array<InBoxFileInfo & { fileBuffer: Buffer }>): Promise<SaveInBoxListResponse> {
- // fileBuffer를 제외한 메타데이터만 전송
- const fileMetadata = files.map(({ fileBuffer, attachment, ...metadata }) => metadata)
-
- const request = { files: fileMetadata }
-
+ private async sendToInBox(
+ files: FileInfoWithBuffer[]
+ ): Promise<SaveInBoxListResponse> {
+ // fileBuffer와 attachment를 제외한 메타데이터만 전송
+ const fileMetadata = files.map(
+ ({ fileBuffer, attachment, ...metadata }) => metadata as InBoxFileInfo
+ );
+
+ const request = { files: fileMetadata };
+
const response = await fetch(`${this.ddcUrl}/SaveInBoxList`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
- 'Accept': 'application/json'
+ Accept: 'application/json',
},
- body: JSON.stringify(request)
- })
+ body: JSON.stringify(request),
+ });
if (!response.ok) {
- const errorText = await response.text()
- throw new Error(`InBox 전송 실패: ${response.statusText} - ${errorText}`)
+ const errorText = await response.text();
+ throw new Error(`InBox 전송 실패: ${response.statusText} - ${errorText}`);
}
- const data = await response.json()
-
+ const data = await response.json();
+
// 응답 구조 확인 및 처리
if (!data.SaveInBoxListResult) {
return {
SaveInBoxListResult: {
success: true,
- message: "전송 완료",
+ message: '전송 완료',
processedCount: files.length,
- files: files.map(f => ({
+ files: files.map((f) => ({
fileName: f.FILE_NAME,
networkPath: `\\\\network\\share\\${f.PROJ_NO}\\${f.SHI_DOC_NO}\\${f.FILE_NAME}`,
- status: 'READY'
- }))
- }
- }
+ status: 'READY',
+ })),
+ },
+ };
}
-
- return data
+
+ return data;
}
/**
- * 네트워크 경로에 파일 저장
+ * SMB 마운트 경로에 파일 저장 (새로운 경로 규칙 적용)
*/
private async saveFilesToNetworkPaths(
- filesWithContent: Array<InBoxFileInfo & { fileBuffer: Buffer, attachment: any }>,
- networkPathInfo: Array<{ fileName: string, networkPath: string, status: string }>
+ filesWithContent: FileInfoWithBuffer[]
) {
for (const fileInfo of filesWithContent) {
- const pathInfo = networkPathInfo.find(p => p.fileName === fileInfo.FILE_NAME)
-
- if (!pathInfo || !pathInfo.networkPath) {
- console.error(`네트워크 경로를 찾을 수 없습니다: ${fileInfo.FILE_NAME}`)
- continue
- }
-
try {
- // 네트워크 경로에 파일 저장
- // Windows 네트워크 경로인 경우 처리
- let targetPath = pathInfo.networkPath
-
- // Windows 네트워크 경로를 Node.js가 이해할 수 있는 형식으로 변환
- if (process.platform === 'win32' && targetPath.startsWith('\\\\')) {
- // 그대로 사용
- } else if (process.platform !== 'win32' && targetPath.startsWith('\\\\')) {
- // Linux/Mac에서는 SMB 마운트 경로로 변환 필요
- // 예: \\\\server\\share -> /mnt/server/share
- targetPath = targetPath.replace(/\\\\/g, '/mnt/').replace(/\\/g, '/')
- }
+ // 새로운 경로 규칙에 따라 마운트 경로 생성
+ const targetPath = this.generateMountPath(fileInfo);
// 디렉토리 생성 (없는 경우)
- const targetDir = path.dirname(targetPath)
- await fs.mkdir(targetDir, { recursive: true })
-
+ const targetDir = path.dirname(targetPath);
+ await fs.mkdir(targetDir, { recursive: true });
+
// 파일 저장
- await fs.writeFile(targetPath, fileInfo.fileBuffer)
-
- console.log(`파일 저장 완료: ${fileInfo.FILE_NAME} -> ${targetPath}`)
-
- // DB에 네트워크 경로 업데이트
+ await fs.writeFile(targetPath, fileInfo.fileBuffer);
+
+ console.log(`파일 저장 완료: ${fileInfo.FILE_NAME} -> ${targetPath}`);
+ console.log(
+ `생성된 경로 구조: proj_no=${fileInfo.PROJ_NO}, cpyCd=${
+ fileInfo.VNDR_CD
+ }, timestamp=${this.getTimestamp()}`
+ );
+
+ // DB에 마운트 경로 업데이트 (네트워크 경로 대신 마운트 경로 저장)
await db
.update(stageSubmissionAttachments)
.set({
- buyerSystemUrl: pathInfo.networkPath,
+ buyerSystemUrl: targetPath, // 생성된 마운트 경로 저장
buyerSystemStatus: 'UPLOADED',
lastModifiedBy: 'EVCP'
})
.where(eq(stageSubmissionAttachments.id, fileInfo.attachment.id))
-
+
} catch (error) {
console.error(`파일 저장 실패: ${fileInfo.FILE_NAME}`, error)
// 개별 파일 실패는 전체 프로세스를 중단하지 않음
@@ -838,16 +922,19 @@ export class ShiBuyerSystemAPI {
eq(stageSubmissions.syncStatus, 'failed'),
sql`next_retry_at <= NOW()`
]
-
+
if (contractId) {
const documentIds = await db
.select({ id: stageDocuments.id })
.from(stageDocuments)
.where(eq(stageDocuments.contractId, contractId))
-
+
if (documentIds.length > 0) {
conditions.push(
- inArray(stageSubmissions.documentId, documentIds.map(d => d.id))
+ inArray(
+ stageSubmissions.documentId,
+ documentIds.map((d) => d.id)
+ )
)
}
}
@@ -859,8 +946,8 @@ export class ShiBuyerSystemAPI {
.limit(10) // 한 번에 최대 10개씩 재시도
if (failedSubmissions.length === 0) {
- return {
- success: true,
+ return {
+ success: true,
message: "재시도할 제출 건이 없습니다.",
retryCount: 0
}