商品导入导出

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
File diff suppressed because one or more lines are too long
+126
View File
@@ -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
+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,
};
}
}
+2
View File
@@ -89,6 +89,8 @@ class PermissionSeeder extends Seeder
['type' => 'rule', 'key' => 'product.goods.update', 'name' => '编辑'],
['type' => 'rule', 'key' => 'product.goods.delete', 'name' => '删除'],
['type' => 'rule', 'key' => 'product.goods.batchPrice', 'name' => '批量调价'],
['type' => 'rule', 'key' => 'product.goods.import', 'name' => '导入'],
['type' => 'rule', 'key' => 'product.goods.export', 'name' => '导出'],
],
],
[
+172
View File
@@ -0,0 +1,172 @@
# 小程序端修改文档(门店端)
> 版本:2026-08-17
> 后端变更:用户表与门店表合并(一个用户就是一个门店),门店登录由「微信静默登录」改为「账号 + 密码登录」。
> 本文档面向小程序(门店端)开发者,说明需要配合修改的内容。**未列出的接口均无变化**。
---
## 一、变更总览
| 事项 | 旧 | 新 |
|------|-----|-----|
| 登录方式 | `wx.login` 拿 code,调 `/mini/auth/login` 静默登录 | 账号 + 密码登录(账号由商家后台分配) |
| 注册 | `POST /mini/auth/register`(微信 code + 手机号 + 门店编码) | **接口下线**,门店账号由商家在 PC 后台创建 |
| 登录主体 | 微信用户(user),再绑定门店(store) | **门店即用户**:登录成功返回的就是门店本身 |
| 修改密码 | 无 | 新增 `PUT /mini/auth/password` |
| Token 鉴权 | Bearer Token | **不变** |
| 其他业务接口 | 购物车 / 订单 / 账单 / 支付 / 通知 / 商品 / 首页 | **全部不变**(路径、入参、出参) |
### 需要小程序端配合的改动
1. **重做登录页**:去掉 `wx.login` 流程,改为账号 + 密码表单(账号密码由商家线下告知门店)。
2. **删除注册页/绑定门店页**:注册接口已下线,「尚未绑定门店」的场景已不存在。
3. **用户信息结构调整**:登录与 `auth/info` 返回的对象字段变化(见下文)。
4. **新增「修改密码」入口**(建议放在「我的」页面)。
5. **重新登录**:旧 token 全部失效,需引导用户用账号密码重新登录一次。
---
## 二、登录接口(变更)
### `POST /mini/auth/login`
**请求参数(变更):**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| username | string | 是 | 登录账号(商家后台分配,4~20 位) |
| password | string | 是 | 登录密码 |
> 旧参数 `code`(wx.login 凭证)已废弃,无需再传,也无需调用 `wx.login`。
**成功响应:**
```json
{
"success": true,
"msg": "登录成功",
"data": {
"token": "1|xxxxxxxxxxxxxxxx",
"user": {
"id": 3,
"name": "好又多超市",
"code": "S000003",
"username": "hyd001",
"avatar": "",
"level_id": 1,
"level": { "id": 1, "name": "一级客户" },
"contact": "张三",
"phone": "13800000000",
"address": "xx路 1 号",
"payment_cycle_days": 2,
"status": 1,
"last_login_at": "2026-08-17 10:00:00",
"created_at": "2026-08-01 09:00:00"
}
}
}
```
**user 字段变化要点:**
- `user` 现在**就是门店对象**(扁平结构),不再有 `user.store` 嵌套,也不再有 `openid``unionid``nickname``email``store_id` 字段。
- 门店名称取 `user.name`(原 `user.store.name`);客户等级取 `user.level`
- `password` 字段永不返回。
**失败响应(`success: false``msg` 提示):**
| 场景 | msg |
|------|-----|
| 账号不存在或密码错误 | 账号或密码错误 |
| 门店已停用 | 账号已被停用,请联系客服处理 |
| 参数缺失 | 请输入登录账号 / 请输入登录密码 |
**Token 使用(不变):** 后续所有请求携带请求头 `Authorization: Bearer {token}`
---
## 三、注册接口(下线)
### ~~`POST /mini/auth/register`~~ —— 已删除
- 门店账号全部由商家在 PC 后台「客户管理 → 门店管理」创建并分配(含登录账号、初始密码)。
- 小程序端请移除注册页、「输入门店编码绑定」页及相关逻辑。
- 同步移除 `wx.getPhoneNumber` 手机号授权流程(后端已不再使用)。
---
## 四、当前用户信息(结构变化)
### `GET /mini/auth/info`(需登录)
返回当前登录门店完整信息(含 `level` 等级嵌套),字段与登录接口的 `user` 一致(见上文示例)。原来读取 `info.store.xxx` 的地方改为直接读 `info.xxx`
---
## 五、修改密码(新增)
### `PUT /mini/auth/password`(需登录)
**请求参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| oldPassword | string | 是 | 原密码 |
| newPassword | string | 是 | 新密码(6~20 位) |
| rePassword | string | 是 | 确认新密码(须与 newPassword 一致) |
**失败场景:** 原密码不正确 / 两次输入的密码不一致 / 新密码至少 6 位。
> 修改密码成功后现有 token 仍然有效,无需强制重新登录。
---
## 六、门店信息(不变)
### `GET /mini/store/info`(需登录)
返回:`{ id, name, code, contact, phone, address, payment_cycle_days }`(门店名称/编码/回款周期只读,由后台维护)。
### `PUT /mini/store/info`(需登录)
可改字段不变:`contact`(联系人)、`phone`(联系电话)、`address`(地址)。
---
## 七、以下接口完全不变
路径、入参、出参均无变化,仅内部归属从「用户」变为「门店」(对小程序透明):
| 模块 | 接口 |
|------|------|
| 首页 | `GET /mini/home/*`(轮播/导航/促销) |
| 商品 | `GET /mini/product/categories``GET /mini/product/list``GET /mini/product/{id}`(登录后按本店等级显示价格) |
| 购物车 | `POST /mini/cart``GET /mini/cart``PUT /mini/cart/{id}``DELETE /mini/cart/{id}``DELETE /mini/cart` |
| 订单 | `POST /mini/order``GET /mini/order``GET /mini/order/summary``GET /mini/order/{id}``PUT /mini/order/{id}/cancel` |
| 账单 | `GET /mini/bill``GET /mini/bill/{id}``GET /mini/bill/export` |
| 支付 | `GET /mini/payment/config``GET /mini/payment``POST /mini/payment``GET /mini/payment/{id}` |
| 通知 | `GET /mini/notice``PUT /mini/notice/{id}/read` |
| 上传 | `POST /mini/upload` |
> 唯一语义差异:数据隔离现在天然按门店划分(一个门店一个账号),原「同一门店多个微信账号各自购物车」的合并场景不再存在。
---
## 八、错误语义(不变)
- 未携带/无效 tokenHTTP `401`
- 业务错误:HTTP `200` + `{ success: false, msg: "..." }`,直接 toast `msg` 即可。
- 「门店未设置客户等级,无法加购/下单,请联系客服」等提示文案不变。
- 「尚未绑定门店」提示已移除(该场景不存在)。
---
## 九、上线 Checklist(小程序端)
- [ ] 登录页改为账号 + 密码表单,移除 `wx.login` / `wx.getPhoneNumber` 调用
- [ ] 移除注册页、门店绑定页及路由
- [ ] 全局用户信息读取点从 `user.store.*` 调整为 `user.*`(门店名、等级、回款周期等)
- [ ] 「我的」页面新增修改密码入口
- [ ] 旧版本缓存的 token 失效处理:401 时引导重新登录
- [ ] 等级价格展示逻辑不变(接口返回的 `price` 字段含义不变)
+308
View File
@@ -0,0 +1,308 @@
<?php
namespace Tests\Feature;
use App\Exports\ProductExport;
use App\Models\ProductCategoryModel;
use App\Models\ProductModel;
use App\Models\SupplierModel;
use Illuminate\Http\UploadedFile;
use Maatwebsite\Excel\Facades\Excel;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
/**
* 商品 Excel 导入/导出:
* 导出 = 全量/分类过滤/模板模式/权限拦截;
* 导入 = 新增+供应商自动创建/整表校验零写入/分类路径消歧/列头校验/权限拦截
*/
class ProductImportExportTest extends ProcurementTestCase
{
/**
* 生成真实 xlsx 上传文件(行为行数组,首行为列头)
*
* @param array<int, array<int, mixed>> $rows
*/
private function makeUploadFile(array $rows): UploadedFile
{
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
foreach ($rows as $index => $row) {
$sheet->fromArray($row, null, 'A' . ($index + 1));
}
$path = tempnam(sys_get_temp_dir(), 'imp') . '.xlsx';
(new Xlsx($spreadsheet))->save($path);
$spreadsheet->disconnectWorksheets();
return new UploadedFile(
$path,
'import.xlsx',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
null,
true
);
}
/** 创建 根分类 + 末级子分类,返回末级分类 */
private function makeLeafCategory(string $rootName = '蔬菜', string $leafName = '叶菜类'): ProductCategoryModel
{
$root = ProductCategoryModel::create(['name' => $rootName, 'parent_id' => 0, 'sort' => 0, 'status' => 1]);
return ProductCategoryModel::create(['name' => $leafName, 'parent_id' => $root->id, 'sort' => 0, 'status' => 1]);
}
// ==================== 导出 ====================
/** 导出全量商品:列头 + 分类路径 + 供应商名 + 状态中文 */
public function test_export_downloads_all_products(): void
{
$this->freezeTime();
$leaf = $this->makeLeafCategory();
$supplier = SupplierModel::create(['name' => '张三蔬菜批发']);
ProductModel::factory()->create([
'category_id' => $leaf->id,
'supplier_id' => $supplier->id,
'name' => '上海青',
'spec' => '10斤/箱',
'unit' => '斤',
'cost_price' => '2.50',
'sort' => 3,
'stock' => 100,
'shelf_life' => 2,
'status' => ProductModel::STATUS_ON,
'remark' => '新鲜直达',
]);
ProductModel::factory()->off()->create(['category_id' => $leaf->id, 'name' => '下架商品']);
$this->actingAsSysUser();
Excel::fake();
$this->get('/product/goods/export')->assertOk();
Excel::assertDownloaded(
'商品列表_' . now()->format('Ymd_His') . '.xlsx',
static function (ProductExport $export): bool {
$rows = $export->collection()->values();
// 列头 + 2 件商品
if ($rows->count() !== 3) {
return false;
}
if ($rows[0] !== ProductExport::HEADERS) {
return false;
}
$row = $rows->firstWhere(0, '上海青');
if ($row === null) {
return false;
}
// [品名, 分类路径, 供应商, 规格, 单位, 成本价, 排序, 库存, 保质期, 状态, 备注]
if ($row[1] !== '蔬菜/叶菜类' || $row[2] !== '张三蔬菜批发' || $row[9] !== '上架') {
return false;
}
if ((float) $row[5] !== 2.5 || (int) $row[6] !== 3 || (int) $row[7] !== 100 || (int) $row[8] !== 2) {
return false;
}
if ($row[10] !== '新鲜直达') {
return false;
}
$offRow = $rows->firstWhere(0, '下架商品');
return $offRow !== null && $offRow[9] === '下架' && $offRow[2] === '';
}
);
}
/** 按末级分类过滤导出 */
public function test_export_filters_by_category(): void
{
$this->freezeTime();
$leafA = $this->makeLeafCategory('蔬菜', '叶菜类');
$leafB = $this->makeLeafCategory('水果', '浆果类');
ProductModel::factory()->create(['category_id' => $leafA->id, 'name' => '菠菜']);
ProductModel::factory()->create(['category_id' => $leafB->id, 'name' => '草莓']);
$this->actingAsSysUser();
Excel::fake();
$this->get('/product/goods/export?category_id=' . $leafA->id)->assertOk();
Excel::assertDownloaded(
'商品列表_' . now()->format('Ymd_His') . '.xlsx',
static function (ProductExport $export): bool {
$rows = $export->collection()->values();
return $rows->count() === 2
&& $rows[1][0] === '菠菜'
&& $rows[1][1] === '蔬菜/叶菜类';
}
);
}
/** template=1 输出导入模板:列头 + 示例行 */
public function test_export_template_mode(): void
{
$this->actingAsSysUser();
Excel::fake();
$this->get('/product/goods/export?template=1')->assertOk();
Excel::assertDownloaded(
'商品导入模板.xlsx',
static function (ProductExport $export): bool {
$rows = $export->collection()->values();
return $rows->count() === 2
&& $rows[0] === ProductExport::HEADERS
&& $rows[1][0] === '西红柿';
}
);
}
/** 无 product.goods.export 权限点 → 拦截 */
public function test_export_requires_permission(): void
{
// 先建占位用户:每个测试方法内首个系统用户自增 id=1,超管旁路会绕过 abilities 校验
$this->actingAsSysUser();
$this->actingAsSysUser(['product.goods.query']);
$response = $this->get('/product/goods/export');
$this->assertFalse($response->json('success'), '缺少导出权限点应被拦截');
}
// ==================== 导入 ====================
/** 正常导入:新增商品 + 供应商自动创建 + 默认值(单位斤/状态上架) */
public function test_import_creates_products_and_auto_supplier(): void
{
$leaf = $this->makeLeafCategory();
$this->actingAsSysUser();
$file = $this->makeUploadFile([
ProductExport::HEADERS,
['上海青', '叶菜类', '新供应商A', '10斤/箱', '', 2.5, 3, 100, 2, '下架', '备注信息'],
['西红柿', '蔬菜/茄果类', '', '散装', '箱', 1.8, '', '', '', '', ''],
]);
// 第二行用到了不存在的「茄果类」路径 → 先补建该分类
ProductCategoryModel::create(['name' => '茄果类', 'parent_id' => $leaf->parent_id, 'sort' => 1, 'status' => 1]);
$response = $this->post('/product/goods/import', ['file' => $file]);
$response->assertJsonPath('success', true);
$response->assertJsonPath('data.created', 2);
$productA = ProductModel::where('name', '上海青')->first();
$this->assertNotNull($productA);
$this->assertSame($leaf->id, $productA->category_id);
$this->assertSame('斤', $productA->unit, '单位留空应默认斤');
$this->assertSame(ProductModel::STATUS_OFF, $productA->status);
$this->assertSame('2.50', (string) $productA->cost_price);
$supplier = SupplierModel::where('name', '新供应商A')->first();
$this->assertNotNull($supplier, '供应商不存在应自动创建');
$this->assertSame($supplier->id, $productA->supplier_id);
$productB = ProductModel::where('name', '西红柿')->first();
$this->assertNotNull($productB);
$this->assertSame(0, $productB->supplier_id, '供应商留空应为 0(不创建)');
$this->assertSame(ProductModel::STATUS_ON, $productB->status, '状态留空应默认上架');
$this->assertSame(0, $productB->sort);
}
/** 供应商名称已存在 → 直接匹配,不重复创建 */
public function test_import_matches_existing_supplier(): void
{
$leaf = $this->makeLeafCategory();
$existing = SupplierModel::create(['name' => '老供应商']);
$this->actingAsSysUser();
$file = $this->makeUploadFile([
ProductExport::HEADERS,
['黄瓜', '叶菜类', '老供应商', '散装', '斤', 1.2, 0, 0, 0, '上架', ''],
]);
$this->post('/product/goods/import', ['file' => $file])->assertJsonPath('success', true);
$this->assertSame(1, SupplierModel::where('name', '老供应商')->count(), '已存在供应商不应重复创建');
$this->assertSame($existing->id, ProductModel::where('name', '黄瓜')->value('supplier_id'));
}
/** 错误行(缺品名/分类不存在/成本价非数字)→ 全部不导入,供应商也不创建 */
public function test_import_errors_abort_all_rows(): void
{
$this->makeLeafCategory();
$this->actingAsSysUser();
$file = $this->makeUploadFile([
ProductExport::HEADERS,
['正常商品', '叶菜类', '供应商X', '散装', '斤', 1.0, 0, 0, 0, '上架', ''],
['', '叶菜类', '', '散装', '斤', 1.0, 0, 0, 0, '上架', ''],
['坏商品A', '不存在的分类', '', '散装', '斤', 1.0, 0, 0, 0, '上架', ''],
['坏商品B', '叶菜类', '', '散装', '斤', 'abc', 0, 0, 0, '上架', ''],
]);
$response = $this->post('/product/goods/import', ['file' => $file]);
$response->assertJsonPath('success', false);
$errors = $response->json('data.errors');
$this->assertCount(3, $errors);
$this->assertSame(3, $errors[0]['row']);
$this->assertStringContainsString('品名', $errors[0]['message']);
$this->assertSame(4, $errors[1]['row']);
$this->assertStringContainsString('分类', $errors[1]['message']);
$this->assertSame(5, $errors[2]['row']);
$this->assertStringContainsString('成本价', $errors[2]['message']);
$this->assertSame(0, ProductModel::count(), '有错误行时整表不应写入');
$this->assertSame(0, SupplierModel::where('name', '供应商X')->count(), '有错误行时供应商不应创建');
}
/** 分类解析:路径消歧命中;单名称歧义/非末级 → 报错 */
public function test_import_category_path_resolution(): void
{
$vegRoot = ProductCategoryModel::create(['name' => '蔬菜', 'parent_id' => 0, 'sort' => 0, 'status' => 1]);
$fruitRoot = ProductCategoryModel::create(['name' => '水果', 'parent_id' => 0, 'sort' => 1, 'status' => 1]);
$vegLeaf = ProductCategoryModel::create(['name' => '叶菜', 'parent_id' => $vegRoot->id, 'sort' => 0, 'status' => 1]);
ProductCategoryModel::create(['name' => '叶菜', 'parent_id' => $fruitRoot->id, 'sort' => 0, 'status' => 1]);
$this->actingAsSysUser();
// 路径写法命中蔬菜/叶菜
$file = $this->makeUploadFile([
ProductExport::HEADERS,
['菠菜', '蔬菜/叶菜', '', '散装', '斤', 1.0, 0, 0, 0, '上架', ''],
]);
$this->post('/product/goods/import', ['file' => $file])->assertJsonPath('success', true);
$this->assertSame($vegLeaf->id, ProductModel::where('name', '菠菜')->value('category_id'));
// 单名称歧义 + 非末级分类 → 报错零写入
$file = $this->makeUploadFile([
ProductExport::HEADERS,
['歧义商品', '叶菜', '', '散装', '斤', 1.0, 0, 0, 0, '上架', ''],
['非末级商品', '蔬菜', '', '散装', '斤', 1.0, 0, 0, 0, '上架', ''],
]);
$response = $this->post('/product/goods/import', ['file' => $file]);
$response->assertJsonPath('success', false);
$this->assertCount(2, $response->json('data.errors'));
$this->assertSame(1, ProductModel::count(), '本次应零写入(仅剩上一条菠菜)');
}
/** 列头与模板不一致 → 拒绝导入 */
public function test_import_rejects_invalid_header(): void
{
$this->actingAsSysUser();
$file = $this->makeUploadFile([
['名称', '类别', '供应商'],
['上海青', '叶菜类', ''],
]);
$response = $this->post('/product/goods/import', ['file' => $file]);
$response->assertJsonPath('success', false);
$this->assertSame(1, $response->json('data.errors.0.row'));
$this->assertSame(0, ProductModel::count());
}
/** 无 product.goods.import 权限点 → 拦截 */
public function test_import_requires_permission(): void
{
$this->actingAsSysUser();
$this->actingAsSysUser(['product.goods.query']);
$file = $this->makeUploadFile([
ProductExport::HEADERS,
['上海青', '叶菜类', '', '散装', '斤', 1.0, 0, 0, 0, '上架', ''],
]);
$response = $this->post('/product/goods/import', ['file' => $file]);
$this->assertFalse($response->json('success'), '缺少导入权限点应被拦截');
$this->assertSame(0, ProductModel::count());
}
}
+42 -9
View File
@@ -1,4 +1,5 @@
import createAxios from '@/utils/request';
import { downloadBlob } from '@/api/common/download.ts';
import type IProduct from '@/domain/iProduct.ts';
import type { IBatchPriceUpdate, IPriceMatrix } from '@/domain/iProduct.ts';
@@ -9,6 +10,47 @@ export interface PriceMatrixParams {
pageSize?: number;
}
/** 导入校验错误行(Excel 行号,1 起) */
export interface IImportError {
row: number;
message: string;
}
/** 商品下拉选项(仅上架) */
export async function getProductOptions(keyword?: string) {
return createAxios<IProduct[]>({
url: '/product/goods/options',
method: 'get',
params: keyword ? { keyword } : {},
});
}
/** 商品列表导出(列格式与导入模板一致,导出文件修改后可直接重新导入) */
export async function exportProducts(params: { category_id?: number } = {}) {
return downloadBlob('/product/goods/export', params, '商品列表.xlsx');
}
/** 下载商品导入模板(列头 + 示例行) */
export async function downloadProductTemplate() {
return downloadBlob('/product/goods/export', { template: 1 }, '商品导入模板.xlsx');
}
/**
* Excel 批量导入商品(整表校验,有错全部不导入)
* 失败时 promise reject,错误行明细在 err.data.data.errors
*/
export async function importProducts(file: File) {
const formData = new FormData();
formData.append('file', file);
return createAxios<{ created: number }>({
url: '/product/goods/import',
method: 'post',
data: formData,
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 60000,
});
}
/**
* A2 价格矩阵:行=商品,列=启用等级,值=按等级上浮比例换算的售价(成本价未设置 null);
* 仅成本价可编辑
@@ -31,12 +73,3 @@ export async function batchPrice(updates: IBatchPriceUpdate[]) {
data: { updates },
});
}
/** 商品下拉选项(仅上架) */
export async function getProductOptions(keyword?: string) {
return createAxios<IProduct[]>({
url: '/product/goods/options',
method: 'get',
params: keyword ? { keyword } : {},
});
}
+113 -1
View File
@@ -1,20 +1,24 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Alert,
Button, Card,
Drawer,
Image,
Input,
InputNumber,
message,
Modal,
Space,
Table,
Tag, Tree,
TreeSelect,
Typography,
Upload,
} from 'antd';
import { TableOutlined } from '@ant-design/icons';
import { DownloadOutlined, InboxOutlined, TableOutlined, UploadOutlined } from '@ant-design/icons';
import type { TableProps } from 'antd';
import XinTable from '@/components/XinTable';
import AuthButton from '@/components/AuthButton';
import type { XinTableColumn, XinTableInstance, XinTableProps } from '@/components/XinTable/typings.ts';
import type IProduct from '@/domain/iProduct.ts';
import type { IBatchPriceUpdate, IPriceMatrixRow } from '@/domain/iProduct.ts';
@@ -25,6 +29,8 @@ import { getSupplierOptions } from '@/api/customer/supplier.ts';
import type ISupplier from '@/domain/iSupplier.ts';
import { getCategoryTree } from '@/api/product/category.ts';
import { batchPrice, getPriceMatrix } from '@/api/product/goods.ts';
import type { IImportError } from '@/api/product/goods.ts';
import { downloadProductTemplate, exportProducts, importProducts } from '@/api/product/goods.ts';
const { Title, Text } = Typography;
@@ -80,6 +86,45 @@ const ProductGoodsPage: React.FC = () => {
/** 跨页未保存的成本价:productId → costnull 视为未修改,不提交) */
const costDirtyRef = useRef<Record<number, number | null>>({});
// ===== Excel 导入/导出 =====
const [exportLoading, setExportLoading] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [importFile, setImportFile] = useState<File | null>(null);
const [importing, setImporting] = useState(false);
const [importErrors, setImportErrors] = useState<IImportError[]>([]);
/** 导出商品列表(跟随侧栏分类筛选;列格式与导入模板一致) */
const handleExport = async () => {
setExportLoading(true);
try {
await exportProducts(activeCategory ? { category_id: activeCategory } : {});
} finally {
setExportLoading(false);
}
};
/** 提交导入:成功刷新列表;校验失败展示错误行明细(错误提示由拦截器统一弹出) */
const handleImport = async () => {
if (!importFile) {
return;
}
setImporting(true);
setImportErrors([]);
try {
await importProducts(importFile);
setImportOpen(false);
setImportFile(null);
tableRef.current?.reset();
} catch (err: any) {
const errors = err?.data?.data?.errors;
if (Array.isArray(errors)) {
setImportErrors(errors);
}
} finally {
setImporting(false);
}
};
useEffect(() => {
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
getCategoryTree().then((res) => setCategoryTree(res.data.data ?? []));
@@ -435,6 +480,16 @@ const ProductGoodsPage: React.FC = () => {
<Button key="matrix" icon={<TableOutlined />} onClick={openMatrix}>
</Button>,
<AuthButton key="export" auth="product.goods.export">
<Button icon={<DownloadOutlined />} loading={exportLoading} onClick={handleExport}>
</Button>
</AuthButton>,
<AuthButton key="import" auth="product.goods.import">
<Button icon={<UploadOutlined />} onClick={() => setImportOpen(true)}>
</Button>
</AuthButton>,
dom.keywordSearch,
],
formProps: {
@@ -484,6 +539,63 @@ const ProductGoodsPage: React.FC = () => {
</div>
</div>
<Modal
title="导入商品"
open={importOpen}
onCancel={() => setImportOpen(false)}
onOk={handleImport}
okText="开始导入"
okButtonProps={{ disabled: !importFile }}
confirmLoading={importing}
destroyOnHidden
width={560}
>
<div className="mb-3">
<Text type="secondary">
**/
//
</Text>
<Button type="link" className="px-0" onClick={downloadProductTemplate}>
</Button>
</div>
<Upload.Dragger
accept=".xlsx,.xls"
maxCount={1}
beforeUpload={(file) => {
setImportFile(file);
setImportErrors([]);
return false;
}}
onRemove={() => {
setImportFile(null);
setImportErrors([]);
}}
>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text"> Excel </p>
<p className="ant-upload-hint"> .xlsx / .xls 1000 </p>
</Upload.Dragger>
{importErrors.length > 0 && (
<Alert
className="mt-3"
type="error"
showIcon
message={`${importErrors.length} 处错误,修正后请重新导入`}
description={
<ul className="max-h-48 overflow-y-auto pl-4 mb-0 list-disc">
{importErrors.map((item, index) => (
<li key={index}> {item.row} {item.message}</li>
))}
</ul>
}
/>
)}
</Modal>
<Drawer
title="价格矩阵 · 批量调价"
open={matrixOpen}