From 8a0c85e50235fc174ecba82396b753c499317845 Mon Sep 17 00:00:00 2001
From: zhang1106 <849185023@qq.com>
Date: Tue, 24 Feb 2026 13:20:26 +0800
Subject: [PATCH] =?UTF-8?q?feat(=E8=80=97=E6=9D=90=E7=AE=A1=E7=90=86):=20?=
=?UTF-8?q?=E5=AE=9E=E7=8E=B0=E8=80=97=E6=9D=90=E6=97=A5=E5=BF=97=E5=BD=92?=
=?UTF-8?q?=E6=A1=A3=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/models/ConsumableLogArchive.js | 87 ++++++++++
backend/routes/consumables.js | 115 +++++++++++--
.../scripts/migrate-consumable-log-archive.js | 126 ++++++++++++++
backend/scripts/remove-consumable-log-fk.js | 155 ++++++++++++++++++
frontend/src/pages/ConsumableLogs.jsx | 143 ++++++++++++++--
5 files changed, 595 insertions(+), 31 deletions(-)
create mode 100644 backend/models/ConsumableLogArchive.js
create mode 100644 backend/scripts/migrate-consumable-log-archive.js
create mode 100644 backend/scripts/remove-consumable-log-fk.js
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 && (
- {value},
},
{
title: '耗材名称',
dataIndex: 'consumableName',
key: 'consumableName',
- width: 150,
+ width: 180,
render: (value, record) => (
- 快照信息: 分类:{item.consumableSnapshot.category || '-'} | - 单位:{item.consumableSnapshot.unit || '-'} | + 快照信息: 分类:{item.consumableSnapshot.category || '-'} | + 单位:{item.consumableSnapshot.unit || '-'} | 单价:{item.consumableSnapshot.unitPrice || '-'}
)} @@ -720,6 +766,67 @@ function ConsumableLogs() { )} + + {/* 归档详情弹窗 */} +归档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.category || '-'}
+单位: {currentArchive.consumableSnapshot.unit || '-'}
+单价: {currentArchive.consumableSnapshot.unitPrice || '-'}
+供应商: {currentArchive.consumableSnapshot.supplier || '-'}
+位置: {currentArchive.consumableSnapshot.location || '-'}
+最小库存: {currentArchive.consumableSnapshot.minStock || '-'}
+最大库存: {currentArchive.consumableSnapshot.maxStock || '-'}
+