修复进销存报表大数据导出
This commit is contained in:
parent
39885bccea
commit
f92ed47def
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
76
app/Jobs/Common/InventoryReportExport.php
Normal file
76
app/Jobs/Common/InventoryReportExport.php
Normal 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 ?? ''));
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user