summaryrefslogtreecommitdiff
path: root/lib/vendors
diff options
context:
space:
mode:
Diffstat (limited to 'lib/vendors')
-rw-r--r--lib/vendors/service.ts992
-rw-r--r--lib/vendors/table/request-pq-dialog.tsx193
2 files changed, 810 insertions, 375 deletions
diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts
index 7c8df1a6..d91fbd03 100644
--- a/lib/vendors/service.ts
+++ b/lib/vendors/service.ts
@@ -5,11 +5,14 @@ import db from "@/db/db";
import { vendorAttachments, VendorContact, vendorContacts, vendorDetailView, vendorItemsView, vendorMaterialsView, vendorPossibleItems, vendorPossibleMateirals, vendors, vendorsWithTypesView, vendorTypes, type Vendor } from "@/db/schema";
import logger from '@/lib/logger';
import * as z from "zod"
+import crypto from 'crypto';
+import fs from 'fs/promises';
+import path from 'path';
+import { v4 as uuidv4 } from 'uuid';
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,
@@ -49,17 +52,17 @@ import type {
import { asc, desc, ilike, inArray, and, or, gte, lte, eq, isNull, count, sql } from "drizzle-orm";
import { rfqItems, rfqs, vendorRfqView } from "@/db/schema/rfq";
-import path from "path";
import { sendEmail } from "../mail/sendEmail";
import { PgTransaction } from "drizzle-orm/pg-core";
import { items, materials } from "@/db/schema/items";
-import { roles, userRoles, users } from "@/db/schema/users";
+import { mfaTokens, roles, userRoles, users } from "@/db/schema/users";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
-import { contracts, contractsDetailView, projects, vendorPQSubmissions, vendorProjectPQs, vendorsLogs } from "@/db/schema";
-import { deleteFile, saveFile } from "../file-stroage";
-
-
+import { contracts, contractsDetailView, projects, vendorPQSubmissions, vendorsLogs } from "@/db/schema";
+import { deleteFile, saveFile, saveBuffer } from "../file-stroage";
+import { basicContractTemplates } from "@/db/schema/basicContractDocumnet";
+import { basicContract } from "@/db/schema/basicContractDocumnet";
+import { headers } from 'next/headers';
/* -----------------------------------------------------
1) 조회 관련
----------------------------------------------------- */
@@ -74,14 +77,14 @@ export async function getVendors(input: GetVendorsSchema) {
async () => {
try {
const offset = (input.page - 1) * input.perPage;
-
+
// 1) 고급 필터 - vendors 대신 vendorsWithTypesView 사용
const advancedWhere = filterColumns({
table: vendorsWithTypesView,
filters: input.filters,
joinOperator: input.joinOperator,
});
-
+
// 2) 글로벌 검색
let globalWhere;
if (input.search) {
@@ -95,10 +98,10 @@ export async function getVendors(input: GetVendorsSchema) {
ilike(vendorsWithTypesView.vendorTypeName, s)
);
}
-
+
// 최종 where 결합
const finalWhere = and(advancedWhere, globalWhere);
-
+
// 간단 검색 (advancedTable=false) 시 예시
const simpleWhere = and(
input.vendorName
@@ -109,10 +112,10 @@ export async function getVendors(input: GetVendorsSchema) {
? ilike(vendorsWithTypesView.country, `%${input.country}%`)
: undefined
);
-
+
// 실제 사용될 where
const where = finalWhere;
-
+
// 정렬
const orderBy =
input.sort.length > 0
@@ -120,7 +123,7 @@ export async function getVendors(input: GetVendorsSchema) {
item.desc ? desc(vendorsWithTypesView[item.id]) : asc(vendorsWithTypesView[item.id])
)
: [asc(vendorsWithTypesView.createdAt)];
-
+
// 트랜잭션 내에서 데이터 조회
const { data, total } = await db.transaction(async (tx) => {
// 1) vendor 목록 조회 (view 사용)
@@ -130,7 +133,7 @@ export async function getVendors(input: GetVendorsSchema) {
offset,
limit: input.perPage,
});
-
+
// 2) 각 vendor의 attachments 조회
const vendorsWithAttachments = await Promise.all(
vendorsData.map(async (vendor) => {
@@ -142,7 +145,7 @@ export async function getVendors(input: GetVendorsSchema) {
})
.from(vendorAttachments)
.where(eq(vendorAttachments.vendorId, vendor.id));
-
+
return {
...vendor,
hasAttachments: attachments.length > 0,
@@ -150,17 +153,17 @@ export async function getVendors(input: GetVendorsSchema) {
};
})
);
-
+
// 3) 전체 개수
const total = await countVendorsWithTypes(tx, where);
return { data: vendorsWithAttachments, total };
});
console.log(total)
-
+
// 페이지 수
const pageCount = Math.ceil(total / input.perPage);
-
+
return { data, pageCount };
} catch (err) {
console.error("Error fetching vendors:", err);
@@ -231,11 +234,11 @@ async function storeVendorFiles(
files: File[],
attachmentType: string
) {
-
+
for (const file of files) {
- const saveResult = await saveFile({file, directory:`vendors/${vendorId}` })
+ const saveResult = await saveFile({ file, directory: `vendors/${vendorId}` })
// Insert attachment record
await tx.insert(vendorAttachments).values({
@@ -250,7 +253,7 @@ async function storeVendorFiles(
export async function getVendorTypes() {
unstable_noStore(); // Next.js server action caching prevention
-
+
try {
const types = await db
.select({
@@ -261,7 +264,7 @@ export async function getVendorTypes() {
})
.from(vendorTypes)
.orderBy(vendorTypes.nameKo);
-
+
return { data: types, error: null };
} catch (error) {
return { data: null, error: getErrorMessage(error) };
@@ -278,12 +281,12 @@ export type CreateVendorData = {
address?: string
email: string
phone?: string
-
+
representativeName?: string
representativeBirth?: string
representativeEmail?: string
representativePhone?: string
-
+
creditAgency?: string
creditRating?: string
cashFlowRating?: string
@@ -299,7 +302,7 @@ export async function createVendor(params: {
vendorData: CreateVendorData
// 기존의 일반 첨부파일
files?: File[]
-
+
// 신용평가 / 현금흐름 등급 첨부
creditRatingFiles?: File[]
cashFlowRatingFiles?: File[]
@@ -312,17 +315,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 {
@@ -330,14 +333,14 @@ 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 {
@@ -345,7 +348,7 @@ export async function createVendor(params: {
error: `이미 등록된 사업자등록번호입니다. (Tax ID ${vendorData.taxId} already exists in the system)`
};
}
-
+
await db.transaction(async (tx) => {
// 1) Insert the vendor (확장 필드도 함께)
const [newVendor] = await insertVendor(tx, {
@@ -360,36 +363,36 @@ export async function createVendor(params: {
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,
@@ -401,7 +404,7 @@ export async function createVendor(params: {
})
}
})
-
+
revalidateTag("vendors")
return { data: null, error: null }
} catch (error) {
@@ -414,7 +417,7 @@ export async function createVendor(params: {
/** 단건 업데이트 */
export async function modifyVendor(
- input: UpdateVendorSchema & { id: string; userId: number; comment:string; } // userId 추가
+ input: UpdateVendorSchema & { id: string; userId: number; comment: string; } // userId 추가
) {
unstable_noStore();
try {
@@ -915,16 +918,16 @@ export async function deleteVendorItem(
)
)
- revalidateTag(`vendor-items-${vendorId}`);
-
+ revalidateTag(`vendor-items-${vendorId}`);
+
return { success: true, message: "Item deleted successfully" }
} catch (error) {
console.error("Error deleting vendor item:", error)
- return {
- success: false,
- message: error instanceof z.ZodError
- ? error.errors[0].message
- : "Failed to delete item"
+ return {
+ success: false,
+ message: error instanceof z.ZodError
+ ? error.errors[0].message
+ : "Failed to delete item"
}
}
}
@@ -935,7 +938,7 @@ export async function updateVendorItem(
newItemCode: string
) {
unstable_noStore(); // Next.js 서버 액션 캐싱 방지
-
+
try {
const validatedData = updateVendorItemSchema.parse({
oldItemCode,
@@ -963,12 +966,12 @@ export async function updateVendorItem(
// 캐시 무효화
revalidateTag(`vendor-items-${vendorId}`)
-
+
return { data: null, error: null }
} catch (err) {
console.error("Error updating vendor item:", err)
- return {
- data: null,
+ return {
+ data: null,
error: getErrorMessage(err)
}
}
@@ -979,7 +982,7 @@ export async function removeVendorItems(input: {
vendorId: number
}) {
unstable_noStore()
-
+
try {
const validatedData = removeVendorItemsSchema.parse(input)
@@ -993,12 +996,12 @@ export async function removeVendorItems(input: {
)
revalidateTag(`vendor-items-${validatedData.vendorId}`)
-
+
return { data: null, error: null }
} catch (err) {
console.error("Error deleting vendor items:", err)
- return {
- data: null,
+ return {
+ data: null,
error: getErrorMessage(err)
}
}
@@ -1111,16 +1114,16 @@ export async function deleteVendorMaterial(
)
)
- revalidateTag(`vendor-materials-${vendorId}`);
-
+ revalidateTag(`vendor-materials-${vendorId}`);
+
return { success: true, message: "Item deleted successfully" }
} catch (error) {
console.error("Error deleting vendor item:", error)
- return {
- success: false,
- message: error instanceof z.ZodError
- ? error.errors[0].message
- : "Failed to delete item"
+ return {
+ success: false,
+ message: error instanceof z.ZodError
+ ? error.errors[0].message
+ : "Failed to delete item"
}
}
}
@@ -1131,7 +1134,7 @@ export async function updateVendorMaterial(
newItemCode: string
) {
unstable_noStore(); // Next.js 서버 액션 캐싱 방지
-
+
try {
const validatedData = updateVendorMaterialSchema.parse({
oldItemCode,
@@ -1159,12 +1162,12 @@ export async function updateVendorMaterial(
// 캐시 무효화
revalidateTag(`vendor-items-${vendorId}`)
-
+
return { data: null, error: null }
} catch (err) {
console.error("Error updating vendor item:", err)
- return {
- data: null,
+ return {
+ data: null,
error: getErrorMessage(err)
}
}
@@ -1175,7 +1178,7 @@ export async function removeVendorMaterials(input: {
vendorId: number
}) {
unstable_noStore()
-
+
try {
const validatedData = removeVendormaterialsSchema.parse(input)
@@ -1189,12 +1192,12 @@ export async function removeVendorMaterials(input: {
)
revalidateTag(`vendor-materials-${validatedData.vendorId}`)
-
+
return { data: null, error: null }
} catch (err) {
console.error("Error deleting vendor items:", err)
- return {
- data: null,
+ return {
+ data: null,
error: getErrorMessage(err)
}
}
@@ -1366,13 +1369,13 @@ interface CreateCompanyInput {
* @param fileId 특정 파일 ID (단일 파일 다운로드시)
* @returns 다운로드할 수 있는 임시 URL
*/
-export async function downloadVendorAttachments(vendorId:number, fileId?:number) {
+export async function downloadVendorAttachments(vendorId: number, fileId?: number) {
try {
// 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',
@@ -1380,29 +1383,29 @@ export async function downloadVendorAttachments(vendorId:number, fileId?:number)
'Content-Type': 'application/json',
},
});
-
+
if (!response.ok) {
throw new Error(`Server responded with ${response.status}: ${response.statusText}`);
}
-
+
// 파일명 가져오기 (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, '');
}
}
-
+
// Blob으로 응답 변환
const blob = await response.blob();
-
+
// Blob URL 생성
const blobUrl = window.URL.createObjectURL(blob);
-
- return {
+
+ return {
url: blobUrl,
fileName,
blob
@@ -1442,9 +1445,9 @@ interface ApproveVendorsInput {
*/
export async function approveVendors(input: ApproveVendorsInput & { userId: number }) {
unstable_noStore();
-
+
try {
- // 트랜잭션 내에서 협력업체 상태 업데이트, 유저 생성 및 이메일 발송
+ // 트랜잭션 내에서 협력업체 상태 업데이트, 유저 활성화 및 이메일 발송
const result = await db.transaction(async (tx) => {
// 0. 업데이트 전 협력업체 상태 조회
const vendorsBeforeUpdate = await tx
@@ -1465,73 +1468,135 @@ export async function approveVendors(input: ApproveVendorsInput & { userId: numb
.where(inArray(vendors.id, input.ids))
.returning();
- // 2. 업데이트된 협력업체 정보 조회
+ // 2. 업데이트된 협력업체 정보 조회 (국가 정보 포함)
const updatedVendors = await tx
.select({
id: vendors.id,
vendorName: vendors.vendorName,
email: vendors.email,
+ country: vendors.country, // 언어 설정용 국가 정보
})
.from(vendors)
.where(inArray(vendors.id, input.ids));
- // 3. 각 벤더에 대한 유저 계정 생성
+ // 3. 각 벤더에 대한 유저 계정 처리
await Promise.all(
updatedVendors.map(async (vendor) => {
if (!vendor.email) return; // 이메일이 없으면 스킵
- // 이미 존재하는 유저인지 확인
- const existingUser = await tx.query.users.findFirst({
- where: eq(users.email, vendor.email),
- columns: {
- id: true
+ // 기존 유저 확인 (비활성 포함)
+ const existingUser = await tx
+ .select({
+ id: users.id,
+ isActive: users.isActive,
+ language: users.language,
+ })
+ .from(users)
+ .where(eq(users.email, vendor.email))
+ .limit(1);
+
+ let currentUser;
+
+ if (existingUser.length > 0) {
+ // 🔄 기존 사용자 존재 시 - 활성화
+ const user = existingUser[0];
+ console.log(`👤 기존 사용자 발견: ${vendor.email} (활성상태: ${user.isActive})`);
+
+ if (!user.isActive) {
+ // 비활성 사용자 활성화
+ const [activatedUser] = await tx
+ .update(users)
+ .set({
+ isActive: true,
+ updatedAt: new Date(),
+ })
+ .where(eq(users.id, user.id))
+ .returning({ id: users.id });
+
+ console.log(`✅ 사용자 활성화 완료: ${vendor.email} (ID: ${user.id})`);
+ currentUser = { id: user.id };
+ } else {
+ console.log(`ℹ️ 사용자가 이미 활성 상태: ${vendor.email}`);
+ currentUser = { id: user.id };
}
- });
+ } else {
+ // 🆕 새 사용자 생성 (회원가입을 거치지 않은 경우 - 드문 케이스)
+ console.log(`🆕 새 사용자 생성: ${vendor.email}`);
+
+ // 국가코드에 따른 언어 설정
+ const language = vendor.country === 'KR' ? 'ko' : 'en';
- // 유저가 존재하지 않는 경우에만 생성
- if (!existingUser) {
- // 유저 생성
const [newUser] = await tx.insert(users).values({
name: vendor.vendorName,
email: vendor.email,
companyId: vendor.id,
- domain: "partners", // 기본값으로 이미 설정되어 있지만 명시적으로 지정
+ domain: "partners",
+ language, // 국가별 언어 설정
+ isActive: true, // 승인과 동시에 활성화
+ // 기본 보안 설정
+ mfaEnabled: false,
+ isLocked: false,
+ failedLoginAttempts: 0,
+ passwordChangeRequired: true, // 패스워드 설정 필요
+ requiresConsentUpdate: false,
}).returning({ id: users.id });
- // "Vendor Admin" 역할 찾기 또는 생성
- let vendorAdminRole = await tx.query.roles.findFirst({
- where: and(
- eq(roles.name, "Vendor Admin"),
- eq(roles.domain, "partners"),
- eq(roles.companyId, vendor.id)
- ),
- columns: {
- id: true
- }
- });
+ console.log(`✅ 새 사용자 생성 완료: ${vendor.email} (ID: ${newUser.id}, 언어: ${language})`);
+ currentUser = newUser;
+ }
- // "Vendor Admin" 역할이 없다면 생성
- if (!vendorAdminRole) {
- const [newRole] = await tx.insert(roles).values({
- name: "Vendor Admin",
- domain: "partners",
- companyId: vendor.id,
- description: "Vendor Administrator role",
- }).returning({ id: roles.id });
-
- vendorAdminRole = newRole;
+ // 4. 역할 할당 (기존 로직 유지)
+ // "Vendor Admin" 역할 찾기 또는 생성
+ let vendorAdminRole = await tx.query.roles.findFirst({
+ where: and(
+ eq(roles.name, "Vendor Admin"),
+ eq(roles.domain, "partners"),
+ eq(roles.companyId, vendor.id)
+ ),
+ columns: {
+ id: true
}
+ });
- // userRoles 테이블에 관계 생성
+ // "Vendor Admin" 역할이 없다면 생성
+ if (!vendorAdminRole) {
+ const [newRole] = await tx.insert(roles).values({
+ name: "Vendor Admin",
+ domain: "partners",
+ companyId: vendor.id,
+ description: "Vendor Administrator role",
+ }).returning({ id: roles.id });
+
+ vendorAdminRole = newRole;
+ console.log(`🎭 새 역할 생성: Vendor Admin (업체: ${vendor.vendorName})`);
+ }
+
+ // 기존 사용자-역할 관계 확인
+ const existingUserRole = await tx
+ .select({ id: userRoles.id })
+ .from(userRoles)
+ .where(
+ and(
+ eq(userRoles.userId, currentUser.id),
+ eq(userRoles.roleId, vendorAdminRole.id)
+ )
+ )
+ .limit(1);
+
+ // 역할이 할당되지 않은 경우에만 추가
+ if (existingUserRole.length === 0) {
await tx.insert(userRoles).values({
- userId: newUser.id,
+ userId: currentUser.id,
roleId: vendorAdminRole.id,
});
+ console.log(`🔗 역할 할당: 사용자 ${currentUser.id} → Vendor Admin`);
+ } else {
+ console.log(`ℹ️ 역할이 이미 할당됨: 사용자 ${currentUser.id}`);
}
})
);
- // 4. 로그 기록
+ // 5. 로그 기록
await Promise.all(
vendorsBeforeUpdate.map(async (vendorBefore) => {
await tx.insert(vendorsLogs).values({
@@ -1540,44 +1605,73 @@ export async function approveVendors(input: ApproveVendorsInput & { userId: numb
action: "status_change",
oldStatus: vendorBefore.status,
newStatus: "IN_REVIEW",
- comment: "Vendor approved for review",
+ comment: "Vendor approved and user account activated",
});
})
);
- // 5. 각 벤더에게 이메일 발송
+ // 6. 각 벤더에게 승인 완료 이메일 발송
await Promise.all(
updatedVendors.map(async (vendor) => {
if (!vendor.email) return; // 이메일이 없으면 스킵
try {
- const userLang = "en"; // 기본값, 필요시 협력업체 언어 설정에서 가져오기
+ // 사용자 언어 확인
+ const userInfo = await tx
+ .select({ language: users.language })
+ .from(users)
+ .where(eq(users.email, vendor.email))
+ .limit(1);
+
+ // 새 토큰 생성 (32바이트 랜덤)
+ const resetToken = crypto.randomBytes(32).toString('hex');
+ const expiresAt = new Date();
+ expiresAt.setHours(expiresAt.getHours() + 1); // 1시간 후 만료
+
+ await db.insert(mfaTokens).values({
+ userId: userInfo[0].id,
+ token: resetToken,
+ type: 'password_reset',
+ expiresAt,
+ isActive: true,
+ });
- const subject =
- "[eVCP] Admin Account Created";
+ const userLang = userInfo.length > 0 ? userInfo[0].language :
+ (vendor.country === 'KR' ? 'ko' : 'en');
+
+ const subject = userLang === 'ko'
+ ? "[eVCP] 업체 승인 완료 - 계정 활성화"
+ : "[eVCP] Vendor Approved - Account Activated";
const headersList = await headers();
const host = headersList.get('host') || 'localhost:3000';
- const baseUrl = `http://${host}`
- const loginUrl = `${baseUrl}/en/login`;
+ const protocol = headersList.get('x-forwarded-proto') || 'http';
+ const baseUrl = `${protocol}://${host}`;
+ const loginUrl = `${baseUrl}/${userLang}/login`;
+ const passwordSetupUrl = `${baseUrl}/${userLang}/auth/reset-password?token=${resetToken}`; // 패스워드 설정 URL
await sendEmail({
to: vendor.email,
subject,
- template: "admin-created", // 이메일 템플릿 이름
+ template: "vendor-approved", // 승인 완료 템플릿
context: {
vendorName: vendor.vendorName,
loginUrl,
+ passwordSetupUrl,
language: userLang,
+ isNewAccount: false, // 기존 계정 활성화임을 표시
},
});
+
+ console.log(`📧 승인 완료 이메일 발송: ${vendor.email}`);
} catch (emailError) {
- console.error(`Failed to send email to vendor ${vendor.id}:`, emailError);
+ console.error(`이메일 발송 실패 - 업체 ${vendor.id}:`, emailError);
// 이메일 전송 실패는 전체 트랜잭션을 실패시키지 않음
}
})
);
+ console.log(`🎉 협력업체 승인 완료: ${updatedVendors.length}개 업체`);
return updated;
});
@@ -1590,7 +1684,7 @@ export async function approveVendors(input: ApproveVendorsInput & { userId: numb
return { data: result, error: null };
} catch (err) {
- console.error("Error approving vendors:", err);
+ console.error("협력업체 승인 처리 오류:", err);
return { data: null, error: getErrorMessage(err) };
}
}
@@ -1612,11 +1706,11 @@ export async function generatePQNumber(isProject: boolean = false) {
const month = (now.getMonth() + 1).toString().padStart(2, '0'); // 월 (01-12)
const day = now.getDate().toString().padStart(2, '0'); // 일 (01-31)
const dateStr = `${year}${month}${day}`;
-
+
// 접두사 설정 (일반 PQ vs 프로젝트 PQ)
const prefix = isProject ? "PPQ" : "PQ";
const datePrefix = `${prefix}-${dateStr}`;
-
+
// 오늘 생성된 가장 큰 시퀀스 번호 조회
const latestPQ = await db
.select({ pqNumber: vendorPQSubmissions.pqNumber })
@@ -1626,9 +1720,9 @@ export async function generatePQNumber(isProject: boolean = false) {
)
.orderBy(desc(vendorPQSubmissions.pqNumber))
.limit(1);
-
+
let sequenceNumber = 1; // 기본값은 1
-
+
// 오늘 생성된 PQ가 있으면 다음 시퀀스 번호 계산
if (latestPQ.length > 0 && latestPQ[0].pqNumber) {
const lastPQ = latestPQ[0].pqNumber;
@@ -1637,13 +1731,13 @@ export async function generatePQNumber(isProject: boolean = false) {
sequenceNumber = parseInt(lastSequence) + 1;
}
}
-
+
// 5자리 시퀀스 번호로 포맷팅 (00001, 00002, ...)
const formattedSequence = sequenceNumber.toString().padStart(5, '0');
-
+
// 최종 PQ 번호 생성
const pqNumber = `${datePrefix}-${formattedSequence}`;
-
+
return pqNumber;
} catch (error) {
console.error('Error generating PQ number:', error);
@@ -1654,206 +1748,7 @@ export async function generatePQNumber(isProject: boolean = false) {
}
}
-export async function requestPQVendors(input: ApproveVendorsInput & {
- userId: number,
- agreements?: Record<string, boolean>,
- dueDate?: string | null,
- type?: "GENERAL" | "PROJECT" | "NON_INSPECTION",
- extraNote?: string,
- pqItems?: string
-}) {
- unstable_noStore();
- const session = await getServerSession(authOptions);
- const requesterId = session?.user?.id ? Number(session.user.id) : null;
-
- try {
- let projectInfo = null;
- if (input.projectId) {
- const project = await db
- .select({
- id: projects.id,
- projectCode: projects.code,
- projectName: projects.name,
- })
- .from(projects)
- .where(eq(projects.id, input.projectId))
- .limit(1);
-
- if (project.length > 0) {
- projectInfo = project[0];
- }
- }
-
- const result = await db.transaction(async (tx) => {
- const vendorsBeforeUpdate = await tx
- .select({ id: vendors.id, status: vendors.status })
- .from(vendors)
- .where(inArray(vendors.id, input.ids));
-
- const [updated] = await tx
- .update(vendors)
- .set({ status: "IN_PQ", updatedAt: new Date() })
- .where(inArray(vendors.id, input.ids))
- .returning();
-
- const updatedVendors = await tx
- .select({ id: vendors.id, vendorName: vendors.vendorName, email: vendors.email })
- .from(vendors)
- .where(inArray(vendors.id, input.ids));
-
- const pqType = input.type;
- const currentDate = new Date();
-
- const existingSubmissions = await tx
- .select({ vendorId: vendorPQSubmissions.vendorId })
- .from(vendorPQSubmissions)
- .where(
- and(
- inArray(vendorPQSubmissions.vendorId, input.ids),
- pqType ? eq(vendorPQSubmissions.type, pqType) : undefined,
- input.projectId
- ? eq(vendorPQSubmissions.projectId, input.projectId)
- : isNull(vendorPQSubmissions.projectId)
- )
- );
-
- const existingVendorIds = new Set(existingSubmissions.map((s) => s.vendorId));
- const newVendorIds = input.ids.filter((id) => !existingVendorIds.has(id));
-
- if (newVendorIds.length > 0) {
- const vendorPQDataPromises = newVendorIds.map(async (vendorId) => {
- const pqNumber = await generatePQNumber(pqType === "PROJECT");
-
- return {
- vendorId,
- pqNumber,
- projectId: input.projectId || null,
- type: pqType,
- status: "REQUESTED",
- requesterId: input.userId || requesterId,
- dueDate: input.dueDate ? new Date(input.dueDate) : null,
- agreements: input.agreements ?? {},
- pqItems: input.pqItems || null,
- createdAt: currentDate,
- updatedAt: currentDate,
- };
- });
-
- const vendorPQData = await Promise.all(vendorPQDataPromises);
-
- await tx.insert(vendorPQSubmissions).values(vendorPQData);
- }
-
- 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})`
- : "General PQ requested",
- });
- })
- );
-
- const headersList = await headers();
- const host = headersList.get("host") || "localhost:3000";
-
- await Promise.all(
- updatedVendors.map(async (vendor) => {
- if (!vendor.email) return;
-
- try {
- const userLang = "en";
-
- const vendorPQ = await tx
- .select({ pqNumber: vendorPQSubmissions.pqNumber })
- .from(vendorPQSubmissions)
- .where(
- and(
- eq(vendorPQSubmissions.vendorId, vendor.id),
- eq(vendorPQSubmissions.type, pqType),
- input.projectId
- ? eq(vendorPQSubmissions.projectId, input.projectId)
- : isNull(vendorPQSubmissions.projectId)
- )
- )
- .limit(1)
- .then((rows) => rows[0]);
-
- const subject = input.projectId
- ? `[eVCP] You are invited to submit Project PQ ${vendorPQ?.pqNumber || ""} for ${projectInfo?.projectCode || "a project"}`
- : input.type === "NON_INSPECTION"
- ? `[eVCP] You are invited to submit Non-Inspection PQ ${vendorPQ?.pqNumber || ""}`
- : `[eVCP] You are invited to submit PQ ${vendorPQ?.pqNumber || ""}`;
-
- const baseLoginUrl = `${host}/partners/pq`;
- const loginUrl = input.projectId
- ? `${baseLoginUrl}?projectId=${input.projectId}`
- : baseLoginUrl;
-
- // 체크된 계약 항목 배열 생성
- const contracts = input.agreements
- ? Object.entries(input.agreements)
- .filter(([_, checked]) => checked)
- .map(([name, _]) => name)
- : [];
-
- // PQ 대상 품목
- const pqItems = input.pqItems || " - ";
-
- await sendEmail({
- to: vendor.email,
- subject,
- template: input.projectId ? "project-pq" : input.type === "NON_INSPECTION" ? "non-inspection-pq" : "pq",
- context: {
- vendorName: vendor.vendorName,
- vendorContact: "", // 담당자 정보가 없으므로 빈 문자열
- pqNumber: vendorPQ?.pqNumber || "",
- senderName: session?.user?.name || "eVCP",
- senderEmail: session?.user?.email || "noreply@evcp.com",
- dueDate: input.dueDate ? new Date(input.dueDate).toLocaleDateString('ko-KR') : "",
- pqItems,
- contracts,
- extraNote: input.extraNote || "",
- currentYear: new Date().getFullYear().toString(),
- loginUrl,
- language: userLang,
- projectCode: projectInfo?.projectCode || "",
- projectName: projectInfo?.projectName || "",
- hasProject: !!input.projectId,
- pqType: input.type || "GENERAL",
- },
- });
- } catch (emailError) {
- console.error(`Failed to send email to vendor ${vendor.id}:`, emailError);
- }
- })
- );
-
- return updated;
- });
-
- revalidateTag("vendors");
- revalidateTag("vendor-status-counts");
- revalidateTag("vendor-pq-submissions");
- revalidateTag("pq-submissions");
-
- if (input.projectId) {
- revalidateTag(`project-${input.projectId}`);
- revalidateTag(`project-pq-submissions-${input.projectId}`);
- }
-
- return { data: result, error: null };
- } catch (err) {
- console.error("Error requesting PQ from vendors:", err);
- return { data: null, error: getErrorMessage(err) };
- }
-}
interface SendVendorsInput {
@@ -2054,7 +1949,7 @@ export async function requestInfo({ ids, userId }: RequestInfoProps) {
const headersList = await headers();
const host = headersList.get('host') || 'localhost:3000';
-
+
// 2. 각 벤더에 대한 로그 기록 및 이메일 발송
for (const vendor of vendorList) {
// 로그 기록
@@ -2290,7 +2185,7 @@ export async function updateVendorInfo(params: {
// 3-2. 파일 시스템에서 파일 삭제
for (const attachment of attachmentsToDelete) {
try {
-
+
await deleteFile(attachment.filePath)
} catch (error) {
@@ -2400,7 +2295,7 @@ export async function exportVendorContacts(vendorId: number) {
.from(vendorContacts)
.where(eq(vendorContacts.vendorId, vendorId))
.orderBy(vendorContacts.isPrimary, vendorContacts.contactName);
-
+
return contacts;
} catch (error) {
console.error("Failed to export vendor contacts:", error);
@@ -2427,7 +2322,7 @@ export async function exportVendorItems(vendorId: number) {
.from(vendorItemsView)
.where(eq(vendorItemsView.vendorId, vendorId))
.orderBy(vendorItemsView.itemName);
-
+
return vendorItems;
} catch (error) {
console.error("Failed to export vendor items:", error);
@@ -2446,7 +2341,7 @@ export async function exportVendorRFQs(vendorId: number) {
.from(vendorRfqView)
.where(eq(vendorRfqView.vendorId, vendorId))
.orderBy(vendorRfqView.rfqVendorUpdated);
-
+
return rfqs;
} catch (error) {
console.error("Failed to export vendor RFQs:", error);
@@ -2465,7 +2360,7 @@ export async function exportVendorContracts(vendorId: number) {
.from(contractsDetailView)
.where(eq(contractsDetailView.vendorId, vendorId))
.orderBy(contractsDetailView.createdAt);
-
+
return contracts;
} catch (error) {
console.error("Failed to export vendor contracts:", error);
@@ -2480,7 +2375,7 @@ export async function exportVendorContracts(vendorId: number) {
export async function exportVendorDetails(vendorIds: number[]) {
try {
if (!vendorIds.length) return [];
-
+
// 벤더 기본 정보 조회
const vendorsData = await db
.select({
@@ -2507,8 +2402,8 @@ export async function exportVendorDetails(vendorIds: number[]) {
})
.from(vendors)
.where(
- vendorIds.length === 1
- ? eq(vendors.id, vendorIds[0])
+ vendorIds.length === 1
+ ? eq(vendors.id, vendorIds[0])
: inArray(vendors.id, vendorIds)
);
@@ -2517,16 +2412,16 @@ export async function exportVendorDetails(vendorIds: number[]) {
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,
@@ -2536,7 +2431,7 @@ export async function exportVendorDetails(vendorIds: number[]) {
};
})
);
-
+
return vendorsWithDetails;
} catch (error) {
console.error("Failed to export vendor details:", error);
@@ -2551,7 +2446,7 @@ export async function exportVendorDetails(vendorIds: number[]) {
export async function searchVendors(searchTerm: string = "", limit: number = 100) {
try {
let whereCondition;
-
+
if (searchTerm.trim()) {
const s = `%${searchTerm.trim()}%`;
whereCondition = or(
@@ -2559,7 +2454,7 @@ export async function searchVendors(searchTerm: string = "", limit: number = 100
ilike(vendorsWithTypesView.vendorCode, s)
);
}
-
+
const vendors = await db
.select({
id: vendorsWithTypesView.id,
@@ -2578,7 +2473,7 @@ export async function searchVendors(searchTerm: string = "", limit: number = 100
)
.orderBy(asc(vendorsWithTypesView.vendorName))
.limit(limit);
-
+
return vendors;
} catch (error) {
console.error("벤더 검색 오류:", error);
@@ -2592,7 +2487,7 @@ export async function searchVendors(searchTerm: string = "", limit: number = 100
*/
export async function getVendorBasicInfo(vendorId: number) {
unstable_noStore();
-
+
try {
return await db.transaction(async (tx) => {
// 1. 기본 벤더 정보 조회 (vendorsWithTypesView 사용)
@@ -2647,7 +2542,7 @@ export async function getVendorBasicInfo(vendorId: number) {
cashFlowRating: vendor.cashFlowRating,
createdAt: vendor.createdAt,
updatedAt: vendor.updatedAt,
-
+
// 연락처 정보
contacts: contacts.map(contact => ({
id: contact.id,
@@ -2657,7 +2552,7 @@ export async function getVendorBasicInfo(vendorId: number) {
contactPhone: contact.contactPhone,
isPrimary: contact.isPrimary,
})),
-
+
// 첨부파일 정보
attachments: attachments.map(attachment => ({
id: attachment.id,
@@ -2666,26 +2561,26 @@ export async function getVendorBasicInfo(vendorId: number) {
attachmentType: attachment.attachmentType,
createdAt: attachment.createdAt,
})),
-
+
// 추가 정보는 임시로 null (나중에 실제 데이터로 교체)
additionalInfo: {
businessType: vendor.vendorTypeId ? `Type ${vendor.vendorTypeId}` : null,
employeeCount: 0, // 실제 데이터가 있을 수 있으므로 유지
mainBusiness: null,
},
-
+
// 매출 정보 (구현 예정 - 나중에 실제 테이블 연결)
salesInfo: null, // 구현 시 { "2023": { totalSales: "1000", totalDebt: "500", ... }, "2022": { ... } } 형태로 연도별 키 사용
-
+
// 추가 정보들 (구현 예정 - 나중에 실제 테이블 연결)
organization: null,
-
+
factoryInfo: null,
-
+
inspectionInfo: null,
-
+
evaluationInfo: null,
-
+
classificationInfo: {
vendorClassification: null,
groupCompany: null,
@@ -2693,11 +2588,11 @@ export async function getVendorBasicInfo(vendorId: number) {
industryType: "제조업", // 기본값으로 유지
isoCertification: null,
},
-
+
contractDetails: null,
-
+
capacityInfo: null,
-
+
calculatedMetrics: null, // 구현 시 { "20231231": { debtRatio: 0, ... }, "20221231": { ... } } 형태로 YYYYMMDD 키 사용
};
});
@@ -2705,4 +2600,373 @@ export async function getVendorBasicInfo(vendorId: number) {
console.error("Error fetching vendor basic info:", error);
return null;
}
-} \ No newline at end of file
+}
+interface RequestBasicContractInfoProps {
+ vendorIds: number[];
+ requestedBy: number;
+ templateId: number;
+ pdfBuffer?: Buffer | Uint8Array | ArrayBuffer; // 생성된 PDF 버퍼 (선택적, 다양한 타입 지원)
+}
+
+
+export async function requestPQVendors(input: ApproveVendorsInput & {
+ userId: number,
+ agreements?: Record<string, boolean>,
+ dueDate?: string | null,
+ type?: "GENERAL" | "PROJECT" | "NON_INSPECTION",
+ extraNote?: string,
+ pqItems?: string,
+ templateId?: number | null
+}) {
+ unstable_noStore();
+
+ const session = await getServerSession(authOptions);
+ const requesterId = session?.user?.id ? Number(session.user.id) : null;
+
+ try {
+ let projectInfo = null;
+ if (input.projectId) {
+ const project = await db
+ .select({
+ id: projects.id,
+ projectCode: projects.code,
+ projectName: projects.name,
+ })
+ .from(projects)
+ .where(eq(projects.id, input.projectId))
+ .limit(1);
+
+ if (project.length > 0) {
+ projectInfo = project[0];
+ }
+ }
+
+ const result = await db.transaction(async (tx) => {
+ const vendorsBeforeUpdate = await tx
+ .select({ id: vendors.id, status: vendors.status })
+ .from(vendors)
+ .where(inArray(vendors.id, input.ids));
+
+ const [updated] = await tx
+ .update(vendors)
+ .set({ status: "IN_PQ", updatedAt: new Date() })
+ .where(inArray(vendors.id, input.ids))
+ .returning();
+
+ const updatedVendors = await tx
+ .select({ id: vendors.id, vendorName: vendors.vendorName, email: vendors.email })
+ .from(vendors)
+ .where(inArray(vendors.id, input.ids));
+
+ const pqType = input.type;
+ const currentDate = new Date();
+
+ const existingSubmissions = await tx
+ .select({ vendorId: vendorPQSubmissions.vendorId })
+ .from(vendorPQSubmissions)
+ .where(
+ and(
+ inArray(vendorPQSubmissions.vendorId, input.ids),
+ pqType ? eq(vendorPQSubmissions.type, pqType) : undefined,
+ input.projectId
+ ? eq(vendorPQSubmissions.projectId, input.projectId)
+ : isNull(vendorPQSubmissions.projectId)
+ )
+ );
+
+ const existingVendorIds = new Set(existingSubmissions.map((s) => s.vendorId));
+ const newVendorIds = input.ids.filter((id) => !existingVendorIds.has(id));
+
+ if (newVendorIds.length > 0) {
+ const vendorPQDataPromises = newVendorIds.map(async (vendorId) => {
+ const pqNumber = await generatePQNumber(pqType === "PROJECT");
+
+ return {
+ vendorId,
+ pqNumber,
+ projectId: input.projectId || null,
+ type: pqType,
+ status: "REQUESTED",
+ requesterId: input.userId || requesterId,
+ dueDate: input.dueDate ? new Date(input.dueDate) : null,
+ agreements: input.agreements ?? {},
+ pqItems: input.pqItems || null,
+ createdAt: currentDate,
+ updatedAt: currentDate,
+ };
+ });
+
+ const vendorPQData = await Promise.all(vendorPQDataPromises);
+
+ await tx.insert(vendorPQSubmissions).values(vendorPQData);
+ }
+
+ 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})`
+ : "General PQ requested",
+ });
+ })
+ );
+
+ const headersList = await headers();
+ const host = headersList.get("host") || "localhost:3000";
+
+ await Promise.all(
+ updatedVendors.map(async (vendor) => {
+ if (!vendor.email) return;
+
+ try {
+ const userLang = "en";
+
+ const vendorPQ = await tx
+ .select({ pqNumber: vendorPQSubmissions.pqNumber })
+ .from(vendorPQSubmissions)
+ .where(
+ and(
+ eq(vendorPQSubmissions.vendorId, vendor.id),
+ eq(vendorPQSubmissions.type, pqType),
+ input.projectId
+ ? eq(vendorPQSubmissions.projectId, input.projectId)
+ : isNull(vendorPQSubmissions.projectId)
+ )
+ )
+ .limit(1)
+ .then((rows) => rows[0]);
+
+ const subject = input.projectId
+ ? `[eVCP] You are invited to submit Project PQ ${vendorPQ?.pqNumber || ""} for ${projectInfo?.projectCode || "a project"}`
+ : input.type === "NON_INSPECTION"
+ ? `[eVCP] You are invited to submit Non-Inspection PQ ${vendorPQ?.pqNumber || ""}`
+ : `[eVCP] You are invited to submit PQ ${vendorPQ?.pqNumber || ""}`;
+
+ const baseLoginUrl = `${host}/partners/pq`;
+ const loginUrl = input.projectId
+ ? `${baseLoginUrl}?projectId=${input.projectId}`
+ : baseLoginUrl;
+
+ // 체크된 계약 항목 배열 생성
+ const contracts = input.agreements
+ ? Object.entries(input.agreements)
+ .filter(([_, checked]) => checked)
+ .map(([name, _]) => name)
+ : [];
+
+ // PQ 대상 품목
+ const pqItems = input.pqItems || " - ";
+
+ await sendEmail({
+ to: vendor.email,
+ subject,
+ template: input.projectId ? "project-pq" : input.type === "NON_INSPECTION" ? "non-inspection-pq" : "pq",
+ context: {
+ vendorName: vendor.vendorName,
+ vendorContact: "", // 담당자 정보가 없으므로 빈 문자열
+ pqNumber: vendorPQ?.pqNumber || "",
+ senderName: session?.user?.name || "eVCP",
+ senderEmail: session?.user?.email || "noreply@evcp.com",
+ dueDate: input.dueDate ? new Date(input.dueDate).toLocaleDateString('ko-KR') : "",
+ pqItems,
+ contracts,
+ extraNote: input.extraNote || "",
+ currentYear: new Date().getFullYear().toString(),
+ loginUrl,
+ language: userLang,
+ projectCode: projectInfo?.projectCode || "",
+ projectName: projectInfo?.projectName || "",
+ hasProject: !!input.projectId,
+ pqType: input.type || "GENERAL",
+ },
+ });
+ } catch (emailError) {
+ console.error(`Failed to send email to vendor ${vendor.id}:`, emailError);
+ }
+ })
+ );
+
+ return updated;
+ });
+
+ revalidateTag("vendors");
+ revalidateTag("vendor-status-counts");
+ revalidateTag("vendor-pq-submissions");
+ revalidateTag("pq-submissions");
+
+ if (input.projectId) {
+ revalidateTag(`project-${input.projectId}`);
+ revalidateTag(`project-pq-submissions-${input.projectId}`);
+ }
+
+ return { data: result, error: null };
+ } catch (err) {
+ console.error("Error requesting PQ from vendors:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+export async function requestBasicContractInfo({
+ vendorIds,
+ requestedBy,
+ templateId,
+ pdfBuffer
+}: RequestBasicContractInfoProps): Promise<{ success?: boolean; error?: string }> {
+ unstable_noStore();
+
+ if (!vendorIds || vendorIds.length === 0) {
+ return { error: "요청할 협력업체가 선택되지 않았습니다." };
+ }
+
+ if (!templateId) {
+ return { error: "계약서 템플릿이 선택되지 않았습니다." };
+ }
+
+ try {
+ // 1. 선택된 템플릿 정보 가져오기
+ const template = await db.query.basicContractTemplates.findFirst({
+ where: eq(basicContractTemplates.id, templateId)
+ });
+
+ if (!template) {
+ return { error: "선택한 템플릿을 찾을 수 없습니다." };
+ }
+
+ // 2. PDF 버퍼가 제공된 경우 파일로 저장, 아니면 원본 템플릿 파일 사용
+ let finalFileName = template.fileName || `${template.templateName}.docx`;
+ let finalFilePath = template.filePath || `/basicContract/${finalFileName}`;
+
+ if (pdfBuffer) {
+ try {
+ const fileId = uuidv4();
+ const fileName = `${fileId}.pdf`;
+ const relativePath = `/basicContract/${fileName}`;
+ const publicDir = path.join(process.cwd(), "public", "basicContract");
+ const absolutePath = path.join(publicDir, fileName);
+
+ // 디렉토리 생성
+ await fs.mkdir(publicDir, { recursive: true });
+
+ // PDF 파일 저장 (다양한 타입을 Buffer로 변환)
+ let bufferData: Buffer;
+ if (Buffer.isBuffer(pdfBuffer)) {
+ bufferData = pdfBuffer;
+ } else if (pdfBuffer instanceof ArrayBuffer) {
+ bufferData = Buffer.from(pdfBuffer);
+ } else if (pdfBuffer instanceof Uint8Array) {
+ bufferData = Buffer.from(pdfBuffer);
+ } else {
+ bufferData = Buffer.from(pdfBuffer as any);
+ }
+ await fs.writeFile(absolutePath, bufferData);
+
+ finalFileName = fileName;
+ finalFilePath = relativePath;
+
+ console.log(`✅ PDF 파일 저장 완료: ${absolutePath}`);
+ } catch (pdfSaveError) {
+ console.error('PDF 파일 저장 오류:', pdfSaveError);
+ return { error: `PDF 파일 저장 실패: ${pdfSaveError instanceof Error ? pdfSaveError.message : '알 수 없는 오류'}` };
+ }
+ } else if (!template.fileName || !template.filePath) {
+ return { error: "템플릿 파일 정보가 없고 PDF 버퍼도 제공되지 않았습니다." };
+ }
+
+ // 3. 협력업체 정보 가져오기
+ const vendorList = await db
+ .select()
+ .from(vendors)
+ .where(inArray(vendors.id, vendorIds));
+
+ if (!vendorList || vendorList.length === 0) {
+ return { error: "선택한 협력업체 정보를 찾을 수 없습니다." };
+ }
+
+ // 3. 각 협력업체에 대해 기본계약 레코드 생성 및 이메일 발송
+ const results = await Promise.all(
+ vendorList.map(async (vendor) => {
+ if (!vendor.email) return; // 이메일이 없으면 스킵
+
+ try {
+ // 3-1. basic_contract 테이블에 레코드 추가
+ const [newContract] = await db
+ .insert(basicContract)
+ .values({
+ templateId: template.id,
+ vendorId: vendor.id,
+ requestedBy: requestedBy,
+ status: "PENDING",
+ fileName: finalFileName, // PDF 변환된 파일 이름 사용
+ filePath: finalFilePath, // PDF 변환된 파일 경로 사용
+ })
+ .returning();
+
+ // 3-2. 협력업체에 이메일 발송
+ const subject = `[${process.env.COMPANY_NAME || '회사명'}] 기본계약서 서명 요청`;
+
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+ // 로그인 또는 서명 페이지 URL 생성
+ const baseUrl = `http://${host}`
+ const loginUrl = `${baseUrl}/partners/basic-contract`;
+
+ // 사용자 언어 설정 (기본값은 한국어)
+ const userLang = "ko";
+
+ // 이메일 발송
+ await sendEmail({
+ to: vendor.email,
+ subject,
+ template: "contract-sign-request", // 이메일 템플릿 이름
+ context: {
+ vendorName: vendor.vendorName,
+ contractId: newContract.id,
+ templateName: template.templateName,
+ loginUrl,
+ language: userLang,
+ },
+ });
+
+ return { vendorId: vendor.id, success: true };
+ } catch (err) {
+ console.error(`협력업체 ${vendor.id} 처리 중 오류:`, err);
+ return { vendorId: vendor.id, success: false, error: getErrorMessage(err) };
+ }
+ })
+ );
+
+ // 4. 실패한 케이스가 있는지 확인
+ const failedVendors = results.filter(r => r && !r.success);
+
+ if (failedVendors.length > 0) {
+ console.error("일부 협력업체 처리 실패:", failedVendors);
+ if (failedVendors.length === vendorIds.length) {
+ // 모든 협력업체 처리 실패
+ return { error: "모든 협력업체에 대한 처리가 실패했습니다." };
+ } else {
+ // 일부 협력업체만 처리 실패
+ return {
+ success: true,
+ error: `${results.length - failedVendors.length}개 협력업체 처리 성공, ${failedVendors.length}개 처리 실패`
+ };
+ }
+ }
+
+ // 5. 캐시 무효화
+ revalidateTag("basic-contract-requests");
+
+ return { success: true };
+ } catch (error) {
+ console.error("기본계약서 요청 중 오류 발생:", error);
+ return {
+ error: error instanceof Error
+ ? error.message
+ : "기본계약서 요청 처리 중 오류가 발생했습니다."
+ };
+ }
+}
diff --git a/lib/vendors/table/request-pq-dialog.tsx b/lib/vendors/table/request-pq-dialog.tsx
index 6d477d9f..1df2d72c 100644
--- a/lib/vendors/table/request-pq-dialog.tsx
+++ b/lib/vendors/table/request-pq-dialog.tsx
@@ -36,11 +36,13 @@ import {
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import { Vendor } from "@/db/schema/vendors"
-import { requestPQVendors } from "../service"
+import { requestBasicContractInfo, requestPQVendors } from "../service"
import { getProjectsWithPQList } from "@/lib/pq/service"
import type { Project } from "@/lib/pq/service"
import { useSession } from "next-auth/react"
import { DatePicker } from "@/components/ui/date-picker"
+import { getALLBasicContractTemplates } from "@/lib/basic-contract/service"
+import type { BasicContractTemplate } from "@/db/schema"
interface RequestPQDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> {
vendors: Row<Vendor>["original"][]
@@ -72,6 +74,9 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro
const [extraNote, setExtraNote] = React.useState<string>("")
const [pqItems, setPqItems] = React.useState<string>("")
const [isLoadingProjects, setIsLoadingProjects] = React.useState(false)
+ const [basicContractTemplates, setBasicContractTemplates] = React.useState<BasicContractTemplate[]>([])
+ const [selectedTemplateIds, setSelectedTemplateIds] = React.useState<number[]>([])
+ const [isLoadingTemplates, setIsLoadingTemplates] = React.useState(false)
React.useEffect(() => {
if (type === "PROJECT") {
@@ -81,6 +86,15 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro
}
}, [type])
+ // 기본계약서 템플릿 로딩
+ React.useEffect(() => {
+ setIsLoadingTemplates(true)
+ getALLBasicContractTemplates()
+ .then(setBasicContractTemplates)
+ .catch(() => toast.error("기본계약서 템플릿 로딩 실패"))
+ .finally(() => setIsLoadingTemplates(false))
+ }, [])
+
React.useEffect(() => {
if (!props.open) {
setType(null)
@@ -89,6 +103,7 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro
setDueDate(null)
setPqItems("")
setExtraNote("")
+ setSelectedTemplateIds([])
}
}, [props.open])
@@ -99,7 +114,10 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro
if (!session?.user?.id) return toast.error("인증 실패")
startApproveTransition(async () => {
- const { error } = await requestPQVendors({
+ try {
+ // 1단계: PQ 생성
+ console.log("🚀 1단계: PQ 생성 시작")
+ const { error: pqError } = await requestPQVendors({
ids: vendors.map((v) => v.id),
userId: Number(session.user.id),
agreements,
@@ -108,16 +126,132 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro
type: type || "GENERAL",
extraNote,
pqItems,
+ templateId: selectedTemplateIds.length > 0 ? selectedTemplateIds[0] : null,
})
-
- if (error) {
- toast.error(error)
- return
+
+ if (pqError) {
+ toast.error(`PQ 생성 실패: ${pqError}`)
+ return
+ }
+ console.log("✅ 1단계: PQ 생성 완료")
+
+ // 2단계 & 3단계: 기본계약서 템플릿이 선택된 경우에만 실행 (여러 템플릿 처리)
+ if (selectedTemplateIds.length > 0) {
+ console.log(`🚀 2단계 & 3단계: ${selectedTemplateIds.length}개 템플릿 처리 시작`)
+
+ let successCount = 0
+ let errorCount = 0
+ const errors: string[] = []
+
+ // 템플릿별로 반복 처리
+ for (let i = 0; i < selectedTemplateIds.length; i++) {
+ const templateId = selectedTemplateIds[i]
+ const selectedTemplate = basicContractTemplates.find(t => t.id === templateId)
+
+ if (!selectedTemplate) {
+ console.error(`템플릿 ID ${templateId}를 찾을 수 없습니다`)
+ errorCount++
+ errors.push(`템플릿 ID ${templateId}를 찾을 수 없습니다`)
+ continue
+ }
+
+ try {
+ console.log(`📄 [${i+1}/${selectedTemplateIds.length}] ${selectedTemplate.templateName} - 2단계: DOCX to PDF 변환 시작`)
+
+ // 템플릿 파일을 가져와서 PDF로 변환
+ const formData = new FormData()
+
+ // 템플릿 파일 가져오기 (서버에서 파일 읽기)
+ const templateResponse = await fetch('/api/basic-contract/get-template', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ templateId })
+ })
+
+ if (!templateResponse.ok) {
+ throw new Error(`템플릿 파일을 가져올 수 없습니다: ${selectedTemplate.templateName}`)
+ }
+ console.log(`✅ [${i+1}/${selectedTemplateIds.length}] ${selectedTemplate.templateName} - 템플릿 파일 가져오기 완료`)
+
+ const templateBlob = await templateResponse.blob()
+ const templateFile = new File([templateBlob], selectedTemplate.fileName || 'template.docx')
+
+ // 템플릿 데이터 생성 (첫 번째 협력업체 정보 기반)
+ const firstVendor = vendors[0]
+ const templateData = {
+ // 영문 변수명으로 변경 (PDFTron이 한글 변수명을 지원하지 않음)
+ vendor_name: firstVendor?.vendorName || '협력업체명',
+ address: firstVendor?.address || '주소',
+ representative_name: firstVendor?.representativeName || '대표자명',
+ today_date: new Date().toLocaleDateString('ko-KR'),
+ }
+
+ console.log(`📝 [${i+1}/${selectedTemplateIds.length}] ${selectedTemplate.templateName} - 생성된 템플릿 데이터:`, templateData)
+
+ formData.append('templateFile', templateFile)
+ formData.append('outputFileName', `${selectedTemplate.templateName}_converted.pdf`)
+ formData.append('templateData', JSON.stringify(templateData))
+
+ // PDF 변환 호출
+ const pdfResponse = await fetch('/api/pdftron/createBasicContractPdf', {
+ method: 'POST',
+ body: formData,
+ })
+ console.log(`✅ [${i+1}/${selectedTemplateIds.length}] ${selectedTemplate.templateName} - PDF 변환 호출 완료`)
+
+ if (!pdfResponse.ok) {
+ const errorText = await pdfResponse.text()
+ throw new Error(`PDF 변환 실패 (${selectedTemplate.templateName}): ${errorText}`)
+ }
+
+ const pdfBuffer = await pdfResponse.arrayBuffer()
+ console.log(`✅ [${i+1}/${selectedTemplateIds.length}] ${selectedTemplate.templateName} - PDF 변환 완료`)
+
+ // 3단계: 변환된 PDF로 기본계약 생성
+ console.log(`📋 [${i+1}/${selectedTemplateIds.length}] ${selectedTemplate.templateName} - 3단계: 기본계약 생성 시작`)
+ const { error: contractError } = await requestBasicContractInfo({
+ vendorIds: vendors.map((v) => v.id),
+ requestedBy: Number(session.user.id),
+ templateId,
+ pdfBuffer: new Uint8Array(pdfBuffer), // ArrayBuffer를 Uint8Array로 변환하여 전달
+ })
+
+ if (contractError) {
+ console.error(`기본계약 생성 오류 (${selectedTemplate.templateName}):`, contractError)
+ errorCount++
+ errors.push(`${selectedTemplate.templateName}: ${contractError}`)
+ } else {
+ console.log(`✅ [${i+1}/${selectedTemplateIds.length}] ${selectedTemplate.templateName} - 3단계: 기본계약 생성 완료`)
+ successCount++
+ }
+ } catch (templateError) {
+ console.error(`템플릿 처리 오류 (${selectedTemplate.templateName}):`, templateError)
+ errorCount++
+ errors.push(`${selectedTemplate.templateName}: ${templateError instanceof Error ? templateError.message : '알 수 없는 오류'}`)
+ }
+ }
+
+ // 결과 토스트 메시지
+ if (successCount > 0 && errorCount === 0) {
+ toast.success(`PQ 요청 및 ${successCount}개 기본계약서 생성이 모두 완료되었습니다!`)
+ } else if (successCount > 0 && errorCount > 0) {
+ toast.success(`PQ는 성공적으로 요청되었습니다. ${successCount}개 기본계약서 성공, ${errorCount}개 실패`)
+ console.error('기본계약서 생성 오류들:', errors)
+ } else if (errorCount > 0) {
+ toast.error(`PQ는 성공적으로 요청되었지만, 모든 기본계약서 생성이 실패했습니다`)
+ console.error('기본계약서 생성 오류들:', errors)
+ }
+ } else {
+ // 기본계약서 템플릿이 선택되지 않은 경우
+ toast.success("PQ가 성공적으로 요청되었습니다")
+ }
+
+ props.onOpenChange?.(false)
+ onSuccess?.()
+ } catch (error) {
+ console.error('전체 프로세스 오류:', error)
+ toast.error(`처리 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`)
}
-
- props.onOpenChange?.(false)
- toast.success("PQ가 성공적으로 요청되었습니다")
- onSuccess?.()
})
}
@@ -204,7 +338,44 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro
/>
</div>
+ {/* 기본계약서 템플릿 선택 (다중 선택) */}
<div className="space-y-2">
+ <Label>기본계약서 템플릿 (선택사항, 복수 선택 가능)</Label>
+ {isLoadingTemplates ? (
+ <div className="text-sm text-muted-foreground">템플릿 로딩 중...</div>
+ ) : (
+ <div className="space-y-2 max-h-40 overflow-y-auto border rounded-md p-3">
+ {basicContractTemplates.map((template) => (
+ <div key={template.id} className="flex items-center gap-2">
+ <Checkbox
+ id={`template-${template.id}`}
+ checked={selectedTemplateIds.includes(template.id)}
+ onCheckedChange={(checked) => {
+ if (checked) {
+ setSelectedTemplateIds(prev => [...prev, template.id])
+ } else {
+ setSelectedTemplateIds(prev => prev.filter(id => id !== template.id))
+ }
+ }}
+ />
+ <Label htmlFor={`template-${template.id}`} className="text-sm">
+ {template.templateName}
+ </Label>
+ </div>
+ ))}
+ {basicContractTemplates.length === 0 && (
+ <div className="text-sm text-muted-foreground">사용 가능한 템플릿이 없습니다.</div>
+ )}
+ </div>
+ )}
+ {selectedTemplateIds.length > 0 && (
+ <div className="text-xs text-muted-foreground">
+ {selectedTemplateIds.length}개 템플릿이 선택되었습니다.
+ </div>
+ )}
+ </div>
+
+ {/* <div className="space-y-2">
<Label>계약 항목 선택</Label>
{AGREEMENT_LIST.map((label) => (
<div key={label} className="flex items-center gap-2">
@@ -218,7 +389,7 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro
<Label htmlFor={label}>{label}</Label>
</div>
))}
- </div>
+ </div> */}
</div>
)