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, ]); } }