增加进销存报表筛选导出 (#219)

This commit is contained in:
chenxi 2026-09-20 18:37:45 +08:00
parent 4e0dcc7f2b
commit d8622dfd3f
5 changed files with 193 additions and 19 deletions

View File

@ -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<string,string> */
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<string,string> */
private function thirdPartyWarehouseStorageFilters(Request $request): array
{

View File

@ -238,14 +238,20 @@ class ThirdPartyWarehouseStorageMirrorRepository
* @param array<string,mixed> $filters
* @return array{data:list<object>,total:int,state:array<string,mixed>,sync:array<string,mixed>}
*/
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<string,mixed> $filters
* @return Generator<int,object>
*/
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<string,mixed> $filters
* @return array{count:int,opening_qty:float,closing_qty:float}

View File

@ -473,6 +473,9 @@
<div class="installation-filter-actions">
<button type="submit" class="biz-btn biz-btn-primary">查询</button>
<a href="{{ request()->url() }}" class="biz-btn biz-btn-ghost">清除</a>
<a href="{{ route('business.product-inventory.inventory-report.export', request()->except('page')) }}" class="biz-btn biz-btn-ghost" aria-label="导出当前筛选结果" title="导出当前筛选结果">
<span class="material-icons-outlined" aria-hidden="true">download</span>导出
</a>
</div>
</form>
@endif

View File

@ -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')

View File

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