批量删除

This commit is contained in:
liu
2026-08-23 14:26:37 +08:00
parent 01e67e02b5
commit 5d58075644
5 changed files with 132 additions and 4 deletions
File diff suppressed because one or more lines are too long
@@ -166,6 +166,23 @@ class ProductController extends BaseController
return $this->success();
}
/** 批量删除商品(软删除),复用 delete 权限点 */
#[DeleteRoute('/batch', 'delete')]
public function batchDelete(Request $request): JsonResponse
{
$data = $request->validate([
'ids' => 'required|array|min:1',
'ids.*' => 'integer|distinct',
], [
'ids.required' => '请选择要删除的商品',
'ids.min' => '请选择要删除的商品',
'ids.*.integer' => '商品 ID 格式错误',
'ids.*.distinct' => '存在重复的商品',
]);
ProductModel::whereIn('id', $data['ids'])->delete();
return $this->success();
}
/**
* A2 价格矩阵:行=商品(支持 category_id / keyword 过滤 + page/pageSize 服务端分页),
* 列=全部启用等级,值=按等级上浮比例换算的售价(成本价未设置为 null);仅成本价可编辑
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace Tests\Feature;
use App\Models\ProductModel;
/**
* 商品批量删除(软删除):正常批量删除、空 ids 校验失败、无 delete 权限点拦截
*/
class ProductBatchDeleteTest extends ProcurementTestCase
{
/** 批量删除成功:选中商品软删除,未选中的保留 */
public function test_batch_delete_soft_deletes_selected_products(): void
{
$this->actingAsSysUser();
$ids = ProductModel::factory()->count(3)->create()->pluck('id')->all();
$keep = ProductModel::factory()->create();
$this->deleteJson('/product/goods/batch', ['ids' => $ids])
->assertOk()
->assertJsonPath('success', true);
foreach ($ids as $id) {
$this->assertSoftDeleted('product', ['id' => $id]);
}
$this->assertDatabaseHas('product', ['id' => $keep->id, 'deleted_at' => null]);
}
/** ids 为空数组 → 校验失败,不删除任何商品 */
public function test_batch_delete_requires_non_empty_ids(): void
{
$this->actingAsSysUser();
$product = ProductModel::factory()->create();
$this->deleteJson('/product/goods/batch', ['ids' => []])
->assertOk()
->assertJsonPath('success', false)
->assertJsonPath('msg', '请选择要删除的商品');
$this->assertDatabaseHas('product', ['id' => $product->id, 'deleted_at' => null]);
}
/** 无 product.goods.delete 权限点 → 拦截 */
public function test_batch_delete_forbidden_without_permission(): void
{
// 先建占位用户:每个测试方法内首个系统用户自增 id=1,超管旁路会绕过 abilities 校验
$this->actingAsSysUser();
$this->actingAsSysUser(['product.goods.query']);
$product = ProductModel::factory()->create();
$this->deleteJson('/product/goods/batch', ['ids' => [$product->id]])
->assertOk()
->assertJsonPath('success', false)
->assertJsonPath('msg', 'No Permission');
$this->assertDatabaseHas('product', ['id' => $product->id, 'deleted_at' => null]);
}
}
+9
View File
@@ -51,6 +51,15 @@ export async function importProducts(file: File) {
});
}
/** 批量删除商品(软删除) */
export async function batchDeleteProducts(ids: number[]) {
return createAxios({
url: '/product/goods/batch',
method: 'delete',
data: { ids },
});
}
/**
* A2 价格矩阵:行=商品,列=启用等级,值=按等级上浮比例换算的售价(成本价未设置 null);
* 仅成本价可编辑
+46 -3
View File
@@ -15,7 +15,7 @@ import {
Typography,
Upload,
} from 'antd';
import { DownloadOutlined, InboxOutlined, TableOutlined, UploadOutlined } from '@ant-design/icons';
import { DeleteOutlined, DownloadOutlined, InboxOutlined, TableOutlined, UploadOutlined } from '@ant-design/icons';
import type { TableProps } from 'antd';
import XinTable from '@/components/XinTable';
import AuthButton from '@/components/AuthButton';
@@ -28,7 +28,7 @@ import type {IProductCategoryTree} from '@/domain/iProductCategory.ts';
import { getSupplierOptions } from '@/api/customer/supplier.ts';
import type ISupplier from '@/domain/iSupplier.ts';
import { getCategoryTree } from '@/api/product/category.ts';
import { batchPrice, getPriceMatrix } from '@/api/product/goods.ts';
import { batchDeleteProducts, batchPrice, getPriceMatrix } from '@/api/product/goods.ts';
import type { IImportError } from '@/api/product/goods.ts';
import { downloadProductTemplate, exportProducts, importProducts } from '@/api/product/goods.ts';
@@ -70,7 +70,35 @@ const ProductGoodsPage: React.FC = () => {
// ===== 分类侧栏 =====
const [activeCategory, setActiveCategory] = useState<number | undefined>(undefined);
const tableRef = useRef<XinTableInstance<IProduct> | null>(null);
useEffect(() => { tableRef.current?.reset() }, [activeCategory]);
useEffect(() => {
setSelectedRowKeys([]);
tableRef.current?.reset();
}, [activeCategory]);
// ===== 批量删除 =====
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
const [batchDeleting, setBatchDeleting] = useState(false);
/** 批量删除(软删除,成功后清空勾选并刷新列表) */
const handleBatchDelete = () => {
window.$modal?.confirm({
title: `确定要删除选中的 ${selectedRowKeys.length} 件商品吗?`,
okText: '删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
setBatchDeleting(true);
try {
await batchDeleteProducts(selectedRowKeys);
message.success('批量删除成功');
setSelectedRowKeys([]);
await tableRef.current?.reload();
} finally {
setBatchDeleting(false);
}
},
});
};
// ===== 价格矩阵抽屉 =====
const [matrixOpen, setMatrixOpen] = useState(false);
@@ -489,6 +517,17 @@ const ProductGoodsPage: React.FC = () => {
actionBarRender: (dom) => [
dom.add,
dom.search,
<AuthButton key="batchDelete" auth="product.goods.delete">
<Button
danger
icon={<DeleteOutlined />}
disabled={selectedRowKeys.length === 0}
loading={batchDeleting}
onClick={handleBatchDelete}
>
{selectedRowKeys.length > 0 ? ` (${selectedRowKeys.length})` : ''}
</Button>
</AuthButton>,
<Button key="matrix" icon={<TableOutlined />} onClick={openMatrix}>
</Button>,
@@ -514,6 +553,10 @@ const ProductGoodsPage: React.FC = () => {
...params,
category_id: activeCategory
}),
rowSelection: {
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys.map(Number)),
},
modalProps: {
width: 800,
centered: true,