From 47fb72704161b4b58a27c7f5c679fc44618de9a1 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Tue, 4 Nov 2025 10:03:32 +0000 Subject: (최겸) 구매 견적 내 RFQ Cancel/Delete, 연동제 적용, MRC Type 개발 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(evcp)/(procurement)/rfq-last/[id]/layout.tsx | 23 +- .../partners/(partners)/rfq-last/[id]/page.tsx | 37 +++ app/api/partners/rfq-last/[id]/response/route.ts | 144 +++++++++- db/schema/rfqLast.ts | 8 +- db/schema/rfqVendor.ts | 82 +++++- lib/rfq-last/attachment/vendor-response-table.tsx | 17 +- lib/rfq-last/cancel-vendor-response-action.ts | 185 ++++++++++++ lib/rfq-last/delete-action.ts | 199 +++++++++++++ lib/rfq-last/service.ts | 42 ++- lib/rfq-last/table/delete-rfq-dialog.tsx | 254 +++++++++++++++++ lib/rfq-last/table/rfq-table-columns.tsx | 1 + lib/rfq-last/table/rfq-table-toolbar-actions.tsx | 56 +++- .../editor/commercial-terms-form.tsx | 271 ++++++++++++++++-- .../editor/vendor-response-editor.tsx | 63 +++++ lib/rfq-last/vendor/add-vendor-dialog.tsx | 122 ++++++-- .../vendor/cancel-vendor-response-dialog.tsx | 208 ++++++++++++++ lib/rfq-last/vendor/price-adjustment-dialog.tsx | 268 ++++++++++++++++++ lib/rfq-last/vendor/rfq-vendor-table.tsx | 155 +++++++++-- lib/soap/ecc/send/cancel-rfq.ts | 309 --------------------- lib/soap/ecc/send/delete-rfq.ts | 309 +++++++++++++++++++++ 20 files changed, 2356 insertions(+), 397 deletions(-) create mode 100644 lib/rfq-last/cancel-vendor-response-action.ts create mode 100644 lib/rfq-last/delete-action.ts create mode 100644 lib/rfq-last/table/delete-rfq-dialog.tsx create mode 100644 lib/rfq-last/vendor/cancel-vendor-response-dialog.tsx create mode 100644 lib/rfq-last/vendor/price-adjustment-dialog.tsx delete mode 100644 lib/soap/ecc/send/cancel-rfq.ts create mode 100644 lib/soap/ecc/send/delete-rfq.ts diff --git a/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/[id]/layout.tsx b/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/[id]/layout.tsx index 6dcbf018..8fffb221 100644 --- a/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/[id]/layout.tsx +++ b/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/[id]/layout.tsx @@ -4,7 +4,6 @@ import { Separator } from "@/components/ui/separator" import { SidebarNav } from "@/components/layout/sidebar-nav" import { formatDate } from "@/lib/utils" import { Button } from "@/components/ui/button" -import { Badge } from "@/components/ui/badge" import { ArrowLeft, Clock, AlertTriangle, CheckCircle, XCircle, AlertCircle, Calendar, CalendarDays } from "lucide-react" import { RfqsLastView } from "@/db/schema" import { findRfqLastById } from "@/lib/rfq-last/service" @@ -47,7 +46,27 @@ export default async function RfqLayout({ // 2) DB에서 해당 협력업체 정보 조회 const rfq: RfqsLastView | null = await findRfqLastById(rfqId) - // 3) 사이드바 메뉴 + // 3) 취소된 RFQ 접근 제어 + if (rfq?.status === "RFQ 삭제") { + return ( +
+ + + 접근 불가 + + 이 RFQ는 삭제되어 접근할 수 없습니다. + + +
+

삭제 사유:

+

{rfq.deleteReason}

+
+ +
+ ); + } + + // 4) 사이드바 메뉴 const sidebarNavItems = [ { title: "견적 문서관리", diff --git a/app/[lng]/partners/(partners)/rfq-last/[id]/page.tsx b/app/[lng]/partners/(partners)/rfq-last/[id]/page.tsx index 7a68e3a2..9052de6f 100644 --- a/app/[lng]/partners/(partners)/rfq-last/[id]/page.tsx +++ b/app/[lng]/partners/(partners)/rfq-last/[id]/page.tsx @@ -86,6 +86,23 @@ export default async function VendorResponsePage({ params }: PageProps) { if (!rfq || !rfq.rfqDetails[0]) { notFound() } + + // 취소된 RFQ 접근 제어 + if (rfq.status === "RFQ 삭제") { + return ( +
+
+

접근 불가

+

이 RFQ는 삭제되어 접근할 수 없습니다.

+
+

삭제 사유:

+

{rfq.deleteReason}

+
+ +
+
+ ) + } const rfqDetail = rfq.rfqDetails[0] @@ -99,8 +116,28 @@ export default async function VendorResponsePage({ params }: PageProps) { with: { quotationItems: true, attachments: true, + priceAdjustmentForm: true, } }) + + // 취소된 RFQ 접근 제어 (vendor response가 취소 상태인 경우) + if (existingResponse?.status === "취소" || rfqDetail.cancelReason) { + return ( +
+
+

RFQ 취소됨

+

+ 이 RFQ는 취소되어 더 이상 견적을 제출할 수 없습니다. +

