feat(耗材管理): 添加SN序列号管理功能
This commit is contained in:
@@ -52,6 +52,12 @@ const Consumable = sequelize.define('Consumable', {
|
|||||||
description: {
|
description: {
|
||||||
type: DataTypes.TEXT
|
type: DataTypes.TEXT
|
||||||
},
|
},
|
||||||
|
snList: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: [],
|
||||||
|
comment: 'SN序列号列表,JSON数组格式'
|
||||||
|
},
|
||||||
status: {
|
status: {
|
||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
defaultValue: 'active'
|
defaultValue: 'active'
|
||||||
|
|||||||
@@ -87,6 +87,12 @@ const ConsumableLog = sequelize.define('ConsumableLog', {
|
|||||||
type: DataTypes.JSON,
|
type: DataTypes.JSON,
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
comment: '耗材快照信息(分类、单位、供应商等),用于耗材删除后追溯'
|
comment: '耗材快照信息(分类、单位、供应商等),用于耗材删除后追溯'
|
||||||
|
},
|
||||||
|
snList: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: [],
|
||||||
|
comment: '本次操作的SN序列号列表'
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
tableName: 'consumable_logs',
|
tableName: 'consumable_logs',
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ const ConsumableRecord = sequelize.define('ConsumableRecord', {
|
|||||||
},
|
},
|
||||||
notes: {
|
notes: {
|
||||||
type: DataTypes.TEXT
|
type: DataTypes.TEXT
|
||||||
|
},
|
||||||
|
snList: {
|
||||||
|
type: DataTypes.JSON,
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: [],
|
||||||
|
comment: '本次操作的SN序列号列表'
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
tableName: 'consumable_records',
|
tableName: 'consumable_records',
|
||||||
|
|||||||
@@ -287,7 +287,7 @@ router.post('/quick-inout', async (req, res) => {
|
|||||||
while (attempt < MAX_RETRIES) {
|
while (attempt < MAX_RETRIES) {
|
||||||
const transaction = await sequelize.transaction();
|
const transaction = await sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
const { consumableId, type, quantity, operator, reason, notes } = req.body;
|
const { consumableId, type, quantity, operator, reason, notes, snList } = req.body;
|
||||||
|
|
||||||
const consumable = await Consumable.findByPk(consumableId, { transaction });
|
const consumable = await Consumable.findByPk(consumableId, { transaction });
|
||||||
if (!consumable) {
|
if (!consumable) {
|
||||||
@@ -297,15 +297,34 @@ router.post('/quick-inout', async (req, res) => {
|
|||||||
|
|
||||||
const previousStock = parseFloat(consumable.currentStock);
|
const previousStock = parseFloat(consumable.currentStock);
|
||||||
let newStock;
|
let newStock;
|
||||||
|
let currentSnList = consumable.snList || [];
|
||||||
|
let updatedSnList = [...currentSnList];
|
||||||
|
let operationSnList = snList || [];
|
||||||
|
|
||||||
if (type === 'in') {
|
if (type === 'in') {
|
||||||
newStock = previousStock + parseFloat(quantity);
|
newStock = previousStock + parseFloat(quantity);
|
||||||
|
if (operationSnList.length > 0) {
|
||||||
|
operationSnList.forEach(sn => {
|
||||||
|
if (!updatedSnList.includes(sn)) {
|
||||||
|
updatedSnList.push(sn);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
} else if (type === 'out') {
|
} else if (type === 'out') {
|
||||||
newStock = previousStock - parseFloat(quantity);
|
newStock = previousStock - parseFloat(quantity);
|
||||||
if (newStock < 0) {
|
if (newStock < 0) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
return res.status(400).json({ error: '库存不足' });
|
return res.status(400).json({ error: '库存不足' });
|
||||||
}
|
}
|
||||||
|
if (operationSnList.length > 0) {
|
||||||
|
for (const sn of operationSnList) {
|
||||||
|
if (!updatedSnList.includes(sn)) {
|
||||||
|
await transaction.rollback();
|
||||||
|
return res.status(400).json({ error: `SN "${sn}" 不存在于当前耗材中` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updatedSnList = updatedSnList.filter(sn => !operationSnList.includes(sn));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
return res.status(400).json({ error: '操作类型无效' });
|
return res.status(400).json({ error: '操作类型无效' });
|
||||||
@@ -314,6 +333,7 @@ router.post('/quick-inout', async (req, res) => {
|
|||||||
const [affectedRows] = await Consumable.update(
|
const [affectedRows] = await Consumable.update(
|
||||||
{
|
{
|
||||||
currentStock: newStock,
|
currentStock: newStock,
|
||||||
|
snList: updatedSnList,
|
||||||
version: sequelize.literal('version + 1')
|
version: sequelize.literal('version + 1')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -342,10 +362,10 @@ router.post('/quick-inout', async (req, res) => {
|
|||||||
currentStock: newStock,
|
currentStock: newStock,
|
||||||
operator,
|
operator,
|
||||||
reason,
|
reason,
|
||||||
notes
|
notes,
|
||||||
|
snList: operationSnList
|
||||||
}, { transaction });
|
}, { transaction });
|
||||||
|
|
||||||
// 系统生成的出入库记录不可编辑
|
|
||||||
await ConsumableLog.create({
|
await ConsumableLog.create({
|
||||||
consumableId,
|
consumableId,
|
||||||
consumableName: consumable.name,
|
consumableName: consumable.name,
|
||||||
@@ -357,6 +377,7 @@ router.post('/quick-inout', async (req, res) => {
|
|||||||
reason,
|
reason,
|
||||||
notes,
|
notes,
|
||||||
isEditable: false,
|
isEditable: false,
|
||||||
|
snList: operationSnList,
|
||||||
consumableSnapshot: {
|
consumableSnapshot: {
|
||||||
category: consumable.category,
|
category: consumable.category,
|
||||||
unit: consumable.unit,
|
unit: consumable.unit,
|
||||||
@@ -391,7 +412,7 @@ router.post('/inout', async (req, res) => {
|
|||||||
while (attempt < MAX_RETRIES) {
|
while (attempt < MAX_RETRIES) {
|
||||||
const transaction = await sequelize.transaction();
|
const transaction = await sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
const { consumableId, type, quantity, operator, reason, recipient, notes } = req.body;
|
const { consumableId, type, quantity, operator, reason, recipient, notes, snList } = req.body;
|
||||||
|
|
||||||
const consumable = await Consumable.findByPk(consumableId, { transaction });
|
const consumable = await Consumable.findByPk(consumableId, { transaction });
|
||||||
if (!consumable) {
|
if (!consumable) {
|
||||||
@@ -401,20 +422,40 @@ router.post('/inout', async (req, res) => {
|
|||||||
|
|
||||||
const previousStock = parseFloat(consumable.currentStock);
|
const previousStock = parseFloat(consumable.currentStock);
|
||||||
let newStock;
|
let newStock;
|
||||||
|
let currentSnList = consumable.snList || [];
|
||||||
|
let updatedSnList = [...currentSnList];
|
||||||
|
let operationSnList = snList || [];
|
||||||
|
|
||||||
if (type === 'in') {
|
if (type === 'in') {
|
||||||
newStock = previousStock + parseFloat(quantity);
|
newStock = previousStock + parseFloat(quantity);
|
||||||
|
if (operationSnList.length > 0) {
|
||||||
|
operationSnList.forEach(sn => {
|
||||||
|
if (!updatedSnList.includes(sn)) {
|
||||||
|
updatedSnList.push(sn);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
newStock = previousStock - parseFloat(quantity);
|
newStock = previousStock - parseFloat(quantity);
|
||||||
if (newStock < 0) {
|
if (newStock < 0) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
return res.status(400).json({ error: '库存不足' });
|
return res.status(400).json({ error: '库存不足' });
|
||||||
}
|
}
|
||||||
|
if (operationSnList.length > 0) {
|
||||||
|
for (const sn of operationSnList) {
|
||||||
|
if (!updatedSnList.includes(sn)) {
|
||||||
|
await transaction.rollback();
|
||||||
|
return res.status(400).json({ error: `SN "${sn}" 不存在于当前耗材中` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updatedSnList = updatedSnList.filter(sn => !operationSnList.includes(sn));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [affectedRows] = await Consumable.update(
|
const [affectedRows] = await Consumable.update(
|
||||||
{
|
{
|
||||||
currentStock: newStock,
|
currentStock: newStock,
|
||||||
|
snList: updatedSnList,
|
||||||
version: sequelize.literal('version + 1')
|
version: sequelize.literal('version + 1')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -444,10 +485,10 @@ router.post('/inout', async (req, res) => {
|
|||||||
operator,
|
operator,
|
||||||
reason,
|
reason,
|
||||||
recipient,
|
recipient,
|
||||||
notes
|
notes,
|
||||||
|
snList: operationSnList
|
||||||
}, { transaction });
|
}, { transaction });
|
||||||
|
|
||||||
// 系统生成的出入库记录不可编辑
|
|
||||||
await ConsumableLog.create({
|
await ConsumableLog.create({
|
||||||
consumableId,
|
consumableId,
|
||||||
consumableName: consumable.name,
|
consumableName: consumable.name,
|
||||||
@@ -459,6 +500,7 @@ router.post('/inout', async (req, res) => {
|
|||||||
reason,
|
reason,
|
||||||
notes,
|
notes,
|
||||||
isEditable: false,
|
isEditable: false,
|
||||||
|
snList: operationSnList,
|
||||||
consumableSnapshot: {
|
consumableSnapshot: {
|
||||||
category: consumable.category,
|
category: consumable.category,
|
||||||
unit: consumable.unit,
|
unit: consumable.unit,
|
||||||
@@ -596,8 +638,13 @@ router.get('/logs', async (req, res) => {
|
|||||||
where.consumableId = consumableId;
|
where.consumableId = consumableId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (operationType && operationType !== 'all') {
|
if (operationType) {
|
||||||
where.operationType = operationType;
|
const types = operationType.split(',').map(t => t.trim()).filter(t => t);
|
||||||
|
if (types.length === 1) {
|
||||||
|
where.operationType = types[0];
|
||||||
|
} else if (types.length > 1) {
|
||||||
|
where.operationType = { [Op.in]: types };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (startDate && endDate) {
|
if (startDate && endDate) {
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ const migrations = [
|
|||||||
name: '耗材日志归档表',
|
name: '耗材日志归档表',
|
||||||
description: '创建 consumable_log_archives 归档表',
|
description: '创建 consumable_log_archives 归档表',
|
||||||
migrate: migrateConsumableLogArchive
|
migrate: migrateConsumableLogArchive
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '耗材SN序列号字段',
|
||||||
|
description: '为 consumables、consumable_records、consumable_logs 添加 snList 字段',
|
||||||
|
migrate: migrateSnList
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -366,6 +371,36 @@ async function migrateConsumableLogArchive() {
|
|||||||
await sequelize.query(`CREATE INDEX idx_archive_deleted_at ON consumable_log_archives(deletedAt)`);
|
await sequelize.query(`CREATE INDEX idx_archive_deleted_at ON consumable_log_archives(deletedAt)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function migrateSnList() {
|
||||||
|
const dialect = sequelize.getDialect();
|
||||||
|
|
||||||
|
const tables = ['consumables', 'consumable_records', 'consumable_logs'];
|
||||||
|
|
||||||
|
for (const table of tables) {
|
||||||
|
const tableInfo = await sequelize.query(
|
||||||
|
dialect === 'sqlite'
|
||||||
|
? `PRAGMA table_info(${table})`
|
||||||
|
: `SHOW COLUMNS FROM ${table}`,
|
||||||
|
{ type: sequelize.QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns = dialect === 'sqlite'
|
||||||
|
? tableInfo.map(col => col.name)
|
||||||
|
: tableInfo.map(col => col.Field);
|
||||||
|
|
||||||
|
if (!columns.includes('snList')) {
|
||||||
|
if (dialect === 'sqlite') {
|
||||||
|
await sequelize.query(`ALTER TABLE ${table} ADD COLUMN snList TEXT DEFAULT '[]'`);
|
||||||
|
} else {
|
||||||
|
await sequelize.query(`ALTER TABLE ${table} ADD COLUMN snList JSON DEFAULT '[]'`);
|
||||||
|
}
|
||||||
|
console.log(` ${table} 表添加 snList 字段成功`);
|
||||||
|
} else {
|
||||||
|
console.log(` ${table} 表 snList 字段已存在,跳过`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 执行迁移
|
// 执行迁移
|
||||||
runMigrations().catch(error => {
|
runMigrations().catch(error => {
|
||||||
console.error('迁移执行失败:', error);
|
console.error('迁移执行失败:', error);
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ function ConsumableLogs() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0 });
|
const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0 });
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
operationType: 'all',
|
operationType: ['in', 'out'],
|
||||||
consumableId: '',
|
consumableId: '',
|
||||||
dateRange: null,
|
dateRange: null,
|
||||||
});
|
});
|
||||||
@@ -73,8 +73,8 @@ function ConsumableLogs() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
const params = { page, pageSize };
|
const params = { page, pageSize };
|
||||||
|
|
||||||
if (currentFilters.operationType !== 'all') {
|
if (currentFilters.operationType && currentFilters.operationType !== 'all' && currentFilters.operationType.length > 0) {
|
||||||
params.operationType = currentFilters.operationType;
|
params.operationType = currentFilters.operationType.join(',');
|
||||||
}
|
}
|
||||||
if (currentFilters.consumableId) {
|
if (currentFilters.consumableId) {
|
||||||
params.consumableId = currentFilters.consumableId;
|
params.consumableId = currentFilters.consumableId;
|
||||||
@@ -541,11 +541,14 @@ function ConsumableLogs() {
|
|||||||
prefix={<SearchOutlined />}
|
prefix={<SearchOutlined />}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
|
mode="multiple"
|
||||||
value={filters.operationType}
|
value={filters.operationType}
|
||||||
onChange={value => handleFilterChange('operationType', value)}
|
onChange={value => handleFilterChange('operationType', value)}
|
||||||
style={{ width: 120 }}
|
style={{ width: 200 }}
|
||||||
|
placeholder="选择操作类型"
|
||||||
|
allowClear
|
||||||
|
maxTagCount="responsive"
|
||||||
>
|
>
|
||||||
<Option value="all">全部类型</Option>
|
|
||||||
<Option value="in">入库</Option>
|
<Option value="in">入库</Option>
|
||||||
<Option value="out">出库</Option>
|
<Option value="out">出库</Option>
|
||||||
<Option value="create">创建</Option>
|
<Option value="create">创建</Option>
|
||||||
@@ -562,7 +565,7 @@ function ConsumableLogs() {
|
|||||||
<Button
|
<Button
|
||||||
icon={<HistoryOutlined />}
|
icon={<HistoryOutlined />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setFilters({ operationType: 'all', consumableId: '', dateRange: null });
|
setFilters({ operationType: ['in', 'out'], consumableId: '', dateRange: null });
|
||||||
fetchLogs(1, pagination.pageSize);
|
fetchLogs(1, pagination.pageSize);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
Space,
|
Space,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
|
Popover,
|
||||||
Upload,
|
Upload,
|
||||||
Progress,
|
Progress,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
@@ -166,6 +167,11 @@ function ConsumableManagement() {
|
|||||||
const [stockType, setStockType] = useState('in');
|
const [stockType, setStockType] = useState('in');
|
||||||
const [stockForm] = Form.useForm();
|
const [stockForm] = Form.useForm();
|
||||||
const [maxStockUnlimited, setMaxStockUnlimited] = useState(false);
|
const [maxStockUnlimited, setMaxStockUnlimited] = useState(false);
|
||||||
|
const [snList, setSnList] = useState([]);
|
||||||
|
const [snInputVisible, setSnInputVisible] = useState(false);
|
||||||
|
const [snInputValue, setSnInputValue] = useState('');
|
||||||
|
const [selectedSnList, setSelectedSnList] = useState([]);
|
||||||
|
const [snSearchKeyword, setSnSearchKeyword] = useState('');
|
||||||
|
|
||||||
const fetchConsumables = useCallback(
|
const fetchConsumables = useCallback(
|
||||||
async (page = 1, pageSize = 10) => {
|
async (page = 1, pageSize = 10) => {
|
||||||
@@ -210,12 +216,14 @@ function ConsumableManagement() {
|
|||||||
consumable.maxStock === null ||
|
consumable.maxStock === null ||
|
||||||
consumable.maxStock === undefined;
|
consumable.maxStock === undefined;
|
||||||
setMaxStockUnlimited(isUnlimited);
|
setMaxStockUnlimited(isUnlimited);
|
||||||
|
setSnList(consumable.snList || []);
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
...consumable,
|
...consumable,
|
||||||
maxStock: isUnlimited ? undefined : consumable.maxStock,
|
maxStock: isUnlimited ? undefined : consumable.maxStock,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setMaxStockUnlimited(true);
|
setMaxStockUnlimited(true);
|
||||||
|
setSnList([]);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
unit: '个',
|
unit: '个',
|
||||||
@@ -233,6 +241,9 @@ function ConsumableManagement() {
|
|||||||
const handleCancel = useCallback(() => {
|
const handleCancel = useCallback(() => {
|
||||||
setModalVisible(false);
|
setModalVisible(false);
|
||||||
setEditingConsumable(null);
|
setEditingConsumable(null);
|
||||||
|
setSnList([]);
|
||||||
|
setSnInputVisible(false);
|
||||||
|
setSnInputValue('');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
@@ -242,6 +253,7 @@ function ConsumableManagement() {
|
|||||||
...values,
|
...values,
|
||||||
maxStock: maxStockUnlimited ? 0 : values.maxStock,
|
maxStock: maxStockUnlimited ? 0 : values.maxStock,
|
||||||
unitPrice: values.unitPrice || 0,
|
unitPrice: values.unitPrice || 0,
|
||||||
|
snList: snList,
|
||||||
};
|
};
|
||||||
if (editingConsumable) {
|
if (editingConsumable) {
|
||||||
await axios.put(`/api/consumables/${editingConsumable.consumableId}`, submitData);
|
await axios.put(`/api/consumables/${editingConsumable.consumableId}`, submitData);
|
||||||
@@ -262,12 +274,13 @@ function ConsumableManagement() {
|
|||||||
setModalVisible(false);
|
setModalVisible(false);
|
||||||
fetchConsumables();
|
fetchConsumables();
|
||||||
setEditingConsumable(null);
|
setEditingConsumable(null);
|
||||||
|
setSnList([]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(editingConsumable ? '耗材更新失败' : '耗材创建失败');
|
message.error(editingConsumable ? '耗材更新失败' : '耗材创建失败');
|
||||||
console.error('提交失败:', error);
|
console.error('提交失败:', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[editingConsumable, fetchConsumables, maxStockUnlimited]
|
[editingConsumable, fetchConsumables, maxStockUnlimited, snList]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
const handleDelete = useCallback(
|
||||||
@@ -539,6 +552,8 @@ function ConsumableManagement() {
|
|||||||
(record, type) => {
|
(record, type) => {
|
||||||
setStockRecord(record);
|
setStockRecord(record);
|
||||||
setStockType(type);
|
setStockType(type);
|
||||||
|
setSelectedSnList([]);
|
||||||
|
setSnSearchKeyword('');
|
||||||
stockForm.setFieldsValue({
|
stockForm.setFieldsValue({
|
||||||
consumableId: record.consumableId,
|
consumableId: record.consumableId,
|
||||||
consumableName: record.name,
|
consumableName: record.name,
|
||||||
@@ -554,6 +569,8 @@ function ConsumableManagement() {
|
|||||||
const handleStockCancel = useCallback(() => {
|
const handleStockCancel = useCallback(() => {
|
||||||
setStockModalVisible(false);
|
setStockModalVisible(false);
|
||||||
setStockRecord(null);
|
setStockRecord(null);
|
||||||
|
setSelectedSnList([]);
|
||||||
|
setSnSearchKeyword('');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleStockSubmit = useCallback(
|
const handleStockSubmit = useCallback(
|
||||||
@@ -566,12 +583,14 @@ function ConsumableManagement() {
|
|||||||
operator: values.operator || '系统管理员',
|
operator: values.operator || '系统管理员',
|
||||||
reason: values.reason,
|
reason: values.reason,
|
||||||
notes: values.notes,
|
notes: values.notes,
|
||||||
|
snList: stockType === 'out' ? selectedSnList : values.snList || [],
|
||||||
});
|
});
|
||||||
message.success({
|
message.success({
|
||||||
content: `${stockType === 'in' ? '入库' : '出库'}操作成功`,
|
content: `${stockType === 'in' ? '入库' : '出库'}操作成功`,
|
||||||
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
|
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
|
||||||
});
|
});
|
||||||
setStockModalVisible(false);
|
setStockModalVisible(false);
|
||||||
|
setSelectedSnList([]);
|
||||||
fetchConsumables();
|
fetchConsumables();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(
|
message.error(
|
||||||
@@ -580,7 +599,7 @@ function ConsumableManagement() {
|
|||||||
console.error('操作失败:', error);
|
console.error('操作失败:', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[stockRecord, stockType, fetchConsumables]
|
[stockRecord, stockType, fetchConsumables, selectedSnList]
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
@@ -606,6 +625,50 @@ function ConsumableManagement() {
|
|||||||
width: 80,
|
width: 80,
|
||||||
render: text => <Text type="secondary">{text}</Text>,
|
render: text => <Text type="secondary">{text}</Text>,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'SN数量',
|
||||||
|
key: 'snCount',
|
||||||
|
width: 120,
|
||||||
|
render: (_, record) => {
|
||||||
|
const snList = record.snList || [];
|
||||||
|
const snCount = snList.length;
|
||||||
|
const stock = record.currentStock || 0;
|
||||||
|
|
||||||
|
if (snCount === 0) {
|
||||||
|
return (
|
||||||
|
<Tag color="default" style={{ borderRadius: '4px' }}>
|
||||||
|
0/{stock}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const snContent = (
|
||||||
|
<div style={{ maxHeight: '200px', overflowY: 'auto', maxWidth: '300px' }}>
|
||||||
|
{snList.map((sn, index) => (
|
||||||
|
<Tag key={index} style={{ marginBottom: '4px', marginRight: '4px' }}>
|
||||||
|
{sn}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
content={snContent}
|
||||||
|
title={`SN列表 (${snCount}个)`}
|
||||||
|
trigger="click"
|
||||||
|
placement="right"
|
||||||
|
>
|
||||||
|
<Tag
|
||||||
|
color="purple"
|
||||||
|
style={{ borderRadius: '4px', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{snCount}/{stock} 🔍
|
||||||
|
</Tag>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '当前库存',
|
title: '当前库存',
|
||||||
dataIndex: 'currentStock',
|
dataIndex: 'currentStock',
|
||||||
@@ -1139,6 +1202,93 @@ function ConsumableManagement() {
|
|||||||
<TextArea rows={3} placeholder="请输入描述信息" />
|
<TextArea rows={3} placeholder="请输入描述信息" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label={
|
||||||
|
<span>
|
||||||
|
SN序列号
|
||||||
|
<Text type="secondary" style={{ fontSize: '12px', marginLeft: '8px' }}>
|
||||||
|
(非必填,已录入 {snList.length} 个)
|
||||||
|
</Text>
|
||||||
|
</span>
|
||||||
|
}>
|
||||||
|
<div style={{ marginBottom: '8px' }}>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => setSnInputVisible(!snInputVisible)}
|
||||||
|
>
|
||||||
|
批量添加
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
onClick={() => {
|
||||||
|
setSnList([]);
|
||||||
|
}}
|
||||||
|
disabled={snList.length === 0}
|
||||||
|
>
|
||||||
|
清空全部
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{snInputVisible && (
|
||||||
|
<div style={{ marginBottom: '12px' }}>
|
||||||
|
<TextArea
|
||||||
|
rows={3}
|
||||||
|
placeholder="输入SN序列号,每行一个"
|
||||||
|
value={snInputValue}
|
||||||
|
onChange={e => setSnInputValue(e.target.value)}
|
||||||
|
style={{ marginBottom: '8px' }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
const newSns = snInputValue
|
||||||
|
.split('\n')
|
||||||
|
.map(s => s.trim())
|
||||||
|
.filter(s => s && !snList.includes(s));
|
||||||
|
if (newSns.length > 0) {
|
||||||
|
setSnList([...snList, ...newSns]);
|
||||||
|
setSnInputValue('');
|
||||||
|
message.success(`成功添加 ${newSns.length} 个SN`);
|
||||||
|
} else {
|
||||||
|
message.warning('没有新的SN可添加(可能已存在或为空)');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
确认添加
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{snList.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxHeight: '150px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
border: `1px solid ${designTokens.colors.neutral[200]}`,
|
||||||
|
borderRadius: designTokens.borderRadius.sm,
|
||||||
|
padding: '8px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{snList.map((sn, index) => (
|
||||||
|
<Tag
|
||||||
|
key={index}
|
||||||
|
closable
|
||||||
|
onClose={() => {
|
||||||
|
setSnList(snList.filter((_, i) => i !== index));
|
||||||
|
}}
|
||||||
|
style={{ marginBottom: '4px' }}
|
||||||
|
>
|
||||||
|
{sn}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item name="status" label="状态" rules={[{ required: true, message: '请选择状态' }]}>
|
<Form.Item name="status" label="状态" rules={[{ required: true, message: '请选择状态' }]}>
|
||||||
<Select placeholder="请选择状态">
|
<Select placeholder="请选择状态">
|
||||||
<Option value="active">启用</Option>
|
<Option value="active">启用</Option>
|
||||||
@@ -1333,12 +1483,117 @@ function ConsumableManagement() {
|
|||||||
<Form.Item name="consumableName" label="耗材名称">
|
<Form.Item name="consumableName" label="耗材名称">
|
||||||
<Input disabled />
|
<Input disabled />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
{stockType === 'out' && stockRecord?.snList && stockRecord.snList.length > 0 && (
|
||||||
|
<Form.Item label={
|
||||||
|
<span>
|
||||||
|
选择SN序列号
|
||||||
|
<Text type="secondary" style={{ fontSize: '12px', marginLeft: '8px' }}>
|
||||||
|
(已选 {selectedSnList.length} 个)
|
||||||
|
</Text>
|
||||||
|
</span>
|
||||||
|
}>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||||
|
<Input
|
||||||
|
placeholder="搜索SN序列号..."
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
allowClear
|
||||||
|
value={snSearchKeyword}
|
||||||
|
onChange={e => setSnSearchKeyword(e.target.value)}
|
||||||
|
style={{ marginBottom: '8px' }}
|
||||||
|
/>
|
||||||
|
<Space size="small" style={{ marginBottom: '8px' }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="link"
|
||||||
|
onClick={() => {
|
||||||
|
const filtered = stockRecord.snList.filter(sn =>
|
||||||
|
sn.toLowerCase().includes(snSearchKeyword.toLowerCase())
|
||||||
|
);
|
||||||
|
setSelectedSnList([...new Set([...selectedSnList, ...filtered])]);
|
||||||
|
stockForm.setFieldsValue({ quantity: [...new Set([...selectedSnList, ...filtered])].length });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
全选过滤结果
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="link"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedSnList([]);
|
||||||
|
stockForm.setFieldsValue({ quantity: 1 });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
清空选择
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxHeight: '150px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
border: `1px solid ${designTokens.colors.neutral[200]}`,
|
||||||
|
borderRadius: designTokens.borderRadius.sm,
|
||||||
|
padding: '8px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
const filteredSnList = stockRecord.snList.filter(sn =>
|
||||||
|
sn.toLowerCase().includes(snSearchKeyword.toLowerCase())
|
||||||
|
);
|
||||||
|
if (filteredSnList.length === 0) {
|
||||||
|
return <Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: '16px 0' }}>无匹配的SN</Text>;
|
||||||
|
}
|
||||||
|
return filteredSnList.map((sn, index) => (
|
||||||
|
<Tag.CheckableTag
|
||||||
|
key={index}
|
||||||
|
checked={selectedSnList.includes(sn)}
|
||||||
|
onChange={checked => {
|
||||||
|
let newSelected;
|
||||||
|
if (checked) {
|
||||||
|
newSelected = [...selectedSnList, sn];
|
||||||
|
} else {
|
||||||
|
newSelected = selectedSnList.filter(s => s !== sn);
|
||||||
|
}
|
||||||
|
setSelectedSnList(newSelected);
|
||||||
|
stockForm.setFieldsValue({ quantity: newSelected.length });
|
||||||
|
}}
|
||||||
|
style={{ marginBottom: '4px' }}
|
||||||
|
>
|
||||||
|
{sn}
|
||||||
|
</Tag.CheckableTag>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||||
|
{snSearchKeyword ? `过滤结果: ${stockRecord.snList.filter(sn => sn.toLowerCase().includes(snSearchKeyword.toLowerCase())).length} 个SN` : `共 ${stockRecord.snList.length} 个SN`}
|
||||||
|
,点击SN进行选择
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stockType === 'in' && (
|
||||||
|
<Form.Item name="snList" label="入库SN序列号(可选)">
|
||||||
|
<Select
|
||||||
|
mode="tags"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder="输入SN后按回车添加"
|
||||||
|
tokenSeparators={[',', '\n']}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="quantity"
|
name="quantity"
|
||||||
label="数量"
|
label="数量"
|
||||||
rules={[{ required: true, message: '请输入数量' }]}
|
rules={[{ required: true, message: '请输入数量' }]}
|
||||||
>
|
>
|
||||||
<InputNumber min={1} style={{ width: '100%' }} placeholder="请输入数量" />
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
max={stockType === 'out' && stockRecord?.snList?.length > 0 ? stockRecord.snList.length : undefined}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder="请输入数量"
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="operator" label="操作人">
|
<Form.Item name="operator" label="操作人">
|
||||||
<Input placeholder="请输入操作人姓名" />
|
<Input placeholder="请输入操作人姓名" />
|
||||||
|
|||||||
Reference in New Issue
Block a user