diff options
| -rw-r--r-- | app/api/auth/[...nextauth]/route.ts | 27 | ||||
| -rw-r--r-- | app/api/test/headers/route.ts | 80 | ||||
| -rw-r--r-- | components/layout/Header.tsx | 5 | ||||
| -rw-r--r-- | components/layout/HeaderDataroom.tsx | 5 | ||||
| -rw-r--r-- | lib/auth/custom-signout.ts | 51 | ||||
| -rw-r--r-- | lib/rfq-last/contract-actions.ts | 181 |
6 files changed, 217 insertions, 132 deletions
diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index 58566cd6..d6ec807f 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -449,35 +449,12 @@ export const authOptions: NextAuthOptions = { }, async redirect({ url, baseUrl }) { - // 상대 경로인 경우 baseUrl과 결합 if (url.startsWith("/")) { return `${baseUrl}${url}`; } - - // 절대 URL인 경우: 허용된 도메인 목록 확인 - try { - const urlObj = new URL(url); - const allowedDomains = [ - "shidataroom.com", - "partners.sevcp.com", - "sevcp.com", - "localhost" // 개발 환경 - ]; - - // 허용된 도메인이면 그대로 반환 - if (allowedDomains.includes(urlObj.hostname)) { - return url; - } - - // 기존 로직: baseUrl과 origin이 같으면 허용 - if (urlObj.origin === baseUrl) { - return url; - } - } catch { - console.error('Invalid redirect URL:', url); + else if (new URL(url).origin === baseUrl) { + return url; } - - // 허용되지 않은 URL은 baseUrl로 리다이렉트 return baseUrl; }, }, diff --git a/app/api/test/headers/route.ts b/app/api/test/headers/route.ts deleted file mode 100644 index 70c14564..00000000 --- a/app/api/test/headers/route.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { headers } from 'next/headers'; - -/** - * 리버스 프록시 헤더 전달 테스트 API - * - * 접속 방법: - * - https://shidataroom.com/api/test/headers - * - https://partners.sevcp.com/api/test/headers - * - https://sevcp.com/api/test/headers - * - * 각 도메인에서 헤더가 올바르게 전달되는지 확인 - */ -export async function GET(request: NextRequest) { - const headersList = await headers(); - - // 중요한 헤더들 수집 - const host = headersList.get('host'); - const xForwardedProto = headersList.get('x-forwarded-proto'); - const xForwardedHost = headersList.get('x-forwarded-host'); - const xForwardedFor = headersList.get('x-forwarded-for'); - const xRealIp = headersList.get('x-real-ip'); - - // 현재 계산된 origin - const proto = xForwardedProto || 'http'; - const computedOrigin = `${proto}://${host}`; - - // request.nextUrl의 origin (Next.js가 인식하는 origin) - const nextUrlOrigin = request.nextUrl.origin; - - return NextResponse.json({ - success: true, - message: '리버스 프록시 헤더 정보', - headers: { - host, - 'x-forwarded-proto': xForwardedProto, - 'x-forwarded-host': xForwardedHost, - 'x-forwarded-for': xForwardedFor, - 'x-real-ip': xRealIp, - }, - computed: { - origin: computedOrigin, - nextUrlOrigin, - isCorrect: computedOrigin === nextUrlOrigin, - }, - recommendations: { - dmz_nginx: { - required: [ - 'proxy_set_header Host $host;', - 'proxy_set_header X-Forwarded-Proto $scheme;', - 'proxy_set_header X-Forwarded-Host $host;', - 'proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;', - 'proxy_set_header X-Real-IP $remote_addr;', - ] - }, - ap_nginx: { - required: [ - 'proxy_set_header Host $host;', - 'proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;', - 'proxy_set_header X-Forwarded-Host $http_x_forwarded_host;', - 'proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;', - 'proxy_set_header X-Real-IP $http_x_real_ip;', - ] - } - }, - test: { - description: '각 도메인에서 이 API를 호출하여 헤더가 올바른지 확인하세요', - expected: { - 'shidataroom.com': 'computed.origin should be "https://shidataroom.com"', - 'partners.sevcp.com': 'computed.origin should be "https://partners.sevcp.com"', - 'sevcp.com': 'computed.origin should be "https://sevcp.com"', - } - } - }, { - headers: { - 'Content-Type': 'application/json', - } - }); -} - diff --git a/components/layout/Header.tsx b/components/layout/Header.tsx index 55991d2c..c5c83e64 100644 --- a/components/layout/Header.tsx +++ b/components/layout/Header.tsx @@ -41,7 +41,8 @@ import { } from "@/config/menuConfig"; import { MobileMenu } from "./MobileMenu"; import { CommandMenu } from "./command-menu"; -import { useSession, signOut } from "next-auth/react"; +import { useSession } from "next-auth/react"; +import { customSignOut } from "@/lib/auth/custom-signout"; import GroupedMenuRenderer from "./GroupedMenuRender"; import { useActiveMenus, filterActiveMenus, filterActiveAdditionalMenus } from "@/hooks/use-active-menus"; import { NotificationDropdown } from "./NotificationDropdown"; @@ -261,7 +262,7 @@ export function Header() { <Link href={`${basePath}/settings`}>{t('user.settings')}</Link> </DropdownMenuItem> <DropdownMenuSeparator /> - <DropdownMenuItem onSelect={() => signOut({ callbackUrl: `${window.location.origin}${basePath}` })}> + <DropdownMenuItem onSelect={() => customSignOut({ callbackUrl: `${window.location.origin}${basePath}` })}> {t('user.logout')} </DropdownMenuItem> </DropdownMenuContent> diff --git a/components/layout/HeaderDataroom.tsx b/components/layout/HeaderDataroom.tsx index 04d61a8d..a986a87d 100644 --- a/components/layout/HeaderDataroom.tsx +++ b/components/layout/HeaderDataroom.tsx @@ -28,7 +28,8 @@ import { cn } from "@/lib/utils"; import Image from "next/image"; import { MobileMenu } from "./MobileMenu"; import { CommandMenu } from "./command-menu"; -import { useSession, signOut } from "next-auth/react"; +import { useSession } from "next-auth/react"; +import { customSignOut } from "@/lib/auth/custom-signout"; import { NotificationDropdown } from "./NotificationDropdown"; // 간단한 메뉴 배열 @@ -138,7 +139,7 @@ export function HeaderDataRoom() { <Link href={`/${lng}/evcp/settings`}>설정</Link> </DropdownMenuItem> <DropdownMenuSeparator /> - <DropdownMenuItem onSelect={() => signOut({ callbackUrl: `${window.location.origin}/${lng}/evcp` })}> + <DropdownMenuItem onSelect={() => customSignOut({ callbackUrl: `${window.location.origin}/${lng}/evcp` })}> 로그아웃 </DropdownMenuItem> </DropdownMenuContent> 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<void> { + 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 생성 중 오류가 발생했습니다." |
