商品导入导出
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use App\Models\ProductCategoryModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\SupplierModel;
|
||||
use Illuminate\Support\Collection;
|
||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithStrictNullComparison;
|
||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
|
||||
/**
|
||||
* 商品档案导出:列格式与导入模板完全一致(首行即列头,导出文件修改后可直接重新导入)。
|
||||
* 分类列输出「父分类/子分类」完整路径;状态列输出中文。
|
||||
* template 模式仅输出列头 + 一行示例数据(首次导入前下载参考)。
|
||||
*/
|
||||
class ProductExport implements FromCollection, WithStrictNullComparison, WithStyles
|
||||
{
|
||||
/** 列头(与 ProductImport 的解析顺序一一对应,改动需同步) */
|
||||
public const array HEADERS = ['品名', '分类', '供应商', '规格/包规', '单位', '成本价', '排序', '库存', '保质期', '状态', '备注'];
|
||||
|
||||
private ?Collection $rows = null;
|
||||
|
||||
/**
|
||||
* @param int $categoryId 末级分类筛选(0=全部商品)
|
||||
* @param bool $template 仅输出列头 + 示例行(导入模板)
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly int $categoryId = 0,
|
||||
private readonly bool $template = false,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 行结构:第 1 行列头,其后每行一件商品
|
||||
*/
|
||||
public function collection(): Collection
|
||||
{
|
||||
if ($this->rows !== null) {
|
||||
return $this->rows;
|
||||
}
|
||||
|
||||
$rows = [self::HEADERS];
|
||||
|
||||
if ($this->template) {
|
||||
$rows[] = ['西红柿', '蔬菜/茄果类', '示例供应商', '10斤/箱', '斤', 2.5, 0, 100, 3, '上架', '示例行,导入前请删除'];
|
||||
return $this->rows = collect($rows);
|
||||
}
|
||||
|
||||
$products = ProductModel::query()
|
||||
->when($this->categoryId > 0, fn ($query) => $query->where('category_id', $this->categoryId))
|
||||
->orderBy('sort')
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->makeVisible('cost_price');
|
||||
|
||||
$categoryPaths = $this->categoryPaths($products->pluck('category_id')->unique()->all());
|
||||
$supplierNames = SupplierModel::withTrashed()->pluck('name', 'id');
|
||||
|
||||
foreach ($products as $product) {
|
||||
$rows[] = [
|
||||
$product->name,
|
||||
$categoryPaths[(int) $product->category_id] ?? '',
|
||||
$supplierNames[(int) $product->supplier_id] ?? '',
|
||||
$product->spec,
|
||||
$product->unit,
|
||||
(float) $product->cost_price,
|
||||
(int) $product->sort,
|
||||
(int) $product->stock,
|
||||
(int) $product->shelf_life,
|
||||
(int) $product->status === ProductModel::STATUS_ON ? '上架' : '下架',
|
||||
$product->remark,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->rows = collect($rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列头加粗、冻结首行、设置列宽
|
||||
*/
|
||||
public function styles(Worksheet $sheet): array
|
||||
{
|
||||
$this->collection();
|
||||
|
||||
$sheet->freezePane('A2');
|
||||
$widths = [20, 16, 14, 14, 8, 10, 8, 8, 8, 8, 24];
|
||||
foreach ($widths as $index => $width) {
|
||||
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
|
||||
}
|
||||
|
||||
return [
|
||||
1 => ['font' => ['bold' => true]],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类ID => 「父分类/子分类」完整路径(沿 parent_id 上溯拼接)
|
||||
*
|
||||
* @param array<int, int> $categoryIds
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function categoryPaths(array $categoryIds): array
|
||||
{
|
||||
$categories = ProductCategoryModel::all()->keyBy('id');
|
||||
$paths = [];
|
||||
foreach ($categoryIds as $categoryId) {
|
||||
$names = [];
|
||||
$cursor = (int) $categoryId;
|
||||
$guard = 0;
|
||||
while ($cursor > 0 && $guard++ < 20) {
|
||||
$category = $categories->get($cursor);
|
||||
if ($category === null) {
|
||||
break;
|
||||
}
|
||||
array_unshift($names, $category->name);
|
||||
$cursor = (int) $category->parent_id;
|
||||
}
|
||||
$paths[(int) $categoryId] = implode('/', $names);
|
||||
}
|
||||
return $paths;
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,10 @@
|
||||
namespace App\Http\Controllers\Product;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Exports\ProductExport;
|
||||
use App\Http\Requests\Product\BatchPriceRequest;
|
||||
use App\Http\Requests\Product\ProductFormRequest;
|
||||
use App\Imports\ProductImport;
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\NoticeModel;
|
||||
use App\Models\ProductModel;
|
||||
@@ -13,6 +15,7 @@ use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use Modules\AnnoRoute\Attribute\DeleteRoute;
|
||||
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||
use Modules\AnnoRoute\Attribute\PostRoute;
|
||||
@@ -20,6 +23,7 @@ use Modules\AnnoRoute\Attribute\PutRoute;
|
||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||
use Modules\Common\Http\Controllers\BaseController;
|
||||
use Modules\SystemTool\Services\SysFileService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* 商品档案管理(售价 = 成本价 × (100 + 客户等级上浮比例) / 100,不再维护等级价格行)
|
||||
@@ -90,6 +94,53 @@ class ProductController extends BaseController
|
||||
return $this->success(['id' => $product->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 商品导出(列格式与导入模板一致,导出文件修改后可直接重新导入);
|
||||
* template=1 时仅输出列头 + 示例行(导入模板)
|
||||
*/
|
||||
#[GetRoute('/export', 'export')]
|
||||
public function export(Request $request): Response
|
||||
{
|
||||
$template = $request->boolean('template');
|
||||
$categoryId = max(0, (int) $request->query('category_id', 0));
|
||||
$filename = $template
|
||||
? '商品导入模板.xlsx'
|
||||
: '商品列表_' . now()->format('Ymd_His') . '.xlsx';
|
||||
return Excel::download(new ProductExport($categoryId, $template), $filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Excel 批量导入商品:整表校验,有错全部不导入;
|
||||
* 分类按名称/路径匹配末级分类,供应商按名称匹配、不存在自动创建;一律新增商品
|
||||
*/
|
||||
#[PostRoute('/import', 'import')]
|
||||
public function import(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'file' => 'required|file|mimes:xlsx,xls|max:10240',
|
||||
], [
|
||||
'file.required' => '请选择要导入的 Excel 文件',
|
||||
'file.mimes' => '仅支持 xlsx/xls 格式的 Excel 文件',
|
||||
'file.max' => '文件大小不能超过 10MB',
|
||||
]);
|
||||
|
||||
$import = new ProductImport();
|
||||
Excel::import($import, $data['file']);
|
||||
|
||||
if ($import->errors !== []) {
|
||||
return $this->error(
|
||||
['errors' => $import->errors],
|
||||
'共发现 ' . count($import->errors) . ' 处数据错误,请修正后重新导入'
|
||||
);
|
||||
}
|
||||
|
||||
$msg = '成功导入 ' . $import->created . ' 件商品';
|
||||
if ($import->suppliersCreated !== []) {
|
||||
$msg .= ',自动创建供应商:' . implode('、', $import->suppliersCreated);
|
||||
}
|
||||
return $this->success(['created' => $import->created], $msg);
|
||||
}
|
||||
|
||||
/** 编辑商品 */
|
||||
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
|
||||
public function update(int $id, ProductFormRequest $request): JsonResponse
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user