connection = $database->connection((string) config('upstream_mirrors.connection', 'business_mirror')); } public function available(): bool { return $this->available ??= $this->connection->getSchemaBuilder()->hasTable(self::TABLE) && $this->connection->getSchemaBuilder()->hasTable(self::STATE); } public function ready(): bool { return $this->available() && $this->connection->table(self::STATE)->where('id', 1)->whereNotNull('completed_at')->exists(); } /** Called inside the raw mirror's transaction, including changes to title/waybill. */ public function storeBatch(array $rows): void { if ($rows === [] || ! $this->available()) return; $keys = []; $values = []; foreach ($rows as $row) { $row = (array) $row; $key = (string) $row['_mirror_key']; $keys[] = $key; if (($row['title'] ?? '') !== '查件') continue; $fields = json_decode((string) ($row['businessFieldList'] ?? ''), true); if (! is_array($fields)) continue; $waybill = ''; // Same last-field-wins semantics as the existing settlement parser. foreach ($fields as $field) { if (is_array($field) && ($field['fieldId'] ?? null) === 'lIdField') { $waybill = trim((string) ($field['value'] ?? '')); } } if ($waybill !== '' && mb_strlen($waybill) <= 191) { $values[] = ['mirror_key' => $key, 'waybill' => $waybill, 'source_id' => (int) ($row['Id'] ?? 0)]; } } // Remove renamed/invalid records too, so an old waybill never survives an update. $this->connection->table(self::TABLE)->whereIn('mirror_key', $keys)->delete(); foreach (array_chunk($values, 500) as $chunk) { $this->connection->table(self::TABLE)->insert($chunk); } } public function prune(): void { if (! $this->available()) return; $this->connection->table(self::TABLE)->whereNotExists(function ($query): void { $query->selectRaw('1')->from(self::SOURCE) ->whereColumn(self::SOURCE . '._mirror_key', self::TABLE . '.mirror_key'); })->delete(); } /** Resume a bounded keyset pass; production invokes this through the global sync lock. */ public function backfill(int $batchSize = 500, int $maxBatches = 2, int $pauseMs = 50): array { if (! $this->available()) throw new \RuntimeException('work_order_waybill_lookup_not_migrated'); $state = $this->connection->table(self::STATE)->where('id', 1)->first(); if ($state?->completed_at) return ['complete' => true, 'rows' => 0, 'batches' => 0]; $cursor = $state?->cursor; $rows = $batches = 0; $batchSize = min(max(1, $batchSize), 1000); do { $batch = $this->connection->table(self::SOURCE)->when($cursor, fn ($query) => $query->where('_mirror_key', '>', $cursor)) ->orderBy('_mirror_key')->limit($batchSize)->get(['_mirror_key', 'Id', 'title', 'businessFieldList']); $complete = $batch->count() < $batchSize; $this->connection->transaction(function () use ($batch, $complete, &$cursor): void { $this->storeBatch($batch->all()); $cursor = $batch->last()?->_mirror_key ?? $cursor; if ($complete) $this->prune(); $this->connection->table(self::STATE)->updateOrInsert(['id' => 1], [ 'cursor' => $cursor, 'completed_at' => $complete ? now() : null, 'updated_at' => now(), ]); }); $rows += $batch->count(); $batches++; if (! $complete && $pauseMs > 0) usleep(min($pauseMs, 1000) * 1000); } while (! $complete && ($maxBatches === 0 || $batches < $maxBatches)); return compact('complete', 'rows', 'batches'); } }