CaiWuXiTong/tests/Feature/Business/CourierQuoteBillingRulesTest.php

290 lines
17 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\Business\CourierQuoteChange;
use App\Models\Business\CourierQuoteVersion;
use App\Support\CourierQuoteFreightCalculator;
use App\Support\CourierQuoteStructure;
use App\Support\QuoteManagementSnapshot;
use Illuminate\Support\Carbon;
use Tests\Feature\FeatureTestCase;
class CourierQuoteBillingRulesTest extends FeatureTestCase
{
private array $files = [];
protected function tearDown(): void
{
foreach ($this->files as $file) {
@unlink($file);
}
parent::tearDown();
}
/** 用各家原表备注里自带的算例核对计费口径。 */
public function testFreightMatchesWorkedExamplesFromQuoteRemarks(): void
{
$f = fn (array $structure, array $row, float $kg, string $rounding = 'none') => CourierQuoteStructure::freight(
$structure, $row, CourierQuoteStructure::roundWeight($kg, $rounding)
)['amount'];
// 江西新建中通:15kg 广东 = 5.5 + 10×0.43 + 5×0.63(累进,用分段表示)
$progressive = ['mode' => 'segments'];
$gd = ['region' => '广东', 'segments' => [
['lo' => 0, 'hi' => 10, 'type' => 'waybill', 'base' => 5.5, 'rate' => 0.43],
['lo' => 10, 'hi' => null, 'type' => 'first_add', 'base' => 9.8, 'fw' => 10, 'rate' => 0.63],
]];
$this->assertEqualsWithDelta(12.95, $f($progressive, $gd, 15), 0.0001);
$this->assertEqualsWithDelta(9.8, $f($progressive, $gd, 10), 0.0001);
// 迈当极兔:8.1kg 广东向上取整按 9kg,3 + 9×1.1 = 12.9;3kg 内一口价 4.2
$md = ['mode' => 'flat_then', 'flat_bands' => [3], 'then_mode' => 'waybill_rate', 'rate_tiers' => [10]];
$mdRow = ['region' => '广东', 'flat' => [4.2], 'base' => 3, 'rates' => [1.1, 1.4]];
$this->assertEqualsWithDelta(12.9, $f($md, $mdRow, 8.1, 'ceil'), 0.0001);
$this->assertEqualsWithDelta(4.2, $f($md, $mdRow, 2.6, 'ceil'), 0.0001);
$this->assertEqualsWithDelta(3 + 11 * 1.4, $f($md, $mdRow, 10.2, 'ceil'), 0.0001);
// 顺兴中通快运:10.4kg = (10.4-10)×1.07 + 首重最低收费 24
$sx = ['mode' => 'first_additional', 'first_weight' => 10, 'rate_tiers' => []];
$this->assertEqualsWithDelta(24.428, $f($sx, ['region' => '安徽', 'base' => 24, 'rates' => [1.07]], 10.4), 0.0001);
$this->assertEqualsWithDelta(24.0, $f($sx, ['region' => '安徽', 'base' => 24, 'rates' => [1.07]], 6), 0.0001);
// 彩艺德邦:四舍五入,4.4 按 4kg(首重 5kg 内 13 元),5.5 按 6kg
$cy = ['mode' => 'first_additional', 'first_weight' => 5, 'rate_tiers' => []];
$cyRow = ['region' => '江苏', 'base' => 13, 'rates' => [1.6]];
$this->assertEqualsWithDelta(13.0, $f($cy, $cyRow, 4.4, 'round'), 0.0001);
$this->assertEqualsWithDelta(14.6, $f($cy, $cyRow, 5.5, 'round'), 0.0001);
// 常州圆通:按总重量所在区间取费率,区间上限含本数
$yt = ['mode' => 'waybill_rate', 'rate_tiers' => [35, 50]];
$ytRow = ['region' => '江苏', 'base' => 3.6, 'rates' => [0.48, 0.98, 1.28]];
$this->assertEqualsWithDelta(3.6 + 35 * 0.48, $f($yt, $ytRow, 35), 0.0001);
$this->assertEqualsWithDelta(3.6 + 35.5 * 0.98, $f($yt, $ytRow, 35.5), 0.0001);
$this->assertEqualsWithDelta(3.6 + 60 * 1.28, $f($yt, $ytRow, 60), 0.0001);
// 派雅极兔:某一段原表为「不发」(价格为空);超过最后一段不在报价范围
$py = ['mode' => 'flat_bands', 'flat_bands' => [3, 5]];
$pyRow = ['region' => '江苏', 'flat' => [4, null]];
$this->assertEqualsWithDelta(4.0, $f($py, $pyRow, 2), 0.0001);
$this->assertSame('该重量段不发', CourierQuoteStructure::freight($py, $pyRow, 4)['reason']);
$this->assertSame('超出报价重量范围', CourierQuoteStructure::freight($py, $pyRow, 6)['reason']);
$this->assertSame('特殊计费,需人工核算', CourierQuoteStructure::freight(['mode' => 'other'], [], 3)['reason']);
}
public function testImportCommandWritesRulesAndCalculatorUsesThemByShipDate(): void
{
$old = $this->version('QB-101', '常州圆通其他', 'CZYTOQT', '2026-06-01', '2026-09-19');
$new = $this->version('QB-102', '常州圆通其他', 'CZYTOQT', '2026-09-20', null);
$seg = $this->version('QB-103', '江西新建中通', 'JXXJZTO', '2000-01-01', null);
$manual = $this->version('QB-104', '华韵安能', 'HYAN', '2025-07-01', null);
$file = $this->rulesFile([
$this->waybill('QB-101', '常州圆通其他', 3.5),
$this->waybill('QB-102', '常州圆通其他', 3.6),
[
'version_id' => 'QB-103', 'company_name' => '江西新建中通', 'conversion' => 'needs_segment_mode',
'structure' => ['mode' => 'other', 'flat_bands' => [], 'then_mode' => '', 'first_weight' => null, 'rate_tiers' => [], 'rounding' => 'none'],
'segments_rows' => [['region' => '广东', 'segments' => [
['lo' => 0, 'hi' => 10, 'type' => 'waybill', 'base' => 5.5, 'rate' => 0.43],
['lo' => 10, 'hi' => null, 'type' => 'first_add', 'base' => 9.8, 'fw' => 10, 'rate' => 0.63],
]]],
'params' => ['volumetric_divisor' => 10000, 'weight_limit_kg' => 45, 'surcharges' => [], 'notes' => []],
],
['version_id' => 'QB-104', 'company_name' => '华韵安能', 'conversion' => 'manual', 'manual_reason' => '按体积计费'],
]);
$this->artisan('courier-quotes:import-billing-rules', ['file' => $file, '--company' => $this->company->id, '--dry-run' => true])
->assertExitCode(0);
$this->assertNull($old->fresh()->billing_structure);
$this->artisan('courier-quotes:import-billing-rules', ['file' => $file, '--company' => $this->company->id])
->assertExitCode(0);
$this->assertSame('waybill_rate', $old->fresh()->billing_structure['mode']);
$this->assertEquals([35, 50], $new->fresh()->billing_structure['rate_tiers']);
$this->assertSame('segments', $seg->fresh()->billing_structure['mode']);
$this->assertNull($manual->fresh()->billing_structure);
$calc = new CourierQuoteFreightCalculator();
// 发货日期决定用哪一版:9 月 1 日走旧版面单 3.5,9 月 25 日走新版面单 3.6
$before = $calc->calculate('CZYTOQT', Carbon::parse('2026-09-01'), '江苏省', 10);
$this->assertSame('QB-101', $before['version_code']);
$this->assertEqualsWithDelta(3.5 + 10 * 0.48, $before['amount'], 0.001);
$after = $calc->calculate('CZYTOQT', Carbon::parse('2026-09-25'), '江苏', 10);
$this->assertSame('QB-102', $after['version_code']);
$this->assertEqualsWithDelta(3.6 + 10 * 0.48, $after['amount'], 0.001);
// 计泡:60×50×40/6000 = 20kg > 实重 10kg
$volume = $calc->calculate('CZYTOQT', Carbon::parse('2026-09-25'), '江苏', 10, 60, 50, 40);
$this->assertEqualsWithDelta(20.0, $volume['volume_weight'], 0.001);
$this->assertEqualsWithDelta(3.6 + 20 * 0.48, $volume['amount'], 0.001);
// 城市单列行优先:深圳走「深圳(广东)」,其他广东城市走广东
$this->assertEqualsWithDelta(3.6 + 2 * 1.0, $calc->calculate('CZYTOQT', Carbon::parse('2026-09-25'), '广东省', 2, city: '深圳')['amount'], 0.001);
$this->assertEqualsWithDelta(3.6 + 2 * 0.75, $calc->calculate('CZYTOQT', Carbon::parse('2026-09-25'), '广东省', 2, city: '广州')['amount'], 0.001);
$this->assertEqualsWithDelta(12.95, $calc->calculate('JXXJZTO', Carbon::parse('2026-10-01'), '广东', 15)['amount'], 0.001);
// 没有快递编码的存量报价按快递名称查
$seg->forceFill(['courier_expcode' => null])->save();
$this->assertEqualsWithDelta(12.95, (new CourierQuoteFreightCalculator())->calculate('江西新建中通', Carbon::parse('2026-10-01'), '广东', 15)['amount'], 0.001);
$seg->forceFill(['courier_expcode' => 'JXXJZTO'])->save();
// 起算重量:靓阁不足 16kg 按 16kg(落在 16-17kg 段 40 元)
$this->version('QB-105', '靓阁快递', 'LGKD', '2024-03-12', null)->forceFill([
'billing_structure' => ['mode' => 'flat_bands', 'flat_bands' => [17.0, 18.0], 'rounding' => 'none', 'min_weight' => 16.0, 'weight_limit' => 45.0],
'billing_rows' => [['region' => '江苏', 'flat' => [40.0, 50.0], 'base' => null, 'rates' => []]],
])->save();
$light = $calc->calculate('LGKD', Carbon::parse('2026-10-01'), '江苏', 3);
$this->assertSame(16.0, $light['billable_weight']);
$this->assertEqualsWithDelta(40.0, $light['amount'], 0.001);
$this->assertEqualsWithDelta(50.0, $calc->calculate('LGKD', Carbon::parse('2026-10-01'), '江苏', 17.5)['amount'], 0.001);
$this->assertSame('超出限重 45kg', $calc->calculate('JXXJZTO', Carbon::parse('2026-10-01'), '广东', 46)['reason']);
$this->assertStringContainsString('未覆盖目的地', $calc->calculate('CZYTOQT', Carbon::parse('2026-09-25'), '西藏', 2)['reason']);
$this->assertStringContainsString('需人工核算', $calc->calculate('HYAN', Carbon::parse('2026-09-25'), '江苏', 2)['reason']);
$this->assertSame('发货日期没有生效的报价', $calc->calculate('CZYTOQT', Carbon::parse('2026-01-01'), '江苏', 2)['reason']);
// 再导一次内容相同 → 跳过;内容不同且不加 --force → 整批报错不写
$this->artisan('courier-quotes:import-billing-rules', ['file' => $file, '--company' => $this->company->id])->assertExitCode(0);
$changed = $this->rulesFile([$this->waybill('QB-102', '常州圆通其他', 9.9)]);
$this->artisan('courier-quotes:import-billing-rules', ['file' => $changed, '--company' => $this->company->id])->assertExitCode(1);
$this->assertEqualsWithDelta(3.6, $new->fresh()->billing_rows[0]['base'], 0.0001);
$this->artisan('courier-quotes:import-billing-rules', ['file' => $changed, '--company' => $this->company->id, '--force' => true])->assertExitCode(0);
$this->assertEqualsWithDelta(9.9, $new->fresh()->billing_rows[0]['base'], 0.0001);
}
public function testImportRejectsWholeBatchWhenCompanyNameDoesNotMatch(): void
{
$a = $this->version('QB-201', '沭阳德邦', 'SYDB', '2026-02-01', null);
$this->version('QB-202', '沭阳德邦其他', 'SYDBQT', '2026-02-01', null);
$file = $this->rulesFile([$this->waybill('QB-201', '沭阳德邦', 6.8), $this->waybill('QB-202', '别的快递', 6.8)]);
$this->artisan('courier-quotes:import-billing-rules', ['file' => $file, '--company' => $this->company->id])->assertExitCode(1);
$this->assertNull($a->fresh()->billing_structure);
}
public function testQuoteDetailShowsBillingSummary(): void
{
$version = $this->version('QB-301', '彩艺德邦', 'CYDB', '2024-07-01', null);
$version->forceFill([
'billing_structure' => ['mode' => 'first_additional', 'flat_bands' => [], 'then_mode' => '', 'first_weight' => 5.0, 'rate_tiers' => [],
'rounding' => 'round', 'volumetric_divisor' => 10000, 'weight_limit' => 60.0, 'per_ticket_surcharge' => 1.0],
'billing_rows' => [['region' => '江苏', 'flat' => [], 'base' => 13.0, 'rates' => [1.6]]],
])->save();
$quotes = (new QuoteManagementSnapshot('/nonexistent-billing.json'))->load()['quotes'];
$quote = collect($quotes)->firstWhere('id', 'QB-301');
$this->assertStringContainsString('首重5kg内按首重价', $quote['billing_summary']);
$this->assertStringContainsString('每票加收 1 元', $quote['billing_summary']);
$this->assertStringContainsString('1 个地区', $quote['billing_summary']);
$this->assertCalculatorAddsPerTicket();
}
private function assertCalculatorAddsPerTicket(): void
{
$result = (new CourierQuoteFreightCalculator())->calculate('CYDB', Carbon::parse('2026-01-01'), '江苏', 5.5);
$this->assertSame(6.0, $result['billable_weight']);
$this->assertEqualsWithDelta(15.6, $result['amount'], 0.001);
$this->assertSame('13+(6-5)×1.6+每票1', $result['formula']);
}
public function test_import_rejects_duplicate_versions_negative_rates_and_unknown_segments_without_partial_writes(): void
{
$version = $this->version('QB-SAFE', '安全报价', 'SAFE', '2026-01-01', null);
$valid = $this->waybill('QB-SAFE', '安全报价', 2);
$negative = $valid;
$negative['rows'][0]['rates'][0] = -1;
$unknown = $valid;
$unknown['conversion'] = 'needs_segment_mode';
$unknown['segments_rows'] = [['region' => '江苏', 'segments' => [['lo' => 0, 'hi' => null, 'type' => 'unknown', 'base' => 2, 'rate' => 1]]]];
foreach ([[$valid, $valid], [$negative], [$unknown], ['invalid-row']] as $items) {
$this->artisan('courier-quotes:import-billing-rules', ['file' => $this->rulesFile($items), '--company' => $this->company->id])->assertExitCode(1);
$this->assertNull($version->fresh()->billing_structure);
}
}
public function test_city_specific_prices_require_the_matching_province_and_accept_chinese_parentheses(): void
{
$version = $this->version('QB-CITY', '城市报价', 'CITY', '2026-01-01', null);
$version->forceFill([
'billing_structure' => ['mode' => 'waybill_rate', 'rate_tiers' => []],
'billing_rows' => [
['region' => '深圳(广东)', 'base' => 5, 'rates' => [1]],
['region' => '江苏', 'base' => 10, 'rates' => [2]],
],
])->save();
$calculator = new CourierQuoteFreightCalculator();
$this->assertEquals(7, $calculator->calculate('CITY', Carbon::parse('2026-10-01'), '广东省', 2, city: '深圳市')['amount']);
$this->assertEquals(14, $calculator->calculate('CITY', Carbon::parse('2026-10-01'), '江苏省', 2, city: '深圳市')['amount']);
}
private function waybill(string $code, string $name, float $base): array
{
$rows = [];
foreach ([['江苏', 0.48, 0.98, 1.28], ['广东', 0.75, 3, 3.3], ['深圳(广东)', 1, 3, 3.3]] as [$region, $r1, $r2, $r3]) {
$rows[] = ['region' => $region, 'flat' => [], 'base' => $base, 'rates' => [$r1, $r2, $r3], 'source_row' => 2];
}
return [
'version_id' => $code, 'company_name' => $name, 'conversion' => 'direct',
'structure' => ['mode' => 'waybill_rate', 'flat_bands' => [], 'then_mode' => '', 'first_weight' => null, 'rate_tiers' => [35.0, 50.0], 'rounding' => 'none'],
'rows' => $rows,
'params' => ['volumetric_divisor' => 6000, 'weight_limit_kg' => null, 'surcharges' => [], 'notes' => ['面单预充6元/单']],
];
}
private function rulesFile(array $versions): string
{
$path = storage_path('framework/testing/billing-rules-' . uniqid('', true) . '.json');
@mkdir(dirname($path), 0777, true);
file_put_contents($path, json_encode(['versions' => $versions], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
$this->files[] = $path;
return $path;
}
private function version(string $code, string $name, string $expcode, string $from, ?string $to): CourierQuoteVersion
{
$change = CourierQuoteChange::create([
'company_id' => $this->company->id,
'number' => 'BG-T-' . $code,
'source_quote_id' => $code,
'source_sheet_name' => $name,
'carrier_name' => $name,
'quote_type' => '标准快递报价',
'change_type' => '面单调整',
'status' => CourierQuoteChange::STATUS_APPROVED,
'effective_date' => $from,
'is_retrospective' => false,
'recalculation_status' => CourierQuoteChange::RECALCULATION_NOT_REQUIRED,
'summary' => '测试报价',
'created_by' => $this->user->id,
'submitted_by' => $this->user->id,
'submitted_at' => now(),
'approved_by' => $this->user->id,
'approved_at' => now(),
]);
return CourierQuoteVersion::create([
'company_id' => $this->company->id,
'version_code' => $code,
'source_quote_id' => $code,
'source_change_id' => $change->id,
'sheet_name' => $name,
'company_name' => $name,
'carrier_name' => $name,
'courier_expcode' => $expcode,
'quote_type' => '标准快递报价',
'effective_date' => $from,
'end_date' => $to,
'source_range' => 'A1:C2',
'values_json' => json_encode([['省份', '面单', '费率']], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'formulas_json' => json_encode([[]], JSON_THROW_ON_ERROR),
'status' => 'approved',
'signature_status' => '历史签署资料已归档',
'created_by' => $this->user->id,
'approved_by' => $this->user->id,
]);
}
}