diff --git a/app/Http/Controllers/Business/CourierAgreementSignatures.php b/app/Http/Controllers/Business/CourierAgreementSignatures.php new file mode 100644 index 00000000..09fe0773 --- /dev/null +++ b/app/Http/Controllers/Business/CourierAgreementSignatures.php @@ -0,0 +1,165 @@ +ensureCanManage(); + abort_unless(EsignClient::configured(), 422, 'E 签宝沙箱尚未配置 App ID 或 AppSecret。'); + abort_unless(Schema::hasTable('business_courier_agreement_signatures'), 503, '协议签署数据表尚未完成迁移,请先执行数据库迁移。'); + + $data = $request->validate($this->rules()); + $type = $data['document_type']; + if ($type === 'authorization' && count($data['authorization_items'] ?? []) === 0) { + throw ValidationException::withMessages(['authorization_items' => '授权委托书至少选择一项授权事项。']); + } + $this->validateEsignAccount($data['esign_account'], 'esign_account'); + if (! empty($data['secondary_esign_account'])) { + $this->validateEsignAccount($data['secondary_esign_account'], 'secondary_esign_account'); + } + + $data['organization_name'] = $data['signing_subject']; + $data['operator_name'] = $data['operator_name'] ?? $data['courier_name']; + $data['operator_account'] = $data['esign_account']; + $data['payload'] = $this->payload($data); + + $signature = $this->service->send($data, user_id()); + + return redirect() + ->route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing', 'agreement_view' => 'tasks']) + ->with('success', $signature->status === CourierAgreementSignature::STATUS_SIGNING + ? "{$signature->documentLabel()}已推送到 E 签宝,等待对方签署。" + : "{$signature->documentLabel()}签署任务已受理。") + ->with('agreement_signature_id', $signature->id); + } + + public function synchronize(CourierAgreementSignature $agreementSignature): RedirectResponse + { + $this->ensureCanManage(); + $signature = $this->service->synchronize($agreementSignature, user_id()); + + return redirect() + ->route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing', 'agreement_view' => 'tasks']) + ->with('success', "{$signature->documentLabel()}状态已同步:{$signature->statusLabel()}。"); + } + + public function archive(CourierAgreementSignature $agreementSignature): RedirectResponse + { + $this->ensureCanManage(); + abort_unless($agreementSignature->status === CourierAgreementSignature::STATUS_COMPLETED && $agreementSignature->remote_status === '2', 422, '只有签署完成的协议可以人工归档。'); + $signature = $this->service->archive($agreementSignature, null, user_id()); + + return redirect() + ->route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing', 'agreement_view' => 'tasks']) + ->with('success', "{$signature->documentLabel()}已人工确认并归档。"); + } + + public function download(CourierAgreementSignature $agreementSignature, string $kind): StreamedResponse + { + $this->ensureCanManage(); + [$path, $suffix] = match ($kind) { + 'source' => [$agreementSignature->source_pdf_path, '签署原文'], + 'signed' => [$agreementSignature->signed_pdf_path, '已签署协议'], + 'certificate' => [$agreementSignature->certificate_path, '签署完成证书'], + default => abort(404), + }; + + abort_if(blank($path) || ! \Storage::disk('uploads')->exists($path), 404, '归档文件不存在。'); + + return \Storage::disk('uploads')->download($path, $agreementSignature->business_no . '-' . $suffix . '.pdf', ['Content-Type' => 'application/pdf']); + } + + private function rules(): array + { + $date = ['required', 'date', 'after_or_equal:today', 'before_or_equal:' . now()->addDays(90)->toDateString()]; + + return [ + 'idempotency_key' => ['required', 'string', 'max:80', 'regex:/^[A-Za-z0-9_-]+$/'], + 'courier_name' => ['required', 'string', 'max:255'], + 'document_type' => ['required', 'in:' . implode(',', array_keys(CourierAgreementSignature::DOCUMENTS))], + 'signing_subject' => ['required', 'string', 'min:2', 'max:255'], + 'company_full_name' => ['required', 'string', 'min:2', 'max:255'], + 'signing_deadline' => $date, + 'esign_account' => ['required', 'string', 'max:255'], + 'secondary_esign_account' => ['required_if:document_type,entity-change,authorization', 'nullable', 'string', 'max:255'], + 'operator_name' => ['nullable', 'string', 'max:100'], + 'archive_mode' => ['required', 'in:auto,manual'], + 'account_proof_effective_date' => ['required_if:document_type,account-proof', 'date'], + 'account_proof_account_name' => ['required_if:document_type,account-proof', 'string', 'max:255'], + 'account_proof_bank_name' => ['required_if:document_type,account-proof', 'string', 'max:255'], + 'account_proof_bank_account' => ['required_if:document_type,account-proof', 'string', 'max:64', 'regex:/^[0-9A-Za-z-]+$/'], + 'account_change_date' => ['required_if:document_type,account-change', 'date'], + 'old_account_name' => ['required_if:document_type,account-change', 'string', 'max:255'], + 'old_account_number' => ['required_if:document_type,account-change', 'string', 'max:64', 'regex:/^[0-9A-Za-z-]+$/'], + 'old_account_stop_date' => ['required_if:document_type,account-change', 'date'], + 'new_account_effective_date' => ['required_if:document_type,account-change', 'date'], + 'new_account_name' => ['required_if:document_type,account-change', 'string', 'max:255'], + 'new_bank_name' => ['required_if:document_type,account-change', 'string', 'max:255'], + 'new_bank_account' => ['required_if:document_type,account-change', 'string', 'max:64', 'regex:/^[0-9A-Za-z-]+$/'], + 'former_company_name' => ['required_if:document_type,entity-change', 'string', 'max:255'], + 'entity_change_date' => ['required_if:document_type,entity-change', 'date'], + 'changed_company_name' => ['required_if:document_type,entity-change', 'string', 'max:255'], + 'changed_company_start_date' => ['required_if:document_type,entity-change', 'date'], + 'entity_proof_date' => ['required_if:document_type,entity-change', 'date'], + 'contract_partner_name' => ['required_if:document_type,shared-contract', 'string', 'max:255'], + 'shared_courier_name' => ['required_if:document_type,shared-contract', 'string', 'max:255'], + 'shared_proof_date' => ['required_if:document_type,shared-contract', 'date'], + 'principal_name' => ['required_if:document_type,authorization', 'string', 'max:100'], + 'principal_id_number' => ['required_if:document_type,authorization', 'string', 'max:64'], + 'trustee_name' => ['required_if:document_type,authorization', 'string', 'max:100'], + 'trustee_id_number' => ['required_if:document_type,authorization', 'string', 'max:64'], + 'authorization_items' => ['nullable', 'array'], + 'authorization_items.*' => ['in:1,2,3,4'], + 'bill_counterparty' => ['required_if:document_type,authorization', 'string', 'max:255'], + 'loan_counterparty' => ['required_if:document_type,authorization', 'string', 'max:255'], + 'payment_counterparty' => ['required_if:document_type,authorization', 'string', 'max:255'], + 'authorization_start_date' => ['required_if:document_type,authorization', 'date'], + 'authorization_end_date' => ['required_if:document_type,authorization', 'date', 'after_or_equal:authorization_start_date'], + ]; + } + + private function payload(array $data): array + { + $allowed = array_keys($this->rules()); + $payload = array_intersect_key($data, array_flip($allowed)); + unset($payload['esign_account'], $payload['idempotency_key'], $payload['operator_name']); + $payload['authorization_items'] = array_values(array_map('strval', $data['authorization_items'] ?? [])); + return $payload; + } + + private function ensureCanManage(): void + { + abort_unless( + in_array(SystemAccess::roleName(user()), [SystemAccess::SUPER_ADMIN, SystemAccess::ADMIN], true), + 403, + '当前账号没有协议签署管理权限。', + ); + } + + private function validateEsignAccount(string $account, string $field): void + { + $valid = preg_match('/^1\d{10}$/', $account) === 1 + || filter_var($account, FILTER_VALIDATE_EMAIL) !== false; + + if (! $valid) { + throw ValidationException::withMessages([$field => 'E签宝账号必须是 11 位手机号或有效邮箱。']); + } + } +} diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 9b705f9f..7975cb20 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -6,6 +6,7 @@ use App\Business\BusinessTableImporter; use App\Business\TableRegistry; use App\Business\UpstreamBusinessInformation; use App\Models\Banking\MarketingAccountBill; +use App\Models\Business\CourierAgreementSignature; use App\Models\StoreReceivables\StoreZhongcaiReconciliation; use App\Utilities\SidebarPermissions; use Illuminate\Http\Request; @@ -13,6 +14,7 @@ use Illuminate\Routing\Controller; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\Paginator; use Illuminate\Support\Collection; +use Illuminate\Database\QueryException; use Modules\EcommerceFinance\Exceptions\UpstreamSnapshotPendingException; use Modules\EcommerceFinance\Repositories\AlibabaBillRepository; use Modules\EcommerceFinance\Repositories\EdgeBandingRepository; @@ -196,6 +198,7 @@ class Pages extends Controller 'edgeBanding' => $this->edgeBandingContent($page['slug']), 'inventoryData' => $this->inventoryContent($page['slug']), 'rawBoardData' => $this->rawBoardContent($page['slug']), + 'agreementTasks' => $this->agreementTasks($page['slug']), ]; } @@ -233,9 +236,28 @@ class Pages extends Controller 'edgeBanding' => $page ? $this->edgeBandingContent($page['slug']) : null, 'inventoryData' => $page ? $this->inventoryContent($page['slug']) : null, 'rawBoardData' => $page ? $this->rawBoardContent($page['slug']) : null, + 'agreementTasks' => $this->agreementTasks($page['slug'] ?? null), ]; } + protected function agreementTasks(?string $page): Collection + { + if ($page !== 'agreement-signing') { + return collect(); + } + + try { + return CourierAgreementSignature::query() + ->latest('id') + ->limit(50) + ->get(); + } catch (QueryException $exception) { + // 迁移尚未执行时仍允许打开页面;发送接口会明确提示数据库未就绪。 + report($exception); + return collect(); + } + } + protected function resolveContent(string $page, ?string $tab): ?array { return match ($page) { diff --git a/app/Http/Controllers/Integrations/EsignCallbacks.php b/app/Http/Controllers/Integrations/EsignCallbacks.php index 2bf8778e..41ad7511 100644 --- a/app/Http/Controllers/Integrations/EsignCallbacks.php +++ b/app/Http/Controllers/Integrations/EsignCallbacks.php @@ -3,7 +3,10 @@ namespace App\Http\Controllers\Integrations; use App\Jobs\Business\ArchiveCourierQuoteSignature; +use App\Jobs\Business\ArchiveCourierAgreementSignature; +use App\Models\Business\CourierAgreementSignature; use App\Models\Business\CourierQuoteSignature; +use App\Support\CourierAgreementSignatureService; use App\Support\CourierQuoteSignatureService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -11,7 +14,7 @@ use Illuminate\Routing\Controller; class EsignCallbacks extends Controller { - public function __invoke(Request $request, CourierQuoteSignatureService $service): JsonResponse + public function __invoke(Request $request, CourierQuoteSignatureService $service, CourierAgreementSignatureService $agreementService): JsonResponse { $appId = trim((string) config('services.esign.app_id')); $appSecret = trim((string) config('services.esign.app_secret')); @@ -60,7 +63,24 @@ class EsignCallbacks extends Controller return $this->success(); } - $signatureRecord = $service->acceptCallback($signFlowId, $remoteStatus, hash('sha256', $rawBody)); + $payloadHash = hash('sha256', $rawBody); + $signatureRecord = $service->acceptCallback($signFlowId, $remoteStatus, $payloadHash); + + if ($signatureRecord === null) { + $agreementRecord = $agreementService->acceptCallback($signFlowId, $remoteStatus, $payloadHash, [ + 'event_key' => hash('sha256', $timestamp . '|' . ($payload['action'] ?? '') . '|' . $signFlowId . '|' . $payloadHash), + 'action' => (string) ($payload['action'] ?? 'SIGN_FLOW_COMPLETE'), + 'timestamp' => $timestamp, + 'raw_body' => $rawBody, + ]); + + if ($agreementRecord + && $remoteStatus === '2' + && $agreementService->autoArchive($agreementRecord) + && $agreementRecord->status !== CourierAgreementSignature::STATUS_ARCHIVED) { + ArchiveCourierAgreementSignature::dispatch($agreementRecord->id); + } + } if ($signatureRecord && $remoteStatus === '2' diff --git a/app/Jobs/Business/ArchiveCourierAgreementSignature.php b/app/Jobs/Business/ArchiveCourierAgreementSignature.php new file mode 100644 index 00000000..2333ce0e --- /dev/null +++ b/app/Jobs/Business/ArchiveCourierAgreementSignature.php @@ -0,0 +1,31 @@ +find($this->signatureId); + if ($signature) { + $service->synchronize($signature); + } + } +} diff --git a/app/Models/Business/CourierAgreementSignature.php b/app/Models/Business/CourierAgreementSignature.php new file mode 100644 index 00000000..3ee62240 --- /dev/null +++ b/app/Models/Business/CourierAgreementSignature.php @@ -0,0 +1,103 @@ + [ + 'code' => 'TPL-ACCOUNT-CERT-V2', + 'version' => '2', + 'name' => '收款账户证明', + 'file' => '收款账户证明文件.doc', + ], + 'account-change' => [ + 'code' => 'TPL-ACCOUNT-CHANGE-V1', + 'version' => '1', + 'name' => '收款账户变更', + 'file' => '收款账户变更证明.doc', + ], + 'entity-change' => [ + 'code' => 'TPL-ENTITY-CHANGE-V1', + 'version' => '1', + 'name' => '公司主体变更', + 'file' => '公司主体变更证明.docx', + ], + 'shared-contract' => [ + 'code' => 'TPL-SHARED-CONTRACT-V1', + 'version' => '1', + 'name' => '共用合同证明', + 'file' => '共用合同证明.docx', + ], + 'authorization' => [ + 'code' => 'TPL-AUTHORIZATION-V1', + 'version' => '1', + 'name' => '授权委托书', + 'file' => '授权委托书(快递).docx', + ], + ]; + + protected $table = 'business_courier_agreement_signatures'; + + protected $fillable = [ + 'company_id', 'business_no', 'idempotency_key', 'document_type', 'template_code', + 'template_version', 'source_file_name', 'status', 'courier_name', 'company_full_name', + 'organization_name', 'operator_name', 'operator_account', 'payload', 'environment', + 'expires_at', 'source_pdf_path', 'source_pdf_sha256', 'esign_file_id', + 'esign_sign_flow_id', 'remote_status', 'signed_pdf_path', 'signed_pdf_sha256', + 'certificate_path', 'certificate_sha256', 'sent_at', 'completed_at', 'archived_at', + 'last_synced_at', 'last_error', 'callback_payload_hash', 'created_by', 'updated_by', + ]; + + protected $casts = [ + 'company_id' => 'integer', + 'expires_at' => 'datetime', + 'sent_at' => 'datetime', + 'completed_at' => 'datetime', + 'archived_at' => 'datetime', + 'last_synced_at' => 'datetime', + 'payload' => 'encrypted:array', + 'operator_account' => 'encrypted', + ]; + + public function statusLabel(): string + { + return match ($this->status) { + self::STATUS_PREPARED => '待推送', + self::STATUS_SENDING => '推送中', + self::STATUS_SIGNING => '待签署', + self::STATUS_COMPLETED => '签署完成,待归档', + self::STATUS_ARCHIVED => '已签署并归档', + self::STATUS_REVOKED => '已撤销', + self::STATUS_EXPIRED => '已过期', + self::STATUS_REJECTED => '已拒签', + self::STATUS_FAILED => '处理失败', + self::STATUS_UNKNOWN => '状态未知,请人工核对', + default => $this->status, + }; + } + + public function canSend(): bool + { + return in_array($this->status, [self::STATUS_PREPARED, self::STATUS_FAILED], true) + && blank($this->esign_sign_flow_id); + } + + public function documentLabel(): string + { + return self::DOCUMENTS[$this->document_type]['name'] ?? $this->document_type; + } +} diff --git a/app/Models/Business/CourierAgreementSignatureEvent.php b/app/Models/Business/CourierAgreementSignatureEvent.php new file mode 100644 index 00000000..28416815 --- /dev/null +++ b/app/Models/Business/CourierAgreementSignatureEvent.php @@ -0,0 +1,26 @@ + 'integer', + 'agreement_signature_id' => 'integer', + 'event_timestamp' => 'integer', + 'verified' => 'boolean', + 'processed' => 'boolean', + 'raw_body' => 'encrypted', + 'received_at' => 'datetime', + 'processed_at' => 'datetime', + ]; +} diff --git a/app/Services/Esign/EsignClient.php b/app/Services/Esign/EsignClient.php index ab9d06ca..20b8086b 100644 --- a/app/Services/Esign/EsignClient.php +++ b/app/Services/Esign/EsignClient.php @@ -32,6 +32,18 @@ final class EsignClient public function uploadPdf(string $bytes, string $fileName): string { + if (! str_starts_with($bytes, '%PDF-')) { + throw new RuntimeException('只能向 E 签宝上传有效 PDF 文件。'); + } + + if (strlen($bytes) > 50 * 1024 * 1024) { + throw new RuntimeException('E 签宝单文件不能超过 50 MB。'); + } + + if (preg_match('/[\\/:*"<>|??\x00-\x1F]/u', $fileName) === 1 || ! str_ends_with(strtolower($fileName), '.pdf')) { + throw new RuntimeException('E 签宝文件名必须是安全的 PDF 文件名。'); + } + $contentMd5 = base64_encode(md5($bytes, true)); $data = $this->request('POST', '/v3/files/file-upload-url', [ 'contentMd5' => $contentMd5, @@ -68,6 +80,7 @@ final class EsignClient 'signFlowExpireTime' => $expiresAtMs, 'autoStart' => true, 'autoFinish' => true, + 'identityVerify' => true, ]; $notifyUrl = trim((string) config('services.esign.notify_url')); @@ -75,13 +88,10 @@ final class EsignClient $flowConfig['notifyUrl'] = $notifyUrl; } - $data = $this->request('POST', '/v3/sign-flow/create-by-file', [ - 'docs' => [[ - 'fileId' => $fileId, - 'fileName' => $fileName, - ]], - 'signFlowConfig' => $flowConfig, - 'signers' => [[ + $signers = $signer['signers'] ?? null; + $signerPayload = is_array($signers) && $signers !== [] + ? array_map(fn (array $item, int $index): array => $this->signerPayload($fileId, $item, $index), array_values($signers), array_keys(array_values($signers))) + : [[ 'signerType' => 1, 'signConfig' => ['signOrder' => 1], 'noticeConfig' => ['noticeTypes' => $signer['notice_types']], @@ -100,7 +110,15 @@ final class EsignClient 'signFieldSize' => '159', ], ]], + ]]; + + $data = $this->request('POST', '/v3/sign-flow/create-by-file', [ + 'docs' => [[ + 'fileId' => $fileId, + 'fileName' => $fileName, ]], + 'signFlowConfig' => $flowConfig, + 'signers' => $signerPayload, ]); $signFlowId = (string) ($data['signFlowId'] ?? ''); @@ -111,6 +129,41 @@ final class EsignClient return $signFlowId; } + private function signerPayload(string $fileId, array $signer, int $index): array + { + $type = (int) ($signer['signer_type'] ?? 1); + $payload = [ + 'signerType' => $type, + 'signConfig' => ['signOrder' => $index + 1], + 'noticeConfig' => ['noticeTypes' => (string) ($signer['notice_types'] ?? '1')], + 'signFields' => [[ + 'fileId' => $fileId, + 'normalSignFieldConfig' => [ + 'freeMode' => true, + 'movableSignField' => true, + 'signFieldSize' => '159', + ], + ]], + ]; + + if ($type === 0) { + $payload['psnSignerInfo'] = [ + 'psnAccount' => $signer['operator_account'], + 'psnInfo' => ['psnName' => $signer['operator_name']], + ]; + } else { + $payload['orgSignerInfo'] = [ + 'orgName' => $signer['organization_name'], + 'transactorInfo' => [ + 'psnAccount' => $signer['operator_account'], + 'psnInfo' => ['psnName' => $signer['operator_name']], + ], + ]; + } + + return $payload; + } + public function signFlowDetail(string $signFlowId): array { return $this->request('GET', "/v3/sign-flow/{$signFlowId}/detail"); diff --git a/app/Support/CourierAgreementPdf.php b/app/Support/CourierAgreementPdf.php new file mode 100644 index 00000000..19d76f92 --- /dev/null +++ b/app/Support/CourierAgreementPdf.php @@ -0,0 +1,42 @@ + $signature, + 'type' => $signature->document_type, + 'title' => $this->title($signature), + 'payload' => $signature->payload ?? [], + 'fontPath' => str_replace('\\', '/', resource_path('fonts/DroidSansFallback.ttf')), + ])->render(); + + $pdf = app('dompdf.wrapper'); + $pdf->setPaper('a4', 'portrait'); + $pdf->loadHTML($html, 'UTF-8'); + $bytes = $pdf->output(); + + if (! is_string($bytes) || ! str_starts_with($bytes, '%PDF-')) { + throw new RuntimeException('协议 PDF 生成失败。'); + } + + return $bytes; + } + + private function title(CourierAgreementSignature $signature): string + { + return match ($signature->document_type) { + 'account-proof' => '收款帐户资料证明', + 'account-change' => '收款帐户资料变更证明', + 'entity-change' => '公司主体变更证明', + 'shared-contract' => '证明', + default => '授权委托书', + }; + } +} diff --git a/app/Support/CourierAgreementSignatureService.php b/app/Support/CourierAgreementSignatureService.php new file mode 100644 index 00000000..e573a3f5 --- /dev/null +++ b/app/Support/CourierAgreementSignatureService.php @@ -0,0 +1,316 @@ + '不支持的协议文件类型。']); + } + + $payload = is_array($data['payload'] ?? null) ? $data['payload'] : $data; + $payload['authorization_items'] = array_values(array_map('strval', $data['authorization_items'] ?? [])); + $idempotencyKey = 'agreement-' . hash('sha256', $companyId . '|' . (string) ($data['idempotency_key'] ?? '')); + $businessNo = 'AGR-' . strtoupper(substr(hash('sha256', $idempotencyKey), 0, 20)); + + $signature = DB::transaction(function () use ($companyId, $document, $data, $payload, $idempotencyKey, $businessNo, $userId): CourierAgreementSignature { + $existing = CourierAgreementSignature::allCompanies() + ->where('company_id', $companyId) + ->where('idempotency_key', $idempotencyKey) + ->lockForUpdate() + ->first(); + + if ($existing) { + if ($existing->esign_sign_flow_id || in_array($existing->status, [CourierAgreementSignature::STATUS_SENDING, CourierAgreementSignature::STATUS_SIGNING, CourierAgreementSignature::STATUS_COMPLETED, CourierAgreementSignature::STATUS_ARCHIVED], true)) { + return $existing; + } + + if (! $existing->canSend()) { + throw ValidationException::withMessages(['signature' => '该协议已进入不可重复发起状态。']); + } + + return $existing; + } + + $signature = new CourierAgreementSignature([ + 'company_id' => $companyId, + 'business_no' => $businessNo, + 'idempotency_key' => $idempotencyKey, + 'document_type' => $data['document_type'], + 'template_code' => $document['code'], + 'template_version' => $document['version'], + 'source_file_name' => $document['file'], + 'status' => CourierAgreementSignature::STATUS_SENDING, + 'courier_name' => $data['courier_name'], + 'company_full_name' => $data['company_full_name'], + 'organization_name' => $data['organization_name'], + 'operator_name' => $data['operator_name'] ?? null, + 'operator_account' => $data['operator_account'] ?? null, + 'payload' => $payload, + 'environment' => (string) config('services.esign.environment', 'sandbox'), + 'expires_at' => Carbon::parse($data['signing_deadline'])->endOfDay(), + 'source_pdf_path' => "courier-agreements/{$companyId}/{$businessNo}/source.pdf", + 'source_pdf_sha256' => '', + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + $signature->save(); + + $bytes = $this->pdf->render($signature); + $signature->source_pdf_sha256 = hash('sha256', $bytes); + if (! Storage::disk('uploads')->put($signature->source_pdf_path, $bytes)) { + throw new RuntimeException('协议 PDF 无法写入私有业务存储。'); + } + $signature->save(); + + return $signature; + }); + + if ($signature->esign_sign_flow_id || $signature->status === CourierAgreementSignature::STATUS_SIGNING) { + return $signature->fresh(); + } + + $flowCreationStarted = false; + try { + $client = app(EsignClient::class); + $bytes = Storage::disk('uploads')->get($signature->source_pdf_path); + $fileName = 'agreement-' . $signature->business_no . '.pdf'; + $fileId = $client->uploadPdf($bytes, $fileName); + $signer = [ + 'flow_title' => $this->safeTitle($signature->company_full_name . ' ' . $signature->documentLabel()), + 'organization_name' => $signature->organization_name, + 'operator_name' => $signature->operator_name ?: $signature->courier_name, + 'operator_account' => $signature->operator_account, + 'notice_types' => '1', + ]; + $signer['signers'] = $this->signersFor($signature); + $flowCreationStarted = true; + $signFlowId = $client->createSignFlow($fileId, $fileName, $signer, $signature->expires_at->getTimestampMs()); + + $signature->update([ + 'status' => CourierAgreementSignature::STATUS_SIGNING, + 'esign_file_id' => $fileId, + 'esign_sign_flow_id' => $signFlowId, + 'remote_status' => '1', + 'sent_at' => now(), + 'last_synced_at' => now(), + 'last_error' => null, + 'updated_by' => $userId, + ]); + + return $signature->fresh(); + } catch (Throwable $exception) { + $signature->update([ + 'status' => $flowCreationStarted ? CourierAgreementSignature::STATUS_UNKNOWN : CourierAgreementSignature::STATUS_FAILED, + 'last_error' => mb_substr((string) preg_replace('/[[:cntrl:]]+/u', ' ', $exception->getMessage()), 0, 1000), + 'updated_by' => $userId, + ]); + throw ValidationException::withMessages(['signature' => $signature->last_error]); + } + } + + public function synchronize(CourierAgreementSignature $signature, ?int $userId = null): CourierAgreementSignature + { + if (blank($signature->esign_sign_flow_id)) { + throw ValidationException::withMessages(['signature' => '该协议尚未创建 E 签宝签署流程。']); + } + + if ($signature->status === CourierAgreementSignature::STATUS_ARCHIVED) { + return $signature; + } + + try { + $client = app(EsignClient::class); + $detail = $client->signFlowDetail($signature->esign_sign_flow_id); + $remoteStatus = (string) ($detail['signFlowStatus'] ?? $detail['status'] ?? ''); + if ($remoteStatus === '') { + throw new RuntimeException('E 签宝未返回签署流程状态。'); + } + + $signature = $this->applyRemoteStatus($signature, $remoteStatus, $userId ?: (int) $signature->updated_by); + + return $remoteStatus === '2' && $this->autoArchive($signature) + ? $this->archive($signature, $client, $userId ?: (int) $signature->updated_by) + : $signature; + } catch (Throwable $exception) { + $signature->update(['last_error' => mb_substr($exception->getMessage(), 0, 1000), 'last_synced_at' => now()]); + throw ValidationException::withMessages(['signature' => $signature->last_error]); + } + } + + public function acceptCallback(string $signFlowId, string $remoteStatus, string $payloadHash, array $event = []): ?CourierAgreementSignature + { + return DB::transaction(function () use ($signFlowId, $remoteStatus, $payloadHash, $event): ?CourierAgreementSignature { + $signature = CourierAgreementSignature::allCompanies()->disableCache()->where('esign_sign_flow_id', $signFlowId)->lockForUpdate()->first(); + if (! $signature) { + return null; + } + $eventKey = (string) ($event['event_key'] ?? hash('sha256', $signFlowId . '|' . $payloadHash)); + $audit = CourierAgreementSignatureEvent::query()->disableCache()->where('event_key', $eventKey)->lockForUpdate()->first(); + if ($audit || $signature->callback_payload_hash === $payloadHash) { + return null; + } + if (in_array($signature->status, [ + CourierAgreementSignature::STATUS_COMPLETED, + CourierAgreementSignature::STATUS_ARCHIVED, + CourierAgreementSignature::STATUS_REVOKED, + CourierAgreementSignature::STATUS_EXPIRED, + CourierAgreementSignature::STATUS_REJECTED, + ], true) && $remoteStatus === $signature->remote_status) { + return null; + } + if ($event !== []) { + $audit = CourierAgreementSignatureEvent::create([ + 'company_id' => $signature->company_id, + 'agreement_signature_id' => $signature->id, + 'event_key' => $eventKey, + 'action' => (string) ($event['action'] ?? 'SIGN_FLOW_COMPLETE'), + 'esign_sign_flow_id' => $signFlowId, + 'event_timestamp' => (int) ($event['timestamp'] ?? now()->getTimestampMs()), + 'verified' => true, + 'raw_body' => (string) ($event['raw_body'] ?? ''), + 'received_at' => now(), + ]); + } + $signature->callback_payload_hash = $payloadHash; + $result = $this->applyRemoteStatus($signature, $remoteStatus, (int) $signature->updated_by); + if ($audit) { + $audit->update(['processed' => true, 'processed_at' => now()]); + } + return $result; + }); + } + + public function archive(CourierAgreementSignature $signature, ?EsignClient $client = null, ?int $userId = null): CourierAgreementSignature + { + if ($signature->status === CourierAgreementSignature::STATUS_ARCHIVED) { + return $signature; + } + $client ??= app(EsignClient::class); + $urls = $client->downloadUrls($signature->esign_sign_flow_id); + $signedUrl = (string) data_get($urls, 'files.0.downloadUrl', ''); + $certificateUrl = (string) ($urls['certificateDownloadUrl'] ?? ''); + if ($signedUrl === '' || $certificateUrl === '') { + throw new RuntimeException('E 签宝未返回签署文件或完成证书下载地址。'); + } + $signedBytes = $client->download($signedUrl); + $certificateBytes = $client->download($certificateUrl); + if (! str_starts_with($signedBytes, '%PDF-') || ! str_starts_with($certificateBytes, '%PDF-')) { + throw new RuntimeException('E 签宝返回的签署文件或完成证书不是有效 PDF。'); + } + $base = "courier-agreements/{$signature->company_id}/{$signature->business_no}"; + $signedPath = $base . '/signed.pdf'; + $certificatePath = $base . '/completion-certificate.pdf'; + $disk = Storage::disk('uploads'); + if (! $disk->put($signedPath, $signedBytes) || ! $disk->put($certificatePath, $certificateBytes)) { + throw new RuntimeException('签署归档文件无法写入私有业务存储。'); + } + $signature->update([ + 'status' => CourierAgreementSignature::STATUS_ARCHIVED, + 'signed_pdf_path' => $signedPath, + 'signed_pdf_sha256' => hash('sha256', $signedBytes), + 'certificate_path' => $certificatePath, + 'certificate_sha256' => hash('sha256', $certificateBytes), + 'archived_at' => now(), + 'last_synced_at' => now(), + 'last_error' => null, + 'updated_by' => $userId ?: (int) $signature->updated_by, + ]); + return $signature->fresh(); + } + + public function autoArchive(CourierAgreementSignature $signature): bool + { + return ($signature->payload['archive_mode'] ?? 'auto') === 'auto'; + } + + private function applyRemoteStatus(CourierAgreementSignature $signature, string $remoteStatus, int $userId): CourierAgreementSignature + { + $status = match ($remoteStatus) { + '1' => CourierAgreementSignature::STATUS_SIGNING, + '2' => CourierAgreementSignature::STATUS_COMPLETED, + '3' => CourierAgreementSignature::STATUS_REVOKED, + '5' => CourierAgreementSignature::STATUS_EXPIRED, + '7' => CourierAgreementSignature::STATUS_REJECTED, + default => $signature->status, + }; + $terminal = [CourierAgreementSignature::STATUS_COMPLETED, CourierAgreementSignature::STATUS_REVOKED, CourierAgreementSignature::STATUS_EXPIRED, CourierAgreementSignature::STATUS_REJECTED, CourierAgreementSignature::STATUS_ARCHIVED]; + if (in_array($signature->status, $terminal, true) && $status === CourierAgreementSignature::STATUS_SIGNING) { + return $signature; + } + $signature->forceFill([ + 'status' => $status, + 'remote_status' => $remoteStatus, + 'completed_at' => $remoteStatus === '2' ? ($signature->completed_at ?? now()) : $signature->completed_at, + 'last_synced_at' => now(), + 'updated_by' => $userId, + ])->save(); + return $signature->refresh(); + } + + private function signersFor(CourierAgreementSignature $signature): array + { + $payload = $signature->payload ?? []; + $primary = [ + 'signer_type' => 1, + 'organization_name' => $signature->organization_name, + 'operator_name' => $signature->operator_name ?: $signature->courier_name, + 'operator_account' => $signature->operator_account, + ]; + + if ($signature->document_type === 'entity-change') { + return [ + array_merge($primary, ['organization_name' => $payload['former_company_name'] ?? $signature->organization_name]), + [ + 'signer_type' => 1, + 'organization_name' => $payload['changed_company_name'] ?? $signature->organization_name, + 'operator_name' => $payload['changed_company_name'] ?? $signature->courier_name, + 'operator_account' => $payload['secondary_esign_account'] ?? $signature->operator_account, + ], + ]; + } + + if ($signature->document_type === 'authorization') { + return [ + array_merge($primary, [ + 'organization_name' => $payload['principal_name'] ?? $signature->organization_name, + 'operator_name' => $payload['principal_name'] ?? $signature->operator_name, + ]), + [ + 'signer_type' => 1, + 'organization_name' => $payload['trustee_name'] ?? $signature->organization_name, + 'operator_name' => $payload['trustee_name'] ?? $signature->courier_name, + 'operator_account' => $payload['secondary_esign_account'] ?? $signature->operator_account, + ], + ]; + } + + return [$primary]; + } + + private function safeTitle(string $title): string + { + $title = preg_replace('/[^\p{Han}\p{L}\p{N}_ -]/u', '-', $title) ?: '协议签署'; + return mb_substr(trim($title), 0, 180); + } +} diff --git a/database/migrations/2026_08_28_000000_create_business_courier_agreement_signatures_table.php b/database/migrations/2026_08_28_000000_create_business_courier_agreement_signatures_table.php new file mode 100644 index 00000000..cb7f4037 --- /dev/null +++ b/database/migrations/2026_08_28_000000_create_business_courier_agreement_signatures_table.php @@ -0,0 +1,60 @@ +increments('id'); + $table->unsignedInteger('company_id'); + $table->string('business_no', 80); + $table->string('idempotency_key', 80); + $table->string('document_type', 40); + $table->string('template_code', 80); + $table->string('template_version', 20)->default('1'); + $table->string('source_file_name', 255); + $table->string('status', 32)->default('prepared'); + $table->string('courier_name', 255); + $table->string('company_full_name', 255); + $table->string('organization_name', 255); + $table->string('operator_name', 100)->nullable(); + $table->text('operator_account')->nullable(); + $table->text('payload'); + $table->string('environment', 32)->default('sandbox'); + $table->timestamp('expires_at')->nullable(); + $table->string('source_pdf_path', 1024); + $table->string('source_pdf_sha256', 64); + $table->string('esign_file_id', 128)->nullable(); + $table->string('esign_sign_flow_id', 128)->nullable(); + $table->string('remote_status', 32)->nullable(); + $table->string('signed_pdf_path', 1024)->nullable(); + $table->string('signed_pdf_sha256', 64)->nullable(); + $table->string('certificate_path', 1024)->nullable(); + $table->string('certificate_sha256', 64)->nullable(); + $table->timestamp('sent_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamp('archived_at')->nullable(); + $table->timestamp('last_synced_at')->nullable(); + $table->text('last_error')->nullable(); + $table->string('callback_payload_hash', 64)->nullable(); + $table->unsignedInteger('created_by'); + $table->unsignedInteger('updated_by'); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['company_id', 'business_no'], 'courier_agreement_signatures_business_unique'); + $table->unique(['company_id', 'idempotency_key'], 'courier_agreement_signatures_idempotency_unique'); + $table->unique('esign_sign_flow_id', 'courier_agreement_signatures_flow_unique'); + $table->index(['company_id', 'status'], 'courier_agreement_signatures_company_status_index'); + }); + } + + public function down(): void + { + Schema::dropIfExists('business_courier_agreement_signatures'); + } +}; diff --git a/database/migrations/2026_08_28_000100_create_business_courier_agreement_signature_events_table.php b/database/migrations/2026_08_28_000100_create_business_courier_agreement_signature_events_table.php new file mode 100644 index 00000000..d4143242 --- /dev/null +++ b/database/migrations/2026_08_28_000100_create_business_courier_agreement_signature_events_table.php @@ -0,0 +1,36 @@ +increments('id'); + $table->unsignedInteger('company_id'); + $table->unsignedInteger('agreement_signature_id')->nullable(); + $table->string('event_key', 128); + $table->string('action', 80); + $table->string('esign_sign_flow_id', 128); + $table->unsignedBigInteger('event_timestamp'); + $table->boolean('verified')->default(true); + $table->boolean('processed')->default(false); + $table->text('raw_body'); + $table->timestamp('received_at'); + $table->timestamp('processed_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique('event_key', 'courier_agreement_signature_events_key_unique'); + $table->index(['company_id', 'esign_sign_flow_id'], 'courier_agreement_signature_events_flow_index'); + }); + } + + public function down(): void + { + Schema::dropIfExists('business_courier_agreement_signature_events'); + } +}; diff --git a/docs/esign-agreement-integration.md b/docs/esign-agreement-integration.md new file mode 100644 index 00000000..598d3c8e --- /dev/null +++ b/docs/esign-agreement-integration.md @@ -0,0 +1,51 @@ +# E签宝协议签署接入说明 + +## 已实现范围 + +- 五份原始文件使用独立类型和字段映射:`account-proof`、`account-change`、`entity-change`、`shared-contract`、`authorization`。 +- 每次发起先在 `business_courier_agreement_signatures` 落库,生成唯一 `business_no` 和 `idempotency_key`,再生成 PDF、上传文件并创建签署流程。 +- 保存 `fileId`、`signFlowId`、模板版本、原文哈希、签署文件哈希和完成证书哈希。 +- 回调复用现有 E签宝验签入口,校验原始 Body、时间戳、AppId 和 HmacSHA256;协议回调按流程 ID 幂等更新并排队归档。 +- 签署完成后只归档文件,不自动启用收款账户;账户、生效日期和收款人仍须财务复核。 + +## 配置步骤 + +在本地 `runtime/.env` 或受控部署环境中配置,禁止提交到 Git: + +```dotenv +ESIGN_ENVIRONMENT=sandbox +ESIGN_BASE_URL=https://smlopenapi.esign.cn +ESIGN_APP_ID=替换为沙箱AppId +ESIGN_APP_SECRET=替换为沙箱AppSecret +ESIGN_NOTIFY_URL=https://你的公网域名/integrations/esign/callback +ESIGN_CALLBACK_TOLERANCE_SECONDS=900 +ESIGN_TIMEOUT=20 +``` + +正式环境单独使用 `https://openapi.esign.cn`、正式 AppId/AppSecret、独立 Webhook 和独立数据库记录。不要把沙箱凭证切换到生产域名,也不要在日志中输出 Secret、完整手机号、邮箱、身份证号或临时下载 URL。 + +在 E签宝开放平台完成:企业认证、SaaS API V3 应用、出口 IP 白名单、Webhook、`SIGN_FLOW_COMPLETE` 订阅,以及短信/邮件和企业签署服务开通。 + +## 联调步骤 + +1. 在页面“协议签署 → 发起签章”选择文件类型,填写该类型专属字段和对方实名绑定的手机号/邮箱。 +2. 点击“推送至E签宝”,确认本地任务进入“待签署”,并记录任务的 `business_no`、`fileId`、`signFlowId`。 +3. 在沙箱签署页完成实名认证、签字或盖章;验证单页和多页文件的签章位置。 +4. 模拟或等待 `SIGN_FLOW_COMPLETE`:只有 `signFlowStatus=2` 才会下载已签 PDF 和完成证书。 +5. 检查任务变为“已签署并归档”,下载原文、已签协议和证书,核对哈希值。 +6. 不发回调时使用任务行的“同步状态”按钮验证主动查单补偿;重复点击推送或重复回调不得产生第二个 `signFlowId`。 + +## 安全审计要点 + +- 业务单号、文件 ID、流程 ID 分开保存;发起前有唯一键,超时后不得直接重发。 +- 回调使用恒定时间比较和时间戳窗口,先验签再解析;回调状态不能把已完成状态回退为处理中。 +- 原文和归档文件存私有 `uploads` 盘并校验 SHA-256;临时下载地址只在服务端短时使用。 +- 个人证件和账号字段采用加密 cast 存储;权限仅允许管理员发起、同步和下载。 +- 企业名称不能作为模糊账号匹配依据;应在 E签宝实名账号确认后填写手机号/邮箱。 +- 电子签署完成不等于财务数据生效,收款账户变更必须保留人工复核和原档案电话核验。 + +## 真实环境上线前 + +- 使用密钥管理系统或受控环境变量保存正式 Secret,并完成轮换/吊销流程。 +- 配置公网 HTTPS、出口 IP 白名单和回调来源 IP 白名单。 +- 为签章坐标、个人/企业签署、拒签/撤销/过期、下载 URL 过期和回调丢失分别留存验收记录。 diff --git a/resources/views/business/pages/courier-payables/agreement-signing.blade.php b/resources/views/business/pages/courier-payables/agreement-signing.blade.php index 254b37e5..2626e070 100644 --- a/resources/views/business/pages/courier-payables/agreement-signing.blade.php +++ b/resources/views/business/pages/courier-payables/agreement-signing.blade.php @@ -1,4 +1,6 @@ @php($agreementCompanyName = company()?->name ?: '当前公司') +@php($agreementTasks = $agreementTasks ?? collect()) +@php($esignConfigured = \App\Services\Esign\EsignClient::configured())
+ + +
业务单号:{{ $signature->business_no }} 模板:{{ $signature->template_code }}
+

{{ $title }}

+ +@if ($type === 'account-proof') +

TO {{ $payload['company_full_name'] }}:

+

基于我公司正规化管理的需要,同时保证广大客户的汇款安全,我司同意自 {{ $payload['account_proof_effective_date'] }} 起我司货款(运费),请汇款至以下账户:

+
+
开户名称:{{ $payload['account_proof_account_name'] }}
+
开户银行:{{ $payload['account_proof_bank_name'] }}
+
银行帐户:{{ $payload['account_proof_bank_account'] }}
+
+

请贵公司按照上述汇款资料付款,如因我司提供收款银行帐户资料错误,导致相关款项支付不到位,则由此产生的一切经济纠纷和责任由我司承担。我司承诺若上述收款银行帐户资料发生变化及时书面通知贵公司。

+

以上收款账户的变更,以我司的盖章为有效。

+

{{ $payload['courier_name'] }} 公司盖章:

日期:签署后生成

+@elseif ($type === 'account-change') +

TO {{ $payload['company_full_name'] }}:

+

基于我公司正规化管理的需要,同时保证广大客户的汇款安全,我司收款账户将于 {{ $payload['account_change_date'] }} 变更账户,之前相应的账户(收款账户为:{{ $payload['old_account_name'] }},账号:{{ $payload['old_account_number'] }})于 {{ $payload['old_account_stop_date'] }} 停止使用,即日起我司收款的银行帐户进行变更,自 {{ $payload['new_account_effective_date'] }} 起我司与贵司往来款项请汇款至以下新账户,新汇款资料如下:

+
+
开户名称:{{ $payload['new_account_name'] }}
+
开户银行:{{ $payload['new_bank_name'] }}
+
银行账户:{{ $payload['new_bank_account'] }}
+
+

如因我司提供收款银行帐户资料错误导致相关款项支付不到位,由此产生的一切经济纠纷和责任由我司承担。以上收款账户的变更,以我司的盖章为有效。

+

{{ $payload['courier_name'] }} 公司盖章:

日期:签署后生成

+@elseif ($type === 'entity-change') +

致 {{ $payload['company_full_name'] }}:

+

我司由于企业名称变更,原公司名称 {{ $payload['former_company_name'] }} 于 {{ $payload['entity_change_date'] }} 变更为 {{ $payload['changed_company_name'] }},自 {{ $payload['changed_company_start_date'] }} 开始,我司将启用变更后的公司与贵公司开展工作,原公司名称停止使用。

+

变更后,原公司的各项事宜由变更后的公司继续执行。原公司的结算未付款项,由变更之后的公司提供的收款账户收款。原公司的业务和款项若出现法律上的纠纷,由变更后的公司承担责任。

+

特此证明!

+

变更前公司法人签字盖公章:{{ $payload['former_company_name'] }}

变更后公司法人签字盖公章:{{ $payload['changed_company_name'] }}

日期:{{ $payload['entity_proof_date'] }}

+@elseif ($type === 'shared-contract') +

与 {{ $payload['contract_partner_name'] }} 合作的 {{ $payload['shared_courier_name'] }} 快递共用同一份合同,打款资料为同一份资料,同一个签字人以及同一个法人!

+

特此证明!

+

法人签字并公司盖章:

日期:{{ $payload['shared_proof_date'] }}

+@else +
+
委托方:{{ $payload['principal_name'] }}
+
身份证号/营业执照号:{{ $payload['principal_id_number'] }}
+
受托方:{{ $payload['trustee_name'] }}
+
身份证号/营业执照号:{{ $payload['trustee_id_number'] }}
+
+

兹委托 {{ $payload['trustee_name'] }} 为我或我司的代理人,全权代表我或我司办理下列第 {{ implode('、', $payload['authorization_items'] ?? []) }} 条事项:

+
    +
  1. 代表委托方代签与 {{ $payload['bill_counterparty'] }} 的往来业务每月账单。
  2. +
  3. 代表委托方代签与 {{ $payload['loan_counterparty'] }} 借支协议的事项。
  4. +
  5. 代表委托方代签汇款账号变更的事项。
  6. +
  7. 代表委托方代收 {{ $payload['payment_counterparty'] }} 的往来货款。
  8. +
+

委托有效期:从 {{ $payload['authorization_start_date'] }} 至 {{ $payload['authorization_end_date'] }} 止。

+

委托方:{{ $payload['principal_name'] }}

受托方:{{ $payload['trustee_name'] }}

日期:签署后生成

+@endif + + diff --git a/routes/admin.php b/routes/admin.php index 607ebe84..269d0da7 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -2,6 +2,7 @@ use App\Http\Controllers\Business\CourierQuoteChanges; use App\Http\Controllers\Business\CourierQuoteSignatures; +use App\Http\Controllers\Business\CourierAgreementSignatures; use Illuminate\Support\Facades\Route; use Modules\EcommerceFinance\Http\Controllers\AccountBills; use Modules\EcommerceFinance\Http\Controllers\AlipayBills; @@ -88,6 +89,16 @@ Route::prefix('workspace/courier-payables/quote-management/signatures') Route::get('{quoteSignature}/files/{kind}', [CourierQuoteSignatures::class, 'download'])->name('download'); }); +Route::prefix('workspace/courier-payables/agreement-signatures') + ->name('business.courier-agreement-signatures.') + ->middleware('business.page:courier-payables,agreement-signing') + ->group(function () { + Route::post('/', [CourierAgreementSignatures::class, 'store'])->name('store'); + Route::post('{agreementSignature}/synchronize', [CourierAgreementSignatures::class, 'synchronize'])->name('synchronize'); + Route::post('{agreementSignature}/archive', [CourierAgreementSignatures::class, 'archive'])->name('archive'); + Route::get('{agreementSignature}/files/{kind}', [CourierAgreementSignatures::class, 'download'])->name('download'); + }); + Route::get('workspace/product-inventory/normal-products/export', 'Business\Pages@exportNormalProducts') ->middleware('business.page:product-inventory,normal-products') ->name('business.product-inventory.normal-products.export'); diff --git a/tests/Feature/Business/CourierAgreementSignatureWorkflowTest.php b/tests/Feature/Business/CourierAgreementSignatureWorkflowTest.php new file mode 100644 index 00000000..681ede2e --- /dev/null +++ b/tests/Feature/Business/CourierAgreementSignatureWorkflowTest.php @@ -0,0 +1,154 @@ + 'sandbox', + 'services.esign.base_url' => 'https://smlopenapi.esign.test', + 'services.esign.app_id' => 'sandbox-app-id', + 'services.esign.app_secret' => 'sandbox-app-secret', + 'services.esign.notify_url' => null, + ]); + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + } + + public function test_account_proof_uses_its_own_document_mapping_and_pushes_once(): void + { + $this->fakeEsign(); + $payload = $this->accountProofPayload('request-001'); + + $this->loginAs() + ->post(route('business.courier-agreement-signatures.store'), $payload) + ->assertRedirect(route('business.pages.show', [ + 'section' => 'courier-payables', + 'page' => 'agreement-signing', + 'agreement_view' => 'tasks', + ])); + + $signature = CourierAgreementSignature::query()->firstOrFail(); + $this->assertSame('TPL-ACCOUNT-CERT-V2', $signature->template_code); + $this->assertSame(CourierAgreementSignature::STATUS_SIGNING, $signature->status); + $this->assertSame('flow-agreement-001', $signature->esign_sign_flow_id); + Storage::disk('uploads')->assertExists($signature->source_pdf_path); + $this->assertSame('新北中通快递有限公司', $signature->payload['account_proof_account_name']); + + $this->loginAs()->post(route('business.courier-agreement-signatures.store'), $payload)->assertRedirect(); + Http::assertSentCount(4); + $this->assertSame(1, CourierAgreementSignature::query()->count()); + } + + public function test_verified_agreement_callback_is_idempotent_and_queues_archive(): void + { + Queue::fake(); + $signature = CourierAgreementSignature::create([ + 'company_id' => $this->company->id, + 'business_no' => 'AGR-CALLBACK-001', + 'idempotency_key' => 'callback-001', + 'document_type' => 'account-proof', + 'template_code' => 'TPL-ACCOUNT-CERT-V2', + 'template_version' => '2', + 'source_file_name' => '收款账户证明文件.doc', + 'status' => CourierAgreementSignature::STATUS_SIGNING, + 'courier_name' => '新北中通', + 'company_full_name' => $this->company->name, + 'organization_name' => '新北中通快递有限公司', + 'operator_name' => '张经理', + 'operator_account' => '13800138000', + 'payload' => ['company_full_name' => $this->company->name, 'courier_name' => '新北中通'], + 'environment' => 'sandbox', + 'source_pdf_path' => 'courier-agreements/test/source.pdf', + 'source_pdf_sha256' => hash('sha256', '%PDF-source'), + 'esign_file_id' => 'file-agreement-001', + 'esign_sign_flow_id' => 'flow-agreement-callback', + 'remote_status' => '1', + 'created_by' => $this->user->id, + 'updated_by' => $this->user->id, + ]); + $payload = json_encode([ + 'action' => 'SIGN_FLOW_COMPLETE', + 'signFlowId' => 'flow-agreement-callback', + 'signFlowStatus' => '2', + ], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + $timestamp = (string) now()->getTimestampMs(); + $callbackSignature = hash_hmac('sha256', $timestamp . '12' . $payload, 'sandbox-app-secret'); + + $server = [ + 'HTTP_X_TSIGN_OPEN_TIMESTAMP' => $timestamp, + 'HTTP_X_TSIGN_OPEN_SIGNATURE_ALGORITHM' => 'HmacSHA256', + 'HTTP_X_TSIGN_OPEN_APP_ID' => 'sandbox-app-id', + 'HTTP_X_TSIGN_OPEN_SIGNATURE' => $callbackSignature, + 'CONTENT_TYPE' => 'application/json', + ]; + $this->call('POST', route('integrations.esign.callback') . '?zeta=2&alpha=1', [], [], [], $server, $payload) + ->assertOk(); + + $signature->refresh(); + $this->assertSame(CourierAgreementSignature::STATUS_COMPLETED, $signature->status); + $this->assertSame(hash('sha256', $payload), $signature->callback_payload_hash); + Queue::assertPushed(ArchiveCourierAgreementSignature::class, 1); + $this->assertNull(app(\App\Support\CourierAgreementSignatureService::class)->acceptCallback( + 'flow-agreement-callback', '2', hash('sha256', $payload), + )); + + $this->call('POST', route('integrations.esign.callback') . '?zeta=2&alpha=1', [], [], [], $server, $payload) + ->assertOk(); + Queue::assertPushed(ArchiveCourierAgreementSignature::class, 1); + } + + private function fakeEsign(): void + { + Http::fake(function (ClientRequest $request) { + return match (true) { + $request->url() === 'https://smlopenapi.esign.test/v3/files/file-upload-url' => Http::response([ + 'code' => 0, + 'data' => ['fileId' => 'file-agreement-001', 'fileUploadUrl' => 'https://upload.esign.test/agreement-001'], + ]), + $request->url() === 'https://upload.esign.test/agreement-001' => Http::response(['ok' => true]), + $request->url() === 'https://smlopenapi.esign.test/v3/files/file-agreement-001' => Http::response([ + 'code' => 0, + 'data' => ['status' => 2], + ]), + $request->url() === 'https://smlopenapi.esign.test/v3/sign-flow/create-by-file' => Http::response([ + 'code' => 0, + 'data' => ['signFlowId' => 'flow-agreement-001'], + ]), + default => Http::response(['code' => 404], 404), + }; + }); + } + + private function accountProofPayload(string $idempotencyKey): array + { + return [ + 'idempotency_key' => $idempotencyKey, + 'courier_name' => '新北中通', + 'document_type' => 'account-proof', + 'signing_subject' => $this->company->name, + 'company_full_name' => $this->company->name, + 'signing_deadline' => now()->addDays(7)->toDateString(), + 'esign_account' => '13800138000', + 'operator_name' => '张经理', + 'archive_mode' => 'auto', + 'account_proof_effective_date' => now()->toDateString(), + 'account_proof_account_name' => '新北中通快递有限公司', + 'account_proof_bank_name' => '中国工商银行', + 'account_proof_bank_account' => '6222000012345678', + ]; + } +}