From f92ed47defd6325e45dfcfe03a3d5d0fec3b3144 Mon Sep 17 00:00:00 2001 From: chenxi Date: Sat, 26 Sep 2026 03:21:14 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=BF=9B=E9=94=80=E5=AD=98?= =?UTF-8?q?=E6=8A=A5=E8=A1=A8=E5=A4=A7=E6=95=B0=E6=8D=AE=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Http/Controllers/Business/Pages.php | 75 +++++- app/Jobs/Common/InventoryReportExport.php | 76 ++++++ ...dPartyWarehouseStorageMirrorRepository.php | 221 +++++++++++++----- tests/Feature/Business/PagesTest.php | 94 ++++++-- 4 files changed, 375 insertions(+), 91 deletions(-) create mode 100644 app/Jobs/Common/InventoryReportExport.php diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 64227c40..7aa71b1d 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -11,6 +11,7 @@ use App\Business\UpstreamBusinessInformation; use App\Business\StoreReceivingAccountTemplate; use App\Business\StoreReceivingAccountWorkbookImporter; use App\Business\WithdrawalManagementReport; +use App\Jobs\Common\InventoryReportExport; use App\Models\Banking\MarketingAccountBill; use App\Models\Business\CourierAgreementSignature; use App\Models\Business\CourierQuoteSignature; @@ -28,6 +29,7 @@ use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\Paginator; use Illuminate\Support\Collection; use Illuminate\Support\Carbon; +use Illuminate\Support\Str; use Illuminate\Database\QueryException; use Modules\EcommerceFinance\Exceptions\UpstreamSnapshotPendingException; use Modules\EcommerceFinance\Repositories\AlibabaAccountBalanceRepository; @@ -58,6 +60,7 @@ use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore; use Modules\EcommerceFinance\View\PlatformPromotionAccountPageData; use Modules\EcommerceFinance\View\OrderDataPageData; use RuntimeException; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException; use Throwable; @@ -1308,7 +1311,7 @@ class Pages extends Controller ]); } - public function exportInventoryReport(Request $request): StreamedResponse + public function exportInventoryReport(Request $request): Response { abort_unless( $this->thirdPartyWarehouseStorage->available() @@ -1340,19 +1343,50 @@ class Pages extends Controller $page = max(1, $request->integer('page', 1)); $perPage = min(max((int) setting('default.list_limit', 25), 1), 100); - return response()->streamDownload(function () use ($filters, $columns, $scope, $page, $perPage): void { - set_time_limit(0); - $csv = fopen('php://output', 'w'); + if ($scope === 'all' && should_queue()) { + $fileName = Str::filename('进销存报表') . '-' . now()->format('Ymd_His') . '-' . Str::random(8) . '.csv'; + dispatch(new InventoryReportExport(user(), $filters, $columns, $fileName))->onQueue('exports'); + flash('进销存报表已提交后台生成,完成后可从通知中心下载。')->success(); + + return back(); + } + + if ($scope === 'all') { + // The local environment intentionally uses the sync queue and has + // no worker process. Defer the same export job until the response + // has been sent so a large CSV never depends on a live browser + // download connection. The job stores the completed file and + // raises the existing notification when it is ready. + $fileName = Str::filename('进销存报表') . '-' . now()->format('Ymd_His') . '-' . Str::random(8) . '.csv'; + dispatch(new InventoryReportExport(user(), $filters, $columns, $fileName))->afterResponse(); + flash('进销存报表已提交后台生成,完成后可从通知中心下载。')->success(); + + return back(); + } + + // Build the complete CSV before opening the HTTP response. Streaming + // rows directly from the database keeps the browser request open for + // the whole aggregation and makes a slow client look like a failed + // export. A disk-backed file keeps memory bounded and lets the web + // server send a framed response with a known length. + set_time_limit(0); + $filePath = tempnam(sys_get_temp_dir(), 'inventory-report-'); + if ($filePath === false) { + throw new RuntimeException('无法创建进销存报表导出临时文件。'); + } + + try { + $csv = fopen($filePath, 'wb'); if ($csv === false) { - throw new RuntimeException('无法创建进销存报表导出流。'); + throw new RuntimeException('无法写入进销存报表导出临时文件。'); } fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); fputcsv($csv, array_column($columns, 'label')); - $written = 0; $rows = $scope === 'page' ? $this->thirdPartyWarehouseStorage->paginateMonthlyInventoryReport($filters, $page, $perPage, false)['data'] : $this->thirdPartyWarehouseStorage->exportMonthlyInventoryReportRows($filters); + $written = 0; foreach ($rows as $row) { fputcsv($csv, array_map(function (array $column) use ($row): mixed { $value = data_get($row, $column['name']); @@ -1364,14 +1398,39 @@ class Pages extends Controller : $this->csvText($value)); }, $columns)); $written++; - if ($written % 500 === 0) { + if ($written % 5000 === 0) { fflush($csv); + gc_collect_cycles(); } } fclose($csv); - }, '进销存报表_' . str_replace('-', '', $filters['month']) . '_' . now()->format('Ymd_His') . '.csv', [ + clearstatcache(true, $filePath); + $fileSize = filesize($filePath); + if ($fileSize === false) { + throw new RuntimeException('无法读取进销存报表导出文件大小。'); + } + } catch (Throwable $exception) { + if (isset($csv) && is_resource($csv)) { + fclose($csv); + } + @unlink($filePath); + throw $exception; + } + + $fileName = '进销存报表_' . str_replace('-', '', $filters['month']) . '_' . now()->format('Ymd_His') . '.csv'; + + return response()->streamDownload(function () use ($filePath): void { + try { + set_time_limit(0); + readfile($filePath); + } finally { + @unlink($filePath); + } + }, $fileName, [ 'Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store', + 'X-Accel-Buffering' => 'no', + 'Content-Length' => (string) $fileSize, ]); } diff --git a/app/Jobs/Common/InventoryReportExport.php b/app/Jobs/Common/InventoryReportExport.php new file mode 100644 index 00000000..a23fab2c --- /dev/null +++ b/app/Jobs/Common/InventoryReportExport.php @@ -0,0 +1,76 @@ +onQueue('exports'); + } + + public function handle(ThirdPartyWarehouseStorageMirrorRepository $repository): void + { + $path = storage_path('app/temp/' . $this->file_name); + File::ensureDirectoryExists(dirname($path)); + + try { + $csv = fopen($path, 'wb'); + if ($csv === false) { + throw new \RuntimeException('无法创建进销存报表导出文件。'); + } + + fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); + fputcsv($csv, array_column($this->columns, 'label')); + foreach ($repository->exportMonthlyInventoryReportRows($this->filters) as $row) { + fputcsv($csv, array_map(function (array $column) use ($row): mixed { + $value = data_get($row, $column['name']); + + return $column['name'] === 'product_code' + ? $this->csvIdentifier($value) + : (($column['kind'] ?? null) === 'integer' + ? ($value === null || $value === '' ? '' : $value) + : $this->csvText($value)); + }, $this->columns)); + } + fclose($csv); + + dispatch(new CreateMediableForExport($this->user, $this->file_name, '进销存报表')); + } catch (Throwable $exception) { + if (isset($csv) && is_resource($csv)) { + fclose($csv); + } + @unlink($path); + report($exception); + $this->user->notify(new ExportFailed($exception->getMessage())); + throw $exception; + } + } + + private function csvIdentifier(mixed $value): string + { + $text = trim((string) ($value ?? '')); + + return $text === '' ? '' : "'" . $text; + } + + private function csvText(mixed $value): string + { + return trim((string) ($value ?? '')); + } +} diff --git a/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php b/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php index 60749544..6455a304 100644 --- a/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php @@ -25,6 +25,14 @@ class ThirdPartyWarehouseStorageMirrorRepository { private const REPORT_QUERY_TIMEOUT_SECONDS = 30; + /** Export queries may aggregate a larger batch, but must stay below the web proxy timeout. */ + private const REPORT_EXPORT_QUERY_TIMEOUT_SECONDS = 90; + + /** Shared movement aggregate is executed once per full export. */ + private const REPORT_EXPORT_AGGREGATE_TIMEOUT_SECONDS = 180; + + private const REPORT_EXPORT_BATCH_SIZE = 50000; + private const REPORT_CACHE_SECONDS = 93600; private const REPORT_CACHE_LOCK_SECONDS = 45; @@ -306,6 +314,9 @@ class ThirdPartyWarehouseStorageMirrorRepository bool $includeTotal = true, ?array $state = null, ?array $sourceCounts = null, + ?int $queryTimeoutSeconds = null, + ?Collection $prefetchedMovementRows = null, + bool $refreshSourceCounts = false, ): array { $state ??= $this->requireReadyState(); @@ -313,7 +324,8 @@ class ThirdPartyWarehouseStorageMirrorRepository throw new RuntimeException('inventory_report_all_warehouses_not_ready'); } $page = max($page, 1); - $perPage = min(max($perPage, 1), 5000); + $perPage = min(max($perPage, 1), $prefetchedMovementRows === null ? 5000 : self::REPORT_EXPORT_BATCH_SIZE); + $queryTimeoutSeconds ??= self::REPORT_QUERY_TIMEOUT_SECONDS; $period = $this->inventoryReportPeriod((string) ($filters['month'] ?? '')); // 月报的进出数量统一来自聚水潭进出仓明细;权威月报只提供同月的期初维度。 $headerGeneration = $this->generation($state, 'active_header_generation'); @@ -341,6 +353,9 @@ class ThirdPartyWarehouseStorageMirrorRepository $state, $authorityIsCurrentMonth, $sourceCounts, + $queryTimeoutSeconds, + $prefetchedMovementRows, + $refreshSourceCounts, ); $rolledForward = $includeTotal && $perPage <= 100 && $authority !== null ? $this->rememberMonthlyInventoryReport( @@ -410,11 +425,11 @@ class ThirdPartyWarehouseStorageMirrorRepository $this->containsFilter($candidates, 's.category', $categoryFilter); } - $total = $includeTotal ? $this->boundedCount(clone $candidates) : 0; + $total = $includeTotal ? $this->boundedCount(clone $candidates, $queryTimeoutSeconds) : 0; $pagePairs = (clone $candidates) ->orderBy('pairs.wms_co_id')->orderBy('pairs.wh_id')->orderBy('s.sku_id') ->forPage($page, $perPage); - $pairRows = $this->boundedGet(clone $pagePairs); + $pairRows = $this->boundedGet(clone $pagePairs, $queryTimeoutSeconds); if ($pairRows->isEmpty()) { return [ 'data' => [], @@ -492,7 +507,7 @@ class ThirdPartyWarehouseStorageMirrorRepository $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) + $rows = $this->boundedGet($monthlyRows, $queryTimeoutSeconds) ->map(function (object $row) use ($period): object { $warehouse = trim((string) ($row->warehouse_name ?? '')); $subwarehouseId = (int) ($row->wh_id ?? 0); @@ -550,12 +565,16 @@ class ThirdPartyWarehouseStorageMirrorRepository * @param array $filters * @return Generator */ - public function exportMonthlyInventoryReportRows(array $filters, int $batchSize = 500): Generator + public function exportMonthlyInventoryReportRows(array $filters, int $batchSize = self::REPORT_EXPORT_BATCH_SIZE): Generator { - $batchSize = min(max($batchSize, 1), 5000); + $batchSize = min(max($batchSize, 1), self::REPORT_EXPORT_BATCH_SIZE); $page = 1; $state = $this->requireReadyState(); $sourceCounts = null; + $prefetchedMovementRows = $this->prefetchMonthlyMovementRows($filters, $state); + $refreshSourceCounts = true; + $exported = 0; + $expectedTotal = null; while (true) { $result = $this->paginateMonthlyInventoryReport( @@ -565,21 +584,40 @@ class ThirdPartyWarehouseStorageMirrorRepository false, $state, $sourceCounts, + self::REPORT_EXPORT_QUERY_TIMEOUT_SECONDS, + $prefetchedMovementRows, + $refreshSourceCounts, ); + $refreshSourceCounts = false; if (isset($result['source_counts']) && is_array($result['source_counts'])) { $sourceCounts = $result['source_counts']; + $expectedTotal ??= array_sum(array_map('intval', $sourceCounts)); } $rows = $result['data'] ?? []; if ($rows === []) { return; } + $rowCount = count($rows); + if ($expectedTotal !== null) { + $remaining = $expectedTotal - $exported; + if ($remaining <= 0) { + return; + } + if ($rowCount > $remaining) { + $rows = array_slice($rows, 0, $remaining); + $rowCount = count($rows); + } + } foreach ($rows as $row) { yield $row; } + $exported += $rowCount; - if (count($rows) < $batchSize) { + // Release the previous batch before constructing the next SQL result. + unset($rows, $result); + if ($rowCount < $batchSize || ($expectedTotal !== null && $exported >= $expectedTotal)) { return; } $page++; @@ -660,8 +698,12 @@ class ThirdPartyWarehouseStorageMirrorRepository array $state, bool $authorityIsCurrentMonth = false, ?array $sourceCounts = null, + ?int $queryTimeoutSeconds = null, + ?Collection $prefetchedMovementRows = null, + bool $refreshSourceCounts = false, ): ?array { try { + $queryTimeoutSeconds ??= self::REPORT_QUERY_TIMEOUT_SECONDS; $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'); @@ -836,28 +878,23 @@ class ThirdPartyWarehouseStorageMirrorRepository ]; $counts = is_array($sourceCounts) ? array_values($sourceCounts) : []; $total = 0; - if ($includeTotal) { - $countCacheKey = $this->monthlyInventoryReportCacheKey( - $period, $filters, $authority, $headerGeneration, $itemGeneration, - $inventoryGeneration, 0, 0, - ) . ':source-counts'; - $cachedCounts = Cache::get($countCacheKey); + $countCacheKey = $this->monthlyInventoryReportCacheKey( + $period, $filters, $authority, $headerGeneration, $itemGeneration, + $inventoryGeneration, 0, 0, + ) . ':source-counts'; + if ($includeTotal || $counts === [] || $refreshSourceCounts) { + $cachedCounts = $refreshSourceCounts ? null : Cache::get($countCacheKey); if (is_array($cachedCounts) && count($cachedCounts) === count($sources)) { - $counts = $cachedCounts; + $counts = array_values($cachedCounts); } else { foreach ($sources as $index => [, $countQuery]) { - $counts[$index] = $this->boundedCount(clone $countQuery); + $counts[$index] = $this->boundedCount(clone $countQuery, $queryTimeoutSeconds); } Cache::put($countCacheKey, $counts, self::REPORT_CACHE_SECONDS); } + } + if ($includeTotal) { $total = array_sum($counts); - } elseif ($counts === []) { - // The exporter walks the same ordered source pages repeatedly. Count - // each source once and pass the result into later batches instead of - // re-running the candidate counts for every page. - foreach ($sources as $index => [, $countQuery]) { - $counts[$index] = $this->boundedCount(clone $countQuery); - } } $offset = ($page - 1) * $perPage; $remaining = $perPage; @@ -896,7 +933,7 @@ class ThirdPartyWarehouseStorageMirrorRepository foreach ($candidatePages as $candidatePage) { $pageCandidates->unionAll($candidatePage); } - $pairRows = $this->boundedGet(clone $pageCandidates); + $pairRows = $this->boundedGet(clone $pageCandidates, $queryTimeoutSeconds); if ($pairRows->isEmpty()) { return [ 'data' => [], @@ -914,42 +951,24 @@ class ThirdPartyWarehouseStorageMirrorRepository (string) $row->wh_id, (string) $row->sku_code, ]))->values(); - $movementRows = collect(); + $movementRows = $prefetchedMovementRows ?? collect(); if ($movementPairs->isNotEmpty()) { - $movements = $this->confirmedMovements($headerGeneration, $itemGeneration, null) - ->where('h.occurred_at', '>=', $movementStart) - ->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]); + if ($prefetchedMovementRows === null) { + $movements = $this->aggregateMonthlyMovementQuery( + $period, + $authorityEnd, + $movementStart, + $headerGeneration, + $itemGeneration, + ); + $movementRows = $this->boundedGet($movements, $queryTimeoutSeconds)->keyBy( + static fn (object $row): string => implode(':', [ + (string) $row->wms_co_id, + (string) $row->wh_id, + (string) $row->sku_code, + ]), + ); } - $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 = $pairRows->map(function (object $row) use ($movementRows, $period): object { @@ -1010,6 +1029,84 @@ class ThirdPartyWarehouseStorageMirrorRepository } } + /** + * Aggregate the complete movement range once for a full export. The normal + * page query intentionally limits movements to the current page, but doing + * that for every export batch repeats the same expensive scan thousands of + * times. The result is keyed by warehouse, subwarehouse and SKU so each + * page can perform an O(1) lookup. + * + * @param array $filters + * @param array $state + * @return Collection|null + */ + private function prefetchMonthlyMovementRows(array $filters, array $state): ?Collection + { + $period = $this->inventoryReportPeriod((string) ($filters['month'] ?? '')); + $headerGeneration = $this->generation($state, 'active_header_generation'); + $itemGeneration = $this->generation($state, 'active_item_generation'); + if ($headerGeneration === null || $itemGeneration === null) { + return null; + } + + $currentAuthority = $this->currentMonthlyInventoryAuthority($period); + $authority = $currentAuthority ?? $this->previousMonthlyInventoryAuthority($period); + if ($authority === null) { + return null; + } + + $authorityEnd = Carbon::parse($authority['source_month']) + ->addMonth()->startOfMonth()->format('Y-m-d 00:00:00'); + $movementStart = $currentAuthority !== null ? $period['start'] : $authorityEnd; + $query = $this->aggregateMonthlyMovementQuery( + $period, + $authorityEnd, + $movementStart, + $headerGeneration, + $itemGeneration, + ); + + return $this->boundedGet($query, self::REPORT_EXPORT_AGGREGATE_TIMEOUT_SECONDS)->keyBy( + static fn (object $row): string => implode(':', [ + (string) $row->wms_co_id, + (string) $row->wh_id, + (string) $row->sku_code, + ]), + ); + } + + /** + * @param array{start:string,end:string} $period + */ + private function aggregateMonthlyMovementQuery( + array $period, + string $authorityEnd, + string $movementStart, + string $headerGeneration, + string $itemGeneration, + ): Builder { + $movements = $this->confirmedMovements($headerGeneration, $itemGeneration, null) + ->where('h.occurred_at', '>=', $movementStart) + ->where('h.occurred_at', '<', $period['end']) + ->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]); + } + + return $movements; + } + /** * @param array{start:string,end:string} $period * @param array $filters @@ -1858,25 +1955,27 @@ class ThirdPartyWarehouseStorageMirrorRepository } /** @return Collection */ - private function boundedGet(Builder $query): Collection + private function boundedGet(Builder $query, ?int $timeoutSeconds = null): Collection { if ($this->connection->getDriverName() !== 'mysql') { return $query->get(); } + $timeoutSeconds = max(1, $timeoutSeconds ?? self::REPORT_QUERY_TIMEOUT_SECONDS); + return collect($this->connection->select( - 'SET STATEMENT max_statement_time=' . self::REPORT_QUERY_TIMEOUT_SECONDS . ' FOR ' . $query->toSql(), + 'SET STATEMENT max_statement_time=' . $timeoutSeconds . ' FOR ' . $query->toSql(), $query->getBindings(), )); } - private function boundedCount(Builder $query): int + private function boundedCount(Builder $query, ?int $timeoutSeconds = null): int { $count = $this->connection->query() ->fromSub($query, 'bounded_count') ->selectRaw('COUNT(*) AS aggregate'); - return (int) ($this->boundedGet($count)->first()->aggregate ?? 0); + return (int) ($this->boundedGet($count, $timeoutSeconds)->first()->aggregate ?? 0); } private function isStatementTimeout(Throwable $exception): bool diff --git a/tests/Feature/Business/PagesTest.php b/tests/Feature/Business/PagesTest.php index b76d3886..73d18126 100644 --- a/tests/Feature/Business/PagesTest.php +++ b/tests/Feature/Business/PagesTest.php @@ -3,6 +3,8 @@ namespace Tests\Feature\Business; use App\Models\Auth\Permission; +use App\Jobs\Common\InventoryReportExport; +use Illuminate\Support\Facades\Queue; use Mockery; use Modules\EcommerceFinance\Repositories\CourierFreightReconciliationMirrorRepository; use Modules\EcommerceFinance\Repositories\NonJingxiRefundMirrorRepository; @@ -94,28 +96,33 @@ class PagesTest extends FeatureTestCase $repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class); $repository->shouldReceive('available')->once()->andReturnTrue(); $repository->shouldReceive('allWarehousesReady')->once()->andReturnTrue(); - $repository->shouldReceive('exportMonthlyInventoryReportRows')->once()->with($filters) - ->andReturn((static function (): \Generator { - yield (object) [ - 'owner' => '常州简尚家居有限公司', - 'month' => '2026-08-01', - 'warehouse_provider' => '测试仓', - 'warehouse' => '测试仓', - 'product_code' => '121630-49', - 'category' => '家具', - 'opening_quantity' => 10, - 'closing_quantity' => 12, - ]; - })()); + $repository->shouldReceive('paginateMonthlyInventoryReport')->once()->with( + $filters, + 1, + Mockery::type('int'), + false, + )->andReturn([ + 'data' => [(object) [ + 'owner' => '常州简尚家居有限公司', + 'month' => '2026-08-01', + 'warehouse_provider' => '测试仓', + 'warehouse' => '测试仓', + 'product_code' => '121630-49', + 'category' => '家具', + 'opening_quantity' => 10, + 'closing_quantity' => 12, + ]], + ]); $this->app->instance(ThirdPartyWarehouseStorageMirrorRepository::class, $repository); $response = $this->loginAs()->get(route( 'business.product-inventory.inventory-report.export', - $filters, + $filters + ['scope' => 'page'], )); - $response->assertOk()->assertDownload(); + $response->assertOk()->assertDownload()->assertHeader('X-Accel-Buffering', 'no'); $content = $response->streamedContent(); + $this->assertSame((string) strlen($content), $response->headers->get('Content-Length')); $this->assertStringStartsWith(chr(0xEF) . chr(0xBB) . chr(0xBF), $content); $this->assertStringContainsString('货主,月份,仓储方,仓库,商品编码,分类,期初数量,期末数量', $content); $this->assertStringContainsString("'121630-49", $content); @@ -126,17 +133,22 @@ class PagesTest extends FeatureTestCase $repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class); $repository->shouldReceive('available')->once()->andReturnTrue(); $repository->shouldReceive('allWarehousesReady')->once()->andReturnTrue(); - $repository->shouldReceive('exportMonthlyInventoryReportRows')->once()->with([ - 'month' => '2026-08', 'product_code' => '121630-49', - 'warehouse_provider' => '', 'category' => '', - ])->andReturn((static function (): \Generator { - yield (object) ['product_code' => '121630-49', 'warehouse' => '主仓', 'opening_quantity' => 10]; - })()); + $repository->shouldReceive('paginateMonthlyInventoryReport')->once()->with( + [ + 'month' => '2026-08', 'product_code' => '121630-49', + 'warehouse_provider' => '', 'category' => '', + ], + 1, + Mockery::type('int'), + false, + )->andReturn([ + 'data' => [(object) ['product_code' => '121630-49', 'warehouse' => '主仓', 'opening_quantity' => 10]], + ]); $this->app->instance(ThirdPartyWarehouseStorageMirrorRepository::class, $repository); $response = $this->loginAs()->get(route('business.product-inventory.inventory-report.export', [ 'month' => '2026-08', 'product_code' => '121630-49', - 'columns' => 'product_code,warehouse,opening_quantity', + 'columns' => 'product_code,warehouse,opening_quantity', 'scope' => 'page', ])); $response->assertOk()->assertDownload(); @@ -165,6 +177,44 @@ class PagesTest extends FeatureTestCase $this->assertStringContainsString("商品编码\n'PAGE-2\n", $response->streamedContent()); } + public function testInventoryReportFullExportUsesTheQueueWhenEnabled(): void + { + Queue::fake(); + config(['queue.default' => 'database']); + + $repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class); + $repository->shouldReceive('available')->once()->andReturnTrue(); + $repository->shouldReceive('allWarehousesReady')->once()->andReturnTrue(); + $repository->shouldNotReceive('exportMonthlyInventoryReportRows'); + $this->app->instance(ThirdPartyWarehouseStorageMirrorRepository::class, $repository); + + $this->loginAs()->get(route('business.product-inventory.inventory-report.export', [ + 'month' => '2026-08', + 'scope' => 'all', + ]))->assertRedirect(); + + Queue::assertPushed(InventoryReportExport::class); + } + + public function testInventoryReportFullExportDefersAfterResponseWhenQueueIsDisabled(): void + { + Queue::fake(); + config(['queue.default' => 'sync']); + + $repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class); + $repository->shouldReceive('available')->once()->andReturnTrue(); + $repository->shouldReceive('allWarehousesReady')->once()->andReturnTrue(); + $repository->shouldNotReceive('exportMonthlyInventoryReportRows'); + $this->app->instance(ThirdPartyWarehouseStorageMirrorRepository::class, $repository); + + $this->loginAs()->get(route('business.product-inventory.inventory-report.export', [ + 'month' => '2026-08', + 'scope' => 'all', + ]))->assertRedirect(); + + Queue::assertPushed(InventoryReportExport::class); + } + public function testInventoryReportExportRejectsFieldsOutsideThePageSchema(): void { $repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class);