修正聚水潭成本价历史凭据边界与未同步状态 (#186)

保留完整成本价历史方案,区分经典接口凭据与新开放平台凭据;首次同步未完成时显示待同步,不误报 0 条。

验证:33 passed,260 assertions。
This commit is contained in:
limeng 2026-09-15 11:39:09 +08:00
parent edb32c33cf
commit 8f150080c5
10 changed files with 152 additions and 15 deletions

View File

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

View File

@ -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,
],
];
}

View File

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

View File

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

View File

@ -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,
];
}

View File

@ -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<string,mixed> $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,

View File

@ -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 @@
</div>
@endif
@if (!($productCatalogJushuitanCostPriceHistorySyncStatus['initialized'] ?? false) && !$productCatalogError)
<div
class="product-catalog-notice"
role="status"
data-jushuitan-cost-price-history-sync-state="{{ $productCatalogJushuitanCostPriceHistorySyncStatus['code'] ?? 'unavailable' }}"
>
<span class="material-icons-outlined text-base" aria-hidden="true">schedule</span>
<span>
@if (($productCatalogJushuitanCostPriceHistorySyncStatus['code'] ?? '') === 'initializing')
聚水潭成本价记录正在执行首次同步,完成前不显示记录数量。
@elseif (($productCatalogJushuitanCostPriceHistorySyncStatus['code'] ?? '') === 'initial_failed')
聚水潭成本价记录首次同步失败,当前记录数量不代表聚水潭真实历史。
@else
聚水潭成本价记录尚未完成首次同步,完成前不显示记录数量。
@endif
</span>
</div>
@endif
<div class="product-catalog-table-wrap">
<table class="product-catalog-table" aria-label="普通商品资料">
<thead>
@ -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)
>
<span class="material-icons-outlined text-base" aria-hidden="true">history</span>
</button>
<span class="product-catalog-history-count">{{ $jushuitanHistoryCount }} 条</span>
<span class="product-catalog-history-count">{{ $jushuitanHistoryInitialized ? $jushuitanHistoryCount . ' 条' : '待同步' }}</span>
<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>

View File

@ -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();

View File

@ -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();
}
}

View File

@ -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']);