From d0e7bfbcf7fdb189c7dd7c23f4402fd4a81f35eb Mon Sep 17 00:00:00 2001 From: limeng <1131177064@qq.com> Date: Mon, 14 Sep 2026 12:29:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=99=AE=E9=80=9A=E5=95=86=E5=93=81?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E5=AE=8C=E6=95=B4=E4=BB=B7=E6=A0=BC=E5=8F=98?= =?UTF-8?q?=E6=9B=B4=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ProductCatalogMirrorRepository.php | 76 ++- .../normal-products.blade.php | 468 +++++++++++++++++- .../Business/ProductCatalogPageTest.php | 36 ++ .../ProductCatalogMirrorRepositoryTest.php | 28 +- 4 files changed, 586 insertions(+), 22 deletions(-) diff --git a/modules/EcommerceFinance/Repositories/ProductCatalogMirrorRepository.php b/modules/EcommerceFinance/Repositories/ProductCatalogMirrorRepository.php index e91cfd4f..e6d34dac 100644 --- a/modules/EcommerceFinance/Repositories/ProductCatalogMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/ProductCatalogMirrorRepository.php @@ -177,7 +177,7 @@ class ProductCatalogMirrorRepository $this->attachPurchasePrices($rows, $purchasePriceSyncStatus['initialized']); $this->attachPhysicalFields($rows); - $this->attachPriceChanges($rows, $priceChangeSyncStatus['initialized']); + $this->attachPriceChanges($rows, $priceChangeSyncStatus['initialized'], false); $this->attachWorkPlatformPrices($rows, $workPlatformPriceSyncStatus['initialized']); foreach ($rows as $row) { yield $row; @@ -307,11 +307,11 @@ class ProductCatalogMirrorRepository ->orderByDesc('effectTime') ->orderByDesc('Id') ->get(['skuCode', 'purchasePrice', 'effectTime']) - ->unique('skuCode') - ->keyBy('skuCode'); + ->unique(fn (object $price): string => $this->normalizeSku($price->skuCode ?? null)) + ->keyBy(fn (object $price): string => $this->normalizeSku($price->skuCode ?? null)); foreach ($rows as $row) { - $price = $candidates->get(trim((string) ($row->sku_id ?? ''))); + $price = $candidates->get($this->normalizeSku($row->sku_id ?? null)); if ($price === null) { continue; } @@ -325,17 +325,20 @@ class ProductCatalogMirrorRepository /** * 变价记录与采购单价一样是补充来源:即使镜像尚未就绪,普通商品行仍必须 - * 完整显示,四个变价字段保持为空,由页面统一说明同步状态。 + * 完整显示,变价字段保持为空,由页面统一说明同步状态。分页页面附带该 + * SKU 的全部定价/变价历史;大批量导出只附带最近一次变价,避免放大内存。 * * @param list $rows */ - private function attachPriceChanges(array $rows, bool $initialized): void + private function attachPriceChanges(array $rows, bool $initialized, bool $includeHistory = true): void { foreach ($rows as $row) { $row->price_change_before_price = null; $row->price_change_after_price = null; $row->price_change_diff_amount = null; $row->price_change_effect_time = null; + $row->price_change_history = []; + $row->price_change_history_count = 0; } if ($rows === [] || ! $initialized) { @@ -353,23 +356,53 @@ class ProductCatalogMirrorRepository return; } - $candidates = $this->connection->table($this->priceChangeTable()) + $query = $this->connection->table($this->priceChangeTable()) ->whereIn('skucode', $skuIds) - ->where('priceType', '1') + ->whereIn('priceType', $includeHistory ? ['0', '1'] : ['1']) ->orderByDesc('effectStartTime') - ->orderByDesc('Id') - ->get([ - 'skucode', - 'originalPrice', - 'afterPrice', - 'diffPrice', - 'effectStartTime', - ]) - ->unique('skucode') - ->keyBy('skucode'); + ->orderByDesc('Id'); + $candidates = $query->get([ + 'Id', + 'skucode', + 'priceType', + 'originalPrice', + 'afterPrice', + 'diffPrice', + 'effectStartTime', + 'effectEndTime', + 'createTime', + ]) + ->groupBy(fn (object $change): string => $this->normalizeSku($change->skucode ?? null)); foreach ($rows as $row) { - $change = $candidates->get(trim((string) ($row->sku_id ?? ''))); + $history = $candidates->get($this->normalizeSku($row->sku_id ?? null), collect())->values(); + + if ($includeHistory) { + $row->price_change_history = $history->map(static function (object $change): array { + $type = trim((string) ($change->priceType ?? '')); + + return [ + 'id' => $change->Id ?? null, + 'record_type' => $type, + 'record_type_label' => match ($type) { + '0' => '定价', + '1' => '变价', + default => $type === '' ? '-' : $type, + }, + 'before_price' => $change->originalPrice ?? null, + 'after_price' => $change->afterPrice ?? null, + 'diff_amount' => $change->diffPrice ?? null, + 'effect_start_time' => $change->effectStartTime ?? null, + 'effect_end_time' => $change->effectEndTime ?? null, + 'created_at' => $change->createTime ?? null, + ]; + })->all(); + $row->price_change_history_count = count($row->price_change_history); + } + + $change = $history->first( + static fn (object $candidate): bool => trim((string) ($candidate->priceType ?? '')) === '1', + ); if ($change === null) { continue; } @@ -469,6 +502,11 @@ class ProductCatalogMirrorRepository ); } + private function normalizeSku(mixed $value): string + { + return mb_strtolower(trim((string) $value), 'UTF-8'); + } + /** @param array $filters */ private function filteredQuery(array $filters): Builder { diff --git a/resources/views/business/pages/product-inventory/normal-products.blade.php b/resources/views/business/pages/product-inventory/normal-products.blade.php index a3e4bb70..b97abd94 100644 --- a/resources/views/business/pages/product-inventory/normal-products.blade.php +++ b/resources/views/business/pages/product-inventory/normal-products.blade.php @@ -230,7 +230,7 @@ .product-catalog-table { width: 100%; - min-width: 3180px; + min-width: 3280px; border-collapse: collapse; font-size: 0.78125rem; } @@ -288,6 +288,49 @@ white-space: nowrap; } + .product-catalog-history-action { + display: flex; + align-items: center; + gap: 7px; + white-space: nowrap; + } + + .product-catalog-icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: 1px solid var(--vip-content-accent-dark, #856632); + border-radius: 50%; + background: var(--vip-content-accent-dark, #856632); + color: #ffffff; + cursor: pointer; + } + + .product-catalog-icon-button:hover { + background: #6a5129; + } + + .product-catalog-icon-button:focus-visible { + box-shadow: 0 0 0 3px rgba(133, 102, 50, 0.20); + outline: none; + } + + .product-catalog-icon-button:disabled { + border-color: var(--vip-border, #d8d2c7); + background: #efede8; + color: var(--catalog-muted); + cursor: not-allowed; + opacity: 0.75; + } + + .product-catalog-history-count { + color: var(--catalog-muted); + font-variant-numeric: tabular-nums; + } + .product-catalog-table .is-clamped { overflow: hidden; text-overflow: ellipsis; @@ -365,6 +408,161 @@ pointer-events: none; } + body.product-price-history-open { + overflow: hidden; + } + + .product-price-history-modal[hidden] { + display: none; + } + + .product-price-history-modal { + position: fixed; + z-index: 1100; + inset: 0; + display: flex; + align-items: flex-end; + justify-content: center; + padding: 16px; + } + + .product-price-history-modal__backdrop { + position: absolute; + inset: 0; + background: rgba(5, 5, 6, 0.54); + } + + .product-price-history-modal__panel { + position: relative; + display: flex; + width: min(1240px, calc(100vw - 32px)); + max-height: min(760px, calc(100vh - 48px)); + flex-direction: column; + overflow: hidden; + border: 1px solid var(--vip-border, #d8d2c7); + border-radius: 8px; + background: #ffffff; + box-shadow: 0 4px 8px rgba(23, 23, 23, 0.10); + } + + .product-price-history-modal__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 16px; + border-bottom: 1px solid var(--vip-border, #d8d2c7); + } + + .product-price-history-modal__heading { + min-width: 0; + } + + .product-price-history-modal__title { + margin: 0; + color: var(--catalog-text); + font-size: 0.875rem; + font-weight: 600; + line-height: 1.25; + } + + .product-price-history-modal__subtitle { + margin: 4px 0 0; + overflow: hidden; + color: var(--catalog-muted); + font-size: 0.75rem; + text-overflow: ellipsis; + white-space: nowrap; + } + + .product-price-history-modal__actions { + display: flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; + } + + .product-price-history-modal__summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0; + border-bottom: 1px solid var(--vip-border, #d8d2c7); + background: var(--vip-content-accent-soft, #f7f0de); + } + + .product-price-history-modal__metric { + min-width: 0; + padding: 10px 16px; + border-right: 1px solid var(--vip-border, #d8d2c7); + } + + .product-price-history-modal__metric:last-child { + border-right: 0; + } + + .product-price-history-modal__metric-label { + display: block; + margin-bottom: 3px; + color: var(--catalog-muted); + font-size: 0.75rem; + } + + .product-price-history-modal__metric-value { + display: block; + overflow: hidden; + color: var(--catalog-text); + font-size: 0.8125rem; + font-variant-numeric: tabular-nums; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; + } + + .product-price-history-modal__table-wrap { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + } + + .product-price-history-table { + width: 100%; + min-width: 980px; + border-collapse: collapse; + font-size: 0.78125rem; + } + + .product-price-history-table th, + .product-price-history-table td { + padding: 9px 10px; + border-bottom: 1px solid #eeeae3; + text-align: left; + vertical-align: middle; + white-space: nowrap; + } + + .product-price-history-table th { + position: sticky; + z-index: 1; + top: 0; + background: #f5f3ee; + color: #4f4a43; + font-size: 0.75rem; + font-weight: 600; + } + + .product-price-history-table td.is-numeric { + text-align: right; + font-variant-numeric: tabular-nums; + } + + .product-price-history-table td.is-positive { + color: var(--vip-warning, #935f07); + } + + .product-price-history-table td.is-negative { + color: var(--vip-success, #53793e); + } + @media (max-width: 1500px) { .product-catalog-filters { grid-template-columns: repeat(4, minmax(140px, 1fr)); @@ -398,6 +596,36 @@ .product-catalog-actions { justify-content: flex-start; } + + .product-price-history-modal { + padding: 8px; + } + + .product-price-history-modal__panel { + width: calc(100vw - 16px); + max-height: calc(100vh - 16px); + } + + .product-price-history-modal__header { + align-items: flex-start; + } + + .product-price-history-modal__actions .product-catalog-button span:last-child { + display: none; + } + + .product-price-history-modal__summary { + grid-template-columns: 1fr; + } + + .product-price-history-modal__metric { + border-right: 0; + border-bottom: 1px solid var(--vip-border, #d8d2c7); + } + + .product-price-history-modal__metric:last-child { + border-bottom: 0; + } } @@ -568,6 +796,7 @@ 变价后价格 差额 变价时间 + 变价记录 数据来源 市场价 重量(kg) @@ -621,6 +850,32 @@ @endforeach {{ isset($row->price_change_diff_amount) && $row->price_change_diff_amount !== null ? number_format((float) $row->price_change_diff_amount, 2) : '-' }} {{ !empty($row->price_change_effect_time) ? \Illuminate\Support\Carbon::parse($row->price_change_effect_time)->format('Y-m-d') : '-' }} + @php + $priceChangeHistory = $row->price_change_history ?? []; + $priceChangeHistoryCount = (int) ($row->price_change_history_count ?? count($priceChangeHistory)); + $priceChangeHistoryId = 'product-price-history-' . (int) ($row->autoid ?? 0); + @endphp + +
+ + {{ $priceChangeHistoryCount }} 条 + +
+ @forelse ($row->data_sources ?? [] as $source) {{ $source }} @@ -650,7 +905,7 @@ @empty - + @if ($productCatalogError) 数据暂时不可用。 @@ -690,4 +945,213 @@ @endif + + + + diff --git a/tests/Feature/Business/ProductCatalogPageTest.php b/tests/Feature/Business/ProductCatalogPageTest.php index 7adf0419..ae2c8d31 100644 --- a/tests/Feature/Business/ProductCatalogPageTest.php +++ b/tests/Feature/Business/ProductCatalogPageTest.php @@ -65,6 +65,31 @@ class ProductCatalogPageTest extends FeatureTestCase 'price_change_after_price' => 475, 'price_change_diff_amount' => -15, 'price_change_effect_time' => '2026-08-11 00:00:00', + 'price_change_history' => [ + [ + 'id' => 12, + 'record_type' => '1', + 'record_type_label' => '变价', + 'before_price' => 490, + 'after_price' => 475, + 'diff_amount' => -15, + 'effect_start_time' => '2026-08-11 00:00:00', + 'effect_end_time' => null, + 'created_at' => '2026-08-10 09:30:00', + ], + [ + 'id' => 3, + 'record_type' => '0', + 'record_type_label' => '定价', + 'before_price' => null, + 'after_price' => 490, + 'diff_amount' => null, + 'effect_start_time' => '2026-01-01 00:00:00', + 'effect_end_time' => '2026-08-10 00:00:00', + 'created_at' => '2025-12-31 08:00:00', + ], + ], + 'price_change_history_count' => 2, 'purchase_order_unit_price' => 65, 'purchase_order_finished_at' => '2026-08-20 11:03:05', 'purchase_order_id' => 827380, @@ -140,6 +165,17 @@ class ProductCatalogPageTest extends FeatureTestCase ->assertSeeText('变价后价格') ->assertSeeText('差额') ->assertSeeText('-15.00') + ->assertSeeText('变价记录') + ->assertSeeText('2 条') + ->assertSee('data-price-history-trigger', false) + ->assertSeeText('价格变更明细') + ->assertSeeText('当前成本价') + ->assertSeeText('当前工作平台价格') + ->assertSeeText('导出变价记录') + ->assertSeeText('生效结束日期') + ->assertSeeText('记录时间') + ->assertSee('"record_type_label":"变价"', false) + ->assertSee('"record_type_label":"定价"', false) ->assertSeeText('数据来源') ->assertSeeText('商品及库存管理(普通商品资料)') ->assertSeeText('1688代发单价') diff --git a/tests/Unit/EcommerceFinance/ProductCatalogMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/ProductCatalogMirrorRepositoryTest.php index b5b8186d..451b86d2 100644 --- a/tests/Unit/EcommerceFinance/ProductCatalogMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/ProductCatalogMirrorRepositoryTest.php @@ -263,17 +263,29 @@ class ProductCatalogMirrorRepositoryTest extends TestCase $this->assertSame('pending', $result['work_platform_price_sync_status']['code']); } - public function test_it_attaches_the_latest_price_type_one_change_details(): void + public function test_it_attaches_all_price_history_and_keeps_the_latest_change_on_the_main_row(): void { $this->markReady(1); $this->insertProduct(1, ['sku_id' => 'SKU-CHANGE']); $this->createPriceChangeTables(true); + $this->insertPriceChange(9, [ + 'skucode' => 'SKU-CHANGE', + 'priceType' => 0, + 'originalPrice' => null, + 'afterPrice' => 500, + 'diffPrice' => null, + 'effectStartTime' => '2026-08-01 00:00:00', + 'effectEndTime' => '2026-08-17 00:00:00', + 'createTime' => '2026-07-31 09:00:00', + ]); $this->insertPriceChange(10, [ 'skucode' => 'SKU-CHANGE', 'originalPrice' => 500, 'afterPrice' => 480, 'diffPrice' => -20, 'effectStartTime' => '2026-08-18 00:00:00', + 'effectEndTime' => '2026-08-19 00:00:00', + 'createTime' => '2026-08-17 09:00:00', ]); $this->insertPriceChange(11, [ 'skucode' => 'SKU-CHANGE', @@ -281,6 +293,7 @@ class ProductCatalogMirrorRepositoryTest extends TestCase 'afterPrice' => 475, 'diffPrice' => -5, 'effectStartTime' => '2026-08-20 00:00:00', + 'createTime' => '2026-08-19 09:00:00', ]); $this->insertPriceChange(12, [ 'skucode' => 'SKU-CHANGE', @@ -298,6 +311,13 @@ class ProductCatalogMirrorRepositoryTest extends TestCase $this->assertSame('475', (string) $row->price_change_after_price); $this->assertSame('-5', (string) $row->price_change_diff_amount); $this->assertStringStartsWith('2026-08-20', (string) $row->price_change_effect_time); + $this->assertSame(3, $row->price_change_history_count); + $this->assertSame(['变价', '变价', '定价'], array_column($row->price_change_history, 'record_type_label')); + $this->assertSame('480', (string) $row->price_change_history[0]['before_price']); + $this->assertSame('475', (string) $row->price_change_history[0]['after_price']); + $this->assertSame('-5', (string) $row->price_change_history[0]['diff_amount']); + $this->assertStringStartsWith('2026-08-19', (string) $row->price_change_history[0]['created_at']); + $this->assertStringStartsWith('2026-08-19', (string) $row->price_change_history[1]['effect_end_time']); $this->assertTrue($result['price_change_sync_status']['initialized']); } @@ -314,6 +334,8 @@ class ProductCatalogMirrorRepositoryTest extends TestCase $this->assertNull($row->price_change_after_price); $this->assertNull($row->price_change_diff_amount); $this->assertNull($row->price_change_effect_time); + $this->assertSame([], $row->price_change_history); + $this->assertSame(0, $row->price_change_history_count); $this->assertFalse($result['price_change_sync_status']['initialized']); $this->assertSame('pending', $result['price_change_sync_status']['code']); } @@ -559,6 +581,8 @@ class ProductCatalogMirrorRepositoryTest extends TestCase $table->decimal('afterPrice', 18, 2)->nullable(); $table->decimal('diffPrice', 18, 2)->nullable(); $table->dateTime('effectStartTime')->nullable(); + $table->dateTime('effectEndTime')->nullable(); + $table->dateTime('createTime')->nullable(); }); DB::connection('business_mirror')->table('business_mirror_table_states')->insert([ 'dataset_key' => ProductCatalogMirrorRepository::PRICE_CHANGE_DATASET_KEY, @@ -579,6 +603,8 @@ class ProductCatalogMirrorRepositoryTest extends TestCase 'afterPrice' => 50, 'diffPrice' => -5, 'effectStartTime' => '2026-08-01 00:00:00', + 'effectEndTime' => null, + 'createTime' => '2026-07-31 09:00:00', ], $overrides)); }