From 03d69456c026b612f563c75437ebb4b711d1136c Mon Sep 17 00:00:00 2001 From: zhang1106 <849185023@qq.com> Date: Thu, 5 Mar 2026 09:57:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=80=97=E6=9D=90=E7=AE=A1=E7=90=86):=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0SN=E5=BA=8F=E5=88=97=E5=8F=B7=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/models/Consumable.js | 6 + backend/models/ConsumableLog.js | 6 + backend/models/ConsumableRecord.js | 6 + backend/routes/consumables.js | 63 ++++- backend/scripts/migrate-all.js | 35 +++ frontend/src/pages/ConsumableLogs.jsx | 15 +- frontend/src/pages/ConsumableManagement.jsx | 261 +++++++++++++++++++- 7 files changed, 375 insertions(+), 17 deletions(-) diff --git a/backend/models/Consumable.js b/backend/models/Consumable.js index af67bc5..141701e 100644 --- a/backend/models/Consumable.js +++ b/backend/models/Consumable.js @@ -52,6 +52,12 @@ const Consumable = sequelize.define('Consumable', { description: { type: DataTypes.TEXT }, + snList: { + type: DataTypes.JSON, + allowNull: true, + defaultValue: [], + comment: 'SN序列号列表,JSON数组格式' + }, status: { type: DataTypes.STRING, defaultValue: 'active' diff --git a/backend/models/ConsumableLog.js b/backend/models/ConsumableLog.js index 9c72a5c..8c3d3cc 100644 --- a/backend/models/ConsumableLog.js +++ b/backend/models/ConsumableLog.js @@ -87,6 +87,12 @@ const ConsumableLog = sequelize.define('ConsumableLog', { type: DataTypes.JSON, allowNull: true, comment: '耗材快照信息(分类、单位、供应商等),用于耗材删除后追溯' + }, + snList: { + type: DataTypes.JSON, + allowNull: true, + defaultValue: [], + comment: '本次操作的SN序列号列表' } }, { tableName: 'consumable_logs', diff --git a/backend/models/ConsumableRecord.js b/backend/models/ConsumableRecord.js index f454a53..8838adc 100644 --- a/backend/models/ConsumableRecord.js +++ b/backend/models/ConsumableRecord.js @@ -46,6 +46,12 @@ const ConsumableRecord = sequelize.define('ConsumableRecord', { }, notes: { type: DataTypes.TEXT + }, + snList: { + type: DataTypes.JSON, + allowNull: true, + defaultValue: [], + comment: '本次操作的SN序列号列表' } }, { tableName: 'consumable_records', diff --git a/backend/routes/consumables.js b/backend/routes/consumables.js index c15f598..3e55dd5 100644 --- a/backend/routes/consumables.js +++ b/backend/routes/consumables.js @@ -287,7 +287,7 @@ router.post('/quick-inout', async (req, res) => { while (attempt < MAX_RETRIES) { const transaction = await sequelize.transaction(); 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 }); if (!consumable) { @@ -297,15 +297,34 @@ router.post('/quick-inout', async (req, res) => { const previousStock = parseFloat(consumable.currentStock); let newStock; + let currentSnList = consumable.snList || []; + let updatedSnList = [...currentSnList]; + let operationSnList = snList || []; if (type === 'in') { newStock = previousStock + parseFloat(quantity); + if (operationSnList.length > 0) { + operationSnList.forEach(sn => { + if (!updatedSnList.includes(sn)) { + updatedSnList.push(sn); + } + }); + } } else if (type === 'out') { newStock = previousStock - parseFloat(quantity); if (newStock < 0) { await transaction.rollback(); 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 { await transaction.rollback(); return res.status(400).json({ error: '操作类型无效' }); @@ -314,6 +333,7 @@ router.post('/quick-inout', async (req, res) => { const [affectedRows] = await Consumable.update( { currentStock: newStock, + snList: updatedSnList, version: sequelize.literal('version + 1') }, { @@ -342,10 +362,10 @@ router.post('/quick-inout', async (req, res) => { currentStock: newStock, operator, reason, - notes + notes, + snList: operationSnList }, { transaction }); - // 系统生成的出入库记录不可编辑 await ConsumableLog.create({ consumableId, consumableName: consumable.name, @@ -357,6 +377,7 @@ router.post('/quick-inout', async (req, res) => { reason, notes, isEditable: false, + snList: operationSnList, consumableSnapshot: { category: consumable.category, unit: consumable.unit, @@ -391,7 +412,7 @@ router.post('/inout', async (req, res) => { while (attempt < MAX_RETRIES) { const transaction = await sequelize.transaction(); 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 }); if (!consumable) { @@ -401,20 +422,40 @@ router.post('/inout', async (req, res) => { const previousStock = parseFloat(consumable.currentStock); let newStock; + let currentSnList = consumable.snList || []; + let updatedSnList = [...currentSnList]; + let operationSnList = snList || []; if (type === 'in') { newStock = previousStock + parseFloat(quantity); + if (operationSnList.length > 0) { + operationSnList.forEach(sn => { + if (!updatedSnList.includes(sn)) { + updatedSnList.push(sn); + } + }); + } } else { newStock = previousStock - parseFloat(quantity); if (newStock < 0) { await transaction.rollback(); 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( { currentStock: newStock, + snList: updatedSnList, version: sequelize.literal('version + 1') }, { @@ -444,10 +485,10 @@ router.post('/inout', async (req, res) => { operator, reason, recipient, - notes + notes, + snList: operationSnList }, { transaction }); - // 系统生成的出入库记录不可编辑 await ConsumableLog.create({ consumableId, consumableName: consumable.name, @@ -459,6 +500,7 @@ router.post('/inout', async (req, res) => { reason, notes, isEditable: false, + snList: operationSnList, consumableSnapshot: { category: consumable.category, unit: consumable.unit, @@ -596,8 +638,13 @@ router.get('/logs', async (req, res) => { where.consumableId = consumableId; } - if (operationType && operationType !== 'all') { - where.operationType = operationType; + if (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) { diff --git a/backend/scripts/migrate-all.js b/backend/scripts/migrate-all.js index 4b96dd8..62c8603 100644 --- a/backend/scripts/migrate-all.js +++ b/backend/scripts/migrate-all.js @@ -42,6 +42,11 @@ const migrations = [ name: '耗材日志归档表', description: '创建 consumable_log_archives 归档表', 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)`); } +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 => { console.error('迁移执行失败:', error); diff --git a/frontend/src/pages/ConsumableLogs.jsx b/frontend/src/pages/ConsumableLogs.jsx index 1edb063..b752e7f 100644 --- a/frontend/src/pages/ConsumableLogs.jsx +++ b/frontend/src/pages/ConsumableLogs.jsx @@ -46,7 +46,7 @@ function ConsumableLogs() { const [loading, setLoading] = useState(true); const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0 }); const [filters, setFilters] = useState({ - operationType: 'all', + operationType: ['in', 'out'], consumableId: '', dateRange: null, }); @@ -73,8 +73,8 @@ function ConsumableLogs() { setLoading(true); const params = { page, pageSize }; - if (currentFilters.operationType !== 'all') { - params.operationType = currentFilters.operationType; + if (currentFilters.operationType && currentFilters.operationType !== 'all' && currentFilters.operationType.length > 0) { + params.operationType = currentFilters.operationType.join(','); } if (currentFilters.consumableId) { params.consumableId = currentFilters.consumableId; @@ -541,11 +541,14 @@ function ConsumableLogs() { prefix={} />