summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--db/schema/rfqLast.ts13
-rw-r--r--lib/rfq-last/contract-actions.ts209
-rw-r--r--lib/soap/ecc/mapper/common-mapper-utils.ts51
-rw-r--r--lib/soap/ecc/mapper/rfq-and-pr-mapper.ts17
-rw-r--r--lib/soap/ecc/send/create-po.ts49
-rw-r--r--lib/soap/sender.ts103
-rw-r--r--lib/soap/types.ts64
-rw-r--r--lib/soap/utils.ts26
-rw-r--r--lib/swp/table/swp-document-detail-dialog.tsx176
-rw-r--r--lib/swp/table/swp-inbox-table.tsx20
-rw-r--r--lib/vendor-document-list/dolce-upload-service.ts184
11 files changed, 605 insertions, 307 deletions
diff --git a/db/schema/rfqLast.ts b/db/schema/rfqLast.ts
index 7374538f..7ab03c30 100644
--- a/db/schema/rfqLast.ts
+++ b/db/schema/rfqLast.ts
@@ -54,6 +54,7 @@ export const rfqsLast = pgTable(
rfqSealedYn: boolean("rfq_sealed_yn").default(false),
EngPicName: varchar("eng_pic_name", { length: 50 }),
+ EngPicEmployeeNumber: varchar("eng_pic_employee_number", { length: 50 }),
packageNo: varchar("package_no", { length: 50 }),
packageName: varchar("package_name", { length: 255 }),
@@ -322,6 +323,17 @@ export const rfqsLastView = pgView("rfqs_last_view").as((qb) => {
projectId: sql<number | null>`${rfqsLast.projectId}`.as("project_id"),
projectCode: sql<string | null>`${projects.code}`.as("project_code"),
projectName: sql<string | null>`${projects.name}`.as("project_name"),
+
+ // Classification (선급)
+ classNo: sql<string | null>`
+ CASE
+ WHEN ${projects.CLS_1} IS NOT NULL AND ${projects.CLS_2} IS NOT NULL
+ THEN CONCAT(${projects.CLS_1}, '-', ${projects.CLS_2})
+ WHEN ${projects.CLS_1} IS NOT NULL
+ THEN ${projects.CLS_1}
+ ELSE NULL
+ END
+ `.as("class_no"),
// Item information
itemCode: sql<string | null>`${rfqsLast.itemCode}`.as("item_code"),
@@ -333,6 +345,7 @@ export const rfqsLastView = pgView("rfqs_last_view").as((qb) => {
// Engineering PIC
engPicName: sql<string | null>`${rfqsLast.EngPicName}`.as("eng_pic_name"),
+ engPicEmployeeNumber: sql<string | null>`${rfqsLast.EngPicEmployeeNumber}`.as("eng_pic_employee_number"),
// Status and dates
status: sql<string>`${rfqsLast.status}`.as("status"),
diff --git a/lib/rfq-last/contract-actions.ts b/lib/rfq-last/contract-actions.ts
index 26b50c3c..a4be7e48 100644
--- a/lib/rfq-last/contract-actions.ts
+++ b/lib/rfq-last/contract-actions.ts
@@ -13,6 +13,7 @@ import { generateContractNumber } from "../general-contracts/service";
import { generateBiddingNumber } from "../bidding/service";
import { createPurchaseOrder } from "@/lib/soap/ecc/send/create-po";
import { getCurrentSAPDate } from "@/lib/soap/utils";
+import { SoapResponseError } from "@/lib/soap/types";
// ===== PO (SAP) 생성 =====
interface CreatePOParams {
@@ -111,73 +112,121 @@ export async function createPO(params: CreatePOParams) {
quotationItems.map(item => [item.rfqPrItemId, item])
);
- // 4. 필수 필드 검증 (경고만 출력, 전송은 계속 진행)
+ // 4. 필수 필드 설정 - 벤더 견적 응답 조건 우선, 구매자 제시 조건을 fallback으로 사용
+ // 우선순위: vendorResponse (벤더 제출 조건) > detailData (구매자 제시 조건)
+
+ const validationErrors: string[] = [];
+
+ // 헤더 필수 필드 검증 및 설정
+ // 1. LIFNR - 벤더 코드
if (!vendorData.vendorCode) {
- console.warn(`⚠️ 벤더 코드가 없습니다. (Vendor ID: ${vendorData.id}) - 빈 값으로 전송합니다.`);
+ validationErrors.push(`❌ 벤더 코드 (Vendor ID: ${vendorData.id})`);
}
- const vendorCode = vendorData.vendorCode || ''; // 빈 값으로 기본값 설정
+ const vendorCode = vendorData.vendorCode || '';
- if (!detailData.paymentTermsCode) {
- console.warn("⚠️ 지급조건(Payment Terms)이 설정되지 않았습니다. - 빈 값으로 전송합니다.");
+ // 2. ANFNR - RFQ 번호
+ const anfnr = rfqData.ANFNR || rfqData.rfqCode;
+ if (!anfnr) {
+ validationErrors.push(`❌ RFQ 번호 (ANFNR 또는 rfqCode)`);
}
- const paymentTermsCode = detailData.paymentTermsCode || ''; // 빈 값으로 기본값 설정
- if (!detailData.incotermsCode) {
- console.warn("⚠️ 인코텀즈(Incoterms)가 설정되지 않았습니다. - 빈 값으로 전송합니다.");
+ // 3. WAERS - 통화 (벤더 견적 통화 우선)
+ const currency = vendorResponse.vendorCurrency || vendorResponse.currency || detailData.currency || params.currency || '';
+ if (!currency) {
+ validationErrors.push(`❌ 통화(Currency) - 벤더 견적 또는 RFQ 조건에 통화가 설정되지 않았습니다.`);
+ }
+
+ // 4. ZTERM - 지급조건 (벤더 제안 조건 우선)
+ const paymentTermsCode = vendorResponse.vendorPaymentTermsCode || detailData.paymentTermsCode || '';
+ if (!paymentTermsCode) {
+ validationErrors.push(`❌ 지급조건(Payment Terms) - 벤더 견적 또는 RFQ 조건에 지급조건이 설정되지 않았습니다.`);
}
- const incotermsCode = detailData.incotermsCode || ''; // 빈 값으로 기본값 설정
- // incoterms 테이블에서 description 조회 (INCO2용)
+ // 5. INCO1 - 인코텀즈 코드 (벤더 제안 조건 우선)
+ const incotermsCode = vendorResponse.vendorIncotermsCode || detailData.incotermsCode || '';
+ if (!incotermsCode) {
+ validationErrors.push(`❌ 인코텀즈 코드(Incoterms) - 벤더 견적 또는 RFQ 조건에 인코텀즈가 설정되지 않았습니다.`);
+ }
+
+ // 6. INCO2 - 인코텀즈 상세 설명 (벤더 제안 조건 우선)
let incotermsDescription = '';
if (incotermsCode) {
- const [incotermsData] = await db
- .select({ description: incoterms.description })
- .from(incoterms)
- .where(eq(incoterms.code, incotermsCode))
- .limit(1);
-
- if (incotermsData?.description) {
- incotermsDescription = incotermsData.description;
+ // 우선순위: 1) 벤더 제출 상세 정보, 2) incoterms 테이블 조회, 3) 구매자 제시 상세 정보
+ if (vendorResponse.vendorIncotermsDetail) {
+ incotermsDescription = vendorResponse.vendorIncotermsDetail;
} else {
- console.warn(`⚠️ 인코텀즈 코드 '${incotermsCode}'에 대한 설명을 찾을 수 없습니다. - detailData.incotermsDetail을 사용합니다.`);
- incotermsDescription = detailData.incotermsDetail || '';
+ const [incotermsData] = await db
+ .select({ description: incoterms.description })
+ .from(incoterms)
+ .where(eq(incoterms.code, incotermsCode))
+ .limit(1);
+
+ if (incotermsData?.description) {
+ incotermsDescription = incotermsData.description;
+ } else if (detailData.incotermsDetail) {
+ incotermsDescription = detailData.incotermsDetail;
+ } else {
+ validationErrors.push(`❌ 인코텀즈 상세 정보(INCO2) - 인코텀즈 코드 '${incotermsCode}'에 대한 설명을 찾을 수 없습니다.`);
+ }
}
- } else {
- console.warn("⚠️ 인코텀즈 상세 정보(INCO2)가 설정되지 않았습니다. - 빈 값으로 전송합니다.");
- incotermsDescription = detailData.incotermsDetail || '';
}
-
- if (!detailData.taxCode) {
- console.warn("⚠️ 세금코드(Tax Code)가 설정되지 않았습니다. - 빈 값으로 전송합니다.");
+
+ // 7. MWSKZ - 세금코드 (벤더 제안 조건 우선)
+ const taxCode = vendorResponse.vendorTaxCode || detailData.taxCode || '';
+ if (!taxCode) {
+ validationErrors.push(`❌ 세금코드(Tax Code) - 벤더 견적 또는 RFQ 조건에 세금코드가 설정되지 않았습니다.`);
}
- const taxCode = detailData.taxCode || ''; // 빈 값으로 기본값 설정
+
+ // PR 아이템 필드를 미리 검증하기 위해 순회
+ prItems.forEach((item, index) => {
+ const itemNum = index + 1;
+
+ if (!item.uom) {
+ validationErrors.push(`❌ PR 아이템 ${itemNum}번 - 단위(UOM)`);
+ }
+
+ const quoteItem = quotationItemMap.get(item.id);
+ if (!quoteItem) {
+ validationErrors.push(`❌ PR 아이템 ${itemNum}번 - 견적 정보 없음 (PR Item ID: ${item.id})`);
+ } else {
+ const unitPrice = Number(quoteItem.unitPrice) || 0;
+ const totalPrice = Number(quoteItem.totalPrice) || 0;
+
+ if (unitPrice <= 0 || totalPrice <= 0) {
+ validationErrors.push(`❌ PR 아이템 ${itemNum}번 - 가격 정보 (단가: ${unitPrice}, 총액: ${totalPrice})`);
+ }
+ }
+ });
- if (!detailData.currency && !params.currency) {
- console.warn("⚠️ 통화(Currency)가 설정되지 않았습니다. - KRW로 기본 설정합니다.");
+ // 검증 에러가 있으면 한 번에 throw
+ if (validationErrors.length > 0) {
+ const errorMessage = `SAP PO 생성을 위한 필수 필드가 누락되었습니다:\n\n${validationErrors.join('\n')}\n\n위 필드들을 모두 입력해주세요.`;
+ console.error("❌ 필수 필드 검증 실패:", errorMessage);
+ throw new Error(errorMessage);
}
- const currency = detailData.currency || params.currency || 'KRW'; // KRW를 기본값으로 설정
- // ANFNR: rfqsLast.ANFNR 우선, 없으면 rfqCode 사용 (ITB, 일반견적은 ANFNR 없으므로..)
- const anfnr = rfqData.ANFNR || rfqData.rfqCode || '';
- if (!anfnr) {
- console.warn("⚠️ RFQ 번호(ANFNR 또는 rfqCode)가 없습니다. - 빈 값으로 전송합니다.");
- }
+ // 검증 완료: 이제 안전하게 사용 가능 (타입 단언)
+ const validAnfnr = anfnr as string;
+ const validVendorCode = vendorCode as string;
+ const validPaymentTermsCode = paymentTermsCode as string;
+ const validIncotermsCode = incotermsCode as string;
+ const validTaxCode = taxCode as string;
// 5. PO 데이터 구성
const poData = {
T_Bidding_HEADER: [{
// 필수 필드
- ANFNR: anfnr,
- LIFNR: vendorCode,
+ ANFNR: validAnfnr,
+ LIFNR: validVendorCode,
ZPROC_IND: '9', // 구매 처리 상태: 9 (기존 로그 기준)
WAERS: currency,
- ZTERM: paymentTermsCode,
- INCO1: incotermsCode,
+ ZTERM: validPaymentTermsCode,
+ INCO1: validIncotermsCode,
INCO2: incotermsDescription.substring(0, 28), // SAP 최대 28자리 제한, incoterms 테이블의 description 사용
- MWSKZ: taxCode,
+ MWSKZ: validTaxCode,
LANDS: vendorCountryCode, // 벤더 국가 코드 사용
ZRCV_DT: getCurrentSAPDate(),
- ZATTEN_IND: 'N', // 참석 여부: N (기본값, 실제 데이터 없음)
+ ZATTEN_IND: 'Y', // 참석 여부: Y (고정값)
IHRAN: getCurrentSAPDate(),
// Optional 필드 (명시적으로 포함 - 유지보수를 위해 구조 유지)
@@ -189,25 +238,18 @@ export async function createPO(params: CreatePOParams) {
LSTEL: '', // Loading Point (데이터 없음)
}],
T_Bidding_ITEM: prItems.map((item, index) => {
- if (!item.uom) {
- console.warn(`⚠️ PR 아이템 ${index + 1}번의 단위(UOM)가 없습니다. - 빈 값으로 전송합니다.`);
- }
-
- // 견적 아이템에서 실제 가격 정보 가져오기
- const quoteItem = quotationItemMap.get(item.id);
- if (!quoteItem) {
- console.warn(`⚠️ PR 아이템 ${item.id}에 대한 견적 정보를 찾을 수 없습니다. - 기본값으로 전송합니다.`);
- }
+ // 견적 아이템에서 실제 가격 정보 가져오기 (이미 검증됨)
+ const quoteItem = quotationItemMap.get(item.id)!; // 검증 통과했으므로 non-null assertion
// 가격 계산: SAP은 소수점을 포함한 문자열 형태로 받음
// DB에서 가져온 값을 명시적으로 숫자로 변환 (문자열이나 Decimal 타입일 수 있음)
- const unitPrice = Number(quoteItem?.unitPrice) || 0;
- const quantity = Number(quoteItem?.quantity || item.quantity) || 0;
- const totalPrice = Number(quoteItem?.totalPrice) || (unitPrice * quantity);
+ const unitPrice = Number(quoteItem.unitPrice);
+ const quantity = Number(quoteItem.quantity || item.quantity);
+ const totalPrice = Number(quoteItem.totalPrice) || (unitPrice * quantity);
// 납기일 계산 (우선순위: 견적 납기일 > PR 납기일 > 현재일자)
let deliveryDate = getCurrentSAPDate();
- if (quoteItem?.vendorDeliveryDate) {
+ if (quoteItem.vendorDeliveryDate) {
deliveryDate = new Date(quoteItem.vendorDeliveryDate).toISOString().split('T')[0].replace(/-/g, '');
} else if (item.deliveryDate) {
deliveryDate = new Date(item.deliveryDate).toISOString().split('T')[0].replace(/-/g, '');
@@ -215,12 +257,12 @@ export async function createPO(params: CreatePOParams) {
return {
// 필수 필드
- ANFNR: anfnr,
+ ANFNR: validAnfnr,
ANFPS: item.prItem || (index + 1).toString().padStart(5, '0'), // PR Item Number 사용
- LIFNR: vendorCode,
+ LIFNR: validVendorCode,
NETPR: unitPrice.toFixed(2), // 단가 (소수점 2자리)
PEINH: '1', // 가격 단위: 1 (표준값, 1단위당 가격)
- BPRME: item.uom || '',
+ BPRME: item.uom!, // 검증 통과했으므로 non-null assertion
NETWR: totalPrice.toFixed(2), // 순액 (세금 제외)
BRTWR: totalPrice.toFixed(2), // 총액: SAP이 taxCode(MWSKZ)로 세금 계산하도록 순액과 동일하게 전송
LFDAT: deliveryDate,
@@ -229,31 +271,28 @@ export async function createPO(params: CreatePOParams) {
ZCON_NO_PO: item.prNo || '', // PR Consolidation Number
EBELP: '', // Series PO Item Seq (시리즈 PO가 아니면 빈 값)
};
- }),
- T_PR_RETURN: prItems.map((item, index) => ({
- // 필수 필드
- ANFNR: anfnr,
- ANFPS: item.prItem || (index + 1).toString().padStart(5, '0'),
- EBELN: item.prNo || rfqData.prNumber || '',
- EBELP: item.prItem || (index + 1).toString().padStart(5, '0'),
- MSGTY: 'S', // Message Type: S (Standard/Success)
-
- // Optional 필드 (명시적으로 포함 - 유지보수를 위해 구조 유지)
- MSGTXT: 'PO Creation from RFQ', // Message Text
- }))
+ })
+ // T_PR_RETURN은 응답용 필드이므로 요청에 포함하지 않음
};
console.log('📤 SAP으로 PO 전송 시작:', {
- ANFNR: anfnr,
- LIFNR: vendorCode,
+ ANFNR: validAnfnr,
+ LIFNR: validVendorCode,
vendorName: vendorData.vendorName,
vendorCountry: vendorCountryCode,
itemCount: prItems.length,
quotationItemCount: quotationItems.length,
totalAmount: params.totalAmount,
currency: currency,
- taxCode: taxCode,
- incoterms: `${incotermsCode} - ${incotermsDescription}`,
+ paymentTerms: validPaymentTermsCode,
+ incoterms: `${validIncotermsCode} - ${incotermsDescription}`,
+ taxCode: validTaxCode,
+ dataSource: {
+ currency: vendorResponse.vendorCurrency ? '벤더 견적' : (detailData.currency ? '구매자 조건' : 'params'),
+ paymentTerms: vendorResponse.vendorPaymentTermsCode ? '벤더 견적' : '구매자 조건',
+ incoterms: vendorResponse.vendorIncotermsCode ? '벤더 견적' : '구매자 조건',
+ taxCode: vendorResponse.vendorTaxCode ? '벤더 견적' : '구매자 조건',
+ }
});
// 디버깅: 전송 데이터 전체 로그 (서버 측 로그이므로 모든 정보 포함)
@@ -269,7 +308,7 @@ export async function createPO(params: CreatePOParams) {
console.log('✅ SAP PO 전송 성공:', sapResult);
// 7. 실제 PO 번호 추출 (SOAP 응답에서 추출하거나 ANFNR 사용)
- const actualPoNumber = sapResult.bidding_number || anfnr;
+ const actualPoNumber = sapResult.bidding_number || validAnfnr;
// 8. DB에 실제 PO 번호 저장 및 RFQ 상태 업데이트
await db.transaction(async (tx) => {
@@ -312,10 +351,30 @@ export async function createPO(params: CreatePOParams) {
};
} catch (error) {
console.error("❌ PO 생성 오류:", error);
- return {
+
+ // 에러 객체에서 추가 정보 추출 (SOAP 응답 포함)
+ const errorResponse: {
+ success: false;
+ error: string;
+ responseData?: string;
+ statusCode?: number;
+ } = {
success: false,
error: error instanceof Error ? error.message : "PO 생성 중 오류가 발생했습니다."
};
+
+ // SOAP 응답 에러인 경우 상세 정보 추가
+ if (error instanceof SoapResponseError) {
+ if (error.responseText) {
+ errorResponse.responseData = error.responseText;
+ console.error("📄 SAP 응답 내용:", error.responseText);
+ }
+ if (error.statusCode) {
+ errorResponse.statusCode = error.statusCode;
+ }
+ }
+
+ return errorResponse;
}
}
diff --git a/lib/soap/ecc/mapper/common-mapper-utils.ts b/lib/soap/ecc/mapper/common-mapper-utils.ts
index 2199490a..ed655e0e 100644
--- a/lib/soap/ecc/mapper/common-mapper-utils.ts
+++ b/lib/soap/ecc/mapper/common-mapper-utils.ts
@@ -307,6 +307,57 @@ export interface ProjectInfo {
}
/**
+ * 사번(PERNR)으로 사용자 정보 조회 함수
+ * PERNR(사번)으로 users 테이블의 employeeNumber와 매칭하여 사용자 정보 조회
+ */
+export async function findUserInfoByPERNR(PERNR: string | null): Promise<{
+ userId: number;
+ userName: string;
+ userEmail: string | null;
+ userPhone: string | null;
+ employeeNumber: string | null;
+} | null> {
+ try {
+ debugLog('사번으로 사용자 조회 시작', { PERNR });
+
+ if (!PERNR) {
+ debugError('PERNR이 null 또는 undefined', { PERNR });
+ return null;
+ }
+
+ const userResult = await db
+ .select({
+ id: users.id,
+ name: users.name,
+ email: users.email,
+ phone: users.phone,
+ employeeNumber: users.employeeNumber
+ })
+ .from(users)
+ .where(eq(users.employeeNumber, PERNR))
+ .limit(1);
+
+ if (userResult.length === 0) {
+ debugError('PERNR에 해당하는 사용자를 찾을 수 없음', { PERNR });
+ return null;
+ }
+
+ const userInfo = {
+ userId: userResult[0].id,
+ userName: userResult[0].name,
+ userEmail: userResult[0].email,
+ userPhone: userResult[0].phone,
+ employeeNumber: userResult[0].employeeNumber
+ };
+ debugSuccess('사번으로 사용자 정보 찾음', { PERNR, userInfo });
+ return userInfo;
+ } catch (error) {
+ debugError('사번으로 사용자 조회 중 오류 발생', { PERNR, error });
+ return null;
+ }
+}
+
+/**
* 협력업체 코드(LIFNR)로 vendorId 찾기
* LIFNR = 벤더코드 (ex. A0001234)
* vendors 테이블의 vendorCode 필드와 비교하여 vendorId를 찾음
diff --git a/lib/soap/ecc/mapper/rfq-and-pr-mapper.ts b/lib/soap/ecc/mapper/rfq-and-pr-mapper.ts
index 85fbb918..d08bc5fb 100644
--- a/lib/soap/ecc/mapper/rfq-and-pr-mapper.ts
+++ b/lib/soap/ecc/mapper/rfq-and-pr-mapper.ts
@@ -24,6 +24,7 @@ import {
findUserInfoByEKGRP,
findProjectInfoByPSPID,
parseSAPDateTime,
+ findUserInfoByPERNR,
} from './common-mapper-utils';
// ECC 데이터 타입 정의
@@ -154,11 +155,13 @@ export async function mapECCRfqHeaderToRfqLast(
const inChargeUserInfo = await findUserInfoByEKGRP(eccHeader.EKGRP || null);
const inChargeUserId = inChargeUserInfo?.userId || null;
- // 대표 PR Item 기반으로 projectId, itemCode, itemName 설정 (없으면 첫번째 PR Item 사용)
+ // 대표 PR Item 기반으로 projectId, itemCode, itemName, 설계담당자 설정 (없으면 첫번째 PR Item 사용)
let projectId: number | null = null;
let itemCode: string | null = null;
let itemName: string | null = null;
let prNumber: string | null = null;
+ let engPicName: string | null = null;
+ let engPicEmployeeNumber: string | null = null;
let representativeItem: ECCBidItem | undefined;
if (firstItem) {
@@ -185,6 +188,15 @@ export async function mapECCRfqHeaderToRfqLast(
// prNumber: 대표 PR의 BANFN 또는 첫번째 PR의 ZREQ_FN 값
prNumber = representativeItem?.BANFN || firstItem.ZREQ_FN || null;
+
+ // 설계담당자 정보: 대표 PR Item의 PERNR로 조회
+ if (targetItem.PERNR) {
+ const engPicInfo = await findUserInfoByPERNR(targetItem.PERNR);
+ if (engPicInfo) {
+ engPicName = engPicInfo.userName;
+ engPicEmployeeNumber = engPicInfo.employeeNumber;
+ }
+ }
}
// 매핑
@@ -199,7 +211,8 @@ export async function mapECCRfqHeaderToRfqLast(
rfqSendDate: null, // ECC에서 제공되지 않음
status: 'RFQ 생성', // 한글 상태로 변경
rfqSealedYn: false,
- EngPicName: null, // ECC에서 제공되지 않음
+ EngPicName: engPicName, // 대표 PR Item의 PERNR로 조회한 설계담당자명
+ EngPicEmployeeNumber: engPicEmployeeNumber, // 대표 PR Item의 PERNR(사번)
packageNo: null, // ECC에서 제공되지 않음
packageName: null, // ECC에서 제공되지 않음
remark: null,
diff --git a/lib/soap/ecc/send/create-po.ts b/lib/soap/ecc/send/create-po.ts
index 0984a208..1e21d39c 100644
--- a/lib/soap/ecc/send/create-po.ts
+++ b/lib/soap/ecc/send/create-po.ts
@@ -44,23 +44,11 @@ export interface POItemData {
EBELP?: string; // Series PO Item Seq
}
-// PR 반환 데이터 타입
-export interface PRReturnData {
- ANFNR: string; // PR Request Number (M)
- ANFPS: string; // Item Number of PR Request (M)
- EBELN: string; // Purchase Requisition Number (M)
- EBELP: string; // Item Number of Purchase Requisition (M)
- MSGTY: string; // Message Type (M)
- MSGTXT?: string; // Message Text
-}
-
// PO 생성 요청 데이터 타입
+// 참고: T_PR_RETURN, EV_ERDAT, EV_ERZET는 응답용 필드이므로 요청에 포함하지 않음
export interface POCreateRequest {
T_Bidding_HEADER: POHeaderData[];
T_Bidding_ITEM: POItemData[];
- T_PR_RETURN: PRReturnData[];
- EV_ERDAT?: string; // Extract Date
- EV_ERZET?: string; // Extract Time
}
@@ -72,10 +60,8 @@ function createPOSoapBodyContent(poData: POCreateRequest): Record<string, unknow
return {
'p1:MT_P2MM3015_S': { // WSDL에서 사용하는 p1 접두사 적용
'T_Bidding_HEADER': poData.T_Bidding_HEADER,
- 'T_Bidding_ITEM': poData.T_Bidding_ITEM,
- 'T_PR_RETURN': poData.T_PR_RETURN,
- ...(poData.EV_ERDAT && { 'EV_ERDAT': poData.EV_ERDAT }),
- ...(poData.EV_ERZET && { 'EV_ERZET': poData.EV_ERZET })
+ 'T_Bidding_ITEM': poData.T_Bidding_ITEM
+ // T_PR_RETURN, EV_ERDAT, EV_ERZET는 응답용 필드이므로 요청에 포함하지 않음
}
};
}
@@ -112,19 +98,7 @@ function validatePOData(poData: POCreateRequest): { isValid: boolean; errors: st
});
}
- // PR 반환 데이터 검증
- if (!poData.T_PR_RETURN || poData.T_PR_RETURN.length === 0) {
- errors.push('T_PR_RETURN은 필수입니다.');
- } else {
- poData.T_PR_RETURN.forEach((prReturn, index) => {
- const requiredFields = ['ANFNR', 'ANFPS', 'EBELN', 'EBELP', 'MSGTY'];
- requiredFields.forEach(field => {
- if (!prReturn[field as keyof PRReturnData]) {
- errors.push(`T_PR_RETURN[${index}].${field}는 필수입니다.`);
- }
- });
- });
- }
+ // T_PR_RETURN은 응답용 필드이므로 검증하지 않음
return {
isValid: errors.length === 0,
@@ -165,7 +139,7 @@ async function sendPOToECC(poData: POCreateRequest): Promise<SoapSendResult> {
};
console.log(`📤 PO 생성 요청 전송 시작 - ANFNR: ${poData.T_Bidding_HEADER[0]?.ANFNR}`);
- console.log(`🔍 헤더 ${poData.T_Bidding_HEADER.length}개, 아이템 ${poData.T_Bidding_ITEM.length}개, PR 반환 ${poData.T_PR_RETURN.length}개`);
+ console.log(`🔍 헤더 ${poData.T_Bidding_HEADER.length}개, 아이템 ${poData.T_Bidding_ITEM.length}개`);
// SOAP XML 전송
const result = await sendSoapXml(config, logInfo);
@@ -341,17 +315,8 @@ export async function createTestPurchaseOrder(): Promise<{
LFDAT: getCurrentSAPDate(),
ZCON_NO_PO: 'CON001',
EBELP: '00001'
- }],
- T_PR_RETURN: [{
- ANFNR: 'TEST001',
- ANFPS: '00001',
- EBELN: 'PR001',
- EBELP: '00001',
- MSGTY: 'S',
- MSGTXT: 'Test message'
- }],
- EV_ERDAT: getCurrentSAPDate(),
- EV_ERZET: getCurrentSAPTime()
+ }]
+ // T_PR_RETURN, EV_ERDAT, EV_ERZET는 응답용 필드이므로 요청에 포함하지 않음
};
const result = await sendPOToECC(testPOData);
diff --git a/lib/soap/sender.ts b/lib/soap/sender.ts
index d12665cb..c0be780d 100644
--- a/lib/soap/sender.ts
+++ b/lib/soap/sender.ts
@@ -3,43 +3,13 @@
import { withSoapLogging } from "@/lib/soap/utils";
import { XMLBuilder } from 'fast-xml-parser';
import { debugLog, debugError, debugWarn, debugSuccess } from '@/lib/debug-utils';
-
-// 기본 인증 정보 타입
-export interface SoapAuthConfig {
- username?: string;
- password?: string;
-}
-
-// SOAP 전송 설정 타입
-export interface SoapSendConfig {
- endpoint: string;
- envelope: Record<string, unknown>;
- soapAction?: string;
- timeout?: number;
- retryCount?: number;
- retryDelay?: number;
- namespace?: string; // 네임스페이스를 동적으로 설정할 수 있도록 추가
- prefix: string; // 네임스페이스 접두사 (필수)
-}
-
-// 로깅 정보 타입
-export interface SoapLogInfo {
- direction: 'INBOUND' | 'OUTBOUND';
- system: string;
- interface: string;
-}
-
-// 전송 결과 타입
-export interface SoapSendResult {
- success: boolean;
- message: string;
- responseText?: string;
- statusCode?: number;
- headers?: Record<string, string>;
- endpoint?: string;
- requestXml?: string;
- requestHeaders?: Record<string, string>;
-}
+import type {
+ SoapAuthConfig,
+ SoapSendConfig,
+ SoapLogInfo,
+ SoapSendResult
+} from './types';
+import { SoapResponseError } from './types';
// 기본 환경변수에서 인증 정보 가져오기
function getDefaultAuth(): SoapAuthConfig {
@@ -154,6 +124,7 @@ export async function sendSoapXml(
}
}
+ let responseText = '';
const result = await withSoapLogging(
logInfo.direction,
logInfo.system,
@@ -161,17 +132,17 @@ export async function sendSoapXml(
xmlData,
async () => {
// 타임아웃 설정
+ let response: Response;
if (config.timeout) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), config.timeout);
try {
- const response = await fetch(config.endpoint, {
+ response = await fetch(config.endpoint, {
...fetchOptions,
signal: controller.signal
});
clearTimeout(timeoutId);
- return response;
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') {
@@ -180,14 +151,20 @@ export async function sendSoapXml(
throw error;
}
} else {
- return await fetch(config.endpoint, fetchOptions);
+ response = await fetch(config.endpoint, fetchOptions);
}
- }
+
+ // 응답 텍스트 읽기 (로깅을 위해 여기서 읽음)
+ responseText = await response.text();
+
+ return response;
+ },
+ // 응답 데이터 추출 함수: responseText를 반환
+ () => responseText
);
// 응답 처리
const response = result as Response;
- const responseText = await response.text();
// 응답 헤더 수집 (디버깅용)
const responseHeadersDebug: Record<string, string> = {};
@@ -203,22 +180,52 @@ export async function sendSoapXml(
});
debugLog('🔍 응답 바디 (전체):', responseText);
- // HTTP 상태 코드가 비정상이거나 SOAP Fault 포함 시 실패로 처리하되 본문을 그대로 반환
- if (!response.ok || responseText.includes('soap:Fault') || responseText.includes('SOAP:Fault')) {
+ // SAP 응답 에러 체크: <MSGTY>E</MSGTY> 또는 <EV_TYPE>E</EV_TYPE> 패턴 감지
+ const hasSapError = responseText.includes('<MSGTY>E</MSGTY>') ||
+ responseText.includes('<EV_TYPE>E</EV_TYPE>');
+
+ // HTTP 상태 코드가 비정상이거나 SOAP Fault 또는 SAP 에러 포함 시 실패로 처리
+ if (!response.ok ||
+ responseText.includes('soap:Fault') ||
+ responseText.includes('SOAP:Fault') ||
+ hasSapError) {
const responseHeaders: Record<string, string> = {};
response.headers.forEach((value, key) => {
responseHeaders[key] = value;
});
- return {
- success: false,
- message: !response.ok ? `HTTP ${response.status}: ${response.statusText}` : 'SOAP Fault',
+
+ // 에러 메시지 결정 및 상세 메시지 추출
+ let errorMessage = '';
+ if (!response.ok) {
+ errorMessage = `HTTP ${response.status}: ${response.statusText}`;
+ } else if (hasSapError) {
+ // SAP 응답에서 에러 메시지 추출 시도
+ const msgtxtMatch = responseText.match(/<MSGTXT>(.*?)<\/MSGTXT>/);
+ const evMessageMatch = responseText.match(/<EV_MESSAGE>(.*?)<\/EV_MESSAGE>/);
+ const detailMessage = msgtxtMatch?.[1] || evMessageMatch?.[1] || '';
+
+ errorMessage = 'SAP 응답 에러: MSGTY=E 또는 EV_TYPE=E 감지';
+ if (detailMessage) {
+ errorMessage += ` - ${detailMessage}`;
+ }
+ } else {
+ // SOAP Fault에서 메시지 추출 시도
+ const faultStringMatch = responseText.match(/<faultstring>(.*?)<\/faultstring>/);
+ const faultMessage = faultStringMatch?.[1] || 'SOAP Fault';
+ errorMessage = faultMessage;
+ }
+
+ debugError('❌ SOAP 응답 에러 감지:', errorMessage);
+
+ // 커스텀 에러 객체 생성 (responseText 포함)
+ throw new SoapResponseError(errorMessage, {
responseText,
statusCode: response.status,
headers: responseHeaders,
endpoint: config.endpoint,
requestXml: xmlData,
requestHeaders
- };
+ });
}
// 응답 헤더 수집
diff --git a/lib/soap/types.ts b/lib/soap/types.ts
new file mode 100644
index 00000000..dac8f83b
--- /dev/null
+++ b/lib/soap/types.ts
@@ -0,0 +1,64 @@
+// SOAP 관련 타입 정의
+
+// 기본 인증 정보 타입
+export interface SoapAuthConfig {
+ username?: string;
+ password?: string;
+}
+
+// SOAP 전송 설정 타입
+export interface SoapSendConfig {
+ endpoint: string;
+ envelope: Record<string, unknown>;
+ soapAction?: string;
+ timeout?: number;
+ retryCount?: number;
+ retryDelay?: number;
+ namespace?: string; // 네임스페이스를 동적으로 설정할 수 있도록 추가
+ prefix: string; // 네임스페이스 접두사 (필수)
+}
+
+// 로깅 정보 타입
+export interface SoapLogInfo {
+ direction: 'INBOUND' | 'OUTBOUND';
+ system: string;
+ interface: string;
+}
+
+// 전송 결과 타입
+export interface SoapSendResult {
+ success: boolean;
+ message: string;
+ responseText?: string;
+ statusCode?: number;
+ headers?: Record<string, string>;
+ endpoint?: string;
+ requestXml?: string;
+ requestHeaders?: Record<string, string>;
+}
+
+// SOAP 에러 타입 (응답 정보 포함)
+export class SoapResponseError extends Error {
+ responseText?: string;
+ statusCode?: number;
+ headers?: Record<string, string>;
+ endpoint?: string;
+ requestXml?: string;
+ requestHeaders?: Record<string, string>;
+
+ constructor(message: string, details?: {
+ responseText?: string;
+ statusCode?: number;
+ headers?: Record<string, string>;
+ endpoint?: string;
+ requestXml?: string;
+ requestHeaders?: Record<string, string>;
+ }) {
+ super(message);
+ this.name = 'SoapResponseError';
+ if (details) {
+ Object.assign(this, details);
+ }
+ }
+}
+
diff --git a/lib/soap/utils.ts b/lib/soap/utils.ts
index 57e3b280..809dd46d 100644
--- a/lib/soap/utils.ts
+++ b/lib/soap/utils.ts
@@ -466,6 +466,7 @@ export async function cleanupOldSoapLogs(): Promise<void> {
* @param interfaceName 인터페이스명
* @param requestData 요청 데이터
* @param processor 실제 비즈니스 로직 함수
+ * @param extractResponse 응답 데이터 추출 함수 (선택사항)
* @returns 처리 결과
*/
export async function withSoapLogging<T>(
@@ -473,7 +474,8 @@ export async function withSoapLogging<T>(
system: string,
interfaceName: string,
requestData: string,
- processor: () => Promise<T>
+ processor: () => Promise<T>,
+ extractResponse?: (result: T) => string | undefined
): Promise<T> {
let logId: number | null = null;
@@ -484,10 +486,16 @@ export async function withSoapLogging<T>(
// 2. 실제 처리 실행
const result = await processor();
- // 3. 성공 로그 완료
- await completeSoapLog(logId, true);
+ // 3. 응답 데이터 추출 (제공된 경우)
+ let responseData: string | undefined;
+ if (extractResponse) {
+ responseData = extractResponse(result);
+ }
+
+ // 4. 성공 로그 완료 (응답 데이터 포함)
+ await completeSoapLog(logId, true, responseData);
- // 4. 로그 정리 (백그라운드)
+ // 5. 로그 정리 (백그라운드)
cleanupOldSoapLogs().catch(error =>
console.error('백그라운드 로그 정리 실패:', error)
);
@@ -495,12 +503,18 @@ export async function withSoapLogging<T>(
return result;
} catch (error) {
- // 5. 실패 로그 완료
+ // 6. 실패 로그 완료
if (logId !== null) {
+ // 에러 객체에 응답 데이터가 포함되어 있는지 확인
+ let errorResponseData: string | undefined;
+ if (error && typeof error === 'object' && 'responseText' in error) {
+ errorResponseData = (error as { responseText?: string }).responseText;
+ }
+
await completeSoapLog(
logId,
false,
- undefined,
+ errorResponseData,
error instanceof Error ? error.message : 'Unknown error'
);
}
diff --git a/lib/swp/table/swp-document-detail-dialog.tsx b/lib/swp/table/swp-document-detail-dialog.tsx
index a87cd0e2..77ef77f7 100644
--- a/lib/swp/table/swp-document-detail-dialog.tsx
+++ b/lib/swp/table/swp-document-detail-dialog.tsx
@@ -192,85 +192,97 @@ export function SwpDocumentDetailDialog({
}
};
- // Revision별로 Activity 그룹핑 (rowspan용)
+ // Revision별로 Activity 그룹핑 및 정렬 (rowspan용)
const groupedActivities = useMemo(() => {
+ // 1. REV 내림차순, createDate 내림차순으로 정렬
+ const sortedActivities = [...activities].sort((a, b) => {
+ // REV 비교 (내림차순)
+ const revCompare = b.revNo.localeCompare(a.revNo);
+ if (revCompare !== 0) return revCompare;
+
+ // 같은 REV 내에서는 createDate 내림차순
+ return b.createDate.localeCompare(a.createDate);
+ });
+
+ // 2. 그룹핑
const groups: Map<string, ActivityRow[]> = new Map();
- activities.forEach((activity) => {
+ sortedActivities.forEach((activity) => {
const key = `${activity.revNo}|${activity.stage}`;
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key)!.push(activity);
});
+
return groups;
}, [activities]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-[95vw] max-h-[80vh] overflow-hidden flex flex-col">
+ <DialogContent className="max-w-[95vw] h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
- <DialogTitle>문서 리비전 히스토리</DialogTitle>
+ <DialogTitle className="text-base">문서 리비전 히스토리</DialogTitle>
{document && (
- <DialogDescription>
+ <DialogDescription className="text-xs">
{document.DOC_NO} - {document.DOC_TITLE}
</DialogDescription>
)}
</DialogHeader>
{document && (
- <div className="flex-1 flex flex-col space-y-4 overflow-hidden min-h-0">
+ <div className="flex-1 flex flex-col space-y-2 overflow-hidden">
{/* 문서 정보 */}
- <div className="grid grid-cols-1 md:grid-cols-5 gap-4 p-4 bg-muted/30 rounded-lg">
- <div>
- <span className="text-sm font-semibold">프로젝트:</span>
- <div className="text-sm">{document.PROJ_NO}</div>
+ <div className="flex items-center gap-4 px-3 py-2 bg-muted/30 rounded text-xs">
+ <div className="flex items-center gap-1">
+ <span className="font-semibold">프로젝트:</span>
+ <span>{document.PROJ_NO}</span>
{document.PROJ_NM && (
- <div className="text-xs text-muted-foreground">{document.PROJ_NM}</div>
+ <span className="text-muted-foreground">({document.PROJ_NM})</span>
)}
</div>
- <div>
- <span className="text-sm font-semibold">패키지:</span>
- <div className="text-sm">{document.PKG_NO || "-"}</div>
+ <div className="flex items-center gap-1">
+ <span className="font-semibold">패키지:</span>
+ <span>{document.PKG_NO || "-"}</span>
</div>
- <div>
- <span className="text-sm font-semibold">업체:</span>
- <div className="text-sm">{document.CPY_NM || "-"}</div>
+ <div className="flex items-center gap-1">
+ <span className="font-semibold">업체:</span>
+ <span>{document.CPY_NM || "-"}</span>
{document.VNDR_CD && (
- <div className="text-xs text-muted-foreground">{document.VNDR_CD}</div>
+ <span className="text-muted-foreground">({document.VNDR_CD})</span>
)}
</div>
- <div>
- <span className="text-sm font-semibold">최신 리비전:</span>
- <div className="text-sm">{document.LTST_REV_NO || "-"}</div>
+ <div className="flex items-center gap-1">
+ <span className="font-semibold">최신 리비전:</span>
+ <span>{document.LTST_REV_NO || "-"}</span>
</div>
- <div>
- <span className="text-sm font-semibold">총 Activity:</span>
- <div className="text-sm">{activities.length}개</div>
+ <div className="flex items-center gap-1">
+ <span className="font-semibold">총 Activity:</span>
+ <span>{activities.length}개</span>
</div>
</div>
{/* Activity 테이블 */}
{isLoading ? (
<div className="flex items-center justify-center p-8">
- <Loader2 className="h-6 w-6 animate-spin" />
- <span className="ml-2">리비전 트리 로딩 중...</span>
+ <Loader2 className="h-5 w-5 animate-spin" />
+ <span className="ml-2 text-sm">리비전 트리 로딩 중...</span>
</div>
) : activities.length > 0 ? (
<>
{/* Activity 테이블 (위) */}
- <div className="flex-1 overflow-auto border rounded-lg min-h-0">
+ <div className="h-[40vh] overflow-auto border rounded-lg">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
- <TableRow>
- <TableHead className="w-[80px]">Rev</TableHead>
- <TableHead className="w-[80px]">Stage</TableHead>
- <TableHead className="w-[80px]">IN/OUT</TableHead>
- <TableHead className="w-[100px]">Status</TableHead>
- <TableHead className="min-w-[150px]">Transmittal No</TableHead>
- <TableHead className="min-w-[150px]">Activity No</TableHead>
- <TableHead className="min-w-[100px]">Ref Activity</TableHead>
- <TableHead className="w-[120px]">Modified</TableHead>
- <TableHead className="w-[80px]">By</TableHead>
+ <TableRow className="text-xs">
+ <TableHead className="w-[70px] text-xs h-8">Rev</TableHead>
+ <TableHead className="w-[70px] text-xs h-8">Stage</TableHead>
+ <TableHead className="w-[70px] text-xs h-8">IN/OUT</TableHead>
+ <TableHead className="w-[90px] text-xs h-8">Status</TableHead>
+ <TableHead className="min-w-[130px] text-xs h-8">Transmittal No</TableHead>
+ <TableHead className="min-w-[130px] text-xs h-8">Activity No</TableHead>
+ <TableHead className="min-w-[90px] text-xs h-8">Ref Activity</TableHead>
+ <TableHead className="w-[100px] text-xs h-8">Modified</TableHead>
+ <TableHead className="w-[70px] text-xs h-8">By</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -280,7 +292,7 @@ export function SwpDocumentDetailDialog({
<TableRow
key={activity.actvNo}
className={cn(
- "cursor-pointer hover:bg-muted/50",
+ "cursor-pointer hover:bg-muted/50 h-8",
selectedActivity?.actvNo === activity.actvNo &&
"bg-blue-50 hover:bg-blue-100"
)}
@@ -289,7 +301,7 @@ export function SwpDocumentDetailDialog({
{/* Rev 컬럼 (첫 행만 표시, rowspan) */}
{idx === 0 && (
<TableCell
- className="font-mono text-sm font-semibold align-top border-r"
+ className="font-mono text-xs font-semibold align-top border-r py-1"
rowSpan={groupActivities.length}
>
{revNo}
@@ -298,45 +310,41 @@ export function SwpDocumentDetailDialog({
{/* Stage 컬럼 (첫 행만 표시, rowspan) */}
{idx === 0 && (
<TableCell
- className="align-top border-r text-sm"
+ className="align-top border-r text-xs py-1"
rowSpan={groupActivities.length}
>
{stage}
</TableCell>
)}
- <TableCell>
+ <TableCell className="py-1">
<Badge
variant="outline"
- className={
+ className={cn(
+ "text-[10px] h-4 px-1",
activity.inOut === "IN"
? "bg-blue-100 text-blue-800"
: "bg-green-100 text-green-800"
- }
+ )}
>
{activity.inOut}
</Badge>
</TableCell>
- <TableCell>
- <div className="text-sm">
- <div className="font-medium">{activity.statusName}</div>
- <div className="text-xs text-muted-foreground">
- {activity.statusCode}
- </div>
- </div>
+ <TableCell className="text-xs py-1">
+ <div className="font-medium">{activity.statusName}</div>
</TableCell>
- <TableCell className="text-sm">
+ <TableCell className="text-xs py-1">
{activity.transmittalNo || "-"}
</TableCell>
- <TableCell className="font-mono text-xs">
+ <TableCell className="font-mono text-xs py-1">
{activity.actvNo}
</TableCell>
- <TableCell className="font-mono text-xs">
+ <TableCell className="font-mono text-xs py-1">
{activity.refActivityNo || "-"}
</TableCell>
- <TableCell className="text-xs">
+ <TableCell className="text-xs py-1">
{formatSwpDateShort(activity.createDate)}
</TableCell>
- <TableCell className="text-xs">
+ <TableCell className="text-xs py-1">
{activity.createEmpNo}
</TableCell>
</TableRow>
@@ -347,57 +355,53 @@ export function SwpDocumentDetailDialog({
</div>
{/* 파일 목록 (아래) */}
- <div className="border rounded-lg overflow-hidden" style={{ height: "250px" }}>
- <div className="p-3 bg-muted/50 border-b">
- <h3 className="font-semibold text-sm">파일 목록</h3>
+ <div className="border rounded-lg overflow-hidden h-[30vh] flex flex-col">
+ <div className="px-3 py-1.5 bg-muted/50 border-b flex-shrink-0">
+ <h3 className="font-semibold text-xs">파일 목록</h3>
{selectedActivity ? (
- <>
- <p className="text-xs text-muted-foreground mt-1">
- Activity: {selectedActivity.actvNo}
- </p>
- <p className="text-xs text-muted-foreground">
- Rev {selectedActivity.revNo} ({selectedActivity.stage}) / {selectedActivity.inOut}
- </p>
- </>
+ <p className="text-[10px] text-muted-foreground mt-0.5">
+ Activity: {selectedActivity.actvNo} / Rev {selectedActivity.revNo} ({selectedActivity.stage}) / {selectedActivity.inOut}
+ </p>
) : (
- <p className="text-xs text-muted-foreground mt-1">
+ <p className="text-[10px] text-muted-foreground mt-0.5">
Activity를 선택하면 파일 목록이 표시됩니다
</p>
)}
</div>
- <div className="overflow-auto p-3" style={{ height: "calc(250px - 80px)" }}>
+ <div className="overflow-auto flex-1">
{selectedActivity ? (
isLoadingFiles ? (
<div className="flex items-center justify-center h-full">
- <Loader2 className="h-5 w-5 animate-spin" />
- <span className="ml-2 text-sm">파일 로딩 중...</span>
+ <Loader2 className="h-4 w-4 animate-spin" />
+ <span className="ml-2 text-xs">파일 로딩 중...</span>
</div>
) : activityFiles.length > 0 ? (
<Table>
- <TableHeader>
+ <TableHeader className="sticky top-0 bg-background">
<TableRow>
- <TableHead className="min-w-[200px]">파일명</TableHead>
- <TableHead className="w-[100px]">크기</TableHead>
- <TableHead className="w-[120px]">날짜</TableHead>
- <TableHead className="w-[100px]">다운로드</TableHead>
+ <TableHead className="min-w-[200px] text-xs h-8">파일명</TableHead>
+ <TableHead className="w-[90px] text-xs h-8">크기</TableHead>
+ <TableHead className="w-[100px] text-xs h-8">날짜</TableHead>
+ <TableHead className="w-[90px] text-xs h-8">다운로드</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{activityFiles.map((file) => (
- <TableRow key={file.FILE_SEQ}>
- <TableCell className="font-medium">
+ <TableRow key={file.FILE_SEQ} className="h-8">
+ <TableCell className="font-medium text-xs py-1">
{file.FILE_NM}
</TableCell>
- <TableCell className="text-sm text-muted-foreground">
+ <TableCell className="text-xs text-muted-foreground py-1">
{file.FILE_SZ ? formatFileSize(file.FILE_SZ) : "-"}
</TableCell>
- <TableCell className="text-sm text-muted-foreground">
+ <TableCell className="text-xs text-muted-foreground py-1">
{file.CRTE_DTM ? formatSwpDateShort(file.CRTE_DTM) : "-"}
</TableCell>
- <TableCell>
+ <TableCell className="py-1">
<Button
variant="outline"
size="sm"
+ className="h-6 px-2 text-[10px]"
onClick={() => handleDownloadFile(file.FILE_NM, document.OWN_DOC_NO || document.DOC_NO)}
>
<Download className="h-3 w-3 mr-1" />
@@ -409,17 +413,17 @@ export function SwpDocumentDetailDialog({
</TableBody>
</Table>
) : (
- <div className="flex items-center justify-center h-full text-sm text-muted-foreground">
+ <div className="flex items-center justify-center h-full text-xs text-muted-foreground">
<div className="text-center">
- <AlertCircle className="h-8 w-8 mx-auto mb-2 opacity-50" />
+ <AlertCircle className="h-6 w-6 mx-auto mb-1 opacity-50" />
<p>파일이 없습니다</p>
</div>
</div>
)
) : (
- <div className="flex items-center justify-center h-full text-sm text-muted-foreground">
+ <div className="flex items-center justify-center h-full text-xs text-muted-foreground">
<div className="text-center">
- <FileIcon className="h-12 w-12 mx-auto mb-2 opacity-30" />
+ <FileIcon className="h-8 w-8 mx-auto mb-1 opacity-30" />
<p>Activity를 선택해주세요</p>
</div>
</div>
@@ -429,8 +433,8 @@ export function SwpDocumentDetailDialog({
</>
) : (
<div className="p-8 text-center text-muted-foreground">
- <AlertCircle className="h-12 w-12 mx-auto mb-2 opacity-50" />
- <p>Activity 정보가 없습니다</p>
+ <AlertCircle className="h-10 w-10 mx-auto mb-2 opacity-50" />
+ <p className="text-sm">Activity 정보가 없습니다</p>
</div>
)}
</div>
diff --git a/lib/swp/table/swp-inbox-table.tsx b/lib/swp/table/swp-inbox-table.tsx
index 633191a1..430447f4 100644
--- a/lib/swp/table/swp-inbox-table.tsx
+++ b/lib/swp/table/swp-inbox-table.tsx
@@ -181,6 +181,10 @@ export function SwpInboxTable({
docGroups.forEach((docFiles, docNo) => {
const totalDocFileCount = docFiles.length;
let isFirstInDoc = true;
+
+ // Document의 첫 번째 파일에서 PKG_NO 가져오기
+ const firstDocFile = docFiles[0];
+ const docPkgNo = firstDocFile?.PKG_NO || null;
// 3단계: ACTV_SEQ 기준으로 그룹화 (최신 Rev 필터링 제거)
const activityGroups = new Map<string, SwpFileApiResponse[]>();
@@ -218,7 +222,7 @@ export function SwpInboxTable({
actvNo: file.ACTV_NO || null,
crter: firstActivityFile.CRTER, // Activity 첫 파일의 CRTER
note1: firstActivityFile.NOTE1 || null, // Activity 첫 파일의 DC Note
- pkgNo: file.PKG_NO || null,
+ pkgNo: docPkgNo, // Document 레벨의 PKG_NO
file,
uploadDate: file.CRTE_DTM,
isFirstInUpload,
@@ -472,13 +476,13 @@ export function SwpInboxTable({
</TableHead>
<TableHead className="w-[100px]">Upload ID</TableHead>
<TableHead className="w-[200px]">Document No</TableHead>
+ <TableHead className="w-[120px]">PKG NO</TableHead>
<TableHead className="w-[80px]">Rev No</TableHead>
<TableHead className="w-[80px]">Stage</TableHead>
<TableHead className="w-[120px]">Status</TableHead>
<TableHead className="w-[100px]">Activity</TableHead>
<TableHead className="w-[120px]">Upload ID (User)</TableHead>
<TableHead className="w-[150px]">DC Note</TableHead>
- <TableHead className="w-[120px]">PKG NO</TableHead>
<TableHead className="w-[400px]">Attachment File</TableHead>
<TableHead className="w-[180px]">Upload Date</TableHead>
</TableRow>
@@ -519,6 +523,13 @@ export function SwpInboxTable({
</TableCell>
) : null}
+ {/* PKG NO - Document의 첫 파일에만 표시 */}
+ {row.isFirstInDoc ? (
+ <TableCell rowSpan={row.fileCountInDoc} className="font-mono text-sm align-top" style={{ verticalAlign: "top" }}>
+ {row.pkgNo || <span className="text-muted-foreground">-</span>}
+ </TableCell>
+ ) : null}
+
{/* Rev No - Rev의 첫 파일에만 표시 */}
{row.isFirstInRev ? (
<TableCell rowSpan={row.fileCountInRev} className="align-top" style={{ verticalAlign: "top" }}>
@@ -578,11 +589,6 @@ export function SwpInboxTable({
</TableCell>
) : null}
- {/* PKG NO - 각 파일마다 표시 */}
- <TableCell className="font-mono text-sm">
- {row.pkgNo || <span className="text-muted-foreground">-</span>}
- </TableCell>
-
{/* Attachment File - 각 파일마다 표시 (줄바꿈 허용) */}
<TableCell className="max-w-[400px]">
{row.file ? (
diff --git a/lib/vendor-document-list/dolce-upload-service.ts b/lib/vendor-document-list/dolce-upload-service.ts
index 35058a65..8c66f234 100644
--- a/lib/vendor-document-list/dolce-upload-service.ts
+++ b/lib/vendor-document-list/dolce-upload-service.ts
@@ -1,9 +1,8 @@
// lib/vendor-document-list/dolce-upload-service.ts
import db from "@/db/db"
import { documents, revisions, documentAttachments, contracts, projects, vendors, issueStages } from "@/db/schema"
-import { eq, and, desc, sql, inArray, min } from "drizzle-orm"
+import { eq, and, inArray, min } from "drizzle-orm"
import { v4 as uuidv4 } from "uuid"
-import path from "path"
import * as crypto from "crypto"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/app/api/auth/[...nextauth]/route"
@@ -59,13 +58,65 @@ interface DOLCEDocument {
RegCompanyCode: string
}
+interface RevisionAttachment {
+ id: number
+ uploadId: string | null
+ fileId: string | null
+ fileName: string
+ filePath: string
+ fileType: string | null
+ fileSize: number | null
+ createdAt: Date
+}
+
+interface RevisionWithAttachments {
+ id: number
+ registerId: string | number | null
+ revision: string
+ revisionStatus: string | null
+ uploaderId: string | number | null
+ uploaderName: string | null
+ submittedDate: string | null
+ comment: string | null
+ usage: string | null
+ usageType: string | null
+ externalUploadId: string | null
+ externalRegisterId: number
+ externalSentAt: string | null
+ serialNo: number | null
+ issueStageId: number
+ stageName: string | null
+ documentId: number
+ documentNo: string
+ documentName: string
+ drawingKind: string | null
+ drawingMoveGbn: string | null
+ discipline: string | null
+ registerGroupId: number | null
+ category?: string | null
+ cGbn: string | null
+ dGbn: string | null
+ degreeGbn: string | null
+ deptGbn: string | null
+ jGbn: string | null
+ sGbn: string | null
+ manager: string | null
+ managerENM: string | null
+ managerNo: string | null
+ shiDrawingNo: string | null
+ externalDocumentId: string | null
+ externalSystemType: string | null
+ externalSyncedAt: Date | null
+ attachments: RevisionAttachment[]
+}
+
interface DOLCEFileMapping {
- CGbn?: string
- Category?: string
+ CGbn?: string | null
+ Category?: string | null
CheckBox: string
- DGbn?: string
- DegreeGbn?: string
- DeptGbn?: string
+ DGbn?: string | null
+ DegreeGbn?: string | null
+ DeptGbn?: string | null
Discipline: string
DrawingKind: string
DrawingMoveGbn: string
@@ -73,7 +124,7 @@ interface DOLCEFileMapping {
DrawingNo: string
DrawingUsage: string
FileNm: string
- JGbn?: string
+ JGbn?: string | null
Manager: string
MappingYN: string
NewOrNot: string
@@ -82,8 +133,8 @@ interface DOLCEFileMapping {
RegisterGroupId: number
RegisterKindCode: string
RegisterSerialNo: number
- RevNo?: string
- SGbn?: string
+ RevNo?: string | null
+ SGbn?: string | null
UploadId: string
}
@@ -115,19 +166,24 @@ class DOLCEUploadService {
async uploadToDoLCE(
projectId: number,
revisionIds: number[],
- userId: string,
- userName?: string
+ userId: string
): Promise<DOLCEUploadResult> {
try {
console.log(`Starting DOLCE upload for contract ${projectId}, revisions: ${revisionIds.join(', ')}`)
- // 1. 계약 정보 조회 (프로젝트 코드, 벤더 코드 등)
+ // 1. 사용자 정보 조회 (DOLCE API에 필요한 정보)
+ const userInfo = await this.getUserInfo(userId)
+ if (!userInfo) {
+ throw new Error(`User info not found for ID: ${userId}`)
+ }
+
+ // 2. 계약 정보 조회 (프로젝트 코드, 벤더 코드 등)
const contractInfo = await this.getContractInfo(projectId)
if (!contractInfo) {
throw new Error(`Contract info not found for ID: ${projectId}`)
}
- // 2. 업로드할 리비전 정보 조회
+ // 3. 업로드할 리비전 정보 조회
const revisionsToUpload = await this.getRevisionsForUpload(revisionIds)
if (revisionsToUpload.length === 0) {
return {
@@ -138,7 +194,7 @@ class DOLCEUploadService {
}
let uploadedDocuments = 0
- let uploadedFiles = 0
+ const uploadedFiles = 0
const errors: string[] = []
const results: any = {
documentResults: [],
@@ -146,19 +202,19 @@ class DOLCEUploadService {
mappingResults: []
}
- // 3. 각 리비전별로 처리
+ // 4. 각 리비전별로 처리
for (const revision of revisionsToUpload) {
try {
console.log(`Processing revision ${revision.revision} for document ${revision.documentNo}`)
- // 3-1. UploadId 미리 생성 (파일이 있는 경우에만)
+ // 4-1. UploadId 미리 생성 (파일이 있는 경우에만)
let uploadId: string | undefined
if (revision.attachments && revision.attachments.length > 0) {
uploadId = uuidv4() // 문서 업로드 시 사용할 UploadId 미리 생성
console.log(`Generated UploadId for document upload: ${uploadId}`)
}
- // 3-2. 문서 정보 업로드 (UploadId 포함)
+ // 4-2. 문서 정보 업로드 (UploadId 포함)
const dolceDoc = this.transformToDoLCEDocument(
revision,
contractInfo,
@@ -166,7 +222,13 @@ class DOLCEUploadService {
contractInfo.vendorCode,
)
- const docResult = await this.uploadDocument([dolceDoc], userId)
+ const docResult = await this.uploadDocument(
+ [dolceDoc],
+ userInfo.userId,
+ userInfo.userName,
+ userInfo.vendorCode,
+ userInfo.userEmail
+ )
if (!docResult.success) {
errors.push(`Document upload failed for ${revision.documentNo}: ${docResult.error}`)
continue // 문서 업로드 실패 시 다음 리비전으로 넘어감
@@ -176,11 +238,11 @@ class DOLCEUploadService {
results.documentResults.push(docResult)
console.log(`✅ Document uploaded successfully: ${revision.documentNo}`)
- // 3-3. 파일 업로드 (이미 생성된 UploadId 사용)
+ // 4-3. 파일 업로드 (이미 생성된 UploadId 사용)
if (uploadId && revision.attachments && revision.attachments.length > 0) {
try {
// 파일 업로드 시 이미 생성된 UploadId 사용
- const fileUploadResults = await this.uploadFiles(
+ await this.uploadFiles(
revision.attachments,
userId,
uploadId // 이미 생성된 UploadId 전달
@@ -192,7 +254,7 @@ class DOLCEUploadService {
}
}
- // 3-5. 성공한 리비전의 상태 업데이트
+ // 4-4. 성공한 리비전의 상태 업데이트
await this.updateRevisionStatus(revision.id, 'SUBMITTED', uploadId)
} catch (error) {
@@ -245,6 +307,41 @@ class DOLCEUploadService {
: null
}
+ /**
+ * 사용자 정보 조회 (DOLCE 업로드에 필요한 정보)
+ */
+ private async getUserInfo(userId: string): Promise<{
+ userId: string;
+ userName: string;
+ userEmail: string;
+ vendorCode: string;
+ } | null> {
+ const { users, vendors } = await import("@/db/schema")
+
+ const [result] = await db
+ .select({
+ userId: users.id,
+ userName: users.name,
+ userEmail: users.email,
+ vendorCode: vendors.vendorCode
+ })
+ .from(users)
+ .innerJoin(vendors, eq(users.companyId, vendors.id))
+ .where(eq(users.id, Number(userId)))
+ .limit(1)
+
+ if (!result) {
+ return null
+ }
+
+ return {
+ userId: String(result.userId),
+ userName: result.userName,
+ userEmail: result.userEmail,
+ vendorCode: result.vendorCode || ""
+ }
+ }
+
/**
* 각 issueStageId별로 첫 번째 revision 정보를 조회
@@ -272,7 +369,7 @@ class DOLCEUploadService {
/**
* 업로드할 리비전 정보 조회 (문서 정보 및 첨부파일 포함)
*/
- private async getRevisionsForUpload(revisionIds: number[]) {
+ private async getRevisionsForUpload(revisionIds: number[]): Promise<RevisionWithAttachments[]> {
// revisions → issueStages → documents 순서로 join하여 정보 조회
const revisionResults = await db
.select({
@@ -333,7 +430,7 @@ class DOLCEUploadService {
.where(inArray(revisions.id, revisionIds))
// 각 리비전의 첨부파일 정보도 조회
- const revisionsWithAttachments = []
+ const revisionsWithAttachments: RevisionWithAttachments[] = []
for (const revision of revisionResults) {
const attachments = await db
.select({
@@ -365,11 +462,11 @@ class DOLCEUploadService {
* @param uploadId 이미 생성된 UploadId (문서 업로드 시 생성됨)
*/
private async uploadFiles(
- attachments: any[],
+ attachments: RevisionAttachment[],
userId: string,
uploadId: string // 이미 생성된 UploadId를 매개변수로 받음
): Promise<Array<{ uploadId: string, fileId: string, filePath: string }>> {
- const uploadResults = []
+ const uploadResults: Array<{ uploadId: string, fileId: string, filePath: string }> = []
const resultDataArray: ResultData[] = []
for (let i = 0; i < attachments.length; i++) {
@@ -516,7 +613,7 @@ class DOLCEUploadService {
private async getFileStats(filePath: string): Promise<{ size: number, birthtime: Date, mtime: Date }> {
try {
// Node.js 환경이라면 fs.stat 사용
- const fs = require('fs').promises
+ const fs = await import('fs/promises')
const stats = await fs.stat(filePath)
return {
@@ -524,7 +621,7 @@ class DOLCEUploadService {
birthtime: stats.birthtime,
mtime: stats.mtime
}
- } catch (error) {
+ } catch {
console.warn(`Could not get file stats for ${filePath}, using defaults`)
// 파일 정보를 가져올 수 없는 경우 기본값 사용
const now = new Date()
@@ -539,13 +636,22 @@ class DOLCEUploadService {
/**
* 문서 정보 업로드 (DetailDwgReceiptMgmtEdit)
*/
- private async uploadDocument(dwgList: DOLCEDocument[], userId: string): Promise<{ success: boolean, error?: string, data?: any }> {
+ private async uploadDocument(
+ dwgList: DOLCEDocument[],
+ userId: string,
+ userName: string,
+ vendorCode: string,
+ email: string
+ ): Promise<{ success: boolean, error?: string, data?: any }> {
try {
const endpoint = `${this.BASE_URL}/Services/VDCSWebService.svc/DetailDwgReceiptMgmtEdit`
const requestBody = {
DwgList: dwgList,
- UserID: userId
+ UserID: userId,
+ UserNM: userName,
+ VENDORCODE: vendorCode,
+ EMAIL: email
}
console.log('Uploading documents to DOLCE:', JSON.stringify(requestBody, null, 2))
@@ -623,12 +729,9 @@ class DOLCEUploadService {
/**
* 리비전 데이터를 DOLCE 문서 형태로 변환 (업데이트된 스키마 사용)
*/
- /**
- * 리비전 데이터를 DOLCE 문서 형태로 변환 (업데이트된 스키마 사용)
- */
private transformToDoLCEDocument(
- revision: any,
- contractInfo: any,
+ revision: RevisionWithAttachments,
+ contractInfo: { projectCode: string; vendorCode: string },
uploadId?: string,
vendorCode?: string,
): DOLCEDocument {
@@ -750,7 +853,7 @@ class DOLCEUploadService {
Mode: mode,
// Status: revision.revisionStatus || "Standby",
Status: "Standby",
- RegisterId: revision.registerId || 0, // registerId가 없으면 0 (ADD 모드)
+ RegisterId: revision.registerId ? (typeof revision.registerId === 'string' ? parseInt(revision.registerId) : revision.registerId) : 0, // registerId가 없으면 0 (ADD 모드)
ProjectNo: contractInfo.projectCode,
Discipline: revision.discipline || "DL",
DrawingKind: revision.drawingKind || "B3",
@@ -772,8 +875,8 @@ class DOLCEUploadService {
* 파일 매핑 데이터 변환
*/
private transformToFileMapping(
- revision: any,
- contractInfo: any,
+ revision: RevisionWithAttachments,
+ contractInfo: { projectCode: string; vendorCode: string },
uploadId: string,
fileName: string
): DOLCEFileMapping {
@@ -1008,8 +1111,7 @@ export const dolceUploadService = new DOLCEUploadService()
export async function uploadRevisionsToDOLCE(
projectId: number,
revisionIds: number[],
- userId: string,
- userName?: string
+ userId: string
): Promise<DOLCEUploadResult> {
- return dolceUploadService.uploadToDoLCE(projectId, revisionIds, userId, userName)
+ return dolceUploadService.uploadToDoLCE(projectId, revisionIds, userId)
} \ No newline at end of file