fix: 优化快递全量汇总的哈希关联 #150

Merged
WuYongJie merged 2 commits from fix/WuYongJie-courier-bounded-summary into main 2026-09-09 19:36:26 +08:00
2 changed files with 86 additions and 8 deletions

View File

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

View File

@ -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();