feat(普通商品资料): 聚水潭成本价记录改为镜像快照比对

- 副本库 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0192A1X1EDtdzpaAVMhBj2nh
This commit is contained in:
limeng 2026-10-08 11:04:26 +08:00
parent f06380bcd0
commit 4c2c422c78
8 changed files with 305 additions and 3 deletions

View File

@ -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<string,mixed>|null $state */
private function shouldRunFull(?array $state): bool
{

View File

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

View File

@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 聚水潭成本价快照:每个商品编码保存一份“上次看到的成本价”。
* 定时任务把普通商品镜像(ops_jst_sku)的当前成本价与快照比对,
* 不一致即写一条变价记录到 jushuitan_cost_price_history_rows,并刷新快照。
*/
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
if (app()->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');
}
};

View File

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

View File

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

View File

@ -25,6 +25,11 @@ class JushuitanCostPriceHistoryMirrorRepository
);
}
public function connection(): ConnectionInterface
{
return $this->connection;
}
public function available(): bool
{
$schema = Schema::connection($this->connection->getName());

View File

@ -0,0 +1,208 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Modules\EcommerceFinance\Repositories\JushuitanCostPriceHistoryMirrorRepository;
use RuntimeException;
use Throwable;
/**
* 聚水潭成本价变价记录(快照比对版)。
*
* 副本库 rpt.jst_sku 每个商品只有一行、同步即覆盖,没有历史;聚水潭开放平台
* 操作日志接口暂无授权。因此改为:定时把共享镜像 ops_jst_sku 的当前成本价与
* 上一次快照比对,变了就记一条“变更前 → 变更后”,变更时间取聚水潭 modified
* (商品最后修改时间,比对频率足够高时即为改价时间)。
*
* 首次运行只建立基线快照,不产生变价记录;之后每次运行只记录新发生的变化。
*/
class JushuitanCostPriceSnapshotDiffer
{
public const SNAPSHOT_TABLE = 'jushuitan_cost_price_snapshots';
public function __construct(
private readonly JushuitanCostPriceHistoryMirrorRepository $history,
private readonly BusinessMirrorDatasetRegistry $datasets,
) {
}
/** @return array{status:string,mode:string,baseline_rows:int,new_skus:int,changes:int} */
public function sync(): array
{
$db = $this->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;
}
}

View File

@ -878,6 +878,15 @@
<span class="material-icons-outlined text-base" aria-hidden="true">history</span>
</button>
<span class="product-catalog-history-count">{{ $jushuitanHistoryCount }} 条</span>
@php $jushuitanLatest = $jushuitanHistory[0] ?? null; @endphp
@if ($jushuitanLatest)
<span class="product-catalog-history-count" title="最近一次成本价变更">
{{ $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
</span>
@endif
<script id="{{ $jushuitanHistoryId }}" type="application/json">@json($jushuitanHistory, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)</script>
</div>
</td>