快递应付 → 快递报价 下新增「快递入驻」页:生成入驻二维码发给快递方,
对方免登录在手机上填写主体、联系人、e签宝签署人、结算账户、报价并上传资质,
内部对接人初审、管理员财务复审后生成首版报价,并自动生成报价确认单 PDF
推送到快递填写的 e签宝账号,签署归档后开通。
- 新增三张表 business_courier_onboardings / _files / _events(纯新增,不改旧表)
- 免登录填报页 /onboarding/courier/{token}:40 位随机 token,可暂存、提交、退回后补件,
链接有效期可延长、可作废;银行账号加密存储;附件存 uploads 盘
- 新增 COURIER_ONBOARDING_PUBLIC_URL 配置二维码里的站点地址(留空用当前访问地址)
- 内置纯 PHP 二维码生成(App\Support\QrCode,SVG 输出,无新增依赖)
- 生成二维码时必须定好系统快递名称(唯一,快递方不可见);财务复审沿用该名称,快递所填报价写成报价管理的 CourierQuoteVersion,
复用 CourierQuoteSignatureService 生成报价确认单并推送 e签宝(失败可重推/同步状态);初审人默认不能复审(COURIER_ONBOARDING_ALLOW_SAME_REVIEWER=true 可在测试环境放开)
- 支持公司 / 个人两种入驻:个人填姓名+身份证号(加密)、传身份证和银行卡,报价确认单按个人身份在 e签宝签署
- 扫码先选入驻方式;证件改为 营业执照 / 法人身份证正反面 / 银行卡正反面 各一张(个人营业执照选填),手机端上传前自动压缩,超限回到填报页提示
- 报价支持 4 种计费方式(面单+费率 / 首重+续重 / 按重量段一口价 / 小重量一口价+超出另算,费率可分段)+「其他」上传报价表;附加规则:计泡、起算重量、取整、每票加收(CourierQuoteStructure)
- 不再要求上传盖章报价单,至少填写一行在线报价
- 开通:报价确认单签署归档、或匹配到已签署协议、或填写线下签署说明
- 生成二维码前必填「快递公司代码」(自动大写、不可重复),入驻信息按代码对齐快递资料
- 公司入驻对公(营业执照主体)+ 对私(法定代表人)两个打款账户必填并自动核对;个人入驻对私必填、对公选填
- 变价:报价管理里后台改价 → 审核 → 自动推送快递 e签宝 → 回签;回签单导出 xlsx/zip;误发版本可作废
- 短期(临时)变价:变价单/报价版本加选填「结束日期」(end_date);按日期取价,期间用临时价,到期次日自动恢复原长期报价,
不用重签;报价管理显示 生效中(临时)/ 暂停中 / 已到期,确认单写明执行期间和到期后恢复的版本,审核提醒期间重叠
- 快递资料分四类:快递资料 / 网点 / 打款信息 / 操作日志,支持查询、导出、重置、编辑;
财务系统入驻的快递直接作为快递资料行,合同起止取协议签署里已签署的合作合同;
点开一行在下方看该快递的网点 / 打款信息(完整账号)/ 操作日志
- 编辑存财务系统覆盖值(business_courier_profiles,不回写工作平台),改动记操作日志(business_courier_profile_events)
- 打款信息新增 / 变更(business_courier_payment_accounts,账号加密)保存后跳到协议签署,自动预填收款账户证明 / 收款账户变更,
按签署状态显示 待推送协议 / 签署中 / 已生效;入口在快递资料明细和「打款信息」页签(表格每个账户可直接「变更」)
- ERP 开通:入驻开通后标「待 ERP 开通」,可手动补录(钉钉「新建快递」流程同步待接入)
- 新增 CourierOnboardingWorkflowTest(8 个用例),更新快递报价目录顺序断言
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gzfsnV3fJ89CoUy1D9V9g
1266 lines
77 KiB
PHP
1266 lines
77 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\CourierQuoteSignature;
|
||
use App\Models\Business\CourierQuoteVersion;
|
||
use App\Models\Common\Company;
|
||
use App\Support\CourierQuoteStructure;
|
||
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 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('入驻流程');
|
||
|
||
$this->withExceptionHandling()->loginAs()
|
||
->post(route('business.courier-onboardings.store'), ['invite_label' => '张经理'])
|
||
->assertSessionHasErrors('courier_name');
|
||
|
||
$response = $this->loginAs()->post(route('business.courier-onboardings.store'), [
|
||
'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'), ['courier_name' => '新北中通', 'workplatform_expcode' => 'NEW1'])
|
||
->assertSessionHasErrors('courier_name');
|
||
|
||
// 快递公司代码必填、自动转大写、不能和其他入驻重复。
|
||
$this->assertSame('XBZT', $onboarding->workplatform_expcode);
|
||
$this->withExceptionHandling()->loginAs()
|
||
->post(route('business.courier-onboardings.store'), ['courier_name' => '另一家'])
|
||
->assertSessionHasErrors('workplatform_expcode');
|
||
$this->withExceptionHandling()->loginAs()
|
||
->post(route('business.courier-onboardings.store'), ['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_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());
|
||
}
|
||
|
||
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'), ['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();
|
||
}
|
||
|
||
private function invite(): CourierOnboarding
|
||
{
|
||
$this->loginAs()->post(route('business.courier-onboardings.store'), [
|
||
'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());
|
||
|
||
return $reviewer;
|
||
}
|
||
|
||
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,
|
||
]);
|
||
}
|
||
}
|