diff --git a/backend/models/ConsumableLog.js b/backend/models/ConsumableLog.js index f0d6d95..9c72a5c 100644 --- a/backend/models/ConsumableLog.js +++ b/backend/models/ConsumableLog.js @@ -1,6 +1,5 @@ const { DataTypes } = require('sequelize'); const { sequelize } = require('../db'); -const Consumable = require('./Consumable'); const ConsumableLog = sequelize.define('ConsumableLog', { id: { @@ -78,6 +77,16 @@ const ConsumableLog = sequelize.define('ConsumableLog', { type: DataTypes.STRING, allowNull: true, comment: '修改原因' + }, + isConsumableDeleted: { + type: DataTypes.BOOLEAN, + defaultValue: false, + comment: '关联耗材是否已被删除' + }, + consumableSnapshot: { + type: DataTypes.JSON, + allowNull: true, + comment: '耗材快照信息(分类、单位、供应商等),用于耗材删除后追溯' } }, { tableName: 'consumable_logs', @@ -89,13 +98,9 @@ const ConsumableLog = sequelize.define('ConsumableLog', { { fields: ['createdAt'] }, { fields: ['consumableId', 'createdAt'] }, { fields: ['originalLogId'] }, - { fields: ['isEditable'] } + { fields: ['isEditable'] }, + { fields: ['isConsumableDeleted'] } ] }); -ConsumableLog.belongsTo(Consumable, { - foreignKey: 'consumableId', - onDelete: 'CASCADE' -}); - module.exports = ConsumableLog; diff --git a/backend/routes/consumables.js b/backend/routes/consumables.js index fe7a6a4..2de8599 100644 --- a/backend/routes/consumables.js +++ b/backend/routes/consumables.js @@ -70,7 +70,16 @@ router.post('/', async (req, res) => { currentStock: consumable.currentStock, operator: req.body.operator || req.body.operatorName || '系统', reason: '新建耗材', - notes: req.body.description || '' + notes: req.body.description || '', + consumableSnapshot: { + category: consumable.category, + unit: consumable.unit, + unitPrice: consumable.unitPrice, + supplier: consumable.supplier, + location: consumable.location, + minStock: consumable.minStock, + maxStock: consumable.maxStock + } }, { transaction }); await transaction.commit(); @@ -138,7 +147,16 @@ router.post('/import', async (req, res) => { currentStock: consumable.currentStock, operator, reason: '批量导入', - notes: '' + notes: '', + consumableSnapshot: { + category: consumable.category, + unit: consumable.unit, + unitPrice: consumable.unitPrice, + supplier: consumable.supplier, + location: consumable.location, + minStock: consumable.minStock, + maxStock: consumable.maxStock + } }, { transaction }); results.success++; @@ -337,7 +355,14 @@ router.post('/quick-inout', async (req, res) => { operator, reason, notes, - isEditable: false + isEditable: false, + consumableSnapshot: { + category: consumable.category, + unit: consumable.unit, + unitPrice: consumable.unitPrice, + supplier: consumable.supplier, + location: consumable.location + } }, { transaction }); await transaction.commit(); @@ -432,7 +457,14 @@ router.post('/inout', async (req, res) => { operator, reason, notes, - isEditable: false + isEditable: false, + consumableSnapshot: { + category: consumable.category, + unit: consumable.unit, + unitPrice: consumable.unitPrice, + supplier: consumable.supplier, + location: consumable.location + } }, { transaction }); await transaction.commit(); @@ -515,7 +547,6 @@ router.post('/adjust', async (req, res) => { const changeQuantity = newStock - previousStock; - // 系统生成的调整记录不可编辑 await ConsumableLog.create({ consumableId, consumableName: consumable.name, @@ -526,7 +557,14 @@ router.post('/adjust', async (req, res) => { operator, reason: reason || (adjustType === 'set' ? '库存调整为 ' + newStock : reason), notes: adjustType === 'set' ? `库存从 ${previousStock} 调整为 ${newStock}` : notes, - isEditable: false + isEditable: false, + consumableSnapshot: { + category: consumable.category, + unit: consumable.unit, + unitPrice: consumable.unitPrice, + supplier: consumable.supplier, + location: consumable.location + } }, { transaction }); await transaction.commit(); @@ -618,7 +656,7 @@ router.get('/logs/export', async (req, res) => { order: [['createdAt', 'DESC']] }); - const csvHeader = 'ID,耗材ID,耗材名称,操作类型,变动数量,操作前库存,操作后库存,操作人,原因,备注,创建时间,更新时间\n'; + const csvHeader = 'ID,耗材ID,耗材名称,操作类型,变动数量,操作前库存,操作后库存,操作人,原因,备注,耗材状态,分类,单位,单价,创建时间,更新时间\n'; const csvRows = logs.map(log => { const operationTypeMap = { 'in': '入库', @@ -629,6 +667,7 @@ router.get('/logs/export', async (req, res) => { 'adjust': '调整', 'import': '导入' }; + const snapshot = log.consumableSnapshot || {}; return [ log.id, log.consumableId, @@ -640,6 +679,10 @@ router.get('/logs/export', async (req, res) => { log.operator, log.reason || '', log.notes || '', + log.isConsumableDeleted ? '已删除' : '正常', + snapshot.category || '', + snapshot.unit || '', + snapshot.unitPrice || '', dayjs(log.createdAt).format('YYYY-MM-DD HH:mm:ss'), dayjs(log.updatedAt).format('YYYY-MM-DD HH:mm:ss') ].map(v => `"${String(v).replace(/"/g, '""')}"`).join(','); @@ -754,7 +797,14 @@ router.put('/:id', async (req, res) => { currentStock: consumable.currentStock, operator: req.body.operator || req.body.operatorName || '系统', reason: '信息更新', - notes: `更新字段: ${Object.keys(req.body).join(', ')}` + notes: `更新字段: ${Object.keys(req.body).join(', ')}`, + consumableSnapshot: { + category: consumable.category, + unit: consumable.unit, + unitPrice: consumable.unitPrice, + supplier: consumable.supplier, + location: consumable.location + } }, { transaction }); await transaction.commit(); @@ -777,17 +827,51 @@ router.delete('/:id', async (req, res) => { const consumableId = consumable.consumableId; const consumableName = consumable.name; const currentStock = consumable.currentStock; + const operator = req.body.operator || req.query.operator || '系统'; + + const consumableSnapshot = { + category: consumable.category, + unit: consumable.unit, + unitPrice: consumable.unitPrice, + supplier: consumable.supplier, + location: consumable.location, + description: consumable.description, + minStock: consumable.minStock, + maxStock: consumable.maxStock, + status: consumable.status + }; + + await ConsumableLog.create({ + consumableId, + consumableName, + operationType: 'delete', + quantity: -currentStock, + previousStock: currentStock, + currentStock: 0, + operator, + reason: '删除耗材', + notes: `删除耗材:${consumableName},删除时库存为 ${currentStock}`, + isEditable: false, + isConsumableDeleted: true, + consumableSnapshot + }, { transaction }); + + await ConsumableLog.update( + { + isConsumableDeleted: true, + consumableSnapshot + }, + { + where: { consumableId }, + transaction + } + ); await ConsumableRecord.destroy({ where: { consumableId }, transaction }); - await ConsumableLog.destroy({ - where: { consumableId }, - transaction - }); - await consumable.destroy({ transaction }); await transaction.commit(); diff --git a/backend/scripts/migrate-consumable-log-decouple.js b/backend/scripts/migrate-consumable-log-decouple.js new file mode 100644 index 0000000..44e1bc9 --- /dev/null +++ b/backend/scripts/migrate-consumable-log-decouple.js @@ -0,0 +1,167 @@ +/** + * 耗材操作日志解耦迁移脚本 + * 添加 isConsumableDeleted 和 consumableSnapshot 字段 + * 实现耗材删除后日志仍可保留 + */ + +const { sequelize, DB_TYPE } = require('../db'); +const Consumable = require('../models/Consumable'); +const ConsumableLog = require('../models/ConsumableLog'); + +async function migrate() { + try { + console.log('开始迁移耗材操作日志解耦...'); + console.log(`数据库类型: ${DB_TYPE}`); + + const isMySQL = DB_TYPE === 'mysql'; + + if (isMySQL) { + await migrateMySQL(); + } else { + await migrateSQLite(); + } + + await updateExistingLogs(); + + console.log('\n迁移完成!耗材删除后日志将被保留。'); + process.exit(0); + } catch (error) { + console.error('迁移失败:', error); + process.exit(1); + } +} + +async function migrateMySQL() { + console.log('\n执行 MySQL 迁移...'); + + try { + await sequelize.query(` + ALTER TABLE consumable_logs + ADD COLUMN isConsumableDeleted TINYINT(1) DEFAULT 0 COMMENT '关联耗材是否已被删除' + `); + console.log('✓ 添加 isConsumableDeleted 字段成功'); + } catch (err) { + if (err.message.includes('Duplicate column')) { + console.log('✓ isConsumableDeleted 字段已存在'); + } else { + throw err; + } + } + + try { + await sequelize.query(` + ALTER TABLE consumable_logs + ADD COLUMN consumableSnapshot JSON DEFAULT NULL COMMENT '耗材快照信息' + `); + console.log('✓ 添加 consumableSnapshot 字段成功'); + } catch (err) { + if (err.message.includes('Duplicate column')) { + console.log('✓ consumableSnapshot 字段已存在'); + } else { + throw err; + } + } + + try { + await sequelize.query(` + CREATE INDEX idx_consumable_logs_is_deleted ON consumable_logs(isConsumableDeleted) + `); + console.log('✓ 创建 isConsumableDeleted 索引成功'); + } catch (err) { + if (err.message.includes('Duplicate key name')) { + console.log('✓ isConsumableDeleted 索引已存在'); + } else { + console.log('! isConsumableDeleted 索引创建失败:', err.message); + } + } +} + +async function migrateSQLite() { + console.log('\n执行 SQLite 迁移...'); + + try { + await sequelize.query(` + ALTER TABLE consumable_logs ADD COLUMN isConsumableDeleted BOOLEAN DEFAULT 0 + `); + console.log('✓ 添加 isConsumableDeleted 字段成功'); + } catch (err) { + if (err.message.includes('duplicate column name')) { + console.log('✓ isConsumableDeleted 字段已存在'); + } else { + throw err; + } + } + + try { + await sequelize.query(` + ALTER TABLE consumable_logs ADD COLUMN consumableSnapshot TEXT + `); + console.log('✓ 添加 consumableSnapshot 字段成功'); + } catch (err) { + if (err.message.includes('duplicate column name')) { + console.log('✓ consumableSnapshot 字段已存在'); + } else { + throw err; + } + } + + try { + await sequelize.query(` + CREATE INDEX IF NOT EXISTS idx_consumable_logs_is_deleted ON consumable_logs(isConsumableDeleted) + `); + console.log('✓ 创建 isConsumableDeleted 索引成功'); + } catch (err) { + console.log('! isConsumableDeleted 索引创建失败:', err.message); + } +} + +async function updateExistingLogs() { + console.log('\n更新现有日志数据...'); + + try { + const consumables = await Consumable.findAll({ + attributes: ['consumableId', 'category', 'unit', 'unitPrice', 'supplier', 'location', 'minStock', 'maxStock', 'status'] + }); + + const consumableMap = new Map(); + consumables.forEach(c => { + consumableMap.set(c.consumableId, { + category: c.category, + unit: c.unit, + unitPrice: c.unitPrice, + supplier: c.supplier, + location: c.location, + minStock: c.minStock, + maxStock: c.maxStock, + status: c.status + }); + }); + + const logs = await ConsumableLog.findAll({ + where: { + consumableSnapshot: null + } + }); + + let updatedCount = 0; + for (const log of logs) { + const snapshot = consumableMap.get(log.consumableId); + if (snapshot) { + await log.update({ consumableSnapshot: snapshot }); + updatedCount++; + } + } + + console.log(`✓ 更新了 ${updatedCount} 条日志的快照信息`); + + const deletedLogsCount = await ConsumableLog.count({ + where: { operationType: 'delete' } + }); + console.log(`✓ 当前有 ${deletedLogsCount} 条删除类型日志`); + + } catch (error) { + console.log('! 更新现有日志数据时出错:', error.message); + } +} + +migrate(); diff --git a/frontend/src/pages/ConsumableLogs.jsx b/frontend/src/pages/ConsumableLogs.jsx index 8b4c4f6..83e0df9 100644 --- a/frontend/src/pages/ConsumableLogs.jsx +++ b/frontend/src/pages/ConsumableLogs.jsx @@ -121,13 +121,39 @@ function ConsumableLogs() { dataIndex: 'consumableId', key: 'consumableId', width: 150, - render: value => {value}, + render: (value, record) => ( + + {value} + {record.isConsumableDeleted && ( + + 已删除 + + )} + + ), }, { title: '耗材名称', dataIndex: 'consumableName', key: 'consumableName', width: 150, + render: (value, record) => ( + +
分类: {record.consumableSnapshot.category || '-'}
+
单位: {record.consumableSnapshot.unit || '-'}
+
单价: {record.consumableSnapshot.unitPrice || '-'}
+
供应商: {record.consumableSnapshot.supplier || '-'}
+
位置: {record.consumableSnapshot.location || '-'}
+ + ) : null + } + > + {value} +
+ ), }, { title: '操作类型', @@ -277,6 +303,10 @@ function ConsumableLogs() { 操作人: log.operator, 原因: log.reason || '', 备注: log.notes || '', + 耗材状态: log.isConsumableDeleted ? '已删除' : '正常', + 分类: log.consumableSnapshot?.category || '', + 单位: log.consumableSnapshot?.unit || '', + 单价: log.consumableSnapshot?.unitPrice || '', })); const ws = XLSX.utils.json_to_sheet(exportData); @@ -646,6 +676,7 @@ function ConsumableLogs() {

耗材: {item.consumableName} ({item.consumableId}) + {item.isConsumableDeleted && 已删除}

操作人: {item.operator} @@ -660,6 +691,13 @@ function ConsumableLogs() { 备注: {item.notes}

)} + {item.consumableSnapshot && ( +

+ 快照信息: 分类:{item.consumableSnapshot.category || '-'} | + 单位:{item.consumableSnapshot.unit || '-'} | + 单价:{item.consumableSnapshot.unitPrice || '-'} +

+ )} {item.modifiedBy && ( <>