387 lines
16 KiB
PHP
387 lines
16 KiB
PHP
<?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 ?? '')));
|
||
}
|
||
}
|