两个「终版」目录(供应商应付(终版)、快递应付(终版))改为独立数据: 保留原有导入、导出、查询、重置、编辑按钮和导入模板,数据初始为空、按表独立存放, 不再借用基础目录的页面与数据。 - 终版目录拥有自己的同名子页面与横向页签 - 新增终版按表数据的独立导入/导出/模板/编辑接口(数据集键 final:<目录>:<页面>) - 供应商终版 13 个页面、快递终版 4 个页面已接入 - 终版导入模板统一增加“月份”列,记录标识列改为选填
148 lines
5.6 KiB
PHP
148 lines
5.6 KiB
PHP
<?php
|
||
|
||
namespace Modules\EcommerceFinance\Services;
|
||
|
||
use Illuminate\Http\UploadedFile;
|
||
use Illuminate\Validation\ValidationException;
|
||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||
use Throwable;
|
||
|
||
/**
|
||
* 「终版」目录按表导入:数据独立存放,可以从空表开始导入,
|
||
* 不要求基础页面那种“先导出再改”的记录标识列。
|
||
*/
|
||
class PayablesFinalImporter
|
||
{
|
||
private const MAX_ROWS = 10000;
|
||
|
||
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.'),
|
||
};
|
||
$spreadsheet = IOFactory::createReader($readerType)->load($file->getRealPath());
|
||
[$rows, $headerRow, $columns] = $this->bestSheet($spreadsheet, $lookup);
|
||
} catch (Throwable) {
|
||
throw ValidationException::withMessages([
|
||
'import' => '无法读取导入文件,请确认文件未损坏且格式为 xlsx、xls 或 csv。',
|
||
]);
|
||
} finally {
|
||
if (isset($spreadsheet)) {
|
||
$spreadsheet->disconnectWorksheets();
|
||
}
|
||
}
|
||
|
||
if ($rows === null || $columns === []) {
|
||
throw ValidationException::withMessages([
|
||
'import' => '没有识别到表头,请先下载本页面的导入模板,按模板列填写后重试。',
|
||
]);
|
||
}
|
||
|
||
$store = $this->store;
|
||
$records = [];
|
||
foreach (array_slice($rows, $headerRow + 1, null, true) as $rowIndex => $row) {
|
||
$line = $rowIndex + 1;
|
||
$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)];
|
||
}
|
||
|
||
private function bestSheet(mixed $spreadsheet, array $lookup): array
|
||
{
|
||
$best = [null, null, []];
|
||
$score = 0;
|
||
foreach ($spreadsheet->getWorksheetIterator() as $worksheet) {
|
||
$rows = $worksheet->toArray(null, false, false, false);
|
||
foreach (array_slice($rows, 0, 20, true) as $rowIndex => $row) {
|
||
$columns = [];
|
||
foreach ($row as $columnIndex => $heading) {
|
||
$key = $this->header($heading);
|
||
if ($key !== '' && isset($lookup[$key])) {
|
||
$columns[$columnIndex] = $lookup[$key];
|
||
}
|
||
}
|
||
if (count($columns) > $score) {
|
||
$best = [$rows, $rowIndex, $columns];
|
||
$score = count($columns);
|
||
}
|
||
}
|
||
}
|
||
|
||
return $best;
|
||
}
|
||
|
||
private function header(mixed $value): string
|
||
{
|
||
return mb_strtolower(preg_replace('/[\s\x{FEFF}_\-()()]+/u', '', trim((string) $value)));
|
||
}
|
||
}
|