From a29930b2acca152f5d4533a1e95f6e51453880b8 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Wed, 9 Sep 2026 18:39:47 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=BF=AB=E9=80=92=E5=AF=B9=E8=B4=A6?= =?UTF-8?q?=E6=98=8E=E7=BB=86=E4=B8=8E=E8=80=97=E6=97=B6=E6=B1=87=E6=80=BB?= =?UTF-8?q?=E8=A7=A3=E8=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Http/Controllers/Business/Pages.php | 34 +++++- ...rFreightReconciliationMirrorRepository.php | 102 +++++++++++++---- .../freight-reconciliation.blade.php | 53 +++++++-- .../finance-table-interactions.blade.php | 22 +++- .../CourierFreightReconciliationPageTest.php | 108 ++++++++++++++++++ ...ightReconciliationMirrorRepositoryTest.php | 61 ++++++++++ 6 files changed, 338 insertions(+), 42 deletions(-) create mode 100644 tests/Feature/Business/CourierFreightReconciliationPageTest.php diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index f38c597a..0d3378e7 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -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, ]; } diff --git a/modules/EcommerceFinance/Repositories/CourierFreightReconciliationMirrorRepository.php b/modules/EcommerceFinance/Repositories/CourierFreightReconciliationMirrorRepository.php index 9a842e38..6853215e 100644 --- a/modules/EcommerceFinance/Repositories/CourierFreightReconciliationMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/CourierFreightReconciliationMirrorRepository.php @@ -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 $filters - * @return array{data:list>,total:int,summary:array,state:array|null,sync:array} + * @return array{data:list>,total:?int,has_more:bool,summary:?array,state:array|null,sync:array} */ 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'); } diff --git a/resources/views/business/pages/courier-payables/freight-reconciliation.blade.php b/resources/views/business/pages/courier-payables/freight-reconciliation.blade.php index b21854f6..709372bd 100644 --- a/resources/views/business/pages/courier-payables/freight-reconciliation.blade.php +++ b/resources/views/business/pages/courier-payables/freight-reconciliation.blade.php @@ -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 @@