快递应付 → 快递报价 下新增「快递入驻」页:生成入驻二维码发给快递方,
对方免登录在手机上填写主体、联系人、结算账户、报价并上传资质,
内部对接人初审、管理员财务复审后生成首版报价,协议签署完成后开通。
- 新增三张表 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
360 lines
16 KiB
PHP
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,
|
|
]);
|
|
}
|
|
}
|