user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); $this->mock(FinanceRoster::class, function ($mock): void { $mock->shouldReceive('findEligible')->andReturn(['display_name' => '测试审核人员'])->byDefault(); }); $this->bindReviewer($this->user); $person = ['dingtalk_user_id' => 'reviewer-' . $this->user->id, 'display_name' => $this->user->name]; DB::table('settings')->updateOrInsert(['company_id' => $this->company->id, 'key' => CourierOnboardingReviewers::KEY], [ 'value' => json_encode(['first' => [$person], 'final' => [$person]]), ]); } public function test_list_page_renders_and_invite_generates_qr_code(): void { $this->loginAs() ->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-onboarding'])) ->assertOk() ->assertSee('aria-label="快递入驻"', false) ->assertSeeText('生成入驻二维码') ->assertSeeText('入驻流程'); $this->withExceptionHandling()->loginAs() ->post(route('business.courier-onboardings.store'), ['invite_label' => '张经理']) ->assertSessionHasErrors('courier_name'); $response = $this->loginAs()->post(route('business.courier-onboardings.store'), [ ...self::profile(), 'courier_name' => '新北中通', 'workplatform_expcode' => 'xbzt', 'invite_label' => '新北中通 · 张经理', 'valid_days' => 15, ]); $onboarding = CourierOnboarding::query()->firstOrFail(); $response->assertRedirect(route('business.courier-onboardings.show', $onboarding)); $this->assertSame('新北中通', $onboarding->courier_name); // 系统快递名称不能和其他进行中的入驻重复。 $this->withExceptionHandling()->loginAs() ->post(route('business.courier-onboardings.store'), [...self::profile(), 'courier_name' => '新北中通', 'workplatform_expcode' => 'NEW1']) ->assertSessionHasErrors('courier_name'); // 快递公司代码必填、自动转大写、不能和其他入驻重复。 $this->assertSame('XBZT', $onboarding->workplatform_expcode); $this->withExceptionHandling()->loginAs() ->post(route('business.courier-onboardings.store'), [...self::profile(), 'courier_name' => '另一家']) ->assertSessionHasErrors('workplatform_expcode'); $this->withExceptionHandling()->loginAs() ->post(route('business.courier-onboardings.store'), [...self::profile(), 'courier_name' => '另一家', 'workplatform_expcode' => 'XBZT']) ->assertSessionHasErrors('workplatform_expcode'); $this->assertSame(CourierOnboarding::STATUS_INVITED, $onboarding->status); $this->assertSame(40, strlen($onboarding->token)); $this->assertMatchesRegularExpression('/^RZ-\d{8}-001$/', $onboarding->number); $this->assertTrue($onboarding->expires_at->between(now()->addDays(14), now()->addDays(16))); $this->loginAs()->get(route('business.courier-onboardings.show', $onboarding)) ->assertOk() ->assertSeeText('入驻二维码') ->assertSee(route('courier-onboarding.form', ['token' => $onboarding->token]), false); config(['courier_onboarding.public_url' => 'https://caiwu.example.cn:8442/']); $this->loginAs()->get(route('business.courier-onboardings.show', $onboarding)) ->assertSee('https://caiwu.example.cn:8442/onboarding/courier/' . $onboarding->token, false); $qr = $this->loginAs()->get(route('business.courier-onboardings.qr', $onboarding)); $qr->assertOk(); $this->assertStringStartsWith('image/svg+xml', (string) $qr->headers->get('Content-Type')); $this->assertStringContainsString('getContent()); } public function test_qr_encoder_builds_valid_structure_for_long_links(): void { $matrix = QrCode::matrix('https://caiwu.example.cn:8442/onboarding/courier/' . str_repeat('Ab3x', 10)); $size = count($matrix); $this->assertSame(0, ($size - 17) % 4); foreach ([[0, 0], [0, $size - 7], [$size - 7, 0]] as [$row, $column]) { $this->assertTrue($matrix[$row][$column], '定位图形外框应为黑色'); $this->assertTrue($matrix[$row + 3][$column + 3], '定位图形中心应为黑色'); $this->assertFalse($matrix[$row + 1][$column + 1], '定位图形内圈应为白色'); } } public function test_courier_fills_form_without_login_and_submits_for_review(): void { $onboarding = $this->invite(); $this->asGuest(); $this->get(route('courier-onboarding.form', ['token' => $onboarding->token])) ->assertOk() ->assertSeeText('快递合作入驻资料') ->assertSeeText('公司全称') ->assertSeeText('黑龙江') ->assertSee('name="files[business_license][]"', false); $this->withExceptionHandling() ->get(route('courier-onboarding.form', ['token' => str_repeat('x', 40)])) ->assertNotFound(); // 暂存允许资料不完整。 $this->post(route('courier-onboarding.update', ['token' => $onboarding->token]), [ 'action' => 'save', 'company_full_name' => '常州新北快递服务有限公司', ])->assertRedirect(route('courier-onboarding.form', ['token' => $onboarding->token])); $this->assertSame(CourierOnboarding::STATUS_INVITED, $onboarding->fresh()->status); // 提交时缺字段、缺附件会被拦下。 $this->withExceptionHandling() ->post(route('courier-onboarding.update', ['token' => $onboarding->token]), ['action' => 'submit']) ->assertSessionHasErrors(['unified_social_credit_code', 'contact_phone', 'esign_account', 'quote_rows', 'files.business_license', 'files.legal_id_card_front', 'files.bank_card_back']); $this->assertSame(CourierOnboarding::STATUS_INVITED, $onboarding->fresh()->status); $this->post(route('courier-onboarding.update', ['token' => $onboarding->token]), $this->completePayload()) ->assertSessionHasNoErrors() ->assertSessionHas('onboarding_success'); $onboarding->refresh(); $this->assertSame(CourierOnboarding::STATUS_SUBMITTED, $onboarding->status); $this->assertSame('91320411MA1XXXXX0Y', $onboarding->unified_social_credit_code); $this->assertSame('6222020000123456789', $onboarding->bank_account_number); $this->assertCount(2, $onboarding->quoteRowList()); $this->assertEquals(['region' => '江苏', 'flat' => [], 'base' => 3.2, 'rates' => [0.8]], $onboarding->quoteRowList()[0]); $this->assertSame(5, $onboarding->files()->count()); $this->assertSame('13800001111', $onboarding->esign_account); Storage::disk('uploads')->assertExists($onboarding->files()->first()->path); // 银行账号加密落库。 $raw = \DB::table('business_courier_onboardings')->where('id', $onboarding->id)->value('bank_account_number'); $this->assertNotSame('6222020000123456789', $raw); // 提交后不能再改。 $this->withExceptionHandling() ->post(route('courier-onboarding.update', ['token' => $onboarding->token]), $this->completePayload()) ->assertSessionHasErrors('form'); $this->get(route('courier-onboarding.form', ['token' => $onboarding->token])) ->assertOk() ->assertSeeText('资料已提交,审核中'); } public function test_return_lets_courier_fix_and_resubmit(): void { $onboarding = $this->submittedOnboarding(); $this->loginAs($this->user, $this->company) ->post(route('business.courier-onboardings.return', $onboarding), ['reason' => '开户行请写到支行']) ->assertRedirect(route('business.courier-onboardings.show', $onboarding)); $this->assertSame(CourierOnboarding::STATUS_RETURNED, $onboarding->fresh()->status); $this->asGuest(); $this->get(route('courier-onboarding.form', ['token' => $onboarding->token])) ->assertOk() ->assertSeeText('开户行请写到支行'); $this->post(route('courier-onboarding.update', ['token' => $onboarding->token]), ['bank_name' => '中国工商银行常州新北支行'] + $this->completePayload(withFiles: false)) ->assertSessionHasNoErrors(); $onboarding->refresh(); $this->assertSame(CourierOnboarding::STATUS_SUBMITTED, $onboarding->status); $this->assertSame('中国工商银行常州新北支行', $onboarding->bank_name); $this->assertNull($onboarding->return_reason); } public function test_two_level_review_creates_quote_version_and_activation_requires_signature(): void { $onboarding = $this->submittedOnboarding(); $this->user->roles()->sync([Role::where('name', 'admin')->firstOrFail()->id]); $this->loginAs($this->user, $this->company) ->post(route('business.courier-onboardings.first-approve', $onboarding), ['note' => '资料齐全']) ->assertRedirect(); $this->assertSame(CourierOnboarding::STATUS_FIRST_APPROVED, $onboarding->fresh()->status); // 初审人不能自己复审。 $this->withExceptionHandling()->loginAs($this->user, $this->company) ->post(route('business.courier-onboardings.final-approve', $onboarding), ['courier_name' => '新北中通']) ->assertSessionHasErrors('approval'); $reviewer = $this->createReviewer(); $this->fakeEsign(); $this->loginAs($reviewer, $this->company) ->get(route('business.courier-onboardings.show', $onboarding)) ->assertOk() ->assertSeeText('复审通过,生成首版报价'); $this->loginAs($reviewer, $this->company) ->post(route('business.courier-onboardings.final-approve', $onboarding), [ 'courier_name' => '新北中通-入驻', 'reconciler' => '李萌', ]) ->assertSessionHasNoErrors(); $onboarding->refresh(); $this->assertSame(CourierOnboarding::STATUS_APPROVED, $onboarding->status); $this->assertSame('新北中通-入驻', $onboarding->courier_name); $version = CourierQuoteVersion::query()->findOrFail($onboarding->quote_version_id); $this->assertSame('新北中通-入驻', $version->carrier_name); $this->assertSame('待签署', $version->signature_status); $this->assertMatchesRegularExpression('/^QB-\d{3}$/', $version->version_code); $values = json_decode($version->values_json, true); $headerIndex = collect($values)->search(static fn ($row) => ($row[0] ?? null) === '省份'); $this->assertSame(['省份', '面单', '费率(元/kg)'], $values[$headerIndex]); $this->assertSame(['江苏', 3.2, 0.8], $values[$headerIndex + 1]); $this->assertContains('计费方式:面单+重量*费率', array_column($values, 0)); $this->assertContains('计泡:长*宽*高/6000,取实重与体积重较大值', array_column($values, 0)); $change = CourierQuoteChange::query()->findOrFail($onboarding->quote_change_id); $this->assertSame(CourierQuoteChange::STATUS_APPROVED, $change->status); $this->assertSame('新快递入驻', $change->change_type); // 复审通过即用快递填的报价生成确认单 PDF,推到快递填写的 e签宝账号。 $quoteSignature = $version->fresh()->signature; $this->assertSame(CourierQuoteSignature::STATUS_SIGNING, $quoteSignature->status); $this->assertSame('flow-onboard', $quoteSignature->esign_sign_flow_id); Storage::disk('uploads')->assertExists($quoteSignature->source_pdf_path); Http::assertSent(static fn (ClientRequest $request): bool => $request->url() === 'https://smlopenapi.esign.test/v3/sign-flow/create-by-file' && data_get(json_decode($request->body(), true), 'signers.0.orgSignerInfo.orgName') === '常州新北快递服务有限公司' && str_contains($request->body(), '13800001111')); // 可以取签署链接直接发给快递,企业签署时按企业身份打开。 $this->loginAs($reviewer, $this->company) ->post(route('business.courier-onboardings.sign-url', $onboarding)) ->assertRedirect() ->assertSessionHas('quote_sign_url', 'https://t.esign.cn/abc'); Http::assertSent(static fn (ClientRequest $request): bool => $request->url() === 'https://smlopenapi.esign.test/v3/sign-flow/flow-onboard/sign-url' && data_get(json_decode($request->body(), true), 'organization.orgName') === '常州新北快递服务有限公司' && data_get(json_decode($request->body(), true), 'operator.psnAccount') === '13800001111'); $this->loginAs($reviewer, $this->company)->withSession(['quote_sign_url' => 'https://t.esign.cn/abc']) ->get(route('business.courier-onboardings.show', $onboarding)) ->assertOk() ->assertSee('value="https://t.esign.cn/abc"', false) ->assertSeeText('获取签署链接'); // 报价确认单还没签完,也没写线下说明,不能开通。 $this->withExceptionHandling()->loginAs($reviewer, $this->company) ->post(route('business.courier-onboardings.activate', $onboarding), []) ->assertSessionHasErrors('offline_note'); Storage::disk('uploads')->put('quote-signatures/test/signed.pdf', '%PDF-signed'); Storage::disk('uploads')->put('quote-signatures/test/certificate.pdf', '%PDF-cert'); $quoteSignature->update([ 'status' => CourierQuoteSignature::STATUS_ARCHIVED, 'signed_pdf_path' => 'quote-signatures/test/signed.pdf', 'certificate_path' => 'quote-signatures/test/certificate.pdf', 'archived_at' => now(), ]); $version->update(['signature_status' => '已签署并归档']); $this->loginAs($reviewer, $this->company) ->get(route('business.courier-onboardings.show', $onboarding)) ->assertOk() ->assertSeeText('报价确认单已签署归档') ->assertSeeText('回签单 PDF') ->assertSeeText('导出电子档'); // 回签单显示在报价管理 · 快递报价,可单个下载或打包导出电子档。 $quotes = app(\App\Support\QuoteManagementSnapshot::class)->load()['quotes']; $listed = collect($quotes)->firstWhere('id', $version->version_code); $this->assertNotNull($listed); $this->assertSame(route('business.courier-quote-signatures.download', [$quoteSignature, 'signed']), $listed['signed_files']['signed']); $this->assertArrayHasKey('certificate', $listed['signed_files']); $this->loginAs($reviewer, $this->company) ->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'quote-management'])) ->assertOk() ->assertSeeText('导出回签单'); $this->loginAs($reviewer, $this->company) ->get($listed['signed_files']['signed']) ->assertOk(); $zipResponse = $this->loginAs($reviewer, $this->company) ->get(route('business.courier-quote-signatures.export', ['codes' => $version->version_code])); $zipResponse->assertOk(); $zipFile = $zipResponse->baseResponse->getFile()->getPathname(); $zip = new \ZipArchive(); $this->assertTrue($zip->open($zipFile) === true); $names = []; for ($i = 0; $i < $zip->numFiles; $i++) { $names[] = $zip->getNameIndex($i); } $zip->close(); $this->assertCount(2, $names); $this->assertTrue(collect($names)->contains(fn ($name) => str_ends_with($name, '-回签单.pdf'))); $this->assertTrue(collect($names)->contains(fn ($name) => str_ends_with($name, '-签署完成证书.pdf'))); // 导出电子档:xlsx 只保留省份表头和价格行,不带标题、执行时间、计费说明。 $this->assertSame(route('business.courier-quote-signatures.sheet', $version), $listed['signed_files']['export']); $sheetResponse = $this->loginAs($reviewer, $this->company)->get($listed['signed_files']['export']); $sheetResponse->assertOk(); $this->assertStringContainsString('.xlsx', (string) $sheetResponse->headers->get('content-disposition')); $xlsxPath = tempnam(sys_get_temp_dir(), 'quote-sheet-') . '.xlsx'; file_put_contents($xlsxPath, $sheetResponse->streamedContent()); $rows = \PhpOffice\PhpSpreadsheet\IOFactory::load($xlsxPath)->getActiveSheet()->toArray(null, false, false, false); @unlink($xlsxPath); $this->assertSame(['省份', '面单', '费率(元/kg)'], $rows[0]); $this->assertSame(['江苏', 3.2, 0.8], $rows[1]); $flat = collect($rows)->flatten()->filter()->implode(' '); $this->assertStringNotContainsString('执行时间', $flat); $this->assertStringNotContainsString('计费方式', $flat); $this->assertStringNotContainsString('计泡', $flat); $this->loginAs($reviewer, $this->company) ->post(route('business.courier-onboardings.activate', $onboarding), []) ->assertSessionHasNoErrors(); $this->assertSame(CourierOnboarding::STATUS_ACTIVATED, $onboarding->fresh()->status); $this->loginAs($reviewer, $this->company) ->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-information', 'courier_tab' => 'onboarded'])) ->assertOk() ->assertSeeText('入驻快递') ->assertSeeText('新北中通-入驻') ->assertSeeText('常州新北快递服务有限公司'); } public function test_person_onboarding_uses_id_card_and_signs_as_individual(): void { $onboarding = $this->invite(); $this->asGuest(); $token = $onboarding->token; $this->get(route('courier-onboarding.form', ['token' => $token])) ->assertOk() ->assertSeeText('入驻类型') ->assertSeeText('身份证号'); $person = [ 'entity_type' => 'person', 'company_full_name' => '王小二', 'unified_social_credit_code' => '91320411MA1XXXXX0Y', 'bank_account_name' => '王小二', 'private_account_name' => '王小二', 'esign_operator_name' => '别人', ] + $this->completePayload(withFiles: false); // 个人缺身份证号、身份证照片时不能提交,但不要求营业执照。 $this->withExceptionHandling() ->post(route('courier-onboarding.update', ['token' => $token]), $person) ->assertSessionHasErrors(['id_number', 'files.legal_id_card_front', 'files.legal_id_card_back', 'files.bank_card_front', 'files.bank_card_back']) ->assertSessionDoesntHaveErrors(['files.business_license', 'unified_social_credit_code']); $this->post(route('courier-onboarding.update', ['token' => $token]), ['id_number' => '32041119900101123x', 'files' => [ 'legal_id_card_front' => [UploadedFile::fake()->image('id-front.jpg', 600, 400)], 'legal_id_card_back' => [UploadedFile::fake()->image('id-back.jpg', 600, 400)], 'bank_card_front' => [UploadedFile::fake()->image('card-front.jpg', 600, 400)], 'bank_card_back' => [UploadedFile::fake()->image('card-back.jpg', 600, 400)], ]] + $person)->assertSessionHasNoErrors(); $onboarding->refresh(); $this->assertSame(CourierOnboarding::STATUS_SUBMITTED, $onboarding->status); $this->assertTrue($onboarding->isPerson()); $this->assertSame('32041119900101123X', $onboarding->id_number); $this->assertNull($onboarding->unified_social_credit_code); $this->assertSame('王小二', $onboarding->esign_operator_name, '个人入驻由本人签署'); $this->assertNotSame('32041119900101123X', \DB::table('business_courier_onboardings')->where('id', $onboarding->id)->value('id_number')); $this->user->roles()->sync([Role::where('name', 'admin')->firstOrFail()->id]); $this->loginAs($this->user, $this->company)->post(route('business.courier-onboardings.first-approve', $onboarding)); $this->loginAs($this->user, $this->company)->get(route('business.courier-onboardings.show', $onboarding)) ->assertOk() ->assertSeeText('个人入驻') ->assertSeeText('320***********123X') ->assertSeeText('身份证 · 人像面'); $reviewer = $this->createReviewer(); $this->fakeEsign(); $this->loginAs($reviewer, $this->company) ->post(route('business.courier-onboardings.final-approve', $onboarding), ['courier_name' => '王小二快递']) ->assertSessionHasNoErrors(); Http::assertSent(static function (ClientRequest $request): bool { if ($request->url() !== 'https://smlopenapi.esign.test/v3/sign-flow/create-by-file') { return false; } $payload = json_decode($request->body(), true); return data_get($payload, 'signers.0.signerType') === 0 && data_get($payload, 'signers.0.psnSignerInfo.psnInfo.psnName') === '王小二' && data_get($payload, 'signers.0.psnSignerInfo.psnAccount') === '13800001111' && data_get($payload, 'signers.0.orgSignerInfo') === null; }); } public function test_new_link_starts_with_entity_chooser_and_reupload_replaces_photo(): void { $onboarding = $this->invite(); $this->asGuest(); $url = route('courier-onboarding.form', ['token' => $onboarding->token]); $this->get($url)->assertOk() ->assertSeeText('请选择入驻方式') ->assertSeeText('个人入驻') ->assertSee('data-entity="none"', false); $this->post(route('courier-onboarding.update', ['token' => $onboarding->token]), [ 'action' => 'save', 'entity_type' => 'person', 'company_full_name' => '王小二', 'files' => ['legal_id_card_front' => [UploadedFile::fake()->image('a.jpg')]], ])->assertSessionHasNoErrors(); $this->post(route('courier-onboarding.update', ['token' => $onboarding->token]), [ 'action' => 'save', 'entity_type' => 'person', 'company_full_name' => '王小二', 'files' => ['legal_id_card_front' => [UploadedFile::fake()->image('b.jpg')]], ])->assertSessionHasNoErrors(); $files = $onboarding->fresh()->files; $this->assertCount(1, $files, '同一项资料重新上传应替换旧照片'); $this->assertSame('b.jpg', $files->first()->original_name); $this->get($url)->assertOk()->assertSee('data-entity="person"', false); $this->get($url . '?too_large=1')->assertOk()->assertSeeText('图片太大,这次没有保存成功'); } public function test_same_reviewer_switch_allows_one_person_in_test_mode(): void { $onboarding = $this->submittedOnboarding(); $this->user->roles()->sync([Role::where('name', 'admin')->firstOrFail()->id]); $this->loginAs($this->user, $this->company)->post(route('business.courier-onboardings.first-approve', $onboarding)); config(['courier_onboarding.allow_same_reviewer' => true]); $this->loginAs($this->user, $this->company) ->get(route('business.courier-onboardings.show', $onboarding)) ->assertSeeText('复审通过,生成首版报价'); $this->loginAs($this->user, $this->company) ->post(route('business.courier-onboardings.final-approve', $onboarding), []) ->assertSessionHasNoErrors(); $this->assertSame(CourierOnboarding::STATUS_APPROVED, $onboarding->fresh()->status); $this->assertSame($onboarding->courier_name, $onboarding->fresh()->courier_name, '复审沿用生成二维码时定好的系统快递名称'); } public function test_quote_structures_cover_the_four_billing_modes(): void { $columns = static fn (array $input): array => array_column(CourierQuoteStructure::columns(CourierQuoteStructure::normalize($input)), 'label'); $this->assertSame(['面单', '费率 0-35kg', '费率 35-50kg', '费率 50kg以上'], $columns(['quote_mode' => 'waybill_rate', 'quote_rate_tiers' => '50,35'])); $this->assertSame(['首重5kg(元)', '续重(元/kg)'], $columns(['quote_mode' => 'first_additional', 'quote_first_weight' => '5'])); $this->assertSame(['0-3kg(含3) 一口价', '3-4kg(含4) 一口价', '4-5kg(含5) 一口价'], $columns(['quote_mode' => 'flat_bands', 'quote_flat_bands' => '3、4、5kg', 'quote_rate_tiers' => '30'])); $this->assertSame(['0-1kg(含1) 一口价', '1-2kg(含2) 一口价', '2-3kg(含3) 一口价', '3kg 以上 面单', '费率 3-30kg', '费率 30kg以上'], $columns(['quote_mode' => 'flat_then', 'quote_flat_bands' => '1,2,3', 'quote_then_mode' => 'waybill_rate', 'quote_rate_tiers' => '30'])); $this->assertSame(['0-3kg(含3) 一口价', '3kg 以上 首重3kg(元)', '续重(元/kg)'], $columns(['quote_mode' => 'flat_then', 'quote_flat_bands' => '3', 'quote_then_mode' => 'first_additional', 'quote_first_weight' => '3'])); $this->assertSame([], $columns(['quote_mode' => 'other'])); $structure = CourierQuoteStructure::normalize(['quote_mode' => 'flat_then', 'quote_flat_bands' => '3']); $rows = CourierQuoteStructure::normalizeRows([ ['region' => '江苏', 'flat' => ['3.8'], 'base' => '3.5', 'rates' => []], ['region' => '西藏', 'flat' => [''], 'base' => '', 'rates' => ['']], ], $structure); $this->assertCount(1, $rows, '整行留空的省份不保存'); $this->assertSame(['quote_rows' => '江苏还有价格没填:费率(元/kg)。不发的省份整行留空即可。'], CourierQuoteStructure::validate($structure, $rows)); $this->assertArrayHasKey('quote_flat_bands', CourierQuoteStructure::validate(CourierQuoteStructure::normalize(['quote_mode' => 'flat_bands']), [])); $this->assertArrayHasKey('quote_first_weight', CourierQuoteStructure::validate(CourierQuoteStructure::normalize(['quote_mode' => 'first_additional']), [])); } public function test_flat_band_quote_and_other_mode_with_quote_sheet(): void { $onboarding = $this->invite(); $this->asGuest(); $flat = [ 'quote_mode' => 'flat_bands', 'quote_flat_bands' => '3,6', 'quote_rows' => [['region' => '山东', 'flat' => ['6', '9']], ['region' => '河北', 'flat' => ['6.5', '']]], 'min_weight' => '1', 'weight_rounding' => 'ceil', 'per_ticket_surcharge' => '1', ] + $this->completePayload(); $this->withExceptionHandling() ->post(route('courier-onboarding.update', ['token' => $onboarding->token]), $flat) ->assertSessionHasErrors(['quote_rows' => '河北还有价格没填:3-6kg(含6) 一口价。不发的省份整行留空即可。']); $flat['quote_rows'][1]['flat'][1] = '10'; $this->post(route('courier-onboarding.update', ['token' => $onboarding->token]), $flat)->assertSessionHasNoErrors(); $onboarding->refresh(); $this->assertSame('flat_bands', $onboarding->quote_mode); $this->assertEquals([3, 6], $onboarding->quoteStructure()['flat_bands']); $this->assertContains('重量取整:向上取整(如 2.1kg 按 3kg)', $onboarding->quoteRuleLines()); $this->assertContains('每票加收:1元', $onboarding->quoteRuleLines()); $other = $this->invite(); $this->asGuest(); $payload = ['quote_mode' => 'other', 'quote_rows' => []] + $this->completePayload(); $this->withExceptionHandling() ->post(route('courier-onboarding.update', ['token' => $other->token]), $payload) ->assertSessionHasErrors('files.quote_sheet'); $payload['files']['quote_sheet'] = [UploadedFile::fake()->create('报价表.xlsx', 30, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')]; $this->post(route('courier-onboarding.update', ['token' => $other->token]), $payload)->assertSessionHasNoErrors(); $this->assertSame(CourierOnboarding::STATUS_SUBMITTED, $other->fresh()->status); } public function test_unassigned_user_cannot_final_review_and_activation_stays_admin_only(): void { $onboarding = $this->submittedOnboarding(); $onboarding->update(['status' => CourierOnboarding::STATUS_FIRST_APPROVED, 'first_reviewed_by' => 999]); $member = $this->createReviewer('manager'); $this->withExceptionHandling()->loginAs($member, $this->company) ->post(route('business.courier-onboardings.final-approve', $onboarding), ['courier_name' => '新北中通']) ->assertForbidden(); $this->assertSame(CourierOnboarding::STATUS_FIRST_APPROVED, $onboarding->fresh()->status); } public function test_cancelled_invitation_link_stops_working(): void { $onboarding = $this->invite(); $this->loginAs()->post(route('business.courier-onboardings.cancel', $onboarding))->assertRedirect(); $this->asGuest(); $this->withExceptionHandling() ->get(route('courier-onboarding.form', ['token' => $onboarding->token])) ->assertNotFound(); } public function test_expired_link_cannot_be_saved_until_extended(): void { $onboarding = $this->invite(); $onboarding->update(['expires_at' => now()->subDay()]); $this->asGuest(); $this->get(route('courier-onboarding.form', ['token' => $onboarding->token])) ->assertOk() ->assertSeeText('链接已过期'); $this->withExceptionHandling() ->post(route('courier-onboarding.update', ['token' => $onboarding->token]), ['action' => 'save']) ->assertSessionHasErrors('form'); $this->loginAs($this->user, $this->company)->post(route('business.courier-onboardings.extend', $onboarding))->assertRedirect(); $this->assertTrue($onboarding->fresh()->expires_at->isFuture()); } public function test_backend_requote_reviews_then_pushes_new_version_to_courier_esign(): void { $onboarding = $this->submittedOnboarding(); $this->user->roles()->sync([Role::where('name', 'admin')->firstOrFail()->id]); $reviewer = $this->createReviewer(); $this->fakeEsign(); $this->loginAs($this->user, $this->company) ->post(route('business.courier-onboardings.first-approve', $onboarding), ['note' => '资料齐全']); $this->loginAs($reviewer, $this->company) ->post(route('business.courier-onboardings.final-approve', $onboarding), ['courier_name' => '宿迁圆通']) ->assertSessionHasNoErrors(); $this->loginAs($reviewer, $this->company) ->post(route('business.courier-onboardings.activate', $onboarding), ['offline_note' => '测试开通']) ->assertSessionHasNoErrors(); $onboarding->refresh(); $firstVersion = $onboarding->quoteVersion; $this->assertSame(CourierOnboarding::STATUS_ACTIVATED, $onboarding->status); $this->assertSame('waybill_rate', CourierQuoteChange::query()->findOrFail($firstVersion->source_change_id)->quote_payload['quote_mode']); // 报价管理里点「变价」:已入驻快递直接链到后台变价页(旧的 create 链接也会转过去),底稿是当前报价。 $listed = collect(app(\App\Support\QuoteManagementSnapshot::class)->load()['quotes'])->firstWhere('id', $firstVersion->version_code); $this->assertSame(route('business.courier-onboardings.requote', $onboarding), $listed['requote_url']); $this->loginAs($this->user, $this->company) ->get(route('business.courier-quote-changes.create', ['source_quote_id' => $firstVersion->version_code])) ->assertRedirect(route('business.courier-onboardings.requote', $onboarding)); $this->loginAs($this->user, $this->company) ->get(route('business.courier-onboardings.requote', $onboarding)) ->assertOk() ->assertSeeText('宿迁圆通 · 变价') ->assertSeeText('底稿版本 ' . $firstVersion->version_code) ->assertDontSeeText('其他(上传报价表)') ->assertDontSee('name="company_full_name"', false) ->assertSee('"base":3.2', false); $quote = static fn (array $rows, array $extra = []): array => [ 'quote_mode' => 'waybill_rate', 'volumetric_rule' => '计泡', 'volumetric_divisor' => '6000', 'quote_effective_date' => now()->addDays(3)->toDateString(), 'quote_rows' => $rows, 'summary' => '11 月起江苏面单上调', ...$extra, ]; // 报价没变不能提交。 $this->withExceptionHandling()->loginAs($this->user, $this->company) ->post(route('business.courier-onboardings.requote.store', $onboarding), $quote([ ['region' => '江苏', 'base' => '3.2', 'rates' => ['0.8']], ['region' => '浙江', 'base' => '3.5', 'rates' => ['0.9']], ])) ->assertSessionHasErrors('quote_rows'); $this->loginAs($this->user, $this->company) ->post(route('business.courier-onboardings.requote.store', $onboarding), $quote([ ['region' => '江苏', 'base' => '3.5', 'rates' => ['0.8']], ['region' => '浙江', 'base' => '3.5', 'rates' => ['0.9']], ])) ->assertSessionHasNoErrors(); $change = CourierQuoteChange::query()->latest('id')->firstOrFail(); $this->assertSame(CourierQuoteChange::STATUS_PENDING_REVIEW, $change->status); $this->assertSame('变价', $change->change_type); $this->assertSame($firstVersion->version_code, $change->source_quote_id); $this->assertSame('宿迁圆通', $change->carrier_name); // 同时只能有一张待审核的变价单。 $this->loginAs($this->user, $this->company) ->get(route('business.courier-onboardings.requote', $onboarding)) ->assertRedirect(route('business.courier-quote-changes.show', $change)); $this->loginAs($reviewer, $this->company) ->get(route('business.courier-quote-changes.show', $change)) ->assertOk() ->assertSeeText('新旧报价对比 · 变价') ->assertSeeText('共 1 处价格变动') ->assertSeeText('+0.3') ->assertSeeText('新报价('); // 发起人不能自己审核;另一人审核通过后生成新版本并按入驻资料推送到快递 e签宝。 $this->withExceptionHandling()->loginAs($this->user, $this->company) ->post(route('business.courier-quote-changes.approve', $change)) ->assertSessionHasErrors('approval'); config(['courier_onboarding.allow_same_reviewer' => true]); $this->loginAs($this->user, $this->company) ->get(route('business.courier-quote-changes.show', $change)) ->assertSeeText('审核通过'); config(['courier_onboarding.allow_same_reviewer' => false]); $this->loginAs($reviewer, $this->company) ->post(route('business.courier-quote-changes.approve', $change)) ->assertSessionHasNoErrors() ->assertSessionHas('success', fn (string $message): bool => str_contains($message, '已推送到快递 e签宝')); $change->refresh(); $newVersion = $change->version; $this->assertNotNull($newVersion); $this->assertNotSame($firstVersion->version_code, $newVersion->version_code); $this->assertSame('宿迁圆通', $newVersion->company_name); $values = json_decode($newVersion->values_json, true); $header = collect($values)->search(static fn ($row) => ($row[0] ?? null) === '省份'); $this->assertSame(['江苏', 3.5, 0.8], $values[$header + 1]); $this->assertSame(CourierQuoteSignature::STATUS_SIGNING, $newVersion->signature->status); $this->assertSame('常州新北快递服务有限公司', $newVersion->signature->organization_name); $this->assertSame('新快递入驻报价', $newVersion->quote_type); $this->assertSame('快递变价报价', collect(app(\App\Support\QuoteManagementSnapshot::class)->load()['quotes'])->firstWhere('id', $newVersion->version_code)['quote_type']); $this->assertSame('新快递入驻报价', collect(app(\App\Support\QuoteManagementSnapshot::class)->load()['quotes'])->firstWhere('id', $firstVersion->version_code)['quote_type']); // 没有回调、没开轮询时,打开报价管理会顺手查询签署中的确认单(距上次查询超过 1 分钟)。 $this->travel(2)->minutes(); $before = $newVersion->signature->fresh()->last_synced_at; $this->loginAs($reviewer, $this->company) ->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'quote-management'])) ->assertOk(); $this->assertTrue($newVersion->signature->fresh()->last_synced_at->gt($before)); Http::assertSent(static fn (ClientRequest $request): bool => str_contains($request->url(), '/v3/sign-flow/' . $newVersion->signature->esign_sign_flow_id . '/detail')); $this->loginAs($reviewer, $this->company) ->get(route('business.courier-onboardings.show', $onboarding)) ->assertOk() ->assertSeeText('报价管理 · 快递报价(变价)') ->assertDontSeeText('发起变价'); // 两版都签完后:执行日期已到且最新的一版「生效中」,旧版「已失效」。版本文字状态滞后时以签署记录为准。 $firstVersion->signature()->update(['status' => CourierQuoteSignature::STATUS_ARCHIVED]); $firstVersion->update(['signature_status' => '已签署并归档', 'effective_date' => today()->subDays(10)]); $newVersion->signature()->update(['status' => CourierQuoteSignature::STATUS_ARCHIVED]); $newVersion->update(['signature_status' => '签署完成,待归档', 'effective_date' => today()]); $quotes = collect(app(\App\Support\QuoteManagementSnapshot::class)->load()['quotes'])->keyBy('id'); $this->assertSame('已失效', $quotes[$firstVersion->version_code]['status']); $this->assertSame($newVersion->version_code, $quotes[$firstVersion->version_code]['superseded_by']); $this->assertSame('生效中', $quotes[$newVersion->version_code]['status']); // 改计费方式:小重量一口价 + 超出另算,底稿是刚生成的新版本。 $this->loginAs($this->user, $this->company) ->post(route('business.courier-onboardings.requote.store', $onboarding), $quote([ ['region' => '江苏', 'flat' => ['3', '4', '5'], 'base' => '3.5', 'rates' => ['1']], ], ['quote_mode' => 'flat_then', 'quote_flat_bands' => '1,2,3', 'quote_then_mode' => 'waybill_rate', 'summary' => '改为三档一口价'])) ->assertSessionHasNoErrors(); $structureChange = CourierQuoteChange::query()->latest('id')->firstOrFail(); $this->assertSame('重新报价', $structureChange->change_type); $this->assertSame($newVersion->version_code, $structureChange->source_quote_id); $this->loginAs($reviewer, $this->company) ->get(route('business.courier-quote-changes.show', $structureChange)) ->assertOk() ->assertSeeText('计费方式调整') ->assertSeeText('不再报价:浙江') ->assertSeeText('0-1kg(含1) 一口价'); // 退回后在报价管理点「变价」继续改,更新同一张变价单。 $this->loginAs($reviewer, $this->company) ->post(route('business.courier-quote-changes.return', $structureChange), ['review_note' => '浙江也要报价']); $this->loginAs($this->user, $this->company) ->get(route('business.courier-onboardings.requote', $onboarding)) ->assertOk() ->assertSeeText('已被退回'); $this->loginAs($this->user, $this->company) ->post(route('business.courier-onboardings.requote.store', $onboarding), $quote([ ['region' => '江苏', 'flat' => ['3', '4', '5'], 'base' => '3.5', 'rates' => ['1']], ['region' => '浙江', 'flat' => ['3', '4', '5'], 'base' => '3.6', 'rates' => ['1']], ], ['quote_mode' => 'flat_then', 'quote_flat_bands' => '1,2,3', 'quote_then_mode' => 'waybill_rate', 'summary' => '改为三档一口价'])) ->assertSessionHasNoErrors(); $this->assertSame(CourierQuoteChange::STATUS_PENDING_REVIEW, $structureChange->fresh()->status); $this->assertSame($structureChange->id, CourierQuoteChange::query()->latest('id')->value('id')); } public function test_wrong_requote_is_flagged_in_review_and_can_be_voided(): void { $onboarding = $this->submittedOnboarding(); $this->user->roles()->sync([Role::where('name', 'admin')->firstOrFail()->id]); $reviewer = $this->createReviewer(); $this->fakeEsign(); $this->loginAs($this->user, $this->company)->post(route('business.courier-onboardings.first-approve', $onboarding), ['note' => 'ok']); $this->loginAs($reviewer, $this->company)->post(route('business.courier-onboardings.final-approve', $onboarding), ['courier_name' => '宿迁圆通']); $this->loginAs($reviewer, $this->company)->post(route('business.courier-onboardings.activate', $onboarding), ['offline_note' => '测试']); $onboarding->refresh(); $firstVersion = $onboarding->quoteVersion; $firstVersion->signature()->update(['status' => CourierQuoteSignature::STATUS_ARCHIVED]); $firstVersion->update(['signature_status' => '已签署并归档', 'effective_date' => today()->subDays(5)]); // 发错:面单多打一位、费率填 0、执行日期早于当前生效版本 → 审核页标红提醒。 $this->loginAs($this->user, $this->company) ->post(route('business.courier-onboardings.requote.store', $onboarding), [ 'quote_mode' => 'waybill_rate', 'volumetric_rule' => '计泡', 'volumetric_divisor' => '6000', 'quote_effective_date' => today()->subDays(10)->toDateString(), 'quote_rows' => [ ['region' => '江苏', 'base' => '32', 'rates' => ['0.8']], ['region' => '浙江', 'base' => '3.5', 'rates' => ['0']], ], 'summary' => '调价', ]) ->assertSessionHasNoErrors(); $change = CourierQuoteChange::query()->latest('id')->firstOrFail(); $this->loginAs($reviewer, $this->company) ->get(route('business.courier-quote-changes.show', $change)) ->assertOk() ->assertSeeText('审核提醒(请重点核对)') ->assertSeeText('早于当前生效版本 ' . $firstVersion->version_code) ->assertSeeText('江苏 面单 3.2→32') ->assertSeeText('价格为 0:浙江 费率(元/kg)'); // 审核人没注意通过了,快递还没签:作废会撤回 e签宝 待签任务,版本不再参与生效。 $this->loginAs($reviewer, $this->company)->post(route('business.courier-quote-changes.approve', $change))->assertSessionHasNoErrors(); $wrong = $change->fresh()->version; $this->assertSame(CourierQuoteSignature::STATUS_SIGNING, $wrong->signature->status); $this->withExceptionHandling()->loginAs($reviewer, $this->company) ->post(route('business.courier-quote-changes.void', $change), []) ->assertSessionHasErrors('void_reason'); $this->loginAs($reviewer, $this->company) ->post(route('business.courier-quote-changes.void', $change), ['void_reason' => '江苏面单多打了一位']) ->assertSessionHasNoErrors() ->assertSessionHas('success', fn (string $message): bool => str_contains($message, '已撤回快递 e签宝')); Http::assertSent(static fn (ClientRequest $request): bool => str_ends_with($request->url(), '/v3/sign-flow/' . $wrong->signature->esign_sign_flow_id . '/revoke')); $wrong->refresh(); $this->assertSame(CourierQuoteVersion::STATUS_VOIDED, $wrong->status); $this->assertSame(CourierQuoteSignature::STATUS_REVOKED, $wrong->signature->status); $quotes = collect(app(\App\Support\QuoteManagementSnapshot::class)->load()['quotes'])->keyBy('id'); $this->assertSame('已作废', $quotes[$wrong->version_code]['status']); $this->assertSame('江苏面单多打了一位', $quotes[$wrong->version_code]['void_reason']); $this->assertSame('生效中', $quotes[$firstVersion->version_code]['status']); $this->loginAs($reviewer, $this->company) ->get(route('business.courier-quote-changes.show', $change)) ->assertOk() ->assertSeeText($wrong->version_code . ' 已作废') ->assertDontSeeText('作废此版本'); // 作废后再变价,底稿回到仍有效的版本。 $this->loginAs($this->user, $this->company) ->get(route('business.courier-onboardings.requote', $onboarding)) ->assertOk() ->assertSeeText('底稿版本 ' . $firstVersion->version_code); // 已签完的版本也能作废(只停用,回签单保留)。 $this->loginAs($reviewer, $this->company) ->post(route('business.courier-quote-changes.void', $firstVersion->source_change_id), ['void_reason' => '测试已签作废']) ->assertSessionHas('success', fn (string $message): bool => str_contains($message, '回签单保留')); $this->assertSame(CourierQuoteVersion::STATUS_VOIDED, $firstVersion->fresh()->status); } public function test_company_needs_both_accounts_matching_license_and_person_corporate_is_optional(): void { $onboarding = $this->invite(); $this->asGuest(); $token = $onboarding->token; $this->get(route('courier-onboarding.form', ['token' => $token])) ->assertOk() ->assertSeeText('对公账户') ->assertSeeText('对私账户') ->assertSeeText('对私银行卡 · 正面'); // 公司入驻:对公、对私都必填。 $this->withExceptionHandling() ->post(route('courier-onboarding.update', ['token' => $token]), [ 'bank_account_name' => '', 'bank_name' => '', 'bank_account_number' => '', 'private_account_name' => '', 'private_bank_name' => '', 'private_account_number' => '', ] + $this->completePayload(withFiles: false)) ->assertSessionHasErrors(['bank_account_name', 'bank_account_number', 'private_account_name', 'private_account_number']); // 对公户名须等于公司全称(全角括号、空格不影响),对私收款人须等于法定代表人。 $this->withExceptionHandling() ->post(route('courier-onboarding.update', ['token' => $token]), [ 'company_full_name' => '常州新北快递服务有限公司(新北分部)', 'bank_account_name' => '常州新北物流有限公司', 'private_account_name' => '李四', ] + $this->completePayload(withFiles: false)) ->assertSessionHasErrors([ 'bank_account_name' => '对公账户户名「常州新北物流有限公司」与营业执照公司全称「常州新北快递服务有限公司(新北分部)」不一致,对公账户须为公司本身的账户。', 'private_account_name' => '对私收款人「李四」与营业执照法定代表人「王某」不一致,对私账户须为营业执照法定代表人本人的银行卡。', ]); $this->post(route('courier-onboarding.update', ['token' => $token]), [ 'company_full_name' => '常州新北快递服务有限公司(新北分部)', 'bank_account_name' => '常州新北快递服务有限公司 (新北分部)', ] + $this->completePayload()) ->assertSessionHasNoErrors(); $onboarding->refresh(); $this->assertSame(CourierOnboarding::STATUS_SUBMITTED, $onboarding->status); $this->assertSame('6228480000001111', $onboarding->private_account_number); $this->assertNotSame('6228480000001111', \DB::table('business_courier_onboardings')->where('id', $onboarding->id)->value('private_account_number')); $this->assertSame(['corporate' => true, 'private' => true], $onboarding->accountChecks()); $this->loginAs() ->get(route('business.courier-onboardings.show', $onboarding)) ->assertOk() ->assertSeeText('✓ 与营业执照公司全称一致') ->assertSeeText('✓ 与法定代表人一致') ->assertSeeText('6228'); // 个人入驻:对公账户选填,对私收款人须为本人。 $person = $this->invite(); $this->asGuest(); $payload = [ 'entity_type' => 'person', 'company_full_name' => '王小二', 'id_number' => '32041119900101123X', 'bank_account_name' => '', 'bank_name' => '', 'bank_account_number' => '', 'private_account_name' => '王某', ] + $this->completePayload(); $this->withExceptionHandling() ->post(route('courier-onboarding.update', ['token' => $person->token]), $payload) ->assertSessionHasErrors(['private_account_name']) ->assertSessionDoesntHaveErrors(['bank_account_name', 'bank_account_number']); $this->post(route('courier-onboarding.update', ['token' => $person->token]), ['private_account_name' => '王小二'] + $payload) ->assertSessionHasNoErrors(); $this->assertSame(CourierOnboarding::STATUS_SUBMITTED, $person->fresh()->status); } public function test_courier_overview_merges_workplatform_and_onboarding_rows(): void { $make = function (string $name, array $extra = []): CourierOnboarding { return CourierOnboarding::query()->forceCreate([ 'company_id' => $this->company->id, 'token' => \Illuminate\Support\Str::random(40), 'number' => 'RZ-TEST-' . uniqid(), 'status' => CourierOnboarding::STATUS_ACTIVATED, 'entity_type' => 'company', 'courier_name' => $name, 'company_full_name' => $name . '有限公司', 'created_by' => $this->user->id, 'updated_by' => $this->user->id, ...$extra, ]); }; $matched = $make('宿迁圆通', ['bank_account_name' => '宿迁圆通有限公司', 'bank_account_number' => '6222020000123456789', 'private_account_name' => '王某', 'private_account_number' => '6228480000001111']); $byCode = $make('韵达-新', ['workplatform_expcode' => 'YD02', 'private_account_name' => '李四', 'private_account_number' => '6200000000009999']); $make('新快递'); $mirror = \Mockery::mock(\Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository::class)->shouldIgnoreMissing(); $mirror->shouldReceive('relatedAvailability')->andReturn([]); $mirror->shouldReceive('overviewSource')->andReturn([ 'express' => [ (object) ['Id' => 1, 'expcode' => 'ZT01', 'expname' => '宿迁圆通', 'status' => '1', 'is_delete' => '0'], (object) ['Id' => 2, 'expcode' => 'YD02', 'expname' => '常州韵达', 'status' => '1', 'is_delete' => '0'], (object) ['Id' => 3, 'expcode' => 'JT03', 'expname' => '老极兔', 'status' => '0', 'is_delete' => '0'], ], 'accounts' => [ 'ZT01' => [ (object) ['account' => '6222 0200 0012 3456 789', 'accountName' => '宿迁圆通有限公司', 'erpAccountId' => 'E1'], (object) ['account' => '6228480000001111', 'accountName' => '王某', 'erpAccountId' => 'E2'], ], 'YD02' => [(object) ['account' => '6200000000001234', 'accountName' => '张三', 'erpAccountId' => 'E3']], ], ]); app()->instance(\Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository::class, $mirror); $rows = collect(app(\App\Business\CourierOverviewData::class)->rows()['rows'])->keyBy('name'); $this->assertSame('both', $rows['宿迁圆通']['source']); $this->assertSame('match', $rows['宿迁圆通']['account_status']); $this->assertSame('name', $rows['宿迁圆通']['linked_by']); $this->assertSame($matched->id, $rows['宿迁圆通']['onboarding']->id); $this->assertSame('mismatch', $rows['常州韵达']['account_status']); $this->assertSame('code', $rows['常州韵达']['linked_by']); $this->assertSame($byCode->id, $rows['常州韵达']['onboarding']->id); $this->assertSame('platform', $rows['老极兔']['source']); $this->assertSame('onboarding', $rows['新快递']['source']); $this->assertSame('pending', $rows['新快递']['erp_status']); $this->assertSame('opened', $rows['宿迁圆通']['erp_status']); $this->assertSame('E1 / E2', $rows['宿迁圆通']['erp_account_id']); // 钉钉流程同步不到时,在入驻详情手动补录 ERP 开通信息。 $this->loginAs() ->post(route('business.courier-onboardings.erp-opening', CourierOnboarding::query()->where('courier_name', '新快递')->firstOrFail()), ['workplatform_expcode' => 'XKD', 'erp_account_id' => 'E9']) ->assertSessionHasNoErrors(); $fresh = CourierOnboarding::query()->where('courier_name', '新快递')->firstOrFail(); $this->assertFalse($fresh->erpPending()); $this->assertSame('manual', $fresh->erp_opened_source); $this->loginAs()->get(route('business.courier-onboardings.show', $fresh))->assertOk()->assertSeeText('ERP 开通')->assertSeeText('手动补录'); $fresh->update(['workplatform_expcode' => null, 'erp_opened_source' => null, 'erp_account_id' => null, 'erp_opened_at' => null]); $this->assertNotEmpty($rows['新快递']['missing_files']); // 快递资料只分四类;对公/对私收款信息不在主表,挪到「打款信息」里完整显示。 $this->loginAs() ->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-information', 'courier_tab' => 'overview'])) ->assertOk() ->assertDontSeeText('快递总览') ->assertDontSee('>对私账户', false) ->assertSeeText('网点') ->assertSeeText('打款信息') ->assertSeeText('操作日志'); $this->loginAs() ->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-information', 'courier_tab' => 'accounts'])) ->assertOk() ->assertSeeText('6228480000001111') ->assertSeeText('6222020000123456789') ->assertSeeText('宿迁圆通有限公司') ->assertSeeText('王某'); // 「快递资料」默认页:工作平台列在左,入驻信息接在右;还没进工作平台的入驻快递排在最前面。 $this->loginAs() ->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-information'])) ->assertOk() ->assertSeeText('入驻单号') ->assertSeeText('ERP 开通') ->assertSeeText('新快递') ->assertSeeText('待 ERP 开通'); // 合同起止取协议签署里已签署的合作合同。 CourierAgreementSignature::query()->forceCreate([ 'company_id' => $this->company->id, 'business_no' => 'XY-HT-1', 'idempotency_key' => uniqid('idem', true), 'document_type' => 'cooperation-contract', 'template_code' => 'test', 'source_file_name' => 'contract.pdf', 'status' => CourierAgreementSignature::STATUS_ARCHIVED, 'courier_name' => '新快递', 'company_full_name' => '新快递有限公司', 'organization_name' => '新快递有限公司', 'payload' => ['cooperation_start_date' => '2026-11-01', 'cooperation_end_date' => '2027-10-31'], 'environment' => 'sandbox', 'source_pdf_path' => 'x.pdf', 'source_pdf_sha256' => str_repeat('a', 64), 'created_by' => $this->user->id, 'updated_by' => $this->user->id, ]); $append = app(\App\Business\CourierOverviewData::class)->expressAppend([ (object) ['Id' => 1, 'expcode' => 'ZT01', 'expname' => '宿迁圆通'], (object) ['Id' => 2, 'expcode' => 'YD02', 'expname' => '常州韵达'], ], 1); $this->assertSame($matched->id, $append['matches']['1']['onboarding']->id); $this->assertSame($byCode->id, $append['matches']['2']['onboarding']->id); $this->assertSame(['新快递'], collect($append['extra'])->map(fn ($info) => $info['onboarding']->courier_name)->all()); $this->assertSame('2026-11-01', $append['extra'][0]['contract_start']); $this->assertSame('2027-10-31', $append['extra'][0]['contract_end']); // 只在工作平台的老快递:发码补资料,生成入驻时带上快递代码。 $this->loginAs() ->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-onboarding', 'courier_name' => '老极兔', 'expcode' => 'JT03'])) ->assertOk() ->assertSee('value="老极兔"', false) ->assertSee('value="JT03"', false); $this->loginAs()->post(route('business.courier-onboardings.store'), [...self::profile(), 'courier_name' => '老极兔', 'workplatform_expcode' => 'JT03']); $this->assertSame('JT03', CourierOnboarding::query()->latest('id')->value('workplatform_expcode')); } public function test_courier_profile_edit_and_payment_account_change_jump_to_agreement_signing(): void { $onboarding = CourierOnboarding::query()->forceCreate([ 'company_id' => $this->company->id, 'token' => \Illuminate\Support\Str::random(40), 'number' => 'RZ-TEST-PA', 'status' => CourierOnboarding::STATUS_ACTIVATED, 'entity_type' => 'company', 'courier_name' => '新快递九', 'company_full_name' => '新快递九有限公司', 'workplatform_expcode' => 'XKD9', 'contact_name' => '张三', 'esign_account' => '13800000009', 'private_account_name' => '李四', 'private_bank_name' => '农业银行', 'private_account_number' => '6228480000009999', 'created_by' => $this->user->id, 'updated_by' => $this->user->id, ]); $mirror = \Mockery::mock(\Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository::class)->shouldIgnoreMissing(); $mirror->shouldReceive('relatedAvailability')->andReturn([]); app()->instance(\Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository::class, $mirror); $page = fn (array $query = []) => route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-information', ...$query]); // 点开主信息:下方看打款信息(完整账号)、网点、操作日志。 $this->loginAs()->get($page(['selected' => 'XKD9'])) ->assertOk() ->assertSeeText('打款信息新增') ->assertSeeText('打款信息变更') ->assertSeeText('6228480000009999') ->assertSee('is-selected', false); $this->loginAs()->get($page(['selected' => 'XKD9', 'edit' => 1]))->assertOk()->assertSeeText('编辑快递资料'); // 编辑:存财务系统覆盖值,主表显示新值,并记操作日志。 $this->loginAs()->post(route('business.courier-profiles.update', 'XKD9'), ['expname' => '新快递九', 'linker' => '赵六', 'status' => '1', 'kuaiyunsgn' => '0']) ->assertSessionHasNoErrors() ->assertRedirect(); $this->assertSame(['linker' => '赵六'], \App\Models\Business\CourierProfile::query()->where('expcode', 'XKD9')->value('overrides')); $this->loginAs()->get($page(['selected' => 'XKD9', 'detail_tab' => 'logs'])) ->assertOk() ->assertSeeText('赵六') ->assertSee('is-overridden', false) ->assertSeeText('联系人:张三 → 赵六'); // 打款信息新增:保存后跳到协议签署,预填收款账户证明。 $this->withExceptionHandling()->loginAs() ->post(route('business.courier-profiles.payment-accounts.store', 'XKD9'), ['form' => 'change', 'action' => 'change', 'account_type' => '对私', 'account_name' => '王五', 'bank_name' => '工行', 'account_number' => '6222000011112222', 'effective_date' => '2026-10-20']) ->assertSessionHasErrors(['old_account_name', 'old_account_number']); $response = $this->loginAs()->post(route('business.courier-profiles.payment-accounts.store', 'XKD9'), [ 'form' => 'add', 'action' => 'add', 'account_type' => '对公', 'account_name' => '新快递九有限公司', 'bank_name' => '工商银行宿迁分行', 'account_number' => '6222 0000 1111 2222', 'effective_date' => '2026-10-20', ]); $record = \App\Models\Business\CourierPaymentAccount::query()->latest('id')->firstOrFail(); $response->assertRedirect(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing', 'agreement_view' => 'create', 'payment_account' => $record->id])); $this->assertSame('6222000011112222', $record->account_number); $this->assertNotSame('6222000011112222', \Illuminate\Support\Facades\DB::table('business_courier_payment_accounts')->value('account_number')); $this->loginAs()->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing', 'agreement_view' => 'create', 'payment_account' => $record->id])) ->assertOk() ->assertSeeText('来自快递资料 · 打款信息新增:新快递九(XKD9)') ->assertSee('"account_proof_bank_account":"6222000011112222"', false) ->assertSee('"document_type":"account-proof"', false) ->assertSee('value="新快递九"', false); $this->loginAs()->get($page(['selected' => 'XKD9']))->assertOk()->assertSeeText('待推送协议')->assertSeeText('去推送'); // 协议签署完成后,这条打款信息显示「已生效」。 CourierAgreementSignature::query()->forceCreate([ 'company_id' => $this->company->id, 'business_no' => 'XY-ZM-1', 'idempotency_key' => uniqid('idem', true), 'document_type' => 'account-proof', 'template_code' => 'test', 'source_file_name' => 'proof.pdf', 'status' => CourierAgreementSignature::STATUS_ARCHIVED, 'courier_name' => '新快递九', 'company_full_name' => '新快递九有限公司', 'organization_name' => '新快递九有限公司', 'payload' => ['account_proof_bank_account' => '6222000011112222'], 'environment' => 'sandbox', 'source_pdf_path' => 'x.pdf', 'source_pdf_sha256' => str_repeat('a', 64), 'created_by' => $this->user->id, 'updated_by' => $this->user->id, ]); $this->loginAs()->get($page(['selected' => 'XKD9']))->assertOk()->assertSeeText('已生效')->assertDontSeeText('待推送协议'); $this->loginAs()->get($page(['courier_tab' => 'logs']))->assertOk()->assertSeeText('打款信息新增')->assertSeeText('编辑快递资料'); // 「打款信息」页签里也能直接新增 / 变更:快递公司代码在表单里填,表格每个账户有「变更」。 $this->loginAs()->get($page(['courier_tab' => 'accounts'])) ->assertOk() ->assertSeeText('打款信息新增') ->assertSeeText('打款信息变更') ->assertSee('data-account-change-from data-code="XKD9"', false) ->assertSee('name="expcode"', false); $this->withExceptionHandling()->loginAs() ->post(route('business.courier-profiles.payment-accounts.store-any'), ['form' => 'add', 'action' => 'add', 'expcode' => 'NOPE', 'account_type' => '对公', 'account_name' => 'x', 'bank_name' => 'y', 'account_number' => '1', 'effective_date' => '2026-10-20']) ->assertSessionHasErrors('expcode'); $this->loginAs()->post(route('business.courier-profiles.payment-accounts.store-any'), [ 'form' => 'change', 'action' => 'change', 'expcode' => 'XKD9', 'account_type' => '对私', 'old_account_name' => '李四', 'old_account_number' => '6228480000009999', 'old_stop_date' => '2026-10-19', 'change_date' => '2026-10-19', 'account_name' => '王五', 'bank_name' => '建设银行', 'account_number' => '6217000000001234', 'effective_date' => '2026-10-20', ])->assertRedirect(); $changeRecord = \App\Models\Business\CourierPaymentAccount::query()->latest('id')->firstOrFail(); $this->assertSame(['XKD9', 'change', '6228480000009999'], [$changeRecord->expcode, $changeRecord->action, $changeRecord->old_account_number]); $this->loginAs()->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing', 'agreement_view' => 'create', 'payment_account' => $changeRecord->id])) ->assertOk() ->assertSee('"document_type":"account-change"', false) ->assertSee('"old_account_number":"6228480000009999"', false); $this->assertSame($onboarding->id, app(\App\Business\CourierOverviewData::class)->infoFor('XKD9', null)['onboarding']->id); } public function test_short_term_requote_runs_for_its_period_then_falls_back_to_long_term_quote(): void { $onboarding = $this->submittedOnboarding(); $this->user->roles()->sync([Role::where('name', 'admin')->firstOrFail()->id]); $reviewer = $this->createReviewer(); $this->fakeEsign(); $this->loginAs($this->user, $this->company)->post(route('business.courier-onboardings.first-approve', $onboarding), ['note' => 'ok']); $this->loginAs($reviewer, $this->company)->post(route('business.courier-onboardings.final-approve', $onboarding), ['courier_name' => '宿迁圆通']); $this->loginAs($reviewer, $this->company)->post(route('business.courier-onboardings.activate', $onboarding), ['offline_note' => '测试']); $onboarding->refresh(); $base = $onboarding->quoteVersion; $base->signature()->update(['status' => CourierQuoteSignature::STATUS_ARCHIVED]); $base->update(['signature_status' => '已签署并归档', 'effective_date' => today()->subDays(11)]); $start = today()->addDay(); $end = today()->addDays(4); $quote = static fn (array $extra = []): array => [ 'quote_mode' => 'waybill_rate', 'volumetric_rule' => '计泡', 'volumetric_divisor' => '6000', 'quote_effective_date' => $start->toDateString(), 'quote_end_date' => $end->toDateString(), 'quote_rows' => [['region' => '江苏', 'base' => '4', 'rates' => ['0.8']], ['region' => '浙江', 'base' => '3.5', 'rates' => ['0.9']]], 'summary' => '旺季临时涨价', ...$extra, ]; $this->loginAs($this->user, $this->company)->get(route('business.courier-onboardings.requote', $onboarding)) ->assertOk()->assertSeeText('结束日期(选填 · 短期变价)'); $this->withExceptionHandling()->loginAs($this->user, $this->company) ->post(route('business.courier-onboardings.requote.store', $onboarding), $quote(['quote_end_date' => today()->toDateString()])) ->assertSessionHasErrors('quote_end_date'); $this->loginAs($this->user, $this->company) ->post(route('business.courier-onboardings.requote.store', $onboarding), $quote()) ->assertSessionHasNoErrors(); $change = CourierQuoteChange::query()->latest('id')->firstOrFail(); $this->assertSame($end->toDateString(), $change->end_date->toDateString()); $this->loginAs($reviewer, $this->company)->get(route('business.courier-quote-changes.show', $change)) ->assertOk() ->assertSeeText($start->toDateString() . ' 至 ' . $end->toDateString() . '(临时)') ->assertSeeText('这是短期变价') ->assertSeeText($end->copy()->addDay()->toDateString() . ' 起自动恢复 ' . $base->version_code); $this->loginAs($reviewer, $this->company)->post(route('business.courier-quote-changes.approve', $change))->assertSessionHasNoErrors(); $temporary = $change->fresh()->version; $this->assertSame($end->toDateString(), $temporary->end_date->toDateString()); $confirmation = app(\App\Support\CourierQuoteConfirmation::class)->data($temporary); $this->assertSame($end->toDateString(), $confirmation['expiry_date']); $this->assertStringContainsString('自动恢复原报价 ' . $base->version_code, $confirmation['temporary_note']); $this->assertSame('长期', app(\App\Support\CourierQuoteConfirmation::class)->data($base)['expiry_date']); $temporary->signature()->update(['status' => CourierQuoteSignature::STATUS_ARCHIVED]); $temporary->update(['signature_status' => '已签署并归档']); // 按日期取价:临时期间用临时价,结束后第二天自动回到原报价。 $requotes = app(\App\Support\CourierRequoteService::class); $this->assertSame($base->id, $requotes->effectiveVersion('宿迁圆通')->id); $this->assertSame($temporary->id, $requotes->effectiveVersion('宿迁圆通', $start)->id); $this->assertSame($temporary->id, $requotes->effectiveVersion('宿迁圆通', $end)->id); $this->assertSame($base->id, $requotes->effectiveVersion('宿迁圆通', $end->copy()->addDay())->id); // 下一次变价的底稿是长期报价,不是临时价。 $this->loginAs($this->user, $this->company)->get(route('business.courier-onboardings.requote', $onboarding)) ->assertOk()->assertSee('"base":3.2', false); $this->travelTo($start->copy()->addDay()->setTime(10, 0)); $quotes = collect(app(\App\Support\QuoteManagementSnapshot::class)->load()['quotes'])->keyBy('id'); $this->assertSame('生效中(临时)', $quotes[$temporary->version_code]['status']); $this->assertSame($start->toDateString() . ' 至 ' . $end->toDateString() . '(临时)', $quotes[$temporary->version_code]['period_label']); $this->assertSame('暂停中', $quotes[$base->version_code]['status']); $this->assertStringContainsString($end->copy()->addDay()->toDateString() . ' 起恢复', $quotes[$base->version_code]['status_note']); $this->travelTo($end->copy()->addDay()->setTime(10, 0)); $quotes = collect(app(\App\Support\QuoteManagementSnapshot::class)->load()['quotes'])->keyBy('id'); $this->assertSame('已到期', $quotes[$temporary->version_code]['status']); $this->assertSame('生效中', $quotes[$base->version_code]['status']); $this->travelBack(); } public function test_invite_profile_fields_are_optional_and_align_to_courier_information(): void { $this->withExceptionHandling()->loginAs() ->post(route('business.courier-onboardings.store'), [...self::profile(), 'courier_name' => '新快递十', 'workplatform_expcode' => 'XKD10', 'courier_category' => '乱填']) ->assertSessionHasErrors('courier_category'); $this->loginAs()->post(route('business.courier-onboardings.store'), [ 'courier_name' => '新快递十', 'workplatform_expcode' => 'XKD10', 'courier_category' => '外采快递', 'cooperation_mode' => '保丢不保损', 'courier_attribute' => '工厂', 'deduction_method' => '损耗+运费', 'waybill_type' => '手填单号', 'courier_email' => 'kf@example.com', 'need_logistics_check' => '是', 'shipping_mode' => '一单多货', 'workplatform_account' => '新开', 'network_code' => '519001', 'network_region' => '江苏省常州市天宁区', ])->assertSessionHasNoErrors(); $onboarding = CourierOnboarding::query()->where('workplatform_expcode', 'XKD10')->firstOrFail(); $this->assertSame(['外采快递', '损耗+运费', '519001'], [$onboarding->courier_category, $onboarding->deduction_method, $onboarding->network_code]); // 快递资料全部必填。 $this->withExceptionHandling()->loginAs()->post(route('business.courier-onboardings.store'), ['courier_name' => '新快递十一', 'workplatform_expcode' => 'XKD11']) ->assertSessionHasErrors(['courier_category' => '请选择快递类型。', 'network_region' => '请填写网点所在地区。']); $this->assertFalse(CourierOnboarding::query()->where('workplatform_expcode', 'XKD11')->exists()); // 详情里修改时不能清空。 $this->withExceptionHandling()->loginAs()->post(route('business.courier-onboardings.internal', CourierOnboarding::query()->where('workplatform_expcode', 'XKD10')->firstOrFail()), ['courier_email' => '']) ->assertSessionHasErrors('courier_email'); $this->loginAs()->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-onboarding', 'group' => 'all'])) ->assertOk()->assertSeeText('快递合作方式')->assertSeeText('保丢不保损')->assertSeeText('XKD10'); // 详情页可改;只改名称/对账人的旧表单不会清空这些字段。 $this->loginAs()->get(route('business.courier-onboardings.show', $onboarding))->assertOk()->assertSeeText('快递资料(我方填写)'); $this->loginAs()->post(route('business.courier-onboardings.internal', $onboarding), ['reconciler' => '王会计'])->assertSessionHasNoErrors(); $this->assertSame('外采快递', $onboarding->fresh()->courier_category); $this->assertSame('新快递十', $onboarding->fresh()->courier_name); $this->loginAs()->post(route('business.courier-onboardings.internal', $onboarding), ['shipping_mode' => '一单一货'])->assertSessionHasNoErrors(); $this->assertSame(['一单一货', '王会计', '新快递十'], [$onboarding->fresh()->shipping_mode, $onboarding->fresh()->reconciler, $onboarding->fresh()->courier_name]); // 快递资料:扣款方式对齐到左侧列,其余接在右侧,网点进网点明细。 $mirror = \Mockery::mock(\Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository::class)->shouldIgnoreMissing(); $mirror->shouldReceive('relatedAvailability')->andReturn([]); app()->instance(\Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository::class, $mirror); $this->loginAs()->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'courier-information', 'selected' => 'XKD10', 'detail_tab' => 'networks'])) ->assertOk() ->assertSee('扣款方式', false) ->assertSeeText('损耗+运费') ->assertSee('快递合作方式', false) ->assertSeeText('kf@example.com') ->assertSeeText('519001') ->assertSeeText('江苏省常州市天宁区'); } /** 生成二维码时必填的快递资料。 */ private static function profile(): array { return [ 'courier_category' => '内采快递', 'cooperation_mode' => '保丢保损', 'courier_attribute' => '简尚', 'deduction_method' => '运费', 'waybill_type' => '系统对接', 'courier_email' => 'courier@example.com', 'need_logistics_check' => '否', 'shipping_mode' => '一单一货', 'workplatform_account' => '已有', 'network_code' => 'N001', 'network_region' => '江苏省常州市', ]; } private function invite(): CourierOnboarding { $this->loginAs()->post(route('business.courier-onboardings.store'), [ ...self::profile(), 'courier_name' => '新北中通-' . uniqid(), 'workplatform_expcode' => 'XB' . uniqid(), 'invite_label' => '张经理', 'valid_days' => 30, ]); return CourierOnboarding::query()->latest('id')->firstOrFail(); } private function submittedOnboarding(): CourierOnboarding { $onboarding = $this->invite(); $this->asGuest(); $this->post(route('courier-onboarding.update', ['token' => $onboarding->token]), $this->completePayload()) ->assertSessionHasNoErrors(); return $onboarding->fresh(); } private function asGuest(): void { auth()->logout(); Company::forgetCurrent(); } /** * @return array */ private function completePayload(bool $withFiles = true): array { $payload = [ 'action' => 'submit', 'company_full_name' => '常州新北快递服务有限公司', 'unified_social_credit_code' => '91320411ma1xxxxx0y', 'legal_representative' => '王某', 'carrier_brand' => '中通', 'network_name' => '常州新北网点', 'service_type' => '快递', 'contact_name' => '张经理', 'contact_phone' => '13800001111', 'esign_operator_name' => '张经理', 'esign_account' => '13800001111', 'bank_account_name' => '常州新北快递服务有限公司', 'bank_name' => '中国工商银行', 'bank_account_number' => '6222 0200 0012 3456 789', 'private_account_name' => '王某', 'private_bank_name' => '中国农业银行', 'private_account_number' => '6228 4800 0000 1111', 'invoice_type' => '增值税专用发票', 'tax_rate' => '6', 'settlement_cycle' => '月结', 'quote_mode' => 'waybill_rate', 'volumetric_rule' => '计泡', 'volumetric_divisor' => '6000', 'quote_effective_date' => now()->addDay()->toDateString(), 'quote_rows' => [ ['region' => '江苏', 'base' => '3.2', 'rates' => ['0.8']], ['region' => '浙江', 'base' => '3.5', 'rates' => ['0.9']], ['region' => '上海', 'base' => '', 'rates' => ['']], ], ]; if ($withFiles) { $payload['files'] = [ 'business_license' => [UploadedFile::fake()->create('license.pdf', 120, 'application/pdf')], 'legal_id_card_front' => [UploadedFile::fake()->image('id-front.jpg', 600, 400)], 'legal_id_card_back' => [UploadedFile::fake()->image('id-back.jpg', 600, 400)], 'bank_card_front' => [UploadedFile::fake()->image('card-front.jpg', 600, 400)], 'bank_card_back' => [UploadedFile::fake()->image('card-back.jpg', 600, 400)], ]; } return $payload; } private function createReviewer(string $role = 'admin') { $reviewer = user_model_class()::create([ 'name' => 'Onboarding Reviewer', 'email' => 'onboarding-reviewer-' . uniqid('', true) . '@example.com', 'password' => 'password', 'locale' => 'zh-CN', 'enabled' => 1, 'landing_page' => 'dashboard', 'created_from' => 'core::test', ]); $reviewer->companies()->attach($this->company->id); $reviewer->roles()->attach(Role::where('name', $role)->firstOrFail()->id); $reviewer->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); if ($role === 'admin') { $this->bindReviewer($reviewer); $settings = app(CourierOnboardingReviewers::class)->settings(); $settings['final'][] = ['dingtalk_user_id' => 'reviewer-' . $reviewer->id, 'display_name' => $reviewer->name]; DB::table('settings')->updateOrInsert(['company_id' => $this->company->id, 'key' => CourierOnboardingReviewers::KEY], ['value' => json_encode($settings)]); } return $reviewer; } private function bindReviewer($user): void { DingTalkIdentity::create(['user_id' => $user->id, 'union_id' => 'union-reviewer-' . $user->id, 'dingtalk_user_id' => 'reviewer-' . $user->id, 'bound_at' => now()]); } public function test_any_designated_final_reviewer_can_pass_and_stale_requests_do_not_duplicate_quotes(): void { $onboarding = $this->submittedOnboarding(); $one = $this->createReviewer(); $two = $this->createReviewer(); // A configured reviewer needs no system administration role. $two->roles()->sync([Role::where('name', 'user')->firstOrFail()->id]); $service = app(CourierOnboardingService::class); $service->firstApprove($onboarding, $this->user->id, null); $stale = $onboarding->fresh(); $this->loginAs($two, $this->company)->get(route('business.courier-onboardings.show', $onboarding)) ->assertSeeText('复审通过,生成首版报价'); $this->post(route('business.courier-onboardings.final-approve', $onboarding), [])->assertSessionHasNoErrors(); $this->assertSame($two->id, $onboarding->fresh()->final_reviewed_by); $versions = CourierQuoteVersion::count(); try { $service->finalApprove($stale, [], $one->id); $this->fail('过期复审请求不应再次成功'); } catch (\Illuminate\Validation\ValidationException $e) { $this->assertArrayHasKey('status', $e->errors()); } $this->assertSame($versions, CourierQuoteVersion::count()); $this->assertSame(1, $onboarding->events()->where('event_type', 'final_approved')->count()); $this->withExceptionHandling()->loginAs($one, $this->company) ->post(route('business.courier-onboardings.return', $onboarding), ['reason' => '过期退回']) ->assertSessionHasErrors('status'); $this->assertSame(CourierOnboarding::STATUS_APPROVED, $onboarding->fresh()->status); } public function test_unconfigured_and_unassigned_admin_cannot_review(): void { $onboarding = $this->submittedOnboarding(); DB::table('settings')->where('key', CourierOnboardingReviewers::KEY)->delete(); $this->withExceptionHandling()->loginAs()->post(route('business.courier-onboardings.first-approve', $onboarding)) ->assertSessionHasErrors('approval'); $other = $this->createReviewer(); $settings = app(CourierOnboardingReviewers::class)->settings(); $settings['first'] = $settings['final']; DB::table('settings')->updateOrInsert(['company_id' => $this->company->id, 'key' => CourierOnboardingReviewers::KEY], ['value' => json_encode($settings)]); foreach (['first-approve' => [], 'return' => ['reason' => '退回'], 'reject' => ['reason' => '拒绝']] as $action => $data) { $this->post(route('business.courier-onboardings.' . $action, $onboarding), $data)->assertForbidden(); } $this->assertSame(CourierOnboarding::STATUS_SUBMITTED, $onboarding->fresh()->status); } public function test_final_review_actions_require_current_stage_reviewer_and_separate_people(): void { $onboarding = $this->submittedOnboarding(); $other = $this->createReviewer(); $this->loginAs()->post(route('business.courier-onboardings.first-approve', $onboarding)); foreach (['final-approve' => [], 'return' => ['reason' => '退回'], 'reject' => ['reason' => '拒绝']] as $action => $data) { $this->withExceptionHandling()->post(route('business.courier-onboardings.' . $action, $onboarding), $data) ->assertSessionHasErrors('approval'); } $this->loginAs($other, $this->company)->post(route('business.courier-onboardings.return', $onboarding), ['reason' => '资料不清晰']) ->assertSessionHasNoErrors(); $this->assertSame(CourierOnboarding::STATUS_RETURNED, $onboarding->fresh()->status); $this->assertNull($onboarding->fresh()->first_reviewed_by); } public function test_invalid_roster_and_upstream_failure_block_review_without_writes(): void { $onboarding = $this->submittedOnboarding(); $this->mock(FinanceRoster::class)->shouldReceive('findEligible')->andReturnNull(); $this->withExceptionHandling()->loginAs()->post(route('business.courier-onboardings.first-approve', $onboarding))->assertForbidden(); $this->mock(FinanceRoster::class)->shouldReceive('findEligible')->andThrow(new \RuntimeException('private connection detail')); $this->post(route('business.courier-onboardings.first-approve', $onboarding))->assertSessionHasErrors('approval'); $this->assertStringNotContainsString('private connection', json_encode(session('errors')->all())); $this->assertSame(CourierOnboarding::STATUS_SUBMITTED, $onboarding->fresh()->status); $this->assertSame(0, $onboarding->events()->where('event_type', 'first_approved')->count()); } public function test_disabled_unbound_and_other_company_users_cannot_review(): void { $onboarding = $this->submittedOnboarding(); $this->user->update(['enabled' => false]); $this->assertReviewForbidden($onboarding); $this->user->update(['enabled' => true]); $this->user->companies()->detach($this->company->id); $this->assertReviewForbidden($onboarding); $this->user->companies()->attach($this->company->id); DingTalkIdentity::where('user_id', $this->user->id)->delete(); $this->assertReviewForbidden($onboarding); $this->assertSame(CourierOnboarding::STATUS_SUBMITTED, $onboarding->fresh()->status); } private function assertReviewForbidden(CourierOnboarding $onboarding): void { try { app(CourierOnboardingService::class)->firstApprove($onboarding, $this->user->id, null); $this->fail('无效账号不能审核'); } catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { $this->assertSame(403, $e->getStatusCode()); } } public function test_production_cannot_bypass_separation_and_configuration_changes_apply_immediately(): void { $onboarding = $this->submittedOnboarding(); $service = app(CourierOnboardingService::class); $service->firstApprove($onboarding, $this->user->id, null); config(['courier_onboarding.allow_same_reviewer' => true]); app()->detectEnvironment(fn () => 'production'); try { $service->finalApprove($onboarding, [], $this->user->id); $this->fail('生产环境不能允许一人包办审核'); } catch (\Illuminate\Validation\ValidationException $e) { $this->assertArrayHasKey('approval', $e->errors()); } app()->detectEnvironment(fn () => 'testing'); config(['courier_onboarding.allow_same_reviewer' => false]); $one = $this->createReviewer(); $two = $this->createReviewer(); app(CourierOnboardingReviewers::class)->save([ 'first' => ['reviewer-' . $this->user->id], 'final' => ['reviewer-' . $two->id], ], $this->user); $this->withExceptionHandling()->loginAs($one, $this->company) ->post(route('business.courier-onboardings.final-approve', $onboarding))->assertForbidden(); $this->loginAs($two, $this->company)->post(route('business.courier-onboardings.final-approve', $onboarding))->assertSessionHasNoErrors(); $this->assertSame($two->id, $onboarding->fresh()->final_reviewed_by); } private function fakeEsign(): void { config([ 'services.esign.environment' => '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, ]); $flows = 0; Http::fake(static function (ClientRequest $request) use (&$flows) { return match (true) { $request->url() === 'https://smlopenapi.esign.test/v3/files/file-upload-url' => Http::response([ 'code' => 0, 'data' => ['fileId' => 'file-onboard', 'fileUploadUrl' => 'https://upload.esign.test/file-onboard'], ]), $request->url() === 'https://upload.esign.test/file-onboard' => Http::response(['errCode' => 0]), $request->url() === 'https://smlopenapi.esign.test/v3/files/file-onboard' => 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' => ++$flows === 1 ? 'flow-onboard' : 'flow-onboard-' . $flows]]), str_ends_with($request->url(), '/detail') => Http::response(['code' => 0, 'data' => ['signFlowStatus' => '1']]), str_ends_with($request->url(), '/revoke') => Http::response(['code' => 0, 'data' => []]), $request->url() === 'https://smlopenapi.esign.test/v3/sign-flow/flow-onboard/sign-url' => Http::response(['code' => 0, 'data' => ['url' => 'https://esign.cn/long', 'shortUrl' => 'https://t.esign.cn/abc']]), default => Http::response(['code' => 404, 'message' => 'unexpected request'], 404), }; }); } private function createSignedAgreement(string $courierName): void { CourierAgreementSignature::query()->forceCreate([ 'company_id' => $this->company->id, 'business_no' => 'XY-TEST-' . uniqid(), 'idempotency_key' => uniqid('idem', true), 'document_type' => 'cooperation-contract', 'template_code' => 'test', 'source_file_name' => 'contract.pdf', 'status' => CourierAgreementSignature::STATUS_ARCHIVED, 'courier_name' => $courierName, 'company_full_name' => '常州新北快递服务有限公司', 'organization_name' => '常州新北快递服务有限公司', 'payload' => [], 'source_pdf_path' => 'test/contract.pdf', 'source_pdf_sha256' => str_repeat('0', 64), 'created_by' => $this->user->id, 'updated_by' => $this->user->id, ]); } }