178 lines
5.8 KiB
PHP
178 lines
5.8 KiB
PHP
<?php
|
||
|
||
namespace App\Business;
|
||
|
||
use Illuminate\Http\UploadedFile;
|
||
use Illuminate\Validation\ValidationException;
|
||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||
use Throwable;
|
||
|
||
class LaborExpenseWorkbookImporter
|
||
{
|
||
private const MAX_DATA_ROWS = 2000;
|
||
|
||
/**
|
||
* @return array{rows: array<int, array<string, mixed>>, row_count: int, blank_count: int}
|
||
*/
|
||
public function import(UploadedFile $file): array
|
||
{
|
||
$fields = collect(config('labor_expense_table.fields', []))
|
||
->filter(static fn (array $field): bool => ($field['kind'] ?? null) !== 'sequence'
|
||
&& blank($field['formula'] ?? null));
|
||
|
||
if ($fields->isEmpty()) {
|
||
throw ValidationException::withMessages([
|
||
'import' => '人工费用表没有可导入的录入列。',
|
||
]);
|
||
}
|
||
|
||
$lookup = [];
|
||
foreach ($fields as $field) {
|
||
$lookup[$this->normalizeHeader($field['key'])] = $field['key'];
|
||
$lookup[$this->normalizeHeader($field['label'])] = $field['key'];
|
||
}
|
||
|
||
$spreadsheet = null;
|
||
|
||
try {
|
||
$readerType = match (mb_strtolower($file->getClientOriginalExtension())) {
|
||
'xlsx' => 'Xlsx',
|
||
'xls' => 'Xls',
|
||
'csv' => 'Csv',
|
||
default => throw new \RuntimeException('Unsupported workbook format.'),
|
||
};
|
||
$reader = IOFactory::createReader($readerType);
|
||
if (method_exists($reader, 'setReadDataOnly')) {
|
||
$reader->setReadDataOnly(true);
|
||
}
|
||
$spreadsheet = $reader->load($file->getRealPath());
|
||
[$sheetRows, $headerRow, $columns] = $this->bestSheet($spreadsheet, $lookup);
|
||
} catch (Throwable) {
|
||
throw ValidationException::withMessages([
|
||
'import' => '无法读取导入文件,请确认文件未损坏且格式为 xlsx、xls 或 csv。',
|
||
]);
|
||
} finally {
|
||
if ($spreadsheet) {
|
||
$spreadsheet->disconnectWorksheets();
|
||
}
|
||
}
|
||
|
||
if ($sheetRows === null || $columns === []) {
|
||
throw ValidationException::withMessages([
|
||
'import' => '未找到当前人工费用表的可录入列,请使用页面“导出全部字段”生成的文件作为模板。',
|
||
]);
|
||
}
|
||
|
||
$fieldMap = $fields->keyBy('key');
|
||
$rows = [];
|
||
$blankCount = 0;
|
||
|
||
foreach (array_slice($sheetRows, $headerRow + 1, null, true) as $rowIndex => $row) {
|
||
$record = [];
|
||
$hasValue = false;
|
||
|
||
foreach ($columns as $columnIndex => $fieldKey) {
|
||
$field = $fieldMap[$fieldKey];
|
||
$value = $this->normalizeCell(
|
||
$row[$columnIndex] ?? null,
|
||
$field['kind'] ?? 'text',
|
||
(int) $rowIndex + 1,
|
||
$field['label'] ?? $fieldKey,
|
||
);
|
||
$record[$fieldKey] = $value;
|
||
$hasValue = $hasValue || $value !== '';
|
||
}
|
||
|
||
if (! $hasValue) {
|
||
$blankCount++;
|
||
continue;
|
||
}
|
||
|
||
$rows[] = $record;
|
||
if (count($rows) > self::MAX_DATA_ROWS) {
|
||
throw ValidationException::withMessages([
|
||
'import' => '单次最多导入 ' . number_format(self::MAX_DATA_ROWS) . ' 行,请拆分文件后重试。',
|
||
]);
|
||
}
|
||
}
|
||
|
||
if ($rows === []) {
|
||
throw ValidationException::withMessages([
|
||
'import' => '表格中没有可导入的数据行。',
|
||
]);
|
||
}
|
||
|
||
return [
|
||
'rows' => $rows,
|
||
'row_count' => count($rows),
|
||
'blank_count' => $blankCount,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @return array{0: array<int, array<int, mixed>>|null, 1: int|null, 2: array<int, string>}
|
||
*/
|
||
private function bestSheet(mixed $spreadsheet, array $lookup): array
|
||
{
|
||
$best = [null, null, []];
|
||
$bestScore = 0;
|
||
|
||
foreach ($spreadsheet->getWorksheetIterator() as $worksheet) {
|
||
$rows = $worksheet->toArray(null, false, false, false);
|
||
|
||
foreach (array_slice($rows, 0, 30, true) as $rowIndex => $row) {
|
||
$columns = [];
|
||
|
||
foreach ($row as $columnIndex => $heading) {
|
||
$key = $this->normalizeHeader($heading);
|
||
if ($key !== '' && isset($lookup[$key])) {
|
||
$columns[$columnIndex] = $lookup[$key];
|
||
}
|
||
}
|
||
|
||
$score = count(array_unique(array_values($columns)));
|
||
if ($score > $bestScore) {
|
||
$best = [$rows, (int) $rowIndex, $columns];
|
||
$bestScore = $score;
|
||
}
|
||
}
|
||
}
|
||
|
||
return $best;
|
||
}
|
||
|
||
private function normalizeCell(mixed $value, string $kind, int $line, string $label): mixed
|
||
{
|
||
if ($value === null) {
|
||
return '';
|
||
}
|
||
|
||
$text = trim((string) $value);
|
||
if ($text === '') {
|
||
return '';
|
||
}
|
||
|
||
if (! in_array($kind, ['number', 'percent'], true)) {
|
||
return $text;
|
||
}
|
||
|
||
$isPercentText = $kind === 'percent' && str_ends_with($text, '%');
|
||
$number = str_replace([',', ',', '¥', '¥', '%'], '', $text);
|
||
|
||
if (! is_numeric($number)) {
|
||
throw ValidationException::withMessages([
|
||
'import' => "第 {$line} 行“{$label}”不是有效数字。",
|
||
]);
|
||
}
|
||
|
||
$normalized = (float) $number;
|
||
|
||
return $isPercentText ? $normalized / 100 : $normalized;
|
||
}
|
||
|
||
private function normalizeHeader(mixed $value): string
|
||
{
|
||
return mb_strtolower(preg_replace('/[\s\x{FEFF}_\-()()*::]+/u', '', trim((string) $value)));
|
||
}
|
||
}
|