From 4c2c422c78876cc2bb0154ebc40ceddc6f6e68aa Mon Sep 17 00:00:00 2001 From: limeng <1131177064@qq.com> Date: Thu, 8 Oct 2026 11:04:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=99=AE=E9=80=9A=E5=95=86=E5=93=81?= =?UTF-8?q?=E8=B5=84=E6=96=99):=20=E8=81=9A=E6=B0=B4=E6=BD=AD=E6=88=90?= =?UTF-8?q?=E6=9C=AC=E4=BB=B7=E8=AE=B0=E5=BD=95=E6=94=B9=E4=B8=BA=E9=95=9C?= =?UTF-8?q?=E5=83=8F=E5=BF=AB=E7=85=A7=E6=AF=94=E5=AF=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 副本库 rpt.jst_sku 一商品一行、无历史;开放平台操作日志接口暂无授权 - 新增 JushuitanCostPriceSnapshotDiffer:ops_jst_sku 当前成本价与快照比对, 变化写入 jushuitan_cost_price_history_rows(变更前/后/差额,时间取聚水潭 modified) - 新表 jushuitan_cost_price_snapshots(迁移 + 运行时幂等建表) - business-mirror:sync-jushuitan-cost-price-history 默认走 snapshot, JUSHUITAN_COST_PRICE_HISTORY_SOURCE=open_api 可切回接口 - 生产 cron 由每日 00:00 改为每 10 分钟 - 页面“聚水潭成本价记录”列显示最近一次变价(前→后 · 时间) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0192A1X1EDtdzpaAVMhBj2nh --- .../SyncJushuitanCostPriceHistory.php | 32 ++- config/services.php | 2 + ..._create_jushuitan_cost_price_snapshots.php | 48 ++++ deploy/activate-incremental-release.sh | 2 +- deploy/activate-release.sh | 2 +- ...huitanCostPriceHistoryMirrorRepository.php | 5 + .../JushuitanCostPriceSnapshotDiffer.php | 208 ++++++++++++++++++ .../normal-products.blade.php | 9 + 8 files changed, 305 insertions(+), 3 deletions(-) create mode 100644 database/migrations/2026_10_08_100000_create_jushuitan_cost_price_snapshots.php create mode 100644 modules/EcommerceFinance/Services/JushuitanCostPriceSnapshotDiffer.php diff --git a/app/Console/Commands/SyncJushuitanCostPriceHistory.php b/app/Console/Commands/SyncJushuitanCostPriceHistory.php index ddda7a45..95f0609c 100644 --- a/app/Console/Commands/SyncJushuitanCostPriceHistory.php +++ b/app/Console/Commands/SyncJushuitanCostPriceHistory.php @@ -6,6 +6,7 @@ use Illuminate\Console\Command; use Illuminate\Support\Facades\Log; use Modules\EcommerceFinance\Repositories\JushuitanCostPriceHistoryMirrorRepository; use Modules\EcommerceFinance\Services\JushuitanCostPriceHistorySynchronizer; +use Modules\EcommerceFinance\Services\JushuitanCostPriceSnapshotDiffer; use Throwable; class SyncJushuitanCostPriceHistory extends Command @@ -15,16 +16,22 @@ class SyncJushuitanCostPriceHistory extends Command {--page-size= : 聚水潭接口每页读取条数,最大 2000} {--max-pages= : 本轮最多读取页数,0 表示直到完成}'; - protected $description = '按时间窗和页码同步聚水潭商品操作日志中的采购价变更到共享业务镜像'; + protected $description = '记录聚水潭成本价变更:默认按普通商品镜像快照比对,可切换为开放平台操作日志'; public function handle( JushuitanCostPriceHistoryMirrorRepository $mirror, JushuitanCostPriceHistorySynchronizer $synchronizer, + JushuitanCostPriceSnapshotDiffer $snapshotDiffer, ): int { if (! (bool) config('upstream_mirrors.refresh_enabled', false) && ! $this->option('full')) { return self::SUCCESS; } + // 默认走“镜像快照比对”:开放平台操作日志接口暂无授权时也能记录成本价变化。 + if ((string) config('services.jushuitan.cost_price_history_source', 'snapshot') === 'snapshot') { + return $this->runSnapshot($snapshotDiffer); + } + try { $state = $mirror->state(); $full = $this->shouldRunFull($state); @@ -58,6 +65,29 @@ class SyncJushuitanCostPriceHistory extends Command return self::SUCCESS; } + private function runSnapshot(JushuitanCostPriceSnapshotDiffer $differ): int + { + try { + $result = $differ->sync(); + } catch (Throwable $exception) { + Log::error('聚水潭成本价快照比对失败。', ['exception' => $exception::class]); + $this->error('聚水潭成本价快照比对失败,已保留上一版共享数据。'); + if ($this->getOutput()->isVerbose() || $this->option('full')) { + $this->line(mb_substr($exception->getMessage(), 0, 500)); + } + + return self::FAILURE; + } + + if ($result['mode'] === 'full') { + $this->info(sprintf('聚水潭成本价基线快照已建立:%d 个商品。之后的成本价变化会被记录。', $result['baseline_rows'])); + } else { + $this->info(sprintf('聚水潭成本价比对完成:新增变价 %d 条,新商品入快照 %d 个。', $result['changes'], $result['new_skus'])); + } + + return self::SUCCESS; + } + /** @param array|null $state */ private function shouldRunFull(?array $state): bool { diff --git a/config/services.php b/config/services.php index 3c1bdf21..a76cd5fa 100644 --- a/config/services.php +++ b/config/services.php @@ -65,6 +65,8 @@ return [ 'access_token' => env('JUSHUITAN_ACCESS_TOKEN'), 'timeout' => (int) env('JUSHUITAN_OPEN_API_TIMEOUT', 20), 'product_operation_history_path' => '/open/webapi/itemapi/itemsku/queryitemskuopshistory', + // snapshot = 普通商品镜像成本价快照比对(默认);open_api = 聚水潭开放平台操作日志接口 + 'cost_price_history_source' => env('JUSHUITAN_COST_PRICE_HISTORY_SOURCE', 'snapshot'), 'cost_price_history_start' => env('JUSHUITAN_COST_PRICE_HISTORY_START', '2000-01-01 00:00:00'), 'cost_price_history_window_days' => (int) env('JUSHUITAN_COST_PRICE_HISTORY_WINDOW_DAYS', 30), 'cost_price_history_overlap_hours' => (int) env('JUSHUITAN_COST_PRICE_HISTORY_OVERLAP_HOURS', 48), diff --git a/database/migrations/2026_10_08_100000_create_jushuitan_cost_price_snapshots.php b/database/migrations/2026_10_08_100000_create_jushuitan_cost_price_snapshots.php new file mode 100644 index 00000000..a5f345fb --- /dev/null +++ b/database/migrations/2026_10_08_100000_create_jushuitan_cost_price_snapshots.php @@ -0,0 +1,48 @@ +environment('testing') + || (string) config('system_database.mode', 'bootstrap') === 'bootstrap') { + return; + } + + $schema = Schema::connection($this->connection()); + if (! $schema->hasTable('jushuitan_cost_price_snapshots')) { + $schema->create('jushuitan_cost_price_snapshots', function (Blueprint $table): void { + $table->string('sku_id_normalized', 191)->primary(); + $table->string('sku_id', 255); + $table->decimal('cost_price', 20, 4)->nullable(); + $table->dateTime('source_modified', 6)->nullable(); + $table->dateTime('captured_at', 6); + $table->timestamps(); + }); + } + } + + public function down(): void + { + if (app()->environment('testing') + || (string) config('system_database.mode', 'bootstrap') === 'bootstrap') { + return; + } + + Schema::connection($this->connection())->dropIfExists('jushuitan_cost_price_snapshots'); + } +}; diff --git a/deploy/activate-incremental-release.sh b/deploy/activate-incremental-release.sh index b467f70b..3b11fdda 100644 --- a/deploy/activate-incremental-release.sh +++ b/deploy/activate-incremental-release.sh @@ -180,7 +180,7 @@ cat > /etc/cron.d/caiwu-jiansun-snapshots <<'EOF' CRON_TZ=Asia/Shanghai * * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-catalog --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-store-report --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan upstream:snapshots:refresh-all --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables --quiet' >/dev/null 2>&1 0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet >/dev/null 2>&1 -0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --quiet >/dev/null 2>&1 +*/10 * * * * root /usr/bin/flock -w 540 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --quiet >/dev/null 2>&1 */2 * * * * root /usr/bin/flock -n /run/caiwu-esign-polling.lock /usr/bin/docker exec -u www-data caiwu_app php artisan esign:poll-signatures --quiet >/dev/null 2>&1 28 1 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan jushuitan:sync-shipping --days=3 >/dev/null 2>&1 EOF diff --git a/deploy/activate-release.sh b/deploy/activate-release.sh index a3df1591..e7cc89a9 100644 --- a/deploy/activate-release.sh +++ b/deploy/activate-release.sh @@ -184,7 +184,7 @@ cat > /etc/cron.d/caiwu-jiansun-snapshots <<'EOF' CRON_TZ=Asia/Shanghai * * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-catalog --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-store-report --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan upstream:snapshots:refresh-all --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables --quiet' >/dev/null 2>&1 0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet >/dev/null 2>&1 -0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --quiet >/dev/null 2>&1 +*/10 * * * * root /usr/bin/flock -w 540 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --quiet >/dev/null 2>&1 */2 * * * * root /usr/bin/flock -n /run/caiwu-esign-polling.lock /usr/bin/docker exec -u www-data caiwu_app php artisan esign:poll-signatures --quiet >/dev/null 2>&1 28 1 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan jushuitan:sync-shipping --days=3 >/dev/null 2>&1 EOF diff --git a/modules/EcommerceFinance/Repositories/JushuitanCostPriceHistoryMirrorRepository.php b/modules/EcommerceFinance/Repositories/JushuitanCostPriceHistoryMirrorRepository.php index bb6a7b7f..baf7f003 100644 --- a/modules/EcommerceFinance/Repositories/JushuitanCostPriceHistoryMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/JushuitanCostPriceHistoryMirrorRepository.php @@ -25,6 +25,11 @@ class JushuitanCostPriceHistoryMirrorRepository ); } + public function connection(): ConnectionInterface + { + return $this->connection; + } + public function available(): bool { $schema = Schema::connection($this->connection->getName()); diff --git a/modules/EcommerceFinance/Services/JushuitanCostPriceSnapshotDiffer.php b/modules/EcommerceFinance/Services/JushuitanCostPriceSnapshotDiffer.php new file mode 100644 index 00000000..1d829377 --- /dev/null +++ b/modules/EcommerceFinance/Services/JushuitanCostPriceSnapshotDiffer.php @@ -0,0 +1,208 @@ +history->connection(); + $schema = Schema::connection($db->getName()); + $productTable = $this->productTable(); + $this->ensureSnapshotTable(); + if (! $this->history->available() + || ! $schema->hasTable(self::SNAPSHOT_TABLE) + || ! $schema->hasTable($productTable)) { + throw new RuntimeException('jushuitan_cost_price_snapshot_schema_missing'); + } + + $state = $this->history->state(); + $baseline = ($state['initial_completed_at'] ?? null) === null; + $mode = $baseline ? 'full' : 'incremental'; + $generation = (string) Str::uuid(); + $this->history->startRun($mode, $generation, now(), now(), false); + + try { + $changes = 0; + if (! $baseline) { + $changes = $this->recordChanges($productTable, $generation); + } + // 新出现的商品(含首次基线)只入快照,不算变价。 + $newSkus = $this->insertNewSkus($productTable); + + $this->history->complete($mode, $generation, now(), $newSkus, $changes); + + return [ + 'status' => 'succeeded', + 'mode' => $mode, + 'baseline_rows' => $baseline ? $newSkus : 0, + 'new_skus' => $baseline ? 0 : $newSkus, + 'changes' => $changes, + ]; + } catch (Throwable $exception) { + $this->history->fail($exception::class); + throw $exception; + } + } + + private function recordChanges(string $productTable, string $generation): int + { + $db = $this->history->connection(); + $snapshot = self::SNAPSHOT_TABLE; + $rows = $db->select( + "SELECT p.sku_id_normalized, s.sku_id, s.i_id, s.name, s.properties_value, s.sku_type, + p.cost_price AS before_price, + CAST(s.cost_price AS DECIMAL(20,4)) AS after_price, + s.modified + FROM `{$productTable}` s + JOIN `{$snapshot}` p ON p.sku_id_normalized = UPPER(TRIM(s.sku_id)) + WHERE NOT (CAST(s.cost_price AS DECIMAL(20,4)) <=> p.cost_price)", + [], + false, + ); + if ($rows === []) { + return 0; + } + + $now = now(); + $seen = []; + $records = []; + foreach ($rows as $row) { + $sku = (string) $row->sku_id_normalized; + if (isset($seen[$sku])) { + continue; + } + $seen[$sku] = true; + $before = $row->before_price === null ? null : (float) $row->before_price; + $after = $row->after_price === null ? null : (float) $row->after_price; + $operatedAt = $row->modified ?? $now->format('Y-m-d H:i:s'); + $values = [ + 'operation_id' => 'snap:' . hash('sha256', implode('|', [ + $sku, (string) $row->before_price, (string) $row->after_price, (string) $operatedAt, + ])), + 'sku_id' => trim((string) $row->sku_id), + 'sku_id_normalized' => $sku, + 'i_id' => $this->nullable($row->i_id ?? null), + 'product_name' => $this->nullable($row->name ?? null), + 'properties_value' => $this->nullable($row->properties_value ?? null), + 'sku_type' => $this->nullable($row->sku_type ?? null), + 'operation_type' => '成本价变更', + 'operation_description' => sprintf( + '成本价 %s → %s', + $before === null ? '空' : number_format($before, 2, '.', ''), + $after === null ? '空' : number_format($after, 2, '.', ''), + ), + 'operator_name' => null, + 'operated_at' => $operatedAt, + 'before_price' => $row->before_price, + 'after_price' => $row->after_price, + 'diff_amount' => $before === null || $after === null ? null : round($after - $before, 4), + ]; + $records[] = $values + [ + '_mirror_generation' => $generation, + 'row_hash' => hash('sha256', json_encode($values, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)), + '_mirror_synced_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + foreach (array_chunk($records, 500) as $chunk) { + $db->transaction(function () use ($db, $chunk, $now): void { + $db->table(JushuitanCostPriceHistoryMirrorRepository::TABLE)->upsert( + $chunk, + ['operation_id'], + ['row_hash', '_mirror_synced_at', 'updated_at'], + ); + foreach ($chunk as $record) { + $db->table(self::SNAPSHOT_TABLE) + ->where('sku_id_normalized', $record['sku_id_normalized']) + ->update([ + 'cost_price' => $record['after_price'], + 'source_modified' => $record['operated_at'], + 'captured_at' => $now, + 'updated_at' => $now, + ]); + } + }); + } + + return count($records); + } + + private function insertNewSkus(string $productTable): int + { + $snapshot = self::SNAPSHOT_TABLE; + + return $this->history->connection()->affectingStatement( + "INSERT IGNORE INTO `{$snapshot}` + (sku_id_normalized, sku_id, cost_price, source_modified, captured_at, created_at, updated_at) + SELECT UPPER(TRIM(s.sku_id)), MAX(s.sku_id), MAX(CAST(s.cost_price AS DECIMAL(20,4))), + MAX(s.modified), NOW(6), NOW(), NOW() + FROM `{$productTable}` s + LEFT JOIN `{$snapshot}` p ON p.sku_id_normalized = UPPER(TRIM(s.sku_id)) + WHERE p.sku_id_normalized IS NULL + AND s.sku_id IS NOT NULL + AND TRIM(s.sku_id) <> '' + GROUP BY UPPER(TRIM(s.sku_id))", + ); + } + + /** 共享镜像可能尚未跑本迁移(本地 bootstrap 模式会跳过),这里幂等兜底建表。 */ + private function ensureSnapshotTable(): void + { + $schema = Schema::connection($this->history->connection()->getName()); + if ($schema->hasTable(self::SNAPSHOT_TABLE)) { + return; + } + $schema->create(self::SNAPSHOT_TABLE, function (Blueprint $table): void { + $table->string('sku_id_normalized', 191)->primary(); + $table->string('sku_id', 255); + $table->decimal('cost_price', 20, 4)->nullable(); + $table->dateTime('source_modified', 6)->nullable(); + $table->dateTime('captured_at', 6); + $table->timestamps(); + }); + } + + private function productTable(): string + { + return $this->datasets->get((string) config( + 'upstream_mirrors.product_catalog.dataset_key', + 'operations_replica.rpt.jst_sku', + ))->targetTable(); + } + + private function nullable(mixed $value): ?string + { + $value = trim((string) $value); + + return $value === '' ? null : $value; + } +} 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 f4db1af5..877fba82 100644 --- a/resources/views/business/pages/product-inventory/normal-products.blade.php +++ b/resources/views/business/pages/product-inventory/normal-products.blade.php @@ -878,6 +878,15 @@ {{ $jushuitanHistoryCount }} 条 + @php $jushuitanLatest = $jushuitanHistory[0] ?? null; @endphp + @if ($jushuitanLatest) + + {{ $jushuitanLatest['before_price'] !== null ? number_format((float) $jushuitanLatest['before_price'], 2) : '空' }}→{{ $jushuitanLatest['after_price'] !== null ? number_format((float) $jushuitanLatest['after_price'], 2) : '空' }} + @if (!empty($jushuitanLatest['effect_start_time'])) + · {{ \Illuminate\Support\Carbon::parse($jushuitanLatest['effect_start_time'])->format('Y-m-d H:i') }} + @endif + + @endif