diff --git a/app/Console/Commands/WarmReportCaches.php b/app/Console/Commands/WarmReportCaches.php index 5fb087af..52fbfd31 100644 --- a/app/Console/Commands/WarmReportCaches.php +++ b/app/Console/Commands/WarmReportCaches.php @@ -4,6 +4,7 @@ namespace App\Console\Commands; use Illuminate\Console\Command; use Illuminate\Support\Facades\Log; +use Modules\EcommerceFinance\Repositories\SmallPaymentWorkOrderMirrorRepository; use Modules\EcommerceFinance\Repositories\StoreReceivingAlipayBillMirrorRepository; use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageMirrorRepository; use Throwable; @@ -12,11 +13,12 @@ class WarmReportCaches extends Command { protected $signature = 'business-mirror:warm-report-caches'; - protected $description = '预热默认进销存与淘系支付宝报表,避免镜像切换后的首批请求重复扫表'; + protected $description = '预热默认进销存、淘系支付宝与小额打款报表,避免镜像切换后的首批请求重复扫表'; public function handle( ThirdPartyWarehouseStorageMirrorRepository $inventory, StoreReceivingAlipayBillMirrorRepository $alipay, + SmallPaymentWorkOrderMirrorRepository $smallPayments, ): int { $failed = false; @@ -54,6 +56,30 @@ class WarmReportCaches extends Command $this->error('淘系支付宝报表缓存预热失败。'); } + try { + $result = $smallPayments->paginate([ + 'from' => now()->subDays(29)->format('Y-m-d') . 'T00:00:00', + 'to' => now()->format('Y-m-d') . 'T23:59:59', + 'work_order_no' => '', + 'shop_name' => '', + 'transaction_no' => '', + 'online_order_no' => '', + 'status' => '', + 'payment_reason' => '', + ], 1, 25); + $options = $smallPayments->filterOptions(); + $this->info(sprintf( + '小额打款首页已预热,共 %d 行、%d 个店铺、%d 个打款原因。', + $result['total'], + count($options['shops']), + count($options['payment_reasons']), + )); + } catch (Throwable $exception) { + $failed = true; + Log::error('小额打款报表缓存预热失败。', ['exception' => $exception::class]); + $this->error('小额打款报表缓存预热失败。'); + } + return $failed ? self::FAILURE : self::SUCCESS; } } diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index c1bb48ba..510abf55 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -1824,6 +1824,9 @@ class Pages extends Controller ]); $syncStatus = ['code' => 'unavailable', 'last_succeeded_at' => null]; $pagePaymentAmount = '0.00'; + $filteredPaymentAmount = '0.00'; + $shopNames = []; + $paymentReasons = []; $error = null; $pending = false; $indexPending = false; @@ -1848,6 +1851,10 @@ class Pages extends Controller ['path' => $request->url(), 'query' => $request->query()], ); $pagePaymentAmount = (string) ($result['page_payment_amount'] ?? '0.00'); + $filteredPaymentAmount = (string) ($result['filtered_payment_amount'] ?? '0.00'); + $filterOptions = $this->smallPaymentWorkOrders->filterOptions(); + $shopNames = $filterOptions['shops']; + $paymentReasons = $filterOptions['payment_reasons']; } } } catch (Throwable $exception) { @@ -1861,6 +1868,13 @@ class Pages extends Controller 'rows' => $rows, 'smallPaymentSyncStatus' => $syncStatus, 'smallPaymentPageAmount' => $pagePaymentAmount, + 'smallPaymentFilteredAmount' => $filteredPaymentAmount, + 'smallPaymentShops' => $shopNames, + 'smallPaymentReasons' => $paymentReasons, + 'smallPaymentStatuses' => [ + '待审核', '审核驳回', '待确认', '确认中', '已完成', '已作废', + '支付宝已打款', '银行卡已打款', '已打款', + ], 'smallPaymentError' => $error, 'smallPaymentPending' => $pending, 'smallPaymentIndexPending' => $indexPending, @@ -1909,12 +1923,15 @@ class Pages extends Controller */ private function smallPaymentWorkOrderFilters(Request $request): array { - $defaultTo = now()->toDateString(); - $defaultFrom = now()->subDays(29)->toDateString(); - $from = $this->dateFilter($request->query('from', $defaultFrom)) ?: $defaultFrom; - $to = $this->dateFilter($request->query('to', $defaultTo)) ?: $defaultTo; + $defaultTo = now()->format('Y-m-d') . 'T23:59:59'; + $defaultFrom = now()->subDays(29)->format('Y-m-d') . 'T00:00:00'; + $rawFrom = $request->query('from', $defaultFrom); + $rawTo = $request->query('to', $defaultTo); + $from = $this->smallPaymentDateTimeFilter($rawFrom, false) ?: $defaultFrom; + $to = $this->smallPaymentDateTimeFilter($rawTo, true) ?: $defaultTo; if ($from > $to) { - [$from, $to] = [$to, $from]; + $from = $this->smallPaymentDateTimeFilter($rawTo, false) ?: $defaultFrom; + $to = $this->smallPaymentDateTimeFilter($rawFrom, true) ?: $defaultTo; } $text = static fn (mixed $value, int $length = 100): string => mb_substr(trim((string) $value), 0, $length); @@ -1931,6 +1948,35 @@ class Pages extends Controller ]; } + private function smallPaymentDateTimeFilter(mixed $value, bool $upperBound): string + { + $value = trim((string) $value); + if (preg_match('/^(\d{4}-\d{2}-\d{2})$/', $value, $dateMatch) === 1) { + if ($this->dateFilter($dateMatch[1]) === '') { + return ''; + } + + return $dateMatch[1] . ($upperBound ? 'T23:59:59' : 'T00:00:00'); + } + + if (preg_match( + '/^(\d{4}-\d{2}-\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?$/', + $value, + $matches, + ) !== 1 || $this->dateFilter($matches[1]) === '') { + return ''; + } + + $hour = (int) $matches[2]; + $minute = (int) $matches[3]; + $second = isset($matches[4]) ? (int) $matches[4] : ($upperBound ? 59 : 0); + if ($hour > 23 || $minute > 59 || $second > 59) { + return ''; + } + + return sprintf('%sT%02d:%02d:%02d', $matches[1], $hour, $minute, $second); + } + private function streamSmallPaymentWorkOrderCsv(iterable $rows, array $filters, array $redirectParameters, mixed $requestedFields = null) { $columns = [ @@ -1991,10 +2037,13 @@ class Pages extends Controller ->with('error', '小额打款工单导出暂时无法完成,请稍后重试。'); } + $filenameFrom = str_replace(['T', ':'], ['_', '-'], $filters['from']); + $filenameTo = str_replace(['T', ':'], ['_', '-'], $filters['to']); + return response()->streamDownload(function () use ($csv): void { fpassthru($csv); fclose($csv); - }, 'small_payment_work_orders_' . $filters['from'] . '_' . $filters['to'] . '.csv', [ + }, 'small_payment_work_orders_' . $filenameFrom . '_' . $filenameTo . '.csv', [ 'Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store', ]); diff --git a/modules/EcommerceFinance/Repositories/SmallPaymentWorkOrderMirrorRepository.php b/modules/EcommerceFinance/Repositories/SmallPaymentWorkOrderMirrorRepository.php index 74a6dd21..8ea87399 100644 --- a/modules/EcommerceFinance/Repositories/SmallPaymentWorkOrderMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/SmallPaymentWorkOrderMirrorRepository.php @@ -3,9 +3,12 @@ namespace Modules\EcommerceFinance\Repositories; use Carbon\CarbonInterface; +use Illuminate\Contracts\Cache\LockTimeoutException; use Illuminate\Database\ConnectionInterface; use Illuminate\Database\Query\Builder; use Illuminate\Support\Carbon; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Schema; use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset; use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry; @@ -21,6 +24,16 @@ class SmallPaymentWorkOrderMirrorRepository public const SMALL_PAYMENT_WORK_TYPE_ID = '82D94F5C54544EE3833CD8A3AB8B9F73'; + private const QUERY_TIMEOUT_SECONDS = 30; + + private const SUMMARY_CACHE_SECONDS = 900; + + private const CACHE_LOCK_SECONDS = 180; + + private const CACHE_WAIT_SECONDS = 10; + + private const FILTER_OPTIONS_CACHE_SECONDS = 900; + private const REQUIRED_QUERY_INDEXES = [ self::WORK_ORDER_DATASET => [ ['woTypeId', 'woNo', 'Id'], @@ -40,6 +53,9 @@ class SmallPaymentWorkOrderMirrorRepository private const COMPLETED_WORK_ORDER_STATUSES = ['CONFIRMED', '已完成', '已确认']; + /** 打款金额在 businessFieldList 中的候选字段名(按优先级) */ + private const PAYMENT_AMOUNT_FIELDS = ['打款金额', '付款金额', '小额打款金额', 'amountField']; + private readonly ConnectionInterface $connection; public function __construct( @@ -147,7 +163,7 @@ class SmallPaymentWorkOrderMirrorRepository /** * @param array{from:string,to:string,work_order_no:string,shop_name:string,transaction_no:string,online_order_no:string,status:string,payment_reason:string} $filters - * @return array{data:list>,total:int,page_payment_amount:string} + * @return array{data:list>,total:int,page_payment_amount:string,filtered_payment_amount:string} */ public function paginate(array $filters, int $page, int $perPage): array { @@ -158,23 +174,96 @@ class SmallPaymentWorkOrderMirrorRepository $query = $this->baseQuery(); $this->applyFilters($query, $filters); - $total = (clone $query)->count(); - $rows = $query + $summary = $this->filteredSummary($filters); + $rows = $this->boundedGet($query ->select($this->selectColumns()) ->orderByDesc('w.completeTime') ->orderByDesc('p.woNo') - ->forPage($page, $perPage) - ->get() + ->forPage($page, $perPage)) ->map(fn (object $row): array => $this->decorate((array) $row)) ->all(); return [ 'data' => $rows, - 'total' => $total, + 'total' => $summary['count'], 'page_payment_amount' => $this->sumMoney(array_column($rows, 'payment_amount')), + 'filtered_payment_amount' => $summary['payment_amount'], ]; } + /** + * 统计当前筛选条件下全部记录的打款金额合计。 + * + * 只取金额所需字段,按 Id 做键集分块批量读取:不使用逐行游标(本地/远端库时每次取行都是一次网络往返), + * 也不复用导出用的分页排序查询(会为大结果集重复执行连接与排序)。 + * + * @param array{from:string,to:string,work_order_no:string,shop_name:string,transaction_no:string,online_order_no:string,status:string,payment_reason:string} $filters + */ + public function filteredPaymentAmount(array $filters, int $chunkSize = 1000): string + { + return $this->filteredSummary($filters, $chunkSize)['payment_amount']; + } + + /** + * @param array{from:string,to:string,work_order_no:string,shop_name:string,transaction_no:string,online_order_no:string,status:string,payment_reason:string} $filters + * @return array{count:int,payment_amount:string} + */ + private function filteredSummary(array $filters, int $chunkSize = 1000): array + { + $this->ensureReady(); + + $key = $this->summaryCacheKey($filters); + + return $this->rememberArray( + $key, + self::SUMMARY_CACHE_SECONDS, + fn (): array => $this->calculateFilteredSummary($filters, $chunkSize), + 'small_payment_work_order_summary_busy', + ); + } + + /** + * @param array{from:string,to:string,work_order_no:string,shop_name:string,transaction_no:string,online_order_no:string,status:string,payment_reason:string} $filters + * @return array{count:int,payment_amount:string} + */ + private function calculateFilteredSummary(array $filters, int $chunkSize): array + { + $this->ensureReady(); + + $query = $this->baseQuery(); + $this->applyFilters($query, $filters); + + $chunkSize = min(max($chunkSize, 100), 5000); + $cents = 0; + $count = 0; + $lastId = null; + + do { + $chunk = $this->boundedGet((clone $query) + ->select(['w.Id AS source_id', 'w.businessFieldList']) + ->when($lastId !== null, static fn (Builder $q): Builder => $q->where('w.Id', '>', $lastId)) + ->orderBy('w.Id') + ->limit($chunkSize)); + + foreach ($chunk as $row) { + $cents += $this->centsFromMoney($this->paymentAmountOf($row->businessFieldList ?? null)); + } + + $count += $chunk->count(); + $lastId = $chunk->isEmpty() ? null : $chunk->last()->source_id; + } while ($chunk->count() === $chunkSize); + + return ['count' => $count, 'payment_amount' => $this->formatCents($cents)]; + } + + private function paymentAmountOf(mixed $businessFieldList): ?string + { + return $this->money($this->field( + $this->businessFields($businessFieldList), + self::PAYMENT_AMOUNT_FIELDS, + )); + } + /** * @param array{from:string,to:string,work_order_no:string,shop_name:string,transaction_no:string,online_order_no:string,status:string,payment_reason:string} $filters * @return iterable> @@ -204,6 +293,72 @@ class SmallPaymentWorkOrderMirrorRepository } while ($rows->count() === $chunkSize); } + /** + * 返回当前小额打款镜像中出现过的店铺名称列表,用于筛选下拉/自动补全。 + * + * @return list + */ + public function shopNames(): array + { + return $this->filterOptions()['shops']; + } + + /** + * 返回当前小额打款镜像中出现过的打款原因列表,用于筛选下拉/自动补全。 + * + * @return list + */ + public function paymentReasons(): array + { + return $this->filterOptions()['payment_reasons']; + } + + /** @return array{shops:list,payment_reasons:list} */ + public function filterOptions(): array + { + if (! $this->available() || ! $this->ready() || ! $this->queryReady()) { + return ['shops' => [], 'payment_reasons' => []]; + } + + $table = $this->dataset(self::PAYMENT_MAP_DATASET)->targetTable(); + + try { + return $this->rememberArray( + 'small-payment-work-orders:filter-options:v1', + self::FILTER_OPTIONS_CACHE_SECONDS, + function () use ($table): array { + $shops = $this->boundedGet($this->connection->table($table) + ->where('woTypeId', self::SMALL_PAYMENT_WORK_TYPE_ID) + ->whereNotNull('shop_name') + ->where('shop_name', '<>', '') + ->distinct() + ->select('shop_name') + ->orderBy('shop_name') + ->limit(1000)) + ->pluck('shop_name') + ->map(static fn (mixed $name): string => (string) $name) + ->all(); + $reasons = $this->boundedGet($this->connection->table($table) + ->where('woTypeId', self::SMALL_PAYMENT_WORK_TYPE_ID) + ->whereNotNull('payment_reason') + ->where('payment_reason', '<>', '') + ->distinct() + ->select('payment_reason') + ->orderBy('payment_reason') + ->limit(200)) + ->pluck('payment_reason') + ->map(static fn (mixed $reason): string => (string) $reason) + ->all(); + + return ['shops' => $shops, 'payment_reasons' => $reasons]; + }, + 'small_payment_work_order_filter_options_busy', + ); + } catch (Throwable) { + return ['shops' => [], 'payment_reasons' => []]; + } + } + private function ensureReady(): void { if (! $this->ready() || ! $this->queryReady()) { @@ -250,8 +405,11 @@ class SmallPaymentWorkOrderMirrorRepository /** @param array $filters */ private function applyFilters(Builder $query, array $filters): void { - $query->where('w.completeTime', '>=', $filters['from'] . ' 00:00:00') - ->where('w.completeTime', '<', Carbon::parse($filters['to'])->addDay()->format('Y-m-d 00:00:00')); + $from = $this->filterDateTime($filters['from'], false); + $to = $this->filterDateTime($filters['to'], true); + + $query->where('w.completeTime', '>=', $from) + ->where('w.completeTime', '<', $to); if ($filters['work_order_no'] !== '') { $query->whereRaw("p.woNo LIKE ? ESCAPE '!'", [$this->contains($filters['work_order_no'])]); @@ -315,9 +473,7 @@ class SmallPaymentWorkOrderMirrorRepository $row['alipay_batch_no'] = $this->field($fields, [ '支付宝批次号', '支付宝打款批次号', '打款批次号', '批次号', 'alipayBatchNo', 'batchNo', ]); - $row['payment_amount'] = $this->money($this->field($fields, [ - '打款金额', '付款金额', '小额打款金额', 'amountField', - ])); + $row['payment_amount'] = $this->money($this->field($fields, self::PAYMENT_AMOUNT_FIELDS)); $row['online_order_no'] = $this->firstFilled( $this->field($fields, ['线上订单号', '线上单号', '订单号', 'orderField']), $row['mapped_online_order_no'] ?? null, @@ -460,14 +616,26 @@ class SmallPaymentWorkOrderMirrorRepository { $cents = 0; foreach ($amounts as $amount) { - if ($amount === null || ! preg_match('/^(-?)(\d+)\.(\d{2})$/', (string) $amount, $matches)) { - continue; - } - - $value = ((int) $matches[2] * 100) + (int) $matches[3]; - $cents += $matches[1] === '-' ? -$value : $value; + $cents += $this->centsFromMoney($amount); } + return $this->formatCents($cents); + } + + /** 将 '123.45' / '-123.45' 形式的金额字符串转成分(无效值计 0)。 */ + private function centsFromMoney(mixed $amount): int + { + if ($amount === null || ! preg_match('/^(-?)(\d+)\.(\d{2})$/', (string) $amount, $matches)) { + return 0; + } + + $value = ((int) $matches[2] * 100) + (int) $matches[3]; + + return $matches[1] === '-' ? -$value : $value; + } + + private function formatCents(int $cents): string + { $sign = $cents < 0 ? '-' : ''; $absolute = abs($cents); @@ -543,6 +711,90 @@ class SmallPaymentWorkOrderMirrorRepository ], static fn (?string $value): bool => $value !== null && $value !== ''))); } + /** @param array $filters */ + private function summaryCacheKey(array $filters): string + { + $normalized = array_map(static fn (mixed $value): string => trim((string) $value), $filters); + ksort($normalized); + + return 'small-payment-work-orders:summary:v1:' . hash( + 'sha256', + (string) json_encode($normalized, JSON_UNESCAPED_UNICODE), + ); + } + + /** @param callable():array $resolver @return array */ + private function rememberArray(string $key, int $seconds, callable $resolver, string $busyMessage): array + { + $cached = Cache::get($key); + if (is_array($cached)) { + return $cached; + } + + try { + return Cache::lock($key . ':lock', self::CACHE_LOCK_SECONDS) + ->block(self::CACHE_WAIT_SECONDS, function () use ($key, $seconds, $resolver): array { + $cached = Cache::get($key); + if (is_array($cached)) { + return $cached; + } + + $resolved = $resolver(); + Cache::put($key, $resolved, $seconds); + + return $resolved; + }); + } catch (LockTimeoutException $exception) { + $cached = Cache::get($key); + if (is_array($cached)) { + return $cached; + } + + throw new RuntimeException($busyMessage, 0, $exception); + } + } + + /** @return Collection */ + private function boundedGet(Builder $query): Collection + { + $connection = $query->getConnection(); + if ($connection->getDriverName() !== 'mysql') { + return $query->get(); + } + + return collect($connection->select( + 'SET STATEMENT max_statement_time=' . self::QUERY_TIMEOUT_SECONDS . ' FOR ' . $query->toSql(), + $query->getBindings(), + )); + } + + /** + * 将前端传来的日期或日期时间字符串统一为 SQL 比较用的日期时间。 + * 仅日期时:from 取 00:00:00,to 取次日 00:00:00(半开区间)。 + * 含分钟时:to 取下一分钟;含秒时:to 取下一秒,完整包含用户选择的最小时间单位。 + */ + private function filterDateTime(string $value, bool $isUpperBound): string + { + $value = trim($value); + $hasTime = str_contains($value, ':') || str_contains($value, 'T'); + + if (! $hasTime) { + return $isUpperBound + ? Carbon::parse($value)->addDay()->format('Y-m-d 00:00:00') + : $value . ' 00:00:00'; + } + + $carbon = Carbon::parse($value); + + if (! $isUpperBound) { + return $carbon->format('Y-m-d H:i:s'); + } + + return preg_match('/:\d{2}:\d{2}$/', str_replace('T', ' ', $value)) === 1 + ? $carbon->clone()->addSecond()->format('Y-m-d H:i:s') + : $carbon->clone()->addMinute()->format('Y-m-d H:i:s'); + } + private function contains(string $value): string { return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $value) . '%'; diff --git a/resources/views/business/pages/refunds/small-payment-work-orders.blade.php b/resources/views/business/pages/refunds/small-payment-work-orders.blade.php index 48f208fa..14efb903 100644 --- a/resources/views/business/pages/refunds/small-payment-work-orders.blade.php +++ b/resources/views/business/pages/refunds/small-payment-work-orders.blade.php @@ -36,6 +36,11 @@ default => '业务镜像尚未建立', }; $lastSucceededAt = $smallPaymentSyncStatus['last_succeeded_at'] ?? null; + $datetimeLocalValue = static function (string $value): string { + $value = str_replace(' ', 'T', trim($value)); + + return substr(str_contains($value, 'T') ? $value : $value . 'T00:00', 0, 16); + }; @endphp
@@ -120,12 +125,12 @@ data-filter-contract="small-payment-work-orders.filters" >
- - + +
- - + +
@@ -133,7 +138,12 @@
- + + + @foreach ($smallPaymentShops as $shopName) +
@@ -145,11 +155,21 @@
- +
- + + + @foreach ($smallPaymentReasons as $reasonOption) +
- 本页打款金额合计 - ¥ {{ $smallPaymentPageAmount }} + 筛选出的打款金额合计 + ¥ {{ $smallPaymentFilteredAmount }}
diff --git a/tests/Feature/Business/SmallPaymentWorkOrderPageTest.php b/tests/Feature/Business/SmallPaymentWorkOrderPageTest.php index f075fea8..51645dfe 100644 --- a/tests/Feature/Business/SmallPaymentWorkOrderPageTest.php +++ b/tests/Feature/Business/SmallPaymentWorkOrderPageTest.php @@ -37,7 +37,7 @@ class SmallPaymentWorkOrderPageTest extends FeatureTestCase $repository->shouldReceive('queryReady')->once()->andReturnTrue(); $repository->shouldReceive('paginate')->once()->withArgs( static fn (array $filters, int $page, int $limit): bool => $filters === [ - 'from' => '2026-09-01', 'to' => '2026-09-03', + 'from' => '2026-09-01T08:30:00', 'to' => '2026-09-03T17:45:59', 'work_order_no' => 'GD-001', 'shop_name' => '京东旗舰店', 'transaction_no' => 'BATCH-001', 'online_order_no' => 'ORDER-001', 'status' => '已完成', 'payment_reason' => '售后补偿', @@ -60,6 +60,11 @@ class SmallPaymentWorkOrderPageTest extends FeatureTestCase ]], 'total' => 26, 'page_payment_amount' => '50.00', + 'filtered_payment_amount' => '50.00', + ]); + $repository->shouldReceive('filterOptions')->once()->andReturn([ + 'shops' => ['京东旗舰店'], + 'payment_reasons' => ['售后补偿'], ]); $this->app->instance(SmallPaymentWorkOrderMirrorRepository::class, $repository); @@ -67,7 +72,7 @@ class SmallPaymentWorkOrderPageTest extends FeatureTestCase 'company_id' => $this->company->id, 'section' => 'financial-operations', 'page' => 'small-payment-work-orders', ]) . '?' . http_build_query([ - 'from' => '2026-09-01', 'to' => '2026-09-03', 'work_order_no' => 'GD-001', + 'from' => '2026-09-01T08:30', 'to' => '2026-09-03T17:45', 'work_order_no' => 'GD-001', 'shop_name' => '京东旗舰店', 'transaction_no' => 'BATCH-001', 'online_order_no' => 'ORDER-001', 'status' => '已完成', 'payment_reason' => '售后补偿', 'page' => 2, @@ -79,8 +84,10 @@ class SmallPaymentWorkOrderPageTest extends FeatureTestCase ->assertSee('data-filter-contract="small-payment-work-orders.filters"', false) ->assertSee('data-sync-state="syncing"', false) ->assertSeeText('业务镜像同步中,当前显示最近成功数据') - ->assertSeeText('完成开始日期') - ->assertSeeText('完成结束日期') + ->assertSeeText('完成开始时间') + ->assertSeeText('完成结束时间') + ->assertSee('value="2026-09-01T08:30"', false) + ->assertSee('value="2026-09-03T17:45"', false) ->assertSee('aria-label="聚水潭小额打款工单"', false) ->assertSeeText('GD-001') ->assertSeeText('支付宝已打款') @@ -89,7 +96,7 @@ class SmallPaymentWorkOrderPageTest extends FeatureTestCase ->assertSeeText('第 2 / 2 页') ->assertSee('workspace/financial-operations/small-payment-work-orders/export', false) ->assertSee('data-finance-export-base=', false) - ->assertSee('data-finance-export-query="{"from":"2026-09-01","to":"2026-09-03"', false) + ->assertSee('data-finance-export-query="{"from":"2026-09-01T08:30:00","to":"2026-09-03T17:45:59"', false) ->assertDontSee('data-finance-export-query="{&quot;', false) ->assertSee('data-export-field="work_order_no"', false) ->assertSee('value="ORDER-001"', false); @@ -123,7 +130,7 @@ class SmallPaymentWorkOrderPageTest extends FeatureTestCase 'from' => '2026-09-01', 'to' => '2026-09-03', ])); - $response->assertOk()->assertDownload('small_payment_work_orders_2026-09-01_2026-09-03.csv'); + $response->assertOk()->assertDownload('small_payment_work_orders_2026-09-01_00-00-00_2026-09-03_23-59-59.csv'); $content = $response->streamedContent(); $this->assertStringStartsWith("\xEF\xBB\xBF", $content); $this->assertStringContainsString('工单编号', $content); diff --git a/tests/Unit/EcommerceFinance/SmallPaymentWorkOrderMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/SmallPaymentWorkOrderMirrorRepositoryTest.php index c1acea5c..f31a8019 100644 --- a/tests/Unit/EcommerceFinance/SmallPaymentWorkOrderMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/SmallPaymentWorkOrderMirrorRepositoryTest.php @@ -4,6 +4,7 @@ namespace Tests\Unit\EcommerceFinance; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Schema; use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository; use Modules\EcommerceFinance\Repositories\SmallPaymentWorkOrderMirrorRepository; @@ -41,6 +42,8 @@ class SmallPaymentWorkOrderMirrorRepositoryTest extends TestCase ], ], ]); + Cache::setDefaultDriver('array'); + Cache::flush(); DB::purge('business_mirror'); $schema = Schema::connection('business_mirror'); @@ -178,6 +181,7 @@ class SmallPaymentWorkOrderMirrorRepositoryTest extends TestCase $this->assertSame(1, $result['total']); $this->assertSame('50.01', $result['page_payment_amount']); + $this->assertSame('50.01', $result['filtered_payment_amount']); $this->assertSame('GD-001', $result['data'][0]['work_order_no']); $this->assertSame('BATCH-001', $result['data'][0]['alipay_batch_no']); $this->assertSame('已完成', $result['data'][0]['work_order_status']); @@ -196,6 +200,7 @@ class SmallPaymentWorkOrderMirrorRepositoryTest extends TestCase $this->assertSame(2, $result['total']); $this->assertSame('GD-001', $result['data'][0]['work_order_no']); $this->assertSame('50.01', $result['page_payment_amount']); + $this->assertSame('70.01', $result['filtered_payment_amount']); } public function test_export_uses_same_filters_and_parsed_shape(): void @@ -241,6 +246,43 @@ class SmallPaymentWorkOrderMirrorRepositoryTest extends TestCase $this->assertSame('支付宝已打款', $result['data'][0]['payment_status']); } + public function test_datetime_minute_bounds_include_the_selected_end_minute(): void + { + $result = $this->repository->paginate($this->filters([ + 'from' => '2026-09-03T10:00', + 'to' => '2026-09-03T10:00', + ]), 1, 25); + + $this->assertSame(1, $result['total']); + $this->assertSame('GD-002', $result['data'][0]['work_order_no']); + } + + public function test_summary_and_filter_options_reuse_cached_results(): void + { + $scanQueries = []; + DB::connection('business_mirror')->listen(static function ($query) use (&$scanQueries): void { + $sql = strtolower((string) $query->sql); + if (str_contains($sql, 'businessfieldlist') || str_contains($sql, 'select distinct')) { + $scanQueries[] = $sql; + } + }); + + $filters = $this->filters(); + $firstAmount = $this->repository->filteredPaymentAmount($filters); + $firstOptions = $this->repository->filterOptions(); + $queriesAfterFirstRead = count($scanQueries); + $secondAmount = $this->repository->filteredPaymentAmount($filters); + $secondOptions = $this->repository->filterOptions(); + + $this->assertSame('70.01', $firstAmount); + $this->assertSame($firstAmount, $secondAmount); + $this->assertSame($firstOptions, $secondOptions); + $this->assertSame(['京东旗舰店', '天猫旗舰店'], $firstOptions['shops']); + $this->assertSame(['售后补偿', '少收多收'], $firstOptions['payment_reasons']); + $this->assertGreaterThan(0, $queriesAfterFirstRead); + $this->assertSame($queriesAfterFirstRead, count($scanQueries)); + } + public function test_unready_state_is_explicit(): void { DB::connection('business_mirror')->table('business_mirror_table_states')