CaiWuXiTong/app/Business/LaborExpenseWorkbookImporter.php
chenxi 1c42428df4 feat: 人工费用支持表格导入 (#758)
新增人工费用 Excel/CSV 导入,自动匹配录入列并重算公式。
2026-10-29 13:23:52 +08:00

178 lines
5.8 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 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)));
}
}