商品及库存普通商品资料 #63
@ -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 = [
|
||||
|
||||
@ -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' => '资金管理',
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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` |
|
||||
|
||||
@ -18,4 +18,7 @@ interface BusinessMirrorDataset
|
||||
public function keyColumns(): array;
|
||||
|
||||
public function watermarkColumn(): ?string;
|
||||
|
||||
/** @return list<list<string>> */
|
||||
public function indexDefinitions(): array;
|
||||
}
|
||||
|
||||
@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Repositories;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||||
use RuntimeException;
|
||||
|
||||
class ProductCatalogMirrorRepository
|
||||
{
|
||||
public const DATASET_KEY = 'operations_replica.rpt.jst_sku';
|
||||
|
||||
private readonly ConnectionInterface $connection;
|
||||
|
||||
public function __construct(
|
||||
private readonly BusinessMirrorDatasetRegistry $datasets,
|
||||
private readonly GenericBusinessMirrorRepository $mirror,
|
||||
) {
|
||||
$this->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<string,mixed>|null */
|
||||
public function state(): ?array
|
||||
{
|
||||
return $this->mirror->state($this->dataset());
|
||||
}
|
||||
|
||||
/** @return array{data:list<object>,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)) . '%';
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<list<string>> */
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
<style>
|
||||
.product-catalog-page {
|
||||
--catalog-text: #171717;
|
||||
--catalog-muted: #68645d;
|
||||
margin: 24px 0;
|
||||
color: var(--catalog-text);
|
||||
}
|
||||
|
||||
.product-catalog-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 42px;
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 12px;
|
||||
border-left: 3px solid var(--vip-content-accent-dark, #856632);
|
||||
background: var(--vip-content-accent-soft, #f7f0de);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.product-catalog-status__primary,
|
||||
.product-catalog-status__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.product-catalog-status__primary {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.product-catalog-status__meta {
|
||||
color: var(--catalog-muted);
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.product-catalog-status__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--vip-success, #53793e);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.product-catalog-status__dot.is-warning {
|
||||
background: var(--vip-warning, #a36b00);
|
||||
}
|
||||
|
||||
.product-catalog-filters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(130px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--vip-border, #d8d2c7);
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.product-catalog-field {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.product-catalog-field.is-wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.product-catalog-field label {
|
||||
color: var(--catalog-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.product-catalog-field input,
|
||||
.product-catalog-field select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--vip-border, #d8d2c7);
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
color: var(--catalog-text);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.product-catalog-field input:focus,
|
||||
.product-catalog-field select:focus {
|
||||
border-color: var(--vip-content-accent-dark, #856632);
|
||||
box-shadow: 0 0 0 3px rgba(133, 102, 50, 0.18);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.product-catalog-price-range {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.product-catalog-actions {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.product-catalog-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--vip-border, #d8d2c7);
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
color: var(--catalog-text);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-catalog-button:hover {
|
||||
border-color: var(--vip-content-accent-dark, #856632);
|
||||
background: var(--vip-content-accent-soft, #f7f0de);
|
||||
}
|
||||
|
||||
.product-catalog-button.is-primary {
|
||||
border-color: var(--vip-content-accent-dark, #856632);
|
||||
background: var(--vip-content-accent-dark, #856632);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.product-catalog-alert {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(184, 0, 0, 0.35);
|
||||
border-radius: 6px;
|
||||
background: rgba(184, 0, 0, 0.05);
|
||||
color: var(--vip-danger, #b80000);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.product-catalog-table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--vip-border, #d8d2c7);
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.product-catalog-table {
|
||||
width: 100%;
|
||||
min-width: 1940px;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.78125rem;
|
||||
}
|
||||
|
||||
.product-catalog-table th {
|
||||
padding: 9px 10px;
|
||||
border-bottom: 1px solid var(--vip-border, #d8d2c7);
|
||||
background: var(--vip-content-accent-soft, #f7f0de);
|
||||
color: #4f4a43;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-catalog-table td {
|
||||
max-width: 240px;
|
||||
padding: 9px 10px;
|
||||
border-bottom: 1px solid #eeeae3;
|
||||
color: var(--catalog-text);
|
||||
line-height: 1.45;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.product-catalog-table tbody tr:hover {
|
||||
background: #fcfaf5;
|
||||
}
|
||||
|
||||
.product-catalog-table tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.product-catalog-table .is-numeric {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-catalog-table .is-code {
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-catalog-table .is-clamped {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-catalog-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
padding: 2px 7px;
|
||||
border: 1px solid var(--vip-border, #d8d2c7);
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
color: var(--catalog-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-catalog-badge.is-active {
|
||||
border-color: rgba(83, 121, 62, 0.30);
|
||||
background: rgba(83, 121, 62, 0.08);
|
||||
color: var(--vip-success, #53793e);
|
||||
}
|
||||
|
||||
.product-catalog-badge.is-danger {
|
||||
border-color: rgba(184, 0, 0, 0.25);
|
||||
background: rgba(184, 0, 0, 0.05);
|
||||
color: var(--vip-danger, #b80000);
|
||||
}
|
||||
|
||||
.product-catalog-empty {
|
||||
min-height: 180px;
|
||||
padding: 42px 16px !important;
|
||||
color: var(--catalog-muted) !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.product-catalog-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-width: 720px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--vip-border, #d8d2c7);
|
||||
color: var(--catalog-muted);
|
||||
font-size: 0.78125rem;
|
||||
}
|
||||
|
||||
.product-catalog-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.product-catalog-page-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--vip-border, #d8d2c7);
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
color: var(--catalog-text);
|
||||
}
|
||||
|
||||
.product-catalog-page-link:hover {
|
||||
border-color: var(--vip-content-accent-dark, #856632);
|
||||
background: var(--vip-content-accent-soft, #f7f0de);
|
||||
}
|
||||
|
||||
.product-catalog-page-link.is-disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1500px) {
|
||||
.product-catalog-filters {
|
||||
grid-template-columns: repeat(4, minmax(140px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.product-catalog-filters {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.product-catalog-status {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.product-catalog-status__meta {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.product-catalog-filters {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.product-catalog-field.is-wide {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.product-catalog-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="product-catalog-page" data-product-catalog-page>
|
||||
<div class="product-catalog-status" role="status">
|
||||
<div class="product-catalog-status__primary">
|
||||
<span class="product-catalog-status__dot {{ $stateCode && $stateCode !== 'succeeded' ? 'is-warning' : '' }}" aria-hidden="true"></span>
|
||||
<span>{{ $stateLabels[$stateCode] ?? ($productCatalogError ? '数据暂不可用' : '共享业务镜像') }}</span>
|
||||
</div>
|
||||
<div class="product-catalog-status__meta">
|
||||
@if (!empty($productCatalogState['last_succeeded_at']))
|
||||
<span>最近同步 {{ $productCatalogState['last_succeeded_at'] }}</span>
|
||||
@endif
|
||||
@if (isset($productCatalogState['mirror_row_count']))
|
||||
<span>镜像总行数 {{ number_format((int) $productCatalogState['mirror_row_count']) }}</span>
|
||||
@endif
|
||||
@if ($productCatalogRows && !$productCatalogError)
|
||||
<span>当前结果 {{ number_format($productCatalogRows->total()) }} 条</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="GET" class="product-catalog-filters" aria-label="普通商品资料筛选">
|
||||
<div class="product-catalog-field">
|
||||
<label for="catalog-i-id">款式编码</label>
|
||||
<input id="catalog-i-id" name="i_id" type="text" value="{{ $productCatalogFilters['i_id'] ?? '' }}" placeholder="精确输入">
|
||||
</div>
|
||||
<div class="product-catalog-field">
|
||||
<label for="catalog-sku-id">商品编码</label>
|
||||
<input id="catalog-sku-id" name="sku_id" type="text" value="{{ $productCatalogFilters['sku_id'] ?? '' }}" placeholder="精确输入">
|
||||
</div>
|
||||
<div class="product-catalog-field is-wide">
|
||||
<label for="catalog-keyword">商品关键词</label>
|
||||
<input id="catalog-keyword" name="keyword" type="text" value="{{ $productCatalogFilters['keyword'] ?? '' }}" placeholder="名称、简称、规格或颜色">
|
||||
</div>
|
||||
<div class="product-catalog-field">
|
||||
<label for="catalog-brand">品牌</label>
|
||||
<input id="catalog-brand" name="brand" type="text" value="{{ $productCatalogFilters['brand'] ?? '' }}">
|
||||
</div>
|
||||
<div class="product-catalog-field">
|
||||
<label for="catalog-category">类目</label>
|
||||
<input id="catalog-category" name="category" type="text" value="{{ $productCatalogFilters['category'] ?? '' }}">
|
||||
</div>
|
||||
<div class="product-catalog-field is-wide">
|
||||
<label for="catalog-supplier">供应商</label>
|
||||
<input id="catalog-supplier" name="supplier_name" type="text" value="{{ $productCatalogFilters['supplier_name'] ?? '' }}">
|
||||
</div>
|
||||
<div class="product-catalog-field">
|
||||
<label for="catalog-enabled">商品状态</label>
|
||||
<select id="catalog-enabled" name="enabled">
|
||||
<option value="">全部状态</option>
|
||||
<option value="1" @selected(($productCatalogFilters['enabled'] ?? '') === '1')>已启用</option>
|
||||
<option value="0" @selected(($productCatalogFilters['enabled'] ?? '') === '0')>已停用</option>
|
||||
<option value="-1" @selected(($productCatalogFilters['enabled'] ?? '') === '-1')>已删除</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="product-catalog-field">
|
||||
<label for="catalog-stock-status">库存同步</label>
|
||||
<select id="catalog-stock-status" name="stock_disabled">
|
||||
<option value="">全部状态</option>
|
||||
<option value="0" @selected(($productCatalogFilters['stock_disabled'] ?? '') === '0')>正常同步</option>
|
||||
<option value="1" @selected(($productCatalogFilters['stock_disabled'] ?? '') === '1')>停止同步</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="product-catalog-field is-wide">
|
||||
<label for="catalog-price-min">销售价区间</label>
|
||||
<div class="product-catalog-price-range">
|
||||
<input id="catalog-price-min" name="sale_price_min" type="number" min="0" step="0.01" value="{{ $productCatalogFilters['sale_price_min'] ?? '' }}" placeholder="最低价">
|
||||
<input name="sale_price_max" type="number" min="0" step="0.01" value="{{ $productCatalogFilters['sale_price_max'] ?? '' }}" placeholder="最高价" aria-label="最高销售价">
|
||||
</div>
|
||||
</div>
|
||||
<div class="product-catalog-actions">
|
||||
<button type="submit" class="product-catalog-button is-primary">
|
||||
<span class="material-icons-outlined text-base" aria-hidden="true">search</span>
|
||||
查询
|
||||
</button>
|
||||
<a
|
||||
class="product-catalog-button"
|
||||
href="{{ route('business.pages.show', ['company_id' => company_id(), 'section' => $section['slug'], 'page' => $page['slug']]) }}"
|
||||
>
|
||||
<span class="material-icons-outlined text-base" aria-hidden="true">refresh</span>
|
||||
重置
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@if ($productCatalogError)
|
||||
<div class="product-catalog-alert" role="alert">
|
||||
<span class="material-icons-outlined text-base" aria-hidden="true">error_outline</span>
|
||||
<span>{{ $productCatalogError }}</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="product-catalog-table-wrap">
|
||||
<table class="product-catalog-table" aria-label="普通商品资料">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>款式编码</th>
|
||||
<th>商品编码</th>
|
||||
<th>商品名称</th>
|
||||
<th>商品简称</th>
|
||||
<th>规格 / 颜色</th>
|
||||
<th>品牌</th>
|
||||
<th>类目</th>
|
||||
<th>供应商</th>
|
||||
<th class="is-numeric">销售价</th>
|
||||
<th class="is-numeric">成本价</th>
|
||||
<th class="is-numeric">采购价</th>
|
||||
<th class="is-numeric">市场价</th>
|
||||
<th>商品状态</th>
|
||||
<th>库存同步</th>
|
||||
<th>业务更新时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@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
|
||||
<tr>
|
||||
<td class="is-code">{{ $row->autoid ?? '-' }}</td>
|
||||
<td class="is-code">{{ $row->i_id ?? '-' }}</td>
|
||||
<td class="is-code">{{ $row->sku_id ?? '-' }}</td>
|
||||
<td class="is-clamped" title="{{ $row->name ?? '' }}">{{ $row->name ?? '-' }}</td>
|
||||
<td class="is-clamped" title="{{ $row->short_name ?? '' }}">{{ $row->short_name ?? '-' }}</td>
|
||||
<td class="is-clamped" title="{{ $specification }}">{{ $specification ?: '-' }}</td>
|
||||
<td>{{ $row->brand ?? '-' }}</td>
|
||||
<td class="is-clamped" title="{{ $row->category ?? '' }}">{{ $row->category ?? '-' }}</td>
|
||||
<td class="is-clamped" title="{{ $row->supplier_name ?? '' }}">{{ $row->supplier_name ?? '-' }}</td>
|
||||
@foreach (['sale_price', 'cost_price', 'purchase_price', 'market_price'] as $priceField)
|
||||
<td class="is-numeric">{{ isset($row->{$priceField}) && $row->{$priceField} !== null ? number_format((float) $row->{$priceField}, 2) : '-' }}</td>
|
||||
@endforeach
|
||||
<td>
|
||||
<span class="product-catalog-badge {{ (int) ($row->enabled ?? 0) === 1 ? 'is-active' : ((int) ($row->enabled ?? 0) === -1 ? 'is-danger' : '') }}">
|
||||
{{ $enabledLabel }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="product-catalog-badge {{ (int) ($row->stock_disabled ?? 0) === 0 ? 'is-active' : 'is-danger' }}">
|
||||
{{ (int) ($row->stock_disabled ?? 0) === 0 ? '正常同步' : '停止同步' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="is-code">{{ $row->modified ?? '-' }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="16" class="product-catalog-empty">
|
||||
<span class="material-icons-outlined text-3xl block mb-2" aria-hidden="true">inventory_2</span>
|
||||
@if ($productCatalogError)
|
||||
数据暂时不可用。
|
||||
@elseif ($hasFilters)
|
||||
没有符合当前筛选条件的普通商品资料。
|
||||
@else
|
||||
当前镜像中没有普通商品资料。
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@if ($productCatalogRows && !$productCatalogError)
|
||||
<div class="product-catalog-footer">
|
||||
<span>
|
||||
第 {{ $productCatalogRows->currentPage() }} / {{ max($productCatalogRows->lastPage(), 1) }} 页,
|
||||
本页 {{ $productCatalogRows->count() }} 条,共 {{ number_format($productCatalogRows->total()) }} 条
|
||||
</span>
|
||||
<nav class="product-catalog-pagination" aria-label="普通商品资料分页">
|
||||
<a
|
||||
href="{{ $productCatalogRows->previousPageUrl() ?: '#' }}"
|
||||
class="product-catalog-page-link {{ $productCatalogRows->onFirstPage() ? 'is-disabled' : '' }}"
|
||||
aria-label="上一页"
|
||||
>
|
||||
<span class="material-icons-outlined text-base" aria-hidden="true">chevron_left</span>
|
||||
</a>
|
||||
<a
|
||||
href="{{ $productCatalogRows->nextPageUrl() ?: '#' }}"
|
||||
class="product-catalog-page-link {{ $productCatalogRows->hasMorePages() ? '' : 'is-disabled' }}"
|
||||
aria-label="下一页"
|
||||
>
|
||||
<span class="material-icons-outlined text-base" aria-hidden="true">chevron_right</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
151
tests/Feature/Business/ProductCatalogPageTest.php
Normal file
151
tests/Feature/Business/ProductCatalogPageTest.php
Normal file
@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Business;
|
||||
|
||||
use App\Models\Auth\Permission;
|
||||
use Modules\EcommerceFinance\Repositories\ProductCatalogMirrorRepository;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class ProductCatalogPageTest extends FeatureTestCase
|
||||
{
|
||||
protected function grantAdminPermission(): void
|
||||
{
|
||||
$this->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();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,292 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\ProductCatalogMirrorRepository;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorSchemaManager;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProductCatalogMirrorRepositoryTest extends TestCase
|
||||
{
|
||||
private ProductCatalogMirrorRepository $repository;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config([
|
||||
'database.connections.business_mirror' => [
|
||||
'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],
|
||||
];
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user