diff --git a/app/Console/Commands/WarmReportCaches.php b/app/Console/Commands/WarmReportCaches.php index ce1bddfe..5fb087af 100644 --- a/app/Console/Commands/WarmReportCaches.php +++ b/app/Console/Commands/WarmReportCaches.php @@ -38,6 +38,16 @@ class WarmReportCaches extends Command try { $result = $alipay->paginate([], 'occurred_at', 'desc', 1, 10); $this->info(sprintf('淘系支付宝首页已预热,共 %d 行。', $result['total'])); + $options = $alipay->filterOptionsForChannel( + 'alipayCSV', + StoreReceivingAlipayBillMirrorRepository::DATASET, + true, + ); + $this->info(sprintf( + '淘系支付宝筛选选项已预热,共 %d 个店铺、%d 个账单项目。', + count($options['shops']), + count($options['bill_items']), + )); } catch (Throwable $exception) { $failed = true; Log::error('淘系支付宝报表缓存预热失败。', ['exception' => $exception::class]); diff --git a/modules/EcommerceFinance/Http/Controllers/AccountBills.php b/modules/EcommerceFinance/Http/Controllers/AccountBills.php index 839cfc7f..880563f5 100644 --- a/modules/EcommerceFinance/Http/Controllers/AccountBills.php +++ b/modules/EcommerceFinance/Http/Controllers/AccountBills.php @@ -166,12 +166,19 @@ class AccountBills extends Controller $this->mirrorRepository($bill_type)->filterOptions() ); } catch (RuntimeException $exception) { - if (! in_array($exception->getMessage(), ['store_receiving_alipay_bill_mirror_not_ready', 'jd_daily_bill_mirror_not_ready', 'jd_wallet_bill_mirror_not_ready'], true)) { + if (! in_array($exception->getMessage(), [ + 'store_receiving_alipay_bill_mirror_not_ready', + 'store_receiving_bill_filter_options_busy', + 'jd_daily_bill_mirror_not_ready', + 'jd_wallet_bill_mirror_not_ready', + ], true)) { throw $exception; } return response()->json([ - 'message' => $label . '账户账单共享业务镜像正在首次同步,筛选选项稍后可用。', + 'message' => $exception->getMessage() === 'store_receiving_bill_filter_options_busy' + ? $label . '账户账单筛选选项正在生成,请稍后重试。' + : $label . '账户账单共享业务镜像正在首次同步,筛选选项稍后可用。', 'shops' => [], 'bill_items' => [], ], 503); diff --git a/modules/EcommerceFinance/Http/Controllers/AlipayBills.php b/modules/EcommerceFinance/Http/Controllers/AlipayBills.php index 9e091acc..c2e40a9b 100644 --- a/modules/EcommerceFinance/Http/Controllers/AlipayBills.php +++ b/modules/EcommerceFinance/Http/Controllers/AlipayBills.php @@ -94,12 +94,17 @@ class AlipayBills extends Controller 'alipayCSV', StoreReceivingAlipayBillMirrorRepository::DATASET, true )); } catch (RuntimeException $exception) { - if ($exception->getMessage() !== 'store_receiving_alipay_bill_mirror_not_ready') { + if (! in_array($exception->getMessage(), [ + 'store_receiving_alipay_bill_mirror_not_ready', + 'store_receiving_bill_filter_options_busy', + ], true)) { throw $exception; } return response()->json([ - 'message' => '淘系支付宝账单共享业务镜像正在首次同步,筛选选项稍后可用。', + 'message' => $exception->getMessage() === 'store_receiving_bill_filter_options_busy' + ? '淘系支付宝筛选选项正在生成,请稍后重试。' + : '淘系支付宝账单共享业务镜像正在首次同步,筛选选项稍后可用。', 'shops' => [], 'bill_items' => [], ], 503); diff --git a/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php b/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php index 1d433ca9..510d4080 100644 --- a/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php @@ -22,6 +22,8 @@ class StoreReceivingAlipayBillMirrorRepository private const AGGREGATE_CACHE_WAIT_SECONDS = 35; + private const FILTER_OPTIONS_CACHE_SECONDS = 900; + /** 淘系支付宝页面不展示其他平台店铺;头条放心购是镜像中的抖音平台名称。 */ private const EXCLUDED_TAOBAO_SHOP_SITE_PREFIXES = [ '拼多多', '京东', '抖音', '头条放心购', '得物', '唯品会', @@ -101,7 +103,7 @@ class StoreReceivingAlipayBillMirrorRepository $channel, $excludeNonTaobaoPlatforms, ), - fn (): array => $this->summary($query, $filters, $channel), + fn (): array => $this->summary($query, $filters, $channel, ! $useRecordedCount), ); $total = $useRecordedCount ? (int) $recordedCount @@ -190,46 +192,51 @@ class StoreReceivingAlipayBillMirrorRepository throw new RuntimeException('store_receiving_alipay_bill_mirror_not_ready'); } - $channelQuery = $this->billQuery($dataset->targetTable()); - $this->applyChannel($channelQuery, $channel, $dataset->fixedFilters()); - if ($excludeNonTaobaoPlatforms) { - $this->applyTaobaoPlatformScope($channelQuery); - } + return $this->rememberFilterOptions( + $this->filterOptionsCacheKey($datasetKey, $channel, $excludeNonTaobaoPlatforms), + function () use ($dataset, $channel, $excludeNonTaobaoPlatforms): array { + $channelQuery = $this->billQuery($dataset->targetTable()); + $this->applyChannel($channelQuery, $channel, $dataset->fixedFilters()); + if ($excludeNonTaobaoPlatforms) { + $this->applyTaobaoPlatformScope($channelQuery); + } - $shopIds = (clone $channelQuery) - ->whereNotNull('b.shop_id') - ->distinct() - ->pluck('b.shop_id') - ->filter(static fn (mixed $value): bool => $value !== null && $value !== '') - ->all(); + $shopIds = (clone $channelQuery) + ->whereNotNull('b.shop_id') + ->distinct() + ->pluck('b.shop_id') + ->filter(static fn (mixed $value): bool => $value !== null && $value !== '') + ->all(); - $shops = $shopIds === [] - ? collect() - : $this->mirror->connection()->table('ops_jst_shop_info') - ->whereIn('shop_id', $shopIds) - ->whereNotNull('shop_name') - ->orderBy('shop_name') - ->pluck('shop_name'); + $shops = $shopIds === [] + ? collect() + : $this->mirror->connection()->table('ops_jst_shop_info') + ->whereIn('shop_id', $shopIds) + ->whereNotNull('shop_name') + ->orderBy('shop_name') + ->pluck('shop_name'); - $billItems = (clone $channelQuery) - ->whereNotNull('b.fee_name') - ->where('b.fee_name', '<>', '') - ->select('b.fee_name') - ->distinct() - ->orderBy('b.fee_name') - ->pluck('b.fee_name'); + $billItems = (clone $channelQuery) + ->whereNotNull('b.fee_name') + ->where('b.fee_name', '<>', '') + ->select('b.fee_name') + ->distinct() + ->orderBy('b.fee_name') + ->pluck('b.fee_name'); - $normalize = static fn ($values): array => collect($values) - ->map(static fn (mixed $value): string => trim((string) $value)) - ->filter() - ->unique() - ->values() - ->all(); + $normalize = static fn ($values): array => collect($values) + ->map(static fn (mixed $value): string => trim((string) $value)) + ->filter() + ->unique() + ->values() + ->all(); - return [ - 'shops' => $normalize($shops), - 'bill_items' => $normalize($billItems), - ]; + return [ + 'shops' => $normalize($shops), + 'bill_items' => $normalize($billItems), + ]; + }, + ); } /** Read bounded pages so a full export does not sort/buffer millions of rows at once. */ @@ -332,6 +339,7 @@ class StoreReceivingAlipayBillMirrorRepository Builder $billQuery, array $filters, string|array|null $channel = null, + bool $includeCount = true, ): array { if ($channel === null && $this->dailySummarySupports($filters)) { @@ -342,7 +350,7 @@ class StoreReceivingAlipayBillMirrorRepository } $aggregate = (clone $billQuery)->selectRaw( - "COUNT(*) AS aggregate_count, " + ($includeCount ? 'COUNT(*) AS aggregate_count, ' : '') . "COALESCE(SUM(CASE WHEN b.inout_type = '1' THEN ABS(COALESCE(b.amount, 0)) ELSE 0 END), 0) AS income, " . "COALESCE(SUM(CASE WHEN b.inout_type = '-1' THEN ABS(COALESCE(b.amount, 0)) ELSE 0 END), 0) AS expense" ); @@ -351,7 +359,7 @@ class StoreReceivingAlipayBillMirrorRepository return [ 'income' => round((float) ($row->income ?? 0), 2), 'expense' => round((float) ($row->expense ?? 0), 2), - 'count' => (int) ($row->aggregate_count ?? 0), + 'count' => $includeCount ? (int) ($row->aggregate_count ?? 0) : null, ]; } @@ -870,6 +878,54 @@ class StoreReceivingAlipayBillMirrorRepository } } + private function filterOptionsCacheKey( + string $datasetKey, + string|array $channel, + bool $excludeNonTaobaoPlatforms, + ): string { + $normalizedChannel = is_array($channel) ? array_map('strval', $channel) : $channel; + if (is_array($normalizedChannel)) { + sort($normalizedChannel); + } + + return 'store-receiving-bills:filter-options:v1:' . hash('sha256', (string) json_encode([ + 'dataset' => $datasetKey, + 'channel' => $normalizedChannel, + 'exclude_non_taobao' => $excludeNonTaobaoPlatforms, + ], JSON_UNESCAPED_UNICODE)); + } + + /** @param callable():array{shops:array,bill_items:array} $resolver */ + private function rememberFilterOptions(string $key, callable $resolver): array + { + $cached = Cache::get($key); + if (is_array($cached)) { + return $cached; + } + + try { + return Cache::lock($key . ':lock', self::AGGREGATE_CACHE_LOCK_SECONDS) + ->block(self::AGGREGATE_CACHE_WAIT_SECONDS, function () use ($key, $resolver): array { + $cached = Cache::get($key); + if (is_array($cached)) { + return $cached; + } + + $resolved = $resolver(); + Cache::put($key, $resolved, self::FILTER_OPTIONS_CACHE_SECONDS); + + return $resolved; + }); + } catch (LockTimeoutException $exception) { + $cached = Cache::get($key); + if (is_array($cached)) { + return $cached; + } + + throw new RuntimeException('store_receiving_bill_filter_options_busy', 0, $exception); + } + } + private function escapeLike(string $value): string { return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $value); diff --git a/resources/assets/js/components/business/TaobaoAlipayBills.vue b/resources/assets/js/components/business/TaobaoAlipayBills.vue index e6c3c55f..97b27711 100644 --- a/resources/assets/js/components/business/TaobaoAlipayBills.vue +++ b/resources/assets/js/components/business/TaobaoAlipayBills.vue @@ -154,7 +154,7 @@ export default { @media(max-width:900px){.table-workspace{padding:.75rem}.tw-stats{grid-template-columns:repeat(2,minmax(0,1fr))}.tw-filter>label,.tw-filter .tw-keyword{width:100%}.tw-filter input,.tw-filter select{width:100%}.tw-date input{min-width:0}.tw-actions{margin-left:0}.tw-toolbar,.tw-pager{align-items:flex-start;flex-direction:column}.tw-tools{justify-content:flex-start}.tw-scroll{max-height:58vh;width:100%}}@media(max-width:520px){.tw-stats{grid-template-columns:1fr}.tw-date span{align-items:stretch;flex-direction:column}.tw-tools>select{min-width:0;max-width:100%}.tw-page-actions{flex-wrap:wrap}} .tw-toolbar { flex-wrap: wrap; } .tw-tools { flex: 1 1 24rem; min-width: 0; } -.tw-filter { grid-template-columns: repeat(4, minmax(0, 1fr)); } +.tw-filter { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); } .tw-filter .tw-date { grid-column: span 2; } .tw-filter ::v-deep .el-select { width: 100%; min-width: 0; } .tw-filter ::v-deep .el-select .el-input__inner { height: 34px; min-width: 0; padding-right: 30px; border-color: #d8d2c7; border-radius: 8px; color: #171717; font: inherit; } diff --git a/tests/Unit/EcommerceFinance/StoreReceivingAlipayBillMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/StoreReceivingAlipayBillMirrorRepositoryTest.php index 0a0b5804..ea2ae5e1 100644 --- a/tests/Unit/EcommerceFinance/StoreReceivingAlipayBillMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/StoreReceivingAlipayBillMirrorRepositoryTest.php @@ -469,6 +469,7 @@ class StoreReceivingAlipayBillMirrorRepositoryTest extends TestCase public function test_alipay_filter_options_match_the_visible_taobao_channel_and_platform_scope(): void { + Cache::flush(); $this->markReady(); $db = DB::connection('business_mirror'); $db->table('ops_jst_shop_info')->insert([ @@ -482,12 +483,25 @@ class StoreReceivingAlipayBillMirrorRepositoryTest extends TestCase $this->row(3, 9, '2026-08-01 11:00:00', '1', 30, 'OtherChannel', '其他渠道费用'), ]); + $db->flushQueryLog(); + $db->enableQueryLog(); $options = $this->repository->filterOptionsForChannel( 'alipayCSV', StoreReceivingAlipayBillMirrorRepository::DATASET, true ); + $cachedOptions = $this->repository->filterOptionsForChannel( + 'alipayCSV', StoreReceivingAlipayBillMirrorRepository::DATASET, true + ); $this->assertSame(['天猫A店'], $options['shops']); $this->assertSame(['交易收款'], $options['bill_items']); + $this->assertSame($options, $cachedOptions); + $optionQueries = array_filter( + $db->getQueryLog(), + static fn (array $query): bool => str_contains($query['query'], 'select distinct') + && (str_contains($query['query'], '"b"."shop_id"') || str_contains($query['query'], '"b"."fee_name"')), + ); + $this->assertCount(2, $optionQueries, '相同筛选范围只应扫描一次店铺和账单项目选项。'); + $db->disableQueryLog(); } public function test_shared_dataset_keeps_channel_predicates_for_all_query_entry_points(): void