*/ 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 */ 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 */ 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 $issues * @return list */ 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 $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; } }