CaiWuXiTong/tests/Feature/Business/CourierOnboardingWorkflowTest.php

1474 lines
91 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use App\Models\Auth\DingTalkIdentity;
use App\Models\Auth\Role;
use App\Models\Business\CourierAgreementSignature;
use App\Models\Business\CourierOnboarding;
use App\Models\Business\CourierQuoteChange;
use App\Models\Business\CourierQuoteSignature;
use App\Models\Business\CourierQuoteVersion;
use App\Models\Common\Company;
use App\Support\CourierQuoteStructure;
use App\Support\CourierOnboardingReviewers;
use App\Support\CourierOnboardingService;
use App\Services\Auth\FinanceRoster;
use App\Support\QrCode;
use Illuminate\Http\Client\Request as ClientRequest;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\DB;
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());
$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('<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', '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('>对私账户</th>', 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('<th>扣款方式</th>', false)
->assertSeeText('损耗+运费')
->assertSee('<th class="is-onboarding">快递合作方式</th>', 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<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',
'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,
]);
}
}