summaryrefslogtreecommitdiff
path: root/lib/vendors/service.ts
diff options
context:
space:
mode:
Diffstat (limited to 'lib/vendors/service.ts')
-rw-r--r--lib/vendors/service.ts731
1 files changed, 530 insertions, 201 deletions
diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts
index 8f095c0e..87a8336d 100644
--- a/lib/vendors/service.ts
+++ b/lib/vendors/service.ts
@@ -2,12 +2,13 @@
import { revalidateTag, unstable_noStore } from "next/cache";
import db from "@/db/db";
-import { vendorAttachments, VendorContact, vendorContacts, vendorDetailView, vendorInvestigations, vendorInvestigationsView, vendorItemsView, vendorPossibleItems, vendors, type Vendor } from "@/db/schema/vendors";
+import { vendorAttachments, VendorContact, vendorContacts, vendorDetailView, vendorInvestigations, vendorInvestigationsView, vendorItemsView, vendorPossibleItems, vendors, vendorsWithTypesView, vendorTypes, type Vendor } from "@/db/schema/vendors";
import logger from '@/lib/logger';
import { filterColumns } from "@/lib/filter-columns";
import { unstable_cache } from "@/lib/unstable-cache";
import { getErrorMessage } from "@/lib/handle-error";
+import { headers } from 'next/headers';
import {
selectVendors,
@@ -24,7 +25,10 @@ import {
countVendorItems,
insertVendorItem,
countRfqHistory,
- selectRfqHistory
+ selectRfqHistory,
+ selectVendorsWithTypes,
+ countVendorsWithTypes,
+
} from "./repository";
import type {
@@ -51,7 +55,8 @@ import { items } from "@/db/schema/items";
import { users } from "@/db/schema/users";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
-import { projects, vendorProjectPQs } from "@/db/schema";
+import { contracts, contractsDetailView, projects, vendorProjectPQs, vendorsLogs } from "@/db/schema";
+import { Hospital } from "lucide-react";
/* -----------------------------------------------------
@@ -68,61 +73,63 @@ export async function getVendors(input: GetVendorsSchema) {
async () => {
try {
const offset = (input.page - 1) * input.perPage;
-
- // 1) 고급 필터
+
+ // 1) 고급 필터 - vendors 대신 vendorsWithTypesView 사용
const advancedWhere = filterColumns({
- table: vendors,
+ table: vendorsWithTypesView,
filters: input.filters,
joinOperator: input.joinOperator,
});
-
+
// 2) 글로벌 검색
let globalWhere;
if (input.search) {
const s = `%${input.search}%`;
globalWhere = or(
- ilike(vendors.vendorName, s),
- ilike(vendors.vendorCode, s),
- ilike(vendors.email, s),
- ilike(vendors.status, s)
+ ilike(vendorsWithTypesView.vendorName, s),
+ ilike(vendorsWithTypesView.vendorCode, s),
+ ilike(vendorsWithTypesView.email, s),
+ ilike(vendorsWithTypesView.status, s),
+ // 추가: 업체 유형 검색
+ ilike(vendorsWithTypesView.vendorTypeName, s)
);
}
-
+
// 최종 where 결합
const finalWhere = and(advancedWhere, globalWhere);
-
+
// 간단 검색 (advancedTable=false) 시 예시
const simpleWhere = and(
input.vendorName
- ? ilike(vendors.vendorName, `%${input.vendorName}%`)
+ ? ilike(vendorsWithTypesView.vendorName, `%${input.vendorName}%`)
: undefined,
- input.status ? ilike(vendors.status, input.status) : undefined,
+ input.status ? ilike(vendorsWithTypesView.status, input.status) : undefined,
input.country
- ? ilike(vendors.country, `%${input.country}%`)
+ ? ilike(vendorsWithTypesView.country, `%${input.country}%`)
: undefined
);
-
+
// 실제 사용될 where
const where = finalWhere;
-
+
// 정렬
const orderBy =
input.sort.length > 0
? input.sort.map((item) =>
- item.desc ? desc(vendors[item.id]) : asc(vendors[item.id])
+ item.desc ? desc(vendorsWithTypesView[item.id]) : asc(vendorsWithTypesView[item.id])
)
- : [asc(vendors.createdAt)];
-
+ : [asc(vendorsWithTypesView.createdAt)];
+
// 트랜잭션 내에서 데이터 조회
const { data, total } = await db.transaction(async (tx) => {
- // 1) vendor 목록 조회
- const vendorsData = await selectVendors(tx, {
+ // 1) vendor 목록 조회 (view 사용)
+ const vendorsData = await selectVendorsWithTypes(tx, {
where,
orderBy,
offset,
limit: input.perPage,
});
-
+
// 2) 각 vendor의 attachments 조회
const vendorsWithAttachments = await Promise.all(
vendorsData.map(async (vendor) => {
@@ -134,7 +141,7 @@ export async function getVendors(input: GetVendorsSchema) {
})
.from(vendorAttachments)
.where(eq(vendorAttachments.vendorId, vendor.id));
-
+
return {
...vendor,
hasAttachments: attachments.length > 0,
@@ -142,17 +149,18 @@ export async function getVendors(input: GetVendorsSchema) {
};
})
);
-
+
// 3) 전체 개수
- const total = await countVendors(tx, where);
+ const total = await countVendorsWithTypes(tx, where);
return { data: vendorsWithAttachments, total };
});
-
+
// 페이지 수
const pageCount = Math.ceil(total / input.perPage);
-
+
return { data, pageCount };
} catch (err) {
+ console.error("Error fetching vendors:", err);
// 에러 발생 시
return { data: [], pageCount: 0 };
}
@@ -165,7 +173,6 @@ export async function getVendors(input: GetVendorsSchema) {
)();
}
-
export async function getVendorStatusCounts() {
return unstable_cache(
async () => {
@@ -252,34 +259,58 @@ async function storeVendorFiles(
}
}
+
+export async function getVendorTypes() {
+ unstable_noStore(); // Next.js server action caching prevention
+
+ try {
+ const types = await db
+ .select({
+ id: vendorTypes.id,
+ code: vendorTypes.code,
+ nameKo: vendorTypes.nameKo,
+ nameEn: vendorTypes.nameEn,
+ })
+ .from(vendorTypes)
+ .orderBy(vendorTypes.nameKo);
+
+ return { data: types, error: null };
+ } catch (error) {
+ return { data: null, error: getErrorMessage(error) };
+ }
+}
+
export type CreateVendorData = {
vendorName: string
+ vendorTypeId: number
vendorCode?: string
+ items?: string
website?: string
taxId: string
address?: string
email: string
phone?: string
-
+
representativeName?: string
representativeBirth?: string
representativeEmail?: string
representativePhone?: string
-
+
creditAgency?: string
creditRating?: string
cashFlowRating?: string
corporateRegistrationNumber?: string
-
+
country?: string
status?: "PENDING_REVIEW" | "IN_REVIEW" | "IN_PQ" | "PQ_FAILED" | "APPROVED" | "ACTIVE" | "INACTIVE" | "BLACKLISTED" | "PQ_SUBMITTED"
}
+// Updated createVendor function with taxId duplicate check
export async function createVendor(params: {
vendorData: CreateVendorData
// 기존의 일반 첨부파일
files?: File[]
-
+
// 신용평가 / 현금흐름 등급 첨부
creditRatingFiles?: File[]
cashFlowRatingFiles?: File[]
@@ -292,17 +323,17 @@ export async function createVendor(params: {
}[]
}) {
unstable_noStore() // Next.js 서버 액션 캐싱 방지
-
+
try {
const { vendorData, files = [], creditRatingFiles = [], cashFlowRatingFiles = [], contacts } = params
-
+
// 이메일 중복 검사 - 이미 users 테이블에 존재하는지 확인
const existingUser = await db
.select({ id: users.id })
.from(users)
.where(eq(users.email, vendorData.email))
.limit(1);
-
+
// 이미 사용자가 존재하면 에러 반환
if (existingUser.length > 0) {
return {
@@ -310,7 +341,22 @@ export async function createVendor(params: {
error: `이미 등록된 이메일입니다. 다른 이메일을 사용해주세요. (Email ${vendorData.email} already exists in the system)`
};
}
-
+
+ // taxId 중복 검사 추가
+ const existingVendor = await db
+ .select({ id: vendors.id })
+ .from(vendors)
+ .where(eq(vendors.taxId, vendorData.taxId))
+ .limit(1);
+
+ // 이미 동일한 taxId를 가진 업체가 존재하면 에러 반환
+ if (existingVendor.length > 0) {
+ return {
+ data: null,
+ error: `이미 등록된 사업자등록번호입니다. (Tax ID ${vendorData.taxId} already exists in the system)`
+ };
+ }
+
await db.transaction(async (tx) => {
// 1) Insert the vendor (확장 필드도 함께)
const [newVendor] = await insertVendor(tx, {
@@ -323,36 +369,38 @@ export async function createVendor(params: {
website: vendorData.website || null,
status: vendorData.status ?? "PENDING_REVIEW",
taxId: vendorData.taxId,
-
+ vendorTypeId: vendorData.vendorTypeId,
+ items: vendorData.items || null,
+
// 대표자 정보
representativeName: vendorData.representativeName || null,
representativeBirth: vendorData.representativeBirth || null,
representativeEmail: vendorData.representativeEmail || null,
representativePhone: vendorData.representativePhone || null,
corporateRegistrationNumber: vendorData.corporateRegistrationNumber || null,
-
+
// 신용/현금흐름
creditAgency: vendorData.creditAgency || null,
creditRating: vendorData.creditRating || null,
cashFlowRating: vendorData.cashFlowRating || null,
})
-
+
// 2) If there are attached files, store them
// (2-1) 일반 첨부
if (files.length > 0) {
await storeVendorFiles(tx, newVendor.id, files, "GENERAL")
}
-
+
// (2-2) 신용평가 파일
if (creditRatingFiles.length > 0) {
await storeVendorFiles(tx, newVendor.id, creditRatingFiles, "CREDIT_RATING")
}
-
+
// (2-3) 현금흐름 파일
if (cashFlowRatingFiles.length > 0) {
await storeVendorFiles(tx, newVendor.id, cashFlowRatingFiles, "CASH_FLOW_RATING")
}
-
+
for (const contact of contacts) {
await tx.insert(vendorContacts).values({
vendorId: newVendor.id,
@@ -364,7 +412,7 @@ export async function createVendor(params: {
})
}
})
-
+
revalidateTag("vendors")
return { data: null, error: null }
} catch (error) {
@@ -377,12 +425,26 @@ export async function createVendor(params: {
/** 단건 업데이트 */
export async function modifyVendor(
- input: UpdateVendorSchema & { id: string }
+ input: UpdateVendorSchema & { id: string; userId: number; comment:string; } // userId 추가
) {
unstable_noStore();
try {
const updated = await db.transaction(async (tx) => {
- // 특정 ID 벤더를 업데이트
+ // 1. 업데이트 전에 기존 벤더 정보를 가져옴
+ const existingVendor = await tx.query.vendors.findFirst({
+ where: eq(vendors.id, parseInt(input.id)),
+ columns: {
+ status: true, // 상태 변경 로깅에 필요한 현재 상태만 가져옴
+ },
+ });
+
+ if (!existingVendor) {
+ throw new Error(`Vendor with ID ${input.id} not found`);
+ }
+
+ const oldStatus = existingVendor.status;
+
+ // 2. 벤더 정보 업데이트
const [res] = await updateVendor(tx, input.id, {
vendorName: input.vendorName,
vendorCode: input.vendorCode,
@@ -391,8 +453,32 @@ export async function modifyVendor(
phone: input.phone,
email: input.email,
website: input.website,
+ creditAgency: input.creditAgency,
+ creditRating: input.creditRating,
+ cashFlowRating: input.cashFlowRating,
status: input.status,
});
+
+ // 3. 상태가 변경되었다면 로그 기록
+ if (oldStatus !== input.status) {
+ await tx.insert(vendorsLogs).values({
+ vendorId: parseInt(input.id),
+ userId: input.userId,
+ action: "status_change",
+ oldStatus,
+ newStatus: input.status,
+ comment: input.comment || `Status changed from ${oldStatus} to ${input.status}`,
+ });
+ } else if (input.comment) {
+ // 상태 변경이 없더라도 코멘트가 있으면 로그 기록
+ await tx.insert(vendorsLogs).values({
+ vendorId: parseInt(input.id),
+ userId: input.userId,
+ action: "vendor_updated",
+ comment: input.comment,
+ });
+ }
+
return res;
});
@@ -414,7 +500,7 @@ export async function modifyVendors(input: {
unstable_noStore();
try {
const data = await db.transaction(async (tx) => {
- // 여러 벤더 일괄 업데이트
+ // 여러 협력업체 일괄 업데이트
const [updated] = await updateVendors(tx, input.ids, {
// 예: 상태만 일괄 변경
status: input.status,
@@ -560,7 +646,7 @@ export async function createVendorContact(input: CreateVendorContactSchema) {
return newContact;
});
- // 캐시 무효화 (벤더 연락처 목록 등)
+ // 캐시 무효화 (협력업체 연락처 목록 등)
revalidateTag(`vendor-contacts-${input.vendorId}`);
return { data: null, error: null };
@@ -723,7 +809,7 @@ export async function createVendorItem(input: CreateVendorItemSchema) {
return newContact;
});
- // 캐시 무효화 (벤더 연락처 목록 등)
+ // 캐시 무효화 (협력업체 연락처 목록 등)
revalidateTag(`vendor-items-${input.vendorId}`);
return { data: null, error: null };
@@ -885,98 +971,55 @@ interface CreateCompanyInput {
/**
- * 벤더 첨부파일 다운로드를 위한 서버 액션
- * @param vendorId 벤더 ID
+ * 협력업체 첨부파일 다운로드를 위한 서버 액션
+ * @param vendorId 협력업체 ID
* @param fileId 특정 파일 ID (단일 파일 다운로드시)
* @returns 다운로드할 수 있는 임시 URL
*/
-export async function downloadVendorAttachments(vendorId: number, fileId?: number) {
+export async function downloadVendorAttachments(vendorId:number, fileId?:number) {
try {
- // 벤더 정보 조회
- const vendor = await db.query.vendors.findFirst({
- where: eq(vendors.id, vendorId)
+ // API 경로 생성 (단일 파일 또는 모든 파일)
+ const url = fileId
+ ? `/api/vendors/attachments/download?id=${fileId}&vendorId=${vendorId}`
+ : `/api/vendors/attachments/download-all?vendorId=${vendorId}`;
+
+ // fetch 요청 (기본적으로 Blob으로 응답 받기)
+ const response = await fetch(url, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
});
-
- if (!vendor) {
- throw new Error(`벤더 정보를 찾을 수 없습니다. (ID: ${vendorId})`);
- }
-
- // 첨부파일 조회 (특정 파일 또는 모든 파일)
- const attachments = fileId
- ? await db.select()
- .from(vendorAttachments)
- .where(eq(vendorAttachments.id, fileId))
- : await db.select()
- .from(vendorAttachments)
- .where(eq(vendorAttachments.vendorId, vendorId));
-
- if (!attachments.length) {
- throw new Error('다운로드할 첨부파일이 없습니다.');
+
+ if (!response.ok) {
+ throw new Error(`Server responded with ${response.status}: ${response.statusText}`);
}
-
- // 업로드 기본 경로
- const basePath = process.env.UPLOAD_DIR || path.join(process.cwd(), 'uploads');
-
- // 단일 파일인 경우 직접 URL 반환
- if (attachments.length === 1) {
- const attachment = attachments[0];
- const filePath = `/api/vendors/attachments/download?id=${attachment.id}`;
- return { url: filePath, fileName: attachment.fileName };
+
+ // 파일명 가져오기 (Content-Disposition 헤더에서)
+ const contentDisposition = response.headers.get('content-disposition');
+ let fileName = fileId ? `file-${fileId}.zip` : `vendor-${vendorId}-files.zip`;
+
+ if (contentDisposition) {
+ const matches = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(contentDisposition);
+ if (matches && matches[1]) {
+ fileName = matches[1].replace(/['"]/g, '');
+ }
}
-
- // 다중 파일: 임시 ZIP 생성 후 URL 반환
- // 임시 디렉토리 생성
- const tempDir = path.join(process.cwd(), 'tmp');
- await fsPromises.mkdir(tempDir, { recursive: true });
-
- // 고유 ID로 임시 ZIP 파일명 생성
- const tempId = randomUUID();
- const zipFileName = `${vendor.vendorName || `vendor-${vendorId}`}-attachments-${tempId}.zip`;
- const zipFilePath = path.join(tempDir, zipFileName);
-
- // JSZip을 사용하여 ZIP 파일 생성
- const zip = new JSZip();
-
- // 파일 읽기 및 추가 작업을 병렬로 처리
- await Promise.all(
- attachments.map(async (attachment) => {
- const filePath = path.join(basePath, attachment.filePath);
-
- try {
- // 파일 존재 확인 (fsPromises.access 사용)
- try {
- await fsPromises.access(filePath, fs.constants.F_OK);
- } catch (e) {
- console.warn(`파일이 존재하지 않습니다: ${filePath}`);
- return; // 파일이 없으면 건너뜀
- }
-
- // 파일 읽기 (fsPromises.readFile 사용)
- const fileData = await fsPromises.readFile(filePath);
-
- // ZIP에 파일 추가
- zip.file(attachment.fileName, fileData);
- } catch (error) {
- console.warn(`파일을 처리할 수 없습니다: ${filePath}`, error);
- // 오류가 있더라도 계속 진행
- }
- })
- );
-
- // ZIP 생성 및 저장
- const zipContent = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE', compressionOptions: { level: 9 } });
- await fsPromises.writeFile(zipFilePath, zipContent);
-
- // 임시 ZIP 파일에 접근할 수 있는 URL 생성
- const downloadUrl = `/api/vendors/attachments/download-temp?file=${encodeURIComponent(zipFileName)}`;
-
- return {
- url: downloadUrl,
- fileName: `${vendor.vendorName || `vendor-${vendorId}`}-attachments.zip`
+
+ // Blob으로 응답 변환
+ const blob = await response.blob();
+
+ // Blob URL 생성
+ const blobUrl = window.URL.createObjectURL(blob);
+
+ return {
+ url: blobUrl,
+ fileName,
+ blob
};
} catch (error) {
- console.error('첨부파일 다운로드 서버 액션 오류:', error);
- throw new Error('첨부파일 다운로드 준비 중 오류가 발생했습니다.');
+ console.error('Download API error:', error);
+ throw error;
}
}
@@ -1016,13 +1059,22 @@ interface ApproveVendorsInput {
/**
* 선택된 벤더의 상태를 IN_REVIEW로 변경하고 이메일 알림을 발송하는 서버 액션
*/
-export async function approveVendors(input: ApproveVendorsInput) {
+export async function approveVendors(input: ApproveVendorsInput & { userId: number }) {
unstable_noStore();
try {
- // 트랜잭션 내에서 벤더 상태 업데이트, 유저 생성 및 이메일 발송
+ // 트랜잭션 내에서 협력업체 상태 업데이트, 유저 생성 및 이메일 발송
const result = await db.transaction(async (tx) => {
- // 1. 벤더 상태 업데이트
+ // 0. 업데이트 전 협력업체 상태 조회
+ const vendorsBeforeUpdate = await tx
+ .select({
+ id: vendors.id,
+ status: vendors.status,
+ })
+ .from(vendors)
+ .where(inArray(vendors.id, input.ids));
+
+ // 1. 협력업체 상태 업데이트
const [updated] = await tx
.update(vendors)
.set({
@@ -1032,7 +1084,7 @@ export async function approveVendors(input: ApproveVendorsInput) {
.where(inArray(vendors.id, input.ids))
.returning();
- // 2. 업데이트된 벤더 정보 조회
+ // 2. 업데이트된 협력업체 정보 조회
const updatedVendors = await tx
.select({
id: vendors.id,
@@ -1067,18 +1119,35 @@ export async function approveVendors(input: ApproveVendorsInput) {
})
);
- // 4. 각 벤더에게 이메일 발송
+ // 4. 로그 기록
+ await Promise.all(
+ vendorsBeforeUpdate.map(async (vendorBefore) => {
+ await tx.insert(vendorsLogs).values({
+ vendorId: vendorBefore.id,
+ userId: input.userId,
+ action: "status_change",
+ oldStatus: vendorBefore.status,
+ newStatus: "IN_REVIEW",
+ comment: "Vendor approved for review",
+ });
+ })
+ );
+
+ // 5. 각 벤더에게 이메일 발송
await Promise.all(
updatedVendors.map(async (vendor) => {
if (!vendor.email) return; // 이메일이 없으면 스킵
try {
- const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기
+ const userLang = "en"; // 기본값, 필요시 협력업체 언어 설정에서 가져오기
const subject =
"[eVCP] Admin Account Created";
- const loginUrl = "http://3.36.56.124:3000/en/login";
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+ const baseUrl = `http://${host}`
+ const loginUrl = `${baseUrl}/en/login`;
await sendEmail({
to: vendor.email,
@@ -1112,7 +1181,7 @@ export async function approveVendors(input: ApproveVendorsInput) {
}
}
-export async function requestPQVendors(input: ApproveVendorsInput) {
+export async function requestPQVendors(input: ApproveVendorsInput & { userId: number }) {
unstable_noStore();
try {
@@ -1134,9 +1203,18 @@ export async function requestPQVendors(input: ApproveVendorsInput) {
}
}
- // 트랜잭션 내에서 벤더 상태 업데이트 및 이메일 발송
+ // 트랜잭션 내에서 협력업체 상태 업데이트 및 이메일 발송
const result = await db.transaction(async (tx) => {
- // 1. 벤더 상태 업데이트
+ // 0. 업데이트 전 협력업체 상태 조회
+ const vendorsBeforeUpdate = await tx
+ .select({
+ id: vendors.id,
+ status: vendors.status,
+ })
+ .from(vendors)
+ .where(inArray(vendors.id, input.ids));
+
+ // 1. 협력업체 상태 업데이트
const [updated] = await tx
.update(vendors)
.set({
@@ -1146,7 +1224,7 @@ export async function requestPQVendors(input: ApproveVendorsInput) {
.where(inArray(vendors.id, input.ids))
.returning();
- // 2. 업데이트된 벤더 정보 조회
+ // 2. 업데이트된 협력업체 정보 조회
const updatedVendors = await tx
.select({
id: vendors.id,
@@ -1169,14 +1247,33 @@ export async function requestPQVendors(input: ApproveVendorsInput) {
await tx.insert(vendorProjectPQs).values(vendorProjectPQsData);
}
-
- // 4. 각 벤더에게 이메일 발송
+
+ // 4. 로그 기록
+ await Promise.all(
+ vendorsBeforeUpdate.map(async (vendorBefore) => {
+ await tx.insert(vendorsLogs).values({
+ vendorId: vendorBefore.id,
+ userId: input.userId,
+ action: "status_change",
+ oldStatus: vendorBefore.status,
+ newStatus: "IN_PQ",
+ comment: input.projectId
+ ? `Project PQ requested (Project: ${projectInfo?.projectCode || input.projectId})`
+ : "PQ requested",
+ });
+ })
+ );
+
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+
+ // 5. 각 벤더에게 이메일 발송
await Promise.all(
updatedVendors.map(async (vendor) => {
if (!vendor.email) return; // 이메일이 없으면 스킵
try {
- const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기
+ const userLang = "en"; // 기본값, 필요시 협력업체 언어 설정에서 가져오기
// 프로젝트 PQ인지 일반 PQ인지에 따라 제목 변경
const subject = input.projectId
@@ -1184,7 +1281,7 @@ export async function requestPQVendors(input: ApproveVendorsInput) {
: "[eVCP] You are invited to submit PQ";
// 로그인 URL에 프로젝트 ID 추가 (프로젝트 PQ인 경우)
- const baseLoginUrl = "http://3.36.56.124:3000/en/login";
+ const baseLoginUrl = `${host}/partners/pq`;
const loginUrl = input.projectId
? `${baseLoginUrl}?projectId=${input.projectId}`
: baseLoginUrl;
@@ -1192,7 +1289,8 @@ export async function requestPQVendors(input: ApproveVendorsInput) {
await sendEmail({
to: vendor.email,
subject,
- template: input.projectId ? "project-pq" : "pq", // 프로젝트별 템플릿 사용
+ template:input.projectId ? "project-pq" : "pq", // 프로젝트별 템플릿 사용
+ // template: "vendor-pq-status", // 프로젝트별 템플릿 사용
context: {
vendorName: vendor.vendorName,
loginUrl,
@@ -1225,21 +1323,20 @@ export async function requestPQVendors(input: ApproveVendorsInput) {
return { data: null, error: getErrorMessage(err) };
}
}
-
interface SendVendorsInput {
ids: number[];
}
/**
- * APPROVED 상태인 벤더 정보를 기간계 시스템에 전송하고 벤더 코드를 업데이트하는 서버 액션
+ * APPROVED 상태인 협력업체 정보를 기간계 시스템에 전송하고 협력업체 코드를 업데이트하는 서버 액션
*/
-export async function sendVendors(input: SendVendorsInput) {
+export async function sendVendors(input: SendVendorsInput & { userId: number }) {
unstable_noStore();
try {
// 트랜잭션 내에서 진행
const result = await db.transaction(async (tx) => {
- // 1. 선택된 벤더 중 APPROVED 상태인 벤더만 필터링
+ // 1. 선택된 협력업체 중 APPROVED 상태인 벤더만 필터링
const approvedVendors = await db.query.vendors.findMany({
where: and(
inArray(vendors.id, input.ids),
@@ -1255,16 +1352,16 @@ export async function sendVendors(input: SendVendorsInput) {
// 2. 각 벤더에 대해 처리
for (const vendor of approvedVendors) {
- // 2-1. 벤더 연락처 정보 조회
+ // 2-1. 협력업체 연락처 정보 조회
const contacts = await db.query.vendorContacts.findMany({
where: eq(vendorContacts.vendorId, vendor.id)
});
- // 2-2. 벤더 가능 아이템 조회
+ // 2-2. 협력업체 가능 아이템 조회
const possibleItems = await db.query.vendorPossibleItems.findMany({
where: eq(vendorPossibleItems.vendorId, vendor.id)
});
- // 2-3. 벤더 첨부파일 조회
+ // 2-3. 협력업체 첨부파일 조회
const attachments = await db.query.vendorAttachments.findMany({
where: eq(vendorAttachments.vendorId, vendor.id),
columns: {
@@ -1274,8 +1371,7 @@ export async function sendVendors(input: SendVendorsInput) {
}
});
-
- // 2-4. 벤더 정보를 기간계 시스템에 전송 (NextJS API 라우트 사용)
+ // 2-4. 협력업체 정보를 기간계 시스템에 전송 (NextJS API 라우트 사용)
const vendorData = {
id: vendor.id,
vendorName: vendor.vendorName,
@@ -1311,7 +1407,7 @@ export async function sendVendors(input: SendVendorsInput) {
throw new Error(`Invalid response from ERP system for vendor ${vendor.id}`);
}
- // 2-5. 벤더 코드 및 상태 업데이트
+ // 2-5. 협력업체 코드 및 상태 업데이트
const vendorCode = responseData.vendorCode;
const [updated] = await tx
@@ -1324,16 +1420,27 @@ export async function sendVendors(input: SendVendorsInput) {
.where(eq(vendors.id, vendor.id))
.returning();
- // 2-6. 벤더에게 알림 이메일 발송
+ // 2-6. 로그 기록
+ await tx.insert(vendorsLogs).values({
+ vendorId: vendor.id,
+ userId: input.userId,
+ action: "status_change",
+ oldStatus: "APPROVED",
+ newStatus: "ACTIVE",
+ comment: `Sent to ERP system. Vendor code assigned: ${vendorCode}`,
+ });
+
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+
+ // 2-7. 벤더에게 알림 이메일 발송
if (vendor.email) {
- const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기
+ const userLang = "en"; // 기본값, 필요시 협력업체 언어 설정에서 가져오기
const subject =
"[eVCP] Vendor Registration Completed";
- const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000'
-
- const portalUrl = `${baseUrl}/en/partners`;
+ const portalUrl = `http://${host}/en/partners`;
await sendEmail({
to: vendor.email,
@@ -1355,12 +1462,20 @@ export async function sendVendors(input: SendVendorsInput) {
message: "Successfully sent to ERP system",
});
} catch (vendorError) {
- // 개별 벤더 처리 오류 기록
+ // 개별 협력업체 처리 오류 기록
results.push({
id: vendor.id,
success: false,
error: getErrorMessage(vendorError),
});
+
+ // 에러가 발생해도 로그는 기록
+ await tx.insert(vendorsLogs).values({
+ vendorId: vendor.id,
+ userId: input.userId,
+ action: "erp_send_failed",
+ comment: `Failed to send to ERP: ${getErrorMessage(vendorError)}`,
+ });
}
}
@@ -1387,55 +1502,68 @@ export async function sendVendors(input: SendVendorsInput) {
}
}
-
interface RequestInfoProps {
ids: number[];
+ userId: number; // 추가: 어떤 사용자가 요청했는지 로깅하기 위함
}
-export async function requestInfo({ ids }: RequestInfoProps) {
+export async function requestInfo({ ids, userId }: RequestInfoProps) {
try {
- // 1. 벤더 정보 가져오기
- const vendorList = await db.query.vendors.findMany({
- where: inArray(vendors.id, ids),
- });
-
- if (!vendorList.length) {
- return { error: "벤더 정보를 찾을 수 없습니다." };
- }
+ return await db.transaction(async (tx) => {
+ // 1. 협력업체 정보 가져오기
+ const vendorList = await tx.query.vendors.findMany({
+ where: inArray(vendors.id, ids),
+ });
- // 2. 각 벤더에게 이메일 보내기
- for (const vendor of vendorList) {
- // 이메일이 없는 경우 스킵
- if (!vendor.email) continue;
+ if (!vendorList.length) {
+ return { error: "협력업체 정보를 찾을 수 없습니다." };
+ }
- // 벤더 정보 페이지 URL 생성
- const vendorInfoUrl = `${process.env.NEXT_PUBLIC_APP_URL}/partners/info?vendorId=${vendor.id}`;
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+
+ // 2. 각 벤더에 대한 로그 기록 및 이메일 발송
+ for (const vendor of vendorList) {
+ // 로그 기록
+ await tx.insert(vendorsLogs).values({
+ vendorId: vendor.id,
+ userId: userId,
+ action: "info_requested",
+ comment: "추가 정보 요청됨",
+ });
- // 벤더에게 이메일 보내기
- await sendEmail({
- to: vendor.email,
- subject: "[EVCP] 추가 정보 요청 / Additional Information Request",
- template: "vendor-additional-info",
- context: {
- vendorName: vendor.vendorName,
- vendorInfoUrl: vendorInfoUrl,
- language: "ko", // 기본 언어 설정, 벤더의 선호 언어가 있다면 그것을 사용할 수 있음
- },
- });
- }
+ // 이메일이 없는 경우 스킵
+ if (!vendor.email) continue;
+
+ // 협력업체 정보 페이지 URL 생성
+ const vendorInfoUrl = `http://${host}/partners/info?vendorId=${vendor.id}`;
+
+ // 벤더에게 이메일 보내기
+ await sendEmail({
+ to: vendor.email,
+ subject: "[EVCP] 추가 정보 요청 / Additional Information Request",
+ template: "vendor-additional-info",
+ context: {
+ vendorName: vendor.vendorName,
+ vendorInfoUrl: vendorInfoUrl,
+ language: "ko", // 기본 언어 설정, 벤더의 선호 언어가 있다면 그것을 사용할 수 있음
+ },
+ });
+ }
- // 3. 성공적으로 처리됨
- return { success: true };
+ // 3. 성공적으로 처리됨
+ return { success: true };
+ });
} catch (error) {
- console.error("벤더 정보 요청 중 오류 발생:", error);
- return { error: "벤더 정보 요청 중 오류가 발생했습니다. 다시 시도해 주세요." };
+ console.error("협력업체 정보 요청 중 오류 발생:", error);
+ return { error: "협력업체 정보 요청 중 오류가 발생했습니다. 다시 시도해 주세요." };
}
}
export async function getVendorDetailById(id: number) {
try {
- // View를 통해 벤더 정보 조회
+ // View를 통해 협력업체 정보 조회
const vendor = await db
.select()
.from(vendorDetailView)
@@ -1496,7 +1624,7 @@ export type ContactInfo = {
}
/**
- * 벤더 정보를 업데이트하는 함수
+ * 협력업체 정보를 업데이트하는 함수
*/
export async function updateVendorInfo(params: {
vendorData: UpdateVendorInfoData
@@ -1533,7 +1661,7 @@ export async function updateVendorInfo(params: {
// 트랜잭션으로 업데이트 수행
await db.transaction(async (tx) => {
- // 1. 벤더 정보 업데이트
+ // 1. 협력업체 정보 업데이트
await tx.update(vendors).set({
vendorName: vendorData.vendorName,
address: vendorData.address || null,
@@ -1672,7 +1800,7 @@ export async function updateVendorInfo(params: {
return {
data: {
success: true,
- message: '벤더 정보가 성공적으로 업데이트되었습니다.',
+ message: '협력업체 정보가 성공적으로 업데이트되었습니다.',
vendorId: vendorData.id
},
error: null
@@ -1681,4 +1809,205 @@ export async function updateVendorInfo(params: {
console.error("Vendor info update error:", error);
return { data: null, error: getErrorMessage(error) }
}
+}
+
+
+
+export interface VendorsLogWithUser {
+ id: number
+ vendorCandidateId: number
+ userId: number
+ userName: string | null
+ userEmail: string | null
+ action: string
+ oldStatus: string | null
+ newStatus: string | null
+ comment: string | null
+ createdAt: Date
+}
+
+export async function getVendorLogs(vendorId: number): Promise<VendorsLogWithUser[]> {
+ try {
+ const logs = await db
+ .select({
+ id: vendorsLogs.id,
+ vendorCandidateId: vendorsLogs.vendorId,
+ userId: vendorsLogs.userId,
+ action: vendorsLogs.action,
+ oldStatus: vendorsLogs.oldStatus,
+ newStatus: vendorsLogs.newStatus,
+ comment: vendorsLogs.comment,
+ createdAt: vendorsLogs.createdAt,
+
+ // 조인한 users 테이블 필드
+ userName: users.name,
+ userEmail: users.email,
+ })
+ .from(vendorsLogs)
+ .leftJoin(users, eq(vendorsLogs.userId, users.id))
+ .where(eq(vendorsLogs.vendorId, vendorId))
+ .orderBy(desc(vendorsLogs.createdAt))
+
+ return logs
+ } catch (error) {
+ console.error("Failed to fetch candidate logs with user info:", error)
+ throw error
+ }
+}
+
+
+
+/**
+ * 엑셀 내보내기용 벤더 연락처 목록 조회
+ * - 페이지네이션 없이 모든 연락처 반환
+ */
+export async function exportVendorContacts(vendorId: number) {
+ try {
+ const contacts = await db
+ .select()
+ .from(vendorContacts)
+ .where(eq(vendorContacts.vendorId, vendorId))
+ .orderBy(vendorContacts.isPrimary, vendorContacts.contactName);
+
+ return contacts;
+ } catch (error) {
+ console.error("Failed to export vendor contacts:", error);
+ return [];
+ }
+}
+
+/**
+ * 엑셀 내보내기용 벤더 아이템 목록 조회
+ * - 페이지네이션 없이 모든 아이템 정보 반환
+ */
+export async function exportVendorItems(vendorId: number) {
+ try {
+ const vendorItems = await db
+ .select({
+ id: vendorItemsView.vendorItemId,
+ vendorId: vendorItemsView.vendorId,
+ itemName: vendorItemsView.itemName,
+ itemCode: vendorItemsView.itemCode,
+ description: vendorItemsView.description,
+ createdAt: vendorItemsView.createdAt,
+ updatedAt: vendorItemsView.updatedAt,
+ })
+ .from(vendorItemsView)
+ .where(eq(vendorItemsView.vendorId, vendorId))
+ .orderBy(vendorItemsView.itemName);
+
+ return vendorItems;
+ } catch (error) {
+ console.error("Failed to export vendor items:", error);
+ return [];
+ }
+}
+
+/**
+ * 엑셀 내보내기용 벤더 RFQ 목록 조회
+ * - 페이지네이션 없이 모든 RFQ 정보 반환
+ */
+export async function exportVendorRFQs(vendorId: number) {
+ try {
+ const rfqs = await db
+ .select()
+ .from(vendorRfqView)
+ .where(eq(vendorRfqView.vendorId, vendorId))
+ .orderBy(vendorRfqView.rfqVendorUpdated);
+
+ return rfqs;
+ } catch (error) {
+ console.error("Failed to export vendor RFQs:", error);
+ return [];
+ }
+}
+
+/**
+ * 엑셀 내보내기용 벤더 계약 목록 조회
+ * - 페이지네이션 없이 모든 계약 정보 반환
+ */
+export async function exportVendorContracts(vendorId: number) {
+ try {
+ const contracts = await db
+ .select()
+ .from(contractsDetailView)
+ .where(eq(contractsDetailView.vendorId, vendorId))
+ .orderBy(contractsDetailView.createdAt);
+
+ return contracts;
+ } catch (error) {
+ console.error("Failed to export vendor contracts:", error);
+ return [];
+ }
+}
+
+/**
+ * 엑셀 내보내기용 벤더 정보 조회
+ * - 페이지네이션 없이 모든 벤더 정보 반환
+ */
+export async function exportVendorDetails(vendorIds: number[]) {
+ try {
+ if (!vendorIds.length) return [];
+
+ // 벤더 기본 정보 조회
+ const vendorsData = await db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ taxId: vendors.taxId,
+ address: vendors.address,
+ country: vendors.country,
+ phone: vendors.phone,
+ email: vendors.email,
+ website: vendors.website,
+ status: vendors.status,
+ representativeName: vendors.representativeName,
+ representativeBirth: vendors.representativeBirth,
+ representativeEmail: vendors.representativeEmail,
+ representativePhone: vendors.representativePhone,
+ corporateRegistrationNumber: vendors.corporateRegistrationNumber,
+ creditAgency: vendors.creditAgency,
+ creditRating: vendors.creditRating,
+ cashFlowRating: vendors.cashFlowRating,
+ createdAt: vendors.createdAt,
+ updatedAt: vendors.updatedAt,
+ })
+ .from(vendors)
+ .where(
+ vendorIds.length === 1
+ ? eq(vendors.id, vendorIds[0])
+ : inArray(vendors.id, vendorIds)
+ );
+
+ // 벤더별 상세 정보를 포함하여 반환
+ const vendorsWithDetails = await Promise.all(
+ vendorsData.map(async (vendor) => {
+ // 연락처 조회
+ const contacts = await exportVendorContacts(vendor.id);
+
+ // 아이템 조회
+ const items = await exportVendorItems(vendor.id);
+
+ // RFQ 조회
+ const rfqs = await exportVendorRFQs(vendor.id);
+
+ // 계약 조회
+ const contracts = await exportVendorContracts(vendor.id);
+
+ return {
+ ...vendor,
+ vendorContacts: contacts,
+ vendorItems: items,
+ vendorRfqs: rfqs,
+ vendorContracts: contracts,
+ };
+ })
+ );
+
+ return vendorsWithDetails;
+ } catch (error) {
+ console.error("Failed to export vendor details:", error);
+ return [];
+ }
} \ No newline at end of file