summaryrefslogtreecommitdiff
path: root/lib/techsales-rfq/service.ts
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-06-15 04:40:22 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-06-15 04:40:22 +0000
commitc5002d77087b256599b174ada611621657fcc523 (patch)
tree515aab399709755cf3d57d9927e2d81467dea700 /lib/techsales-rfq/service.ts
parent9f3b8915ab20f177edafd3c4a4cc1ca0da0fc766 (diff)
(최겸) 기술영업 조선,해양RFQ 수정
Diffstat (limited to 'lib/techsales-rfq/service.ts')
-rw-r--r--lib/techsales-rfq/service.ts1958
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