220 lines
8.4 KiB
PHP
220 lines
8.4 KiB
PHP
<?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)));
|
||
}
|
||
}
|