CaiWuXiTong/tests/Feature/Business/CourierOnboardingWorkflowTest.php
limeng d19409627a feat(快递入驻): 快递扫码填写入驻资料 + 两级审核 + 首版报价 + 签署开通
快递应付 → 快递报价 下新增「快递入驻」页:生成入驻二维码发给快递方,
对方免登录在手机上填写主体、联系人、结算账户、报价并上传资质,
内部对接人初审、管理员财务复审后生成首版报价,协议签署完成后开通。

- 新增三张表 business_courier_onboardings / _files / _events(纯新增,不改旧表)
- 免登录填报页 /onboarding/courier/{token}:40 位随机 token,可暂存、提交、退回后补件,
  链接有效期可延长、可作废;银行账号加密存储;附件存 uploads 盘
- 内置纯 PHP 二维码生成(App\Support\QrCode,SVG 输出,无新增依赖)
- 财务复审:确认系统快递名称(唯一),快递所填报价写成报价管理的 CourierQuoteVersion,
  可直接在协议签署页发起报价确认单;初审人不能复审
- 开通:需匹配到已签署的协议签署任务,或填写线下签署说明
- 快递资料页新增「入驻快递」页签,列出已开通的入驻快递
- 新增 CourierOnboardingWorkflowTest(8 个用例),更新快递报价目录顺序断言

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gzfsnV3fJ89CoUy1D9V9g
2026-10-12 17:37:44 +08:00

360 lines
16 KiB
PHP

<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use App\Models\Auth\Role;
use App\Models\Business\CourierAgreementSignature;
use App\Models\Business\CourierOnboarding;
use App\Models\Business\CourierQuoteChange;
use App\Models\Business\CourierQuoteVersion;
use App\Models\Common\Company;
use App\Support\QrCode;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\Feature\FeatureTestCase;
class CourierOnboardingWorkflowTest extends FeatureTestCase
{
protected function setUp(): void
{
parent::setUp();
Storage::fake('uploads');
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
}
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('入驻流程');
$response = $this->loginAs()->post(route('business.courier-onboardings.store'), [
'invite_label' => '新北中通 · 张经理',
'valid_days' => 15,
]);
$onboarding = CourierOnboarding::query()->firstOrFail();
$response->assertRedirect(route('business.courier-onboardings.show', $onboarding));
$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);
$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('<svg', $qr->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', 'files.business_license']);
$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' => '江苏', 'first_weight' => 1, 'first_price' => 3.2, 'rate' => 0.8], $onboarding->quoteRowList()[0]);
$this->assertSame(3, $onboarding->files()->count());
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->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);
$this->assertSame(['省份', '首重(kg)', '面单(元)', '费率(元/kg)'], $values[2]);
$this->assertSame(['江苏', 1, 3.2, 0.8], $values[3]);
$change = CourierQuoteChange::query()->findOrFail($onboarding->quote_change_id);
$this->assertSame(CourierQuoteChange::STATUS_APPROVED, $change->status);
$this->assertSame('新快递入驻', $change->change_type);
// 没有签署记录也没写线下说明,不能开通。
$this->withExceptionHandling()->loginAs($reviewer, $this->company)
->post(route('business.courier-onboardings.activate', $onboarding), [])
->assertSessionHasErrors('offline_note');
$this->createSignedAgreement('新北中通-入驻');
$this->loginAs($reviewer, $this->company)
->get(route('business.courier-onboardings.show', $onboarding))
->assertOk()
->assertSeeText('已签署并归档');
$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_non_admin_cannot_final_review_or_activate(): 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());
}
private function invite(): CourierOnboarding
{
$this->loginAs()->post(route('business.courier-onboardings.store'), ['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<string,mixed>
*/
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',
'bank_account_name' => '常州新北快递服务有限公司',
'bank_name' => '中国工商银行',
'bank_account_number' => '6222 0200 0012 3456 789',
'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' => '江苏', 'first_weight' => '1', 'first_price' => '3.2', 'rate' => '0.8'],
['region' => '浙江', 'first_weight' => '1', 'first_price' => '3.5', 'rate' => '0.9'],
['region' => '上海', 'first_weight' => '', 'first_price' => '', 'rate' => ''],
],
];
if ($withFiles) {
$payload['files'] = [
'business_license' => [UploadedFile::fake()->image('license.jpg', 600, 400)],
'bank_account_proof' => [UploadedFile::fake()->create('bank.pdf', 120, 'application/pdf')],
'quote_sheet' => [UploadedFile::fake()->image('quote.png', 600, 800)],
];
}
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());
return $reviewer;
}
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,
]);
}
}