summaryrefslogtreecommitdiff
path: root/lib/techsales-rfq/service.ts
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-21 07:54:26 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-21 07:54:26 +0000
commit14f61e24947fb92dd71ec0a7196a6e815f8e66da (patch)
tree317c501d64662d05914330628f867467fba78132 /lib/techsales-rfq/service.ts
parent194bd4bd7e6144d5c09c5e3f5476d254234dce72 (diff)
(최겸)기술영업 RFQ 담당자 초대, 요구사항 반영
Diffstat (limited to 'lib/techsales-rfq/service.ts')
-rw-r--r--lib/techsales-rfq/service.ts7112
1 files changed, 3697 insertions, 3415 deletions
diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts
index c991aa42..fd50b7a6 100644
--- a/lib/techsales-rfq/service.ts
+++ b/lib/techsales-rfq/service.ts
@@ -1,3416 +1,3698 @@
-'use server'
-
-import { unstable_noStore, revalidateTag, revalidatePath } from "next/cache";
-import db from "@/db/db";
-import {
- techSalesRfqs,
- techSalesVendorQuotations,
- techSalesVendorQuotationRevisions,
- techSalesAttachments,
- techSalesVendorQuotationAttachments,
- users,
- techSalesRfqComments,
- techSalesRfqItems,
- biddingProjects
-} from "@/db/schema";
-import { and, desc, eq, ilike, or, sql, inArray, count, asc } 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,
- selectSingleTechSalesVendorQuotationWithJoin
-} 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 } from "../utils";
-import { techVendors, techVendorPossibleItems } from "@/db/schema/techVendors";
-import { decryptWithServerAction } from "@/components/drm/drmUtils";
-import { deleteFile, saveDRMFile } from "../file-stroage";
-
-// 정렬 타입 정의
-// 의도적으로 any 사용 - drizzle ORM의 orderBy 타입이 복잡함
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-type OrderByType = any;
-
-export type Project = {
- id: number;
- projectCode: string;
- projectName: string;
- pjtType: "SHIP" | "TOP" | "HULL";
-}
-
-/**
- * 연도별 순차 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 데이터 조회하는 함수
- * 페이지네이션, 필터링, 정렬 등 지원
- */
-export async function getTechSalesRfqsWithJoin(input: GetTechSalesRfqsSchema & { rfqType?: "SHIP" | "TOP" | "HULL" }) {
- 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),
- ilike(techSalesRfqs.description, s),
- ilike(techSalesRfqs.remark, 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 => {
- // TypeScript 에러 방지를 위한 타입 단언
- const sortField = item.id as string;
-
- switch (sortField) {
- 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 'description':
- return item.desc ? desc(techSalesRfqs.description) : techSalesRfqs.description;
- case 'status':
- return item.desc ? desc(techSalesRfqs.status) : techSalesRfqs.status;
- case 'dueDate':
- return item.desc ? desc(techSalesRfqs.dueDate) : techSalesRfqs.dueDate;
- case 'rfqSendDate':
- return item.desc ? desc(techSalesRfqs.rfqSendDate) : techSalesRfqs.rfqSendDate;
- case 'remark':
- return item.desc ? desc(techSalesRfqs.remark) : techSalesRfqs.remark;
- 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 함수 호출 - rfqType 매개변수 추가
- return await db.transaction(async (tx) => {
- const [data, total] = await Promise.all([
- selectTechSalesRfqsWithJoin(tx, {
- where: finalWhere,
- orderBy,
- offset,
- limit: input.perPage,
- rfqType: input.rfqType,
- }),
- countTechSalesRfqsWithJoin(tx, finalWhere, input.rfqType),
- ]);
-
- const pageCount = Math.ceil(Number(total) / input.perPage);
- return { data, pageCount, total: Number(total) };
- });
- } catch (err) {
- console.error("Error fetching RFQs with join:", err);
- return { data: [], pageCount: 0, total: 0 };
- }
- },
- [JSON.stringify(input)],
- {
- revalidate: 60,
- 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;
- rfqType?: "SHIP" | "TOP" | "HULL"; // rfqType 매개변수 추가
-}) {
- 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 dataWithAttachments = await Promise.all(
- data.map(async (quotation) => {
- const attachments = await db.query.techSalesVendorQuotationAttachments.findMany({
- where: eq(techSalesVendorQuotationAttachments.quotationId, quotation.id),
- orderBy: [desc(techSalesVendorQuotationAttachments.createdAt)],
- });
-
- return {
- ...quotation,
- quotationAttachments: attachments.map(att => ({
- id: att.id,
- fileName: att.fileName,
- fileSize: att.fileSize,
- filePath: att.filePath,
- description: att.description,
- }))
- };
- })
- );
-
- const total = await countTechSalesVendorQuotationsWithJoin(tx, finalWhere);
- return { data: dataWithAttachments, 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;
- rfqType?: "SHIP" | "TOP" | "HULL"; // rfqType 매개변수 추가
-}) {
- 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),
- ilike(techSalesRfqs.description, 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,
- rfqType: input.rfqType, // rfqType 매개변수 추가
- });
- });
-
- 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 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,
- description: true,
- rfqType: true,
- },
- with: {
- biddingProject: 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),
- inArray(techSalesVendorQuotations.vendorId, input.vendorIds)
- ),
- 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 상태 업데이트 (최초 발송인 경우 rfqSendDate 설정)
- const updateData: Partial<typeof techSalesRfqs.$inferInsert> = {
- status: "RFQ Sent",
- sentBy: Number(session.user.id),
- updatedBy: Number(session.user.id),
- updatedAt: new Date(),
- };
-
- // rfqSendDate가 null인 경우에만 최초 전송일 설정
- if (!rfq.rfqSendDate) {
- updateData.rfqSendDate = new Date();
- }
-
- await tx.update(techSalesRfqs)
- .set(updateData)
- .where(eq(techSalesRfqs.id, input.rfqId));
-
- // 2. 선택된 벤더들의 견적서 상태를 "Assigned"에서 "Draft"로 변경
- for (const quotation of vendorQuotations) {
- if (quotation.status === "Assigned") {
- await tx.update(techSalesVendorQuotations)
- .set({
- status: "Draft",
- updatedBy: Number(session.user.id),
- updatedAt: new Date(),
- })
- .where(eq(techSalesVendorQuotations.id, quotation.id));
- }
- }
-
- // 2. 각 벤더에 대해 이메일 발송 처리
- for (const quotation of vendorQuotations) {
- if (!quotation.vendorId || !quotation.vendor) continue;
-
- // 벤더에 속한 모든 사용자 조회
- const vendorUsers = await db.query.users.findMany({
- where: eq(users.companyId, quotation.vendor.id),
- 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";
-
- // RFQ 아이템 목록 조회
- const rfqItemsResult = await getTechSalesRfqItems(rfq.id);
- const rfqItems = rfqItemsResult.data || [];
-
- // 이메일 컨텍스트 구성 (시리즈 정보 제거, 프로젝트 정보 간소화)
- const emailContext = {
- language: language,
- rfq: {
- id: rfq.id,
- code: rfq.rfqCode,
- title: rfqItems.length > 0 ? rfqItems.map(item => item.itemList).join(', ') : '',
- projectCode: rfq.biddingProject?.pspid || '',
- projectName: rfq.biddingProject?.projNm || '',
- description: rfq.remark || '',
- dueDate: rfq.dueDate ? formatDate(rfq.dueDate, "KR") : 'N/A',
- materialCode: rfq.materialCode || '',
- type: rfq.rfqType || 'SHIP',
- },
- items: rfqItems.map(item => ({
- itemCode: item.itemCode,
- itemList: item.itemList,
- workType: item.workType,
- shipType: item.shipType,
- subItemName: item.subItemName,
- itemType: item.itemType,
- })),
- vendor: {
- id: quotation.vendor.id,
- code: quotation.vendor.vendorCode || '',
- name: quotation.vendor.vendorName,
- },
- sender: {
- fullName: sender.name || '',
- email: sender.email,
- },
- project: {
- // 기본 정보만 유지
- id: rfq.biddingProject?.pspid || '',
- name: rfq.biddingProject?.projNm || '',
- sector: rfq.biddingProject?.sector || '',
- shipType: rfq.biddingProject?.ptypeNm || '',
- shipCount: rfq.biddingProject?.projMsrm || 0,
- ownerName: rfq.biddingProject?.kunnrNm || '',
- className: rfq.biddingProject?.cls1Nm || '',
- },
- 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} - ${rfqItems.length > 0 ? rfqItems.map(item => item.itemList).join(', ') : '견적 요청'} ${emailContext.versionInfo}`
- : `[기술영업 RFQ] ${rfq.rfqCode} - ${rfqItems.length > 0 ? rfqItems.map(item => item.itemList).join(', ') : '견적 요청'}`,
- template: 'tech-sales-rfq-invite-ko', // 기술영업용 템플릿
- context: emailContext,
- cc: sender.email, // 발신자를 CC에 추가
- });
- }
- }
- });
-
- // 캐시 무효화
- revalidateTag("techSalesRfqs");
- revalidateTag("techSalesVendorQuotations");
- revalidateTag(`techSalesRfq-${input.rfqId}`);
- revalidatePath(getTechSalesRevalidationPath(rfq?.rfqType || "SHIP"));
-
- return {
- success: true,
- message: `${vendorQuotations.length}개 벤더에게 RFQ가 성공적으로 발송되었습니다`,
- sentCount: vendorQuotations.length,
- };
- } catch (err) {
- console.error("기술영업 RFQ 발송 오류:", err);
- return {
- success: false,
- message: "RFQ 발송 중 오류가 발생했습니다",
- };
- }
-}
-
-/**
- * 벤더용 기술영업 RFQ 견적서 조회 (withJoin 사용)
- */
-export async function getTechSalesVendorQuotation(quotationId: number) {
- unstable_noStore();
- try {
- const quotation = await db.transaction(async (tx) => {
- return await selectSingleTechSalesVendorQuotationWithJoin(tx, quotationId);
- });
-
- if (!quotation) {
- return { data: null, error: "견적서를 찾을 수 없습니다." };
- }
-
- // RFQ 아이템 정보도 함께 조회
- const itemsResult = await getTechSalesRfqItems(quotation.rfqId);
- const items = itemsResult.data || [];
-
- // 견적서 첨부파일 조회
- const quotationAttachments = await db.query.techSalesVendorQuotationAttachments.findMany({
- where: eq(techSalesVendorQuotationAttachments.quotationId, quotationId),
- orderBy: [desc(techSalesVendorQuotationAttachments.createdAt)],
- });
-
- // 기존 구조와 호환되도록 데이터 재구성
- const formattedQuotation = {
- id: quotation.id,
- rfqId: quotation.rfqId,
- vendorId: quotation.vendorId,
- quotationCode: quotation.quotationCode,
- quotationVersion: quotation.quotationVersion,
- totalPrice: quotation.totalPrice,
- currency: quotation.currency,
- validUntil: quotation.validUntil,
- status: quotation.status,
- remark: quotation.remark,
- rejectionReason: quotation.rejectionReason,
- submittedAt: quotation.submittedAt,
- acceptedAt: quotation.acceptedAt,
- createdAt: quotation.createdAt,
- updatedAt: quotation.updatedAt,
- createdBy: quotation.createdBy,
- updatedBy: quotation.updatedBy,
-
- // RFQ 정보
- rfq: {
- id: quotation.rfqId,
- rfqCode: quotation.rfqCode,
- rfqType: quotation.rfqType,
- status: quotation.rfqStatus,
- dueDate: quotation.dueDate,
- rfqSendDate: quotation.rfqSendDate,
- materialCode: quotation.materialCode,
- description: quotation.description,
- remark: quotation.rfqRemark,
- picCode: quotation.picCode,
- createdBy: quotation.rfqCreatedBy,
- biddingProjectId: quotation.biddingProjectId,
-
- // 아이템 정보 추가
- items: items,
-
- // 생성자 정보
- createdByUser: {
- id: quotation.rfqCreatedBy,
- name: quotation.rfqCreatedByName,
- email: quotation.rfqCreatedByEmail,
- },
-
- // 프로젝트 정보
- biddingProject: quotation.biddingProjectId ? {
- id: quotation.biddingProjectId,
- pspid: quotation.pspid,
- projNm: quotation.projNm,
- sector: quotation.sector,
- projMsrm: quotation.projMsrm,
- ptypeNm: quotation.ptypeNm,
- } : null,
- },
-
- // 벤더 정보
- vendor: {
- id: quotation.vendorId,
- vendorName: quotation.vendorName,
- vendorCode: quotation.vendorCode,
- country: quotation.vendorCountry,
- email: quotation.vendorEmail,
- phone: quotation.vendorPhone,
- },
-
- // 첨부파일 정보
- quotationAttachments: quotationAttachments.map(attachment => ({
- id: attachment.id,
- fileName: attachment.fileName,
- fileSize: attachment.fileSize,
- filePath: attachment.filePath,
- description: attachment.description,
- }))
- };
-
- return { data: formattedQuotation, error: null };
- } catch (err) {
- console.error("Error fetching vendor quotation:", err);
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-/**
- * 기술영업 벤더 견적서 업데이트 (임시저장),
- * 현재는 submit으로 처리, revision 을 아래의 함수로 사용가능함.
- */
-export async function updateTechSalesVendorQuotation(data: {
- id: number
- currency: string
- totalPrice: string
- validUntil: Date
- remark?: string
- updatedBy: number
- changeReason?: string
-}) {
- try {
- return await db.transaction(async (tx) => {
- // 현재 견적서 전체 데이터 조회 (revision 저장용)
- const currentQuotation = await tx.query.techSalesVendorQuotations.findFirst({
- where: eq(techSalesVendorQuotations.id, data.id),
- });
-
- if (!currentQuotation) {
- return { data: null, error: "견적서를 찾을 수 없습니다." };
- }
-
- // Accepted나 Rejected 상태가 아니면 수정 가능
- if (["Rejected"].includes(currentQuotation.status)) {
- return { data: null, error: "승인되거나 거절된 견적서는 수정할 수 없습니다." };
- }
-
- // 실제 변경사항이 있는지 확인
- const hasChanges =
- currentQuotation.currency !== data.currency ||
- currentQuotation.totalPrice !== data.totalPrice ||
- currentQuotation.validUntil?.getTime() !== data.validUntil.getTime() ||
- currentQuotation.remark !== (data.remark || null);
-
- if (!hasChanges) {
- return { data: currentQuotation, error: null };
- }
-
- // 현재 버전을 revision history에 저장
- await tx.insert(techSalesVendorQuotationRevisions).values({
- quotationId: data.id,
- version: currentQuotation.quotationVersion || 1,
- snapshot: {
- currency: currentQuotation.currency,
- totalPrice: currentQuotation.totalPrice,
- validUntil: currentQuotation.validUntil,
- remark: currentQuotation.remark,
- status: currentQuotation.status,
- quotationVersion: currentQuotation.quotationVersion,
- submittedAt: currentQuotation.submittedAt,
- acceptedAt: currentQuotation.acceptedAt,
- updatedAt: currentQuotation.updatedAt,
- },
- changeReason: data.changeReason || "견적서 수정",
- revisedBy: data.updatedBy,
- });
-
- // 새로운 버전으로 업데이트
- const result = await tx
- .update(techSalesVendorQuotations)
- .set({
- currency: data.currency,
- totalPrice: data.totalPrice,
- validUntil: data.validUntil,
- remark: data.remark || null,
- quotationVersion: (currentQuotation.quotationVersion || 1) + 1,
- status: "Revised", // 수정된 상태로 변경
- updatedAt: new Date(),
- })
- .where(eq(techSalesVendorQuotations.id, data.id))
- .returning();
-
- return { data: result[0], error: null };
- });
- } catch (error) {
- console.error("Error updating tech sales vendor quotation:", error);
- return { data: null, error: "견적서 업데이트 중 오류가 발생했습니다" };
- } finally {
- // 캐시 무효화
- revalidateTag("techSalesVendorQuotations");
- revalidatePath(`/partners/techsales/rfq-ship/${data.id}`);
- }
-}
-
-/**
- * 기술영업 벤더 견적서 제출
- */
-export async function submitTechSalesVendorQuotation(data: {
- id: number
- currency: string
- totalPrice: string
- validUntil: Date
- remark?: string
- attachments?: Array<{
- fileName: string
- filePath: string
- fileSize: number
- }>
- updatedBy: number
-}) {
- try {
- return await db.transaction(async (tx) => {
- // 현재 견적서 전체 데이터 조회 (revision 저장용)
- const currentQuotation = await tx.query.techSalesVendorQuotations.findFirst({
- where: eq(techSalesVendorQuotations.id, data.id),
- });
-
- if (!currentQuotation) {
- return { data: null, error: "견적서를 찾을 수 없습니다." };
- }
-
- // Rejected 상태에서는 제출 불가
- if (["Rejected"].includes(currentQuotation.status)) {
- return { data: null, error: "거절된 견적서는 제출할 수 없습니다." };
- }
-
- // // 실제 변경사항이 있는지 확인
- // const hasChanges =
- // currentQuotation.currency !== data.currency ||
- // currentQuotation.totalPrice !== data.totalPrice ||
- // currentQuotation.validUntil?.getTime() !== data.validUntil.getTime() ||
- // currentQuotation.remark !== (data.remark || null);
-
- // // 변경사항이 있거나 처음 제출하는 경우 revision 저장
- // if (hasChanges || currentQuotation.status === "Draft") {
- // await tx.insert(techSalesVendorQuotationRevisions).values({
- // quotationId: data.id,
- // version: currentQuotation.quotationVersion || 1,
- // snapshot: {
- // currency: currentQuotation.currency,
- // totalPrice: currentQuotation.totalPrice,
- // validUntil: currentQuotation.validUntil,
- // remark: currentQuotation.remark,
- // status: currentQuotation.status,
- // quotationVersion: currentQuotation.quotationVersion,
- // submittedAt: currentQuotation.submittedAt,
- // acceptedAt: currentQuotation.acceptedAt,
- // updatedAt: currentQuotation.updatedAt,
- // },
- // changeReason: "견적서 제출",
- // revisedBy: data.updatedBy,
- // });
- // }
-
- // 첫 제출인지 확인 (quotationVersion이 null인 경우)
- const isFirstSubmission = currentQuotation.quotationVersion === null;
-
- // 첫 제출이 아닌 경우에만 revision 저장 (변경사항 이력 관리)
- if (!isFirstSubmission) {
- await tx.insert(techSalesVendorQuotationRevisions).values({
- quotationId: data.id,
- version: currentQuotation.quotationVersion || 1,
- snapshot: {
- currency: currentQuotation.currency,
- totalPrice: currentQuotation.totalPrice,
- validUntil: currentQuotation.validUntil,
- remark: currentQuotation.remark,
- status: currentQuotation.status,
- quotationVersion: currentQuotation.quotationVersion,
- submittedAt: currentQuotation.submittedAt,
- acceptedAt: currentQuotation.acceptedAt,
- updatedAt: currentQuotation.updatedAt,
- },
- changeReason: "견적서 제출",
- revisedBy: data.updatedBy,
- });
- }
-
- // 새로운 버전 번호 계산 (첫 제출은 1, 재제출은 1 증가)
- const newRevisionId = isFirstSubmission ? 1 : (currentQuotation.quotationVersion || 1) + 1;
-
- // 새로운 버전으로 업데이트
- const result = await tx
- .update(techSalesVendorQuotations)
- .set({
- currency: data.currency,
- totalPrice: data.totalPrice,
- validUntil: data.validUntil,
- remark: data.remark || null,
- quotationVersion: newRevisionId,
- status: "Submitted",
- submittedAt: new Date(),
- updatedAt: new Date(),
- })
- .where(eq(techSalesVendorQuotations.id, data.id))
- .returning();
-
- // 첨부파일 처리 (새로운 revisionId 사용)
- if (data.attachments && data.attachments.length > 0) {
- for (const attachment of data.attachments) {
- await tx.insert(techSalesVendorQuotationAttachments).values({
- quotationId: data.id,
- revisionId: newRevisionId, // 새로운 리비전 ID 사용
- fileName: attachment.fileName,
- originalFileName: attachment.fileName,
- fileSize: attachment.fileSize,
- filePath: attachment.filePath,
- fileType: attachment.fileName.split('.').pop() || 'unknown',
- uploadedBy: data.updatedBy,
- isVendorUpload: true,
- });
- }
- }
-
- // 메일 발송 (백그라운드에서 실행)
- if (result[0]) {
- // 벤더에게 견적 제출 확인 메일 발송
- sendQuotationSubmittedNotificationToVendor(data.id).catch(error => {
- console.error("벤더 견적 제출 확인 메일 발송 실패:", error);
- });
-
- // 담당자에게 견적 접수 알림 메일 발송
- sendQuotationSubmittedNotificationToManager(data.id).catch(error => {
- console.error("담당자 견적 접수 알림 메일 발송 실패:", error);
- });
- }
-
- return { data: result[0], error: null };
- });
- } catch (error) {
- console.error("Error submitting tech sales vendor quotation:", error);
- return { data: null, error: "견적서 제출 중 오류가 발생했습니다" };
- } finally {
- // 캐시 무효화
- revalidateTag("techSalesVendorQuotations");
- revalidatePath(`/partners/techsales/rfq-ship`);
- }
-}
-
-/**
- * 통화 목록 조회
- */
-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;
- rfqType?: "SHIP" | "TOP" | "HULL";
-}, vendorId: string) {
- return unstable_cache(
- async () => {
- try {
- console.log('🔍 [getVendorQuotations] 호출됨:', {
- vendorId,
- vendorIdParsed: parseInt(vendorId),
- rfqType: input.rfqType,
- inputData: input
- });
-
- const { page, perPage, sort, filters = [], search = "", from = "", to = "" } = input;
- const offset = (page - 1) * perPage;
- const limit = perPage;
-
- // 기본 조건: 해당 벤더의 견적서만 조회 (Assigned 상태 제외)
- const vendorIdNum = parseInt(vendorId);
- if (isNaN(vendorIdNum)) {
- console.error('❌ [getVendorQuotations] Invalid vendorId:', vendorId);
- return { data: [], pageCount: 0, total: 0 };
- }
-
- const baseConditions = [
- eq(techSalesVendorQuotations.vendorId, vendorIdNum),
- sql`${techSalesVendorQuotations.status} != 'Assigned'` // Assigned 상태 제외
- ];
-
- // rfqType 필터링 추가
- if (input.rfqType) {
- baseConditions.push(eq(techSalesRfqs.rfqType, input.rfqType));
- }
-
- // 검색 조건 추가
- 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;
- case 'rfqCode':
- return item.desc ? desc(techSalesRfqs.rfqCode) : techSalesRfqs.rfqCode;
- case 'materialCode':
- return item.desc ? desc(techSalesRfqs.materialCode) : techSalesRfqs.materialCode;
- case 'dueDate':
- return item.desc ? desc(techSalesRfqs.dueDate) : techSalesRfqs.dueDate;
- case 'rfqStatus':
- return item.desc ? desc(techSalesRfqs.status) : techSalesRfqs.status;
- default:
- return item.desc ? desc(techSalesVendorQuotations.updatedAt) : techSalesVendorQuotations.updatedAt;
- }
- });
- }
-
- // 조인을 포함한 데이터 조회 (중복 제거를 위해 techSalesAttachments JOIN 제거)
- 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,
- quotationCode: techSalesVendorQuotations.quotationCode,
- quotationVersion: techSalesVendorQuotations.quotationVersion,
- rejectionReason: techSalesVendorQuotations.rejectionReason,
- acceptedAt: techSalesVendorQuotations.acceptedAt,
- // RFQ 정보
- rfqCode: techSalesRfqs.rfqCode,
- materialCode: techSalesRfqs.materialCode,
- dueDate: techSalesRfqs.dueDate,
- rfqStatus: techSalesRfqs.status,
- description: techSalesRfqs.description,
- // 프로젝트 정보 (직접 조인)
- projNm: biddingProjects.projNm,
- // 아이템 개수
- itemCount: sql<number>`(
- SELECT COUNT(*)
- FROM tech_sales_rfq_items
- WHERE tech_sales_rfq_items.rfq_id = ${techSalesRfqs.id}
- )`,
- // RFQ 첨부파일 개수 (RFQ_COMMON 타입만 카운트)
- attachmentCount: sql<number>`(
- SELECT COUNT(*)
- FROM tech_sales_attachments
- WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id}
- AND tech_sales_attachments.attachment_type = 'RFQ_COMMON'
- )`,
- })
- .from(techSalesVendorQuotations)
- .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
- .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.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(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.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 };
- }
- },
- [JSON.stringify(input), vendorId], // 캐싱 키
- {
- revalidate: 60, // 1분간 캐시
- tags: [
- "techSalesVendorQuotations",
- `vendor-${vendorId}-quotations`
- ],
- }
- )();
-}
-
-/**
- * 기술영업 벤더 견적 승인 (벤더 선택)
- */
-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))
-
- // 4. RFQ 상태를 Closed로 변경
- await tx
- .update(techSalesRfqs)
- .set({
- status: "Closed",
- updatedAt: new Date(),
- })
- .where(eq(techSalesRfqs.id, quotation.rfqId))
-
- return quotation
- })
-
- // 메일 발송 (백그라운드에서 실행)
- // 선택된 벤더에게 견적 선택 알림 메일 발송
- sendQuotationAcceptedNotification(quotationId).catch(error => {
- console.error("벤더 견적 선택 알림 메일 발송 실패:", error);
- });
-
- // 캐시 무효화
- revalidateTag("techSalesVendorQuotations")
- revalidateTag(`techSalesRfq-${result.rfqId}`)
- revalidateTag("techSalesRfqs")
-
- // 해당 RFQ의 모든 벤더 캐시 무효화 (선택된 벤더와 거절된 벤더들)
- const allVendorsInRfq = await db.query.techSalesVendorQuotations.findMany({
- where: eq(techSalesVendorQuotations.rfqId, result.rfqId),
- columns: { vendorId: true }
- });
-
- for (const vendorQuotation of allVendorsInRfq) {
- revalidateTag(`vendor-${vendorQuotation.vendorId}-quotations`);
- }
- revalidatePath("/evcp/budgetary-tech-sales-ship")
- revalidatePath("/partners/techsales")
-
-
- return { success: true, data: result }
- } catch (error) {
- console.error("벤더 견적 승인 오류:", error)
- return {
- success: false,
- error: error instanceof Error ? error.message : "벤더 견적 승인에 실패했습니다"
- }
- }
-}
-
-/**
- * 기술영업 RFQ 첨부파일 생성 (파일 업로드), 사용x
- */
-export async function createTechSalesRfqAttachments(params: {
- techSalesRfqId: number
- files: File[]
- createdBy: number
- attachmentType?: "RFQ_COMMON" | "VENDOR_SPECIFIC"
- description?: string
-}) {
- unstable_noStore();
- try {
- const { techSalesRfqId, files, createdBy, attachmentType = "RFQ_COMMON", description } = params;
-
- if (!files || files.length === 0) {
- return { data: null, error: "업로드할 파일이 없습니다." };
- }
-
- // RFQ 존재 확인
- const rfq = await db.query.techSalesRfqs.findFirst({
- where: eq(techSalesRfqs.id, techSalesRfqId),
- columns: { id: true, status: true }
- });
-
- if (!rfq) {
- return { data: null, error: "RFQ를 찾을 수 없습니다." };
- }
-
- // 편집 가능한 상태 확인
- if (!["RFQ Created", "RFQ Vendor Assignned"].includes(rfq.status)) {
- return { data: null, error: "현재 상태에서는 첨부파일을 추가할 수 없습니다." };
- }
-
- const results: typeof techSalesAttachments.$inferSelect[] = [];
-
- // 트랜잭션으로 처리
- await db.transaction(async (tx) => {
-
- for (const file of files) {
- const saveResult = await saveDRMFile(file, decryptWithServerAction,`techsales-rfq/${techSalesRfqId}` )
-
- // DB에 첨부파일 레코드 생성
- const [newAttachment] = await tx.insert(techSalesAttachments).values({
- techSalesRfqId,
- attachmentType,
- fileName: saveResult.fileName,
- originalFileName: file.name,
- filePath: saveResult.publicPath,
- fileSize: file.size,
- fileType: file.type || undefined,
- description: description || undefined,
- createdBy,
- }).returning();
-
- results.push(newAttachment);
- }
- });
-
- // RFQ 타입 조회하여 캐시 무효화
- const rfqType = await db.query.techSalesRfqs.findFirst({
- where: eq(techSalesRfqs.id, techSalesRfqId),
- columns: { rfqType: true }
- });
-
- revalidateTag("techSalesRfqs");
- revalidateTag(`techSalesRfq-${techSalesRfqId}`);
- revalidatePath(getTechSalesRevalidationPath(rfqType?.rfqType || "SHIP"));
-
- return { data: results, error: null };
- } catch (err) {
- console.error("기술영업 RFQ 첨부파일 생성 오류:", err);
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-/**
- * 기술영업 RFQ 첨부파일 조회
- */
-export async function getTechSalesRfqAttachments(techSalesRfqId: number) {
- unstable_noStore();
- try {
- const attachments = await db.query.techSalesAttachments.findMany({
- where: eq(techSalesAttachments.techSalesRfqId, techSalesRfqId),
- orderBy: [desc(techSalesAttachments.createdAt)],
- with: {
- createdByUser: {
- columns: {
- id: true,
- name: true,
- email: true,
- }
- }
- }
- });
-
- return { data: attachments, error: null };
- } catch (err) {
- console.error("기술영업 RFQ 첨부파일 조회 오류:", err);
- return { data: [], error: getErrorMessage(err) };
- }
-}
-
-/**
- * RFQ 첨부파일 타입별 조회
- */
-export async function getTechSalesRfqAttachmentsByType(
- techSalesRfqId: number,
- attachmentType: "RFQ_COMMON" | "VENDOR_SPECIFIC" | "TBE_RESULT" | "CBE_RESULT"
-) {
- unstable_noStore();
- try {
- const attachments = await db.query.techSalesAttachments.findMany({
- where: and(
- eq(techSalesAttachments.techSalesRfqId, techSalesRfqId),
- eq(techSalesAttachments.attachmentType, attachmentType)
- ),
- orderBy: [desc(techSalesAttachments.createdAt)],
- with: {
- createdByUser: {
- columns: {
- id: true,
- name: true,
- email: true,
- }
- }
- }
- });
-
- return { data: attachments, error: null };
- } catch (err) {
- console.error(`기술영업 RFQ ${attachmentType} 첨부파일 조회 오류:`, err);
- return { data: [], error: getErrorMessage(err) };
- }
-}
-
-/**
- * 기술영업 RFQ 첨부파일 삭제
- */
-export async function deleteTechSalesRfqAttachment(attachmentId: number) {
- unstable_noStore();
- try {
- // 첨부파일 정보 조회
- const attachment = await db.query.techSalesAttachments.findFirst({
- where: eq(techSalesAttachments.id, attachmentId),
- });
-
- if (!attachment) {
- return { data: null, error: "첨부파일을 찾을 수 없습니다." };
- }
-
- // RFQ 상태 확인
- const rfq = await db.query.techSalesRfqs.findFirst({
- where: eq(techSalesRfqs.id, attachment.techSalesRfqId!), // Non-null assertion since we know it exists
- columns: { id: true, status: true }
- });
-
- if (!rfq) {
- return { data: null, error: "RFQ를 찾을 수 없습니다." };
- }
-
- // 편집 가능한 상태 확인
- if (!["RFQ Created", "RFQ Vendor Assignned"].includes(rfq.status)) {
- return { data: null, error: "현재 상태에서는 첨부파일을 삭제할 수 없습니다." };
- }
-
- // 트랜잭션으로 처리
- const result = await db.transaction(async (tx) => {
- // DB에서 레코드 삭제
- const deletedAttachment = await tx.delete(techSalesAttachments)
- .where(eq(techSalesAttachments.id, attachmentId))
- .returning();
-
- // 파일 시스템에서 파일 삭제
- try {
- await deleteFile(`${attachment.filePath}`)
-
- } catch (fileError) {
- console.warn("파일 삭제 실패:", fileError);
- // 파일 삭제 실패는 심각한 오류가 아니므로 계속 진행
- }
-
- return deletedAttachment[0];
- });
-
- // RFQ 타입 조회하여 캐시 무효화
- const attachmentRfq = await db.query.techSalesRfqs.findFirst({
- where: eq(techSalesRfqs.id, attachment.techSalesRfqId!),
- columns: { rfqType: true }
- });
-
- revalidateTag("techSalesRfqs");
- revalidateTag(`techSalesRfq-${attachment.techSalesRfqId}`);
- revalidatePath(getTechSalesRevalidationPath(attachmentRfq?.rfqType || "SHIP"));
-
- return { data: result, error: null };
- } catch (err) {
- console.error("기술영업 RFQ 첨부파일 삭제 오류:", err);
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-/**
- * 기술영업 RFQ 첨부파일 일괄 처리 (업로드 + 삭제)
- */
-export async function processTechSalesRfqAttachments(params: {
- techSalesRfqId: number
- newFiles: { file: File; attachmentType: "RFQ_COMMON" | "VENDOR_SPECIFIC" | "TBE_RESULT" | "CBE_RESULT"; description?: string }[]
- deleteAttachmentIds: number[]
- createdBy: number
-}) {
- unstable_noStore();
- try {
- const { techSalesRfqId, newFiles, deleteAttachmentIds, createdBy } = params;
-
- // RFQ 존재 및 상태 확인
- const rfq = await db.query.techSalesRfqs.findFirst({
- where: eq(techSalesRfqs.id, techSalesRfqId),
- columns: { id: true, status: true }
- });
-
- if (!rfq) {
- return { data: null, error: "RFQ를 찾을 수 없습니다." };
- }
-
- if (!["RFQ Created", "RFQ Vendor Assignned"].includes(rfq.status)) {
- return { data: null, error: "현재 상태에서는 첨부파일을 수정할 수 없습니다." };
- }
-
- const results = {
- uploaded: [] as typeof techSalesAttachments.$inferSelect[],
- deleted: [] as typeof techSalesAttachments.$inferSelect[],
- };
-
- await db.transaction(async (tx) => {
-
- // 1. 삭제할 첨부파일 처리
- if (deleteAttachmentIds.length > 0) {
- const attachmentsToDelete = await tx.query.techSalesAttachments.findMany({
- where: sql`${techSalesAttachments.id} IN (${deleteAttachmentIds.join(',')})`
- });
-
- for (const attachment of attachmentsToDelete) {
- // DB에서 레코드 삭제
- const [deletedAttachment] = await tx.delete(techSalesAttachments)
- .where(eq(techSalesAttachments.id, attachment.id))
- .returning();
-
- results.deleted.push(deletedAttachment);
- await deleteFile(attachment.filePath)
-
- }
- }
-
- // 2. 새 파일 업로드 처리
- if (newFiles.length > 0) {
- for (const { file, attachmentType, description } of newFiles) {
- const saveResult = await saveDRMFile(file, decryptWithServerAction,`techsales-rfq/${techSalesRfqId}` )
-
- // DB에 첨부파일 레코드 생성
- const [newAttachment] = await tx.insert(techSalesAttachments).values({
- techSalesRfqId,
- attachmentType,
- fileName: saveResult.fileName,
- originalFileName: file.name,
- filePath: saveResult.publicPath,
- fileSize: file.size,
- fileType: file.type || undefined,
- description: description || undefined,
- createdBy,
- }).returning();
-
- results.uploaded.push(newAttachment);
- }
- }
- });
-
- // 캐시 무효화
- revalidateTag("techSalesRfqs");
- revalidateTag(`techSalesRfq-${techSalesRfqId}`);
- revalidatePath("/evcp/budgetary-tech-sales-ship");
-
- return {
- data: results,
- error: null,
- message: `${results.uploaded.length}개 업로드, ${results.deleted.length}개 삭제 완료`
- };
- } catch (err) {
- console.error("기술영업 RFQ 첨부파일 일괄 처리 오류:", err);
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-// ========================================
-// 메일 발송 관련 함수들
-// ========================================
-
-/**
- * 벤더 견적 제출 확인 메일 발송 (벤더용)
- */
-export async function sendQuotationSubmittedNotificationToVendor(quotationId: number) {
- try {
- // 견적서 정보 조회 (projectSeries 조인 추가)
- const quotation = await db.query.techSalesVendorQuotations.findFirst({
- where: eq(techSalesVendorQuotations.id, quotationId),
- with: {
- rfq: {
- with: {
- biddingProject: true,
- createdByUser: {
- columns: {
- id: true,
- name: true,
- email: true,
- }
- }
- }
- },
- vendor: {
- columns: {
- id: true,
- vendorName: true,
- vendorCode: true,
- }
- }
- }
- });
-
- if (!quotation || !quotation.rfq || !quotation.vendor) {
- console.error("견적서 또는 관련 정보를 찾을 수 없습니다");
- return { success: false, error: "견적서 정보를 찾을 수 없습니다" };
- }
-
- // 벤더 사용자들 조회
- const vendorUsers = await db.query.users.findMany({
- where: eq(users.companyId, quotation.vendor.id),
- columns: {
- id: true,
- email: true,
- name: true,
- language: true
- }
- });
-
- const vendorEmails = vendorUsers
- .filter(user => user.email)
- .map(user => user.email)
- .join(", ");
-
- if (!vendorEmails) {
- console.warn(`벤더 ID ${quotation.vendor.id}에 등록된 이메일 주소가 없습니다`);
- return { success: false, error: "벤더 이메일 주소가 없습니다" };
- }
-
- // RFQ 아이템 정보 조회
- const rfqItemsResult = await getTechSalesRfqItems(quotation.rfq.id);
- const rfqItems = rfqItemsResult.data || [];
-
- // 이메일 컨텍스트 구성 (시리즈 정보 제거, 프로젝트 정보 간소화)
- const emailContext = {
- language: vendorUsers[0]?.language || "ko",
- quotation: {
- id: quotation.id,
- currency: quotation.currency,
- totalPrice: quotation.totalPrice,
- validUntil: quotation.validUntil,
- submittedAt: quotation.submittedAt,
- remark: quotation.remark,
- },
- rfq: {
- id: quotation.rfq.id,
- code: quotation.rfq.rfqCode,
- title: quotation.rfq.description || '',
- projectCode: quotation.rfq.biddingProject?.pspid || '',
- projectName: quotation.rfq.biddingProject?.projNm || '',
- dueDate: quotation.rfq.dueDate,
- materialCode: quotation.rfq.materialCode,
- description: quotation.rfq.remark,
- },
- items: rfqItems.map(item => ({
- itemCode: item.itemCode,
- itemList: item.itemList,
- workType: item.workType,
- shipType: item.shipType,
- subItemName: item.subItemName,
- itemType: item.itemType,
- })),
- vendor: {
- id: quotation.vendor.id,
- code: quotation.vendor.vendorCode,
- name: quotation.vendor.vendorName,
- },
- project: {
- name: quotation.rfq.biddingProject?.projNm || '',
- sector: quotation.rfq.biddingProject?.sector || '',
- shipCount: quotation.rfq.biddingProject?.projMsrm ? Number(quotation.rfq.biddingProject.projMsrm) : 0,
- ownerName: quotation.rfq.biddingProject?.kunnrNm || '',
- className: quotation.rfq.biddingProject?.cls1Nm || '',
- },
- manager: {
- name: quotation.rfq.createdByUser?.name || '',
- email: quotation.rfq.createdByUser?.email || '',
- },
- systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/partners',
- companyName: 'Samsung Heavy Industries',
- year: new Date().getFullYear(),
- };
-
- // 이메일 발송
- await sendEmail({
- to: vendorEmails,
- subject: `[견적 제출 확인] ${quotation.rfq.rfqCode} - 견적 요청`,
- template: 'tech-sales-quotation-submitted-vendor-ko',
- context: emailContext,
- });
-
- console.log(`벤더 견적 제출 확인 메일 발송 완료: ${vendorEmails}`);
- return { success: true };
- } catch (error) {
- console.error("벤더 견적 제출 확인 메일 발송 오류:", error);
- return { success: false, error: "메일 발송 중 오류가 발생했습니다" };
- }
-}
-
-/**
- * 벤더 견적 접수 알림 메일 발송 (담당자용)
- */
-export async function sendQuotationSubmittedNotificationToManager(quotationId: number) {
- try {
- // 견적서 정보 조회
- const quotation = await db.query.techSalesVendorQuotations.findFirst({
- where: eq(techSalesVendorQuotations.id, quotationId),
- with: {
- rfq: {
- with: {
- biddingProject: true,
- createdByUser: {
- columns: {
- id: true,
- name: true,
- email: true,
- }
- }
- }
- },
- vendor: {
- columns: {
- id: true,
- vendorName: true,
- vendorCode: true,
- }
- }
- }
- });
-
- if (!quotation || !quotation.rfq || !quotation.vendor) {
- console.error("견적서 또는 관련 정보를 찾을 수 없습니다");
- return { success: false, error: "견적서 정보를 찾을 수 없습니다" };
- }
-
- const manager = quotation.rfq.createdByUser;
- if (!manager?.email) {
- console.warn("담당자 이메일 주소가 없습니다");
- return { success: false, error: "담당자 이메일 주소가 없습니다" };
- }
-
- // RFQ 아이템 정보 조회
- const rfqItemsResult = await getTechSalesRfqItems(quotation.rfq.id);
- const rfqItems = rfqItemsResult.data || [];
-
- // 이메일 컨텍스트 구성 (시리즈 정보 제거, 프로젝트 정보 간소화)
- const emailContext = {
- language: "ko",
- quotation: {
- id: quotation.id,
- currency: quotation.currency,
- totalPrice: quotation.totalPrice,
- validUntil: quotation.validUntil,
- submittedAt: quotation.submittedAt,
- remark: quotation.remark,
- },
- rfq: {
- id: quotation.rfq.id,
- code: quotation.rfq.rfqCode,
- title: quotation.rfq.description || '',
- projectCode: quotation.rfq.biddingProject?.pspid || '',
- projectName: quotation.rfq.biddingProject?.projNm || '',
- dueDate: quotation.rfq.dueDate,
- materialCode: quotation.rfq.materialCode,
- description: quotation.rfq.remark,
- },
- items: rfqItems.map(item => ({
- itemCode: item.itemCode,
- itemList: item.itemList,
- workType: item.workType,
- shipType: item.shipType,
- subItemName: item.subItemName,
- itemType: item.itemType,
- })),
- vendor: {
- id: quotation.vendor.id,
- code: quotation.vendor.vendorCode,
- name: quotation.vendor.vendorName,
- },
- project: {
- name: quotation.rfq.biddingProject?.projNm || '',
- sector: quotation.rfq.biddingProject?.sector || '',
- shipCount: quotation.rfq.biddingProject?.projMsrm ? Number(quotation.rfq.biddingProject.projMsrm) : 0,
- ownerName: quotation.rfq.biddingProject?.kunnrNm || '',
- className: quotation.rfq.biddingProject?.cls1Nm || '',
- },
- manager: {
- name: manager.name || '',
- email: manager.email,
- },
- systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/evcp',
- companyName: 'Samsung Heavy Industries',
- year: new Date().getFullYear(),
- };
-
- // 이메일 발송
- await sendEmail({
- to: manager.email,
- subject: `[견적 접수 알림] ${quotation.vendor.vendorName}에서 ${quotation.rfq.rfqCode} 견적서를 제출했습니다`,
- template: 'tech-sales-quotation-submitted-manager-ko',
- context: emailContext,
- });
-
- console.log(`담당자 견적 접수 알림 메일 발송 완료: ${manager.email}`);
- return { success: true };
- } catch (error) {
- console.error("담당자 견적 접수 알림 메일 발송 오류:", error);
- return { success: false, error: "메일 발송 중 오류가 발생했습니다" };
- }
-}
-
-/**
- * 벤더 견적 선택 알림 메일 발송
- */
-export async function sendQuotationAcceptedNotification(quotationId: number) {
- try {
- // 견적서 정보 조회
- const quotation = await db.query.techSalesVendorQuotations.findFirst({
- where: eq(techSalesVendorQuotations.id, quotationId),
- with: {
- rfq: {
- with: {
- biddingProject: true,
- createdByUser: {
- columns: {
- id: true,
- name: true,
- email: true,
- }
- }
- }
- },
- vendor: {
- columns: {
- id: true,
- vendorName: true,
- vendorCode: true,
- }
- }
- }
- });
-
- if (!quotation || !quotation.rfq || !quotation.vendor) {
- console.error("견적서 또는 관련 정보를 찾을 수 없습니다");
- return { success: false, error: "견적서 정보를 찾을 수 없습니다" };
- }
-
- // 벤더 사용자들 조회
- const vendorUsers = await db.query.users.findMany({
- where: eq(users.companyId, quotation.vendor.id),
- columns: {
- id: true,
- email: true,
- name: true,
- language: true
- }
- });
-
- const vendorEmails = vendorUsers
- .filter(user => user.email)
- .map(user => user.email)
- .join(", ");
-
- if (!vendorEmails) {
- console.warn(`벤더 ID ${quotation.vendor.id}에 등록된 이메일 주소가 없습니다`);
- return { success: false, error: "벤더 이메일 주소가 없습니다" };
- }
-
- // RFQ 아이템 정보 조회
- const rfqItemsResult = await getTechSalesRfqItems(quotation.rfq.id);
- const rfqItems = rfqItemsResult.data || [];
-
- // 이메일 컨텍스트 구성 (시리즈 정보 제거, 프로젝트 정보 간소화)
- const emailContext = {
- language: vendorUsers[0]?.language || "ko",
- quotation: {
- id: quotation.id,
- currency: quotation.currency,
- totalPrice: quotation.totalPrice,
- validUntil: quotation.validUntil,
- acceptedAt: quotation.acceptedAt,
- remark: quotation.remark,
- },
- rfq: {
- id: quotation.rfq.id,
- code: quotation.rfq.rfqCode,
- title: quotation.rfq.description || '',
- projectCode: quotation.rfq.biddingProject?.pspid || '',
- projectName: quotation.rfq.biddingProject?.projNm || '',
- dueDate: quotation.rfq.dueDate,
- materialCode: quotation.rfq.materialCode,
- description: quotation.rfq.remark,
- },
- items: rfqItems.map(item => ({
- itemCode: item.itemCode,
- itemList: item.itemList,
- workType: item.workType,
- shipType: item.shipType,
- subItemName: item.subItemName,
- itemType: item.itemType,
- })),
- vendor: {
- id: quotation.vendor.id,
- code: quotation.vendor.vendorCode,
- name: quotation.vendor.vendorName,
- },
- project: {
- name: quotation.rfq.biddingProject?.projNm || '',
- sector: quotation.rfq.biddingProject?.sector || '',
- shipCount: quotation.rfq.biddingProject?.projMsrm ? Number(quotation.rfq.biddingProject.projMsrm) : 0,
- ownerName: quotation.rfq.biddingProject?.kunnrNm || '',
- className: quotation.rfq.biddingProject?.cls1Nm || '',
- },
- manager: {
- name: quotation.rfq.createdByUser?.name || '',
- email: quotation.rfq.createdByUser?.email || '',
- },
- systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/partners',
- companyName: 'Samsung Heavy Industries',
- year: new Date().getFullYear(),
- };
-
- // 이메일 발송
- await sendEmail({
- to: vendorEmails,
- subject: `[견적 선택 알림] ${quotation.rfq.rfqCode} - 귀하의 견적이 선택되었습니다`,
- template: 'tech-sales-quotation-accepted-ko',
- context: emailContext,
- });
-
- console.log(`벤더 견적 선택 알림 메일 발송 완료: ${vendorEmails}`);
- return { success: true };
- } catch (error) {
- console.error("벤더 견적 선택 알림 메일 발송 오류:", error);
- return { success: false, error: "메일 발송 중 오류가 발생했습니다" };
- }
-}
-
-// ==================== Vendor Communication 관련 ====================
-
-export interface TechSalesAttachment {
- id: number
- fileName: string
- fileSize: number
- fileType: string | null // <- null 허용
- filePath: string
- uploadedAt: Date
-}
-
-export interface TechSalesComment {
- id: number
- rfqId: number
- vendorId: number | null // null 허용으로 변경
- userId?: number | null // null 허용으로 변경
- content: string
- isVendorComment: boolean | null // null 허용으로 변경
- createdAt: Date
- updatedAt: Date
- userName?: string | null // null 허용으로 변경
- vendorName?: string | null // null 허용으로 변경
- attachments: TechSalesAttachment[]
- isRead: boolean | null // null 허용으로 변경
-}
-
-/**
- * 특정 RFQ의 벤더별 읽지 않은 메시지 개수를 조회하는 함수
- *
- * @param rfqId RFQ ID
- * @returns 벤더별 읽지 않은 메시지 개수 (vendorId: count)
- */
-export async function getTechSalesUnreadMessageCounts(rfqId: number): Promise<Record<number, number>> {
- try {
- // 벤더가 보낸 읽지 않은 메시지를 벤더별로 카운트
- const unreadCounts = await db
- .select({
- vendorId: techSalesRfqComments.vendorId,
- count: sql<number>`count(*)`,
- })
- .from(techSalesRfqComments)
- .where(
- and(
- eq(techSalesRfqComments.rfqId, rfqId),
- eq(techSalesRfqComments.isVendorComment, true), // 벤더가 보낸 메시지
- eq(techSalesRfqComments.isRead, false), // 읽지 않은 메시지
- sql`${techSalesRfqComments.vendorId} IS NOT NULL` // vendorId가 null이 아닌 것
- )
- )
- .groupBy(techSalesRfqComments.vendorId);
-
- // Record<number, number> 형태로 변환
- const result: Record<number, number> = {};
- unreadCounts.forEach(item => {
- if (item.vendorId) {
- result[item.vendorId] = item.count;
- }
- });
-
- return result;
- } catch (error) {
- console.error('techSales 읽지 않은 메시지 개수 조회 오류:', error);
- return {};
- }
-}
-
-/**
- * 특정 RFQ와 벤더 간의 커뮤니케이션 메시지를 가져오는 서버 액션
- *
- * @param rfqId RFQ ID
- * @param vendorId 벤더 ID
- * @returns 코멘트 목록
- */
-export async function fetchTechSalesVendorComments(rfqId: number, vendorId?: number): Promise<TechSalesComment[]> {
- if (!vendorId) {
- return []
- }
-
- try {
- // 인증 확인
- const session = await getServerSession(authOptions);
-
- if (!session?.user) {
- throw new Error("인증이 필요합니다")
- }
-
- // 코멘트 쿼리
- const comments = await db.query.techSalesRfqComments.findMany({
- where: and(
- eq(techSalesRfqComments.rfqId, rfqId),
- eq(techSalesRfqComments.vendorId, vendorId)
- ),
- orderBy: [techSalesRfqComments.createdAt],
- with: {
- user: {
- columns: {
- name: true
- }
- },
- vendor: {
- columns: {
- vendorName: true
- }
- },
- attachments: true,
- }
- })
-
- // 결과 매핑
- return comments.map(comment => ({
- id: comment.id,
- rfqId: comment.rfqId,
- vendorId: comment.vendorId,
- userId: comment.userId || undefined,
- content: comment.content,
- isVendorComment: comment.isVendorComment,
- createdAt: comment.createdAt,
- updatedAt: comment.updatedAt,
- userName: comment.user?.name,
- vendorName: comment.vendor?.vendorName,
- isRead: comment.isRead,
- attachments: comment.attachments.map(att => ({
- id: att.id,
- fileName: att.fileName,
- fileSize: att.fileSize,
- fileType: att.fileType,
- filePath: att.filePath,
- uploadedAt: att.uploadedAt
- }))
- }))
- } catch (error) {
- console.error('techSales 벤더 코멘트 가져오기 오류:', error)
- throw error
- }
-}
-
-/**
- * 코멘트를 읽음 상태로 표시하는 서버 액션
- *
- * @param rfqId RFQ ID
- * @param vendorId 벤더 ID
- */
-export async function markTechSalesMessagesAsRead(rfqId: number, vendorId?: number): Promise<void> {
- if (!vendorId) {
- return
- }
-
- try {
- // 인증 확인
- const session = await getServerSession(authOptions);
-
- if (!session?.user) {
- throw new Error("인증이 필요합니다")
- }
-
- // 벤더가 작성한 읽지 않은 코멘트 업데이트
- await db.update(techSalesRfqComments)
- .set({ isRead: true })
- .where(
- and(
- eq(techSalesRfqComments.rfqId, rfqId),
- eq(techSalesRfqComments.vendorId, vendorId),
- eq(techSalesRfqComments.isVendorComment, true),
- eq(techSalesRfqComments.isRead, false)
- )
- )
-
- // 캐시 무효화
- revalidateTag(`tech-sales-rfq-${rfqId}-comments`)
- } catch (error) {
- console.error('techSales 메시지 읽음 표시 오류:', error)
- throw error
- }
-}
-
-// ==================== RFQ 조선/해양 관련 ====================
-
-/**
- * 기술영업 조선 RFQ 생성 (1:N 관계)
- */
-export async function createTechSalesShipRfq(input: {
- biddingProjectId: number;
- itemIds: number[]; // 조선 아이템 ID 배열
- dueDate: Date;
- description?: string;
- createdBy: number;
-}) {
- unstable_noStore();
- try {
- return 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}를 찾을 수 없습니다.`);
- }
-
- // RFQ 코드 생성 (SHIP 타입)
- const rfqCode = await generateRfqCodes(tx, 1);
-
- // RFQ 생성
- const [rfq] = await tx
- .insert(techSalesRfqs)
- .values({
- rfqCode: rfqCode[0],
- biddingProjectId: input.biddingProjectId,
- description: input.description,
- dueDate: input.dueDate,
- status: "RFQ Created",
- rfqType: "SHIP",
- createdBy: input.createdBy,
- updatedBy: input.createdBy,
- })
- .returning({ id: techSalesRfqs.id });
-
- // 아이템들 추가
- for (const itemId of input.itemIds) {
- await tx
- .insert(techSalesRfqItems)
- .values({
- rfqId: rfq.id,
- itemShipbuildingId: itemId,
- itemType: "SHIP",
- });
- }
-
- // 캐시 무효화
- revalidateTag("techSalesRfqs");
- revalidatePath("/evcp/budgetary-tech-sales-ship");
-
- return { data: rfq, error: null };
- });
- } catch (err) {
- console.error("Error creating Ship RFQ:", err);
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-/**
- * 기술영업 해양 Hull RFQ 생성 (1:N 관계)
- */
-export async function createTechSalesHullRfq(input: {
- biddingProjectId: number;
- itemIds: number[]; // Hull 아이템 ID 배열
- dueDate: Date;
- description?: string;
- createdBy: number;
-}) {
- unstable_noStore();
- console.log('🔍 createTechSalesHullRfq 호출됨:', input);
-
- try {
- return 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}를 찾을 수 없습니다.`);
- }
-
- // RFQ 코드 생성 (HULL 타입)
- const hullRfqCode = await generateRfqCodes(tx, 1);
-
- // RFQ 생성
- const [rfq] = await tx
- .insert(techSalesRfqs)
- .values({
- rfqCode: hullRfqCode[0],
- biddingProjectId: input.biddingProjectId,
- description: input.description,
- dueDate: input.dueDate,
- status: "RFQ Created",
- rfqType: "HULL",
- createdBy: input.createdBy,
- updatedBy: input.createdBy,
- })
- .returning({ id: techSalesRfqs.id });
-
- // 아이템들 추가
- for (const itemId of input.itemIds) {
- await tx
- .insert(techSalesRfqItems)
- .values({
- rfqId: rfq.id,
- itemOffshoreHullId: itemId,
- itemType: "HULL",
- });
- }
-
- // 캐시 무효화
- revalidateTag("techSalesRfqs");
- revalidatePath("/evcp/budgetary-tech-sales-hull");
-
- return { data: rfq, error: null };
- });
- } catch (err) {
- console.error("Error creating Hull RFQ:", err);
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-/**
- * 기술영업 해양 TOP RFQ 생성 (1:N 관계)
- */
-export async function createTechSalesTopRfq(input: {
- biddingProjectId: number;
- itemIds: number[]; // TOP 아이템 ID 배열
- dueDate: Date;
- description?: string;
- createdBy: number;
-}) {
- unstable_noStore();
- console.log('🔍 createTechSalesTopRfq 호출됨:', input);
-
- try {
- return 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}를 찾을 수 없습니다.`);
- }
-
- // RFQ 코드 생성 (TOP 타입)
- const topRfqCode = await generateRfqCodes(tx, 1);
-
- // RFQ 생성
- const [rfq] = await tx
- .insert(techSalesRfqs)
- .values({
- rfqCode: topRfqCode[0],
- biddingProjectId: input.biddingProjectId,
- description: input.description,
- dueDate: input.dueDate,
- status: "RFQ Created",
- rfqType: "TOP",
- createdBy: input.createdBy,
- updatedBy: input.createdBy,
- })
- .returning({ id: techSalesRfqs.id });
-
- // 아이템들 추가
- for (const itemId of input.itemIds) {
- await tx
- .insert(techSalesRfqItems)
- .values({
- rfqId: rfq.id,
- itemOffshoreTopId: itemId,
- itemType: "TOP",
- });
- }
-
- // 캐시 무효화
- revalidateTag("techSalesRfqs");
- revalidatePath("/evcp/budgetary-tech-sales-top");
-
- return { data: rfq, error: null };
- });
- } catch (err) {
- console.error("Error creating TOP RFQ:", err);
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-/**
- * 조선 RFQ 전용 조회 함수
- */
-export async function getTechSalesShipRfqsWithJoin(input: GetTechSalesRfqsSchema) {
- return getTechSalesRfqsWithJoin({ ...input, rfqType: "SHIP" });
-}
-
-/**
- * 해양 TOP RFQ 전용 조회 함수
- */
-export async function getTechSalesTopRfqsWithJoin(input: GetTechSalesRfqsSchema) {
- return getTechSalesRfqsWithJoin({ ...input, rfqType: "TOP" });
-}
-
-/**
- * 해양 HULL RFQ 전용 조회 함수
- */
-export async function getTechSalesHullRfqsWithJoin(input: GetTechSalesRfqsSchema) {
- return getTechSalesRfqsWithJoin({ ...input, rfqType: "HULL" });
-}
-
-/**
- * 조선 벤더 견적서 전용 조회 함수
- */
-export async function getTechSalesShipVendorQuotationsWithJoin(input: {
- rfqId?: number;
- vendorId?: number;
- search?: string;
- filters?: Filter<typeof techSalesVendorQuotations>[];
- sort?: { id: string; desc: boolean }[];
- page: number;
- perPage: number;
-}) {
- return getTechSalesVendorQuotationsWithJoin({ ...input, rfqType: "SHIP" });
-}
-
-/**
- * 해양 TOP 벤더 견적서 전용 조회 함수
- */
-export async function getTechSalesTopVendorQuotationsWithJoin(input: {
- rfqId?: number;
- vendorId?: number;
- search?: string;
- filters?: Filter<typeof techSalesVendorQuotations>[];
- sort?: { id: string; desc: boolean }[];
- page: number;
- perPage: number;
-}) {
- return getTechSalesVendorQuotationsWithJoin({ ...input, rfqType: "TOP" });
-}
-
-/**
- * 해양 HULL 벤더 견적서 전용 조회 함수
- */
-export async function getTechSalesHullVendorQuotationsWithJoin(input: {
- rfqId?: number;
- vendorId?: number;
- search?: string;
- filters?: Filter<typeof techSalesVendorQuotations>[];
- sort?: { id: string; desc: boolean }[];
- page: number;
- perPage: number;
-}) {
- return getTechSalesVendorQuotationsWithJoin({ ...input, rfqType: "HULL" });
-}
-
-/**
- * 기술영업 RFQ의 아이템 목록 조회
- */
-export async function getTechSalesRfqItems(rfqId: number) {
- unstable_noStore();
- try {
- const items = await db.query.techSalesRfqItems.findMany({
- where: eq(techSalesRfqItems.rfqId, rfqId),
- with: {
- itemShipbuilding: {
- columns: {
- id: true,
- itemCode: true,
- itemList: true,
- workType: true,
- shipTypes: true,
- }
- },
- itemOffshoreTop: {
- columns: {
- id: true,
- itemCode: true,
- itemList: true,
- workType: true,
- subItemList: true,
- }
- },
- itemOffshoreHull: {
- columns: {
- id: true,
- itemCode: true,
- itemList: true,
- workType: true,
- subItemList: true,
- }
- }
- },
- orderBy: [techSalesRfqItems.id]
- });
-
- // 아이템 타입에 따라 정보 매핑
- const mappedItems = items.map(item => {
- let itemInfo = null;
-
- switch (item.itemType) {
- case 'SHIP':
- itemInfo = item.itemShipbuilding;
- break;
- case 'TOP':
- itemInfo = item.itemOffshoreTop;
- break;
- case 'HULL':
- itemInfo = item.itemOffshoreHull;
- break;
- }
-
- return {
- id: item.id,
- rfqId: item.rfqId,
- itemType: item.itemType,
- itemCode: itemInfo?.itemCode || '',
- itemList: itemInfo?.itemList || '',
- workType: itemInfo?.workType || '',
- // 조선이면 shipType, 해양이면 subItemList
- shipType: item.itemType === 'SHIP' ? (itemInfo as { shipTypes?: string })?.shipTypes || '' : undefined,
- subItemName: item.itemType !== 'SHIP' ? (itemInfo as { subItemList?: string })?.subItemList || '' : undefined,
- };
- });
-
- return { data: mappedItems, error: null };
- } catch (err) {
- console.error("Error fetching RFQ items:", err);
- return { data: [], error: getErrorMessage(err) };
- }
-}
-
-/**
- * RFQ 아이템들과 매칭되는 후보 벤더들을 찾는 함수
- */
-export async function getTechSalesRfqCandidateVendors(rfqId: number) {
- unstable_noStore();
-
- try {
- return await db.transaction(async (tx) => {
- // 1. RFQ 정보 조회 (타입 확인)
- const rfq = await tx.query.techSalesRfqs.findFirst({
- where: eq(techSalesRfqs.id, rfqId),
- columns: {
- id: true,
- rfqType: true
- }
- });
-
- if (!rfq) {
- return { data: [], error: "RFQ를 찾을 수 없습니다." };
- }
-
- // 2. RFQ 아이템들 조회
- const rfqItems = await tx.query.techSalesRfqItems.findMany({
- where: eq(techSalesRfqItems.rfqId, rfqId),
- with: {
- itemShipbuilding: true,
- itemOffshoreTop: true,
- itemOffshoreHull: true,
- }
- });
-
- if (rfqItems.length === 0) {
- return { data: [], error: null };
- }
-
- // 3. 아이템 코드들 추출
- const itemCodes: string[] = [];
- rfqItems.forEach(item => {
- if (item.itemType === "SHIP" && item.itemShipbuilding?.itemCode) {
- itemCodes.push(item.itemShipbuilding.itemCode);
- } else if (item.itemType === "TOP" && item.itemOffshoreTop?.itemCode) {
- itemCodes.push(item.itemOffshoreTop.itemCode);
- } else if (item.itemType === "HULL" && item.itemOffshoreHull?.itemCode) {
- itemCodes.push(item.itemOffshoreHull.itemCode);
- }
- });
-
- if (itemCodes.length === 0) {
- return { data: [], error: null };
- }
-
- // 4. RFQ 타입에 따른 벤더 타입 매핑
- const vendorTypeFilter = rfq.rfqType === "SHIP" ? "SHIP" :
- rfq.rfqType === "TOP" ? "OFFSHORE_TOP" :
- rfq.rfqType === "HULL" ? "OFFSHORE_HULL" : null;
-
- if (!vendorTypeFilter) {
- return { data: [], error: "지원되지 않는 RFQ 타입입니다." };
- }
-
- // 5. 매칭되는 벤더들 조회 (타입 필터링 포함)
- const candidateVendors = await tx
- .select({
- id: techVendors.id, // 벤더 ID를 id로 명명하여 key 문제 해결
- vendorId: techVendors.id, // 호환성을 위해 유지
- vendorName: techVendors.vendorName,
- vendorCode: techVendors.vendorCode,
- country: techVendors.country,
- email: techVendors.email,
- phone: techVendors.phone,
- status: techVendors.status,
- techVendorType: techVendors.techVendorType,
- matchedItemCodes: sql<string[]>`
- array_agg(DISTINCT ${techVendorPossibleItems.itemCode})
- `,
- matchedItemCount: sql<number>`
- count(DISTINCT ${techVendorPossibleItems.itemCode})
- `,
- })
- .from(techVendorPossibleItems)
- .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id))
- .where(
- and(
- inArray(techVendorPossibleItems.itemCode, itemCodes),
- eq(techVendors.status, "ACTIVE")
- // 벤더 타입 필터링 임시 제거 - 데이터 확인 후 다시 추가
- // eq(techVendors.techVendorType, vendorTypeFilter)
- )
- )
- .groupBy(
- techVendorPossibleItems.vendorId,
- techVendors.id,
- techVendors.vendorName,
- techVendors.vendorCode,
- techVendors.country,
- techVendors.email,
- techVendors.phone,
- techVendors.status,
- techVendors.techVendorType
- )
- .orderBy(desc(sql`count(DISTINCT ${techVendorPossibleItems.itemCode})`));
-
- return { data: candidateVendors, error: null };
- });
- } catch (err) {
- console.error("Error fetching candidate vendors:", err);
- return { data: [], error: getErrorMessage(err) };
- }
-}
-
-/**
- * RFQ 타입에 따른 캐시 무효화 경로 반환
- */
-function getTechSalesRevalidationPath(rfqType: "SHIP" | "TOP" | "HULL"): string {
- switch (rfqType) {
- case "SHIP":
- return "/evcp/budgetary-tech-sales-ship";
- case "TOP":
- return "/evcp/budgetary-tech-sales-top";
- case "HULL":
- return "/evcp/budgetary-tech-sales-hull";
- default:
- return "/evcp/budgetary-tech-sales-ship";
- }
-}
-
-/**
- * 기술영업 RFQ에 여러 벤더 추가 (techVendors 기반)
- * 벤더 추가 시에는 견적서를 생성하지 않고, RFQ 전송 시에 견적서를 생성
- */
-export async function addTechVendorsToTechSalesRfq(input: {
- rfqId: number;
- vendorIds: number[];
- createdBy: number;
-}) {
- unstable_noStore();
-
- try {
- return await db.transaction(async (tx) => {
- const results = [];
- const errors: string[] = [];
-
- // 1. RFQ 상태 및 타입 확인
- const rfq = await tx.query.techSalesRfqs.findFirst({
- where: eq(techSalesRfqs.id, input.rfqId),
- columns: {
- id: true,
- status: true,
- rfqType: true,
- }
- });
-
- if (!rfq) {
- throw new Error("RFQ를 찾을 수 없습니다");
- }
-
- // 2. 각 벤더에 대해 처리 (이미 추가된 벤더는 견적서가 있는지 확인)
- for (const vendorId of input.vendorIds) {
- try {
- // 이미 추가된 벤더인지 확인 (견적서 존재 여부로 확인)
- const existingQuotation = await tx.query.techSalesVendorQuotations.findFirst({
- where: and(
- eq(techSalesVendorQuotations.rfqId, input.rfqId),
- eq(techSalesVendorQuotations.vendorId, vendorId)
- )
- });
-
- if (existingQuotation) {
- errors.push(`벤더 ID ${vendorId}는 이미 추가되어 있습니다.`);
- continue;
- }
-
- // 벤더가 실제로 존재하는지 확인
- const vendor = await tx.query.techVendors.findFirst({
- where: eq(techVendors.id, vendorId),
- columns: { id: true, vendorName: true }
- });
-
- if (!vendor) {
- errors.push(`벤더 ID ${vendorId}를 찾을 수 없습니다.`);
- continue;
- }
-
- // 🔥 중요: 벤더 추가 시에는 견적서를 생성하지 않고, "Assigned" 상태로만 생성
- // quotation_version은 null로 설정하여 벤더가 실제 견적 제출 시에만 리비전 생성
- const [quotation] = await tx
- .insert(techSalesVendorQuotations)
- .values({
- rfqId: input.rfqId,
- vendorId: vendorId,
- status: "Assigned", // Draft가 아닌 Assigned 상태로 생성
- quotationVersion: null, // 리비전은 견적 제출 시에만 생성
- createdBy: input.createdBy,
- updatedBy: input.createdBy,
- })
- .returning({ id: techSalesVendorQuotations.id });
-
- // 🆕 RFQ의 아이템 코드들을 tech_vendor_possible_items에 추가
- try {
- // RFQ의 아이템들 조회
- const rfqItemsResult = await getTechSalesRfqItems(input.rfqId);
-
- if (rfqItemsResult.data && rfqItemsResult.data.length > 0) {
- const itemCodes = rfqItemsResult.data
- .map(item => item.itemCode)
- .filter(code => code); // 빈 코드 제외
-
- // 각 아이템 코드에 대해 tech_vendor_possible_items에 추가 (중복 체크)
- for (const itemCode of itemCodes) {
- // 이미 존재하는지 확인
- const existing = await tx.query.techVendorPossibleItems.findFirst({
- where: and(
- eq(techVendorPossibleItems.vendorId, vendorId),
- eq(techVendorPossibleItems.itemCode, itemCode)
- )
- });
-
- // 존재하지 않으면 추가
- if (!existing) {
- await tx.insert(techVendorPossibleItems).values({
- vendorId: vendorId,
- itemCode: itemCode,
- });
- }
- }
- }
- } catch (possibleItemError) {
- // tech_vendor_possible_items 추가 실패는 전체 실패로 처리하지 않음
- console.warn(`벤더 ${vendorId}의 가능 아이템 추가 실패:`, possibleItemError);
- }
-
- results.push({ id: quotation.id, vendorId, vendorName: vendor.vendorName });
- } 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));
- }
-
- // 캐시 무효화 (RFQ 타입에 따른 동적 경로)
- revalidateTag("techSalesRfqs");
- revalidateTag("techSalesVendorQuotations");
- revalidateTag(`techSalesRfq-${input.rfqId}`);
- revalidatePath(getTechSalesRevalidationPath(rfq.rfqType || "SHIP"));
-
- return {
- data: results,
- error: errors.length > 0 ? errors.join(", ") : null,
- successCount: results.length,
- errorCount: errors.length
- };
- });
- } catch (err) {
- console.error("Error adding tech vendors to RFQ:", err);
- return { data: [], error: getErrorMessage(err) };
- }
-}
-
-/**
- * 기술영업 RFQ의 벤더 목록 조회 (techVendors 기반)
- */
-export async function getTechSalesRfqTechVendors(rfqId: number) {
- unstable_noStore();
-
- try {
- return await db.transaction(async (tx) => {
- const vendors = await tx
- .select({
- id: techSalesVendorQuotations.id,
- vendorId: techVendors.id,
- vendorName: techVendors.vendorName,
- vendorCode: techVendors.vendorCode,
- country: techVendors.country,
- email: techVendors.email,
- phone: techVendors.phone,
- status: techSalesVendorQuotations.status,
- totalPrice: techSalesVendorQuotations.totalPrice,
- currency: techSalesVendorQuotations.currency,
- validUntil: techSalesVendorQuotations.validUntil,
- submittedAt: techSalesVendorQuotations.submittedAt,
- createdAt: techSalesVendorQuotations.createdAt,
- })
- .from(techSalesVendorQuotations)
- .innerJoin(techVendors, eq(techSalesVendorQuotations.vendorId, techVendors.id))
- .where(eq(techSalesVendorQuotations.rfqId, rfqId))
- .orderBy(desc(techSalesVendorQuotations.createdAt));
-
- return { data: vendors, error: null };
- });
- } catch (err) {
- console.error("Error fetching RFQ tech vendors:", err);
- return { data: [], error: getErrorMessage(err) };
- }
-}
-
-/**
- * 기술영업 RFQ에서 기술영업 벤더 제거 (techVendors 기반)
- */
-export async function removeTechVendorFromTechSalesRfq(input: {
- rfqId: number;
- vendorId: number;
-}) {
- unstable_noStore();
-
- try {
- return await db.transaction(async (tx) => {
- // 해당 벤더의 견적서 상태 확인
- const existingQuotation = await tx.query.techSalesVendorQuotations.findFirst({
- where: and(
- eq(techSalesVendorQuotations.rfqId, input.rfqId),
- eq(techSalesVendorQuotations.vendorId, input.vendorId)
- )
- });
-
- if (!existingQuotation) {
- return { data: null, error: "해당 벤더가 이 RFQ에 존재하지 않습니다." };
- }
-
- // Assigned 상태가 아닌 경우 삭제 불가
- if (existingQuotation.status !== "Assigned") {
- return { data: null, error: "Assigned 상태의 벤더만 삭제할 수 있습니다." };
- }
-
- // 해당 벤더의 견적서 삭제
- const [deletedQuotation] = await tx
- .delete(techSalesVendorQuotations)
- .where(
- and(
- eq(techSalesVendorQuotations.rfqId, input.rfqId),
- eq(techSalesVendorQuotations.vendorId, input.vendorId)
- )
- )
- .returning({ id: techSalesVendorQuotations.id });
-
- // 캐시 무효화
- revalidateTag("techSalesRfqs");
- revalidateTag("techSalesVendorQuotations");
-
- return { data: deletedQuotation, error: null };
- });
- } catch (err) {
- console.error("Error removing tech vendor from RFQ:", err);
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-/**
- * 기술영업 RFQ에서 여러 기술영업 벤더 제거 (techVendors 기반)
- */
-export async function removeTechVendorsFromTechSalesRfq(input: {
- rfqId: number;
- vendorIds: number[];
-}) {
- unstable_noStore();
-
- try {
- return await db.transaction(async (tx) => {
- const results = [];
- const errors: string[] = [];
-
- for (const vendorId of input.vendorIds) {
- // 해당 벤더의 견적서 상태 확인
- const existingQuotation = await tx.query.techSalesVendorQuotations.findFirst({
- where: and(
- eq(techSalesVendorQuotations.rfqId, input.rfqId),
- eq(techSalesVendorQuotations.vendorId, vendorId)
- )
- });
-
- if (!existingQuotation) {
- errors.push(`벤더 ID ${vendorId}가 이 RFQ에 존재하지 않습니다.`);
- continue;
- }
-
- // Assigned 상태가 아닌 경우 삭제 불가
- if (existingQuotation.status !== "Assigned") {
- errors.push(`벤더 ID ${vendorId}는 Assigned 상태가 아니므로 삭제할 수 없습니다.`);
- continue;
- }
-
- // 해당 벤더의 견적서 삭제
- const [deletedQuotation] = await tx
- .delete(techSalesVendorQuotations)
- .where(
- and(
- eq(techSalesVendorQuotations.rfqId, input.rfqId),
- eq(techSalesVendorQuotations.vendorId, vendorId)
- )
- )
- .returning({ id: techSalesVendorQuotations.id });
-
- results.push(deletedQuotation);
- }
-
- // 캐시 무효화
- revalidateTag("techSalesRfqs");
- revalidateTag("techSalesVendorQuotations");
-
- return {
- data: results,
- error: errors.length > 0 ? errors.join(", ") : null,
- successCount: results.length,
- errorCount: errors.length
- };
- });
- } catch (err) {
- console.error("Error removing tech vendors from RFQ:", err);
- return { data: [], error: getErrorMessage(err) };
- }
-}
-
-/**
- * 기술영업 벤더 검색
- */
-export async function searchTechVendors(searchTerm: string, limit = 100, rfqType?: "SHIP" | "TOP" | "HULL") {
- unstable_noStore();
-
- try {
- // RFQ 타입에 따른 벤더 타입 매핑
- const vendorTypeFilter = rfqType === "SHIP" ? "조선" :
- rfqType === "TOP" ? "해양TOP" :
- rfqType === "HULL" ? "해양HULL" : null;
-
- const whereConditions = [
- eq(techVendors.status, "ACTIVE"),
- or(
- ilike(techVendors.vendorName, `%${searchTerm}%`),
- ilike(techVendors.vendorCode, `%${searchTerm}%`)
- )
- ];
-
- // RFQ 타입이 지정된 경우 벤더 타입 필터링 추가 (컴마 구분 문자열에서 검색)
- if (vendorTypeFilter) {
- whereConditions.push(sql`${techVendors.techVendorType} LIKE ${'%' + vendorTypeFilter + '%'}`);
- }
-
- const results = await db
- .select({
- id: techVendors.id,
- vendorName: techVendors.vendorName,
- vendorCode: techVendors.vendorCode,
- status: techVendors.status,
- country: techVendors.country,
- techVendorType: techVendors.techVendorType,
- })
- .from(techVendors)
- .where(and(...whereConditions))
- .limit(limit)
- .orderBy(techVendors.vendorName);
-
- return results;
- } catch (err) {
- console.error("Error searching tech vendors:", err);
- throw new Error(getErrorMessage(err));
- }
-}
-
-
-/**
- * 벤더 견적서 거절 처리 (벤더가 직접 거절)
- */
-export async function rejectTechSalesVendorQuotations(input: {
- quotationIds: number[];
- rejectionReason?: string;
-}) {
- try {
- const session = await getServerSession(authOptions);
- if (!session?.user?.id) {
- throw new Error("인증이 필요합니다.");
- }
-
- const result = await db.transaction(async (tx) => {
- // 견적서들이 존재하고 벤더가 권한이 있는지 확인
- const quotations = await tx
- .select({
- id: techSalesVendorQuotations.id,
- status: techSalesVendorQuotations.status,
- vendorId: techSalesVendorQuotations.vendorId,
- })
- .from(techSalesVendorQuotations)
- .where(inArray(techSalesVendorQuotations.id, input.quotationIds));
-
- if (quotations.length !== input.quotationIds.length) {
- throw new Error("일부 견적서를 찾을 수 없습니다.");
- }
-
- // 이미 거절된 견적서가 있는지 확인
- const alreadyRejected = quotations.filter(q => q.status === "Rejected");
- if (alreadyRejected.length > 0) {
- throw new Error("이미 거절된 견적서가 포함되어 있습니다.");
- }
-
- // 승인된 견적서가 있는지 확인
- const alreadyAccepted = quotations.filter(q => q.status === "Accepted");
- if (alreadyAccepted.length > 0) {
- throw new Error("이미 승인된 견적서는 거절할 수 없습니다.");
- }
-
- // 견적서 상태를 거절로 변경
- await tx
- .update(techSalesVendorQuotations)
- .set({
- status: "Rejected",
- rejectionReason: input.rejectionReason || null,
- updatedBy: parseInt(session.user.id),
- updatedAt: new Date(),
- })
- .where(inArray(techSalesVendorQuotations.id, input.quotationIds));
-
- return { success: true, updatedCount: quotations.length };
- });
- revalidateTag("techSalesRfqs");
- revalidateTag("techSalesVendorQuotations");
- revalidatePath("/partners/techsales/rfq-ship", "page");
- return {
- success: true,
- message: `${result.updatedCount}개의 견적서가 거절되었습니다.`,
- data: result
- };
- } catch (error) {
- console.error("견적서 거절 오류:", error);
- return {
- success: false,
- error: getErrorMessage(error)
- };
- }
-}
-
-// ==================== Revision 관련 ====================
-
-/**
- * 견적서 revision 히스토리 조회
- */
-export async function getTechSalesVendorQuotationRevisions(quotationId: number) {
- try {
- const revisions = await db
- .select({
- id: techSalesVendorQuotationRevisions.id,
- version: techSalesVendorQuotationRevisions.version,
- snapshot: techSalesVendorQuotationRevisions.snapshot,
- changeReason: techSalesVendorQuotationRevisions.changeReason,
- revisionNote: techSalesVendorQuotationRevisions.revisionNote,
- revisedBy: techSalesVendorQuotationRevisions.revisedBy,
- revisedAt: techSalesVendorQuotationRevisions.revisedAt,
- // 수정자 정보 조인
- revisedByName: users.name,
- })
- .from(techSalesVendorQuotationRevisions)
- .leftJoin(users, eq(techSalesVendorQuotationRevisions.revisedBy, users.id))
- .where(eq(techSalesVendorQuotationRevisions.quotationId, quotationId))
- .orderBy(desc(techSalesVendorQuotationRevisions.version));
-
- return { data: revisions, error: null };
- } catch (error) {
- console.error("견적서 revision 히스토리 조회 오류:", error);
- return { data: null, error: "견적서 히스토리를 조회하는 중 오류가 발생했습니다." };
- }
-}
-
-/**
- * 견적서의 현재 버전과 revision 히스토리를 함께 조회 (각 리비전의 첨부파일 포함)
- */
-export async function getTechSalesVendorQuotationWithRevisions(quotationId: number) {
- try {
- // 먼저 현재 견적서 조회
- const currentQuotation = await db.query.techSalesVendorQuotations.findFirst({
- where: eq(techSalesVendorQuotations.id, quotationId),
- with: {
- // 벤더 정보와 RFQ 정보도 함께 조회 (필요한 경우)
- }
- });
-
- if (!currentQuotation) {
- return { data: null, error: "견적서를 찾을 수 없습니다." };
- }
-
- // 이제 현재 견적서의 정보를 알고 있으므로 병렬로 나머지 정보 조회
- const [revisionsResult, currentAttachments] = await Promise.all([
- getTechSalesVendorQuotationRevisions(quotationId),
- getTechSalesVendorQuotationAttachmentsByRevision(quotationId, currentQuotation.quotationVersion || 0)
- ]);
-
- // 현재 견적서에 첨부파일 정보 추가
- const currentWithAttachments = {
- ...currentQuotation,
- attachments: currentAttachments.data || []
- };
-
- // 각 리비전의 첨부파일 정보 추가
- const revisionsWithAttachments = await Promise.all(
- (revisionsResult.data || []).map(async (revision) => {
- const attachmentsResult = await getTechSalesVendorQuotationAttachmentsByRevision(quotationId, revision.version);
- return {
- ...revision,
- attachments: attachmentsResult.data || []
- };
- })
- );
-
- return {
- data: {
- current: currentWithAttachments,
- revisions: revisionsWithAttachments
- },
- error: null
- };
- } catch (error) {
- console.error("견적서 전체 히스토리 조회 오류:", error);
- return { data: null, error: "견적서 정보를 조회하는 중 오류가 발생했습니다." };
- }
-}
-
-/**
- * 견적서 첨부파일 조회 (리비전 ID 기준 오름차순 정렬)
- */
-export async function getTechSalesVendorQuotationAttachments(quotationId: number) {
- return unstable_cache(
- async () => {
- try {
- const attachments = await db
- .select({
- id: techSalesVendorQuotationAttachments.id,
- quotationId: techSalesVendorQuotationAttachments.quotationId,
- revisionId: techSalesVendorQuotationAttachments.revisionId,
- fileName: techSalesVendorQuotationAttachments.fileName,
- originalFileName: techSalesVendorQuotationAttachments.originalFileName,
- fileSize: techSalesVendorQuotationAttachments.fileSize,
- fileType: techSalesVendorQuotationAttachments.fileType,
- filePath: techSalesVendorQuotationAttachments.filePath,
- description: techSalesVendorQuotationAttachments.description,
- uploadedBy: techSalesVendorQuotationAttachments.uploadedBy,
- vendorId: techSalesVendorQuotationAttachments.vendorId,
- isVendorUpload: techSalesVendorQuotationAttachments.isVendorUpload,
- createdAt: techSalesVendorQuotationAttachments.createdAt,
- updatedAt: techSalesVendorQuotationAttachments.updatedAt,
- })
- .from(techSalesVendorQuotationAttachments)
- .where(eq(techSalesVendorQuotationAttachments.quotationId, quotationId))
- .orderBy(desc(techSalesVendorQuotationAttachments.createdAt));
-
- return { data: attachments };
- } catch (error) {
- console.error("견적서 첨부파일 조회 오류:", error);
- return { error: "견적서 첨부파일 조회 중 오류가 발생했습니다." };
- }
- },
- [`quotation-attachments-${quotationId}`],
- {
- revalidate: 60,
- tags: [`quotation-${quotationId}`, "quotation-attachments"],
- }
- )();
-}
-
-/**
- * 특정 리비전의 견적서 첨부파일 조회
- */
-export async function getTechSalesVendorQuotationAttachmentsByRevision(quotationId: number, revisionId: number) {
- try {
- const attachments = await db
- .select({
- id: techSalesVendorQuotationAttachments.id,
- quotationId: techSalesVendorQuotationAttachments.quotationId,
- revisionId: techSalesVendorQuotationAttachments.revisionId,
- fileName: techSalesVendorQuotationAttachments.fileName,
- originalFileName: techSalesVendorQuotationAttachments.originalFileName,
- fileSize: techSalesVendorQuotationAttachments.fileSize,
- fileType: techSalesVendorQuotationAttachments.fileType,
- filePath: techSalesVendorQuotationAttachments.filePath,
- description: techSalesVendorQuotationAttachments.description,
- uploadedBy: techSalesVendorQuotationAttachments.uploadedBy,
- vendorId: techSalesVendorQuotationAttachments.vendorId,
- isVendorUpload: techSalesVendorQuotationAttachments.isVendorUpload,
- createdAt: techSalesVendorQuotationAttachments.createdAt,
- updatedAt: techSalesVendorQuotationAttachments.updatedAt,
- })
- .from(techSalesVendorQuotationAttachments)
- .where(and(
- eq(techSalesVendorQuotationAttachments.quotationId, quotationId),
- eq(techSalesVendorQuotationAttachments.revisionId, revisionId)
- ))
- .orderBy(desc(techSalesVendorQuotationAttachments.createdAt));
-
- return { data: attachments };
- } catch (error) {
- console.error("리비전별 견적서 첨부파일 조회 오류:", error);
- return { error: "첨부파일 조회 중 오류가 발생했습니다." };
- }
-}
-
-
-// ==================== Project AVL 관련 ====================
-
-/**
- * Accepted 상태의 Tech Sales Vendor Quotations 조회 (RFQ, Vendor 정보 포함)
- */
-export async function getAcceptedTechSalesVendorQuotations(input: {
- search?: string;
- filters?: Filter<typeof techSalesVendorQuotations>[];
- sort?: { id: string; desc: boolean }[];
- page: number;
- perPage: number;
- rfqType?: "SHIP" | "TOP" | "HULL";
-}) {
- unstable_noStore();
-
- try {
- const offset = (input.page - 1) * input.perPage;
-
- // 기본 WHERE 조건: status = 'Accepted'만 조회, rfqType이 'SHIP'이 아닌 것만
- const baseConditions = [
- eq(techSalesVendorQuotations.status, 'Accepted'),
- sql`${techSalesRfqs.rfqType} != 'SHIP'` // 조선 RFQ 타입 제외
- ];
-
- // 검색 조건 추가
- const searchConditions = [];
- if (input.search) {
- searchConditions.push(
- ilike(techSalesRfqs.rfqCode, `%${input.search}%`),
- ilike(techSalesRfqs.description, `%${input.search}%`),
- ilike(sql`vendors.vendor_name`, `%${input.search}%`),
- ilike(sql`vendors.vendor_code`, `%${input.search}%`)
- );
- }
-
- // 정렬 조건 변환
- const orderByConditions: OrderByType[] = [];
- if (input.sort?.length) {
- input.sort.forEach((sortItem) => {
- switch (sortItem.id) {
- case "rfqCode":
- orderByConditions.push(sortItem.desc ? desc(techSalesRfqs.rfqCode) : asc(techSalesRfqs.rfqCode));
- break;
- case "description":
- orderByConditions.push(sortItem.desc ? desc(techSalesRfqs.description) : asc(techSalesRfqs.description));
- break;
- case "vendorName":
- orderByConditions.push(sortItem.desc ? desc(sql`vendors.vendor_name`) : asc(sql`vendors.vendor_name`));
- break;
- case "vendorCode":
- orderByConditions.push(sortItem.desc ? desc(sql`vendors.vendor_code`) : asc(sql`vendors.vendor_code`));
- break;
- case "totalPrice":
- orderByConditions.push(sortItem.desc ? desc(techSalesVendorQuotations.totalPrice) : asc(techSalesVendorQuotations.totalPrice));
- break;
- case "acceptedAt":
- orderByConditions.push(sortItem.desc ? desc(techSalesVendorQuotations.acceptedAt) : asc(techSalesVendorQuotations.acceptedAt));
- break;
- default:
- orderByConditions.push(desc(techSalesVendorQuotations.acceptedAt));
- }
- });
- } else {
- orderByConditions.push(desc(techSalesVendorQuotations.acceptedAt));
- }
-
- // 필터 조건 추가
- const filterConditions = [];
- if (input.filters?.length) {
- const filterWhere = filterColumns({
- table: techSalesVendorQuotations,
- filters: input.filters,
- joinOperator: "and",
- });
- if (filterWhere) {
- filterConditions.push(filterWhere);
- }
- }
-
- // RFQ 타입 필터
- if (input.rfqType) {
- filterConditions.push(eq(techSalesRfqs.rfqType, input.rfqType));
- }
-
- // 모든 조건 결합
- const allConditions = [
- ...baseConditions,
- ...filterConditions,
- ...(searchConditions.length > 0 ? [or(...searchConditions)] : [])
- ];
-
- const whereCondition = allConditions.length > 1
- ? and(...allConditions)
- : allConditions[0];
-
- // 데이터 조회
- const data = await db
- .select({
- // Quotation 정보
- id: techSalesVendorQuotations.id,
- rfqId: techSalesVendorQuotations.rfqId,
- vendorId: techSalesVendorQuotations.vendorId,
- quotationCode: techSalesVendorQuotations.quotationCode,
- quotationVersion: techSalesVendorQuotations.quotationVersion,
- totalPrice: techSalesVendorQuotations.totalPrice,
- currency: techSalesVendorQuotations.currency,
- validUntil: techSalesVendorQuotations.validUntil,
- status: techSalesVendorQuotations.status,
- remark: techSalesVendorQuotations.remark,
- submittedAt: techSalesVendorQuotations.submittedAt,
- acceptedAt: techSalesVendorQuotations.acceptedAt,
- createdAt: techSalesVendorQuotations.createdAt,
- updatedAt: techSalesVendorQuotations.updatedAt,
-
- // RFQ 정보
- rfqCode: techSalesRfqs.rfqCode,
- rfqType: techSalesRfqs.rfqType,
- description: techSalesRfqs.description,
- dueDate: techSalesRfqs.dueDate,
- rfqStatus: techSalesRfqs.status,
- materialCode: techSalesRfqs.materialCode,
-
- // Vendor 정보
- vendorName: sql<string>`vendors.vendor_name`,
- vendorCode: sql<string | null>`vendors.vendor_code`,
- vendorEmail: sql<string | null>`vendors.email`,
- vendorCountry: sql<string | null>`vendors.country`,
-
- // Project 정보
- projNm: biddingProjects.projNm,
- pspid: biddingProjects.pspid,
- sector: biddingProjects.sector,
- })
- .from(techSalesVendorQuotations)
- .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
- .leftJoin(sql`vendors`, eq(techSalesVendorQuotations.vendorId, sql`vendors.id`))
- .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id))
- .where(whereCondition)
- .orderBy(...orderByConditions)
- .limit(input.perPage)
- .offset(offset);
-
- // 총 개수 조회
- const totalCount = await db
- .select({ count: count() })
- .from(techSalesVendorQuotations)
- .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
- .leftJoin(sql`vendors`, eq(techSalesVendorQuotations.vendorId, sql`vendors.id`))
- .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id))
- .where(whereCondition);
-
- const total = totalCount[0]?.count ?? 0;
- const pageCount = Math.ceil(total / input.perPage);
-
- return {
- data,
- pageCount,
- total,
- };
-
- } catch (error) {
- console.error("getAcceptedTechSalesVendorQuotations 오류:", error);
- throw new Error(`Accepted quotations 조회 실패: ${getErrorMessage(error)}`);
- }
-}
-
-export async function getBidProjects(pjtType: 'SHIP' | 'TOP' | 'HULL'): Promise<Project[]> {
- try {
- // 트랜잭션을 사용하여 프로젝트 데이터 조회
- const projectList = await db.transaction(async (tx) => {
- // 기본 쿼리 구성
- const query = tx
- .select({
- id: biddingProjects.id,
- projectCode: biddingProjects.pspid,
- projectName: biddingProjects.projNm,
- pjtType: biddingProjects.pjtType,
- })
- .from(biddingProjects)
- .where(eq(biddingProjects.pjtType, pjtType));
-
- const results = await query.orderBy(biddingProjects.id);
- return results;
- });
-
- // Handle null projectName values and ensure pjtType is not null
- const validProjectList = projectList.map(project => ({
- ...project,
- projectName: project.projectName || '', // Replace null with empty string
- pjtType: project.pjtType as "SHIP" | "TOP" | "HULL" // Type assertion since WHERE filters ensure non-null
- }));
-
- return validProjectList;
- } catch (error) {
- console.error("프로젝트 목록 가져오기 실패:", error);
- return []; // 오류 발생 시 빈 배열 반환
- }
+'use server'
+
+import { unstable_noStore, revalidateTag, revalidatePath } from "next/cache";
+import db from "@/db/db";
+import {
+ techSalesRfqs,
+ techSalesVendorQuotations,
+ techSalesVendorQuotationRevisions,
+ techSalesAttachments,
+ techSalesVendorQuotationAttachments,
+ techSalesVendorQuotationContacts,
+ techSalesContactPossibleItems,
+ users,
+ techSalesRfqComments,
+ techSalesRfqItems,
+ biddingProjects
+} from "@/db/schema";
+import { and, desc, eq, ilike, or, sql, inArray, count, asc } 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,
+ selectSingleTechSalesVendorQuotationWithJoin
+} 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 } from "../utils";
+import { techVendors, techVendorPossibleItems, techVendorContacts } from "@/db/schema/techVendors";
+import { deleteFile, saveDRMFile, saveFile } from "@/lib/file-stroage";
+import { decryptWithServerAction } from "@/components/drm/drmUtils";
+
+// 정렬 타입 정의
+// 의도적으로 any 사용 - drizzle ORM의 orderBy 타입이 복잡함
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type OrderByType = any;
+
+export type Project = {
+ id: number;
+ projectCode: string;
+ projectName: string;
+ pjtType: "SHIP" | "TOP" | "HULL";
+}
+
+/**
+ * 연도별 순차 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 데이터 조회하는 함수
+ * 페이지네이션, 필터링, 정렬 등 지원
+ */
+export async function getTechSalesRfqsWithJoin(input: GetTechSalesRfqsSchema & { rfqType?: "SHIP" | "TOP" | "HULL" }) {
+ 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),
+ ilike(techSalesRfqs.description, s),
+ ilike(techSalesRfqs.remark, 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 => {
+ // TypeScript 에러 방지를 위한 타입 단언
+ const sortField = item.id as string;
+
+ switch (sortField) {
+ 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 'description':
+ return item.desc ? desc(techSalesRfqs.description) : techSalesRfqs.description;
+ case 'status':
+ return item.desc ? desc(techSalesRfqs.status) : techSalesRfqs.status;
+ case 'dueDate':
+ return item.desc ? desc(techSalesRfqs.dueDate) : techSalesRfqs.dueDate;
+ case 'rfqSendDate':
+ return item.desc ? desc(techSalesRfqs.rfqSendDate) : techSalesRfqs.rfqSendDate;
+ case 'remark':
+ return item.desc ? desc(techSalesRfqs.remark) : techSalesRfqs.remark;
+ 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 함수 호출 - rfqType 매개변수 추가
+ return await db.transaction(async (tx) => {
+ const [data, total] = await Promise.all([
+ selectTechSalesRfqsWithJoin(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ rfqType: input.rfqType,
+ }),
+ countTechSalesRfqsWithJoin(tx, finalWhere, input.rfqType),
+ ]);
+
+ const pageCount = Math.ceil(Number(total) / input.perPage);
+ return { data, pageCount, total: Number(total) };
+ });
+ } catch (err) {
+ console.error("Error fetching RFQs with join:", err);
+ return { data: [], pageCount: 0, total: 0 };
+ }
+ },
+ [JSON.stringify(input)],
+ {
+ revalidate: 60,
+ 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;
+ rfqType?: "SHIP" | "TOP" | "HULL"; // rfqType 매개변수 추가
+}) {
+ 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 dataWithAttachments = await Promise.all(
+ data.map(async (quotation) => {
+ const attachments = await db.query.techSalesVendorQuotationAttachments.findMany({
+ where: eq(techSalesVendorQuotationAttachments.quotationId, quotation.id),
+ orderBy: [desc(techSalesVendorQuotationAttachments.createdAt)],
+ });
+
+ return {
+ ...quotation,
+ quotationAttachments: attachments.map(att => ({
+ id: att.id,
+ fileName: att.fileName,
+ fileSize: att.fileSize,
+ filePath: att.filePath,
+ description: att.description,
+ }))
+ };
+ })
+ );
+
+ const total = await countTechSalesVendorQuotationsWithJoin(tx, finalWhere);
+ return { data: dataWithAttachments, 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;
+ rfqType?: "SHIP" | "TOP" | "HULL"; // rfqType 매개변수 추가
+}) {
+ 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),
+ ilike(techSalesRfqs.description, 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,
+ rfqType: input.rfqType, // rfqType 매개변수 추가
+ });
+ });
+
+ 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 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 발송 (선택된 벤더들의 선택된 contact들에게)
+ */
+export async function sendTechSalesRfqToVendors(input: {
+ rfqId: number;
+ vendorIds: number[];
+ selectedContacts?: Array<{
+ vendorId: number;
+ contactId: number;
+ contactEmail: string;
+ contactName: string;
+ }>;
+}) {
+ 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,
+ description: true,
+ rfqType: true,
+ },
+ with: {
+ biddingProject: 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),
+ inArray(techSalesVendorQuotations.vendorId, input.vendorIds)
+ ),
+ 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 상태 업데이트 (최초 발송인 경우 rfqSendDate 설정)
+ const updateData: Partial<typeof techSalesRfqs.$inferInsert> = {
+ status: "RFQ Sent",
+ sentBy: Number(session.user.id),
+ updatedBy: Number(session.user.id),
+ updatedAt: new Date(),
+ };
+
+ // rfqSendDate가 null인 경우에만 최초 전송일 설정
+ if (!rfq.rfqSendDate) {
+ updateData.rfqSendDate = new Date();
+ }
+
+ await tx.update(techSalesRfqs)
+ .set(updateData)
+ .where(eq(techSalesRfqs.id, input.rfqId));
+
+ // 2. 선택된 벤더들의 견적서 상태를 "Assigned"에서 "Draft"로 변경
+ for (const quotation of vendorQuotations) {
+ if (quotation.status === "Assigned") {
+ await tx.update(techSalesVendorQuotations)
+ .set({
+ status: "Draft",
+ updatedBy: Number(session.user.id),
+ updatedAt: new Date(),
+ })
+ .where(eq(techSalesVendorQuotations.id, quotation.id));
+ }
+ }
+
+ // 3. 각 벤더에 대해 이메일 발송 처리
+ for (const quotation of vendorQuotations) {
+ if (!quotation.vendorId || !quotation.vendor) continue;
+
+ let vendorEmailsString = "";
+
+ // contact 기반 발송 또는 기존 방식 (모든 벤더 사용자)
+ if (input.selectedContacts && input.selectedContacts.length > 0) {
+ // 선택된 contact들에게만 발송
+ const vendorContacts = input.selectedContacts.filter(
+ contact => contact.vendorId === quotation.vendor!.id
+ );
+
+ if (vendorContacts.length > 0) {
+ vendorEmailsString = vendorContacts
+ .map(contact => contact.contactEmail)
+ .join(", ");
+ }
+ } else {
+ // 기존 방식: 벤더에 속한 모든 사용자에게 발송
+ const vendorUsers = await db.query.users.findMany({
+ where: eq(users.companyId, quotation.vendor.id),
+ columns: {
+ id: true,
+ email: true,
+ name: true,
+ language: true
+ }
+ });
+
+ vendorEmailsString = vendorUsers
+ .filter(user => user.email)
+ .map(user => user.email)
+ .join(", ");
+ }
+
+ if (vendorEmailsString) {
+ // 대표 언어 결정 (기본값 한국어)
+ const language = "ko";
+
+ // RFQ 아이템 목록 조회
+ const rfqItemsResult = await getTechSalesRfqItems(rfq.id);
+ const rfqItems = rfqItemsResult.data || [];
+
+ // 이메일 컨텍스트 구성
+ const emailContext = {
+ language: language,
+ rfq: {
+ id: rfq.id,
+ code: rfq.rfqCode,
+ title: rfqItems.length > 0 ? rfqItems.map(item => item.itemList).join(', ') : '',
+ projectCode: rfq.biddingProject?.pspid || '',
+ projectName: rfq.biddingProject?.projNm || '',
+ description: rfq.remark || '',
+ dueDate: rfq.dueDate ? formatDate(rfq.dueDate, "KR") : 'N/A',
+ materialCode: rfq.materialCode || '',
+ type: rfq.rfqType || 'SHIP',
+ },
+ items: rfqItems.map(item => ({
+ itemCode: item.itemCode,
+ itemList: item.itemList,
+ workType: item.workType,
+ shipTypes: item.shipTypes,
+ subItemList: item.subItemList,
+ itemType: item.itemType,
+ })),
+ vendor: {
+ id: quotation.vendor.id,
+ code: quotation.vendor.vendorCode || '',
+ name: quotation.vendor.vendorName,
+ },
+ sender: {
+ fullName: sender.name || '',
+ email: sender.email,
+ },
+ project: {
+ id: rfq.biddingProject?.pspid || '',
+ name: rfq.biddingProject?.projNm || '',
+ sector: rfq.biddingProject?.sector || '',
+ shipType: rfq.biddingProject?.ptypeNm || '',
+ shipCount: rfq.biddingProject?.projMsrm || 0,
+ ownerName: rfq.biddingProject?.kunnrNm || '',
+ className: rfq.biddingProject?.cls1Nm || '',
+ },
+ 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} - ${rfqItems.length > 0 ? rfqItems.map(item => item.itemList).join(', ') : '견적 요청'} ${emailContext.versionInfo}`
+ : `[기술영업 RFQ] ${rfq.rfqCode} - ${rfqItems.length > 0 ? rfqItems.map(item => item.itemList).join(', ') : '견적 요청'}`,
+ template: 'tech-sales-rfq-invite-ko', // 기술영업용 템플릿
+ context: emailContext,
+ cc: sender.email, // 발신자를 CC에 추가
+ });
+
+ // 4. 선택된 담당자 정보를 quotation_contacts 테이블에 저장
+ if (input.selectedContacts && input.selectedContacts.length > 0) {
+ const vendorContacts = input.selectedContacts.filter(
+ contact => contact.vendorId === quotation.vendor!.id
+ );
+
+ for (const contact of vendorContacts) {
+ // quotation_contacts 중복 체크
+ const existingQuotationContact = await tx.query.techSalesVendorQuotationContacts.findFirst({
+ where: and(
+ eq(techSalesVendorQuotationContacts.quotationId, quotation.id),
+ eq(techSalesVendorQuotationContacts.contactId, contact.contactId)
+ )
+ });
+
+ if (!existingQuotationContact) {
+ await tx.insert(techSalesVendorQuotationContacts).values({
+ quotationId: quotation.id,
+ contactId: contact.contactId,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ });
+ }
+
+ // 5. 담당자별 아이템 매핑 정보 저장 (중복 방지)
+ for (const item of rfqItems) {
+ // tech_vendor_possible_items에서 해당 벤더의 아이템 찾기
+ const vendorPossibleItem = await tx.query.techVendorPossibleItems.findFirst({
+ where: and(
+ eq(techVendorPossibleItems.vendorId, quotation.vendor!.id),
+ eq(techVendorPossibleItems.itemCode, item.itemCode || '')
+ )
+ });
+
+ if (vendorPossibleItem) {
+ // contact_possible_items 중복 체크
+ const existingContactPossibleItem = await tx.query.techSalesContactPossibleItems.findFirst({
+ where: and(
+ eq(techSalesContactPossibleItems.contactId, contact.contactId),
+ eq(techSalesContactPossibleItems.vendorPossibleItemId, vendorPossibleItem.id)
+ )
+ });
+
+ if (!existingContactPossibleItem) {
+ await tx.insert(techSalesContactPossibleItems).values({
+ contactId: contact.contactId,
+ vendorPossibleItemId: vendorPossibleItem.id,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ });
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ });
+
+ // 캐시 무효화
+ revalidateTag("techSalesRfqs");
+ revalidateTag("techSalesVendorQuotations");
+ revalidateTag(`techSalesRfq-${input.rfqId}`);
+ revalidatePath(getTechSalesRevalidationPath(rfq?.rfqType || "SHIP"));
+
+ const sentContactCount = input.selectedContacts?.length || vendorQuotations.length;
+ const messageDetail = input.selectedContacts && input.selectedContacts.length > 0
+ ? `${sentContactCount}명의 연락처에게 RFQ가 성공적으로 발송되었습니다`
+ : `${vendorQuotations.length}개 벤더에게 RFQ가 성공적으로 발송되었습니다`;
+
+ return {
+ success: true,
+ message: messageDetail,
+ sentCount: sentContactCount,
+ };
+ } catch (err) {
+ console.error("기술영업 RFQ 발송 오류:", err);
+ return {
+ success: false,
+ message: "RFQ 발송 중 오류가 발생했습니다",
+ };
+ }
+}
+
+/**
+ * 벤더용 기술영업 RFQ 견적서 조회 (withJoin 사용)
+ */
+export async function getTechSalesVendorQuotation(quotationId: number) {
+ unstable_noStore();
+ try {
+ const quotation = await db.transaction(async (tx) => {
+ return await selectSingleTechSalesVendorQuotationWithJoin(tx, quotationId);
+ });
+
+ if (!quotation) {
+ return { data: null, error: "견적서를 찾을 수 없습니다." };
+ }
+
+ // RFQ 아이템 정보도 함께 조회
+ const itemsResult = await getTechSalesRfqItems(quotation.rfqId);
+ const items = itemsResult.data || [];
+
+ // 견적서 첨부파일 조회
+ const quotationAttachments = await db.query.techSalesVendorQuotationAttachments.findMany({
+ where: eq(techSalesVendorQuotationAttachments.quotationId, quotationId),
+ orderBy: [desc(techSalesVendorQuotationAttachments.createdAt)],
+ });
+
+ // 기존 구조와 호환되도록 데이터 재구성
+ const formattedQuotation = {
+ id: quotation.id,
+ rfqId: quotation.rfqId,
+ vendorId: quotation.vendorId,
+ quotationCode: quotation.quotationCode,
+ quotationVersion: quotation.quotationVersion,
+ totalPrice: quotation.totalPrice,
+ currency: quotation.currency,
+ validUntil: quotation.validUntil,
+ status: quotation.status,
+ remark: quotation.remark,
+ rejectionReason: quotation.rejectionReason,
+ submittedAt: quotation.submittedAt,
+ acceptedAt: quotation.acceptedAt,
+ createdAt: quotation.createdAt,
+ updatedAt: quotation.updatedAt,
+ createdBy: quotation.createdBy,
+ updatedBy: quotation.updatedBy,
+
+ // RFQ 정보
+ rfq: {
+ id: quotation.rfqId,
+ rfqCode: quotation.rfqCode,
+ rfqType: quotation.rfqType,
+ status: quotation.rfqStatus,
+ dueDate: quotation.dueDate,
+ rfqSendDate: quotation.rfqSendDate,
+ materialCode: quotation.materialCode,
+ description: quotation.description,
+ remark: quotation.rfqRemark,
+ picCode: quotation.picCode,
+ createdBy: quotation.rfqCreatedBy,
+ biddingProjectId: quotation.biddingProjectId,
+
+ // 아이템 정보 추가
+ items: items,
+
+ // 생성자 정보
+ createdByUser: {
+ id: quotation.rfqCreatedBy,
+ name: quotation.rfqCreatedByName,
+ email: quotation.rfqCreatedByEmail,
+ },
+
+ // 프로젝트 정보
+ biddingProject: quotation.biddingProjectId ? {
+ id: quotation.biddingProjectId,
+ pspid: quotation.pspid,
+ projNm: quotation.projNm,
+ sector: quotation.sector,
+ projMsrm: quotation.projMsrm,
+ ptypeNm: quotation.ptypeNm,
+ } : null,
+ },
+
+ // 벤더 정보
+ vendor: {
+ id: quotation.vendorId,
+ vendorName: quotation.vendorName,
+ vendorCode: quotation.vendorCode,
+ country: quotation.vendorCountry,
+ email: quotation.vendorEmail,
+ phone: quotation.vendorPhone,
+ },
+
+ // 첨부파일 정보
+ quotationAttachments: quotationAttachments.map(attachment => ({
+ id: attachment.id,
+ fileName: attachment.fileName,
+ fileSize: attachment.fileSize,
+ filePath: attachment.filePath,
+ description: attachment.description,
+ }))
+ };
+
+ return { data: formattedQuotation, error: null };
+ } catch (err) {
+ console.error("Error fetching vendor quotation:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 벤더 견적서 업데이트 (임시저장),
+ * 현재는 submit으로 처리, revision 을 아래의 함수로 사용가능함.
+ */
+export async function updateTechSalesVendorQuotation(data: {
+ id: number
+ currency: string
+ totalPrice: string
+ validUntil: Date
+ remark?: string
+ updatedBy: number
+ changeReason?: string
+}) {
+ try {
+ return await db.transaction(async (tx) => {
+ // 현재 견적서 전체 데이터 조회 (revision 저장용)
+ const currentQuotation = await tx.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, data.id),
+ });
+
+ if (!currentQuotation) {
+ return { data: null, error: "견적서를 찾을 수 없습니다." };
+ }
+
+ // Accepted나 Rejected 상태가 아니면 수정 가능
+ if (["Rejected"].includes(currentQuotation.status)) {
+ return { data: null, error: "승인되거나 거절된 견적서는 수정할 수 없습니다." };
+ }
+
+ // 실제 변경사항이 있는지 확인
+ const hasChanges =
+ currentQuotation.currency !== data.currency ||
+ currentQuotation.totalPrice !== data.totalPrice ||
+ currentQuotation.validUntil?.getTime() !== data.validUntil.getTime() ||
+ currentQuotation.remark !== (data.remark || null);
+
+ if (!hasChanges) {
+ return { data: currentQuotation, error: null };
+ }
+
+ // 현재 버전을 revision history에 저장
+ await tx.insert(techSalesVendorQuotationRevisions).values({
+ quotationId: data.id,
+ version: currentQuotation.quotationVersion || 1,
+ snapshot: {
+ currency: currentQuotation.currency,
+ totalPrice: currentQuotation.totalPrice,
+ validUntil: currentQuotation.validUntil,
+ remark: currentQuotation.remark,
+ status: currentQuotation.status,
+ quotationVersion: currentQuotation.quotationVersion,
+ submittedAt: currentQuotation.submittedAt,
+ acceptedAt: currentQuotation.acceptedAt,
+ updatedAt: currentQuotation.updatedAt,
+ },
+ changeReason: data.changeReason || "견적서 수정",
+ revisedBy: data.updatedBy,
+ });
+
+ // 새로운 버전으로 업데이트
+ const result = await tx
+ .update(techSalesVendorQuotations)
+ .set({
+ currency: data.currency,
+ totalPrice: data.totalPrice,
+ validUntil: data.validUntil,
+ remark: data.remark || null,
+ quotationVersion: (currentQuotation.quotationVersion || 1) + 1,
+ status: "Revised", // 수정된 상태로 변경
+ updatedAt: new Date(),
+ })
+ .where(eq(techSalesVendorQuotations.id, data.id))
+ .returning();
+
+ return { data: result[0], error: null };
+ });
+ } catch (error) {
+ console.error("Error updating tech sales vendor quotation:", error);
+ return { data: null, error: "견적서 업데이트 중 오류가 발생했습니다" };
+ } finally {
+ // 캐시 무효화
+ revalidateTag("techSalesVendorQuotations");
+ revalidatePath(`/partners/techsales/rfq-ship/${data.id}`);
+ }
+}
+
+/**
+ * 기술영업 벤더 견적서 제출
+ */
+export async function submitTechSalesVendorQuotation(data: {
+ id: number
+ currency: string
+ totalPrice: string
+ validUntil: Date
+ remark?: string
+ attachments?: Array<{
+ fileName: string
+ originalFileName: string
+ filePath: string
+ fileSize: number
+ }>
+ updatedBy: number
+}) {
+ try {
+ return await db.transaction(async (tx) => {
+ // 현재 견적서 전체 데이터 조회 (revision 저장용)
+ const currentQuotation = await tx.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, data.id),
+ });
+
+ if (!currentQuotation) {
+ return { data: null, error: "견적서를 찾을 수 없습니다." };
+ }
+
+ // Rejected 상태에서는 제출 불가
+ if (["Rejected"].includes(currentQuotation.status)) {
+ return { data: null, error: "거절된 견적서는 제출할 수 없습니다." };
+ }
+
+ // // 실제 변경사항이 있는지 확인
+ // const hasChanges =
+ // currentQuotation.currency !== data.currency ||
+ // currentQuotation.totalPrice !== data.totalPrice ||
+ // currentQuotation.validUntil?.getTime() !== data.validUntil.getTime() ||
+ // currentQuotation.remark !== (data.remark || null);
+
+ // // 변경사항이 있거나 처음 제출하는 경우 revision 저장
+ // if (hasChanges || currentQuotation.status === "Draft") {
+ // await tx.insert(techSalesVendorQuotationRevisions).values({
+ // quotationId: data.id,
+ // version: currentQuotation.quotationVersion || 1,
+ // snapshot: {
+ // currency: currentQuotation.currency,
+ // totalPrice: currentQuotation.totalPrice,
+ // validUntil: currentQuotation.validUntil,
+ // remark: currentQuotation.remark,
+ // status: currentQuotation.status,
+ // quotationVersion: currentQuotation.quotationVersion,
+ // submittedAt: currentQuotation.submittedAt,
+ // acceptedAt: currentQuotation.acceptedAt,
+ // updatedAt: currentQuotation.updatedAt,
+ // },
+ // changeReason: "견적서 제출",
+ // revisedBy: data.updatedBy,
+ // });
+ // }
+
+ // 첫 제출인지 확인 (quotationVersion이 null인 경우)
+ const isFirstSubmission = currentQuotation.quotationVersion === null;
+
+ // 첫 제출이 아닌 경우에만 revision 저장 (변경사항 이력 관리)
+ if (!isFirstSubmission) {
+ await tx.insert(techSalesVendorQuotationRevisions).values({
+ quotationId: data.id,
+ version: currentQuotation.quotationVersion || 1,
+ snapshot: {
+ currency: currentQuotation.currency,
+ totalPrice: currentQuotation.totalPrice,
+ validUntil: currentQuotation.validUntil,
+ remark: currentQuotation.remark,
+ status: currentQuotation.status,
+ quotationVersion: currentQuotation.quotationVersion,
+ submittedAt: currentQuotation.submittedAt,
+ acceptedAt: currentQuotation.acceptedAt,
+ updatedAt: currentQuotation.updatedAt,
+ },
+ changeReason: "견적서 제출",
+ revisedBy: data.updatedBy,
+ });
+ }
+
+ // 새로운 버전 번호 계산 (첫 제출은 1, 재제출은 1 증가)
+ const newRevisionId = isFirstSubmission ? 1 : (currentQuotation.quotationVersion || 1) + 1;
+
+ // 새로운 버전으로 업데이트
+ const result = await tx
+ .update(techSalesVendorQuotations)
+ .set({
+ currency: data.currency,
+ totalPrice: data.totalPrice,
+ validUntil: data.validUntil,
+ remark: data.remark || null,
+ quotationVersion: newRevisionId,
+ status: "Submitted",
+ submittedAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .where(eq(techSalesVendorQuotations.id, data.id))
+ .returning();
+
+ // 첨부파일 처리 (새로운 revisionId 사용)
+ if (data.attachments && data.attachments.length > 0) {
+ for (const attachment of data.attachments) {
+ await tx.insert(techSalesVendorQuotationAttachments).values({
+ quotationId: data.id,
+ revisionId: newRevisionId, // 새로운 리비전 ID 사용
+ fileName: attachment.fileName, // 해시된 파일명 (저장용)
+ originalFileName: attachment.originalFileName, // 원본 파일명 (표시용)
+ fileSize: attachment.fileSize,
+ filePath: attachment.filePath,
+ fileType: attachment.originalFileName.split('.').pop() || 'unknown',
+ uploadedBy: data.updatedBy,
+ isVendorUpload: true,
+ });
+ }
+ }
+
+ // 메일 발송 (백그라운드에서 실행)
+ if (result[0]) {
+ // 벤더에게 견적 제출 확인 메일 발송
+ sendQuotationSubmittedNotificationToVendor(data.id).catch(error => {
+ console.error("벤더 견적 제출 확인 메일 발송 실패:", error);
+ });
+
+ // 담당자에게 견적 접수 알림 메일 발송
+ sendQuotationSubmittedNotificationToManager(data.id).catch(error => {
+ console.error("담당자 견적 접수 알림 메일 발송 실패:", error);
+ });
+ }
+
+ return { data: result[0], error: null };
+ });
+ } catch (error) {
+ console.error("Error submitting tech sales vendor quotation:", error);
+ return { data: null, error: "견적서 제출 중 오류가 발생했습니다" };
+ } finally {
+ // 캐시 무효화
+ revalidateTag("techSalesVendorQuotations");
+ revalidatePath(`/partners/techsales/rfq-ship`);
+ }
+}
+
+/**
+ * 통화 목록 조회
+ */
+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;
+ rfqType?: "SHIP" | "TOP" | "HULL";
+}, vendorId: string) {
+ return unstable_cache(
+ async () => {
+ try {
+
+
+ const { page, perPage, sort, filters = [], search = "", from = "", to = "" } = input;
+ const offset = (page - 1) * perPage;
+ const limit = perPage;
+
+ // 기본 조건: 해당 벤더의 견적서만 조회 (Assigned 상태 제외)
+ const vendorIdNum = parseInt(vendorId);
+ if (isNaN(vendorIdNum)) {
+ console.error('❌ [getVendorQuotations] Invalid vendorId:', vendorId);
+ return { data: [], pageCount: 0, total: 0 };
+ }
+
+ const baseConditions = [
+ eq(techSalesVendorQuotations.vendorId, vendorIdNum),
+ sql`${techSalesVendorQuotations.status} != 'Assigned'` // Assigned 상태 제외
+ ];
+
+ // rfqType 필터링 추가
+ if (input.rfqType) {
+ baseConditions.push(eq(techSalesRfqs.rfqType, input.rfqType));
+ }
+
+ // 검색 조건 추가
+ 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;
+ case 'rfqCode':
+ return item.desc ? desc(techSalesRfqs.rfqCode) : techSalesRfqs.rfqCode;
+ case 'materialCode':
+ return item.desc ? desc(techSalesRfqs.materialCode) : techSalesRfqs.materialCode;
+ case 'dueDate':
+ return item.desc ? desc(techSalesRfqs.dueDate) : techSalesRfqs.dueDate;
+ case 'rfqStatus':
+ return item.desc ? desc(techSalesRfqs.status) : techSalesRfqs.status;
+ default:
+ return item.desc ? desc(techSalesVendorQuotations.updatedAt) : techSalesVendorQuotations.updatedAt;
+ }
+ });
+ }
+
+ // 조인을 포함한 데이터 조회 (중복 제거를 위해 techSalesAttachments JOIN 제거)
+ 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,
+ quotationCode: techSalesVendorQuotations.quotationCode,
+ quotationVersion: techSalesVendorQuotations.quotationVersion,
+ rejectionReason: techSalesVendorQuotations.rejectionReason,
+ acceptedAt: techSalesVendorQuotations.acceptedAt,
+ // RFQ 정보
+ rfqCode: techSalesRfqs.rfqCode,
+ materialCode: techSalesRfqs.materialCode,
+ dueDate: techSalesRfqs.dueDate,
+ rfqStatus: techSalesRfqs.status,
+ description: techSalesRfqs.description,
+ // 프로젝트 정보 (직접 조인)
+ projNm: biddingProjects.projNm,
+ // 아이템 개수
+ itemCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_rfq_items
+ WHERE tech_sales_rfq_items.rfq_id = ${techSalesRfqs.id}
+ )`,
+ // RFQ 첨부파일 개수 (RFQ_COMMON 타입만 카운트)
+ attachmentCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_attachments
+ WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id}
+ AND tech_sales_attachments.attachment_type = 'RFQ_COMMON'
+ )`,
+ })
+ .from(techSalesVendorQuotations)
+ .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
+ .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.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(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.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 };
+ }
+ },
+ [JSON.stringify(input), vendorId], // 캐싱 키
+ {
+ revalidate: 60, // 1분간 캐시
+ tags: [
+ "techSalesVendorQuotations",
+ `vendor-${vendorId}-quotations`
+ ],
+ }
+ )();
+}
+
+/**
+ * 기술영업 벤더 견적 승인 (벤더 선택)
+ */
+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))
+
+ // 4. RFQ 상태를 Closed로 변경
+ await tx
+ .update(techSalesRfqs)
+ .set({
+ status: "Closed",
+ updatedAt: new Date(),
+ })
+ .where(eq(techSalesRfqs.id, quotation.rfqId))
+
+ return quotation
+ })
+
+ // 메일 발송 (백그라운드에서 실행)
+ // 선택된 벤더에게 견적 선택 알림 메일 발송
+ sendQuotationAcceptedNotification(quotationId).catch(error => {
+ console.error("벤더 견적 선택 알림 메일 발송 실패:", error);
+ });
+
+ // 캐시 무효화
+ revalidateTag("techSalesVendorQuotations")
+ revalidateTag(`techSalesRfq-${result.rfqId}`)
+ revalidateTag("techSalesRfqs")
+
+ // 해당 RFQ의 모든 벤더 캐시 무효화 (선택된 벤더와 거절된 벤더들)
+ const allVendorsInRfq = await db.query.techSalesVendorQuotations.findMany({
+ where: eq(techSalesVendorQuotations.rfqId, result.rfqId),
+ columns: { vendorId: true }
+ });
+
+ for (const vendorQuotation of allVendorsInRfq) {
+ revalidateTag(`vendor-${vendorQuotation.vendorId}-quotations`);
+ }
+ revalidatePath("/evcp/budgetary-tech-sales-ship")
+ revalidatePath("/partners/techsales")
+
+
+ return { success: true, data: result }
+ } catch (error) {
+ console.error("벤더 견적 승인 오류:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "벤더 견적 승인에 실패했습니다"
+ }
+ }
+}
+
+/**
+ * 기술영업 RFQ 첨부파일 생성
+ */
+export async function createTechSalesRfqAttachments(params: {
+ techSalesRfqId: number
+ files: File[]
+ createdBy: number
+ attachmentType?: "RFQ_COMMON" | "VENDOR_SPECIFIC"
+ description?: string
+}) {
+ unstable_noStore();
+ try {
+ const { techSalesRfqId, files, createdBy, attachmentType = "RFQ_COMMON", description } = params;
+
+
+
+ if (!files || files.length === 0) {
+ return { data: null, error: "업로드할 파일이 없습니다." };
+ }
+
+ // RFQ 존재 확인
+ const rfq = await db.query.techSalesRfqs.findFirst({
+ where: eq(techSalesRfqs.id, techSalesRfqId),
+ columns: { id: true, status: true }
+ });
+
+ if (!rfq) {
+ return { data: null, error: "RFQ를 찾을 수 없습니다." };
+ }
+
+ // // 편집 가능한 상태 확인
+ // if (!["RFQ Created", "RFQ Vendor Assignned"].includes(rfq.status)) {
+ // return { data: null, error: "현재 상태에서는 첨부파일을 추가할 수 없습니다." };
+ // }
+
+ const results: typeof techSalesAttachments.$inferSelect[] = [];
+
+ // 트랜잭션으로 처리
+ await db.transaction(async (tx) => {
+
+ for (const file of files) {
+
+
+ const saveResult = await saveDRMFile(
+ file,
+ decryptWithServerAction,
+ `techsales-rfq/${techSalesRfqId}`
+ );
+
+ if (!saveResult.success) {
+ throw new Error(saveResult.error || "파일 저장에 실패했습니다.");
+ }
+
+ // DB에 첨부파일 레코드 생성
+ const [newAttachment] = await tx.insert(techSalesAttachments).values({
+ techSalesRfqId,
+ attachmentType,
+ fileName: saveResult.fileName!,
+ originalFileName: file.name,
+ filePath: saveResult.publicPath!,
+ fileSize: file.size,
+ fileType: file.type || undefined,
+ description: description || undefined,
+ createdBy,
+ }).returning();
+
+ results.push(newAttachment);
+ }
+ });
+
+
+
+ // RFQ 타입 조회하여 캐시 무효화
+ const rfqType = await db.query.techSalesRfqs.findFirst({
+ where: eq(techSalesRfqs.id, techSalesRfqId),
+ columns: { rfqType: true }
+ });
+
+ revalidateTag("techSalesRfqs");
+ revalidateTag(`techSalesRfq-${techSalesRfqId}`);
+ revalidatePath(getTechSalesRevalidationPath(rfqType?.rfqType || "SHIP"));
+ revalidatePath("/partners/techsales");
+ return { data: results, error: null };
+ } catch (err) {
+ console.error("기술영업 RFQ 첨부파일 생성 오류:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 RFQ 첨부파일 조회
+ */
+export async function getTechSalesRfqAttachments(techSalesRfqId: number) {
+ unstable_noStore();
+ try {
+ const attachments = await db.query.techSalesAttachments.findMany({
+ where: eq(techSalesAttachments.techSalesRfqId, techSalesRfqId),
+ orderBy: [desc(techSalesAttachments.createdAt)],
+ with: {
+ createdByUser: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ }
+ }
+ }
+ });
+
+ return { data: attachments, error: null };
+ } catch (err) {
+ console.error("기술영업 RFQ 첨부파일 조회 오류:", err);
+ return { data: [], error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * RFQ 첨부파일 타입별 조회
+ */
+export async function getTechSalesRfqAttachmentsByType(
+ techSalesRfqId: number,
+ attachmentType: "RFQ_COMMON" | "VENDOR_SPECIFIC" | "TBE_RESULT" | "CBE_RESULT"
+) {
+ unstable_noStore();
+ try {
+ const attachments = await db.query.techSalesAttachments.findMany({
+ where: and(
+ eq(techSalesAttachments.techSalesRfqId, techSalesRfqId),
+ eq(techSalesAttachments.attachmentType, attachmentType)
+ ),
+ orderBy: [desc(techSalesAttachments.createdAt)],
+ with: {
+ createdByUser: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ }
+ }
+ }
+ });
+
+ return { data: attachments, error: null };
+ } catch (err) {
+ console.error(`기술영업 RFQ ${attachmentType} 첨부파일 조회 오류:`, err);
+ return { data: [], error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 RFQ 첨부파일 삭제
+ */
+export async function deleteTechSalesRfqAttachment(attachmentId: number) {
+ unstable_noStore();
+ try {
+ // 첨부파일 정보 조회
+ const attachment = await db.query.techSalesAttachments.findFirst({
+ where: eq(techSalesAttachments.id, attachmentId),
+ });
+
+ if (!attachment) {
+ return { data: null, error: "첨부파일을 찾을 수 없습니다." };
+ }
+
+ // RFQ 상태 확인
+ const rfq = await db.query.techSalesRfqs.findFirst({
+ where: eq(techSalesRfqs.id, attachment.techSalesRfqId!), // Non-null assertion since we know it exists
+ columns: { id: true, status: true }
+ });
+
+ if (!rfq) {
+ return { data: null, error: "RFQ를 찾을 수 없습니다." };
+ }
+
+ // // 편집 가능한 상태 확인
+ // if (!["RFQ Created", "RFQ Vendor Assignned"].includes(rfq.status)) {
+ // return { data: null, error: "현재 상태에서는 첨부파일을 삭제할 수 없습니다." };
+ // }
+
+ // 트랜잭션으로 처리
+ const result = await db.transaction(async (tx) => {
+ // DB에서 레코드 삭제
+ const deletedAttachment = await tx.delete(techSalesAttachments)
+ .where(eq(techSalesAttachments.id, attachmentId))
+ .returning();
+
+ // 파일 시스템에서 파일 삭제
+ try {
+ deleteFile(attachment.filePath)
+
+ } catch (fileError) {
+ console.warn("파일 삭제 실패:", fileError);
+ // 파일 삭제 실패는 심각한 오류가 아니므로 계속 진행
+ }
+
+ return deletedAttachment[0];
+ });
+
+ // RFQ 타입 조회하여 캐시 무효화
+ const attachmentRfq = await db.query.techSalesRfqs.findFirst({
+ where: eq(techSalesRfqs.id, attachment.techSalesRfqId!),
+ columns: { rfqType: true }
+ });
+
+ revalidateTag("techSalesRfqs");
+ revalidateTag(`techSalesRfq-${attachment.techSalesRfqId}`);
+ revalidatePath(getTechSalesRevalidationPath(attachmentRfq?.rfqType || "SHIP"));
+
+ return { data: result, error: null };
+ } catch (err) {
+ console.error("기술영업 RFQ 첨부파일 삭제 오류:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 RFQ 첨부파일 일괄 처리 (업로드 + 삭제)
+ */
+export async function processTechSalesRfqAttachments(params: {
+ techSalesRfqId: number
+ newFiles: { file: File; attachmentType: "RFQ_COMMON" | "VENDOR_SPECIFIC" | "TBE_RESULT" | "CBE_RESULT"; description?: string }[]
+ deleteAttachmentIds: number[]
+ createdBy: number
+}) {
+ unstable_noStore();
+ try {
+ const { techSalesRfqId, newFiles, deleteAttachmentIds, createdBy } = params;
+
+
+
+ // RFQ 존재 및 상태 확인
+ const rfq = await db.query.techSalesRfqs.findFirst({
+ where: eq(techSalesRfqs.id, techSalesRfqId),
+ columns: { id: true, status: true }
+ });
+
+ if (!rfq) {
+ return { data: null, error: "RFQ를 찾을 수 없습니다." };
+ }
+ // // 편집 가능한 상태 확인
+ // if (!["RFQ Created", "RFQ Vendor Assignned"].includes(rfq.status)) {
+ // return { data: null, error: "현재 상태에서는 첨부파일을 수정할 수 없습니다." };
+ // }
+
+ const results = {
+ uploaded: [] as typeof techSalesAttachments.$inferSelect[],
+ deleted: [] as typeof techSalesAttachments.$inferSelect[],
+ };
+
+ await db.transaction(async (tx) => {
+
+ // 1. 삭제할 첨부파일 처리
+ if (deleteAttachmentIds.length > 0) {
+ const attachmentsToDelete = await tx.query.techSalesAttachments.findMany({
+ where: sql`${techSalesAttachments.id} IN (${deleteAttachmentIds.join(',')})`
+ });
+
+ for (const attachment of attachmentsToDelete) {
+ // DB에서 레코드 삭제
+ const [deletedAttachment] = await tx.delete(techSalesAttachments)
+ .where(eq(techSalesAttachments.id, attachment.id))
+ .returning();
+
+ results.deleted.push(deletedAttachment);
+ await deleteFile(attachment.filePath);
+ }
+ }
+
+ // 2. 새 파일 업로드 처리
+ if (newFiles.length > 0) {
+ for (const { file, attachmentType, description } of newFiles) {
+ const saveResult = await saveDRMFile(
+ file,
+ decryptWithServerAction,
+ `techsales-rfq/${techSalesRfqId}`
+ );
+
+ if (!saveResult.success) {
+ throw new Error(saveResult.error || "파일 저장에 실패했습니다.");
+ }
+
+ // DB에 첨부파일 레코드 생성
+ const [newAttachment] = await tx.insert(techSalesAttachments).values({
+ techSalesRfqId,
+ attachmentType,
+ fileName: saveResult.fileName!,
+ originalFileName: file.name,
+ filePath: saveResult.publicPath!,
+ fileSize: file.size,
+ fileType: file.type || undefined,
+ description: description || undefined,
+ createdBy,
+ }).returning();
+
+ results.uploaded.push(newAttachment);
+ }
+ }
+ });
+
+
+
+ // 캐시 무효화
+ revalidateTag("techSalesRfqs");
+ revalidateTag(`techSalesRfq-${techSalesRfqId}`);
+ revalidatePath("/evcp/budgetary-tech-sales-ship");
+
+ return {
+ data: results,
+ error: null,
+ message: `${results.uploaded.length}개 업로드, ${results.deleted.length}개 삭제 완료`
+ };
+ } catch (err) {
+ console.error("기술영업 RFQ 첨부파일 일괄 처리 오류:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+// ========================================
+// 메일 발송 관련 함수들
+// ========================================
+
+/**
+ * 벤더 견적 제출 확인 메일 발송 (벤더용)
+ */
+export async function sendQuotationSubmittedNotificationToVendor(quotationId: number) {
+ try {
+ // 견적서 정보 조회 (projectSeries 조인 추가)
+ const quotation = await db.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, quotationId),
+ with: {
+ rfq: {
+ with: {
+ biddingProject: true,
+ createdByUser: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ }
+ }
+ }
+ },
+ vendor: {
+ columns: {
+ id: true,
+ vendorName: true,
+ vendorCode: true,
+ }
+ }
+ }
+ });
+
+ if (!quotation || !quotation.rfq || !quotation.vendor) {
+ console.error("견적서 또는 관련 정보를 찾을 수 없습니다");
+ return { success: false, error: "견적서 정보를 찾을 수 없습니다" };
+ }
+
+ // 벤더 사용자들 조회
+ const vendorUsers = await db.query.users.findMany({
+ where: eq(users.companyId, quotation.vendor.id),
+ columns: {
+ id: true,
+ email: true,
+ name: true,
+ language: true
+ }
+ });
+
+ const vendorEmails = vendorUsers
+ .filter(user => user.email)
+ .map(user => user.email)
+ .join(", ");
+
+ if (!vendorEmails) {
+ console.warn(`벤더 ID ${quotation.vendor.id}에 등록된 이메일 주소가 없습니다`);
+ return { success: false, error: "벤더 이메일 주소가 없습니다" };
+ }
+
+ // RFQ 아이템 정보 조회
+ const rfqItemsResult = await getTechSalesRfqItems(quotation.rfq.id);
+ const rfqItems = rfqItemsResult.data || [];
+
+ // 이메일 컨텍스트 구성 (시리즈 정보 제거, 프로젝트 정보 간소화)
+ const emailContext = {
+ language: vendorUsers[0]?.language || "ko",
+ quotation: {
+ id: quotation.id,
+ currency: quotation.currency,
+ totalPrice: quotation.totalPrice,
+ validUntil: quotation.validUntil,
+ submittedAt: quotation.submittedAt,
+ remark: quotation.remark,
+ },
+ rfq: {
+ id: quotation.rfq.id,
+ code: quotation.rfq.rfqCode,
+ title: quotation.rfq.description || '',
+ projectCode: quotation.rfq.biddingProject?.pspid || '',
+ projectName: quotation.rfq.biddingProject?.projNm || '',
+ dueDate: quotation.rfq.dueDate,
+ materialCode: quotation.rfq.materialCode,
+ description: quotation.rfq.remark,
+ },
+ items: rfqItems.map(item => ({
+ itemCode: item.itemCode,
+ itemList: item.itemList,
+ workType: item.workType,
+ shipTypes: item.shipTypes,
+ subItemList: item.subItemList,
+ itemType: item.itemType,
+ })),
+ vendor: {
+ id: quotation.vendor.id,
+ code: quotation.vendor.vendorCode,
+ name: quotation.vendor.vendorName,
+ },
+ project: {
+ name: quotation.rfq.biddingProject?.projNm || '',
+ sector: quotation.rfq.biddingProject?.sector || '',
+ shipCount: quotation.rfq.biddingProject?.projMsrm ? Number(quotation.rfq.biddingProject.projMsrm) : 0,
+ ownerName: quotation.rfq.biddingProject?.kunnrNm || '',
+ className: quotation.rfq.biddingProject?.cls1Nm || '',
+ },
+ manager: {
+ name: quotation.rfq.createdByUser?.name || '',
+ email: quotation.rfq.createdByUser?.email || '',
+ },
+ systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/partners',
+ companyName: 'Samsung Heavy Industries',
+ year: new Date().getFullYear(),
+ };
+
+ // 이메일 발송
+ await sendEmail({
+ to: vendorEmails,
+ subject: `[견적 제출 확인] ${quotation.rfq.rfqCode} - 견적 요청`,
+ template: 'tech-sales-quotation-submitted-vendor-ko',
+ context: emailContext,
+ });
+
+ console.log(`벤더 견적 제출 확인 메일 발송 완료: ${vendorEmails}`);
+ return { success: true };
+ } catch (error) {
+ console.error("벤더 견적 제출 확인 메일 발송 오류:", error);
+ return { success: false, error: "메일 발송 중 오류가 발생했습니다" };
+ }
+}
+
+/**
+ * 벤더 견적 접수 알림 메일 발송 (담당자용)
+ */
+export async function sendQuotationSubmittedNotificationToManager(quotationId: number) {
+ try {
+ // 견적서 정보 조회
+ const quotation = await db.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, quotationId),
+ with: {
+ rfq: {
+ with: {
+ biddingProject: true,
+ createdByUser: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ }
+ }
+ }
+ },
+ vendor: {
+ columns: {
+ id: true,
+ vendorName: true,
+ vendorCode: true,
+ }
+ }
+ }
+ });
+
+ if (!quotation || !quotation.rfq || !quotation.vendor) {
+ console.error("견적서 또는 관련 정보를 찾을 수 없습니다");
+ return { success: false, error: "견적서 정보를 찾을 수 없습니다" };
+ }
+
+ const manager = quotation.rfq.createdByUser;
+ if (!manager?.email) {
+ console.warn("담당자 이메일 주소가 없습니다");
+ return { success: false, error: "담당자 이메일 주소가 없습니다" };
+ }
+
+ // RFQ 아이템 정보 조회
+ const rfqItemsResult = await getTechSalesRfqItems(quotation.rfq.id);
+ const rfqItems = rfqItemsResult.data || [];
+
+ // 이메일 컨텍스트 구성 (시리즈 정보 제거, 프로젝트 정보 간소화)
+ const emailContext = {
+ language: "ko",
+ quotation: {
+ id: quotation.id,
+ currency: quotation.currency,
+ totalPrice: quotation.totalPrice,
+ validUntil: quotation.validUntil,
+ submittedAt: quotation.submittedAt,
+ remark: quotation.remark,
+ },
+ rfq: {
+ id: quotation.rfq.id,
+ code: quotation.rfq.rfqCode,
+ title: quotation.rfq.description || '',
+ projectCode: quotation.rfq.biddingProject?.pspid || '',
+ projectName: quotation.rfq.biddingProject?.projNm || '',
+ dueDate: quotation.rfq.dueDate,
+ materialCode: quotation.rfq.materialCode,
+ description: quotation.rfq.remark,
+ },
+ items: rfqItems.map(item => ({
+ itemCode: item.itemCode,
+ itemList: item.itemList,
+ workType: item.workType,
+ shipTypes: item.shipTypes,
+ subItemList: item.subItemList,
+ itemType: item.itemType,
+ })),
+ vendor: {
+ id: quotation.vendor.id,
+ code: quotation.vendor.vendorCode,
+ name: quotation.vendor.vendorName,
+ },
+ project: {
+ name: quotation.rfq.biddingProject?.projNm || '',
+ sector: quotation.rfq.biddingProject?.sector || '',
+ shipCount: quotation.rfq.biddingProject?.projMsrm ? Number(quotation.rfq.biddingProject.projMsrm) : 0,
+ ownerName: quotation.rfq.biddingProject?.kunnrNm || '',
+ className: quotation.rfq.biddingProject?.cls1Nm || '',
+ },
+ manager: {
+ name: manager.name || '',
+ email: manager.email,
+ },
+ systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/evcp',
+ companyName: 'Samsung Heavy Industries',
+ year: new Date().getFullYear(),
+ };
+
+ // 이메일 발송
+ await sendEmail({
+ to: manager.email,
+ subject: `[견적 접수 알림] ${quotation.vendor.vendorName}에서 ${quotation.rfq.rfqCode} 견적서를 제출했습니다`,
+ template: 'tech-sales-quotation-submitted-manager-ko',
+ context: emailContext,
+ });
+
+ console.log(`담당자 견적 접수 알림 메일 발송 완료: ${manager.email}`);
+ return { success: true };
+ } catch (error) {
+ console.error("담당자 견적 접수 알림 메일 발송 오류:", error);
+ return { success: false, error: "메일 발송 중 오류가 발생했습니다" };
+ }
+}
+
+/**
+ * 벤더 견적 선택 알림 메일 발송
+ */
+export async function sendQuotationAcceptedNotification(quotationId: number) {
+ try {
+ // 견적서 정보 조회
+ const quotation = await db.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, quotationId),
+ with: {
+ rfq: {
+ with: {
+ biddingProject: true,
+ createdByUser: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ }
+ }
+ }
+ },
+ vendor: {
+ columns: {
+ id: true,
+ vendorName: true,
+ vendorCode: true,
+ }
+ }
+ }
+ });
+
+ if (!quotation || !quotation.rfq || !quotation.vendor) {
+ console.error("견적서 또는 관련 정보를 찾을 수 없습니다");
+ return { success: false, error: "견적서 정보를 찾을 수 없습니다" };
+ }
+
+ // 벤더 사용자들 조회
+ const vendorUsers = await db.query.users.findMany({
+ where: eq(users.companyId, quotation.vendor.id),
+ columns: {
+ id: true,
+ email: true,
+ name: true,
+ language: true
+ }
+ });
+
+ const vendorEmails = vendorUsers
+ .filter(user => user.email)
+ .map(user => user.email)
+ .join(", ");
+
+ if (!vendorEmails) {
+ console.warn(`벤더 ID ${quotation.vendor.id}에 등록된 이메일 주소가 없습니다`);
+ return { success: false, error: "벤더 이메일 주소가 없습니다" };
+ }
+
+ // RFQ 아이템 정보 조회
+ const rfqItemsResult = await getTechSalesRfqItems(quotation.rfq.id);
+ const rfqItems = rfqItemsResult.data || [];
+
+ // 이메일 컨텍스트 구성 (시리즈 정보 제거, 프로젝트 정보 간소화)
+ const emailContext = {
+ language: vendorUsers[0]?.language || "ko",
+ quotation: {
+ id: quotation.id,
+ currency: quotation.currency,
+ totalPrice: quotation.totalPrice,
+ validUntil: quotation.validUntil,
+ acceptedAt: quotation.acceptedAt,
+ remark: quotation.remark,
+ },
+ rfq: {
+ id: quotation.rfq.id,
+ code: quotation.rfq.rfqCode,
+ title: quotation.rfq.description || '',
+ projectCode: quotation.rfq.biddingProject?.pspid || '',
+ projectName: quotation.rfq.biddingProject?.projNm || '',
+ dueDate: quotation.rfq.dueDate,
+ materialCode: quotation.rfq.materialCode,
+ description: quotation.rfq.remark,
+ },
+ items: rfqItems.map(item => ({
+ itemCode: item.itemCode,
+ itemList: item.itemList,
+ workType: item.workType,
+ shipTypes: item.shipTypes,
+ subItemList: item.subItemList,
+ itemType: item.itemType,
+ })),
+ vendor: {
+ id: quotation.vendor.id,
+ code: quotation.vendor.vendorCode,
+ name: quotation.vendor.vendorName,
+ },
+ project: {
+ name: quotation.rfq.biddingProject?.projNm || '',
+ sector: quotation.rfq.biddingProject?.sector || '',
+ shipCount: quotation.rfq.biddingProject?.projMsrm ? Number(quotation.rfq.biddingProject.projMsrm) : 0,
+ ownerName: quotation.rfq.biddingProject?.kunnrNm || '',
+ className: quotation.rfq.biddingProject?.cls1Nm || '',
+ },
+ manager: {
+ name: quotation.rfq.createdByUser?.name || '',
+ email: quotation.rfq.createdByUser?.email || '',
+ },
+ systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/partners',
+ companyName: 'Samsung Heavy Industries',
+ year: new Date().getFullYear(),
+ };
+
+ // 이메일 발송
+ await sendEmail({
+ to: vendorEmails,
+ subject: `[견적 선택 알림] ${quotation.rfq.rfqCode} - 귀하의 견적이 선택되었습니다`,
+ template: 'tech-sales-quotation-accepted-ko',
+ context: emailContext,
+ });
+
+ console.log(`벤더 견적 선택 알림 메일 발송 완료: ${vendorEmails}`);
+ return { success: true };
+ } catch (error) {
+ console.error("벤더 견적 선택 알림 메일 발송 오류:", error);
+ return { success: false, error: "메일 발송 중 오류가 발생했습니다" };
+ }
+}
+
+// ==================== Vendor Communication 관련 ====================
+
+export interface TechSalesAttachment {
+ id: number
+ fileName: string
+ fileSize: number
+ fileType: string | null // <- null 허용
+ filePath: string
+ uploadedAt: Date
+}
+
+export interface TechSalesComment {
+ id: number
+ rfqId: number
+ vendorId: number | null // null 허용으로 변경
+ userId?: number | null // null 허용으로 변경
+ content: string
+ isVendorComment: boolean | null // null 허용으로 변경
+ createdAt: Date
+ updatedAt: Date
+ userName?: string | null // null 허용으로 변경
+ vendorName?: string | null // null 허용으로 변경
+ attachments: TechSalesAttachment[]
+ isRead: boolean | null // null 허용으로 변경
+}
+
+/**
+ * 특정 RFQ의 벤더별 읽지 않은 메시지 개수를 조회하는 함수
+ *
+ * @param rfqId RFQ ID
+ * @returns 벤더별 읽지 않은 메시지 개수 (vendorId: count)
+ */
+export async function getTechSalesUnreadMessageCounts(rfqId: number): Promise<Record<number, number>> {
+ try {
+ // 벤더가 보낸 읽지 않은 메시지를 벤더별로 카운트
+ const unreadCounts = await db
+ .select({
+ vendorId: techSalesRfqComments.vendorId,
+ count: sql<number>`count(*)`,
+ })
+ .from(techSalesRfqComments)
+ .where(
+ and(
+ eq(techSalesRfqComments.rfqId, rfqId),
+ eq(techSalesRfqComments.isVendorComment, true), // 벤더가 보낸 메시지
+ eq(techSalesRfqComments.isRead, false), // 읽지 않은 메시지
+ sql`${techSalesRfqComments.vendorId} IS NOT NULL` // vendorId가 null이 아닌 것
+ )
+ )
+ .groupBy(techSalesRfqComments.vendorId);
+
+ // Record<number, number> 형태로 변환
+ const result: Record<number, number> = {};
+ unreadCounts.forEach(item => {
+ if (item.vendorId) {
+ result[item.vendorId] = item.count;
+ }
+ });
+
+ return result;
+ } catch (error) {
+ console.error('techSales 읽지 않은 메시지 개수 조회 오류:', error);
+ return {};
+ }
+}
+
+/**
+ * 특정 RFQ와 벤더 간의 커뮤니케이션 메시지를 가져오는 서버 액션
+ *
+ * @param rfqId RFQ ID
+ * @param vendorId 벤더 ID
+ * @returns 코멘트 목록
+ */
+export async function fetchTechSalesVendorComments(rfqId: number, vendorId?: number): Promise<TechSalesComment[]> {
+ if (!vendorId) {
+ return []
+ }
+
+ try {
+ // 인증 확인
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다")
+ }
+
+ // 코멘트 쿼리
+ const comments = await db.query.techSalesRfqComments.findMany({
+ where: and(
+ eq(techSalesRfqComments.rfqId, rfqId),
+ eq(techSalesRfqComments.vendorId, vendorId)
+ ),
+ orderBy: [techSalesRfqComments.createdAt],
+ with: {
+ user: {
+ columns: {
+ name: true
+ }
+ },
+ vendor: {
+ columns: {
+ vendorName: true
+ }
+ },
+ attachments: true,
+ }
+ })
+
+ // 결과 매핑
+ return comments.map(comment => ({
+ id: comment.id,
+ rfqId: comment.rfqId,
+ vendorId: comment.vendorId,
+ userId: comment.userId || undefined,
+ content: comment.content,
+ isVendorComment: comment.isVendorComment,
+ createdAt: comment.createdAt,
+ updatedAt: comment.updatedAt,
+ userName: comment.user?.name,
+ vendorName: comment.vendor?.vendorName,
+ isRead: comment.isRead,
+ attachments: comment.attachments.map(att => ({
+ id: att.id,
+ fileName: att.fileName,
+ fileSize: att.fileSize,
+ fileType: att.fileType,
+ filePath: att.filePath,
+ originalFileName: att.originalFileName,
+ uploadedAt: att.uploadedAt
+ }))
+ }))
+ } catch (error) {
+ console.error('techSales 벤더 코멘트 가져오기 오류:', error)
+ throw error
+ }
+}
+
+/**
+ * 코멘트를 읽음 상태로 표시하는 서버 액션
+ *
+ * @param rfqId RFQ ID
+ * @param vendorId 벤더 ID
+ */
+export async function markTechSalesMessagesAsRead(rfqId: number, vendorId?: number): Promise<void> {
+ if (!vendorId) {
+ return
+ }
+
+ try {
+ // 인증 확인
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다")
+ }
+
+ // 벤더가 작성한 읽지 않은 코멘트 업데이트
+ await db.update(techSalesRfqComments)
+ .set({ isRead: true })
+ .where(
+ and(
+ eq(techSalesRfqComments.rfqId, rfqId),
+ eq(techSalesRfqComments.vendorId, vendorId),
+ eq(techSalesRfqComments.isVendorComment, true),
+ eq(techSalesRfqComments.isRead, false)
+ )
+ )
+
+ // 캐시 무효화
+ revalidateTag(`tech-sales-rfq-${rfqId}-comments`)
+ } catch (error) {
+ console.error('techSales 메시지 읽음 표시 오류:', error)
+ throw error
+ }
+}
+
+// ==================== RFQ 조선/해양 관련 ====================
+
+/**
+ * 기술영업 조선 RFQ 생성 (1:N 관계)
+ */
+export async function createTechSalesShipRfq(input: {
+ biddingProjectId: number;
+ itemIds: number[]; // 조선 아이템 ID 배열
+ dueDate: Date;
+ description?: string;
+ createdBy: number;
+}) {
+ unstable_noStore();
+ try {
+ return 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}를 찾을 수 없습니다.`);
+ }
+
+ // RFQ 코드 생성 (SHIP 타입)
+ const rfqCode = await generateRfqCodes(tx, 1);
+
+ // RFQ 생성
+ const [rfq] = await tx
+ .insert(techSalesRfqs)
+ .values({
+ rfqCode: rfqCode[0],
+ biddingProjectId: input.biddingProjectId,
+ description: input.description,
+ dueDate: input.dueDate,
+ status: "RFQ Created",
+ rfqType: "SHIP",
+ createdBy: input.createdBy,
+ updatedBy: input.createdBy,
+ })
+ .returning({ id: techSalesRfqs.id });
+
+ // 아이템들 추가
+ for (const itemId of input.itemIds) {
+ await tx
+ .insert(techSalesRfqItems)
+ .values({
+ rfqId: rfq.id,
+ itemShipbuildingId: itemId,
+ itemType: "SHIP",
+ });
+ }
+
+ // 캐시 무효화
+ revalidateTag("techSalesRfqs");
+ revalidatePath("/evcp/budgetary-tech-sales-ship");
+
+ return { data: rfq, error: null };
+ });
+ } catch (err) {
+ console.error("Error creating Ship RFQ:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 해양 Hull RFQ 생성 (1:N 관계)
+ */
+export async function createTechSalesHullRfq(input: {
+ biddingProjectId: number;
+ itemIds: number[]; // Hull 아이템 ID 배열
+ dueDate: Date;
+ description?: string;
+ createdBy: number;
+}) {
+ unstable_noStore();
+ console.log('🔍 createTechSalesHullRfq 호출됨:', input);
+
+ try {
+ return 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}를 찾을 수 없습니다.`);
+ }
+
+ // RFQ 코드 생성 (HULL 타입)
+ const hullRfqCode = await generateRfqCodes(tx, 1);
+
+ // RFQ 생성
+ const [rfq] = await tx
+ .insert(techSalesRfqs)
+ .values({
+ rfqCode: hullRfqCode[0],
+ biddingProjectId: input.biddingProjectId,
+ description: input.description,
+ dueDate: input.dueDate,
+ status: "RFQ Created",
+ rfqType: "HULL",
+ createdBy: input.createdBy,
+ updatedBy: input.createdBy,
+ })
+ .returning({ id: techSalesRfqs.id });
+
+ // 아이템들 추가
+ for (const itemId of input.itemIds) {
+ await tx
+ .insert(techSalesRfqItems)
+ .values({
+ rfqId: rfq.id,
+ itemOffshoreHullId: itemId,
+ itemType: "HULL",
+ });
+ }
+
+ // 캐시 무효화
+ revalidateTag("techSalesRfqs");
+ revalidatePath("/evcp/budgetary-tech-sales-hull");
+
+ return { data: rfq, error: null };
+ });
+ } catch (err) {
+ console.error("Error creating Hull RFQ:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 해양 TOP RFQ 생성 (1:N 관계)
+ */
+export async function createTechSalesTopRfq(input: {
+ biddingProjectId: number;
+ itemIds: number[]; // TOP 아이템 ID 배열
+ dueDate: Date;
+ description?: string;
+ createdBy: number;
+}) {
+ unstable_noStore();
+ console.log('🔍 createTechSalesTopRfq 호출됨:', input);
+
+ try {
+ return 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}를 찾을 수 없습니다.`);
+ }
+
+ // RFQ 코드 생성 (TOP 타입)
+ const topRfqCode = await generateRfqCodes(tx, 1);
+
+ // RFQ 생성
+ const [rfq] = await tx
+ .insert(techSalesRfqs)
+ .values({
+ rfqCode: topRfqCode[0],
+ biddingProjectId: input.biddingProjectId,
+ description: input.description,
+ dueDate: input.dueDate,
+ status: "RFQ Created",
+ rfqType: "TOP",
+ createdBy: input.createdBy,
+ updatedBy: input.createdBy,
+ })
+ .returning({ id: techSalesRfqs.id });
+
+ // 아이템들 추가
+ for (const itemId of input.itemIds) {
+ await tx
+ .insert(techSalesRfqItems)
+ .values({
+ rfqId: rfq.id,
+ itemOffshoreTopId: itemId,
+ itemType: "TOP",
+ });
+ }
+
+ // 캐시 무효화
+ revalidateTag("techSalesRfqs");
+ revalidatePath("/evcp/budgetary-tech-sales-top");
+
+ return { data: rfq, error: null };
+ });
+ } catch (err) {
+ console.error("Error creating TOP RFQ:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 조선 RFQ 전용 조회 함수
+ */
+export async function getTechSalesShipRfqsWithJoin(input: GetTechSalesRfqsSchema) {
+ return getTechSalesRfqsWithJoin({ ...input, rfqType: "SHIP" });
+}
+
+/**
+ * 해양 TOP RFQ 전용 조회 함수
+ */
+export async function getTechSalesTopRfqsWithJoin(input: GetTechSalesRfqsSchema) {
+ return getTechSalesRfqsWithJoin({ ...input, rfqType: "TOP" });
+}
+
+/**
+ * 해양 HULL RFQ 전용 조회 함수
+ */
+export async function getTechSalesHullRfqsWithJoin(input: GetTechSalesRfqsSchema) {
+ return getTechSalesRfqsWithJoin({ ...input, rfqType: "HULL" });
+}
+
+/**
+ * 조선 벤더 견적서 전용 조회 함수
+ */
+export async function getTechSalesShipVendorQuotationsWithJoin(input: {
+ rfqId?: number;
+ vendorId?: number;
+ search?: string;
+ filters?: Filter<typeof techSalesVendorQuotations>[];
+ sort?: { id: string; desc: boolean }[];
+ page: number;
+ perPage: number;
+}) {
+ return getTechSalesVendorQuotationsWithJoin({ ...input, rfqType: "SHIP" });
+}
+
+/**
+ * 해양 TOP 벤더 견적서 전용 조회 함수
+ */
+export async function getTechSalesTopVendorQuotationsWithJoin(input: {
+ rfqId?: number;
+ vendorId?: number;
+ search?: string;
+ filters?: Filter<typeof techSalesVendorQuotations>[];
+ sort?: { id: string; desc: boolean }[];
+ page: number;
+ perPage: number;
+}) {
+ return getTechSalesVendorQuotationsWithJoin({ ...input, rfqType: "TOP" });
+}
+
+/**
+ * 해양 HULL 벤더 견적서 전용 조회 함수
+ */
+export async function getTechSalesHullVendorQuotationsWithJoin(input: {
+ rfqId?: number;
+ vendorId?: number;
+ search?: string;
+ filters?: Filter<typeof techSalesVendorQuotations>[];
+ sort?: { id: string; desc: boolean }[];
+ page: number;
+ perPage: number;
+}) {
+ return getTechSalesVendorQuotationsWithJoin({ ...input, rfqType: "HULL" });
+}
+
+/**
+ * 기술영업 RFQ의 아이템 목록 조회
+ */
+export async function getTechSalesRfqItems(rfqId: number) {
+ unstable_noStore();
+ try {
+ const items = await db.query.techSalesRfqItems.findMany({
+ where: eq(techSalesRfqItems.rfqId, rfqId),
+ with: {
+ itemShipbuilding: {
+ columns: {
+ id: true,
+ itemCode: true,
+ itemList: true,
+ workType: true,
+ shipTypes: true,
+ }
+ },
+ itemOffshoreTop: {
+ columns: {
+ id: true,
+ itemCode: true,
+ itemList: true,
+ workType: true,
+ subItemList: true,
+ }
+ },
+ itemOffshoreHull: {
+ columns: {
+ id: true,
+ itemCode: true,
+ itemList: true,
+ workType: true,
+ subItemList: true,
+ }
+ }
+ },
+ orderBy: [techSalesRfqItems.id]
+ });
+
+ // 아이템 타입에 따라 정보 매핑
+ const mappedItems = items.map(item => {
+ let itemInfo = null;
+
+ switch (item.itemType) {
+ case 'SHIP':
+ itemInfo = item.itemShipbuilding;
+ break;
+ case 'TOP':
+ itemInfo = item.itemOffshoreTop;
+ break;
+ case 'HULL':
+ itemInfo = item.itemOffshoreHull;
+ break;
+ }
+
+ return {
+ id: item.id,
+ rfqId: item.rfqId,
+ itemType: item.itemType,
+ itemCode: itemInfo?.itemCode || '',
+ itemList: itemInfo?.itemList || '',
+ workType: itemInfo?.workType || '',
+ // 조선이면 shipType, 해양이면 subItemList
+ shipTypes: item.itemType === 'SHIP' ? (itemInfo as { shipTypes?: string })?.shipTypes || '' : undefined,
+ subItemList: item.itemType !== 'SHIP' ? (itemInfo as { subItemList?: string })?.subItemList || '' : undefined,
+ };
+ });
+
+ return { data: mappedItems, error: null };
+ } catch (err) {
+ console.error("Error fetching RFQ items:", err);
+ return { data: [], error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * RFQ 아이템들과 매칭되는 후보 벤더들을 찾는 함수
+ */
+export async function getTechSalesRfqCandidateVendors(rfqId: number) {
+ unstable_noStore();
+
+ try {
+ return await db.transaction(async (tx) => {
+ // 1. RFQ 정보 조회 (타입 확인)
+ const rfq = await tx.query.techSalesRfqs.findFirst({
+ where: eq(techSalesRfqs.id, rfqId),
+ columns: {
+ id: true,
+ rfqType: true
+ }
+ });
+
+ if (!rfq) {
+ return { data: [], error: "RFQ를 찾을 수 없습니다." };
+ }
+
+ // 2. RFQ 아이템들 조회
+ const rfqItems = await tx.query.techSalesRfqItems.findMany({
+ where: eq(techSalesRfqItems.rfqId, rfqId),
+ with: {
+ itemShipbuilding: true,
+ itemOffshoreTop: true,
+ itemOffshoreHull: true,
+ }
+ });
+
+ if (rfqItems.length === 0) {
+ return { data: [], error: null };
+ }
+
+ // 3. 아이템 코드들 추출
+ const itemCodes: string[] = [];
+ rfqItems.forEach(item => {
+ if (item.itemType === "SHIP" && item.itemShipbuilding?.itemCode) {
+ itemCodes.push(item.itemShipbuilding.itemCode);
+ } else if (item.itemType === "TOP" && item.itemOffshoreTop?.itemCode) {
+ itemCodes.push(item.itemOffshoreTop.itemCode);
+ } else if (item.itemType === "HULL" && item.itemOffshoreHull?.itemCode) {
+ itemCodes.push(item.itemOffshoreHull.itemCode);
+ }
+ });
+
+ if (itemCodes.length === 0) {
+ return { data: [], error: null };
+ }
+
+ // 4. RFQ 타입에 따른 벤더 타입 매핑
+ const vendorTypeFilter = rfq.rfqType === "SHIP" ? "SHIP" :
+ rfq.rfqType === "TOP" ? "OFFSHORE_TOP" :
+ rfq.rfqType === "HULL" ? "OFFSHORE_HULL" : null;
+
+ if (!vendorTypeFilter) {
+ return { data: [], error: "지원되지 않는 RFQ 타입입니다." };
+ }
+
+ // 5. 매칭되는 벤더들 조회 (타입 필터링 포함)
+ const candidateVendors = await tx
+ .select({
+ id: techVendors.id, // 벤더 ID를 id로 명명하여 key 문제 해결
+ vendorId: techVendors.id, // 호환성을 위해 유지
+ vendorName: techVendors.vendorName,
+ vendorCode: techVendors.vendorCode,
+ country: techVendors.country,
+ email: techVendors.email,
+ phone: techVendors.phone,
+ status: techVendors.status,
+ techVendorType: techVendors.techVendorType,
+ matchedItemCodes: sql<string[]>`
+ array_agg(DISTINCT ${techVendorPossibleItems.itemCode})
+ `,
+ matchedItemCount: sql<number>`
+ count(DISTINCT ${techVendorPossibleItems.itemCode})
+ `,
+ })
+ .from(techVendorPossibleItems)
+ .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id))
+ .where(
+ and(
+ inArray(techVendorPossibleItems.itemCode, itemCodes),
+ or(
+ eq(techVendors.status, "ACTIVE"),
+ eq(techVendors.status, "QUOTE_COMPARISON") // 견적비교용 벤더도 RFQ 초대 가능
+ )
+ // 벤더 타입 필터링 임시 제거 - 데이터 확인 후 다시 추가
+ // eq(techVendors.techVendorType, vendorTypeFilter)
+ )
+ )
+ .groupBy(
+ techVendorPossibleItems.vendorId,
+ techVendors.id,
+ techVendors.vendorName,
+ techVendors.vendorCode,
+ techVendors.country,
+ techVendors.email,
+ techVendors.phone,
+ techVendors.status,
+ techVendors.techVendorType
+ )
+ .orderBy(desc(sql`count(DISTINCT ${techVendorPossibleItems.itemCode})`));
+
+ return { data: candidateVendors, error: null };
+ });
+ } catch (err) {
+ console.error("Error fetching candidate vendors:", err);
+ return { data: [], error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * RFQ 타입에 따른 캐시 무효화 경로 반환
+ */
+function getTechSalesRevalidationPath(rfqType: "SHIP" | "TOP" | "HULL"): string {
+ switch (rfqType) {
+ case "SHIP":
+ return "/evcp/budgetary-tech-sales-ship";
+ case "TOP":
+ return "/evcp/budgetary-tech-sales-top";
+ case "HULL":
+ return "/evcp/budgetary-tech-sales-hull";
+ default:
+ return "/evcp/budgetary-tech-sales-ship";
+ }
+}
+
+/**
+ * 기술영업 RFQ에 여러 벤더 추가 (techVendors 기반)
+ * 벤더 추가 시에는 견적서를 생성하지 않고, RFQ 전송 시에 견적서를 생성
+ */
+export async function addTechVendorsToTechSalesRfq(input: {
+ rfqId: number;
+ vendorIds: number[];
+ createdBy: number;
+}) {
+ unstable_noStore();
+
+ try {
+ return await db.transaction(async (tx) => {
+ const results = [];
+ const errors: string[] = [];
+
+ // 1. RFQ 상태 및 타입 확인
+ const rfq = await tx.query.techSalesRfqs.findFirst({
+ where: eq(techSalesRfqs.id, input.rfqId),
+ columns: {
+ id: true,
+ status: true,
+ rfqType: true,
+ }
+ });
+
+ if (!rfq) {
+ throw new Error("RFQ를 찾을 수 없습니다");
+ }
+
+ // 2. 각 벤더에 대해 처리 (이미 추가된 벤더는 견적서가 있는지 확인)
+ for (const vendorId of input.vendorIds) {
+ try {
+ // 이미 추가된 벤더인지 확인 (견적서 존재 여부로 확인)
+ const existingQuotation = await tx.query.techSalesVendorQuotations.findFirst({
+ where: and(
+ eq(techSalesVendorQuotations.rfqId, input.rfqId),
+ eq(techSalesVendorQuotations.vendorId, vendorId)
+ )
+ });
+
+ if (existingQuotation) {
+ errors.push(`벤더 ID ${vendorId}는 이미 추가되어 있습니다.`);
+ continue;
+ }
+
+ // 벤더가 실제로 존재하는지 확인
+ const vendor = await tx.query.techVendors.findFirst({
+ where: eq(techVendors.id, vendorId),
+ columns: { id: true, vendorName: true }
+ });
+
+ if (!vendor) {
+ errors.push(`벤더 ID ${vendorId}를 찾을 수 없습니다.`);
+ continue;
+ }
+
+ // 🔥 중요: 벤더 추가 시에는 견적서를 생성하지 않고, "Assigned" 상태로만 생성
+ // quotation_version은 null로 설정하여 벤더가 실제 견적 제출 시에만 리비전 생성
+ const [quotation] = await tx
+ .insert(techSalesVendorQuotations)
+ .values({
+ rfqId: input.rfqId,
+ vendorId: vendorId,
+ status: "Assigned", // Draft가 아닌 Assigned 상태로 생성
+ quotationVersion: null, // 리비전은 견적 제출 시에만 생성
+ createdBy: input.createdBy,
+ updatedBy: input.createdBy,
+ })
+ .returning({ id: techSalesVendorQuotations.id });
+
+ // 🆕 RFQ의 아이템 코드들을 tech_vendor_possible_items에 추가
+ try {
+ // RFQ의 아이템들 조회
+ const rfqItemsResult = await getTechSalesRfqItems(input.rfqId);
+
+ if (rfqItemsResult.data && rfqItemsResult.data.length > 0) {
+ for (const item of rfqItemsResult.data) {
+ const {
+ itemCode,
+ itemList,
+ workType, // 공종
+ shipTypes, // 선종 (배열일 수 있음)
+ subItemList // 서브아이템리스트 (있을 수도 있음)
+ } = item;
+
+ // 동적 where 조건 생성: 값이 있으면 비교, 없으면 비교하지 않음
+ const whereConds = [
+ eq(techVendorPossibleItems.vendorId, vendorId),
+ itemCode ? eq(techVendorPossibleItems.itemCode, itemCode) : undefined,
+ itemList ? eq(techVendorPossibleItems.itemList, itemList) : undefined,
+ workType ? eq(techVendorPossibleItems.workType, workType) : undefined,
+ shipTypes ? eq(techVendorPossibleItems.shipTypes, shipTypes) : undefined,
+ subItemList ? eq(techVendorPossibleItems.subItemList, subItemList) : undefined,
+ ].filter(Boolean);
+
+ const existing = await tx.query.techVendorPossibleItems.findFirst({
+ where: and(...whereConds)
+ });
+
+ if (!existing) {
+ await tx.insert(techVendorPossibleItems).values({
+ vendorId : vendorId,
+ itemCode: itemCode ?? null,
+ itemList: itemList ?? null,
+ workType: workType ?? null,
+ shipTypes: shipTypes ?? null,
+ subItemList: subItemList ?? null,
+ });
+ }
+ }
+ }
+ } catch (possibleItemError) {
+ // tech_vendor_possible_items 추가 실패는 전체 실패로 처리하지 않음
+ console.warn(`벤더 ${vendorId}의 가능 아이템 추가 실패:`, possibleItemError);
+ }
+
+ results.push({ id: quotation.id, vendorId, vendorName: vendor.vendorName });
+ } 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));
+ }
+
+ // 캐시 무효화 (RFQ 타입에 따른 동적 경로)
+ revalidateTag("techSalesRfqs");
+ revalidateTag("techSalesVendorQuotations");
+ revalidateTag(`techSalesRfq-${input.rfqId}`);
+ revalidatePath(getTechSalesRevalidationPath(rfq.rfqType || "SHIP"));
+
+ return {
+ data: results,
+ error: errors.length > 0 ? errors.join(", ") : null,
+ successCount: results.length,
+ errorCount: errors.length
+ };
+ });
+ } catch (err) {
+ console.error("Error adding tech vendors to RFQ:", err);
+ return { data: [], error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 RFQ의 벤더 목록 조회 (techVendors 기반)
+ */
+export async function getTechSalesRfqTechVendors(rfqId: number) {
+ unstable_noStore();
+
+ try {
+ return await db.transaction(async (tx) => {
+ const vendors = await tx
+ .select({
+ id: techSalesVendorQuotations.id,
+ vendorId: techVendors.id,
+ vendorName: techVendors.vendorName,
+ vendorCode: techVendors.vendorCode,
+ country: techVendors.country,
+ email: techVendors.email,
+ phone: techVendors.phone,
+ status: techSalesVendorQuotations.status,
+ totalPrice: techSalesVendorQuotations.totalPrice,
+ currency: techSalesVendorQuotations.currency,
+ validUntil: techSalesVendorQuotations.validUntil,
+ submittedAt: techSalesVendorQuotations.submittedAt,
+ createdAt: techSalesVendorQuotations.createdAt,
+ })
+ .from(techSalesVendorQuotations)
+ .innerJoin(techVendors, eq(techSalesVendorQuotations.vendorId, techVendors.id))
+ .where(eq(techSalesVendorQuotations.rfqId, rfqId))
+ .orderBy(desc(techSalesVendorQuotations.createdAt));
+
+ return { data: vendors, error: null };
+ });
+ } catch (err) {
+ console.error("Error fetching RFQ tech vendors:", err);
+ return { data: [], error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 RFQ에서 기술영업 벤더 제거 (techVendors 기반)
+ */
+export async function removeTechVendorFromTechSalesRfq(input: {
+ rfqId: number;
+ vendorId: number;
+}) {
+ unstable_noStore();
+
+ try {
+ return await db.transaction(async (tx) => {
+ // 해당 벤더의 견적서 상태 확인
+ const existingQuotation = await tx.query.techSalesVendorQuotations.findFirst({
+ where: and(
+ eq(techSalesVendorQuotations.rfqId, input.rfqId),
+ eq(techSalesVendorQuotations.vendorId, input.vendorId)
+ )
+ });
+
+ if (!existingQuotation) {
+ return { data: null, error: "해당 벤더가 이 RFQ에 존재하지 않습니다." };
+ }
+
+ // Assigned 상태가 아닌 경우 삭제 불가
+ if (existingQuotation.status !== "Assigned") {
+ return { data: null, error: "Assigned 상태의 벤더만 삭제할 수 있습니다." };
+ }
+
+ // 해당 벤더의 견적서 삭제
+ const [deletedQuotation] = await tx
+ .delete(techSalesVendorQuotations)
+ .where(
+ and(
+ eq(techSalesVendorQuotations.rfqId, input.rfqId),
+ eq(techSalesVendorQuotations.vendorId, input.vendorId)
+ )
+ )
+ .returning({ id: techSalesVendorQuotations.id });
+
+ // 캐시 무효화
+ revalidateTag("techSalesRfqs");
+ revalidateTag("techSalesVendorQuotations");
+
+ return { data: deletedQuotation, error: null };
+ });
+ } catch (err) {
+ console.error("Error removing tech vendor from RFQ:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 RFQ에서 여러 기술영업 벤더 제거 (techVendors 기반)
+ */
+export async function removeTechVendorsFromTechSalesRfq(input: {
+ rfqId: number;
+ vendorIds: number[];
+}) {
+ unstable_noStore();
+
+ try {
+ return await db.transaction(async (tx) => {
+ const results = [];
+ const errors: string[] = [];
+
+ for (const vendorId of input.vendorIds) {
+ // 해당 벤더의 견적서 상태 확인
+ const existingQuotation = await tx.query.techSalesVendorQuotations.findFirst({
+ where: and(
+ eq(techSalesVendorQuotations.rfqId, input.rfqId),
+ eq(techSalesVendorQuotations.vendorId, vendorId)
+ )
+ });
+
+ if (!existingQuotation) {
+ errors.push(`벤더 ID ${vendorId}가 이 RFQ에 존재하지 않습니다.`);
+ continue;
+ }
+
+ // Assigned 상태가 아닌 경우 삭제 불가
+ if (existingQuotation.status !== "Assigned") {
+ errors.push(`벤더 ID ${vendorId}는 Assigned 상태가 아니므로 삭제할 수 없습니다.`);
+ continue;
+ }
+
+ // 해당 벤더의 견적서 삭제
+ const [deletedQuotation] = await tx
+ .delete(techSalesVendorQuotations)
+ .where(
+ and(
+ eq(techSalesVendorQuotations.rfqId, input.rfqId),
+ eq(techSalesVendorQuotations.vendorId, vendorId)
+ )
+ )
+ .returning({ id: techSalesVendorQuotations.id });
+
+ results.push(deletedQuotation);
+ }
+
+ // 캐시 무효화
+ revalidateTag("techSalesRfqs");
+ revalidateTag("techSalesVendorQuotations");
+
+ return {
+ data: results,
+ error: errors.length > 0 ? errors.join(", ") : null,
+ successCount: results.length,
+ errorCount: errors.length
+ };
+ });
+ } catch (err) {
+ console.error("Error removing tech vendors from RFQ:", err);
+ return { data: [], error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 벤더 검색
+ */
+export async function searchTechVendors(searchTerm: string, limit = 100, rfqType?: "SHIP" | "TOP" | "HULL") {
+ unstable_noStore();
+
+ try {
+ // RFQ 타입에 따른 벤더 타입 매핑
+ const vendorTypeFilter = rfqType === "SHIP" ? "조선" :
+ rfqType === "TOP" ? "해양TOP" :
+ rfqType === "HULL" ? "해양HULL" : null;
+
+ const whereConditions = [
+ or(
+ eq(techVendors.status, "ACTIVE"),
+ eq(techVendors.status, "QUOTE_COMPARISON")
+ ),
+ or(
+ ilike(techVendors.vendorName, `%${searchTerm}%`),
+ ilike(techVendors.vendorCode, `%${searchTerm}%`)
+ )
+ ];
+
+ // RFQ 타입이 지정된 경우 벤더 타입 필터링 추가 (컴마 구분 문자열에서 검색)
+ if (vendorTypeFilter) {
+ whereConditions.push(sql`${techVendors.techVendorType} LIKE ${'%' + vendorTypeFilter + '%'}`);
+ }
+
+ const results = await db
+ .select({
+ id: techVendors.id,
+ vendorName: techVendors.vendorName,
+ vendorCode: techVendors.vendorCode,
+ status: techVendors.status,
+ country: techVendors.country,
+ techVendorType: techVendors.techVendorType,
+ })
+ .from(techVendors)
+ .where(and(...whereConditions))
+ .limit(limit)
+ .orderBy(techVendors.vendorName);
+
+ return results;
+ } catch (err) {
+ console.error("Error searching tech vendors:", err);
+ throw new Error(getErrorMessage(err));
+ }
+}
+
+
+/**
+ * 벤더 견적서 거절 처리 (벤더가 직접 거절)
+ */
+export async function rejectTechSalesVendorQuotations(input: {
+ quotationIds: number[];
+ rejectionReason?: string;
+}) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ throw new Error("인증이 필요합니다.");
+ }
+
+ const result = await db.transaction(async (tx) => {
+ // 견적서들이 존재하고 벤더가 권한이 있는지 확인
+ const quotations = await tx
+ .select({
+ id: techSalesVendorQuotations.id,
+ status: techSalesVendorQuotations.status,
+ vendorId: techSalesVendorQuotations.vendorId,
+ })
+ .from(techSalesVendorQuotations)
+ .where(inArray(techSalesVendorQuotations.id, input.quotationIds));
+
+ if (quotations.length !== input.quotationIds.length) {
+ throw new Error("일부 견적서를 찾을 수 없습니다.");
+ }
+
+ // 이미 거절된 견적서가 있는지 확인
+ const alreadyRejected = quotations.filter(q => q.status === "Rejected");
+ if (alreadyRejected.length > 0) {
+ throw new Error("이미 거절된 견적서가 포함되어 있습니다.");
+ }
+
+ // 승인된 견적서가 있는지 확인
+ const alreadyAccepted = quotations.filter(q => q.status === "Accepted");
+ if (alreadyAccepted.length > 0) {
+ throw new Error("이미 승인된 견적서는 거절할 수 없습니다.");
+ }
+
+ // 견적서 상태를 거절로 변경
+ await tx
+ .update(techSalesVendorQuotations)
+ .set({
+ status: "Rejected",
+ rejectionReason: input.rejectionReason || null,
+ updatedBy: parseInt(session.user.id),
+ updatedAt: new Date(),
+ })
+ .where(inArray(techSalesVendorQuotations.id, input.quotationIds));
+
+ return { success: true, updatedCount: quotations.length };
+ });
+ revalidateTag("techSalesRfqs");
+ revalidateTag("techSalesVendorQuotations");
+ revalidatePath("/partners/techsales/rfq-ship", "page");
+ return {
+ success: true,
+ message: `${result.updatedCount}개의 견적서가 거절되었습니다.`,
+ data: result
+ };
+ } catch (error) {
+ console.error("견적서 거절 오류:", error);
+ return {
+ success: false,
+ error: getErrorMessage(error)
+ };
+ }
+}
+
+// ==================== Revision 관련 ====================
+
+/**
+ * 견적서 revision 히스토리 조회
+ */
+export async function getTechSalesVendorQuotationRevisions(quotationId: number) {
+ try {
+ const revisions = await db
+ .select({
+ id: techSalesVendorQuotationRevisions.id,
+ version: techSalesVendorQuotationRevisions.version,
+ snapshot: techSalesVendorQuotationRevisions.snapshot,
+ changeReason: techSalesVendorQuotationRevisions.changeReason,
+ revisionNote: techSalesVendorQuotationRevisions.revisionNote,
+ revisedBy: techSalesVendorQuotationRevisions.revisedBy,
+ revisedAt: techSalesVendorQuotationRevisions.revisedAt,
+ // 수정자 정보 조인
+ revisedByName: users.name,
+ })
+ .from(techSalesVendorQuotationRevisions)
+ .leftJoin(users, eq(techSalesVendorQuotationRevisions.revisedBy, users.id))
+ .where(eq(techSalesVendorQuotationRevisions.quotationId, quotationId))
+ .orderBy(desc(techSalesVendorQuotationRevisions.version));
+
+ return { data: revisions, error: null };
+ } catch (error) {
+ console.error("견적서 revision 히스토리 조회 오류:", error);
+ return { data: null, error: "견적서 히스토리를 조회하는 중 오류가 발생했습니다." };
+ }
+}
+
+/**
+ * 견적서의 현재 버전과 revision 히스토리를 함께 조회 (각 리비전의 첨부파일 포함)
+ */
+export async function getTechSalesVendorQuotationWithRevisions(quotationId: number) {
+ try {
+ // 먼저 현재 견적서 조회
+ const currentQuotation = await db.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, quotationId),
+ with: {
+ // 벤더 정보와 RFQ 정보도 함께 조회 (필요한 경우)
+ }
+ });
+
+ if (!currentQuotation) {
+ return { data: null, error: "견적서를 찾을 수 없습니다." };
+ }
+
+ // 이제 현재 견적서의 정보를 알고 있으므로 병렬로 나머지 정보 조회
+ const [revisionsResult, currentAttachments] = await Promise.all([
+ getTechSalesVendorQuotationRevisions(quotationId),
+ getTechSalesVendorQuotationAttachmentsByRevision(quotationId, currentQuotation.quotationVersion || 0)
+ ]);
+
+ // 현재 견적서에 첨부파일 정보 추가
+ const currentWithAttachments = {
+ ...currentQuotation,
+ attachments: currentAttachments.data || []
+ };
+
+ // 각 리비전의 첨부파일 정보 추가
+ const revisionsWithAttachments = await Promise.all(
+ (revisionsResult.data || []).map(async (revision) => {
+ const attachmentsResult = await getTechSalesVendorQuotationAttachmentsByRevision(quotationId, revision.version);
+ return {
+ ...revision,
+ attachments: attachmentsResult.data || []
+ };
+ })
+ );
+
+ return {
+ data: {
+ current: currentWithAttachments,
+ revisions: revisionsWithAttachments
+ },
+ error: null
+ };
+ } catch (error) {
+ console.error("견적서 전체 히스토리 조회 오류:", error);
+ return { data: null, error: "견적서 정보를 조회하는 중 오류가 발생했습니다." };
+ }
+}
+
+/**
+ * 견적서 첨부파일 조회 (리비전 ID 기준 오름차순 정렬)
+ */
+export async function getTechSalesVendorQuotationAttachments(quotationId: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ const attachments = await db
+ .select({
+ id: techSalesVendorQuotationAttachments.id,
+ quotationId: techSalesVendorQuotationAttachments.quotationId,
+ revisionId: techSalesVendorQuotationAttachments.revisionId,
+ fileName: techSalesVendorQuotationAttachments.fileName,
+ originalFileName: techSalesVendorQuotationAttachments.originalFileName,
+ fileSize: techSalesVendorQuotationAttachments.fileSize,
+ fileType: techSalesVendorQuotationAttachments.fileType,
+ filePath: techSalesVendorQuotationAttachments.filePath,
+ description: techSalesVendorQuotationAttachments.description,
+ uploadedBy: techSalesVendorQuotationAttachments.uploadedBy,
+ vendorId: techSalesVendorQuotationAttachments.vendorId,
+ isVendorUpload: techSalesVendorQuotationAttachments.isVendorUpload,
+ createdAt: techSalesVendorQuotationAttachments.createdAt,
+ updatedAt: techSalesVendorQuotationAttachments.updatedAt,
+ })
+ .from(techSalesVendorQuotationAttachments)
+ .where(eq(techSalesVendorQuotationAttachments.quotationId, quotationId))
+ .orderBy(desc(techSalesVendorQuotationAttachments.createdAt));
+
+ return { data: attachments };
+ } catch (error) {
+ console.error("견적서 첨부파일 조회 오류:", error);
+ return { error: "견적서 첨부파일 조회 중 오류가 발생했습니다." };
+ }
+ },
+ [`quotation-attachments-${quotationId}`],
+ {
+ revalidate: 60,
+ tags: [`quotation-${quotationId}`, "quotation-attachments"],
+ }
+ )();
+}
+
+/**
+ * 특정 리비전의 견적서 첨부파일 조회
+ */
+export async function getTechSalesVendorQuotationAttachmentsByRevision(quotationId: number, revisionId: number) {
+ try {
+ const attachments = await db
+ .select({
+ id: techSalesVendorQuotationAttachments.id,
+ quotationId: techSalesVendorQuotationAttachments.quotationId,
+ revisionId: techSalesVendorQuotationAttachments.revisionId,
+ fileName: techSalesVendorQuotationAttachments.fileName,
+ originalFileName: techSalesVendorQuotationAttachments.originalFileName,
+ fileSize: techSalesVendorQuotationAttachments.fileSize,
+ fileType: techSalesVendorQuotationAttachments.fileType,
+ filePath: techSalesVendorQuotationAttachments.filePath,
+ description: techSalesVendorQuotationAttachments.description,
+ uploadedBy: techSalesVendorQuotationAttachments.uploadedBy,
+ vendorId: techSalesVendorQuotationAttachments.vendorId,
+ isVendorUpload: techSalesVendorQuotationAttachments.isVendorUpload,
+ createdAt: techSalesVendorQuotationAttachments.createdAt,
+ updatedAt: techSalesVendorQuotationAttachments.updatedAt,
+ })
+ .from(techSalesVendorQuotationAttachments)
+ .where(and(
+ eq(techSalesVendorQuotationAttachments.quotationId, quotationId),
+ eq(techSalesVendorQuotationAttachments.revisionId, revisionId)
+ ))
+ .orderBy(desc(techSalesVendorQuotationAttachments.createdAt));
+
+ return { data: attachments };
+ } catch (error) {
+ console.error("리비전별 견적서 첨부파일 조회 오류:", error);
+ return { error: "첨부파일 조회 중 오류가 발생했습니다." };
+ }
+}
+
+
+// ==================== Project AVL 관련 ====================
+
+/**
+ * Accepted 상태의 Tech Sales Vendor Quotations 조회 (RFQ, Vendor 정보 포함)
+ */
+export async function getAcceptedTechSalesVendorQuotations(input: {
+ search?: string;
+ filters?: Filter<typeof techSalesVendorQuotations>[];
+ sort?: { id: string; desc: boolean }[];
+ page: number;
+ perPage: number;
+ rfqType?: "SHIP" | "TOP" | "HULL";
+}) {
+ unstable_noStore();
+
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // 기본 WHERE 조건: status = 'Accepted'만 조회, rfqType이 'SHIP'이 아닌 것만
+ const baseConditions = [
+ eq(techSalesVendorQuotations.status, 'Accepted'),
+ sql`${techSalesRfqs.rfqType} != 'SHIP'` // 조선 RFQ 타입 제외
+ ];
+
+ // 검색 조건 추가
+ const searchConditions = [];
+ if (input.search) {
+ searchConditions.push(
+ ilike(techSalesRfqs.rfqCode, `%${input.search}%`),
+ ilike(techSalesRfqs.description, `%${input.search}%`),
+ ilike(sql`vendors.vendor_name`, `%${input.search}%`),
+ ilike(sql`vendors.vendor_code`, `%${input.search}%`)
+ );
+ }
+
+ // 정렬 조건 변환
+ const orderByConditions: OrderByType[] = [];
+ if (input.sort?.length) {
+ input.sort.forEach((sortItem) => {
+ switch (sortItem.id) {
+ case "rfqCode":
+ orderByConditions.push(sortItem.desc ? desc(techSalesRfqs.rfqCode) : asc(techSalesRfqs.rfqCode));
+ break;
+ case "description":
+ orderByConditions.push(sortItem.desc ? desc(techSalesRfqs.description) : asc(techSalesRfqs.description));
+ break;
+ case "vendorName":
+ orderByConditions.push(sortItem.desc ? desc(sql`vendors.vendor_name`) : asc(sql`vendors.vendor_name`));
+ break;
+ case "vendorCode":
+ orderByConditions.push(sortItem.desc ? desc(sql`vendors.vendor_code`) : asc(sql`vendors.vendor_code`));
+ break;
+ case "totalPrice":
+ orderByConditions.push(sortItem.desc ? desc(techSalesVendorQuotations.totalPrice) : asc(techSalesVendorQuotations.totalPrice));
+ break;
+ case "acceptedAt":
+ orderByConditions.push(sortItem.desc ? desc(techSalesVendorQuotations.acceptedAt) : asc(techSalesVendorQuotations.acceptedAt));
+ break;
+ default:
+ orderByConditions.push(desc(techSalesVendorQuotations.acceptedAt));
+ }
+ });
+ } else {
+ orderByConditions.push(desc(techSalesVendorQuotations.acceptedAt));
+ }
+
+ // 필터 조건 추가
+ const filterConditions = [];
+ if (input.filters?.length) {
+ const filterWhere = filterColumns({
+ table: techSalesVendorQuotations,
+ filters: input.filters,
+ joinOperator: "and",
+ });
+ if (filterWhere) {
+ filterConditions.push(filterWhere);
+ }
+ }
+
+ // RFQ 타입 필터
+ if (input.rfqType) {
+ filterConditions.push(eq(techSalesRfqs.rfqType, input.rfqType));
+ }
+
+ // 모든 조건 결합
+ const allConditions = [
+ ...baseConditions,
+ ...filterConditions,
+ ...(searchConditions.length > 0 ? [or(...searchConditions)] : [])
+ ];
+
+ const whereCondition = allConditions.length > 1
+ ? and(...allConditions)
+ : allConditions[0];
+
+ // 데이터 조회
+ const data = await db
+ .select({
+ // Quotation 정보
+ id: techSalesVendorQuotations.id,
+ rfqId: techSalesVendorQuotations.rfqId,
+ vendorId: techSalesVendorQuotations.vendorId,
+ quotationCode: techSalesVendorQuotations.quotationCode,
+ quotationVersion: techSalesVendorQuotations.quotationVersion,
+ totalPrice: techSalesVendorQuotations.totalPrice,
+ currency: techSalesVendorQuotations.currency,
+ validUntil: techSalesVendorQuotations.validUntil,
+ status: techSalesVendorQuotations.status,
+ remark: techSalesVendorQuotations.remark,
+ submittedAt: techSalesVendorQuotations.submittedAt,
+ acceptedAt: techSalesVendorQuotations.acceptedAt,
+ createdAt: techSalesVendorQuotations.createdAt,
+ updatedAt: techSalesVendorQuotations.updatedAt,
+
+ // RFQ 정보
+ rfqCode: techSalesRfqs.rfqCode,
+ rfqType: techSalesRfqs.rfqType,
+ description: techSalesRfqs.description,
+ dueDate: techSalesRfqs.dueDate,
+ rfqStatus: techSalesRfqs.status,
+ materialCode: techSalesRfqs.materialCode,
+
+ // Vendor 정보
+ vendorName: sql<string>`vendors.vendor_name`,
+ vendorCode: sql<string | null>`vendors.vendor_code`,
+ vendorEmail: sql<string | null>`vendors.email`,
+ vendorCountry: sql<string | null>`vendors.country`,
+
+ // Project 정보
+ projNm: biddingProjects.projNm,
+ pspid: biddingProjects.pspid,
+ sector: biddingProjects.sector,
+ })
+ .from(techSalesVendorQuotations)
+ .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
+ .leftJoin(sql`vendors`, eq(techSalesVendorQuotations.vendorId, sql`vendors.id`))
+ .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id))
+ .where(whereCondition)
+ .orderBy(...orderByConditions)
+ .limit(input.perPage)
+ .offset(offset);
+
+ // 총 개수 조회
+ const totalCount = await db
+ .select({ count: count() })
+ .from(techSalesVendorQuotations)
+ .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
+ .leftJoin(sql`vendors`, eq(techSalesVendorQuotations.vendorId, sql`vendors.id`))
+ .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id))
+ .where(whereCondition);
+
+ const total = totalCount[0]?.count ?? 0;
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return {
+ data,
+ pageCount,
+ total,
+ };
+
+ } catch (error) {
+ console.error("getAcceptedTechSalesVendorQuotations 오류:", error);
+ throw new Error(`Accepted quotations 조회 실패: ${getErrorMessage(error)}`);
+ }
+}
+
+export async function getBidProjects(pjtType: 'SHIP' | 'TOP' | 'HULL'): Promise<Project[]> {
+ try {
+ // 트랜잭션을 사용하여 프로젝트 데이터 조회
+ const projectList = await db.transaction(async (tx) => {
+ // 기본 쿼리 구성
+ const query = tx
+ .select({
+ id: biddingProjects.id,
+ projectCode: biddingProjects.pspid,
+ projectName: biddingProjects.projNm,
+ pjtType: biddingProjects.pjtType,
+ })
+ .from(biddingProjects)
+ .where(eq(biddingProjects.pjtType, pjtType));
+
+ const results = await query.orderBy(biddingProjects.id);
+ return results;
+ });
+
+ // Handle null projectName values and ensure pjtType is not null
+ const validProjectList = projectList.map(project => ({
+ ...project,
+ projectName: project.projectName || '', // Replace null with empty string
+ pjtType: project.pjtType as "SHIP" | "TOP" | "HULL" // Type assertion since WHERE filters ensure non-null
+ }));
+
+ return validProjectList;
+ } catch (error) {
+ console.error("프로젝트 목록 가져오기 실패:", error);
+ return []; // 오류 발생 시 빈 배열 반환
+ }
+}
+
+/**
+ * 여러 벤더의 contact 정보 조회
+ */
+export async function getTechVendorsContacts(vendorIds: number[]) {
+ unstable_noStore();
+ try {
+ // 직접 조인으로 벤더와 contact 정보 조회
+ const contactsWithVendor = await db
+ .select({
+ contactId: techVendorContacts.id,
+ contactName: techVendorContacts.contactName,
+ contactPosition: techVendorContacts.contactPosition,
+ contactEmail: techVendorContacts.contactEmail,
+ contactPhone: techVendorContacts.contactPhone,
+ isPrimary: techVendorContacts.isPrimary,
+ vendorId: techVendorContacts.vendorId,
+ vendorName: techVendors.vendorName,
+ vendorCode: techVendors.vendorCode
+ })
+ .from(techVendorContacts)
+ .leftJoin(techVendors, eq(techVendorContacts.vendorId, techVendors.id))
+ .where(inArray(techVendorContacts.vendorId, vendorIds))
+ .orderBy(
+ asc(techVendorContacts.vendorId),
+ desc(techVendorContacts.isPrimary),
+ asc(techVendorContacts.contactName)
+ );
+
+ // 벤더별로 그룹화
+ const contactsByVendor = contactsWithVendor.reduce((acc, row) => {
+ const vendorId = row.vendorId;
+ if (!acc[vendorId]) {
+ acc[vendorId] = {
+ vendor: {
+ id: vendorId,
+ vendorName: row.vendorName || '',
+ vendorCode: row.vendorCode || ''
+ },
+ contacts: []
+ };
+ }
+ acc[vendorId].contacts.push({
+ id: row.contactId,
+ contactName: row.contactName,
+ contactPosition: row.contactPosition,
+ contactEmail: row.contactEmail,
+ contactPhone: row.contactPhone,
+ isPrimary: row.isPrimary
+ });
+ return acc;
+ }, {} as Record<number, {
+ vendor: {
+ id: number;
+ vendorName: string;
+ vendorCode: string | null;
+ };
+ contacts: Array<{
+ id: number;
+ contactName: string;
+ contactPosition: string | null;
+ contactEmail: string;
+ contactPhone: string | null;
+ isPrimary: boolean;
+ }>;
+ }>);
+
+ return { data: contactsByVendor, error: null };
+ } catch (err) {
+ console.error("벤더 contact 조회 오류:", err);
+ return { data: {}, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * quotation별 발송된 담당자 정보 조회
+ */
+export async function getQuotationContacts(quotationId: number) {
+ unstable_noStore();
+ try {
+ // quotation에 연결된 담당자들 조회
+ const quotationContacts = await db
+ .select({
+ id: techSalesVendorQuotationContacts.id,
+ contactId: techSalesVendorQuotationContacts.contactId,
+ contactName: techVendorContacts.contactName,
+ contactPosition: techVendorContacts.contactPosition,
+ contactEmail: techVendorContacts.contactEmail,
+ contactPhone: techVendorContacts.contactPhone,
+ contactCountry: techVendorContacts.contactCountry,
+ isPrimary: techVendorContacts.isPrimary,
+ createdAt: techSalesVendorQuotationContacts.createdAt,
+ })
+ .from(techSalesVendorQuotationContacts)
+ .innerJoin(
+ techVendorContacts,
+ eq(techSalesVendorQuotationContacts.contactId, techVendorContacts.id)
+ )
+ .where(eq(techSalesVendorQuotationContacts.quotationId, quotationId))
+ .orderBy(techSalesVendorQuotationContacts.createdAt);
+
+ return {
+ success: true,
+ data: quotationContacts,
+ error: null,
+ };
+ } catch (error) {
+ console.error("Quotation contacts 조회 오류:", error);
+ return {
+ success: false,
+ data: [],
+ error: getErrorMessage(error),
+ };
+ }
+}
+
+/**
+ * 견적서 첨부파일 업로드 (클라이언트용)
+ */
+export async function uploadQuotationAttachments(
+ quotationId: number,
+ files: File[],
+ userId: number
+): Promise<{ success: boolean; attachments?: Array<{ fileName: string; originalFileName: string; filePath: string; fileSize: number }>; error?: string }> {
+ try {
+ const uploadedAttachments = [];
+
+ for (const file of files) {
+ const saveResult = await saveFile({
+ file,
+ directory: `techsales-quotations/${quotationId}`,
+ userId: userId.toString(),
+ });
+
+ if (!saveResult.success) {
+ throw new Error(saveResult.error || '파일 저장에 실패했습니다.');
+ }
+
+ uploadedAttachments.push({
+ fileName: saveResult.fileName!, // 해시된 파일명 (저장용)
+ originalFileName: saveResult.originalName!, // 원본 파일명 (표시용)
+ filePath: saveResult.publicPath!,
+ fileSize: file.size,
+ });
+ }
+
+ return {
+ success: true,
+ attachments: uploadedAttachments
+ };
+ } catch (error) {
+ console.error('견적서 첨부파일 업로드 오류:', error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '파일 업로드 중 오류가 발생했습니다.'
+ };
+ }
} \ No newline at end of file