'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); $this->assertSame('新北中通快递有限公司', $signature->organization_name); $this->assertSame('2026-09-21', $signature->payload['signature_date']); $this->assertSame('courier', $signature->payload['partner_type']); $this->assertSame('快递公司', $signature->partnerTypeLabel()); Storage::disk('uploads')->assertExists($signature->source_pdf_path); $this->assertSame('新北中通快递有限公司', $signature->payload['account_proof_account_name']); Http::assertSent(function (ClientRequest $request): bool { if ($request->url() !== 'https://smlopenapi.esign.test/v3/sign-flow/create-by-file') { return false; } return data_get($request->data(), 'signers.0.signerType') === 1 && data_get($request->data(), 'signers.0.orgSignerInfo.orgName') === '新北中通快递有限公司' && data_get($request->data(), 'signers.0.orgSignerInfo.transactorInfo.psnInfo.psnName') === '张经理'; }); $this->loginAs()->post(route('business.courier-agreement-signatures.store'), $payload)->assertRedirect(); Http::assertSentCount(4); $this->assertSame(1, CourierAgreementSignature::query()->count()); } public function test_authorized_business_user_can_push_without_becoming_an_administrator(): void { $this->user->syncRoles([Role::where('name', 'user')->firstOrFail()->id]); AgreementSigningPermissions::setGranted($this->user, true); $this->fakeEsign(); $this->loginAs()->post(route('business.courier-agreement-signatures.store'), $this->accountProofPayload('business-authorized')) ->assertRedirect(); $signature = CourierAgreementSignature::query()->firstOrFail(); $this->assertSame('flow-agreement-001', $signature->esign_sign_flow_id); $this->assertSame($this->user->id, $signature->created_by); $this->assertSame(['user'], $this->user->roles()->pluck('name')->all()); $this->assertFalse(AgreementSigningPermissions::canAdminister($this->user)); } public function test_revoked_signing_or_page_permission_blocks_push_before_any_esign_call(): void { $this->withExceptionHandling(); Http::preventStrayRequests(); $this->user->syncRoles([Role::where('name', 'user')->firstOrFail()->id]); $this->loginAs(); $url = route('business.courier-agreement-signatures.store'); $payload = $this->accountProofPayload('business-not-authorized'); $this->post($url, $payload)->assertForbidden(); AgreementSigningPermissions::setGranted($this->user, true); AgreementSigningPermissions::setGranted($this->user, false); $this->post($url, $payload)->assertForbidden(); AgreementSigningPermissions::setGranted($this->user, true); $subject = 'user_' . $this->user->id; setting()->set('system.sidebar_permissions', ['configured' => [$subject], 'subjects' => [$subject => []]]); $this->post($url, $payload)->assertForbidden(); $this->assertSame(0, CourierAgreementSignature::query()->count()); Http::assertNothingSent(); } public function test_supplier_requests_require_a_selected_supplier_before_sending(): void { Http::preventStrayRequests(); $this->withExceptionHandling(); $payload = $this->accountProofPayload('supplier-not-connected'); $payload['partner_type'] = 'supplier'; $this->loginAs()->post(route('business.courier-agreement-signatures.store'), $payload) ->assertSessionHasErrors('supplier_id'); $this->assertSame(0, CourierAgreementSignature::query()->count()); Http::assertNothingSent(); } public function test_supplier_request_uses_selected_supplier_and_its_signing_account(): void { $this->fakeEsign(); $repository = Mockery::mock(SupplierManagementMirrorRepository::class); $repository->shouldReceive('signingSupplier')->with(7)->once()->andReturn((object) [ 'Id' => 7, 'scmname' => '测试供应商', 'accountbankno' => '13900139000', ]); $this->app->instance(SupplierManagementMirrorRepository::class, $repository); $payload = $this->accountProofPayload('supplier-signing'); $payload['partner_type'] = 'supplier'; $payload['supplier_id'] = 7; $payload['courier_name'] = '伪造名称'; unset($payload['esign_account']); $this->loginAs()->post(route('business.courier-agreement-signatures.store'), $payload)->assertRedirect(); $signature = CourierAgreementSignature::query()->firstOrFail(); $this->assertSame('测试供应商', $signature->courier_name); $this->assertSame('13900139000', $signature->operator_account); $this->assertSame('supplier', $signature->payload['partner_type']); $this->assertSame(7, $signature->payload['supplier_id']); $this->assertSame(CourierAgreementSignature::STATUS_SIGNING, $signature->status); } public function test_invalid_supplier_and_courier_only_templates_never_send(): void { Http::preventStrayRequests(); $this->withExceptionHandling(); $repository = Mockery::mock(SupplierManagementMirrorRepository::class); $repository->shouldReceive('signingSupplier')->with(999)->once()->andReturnNull(); $this->app->instance(SupplierManagementMirrorRepository::class, $repository); $payload = $this->accountProofPayload('supplier-invalid'); $payload['partner_type'] = 'supplier'; $payload['supplier_id'] = 999; $this->loginAs()->post(route('business.courier-agreement-signatures.store'), $payload)->assertSessionHasErrors('supplier_id'); $payload['document_type'] = 'quote-confirmation'; $this->loginAs()->post(route('business.courier-agreement-signatures.store'), $payload)->assertSessionHasErrors('document_type'); $this->assertSame(0, CourierAgreementSignature::query()->count()); Http::assertNothingSent(); } public function test_supplier_cooperation_contract_without_a_template_never_sends(): void { Http::preventStrayRequests(); $this->withExceptionHandling(); $payload = $this->cooperationContractPayload('supplier-cooperation'); $payload['partner_type'] = 'supplier'; $payload['supplier_id'] = 7; $this->loginAs()->post(route('business.courier-agreement-signatures.store'), $payload) ->assertSessionHasErrors('supplier_contract_template'); $this->assertSame(0, CourierAgreementSignature::query()->count()); Http::assertNothingSent(); } /** @dataProvider supplierTemplateProvider */ public function test_supplier_contract_keeps_the_selected_template_and_archives_the_signed_file(string $template): void { $this->fakeEsign(); $payload = $this->supplierContractPayload($template); $this->loginAs()->post(route('business.courier-agreement-signatures.store'), $payload)->assertRedirect(); $signature = CourierAgreementSignature::query()->firstOrFail(); $metadata = SupplierCooperationTemplates::TEMPLATES[$template]; $this->assertSame($metadata['code'], $signature->template_code); $this->assertSame('1', $signature->template_version); $this->assertSame($metadata['file'], $signature->source_file_name); $this->assertSame($metadata['name'], $signature->documentLabel()); $this->assertSame($template, $signature->payload['supplier_contract_template']); $this->assertSame('SUP-TEST-001', $signature->payload['supplier_contract_fields']['number']); $this->assertSame('张经理', $signature->payload['supplier_contract_signer_name']); $source = Storage::disk('uploads')->get($signature->source_pdf_path); $this->assertStringStartsWith('%PDF-', $source); $this->assertSame(hash('sha256', $source), $signature->source_pdf_sha256); Http::assertSent(function (ClientRequest $request): bool { if ($request->url() !== 'https://smlopenapi.esign.test/v3/sign-flow/create-by-file') return false; $signers = $request->data()['signers'] ?? []; return count($signers) === 2 && data_get($signers, '0.orgSignerInfo.orgName') === $this->company->name && data_get($signers, '0.orgSignerInfo.transactorInfo.psnAccount') === '13900139000' && data_get($signers, '0.orgSignerInfo.transactorInfo.psnInfo.psnName') === '李经理' && data_get($signers, '1.orgSignerInfo.orgName') === '测试供应商有限公司' && data_get($signers, '1.orgSignerInfo.transactorInfo.psnAccount') === '13800138000' && data_get($signers, '1.orgSignerInfo.transactorInfo.psnInfo.psnName') === '张经理'; }); Http::swap(new \Illuminate\Http\Client\Factory()); Http::fake([ 'https://smlopenapi.esign.test/v3/sign-flow/flow-agreement-001/detail' => Http::response(['code' => 0, 'data' => ['signFlowStatus' => 2]]), 'https://smlopenapi.esign.test/v3/sign-flow/flow-agreement-001/file-download-url' => Http::response(['code' => 0, 'data' => ['files' => [['downloadUrl' => 'https://download.esign.test/supplier-signed']]]]), 'https://download.esign.test/supplier-signed' => Http::response('%PDF-supplier-signed'), ]); $archived = app(\App\Support\CourierAgreementSignatureService::class)->synchronize($signature, $this->user->id); $this->assertSame(CourierAgreementSignature::STATUS_ARCHIVED, $archived->status); $this->assertSame('%PDF-supplier-signed', Storage::disk('uploads')->get($archived->signed_pdf_path)); $this->assertSame($metadata['code'], $archived->template_code); $this->assertSame($source, Storage::disk('uploads')->get($archived->source_pdf_path)); } public static function supplierTemplateProvider(): array { return [['dropshipping'], ['consignment'], ['production']]; } public function test_supplier_contract_rejects_invalid_template_and_missing_contract_identity(): void { Http::preventStrayRequests(); $this->withExceptionHandling(); $payload = $this->cooperationContractPayload('invalid-supplier-contract'); $payload += ['partner_type' => 'supplier', 'supplier_id' => 7, 'supplier_contract_template' => '../other']; $this->loginAs()->post(route('business.courier-agreement-signatures.store'), $payload)->assertSessionHasErrors('supplier_contract_template'); $payload = $this->supplierContractPayload('dropshipping'); $payload['supplier_contract_fields']['number'] = ''; $payload['supplier_contract_fields']['location'] = ''; $this->post(route('business.courier-agreement-signatures.store'), $payload)->assertSessionHasErrors(['supplier_contract_fields.number', 'supplier_contract_fields.location']); $this->assertSame(0, CourierAgreementSignature::query()->count()); Http::assertNothingSent(); } public function test_supplier_contract_variables_are_escaped_and_cover_the_body_and_appendices(): void { $templates = app(SupplierCooperationTemplates::class); foreach (array_keys(SupplierCooperationTemplates::TEMPLATES) as $template) { $html = view('business.partials.supplier-cooperation-contract', [ 'templateKey' => $template, 'mode' => 'preview', 'payload' => ['cooperation_party_a_name' => '', 'cooperation_party_b_name' => '新供应商', 'supplier_contract_fields' => ['number' => 'SUP-NEW-001']], ])->render(); $this->assertStringContainsString('<script>', $html); $this->assertStringNotContainsString(''; $signature = new CourierAgreementSignature([ 'business_no' => 'AGR-FONT-REGRESSION', 'template_code' => 'TPL-COOPERATION-CONTRACT-V1', 'document_type' => 'cooperation-contract', 'payload' => $payload, ]); $html = view('business.pdf.courier-agreement', [ 'signature' => $signature, 'type' => $signature->document_type, 'title' => '服务协议', 'payload' => $payload, 'signatureDate' => '2026年10月10日', 'fontPath' => str_replace('\\', '/', resource_path('fonts/DroidSansFallback.ttf')), ])->render(); $original = new DOMDocument(); $original->loadHTML($html, LIBXML_NOWARNING | LIBXML_NOERROR); $pdf = app('dompdf.wrapper'); $document = null; $wrapper = \Mockery::mock($pdf); $wrapper->shouldReceive('output')->once()->andReturnUsing(function () use ($pdf, &$document): string { $document = clone $pdf->getDomPDF()->getDom(); return $pdf->output(); }); $this->app->instance('dompdf.wrapper', $wrapper); $wordBreakPattern = \Dompdf\FrameReflower\Text::$_wordbreak_pattern; $bytes = app(CourierAgreementPdf::class)->render($signature); $this->assertStringStartsWith('%PDF-', $bytes); $this->assertSame($wordBreakPattern, \Dompdf\FrameReflower\Text::$_wordbreak_pattern); $text = $document->getElementsByTagName('body')->item(0)->textContent; $this->assertSame( preg_replace('/\s+/u', '', $original->getElementsByTagName('body')->item(0)->textContent), preg_replace('/\s+/u', '', $text), ); $this->assertStringContainsString('4.6.1、针对甲方客服录入', $text); $this->assertStringContainsString('95%', $text); $this->assertStringContainsString('5000元/次', $text); $xpath = new DOMXPath($document); $parties = $xpath->query('//table[@class="contract-signatures__table"]//td'); $this->assertStringContainsString('签约地:常州市', $parties->item(0)->textContent); $this->assertStringContainsString('签约地:南京市 ', $parties->item(1)->textContent); $this->assertSame(0, $xpath->query('//body//script')->length); foreach ($xpath->query('//body//text()[not(ancestor::*[contains(concat(" ", normalize-space(@class), " "), " latin ")])]') as $node) { $this->assertDoesNotMatchRegularExpression('/[\x21-\x7E\x{2018}-\x{201F}]/u', $node->nodeValue); } } /** @dataProvider missingOrBlankSigningPlaces */ public function test_cooperation_contract_defaults_missing_or_blank_party_b_signing_place(?string $place): void { $this->fakeEsign(); $payload = $this->cooperationContractPayload('signing-place-default'); if ($place !== null) { $payload['cooperation_party_b_signing_place'] = $place; } $this->loginAs()->post(route('business.courier-agreement-signatures.store'), $payload)->assertRedirect(); $signature = CourierAgreementSignature::query()->firstOrFail(); $this->assertSame('常州市', $signature->payload['cooperation_party_b_signing_place']); // Old saved payloads also retain the previous location when rendered. $html = view('business.partials.cooperation-contract', [ 'mode' => 'pdf', 'payload' => [], 'signatureDate' => '待填写', 'mixedText' => fn ($value) => e($value), ])->render(); $this->assertSame(2, substr_count($html, '签约地:常州市')); } public static function missingOrBlankSigningPlaces(): array { return ['missing' => [null], 'empty' => [''], 'whitespace' => [' ']]; } public function test_cooperation_contract_uses_the_persons_real_name_instead_of_the_courier_name(): void { $this->fakeEsign(); $this->loginAs()->post(route('business.courier-agreement-signatures.store'), [ 'idempotency_key' => 'cooperation-person', 'courier_name' => '新北中通', 'document_type' => 'cooperation-contract', 'signing_subject' => $this->company->name, 'company_full_name' => $this->company->name, 'signature_date' => '2026-10-10', 'signing_deadline' => now()->addDays(7)->toDateString(), 'esign_account' => '13800138000', 'counterparty_signer_type' => 'person', 'secondary_operator_name' => '我方经办人', 'archive_mode' => 'auto', 'cooperation_party_a_name' => $this->company->name, 'cooperation_party_a_phone' => '0519-88886666', 'cooperation_party_b_name' => '张三', 'cooperation_party_b_phone' => '13800138000', 'cooperation_start_date' => '2026-10-10', 'cooperation_end_date' => '2027-10-09', ])->assertRedirect()->assertSessionHasNoErrors(); $signature = CourierAgreementSignature::query()->firstOrFail(); $this->assertSame('张三', $signature->operator_name); Http::assertSent(fn (ClientRequest $request): bool => str_ends_with($request->url(), '/v3/sign-flow/create-by-file') && count($request->data()['signers']) === 2 && data_get($request->data(), 'signers.0.orgSignerInfo.transactorInfo.psnInfo.psnName') === '我方经办人' && data_get($request->data(), 'signers.1.signerType') === 0 && data_get($request->data(), 'signers.1.psnSignerInfo.psnInfo.psnName') === '张三' && data_get($request->data(), 'signers.1.psnSignerInfo.psnAccount') === '13800138000' && data_get($request->data(), 'signers.1.orgSignerInfo') === null ); } public function test_registered_entity_change_rejects_missing_former_signer_real_name(): void { $this->fakeEsign(); $payload = [ 'idempotency_key' => 'entity-change-missing-former-signer-name', 'courier_name' => '新北中通', 'document_type' => 'entity-change', 'signing_subject' => $this->company->name, 'company_full_name' => $this->company->name, 'signature_date' => '2026-10-10', 'signing_deadline' => now()->addDays(7)->toDateString(), 'esign_account' => '13800138000', 'archive_mode' => 'auto', 'former_company_name' => '原公司有限公司', 'entity_change_date' => '2026-10-01', 'changed_company_name' => '新公司有限公司', 'changed_company_start_date' => '2026-10-10', 'secondary_esign_account' => '13900139000', 'secondary_operator_name' => '新法人', ]; $this->withExceptionHandling()->loginAs() ->from(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing', 'agreement_view' => 'create'])) ->post(route('business.courier-agreement-signatures.store'), $payload) ->assertSessionHasErrors('operator_name'); $this->assertSame(0, CourierAgreementSignature::query()->count()); } public static function authorizationSignerTypes(): array { return [['person', 'person'], ['organization', 'person'], ['person', 'organization'], ['organization', 'organization']]; } #[\PHPUnit\Framework\Attributes\DataProvider('authorizationSignerTypes')] public function test_authorization_supports_independent_person_and_enterprise_signers(string $principalType, string $trusteeType): void { $this->fakeEsign(); $this->loginAs()->post(route('business.courier-agreement-signatures.store'), [ 'idempotency_key' => 'authorization-' . $principalType . '-' . $trusteeType, 'courier_name' => '新北中通', 'document_type' => 'authorization', 'signing_subject' => $this->company->name, 'company_full_name' => $this->company->name, 'signature_date' => '2026-10-10', 'signing_deadline' => now()->addDays(7)->toDateString(), 'esign_account' => '13800138000', 'secondary_esign_account' => '13900139000', 'counterparty_signer_type' => $principalType, 'secondary_signer_type' => $trusteeType, 'operator_name' => $principalType === 'organization' ? '张三' : null, 'secondary_operator_name' => $trusteeType === 'organization' ? '李四' : null, 'archive_mode' => 'auto', 'principal_name' => $principalType === 'person' ? '张三' : '委托公司有限公司', 'principal_id_number' => 'TEST-PRINCIPAL-ID', 'trustee_name' => $trusteeType === 'person' ? '李四' : '受托公司有限公司', 'trustee_id_number' => 'TEST-TRUSTEE-ID', 'authorization_items' => ['1', '4'], 'bill_counterparty' => $this->company->name, 'loan_counterparty' => $this->company->name, 'payment_counterparty' => $this->company->name, 'authorization_start_date' => '2026-10-10', 'authorization_end_date' => '2027-10-09', ])->assertRedirect()->assertSessionHasNoErrors(); Http::assertSent(function (ClientRequest $request) use ($principalType, $trusteeType): bool { if (! str_ends_with($request->url(), '/v3/sign-flow/create-by-file')) { return false; } $signers = $request->data()['signers']; foreach ([[$principalType, '张三', '13800138000'], [$trusteeType, '李四', '13900139000']] as $index => [$type, $name, $account]) { $identityPath = $type === 'person' ? 'psnSignerInfo' : 'orgSignerInfo.transactorInfo'; if (data_get($signers, "$index.signerType") !== ($type === 'person' ? 0 : 1) || data_get($signers, "$index.$identityPath.psnInfo.psnName") !== $name || data_get($signers, "$index.$identityPath.psnAccount") !== $account || ($type === 'person' && data_get($signers, "$index.orgSignerInfo") !== null)) { return false; } } return count($signers) === 2; }); } public function test_cooperation_contract_requires_both_real_names_before_creating_a_task(): void { $this->fakeEsign(); foreach (['operator_name', 'secondary_operator_name'] as $field) { $payload = $this->cooperationContractPayload('missing-' . $field); $payload[$field] = ' '; $this->withExceptionHandling()->loginAs() ->post(route('business.courier-agreement-signatures.store'), $payload) ->assertSessionHasErrors($field); } $this->assertSame(0, CourierAgreementSignature::query()->count()); Http::assertNothingSent(); } public function test_cooperation_contract_rejects_company_names_as_personal_names(): void { $this->fakeEsign(); foreach (['operator_name' => 'cooperation_party_b_name', 'secondary_operator_name' => 'cooperation_party_a_name'] as $field => $companyField) { $payload = $this->cooperationContractPayload('company-' . $field); $payload[$field] = $payload[$companyField]; $this->withExceptionHandling()->loginAs() ->post(route('business.courier-agreement-signatures.store'), $payload) ->assertSessionHasErrors($field); } $this->assertSame(0, CourierAgreementSignature::query()->count()); Http::assertNothingSent(); } public function test_shared_contract_accepts_multiple_couriers_and_preserves_note_in_pdf(): void { $this->fakeEsign(); $this->loginAs() ->post(route('business.courier-agreement-signatures.store'), [ 'idempotency_key' => 'shared-contract-multiple-001', 'courier_name' => '新北中通', 'document_type' => 'shared-contract', 'signing_subject' => $this->company->name, 'company_full_name' => '常州电优闪供应链管理有限公司', 'signature_date' => '2026-09-21', 'signing_deadline' => now()->addDays(7)->toDateString(), 'esign_account' => '13800138000', 'operator_name' => '张经理', 'archive_mode' => 'auto', 'contract_partner_name' => '宿迁天枢物流科技有限公司', 'shared_courier_names' => ['常州圆通其他', '常州圆通-丁楼'], 'shared_note' => '请在3个工作日内完成签章。', ]) ->assertRedirect(); $signature = CourierAgreementSignature::query()->firstOrFail(); $this->assertSame(['常州圆通其他', '常州圆通-丁楼'], $signature->payload['shared_courier_names']); $this->assertSame('宿迁天枢物流科技有限公司', $signature->organization_name); $this->assertSame('organization', $signature->payload['counterparty_signer_type']); $this->assertSame('张经理', $signature->operator_name); $this->assertSame('常州圆通其他', $signature->payload['shared_courier_name']); $this->assertSame('请在3个工作日内完成签章。', $signature->payload['shared_note']); $pdf = Storage::disk('uploads')->get($signature->source_pdf_path); $this->assertStringStartsWith('%PDF-', $pdf); $html = view('business.pdf.courier-agreement', [ 'signature' => $signature, 'type' => $signature->document_type, 'title' => $signature->documentLabel(), 'payload' => $signature->payload, 'signatureDate' => '2026年9月21日', 'fontPath' => str_replace('\\', '/', resource_path('fonts/DroidSansFallback.ttf')), ])->render(); foreach (['常州电优闪供应链管理有限公司', '宿迁天枢物流科技有限公司', '常州圆通其他', '常州圆通-丁楼', '请在3个工作日内完成签章。'] as $text) { $this->assertStringContainsString($text, strip_tags($html)); } $this->assertStringContainsString('与 常州电优闪供应链管理有限公司 合作的', strip_tags($html)); $this->assertStringNotContainsString('与 宿迁天枢物流科技有限公司 合作的', strip_tags($html)); $this->assertStringContainsString('均归属于 宿迁天枢物流科技有限公司', strip_tags($html)); $this->assertStringContainsString('确认企业:宿迁天枢物流科技有限公司', strip_tags($html)); Http::assertSent(function (ClientRequest $request): bool { if ($request->url() !== 'https://smlopenapi.esign.test/v3/sign-flow/create-by-file') { return false; } return count($request->data()['signers'] ?? []) === 1 && data_get($request->data(), 'signers.0.signerType') === 1 && data_get($request->data(), 'signers.0.orgSignerInfo.orgName') === '宿迁天枢物流科技有限公司' && data_get($request->data(), 'signers.0.orgSignerInfo.transactorInfo.psnAccount') === '13800138000' && data_get($request->data(), 'signers.0.orgSignerInfo.transactorInfo.psnInfo.psnName') === '张经理'; }); } public function test_shared_contract_can_be_confirmed_by_a_person_without_an_enterprise_seal(): void { $this->fakeEsign(); $payload = [ 'idempotency_key' => 'shared-contract-person', 'courier_name' => '常州圆通其他', 'document_type' => 'shared-contract', 'signing_subject' => $this->company->name, 'company_full_name' => '常州电优闪供应链管理有限公司', 'signature_date' => '2026-09-21', 'signing_deadline' => now()->addDays(7)->toDateString(), 'esign_account' => '13800138000', 'archive_mode' => 'auto', 'counterparty_signer_type' => 'person', 'contract_partner_name' => '张三', 'shared_courier_names' => ['常州圆通其他', '常州圆通-丁楼'], ]; $this->loginAs()->post(route('business.courier-agreement-signatures.store'), $payload)->assertRedirect(); $signature = CourierAgreementSignature::query()->firstOrFail(); $this->assertSame('person', $signature->payload['counterparty_signer_type']); $this->assertSame('张三', $signature->organization_name); $this->assertSame('张三', $signature->operator_name); $this->assertSame($payload['shared_courier_names'], $signature->payload['shared_courier_names']); $this->assertStringStartsWith('%PDF-', Storage::disk('uploads')->get($signature->source_pdf_path)); $html = view('business.pdf.courier-agreement', [ 'signature' => $signature, 'type' => $signature->document_type, 'title' => $signature->documentLabel(), 'payload' => $signature->payload, 'signatureDate' => '2026年9月21日', 'fontPath' => str_replace('\\', '/', resource_path('fonts/DroidSansFallback.ttf')), ])->render(); $this->assertStringContainsString('与 常州电优闪供应链管理有限公司 合作的', strip_tags($html)); $this->assertStringContainsString('均归属于 张三', strip_tags($html)); $this->assertStringContainsString('确认人(个人签字):张三', strip_tags($html)); $this->assertStringNotContainsString('法人', $html); $this->assertStringNotContainsString('公司盖章', $html); Http::assertSent(function (ClientRequest $request): bool { return $request->url() === 'https://smlopenapi.esign.test/v3/sign-flow/create-by-file' && count($request->data()['signers'] ?? []) === 1 && data_get($request->data(), 'signers.0.signerType') === 0 && data_get($request->data(), 'signers.0.psnSignerInfo.psnAccount') === '13800138000' && data_get($request->data(), 'signers.0.psnSignerInfo.psnInfo.psnName') === '张三' && data_get($request->data(), 'signers.0.orgSignerInfo') === null; }); $this->post(route('business.courier-agreement-signatures.store'), $payload)->assertRedirect(); $this->assertDatabaseCount('business_courier_agreement_signatures', 1); Http::assertSentCount(4); } public function test_shared_contract_rejects_missing_real_name_without_creating_a_task_or_remote_request(): void { Http::fake(); $payload = [ 'idempotency_key' => 'shared-contract-missing-name', 'courier_name' => '快递公司乙', 'document_type' => 'shared-contract', 'signing_subject' => $this->company->name, 'company_full_name' => $this->company->name, 'signature_date' => '2026-09-21', 'signing_deadline' => now()->addDays(7)->toDateString(), 'esign_account' => '13800138000', 'archive_mode' => 'auto', 'contract_partner_name' => '合同主体有限公司', 'shared_courier_names' => ['快递公司乙', '快递公司丙'], ]; $this->withExceptionHandling()->loginAs() ->postJson(route('business.courier-agreement-signatures.store'), $payload) ->assertStatus(422) ->assertJsonValidationErrors('operator_name'); $payload['operator_name'] = ' '; $this->postJson(route('business.courier-agreement-signatures.store'), $payload) ->assertStatus(422) ->assertJsonValidationErrors('operator_name'); $payload['counterparty_signer_type'] = 'person'; unset($payload['operator_name']); $payload['contract_partner_name'] = ' '; $this->postJson(route('business.courier-agreement-signatures.store'), $payload) ->assertStatus(422) ->assertJsonValidationErrors('contract_partner_name'); $payload['contract_partner_name'] = '张三'; $payload['counterparty_signer_type'] = 'invalid'; $this->postJson(route('business.courier-agreement-signatures.store'), $payload) ->assertStatus(422) ->assertJsonValidationErrors('counterparty_signer_type'); $this->assertDatabaseCount('business_courier_agreement_signatures', 0); Http::assertNothingSent(); } public function test_quote_confirmation_binds_an_approved_version_and_uses_only_the_courier_signer(): void { $this->fakeEsign(); $version = CourierQuoteVersion::create([ 'company_id' => $this->company->id, 'version_code' => 'QB-CONF-001', 'source_quote_id' => 'QB-001', 'source_change_id' => CourierQuoteChange::create([ 'company_id' => $this->company->id, 'number' => 'BG-CONF-001', 'source_quote_id' => 'QB-001', 'source_sheet_name' => '新北中通报价', 'carrier_name' => '新北中通', 'quote_type' => '标准报价', 'change_type' => '面单调整', 'status' => CourierQuoteChange::STATUS_APPROVED, 'effective_date' => now()->addDay()->toDateString(), 'summary' => '报价确认单测试。', 'created_by' => $this->user->id, 'approved_by' => $this->user->id, 'approved_at' => now(), ])->id, 'sheet_name' => '新北中通报价', 'company_name' => $this->company->name, 'carrier_name' => '新北中通', 'quote_type' => '标准报价', 'effective_date' => now()->addDay()->toDateString(), 'values_json' => json_encode([ ['地区', '面单费', '计泡系数'], ['广东省', 6.5, 1.2], ], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), 'formulas_json' => json_encode([[], []], JSON_THROW_ON_ERROR), 'status' => 'approved', 'signature_status' => '未签署', 'created_by' => $this->user->id, 'approved_by' => $this->user->id, ]); $this->loginAs() ->get(route('business.courier-agreement-signatures.quotes.preview', $version)) ->assertOk() ->assertJsonPath('version_code', 'QB-CONF-001') ->assertJsonPath('carrier_name', '新北中通') ->assertJsonPath('face_fee', '6.5') ->assertJsonPath('bubble_coefficient', '1.2') ->assertJsonPath('values.1.0', '广东省'); $this->loginAs() ->post(route('business.courier-agreement-signatures.store'), [ 'idempotency_key' => 'quote-confirmation-001', 'courier_name' => '新北中通', 'document_type' => 'quote-confirmation', 'quote_version_id' => $version->id, 'signing_subject' => $this->company->name, 'company_full_name' => $this->company->name, 'signature_date' => '2026-09-21', 'signing_deadline' => now()->addDays(7)->toDateString(), 'esign_account' => '13800138000', 'operator_name' => '李经理', 'archive_mode' => 'auto', ]) ->assertRedirect(); $signature = CourierQuoteSignature::query()->firstOrFail(); $this->assertDatabaseCount('business_courier_agreement_signatures', 0); $this->assertSame($version->id, $signature->quote_version_id); $this->assertSame(CourierQuoteSignature::STATUS_SIGNING, $signature->status); $this->assertSame('待签署', $version->fresh()->signature_status); Storage::disk('uploads')->assertExists($signature->source_pdf_path); $this->assertStringStartsWith('%PDF-', Storage::disk('uploads')->get($signature->source_pdf_path)); Http::assertSent(function (ClientRequest $request): bool { if ($request->url() !== 'https://smlopenapi.esign.test/v3/sign-flow/create-by-file') { return false; } $signers = $request->data()['signers'] ?? []; return count($signers) === 1 && ($signers[0]['signerType'] ?? null) === 1 && ($signers[0]['orgSignerInfo']['orgName'] ?? null) === '新北中通' && ($signers[0]['orgSignerInfo']['transactorInfo']['psnAccount'] ?? null) === '13800138000'; }); } public function test_signature_date_is_required_for_every_signing_request(): void { $payload = $this->accountProofPayload('missing-signature-date'); unset($payload['signature_date']); $this->withExceptionHandling()->loginAs() ->post(route('business.courier-agreement-signatures.store'), $payload) ->assertSessionHasErrors('signature_date'); $this->assertDatabaseCount('business_courier_agreement_signatures', 0); } public function test_all_agreement_templates_render_the_selected_signature_date(): void { $payload = [ 'company_full_name' => $this->company->name, 'signature_date' => '2026-09-21', 'account_proof_effective_date' => '2026-09-01', 'account_proof_account_name' => '测试收款人', 'account_proof_bank_name' => '测试银行', 'account_proof_bank_account' => '6222000012345678', 'account_change_date' => '2026-09-01', 'old_account_name' => '旧账户', 'old_account_number' => '6222000011111111', 'old_account_stop_date' => '2026-09-01', 'new_account_effective_date' => '2026-09-02', 'new_account_name' => '新账户', 'new_bank_name' => '新银行', 'new_bank_account' => '6222000022222222', 'former_company_name' => '变更前公司', 'entity_change_date' => '2026-09-01', 'changed_company_name' => '变更后公司', 'changed_company_start_date' => '2026-09-02', 'contract_partner_name' => '快递公司甲', 'shared_courier_names' => ['快递公司乙'], 'cooperation_party_a_name' => '甲方公司', 'cooperation_party_a_phone' => '0519-88886666', 'cooperation_party_b_name' => '乙方公司', 'cooperation_party_b_phone' => '0519-88889999', 'cooperation_start_date' => '2026-09-01', 'cooperation_end_date' => '2027-08-31', 'principal_name' => '委托方', 'principal_id_number' => '91320400TEST', 'trustee_name' => '受托方', 'trustee_id_number' => '320400TEST', 'authorization_items' => ['1'], 'bill_counterparty' => '对账方', 'loan_counterparty' => '借支方', 'payment_counterparty' => '付款方', 'authorization_start_date' => '2026-09-01', 'authorization_end_date' => '2027-08-31', ]; foreach (['account-proof', 'account-change', 'entity-change', 'shared-contract', 'cooperation-contract', 'authorization'] as $type) { $signature = new CourierAgreementSignature([ 'business_no' => 'AGR-DATE-' . $type, 'template_code' => CourierAgreementSignature::DOCUMENTS[$type]['code'], 'document_type' => $type, ]); $html = view('business.pdf.courier-agreement', [ 'signature' => $signature, 'type' => $type, 'title' => CourierAgreementSignature::DOCUMENTS[$type]['name'], 'payload' => $payload, 'signatureDate' => '2026年9月21日', 'fontPath' => str_replace('\\', '/', resource_path('fonts/DroidSansFallback.ttf')), ])->render(); $this->assertStringContainsString('2026年9月21日', strip_tags($html), "{$type} 未输出签署日期变量"); $this->assertStringNotContainsString('签署后生成', $html, "{$type} 仍包含旧占位文案"); } } public function test_authorization_items_use_a_separate_number_column_in_the_pdf_template(): void { $signature = new CourierAgreementSignature([ 'business_no' => 'AGR-AUTHORIZATION-LAYOUT', 'template_code' => CourierAgreementSignature::DOCUMENTS['authorization']['code'], 'document_type' => 'authorization', ]); $html = view('business.pdf.courier-agreement', [ 'signature' => $signature, 'type' => 'authorization', 'title' => '授权委托书', 'payload' => [ 'principal_name' => '委托方', 'principal_id_number' => '91320400TEST', 'trustee_name' => '受托方', 'trustee_id_number' => '320400TEST', 'authorization_items' => ['1', '2', '3', '4'], 'bill_counterparty' => '对账方', 'loan_counterparty' => '借支方', 'payment_counterparty' => '付款方', 'authorization_start_date' => '2026-09-01', 'authorization_end_date' => '2027-08-31', ], 'signatureDate' => '2026年9月21日', 'fontPath' => str_replace('\\', '/', resource_path('fonts/DroidSansFallback.ttf')), ])->render(); $this->assertStringContainsString('class="authorization-list"', $html); $this->assertSame(4, substr_count($html, 'class="authorization-item"')); $this->assertStringContainsString('class="authorization-item__number latin">1.', $html); $this->assertStringContainsString('class="authorization-item__number latin">4.', $html); $this->assertStringNotContainsString(' $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); } public function test_polling_archives_mainland_agreement_without_completion_certificate(): void { $signature = CourierAgreementSignature::create([ 'company_id' => $this->company->id, 'business_no' => 'AGR-MAINLAND-001', 'idempotency_key' => 'mainland-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' => ['archive_mode' => 'auto'], 'environment' => 'sandbox', 'source_pdf_path' => 'courier-agreements/mainland/source.pdf', 'source_pdf_sha256' => hash('sha256', '%PDF-source'), 'esign_file_id' => 'file-agreement-mainland', 'esign_sign_flow_id' => 'flow-agreement-mainland', 'remote_status' => '1', 'created_by' => $this->user->id, 'updated_by' => $this->user->id, ]); Http::fake([ 'https://smlopenapi.esign.test/v3/sign-flow/flow-agreement-mainland/detail' => Http::response([ 'code' => 0, 'data' => ['signFlowStatus' => 2], ]), 'https://smlopenapi.esign.test/v3/sign-flow/flow-agreement-mainland/file-download-url' => Http::response([ 'code' => 0, 'data' => [ 'files' => [['downloadUrl' => 'https://download.esign.test/agreement-mainland']], 'certificateDownloadUrl' => null, ], ]), 'https://download.esign.test/agreement-mainland' => Http::response('%PDF-agreement-mainland'), ]); $result = app(\App\Support\CourierAgreementSignatureService::class) ->synchronize($signature, $this->user->id); $this->assertSame(CourierAgreementSignature::STATUS_ARCHIVED, $result->status); Storage::disk('uploads')->assertExists($result->signed_pdf_path); $this->assertNull($result->certificate_path); $this->assertNull($result->certificate_sha256); } public function test_source_download_uses_existing_local_history_file_without_remote_request(): void { Http::fake(); $bytes = '%PDF-local-history'; $signature = $this->agreementSignature([ 'source_pdf_sha256' => hash('sha256', $bytes), ]); Storage::disk('uploads')->put($signature->source_pdf_path, $bytes); $this->loginAs() ->get(route('business.courier-agreement-signatures.download', [$signature, 'source'])) ->assertOk() ->assertDownload(); Http::assertNothingSent(); } public function test_source_download_restores_missing_history_file_from_esign_after_hash_verification(): void { $bytes = '%PDF-remote-history'; $signature = $this->agreementSignature([ 'source_pdf_sha256' => hash('sha256', $bytes), 'esign_file_id' => 'file-history-001', ]); Http::fake([ 'https://smlopenapi.esign.test/v3/files/file-history-001' => Http::response([ 'code' => 0, 'data' => ['fileDownloadUrl' => 'https://download.esign.test/source-history-001'], ]), 'https://download.esign.test/source-history-001' => Http::response($bytes), ]); $this->loginAs() ->get(route('business.courier-agreement-signatures.download', [$signature, 'source'])) ->assertOk() ->assertDownload(); Storage::disk('uploads')->assertExists($signature->source_pdf_path); $this->assertSame($bytes, Storage::disk('uploads')->get($signature->source_pdf_path)); Http::assertSentCount(2); } public function test_source_download_rejects_remote_history_file_when_hash_does_not_match(): void { $signature = $this->agreementSignature([ 'source_pdf_sha256' => hash('sha256', '%PDF-expected-history'), 'esign_file_id' => 'file-history-mismatch', ]); Http::fake([ 'https://smlopenapi.esign.test/v3/files/file-history-mismatch' => Http::response([ 'code' => 0, 'data' => ['downloadUrl' => 'https://download.esign.test/source-history-mismatch'], ]), 'https://download.esign.test/source-history-mismatch' => Http::response('%PDF-different-history'), ]); $this->withExceptionHandling()->loginAs() ->get(route('business.courier-agreement-signatures.download', [$signature, 'source'])) ->assertRedirect(route('business.pages.show', [ 'section' => 'courier-payables', 'page' => 'agreement-signing', 'agreement_view' => 'tasks', ])) ->assertSessionHas('error', 'E 签宝历史原文件与系统留存摘要不一致,已停止自动恢复。'); Storage::disk('uploads')->assertMissing($signature->source_pdf_path); } public function test_source_download_explains_when_missing_history_was_never_uploaded(): void { $signature = $this->agreementSignature(); $this->withExceptionHandling()->loginAs() ->get(route('business.courier-agreement-signatures.download', [$signature, 'source'])) ->assertRedirect(route('business.pages.show', [ 'section' => 'courier-payables', 'page' => 'agreement-signing', 'agreement_view' => 'tasks', ])) ->assertSessionHas('error', '本地历史文件缺失,且该任务未成功上传至 E 签宝,无法自动恢复。'); } public function test_esign_business_error_is_saved_as_failed_and_preserves_uploaded_file_id(): 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-business-error', 'fileUploadUrl' => 'https://upload.esign.test/business-error'], ]), $request->url() === 'https://upload.esign.test/business-error' => Http::response(['ok' => true]), $request->url() === 'https://smlopenapi.esign.test/v3/files/file-business-error' => Http::response([ 'code' => 0, 'data' => ['status' => 2], ]), $request->url() === 'https://smlopenapi.esign.test/v3/sign-flow/create-by-file' => Http::response([ 'code' => 1435002, 'message' => '签署账号 18810726376 与身份信息不一致', ]), default => Http::response(['code' => 404], 404), }; }); $this->withExceptionHandling()->loginAs() ->post(route('business.courier-agreement-signatures.store'), $this->accountProofPayload('business-error')) ->assertSessionHasErrors('signature'); $signature = CourierAgreementSignature::query()->firstOrFail(); $this->assertSame(CourierAgreementSignature::STATUS_FAILED, $signature->status); $this->assertSame('file-business-error', $signature->esign_file_id); $this->assertStringContainsString('1435002', $signature->last_error); $this->assertStringContainsString('188****6376', $signature->last_error); $this->assertStringNotContainsString('18810726376', $signature->last_error); } public function test_server_error_during_flow_creation_keeps_unknown_status(): 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-server-error', 'fileUploadUrl' => 'https://upload.esign.test/server-error'], ]), $request->url() === 'https://upload.esign.test/server-error' => Http::response(['ok' => true]), $request->url() === 'https://smlopenapi.esign.test/v3/files/file-server-error' => Http::response([ 'code' => 0, 'data' => ['status' => 2], ]), $request->url() === 'https://smlopenapi.esign.test/v3/sign-flow/create-by-file' => Http::response([], 500), default => Http::response(['code' => 404], 404), }; }); $this->withExceptionHandling()->loginAs() ->post(route('business.courier-agreement-signatures.store'), $this->accountProofPayload('server-error')) ->assertSessionHasErrors('signature'); $signature = CourierAgreementSignature::query()->firstOrFail(); $this->assertSame(CourierAgreementSignature::STATUS_UNKNOWN, $signature->status); $this->assertSame('file-server-error', $signature->esign_file_id); } public function test_manual_archive_requires_a_valid_reason_and_other_requires_a_non_blank_note(): void { Http::fake(); $signature = $this->agreementSignature([ 'status' => CourierAgreementSignature::STATUS_COMPLETED, 'remote_status' => '2', 'esign_sign_flow_id' => 'flow-manual', 'payload' => ['archive_mode' => 'manual'], ]); foreach ([ [[], 'archive_reason_code'], [['archive_reason_code' => 'invalid'], 'archive_reason_code'], [['archive_reason_code' => 'other'], 'archive_note'], [['archive_reason_code' => 'other', 'archive_note' => ' '], 'archive_note'], [['archive_reason_code' => 'confirmed', 'archive_note' => str_repeat('字', 201)], 'archive_note'], ] as [$data, $field]) { $this->withExceptionHandling()->loginAs() ->post(route('business.courier-agreement-signatures.archive', $signature), $data) ->assertSessionHasErrors($field); $this->assertSame(CourierAgreementSignature::STATUS_COMPLETED, $signature->fresh()->status); } Http::assertNothingSent(); } public function test_manual_archive_persists_reason_and_keeps_all_files_downloadable(): void { $signature = $this->agreementSignature([ 'status' => CourierAgreementSignature::STATUS_COMPLETED, 'remote_status' => '2', 'esign_sign_flow_id' => 'flow-manual', 'payload' => ['archive_mode' => 'manual', 'signing_subject' => $this->company->name], ]); $this->fakeArchiveDownload(); Storage::disk('uploads')->put($signature->source_pdf_path, '%PDF-original'); $this->loginAs()->post(route('business.courier-agreement-signatures.archive', $signature), [ 'archive_reason_code' => 'confirmed', 'archive_note' => ' 已核对签章及账号 ', ])->assertRedirect(); $signature->refresh(); $this->assertSame(CourierAgreementSignature::STATUS_ARCHIVED, $signature->status); $this->assertSame('财务已确认:已核对签章及账号', $signature->payload['archive_reason']); $this->assertSame($this->company->name, $signature->payload['signing_subject']); $this->assertNotNull($signature->archived_at); $this->assertSame(hash('sha256', '%PDF-signed'), $signature->signed_pdf_sha256); $this->assertSame(hash('sha256', '%PDF-certificate'), $signature->certificate_sha256); foreach (['source', 'signed', 'certificate'] as $kind) { $this->get(route('business.courier-agreement-signatures.download', [$signature, $kind]))->assertOk()->assertDownload(); } $this->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing'])) ->assertOk()->assertDontSeeText($signature->business_no); $this->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing', 'agreement_scope' => 'archived'])) ->assertOk()->assertSeeText($signature->business_no)->assertSeeText('财务已确认:已核对签章及账号'); } public function test_manual_archive_preserves_unreadable_legacy_payload_and_still_archives_files(): void { $signature = $this->agreementSignature([ 'status' => CourierAgreementSignature::STATUS_COMPLETED, 'remote_status' => '2', 'esign_sign_flow_id' => 'flow-manual', ]); $signature->getConnection()->table($signature->getTable())->where('id', $signature->id) ->update(['payload' => 'unreadable-legacy-payload']); $this->fakeArchiveDownload(); $this->loginAs()->post(route('business.courier-agreement-signatures.archive', $signature), [ 'archive_reason_code' => 'backfill', ])->assertRedirect(); $signature->refresh(); $this->assertSame(CourierAgreementSignature::STATUS_ARCHIVED, $signature->status); $this->assertSame('unreadable-legacy-payload', $signature->getRawOriginal('payload')); $this->assertSame('', $signature->archiveReason()); Storage::disk('uploads')->assertExists($signature->signed_pdf_path); } public function test_manual_archive_rejects_an_incomplete_flow_before_downloading(): void { Http::fake(); $signature = $this->agreementSignature(['status' => CourierAgreementSignature::STATUS_SIGNING, 'remote_status' => '1']); $this->withExceptionHandling()->loginAs()->post(route('business.courier-agreement-signatures.archive', $signature), [ 'archive_reason_code' => 'completed', ])->assertStatus(422); Http::assertNothingSent(); } private function fakeArchiveDownload(): void { Http::fake([ 'https://smlopenapi.esign.test/v3/sign-flow/flow-manual/file-download-url' => Http::response([ 'code' => 0, 'data' => [ 'files' => [['downloadUrl' => 'https://download.esign.test/manual-signed']], 'certificateDownloadUrl' => 'https://download.esign.test/manual-certificate', ], ]), 'https://download.esign.test/manual-signed' => Http::response('%PDF-signed'), 'https://download.esign.test/manual-certificate' => Http::response('%PDF-certificate'), ]); } 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 supplierContractPayload(string $template): array { $repository = Mockery::mock(SupplierManagementMirrorRepository::class); $repository->shouldReceive('signingSupplier')->with(7)->once()->andReturn((object) [ 'Id' => 7, 'scmname' => '测试供应商', 'accountbankno' => '13800138000', ]); $this->app->instance(SupplierManagementMirrorRepository::class, $repository); return array_merge($this->cooperationContractPayload('supplier-' . $template), [ 'partner_type' => 'supplier', 'supplier_id' => 7, 'supplier_contract_template' => $template, 'cooperation_party_b_name' => '测试供应商有限公司', 'supplier_contract_fields' => ['number' => 'SUP-TEST-001', 'location' => '测试签订地', 'party_b_address' => '测试供应商地址'], ]); } 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, 'signature_date' => '2026-09-21', 'signing_deadline' => now()->addDays(7)->toDateString(), 'esign_account' => '13800138000', 'operator_name' => '张经理', 'counterparty_organization_name' => '新北中通快递有限公司', 'archive_mode' => 'auto', 'account_proof_effective_date' => now()->toDateString(), 'account_proof_account_name' => '新北中通快递有限公司', 'account_proof_bank_name' => '中国工商银行', 'account_proof_bank_account' => '6222000012345678', ]; } private function cooperationContractPayload(string $idempotencyKey): array { return [ 'idempotency_key' => $idempotencyKey, 'courier_name' => '新北中通', 'document_type' => 'cooperation-contract', 'signing_subject' => $this->company->name, 'company_full_name' => $this->company->name, 'signature_date' => '2026-09-21', 'signing_deadline' => now()->addDays(7)->toDateString(), 'esign_account' => '13800138000', 'secondary_esign_account' => '13900139000', 'operator_name' => '张经理', 'secondary_operator_name' => '李经理', 'archive_mode' => 'auto', 'cooperation_party_a_name' => $this->company->name, 'cooperation_party_a_phone' => '0519-88886666', 'cooperation_party_b_name' => '新北中通快递有限公司', 'cooperation_party_b_phone' => '18112896651', 'cooperation_start_date' => '2026-01-01', 'cooperation_end_date' => '2026-12-31', ]; } private function accountChangePayload(string $idempotencyKey): array { return [ 'idempotency_key' => $idempotencyKey, 'courier_name' => '新北中通', 'document_type' => 'account-change', 'signing_subject' => $this->company->name, 'company_full_name' => $this->company->name, 'signature_date' => '2026-09-21', 'signing_deadline' => now()->addDays(7)->toDateString(), 'esign_account' => '13800138000', 'operator_name' => '张经理', 'counterparty_organization_name' => '新北中通快递有限公司', 'archive_mode' => 'auto', 'account_change_date' => now()->toDateString(), 'old_account_name' => '新北中通快递有限公司', 'old_account_number' => '6222000012345678', 'old_account_stop_date' => now()->toDateString(), 'new_account_effective_date' => now()->addDay()->toDateString(), 'new_account_name' => '新北中通快递有限公司', 'new_bank_name' => '中国工商银行', 'new_bank_account' => '6222000098765432', ]; } private function agreementSignature(array $attributes = []): CourierAgreementSignature { return CourierAgreementSignature::create(array_merge([ 'company_id' => $this->company->id, 'business_no' => 'AGR-DOWNLOAD-HISTORY-001', 'idempotency_key' => 'download-history-001', 'document_type' => 'account-proof', 'template_code' => 'TPL-ACCOUNT-CERT-V2', 'template_version' => '2', 'source_file_name' => '收款账户证明文件.doc', 'status' => CourierAgreementSignature::STATUS_FAILED, 'courier_name' => '测试快递', 'company_full_name' => $this->company->name, 'organization_name' => '测试快递有限公司', 'operator_name' => '测试人员', 'operator_account' => '13800138000', 'payload' => ['signing_subject' => $this->company->name], 'environment' => 'sandbox', 'source_pdf_path' => 'courier-agreements/history/source.pdf', 'source_pdf_sha256' => hash('sha256', '%PDF-history-source'), 'created_by' => $this->user->id, 'updated_by' => $this->user->id, ], $attributes)); } }