summaryrefslogtreecommitdiff
path: root/lib/b-rfq/service.ts
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-08 11:23:40 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-08 11:23:40 +0000
commitb84621f9b2b7161a5ad4f0b194264e9df3e65dbf (patch)
treece5ec30b3d1e5104a3a2d942c71973779436783b /lib/b-rfq/service.ts
parent97936ddf280c56a4f122dedcb8dc389d0d2e63a2 (diff)
(대표님) 20250708 미반영분 커밋
Diffstat (limited to 'lib/b-rfq/service.ts')
-rw-r--r--lib/b-rfq/service.ts2025
1 files changed, 1192 insertions, 833 deletions
diff --git a/lib/b-rfq/service.ts b/lib/b-rfq/service.ts
index 5a65872b..4def634b 100644
--- a/lib/b-rfq/service.ts
+++ b/lib/b-rfq/service.ts
@@ -1,17 +1,21 @@
'use server'
-import { revalidateTag, unstable_cache ,unstable_noStore} from "next/cache"
-import {count, desc, asc, and, or, gte, lte, ilike, eq, inArray, sql } from "drizzle-orm"
+import { revalidatePath, revalidateTag, unstable_cache, unstable_noStore } from "next/cache"
+import { count, desc, asc, and, or, gte, lte, ilike, eq, inArray, sql } from "drizzle-orm"
import { filterColumns } from "@/lib/filter-columns"
import db from "@/db/db"
-import { vendorResponseDetailView,
+import {
+ vendorResponseDetailView,
attachmentRevisionHistoryView,
rfqProgressSummaryView,
- vendorResponseAttachmentsEnhanced ,Incoterm, RfqDashboardView, Vendor, VendorAttachmentResponse, bRfqAttachmentRevisions, bRfqs, bRfqsAttachments, incoterms, initialRfq, initialRfqDetailView, projects, users, vendorAttachmentResponses, vendors,
- vendorResponseAttachmentsB} from "@/db/schema" // 실제 스키마 import 경로에 맞게 수정
+ vendorResponseAttachmentsEnhanced, Incoterm, RfqDashboardView, Vendor, VendorAttachmentResponse, bRfqAttachmentRevisions, bRfqs, bRfqsAttachments, incoterms, initialRfq, initialRfqDetailView, projects, users, vendorAttachmentResponses, vendors,
+ vendorResponseAttachmentsB,
+ finalRfq,
+ finalRfqDetailView
+} from "@/db/schema" // 실제 스키마 import 경로에 맞게 수정
import { rfqDashboardView } from "@/db/schema" // 뷰 import
import type { SQL } from "drizzle-orm"
-import { AttachmentRecord, BulkEmailInput, CreateRfqInput, DeleteAttachmentsInput, GetInitialRfqDetailSchema, GetRFQDashboardSchema, GetRfqAttachmentsSchema, GetVendorResponsesSchema, RemoveInitialRfqsSchema, RequestRevisionResult, ResponseStatus, UpdateInitialRfqSchema, VendorRfqResponseSummary, attachmentRecordSchema, bulkEmailSchema, createRfqServerSchema, deleteAttachmentsSchema, removeInitialRfqsSchema, requestRevisionSchema, updateInitialRfqSchema } from "./validations"
+import { AttachmentRecord, BulkEmailInput, CreateRfqInput, DeleteAttachmentsInput, GetInitialRfqDetailSchema, GetRFQDashboardSchema, GetRfqAttachmentsSchema, GetVendorResponsesSchema, RemoveInitialRfqsSchema, RequestRevisionResult, ResponseStatus, ShortListConfirmInput, UpdateInitialRfqSchema, VendorRfqResponseSummary, attachmentRecordSchema, bulkEmailSchema, createRfqServerSchema, deleteAttachmentsSchema, removeInitialRfqsSchema, requestRevisionSchema, updateInitialRfqSchema, shortListConfirmSchema, GetFinalRfqDetailSchema } from "./validations"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/app/api/auth/[...nextauth]/route"
import { unlink } from "fs/promises"
@@ -21,7 +25,7 @@ import { sendEmail } from "../mail/sendEmail"
import { RfqType } from "../rfqs/validations"
const tag = {
- initialRfqDetail:"initial-rfq",
+ initialRfqDetail: "initial-rfq",
rfqDashboard: 'rfq-dashboard',
rfq: (id: number) => `rfq-${id}`,
rfqAttachments: (rfqId: number) => `rfq-attachments-${rfqId}`,
@@ -34,122 +38,122 @@ const tag = {
export async function getRFQDashboard(input: GetRFQDashboardSchema) {
- try {
- const offset = (input.page - 1) * input.perPage;
-
- const rfqFilterMapping = createRFQFilterMapping();
- const joinedTables = getRFQJoinedTables();
-
- console.log(input, "견적 인풋")
-
- // 1) 고급 필터 조건
- let advancedWhere: SQL<unknown> | undefined = undefined;
- if (input.filters && input.filters.length > 0) {
- advancedWhere = filterColumns({
- table: rfqDashboardView,
- filters: input.filters,
- joinOperator: input.joinOperator || 'and',
- joinedTables,
- customColumnMapping: rfqFilterMapping,
- });
- }
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ const rfqFilterMapping = createRFQFilterMapping();
+ const joinedTables = getRFQJoinedTables();
+
+ console.log(input, "견적 인풋")
+
+ // 1) 고급 필터 조건
+ let advancedWhere: SQL<unknown> | undefined = undefined;
+ if (input.filters && input.filters.length > 0) {
+ advancedWhere = filterColumns({
+ table: rfqDashboardView,
+ filters: input.filters,
+ joinOperator: input.joinOperator || 'and',
+ joinedTables,
+ customColumnMapping: rfqFilterMapping,
+ });
+ }
- // 2) 기본 필터 조건
- let basicWhere: SQL<unknown> | undefined = undefined;
- if (input.basicFilters && input.basicFilters.length > 0) {
- basicWhere = filterColumns({
- table: rfqDashboardView,
- filters: input.basicFilters,
- joinOperator: input.basicJoinOperator || 'and',
- joinedTables,
- customColumnMapping: rfqFilterMapping,
- });
- }
+ // 2) 기본 필터 조건
+ let basicWhere: SQL<unknown> | undefined = undefined;
+ if (input.basicFilters && input.basicFilters.length > 0) {
+ basicWhere = filterColumns({
+ table: rfqDashboardView,
+ filters: input.basicFilters,
+ joinOperator: input.basicJoinOperator || 'and',
+ joinedTables,
+ customColumnMapping: rfqFilterMapping,
+ });
+ }
- // 3) 글로벌 검색 조건
- let globalWhere: SQL<unknown> | undefined = undefined;
- if (input.search) {
- const s = `%${input.search}%`;
+ // 3) 글로벌 검색 조건
+ let globalWhere: SQL<unknown> | undefined = undefined;
+ if (input.search) {
+ const s = `%${input.search}%`;
- const validSearchConditions: SQL<unknown>[] = [];
+ const validSearchConditions: SQL<unknown>[] = [];
- const rfqCodeCondition = ilike(rfqDashboardView.rfqCode, s);
- if (rfqCodeCondition) validSearchConditions.push(rfqCodeCondition);
+ const rfqCodeCondition = ilike(rfqDashboardView.rfqCode, s);
+ if (rfqCodeCondition) validSearchConditions.push(rfqCodeCondition);
- const descriptionCondition = ilike(rfqDashboardView.description, s);
- if (descriptionCondition) validSearchConditions.push(descriptionCondition);
+ const descriptionCondition = ilike(rfqDashboardView.description, s);
+ if (descriptionCondition) validSearchConditions.push(descriptionCondition);
- const projectNameCondition = ilike(rfqDashboardView.projectName, s);
- if (projectNameCondition) validSearchConditions.push(projectNameCondition);
+ const projectNameCondition = ilike(rfqDashboardView.projectName, s);
+ if (projectNameCondition) validSearchConditions.push(projectNameCondition);
- const projectCodeCondition = ilike(rfqDashboardView.projectCode, s);
- if (projectCodeCondition) validSearchConditions.push(projectCodeCondition);
+ const projectCodeCondition = ilike(rfqDashboardView.projectCode, s);
+ if (projectCodeCondition) validSearchConditions.push(projectCodeCondition);
- const picNameCondition = ilike(rfqDashboardView.picName, s);
- if (picNameCondition) validSearchConditions.push(picNameCondition);
+ const picNameCondition = ilike(rfqDashboardView.picName, s);
+ if (picNameCondition) validSearchConditions.push(picNameCondition);
- const packageNoCondition = ilike(rfqDashboardView.packageNo, s);
- if (packageNoCondition) validSearchConditions.push(packageNoCondition);
+ const packageNoCondition = ilike(rfqDashboardView.packageNo, s);
+ if (packageNoCondition) validSearchConditions.push(packageNoCondition);
- const packageNameCondition = ilike(rfqDashboardView.packageName, s);
- if (packageNameCondition) validSearchConditions.push(packageNameCondition);
+ const packageNameCondition = ilike(rfqDashboardView.packageName, s);
+ if (packageNameCondition) validSearchConditions.push(packageNameCondition);
- if (validSearchConditions.length > 0) {
- globalWhere = or(...validSearchConditions);
- }
- }
+ if (validSearchConditions.length > 0) {
+ globalWhere = or(...validSearchConditions);
+ }
+ }
- // 6) 최종 WHERE 조건 생성
- const whereConditions: SQL<unknown>[] = [];
+ // 6) 최종 WHERE 조건 생성
+ const whereConditions: SQL<unknown>[] = [];
- if (advancedWhere) whereConditions.push(advancedWhere);
- if (basicWhere) whereConditions.push(basicWhere);
- if (globalWhere) whereConditions.push(globalWhere);
+ if (advancedWhere) whereConditions.push(advancedWhere);
+ if (basicWhere) whereConditions.push(basicWhere);
+ if (globalWhere) whereConditions.push(globalWhere);
- const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
+ const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
- // 7) 전체 데이터 수 조회
- const totalResult = await db
- .select({ count: count() })
- .from(rfqDashboardView)
- .where(finalWhere);
+ // 7) 전체 데이터 수 조회
+ const totalResult = await db
+ .select({ count: count() })
+ .from(rfqDashboardView)
+ .where(finalWhere);
- const total = totalResult[0]?.count || 0;
+ const total = totalResult[0]?.count || 0;
- if (total === 0) {
- return { data: [], pageCount: 0, total: 0 };
- }
+ if (total === 0) {
+ return { data: [], pageCount: 0, total: 0 };
+ }
- console.log(total)
+ console.log(total)
- // 8) 정렬 및 페이징 처리된 데이터 조회
- const orderByColumns = input.sort.map((sort) => {
- const column = sort.id as keyof typeof rfqDashboardView.$inferSelect;
- return sort.desc ? desc(rfqDashboardView[column]) : asc(rfqDashboardView[column]);
- });
+ // 8) 정렬 및 페이징 처리된 데이터 조회
+ const orderByColumns = input.sort.map((sort) => {
+ const column = sort.id as keyof typeof rfqDashboardView.$inferSelect;
+ return sort.desc ? desc(rfqDashboardView[column]) : asc(rfqDashboardView[column]);
+ });
- if (orderByColumns.length === 0) {
- orderByColumns.push(desc(rfqDashboardView.createdAt));
- }
+ if (orderByColumns.length === 0) {
+ orderByColumns.push(desc(rfqDashboardView.createdAt));
+ }
+
+ const rfqData = await db
+ .select()
+ .from(rfqDashboardView)
+ .where(finalWhere)
+ .orderBy(...orderByColumns)
+ .limit(input.perPage)
+ .offset(offset);
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data: rfqData, pageCount, total };
+ } catch (err) {
+ console.error("Error in getRFQDashboard:", err);
+ return { data: [], pageCount: 0, total: 0 };
+ }
- const rfqData = await db
- .select()
- .from(rfqDashboardView)
- .where(finalWhere)
- .orderBy(...orderByColumns)
- .limit(input.perPage)
- .offset(offset);
-
- const pageCount = Math.ceil(total / input.perPage);
-
- return { data: rfqData, pageCount, total };
- } catch (err) {
- console.error("Error in getRFQDashboard:", err);
- return { data: [], pageCount: 0, total: 0 };
- }
-
}
// 헬퍼 함수들
@@ -311,142 +315,142 @@ export async function getRfqAttachments(
input: GetRfqAttachmentsSchema,
rfqId: number
) {
- try {
- const offset = (input.page - 1) * input.perPage
+ try {
+ const offset = (input.page - 1) * input.perPage
- // Advanced Filter 처리 (메인 테이블 기준)
- const advancedWhere = filterColumns({
- table: bRfqsAttachments,
- filters: input.filters,
- joinOperator: input.joinOperator,
- })
+ // Advanced Filter 처리 (메인 테이블 기준)
+ const advancedWhere = filterColumns({
+ table: bRfqsAttachments,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ })
- // 전역 검색 (첨부파일 + 리비전 파일명 검색)
- let globalWhere
- if (input.search) {
- const s = `%${input.search}%`
- globalWhere = or(
- ilike(bRfqsAttachments.serialNo, s),
- ilike(bRfqsAttachments.description, s),
- ilike(bRfqsAttachments.currentRevision, s),
- ilike(bRfqAttachmentRevisions.fileName, s),
- ilike(bRfqAttachmentRevisions.originalFileName, s)
- )
- }
+ // 전역 검색 (첨부파일 + 리비전 파일명 검색)
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(
+ ilike(bRfqsAttachments.serialNo, s),
+ ilike(bRfqsAttachments.description, s),
+ ilike(bRfqsAttachments.currentRevision, s),
+ ilike(bRfqAttachmentRevisions.fileName, s),
+ ilike(bRfqAttachmentRevisions.originalFileName, s)
+ )
+ }
- // 기본 필터
- let basicWhere
- if (input.attachmentType.length > 0 || input.fileType.length > 0) {
- basicWhere = and(
- input.attachmentType.length > 0
- ? inArray(bRfqsAttachments.attachmentType, input.attachmentType)
- : undefined,
- input.fileType.length > 0
- ? inArray(bRfqAttachmentRevisions.fileType, input.fileType)
- : undefined
- )
- }
+ // 기본 필터
+ let basicWhere
+ if (input.attachmentType.length > 0 || input.fileType.length > 0) {
+ basicWhere = and(
+ input.attachmentType.length > 0
+ ? inArray(bRfqsAttachments.attachmentType, input.attachmentType)
+ : undefined,
+ input.fileType.length > 0
+ ? inArray(bRfqAttachmentRevisions.fileType, input.fileType)
+ : undefined
+ )
+ }
- // 최종 WHERE 절
- const finalWhere = and(
- eq(bRfqsAttachments.rfqId, rfqId), // RFQ ID 필수 조건
- advancedWhere,
- globalWhere,
- basicWhere
- )
+ // 최종 WHERE 절
+ const finalWhere = and(
+ eq(bRfqsAttachments.rfqId, rfqId), // RFQ ID 필수 조건
+ advancedWhere,
+ globalWhere,
+ basicWhere
+ )
+
+ // 정렬 (메인 테이블 기준)
+ const orderBy = input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(bRfqsAttachments[item.id as keyof typeof bRfqsAttachments]) : asc(bRfqsAttachments[item.id as keyof typeof bRfqsAttachments])
+ )
+ : [desc(bRfqsAttachments.createdAt)]
- // 정렬 (메인 테이블 기준)
- const orderBy = input.sort.length > 0
- ? input.sort.map((item) =>
- item.desc ? desc(bRfqsAttachments[item.id as keyof typeof bRfqsAttachments]) : asc(bRfqsAttachments[item.id as keyof typeof bRfqsAttachments])
+ // 트랜잭션으로 데이터 조회
+ const { data, total } = await db.transaction(async (tx) => {
+ // 메인 데이터 조회 (첨부파일 + 최신 리비전 조인)
+ const data = await tx
+ .select({
+ // 첨부파일 메인 정보
+ id: bRfqsAttachments.id,
+ attachmentType: bRfqsAttachments.attachmentType,
+ serialNo: bRfqsAttachments.serialNo,
+ rfqId: bRfqsAttachments.rfqId,
+ currentRevision: bRfqsAttachments.currentRevision,
+ latestRevisionId: bRfqsAttachments.latestRevisionId,
+ description: bRfqsAttachments.description,
+ createdBy: bRfqsAttachments.createdBy,
+ createdAt: bRfqsAttachments.createdAt,
+ updatedAt: bRfqsAttachments.updatedAt,
+
+ // 최신 리비전 파일 정보
+ fileName: bRfqAttachmentRevisions.fileName,
+ originalFileName: bRfqAttachmentRevisions.originalFileName,
+ filePath: bRfqAttachmentRevisions.filePath,
+ fileSize: bRfqAttachmentRevisions.fileSize,
+ fileType: bRfqAttachmentRevisions.fileType,
+ revisionComment: bRfqAttachmentRevisions.revisionComment,
+
+ // 생성자 정보
+ createdByName: users.name,
+ })
+ .from(bRfqsAttachments)
+ .leftJoin(
+ bRfqAttachmentRevisions,
+ and(
+ eq(bRfqsAttachments.latestRevisionId, bRfqAttachmentRevisions.id),
+ eq(bRfqAttachmentRevisions.isLatest, true)
)
- : [desc(bRfqsAttachments.createdAt)]
-
- // 트랜잭션으로 데이터 조회
- const { data, total } = await db.transaction(async (tx) => {
- // 메인 데이터 조회 (첨부파일 + 최신 리비전 조인)
- const data = await tx
- .select({
- // 첨부파일 메인 정보
- id: bRfqsAttachments.id,
- attachmentType: bRfqsAttachments.attachmentType,
- serialNo: bRfqsAttachments.serialNo,
- rfqId: bRfqsAttachments.rfqId,
- currentRevision: bRfqsAttachments.currentRevision,
- latestRevisionId: bRfqsAttachments.latestRevisionId,
- description: bRfqsAttachments.description,
- createdBy: bRfqsAttachments.createdBy,
- createdAt: bRfqsAttachments.createdAt,
- updatedAt: bRfqsAttachments.updatedAt,
-
- // 최신 리비전 파일 정보
- fileName: bRfqAttachmentRevisions.fileName,
- originalFileName: bRfqAttachmentRevisions.originalFileName,
- filePath: bRfqAttachmentRevisions.filePath,
- fileSize: bRfqAttachmentRevisions.fileSize,
- fileType: bRfqAttachmentRevisions.fileType,
- revisionComment: bRfqAttachmentRevisions.revisionComment,
-
- // 생성자 정보
- createdByName: users.name,
- })
- .from(bRfqsAttachments)
- .leftJoin(
- bRfqAttachmentRevisions,
- and(
- eq(bRfqsAttachments.latestRevisionId, bRfqAttachmentRevisions.id),
- eq(bRfqAttachmentRevisions.isLatest, true)
- )
- )
- .leftJoin(users, eq(bRfqsAttachments.createdBy, users.id))
- .where(finalWhere)
- .orderBy(...orderBy)
- .limit(input.perPage)
- .offset(offset)
-
- // 전체 개수 조회
- const totalResult = await tx
- .select({ count: count() })
- .from(bRfqsAttachments)
- .leftJoin(
- bRfqAttachmentRevisions,
- eq(bRfqsAttachments.latestRevisionId, bRfqAttachmentRevisions.id)
- )
- .where(finalWhere)
-
- const total = totalResult[0]?.count ?? 0
+ )
+ .leftJoin(users, eq(bRfqsAttachments.createdBy, users.id))
+ .where(finalWhere)
+ .orderBy(...orderBy)
+ .limit(input.perPage)
+ .offset(offset)
+
+ // 전체 개수 조회
+ const totalResult = await tx
+ .select({ count: count() })
+ .from(bRfqsAttachments)
+ .leftJoin(
+ bRfqAttachmentRevisions,
+ eq(bRfqsAttachments.latestRevisionId, bRfqAttachmentRevisions.id)
+ )
+ .where(finalWhere)
- return { data, total }
- })
+ const total = totalResult[0]?.count ?? 0
- const pageCount = Math.ceil(total / input.perPage)
+ return { data, total }
+ })
- // 각 첨부파일별 벤더 응답 통계 조회
- const attachmentIds = data.map(item => item.id)
- let responseStatsMap: Record<number, any> = {}
+ const pageCount = Math.ceil(total / input.perPage)
- if (attachmentIds.length > 0) {
- responseStatsMap = await getAttachmentResponseStats(attachmentIds)
- }
+ // 각 첨부파일별 벤더 응답 통계 조회
+ const attachmentIds = data.map(item => item.id)
+ let responseStatsMap: Record<number, any> = {}
- // 통계 데이터 병합
- const dataWithStats = data.map(attachment => ({
- ...attachment,
- responseStats: responseStatsMap[attachment.id] || {
- totalVendors: 0,
- respondedCount: 0,
- pendingCount: 0,
- waivedCount: 0,
- responseRate: 0
- }
- }))
+ if (attachmentIds.length > 0) {
+ responseStatsMap = await getAttachmentResponseStats(attachmentIds)
+ }
- return { data: dataWithStats, pageCount }
- } catch (err) {
- console.error("getRfqAttachments error:", err)
- return { data: [], pageCount: 0 }
+ // 통계 데이터 병합
+ const dataWithStats = data.map(attachment => ({
+ ...attachment,
+ responseStats: responseStatsMap[attachment.id] || {
+ totalVendors: 0,
+ respondedCount: 0,
+ pendingCount: 0,
+ waivedCount: 0,
+ responseRate: 0
}
-
+ }))
+
+ return { data: dataWithStats, pageCount }
+ } catch (err) {
+ console.error("getRfqAttachments error:", err)
+ return { data: [], pageCount: 0 }
+ }
+
}
// 첨부파일별 벤더 응답 통계 조회
@@ -529,7 +533,7 @@ export async function getVendorResponsesForAttachment(
// 2. 각 응답에 대한 파일 정보 가져오기
const responseIds = responses.map(r => r.id);
-
+
let responseFiles: any[] = [];
if (responseIds.length > 0) {
responseFiles = await db
@@ -657,14 +661,14 @@ export async function requestTbe(rfqId: number, attachmentIds?: number[]) {
await db.transaction(async (tx) => {
const [updatedRfq] = await tx
- .update(bRfqs)
- .set({
- status: "TBE started",
- updatedBy: Number(session.user.id),
- updatedAt: new Date(),
- })
- .where(eq(bRfqs.id, rfqId))
- .returning()
+ .update(bRfqs)
+ .set({
+ status: "TBE started",
+ updatedBy: Number(session.user.id),
+ updatedAt: new Date(),
+ })
+ .where(eq(bRfqs.id, rfqId))
+ .returning()
// 각 첨부파일에 대해 벤더 응답 레코드 생성 또는 업데이트
for (const attachment of targetAttachments) {
@@ -673,7 +677,7 @@ export async function requestTbe(rfqId: number, attachmentIds?: number[]) {
}
})
-
+
const attachmentCount = targetAttachments.length
const attachmentList = targetAttachments
.map(a => `${a.serialNo} (${a.currentRevision})`)
@@ -776,7 +780,7 @@ export async function addRfqAttachmentRecord(record: AttachmentRecord) {
return { attachment, revision }
})
- return {
+ return {
success: true,
message: `파일이 성공적으로 등록되었습니다. (시리얼: ${result.attachment.serialNo}, 리비전: Rev.0)`,
attachment: result.attachment,
@@ -884,7 +888,7 @@ export async function addRevisionToAttachment(
return inserted;
});
-
+
return {
success: true,
@@ -903,39 +907,39 @@ export async function addRevisionToAttachment(
// 특정 첨부파일의 모든 리비전 조회
export async function getAttachmentRevisions(attachmentId: number) {
- try {
- const revisions = await db
- .select({
- id: bRfqAttachmentRevisions.id,
- revisionNo: bRfqAttachmentRevisions.revisionNo,
- fileName: bRfqAttachmentRevisions.fileName,
- originalFileName: bRfqAttachmentRevisions.originalFileName,
- filePath: bRfqAttachmentRevisions.filePath,
- fileSize: bRfqAttachmentRevisions.fileSize,
- fileType: bRfqAttachmentRevisions.fileType,
- revisionComment: bRfqAttachmentRevisions.revisionComment,
- isLatest: bRfqAttachmentRevisions.isLatest,
- createdBy: bRfqAttachmentRevisions.createdBy,
- createdAt: bRfqAttachmentRevisions.createdAt,
- createdByName: users.name,
- })
- .from(bRfqAttachmentRevisions)
- .leftJoin(users, eq(bRfqAttachmentRevisions.createdBy, users.id))
- .where(eq(bRfqAttachmentRevisions.attachmentId, attachmentId))
- .orderBy(desc(bRfqAttachmentRevisions.createdAt))
+ try {
+ const revisions = await db
+ .select({
+ id: bRfqAttachmentRevisions.id,
+ revisionNo: bRfqAttachmentRevisions.revisionNo,
+ fileName: bRfqAttachmentRevisions.fileName,
+ originalFileName: bRfqAttachmentRevisions.originalFileName,
+ filePath: bRfqAttachmentRevisions.filePath,
+ fileSize: bRfqAttachmentRevisions.fileSize,
+ fileType: bRfqAttachmentRevisions.fileType,
+ revisionComment: bRfqAttachmentRevisions.revisionComment,
+ isLatest: bRfqAttachmentRevisions.isLatest,
+ createdBy: bRfqAttachmentRevisions.createdBy,
+ createdAt: bRfqAttachmentRevisions.createdAt,
+ createdByName: users.name,
+ })
+ .from(bRfqAttachmentRevisions)
+ .leftJoin(users, eq(bRfqAttachmentRevisions.createdBy, users.id))
+ .where(eq(bRfqAttachmentRevisions.attachmentId, attachmentId))
+ .orderBy(desc(bRfqAttachmentRevisions.createdAt))
- return {
- success: true,
- revisions,
- }
- } catch (error) {
- console.error("getAttachmentRevisions error:", error)
- return {
- success: false,
- message: "리비전 조회 중 오류가 발생했습니다.",
- revisions: [],
- }
- }
+ return {
+ success: true,
+ revisions,
+ }
+ } catch (error) {
+ console.error("getAttachmentRevisions error:", error)
+ return {
+ success: false,
+ message: "리비전 조회 중 오류가 발생했습니다.",
+ revisions: [],
+ }
+ }
}
@@ -1003,7 +1007,7 @@ export async function deleteRfqAttachments(input: DeleteAttachmentsInput) {
}
})
-
+
return {
success: true,
message: `${result.deletedCount}개의 첨부파일이 삭제되었습니다.`,
@@ -1012,7 +1016,7 @@ export async function deleteRfqAttachments(input: DeleteAttachmentsInput) {
} catch (error) {
console.error("deleteRfqAttachments error:", error)
-
+
return {
success: false,
message: error instanceof Error ? error.message : "첨부파일 삭제 중 오류가 발생했습니다.",
@@ -1026,119 +1030,119 @@ export async function deleteRfqAttachments(input: DeleteAttachmentsInput) {
export async function getInitialRfqDetail(input: GetInitialRfqDetailSchema, rfqId?: number) {
- try {
- const offset = (input.page - 1) * input.perPage;
-
- // 1) 고급 필터 조건
- let advancedWhere: SQL<unknown> | undefined = undefined;
- if (input.filters && input.filters.length > 0) {
- advancedWhere = filterColumns({
- table: initialRfqDetailView,
- filters: input.filters,
- joinOperator: input.joinOperator || 'and',
- });
- }
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // 1) 고급 필터 조건
+ let advancedWhere: SQL<unknown> | undefined = undefined;
+ if (input.filters && input.filters.length > 0) {
+ advancedWhere = filterColumns({
+ table: initialRfqDetailView,
+ filters: input.filters,
+ joinOperator: input.joinOperator || 'and',
+ });
+ }
- // 2) 기본 필터 조건
- let basicWhere: SQL<unknown> | undefined = undefined;
- if (input.basicFilters && input.basicFilters.length > 0) {
- basicWhere = filterColumns({
- table: initialRfqDetailView,
- filters: input.basicFilters,
- joinOperator: input.basicJoinOperator || 'and',
- });
- }
+ // 2) 기본 필터 조건
+ let basicWhere: SQL<unknown> | undefined = undefined;
+ if (input.basicFilters && input.basicFilters.length > 0) {
+ basicWhere = filterColumns({
+ table: initialRfqDetailView,
+ filters: input.basicFilters,
+ joinOperator: input.basicJoinOperator || 'and',
+ });
+ }
- let rfqIdWhere: SQL<unknown> | undefined = undefined;
- if (rfqId) {
- rfqIdWhere = eq(initialRfqDetailView.rfqId, rfqId);
- }
+ let rfqIdWhere: SQL<unknown> | undefined = undefined;
+ if (rfqId) {
+ rfqIdWhere = eq(initialRfqDetailView.rfqId, rfqId);
+ }
- // 3) 글로벌 검색 조건
- let globalWhere: SQL<unknown> | undefined = undefined;
- if (input.search) {
- const s = `%${input.search}%`;
+ // 3) 글로벌 검색 조건
+ let globalWhere: SQL<unknown> | undefined = undefined;
+ if (input.search) {
+ const s = `%${input.search}%`;
- const validSearchConditions: SQL<unknown>[] = [];
+ const validSearchConditions: SQL<unknown>[] = [];
- const rfqCodeCondition = ilike(initialRfqDetailView.rfqCode, s);
- if (rfqCodeCondition) validSearchConditions.push(rfqCodeCondition);
+ const rfqCodeCondition = ilike(initialRfqDetailView.rfqCode, s);
+ if (rfqCodeCondition) validSearchConditions.push(rfqCodeCondition);
- const vendorNameCondition = ilike(initialRfqDetailView.vendorName, s);
- if (vendorNameCondition) validSearchConditions.push(vendorNameCondition);
+ const vendorNameCondition = ilike(initialRfqDetailView.vendorName, s);
+ if (vendorNameCondition) validSearchConditions.push(vendorNameCondition);
- const vendorCodeCondition = ilike(initialRfqDetailView.vendorCode, s);
- if (vendorCodeCondition) validSearchConditions.push(vendorCodeCondition);
+ const vendorCodeCondition = ilike(initialRfqDetailView.vendorCode, s);
+ if (vendorCodeCondition) validSearchConditions.push(vendorCodeCondition);
- const vendorCountryCondition = ilike(initialRfqDetailView.vendorCountry, s);
- if (vendorCountryCondition) validSearchConditions.push(vendorCountryCondition);
+ const vendorCountryCondition = ilike(initialRfqDetailView.vendorCountry, s);
+ if (vendorCountryCondition) validSearchConditions.push(vendorCountryCondition);
- const incotermsDescriptionCondition = ilike(initialRfqDetailView.incotermsDescription, s);
- if (incotermsDescriptionCondition) validSearchConditions.push(incotermsDescriptionCondition);
+ const incotermsDescriptionCondition = ilike(initialRfqDetailView.incotermsDescription, s);
+ if (incotermsDescriptionCondition) validSearchConditions.push(incotermsDescriptionCondition);
- const classificationCondition = ilike(initialRfqDetailView.classification, s);
- if (classificationCondition) validSearchConditions.push(classificationCondition);
+ const classificationCondition = ilike(initialRfqDetailView.classification, s);
+ if (classificationCondition) validSearchConditions.push(classificationCondition);
- const sparepartCondition = ilike(initialRfqDetailView.sparepart, s);
- if (sparepartCondition) validSearchConditions.push(sparepartCondition);
+ const sparepartCondition = ilike(initialRfqDetailView.sparepart, s);
+ if (sparepartCondition) validSearchConditions.push(sparepartCondition);
- if (validSearchConditions.length > 0) {
- globalWhere = or(...validSearchConditions);
- }
- }
+ if (validSearchConditions.length > 0) {
+ globalWhere = or(...validSearchConditions);
+ }
+ }
- // 5) 최종 WHERE 조건 생성
- const whereConditions: SQL<unknown>[] = [];
+ // 5) 최종 WHERE 조건 생성
+ const whereConditions: SQL<unknown>[] = [];
- if (advancedWhere) whereConditions.push(advancedWhere);
- if (basicWhere) whereConditions.push(basicWhere);
- if (globalWhere) whereConditions.push(globalWhere);
- if (rfqIdWhere) whereConditions.push(rfqIdWhere);
+ if (advancedWhere) whereConditions.push(advancedWhere);
+ if (basicWhere) whereConditions.push(basicWhere);
+ if (globalWhere) whereConditions.push(globalWhere);
+ if (rfqIdWhere) whereConditions.push(rfqIdWhere);
- const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
+ const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
- // 6) 전체 데이터 수 조회
- const totalResult = await db
- .select({ count: count() })
- .from(initialRfqDetailView)
- .where(finalWhere);
+ // 6) 전체 데이터 수 조회
+ const totalResult = await db
+ .select({ count: count() })
+ .from(initialRfqDetailView)
+ .where(finalWhere);
- const total = totalResult[0]?.count || 0;
+ const total = totalResult[0]?.count || 0;
- if (total === 0) {
- return { data: [], pageCount: 0, total: 0 };
- }
+ if (total === 0) {
+ return { data: [], pageCount: 0, total: 0 };
+ }
- console.log(totalResult);
- console.log(total);
+ console.log(totalResult);
+ console.log(total);
- // 7) 정렬 및 페이징 처리된 데이터 조회
- const orderByColumns = input.sort.map((sort) => {
- const column = sort.id as keyof typeof initialRfqDetailView.$inferSelect;
- return sort.desc ? desc(initialRfqDetailView[column]) : asc(initialRfqDetailView[column]);
- });
+ // 7) 정렬 및 페이징 처리된 데이터 조회
+ const orderByColumns = input.sort.map((sort) => {
+ const column = sort.id as keyof typeof initialRfqDetailView.$inferSelect;
+ return sort.desc ? desc(initialRfqDetailView[column]) : asc(initialRfqDetailView[column]);
+ });
- if (orderByColumns.length === 0) {
- orderByColumns.push(desc(initialRfqDetailView.createdAt));
- }
+ if (orderByColumns.length === 0) {
+ orderByColumns.push(desc(initialRfqDetailView.createdAt));
+ }
- const initialRfqData = await db
- .select()
- .from(initialRfqDetailView)
- .where(finalWhere)
- .orderBy(...orderByColumns)
- .limit(input.perPage)
- .offset(offset);
-
- const pageCount = Math.ceil(total / input.perPage);
-
- return { data: initialRfqData, pageCount, total };
- } catch (err) {
- console.error("Error in getInitialRfqDetail:", err);
- return { data: [], pageCount: 0, total: 0 };
- }
+ const initialRfqData = await db
+ .select()
+ .from(initialRfqDetailView)
+ .where(finalWhere)
+ .orderBy(...orderByColumns)
+ .limit(input.perPage)
+ .offset(offset);
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data: initialRfqData, pageCount, total };
+ } catch (err) {
+ console.error("Error in getInitialRfqDetail:", err);
+ return { data: [], pageCount: 0, total: 0 };
+ }
}
export async function getVendorsForSelection() {
@@ -1177,7 +1181,7 @@ export async function getVendorsForSelection() {
export async function addInitialRfqRecord(data: AddInitialRfqFormData & { rfqId: number }) {
try {
console.log('Incoming data:', data);
-
+
const [newRecord] = await db
.insert(initialRfq)
.values({
@@ -1233,7 +1237,7 @@ export async function getIncotermsForSelection() {
}
export async function removeInitialRfqs(input: RemoveInitialRfqsSchema) {
- unstable_noStore ()
+ unstable_noStore()
try {
const { ids } = removeInitialRfqsSchema.parse(input)
@@ -1259,10 +1263,10 @@ interface ModifyInitialRfqInput extends UpdateInitialRfqSchema {
}
export async function modifyInitialRfq(input: ModifyInitialRfqInput) {
- unstable_noStore ()
+ unstable_noStore()
try {
const { id, ...updateData } = input
-
+
// validation
updateInitialRfqSchema.parse(updateData)
@@ -1427,17 +1431,17 @@ export async function sendBulkInitialRfqEmails(input: BulkEmailInput) {
// 각 벤더의 모든 유효한 이메일 주소를 정리하는 함수
function getAllVendorEmails(vendor: typeof vendorsWithAllEmails[0]): string[] {
const emails: string[] = []
-
+
// 벤더 기본 이메일
if (vendor.email) {
emails.push(vendor.email)
}
-
+
// 대표자 이메일
if (vendor.representativeEmail && vendor.representativeEmail !== vendor.email) {
emails.push(vendor.representativeEmail)
}
-
+
// 연락처 이메일들
if (vendor.contactEmails && Array.isArray(vendor.contactEmails)) {
vendor.contactEmails.forEach(contactEmail => {
@@ -1446,7 +1450,7 @@ export async function sendBulkInitialRfqEmails(input: BulkEmailInput) {
}
})
}
-
+
return emails.filter(email => email && email.trim() !== '')
}
@@ -1464,7 +1468,7 @@ export async function sendBulkInitialRfqEmails(input: BulkEmailInput) {
// 해당 RFQ의 첨부파일들
const rfqAttachments = attachments.filter(att => att.rfqId === rfqDetail.rfqId)
-
+
// 벤더 정보
const vendor = vendorsWithAllEmails.find(v => v.id === rfqDetail.vendorId)
if (!vendor) {
@@ -1474,7 +1478,7 @@ export async function sendBulkInitialRfqEmails(input: BulkEmailInput) {
// 해당 벤더의 모든 이메일 주소 수집
const vendorEmails = getAllVendorEmails(vendor)
-
+
if (vendorEmails.length === 0) {
errors.push(`벤더 이메일 주소가 없습니다: ${vendor.vendorName}`)
continue
@@ -1486,7 +1490,7 @@ export async function sendBulkInitialRfqEmails(input: BulkEmailInput) {
let revisionToUse = currentRfqRevision
// 첫 번째 첨부파일을 기준으로 기존 응답 조회 (리비전 상태 확인용)
- if (rfqAttachments.length > 0 && rfqDetail.initialRfqId) {
+ if (rfqAttachments.length > 0 && rfqDetail.initialRfqId) {
const existingResponses = await db
.select()
.from(vendorAttachmentResponses)
@@ -1501,7 +1505,7 @@ export async function sendBulkInitialRfqEmails(input: BulkEmailInput) {
if (existingResponses.length > 0) {
// 기존 응답이 있음
const existingRevision = parseInt(existingResponses[0].currentRevision?.replace("Rev.", "") || "0")
-
+
if (currentRfqRevision > existingRevision) {
// RFQ 리비전이 올라감 → 리비전 업데이트
emailType = "REVISION"
@@ -1555,7 +1559,7 @@ export async function sendBulkInitialRfqEmails(input: BulkEmailInput) {
})
.where(eq(vendorAttachmentResponses.id, existingResponse[0].id))
}
-
+
}
const formatDateSafely = (date: Date | string | null | undefined): string => {
@@ -1565,11 +1569,11 @@ export async function sendBulkInitialRfqEmails(input: BulkEmailInput) {
const dateObj = new Date(date)
// 유효한 날짜인지 확인
if (isNaN(dateObj.getTime())) return ""
-
- return dateObj.toLocaleDateString('en-US', {
- year: 'numeric',
- month: '2-digit',
- day: '2-digit'
+
+ return dateObj.toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit'
})
} catch (error) {
console.error("Date formatting error:", error)
@@ -1579,7 +1583,7 @@ export async function sendBulkInitialRfqEmails(input: BulkEmailInput) {
// 7. 이메일 발송
const emailData: EmailData = {
- name:vendor.vendorName,
+ name: vendor.vendorName,
rfqCode: rfqDetail.rfqCode || "",
projectName: rfqDetail.rfqCode || "", // 실제 프로젝트명이 있다면 사용
projectCompany: rfqDetail.projectCompany || "",
@@ -1589,7 +1593,7 @@ export async function sendBulkInitialRfqEmails(input: BulkEmailInput) {
incotermsCode: rfqDetail.incotermsCode || "FOB",
incotermsDescription: rfqDetail.incotermsDescription || "FOB Finland Port",
dueDate: rfqDetail.dueDate ? formatDateSafely(rfqDetail.dueDate) : "",
- validDate: rfqDetail.validDate ?formatDateSafely(rfqDetail.validDate) : "",
+ validDate: rfqDetail.validDate ? formatDateSafely(rfqDetail.validDate) : "",
sparepart: rfqDetail.sparepart || "One(1) year operational spare parts",
vendorName: vendor.vendorName,
picName: session.user.name || rfqDetail.picName || "Procurement Manager",
@@ -1603,7 +1607,7 @@ export async function sendBulkInitialRfqEmails(input: BulkEmailInput) {
// 이메일 제목 생성 (타입에 따라 다르게)
let emailSubject = ""
const revisionText = revisionToUse > 0 ? ` Rev.${revisionToUse}` : ""
-
+
switch (emailType) {
case "NEW":
emailSubject = `[SHI RFQ] ${rfqDetail.rfqCode}${revisionText} Invitation to Bidder for ${emailData.packageName} * ${vendor.vendorName} * RFQ No. ${rfqDetail.rfqCode}`
@@ -1618,7 +1622,6 @@ export async function sendBulkInitialRfqEmails(input: BulkEmailInput) {
// nodemailer로 모든 이메일 주소에 한번에 발송
await sendEmail({
- // from: session.user.email || undefined,
to: vendorEmails.join(", "), // 콤마+공백으로 구분
subject: emailSubject,
template: "initial-rfq-invitation", // hbs 템플릿 파일명
@@ -1629,7 +1632,7 @@ export async function sendBulkInitialRfqEmails(input: BulkEmailInput) {
})
// 8. 초기 RFQ 상태 업데이트 (리비전은 변경하지 않음 - 이미 DB에 저장된 값 사용)
- if(rfqDetail.initialRfqId && rfqDetail.rfqId){
+ if (rfqDetail.initialRfqId && rfqDetail.rfqId) {
// Promise.all로 두 테이블 동시 업데이트
await Promise.all([
// initialRfq 테이블 업데이트
@@ -1640,7 +1643,7 @@ export async function sendBulkInitialRfqEmails(input: BulkEmailInput) {
updatedAt: new Date(),
})
.where(eq(initialRfq.id, rfqDetail.initialRfqId)),
-
+
// bRfqs 테이블 status도 함께 업데이트
db
.update(bRfqs)
@@ -1730,310 +1733,310 @@ export type VendorResponseDetail = VendorAttachmentResponse & {
};
export async function getVendorRfqResponses(input: GetVendorResponsesSchema, vendorId?: string, rfqId?: string) {
- try {
- // 페이지네이션 설정
- const page = input.page || 1;
- const perPage = input.perPage || 10;
- const offset = (page - 1) * perPage;
+ try {
+ // 페이지네이션 설정
+ const page = input.page || 1;
+ const perPage = input.perPage || 10;
+ const offset = (page - 1) * perPage;
- // 기본 조건
- let whereConditions = [];
+ // 기본 조건
+ let whereConditions = [];
- // 벤더 ID 조건
- if (vendorId) {
- whereConditions.push(eq(vendorAttachmentResponses.vendorId, Number(vendorId)));
- }
+ // 벤더 ID 조건
+ if (vendorId) {
+ whereConditions.push(eq(vendorAttachmentResponses.vendorId, Number(vendorId)));
+ }
- // RFQ 타입 조건
- // if (input.rfqType !== "ALL") {
- // whereConditions.push(eq(vendorAttachmentResponses.rfqType, input.rfqType as RfqType));
- // }
+ // RFQ 타입 조건
+ // if (input.rfqType !== "ALL") {
+ // whereConditions.push(eq(vendorAttachmentResponses.rfqType, input.rfqType as RfqType));
+ // }
- // 날짜 범위 조건
- if (input.from && input.to) {
- whereConditions.push(
- and(
- gte(vendorAttachmentResponses.requestedAt, new Date(input.from)),
- lte(vendorAttachmentResponses.requestedAt, new Date(input.to))
- )
- );
- }
+ // 날짜 범위 조건
+ if (input.from && input.to) {
+ whereConditions.push(
+ and(
+ gte(vendorAttachmentResponses.requestedAt, new Date(input.from)),
+ lte(vendorAttachmentResponses.requestedAt, new Date(input.to))
+ )
+ );
+ }
- const baseWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
-
- // 그룹핑된 응답 요약 데이터 조회
- const groupedResponses = await db
- .select({
- vendorId: vendorAttachmentResponses.vendorId,
- rfqRecordId: vendorAttachmentResponses.rfqRecordId,
- rfqType: vendorAttachmentResponses.rfqType,
-
- // 통계 계산 (조건부 COUNT 수정)
- totalAttachments: count(),
- respondedCount: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' THEN 1 ELSE 0 END)`,
- pendingCount: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'NOT_RESPONDED' THEN 1 ELSE 0 END)`,
- revisionRequestedCount: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'REVISION_REQUESTED' THEN 1 ELSE 0 END)`,
- waivedCount: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'WAIVED' THEN 1 ELSE 0 END)`,
-
- // 날짜 정보
- requestedAt: sql<Date>`MIN(${vendorAttachmentResponses.requestedAt})`,
- lastRespondedAt: sql<Date | null>`MAX(${vendorAttachmentResponses.respondedAt})`,
-
- // 코멘트 여부
- hasComments: sql<boolean>`BOOL_OR(${vendorAttachmentResponses.responseComment} IS NOT NULL OR ${vendorAttachmentResponses.vendorComment} IS NOT NULL)`,
- })
- .from(vendorAttachmentResponses)
- .where(baseWhere)
- .groupBy(
- vendorAttachmentResponses.vendorId,
- vendorAttachmentResponses.rfqRecordId,
- vendorAttachmentResponses.rfqType
- )
- .orderBy(desc(sql`MIN(${vendorAttachmentResponses.requestedAt})`))
- .offset(offset)
- .limit(perPage);
+ const baseWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
- // 벤더 정보와 RFQ 정보를 별도로 조회
- const vendorIds = [...new Set(groupedResponses.map(r => r.vendorId))];
- const rfqRecordIds = [...new Set(groupedResponses.map(r => r.rfqRecordId))];
+ // 그룹핑된 응답 요약 데이터 조회
+ const groupedResponses = await db
+ .select({
+ vendorId: vendorAttachmentResponses.vendorId,
+ rfqRecordId: vendorAttachmentResponses.rfqRecordId,
+ rfqType: vendorAttachmentResponses.rfqType,
- // 벤더 정보 조회
- const vendorsData = await db.query.vendors.findMany({
- where: or(...vendorIds.map(id => eq(vendors.id, id))),
- columns: {
- id: true,
- vendorCode: true,
- vendorName: true,
- country: true,
- businessSize: true,
- }
- });
-
- // RFQ 정보 조회 (초기 RFQ와 최종 RFQ 모두)
- const [initialRfqs] = await Promise.all([
- db.query.initialRfq.findMany({
- where: or(...rfqRecordIds.map(id => eq(initialRfq.id, id))),
- with: {
- rfq: {
- columns: {
- id: true,
- rfqCode: true,
- description: true,
- status: true,
- dueDate: true,
- }
- }
- }
- })
+ // 통계 계산 (조건부 COUNT 수정)
+ totalAttachments: count(),
+ respondedCount: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' THEN 1 ELSE 0 END)`,
+ pendingCount: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'NOT_RESPONDED' THEN 1 ELSE 0 END)`,
+ revisionRequestedCount: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'REVISION_REQUESTED' THEN 1 ELSE 0 END)`,
+ waivedCount: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'WAIVED' THEN 1 ELSE 0 END)`,
- ]);
+ // 날짜 정보
+ requestedAt: sql<Date>`MIN(${vendorAttachmentResponses.requestedAt})`,
+ lastRespondedAt: sql<Date | null>`MAX(${vendorAttachmentResponses.respondedAt})`,
- // 데이터 조합 및 변환
- const transformedResponses: VendorRfqResponseSummary[] = groupedResponses.map(response => {
- const vendor = vendorsData.find(v => v.id === response.vendorId);
-
- let rfqInfo = null;
- if (response.rfqType === "INITIAL") {
- const initialRfq = initialRfqs.find(r => r.id === response.rfqRecordId);
- rfqInfo = initialRfq?.rfq || null;
- }
+ // 코멘트 여부
+ hasComments: sql<boolean>`BOOL_OR(${vendorAttachmentResponses.responseComment} IS NOT NULL OR ${vendorAttachmentResponses.vendorComment} IS NOT NULL)`,
+ })
+ .from(vendorAttachmentResponses)
+ .where(baseWhere)
+ .groupBy(
+ vendorAttachmentResponses.vendorId,
+ vendorAttachmentResponses.rfqRecordId,
+ vendorAttachmentResponses.rfqType
+ )
+ .orderBy(desc(sql`MIN(${vendorAttachmentResponses.requestedAt})`))
+ .offset(offset)
+ .limit(perPage);
+
+ // 벤더 정보와 RFQ 정보를 별도로 조회
+ const vendorIds = [...new Set(groupedResponses.map(r => r.vendorId))];
+ const rfqRecordIds = [...new Set(groupedResponses.map(r => r.rfqRecordId))];
+
+ // 벤더 정보 조회
+ const vendorsData = await db.query.vendors.findMany({
+ where: or(...vendorIds.map(id => eq(vendors.id, id))),
+ columns: {
+ id: true,
+ vendorCode: true,
+ vendorName: true,
+ country: true,
+ businessSize: true,
+ }
+ });
- // 응답률 계산
- const responseRate = Number(response.totalAttachments) > 0
- ? Math.round((Number(response.respondedCount) / Number(response.totalAttachments)) * 100)
- : 0;
-
- // 완료율 계산 (응답완료 + 포기)
- const completionRate = Number(response.totalAttachments) > 0
- ? Math.round(((Number(response.respondedCount) + Number(response.waivedCount)) / Number(response.totalAttachments)) * 100)
- : 0;
-
- // 전체 상태 결정
- let overallStatus: ResponseStatus = "NOT_RESPONDED";
- if (Number(response.revisionRequestedCount) > 0) {
- overallStatus = "REVISION_REQUESTED";
- } else if (completionRate === 100) {
- overallStatus = Number(response.waivedCount) === Number(response.totalAttachments) ? "WAIVED" : "RESPONDED";
- } else if (Number(response.respondedCount) > 0) {
- overallStatus = "RESPONDED"; // 부분 응답
+ // RFQ 정보 조회 (초기 RFQ와 최종 RFQ 모두)
+ const [initialRfqs] = await Promise.all([
+ db.query.initialRfq.findMany({
+ where: or(...rfqRecordIds.map(id => eq(initialRfq.id, id))),
+ with: {
+ rfq: {
+ columns: {
+ id: true,
+ rfqCode: true,
+ description: true,
+ status: true,
+ dueDate: true,
+ }
}
+ }
+ })
- return {
- id: `${response.vendorId}-${response.rfqRecordId}-${response.rfqType}`,
- vendorId: response.vendorId,
- rfqRecordId: response.rfqRecordId,
- rfqType: response.rfqType,
- rfq: rfqInfo,
- vendor: vendor || null,
- totalAttachments: Number(response.totalAttachments),
- respondedCount: Number(response.respondedCount),
- pendingCount: Number(response.pendingCount),
- revisionRequestedCount: Number(response.revisionRequestedCount),
- waivedCount: Number(response.waivedCount),
- responseRate,
- completionRate,
- overallStatus,
- requestedAt: response.requestedAt,
- lastRespondedAt: response.lastRespondedAt,
- hasComments: response.hasComments,
- };
- });
-
- // 전체 개수 조회 (그룹핑 기준) - PostgreSQL 호환 방식
- const totalCountResult = await db
- .select({
- totalCount: sql<number>`COUNT(DISTINCT (${vendorAttachmentResponses.vendorId}, ${vendorAttachmentResponses.rfqRecordId}, ${vendorAttachmentResponses.rfqType}))`
- })
- .from(vendorAttachmentResponses)
- .where(baseWhere);
+ ]);
- const totalCount = Number(totalCountResult[0].totalCount);
- const pageCount = Math.ceil(totalCount / perPage);
+ // 데이터 조합 및 변환
+ const transformedResponses: VendorRfqResponseSummary[] = groupedResponses.map(response => {
+ const vendor = vendorsData.find(v => v.id === response.vendorId);
- return {
- data: transformedResponses,
- pageCount,
- totalCount
- };
+ let rfqInfo = null;
+ if (response.rfqType === "INITIAL") {
+ const initialRfq = initialRfqs.find(r => r.id === response.rfqRecordId);
+ rfqInfo = initialRfq?.rfq || null;
+ }
- } catch (err) {
- console.error("getVendorRfqResponses 에러:", err);
- return { data: [], pageCount: 0, totalCount: 0 };
+ // 응답률 계산
+ const responseRate = Number(response.totalAttachments) > 0
+ ? Math.round((Number(response.respondedCount) / Number(response.totalAttachments)) * 100)
+ : 0;
+
+ // 완료율 계산 (응답완료 + 포기)
+ const completionRate = Number(response.totalAttachments) > 0
+ ? Math.round(((Number(response.respondedCount) + Number(response.waivedCount)) / Number(response.totalAttachments)) * 100)
+ : 0;
+
+ // 전체 상태 결정
+ let overallStatus: ResponseStatus = "NOT_RESPONDED";
+ if (Number(response.revisionRequestedCount) > 0) {
+ overallStatus = "REVISION_REQUESTED";
+ } else if (completionRate === 100) {
+ overallStatus = Number(response.waivedCount) === Number(response.totalAttachments) ? "WAIVED" : "RESPONDED";
+ } else if (Number(response.respondedCount) > 0) {
+ overallStatus = "RESPONDED"; // 부분 응답
}
+
+ return {
+ id: `${response.vendorId}-${response.rfqRecordId}-${response.rfqType}`,
+ vendorId: response.vendorId,
+ rfqRecordId: response.rfqRecordId,
+ rfqType: response.rfqType,
+ rfq: rfqInfo,
+ vendor: vendor || null,
+ totalAttachments: Number(response.totalAttachments),
+ respondedCount: Number(response.respondedCount),
+ pendingCount: Number(response.pendingCount),
+ revisionRequestedCount: Number(response.revisionRequestedCount),
+ waivedCount: Number(response.waivedCount),
+ responseRate,
+ completionRate,
+ overallStatus,
+ requestedAt: response.requestedAt,
+ lastRespondedAt: response.lastRespondedAt,
+ hasComments: response.hasComments,
+ };
+ });
+
+ // 전체 개수 조회 (그룹핑 기준) - PostgreSQL 호환 방식
+ const totalCountResult = await db
+ .select({
+ totalCount: sql<number>`COUNT(DISTINCT (${vendorAttachmentResponses.vendorId}, ${vendorAttachmentResponses.rfqRecordId}, ${vendorAttachmentResponses.rfqType}))`
+ })
+ .from(vendorAttachmentResponses)
+ .where(baseWhere);
+
+ const totalCount = Number(totalCountResult[0].totalCount);
+ const pageCount = Math.ceil(totalCount / perPage);
+
+ return {
+ data: transformedResponses,
+ pageCount,
+ totalCount
+ };
+
+ } catch (err) {
+ console.error("getVendorRfqResponses 에러:", err);
+ return { data: [], pageCount: 0, totalCount: 0 };
+ }
}
/**
* 특정 RFQ의 첨부파일별 응답 상세 조회 (상세 페이지용)
*/
export async function getRfqAttachmentResponses(vendorId: string, rfqRecordId: string) {
- try {
- // 해당 RFQ의 모든 첨부파일 응답 조회
- const responses = await db.query.vendorAttachmentResponses.findMany({
- where: and(
- eq(vendorAttachmentResponses.vendorId, Number(vendorId)),
- eq(vendorAttachmentResponses.rfqRecordId, Number(rfqRecordId)),
- ),
+ try {
+ // 해당 RFQ의 모든 첨부파일 응답 조회
+ const responses = await db.query.vendorAttachmentResponses.findMany({
+ where: and(
+ eq(vendorAttachmentResponses.vendorId, Number(vendorId)),
+ eq(vendorAttachmentResponses.rfqRecordId, Number(rfqRecordId)),
+ ),
+ with: {
+ attachment: {
with: {
- attachment: {
+ rfq: {
+ columns: {
+ id: true,
+ rfqCode: true,
+ description: true,
+ status: true,
+ dueDate: true,
+ // 추가 정보
+ picCode: true,
+ picName: true,
+ EngPicName: true,
+ packageNo: true,
+ packageName: true,
+ projectId: true,
+ projectCompany: true,
+ projectFlag: true,
+ projectSite: true,
+ remark: true,
+ },
with: {
- rfq: {
+ project: {
columns: {
id: true,
- rfqCode: true,
- description: true,
- status: true,
- dueDate: true,
- // 추가 정보
- picCode: true,
- picName: true,
- EngPicName: true,
- packageNo: true,
- packageName: true,
- projectId: true,
- projectCompany: true,
- projectFlag: true,
- projectSite: true,
- remark: true,
- },
- with: {
- project: {
- columns: {
- id: true,
- code: true,
- name: true,
- type: true,
- }
- }
+ code: true,
+ name: true,
+ type: true,
}
}
}
- },
- vendor: {
- columns: {
- id: true,
- vendorCode: true,
- vendorName: true,
- country: true,
- businessSize: true,
- }
- },
- responseAttachments: true,
- },
- orderBy: [asc(vendorAttachmentResponses.attachmentId)]
- });
+ }
+ }
+ },
+ vendor: {
+ columns: {
+ id: true,
+ vendorCode: true,
+ vendorName: true,
+ country: true,
+ businessSize: true,
+ }
+ },
+ responseAttachments: true,
+ },
+ orderBy: [asc(vendorAttachmentResponses.attachmentId)]
+ });
- return {
- data: responses,
- rfqInfo: responses[0]?.attachment?.rfq || null,
- vendorInfo: responses[0]?.vendor || null,
- };
+ return {
+ data: responses,
+ rfqInfo: responses[0]?.attachment?.rfq || null,
+ vendorInfo: responses[0]?.vendor || null,
+ };
- } catch (err) {
- console.error("getRfqAttachmentResponses 에러:", err);
- return { data: [], rfqInfo: null, vendorInfo: null };
- }
+ } catch (err) {
+ console.error("getRfqAttachmentResponses 에러:", err);
+ return { data: [], rfqInfo: null, vendorInfo: null };
+ }
}
export async function getVendorResponseStatusCounts(vendorId?: string, rfqId?: string, rfqType?: RfqType) {
- try {
- const initial: Record<ResponseStatus, number> = {
- NOT_RESPONDED: 0,
- RESPONDED: 0,
- REVISION_REQUESTED: 0,
- WAIVED: 0,
- };
+ try {
+ const initial: Record<ResponseStatus, number> = {
+ NOT_RESPONDED: 0,
+ RESPONDED: 0,
+ REVISION_REQUESTED: 0,
+ WAIVED: 0,
+ };
- // 조건 설정
- let whereConditions = [];
+ // 조건 설정
+ let whereConditions = [];
- // 벤더 ID 조건
- if (vendorId) {
- whereConditions.push(eq(vendorAttachmentResponses.vendorId, Number(vendorId)));
- }
+ // 벤더 ID 조건
+ if (vendorId) {
+ whereConditions.push(eq(vendorAttachmentResponses.vendorId, Number(vendorId)));
+ }
- // RFQ ID 조건
- if (rfqId) {
- const attachmentIds = await db
- .select({ id: bRfqsAttachments.id })
- .from(bRfqsAttachments)
- .where(eq(bRfqsAttachments.rfqId, Number(rfqId)));
-
- if (attachmentIds.length > 0) {
- whereConditions.push(
- or(...attachmentIds.map(att => eq(vendorAttachmentResponses.attachmentId, att.id)))
- );
- }
- }
+ // RFQ ID 조건
+ if (rfqId) {
+ const attachmentIds = await db
+ .select({ id: bRfqsAttachments.id })
+ .from(bRfqsAttachments)
+ .where(eq(bRfqsAttachments.rfqId, Number(rfqId)));
- // RFQ 타입 조건
- if (rfqType) {
- whereConditions.push(eq(vendorAttachmentResponses.rfqType, rfqType));
- }
+ if (attachmentIds.length > 0) {
+ whereConditions.push(
+ or(...attachmentIds.map(att => eq(vendorAttachmentResponses.attachmentId, att.id)))
+ );
+ }
+ }
- const whereCondition = whereConditions.length > 0 ? and(...whereConditions) : undefined;
+ // RFQ 타입 조건
+ if (rfqType) {
+ whereConditions.push(eq(vendorAttachmentResponses.rfqType, rfqType));
+ }
- // 상태별 그룹핑 쿼리
- const rows = await db
- .select({
- status: vendorAttachmentResponses.responseStatus,
- count: count(),
- })
- .from(vendorAttachmentResponses)
- .where(whereCondition)
- .groupBy(vendorAttachmentResponses.responseStatus);
-
- // 결과 처리
- const result = rows.reduce<Record<ResponseStatus, number>>((acc, { status, count }) => {
- if (status) {
- acc[status as ResponseStatus] = Number(count);
- }
- return acc;
- }, initial);
+ const whereCondition = whereConditions.length > 0 ? and(...whereConditions) : undefined;
+
+ // 상태별 그룹핑 쿼리
+ const rows = await db
+ .select({
+ status: vendorAttachmentResponses.responseStatus,
+ count: count(),
+ })
+ .from(vendorAttachmentResponses)
+ .where(whereCondition)
+ .groupBy(vendorAttachmentResponses.responseStatus);
- return result;
- } catch (err) {
- console.error("getVendorResponseStatusCounts 에러:", err);
- return {} as Record<ResponseStatus, number>;
+ // 결과 처리
+ const result = rows.reduce<Record<ResponseStatus, number>>((acc, { status, count }) => {
+ if (status) {
+ acc[status as ResponseStatus] = Number(count);
}
+ return acc;
+ }, initial);
+
+ return result;
+ } catch (err) {
+ console.error("getVendorResponseStatusCounts 에러:", err);
+ return {} as Record<ResponseStatus, number>;
+ }
}
/**
@@ -2041,101 +2044,101 @@ export async function getVendorResponseStatusCounts(vendorId?: string, rfqId?: s
*/
export async function getRfqResponseSummary(rfqId: string, rfqType?: RfqType) {
- try {
- // RFQ의 첨부파일 목록 조회 (relations 사용)
- const attachments = await db.query.bRfqsAttachments.findMany({
- where: eq(bRfqsAttachments.rfqId, Number(rfqId)),
- columns: {
- id: true,
- attachmentType: true,
- serialNo: true,
- description: true,
- }
- });
-
- if (attachments.length === 0) {
- return {
- totalAttachments: 0,
- totalVendors: 0,
- responseRate: 0,
- completionRate: 0,
- statusCounts: {} as Record<ResponseStatus, number>
- };
- }
-
- // 조건 설정
- let whereConditions = [
- or(...attachments.map(att => eq(vendorAttachmentResponses.attachmentId, att.id)))
- ];
+ try {
+ // RFQ의 첨부파일 목록 조회 (relations 사용)
+ const attachments = await db.query.bRfqsAttachments.findMany({
+ where: eq(bRfqsAttachments.rfqId, Number(rfqId)),
+ columns: {
+ id: true,
+ attachmentType: true,
+ serialNo: true,
+ description: true,
+ }
+ });
- if (rfqType) {
- whereConditions.push(eq(vendorAttachmentResponses.rfqType, rfqType));
- }
+ if (attachments.length === 0) {
+ return {
+ totalAttachments: 0,
+ totalVendors: 0,
+ responseRate: 0,
+ completionRate: 0,
+ statusCounts: {} as Record<ResponseStatus, number>
+ };
+ }
- const whereCondition = and(...whereConditions);
+ // 조건 설정
+ let whereConditions = [
+ or(...attachments.map(att => eq(vendorAttachmentResponses.attachmentId, att.id)))
+ ];
- // 벤더 수 및 응답 통계 조회
- const [vendorStats, statusCounts] = await Promise.all([
- // 전체 벤더 수 및 응답 벤더 수 (조건부 COUNT 수정)
- db
- .select({
- totalVendors: count(),
- respondedVendors: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' THEN 1 ELSE 0 END)`,
- completedVendors: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' OR ${vendorAttachmentResponses.responseStatus} = 'WAIVED' THEN 1 ELSE 0 END)`,
- })
- .from(vendorAttachmentResponses)
- .where(whereCondition),
+ if (rfqType) {
+ whereConditions.push(eq(vendorAttachmentResponses.rfqType, rfqType));
+ }
- // 상태별 개수
- db
- .select({
- status: vendorAttachmentResponses.responseStatus,
- count: count(),
- })
- .from(vendorAttachmentResponses)
- .where(whereCondition)
- .groupBy(vendorAttachmentResponses.responseStatus)
- ]);
-
- const stats = vendorStats[0];
- const statusCountsMap = statusCounts.reduce<Record<ResponseStatus, number>>((acc, { status, count }) => {
- if (status) {
- acc[status as ResponseStatus] = Number(count);
- }
- return acc;
- }, {
- NOT_RESPONDED: 0,
- RESPONDED: 0,
- REVISION_REQUESTED: 0,
- WAIVED: 0,
- });
-
- const responseRate = stats.totalVendors > 0
- ? Math.round((Number(stats.respondedVendors) / Number(stats.totalVendors)) * 100)
- : 0;
-
- const completionRate = stats.totalVendors > 0
- ? Math.round((Number(stats.completedVendors) / Number(stats.totalVendors)) * 100)
- : 0;
+ const whereCondition = and(...whereConditions);
- return {
- totalAttachments: attachments.length,
- totalVendors: Number(stats.totalVendors),
- responseRate,
- completionRate,
- statusCounts: statusCountsMap
- };
+ // 벤더 수 및 응답 통계 조회
+ const [vendorStats, statusCounts] = await Promise.all([
+ // 전체 벤더 수 및 응답 벤더 수 (조건부 COUNT 수정)
+ db
+ .select({
+ totalVendors: count(),
+ respondedVendors: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' THEN 1 ELSE 0 END)`,
+ completedVendors: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' OR ${vendorAttachmentResponses.responseStatus} = 'WAIVED' THEN 1 ELSE 0 END)`,
+ })
+ .from(vendorAttachmentResponses)
+ .where(whereCondition),
- } catch (err) {
- console.error("getRfqResponseSummary 에러:", err);
- return {
- totalAttachments: 0,
- totalVendors: 0,
- responseRate: 0,
- completionRate: 0,
- statusCounts: {} as Record<ResponseStatus, number>
- };
+ // 상태별 개수
+ db
+ .select({
+ status: vendorAttachmentResponses.responseStatus,
+ count: count(),
+ })
+ .from(vendorAttachmentResponses)
+ .where(whereCondition)
+ .groupBy(vendorAttachmentResponses.responseStatus)
+ ]);
+
+ const stats = vendorStats[0];
+ const statusCountsMap = statusCounts.reduce<Record<ResponseStatus, number>>((acc, { status, count }) => {
+ if (status) {
+ acc[status as ResponseStatus] = Number(count);
}
+ return acc;
+ }, {
+ NOT_RESPONDED: 0,
+ RESPONDED: 0,
+ REVISION_REQUESTED: 0,
+ WAIVED: 0,
+ });
+
+ const responseRate = stats.totalVendors > 0
+ ? Math.round((Number(stats.respondedVendors) / Number(stats.totalVendors)) * 100)
+ : 0;
+
+ const completionRate = stats.totalVendors > 0
+ ? Math.round((Number(stats.completedVendors) / Number(stats.totalVendors)) * 100)
+ : 0;
+
+ return {
+ totalAttachments: attachments.length,
+ totalVendors: Number(stats.totalVendors),
+ responseRate,
+ completionRate,
+ statusCounts: statusCountsMap
+ };
+
+ } catch (err) {
+ console.error("getRfqResponseSummary 에러:", err);
+ return {
+ totalAttachments: 0,
+ totalVendors: 0,
+ responseRate: 0,
+ completionRate: 0,
+ statusCounts: {} as Record<ResponseStatus, number>
+ };
+ }
}
/**
@@ -2143,54 +2146,54 @@ export async function getRfqResponseSummary(rfqId: string, rfqType?: RfqType) {
*/
export async function getVendorResponseProgress(vendorId: string) {
- try {
- let whereConditions = [eq(vendorAttachmentResponses.vendorId, Number(vendorId))];
-
- const whereCondition = and(...whereConditions);
-
- const progress = await db
- .select({
- totalRequests: count(),
- responded: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' THEN 1 ELSE 0 END)`,
- pending: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'NOT_RESPONDED' THEN 1 ELSE 0 END)`,
- revisionRequested: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'REVISION_REQUESTED' THEN 1 ELSE 0 END)`,
- waived: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'WAIVED' THEN 1 ELSE 0 END)`,
- })
- .from(vendorAttachmentResponses)
- .where(whereCondition);
- console.log(progress,"progress")
-
- const stats = progress[0];
- const responseRate = Number(stats.totalRequests) > 0
- ? Math.round((Number(stats.responded) / Number(stats.totalRequests)) * 100)
- : 0;
-
- const completionRate = Number(stats.totalRequests) > 0
- ? Math.round(((Number(stats.responded) + Number(stats.waived)) / Number(stats.totalRequests)) * 100)
- : 0;
+ try {
+ let whereConditions = [eq(vendorAttachmentResponses.vendorId, Number(vendorId))];
- return {
- totalRequests: Number(stats.totalRequests),
- responded: Number(stats.responded),
- pending: Number(stats.pending),
- revisionRequested: Number(stats.revisionRequested),
- waived: Number(stats.waived),
- responseRate,
- completionRate,
- };
+ const whereCondition = and(...whereConditions);
- } catch (err) {
- console.error("getVendorResponseProgress 에러:", err);
- return {
- totalRequests: 0,
- responded: 0,
- pending: 0,
- revisionRequested: 0,
- waived: 0,
- responseRate: 0,
- completionRate: 0,
- };
- }
+ const progress = await db
+ .select({
+ totalRequests: count(),
+ responded: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' THEN 1 ELSE 0 END)`,
+ pending: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'NOT_RESPONDED' THEN 1 ELSE 0 END)`,
+ revisionRequested: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'REVISION_REQUESTED' THEN 1 ELSE 0 END)`,
+ waived: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'WAIVED' THEN 1 ELSE 0 END)`,
+ })
+ .from(vendorAttachmentResponses)
+ .where(whereCondition);
+ console.log(progress, "progress")
+
+ const stats = progress[0];
+ const responseRate = Number(stats.totalRequests) > 0
+ ? Math.round((Number(stats.responded) / Number(stats.totalRequests)) * 100)
+ : 0;
+
+ const completionRate = Number(stats.totalRequests) > 0
+ ? Math.round(((Number(stats.responded) + Number(stats.waived)) / Number(stats.totalRequests)) * 100)
+ : 0;
+
+ return {
+ totalRequests: Number(stats.totalRequests),
+ responded: Number(stats.responded),
+ pending: Number(stats.pending),
+ revisionRequested: Number(stats.revisionRequested),
+ waived: Number(stats.waived),
+ responseRate,
+ completionRate,
+ };
+
+ } catch (err) {
+ console.error("getVendorResponseProgress 에러:", err);
+ return {
+ totalRequests: 0,
+ responded: 0,
+ pending: 0,
+ revisionRequested: 0,
+ waived: 0,
+ responseRate: 0,
+ completionRate: 0,
+ };
+ }
}
@@ -2214,7 +2217,7 @@ export async function getRfqAttachmentResponsesWithRevisions(vendorId: string, r
.from(rfqProgressSummaryView)
.where(eq(rfqProgressSummaryView.rfqId, responses[0]?.rfqId || 0))
.limit(1);
-
+
const progressSummary = progressSummaryResult[0] || null;
// 3. 각 응답의 첨부파일 리비전 히스토리 조회
@@ -2225,7 +2228,7 @@ export async function getRfqAttachmentResponsesWithRevisions(vendorId: string, r
.from(attachmentRevisionHistoryView)
.where(eq(attachmentRevisionHistoryView.attachmentId, response.attachmentId))
.orderBy(desc(attachmentRevisionHistoryView.clientRevisionCreatedAt));
-
+
return {
attachmentId: response.attachmentId,
revisions: history
@@ -2241,7 +2244,7 @@ export async function getRfqAttachmentResponsesWithRevisions(vendorId: string, r
.from(vendorResponseAttachmentsEnhanced)
.where(eq(vendorResponseAttachmentsEnhanced.vendorResponseId, response.responseId))
.orderBy(desc(vendorResponseAttachmentsEnhanced.uploadedAt));
-
+
return {
responseId: response.responseId,
files: files
@@ -2253,7 +2256,7 @@ export async function getRfqAttachmentResponsesWithRevisions(vendorId: string, r
const enhancedResponses = responses.map(response => {
const attachmentHistory = attachmentHistories.find(h => h.attachmentId === response.attachmentId);
const responseFileData = responseFiles.find(f => f.responseId === response.responseId);
-
+
return {
...response,
// 첨부파일 정보에 리비전 히스토리 추가
@@ -2293,7 +2296,7 @@ export async function getRfqAttachmentResponsesWithRevisions(vendorId: string, r
versionLag: response.versionLag,
needsUpdate: response.needsUpdate,
hasMultipleRevisions: response.hasMultipleRevisions,
-
+
// 새로 추가된 필드들
revisionRequestComment: response.revisionRequestComment,
revisionRequestedAt: response.revisionRequestedAt?.toISOString() || null,
@@ -2361,10 +2364,10 @@ export async function getRfqAttachmentResponsesWithRevisions(vendorId: string, r
} catch (err) {
console.error("getRfqAttachmentResponsesWithRevisions 에러:", err);
- return {
- data: [],
- rfqInfo: null,
- vendorInfo: null,
+ return {
+ data: [],
+ rfqInfo: null,
+ vendorInfo: null,
statistics: {
total: 0,
responded: 0,
@@ -2385,51 +2388,51 @@ export async function getRfqAttachmentResponsesWithRevisions(vendorId: string, r
// 첨부파일 리비전 히스토리 조회
export async function getAttachmentRevisionHistory(attachmentId: number) {
- try {
- const history = await db
- .select()
- .from(attachmentRevisionHistoryView)
- .where(eq(attachmentRevisionHistoryView.attachmentId, attachmentId))
- .orderBy(desc(attachmentRevisionHistoryView.clientRevisionCreatedAt));
+ try {
+ const history = await db
+ .select()
+ .from(attachmentRevisionHistoryView)
+ .where(eq(attachmentRevisionHistoryView.attachmentId, attachmentId))
+ .orderBy(desc(attachmentRevisionHistoryView.clientRevisionCreatedAt));
- return history;
- } catch (err) {
- console.error("getAttachmentRevisionHistory 에러:", err);
- return [];
- }
+ return history;
+ } catch (err) {
+ console.error("getAttachmentRevisionHistory 에러:", err);
+ return [];
}
+}
// RFQ 전체 진행 현황 조회
export async function getRfqProgressSummary(rfqId: number) {
- try {
- const summaryResult = await db
- .select()
- .from(rfqProgressSummaryView)
- .where(eq(rfqProgressSummaryView.rfqId, rfqId))
- .limit(1);
-
- return summaryResult[0] || null;
- } catch (err) {
- console.error("getRfqProgressSummary 에러:", err);
- return null;
- }
+ try {
+ const summaryResult = await db
+ .select()
+ .from(rfqProgressSummaryView)
+ .where(eq(rfqProgressSummaryView.rfqId, rfqId))
+ .limit(1);
+
+ return summaryResult[0] || null;
+ } catch (err) {
+ console.error("getRfqProgressSummary 에러:", err);
+ return null;
+ }
}
// 벤더 응답 파일 상세 조회 (향상된 정보 포함)
export async function getVendorResponseFiles(vendorResponseId: number) {
- try {
- const files = await db
- .select()
- .from(vendorResponseAttachmentsEnhanced)
- .where(eq(vendorResponseAttachmentsEnhanced.vendorResponseId, vendorResponseId))
- .orderBy(desc(vendorResponseAttachmentsEnhanced.uploadedAt));
+ try {
+ const files = await db
+ .select()
+ .from(vendorResponseAttachmentsEnhanced)
+ .where(eq(vendorResponseAttachmentsEnhanced.vendorResponseId, vendorResponseId))
+ .orderBy(desc(vendorResponseAttachmentsEnhanced.uploadedAt));
- return files;
- } catch (err) {
- console.error("getVendorResponseFiles 에러:", err);
- return [];
- }
+ return files;
+ } catch (err) {
+ console.error("getVendorResponseFiles 에러:", err);
+ return [];
}
+}
// 타입 정의 확장
@@ -2440,53 +2443,53 @@ export type EnhancedVendorResponse = {
rfqCode: string;
rfqType: "INITIAL" | "FINAL";
rfqRecordId: number;
-
+
// 첨부파일 정보
attachmentId: number;
attachmentType: string;
serialNo: string;
attachmentDescription?: string;
-
+
// 벤더 정보
vendorId: number;
vendorCode: string;
vendorName: string;
vendorCountry: string;
-
+
// 응답 상태
responseStatus: "NOT_RESPONDED" | "RESPONDED" | "REVISION_REQUESTED" | "WAIVED";
currentRevision: string;
respondedRevision?: string;
effectiveStatus: string;
-
+
// 코멘트 관련 필드들 (새로 추가된 필드 포함)
responseComment?: string; // 벤더가 응답할 때 작성하는 코멘트
vendorComment?: string; // 벤더 내부 메모
revisionRequestComment?: string; // 발주처가 수정 요청할 때 작성하는 사유 (새로 추가)
-
+
// 날짜 관련 필드들 (새로 추가된 필드 포함)
requestedAt: string;
respondedAt?: string;
revisionRequestedAt?: string; // 수정 요청 날짜 (새로 추가)
-
+
// 발주처 최신 리비전 정보
latestClientRevisionNo?: string;
latestClientFileName?: string;
latestClientFileSize?: number;
latestClientRevisionComment?: string;
-
+
// 리비전 분석
isVersionMatched: boolean;
versionLag?: number;
needsUpdate: boolean;
hasMultipleRevisions: boolean;
-
+
// 응답 파일 통계
totalResponseFiles: number;
latestResponseFileName?: string;
latestResponseFileSize?: number;
latestResponseUploadedAt?: string;
-
+
// 첨부파일 정보 (리비전 히스토리 포함)
attachment: {
id: number;
@@ -2506,7 +2509,7 @@ export type EnhancedVendorResponse = {
isLatest: boolean;
}>;
};
-
+
// 벤더 응답 파일들
responseAttachments: Array<{
id: number;
@@ -2592,10 +2595,366 @@ export async function requestRevision(
} catch (error) {
console.error("Request revision server action error:", error);
- return {
+ return {
success: false,
message: "내부 서버 오류가 발생했습니다",
error: "INTERNAL_ERROR",
};
}
+}
+
+
+
+export async function shortListConfirm(input: ShortListConfirmInput) {
+ try {
+ const validatedInput = shortListConfirmSchema.parse(input)
+ const { rfqId, selectedVendorIds, rejectedVendorIds } = validatedInput
+
+ // 1. RFQ 정보 조회
+ const rfqInfo = await db
+ .select()
+ .from(bRfqs)
+ .where(eq(bRfqs.id, rfqId))
+ .limit(1)
+
+ if (!rfqInfo.length) {
+ return { success: false, message: "RFQ를 찾을 수 없습니다." }
+ }
+
+ const rfq = rfqInfo[0]
+
+ // 2. 기존 initial_rfq에서 필요한 정보 조회
+ const initialRfqData = await db
+ .select({
+ id: initialRfq.id,
+ vendorId: initialRfq.vendorId,
+ dueDate: initialRfq.dueDate,
+ validDate: initialRfq.validDate,
+ incotermsCode: initialRfq.incotermsCode,
+ gtc: initialRfq.gtc,
+ gtcValidDate: initialRfq.gtcValidDate,
+ classification: initialRfq.classification,
+ sparepart: initialRfq.sparepart,
+ cpRequestYn: initialRfq.cpRequestYn,
+ prjectGtcYn: initialRfq.prjectGtcYn,
+ returnRevision: initialRfq.returnRevision,
+ })
+ .from(initialRfq)
+ .where(
+ and(
+ eq(initialRfq.rfqId, rfqId),
+ inArray(initialRfq.vendorId, [...selectedVendorIds, ...rejectedVendorIds])
+ )
+ )
+
+ if (!initialRfqData.length) {
+ return { success: false, message: "해당 RFQ의 초기 RFQ 데이터를 찾을 수 없습니다." }
+ }
+
+ // 3. 탈락된 벤더들의 이메일 정보 조회
+ let rejectedVendorEmails: Array<{
+ vendorId: number
+ vendorName: string
+ email: string
+ }> = []
+
+ if (rejectedVendorIds.length > 0) {
+ rejectedVendorEmails = await db
+ .select({
+ vendorId: vendors.id,
+ vendorName: vendors.vendorName,
+ email: vendors.email,
+ })
+ .from(vendors)
+ .where(inArray(vendors.id, rejectedVendorIds))
+ }
+
+ await db.transaction(async (tx) => {
+ // 4. 선택된 벤더들에 대해 final_rfq 테이블에 데이터 생성/업데이트
+ for (const vendorId of selectedVendorIds) {
+ const initialData = initialRfqData.find(data => data.vendorId === vendorId)
+
+ if (initialData) {
+ // 기존 final_rfq 레코드 확인
+ const existingFinalRfq = await tx
+ .select()
+ .from(finalRfq)
+ .where(
+ and(
+ eq(finalRfq.rfqId, rfqId),
+ eq(finalRfq.vendorId, vendorId)
+ )
+ )
+ .limit(1)
+
+ if (existingFinalRfq.length > 0) {
+ // 기존 레코드 업데이트
+ await tx
+ .update(finalRfq)
+ .set({
+ shortList: true,
+ finalRfqStatus: "DRAFT",
+ dueDate: initialData.dueDate,
+ validDate: initialData.validDate,
+ incotermsCode: initialData.incotermsCode,
+ gtc: initialData.gtc,
+ gtcValidDate: initialData.gtcValidDate,
+ classification: initialData.classification,
+ sparepart: initialData.sparepart,
+ cpRequestYn: initialData.cpRequestYn,
+ prjectGtcYn: initialData.prjectGtcYn,
+ updatedAt: new Date(),
+ })
+ .where(eq(finalRfq.id, existingFinalRfq[0].id))
+ } else {
+ // 새 레코드 생성
+ await tx
+ .insert(finalRfq)
+ .values({
+ rfqId,
+ vendorId,
+ finalRfqStatus: "DRAFT",
+ dueDate: initialData.dueDate,
+ validDate: initialData.validDate,
+ incotermsCode: initialData.incotermsCode,
+ gtc: initialData.gtc,
+ gtcValidDate: initialData.gtcValidDate,
+ classification: initialData.classification,
+ sparepart: initialData.sparepart,
+ shortList: true,
+ returnYn: false,
+ cpRequestYn: initialData.cpRequestYn,
+ prjectGtcYn: initialData.prjectGtcYn,
+ returnRevision: 0,
+ currency: "KRW",
+ taxCode: "VV",
+ deliveryDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30일 후
+ firsttimeYn: true,
+ materialPriceRelatedYn: false,
+ })
+ }
+ }
+ }
+
+ // 5. 탈락된 벤더들에 대해서는 shortList: false로 설정 (있다면)
+ if (rejectedVendorIds.length > 0) {
+ // 기존에 final_rfq에 있는 탈락 벤더들은 shortList를 false로 업데이트
+ await tx
+ .update(finalRfq)
+ .set({
+ shortList: false,
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(finalRfq.rfqId, rfqId),
+ inArray(finalRfq.vendorId, rejectedVendorIds)
+ )
+ )
+ }
+
+ // 6. initial_rfq의 shortList 필드도 업데이트
+ if (selectedVendorIds.length > 0) {
+ await tx
+ .update(initialRfq)
+ .set({
+ shortList: true,
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(initialRfq.rfqId, rfqId),
+ inArray(initialRfq.vendorId, selectedVendorIds)
+ )
+ )
+ }
+
+ if (rejectedVendorIds.length > 0) {
+ await tx
+ .update(initialRfq)
+ .set({
+ shortList: false,
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(initialRfq.rfqId, rfqId),
+ inArray(initialRfq.vendorId, rejectedVendorIds)
+ )
+ )
+ }
+ })
+
+ // 7. 탈락된 벤더들에게 Letter of Regret 이메일 발송
+ const emailErrors: string[] = []
+
+ for (const rejectedVendor of rejectedVendorEmails) {
+ if (rejectedVendor.email) {
+ try {
+ await sendEmail({
+ to: rejectedVendor.email,
+ subject: `Letter of Regret - RFQ ${rfq.rfqCode}`,
+ template: "letter-of-regret",
+ context: {
+ rfqCode: rfq.rfqCode,
+ vendorName: rejectedVendor.vendorName,
+ projectTitle: rfq.projectTitle || "Project",
+ dateTime: new Date().toLocaleDateString("ko-KR", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ }),
+ companyName: "Your Company Name", // 실제 회사명으로 변경
+ language: "ko",
+ },
+ })
+ } catch (error) {
+ console.error(`Email sending failed for vendor ${rejectedVendor.vendorName}:`, error)
+ emailErrors.push(`${rejectedVendor.vendorName}에게 이메일 발송 실패`)
+ }
+ }
+ }
+
+ // 8. 페이지 revalidation
+ revalidatePath(`/evcp/a-rfq/${rfqId}`)
+ revalidatePath(`/evcp/b-rfq/${rfqId}`)
+
+ const successMessage = `Short List가 확정되었습니다. (선택: ${selectedVendorIds.length}개, 탈락: ${rejectedVendorIds.length}개)`
+
+ return {
+ success: true,
+ message: successMessage,
+ errors: emailErrors.length > 0 ? emailErrors : undefined,
+ data: {
+ selectedCount: selectedVendorIds.length,
+ rejectedCount: rejectedVendorIds.length,
+ emailsSent: rejectedVendorEmails.length - emailErrors.length,
+ },
+ }
+
+ } catch (error) {
+ console.error("Short List confirm error:", error)
+ return {
+ success: false,
+ message: "Short List 확정 중 오류가 발생했습니다.",
+ }
+ }
+}
+
+export async function getFinalRfqDetail(input: GetFinalRfqDetailSchema, rfqId?: number) {
+
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // 1) 고급 필터 조건
+ let advancedWhere: SQL<unknown> | undefined = undefined;
+ if (input.filters && input.filters.length > 0) {
+ advancedWhere = filterColumns({
+ table: finalRfqDetailView,
+ filters: input.filters,
+ joinOperator: input.joinOperator || 'and',
+ });
+ }
+
+ // 2) 기본 필터 조건
+ let basicWhere: SQL<unknown> | undefined = undefined;
+ if (input.basicFilters && input.basicFilters.length > 0) {
+ basicWhere = filterColumns({
+ table: finalRfqDetailView,
+ filters: input.basicFilters,
+ joinOperator: input.basicJoinOperator || 'and',
+ });
+ }
+
+ let rfqIdWhere: SQL<unknown> | undefined = undefined;
+ if (rfqId) {
+ rfqIdWhere = eq(finalRfqDetailView.rfqId, rfqId);
+ }
+
+ // 3) 글로벌 검색 조건
+ let globalWhere: SQL<unknown> | undefined = undefined;
+ if (input.search) {
+ const s = `%${input.search}%`;
+
+ const validSearchConditions: SQL<unknown>[] = [];
+
+ const rfqCodeCondition = ilike(finalRfqDetailView.rfqCode, s);
+ if (rfqCodeCondition) validSearchConditions.push(rfqCodeCondition);
+
+ const vendorNameCondition = ilike(finalRfqDetailView.vendorName, s);
+ if (vendorNameCondition) validSearchConditions.push(vendorNameCondition);
+
+ const vendorCodeCondition = ilike(finalRfqDetailView.vendorCode, s);
+ if (vendorCodeCondition) validSearchConditions.push(vendorCodeCondition);
+
+ const vendorCountryCondition = ilike(finalRfqDetailView.vendorCountry, s);
+ if (vendorCountryCondition) validSearchConditions.push(vendorCountryCondition);
+
+ const incotermsDescriptionCondition = ilike(finalRfqDetailView.incotermsDescription, s);
+ if (incotermsDescriptionCondition) validSearchConditions.push(incotermsDescriptionCondition);
+
+ const paymentTermsDescriptionCondition = ilike(finalRfqDetailView.paymentTermsDescription, s);
+ if (paymentTermsDescriptionCondition) validSearchConditions.push(paymentTermsDescriptionCondition);
+
+ const classificationCondition = ilike(finalRfqDetailView.classification, s);
+ if (classificationCondition) validSearchConditions.push(classificationCondition);
+
+ const sparepartCondition = ilike(finalRfqDetailView.sparepart, s);
+ if (sparepartCondition) validSearchConditions.push(sparepartCondition);
+
+ if (validSearchConditions.length > 0) {
+ globalWhere = or(...validSearchConditions);
+ }
+ }
+
+ // 5) 최종 WHERE 조건 생성
+ const whereConditions: SQL<unknown>[] = [];
+
+ if (advancedWhere) whereConditions.push(advancedWhere);
+ if (basicWhere) whereConditions.push(basicWhere);
+ if (globalWhere) whereConditions.push(globalWhere);
+ if (rfqIdWhere) whereConditions.push(rfqIdWhere);
+
+ const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
+
+ // 6) 전체 데이터 수 조회
+ const totalResult = await db
+ .select({ count: count() })
+ .from(finalRfqDetailView)
+ .where(finalWhere);
+
+ const total = totalResult[0]?.count || 0;
+
+ if (total === 0) {
+ return { data: [], pageCount: 0, total: 0 };
+ }
+
+ console.log(totalResult);
+ console.log(total);
+
+ // 7) 정렬 및 페이징 처리된 데이터 조회
+ const orderByColumns = input.sort.map((sort) => {
+ const column = sort.id as keyof typeof finalRfqDetailView.$inferSelect;
+ return sort.desc ? desc(finalRfqDetailView[column]) : asc(finalRfqDetailView[column]);
+ });
+
+ if (orderByColumns.length === 0) {
+ orderByColumns.push(desc(finalRfqDetailView.createdAt));
+ }
+
+ const finalRfqData = await db
+ .select()
+ .from(finalRfqDetailView)
+ .where(finalWhere)
+ .orderBy(...orderByColumns)
+ .limit(input.perPage)
+ .offset(offset);
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data: finalRfqData, pageCount, total };
+ } catch (err) {
+ console.error("Error in getFinalRfqDetail:", err);
+ return { data: [], pageCount: 0, total: 0 };
+ }
} \ No newline at end of file