CaiWuXiTong/app/Business/CourierSurchargeQuoteWorkbookImporter.php

387 lines
16 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 App\Models\Business\CourierSurchargeQuote;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Throwable;
class CourierSurchargeQuoteWorkbookImporter
{
public const MAX_ROWS = 500;
public const DUPLICATE_SKIP = 'skip';
public const DUPLICATE_UPDATE = 'update';
public const HEADERS = [
'快递名称',
'快递归属',
'对账人',
'供应商',
'附加费类型',
'结算方式',
'计价标准',
'生效条件',
'生效日期',
'状态',
'备注',
];
public function __construct(private readonly CourierSurchargeQuoteRepository $quotes)
{
}
public function template(): StreamedResponse
{
$types = $this->quotes->activeTypeNames();
return response()->streamDownload(function () use ($types): void {
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('附加费报价');
$sheet->fromArray(self::HEADERS, null, 'A1');
$lastColumn = Coordinate::stringFromColumnIndex(count(self::HEADERS));
$headerRange = 'A1:' . $lastColumn . '1';
$sheet->getStyle($headerRange)->applyFromArray([
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '856632']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => 'D8D2C7']]],
]);
$sheet->getRowDimension(1)->setRowHeight(24);
$sheet->freezePane('A2');
$sheet->setAutoFilter($headerRange);
$widths = [18, 14, 14, 20, 24, 18, 30, 32, 14, 12, 30];
foreach ($widths as $index => $width) {
$column = Coordinate::stringFromColumnIndex($index + 1);
$sheet->getColumnDimension($column)->setWidth($width);
$sheet->getStyle($column . '2:' . $column . (self::MAX_ROWS + 1))
->getNumberFormat()
->setFormatCode('@');
}
$instructions = $spreadsheet->createSheet();
$instructions->setTitle('填写说明');
$instructions->fromArray([
['项目', '填写要求'],
['必填字段', '快递名称、快递归属、对账人、附加费类型、结算方式、计价标准、生效条件、生效日期、状态。'],
['供应商', '选填;没有供应商时保持空白。'],
['附加费类型', '多种类型使用“、”分隔;只能填写当前已启用类型。当前可选:' . implode('、', $types)],
['快递归属', '自有快递 或 代发仓。'],
['结算方式', implode('、', CourierSurchargeQuoteRepository::MODES)],
['生效日期', '使用 YYYY-MM-DD,例如 2026-09-22。'],
['状态', '已启用 或 已停用;也可填写 active 或 inactive。'],
['重复规则', '预览后必须明确选择跳过已有规则或更新已有规则,系统不会静默覆盖。'],
['单次上限', '最多 ' . self::MAX_ROWS . ' 条非空数据。'],
], null, 'A1');
$instructions->getStyle('A1:B1')->applyFromArray([
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '856632']],
]);
$instructions->getColumnDimension('A')->setWidth(18);
$instructions->getColumnDimension('B')->setWidth(90);
$instructions->getStyle('A1:B10')->getAlignment()->setVertical(Alignment::VERTICAL_CENTER)->setWrapText(true);
foreach (range(1, 10) as $row) {
$instructions->getRowDimension($row)->setRowHeight($row === 1 ? 24 : 34);
}
$spreadsheet->setActiveSheetIndex(0);
(new Xlsx($spreadsheet))->save('php://output');
$spreadsheet->disconnectWorksheets();
}, '附加费报价导入模板_' . now()->format('Ymd') . '.xlsx', [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Cache-Control' => 'private, no-store',
]);
}
/**
* @return array{
* rows:list<array{line:int,data:array<string,mixed>,display:array<string,string>,errors:list<string>,result:string,result_label:string,existing_id:?int}>,
* summary:array{total:int,valid:int,new:int,duplicate:int,invalid:int}
* }
*/
public function preview(UploadedFile $file): array
{
$extension = mb_strtolower($file->getClientOriginalExtension());
if (! in_array($extension, ['xlsx', 'xls', 'csv'], true)) {
throw ValidationException::withMessages(['import' => '仅支持 xlsx、xls 或 csv 文件。']);
}
try {
$reader = IOFactory::createReader(match ($extension) {
'xlsx' => 'Xlsx',
'xls' => 'Xls',
'csv' => 'Csv',
});
$reader->setReadDataOnly(true);
$spreadsheet = $reader->load($file->getRealPath());
$rows = $spreadsheet->getActiveSheet()->toArray(null, false, false, false);
} catch (Throwable) {
throw ValidationException::withMessages([
'import' => '无法读取导入文件,请确认文件未损坏且格式为 xlsx、xls 或 csv。',
]);
} finally {
if (isset($spreadsheet)) {
$spreadsheet->disconnectWorksheets();
}
}
$headers = array_map(fn (mixed $value): string => $this->text($value), $rows[0] ?? []);
while ($headers !== [] && end($headers) === '') {
array_pop($headers);
}
if ($headers !== self::HEADERS) {
throw ValidationException::withMessages([
'import' => '表头不正确。请下载最新模板,并保持 11 个字段名称及顺序不变。',
]);
}
$dataRows = [];
foreach (array_slice($rows, 1, null, true) as $index => $row) {
$row = array_slice(array_pad($row, count(self::HEADERS), null), 0, count(self::HEADERS));
if (collect($row)->every(fn (mixed $value): bool => $this->text($value) === '')) {
continue;
}
$dataRows[] = ['line' => $index + 1, 'values' => $row];
if (count($dataRows) > self::MAX_ROWS) {
throw ValidationException::withMessages([
'import' => '单次最多导入 ' . self::MAX_ROWS . ' 条非空数据,请拆分文件后重试。',
]);
}
}
if ($dataRows === []) {
throw ValidationException::withMessages(['import' => '表格中没有可预览的数据行。']);
}
$previewRows = array_map(fn (array $row): array => $this->normalizeRow($row['values'], $row['line']), $dataRows);
$signatures = [];
foreach ($previewRows as $index => $row) {
if ($row['errors'] === []) {
$signatures[$this->quotes->importIdentity($row['data'])][] = $index;
}
}
foreach ($signatures as $indexes) {
if (count($indexes) < 2) {
continue;
}
$lines = implode('、', array_map(fn (int $index): int => $previewRows[$index]['line'], $indexes));
foreach ($indexes as $index) {
$previewRows[$index]['errors'][] = '文件内规则重复(行号:' . $lines . ')。';
}
}
foreach ($previewRows as &$row) {
if ($row['errors'] !== []) {
$row['result'] = 'invalid';
$row['result_label'] = '校验失败';
continue;
}
$existing = $this->quotes->findImportDuplicate($row['data']);
$row['existing_id'] = $existing?->id;
$row['result'] = $existing ? 'duplicate' : 'new';
$row['result_label'] = $existing ? '已有规则' : '可新增';
}
unset($row);
$collection = collect($previewRows);
return [
'rows' => $previewRows,
'summary' => [
'total' => $collection->count(),
'valid' => $collection->where('result', '!=', 'invalid')->count(),
'new' => $collection->where('result', 'new')->count(),
'duplicate' => $collection->where('result', 'duplicate')->count(),
'invalid' => $collection->where('result', 'invalid')->count(),
],
];
}
/**
* @param list<array{line:int,data:array<string,mixed>,errors:list<string>}> $rows
* @return array{created:int,updated:int,skipped:int,total:int}
*/
public function commit(array $rows, string $duplicatePolicy, int $userId): array
{
if (! in_array($duplicatePolicy, [self::DUPLICATE_SKIP, self::DUPLICATE_UPDATE], true)) {
throw ValidationException::withMessages(['duplicate_policy' => '请选择已有规则的处理方式。']);
}
if ($rows === [] || count($rows) > self::MAX_ROWS || collect($rows)->contains(fn (array $row): bool => ($row['errors'] ?? []) !== [])) {
throw ValidationException::withMessages(['import' => '预览数据无效或包含错误,请重新上传文件。']);
}
$activeTypes = $this->quotes->activeTypeNames();
foreach ($rows as $row) {
$inactive = array_values(array_diff((array) ($row['data']['types'] ?? []), $activeTypes));
if ($inactive !== []) {
throw ValidationException::withMessages([
'import' => '附加费类型“' . implode('、', $inactive) . '”已停用或不存在,请重新预览。',
]);
}
}
$counts = ['created' => 0, 'updated' => 0, 'skipped' => 0, 'total' => count($rows)];
DB::transaction(function () use ($rows, $duplicatePolicy, $userId, &$counts): void {
foreach ($rows as $row) {
$data = $row['data'];
$status = $data['status'];
unset($data['status']);
$existing = $this->quotes->findImportDuplicate($data, true);
if ($existing && $duplicatePolicy === self::DUPLICATE_SKIP) {
$counts['skipped']++;
continue;
}
if ($existing) {
$quote = $this->quotes->update($existing, $data, $userId);
$counts['updated']++;
} else {
$quote = $this->quotes->create($data, $userId);
$counts['created']++;
}
if ($quote->status !== $status) {
$this->quotes->setStatus($quote, $status, $userId);
}
}
});
return $counts;
}
/** @return array{line:int,data:array<string,mixed>,display:array<string,string>,errors:list<string>,result:string,result_label:string,existing_id:?int} */
private function normalizeRow(array $values, int $line): array
{
$raw = array_combine(self::HEADERS, $values);
$display = [];
foreach ($raw as $header => $value) {
$display[$header] = $this->text($value);
}
$errors = [];
foreach (['快递名称', '快递归属', '对账人', '附加费类型', '结算方式', '计价标准', '生效条件', '生效日期', '状态'] as $header) {
if ($display[$header] === '') {
$errors[] = $header . '不能为空。';
}
}
$this->checkLength($display, '快递名称', 191, $errors);
$this->checkLength($display, '对账人', 100, $errors);
$this->checkLength($display, '供应商', 191, $errors);
$this->checkLength($display, '计价标准', 2000, $errors);
$this->checkLength($display, '生效条件', 2000, $errors);
$this->checkLength($display, '备注', 2000, $errors);
$types = array_values(array_unique(array_filter(array_map(
'trim',
preg_split('/[、,,;;]+/u', $display['附加费类型']) ?: [],
))));
if (count($types) > 50) {
$errors[] = '附加费类型最多 50 项。';
}
$validTypes = $this->quotes->validTypeNames();
$activeTypes = $this->quotes->activeTypeNames();
$unknownTypes = array_values(array_diff($types, $validTypes));
$inactiveTypes = array_values(array_diff(array_intersect($types, $validTypes), $activeTypes));
if ($unknownTypes !== []) {
$errors[] = '附加费类型不存在:' . implode('、', $unknownTypes) . '。';
}
if ($inactiveTypes !== []) {
$errors[] = '附加费类型已停用:' . implode('、', $inactiveTypes) . '。';
}
if ($display['快递归属'] !== '' && ! in_array($display['快递归属'], CourierSurchargeQuoteRepository::SCOPES, true)) {
$errors[] = '快递归属只能填写“自有快递”或“代发仓”。';
}
if ($display['结算方式'] !== '' && ! in_array($display['结算方式'], CourierSurchargeQuoteRepository::MODES, true)) {
$errors[] = '结算方式不在允许范围内。';
}
$effectiveAt = $this->date($raw['生效日期']);
if ($display['生效日期'] !== '' && $effectiveAt === null) {
$errors[] = '生效日期必须是有效的 YYYY-MM-DD 日期。';
}
$status = match (mb_strtolower($display['状态'])) {
'已启用', CourierSurchargeQuote::STATUS_ACTIVE => CourierSurchargeQuote::STATUS_ACTIVE,
'已停用', CourierSurchargeQuote::STATUS_INACTIVE => CourierSurchargeQuote::STATUS_INACTIVE,
default => null,
};
if ($display['状态'] !== '' && $status === null) {
$errors[] = '状态只能填写“已启用/active”或“已停用/inactive”。';
}
if ($effectiveAt !== null) {
$display['生效日期'] = $effectiveAt;
}
return [
'line' => $line,
'data' => [
'courier' => $display['快递名称'],
'scope' => $display['快递归属'],
'reconciler' => $display['对账人'],
'supplier' => $display['供应商'] !== '' ? $display['供应商'] : null,
'types' => $types,
'mode' => $display['结算方式'],
'standard' => $display['计价标准'],
'condition' => $display['生效条件'],
'effective_at' => $effectiveAt,
'status' => $status,
'remark' => $display['备注'],
],
'display' => $display,
'errors' => array_values(array_unique($errors)),
'result' => 'invalid',
'result_label' => '校验失败',
'existing_id' => null,
];
}
/** @param array<string,string> $values @param list<string> $errors */
private function checkLength(array $values, string $field, int $max, array &$errors): void
{
if (mb_strlen($values[$field]) > $max) {
$errors[] = $field . '不能超过 ' . $max . ' 个字符。';
}
}
private function date(mixed $value): ?string
{
if (is_numeric($value) && (float) $value > 0) {
try {
return ExcelDate::excelToDateTimeObject((float) $value)->format('Y-m-d');
} catch (Throwable) {
return null;
}
}
$value = $this->text($value);
if (! preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $value, $matches)) {
return null;
}
return checkdate((int) $matches[2], (int) $matches[3], (int) $matches[1]) ? $value : null;
}
private function text(mixed $value): string
{
return trim(str_replace("\xEF\xBB\xBF", '', (string) ($value ?? '')));
}
}