diff options
Diffstat (limited to 'lib/basic-contract/service.ts')
| -rw-r--r-- | lib/basic-contract/service.ts | 1319 |
1 files changed, 1302 insertions, 17 deletions
diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts index 58463f16..194d27eb 100644 --- a/lib/basic-contract/service.ts +++ b/lib/basic-contract/service.ts @@ -17,19 +17,30 @@ import { complianceResponseFiles,
complianceResponses,
complianceSurveyTemplates,
- vendorAttachments,
- vendors,
+ vendorAttachments, basicContractTemplateStatsView,
type BasicContractTemplate as DBBasicContractTemplate,
type NewComplianceResponse,
type NewComplianceResponseAnswer,
- type NewComplianceResponseFile
+ type NewComplianceResponseFile,
+ gtcVendorDocuments,
+ gtcVendorClauses,
+ gtcClauses,
+ gtcDocuments,
+ vendors,
+ gtcNegotiationHistory,
+ type GtcVendorClause,
+ type GtcClause,
+ projects,
+ legalWorks
} from "@/db/schema";
+import path from "path";
import {
GetBasicContractTemplatesSchema,
CreateBasicContractTemplateSchema,
GetBasciContractsSchema,
} from "./validations";
+import { readFile } from "fs/promises"
import {
insertBasicContractTemplate,
@@ -39,7 +50,11 @@ import { getBasicContractTemplateById,
selectBasicContracts,
countBasicContracts,
- findAllTemplates
+ findAllTemplates,
+ countBasicContractsById,
+ selectBasicContractsById,
+ selectBasicContractsVendor,
+ countBasicContractsVendor
} from "./repository";
import { revalidatePath } from 'next/cache';
import { sendEmail } from "../mail/sendEmail";
@@ -47,7 +62,8 @@ import { headers } from 'next/headers'; import { filterColumns } from "@/lib/filter-columns";
import { differenceInDays, addYears, isBefore } from "date-fns";
import { deleteFile, saveFile } from "@/lib/file-stroage";
-
+import { getServerSession } from "next-auth/next"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
// 템플릿 추가
@@ -607,7 +623,7 @@ export async function getBasicContracts(input: GetBasciContractsSchema) { // advancedTable 모드면 filterColumns()로 where 절 구성
const advancedWhere = filterColumns({
- table: basicContractView,
+ table: basicContractTemplateStatsView,
filters: input.filters,
joinOperator: input.joinOperator,
});
@@ -616,11 +632,7 @@ export async function getBasicContracts(input: GetBasciContractsSchema) { let globalWhere
if (input.search) {
const s = `%${input.search}%`
- globalWhere = or(ilike(basicContractView.templateName, s),
- ilike(basicContractView.vendorName, s)
- , ilike(basicContractView.vendorCode, s)
- , ilike(basicContractView.vendorEmail, s)
- , ilike(basicContractView.status, s)
+ globalWhere = or(ilike(basicContractTemplateStatsView.templateName, s),
)
// 필요시 여러 칼럼 OR조건 (status, priority, etc)
}
@@ -638,9 +650,9 @@ export async function getBasicContracts(input: GetBasciContractsSchema) { const orderBy =
input.sort.length > 0
? input.sort.map((item) =>
- item.desc ? desc(basicContractView[item.id]) : asc(basicContractView[item.id])
+ item.desc ? desc(basicContractTemplateStatsView[item.id]) : asc(basicContractTemplateStatsView[item.id])
)
- : [asc(basicContractView.createdAt)];
+ : [asc(basicContractTemplateStatsView.lastActivityDate)];
// 트랜잭션 내부에서 Repository 호출
const { data, total } = await db.transaction(async (tx) => {
@@ -659,6 +671,7 @@ export async function getBasicContracts(input: GetBasciContractsSchema) { return { data, pageCount };
} catch (err) {
+ console.log(err)
// 에러 발생 시 디폴트
return { data: [], pageCount: 0 };
}
@@ -666,14 +679,14 @@ export async function getBasicContracts(input: GetBasciContractsSchema) { [JSON.stringify(input)], // 캐싱 키
{
revalidate: 3600,
- tags: ["basicContractView"], // revalidateTag("basicContractView") 호출 시 무효화
+ tags: ["basicContractTemplateStatsView"], // revalidateTag("basicContractTemplateStatsView") 호출 시 무효화
}
)();
}
export async function getBasicContractsByVendorId(
- input: GetBasciContractsSchema,
+ input: GetBasciContractsVendorSchema,
vendorId: number
) {
// return unstable_cache(
@@ -726,14 +739,14 @@ export async function getBasicContractsByVendorId( // 트랜잭션 내부에서 Repository 호출
const { data, total } = await db.transaction(async (tx) => {
- const data = await selectBasicContracts(tx, {
+ const data = await selectBasicContractsVendor(tx, {
where,
orderBy,
offset,
limit: input.perPage,
});
- const total = await countBasicContracts(tx, where);
+ const total = await countBasicContractsVendor(tx, where);
return { data, total };
});
@@ -753,6 +766,91 @@ export async function getBasicContractsByVendorId( // )();
}
+
+export async function getBasicContractsByTemplateId(
+ input: GetBasciContractsByIdSchema,
+ templateId: number
+) {
+ // return unstable_cache(
+ // async () => {
+ try {
+
+ console.log(input.sort)
+ const offset = (input.page - 1) * input.perPage;
+
+ // const advancedTable = input.flags.includes("advancedTable");
+ const advancedTable = true;
+
+ // advancedTable 모드면 filterColumns()로 where 절 구성
+ const advancedWhere = filterColumns({
+ table: basicContractView,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(basicContractView.templateName, s),
+ ilike(basicContractView.vendorName, s),
+ ilike(basicContractView.vendorCode, s),
+ ilike(basicContractView.vendorEmail, s),
+ ilike(basicContractView.status, s)
+ );
+ // 필요시 여러 칼럼 OR조건 (status, priority, etc)
+ }
+
+ // 벤더 ID 필터링 조건 추가
+ const templateCondition = eq(basicContractView.templateId, templateId);
+
+ const finalWhere = and(
+ // 항상 벤더 ID 조건을 포함
+ templateCondition,
+ // 기존 조건들
+ advancedWhere,
+ globalWhere
+ );
+
+ const where = finalWhere;
+
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(basicContractView[item.id]) : asc(basicContractView[item.id])
+ )
+ : [asc(basicContractView.createdAt)];
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectBasicContractsById(tx, {
+ where,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ const total = await countBasicContractsById(tx, where);
+ return { data, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount };
+ } catch (err) {
+ // 에러 발생 시 디폴트\
+ console.log(err)
+ return { data: [], pageCount: 0 };
+ }
+ // },
+ // [JSON.stringify(input), String(vendorId)], // 캐싱 키에 vendorId 추가
+ // {
+ // revalidate: 3600,
+ // tags: ["basicContractView-vendor"], // revalidateTag("basicContractView") 호출 시 무효화
+ // }
+ // )();
+}
+
export async function getAllTemplates(): Promise<BasicContractTemplate[]> {
try {
return await findAllTemplates();
@@ -1677,4 +1775,1191 @@ export async function uploadSurveyFile(file: File, contractId: number, answerId: message: error instanceof Error ? error.message : '파일 업로드에 실패했습니다.'
};
}
+}
+
+
+// 기존 응답 조회를 위한 타입
+export interface ExistingResponse {
+ responseId: number;
+ status: string;
+ completedAt: string | null;
+ answers: {
+ questionId: number;
+ answerValue: string | null;
+ detailText: string | null;
+ otherText: string | null;
+ files: Array<{
+ id: number;
+ fileName: string;
+ filePath: string;
+ fileSize: number;
+ }>;
+ }[];
+}
+
+// 기존 응답 조회 서버 액션
+export async function getExistingSurveyResponse(
+ contractId: number,
+ templateId: number
+): Promise<{ success: boolean; data: ExistingResponse | null; message?: string }> {
+ try {
+ // 1. 해당 계약서의 응답 조회
+ const response = await db
+ .select()
+ .from(complianceResponses)
+ .where(
+ and(
+ eq(complianceResponses.basicContractId, contractId),
+ eq(complianceResponses.templateId, templateId)
+ )
+ )
+ .limit(1);
+
+ if (!response || response.length === 0) {
+ return { success: true, data: null };
+ }
+
+ const responseData = response[0];
+
+ // 2. 해당 응답의 모든 답변 조회
+ const answers = await db
+ .select({
+ questionId: complianceResponseAnswers.questionId,
+ answerValue: complianceResponseAnswers.answerValue,
+ detailText: complianceResponseAnswers.detailText,
+ otherText: complianceResponseAnswers.otherText,
+ answerId: complianceResponseAnswers.id,
+ })
+ .from(complianceResponseAnswers)
+ .where(eq(complianceResponseAnswers.responseId, responseData.id));
+
+ // 3. 각 답변의 파일들 조회
+ const answerIds = answers.map(a => a.answerId);
+ const files = answerIds.length > 0
+ ? await db
+ .select()
+ .from(complianceResponseFiles)
+ .where(inArray(complianceResponseFiles.answerId, answerIds))
+ : [];
+
+ // 4. 답변별 파일 그룹화
+ const filesByAnswerId = files.reduce((acc, file) => {
+ if (!acc[file.answerId]) {
+ acc[file.answerId] = [];
+ }
+ acc[file.answerId].push({
+ id: file.id,
+ fileName: file.fileName,
+ filePath: file.filePath,
+ fileSize: file.fileSize || 0,
+ });
+ return acc;
+ }, {} as Record<number, Array<{id: number; fileName: string; filePath: string; fileSize: number}>>);
+
+ // 5. 최종 데이터 구성
+ const answersWithFiles = answers.map(answer => ({
+ questionId: answer.questionId,
+ answerValue: answer.answerValue,
+ detailText: answer.detailText,
+ otherText: answer.otherText,
+ files: filesByAnswerId[answer.answerId] || [],
+ }));
+
+ return {
+ success: true,
+ data: {
+ responseId: responseData.id,
+ status: responseData.status,
+ completedAt: responseData.completedAt?.toISOString() || null,
+ answers: answersWithFiles,
+ },
+ };
+
+ } catch (error) {
+ console.error('기존 설문 응답 조회 실패:', error);
+ return {
+ success: false,
+ data: null,
+ message: '기존 응답을 불러오는데 실패했습니다.'
+ };
+ }
+}
+
+export type GtcVendorData = {
+ vendorDocument: {
+ id: number;
+ name: string;
+ description: string | null;
+ version: string;
+ reviewStatus: string;
+ vendorId: number;
+ baseDocumentId: number;
+ vendorName: string;
+ vendorCode: string;
+ };
+ clauses: Array<{
+ id: number;
+ baseClauseId: number;
+ vendorDocumentId: number;
+ parentId: number | null;
+ depth: number;
+ sortOrder: string;
+ fullPath: string | null;
+ reviewStatus: string;
+ negotiationNote: string | null;
+ isExcluded: boolean;
+
+ // 실제 표시될 값들 (수정된 값이 우선, 없으면 기본값)
+ effectiveItemNumber: string;
+ effectiveCategory: string | null;
+ effectiveSubtitle: string;
+ effectiveContent: string | null;
+
+ // 기본 조항 정보
+ baseItemNumber: string;
+ baseCategory: string | null;
+ baseSubtitle: string;
+ baseContent: string | null;
+
+ // 수정 여부
+ hasModifications: boolean;
+ isNumberModified: boolean;
+ isCategoryModified: boolean;
+ isSubtitleModified: boolean;
+ isContentModified: boolean;
+
+ // 코멘트 관련
+ hasComment: boolean;
+ pendingComment: string | null;
+ }>;
+};
+
+/**
+ * 현재 사용자(벤더)의 GTC 데이터를 가져옵니다.
+ * @param contractId 기본 GTC 문서 ID (선택사항, 없으면 가장 최신 문서 사용)
+ * @returns GTC 벤더 데이터 또는 null
+ */
+export async function getVendorGtcData(contractId?: number): Promise<GtcVendorData | null> {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.companyId) {
+ throw new Error("회사 정보가 없습니다.");
+ }
+
+ console.log(contractId, "contractId");
+
+ const companyId = session.user.companyId;
+ const vendorId = companyId; // companyId를 vendorId로 사용
+
+ // 1. 계약 정보 가져오기
+ const existingContract = await db.query.basicContract.findFirst({
+ where: eq(basicContract.id, contractId),
+ });
+
+ if (!existingContract) {
+ throw new Error("계약을 찾을 수 없습니다.");
+ }
+
+ // 2. 계약 템플릿 정보 가져오기
+ const existingContractTemplate = await db.query.basicContractTemplates.findFirst({
+ where: eq(basicContractTemplates.id, existingContract.templateId), // id가 아니라 templateId여야 할 것 같음
+ });
+
+ if (!existingContractTemplate) {
+ throw new Error("계약 템플릿을 찾을 수 없습니다.");
+ }
+
+ // 3. General 타입인지 확인
+ const isGeneral = existingContractTemplate.templateName.toLowerCase().includes('general');
+
+ let targetBaseDocumentId: number;
+
+ if (isGeneral) {
+ // General인 경우: type이 'standard'인 활성 상태의 첫 번째 문서 사용
+ const standardGtcDoc = await db.query.gtcDocuments.findFirst({
+ where: and(
+ eq(gtcDocuments.type, 'standard'),
+ eq(gtcDocuments.isActive, true)
+ ),
+ orderBy: [desc(gtcDocuments.revision), desc(gtcDocuments.createdAt)] // 최신 리비전 우선
+ });
+
+ if (!standardGtcDoc) {
+ throw new Error("표준 GTC 문서를 찾을 수 없습니다.");
+ }
+
+ targetBaseDocumentId = standardGtcDoc.id;
+ console.log(`표준 GTC 문서 사용: ${targetBaseDocumentId}`);
+
+ } else {
+ // General이 아닌 경우: 프로젝트별 GTC 문서 사용
+ const projectCode = existingContractTemplate.templateName.split(" ")[0];
+
+ if (!projectCode) {
+ throw new Error("템플릿 이름에서 프로젝트 코드를 찾을 수 없습니다.");
+ }
+
+ // 프로젝트 찾기
+ const existingProject = await db.query.projects.findFirst({
+ where: eq(projects.code, projectCode),
+ });
+
+ if (!existingProject) {
+ throw new Error(`프로젝트를 찾을 수 없습니다: ${projectCode}`);
+ }
+
+ // 해당 프로젝트의 GTC 문서 찾기
+ const projectGtcDoc = await db.query.gtcDocuments.findFirst({
+ where: and(
+ eq(gtcDocuments.type, 'project'),
+ eq(gtcDocuments.projectId, existingProject.id),
+ eq(gtcDocuments.isActive, true)
+ ),
+ orderBy: [desc(gtcDocuments.revision), desc(gtcDocuments.createdAt)] // 최신 리비전 우선
+ });
+
+ if (!projectGtcDoc) {
+ console.warn(`프로젝트 ${projectCode}에 대한 GTC 문서가 없습니다. 표준 GTC 문서를 사용합니다.`);
+
+ // 프로젝트별 GTC가 없으면 표준 GTC 사용
+ const standardGtcDoc = await db.query.gtcDocuments.findFirst({
+ where: and(
+ eq(gtcDocuments.type, 'standard'),
+ eq(gtcDocuments.isActive, true)
+ ),
+ orderBy: [desc(gtcDocuments.revision), desc(gtcDocuments.createdAt)]
+ });
+
+ if (!standardGtcDoc) {
+ throw new Error("표준 GTC 문서도 찾을 수 없습니다.");
+ }
+
+ targetBaseDocumentId = standardGtcDoc.id;
+ } else {
+ targetBaseDocumentId = projectGtcDoc.id;
+ console.log(`프로젝트 GTC 문서 사용: ${targetBaseDocumentId} (프로젝트: ${projectCode})`);
+ }
+ }
+
+ // 4. 벤더 문서 정보 가져오기 (없어도 기본 조항은 보여줌)
+ const vendorDocResult = await db
+ .select({
+ id: gtcVendorDocuments.id,
+ name: gtcVendorDocuments.name,
+ description: gtcVendorDocuments.description,
+ version: gtcVendorDocuments.version,
+ reviewStatus: gtcVendorDocuments.reviewStatus,
+ vendorId: gtcVendorDocuments.vendorId,
+ baseDocumentId: gtcVendorDocuments.baseDocumentId,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ })
+ .from(gtcVendorDocuments)
+ .leftJoin(vendors, eq(gtcVendorDocuments.vendorId, vendors.id))
+ .where(
+ and(
+ eq(gtcVendorDocuments.vendorId, vendorId),
+ eq(gtcVendorDocuments.baseDocumentId, targetBaseDocumentId),
+ eq(gtcVendorDocuments.isActive, true)
+ )
+ )
+ .limit(1);
+
+ // 벤더 문서가 없으면 기본 정보로 생성
+ const vendorDocument = vendorDocResult.length > 0 ? vendorDocResult[0] : {
+ id: null, // 벤더 문서가 아직 생성되지 않음
+ name: `GTC 검토 (벤더 ID: ${vendorId})`,
+ description: "기본 GTC 조항 검토",
+ version: "1.0",
+ reviewStatus: "pending",
+ vendorId: vendorId,
+ baseDocumentId: targetBaseDocumentId,
+ vendorName: "Unknown Vendor",
+ vendorCode: "UNKNOWN"
+ };
+
+ if (vendorDocResult.length === 0) {
+ console.info(`벤더 ID ${vendorId}에 대한 GTC 벤더 문서가 없습니다. 기본 조항만 표시합니다. (baseDocumentId: ${targetBaseDocumentId})`);
+ }
+
+ // 5. 기본 조항들 가져오기 (벤더별 수정 사항과 함께)
+ const clausesResult = await db
+ .select({
+ // 기본 조항 정보 (메인)
+ baseClauseId: gtcClauses.id,
+ baseItemNumber: gtcClauses.itemNumber,
+ baseCategory: gtcClauses.category,
+ baseSubtitle: gtcClauses.subtitle,
+ baseContent: gtcClauses.content,
+ baseParentId: gtcClauses.parentId,
+ baseDepth: gtcClauses.depth,
+ baseSortOrder: gtcClauses.sortOrder,
+ baseFullPath: gtcClauses.fullPath,
+
+ // 벤더 조항 정보 (있는 경우만)
+ vendorClauseId: gtcVendorClauses.id,
+ vendorDocumentId: gtcVendorClauses.vendorDocumentId,
+ reviewStatus: gtcVendorClauses.reviewStatus,
+ negotiationNote: gtcVendorClauses.negotiationNote,
+ isExcluded: gtcVendorClauses.isExcluded,
+
+ // 수정된 값들 (있는 경우만)
+ modifiedItemNumber: gtcVendorClauses.modifiedItemNumber,
+ modifiedCategory: gtcVendorClauses.modifiedCategory,
+ modifiedSubtitle: gtcVendorClauses.modifiedSubtitle,
+ modifiedContent: gtcVendorClauses.modifiedContent,
+
+ // 수정 여부
+ isNumberModified: gtcVendorClauses.isNumberModified,
+ isCategoryModified: gtcVendorClauses.isCategoryModified,
+ isSubtitleModified: gtcVendorClauses.isSubtitleModified,
+ isContentModified: gtcVendorClauses.isContentModified,
+ })
+ .from(gtcClauses)
+ .leftJoin(gtcVendorClauses, and(
+ eq(gtcVendorClauses.baseClauseId, gtcClauses.id),
+ vendorDocument.id ? eq(gtcVendorClauses.vendorDocumentId, vendorDocument.id) : sql`false`, // 벤더 문서가 없으면 조인하지 않음
+ eq(gtcVendorClauses.isActive, true)
+ ))
+ .where(
+ and(
+ eq(gtcClauses.documentId, targetBaseDocumentId),
+ eq(gtcClauses.isActive, true)
+ )
+ )
+ .orderBy(gtcClauses.sortOrder);
+
+ // 6. 데이터 변환 및 추가 정보 계산
+ const clauses = clausesResult.map(clause => {
+ // 벤더별 수정사항이 있는지 확인
+ const hasVendorData = !!clause.vendorClauseId;
+
+ const hasModifications = hasVendorData && (
+ clause.isNumberModified ||
+ clause.isCategoryModified ||
+ clause.isSubtitleModified ||
+ clause.isContentModified
+ );
+
+ const hasComment = hasVendorData && !!clause.negotiationNote;
+
+ return {
+ // 벤더 조항 ID (있는 경우만, 없으면 null)
+ // id: clause.vendorClauseId,
+ id: clause.baseClauseId,
+ vendorClauseId: clause.vendorClauseId,
+ vendorDocumentId: hasVendorData ? clause.vendorDocumentId : null,
+
+ // 기본 조항의 계층 구조 정보 사용
+ parentId: clause.baseParentId,
+ depth: clause.baseDepth,
+ sortOrder: clause.baseSortOrder,
+ fullPath: clause.baseFullPath,
+
+ // 상태 정보 (벤더 데이터가 있는 경우만)
+ reviewStatus: clause.reviewStatus || 'pending',
+ negotiationNote: clause.negotiationNote,
+ isExcluded: clause.isExcluded || false,
+
+ // 실제 표시될 값들 (수정된 값이 있으면 그것을, 없으면 기본값)
+ effectiveItemNumber: clause.modifiedItemNumber || clause.baseItemNumber,
+ effectiveCategory: clause.modifiedCategory || clause.baseCategory,
+ effectiveSubtitle: clause.modifiedSubtitle || clause.baseSubtitle,
+ effectiveContent: clause.modifiedContent || clause.baseContent,
+
+ // 기본 조항 정보
+ baseItemNumber: clause.baseItemNumber,
+ baseCategory: clause.baseCategory,
+ baseSubtitle: clause.baseSubtitle,
+ baseContent: clause.baseContent,
+
+ // 수정 여부
+ hasModifications,
+ isNumberModified: clause.isNumberModified || false,
+ isCategoryModified: clause.isCategoryModified || false,
+ isSubtitleModified: clause.isSubtitleModified || false,
+ isContentModified: clause.isContentModified || false,
+
+ // 코멘트 관련
+ hasComment,
+ pendingComment: null, // 클라이언트에서 관리
+ };
+ });
+
+ return {
+ vendorDocument,
+ clauses,
+ };
+
+ } catch (error) {
+ console.error('GTC 벤더 데이터 가져오기 실패:', error);
+ throw error;
+ }
+}
+
+
+interface ClauseUpdateData {
+ itemNumber: string;
+ category: string | null;
+ subtitle: string;
+ content: string | null;
+ comment: string;
+}
+
+interface VendorDocument {
+ id: number | null;
+ vendorId: number;
+ baseDocumentId: number;
+ name: string;
+ description: string;
+ version: string;
+}
+
+export async function updateVendorClause(
+ baseClauseId: number,
+ vendorClauseId: number | null,
+ clauseData: ClauseUpdateData,
+ vendorDocument?: VendorDocument
+): Promise<{ success: boolean; error?: string; vendorClauseId?: number; vendorDocumentId?: number }> {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.companyId) {
+ return { success: false, error: "회사 정보가 없습니다." };
+ }
+
+ const companyId = session.user.companyId;
+ const vendorId = companyId; // companyId를 vendorId로 사용
+ const userId = Number(session.user.id);
+
+ // 1. 기본 조항 정보 가져오기 (비교용)
+ const baseClause = await db.query.gtcClauses.findFirst({
+ where: eq(gtcClauses.id, baseClauseId),
+ });
+
+ if (!baseClause) {
+ return { success: false, error: "기본 조항을 찾을 수 없습니다." };
+ }
+
+ // 2. 벤더 문서 ID 확보 (없으면 생성)
+ let finalVendorDocumentId = vendorDocument?.id;
+
+ if (!finalVendorDocumentId && vendorDocument) {
+ // 벤더 문서 생성
+ const newVendorDoc = await db.insert(gtcVendorDocuments).values({
+ vendorId: vendorId,
+ baseDocumentId: vendorDocument.baseDocumentId,
+ name: vendorDocument.name,
+ description: vendorDocument.description,
+ version: vendorDocument.version,
+ reviewStatus: 'reviewing',
+ createdById: userId,
+ updatedById: userId,
+ }).returning({ id: gtcVendorDocuments.id });
+
+ if (newVendorDoc.length === 0) {
+ return { success: false, error: "벤더 문서 생성에 실패했습니다." };
+ }
+
+ finalVendorDocumentId = newVendorDoc[0].id;
+ console.log(`새 벤더 문서 생성: ${finalVendorDocumentId}`);
+ }
+
+ if (!finalVendorDocumentId) {
+ return { success: false, error: "벤더 문서 ID를 확보할 수 없습니다." };
+ }
+
+ // 3. 수정 여부 확인
+ const isNumberModified = clauseData.itemNumber !== baseClause.itemNumber;
+ const isCategoryModified = clauseData.category !== baseClause.category;
+ const isSubtitleModified = clauseData.subtitle !== baseClause.subtitle;
+ const isContentModified = clauseData.content !== baseClause.content;
+
+ const hasAnyModifications = isNumberModified || isCategoryModified || isSubtitleModified || isContentModified;
+ const hasComment = !!(clauseData.comment?.trim());
+
+ // 4. 벤더 조항 데이터 준비
+ const vendorClauseData = {
+ vendorDocumentId: finalVendorDocumentId,
+ baseClauseId: baseClauseId,
+ parentId: baseClause.parentId,
+ depth: baseClause.depth,
+ sortOrder: baseClause.sortOrder,
+ fullPath: baseClause.fullPath,
+
+ // 수정된 값들 (수정되지 않았으면 null로 저장)
+ modifiedItemNumber: isNumberModified ? clauseData.itemNumber : null,
+ modifiedCategory: isCategoryModified ? clauseData.category : null,
+ modifiedSubtitle: isSubtitleModified ? clauseData.subtitle : null,
+ modifiedContent: isContentModified ? clauseData.content : null,
+
+ // 수정 여부 플래그
+ isNumberModified,
+ isCategoryModified,
+ isSubtitleModified,
+ isContentModified,
+
+ // 상태 정보
+ reviewStatus: (hasAnyModifications || hasComment) ? 'reviewing' : 'draft',
+ negotiationNote: clauseData.comment?.trim() || null,
+ editReason: clauseData.comment?.trim() || null, // 수정 이유도 동일하게 저장
+
+ updatedAt: new Date(),
+ updatedById: userId,
+ };
+
+ let finalVendorClauseId = vendorClauseId;
+
+ // 5. 벤더 조항 생성 또는 업데이트
+ if (vendorClauseId) {
+ // 기존 벤더 조항 업데이트
+ await db
+ .update(gtcVendorClauses)
+ .set(vendorClauseData)
+ .where(eq(gtcVendorClauses.id, vendorClauseId));
+
+ console.log(`벤더 조항 업데이트: ${vendorClauseId}`);
+ } else {
+ // 새 벤더 조항 생성
+ const newVendorClause = await db.insert(gtcVendorClauses).values({
+ ...vendorClauseData,
+ createdById: userId,
+ }).returning({ id: gtcVendorClauses.id });
+
+ if (newVendorClause.length === 0) {
+ return { success: false, error: "벤더 조항 생성에 실패했습니다." };
+ }
+
+ finalVendorClauseId = newVendorClause[0].id;
+ console.log(`새 벤더 조항 생성: ${finalVendorClauseId}`);
+ }
+
+ // 6. 협의 이력에 기록
+ if (hasAnyModifications || hasComment) {
+ const historyAction = hasAnyModifications ? 'modified' : 'commented';
+ const historyComment = hasAnyModifications
+ ? `조항 수정: ${clauseData.comment || '수정 이유 없음'}`
+ : clauseData.comment;
+
+ await db.insert(gtcNegotiationHistory).values({
+ vendorClauseId: finalVendorClauseId,
+ action: historyAction,
+ comment: historyComment?.trim(),
+ actorType: 'vendor',
+ actorId: session.user.id,
+ actorName: session.user.name,
+ actorEmail: session.user.email,
+ });
+ }
+
+ return {
+ success: true,
+ vendorClauseId: finalVendorClauseId,
+ vendorDocumentId: finalVendorDocumentId
+ };
+
+ } catch (error) {
+ console.error('벤더 조항 업데이트 실패:', error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'
+ };
+ }
+}
+
+// 기존 함수는 호환성을 위해 유지하되, 새 함수를 호출하도록 변경
+export async function updateVendorClauseComment(
+ clauseId: number,
+ comment: string
+): Promise<{ success: boolean; error?: string }> {
+ console.warn('updateVendorClauseComment is deprecated. Use updateVendorClause instead.');
+
+ // 기본 조항 정보 가져오기
+ const baseClause = await db.query.gtcClauses.findFirst({
+ where: eq(gtcClauses.id, clauseId),
+ });
+
+ if (!baseClause) {
+ return { success: false, error: "기본 조항을 찾을 수 없습니다." };
+ }
+
+ // 기존 벤더 조항 찾기
+ const session = await getServerSession(authOptions);
+ const vendorId = session?.user?.companyId;
+
+ const existingVendorClause = await db.query.gtcVendorClauses.findFirst({
+ where: and(
+ eq(gtcVendorClauses.baseClauseId, clauseId),
+ eq(gtcVendorClauses.isActive, true)
+ ),
+ with: {
+ vendorDocument: true
+ }
+ });
+
+ const clauseData: ClauseUpdateData = {
+ itemNumber: baseClause.itemNumber,
+ category: baseClause.category,
+ subtitle: baseClause.subtitle,
+ content: baseClause.content,
+ comment: comment,
+ };
+
+ const result = await updateVendorClause(
+ clauseId,
+ existingVendorClause?.id || null,
+ clauseData,
+ existingVendorClause?.vendorDocument || undefined
+ );
+
+ return {
+ success: result.success,
+ error: result.error
+ };
+}
+
+
+/**
+ * 벤더 조항 코멘트들의 상태 체크
+ */
+export async function checkVendorClausesCommentStatus(
+ vendorDocumentId: number
+): Promise<{ hasComments: boolean; commentCount: number }> {
+ try {
+ const clausesWithComments = await db
+ .select({
+ id: gtcVendorClauses.id,
+ negotiationNote: gtcVendorClauses.negotiationNote
+ })
+ .from(gtcVendorClauses)
+ .where(
+ and(
+ eq(gtcVendorClauses.vendorDocumentId, vendorDocumentId),
+ eq(gtcVendorClauses.isActive, true)
+ )
+ );
+
+ const commentCount = clausesWithComments.filter(
+ clause => clause.negotiationNote && clause.negotiationNote.trim().length > 0
+ ).length;
+
+ return {
+ hasComments: commentCount > 0,
+ commentCount,
+ };
+
+ } catch (error) {
+ console.error('벤더 조항 코멘트 상태 체크 실패:', error);
+ return { hasComments: false, commentCount: 0 };
+ }
+}
+
+/**
+ * 특정 템플릿의 기본 정보를 조회하는 서버 액션
+ * @param templateId - 조회할 템플릿의 ID
+ * @returns 템플릿 기본 정보 또는 null
+ */
+export async function getBasicContractTemplateInfo(templateId: number) {
+ try {
+ const templateInfo = await db
+ .select({
+ templateId: basicContractTemplates.id,
+ templateName: basicContractTemplates.templateName,
+ revision: basicContractTemplates.revision,
+ status: basicContractTemplates.status,
+ legalReviewRequired: basicContractTemplates.legalReviewRequired,
+ validityPeriod: basicContractTemplates.validityPeriod,
+ fileName: basicContractTemplates.fileName,
+ filePath: basicContractTemplates.filePath,
+ createdAt: basicContractTemplates.createdAt,
+ updatedAt: basicContractTemplates.updatedAt,
+ createdBy: basicContractTemplates.createdBy,
+ updatedBy: basicContractTemplates.updatedBy,
+ disposedAt: basicContractTemplates.disposedAt,
+ restoredAt: basicContractTemplates.restoredAt,
+ })
+ .from(basicContractTemplates)
+ .where(eq(basicContractTemplates.id, templateId))
+ .then((res) => res[0] || null)
+
+ return templateInfo
+ } catch (error) {
+ console.error("Error fetching template info:", error)
+ return null
+ }
+}
+
+
+
+/**
+ * 카테고리 자동 분류 함수
+ */
+function getCategoryFromTemplateName(templateName: string | null): string {
+ if (!templateName) return "기타"
+
+ const templateNameLower = templateName.toLowerCase()
+
+ if (templateNameLower.includes("준법")) {
+ return "CP"
+ } else if (templateNameLower.includes("gtc")) {
+ return "GTC"
+ }
+
+ return "기타"
+}
+
+/**
+ * 법무검토 요청 서버 액션
+ */
+export async function requestLegalReviewAction(
+ contractIds: number[],
+ reviewNote?: string
+): Promise<{ success: boolean; message: string; data?: any }> {
+ try {
+ // 세션 확인
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ return {
+ success: false,
+ message: "로그인이 필요합니다."
+ }
+ }
+
+ // 계약서 정보 조회
+ const contracts = await db
+ .select({
+ id: basicContractView.id,
+ vendorId: basicContractView.vendorId,
+ vendorCode: basicContractView.vendorCode,
+ vendorName: basicContractView.vendorName,
+ templateName: basicContractView.templateName,
+ legalReviewRequired: basicContractView.legalReviewRequired,
+ legalReviewRequestedAt: basicContractView.legalReviewRequestedAt,
+ })
+ .from(basicContractView)
+ .where(inArray(basicContractView.id, contractIds))
+
+ if (contracts.length === 0) {
+ return {
+ success: false,
+ message: "선택된 계약서를 찾을 수 없습니다."
+ }
+ }
+
+ // 법무검토 요청 가능한 계약서 필터링
+ const eligibleContracts = contracts.filter(contract =>
+ contract.legalReviewRequired && !contract.legalReviewRequestedAt
+ )
+
+ if (eligibleContracts.length === 0) {
+ return {
+ success: false,
+ message: "법무검토 요청 가능한 계약서가 없습니다."
+ }
+ }
+
+ const currentDate = new Date()
+ const reviewer = session.user.name || session.user.email || "알 수 없음"
+
+ // 트랜잭션으로 처리
+ const results = await db.transaction(async (tx) => {
+ const legalWorkResults = []
+ const contractUpdateResults = []
+
+ // 각 계약서에 대해 legalWorks 레코드 생성
+ for (const contract of eligibleContracts) {
+ const category = getCategoryFromTemplateName(contract.templateName)
+
+ // legalWorks에 레코드 삽입
+ const legalWorkResult = await tx.insert(legalWorks).values({
+ basicContractId: contract.id, // 레퍼런스 ID
+ category: category,
+ status: "검토요청",
+ vendorId: contract.vendorId,
+ vendorCode: contract.vendorCode,
+ vendorName: contract.vendorName || "업체명 없음",
+ isUrgent: false,
+ consultationDate: currentDate.toISOString().split('T')[0], // YYYY-MM-DD 형식
+ reviewer: reviewer,
+ hasAttachment: false, // 기본값, 나중에 첨부파일 로직 추가 시 수정
+ createdAt: currentDate,
+ updatedAt: currentDate,
+ }).returning({ id: legalWorks.id })
+
+ legalWorkResults.push(legalWorkResult[0])
+
+ // basicContract 테이블의 legalReviewRequestedAt 업데이트
+ const contractUpdateResult = await tx
+ .update(basicContract)
+ .set({
+ legalReviewRequestedAt: currentDate,
+ updatedAt: currentDate,
+ })
+ .where(eq(basicContract.id, contract.id))
+ .returning({ id: basicContract.id })
+
+ contractUpdateResults.push(contractUpdateResult[0])
+ }
+
+ return {
+ legalWorks: legalWorkResults,
+ contractUpdates: contractUpdateResults
+ }
+ })
+
+
+ console.log("법무검토 요청 완료:", {
+ requestedBy: reviewer,
+ contractIds: eligibleContracts.map(c => c.id),
+ legalWorkIds: results.legalWorks.map(r => r.id),
+ reviewNote,
+ })
+
+ return {
+ success: true,
+ message: `${eligibleContracts.length}건의 계약서에 대한 법무검토를 요청했습니다.`,
+ data: {
+ processedCount: eligibleContracts.length,
+ totalRequested: contractIds.length,
+ legalWorkIds: results.legalWorks.map(r => r.id),
+ }
+ }
+
+ } catch (error) {
+ console.error("법무검토 요청 중 오류:", error)
+
+ return {
+ success: false,
+ message: "법무검토 요청 중 오류가 발생했습니다. 다시 시도해 주세요.",
+ }
+ }
+}
+
+export async function processBuyerSignatureAction(
+ contractId: number,
+ signedFileData: ArrayBuffer,
+ fileName: string
+): Promise<{ success: boolean; message: string; data?: any }> {
+ try {
+ // 세션 확인
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ return {
+ success: false,
+ message: "로그인이 필요합니다."
+ }
+ }
+
+ // 계약서 정보 조회 및 상태 확인
+ const contract = await db
+ .select()
+ .from(basicContractView)
+ .where(eq(basicContractView.id, contractId))
+ .limit(1)
+
+ if (contract.length === 0) {
+ return {
+ success: false,
+ message: "계약서를 찾을 수 없습니다."
+ }
+ }
+
+ const contractData = contract[0]
+
+ // 최종승인 가능 상태 확인
+ if (contractData.completedAt !== null) {
+ return {
+ success: false,
+ message: "이미 완료된 계약서입니다."
+ }
+ }
+
+ if (!contractData.signedFilePath) {
+ return {
+ success: false,
+ message: "협력업체 서명이 완료되지 않았습니다."
+ }
+ }
+
+ if (contractData.legalReviewRequestedAt && !contractData.legalReviewCompletedAt) {
+ return {
+ success: false,
+ message: "법무검토가 완료되지 않았습니다."
+ }
+ }
+
+ // 파일 저장 로직 (기존 파일 덮어쓰기)
+ // TODO: 실제 파일 저장 구현
+ const saveResult = await saveFile({signedFileData,directory: "basicContract/signed" });
+
+ const currentDate = new Date()
+
+ // 계약서 상태 업데이트
+ const updatedContract = await db
+ .update(basicContract)
+ .set({
+ buyerSignedAt: currentDate,
+ completedAt: currentDate,
+ status: "COMPLETED",
+ updatedAt: currentDate,
+ // signedFilePath: savedFilePath, // 새로운 파일 경로로 업데이트
+ })
+ .where(eq(basicContract.id, contractId))
+ .returning()
+
+ // 캐시 재검증
+ revalidatePath("/contracts")
+
+ console.log("구매자 서명 및 최종승인 완료:", {
+ contractId,
+ buyerSigner: session.user.name || session.user.email,
+ completedAt: currentDate,
+ })
+
+ return {
+ success: true,
+ message: "계약서 최종승인이 완료되었습니다.",
+ data: {
+ contractId,
+ completedAt: currentDate,
+ }
+ }
+
+ } catch (error) {
+ console.error("구매자 서명 처리 중 오류:", error)
+
+ return {
+ success: false,
+ message: "최종승인 처리 중 오류가 발생했습니다. 다시 시도해 주세요.",
+ }
+ }
+}
+
+/**
+ * 일괄 최종승인 (서명 다이얼로그 호출용)
+ */
+export async function prepareFinalApprovalAction(
+ contractIds: number[]
+): Promise<{ success: boolean; message: string; contracts?: any[] }> {
+ try {
+ // 세션 확인
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ return {
+ success: false,
+ message: "로그인이 필요합니다."
+ }
+ }
+
+ // 계약서 정보 조회
+ const contracts = await db
+ .select()
+ .from(basicContractView)
+ .where(inArray(basicContractView.id, contractIds))
+
+ if (contracts.length === 0) {
+ return {
+ success: false,
+ message: "선택된 계약서를 찾을 수 없습니다."
+ }
+ }
+
+ // 최종승인 가능한 계약서 필터링
+ const eligibleContracts = contracts.filter(contract => {
+ if (contract.completedAt !== null || !contract.signedFilePath) {
+ return false
+ }
+ if (contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt) {
+ return false
+ }
+ return true
+ })
+
+ if (eligibleContracts.length === 0) {
+ return {
+ success: false,
+ message: "최종승인 가능한 계약서가 없습니다."
+ }
+ }
+
+ // 서명 다이얼로그에서 사용할 수 있는 형태로 변환
+ const contractsForSigning = eligibleContracts.map(contract => ({
+ id: contract.id,
+ templateName: contract.templateName,
+ signedFilePath: contract.signedFilePath,
+ signedFileName: contract.signedFileName,
+ vendorName: contract.vendorName,
+ vendorCode: contract.vendorCode,
+ requestedByName: "구매팀", // 최종승인자 표시
+ createdAt: contract.createdAt,
+ // 다른 필요한 필드들...
+ }))
+
+ return {
+ success: true,
+ message: `${eligibleContracts.length}건의 계약서를 최종승인할 준비가 되었습니다.`,
+ contracts: contractsForSigning
+ }
+
+ } catch (error) {
+ console.error("최종승인 준비 중 오류:", error)
+
+ return {
+ success: false,
+ message: "최종승인 준비 중 오류가 발생했습니다.",
+ }
+ }
+}
+
+/**
+ * 서명 없이 승인만 처리 (간단한 승인 방식)
+ */
+export async function quickFinalApprovalAction(
+ contractIds: number[]
+): Promise<{ success: boolean; message: string; data?: any }> {
+ try {
+ // 세션 확인
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ return {
+ success: false,
+ message: "로그인이 필요합니다."
+ }
+ }
+
+ // 계약서 정보 조회
+ const contracts = await db
+ .select()
+ .from(basicContract)
+ .where(inArray(basicContract.id, contractIds))
+
+ // 승인 가능한 계약서 필터링
+ const eligibleContracts = contracts.filter(contract => {
+ if (contract.completedAt !== null || !contract.signedFilePath) {
+ return false
+ }
+ if (contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt) {
+ return false
+ }
+ return true
+ })
+
+ if (eligibleContracts.length === 0) {
+ return {
+ success: false,
+ message: "최종승인 가능한 계약서가 없습니다."
+ }
+ }
+
+ const currentDate = new Date()
+ const approver = session.user.name || session.user.email || "알 수 없음"
+
+ // 일괄 업데이트
+ const updatedContracts = await db
+ .update(basicContract)
+ .set({
+ buyerSignedAt: currentDate,
+ completedAt: currentDate,
+ status: "COMPLETED",
+ updatedAt: currentDate,
+ })
+ .where(inArray(basicContract.id, eligibleContracts.map(c => c.id)))
+ .returning({ id: basicContract.id })
+
+ // 캐시 재검증
+ revalidatePath("/contracts")
+
+ console.log("일괄 최종승인 완료:", {
+ approver,
+ contractIds: updatedContracts.map(c => c.id),
+ completedAt: currentDate,
+ })
+
+ return {
+ success: true,
+ message: `${updatedContracts.length}건의 계약서 최종승인이 완료되었습니다.`,
+ data: {
+ processedCount: updatedContracts.length,
+ contractIds: updatedContracts.map(c => c.id),
+ }
+ }
+
+ } catch (error) {
+ console.error("일괄 최종승인 중 오류:", error)
+
+ return {
+ success: false,
+ message: "최종승인 처리 중 오류가 발생했습니다. 다시 시도해 주세요.",
+ }
+ }
+}
+
+
+export async function getVendorSignatureFile() {
+ try {
+ // 세션에서 사용자 정보 가져오기
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user?.companyId) {
+ throw new Error("인증되지 않은 사용자이거나 회사 정보가 없습니다.")
+ }
+
+ // 조건에 맞는 vendor attachment 찾기
+ const signatureAttachment = await db.query.vendorAttachments.findFirst({
+ where: and(
+ eq(vendorAttachments.vendorId, session.user.companyId),
+ eq(vendorAttachments.attachmentType, "SIGNATURE")
+ )
+ })
+
+ if (!signatureAttachment) {
+ return {
+ success: false,
+ error: "서명 파일을 찾을 수 없습니다."
+ }
+ }
+
+ // 파일 읽기
+ let filePath: string;
+ const nasPath = process.env.NAS_PATH || "/evcp_nas"
+
+
+ if (process.env.NODE_ENV === 'production') {
+ // ✅ 프로덕션: NAS 경로 사용
+ filePath = path.join(nasPath, signatureAttachment.filePath);
+
+ } else {
+ // 개발: public 폴더
+ filePath = path.join(process.cwd(), 'public', signatureAttachment.filePath);
+ }
+
+ const fileBuffer = await readFile(filePath)
+
+ // Base64로 인코딩
+ const base64File = fileBuffer.toString('base64')
+
+ return {
+ success: true,
+ data: {
+ id: signatureAttachment.id,
+ fileName: signatureAttachment.fileName,
+ fileType: signatureAttachment.fileType,
+ base64: base64File,
+ // 웹에서 사용할 수 있는 data URL 형식도 제공
+ dataUrl: `data:${signatureAttachment.fileType || 'application/octet-stream'};base64,${base64File}`
+ }
+ }
+
+ } catch (error) {
+ console.error("서명 파일 조회 중 오류:", error)
+ console.log("서명 파일 조회 중 오류:", error)
+
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "파일을 읽는 중 오류가 발생했습니다."
+ }
+ }
}
\ No newline at end of file |
