CaiWuXiTong/modules/EcommerceFinance/Services/PayablesFinalImporter.php
renshen 7742163632 应付款终版导入:分块读取工作簿,避免大文件把进程撑爆导致 500 (#359)
按 2000 行分块读取(大文件不再整表载入内存),读满 1 万行立即提示拆分文件;php.ini 补 log_errors/error_log,致命错误可查。
2026-10-16 23:15:33 +08:00

220 lines
8.4 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Validation\ValidationException;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Reader\IReadFilter;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use Throwable;
/**
* 「终版」目录按表导入:数据独立存放,可以从空表开始导入,
* 不要求基础页面那种“先导出再改”的记录标识列。
*
* 读取按行分块(每次只把一块行载入内存),避免整表载入把 PHP 进程撑爆
* ——大文件或带“幽灵区域”的工作簿以前会让导入直接 500。
*/
class PayablesFinalImporter
{
private const MAX_ROWS = 10000;
/** 分块读取:每块载入的行数。 */
private const CHUNK_ROWS = 2000;
/** 表头只在前若干行里找,避免把数据行误判成表头。 */
private const HEADER_SCAN_ROWS = 20;
public function __construct(
private readonly PayablesFieldNormalizer $normalizer,
private readonly SupplierPayableOverrideStore $store,
) {
}
/**
* @param array<string,array{label:string,type:string}> $fields 该表的字段定义(决定表头)
* @param string $storeDataset 「终版」目录实际保存记录用的数据集键
* @return array{created:int,updated:int,rows:int}
*/
public function import(array $fields, string $storeDataset, UploadedFile $file, int $companyId, ?int $userId): array
{
$lookup = ['记录标识' => '_action_key', 'recordkey' => '_action_key', '_action_key' => '_action_key'];
foreach ($fields as $field => $definition) {
$lookup[$this->header($field)] = $field;
$lookup[$this->header($definition['label'])] = $field;
}
try {
$readerType = match (mb_strtolower($file->getClientOriginalExtension())) {
'xlsx' => 'Xlsx',
'xls' => 'Xls',
'csv' => 'Csv',
default => throw new \RuntimeException('Unsupported workbook format.'),
};
$header = $this->locateHeader($readerType, $file->getRealPath(), $lookup);
} catch (Throwable) {
throw ValidationException::withMessages([
'import' => '无法读取导入文件,请确认文件未损坏且格式为 xlsx、xls 或 csv。',
]);
}
if ($header === null) {
throw ValidationException::withMessages([
'import' => '没有识别到表头,请先下载本页面的导入模板,按模板列填写后重试。',
]);
}
[$sheetIndex, $headerRow, $columns] = $header;
$store = $this->store;
$records = [];
foreach ($this->rowsAfter($readerType, $file->getRealPath(), $sheetIndex, $headerRow + 1) as $line => $row) {
$key = '';
$rawValues = [];
foreach ($columns as $columnIndex => $field) {
if ($field === '_action_key') {
$key = trim((string) ($row[$columnIndex] ?? ''));
} else {
$rawValues[$field] = $row[$columnIndex] ?? null;
}
}
if (collect($rawValues)->every(static fn (mixed $value): bool => trim((string) $value) === '')) {
continue;
}
try {
$values = $this->normalizer->normalize($fields, $rawValues);
} catch (ValidationException $exception) {
throw ValidationException::withMessages([
'import' => "第 {$line} 行:" . collect($exception->errors())->flatten()->first(),
]);
}
if ($key === '') {
$key = $store->key($storeDataset, $values);
}
if (isset($records[$key])) {
throw ValidationException::withMessages(['import' => "第 {$line} 行与前一行内容重复。"]);
}
$records[$key] = $values;
if (count($records) > self::MAX_ROWS) {
throw ValidationException::withMessages(['import' => '单次最多导入 10,000 条记录,请拆分文件后重试。']);
}
}
if ($records === []) {
throw ValidationException::withMessages(['import' => '表格中没有可导入的数据行。']);
}
$counts = ['created' => 0, 'updated' => 0];
$store->transaction(function () use ($store, $records, $storeDataset, $companyId, $userId, &$counts): void {
foreach ($records as $key => $values) {
$result = $store->save($storeDataset, $key, $values, $companyId, $userId, 'import');
$counts['created'] += $result['created'];
$counts['updated'] += $result['updated'];
}
});
return $counts + ['rows' => count($records)];
}
/**
* 载入第一块行,在其中找到表头。
*
* @param array<string,string> $lookup
* @return array{0:int,1:int,2:array<int,string>}|null [sheet 序号, 表头行号(1 基), 列序号 => 字段]
*/
private function locateHeader(string $readerType, string $path, array $lookup): ?array
{
$best = null;
$score = 0;
$spreadsheet = $this->loadChunk($readerType, $path, 1, self::CHUNK_ROWS);
try {
foreach ($spreadsheet->getWorksheetIterator() as $sheetIndex => $worksheet) {
foreach ($worksheet->getRowIterator(1, self::HEADER_SCAN_ROWS) as $row) {
$columns = [];
foreach ($row->getCellIterator() as $cell) {
$key = $this->header($cell->getValue());
if ($key !== '' && isset($lookup[$key])) {
$columns[Coordinate::columnIndexFromString($cell->getColumn()) - 1] = $lookup[$key];
}
}
if (count($columns) > $score) {
$score = count($columns);
$best = [$sheetIndex, $row->getRowIndex(), $columns];
}
}
}
} finally {
$spreadsheet->disconnectWorksheets();
}
return $best;
}
/**
* 从指定行开始按块读取数据行。
*
* @return \Generator<int,array<int,mixed>> 行号(1 基)=> 列序号 => 单元格值
*/
private function rowsAfter(string $readerType, string $path, int $sheetIndex, int $firstRow): \Generator
{
for ($start = $firstRow; ; $start += self::CHUNK_ROWS) {
$spreadsheet = $this->loadChunk($readerType, $path, $start, self::CHUNK_ROWS);
try {
$worksheet = $spreadsheet->getSheet($sheetIndex);
$highestRow = $worksheet->getHighestRow();
if ($highestRow < $start) {
return;
}
$endRow = min($start + self::CHUNK_ROWS - 1, $highestRow);
foreach ($worksheet->getRowIterator($start, $endRow) as $row) {
$cells = [];
foreach ($row->getCellIterator() as $cell) {
$cells[Coordinate::columnIndexFromString($cell->getColumn()) - 1] = $cell->getValue();
}
yield $row->getRowIndex() => $cells;
}
} finally {
$spreadsheet->disconnectWorksheets();
}
}
}
/** 只把 [startRow, endRow] 这些行载入内存。 */
private function loadChunk(string $readerType, string $path, int $startRow, int $rows): Spreadsheet
{
$reader = IOFactory::createReader($readerType);
$reader->setReadDataOnly(true);
$reader->setReadFilter(new class($startRow, $startRow + $rows - 1) implements IReadFilter {
public function __construct(
private readonly int $startRow,
private readonly int $endRow,
) {
}
public function readCell($columnAddress, $row, $worksheetName = ''): bool
{
return $row >= $this->startRow && $row <= $this->endRow;
}
});
return $reader->load($path);
}
private function header(mixed $value): string
{
return mb_strtolower(preg_replace('/[\s\x{FEFF}_\-()()]+/u', '', trim((string) $value)));
}
}