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.ts1345
1 files changed, 1345 insertions, 0 deletions
diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts
new file mode 100644
index 00000000..2da16888
--- /dev/null
+++ b/lib/vendors/service.ts
@@ -0,0 +1,1345 @@
+"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택)
+
+import { revalidateTag, unstable_noStore } from "next/cache";
+import db from "@/db/db";
+import { vendorAttachments, VendorContact, vendorContacts, vendorItemsView, vendorPossibleItems, vendors, 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 {
+ selectVendors,
+ countVendors,
+ insertVendor,
+ updateVendor,
+ updateVendors, groupByStatus,
+ getVendorById,
+ getVendorContactsById,
+ selectVendorContacts,
+ countVendorContacts,
+ insertVendorContact,
+ selectVendorItems,
+ countVendorItems,
+ insertVendorItem,
+ countRfqHistory,
+ selectRfqHistory
+} from "./repository";
+
+import type {
+ CreateVendorSchema,
+ UpdateVendorSchema,
+ GetVendorsSchema,
+ GetVendorContactsSchema,
+ CreateVendorContactSchema,
+ GetVendorItemsSchema,
+ CreateVendorItemSchema,
+ GetRfqHistorySchema,
+} from "./validations";
+
+import { asc, desc, ilike, inArray, and, or, gte, lte, eq, isNull } from "drizzle-orm";
+import { rfqItems, rfqs, vendorRfqView } from "@/db/schema/rfq";
+import path from "path";
+import fs from "fs/promises";
+import { randomUUID } from "crypto";
+import JSZip from 'jszip';
+import { promises as fsPromises } from 'fs';
+import { sendEmail } from "../mail/sendEmail";
+import { PgTransaction } from "drizzle-orm/pg-core";
+import { items } from "@/db/schema/items";
+import { id_ID } from "@faker-js/faker";
+import { users } from "@/db/schema/users";
+
+
+/* -----------------------------------------------------
+ 1) 조회 관련
+----------------------------------------------------- */
+
+/**
+ * 복잡한 조건으로 Vendor 목록을 조회 (+ pagination) 하고,
+ * 총 개수에 따라 pageCount를 계산해서 리턴.
+ * Next.js의 unstable_cache를 사용해 일정 시간 캐시.
+ */
+export async function getVendors(input: GetVendorsSchema) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // 1) 고급 필터
+ const advancedWhere = filterColumns({
+ table: vendors,
+ 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)
+ );
+ }
+
+ // 최종 where 결합
+ const finalWhere = and(advancedWhere, globalWhere);
+
+ // 간단 검색 (advancedTable=false) 시 예시
+ const simpleWhere = and(
+ input.vendorName
+ ? ilike(vendors.vendorName, `%${input.vendorName}%`)
+ : undefined,
+ input.status ? ilike(vendors.status, input.status) : undefined,
+ input.country
+ ? ilike(vendors.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])
+ )
+ : [asc(vendors.createdAt)];
+
+ // 트랜잭션 내에서 데이터 조회
+ const { data, total } = await db.transaction(async (tx) => {
+ // 1) vendor 목록 조회
+ const vendorsData = await selectVendors(tx, {
+ where,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ // 2) 각 vendor의 attachments 조회
+ const vendorsWithAttachments = await Promise.all(
+ vendorsData.map(async (vendor) => {
+ const attachments = await tx
+ .select({
+ id: vendorAttachments.id,
+ fileName: vendorAttachments.fileName,
+ filePath: vendorAttachments.filePath,
+ })
+ .from(vendorAttachments)
+ .where(eq(vendorAttachments.vendorId, vendor.id));
+
+ return {
+ ...vendor,
+ hasAttachments: attachments.length > 0,
+ attachmentsList: attachments,
+ };
+ })
+ );
+
+ // 3) 전체 개수
+ const total = await countVendors(tx, where);
+ return { data: vendorsWithAttachments, total };
+ });
+
+ // 페이지 수
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount };
+ } catch (err) {
+ // 에러 발생 시
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input)], // 캐싱 키
+ {
+ revalidate: 3600,
+ tags: ["vendors"], // revalidateTag("vendors") 호출 시 무효화
+ }
+ )();
+}
+
+
+export async function getVendorStatusCounts() {
+ return unstable_cache(
+ async () => {
+ try {
+
+ const initial: Record<Vendor["status"], number> = {
+ ACTIVE: 0,
+ INACTIVE: 0,
+ BLACKLISTED: 0,
+ "PENDING_REVIEW": 0,
+ "IN_REVIEW": 0,
+ "REJECTED": 0,
+ "IN_PQ": 0,
+ "PQ_FAILED": 0,
+ "APPROVED": 0,
+ "PQ_SUBMITTED": 0
+ };
+
+
+ const result = await db.transaction(async (tx) => {
+ const rows = await groupByStatus(tx);
+ return rows.reduce<Record<Vendor["status"], number>>((acc, { status, count }) => {
+ acc[status] = count;
+ return acc;
+ }, initial);
+ });
+
+ return result;
+ } catch (err) {
+ return {} as Record<Vendor["status"], number>;
+ }
+ },
+ ["task-status-counts"], // 캐싱 키
+ {
+ revalidate: 3600,
+ }
+ )();
+}
+
+/* -----------------------------------------------------
+ 2) 생성(Create)
+----------------------------------------------------- */
+
+/**
+ * 신규 Vendor 생성
+ */
+
+async function storeVendorFiles(
+ tx: PgTransaction<any, any, any>,
+ vendorId: number,
+ files: File[],
+ attachmentType: string
+) {
+ const vendorDir = path.join(
+ process.cwd(),
+ "public",
+ "vendors",
+ String(vendorId)
+ )
+ await fs.mkdir(vendorDir, { recursive: true })
+
+ for (const file of files) {
+ // Convert file to buffer
+ const ab = await file.arrayBuffer()
+ const buffer = Buffer.from(ab)
+
+ // Generate a unique filename
+ const uniqueName = `${randomUUID()}-${file.name}`
+ const relativePath = path.join("vendors", String(vendorId), uniqueName)
+ const absolutePath = path.join(process.cwd(), "public", relativePath)
+
+ // Write to disk
+ await fs.writeFile(absolutePath, buffer)
+
+ // Insert attachment record
+ await tx.insert(vendorAttachments).values({
+ vendorId,
+ fileName: file.name,
+ filePath: "/" + relativePath.replace(/\\/g, "/"),
+ attachmentType, // "GENERAL", "CREDIT_RATING", "CASH_FLOW_RATING", ...
+ })
+ }
+}
+
+export type CreateVendorData = {
+ vendorName: string
+ vendorCode?: 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"
+}
+
+export async function createVendor(params: {
+ vendorData: CreateVendorData
+ // 기존의 일반 첨부파일
+ files?: File[]
+
+ // 신용평가 / 현금흐름 등급 첨부
+ creditRatingFiles?: File[]
+ cashFlowRatingFiles?: File[]
+ contacts: {
+ contactName: string
+ contactPosition?: string
+ contactEmail: string
+ contactPhone?: string
+ isPrimary?: boolean
+ }[]
+}) {
+ 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 {
+ data: null,
+ error: `이미 등록된 이메일입니다. 다른 이메일을 사용해주세요. (Email ${vendorData.email} already exists in the system)`
+ };
+ }
+
+ await db.transaction(async (tx) => {
+ // 1) Insert the vendor (확장 필드도 함께)
+ const [newVendor] = await insertVendor(tx, {
+ vendorName: vendorData.vendorName,
+ vendorCode: vendorData.vendorCode || null,
+ address: vendorData.address || null,
+ country: vendorData.country || null,
+ phone: vendorData.phone || null,
+ email: vendorData.email,
+ website: vendorData.website || null,
+ status: vendorData.status ?? "PENDING_REVIEW",
+ taxId: vendorData.taxId,
+
+ // 대표자 정보
+ 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,
+ contactName: contact.contactName,
+ contactPosition: contact.contactPosition || null,
+ contactEmail: contact.contactEmail,
+ contactPhone: contact.contactPhone || null,
+ isPrimary: contact.isPrimary ?? false,
+ })
+ }
+ })
+
+ revalidateTag("vendors")
+ return { data: null, error: null }
+ } catch (error) {
+ return { data: null, error: getErrorMessage(error) }
+ }
+}
+/* -----------------------------------------------------
+ 3) 업데이트 (단건/복수)
+----------------------------------------------------- */
+
+/** 단건 업데이트 */
+export async function modifyVendor(
+ input: UpdateVendorSchema & { id: string }
+) {
+ unstable_noStore();
+ try {
+ const updated = await db.transaction(async (tx) => {
+ // 특정 ID 벤더를 업데이트
+ const [res] = await updateVendor(tx, input.id, {
+ vendorName: input.vendorName,
+ vendorCode: input.vendorCode,
+ address: input.address,
+ country: input.country,
+ phone: input.phone,
+ email: input.email,
+ website: input.website,
+ status: input.status,
+ });
+ return res;
+ });
+
+ // 필요 시, status 변경 등에 따른 다른 캐시도 무효화
+ revalidateTag("vendors");
+ revalidateTag("rfq-vendors");
+
+ return { data: updated, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/** 복수 업데이트 */
+export async function modifyVendors(input: {
+ ids: string[];
+ status?: Vendor["status"];
+}) {
+ unstable_noStore();
+ try {
+ const data = await db.transaction(async (tx) => {
+ // 여러 벤더 일괄 업데이트
+ const [updated] = await updateVendors(tx, input.ids, {
+ // 예: 상태만 일괄 변경
+ status: input.status,
+ });
+ return updated;
+ });
+
+ revalidateTag("vendors");
+ if (data.status === input.status) {
+ revalidateTag("vendor-status-counts");
+ }
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+export const findVendorById = async (id: number): Promise<Vendor | null> => {
+ try {
+ logger.info({ id }, 'Fetching user by ID');
+ const vendor = await getVendorById(id);
+ if (!vendor) {
+ logger.warn({ id }, 'User not found');
+ } else {
+ logger.debug({ vendor }, 'User fetched successfully');
+ }
+ return vendor;
+ } catch (error) {
+ logger.error({ error }, 'Error fetching user by ID');
+ throw new Error('Failed to fetch user');
+ }
+};
+
+
+export const findVendorContactsById = async (id: number): Promise<VendorContact | null> => {
+ try {
+ logger.info({ id }, 'Fetching user by ID');
+ const vendor = await getVendorContactsById(id);
+ if (!vendor) {
+ logger.warn({ id }, 'User not found');
+ } else {
+ logger.debug({ vendor }, 'User fetched successfully');
+ }
+ return vendor;
+ } catch (error) {
+ logger.error({ error }, 'Error fetching user by ID');
+ throw new Error('Failed to fetch user');
+ }
+};
+
+
+export async function getVendorContacts(input: GetVendorContactsSchema, id: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // const advancedTable = input.flags.includes("advancedTable");
+ const advancedTable = true;
+
+
+ // advancedTable 모드면 filterColumns()로 where 절 구성
+ const advancedWhere = filterColumns({
+ table: vendorContacts,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(ilike(vendorContacts.contactName, s), ilike(vendorContacts.contactPosition, s)
+ , ilike(vendorContacts.contactEmail, s), ilike(vendorContacts.contactPhone, s)
+ )
+ // 필요시 여러 칼럼 OR조건 (status, priority, etc)
+ }
+
+ const vendorWhere = eq(vendorContacts.vendorId, id)
+
+ const finalWhere = and(
+ // advancedWhere or your existing conditions
+ advancedWhere,
+ globalWhere,
+ vendorWhere
+ )
+
+
+ // 아니면 ilike, inArray, gte 등으로 where 절 구성
+ const where = finalWhere
+
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(vendorContacts[item.id]) : asc(vendorContacts[item.id])
+ )
+ : [asc(vendorContacts.createdAt)];
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectVendorContacts(tx, {
+ where,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+ const total = await countVendorContacts(tx, where);
+ return { data, total };
+ });
+
+
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+
+ return { data, pageCount };
+ } catch (err) {
+ // 에러 발생 시 디폴트
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input), String(id)], // 캐싱 키
+ {
+ revalidate: 3600,
+ tags: [`vendor-contacts-${id}`], // revalidateTag("tasks") 호출 시 무효화
+ }
+ )();
+}
+
+export async function createVendorContact(input: CreateVendorContactSchema) {
+ unstable_noStore(); // Next.js 서버 액션 캐싱 방지
+ try {
+ await db.transaction(async (tx) => {
+ // DB Insert
+ const [newContact] = await insertVendorContact(tx, {
+ vendorId: input.vendorId,
+ contactName: input.contactName,
+ contactPosition: input.contactPosition || "",
+ contactEmail: input.contactEmail,
+ contactPhone: input.contactPhone || "",
+ isPrimary: input.isPrimary || false,
+ });
+ return newContact;
+ });
+
+ // 캐시 무효화 (벤더 연락처 목록 등)
+ revalidateTag(`vendor-contacts-${input.vendorId}`);
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+
+///item
+
+export async function getVendorItems(input: GetVendorItemsSchema, id: number) {
+ const cachedFunction = unstable_cache(
+
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // const advancedTable = input.flags.includes("advancedTable");
+ const advancedTable = true;
+
+ // advancedTable 모드면 filterColumns()로 where 절 구성
+ const advancedWhere = filterColumns({
+ table: vendorItemsView,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(ilike(vendorItemsView.itemCode, s)
+ , ilike(vendorItemsView.description, s)
+ )
+ // 필요시 여러 칼럼 OR조건 (status, priority, etc)
+ }
+
+ const vendorWhere = eq(vendorItemsView.vendorId, id)
+
+ const finalWhere = and(
+ // advancedWhere or your existing conditions
+ advancedWhere,
+ globalWhere,
+ vendorWhere
+ )
+
+
+ // 아니면 ilike, inArray, gte 등으로 where 절 구성
+ const where = finalWhere
+
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(vendorItemsView[item.id]) : asc(vendorItemsView[item.id])
+ )
+ : [asc(vendorItemsView.createdAt)];
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectVendorItems(tx, {
+ where,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+ const total = await countVendorItems(tx, where);
+ return { data, total };
+ });
+
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+
+ console.log(data)
+
+ return { data, pageCount };
+ } catch (err) {
+ // 에러 발생 시 디폴트
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input), String(id)], // 캐싱 키
+ {
+ revalidate: 3600,
+ tags: [`vendor-items-${id}`], // revalidateTag("tasks") 호출 시 무효화
+ }
+ );
+ return cachedFunction();
+}
+
+export interface ItemDropdownOption {
+ itemCode: string;
+ itemName: string;
+ description: string | null;
+}
+
+/**
+ * Vendor Item 추가 시 사용할 아이템 목록 조회 (전체 목록 반환)
+ * 아이템 코드, 이름, 설명만 간소화해서 반환
+ */
+export async function getItemsForVendor(vendorId: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ // 해당 vendorId가 이미 가지고 있는 itemCode 목록을 서브쿼리로 구함
+ // 그 아이템코드를 제외(notIn)하여 모든 items 테이블에서 조회
+ const itemsData = await db
+ .select({
+ itemCode: items.itemCode,
+ itemName: items.itemName,
+ description: items.description,
+ })
+ .from(items)
+ .leftJoin(
+ vendorPossibleItems,
+ eq(items.itemCode, vendorPossibleItems.itemCode)
+ )
+ // vendorPossibleItems.vendorId가 이 vendorId인 행이 없는(즉 아직 등록되지 않은) 아이템만
+ .where(
+ isNull(vendorPossibleItems.id) // 또는 isNull(vendorPossibleItems.itemCode)
+ )
+ .orderBy(asc(items.itemName))
+
+ return {
+ data: itemsData.map((item) => ({
+ itemCode: item.itemCode ?? "", // null이라면 ""로 치환
+ itemName: item.itemName,
+ description: item.description ?? "" // null이라면 ""로 치환
+ })),
+ error: null
+ }
+ } catch (err) {
+ console.error("Failed to fetch items for vendor dropdown:", err)
+ return {
+ data: [],
+ error: "아이템 목록을 불러오는데 실패했습니다.",
+ }
+ }
+ },
+ // 캐시 키를 vendorId 별로 달리 해야 한다.
+ ["items-for-vendor", String(vendorId)],
+ {
+ revalidate: 3600, // 1시간 캐싱
+ tags: ["items"], // revalidateTag("items") 호출 시 무효화
+ }
+ )()
+}
+
+export async function createVendorItem(input: CreateVendorItemSchema) {
+ unstable_noStore(); // Next.js 서버 액션 캐싱 방지
+ try {
+ await db.transaction(async (tx) => {
+ // DB Insert
+ const [newContact] = await insertVendorItem(tx, {
+ vendorId: input.vendorId,
+ itemCode: input.itemCode,
+
+ });
+ return newContact;
+ });
+
+ // 캐시 무효화 (벤더 연락처 목록 등)
+ revalidateTag(`vendor-items-${input.vendorId}`);
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+export async function getRfqHistory(input: GetRfqHistorySchema, vendorId: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ logger.info({ vendorId, input }, "Starting getRfqHistory");
+
+ const offset = (input.page - 1) * input.perPage;
+
+ // 기본 where 조건 (vendorId)
+ const vendorWhere = eq(vendorRfqView.vendorId, vendorId);
+ logger.debug({ vendorWhere }, "Vendor where condition");
+
+ // 고급 필터링
+ const advancedWhere = filterColumns({
+ table: vendorRfqView,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+ logger.debug({ advancedWhere }, "Advanced where condition");
+
+ // 글로벌 검색
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(vendorRfqView.rfqCode, s),
+ ilike(vendorRfqView.projectCode, s),
+ ilike(vendorRfqView.projectName, s)
+ );
+ logger.debug({ globalWhere, search: input.search }, "Global search condition");
+ }
+
+ const finalWhere = and(
+ advancedWhere,
+ globalWhere,
+ vendorWhere
+ );
+ logger.debug({ finalWhere }, "Final where condition");
+
+ // 정렬 조건
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(rfqs[item.id]) : asc(rfqs[item.id])
+ )
+ : [desc(rfqs.createdAt)];
+ logger.debug({ orderBy }, "Order by condition");
+
+ // 트랜잭션으로 데이터 조회
+ const { data, total } = await db.transaction(async (tx) => {
+ logger.debug("Starting transaction for RFQ history query");
+
+ const data = await selectRfqHistory(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+ logger.debug({ dataLength: data.length }, "RFQ history data fetched");
+
+ // RFQ 아이템 정보 조회
+ const rfqIds = data.map(rfq => rfq.id);
+ const items = await tx
+ .select({
+ rfqId: rfqItems.rfqId,
+ id: rfqItems.id,
+ itemCode: rfqItems.itemCode,
+ description: rfqItems.description,
+ quantity: rfqItems.quantity,
+ uom: rfqItems.uom,
+ })
+ .from(rfqItems)
+ .where(inArray(rfqItems.rfqId, rfqIds));
+
+ // RFQ 데이터에 아이템 정보 추가
+ const dataWithItems = data.map(rfq => ({
+ ...rfq,
+ items: items.filter(item => item.rfqId === rfq.id),
+ }));
+
+ const total = await countRfqHistory(tx, finalWhere);
+ logger.debug({ total }, "RFQ history total count");
+
+ return { data: dataWithItems, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+ logger.info({
+ vendorId,
+ dataLength: data.length,
+ total,
+ pageCount
+ }, "RFQ history query completed");
+
+ return { data, pageCount };
+ } catch (err) {
+ logger.error({
+ err,
+ vendorId,
+ stack: err instanceof Error ? err.stack : undefined
+ }, 'Error fetching RFQ history');
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify({ input, vendorId })],
+ {
+ revalidate: 3600,
+ tags: ["rfq-history"],
+ }
+ )();
+}
+
+export async function checkJoinPortal(taxID: string) {
+ try {
+ // 이미 등록된 회사가 있는지 검색
+ const result = await db.select().from(vendors).where(eq(vendors.taxId, taxID)).limit(1)
+
+ if (result.length > 0) {
+ // 이미 가입되어 있음
+ // data에 예시로 vendorName이나 다른 정보를 담아 반환
+ return {
+ success: false,
+ data: result[0].vendorName ?? "Already joined",
+ }
+ }
+
+ // 미가입 → 가입 가능
+ return {
+ success: true,
+ }
+ } catch (err) {
+ console.error("checkJoinPortal error:", err)
+ // 서버 에러 시
+ return {
+ success: false,
+ data: "서버 에러가 발생했습니다.",
+ }
+ }
+}
+
+interface CreateCompanyInput {
+ vendorName: string
+ taxId: string
+ email: string
+ address: string
+ phone?: string
+ country?: string
+ // 필요한 필드 추가 가능 (vendorCode, website 등)
+}
+
+
+/**
+ * 벤더 첨부파일 다운로드를 위한 서버 액션
+ * @param vendorId 벤더 ID
+ * @param fileId 특정 파일 ID (단일 파일 다운로드시)
+ * @returns 다운로드할 수 있는 임시 URL
+ */
+export async function downloadVendorAttachments(vendorId: number, fileId?: number) {
+ try {
+ // 벤더 정보 조회
+ const vendor = await db.select()
+ .from(vendors)
+ .where(eq(vendors.id, vendorId))
+ .limit(1)
+ .then(rows => rows[0]);
+
+ 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('다운로드할 첨부파일이 없습니다.');
+ }
+
+ // 업로드 기본 경로
+ 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 };
+ }
+
+ // 다중 파일: 임시 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`
+ };
+ } catch (error) {
+ console.error('첨부파일 다운로드 서버 액션 오류:', error);
+ throw new Error('첨부파일 다운로드 준비 중 오류가 발생했습니다.');
+ }
+}
+
+/**
+ * 임시 ZIP 파일 정리를 위한 서버 액션
+ * @param fileName 정리할 파일명
+ */
+export async function cleanupTempFiles(fileName: string) {
+ 'use server';
+
+ try {
+ const tempDir = path.join(process.cwd(), 'tmp');
+ const filePath = path.join(tempDir, fileName);
+
+ try {
+ // 파일 존재 확인
+ await fsPromises.access(filePath, fs.constants.F_OK);
+ // 파일 삭제
+ await fsPromises.unlink(filePath);
+ } catch {
+ // 파일이 없으면 무시
+ }
+
+ return { success: true };
+ } catch (error) {
+ console.error('임시 파일 정리 오류:', error);
+ return { success: false, error: '임시 파일 정리 중 오류가 발생했습니다.' };
+ }
+}
+
+
+interface ApproveVendorsInput {
+ ids: number[];
+}
+
+/**
+ * 선택된 벤더의 상태를 IN_REVIEW로 변경하고 이메일 알림을 발송하는 서버 액션
+ */
+export async function approveVendors(input: ApproveVendorsInput) {
+ unstable_noStore();
+
+ try {
+ // 트랜잭션 내에서 벤더 상태 업데이트, 유저 생성 및 이메일 발송
+ const result = await db.transaction(async (tx) => {
+ // 1. 벤더 상태 업데이트
+ const [updated] = await tx
+ .update(vendors)
+ .set({
+ status: "IN_REVIEW",
+ updatedAt: new Date()
+ })
+ .where(inArray(vendors.id, input.ids))
+ .returning();
+
+ // 2. 업데이트된 벤더 정보 조회
+ const updatedVendors = await tx
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ email: vendors.email,
+ })
+ .from(vendors)
+ .where(inArray(vendors.id, input.ids));
+
+ // 3. 각 벤더에 대한 유저 계정 생성
+ await Promise.all(
+ updatedVendors.map(async (vendor) => {
+ if (!vendor.email) return; // 이메일이 없으면 스킵
+
+ // 이미 존재하는 유저인지 확인
+ const existingUser = await tx
+ .select({ id: users.id })
+ .from(users)
+ .where(eq(users.email, vendor.email))
+ .limit(1);
+
+ // 유저가 존재하지 않는 경우에만 생성
+ if (existingUser.length === 0) {
+ await tx.insert(users).values({
+ name: vendor.vendorName,
+ email: vendor.email,
+ companyId: vendor.id,
+ domain: "partners", // 기본값으로 이미 설정되어 있지만 명시적으로 지정
+ });
+ }
+ })
+ );
+
+ // 4. 각 벤더에게 이메일 발송
+ await Promise.all(
+ updatedVendors.map(async (vendor) => {
+ if (!vendor.email) return; // 이메일이 없으면 스킵
+
+ try {
+ const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기
+
+ const subject =
+ "[eVCP] Admin Account Created";
+
+ const loginUrl = "http://3.36.56.124:3000/en/login";
+
+ await sendEmail({
+ to: vendor.email,
+ subject,
+ template: "admin-created", // 이메일 템플릿 이름
+ context: {
+ vendorName: vendor.vendorName,
+ loginUrl,
+ language: userLang,
+ },
+ });
+ } catch (emailError) {
+ console.error(`Failed to send email to vendor ${vendor.id}:`, emailError);
+ // 이메일 전송 실패는 전체 트랜잭션을 실패시키지 않음
+ }
+ })
+ );
+
+ return updated;
+ });
+
+ // 캐시 무효화
+ revalidateTag("vendors");
+ revalidateTag("vendor-status-counts");
+ revalidateTag("users"); // 유저 캐시도 무효화
+
+ return { data: result, error: null };
+ } catch (err) {
+ console.error("Error approving vendors:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+export async function requestPQVendors(input: ApproveVendorsInput) {
+ unstable_noStore();
+
+ try {
+ // 트랜잭션 내에서 벤더 상태 업데이트 및 이메일 발송
+ const result = await db.transaction(async (tx) => {
+ // 1. 벤더 상태 업데이트
+ const [updated] = await tx
+ .update(vendors)
+ .set({
+ status: "IN_PQ",
+ updatedAt: new Date()
+ })
+ .where(inArray(vendors.id, input.ids))
+ .returning();
+
+ // 2. 업데이트된 벤더 정보 조회
+ const updatedVendors = await tx
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ email: vendors.email,
+ })
+ .from(vendors)
+ .where(inArray(vendors.id, input.ids));
+
+ // 3. 각 벤더에게 이메일 발송
+ await Promise.all(
+ updatedVendors.map(async (vendor) => {
+ if (!vendor.email) return; // 이메일이 없으면 스킵
+
+ try {
+ const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기
+
+ const subject =
+ "[eVCP] You are invited to submit PQ";
+
+ const loginUrl = "http://3.36.56.124:3000/en/login";
+
+ await sendEmail({
+ to: vendor.email,
+ subject,
+ template: "pq", // 이메일 템플릿 이름
+ context: {
+ vendorName: vendor.vendorName,
+ loginUrl,
+ language: userLang,
+ },
+ });
+ } catch (emailError) {
+ console.error(`Failed to send email to vendor ${vendor.id}:`, emailError);
+ // 이메일 전송 실패는 전체 트랜잭션을 실패시키지 않음
+ }
+ })
+ );
+
+ return updated;
+ });
+
+ // 캐시 무효화
+ revalidateTag("vendors");
+ revalidateTag("vendor-status-counts");
+
+ return { data: result, error: null };
+ } catch (err) {
+ console.error("Error approving vendors:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+interface SendVendorsInput {
+ ids: number[];
+}
+
+/**
+ * APPROVED 상태인 벤더 정보를 기간계 시스템에 전송하고 벤더 코드를 업데이트하는 서버 액션
+ */
+export async function sendVendors(input: SendVendorsInput) {
+ unstable_noStore();
+
+ try {
+ // 트랜잭션 내에서 진행
+ const result = await db.transaction(async (tx) => {
+ // 1. 선택된 벤더 중 APPROVED 상태인 벤더만 필터링
+ const approvedVendors = await tx
+ .select()
+ .from(vendors)
+ .where(
+ and(
+ inArray(vendors.id, input.ids),
+ eq(vendors.status, "APPROVED")
+ )
+ );
+
+ if (!approvedVendors.length) {
+ throw new Error("No approved vendors found in the selection");
+ }
+
+ // 벤더별 처리 결과를 저장할 배열
+ const results = [];
+
+ // 2. 각 벤더에 대해 처리
+ for (const vendor of approvedVendors) {
+ // 2-1. 벤더 연락처 정보 조회
+ const contacts = await tx
+ .select()
+ .from(vendorContacts)
+ .where(eq(vendorContacts.vendorId, vendor.id));
+
+ // 2-2. 벤더 가능 아이템 조회
+ const possibleItems = await tx
+ .select()
+ .from(vendorPossibleItems)
+ .where(eq(vendorPossibleItems.vendorId, vendor.id));
+
+ // 2-3. 벤더 첨부파일 조회
+ const attachments = await tx
+ .select({
+ id: vendorAttachments.id,
+ fileName: vendorAttachments.fileName,
+ filePath: vendorAttachments.filePath,
+ })
+ .from(vendorAttachments)
+ .where(eq(vendorAttachments.vendorId, vendor.id));
+
+ // 2-4. 벤더 정보를 기간계 시스템에 전송 (NextJS API 라우트 사용)
+ const vendorData = {
+ id: vendor.id,
+ vendorName: vendor.vendorName,
+ taxId: vendor.taxId,
+ address: vendor.address || "",
+ country: vendor.country || "",
+ phone: vendor.phone || "",
+ email: vendor.email || "",
+ website: vendor.website || "",
+ contacts,
+ possibleItems,
+ attachments,
+ };
+
+ try {
+ // 내부 API 호출 (기간계 시스템 연동 API)
+ const erpResponse = await fetch(`/api/erp/vendors`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(vendorData),
+ });
+
+ if (!erpResponse.ok) {
+ const errorData = await erpResponse.json();
+ throw new Error(`ERP system error for vendor ${vendor.id}: ${errorData.message || erpResponse.statusText}`);
+ }
+
+ const responseData = await erpResponse.json();
+
+ if (!responseData.success || !responseData.vendorCode) {
+ throw new Error(`Invalid response from ERP system for vendor ${vendor.id}`);
+ }
+
+ // 2-5. 벤더 코드 및 상태 업데이트
+ const vendorCode = responseData.vendorCode;
+
+ const [updated] = await tx
+ .update(vendors)
+ .set({
+ vendorCode,
+ status: "ACTIVE", // 상태를 ACTIVE로 변경
+ updatedAt: new Date(),
+ })
+ .where(eq(vendors.id, vendor.id))
+ .returning();
+
+ // 2-6. 벤더에게 알림 이메일 발송
+ if (vendor.email) {
+ 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`;
+
+ await sendEmail({
+ to: vendor.email,
+ subject,
+ template: "vendor-active",
+ context: {
+ vendorName: vendor.vendorName,
+ vendorCode,
+ portalUrl,
+ language: userLang,
+ },
+ });
+ }
+
+ results.push({
+ id: vendor.id,
+ success: true,
+ vendorCode,
+ message: "Successfully sent to ERP system",
+ });
+ } catch (vendorError) {
+ // 개별 벤더 처리 오류 기록
+ results.push({
+ id: vendor.id,
+ success: false,
+ error: getErrorMessage(vendorError),
+ });
+ }
+ }
+
+ // 3. 처리 결과 반환
+ const successCount = results.filter(r => r.success).length;
+ const failCount = results.filter(r => !r.success).length;
+
+ return {
+ totalProcessed: results.length,
+ successCount,
+ failCount,
+ results,
+ };
+ });
+
+ // 캐시 무효화
+ revalidateTag("vendors");
+ revalidateTag("vendor-status-counts");
+
+ return { data: result, error: null };
+ } catch (err) {
+ console.error("Error sending vendors to ERP:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+