diff --git a/modules/EcommerceFinance/Repositories/CourierFreightReconciliationMirrorRepository.php b/modules/EcommerceFinance/Repositories/CourierFreightReconciliationMirrorRepository.php index 6853215e..a366da48 100644 --- a/modules/EcommerceFinance/Repositories/CourierFreightReconciliationMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/CourierFreightReconciliationMirrorRepository.php @@ -215,15 +215,26 @@ class CourierFreightReconciliationMirrorRepository // 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; + $previousSettings = []; 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'); + $settings = ['max_statement_time' => 300]; + if (array_filter($filters, static fn ($value): bool => trim((string) $value) !== '') === []) { + $settings += [ + 'optimizer_switch' => 'derived_merge=off,derived_with_keys=off', + 'join_cache_level' => 8, + 'join_buffer_size' => 16777216, + 'join_buffer_space_limit' => 33554432, + ]; + } + foreach ($settings as $name => $value) { + $previousSettings[$name] = $this->connection->selectOne('SELECT @@SESSION.' . $name . ' AS value')->value; + $this->setSummarySessionSetting($name, $value); + } } $asOf = now()->toDateTimeString(); - $row = $this->filteredQuery($filters)->selectRaw( + $row = $this->summaryQuery($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]; @@ -231,23 +242,72 @@ class CourierFreightReconciliationMirrorRepository return $summary; } finally { try { - if ($previousTimeout !== null) $this->connection->statement('SET SESSION max_statement_time=' . (float) $previousTimeout); + foreach (array_reverse($previousSettings, true) as $name => $value) { + $this->setSummarySessionSetting($name, $value); + } } finally { $lock->release(); } } } + private function setSummarySessionSetting(string $name, mixed $value): void + { + if ($name === 'optimizer_switch') { + $this->connection->statement('SET SESSION optimizer_switch=?', [$value]); + return; + } + // MariaDB rejects quoted numeric values for these session variables. + // Names come only from the fixed settings map above. + $this->connection->statement('SET SESSION ' . $name . '=' . (float) $value); + } + 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)); + return 'courier-freight-summary:v3:' . hash('sha256', json_encode($filters)); } - private function filteredQuery(array $filters, bool $includeRelations = true): Builder + /** Materialize latest associations once; use compact collation-aware hash joins for the full scan. */ + private function summaryQuery(array $filters = []): Builder { - $query = ($includeRelations ? $this->detailQuery() : $this->connection->table($this->datasets->get(self::DETAILS_DATASET)->targetTable() . ' as d')) + $activeFilters = array_filter($filters, static fn ($value): bool => trim((string) $value) !== ''); + if ($activeFilters !== []) { + $ids = $this->filteredQuery($filters, ($filters['express_name'] ?? '') !== '')->select('d.id')->distinct(); + return $this->detailQuery($ids); + } + $columns = ['so_id', 'io_id']; + $lookup = fn (string $column): string => $this->connection->getDriverName() === 'mysql' + ? 'CRC32(WEIGHT_STRING(RTRIM(' . $column . ')))' + : 'SUBSTR(' . $column . ', 1, 64)'; + $extension = $this->latestSummaryProjection(self::EXTENSION_DATASET, 'so_id', $columns) + ->selectRaw($lookup('candidate.so_id') . ' AS lookup_key'); + $outbound = $this->latestSummaryProjection(self::OUTBOUND_DATASET, 'io_id', ['io_id', 'freight']); + $query = $this->connection->table($this->datasets->get(self::DETAILS_DATASET)->targetTable() . ' as d') + ->leftJoinSub($extension, 'x', static function ($join) use ($lookup): void { + $join->whereRaw('x.lookup_key = ' . $lookup('d.so_id')) + // Keep the full comparison: a hash collision must never + // combine different orders or multiply freight amounts. + ->whereRaw("COALESCE(x.so_id, '') = COALESCE(d.so_id, '')"); + }) + ->leftJoinSub($outbound, 'o', 'o.io_id', '=', 'x.io_id'); + return $this->filteredQuery([], true, $query); + } + + private function latestSummaryProjection(string $dataset, string $relation, array $columns): Builder + { + $table = $this->datasets->get($dataset)->targetTable(); + $ids = $this->connection->table($table)->select($relation)->selectRaw('MAX(Id) AS latest_id')->groupBy($relation)->orderByRaw('NULL'); + return $this->connection->query()->fromSub($ids, 'latest_ids') + ->join($table . ' as candidate', static function ($join) use ($relation): void { + $join->on('latest_ids.' . $relation, '=', 'candidate.' . $relation)->on('latest_ids.latest_id', '=', 'candidate.Id'); + })->select(array_map(static fn (string $column): string => 'candidate.' . $column, $columns)); + } + + private function filteredQuery(array $filters, bool $includeRelations = true, ?Builder $base = null): Builder + { + $query = ($base ?? ($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'] ?? ''); diff --git a/tests/Unit/EcommerceFinance/CourierFreightReconciliationMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/CourierFreightReconciliationMirrorRepositoryTest.php index c36159d0..1878dc2e 100644 --- a/tests/Unit/EcommerceFinance/CourierFreightReconciliationMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/CourierFreightReconciliationMirrorRepositoryTest.php @@ -209,6 +209,24 @@ class CourierFreightReconciliationMirrorRepositoryTest extends TestCase $db->disableQueryLog(); } + public function test_summary_lookup_keeps_full_long_order_identifiers_distinct(): void + { + $this->markReady(); + $db = DB::connection('business_mirror'); + $prefix = str_repeat('X', 64); + $db->table('js_jst_order_express_details')->insert(['id' => 1, 'logistic_id' => 'A', 'so_id' => $prefix . 'A']); + $db->table('js_jst_send_data_view_extension')->insert([ + ['Id' => 1, 'so_id' => $prefix . 'A', 'io_id' => 1], + ['Id' => 2, 'so_id' => $prefix . 'B', 'io_id' => 2], + ]); + $db->table('js_jst_sales_outbound_info')->insert([ + ['Id' => 1, 'io_id' => 1, 'freight' => 1], ['Id' => 2, 'io_id' => 2, 'freight' => 2], + ]); + $summary = $this->repository->summarize([]); + $this->assertSame(1, $summary['count']); + $this->assertSame(1.0, $summary['freight']); + } + private function markReady(): void { $now = now()->toDateTimeString();