user->attachPermission( Permission::where('name', 'read-admin-panel')->firstOrFail() ); } public function test_normal_products_page_renders_mirror_rows_and_filters(): void { $this->grantAdminPermission(); $state = [ 'status' => 'succeeded', 'last_succeeded_at' => '2026-08-15 17:51:03', 'last_full_succeeded_at' => '2026-08-10 03:15:00', 'mirror_row_count' => 547217, ]; $repository = \Mockery::mock(ProductCatalogMirrorRepository::class); $repository->shouldReceive('available')->once()->andReturnTrue(); $repository->shouldReceive('state')->once()->andReturn($state); $repository->shouldReceive('syncStatus')->once()->with($state)->andReturn([ 'code' => 'healthy', 'lag_minutes' => 4, ]); $repository->shouldReceive('ready')->once()->andReturnTrue(); $repository->shouldReceive('paginate') ->once() ->withArgs(static function (array $filters, int $page, int $limit): bool { return $filters['i_id'] === 'STYLE-001' && $filters['sku_id'] === 'SKU-001' && $filters['keyword'] === '胡桃' && $filters['enabled'] === '1' && $filters['stock_disabled'] === '0' && $filters['sale_price_min'] === '800' && $filters['sale_price_max'] === '900' && $page === 1 && $limit <= 50; }) ->andReturn([ 'data' => [(object) [ 'autoid' => 1001, 'i_id' => 'STYLE-001', 'sku_id' => 'SKU-001', 'name' => '岩板餐桌', 'short_name' => '餐桌', 'properties_value' => '1.4米', 'color' => '胡桃色', 'sale_price' => 899, 'cost_price' => 500, 'purchase_price' => 480, 'work_platform_price' => 475, 'work_platform_price_effect_time' => '2026-08-11 00:00:00', 'pricing_status' => '已定价', 'price_change_before_price' => 490, 'price_change_after_price' => 475, 'price_change_diff_amount' => -15, 'price_change_effect_time' => '2026-08-11 00:00:00', 'price_change_history' => [ [ 'id' => 12, 'record_type' => '1', 'record_type_label' => '变价', 'before_price' => 490, 'after_price' => 475, 'diff_amount' => -15, 'effect_start_time' => '2026-08-11 00:00:00', 'effect_end_time' => null, 'created_at' => '2026-08-10 09:30:00', ], [ 'id' => 3, 'record_type' => '0', 'record_type_label' => '定价', 'before_price' => null, 'after_price' => 490, 'diff_amount' => null, 'effect_start_time' => '2026-01-01 00:00:00', 'effect_end_time' => '2026-08-10 00:00:00', 'created_at' => '2025-12-31 08:00:00', ], ], 'price_change_history_count' => 2, 'work_platform_price_history_count' => 2, 'jushuitan_cost_price_history' => [[ 'source' => '聚水潭商品及库存管理', 'record_type_label' => '成本价变更', 'cost_price' => 500, 'before_price' => 480, 'after_price' => 500, 'diff_amount' => 20, 'effect_start_time' => '2026-08-12', 'effect_end_time' => null, 'remark' => '采购价:480->500', 'operator' => '简尚', 'operation' => '商品与库存管理导入', ]], 'jushuitan_cost_price_history_count' => 1, 'jushuitan_cost_price_observations' => [[ 'record_type_label' => '观测到成本价变化', 'before_price' => 499, 'after_price' => 500, 'effect_start_time' => '2026-10-20 12:00:00', 'remark' => '发现时间,不是真实改价时间', ]], 'purchase_order_unit_price' => 65, 'purchase_order_finished_at' => '2026-08-20 11:03:05', 'purchase_order_id' => 827380, 'data_sources' => ['商品及库存管理(普通商品资料)', '工作平台价格', '1688代发单价'], 'market_price' => 1099, 'brand' => '北木', 'category' => '住宅家具', 'supplier_name' => '新北供应商', 'weight' => 4.32, 'l' => 49, 'w' => 18.5, 'h' => 10.5, 'volume' => 9518.25, 'unit' => '个', 'board_material' => '正常板材', 'product_type' => '成品', 'creator_id' => 19203883, 'enabled' => 1, 'stock_disabled' => 0, 'modified' => '2026-08-15 10:00:00', ]], 'total' => 1, 'purchase_price_sync_status' => [ 'code' => 'ready', 'initialized' => true, 'last_succeeded_at' => '2026-08-21 12:00:00', ], 'work_platform_price_sync_status' => [ 'code' => 'ready', 'initialized' => true, 'last_succeeded_at' => '2026-09-01 03:02:00', ], 'price_change_sync_status' => [ 'code' => 'ready', 'initialized' => true, 'last_succeeded_at' => '2026-09-01 03:02:00', ], 'jushuitan_cost_price_history_sync_status' => [ 'code' => 'failed', 'initialized' => true, 'last_succeeded_at' => '2026-09-15 00:05:00', ], ]); $this->app->instance(ProductCatalogMirrorRepository::class, $repository); $response = $this->loginAs()->get(route('business.pages.show', [ 'section' => 'product-inventory', 'page' => 'normal-products', 'i_id' => 'STYLE-001', 'sku_id' => 'SKU-001', 'keyword' => '胡桃', 'enabled' => '1', 'stock_disabled' => '0', 'sale_price_min' => '900', 'sale_price_max' => '800', ])); $response ->assertOk() ->assertSee('data-product-catalog-page', false) ->assertSeeText('普通商品资料') ->assertSeeText('聚水潭成本价历史最近同步失败') ->assertSeeText('观测 1 条(最近20条)') ->assertSee('data-sync-state="healthy"', false) ->assertSeeText('同步正常') ->assertSeeText('最近同步 2026-08-15 17:51:03') ->assertSeeText('数据延迟 4 分钟') ->assertSeeText('最近校准 2026-08-10 03:15:00') ->assertSeeText('STYLE-001') ->assertSeeText('SKU-001') ->assertSeeText('岩板餐桌') ->assertSeeText('899.00') ->assertSeeText('普通商品采购价') ->assertSeeText('工作平台价格') ->assertSeeText('475.00') ->assertSeeText('2026-08-11') ->assertSeeText('已定价') ->assertSeeText('工作平台变价前价格') ->assertSeeText('490.00') ->assertSeeText('工作平台变价后价格') ->assertSeeText('工作平台差额') ->assertSeeText('-15.00') ->assertSeeText('聚水潭成本价记录') ->assertSeeText('工作平台价格记录') ->assertSeeText('变价前价格') ->assertSeeText('变价后价格') ->assertSeeText('2 条') ->assertSeeText('1 条') ->assertSee('data-price-history-trigger', false) ->assertSeeText('价格记录') ->assertSeeText('当前成本价') ->assertSeeText('当前工作平台价格') ->assertSeeText('导出价格记录') ->assertSeeText('生效结束日期') ->assertSeeText('记录时间') ->assertSeeText('操作方式') ->assertSeeText('操作人') ->assertSee('"remark":"采购价:480-\\u003E500"', false) ->assertSee('简尚', false) ->assertSee('商品与库存管理导入', false) ->assertSee('"record_type_label":"变价"', false) ->assertSee('"record_type_label":"定价"', false) ->assertSeeText('数据来源') ->assertSeeText('商品及库存管理(普通商品资料)') ->assertSeeText('1688代发单价') ->assertDontSee('1688代发单价', false) ->assertDontSeeText('65.00') ->assertSeeText('导出') ->assertDontSeeText('工作平台价格尚未完成首次同步') ->assertDontSeeText('商品价格变价记录尚未完成首次同步') ->assertSeeText('正常同步') ->assertSeeText('重量(kg)') ->assertSeeText('4.32') ->assertSeeText('长(cm)') ->assertSeeText('49.00') ->assertSeeText('体积(cm³)') ->assertSeeText('9,518.25') ->assertSeeText('正常板材') ->assertSeeText('成品') ->assertSeeText('19203883') ->assertDontSee('pic_url'); } public function test_normal_products_page_does_not_report_zero_jushuitan_history_before_initial_sync(): void { $this->grantAdminPermission(); $state = [ 'status' => 'succeeded', 'initial_completed_at' => '2026-08-14 21:15:36', 'last_succeeded_at' => '2026-09-15 10:00:00', 'mirror_row_count' => 558035, ]; $repository = \Mockery::mock(ProductCatalogMirrorRepository::class); $repository->shouldReceive('available')->once()->andReturnTrue(); $repository->shouldReceive('state')->once()->andReturn($state); $repository->shouldReceive('syncStatus')->once()->with($state)->andReturn([ 'code' => 'healthy', 'lag_minutes' => 2, ]); $repository->shouldReceive('ready')->once()->andReturnTrue(); $repository->shouldReceive('paginate')->once()->andReturn([ 'data' => [(object) [ 'autoid' => 15961, 'sku_id' => '15961G', 'name' => '测试商品', 'cost_price' => 65, 'jushuitan_cost_price_history' => [], 'jushuitan_cost_price_history_count' => 0, ]], 'total' => 1, 'jushuitan_cost_price_history_sync_status' => [ 'code' => 'initial_failed', 'initialized' => false, 'last_succeeded_at' => null, ], ]); $this->app->instance(ProductCatalogMirrorRepository::class, $repository); $this->loginAs()->get(route('business.pages.show', [ 'section' => 'product-inventory', 'page' => 'normal-products', 'sku_id' => '15961G', ])) ->assertOk() ->assertSee('data-jushuitan-cost-price-history-sync-state="initial_failed"', false) ->assertSeeText('聚水潭成本价记录首次同步失败') ->assertSeeText('当前记录数量不代表聚水潭真实历史') ->assertSeeText('待同步') ->assertSee('title="聚水潭成本价记录尚未完成首次同步"', false); } public function test_normal_products_export_uses_all_filters_and_includes_data_source_column(): void { $this->grantAdminPermission(); $repository = \Mockery::mock(ProductCatalogMirrorRepository::class); $repository->shouldReceive('available')->once()->andReturnTrue(); $repository->shouldReceive('ready')->once()->andReturnTrue(); $repository->shouldReceive('exportRows') ->once() ->withArgs(static function (array $filters): bool { return $filters['sku_id'] === 'SKU-001' && $filters['supplier_name'] === '新北供应商' && $filters['sale_price_min'] === '800' && $filters['sale_price_max'] === '900'; }) ->andReturn((static function (): \Generator { yield (object) [ 'autoid' => 1001, 'i_id' => 'STYLE-001', 'sku_id' => 'SKU-001', 'name' => '岩板餐桌', 'short_name' => '餐桌', 'properties_value' => '1.4米', 'color' => '胡桃色', 'brand' => '北木', 'category' => '住宅家具', 'supplier_name' => '新北供应商', 'sale_price' => 899, 'cost_price' => 500, 'purchase_price' => 480, 'work_platform_price' => 475, 'work_platform_price_effect_time' => '2026-08-11 00:00:00', 'pricing_status' => '已定价', 'price_change_before_price' => 490, 'price_change_after_price' => 475, 'price_change_diff_amount' => -15, 'price_change_effect_time' => '2026-08-11 00:00:00', 'purchase_order_unit_price' => 65, 'data_sources' => ['商品及库存管理(普通商品资料)', '工作平台价格', '1688代发单价'], 'market_price' => 1099, 'enabled' => 1, 'stock_disabled' => 0, 'modified' => '2026-08-15 10:00:00', ]; })()); $this->app->instance(ProductCatalogMirrorRepository::class, $repository); $response = $this->loginAs()->get(route('business.product-inventory.normal-products.export', [ 'sku_id' => 'SKU-001', 'supplier_name' => '新北供应商', 'sale_price_min' => '900', 'sale_price_max' => '800', ])); $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('工作平台价格', $content); $this->assertStringContainsString('475', $content); $this->assertStringContainsString('定价状态', $content); $this->assertStringContainsString('已定价', $content); $this->assertStringContainsString('变价前价格', $content); $this->assertStringContainsString('490', $content); $this->assertStringContainsString('变价后价格', $content); $stream = fopen('php://memory', 'r+'); fwrite($stream, $content); rewind($stream); $header = fgetcsv($stream); fclose($stream); $this->assertIsArray($header); $this->assertNotContains('1688代发单价', $header); $this->assertStringContainsString('商品及库存管理(普通商品资料);工作平台价格;1688代发单价', $content); $this->assertStringContainsString('SKU-001', $content); } public function test_normal_products_page_marks_work_platform_prices_as_not_yet_synchronized(): void { $this->grantAdminPermission(); $state = [ 'status' => 'succeeded', 'initial_completed_at' => '2026-08-14 21:15:36', 'last_succeeded_at' => '2026-08-21 12:00:00', 'mirror_row_count' => 547217, ]; $repository = \Mockery::mock(ProductCatalogMirrorRepository::class); $repository->shouldReceive('available')->once()->andReturnTrue(); $repository->shouldReceive('state')->once()->andReturn($state); $repository->shouldReceive('syncStatus')->once()->with($state)->andReturn([ 'code' => 'healthy', 'lag_minutes' => 1, ]); $repository->shouldReceive('ready')->once()->andReturnTrue(); $repository->shouldReceive('paginate')->once()->andReturn([ 'data' => [], 'total' => 0, 'work_platform_price_sync_status' => [ 'code' => 'pending', 'initialized' => false, 'last_succeeded_at' => null, ], 'price_change_sync_status' => [ 'code' => 'ready', 'initialized' => true, 'last_succeeded_at' => '2026-09-01 03:02:00', ], ]); $this->app->instance(ProductCatalogMirrorRepository::class, $repository); $this->loginAs()->get(route('business.pages.show', [ 'section' => 'product-inventory', 'page' => 'normal-products', ])) ->assertOk() ->assertSee('data-work-platform-price-sync-state="pending"', false) ->assertSeeText('工作平台价格尚未完成首次同步') ->assertSeeText('当前仅显示普通商品采购价'); } public function test_normal_products_page_does_not_treat_initial_sync_as_empty_data(): void { $this->grantAdminPermission(); $state = ['status' => 'running']; $repository = \Mockery::mock(ProductCatalogMirrorRepository::class); $repository->shouldReceive('available')->once()->andReturnTrue(); $repository->shouldReceive('state')->once()->andReturn($state); $repository->shouldReceive('syncStatus')->once()->with($state)->andReturn([ 'code' => 'initializing', 'lag_minutes' => null, ]); $repository->shouldReceive('ready')->once()->andReturnFalse(); $repository->shouldNotReceive('paginate'); $this->app->instance(ProductCatalogMirrorRepository::class, $repository); $this->loginAs()->get(route('business.pages.show', [ 'section' => 'product-inventory', 'page' => 'normal-products', ])) ->assertOk() ->assertSeeText('普通商品资料正在执行首次全量同步') ->assertSeeText('首次同步中') ->assertDontSeeText('当前镜像中没有普通商品资料。'); } public function test_normal_products_page_reports_an_unavailable_mirror(): void { $this->grantAdminPermission(); $repository = \Mockery::mock(ProductCatalogMirrorRepository::class); $repository->shouldReceive('available')->once()->andReturnFalse(); $repository->shouldNotReceive('state'); $repository->shouldNotReceive('syncStatus'); $repository->shouldNotReceive('ready'); $repository->shouldNotReceive('paginate'); $this->app->instance(ProductCatalogMirrorRepository::class, $repository); $this->loginAs()->get(route('business.pages.show', [ 'section' => 'product-inventory', 'page' => 'normal-products', ])) ->assertOk() ->assertSeeText('共享业务镜像尚未建立') ->assertDontSeeText('当前镜像中没有普通商品资料。'); } public function test_normal_products_page_keeps_old_rows_visible_after_a_sync_failure(): void { $this->grantAdminPermission(); $state = [ 'status' => 'failed', 'run_mode' => 'incremental', 'initial_completed_at' => '2026-08-14 21:15:36', 'last_succeeded_at' => '2026-08-15 17:51:03', 'mirror_row_count' => 547217, ]; $repository = \Mockery::mock(ProductCatalogMirrorRepository::class); $repository->shouldReceive('available')->once()->andReturnTrue(); $repository->shouldReceive('state')->once()->andReturn($state); $repository->shouldReceive('syncStatus')->once()->with($state)->andReturn([ 'code' => 'failed', 'lag_minutes' => 18, ]); $repository->shouldReceive('ready')->once()->andReturnTrue(); $repository->shouldReceive('paginate')->once()->andReturn([ 'data' => [(object) [ 'autoid' => 1001, 'i_id' => 'STYLE-OLD', 'sku_id' => 'SKU-OLD', 'name' => '最近成功同步的商品', 'short_name' => null, 'properties_value' => null, 'color' => null, 'sale_price' => 100, 'cost_price' => null, 'purchase_price' => null, 'market_price' => null, 'brand' => null, 'category' => null, 'supplier_name' => null, 'enabled' => 1, 'stock_disabled' => 0, 'modified' => '2026-08-15 17:45:00', ]], 'total' => 1, ]); $this->app->instance(ProductCatalogMirrorRepository::class, $repository); $this->loginAs()->get(route('business.pages.show', [ 'section' => 'product-inventory', 'page' => 'normal-products', ])) ->assertOk() ->assertSee('data-sync-state="failed"', false) ->assertSeeText('同步失败,显示最近数据') ->assertSeeText('最近成功同步的商品'); } public function test_normal_products_export_reports_an_unavailable_mirror(): void { $this->grantAdminPermission(); $repository = \Mockery::mock(ProductCatalogMirrorRepository::class); $repository->shouldReceive('available')->once()->andReturnFalse(); $repository->shouldNotReceive('ready'); $repository->shouldNotReceive('exportRows'); $this->app->instance(ProductCatalogMirrorRepository::class, $repository); $this->withExceptionHandling() ->loginAs() ->get(route('business.product-inventory.normal-products.export')) ->assertServiceUnavailable(); } public function test_normal_products_export_rejects_users_without_sidebar_permission(): void { $subject = 'user_' . $this->user->id; $this->user->roles()->sync([\App\Models\Auth\Role::where('name', 'admin')->firstOrFail()->id]); setting()->set('system.sidebar_permissions', json_encode([ 'configured' => [$subject], 'subjects' => [ $subject => ['company-management/basic-information'], ], ], JSON_UNESCAPED_UNICODE)); $this->withExceptionHandling() ->loginAs() ->get(route('business.product-inventory.normal-products.export')) ->assertForbidden(); } public function test_normal_products_page_rejects_users_without_sidebar_permission(): void { $subject = 'user_' . $this->user->id; $this->user->roles()->sync([\App\Models\Auth\Role::where('name', 'admin')->firstOrFail()->id]); setting()->set('system.sidebar_permissions', json_encode([ 'configured' => [$subject], 'subjects' => [ $subject => ['company-management/basic-information'], ], ], JSON_UNESCAPED_UNICODE)); $this->withExceptionHandling() ->loginAs() ->get(route('business.pages.show', [ 'section' => 'product-inventory', 'page' => 'normal-products', ])) ->assertForbidden(); } }