diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index c870e7cd..c1bb48ba 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -1320,6 +1320,7 @@ class Pages extends Controller $columns = $inventoryPage['directory']['fields'] ?? []; return response()->streamDownload(function () use ($filters, $columns): void { + set_time_limit(0); $csv = fopen('php://output', 'w'); if ($csv === false) { throw new RuntimeException('无法创建进销存报表导出流。'); @@ -1327,6 +1328,7 @@ class Pages extends Controller fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); fputcsv($csv, array_column($columns, 'label')); + $written = 0; foreach ($this->thirdPartyWarehouseStorage->exportMonthlyInventoryReportRows($filters) as $row) { fputcsv($csv, array_map(function (array $column) use ($row): mixed { $value = data_get($row, $column['name']); @@ -1337,6 +1339,10 @@ class Pages extends Controller ? ($value === null || $value === '' ? '' : $value) : $this->csvText($value)); }, $columns)); + $written++; + if ($written % 500 === 0) { + fflush($csv); + } } fclose($csv); }, '进销存报表_' . str_replace('-', '', $filters['month']) . '_' . now()->format('Ymd_His') . '.csv', [ diff --git a/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php b/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php index 10e07960..2aee8833 100644 --- a/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php @@ -585,12 +585,10 @@ class ThirdPartyWarehouseStorageMirrorRepository * @param array $filters * @return Generator */ - public function exportMonthlyInventoryReportRows(array $filters, int $batchSize = 2000): Generator + public function exportMonthlyInventoryReportRows(array $filters, int $batchSize = 500): Generator { $batchSize = min(max($batchSize, 1), 5000); $page = 1; - $exported = 0; - $total = null; $state = $this->requireReadyState(); while (true) { @@ -598,12 +596,9 @@ class ThirdPartyWarehouseStorageMirrorRepository $filters, $page, $batchSize, - $total === null, + false, $state, ); - if ($total === null) { - $total = (int) ($result['total'] ?? 0); - } $rows = $result['data'] ?? []; if ($rows === []) { @@ -613,8 +608,7 @@ class ThirdPartyWarehouseStorageMirrorRepository yield $row; } - $exported += count($rows); - if ($exported >= $total || count($rows) < $batchSize) { + if (count($rows) < $batchSize) { return; } $page++; @@ -779,23 +773,64 @@ class ThirdPartyWarehouseStorageMirrorRepository array_merge([$owner], $inventoryWarehouseBindings), ); - $candidateUnion = $authorityCandidates->unionAll($inventoryDimensions); - $candidates = $this->connection->query()->fromSub($candidateUnion, 'candidate_rows'); - - $pageCandidates = (clone $candidates) - ->orderBy('warehouse_provider')->orderBy('wh_id')->orderBy('sku_code') - ->forPage($page, $perPage); 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); + $candidateUnion = $authorityCandidates->unionAll($inventoryDimensions); + $candidates = $this->connection->query()->fromSub($candidateUnion, 'candidate_rows'); + $pageCandidates = (clone $candidates) + ->orderBy('warehouse_provider')->orderBy('wh_id')->orderBy('sku_code') + ->forPage($page, $perPage) + ->select('candidate_rows.*')->selectRaw('COUNT(*) OVER() AS report_total'); + $pairRows = $this->boundedGet(clone $pageCandidates); + $total = (int) ($pairRows->first()->report_total ?? 0); + } else { + // Export does not need a total. Page the authoritative baseline and newly + // discovered inventory dimensions separately so every batch avoids the + // expensive full UNION count/window query. + $authorityTotal = $this->boundedCount(clone $authorityRows); + $total = 0; + $offset = ($page - 1) * $perPage; + $remaining = $perPage; + $candidatePages = []; + + if ($offset < $authorityTotal) { + $authorityLimit = min($remaining, $authorityTotal - $offset); + $candidatePages[] = (clone $authorityCandidates) + ->orderBy('authority.warehouse_provider') + ->orderBy('wh_id') + ->orderBy('authority.product_code') + ->offset($offset) + ->limit($authorityLimit); + $remaining -= $authorityLimit; + $inventoryOffset = 0; + } else { + $inventoryOffset = $offset - $authorityTotal; } + if ($remaining > 0) { + $candidatePages[] = (clone $inventoryDimensions) + ->orderBy('w.name') + ->orderBy('inventory.wh_id') + ->orderBy('inventory.sku_code') + ->offset($inventoryOffset) + ->limit($remaining); + } + + if ($candidatePages === []) { + return [ + 'data' => [], + 'total' => 0, + 'state' => $state, + 'sync' => $this->syncStatus($state), + ]; + } + + $pageCandidates = array_shift($candidatePages); + foreach ($candidatePages as $candidatePage) { + $pageCandidates->unionAll($candidatePage); + } + $pairRows = $this->boundedGet(clone $pageCandidates); + } + if ($pairRows->isEmpty()) { return [ 'data' => [], 'total' => $total, diff --git a/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php index af34e786..13ad5604 100644 --- a/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php @@ -447,6 +447,35 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase $this->assertNotContains('LEGACY-CODE-NOT-IN-CATALOG', $codes); } + public function test_inventory_report_export_reads_roll_forward_rows_without_total_count(): void + { + $this->markReady(); + $this->insertProduct('SKU-EXPORT'); + $this->insertInventory('SKU-EXPORT', 999, '2026-09-11 03:21:00'); + $this->insertInventoryReportAuthoritativeRow([ + 'source_month' => '2026-07-01', + 'report_month' => '2026-07-01', + 'warehouse_provider' => '三方-南昌樵舍仓', + 'warehouse' => '三方-南昌樵舍仓主仓', + 'product_code' => 'SKU-EXPORT', + 'opening_quantity' => 10, + 'closing_quantity' => 12, + ]); + $this->insertMovement('SKU-EXPORT', '采购进仓', 3, 1, '2026-08-02 10:00:00'); + + $rows = iterator_to_array($this->repository->exportMonthlyInventoryReportRows([ + 'month' => '2026-08', + 'product_code' => 'SKU-EXPORT', + 'warehouse_provider' => '', + 'category' => '', + ], 1), false); + + $this->assertCount(1, $rows); + $this->assertSame('SKU-EXPORT', $rows[0]->product_code); + $this->assertSame(12.0, (float) $rows[0]->opening_quantity); + $this->assertSame(15.0, (float) $rows[0]->closing_quantity); + } + public function test_third_party_page_remains_ready_when_only_the_all_warehouse_dimension_is_missing(): void { $this->markReady();