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();
}
}