364 lines
12 KiB
PHP
364 lines
12 KiB
PHP
<?php
|
||
|
||
namespace App\Imports;
|
||
|
||
use App\Exports\ProductExport;
|
||
use App\Models\ProductCategoryModel;
|
||
use App\Models\ProductModel;
|
||
use App\Models\SupplierModel;
|
||
use Illuminate\Support\Collection;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Maatwebsite\Excel\Concerns\ToCollection;
|
||
|
||
/**
|
||
* 商品档案 Excel 导入:整表校验,任何一行有错则全部不导入(返回全部错误行号+原因)。
|
||
* 规则:品名必填;分类按名称/「父分类/子分类」路径匹配末级分类;供应商按名称匹配、不存在自动创建;
|
||
* 导入一律新增商品,不做匹配更新。
|
||
*/
|
||
class ProductImport implements ToCollection
|
||
{
|
||
/** 单次导入数据行上限(防误传超大文件) */
|
||
private const int MAX_ROWS = 1000;
|
||
|
||
/** @var array<int, array{row: int, message: string}> 校验错误行(Excel 行号,1 起) */
|
||
public array $errors = [];
|
||
|
||
/** 成功写入的商品数 */
|
||
public int $created = 0;
|
||
|
||
/** @var array<int, string> 本次自动创建的供应商名称 */
|
||
public array $suppliersCreated = [];
|
||
|
||
/**
|
||
* maatwebsite 入口:读取首个工作表全部行(第 1 行为列头)
|
||
*
|
||
* @param Collection<int, Collection<int, mixed>> $rows
|
||
*/
|
||
public function collection(Collection $rows): void
|
||
{
|
||
$header = $rows->first();
|
||
if (! $this->isValidHeader($header)) {
|
||
$this->errors[] = ['row' => 1, 'message' => '列头与导入模板不一致,请下载最新模板后重试'];
|
||
return;
|
||
}
|
||
|
||
$dataRows = $rows->slice(1)->values();
|
||
if ($dataRows->count() > self::MAX_ROWS) {
|
||
$this->errors[] = ['row' => 2, 'message' => '单次最多导入 ' . self::MAX_ROWS . ' 行,当前 ' . $dataRows->count() . ' 行,请拆分后导入'];
|
||
return;
|
||
}
|
||
|
||
$categories = ProductCategoryModel::all();
|
||
$leafIds = $this->leafCategoryIds($categories);
|
||
|
||
$parsed = [];
|
||
foreach ($dataRows as $index => $row) {
|
||
$excelRow = $index + 2; // Excel 行号(1 起,含列头行)
|
||
$cells = array_map(static fn ($cell) => is_string($cell) ? trim($cell) : $cell, $row->toArray());
|
||
|
||
// 整行空白视为空行跳过
|
||
if (implode('', array_map(static fn ($cell) => (string) $cell, $cells)) === '') {
|
||
continue;
|
||
}
|
||
|
||
$result = $this->parseRow($cells, $categories, $leafIds);
|
||
if ($result['errors'] !== []) {
|
||
foreach ($result['errors'] as $message) {
|
||
$this->errors[] = ['row' => $excelRow, 'message' => $message];
|
||
}
|
||
} else {
|
||
$parsed[] = $result['data'];
|
||
}
|
||
}
|
||
|
||
if ($this->errors !== []) {
|
||
return; // 整表校验:有错一行不写
|
||
}
|
||
if ($parsed === []) {
|
||
$this->errors[] = ['row' => 2, 'message' => '表格中没有可导入的数据行'];
|
||
return;
|
||
}
|
||
|
||
$this->writeRows($parsed);
|
||
}
|
||
|
||
/**
|
||
* 解析并校验一行数据;全部合法时返回写入字段
|
||
*
|
||
* @param array<int, mixed> $cells
|
||
* @param Collection<int, ProductCategoryModel> $categories
|
||
* @param array<int, true> $leafIds
|
||
* @return array{data: array<string, mixed>, errors: array<int, string>}
|
||
*/
|
||
private function parseRow(array $cells, Collection $categories, array $leafIds): array
|
||
{
|
||
[$name, $categoryName, $supplierName, $market, $spec, $unit, $costPrice, $sort, $stock, $shelfLife, $statusText, $remark] =
|
||
array_pad(array_slice($cells, 0, 12), 12, null);
|
||
|
||
$errors = [];
|
||
|
||
$name = (string) $name;
|
||
if ($name === '') {
|
||
$errors[] = '品名不能为空';
|
||
} elseif (mb_strlen($name) > 100) {
|
||
$errors[] = '品名最长 100 个字符';
|
||
}
|
||
|
||
$categoryId = 0;
|
||
$categoryName = (string) $categoryName;
|
||
if ($categoryName === '') {
|
||
$errors[] = '分类不能为空';
|
||
} else {
|
||
$categoryId = $this->resolveCategoryId($categoryName, $categories, $leafIds);
|
||
if ($categoryId === 0) {
|
||
$errors[] = '分类「' . $categoryName . '」不存在或不是末级分类(多个同名末级分类时请使用「父分类/子分类」格式)';
|
||
}
|
||
}
|
||
|
||
$supplierName = (string) $supplierName;
|
||
if (mb_strlen($supplierName) > 100) {
|
||
$errors[] = '供应商名称最长 100 个字符';
|
||
}
|
||
|
||
$market = (string) $market;
|
||
if (mb_strlen($market) > 50) {
|
||
$errors[] = '市场最长 50 个字符';
|
||
}
|
||
|
||
$spec = (string) $spec;
|
||
if (mb_strlen($spec) > 100) {
|
||
$errors[] = '规格/包规最长 100 个字符';
|
||
}
|
||
|
||
$unit = (string) $unit === '' ? '斤' : (string) $unit;
|
||
if (mb_strlen($unit) > 20) {
|
||
$errors[] = '单位最长 20 个字符';
|
||
}
|
||
|
||
$cost = $this->parseDecimal($costPrice, 0, 99999999);
|
||
if ($cost === null) {
|
||
$errors[] = '成本价必须为不小于 0 的数字';
|
||
}
|
||
|
||
$sort = $this->parseInteger($sort);
|
||
if ($sort === null) {
|
||
$errors[] = '排序必须为不小于 0 的整数';
|
||
}
|
||
|
||
$stock = $this->parseInteger($stock);
|
||
if ($stock === null) {
|
||
$errors[] = '库存必须为不小于 0 的整数';
|
||
}
|
||
|
||
$shelfLife = $this->parseInteger($shelfLife);
|
||
if ($shelfLife === null) {
|
||
$errors[] = '保质期必须为不小于 0 的整数';
|
||
}
|
||
|
||
$status = $this->parseStatus($statusText);
|
||
if ($status === null) {
|
||
$errors[] = '状态只能填「上架」或「下架」';
|
||
}
|
||
|
||
$remark = (string) $remark;
|
||
if (mb_strlen($remark) > 255) {
|
||
$errors[] = '备注最长 255 个字符';
|
||
}
|
||
|
||
if ($errors !== []) {
|
||
return ['data' => [], 'errors' => $errors];
|
||
}
|
||
|
||
return [
|
||
'data' => [
|
||
'category_id' => $categoryId,
|
||
'supplier_id' => 0, // 写入阶段按 supplier_name 解析(匹配或自动创建)
|
||
'supplier_name' => $supplierName, // 写入阶段解析,不入 product 表
|
||
'market' => $market,
|
||
'name' => $name,
|
||
'spec' => $spec,
|
||
'unit' => $unit,
|
||
'image_ids' => '',
|
||
'content' => '',
|
||
'sort' => $sort,
|
||
'shelf_life' => $shelfLife,
|
||
'stock' => $stock,
|
||
'status' => $status,
|
||
'cost_price' => $cost,
|
||
'remark' => $remark,
|
||
],
|
||
'errors' => [],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 事务写入:自动创建缺失供应商,批量插入商品
|
||
*
|
||
* @param array<int, array<string, mixed>> $parsed
|
||
*/
|
||
private function writeRows(array $parsed): void
|
||
{
|
||
DB::transaction(function () use ($parsed) {
|
||
// 供应商按名称匹配,不存在自动创建(supplier_id=0 且 supplier_name 非空 = 待创建)
|
||
$supplierIds = SupplierModel::pluck('id', 'name');
|
||
foreach (collect($parsed)->pluck('supplier_name')->filter()->unique() as $name) {
|
||
if (! isset($supplierIds[$name])) {
|
||
$supplier = SupplierModel::create(['name' => $name]);
|
||
$supplierIds[$name] = $supplier->id;
|
||
$this->suppliersCreated[] = $name;
|
||
}
|
||
}
|
||
|
||
$now = now();
|
||
$payload = array_map(static function (array $row) use ($supplierIds, $now) {
|
||
if ($row['supplier_name'] !== '') {
|
||
$row['supplier_id'] = (int) $supplierIds[$row['supplier_name']];
|
||
}
|
||
unset($row['supplier_name']);
|
||
$row['created_at'] = $now;
|
||
$row['updated_at'] = $now;
|
||
return $row;
|
||
}, $parsed);
|
||
|
||
ProductModel::insert($payload);
|
||
$this->created = count($payload);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 分类名称/路径 → 末级分类ID(0 = 解析失败)
|
||
* 支持「父分类/子分类」路径逐级匹配;单名称时要求末级分类唯一
|
||
*
|
||
* @param Collection<int, ProductCategoryModel> $categories
|
||
* @param array<int, true> $leafIds
|
||
*/
|
||
private function resolveCategoryId(string $name, Collection $categories, array $leafIds): int
|
||
{
|
||
// 需 u 修饰符:多字节分隔符(/)按字节解析会切碎中文
|
||
$segments = array_values(array_filter(array_map(
|
||
static fn ($segment) => trim($segment),
|
||
preg_split('#[//\\\\]#u', $name) ?: []
|
||
), static fn ($segment) => $segment !== ''));
|
||
|
||
if ($segments === []) {
|
||
return 0;
|
||
}
|
||
|
||
if (count($segments) > 1) {
|
||
// 路径逐级匹配
|
||
$parentId = 0;
|
||
$matched = null;
|
||
foreach ($segments as $segment) {
|
||
$matched = $categories->first(
|
||
static fn (ProductCategoryModel $category) => $category->name === $segment
|
||
&& (int) $category->parent_id === $parentId
|
||
);
|
||
if ($matched === null) {
|
||
return 0;
|
||
}
|
||
$parentId = (int) $matched->id;
|
||
}
|
||
return isset($leafIds[(int) $matched->id]) ? (int) $matched->id : 0;
|
||
}
|
||
|
||
// 单名称:仅在末级分类中唯一时命中
|
||
$leafMatches = $categories->filter(
|
||
static fn (ProductCategoryModel $category) => $category->name === $segments[0]
|
||
&& isset($leafIds[(int) $category->id])
|
||
);
|
||
if ($leafMatches->count() !== 1) {
|
||
return 0;
|
||
}
|
||
return (int) $leafMatches->first()->id;
|
||
}
|
||
|
||
/**
|
||
* 末级分类ID集合(无任何子分类)
|
||
*
|
||
* @param Collection<int, ProductCategoryModel> $categories
|
||
* @return array<int, true>
|
||
*/
|
||
private function leafCategoryIds(Collection $categories): array
|
||
{
|
||
$parentIds = [];
|
||
foreach ($categories as $category) {
|
||
if ((int) $category->parent_id > 0) {
|
||
$parentIds[(int) $category->parent_id] = true;
|
||
}
|
||
}
|
||
$leafIds = [];
|
||
foreach ($categories as $category) {
|
||
if (! isset($parentIds[(int) $category->id])) {
|
||
$leafIds[(int) $category->id] = true;
|
||
}
|
||
}
|
||
return $leafIds;
|
||
}
|
||
|
||
/**
|
||
* 列头校验:与导出/模板列头完全一致(允许尾部多余空单元格)
|
||
*/
|
||
private function isValidHeader(?Collection $header): bool
|
||
{
|
||
if ($header === null) {
|
||
return false;
|
||
}
|
||
$cells = array_map(static fn ($cell) => trim((string) $cell), $header->toArray());
|
||
foreach (ProductExport::HEADERS as $index => $expected) {
|
||
if (($cells[$index] ?? '') !== $expected) {
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 解析十进制数字(空串取默认值;越界/非数字返回 null)
|
||
*/
|
||
private function parseDecimal(mixed $value, float $min, float $max): ?string
|
||
{
|
||
if ($value === null || $value === '') {
|
||
$value = 0;
|
||
}
|
||
if (! is_numeric($value)) {
|
||
return null;
|
||
}
|
||
$number = (float) $value;
|
||
if ($number < $min || $number > $max) {
|
||
return null;
|
||
}
|
||
return number_format($number, 2, '.', '');
|
||
}
|
||
|
||
/**
|
||
* 解析非负整数(空串取 0;非整数/负数返回 null)
|
||
*/
|
||
private function parseInteger(mixed $value): ?int
|
||
{
|
||
if ($value === null || $value === '') {
|
||
return 0;
|
||
}
|
||
if (! is_numeric($value)) {
|
||
return null;
|
||
}
|
||
$number = (float) $value;
|
||
if ($number < 0 || $number !== (float) (int) $number) {
|
||
return null;
|
||
}
|
||
return (int) $number;
|
||
}
|
||
|
||
/**
|
||
* 解析状态(空串默认上架;接受 上架/下架/1/0)
|
||
*/
|
||
private function parseStatus(mixed $value): ?int
|
||
{
|
||
$text = trim((string) $value);
|
||
return match ($text) {
|
||
'', '上架', '1' => ProductModel::STATUS_ON,
|
||
'下架', '0' => ProductModel::STATUS_OFF,
|
||
default => null,
|
||
};
|
||
}
|
||
}
|