From 8d7f6da49ffdd93f5ac0f5d4c12513accdc00537 Mon Sep 17 00:00:00 2001 From: limeng <1131177064@qq.com> Date: Thu, 15 Oct 2026 08:19:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=8D=8F=E8=AE=AE=E7=AD=BE=E7=BD=B2):=20?= =?UTF-8?q?=E6=8C=89=E9=9C=80=E7=94=9F=E6=88=90=E8=AF=81=E4=BB=B6=E6=8E=A8?= =?UTF-8?q?=E9=80=81=E5=8D=95=E5=B9=B6=E7=8B=AC=E7=AB=8B=E7=AD=BE=E7=BD=B2?= =?UTF-8?q?=E5=BD=92=E6=A1=A3=20(#335)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 1 + .../Business/CourierAgreementSignatures.php | 21 +- .../Business/CourierAgreementSignature.php | 9 + app/Services/Esign/EsignClient.php | 29 ++- app/Support/CourierAgreementCredentials.php | 105 ++++++++ .../CourierAgreementSignatureService.php | 59 ++++- deploy/host-nginx-8442-dingtalk.conf | 4 +- deploy/php.ini | 2 +- docs/esign-agreement-integration.md | 6 + .../agreement-signing.blade.php | 12 +- .../credential-upload.blade.php | 101 ++++++++ .../business/pdf/courier-credential.blade.php | 34 +++ .../CourierAgreementCredentialsTest.php | 236 ++++++++++++++++++ 13 files changed, 605 insertions(+), 14 deletions(-) create mode 100644 app/Support/CourierAgreementCredentials.php create mode 100644 resources/views/business/pages/courier-payables/credential-upload.blade.php create mode 100644 resources/views/business/pdf/courier-credential.blade.php create mode 100644 tests/Feature/Business/CourierAgreementCredentialsTest.php diff --git a/.dockerignore b/.dockerignore index b0370ad0..77d8ad4e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -22,6 +22,7 @@ storage/debugbar storage/framework/cache/data/* storage/framework/sessions/* storage/framework/views/* +storage/framework/testing storage/logs/* tests test-data diff --git a/app/Http/Controllers/Business/CourierAgreementSignatures.php b/app/Http/Controllers/Business/CourierAgreementSignatures.php index 7cb84959..8e839026 100644 --- a/app/Http/Controllers/Business/CourierAgreementSignatures.php +++ b/app/Http/Controllers/Business/CourierAgreementSignatures.php @@ -8,6 +8,7 @@ use App\Models\Business\CourierQuoteVersion; use App\Models\Business\CourierSigningSubject; use App\Services\Esign\EsignClient; use App\Support\CourierAgreementFileArchive; +use App\Support\CourierAgreementCredentials; use App\Support\CourierAgreementSignatureService; use App\Support\CourierQuoteConfirmation; use App\Support\CourierQuoteSignatureService; @@ -198,6 +199,12 @@ class CourierAgreementSignatures extends Controller $data['operator_account'] = $data['esign_account']; $data['payload'] = $this->payload($data); + $data['payload']['credential_required'] = $request->boolean('credential_required'); + $data['payload']['credential_signer'] = (int) ($data['credential_signer'] ?? ($type === 'entity-change' ? 1 : 0)); + if ($request->boolean('credential_required') && $type === 'entity-change' + && $data['payload']['credential_signer'] === 0 && $data['counterparty_signer_type'] === 'person') { + throw ValidationException::withMessages(['credential_signer' => '营业执照需要企业盖章,请选择变更后主体签署证件。']); + } $signature = $this->service->send($data, user_id()); @@ -387,6 +394,16 @@ class CourierAgreementSignatures extends Controller public function download(CourierAgreementSignature $agreementSignature, string $kind): StreamedResponse|RedirectResponse { $this->ensureCanManage(); + abort_unless($agreementSignature->company_id === (int) company_id(), 404); + if (preg_match('/^credential-(license|identity|bank)-(source|signed)$/', $kind, $match)) { + $document = $agreementSignature->credentialDocuments()[$match[1]] ?? null; + abort_unless($document, 404); + $path = $document[$match[2] . '_path'] ?? null; + abort_if(blank($path) || ! \Storage::disk('uploads')->exists($path), 404, '证件推送单不存在。'); + return \Storage::disk('uploads')->response($path, + $agreementSignature->business_no . '-' . $document['name'] . ($match[2] === 'signed' ? '-已签署' : '-原文') . '.pdf', + ['Content-Type' => 'application/pdf'], request()->boolean('preview') ? 'inline' : 'attachment'); + } try { [$path, $suffix] = match ($kind) { 'source' => [$this->agreementFileArchive->sourcePath($agreementSignature), '签署原文'], @@ -519,14 +536,14 @@ class CourierAgreementSignatures extends Controller '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'], - ] + $supplierFields; + ] + $supplierFields + CourierAgreementCredentials::rules((string) request('document_type'), request()->boolean('credential_required')); } 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']); + unset($payload['esign_account'], $payload['idempotency_key'], $payload['operator_name'], $payload['credential_images']); $payload['authorization_items'] = array_values(array_map('strval', $data['authorization_items'] ?? [])); if (($data['document_type'] ?? null) === 'entity-change') { $payload['former_legal_representative'] = trim((string) ($data['operator_name'] ?? '')); diff --git a/app/Models/Business/CourierAgreementSignature.php b/app/Models/Business/CourierAgreementSignature.php index b512412f..c6441816 100644 --- a/app/Models/Business/CourierAgreementSignature.php +++ b/app/Models/Business/CourierAgreementSignature.php @@ -100,6 +100,15 @@ class CourierAgreementSignature extends Model return $partnerType === 'supplier' ? '供应商' : '快递公司'; } + public function credentialDocuments(): array + { + try { + return $this->payload['credential_documents'] ?? []; + } catch (\Throwable) { + return []; + } + } + public function statusLabel(): string { return match ($this->status) { diff --git a/app/Services/Esign/EsignClient.php b/app/Services/Esign/EsignClient.php index 6521b9d7..666476d1 100644 --- a/app/Services/Esign/EsignClient.php +++ b/app/Services/Esign/EsignClient.php @@ -120,11 +120,34 @@ final class EsignClient ]], ]]; - $data = $this->request('POST', '/v3/sign-flow/create-by-file', [ - 'docs' => [[ + $docs = [[ 'fileId' => $fileId, 'fileName' => $fileName, - ]], + ]]; + foreach ($signer['additional_docs'] ?? [] as $document) { + $index = (int) $document['signer_index']; + if (! isset($signerPayload[$index]) || blank($document['fileId'])) { + throw new RuntimeException('证件推送单的签署方或文件 ID 无效。'); + } + if (($document['stamp_only'] ?? false) && $signerPayload[$index]['signerType'] !== 1) { + throw new RuntimeException('营业执照需要企业盖章,请选择企业签署方。'); + } + $docs[] = ['fileId' => $document['fileId'], 'fileName' => $document['fileName']]; + // Each credential PDF is one page, with exactly one field assigned + // to the chosen recipient. Other signers only sign the main file. + $signerPayload[$index]['signFields'][] = [ + 'fileId' => $document['fileId'], + 'normalSignFieldConfig' => [ + 'freeMode' => false, + 'movableSignField' => false, + 'signFieldStyle' => 1, + 'signFieldSize' => '120', + 'signFieldPosition' => ['positionPage' => '1', 'positionX' => 420.0, 'positionY' => 725.0], + ], + ]; + } + $data = $this->request('POST', '/v3/sign-flow/create-by-file', [ + 'docs' => $docs, 'signFlowConfig' => $flowConfig, 'signers' => $signerPayload, ]); diff --git a/app/Support/CourierAgreementCredentials.php b/app/Support/CourierAgreementCredentials.php new file mode 100644 index 00000000..69937030 --- /dev/null +++ b/app/Support/CourierAgreementCredentials.php @@ -0,0 +1,105 @@ + '营业执照', 'id_front' => '身份证人像面', 'id_back' => '身份证国徽面', + 'bank_front' => '银行卡正面', 'bank_back' => '银行卡背面', + ]; + + public static function definitions(string $type): array + { + $sheets = [ + 'identity' => ['name' => '身份证推送单', 'images' => ['id_front', 'id_back']], + 'bank' => ['name' => '银行卡推送单', 'images' => ['bank_front', 'bank_back']], + ]; + return match ($type) { + 'authorization' => $sheets, + 'entity-change' => ['license' => ['name' => '营业执照盖章推送单', 'images' => ['license']]] + $sheets, + default => [], + }; + } + + public static function rules(string $type, bool $requested): array + { + $allowed = self::definitions($type); + $required = $requested ? array_merge(...array_column($allowed ?: [['images' => []]], 'images')) : []; + $rules = [ + 'credential_required' => ['nullable', 'boolean', Rule::prohibitedIf($allowed === [] && $requested)], + 'credential_signer' => ['nullable', 'integer', 'in:0,1'], + 'credential_images' => ['nullable', 'array:' . implode(',', array_keys(self::IMAGES))], + ]; + foreach (self::IMAGES as $key => $label) { + $active = in_array($key, $required, true); + $rules['credential_images.' . $key] = [Rule::requiredIf($active), Rule::prohibitedIf(! $active), + 'nullable', 'image', 'mimes:jpg,jpeg,png', 'max:10240', 'dimensions:max_width=5000,max_height=5000']; + } + return $rules; + } + + public function prepare(CourierAgreementSignature $signature, array $uploads): array + { + $definitions = self::definitions($signature->document_type); + if (! ($signature->payload['credential_required'] ?? false)) { + return []; + } + $disk = Storage::disk('uploads'); + $base = "courier-agreements/{$signature->company_id}/{$signature->business_no}/credentials"; + $result = []; + foreach ($definitions as $key => $definition) { + $pictures = []; + $originals = []; + foreach ($definition['images'] as $imageKey) { + $upload = $uploads[$imageKey] ?? null; + if (! $upload instanceof UploadedFile || ! $upload->isValid()) { + throw new RuntimeException('证件图片不完整,请重新上传。'); + } + $extension = $upload->getMimeType() === 'image/png' ? 'png' : 'jpg'; + $originalPath = "{$base}/{$imageKey}.{$extension}"; + $original = file_get_contents($upload->getRealPath()); + if (! $disk->put($originalPath, $original)) { + throw new RuntimeException('证件原始图片无法写入私有存储。'); + } + $image = (new ImageManager(['driver' => 'gd']))->make($upload->getRealPath())->orientate(); + $image->resize(2400, 2400, static function ($constraint): void { + $constraint->aspectRatio(); + $constraint->upsize(); + }); + $maxHeight = $key === 'license' ? 470 : 180; + $scale = min(450 / $image->width(), $maxHeight / $image->height()); + $pictures[] = ['label' => self::IMAGES[$imageKey], + 'uri' => 'data:image/png;base64,' . base64_encode((string) $image->encode('png')), + 'width' => round($image->width() * $scale, 2), 'height' => round($image->height() * $scale, 2)]; + $originals[$imageKey] = ['path' => $originalPath, 'sha256' => hash('sha256', $original)]; + $image->destroy(); + } + $pdf = app('dompdf.wrapper'); + $pdf->setPaper('a4', 'portrait'); + $pdf->loadHTML(view('business.pdf.courier-credential', [ + 'title' => $definition['name'], 'pictures' => $pictures, + 'stampOnly' => $key === 'license', 'signature' => $signature, + 'fontPath' => str_replace('\\', '/', resource_path('fonts/DroidSansFallback.ttf')), + ])->render(), 'UTF-8'); + $bytes = $pdf->output(); + if (! str_starts_with($bytes, '%PDF-') || $pdf->getDomPDF()->getCanvas()->get_page_count() !== 1) { + throw new RuntimeException('证件推送单必须为一页 PDF,请检查上传图片。'); + } + $sourcePath = "{$base}/{$key}-source.pdf"; + if (! $disk->put($sourcePath, $bytes)) { + throw new RuntimeException('证件推送单无法写入私有存储。'); + } + $result[$key] = ['name' => $definition['name'], 'source_path' => $sourcePath, + 'source_sha256' => hash('sha256', $bytes), 'originals' => $originals]; + } + return $result; + } +} diff --git a/app/Support/CourierAgreementSignatureService.php b/app/Support/CourierAgreementSignatureService.php index 3fcc2c06..5cdba331 100644 --- a/app/Support/CourierAgreementSignatureService.php +++ b/app/Support/CourierAgreementSignatureService.php @@ -36,7 +36,8 @@ final class CourierAgreementSignatureService $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 { + $claimedAttempt = false; + $signature = DB::transaction(function () use ($companyId, $document, $data, $payload, $idempotencyKey, $businessNo, $userId, &$claimedAttempt): CourierAgreementSignature { $existing = CourierAgreementSignature::allCompanies() ->withTrashed() ->where('company_id', $companyId) @@ -56,6 +57,7 @@ final class CourierAgreementSignatureService throw ValidationException::withMessages(['signature' => '该协议已进入不可重复发起状态。']); } + $claimedAttempt = true; $existing->update([ 'status' => CourierAgreementSignature::STATUS_SENDING, 'updated_by' => $userId, @@ -64,6 +66,7 @@ final class CourierAgreementSignatureService return $existing; } + $claimedAttempt = true; $signature = new CourierAgreementSignature([ 'company_id' => $companyId, 'business_no' => $businessNo, @@ -88,6 +91,13 @@ final class CourierAgreementSignatureService ]); $signature->save(); + $credentials = app(CourierAgreementCredentials::class)->prepare($signature, $data['credential_images'] ?? []); + if ($credentials !== []) { + $storedPayload = $signature->payload; + $storedPayload['credential_documents'] = $credentials; + $signature->payload = $storedPayload; + $signature->save(); + } $bytes = $this->pdf->render($signature); $signature->source_pdf_sha256 = hash('sha256', $bytes); if (! Storage::disk('uploads')->put($signature->source_pdf_path, $bytes)) { @@ -98,7 +108,7 @@ final class CourierAgreementSignatureService return $signature; }); - if ($signature->esign_sign_flow_id || $signature->status === CourierAgreementSignature::STATUS_SIGNING) { + if (! $claimedAttempt || $signature->esign_sign_flow_id || $signature->status === CourierAgreementSignature::STATUS_SIGNING) { return $signature->fresh(); } @@ -123,6 +133,20 @@ final class CourierAgreementSignatureService 'notice_types' => '1', ]; $signer['signers'] = $signers; + $storedPayload = $signature->payload; + $credentialDocuments = $storedPayload['credential_documents'] ?? []; + $recipient = (int) ($storedPayload['credential_signer'] ?? ($signature->document_type === 'entity-change' ? 1 : 0)); + foreach ($credentialDocuments as $key => &$credential) { + $credentialId = $client->uploadPdf(Storage::disk('uploads')->get($credential['source_path']), $key . '-' . $signature->business_no . '.pdf'); + $credential['file_id'] = $credentialId; + $signer['additional_docs'][] = ['fileId' => $credentialId, 'fileName' => $credential['name'] . '.pdf', + 'signer_index' => $recipient, 'stamp_only' => $key === 'license']; + } + unset($credential); + if ($credentialDocuments !== []) { + $storedPayload['credential_documents'] = $credentialDocuments; + $signature->update(['payload' => $storedPayload]); + } $flowCreationStarted = true; $signFlowId = $client->createSignFlow($fileId, $fileName, $signer, $signature->expires_at->getTimestampMs()); @@ -230,7 +254,11 @@ final class CourierAgreementSignatureService } $client ??= app(EsignClient::class); $urls = $client->downloadUrls($signature->esign_sign_flow_id); - $signedUrl = (string) data_get($urls, 'files.0.downloadUrl', ''); + $credentials = $signature->credentialDocuments(); + $remoteFiles = collect($urls['files'] ?? []); + $signedUrl = $credentials === [] + ? (string) data_get($urls, 'files.0.downloadUrl', '') + : (string) ($remoteFiles->firstWhere('fileId', $signature->esign_file_id)['downloadUrl'] ?? ''); $certificateUrl = (string) ($urls['certificateDownloadUrl'] ?? ''); if ($signedUrl === '') { throw new RuntimeException('E 签宝未返回签署文件下载地址。'); @@ -249,6 +277,22 @@ final class CourierAgreementSignatureService || ($certificateBytes !== null && ! $disk->put($certificatePath, $certificateBytes))) { throw new RuntimeException('签署归档文件无法写入私有业务存储。'); } + foreach ($credentials as $key => &$credential) { + $url = (string) ($remoteFiles->firstWhere('fileId', $credential['file_id'])['downloadUrl'] ?? ''); + if ($url === '') { + throw new RuntimeException('E 签宝未返回全部证件推送单,暂不能完成归档。'); + } + $bytes = $client->download($url); + if (! str_starts_with($bytes, '%PDF-')) { + throw new RuntimeException('E 签宝返回的证件推送单不是有效 PDF。'); + } + $credential['signed_path'] = $base . '/credentials/' . $key . '-signed.pdf'; + $credential['signed_sha256'] = hash('sha256', $bytes); + if (! $disk->put($credential['signed_path'], $bytes)) { + throw new RuntimeException('证件签署文件无法写入私有业务存储。'); + } + } + unset($credential); $archive = [ 'status' => CourierAgreementSignature::STATUS_ARCHIVED, 'signed_pdf_path' => $signedPath, @@ -259,10 +303,15 @@ final class CourierAgreementSignatureService 'updated_by' => $userId ?: (int) $signature->updated_by, ]; $archiveReason ??= '签署完成后自动归档'; - if (filled($archiveReason)) { + if (filled($archiveReason) || $credentials !== []) { try { $payload = $signature->payload ?? []; - $payload['archive_reason'] = mb_substr(trim($archiveReason), 0, 255); + if ($credentials !== []) { + $payload['credential_documents'] = $credentials; + } + if (filled($archiveReason)) { + $payload['archive_reason'] = mb_substr(trim($archiveReason), 0, 255); + } $archive['payload'] = $payload; } catch (Throwable) { // Preserve unreadable historical payloads; retiring an APP_KEY diff --git a/deploy/host-nginx-8442-dingtalk.conf b/deploy/host-nginx-8442-dingtalk.conf index 43b01e78..1cbddb53 100644 --- a/deploy/host-nginx-8442-dingtalk.conf +++ b/deploy/host-nginx-8442-dingtalk.conf @@ -7,7 +7,7 @@ server { ssl_session_cache shared:CAIWU_DOMAIN_SSL:2m; ssl_session_timeout 10m; ssl_protocols TLSv1.2 TLSv1.3; - client_max_body_size 12m; + client_max_body_size 64m; # The legacy registered HTTP callback must reach the browser's canonical # HTTPS origin before Laravel reads its session. Proxying it with a forged @@ -34,7 +34,7 @@ server { ssl_session_cache shared:CAIWU_DOMAIN_SSL:2m; ssl_session_timeout 10m; ssl_protocols TLSv1.2 TLSv1.3; - client_max_body_size 12m; + client_max_body_size 64m; location / { proxy_pass http://127.0.0.1:18088; proxy_http_version 1.1; diff --git a/deploy/php.ini b/deploy/php.ini index 4851b372..16757f47 100644 --- a/deploy/php.ini +++ b/deploy/php.ini @@ -1,6 +1,6 @@ memory_limit=512M max_execution_time=120 -post_max_size=12M +post_max_size=64M upload_max_filesize=10M date.timezone=Asia/Shanghai expose_php=Off diff --git a/docs/esign-agreement-integration.md b/docs/esign-agreement-integration.md index 446b5728..06900fbd 100644 --- a/docs/esign-agreement-integration.md +++ b/docs/esign-agreement-integration.md @@ -8,6 +8,12 @@ - 回调复用现有 E签宝验签入口,校验原始 Body、时间戳、AppId 和 HmacSHA256;协议回调按流程 ID 幂等更新并排队归档。 - 收款账户证明及账户变更中的开户名称按实际银行卡持卡人姓名填写,不要求与营业执照或公司全称一致;签署主体和公司全称仍单独维护。 - 签署完成后只归档文件,不自动启用收款账户;账户、生效日期和收款人仍须财务复核。 +- 证件补充默认关闭,仅适用于授权委托书与公司主体变更。收款账户变更没有证件上传入口。 +- 授权委托书按需发送三份独立 PDF:主文件、身份证正反面合页推送单、银行卡正反面合页推送单。主体变更按需增加一份独立营业执照盖章推送单,共四份。 +- 每张证件推送单仅有一处固定签署位置,分配给页面选定的证件签署方;营业执照要求企业盖章。主文件原有签署顺序保持不变。 +- 原始图片、推送单原文与已签文件存入私有业务存储;文件 ID、路径和 SHA-256 放在原任务的加密 payload 内,无需数据库迁移。 +- 同一流程中的文件按 fileId 匹配并分别回传归档;缺少任一推送单时不能标记归档成功,可通过现有同步或归档任务重试。任务行支持逐份预览和下载。 +- 证件单张 JPG / PNG 上限 10 MB,宽高上限 5000 像素。多图请求需要 PHP 与生产入口 Nginx 的请求总量上限均为 64 MB;更新入口配置前备份并核对现有配置,保留其他配置。 ## 配置步骤 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 6c3f7a87..dcde55a2 100644 --- a/resources/views/business/pages/courier-payables/agreement-signing.blade.php +++ b/resources/views/business/pages/courier-payables/agreement-signing.blade.php @@ -1511,6 +1511,13 @@ @if ($task->certificate_path) @endif + @foreach ($task->credentialDocuments() as $credentialKey => $credential) + 预览{{ $credential['name'] }} + {{ $credential['name'] }}原文 + @if (!empty($credential['signed_path'])) + {{ $credential['name'] }}已签署 + @endif + @endforeach
@csrf @method('DELETE') @@ -1567,7 +1574,7 @@ + @include('business.pages.courier-payables.credential-upload') +
回传归档
@@ -1950,6 +1959,7 @@ TPL-ACCOUNT-CERT-V2 · 蓝色=已填 红色=待填
+

收款帐户资料证明

TO {{ $agreementCompanyName }}:

diff --git a/resources/views/business/pages/courier-payables/credential-upload.blade.php b/resources/views/business/pages/courier-payables/credential-upload.blade.php new file mode 100644 index 00000000..a6a15d4e --- /dev/null +++ b/resources/views/business/pages/courier-payables/credential-upload.blade.php @@ -0,0 +1,101 @@ + + diff --git a/resources/views/business/pdf/courier-credential.blade.php b/resources/views/business/pdf/courier-credential.blade.php new file mode 100644 index 00000000..3b55a0a9 --- /dev/null +++ b/resources/views/business/pdf/courier-credential.blade.php @@ -0,0 +1,34 @@ + + + + + + + +

{{ $title }}

+
合作方:{{ $signature->courier_name }} 任务编号:{{ $signature->business_no }}
+ @foreach ($pictures as $picture) +
+
{{ $picture['label'] }}
+ {{ $picture['label'] }} +
+ @endforeach +
+
@if ($stampOnly)盖章确认@else签字 / 盖章确认@endif
+
本页资料一并确认,仅需{{ $stampOnly ? '盖章' : '签字或盖章' }}一次。
+
+ + diff --git a/tests/Feature/Business/CourierAgreementCredentialsTest.php b/tests/Feature/Business/CourierAgreementCredentialsTest.php new file mode 100644 index 00000000..61a9768e --- /dev/null +++ b/tests/Feature/Business/CourierAgreementCredentialsTest.php @@ -0,0 +1,236 @@ + 'base64:' . base64_encode(str_repeat('k', 32)), + 'services.esign.environment' => 'sandbox', 'services.esign.base_url' => 'https://smlopenapi.esign.test', + 'services.esign.app_id' => 'sandbox-test-id', 'services.esign.app_secret' => 'sandbox-test-secret']); + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + } + + public function test_documents_are_optional_and_account_change_rejects_credentials(): void + { + foreach (['authorization', 'entity-change', 'account-change'] as $type) { + $this->assertTrue(Validator::make([], CourierAgreementCredentials::rules($type, false))->passes()); + } + $this->assertTrue(Validator::make(['credential_required' => 1], CourierAgreementCredentials::rules('account-change', true))->fails()); + $this->assertTrue(Validator::make(['credential_required' => 1], CourierAgreementCredentials::rules('authorization', true))->fails()); + } + + public function test_page_renders_optional_uploads_and_live_sheet_preview(): void + { + $couriers = \Mockery::mock(\Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository::class); + $couriers->shouldReceive('names')->andReturn(['测试快递']); + $couriers->shouldReceive('signingAccountsByName')->andReturn(['测试快递' => '13800138000']); + $this->app->instance(\Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository::class, $couriers); + $suppliers = \Mockery::mock(\Modules\EcommerceFinance\Repositories\SupplierManagementMirrorRepository::class); + $suppliers->shouldReceive('signingOptions')->andReturn([]); + $this->app->instance(\Modules\EcommerceFinance\Repositories\SupplierManagementMirrorRepository::class, $suppliers); + $response = $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'courier-payables', 'page' => 'agreement-signing', 'agreement_view' => 'create', + ])); + $response->assertOk()->assertSee('enctype="multipart/form-data"', false) + ->assertSee('data-credential-preview', false)->assertSee('data-credential-required disabled', false) + ->assertSeeText('本次需要补充证件并签署留档'); + if ($qa = getenv('CAIWU_CREDENTIAL_QA')) { + file_put_contents($qa . '/page.html', $response->getContent()); + } + } + + public function test_authorization_sends_three_files_and_each_sheet_has_only_one_signature_field(): void + { + $this->fakeEsign(); + $response = $this->loginAs()->post(route('business.courier-agreement-signatures.store'), $this->payload('authorization')); + $response->assertRedirect()->assertSessionHasNoErrors(); + $signature = CourierAgreementSignature::query()->firstOrFail(); + $this->assertCount(2, $signature->credentialDocuments()); + foreach ($signature->credentialDocuments() as $document) { + $this->assertCount(2, $document['originals']); + Storage::disk('uploads')->assertExists($document['source_path']); + } + Http::assertSent(function (ClientRequest $request): bool { + if (! str_ends_with($request->url(), '/create-by-file')) return false; + $data = $request->data(); + $this->assertCount(3, $data['docs']); + $this->assertCount(3, $data['signers'][0]['signFields']); + $this->assertCount(1, $data['signers'][1]['signFields']); + foreach (array_slice($data['signers'][0]['signFields'], 1) as $field) { + $this->assertSame('1', $field['normalSignFieldConfig']['signFieldPosition']['positionPage']); + $this->assertFalse($field['normalSignFieldConfig']['freeMode']); + } + return true; + }); + // Reposting the same request must not create a second signing flow. + $this->loginAs()->post(route('business.courier-agreement-signatures.store'), $this->payload('authorization'))->assertRedirect(); + $this->assertCount(1, Http::recorded(fn (ClientRequest $request) => str_ends_with($request->url(), '/create-by-file'))); + } + + public function test_missing_back_side_stops_request_before_sending_files(): void + { + Http::fake(); + $payload = $this->payload('authorization'); + unset($payload['credential_images']['id_back']); + $this->withExceptionHandling()->loginAs()->post(route('business.courier-agreement-signatures.store'), $payload) + ->assertSessionHasErrors('credential_images.id_back'); + $this->assertDatabaseCount('business_courier_agreement_signatures', 0); + Http::assertNothingSent(); + } + + public function test_duplicate_processing_request_does_not_create_another_flow(): void + { + Http::fake(); + $payload = $this->payload('authorization'); + $key = 'agreement-' . hash('sha256', $this->company->id . '|' . $payload['idempotency_key']); + CourierAgreementSignature::create(['company_id' => $this->company->id, 'business_no' => 'AGR-IN-PROGRESS', + 'idempotency_key' => $key, 'document_type' => 'authorization', 'template_code' => 'TPL-AUTHORIZATION-V1', + 'template_version' => '1', 'source_file_name' => '授权委托书.pdf', 'status' => 'sending', + 'courier_name' => '测试快递', 'company_full_name' => $this->company->name, + 'organization_name' => '测试委托公司', 'payload' => [], 'environment' => 'sandbox', + 'source_pdf_path' => 'pending/source.pdf', 'source_pdf_sha256' => '', + 'created_by' => $this->user->id, 'updated_by' => $this->user->id]); + $this->loginAs()->post(route('business.courier-agreement-signatures.store'), $payload) + ->assertRedirect()->assertSessionHasNoErrors(); + Http::assertNothingSent(); + $this->assertDatabaseCount('business_courier_agreement_signatures', 1); + } + + public function test_personal_signer_cannot_be_assigned_license_stamp(): void + { + Http::fake(); + $payload = $this->payload('entity-change'); + $payload['entity_change_old_company_deregistered'] = 1; + $payload['credential_signer'] = 0; + $this->withExceptionHandling()->loginAs()->post(route('business.courier-agreement-signatures.store'), $payload) + ->assertSessionHasErrors('credential_signer'); + $this->assertDatabaseCount('business_courier_agreement_signatures', 0); + Http::assertNothingSent(); + } + + public function test_entity_change_sends_four_files_with_credentials_only_for_new_company(): void + { + $this->fakeEsign(); + $this->loginAs()->post(route('business.courier-agreement-signatures.store'), $this->payload('entity-change')) + ->assertRedirect()->assertSessionHasNoErrors(); + $signature = CourierAgreementSignature::query()->firstOrFail(); + $this->assertCount(3, $signature->credentialDocuments()); + $this->assertCount(1, $signature->credentialDocuments()['license']['originals']); + if ($qa = getenv('CAIWU_CREDENTIAL_QA')) { + foreach ($signature->credentialDocuments() as $key => $document) { + file_put_contents($qa . '/' . $key . '.pdf', Storage::disk('uploads')->get($document['source_path'])); + } + } + Http::assertSent(function (ClientRequest $request): bool { + if (! str_ends_with($request->url(), '/create-by-file')) return false; + $data = $request->data(); + $this->assertCount(4, $data['docs']); + $this->assertCount(1, $data['signers'][0]['signFields']); + $this->assertCount(4, $data['signers'][1]['signFields']); + $this->assertSame(1, $data['signers'][1]['signerType']); + return true; + }); + } + + public function test_archive_matches_file_ids_even_when_remote_files_are_out_of_order(): void + { + $this->fakeEsign(); + $this->loginAs()->post(route('business.courier-agreement-signatures.store'), $this->payload('authorization'))->assertRedirect(); + $signature = CourierAgreementSignature::query()->firstOrFail(); + $signature->update(['status' => 'completed', 'remote_status' => '2']); + $this->fakeDownloads($signature); + $archived = app(CourierAgreementSignatureService::class)->archive($signature); + $this->assertSame('archived', $archived->status); + $this->assertSame('%PDF-main', Storage::disk('uploads')->get($archived->signed_pdf_path)); + foreach ($archived->credentialDocuments() as $key => $document) { + $this->assertSame('%PDF-' . $key, Storage::disk('uploads')->get($document['signed_path'])); + $this->assertSame(hash('sha256', '%PDF-' . $key), $document['signed_sha256']); + $this->loginAs()->get(route('business.courier-agreement-signatures.download', [$archived, 'credential-' . $key . '-signed']))->assertOk(); + $preview = $this->loginAs()->get(route('business.courier-agreement-signatures.download', [$archived, 'credential-' . $key . '-signed', 'preview' => 1])); + $preview->assertOk(); + $this->assertStringStartsWith('inline;', $preview->headers->get('Content-Disposition')); + } + } + + public function test_missing_credential_download_prevents_marking_task_archived(): void + { + $this->fakeEsign(); + $this->loginAs()->post(route('business.courier-agreement-signatures.store'), $this->payload('authorization'))->assertRedirect(); + $signature = CourierAgreementSignature::query()->firstOrFail(); + $signature->update(['status' => 'completed', 'remote_status' => '2']); + $this->fakeDownloads($signature, true); + try { + app(CourierAgreementSignatureService::class)->archive($signature); + $this->fail('Missing credential must prevent successful archive'); + } catch (RuntimeException $e) { + $this->assertStringContainsString('全部证件', $e->getMessage()); + } + $this->assertSame('completed', $signature->fresh()->status); + $this->assertNull($signature->fresh()->archived_at); + } + + private function fakeEsign(): void + { + $counter = 0; + Http::fake(function (ClientRequest $request) use (&$counter) { + if (str_ends_with($request->url(), '/file-upload-url')) { + $id = 'file-' . ++$counter; + return Http::response(['code' => 0, 'data' => ['fileId' => $id, 'fileUploadUrl' => 'https://upload.esign.test/' . $id]]); + } + if (str_starts_with($request->url(), 'https://upload.esign.test/')) return Http::response('ok'); + if (preg_match('~/v3/files/file-\d+$~', $request->url())) return Http::response(['code' => 0, 'data' => ['status' => 2]]); + if (str_ends_with($request->url(), '/create-by-file')) return Http::response(['code' => 0, 'data' => ['signFlowId' => 'flow-credentials']]); + return Http::response([], 404); + }); + } + + private function fakeDownloads(CourierAgreementSignature $signature, bool $missing = false): void + { + Http::swap(new \Illuminate\Http\Client\Factory()); + $files = [['fileId' => $signature->esign_file_id, 'downloadUrl' => 'https://download.esign.test/main']]; + $responses = ['https://download.esign.test/main' => Http::response('%PDF-main')]; + foreach ($signature->credentialDocuments() as $key => $document) { + if (! $missing || $key !== 'bank') $files[] = ['fileId' => $document['file_id'], 'downloadUrl' => 'https://download.esign.test/' . $key]; + $responses['https://download.esign.test/' . $key] = Http::response('%PDF-' . $key); + } + $responses['https://smlopenapi.esign.test/v3/sign-flow/flow-credentials/file-download-url'] = Http::response(['code' => 0, 'data' => ['files' => array_reverse($files)]]); + Http::fake($responses); + } + + private function payload(string $type): array + { + $images = []; + foreach (CourierAgreementCredentials::definitions($type) as $definition) { + foreach ($definition['images'] as $key) $images[$key] = UploadedFile::fake()->image($key . '.png', 900, 550); + } + return ['partner_type' => 'courier', 'idempotency_key' => 'credentials-' . $type, + 'courier_name' => '测试快递', 'document_type' => $type, 'signing_subject' => $this->company->name, + 'company_full_name' => $this->company->name, 'signature_date' => now()->toDateString(), + 'signing_deadline' => now()->addDays(7)->toDateString(), 'esign_account' => '13800138000', + 'secondary_esign_account' => '13900139000', 'operator_name' => '测试甲方', 'secondary_operator_name' => '测试乙方', + 'counterparty_signer_type' => 'organization', 'secondary_signer_type' => 'person', 'archive_mode' => 'auto', + 'principal_name' => '测试委托公司', 'principal_id_number' => 'TEST-PRINCIPAL', + 'trustee_name' => '测试乙方', 'trustee_id_number' => 'TEST-TRUSTEE', 'authorization_items' => ['1'], + 'bill_counterparty' => '测试往来公司', 'loan_counterparty' => '测试往来公司', 'payment_counterparty' => '测试往来公司', + 'authorization_start_date' => now()->toDateString(), 'authorization_end_date' => now()->addYear()->toDateString(), + 'former_company_name' => '测试原公司', 'changed_company_name' => '测试新公司', + 'entity_change_date' => now()->toDateString(), 'changed_company_start_date' => now()->toDateString(), + 'credential_required' => 1, 'credential_signer' => $type === 'entity-change' ? 1 : 0, 'credential_images' => $images]; + } +}