grantPermission(); $repository = Mockery::mock(ShippingOrderRepository::class); $repository->shouldReceive('paginate')->once()->withArgs(function (array $filters, int $page, int $limit, string $mode): bool { return $filters === [ 'express_no' => 'EXP-001', 'sourceno' => 'TRADE-001', 'barcode' => 'BAR-001', 'supplier_name' => ['新北供应商'], 'shop_name' => ['测试店铺'], 'warehouse_name' => ['一号仓'], 'express_company' => ['顺丰'], 'ship_from' => '2026-09-01', 'ship_to' => '2026-09-16', ] && $page === 1 && $limit > 0 && $mode === 'detail'; })->andReturn(['data' => [], 'total' => 0]); $repository->shouldReceive('syncStatus')->once()->andReturn([ 'code' => 'succeeded', 'last_succeeded_at' => '2026-09-16 10:16:36', ]); $this->app->instance(ShippingOrderRepository::class, $repository); $this->loginAs()->get(route('business.pages.show', [ 'section' => 'courier-payables', 'page' => 'shipping-data', 'express_no' => 'EXP-001', 'sourceno' => 'TRADE-001', 'barcode' => 'BAR-001', 'supplier_name' => '新北供应商', 'shop_name' => '测试店铺', 'warehouse_name' => '一号仓', 'express_company' => '顺丰', 'ship_from' => '2026-09-01', 'ship_to' => '2026-09-16', 'mode' => 'detail', ])) ->assertOk() ->assertSee('name="barcode"', false) ->assertSee('name="supplier_name[]"', false) ->assertSee('name="shop_name[]"', false) ->assertSee('name="warehouse_name[]"', false) ->assertSee('name="express_company[]"', false) ->assertSee('type="date" name="ship_from"', false) ->assertSee('data-date-picker-input="shipping-from"', false) ->assertSeeText('导出') ->assertSeeText('导出明细'); } public function test_page_shows_partial_backfill_and_pending_freight_warning(): void { $this->grantPermission(); $repository = Mockery::mock(ShippingOrderRepository::class); $repository->shouldReceive('paginate')->once()->andReturn(['data' => [], 'total' => 0]); $repository->shouldReceive('syncStatus')->once()->andReturn([ 'code' => 'partial', 'last_succeeded_at' => '2026-09-24 09:00:00', 'freight_pending' => true, 'warning' => '2 个日期窗口的运费和结算重量待补,空白不代表 0。', ]); $this->app->instance(ShippingOrderRepository::class, $repository); $this->loginAs()->get(route('business.pages.show', [ 'section' => 'courier-payables', 'page' => 'shipping-data', 'ship_from' => '2026-09-01', 'ship_to' => '2026-09-24', ])) ->assertOk() ->assertSeeText('部分数据已同步 · 运费待补') ->assertSeeText('最近成功同步 2026-09-24 09:00:00') ->assertSeeText('2 个日期窗口的运费和结算重量待补,空白不代表 0。') ->assertDontSeeText('数据新鲜'); } public function test_page_distinguishes_pending_items_from_pending_freight(): void { $this->grantPermission(); $repository = Mockery::mock(ShippingOrderRepository::class); $repository->shouldReceive('paginate')->once()->andReturn(['data' => [], 'total' => 0]); $repository->shouldReceive('syncStatus')->once()->andReturn([ 'code' => 'partial', 'last_succeeded_at' => null, 'freight_pending' => false, 'warning' => '1 个日期窗口的商品或金额待核对。', ]); $this->app->instance(ShippingOrderRepository::class, $repository); $this->loginAs()->get(route('business.pages.show', [ 'section' => 'courier-payables', 'page' => 'shipping-data', ])) ->assertOk() ->assertSeeText('部分数据已同步 · 商品或金额待核对') ->assertSeeText('1 个日期窗口的商品或金额待核对。') ->assertDontSeeText('运费待补'); } public function test_page_hides_repository_sql_exception_details(): void { $this->grantPermission(); $repository = Mockery::mock(ShippingOrderRepository::class); $repository->shouldReceive('paginate')->once()->andThrow(new \RuntimeException( 'SQLSTATE[42S02]: table SECRET_MIRROR_TABLE does not exist', )); $repository->shouldNotReceive('syncStatus'); $this->app->instance(ShippingOrderRepository::class, $repository); $this->loginAs()->get(route('business.pages.show', [ 'section' => 'courier-payables', 'page' => 'shipping-data', ])) ->assertOk() ->assertSeeText('发货数据暂时无法读取(可能尚未运行迁移或同步)。') ->assertDontSee('SQLSTATE') ->assertDontSee('SECRET_MIRROR_TABLE'); } public function test_export_uses_current_filters_and_keeps_identifiers_unprefixed(): void { $this->grantPermission(); $repository = Mockery::mock(ShippingOrderRepository::class); $repository->shouldReceive('exportRows')->once()->withArgs(function (array $filters, int $batchSize, string $mode): bool { return $filters['barcode'] === '6901234567890' && $filters['supplier_name'] === ['新北供应商'] && $filters['shop_name'] === ['测试店铺'] && $filters['warehouse_name'] === ['一号仓'] && $filters['express_company'] === ['顺丰'] && $filters['ship_from'] === '2026-09-01' && $filters['ship_to'] === '2026-09-16' && $batchSize === 1000 && $mode === 'merged'; })->andReturn((static function (): \Generator { yield [ 'express_no' => '01234567890123456789', 'trade_no' => 'TRADE-001', 'barcode' => '6901234567890', 'product_name' => '测试商品', 'qty' => 2, 'shop_name' => '测试店铺', 'warehouse_name' => '一号仓', 'supplier_name' => '新北供应商', 'express_name1' => '顺丰速运', 'ship_time' => '2026-09-10 12:00:00', ]; })()); $this->app->instance(ShippingOrderRepository::class, $repository); $response = $this->loginAs()->get(route('business.courier-payables.shipping-data.export', [ 'barcode' => '6901234567890', 'supplier_name' => '新北供应商', 'shop_name' => '测试店铺', 'warehouse_name' => '一号仓', 'express_company' => '顺丰', 'ship_from' => '2026-09-01', 'ship_to' => '2026-09-16', ])); $response->assertOk(); $this->assertStringContainsString('attachment;', (string) $response->headers->get('content-disposition')); $content = $response->streamedContent(); $this->assertStringStartsWith(chr(0xEF) . chr(0xBB) . chr(0xBF), $content); $this->assertStringContainsString('快递单号', $content); $this->assertStringContainsString('01234567890123456789', $content); $this->assertStringContainsString('6901234567890', $content); $this->assertStringNotContainsString("'01234567890123456789", $content); $this->assertStringNotContainsString("'6901234567890", $content); $this->assertStringContainsString('新北供应商', $content); } private function grantPermission(): void { $this->user->attachPermission( Permission::where('name', 'read-admin-panel')->firstOrFail(), ); } }