diff --git a/backend/models/ConsumableLogArchive.js b/backend/models/ConsumableLogArchive.js new file mode 100644 index 0000000..25f39ea --- /dev/null +++ b/backend/models/ConsumableLogArchive.js @@ -0,0 +1,87 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../db'); + +/** + * 耗材操作日志归档表 + * 用于存储被删除耗材的历史操作记录 + */ +const ConsumableLogArchive = sequelize.define('ConsumableLogArchive', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + archiveId: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + comment: '归档记录唯一标识' + }, + consumableId: { + type: DataTypes.STRING, + allowNull: false, + comment: '被删除的耗材ID' + }, + consumableName: { + type: DataTypes.STRING, + allowNull: false, + comment: '耗材名称' + }, + consumableSnapshot: { + type: DataTypes.JSON, + allowNull: true, + comment: '耗材快照信息' + }, + totalOperations: { + type: DataTypes.INTEGER, + defaultValue: 0, + comment: '操作记录总数' + }, + firstOperationAt: { + type: DataTypes.DATE, + comment: '首次操作时间' + }, + lastOperationAt: { + type: DataTypes.DATE, + comment: '最后操作时间' + }, + totalInQuantity: { + type: DataTypes.INTEGER, + defaultValue: 0, + comment: '总入库数量' + }, + totalOutQuantity: { + type: DataTypes.INTEGER, + defaultValue: 0, + comment: '总出库数量' + }, + finalStock: { + type: DataTypes.INTEGER, + defaultValue: 0, + comment: '删除时库存' + }, + deletedBy: { + type: DataTypes.STRING, + comment: '删除人' + }, + deletedAt: { + type: DataTypes.DATE, + comment: '删除时间' + }, + deleteReason: { + type: DataTypes.STRING, + comment: '删除原因' + } +}, { + tableName: 'consumable_log_archives', + timestamps: true, + comment: '耗材操作日志归档表', + indexes: [ + { fields: ['consumableId'] }, + { fields: ['archiveId'] }, + { fields: ['deletedAt'] }, + { fields: ['consumableId', 'deletedAt'] } + ] +}); + +module.exports = ConsumableLogArchive; diff --git a/backend/routes/consumables.js b/backend/routes/consumables.js index 2de8599..c15f598 100644 --- a/backend/routes/consumables.js +++ b/backend/routes/consumables.js @@ -6,6 +6,7 @@ const dayjs = require('dayjs'); const Consumable = require('../models/Consumable'); const ConsumableRecord = require('../models/ConsumableRecord'); const ConsumableLog = require('../models/ConsumableLog'); +const ConsumableLogArchive = require('../models/ConsumableLogArchive'); router.get('/', async (req, res) => { try { @@ -841,6 +842,41 @@ router.delete('/:id', async (req, res) => { status: consumable.status }; + // 查询该耗材的所有操作日志 + const logs = await ConsumableLog.findAll({ + where: { consumableId }, + order: [['createdAt', 'ASC']], + transaction + }); + + // 计算统计数据 + const totalOperations = logs.length; + const totalInQuantity = logs + .filter(l => l.operationType === 'in') + .reduce((sum, l) => sum + (l.quantity || 0), 0); + const totalOutQuantity = logs + .filter(l => l.operationType === 'out') + .reduce((sum, l) => sum + Math.abs(l.quantity || 0), 0); + + // 创建归档记录 + const archiveId = `ARC${Date.now()}`; + await ConsumableLogArchive.create({ + archiveId, + consumableId, + consumableName, + consumableSnapshot, + totalOperations, + firstOperationAt: logs.length > 0 ? logs[0].createdAt : null, + lastOperationAt: logs.length > 0 ? logs[logs.length - 1].createdAt : null, + totalInQuantity, + totalOutQuantity, + finalStock: currentStock, + deletedBy: operator, + deletedAt: new Date(), + deleteReason: req.body.reason || '删除耗材' + }, { transaction }); + + // 创建一条汇总日志(用于在日志列表中显示) await ConsumableLog.create({ consumableId, consumableName, @@ -850,22 +886,18 @@ router.delete('/:id', async (req, res) => { currentStock: 0, operator, reason: '删除耗材', - notes: `删除耗材:${consumableName},删除时库存为 ${currentStock}`, + notes: `删除耗材:${consumableName},共${totalOperations}条操作记录已归档(归档ID: ${archiveId})`, isEditable: false, isConsumableDeleted: true, - consumableSnapshot + consumableSnapshot, + relatedId: archiveId // 关联归档ID }, { transaction }); - await ConsumableLog.update( - { - isConsumableDeleted: true, - consumableSnapshot - }, - { - where: { consumableId }, - transaction - } - ); + // 删除原日志记录(已归档) + await ConsumableLog.destroy({ + where: { consumableId }, + transaction + }); await ConsumableRecord.destroy({ where: { consumableId }, @@ -875,13 +907,70 @@ router.delete('/:id', async (req, res) => { await consumable.destroy({ transaction }); await transaction.commit(); - res.json({ message: '删除成功' }); + res.json({ + message: '删除成功', + archiveId, + archivedLogs: totalOperations + }); } catch (error) { await transaction.rollback(); res.status(500).json({ error: error.message }); } }); +// 查询归档记录列表 +router.get('/archives', async (req, res) => { + try { + const { keyword, page = 1, pageSize = 10 } = req.query; + const offset = (page - 1) * pageSize; + + const where = {}; + + if (keyword) { + where[Op.or] = [ + { consumableId: { [Op.like]: `%${keyword}%` } }, + { consumableName: { [Op.like]: `%${keyword}%` } }, + { archiveId: { [Op.like]: `%${keyword}%` } } + ]; + } + + const { count, rows } = await ConsumableLogArchive.findAndCountAll({ + where, + offset, + limit: parseInt(pageSize), + order: [['deletedAt', 'DESC']] + }); + + res.json({ + total: count, + archives: rows, + page: parseInt(page), + pageSize: parseInt(pageSize) + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 查询单个归档记录详情 +router.get('/archives/:archiveId', async (req, res) => { + try { + const { archiveId } = req.params; + + const archive = await ConsumableLogArchive.findOne({ + where: { archiveId } + }); + + if (!archive) { + return res.status(404).json({ error: '归档记录不存在' }); + } + + res.json(archive); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + // 修改日志记录 router.put('/logs/:id', async (req, res) => { const transaction = await sequelize.transaction(); diff --git a/backend/scripts/migrate-consumable-log-archive.js b/backend/scripts/migrate-consumable-log-archive.js new file mode 100644 index 0000000..f229830 --- /dev/null +++ b/backend/scripts/migrate-consumable-log-archive.js @@ -0,0 +1,126 @@ +/** + * 耗材操作日志归档表迁移脚本 + * 创建归档表用于存储被删除耗材的历史操作记录 + */ + +const { sequelize, DB_TYPE } = require('../db'); + +async function migrate() { + try { + console.log('开始创建耗材操作日志归档表...'); + console.log(`数据库类型: ${DB_TYPE}`); + + if (DB_TYPE === 'mysql') { + await migrateMySQL(); + } else { + await migrateSQLite(); + } + + 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(` + CREATE TABLE IF NOT EXISTS consumable_log_archives ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + archiveId VARCHAR(255) NOT NULL UNIQUE COMMENT '归档记录唯一标识', + consumableId VARCHAR(255) NOT NULL COMMENT '被删除的耗材ID', + consumableName VARCHAR(255) NOT NULL COMMENT '耗材名称', + consumableSnapshot JSON DEFAULT NULL COMMENT '耗材快照信息', + totalOperations INTEGER DEFAULT 0 COMMENT '操作记录总数', + firstOperationAt DATETIME COMMENT '首次操作时间', + lastOperationAt DATETIME COMMENT '最后操作时间', + totalInQuantity INTEGER DEFAULT 0 COMMENT '总入库数量', + totalOutQuantity INTEGER DEFAULT 0 COMMENT '总出库数量', + finalStock INTEGER DEFAULT 0 COMMENT '删除时库存', + deletedBy VARCHAR(255) COMMENT '删除人', + deletedAt DATETIME COMMENT '删除时间', + deleteReason VARCHAR(255) COMMENT '删除原因', + createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_consumable_id (consumableId), + INDEX idx_archive_id (archiveId), + INDEX idx_deleted_at (deletedAt), + INDEX idx_consumable_deleted (consumableId, deletedAt) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='耗材操作日志归档表' + `); + console.log('✓ 创建归档表成功'); + } catch (err) { + if (err.message.includes('already exists')) { + console.log('✓ 归档表已存在'); + } else { + throw err; + } + } +} + +async function migrateSQLite() { + console.log('\n执行 SQLite 迁移...'); + + try { + // 检查表是否已存在 + const tables = await sequelize.query( + `SELECT name FROM sqlite_master WHERE type='table' AND name='consumable_log_archives'`, + { type: sequelize.QueryTypes.SELECT } + ); + + if (tables && tables.length > 0) { + console.log('✓ 归档表已存在'); + return; + } + + await sequelize.query(` + CREATE TABLE consumable_log_archives ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + archiveId VARCHAR(255) NOT NULL UNIQUE, + consumableId VARCHAR(255) NOT NULL, + consumableName VARCHAR(255) NOT NULL, + consumableSnapshot TEXT, + totalOperations INTEGER DEFAULT 0, + firstOperationAt DATETIME, + lastOperationAt DATETIME, + totalInQuantity INTEGER DEFAULT 0, + totalOutQuantity INTEGER DEFAULT 0, + finalStock INTEGER DEFAULT 0, + deletedBy VARCHAR(255), + deletedAt DATETIME, + deleteReason VARCHAR(255), + createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `); + console.log('✓ 创建归档表成功'); + + // 创建索引 + const indexes = [ + { name: 'idx_archive_consumable_id', fields: 'consumableId' }, + { name: 'idx_archive_archive_id', fields: 'archiveId' }, + { name: 'idx_archive_deleted_at', fields: 'deletedAt' }, + { name: 'idx_archive_consumable_deleted', fields: 'consumableId, deletedAt' } + ]; + + for (const idx of indexes) { + try { + await sequelize.query( + `CREATE INDEX ${idx.name} ON consumable_log_archives(${idx.fields})` + ); + console.log(`✓ 创建索引: ${idx.name}`); + } catch (err) { + console.log(`! 创建索引失败: ${idx.name}`, err.message); + } + } + } catch (err) { + console.error('创建归档表失败:', err); + throw err; + } +} + +migrate(); diff --git a/backend/scripts/remove-consumable-log-fk.js b/backend/scripts/remove-consumable-log-fk.js new file mode 100644 index 0000000..c970535 --- /dev/null +++ b/backend/scripts/remove-consumable-log-fk.js @@ -0,0 +1,155 @@ +/** + * 移除 consumable_logs 表的外键约束 + * 解决删除耗材时日志被级联删除的问题 + */ + +const { sequelize, DB_TYPE } = require('../db'); + +async function removeForeignKey() { + try { + console.log('开始移除 consumable_logs 表的外键约束...'); + console.log(`数据库类型: ${DB_TYPE}`); + + if (DB_TYPE === 'mysql') { + await removeMySQLForeignKey(); + } else { + await removeSQLiteForeignKey(); + } + + console.log('\n外键约束移除完成!'); + process.exit(0); + } catch (error) { + console.error('移除外键约束失败:', error); + process.exit(1); + } +} + +async function removeMySQLForeignKey() { + console.log('\n执行 MySQL 外键移除...'); + + // 获取外键名称 + const [fks] = await sequelize.query(` + SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE TABLE_NAME = 'consumable_logs' + AND TABLE_SCHEMA = DATABASE() + AND REFERENCED_TABLE_NAME IS NOT NULL + `); + + console.log('发现外键约束:', fks); + + for (const fk of fks) { + try { + await sequelize.query(` + ALTER TABLE consumable_logs DROP FOREIGN KEY ${fk.CONSTRAINT_NAME} + `); + console.log(`✓ 移除外键约束: ${fk.CONSTRAINT_NAME}`); + } catch (err) { + console.log(`! 移除外键约束失败: ${fk.CONSTRAINT_NAME}`, err.message); + } + } +} + +async function removeSQLiteForeignKey() { + console.log('\n执行 SQLite 外键移除...'); + console.log('SQLite 不支持直接删除外键,需要重建表'); + + // 检查外键是否存在 + const fks = await sequelize.query( + `PRAGMA foreign_key_list(consumable_logs);`, + { type: sequelize.QueryTypes.SELECT } + ); + + if (!fks || fks.length === 0) { + console.log('✓ 没有外键约束需要移除'); + return; + } + + console.log('发现外键约束:', fks); + + // SQLite 不支持 ALTER TABLE DROP FOREIGN KEY,需要重建表 + await sequelize.transaction(async (transaction) => { + // 获取表结构 + const columns = await sequelize.query( + `PRAGMA table_info(consumable_logs);`, + { type: sequelize.QueryTypes.SELECT, transaction } + ); + + console.log('\n当前表列:', columns.map(c => c.name)); + + // 创建新表(不包含外键约束) + await sequelize.query(` + CREATE TABLE consumable_logs_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + consumableId VARCHAR(255) NOT NULL, + consumableName VARCHAR(255) NOT NULL, + operationType TEXT NOT NULL, + quantity INTEGER DEFAULT 0, + previousStock INTEGER NOT NULL, + currentStock INTEGER NOT NULL, + operator VARCHAR(255), + reason VARCHAR(255), + notes TEXT, + relatedId VARCHAR(255), + isEditable TINYINT(1) DEFAULT 1, + originalLogId INTEGER, + modifiedBy VARCHAR(255), + modifiedAt DATETIME, + modificationReason VARCHAR(255), + createdAt DATETIME NOT NULL, + updatedAt DATETIME NOT NULL, + isConsumableDeleted BOOLEAN DEFAULT 0, + consumableSnapshot TEXT + ) + `, { transaction }); + + console.log('✓ 创建新表成功'); + + // 复制数据 + await sequelize.query(` + INSERT INTO consumable_logs_new + SELECT * FROM consumable_logs + `, { transaction }); + + const countResult = await sequelize.query( + `SELECT COUNT(*) as count FROM consumable_logs_new`, + { type: sequelize.QueryTypes.SELECT, transaction } + ); + console.log(`✓ 复制了 ${countResult[0].count} 条数据`); + + // 删除旧表 + await sequelize.query(`DROP TABLE consumable_logs`, { transaction }); + console.log('✓ 删除旧表成功'); + + // 重命名新表 + await sequelize.query(`ALTER TABLE consumable_logs_new RENAME TO consumable_logs`, { transaction }); + console.log('✓ 重命名新表成功'); + + // 创建索引 + const indexes = [ + { name: 'consumable_logs_consumable_id', fields: ['consumableId'] }, + { name: 'consumable_logs_operation_type', fields: ['operationType'] }, + { name: 'consumable_logs_created_at', fields: ['createdAt'] }, + { name: 'consumable_logs_consumable_id_created_at', fields: ['consumableId', 'createdAt'] }, + { name: 'consumable_logs_original_log_id', fields: ['originalLogId'] }, + { name: 'consumable_logs_is_editable', fields: ['isEditable'] }, + { name: 'idx_consumable_logs_is_deleted', fields: ['isConsumableDeleted'] } + ]; + + for (const idx of indexes) { + try { + await sequelize.query( + `CREATE INDEX IF NOT EXISTS ${idx.name} ON consumable_logs(${idx.fields.join(', ')})`, + { transaction } + ); + console.log(`✓ 创建索引: ${idx.name}`); + } catch (err) { + console.log(`! 创建索引失败: ${idx.name}`, err.message); + } + } + }); + + console.log('\n✓ 表重建完成,外键约束已移除'); +} + +removeForeignKey(); diff --git a/frontend/src/pages/ConsumableLogs.jsx b/frontend/src/pages/ConsumableLogs.jsx index 83e0df9..857eaca 100644 --- a/frontend/src/pages/ConsumableLogs.jsx +++ b/frontend/src/pages/ConsumableLogs.jsx @@ -16,6 +16,10 @@ import { Form, Tooltip, Timeline, + Row, + Col, + Statistic, + Divider, } from 'antd'; import { HistoryOutlined, @@ -57,6 +61,11 @@ function ConsumableLogs() { const [form] = Form.useForm(); const fileInputRef = useRef(null); + // 归档详情弹窗状态 + const [archiveModalVisible, setArchiveModalVisible] = useState(false); + const [currentArchive, setCurrentArchive] = useState(null); + const [archiveLoading, setArchiveLoading] = useState(false); + const fetchLogs = async (page = 1, pageSize = 10, currentFilters = filters) => { try { setLoading(true); @@ -121,22 +130,13 @@ function ConsumableLogs() { dataIndex: 'consumableId', key: 'consumableId', width: 150, - render: (value, record) => ( - - {value} - {record.isConsumableDeleted && ( - - 已删除 - - )} - - ), + render: value => {value}, }, { title: '耗材名称', dataIndex: 'consumableName', key: 'consumableName', - width: 150, + width: 180, render: (value, record) => ( - {value} + + {value} + {record.isConsumableDeleted && ( + 已删除 + )} + ), }, @@ -159,8 +164,23 @@ function ConsumableLogs() { title: '操作类型', dataIndex: 'operationType', key: 'operationType', - width: 100, - render: type => getOperationTag(type), + width: 120, + render: (type, record) => ( + + {getOperationTag(type)} + {type === 'delete' && record.relatedId && ( + + handleViewArchive(record.relatedId)} + > + 已归档 + + + )} + + ), }, { title: '变动数量', @@ -209,8 +229,19 @@ function ConsumableLogs() { dataIndex: 'notes', key: 'notes', width: 200, - render: value => value || '-', - ellipsis: true, + render: value => ( + + + {value || '-'} + + + ), }, { title: '操作', @@ -430,6 +461,21 @@ function ConsumableLogs() { setEditModalVisible(true); }; + // 查看归档详情 + const handleViewArchive = async archiveId => { + setArchiveModalVisible(true); + setArchiveLoading(true); + try { + const response = await axios.get(`/api/consumables/archives/${archiveId}`); + setCurrentArchive(response.data); + } catch (error) { + message.error('获取归档详情失败'); + console.error('获取归档详情失败:', error); + } finally { + setArchiveLoading(false); + } + }; + // 提交编辑 const handleEditSubmit = async values => { if (!currentLog) return; @@ -693,8 +739,8 @@ function ConsumableLogs() { )} {item.consumableSnapshot && (

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

)} @@ -720,6 +766,67 @@ function ConsumableLogs() { )} + + {/* 归档详情弹窗 */} + { + setArchiveModalVisible(false); + setCurrentArchive(null); + }} + footer={null} + width={600} + > + {archiveLoading ? ( +
加载中...
+ ) : !currentArchive ? ( +
+ 无法获取归档信息 +
+ ) : ( +
+ +

归档ID: {currentArchive.archiveId}

+

耗材ID: {currentArchive.consumableId}

+

耗材名称: {currentArchive.consumableName}

+

删除人: {currentArchive.deletedBy}

+

删除时间: {dayjs(currentArchive.deletedAt).format('YYYY-MM-DD HH:mm:ss')}

+

删除原因: {currentArchive.deleteReason || '-'}

+
+ + + + + + + + + + + + + + +

首次操作: {currentArchive.firstOperationAt ? dayjs(currentArchive.firstOperationAt).format('YYYY-MM-DD HH:mm:ss') : '-'}

+

最后操作: {currentArchive.lastOperationAt ? dayjs(currentArchive.lastOperationAt).format('YYYY-MM-DD HH:mm:ss') : '-'}

+

删除时库存: {currentArchive.finalStock}

+
+ + {currentArchive.consumableSnapshot && ( + +

分类: {currentArchive.consumableSnapshot.category || '-'}

+

单位: {currentArchive.consumableSnapshot.unit || '-'}

+

单价: {currentArchive.consumableSnapshot.unitPrice || '-'}

+

供应商: {currentArchive.consumableSnapshot.supplier || '-'}

+

位置: {currentArchive.consumableSnapshot.location || '-'}

+

最小库存: {currentArchive.consumableSnapshot.minStock || '-'}

+

最大库存: {currentArchive.consumableSnapshot.maxStock || '-'}

+
+ )} +
+ )} +
); }