[['Id'], ['as_date', 'Id'], ['o_id', 'Id'], ['as_id', 'Id'], ['shop_id', 'outer_as_id'], ['so_id'], ['outer_as_id'], ['as_date', 'Id', 'as_id', 'shop_site']], self::ITEM_DATASET => [['as_id', 'Id']], self::SUPPLEMENT_DATASET => [['as_id'], ['as_date', 'as_id'], ['so_id'], ['outer_as_id']], self::SUPPLEMENT_ITEM_DATASET => [['as_id', 'asi_id']], self::SHOP_DATASET => [['shop_id', 'Id']], 'operations_replica.rpt.jst_Orders' => [['o_id', 'Id']], 'operations_replica.rpt.jst_OrderItem' => [['o_id', 'Id']], ]; private readonly ConnectionInterface $connection; private ?array $excludedShopIds = null; private ?string $supplementDateIndex = null; public function __construct( private readonly BusinessMirrorDatasetRegistry $datasets, private readonly GenericBusinessMirrorRepository $mirror, ) { $this->connection = $mirror->connection(); } public function available(): bool { $schema = Schema::connection($this->connection->getName()); return $schema->hasTable('business_mirror_table_states') && collect([self::INFO_DATASET, self::ITEM_DATASET, self::SUPPLEMENT_DATASET, self::SUPPLEMENT_ITEM_DATASET, self::SHOP_DATASET]) ->every(fn (string $key): bool => $schema->hasTable($this->dataset($key)->targetTable())); } public function ready(): bool { return collect([self::INFO_DATASET, self::ITEM_DATASET, self::SUPPLEMENT_DATASET, self::SUPPLEMENT_ITEM_DATASET, self::SHOP_DATASET]) ->every(fn (string $key): bool => $this->mirror->ready($this->dataset($key))); } public function queryReady(): bool { $schema = Schema::connection($this->connection->getName()); try { foreach (self::REQUIRED_QUERY_INDEXES as $datasetKey => $requiredIndexes) { $table = $this->dataset($datasetKey)->targetTable(); if (! $schema->hasTable($table)) { return false; } $tableIndexes = collect($schema->getIndexes($table)); if ($datasetKey === self::SUPPLEMENT_DATASET) { $this->supplementDateIndex = $tableIndexes->first( static fn (array $index): bool => array_map('strtolower', $index['columns']) === ['as_date', 'as_id'] )['name'] ?? null; } $indexes = $tableIndexes ->map(static fn (array $index): array => array_map( 'strtolower', (array) ($index['columns'] ?? []), )); foreach ($requiredIndexes as $required) { if (! $indexes->contains(array_map('strtolower', $required))) { return false; } } } } catch (\Throwable) { return false; } return true; } /** @return array|null> */ public function states(): array { return [ self::INFO_DATASET => $this->mirror->state($this->dataset(self::INFO_DATASET)), self::ITEM_DATASET => $this->mirror->state($this->dataset(self::ITEM_DATASET)), self::SUPPLEMENT_DATASET => $this->mirror->state($this->dataset(self::SUPPLEMENT_DATASET)), self::SUPPLEMENT_ITEM_DATASET => $this->mirror->state($this->dataset(self::SUPPLEMENT_ITEM_DATASET)), self::SHOP_DATASET => $this->mirror->state($this->dataset(self::SHOP_DATASET)), ]; } /** * @param array|null>|null $states * @return array{code:string,last_succeeded_at:?string} */ public function syncStatus(?array $states = null, ?CarbonInterface $at = null): array { $states ??= $this->states(); if (count(array_filter($states, 'is_array')) !== 5) { return ['code' => 'unavailable', 'last_succeeded_at' => null]; } $initialized = collect($states)->every( static fn (?array $state): bool => ($state['initial_completed_at'] ?? null) !== null ); $statuses = collect($states)->pluck('status')->map('strval'); $lastSucceededAt = collect($states) ->pluck('last_succeeded_at') ->filter() ->sort() ->first(); if (! $initialized) { $code = $statuses->contains('failed') ? 'initial_failed' : 'initializing'; } elseif ($statuses->contains('failed')) { $code = 'failed'; } elseif ($statuses->contains('running')) { $code = 'syncing'; } else { $lastSucceeded = $lastSucceededAt ? Carbon::parse($lastSucceededAt) : null; $lagMinutes = $lastSucceeded === null ? null : (int) ceil(max(0, ($at ?? now())->getTimestamp() - $lastSucceeded->getTimestamp()) / 60); $code = $lagMinutes === null || $lagMinutes > 30 ? 'stale' : 'healthy'; } return ['code' => $code, 'last_succeeded_at' => $lastSucceededAt]; } /** * @param array{from:string,to:string,order_no:string,shop_name:string,sku_id:string} $filters * @return array{data:list,total:int} */ public function paginate(array $filters, int $page, int $perPage): array { if (! $this->ready() || ! $this->queryReady()) { throw new RuntimeException('business_mirror_dataset_not_ready'); } $page = max($page, 1); $perPage = min(max($perPage, 1), 100); $total = $this->sourceQuery($filters, false)->count() + $this->sourceQuery($filters, true)->count(); $keys = $this->orderedKeys($filters, null, $page * $perPage)->forPage($page, $perPage)->get(); $rows = $this->loadRows($keys); return ['data' => $rows->all(), 'total' => $total]; } /** * Export all rows matching the supplied filters in the same order and * shape as the paginated page data, without loading the full result set. * * @param array{from:string,to:string,order_no:string,shop_name:string,sku_id:string} $filters * @return iterable */ public function exportRows(array $filters, int $chunkSize = 1000): iterable { if (! $this->ready() || ! $this->queryReady()) { throw new RuntimeException('business_mirror_dataset_not_ready'); } $chunkSize = min(max($chunkSize, 1), 1000); $cursor = null; do { $keys = $this->orderedKeys($filters, $cursor, $chunkSize)->limit($chunkSize)->get(); if ($keys->isEmpty()) { break; } foreach ($this->loadRows($keys) as $row) { yield $row; } $cursor = $keys->last(); } while ($keys->count() === $chunkSize); } /** @return list */ private function selectColumns(): array { return [ 'r.as_date as refundDate', 'r.as_id', 'r.outer_as_id', 'r.o_id', 'r.so_id', 'r.type', 'r.node', 'r.shop_type', 'r.shop_name', 'r.shop_site as platform', 'r.refund as totalRefundMoney', 'i.outer_oi_id', 'i.shop_sku_id', 'i.i_id', 'i.sku_id', 'i.qty', 'i.shop_amount as detailRefundMoney', 'i.type as refundType', 'i.r_qty as refundQty', 'r.receive_refund as RefundMoneyIncome', 'r.buyer_apply_refund as AfSalesMoney', 'r.createTime', 'r.status', 'r.shop_status', 'r.created', 'r.confirm_date', ]; } private function sourceQuery(array $filters, bool $supplement, bool $withItems = true): Builder { $table = $supplement ? 'ops_jst_qmcust_refunds' : 'ops_jst_return_refund_info'; $query = $this->connection->table($table . ' as r'); if ($supplement && $filters['order_no'] === '' && $this->supplementDateIndex !== null && $this->connection->getDriverName() === 'mysql') { // The legacy text payload makes a full table scan very expensive; // keep date windows on the verified shared-mirror lookup index. $query->forceIndex($this->supplementDateIndex); } if ($filters['order_no'] !== '') { $needle = trim($filters['order_no']); $key = $supplement ? 'as_id' : 'Id'; $matches = $this->connection->table($table)->select($key . ' as matched_id')->where('so_id', $needle) ->union($this->connection->table($table)->select($key . ' as matched_id')->where('outer_as_id', $needle)); if (ctype_digit($needle)) { $matches->union($this->connection->table($table)->select($key . ' as matched_id')->where('o_id', $needle)); } // Separate indexed lookups: MariaDB cannot reliably index-merge // OR predicates across the replica's long text identifier fields. $query->joinSub($matches, 'matching_refund', 'matching_refund.matched_id', '=', 'r.' . $key); } $itemTable = $supplement ? 'ops_jst_qmcust_refund_items' : 'ops_jst_return_refund_item'; if ($withItems) { $query->leftJoin($itemTable . ' as i', 'i.as_id', '=', 'r.as_id'); } $platform = 'r.shop_site'; if ($supplement) { // A shop may have several historical rows. An ambiguous platform is // left unknown, and must never multiply the refund rows. $this->excludedShopIds ??= $this->connection->table('ops_jst_shop_info') ->get(['shop_id', 'shop_site'])->groupBy('shop_id') ->filter(function ($rows): bool { $sites = $rows->pluck('shop_site')->filter()->unique(); $site = (string) $sites->first(); return $sites->count() === 1 && (str_contains($site, '京喜') || str_contains(strtolower($site), 'jingxi')); })->keys()->map('strval')->all(); if ($this->excludedShopIds !== []) { $query->where(fn (Builder $q) => $q->whereNull('r.shop_id')->orWhereNotIn('r.shop_id', $this->excludedShopIds)); } $query->whereNotExists(function (Builder $existing): void { $existing->selectRaw('1')->from('ops_jst_return_refund_info as existing') ->whereColumn('existing.as_id', 'r.as_id'); }) ->whereNotExists(function (Builder $existing): void { $existing->selectRaw('1')->from('ops_jst_return_refund_info as existing') ->whereNotNull('r.outer_as_id')->where('r.outer_as_id', '!=', '') ->whereNotNull('r.shop_id')->where('r.shop_id', '!=', '') ->whereColumn('existing.shop_id', 'r.shop_id') ->whereColumn('existing.outer_as_id', 'r.outer_as_id'); }); $platform = null; } if ($platform !== null) { $query->where(function (Builder $query) use ($platform): void { $query->whereNull($platform)->orWhere(function (Builder $query) use ($platform): void { $query->where($platform, 'not like', '%京喜%') ->whereRaw('LOWER(' . $platform . ') NOT LIKE ?', ['%jingxi%']); }); }); } $this->applyFilters($query, $filters); if ($filters['sku_id'] !== '') { if ($withItems) { $this->applySkuFilter($query, $filters['sku_id']); } else { $query->whereExists(function (Builder $items) use ($itemTable, $filters): void { $items->selectRaw('1')->from($itemTable . ' as i')->whereColumn('i.as_id', 'r.as_id'); $this->applySkuFilter($items, $filters['sku_id']); }); } } return $query; } private const SORT_COLUMNS = ['refundDate', '_source_rank', '_head_id', '_source_id', '_item_id']; private function orderedKeys(array $filters, ?object $cursor, int $limit): Builder { $branches = []; foreach ([false, true] as $supplement) { $rank = $supplement ? 1 : 0; $headColumn = $supplement ? 'r.as_id' : 'r.Id'; $heads = $this->sourceQuery($filters, $supplement, false) ->select(['r.as_date', 'r.as_id'])->when(! $supplement, fn (Builder $q) => $q->addSelect('r.Id')); if ($cursor !== null) { $heads->where(function (Builder $after) use ($cursor, $rank, $headColumn, $supplement, $filters): void { $after->where('r.as_date', '<', $cursor->refundDate); if ($rank <= (int) $cursor->_source_rank) { $after->orWhere(function (Builder $sameDate) use ($cursor, $rank, $headColumn, $supplement, $filters): void { $sameDate->where('r.as_date', $cursor->refundDate); if ($rank === (int) $cursor->_source_rank) { $headValue = $supplement ? $cursor->_source_id : $cursor->_head_id; $sameDate->where(function (Builder $head) use ($headColumn, $headValue, $cursor, $supplement, $filters): void { $head->where($headColumn, '<', $headValue) ->orWhere(function (Builder $remaining) use ($headColumn, $headValue, $cursor, $supplement, $filters): void { $remaining->where($headColumn, $headValue)->whereExists(function (Builder $items) use ($cursor, $supplement, $filters): void { $items->selectRaw('1')->from(($supplement ? 'ops_jst_qmcust_refund_items' : 'ops_jst_return_refund_item') . ' as i') ->whereColumn('i.as_id', 'r.as_id') ->where($supplement ? 'i.asi_id' : 'i.Id', '<', $cursor->_item_id); if ($filters['sku_id'] !== '') { $this->applySkuFilter($items, $filters['sku_id']); } }); }); }); } }); } }); } $heads->orderByDesc('r.as_date')->orderByDesc($headColumn)->limit($limit); // Each eligible header contributes at least one row. The newest N // headers therefore contain the newest N matching detail rows. $source = $this->connection->query()->fromSub($heads, 'r') ->leftJoin(($supplement ? 'ops_jst_qmcust_refund_items' : 'ops_jst_return_refund_item') . ' as i', 'i.as_id', '=', 'r.as_id'); if ($filters['sku_id'] !== '') { $this->applySkuFilter($source, $filters['sku_id']); } $source->selectRaw($supplement ? 'r.as_date AS refundDate, 1 AS _source_rank, 0 AS _head_id, COALESCE(i.asi_id, 0) AS _item_id, r.as_id AS _source_id' : "r.as_date AS refundDate, 0 AS _source_rank, r.Id AS _head_id, COALESCE(i.Id, 0) AS _item_id, '' AS _source_id"); $branch = $this->connection->query()->fromSub($source, 'source_keys'); if ($cursor !== null) { $branch->where(function (Builder $after) use ($cursor): void { foreach (self::SORT_COLUMNS as $position => $column) { $after->orWhere(function (Builder $part) use ($cursor, $position, $column): void { foreach (array_slice(self::SORT_COLUMNS, 0, $position) as $equal) { $part->where($equal, '=', $cursor->$equal); } $part->where($column, '<', $cursor->$column); }); } }); } foreach (self::SORT_COLUMNS as $column) { $branch->orderByDesc($column); } $branches[] = $this->connection->query()->fromSub($branch->limit($limit), 'bounded_keys'); } // Bound both branches before the final merge, including cursor exports. $query = $this->connection->query()->fromSub($branches[0]->unionAll($branches[1]), 'refund_keys'); foreach (self::SORT_COLUMNS as $column) { $query->orderByDesc($column); } return $query; } private function loadRows($keys) { $loaded = collect(); foreach ([false, true] as $supplement) { $sourceKeys = $keys->filter(fn (object $key): bool => (bool) $key->_source_rank === $supplement); if ($sourceKeys->isEmpty()) { continue; } $table = $supplement ? 'ops_jst_qmcust_refunds' : 'ops_jst_return_refund_info'; $itemTable = $supplement ? 'ops_jst_qmcust_refund_items' : 'ops_jst_return_refund_item'; $itemKey = $supplement ? 'i.asi_id' : 'i.Id'; $query = $this->connection->table($table . ' as r') ->leftJoin($itemTable . ' as i', 'i.as_id', '=', 'r.as_id') ->whereIn($supplement ? 'r.as_id' : 'r.Id', $sourceKeys->pluck($supplement ? '_source_id' : '_head_id')) ->where(function (Builder $items) use ($sourceKeys, $itemKey): void { $items->whereIn($itemKey, $sourceKeys->pluck('_item_id')); if ($sourceKeys->contains(fn (object $key): bool => (int) $key->_item_id === 0)) { $items->orWhereNull($itemKey); } }); if ($supplement) { $query->select([ 'r.as_date as refundDate', 'r.as_id', 'r.outer_as_id', 'r.o_id', 'r.so_id', 'r.type', 'r.node', 'r.shop_type', 'r.shop_name', 'r.refund as totalRefundMoney', 'i.outer_oi_id', 'i.shop_sku_id', 'i.i_id', 'i.sku_id', 'i.qty', 'i.shop_amount as detailRefundMoney', 'i.type as refundType', 'i.r_qty as refundQty', 'r.buyer_apply_refund as AfSalesMoney', 'r.local_created as createTime', 'r.status', 'r.shop_status', 'r.created', 'r.confirm_date', 'r.shop_id', ])->selectRaw("COALESCE(i.asi_id, r.as_id) AS Id, NULL AS RefundMoneyIncome, '退款清单' AS dataSource, 1 AS _source_rank, 0 AS _head_id, COALESCE(i.asi_id, 0) AS _item_id, r.as_id AS _source_id"); } else { $query->select($this->selectColumns()) ->selectRaw("COALESCE(i.Id, r.Id) AS Id, '售后资料' AS dataSource, 0 AS _source_rank, r.Id AS _head_id, COALESCE(i.Id, 0) AS _item_id, '' AS _source_id"); } $loaded = $loaded->concat($query->get()); } $byIdentity = $loaded->keyBy(fn (object $row): string => $this->identity($row)); $rows = $keys->map(fn (object $key) => $byIdentity->get($this->identity($key)))->filter()->values(); $shopIds = $rows->where('_source_rank', 1)->pluck('shop_id')->unique(); $platforms = $shopIds->isEmpty() ? collect() : $this->connection->table('ops_jst_shop_info') ->whereIn('shop_id', $shopIds)->get(['shop_id', 'shop_site'])->groupBy('shop_id'); foreach ($rows as $row) { if ((int) $row->_source_rank === 1) { $sites = ($platforms->get($row->shop_id) ?? collect())->pluck('shop_site')->filter()->unique()->values(); $row->platform = $sites->count() === 1 ? $sites->first() : '待确认'; unset($row->shop_id); } $row->detailStatus = (int) $row->_item_id === 0 ? '暂无商品明细' : '已关联'; foreach (['totalRefundMoney', 'detailRefundMoney', 'RefundMoneyIncome', 'AfSalesMoney'] as $money) { if (! is_numeric($row->$money)) { $row->$money = null; } } foreach (array_slice(self::SORT_COLUMNS, 1) as $column) { unset($row->$column); } } $this->hydratePage($rows); return $rows; } private function identity(object $row): string { return implode('|', [$row->_source_rank, $row->_head_id, $row->_item_id, $row->_source_id]); } /** @param array{from:string,to:string,order_no:string,shop_name:string,sku_id:string} $filters */ private function applyFilters(Builder $query, array $filters): void { $query->where('r.as_date', '>=', $filters['from'] . ' 00:00:00') ->where('r.as_date', '<', Carbon::parse($filters['to'])->addDay()->format('Y-m-d 00:00:00')); if ($filters['shop_name'] !== '') { $query->whereRaw("r.shop_name LIKE ? ESCAPE '!'", [$this->contains($filters['shop_name'])]); } } private function applySkuFilter(Builder $query, string $value): void { $needle = $this->contains($value); $query->where(function (Builder $sku) use ($needle): void { $sku->whereRaw("i.sku_id LIKE ? ESCAPE '!'", [$needle]) ->orWhereRaw("i.shop_sku_id LIKE ? ESCAPE '!'", [$needle]) ->orWhereRaw("i.i_id LIKE ? ESCAPE '!'", [$needle]); }); } /** @param \Illuminate\Support\Collection $rows */ private function hydratePage($rows): void { if ($rows->isEmpty()) { return; } $primaryRows = $rows->where('dataSource', '售后资料'); $orderIds = $primaryRows->pluck('o_id')->filter()->unique()->values()->all(); $skuIds = $rows->pluck('sku_id')->filter()->unique()->values()->all(); $shopNames = $rows->pluck('shop_name')->filter()->unique()->values()->all(); $orderItems = $this->auxiliaryReady(self::AUXILIARY_DATASETS[1]) ? $this->connection->table('ops_jst_order_item') ->whereIn('o_id', $orderIds) ->whereIn('outer_oi_id', $primaryRows->pluck('outer_oi_id')->filter()->unique()->all()) ->get(['o_id', 'outer_oi_id', 'shop_i_id', 'shop_sku_id', 'i_id', 'sku_id', 'buyer_paid_amount']) ->keyBy(static fn (object $row): string => $row->o_id . '|' . $row->outer_oi_id) : collect(); $orders = $this->auxiliaryReady(self::AUXILIARY_DATASETS[0]) ? $this->connection->table('ops_jst_orders')->whereIn('o_id', $orderIds) ->orderByDesc('Id')->get(['o_id', 'receiver_zip'])->unique('o_id')->keyBy('o_id') : collect(); $skus = $this->auxiliaryReady(self::AUXILIARY_DATASETS[2]) ? $this->connection->table('ops_jst_sku')->whereIn('sku_id', $skuIds) ->where('sku_type', 'normal')->orderByDesc('autoid')->get(['sku_id', 'cost_price']) ->unique('sku_id')->keyBy('sku_id') : collect(); $shops = $this->auxiliaryReady(self::AUXILIARY_DATASETS[3]) ? $this->connection->table('ops_fys_shop_info')->whereIn('erpShopName', $shopNames) ->orderByDesc('Id')->get(['erpShopName', 'responsibleDepartment']) ->unique('erpShopName')->keyBy('erpShopName') : collect(); foreach ($rows as $row) { $orderItem = $row->dataSource === '售后资料' ? $orderItems->get($row->o_id . '|' . $row->outer_oi_id) : null; $skuId = $row->sku_id ?: ($orderItem->sku_id ?? null); $unitCost = isset($skus[$skuId]) ? (float) $skus[$skuId]->cost_price : null; $refundQuantity = $row->refundQty === null ? null : (float) $row->refundQty; $row->isZC = str_contains((string) $row->shop_type, '自营') ? '是' : '否'; $row->responsibleDepartment = $shops[$row->shop_name]->responsibleDepartment ?? null; $row->originPostcode = $row->dataSource === '售后资料' ? ($orders[$row->o_id]->receiver_zip ?? null) : null; $row->shop_i_id = $orderItem->shop_i_id ?? null; $row->shop_sku_id = $row->shop_sku_id ?: ($orderItem->shop_sku_id ?? null); $row->i_id = $row->i_id ?: ($orderItem->i_id ?? null); $row->sku_id = $skuId; $row->buyer_paid_amount = $orderItem->buyer_paid_amount ?? null; $row->unitCostGoods = $unitCost; $row->refundCostGoods = $unitCost === null || $refundQuantity === null ? null : $unitCost * $refundQuantity; unset($row->shop_type, $row->outer_oi_id); } } private function auxiliaryReady(string $datasetKey): bool { try { return $this->mirror->ready($this->dataset($datasetKey)); } catch (\Throwable) { return false; } } private function contains(string $value): string { return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim($value)) . '%'; } private function dataset(string $key): BusinessMirrorDataset { return $this->datasets->get($key); } }