From 41d80e40564d1a6dcc20abcbfaf42e2fe3fc2865 Mon Sep 17 00:00:00 2001 From: limeng <1131177064@qq.com> Date: Mon, 14 Sep 2026 17:08:38 +0800 Subject: [PATCH] feat: add jushuitan cost price history --- .../SyncJushuitanCostPriceHistory.php | 73 ++++ config/services.php | 15 + ...te_jushuitan_cost_price_history_mirror.php | 85 +++++ deploy/activate-incremental-release.sh | 1 + deploy/activate-release.sh | 1 + docs/上游业务镜像清单.md | 4 +- ...huitanCostPriceHistoryMirrorRepository.php | 328 ++++++++++++++++++ .../ProductCatalogMirrorRepository.php | 57 ++- .../JushuitanCostPriceHistorySynchronizer.php | 150 ++++++++ .../Services/JushuitanOpenApiClient.php | 169 +++++++++ .../normal-products.blade.php | 76 ++-- .../Business/ProductCatalogPageTest.php | 36 +- ...anCostPriceHistoryMirrorRepositoryTest.php | 198 +++++++++++ ...huitanCostPriceHistorySynchronizerTest.php | 126 +++++++ .../JushuitanOpenApiClientTest.php | 150 ++++++++ .../ProductCatalogMirrorRepositoryTest.php | 77 ++++ 16 files changed, 1513 insertions(+), 33 deletions(-) create mode 100644 app/Console/Commands/SyncJushuitanCostPriceHistory.php create mode 100644 database/migrations/2026_09_14_120000_create_jushuitan_cost_price_history_mirror.php create mode 100644 modules/EcommerceFinance/Repositories/JushuitanCostPriceHistoryMirrorRepository.php create mode 100644 modules/EcommerceFinance/Services/JushuitanCostPriceHistorySynchronizer.php create mode 100644 modules/EcommerceFinance/Services/JushuitanOpenApiClient.php create mode 100644 tests/Unit/EcommerceFinance/JushuitanCostPriceHistoryMirrorRepositoryTest.php create mode 100644 tests/Unit/EcommerceFinance/JushuitanCostPriceHistorySynchronizerTest.php create mode 100644 tests/Unit/EcommerceFinance/JushuitanOpenApiClientTest.php diff --git a/app/Console/Commands/SyncJushuitanCostPriceHistory.php b/app/Console/Commands/SyncJushuitanCostPriceHistory.php new file mode 100644 index 00000000..ddda7a45 --- /dev/null +++ b/app/Console/Commands/SyncJushuitanCostPriceHistory.php @@ -0,0 +1,73 @@ +option('full')) { + return self::SUCCESS; + } + + try { + $state = $mirror->state(); + $full = $this->shouldRunFull($state); + $result = $synchronizer->sync( + $full, + $this->option('page-size') === null ? null : (int) $this->option('page-size'), + $this->option('max-pages') === null ? null : (int) $this->option('max-pages'), + ); + } catch (Throwable $exception) { + Log::error('聚水潭历史成本价镜像同步失败。', ['exception' => $exception::class]); + $this->error('聚水潭成本价历史同步失败,已保留上一版共享数据。'); + + return self::FAILURE; + } + + if ($result['status'] === 'requires_full_sync') { + $this->warn('聚水潭成本价历史尚未完成首次全量同步,请在凭据和备份确认后显式使用 --full。'); + + return self::SUCCESS; + } + + $this->info(sprintf( + '聚水潭成本价操作日志%s:%s,本轮 %d 页,累计扫描 %d 条操作日志、同步 %d 条采购价变更。', + $result['mode'] === 'full' ? '全量同步' : '增量同步', + $result['status'] === 'succeeded' ? '完成' : '进行中', + $result['pages'], + $result['source_rows'], + $result['rows'], + )); + + return self::SUCCESS; + } + + /** @param array|null $state */ + private function shouldRunFull(?array $state): bool + { + if ((bool) $this->option('full')) { + return true; + } + + // 显式全量中断后由日常任务续传同一固定时间窗,完成前不激活半份数据。 + return is_array($state) + && ($state['run_mode'] ?? null) === 'full' + && in_array($state['status'] ?? null, ['running', 'failed'], true); + } +} diff --git a/config/services.php b/config/services.php index b14926cc..3c1bdf21 100644 --- a/config/services.php +++ b/config/services.php @@ -57,4 +57,19 @@ return [ 'poll_min_age_seconds' => (int) env('ESIGN_POLL_MIN_AGE_SECONDS', 120), ], + 'jushuitan' => [ + // 聚水潭开放平台凭据只从私有 runtime/.env 读取,不进入代码仓库。 + 'base_url' => env('JUSHUITAN_OPEN_API_URL', 'https://openapi.jushuitan.com'), + 'app_key' => env('JUSHUITAN_APP_KEY'), + 'app_secret' => env('JUSHUITAN_APP_SECRET'), + 'access_token' => env('JUSHUITAN_ACCESS_TOKEN'), + 'timeout' => (int) env('JUSHUITAN_OPEN_API_TIMEOUT', 20), + 'product_operation_history_path' => '/open/webapi/itemapi/itemsku/queryitemskuopshistory', + '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), + 'cost_price_history_page_size' => (int) env('JUSHUITAN_COST_PRICE_HISTORY_PAGE_SIZE', 2000), + 'cost_price_history_max_pages' => (int) env('JUSHUITAN_COST_PRICE_HISTORY_MAX_PAGES', 30), + ], + ]; diff --git a/database/migrations/2026_09_14_120000_create_jushuitan_cost_price_history_mirror.php b/database/migrations/2026_09_14_120000_create_jushuitan_cost_price_history_mirror.php new file mode 100644 index 00000000..6e6f7e0d --- /dev/null +++ b/database/migrations/2026_09_14_120000_create_jushuitan_cost_price_history_mirror.php @@ -0,0 +1,85 @@ +environment('testing') + || (string) config('system_database.mode', 'bootstrap') === 'bootstrap') { + return; + } + + $schema = Schema::connection($this->connection()); + + if (! $schema->hasTable('jushuitan_cost_price_history_sync_states')) { + $schema->create('jushuitan_cost_price_history_sync_states', function (Blueprint $table): void { + $table->string('dataset_key', 120)->primary(); + $table->string('status', 24)->default('pending'); + $table->string('run_mode', 24)->nullable(); + $table->uuid('generation')->nullable(); + $table->dateTime('window_started_at', 6)->nullable(); + $table->dateTime('window_ends_at', 6)->nullable(); + $table->dateTime('cursor_window_started_at', 6)->nullable(); + $table->unsignedInteger('cursor_page')->nullable(); + $table->dateTime('initial_completed_at')->nullable(); + $table->dateTime('last_full_succeeded_at')->nullable(); + $table->dateTime('last_attempted_at')->nullable(); + $table->dateTime('last_succeeded_at')->nullable(); + $table->unsignedBigInteger('last_processed_source_rows')->default(0); + $table->unsignedBigInteger('last_upserted_rows')->default(0); + $table->unsignedBigInteger('mirror_row_count')->default(0); + $table->string('last_error_code', 120)->nullable(); + $table->timestamps(); + }); + } + + if (! $schema->hasTable('jushuitan_cost_price_history_rows')) { + $schema->create('jushuitan_cost_price_history_rows', function (Blueprint $table): void { + $table->string('operation_id', 128)->primary(); + $table->string('sku_id', 255); + $table->string('sku_id_normalized', 255); + $table->string('i_id', 255)->nullable(); + $table->string('product_name', 500)->nullable(); + $table->string('properties_value', 500)->nullable(); + $table->string('sku_type', 64)->nullable(); + $table->string('operation_type', 255)->nullable(); + $table->text('operation_description')->nullable(); + $table->string('operator_name', 255)->nullable(); + $table->dateTime('operated_at', 6)->nullable(); + $table->decimal('before_price', 20, 4)->nullable(); + $table->decimal('after_price', 20, 4)->nullable(); + $table->decimal('diff_amount', 20, 4)->nullable(); + $table->uuid('_mirror_generation')->nullable()->index(); + $table->char('row_hash', 64); + $table->dateTime('_mirror_synced_at', 6)->index(); + $table->timestamps(); + + $table->index( + ['sku_id_normalized', 'operated_at', 'operation_id'], + 'jushuitan_cost_price_history_lookup', + ); + }); + } + } + + public function down(): void + { + if (app()->environment('testing') + || (string) config('system_database.mode', 'bootstrap') === 'bootstrap') { + return; + } + + $schema = Schema::connection($this->connection()); + $schema->dropIfExists('jushuitan_cost_price_history_rows'); + $schema->dropIfExists('jushuitan_cost_price_history_sync_states'); + } +}; diff --git a/deploy/activate-incremental-release.sh b/deploy/activate-incremental-release.sh index f2c46895..642f5690 100644 --- a/deploy/activate-incremental-release.sh +++ b/deploy/activate-incremental-release.sh @@ -180,6 +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 */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 EOF chmod 644 /etc/cron.d/caiwu-jiansun-snapshots diff --git a/deploy/activate-release.sh b/deploy/activate-release.sh index aff037bf..066acca4 100644 --- a/deploy/activate-release.sh +++ b/deploy/activate-release.sh @@ -184,6 +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 */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 EOF chmod 644 /etc/cron.d/caiwu-jiansun-snapshots diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index 071eac72..8d1e95fc 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -27,7 +27,8 @@ | 已接入待首次同步 | 店铺收款账单的拼多多账户读取 `jiansun.dbo.jst_originalBillInfo` 中固定 `channel = PinduoduoImport` 的独立切片镜像 `js_jst_pdd_receiving_bill_rows`;不使用慢一天的 `operations_replica` 副本或营销账户 `pdd_spread_bills`。 | | 已完成首次导入 | 提现管理的每日店铺余额读取聚水潭“平台原始账单[新] → 店铺余额表”接口 JSON/XLSX 及多平台“平台资金账户余额”导出,进入 `jushuitan_shop_balance_snapshots`;已导入 2026-09-11 的淘系、拼多多和抖音共 164 条余额。页面按店铺名称精确匹配并强制使用平台消歧,未匹配和多匹配不会显示为零。 | | 已接入待首次同步 | 店铺营销账户余额的京东账户读取 `js_data_middle_platform.dbo.jd_jzt_finance_records` 镜像 `jsm_jd_jzt_finance_records`;按 `id` 稳定断点、`updated_at` 重叠增量,页面不直连上游。 | - | 本次接入 | 普通商品资料的工作平台采购价格接入 `jiansun.dbo.pm_priceAdjustment`(镜像 `js_pm_price_adjustment`):按 `skuCode` 关联 `jst_sku.sku_id`,取 `effectTime` 不晚于当前时间的最新 `purchasePrice`,并在页面和导出中标记“工作平台价格”;源表没有可靠更新时间,按 `Id` 稳定键追加,首次全量完成前只显示普通商品自身采购价。 | +| 本次接入 | 普通商品资料的工作平台采购价格接入 `jiansun.dbo.pm_priceAdjustment`(镜像 `js_pm_price_adjustment`):按 `skuCode` 关联 `jst_sku.sku_id`,取 `effectTime` 不晚于当前时间的最新 `purchasePrice`,并在页面和导出中标记“工作平台价格”;源表没有可靠更新时间,按 `Id` 稳定键追加,首次全量完成前只显示普通商品自身采购价。 | +| 本次接入 | 普通商品成本价变更读取聚水潭开放平台“商品及库存管理”的商品操作日志接口 `/open/webapi/itemapi/itemsku/queryitemskuopshistory`,只镜像操作内容含“采购价”的记录到 `jushuitan_cost_price_history_rows`;审计历史按 `operation_id` 只追加或更新,全量回补不删除旧记录;页面按商品编码分别展示聚水潭成本价记录和工作平台价格记录,不混用来源。 | ## 经营分析副本库(operations_replica) @@ -95,6 +96,7 @@ 10. `operations_replica.rpt.jst_originalBillInfo` 是供多个业务功能复用的原始账单镜像。首次同步由 Codex 备份后自主安排在生产同步节点低峰期分批续传,无需另行授权;页面请求和普通开发机不启动刷新 worker。同步完成后,开发者通过固定 Repository 查询 `ops_jst_original_bill_info`,不得各自创建整表快照。 淘系支付宝账户账单当前仍读取此 MariaDB 镜像,统一调度须持续增量,不因 ClickHouse 尚未启用而暂停。账单冲减则复用“订单数据 → 原始数据 → 订单售后费用明细”,不按该表费用名称推断 500112。 11. 拼多多货款账户账单使用 `jiansun.dbo.jst_originalBillInfo.pinduoduo_receiving` 数据集。同步器必须以参数绑定固定 `channel = PinduoduoImport`,只复制该渠道切片;页面 Repository 只读 `js_jst_pdd_receiving_bill_rows`。首次全量按 `Id` 断点推进,后续按 `updateTime` 保留重叠窗口,不得切回落后一天的 `operations_replica` 副本。 12. 三方仓进销存首次全量按头表 `Id` 区间小批断点续传,头表只保存 `io_date >= 2026-08-01` 的目标仓数据,不激活半份数据;日常增量以头表 `(modified, Id)` 作稳定游标并保留 48 小时时间重叠,由命中头单驱动整单明细替换。全量和增量都先写待发布代次,库存抓取完成后再把流水追平到库存抓取之后的固定源时间边界,三部分成功后才一次切换;运行中或失败时页面始终读取上一成功代次。增量扫描不先限定仓库或业务日期,确保单据移出目标仓或日期改到起始日前时可从镜像撤回,重载当前版本时再固定仓库和起始日期。页面只统计 `Confirmed` 的 12 类进销存流水,数量按上游 `qty * qty_type` 保留符号。库存同时保存上游 `source_modified_at` 和实际 `captured_at`,历史余额以每个 SKU 的抓取时间为回推锚点;页面始终按“日期 + 商品”输出,日期区间固定为 `[当天 00:00, 次日 00:00)`。每天 `00:00` 执行增量同步,失败时继续展示上一成功代次。 +13. 聚水潭商品操作日志按最多 30 天的固定时间窗和接口页码稳定断点同步,首次全量从 `2000-01-01` 起扫描;只保留操作内容含“采购价”的记录,并从“采购价:旧值->新值”解析前后价格和差额。日常同步回看 48 小时,按操作日志 `autoid` 幂等更新;接口失败时保留上一版镜像。页面不得把这些聚水潭记录与 `jiansun.dbo.pm_priceAdjustment` 的工作平台价格记录混为同一来源。 工作平台采购价格首次全量需在备份和负责人授权后单独执行: diff --git a/modules/EcommerceFinance/Repositories/JushuitanCostPriceHistoryMirrorRepository.php b/modules/EcommerceFinance/Repositories/JushuitanCostPriceHistoryMirrorRepository.php new file mode 100644 index 00000000..bb6a7b7f --- /dev/null +++ b/modules/EcommerceFinance/Repositories/JushuitanCostPriceHistoryMirrorRepository.php @@ -0,0 +1,328 @@ +connection = $database->connection( + (string) config('upstream_mirrors.connection', 'business_mirror'), + ); + } + + public function available(): bool + { + $schema = Schema::connection($this->connection->getName()); + + return $schema->hasTable(self::STATE_TABLE) + && $schema->hasTable(self::TABLE); + } + + /** @return array|null */ + public function state(): ?array + { + if (! $this->available()) { + return null; + } + + $row = $this->connection->table(self::STATE_TABLE) + ->where('dataset_key', self::DATASET_KEY) + ->first(); + + return $row === null ? null : (array) $row; + } + + /** @return array{code:string,initialized:bool,last_succeeded_at:?string} */ + public function syncStatus(): array + { + $state = $this->state(); + if (! is_array($state)) { + return ['code' => 'unavailable', 'initialized' => false, 'last_succeeded_at' => null]; + } + + if (($state['initial_completed_at'] ?? null) === null) { + return [ + 'code' => match ((string) ($state['status'] ?? 'pending')) { + 'running' => 'initializing', + 'failed' => 'initial_failed', + default => 'pending', + }, + 'initialized' => false, + 'last_succeeded_at' => null, + ]; + } + + return [ + 'code' => ($state['status'] ?? null) === 'failed' ? 'failed' : 'ready', + 'initialized' => true, + 'last_succeeded_at' => ($state['last_succeeded_at'] ?? null) === null + ? null + : (string) $state['last_succeeded_at'], + ]; + } + + /** @return array */ + public function startRun( + string $mode, + string $generation, + Carbon $since, + Carbon $until, + bool $resume, + ): array { + $now = now(); + $values = [ + 'status' => 'running', + 'run_mode' => $mode, + 'generation' => $generation, + 'last_attempted_at' => $now, + 'last_error_code' => null, + 'updated_at' => $now, + ]; + + if (! $resume) { + $values += [ + 'window_started_at' => $since, + 'window_ends_at' => $until, + 'cursor_window_started_at' => $since, + 'cursor_page' => 1, + 'last_processed_source_rows' => 0, + 'last_upserted_rows' => 0, + ]; + } + + $query = $this->connection->table(self::STATE_TABLE) + ->where('dataset_key', self::DATASET_KEY); + if ($query->exists()) { + $query->update($values); + } else { + $this->connection->table(self::STATE_TABLE)->insert( + ['dataset_key' => self::DATASET_KEY, 'created_at' => $now] + $values, + ); + } + + return $this->state() ?? []; + } + + /** @param list> $records */ + public function storePage( + array $records, + Carbon $cursorWindowStartedAt, + int $cursorPage, + string $generation, + int $processedSourceRows, + int $upsertedRows, + ): void { + $now = now(); + $mapped = array_map(function (array $record) use ($generation, $now): array { + $skuId = trim((string) ($record['sku_id'] ?? '')); + $values = [ + 'operation_id' => $this->operationId($record), + 'sku_id' => $skuId, + 'sku_id_normalized' => $this->normalizeSku($skuId), + 'i_id' => $this->nullableString($record['i_id'] ?? null), + 'product_name' => $this->nullableString($record['product_name'] ?? null), + 'properties_value' => $this->nullableString($record['properties_value'] ?? null), + 'sku_type' => $this->nullableString($record['sku_type'] ?? null), + 'operation_type' => $this->nullableString($record['operation_type'] ?? null), + 'operation_description' => $this->nullableString($record['operation_description'] ?? null), + 'operator_name' => $this->nullableString($record['operator_name'] ?? null), + 'operated_at' => $this->dateTime($record['operated_at'] ?? null), + 'before_price' => $record['before_price'] ?? null, + 'after_price' => $record['after_price'] ?? null, + 'diff_amount' => $record['diff_amount'] ?? null, + ]; + + return $values + [ + '_mirror_generation' => $generation, + 'row_hash' => hash('sha256', json_encode( + $values, + JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION | JSON_THROW_ON_ERROR, + )), + '_mirror_synced_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ]; + }, $records); + + $this->connection->transaction(function () use ( + $mapped, + $cursorWindowStartedAt, + $cursorPage, + $generation, + $processedSourceRows, + $upsertedRows, + $now, + ): void { + if ($mapped !== []) { + $this->connection->table(self::TABLE)->upsert( + $mapped, + ['operation_id'], + [ + 'sku_id', 'sku_id_normalized', 'i_id', 'product_name', + 'properties_value', 'sku_type', 'operation_type', + 'operation_description', 'operator_name', 'operated_at', + 'before_price', 'after_price', 'diff_amount', + '_mirror_generation', 'row_hash', '_mirror_synced_at', 'updated_at', + ], + ); + } + $this->connection->table(self::STATE_TABLE) + ->where('dataset_key', self::DATASET_KEY) + ->update([ + 'cursor_window_started_at' => $cursorWindowStartedAt, + 'cursor_page' => $cursorPage, + 'last_processed_source_rows' => $processedSourceRows, + 'last_upserted_rows' => $upsertedRows, + 'updated_at' => $now, + ]); + }); + } + + public function complete( + string $mode, + string $generation, + Carbon $until, + int $processedSourceRows, + int $upsertedRows, + ): void { + $now = now(); + $this->connection->transaction(function () use ( + $mode, + $generation, + $until, + $processedSourceRows, + $upsertedRows, + $now, + ): void { + $values = [ + 'status' => 'succeeded', + 'cursor_window_started_at' => null, + 'cursor_page' => null, + 'last_succeeded_at' => $until, + 'last_processed_source_rows' => $processedSourceRows, + 'last_upserted_rows' => $upsertedRows, + 'mirror_row_count' => $this->connection->table(self::TABLE)->count(), + 'last_error_code' => null, + 'updated_at' => $now, + ]; + if ($mode === 'full') { + $values['initial_completed_at'] = $now; + $values['last_full_succeeded_at'] = $now; + } + $this->connection->table(self::STATE_TABLE) + ->where('dataset_key', self::DATASET_KEY) + ->update($values); + }); + } + + public function fail(string $errorCode): void + { + $this->connection->table(self::STATE_TABLE) + ->where('dataset_key', self::DATASET_KEY) + ->update([ + 'status' => 'failed', + 'last_error_code' => mb_substr($errorCode, 0, 120), + 'updated_at' => now(), + ]); + } + + /** @param list $normalizedSkus @return Collection> */ + public function historyForSkus(array $normalizedSkus): Collection + { + if ($normalizedSkus === []) { + return collect(); + } + + return $this->connection->table(self::TABLE) + ->whereIn('sku_id_normalized', $normalizedSkus) + ->orderBy('sku_id_normalized') + ->orderByDesc('operated_at') + ->orderByDesc('operation_id') + ->get([ + 'operation_id', 'sku_id', 'sku_id_normalized', 'operation_type', + 'operation_description', 'operator_name', 'operated_at', + 'before_price', 'after_price', 'diff_amount', + ]) + ->groupBy('sku_id_normalized'); + } + + /** @return list> */ + public function formatHistory(Collection $records): array + { + return $records->map(static fn (object $record): array => [ + 'id' => $record->operation_id, + 'source' => '聚水潭商品及库存管理', + 'record_type_label' => '成本价变更', + 'cost_price' => $record->after_price, + 'before_price' => $record->before_price, + 'after_price' => $record->after_price, + 'diff_amount' => $record->diff_amount, + 'effect_start_time' => $record->operated_at, + 'effect_end_time' => null, + 'remark' => $record->operation_description, + 'created_at' => $record->operated_at, + 'operator' => $record->operator_name, + 'operation' => $record->operation_type, + ])->values()->all(); + } + + private function normalizeSku(mixed $value): string + { + return mb_strtoupper(trim((string) $value)); + } + + /** @param array $record */ + private function operationId(array $record): string + { + $operationId = trim((string) ($record['operation_id'] ?? '')); + if ($operationId !== '') { + return $operationId; + } + + return hash('sha256', json_encode([ + $this->normalizeSku($record['sku_id'] ?? null), + $record['operation_description'] ?? null, + $record['operator_name'] ?? null, + $record['operated_at'] ?? null, + ], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)); + } + + private function nullableString(mixed $value): ?string + { + if ($value === null) { + return null; + } + $value = trim((string) $value); + + return $value === '' ? null : $value; + } + + private function dateTime(mixed $value): ?string + { + $value = $this->nullableString($value); + if ($value === null) { + return null; + } + + try { + return Carbon::parse($value)->format('Y-m-d H:i:s.u'); + } catch (\Throwable) { + return null; + } + } +} diff --git a/modules/EcommerceFinance/Repositories/ProductCatalogMirrorRepository.php b/modules/EcommerceFinance/Repositories/ProductCatalogMirrorRepository.php index e6d34dac..2d3e147d 100644 --- a/modules/EcommerceFinance/Repositories/ProductCatalogMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/ProductCatalogMirrorRepository.php @@ -23,6 +23,8 @@ class ProductCatalogMirrorRepository public const WORK_PLATFORM_SOURCE_LABEL = '工作平台价格'; + public const JUSHUITAN_COST_HISTORY_SOURCE_LABEL = '聚水潭商品及库存管理'; + /** * 工作平台采购价来自 JianSun 旧业务库的价格调整表,按 SKU 编码 * 关联普通商品资料;同一 SKU 取当前已生效的最新价格记录。 @@ -45,6 +47,7 @@ class ProductCatalogMirrorRepository public function __construct( private readonly BusinessMirrorDatasetRegistry $datasets, private readonly GenericBusinessMirrorRepository $mirror, + private readonly ?JushuitanCostPriceHistoryMirrorRepository $jushuitanCostPriceHistory = null, ) { $this->connection = $mirror->connection(); } @@ -135,6 +138,8 @@ class ProductCatalogMirrorRepository $this->attachPriceChanges($rows, $priceChangeSyncStatus['initialized']); $workPlatformPriceSyncStatus = $this->workPlatformPriceSyncStatus(); $this->attachWorkPlatformPrices($rows, $workPlatformPriceSyncStatus['initialized']); + $jushuitanCostPriceHistorySyncStatus = $this->jushuitanCostPriceHistorySyncStatus(); + $this->attachJushuitanCostPriceHistory($rows, $jushuitanCostPriceHistorySyncStatus['initialized']); return [ 'data' => $rows, @@ -339,6 +344,8 @@ class ProductCatalogMirrorRepository $row->price_change_effect_time = null; $row->price_change_history = []; $row->price_change_history_count = 0; + $row->work_platform_price_history = []; + $row->work_platform_price_history_count = 0; } if ($rows === [] || ! $initialized) { @@ -376,13 +383,13 @@ class ProductCatalogMirrorRepository foreach ($rows as $row) { $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, + 'source' => self::WORK_PLATFORM_SOURCE_LABEL, 'record_type' => $type, 'record_type_label' => match ($type) { '0' => '定价', @@ -398,6 +405,8 @@ class ProductCatalogMirrorRepository ]; })->all(); $row->price_change_history_count = count($row->price_change_history); + $row->work_platform_price_history = $row->price_change_history; + $row->work_platform_price_history_count = $row->price_change_history_count; } $change = $history->first( @@ -414,6 +423,40 @@ class ProductCatalogMirrorRepository } } + /** @param list $rows */ + private function attachJushuitanCostPriceHistory(array $rows, bool $initialized): void + { + foreach ($rows as $row) { + $row->jushuitan_cost_price_history = []; + $row->jushuitan_cost_price_history_count = 0; + } + + if ($rows === [] || ! $initialized || $this->jushuitanCostPriceHistory === null) { + return; + } + + $skuIds = collect($rows) + ->pluck('sku_id') + ->map(fn (mixed $value): string => $this->normalizeJushuitanSku($value)) + ->filter() + ->unique() + ->values() + ->all(); + if ($skuIds === []) { + return; + } + + $histories = $this->jushuitanCostPriceHistory->historyForSkus($skuIds); + foreach ($rows as $row) { + $history = $histories->get($this->normalizeJushuitanSku($row->sku_id ?? null), collect()); + $row->jushuitan_cost_price_history = $this->jushuitanCostPriceHistory->formatHistory($history); + $row->jushuitan_cost_price_history_count = count($row->jushuitan_cost_price_history); + if ($row->jushuitan_cost_price_history_count > 0) { + $row->data_sources[] = self::JUSHUITAN_COST_HISTORY_SOURCE_LABEL; + } + } + } + /** @return array{code:string,initialized:bool,last_succeeded_at:?string} */ public function priceChangeSyncStatus(): array { @@ -486,6 +529,13 @@ class ProductCatalogMirrorRepository ]; } + /** @return array{code:string,initialized:bool,last_succeeded_at:?string} */ + private function jushuitanCostPriceHistorySyncStatus(): array + { + return $this->jushuitanCostPriceHistory?->syncStatus() + ?? ['code' => 'unavailable', 'initialized' => false, 'last_succeeded_at' => null]; + } + private function priceChangeTable(): string { return (string) config( @@ -507,6 +557,11 @@ class ProductCatalogMirrorRepository return mb_strtolower(trim((string) $value), 'UTF-8'); } + private function normalizeJushuitanSku(mixed $value): string + { + return mb_strtoupper(trim((string) $value), 'UTF-8'); + } + /** @param array $filters */ private function filteredQuery(array $filters): Builder { diff --git a/modules/EcommerceFinance/Services/JushuitanCostPriceHistorySynchronizer.php b/modules/EcommerceFinance/Services/JushuitanCostPriceHistorySynchronizer.php new file mode 100644 index 00000000..5caa4edf --- /dev/null +++ b/modules/EcommerceFinance/Services/JushuitanCostPriceHistorySynchronizer.php @@ -0,0 +1,150 @@ +mirror->available()) { + throw new RuntimeException('jushuitan_cost_price_history_mirror_schema_missing'); + } + + $state = $this->mirror->state(); + if (! $full && ($state['initial_completed_at'] ?? null) === null) { + return ['status' => 'requires_full_sync', 'mode' => 'incremental', 'pages' => 0, 'source_rows' => 0, 'rows' => 0]; + } + + $mode = $full ? 'full' : 'incremental'; + $resume = is_array($state) + && ($state['run_mode'] ?? null) === $mode + && in_array($state['status'] ?? null, ['running', 'failed'], true) + && ($state['window_started_at'] ?? null) !== null + && ($state['window_ends_at'] ?? null) !== null; + $generation = $resume && is_string($state['generation'] ?? null) + ? (string) $state['generation'] + : (string) Str::uuid(); + if ($resume) { + $since = Carbon::parse((string) $state['window_started_at']); + $until = Carbon::parse((string) $state['window_ends_at']); + } elseif ($full) { + $since = $this->historyStart(); + $until = now()->addSecond(); + } else { + $overlap = max((int) config('services.jushuitan.cost_price_history_overlap_hours', 48), 1); + $since = Carbon::parse((string) $state['last_succeeded_at'])->subHours($overlap); + $until = now()->addSecond(); + } + + $state = $this->mirror->startRun($mode, $generation, $since, $until, $resume); + $windowStartedAt = ($state['cursor_window_started_at'] ?? null) === null + ? $since->copy() + : Carbon::parse((string) $state['cursor_window_started_at']); + $page = max((int) ($state['cursor_page'] ?? 1), 1); + $pageSize = min(max( + $pageSize ?? (int) config('services.jushuitan.cost_price_history_page_size', 2000), + 1, + ), 2000); + $maxPages = max( + $maxPages ?? (int) config('services.jushuitan.cost_price_history_max_pages', 30), + 0, + ); + $pages = 0; + $processedSourceRows = (int) ($state['last_processed_source_rows'] ?? 0); + $upsertedRows = (int) ($state['last_upserted_rows'] ?? 0); + $windowDays = min(max( + (int) config('services.jushuitan.cost_price_history_window_days', 30), + 1, + ), 30); + + try { + while ($windowStartedAt->lt($until)) { + $windowEndsAt = $windowStartedAt->copy()->addDays($windowDays); + if ($windowEndsAt->gte($until)) { + $windowEndsAt = $until->copy(); + } + + $result = $this->upstream->getCostPriceOperationHistory( + $windowStartedAt, + $windowEndsAt, + $page, + $pageSize, + ); + $records = $result['records']; + $processedSourceRows += $result['source_rows']; + $upsertedRows += count($records); + $pages++; + + $hasNextPage = $result['total_pages'] > 0 && $page < $result['total_pages']; + // 相邻窗口在边界秒保留一次重叠,避免接口时间精度高于秒时漏掉记录; + // operation_id 的幂等 upsert 会消除边界重复。 + $nextWindowStartedAt = $hasNextPage + ? $windowStartedAt->copy() + : $windowEndsAt->copy(); + $nextPage = $hasNextPage ? $page + 1 : 1; + $this->mirror->storePage( + $records, + $nextWindowStartedAt, + $nextPage, + $generation, + $processedSourceRows, + $upsertedRows, + ); + $windowStartedAt = $nextWindowStartedAt; + $page = $nextPage; + + if ($maxPages > 0 && $pages >= $maxPages && $windowStartedAt->lt($until)) { + return [ + 'status' => 'running', + 'mode' => $mode, + 'pages' => $pages, + 'source_rows' => $processedSourceRows, + 'rows' => $upsertedRows, + ]; + } + } + + $this->mirror->complete( + $mode, + $generation, + $until, + $processedSourceRows, + $upsertedRows, + ); + + return [ + 'status' => 'succeeded', + 'mode' => $mode, + 'pages' => $pages, + 'source_rows' => $processedSourceRows, + 'rows' => $upsertedRows, + ]; + } catch (Throwable $exception) { + $this->mirror->fail($exception::class); + throw $exception; + } + } + + private function historyStart(): Carbon + { + $configured = trim((string) config( + 'services.jushuitan.cost_price_history_start', + '2000-01-01 00:00:00', + )); + + return Carbon::parse($configured === '' ? '2000-01-01 00:00:00' : $configured); + } +} diff --git a/modules/EcommerceFinance/Services/JushuitanOpenApiClient.php b/modules/EcommerceFinance/Services/JushuitanOpenApiClient.php new file mode 100644 index 00000000..0bfc6b11 --- /dev/null +++ b/modules/EcommerceFinance/Services/JushuitanOpenApiClient.php @@ -0,0 +1,169 @@ +>,source_rows:int,current_page:int,total_pages:int,total_count:int} + */ + public function getCostPriceOperationHistory( + Carbon $start, + Carbon $end, + int $page, + int $pageSize, + ): array { + $payload = $this->request([ + 'current_page' => max($page, 1), + 'page_size' => min(max($pageSize, 1), 2000), + 'start_time' => $start->format('Y-m-d H:i:s'), + 'end_time' => $end->format('Y-m-d H:i:s'), + ]); + $data = is_array($payload['data'] ?? null) ? $payload['data'] : []; + $pageInfo = is_array($data['page'] ?? null) ? $data['page'] : []; + + return [ + 'records' => $this->normalizeCostPriceOperations($data['list'] ?? []), + 'source_rows' => is_array($data['list'] ?? null) ? count($data['list']) : 0, + 'current_page' => max((int) ($pageInfo['current_page'] ?? $page), 1), + 'total_pages' => max((int) ($pageInfo['pages'] ?? 0), 0), + 'total_count' => max((int) ($pageInfo['count'] ?? 0), 0), + ]; + } + + /** @param array $biz @return array */ + private function request(array $biz): array + { + $appKey = trim((string) config('services.jushuitan.app_key')); + $appSecret = trim((string) config('services.jushuitan.app_secret')); + $accessToken = trim((string) config('services.jushuitan.access_token')); + if ($appKey === '' || $appSecret === '' || $accessToken === '') { + throw new RuntimeException('jushuitan_api_credentials_missing'); + } + + $params = [ + 'access_token' => $accessToken, + 'app_key' => $appKey, + 'biz' => json_encode( + $biz, + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR, + ), + 'charset' => 'utf-8', + 'timestamp' => (string) now()->timestamp, + 'version' => '2', + ]; + $params['sign'] = $this->sign($params, $appSecret); + + $baseUrl = rtrim((string) config('services.jushuitan.base_url'), '/'); + $path = (string) config( + 'services.jushuitan.product_operation_history_path', + '/open/webapi/itemapi/itemsku/queryitemskuopshistory', + ); + /** @var PendingRequest $request */ + $request = Http::asForm() + ->acceptJson() + ->timeout(max((int) config('services.jushuitan.timeout', 20), 1)); + $response = $request->post($baseUrl . '/' . ltrim($path, '/'), $params); + + if (! $response->successful()) { + throw new RuntimeException('jushuitan_api_http_' . $response->status()); + } + + $payload = $response->json(); + if (! is_array($payload)) { + throw new RuntimeException('jushuitan_api_invalid_json'); + } + $code = (int) ($payload['code'] ?? -1); + if ($code !== 0) { + throw new RuntimeException('jushuitan_api_code_' . $code); + } + + return $payload; + } + + /** @param array $params */ + private function sign(array $params, string $appSecret): string + { + ksort($params, SORT_STRING); + $plain = $appSecret; + foreach ($params as $key => $value) { + if ($key === 'sign' || $value === null || $value === '') { + continue; + } + $plain .= $key . (string) $value; + } + + return md5($plain); + } + + /** @param mixed $records @return list> */ + private function normalizeCostPriceOperations(mixed $records): array + { + if (! is_array($records)) { + return []; + } + + $result = []; + foreach ($records as $record) { + if (! is_array($record)) { + continue; + } + $skuId = trim((string) ($record['sku_id'] ?? '')); + $description = trim((string) ($record['operation_description'] ?? '')); + if ($skuId === '' || ! str_contains($description, '采购价')) { + continue; + } + + [$beforePrice, $afterPrice] = $this->pricesFromDescription($description); + $result[] = [ + 'operation_id' => (string) ($record['autoid'] ?? ''), + 'sku_id' => $skuId, + 'i_id' => $this->nullableString($record['i_id'] ?? null), + 'product_name' => $this->nullableString($record['name'] ?? null), + 'properties_value' => $this->nullableString($record['properties_value'] ?? null), + 'sku_type' => $this->nullableString($record['sku_type'] ?? null), + 'operation_type' => $this->nullableString($record['operation_type'] ?? null), + 'operation_description' => $description, + 'operator_name' => $this->nullableString($record['creator_name'] ?? null), + 'operated_at' => $this->nullableString($record['created'] ?? null), + 'before_price' => $beforePrice, + 'after_price' => $afterPrice, + 'diff_amount' => ($beforePrice === null || $afterPrice === null) + ? null + : $afterPrice - $beforePrice, + ]; + } + + return $result; + } + + /** @return array{0:?float,1:?float} */ + private function pricesFromDescription(string $description): array + { + $matched = preg_match( + '/采购价\s*[::]\s*(-?\d+(?:\.\d+)?)\s*(?:->|=>|→|至)\s*(-?\d+(?:\.\d+)?)/u', + $description, + $matches, + ); + if ($matched !== 1) { + return [null, null]; + } + + return [(float) $matches[1], (float) $matches[2]]; + } + + private function nullableString(mixed $value): ?string + { + if ($value === null) { + return null; + } + $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 b97abd94..f4db1af5 100644 --- a/resources/views/business/pages/product-inventory/normal-products.blade.php +++ b/resources/views/business/pages/product-inventory/normal-products.blade.php @@ -743,11 +743,11 @@ @if (($productCatalogPriceChangeSyncStatus['code'] ?? '') === 'initializing') - 商品价格变价记录正在执行首次同步,完成前变价前价格、变价后价格、差额与变价时间显示为空。 + 工作平台价格变价记录正在执行首次同步,完成前工作平台变价前价格、工作平台变价后价格、工作平台差额与工作平台变价时间显示为空。 @elseif (($productCatalogPriceChangeSyncStatus['code'] ?? '') === 'initial_failed') - 商品价格变价记录首次同步失败,当前不显示变价前价格、变价后价格、差额与变价时间。 + 工作平台价格变价记录首次同步失败,当前不显示工作平台变价前价格、工作平台变价后价格、工作平台差额与工作平台变价时间。 @else - 商品价格变价记录尚未完成首次同步,当前不显示变价前价格、变价后价格、差额与变价时间。 + 工作平台价格变价记录尚未完成首次同步,当前不显示工作平台变价前价格、工作平台变价后价格、工作平台差额与工作平台变价时间。 @endif @@ -792,11 +792,12 @@ 工作平台价格 工作平台价格生效日期 定价状态 - 变价前价格 - 变价后价格 - 差额 - 变价时间 - 变价记录 + 工作平台变价前价格 + 工作平台变价后价格 + 工作平台差额 + 工作平台变价时间 + 聚水潭成本价记录 + 工作平台价格记录 数据来源 市场价 重量(kg) @@ -851,9 +852,12 @@ {{ 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); + $jushuitanHistory = $row->jushuitan_cost_price_history ?? []; + $jushuitanHistoryCount = (int) ($row->jushuitan_cost_price_history_count ?? count($jushuitanHistory)); + $jushuitanHistoryId = 'product-jushuitan-cost-history-' . (int) ($row->autoid ?? 0); + $workPlatformHistory = $row->work_platform_price_history ?? $row->price_change_history ?? []; + $workPlatformHistoryCount = (int) ($row->work_platform_price_history_count ?? $row->price_change_history_count ?? count($workPlatformHistory)); + $workPlatformHistoryId = 'product-work-platform-history-' . (int) ($row->autoid ?? 0); @endphp
@@ -861,19 +865,29 @@ type="button" class="product-catalog-icon-button" data-price-history-trigger - data-history-target="{{ $priceChangeHistoryId }}" + data-history-target="{{ $jushuitanHistoryId }}" + data-history-source="聚水潭成本价记录" data-sku-code="{{ $row->sku_id ?? '' }}" data-product-name="{{ $row->name ?? '' }}" data-cost-price="{{ $row->cost_price ?? '' }}" data-work-platform-price="{{ $row->work_platform_price ?? '' }}" - aria-label="查看 {{ $row->sku_id ?? '当前商品' }} 的价格变更明细" - title="{{ $priceChangeHistoryCount > 0 ? '查看价格变更明细' : '当前商品暂无价格变更记录' }}" - @disabled($priceChangeHistoryCount === 0) + aria-label="查看 {{ $row->sku_id ?? '当前商品' }} 的聚水潭成本价记录" + title="{{ $jushuitanHistoryCount > 0 ? '查看聚水潭成本价记录' : '当前商品暂无聚水潭成本价记录' }}" + @disabled($jushuitanHistoryCount === 0) > - {{ $priceChangeHistoryCount }} 条 - + {{ $jushuitanHistoryCount }} 条 + +
+ + +
+ + {{ $workPlatformHistoryCount }} 条 +
@@ -905,7 +919,7 @@ @empty - + @if ($productCatalogError) 数据暂时不可用。 @@ -956,19 +970,19 @@ >
-

价格变更明细

+

价格记录

- +
@@ -999,6 +1013,9 @@ + + + @@ -1021,7 +1038,7 @@ const tableBody = page.querySelector('[data-price-history-body]'); const exportButton = page.querySelector('[data-price-history-export]'); const closeButton = modal?.querySelector('button[data-price-history-close]'); - const state = { history: [], sku: '', productName: '', trigger: null }; + const state = { history: [], sku: '', productName: '', source: '', trigger: null }; if (!modal || !tableBody || !exportButton || !closeButton) { return; @@ -1070,6 +1087,9 @@ appendCell(row, formatMoney(item.diff_amount), differenceClass); appendCell(row, formatDate(item.effect_start_time)); appendCell(row, formatDate(item.effect_end_time)); + appendCell(row, item.remark); + appendCell(row, item.operation || '接口未提供'); + appendCell(row, item.operator || '接口未提供'); appendCell(row, formatDate(item.created_at, true)); tableBody.appendChild(row); }); @@ -1092,7 +1112,9 @@ state.sku = trigger.dataset.skuCode || ''; state.productName = trigger.dataset.productName || ''; + state.source = trigger.dataset.historySource || '价格记录'; state.trigger = trigger; + page.querySelector('[data-price-history-title]').textContent = state.source; page.querySelector('[data-price-history-subtitle]').textContent = `${state.productName || '未命名商品'} · ${state.history.length} 条记录`; page.querySelector('[data-price-history-sku]').textContent = displayValue(state.sku); page.querySelector('[data-price-history-cost]').textContent = formatMoney(trigger.dataset.costPrice); @@ -1111,16 +1133,20 @@ const csvCell = (value) => `"${displayValue(value).replaceAll('"', '""')}"`; const exportHistory = () => { - const headers = ['价格类型', '变价前价格', '变价后价格', '差价', '生效开始日期', '生效结束日期', '记录时间']; + const headers = ['来源', '价格类型', '变价前价格', '变价后价格', '差价', '生效开始日期', '生效结束日期', '备注', '操作方式', '操作人', '记录时间']; const lines = [headers.map(csvCell).join(',')]; state.history.forEach((item) => { lines.push([ + item.source || state.source, item.record_type_label, item.before_price, item.after_price, item.diff_amount, formatDate(item.effect_start_time), formatDate(item.effect_end_time), + item.remark, + item.operation || '接口未提供', + item.operator || '接口未提供', formatDate(item.created_at, true), ].map(csvCell).join(',')); }); @@ -1129,7 +1155,7 @@ const link = document.createElement('a'); const safeSku = (state.sku || '商品').replace(/[\\/:*?"<>|]/g, '_'); link.href = URL.createObjectURL(blob); - link.download = `${safeSku}_价格变更明细.csv`; + link.download = `${safeSku}_${state.source || '价格记录'}.csv`; document.body.appendChild(link); link.click(); link.remove(); diff --git a/tests/Feature/Business/ProductCatalogPageTest.php b/tests/Feature/Business/ProductCatalogPageTest.php index ae2c8d31..4c84c1c1 100644 --- a/tests/Feature/Business/ProductCatalogPageTest.php +++ b/tests/Feature/Business/ProductCatalogPageTest.php @@ -90,6 +90,21 @@ class ProductCatalogPageTest extends FeatureTestCase ], ], 'price_change_history_count' => 2, + 'work_platform_price_history_count' => 2, + 'jushuitan_cost_price_history' => [[ + 'source' => '聚水潭商品及库存管理', + 'record_type_label' => '成本价变更', + 'cost_price' => 500, + 'before_price' => 480, + 'after_price' => 500, + 'diff_amount' => 20, + 'effect_start_time' => '2026-08-12', + 'effect_end_time' => null, + 'remark' => '采购价:480->500', + 'operator' => '简尚', + 'operation' => '商品与库存管理导入', + ]], + 'jushuitan_cost_price_history_count' => 1, 'purchase_order_unit_price' => 65, 'purchase_order_finished_at' => '2026-08-20 11:03:05', 'purchase_order_id' => 827380, @@ -160,20 +175,29 @@ class ProductCatalogPageTest extends FeatureTestCase ->assertSeeText('475.00') ->assertSeeText('2026-08-11') ->assertSeeText('已定价') - ->assertSeeText('变价前价格') + ->assertSeeText('工作平台变价前价格') ->assertSeeText('490.00') - ->assertSeeText('变价后价格') - ->assertSeeText('差额') + ->assertSeeText('工作平台变价后价格') + ->assertSeeText('工作平台差额') ->assertSeeText('-15.00') - ->assertSeeText('变价记录') + ->assertSeeText('聚水潭成本价记录') + ->assertSeeText('工作平台价格记录') + ->assertSeeText('变价前价格') + ->assertSeeText('变价后价格') ->assertSeeText('2 条') + ->assertSeeText('1 条') ->assertSee('data-price-history-trigger', false) - ->assertSeeText('价格变更明细') + ->assertSeeText('价格记录') ->assertSeeText('当前成本价') ->assertSeeText('当前工作平台价格') - ->assertSeeText('导出变价记录') + ->assertSeeText('导出价格记录') ->assertSeeText('生效结束日期') ->assertSeeText('记录时间') + ->assertSeeText('操作方式') + ->assertSeeText('操作人') + ->assertSee('"remark":"采购价:480-\\u003E500"', false) + ->assertSee('简尚', false) + ->assertSee('商品与库存管理导入', false) ->assertSee('"record_type_label":"变价"', false) ->assertSee('"record_type_label":"定价"', false) ->assertSeeText('数据来源') diff --git a/tests/Unit/EcommerceFinance/JushuitanCostPriceHistoryMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/JushuitanCostPriceHistoryMirrorRepositoryTest.php new file mode 100644 index 00000000..6e4c11d9 --- /dev/null +++ b/tests/Unit/EcommerceFinance/JushuitanCostPriceHistoryMirrorRepositoryTest.php @@ -0,0 +1,198 @@ +set('database.connections.business_mirror', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + 'foreign_key_constraints' => true, + ]); + config()->set('upstream_mirrors.connection', 'business_mirror'); + DB::purge('business_mirror'); + $this->createTables(); + $this->repository = app(JushuitanCostPriceHistoryMirrorRepository::class); + } + + public function test_it_stores_and_formats_multiple_cost_price_operations_for_the_same_sku(): void + { + $generation = '11111111-1111-4111-8111-111111111111'; + $this->repository->startRun( + 'full', + $generation, + Carbon::parse('2026-06-01'), + Carbon::parse('2026-07-01'), + false, + ); + $this->repository->storePage([ + [ + 'operation_id' => '1', + 'sku_id' => '15961g', + 'operation_type' => '商品与库存管理导入', + 'operation_description' => '采购价:72.2222->65', + 'operator_name' => '简尚', + 'operated_at' => '2026-06-06 14:32:02.993', + 'before_price' => 72.2222, + 'after_price' => 65, + 'diff_amount' => -7.2222, + ], + [ + 'operation_id' => '2', + 'sku_id' => '15961G', + 'operation_type' => '编辑', + 'operation_description' => '采购价:65->63', + 'operator_name' => '测试人', + 'operated_at' => '2026-06-07 08:00:00', + 'before_price' => 65, + 'after_price' => 63, + 'diff_amount' => -2, + ], + ], Carbon::parse('2026-07-01'), 1, $generation, 2, 2); + $this->repository->complete('full', $generation, Carbon::parse('2026-07-01'), 2, 2); + + $records = $this->repository->historyForSkus(['15961G'])->get('15961G'); + $history = $this->repository->formatHistory($records); + + $this->assertCount(2, $history); + $this->assertSame(['2', '1'], array_column($history, 'id')); + $this->assertSame('聚水潭商品及库存管理', $history[0]['source']); + $this->assertSame('测试人', $history[0]['operator']); + $this->assertSame('编辑', $history[0]['operation']); + $this->assertSame('采购价:65->63', $history[0]['remark']); + $this->assertSame('63', (string) $history[0]['after_price']); + $this->assertTrue($this->repository->syncStatus()['initialized']); + } + + public function test_incremental_empty_page_and_failed_run_keep_previous_history(): void + { + DB::connection('business_mirror')->table(JushuitanCostPriceHistoryMirrorRepository::TABLE)->insert([ + 'operation_id' => 'existing', + 'sku_id' => 'SKU-KEEP', + 'sku_id_normalized' => 'SKU-KEEP', + 'operation_description' => '采购价:20->18', + 'operated_at' => '2026-06-01 00:00:00', + 'before_price' => 20, + 'after_price' => 18, + 'diff_amount' => -2, + '_mirror_generation' => 'old-generation', + 'row_hash' => hash('sha256', 'existing'), + '_mirror_synced_at' => '2026-06-01 00:00:00', + ]); + DB::connection('business_mirror')->table(JushuitanCostPriceHistoryMirrorRepository::STATE_TABLE)->insert([ + 'dataset_key' => JushuitanCostPriceHistoryMirrorRepository::DATASET_KEY, + 'status' => 'succeeded', + 'run_mode' => 'incremental', + 'generation' => '22222222-2222-4222-8222-222222222222', + 'window_started_at' => '2026-06-01 00:00:00', + 'window_ends_at' => '2026-06-03 00:00:00', + 'initial_completed_at' => '2026-06-01 00:00:00', + 'last_succeeded_at' => '2026-06-01 00:00:00', + ]); + + $this->repository->storePage( + [], + Carbon::parse('2026-06-03'), + 1, + '22222222-2222-4222-8222-222222222222', + 0, + 0, + ); + $this->repository->fail('upstream_unavailable'); + + $this->assertSame(1, DB::connection('business_mirror')->table(JushuitanCostPriceHistoryMirrorRepository::TABLE)->count()); + $this->assertSame('failed', $this->repository->state()['status']); + } + + public function test_empty_full_sync_keeps_existing_audit_history(): void + { + DB::connection('business_mirror')->table(JushuitanCostPriceHistoryMirrorRepository::TABLE)->insert([ + 'operation_id' => 'existing', + 'sku_id' => 'SKU-KEEP', + 'sku_id_normalized' => 'SKU-KEEP', + 'operation_description' => '采购价:20->18', + 'operated_at' => '2026-06-01 00:00:00', + 'before_price' => 20, + 'after_price' => 18, + 'diff_amount' => -2, + '_mirror_generation' => 'old-generation', + 'row_hash' => hash('sha256', 'existing'), + '_mirror_synced_at' => '2026-06-01 00:00:00', + ]); + $generation = '33333333-3333-4333-8333-333333333333'; + $this->repository->startRun( + 'full', + $generation, + Carbon::parse('2026-06-01'), + Carbon::parse('2026-07-01'), + false, + ); + + $this->repository->complete( + 'full', + $generation, + Carbon::parse('2026-07-01'), + 0, + 0, + ); + + $this->assertSame(1, DB::connection('business_mirror')->table(JushuitanCostPriceHistoryMirrorRepository::TABLE)->count()); + $this->assertSame('succeeded', $this->repository->state()['status']); + } + + private function createTables(): void + { + Schema::connection('business_mirror')->create(JushuitanCostPriceHistoryMirrorRepository::STATE_TABLE, function (Blueprint $table): void { + $table->string('dataset_key')->primary(); + $table->string('status')->default('pending'); + $table->string('run_mode')->nullable(); + $table->string('generation')->nullable(); + $table->dateTime('window_started_at')->nullable(); + $table->dateTime('window_ends_at')->nullable(); + $table->dateTime('cursor_window_started_at')->nullable(); + $table->unsignedInteger('cursor_page')->nullable(); + $table->dateTime('initial_completed_at')->nullable(); + $table->dateTime('last_full_succeeded_at')->nullable(); + $table->dateTime('last_attempted_at')->nullable(); + $table->dateTime('last_succeeded_at')->nullable(); + $table->unsignedBigInteger('last_processed_source_rows')->default(0); + $table->unsignedBigInteger('last_upserted_rows')->default(0); + $table->unsignedBigInteger('mirror_row_count')->default(0); + $table->string('last_error_code')->nullable(); + $table->timestamps(); + }); + Schema::connection('business_mirror')->create(JushuitanCostPriceHistoryMirrorRepository::TABLE, function (Blueprint $table): void { + $table->string('operation_id')->primary(); + $table->string('sku_id'); + $table->string('sku_id_normalized'); + $table->string('i_id')->nullable(); + $table->string('product_name')->nullable(); + $table->string('properties_value')->nullable(); + $table->string('sku_type')->nullable(); + $table->string('operation_type')->nullable(); + $table->text('operation_description')->nullable(); + $table->string('operator_name')->nullable(); + $table->dateTime('operated_at')->nullable(); + $table->decimal('before_price', 20, 4)->nullable(); + $table->decimal('after_price', 20, 4)->nullable(); + $table->decimal('diff_amount', 20, 4)->nullable(); + $table->string('_mirror_generation')->nullable(); + $table->string('row_hash'); + $table->dateTime('_mirror_synced_at'); + $table->timestamps(); + }); + } +} diff --git a/tests/Unit/EcommerceFinance/JushuitanCostPriceHistorySynchronizerTest.php b/tests/Unit/EcommerceFinance/JushuitanCostPriceHistorySynchronizerTest.php new file mode 100644 index 00000000..3503c429 --- /dev/null +++ b/tests/Unit/EcommerceFinance/JushuitanCostPriceHistorySynchronizerTest.php @@ -0,0 +1,126 @@ +createMock(JushuitanOpenApiClient::class); + $upstream->expects($this->never())->method('getCostPriceOperationHistory'); + $mirror = $this->createMock(JushuitanCostPriceHistoryMirrorRepository::class); + $mirror->method('available')->willReturn(true); + $mirror->method('state')->willReturn(null); + + $result = (new JushuitanCostPriceHistorySynchronizer($upstream, $mirror))->sync(); + + $this->assertSame('requires_full_sync', $result['status']); + } + + public function test_full_sync_uses_stable_time_windows_and_page_cursor(): void + { + Carbon::setTestNow('2026-09-14 00:00:00'); + config()->set('services.jushuitan.cost_price_history_start', '2026-08-01 00:00:00'); + config()->set('services.jushuitan.cost_price_history_window_days', 30); + $responses = [[ + 'records' => [['operation_id' => '10', 'sku_id' => 'SKU-10']], + 'source_rows' => 2, + 'current_page' => 1, + 'total_pages' => 2, + 'total_count' => 3, + ], [ + 'records' => [['operation_id' => '20', 'sku_id' => 'SKU-20']], + 'source_rows' => 1, + 'current_page' => 2, + 'total_pages' => 2, + 'total_count' => 3, + ], [ + 'records' => [], + 'source_rows' => 1, + 'current_page' => 1, + 'total_pages' => 1, + 'total_count' => 1, + ]]; + $apiCalls = []; + $upstream = $this->createMock(JushuitanOpenApiClient::class); + $upstream->expects($this->exactly(3))->method('getCostPriceOperationHistory') + ->willReturnCallback(function (Carbon $start, Carbon $end, int $page, int $pageSize) use (&$responses, &$apiCalls): array { + $apiCalls[] = [$start->format('Y-m-d H:i:s'), $end->format('Y-m-d H:i:s'), $page, $pageSize]; + + return array_shift($responses); + }); + + $storedPages = []; + $mirror = $this->createMock(JushuitanCostPriceHistoryMirrorRepository::class); + $mirror->method('available')->willReturn(true); + $mirror->method('state')->willReturn(null); + $mirror->expects($this->once())->method('startRun') + ->with('full', $this->isType('string'), $this->isInstanceOf(Carbon::class), $this->isInstanceOf(Carbon::class), false) + ->willReturn([ + 'cursor_window_started_at' => '2026-08-01 00:00:00', + 'cursor_page' => 1, + 'last_processed_source_rows' => 0, + 'last_upserted_rows' => 0, + ]); + $mirror->expects($this->exactly(3))->method('storePage') + ->willReturnCallback(function (...$arguments) use (&$storedPages): void { + $storedPages[] = $arguments; + }); + $mirror->expects($this->once())->method('complete') + ->with('full', $this->isType('string'), $this->isInstanceOf(Carbon::class), 4, 2); + + $result = (new JushuitanCostPriceHistorySynchronizer($upstream, $mirror))->sync(true, 2000, 0); + + $this->assertSame([ + ['2026-08-01 00:00:00', '2026-08-31 00:00:00', 1, 2000], + ['2026-08-01 00:00:00', '2026-08-31 00:00:00', 2, 2000], + ['2026-08-31 00:00:00', '2026-09-14 00:00:01', 1, 2000], + ], $apiCalls); + $this->assertSame('2026-08-01 00:00:00', $storedPages[0][1]->format('Y-m-d H:i:s')); + $this->assertSame(2, $storedPages[0][2]); + $this->assertSame('2026-08-31 00:00:00', $storedPages[1][1]->format('Y-m-d H:i:s')); + $this->assertSame(1, $storedPages[1][2]); + $this->assertSame('succeeded', $result['status']); + $this->assertSame(4, $result['source_rows']); + $this->assertSame(2, $result['rows']); + } + + public function test_api_failure_marks_run_failed_without_completing_or_storing_a_page(): void + { + Carbon::setTestNow('2026-09-14 00:00:00'); + $upstream = $this->createMock(JushuitanOpenApiClient::class); + $upstream->expects($this->once())->method('getCostPriceOperationHistory') + ->willThrowException(new RuntimeException('upstream_unavailable')); + $mirror = $this->createMock(JushuitanCostPriceHistoryMirrorRepository::class); + $mirror->method('available')->willReturn(true); + $mirror->method('state')->willReturn([ + 'initial_completed_at' => '2026-09-13 00:00:00', + 'last_succeeded_at' => '2026-09-13 00:00:00', + ]); + $mirror->expects($this->once())->method('startRun')->willReturn([ + 'cursor_window_started_at' => '2026-09-11 00:00:00', + 'cursor_page' => 1, + 'last_processed_source_rows' => 0, + 'last_upserted_rows' => 0, + ]); + $mirror->expects($this->never())->method('storePage'); + $mirror->expects($this->never())->method('complete'); + $mirror->expects($this->once())->method('fail')->with(RuntimeException::class); + + $this->expectExceptionMessage('upstream_unavailable'); + (new JushuitanCostPriceHistorySynchronizer($upstream, $mirror))->sync(); + } +} diff --git a/tests/Unit/EcommerceFinance/JushuitanOpenApiClientTest.php b/tests/Unit/EcommerceFinance/JushuitanOpenApiClientTest.php new file mode 100644 index 00000000..801375db --- /dev/null +++ b/tests/Unit/EcommerceFinance/JushuitanOpenApiClientTest.php @@ -0,0 +1,150 @@ +set('services.jushuitan', [ + 'base_url' => 'https://jushuitan.example.test', + 'app_key' => 'test-app-key', + 'app_secret' => 'test-app-secret', + 'access_token' => 'test-access-token', + 'timeout' => 20, + 'product_operation_history_path' => '/open/webapi/itemapi/itemsku/queryitemskuopshistory', + ]); + Http::preventStrayRequests(); + } + + protected function tearDown(): void + { + Carbon::setTestNow(); + parent::tearDown(); + } + + public function test_it_posts_signed_form_and_keeps_only_cost_price_operations(): void + { + Http::fake([ + 'https://jushuitan.example.test/*' => Http::response([ + 'code' => 0, + 'data' => [ + 'page' => ['current_page' => 1, 'page_size' => 2000, 'count' => 2, 'pages' => 1], + 'list' => [[ + 'autoid' => 101, + 'operation_type' => '商品与库存管理导入', + 'operation_description' => '采购价:72.2222->65', + 'created' => '2026-06-06 14:32:02.993', + 'creator_name' => '简尚', + 'sku_id' => '15961G', + 'i_id' => 'ZWJ0111', + 'name' => '测试商品', + 'properties_value' => '胡桃色', + 'sku_type' => 'normal', + ], [ + 'autoid' => 102, + 'operation_type' => '编辑', + 'operation_description' => '商品名称:旧名称->新名称', + 'created' => '2026-06-06 14:33:00', + 'creator_name' => '测试人', + 'sku_id' => '15961G', + ]], + ], + ]), + ]); + + $result = (new JushuitanOpenApiClient())->getCostPriceOperationHistory( + Carbon::parse('2026-06-01 00:00:00'), + Carbon::parse('2026-06-30 23:59:59'), + 1, + 2000, + ); + + $this->assertSame(2, $result['source_rows']); + $this->assertCount(1, $result['records']); + $this->assertSame('101', $result['records'][0]['operation_id']); + $this->assertSame(72.2222, $result['records'][0]['before_price']); + $this->assertSame(65.0, $result['records'][0]['after_price']); + $this->assertEqualsWithDelta(-7.2222, $result['records'][0]['diff_amount'], 0.00001); + $this->assertSame('简尚', $result['records'][0]['operator_name']); + + Http::assertSent(function (Request $request): bool { + $data = $request->data(); + $sign = $data['sign'] ?? null; + unset($data['sign']); + ksort($data, SORT_STRING); + $plain = 'test-app-secret'; + foreach ($data as $key => $value) { + $plain .= $key . (string) $value; + } + $biz = json_decode((string) ($data['biz'] ?? ''), true); + + return $request->method() === 'POST' + && $request->url() === 'https://jushuitan.example.test/open/webapi/itemapi/itemsku/queryitemskuopshistory' + && str_starts_with((string) $request->header('Content-Type')[0], 'application/x-www-form-urlencoded') + && $sign === md5($plain) + && $biz === [ + 'current_page' => 1, + 'page_size' => 2000, + 'start_time' => '2026-06-01 00:00:00', + 'end_time' => '2026-06-30 23:59:59', + ]; + }); + } + + public function test_it_preserves_unparsed_cost_operation_content_without_inventing_prices(): void + { + Http::fake([ + '*' => Http::response([ + 'code' => 0, + 'data' => [ + 'page' => ['current_page' => 1, 'count' => 1, 'pages' => 1], + 'list' => [[ + 'autoid' => 103, + 'operation_description' => '采购价已由批量任务更新', + 'created' => '2026-06-07 08:00:00', + 'sku_id' => 'SKU-UNPARSED', + ]], + ], + ]), + ]); + + $result = (new JushuitanOpenApiClient())->getCostPriceOperationHistory( + Carbon::parse('2026-06-01'), + Carbon::parse('2026-06-30'), + 1, + 100, + ); + + $this->assertCount(1, $result['records']); + $this->assertNull($result['records'][0]['before_price']); + $this->assertNull($result['records'][0]['after_price']); + $this->assertSame('采购价已由批量任务更新', $result['records'][0]['operation_description']); + } + + public function test_it_rejects_missing_credentials_and_upstream_error_codes(): void + { + config()->set('services.jushuitan.access_token', null); + try { + (new JushuitanOpenApiClient())->getCostPriceOperationHistory(now(), now(), 1, 100); + $this->fail('Expected missing credential exception.'); + } catch (RuntimeException $exception) { + $this->assertSame('jushuitan_api_credentials_missing', $exception->getMessage()); + } + + config()->set('services.jushuitan.access_token', 'test-access-token'); + Http::fake(['*' => Http::response(['code' => 10001, 'msg' => 'denied'])]); + + $this->expectExceptionMessage('jushuitan_api_code_10001'); + (new JushuitanOpenApiClient())->getCostPriceOperationHistory(now(), now(), 1, 100); + } +} diff --git a/tests/Unit/EcommerceFinance/ProductCatalogMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/ProductCatalogMirrorRepositoryTest.php index 451b86d2..a724ac58 100644 --- a/tests/Unit/EcommerceFinance/ProductCatalogMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/ProductCatalogMirrorRepositoryTest.php @@ -7,6 +7,7 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository; +use Modules\EcommerceFinance\Repositories\JushuitanCostPriceHistoryMirrorRepository; use Modules\EcommerceFinance\Repositories\ProductCatalogMirrorRepository; use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry; use Modules\EcommerceFinance\Services\BusinessMirrorSchemaManager; @@ -263,6 +264,51 @@ class ProductCatalogMirrorRepositoryTest extends TestCase $this->assertSame('pending', $result['work_platform_price_sync_status']['code']); } + public function test_it_attaches_jushuitan_cost_history_without_replacing_work_platform_history(): void + { + $this->markReady(1); + $this->insertProduct(1, ['sku_id' => 'sku-cost']); + $this->createPriceChangeTables(true); + $this->insertPriceChange(100, [ + 'skucode' => 'sku-cost', + 'originalPrice' => 70, + 'afterPrice' => 68, + 'diffPrice' => -2, + ]); + $this->createJushuitanCostHistoryTables(); + DB::connection('business_mirror')->table(JushuitanCostPriceHistoryMirrorRepository::TABLE)->insert([ + 'operation_id' => 'cost-1', + 'sku_id' => 'SKU-COST', + 'sku_id_normalized' => 'SKU-COST', + 'operation_type' => '商品与库存管理导入', + 'operation_description' => '采购价:60->66.5', + 'operator_name' => '简尚', + 'operated_at' => '2026-08-20 14:32:02.993', + 'before_price' => 60, + 'after_price' => 66.5, + 'diff_amount' => 6.5, + 'row_hash' => hash('sha256', 'row-1'), + '_mirror_synced_at' => '2026-08-20 00:00:00', + ]); + + $repository = new ProductCatalogMirrorRepository( + new BusinessMirrorDatasetRegistry(), + new GenericBusinessMirrorRepository(app('db')), + app(JushuitanCostPriceHistoryMirrorRepository::class), + ); + $result = $repository->paginate($this->filters(), 1, 25); + $row = $result['data'][0]; + + $this->assertSame(1, $row->jushuitan_cost_price_history_count); + $this->assertSame('聚水潭商品及库存管理', $row->jushuitan_cost_price_history[0]['source']); + $this->assertSame('66.5', (string) $row->jushuitan_cost_price_history[0]['cost_price']); + $this->assertSame('简尚', $row->jushuitan_cost_price_history[0]['operator']); + $this->assertSame(1, $row->work_platform_price_history_count); + $this->assertSame('工作平台价格', $row->work_platform_price_history[0]['source']); + $this->assertSame('68', (string) $row->work_platform_price_history[0]['after_price']); + $this->assertContains(ProductCatalogMirrorRepository::JUSHUITAN_COST_HISTORY_SOURCE_LABEL, $row->data_sources); + } + public function test_it_attaches_all_price_history_and_keeps_the_latest_change_on_the_main_row(): void { $this->markReady(1); @@ -312,6 +358,7 @@ class ProductCatalogMirrorRepositoryTest extends TestCase $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(3, $row->work_platform_price_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']); @@ -620,6 +667,36 @@ class ProductCatalogMirrorRepositoryTest extends TestCase }); } + private function createJushuitanCostHistoryTables(): void + { + Schema::connection('business_mirror')->create(JushuitanCostPriceHistoryMirrorRepository::STATE_TABLE, function (Blueprint $table): void { + $table->string('dataset_key')->primary(); + $table->string('status')->nullable(); + $table->dateTime('initial_completed_at')->nullable(); + $table->dateTime('last_succeeded_at')->nullable(); + }); + Schema::connection('business_mirror')->create(JushuitanCostPriceHistoryMirrorRepository::TABLE, function (Blueprint $table): void { + $table->string('operation_id')->primary(); + $table->string('sku_id'); + $table->string('sku_id_normalized'); + $table->string('operation_type')->nullable(); + $table->text('operation_description')->nullable(); + $table->string('operator_name')->nullable(); + $table->dateTime('operated_at')->nullable(); + $table->decimal('before_price', 20, 4)->nullable(); + $table->decimal('after_price', 20, 4)->nullable(); + $table->decimal('diff_amount', 20, 4)->nullable(); + $table->string('row_hash'); + $table->dateTime('_mirror_synced_at'); + }); + DB::connection('business_mirror')->table(JushuitanCostPriceHistoryMirrorRepository::STATE_TABLE)->insert([ + 'dataset_key' => JushuitanCostPriceHistoryMirrorRepository::DATASET_KEY, + 'status' => 'succeeded', + 'initial_completed_at' => '2026-08-19 00:00:00', + 'last_succeeded_at' => '2026-08-20 00:00:00', + ]); + } + private function insertPurchasePrice(int $itemId, array $overrides = []): void { DB::connection('business_mirror')->table('product_purchase_price_mirror_rows')->insert(array_merge([
价格类型差价 生效开始日期 生效结束日期备注操作方式操作人 记录时间