修复进销存报表大数据导出

This commit is contained in:
chenxi 2026-09-26 03:21:14 +08:00
parent 39885bccea
commit f92ed47def
4 changed files with 375 additions and 91 deletions

View File

@ -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,
]);
}

View File

@ -0,0 +1,76 @@
<?php
namespace App\Jobs\Common;
use App\Abstracts\JobShouldQueue;
use App\Notifications\Common\ExportFailed;
use Illuminate\Support\Facades\File;
use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageMirrorRepository;
use Throwable;
class InventoryReportExport extends JobShouldQueue
{
/** A complete month can exceed the web request and default queue timeout. */
public int $timeout = 600;
public int $tries = 1;
public function __construct(
protected $user,
protected array $filters,
protected array $columns,
protected string $file_name,
) {
$this->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 ?? ''));
}
}

View File

@ -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<string,mixed> $filters
* @return Generator<int,object>
*/
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<string,mixed> $filters
* @param array<string,mixed> $state
* @return Collection<string,object>|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<string,mixed> $filters
@ -1858,25 +1955,27 @@ class ThirdPartyWarehouseStorageMirrorRepository
}
/** @return Collection<int,object> */
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

View File

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