feat(耗材管理): 实现耗材日志归档功能

This commit is contained in:
zhang1106
2026-02-24 13:20:26 +08:00
parent b810e2cca1
commit 8a0c85e502
5 changed files with 595 additions and 31 deletions
+87
View File
@@ -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;
+102 -13
View File
@@ -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();
@@ -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();
+155
View File
@@ -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();