summaryrefslogtreecommitdiff
path: root/lib/general-contracts
diff options
context:
space:
mode:
Diffstat (limited to 'lib/general-contracts')
-rw-r--r--lib/general-contracts/approval-actions.ts136
-rw-r--r--lib/general-contracts/approval-template-variables.ts369
-rw-r--r--lib/general-contracts/detail/general-contract-approval-request-dialog.tsx163
-rw-r--r--lib/general-contracts/handlers.ts157
-rw-r--r--lib/general-contracts/service.ts2
5 files changed, 802 insertions, 25 deletions
diff --git a/lib/general-contracts/approval-actions.ts b/lib/general-contracts/approval-actions.ts
new file mode 100644
index 00000000..e75d6cd6
--- /dev/null
+++ b/lib/general-contracts/approval-actions.ts
@@ -0,0 +1,136 @@
+/**
+ * 일반계약 관련 결재 서버 액션
+ *
+ * 사용자가 UI에서 호출하는 함수들
+ * ApprovalSubmissionSaga를 사용하여 결재 프로세스를 시작
+ */
+
+'use server';
+
+import { ApprovalSubmissionSaga } from '@/lib/approval';
+import { mapContractToApprovalTemplateVariables } from './approval-template-variables';
+import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils';
+import db from '@/db/db';
+import { eq } from 'drizzle-orm';
+import { generalContracts } from '@/db/schema/generalContract';
+import { users } from '@/db/schema';
+
+interface ContractSummary {
+ basicInfo: Record<string, unknown>;
+ items: Record<string, unknown>[];
+ subcontractChecklist: Record<string, unknown> | null;
+ storageInfo?: Record<string, unknown>[];
+ pdfPath?: string;
+ basicContractPdfs?: Array<{ key: string; buffer: number[]; fileName: string }>;
+}
+
+/**
+ * 결재를 거쳐 일반계약 승인 요청을 처리하는 서버 액션
+ *
+ * 사용법 (클라이언트 컴포넌트에서):
+ * ```typescript
+ * const result = await requestContractApprovalWithApproval({
+ * contractId: 123,
+ * contractSummary: summaryData,
+ * currentUser: { id: 1, epId: 'EP001', email: 'user@example.com' },
+ * approvers: ['EP002', 'EP003'],
+ * title: '계약 체결 진행 품의 요청서'
+ * });
+ *
+ * if (result.status === 'pending_approval') {
+ * console.log('결재 ID:', result.approvalId);
+ * }
+ * ```
+ */
+export async function requestContractApprovalWithApproval(data: {
+ contractId: number;
+ contractSummary: ContractSummary;
+ currentUser: { id: number; epId: string | null; email?: string };
+ approvers?: string[]; // Knox EP ID 배열 (결재선)
+ title?: string; // 결재 제목 (선택사항, 미지정 시 자동 생성)
+}) {
+ debugLog('[ContractApproval] 일반계약 승인 요청 결재 서버 액션 시작', {
+ contractId: data.contractId,
+ contractNumber: data.contractSummary.basicInfo?.contractNumber,
+ contractName: data.contractSummary.basicInfo?.name,
+ userId: data.currentUser.id,
+ hasEpId: !!data.currentUser.epId,
+ });
+
+ // 입력 검증
+ if (!data.currentUser.epId) {
+ debugError('[ContractApproval] Knox EP ID 없음');
+ throw new Error('Knox EP ID가 필요합니다');
+ }
+
+ if (!data.contractId) {
+ debugError('[ContractApproval] 계약 ID 없음');
+ throw new Error('계약 ID가 필요합니다');
+ }
+
+ // 1. 유저의 nonsapUserId 조회 (Cronjob 환경을 위해)
+ debugLog('[ContractApproval] nonsapUserId 조회');
+ const userResult = await db.query.users.findFirst({
+ where: eq(users.id, data.currentUser.id),
+ columns: { nonsapUserId: true }
+ });
+ const nonsapUserId = userResult?.nonsapUserId || null;
+ debugLog('[ContractApproval] nonsapUserId 조회 완료', { nonsapUserId });
+
+ // 2. 템플릿 변수 매핑
+ debugLog('[ContractApproval] 템플릿 변수 매핑 시작');
+ const variables = await mapContractToApprovalTemplateVariables(data.contractSummary);
+ debugLog('[ContractApproval] 템플릿 변수 매핑 완료', {
+ variableKeys: Object.keys(variables),
+ });
+
+ // 3. 결재 워크플로우 시작 (Saga 패턴)
+ debugLog('[ContractApproval] ApprovalSubmissionSaga 생성');
+ const saga = new ApprovalSubmissionSaga(
+ // actionType: 핸들러를 찾을 때 사용할 키
+ 'general_contract_approval',
+
+ // actionPayload: 결재 승인 후 핸들러에 전달될 데이터
+ {
+ contractId: data.contractId,
+ contractSummary: data.contractSummary,
+ currentUser: {
+ id: data.currentUser.id,
+ email: data.currentUser.email,
+ nonsapUserId: nonsapUserId,
+ },
+ },
+
+ // approvalConfig: 결재 상신 정보 (템플릿 포함)
+ {
+ title: data.title || `계약 체결 진행 품의 요청서 - ${data.contractSummary.basicInfo?.contractNumber || data.contractId}`,
+ description: `${data.contractSummary.basicInfo?.name || '일반계약'} 계약 체결 진행 품의 요청`,
+ templateName: '일반계약 결재', // 한국어 템플릿명
+ variables, // 치환할 변수들
+ approvers: data.approvers,
+ currentUser: data.currentUser,
+ }
+ );
+
+ debugLog('[ContractApproval] Saga 실행 시작');
+ const result = await saga.execute();
+
+ // 4. 결재 상신 성공 시 상태를 'approval_in_progress'로 변경
+ if (result.status === 'pending_approval') {
+ debugLog('[ContractApproval] 상태를 approval_in_progress로 변경');
+ await db.update(generalContracts)
+ .set({
+ status: 'approval_in_progress',
+ lastUpdatedAt: new Date()
+ })
+ .where(eq(generalContracts.id, data.contractId));
+ }
+
+ debugSuccess('[ContractApproval] 결재 워크플로우 완료', {
+ approvalId: result.approvalId,
+ status: result.status,
+ });
+
+ return result;
+}
+
diff --git a/lib/general-contracts/approval-template-variables.ts b/lib/general-contracts/approval-template-variables.ts
new file mode 100644
index 00000000..6924694e
--- /dev/null
+++ b/lib/general-contracts/approval-template-variables.ts
@@ -0,0 +1,369 @@
+/**
+ * 일반계약 결재 템플릿 변수 매핑 함수
+ *
+ * 제공된 HTML 템플릿의 변수명에 맞춰 매핑
+ */
+
+'use server';
+
+import { format } from 'date-fns';
+
+interface ContractSummary {
+ basicInfo: Record<string, unknown>;
+ items: Record<string, unknown>[];
+ subcontractChecklist: Record<string, unknown> | null;
+ storageInfo?: Record<string, unknown>[];
+}
+
+/**
+ * 일반계약 데이터를 결재 템플릿 변수로 매핑
+ *
+ * @param contractSummary - 계약 요약 정보
+ * @returns 템플릿 변수 객체 (Record<string, string>)
+ */
+export async function mapContractToApprovalTemplateVariables(
+ contractSummary: ContractSummary
+): Promise<Record<string, string>> {
+ const { basicInfo, items, subcontractChecklist } = contractSummary;
+
+ // 날짜 포맷팅 헬퍼
+ const formatDate = (date: any) => {
+ if (!date) return '';
+ try {
+ const d = new Date(date);
+ if (isNaN(d.getTime())) return String(date);
+ return format(d, 'yyyy-MM-dd');
+ } catch {
+ return String(date || '');
+ }
+ };
+
+ // 금액 포맷팅 헬퍼
+ const formatCurrency = (amount: any) => {
+ if (amount === undefined || amount === null || amount === '') return '';
+ const num = Number(amount);
+ if (isNaN(num)) return String(amount);
+ return num.toLocaleString('ko-KR');
+ };
+
+ // 계약기간 포맷팅
+ const contractPeriod = basicInfo.startDate && basicInfo.endDate
+ ? `${formatDate(basicInfo.startDate)} ~ ${formatDate(basicInfo.endDate)}`
+ : '';
+
+ // 계약체결방식
+ const contractExecutionMethod = basicInfo.executionMethod || '';
+
+ // 계약종류
+ const contractType = basicInfo.type || '';
+
+ // 업체선정방식
+ const vendorSelectionMethod = basicInfo.contractSourceType || '';
+
+ // 매입 부가가치세
+ const taxType = basicInfo.taxType || '';
+
+ // SHI 지급조건
+ const paymentTerm = basicInfo.paymentTerm || '';
+
+ // SHI 인도조건
+ const deliveryTerm = basicInfo.deliveryTerm || '';
+ const deliveryType = basicInfo.deliveryType || '';
+
+ // 사외업체 야드 투입 여부
+ const externalYardEntry = basicInfo.externalYardEntry === 'Y' ? '예' : '아니오';
+
+ // 직종
+ const workType = basicInfo.workType || '';
+
+ // 재하도 협력사
+ const subcontractVendor = basicInfo.subcontractVendorName || '';
+
+ // 계약 내용
+ const contractContent = basicInfo.notes || basicInfo.name || '';
+
+ // 계약성립조건
+ let establishmentConditionsText = '';
+ if (basicInfo.contractEstablishmentConditions) {
+ try {
+ const cond = typeof basicInfo.contractEstablishmentConditions === 'string'
+ ? JSON.parse(basicInfo.contractEstablishmentConditions)
+ : basicInfo.contractEstablishmentConditions;
+
+ const active: string[] = [];
+ if (cond.regularVendorRegistration) active.push('정규업체 등록(실사 포함) 시');
+ if (cond.projectAward) active.push('프로젝트 수주 시');
+ if (cond.ownerApproval) active.push('선주 승인 시');
+ if (cond.other) active.push('기타');
+ establishmentConditionsText = active.join(', ');
+ } catch (e) {
+ console.warn('계약성립조건 파싱 실패:', e);
+ }
+ }
+
+ // 계약해지조건
+ let terminationConditionsText = '';
+ if (basicInfo.contractTerminationConditions) {
+ try {
+ const cond = typeof basicInfo.contractTerminationConditions === 'string'
+ ? JSON.parse(basicInfo.contractTerminationConditions)
+ : basicInfo.contractTerminationConditions;
+
+ const active: string[] = [];
+ if (cond.standardTermination) active.push('표준 계약해지조건');
+ if (cond.projectNotAwarded) active.push('프로젝트 미수주 시');
+ if (cond.other) active.push('기타');
+ terminationConditionsText = active.join(', ');
+ } catch (e) {
+ console.warn('계약해지조건 파싱 실패:', e);
+ }
+ }
+
+ // 협력사 정보
+ const vendorCode = basicInfo.vendorCode || '';
+ const vendorName = basicInfo.vendorName || '';
+ const vendorContactPerson = basicInfo.vendorContactPerson || '';
+ const vendorPhone = basicInfo.vendorPhone || '';
+ const vendorEmail = basicInfo.vendorEmail || '';
+ const vendorNote = '';
+
+ // 자재 정보 (최대 100건)
+ const materialItems = items.slice(0, 100);
+ const materialCount = items.length;
+
+ // 보증 정보
+ const guarantees: Array<{
+ type: string;
+ order: number;
+ bondNumber: string;
+ rate: string;
+ amount: string;
+ period: string;
+ startDate: string;
+ endDate: string;
+ issuer: string;
+ }> = [];
+
+ // 계약보증
+ if (basicInfo.contractBond) {
+ const bond = typeof basicInfo.contractBond === 'string'
+ ? JSON.parse(basicInfo.contractBond)
+ : basicInfo.contractBond;
+
+ if (bond && Array.isArray(bond)) {
+ bond.forEach((b: any, idx: number) => {
+ guarantees.push({
+ type: '계약보증',
+ order: idx + 1,
+ bondNumber: b.bondNumber || '',
+ rate: b.rate ? `${b.rate}%` : '',
+ amount: formatCurrency(b.amount),
+ period: b.period || '',
+ startDate: formatDate(b.startDate),
+ endDate: formatDate(b.endDate),
+ issuer: b.issuer || '',
+ });
+ });
+ }
+ }
+
+ // 지급보증
+ if (basicInfo.paymentBond) {
+ const bond = typeof basicInfo.paymentBond === 'string'
+ ? JSON.parse(basicInfo.paymentBond)
+ : basicInfo.paymentBond;
+
+ if (bond && Array.isArray(bond)) {
+ bond.forEach((b: any, idx: number) => {
+ guarantees.push({
+ type: '지급보증',
+ order: idx + 1,
+ bondNumber: b.bondNumber || '',
+ rate: b.rate ? `${b.rate}%` : '',
+ amount: formatCurrency(b.amount),
+ period: b.period || '',
+ startDate: formatDate(b.startDate),
+ endDate: formatDate(b.endDate),
+ issuer: b.issuer || '',
+ });
+ });
+ }
+ }
+
+ // 하자보증
+ if (basicInfo.defectBond) {
+ const bond = typeof basicInfo.defectBond === 'string'
+ ? JSON.parse(basicInfo.defectBond)
+ : basicInfo.defectBond;
+
+ if (bond && Array.isArray(bond)) {
+ bond.forEach((b: any, idx: number) => {
+ guarantees.push({
+ type: '하자보증',
+ order: idx + 1,
+ bondNumber: b.bondNumber || '',
+ rate: b.rate ? `${b.rate}%` : '',
+ amount: formatCurrency(b.amount),
+ period: b.period || '',
+ startDate: formatDate(b.startDate),
+ endDate: formatDate(b.endDate),
+ issuer: b.issuer || '',
+ });
+ });
+ }
+ }
+
+ // 보증 전체 비고
+ const guaranteeNote = basicInfo.guaranteeNote || '';
+
+ // 하도급 체크리스트
+ const checklistItems: Array<{
+ category: string;
+ item1: string;
+ item2: string;
+ result: string;
+ department: string;
+ cause: string;
+ measure: string;
+ }> = [];
+
+ if (subcontractChecklist) {
+ // 1-1. 작업 시 서면 발급
+ checklistItems.push({
+ category: '계약 시 [계약 체결 단계]',
+ item1: '1-1. 작업 시 서면 발급',
+ item2: '-',
+ result: subcontractChecklist.workDocumentIssued === '준수' ? '준수' :
+ subcontractChecklist.workDocumentIssued === '위반' ? '위반' :
+ subcontractChecklist.workDocumentIssued === '위반의심' ? '위반의심' : '',
+ department: subcontractChecklist.workDocumentIssuedDepartment || '',
+ cause: subcontractChecklist.workDocumentIssuedCause || '',
+ measure: subcontractChecklist.workDocumentIssuedMeasure || '',
+ });
+
+ // 1-2. 6대 법정 기재사항 명기 여부
+ checklistItems.push({
+ category: '계약 시 [계약 체결 단계]',
+ item1: '1-2. 6대 법정 기재사항 명기 여부',
+ item2: '-',
+ result: subcontractChecklist.sixLegalItems === '준수' ? '준수' :
+ subcontractChecklist.sixLegalItems === '위반' ? '위반' :
+ subcontractChecklist.sixLegalItems === '위반의심' ? '위반의심' : '',
+ department: subcontractChecklist.sixLegalItemsDepartment || '',
+ cause: subcontractChecklist.sixLegalItemsCause || '',
+ measure: subcontractChecklist.sixLegalItemsMeasure || '',
+ });
+
+ // 2. 부당 하도급 대금 결정 행위
+ checklistItems.push({
+ category: '계약 시 [계약 체결 단계]',
+ item1: '-',
+ item2: '2. 부당 하도급 대금 결정 행위 (대금 결정 방법)',
+ result: subcontractChecklist.unfairSubcontractPrice === '준수' ? '준수' :
+ subcontractChecklist.unfairSubcontractPrice === '위반' ? '위반' :
+ subcontractChecklist.unfairSubcontractPrice === '위반의심' ? '위반의심' : '',
+ department: subcontractChecklist.unfairSubcontractPriceDepartment || '',
+ cause: subcontractChecklist.unfairSubcontractPriceCause || '',
+ measure: subcontractChecklist.unfairSubcontractPriceMeasure || '',
+ });
+ }
+
+ // 총 계약 금액 계산
+ const totalContractAmount = items.reduce((sum, item) => {
+ const amount = Number(item.contractAmount || item.totalLineAmount || 0);
+ return sum + (isNaN(amount) ? 0 : amount);
+ }, 0);
+
+ // 변수 매핑
+ const variables: Record<string, string> = {
+ // 계약 기본 정보
+ '계약번호': String(basicInfo.contractNumber || ''),
+ '계약명': String(basicInfo.name || basicInfo.contractName || ''),
+ '계약체결방식': String(contractExecutionMethod),
+ '계약종류': String(contractType),
+ '구매담당자': String(basicInfo.managerName || basicInfo.registeredByName || ''),
+ '업체선정방식': String(vendorSelectionMethod),
+ '입찰번호': String(basicInfo.linkedBidNumber || ''),
+ '입찰명': String(basicInfo.linkedBidName || ''),
+ '계약기간': contractPeriod,
+ '계약일자': formatDate(basicInfo.registeredAt || basicInfo.createdAt),
+ '매입_부가가치세': String(taxType),
+ '계약_담당자': String(basicInfo.managerName || basicInfo.registeredByName || ''),
+ '계약부서': String(basicInfo.departmentName || ''),
+ '계약금액': formatCurrency(basicInfo.contractAmount),
+ 'SHI_지급조건': String(paymentTerm),
+ 'SHI_인도조건': String(deliveryTerm),
+ 'SHI_인도조건_옵션': String(deliveryType),
+ '선적지': String(basicInfo.shippingLocation || ''),
+ '하역지': String(basicInfo.dischargeLocation || ''),
+ '사외업체_야드_투입여부': externalYardEntry,
+ '프로젝트': String(basicInfo.projectName || basicInfo.projectCode || ''),
+ '직종': String(workType),
+ '재하도_협력사': String(subcontractVendor),
+ '계약내용': String(contractContent),
+ '계약성립조건': establishmentConditionsText,
+ '계약해지조건': terminationConditionsText,
+
+ // 협력사 정보
+ '협력사코드': String(vendorCode),
+ '협력사명': String(vendorName),
+ '협력사_담당자': String(vendorContactPerson),
+ '전화번호': String(vendorPhone),
+ '이메일': String(vendorEmail),
+ '비고': String(vendorNote),
+
+ // 자재 정보
+ '대상_자재_수': String(materialCount),
+ };
+
+ // 자재 정보 변수 (최대 100건)
+ materialItems.forEach((item, index) => {
+ const idx = index + 1;
+ variables[`플랜트_${idx}`] = String(item.plant || '');
+ variables[`프로젝트_${idx}`] = String(item.projectName || item.projectCode || '');
+ variables[`자재그룹_${idx}`] = String(item.itemGroup || item.itemCode || '');
+ variables[`자재그룹명_${idx}`] = String(item.itemGroupName || '');
+ variables[`자재번호_${idx}`] = String(item.itemCode || '');
+ variables[`자재상세_${idx}`] = String(item.itemInfo || item.description || '');
+ variables[`연간단가여부_${idx}`] = String(item.isAnnualPrice ? '예' : '아니오');
+ variables[`수량_${idx}`] = formatCurrency(item.quantity);
+ variables[`구매단위_${idx}`] = String(item.quantityUnit || '');
+ variables[`계약단가_${idx}`] = formatCurrency(item.contractUnitPrice || item.unitPrice);
+ variables[`수량단위_${idx}`] = String(item.quantityUnit || '');
+ variables[`총중량_${idx}`] = formatCurrency(item.totalWeight);
+ variables[`중량단위_${idx}`] = String(item.weightUnit || '');
+ variables[`계약금액_${idx}`] = formatCurrency(item.contractAmount || item.totalLineAmount);
+ });
+
+ // 총 계약 금액
+ variables['총_계약금액'] = formatCurrency(totalContractAmount);
+
+ // 보증 정보 변수
+ guarantees.forEach((guarantee, index) => {
+ const idx = index + 1;
+ const typeKey = String(guarantee.type);
+ variables[`${typeKey}_차수_${idx}`] = String(guarantee.order);
+ variables[`${typeKey}_증권번호_${idx}`] = String(guarantee.bondNumber || '');
+ variables[`${typeKey}_보증금율_${idx}`] = String(guarantee.rate || '');
+ variables[`${typeKey}_보증금액_${idx}`] = String(guarantee.amount || '');
+ variables[`${typeKey}_보증기간_${idx}`] = String(guarantee.period || '');
+ variables[`${typeKey}_시작일_${idx}`] = String(guarantee.startDate || '');
+ variables[`${typeKey}_종료일_${idx}`] = String(guarantee.endDate || '');
+ variables[`${typeKey}_발행기관_${idx}`] = String(guarantee.issuer || '');
+ });
+
+ // 보증 전체 비고
+ variables['보증_전체_비고'] = String(guaranteeNote);
+
+ // 하도급 체크리스트 변수
+ checklistItems.forEach((item, index) => {
+ const idx = index + 1;
+ variables[`점검결과_${idx === 1 ? '1_1' : idx === 2 ? '1_2' : '2'}`] = String(item.result);
+ variables[`귀책부서_${idx === 1 ? '1_1' : idx === 2 ? '1_2' : '2'}`] = String(item.department);
+ variables[`원인_${idx === 1 ? '1_1' : idx === 2 ? '1_2' : '2'}`] = String(item.cause);
+ variables[`대책_${idx === 1 ? '1_1' : idx === 2 ? '1_2' : '2'}`] = String(item.measure);
+ });
+
+ return variables;
+}
+
diff --git a/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx b/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx
index 46251c71..d44f4290 100644
--- a/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx
+++ b/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx
@@ -35,6 +35,9 @@ import {
getStorageInfo
} from '../service'
import { mapContractDataToTemplateVariables } from '../utils'
+import { ApprovalPreviewDialog } from '@/lib/approval/client'
+import { requestContractApprovalWithApproval } from '../approval-actions'
+import { mapContractToApprovalTemplateVariables } from '../approval-template-variables'
interface ContractApprovalRequestDialogProps {
contract: Record<string, unknown>
@@ -47,6 +50,8 @@ interface ContractSummary {
items: Record<string, unknown>[]
subcontractChecklist: Record<string, unknown> | null
storageInfo?: Record<string, unknown>[]
+ pdfPath?: string
+ basicContractPdfs?: Array<{ key: string; buffer: number[]; fileName: string }>
}
export function ContractApprovalRequestDialog({
@@ -72,6 +77,12 @@ export function ContractApprovalRequestDialog({
}>>([])
const [isLoadingBasicContracts, setIsLoadingBasicContracts] = useState(false)
+ // 결재 관련 상태
+ const [approvalDialogOpen, setApprovalDialogOpen] = useState(false)
+ const [approvalVariables, setApprovalVariables] = useState<Record<string, string>>({})
+ const [savedPdfPath, setSavedPdfPath] = useState<string | null>(null)
+ const [savedBasicContractPdfs, setSavedBasicContractPdfs] = useState<Array<{ key: string; buffer: number[]; fileName: string }>>([])
+
const contractId = contract.id as number
const userId = session?.user?.id || ''
@@ -143,7 +154,7 @@ export function ContractApprovalRequestDialog({
await new Promise(resolve => setTimeout(resolve, 3000));
const fileData = await templateDoc.getFileData();
- const pdfBuffer = await Core.officeToPDFBuffer(fileData, { extension: 'docx' });
+ const pdfBuffer = await (Core as any).officeToPDFBuffer(fileData, { extension: 'docx' });
const fileName = `${contractType}_${contractSummary?.basicInfo?.vendorCode || vendorId}_${Date.now()}.pdf`;
@@ -542,7 +553,42 @@ export function ContractApprovalRequestDialog({
console.log("🔄 PDF 미리보기 닫기 완료")
}
- // 최종 전송
+ // PDF를 서버에 저장하는 함수 (API route 사용)
+ const savePdfToServer = async (pdfBuffer: Uint8Array, fileName: string): Promise<string | null> => {
+ try {
+ // PDF 버퍼를 Blob으로 변환
+ const pdfBlob = new Blob([pdfBuffer], { type: 'application/pdf' });
+
+ // FormData 생성
+ const formData = new FormData();
+ formData.append('file', pdfBlob, fileName);
+ formData.append('contractId', String(contractId));
+
+ // API route로 업로드
+ const response = await fetch('/api/general-contracts/upload-pdf', {
+ method: 'POST',
+ body: formData,
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || 'PDF 파일 저장에 실패했습니다.');
+ }
+
+ const result = await response.json();
+
+ if (!result.success) {
+ throw new Error(result.error || 'PDF 파일 저장에 실패했습니다.');
+ }
+
+ return result.filePath;
+ } catch (error) {
+ console.error('PDF 저장 실패:', error);
+ return null;
+ }
+ };
+
+ // 최종 전송 - 결재 프로세스 시작
const handleFinalSubmit = async () => {
if (!generatedPdfUrl || !contractSummary || !generatedPdfBuffer) {
toast.error('생성된 PDF가 필요합니다.')
@@ -594,34 +640,77 @@ export function ContractApprovalRequestDialog({
}
}
- // 서버액션을 사용하여 계약승인요청 전송
- const result = await sendContractApprovalRequest(
- contractSummary,
+ // PDF를 서버에 저장
+ toast.info('PDF를 서버에 저장하는 중입니다...');
+ const pdfPath = await savePdfToServer(
generatedPdfBuffer,
- 'contractDocument',
- userId,
- generatedBasicContractPdfs
- )
+ `contract_${contractId}_${Date.now()}.pdf`
+ );
- if (result.success) {
- toast.success('계약승인요청이 전송되었습니다.')
- onOpenChange(false)
- } else {
- // 서버에서 이미 처리된 에러 메시지 표시
- toast.error(result.error || '계약승인요청 전송 실패')
- return
+ if (!pdfPath) {
+ toast.error('PDF 저장에 실패했습니다.');
+ return;
}
+
+ setSavedPdfPath(pdfPath);
+ setSavedBasicContractPdfs(generatedBasicContractPdfs);
+
+ // 결재 템플릿 변수 매핑
+ const approvalVars = await mapContractToApprovalTemplateVariables(contractSummary);
+ setApprovalVariables(approvalVars);
+
+ // 계약승인요청 dialog close
+ onOpenChange(false);
+
+ // 결재 템플릿 dialog open
+ setApprovalDialogOpen(true);
} catch (error: any) {
- console.error('Error submitting approval request:', error)
+ console.error('Error preparing approval:', error);
+ toast.error('결재 준비 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
- // 데이터베이스 중복 키 오류 처리
- if (error.message && error.message.includes('duplicate key value violates unique constraint')) {
- toast.error('이미 존재하는 계약번호입니다. 다른 계약번호를 사용해주세요.')
- return
- }
+ // 결재 등록 처리
+ const handleApprovalSubmit = async (data: {
+ approvers: string[];
+ title: string;
+ attachments?: File[];
+ }) => {
+ if (!contractSummary || !savedPdfPath) {
+ toast.error('계약 정보가 필요합니다.')
+ return
+ }
- // 다른 오류에 대한 일반적인 처리
- toast.error('계약승인요청 전송 중 오류가 발생했습니다.')
+ setIsLoading(true)
+ try {
+ const result = await requestContractApprovalWithApproval({
+ contractId,
+ contractSummary: {
+ ...contractSummary,
+ // PDF 경로를 contractSummary에 추가
+ pdfPath: savedPdfPath || undefined,
+ basicContractPdfs: savedBasicContractPdfs.length > 0 ? savedBasicContractPdfs : undefined,
+ } as ContractSummary,
+ currentUser: {
+ id: Number(userId),
+ epId: session?.user?.epId || null,
+ email: session?.user?.email || undefined,
+ },
+ approvers: data.approvers,
+ title: data.title,
+ });
+
+ if (result.status === 'pending_approval') {
+ toast.success('결재가 등록되었습니다.')
+ setApprovalDialogOpen(false);
+ } else {
+ toast.error('결재 등록에 실패했습니다.')
+ }
+ } catch (error: any) {
+ console.error('Error submitting approval:', error);
+ toast.error(`결재 등록 중 오류가 발생했습니다: ${error.message || '알 수 없는 오류'}`);
} finally {
setIsLoading(false)
}
@@ -1064,5 +1153,31 @@ export function ContractApprovalRequestDialog({
</TabsContent>
</Tabs>
</DialogContent>
+
+ {/* 결재 미리보기 Dialog */}
+ {session?.user && session.user.epId && contractSummary && (
+ <ApprovalPreviewDialog
+ open={approvalDialogOpen}
+ onOpenChange={(open) => {
+ setApprovalDialogOpen(open);
+ if (!open) {
+ setApprovalVariables({});
+ setSavedPdfPath(null);
+ setSavedBasicContractPdfs([]);
+ }
+ }}
+ templateName="일반계약 결재"
+ variables={approvalVariables}
+ title={`계약 체결 진행 품의 요청서 - ${contractSummary.basicInfo?.contractNumber || contractId}`}
+ currentUser={{
+ id: Number(session.user.id),
+ epId: session.user.epId,
+ name: session.user.name || undefined,
+ email: session.user.email || undefined,
+ }}
+ onConfirm={handleApprovalSubmit}
+ enableAttachments={false}
+ />
+ )}
</Dialog>
)} \ No newline at end of file
diff --git a/lib/general-contracts/handlers.ts b/lib/general-contracts/handlers.ts
new file mode 100644
index 00000000..029fb9cd
--- /dev/null
+++ b/lib/general-contracts/handlers.ts
@@ -0,0 +1,157 @@
+/**
+ * 일반계약 관련 결재 액션 핸들러
+ *
+ * 실제 비즈니스 로직만 포함 (결재 로직은 approval-workflow에서 처리)
+ */
+
+'use server';
+
+import { sendContractApprovalRequest } from './service';
+import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils';
+import db from '@/db/db';
+import { eq } from 'drizzle-orm';
+import { generalContracts } from '@/db/schema/generalContract';
+
+interface ContractSummary {
+ basicInfo: Record<string, unknown>;
+ items: Record<string, unknown>[];
+ subcontractChecklist: Record<string, unknown> | null;
+ storageInfo?: Record<string, unknown>[];
+}
+
+/**
+ * 일반계약 승인 핸들러 (결재 승인 후 계약승인요청 전송 실행)
+ *
+ * 결재 승인 후 자동으로 계약승인요청을 전송함
+ * 이 함수는 직접 호출하지 않고, 결재 워크플로우에서 자동으로 호출됨
+ *
+ * @param payload - withApproval()에서 전달한 actionPayload
+ */
+export async function approveContractInternal(payload: {
+ contractId: number;
+ contractSummary: ContractSummary;
+ currentUser?: {
+ id: string | number;
+ name?: string | null;
+ email?: string | null;
+ nonsapUserId?: string | null;
+ };
+}) {
+ debugLog('[ContractApprovalHandler] 일반계약 승인 핸들러 시작', {
+ contractId: payload.contractId,
+ contractNumber: payload.contractSummary.basicInfo?.contractNumber,
+ contractName: payload.contractSummary.basicInfo?.name,
+ hasCurrentUser: !!payload.currentUser,
+ });
+
+ try {
+ // 1. 계약 정보 확인
+ const [contract] = await db
+ .select()
+ .from(generalContracts)
+ .where(eq(generalContracts.id, payload.contractId))
+ .limit(1);
+
+ if (!contract) {
+ throw new Error('계약을 찾을 수 없습니다.');
+ }
+
+ // 2. 계약승인요청 전송
+ debugLog('[ContractApprovalHandler] sendContractApprovalRequest 호출');
+
+ // PDF 경로에서 PDF 버퍼 읽기
+ const pdfPath = (payload.contractSummary as any).pdfPath;
+ if (!pdfPath) {
+ throw new Error('PDF 경로가 없습니다.');
+ }
+
+ // PDF 파일 읽기
+ const fs = await import('fs/promises');
+ const path = await import('path');
+
+ const nasPath = process.env.NAS_PATH || "/evcp_nas";
+ const isProduction = process.env.NODE_ENV === "production";
+ const baseDir = isProduction ? nasPath : path.join(process.cwd(), "public");
+
+ // publicPath에서 실제 파일 경로로 변환
+ const actualPath = pdfPath.startsWith('/')
+ ? path.join(baseDir, pdfPath)
+ : path.join(baseDir, 'generalContracts', pdfPath);
+
+ let pdfBuffer: Uint8Array;
+ try {
+ const fileBuffer = await fs.readFile(actualPath);
+ pdfBuffer = new Uint8Array(fileBuffer);
+ } catch (error) {
+ debugError('[ContractApprovalHandler] PDF 파일 읽기 실패', error);
+ throw new Error('PDF 파일을 읽을 수 없습니다.');
+ }
+
+ // 기본계약서는 클라이언트에서 이미 생성되었을 것으로 가정
+ const generatedBasicContracts: Array<{ key: string; buffer: number[]; fileName: string }> =
+ (payload.contractSummary as any).basicContractPdfs || [];
+
+ const userId = payload.currentUser?.id
+ ? String(payload.currentUser.id)
+ : String(contract.registeredById);
+
+ const result = await sendContractApprovalRequest(
+ payload.contractSummary,
+ pdfBuffer,
+ 'contractDocument',
+ userId,
+ generatedBasicContracts
+ );
+
+ if (!result.success) {
+ debugError('[ContractApprovalHandler] 계약승인요청 전송 실패', result.error);
+
+ // 전송 실패 시 상태를 원래대로 되돌림
+ await db.update(generalContracts)
+ .set({
+ status: 'Draft',
+ lastUpdatedAt: new Date()
+ })
+ .where(eq(generalContracts.id, payload.contractId));
+
+ throw new Error(result.error || '계약승인요청 전송에 실패했습니다.');
+ }
+
+ // 3. 전송 성공 시 상태를 'Contract Accept Request'로 변경
+ debugLog('[ContractApprovalHandler] 계약승인요청 전송 성공, 상태를 Contract Accept Request로 변경');
+ await db.update(generalContracts)
+ .set({
+ status: 'Contract Accept Request',
+ lastUpdatedAt: new Date()
+ })
+ .where(eq(generalContracts.id, payload.contractId));
+
+ debugSuccess('[ContractApprovalHandler] 일반계약 승인 완료', {
+ contractId: payload.contractId,
+ result: result
+ });
+
+ return {
+ success: true,
+ message: '계약승인요청이 전송되었습니다.',
+ result: result
+ };
+ } catch (error) {
+ debugError('[ContractApprovalHandler] 일반계약 승인 중 에러', error);
+
+ // 에러 발생 시 상태를 원래대로 되돌림
+ try {
+ await db.update(generalContracts)
+ .set({
+ status: 'Draft',
+ lastUpdatedAt: new Date()
+ })
+ .where(eq(generalContracts.id, payload.contractId));
+ } catch (updateError) {
+ debugError('[ContractApprovalHandler] 상태 업데이트 실패', updateError);
+ }
+
+ throw error;
+ }
+}
+
diff --git a/lib/general-contracts/service.ts b/lib/general-contracts/service.ts
index 72b6449f..b803d2d4 100644
--- a/lib/general-contracts/service.ts
+++ b/lib/general-contracts/service.ts
@@ -1386,7 +1386,7 @@ export async function sendContractApprovalRequest(
signerStatus: 'PENDING',
})
- // 사외업체 야드투입이 'Y'인 경우 안전담당자 자동 지정
+ // 사외업체 야드투입이 'Y'인 경우 안전담당자 자동 지정 - 수정필요 12/05
if (contractSummary.basicInfo?.externalYardEntry === 'Y') {
try {
// 안전담당자 역할을 가진 사용자 조회 (역할명에 '안전' 또는 'safety' 포함)