From 8f150080c582b3625e5925ea6b75d5f4a1912f66 Mon Sep 17 00:00:00 2001 From: limeng <1131177064@qq.com> Date: Tue, 15 Sep 2026 11:39:09 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E8=81=9A=E6=B0=B4=E6=BD=AD?= =?UTF-8?q?=E6=88=90=E6=9C=AC=E4=BB=B7=E5=8E=86=E5=8F=B2=E5=87=AD=E6=8D=AE?= =?UTF-8?q?=E8=BE=B9=E7=95=8C=E4=B8=8E=E6=9C=AA=E5=90=8C=E6=AD=A5=E7=8A=B6?= =?UTF-8?q?=E6=80=81=20(#186)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留完整成本价历史方案,区分经典接口凭据与新开放平台凭据;首次同步未完成时显示待同步,不误报 0 条。 验证:33 passed,260 assertions。 --- .../SyncJushuitanCostPriceHistory.php | 8 ++- app/Http/Controllers/Business/Pages.php | 10 ++++ config/services.php | 12 ++--- docs/上游业务镜像清单.md | 2 +- .../ProductCatalogMirrorRepository.php | 4 +- .../Services/JushuitanOpenApiClient.php | 15 +++++- .../normal-products.blade.php | 29 +++++++++- .../Business/ProductCatalogPageTest.php | 54 +++++++++++++++++++ .../JushuitanOpenApiClientTest.php | 31 ++++++++++- .../ProductCatalogMirrorRepositoryTest.php | 2 + 10 files changed, 152 insertions(+), 15 deletions(-) diff --git a/app/Console/Commands/SyncJushuitanCostPriceHistory.php b/app/Console/Commands/SyncJushuitanCostPriceHistory.php index 61dd2d4a..110ab52b 100644 --- a/app/Console/Commands/SyncJushuitanCostPriceHistory.php +++ b/app/Console/Commands/SyncJushuitanCostPriceHistory.php @@ -35,13 +35,17 @@ class SyncJushuitanCostPriceHistory extends Command ); } catch (Throwable $exception) { Log::error('聚水潭历史成本价镜像同步失败。', ['exception' => $exception::class]); - $this->error('聚水潭成本价历史同步失败,已保留上一版共享数据。'); + $this->error(match ($exception->getMessage()) { + 'jushuitan_classic_api_credentials_missing' => '聚水潭经典开放接口凭据未配置,请配置 partnerid、partnerkey 和 token。', + 'jushuitan_classic_api_partner_id_invalid' => '聚水潭经典开放接口 partnerid 无效,请核对同一应用的 partnerid、partnerkey 和 token。', + default => '聚水潭成本价历史同步失败,已保留上一版共享数据。', + }); return self::FAILURE; } if ($result['status'] === 'requires_full_sync') { - $this->warn('聚水潭成本价历史尚未完成首次全量同步,请在凭据和备份确认后显式使用 --full。'); + $this->warn('聚水潭成本价历史尚未完成首次全量同步,请配置经典开放接口凭据后显式使用 --full。'); return self::SUCCESS; } diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 3d5a9dca..ba531d40 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -2307,6 +2307,11 @@ class Pages extends Controller 'initialized' => false, 'last_succeeded_at' => null, ], + 'productCatalogJushuitanCostPriceHistorySyncStatus' => $result['jushuitan_cost_price_history_sync_status'] ?? [ + 'code' => 'unavailable', + 'initialized' => false, + 'last_succeeded_at' => null, + ], ]; } catch (Throwable $exception) { report($exception); @@ -2586,6 +2591,11 @@ class Pages extends Controller 'initialized' => false, 'last_succeeded_at' => null, ], + 'productCatalogJushuitanCostPriceHistorySyncStatus' => [ + 'code' => 'unavailable', + 'initialized' => false, + 'last_succeeded_at' => null, + ], ]; } diff --git a/config/services.php b/config/services.php index 33c52cf5..58b7392b 100644 --- a/config/services.php +++ b/config/services.php @@ -59,12 +59,12 @@ return [ 'jushuitan' => [ // 聚水潭开放平台凭据只从私有 runtime/.env 读取,不进入代码仓库。 - // 商品操作日志仍使用经典开放接口;兼容现有 APP_* 环境变量名, - // 实际含义依次为 partnerid、partnerkey、token。 - 'base_url' => env('JUSHUITAN_OPEN_API_URL', 'https://open.erp321.com/api/open/query.aspx'), - 'partner_id' => env('JUSHUITAN_PARTNER_ID', env('JUSHUITAN_APP_KEY')), - 'partner_key' => env('JUSHUITAN_PARTNER_KEY', env('JUSHUITAN_APP_SECRET')), - 'token' => env('JUSHUITAN_TOKEN', env('JUSHUITAN_ACCESS_TOKEN')), + // 商品操作日志使用经典开放接口。经典接口的 partnerid / partnerkey / token + // 与新开放平台的 App Key / App Secret / Access Token 不是同一套凭据,禁止混用。 + 'base_url' => env('JUSHUITAN_CLASSIC_API_URL', 'https://open.erp321.com/api/open/query.aspx'), + 'partner_id' => env('JUSHUITAN_PARTNER_ID'), + 'partner_key' => env('JUSHUITAN_PARTNER_KEY'), + 'token' => env('JUSHUITAN_TOKEN'), 'timeout' => (int) env('JUSHUITAN_OPEN_API_TIMEOUT', 20), 'product_operation_history_method' => 'worklog.query', 'cost_price_history_start' => env('JUSHUITAN_COST_PRICE_HISTORY_START', '2000-01-01 00:00:00'), diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index ed4ccf1b..8f58b741 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -29,7 +29,7 @@ | 已完成首次导入 | 提现管理的每日店铺余额读取聚水潭“平台原始账单[新] → 店铺余额表”接口 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` 稳定键追加,首次全量完成前只显示普通商品自身采购价。 | -| 本次接入 | 普通商品成本价变更读取聚水潭经典开放平台“商品操作日志查询”接口 `worklog.query`,只镜像操作内容含“采购价”的记录到 `jushuitan_cost_price_history_rows`;审计历史按稳定记录键只追加或更新,全量回补不删除旧记录;页面按商品编码分别展示聚水潭成本价记录和工作平台价格记录,不混用来源。 | +| 待有效凭据后首次同步 | 普通商品当前成本价、商品类型和更新时间继续读取 `operations_replica.rpt.jst_sku`;`rpt.fys_skuCostPriceUpdate` 是一 SKU 一行的旧快照,不能充当变价历史。完整成本价变更仍读取聚水潭经典开放平台“商品操作日志查询”接口 `worklog.query`,只镜像操作内容含“采购价”的记录到 `jushuitan_cost_price_history_rows`;经典接口必须在服务器私有配置中使用同一应用的 `JUSHUITAN_PARTNER_ID / JUSHUITAN_PARTNER_KEY / JUSHUITAN_TOKEN`,不得把新开放平台的 App Key / App Secret / Access Token 混用。页面在首次同步完成前明确显示待同步或失败,不把 0 条解释为真实无历史。 | ## 经营分析副本库(operations_replica) diff --git a/modules/EcommerceFinance/Repositories/ProductCatalogMirrorRepository.php b/modules/EcommerceFinance/Repositories/ProductCatalogMirrorRepository.php index 2d3e147d..0f49c1aa 100644 --- a/modules/EcommerceFinance/Repositories/ProductCatalogMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/ProductCatalogMirrorRepository.php @@ -112,7 +112,8 @@ class ProductCatalogMirrorRepository * total:int, * purchase_price_sync_status:array{code:string,initialized:bool,last_succeeded_at:?string}, * price_change_sync_status:array{code:string,initialized:bool,last_succeeded_at:?string}, - * work_platform_price_sync_status:array{code:string,initialized:bool,last_succeeded_at:?string} + * work_platform_price_sync_status:array{code:string,initialized:bool,last_succeeded_at:?string}, + * jushuitan_cost_price_history_sync_status:array{code:string,initialized:bool,last_succeeded_at:?string} * } */ public function paginate(array $filters, int $page, int $perPage): array @@ -147,6 +148,7 @@ class ProductCatalogMirrorRepository 'purchase_price_sync_status' => $purchasePriceSyncStatus, 'price_change_sync_status' => $priceChangeSyncStatus, 'work_platform_price_sync_status' => $workPlatformPriceSyncStatus, + 'jushuitan_cost_price_history_sync_status' => $jushuitanCostPriceHistorySyncStatus, ]; } diff --git a/modules/EcommerceFinance/Services/JushuitanOpenApiClient.php b/modules/EcommerceFinance/Services/JushuitanOpenApiClient.php index 4f68fec3..c5b37720 100644 --- a/modules/EcommerceFinance/Services/JushuitanOpenApiClient.php +++ b/modules/EcommerceFinance/Services/JushuitanOpenApiClient.php @@ -41,7 +41,7 @@ class JushuitanOpenApiClient $partnerKey = trim((string) config('services.jushuitan.partner_key')); $token = trim((string) config('services.jushuitan.token')); if ($partnerId === '' || $partnerKey === '' || $token === '') { - throw new RuntimeException('jushuitan_api_credentials_missing'); + throw new RuntimeException('jushuitan_classic_api_credentials_missing'); } $method = (string) config( @@ -81,12 +81,23 @@ class JushuitanOpenApiClient } $code = (int) ($payload['code'] ?? -1); if ($code !== 0) { - throw new RuntimeException('jushuitan_api_code_' . $code); + throw new RuntimeException($this->errorCode($code, $payload)); } return $payload; } + /** @param array $payload */ + private function errorCode(int $code, array $payload): string + { + $message = mb_strtolower(trim((string) ($payload['msg'] ?? $payload['message'] ?? ''))); + if ($code === 100 && str_contains($message, 'partnerid')) { + return 'jushuitan_classic_api_partner_id_invalid'; + } + + return 'jushuitan_api_code_' . $code; + } + private function sign( string $method, string $partnerId, 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..5686b786 100644 --- a/resources/views/business/pages/product-inventory/normal-products.blade.php +++ b/resources/views/business/pages/product-inventory/normal-products.blade.php @@ -19,6 +19,11 @@ 'initialized' => false, 'last_succeeded_at' => null, ]; + $productCatalogJushuitanCostPriceHistorySyncStatus = $content['productCatalogJushuitanCostPriceHistorySyncStatus'] ?? [ + 'code' => 'unavailable', + 'initialized' => false, + 'last_succeeded_at' => null, + ]; $hasFilters = collect($productCatalogFilters)->contains( static fn ($value) => $value !== null && $value !== '' ); @@ -773,6 +778,25 @@ @endif + @if (!($productCatalogJushuitanCostPriceHistorySyncStatus['initialized'] ?? false) && !$productCatalogError) +
+ + + @if (($productCatalogJushuitanCostPriceHistorySyncStatus['code'] ?? '') === 'initializing') + 聚水潭成本价记录正在执行首次同步,完成前不显示记录数量。 + @elseif (($productCatalogJushuitanCostPriceHistorySyncStatus['code'] ?? '') === 'initial_failed') + 聚水潭成本价记录首次同步失败,当前记录数量不代表聚水潭真实历史。 + @else + 聚水潭成本价记录尚未完成首次同步,完成前不显示记录数量。 + @endif + +
+ @endif +
@@ -854,6 +878,7 @@ @php $jushuitanHistory = $row->jushuitan_cost_price_history ?? []; $jushuitanHistoryCount = (int) ($row->jushuitan_cost_price_history_count ?? count($jushuitanHistory)); + $jushuitanHistoryInitialized = (bool) ($productCatalogJushuitanCostPriceHistorySyncStatus['initialized'] ?? false); $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)); @@ -872,12 +897,12 @@ data-cost-price="{{ $row->cost_price ?? '' }}" data-work-platform-price="{{ $row->work_platform_price ?? '' }}" aria-label="查看 {{ $row->sku_id ?? '当前商品' }} 的聚水潭成本价记录" - title="{{ $jushuitanHistoryCount > 0 ? '查看聚水潭成本价记录' : '当前商品暂无聚水潭成本价记录' }}" + title="{{ !$jushuitanHistoryInitialized ? '聚水潭成本价记录尚未完成首次同步' : ($jushuitanHistoryCount > 0 ? '查看聚水潭成本价记录' : '当前商品暂无聚水潭成本价记录') }}" @disabled($jushuitanHistoryCount === 0) > - {{ $jushuitanHistoryCount }} 条 + {{ $jushuitanHistoryInitialized ? $jushuitanHistoryCount . ' 条' : '待同步' }} diff --git a/tests/Feature/Business/ProductCatalogPageTest.php b/tests/Feature/Business/ProductCatalogPageTest.php index 4c84c1c1..abbdbdff 100644 --- a/tests/Feature/Business/ProductCatalogPageTest.php +++ b/tests/Feature/Business/ProductCatalogPageTest.php @@ -142,6 +142,11 @@ class ProductCatalogPageTest extends FeatureTestCase 'initialized' => true, 'last_succeeded_at' => '2026-09-01 03:02:00', ], + 'jushuitan_cost_price_history_sync_status' => [ + 'code' => 'ready', + 'initialized' => true, + 'last_succeeded_at' => '2026-09-15 00:05:00', + ], ]); $this->app->instance(ProductCatalogMirrorRepository::class, $repository); @@ -221,6 +226,55 @@ class ProductCatalogPageTest extends FeatureTestCase ->assertDontSee('pic_url'); } + public function test_normal_products_page_does_not_report_zero_jushuitan_history_before_initial_sync(): void + { + $this->grantAdminPermission(); + + $state = [ + 'status' => 'succeeded', + 'initial_completed_at' => '2026-08-14 21:15:36', + 'last_succeeded_at' => '2026-09-15 10:00:00', + 'mirror_row_count' => 558035, + ]; + $repository = \Mockery::mock(ProductCatalogMirrorRepository::class); + $repository->shouldReceive('available')->once()->andReturnTrue(); + $repository->shouldReceive('state')->once()->andReturn($state); + $repository->shouldReceive('syncStatus')->once()->with($state)->andReturn([ + 'code' => 'healthy', + 'lag_minutes' => 2, + ]); + $repository->shouldReceive('ready')->once()->andReturnTrue(); + $repository->shouldReceive('paginate')->once()->andReturn([ + 'data' => [(object) [ + 'autoid' => 15961, + 'sku_id' => '15961G', + 'name' => '测试商品', + 'cost_price' => 65, + 'jushuitan_cost_price_history' => [], + 'jushuitan_cost_price_history_count' => 0, + ]], + 'total' => 1, + 'jushuitan_cost_price_history_sync_status' => [ + 'code' => 'initial_failed', + 'initialized' => false, + 'last_succeeded_at' => null, + ], + ]); + $this->app->instance(ProductCatalogMirrorRepository::class, $repository); + + $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'product-inventory', + 'page' => 'normal-products', + 'sku_id' => '15961G', + ])) + ->assertOk() + ->assertSee('data-jushuitan-cost-price-history-sync-state="initial_failed"', false) + ->assertSeeText('聚水潭成本价记录首次同步失败') + ->assertSeeText('当前记录数量不代表聚水潭真实历史') + ->assertSeeText('待同步') + ->assertSee('title="聚水潭成本价记录尚未完成首次同步"', false); + } + public function test_normal_products_export_uses_all_filters_and_includes_data_source_column(): void { $this->grantAdminPermission(); diff --git a/tests/Unit/EcommerceFinance/JushuitanOpenApiClientTest.php b/tests/Unit/EcommerceFinance/JushuitanOpenApiClientTest.php index beb93128..a4a5e9ff 100644 --- a/tests/Unit/EcommerceFinance/JushuitanOpenApiClientTest.php +++ b/tests/Unit/EcommerceFinance/JushuitanOpenApiClientTest.php @@ -136,7 +136,7 @@ class JushuitanOpenApiClientTest extends TestCase (new JushuitanOpenApiClient())->getCostPriceOperationHistory(now(), now(), 1, 100); $this->fail('Expected missing credential exception.'); } catch (RuntimeException $exception) { - $this->assertSame('jushuitan_api_credentials_missing', $exception->getMessage()); + $this->assertSame('jushuitan_classic_api_credentials_missing', $exception->getMessage()); } config()->set('services.jushuitan.token', 'test-token'); @@ -145,4 +145,33 @@ class JushuitanOpenApiClientTest extends TestCase $this->expectExceptionMessage('jushuitan_api_code_10001'); (new JushuitanOpenApiClient())->getCostPriceOperationHistory(now(), now(), 1, 100); } + + public function test_it_reports_an_invalid_classic_partner_id_without_exposing_upstream_text(): void + { + Http::fake(['*' => Http::response([ + 'code' => 100, + 'msg' => '验证失败! partnerid 无效', + ])]); + + $this->expectExceptionMessage('jushuitan_classic_api_partner_id_invalid'); + (new JushuitanOpenApiClient())->getCostPriceOperationHistory(now(), now(), 1, 50); + } + + public function test_new_platform_app_credentials_do_not_replace_classic_credentials(): void + { + config()->set('services.jushuitan.partner_id', null); + config()->set('services.jushuitan.partner_key', null); + config()->set('services.jushuitan.token', null); + config()->set('services.jushuitan.app_key', 'new-platform-app-key'); + config()->set('services.jushuitan.app_secret', 'new-platform-app-secret'); + config()->set('services.jushuitan.access_token', 'new-platform-access-token'); + + try { + (new JushuitanOpenApiClient())->getCostPriceOperationHistory(now(), now(), 1, 50); + $this->fail('Expected missing classic credential exception.'); + } catch (RuntimeException $exception) { + $this->assertSame('jushuitan_classic_api_credentials_missing', $exception->getMessage()); + } + Http::assertNothingSent(); + } } diff --git a/tests/Unit/EcommerceFinance/ProductCatalogMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/ProductCatalogMirrorRepositoryTest.php index a724ac58..6184348a 100644 --- a/tests/Unit/EcommerceFinance/ProductCatalogMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/ProductCatalogMirrorRepositoryTest.php @@ -303,6 +303,8 @@ class ProductCatalogMirrorRepositoryTest extends TestCase $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('ready', $result['jushuitan_cost_price_history_sync_status']['code']); + $this->assertTrue($result['jushuitan_cost_price_history_sync_status']['initialized']); $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']);