117 lines
4.6 KiB
PHP
117 lines
4.6 KiB
PHP
<?php
|
||
|
||
namespace Modules\EcommerceFinance\Services;
|
||
|
||
use Illuminate\Support\Carbon;
|
||
use Modules\EcommerceFinance\Repositories\ProductPriceMirrorRepository;
|
||
use Modules\EcommerceFinance\Repositories\ProductPriceRepository;
|
||
use RuntimeException;
|
||
use Throwable;
|
||
|
||
class ProductPriceMirrorSynchronizer
|
||
{
|
||
public function __construct(
|
||
private readonly ProductPriceRepository $upstream,
|
||
private readonly ProductPriceMirrorRepository $mirror,
|
||
) {
|
||
}
|
||
|
||
/** @return array{status:string,mode:string,batches:int,rows:int} */
|
||
public function sync(bool $full = false, ?int $batchSize = null, ?int $maxBatches = null): array
|
||
{
|
||
if (! $this->mirror->ready()) {
|
||
throw new RuntimeException('business_mirror_schema_missing');
|
||
}
|
||
|
||
$state = $this->mirror->state();
|
||
|
||
if (! $full && ($state['initial_completed_at'] ?? null) === null) {
|
||
return ['status' => 'requires_full_sync', 'mode' => 'incremental', 'batches' => 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;
|
||
|
||
if ($resume) {
|
||
$since = Carbon::parse((string) $state['window_started_at']);
|
||
$until = Carbon::parse((string) $state['window_ends_at']);
|
||
} elseif ($full) {
|
||
$since = Carbon::create(1900, 1, 1, 0, 0, 0, config('app.timezone'));
|
||
$until = now()->addSecond();
|
||
} else {
|
||
$overlapHours = max((int) config('upstream_mirrors.product_prices.overlap_hours', 48), 1);
|
||
$since = Carbon::parse((string) $state['last_succeeded_at'])->subHours($overlapHours);
|
||
$until = now()->addSecond();
|
||
}
|
||
|
||
$state = $this->mirror->startRun($mode, $since, $until, $resume);
|
||
$cursor = $this->cursorFromState($state);
|
||
$batchSize ??= (int) config('upstream_mirrors.product_prices.batch_size', 500);
|
||
$batchSize = min(max($batchSize, 1), 2000);
|
||
$maxBatches ??= (int) config('upstream_mirrors.product_prices.max_batches_per_run', 4);
|
||
$maxBatches = max($maxBatches, 0);
|
||
$batches = 0;
|
||
$rows = (int) ($state['last_upserted_rows'] ?? 0);
|
||
|
||
try {
|
||
while (true) {
|
||
$batch = $this->upstream->mirrorBatch(
|
||
$cursor,
|
||
$since->format('Y-m-d H:i:s'),
|
||
$until->format('Y-m-d H:i:s'),
|
||
$batchSize,
|
||
);
|
||
|
||
if ($batch === []) {
|
||
$this->mirror->complete($mode, $until, $rows);
|
||
|
||
return ['status' => 'succeeded', 'mode' => $mode, 'batches' => $batches, 'rows' => $rows];
|
||
}
|
||
|
||
$last = $batch[array_key_last($batch)];
|
||
$cursor = [
|
||
'completed_at' => (string) ($last['cursor_completed_at'] ?? $last['completed_at']),
|
||
'business_id' => (string) $last['business_id'],
|
||
'barcode' => (string) $last['barcode'],
|
||
];
|
||
$rows += count($batch);
|
||
$batches++;
|
||
$this->mirror->storeBatch($batch, $cursor, $rows);
|
||
|
||
if (count($batch) < $batchSize) {
|
||
$this->mirror->complete($mode, $until, $rows);
|
||
|
||
return ['status' => 'succeeded', 'mode' => $mode, 'batches' => $batches, 'rows' => $rows];
|
||
}
|
||
|
||
if ($maxBatches > 0 && $batches >= $maxBatches) {
|
||
return ['status' => 'running', 'mode' => $mode, 'batches' => $batches, 'rows' => $rows];
|
||
}
|
||
}
|
||
} catch (Throwable $exception) {
|
||
$this->mirror->fail($exception::class);
|
||
throw $exception;
|
||
}
|
||
}
|
||
|
||
/** @return array{completed_at:string,business_id:string,barcode:string}|null */
|
||
private function cursorFromState(array $state): ?array
|
||
{
|
||
if (($state['cursor_completed_at'] ?? null) === null) {
|
||
return null;
|
||
}
|
||
|
||
return [
|
||
// 直接复用 SQL Server 返回的 datetime2(7) ISO 字符串;Carbon 只有
|
||
// 6 位微秒精度,解析再格式化会丢掉最后一位并破坏稳定游标。
|
||
'completed_at' => (string) $state['cursor_completed_at'],
|
||
'business_id' => (string) ($state['cursor_business_id'] ?? ''),
|
||
'barcode' => (string) ($state['cursor_product_code'] ?? ''),
|
||
];
|
||
}
|
||
}
|