修正聚水潭成本价历史凭据边界与未同步状态 (#186)
保留完整成本价历史方案,区分经典接口凭据与新开放平台凭据;首次同步未完成时显示待同步,不误报 0 条。 验证:33 passed,260 assertions。
This commit is contained in:
parent
edb32c33cf
commit
8f150080c5
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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']);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user