整合小额打款筛选与汇总性能保护 #271

Merged
WuYongJie merged 3 commits from feature/WuYongJie-small-payment-filter-integration into main 2026-09-24 16:14:18 +08:00
6 changed files with 435 additions and 39 deletions

View File

@ -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;
}
}

View File

@ -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',
]);

View File

@ -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<array<string,mixed>>,total:int,page_payment_amount:string}
* @return array{data:list<array<string,mixed>>,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<int,array<string,mixed>>
@ -204,6 +293,72 @@ class SmallPaymentWorkOrderMirrorRepository
} while ($rows->count() === $chunkSize);
}
/**
* 返回当前小额打款镜像中出现过的店铺名称列表,用于筛选下拉/自动补全。
*
* @return list<string>
*/
public function shopNames(): array
{
return $this->filterOptions()['shops'];
}
/**
* 返回当前小额打款镜像中出现过的打款原因列表,用于筛选下拉/自动补全。
*
* @return list<string>
*/
public function paymentReasons(): array
{
return $this->filterOptions()['payment_reasons'];
}
/** @return array{shops:list<string>,payment_reasons:list<string>} */
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<string,string> $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<string,string> $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<string,mixed> $resolver @return array<string,mixed> */
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<int,object> */
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) . '%';

View File

@ -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
<div class="upstream-data-page upstream-data-page--spaced" data-small-payment-work-orders="true">
@ -120,12 +125,12 @@
data-filter-contract="small-payment-work-orders.filters"
>
<div class="upstream-data-field">
<label for="small-payment-from">完成开始日期</label>
<input id="small-payment-from" type="date" name="from" value="{{ $filters['from'] }}">
<label for="small-payment-from">完成开始时间</label>
<input id="small-payment-from" type="datetime-local" name="from" value="{{ $datetimeLocalValue($filters['from']) }}">
</div>
<div class="upstream-data-field">
<label for="small-payment-to">完成结束日期</label>
<input id="small-payment-to" type="date" name="to" value="{{ $filters['to'] }}">
<label for="small-payment-to">完成结束时间</label>
<input id="small-payment-to" type="datetime-local" name="to" value="{{ $datetimeLocalValue($filters['to']) }}">
</div>
<div class="upstream-data-field">
<label for="small-payment-work-order-no">工单编号</label>
@ -133,7 +138,12 @@
</div>
<div class="upstream-data-field">
<label for="small-payment-shop">店铺名称</label>
<input id="small-payment-shop" type="search" name="shop_name" value="{{ $filters['shop_name'] }}">
<input id="small-payment-shop" type="search" name="shop_name" value="{{ $filters['shop_name'] }}" list="small-payment-shop-list" autocomplete="off">
<datalist id="small-payment-shop-list">
@foreach ($smallPaymentShops as $shopName)
<option value="{{ $shopName }}">
@endforeach
</datalist>
</div>
<div class="upstream-data-field">
<label for="small-payment-transaction">支付宝批次号</label>
@ -145,11 +155,21 @@
</div>
<div class="upstream-data-field">
<label for="small-payment-status">工单/打款状态</label>
<input id="small-payment-status" type="search" name="status" value="{{ $filters['status'] }}" placeholder="例如 已完成/支付宝已打款">
<select id="small-payment-status" name="status">
<option value="">全部</option>
@foreach ($smallPaymentStatuses as $statusOption)
<option value="{{ $statusOption }}" @selected($filters['status'] === $statusOption)>{{ $statusOption }}</option>
@endforeach
</select>
</div>
<div class="upstream-data-field">
<label for="small-payment-reason">打款原因</label>
<input id="small-payment-reason" type="search" name="payment_reason" value="{{ $filters['payment_reason'] }}">
<input id="small-payment-reason" type="search" name="payment_reason" value="{{ $filters['payment_reason'] }}" list="small-payment-reason-list" autocomplete="off">
<datalist id="small-payment-reason-list">
@foreach ($smallPaymentReasons as $reasonOption)
<option value="{{ $reasonOption }}">
@endforeach
</datalist>
</div>
<div class="upstream-data-actions">
<button class="upstream-data-button upstream-data-button--primary" type="submit">
@ -178,8 +198,8 @@
<span class="small-payment-summary__value">{{ number_format($rows->total()) }} 条</span>
</div>
<div class="small-payment-summary__item">
<span class="small-payment-summary__label">本页打款金额合计</span>
<span class="small-payment-summary__value">¥ {{ $smallPaymentPageAmount }}</span>
<span class="small-payment-summary__label">筛选出的打款金额合计</span>
<span class="small-payment-summary__value">¥ {{ $smallPaymentFilteredAmount }}</span>
</div>
</div>

View File

@ -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="{&quot;from&quot;:&quot;2026-09-01&quot;,&quot;to&quot;:&quot;2026-09-03&quot;', false)
->assertSee('data-finance-export-query="{&quot;from&quot;:&quot;2026-09-01T08:30:00&quot;,&quot;to&quot;:&quot;2026-09-03T17:45:59&quot;', false)
->assertDontSee('data-finance-export-query="{&amp;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);

View File

@ -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')