diff options
| -rw-r--r-- | app/api/general-contracts/upload-pdf/route.ts | 73 | ||||
| -rw-r--r-- | db/schema/bidding.ts | 12 | ||||
| -rw-r--r-- | db/schema/generalContract.ts | 2 | ||||
| -rw-r--r-- | lib/approval/handlers-registry.ts | 7 | ||||
| -rw-r--r-- | lib/approval/templates/일반계약 결재.html | 3024 | ||||
| -rw-r--r-- | lib/bidding/actions.ts | 22 | ||||
| -rw-r--r-- | lib/bidding/detail/table/price-adjustment-dialog.tsx | 10 | ||||
| -rw-r--r-- | lib/bidding/service.ts | 322 | ||||
| -rw-r--r-- | lib/general-contracts/approval-actions.ts | 136 | ||||
| -rw-r--r-- | lib/general-contracts/approval-template-variables.ts | 369 | ||||
| -rw-r--r-- | lib/general-contracts/detail/general-contract-approval-request-dialog.tsx | 163 | ||||
| -rw-r--r-- | lib/general-contracts/handlers.ts | 157 | ||||
| -rw-r--r-- | lib/general-contracts/service.ts | 2 | ||||
| -rw-r--r-- | lib/soap/ecc/mapper/bidding-and-pr-mapper.ts | 6 | ||||
| -rw-r--r-- | lib/soap/ecc/send/chemical-substance-check.ts | 449 |
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' + }; + } +} |
