summaryrefslogtreecommitdiff
path: root/lib/rfq-last/service.ts
diff options
context:
space:
mode:
Diffstat (limited to 'lib/rfq-last/service.ts')
-rw-r--r--lib/rfq-last/service.ts1267
1 files changed, 1207 insertions, 60 deletions
diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts
index ffeed1b1..67cb901f 100644
--- a/lib/rfq-last/service.ts
+++ b/lib/rfq-last/service.ts
@@ -1,12 +1,14 @@
// lib/rfq/service.ts
'use server'
-import { unstable_cache, unstable_noStore } from "next/cache";
+import { revalidatePath, unstable_cache, unstable_noStore } from "next/cache";
import db from "@/db/db";
-import { RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView } from "@/db/schema";
-import {sql, and, desc, asc, like, ilike, or, eq, SQL, count, gte, lte, isNotNull, ne, inArray } from "drizzle-orm";
+import {paymentTerms,incoterms, rfqLastVendorQuotationItems,rfqLastVendorAttachments,rfqLastVendorResponses, RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView ,vendors, rfqLastDetails, rfqLastVendorResponseHistory, rfqLastDetailsView} 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";
+import { getServerSession } from "next-auth/next"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
export async function getRfqs(input: GetRfqsSchema) {
unstable_noStore();
@@ -172,68 +174,57 @@ export const findRfqLastById = async (id: number): Promise<RfqsLastView | null>
};
-export async function getRfqLastAttachments(
- input: GetRfqLastAttachmentsSchema,
- rfqId: number,
- attachmentType: "설계" | "구매"
-) {
+// 모든 첨부파일을 가져오는 새로운 서버 액션
+export async function getRfqAllAttachments(rfqId: number) {
try {
- const offset = (input.page - 1) * input.perPage
-
- // Advanced Filter 처리 (메인 테이블 기준)
- const advancedWhere = filterColumns({
- table: rfqLastAttachments,
- filters: input.filters,
- joinOperator: input.joinOperator,
- })
-
- // 전역 검색
- let globalWhere
- if (input.search) {
- const s = `%${input.search}%`
- globalWhere = or(
- ilike(rfqLastAttachments.serialNo, s),
- ilike(rfqLastAttachments.description, s),
- ilike(rfqLastAttachments.currentRevision, s),
- ilike(rfqLastAttachmentRevisions.fileName, s),
- ilike(rfqLastAttachmentRevisions.originalFileName, s)
+ // 데이터 조회
+ const data = await db
+ .select({
+ // 첨부파일 메인 정보
+ id: rfqLastAttachments.id,
+ attachmentType: rfqLastAttachments.attachmentType,
+ serialNo: rfqLastAttachments.serialNo,
+ rfqId: rfqLastAttachments.rfqId,
+ currentRevision: rfqLastAttachments.currentRevision,
+ latestRevisionId: rfqLastAttachments.latestRevisionId,
+ description: rfqLastAttachments.description,
+ createdBy: rfqLastAttachments.createdBy,
+ createdAt: rfqLastAttachments.createdAt,
+ updatedAt: rfqLastAttachments.updatedAt,
+
+ // 최신 리비전 파일 정보
+ fileName: rfqLastAttachmentRevisions.fileName,
+ originalFileName: rfqLastAttachmentRevisions.originalFileName,
+ filePath: rfqLastAttachmentRevisions.filePath,
+ fileSize: rfqLastAttachmentRevisions.fileSize,
+ fileType: rfqLastAttachmentRevisions.fileType,
+ revisionComment: rfqLastAttachmentRevisions.revisionComment,
+
+ // 생성자 정보
+ createdByName: users.name,
+ })
+ .from(rfqLastAttachments)
+ .leftJoin(
+ rfqLastAttachmentRevisions,
+ and(
+ eq(rfqLastAttachments.latestRevisionId, rfqLastAttachmentRevisions.id),
+ eq(rfqLastAttachmentRevisions.isLatest, true)
+ )
)
- }
+ .leftJoin(users, eq(rfqLastAttachments.createdBy, users.id))
+ .where(eq(rfqLastAttachments.rfqId, rfqId))
+ .orderBy(desc(rfqLastAttachments.createdAt))
- // 파일 타입 필터
- let fileTypeWhere
- if (input.fileType && input.fileType.length > 0) {
- fileTypeWhere = inArray(rfqLastAttachmentRevisions.fileType, input.fileType)
+ return {
+ data,
+ success: true
}
-
- // 최종 WHERE 절
- const finalWhere = and(
- eq(rfqLastAttachments.rfqId, rfqId),
- eq(rfqLastAttachments.attachmentType, attachmentType),
- advancedWhere,
- globalWhere,
- fileTypeWhere
- )
-
- // 정렬
- const orderBy = input.sort.length > 0
- ? input.sort.map((item) =>
- item.desc
- ? desc(rfqLastAttachments[item.id as keyof typeof rfqLastAttachments])
- : asc(rfqLastAttachments[item.id as keyof typeof rfqLastAttachments])
- )
- : [desc(rfqLastAttachments.createdAt)]
-
- // 데이터 조회 (기존 코드와 동일)
- const { data, total } = await db.transaction(async (tx) => {
- // ... 기존 조회 로직
- })
-
- const pageCount = Math.ceil(total / input.perPage)
- return { data, pageCount }
} catch (err) {
- console.error("getRfqAttachments error:", err)
- return { data: [], pageCount: 0 }
+ console.error("getRfqAllAttachments error:", err)
+ return {
+ data: [],
+ success: false
+ }
}
}
// 사용자 목록 조회 (필터용)
@@ -689,3 +680,1159 @@ export async function getRfqBasicInfoAction(rfqId: number) {
}
}
+export interface RevisionHistory {
+ id: number;
+ attachmentId: number;
+ revisionNo: string;
+ fileName: string;
+ originalFileName: string;
+ filePath: string;
+ fileSize: number;
+ fileType: string;
+ isLatest: boolean;
+ revisionComment: string | null;
+ createdBy: number;
+ createdAt: Date;
+ createdByName: string | null;
+}
+
+export interface AttachmentWithHistory {
+ id: number;
+ serialNo: string | null;
+ description: string | null;
+ currentRevision: string | null;
+ originalFileName: string | null;
+ revisions: RevisionHistory[];
+}
+
+// 리비전 히스토리 조회
+export async function getRevisionHistory(attachmentId: number): Promise<{
+ success: boolean;
+ data?: AttachmentWithHistory;
+ error?: string;
+}> {
+ try {
+ // 첨부파일 기본 정보 조회
+ const [attachment] = await db
+ .select({
+ id: rfqLastAttachments.id,
+ serialNo: rfqLastAttachments.serialNo,
+ description: rfqLastAttachments.description,
+ currentRevision: rfqLastAttachments.currentRevision,
+ latestRevisionId: rfqLastAttachments.latestRevisionId,
+ })
+ .from(rfqLastAttachments)
+ .where(eq(rfqLastAttachments.id, attachmentId));
+
+ if (!attachment) {
+ return {
+ success: false,
+ error: "첨부파일을 찾을 수 없습니다.",
+ };
+ }
+
+ // 최신 리비전 정보 조회 (파일명 가져오기 위해)
+ let originalFileName: string | null = null;
+ if (attachment.latestRevisionId) {
+ const [latestRevision] = await db
+ .select({
+ originalFileName: rfqLastAttachmentRevisions.originalFileName,
+ })
+ .from(rfqLastAttachmentRevisions)
+ .where(eq(rfqLastAttachmentRevisions.id, attachment.latestRevisionId));
+
+ originalFileName = latestRevision?.originalFileName || null;
+ }
+
+ // 모든 리비전 히스토리 조회
+ const revisions = await db
+ .select({
+ id: rfqLastAttachmentRevisions.id,
+ attachmentId: rfqLastAttachmentRevisions.attachmentId,
+ revisionNo: rfqLastAttachmentRevisions.revisionNo,
+ fileName: rfqLastAttachmentRevisions.fileName,
+ originalFileName: rfqLastAttachmentRevisions.originalFileName,
+ filePath: rfqLastAttachmentRevisions.filePath,
+ fileSize: rfqLastAttachmentRevisions.fileSize,
+ fileType: rfqLastAttachmentRevisions.fileType,
+ isLatest: rfqLastAttachmentRevisions.isLatest,
+ revisionComment: rfqLastAttachmentRevisions.revisionComment,
+ createdBy: rfqLastAttachmentRevisions.createdBy,
+ createdAt: rfqLastAttachmentRevisions.createdAt,
+ createdByName: users.name,
+ })
+ .from(rfqLastAttachmentRevisions)
+ .leftJoin(users, eq(rfqLastAttachmentRevisions.createdBy, users.id))
+ .where(eq(rfqLastAttachmentRevisions.attachmentId, attachmentId))
+ .orderBy(desc(rfqLastAttachmentRevisions.createdAt));
+
+ return {
+ success: true,
+ data: {
+ ...attachment,
+ originalFileName,
+ revisions,
+ },
+ };
+ } catch (error) {
+ console.error("Get revision history error:", error);
+ return {
+ success: false,
+ error: "리비전 히스토리 조회 중 오류가 발생했습니다.",
+ };
+ }
+}
+
+// 특정 리비전 다운로드 URL 생성
+export async function getRevisionDownloadUrl(revisionId: number): Promise<{
+ success: boolean;
+ data?: {
+ url: string;
+ fileName: string;
+ };
+ error?: string;
+}> {
+ try {
+ const [revision] = await db
+ .select({
+ filePath: rfqLastAttachmentRevisions.filePath,
+ originalFileName: rfqLastAttachmentRevisions.originalFileName,
+ })
+ .from(rfqLastAttachmentRevisions)
+ .where(eq(rfqLastAttachmentRevisions.id, revisionId));
+
+ if (!revision) {
+ return {
+ success: false,
+ error: "리비전을 찾을 수 없습니다.",
+ };
+ }
+
+ return {
+ success: true,
+ data: {
+ url: revision.filePath,
+ fileName: revision.originalFileName,
+ },
+ };
+ } catch (error) {
+ console.error("Get revision download URL error:", error);
+ return {
+ success: false,
+ error: "다운로드 URL 생성 중 오류가 발생했습니다.",
+ };
+ }
+}
+
+export async function getRfqVendorAttachments(rfqId: number) {
+ try {
+ // 데이터 조회
+ const data = await db
+ .select({
+ // 첨부파일 메인 정보
+ id: rfqLastVendorAttachments.id,
+ vendorResponseId: rfqLastVendorAttachments.vendorResponseId,
+ attachmentType: rfqLastVendorAttachments.attachmentType,
+ documentNo: rfqLastVendorAttachments.documentNo,
+
+ // 파일 정보
+ fileName: rfqLastVendorAttachments.fileName,
+ originalFileName: rfqLastVendorAttachments.originalFileName,
+ filePath: rfqLastVendorAttachments.filePath,
+ fileSize: rfqLastVendorAttachments.fileSize,
+ fileType: rfqLastVendorAttachments.fileType,
+
+ // 파일 설명
+ description: rfqLastVendorAttachments.description,
+
+ // 유효기간
+ validFrom: rfqLastVendorAttachments.validFrom,
+ validTo: rfqLastVendorAttachments.validTo,
+
+ // 업로드 정보
+ uploadedBy: rfqLastVendorAttachments.uploadedBy,
+ uploadedAt: rfqLastVendorAttachments.uploadedAt,
+
+ // 업로더 정보
+ uploadedByName: users.name,
+
+ // 벤더 정보
+ vendorId: rfqLastVendorResponses.vendorId,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+
+ // 응답 상태
+ responseStatus: rfqLastVendorResponses.status,
+ responseVersion: rfqLastVendorResponses.responseVersion,
+ })
+ .from(rfqLastVendorAttachments)
+ .leftJoin(
+ rfqLastVendorResponses,
+ eq(rfqLastVendorAttachments.vendorResponseId, rfqLastVendorResponses.id)
+ )
+ .leftJoin(users, eq(rfqLastVendorAttachments.uploadedBy, users.id))
+ .leftJoin(vendors, eq(rfqLastVendorResponses.vendorId, vendors.id))
+ .where(eq(rfqLastVendorResponses.rfqsLastId, rfqId))
+ .orderBy(desc(rfqLastVendorAttachments.uploadedAt))
+
+ return {
+ vendorData,
+ vendorSuccess: true
+ }
+ } catch (err) {
+ console.error("getRfqVendorAttachments error:", err)
+ return {
+ vendorData: [],
+ vendorSuccess: false
+ }
+ }
+}
+
+
+
+// 벤더 추가 액션
+export async function addVendorToRfq({
+ rfqId,
+ vendorId,
+ conditions,
+}: {
+ rfqId: number;
+ vendorId: number;
+ conditions: {
+ currency: string;
+ paymentTermsCode: string;
+ incotermsCode: string;
+ incotermsDetail?: string;
+ deliveryDate: Date;
+ contractDuration?: string;
+ taxCode?: string;
+ placeOfShipping?: string;
+ placeOfDestination?: string;
+ materialPriceRelatedYn?: boolean;
+ sparepartYn?: boolean;
+ firstYn?: boolean;
+ firstDescription?: string;
+ sparepartDescription?: string;
+ };
+}) {
+ try {
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+ const userId = Number(session.user.id)
+ // 중복 체크
+ const existing = await db
+ .select()
+ .from(rfqLastDetails)
+ .where(
+ and(
+ eq(rfqLastDetails.rfqsLastId, rfqId),
+ eq(rfqLastDetails.vendorsId, vendorId)
+ )
+ )
+ .limit(1);
+
+ if (existing.length > 0) {
+ return { success: false, error: "이미 추가된 벤더입니다." };
+ }
+
+ // 트랜잭션으로 처리
+ await db.transaction(async (tx) => {
+ // 1. rfqLastDetails에 벤더 추가
+ const [detail] = await tx
+ .insert(rfqLastDetails)
+ .values({
+ rfqsLastId: rfqId,
+ vendorsId: vendorId,
+ ...conditions,
+ updatedBy: userId,
+ })
+ .returning();
+
+ // 2. rfqLastVendorResponses에 초기 응답 레코드 생성
+ const [response] = await tx
+ .insert(rfqLastVendorResponses)
+ .values({
+ rfqsLastId: rfqId,
+ rfqLastDetailsId: detail.id,
+ vendorId: vendorId,
+ status: "초대됨",
+ responseVersion: 1,
+ isLatest: true,
+ currency: conditions.currency,
+ // 구매자 제시 조건 복사 (초기값)
+ vendorCurrency: conditions.currency,
+ vendorPaymentTermsCode: conditions.paymentTermsCode,
+ vendorIncotermsCode: conditions.incotermsCode,
+ vendorIncotermsDetail: conditions.incotermsDetail,
+ vendorDeliveryDate: conditions.deliveryDate,
+ vendorContractDuration: conditions.contractDuration,
+ vendorTaxCode: conditions.taxCode,
+ vendorPlaceOfShipping: conditions.placeOfShipping,
+ vendorPlaceOfDestination: conditions.placeOfDestination,
+ vendorMaterialPriceRelatedYn: conditions.materialPriceRelatedYn,
+ vendorSparepartYn: conditions.sparepartYn,
+ vendorFirstYn: conditions.firstYn,
+ vendorFirstDescription: conditions.firstDescription,
+ vendorSparepartDescription: conditions.sparepartDescription,
+ createdBy: user.id,
+ updatedBy: user.id,
+ })
+ .returning();
+
+ // 3. 이력 기록
+ await tx.insert(rfqLastVendorResponseHistory).values({
+ vendorResponseId: response.id,
+ action: "생성",
+ newStatus: "초대됨",
+ changeDetails: { action: "벤더 초대", conditions },
+ performedBy: userId,
+ });
+ });
+
+ revalidatePath(`/rfq-last/${rfqId}/vendor`);
+
+ return { success: true };
+ } catch (error) {
+ console.error("Add vendor error:", error);
+ return { success: false, error: "벤더 추가 중 오류가 발생했습니다." };
+ }
+}
+
+export async function addVendorsToRfq({
+ rfqId,
+ vendorIds,
+ conditions,
+}: {
+ rfqId: number;
+ vendorIds: number[];
+ conditions?: {
+ currency: string;
+ paymentTermsCode: string;
+ incotermsCode: string;
+ incotermsDetail?: string;
+ deliveryDate: Date;
+ contractDuration?: string;
+ taxCode?: string;
+ placeOfShipping?: string;
+ placeOfDestination?: string;
+ materialPriceRelatedYn?: boolean;
+ sparepartYn?: boolean;
+ firstYn?: boolean;
+ firstDescription?: string;
+ sparepartDescription?: string;
+ } | null;
+}) {
+ try {
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+ const userId = Number(session.user.id)
+
+ // 빈 배열 체크
+ if (!vendorIds || vendorIds.length === 0) {
+ return { success: false, error: "벤더를 선택해주세요." };
+ }
+
+ // 중복 체크 - 이미 추가된 벤더들 확인
+ const existingVendors = await db
+ .select({
+ vendorId: rfqLastDetails.vendorsId,
+ })
+ .from(rfqLastDetails)
+ .where(
+ and(
+ eq(rfqLastDetails.rfqsLastId, rfqId),
+ inArray(rfqLastDetails.vendorsId, vendorIds)
+ )
+ );
+
+ const existingVendorIds = existingVendors.map(v => v.vendorId);
+ const newVendorIds = vendorIds.filter(id => !existingVendorIds.includes(id));
+
+ if (newVendorIds.length === 0) {
+ return {
+ success: false,
+ error: "모든 벤더가 이미 추가되어 있습니다."
+ };
+ }
+
+ // 일부만 중복인 경우 경고 메시지 준비
+ const skippedCount = vendorIds.length - newVendorIds.length;
+
+ // 트랜잭션으로 처리
+ const results = await db.transaction(async (tx) => {
+ const addedVendors = [];
+
+ for (const vendorId of newVendorIds) {
+ // conditions가 없는 경우 기본값 설정
+ const vendorConditions = conditions || {
+ currency: "USD",
+ paymentTermsCode: "NET30",
+ incotermsCode: "FOB",
+ deliveryDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30일 후
+ taxCode: "VV",
+ };
+
+ // 1. rfqLastDetails에 벤더 추가
+ const [detail] = await tx
+ .insert(rfqLastDetails)
+ .values({
+ rfqsLastId: rfqId,
+ vendorsId: vendorId,
+ ...vendorConditions,
+ updatedBy: userId,
+ })
+ .returning();
+
+ // 2. rfqLastVendorResponses에 초기 응답 레코드 생성
+ const [response] = await tx
+ .insert(rfqLastVendorResponses)
+ .values({
+ rfqsLastId: rfqId,
+ rfqLastDetailsId: detail.id,
+ vendorId: vendorId,
+ status: "초대됨",
+ responseVersion: 1,
+ isLatest: true,
+ currency: vendorConditions.currency,
+ // 구매자 제시 조건 복사 (초기값)
+ vendorCurrency: vendorConditions.currency,
+ vendorPaymentTermsCode: vendorConditions.paymentTermsCode,
+ vendorIncotermsCode: vendorConditions.incotermsCode,
+ vendorIncotermsDetail: vendorConditions.incotermsDetail,
+ vendorDeliveryDate: vendorConditions.deliveryDate,
+ vendorContractDuration: vendorConditions.contractDuration,
+ vendorTaxCode: vendorConditions.taxCode,
+ vendorPlaceOfShipping: vendorConditions.placeOfShipping,
+ vendorPlaceOfDestination: vendorConditions.placeOfDestination,
+ vendorMaterialPriceRelatedYn: vendorConditions.materialPriceRelatedYn,
+ vendorSparepartYn: vendorConditions.sparepartYn,
+ vendorFirstYn: vendorConditions.firstYn,
+ vendorFirstDescription: vendorConditions.firstDescription,
+ vendorSparepartDescription: vendorConditions.sparepartDescription,
+ createdBy: userId,
+ updatedBy: userId,
+ })
+ .returning();
+
+ // 3. 이력 기록
+ await tx.insert(rfqLastVendorResponseHistory).values({
+ vendorResponseId: response.id,
+ action: "생성",
+ newStatus: "초대됨",
+ changeDetails: {
+ action: "벤더 초대",
+ conditions: vendorConditions,
+ batchAdd: true,
+ totalVendors: newVendorIds.length
+ },
+ performedBy: userId,
+ });
+
+ addedVendors.push({
+ vendorId,
+ detailId: detail.id,
+ responseId: response.id,
+ });
+ }
+
+ return addedVendors;
+ });
+
+ revalidatePath(`/rfq-last/${rfqId}/vendor`);
+
+ // 성공 메시지 구성
+ let message = `${results.length}개 벤더가 추가되었습니다.`;
+ if (skippedCount > 0) {
+ message += ` (${skippedCount}개는 이미 추가된 벤더로 제외)`;
+ }
+
+ return {
+ success: true,
+ data: {
+ added: results.length,
+ skipped: skippedCount,
+ message,
+ }
+ };
+ } catch (error) {
+ console.error("Add vendors error:", error);
+ return {
+ success: false,
+ error: "벤더 추가 중 오류가 발생했습니다."
+ };
+ }
+}
+
+// 벤더 조건 일괄 업데이트 함수 (추가)
+export async function updateVendorConditionsBatch({
+ rfqId,
+ vendorIds,
+ conditions,
+}: {
+ rfqId: number;
+ vendorIds: number[];
+ conditions: {
+ currency?: string;
+ paymentTermsCode?: string;
+ incotermsCode?: string;
+ incotermsDetail?: string;
+ deliveryDate?: Date;
+ contractDuration?: string;
+ taxCode?: string;
+ placeOfShipping?: string;
+ placeOfDestination?: string;
+ materialPriceRelatedYn?: boolean;
+ sparepartYn?: boolean;
+ firstYn?: boolean;
+ firstDescription?: string;
+ sparepartDescription?: string;
+ };
+}) {
+ try {
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+ const userId = Number(session.user.id)
+
+ if (!vendorIds || vendorIds.length === 0) {
+ return { success: false, error: "벤더를 선택해주세요." };
+ }
+
+ // 트랜잭션으로 처리
+ await db.transaction(async (tx) => {
+ // 1. rfqLastDetails 업데이트
+ await tx
+ .update(rfqLastDetails)
+ .set({
+ ...conditions,
+ updatedBy: userId,
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(rfqLastDetails.rfqsLastId, rfqId),
+ inArray(rfqLastDetails.vendorsId, vendorIds)
+ )
+ );
+
+ // 2. rfqLastVendorResponses의 구매자 제시 조건도 업데이트
+ const vendorConditions = Object.keys(conditions).reduce((acc, key) => {
+ if (conditions[key] !== undefined) {
+ acc[`vendor${key.charAt(0).toUpperCase() + key.slice(1)}`] = conditions[key];
+ }
+ return acc;
+ }, {});
+
+ await tx
+ .update(rfqLastVendorResponses)
+ .set({
+ ...vendorConditions,
+ updatedBy: userId,
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(rfqLastVendorResponses.rfqsLastId, rfqId),
+ inArray(rfqLastVendorResponses.vendorId, vendorIds),
+ eq(rfqLastVendorResponses.isLatest, true)
+ )
+ );
+
+ // 3. 이력 기록 (각 벤더별로)
+ const responses = await tx
+ .select({ id: rfqLastVendorResponses.id })
+ .from(rfqLastVendorResponses)
+ .where(
+ and(
+ eq(rfqLastVendorResponses.rfqsLastId, rfqId),
+ inArray(rfqLastVendorResponses.vendorId, vendorIds),
+ eq(rfqLastVendorResponses.isLatest, true)
+ )
+ );
+
+ for (const response of responses) {
+ await tx.insert(rfqLastVendorResponseHistory).values({
+ vendorResponseId: response.id,
+ action: "조건변경",
+ changeDetails: {
+ action: "조건 일괄 업데이트",
+ conditions,
+ batchUpdate: true,
+ totalVendors: vendorIds.length
+ },
+ performedBy: userId,
+ });
+ }
+ });
+
+ revalidatePath(`/rfq-last/${rfqId}/vendor`);
+
+ return {
+ success: true,
+ data: {
+ message: `${vendorIds.length}개 벤더의 조건이 업데이트되었습니다.`
+ }
+ };
+ } catch (error) {
+ console.error("Update vendor conditions error:", error);
+ return {
+ success: false,
+ error: "조건 업데이트 중 오류가 발생했습니다."
+ };
+ }
+}
+
+// RFQ 발송 액션
+export async function sendRfqToVendors({
+ rfqId,
+ vendorIds,
+}: {
+ rfqId: number;
+ vendorIds: number[];
+}) {
+ try {
+
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.")
+ }
+ const userId = Number(session.user.id)
+
+ // 벤더별 응답 상태 업데이트
+ for (const vendorId of vendorIds) {
+ const [response] = await db
+ .select()
+ .from(rfqLastVendorResponses)
+ .where(
+ and(
+ eq(rfqLastVendorResponses.rfqsLastId, rfqId),
+ eq(rfqLastVendorResponses.vendorId, vendorId),
+ eq(rfqLastVendorResponses.isLatest, true)
+ )
+ )
+ .limit(1);
+
+ if (response) {
+ // 상태 업데이트
+ await db
+ .update(rfqLastVendorResponses)
+ .set({
+ status: "작성중",
+ updatedBy: userId,
+ updatedAt: new Date(),
+ })
+ .where(eq(rfqLastVendorResponses.id, response.id));
+
+ // 이력 기록
+ await db.insert(rfqLastVendorResponseHistory).values({
+ vendorResponseId: response.id,
+ action: "발송",
+ previousStatus: response.status,
+ newStatus: "작성중",
+ changeDetails: { action: "RFQ 발송" },
+ performedBy: userId,
+ });
+ }
+ }
+
+ // TODO: 실제 이메일 발송 로직
+
+ revalidatePath(`/rfq-last/${rfqId}/vendor`);
+
+ return { success: true, count: vendorIds.length };
+ } catch (error) {
+ console.error("Send RFQ error:", error);
+ return { success: false, error: "RFQ 발송 중 오류가 발생했습니다." };
+ }
+}
+
+// 벤더 삭제 액션
+export async function removeVendorFromRfq({
+ rfqId,
+ vendorId,
+}: {
+ rfqId: number;
+ vendorId: number;
+}) {
+ try {
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+ // 응답 체크
+ const [response] = await db
+ .select()
+ .from(rfqLastVendorResponses)
+ .where(
+ and(
+ eq(rfqLastVendorResponses.rfqsLastId, rfqId),
+ eq(rfqLastVendorResponses.vendorId, vendorId),
+ eq(rfqLastVendorResponses.isLatest, true)
+ )
+ )
+ .limit(1);
+
+ if (response && response.status !== "초대됨") {
+ return {
+ success: false,
+ error: "이미 진행 중인 벤더는 삭제할 수 없습니다."
+ };
+ }
+
+ // 삭제
+ await db
+ .delete(rfqLastDetails)
+ .where(
+ and(
+ eq(rfqLastDetails.rfqsLastId, rfqId),
+ eq(rfqLastDetails.vendorsId, vendorId)
+ )
+ );
+
+ revalidatePath(`/rfq-last/${rfqId}/vendor`);
+
+ return { success: true };
+ } catch (error) {
+ console.error("Remove vendor error:", error);
+ return { success: false, error: "벤더 삭제 중 오류가 발생했습니다." };
+ }
+}
+
+// 벤더 응답 상태 업데이트
+export async function updateVendorResponseStatus({
+ responseId,
+ status,
+ reason,
+}: {
+ responseId: number;
+ status: "작성중" | "제출완료" | "수정요청" | "최종확정" | "취소";
+ reason?: string;
+}) {
+ try {
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+
+ const [current] = await db
+ .select()
+ .from(rfqLastVendorResponses)
+ .where(eq(rfqLastVendorResponses.id, responseId))
+ .limit(1);
+
+ if (!current) {
+ return { success: false, error: "응답을 찾을 수 없습니다." };
+ }
+
+ // 상태 업데이트
+ await db
+ .update(rfqLastVendorResponses)
+ .set({
+ status,
+ submittedAt: status === "제출완료" ? new Date() : current.submittedAt,
+ updatedBy: Number(session.user.id),
+ updatedAt: new Date(),
+ })
+ .where(eq(rfqLastVendorResponses.id, responseId));
+
+ // 이력 기록
+ await db.insert(rfqLastVendorResponseHistory).values({
+ vendorResponseId: responseId,
+ action: getActionFromStatus(status),
+ previousStatus: current.status,
+ newStatus: status,
+ changeReason: reason,
+ performedBy: Number(session.user.id),
+ });
+
+ revalidatePath(`/evcp/rfq-last/${current.rfqsLastId}/vendor`);
+
+ return { success: true };
+ } catch (error) {
+ console.error("Update status error:", error);
+ return { success: false, error: "상태 업데이트 중 오류가 발생했습니다." };
+ }
+}
+
+// 상태에 따른 액션 텍스트
+function getActionFromStatus(status: string): string {
+ switch (status) {
+ case "제출완료": return "제출";
+ case "수정요청": return "반려";
+ case "최종확정": return "승인";
+ case "취소": return "취소";
+ default: return "수정";
+ }
+}
+
+export async function getRfqVendorResponses(rfqId: number) {
+ try {
+ // 1. RFQ 기본 정보 조회
+ const rfqData = await db
+ .select({
+ id: rfqsLast.id,
+ rfqCode: rfqsLast.rfqCode,
+ title: rfqsLast.title,
+ status: rfqsLast.status,
+ startDate: rfqsLast.startDate,
+ endDate: rfqsLast.endDate,
+ })
+ .from(rfqsLast)
+ .where(eq(rfqsLast.id, rfqId))
+ .limit(1);
+
+ if (!rfqData || rfqData.length === 0) {
+ return {
+ success: false,
+ error: "RFQ를 찾을 수 없습니다.",
+ data: null
+ };
+ }
+
+ // 2. RFQ 세부 정보 조회 (복수 버전이 있을 수 있음)
+ const details = await db
+ .select()
+ .from(rfqLastDetails)
+ .where(eq(rfqLastDetails.rfqsLastId, rfqId))
+ .orderBy(desc(rfqLastDetails.version));
+
+ // 3. 벤더 응답 정보 조회 (벤더 정보, 제출자 정보 포함)
+ const vendorResponsesData = await db
+ .select({
+ // 응답 기본 정보
+ id: rfqLastVendorResponses.id,
+ rfqsLastId: rfqLastVendorResponses.rfqsLastId,
+ rfqLastDetailsId: rfqLastVendorResponses.rfqLastDetailsId,
+ responseVersion: rfqLastVendorResponses.responseVersion,
+ isLatest: rfqLastVendorResponses.isLatest,
+ status: rfqLastVendorResponses.status,
+
+ // 벤더 정보
+ vendorId: rfqLastVendorResponses.vendorId,
+ vendorCode: vendors.vendorCode,
+ vendorName: vendors.vendorName,
+ vendorEmail: vendors.email,
+
+ // 제출 정보
+ submittedAt: rfqLastVendorResponses.submittedAt,
+ submittedBy: rfqLastVendorResponses.submittedBy,
+ submittedByName: users.name,
+
+ // 금액 정보
+ totalAmount: rfqLastVendorResponses.totalAmount,
+ currency: rfqLastVendorResponses.currency,
+
+ // 벤더 제안 조건
+ vendorCurrency: rfqLastVendorResponses.vendorCurrency,
+ vendorPaymentTermsCode: rfqLastVendorResponses.vendorPaymentTermsCode,
+ vendorIncotermsCode: rfqLastVendorResponses.vendorIncotermsCode,
+ vendorDeliveryDate: rfqLastVendorResponses.vendorDeliveryDate,
+ vendorContractDuration: rfqLastVendorResponses.vendorContractDuration,
+
+ // 초도품/Spare part 응답
+ vendorFirstYn: rfqLastVendorResponses.vendorFirstYn,
+ vendorFirstAcceptance: rfqLastVendorResponses.vendorFirstAcceptance,
+ vendorSparepartYn: rfqLastVendorResponses.vendorSparepartYn,
+ vendorSparepartAcceptance: rfqLastVendorResponses.vendorSparepartAcceptance,
+
+ // 비고
+ generalRemark: rfqLastVendorResponses.generalRemark,
+ technicalProposal: rfqLastVendorResponses.technicalProposal,
+
+ // 타임스탬프
+ createdAt: rfqLastVendorResponses.createdAt,
+ updatedAt: rfqLastVendorResponses.updatedAt,
+ })
+ .from(rfqLastVendorResponses)
+ .leftJoin(vendors, eq(rfqLastVendorResponses.vendorId, vendors.id))
+ .leftJoin(users, eq(rfqLastVendorResponses.submittedBy, users.id))
+ .where(
+ and(
+ eq(rfqLastVendorResponses.rfqsLastId, rfqId),
+ eq(rfqLastVendorResponses.isLatest, true) // 최신 버전만 조회
+ )
+ )
+ .orderBy(desc(rfqLastVendorResponses.createdAt));
+
+ // 4. 각 벤더 응답별 견적 아이템 수와 첨부파일 수 계산
+ const vendorResponsesWithCounts = await Promise.all(
+ vendorResponsesData.map(async (response) => {
+ // 견적 아이템 수 조회
+ const itemCount = await db
+ .select({ count: sql`COUNT(*)::int` })
+ .from(rfqLastVendorQuotationItems)
+ .where(eq(rfqLastVendorQuotationItems.vendorResponseId, response.id));
+
+ // 첨부파일 수 조회
+ const attachmentCount = await db
+ .select({ count: sql`COUNT(*)::int` })
+ .from(rfqLastVendorAttachments)
+ .where(eq(rfqLastVendorAttachments.vendorResponseId, response.id));
+
+ return {
+ ...response,
+ quotedItemCount: itemCount[0]?.count || 0,
+ attachmentCount: attachmentCount[0]?.count || 0,
+ };
+ })
+ );
+
+ // 5. 응답 데이터 정리
+ const formattedResponses = vendorResponsesWithCounts.map(response => ({
+ id: response.id,
+ rfqsLastId: response.rfqsLastId,
+ rfqLastDetailsId: response.rfqLastDetailsId,
+ responseVersion: response.responseVersion,
+ isLatest: response.isLatest,
+ status: response.status || "초대됨", // 기본값 설정
+
+ // 벤더 정보
+ vendor: {
+ id: response.vendorId,
+ code: response.vendorCode,
+ name: response.vendorName,
+ email: response.vendorEmail,
+ },
+
+ // 제출 정보
+ submission: {
+ submittedAt: response.submittedAt,
+ submittedBy: response.submittedBy,
+ submittedByName: response.submittedByName,
+ },
+
+ // 금액 정보
+ pricing: {
+ totalAmount: response.totalAmount,
+ currency: response.currency || "USD",
+ vendorCurrency: response.vendorCurrency,
+ },
+
+ // 벤더 제안 조건
+ vendorTerms: {
+ paymentTermsCode: response.vendorPaymentTermsCode,
+ incotermsCode: response.vendorIncotermsCode,
+ deliveryDate: response.vendorDeliveryDate,
+ contractDuration: response.vendorContractDuration,
+ },
+
+ // 초도품/Spare part
+ additionalRequirements: {
+ firstArticle: {
+ required: response.vendorFirstYn,
+ acceptance: response.vendorFirstAcceptance,
+ },
+ sparePart: {
+ required: response.vendorSparepartYn,
+ acceptance: response.vendorSparepartAcceptance,
+ },
+ },
+
+ // 카운트 정보
+ counts: {
+ quotedItems: response.quotedItemCount,
+ attachments: response.attachmentCount,
+ },
+
+ // 비고
+ remarks: {
+ general: response.generalRemark,
+ technical: response.technicalProposal,
+ },
+
+ // 타임스탬프
+ timestamps: {
+ createdAt: response.createdAt,
+ updatedAt: response.updatedAt,
+ },
+ }));
+
+ return {
+ success: true,
+ data: formattedResponses,
+ rfq: rfqData[0],
+ details: details,
+ };
+
+ } catch (error) {
+ console.error("Failed to get vendor responses:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "벤더 응답 정보를 가져오는데 실패했습니다.",
+ data: null,
+ };
+ }
+}
+
+export async function getRfqWithDetails(rfqId: number) {
+ try {
+ // 1. RFQ 기본 정보 조회 (rfqsLastView 활용)
+ const [rfqData] = await db
+ .select()
+ .from(rfqsLastView)
+ .where(eq(rfqsLastView.id, rfqId));
+
+ if (!rfqData) {
+ return { success: false, error: "RFQ를 찾을 수 없습니다." };
+ }
+
+ // 2. 벤더별 상세 조건 조회 (rfqLastDetailsView 활용)
+ const details = await db
+ .select()
+ .from(rfqLastDetailsView)
+ .where(eq(rfqLastDetailsView.rfqId, rfqId))
+ .orderBy(desc(rfqLastDetailsView.detailId));
+
+ return {
+ success: true,
+ data: {
+ // RFQ 기본 정보 (rfqsLastView에서 제공)
+ id: rfqData.id,
+ rfqCode: rfqData.rfqCode,
+ rfqType: rfqData.rfqType,
+ rfqTitle: rfqData.rfqTitle,
+ series: rfqData.series,
+ rfqSealedYn: rfqData.rfqSealedYn,
+
+ // ITB 관련
+ projectCompany: rfqData.projectCompany,
+ projectFlag: rfqData.projectFlag,
+ projectSite: rfqData.projectSite,
+ smCode: rfqData.smCode,
+
+ // PR 정보
+ prNumber: rfqData.prNumber,
+ prIssueDate: rfqData.prIssueDate,
+
+ // 프로젝트 정보
+ projectId: rfqData.projectId,
+ projectCode: rfqData.projectCode,
+ projectName: rfqData.projectName,
+
+ // 아이템 정보
+ itemCode: rfqData.itemCode,
+ itemName: rfqData.itemName,
+
+ // 패키지 정보
+ packageNo: rfqData.packageNo,
+ packageName: rfqData.packageName,
+
+ // 날짜 및 상태
+ dueDate: rfqData.dueDate,
+ rfqSendDate: rfqData.rfqSendDate,
+ status: rfqData.status,
+
+ // PIC 정보
+ picId: rfqData.picId,
+ picCode: rfqData.picCode,
+ picName: rfqData.picName,
+ picUserName: rfqData.picUserName,
+ engPicName: rfqData.engPicName,
+
+ // 집계 정보 (View에서 이미 계산됨)
+ vendorCount: rfqData.vendorCount,
+ shortListedVendorCount: rfqData.shortListedVendorCount,
+ quotationReceivedCount: rfqData.quotationReceivedCount,
+ prItemsCount: rfqData.prItemsCount,
+ majorItemsCount: rfqData.majorItemsCount,
+
+ // 견적 제출 정보
+ earliestQuotationSubmittedAt: rfqData.earliestQuotationSubmittedAt,
+
+ // Major Item 정보
+ majorItemMaterialCode: rfqData.majorItemMaterialCode,
+ majorItemMaterialDescription: rfqData.majorItemMaterialDescription,
+ majorItemMaterialCategory: rfqData.majorItemMaterialCategory,
+ majorItemPrNo: rfqData.majorItemPrNo,
+
+ // 감사 정보
+ createdBy: rfqData.createdBy,
+ createdByUserName: rfqData.createdByUserName,
+ createdAt: rfqData.createdAt,
+ sentBy: rfqData.sentBy,
+ sentByUserName: rfqData.sentByUserName,
+ updatedBy: rfqData.updatedBy,
+ updatedByUserName: rfqData.updatedByUserName,
+ updatedAt: rfqData.updatedAt,
+
+ // 비고
+ remark: rfqData.remark,
+
+ // 벤더별 상세 조건 (rfqLastDetailsView에서 제공)
+ details: details.map(d => ({
+ detailId: d.detailId,
+
+ // 벤더 정보
+ vendorId: d.vendorId,
+ vendorName: d.vendorName,
+ vendorCode: d.vendorCode,
+ vendorCountry: d.vendorCountry,
+
+ // 조건 정보
+ currency: d.currency,
+ paymentTermsCode: d.paymentTermsCode,
+ paymentTermsDescription: d.paymentTermsDescription,
+ incotermsCode: d.incotermsCode,
+ incotermsDescription: d.incotermsDescription,
+ incotermsDetail: d.incotermsDetail,
+ deliveryDate: d.deliveryDate,
+ contractDuration: d.contractDuration,
+ taxCode: d.taxCode,
+ placeOfShipping: d.placeOfShipping,
+ placeOfDestination: d.placeOfDestination,
+
+ // Boolean 필드들
+ shortList: d.shortList,
+ returnYn: d.returnYn,
+ returnedAt: d.returnedAt,
+ prjectGtcYn: d.prjectGtcYn,
+ generalGtcYn: d.generalGtcYn,
+ ndaYn: d.ndaYn,
+ agreementYn: d.agreementYn,
+ materialPriceRelatedYn: d.materialPriceRelatedYn,
+ sparepartYn: d.sparepartYn,
+ firstYn: d.firstYn,
+
+ // 설명 필드
+ firstDescription: d.firstDescription,
+ sparepartDescription: d.sparepartDescription,
+ remark: d.remark,
+ cancelReason: d.cancelReason,
+
+ // 견적 관련 정보 (View에서 이미 계산됨)
+ hasQuotation: d.hasQuotation,
+ quotationStatus: d.quotationStatus,
+ quotationTotalPrice: d.quotationTotalPrice,
+ quotationVersion: d.quotationVersion,
+ quotationVersionCount: d.quotationVersionCount,
+ lastQuotationDate: d.lastQuotationDate,
+ quotationSubmittedAt: d.quotationSubmittedAt,
+
+ // 감사 정보
+ updatedBy: d.updatedBy,
+ updatedByUserName: d.updatedByUserName,
+ updatedAt: d.updatedAt,
+ })),
+ }
+ };
+ } catch (error) {
+ console.error("Get RFQ with details error:", error);
+ return { success: false, error: "데이터 조회 중 오류가 발생했습니다." };
+ }
+} \ No newline at end of file