summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/api/general-contracts/upload-pdf/route.ts73
-rw-r--r--db/schema/bidding.ts12
-rw-r--r--db/schema/generalContract.ts2
-rw-r--r--lib/approval/handlers-registry.ts7
-rw-r--r--lib/approval/templates/일반계약 결재.html3024
-rw-r--r--lib/bidding/actions.ts22
-rw-r--r--lib/bidding/detail/table/price-adjustment-dialog.tsx10
-rw-r--r--lib/bidding/service.ts322
-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
-rw-r--r--lib/soap/ecc/mapper/bidding-and-pr-mapper.ts6
-rw-r--r--lib/soap/ecc/send/chemical-substance-check.ts449
15 files changed, 4715 insertions, 39 deletions
diff --git a/app/api/general-contracts/upload-pdf/route.ts b/app/api/general-contracts/upload-pdf/route.ts
new file mode 100644
index 00000000..9480f7f5
--- /dev/null
+++ b/app/api/general-contracts/upload-pdf/route.ts
@@ -0,0 +1,73 @@
+/**
+ * 일반계약 PDF 업로드 API
+ * 클라이언트에서 생성된 PDF를 서버에 저장
+ */
+
+import { NextRequest, NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth/next';
+import { authOptions } from '@/app/api/auth/[...nextauth]/route';
+import { saveBuffer } from '@/lib/file-stroage';
+
+export async function POST(request: NextRequest) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ return NextResponse.json(
+ { success: false, error: '인증이 필요합니다' },
+ { status: 401 }
+ );
+ }
+
+ const formData = await request.formData();
+ const file = formData.get('file') as File;
+ const contractId = formData.get('contractId') as string;
+
+ if (!file) {
+ return NextResponse.json(
+ { success: false, error: '파일이 제공되지 않았습니다' },
+ { status: 400 }
+ );
+ }
+
+ // 파일을 ArrayBuffer로 읽기
+ const arrayBuffer = await file.arrayBuffer();
+ const buffer = Buffer.from(arrayBuffer);
+
+ // saveBuffer 함수를 사용해서 파일 저장
+ const saveResult = await saveBuffer({
+ buffer: buffer,
+ fileName: `${Date.now()}_${file.name}`,
+ directory: "generalContracts",
+ originalName: file.name,
+ userId: session.user.id
+ });
+
+ if (!saveResult.success) {
+ return NextResponse.json(
+ { success: false, error: saveResult.error || 'PDF 파일 저장에 실패했습니다.' },
+ { status: 500 }
+ );
+ }
+
+ const finalFilePath = saveResult.publicPath
+ ? saveResult.publicPath.replace('/api/files/', '')
+ : `/generalContracts/${saveResult.fileName}`;
+
+ return NextResponse.json({
+ success: true,
+ filePath: finalFilePath,
+ fileName: saveResult.fileName,
+ publicPath: saveResult.publicPath,
+ });
+ } catch (error) {
+ console.error('PDF 업로드 오류:', error);
+ return NextResponse.json(
+ {
+ success: false,
+ error: error instanceof Error ? error.message : 'PDF 업로드 중 오류가 발생했습니다.'
+ },
+ { status: 500 }
+ );
+ }
+}
+
diff --git a/db/schema/bidding.ts b/db/schema/bidding.ts
index fa3f1df5..c5370174 100644
--- a/db/schema/bidding.ts
+++ b/db/schema/bidding.ts
@@ -176,8 +176,10 @@ export const biddings = pgTable('biddings', {
// 일정 관리
preQuoteDate: date('pre_quote_date'), // 사전견적일
biddingRegistrationDate: date('bidding_registration_date'), // 입찰등록일
- submissionStartDate: timestamp('submission_start_date'), // 입찰서제출기간 시작
- submissionEndDate: timestamp('submission_end_date'), // 입찰서제출기간 끝
+ submissionStartDate: timestamp('submission_start_date'), // 입찰서제출기간 시작 (시간만 저장, 결재완료 후 실제 날짜로 계산)
+ submissionEndDate: timestamp('submission_end_date'), // 입찰서제출기간 끝 (시간만 저장, 결재완료 후 실제 날짜로 계산)
+ submissionStartOffset: integer('submission_start_offset'), // 시작일 오프셋 (결재완료일 + n일)
+ submissionDurationDays: integer('submission_duration_days'), // 입찰 기간 (시작일 + n일)
evaluationDate: timestamp('evaluation_date'),
// 사양설명회
@@ -188,6 +190,7 @@ export const biddings = pgTable('biddings', {
budget: decimal('budget', { precision: 15, scale: 2 }), // 예산
targetPrice: decimal('target_price', { precision: 15, scale: 2 }), // 내정가
targetPriceCalculationCriteria: text('target_price_calculation_criteria'), // 내정가 산정 기준
+ actualPrice: decimal('actual_price', { precision: 15, scale: 2 }), // 실적가
finalBidPrice: decimal('final_bid_price', { precision: 15, scale: 2 }), // 최종입찰가
// PR 정보
@@ -403,6 +406,11 @@ export const biddingCompanies = pgTable('bidding_companies', {
//연동제 적용요건 문의 여부
isPriceAdjustmentApplicableQuestion: boolean('is_price_adjustment_applicable_question').default(false), // 연동제 적용요건 문의 여부
+ // SHI 연동제 적용여부 및 관련 정보
+ shiPriceAdjustmentApplied: boolean('shi_price_adjustment_applied'), // SHI 연동제 적용여부 (null: 미정, true: 적용, false: 미적용)
+ priceAdjustmentNote: text('price_adjustment_note'), // 연동제 Note (textarea)
+ hasChemicalSubstance: boolean('has_chemical_substance'), // 화학물질여부
+
// 기타
notes: text('notes'), // 특이사항
contactPerson: varchar('contact_person', { length: 100 }), // 업체 담당자
diff --git a/db/schema/generalContract.ts b/db/schema/generalContract.ts
index 6f48581f..7cc6cd6e 100644
--- a/db/schema/generalContract.ts
+++ b/db/schema/generalContract.ts
@@ -37,7 +37,7 @@ export const generalContracts = pgTable('general_contracts', {
// ═══════════════════════════════════════════════════════════════
// 계약 분류 및 상태
// ═══════════════════════════════════════════════════════════════
- status: varchar('status', { length: 50 }).notNull(), // 계약 상태 (Draft, Complete the Contract, Contract Delete 등)
+ status: varchar('status', { length: 50 }).notNull(), // 계약 상태 (Draft, Complete the Contract, Contract Delete, approval request 등)
category: varchar('category', { length: 50 }).notNull(), // 계약구분 (단가계약, 일반계약, 매각계약)
type: varchar('type', { length: 50 }), // 계약종류 (UP, LE, IL, AL 등)
executionMethod: varchar('execution_method', { length: 50 }), // 체결방식 (오프라인, 온라인 등)
diff --git a/lib/approval/handlers-registry.ts b/lib/approval/handlers-registry.ts
index beb6b971..235c9b7b 100644
--- a/lib/approval/handlers-registry.ts
+++ b/lib/approval/handlers-registry.ts
@@ -40,9 +40,10 @@ export async function initializeApprovalHandlers() {
// 벤더 가입 승인 핸들러 등록 (결재 승인 후 실행될 함수 approveVendorWithMDGInternal)
registerActionHandler('vendor_approval', approveVendorWithMDGInternal);
- // 5. 계약 승인 핸들러
- // const { approveContractInternal } = await import('@/lib/contract/handlers');
- // registerActionHandler('contract_approval', approveContractInternal);
+ // 5. 일반계약 승인 핸들러
+ const { approveContractInternal } = await import('@/lib/general-contracts/handlers');
+ // 일반계약 승인 핸들러 등록 (결재 승인 후 실행될 함수 approveContractInternal)
+ registerActionHandler('general_contract_approval', approveContractInternal);
// 6. RFQ 발송 핸들러 (첨부파일이 있는 경우)
const { sendRfqWithApprovalInternal } = await import('@/lib/rfq-last/approval-handlers');
diff --git a/lib/approval/templates/일반계약 결재.html b/lib/approval/templates/일반계약 결재.html
new file mode 100644
index 00000000..99389030
--- /dev/null
+++ b/lib/approval/templates/일반계약 결재.html
@@ -0,0 +1,3024 @@
+<div
+
+ style="
+
+ max-width: 1000px;
+
+ margin: 0 auto;
+
+ font-family: 'Malgun Gothic', 'Segoe UI', sans-serif;
+
+ font-size: 14px;
+
+ color: #333;
+
+ line-height: 1.5;
+
+ border: 1px solid #666; /* 전체적인 테두리 추가 */
+
+ "
+
+>
+
+ <!-- 1. 제목 및 안내 문구 -->
+
+ <table
+
+ style="
+
+ width: 100%;
+
+ border-collapse: collapse;
+
+ margin-bottom: 0px;
+
+ border-bottom: 2px solid #000;
+
+ "
+
+ >
+
+ <thead>
+
+ <tr>
+
+ <th
+
+ style="
+
+ background-color: #fff;
+
+ color: #000;
+
+ padding: 15px;
+
+ text-align: center;
+
+ font-size: 20px;
+
+ font-weight: 700;
+
+ "
+
+ >
+
+ 계약 체결 진행 품의 요청서 (구매성)
+
+ </th>
+
+ </tr>
+
+ <tr>
+
+ <td
+
+ style="
+
+ padding: 5px 15px;
+
+ text-align: right;
+
+ font-size: 12px;
+
+ color: #666;
+
+ border-bottom: 1px solid #ccc;
+
+ "
+
+ >
+
+ *결재 완료 후 계약 체결을 진행할 수 있습니다.
+
+ <br />
+
+ * 본 계약은 계약 갱신이 불필요하여 만료 알림이 설정되지 않았습니다.
+
+ </td>
+
+ </tr>
+
+ </thead>
+
+ </table>
+
+
+
+ <!-- 2. 계약 기본 정보 -->
+
+ <table
+
+ style="
+
+ width: 100%;
+
+ border-collapse: collapse;
+
+ margin-bottom: 15px;
+
+ "
+
+ >
+
+ <thead>
+
+ <tr>
+
+ <th
+
+ colspan="6"
+
+ style="
+
+ background-color: #333;
+
+ color: #fff;
+
+ padding: 10px;
+
+ text-align: left;
+
+ font-size: 15px;
+
+ font-weight: 600;
+
+ border-bottom: 1px solid #666;
+
+ "
+
+ >
+
+ ■ 계약 기본 정보
+
+ </th>
+
+ </tr>
+
+ </thead>
+
+ <tbody>
+
+ <!-- 1행 -->
+
+ <tr>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ width: 15%;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 계약번호
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ width: 20%;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{계약번호}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ width: 15%;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 계약명
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ width: 20%;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{계약명}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ width: 15%;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 계약체결방식
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ width: 15%;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{계약체결방식}}
+
+ </td>
+
+ </tr>
+
+ <!-- 2행 -->
+
+ <tr>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 계약종류
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{계약종류}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 구매담당자
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{구매담당자}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 업체선정방식
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{업체선정방식}}
+
+ </td>
+
+ </tr>
+
+ <!-- 3행 -->
+
+ <tr>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 입찰번호
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{입찰번호}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 입찰명
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{입찰명}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 계약기간
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{계약기간}}
+
+ </td>
+
+ </tr>
+
+ <!-- 4행 -->
+
+ <tr>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 계약일자
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{계약일자}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 매입 부가가치세
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{매입_부가가치세}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 계약 담당자
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{계약_담당자}}
+
+ </td>
+
+ </tr>
+
+ <!-- 5행 -->
+
+ <tr>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 계약부서
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{계약부서}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 계약 금액
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{계약금액}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ SHI 지급조건
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{SHI_지급조건}}
+
+ </td>
+
+ </tr>
+
+ <!-- 6행 -->
+
+ <tr>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ SHI 인도조건
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{SHI_인도조건}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ SHI 인도조건(옵션)
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{SHI_인도조건_옵션}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 선적지
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{선적지}}
+
+ </td>
+
+ </tr>
+
+ <!-- 7행 -->
+
+ <tr>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 하역지
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{하역지}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 사외업체 야드 투입 여부
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{사외업체_야드_투입여부}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 프로젝트
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{프로젝트}}
+
+ </td>
+
+ </tr>
+
+ <!-- 8행 -->
+
+ <tr>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 직종
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{직종}}
+
+ </td>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 재하도 협력사
+
+ </td>
+
+ <td
+
+ colspan="3"
+
+ style="
+
+ padding: 8px 10px;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{재하도_협력사}}
+
+ </td>
+
+ </tr>
+
+ <!-- 9행: 계약 내용 -->
+
+ <tr>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ text-align: center;
+
+ "
+
+ >
+
+ 계약 내용
+
+ </td>
+
+ <td
+
+ colspan="5"
+
+ style="
+
+ padding: 8px 10px;
+
+ height: 80px;
+
+ border: 1px solid #ccc;
+
+ vertical-align: top;
+
+ "
+
+ >
+
+ {{계약내용}}
+
+ </td>
+
+ </tr>
+
+ </tbody>
+
+ </table>
+
+
+
+ <!-- 3. 계약 협력사 및 담당자 정보 -->
+
+ <table
+
+ style="
+
+ width: 100%;
+
+ border-collapse: collapse;
+
+ margin-bottom: 15px;
+
+ "
+
+ >
+
+ <thead>
+
+ <tr>
+
+ <th
+
+ colspan="6"
+
+ style="
+
+ background-color: #333;
+
+ color: #fff;
+
+ padding: 10px;
+
+ text-align: left;
+
+ font-size: 15px;
+
+ font-weight: 600;
+
+ border-bottom: 1px solid #666;
+
+ "
+
+ >
+
+ ■ 계약 협력사 및 담당자 정보
+
+ </th>
+
+ </tr>
+
+ <tr>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 8px 10px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 15%;
+
+ "
+
+ >
+
+ 협력사 코드
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 8px 10px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 25%;
+
+ "
+
+ >
+
+ 협력사명
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 8px 10px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 15%;
+
+ "
+
+ >
+
+ 담당자
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 8px 10px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 15%;
+
+ "
+
+ >
+
+ 전화번호
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 8px 10px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 20%;
+
+ "
+
+ >
+
+ 이메일
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 8px 10px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 10%;
+
+ "
+
+ >
+
+ 비고
+
+ </th>
+
+ </tr>
+
+ </thead>
+
+ <tbody>
+
+ <!-- 데이터 행 (반복 영역) -->
+
+ <tr>
+
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{협력사코드}}</td>
+
+ <td style="padding: 8px 10px; border: 1px solid #ccc;">{{협력사명}}</td>
+
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{협력사_담당자}}</td>
+
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{전화번호}}</td>
+
+ <td style="padding: 8px 10px; border: 1px solid #ccc;">{{이메일}}</td>
+
+ <td style="padding: 8px 10px; border: 1px solid #ccc;">{{비고}}</td>
+
+ </tr>
+
+ <!-- /데이터 행 -->
+
+ </tbody>
+
+ </table>
+
+
+
+ <!-- 4. 계약 대상 자재 정보 -->
+
+ <table
+
+ style="
+
+ width: 100%;
+
+ border-collapse: collapse;
+
+ margin-bottom: 15px;
+
+ "
+
+ >
+
+ <thead>
+
+ <tr>
+
+ <th
+
+ colspan="15"
+
+ style="
+
+ background-color: #333;
+
+ color: #fff;
+
+ padding: 10px;
+
+ text-align: left;
+
+ font-size: 15px;
+
+ font-weight: 600;
+
+ border-bottom: 1px solid #666;
+
+ "
+
+ >
+
+ ■ 계약 대상 자재 정보 (총 {{대상_자재_수}}건 - 결재본문 내 표시 자재는 100건 이하로 제한되어 있습니다)
+
+ </th>
+
+ </tr>
+
+ <tr style="font-size: 12px;">
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 5%;
+
+ "
+
+ >
+
+ 순번
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 6%;
+
+ "
+
+ >
+
+ 플랜트
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 6%;
+
+ "
+
+ >
+
+ 프로젝트
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 8%;
+
+ "
+
+ >
+
+ 자재그룹
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 10%;
+
+ "
+
+ >
+
+ 자재그룹명
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 10%;
+
+ "
+
+ >
+
+ 자재번호
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 15%;
+
+ "
+
+ >
+
+ 자재상세
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 5%;
+
+ "
+
+ >
+
+ 연간단가 여부
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 5%;
+
+ "
+
+ >
+
+ 수량
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 5%;
+
+ "
+
+ >
+
+ 구매단위
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 10%;
+
+ "
+
+ >
+
+ 계약단가
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 5%;
+
+ "
+
+ >
+
+ 수량단위
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 5%;
+
+ "
+
+ >
+
+ 총중량
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 5%;
+
+ "
+
+ >
+
+ 중량단위
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 10%;
+
+ "
+
+ >
+
+ 계약금액
+
+ </th>
+
+ </tr>
+
+ </thead>
+
+ <tbody>
+
+ <!-- 데이터 행 (반복 영역) -->
+
+ <tr style="font-size: 12px;">
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">1</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{플랜트_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{프로젝트_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{자재그룹_1}}</td>
+
+ <td style="padding: 6px 4px; border: 1px solid #ccc;">{{자재그룹명_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{자재번호_1}}</td>
+
+ <td style="padding: 6px 4px; border: 1px solid #ccc;">{{자재상세_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{연간단가여부_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{수량_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{구매단위_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{계약단가_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{수량단위_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{총중량_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{중량단위_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc; font-weight: 600;">{{계약금액_1}}</td>
+
+ </tr>
+
+ <tr style="font-size: 12px;">
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">2</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{플랜트_2}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{프로젝트_2}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{자재그룹_2}}</td>
+
+ <td style="padding: 6px 4px; border: 1px solid #ccc;">{{자재그룹명_2}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{자재번호_2}}</td>
+
+ <td style="padding: 6px 4px; border: 1px solid #ccc;">{{자재상세_2}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{연간단가여부_2}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{수량_2}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{구매단위_2}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{계약단가_2}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{수량단위_2}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{총중량_2}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{중량단위_2}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc; font-weight: 600;">{{계약금액_2}}</td>
+
+ </tr>
+
+ <!-- /데이터 행 -->
+
+ <tr>
+
+ <td colspan="14" style="background-color: #f5f5f5; padding: 8px 10px; text-align: center; font-weight: 700; border: 1px solid #ccc;">총 계약 금액</td>
+
+ <td style="padding: 8px 10px; text-align: right; font-weight: 700; border: 1px solid #ccc;">{{총_계약금액}}</td>
+
+ </tr>
+
+ </tbody>
+
+ </table>
+
+
+
+ <!-- 5. 보증 내용 -->
+
+ <!-- <table
+
+ style="
+
+ width: 100%;
+
+ border-collapse: collapse;
+
+ margin-bottom: 15px;
+
+ "
+
+ >
+
+ <thead>
+
+ <tr>
+
+ <th
+
+ colspan="10"
+
+ style="
+
+ background-color: #333;
+
+ color: #fff;
+
+ padding: 10px;
+
+ text-align: left;
+
+ font-size: 15px;
+
+ font-weight: 600;
+
+ border-bottom: 1px solid #666;
+
+ "
+
+ >
+
+ ■ 보증 내용
+
+ </th>
+
+ </tr>
+
+ <tr style="font-size: 12px;">
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 10%;
+
+ "
+
+ >
+
+ 구분
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 5%;
+
+ "
+
+ >
+
+ 차수
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 15%;
+
+ "
+
+ >
+
+ 증권번호
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 7%;
+
+ "
+
+ >
+
+ 보증금율(%)
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 13%;
+
+ "
+
+ >
+
+ 보증 금액
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 10%;
+
+ "
+
+ >
+
+ 보증 기간
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 10%;
+
+ "
+
+ >
+
+ 보증기간 시작일
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 10%;
+
+ "
+
+ >
+
+ 보증기간 종료일
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 15%;
+
+ "
+
+ >
+
+ 발행 기관
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 5%;
+
+ "
+
+ >
+
+ 발행<br>비고<br>지
+
+ </th>
+
+ </tr>
+
+ </thead>
+
+ <tbody>
+
+
+
+ <tr>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ 계약보증
+
+ </td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{계약보증_차수_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{계약보증_증권번호_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{계약보증_보증금율_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{계약보증_보증금액_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{계약보증_보증기간_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{계약보증_시작일_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{계약보증_종료일_1}}</td>
+
+ <td style="padding: 6px 4px; border: 1px solid #ccc;">{{계약보증_발행기관_1}}</td>
+
+ <td style="padding: 6px 4px; border: 1px solid #ccc;">{{계약보증_비고_1}}</td>
+
+ </tr>
+
+
+
+ <tr>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ 지급보증
+
+ </td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{지급보증_차수_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{지급보증_증권번호_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{지급보증_보증금율_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{지급보증_보증금액_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{지급보증_보증기간_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{지급보증_시작일_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{지급보증_종료일_1}}</td>
+
+ <td style="padding: 6px 4px; border: 1px solid #ccc;">{{지급보증_발행기관_1}}</td>
+
+ <td style="padding: 6px 4px; border: 1px solid #ccc;">{{지급보증_비고_1}}</td>
+
+ </tr>
+
+
+
+ <tr>
+
+ <td
+
+ style="
+
+ background-color: #f5f5f5;
+
+ padding: 8px 10px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ 하자보증
+
+ </td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{하자보증_차수_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{하자보증_증권번호_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{하자보증_보증금율_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{하자보증_보증금액_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{하자보증_보증기간_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{하자보증_시작일_1}}</td>
+
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{하자보증_종료일_1}}</td>
+
+ <td style="padding: 6px 4px; border: 1px solid #ccc;">{{하자보증_발행기관_1}}</td>
+
+ <td style="padding: 6px 4px; border: 1px solid #ccc;">{{하자보증_비고_1}}</td>
+
+ </tr>
+
+ </tbody>
+
+ </table>
+
+-->
+
+
+
+ <!-- 6. 하도급 자율점검 Check List -->
+
+ <table
+
+ style="
+
+ width: 100%;
+
+ border-collapse: collapse;
+
+ margin-bottom: 15px;
+
+ "
+
+ >
+
+ <thead>
+
+ <tr>
+
+ <th
+
+ colspan="12"
+
+ style="
+
+ background-color: #333;
+
+ color: #fff;
+
+ padding: 10px;
+
+ text-align: left;
+
+ font-size: 15px;
+
+ font-weight: 600;
+
+ border-bottom: 1px solid #666;
+
+ "
+
+ >
+
+ ■ 하도급 자율점검 Check List
+
+ </th>
+
+ </tr>
+
+ <!-- 헤더 1행: 계약 시 -->
+
+ <tr style="font-size: 12px;">
+
+ <th
+
+ colspan="12"
+
+ style="
+
+ background-color: #d9d9d9;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ 계약 시 [계약체결 단계]
+
+ </th>
+
+ </tr>
+
+ <!-- 헤더 2행 ~ 4행 (복합 구조) -->
+
+ <tr style="font-size: 12px;">
+
+ <!-- 작업 前 서면발급 -->
+
+ <th
+
+ rowspan="3"
+
+ style="
+
+ background-color: #d9d9d9;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 5%;
+
+ "
+
+ >
+
+ 작업 前<br>서면발급
+
+ </th>
+
+ <!-- 1. 계약서면 발급 -->
+
+ <th
+
+ colspan="6"
+
+ style="
+
+ background-color: #d9d9d9;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 30%;
+
+ "
+
+ >
+
+ 1. 계약서면 발급
+
+ </th>
+
+ <!-- 2. 부당 하도급 대금 결정 행위 -->
+
+ <th
+
+ rowspan="3"
+
+ style="
+
+ background-color: #d9d9d9;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 10%;
+
+ "
+
+ >
+
+ 2. 부당하도급대<br>금 결정 행위<br>(대금결정방법)
+
+ </th>
+
+ <!-- 점검 결과 -->
+
+ <th
+
+ rowspan="3"
+
+ style="
+
+ background-color: #d9d9d9;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 10%;
+
+ "
+
+ >
+
+ 점검결과<br>"준수"<br>"위반"<br>"위반의심"
+
+ </th>
+
+ <!-- 위반/위반의심 시 작성 항목 -->
+
+ <th
+
+ colspan="3"
+
+ rowspan="2"
+
+ style="
+
+ background-color: #d9d9d9;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ width: 45%;
+
+ "
+
+ >
+
+ 위반/위반의심 시 a~c 작성 欄
+
+ </th>
+
+ </tr>
+
+ <tr style="font-size: 12px;">
+
+ <!-- 6대 법정 기재사항 -->
+
+ <th
+
+ colspan="6"
+
+ style="
+
+ background-color: #d9d9d9;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ 6대 법정 기재사항 명기 여부
+
+ </th>
+
+ </tr>
+
+ <tr style="font-size: 12px;">
+
+ <!-- 1~6 항목 -->
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ ①위탁일자<br>/위탁내용
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ ②인도시기<br>/장소
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ ③검사방법<br>/시기
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ ④대금지급<br>방법/기일
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ ⑤원재료지급<br>방법/기일
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ ⑥원재료가격변동<br>에 따른 대금조정 등
+
+ </th>
+
+ <!-- a, b, c -->
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ a. 귀책부서
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ b. 원인
+
+ </th>
+
+ <th
+
+ style="
+
+ background-color: #e8e8e8;
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ font-weight: 600;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ c. 대책
+
+ </th>
+
+ </tr>
+
+ </thead>
+
+ <tbody>
+
+ <!-- 데이터 행 -->
+
+ <tr style="font-size: 12px;">
+
+ <td
+
+ style="
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{작업전_서면발급_체크}}
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{기재사항_1}}
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{기재사항_2}}
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{기재사항_3}}
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{기재사항_4}}
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{기재사항_5}}
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{기재사항_6}}
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{부당대금_결정}}
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{점검결과}}
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{귀책부서}}
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{원인}}
+
+ </td>
+
+ <td
+
+ style="
+
+ padding: 6px 4px;
+
+ text-align: center;
+
+ border: 1px solid #ccc;
+
+ "
+
+ >
+
+ {{대책}}
+
+ </td>
+
+ </tr>
+
+ </tbody>
+
+ </table>
+
+</div>
+
diff --git a/lib/bidding/actions.ts b/lib/bidding/actions.ts
index 6bedbab5..64dc3aa8 100644
--- a/lib/bidding/actions.ts
+++ b/lib/bidding/actions.ts
@@ -20,7 +20,7 @@ import { createPurchaseOrder } from "@/lib/soap/ecc/send/create-po-bidding"
import { getCurrentSAPDate } from "@/lib/soap/utils"
import { generateContractNumber } from "@/lib/general-contracts/service"
import { saveFile } from "@/lib/file-stroage"
-
+import { checkAndSaveChemicalSubstancesForBidding } from "./service"
// TO Contract
export async function transmitToContract(biddingId: number, userId: number) {
try {
@@ -738,6 +738,26 @@ export async function openBiddingAction(biddingId: number) {
})
.where(eq(biddings.id, biddingId))
+ // 4. 화학물질 조회 실행 (비동기로 실행해서 개찰 성능에 영향 없도록)
+ try {
+ // 개찰 트랜잭션이 완료된 후 화학물질 조회 시작
+ setImmediate(async () => {
+ try {
+ const result = await checkAndSaveChemicalSubstancesForBidding(biddingId)
+ if (result.success) {
+ console.log(`입찰 ${biddingId} 화학물질 조회 완료: ${result.results.filter(r => r.success).length}/${result.results.length}개 업체`)
+ } else {
+ console.error(`입찰 ${biddingId} 화학물질 조회 실패:`, result.message)
+ }
+ } catch (error) {
+ console.error(`입찰 ${biddingId} 화학물질 조회 중 오류:`, error)
+ }
+ })
+ } catch (error) {
+ // 화학물질 조회 실패해도 개찰은 성공으로 처리
+ console.error('화학물질 조회 시작 실패:', error)
+ }
+
return { success: true, message: isDeadlinePassed ? '개찰이 완료되었습니다.' : '조기개찰이 완료되었습니다.' }
})
diff --git a/lib/bidding/detail/table/price-adjustment-dialog.tsx b/lib/bidding/detail/table/price-adjustment-dialog.tsx
index 14bbd843..96a3af0c 100644
--- a/lib/bidding/detail/table/price-adjustment-dialog.tsx
+++ b/lib/bidding/detail/table/price-adjustment-dialog.tsx
@@ -94,13 +94,13 @@ export function PriceAdjustmentDialog({
<DialogHeader>
<DialogTitle>연동제 적용 설정</DialogTitle>
<DialogDescription>
- <span className="font-semibold text-primary">{vendor.vendorName}</span> 업체의 연동제 적용 여부 및 화학물질 정보를 설정합니다.
+ <span className="font-semibold text-primary">{vendor.vendorName}</span> 업체의 연동제 적용 여부를 설정합니다.
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* 업체가 제출한 연동제 요청 여부 (읽기 전용) */}
- <div className="flex flex-row items-center justify-between rounded-lg border p-4 bg-muted/50">
+ {/* <div className="flex flex-row items-center justify-between rounded-lg border p-4 bg-muted/50">
<div className="space-y-0.5">
<Label className="text-base">업체 연동제 요청</Label>
<p className="text-sm text-muted-foreground">
@@ -110,7 +110,7 @@ export function PriceAdjustmentDialog({
<span className={`font-medium ${vendor.isPriceAdjustmentApplicableQuestion ? 'text-green-600' : 'text-gray-500'}`}>
{vendor.isPriceAdjustmentApplicableQuestion === null ? '미정' : vendor.isPriceAdjustmentApplicableQuestion ? '예' : '아니오'}
</span>
- </div>
+ </div> */}
{/* SHI 연동제 적용여부 */}
<div className="flex flex-row items-center justify-between rounded-lg border p-4">
@@ -147,7 +147,7 @@ export function PriceAdjustmentDialog({
</div>
{/* 화학물질 여부 */}
- <div className="flex flex-row items-center justify-between rounded-lg border p-4">
+ {/* <div className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label className="text-base">화학물질 해당여부</Label>
<p className="text-sm text-muted-foreground">
@@ -166,7 +166,7 @@ export function PriceAdjustmentDialog({
해당
</span>
</div>
- </div>
+ </div> */}
</div>
<DialogFooter>
diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts
index d45e9286..71ee01ab 100644
--- a/lib/bidding/service.ts
+++ b/lib/bidding/service.ts
@@ -44,6 +44,7 @@ import { saveFile, saveBuffer } from '../file-stroage'
import { decryptBufferWithServerAction } from '@/components/drm/drmUtils'
import { getVendorPricesForBidding } from './detail/service'
import { getPrItemsForBidding } from './pre-quote/service'
+import { checkChemicalSubstance, checkMultipleChemicalSubstances, type ChemicalSubstanceResult } from '@/lib/soap/ecc/send/chemical-substance-check'
// 사용자 이메일로 사용자 코드 조회
@@ -3987,3 +3988,324 @@ export async function getBiddingSelectionItemsAndPrices(biddingId: number) {
throw error
}
}
+
+// ========================================
+// 화학물질 조회 및 저장 관련 함수들
+// ========================================
+
+/**
+ * 입찰 참여업체의 화학물질 정보를 조회하고 DB에 저장
+ */
+// export async function checkAndSaveChemicalSubstanceForBiddingCompany(biddingCompanyId: number) {
+// try {
+// // 입찰 참여업체 정보 조회 (벤더 정보 포함)
+// const biddingCompanyInfo = await db
+// .select({
+// id: biddingCompanies.id,
+// biddingId: biddingCompanies.biddingId,
+// companyId: biddingCompanies.companyId,
+// hasChemicalSubstance: biddingCompanies.hasChemicalSubstance,
+// vendors: {
+// vendorCode: vendors.vendorCode
+// }
+// })
+// .from(biddingCompanies)
+// .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+// .where(eq(biddingCompanies.id, biddingCompanyId))
+// .limit(1)
+
+// if (!biddingCompanyInfo[0]) {
+// throw new Error(`입찰 참여업체를 찾을 수 없습니다: ${biddingCompanyId}`)
+// }
+
+// const companyInfo = biddingCompanyInfo[0]
+
+// // 이미 화학물질 검사가 완료된 경우 스킵
+// if (companyInfo.hasChemicalSubstance !== null && companyInfo.hasChemicalSubstance !== undefined) {
+// console.log(`이미 화학물질 검사가 완료된 입찰 참여업체: ${biddingCompanyId}`)
+// return {
+// success: true,
+// message: '이미 화학물질 검사가 완료되었습니다.',
+// hasChemicalSubstance: companyInfo.hasChemicalSubstance
+// }
+// }
+
+// // 벤더 코드가 없는 경우 스킵
+// if (!companyInfo.vendors?.vendorCode) {
+// console.log(`벤더 코드가 없는 입찰 참여업체: ${biddingCompanyId}`)
+// return {
+// success: false,
+// message: '벤더 코드가 없습니다.'
+// }
+// }
+
+// // 입찰의 PR 아이템들 조회 (자재번호 있는 것만)
+// const prItems = await db
+// .select({
+// id: prItemsForBidding.id,
+// materialNumber: prItemsForBidding.materialNumber
+// })
+// .from(prItemsForBidding)
+// .where(and(
+// eq(prItemsForBidding.biddingId, companyInfo.biddingId),
+// isNotNull(prItemsForBidding.materialNumber),
+// sql`${prItemsForBidding.materialNumber} != ''`
+// ))
+
+// if (prItems.length === 0) {
+// console.log(`자재번호가 있는 PR 아이템이 없는 입찰: ${companyInfo.biddingId}`)
+// return {
+// success: false,
+// message: '조회할 자재가 없습니다.'
+// }
+// }
+
+// // 각 자재에 대해 화학물질 조회
+// let hasAnyChemicalSubstance = false
+// const results: Array<{ materialNumber: string; hasChemicalSubstance: boolean; message: string }> = []
+
+// for (const prItem of prItems) {
+// try {
+// const checkResult = await checkChemicalSubstance({
+// bukrs: 'H100', // 회사코드는 H100 고정
+// werks: 'PM11', // WERKS는 PM11 고정
+// lifnr: companyInfo.vendors.vendorCode,
+// matnr: prItem.materialNumber!
+// })
+
+// if (checkResult.success) {
+// const itemHasChemical = checkResult.hasChemicalSubstance || false
+// hasAnyChemicalSubstance = hasAnyChemicalSubstance || itemHasChemical
+
+// results.push({
+// materialNumber: prItem.materialNumber!,
+// hasChemicalSubstance: itemHasChemical,
+// message: checkResult.message || '조회 성공'
+// })
+// } else {
+// results.push({
+// materialNumber: prItem.materialNumber!,
+// hasChemicalSubstance: false,
+// message: checkResult.message || '조회 실패'
+// })
+// }
+
+// // API 호출 간 지연
+// await new Promise(resolve => setTimeout(resolve, 500))
+
+// } catch (error) {
+// results.push({
+// materialNumber: prItem.materialNumber!,
+// hasChemicalSubstance: false,
+// message: error instanceof Error ? error.message : 'Unknown error'
+// })
+// }
+// }
+
+// // 하나라도 Y(Y=true)이면 true, 모두 N(false)이면 false
+// const finalHasChemicalSubstance = hasAnyChemicalSubstance
+
+// // DB에 결과 저장
+// await db
+// .update(biddingCompanies)
+// .set({
+// hasChemicalSubstance: finalHasChemicalSubstance,
+// updatedAt: new Date()
+// })
+// .where(eq(biddingCompanies.id, biddingCompanyId))
+
+// console.log(`화학물질 정보 저장 완료: 입찰 참여업체 ${biddingCompanyId}, 화학물질 ${finalHasChemicalSubstance ? '있음' : '없음'} (${results.filter(r => r.hasChemicalSubstance).length}/${results.length})`)
+
+// return {
+// success: true,
+// message: `화학물질 조회 및 저장이 완료되었습니다. (${results.filter(r => r.hasChemicalSubstance).length}/${results.length}개 자재에 화학물질 있음)`,
+// hasChemicalSubstance: finalHasChemicalSubstance,
+// results
+// }
+
+// } catch (error) {
+// console.error(`화학물질 조회 실패 (입찰 참여업체 ${biddingCompanyId}):`, error)
+// return {
+// success: false,
+// message: error instanceof Error ? error.message : 'Unknown error',
+// hasChemicalSubstance: null,
+// results: []
+// }
+// }
+// }
+
+/**
+ * 입찰의 모든 참여업체에 대한 화학물질 정보를 일괄 조회하고 저장
+ */
+export async function checkAndSaveChemicalSubstancesForBidding(biddingId: number) {
+ try {
+ // 입찰의 모든 참여업체 조회 (벤더 코드 있는 것만)
+ const biddingCompaniesList = await db
+ .select({
+ id: biddingCompanies.id,
+ companyId: biddingCompanies.companyId,
+ hasChemicalSubstance: biddingCompanies.hasChemicalSubstance,
+ vendors: {
+ vendorCode: vendors.vendorCode,
+ vendorName: vendors.vendorName
+ }
+ })
+ .from(biddingCompanies)
+ .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .where(and(
+ eq(biddingCompanies.biddingId, biddingId),
+ isNotNull(vendors.vendorCode),
+ sql`${vendors.vendorCode} != ''`
+ ))
+
+ if (biddingCompaniesList.length === 0) {
+ return {
+ success: true,
+ message: '벤더 코드가 있는 참여업체가 없습니다.',
+ results: []
+ }
+ }
+
+ // 입찰의 PR 아이템들 조회 (자재번호 있는 것만)
+ const prItems = await db
+ .select({
+ materialNumber: prItemsForBidding.materialNumber
+ })
+ .from(prItemsForBidding)
+ .where(and(
+ eq(prItemsForBidding.biddingId, biddingId),
+ isNotNull(prItemsForBidding.materialNumber),
+ sql`${prItemsForBidding.materialNumber} != ''`
+ ))
+
+ if (prItems.length === 0) {
+ return {
+ success: false,
+ message: '조회할 자재가 없습니다.',
+ results: []
+ }
+ }
+
+ const materialNumbers = prItems.map(item => item.materialNumber!).filter(Boolean)
+
+ // 각 참여업체에 대해 화학물질 조회
+ const results: Array<{
+ biddingCompanyId: number;
+ vendorCode: string;
+ vendorName: string;
+ success: boolean;
+ hasChemicalSubstance?: boolean;
+ message: string;
+ materialResults?: Array<{ materialNumber: string; hasChemicalSubstance: boolean; message: string }>;
+ }> = []
+
+ for (const biddingCompany of biddingCompaniesList) {
+ try {
+ // 이미 검사가 완료된 경우 스킵
+ if (biddingCompany.hasChemicalSubstance !== null && biddingCompany.hasChemicalSubstance !== undefined) {
+ results.push({
+ biddingCompanyId: biddingCompany.id,
+ vendorCode: biddingCompany.vendors!.vendorCode!,
+ vendorName: biddingCompany.vendors!.vendorName || '',
+ success: true,
+ hasChemicalSubstance: biddingCompany.hasChemicalSubstance,
+ message: '이미 검사가 완료되었습니다.'
+ })
+ continue
+ }
+
+ // 각 자재에 대해 화학물질 조회
+ let hasAnyChemicalSubstance = false
+ const materialResults: Array<{ materialNumber: string; hasChemicalSubstance: boolean; message: string }> = []
+
+ for (const materialNumber of materialNumbers) {
+ try {
+ const checkResult = await checkChemicalSubstance({
+ bukrs: 'H100', // 회사코드는 H100 고정
+ werks: 'PM11', // WERKS는 PM11 고정
+ lifnr: biddingCompany.vendors!.vendorCode!,
+ matnr: materialNumber
+ })
+
+ if (checkResult.success) {
+ const itemHasChemical = checkResult.hasChemicalSubstance || false
+ hasAnyChemicalSubstance = hasAnyChemicalSubstance || itemHasChemical
+
+ materialResults.push({
+ materialNumber,
+ hasChemicalSubstance: itemHasChemical,
+ message: checkResult.message || '조회 성공'
+ })
+ } else {
+ materialResults.push({
+ materialNumber,
+ hasChemicalSubstance: false,
+ message: checkResult.message || '조회 실패'
+ })
+ }
+
+ // API 호출 간 지연
+ await new Promise(resolve => setTimeout(resolve, 500))
+
+ } catch (error) {
+ materialResults.push({
+ materialNumber,
+ hasChemicalSubstance: false,
+ message: error instanceof Error ? error.message : 'Unknown error'
+ })
+ }
+ }
+
+ // 하나라도 Y이면 true, 모두 N이면 false
+ const finalHasChemicalSubstance = hasAnyChemicalSubstance
+
+ // DB에 결과 저장
+ await db
+ .update(biddingCompanies)
+ .set({
+ hasChemicalSubstance: finalHasChemicalSubstance,
+ updatedAt: new Date()
+ })
+ .where(eq(biddingCompanies.id, biddingCompany.id))
+
+ results.push({
+ biddingCompanyId: biddingCompany.id,
+ vendorCode: biddingCompany.vendors!.vendorCode!,
+ vendorName: biddingCompany.vendors!.vendorName || '',
+ success: true,
+ hasChemicalSubstance: finalHasChemicalSubstance,
+ message: `조회 완료 (${materialResults.filter(r => r.hasChemicalSubstance).length}/${materialResults.length}개 자재에 화학물질 있음)`,
+ materialResults
+ })
+
+ } catch (error) {
+ results.push({
+ biddingCompanyId: biddingCompany.id,
+ vendorCode: biddingCompany.vendors!.vendorCode!,
+ vendorName: biddingCompany.vendors!.vendorName || '',
+ success: false,
+ message: error instanceof Error ? error.message : 'Unknown error'
+ })
+ }
+ }
+
+ const successCount = results.filter(r => r.success).length
+ const totalCount = results.length
+
+ console.log(`입찰 ${biddingId} 화학물질 일괄 조회 완료: ${successCount}/${totalCount} 성공`)
+
+ return {
+ success: true,
+ message: `화학물질 일괄 조회 완료: ${successCount}/${totalCount} 성공`,
+ results
+ }
+
+ } catch (error) {
+ console.error(`입찰 화학물질 일괄 조회 실패 (${biddingId}):`, error)
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : 'Unknown error',
+ results: []
+ }
+ }
+}
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' 포함)
diff --git a/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts b/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts
index 00873d83..9bf61452 100644
--- a/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts
+++ b/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts
@@ -256,11 +256,14 @@ export async function mapECCBiddingHeaderToBidding(
// prNumber: 대표 PR의 BANFN 또는 타겟 PR의 ZREQ_FN 값
prNumber = representativeItem?.BANFN || targetItem.ZREQ_FN || null;
}
+
+ // 원입찰번호(originalBiddingNumber)에 생성된 biddingNumber에서 '-'로 split해서 앞부분을 사용
+ const originalBiddingNumber = biddingNumber ? biddingNumber.split('-')[0] : (eccHeader.ANFNR || null);
// 매핑
const mappedData: BiddingData = {
biddingNumber, // 생성된 Bidding 코드
- originalBiddingNumber: eccHeader.ANFNR || null, // 원입찰번호
+ originalBiddingNumber, // 원입찰번호에 생성된 biddingnumber split 결과 사용
revision: 0, // 기본 리비전 0 (I/F 해서 가져온 건 보낸 적 없으므로 0 고정)
projectName, // 타겟 PR Item의 PSPID로 찾은 프로젝트 이름
itemName, // 타겟 PR Item 정보로 조회한 자재명/내역
@@ -279,7 +282,6 @@ export async function mapECCBiddingHeaderToBidding(
biddingRegistrationDate: new Date().toISOString(), // 입찰등록일 I/F 시점 등록(1120 이시원 프로 요청)
submissionStartDate: null,
submissionEndDate: null,
- evaluationDate: null,
// 사양설명회
hasSpecificationMeeting: false, // 기본값 처리하고, 입찰관리상세에서 사용자가 관리
diff --git a/lib/soap/ecc/send/chemical-substance-check.ts b/lib/soap/ecc/send/chemical-substance-check.ts
new file mode 100644
index 00000000..b5c4cc25
--- /dev/null
+++ b/lib/soap/ecc/send/chemical-substance-check.ts
@@ -0,0 +1,449 @@
+'use server'
+
+import { sendSoapXml } from "@/lib/soap/sender";
+import type { SoapSendConfig, SoapLogInfo, SoapSendResult } from "@/lib/soap/types";
+
+// ECC 화학물질 조회 엔드포인트 (WSDL에 명시된 인터페이스 사용)
+const ECC_CHEMICAL_SUBSTANCE_ENDPOINT = "http://shii8dvddb01.hec.serp.shi.samsung.net:50000/sap/xi/engine?type=entry&version=3.0&Sender.Service=P2038_Q&Interface=http%3A%2F%2Fshi.samsung.co.kr%2FP2_MM%2FMMM%5E[P2MM_INTERFACE_NAME]";
+
+// 화학물질 조회 요청 데이터 타입
+export interface ChemicalSubstanceCheckRequest {
+ T_LIST: Array<{
+ BUKRS: string; // Company Code (M, CHAR 4)
+ WERKS: string; // Plant (M, CHAR 4)
+ LIFNR: string; // Vendor's account number (M, CHAR 10)
+ MATNR: string; // Material Number (M, CHAR 18)
+ }>;
+}
+
+// 화학물질 조회 응답 데이터 타입
+export interface ChemicalSubstanceCheckResponse {
+ T_LIST: Array<{
+ QINSPST: string; // Y/N (화학물질 여부)
+ SGTXT: string; // Text (상세 메시지)
+ }>;
+}
+
+// 화학물질 조회 결과 타입 (DB 저장용)
+export interface ChemicalSubstanceResult {
+ bukrs: string;
+ werks: string;
+ lifnr: string;
+ matnr: string;
+ hasChemicalSubstance: boolean;
+ message: string;
+ checkedAt: Date;
+}
+
+// SOAP Body Content 생성 함수
+function createChemicalSubstanceCheckSoapBodyContent(data: ChemicalSubstanceCheckRequest): Record<string, unknown> {
+ return {
+ 'p1:MT_[INTERFACE_NAME]_S': { // 실제 인터페이스명으로 변경 필요
+ 'T_LIST': data.T_LIST
+ }
+ };
+}
+
+// 화학물질 조회 데이터 검증 함수
+function validateChemicalSubstanceCheckData(data: ChemicalSubstanceCheckRequest): { isValid: boolean; errors: string[] } {
+ const errors: string[] = [];
+
+ // T_LIST 배열 검증
+ if (!data.T_LIST || !Array.isArray(data.T_LIST) || data.T_LIST.length === 0) {
+ errors.push('T_LIST는 필수이며 최소 1개 이상의 데이터가 있어야 합니다.');
+ } else {
+ data.T_LIST.forEach((item, index) => {
+ // 필수 필드 검증
+ if (!item.BUKRS || typeof item.BUKRS !== 'string' || item.BUKRS.trim() === '') {
+ errors.push(`T_LIST[${index}].BUKRS은 필수입니다.`);
+ } else if (item.BUKRS.length > 4) {
+ errors.push(`T_LIST[${index}].BUKRS은 4자를 초과할 수 없습니다.`);
+ }
+
+ if (!item.WERKS || typeof item.WERKS !== 'string' || item.WERKS.trim() === '') {
+ errors.push(`T_LIST[${index}].WERKS는 필수입니다.`);
+ } else if (item.WERKS.length > 4) {
+ errors.push(`T_LIST[${index}].WERKS는 4자를 초과할 수 없습니다.`);
+ }
+
+ if (!item.LIFNR || typeof item.LIFNR !== 'string' || item.LIFNR.trim() === '') {
+ errors.push(`T_LIST[${index}].LIFNR은 필수입니다.`);
+ } else if (item.LIFNR.length > 10) {
+ errors.push(`T_LIST[${index}].LIFNR은 10자를 초과할 수 없습니다.`);
+ }
+
+ if (!item.MATNR || typeof item.MATNR !== 'string' || item.MATNR.trim() === '') {
+ errors.push(`T_LIST[${index}].MATNR은 필수입니다.`);
+ } else if (item.MATNR.length > 18) {
+ errors.push(`T_LIST[${index}].MATNR은 18자를 초과할 수 없습니다.`);
+ }
+ });
+ }
+
+ return {
+ isValid: errors.length === 0,
+ errors
+ };
+}
+
+// ECC로 화학물질 조회 SOAP XML 전송하는 함수
+async function sendChemicalSubstanceCheckToECC(data: ChemicalSubstanceCheckRequest): Promise<SoapSendResult> {
+ try {
+ // 데이터 검증
+ const validation = validateChemicalSubstanceCheckData(data);
+ if (!validation.isValid) {
+ return {
+ success: false,
+ message: `데이터 검증 실패: ${validation.errors.join(', ')}`
+ };
+ }
+
+ // SOAP Body Content 생성
+ const soapBodyContent = createChemicalSubstanceCheckSoapBodyContent(data);
+
+ // SOAP 전송 설정
+ const config: SoapSendConfig = {
+ endpoint: ECC_CHEMICAL_SUBSTANCE_ENDPOINT,
+ envelope: soapBodyContent,
+ soapAction: 'http://sap.com/xi/WebService/soap1.1',
+ timeout: 30000, // 화학물질 조회는 30초 타임아웃
+ retryCount: 3,
+ retryDelay: 1000,
+ namespace: 'http://shi.samsung.co.kr/P2_MM/MMM', // ECC MM 모듈 네임스페이스
+ prefix: 'p1' // WSDL에서 사용하는 p1 접두사
+ };
+
+ // 로그 정보
+ const logInfo: SoapLogInfo = {
+ direction: 'OUTBOUND',
+ system: 'S-ERP ECC',
+ interface: 'IF_ECC_EVCP_CHEMICAL_SUBSTANCE_CHECK'
+ };
+
+ const materials = data.T_LIST.map(item => `${item.BUKRS}/${item.WERKS}/${item.LIFNR}/${item.MATNR}`).join(', ');
+ console.log(`📤 화학물질 조회 요청 전송 시작 - Materials: ${materials}`);
+ console.log(`🔍 조회 대상 물질 ${data.T_LIST.length}개`);
+
+ // SOAP XML 전송
+ const result = await sendSoapXml(config, logInfo);
+
+ if (result.success) {
+ console.log(`✅ 화학물질 조회 요청 전송 성공 - Materials: ${materials}`);
+ } else {
+ console.error(`❌ 화학물질 조회 요청 전송 실패 - Materials: ${materials}, 오류: ${result.message}`);
+ }
+
+ return result;
+
+ } catch (error) {
+ console.error('❌ 화학물질 조회 전송 중 오류 발생:', error);
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : 'Unknown error'
+ };
+ }
+}
+
+// ========================================
+// 메인 화학물질 조회 서버 액션 함수들
+// ========================================
+
+// 단일 화학물질 조회 요청 처리
+export async function checkChemicalSubstance(params: {
+ bukrs: string;
+ werks: string;
+ lifnr: string;
+ matnr: string;
+}): Promise<{
+ success: boolean;
+ message: string;
+ hasChemicalSubstance?: boolean;
+ responseData?: string;
+ statusCode?: number;
+ headers?: Record<string, string>;
+ endpoint?: string;
+ requestXml?: string;
+ material?: string;
+}> {
+ try {
+ console.log(`🚀 화학물질 조회 요청 시작 - Material: ${params.bukrs}/${params.werks}/${params.lifnr}/${params.matnr}`);
+
+ const requestData: ChemicalSubstanceCheckRequest = {
+ T_LIST: [{
+ BUKRS: params.bukrs,
+ WERKS: params.werks,
+ LIFNR: params.lifnr,
+ MATNR: params.matnr
+ }]
+ };
+
+ const result = await sendChemicalSubstanceCheckToECC(requestData);
+
+ let hasChemicalSubstance: boolean | undefined;
+ let message = result.message;
+
+ if (result.success && result.responseText) {
+ try {
+ // 응답 파싱 로직 (실제 응답 구조에 따라 조정 필요)
+ // QINSPST = 'Y' 이면 화학물질 있음, 'N'이면 없음
+ const responseData = JSON.parse(result.responseText);
+ if (responseData?.T_LIST?.[0]) {
+ const item = responseData.T_LIST[0];
+ hasChemicalSubstance = item.QINSPST === 'Y';
+ message = item.SGTXT || result.message;
+ }
+ } catch (parseError) {
+ console.warn('응답 데이터 파싱 실패:', parseError);
+ }
+ }
+
+ return {
+ success: result.success,
+ message,
+ hasChemicalSubstance,
+ responseData: result.responseText,
+ statusCode: result.statusCode,
+ headers: result.headers,
+ endpoint: result.endpoint,
+ requestXml: result.requestXml,
+ material: `${params.bukrs}/${params.werks}/${params.lifnr}/${params.matnr}`
+ };
+
+ } catch (error) {
+ console.error('❌ 화학물질 조회 요청 처리 실패:', error);
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : 'Unknown error'
+ };
+ }
+}
+
+// 여러 물질 배치 화학물질 조회 요청 처리
+export async function checkMultipleChemicalSubstances(items: Array<{
+ bukrs: string;
+ werks: string;
+ lifnr: string;
+ matnr: string;
+}>): Promise<{
+ success: boolean;
+ message: string;
+ results?: Array<{
+ material: string;
+ hasChemicalSubstance?: boolean;
+ success: boolean;
+ error?: string;
+ message?: string;
+ }>;
+}> {
+ try {
+ console.log(`🚀 배치 화학물질 조회 요청 시작: ${items.length}개`);
+
+ const requestData: ChemicalSubstanceCheckRequest = {
+ T_LIST: items.map(item => ({
+ BUKRS: item.bukrs,
+ WERKS: item.werks,
+ LIFNR: item.lifnr,
+ MATNR: item.matnr
+ }))
+ };
+
+ const result = await sendChemicalSubstanceCheckToECC(requestData);
+
+ let results: Array<{
+ material: string;
+ hasChemicalSubstance?: boolean;
+ success: boolean;
+ error?: string;
+ message?: string;
+ }> | undefined;
+
+ if (result.success && result.responseText) {
+ try {
+ const responseData = JSON.parse(result.responseText);
+ if (responseData?.T_LIST && Array.isArray(responseData.T_LIST)) {
+ results = responseData.T_LIST.map((item: any, index: number) => {
+ const originalItem = items[index];
+ const material = `${originalItem.bukrs}/${originalItem.werks}/${originalItem.lifnr}/${originalItem.matnr}`;
+
+ return {
+ material,
+ hasChemicalSubstance: item.QINSPST === 'Y',
+ success: true,
+ message: item.SGTXT
+ };
+ });
+ }
+ } catch (parseError) {
+ console.warn('배치 응답 데이터 파싱 실패:', parseError);
+ // 파싱 실패시 전체 실패로 처리
+ results = items.map(item => ({
+ material: `${item.bukrs}/${item.werks}/${item.lifnr}/${item.matnr}`,
+ success: false,
+ error: '응답 데이터 파싱 실패'
+ }));
+ }
+ } else {
+ // 전송 실패시 모든 항목 실패로 처리
+ results = items.map(item => ({
+ material: `${item.bukrs}/${item.werks}/${item.lifnr}/${item.matnr}`,
+ success: false,
+ error: result.message
+ }));
+ }
+
+ const successCount = results?.filter(r => r.success).length || 0;
+ const failCount = (results?.length || 0) - successCount;
+
+ console.log(`🎉 배치 화학물질 조회 완료: 성공 ${successCount}개, 실패 ${failCount}개`);
+
+ return {
+ success: result.success,
+ message: result.success
+ ? `배치 화학물질 조회 성공: ${successCount}개`
+ : `배치 화학물질 조회 실패: ${result.message}`,
+ results
+ };
+
+ } catch (error) {
+ console.error('❌ 배치 화학물질 조회 중 전체 오류 발생:', error);
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : 'Unknown error'
+ };
+ }
+}
+
+// 개별 처리 방식의 배치 화학물질 조회 (각각 따로 전송)
+export async function checkMultipleChemicalSubstancesIndividually(items: Array<{
+ bukrs: string;
+ werks: string;
+ lifnr: string;
+ matnr: string;
+}>): Promise<{
+ success: boolean;
+ message: string;
+ results?: Array<{
+ material: string;
+ hasChemicalSubstance?: boolean;
+ success: boolean;
+ error?: string;
+ message?: string;
+ }>;
+}> {
+ try {
+ console.log(`🚀 개별 화학물질 조회 요청 시작: ${items.length}개`);
+
+ const results: Array<{
+ material: string;
+ hasChemicalSubstance?: boolean;
+ success: boolean;
+ error?: string;
+ message?: string;
+ }> = [];
+
+ for (const item of items) {
+ try {
+ const material = `${item.bukrs}/${item.werks}/${item.lifnr}/${item.matnr}`;
+ console.log(`📤 화학물질 조회 처리 중: ${material}`);
+
+ const checkResult = await checkChemicalSubstance(item);
+
+ results.push({
+ material,
+ hasChemicalSubstance: checkResult.hasChemicalSubstance,
+ success: checkResult.success,
+ error: checkResult.success ? undefined : checkResult.message,
+ message: checkResult.message
+ });
+
+ // 개별 처리간 지연 (시스템 부하 방지)
+ if (items.length > 1) {
+ await new Promise(resolve => setTimeout(resolve, 500));
+ }
+
+ } catch (error) {
+ const material = `${item.bukrs}/${item.werks}/${item.lifnr}/${item.matnr}`;
+ console.error(`❌ 화학물질 조회 처리 실패: ${material}`, error);
+ results.push({
+ material,
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error'
+ });
+ }
+ }
+
+ const successCount = results.filter(r => r.success).length;
+ const failCount = results.length - successCount;
+
+ console.log(`🎉 개별 화학물질 조회 완료: 성공 ${successCount}개, 실패 ${failCount}개`);
+
+ return {
+ success: failCount === 0,
+ message: `개별 화학물질 조회 완료: 성공 ${successCount}개, 실패 ${failCount}개`,
+ results
+ };
+
+ } catch (error) {
+ console.error('❌ 개별 화학물질 조회 중 전체 오류 발생:', error);
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : 'Unknown error'
+ };
+ }
+}
+
+// 테스트용 화학물질 조회 함수 (샘플 데이터 포함)
+export async function checkTestChemicalSubstance(): Promise<{
+ success: boolean;
+ message: string;
+ hasChemicalSubstance?: boolean;
+ responseData?: string;
+ testData?: ChemicalSubstanceCheckRequest;
+}> {
+ try {
+ console.log('🧪 테스트용 화학물질 조회 시작');
+
+ // 테스트용 샘플 데이터 생성
+ const testData: ChemicalSubstanceCheckRequest = {
+ T_LIST: [{
+ BUKRS: '1000',
+ WERKS: '1000',
+ LIFNR: 'TEST_VENDOR',
+ MATNR: 'TEST_MATERIAL'
+ }]
+ };
+
+ const result = await sendChemicalSubstanceCheckToECC(testData);
+
+ let hasChemicalSubstance: boolean | undefined;
+ let message = result.message;
+
+ if (result.success && result.responseText) {
+ try {
+ const responseData = JSON.parse(result.responseText);
+ if (responseData?.T_LIST?.[0]) {
+ const item = responseData.T_LIST[0];
+ hasChemicalSubstance = item.QINSPST === 'Y';
+ message = item.SGTXT || result.message;
+ }
+ } catch (parseError) {
+ console.warn('테스트 응답 데이터 파싱 실패:', parseError);
+ }
+ }
+
+ return {
+ success: result.success,
+ message,
+ hasChemicalSubstance,
+ responseData: result.responseText,
+ testData
+ };
+
+ } catch (error) {
+ console.error('❌ 테스트 화학물질 조회 실패:', error);
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : 'Unknown error'
+ };
+ }
+}