summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-10-30 21:21:29 +0900
committerjoonhoekim <26rote@gmail.com>2025-10-30 21:21:29 +0900
commit9d77688b3fbce108e170e0f874fbd9da66fd25d1 (patch)
treeed6d8462c2c1ab3bbf932bb50309a1df308821f9
parent788eb678c45c6d3767cd2679c41ad5387ae6c3f0 (diff)
(김준회) 멀티도메인 대응 로그아웃 커스텀 처리, PO 생성 서버액션 연결
-rw-r--r--app/api/auth/[...nextauth]/route.ts27
-rw-r--r--app/api/test/headers/route.ts80
-rw-r--r--components/layout/Header.tsx5
-rw-r--r--components/layout/HeaderDataroom.tsx5
-rw-r--r--lib/auth/custom-signout.ts51
-rw-r--r--lib/rfq-last/contract-actions.ts181
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 생성 중 오류가 발생했습니다."