Compare commits
3 Commits
52ab195432
...
4fe4e985f6
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fe4e985f6 | |||
| 22237a47e7 | |||
| cd7007bc49 |
File diff suppressed because one or more lines are too long
@@ -0,0 +1,127 @@
|
|||||||
|
<?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->market,
|
||||||
|
$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, 12, 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -126,6 +126,7 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
|
|||||||
'category' => $categoryNames[(int) $productId] ?? '',
|
'category' => $categoryNames[(int) $productId] ?? '',
|
||||||
'product_name' => (string) ($snapshot->product_name ?? $product->name),
|
'product_name' => (string) ($snapshot->product_name ?? $product->name),
|
||||||
'supplier' => $supplierNames[$rowSupplierId] ?? '',
|
'supplier' => $supplierNames[$rowSupplierId] ?? '',
|
||||||
|
'market' => (string) ($product->market ?? ''),
|
||||||
'product_spec' => $spec,
|
'product_spec' => $spec,
|
||||||
'unit' => (string) ($snapshot->unit ?? $product->unit),
|
'unit' => (string) ($snapshot->unit ?? $product->unit),
|
||||||
'cost_price' => (float) ($snapshot->cost_price ?? $product->cost_price),
|
'cost_price' => (float) ($snapshot->cost_price ?? $product->cost_price),
|
||||||
@@ -172,7 +173,7 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
|
|||||||
|
|
||||||
// 列头
|
// 列头
|
||||||
$rows[] = array_merge(
|
$rows[] = array_merge(
|
||||||
['序号', '分类', '品名', '供应商', '包规', '单位', '成本', '参考零售价', '数量', '实际称重', '金额'],
|
['序号', '分类', '品名', '供应商', '市场', '包规', '单位', '成本', '参考零售价', '数量', '实际称重', '金额'],
|
||||||
array_values($this->storeNames),
|
array_values($this->storeNames),
|
||||||
);
|
);
|
||||||
$this->specialRows[++$rowIndex] = 'header';
|
$this->specialRows[++$rowIndex] = 'header';
|
||||||
@@ -189,6 +190,7 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
|
|||||||
$item['category'],
|
$item['category'],
|
||||||
$item['product_name'],
|
$item['product_name'],
|
||||||
$item['supplier'],
|
$item['supplier'],
|
||||||
|
$item['market'],
|
||||||
$item['product_spec'],
|
$item['product_spec'],
|
||||||
$item['unit'],
|
$item['unit'],
|
||||||
$item['cost_price'],
|
$item['cost_price'],
|
||||||
@@ -208,7 +210,7 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
|
|||||||
|
|
||||||
// 合计行(行合计 + 门店列合计)
|
// 合计行(行合计 + 门店列合计)
|
||||||
$rows[] = array_merge(
|
$rows[] = array_merge(
|
||||||
['', '', '合计', '', '', '', '', '', (float) $totalQuantity, (float) $totalWeight, (float) $totalAmount],
|
['', '', '合计', '', '', '', '', '', '', (float) $totalQuantity, (float) $totalWeight, (float) $totalAmount],
|
||||||
array_values($storeTotals),
|
array_values($storeTotals),
|
||||||
);
|
);
|
||||||
$this->specialRows[++$rowIndex] = 'summary';
|
$this->specialRows[++$rowIndex] = 'summary';
|
||||||
@@ -225,7 +227,7 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
|
|||||||
$this->collection();
|
$this->collection();
|
||||||
|
|
||||||
$sheet->freezePane('A' . ($this->headerRow + 1));
|
$sheet->freezePane('A' . ($this->headerRow + 1));
|
||||||
$widths = [6, 10, 20, 12, 12, 8, 10, 12, 10, 12, 12];
|
$widths = [6, 10, 20, 12, 12, 12, 8, 10, 12, 10, 12, 12];
|
||||||
foreach ($widths as $index => $width) {
|
foreach ($widths as $index => $width) {
|
||||||
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
|
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
|
||||||
}
|
}
|
||||||
@@ -233,7 +235,7 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
|
|||||||
// 门店列整列(列头 → 合计行)填充突出颜色
|
// 门店列整列(列头 → 合计行)填充突出颜色
|
||||||
$storeCount = count($this->storeNames);
|
$storeCount = count($this->storeNames);
|
||||||
for ($i = 0; $i < $storeCount; $i++) {
|
for ($i = 0; $i < $storeCount; $i++) {
|
||||||
$column = Coordinate::stringFromColumnIndex(12 + $i);
|
$column = Coordinate::stringFromColumnIndex(13 + $i);
|
||||||
$sheet->getColumnDimension($column)->setWidth(12);
|
$sheet->getColumnDimension($column)->setWidth(12);
|
||||||
$sheet->getStyle($column . $this->headerRow . ':' . $column . $this->lastRow)
|
$sheet->getStyle($column . $this->headerRow . ':' . $column . $this->lastRow)
|
||||||
->getFill()
|
->getFill()
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ class PurchaseStoreSheet implements FromCollection, WithStrictNullComparison, Wi
|
|||||||
$rowIndex++;
|
$rowIndex++;
|
||||||
|
|
||||||
// 列头
|
// 列头
|
||||||
$rows[] = ['序号', '品名', '包规', '单位', '单价', '数量', '重量(斤)', '预计金额'];
|
$rows[] = ['序号', '品名', '市场', '包规', '单位', '单价', '数量', '重量(斤)', '预计金额'];
|
||||||
$this->specialRows[++$rowIndex] = 'header';
|
$this->specialRows[++$rowIndex] = 'header';
|
||||||
$this->headerRow = $rowIndex;
|
$this->headerRow = $rowIndex;
|
||||||
|
|
||||||
@@ -67,6 +67,7 @@ class PurchaseStoreSheet implements FromCollection, WithStrictNullComparison, Wi
|
|||||||
$rows[] = [
|
$rows[] = [
|
||||||
$sort + 1,
|
$sort + 1,
|
||||||
$item['product_name'],
|
$item['product_name'],
|
||||||
|
$item['market'],
|
||||||
$item['product_spec'],
|
$item['product_spec'],
|
||||||
$item['unit'],
|
$item['unit'],
|
||||||
(float) $item['price'],
|
(float) $item['price'],
|
||||||
@@ -81,7 +82,7 @@ class PurchaseStoreSheet implements FromCollection, WithStrictNullComparison, Wi
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 合计行
|
// 合计行
|
||||||
$rows[] = ['', '合计', '', '', '', $totalQuantity, (float) $totalWeight, (float) $totalAmount];
|
$rows[] = ['', '合计', '', '', '', '', $totalQuantity, (float) $totalWeight, (float) $totalAmount];
|
||||||
$this->specialRows[++$rowIndex] = 'summary';
|
$this->specialRows[++$rowIndex] = 'summary';
|
||||||
|
|
||||||
return $this->rows = collect($rows);
|
return $this->rows = collect($rows);
|
||||||
@@ -100,7 +101,7 @@ class PurchaseStoreSheet implements FromCollection, WithStrictNullComparison, Wi
|
|||||||
$this->collection();
|
$this->collection();
|
||||||
|
|
||||||
$sheet->freezePane('A' . ($this->headerRow + 1));
|
$sheet->freezePane('A' . ($this->headerRow + 1));
|
||||||
$widths = [6, 24, 14, 8, 10, 10, 12, 12];
|
$widths = [6, 24, 12, 14, 8, 10, 10, 12, 12];
|
||||||
foreach ($widths as $index => $width) {
|
foreach ($widths as $index => $width) {
|
||||||
$sheet->getColumnDimension(\PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($index + 1))
|
$sheet->getColumnDimension(\PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($index + 1))
|
||||||
->setWidth($width);
|
->setWidth($width);
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
|
|||||||
$rowIndex++;
|
$rowIndex++;
|
||||||
|
|
||||||
// 列头
|
// 列头
|
||||||
$rows[] = ['序号', '品名', '包规', '单位', '成本价', '数量', '重量(斤)', '金额'];
|
$rows[] = ['序号', '品名', '市场', '包规', '单位', '成本价', '数量', '重量(斤)', '金额'];
|
||||||
$this->specialRows[++$rowIndex] = 'header';
|
$this->specialRows[++$rowIndex] = 'header';
|
||||||
$this->headerRow = $rowIndex;
|
$this->headerRow = $rowIndex;
|
||||||
|
|
||||||
@@ -68,6 +68,7 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
|
|||||||
$rows[] = [
|
$rows[] = [
|
||||||
$sort + 1,
|
$sort + 1,
|
||||||
$item['product_name'],
|
$item['product_name'],
|
||||||
|
$item['market'],
|
||||||
$item['product_spec'],
|
$item['product_spec'],
|
||||||
$item['unit'],
|
$item['unit'],
|
||||||
(float) $item['cost_price'],
|
(float) $item['cost_price'],
|
||||||
@@ -82,7 +83,7 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 合计行
|
// 合计行
|
||||||
$rows[] = ['', '合计', '', '', '', $totalQuantity, (float) $totalWeight, (float) $totalAmount];
|
$rows[] = ['', '合计', '', '', '', '', $totalQuantity, (float) $totalWeight, (float) $totalAmount];
|
||||||
$this->specialRows[++$rowIndex] = 'summary';
|
$this->specialRows[++$rowIndex] = 'summary';
|
||||||
|
|
||||||
return $this->rows = collect($rows);
|
return $this->rows = collect($rows);
|
||||||
@@ -101,7 +102,7 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
|
|||||||
$this->collection();
|
$this->collection();
|
||||||
|
|
||||||
$sheet->freezePane('A' . ($this->headerRow + 1));
|
$sheet->freezePane('A' . ($this->headerRow + 1));
|
||||||
$widths = [6, 24, 14, 8, 10, 10, 12, 12];
|
$widths = [6, 24, 12, 14, 8, 10, 10, 12, 12];
|
||||||
foreach ($widths as $index => $width) {
|
foreach ($widths as $index => $width) {
|
||||||
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
|
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
namespace App\Http\Controllers\Product;
|
namespace App\Http\Controllers\Product;
|
||||||
|
|
||||||
use App\Exceptions\RepositoryException;
|
use App\Exceptions\RepositoryException;
|
||||||
|
use App\Exports\ProductExport;
|
||||||
use App\Http\Requests\Product\BatchPriceRequest;
|
use App\Http\Requests\Product\BatchPriceRequest;
|
||||||
use App\Http\Requests\Product\ProductFormRequest;
|
use App\Http\Requests\Product\ProductFormRequest;
|
||||||
|
use App\Imports\ProductImport;
|
||||||
use App\Models\CustomerLevelModel;
|
use App\Models\CustomerLevelModel;
|
||||||
use App\Models\NoticeModel;
|
use App\Models\NoticeModel;
|
||||||
use App\Models\ProductModel;
|
use App\Models\ProductModel;
|
||||||
@@ -13,6 +15,7 @@ use Illuminate\Http\JsonResponse;
|
|||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Maatwebsite\Excel\Facades\Excel;
|
||||||
use Modules\AnnoRoute\Attribute\DeleteRoute;
|
use Modules\AnnoRoute\Attribute\DeleteRoute;
|
||||||
use Modules\AnnoRoute\Attribute\GetRoute;
|
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||||
use Modules\AnnoRoute\Attribute\PostRoute;
|
use Modules\AnnoRoute\Attribute\PostRoute;
|
||||||
@@ -20,6 +23,7 @@ use Modules\AnnoRoute\Attribute\PutRoute;
|
|||||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||||
use Modules\Common\Http\Controllers\BaseController;
|
use Modules\Common\Http\Controllers\BaseController;
|
||||||
use Modules\SystemTool\Services\SysFileService;
|
use Modules\SystemTool\Services\SysFileService;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 商品档案管理(售价 = 成本价 × (100 + 客户等级上浮比例) / 100,不再维护等级价格行)
|
* 商品档案管理(售价 = 成本价 × (100 + 客户等级上浮比例) / 100,不再维护等级价格行)
|
||||||
@@ -31,6 +35,7 @@ class ProductController extends BaseController
|
|||||||
'name' => 'like',
|
'name' => 'like',
|
||||||
'category_id' => '=',
|
'category_id' => '=',
|
||||||
'supplier_id' => '=',
|
'supplier_id' => '=',
|
||||||
|
'market' => 'like',
|
||||||
'status' => '=',
|
'status' => '=',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -90,6 +95,53 @@ class ProductController extends BaseController
|
|||||||
return $this->success(['id' => $product->id]);
|
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]+'])]
|
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
|
||||||
public function update(int $id, ProductFormRequest $request): JsonResponse
|
public function update(int $id, ProductFormRequest $request): JsonResponse
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ class PurchaseOrderController extends BaseController
|
|||||||
'unit' => $first->unit, // 单位
|
'unit' => $first->unit, // 单位
|
||||||
'supplier_id' => (int) $first->supplier_id, // 供应商id
|
'supplier_id' => (int) $first->supplier_id, // 供应商id
|
||||||
'supplier' => $supplier, // 供应商
|
'supplier' => $supplier, // 供应商
|
||||||
|
'market' => (string) ($product?->market ?? ''), // 市场(取商品档案)
|
||||||
'cost_price' => $first->cost_price, // 成本价
|
'cost_price' => $first->cost_price, // 成本价
|
||||||
'quantity' => $quantity,
|
'quantity' => $quantity,
|
||||||
'weight' => (float) $weight,
|
'weight' => (float) $weight,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class ProductFormRequest extends BaseFormRequest
|
|||||||
return [
|
return [
|
||||||
'category_id' => ['required','integer', new Exists(ProductCategoryModel::class,'id')],
|
'category_id' => ['required','integer', new Exists(ProductCategoryModel::class,'id')],
|
||||||
'supplier_id' => ['nullable','integer', 'exclude_if:supplier_id,0', new Exists(SupplierModel::class,'id')],
|
'supplier_id' => ['nullable','integer', 'exclude_if:supplier_id,0', new Exists(SupplierModel::class,'id')],
|
||||||
|
'market' => 'nullable|string|max:50',
|
||||||
'name' => 'required|string|max:100',
|
'name' => 'required|string|max:100',
|
||||||
'spec' => 'nullable|string|max:100',
|
'spec' => 'nullable|string|max:100',
|
||||||
'unit' => 'nullable|string|max:20',
|
'unit' => 'nullable|string|max:20',
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
<?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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ class ProductModel extends Model
|
|||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'category_id',
|
'category_id',
|
||||||
'supplier_id',
|
'supplier_id',
|
||||||
|
'market',
|
||||||
'name',
|
'name',
|
||||||
'spec',
|
'spec',
|
||||||
'unit',
|
'unit',
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ class PurchaseItemService
|
|||||||
'product_name' => $first->product_name,
|
'product_name' => $first->product_name,
|
||||||
'product_spec' => $first->product_spec,
|
'product_spec' => $first->product_spec,
|
||||||
'unit' => $first->unit,
|
'unit' => $first->unit,
|
||||||
|
'market' => (string) data_get($first, 'market', ''),
|
||||||
// 加权平均单价(保证 单价×数量=预计金额)
|
// 加权平均单价(保证 单价×数量=预计金额)
|
||||||
'price' => $quantity > 0
|
'price' => $quantity > 0
|
||||||
? bcdiv($amount, (string) $quantity, 2)
|
? bcdiv($amount, (string) $quantity, 2)
|
||||||
@@ -124,6 +125,7 @@ class PurchaseItemService
|
|||||||
'product_name' => $first->product_name,
|
'product_name' => $first->product_name,
|
||||||
'product_spec' => $first->product_spec,
|
'product_spec' => $first->product_spec,
|
||||||
'unit' => $first->unit,
|
'unit' => $first->unit,
|
||||||
|
'market' => (string) data_get($first, 'market', ''),
|
||||||
'cost_price' => (string) $first->cost_price,
|
'cost_price' => (string) $first->cost_price,
|
||||||
'quantity' => $quantity,
|
'quantity' => $quantity,
|
||||||
'weight' => $weight,
|
'weight' => $weight,
|
||||||
@@ -151,17 +153,18 @@ class PurchaseItemService
|
|||||||
->get()
|
->get()
|
||||||
->makeVisible('cost_price');
|
->makeVisible('cost_price');
|
||||||
|
|
||||||
// 排序键:商品分类 sort → 商品 sort(商品含软删除,保证历史单据可导出)
|
// 排序键:商品分类 sort → 商品 sort;市场取商品档案(商品含软删除,保证历史单据可导出)
|
||||||
$products = ProductModel::withTrashed()
|
$products = ProductModel::withTrashed()
|
||||||
->with('category:id,sort')
|
->with('category:id,sort')
|
||||||
->whereIn('id', $items->pluck('product_id')->unique())
|
->whereIn('id', $items->pluck('product_id')->unique())
|
||||||
->get(['id', 'category_id', 'sort'])
|
->get(['id', 'category_id', 'sort', 'market'])
|
||||||
->keyBy('id');
|
->keyBy('id');
|
||||||
|
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
$product = $products->get((int) $item->product_id);
|
$product = $products->get((int) $item->product_id);
|
||||||
$item->setAttribute('category_sort', (int) ($product?->category?->sort ?? 9999));
|
$item->setAttribute('category_sort', (int) ($product?->category?->sort ?? 9999));
|
||||||
$item->setAttribute('product_sort', (int) ($product?->sort ?? 9999));
|
$item->setAttribute('product_sort', (int) ($product?->sort ?? 9999));
|
||||||
|
$item->setAttribute('market', (string) ($product?->market ?? ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
return $items;
|
return $items;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class ProductModelFactory extends Factory
|
|||||||
return [
|
return [
|
||||||
'category_id' => 0,
|
'category_id' => 0,
|
||||||
'supplier_id' => 0,
|
'supplier_id' => 0,
|
||||||
|
'market' => '',
|
||||||
'name' => self::NAMES[$seq % count(self::NAMES)] . $seq,
|
'name' => self::NAMES[$seq % count(self::NAMES)] . $seq,
|
||||||
'spec' => self::SPECS[$seq % count(self::SPECS)],
|
'spec' => self::SPECS[$seq % count(self::SPECS)],
|
||||||
'unit' => self::UNITS[$seq % count(self::UNITS)],
|
'unit' => self::UNITS[$seq % count(self::UNITS)],
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ return new class extends Migration
|
|||||||
$table->increments('id')->comment('商品ID');
|
$table->increments('id')->comment('商品ID');
|
||||||
$table->integer('category_id')->default(0)->comment('商品分类ID');
|
$table->integer('category_id')->default(0)->comment('商品分类ID');
|
||||||
$table->integer('supplier_id')->default(0)->comment('默认供应商ID');
|
$table->integer('supplier_id')->default(0)->comment('默认供应商ID');
|
||||||
|
$table->string('market', 50)->default('')->comment('市场(如:新发地、岳各庄)');
|
||||||
$table->string('name', 100)->comment('品名');
|
$table->string('name', 100)->comment('品名');
|
||||||
$table->string('spec', 100)->default('')->comment('规格/包规');
|
$table->string('spec', 100)->default('')->comment('规格/包规');
|
||||||
$table->string('unit', 20)->default('斤')->comment('计价单位(斤/件/箱等)');
|
$table->string('unit', 20)->default('斤')->comment('计价单位(斤/件/箱等)');
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ class PermissionSeeder extends Seeder
|
|||||||
['type' => 'rule', 'key' => 'product.goods.update', 'name' => '编辑'],
|
['type' => 'rule', 'key' => 'product.goods.update', 'name' => '编辑'],
|
||||||
['type' => 'rule', 'key' => 'product.goods.delete', 'name' => '删除'],
|
['type' => 'rule', 'key' => 'product.goods.delete', 'name' => '删除'],
|
||||||
['type' => 'rule', 'key' => 'product.goods.batchPrice', 'name' => '批量调价'],
|
['type' => 'rule', 'key' => 'product.goods.batchPrice', 'name' => '批量调价'],
|
||||||
|
['type' => 'rule', 'key' => 'product.goods.import', 'name' => '导入'],
|
||||||
|
['type' => 'rule', 'key' => 'product.goods.export', 'name' => '导出'],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -108,12 +108,14 @@ class ExportTest extends ProcurementTestCase
|
|||||||
'cost_price' => '5.00',
|
'cost_price' => '5.00',
|
||||||
'spec' => '10斤/箱',
|
'spec' => '10斤/箱',
|
||||||
'supplier_id' => $supplierA->id,
|
'supplier_id' => $supplierA->id,
|
||||||
|
'market' => '新发地',
|
||||||
]);
|
]);
|
||||||
$meat = ProductModel::factory()->create([
|
$meat = ProductModel::factory()->create([
|
||||||
'status' => ProductModel::STATUS_ON,
|
'status' => ProductModel::STATUS_ON,
|
||||||
'cost_price' => '20.00',
|
'cost_price' => '20.00',
|
||||||
'spec' => '20斤/箱',
|
'spec' => '20斤/箱',
|
||||||
'supplier_id' => $supplierB->id,
|
'supplier_id' => $supplierB->id,
|
||||||
|
'market' => '岳各庄',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->actingAsMiniStore($storeA);
|
$this->actingAsMiniStore($storeA);
|
||||||
@@ -154,24 +156,25 @@ class ExportTest extends ProcurementTestCase
|
|||||||
if ($rows->count() !== 8) {
|
if ($rows->count() !== 8) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// 列头含参考零售价与门店列
|
// 列头含市场/参考零售价与门店列(市场列在供应商后,门店列从索引 12 起)
|
||||||
$header = $rows[3];
|
$header = $rows[3];
|
||||||
if ($header[7] !== '参考零售价' || $header[11] !== $storeA->name || $header[12] !== $storeB->name) {
|
if ($header[4] !== '市场' || $header[8] !== '参考零售价' || $header[12] !== $storeA->name || $header[13] !== $storeB->name) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// 蔬菜行:数量 2+3=5、金额 25、参考零售价 5÷10=0.50、门店列 2/3
|
// 蔬菜行:市场 新发地、数量 2+3=5、金额 25、参考零售价 5÷10=0.50、门店列 2/3
|
||||||
$vegRow = $rows->firstWhere(2, $veg->name);
|
$vegRow = $rows->firstWhere(2, $veg->name);
|
||||||
if ($vegRow === null
|
if ($vegRow === null
|
||||||
|| (float) $vegRow[8] !== 5.0 || (float) $vegRow[10] !== 25.0
|
|| $vegRow[4] !== '新发地'
|
||||||
|| (float) $vegRow[7] !== 0.5
|
|| (float) $vegRow[9] !== 5.0 || (float) $vegRow[11] !== 25.0
|
||||||
|| (float) $vegRow[11] !== 2.0 || (float) $vegRow[12] !== 3.0) {
|
|| (float) $vegRow[8] !== 0.5
|
||||||
|
|| (float) $vegRow[12] !== 2.0 || (float) $vegRow[13] !== 3.0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// 无订货商品行:数量 0、参考零售价留空、门店列 0
|
// 无订货商品行:数量 0、参考零售价留空、门店列 0
|
||||||
$extraRow = $rows->firstWhere(2, $extra->name);
|
$extraRow = $rows->firstWhere(2, $extra->name);
|
||||||
if ($extraRow === null
|
if ($extraRow === null
|
||||||
|| (float) $extraRow[8] !== 0.0 || $extraRow[7] !== ''
|
|| (float) $extraRow[9] !== 0.0 || $extraRow[8] !== ''
|
||||||
|| (float) $extraRow[11] !== 0.0) {
|
|| (float) $extraRow[12] !== 0.0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// 合计行:总数量 6、总金额 45、门店列合计 3/3
|
// 合计行:总数量 6、总金额 45、门店列合计 3/3
|
||||||
@@ -179,8 +182,8 @@ class ExportTest extends ProcurementTestCase
|
|||||||
if ($total[2] !== '合计') {
|
if ($total[2] !== '合计') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return (float) $total[8] === 6.0 && (float) $total[10] === 45.0
|
return (float) $total[9] === 6.0 && (float) $total[11] === 45.0
|
||||||
&& (float) $total[11] === 3.0 && (float) $total[12] === 3.0;
|
&& (float) $total[12] === 3.0 && (float) $total[13] === 3.0;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -234,12 +237,12 @@ class ExportTest extends ProcurementTestCase
|
|||||||
if ($rows->count() !== 6) {
|
if ($rows->count() !== 6) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if ($rows[2] !== ['序号', '品名', '包规', '单位', '单价', '数量', '重量(斤)', '预计金额']) {
|
if ($rows[2] !== ['序号', '品名', '市场', '包规', '单位', '单价', '数量', '重量(斤)', '预计金额']) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// 合计:数量 2+1=3,金额 10+20=30
|
// 合计:数量 2+1=3,金额 10+20=30
|
||||||
$total = $rows->last();
|
$total = $rows->last();
|
||||||
return $total[1] === '合计' && (int) $total[5] === 3 && (float) $total[7] === 30.0;
|
return $total[1] === '合计' && (int) $total[6] === 3 && (float) $total[8] === 30.0;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -294,23 +297,23 @@ class ExportTest extends ProcurementTestCase
|
|||||||
if ($titles !== [$supplierA->name, $supplierB->name]) {
|
if ($titles !== [$supplierA->name, $supplierB->name]) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// 供应商甲:蔬菜 2+3=5 件、金额 5×5=25
|
// 供应商甲:蔬菜 2+3=5 件、金额 5×5=25(市场列在品名后)
|
||||||
$rowsA = $sheets[0]->collection()->values();
|
$rowsA = $sheets[0]->collection()->values();
|
||||||
if ($rowsA[2] !== ['序号', '品名', '包规', '单位', '成本价', '数量', '重量(斤)', '金额']) {
|
if ($rowsA[2] !== ['序号', '品名', '市场', '包规', '单位', '成本价', '数量', '重量(斤)', '金额']) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
$vegRow = $rowsA->firstWhere(1, $veg->name);
|
$vegRow = $rowsA->firstWhere(1, $veg->name);
|
||||||
if ($vegRow === null || (int) $vegRow[5] !== 5 || (float) $vegRow[7] !== 25.0) {
|
if ($vegRow === null || $vegRow[2] !== '新发地' || (int) $vegRow[6] !== 5 || (float) $vegRow[8] !== 25.0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
$totalA = $rowsA->last();
|
$totalA = $rowsA->last();
|
||||||
if ($totalA[1] !== '合计' || (int) $totalA[5] !== 5 || (float) $totalA[7] !== 25.0) {
|
if ($totalA[1] !== '合计' || (int) $totalA[6] !== 5 || (float) $totalA[8] !== 25.0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// 供应商乙:肉 1 件、金额 20
|
// 供应商乙:肉 1 件、金额 20
|
||||||
$rowsB = $sheets[1]->collection()->values();
|
$rowsB = $sheets[1]->collection()->values();
|
||||||
$meatRow = $rowsB->firstWhere(1, $meat->name);
|
$meatRow = $rowsB->firstWhere(1, $meat->name);
|
||||||
return $meatRow !== null && (int) $meatRow[5] === 1 && (float) $meatRow[7] === 20.0;
|
return $meatRow !== null && $meatRow[2] === '岳各庄' && (int) $meatRow[6] === 1 && (float) $meatRow[8] === 20.0;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
<?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,
|
||||||
|
'market' => '新发地',
|
||||||
|
'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[3] !== '新发地' || $row[10] !== '上架') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ((float) $row[6] !== 2.5 || (int) $row[7] !== 3 || (int) $row[8] !== 100 || (int) $row[9] !== 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($row[11] !== '新鲜直达') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$offRow = $rows->firstWhere(0, '下架商品');
|
||||||
|
return $offRow !== null && $offRow[10] === '下架' && $offRow[2] === '' && $offRow[3] === '';
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按末级分类过滤导出 */
|
||||||
|
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_list_filters_by_market(): void
|
||||||
|
{
|
||||||
|
ProductModel::factory()->create(['name' => '大白菜X', 'market' => '新发地']);
|
||||||
|
ProductModel::factory()->create(['name' => '土豆X', 'market' => '岳各庄']);
|
||||||
|
ProductModel::factory()->create(['name' => '无市场商品X', 'market' => '']);
|
||||||
|
|
||||||
|
$this->actingAsSysUser();
|
||||||
|
$response = $this->getJson('/product/goods?market=新发');
|
||||||
|
$response->assertJsonPath('success', true);
|
||||||
|
$names = collect($response->json('data.data'))->pluck('name');
|
||||||
|
$this->assertContains('大白菜X', $names);
|
||||||
|
$this->assertNotContains('土豆X', $names);
|
||||||
|
$this->assertNotContains('无市场商品X', $names);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 导入 ====================
|
||||||
|
|
||||||
|
/** 正常导入:新增商品 + 供应商自动创建 + 默认值(单位斤/状态上架) */
|
||||||
|
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->market);
|
||||||
|
$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('', $productB->market, '市场留空应为空字符串');
|
||||||
|
$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'));
|
||||||
|
$this->assertSame('岳各庄', ProductModel::where('name', '菠菜')->value('market'));
|
||||||
|
|
||||||
|
// 单名称歧义 + 非末级分类 → 报错零写入
|
||||||
|
$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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import createAxios from '@/utils/request';
|
import createAxios from '@/utils/request';
|
||||||
|
import { downloadBlob } from '@/api/common/download.ts';
|
||||||
import type IProduct from '@/domain/iProduct.ts';
|
import type IProduct from '@/domain/iProduct.ts';
|
||||||
import type { IBatchPriceUpdate, IPriceMatrix } from '@/domain/iProduct.ts';
|
import type { IBatchPriceUpdate, IPriceMatrix } from '@/domain/iProduct.ts';
|
||||||
|
|
||||||
@@ -9,6 +10,47 @@ export interface PriceMatrixParams {
|
|||||||
pageSize?: number;
|
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);
|
* A2 价格矩阵:行=商品,列=启用等级,值=按等级上浮比例换算的售价(成本价未设置 null);
|
||||||
* 仅成本价可编辑
|
* 仅成本价可编辑
|
||||||
@@ -31,12 +73,3 @@ export async function batchPrice(updates: IBatchPriceUpdate[]) {
|
|||||||
data: { updates },
|
data: { updates },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 商品下拉选项(仅上架) */
|
|
||||||
export async function getProductOptions(keyword?: string) {
|
|
||||||
return createAxios<IProduct[]>({
|
|
||||||
url: '/product/goods/options',
|
|
||||||
method: 'get',
|
|
||||||
params: keyword ? { keyword } : {},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -548,13 +548,14 @@ export default function XinTable<T extends Record<string, any> = any>(props: Xin
|
|||||||
<Space>{ ...actionBarRender() }</Space>
|
<Space>{ ...actionBarRender() }</Space>
|
||||||
<Space size={1}>{...toolBarRender()}</Space>
|
<Space size={1}>{...toolBarRender()}</Space>
|
||||||
</Flex>
|
</Flex>
|
||||||
{/* 表格 */}
|
{/* 表格(默认横向滚动:窄屏/平板下表格内部滚动而非撑宽整页) */}
|
||||||
<Table
|
<Table
|
||||||
loading={loading}
|
loading={loading}
|
||||||
dataSource={dataSource}
|
dataSource={dataSource}
|
||||||
size={density}
|
size={density}
|
||||||
bordered={bordered}
|
bordered={bordered}
|
||||||
{...props}
|
{...props}
|
||||||
|
scroll={props.scroll ?? { x: 'max-content' }}
|
||||||
columns={tableColumns}
|
columns={tableColumns}
|
||||||
rowKey={rowKey}
|
rowKey={rowKey}
|
||||||
onChange={handleTableChange}
|
onChange={handleTableChange}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export default interface IProduct {
|
|||||||
category_id?: number;
|
category_id?: number;
|
||||||
/** 供应商ID */
|
/** 供应商ID */
|
||||||
supplier_id?: number;
|
supplier_id?: number;
|
||||||
|
/** 市场(如:新发地、岳各庄) */
|
||||||
|
market?: string;
|
||||||
/** 商品名称 */
|
/** 商品名称 */
|
||||||
name?: string;
|
name?: string;
|
||||||
/** 规格/包规 */
|
/** 规格/包规 */
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export interface IPurchaseDetailRow {
|
|||||||
unit: string;
|
unit: string;
|
||||||
supplier_id: number;
|
supplier_id: number;
|
||||||
supplier?: { id: number; name: string } | null;
|
supplier?: { id: number; name: string } | null;
|
||||||
|
/** 市场(取商品档案) */
|
||||||
|
market?: string;
|
||||||
/** 成本 */
|
/** 成本 */
|
||||||
cost_price: number;
|
cost_price: number;
|
||||||
/** 合计数量 */
|
/** 合计数量 */
|
||||||
@@ -35,6 +37,8 @@ export interface IPurchaseSupplierItem {
|
|||||||
/** 规格/包规 */
|
/** 规格/包规 */
|
||||||
product_spec: string;
|
product_spec: string;
|
||||||
unit: string;
|
unit: string;
|
||||||
|
/** 市场(取商品档案) */
|
||||||
|
market?: string;
|
||||||
/** 成本价 */
|
/** 成本价 */
|
||||||
cost_price: string;
|
cost_price: string;
|
||||||
/** 数量(包数) */
|
/** 数量(包数) */
|
||||||
@@ -193,6 +197,8 @@ export interface IPurchaseStoreItem {
|
|||||||
/** 规格/包规 */
|
/** 规格/包规 */
|
||||||
product_spec: string;
|
product_spec: string;
|
||||||
unit: string;
|
unit: string;
|
||||||
|
/** 市场(取商品档案) */
|
||||||
|
market?: string;
|
||||||
/** 单价(每包;多笔单价时为加权平均,保证 单价×数量=预计金额) */
|
/** 单价(每包;多笔单价时为加权平均,保证 单价×数量=预计金额) */
|
||||||
price: string;
|
price: string;
|
||||||
/** 数量(包数) */
|
/** 数量(包数) */
|
||||||
|
|||||||
@@ -64,7 +64,9 @@ const LayoutContent: React.FC = () => {
|
|||||||
{ layout === "columns" ? <ColumnsMenu/> : <MenuRender />}
|
{ layout === "columns" ? <ColumnsMenu/> : <MenuRender />}
|
||||||
</Sider>
|
</Sider>
|
||||||
)}
|
)}
|
||||||
<Layout>
|
{/* min-w-0:flex 子项默认 min-width:auto 会被宽表格撑破,
|
||||||
|
归零后内容区宽度收敛到视口,表格自身的横向滚动才生效 */}
|
||||||
|
<Layout className="min-w-0">
|
||||||
<Content style={{padding: themeConfig.bodyPadding}}>
|
<Content style={{padding: themeConfig.bodyPadding}}>
|
||||||
<Outlet/>
|
<Outlet/>
|
||||||
</Content>
|
</Content>
|
||||||
|
|||||||
@@ -429,6 +429,7 @@ const Index: React.FC = () => {
|
|||||||
rowKey={"product_id"}
|
rowKey={"product_id"}
|
||||||
dataSource={dashboard.top_products}
|
dataSource={dashboard.top_products}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
title: t("dashboard.analysis.rank"),
|
title: t("dashboard.analysis.rank"),
|
||||||
@@ -472,6 +473,7 @@ const Index: React.FC = () => {
|
|||||||
rowKey={"store_id"}
|
rowKey={"store_id"}
|
||||||
dataSource={dashboard.top_stores}
|
dataSource={dashboard.top_stores}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
title: t("dashboard.analysis.rank"),
|
title: t("dashboard.analysis.rank"),
|
||||||
@@ -558,6 +560,7 @@ const Index: React.FC = () => {
|
|||||||
rowKey={"id"}
|
rowKey={"id"}
|
||||||
dataSource={dashboard.latest_orders}
|
dataSource={dashboard.latest_orders}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
title: t("dashboard.analysis.orderNo"),
|
title: t("dashboard.analysis.orderNo"),
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ const Layout = () => {
|
|||||||
columns={productColumns}
|
columns={productColumns}
|
||||||
dataSource={productData}
|
dataSource={productData}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
summary={() => (
|
summary={() => (
|
||||||
<Table.Summary>
|
<Table.Summary>
|
||||||
<Table.Summary.Row>
|
<Table.Summary.Row>
|
||||||
|
|||||||
+125
-1
@@ -1,20 +1,24 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Button, Card,
|
Button, Card,
|
||||||
Drawer,
|
Drawer,
|
||||||
Image,
|
Image,
|
||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
message,
|
message,
|
||||||
|
Modal,
|
||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
Tag, Tree,
|
Tag, Tree,
|
||||||
TreeSelect,
|
TreeSelect,
|
||||||
Typography,
|
Typography,
|
||||||
|
Upload,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { TableOutlined } from '@ant-design/icons';
|
import { DownloadOutlined, InboxOutlined, TableOutlined, UploadOutlined } from '@ant-design/icons';
|
||||||
import type { TableProps } from 'antd';
|
import type { TableProps } from 'antd';
|
||||||
import XinTable from '@/components/XinTable';
|
import XinTable from '@/components/XinTable';
|
||||||
|
import AuthButton from '@/components/AuthButton';
|
||||||
import type { XinTableColumn, XinTableInstance, XinTableProps } from '@/components/XinTable/typings.ts';
|
import type { XinTableColumn, XinTableInstance, XinTableProps } from '@/components/XinTable/typings.ts';
|
||||||
import type IProduct from '@/domain/iProduct.ts';
|
import type IProduct from '@/domain/iProduct.ts';
|
||||||
import type { IBatchPriceUpdate, IPriceMatrixRow } 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 type ISupplier from '@/domain/iSupplier.ts';
|
||||||
import { getCategoryTree } from '@/api/product/category.ts';
|
import { getCategoryTree } from '@/api/product/category.ts';
|
||||||
import { batchPrice, getPriceMatrix } from '@/api/product/goods.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;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
@@ -80,6 +86,45 @@ const ProductGoodsPage: React.FC = () => {
|
|||||||
/** 跨页未保存的成本价:productId → cost(null 视为未修改,不提交) */
|
/** 跨页未保存的成本价:productId → cost(null 视为未修改,不提交) */
|
||||||
const costDirtyRef = useRef<Record<number, number | null>>({});
|
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(() => {
|
useEffect(() => {
|
||||||
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
|
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
|
||||||
getCategoryTree().then((res) => setCategoryTree(res.data.data ?? []));
|
getCategoryTree().then((res) => setCategoryTree(res.data.data ?? []));
|
||||||
@@ -356,6 +401,18 @@ const ProductGoodsPage: React.FC = () => {
|
|||||||
},
|
},
|
||||||
render: (_, record) => record.supplier?.name ?? '-',
|
render: (_, record) => record.supplier?.name ?? '-',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '市场',
|
||||||
|
dataIndex: 'market',
|
||||||
|
valueType: 'text',
|
||||||
|
align: 'center',
|
||||||
|
fieldProps: {
|
||||||
|
placeholder: '市场名称,如:新发地',
|
||||||
|
maxLength: 50,
|
||||||
|
allowClear: true,
|
||||||
|
},
|
||||||
|
render: (_, record) => record.market || '-',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
@@ -435,6 +492,16 @@ const ProductGoodsPage: React.FC = () => {
|
|||||||
<Button key="matrix" icon={<TableOutlined />} onClick={openMatrix}>
|
<Button key="matrix" icon={<TableOutlined />} onClick={openMatrix}>
|
||||||
价格矩阵
|
价格矩阵
|
||||||
</Button>,
|
</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,
|
dom.keywordSearch,
|
||||||
],
|
],
|
||||||
formProps: {
|
formProps: {
|
||||||
@@ -484,6 +551,63 @@ const ProductGoodsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<Drawer
|
||||||
title="价格矩阵 · 批量调价"
|
title="价格矩阵 · 批量调价"
|
||||||
open={matrixOpen}
|
open={matrixOpen}
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
product_name: row.product_name,
|
product_name: row.product_name,
|
||||||
product_spec: row.product_spec,
|
product_spec: row.product_spec,
|
||||||
unit: row.unit,
|
unit: row.unit,
|
||||||
|
market: row.market,
|
||||||
cost_price: String(row.cost_price),
|
cost_price: String(row.cost_price),
|
||||||
quantity: row.quantity,
|
quantity: row.quantity,
|
||||||
weight: String(row.weight),
|
weight: String(row.weight),
|
||||||
@@ -474,6 +475,7 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
align: 'center',
|
align: 'center',
|
||||||
render: (_, row) => row.supplier?.name ?? '-',
|
render: (_, row) => row.supplier?.name ?? '-',
|
||||||
},
|
},
|
||||||
|
{ title: '市场', dataIndex: 'market', width: 90, align: 'center', render: (v) => v || '-' },
|
||||||
{
|
{
|
||||||
title: '参考零售价',
|
title: '参考零售价',
|
||||||
key: 'retail_price',
|
key: 'retail_price',
|
||||||
@@ -558,27 +560,28 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Table.Summary.Row>
|
<Table.Summary.Row>
|
||||||
<Table.Summary.Cell index={0} colSpan={6} align="center">
|
<Table.Summary.Cell index={0} colSpan={7} align="center">
|
||||||
<Text strong>成本统计(按门店)</Text>
|
<Text strong>成本统计(按门店)</Text>
|
||||||
</Table.Summary.Cell>
|
</Table.Summary.Cell>
|
||||||
{storeTotals.map((amount, index) => (
|
{storeTotals.map((amount, index) => (
|
||||||
<Table.Summary.Cell key={stores[index].id} index={6 + index} align="center">
|
<Table.Summary.Cell key={stores[index].id} index={7 + index} align="center">
|
||||||
<Text strong>¥{amount.toFixed(2)}</Text>
|
<Text strong>¥{amount.toFixed(2)}</Text>
|
||||||
</Table.Summary.Cell>
|
</Table.Summary.Cell>
|
||||||
))}
|
))}
|
||||||
<Table.Summary.Cell index={6 + stores.length} align="center">
|
<Table.Summary.Cell index={7 + stores.length} align="center">
|
||||||
<Text strong>{totalQuantity}</Text>
|
<Text strong>{totalQuantity}</Text>
|
||||||
</Table.Summary.Cell>
|
</Table.Summary.Cell>
|
||||||
<Table.Summary.Cell index={7 + stores.length} align="center">
|
<Table.Summary.Cell index={8 + stores.length} align="center">
|
||||||
<Text strong>¥{totalAmount.toFixed(2)}</Text>
|
<Text strong>¥{totalAmount.toFixed(2)}</Text>
|
||||||
</Table.Summary.Cell>
|
</Table.Summary.Cell>
|
||||||
</Table.Summary.Row>
|
</Table.Summary.Row>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 门店购买详情列:商品/购买参考零售价(单价÷包规)/包规/单位/单价/数量/预计金额 + 操作 */
|
/** 门店购买详情列:商品/市场/购买参考零售价(单价÷包规)/包规/单位/单价/数量/预计金额 + 操作 */
|
||||||
const storeColumns: TableProps<IPurchaseStoreItem>['columns'] = [
|
const storeColumns: TableProps<IPurchaseStoreItem>['columns'] = [
|
||||||
{ title: '商品', dataIndex: 'product_name', width: 160, align: 'center' },
|
{ title: '商品', dataIndex: 'product_name', width: 160, align: 'center' },
|
||||||
|
{ title: '市场', dataIndex: 'market', width: 90, align: 'center', render: (v) => v || '-' },
|
||||||
{
|
{
|
||||||
title: '参考零售价',
|
title: '参考零售价',
|
||||||
key: 'retail_price',
|
key: 'retail_price',
|
||||||
@@ -654,28 +657,29 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
const totalWeight = items.reduce((sum, row) => sum + Number(row.weight), 0);
|
const totalWeight = items.reduce((sum, row) => sum + Number(row.weight), 0);
|
||||||
return (
|
return (
|
||||||
<Table.Summary.Row>
|
<Table.Summary.Row>
|
||||||
<Table.Summary.Cell index={0} colSpan={5} align="center">
|
<Table.Summary.Cell index={0} colSpan={6} align="center">
|
||||||
<Text strong>合计</Text>
|
<Text strong>合计</Text>
|
||||||
</Table.Summary.Cell>
|
</Table.Summary.Cell>
|
||||||
<Table.Summary.Cell index={5} align="center">
|
<Table.Summary.Cell index={6} align="center">
|
||||||
<Text strong>{totalQuantity}</Text>
|
<Text strong>{totalQuantity}</Text>
|
||||||
</Table.Summary.Cell>
|
</Table.Summary.Cell>
|
||||||
<Table.Summary.Cell index={6} align="center">
|
<Table.Summary.Cell index={7} align="center">
|
||||||
<Text strong>{totalWeight.toFixed(3)}斤</Text>
|
<Text strong>{totalWeight.toFixed(3)}斤</Text>
|
||||||
</Table.Summary.Cell>
|
</Table.Summary.Cell>
|
||||||
<Table.Summary.Cell index={7} align="center">
|
<Table.Summary.Cell index={8} align="center">
|
||||||
<Text strong type="danger">¥{totalAmount.toFixed(2)}</Text>
|
<Text strong type="danger">¥{totalAmount.toFixed(2)}</Text>
|
||||||
</Table.Summary.Cell>
|
</Table.Summary.Cell>
|
||||||
<Table.Summary.Cell index={8} align="center">
|
<Table.Summary.Cell index={9} align="center">
|
||||||
<Text strong>-</Text>
|
<Text strong>-</Text>
|
||||||
</Table.Summary.Cell>
|
</Table.Summary.Cell>
|
||||||
</Table.Summary.Row>
|
</Table.Summary.Row>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 供应商采购明细列:品名/包规/单位/成本价/数量/重量/金额(成本口径) */
|
/** 供应商采购明细列:品名/市场/包规/单位/成本价/数量/重量/金额(成本口径) */
|
||||||
const supplierColumns: TableProps<IPurchaseSupplierItem>['columns'] = [
|
const supplierColumns: TableProps<IPurchaseSupplierItem>['columns'] = [
|
||||||
{ title: '品名', dataIndex: 'product_name', width: 180, align: 'center' },
|
{ title: '品名', dataIndex: 'product_name', width: 180, align: 'center' },
|
||||||
|
{ title: '市场', dataIndex: 'market', width: 100, align: 'center', render: (v) => v || '-' },
|
||||||
{ title: '包规', dataIndex: 'product_spec', width: 110, align: 'center', render: (v) => v || '-' },
|
{ title: '包规', dataIndex: 'product_spec', width: 110, align: 'center', render: (v) => v || '-' },
|
||||||
{ title: '单位', dataIndex: 'unit', width: 90, align: 'center', render: (v) => v || '-' },
|
{ title: '单位', dataIndex: 'unit', width: 90, align: 'center', render: (v) => v || '-' },
|
||||||
{
|
{
|
||||||
@@ -709,16 +713,16 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
const totalAmount = supplierItems.reduce((sum, row) => sum + Number(row.amount), 0);
|
const totalAmount = supplierItems.reduce((sum, row) => sum + Number(row.amount), 0);
|
||||||
return (
|
return (
|
||||||
<Table.Summary.Row>
|
<Table.Summary.Row>
|
||||||
<Table.Summary.Cell index={0} colSpan={4} align="center">
|
<Table.Summary.Cell index={0} colSpan={5} align="center">
|
||||||
<Text strong>合计</Text>
|
<Text strong>合计</Text>
|
||||||
</Table.Summary.Cell>
|
</Table.Summary.Cell>
|
||||||
<Table.Summary.Cell index={4} align="center">
|
<Table.Summary.Cell index={5} align="center">
|
||||||
<Text strong>{totalQuantity}</Text>
|
<Text strong>{totalQuantity}</Text>
|
||||||
</Table.Summary.Cell>
|
</Table.Summary.Cell>
|
||||||
<Table.Summary.Cell index={5} align="center">
|
<Table.Summary.Cell index={6} align="center">
|
||||||
<Text strong>{totalWeight.toFixed(3)}斤</Text>
|
<Text strong>{totalWeight.toFixed(3)}斤</Text>
|
||||||
</Table.Summary.Cell>
|
</Table.Summary.Cell>
|
||||||
<Table.Summary.Cell index={6} align="center">
|
<Table.Summary.Cell index={7} align="center">
|
||||||
<Text strong type="danger">¥{totalAmount.toFixed(2)}</Text>
|
<Text strong type="danger">¥{totalAmount.toFixed(2)}</Text>
|
||||||
</Table.Summary.Cell>
|
</Table.Summary.Cell>
|
||||||
</Table.Summary.Row>
|
</Table.Summary.Row>
|
||||||
@@ -1057,6 +1061,7 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
columns={storeColumns}
|
columns={storeColumns}
|
||||||
dataSource={storeSummary.items}
|
dataSource={storeSummary.items}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
summary={renderStoreTotal}
|
summary={renderStoreTotal}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -1107,6 +1112,7 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
columns={supplierColumns}
|
columns={supplierColumns}
|
||||||
dataSource={supplierItems}
|
dataSource={supplierItems}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
summary={renderSupplierTotal}
|
summary={renderSupplierTotal}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -1126,6 +1132,7 @@ const PurchaseOrderPage: React.FC = () => {
|
|||||||
columns={billColumns}
|
columns={billColumns}
|
||||||
dataSource={detail.bills}
|
dataSource={detail.bills}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
summary={renderBillSummary}
|
summary={renderBillSummary}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -418,6 +418,7 @@ const BillPage: React.FC = () => {
|
|||||||
columns={itemColumns}
|
columns={itemColumns}
|
||||||
dataSource={detail.items}
|
dataSource={detail.items}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
summary={renderItemSummary}
|
summary={renderItemSummary}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -431,6 +432,7 @@ const BillPage: React.FC = () => {
|
|||||||
columns={orderColumns}
|
columns={orderColumns}
|
||||||
dataSource={detail.orders}
|
dataSource={detail.orders}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -342,6 +342,7 @@ const PaymentPage: React.FC = () => {
|
|||||||
columns={billColumns}
|
columns={billColumns}
|
||||||
dataSource={detail.bills}
|
dataSource={detail.bills}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
Reference in New Issue
Block a user