290 lines
17 KiB
PHP
290 lines
17 KiB
PHP
<?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,
|
||
]);
|
||
}
|
||
}
|