Files
xin-procurement/app/Imports/ProductImport.php
T
2026-08-20 15:39:39 +08:00

364 lines
12 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\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,
};
}
}