+ {rfqDetail.cancelReason && ( +

+ 취소 사유: {rfqDetail.cancelReason} +

+ )} +
+
+ ) + } // PR Items 가져오기 const prItems = await db.query.rfqPrItems.findMany({ diff --git a/app/api/partners/rfq-last/[id]/response/route.ts b/app/api/partners/rfq-last/[id]/response/route.ts index 5d05db50..06ace9a0 100644 --- a/app/api/partners/rfq-last/[id]/response/route.ts +++ b/app/api/partners/rfq-last/[id]/response/route.ts @@ -7,9 +7,10 @@ import { rfqLastVendorResponses, rfqLastVendorQuotationItems, rfqLastVendorAttachments, - rfqLastVendorResponseHistory + rfqLastVendorResponseHistory, + rfqLastPriceAdjustmentForms } from "@/db/schema" -import { eq, and, inArray } from "drizzle-orm" +import { eq, and } from "drizzle-orm" import { writeFile, mkdir } from "fs/promises" import { createWriteStream } from "fs" import { pipeline } from "stream/promises" @@ -139,17 +140,77 @@ export async function POST( await tx.insert(rfqLastVendorQuotationItems).values(quotationItemsData) } - // 이력 기록 + // 3. 연동제 정보 저장 (연동제 적용이 true이고 연동제 정보가 있는 경우) + if (data.vendorMaterialPriceRelatedYn && data.priceAdjustmentForm && vendorResponse.id) { + const priceAdjustmentData: { + rfqLastVendorResponsesId: number; + itemName?: string | null; + adjustmentReflectionPoint?: string | null; + majorApplicableRawMaterial?: string | null; + adjustmentFormula?: string | null; + rawMaterialPriceIndex?: string | null; + referenceDate?: string | null; + comparisonDate?: string | null; + adjustmentRatio?: number | null; + notes?: string | null; + adjustmentConditions?: string | null; + majorNonApplicableRawMaterial?: string | null; + adjustmentPeriod?: string | null; + contractorWriter?: string | null; + adjustmentDate?: string | null; + nonApplicableReason?: string | null; + } = { + rfqLastVendorResponsesId: vendorResponse.id, + itemName: data.priceAdjustmentForm.itemName || null, + adjustmentReflectionPoint: data.priceAdjustmentForm.adjustmentReflectionPoint || null, + majorApplicableRawMaterial: data.priceAdjustmentForm.majorApplicableRawMaterial || null, + adjustmentFormula: data.priceAdjustmentForm.adjustmentFormula || null, + rawMaterialPriceIndex: data.priceAdjustmentForm.rawMaterialPriceIndex || null, + referenceDate: data.priceAdjustmentForm.referenceDate || null, + comparisonDate: data.priceAdjustmentForm.comparisonDate || null, + adjustmentRatio: data.priceAdjustmentForm.adjustmentRatio || null, + notes: data.priceAdjustmentForm.notes || null, + adjustmentConditions: data.priceAdjustmentForm.adjustmentConditions || null, + majorNonApplicableRawMaterial: data.priceAdjustmentForm.majorNonApplicableRawMaterial || null, + adjustmentPeriod: data.priceAdjustmentForm.adjustmentPeriod || null, + contractorWriter: data.priceAdjustmentForm.contractorWriter || null, + adjustmentDate: data.priceAdjustmentForm.adjustmentDate || null, + nonApplicableReason: data.priceAdjustmentForm.nonApplicableReason || null, + } + + // 기존 연동제 정보가 있는지 확인 + const existingPriceAdjustment = await tx + .select() + .from(rfqLastPriceAdjustmentForms) + .where(eq(rfqLastPriceAdjustmentForms.rfqLastVendorResponsesId, vendorResponse.id)) + .limit(1) + + if (existingPriceAdjustment.length > 0) { + // 업데이트 + await tx + .update(rfqLastPriceAdjustmentForms) + .set({ + ...priceAdjustmentData, + updatedAt: new Date(), + }) + .where(eq(rfqLastPriceAdjustmentForms.rfqLastVendorResponsesId, vendorResponse.id)) + } else { + // 새로 생성 + await tx.insert(rfqLastPriceAdjustmentForms).values(priceAdjustmentData) + } + } + + // 4. 이력 기록 await tx.insert(rfqLastVendorResponseHistory).values({ - vendorResponseId: vendorResponseId, - action: isNewResponse ? "생성" : (data.status === "제출완료" ? "제출" : "수정"), - previousStatus: existingResponse?.status || null, + vendorResponseId: vendorResponse.id, + action: "생성", + previousStatus: null, newStatus: data.status || "작성중", changeDetails: data, performedBy: session.user.id, }) - return { id: vendorResponseId, isNew: isNewResponse } + return { id: vendorResponse.id, isNew: true } }) // 파일 저장 (트랜잭션 밖에서 처리) @@ -262,7 +323,7 @@ export async function PUT( const previousStatus = existingResponse.status // 2. 새 버전 생성 (제출 시) 또는 기존 버전 업데이트 - let responseId = existingResponse.id + const responseId = existingResponse.id // if (data.status === "제출완료" && previousStatus !== "제출완료") { // // 기존 버전을 비활성화 @@ -362,7 +423,72 @@ export async function PUT( await tx.insert(rfqLastVendorQuotationItems).values(quotationItemsData) } - // 4. 이력 기록 + // 4. 연동제 정보 저장/업데이트 (연동제 적용이 true이고 연동제 정보가 있는 경우) + if (data.vendorMaterialPriceRelatedYn && data.priceAdjustmentForm && responseId) { + const priceAdjustmentData: { + rfqLastVendorResponsesId: number; + itemName?: string | null; + adjustmentReflectionPoint?: string | null; + majorApplicableRawMaterial?: string | null; + adjustmentFormula?: string | null; + rawMaterialPriceIndex?: string | null; + referenceDate?: string | null; + comparisonDate?: string | null; + adjustmentRatio?: number | null; + notes?: string | null; + adjustmentConditions?: string | null; + majorNonApplicableRawMaterial?: string | null; + adjustmentPeriod?: string | null; + contractorWriter?: string | null; + adjustmentDate?: string | null; + nonApplicableReason?: string | null; + } = { + rfqLastVendorResponsesId: responseId, + itemName: data.priceAdjustmentForm.itemName || null, + adjustmentReflectionPoint: data.priceAdjustmentForm.adjustmentReflectionPoint || null, + majorApplicableRawMaterial: data.priceAdjustmentForm.majorApplicableRawMaterial || null, + adjustmentFormula: data.priceAdjustmentForm.adjustmentFormula || null, + rawMaterialPriceIndex: data.priceAdjustmentForm.rawMaterialPriceIndex || null, + referenceDate: data.priceAdjustmentForm.referenceDate || null, + comparisonDate: data.priceAdjustmentForm.comparisonDate || null, + adjustmentRatio: data.priceAdjustmentForm.adjustmentRatio || null, + notes: data.priceAdjustmentForm.notes || null, + adjustmentConditions: data.priceAdjustmentForm.adjustmentConditions || null, + majorNonApplicableRawMaterial: data.priceAdjustmentForm.majorNonApplicableRawMaterial || null, + adjustmentPeriod: data.priceAdjustmentForm.adjustmentPeriod || null, + contractorWriter: data.priceAdjustmentForm.contractorWriter || null, + adjustmentDate: data.priceAdjustmentForm.adjustmentDate || null, + nonApplicableReason: data.priceAdjustmentForm.nonApplicableReason || null, + } + + // 기존 연동제 정보가 있는지 확인 + const existingPriceAdjustment = await tx + .select() + .from(rfqLastPriceAdjustmentForms) + .where(eq(rfqLastPriceAdjustmentForms.rfqLastVendorResponsesId, responseId)) + .limit(1) + + if (existingPriceAdjustment.length > 0) { + // 업데이트 + await tx + .update(rfqLastPriceAdjustmentForms) + .set({ + ...priceAdjustmentData, + updatedAt: new Date(), + }) + .where(eq(rfqLastPriceAdjustmentForms.rfqLastVendorResponsesId, responseId)) + } else { + // 새로 생성 + await tx.insert(rfqLastPriceAdjustmentForms).values(priceAdjustmentData) + } + } else if (data.vendorMaterialPriceRelatedYn === false && responseId) { + // 연동제 미적용 시 기존 데이터 삭제 + await tx + .delete(rfqLastPriceAdjustmentForms) + .where(eq(rfqLastPriceAdjustmentForms.rfqLastVendorResponsesId, responseId)) + } + + // 5. 이력 기록 await tx.insert(rfqLastVendorResponseHistory).values({ vendorResponseId: responseId, action: data.status === "제출완료" ? "제출" : "수정", diff --git a/db/schema/rfqLast.ts b/db/schema/rfqLast.ts index b4ec968b..19c213c0 100644 --- a/db/schema/rfqLast.ts +++ b/db/schema/rfqLast.ts @@ -14,7 +14,8 @@ export type RfqStatus = | "TBE 완료" | "RFQ 발송" | "견적접수" - | "최종업체선정"; + | "최종업체선정" + | "RFQ 삭제"; export const rfqsLast = pgTable( @@ -96,6 +97,9 @@ export const rfqsLast = pgTable( // SS = 시리즈 통합, II = 품목 통합, 공란 = 통합 없음 series: varchar("series", { length: 50 }), + // RFQ 삭제 사유 + deleteReason: text("delete_reason"), + }, ); @@ -296,6 +300,7 @@ export const rfqsLastView = pgView("rfqs_last_view").as((qb) => { // Basic RFQ identification id: sql`${rfqsLast.id}`.as("id"), rfqCode: sql`${rfqsLast.rfqCode}`.as("rfq_code"), + ANFNR: sql`${rfqsLast.ANFNR}`.as("ANFNR"), series: sql`${rfqsLast.series}`.as("series"), rfqSealedYn: sql`${rfqsLast.rfqSealedYn}`.as("rfq_sealed_yn"), @@ -384,6 +389,7 @@ export const rfqsLastView = pgView("rfqs_last_view").as((qb) => { updatedAt: sql`${rfqsLast.updatedAt}`.as("updated_at"), remark: sql`${rfqsLast.remark}`.as("remark"), + deleteReason: sql`${rfqsLast.deleteReason}`.as("delete_reason"), // PR Items related information majorItemMaterialCode: sql`( diff --git a/db/schema/rfqVendor.ts b/db/schema/rfqVendor.ts index dea196b1..f7fcce64 100644 --- a/db/schema/rfqVendor.ts +++ b/db/schema/rfqVendor.ts @@ -1,4 +1,4 @@ -import { pgTable, pgView, serial, varchar, text, timestamp, boolean, integer, numeric, date, alias, jsonb } from "drizzle-orm/pg-core"; +import { pgTable, pgView, serial, varchar, text, timestamp, boolean, integer, numeric, date, alias, jsonb, decimal } from "drizzle-orm/pg-core"; import { eq, sql, relations,and } from "drizzle-orm"; import { rfqsLast, rfqLastDetails, rfqPrItems } from "./rfqLast"; import { users } from "./users"; @@ -30,8 +30,6 @@ export const rfqLastVendorResponses = pgTable( isLatest: boolean("is_latest").notNull().default(true), isDocumentConfirmed: boolean("is_document_confirmed").default(false), - - // 참여 여부 관련 필드 (새로 추가) participationStatus: varchar("participation_status", { length: 20 }) .$type<"미응답" | "참여" | "불참">() @@ -44,10 +42,9 @@ export const rfqLastVendorResponses = pgTable( // 응답 상태 (수정: 참여 결정 후에만 의미 있음) status: varchar("status", { length: 30 }) - .$type<"대기중" | "작성중" | "제출완료" | "수정요청" | "최종확정" | "취소">() + .$type<"대기중" | "작성중" | "제출완료" | "수정요청" | "최종확정" | "취소" | "삭제">() .notNull() .default("대기중"), - // 제출 정보 submittedAt: timestamp("submitted_at"), submittedBy: integer("submitted_by") @@ -238,6 +235,65 @@ export const rfqLastVendorResponseHistory = pgTable( } ); +// ========================================== +// 5. RFQ-last 연동제 폼 테이블 +// ========================================== +export const rfqLastPriceAdjustmentForms = pgTable('rfq_last_price_adjustment_forms', { + id: serial('id').primaryKey(), + + // rfqLastVendorResponses 테이블과 외래 키로 연결 + rfqLastVendorResponsesId: integer('rfq_last_vendor_responses_id') + .notNull() + .references(() => rfqLastVendorResponses.id, { onDelete: 'cascade' }), + + // 품목등의 명칭 + itemName: varchar('item_name', { length: 255 }), + + // 조정대금 반영시점 + adjustmentReflectionPoint: varchar('adjustment_reflection_point', { length: 255 }), + + // 연동대상 주요 원재료 + majorApplicableRawMaterial: text('major_applicable_raw_material'), + + // 하도급대금등 연동 산식 + adjustmentFormula: text('adjustment_formula'), + + // 원재료 가격 기준지표 + rawMaterialPriceIndex: text('raw_material_price_index'), + + // 기준시점 및 비교시점 + referenceDate: date('reference_date'), // 기준시점 + comparisonDate: date('comparison_date'), // 비교시점 + + // 연동 비율 + adjustmentRatio: decimal('adjustment_ratio', { precision: 5, scale: 2 }), // 소수점 2자리까지 + + // 기타 사항 + notes: text('notes'), + + // 조정요건 + adjustmentConditions: text('adjustment_conditions'), + + // 연동 미적용 주요 원재료 + majorNonApplicableRawMaterial: text('major_non_applicable_raw_material'), + + // 조정주기 + adjustmentPeriod: varchar('adjustment_period', { length: 100 }), + + // 수탁기업(협력사) 작성자 + contractorWriter: varchar('contractor_writer', { length: 100 }), + + // 조정일 + adjustmentDate: date('adjustment_date'), + + // 연동 미적용 사유 + nonApplicableReason: text('non_applicable_reason'), + + // 메타 정보 + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + // ========================================== // Views // ========================================== @@ -453,6 +509,22 @@ export const vendorQuotationItemsRelations = relations( quotationItems: many(rfqLastVendorQuotationItems), attachments: many(rfqLastVendorAttachments), history: many(rfqLastVendorResponseHistory), + priceAdjustmentForm: one(rfqLastPriceAdjustmentForms, { + fields: [rfqLastVendorResponses.id], + references: [rfqLastPriceAdjustmentForms.rfqLastVendorResponsesId], + relationName: "vendorResponsePriceAdjustmentForm", + }), + }) + ); + + // 연동제 폼 테이블의 relation + export const priceAdjustmentFormRelations = relations( + rfqLastPriceAdjustmentForms, + ({ one }) => ({ + vendorResponse: one(rfqLastVendorResponses, { + fields: [rfqLastPriceAdjustmentForms.rfqLastVendorResponsesId], + references: [rfqLastVendorResponses.id], + }), }) ); diff --git a/lib/rfq-last/attachment/vendor-response-table.tsx b/lib/rfq-last/attachment/vendor-response-table.tsx index 8488eea1..22f813b3 100644 --- a/lib/rfq-last/attachment/vendor-response-table.tsx +++ b/lib/rfq-last/attachment/vendor-response-table.tsx @@ -19,7 +19,8 @@ import { Calendar, AlertCircle, X, - CheckCircle2 + CheckCircle2, + XCircle } from "lucide-react"; import { format, formatDistanceToNow, isValid, isBefore, isAfter } from "date-fns"; import { ko } from "date-fns/locale"; @@ -184,6 +185,13 @@ export function VendorResponseTable({ return vendorItem?.vendorId || null; }, [selectedVendor, data]); + // 선택된 벤더의 응답 상태 확인 (취소 상태인지) + const isVendorCancelled = React.useMemo(() => { + if (!selectedVendor) return false; + // 선택된 벤더의 문서 중 하나라도 취소 상태인지 확인 + return filteredData.some(item => item.responseStatus === "취소"); + }, [selectedVendor, filteredData]); + // 데이터 새로고침 const handleRefresh = React.useCallback(async () => { setIsRefreshing(true); @@ -567,13 +575,18 @@ export function VendorResponseTable({ size="sm" onClick={() => setShowConfirmDialog(true)} className="h-7" - disabled={confirmedVendors.has(selectedVendorId)} + disabled={confirmedVendors.has(selectedVendorId) || isVendorCancelled} > {confirmedVendors.has(selectedVendorId) ? ( <> 확정완료 + ) : isVendorCancelled ? ( + <> + + 취소됨 (확정 불가) + ) : ( <> diff --git a/lib/rfq-last/cancel-vendor-response-action.ts b/lib/rfq-last/cancel-vendor-response-action.ts new file mode 100644 index 00000000..e329a551 --- /dev/null +++ b/lib/rfq-last/cancel-vendor-response-action.ts @@ -0,0 +1,185 @@ +'use server' + +import { revalidatePath } from "next/cache"; +import db from "@/db/db"; +import { rfqLastDetails, rfqLastVendorResponses, rfqLastTbeSessions } from "@/db/schema"; +import { eq, and, inArray } from "drizzle-orm"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; + +/** + * RFQ 벤더 응답 취소 서버 액션 + * RFQ 발송 후 특정 벤더에 한하여 취소 처리 + * - vendor response를 "취소" 상태로 변경 + * - cancelReason 업데이트 + * - TBE 진행중이면 TBE 취소 처리 + * - 업체선정 진행중이면 업체선정 취소 처리 + */ +export async function cancelVendorResponse( + rfqId: number, + detailIds: number[], + cancelReason: string +): Promise<{ + success: boolean; + message: string; + results?: Array<{ detailId: number; success: boolean; error?: string }>; +}> { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.id) { + return { + success: false, + message: "인증이 필요합니다." + }; + } + + const userId = Number(session.user.id); + + if (!cancelReason || cancelReason.trim() === "") { + return { + success: false, + message: "취소 사유를 입력해주세요." + }; + } + + // 1. RFQ Detail 정보 조회 + const rfqDetails = await db.query.rfqLastDetails.findMany({ + where: and( + eq(rfqLastDetails.rfqsLastId, rfqId), + inArray(rfqLastDetails.id, detailIds), + eq(rfqLastDetails.isLatest, true) + ), + columns: { + id: true, + vendorsId: true, + } + }); + + if (rfqDetails.length === 0) { + return { + success: false, + message: "취소할 벤더를 찾을 수 없습니다." + }; + } + + const vendorIds = rfqDetails.map(d => d.vendorsId).filter(id => id != null) as number[]; + const results: Array<{ detailId: number; success: boolean; error?: string }> = []; + + // 2. 각 벤더에 대해 취소 처리 + for (const detail of rfqDetails) { + try { + await db.transaction(async (tx) => { + const vendorId = detail.vendorsId; + if (!vendorId) { + throw new Error("벤더 ID가 없습니다."); + } + + // 2-1. RFQ Detail의 cancelReason 업데이트 + await tx + .update(rfqLastDetails) + .set({ + cancelReason: cancelReason, + updatedBy: userId, + updatedAt: new Date() + }) + .where(eq(rfqLastDetails.id, detail.id)); + + // 2-2. 업체선정이 되어 있다면 취소 처리 + await tx + .update(rfqLastDetails) + .set({ + isSelected: false, + selectionDate: null, + selectionReason: null, + selectedBy: null, + updatedBy: userId, + updatedAt: new Date() + }) + .where( + and( + eq(rfqLastDetails.id, detail.id), + eq(rfqLastDetails.isSelected, true) + ) + ); + + // 2-3. Vendor Response를 "취소" 상태로 변경 + await tx + .update(rfqLastVendorResponses) + .set({ + status: "취소", + updatedBy: userId, + updatedAt: new Date() + }) + .where( + and( + eq(rfqLastVendorResponses.rfqsLastId, rfqId), + eq(rfqLastVendorResponses.vendorId, vendorId), + eq(rfqLastVendorResponses.isLatest, true), + // 이미 취소된 것은 제외 + inArray(rfqLastVendorResponses.status, ["대기중", "작성중", "제출완료", "수정요청", "최종확정"]) + ) + ); + + // 2-4. TBE 세션이 진행중이면 취소 처리 + await tx + .update(rfqLastTbeSessions) + .set({ + status: "취소", + updatedBy: userId, + updatedAt: new Date() + }) + .where( + and( + eq(rfqLastTbeSessions.rfqsLastId, rfqId), + eq(rfqLastTbeSessions.vendorId, vendorId), + inArray(rfqLastTbeSessions.status, ["생성중", "준비중", "진행중", "검토중", "보류"]) + ) + ); + }); + + results.push({ + detailId: detail.id, + success: true + }); + + } catch (error) { + console.error(`벤더 응답 취소 실패 (Detail ID: ${detail.id}):`, error); + results.push({ + detailId: detail.id, + success: false, + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } + + // 3. 캐시 갱신 + revalidatePath(`/evcp/rfq-last/${rfqId}`); + revalidatePath(`/evcp/rfq-last/${rfqId}/vendor`); + + const successCount = results.filter(r => r.success).length; + const failCount = results.length - successCount; + + if (failCount === 0) { + return { + success: true, + message: `RFQ 취소가 완료되었습니다. (${successCount}건)`, + results + }; + } else { + return { + success: false, + message: `RFQ 취소 중 일부 실패했습니다. (성공: ${successCount}건, 실패: ${failCount}건)`, + results + }; + } + + } catch (error) { + console.error("RFQ 벤더 응답 취소 처리 중 오류:", error); + return { + success: false, + message: error instanceof Error ? error.message : "RFQ 취소 처리 중 오류가 발생했습니다." + }; + } +} + diff --git a/lib/rfq-last/delete-action.ts b/lib/rfq-last/delete-action.ts new file mode 100644 index 00000000..3b5f13de --- /dev/null +++ b/lib/rfq-last/delete-action.ts @@ -0,0 +1,199 @@ +'use server' + +import { revalidatePath } from "next/cache"; +import db from "@/db/db"; +import { rfqsLast, rfqLastTbeSessions, rfqLastDetails } from "@/db/schema"; +import { rfqLastVendorResponses } from "@/db/schema/rfqVendor"; +import { eq, and, inArray } from "drizzle-orm"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { cancelRFQ } from "@/lib/soap/ecc/send/delete-rfq"; + +/** + * RFQ 삭제 (상태 변경) 서버 액션 + * ANFNR이 있는 RFQ만 삭제 가능하며, ECC로 SOAP 취소 요청을 전송한 후 + * 성공 시 RFQ 상태를 "RFQ 삭제"로 변경하고 연결된 TBE 세션을 "취소" 상태로 변경 + * 또한 연결된 vendor response를 "RFQ 삭제" 상태로 변경하고, + * 업체선정이 진행중인 경우 업체선정 취소 처리 + */ +export async function deleteRfq(rfqIds: number[], deleteReason?: string): Promise<{ + success: boolean; + message: string; + results?: Array<{ rfqId: number; success: boolean; error?: string }>; +}> { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.id) { + return { + success: false, + message: "인증이 필요합니다." + }; + } + + const userId = Number(session.user.id); + + // 1. RFQ 정보 조회 및 ANFNR 유효성 검증 + const rfqs = await db.query.rfqsLast.findMany({ + where: inArray(rfqsLast.id, rfqIds), + columns: { + id: true, + rfqCode: true, + ANFNR: true, + status: true, + } + }); + + // ANFNR이 있는 RFQ만 필터링 + const rfqsWithAnfnr = rfqs.filter(rfq => rfq.ANFNR && rfq.ANFNR.trim() !== ""); + + if (rfqsWithAnfnr.length === 0) { + return { + success: false, + message: "ANFNR이 있는 RFQ가 선택되지 않았습니다." + }; + } + + // 요청된 RFQ 중 일부가 없거나 ANFNR이 없는 경우 확인 + const missingIds = rfqIds.filter(id => !rfqs.find(r => r.id === id)); + const rfqsWithoutAnfnr = rfqs.filter(rfq => !rfq.ANFNR || rfq.ANFNR.trim() === ""); + + if (missingIds.length > 0 || rfqsWithoutAnfnr.length > 0) { + const warnings: string[] = []; + if (missingIds.length > 0) { + warnings.push(`존재하지 않는 RFQ: ${missingIds.join(", ")}`); + } + if (rfqsWithoutAnfnr.length > 0) { + warnings.push(`ANFNR이 없는 RFQ: ${rfqsWithoutAnfnr.map(r => r.rfqCode || r.id).join(", ")}`); + } + } + + const results: Array<{ rfqId: number; success: boolean; error?: string }> = []; + + // 2. 각 RFQ에 대해 ECC 취소 요청 및 상태 변경 처리 + for (const rfq of rfqsWithAnfnr) { + try { + // 2-1. ECC로 SOAP 취소 요청 전송 + const cancelResult = await cancelRFQ(rfq.ANFNR!); + + if (!cancelResult.success) { + results.push({ + rfqId: rfq.id, + success: false, + error: cancelResult.message + }); + continue; + } + + // 2-2. ECC 요청 성공 시 트랜잭션 내에서 상태 변경 + await db.transaction(async (tx) => { + // RFQ 상태를 "RFQ 삭제"로 변경 및 삭제 사유 저장 + await tx + .update(rfqsLast) + .set({ + status: "RFQ 삭제", + deleteReason: deleteReason || null, + updatedBy: userId, + updatedAt: new Date() + }) + .where(eq(rfqsLast.id, rfq.id)); + + // 연결된 모든 TBE 세션을 "취소" 상태로 변경 + // TBE 세션이 없어도 정상 동작 (조건에 맞는 레코드가 없으면 업데이트 없이 종료) + await tx + .update(rfqLastTbeSessions) + .set({ + status: "취소", + updatedBy: userId, + updatedAt: new Date() + }) + .where( + and( + eq(rfqLastTbeSessions.rfqsLastId, rfq.id), + inArray(rfqLastTbeSessions.status, ["생성중", "준비중", "진행중", "검토중", "보류"]) + ) + ); + + // 연결된 모든 vendor response를 "취소" 상태로 변경 (RFQ 삭제 처리) + // 참고: 스키마에 "RFQ 삭제" 상태가 없으므로 "취소" 상태를 사용 + await tx + .update(rfqLastVendorResponses) + .set({ + status: "취소", + updatedBy: userId, + updatedAt: new Date() + }) + .where( + and( + eq(rfqLastVendorResponses.rfqsLastId, rfq.id), + eq(rfqLastVendorResponses.isLatest, true), + inArray(rfqLastVendorResponses.status, ["대기중", "작성중", "제출완료", "수정요청", "최종확정"]) + ) + ); + + // 업체선정이 진행중인 경우 취소 처리 + // isSelected가 true인 경우 또는 contractStatus가 "일반계약 진행중"인 경우 + await tx + .update(rfqLastDetails) + .set({ + isSelected: false, + selectionDate: null, + selectionReason: null, + selectedBy: null, + contractStatus: null, // 계약 상태 초기화 + updatedBy: userId, + updatedAt: new Date() + }) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfq.id), + eq(rfqLastDetails.isLatest, true) + ) + ); + }); + + // 2-3. 캐시 갱신 + revalidatePath("/evcp/rfq-last"); + revalidatePath(`/evcp/rfq-last/${rfq.id}`); + + results.push({ + rfqId: rfq.id, + success: true + }); + + } catch (error) { + console.error(`RFQ 삭제 실패 (ID: ${rfq.id}, ANFNR: ${rfq.ANFNR}):`, error); + results.push({ + rfqId: rfq.id, + success: false, + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } + + const successCount = results.filter(r => r.success).length; + const failCount = results.length - successCount; + + if (failCount === 0) { + return { + success: true, + message: `RFQ 삭제가 완료되었습니다. (${successCount}건)`, + results + }; + } else { + return { + success: false, + message: `RFQ 삭제 중 일부 실패했습니다. (성공: ${successCount}건, 실패: ${failCount}건)`, + results + }; + } + + } catch (error) { + console.error("RFQ 삭제 처리 중 오류:", error); + return { + success: false, + message: error instanceof Error ? error.message : "RFQ 삭제 처리 중 오류가 발생했습니다." + }; + } +} + diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts index 461a635a..65ead12b 100644 --- a/lib/rfq-last/service.ts +++ b/lib/rfq-last/service.ts @@ -3,7 +3,7 @@ import { revalidatePath, unstable_cache, unstable_noStore } from "next/cache"; import db from "@/db/db"; -import { avlVendorInfo, paymentTerms, incoterms, rfqLastVendorQuotationItems, rfqLastVendorAttachments, rfqLastVendorResponses, RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView, vendors, rfqLastDetails, rfqLastVendorResponseHistory, rfqLastDetailsView, vendorContacts, projects, basicContract, basicContractTemplates, rfqLastTbeSessions, rfqLastTbeDocumentReviews, templateDetailView, RfqStatus, purchaseRequests } from "@/db/schema"; +import { avlVendorInfo, paymentTerms, incoterms, rfqLastVendorQuotationItems, rfqLastVendorAttachments, rfqLastVendorResponses, RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView, vendors, rfqLastDetails, rfqLastVendorResponseHistory, rfqLastDetailsView, vendorContacts, projects, basicContract, basicContractTemplates, rfqLastTbeSessions, rfqLastTbeDocumentReviews, templateDetailView, RfqStatus, rfqLastPriceAdjustmentForms } from "@/db/schema"; import { sql, and, desc, asc, like, ilike, or, eq, SQL, count, gte, lte, isNotNull, ne, inArray } from "drizzle-orm"; import { filterColumns } from "@/lib/filter-columns"; import { GetRfqLastAttachmentsSchema, GetRfqsSchema } from "./validations"; @@ -1851,13 +1851,41 @@ export async function getRfqVendorResponses(rfqId: number) { .where(eq(rfqLastVendorAttachments.vendorResponseId, response.id)) .orderBy(rfqLastVendorAttachments.attachmentType, rfqLastVendorAttachments.uploadedAt); + // 연동제 폼 조회 + const priceAdjustmentForm = await db + .select({ + id: rfqLastPriceAdjustmentForms.id, + rfqLastVendorResponsesId: rfqLastPriceAdjustmentForms.rfqLastVendorResponsesId, + itemName: rfqLastPriceAdjustmentForms.itemName, + adjustmentReflectionPoint: rfqLastPriceAdjustmentForms.adjustmentReflectionPoint, + majorApplicableRawMaterial: rfqLastPriceAdjustmentForms.majorApplicableRawMaterial, + adjustmentFormula: rfqLastPriceAdjustmentForms.adjustmentFormula, + rawMaterialPriceIndex: rfqLastPriceAdjustmentForms.rawMaterialPriceIndex, + referenceDate: rfqLastPriceAdjustmentForms.referenceDate, + comparisonDate: rfqLastPriceAdjustmentForms.comparisonDate, + adjustmentRatio: rfqLastPriceAdjustmentForms.adjustmentRatio, + notes: rfqLastPriceAdjustmentForms.notes, + adjustmentConditions: rfqLastPriceAdjustmentForms.adjustmentConditions, + majorNonApplicableRawMaterial: rfqLastPriceAdjustmentForms.majorNonApplicableRawMaterial, + adjustmentPeriod: rfqLastPriceAdjustmentForms.adjustmentPeriod, + contractorWriter: rfqLastPriceAdjustmentForms.contractorWriter, + adjustmentDate: rfqLastPriceAdjustmentForms.adjustmentDate, + nonApplicableReason: rfqLastPriceAdjustmentForms.nonApplicableReason, + createdAt: rfqLastPriceAdjustmentForms.createdAt, + updatedAt: rfqLastPriceAdjustmentForms.updatedAt, + }) + .from(rfqLastPriceAdjustmentForms) + .where(eq(rfqLastPriceAdjustmentForms.rfqLastVendorResponsesId, response.id)) + .limit(1); + // 해당 벤더의 총 응답 수 가져오기 const vendorResponseCount = responseCountMap.get(response.vendorId) || 0; return { ...response, - quotationItems, - attachments, + quotationItems: quotationItems || [], + attachments: attachments || [], + priceAdjustmentForm: priceAdjustmentForm && priceAdjustmentForm.length > 0 ? priceAdjustmentForm[0] : null, vendorResponseCount, }; }) @@ -1933,6 +1961,7 @@ export async function getRfqVendorResponses(rfqId: number) { materialPriceRelated: { required: response.vendorMaterialPriceRelatedYn, reason: response.vendorMaterialPriceRelatedReason, + priceAdjustmentForm: response.priceAdjustmentForm || null, }, }, @@ -1958,8 +1987,11 @@ export async function getRfqVendorResponses(rfqId: number) { technical: response.technicalProposal, }, + // 연동제 폼 정보 (최상위 레벨에 추가) + priceAdjustmentForm: response.priceAdjustmentForm || null, + // 견적 아이템 상세 - quotationItems: response.quotationItems.map(item => ({ + quotationItems: (response.quotationItems || []).map(item => ({ id: item.id, rfqPrItemId: item.rfqPrItemId, prNo: item.prNo, @@ -1984,7 +2016,7 @@ export async function getRfqVendorResponses(rfqId: number) { })), // 첨부파일 상세 - attachments: response.attachments.map(file => ({ + attachments: (response.attachments || []).map(file => ({ id: file.id, attachmentType: file.attachmentType, documentNo: file.documentNo, diff --git a/lib/rfq-last/table/delete-rfq-dialog.tsx b/lib/rfq-last/table/delete-rfq-dialog.tsx new file mode 100644 index 00000000..01af5453 --- /dev/null +++ b/lib/rfq-last/table/delete-rfq-dialog.tsx @@ -0,0 +1,254 @@ +"use client"; + +import * as React from "react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { RfqsLastView } from "@/db/schema"; +import { deleteRfq } from "@/lib/rfq-last/delete-action"; +import { Loader2, AlertTriangle } from "lucide-react"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { toast } from "sonner"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; + +interface DeleteRfqDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + selectedRfqs: RfqsLastView[]; + onSuccess?: () => void; +} + +export function DeleteRfqDialog({ + open, + onOpenChange, + selectedRfqs, + onSuccess, +}: DeleteRfqDialogProps) { + const [isDeleting, setIsDeleting] = React.useState(false); + const [deleteReason, setDeleteReason] = React.useState(""); + + // ANFNR이 있는 RFQ만 필터링 + const rfqsWithAnfnr = React.useMemo(() => { + return selectedRfqs.filter(rfq => rfq.ANFNR && rfq.ANFNR.trim() !== ""); + }, [selectedRfqs]); + + const handleDelete = async () => { + if (rfqsWithAnfnr.length === 0) { + toast.error("ANFNR이 있는 RFQ가 선택되지 않았습니다."); + return; + } + + if (!deleteReason || deleteReason.trim() === "") { + toast.error("삭제 사유를 입력해주세요."); + return; + } + + setIsDeleting(true); + + try { + const rfqIds = rfqsWithAnfnr.map(rfq => rfq.id); + const result = await deleteRfq(rfqIds, deleteReason.trim()); + + if (result.results) { + const successCount = result.results.filter(r => r.success).length; + const failCount = result.results.length - successCount; + + if (result.success) { + // 성공한 RFQ 목록 + const successRfqs = result.results + .filter(r => r.success) + .map(r => { + const rfq = rfqsWithAnfnr.find(rf => rf.id === r.rfqId); + return rfq?.rfqCode || `RFQ ID: ${r.rfqId}`; + }); + + if (successCount > 0) { + toast.success( + `RFQ 삭제가 완료되었습니다. (${successCount}건)`, + { + description: successRfqs.length <= 3 + ? successRfqs.join(", ") + : `${successRfqs.slice(0, 3).join(", ")} 외 ${successRfqs.length - 3}건`, + duration: 5000, + } + ); + } + + // 실패한 RFQ가 있는 경우 + if (failCount > 0) { + const failRfqs = result.results + .filter(r => !r.success) + .map(r => { + const rfq = rfqsWithAnfnr.find(rf => rf.id === r.rfqId); + return `${rfq?.rfqCode || r.rfqId}: ${r.error || "알 수 없는 오류"}`; + }); + + toast.error( + `${failCount}건의 RFQ 삭제가 실패했습니다.`, + { + description: failRfqs.length <= 3 + ? failRfqs.join(", ") + : `${failRfqs.slice(0, 3).join(", ")} 외 ${failRfqs.length - 3}건`, + duration: 7000, + } + ); + } + } else { + // 전체 실패 + toast.error(result.message || "RFQ 삭제에 실패했습니다."); + } + } else { + if (result.success) { + toast.success(result.message); + } else { + toast.error(result.message); + } + } + + // 성공 여부와 관계없이 다이얼로그 닫기 및 콜백 호출 + if (result.success) { + setDeleteReason(""); // 성공 시 입력 필드 초기화 + onOpenChange(false); + onSuccess?.(); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : "RFQ 삭제 중 오류가 발생했습니다."); + } finally { + setIsDeleting(false); + } + }; + + const handleClose = () => { + if (!isDeleting) { + setDeleteReason(""); // 다이얼로그 닫을 때 입력 필드 초기화 + onOpenChange(false); + } + }; + + // ANFNR이 없는 RFQ가 포함된 경우 경고 표시 + const rfqsWithoutAnfnr = selectedRfqs.filter(rfq => !rfq.ANFNR || rfq.ANFNR.trim() === ""); + const hasWarning = rfqsWithoutAnfnr.length > 0; + + return ( + + + + RFQ 삭제 + + {isDeleting ? ( + /* 로딩 중 상태 - 다른 내용 숨김 */ +
+ +
+ RFQ 삭제 처리 중... + + ECC로 취소 요청을 전송하고 있습니다. + +
+
+ ) : ( + <> +
+ 선택된 RFQ 중 ANFNR이 있는 RFQ만 삭제됩니다. +
+ + {/* 삭제 대상 RFQ 목록 */} + {rfqsWithAnfnr.length > 0 && ( +
+

삭제 대상 RFQ ({rfqsWithAnfnr.length}건):

+
+ {rfqsWithAnfnr.map((rfq) => ( +
+ {rfq.rfqCode} + {rfq.rfqTitle && ( + + - {rfq.rfqTitle} + + )} +
+ ))} +
+
+ )} + + {/* ANFNR이 없는 RFQ 경고 */} + {hasWarning && ( + + + +
+

ANFNR이 없는 RFQ는 삭제할 수 없습니다 ({rfqsWithoutAnfnr.length}건):

+
+ {rfqsWithoutAnfnr.map((rfq) => ( +
+ {rfq.rfqCode} + {rfq.rfqTitle && ( + + - {rfq.rfqTitle} + + )} +
+ ))} +
+
+
+
+ )} + + {/* 삭제 사유 입력 */} +
+ +