summaryrefslogtreecommitdiff
path: root/lib/techsales-rfq/service.ts
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-05-28 19:03:21 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-05-28 19:03:21 +0000
commit5036cf2908792cef45f06256e71f10920f647f49 (patch)
tree3116e7419e872d45025d1d48e6ddaffe2ba2dd38 /lib/techsales-rfq/service.ts
parent7ae037e9c2fc0be1fe68cecb461c5e1e837cb0da (diff)
(김준회) 기술영업 조선 RFQ (SHI/벤더)
Diffstat (limited to 'lib/techsales-rfq/service.ts')
-rw-r--r--lib/techsales-rfq/service.ts1540
1 files changed, 1540 insertions, 0 deletions
diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts
new file mode 100644
index 00000000..88fef4b7
--- /dev/null
+++ b/lib/techsales-rfq/service.ts
@@ -0,0 +1,1540 @@
+'use server'
+
+import { unstable_noStore, revalidateTag, revalidatePath } from "next/cache";
+import db from "@/db/db";
+import {
+ techSalesRfqs,
+ techSalesVendorQuotations,
+ items,
+ users,
+ TECH_SALES_QUOTATION_STATUSES
+} from "@/db/schema";
+import { and, desc, eq, ilike, or, sql, ne } from "drizzle-orm";
+import { unstable_cache } from "@/lib/unstable-cache";
+import { filterColumns } from "@/lib/filter-columns";
+import { getErrorMessage } from "@/lib/handle-error";
+import type { Filter } from "@/types/table";
+import {
+ selectTechSalesRfqsWithJoin,
+ countTechSalesRfqsWithJoin,
+ selectTechSalesVendorQuotationsWithJoin,
+ countTechSalesVendorQuotationsWithJoin,
+ selectTechSalesDashboardWithJoin
+} from "./repository";
+import { GetTechSalesRfqsSchema } from "./validations";
+import { getServerSession } from "next-auth/next";
+import { authOptions } from "@/app/api/auth/[...nextauth]/route";
+import { sendEmail } from "../mail/sendEmail";
+import { formatDate, formatDateToQuarter } from "../utils";
+
+// 정렬 타입 정의
+// 의도적으로 any 사용 - drizzle ORM의 orderBy 타입이 복잡함
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type OrderByType = any;
+
+// 시리즈 스냅샷 타입 정의
+interface SeriesSnapshot {
+ pspid: string;
+ sersNo: string;
+ scDt?: string;
+ klDt?: string;
+ lcDt?: string;
+ dlDt?: string;
+ dockNo?: string;
+ dockNm?: string;
+ projNo?: string;
+ post1?: string;
+}
+
+/**
+ * 연도별 순차 RFQ 코드 생성 함수 (다중 생성 지원)
+ * 형식: RFQ-YYYY-001, RFQ-YYYY-002, ...
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+async function generateRfqCodes(tx: any, count: number, year?: number): Promise<string[]> {
+ const currentYear = year || new Date().getFullYear();
+ const yearPrefix = `RFQ-${currentYear}-`;
+
+ // 해당 연도의 가장 최근 RFQ 코드 조회
+ const latestRfq = await tx
+ .select({ rfqCode: techSalesRfqs.rfqCode })
+ .from(techSalesRfqs)
+ .where(ilike(techSalesRfqs.rfqCode, `${yearPrefix}%`))
+ .orderBy(desc(techSalesRfqs.rfqCode))
+ .limit(1);
+
+ let nextNumber = 1;
+
+ if (latestRfq.length > 0) {
+ // 기존 코드에서 번호 추출 (RFQ-2024-001 -> 001)
+ const lastCode = latestRfq[0].rfqCode;
+ const numberPart = lastCode.split('-').pop();
+ if (numberPart) {
+ const lastNumber = parseInt(numberPart, 10);
+ if (!isNaN(lastNumber)) {
+ nextNumber = lastNumber + 1;
+ }
+ }
+ }
+
+ // 요청된 개수만큼 순차적으로 코드 생성
+ const codes: string[] = [];
+ for (let i = 0; i < count; i++) {
+ const paddedNumber = (nextNumber + i).toString().padStart(3, '0');
+ codes.push(`${yearPrefix}${paddedNumber}`);
+ }
+
+ return codes;
+}
+
+/**
+ * 기술영업 조선 RFQ 생성 액션
+ *
+ * 받을 파라미터 (생성시 입력하는 것)
+ * 1. RFQ 관련
+ * 2. 프로젝트 관련
+ * 3. 자재 관련 (자재그룹)
+ *
+ * 나머지 벤더, 첨부파일 등은 생성 이후 처리
+ */
+export async function createTechSalesRfq(input: {
+ // 프로젝트 관련
+ biddingProjectId: number;
+ // 자재 관련 (자재그룹 코드들)
+ materialGroupCodes: string[];
+ // 기본 정보
+ dueDate?: Date;
+ remark?: string;
+ createdBy: number;
+}) {
+ unstable_noStore();
+ try {
+ const results: typeof techSalesRfqs.$inferSelect[] = [];
+
+ // 트랜잭션으로 처리
+ await db.transaction(async (tx) => {
+ // 실제 프로젝트 정보 조회
+ const biddingProject = await tx.query.biddingProjects.findFirst({
+ where: (biddingProjects, { eq }) => eq(biddingProjects.id, input.biddingProjectId)
+ });
+
+ if (!biddingProject) {
+ throw new Error(`프로젝트 ID ${input.biddingProjectId}를 찾을 수 없습니다.`);
+ }
+
+ // 프로젝트 시리즈 정보 조회
+ const seriesInfo = await tx.query.projectSeries.findMany({
+ where: (projectSeries, { eq }) => eq(projectSeries.pspid, biddingProject.pspid)
+ });
+
+ // 프로젝트 스냅샷 생성
+ const projectSnapshot = {
+ pspid: biddingProject.pspid,
+ projNm: biddingProject.projNm || undefined,
+ sector: biddingProject.sector || undefined,
+ projMsrm: biddingProject.projMsrm ? Number(biddingProject.projMsrm) : undefined,
+ kunnr: biddingProject.kunnr || undefined,
+ kunnrNm: biddingProject.kunnrNm || undefined,
+ cls1: biddingProject.cls1 || undefined,
+ cls1Nm: biddingProject.cls1Nm || undefined,
+ ptype: biddingProject.ptype || undefined,
+ ptypeNm: biddingProject.ptypeNm || undefined,
+ pmodelCd: biddingProject.pmodelCd || undefined,
+ pmodelNm: biddingProject.pmodelNm || undefined,
+ pmodelSz: biddingProject.pmodelSz || undefined,
+ pmodelUom: biddingProject.pmodelUom || undefined,
+ txt04: biddingProject.txt04 || undefined,
+ txt30: biddingProject.txt30 || undefined,
+ estmPm: biddingProject.estmPm || undefined,
+ pspCreatedAt: biddingProject.createdAt,
+ pspUpdatedAt: biddingProject.updatedAt,
+ };
+
+ // 시리즈 스냅샷 생성
+ const seriesSnapshot = seriesInfo.map(series => ({
+ pspid: series.pspid,
+ sersNo: series.sersNo.toString(),
+ scDt: series.scDt || undefined,
+ klDt: series.klDt || undefined,
+ lcDt: series.lcDt || undefined,
+ dlDt: series.dlDt || undefined,
+ dockNo: series.dockNo || undefined,
+ dockNm: series.dockNm || undefined,
+ projNo: series.projNo || undefined,
+ post1: series.post1 || undefined,
+ }));
+
+ // 각 자재그룹 코드별로 RFQ 생성
+ for (const materialCode of input.materialGroupCodes) {
+ // RFQ 코드 생성 (임시로 타임스탬프 기반)
+ const rfqCode = await generateRfqCodes(tx, 1);
+
+ // 기본 due date 설정 (7일 후)
+ const dueDate = input.dueDate || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
+
+ // 기존 item 확인 또는 새로 생성
+ let itemId: number;
+ const existingItem = await tx.query.items.findFirst({
+ where: (items, { eq }) => eq(items.itemCode, materialCode),
+ columns: { id: true }
+ });
+
+ if (existingItem) {
+ // 기존 item 사용
+ itemId = existingItem.id;
+ } else {
+ // 새 item 생성
+ const [newItem] = await tx.insert(items).values({
+ itemCode: materialCode,
+ itemName: `자재그룹 ${materialCode}`,
+ description: `기술영업 자재그룹`,
+ }).returning();
+ itemId = newItem.id;
+ }
+
+ // 새 기술영업 RFQ 작성 (스냅샷 포함)
+ const [newRfq] = await tx.insert(techSalesRfqs).values({
+ rfqCode: rfqCode[0],
+ itemId: itemId,
+ biddingProjectId: input.biddingProjectId,
+ materialCode,
+ dueDate,
+ remark: input.remark,
+ createdBy: input.createdBy,
+ updatedBy: input.createdBy,
+ // 스냅샷 데이터 추가
+ projectSnapshot,
+ seriesSnapshot,
+ }).returning();
+
+ results.push(newRfq);
+ }
+ });
+
+ // 캐시 무효화
+ revalidateTag("techSalesRfqs");
+ revalidatePath("/evcp/budgetary-tech-sales-ship");
+
+ return { data: results, error: null };
+ } catch (err) {
+ console.error("Error creating RFQ:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 직접 조인을 사용하여 RFQ 데이터 조회하는 함수
+ * 페이지네이션, 필터링, 정렬 등 지원
+ */
+export async function getTechSalesRfqsWithJoin(input: GetTechSalesRfqsSchema) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // 기본 필터 처리 - RFQFilterBox에서 오는 필터
+ const basicFilters = input.basicFilters || [];
+ const basicJoinOperator = input.basicJoinOperator || "and";
+
+ // 고급 필터 처리 - 테이블의 DataTableFilterList에서 오는 필터
+ const advancedFilters = input.filters || [];
+ const advancedJoinOperator = input.joinOperator || "and";
+
+ // 기본 필터 조건 생성
+ let basicWhere;
+ if (basicFilters.length > 0) {
+ basicWhere = filterColumns({
+ table: techSalesRfqs,
+ filters: basicFilters,
+ joinOperator: basicJoinOperator,
+ });
+ }
+
+ // 고급 필터 조건 생성
+ let advancedWhere;
+ if (advancedFilters.length > 0) {
+ advancedWhere = filterColumns({
+ table: techSalesRfqs,
+ filters: advancedFilters,
+ joinOperator: advancedJoinOperator,
+ });
+ }
+
+ // 전역 검색 조건
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(techSalesRfqs.rfqCode, s),
+ ilike(techSalesRfqs.materialCode, s),
+ // JSON 필드 검색
+ sql`${techSalesRfqs.projectSnapshot}->>'pspid' ILIKE ${s}`,
+ sql`${techSalesRfqs.projectSnapshot}->>'projNm' ILIKE ${s}`,
+ sql`${techSalesRfqs.projectSnapshot}->>'ptypeNm' ILIKE ${s}`
+ );
+ }
+
+ // 모든 조건 결합
+ const whereConditions = [];
+ if (basicWhere) whereConditions.push(basicWhere);
+ if (advancedWhere) whereConditions.push(advancedWhere);
+ if (globalWhere) whereConditions.push(globalWhere);
+
+ // 조건이 있을 때만 and() 사용
+ const finalWhere = whereConditions.length > 0
+ ? and(...whereConditions)
+ : undefined;
+
+ // 정렬 기준 설정
+ let orderBy: OrderByType[] = [desc(techSalesRfqs.createdAt)]; // 기본 정렬
+
+ if (input.sort?.length) {
+ // 안전하게 접근하여 정렬 기준 설정
+ orderBy = input.sort.map(item => {
+ switch (item.id) {
+ case 'id':
+ return item.desc ? desc(techSalesRfqs.id) : techSalesRfqs.id;
+ case 'rfqCode':
+ return item.desc ? desc(techSalesRfqs.rfqCode) : techSalesRfqs.rfqCode;
+ case 'materialCode':
+ return item.desc ? desc(techSalesRfqs.materialCode) : techSalesRfqs.materialCode;
+ case 'status':
+ return item.desc ? desc(techSalesRfqs.status) : techSalesRfqs.status;
+ case 'dueDate':
+ return item.desc ? desc(techSalesRfqs.dueDate) : techSalesRfqs.dueDate;
+ case 'createdAt':
+ return item.desc ? desc(techSalesRfqs.createdAt) : techSalesRfqs.createdAt;
+ case 'updatedAt':
+ return item.desc ? desc(techSalesRfqs.updatedAt) : techSalesRfqs.updatedAt;
+ default:
+ return item.desc ? desc(techSalesRfqs.createdAt) : techSalesRfqs.createdAt;
+ }
+ });
+ }
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectTechSalesRfqsWithJoin(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ const total = await countTechSalesRfqsWithJoin(tx, finalWhere);
+ return { data, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount, total };
+ } catch (err) {
+ console.error("Error fetching RFQs with join:", err);
+ return { data: [], pageCount: 0, total: 0 };
+ }
+ },
+ [JSON.stringify(input)], // 캐싱 키
+ {
+ revalidate: 60, // 1분간 캐시
+ tags: ["techSalesRfqs"],
+ }
+ )();
+}
+
+/**
+ * 직접 조인을 사용하여 벤더 견적서 조회하는 함수
+ */
+export async function getTechSalesVendorQuotationsWithJoin(input: {
+ rfqId?: number;
+ vendorId?: number;
+ search?: string;
+ filters?: Filter<typeof techSalesVendorQuotations>[];
+ sort?: { id: string; desc: boolean }[];
+ page: number;
+ perPage: number;
+}) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // 기본 필터 조건들
+ const whereConditions = [];
+
+ // RFQ ID 필터
+ if (input.rfqId) {
+ whereConditions.push(eq(techSalesVendorQuotations.rfqId, input.rfqId));
+ }
+
+ // 벤더 ID 필터
+ if (input.vendorId) {
+ whereConditions.push(eq(techSalesVendorQuotations.vendorId, input.vendorId));
+ }
+
+ // 검색 조건
+ if (input.search) {
+ const s = `%${input.search}%`;
+ const searchCondition = or(
+ ilike(techSalesVendorQuotations.currency, s),
+ ilike(techSalesVendorQuotations.status, s)
+ );
+ if (searchCondition) {
+ whereConditions.push(searchCondition);
+ }
+ }
+
+ // 고급 필터 처리
+ if (input.filters && input.filters.length > 0) {
+ const filterWhere = filterColumns({
+ table: techSalesVendorQuotations,
+ filters: input.filters as Filter<typeof techSalesVendorQuotations>[],
+ joinOperator: "and",
+ });
+ if (filterWhere) {
+ whereConditions.push(filterWhere);
+ }
+ }
+
+ // 최종 WHERE 조건
+ const finalWhere = whereConditions.length > 0
+ ? and(...whereConditions)
+ : undefined;
+
+ // 정렬 기준 설정
+ let orderBy: OrderByType[] = [desc(techSalesVendorQuotations.createdAt)];
+
+ if (input.sort?.length) {
+ orderBy = input.sort.map(item => {
+ switch (item.id) {
+ case 'id':
+ return item.desc ? desc(techSalesVendorQuotations.id) : techSalesVendorQuotations.id;
+ case 'status':
+ return item.desc ? desc(techSalesVendorQuotations.status) : techSalesVendorQuotations.status;
+ case 'currency':
+ return item.desc ? desc(techSalesVendorQuotations.currency) : techSalesVendorQuotations.currency;
+ case 'totalPrice':
+ return item.desc ? desc(techSalesVendorQuotations.totalPrice) : techSalesVendorQuotations.totalPrice;
+ case 'createdAt':
+ return item.desc ? desc(techSalesVendorQuotations.createdAt) : techSalesVendorQuotations.createdAt;
+ case 'updatedAt':
+ return item.desc ? desc(techSalesVendorQuotations.updatedAt) : techSalesVendorQuotations.updatedAt;
+ default:
+ return item.desc ? desc(techSalesVendorQuotations.createdAt) : techSalesVendorQuotations.createdAt;
+ }
+ });
+ }
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectTechSalesVendorQuotationsWithJoin(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ const total = await countTechSalesVendorQuotationsWithJoin(tx, finalWhere);
+ return { data, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount, total };
+ } catch (err) {
+ console.error("Error fetching vendor quotations with join:", err);
+ return { data: [], pageCount: 0, total: 0 };
+ }
+ },
+ [JSON.stringify(input)],
+ {
+ revalidate: 60,
+ tags: [
+ "techSalesVendorQuotations",
+ ...(input.rfqId ? [`techSalesRfq-${input.rfqId}`] : [])
+ ],
+ }
+ )();
+}
+
+/**
+ * 직접 조인을 사용하여 RFQ 대시보드 데이터 조회하는 함수
+ */
+export async function getTechSalesDashboardWithJoin(input: {
+ search?: string;
+ filters?: Filter<typeof techSalesRfqs>[];
+ sort?: { id: string; desc: boolean }[];
+ page: number;
+ perPage: number;
+}) {
+ unstable_noStore(); // 대시보드는 항상 최신 데이터를 보여주기 위해 캐시하지 않음
+
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // Advanced filtering
+ const advancedWhere = input.filters ? filterColumns({
+ table: techSalesRfqs,
+ filters: input.filters as Filter<typeof techSalesRfqs>[],
+ joinOperator: 'and',
+ }) : undefined;
+
+ // Global search
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(techSalesRfqs.rfqCode, s),
+ ilike(techSalesRfqs.materialCode, s),
+ // JSON 필드 검색
+ sql`${techSalesRfqs.projectSnapshot}->>'pspid' ILIKE ${s}`,
+ sql`${techSalesRfqs.projectSnapshot}->>'projNm' ILIKE ${s}`
+ );
+ }
+
+ const finalWhere = and(
+ advancedWhere,
+ globalWhere
+ );
+
+ // 정렬 기준 설정
+ let orderBy: OrderByType[] = [desc(techSalesRfqs.updatedAt)]; // 기본 정렬
+
+ if (input.sort?.length) {
+ // 안전하게 접근하여 정렬 기준 설정
+ orderBy = input.sort.map(item => {
+ switch (item.id) {
+ case 'id':
+ return item.desc ? desc(techSalesRfqs.id) : techSalesRfqs.id;
+ case 'rfqCode':
+ return item.desc ? desc(techSalesRfqs.rfqCode) : techSalesRfqs.rfqCode;
+ case 'status':
+ return item.desc ? desc(techSalesRfqs.status) : techSalesRfqs.status;
+ case 'dueDate':
+ return item.desc ? desc(techSalesRfqs.dueDate) : techSalesRfqs.dueDate;
+ case 'createdAt':
+ return item.desc ? desc(techSalesRfqs.createdAt) : techSalesRfqs.createdAt;
+ case 'updatedAt':
+ return item.desc ? desc(techSalesRfqs.updatedAt) : techSalesRfqs.updatedAt;
+ default:
+ return item.desc ? desc(techSalesRfqs.updatedAt) : techSalesRfqs.updatedAt;
+ }
+ });
+ }
+
+ // 트랜잭션 내부에서 Repository 호출
+ const data = await db.transaction(async (tx) => {
+ return await selectTechSalesDashboardWithJoin(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+ });
+
+ return { data, success: true };
+ } catch (err) {
+ console.error("Error fetching dashboard data with join:", err);
+ return { data: [], success: false, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 RFQ에 벤더 추가 (단일)
+ */
+export async function addVendorToTechSalesRfq(input: {
+ rfqId: number;
+ vendorId: number;
+ createdBy: number;
+}) {
+ unstable_noStore();
+ try {
+ // 이미 해당 RFQ에 벤더가 추가되어 있는지 확인
+ const existingQuotation = await db
+ .select()
+ .from(techSalesVendorQuotations)
+ .where(
+ and(
+ eq(techSalesVendorQuotations.rfqId, input.rfqId),
+ eq(techSalesVendorQuotations.vendorId, input.vendorId)
+ )
+ )
+ .limit(1);
+
+ if (existingQuotation.length > 0) {
+ return {
+ data: null,
+ error: "이미 해당 벤더가 이 RFQ에 추가되어 있습니다."
+ };
+ }
+
+ // 새 벤더 견적서 레코드 생성
+ const [newQuotation] = await db
+ .insert(techSalesVendorQuotations)
+ .values({
+ rfqId: input.rfqId,
+ vendorId: input.vendorId,
+ status: "Draft",
+ totalPrice: "0",
+ currency: "USD",
+ createdBy: input.createdBy,
+ updatedBy: input.createdBy,
+ })
+ .returning();
+
+ return { data: newQuotation, error: null };
+ } catch (err) {
+ console.error("Error adding vendor to RFQ:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 RFQ에 여러 벤더 추가 (다중)
+ */
+export async function addVendorsToTechSalesRfq(input: {
+ rfqId: number;
+ vendorIds: number[];
+ createdBy: number;
+}) {
+ unstable_noStore();
+ try {
+ const results: typeof techSalesVendorQuotations.$inferSelect[] = [];
+ const errors: string[] = [];
+
+ // 트랜잭션으로 처리
+ await db.transaction(async (tx) => {
+ // 1. RFQ 상태 확인
+ const rfq = await tx.query.techSalesRfqs.findFirst({
+ where: eq(techSalesRfqs.id, input.rfqId),
+ columns: {
+ id: true,
+ status: true
+ }
+ });
+
+ if (!rfq) {
+ throw new Error("RFQ를 찾을 수 없습니다");
+ }
+
+ // 2. 각 벤더에 대해 처리
+ for (const vendorId of input.vendorIds) {
+ try {
+ // 이미 해당 RFQ에 벤더가 추가되어 있는지 확인
+ const existingQuotation = await tx
+ .select()
+ .from(techSalesVendorQuotations)
+ .where(
+ and(
+ eq(techSalesVendorQuotations.rfqId, input.rfqId),
+ eq(techSalesVendorQuotations.vendorId, vendorId)
+ )
+ )
+ .limit(1);
+
+ if (existingQuotation.length > 0) {
+ errors.push(`벤더 ID ${vendorId}는 이미 추가되어 있습니다.`);
+ continue;
+ }
+
+ // 새 벤더 견적서 레코드 생성
+ const [newQuotation] = await tx
+ .insert(techSalesVendorQuotations)
+ .values({
+ rfqId: input.rfqId,
+ vendorId: vendorId,
+ status: "Draft",
+ totalPrice: "0",
+ currency: "USD",
+ createdBy: input.createdBy,
+ updatedBy: input.createdBy,
+ })
+ .returning();
+
+ results.push(newQuotation);
+ } catch (vendorError) {
+ console.error(`Error adding vendor ${vendorId}:`, vendorError);
+ errors.push(`벤더 ID ${vendorId} 추가 중 오류가 발생했습니다.`);
+ }
+ }
+
+ // 3. RFQ 상태가 "RFQ Created"이고 성공적으로 추가된 벤더가 있는 경우 상태 업데이트
+ if (rfq.status === "RFQ Created" && results.length > 0) {
+ await tx.update(techSalesRfqs)
+ .set({
+ status: "RFQ Vendor Assignned",
+ updatedBy: input.createdBy,
+ updatedAt: new Date()
+ })
+ .where(eq(techSalesRfqs.id, input.rfqId));
+ }
+ });
+
+ // 캐시 무효화 추가
+ revalidateTag("techSalesVendorQuotations");
+ revalidateTag(`techSalesRfq-${input.rfqId}`);
+ revalidatePath("/evcp/budgetary-tech-sales-ship");
+
+ return {
+ data: results,
+ error: errors.length > 0 ? errors.join(", ") : null,
+ successCount: results.length,
+ errorCount: errors.length
+ };
+ } catch (err) {
+ console.error("Error adding vendors to RFQ:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 RFQ에서 벤더 제거 (Draft 상태 체크 포함)
+ */
+export async function removeVendorFromTechSalesRfq(input: {
+ rfqId: number;
+ vendorId: number;
+}) {
+ unstable_noStore();
+ try {
+ // 먼저 해당 벤더의 견적서 상태 확인
+ const existingQuotation = await db
+ .select()
+ .from(techSalesVendorQuotations)
+ .where(
+ and(
+ eq(techSalesVendorQuotations.rfqId, input.rfqId),
+ eq(techSalesVendorQuotations.vendorId, input.vendorId)
+ )
+ )
+ .limit(1);
+
+ if (existingQuotation.length === 0) {
+ return {
+ data: null,
+ error: "해당 벤더가 이 RFQ에 존재하지 않습니다."
+ };
+ }
+
+ // Draft 상태가 아닌 경우 삭제 불가
+ if (existingQuotation[0].status !== "Draft") {
+ return {
+ data: null,
+ error: "Draft 상태의 벤더만 삭제할 수 있습니다."
+ };
+ }
+
+ // 해당 벤더의 견적서 삭제
+ const deletedQuotations = await db
+ .delete(techSalesVendorQuotations)
+ .where(
+ and(
+ eq(techSalesVendorQuotations.rfqId, input.rfqId),
+ eq(techSalesVendorQuotations.vendorId, input.vendorId)
+ )
+ )
+ .returning();
+
+ // 캐시 무효화 추가
+ revalidateTag("techSalesVendorQuotations");
+ revalidateTag(`techSalesRfq-${input.rfqId}`);
+ revalidatePath("/evcp/budgetary-tech-sales-ship");
+
+ return { data: deletedQuotations[0], error: null };
+ } catch (err) {
+ console.error("Error removing vendor from RFQ:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 RFQ에서 여러 벤더 일괄 제거 (Draft 상태 체크 포함)
+ */
+export async function removeVendorsFromTechSalesRfq(input: {
+ rfqId: number;
+ vendorIds: number[];
+}) {
+ unstable_noStore();
+ try {
+ const results: typeof techSalesVendorQuotations.$inferSelect[] = [];
+ const errors: string[] = [];
+
+ // 트랜잭션으로 처리
+ await db.transaction(async (tx) => {
+ for (const vendorId of input.vendorIds) {
+ try {
+ // 먼저 해당 벤더의 견적서 상태 확인
+ const existingQuotation = await tx
+ .select()
+ .from(techSalesVendorQuotations)
+ .where(
+ and(
+ eq(techSalesVendorQuotations.rfqId, input.rfqId),
+ eq(techSalesVendorQuotations.vendorId, vendorId)
+ )
+ )
+ .limit(1);
+
+ if (existingQuotation.length === 0) {
+ errors.push(`벤더 ID ${vendorId}가 이 RFQ에 존재하지 않습니다.`);
+ continue;
+ }
+
+ // Draft 상태가 아닌 경우 삭제 불가
+ if (existingQuotation[0].status !== "Draft") {
+ errors.push(`벤더 ID ${vendorId}는 Draft 상태가 아니므로 삭제할 수 없습니다.`);
+ continue;
+ }
+
+ // 해당 벤더의 견적서 삭제
+ const deletedQuotations = await tx
+ .delete(techSalesVendorQuotations)
+ .where(
+ and(
+ eq(techSalesVendorQuotations.rfqId, input.rfqId),
+ eq(techSalesVendorQuotations.vendorId, vendorId)
+ )
+ )
+ .returning();
+
+ if (deletedQuotations.length > 0) {
+ results.push(deletedQuotations[0]);
+ }
+ } catch (vendorError) {
+ console.error(`Error removing vendor ${vendorId}:`, vendorError);
+ errors.push(`벤더 ID ${vendorId} 삭제 중 오류가 발생했습니다.`);
+ }
+ }
+ });
+
+ // 캐시 무효화 추가
+ revalidateTag("techSalesVendorQuotations");
+ revalidateTag(`techSalesRfq-${input.rfqId}`);
+ revalidatePath("/evcp/budgetary-tech-sales-ship");
+
+ return {
+ data: results,
+ error: errors.length > 0 ? errors.join(", ") : null,
+ successCount: results.length,
+ errorCount: errors.length
+ };
+ } catch (err) {
+ console.error("Error removing vendors from RFQ:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 특정 RFQ의 벤더 목록 조회
+ */
+export async function getTechSalesRfqVendors(rfqId: number) {
+ unstable_noStore();
+ try {
+ // Repository 함수를 사용하여 벤더 견적 목록 조회
+ const result = await getTechSalesVendorQuotationsWithJoin({
+ rfqId,
+ page: 1,
+ perPage: 1000, // 충분히 큰 수로 설정하여 모든 벤더 조회
+ });
+
+ return { data: result.data, error: null };
+ } catch (err) {
+ console.error("Error fetching RFQ vendors:", err);
+ return { data: [], error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 RFQ 발송 (선택된 벤더들에게)
+ */
+export async function sendTechSalesRfqToVendors(input: {
+ rfqId: number;
+ vendorIds: number[];
+}) {
+ unstable_noStore();
+ try {
+ // 인증 확인
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user) {
+ return {
+ success: false,
+ message: "인증이 필요합니다",
+ };
+ }
+
+ // RFQ 정보 조회
+ const rfq = await db.query.techSalesRfqs.findFirst({
+ where: eq(techSalesRfqs.id, input.rfqId),
+ columns: {
+ id: true,
+ rfqCode: true,
+ status: true,
+ dueDate: true,
+ rfqSendDate: true,
+ remark: true,
+ materialCode: true,
+ projectSnapshot: true,
+ seriesSnapshot: true,
+ },
+ with: {
+ item: {
+ columns: {
+ id: true,
+ itemCode: true,
+ itemName: true,
+ }
+ },
+ biddingProject: {
+ columns: {
+ id: true,
+ pspid: true,
+ projNm: true,
+ sector: true,
+ ptypeNm: true,
+ }
+ },
+ createdByUser: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ }
+ }
+ }
+ });
+
+ if (!rfq) {
+ return {
+ success: false,
+ message: "RFQ를 찾을 수 없습니다",
+ };
+ }
+
+ // 발송 가능한 상태인지 확인
+ if (rfq.status !== "RFQ Vendor Assignned" && rfq.status !== "RFQ Sent") {
+ return {
+ success: false,
+ message: "벤더가 할당된 RFQ 또는 이미 전송된 RFQ만 다시 전송할 수 있습니다",
+ };
+ }
+
+ const isResend = rfq.status === "RFQ Sent";
+
+ // 현재 사용자 정보 조회
+ const sender = await db.query.users.findFirst({
+ where: eq(users.id, Number(session.user.id)),
+ columns: {
+ id: true,
+ email: true,
+ name: true,
+ }
+ });
+
+ if (!sender || !sender.email) {
+ return {
+ success: false,
+ message: "보내는 사람의 이메일 정보를 찾을 수 없습니다",
+ };
+ }
+
+ // 선택된 벤더들의 견적서 정보 조회
+ const vendorQuotations = await db.query.techSalesVendorQuotations.findMany({
+ where: and(
+ eq(techSalesVendorQuotations.rfqId, input.rfqId),
+ sql`${techSalesVendorQuotations.vendorId} IN (${input.vendorIds.join(',')})`
+ ),
+ columns: {
+ id: true,
+ vendorId: true,
+ status: true,
+ currency: true,
+ },
+ with: {
+ vendor: {
+ columns: {
+ id: true,
+ vendorName: true,
+ vendorCode: true,
+ }
+ }
+ }
+ });
+
+ if (vendorQuotations.length === 0) {
+ return {
+ success: false,
+ message: "선택된 벤더가 이 RFQ에 할당되어 있지 않습니다",
+ };
+ }
+
+ // 트랜잭션 시작
+ await db.transaction(async (tx) => {
+ // 1. RFQ 상태 업데이트 (첫 발송인 경우에만)
+ if (!isResend) {
+ await tx.update(techSalesRfqs)
+ .set({
+ status: "RFQ Sent",
+ rfqSendDate: new Date(),
+ sentBy: Number(session.user.id),
+ updatedBy: Number(session.user.id),
+ updatedAt: new Date(),
+ })
+ .where(eq(techSalesRfqs.id, input.rfqId));
+ }
+
+ // 2. 각 벤더에 대해 이메일 발송 처리
+ for (const quotation of vendorQuotations) {
+ if (!quotation.vendorId || !quotation.vendor) continue;
+
+ // 벤더에 속한 모든 사용자 조회
+ const vendorUsers = await db.query.users.findMany({
+ where: eq(users.companyId, quotation.vendorId),
+ columns: {
+ id: true,
+ email: true,
+ name: true,
+ language: true
+ }
+ });
+
+ // 유효한 이메일 주소만 필터링
+ const vendorEmailsString = vendorUsers
+ .filter(user => user.email)
+ .map(user => user.email)
+ .join(", ");
+
+ if (vendorEmailsString) {
+ // 대표 언어 결정 (첫 번째 사용자의 언어 또는 기본값)
+ const language = vendorUsers[0]?.language || "ko";
+
+ // 시리즈 정보 처리
+ const seriesInfo = rfq.seriesSnapshot ? rfq.seriesSnapshot.map((series: SeriesSnapshot) => ({
+ sersNo: series.sersNo,
+ klQuarter: series.klDt ? formatDateToQuarter(series.klDt) : '',
+ scDt: series.scDt,
+ lcDt: series.lcDt,
+ dlDt: series.dlDt,
+ dockNo: series.dockNo,
+ dockNm: series.dockNm,
+ projNo: series.projNo,
+ post1: series.post1,
+ })) : [];
+
+ // 이메일 컨텍스트 구성
+ const emailContext = {
+ language: language,
+ rfq: {
+ id: rfq.id,
+ code: rfq.rfqCode,
+ title: rfq.item?.itemName || '',
+ projectCode: rfq.biddingProject?.pspid || '',
+ projectName: rfq.biddingProject?.projNm || '',
+ description: rfq.remark || '',
+ dueDate: rfq.dueDate ? formatDate(rfq.dueDate) : 'N/A',
+ materialCode: rfq.materialCode || '',
+ },
+ vendor: {
+ id: quotation.vendor.id,
+ code: quotation.vendor.vendorCode || '',
+ name: quotation.vendor.vendorName,
+ },
+ sender: {
+ fullName: sender.name || '',
+ email: sender.email,
+ },
+ project: {
+ // 기본 정보
+ id: rfq.projectSnapshot?.pspid || rfq.biddingProject?.pspid || '',
+ name: rfq.projectSnapshot?.projNm || rfq.biddingProject?.projNm || '',
+ sector: rfq.projectSnapshot?.sector || rfq.biddingProject?.sector || '',
+ shipType: rfq.projectSnapshot?.ptypeNm || rfq.biddingProject?.ptypeNm || '',
+
+ // 추가 프로젝트 정보
+ shipCount: rfq.projectSnapshot?.projMsrm || 0,
+ ownerCode: rfq.projectSnapshot?.kunnr || '',
+ ownerName: rfq.projectSnapshot?.kunnrNm || '',
+ classCode: rfq.projectSnapshot?.cls1 || '',
+ className: rfq.projectSnapshot?.cls1Nm || '',
+ shipTypeCode: rfq.projectSnapshot?.ptype || '',
+ shipModelCode: rfq.projectSnapshot?.pmodelCd || '',
+ shipModelName: rfq.projectSnapshot?.pmodelNm || '',
+ shipModelSize: rfq.projectSnapshot?.pmodelSz || '',
+ shipModelUnit: rfq.projectSnapshot?.pmodelUom || '',
+ estimateStatus: rfq.projectSnapshot?.txt30 || '',
+ projectManager: rfq.projectSnapshot?.estmPm || '',
+ },
+ series: seriesInfo,
+ details: {
+ currency: quotation.currency || 'USD',
+ },
+ quotationCode: `${rfq.rfqCode}-${quotation.vendorId}`,
+ systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/partners',
+ isResend: isResend,
+ versionInfo: isResend ? '(재전송)' : '',
+ };
+
+ // 이메일 전송
+ await sendEmail({
+ to: vendorEmailsString,
+ subject: isResend
+ ? `[기술영업 RFQ 재전송] ${rfq.rfqCode} - ${rfq.item?.itemName || '견적 요청'} ${emailContext.versionInfo}`
+ : `[기술영업 RFQ] ${rfq.rfqCode} - ${rfq.item?.itemName || '견적 요청'}`,
+ template: 'tech-sales-rfq-invite-ko', // 기술영업용 템플릿
+ context: emailContext,
+ cc: sender.email, // 발신자를 CC에 추가
+ });
+ }
+ }
+ });
+
+ // 캐시 무효화
+ revalidateTag("techSalesRfqs");
+ revalidateTag("techSalesVendorQuotations");
+ revalidateTag(`techSalesRfq-${input.rfqId}`);
+ revalidatePath("/evcp/budgetary-tech-sales-ship");
+
+ return {
+ success: true,
+ message: `${vendorQuotations.length}개 벤더에게 RFQ가 성공적으로 발송되었습니다`,
+ sentCount: vendorQuotations.length,
+ };
+ } catch (err) {
+ console.error("기술영업 RFQ 발송 오류:", err);
+ return {
+ success: false,
+ message: "RFQ 발송 중 오류가 발생했습니다",
+ };
+ }
+}
+
+/**
+ * 벤더용 기술영업 RFQ 견적서 조회
+ */
+export async function getTechSalesVendorQuotation(quotationId: number) {
+ unstable_noStore();
+ try {
+ const quotation = await db.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, quotationId),
+ with: {
+ rfq: {
+ with: {
+ item: true,
+ biddingProject: true,
+ createdByUser: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ }
+ }
+ }
+ },
+ vendor: true,
+ }
+ });
+
+ if (!quotation) {
+ return { data: null, error: "견적서를 찾을 수 없습니다." };
+ }
+
+ return { data: quotation, error: null };
+ } catch (err) {
+ console.error("Error fetching vendor quotation:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 벤더 견적서 업데이트 (임시저장)
+ */
+export async function updateTechSalesVendorQuotation(data: {
+ id: number
+ currency: string
+ totalPrice: string
+ validUntil: Date
+ remark?: string
+ updatedBy: number
+}) {
+ try {
+ // 현재 견적서 상태 확인
+ const currentQuotation = await db.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, data.id),
+ columns: {
+ status: true,
+ }
+ });
+
+ if (!currentQuotation) {
+ return { data: null, error: "견적서를 찾을 수 없습니다." };
+ }
+
+ // Draft 또는 Revised 상태에서만 수정 가능
+ if (!["Draft", "Revised"].includes(currentQuotation.status)) {
+ return { data: null, error: "현재 상태에서는 견적서를 수정할 수 없습니다." };
+ }
+
+ const result = await db
+ .update(techSalesVendorQuotations)
+ .set({
+ currency: data.currency,
+ totalPrice: data.totalPrice,
+ validUntil: data.validUntil,
+ remark: data.remark || null,
+ updatedAt: new Date(),
+ })
+ .where(eq(techSalesVendorQuotations.id, data.id))
+ .returning()
+
+ // 캐시 무효화
+ revalidateTag("techSalesVendorQuotations")
+ revalidatePath(`/partners/techsales/rfq-ship/${data.id}`)
+
+ return { data: result[0], error: null }
+ } catch (error) {
+ console.error("Error updating tech sales vendor quotation:", error)
+ return { data: null, error: "견적서 업데이트 중 오류가 발생했습니다" }
+ }
+}
+
+/**
+ * 기술영업 벤더 견적서 제출
+ */
+export async function submitTechSalesVendorQuotation(data: {
+ id: number
+ currency: string
+ totalPrice: string
+ validUntil: Date
+ remark?: string
+ updatedBy: number
+}) {
+ try {
+ // 현재 견적서 상태 확인
+ const currentQuotation = await db.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, data.id),
+ columns: {
+ status: true,
+ }
+ });
+
+ if (!currentQuotation) {
+ return { data: null, error: "견적서를 찾을 수 없습니다." };
+ }
+
+ // Draft 또는 Revised 상태에서만 제출 가능
+ if (!["Draft", "Revised"].includes(currentQuotation.status)) {
+ return { data: null, error: "현재 상태에서는 견적서를 제출할 수 없습니다." };
+ }
+
+ const result = await db
+ .update(techSalesVendorQuotations)
+ .set({
+ currency: data.currency,
+ totalPrice: data.totalPrice,
+ validUntil: data.validUntil,
+ remark: data.remark || null,
+ status: "Submitted",
+ submittedAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .where(eq(techSalesVendorQuotations.id, data.id))
+ .returning()
+
+ // 캐시 무효화
+ revalidateTag("techSalesVendorQuotations")
+ revalidatePath(`/partners/techsales/rfq-ship/${data.id}`)
+
+ return { data: result[0], error: null }
+ } catch (error) {
+ console.error("Error submitting tech sales vendor quotation:", error)
+ return { data: null, error: "견적서 제출 중 오류가 발생했습니다" }
+ }
+}
+
+/**
+ * 통화 목록 조회
+ */
+export async function fetchCurrencies() {
+ try {
+ // 기본 통화 목록 (실제로는 DB에서 가져와야 함)
+ const currencies = [
+ { code: "USD", name: "미국 달러" },
+ { code: "KRW", name: "한국 원" },
+ { code: "EUR", name: "유로" },
+ { code: "JPY", name: "일본 엔" },
+ { code: "CNY", name: "중국 위안" },
+ ]
+
+ return { data: currencies, error: null }
+ } catch (error) {
+ console.error("Error fetching currencies:", error)
+ return { data: null, error: "통화 목록 조회 중 오류가 발생했습니다" }
+ }
+}
+
+/**
+ * 벤더용 기술영업 견적서 목록 조회 (페이지네이션 포함)
+ */
+export async function getVendorQuotations(input: {
+ flags?: string[];
+ page: number;
+ perPage: number;
+ sort?: { id: string; desc: boolean }[];
+ filters?: Filter<typeof techSalesVendorQuotations>[];
+ joinOperator?: "and" | "or";
+ basicFilters?: Filter<typeof techSalesVendorQuotations>[];
+ basicJoinOperator?: "and" | "or";
+ search?: string;
+ from?: string;
+ to?: string;
+}, vendorId: string) {
+ unstable_noStore();
+ try {
+ const { page, perPage, sort, filters = [], search = "", from = "", to = "" } = input;
+ const offset = (page - 1) * perPage;
+ const limit = perPage;
+
+ // 기본 조건: 해당 벤더의 견적서만 조회
+ const baseConditions = [eq(techSalesVendorQuotations.vendorId, parseInt(vendorId))];
+
+ // 검색 조건 추가
+ if (search) {
+ const s = `%${search}%`;
+ const searchCondition = or(
+ ilike(techSalesVendorQuotations.currency, s),
+ ilike(techSalesVendorQuotations.status, s)
+ );
+ if (searchCondition) {
+ baseConditions.push(searchCondition);
+ }
+ }
+
+ // 날짜 범위 필터
+ if (from) {
+ baseConditions.push(sql`${techSalesVendorQuotations.createdAt} >= ${from}`);
+ }
+ if (to) {
+ baseConditions.push(sql`${techSalesVendorQuotations.createdAt} <= ${to}`);
+ }
+
+ // 고급 필터 처리
+ if (filters.length > 0) {
+ const filterWhere = filterColumns({
+ table: techSalesVendorQuotations,
+ filters: filters as Filter<typeof techSalesVendorQuotations>[],
+ joinOperator: input.joinOperator || "and",
+ });
+ if (filterWhere) {
+ baseConditions.push(filterWhere);
+ }
+ }
+
+ // 최종 WHERE 조건
+ const finalWhere = baseConditions.length > 0
+ ? and(...baseConditions)
+ : undefined;
+
+ // 정렬 기준 설정
+ let orderBy: OrderByType[] = [desc(techSalesVendorQuotations.updatedAt)];
+
+ if (sort?.length) {
+ orderBy = sort.map(item => {
+ switch (item.id) {
+ case 'id':
+ return item.desc ? desc(techSalesVendorQuotations.id) : techSalesVendorQuotations.id;
+ case 'status':
+ return item.desc ? desc(techSalesVendorQuotations.status) : techSalesVendorQuotations.status;
+ case 'currency':
+ return item.desc ? desc(techSalesVendorQuotations.currency) : techSalesVendorQuotations.currency;
+ case 'totalPrice':
+ return item.desc ? desc(techSalesVendorQuotations.totalPrice) : techSalesVendorQuotations.totalPrice;
+ case 'validUntil':
+ return item.desc ? desc(techSalesVendorQuotations.validUntil) : techSalesVendorQuotations.validUntil;
+ case 'submittedAt':
+ return item.desc ? desc(techSalesVendorQuotations.submittedAt) : techSalesVendorQuotations.submittedAt;
+ case 'createdAt':
+ return item.desc ? desc(techSalesVendorQuotations.createdAt) : techSalesVendorQuotations.createdAt;
+ case 'updatedAt':
+ return item.desc ? desc(techSalesVendorQuotations.updatedAt) : techSalesVendorQuotations.updatedAt;
+ default:
+ return item.desc ? desc(techSalesVendorQuotations.updatedAt) : techSalesVendorQuotations.updatedAt;
+ }
+ });
+ }
+
+ // 조인을 포함한 데이터 조회
+ const data = await db
+ .select({
+ id: techSalesVendorQuotations.id,
+ rfqId: techSalesVendorQuotations.rfqId,
+ vendorId: techSalesVendorQuotations.vendorId,
+ status: techSalesVendorQuotations.status,
+ currency: techSalesVendorQuotations.currency,
+ totalPrice: techSalesVendorQuotations.totalPrice,
+ validUntil: techSalesVendorQuotations.validUntil,
+ submittedAt: techSalesVendorQuotations.submittedAt,
+ remark: techSalesVendorQuotations.remark,
+ createdAt: techSalesVendorQuotations.createdAt,
+ updatedAt: techSalesVendorQuotations.updatedAt,
+ createdBy: techSalesVendorQuotations.createdBy,
+ updatedBy: techSalesVendorQuotations.updatedBy,
+ // RFQ 정보
+ rfqCode: techSalesRfqs.rfqCode,
+ materialCode: techSalesRfqs.materialCode,
+ dueDate: techSalesRfqs.dueDate,
+ rfqStatus: techSalesRfqs.status,
+ // 아이템 정보
+ itemName: items.itemName,
+ // 프로젝트 정보 (JSON에서 추출)
+ projNm: sql<string>`${techSalesRfqs.projectSnapshot}->>'projNm'`,
+ })
+ .from(techSalesVendorQuotations)
+ .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
+ .leftJoin(items, eq(techSalesRfqs.itemId, items.id))
+ .where(finalWhere)
+ .orderBy(...orderBy)
+ .limit(limit)
+ .offset(offset);
+
+ // 총 개수 조회
+ const totalResult = await db
+ .select({ count: sql<number>`count(*)` })
+ .from(techSalesVendorQuotations)
+ .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
+ .leftJoin(items, eq(techSalesRfqs.itemId, items.id))
+ .where(finalWhere);
+
+ const total = totalResult[0]?.count || 0;
+ const pageCount = Math.ceil(total / perPage);
+
+ return { data, pageCount, total };
+ } catch (err) {
+ console.error("Error fetching vendor quotations:", err);
+ return { data: [], pageCount: 0, total: 0 };
+ }
+}
+
+/**
+ * 벤더용 기술영업 견적서 상태별 개수 조회
+ */
+export async function getQuotationStatusCounts(vendorId: string) {
+ unstable_noStore();
+ try {
+ const result = await db
+ .select({
+ status: techSalesVendorQuotations.status,
+ count: sql<number>`count(*)`,
+ })
+ .from(techSalesVendorQuotations)
+ .where(eq(techSalesVendorQuotations.vendorId, parseInt(vendorId)))
+ .groupBy(techSalesVendorQuotations.status);
+
+ return { data: result, error: null };
+ } catch (err) {
+ console.error("Error fetching quotation status counts:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 벤더 견적 승인 (벤더 선택)
+ */
+export async function acceptTechSalesVendorQuotation(quotationId: number) {
+ try {
+ const result = await db.transaction(async (tx) => {
+ // 1. 선택된 견적 정보 조회
+ const selectedQuotation = await tx
+ .select()
+ .from(techSalesVendorQuotations)
+ .where(eq(techSalesVendorQuotations.id, quotationId))
+ .limit(1)
+
+ if (selectedQuotation.length === 0) {
+ throw new Error("견적을 찾을 수 없습니다")
+ }
+
+ const quotation = selectedQuotation[0]
+
+ // 2. 선택된 견적을 Accepted로 변경
+ await tx
+ .update(techSalesVendorQuotations)
+ .set({
+ status: "Accepted",
+ acceptedAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .where(eq(techSalesVendorQuotations.id, quotationId))
+
+ // 3. 같은 RFQ의 다른 견적들을 Rejected로 변경
+ await tx
+ .update(techSalesVendorQuotations)
+ .set({
+ status: "Rejected",
+ rejectionReason: "다른 벤더가 선택됨",
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(techSalesVendorQuotations.rfqId, quotation.rfqId),
+ ne(techSalesVendorQuotations.id, quotationId),
+ eq(techSalesVendorQuotations.status, "Submitted")
+ )
+ )
+
+ // 4. RFQ 상태를 Closed로 변경
+ await tx
+ .update(techSalesRfqs)
+ .set({
+ status: "Closed",
+ updatedAt: new Date(),
+ })
+ .where(eq(techSalesRfqs.id, quotation.rfqId))
+
+ return quotation
+ })
+
+ // 캐시 무효화
+ revalidateTag("techSalesVendorQuotations")
+ revalidateTag(`techSalesRfq-${result.rfqId}`)
+ revalidateTag("techSalesRfqs")
+
+ return { success: true, data: result }
+ } catch (error) {
+ console.error("벤더 견적 승인 오류:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "벤더 견적 승인에 실패했습니다"
+ }
+ }
+}
+
+/**
+ * 기술영업 벤더 견적 거절
+ */
+export async function rejectTechSalesVendorQuotation(quotationId: number, rejectionReason?: string) {
+ try {
+ const result = await db
+ .update(techSalesVendorQuotations)
+ .set({
+ status: "Rejected",
+ rejectionReason: rejectionReason || "기술영업 담당자에 의해 거절됨",
+ updatedAt: new Date(),
+ })
+ .where(eq(techSalesVendorQuotations.id, quotationId))
+ .returning()
+
+ if (result.length === 0) {
+ throw new Error("견적을 찾을 수 없습니다")
+ }
+
+ // 캐시 무효화
+ revalidateTag("techSalesVendorQuotations")
+ revalidateTag(`techSalesRfq-${result[0].rfqId}`)
+
+ return { success: true, data: result[0] }
+ } catch (error) {
+ console.error("벤더 견적 거절 오류:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "벤더 견적 거절에 실패했습니다"
+ }
+ }
+} \ No newline at end of file