商品导入导出

This commit is contained in:
liu
2026-08-20 14:45:06 +08:00
parent 52ab195432
commit cd7007bc49
9 changed files with 1172 additions and 11 deletions
+357
View File
@@ -0,0 +1,357 @@
<?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, $spec, $unit, $costPrice, $sort, $stock, $shelfLife, $statusText, $remark] =
array_pad(array_slice($cells, 0, 11), 11, 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 个字符';
}
$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 表
'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,
};
}
}