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 && (
+
耗材: {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 && ( <>