summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/auth/custom-signout.ts51
-rw-r--r--lib/rfq-last/contract-actions.ts181
2 files changed, 209 insertions, 23 deletions
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 생성 중 오류가 발생했습니다."