diff --git a/config/upstream_mirrors.php b/config/upstream_mirrors.php index b26eb56a..fb849e95 100644 --- a/config/upstream_mirrors.php +++ b/config/upstream_mirrors.php @@ -497,6 +497,7 @@ return [ 'columns' => ['as_id', 'status', 'shop_status', 'confirm_date', 'modified', 'as_date'], 'indexes' => [ ['as_id'], + ['status(20)', 'shop_status(20)', 'as_id'], ], ], 'operations_replica.rpt.jst_shopInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_shopInfo', 'target' => 'ops_jst_shop_info', 'keys' => ['Id'], 'watermark' => 'updateTime', 'indexes' => [['shop_id', 'Id']]], diff --git a/modules/EcommerceFinance/Repositories/NonJingxiRefundMirrorRepository.php b/modules/EcommerceFinance/Repositories/NonJingxiRefundMirrorRepository.php index 63aef579..7e469274 100644 --- a/modules/EcommerceFinance/Repositories/NonJingxiRefundMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/NonJingxiRefundMirrorRepository.php @@ -42,6 +42,7 @@ class NonJingxiRefundMirrorRepository self::SUPPLEMENT_DATASET => [['as_id'], ['as_date', 'as_id'], ['so_id'], ['outer_as_id'], ['status', 'shop_status', 'as_date', 'as_id']], self::SUPPLEMENT_ITEM_DATASET => [['as_id', 'asi_id']], self::SHOP_DATASET => [['shop_id', 'Id']], + self::STATUS_DATASET => [['as_id'], ['status', 'shop_status', 'as_id']], 'operations_replica.rpt.jst_Orders' => [['o_id', 'Id']], 'operations_replica.rpt.jst_OrderItem' => [['o_id', 'Id']], ]; @@ -209,14 +210,25 @@ class NonJingxiRefundMirrorRepository $page = max($page, 1); $perPage = min(max($perPage, 1), 100); - $total = $this->sourceQuery($filters, false)->count() - + $this->sourceQuery($filters, true)->count(); + $total = $this->detailRowCount($filters, false) + + $this->detailRowCount($filters, true); $keys = $this->orderedKeys($filters, null, $page * $perPage)->forPage($page, $perPage)->get(); $rows = $this->loadRows($keys); return ['data' => $rows->all(), 'total' => $total]; } + /** @param array $filters */ + private function detailRowCount(array $filters, bool $supplement): int + { + $groupColumns = $this->detailGroupColumns($supplement); + $grouped = $this->sourceQuery($filters, $supplement) + ->select($groupColumns) + ->groupBy($groupColumns); + + return $this->connection->query()->fromSub($grouped, 'deduplicated_detail_rows')->count(); + } + /** * 售后状态为待确认、且线上退款已成功的记录。 * @@ -469,8 +481,12 @@ class NonJingxiRefundMirrorRepository 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 + $statusAuthoritative = (bool) ($filters['_status_authoritative'] ?? false); + $query = $statusAuthoritative + ? $this->connection->table('ops_jst_refund_status as current_refund') + ->join($table . ' as r', 'r.as_id', '=', 'current_refund.as_id') + : $this->connection->table($table . ' as r'); + if (! $statusAuthoritative && $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. @@ -532,10 +548,12 @@ class NonJingxiRefundMirrorRepository if (trim((string) ($filters['status'] ?? '')) !== '' || trim((string) ($filters['shop_status'] ?? '')) !== '') { $statusAlias = 'current_refund'; - $query->leftJoin('ops_jst_refund_status as current_refund', 'current_refund.as_id', '=', 'r.as_id'); + if (! $statusAuthoritative) { + $query->leftJoin('ops_jst_refund_status as current_refund', 'current_refund.as_id', '=', 'r.as_id'); + } } $this->applyPlatformFilter($query, $filters, $supplement); - $this->applyFilters($query, $filters, $statusAlias); + $this->applyFilters($query, $filters, $statusAlias, $statusAuthoritative); if ($filters['sku_id'] !== '') { if ($withItems) { $this->applySkuFilter($query, $filters['sku_id']); @@ -572,9 +590,12 @@ class NonJingxiRefundMirrorRepository $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') + $itemKey = $supplement ? 'i.asi_id' : 'i.Id'; + $items->select($this->itemGroupColumns()) + ->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); + ->groupBy($this->itemGroupColumns()) + ->havingRaw("MAX({$itemKey}) < ?", [$cursor->_item_id]); if ($filters['sku_id'] !== '') { $this->applySkuFilter($items, $filters['sku_id']); } @@ -595,8 +616,9 @@ class NonJingxiRefundMirrorRepository $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"); + ? 'r.as_date AS refundDate, 1 AS _source_rank, 0 AS _head_id, MAX(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, MAX(COALESCE(i.Id, 0)) AS _item_id, '' AS _source_id") + ->groupBy($this->detailGroupColumns($supplement)); $branch = $this->connection->query()->fromSub($source, 'source_keys'); if ($cursor !== null) { $branch->where(function (Builder $after) use ($cursor): void { @@ -624,6 +646,27 @@ class NonJingxiRefundMirrorRepository return $query; } + /** @return list */ + private function detailGroupColumns(bool $supplement): array + { + return array_merge([$supplement ? 'r.as_id' : 'r.Id'], $this->itemGroupColumns()); + } + + /** @return list */ + private function itemGroupColumns(): array + { + return [ + 'i.outer_oi_id', + 'i.shop_sku_id', + 'i.i_id', + 'i.sku_id', + 'i.qty', + 'i.shop_amount', + 'i.r_qty', + 'i.type', + ]; + } + private function loadRows($keys) { $loaded = collect(); @@ -692,7 +735,12 @@ class NonJingxiRefundMirrorRepository } /** @param array{from:string,to:string,order_no:string,waybill_no:string,shop_name:string,sku_id:string,platform?:string} $filters */ - private function applyFilters(Builder $query, array $filters, ?string $statusAlias = null): void + private function applyFilters( + Builder $query, + array $filters, + ?string $statusAlias = null, + bool $statusAuthoritative = false, + ): 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')); @@ -701,6 +749,8 @@ class NonJingxiRefundMirrorRepository if ($status !== '') { if ($statusAlias === null) { $query->where('r.status', $status); + } elseif ($statusAuthoritative) { + $query->where("{$statusAlias}.status", $status); } else { $query->whereRaw("COALESCE({$statusAlias}.status, r.status) = ?", [$status]); } @@ -710,6 +760,8 @@ class NonJingxiRefundMirrorRepository if ($shopStatus !== '') { if ($statusAlias === null) { $query->where('r.shop_status', $shopStatus); + } elseif ($statusAuthoritative) { + $query->where("{$statusAlias}.shop_status", $shopStatus); } else { $query->whereRaw("COALESCE({$statusAlias}.shop_status, r.shop_status) = ?", [$shopStatus]); } @@ -809,6 +861,7 @@ class NonJingxiRefundMirrorRepository return array_replace($filters, [ 'status' => self::PENDING_CONFIRMATION_STATUS, 'shop_status' => self::PENDING_CONFIRMATION_ONLINE_STATUS, + '_status_authoritative' => true, ]); } diff --git a/tests/Unit/EcommerceFinance/NonJingxiRefundMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/NonJingxiRefundMirrorRepositoryTest.php index 652a852a..17a4469c 100644 --- a/tests/Unit/EcommerceFinance/NonJingxiRefundMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/NonJingxiRefundMirrorRepositoryTest.php @@ -224,6 +224,41 @@ class NonJingxiRefundMirrorRepositoryTest extends TestCase )); } + public function test_pending_confirmation_collapses_exact_duplicate_item_rows(): void + { + $db = DB::connection('business_mirror'); + $db->table('ops_jst_return_refund_info')->insert([ + array_replace($this->refundInfo(1, 101, '2026-10-31 16:57:49', '京东商城', '京东测试店'), [ + 'status' => 'WaitConfirm', + 'shop_status' => 'SUCCESS', + ]), + ]); + $first = $this->refundItem(11, 101, 'SKU-DUP', 'ITEM-DUP'); + $duplicate = array_replace($first, ['Id' => 12]); + $db->table('ops_jst_return_refund_item')->insert([$first, $duplicate]); + $db->table('ops_jst_refund_status')->insert([ + ['as_id' => '101', 'status' => 'WaitConfirm', 'shop_status' => 'SUCCESS'], + ]); + + $filters = [ + 'from' => '2026-10-01', + 'to' => '2026-10-31', + 'order_no' => '', + 'waybill_no' => '', + 'shop_name' => '', + 'sku_id' => '', + ]; + $page = $this->repository->paginatePendingConfirmation($filters, 1, 25); + $this->assertSame(1, $page['total']); + $this->assertCount(1, $page['data']); + $this->assertSame(12, (int) $page['data'][0]->Id); + $this->assertSame('SKU-DUP', $page['data'][0]->sku_id); + + $export = iterator_to_array($this->repository->exportPendingConfirmationRows($filters, 1)); + $this->assertCount(1, $export); + $this->assertSame(12, (int) $export[0]->Id); + } + public function test_pending_confirmation_monthly_summary_combines_unique_source_counts(): void { $db = DB::connection('business_mirror'); @@ -409,7 +444,7 @@ class NonJingxiRefundMirrorRepositoryTest extends TestCase ['asi_id' => 502, 'as_id' => '202', 'sku_id' => 'MATCH'], ]); $filters = ['from' => '2026-07-01', 'to' => '2026-07-31', 'order_no' => '', 'waybill_no' => '', 'shop_name' => '', 'sku_id' => '']; - $expected = ['202:502', '202:501', '201:201', '102:14', '102:13', '102:12', '101:11']; + $expected = ['202:502', '201:201', '102:14', '102:13', '102:12', '101:11']; $identity = fn ($r) => $r->as_id . ':' . $r->Id; $this->assertSame($expected, array_map($identity, iterator_to_array($this->repository->exportRows($filters, 1)))); $paged = []; @@ -418,7 +453,7 @@ class NonJingxiRefundMirrorRepositoryTest extends TestCase } $this->assertSame($expected, array_map($identity, $paged)); $filters['sku_id'] = 'MATCH'; - $this->assertSame(['202:502', '202:501', '102:14', '102:12', '101:11'], + $this->assertSame(['202:502', '102:14', '102:12', '101:11'], array_map($identity, iterator_to_array($this->repository->exportRows($filters, 1)))); } @@ -552,6 +587,7 @@ class NonJingxiRefundMirrorRepositoryTest extends TestCase $table->string('as_id'); $table->string('status')->nullable(); $table->string('shop_status')->nullable(); $table->dateTime('confirm_date')->nullable(); $table->dateTime('modified')->nullable(); $table->dateTime('as_date')->nullable(); $table->index(['as_id']); + $table->index(['status', 'shop_status', 'as_id']); }); $schema->create('ops_jst_qmcust_refunds', function (Blueprint $table): void { $table->string('as_id'); $table->dateTime('as_date'); $table->string('outer_as_id')->nullable();