商品及库存普通商品资料 #63

Merged
limeng merged 2 commits from feature/limeng-product-inventory into main 2026-08-15 18:21:38 +08:00
11 changed files with 1305 additions and 5 deletions

View File

@ -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 = [

View File

@ -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' => '资金管理',

View File

@ -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'],

View File

@ -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` |

View File

@ -18,4 +18,7 @@ interface BusinessMirrorDataset
public function keyColumns(): array;
public function watermarkColumn(): ?string;
/** @return list<list<string>> */
public function indexDefinitions(): array;
}

View File

@ -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)) . '%';
}
}

View File

@ -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; }
};
}
}

View File

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

View File

@ -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>

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

View File

@ -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],
];
}
}