fix: 快递对账明细与耗时汇总解耦 #149

Merged
WuYongJie merged 1 commits from fix/WuYongJie-courier-page-timeout into main 2026-09-09 18:40:16 +08:00
6 changed files with 338 additions and 42 deletions

View File

@ -190,6 +190,21 @@ class Pages extends Controller
]);
}
if ($section === 'courier-payables' && $page === 'freight-reconciliation' && request()->boolean('courier_summary')) {
try {
$summary = $this->courierFreightReconciliations->requestSummary($this->freightReconciliationFilters());
$response = response()->json(['data' => $summary], $summary === null ? 202 : 200, [
'Cache-Control' => 'private, no-store', 'X-Accel-Buffering' => 'no',
]);
// Explicit framing lets clients finish reading before the
// application termination callback computes the aggregate.
return $response->header('Content-Length', strlen($response->getContent()));
} catch (Throwable $exception) {
report($exception);
return response()->json(['message' => '完整汇总暂时无法计算,明细仍可正常查看。'], 503, ['Cache-Control' => 'private, no-store']);
}
}
return view('business.pages.show', $this->viewData(
$sectionConfig,
$pageConfig,
@ -630,14 +645,14 @@ class Pages extends Controller
];
}
protected function contentForFreightReconciliation(): array
private function freightReconciliationFilters(): array
{
$request = request();
$date = static function (mixed $value): string {
$value = trim((string) $value);
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1 ? $value : '';
};
$filters = [
return [
'express_no' => mb_substr(trim((string) $request->query('express_no', '')), 0, 80),
'express_name' => mb_substr(trim((string) $request->query('express_name', '')), 0, 80),
'month' => mb_substr(trim((string) $request->query('month', '')), 0, 20),
@ -645,15 +660,21 @@ class Pages extends Controller
'modified_from' => $date($request->query('modified_from', '')),
'modified_to' => $date($request->query('modified_to', '')),
];
}
protected function contentForFreightReconciliation(): array
{
$request = request();
$filters = $this->freightReconciliationFilters();
$page = max((int) $request->query('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$rows = new LengthAwarePaginator([], 0, $limit, $page, [
$rows = new Paginator([], $limit, $page, [
'path' => $request->url(),
'query' => $request->except('page'),
]);
$state = null;
$sync = ['code' => 'unavailable', 'last_succeeded_at' => null, 'lag_minutes' => null];
$summary = ['count' => 0, 'payable_amount' => 0.0, 'actual_payment' => 0.0, 'confirmed_amount' => 0.0, 'reconciliation_difference' => 0.0];
$summary = ['count' => null, 'freight' => null];
$error = null;
$pending = false;
@ -667,13 +688,13 @@ class Pages extends Controller
$error = '运费对账镜像正在完成首次同步,完成前不会把未同步数据误判为无数据。';
} else {
$result = $this->courierFreightReconciliations->paginate($filters, $page, $limit);
$rows = new LengthAwarePaginator(
$rows = new Paginator(
Collection::make($result['data'] ?? []),
(int) ($result['total'] ?? 0),
$limit,
$page,
['path' => $request->url(), 'query' => $request->except('page')],
);
$rows->hasMorePagesWhen((bool) ($result['has_more'] ?? false));
$state = $result['state'] ?? null;
$sync = $result['sync'] ?? $sync;
$summary = $result['summary'] ?? $summary;
@ -692,6 +713,7 @@ class Pages extends Controller
'freightReconciliationSync' => $sync,
'freightReconciliationError' => $error,
'freightReconciliationPending' => $pending,
'freightReconciliationSummaryUrl' => $error === null ? $request->fullUrlWithQuery(['courier_summary' => 1, 'page' => null]) : null,
];
}

View File

@ -5,6 +5,7 @@ namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use RuntimeException;
@ -154,7 +155,7 @@ class CourierFreightReconciliationMirrorRepository
/**
* @param array<string,string> $filters
* @return array{data:list<array<string,mixed>>,total:int,summary:array<string,float|int>,state:array<string,mixed>|null,sync:array<string,mixed>}
* @return array{data:list<array<string,mixed>>,total:?int,has_more:bool,summary:?array,state:array<string,mixed>|null,sync:array<string,mixed>}
*/
public function paginate(array $filters, int $page, int $perPage): array
{
@ -164,41 +165,90 @@ class CourierFreightReconciliationMirrorRepository
$page = max($page, 1);
$perPage = min(max($perPage, 1), 100);
$query = $this->filteredQuery($filters);
// Both joins select at most one latest row, so this count is also the
// paginator total. Avoid traversing the entire joined history twice.
$summary = (clone $query)->selectRaw(
'COUNT(d.id) AS aggregate_count, COALESCE(SUM(o.freight), 0) AS freight',
)->first();
$total = (int) ($summary->aggregate_count ?? 0);
$rows = (clone $query)->select($this->selectColumns())
$summary = Cache::get($this->summaryCacheKey($filters));
// Select page identifiers before loading the wide optional details. A
// contains-waybill filter can then scan the compact covering index.
$candidates = $this->filteredQuery($filters, trim((string) ($filters['express_name'] ?? '')) !== '')
->select('d.id')
->orderByDesc('d.id')
->offset(($page - 1) * $perPage)->limit($perPage + 1);
$rows = $this->detailQuery($candidates)->select($this->selectColumns())
->orderByDesc('d.id')
->forPage($page, $perPage)
->get()
->map(fn (object $row): array => $this->mapRow($row))
->all();
$state = $this->state();
$freight = (float) ($summary->freight ?? 0);
return [
'data' => $rows,
'total' => $total,
'summary' => [
'count' => (int) ($summary->aggregate_count ?? 0),
'freight' => $freight,
'payable_amount' => $freight,
'actual_payment' => 0.0,
'confirmed_amount' => 0.0,
'reconciliation_difference' => 0.0,
],
'data' => array_slice($rows, 0, $perPage),
'total' => $summary['count'] ?? null,
'has_more' => count($rows) > $perPage,
'summary' => $summary,
'state' => $state,
'sync' => $this->syncStatus($state),
];
}
private function filteredQuery(array $filters): Builder
/** Send a small pending response before starting the bounded calculation. */
public function requestSummary(array $filters): ?array
{
$query = $this->detailQuery()->whereNotNull('d.logistic_id')->where('d.logistic_id', '!=', '');
$key = $this->summaryCacheKey($filters);
if (is_array($cached = Cache::get($key))) return $cached;
if (Cache::has($key . ':failed')) throw new RuntimeException('courier_summary_recently_failed');
app()->terminating(function () use ($filters, $key): void {
try {
$this->summarize($filters);
} catch (Throwable $exception) {
Cache::put($key . ':failed', true, 60);
report($exception);
}
});
return null;
}
/** The separate summary request preserves full-filter totals without delaying rows. */
public function summarize(array $filters): ?array
{
if (! $this->ready()) throw new RuntimeException('courier_freight_reconciliation_mirror_not_ready');
$key = $this->summaryCacheKey($filters);
if (is_array($cached = Cache::get($key))) return $cached;
// One expensive aggregate at a time, including requests for different filters.
$lock = Cache::lock('courier-freight-summary:calculation', 360);
if (! $lock->get()) return null;
$previousTimeout = null;
try {
if (is_array($cached = Cache::get($key))) return $cached;
if ($this->connection->getDriverName() === 'mysql') {
$previousTimeout = $this->connection->selectOne('SELECT @@SESSION.max_statement_time AS value')->value;
$this->connection->statement('SET SESSION max_statement_time=300');
}
$asOf = now()->toDateTimeString();
$row = $this->filteredQuery($filters)->selectRaw(
'COUNT(d.id) AS aggregate_count, COALESCE(SUM(o.freight), 0) AS freight',
)->first();
$summary = ['count' => (int) $row->aggregate_count, 'freight' => (float) $row->freight, 'computed_at' => $asOf];
Cache::put($key, $summary, 900);
return $summary;
} finally {
try {
if ($previousTimeout !== null) $this->connection->statement('SET SESSION max_statement_time=' . (float) $previousTimeout);
} finally {
$lock->release();
}
}
}
private function summaryCacheKey(array $filters): string
{
$filters = array_filter(array_map(static fn ($value): string => trim((string) $value), $filters), static fn (string $value): bool => $value !== '');
ksort($filters);
return 'courier-freight-summary:v1:' . hash('sha256', json_encode($filters));
}
private function filteredQuery(array $filters, bool $includeRelations = true): Builder
{
$query = ($includeRelations ? $this->detailQuery() : $this->connection->table($this->datasets->get(self::DETAILS_DATASET)->targetTable() . ' as d'))
->whereNotNull('d.logistic_id')->where('d.logistic_id', '!=', '');
$this->containsFilter($query, 'd.logistic_id', $filters['express_no'] ?? '');
if (($filters['express_name'] ?? '') !== '') {
@ -226,13 +276,15 @@ class CourierFreightReconciliationMirrorRepository
return $query;
}
private function detailQuery(): Builder
private function detailQuery(?Builder $pageIds = null): Builder
{
$details = $this->datasets->get(self::DETAILS_DATASET)->targetTable();
$extension = $this->datasets->get(self::EXTENSION_DATASET)->targetTable();
$outbound = $this->datasets->get(self::OUTBOUND_DATASET)->targetTable();
return $this->connection->table($details . ' as d')
$query = $pageIds === null ? $this->connection->table($details . ' as d')
: $this->connection->query()->fromSub($pageIds, 'page_ids')->join($details . ' as d', 'd.id', '=', 'page_ids.id');
return $query
->leftJoinSub($this->latestBy($extension, 'so_id', 'Id'), 'x', 'x.so_id', '=', 'd.so_id')
->leftJoinSub($this->latestBy($outbound, 'io_id', 'Id'), 'o', 'o.io_id', '=', 'x.io_id');
}

View File

@ -1,6 +1,7 @@
@php
$filters = $freightReconciliationFilters ?? [];
$summary = $freightReconciliationSummary ?? [];
$summaryReady = isset($summary['count'], $summary['freight']);
$sync = $freightReconciliationSync ?? ['code' => 'unavailable', 'last_succeeded_at' => null, 'lag_minutes' => null];
$statusLabels = ['fresh' => '数据新鲜', 'stale' => '数据可能滞后', 'syncing' => '同步中', 'initializing' => '首次同步中', 'pending' => '等待同步', 'failed' => '同步失败', 'initial_failed' => '首次同步失败', 'unavailable' => '镜像不可用'];
$money = static fn (mixed $value): string => number_format((float) ($value ?? 0), 2);
@ -10,6 +11,7 @@
<div class="courier-reconciliation-page">
<style>
.courier-reconciliation-page { color: #29251f; }
.courier-reconciliation-page .finance-table-field-picker__panel { left: 0; right: auto; }
.courier-reconciliation-page .recon-control { border: 1px solid #d9d0c2; border-radius: .375rem; padding: .5rem .7rem; font-size: .8125rem; background: #fff; outline: none; }
.courier-reconciliation-page .recon-control:focus { border-color: #b18a4a; box-shadow: 0 0 0 2px rgba(177,138,74,.15); }
.courier-reconciliation-page .recon-button { border-radius: .375rem; padding: .5rem 1rem; font-size: .8125rem; }
@ -37,9 +39,12 @@
<div class="mb-4 rounded border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">{{ $freightReconciliationError }}</div>
@endif
<div class="mb-5 grid grid-cols-2 gap-3 lg:grid-cols-2">
<div class="rounded-lg border border-[#e7e0d5] bg-white px-4 py-3 shadow-sm"><div class="text-xs text-gray-500">记录数</div><div class="mt-1 text-lg font-semibold text-[#4b3a24]">{{ number_format((int) ($summary['count'] ?? 0)) }}</div></div>
<div class="rounded-lg border border-[#e7e0d5] bg-white px-4 py-3 shadow-sm"><div class="text-xs text-gray-500">运费合计</div><div class="mt-1 text-lg font-semibold text-[#4b3a24]">¥ {{ $money($summary['freight'] ?? $summary['payable_amount'] ?? 0) }}</div></div>
<div class="mb-5" data-courier-summary data-summary-ready="{{ $summaryReady ? 'true' : 'false' }}" data-summary-url="{{ $freightReconciliationSummaryUrl ?? '' }}">
<div class="grid grid-cols-2 gap-3">
<div class="rounded-lg border border-[#e7e0d5] bg-white px-4 py-3 shadow-sm"><div class="text-xs text-gray-500">记录数</div><div class="mt-1 text-lg font-semibold text-[#4b3a24]" data-summary-count>{{ $summaryReady ? number_format((int) $summary['count']) : '—' }}</div></div>
<div class="rounded-lg border border-[#e7e0d5] bg-white px-4 py-3 shadow-sm"><div class="text-xs text-gray-500">运费合计</div><div class="mt-1 text-lg font-semibold text-[#4b3a24]" data-summary-freight>{{ $summaryReady ? '¥ ' . $money($summary['freight']) : '—' }}</div></div>
</div>
<p class="mt-2 text-xs text-gray-500" data-summary-status role="status" aria-live="polite">@if ($summaryReady) 完整筛选汇总{{ !empty($summary['computed_at']) ? ' · ' . $summary['computed_at'] : '' }} @elseif (!empty($freightReconciliationSummaryUrl)) 正在计算完整筛选汇总,明细可继续查看… @else 同步完成后提供汇总 @endif</p>
</div>
<div class="mb-5 rounded-lg border border-[#e7e0d5] bg-white p-4 shadow-sm">
@ -58,7 +63,7 @@
</form>
</div>
<div class="overflow-hidden rounded-lg border border-[#e7e0d5] bg-white shadow-sm">
<div data-finance-table-surface class="relative rounded-lg border border-[#e7e0d5] bg-white shadow-sm">
<div class="overflow-x-auto"><table class="recon-table min-w-full text-sm"><thead><tr>
@foreach (['快递单号','商品ID','发货时间','交易订单号','省','市','区','条形码','快递名称1','快递名称2','数量','商品名称','平台','店铺名称','仓库','体积(cm3)','供应商','长(cm)','宽(cm)','高(cm)','运费','对账重量(kg)','对账体积(cm3)'] as $heading)
<th class="text-left">{{ $heading }}</th>
@ -72,8 +77,9 @@
<td>{{ $row['transaction_order_no'] ?? '-' }}</td>
<td>{{ $row['province'] ?? '-' }}</td><td>{{ $row['city'] ?? '-' }}</td><td>{{ $row['district'] ?? '-' }}</td><td>{{ $row['barcode'] ?? '-' }}</td>
<td>{{ $row['express_name'] ?? '-' }}</td><td>{{ $row['express_name_2'] ?? '-' }}</td><td>{{ $row['quantity'] ?? '-' }}</td>
<td>{{ $row['product_name'] ?? '-' }}</td><td>{{ $row['platform'] ?? '-' }}</td><td>{{ $row['shop_name'] ?? '-' }}</td><td>{{ $row['warehouse'] ?? '-' }}</td><td>{{ $row['supplier'] ?? '-' }}</td>
@foreach (['volume','length','width','height','freight','reconciliation_weight','reconciliation_volume'] as $numberField)
<td>{{ $row['product_name'] ?? '-' }}</td><td>{{ $row['platform'] ?? '-' }}</td><td>{{ $row['shop_name'] ?? '-' }}</td><td>{{ $row['warehouse'] ?? '-' }}</td>
<td class="text-right tabular-nums">{{ $number($row['volume'] ?? null) }}</td><td>{{ $row['supplier'] ?? '-' }}</td>
@foreach (['length','width','height','freight','reconciliation_weight','reconciliation_volume'] as $numberField)
<td class="text-right tabular-nums">{{ $row[$numberField] === null ? '-' : ($numberField === 'freight' ? $money($row[$numberField]) : $number($row[$numberField])) }}</td>
@endforeach
</tr>
@ -82,7 +88,40 @@
@endforelse
</tbody></table></div>
@if (($freightReconciliations ?? null)?->hasPages())
<div class="flex items-center justify-between border-t border-[#eee8de] px-4 py-3 text-sm text-gray-500"><span>共 {{ $freightReconciliations->total() }} 条记录</span><div>{{ $freightReconciliations->onEachSide(1)->links() }}</div></div>
<div class="flex items-center justify-between border-t border-[#eee8de] px-4 py-3 text-sm text-gray-500"><span>第 {{ $freightReconciliations->currentPage() }} 页 · 本页 {{ $freightReconciliations->count() }} 条</span><div>{{ $freightReconciliations->links() }}</div></div>
@endif
</div>
</div>
<script>
(() => {
const root = document.querySelector('[data-courier-summary]');
const url = root?.dataset.summaryUrl;
if (!url || root.dataset.summaryReady === 'true') return;
const locationKey = window.location.href;
let attempts = 0;
const load = async () => {
if (window.location.href !== locationKey) return;
try {
const response = await fetch(url, { headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' } });
if (window.location.href !== locationKey) return;
if (response.status === 202 && ++attempts < 100) {
window.setTimeout(load, 3000);
return;
}
if (!response.ok || response.status === 202) throw new Error('summary_unavailable');
const { data } = await response.json();
const current = document.querySelector('[data-courier-summary]');
if (!current || !data) return;
current.querySelector('[data-summary-count]').textContent = new Intl.NumberFormat('zh-CN').format(data.count);
current.querySelector('[data-summary-freight]').textContent = '¥ ' + new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(data.freight);
current.querySelector('[data-summary-status]').textContent = '完整筛选汇总 · ' + data.computed_at;
current.dataset.summaryReady = 'true';
} catch (_) {
const status = document.querySelector('[data-courier-summary] [data-summary-status]');
if (window.location.href === locationKey && status) status.textContent = '完整汇总暂时无法计算,明细仍可正常查看。';
}
};
load();
})();
</script>

View File

@ -181,7 +181,7 @@
}
.finance-table-field-picker__option.is-dragging { opacity: 0.45; }
.finance-table-field-picker__option input { flex: 0 0 auto; accent-color: var(--vip-content-accent, #b9954f); }
.finance-table-field-picker__option input { flex: 0 0 auto; color: var(--vip-content-accent-dark, #856632); accent-color: var(--vip-content-accent, #b9954f); }
.finance-table-field-picker__option span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.finance-table-field-picker__actions { display: flex; grid-column: 1 / -1; gap: 0.35rem; padding-top: 0.35rem; border-top: 1px solid var(--vip-border, #d8d2c7); }
.finance-table-field-picker__actions button { min-height: 1.75rem; padding: 0 0.5rem; border: 1px solid var(--vip-border, #d8d2c7); border-radius: 0.35rem; background: #fff; color: var(--vip-content-accent-dark, #856632); cursor: pointer; font: inherit; }
@ -509,7 +509,9 @@
defaults.forEach((key) => { if (!valid.includes(key)) valid.push(key); });
reorderCells(table, valid);
[...panel.querySelectorAll('[data-finance-option-key], .directory-field-picker__option')].sort((a, b) => valid.indexOf(a.dataset.financeOptionKey || a.querySelector('input')?.value) - valid.indexOf(b.dataset.financeOptionKey || b.querySelector('input')?.value)).forEach((option) => panel.insertBefore(option, panel.querySelector('.finance-table-field-picker__actions')));
if (save) localStorage.setItem(storageKey(table, 'order'), JSON.stringify(valid));
if (save) {
try { localStorage.setItem(storageKey(table, 'order'), JSON.stringify(valid)); } catch (_) {}
}
};
try {
const savedVisibility = JSON.parse(localStorage.getItem(storageKey(table, 'visibility')) || 'null');
@ -533,6 +535,18 @@
return;
}
option.draggable = true;
option.querySelector('input')?.setAttribute('aria-keyshortcuts', 'Alt+ArrowUp Alt+ArrowDown');
option.addEventListener('keydown', (event) => {
if (!event.altKey || !['ArrowUp', 'ArrowDown'].includes(event.key)) return;
event.preventDefault();
const order = [...panel.querySelectorAll('[data-finance-option-key]')].map((item) => item.dataset.financeOptionKey);
const index = order.indexOf(option.dataset.financeOptionKey);
const next = Math.max(0, Math.min(order.length - 1, index + (event.key === 'ArrowUp' ? -1 : 1)));
if (next === index) return;
[order[index], order[next]] = [order[next], order[index]];
applyOrder(order);
option.querySelector('input')?.focus();
});
option.addEventListener('dragstart', () => { dragging = option; option.classList.add('is-dragging'); });
option.addEventListener('dragend', () => { option.classList.remove('is-dragging'); dragging = null; });
option.addEventListener('dragover', (event) => { event.preventDefault(); if (dragging && dragging !== option) panel.insertBefore(dragging, option); });
@ -543,7 +557,7 @@
function ensureControls(table, headers) {
const existingDirectoryTools = table.closest('[data-directory-page]')?.querySelector('[data-directory-tools]');
const root = table.closest('.upstream-data-table-panel, .directory-table-page, .tb-panel, .ab-panel, .marketing-bills-table-wrapper, .bill-table-wrapper, .jd-wallet-table-wrap, .alibaba-bill-table-wrapper, .zhongcai-table-wrapper') || table.parentElement;
const root = table.closest('[data-finance-table-surface], .upstream-data-table-panel, .directory-table-page, .tb-panel, .ab-panel, .marketing-bills-table-wrapper, .bill-table-wrapper, .jd-wallet-table-wrap, .alibaba-bill-table-wrapper, .zhongcai-table-wrapper') || table.parentElement;
if (!root) return;
let controls = existingDirectoryTools || root.querySelector('[data-finance-controls]');
if (!controls) {
@ -676,7 +690,7 @@
if (!table || table.hasAttribute('data-finance-table-native') || tableNames.has(table)) return;
const prepared = prepareColumns(table);
if (!prepared.headers.length) return;
tableNames.set(table, true);
tableNames.add(table);
ensureControls(table, prepared.headers);
}

View File

@ -0,0 +1,108 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Illuminate\Testing\TestResponse;
use Modules\EcommerceFinance\Repositories\CourierFreightReconciliationMirrorRepository;
use Tests\Feature\FeatureTestCase;
class CourierFreightReconciliationPageTest extends FeatureTestCase
{
protected function renderFreightPage(bool $summaryReady = true): TestResponse
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$repository = \Mockery::mock(CourierFreightReconciliationMirrorRepository::class);
$repository->shouldReceive('available')->once()->andReturnTrue();
$repository->shouldReceive('ready')->once()->andReturnTrue();
$repository->shouldReceive('paginate')->once()->andReturn([
'data' => [[
'waybill' => '00123456789012345678', 'product_id' => 'ITEM-1',
'send_time' => '2026-09-01 12:00:00', 'transaction_order_no' => 'SO-1',
'province' => '江苏省', 'city' => '常州市', 'district' => '武进区',
'barcode' => 'SKU-1', 'express_name' => '测试快递', 'express_name_2' => '测试网点',
'quantity' => 2, 'product_name' => '验收商品', 'platform' => '测试平台',
'shop_name' => '验收店铺', 'warehouse' => '验收仓库', 'volume' => 7161,
'supplier' => '验收供应商', 'length' => 11, 'width' => 21, 'height' => 31,
'freight' => 8.8, 'reconciliation_weight' => 0.7, 'reconciliation_volume' => 7161,
]],
'total' => $summaryReady ? 1 : null, 'has_more' => false,
'summary' => $summaryReady ? ['count' => 1, 'freight' => 8.8] : null,
'sync' => ['code' => 'fresh', 'last_succeeded_at' => '2026-09-09 10:00:00', 'lag_minutes' => 0],
]);
$this->app->instance(CourierFreightReconciliationMirrorRepository::class, $repository);
return $this->loginAs()->get(route('business.pages.show', [
'section' => 'courier-payables', 'page' => 'freight-reconciliation',
]));
}
public function test_rendered_values_match_their_column_headings(): void
{
$response = $this->renderFreightPage()->assertOk();
$document = new \DOMDocument();
$previous = libxml_use_internal_errors(true);
$document->loadHTML('<?xml encoding="UTF-8">' . $response->getContent());
libxml_clear_errors();
libxml_use_internal_errors($previous);
$xpath = new \DOMXPath($document);
$headers = $xpath->query('//table[contains(@class,"recon-table")]/thead/tr/th');
$values = $xpath->query('//table[contains(@class,"recon-table")]/tbody/tr[1]/td');
$this->assertCount(23, $headers);
$this->assertCount(23, $values);
$row = [];
foreach ($headers as $index => $header) $row[trim($header->textContent)] = trim($values->item($index)->textContent);
$this->assertSame('7161', $row['体积(cm3)']);
$this->assertSame('验收供应商', $row['供应商']);
$this->assertSame('11', $row['长(cm)']);
$this->assertSame('21', $row['宽(cm)']);
$this->assertSame('31', $row['高(cm)']);
$this->assertSame('8.80', $row['运费']);
$this->assertSame('00123456789012345678', $row['快递单号']);
}
public function test_rows_remain_available_while_the_complete_summary_is_pending(): void
{
$this->renderFreightPage(false)->assertOk()
->assertSeeText('验收商品')
->assertSeeText('正在计算完整筛选汇总')
->assertSee('data-summary-ready="false"', false)
->assertSee('data-summary-count>—', false);
}
public function test_summary_request_keeps_filters_and_returns_only_the_summary(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$repository = \Mockery::mock(CourierFreightReconciliationMirrorRepository::class);
$repository->shouldReceive('requestSummary')->once()
->withArgs(fn (array $filters) => $filters['month'] === '2026-09' && $filters['express_no'] === 'YT')
->andReturn(['count' => 48, 'freight' => 8.8, 'computed_at' => '2026-09-09 10:00:00']);
$this->app->instance(CourierFreightReconciliationMirrorRepository::class, $repository);
$this->loginAs()->get(route('business.pages.show', [
'section' => 'courier-payables', 'page' => 'freight-reconciliation',
'courier_summary' => 1, 'month' => '2026-09', 'express_no' => ' YT ',
]))->assertOk()->assertJsonPath('data.count', 48)->assertJsonPath('data.freight', 8.8);
}
public function test_busy_summary_returns_pending_without_starting_another_calculation(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$repository = \Mockery::mock(CourierFreightReconciliationMirrorRepository::class);
$repository->shouldReceive('requestSummary')->once()->andReturnNull();
$this->app->instance(CourierFreightReconciliationMirrorRepository::class, $repository);
$this->loginAs()->get(route('business.pages.show', [
'section' => 'courier-payables', 'page' => 'freight-reconciliation', 'courier_summary' => 1,
]))->assertStatus(202)->assertJsonPath('data', null)->assertHeader('Content-Length', '13')->assertHeader('X-Accel-Buffering', 'no');
}
public function test_summary_is_not_available_without_login(): void
{
$repository = \Mockery::mock(CourierFreightReconciliationMirrorRepository::class);
$repository->shouldNotReceive('requestSummary');
$this->app->instance(CourierFreightReconciliationMirrorRepository::class, $repository);
$this->withExceptionHandling()->get(route('business.pages.show', [
'company_id' => $this->company->id,
'section' => 'courier-payables', 'page' => 'freight-reconciliation', 'courier_summary' => 1,
]))->assertRedirect();
}
}

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\CourierFreightReconciliationMirrorRepository;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
@ -112,6 +113,7 @@ class CourierFreightReconciliationMirrorRepositoryTest extends TestCase
['Id' => 101, 'io_id' => 100, 'freight' => 0.8],
]);
$this->repository->summarize(['express_no' => 'YT', 'month' => '2026-09']);
$result = $this->repository->paginate(['express_no' => 'YT', 'month' => '2026-09'], 1, 25);
$this->assertSame(1, $result['total']);
@ -138,6 +140,7 @@ class CourierFreightReconciliationMirrorRepositoryTest extends TestCase
'send_time' => '2026-09-02 00:00:00', 'logistic_status_name' => '已发货',
]);
$this->repository->summarize(['express_no' => 'ZT-002']);
$result = $this->repository->paginate(['express_no' => 'ZT-002'], 1, 25);
$this->assertSame(1, $result['total']);
@ -148,6 +151,64 @@ class CourierFreightReconciliationMirrorRepositoryTest extends TestCase
$this->assertNull($result['data'][0]['freight']);
}
public function test_pages_do_not_wait_for_full_aggregation_and_do_not_invent_totals(): void
{
$this->markReady();
$db = DB::connection('business_mirror');
$db->table('js_jst_order_express_details')->insert([
['id' => 1, 'logistic_id' => 'A'], ['id' => 2, 'logistic_id' => 'B'], ['id' => 3, 'logistic_id' => 'C'],
]);
$db->enableQueryLog();
$first = $this->repository->paginate([], 1, 2);
$second = $this->repository->paginate([], 2, 2);
$this->assertSame([3, 2], array_column($first['data'], 'detail_id'));
$this->assertSame([1], array_column($second['data'], 'detail_id'));
$this->assertTrue($first['has_more']);
$this->assertFalse($second['has_more']);
$this->assertNull($first['total']);
$this->assertNull($first['summary']);
$queries = strtolower(implode(' ', array_column($db->getQueryLog(), 'query')));
$db->disableQueryLog();
$this->assertStringNotContainsString('count(', $queries);
$this->assertStringNotContainsString('sum(', $queries);
}
public function test_summary_reuses_cache_and_serializes_expensive_requests(): void
{
$this->markReady();
$db = DB::connection('business_mirror');
$db->table('js_jst_order_express_details')->insert(['id' => 1, 'logistic_id' => 'A']);
$lock = Cache::lock('courier-freight-summary:calculation', 360);
$this->assertTrue($lock->get());
try {
$this->assertNull($this->repository->summarize([]));
} finally {
$lock->release();
}
$summary = $this->repository->summarize([]);
$this->assertSame(1, $summary['count']);
$this->assertArrayHasKey('computed_at', $summary);
$db->enableQueryLog();
$this->assertSame($summary, $this->repository->summarize(['month' => '', 'express_no' => '']));
$this->assertStringNotContainsString('sum(', strtolower(implode(' ', array_column($db->getQueryLog(), 'query'))));
$db->disableQueryLog();
$this->assertSame(0, $this->repository->summarize(['express_no' => 'MISSING'])['count']);
}
public function test_summary_request_defers_database_work_until_after_the_response(): void
{
$this->markReady();
$db = DB::connection('business_mirror');
$db->table('js_jst_order_express_details')->insert(['id' => 1, 'logistic_id' => 'A']);
$db->enableQueryLog();
$this->assertNull($this->repository->requestSummary([]));
$this->assertSame([], $db->getQueryLog());
$this->app->terminate();
$result = $this->repository->requestSummary([]);
$this->assertSame(1, $result['count']);
$db->disableQueryLog();
}
private function markReady(): void
{
$now = now()->toDateTimeString();