From 6db4244fdf1e2dfab3cf93e0b098358ca1fa0c43 Mon Sep 17 00:00:00 2001 From: limeng <1131177064@qq.com> Date: Sat, 15 Aug 2026 17:46:42 +0800 Subject: [PATCH 1/2] chore: start product inventory task -- 2.47.2 From 5e8b42000d99a09d527b7ad4611766446f2b50ca Mon Sep 17 00:00:00 2001 From: limeng <1131177064@qq.com> Date: Sat, 15 Aug 2026 18:15:16 +0800 Subject: [PATCH 2/2] feat: add normal product catalog mirror page --- app/Http/Controllers/Business/Pages.php | 122 ++++ config/business_navigation.php | 8 + config/upstream_mirrors.php | 16 +- docs/上游业务镜像清单.md | 3 +- .../Contracts/BusinessMirrorDataset.php | 3 + .../ProductCatalogMirrorRepository.php | 128 +++++ .../BusinessMirrorDatasetRegistry.php | 13 +- .../Services/BusinessMirrorSchemaManager.php | 48 ++ .../normal-products.blade.php | 526 ++++++++++++++++++ .../Business/ProductCatalogPageTest.php | 151 +++++ .../ProductCatalogMirrorRepositoryTest.php | 292 ++++++++++ 11 files changed, 1305 insertions(+), 5 deletions(-) create mode 100644 modules/EcommerceFinance/Repositories/ProductCatalogMirrorRepository.php create mode 100644 resources/views/business/pages/product-inventory/normal-products.blade.php create mode 100644 tests/Feature/Business/ProductCatalogPageTest.php create mode 100644 tests/Unit/EcommerceFinance/ProductCatalogMirrorRepositoryTest.php diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 2ee0f3c5..8d292e4c 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -19,6 +19,7 @@ use Modules\EcommerceFinance\Repositories\EdgeBandingRepository; use Modules\EcommerceFinance\Repositories\InventoryRepository; use Modules\EcommerceFinance\Repositories\JdWalletBillRepository; use Modules\EcommerceFinance\Repositories\PinduoduoSpreadBillRepository; +use Modules\EcommerceFinance\Repositories\ProductCatalogMirrorRepository; use Modules\EcommerceFinance\Repositories\ProductPriceMirrorRepository; use Modules\EcommerceFinance\Repositories\RawBoardPayablesRepository; use Modules\EcommerceFinance\Repositories\SupplierPayableReportRepository; @@ -38,6 +39,7 @@ class Pages extends Controller public function __construct( private readonly EdgeBandingRepository $edgeBanding, private readonly InventoryRepository $inventory, + private readonly ProductCatalogMirrorRepository $productCatalog, private readonly ProductPriceMirrorRepository $productPrices, private readonly RawBoardPayablesRepository $rawBoardPayables, private readonly SupplierPayableReportRepository $supplierPayableReports, @@ -235,6 +237,7 @@ class Pages extends Controller 'marketing-account-bills' => $this->contentForMarketingAccountBills($tab), 'marketing-account-balance' => $this->contentForMarketingAccountBalance($tab), 'zhongcai-reconciliation' => $this->contentForZhongcaiReconciliation(), + 'normal-products' => $this->contentForNormalProducts(), 'product-prices' => $this->contentForProductPrices(), 'board-deductions' => $this->contentForBoardDeductions(), 'supplier-payables-report' => $this->contentForSupplierPayablesReport(), @@ -505,6 +508,114 @@ class Pages extends Controller ); } + protected function contentForNormalProducts(): array + { + $filters = [ + 'i_id' => mb_substr(trim((string) request('i_id', '')), 0, 50), + 'sku_id' => mb_substr(trim((string) request('sku_id', '')), 0, 50), + 'keyword' => mb_substr(trim((string) request('keyword', '')), 0, 100), + 'brand' => mb_substr(trim((string) request('brand', '')), 0, 100), + 'category' => mb_substr(trim((string) request('category', '')), 0, 100), + 'supplier_name' => mb_substr(trim((string) request('supplier_name', '')), 0, 191), + 'enabled' => in_array((string) request('enabled', ''), ['-1', '0', '1'], true) + ? (string) request('enabled') + : '', + 'stock_disabled' => in_array((string) request('stock_disabled', ''), ['0', '1'], true) + ? (string) request('stock_disabled') + : '', + 'sale_price_min' => $this->decimalFilter(request('sale_price_min')), + 'sale_price_max' => $this->decimalFilter(request('sale_price_max')), + ]; + + if ($filters['sale_price_min'] !== null + && $filters['sale_price_max'] !== null + && (float) $filters['sale_price_min'] > (float) $filters['sale_price_max']) { + [$filters['sale_price_min'], $filters['sale_price_max']] = [ + $filters['sale_price_max'], + $filters['sale_price_min'], + ]; + } + + $page = max((int) request('page', 1), 1); + $limit = min(max((int) setting('default.list_limit', 25), 1), 50); + + try { + if (! $this->productCatalog->available()) { + return $this->emptyProductCatalogContent( + $filters, + $limit, + $page, + '共享业务镜像尚未建立,普通商品资料暂时无法读取。', + null, + ); + } + + $state = $this->productCatalog->state(); + if (! $this->productCatalog->ready()) { + return $this->emptyProductCatalogContent( + $filters, + $limit, + $page, + '普通商品资料正在执行首次全量同步,完成前不会把未同步数据误判为无数据。', + $state, + ); + } + + $result = $this->productCatalog->paginate($filters, $page, $limit); + $paginator = new LengthAwarePaginator( + $result['data'], + $result['total'], + $limit, + $page, + [ + 'path' => request()->url(), + 'query' => request()->except('page'), + ], + ); + + return [ + 'productCatalogRows' => $paginator, + 'productCatalogFilters' => $filters, + 'productCatalogError' => null, + 'productCatalogState' => $state, + ]; + } catch (Throwable $exception) { + report($exception); + + return $this->emptyProductCatalogContent( + $filters, + $limit, + $page, + '共享业务数据暂时无法读取,请稍后重试。', + null, + ); + } + } + + private function emptyProductCatalogContent( + array $filters, + int $limit, + int $page, + string $error, + ?array $state, + ): array { + return [ + 'productCatalogRows' => new LengthAwarePaginator( + [], + 0, + $limit, + $page, + [ + 'path' => request()->url(), + 'query' => request()->except('page'), + ], + ), + 'productCatalogFilters' => $filters, + 'productCatalogError' => $error, + 'productCatalogState' => $state, + ]; + } + protected function contentForProductPrices(): array { $filters = [ @@ -1335,6 +1446,17 @@ class Pages extends Controller return checkdate($month, $day, $year) ? $date : ''; } + private function decimalFilter(mixed $value): ?string + { + $value = trim((string) $value); + + if ($value === '' || ! is_numeric($value)) { + return null; + } + + return (string) (float) $value; + } + protected function redirectBack(string $section, string $page, ?string $tab) { $parameters = [ diff --git a/config/business_navigation.php b/config/business_navigation.php index a3310bf7..b791fe8a 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -37,6 +37,14 @@ return [ ['slug' => 'change-log', 'title' => '店铺变更统日志'], ], ], + [ + 'slug' => 'product-inventory', + 'title' => '商品及库存管理', + 'icon' => 'inventory_2', + 'pages' => [ + ['slug' => 'normal-products', 'title' => '普通商品资料'], + ], + ], [ 'slug' => 'fund-management', 'title' => '资金管理', diff --git a/config/upstream_mirrors.php b/config/upstream_mirrors.php index 2df70817..d44842f2 100644 --- a/config/upstream_mirrors.php +++ b/config/upstream_mirrors.php @@ -46,7 +46,21 @@ return [ 'operations_replica.rpt.fys_TrademarkInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'fys_TrademarkInfo', 'target' => 'ops_fys_trademark_info', 'keys' => ['Id'], 'watermark' => 'updateTime'], 'operations_replica.rpt.jst_Orders' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_Orders', 'target' => 'ops_jst_orders', 'keys' => ['Id'], 'watermark' => 'updateTime'], 'operations_replica.rpt.jst_OrderItem' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_OrderItem', 'target' => 'ops_jst_order_item', 'keys' => ['Id']], - 'operations_replica.rpt.jst_sku' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_sku', 'target' => 'ops_jst_sku', 'keys' => ['autoid']], + 'operations_replica.rpt.jst_sku' => [ + 'source_key' => 'operations_replica', + 'schema' => 'rpt', + 'object' => 'jst_sku', + 'target' => 'ops_jst_sku', + 'keys' => ['autoid'], + 'watermark' => 'modified', + 'indexes' => [ + ['sku_type', 'autoid'], + ['sku_type', 'i_id', 'autoid'], + ['sku_type', 'sku_id', 'autoid'], + ['sku_type', 'enabled', 'autoid'], + ['sku_type', 'stock_disabled', 'autoid'], + ], + ], 'operations_replica.rpt.jst_warehouseInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_warehouseInfo', 'target' => 'ops_jst_warehouse_info', 'keys' => ['Id'], 'watermark' => 'updateTime'], 'operations_replica.rpt.jst_skumap' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_skumap', 'target' => 'ops_jst_sku_map', 'keys' => ['shop_id', 'shop_i_id', 'shop_sku_id'], 'watermark' => 'modifiedTime'], 'operations_replica.rpt.ads_itemid_sales_cost_ratio_v2_di' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'ads_itemid_sales_cost_ratio_v2_di', 'target' => 'ops_item_sales_cost_ratio', 'keys' => ['stat_date', 'item_id'], 'watermark' => 'item_id_update'], diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index 41ee91ea..c0f5f38f 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -11,6 +11,7 @@ | 状态 | 说明 | | --- | --- | | 已完成 | `operations_replica/rpt.Bulk_Price_Limit` 已进入类型化表 `product_price_mirror_rows`,商品价格页面已读取镜像并支持完整分页。 | +| 已完成 | `operations_replica/rpt.jst_sku` 已进入 `ops_jst_sku`,普通商品资料页面固定读取 `sku_type = normal` 的共享镜像,并支持稳定分页和业务筛选。 | | 本次建立 | `config/upstream_mirrors.php` 中登记全部当前代码引用的原表;通用表级同步框架负责建表、稳定游标、断点续传、增量重叠和全量校准。 | | 已开始逐域切换 | 公司、店铺、商标页面在对应镜像完成首次全量后自动改读完整镜像表;首次全量前保留旧快照作为兼容回滚。其他业务域仍需逐项切换,不得宣称全项目已经完成。 | @@ -22,7 +23,7 @@ | 店铺资料 | `rpt.fys_shopInfo` | `ops_fys_shop_info` | | 商标资料 | `rpt.fys_TrademarkInfo` | `ops_fys_trademark_info` | | 订单 | `rpt.jst_Orders`、`rpt.jst_OrderItem` | `ops_jst_orders`、`ops_jst_order_item` | -| 商品与仓库 | `rpt.jst_sku`、`rpt.jst_warehouseInfo`、`rpt.jst_skumap` | `ops_jst_*` | +| 商品与仓库 | `rpt.jst_sku`、`rpt.jst_warehouseInfo`、`rpt.jst_skumap` | `ops_jst_*`(普通商品资料已切换) | | 推广费用 | `rpt.ads_itemid_sales_cost_ratio_v2_di` | `ops_item_sales_cost_ratio` | | 退款退货 | `rpt.jst_returnAndRefundInfo`、`rpt.jst_returnAndRefundItem` | `ops_jst_return_refund_*` | | 聚水潭店铺 | `rpt.jst_shopInfo` | `ops_jst_shop_info` | diff --git a/modules/EcommerceFinance/Contracts/BusinessMirrorDataset.php b/modules/EcommerceFinance/Contracts/BusinessMirrorDataset.php index 015d6b41..750c0bdd 100644 --- a/modules/EcommerceFinance/Contracts/BusinessMirrorDataset.php +++ b/modules/EcommerceFinance/Contracts/BusinessMirrorDataset.php @@ -18,4 +18,7 @@ interface BusinessMirrorDataset public function keyColumns(): array; public function watermarkColumn(): ?string; + + /** @return list> */ + public function indexDefinitions(): array; } diff --git a/modules/EcommerceFinance/Repositories/ProductCatalogMirrorRepository.php b/modules/EcommerceFinance/Repositories/ProductCatalogMirrorRepository.php new file mode 100644 index 00000000..b78582e6 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/ProductCatalogMirrorRepository.php @@ -0,0 +1,128 @@ +connection = $mirror->connection(); + } + + public function available(): bool + { + $schema = Schema::connection($this->connection->getName()); + + return $schema->hasTable('business_mirror_table_states') + && $schema->hasTable($this->dataset()->targetTable()); + } + + public function ready(): bool + { + return $this->mirror->ready($this->dataset()); + } + + /** @return array|null */ + public function state(): ?array + { + return $this->mirror->state($this->dataset()); + } + + /** @return array{data:list,total:int} */ + public function paginate(array $filters, int $page, int $perPage): array + { + if (! $this->ready()) { + throw new RuntimeException('business_mirror_dataset_not_ready'); + } + + $page = max($page, 1); + $perPage = min(max($perPage, 1), 50); + $query = $this->connection->table($this->dataset()->targetTable()) + ->where('sku_type', 'normal') + ->select([ + 'autoid', + 'i_id', + 'sku_id', + 'name', + 'short_name', + 'properties_value', + 'color', + 'sale_price', + 'cost_price', + 'purchase_price', + 'market_price', + 'brand', + 'category', + 'supplier_name', + 'enabled', + 'stock_disabled', + 'modified', + '_mirror_synced_at', + ]); + + foreach (['i_id', 'sku_id'] as $column) { + if (($filters[$column] ?? '') !== '') { + $query->where($column, $filters[$column]); + } + } + + if (($filters['keyword'] ?? '') !== '') { + $keyword = $this->contains($filters['keyword']); + $query->where(static function ($query) use ($keyword): void { + $query->whereRaw("name LIKE ? ESCAPE '!'", [$keyword]) + ->orWhereRaw("short_name LIKE ? ESCAPE '!'", [$keyword]) + ->orWhereRaw("properties_value LIKE ? ESCAPE '!'", [$keyword]) + ->orWhereRaw("color LIKE ? ESCAPE '!'", [$keyword]); + }); + } + + foreach (['brand', 'category', 'supplier_name'] as $column) { + if (($filters[$column] ?? '') !== '') { + $query->whereRaw($column . " LIKE ? ESCAPE '!'", [$this->contains($filters[$column])]); + } + } + + foreach (['enabled', 'stock_disabled'] as $column) { + if (($filters[$column] ?? '') !== '') { + $query->where($column, (int) $filters[$column]); + } + } + + if (($filters['sale_price_min'] ?? null) !== null) { + $query->where('sale_price', '>=', $filters['sale_price_min']); + } + if (($filters['sale_price_max'] ?? null) !== null) { + $query->where('sale_price', '<=', $filters['sale_price_max']); + } + + $total = (clone $query)->count(); + $rows = $query->orderBy('autoid') + ->forPage($page, $perPage) + ->get() + ->all(); + + return ['data' => $rows, 'total' => $total]; + } + + private function dataset(): BusinessMirrorDataset + { + return $this->datasets->get(self::DATASET_KEY); + } + + private function contains(string $value): string + { + return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim($value)) . '%'; + } +} diff --git a/modules/EcommerceFinance/Services/BusinessMirrorDatasetRegistry.php b/modules/EcommerceFinance/Services/BusinessMirrorDatasetRegistry.php index 5df609ee..6464d7a8 100644 --- a/modules/EcommerceFinance/Services/BusinessMirrorDatasetRegistry.php +++ b/modules/EcommerceFinance/Services/BusinessMirrorDatasetRegistry.php @@ -39,14 +39,19 @@ class BusinessMirrorDatasetRegistry $target = trim((string) ($definition['target'] ?? '')); $keys = array_values(array_filter(array_map('strval', (array) ($definition['keys'] ?? [])))); $watermark = trim((string) ($definition['watermark'] ?? '')); + $indexes = array_values(array_map( + static fn (mixed $columns): array => array_values(array_filter(array_map('strval', (array) $columns))), + (array) ($definition['indexes'] ?? []), + )); + $indexColumns = array_merge(...($indexes ?: [[]])); - foreach ([$source, $schema, $object, $target, ...$keys] as $identifier) { + foreach ([$source, $schema, $object, $target, ...$keys, ...$indexColumns] as $identifier) { if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $identifier) !== 1) { throw new InvalidArgumentException('业务镜像数据集包含不安全的标识符:' . $key); } } - if ($keys === [] || strlen($target) > 64) { + if ($keys === [] || in_array([], $indexes, true) || strlen($target) > 64) { throw new InvalidArgumentException('业务镜像数据集缺少稳定键或目标表名过长:' . $key); } @@ -54,7 +59,7 @@ class BusinessMirrorDatasetRegistry throw new InvalidArgumentException('业务镜像数据集的增量字段不安全:' . $key); } - return new readonly class($key, $source, $schema, $object, $target, $keys, $watermark ?: null) implements BusinessMirrorDataset { + return new readonly class($key, $source, $schema, $object, $target, $keys, $watermark ?: null, $indexes) implements BusinessMirrorDataset { public function __construct( private string $key, private string $source, @@ -63,6 +68,7 @@ class BusinessMirrorDatasetRegistry private string $target, private array $keys, private ?string $watermark, + private array $indexes, ) { } @@ -73,6 +79,7 @@ class BusinessMirrorDatasetRegistry public function targetTable(): string { return $this->target; } public function keyColumns(): array { return $this->keys; } public function watermarkColumn(): ?string { return $this->watermark; } + public function indexDefinitions(): array { return $this->indexes; } }; } } diff --git a/modules/EcommerceFinance/Services/BusinessMirrorSchemaManager.php b/modules/EcommerceFinance/Services/BusinessMirrorSchemaManager.php index 32b1aeb9..547b5401 100644 --- a/modules/EcommerceFinance/Services/BusinessMirrorSchemaManager.php +++ b/modules/EcommerceFinance/Services/BusinessMirrorSchemaManager.php @@ -32,6 +32,10 @@ class BusinessMirrorSchemaManager if ($watermark !== null && $this->isIndexable($columns, $watermark)) { $table->index($watermark, $this->indexName($dataset->targetTable(), 'watermark')); } + + foreach ($this->validatedIndexes($dataset, $columns) as $position => $index) { + $table->index($index, $this->configuredIndexName($dataset->targetTable(), $index, $position)); + } }); return $columns; @@ -51,6 +55,23 @@ class BusinessMirrorSchemaManager }); } + $configuredIndexes = $this->validatedIndexes($dataset, $columns); + $existingIndexes = collect($schema->getIndexes($dataset->targetTable())) + ->map(static fn (array $index): array => array_map('strtolower', (array) ($index['columns'] ?? []))) + ->all(); + $missingIndexes = array_values(array_filter( + $configuredIndexes, + static fn (array $index): bool => ! in_array(array_map('strtolower', $index), $existingIndexes, true), + )); + + if ($missingIndexes !== []) { + $schema->table($dataset->targetTable(), function (Blueprint $table) use ($dataset, $missingIndexes): void { + foreach ($missingIndexes as $position => $index) { + $table->index($index, $this->configuredIndexName($dataset->targetTable(), $index, $position)); + } + }); + } + return $columns; } @@ -147,4 +168,31 @@ class BusinessMirrorSchemaManager { return substr($table . '_' . $suffix, 0, 64); } + + /** @return list> */ + private function validatedIndexes(BusinessMirrorDataset $dataset, array $columns): array + { + $columnNames = array_map( + static fn (array $column): string => strtolower((string) $column['column_name']), + $columns, + ); + + foreach ($dataset->indexDefinitions() as $index) { + foreach ($index as $column) { + if (! in_array(strtolower($column), $columnNames, true) + || ! $this->isIndexable($columns, $column)) { + throw new RuntimeException('business_mirror_index_column_unsupported:' . $dataset->key()); + } + } + } + + return $dataset->indexDefinitions(); + } + + private function configuredIndexName(string $table, array $columns, int $position): string + { + $signature = implode('_', $columns) . '_' . $position; + + return substr($table . '_lookup_' . hash('sha256', $signature), 0, 64); + } } diff --git a/resources/views/business/pages/product-inventory/normal-products.blade.php b/resources/views/business/pages/product-inventory/normal-products.blade.php new file mode 100644 index 00000000..c76d4bb3 --- /dev/null +++ b/resources/views/business/pages/product-inventory/normal-products.blade.php @@ -0,0 +1,526 @@ +@php + $productCatalogRows = $content['productCatalogRows'] ?? null; + $productCatalogFilters = $content['productCatalogFilters'] ?? []; + $productCatalogError = $content['productCatalogError'] ?? null; + $productCatalogState = $content['productCatalogState'] ?? null; + $hasFilters = collect($productCatalogFilters)->contains( + static fn ($value) => $value !== null && $value !== '' + ); + $stateLabels = [ + 'succeeded' => '同步完成', + 'running' => '同步中', + 'failed' => '最近同步失败', + 'pending' => '等待同步', + ]; + $stateCode = is_array($productCatalogState) ? ($productCatalogState['status'] ?? '') : ''; +@endphp + + + +
+
+
+ + {{ $stateLabels[$stateCode] ?? ($productCatalogError ? '数据暂不可用' : '共享业务镜像') }} +
+
+ @if (!empty($productCatalogState['last_succeeded_at'])) + 最近同步 {{ $productCatalogState['last_succeeded_at'] }} + @endif + @if (isset($productCatalogState['mirror_row_count'])) + 镜像总行数 {{ number_format((int) $productCatalogState['mirror_row_count']) }} + @endif + @if ($productCatalogRows && !$productCatalogError) + 当前结果 {{ number_format($productCatalogRows->total()) }} 条 + @endif +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ + + + 重置 + +
+
+ + @if ($productCatalogError) + + @endif + +
+ + + + + + + + + + + + + + + + + + + + + + + @forelse ($productCatalogRows ?? [] as $row) + @php + $enabledLabel = match ((int) ($row->enabled ?? 0)) { + 1 => '已启用', + -1 => '已删除', + default => '已停用', + }; + $specification = collect([$row->properties_value ?? null, $row->color ?? null]) + ->filter(static fn ($value) => $value !== null && $value !== '') + ->unique() + ->implode(' / '); + @endphp + + + + + + + + + + + @foreach (['sale_price', 'cost_price', 'purchase_price', 'market_price'] as $priceField) + + @endforeach + + + + + @empty + + + + @endforelse + +
ID款式编码商品编码商品名称商品简称规格 / 颜色品牌类目供应商销售价成本价采购价市场价商品状态库存同步业务更新时间
{{ $row->autoid ?? '-' }}{{ $row->i_id ?? '-' }}{{ $row->sku_id ?? '-' }}{{ $row->name ?? '-' }}{{ $row->short_name ?? '-' }}{{ $specification ?: '-' }}{{ $row->brand ?? '-' }}{{ $row->category ?? '-' }}{{ $row->supplier_name ?? '-' }}{{ isset($row->{$priceField}) && $row->{$priceField} !== null ? number_format((float) $row->{$priceField}, 2) : '-' }} + + {{ $enabledLabel }} + + + + {{ (int) ($row->stock_disabled ?? 0) === 0 ? '正常同步' : '停止同步' }} + + {{ $row->modified ?? '-' }}
+ + @if ($productCatalogError) + 数据暂时不可用。 + @elseif ($hasFilters) + 没有符合当前筛选条件的普通商品资料。 + @else + 当前镜像中没有普通商品资料。 + @endif +
+ + @if ($productCatalogRows && !$productCatalogError) + + @endif +
+
diff --git a/tests/Feature/Business/ProductCatalogPageTest.php b/tests/Feature/Business/ProductCatalogPageTest.php new file mode 100644 index 00000000..da21e987 --- /dev/null +++ b/tests/Feature/Business/ProductCatalogPageTest.php @@ -0,0 +1,151 @@ +user->attachPermission( + Permission::where('name', 'read-admin-panel')->firstOrFail() + ); + } + + public function test_normal_products_page_renders_mirror_rows_and_filters(): void + { + $this->grantAdminPermission(); + + $repository = \Mockery::mock(ProductCatalogMirrorRepository::class); + $repository->shouldReceive('available')->once()->andReturnTrue(); + $repository->shouldReceive('state')->once()->andReturn([ + 'status' => 'succeeded', + 'last_succeeded_at' => '2026-08-15 17:51:03', + 'mirror_row_count' => 547217, + ]); + $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, + 'market_price' => 1099, + 'brand' => '北木', + 'category' => '住宅家具', + 'supplier_name' => '新北供应商', + 'enabled' => 1, + 'stock_disabled' => 0, + 'modified' => '2026-08-15 10:00:00', + ]], + 'total' => 1, + ]); + $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('最近同步 2026-08-15 17:51:03') + ->assertSeeText('STYLE-001') + ->assertSeeText('SKU-001') + ->assertSeeText('岩板餐桌') + ->assertSeeText('899.00') + ->assertSeeText('正常同步') + ->assertDontSee('pic_url'); + } + + public function test_normal_products_page_does_not_treat_initial_sync_as_empty_data(): void + { + $this->grantAdminPermission(); + + $repository = \Mockery::mock(ProductCatalogMirrorRepository::class); + $repository->shouldReceive('available')->once()->andReturnTrue(); + $repository->shouldReceive('state')->once()->andReturn(['status' => 'running']); + $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('普通商品资料正在执行首次全量同步') + ->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('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_rejects_users_without_sidebar_permission(): void + { + $subject = 'user_' . $this->user->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(); + } +} diff --git a/tests/Unit/EcommerceFinance/ProductCatalogMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/ProductCatalogMirrorRepositoryTest.php new file mode 100644 index 00000000..762aa7de --- /dev/null +++ b/tests/Unit/EcommerceFinance/ProductCatalogMirrorRepositoryTest.php @@ -0,0 +1,292 @@ + [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ], + 'upstream_mirrors.connection' => 'business_mirror', + 'upstream_mirrors.datasets' => [ + ProductCatalogMirrorRepository::DATASET_KEY => $this->datasetDefinition(), + ], + ]); + DB::purge('business_mirror'); + + Schema::connection('business_mirror')->create('business_mirror_table_states', function (Blueprint $table): void { + $table->string('dataset_key')->primary(); + $table->string('status')->nullable(); + $table->json('cursor')->nullable(); + $table->json('high_watermark')->nullable(); + $table->dateTime('initial_completed_at')->nullable(); + $table->dateTime('last_succeeded_at')->nullable(); + $table->unsignedBigInteger('mirror_row_count')->default(0); + }); + $this->createProductTable(); + + $registry = new BusinessMirrorDatasetRegistry(); + $this->repository = new ProductCatalogMirrorRepository( + $registry, + new GenericBusinessMirrorRepository(app('db')), + ); + } + + public function test_it_filters_normal_products_and_uses_stable_autoid_pagination(): void + { + $this->markReady(3); + $this->insertProduct(20, ['i_id' => 'STYLE-20', 'sku_id' => 'SKU-20', 'name' => '云朵餐椅', 'brand' => '自在家', 'sale_price' => 299.9]); + $this->insertProduct(10, ['i_id' => 'STYLE-10', 'sku_id' => 'SKU-10', 'name' => '北欧书桌', 'brand' => '北木', 'sale_price' => 499]); + $this->insertProduct(5, ['sku_type' => 'package', 'name' => '组合商品']); + + $firstPage = $this->repository->paginate($this->filters(), 1, 1); + $secondPage = $this->repository->paginate($this->filters(), 2, 1); + + $this->assertSame(2, $firstPage['total']); + $this->assertSame(10, $firstPage['data'][0]->autoid); + $this->assertSame(20, $secondPage['data'][0]->autoid); + $this->assertTrue($this->repository->available()); + $this->assertTrue($this->repository->ready()); + $this->assertSame('succeeded', $this->repository->state()['status']); + } + + public function test_it_applies_codes_keywords_status_supplier_and_price_filters(): void + { + $this->markReady(2); + $this->insertProduct(1, [ + 'i_id' => 'STYLE-001', + 'sku_id' => 'SKU-001', + 'name' => '岩板餐桌 100%款', + 'short_name' => '餐桌', + 'properties_value' => '1.4米', + 'color' => '胡桃色', + 'brand' => '北木', + 'category' => '住宅家具', + 'supplier_name' => '新北供应商', + 'enabled' => 1, + 'stock_disabled' => 0, + 'sale_price' => 899, + ]); + $this->insertProduct(2, [ + 'i_id' => 'STYLE-002', + 'sku_id' => 'SKU-002', + 'name' => '布艺沙发', + 'supplier_name' => '其他供应商', + 'enabled' => 0, + 'stock_disabled' => 1, + 'sale_price' => 1299, + ]); + + $filters = $this->filters([ + 'i_id' => 'STYLE-001', + 'sku_id' => 'SKU-001', + 'keyword' => '100%', + 'brand' => '北木', + 'category' => '家具', + 'supplier_name' => '新北', + 'enabled' => '1', + 'stock_disabled' => '0', + 'sale_price_min' => '800', + 'sale_price_max' => '900', + ]); + $result = $this->repository->paginate($filters, 1, 50); + + $this->assertSame(1, $result['total']); + $this->assertSame('SKU-001', $result['data'][0]->sku_id); + $this->assertObjectNotHasProperty('pic_url', $result['data'][0]); + } + + public function test_it_refuses_queries_before_the_initial_sync_completes(): void + { + DB::connection('business_mirror')->table('business_mirror_table_states')->insert([ + 'dataset_key' => ProductCatalogMirrorRepository::DATASET_KEY, + 'status' => 'running', + 'mirror_row_count' => 0, + ]); + + $this->assertFalse($this->repository->ready()); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('business_mirror_dataset_not_ready'); + + $this->repository->paginate($this->filters(), 1, 25); + } + + public function test_schema_manager_adds_configured_lookup_indexes_idempotently(): void + { + Schema::connection('business_mirror')->drop('ops_jst_sku'); + + foreach ($this->schemaColumns() as $position => [$name, $type, $length]) { + DB::table('upstream_schema_columns')->insert([ + 'source_key' => 'operations_replica', + 'schema_name' => 'rpt', + 'object_name' => 'jst_sku', + 'object_type' => 'view', + 'column_name' => $name, + 'ordinal_position' => $position + 1, + 'data_type' => $type, + 'max_length' => $length, + 'numeric_precision' => $type === 'decimal' ? 18 : null, + 'numeric_scale' => $type === 'decimal' ? 2 : null, + 'is_nullable' => true, + 'synchronized_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + $dataset = (new BusinessMirrorDatasetRegistry())->get(ProductCatalogMirrorRepository::DATASET_KEY); + $manager = new BusinessMirrorSchemaManager(); + $manager->ensure($dataset); + $manager->ensure($dataset); + + $indexes = collect(Schema::connection('business_mirror')->getIndexes('ops_jst_sku')) + ->map(static fn (array $index): array => array_map('strtolower', (array) ($index['columns'] ?? []))) + ->all(); + + foreach ($this->datasetDefinition()['indexes'] as $expected) { + $this->assertContains(array_map('strtolower', $expected), $indexes); + } + } + + private function createProductTable(): void + { + Schema::connection('business_mirror')->create('ops_jst_sku', function (Blueprint $table): void { + $table->string('_mirror_key')->primary(); + $table->integer('autoid'); + $table->string('i_id')->nullable(); + $table->string('sku_id')->nullable(); + $table->text('name')->nullable(); + $table->text('short_name')->nullable(); + $table->text('properties_value')->nullable(); + $table->string('color')->nullable(); + $table->decimal('sale_price', 18, 2)->nullable(); + $table->decimal('cost_price', 18, 2)->nullable(); + $table->decimal('purchase_price', 18, 2)->nullable(); + $table->decimal('market_price', 18, 2)->nullable(); + $table->text('brand')->nullable(); + $table->text('category')->nullable(); + $table->text('supplier_name')->nullable(); + $table->integer('enabled')->nullable(); + $table->integer('stock_disabled')->nullable(); + $table->dateTime('modified')->nullable(); + $table->string('sku_type')->nullable(); + $table->dateTime('_mirror_synced_at')->nullable(); + }); + } + + private function markReady(int $rows): void + { + DB::connection('business_mirror')->table('business_mirror_table_states')->insert([ + 'dataset_key' => ProductCatalogMirrorRepository::DATASET_KEY, + 'status' => 'succeeded', + 'initial_completed_at' => '2026-08-14 21:15:36', + 'last_succeeded_at' => '2026-08-15 17:51:03', + 'mirror_row_count' => $rows, + ]); + } + + private function insertProduct(int $autoid, array $overrides = []): void + { + DB::connection('business_mirror')->table('ops_jst_sku')->insert(array_merge([ + '_mirror_key' => hash('sha256', (string) $autoid), + 'autoid' => $autoid, + 'i_id' => 'STYLE-' . $autoid, + 'sku_id' => 'SKU-' . $autoid, + 'name' => '测试商品', + 'short_name' => '测试', + 'properties_value' => '标准款', + 'color' => '原木色', + 'sale_price' => 100, + 'cost_price' => 60, + 'purchase_price' => 55, + 'market_price' => 120, + 'brand' => '测试品牌', + 'category' => '测试类目', + 'supplier_name' => '测试供应商', + 'enabled' => 1, + 'stock_disabled' => 0, + 'modified' => '2026-08-15 10:00:00', + 'sku_type' => 'normal', + '_mirror_synced_at' => '2026-08-15 17:51:03', + ], $overrides)); + } + + private function filters(array $overrides = []): array + { + return array_merge([ + 'i_id' => '', + 'sku_id' => '', + 'keyword' => '', + 'brand' => '', + 'category' => '', + 'supplier_name' => '', + 'enabled' => '', + 'stock_disabled' => '', + 'sale_price_min' => null, + 'sale_price_max' => null, + ], $overrides); + } + + private function datasetDefinition(): array + { + return [ + 'source_key' => 'operations_replica', + 'schema' => 'rpt', + 'object' => 'jst_sku', + 'target' => 'ops_jst_sku', + 'keys' => ['autoid'], + 'watermark' => 'modified', + 'indexes' => [ + ['sku_type', 'autoid'], + ['sku_type', 'i_id', 'autoid'], + ['sku_type', 'sku_id', 'autoid'], + ['sku_type', 'enabled', 'autoid'], + ['sku_type', 'stock_disabled', 'autoid'], + ], + ]; + } + + private function schemaColumns(): array + { + return [ + ['autoid', 'int', null], + ['i_id', 'nvarchar', 50], + ['sku_id', 'nvarchar', 50], + ['name', 'nvarchar', 255], + ['short_name', 'nvarchar', 100], + ['properties_value', 'nvarchar', 100], + ['color', 'nvarchar', 50], + ['sale_price', 'decimal', null], + ['cost_price', 'decimal', null], + ['purchase_price', 'decimal', null], + ['market_price', 'decimal', null], + ['brand', 'nvarchar', 100], + ['category', 'nvarchar', 100], + ['supplier_name', 'nvarchar', 255], + ['enabled', 'int', null], + ['stock_disabled', 'int', null], + ['modified', 'datetime', null], + ['sku_type', 'nvarchar', 50], + ]; + } +} -- 2.47.2