'matched', 'freight_cents' => 1000, 'matches' => [['courier' => '首发快递']]]; $rates = ['首发快递' => ['rate_basis_points' => 5000]]; foreach (['外采快递', '线下快递'] as $name) { foreach (['express_name1', 'express_name2'] as $field) { $row = $calculator->apply($base + [$field => ' ' . $name . ' '], $rates); $this->assertSame('excluded_courier', $row['deduction_state']); $this->assertSame(0, $row['deduction_cents']); $this->assertFalse(AccessoryReshipPageData::inCategory($row, 'ready')); $this->assertTrue(AccessoryReshipPageData::inCategory($row, 'all')); } } $ready = $calculator->apply($base + ['express_name1' => '正常快递'], $rates); $this->assertTrue(AccessoryReshipPageData::inCategory($ready, 'ready')); $this->assertSame(500, $ready['deduction_cents']); foreach ([0, 1, null] as $freight) { $row = $calculator->apply(array_replace($base, ['freight_cents' => $freight]), ['首发快递' => ['rate_basis_points' => 1]]); $this->assertFalse(AccessoryReshipPageData::inCategory($row, 'ready')); } foreach (['zero_rate', 'selection_required', 'missing_config', 'invalid_amount', 'unmatched'] as $state) { $this->assertFalse(AccessoryReshipPageData::inCategory(array_replace($ready, ['deduction_state' => $state]), 'ready')); } } private function selections(): AccessoryReshipSelectionStore { $store = Mockery::mock(AccessoryReshipSelectionStore::class); $store->shouldReceive('forMonth')->andReturn([]); return $store; } private function configs(): AccessoryReshipCourierStore { $store = Mockery::mock(AccessoryReshipCourierStore::class); $store->shouldReceive('activeRates')->andReturn(['快递' => ['courier_code' => 'TEST', 'rate_basis_points' => 5000], '顺丰' => ['courier_code' => 'SF', 'rate_basis_points' => 10000]]); return $store; } protected function tearDown(): void { Mockery::close(); parent::tearDown(); } public function test_january_uses_current_month_and_previous_two_months(): void { $repo = Mockery::mock(ShippingOrderRepository::class); $repo->shouldReceive('accessoryReshipBatches')->once()->with('2026-01-01', '2026-02-01', '2025-11-01')->andReturn((function () { yield ['accessories' => [['trade_no' => '001', 'express_no' => 'BF001', 'express_name1' => '补发快递', 'ship_time' => '2026-09-01', 'barcode' => 'SKU-1', 'freight' => '2.50']], 'originals' => []]; })()); $data = new AccessoryReshipPageData($repo, new AccessoryReshipMatcher, $this->configs(), new AccessoryReshipRateCalculator, $this->selections()); $rows = iterator_to_array($data->rows(['month' => '2026-01'])); $this->assertSame('unmatched', $rows[0]['status']); $this->assertSame(0, $rows[0]['deduction_cents']); } public function test_summary_does_not_duplicate_candidate_freight_and_paginates(): void { $request = Request::create('/workspace/courier-payables/accessory-reship-deductions?month=2026-09&match_status=all&page=2'); Container::getInstance()->instance('request', $request); $accessories = []; for ($i = 0; $i < 105; $i++) $accessories[] = ['trade_no' => '001', 'express_no' => 'BF001', 'express_name1' => '补发快递', 'ship_time' => '2026-09-01', 'barcode' => 'SKU-1', 'freight' => '0.10']; $original = ['trade_no' => '001', 'barcode' => 'SKU', 'express_no' => 'FIRST', 'express_name1' => '快递', 'ship_time' => '2026-08-01', 'warehouse_name' => '成品仓']; $repo = Mockery::mock(ShippingOrderRepository::class); $repo->shouldReceive('accessoryReshipBatches')->andReturn((function () use ($accessories, $original) { yield ['accessories' => $accessories, 'originals' => [$original, array_replace($original, ['express_no' => 'SECOND'])]]; })()); $data = (new AccessoryReshipPageData($repo, new AccessoryReshipMatcher, $this->configs(), new AccessoryReshipRateCalculator, $this->selections()))->page(); $this->assertCount(50, $data['reshipRows']); $this->assertSame(105, $data['reshipRows']->total()); $this->assertSame(105, $data['reshipSummary']['matched']); $this->assertSame(525, $data['reshipSummary']['amount']); $this->assertSame(105, $data['reshipSummary']['ready']); $this->assertSame(0, $data['reshipSummary']['ambiguous']); } public function test_filters_search_all_rows_and_keep_summary_pagination_consistent(): void { Container::getInstance()->instance('request', Request::create('/?month=2026-09&match_status=all&courier=补发&original_month=2026-08&page=2')); $accessories = []; for ($i = 0; $i < 105; $i++) $accessories[] = ['trade_no'=>'001', 'express_no'=>'BF'.$i, 'express_name1'=>'补发快递', 'ship_time'=>'2026-09-15', 'barcode'=>'SKU-1', 'freight'=>'0.10']; $accessories[] = array_replace($accessories[0], ['trade_no'=>'NO-MATCH']); $repo = Mockery::mock(ShippingOrderRepository::class); $repo->shouldReceive('accessoryReshipBatches')->andReturn((function () use ($accessories) { yield ['accessories'=>$accessories, 'originals'=>[['trade_no'=>'001','barcode'=>'SKU','express_no'=>'SF001','express_name1'=>'顺丰','ship_time'=>'2026-08-01','warehouse_name'=>'成品仓']]]; })()); $data = (new AccessoryReshipPageData($repo, new AccessoryReshipMatcher, $this->configs(), new AccessoryReshipRateCalculator, $this->selections()))->page(); $this->assertNull($data['reshipError']); $this->assertCount(50, $data['reshipRows']); $this->assertSame(105, $data['reshipRows']->total()); $this->assertSame(105, $data['reshipSummary']['all']); $this->assertSame(1050, $data['reshipSummary']['amount']); } public function test_filters_require_same_candidate_and_support_fields_and_unknown_amount(): void { $data = new AccessoryReshipPageData(Mockery::mock(ShippingOrderRepository::class), new AccessoryReshipMatcher, $this->configs(), new AccessoryReshipRateCalculator, $this->selections()); $row = ['trade_no'=>'T001','express_no'=>'BF001','express_name1'=>'','express_name2'=>'补发快递','ship_time'=>'2026-09-15','barcode'=>'SKU-1','normalized_barcode'=>'SKU','freight_cents'=>1230,'deduction_cents'=>1230,'matches'=>[['courier'=>'顺丰','express_no'=>'SF001','month'=>'2026-08'],['courier'=>'圆通','express_no'=>'YT001','month'=>'2026-09']]]; $this->assertTrue($data->matchesFilters($row, ['trade_no'=>'T001','express_no'=>'bf001','courier'=>'补发','ship_date'=>'2026-09-15','barcode'=>'SKU-1','normalized_barcode'=>'SKU','freight'=>'12.30','deduction_amount'=>'12.30','original_courier'=>'顺丰','original_express_no'=>'sf001','original_month'=>'2026-08'])); $this->assertFalse($data->matchesFilters($row, ['original_courier'=>'顺丰','original_express_no'=>'YT001'])); $this->assertFalse($data->matchesFilters($row, ['trade_no'=>'missing'])); $row['freight_cents'] = $row['deduction_cents'] = null; $this->assertTrue($data->matchesFilters($row, ['freight'=>'待核实','deduction_amount'=>'待核实'])); $this->assertFalse($data->matchesFilters($row, ['deduction_amount'=>'0.00'])); } }