From d8622dfd3fc0149c9a20cca4f07555f9beaf575e Mon Sep 17 00:00:00 2001 From: chenxi Date: Sun, 20 Sep 2026 18:37:45 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E8=BF=9B=E9=94=80=E5=AD=98?= =?UTF-8?q?=E6=8A=A5=E8=A1=A8=E7=AD=9B=E9=80=89=E5=AF=BC=E5=87=BA=20(#219)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Http/Controllers/Business/Pages.php | 76 +++++++++++++++---- ...dPartyWarehouseStorageMirrorRepository.php | 54 ++++++++++++- .../pages/partials/directory-table.blade.php | 3 + routes/admin.php | 4 + tests/Feature/Business/PagesTest.php | 75 ++++++++++++++++++ 5 files changed, 193 insertions(+), 19 deletions(-) diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index a19eb1cc..50653e30 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -494,21 +494,7 @@ class Pages extends Controller { $page = max(1, request()->integer('page', 1)); $perPage = min(max((int) setting('default.list_limit', 25), 1), 100); - $month = trim((string) request('month', now()->startOfMonth()->subMonth()->format('Y-m'))); - if (preg_match('/^(\d{4})-(\d{2})$/', $month, $matches) !== 1 - || ! checkdate((int) $matches[2], 1, (int) $matches[1])) { - $month = now()->startOfMonth()->subMonth()->format('Y-m'); - } - $minimumMonth = substr(ThirdPartyWarehouseStorageMirrorRepository::START_DATE, 0, 7); - if ($month < $minimumMonth) { - $month = $minimumMonth; - } - $filters = [ - 'month' => $month, - 'product_code' => mb_substr(trim((string) request('product_code', '')), 0, 100), - 'warehouse_provider' => mb_substr(trim((string) request('warehouse_provider', '')), 0, 191), - 'category' => mb_substr(trim((string) request('category', '')), 0, 191), - ]; + $filters = $this->inventoryReportFilters(request()); $error = null; try { @@ -545,6 +531,27 @@ class Pages extends Controller ]), $error]; } + /** @return array */ + private function inventoryReportFilters(Request $request): array + { + $month = trim((string) $request->query('month', now()->startOfMonth()->subMonth()->format('Y-m'))); + if (preg_match('/^(\d{4})-(\d{2})$/', $month, $matches) !== 1 + || ! checkdate((int) $matches[2], 1, (int) $matches[1])) { + $month = now()->startOfMonth()->subMonth()->format('Y-m'); + } + $minimumMonth = substr(ThirdPartyWarehouseStorageMirrorRepository::START_DATE, 0, 7); + if ($month < $minimumMonth) { + $month = $minimumMonth; + } + + return [ + 'month' => $month, + 'product_code' => mb_substr(trim((string) $request->query('product_code', '')), 0, 100), + 'warehouse_provider' => mb_substr(trim((string) $request->query('warehouse_provider', '')), 0, 191), + 'category' => mb_substr(trim((string) $request->query('category', '')), 0, 191), + ]; + } + private function isStoreReceivingAccountPage(string $section, string $page): bool { return $section === 'fund-management' && $page === 'store-receiving-accounts'; @@ -1050,6 +1057,45 @@ class Pages extends Controller ]); } + public function exportInventoryReport(Request $request): StreamedResponse + { + abort_unless( + $this->thirdPartyWarehouseStorage->available() + && $this->thirdPartyWarehouseStorage->allWarehousesReady(), + 503, + ); + + $filters = $this->inventoryReportFilters($request); + $productInventory = collect(config('business_navigation'))->firstWhere('slug', 'product-inventory'); + $inventoryPage = collect($productInventory['pages'] ?? [])->firstWhere('slug', 'inventory-report'); + $columns = $inventoryPage['directory']['fields'] ?? []; + + return response()->streamDownload(function () use ($filters, $columns): void { + $csv = fopen('php://output', 'w'); + if ($csv === false) { + throw new RuntimeException('无法创建进销存报表导出流。'); + } + + fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); + fputcsv($csv, array_column($columns, 'label')); + foreach ($this->thirdPartyWarehouseStorage->exportMonthlyInventoryReportRows($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)); + }, $columns)); + } + fclose($csv); + }, '进销存报表_' . str_replace('-', '', $filters['month']) . '_' . now()->format('Ymd_His') . '.csv', [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Cache-Control' => 'private, no-store', + ]); + } + /** @return array */ private function thirdPartyWarehouseStorageFilters(Request $request): array { diff --git a/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php b/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php index 25eed583..2034b167 100644 --- a/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php @@ -238,14 +238,20 @@ class ThirdPartyWarehouseStorageMirrorRepository * @param array $filters * @return array{data:list,total:int,state:array,sync:array} */ - public function paginateMonthlyInventoryReport(array $filters, int $page, int $perPage): array + public function paginateMonthlyInventoryReport( + array $filters, + int $page, + int $perPage, + bool $includeTotal = true, + ?array $state = null, + ): array { - $state = $this->requireReadyState(); + $state ??= $this->requireReadyState(); if ((int) ($state['scope_version'] ?? 1) < 2) { throw new RuntimeException('inventory_report_all_warehouses_not_ready'); } $page = max($page, 1); - $perPage = min(max($perPage, 1), 100); + $perPage = min(max($perPage, 1), 5000); $period = $this->period([ 'dimension' => 'month', 'period' => $filters['month'] ?? '', @@ -296,7 +302,7 @@ class ThirdPartyWarehouseStorageMirrorRepository $candidates->whereIn('pairs.sku_code', $categorySkus); } - $total = (clone $candidates)->count(); + $total = $includeTotal ? (clone $candidates)->count() : 0; $pagePairs = (clone $candidates) ->orderBy('pairs.wms_co_id')->orderBy('pairs.sku_code') ->forPage($page, $perPage); @@ -413,6 +419,46 @@ class ThirdPartyWarehouseStorageMirrorRepository ]; } + /** + * @param array $filters + * @return Generator + */ + public function exportMonthlyInventoryReportRows(array $filters, int $batchSize = 2000): Generator + { + $batchSize = min(max($batchSize, 1), 5000); + $page = 1; + $exported = 0; + $total = null; + $state = $this->requireReadyState(); + + while (true) { + $result = $this->paginateMonthlyInventoryReport( + $filters, + $page, + $batchSize, + $total === null, + $state, + ); + if ($total === null) { + $total = (int) ($result['total'] ?? 0); + } + + $rows = $result['data'] ?? []; + if ($rows === []) { + return; + } + foreach ($rows as $row) { + yield $row; + } + + $exported += count($rows); + if ($exported >= $total || count($rows) < $batchSize) { + return; + } + $page++; + } + } + /** * @param array $filters * @return array{count:int,opening_qty:float,closing_qty:float} diff --git a/resources/views/business/pages/partials/directory-table.blade.php b/resources/views/business/pages/partials/directory-table.blade.php index 5838a0de..a940a7ca 100644 --- a/resources/views/business/pages/partials/directory-table.blade.php +++ b/resources/views/business/pages/partials/directory-table.blade.php @@ -473,6 +473,9 @@ @endif diff --git a/routes/admin.php b/routes/admin.php index c5aff9ba..00a71acd 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -129,6 +129,10 @@ Route::get('workspace/product-inventory/online-daifa-price/export', 'Business\Pa ->middleware('business.page:product-inventory,online-daifa-price') ->name('business.product-inventory.online-daifa-price.export'); +Route::get('workspace/product-inventory/inventory-report/export', 'Business\Pages@exportInventoryReport') + ->middleware('business.page:product-inventory,inventory-report') + ->name('business.product-inventory.inventory-report.export'); + Route::prefix('workspace/supplier-payables/product-prices') ->name('business.supplier-payables.product-prices.') ->middleware('business.page:supplier-payables,product-prices') diff --git a/tests/Feature/Business/PagesTest.php b/tests/Feature/Business/PagesTest.php index 25d283f5..b8e7fb0a 100644 --- a/tests/Feature/Business/PagesTest.php +++ b/tests/Feature/Business/PagesTest.php @@ -14,6 +14,81 @@ use Tests\Feature\FeatureTestCase; class PagesTest extends FeatureTestCase { + public function testInventoryReportProvidesAFilteredExportButton(): void + { + $repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class); + $repository->shouldReceive('available')->once()->andReturnTrue(); + $repository->shouldReceive('allWarehousesReady')->once()->andReturnTrue(); + $repository->shouldReceive('paginateMonthlyInventoryReport')->once()->with( + [ + 'month' => '2026-08', + 'product_code' => '121630-49', + 'warehouse_provider' => '', + 'category' => '', + ], + 1, + Mockery::type('int'), + )->andReturn([ + 'data' => [(object) ['product_code' => '121630-49']], + 'total' => 1, + 'state' => ['status' => 'succeeded'], + 'sync' => ['code' => 'healthy'], + ]); + $this->app->instance(ThirdPartyWarehouseStorageMirrorRepository::class, $repository); + + $response = $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'product-inventory', + 'page' => 'inventory-report', + 'month' => '2026-08', + 'product_code' => '121630-49', + ])); + + $response->assertOk() + ->assertSeeText('导出') + ->assertSee(e(route('business.product-inventory.inventory-report.export', [ + 'month' => '2026-08', + 'product_code' => '121630-49', + ])), false); + } + + public function testInventoryReportExportUsesCurrentFiltersAndPageColumnOrder(): void + { + $filters = [ + 'month' => '2026-08', + 'product_code' => '121630-49', + 'warehouse_provider' => '测试仓', + 'category' => '家具', + ]; + $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, + ]; + })()); + $this->app->instance(ThirdPartyWarehouseStorageMirrorRepository::class, $repository); + + $response = $this->loginAs()->get(route( + 'business.product-inventory.inventory-report.export', + $filters, + )); + + $response->assertOk()->assertDownload(); + $content = $response->streamedContent(); + $this->assertStringStartsWith(chr(0xEF) . chr(0xBB) . chr(0xBF), $content); + $this->assertStringContainsString('货主,月份,仓储方,仓库,商品编码,分类,期初数量,期末数量', $content); + $this->assertStringContainsString("'121630-49", $content); + } + public function testInventoryReportDoesNotFallBackToLegacyRowsBeforeAllWarehouseMirrorIsReady(): void { $repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class);