Compare commits

..

3 Commits

Author SHA1 Message Date
xinadmin 4fe4e985f6 布局适配 2026-08-20 16:48:04 +08:00
xinadmin 22237a47e7 添加市场 2026-08-20 15:39:39 +08:00
xinadmin cd7007bc49 商品导入导出 2026-08-20 14:45:06 +08:00
27 changed files with 1126 additions and 57 deletions
File diff suppressed because one or more lines are too long
+127
View File
@@ -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;
}
}
+6 -4
View File
@@ -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()
+4 -3
View File
@@ -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);
+4 -3
View File
@@ -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',
+363
View File
@@ -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);
});
}
/**
* 分类名称/路径 末级分类ID0 = 解析失败)
* 支持「父分类/子分类」路径逐级匹配;单名称时要求末级分类唯一
*
* @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,
};
}
}
+1
View File
@@ -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',
+5 -2
View File
@@ -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('计价单位(斤/件/箱等)');
+2
View File
@@ -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' => '导出'],
], ],
], ],
[ [
+20 -17
View File
@@ -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;
} }
); );
} }
+328
View File
@@ -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());
}
}
+42 -9
View File
@@ -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 } : {},
});
}
+2 -1
View File
@@ -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}
+2
View File
@@ -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;
/** 规格/包规 */ /** 规格/包规 */
+6
View File
@@ -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;
/** 数量(包数) */ /** 数量(包数) */
+3 -1
View File
@@ -64,7 +64,9 @@ const LayoutContent: React.FC = () => {
{ layout === "columns" ? <ColumnsMenu/> : <MenuRender />} { layout === "columns" ? <ColumnsMenu/> : <MenuRender />}
</Sider> </Sider>
)} )}
<Layout> {/* min-w-0flex min-width:auto
*/}
<Layout className="min-w-0">
<Content style={{padding: themeConfig.bodyPadding}}> <Content style={{padding: themeConfig.bodyPadding}}>
<Outlet/> <Outlet/>
</Content> </Content>
+3
View File
@@ -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"),
+1
View File
@@ -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
View File
@@ -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 → costnull 视为未修改,不提交) */ /** 跨页未保存的成本价:productId → costnull 视为未修改,不提交) */
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}
+22 -15
View File
@@ -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}
/> />
) : ( ) : (
+2
View File
@@ -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}
+1
View File
@@ -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}