diff options
Diffstat (limited to 'lib/techsales-rfq/service.ts')
| -rw-r--r-- | lib/techsales-rfq/service.ts | 1958 |
1 files changed, 1258 insertions, 700 deletions
diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts index 26117452..d74c54b4 100644 --- a/lib/techsales-rfq/service.ts +++ b/lib/techsales-rfq/service.ts @@ -6,11 +6,13 @@ import { techSalesRfqs, techSalesVendorQuotations, techSalesAttachments, - itemShipbuilding, users, - techSalesRfqComments + techSalesRfqComments, + techSalesRfqItems, + projectSeries, + biddingProjects } from "@/db/schema"; -import { and, desc, eq, ilike, or, sql, ne } from "drizzle-orm"; +import { and, desc, eq, ilike, or, sql, inArray } from "drizzle-orm"; import { unstable_cache } from "@/lib/unstable-cache"; import { filterColumns } from "@/lib/filter-columns"; import { getErrorMessage } from "@/lib/handle-error"; @@ -20,117 +22,22 @@ import { countTechSalesRfqsWithJoin, selectTechSalesVendorQuotationsWithJoin, countTechSalesVendorQuotationsWithJoin, - selectTechSalesDashboardWithJoin + 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, formatDateToQuarter } from "../utils"; +import { techVendors, techVendorPossibleItems } from "@/db/schema/techVendors"; // 정렬 타입 정의 // 의도적으로 any 사용 - drizzle ORM의 orderBy 타입이 복잡함 // eslint-disable-next-line @typescript-eslint/no-explicit-any type OrderByType = any; -// 시리즈 스냅샷 타입 정의 -interface SeriesSnapshot { - pspid: string; - sersNo: string; - scDt?: string; - klDt?: string; - lcDt?: string; - dlDt?: string; - dockNo?: string; - dockNm?: string; - projNo?: string; - post1?: string; -} - -// JSON 필드 식별 함수 -function isJsonField(fieldId: string): boolean { - const jsonFields = ['projNm', 'ptypeNm', 'projMsrm', 'sector', 'pspid']; - return jsonFields.includes(fieldId); -} - -// JSON 필드 필터링 함수 -function filterJsonFields(filters: Filter<typeof techSalesRfqs>[], joinOperator: "and" | "or") { - const joinFn = joinOperator === "and" ? and : or; - - const conditions = filters.map(filter => { - const fieldId = filter.id as string; - const value = filter.value; - - switch (fieldId) { - case 'projNm': - return createJsonFieldCondition('projNm', filter.operator, value); - case 'ptypeNm': - return createJsonFieldCondition('ptypeNm', filter.operator, value); - case 'sector': - return createJsonFieldCondition('sector', filter.operator, value); - case 'pspid': - return createJsonFieldCondition('pspid', filter.operator, value); - case 'projMsrm': - // 숫자 필드는 특별 처리 - return createJsonNumberFieldCondition('projMsrm', filter.operator, value); - default: - return undefined; - } - }).filter(Boolean); - - return conditions.length > 0 ? joinFn(...conditions) : undefined; -} -// JSON 텍스트 필드 조건 생성 -function createJsonFieldCondition(fieldName: string, operator: string, value: unknown) { - const jsonPath = `${techSalesRfqs.projectSnapshot}->>'${fieldName}'`; - - switch (operator) { - case 'eq': - return sql`${sql.raw(jsonPath)} = ${value}`; - case 'ne': - return sql`${sql.raw(jsonPath)} != ${value}`; - case 'iLike': - return sql`${sql.raw(jsonPath)} ILIKE ${'%' + value + '%'}`; - case 'notILike': - return sql`${sql.raw(jsonPath)} NOT ILIKE ${'%' + value + '%'}`; - case 'isEmpty': - return sql`(${sql.raw(jsonPath)} IS NULL OR ${sql.raw(jsonPath)} = '')`; - case 'isNotEmpty': - return sql`(${sql.raw(jsonPath)} IS NOT NULL AND ${sql.raw(jsonPath)} != '')`; - default: - return undefined; - } -} - -// JSON 숫자 필드 조건 생성 -function createJsonNumberFieldCondition(fieldName: string, operator: string, value: unknown) { - const jsonPath = `(${techSalesRfqs.projectSnapshot}->>'${fieldName}')::int`; - const numValue = parseInt(value as string, 10); - - if (isNaN(numValue)) return undefined; - - switch (operator) { - case 'eq': - return sql`${sql.raw(jsonPath)} = ${numValue}`; - case 'ne': - return sql`${sql.raw(jsonPath)} != ${numValue}`; - case 'gt': - return sql`${sql.raw(jsonPath)} > ${numValue}`; - case 'gte': - return sql`${sql.raw(jsonPath)} >= ${numValue}`; - case 'lt': - return sql`${sql.raw(jsonPath)} < ${numValue}`; - case 'lte': - return sql`${sql.raw(jsonPath)} <= ${numValue}`; - case 'isEmpty': - return sql`${techSalesRfqs.projectSnapshot}->>'${fieldName}' IS NULL`; - case 'isNotEmpty': - return sql`${techSalesRfqs.projectSnapshot}->>'${fieldName}' IS NOT NULL`; - default: - return undefined; - } -} /** * 연도별 순차 RFQ 코드 생성 함수 (다중 생성 지원) @@ -183,141 +90,142 @@ async function generateRfqCodes(tx: any, count: number, year?: number): Promise< * * 나머지 벤더, 첨부파일 등은 생성 이후 처리 */ -export async function createTechSalesRfq(input: { - // 프로젝트 관련 - biddingProjectId: number; - // 조선 아이템 관련 - itemShipbuildingId: number; - // 자재 관련 (자재그룹 코드들을 CSV로) - materialGroupCodes: string[]; - // 기본 정보 - dueDate?: Date; - remark?: string; - createdBy: number; -}) { - unstable_noStore(); - console.log('🔍 createTechSalesRfq 호출됨:', { - biddingProjectId: input.biddingProjectId, - itemShipbuildingId: input.itemShipbuildingId, - materialGroupCodes: input.materialGroupCodes, - dueDate: input.dueDate, - remark: input.remark, - createdBy: input.createdBy - }); +// export async function createTechSalesRfq(input: { +// // 프로젝트 관련 +// biddingProjectId: number; +// // 조선 아이템 관련 +// itemShipbuildingId: number; +// // 자재 관련 (자재그룹 코드들을 CSV로) +// materialGroupCodes: string[]; +// // 기본 정보 +// dueDate?: Date; +// remark?: string; +// createdBy: number; +// }) { +// unstable_noStore(); +// console.log('🔍 createTechSalesRfq 호출됨:', { +// biddingProjectId: input.biddingProjectId, +// itemShipbuildingId: input.itemShipbuildingId, +// materialGroupCodes: input.materialGroupCodes, +// dueDate: input.dueDate, +// remark: input.remark, +// createdBy: input.createdBy +// }); - try { - let result: typeof techSalesRfqs.$inferSelect | undefined; +// try { +// let result: typeof techSalesRfqs.$inferSelect | undefined; - // 트랜잭션으로 처리 - await db.transaction(async (tx) => { - // 실제 프로젝트 정보 조회 - const biddingProject = await tx.query.biddingProjects.findFirst({ - where: (biddingProjects, { eq }) => eq(biddingProjects.id, input.biddingProjectId) - }); - - if (!biddingProject) { - throw new Error(`프로젝트 ID ${input.biddingProjectId}를 찾을 수 없습니다.`); - } - - // 프로젝트 시리즈 정보 조회 - const seriesInfo = await tx.query.projectSeries.findMany({ - where: (projectSeries, { eq }) => eq(projectSeries.pspid, biddingProject.pspid) - }); - - // 프로젝트 스냅샷 생성 - const projectSnapshot = { - pspid: biddingProject.pspid, - projNm: biddingProject.projNm || undefined, - sector: biddingProject.sector || undefined, - projMsrm: biddingProject.projMsrm ? Number(biddingProject.projMsrm) : undefined, - kunnr: biddingProject.kunnr || undefined, - kunnrNm: biddingProject.kunnrNm || undefined, - cls1: biddingProject.cls1 || undefined, - cls1Nm: biddingProject.cls1Nm || undefined, - ptype: biddingProject.ptype || undefined, - ptypeNm: biddingProject.ptypeNm || undefined, - pmodelCd: biddingProject.pmodelCd || undefined, - pmodelNm: biddingProject.pmodelNm || undefined, - pmodelSz: biddingProject.pmodelSz || undefined, - pmodelUom: biddingProject.pmodelUom || undefined, - txt04: biddingProject.txt04 || undefined, - txt30: biddingProject.txt30 || undefined, - estmPm: biddingProject.estmPm || undefined, - pspCreatedAt: biddingProject.createdAt, - pspUpdatedAt: biddingProject.updatedAt, - }; - - // 시리즈 스냅샷 생성 - const seriesSnapshot = seriesInfo.map(series => ({ - pspid: series.pspid, - sersNo: series.sersNo.toString(), - scDt: series.scDt || undefined, - klDt: series.klDt || undefined, - lcDt: series.lcDt || undefined, - dlDt: series.dlDt || undefined, - dockNo: series.dockNo || undefined, - dockNm: series.dockNm || undefined, - projNo: series.projNo || undefined, - post1: series.post1 || undefined, - })); - - // RFQ 코드 생성 - const rfqCode = await generateRfqCodes(tx, 1); +// // 트랜잭션으로 처리 +// await db.transaction(async (tx) => { +// // 실제 프로젝트 정보 조회 +// const biddingProject = await tx.query.biddingProjects.findFirst({ +// where: (biddingProjects, { eq }) => eq(biddingProjects.id, input.biddingProjectId) +// }); + +// if (!biddingProject) { +// throw new Error(`프로젝트 ID ${input.biddingProjectId}를 찾을 수 없습니다.`); +// } + +// // 프로젝트 시리즈 정보 조회 +// const seriesInfo = await tx.query.projectSeries.findMany({ +// where: (projectSeries, { eq }) => eq(projectSeries.pspid, biddingProject.pspid) +// }); + +// // 프로젝트 스냅샷 생성 +// const projectSnapshot = { +// pspid: biddingProject.pspid, +// projNm: biddingProject.projNm || undefined, +// sector: biddingProject.sector || undefined, +// projMsrm: biddingProject.projMsrm ? Number(biddingProject.projMsrm) : undefined, +// kunnr: biddingProject.kunnr || undefined, +// kunnrNm: biddingProject.kunnrNm || undefined, +// cls1: biddingProject.cls1 || undefined, +// cls1Nm: biddingProject.cls1Nm || undefined, +// ptype: biddingProject.ptype || undefined, +// ptypeNm: biddingProject.ptypeNm || undefined, +// pmodelCd: biddingProject.pmodelCd || undefined, +// pmodelNm: biddingProject.pmodelNm || undefined, +// pmodelSz: biddingProject.pmodelSz || undefined, +// pmodelUom: biddingProject.pmodelUom || undefined, +// txt04: biddingProject.txt04 || undefined, +// txt30: biddingProject.txt30 || undefined, +// estmPm: biddingProject.estmPm || undefined, +// pspCreatedAt: biddingProject.createdAt, +// pspUpdatedAt: biddingProject.updatedAt, +// }; + +// // 시리즈 스냅샷 생성 +// const seriesSnapshot = seriesInfo.map(series => ({ +// pspid: series.pspid, +// sersNo: series.sersNo.toString(), +// scDt: series.scDt || undefined, +// klDt: series.klDt || undefined, +// lcDt: series.lcDt || undefined, +// dlDt: series.dlDt || undefined, +// dockNo: series.dockNo || undefined, +// dockNm: series.dockNm || undefined, +// projNo: series.projNo || undefined, +// post1: series.post1 || undefined, +// })); + +// // RFQ 코드 생성 +// const rfqCode = await generateRfqCodes(tx, 1); - // 기본 due date 설정 (7일 후) - const dueDate = input.dueDate || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); +// // 기본 due date 설정 (7일 후) +// const dueDate = input.dueDate || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); - // itemShipbuildingId 유효성 검증 - console.log('🔍 itemShipbuildingId 검증:', input.itemShipbuildingId); - const existingItemShipbuilding = await tx.query.itemShipbuilding.findFirst({ - where: (itemShipbuilding, { eq }) => eq(itemShipbuilding.id, input.itemShipbuildingId), - columns: { id: true, itemCode: true, itemList: true } - }); +// // itemShipbuildingId 유효성 검증 +// console.log('🔍 itemShipbuildingId 검증:', input.itemShipbuildingId); +// const existingItemShipbuilding = await tx.query.itemShipbuilding.findFirst({ +// where: (itemShipbuilding, { eq }) => eq(itemShipbuilding.id, input.itemShipbuildingId), +// columns: { id: true, itemCode: true, itemList: true } +// }); - if (!existingItemShipbuilding) { - throw new Error(`itemShipbuildingId ${input.itemShipbuildingId}에 해당하는 itemShipbuilding 레코드를 찾을 수 없습니다.`); - } +// if (!existingItemShipbuilding) { +// throw new Error(`itemShipbuildingId ${input.itemShipbuildingId}에 해당하는 itemShipbuilding 레코드를 찾을 수 없습니다.`); +// } - console.log('✅ itemShipbuilding 찾음:', existingItemShipbuilding); +// console.log('✅ itemShipbuilding 찾음:', existingItemShipbuilding); - // 새 기술영업 RFQ 작성 (스냅샷 포함) - const [newRfq] = await tx.insert(techSalesRfqs).values({ - rfqCode: rfqCode[0], - itemShipbuildingId: input.itemShipbuildingId, - biddingProjectId: input.biddingProjectId, - materialCode: input.materialGroupCodes.join(','), // 모든 materialCode를 CSV로 저장 - dueDate, - remark: input.remark, - createdBy: input.createdBy, - updatedBy: input.createdBy, - // 스냅샷 데이터 추가 - projectSnapshot, - seriesSnapshot, - }).returning(); +// // 새 기술영업 RFQ 작성 (스냅샷 포함) +// const [newRfq] = await tx.insert(techSalesRfqs).values({ +// rfqCode: rfqCode[0], +// rfqType: "SHIP", +// itemShipbuildingId: input.itemShipbuildingId, +// biddingProjectId: input.biddingProjectId, +// materialCode: input.materialGroupCodes.join(','), // 모든 materialCode를 CSV로 저장 +// dueDate, +// remark: input.remark, +// createdBy: input.createdBy, +// updatedBy: input.createdBy, +// // 스냅샷 데이터 추가 +// projectSnapshot, +// seriesSnapshot, +// }).returning(); - result = newRfq; - }); +// result = newRfq; +// }); - // 캐시 무효화 - revalidateTag("techSalesRfqs"); - revalidatePath("/evcp/budgetary-tech-sales-ship"); +// // 캐시 무효화 +// revalidateTag("techSalesRfqs"); +// revalidatePath("/evcp/budgetary-tech-sales-ship"); - if (!result) { - throw new Error(`RFQ 생성에 실패했습니다. 입력값: ${JSON.stringify(input)}`); - } +// if (!result) { +// throw new Error(`RFQ 생성에 실패했습니다. 입력값: ${JSON.stringify(input)}`); +// } - return { data: [result], error: null }; - } catch (err) { - console.error("Error creating RFQ:", err); - return { data: null, error: getErrorMessage(err) }; - } -} +// return { data: [result], error: null }; +// } catch (err) { +// console.error("Error creating RFQ:", err); +// return { data: null, error: getErrorMessage(err) }; +// } +// } /** * 직접 조인을 사용하여 RFQ 데이터 조회하는 함수 * 페이지네이션, 필터링, 정렬 등 지원 */ -export async function getTechSalesRfqsWithJoin(input: GetTechSalesRfqsSchema) { +export async function getTechSalesRfqsWithJoin(input: GetTechSalesRfqsSchema & { rfqType?: "SHIP" | "TOP" | "HULL" }) { return unstable_cache( async () => { try { @@ -341,26 +249,14 @@ export async function getTechSalesRfqsWithJoin(input: GetTechSalesRfqsSchema) { }); } - // 고급 필터 조건 생성 (JSON 필드 지원) + // 고급 필터 조건 생성 let advancedWhere; if (advancedFilters.length > 0) { - // 일반 필드와 JSON 필드 분리 - const normalFilters = advancedFilters.filter(f => !isJsonField(f.id as string)); - const jsonFilters = advancedFilters.filter(f => isJsonField(f.id as string)); - - const normalWhere = normalFilters.length > 0 ? filterColumns({ + advancedWhere = filterColumns({ table: techSalesRfqs, - filters: normalFilters, + filters: advancedFilters, joinOperator: advancedJoinOperator, - }) : undefined; - - const jsonWhere = jsonFilters.length > 0 ? filterJsonFields(jsonFilters, advancedJoinOperator) : undefined; - - if (normalWhere && jsonWhere) { - advancedWhere = advancedJoinOperator === "and" ? and(normalWhere, jsonWhere) : or(normalWhere, jsonWhere); - } else { - advancedWhere = normalWhere || jsonWhere; - } + }); } // 전역 검색 조건 @@ -370,10 +266,8 @@ export async function getTechSalesRfqsWithJoin(input: GetTechSalesRfqsSchema) { globalWhere = or( ilike(techSalesRfqs.rfqCode, s), ilike(techSalesRfqs.materialCode, s), - // JSON 필드 검색 - sql`${techSalesRfqs.projectSnapshot}->>'pspid' ILIKE ${s}`, - sql`${techSalesRfqs.projectSnapshot}->>'projNm' ILIKE ${s}`, - sql`${techSalesRfqs.projectSnapshot}->>'ptypeNm' ILIKE ${s}` + ilike(techSalesRfqs.description, s), + ilike(techSalesRfqs.remark, s) ); } @@ -404,49 +298,16 @@ export async function getTechSalesRfqsWithJoin(input: GetTechSalesRfqsSchema) { return item.desc ? desc(techSalesRfqs.rfqCode) : techSalesRfqs.rfqCode; case 'materialCode': return item.desc ? desc(techSalesRfqs.materialCode) : techSalesRfqs.materialCode; - case 'itemName': - // itemName은 조인된 itemShipbuilding.itemList 필드 - return item.desc ? desc(sql`item_shipbuilding.item_list`) : sql`item_shipbuilding.item_list`; + 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 'projNm': - // JSON 필드에서 추출된 프로젝트명 - return item.desc ? desc(sql`${techSalesRfqs.projectSnapshot}->>'projNm'`) : sql`${techSalesRfqs.projectSnapshot}->>'projNm'`; - case 'projMsrm': - // JSON 필드에서 추출된 척수 (정수 캐스팅) - return item.desc ? desc(sql`(${techSalesRfqs.projectSnapshot}->>'projMsrm')::int`) : sql`(${techSalesRfqs.projectSnapshot}->>'projMsrm')::int`; - case 'ptypeNm': - // JSON 필드에서 추출된 선종명 - return item.desc ? desc(sql`${techSalesRfqs.projectSnapshot}->>'ptypeNm'`) : sql`${techSalesRfqs.projectSnapshot}->>'ptypeNm'`; - case 'quotationCount': - // 서브쿼리로 계산된 견적수 - repository의 SELECT에서 정의한 컬럼명 사용 - return item.desc ? desc(sql`( - SELECT COUNT(*) - FROM tech_sales_vendor_quotations - WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id} - )`) : sql`( - SELECT COUNT(*) - FROM tech_sales_vendor_quotations - WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id} - )`; - case 'attachmentCount': - // 서브쿼리로 계산된 첨부파일수 - repository의 SELECT에서 정의한 컬럼명 사용 - return item.desc ? desc(sql`( - SELECT COUNT(*) - FROM tech_sales_attachments - WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id} - )`) : sql`( - SELECT COUNT(*) - FROM tech_sales_attachments - WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id} - )`; - case 'createdByName': - // 조인된 사용자명 - return item.desc ? desc(sql`created_user.name`) : sql`created_user.name`; + case 'remark': + return item.desc ? desc(techSalesRfqs.remark) : techSalesRfqs.remark; case 'createdAt': return item.desc ? desc(techSalesRfqs.createdAt) : techSalesRfqs.createdAt; case 'updatedAt': @@ -457,30 +318,30 @@ export async function getTechSalesRfqsWithJoin(input: GetTechSalesRfqsSchema) { }); } - // 트랜잭션 내부에서 Repository 호출 - const { data, total } = await db.transaction(async (tx) => { - const data = await selectTechSalesRfqsWithJoin(tx, { - where: finalWhere, - orderBy, - offset, - limit: input.perPage, - }); - - const total = await countTechSalesRfqsWithJoin(tx, finalWhere); - return { data, total }; + // 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) }; }); - - const pageCount = Math.ceil(total / input.perPage); - - return { data, pageCount, total }; } catch (err) { console.error("Error fetching RFQs with join:", err); return { data: [], pageCount: 0, total: 0 }; } }, - [JSON.stringify(input)], // 캐싱 키 + [JSON.stringify(input)], { - revalidate: 60, // 1분간 캐시 + revalidate: 60, tags: ["techSalesRfqs"], } )(); @@ -497,6 +358,7 @@ export async function getTechSalesVendorQuotationsWithJoin(input: { sort?: { id: string; desc: boolean }[]; page: number; perPage: number; + rfqType?: "SHIP" | "TOP" | "HULL"; // rfqType 매개변수 추가 }) { return unstable_cache( async () => { @@ -610,6 +472,7 @@ export async function getTechSalesDashboardWithJoin(input: { sort?: { id: string; desc: boolean }[]; page: number; perPage: number; + rfqType?: "SHIP" | "TOP" | "HULL"; // rfqType 매개변수 추가 }) { unstable_noStore(); // 대시보드는 항상 최신 데이터를 보여주기 위해 캐시하지 않음 @@ -630,9 +493,7 @@ export async function getTechSalesDashboardWithJoin(input: { globalWhere = or( ilike(techSalesRfqs.rfqCode, s), ilike(techSalesRfqs.materialCode, s), - // JSON 필드 검색 - sql`${techSalesRfqs.projectSnapshot}->>'pspid' ILIKE ${s}`, - sql`${techSalesRfqs.projectSnapshot}->>'projNm' ILIKE ${s}` + ilike(techSalesRfqs.description, s) ); } @@ -673,6 +534,7 @@ export async function getTechSalesDashboardWithJoin(input: { orderBy, offset, limit: input.perPage, + rfqType: input.rfqType, // rfqType 매개변수 추가 }); }); @@ -1036,26 +898,11 @@ export async function sendTechSalesRfqToVendors(input: { rfqSendDate: true, remark: true, materialCode: true, - projectSnapshot: true, - seriesSnapshot: true, + description: true, + rfqType: true, }, with: { - itemShipbuilding: { - columns: { - id: true, - itemCode: true, - itemList: true, - } - }, - biddingProject: { - columns: { - id: true, - pspid: true, - projNm: true, - sector: true, - ptypeNm: true, - } - }, + biddingProject: true, createdByUser: { columns: { id: true, @@ -1174,18 +1021,24 @@ export async function sendTechSalesRfqToVendors(input: { // 대표 언어 결정 (첫 번째 사용자의 언어 또는 기본값) const language = vendorUsers[0]?.language || "ko"; - // 시리즈 정보 처리 - const seriesInfo = rfq.seriesSnapshot ? rfq.seriesSnapshot.map((series: SeriesSnapshot) => ({ - sersNo: series.sersNo, - klQuarter: series.klDt ? formatDateToQuarter(series.klDt) : '', - scDt: series.scDt, - lcDt: series.lcDt, - dlDt: series.dlDt, - dockNo: series.dockNo, - dockNm: series.dockNm, - projNo: series.projNo, - post1: series.post1, - })) : []; + // 시리즈 정보 처리 - 직접 조회 + const seriesInfo = rfq.biddingProject?.pspid ? await db.query.projectSeries.findMany({ + where: eq(projectSeries.pspid, rfq.biddingProject.pspid) + }).then(series => series.map(s => ({ + sersNo: s.sersNo.toString(), + klQuarter: s.klDt ? formatDateToQuarter(s.klDt) : '', + scDt: s.scDt, + lcDt: s.lcDt, + dlDt: s.dlDt, + dockNo: s.dockNo, + dockNm: s.dockNm, + projNo: s.projNo, + post1: s.post1, + }))) : []; + + // RFQ 아이템 목록 조회 + const rfqItemsResult = await getTechSalesRfqItems(rfq.id); + const rfqItems = rfqItemsResult.data || []; // 이메일 컨텍스트 구성 const emailContext = { @@ -1193,13 +1046,22 @@ export async function sendTechSalesRfqToVendors(input: { rfq: { id: rfq.id, code: rfq.rfqCode, - title: rfq.itemShipbuilding?.itemList || '', + 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) : '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 || '', @@ -1211,24 +1073,24 @@ export async function sendTechSalesRfqToVendors(input: { }, project: { // 기본 정보 - id: rfq.projectSnapshot?.pspid || rfq.biddingProject?.pspid || '', - name: rfq.projectSnapshot?.projNm || rfq.biddingProject?.projNm || '', - sector: rfq.projectSnapshot?.sector || rfq.biddingProject?.sector || '', - shipType: rfq.projectSnapshot?.ptypeNm || rfq.biddingProject?.ptypeNm || '', + id: rfq.biddingProject?.pspid || '', + name: rfq.biddingProject?.projNm || '', + sector: rfq.biddingProject?.sector || '', + shipType: rfq.biddingProject?.ptypeNm || '', // 추가 프로젝트 정보 - shipCount: rfq.projectSnapshot?.projMsrm || 0, - ownerCode: rfq.projectSnapshot?.kunnr || '', - ownerName: rfq.projectSnapshot?.kunnrNm || '', - classCode: rfq.projectSnapshot?.cls1 || '', - className: rfq.projectSnapshot?.cls1Nm || '', - shipTypeCode: rfq.projectSnapshot?.ptype || '', - shipModelCode: rfq.projectSnapshot?.pmodelCd || '', - shipModelName: rfq.projectSnapshot?.pmodelNm || '', - shipModelSize: rfq.projectSnapshot?.pmodelSz || '', - shipModelUnit: rfq.projectSnapshot?.pmodelUom || '', - estimateStatus: rfq.projectSnapshot?.txt30 || '', - projectManager: rfq.projectSnapshot?.estmPm || '', + shipCount: rfq.biddingProject?.projMsrm || 0, + ownerCode: rfq.biddingProject?.kunnr || '', + ownerName: rfq.biddingProject?.kunnrNm || '', + classCode: rfq.biddingProject?.cls1 || '', + className: rfq.biddingProject?.cls1Nm || '', + shipTypeCode: rfq.biddingProject?.ptype || '', + shipModelCode: rfq.biddingProject?.pmodelCd || '', + shipModelName: rfq.biddingProject?.pmodelNm || '', + shipModelSize: rfq.biddingProject?.pmodelSz || '', + shipModelUnit: rfq.biddingProject?.pmodelUom || '', + estimateStatus: rfq.biddingProject?.txt30 || '', + projectManager: rfq.biddingProject?.estmPm || '', }, series: seriesInfo, details: { @@ -1244,8 +1106,8 @@ export async function sendTechSalesRfqToVendors(input: { await sendEmail({ to: vendorEmailsString, subject: isResend - ? `[기술영업 RFQ 재전송] ${rfq.rfqCode} - ${rfq.itemShipbuilding?.itemList || '견적 요청'} ${emailContext.versionInfo}` - : `[기술영업 RFQ] ${rfq.rfqCode} - ${rfq.itemShipbuilding?.itemList || '견적 요청'}`, + ? `[기술영업 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에 추가 @@ -1275,36 +1137,91 @@ export async function sendTechSalesRfqToVendors(input: { } /** - * 벤더용 기술영업 RFQ 견적서 조회 + * 벤더용 기술영업 RFQ 견적서 조회 (withJoin 사용) */ export async function getTechSalesVendorQuotation(quotationId: number) { unstable_noStore(); try { - const quotation = await db.query.techSalesVendorQuotations.findFirst({ - where: eq(techSalesVendorQuotations.id, quotationId), - with: { - rfq: { - with: { - itemShipbuilding: true, - biddingProject: true, - createdByUser: { - columns: { - id: true, - name: true, - email: true, - } - } - } - }, - vendor: true, - } + const quotation = await db.transaction(async (tx) => { + return await selectSingleTechSalesVendorQuotationWithJoin(tx, quotationId); }); if (!quotation) { return { data: null, error: "견적서를 찾을 수 없습니다." }; } - return { data: quotation, error: null }; + // RFQ 아이템 정보도 함께 조회 + const itemsResult = await getTechSalesRfqItems(quotation.rfqId); + const items = itemsResult.data || []; + + // 기존 구조와 호환되도록 데이터 재구성 + 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, + } + }; + + return { data: formattedQuotation, error: null }; } catch (err) { console.error("Error fetching vendor quotation:", err); return { data: null, error: getErrorMessage(err) }; @@ -1469,34 +1386,16 @@ export async function getVendorQuotations(input: { search?: string; from?: string; to?: string; + rfqType?: "SHIP" | "TOP" | "HULL"; }, vendorId: string) { return unstable_cache( async () => { try { - // 디버깅 로그 추가 - console.log('🔍 [getVendorQuotations] 받은 파라미터:'); - console.log(' 📊 기본 정보:', { + console.log('🔍 [getVendorQuotations] 호출됨:', { vendorId, - page: input.page, - perPage: input.perPage, - }); - console.log(' 🔧 정렬 정보:', { - sort: input.sort, - sortLength: input.sort?.length, - sortDetails: input.sort?.map(s => `${s.id}:${s.desc ? 'DESC' : 'ASC'}`) - }); - console.log(' 🔍 필터 정보:', { - filters: input.filters, - filtersLength: input.filters?.length, - joinOperator: input.joinOperator, - basicFilters: input.basicFilters, - basicFiltersLength: input.basicFilters?.length, - basicJoinOperator: input.basicJoinOperator - }); - console.log(' 🔎 검색 정보:', { - search: input.search, - from: input.from, - to: input.to + vendorIdParsed: parseInt(vendorId), + rfqType: input.rfqType, + inputData: input }); const { page, perPage, sort, filters = [], search = "", from = "", to = "" } = input; @@ -1504,7 +1403,18 @@ export async function getVendorQuotations(input: { const limit = perPage; // 기본 조건: 해당 벤더의 견적서만 조회 - const baseConditions = [eq(techSalesVendorQuotations.vendorId, parseInt(vendorId))]; + 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)]; + + // rfqType 필터링 추가 + if (input.rfqType) { + baseConditions.push(eq(techSalesRfqs.rfqType, input.rfqType)); + } // 검색 조건 추가 if (search) { @@ -1528,125 +1438,13 @@ export async function getVendorQuotations(input: { // 고급 필터 처리 if (filters.length > 0) { - // 조인된 테이블의 컬럼들을 분리 - const joinedColumnFilters = []; - const baseTableFilters = []; - - for (const filter of filters) { - const filterId = filter.id as string; - - // 조인된 컬럼들인지 확인 - if (['rfqCode', 'materialCode', 'dueDate', 'rfqStatus', 'itemName', 'projNm', 'pspid', 'sector', 'kunnrNm'].includes(filterId)) { - joinedColumnFilters.push(filter); - } else { - baseTableFilters.push(filter); - } - } - - // 기본 테이블 필터 처리 - if (baseTableFilters.length > 0) { - const filterWhere = filterColumns({ - table: techSalesVendorQuotations, - filters: baseTableFilters as Filter<typeof techSalesVendorQuotations>[], - joinOperator: input.joinOperator || "and", - }); - if (filterWhere) { - baseConditions.push(filterWhere); - } - } - - // 조인된 컬럼 필터 처리 - if (joinedColumnFilters.length > 0) { - const joinedConditions = joinedColumnFilters.map(filter => { - const filterId = filter.id as string; - const value = filter.value; - const operator = filter.operator || 'eq'; - - switch (filterId) { - case 'rfqCode': - if (operator === 'iLike') { - return ilike(techSalesRfqs.rfqCode, `%${value}%`); - } else if (operator === 'eq') { - return eq(techSalesRfqs.rfqCode, value as string); - } - break; - - case 'materialCode': - if (operator === 'iLike') { - return ilike(techSalesRfqs.materialCode, `%${value}%`); - } else if (operator === 'eq') { - return eq(techSalesRfqs.materialCode, value as string); - } - break; - - case 'dueDate': - if (operator === 'eq') { - return eq(techSalesRfqs.dueDate, new Date(value as string)); - } else if (operator === 'gte') { - return sql`${techSalesRfqs.dueDate} >= ${new Date(value as string)}`; - } else if (operator === 'lte') { - return sql`${techSalesRfqs.dueDate} <= ${new Date(value as string)}`; - } - break; - - case 'rfqStatus': - if (Array.isArray(value) && value.length > 0) { - return sql`${techSalesRfqs.status} IN (${value.map(v => `'${v}'`).join(',')})`; - } else if (typeof value === 'string') { - return eq(techSalesRfqs.status, value); - } - break; - - case 'itemName': - if (operator === 'iLike') { - return ilike(itemShipbuilding.itemList, `%${value}%`); - } else if (operator === 'eq') { - return eq(itemShipbuilding.itemList, value as string); - } - break; - - case 'projNm': - if (operator === 'iLike') { - return sql`${techSalesRfqs.projectSnapshot}->>'projNm' ILIKE ${'%' + value + '%'}`; - } else if (operator === 'eq') { - return sql`${techSalesRfqs.projectSnapshot}->>'projNm' = ${value}`; - } - break; - - case 'pspid': - if (operator === 'iLike') { - return sql`${techSalesRfqs.projectSnapshot}->>'pspid' ILIKE ${'%' + value + '%'}`; - } else if (operator === 'eq') { - return sql`${techSalesRfqs.projectSnapshot}->>'pspid' = ${value}`; - } - break; - - case 'sector': - if (operator === 'iLike') { - return sql`${techSalesRfqs.projectSnapshot}->>'sector' ILIKE ${'%' + value + '%'}`; - } else if (operator === 'eq') { - return sql`${techSalesRfqs.projectSnapshot}->>'sector' = ${value}`; - } - break; - - case 'kunnrNm': - if (operator === 'iLike') { - return sql`${techSalesRfqs.projectSnapshot}->>'kunnrNm' ILIKE ${'%' + value + '%'}`; - } else if (operator === 'eq') { - return sql`${techSalesRfqs.projectSnapshot}->>'kunnrNm' = ${value}`; - } - break; - } - return undefined; - }).filter(Boolean); - - if (joinedConditions.length > 0) { - const joinOperator = input.joinOperator || "and"; - const combinedCondition = joinOperator === "and" - ? and(...joinedConditions) - : or(...joinedConditions); - baseConditions.push(combinedCondition); - } + const filterWhere = filterColumns({ + table: techSalesVendorQuotations, + filters: filters as Filter<typeof techSalesVendorQuotations>[], + joinOperator: input.joinOperator || "and", + }); + if (filterWhere) { + baseConditions.push(filterWhere); } } @@ -1677,19 +1475,6 @@ export async function getVendorQuotations(input: { return item.desc ? desc(techSalesVendorQuotations.createdAt) : techSalesVendorQuotations.createdAt; case 'updatedAt': return item.desc ? desc(techSalesVendorQuotations.updatedAt) : techSalesVendorQuotations.updatedAt; - case 'createdBy': - return item.desc ? desc(techSalesVendorQuotations.createdBy) : techSalesVendorQuotations.createdBy; - case 'updatedBy': - return item.desc ? desc(techSalesVendorQuotations.updatedBy) : techSalesVendorQuotations.updatedBy; - case 'quotationCode': - return item.desc ? desc(techSalesVendorQuotations.quotationCode) : techSalesVendorQuotations.quotationCode; - case 'quotationVersion': - return item.desc ? desc(techSalesVendorQuotations.quotationVersion) : techSalesVendorQuotations.quotationVersion; - case 'rejectionReason': - return item.desc ? desc(techSalesVendorQuotations.rejectionReason) : techSalesVendorQuotations.rejectionReason; - case 'acceptedAt': - return item.desc ? desc(techSalesVendorQuotations.acceptedAt) : techSalesVendorQuotations.acceptedAt; - // 조인된 RFQ 정보 정렬 case 'rfqCode': return item.desc ? desc(techSalesRfqs.rfqCode) : techSalesRfqs.rfqCode; case 'materialCode': @@ -1698,38 +1483,6 @@ export async function getVendorQuotations(input: { return item.desc ? desc(techSalesRfqs.dueDate) : techSalesRfqs.dueDate; case 'rfqStatus': return item.desc ? desc(techSalesRfqs.status) : techSalesRfqs.status; - // 조인된 아이템 정보 정렬 - case 'itemName': - return item.desc ? desc(itemShipbuilding.itemList) : itemShipbuilding.itemList; - // JSON 필드에서 추출된 프로젝트 정보 정렬 - case 'projNm': - return item.desc ? desc(sql`${techSalesRfqs.projectSnapshot}->>'projNm'`) : sql`${techSalesRfqs.projectSnapshot}->>'projNm'`; - case 'projMsrm': - // 척수 (정수 캐스팅) - return item.desc ? desc(sql`(${techSalesRfqs.projectSnapshot}->>'projMsrm')::int`) : sql`(${techSalesRfqs.projectSnapshot}->>'projMsrm')::int`; - case 'ptypeNm': - // 선종명 - return item.desc ? desc(sql`${techSalesRfqs.projectSnapshot}->>'ptypeNm'`) : sql`${techSalesRfqs.projectSnapshot}->>'ptypeNm'`; - case 'pspid': - // 프로젝트 ID - return item.desc ? desc(sql`${techSalesRfqs.projectSnapshot}->>'pspid'`) : sql`${techSalesRfqs.projectSnapshot}->>'pspid'`; - case 'sector': - // 섹터 - return item.desc ? desc(sql`${techSalesRfqs.projectSnapshot}->>'sector'`) : sql`${techSalesRfqs.projectSnapshot}->>'sector'`; - case 'kunnrNm': - // 고객명 - return item.desc ? desc(sql`${techSalesRfqs.projectSnapshot}->>'kunnrNm'`) : sql`${techSalesRfqs.projectSnapshot}->>'kunnrNm'`; - // 계산된 필드 정렬 - case 'attachmentCount': - return item.desc ? desc(sql`( - SELECT COUNT(*) - FROM tech_sales_attachments - WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id} - )`) : sql`( - SELECT COUNT(*) - FROM tech_sales_attachments - WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id} - )`; default: return item.desc ? desc(techSalesVendorQuotations.updatedAt) : techSalesVendorQuotations.updatedAt; } @@ -1761,10 +1514,11 @@ export async function getVendorQuotations(input: { materialCode: techSalesRfqs.materialCode, dueDate: techSalesRfqs.dueDate, rfqStatus: techSalesRfqs.status, - // 아이템 정보 - itemName: itemShipbuilding.itemList, - // 프로젝트 정보 (JSON에서 추출) - projNm: sql<string>`${techSalesRfqs.projectSnapshot}->>'projNm'`, + description: techSalesRfqs.description, + // 프로젝트 정보 (직접 조인) + projNm: biddingProjects.projNm, + // 아이템 정보 추가 (임시로 description 사용) + // itemName: techSalesRfqs.description, // 첨부파일 개수 attachmentCount: sql<number>`( SELECT COUNT(*) @@ -1774,7 +1528,7 @@ export async function getVendorQuotations(input: { }) .from(techSalesVendorQuotations) .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id)) - .leftJoin(itemShipbuilding, eq(techSalesRfqs.itemShipbuildingId, itemShipbuilding.id)) + .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id)) .where(finalWhere) .orderBy(...orderBy) .limit(limit) @@ -1785,7 +1539,7 @@ export async function getVendorQuotations(input: { .select({ count: sql<number>`count(*)` }) .from(techSalesVendorQuotations) .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id)) - .leftJoin(itemShipbuilding, eq(techSalesRfqs.itemShipbuildingId, itemShipbuilding.id)) + .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id)) .where(finalWhere); const total = totalResult[0]?.count || 0; @@ -1811,17 +1565,26 @@ export async function getVendorQuotations(input: { /** * 벤더용 기술영업 견적서 상태별 개수 조회 */ -export async function getQuotationStatusCounts(vendorId: string) { +export async function getQuotationStatusCounts(vendorId: string, rfqType?: "SHIP" | "TOP" | "HULL") { return unstable_cache( async () => { try { - const result = await db + const query = db .select({ status: techSalesVendorQuotations.status, count: sql<number>`count(*)`, }) .from(techSalesVendorQuotations) - .where(eq(techSalesVendorQuotations.vendorId, parseInt(vendorId))) + .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id)); + + // 조건 설정 + const conditions = [eq(techSalesVendorQuotations.vendorId, parseInt(vendorId))]; + if (rfqType) { + conditions.push(eq(techSalesRfqs.rfqType, rfqType)); + } + + const result = await query + .where(and(...conditions)) .groupBy(techSalesVendorQuotations.status); return { data: result, error: null }; @@ -1870,21 +1633,21 @@ export async function acceptTechSalesVendorQuotation(quotationId: number) { }) .where(eq(techSalesVendorQuotations.id, quotationId)) - // 3. 같은 RFQ의 다른 견적들을 Rejected로 변경 - await tx - .update(techSalesVendorQuotations) - .set({ - status: "Rejected", - rejectionReason: "다른 벤더가 선택됨", - updatedAt: new Date(), - }) - .where( - and( - eq(techSalesVendorQuotations.rfqId, quotation.rfqId), - ne(techSalesVendorQuotations.id, quotationId), - eq(techSalesVendorQuotations.status, "Submitted") - ) - ) + // // 3. 같은 RFQ의 다른 견적들을 Rejected로 변경 + // await tx + // .update(techSalesVendorQuotations) + // .set({ + // status: "Rejected", + // rejectionReason: "다른 벤더가 선택됨", + // updatedAt: new Date(), + // }) + // .where( + // and( + // eq(techSalesVendorQuotations.rfqId, quotation.rfqId), + // ne(techSalesVendorQuotations.id, quotationId), + // eq(techSalesVendorQuotations.status, "Submitted") + // ) + // ) // 4. RFQ 상태를 Closed로 변경 await tx @@ -1904,27 +1667,27 @@ export async function acceptTechSalesVendorQuotation(quotationId: number) { console.error("벤더 견적 선택 알림 메일 발송 실패:", error); }); - // 거절된 견적들에 대한 알림 메일 발송 - 트랜잭션 완료 후 별도로 처리 - setTimeout(async () => { - try { - const rejectedQuotations = await db.query.techSalesVendorQuotations.findMany({ - where: and( - eq(techSalesVendorQuotations.rfqId, result.rfqId), - ne(techSalesVendorQuotations.id, quotationId), - eq(techSalesVendorQuotations.status, "Rejected") - ), - columns: { id: true } - }); - - for (const rejectedQuotation of rejectedQuotations) { - sendQuotationRejectedNotification(rejectedQuotation.id).catch(error => { - console.error("벤더 견적 거절 알림 메일 발송 실패:", error); - }); - } - } catch (error) { - console.error("거절된 견적 알림 메일 발송 중 오류:", error); - } - }, 1000); // 1초 후 실행 + // // 거절된 견적들에 대한 알림 메일 발송 - 트랜잭션 완료 후 별도로 처리 + // setTimeout(async () => { + // try { + // const rejectedQuotations = await db.query.techSalesVendorQuotations.findMany({ + // where: and( + // eq(techSalesVendorQuotations.rfqId, result.rfqId), + // ne(techSalesVendorQuotations.id, quotationId), + // eq(techSalesVendorQuotations.status, "Rejected") + // ), + // columns: { id: true } + // }); + + // for (const rejectedQuotation of rejectedQuotations) { + // sendQuotationRejectedNotification(rejectedQuotation.id).catch(error => { + // console.error("벤더 견적 거절 알림 메일 발송 실패:", error); + // }); + // } + // } catch (error) { + // console.error("거절된 견적 알림 메일 발송 중 오류:", error); + // } + // }, 1000); // 1초 후 실행 // 캐시 무효화 revalidateTag("techSalesVendorQuotations") @@ -1951,44 +1714,44 @@ export async function acceptTechSalesVendorQuotation(quotationId: number) { } } -/** - * 기술영업 벤더 견적 거절 - */ -export async function rejectTechSalesVendorQuotation(quotationId: number, rejectionReason?: string) { - try { - const result = await db - .update(techSalesVendorQuotations) - .set({ - status: "Rejected", - rejectionReason: rejectionReason || "기술영업 담당자에 의해 거절됨", - updatedAt: new Date(), - }) - .where(eq(techSalesVendorQuotations.id, quotationId)) - .returning() - - if (result.length === 0) { - throw new Error("견적을 찾을 수 없습니다") - } - - // 메일 발송 (백그라운드에서 실행) - sendQuotationRejectedNotification(quotationId).catch(error => { - console.error("벤더 견적 거절 알림 메일 발송 실패:", error); - }); - - // 캐시 무효화 - revalidateTag("techSalesVendorQuotations") - revalidateTag(`techSalesRfq-${result[0].rfqId}`) - revalidateTag(`vendor-${result[0].vendorId}-quotations`) - - return { success: true, data: result[0] } - } catch (error) { - console.error("벤더 견적 거절 오류:", error) - return { - success: false, - error: error instanceof Error ? error.message : "벤더 견적 거절에 실패했습니다" - } - } -} +// /** +// * 기술영업 벤더 견적 거절 +// */ +// export async function rejectTechSalesVendorQuotation(quotationId: number, rejectionReason?: string) { +// // try { +// // const result = await db +// // .update(techSalesVendorQuotations) +// // .set({ +// // status: "Rejected" as any, +// // rejectionReason: rejectionReason || "기술영업 담당자에 의해 거절됨", +// // updatedAt: new Date(), +// // }) +// // .where(eq(techSalesVendorQuotations.id, quotationId)) +// // .returning() + +// // if (result.length === 0) { +// // throw new Error("견적을 찾을 수 없습니다") +// // } + +// // // 메일 발송 (백그라운드에서 실행) +// // sendQuotationRejectedNotification(quotationId).catch(error => { +// // console.error("벤더 견적 거절 알림 메일 발송 실패:", error); +// // }); + +// // // 캐시 무효화 +// // revalidateTag("techSalesVendorQuotations") +// // revalidateTag(`techSalesRfq-${result[0].rfqId}`) +// // revalidateTag(`vendor-${result[0].vendorId}-quotations`) + +// // return { success: true, data: result[0] } +// // } catch (error) { +// // console.error("벤더 견적 거절 오류:", error) +// // return { +// // success: false, +// // error: error instanceof Error ? error.message : "벤더 견적 거절에 실패했습니다" +// // } +// // } +// } /** * 기술영업 RFQ 첨부파일 생성 (파일 업로드) @@ -2288,13 +2051,12 @@ export async function processTechSalesRfqAttachments(params: { */ export async function sendQuotationSubmittedNotificationToVendor(quotationId: number) { try { - // 견적서 정보 조회 + // 견적서 정보 조회 (projectSeries 조인 추가) const quotation = await db.query.techSalesVendorQuotations.findFirst({ where: eq(techSalesVendorQuotations.id, quotationId), with: { rfq: { with: { - itemShipbuilding: true, biddingProject: true, createdByUser: { columns: { @@ -2341,12 +2103,16 @@ export async function sendQuotationSubmittedNotificationToVendor(quotationId: nu return { success: false, error: "벤더 이메일 주소가 없습니다" }; } - // 프로젝트 정보 준비 - const projectInfo = (quotation.rfq.projectSnapshot as Record<string, unknown>) || {}; + // 프로젝트 시리즈 정보 조회 + const seriesData = quotation.rfq.biddingProject?.pspid + ? await db.query.projectSeries.findMany({ + where: eq(projectSeries.pspid, quotation.rfq.biddingProject.pspid) + }) + : []; // 시리즈 정보 처리 - const seriesInfo = quotation.rfq.seriesSnapshot ? quotation.rfq.seriesSnapshot.map((series: SeriesSnapshot) => ({ - sersNo: series.sersNo, + const seriesInfo = seriesData.map(series => ({ + sersNo: series.sersNo?.toString() || '', klQuarter: series.klDt ? formatDateToQuarter(series.klDt) : '', scDt: series.scDt, lcDt: series.lcDt, @@ -2355,7 +2121,7 @@ export async function sendQuotationSubmittedNotificationToVendor(quotationId: nu dockNm: series.dockNm, projNo: series.projNo, post1: series.post1, - })) : []; + })); // 이메일 컨텍스트 구성 const emailContext = { @@ -2371,9 +2137,9 @@ export async function sendQuotationSubmittedNotificationToVendor(quotationId: nu rfq: { id: quotation.rfq.id, code: quotation.rfq.rfqCode, - title: quotation.rfq.itemShipbuilding?.itemList || '', - projectCode: projectInfo.pspid || quotation.rfq.biddingProject?.pspid || '', - projectName: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '', + 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, @@ -2384,12 +2150,12 @@ export async function sendQuotationSubmittedNotificationToVendor(quotationId: nu name: quotation.vendor.vendorName, }, project: { - name: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '', - sector: projectInfo.sector || quotation.rfq.biddingProject?.sector || '', - shipCount: projectInfo.projMsrm || 0, - ownerName: projectInfo.kunnrNm || '', - className: projectInfo.cls1Nm || '', - shipModelName: projectInfo.pmodelNm || '', + 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 || '', + shipModelName: quotation.rfq.biddingProject?.pmodelNm || '', }, series: seriesInfo, manager: { @@ -2404,7 +2170,7 @@ export async function sendQuotationSubmittedNotificationToVendor(quotationId: nu // 이메일 발송 await sendEmail({ to: vendorEmails, - subject: `[견적 제출 확인] ${quotation.rfq.rfqCode} - ${quotation.rfq.itemShipbuilding?.itemList || '견적 요청'}`, + subject: `[견적 제출 확인] ${quotation.rfq.rfqCode} - 견적 요청`, template: 'tech-sales-quotation-submitted-vendor-ko', context: emailContext, }); @@ -2428,7 +2194,6 @@ export async function sendQuotationSubmittedNotificationToManager(quotationId: n with: { rfq: { with: { - itemShipbuilding: true, biddingProject: true, createdByUser: { columns: { @@ -2460,12 +2225,16 @@ export async function sendQuotationSubmittedNotificationToManager(quotationId: n return { success: false, error: "담당자 이메일 주소가 없습니다" }; } - // 프로젝트 정보 준비 - const projectInfo = (quotation.rfq.projectSnapshot as Record<string, unknown>) || {}; + // 프로젝트 시리즈 정보 조회 + const seriesData = quotation.rfq.biddingProject?.pspid + ? await db.query.projectSeries.findMany({ + where: eq(projectSeries.pspid, quotation.rfq.biddingProject.pspid) + }) + : []; // 시리즈 정보 처리 - const seriesInfo = quotation.rfq.seriesSnapshot ? quotation.rfq.seriesSnapshot.map((series: SeriesSnapshot) => ({ - sersNo: series.sersNo, + const seriesInfo = seriesData.map(series => ({ + sersNo: series.sersNo?.toString() || '', klQuarter: series.klDt ? formatDateToQuarter(series.klDt) : '', scDt: series.scDt, lcDt: series.lcDt, @@ -2474,7 +2243,7 @@ export async function sendQuotationSubmittedNotificationToManager(quotationId: n dockNm: series.dockNm, projNo: series.projNo, post1: series.post1, - })) : []; + })); // 이메일 컨텍스트 구성 const emailContext = { @@ -2490,9 +2259,9 @@ export async function sendQuotationSubmittedNotificationToManager(quotationId: n rfq: { id: quotation.rfq.id, code: quotation.rfq.rfqCode, - title: quotation.rfq.itemShipbuilding?.itemList || '', - projectCode: projectInfo.pspid || quotation.rfq.biddingProject?.pspid || '', - projectName: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '', + 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, @@ -2503,12 +2272,12 @@ export async function sendQuotationSubmittedNotificationToManager(quotationId: n name: quotation.vendor.vendorName, }, project: { - name: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '', - sector: projectInfo.sector || quotation.rfq.biddingProject?.sector || '', - shipCount: projectInfo.projMsrm || 0, - ownerName: projectInfo.kunnrNm || '', - className: projectInfo.cls1Nm || '', - shipModelName: projectInfo.pmodelNm || '', + 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 || '', + shipModelName: quotation.rfq.biddingProject?.pmodelNm || '', }, series: seriesInfo, manager: { @@ -2547,7 +2316,6 @@ export async function sendQuotationAcceptedNotification(quotationId: number) { with: { rfq: { with: { - itemShipbuilding: true, biddingProject: true, createdByUser: { columns: { @@ -2594,12 +2362,16 @@ export async function sendQuotationAcceptedNotification(quotationId: number) { return { success: false, error: "벤더 이메일 주소가 없습니다" }; } - // 프로젝트 정보 준비 - const projectInfo = (quotation.rfq.projectSnapshot as Record<string, unknown>) || {}; + // 프로젝트 시리즈 정보 조회 + const seriesData = quotation.rfq.biddingProject?.pspid + ? await db.query.projectSeries.findMany({ + where: eq(projectSeries.pspid, quotation.rfq.biddingProject.pspid) + }) + : []; // 시리즈 정보 처리 - const seriesInfo = quotation.rfq.seriesSnapshot ? quotation.rfq.seriesSnapshot.map((series: SeriesSnapshot) => ({ - sersNo: series.sersNo, + const seriesInfo = seriesData.map(series => ({ + sersNo: series.sersNo?.toString() || '', klQuarter: series.klDt ? formatDateToQuarter(series.klDt) : '', scDt: series.scDt, lcDt: series.lcDt, @@ -2608,7 +2380,7 @@ export async function sendQuotationAcceptedNotification(quotationId: number) { dockNm: series.dockNm, projNo: series.projNo, post1: series.post1, - })) : []; + })); // 이메일 컨텍스트 구성 const emailContext = { @@ -2624,9 +2396,9 @@ export async function sendQuotationAcceptedNotification(quotationId: number) { rfq: { id: quotation.rfq.id, code: quotation.rfq.rfqCode, - title: quotation.rfq.itemShipbuilding?.itemList || '', - projectCode: projectInfo.pspid || quotation.rfq.biddingProject?.pspid || '', - projectName: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '', + 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, @@ -2637,12 +2409,12 @@ export async function sendQuotationAcceptedNotification(quotationId: number) { name: quotation.vendor.vendorName, }, project: { - name: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '', - sector: projectInfo.sector || quotation.rfq.biddingProject?.sector || '', - shipCount: projectInfo.projMsrm || 0, - ownerName: projectInfo.kunnrNm || '', - className: projectInfo.cls1Nm || '', - shipModelName: projectInfo.pmodelNm || '', + 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 || '', + shipModelName: quotation.rfq.biddingProject?.pmodelNm || '', }, series: seriesInfo, manager: { @@ -2681,7 +2453,6 @@ export async function sendQuotationRejectedNotification(quotationId: number) { with: { rfq: { with: { - itemShipbuilding: true, biddingProject: true, createdByUser: { columns: { @@ -2728,12 +2499,16 @@ export async function sendQuotationRejectedNotification(quotationId: number) { return { success: false, error: "벤더 이메일 주소가 없습니다" }; } - // 프로젝트 정보 준비 - const projectInfo = (quotation.rfq.projectSnapshot as Record<string, unknown>) || {}; + // 프로젝트 시리즈 정보 조회 + const seriesData = quotation.rfq.biddingProject?.pspid + ? await db.query.projectSeries.findMany({ + where: eq(projectSeries.pspid, quotation.rfq.biddingProject.pspid) + }) + : []; // 시리즈 정보 처리 - const seriesInfo = quotation.rfq.seriesSnapshot ? quotation.rfq.seriesSnapshot.map((series: SeriesSnapshot) => ({ - sersNo: series.sersNo, + const seriesInfo = seriesData.map(series => ({ + sersNo: series.sersNo?.toString() || '', klQuarter: series.klDt ? formatDateToQuarter(series.klDt) : '', scDt: series.scDt, lcDt: series.lcDt, @@ -2742,7 +2517,7 @@ export async function sendQuotationRejectedNotification(quotationId: number) { dockNm: series.dockNm, projNo: series.projNo, post1: series.post1, - })) : []; + })); // 이메일 컨텍스트 구성 const emailContext = { @@ -2758,9 +2533,9 @@ export async function sendQuotationRejectedNotification(quotationId: number) { rfq: { id: quotation.rfq.id, code: quotation.rfq.rfqCode, - title: quotation.rfq.itemShipbuilding?.itemList || '', - projectCode: projectInfo.pspid || quotation.rfq.biddingProject?.pspid || '', - projectName: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '', + 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, @@ -2771,12 +2546,12 @@ export async function sendQuotationRejectedNotification(quotationId: number) { name: quotation.vendor.vendorName, }, project: { - name: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '', - sector: projectInfo.sector || quotation.rfq.biddingProject?.sector || '', - shipCount: projectInfo.projMsrm || 0, - ownerName: projectInfo.kunnrNm || '', - className: projectInfo.cls1Nm || '', - shipModelName: projectInfo.pmodelNm || '', + 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 || '', + shipModelName: quotation.rfq.biddingProject?.pmodelNm || '', }, series: seriesInfo, manager: { @@ -2791,7 +2566,7 @@ export async function sendQuotationRejectedNotification(quotationId: number) { // 이메일 발송 await sendEmail({ to: vendorEmails, - subject: `[견적 검토 결과] ${quotation.rfq.rfqCode} - 견적 검토 결과를 안내드립니다`, + subject: `[견적 거절 알림] ${quotation.rfq.rfqCode} - 견적 결과 안내`, template: 'tech-sales-quotation-rejected-ko', context: emailContext, }); @@ -2977,4 +2752,787 @@ export async function markTechSalesMessagesAsRead(rfqId: number, vendorId?: numb console.error('techSales 메시지 읽음 표시 오류:', error) throw error } +} + +/** + * 기술영업 조선 RFQ 생성 (1:N 관계) + */ +export async function createTechSalesShipRfq(input: { + biddingProjectId: number; + itemIds: number[]; // 조선 아이템 ID 배열 + dueDate: Date; + description?: string; + createdBy: number; +}) { + unstable_noStore(); + console.log('🔍 createTechSalesShipRfq 호출됨:', 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 코드 생성 (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" }); +} + +/** + * 조선 대시보드 전용 조회 함수 + */ +export async function getTechSalesShipDashboardWithJoin(input: { + search?: string; + filters?: Filter<typeof techSalesRfqs>[]; + sort?: { id: string; desc: boolean }[]; + page: number; + perPage: number; +}) { + return getTechSalesDashboardWithJoin({ ...input, rfqType: "SHIP" }); +} + +/** + * 해양 TOP 대시보드 전용 조회 함수 + */ +export async function getTechSalesTopDashboardWithJoin(input: { + search?: string; + filters?: Filter<typeof techSalesRfqs>[]; + sort?: { id: string; desc: boolean }[]; + page: number; + perPage: number; +}) { + return getTechSalesDashboardWithJoin({ ...input, rfqType: "TOP" }); +} + +/** + * 해양 HULL 대시보드 전용 조회 함수 + */ +export async function getTechSalesHullDashboardWithJoin(input: { + search?: string; + filters?: Filter<typeof techSalesRfqs>[]; + sort?: { id: string; desc: boolean }[]; + page: number; + perPage: number; +}) { + return getTechSalesDashboardWithJoin({ ...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에 벤더 추가 (techVendors 기반) + */ +export async function addTechVendorToTechSalesRfq(input: { + rfqId: number; + vendorId: number; + createdBy: 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: "이미 추가된 벤더입니다." }; + } + + // 새로운 견적서 레코드 생성 + const [quotation] = await tx + .insert(techSalesVendorQuotations) + .values({ + rfqId: input.rfqId, + vendorId: input.vendorId, + status: "Draft", + createdBy: input.createdBy, + updatedBy: input.createdBy, + }) + .returning({ id: techSalesVendorQuotations.id }); + + // 캐시 무효화 + revalidateTag("techSalesRfqs"); + + return { data: quotation, error: null }; + }); + } catch (err) { + console.error("Error adding tech vendor to RFQ:", err); + return { data: null, error: getErrorMessage(err) }; + } +} + +/** + * 기술영업 RFQ에 여러 벤더 추가 (techVendors 기반) + */ +export async function addTechVendorsToTechSalesRfq(input: { + rfqId: number; + vendorIds: number[]; + createdBy: number; +}) { + unstable_noStore(); + + try { + return await db.transaction(async (tx) => { + const results = []; + + 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) { + // 새로운 견적서 레코드 생성 + const [quotation] = await tx + .insert(techSalesVendorQuotations) + .values({ + rfqId: input.rfqId, + vendorId: vendorId, + status: "Draft", + createdBy: input.createdBy, + updatedBy: input.createdBy, + }) + .returning({ id: techSalesVendorQuotations.id }); + + results.push(quotation); + } + } + + // 캐시 무효화 + revalidateTag("techSalesRfqs"); + + return { data: results, error: null }; + }); + } 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에 존재하지 않습니다." }; + } + + // Draft 상태가 아닌 경우 삭제 불가 + if (existingQuotation.status !== "Draft") { + return { data: null, error: "Draft 상태의 벤더만 삭제할 수 있습니다." }; + } + + // 해당 벤더의 견적서 삭제 + 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; + } + + // Draft 상태가 아닌 경우 삭제 불가 + if (existingQuotation.status !== "Draft") { + errors.push(`벤더 ID ${vendorId}는 Draft 상태가 아니므로 삭제할 수 없습니다.`); + 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" ? "SHIP" : + rfqType === "TOP" ? "OFFSHORE_TOP" : + rfqType === "HULL" ? "OFFSHORE_HULL" : null; + + const whereConditions = [ + eq(techVendors.status, "ACTIVE"), + or( + ilike(techVendors.vendorName, `%${searchTerm}%`), + ilike(techVendors.vendorCode, `%${searchTerm}%`) + ) + ]; + + // RFQ 타입이 지정된 경우 벤더 타입 필터링 추가 + if (vendorTypeFilter) { + whereConditions.push(eq(techVendors.techVendorType, 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)); + } }
\ No newline at end of file |
