From 9d77688b3fbce108e170e0f874fbd9da66fd25d1 Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Thu, 30 Oct 2025 21:21:29 +0900 Subject: (김준회) 멀티도메인 대응 로그아웃 커스텀 처리, PO 생성 서버액션 연결 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/auth/custom-signout.ts | 51 +++++++++++ lib/rfq-last/contract-actions.ts | 181 ++++++++++++++++++++++++++++++++++----- 2 files changed, 209 insertions(+), 23 deletions(-) create mode 100644 lib/auth/custom-signout.ts (limited to 'lib') diff --git a/lib/auth/custom-signout.ts b/lib/auth/custom-signout.ts new file mode 100644 index 00000000..d59bd81c --- /dev/null +++ b/lib/auth/custom-signout.ts @@ -0,0 +1,51 @@ +/** + * 커스텀 로그아웃 함수 + * NextAuth의 signOut이 NEXTAUTH_URL로 강제 리다이렉트하는 문제를 해결하기 위해 직접 구현 + */ + +interface CustomSignOutOptions { + callbackUrl?: string; + redirect?: boolean; +} + +/** + * 커스텀 로그아웃 함수 + * + * @param options - callbackUrl: 로그아웃 후 이동할 URL (기본: 현재 origin + "/") + * @param options - redirect: 자동 리다이렉트 여부 (기본: true) + */ +export async function customSignOut(options?: CustomSignOutOptions): Promise { + const { callbackUrl, redirect = true } = options || {}; + + try { + // 1. CSRF 토큰 가져오기 + const csrfResponse = await fetch('/api/auth/csrf'); + const { csrfToken } = await csrfResponse.json(); + + // 2. 서버에 로그아웃 요청 + await fetch('/api/auth/signout', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + csrfToken, + json: 'true', + }), + }); + + // 3. 리다이렉트 + if (redirect) { + const finalUrl = callbackUrl || window.location.origin; + window.location.href = finalUrl; + } + } catch (error) { + console.error('Custom sign out error:', error); + // 에러 발생 시에도 리다이렉트 (세션이 이미 만료되었을 수 있음) + if (redirect) { + const finalUrl = callbackUrl || window.location.origin; + window.location.href = finalUrl; + } + } +} + diff --git a/lib/rfq-last/contract-actions.ts b/lib/rfq-last/contract-actions.ts index 1f86352a..e717c815 100644 --- a/lib/rfq-last/contract-actions.ts +++ b/lib/rfq-last/contract-actions.ts @@ -3,13 +3,15 @@ import db from "@/db/db"; import { rfqsLast, rfqLastDetails,rfqPrItems, prItemsForBidding,biddingConditions,biddingCompanies, projects, - biddings,generalContracts ,generalContractItems} from "@/db/schema"; + biddings,generalContracts ,generalContractItems, vendors} from "@/db/schema"; import { eq, and } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { getServerSession } from "next-auth/next" import { authOptions } from "@/app/api/auth/[...nextauth]/route" 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"; // ===== PO (SAP) 생성 ===== interface CreatePOParams { @@ -23,12 +25,30 @@ interface CreatePOParams { export async function createPO(params: CreatePOParams) { try { - const userId = 1; // TODO: 실제 사용자 ID 가져오기 + const session = await getServerSession(authOptions); + if (!session?.user) { + throw new Error("인증이 필요합니다."); + } + const userId = session.user.id; - // 1. 선정된 업체 확인 - const [selectedVendor] = await db + // 1. RFQ 정보 조회 + const [rfqData] = await db .select() + .from(rfqsLast) + .where(eq(rfqsLast.id, params.rfqId)); + + if (!rfqData) { + throw new Error("RFQ 정보를 찾을 수 없습니다."); + } + + // 2. 선정된 업체 확인 및 상세 정보 조회 + const [selectedVendor] = await db + .select({ + detail: rfqLastDetails, + vendor: vendors, + }) .from(rfqLastDetails) + .leftJoin(vendors, eq(rfqLastDetails.vendorsId, vendors.id)) .where( and( eq(rfqLastDetails.rfqsLastId, params.rfqId), @@ -38,25 +58,139 @@ export async function createPO(params: CreatePOParams) { ) ); - if (!selectedVendor) { + if (!selectedVendor || !selectedVendor.vendor) { throw new Error("선정된 업체 정보를 찾을 수 없습니다."); } - // 2. SAP 연동 로직 (TODO: 실제 구현 필요) - // - SAP API 호출 - // - PO 번호 생성 - // - 아이템 정보 전송 - // - 결재 라인 설정 + const vendorData = selectedVendor.vendor; + const detailData = selectedVendor.detail; + + // 3. PR 아이템 정보 조회 + const prItems = await db + .select() + .from(rfqPrItems) + .where(eq(rfqPrItems.rfqsLastId, params.rfqId)); + + if (prItems.length === 0) { + throw new Error("PR 아이템 정보를 찾을 수 없습니다."); + } + + // 4. 필수 필드 검증 + if (!vendorData.vendorCode) { + throw new Error(`벤더 코드가 없습니다. (Vendor ID: ${vendorData.id})`); + } + const vendorCode = vendorData.vendorCode; // 타입 좁히기 + + if (!detailData.paymentTermsCode) { + throw new Error("지급조건(Payment Terms)이 설정되지 않았습니다."); + } + const paymentTermsCode = detailData.paymentTermsCode; // 타입 좁히기 - // 3. 계약 상태 업데이트 + if (!detailData.incotermsCode) { + throw new Error("인코텀즈(Incoterms)가 설정되지 않았습니다."); + } + const incotermsCode = detailData.incotermsCode; // 타입 좁히기 + + if (!detailData.taxCode) { + throw new Error("세금코드(Tax Code)가 설정되지 않았습니다."); + } + const taxCode = detailData.taxCode; // 타입 좁히기 + + if (!detailData.currency && !params.currency) { + throw new Error("통화(Currency)가 설정되지 않았습니다."); + } + const currency = detailData.currency || params.currency!; // 타입 좁히기 + + // ANFNR: rfqsLast.ANFNR 우선, 없으면 rfqCode 사용 (ITB, 일반견적은 ANFNR 없으므로..) + const anfnr = rfqData.ANFNR || rfqData.rfqCode; + if (!anfnr) { + throw new Error("RFQ 번호(ANFNR 또는 rfqCode)가 없습니다."); + } + + // 5. PO 데이터 구성 + const poData = { + T_Bidding_HEADER: [{ + ANFNR: anfnr, + LIFNR: vendorCode, + ZPROC_IND: 'A', // TODO: 구매 처리 상태 - 의미 확인 필요 + WAERS: currency, + ZTERM: paymentTermsCode, + INCO1: incotermsCode, + INCO2: detailData.incotermsDetail || detailData.placeOfDestination || detailData.placeOfShipping || '', + MWSKZ: taxCode, + LANDS: 'KR', + ZRCV_DT: getCurrentSAPDate(), + ZATTEN_IND: 'Y', + IHRAN: getCurrentSAPDate(), + TEXT: `PO from RFQ: ${rfqData.rfqTitle || rfqData.itemName || ''}`, + ZDLV_CNTLR: rfqData.picCode || undefined, + ZDLV_PRICE_T: detailData.materialPriceRelatedYn ? 'Y' : 'N', + ZDLV_PRICE_NOTE: detailData.materialPriceRelatedYn ? '연동제 적용' : undefined, + }], + T_Bidding_ITEM: prItems.map((item, index) => { + if (!item.uom) { + throw new Error(`PR 아이템 ${index + 1}번의 단위(UOM)가 없습니다.`); + } + + // TODO: 아이템별 단가 및 금액 정보를 견적 응답(rfqLastVendorResponseItems)에서 가져와야 함 + // 현재는 총액만 받아서 계산할 수 없음 + // - NETPR: 아이템별 단가 (견적서의 unitPrice) + // - PEINH: 가격 단위 (견적서의 priceUnit 또는 기본값 확인 필요) + // - NETWR: 아이템별 순액 (quantity * unitPrice) + // - BRTWR: 아이템별 총액 (NETWR + 세금, 세율은 MWSKZ에 따라 다름) + + return { + ANFNR: anfnr, + ANFPS: (index + 1).toString().padStart(5, '0'), + LIFNR: vendorCode, + NETPR: '0', // TODO: 견적서에서 실제 단가 가져오기 + PEINH: '1', // TODO: 가격 단위 확인 필요 + BPRME: item.uom, + NETWR: '0', // TODO: 견적서에서 실제 순액 가져오기 + BRTWR: '0', // TODO: 견적서에서 실제 총액(세금 포함) 가져오기 + LFDAT: item.deliveryDate ? new Date(item.deliveryDate).toISOString().split('T')[0] : getCurrentSAPDate(), + }; + }), + T_PR_RETURN: [{ + ANFNR: anfnr, + ANFPS: '00001', + EBELN: rfqData.prNumber || '', + EBELP: '00001', + MSGTY: 'S', + MSGTXT: 'Success' + }] + }; + + console.log('📤 SAP으로 PO 전송 시작:', { + ANFNR: anfnr, + LIFNR: vendorCode, + vendorName: vendorData.vendorName, + itemCount: prItems.length, + totalAmount: params.totalAmount, + currency: currency, + }); + + // 6. SAP SOAP 요청 전송 + const sapResult = await createPurchaseOrder(poData); + + if (!sapResult.success) { + throw new Error(`SAP PO 생성 실패: ${sapResult.message}`); + } + + console.log('✅ SAP PO 전송 성공:', sapResult); + + // 7. 실제 PO 번호 추출 (SOAP 응답에서 추출하거나 ANFNR 사용) + const actualPoNumber = sapResult.bidding_number || anfnr; + + // 8. DB에 실제 PO 번호 저장 및 RFQ 상태 업데이트 await db.transaction(async (tx) => { - // rfqLastDetails에 계약 정보 업데이트 + // rfqLastDetails 업데이트 await tx .update(rfqLastDetails) .set({ contractStatus: "진행중", contractCreatedAt: new Date(), - contractNo: `PO-${Date.now()}`, // TODO: 실제 PO 번호로 변경 + contractNo: actualPoNumber, updatedAt: new Date(), updatedBy: userId, }) @@ -69,13 +203,14 @@ export async function createPO(params: CreatePOParams) { ); // RFQ 상태 업데이트 - // await tx - // .update(rfqsLast) - // .set({ - // status: "PO 생성 완료", - // updatedAt: new Date(), - // }) - // .where(eq(rfqsLast.id, params.rfqId)); + await tx + .update(rfqsLast) + .set({ + status: "최종업체선정", // PO 생성 완료 상태 + updatedAt: new Date(), + updatedBy: userId, + }) + .where(eq(rfqsLast.id, params.rfqId)); }); revalidatePath(`/rfq/${params.rfqId}`); @@ -83,11 +218,11 @@ export async function createPO(params: CreatePOParams) { return { success: true, - message: "PO가 성공적으로 생성되었습니다.", - poNumber: `PO-${Date.now()}`, // TODO: 실제 PO 번호 반환 + message: "PO가 성공적으로 생성되어 SAP로 전송되었습니다.", + poNumber: actualPoNumber, }; } catch (error) { - console.error("PO 생성 오류:", error); + console.error("❌ PO 생성 오류:", error); return { success: false, error: error instanceof Error ? error.message : "PO 생성 중 오류가 발생했습니다." -- cgit v1.2.3