diff --git a/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php b/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php index eb51b30d..40097ba2 100644 --- a/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php @@ -3,6 +3,8 @@ namespace Modules\EcommerceFinance\Repositories; use Illuminate\Database\Query\Builder; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry; @@ -11,6 +13,8 @@ use RuntimeException; /** 店铺收款账单·淘系支付宝账户只读经营分析副本镜像。 */ class StoreReceivingAlipayBillMirrorRepository { + private const PAGE_QUERY_TIMEOUT_SECONDS = 30; + /** 淘系支付宝页面不展示其他平台店铺;头条放心购是镜像中的抖音平台名称。 */ private const EXCLUDED_TAOBAO_SHOP_SITE_PREFIXES = [ '拼多多', '京东', '抖音', '头条放心购', '得物', '唯品会', @@ -83,8 +87,20 @@ class StoreReceivingAlipayBillMirrorRepository && $this->channelCoversDataset($channel, $dataset->fixedFilters()) && ! $hasFilters && (($state['status'] ?? null) === 'succeeded' || (bool) ($state['mirror_row_count_is_exact'] ?? false)) && $recordedCount !== null && ctype_digit((string) $recordedCount); - $total = $useRecordedCount ? (int) $recordedCount : (clone $query)->count(); - $summary = $this->summary($query, $filters, $channel); + $summary = Cache::remember( + $this->aggregateCacheKey( + $datasetKey, + $filters, + $channel, + $excludeNonTaobaoPlatforms, + $state, + ), + 120, + fn (): array => $this->summary($query, $filters, $channel), + ); + $total = $useRecordedCount + ? (int) $recordedCount + : ($summary['count'] ?? $this->boundedCount(clone $query)); $billRows = $this->pageRows( $query, $sortColumn, $direction, $page, $perPage, $filters, $channel, $targetTable, @@ -268,29 +284,32 @@ class StoreReceivingAlipayBillMirrorRepository string $targetTable, ): array { if ($channel === null || ($filters['bill_item'] ?? '') === '') { - return $query->select('b.*') + $pageQuery = $query->select('b.*') ->orderBy($sortColumn, $direction) ->orderBy('b.Id', $direction) - ->forPage($page, $perPage) - ->get() - ->all(); + ->forPage($page, $perPage); + + return $this->boundedGet($pageQuery)->all(); } $idQuery = clone $query; if ($targetTable === 'ops_jst_original_bill_info' && $idQuery->getConnection()->getDriverName() === 'mysql') { $idQuery->forceIndex('idx_original_bill_channel_shop'); } - $ids = $idQuery->select('b.Id') + $idQuery->select('b.Id') ->orderBy($sortColumn, $direction) ->orderBy('b.Id', $direction) - ->forPage($page, $perPage) - ->pluck('b.Id') + ->forPage($page, $perPage); + $ids = $this->boundedGet($idQuery) + ->pluck('Id') ->all(); if ($ids === []) { return []; } - $rowsById = $this->billQuery($targetTable)->whereIn('b.Id', $ids)->select('b.*')->get() + $rowsById = $this->boundedGet( + $this->billQuery($targetTable)->whereIn('b.Id', $ids)->select('b.*'), + ) ->keyBy(static fn (object $row): string => (string) $row->Id); return collect($ids) @@ -300,7 +319,7 @@ class StoreReceivingAlipayBillMirrorRepository ->all(); } - /** @return array{income:float,expense:float} */ + /** @return array{income:float,expense:float,count:?int} */ private function summary( Builder $billQuery, array $filters, @@ -310,18 +329,21 @@ class StoreReceivingAlipayBillMirrorRepository if ($channel === null && $this->dailySummarySupports($filters)) { $summary = $this->dailySummary($filters); if ($summary !== null) { - return $summary; + return $summary + ['count' => null]; } } - $row = (clone $billQuery)->selectRaw( - "COALESCE(SUM(CASE WHEN b.inout_type = '1' THEN ABS(COALESCE(b.amount, 0)) ELSE 0 END), 0) AS income, " + $aggregate = (clone $billQuery)->selectRaw( + "COUNT(*) AS aggregate_count, " + . "COALESCE(SUM(CASE WHEN b.inout_type = '1' THEN ABS(COALESCE(b.amount, 0)) ELSE 0 END), 0) AS income, " . "COALESCE(SUM(CASE WHEN b.inout_type = '-1' THEN ABS(COALESCE(b.amount, 0)) ELSE 0 END), 0) AS expense" - )->first(); + ); + $row = $this->boundedGet($aggregate)->first(); return [ 'income' => round((float) ($row->income ?? 0), 2), 'expense' => round((float) ($row->expense ?? 0), 2), + 'count' => (int) ($row->aggregate_count ?? 0), ]; } @@ -758,6 +780,62 @@ class StoreReceivingAlipayBillMirrorRepository return $value === null || $value === '' ? '' : date('Y-m-d H:i:s', strtotime((string) $value)); } + /** @return Collection */ + 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::PAGE_QUERY_TIMEOUT_SECONDS . ' FOR ' . $query->toSql(), + $query->getBindings(), + )); + } + + private function boundedCount(Builder $query): int + { + $count = $query->getConnection()->query() + ->fromSub($query, 'bounded_count') + ->selectRaw('COUNT(*) AS aggregate'); + + return (int) ($this->boundedGet($count)->first()->aggregate ?? 0); + } + + /** @param array $filters @param array $state */ + private function aggregateCacheKey( + string $datasetKey, + array $filters, + string|array|null $channel, + bool $excludeNonTaobaoPlatforms, + array $state, + ): string { + $normalizedFilters = array_filter( + array_map( + static fn (mixed $value): string => trim((string) $value), + $filters, + ), + static fn (string $value): bool => $value !== '', + ); + ksort($normalizedFilters); + $normalizedChannel = is_array($channel) ? array_map('strval', $channel) : $channel; + if (is_array($normalizedChannel)) { + sort($normalizedChannel); + } + + return 'store-receiving-bills:aggregate:v2:' . hash('sha256', (string) json_encode([ + 'dataset' => $datasetKey, + 'filters' => $normalizedFilters, + 'channel' => $normalizedChannel, + 'exclude_non_taobao' => $excludeNonTaobaoPlatforms, + 'status' => $state['status'] ?? null, + 'last_succeeded_at' => $state['last_succeeded_at'] ?? null, + 'mirror_row_count' => $state['mirror_row_count'] ?? null, + 'mirror_row_count_is_exact' => $state['mirror_row_count_is_exact'] ?? null, + ], JSON_UNESCAPED_UNICODE)); + } + private function escapeLike(string $value): string { return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $value); diff --git a/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php b/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php index f8b0260b..6094f1d0 100644 --- a/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php @@ -8,6 +8,8 @@ use Illuminate\Database\ConnectionInterface; use Illuminate\Database\DatabaseManager; use Illuminate\Database\Query\Builder; use Illuminate\Support\Carbon; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Schema; use RuntimeException; use Throwable; @@ -20,6 +22,8 @@ use Throwable; */ class ThirdPartyWarehouseStorageMirrorRepository { + private const REPORT_QUERY_TIMEOUT_SECONDS = 30; + public const DATASET_KEY = 'jiansun.third-party-warehouse-storage'; public const WAREHOUSE_ID = 15045364; @@ -305,13 +309,13 @@ class ThirdPartyWarehouseStorageMirrorRepository $period = $this->inventoryReportPeriod((string) ($filters['month'] ?? '')); $authoritative = $this->monthlyInventoryAuthoritativeQuery($period, $filters); if ($authoritative !== null) { - $total = $includeTotal ? (clone $authoritative)->count() : 0; - $rows = (clone $authoritative) + $total = $includeTotal ? $this->boundedCount(clone $authoritative) : 0; + $authoritativePage = (clone $authoritative) ->orderBy('warehouse_provider') ->orderBy('warehouse') ->orderBy('product_code') - ->forPage($page, $perPage) - ->get() + ->forPage($page, $perPage); + $rows = $this->boundedGet($authoritativePage) ->map(static fn (object $row): object => (object) [ 'owner' => $row->owner, 'month' => $row->report_month, @@ -351,17 +355,38 @@ class ThirdPartyWarehouseStorageMirrorRepository throw new RuntimeException('inventory_report_mirror_not_ready'); } - $rolledForward = $this->monthlyInventoryRollForward( - $period, - $filters, - $headerGeneration, - $itemGeneration, - $inventoryGeneration, - $page, - $perPage, - $includeTotal, - $state, - ); + $authority = $this->previousMonthlyInventoryAuthority($period); + $resolveRollForward = fn (): ?array => $authority === null + ? null + : $this->monthlyInventoryRollForward( + $period, + $filters, + $authority, + $headerGeneration, + $itemGeneration, + $inventoryGeneration, + $page, + $perPage, + $includeTotal, + $state, + ); + $rolledForward = $includeTotal && $perPage <= 100 && $authority !== null + ? Cache::remember( + $this->monthlyInventoryReportCacheKey( + $period, + $filters, + $authority, + $headerGeneration, + $itemGeneration, + $inventoryGeneration, + $page, + $perPage, + $state, + ), + 120, + $resolveRollForward, + ) + : $resolveRollForward(); if ($rolledForward !== null) { return $rolledForward; } @@ -412,11 +437,11 @@ class ThirdPartyWarehouseStorageMirrorRepository $this->containsFilter($candidates, 's.category', $categoryFilter); } - $total = $includeTotal ? (clone $candidates)->count() : 0; + $total = $includeTotal ? $this->boundedCount(clone $candidates) : 0; $pagePairs = (clone $candidates) ->orderBy('pairs.wms_co_id')->orderBy('pairs.wh_id')->orderBy('s.sku_id') ->forPage($page, $perPage); - $pairRows = (clone $pagePairs)->get(); + $pairRows = $this->boundedGet(clone $pagePairs); if ($pairRows->isEmpty()) { return [ 'data' => [], @@ -492,8 +517,9 @@ class ThirdPartyWarehouseStorageMirrorRepository } $monthly->selectRaw('0 AS opening_aux'); - $rows = $this->connection->query()->fromSub($monthly, 'm') - ->select('m.*')->orderBy('m.warehouse_name')->orderBy('m.wh_id')->orderBy('m.sku_code')->get() + $monthlyRows = $this->connection->query()->fromSub($monthly, 'm') + ->select('m.*')->orderBy('m.warehouse_name')->orderBy('m.wh_id')->orderBy('m.sku_code'); + $rows = $this->boundedGet($monthlyRows) ->map(function (object $row) use ($period): object { $warehouse = trim((string) ($row->warehouse_name ?? '')); $subwarehouseId = (int) ($row->wh_id ?? 0); @@ -607,7 +633,7 @@ class ThirdPartyWarehouseStorageMirrorRepository $state = $this->connection->table(self::INVENTORY_REPORT_AUTHORITATIVE_STATE_TABLE) ->where('dataset_key', self::INVENTORY_REPORT_AUTHORITATIVE_DATASET_KEY) ->whereNotNull('active_generation') - ->whereDate('source_month', $month) + ->where('source_month', $month) ->first(); if ($state === null || trim((string) $state->active_generation) === '') { return null; @@ -615,7 +641,7 @@ class ThirdPartyWarehouseStorageMirrorRepository $query = $this->connection->table(self::INVENTORY_REPORT_AUTHORITATIVE_ROW_TABLE) ->where('generation', (string) $state->active_generation) - ->whereDate('report_month', $month) + ->where('report_month', $month) ->select([ 'owner', 'report_month', 'warehouse_provider', 'warehouse', 'product_code', 'category', 'opening_quantity', 'closing_quantity', @@ -644,12 +670,14 @@ class ThirdPartyWarehouseStorageMirrorRepository * * @param array{start:string,end:string} $period * @param array $filters + * @param array{generation:string,source_month:string} $authority * @param array $state * @return array{data:list,total:int,state:array,sync:array}|null */ private function monthlyInventoryRollForward( array $period, array $filters, + array $authority, string $headerGeneration, string $itemGeneration, string $inventoryGeneration, @@ -659,11 +687,6 @@ class ThirdPartyWarehouseStorageMirrorRepository array $state, ): ?array { try { - $authority = $this->previousMonthlyInventoryAuthority($period); - if ($authority === null) { - return null; - } - $latestProducts = $this->connection->table(self::PRODUCT_TABLE) ->where('sku_type', 'normal')->whereNotNull('sku_id')->where('sku_id', '!=', '') ->groupBy('sku_id')->select('sku_id')->selectRaw('MAX(autoid) AS autoid'); @@ -674,7 +697,7 @@ class ThirdPartyWarehouseStorageMirrorRepository $authorityRows = $this->connection ->table(self::INVENTORY_REPORT_AUTHORITATIVE_ROW_TABLE . ' as authority') ->where('authority.generation', $authority['generation']) - ->whereDate('authority.report_month', $authority['source_month']); + ->where('authority.report_month', $authority['source_month']); $productCode = trim((string) ($filters['product_code'] ?? '')); if ($productCode !== '') { $authorityRows->where('authority.product_code', $productCode); @@ -734,7 +757,7 @@ class ThirdPartyWarehouseStorageMirrorRepository $prior->selectRaw('1') ->from(self::INVENTORY_REPORT_AUTHORITATIVE_ROW_TABLE . ' as prior') ->where('prior.generation', $authority['generation']) - ->whereDate('prior.report_month', $authority['source_month']) + ->where('prior.report_month', $authority['source_month']) ->whereColumn('prior.product_code', 'inventory.sku_code') ->whereColumn('prior.warehouse_provider', 'w.name') ->whereRaw('prior.warehouse = ' . $inventoryWarehouseSql, $inventoryWarehouseBindings); @@ -751,12 +774,20 @@ class ThirdPartyWarehouseStorageMirrorRepository $candidateUnion = $authorityCandidates->unionAll($inventoryDimensions); $candidates = $this->connection->query()->fromSub($candidateUnion, 'candidate_rows'); - $total = $includeTotal ? $this->connection->query()->fromSub(clone $candidates, 'counted')->count() : 0; $pageCandidates = (clone $candidates) ->orderBy('warehouse_provider')->orderBy('wh_id')->orderBy('sku_code') ->forPage($page, $perPage); - $pairRows = (clone $pageCandidates)->get(); + if ($includeTotal) { + $pageCandidates->select('candidate_rows.*')->selectRaw('COUNT(*) OVER() AS report_total'); + } + $pairRows = $this->boundedGet(clone $pageCandidates); + $total = $includeTotal ? (int) ($pairRows->first()->report_total ?? 0) : 0; if ($pairRows->isEmpty()) { + if ($includeTotal && $page > 1) { + $total = $this->boundedCount(clone $authorityRows) + + $this->boundedCount(clone $inventoryDimensions); + } + return [ 'data' => [], 'total' => $total, @@ -767,77 +798,91 @@ class ThirdPartyWarehouseStorageMirrorRepository $authorityEnd = Carbon::parse($authority['source_month']) ->addMonth()->startOfMonth()->format('Y-m-d 00:00:00'); - $movements = $this->confirmedMovements($headerGeneration, $itemGeneration, null) - ->where('h.occurred_at', '>=', $authorityEnd) - ->where('h.occurred_at', '<', $period['end']) - ->whereIn('h.wms_co_id', $pairRows->pluck('wms_co_id')->filter()->unique()->values()->all()) - ->whereIn('i.sku_code', $pairRows->pluck('sku_code')->unique()->values()->all()) - ->select([ - 'h.wms_co_id', 'h.io_type', 'h.occurred_at', 'i.sku_code', - 'i.qty as movement_qty', 'i.qty_type as movement_qty_type', - ])->selectRaw('COALESCE(NULLIF(h.wh_id, 0), 1) AS wh_id'); - $signed = '(COALESCE(mv.movement_qty, 0) * COALESCE(mv.movement_qty_type, 0))'; - $monthly = $this->connection->query()->fromSub($pageCandidates, 'a') - ->leftJoinSub($movements, 'mv', function ($join): void { - $join->on('mv.wms_co_id', '=', 'a.wms_co_id') - ->on('mv.wh_id', '=', 'a.wh_id') - ->on('mv.sku_code', '=', 'a.sku_code'); - })->groupBy([ - 'a.owner', 'a.warehouse_provider', 'a.source_warehouse', 'a.wms_co_id', - 'a.wh_id', 'a.sku_code', 'a.category', 'a.opening_qty', - ])->select([ - 'a.owner', 'a.warehouse_provider', 'a.source_warehouse', 'a.wms_co_id', - 'a.wh_id', 'a.sku_code', 'a.category', 'a.opening_qty', - ]); - $monthly->selectRaw( - 'a.opening_qty + COALESCE(SUM(CASE WHEN mv.occurred_at >= ? AND mv.occurred_at < ? ' - . 'THEN ' . $signed . ' ELSE 0 END), 0) AS rolled_opening_qty', - [$authorityEnd, $period['start']], - ); - foreach (self::MOVEMENT_COLUMNS as $column => $type) { - $expression = 'COALESCE(SUM(CASE WHEN mv.occurred_at >= ? AND mv.occurred_at < ? ' - . 'AND mv.io_type = ? THEN ' . $signed . ' ELSE 0 END), 0)'; - $monthly->selectRaw($expression . ' AS ' . $column, [$period['start'], $period['end'], $type]); + $movementPairs = $pairRows->filter( + static fn (object $row): bool => $row->wms_co_id !== null && trim((string) $row->sku_code) !== '', + )->unique(static fn (object $row): string => implode(':', [ + (string) $row->wms_co_id, + (string) $row->wh_id, + (string) $row->sku_code, + ]))->values(); + $movementRows = collect(); + if ($movementPairs->isNotEmpty()) { + $movements = $this->confirmedMovements($headerGeneration, $itemGeneration, null) + ->where('h.occurred_at', '>=', $authorityEnd) + ->where('h.occurred_at', '<', $period['end']) + ->where(function (Builder $pairs) use ($movementPairs): void { + foreach ($movementPairs as $pair) { + $pairs->orWhere(function (Builder $candidate) use ($pair): void { + $candidate->where('h.wms_co_id', $pair->wms_co_id) + ->whereRaw('COALESCE(NULLIF(h.wh_id, 0), 1) = ?', [(int) $pair->wh_id]) + ->where('i.sku_code', $pair->sku_code); + }); + } + }) + ->groupBy(['h.wms_co_id', 'i.sku_code']) + ->groupByRaw('COALESCE(NULLIF(h.wh_id, 0), 1)') + ->select(['h.wms_co_id', 'i.sku_code']) + ->selectRaw('COALESCE(NULLIF(h.wh_id, 0), 1) AS wh_id'); + $signed = '(COALESCE(i.qty, 0) * COALESCE(i.qty_type, 0))'; + $movements->selectRaw( + 'COALESCE(SUM(CASE WHEN h.occurred_at >= ? AND h.occurred_at < ? ' + . 'THEN ' . $signed . ' ELSE 0 END), 0) AS rolled_opening_delta', + [$authorityEnd, $period['start']], + ); + foreach (self::MOVEMENT_COLUMNS as $column => $type) { + $expression = 'COALESCE(SUM(CASE WHEN h.occurred_at >= ? AND h.occurred_at < ? ' + . 'AND h.io_type = ? THEN ' . $signed . ' ELSE 0 END), 0)'; + $movements->selectRaw($expression . ' AS ' . $column, [$period['start'], $period['end'], $type]); + } + $movementRows = $this->boundedGet($movements)->keyBy( + static fn (object $row): string => implode(':', [ + (string) $row->wms_co_id, + (string) $row->wh_id, + (string) $row->sku_code, + ]), + ); } - $rows = $monthly->orderBy('a.warehouse_provider')->orderBy('a.wh_id')->orderBy('a.sku_code') - ->get()->map(function (object $row) use ($period): object { - $sourceWarehouse = trim((string) ($row->source_warehouse ?? '')); - $warehouse = $sourceWarehouse !== '' - ? $sourceWarehouse - : $this->subwarehouseName( - trim((string) $row->warehouse_provider), - (int) $row->wh_id, - ); - $closingQuantity = (float) $row->rolled_opening_qty; - foreach (array_keys(self::MOVEMENT_COLUMNS) as $column) { - $closingQuantity += (float) $row->{$column}; - } + $rows = $pairRows->map(function (object $row) use ($movementRows, $period): object { + $key = implode(':', [(string) $row->wms_co_id, (string) $row->wh_id, (string) $row->sku_code]); + $movement = $movementRows->get($key); + $sourceWarehouse = trim((string) ($row->source_warehouse ?? '')); + $warehouse = $sourceWarehouse !== '' + ? $sourceWarehouse + : $this->subwarehouseName( + trim((string) $row->warehouse_provider), + (int) $row->wh_id, + ); + $rolledOpening = (float) $row->opening_qty + (float) ($movement->rolled_opening_delta ?? 0); + $closingQuantity = $rolledOpening; + foreach (array_keys(self::MOVEMENT_COLUMNS) as $column) { + $closingQuantity += (float) ($movement->{$column} ?? 0); + } - return (object) [ - 'owner' => $row->owner, - 'month' => Carbon::parse($period['start'])->toDateString(), - 'warehouse_provider' => $row->warehouse_provider, - 'warehouse' => $warehouse, - 'product_code' => $row->sku_code, - 'category' => $row->category, - 'opening_quantity' => $row->rolled_opening_qty, - 'closing_quantity' => $closingQuantity, - 'processing_in' => $row->processing_in, - 'processing_out' => $row->processing_out, - 'purchase_in' => $row->purchase_in, - 'purchase_return' => $row->purchase_return, - 'other_in' => $row->other_in, - 'other_return' => $row->other_return, - 'sales_out' => $row->sales_out, - 'sales_return' => $row->sales_return, - 'stocktake' => $row->stocktake, - 'transfer_out' => $row->transfer_out, - 'transfer_in' => $row->transfer_in, - 'opening' => 0, - 'other_out' => $row->other_out, - ]; - })->all(); + return (object) [ + 'owner' => $row->owner, + 'month' => Carbon::parse($period['start'])->toDateString(), + 'warehouse_provider' => $row->warehouse_provider, + 'warehouse' => $warehouse, + 'product_code' => $row->sku_code, + 'category' => $row->category, + 'opening_quantity' => $rolledOpening, + 'closing_quantity' => $closingQuantity, + 'processing_in' => $movement->processing_in ?? 0, + 'processing_out' => $movement->processing_out ?? 0, + 'purchase_in' => $movement->purchase_in ?? 0, + 'purchase_return' => $movement->purchase_return ?? 0, + 'other_in' => $movement->other_in ?? 0, + 'other_return' => $movement->other_return ?? 0, + 'sales_out' => $movement->sales_out ?? 0, + 'sales_return' => $movement->sales_return ?? 0, + 'stocktake' => $movement->stocktake ?? 0, + 'transfer_out' => $movement->transfer_out ?? 0, + 'transfer_in' => $movement->transfer_in ?? 0, + 'opening' => 0, + 'other_out' => $movement->other_out ?? 0, + ]; + })->all(); return [ 'data' => $rows, @@ -846,12 +891,52 @@ class ThirdPartyWarehouseStorageMirrorRepository 'sync' => $this->syncStatus($state), ]; } catch (Throwable $exception) { + if ($this->isStatementTimeout($exception)) { + throw $exception; + } report($exception); return null; } } + /** + * @param array{start:string,end:string} $period + * @param array $filters + * @param array{generation:string,source_month:string} $authority + * @param array $state + */ + private function monthlyInventoryReportCacheKey( + array $period, + array $filters, + array $authority, + string $headerGeneration, + string $itemGeneration, + string $inventoryGeneration, + int $page, + int $perPage, + array $state, + ): string { + $normalizedFilters = [ + 'product_code' => trim((string) ($filters['product_code'] ?? '')), + 'warehouse_provider' => trim((string) ($filters['warehouse_provider'] ?? '')), + 'category' => trim((string) ($filters['category'] ?? '')), + ]; + + return 'inventory-report:monthly-page:v2:' . hash('sha256', (string) json_encode([ + 'period' => $period, + 'filters' => $normalizedFilters, + 'authority' => $authority, + 'header_generation' => $headerGeneration, + 'item_generation' => $itemGeneration, + 'inventory_generation' => $inventoryGeneration, + 'status' => $state['status'] ?? null, + 'last_succeeded_at' => $state['last_succeeded_at'] ?? null, + 'page' => $page, + 'per_page' => $perPage, + ], JSON_UNESCAPED_UNICODE)); + } + /** @param array{start:string,end:string} $period */ private function previousMonthlyInventoryAuthority(array $period): ?array { @@ -1609,4 +1694,34 @@ class ThirdPartyWarehouseStorageMirrorRepository { return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $value) . '%'; } + + /** @return Collection */ + private function boundedGet(Builder $query): Collection + { + if ($this->connection->getDriverName() !== 'mysql') { + return $query->get(); + } + + return collect($this->connection->select( + 'SET STATEMENT max_statement_time=' . self::REPORT_QUERY_TIMEOUT_SECONDS . ' FOR ' . $query->toSql(), + $query->getBindings(), + )); + } + + private function boundedCount(Builder $query): int + { + $count = $this->connection->query() + ->fromSub($query, 'bounded_count') + ->selectRaw('COUNT(*) AS aggregate'); + + return (int) ($this->boundedGet($count)->first()->aggregate ?? 0); + } + + private function isStatementTimeout(Throwable $exception): bool + { + $message = strtolower($exception->getMessage()); + + return str_contains($message, 'max_statement_time') + || str_contains($message, 'query execution was interrupted'); + } } diff --git a/tests/Unit/EcommerceFinance/StoreReceivingAlipayBillMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/StoreReceivingAlipayBillMirrorRepositoryTest.php index f6c4c46a..05e262b2 100644 --- a/tests/Unit/EcommerceFinance/StoreReceivingAlipayBillMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/StoreReceivingAlipayBillMirrorRepositoryTest.php @@ -349,7 +349,14 @@ class StoreReceivingAlipayBillMirrorRepositoryTest extends TestCase $this->assertSame(2, $first['total']); $this->assertSame(2, $second['total']); $this->assertNotSame($first['data'][0]['id'], $second['data'][0]['id']); - $this->assertCount(2, array_filter($db->getQueryLog(), fn ($q) => str_contains(strtolower($q['query']), 'count(*)'))); + $aggregateQueries = array_values(array_filter( + $db->getQueryLog(), + static fn (array $query): bool => str_contains(strtolower($query['query']), 'count(*)'), + )); + $this->assertCount(1, $aggregateQueries); + foreach ($aggregateQueries as $query) { + $this->assertStringContainsString('sum(case', strtolower($query['query'])); + } $db->disableQueryLog(); $db->table('ops_jst_original_bill_info')->insert($this->row(3, 7, '2026-08-03 09:00:00', '1', 4)); @@ -484,7 +491,7 @@ class StoreReceivingAlipayBillMirrorRepositoryTest extends TestCase static fn (array $query): bool => str_contains($query['query'], '"b"."channel" = ?') && in_array('CommonBillTGCPay', $query['bindings'], true), ); - $this->assertGreaterThanOrEqual(6, count($channelQueries)); + $this->assertGreaterThanOrEqual(5, count($channelQueries)); $db->disableQueryLog(); } diff --git a/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php index b5a30710..af34e786 100644 --- a/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php @@ -222,12 +222,18 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase 'warehouse_id' => 15038426, ]); - $result = $this->repository->paginateMonthlyInventoryReport([ + $db = DB::connection('business_mirror'); + $db->flushQueryLog(); + $db->enableQueryLog(); + $filters = [ 'month' => '2026-09', 'product_code' => 'SKU-ROLL-FORWARD', 'warehouse_provider' => '', 'category' => '', - ], 1, 25); + ]; + $result = $this->repository->paginateMonthlyInventoryReport($filters, 1, 25); + $queries = $db->getQueryLog(); + $db->disableQueryLog(); $rows = collect($result['data'])->keyBy('warehouse'); $this->assertSame(2, $result['total']); @@ -237,6 +243,28 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase $this->assertSame(0.0, (float) $rows['简尚-崔桥工厂仓主仓']->opening_quantity); $this->assertSame(5.0, (float) $rows['简尚-崔桥工厂仓主仓']->closing_quantity); $this->assertSame(5.0, (float) $rows['简尚-崔桥工厂仓主仓']->transfer_in); + + $countQueries = array_values(array_filter( + $queries, + static fn (array $query): bool => str_contains(strtolower($query['query']), 'count(*)'), + )); + $this->assertCount(1, $countQueries); + $this->assertStringContainsString('count(*) over()', strtolower($countQueries[0]['query'])); + $candidatePage = collect($queries) + ->first(static fn (array $query): bool => str_contains($query['query'], '"prior"."report_month"')); + $this->assertNotNull($candidatePage); + $this->assertStringNotContainsString('strftime', strtolower($candidatePage['query'])); + + $db->flushQueryLog(); + $db->enableQueryLog(); + $cached = $this->repository->paginateMonthlyInventoryReport($filters, 1, 25); + $cachedQueries = $db->getQueryLog(); + $db->disableQueryLog(); + $this->assertSame($result['total'], $cached['total']); + $this->assertCount(0, array_filter( + $cachedQueries, + static fn (array $query): bool => str_contains(strtolower($query['query']), 'report_total'), + )); } public function test_inventory_report_rolls_multiple_months_from_the_july_authoritative_baseline(): void