CaiWuXiTong/app/Business/JstOrderLabelDailySynchronizer.php

323 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Business;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Carbon;
use InvalidArgumentException;
use RuntimeException;
use Throwable;
/**
* 聚水潭订单标签明细的每日同步。
*
* 先核对历史日期是否逐日覆盖,再补齐缺口,最后替换刷新最近数日以吸收上游迟到修改。
*/
class JstOrderLabelDailySynchronizer
{
public const SOURCE_DATASET = 'operations_replica.rpt.jst_Orders';
public function __construct(
private readonly DatabaseManager $databases,
private readonly JstOrderLabelImporter $importer,
) {}
/**
* @return array<string,mixed>
*/
public function sync(
string $from,
string $to,
int $companyId,
int $refreshDays = 7,
bool $dryRun = false,
): array {
[$fromDate, $toDate] = $this->dateRange($from, $to);
if ($companyId <= 0) {
throw new InvalidArgumentException('公司 ID 无效。');
}
if ($refreshDays < 1 || $refreshDays > 31) {
throw new InvalidArgumentException('刷新天数必须在 1 至 31 之间。');
}
$this->assertSourceReady();
$refreshStart = $fromDate->copy();
$candidateRefreshStart = $toDate->copy()->subDays($refreshDays - 1);
if ($candidateRefreshStart->gt($refreshStart)) {
$refreshStart = $candidateRefreshStart;
}
$historyEnd = $refreshStart->copy()->subDay();
$historyCoverage = $historyEnd->lt($fromDate)
? []
: $this->coverage($fromDate, $historyEnd, $companyId);
$historyIssues = array_values(array_filter(
$historyCoverage,
static fn (array $row): bool => $row['status'] !== 'ok',
));
foreach ($historyIssues as $issue) {
if ($issue['status'] === 'source_empty') {
throw new RuntimeException(sprintf(
'历史数据检查未通过:源订单镜像在 %s 没有订单,已停止拉取新数据。',
$issue['date'],
));
}
}
$backfills = $this->contiguousRanges($historyIssues);
if ($dryRun) {
return [
'status' => $historyIssues === [] ? 'would_refresh' : 'would_backfill',
'from' => $fromDate->toDateString(),
'to' => $toDate->toDateString(),
'checked_days' => (int) $fromDate->diffInDays($toDate) + 1,
'history_issues' => $historyIssues,
'backfills' => array_map(static fn (array $range): array => [
'from' => $range['from'],
'to' => $range['to'],
], $backfills),
'refresh' => [
'from' => $refreshStart->toDateString(),
'to' => $toDate->toDateString(),
],
];
}
$backfillResults = [];
foreach ($backfills as $range) {
$backfillResults[] = $this->importer->import(
$range['from'],
$range['to'],
$companyId,
true,
'jst-order-label-daily-sync',
);
}
if ($historyIssues !== []) {
$remainingHistory = $this->coverage($fromDate, $historyEnd, $companyId);
$remainingIssues = array_values(array_filter(
$remainingHistory,
static fn (array $row): bool => $row['status'] !== 'ok',
));
if ($remainingIssues !== []) {
throw new RuntimeException('历史缺口补导后仍未通过覆盖检查:' . $this->issueSummary($remainingIssues));
}
}
$refreshResult = $this->importer->import(
$refreshStart->toDateString(),
$toDate->toDateString(),
$companyId,
true,
'jst-order-label-daily-sync',
);
$finalCoverage = $this->coverage($fromDate, $toDate, $companyId);
$finalIssues = array_values(array_filter(
$finalCoverage,
static fn (array $row): bool => $row['status'] !== 'ok',
));
if ($finalIssues !== []) {
throw new RuntimeException('同步后覆盖检查未通过:' . $this->issueSummary($finalIssues));
}
return [
'status' => 'ok',
'from' => $fromDate->toDateString(),
'to' => $toDate->toDateString(),
'checked_days' => count($finalCoverage),
'backfilled_ranges' => array_map(static fn (array $range): array => [
'from' => $range['from'],
'to' => $range['to'],
], $backfills),
'backfill_results' => $backfillResults,
'refresh' => $refreshResult,
];
}
/**
* 只读确认指定日期都已覆盖;任何缺日或订单数不一致都会阻止下游重算。
*
* @return list<array{date:string,source_orders:int,target_orders:int,target_rows:int,status:string}>
*/
public function assertCovered(string $from, string $to, int $companyId): array
{
[$fromDate, $toDate] = $this->dateRange($from, $to);
if ($companyId <= 0) {
throw new InvalidArgumentException('公司 ID 无效。');
}
$this->assertSourceReady();
$coverage = $this->coverage($fromDate, $toDate, $companyId);
$issues = array_values(array_filter(
$coverage,
static fn (array $row): bool => $row['status'] !== 'ok',
));
if ($issues !== []) {
throw new RuntimeException('历史数据检查未通过:' . $this->issueSummary($issues));
}
return $coverage;
}
/**
* 逐日比较源订单号数与标签明细订单号数。
*
* 标签拆行后同一订单会出现多行,因此不能用总行数直接比较订单完整性。
*
* @return list<array{
* date:string,source_orders:int,target_orders:int,target_rows:int,status:string
* }>
*/
public function coverage(Carbon $from, Carbon $to, int $companyId): array
{
$start = $from->copy()->startOfDay()->toDateTimeString();
$end = $to->copy()->addDay()->startOfDay()->toDateTimeString();
$mirror = $this->mirror();
$target = $this->databases->connection();
$sourceRows = $mirror->table('ops_jst_orders')
->where('order_date', '>=', $start)
->where('order_date', '<', $end)
->selectRaw('DATE(order_date) AS day, COUNT(DISTINCT so_id) AS order_count')
->groupByRaw('DATE(order_date)')
->get()
->keyBy('day');
$targetRows = $target->table('jst_order_labels')
->where('company_id', $companyId)
->where('ordered_at', '>=', $start)
->where('ordered_at', '<', $end)
->selectRaw('DATE(ordered_at) AS day, COUNT(*) AS row_count, COUNT(DISTINCT order_no) AS order_count')
->groupByRaw('DATE(ordered_at)')
->get()
->keyBy('day');
$coverage = [];
for ($day = $from->copy()->startOfDay(); $day->lte($to); $day->addDay()) {
$date = $day->toDateString();
$sourceOrders = (int) ($sourceRows[$date]->order_count ?? 0);
$targetOrderCount = (int) ($targetRows[$date]->order_count ?? 0);
$targetRowsCount = (int) ($targetRows[$date]->row_count ?? 0);
$status = match (true) {
$sourceOrders === 0 => 'source_empty',
$targetOrderCount === 0 => 'missing',
$targetOrderCount < $sourceOrders => 'incomplete',
$targetOrderCount > $sourceOrders => 'extra_orders',
default => 'ok',
};
$coverage[] = [
'date' => $date,
'source_orders' => $sourceOrders,
'target_orders' => $targetOrderCount,
'target_rows' => $targetRowsCount,
'status' => $status,
];
}
return $coverage;
}
private function assertSourceReady(): void
{
$mirror = $this->mirror();
$schema = $mirror->getSchemaBuilder();
if (! $schema->hasTable('ops_jst_orders')) {
throw new RuntimeException('聚水潭订单镜像尚未建立(ops_jst_orders)。');
}
if (! $schema->hasTable('business_mirror_table_states')) {
throw new RuntimeException('聚水潭订单镜像同步状态表尚未建立。');
}
$state = $mirror->table('business_mirror_table_states')
->where('dataset_key', self::SOURCE_DATASET)
->first(['status', 'initial_completed_at']);
if ($state === null || $state->initial_completed_at === null) {
throw new RuntimeException('聚水潭订单镜像尚未完成首次同步。');
}
if ((string) $state->status !== 'succeeded') {
throw new RuntimeException('聚水潭订单镜像最近同步未成功,已停止标签明细同步。');
}
}
private function mirror(): \Illuminate\Database\ConnectionInterface
{
return $this->databases->connection(
(string) config('upstream_mirrors.connection', 'business_mirror'),
);
}
/**
* @param list<array{date:string}> $issues
* @return list<array{from:string,to:string}>
*/
private function contiguousRanges(array $issues): array
{
usort($issues, static fn (array $left, array $right): int => $left['date'] <=> $right['date']);
$ranges = [];
$start = null;
$previous = null;
foreach ($issues as $issue) {
$day = Carbon::createFromFormat('!Y-m-d', $issue['date'], 'Asia/Shanghai');
if ($start === null) {
$start = $day;
$previous = $day;
continue;
}
if (! $day->isSameDay($previous->copy()->addDay())) {
$ranges[] = ['from' => $start->toDateString(), 'to' => $previous->toDateString()];
$start = $day;
}
$previous = $day;
}
if ($start !== null && $previous !== null) {
$ranges[] = ['from' => $start->toDateString(), 'to' => $previous->toDateString()];
}
return $ranges;
}
/** @param list<array{date:string,status:string}> $issues */
private function issueSummary(array $issues): string
{
return implode(',', array_map(
static fn (array $issue): string => $issue['date'] . '=' . $issue['status'],
$issues,
));
}
/** @return array{0:Carbon,1:Carbon} */
private function dateRange(string $from, string $to): array
{
$fromDate = $this->date($from);
$toDate = $this->date($to);
if ($fromDate === null || $toDate === null || $fromDate->gt($toDate)) {
throw new InvalidArgumentException('日期范围无效,应为 from <= to,格式 YYYY-MM-DD。');
}
return [$fromDate, $toDate];
}
private function date(string $value): ?Carbon
{
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) {
return null;
}
try {
$date = Carbon::createFromFormat('!Y-m-d', $value, 'Asia/Shanghai');
} catch (Throwable) {
return null;
}
return $date->format('Y-m-d') === $value ? $date : null;
}
}