feat: add jushuitan cost price history
This commit is contained in:
parent
0b00f01566
commit
41d80e4056
73
app/Console/Commands/SyncJushuitanCostPriceHistory.php
Normal file
73
app/Console/Commands/SyncJushuitanCostPriceHistory.php
Normal file
@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\EcommerceFinance\Repositories\JushuitanCostPriceHistoryMirrorRepository;
|
||||
use Modules\EcommerceFinance\Services\JushuitanCostPriceHistorySynchronizer;
|
||||
use Throwable;
|
||||
|
||||
class SyncJushuitanCostPriceHistory extends Command
|
||||
{
|
||||
protected $signature = 'business-mirror:sync-jushuitan-cost-price-history
|
||||
{--full : 强制启动或继续首次全量同步}
|
||||
{--page-size= : 聚水潭接口每页读取条数,最大 2000}
|
||||
{--max-pages= : 本轮最多读取页数,0 表示直到完成}';
|
||||
|
||||
protected $description = '按时间窗和页码同步聚水潭商品操作日志中的采购价变更到共享业务镜像';
|
||||
|
||||
public function handle(
|
||||
JushuitanCostPriceHistoryMirrorRepository $mirror,
|
||||
JushuitanCostPriceHistorySynchronizer $synchronizer,
|
||||
): int {
|
||||
if (! (bool) config('upstream_mirrors.refresh_enabled', false) && ! $this->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<string,mixed>|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);
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
private function connection(): string
|
||||
{
|
||||
return (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
}
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
if (app()->environment('testing')
|
||||
|| (string) config('system_database.mode', 'bootstrap') === 'bootstrap') {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema = Schema::connection($this->connection());
|
||||
|
||||
if (! $schema->hasTable('jushuitan_cost_price_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');
|
||||
}
|
||||
};
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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` 的工作平台价格记录混为同一来源。
|
||||
|
||||
工作平台采购价格首次全量需在备份和负责人授权后单独执行:
|
||||
|
||||
|
||||
@ -0,0 +1,328 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Repositories;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\DatabaseManager;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class JushuitanCostPriceHistoryMirrorRepository
|
||||
{
|
||||
public const DATASET_KEY = 'jushuitan.product-cost-price-operation-history';
|
||||
|
||||
public const TABLE = 'jushuitan_cost_price_history_rows';
|
||||
|
||||
public const STATE_TABLE = 'jushuitan_cost_price_history_sync_states';
|
||||
|
||||
private readonly ConnectionInterface $connection;
|
||||
|
||||
public function __construct(DatabaseManager $database)
|
||||
{
|
||||
$this->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<string,mixed>|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<string,mixed> */
|
||||
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<array<string,mixed>> $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<string> $normalizedSkus @return Collection<string,Collection<int,object>> */
|
||||
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<array<string,mixed>> */
|
||||
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<string,mixed> $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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<object> $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<string,mixed> $filters */
|
||||
private function filteredQuery(array $filters): Builder
|
||||
{
|
||||
|
||||
@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\EcommerceFinance\Repositories\JushuitanCostPriceHistoryMirrorRepository;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class JushuitanCostPriceHistorySynchronizer
|
||||
{
|
||||
public function __construct(
|
||||
private readonly JushuitanOpenApiClient $upstream,
|
||||
private readonly JushuitanCostPriceHistoryMirrorRepository $mirror,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array{status:string,mode:string,pages:int,source_rows:int,rows:int} */
|
||||
public function sync(bool $full = false, ?int $pageSize = null, ?int $maxPages = null): array
|
||||
{
|
||||
if (! $this->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);
|
||||
}
|
||||
}
|
||||
169
modules/EcommerceFinance/Services/JushuitanOpenApiClient.php
Normal file
169
modules/EcommerceFinance/Services/JushuitanOpenApiClient.php
Normal file
@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
class JushuitanOpenApiClient
|
||||
{
|
||||
/**
|
||||
* @return array{records:list<array<string,mixed>>,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<string,mixed> $biz @return array<string,mixed> */
|
||||
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<string,mixed> $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<array<string,mixed>> */
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -743,11 +743,11 @@
|
||||
<span class="material-icons-outlined text-base" aria-hidden="true">schedule</span>
|
||||
<span>
|
||||
@if (($productCatalogPriceChangeSyncStatus['code'] ?? '') === 'initializing')
|
||||
商品价格变价记录正在执行首次同步,完成前变价前价格、变价后价格、差额与变价时间显示为空。
|
||||
工作平台价格变价记录正在执行首次同步,完成前工作平台变价前价格、工作平台变价后价格、工作平台差额与工作平台变价时间显示为空。
|
||||
@elseif (($productCatalogPriceChangeSyncStatus['code'] ?? '') === 'initial_failed')
|
||||
商品价格变价记录首次同步失败,当前不显示变价前价格、变价后价格、差额与变价时间。
|
||||
工作平台价格变价记录首次同步失败,当前不显示工作平台变价前价格、工作平台变价后价格、工作平台差额与工作平台变价时间。
|
||||
@else
|
||||
商品价格变价记录尚未完成首次同步,当前不显示变价前价格、变价后价格、差额与变价时间。
|
||||
工作平台价格变价记录尚未完成首次同步,当前不显示工作平台变价前价格、工作平台变价后价格、工作平台差额与工作平台变价时间。
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
@ -792,11 +792,12 @@
|
||||
<th class="is-numeric">工作平台价格</th>
|
||||
<th>工作平台价格生效日期</th>
|
||||
<th>定价状态</th>
|
||||
<th class="is-numeric">变价前价格</th>
|
||||
<th class="is-numeric">变价后价格</th>
|
||||
<th class="is-numeric">差额</th>
|
||||
<th>变价时间</th>
|
||||
<th>变价记录</th>
|
||||
<th class="is-numeric">工作平台变价前价格</th>
|
||||
<th class="is-numeric">工作平台变价后价格</th>
|
||||
<th class="is-numeric">工作平台差额</th>
|
||||
<th>工作平台变价时间</th>
|
||||
<th>聚水潭成本价记录</th>
|
||||
<th>工作平台价格记录</th>
|
||||
<th>数据来源</th>
|
||||
<th class="is-numeric">市场价</th>
|
||||
<th class="is-numeric">重量(kg)</th>
|
||||
@ -851,9 +852,12 @@
|
||||
<td class="is-numeric">{{ isset($row->price_change_diff_amount) && $row->price_change_diff_amount !== null ? number_format((float) $row->price_change_diff_amount, 2) : '-' }}</td>
|
||||
<td class="is-code">{{ !empty($row->price_change_effect_time) ? \Illuminate\Support\Carbon::parse($row->price_change_effect_time)->format('Y-m-d') : '-' }}</td>
|
||||
@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
|
||||
<td>
|
||||
<div class="product-catalog-history-action">
|
||||
@ -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)
|
||||
>
|
||||
<span class="material-icons-outlined text-base" aria-hidden="true">history</span>
|
||||
</button>
|
||||
<span class="product-catalog-history-count">{{ $priceChangeHistoryCount }} 条</span>
|
||||
<script id="{{ $priceChangeHistoryId }}" type="application/json">@json($priceChangeHistory, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)</script>
|
||||
<span class="product-catalog-history-count">{{ $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>
|
||||
<td>
|
||||
<div class="product-catalog-history-action">
|
||||
<button type="button" class="product-catalog-icon-button" data-price-history-trigger data-history-target="{{ $workPlatformHistoryId }}" 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="{{ $workPlatformHistoryCount > 0 ? '查看工作平台价格记录' : '当前商品暂无工作平台价格记录' }}" @disabled($workPlatformHistoryCount === 0)>
|
||||
<span class="material-icons-outlined text-base" aria-hidden="true">history</span>
|
||||
</button>
|
||||
<span class="product-catalog-history-count">{{ $workPlatformHistoryCount }} 条</span>
|
||||
<script id="{{ $workPlatformHistoryId }}" type="application/json">@json($workPlatformHistory, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)</script>
|
||||
</div>
|
||||
</td>
|
||||
<td class="product-catalog-source-list">
|
||||
@ -905,7 +919,7 @@
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="34" class="product-catalog-empty">
|
||||
<td colspan="35" class="product-catalog-empty">
|
||||
<span class="material-icons-outlined text-3xl block mb-2" aria-hidden="true">inventory_2</span>
|
||||
@if ($productCatalogError)
|
||||
数据暂时不可用。
|
||||
@ -956,19 +970,19 @@
|
||||
>
|
||||
<header class="product-price-history-modal__header">
|
||||
<div class="product-price-history-modal__heading">
|
||||
<h2 id="product-price-history-title" class="product-price-history-modal__title">价格变更明细</h2>
|
||||
<h2 id="product-price-history-title" class="product-price-history-modal__title" data-price-history-title>价格记录</h2>
|
||||
<p class="product-price-history-modal__subtitle" data-price-history-subtitle></p>
|
||||
</div>
|
||||
<div class="product-price-history-modal__actions">
|
||||
<button type="button" class="product-catalog-button" data-price-history-export>
|
||||
<span class="material-icons-outlined text-base" aria-hidden="true">download</span>
|
||||
<span>导出变价记录</span>
|
||||
<span>导出价格记录</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="product-catalog-icon-button"
|
||||
data-price-history-close
|
||||
aria-label="关闭价格变更明细"
|
||||
aria-label="关闭价格记录"
|
||||
title="关闭"
|
||||
>
|
||||
<span class="material-icons-outlined text-base" aria-hidden="true">close</span>
|
||||
@ -990,7 +1004,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="product-price-history-modal__table-wrap">
|
||||
<table class="product-price-history-table" aria-label="当前商品价格变更明细">
|
||||
<table class="product-price-history-table" aria-label="当前商品价格记录">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>价格类型</th>
|
||||
@ -999,6 +1013,9 @@
|
||||
<th class="is-numeric">差价</th>
|
||||
<th>生效开始日期</th>
|
||||
<th>生效结束日期</th>
|
||||
<th>备注</th>
|
||||
<th>操作方式</th>
|
||||
<th>操作人</th>
|
||||
<th>记录时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -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();
|
||||
|
||||
@ -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('数据来源')
|
||||
|
||||
@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Repositories\JushuitanCostPriceHistoryMirrorRepository;
|
||||
use Tests\TestCase;
|
||||
|
||||
class JushuitanCostPriceHistoryMirrorRepositoryTest extends TestCase
|
||||
{
|
||||
private JushuitanCostPriceHistoryMirrorRepository $repository;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
config()->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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Modules\EcommerceFinance\Repositories\JushuitanCostPriceHistoryMirrorRepository;
|
||||
use Modules\EcommerceFinance\Services\JushuitanCostPriceHistorySynchronizer;
|
||||
use Modules\EcommerceFinance\Services\JushuitanOpenApiClient;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class JushuitanCostPriceHistorySynchronizerTest extends TestCase
|
||||
{
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Carbon::setTestNow();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_incremental_sync_waits_for_completed_initial_sync(): void
|
||||
{
|
||||
$upstream = $this->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();
|
||||
}
|
||||
}
|
||||
150
tests/Unit/EcommerceFinance/JushuitanOpenApiClientTest.php
Normal file
150
tests/Unit/EcommerceFinance/JushuitanOpenApiClientTest.php
Normal file
@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Modules\EcommerceFinance\Services\JushuitanOpenApiClient;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class JushuitanOpenApiClientTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Carbon::setTestNow('2026-09-14 06:00:00');
|
||||
config()->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);
|
||||
}
|
||||
}
|
||||
@ -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([
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user