feat(耗材管理): 实现耗材日志归档功能
This commit is contained in:
@@ -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;
|
||||||
+100
-11
@@ -6,6 +6,7 @@ const dayjs = require('dayjs');
|
|||||||
const Consumable = require('../models/Consumable');
|
const Consumable = require('../models/Consumable');
|
||||||
const ConsumableRecord = require('../models/ConsumableRecord');
|
const ConsumableRecord = require('../models/ConsumableRecord');
|
||||||
const ConsumableLog = require('../models/ConsumableLog');
|
const ConsumableLog = require('../models/ConsumableLog');
|
||||||
|
const ConsumableLogArchive = require('../models/ConsumableLogArchive');
|
||||||
|
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -841,6 +842,41 @@ router.delete('/:id', async (req, res) => {
|
|||||||
status: consumable.status
|
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({
|
await ConsumableLog.create({
|
||||||
consumableId,
|
consumableId,
|
||||||
consumableName,
|
consumableName,
|
||||||
@@ -850,22 +886,18 @@ router.delete('/:id', async (req, res) => {
|
|||||||
currentStock: 0,
|
currentStock: 0,
|
||||||
operator,
|
operator,
|
||||||
reason: '删除耗材',
|
reason: '删除耗材',
|
||||||
notes: `删除耗材:${consumableName},删除时库存为 ${currentStock}`,
|
notes: `删除耗材:${consumableName},共${totalOperations}条操作记录已归档(归档ID: ${archiveId})`,
|
||||||
isEditable: false,
|
isEditable: false,
|
||||||
isConsumableDeleted: true,
|
isConsumableDeleted: true,
|
||||||
consumableSnapshot
|
consumableSnapshot,
|
||||||
|
relatedId: archiveId // 关联归档ID
|
||||||
}, { transaction });
|
}, { transaction });
|
||||||
|
|
||||||
await ConsumableLog.update(
|
// 删除原日志记录(已归档)
|
||||||
{
|
await ConsumableLog.destroy({
|
||||||
isConsumableDeleted: true,
|
|
||||||
consumableSnapshot
|
|
||||||
},
|
|
||||||
{
|
|
||||||
where: { consumableId },
|
where: { consumableId },
|
||||||
transaction
|
transaction
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
await ConsumableRecord.destroy({
|
await ConsumableRecord.destroy({
|
||||||
where: { consumableId },
|
where: { consumableId },
|
||||||
@@ -875,13 +907,70 @@ router.delete('/:id', async (req, res) => {
|
|||||||
await consumable.destroy({ transaction });
|
await consumable.destroy({ transaction });
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
res.json({ message: '删除成功' });
|
res.json({
|
||||||
|
message: '删除成功',
|
||||||
|
archiveId,
|
||||||
|
archivedLogs: totalOperations
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
res.status(500).json({ error: error.message });
|
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) => {
|
router.put('/logs/:id', async (req, res) => {
|
||||||
const transaction = await sequelize.transaction();
|
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();
|
||||||
@@ -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();
|
||||||
@@ -16,6 +16,10 @@ import {
|
|||||||
Form,
|
Form,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Timeline,
|
Timeline,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Statistic,
|
||||||
|
Divider,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
HistoryOutlined,
|
HistoryOutlined,
|
||||||
@@ -57,6 +61,11 @@ function ConsumableLogs() {
|
|||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const fileInputRef = useRef(null);
|
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) => {
|
const fetchLogs = async (page = 1, pageSize = 10, currentFilters = filters) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -121,22 +130,13 @@ function ConsumableLogs() {
|
|||||||
dataIndex: 'consumableId',
|
dataIndex: 'consumableId',
|
||||||
key: 'consumableId',
|
key: 'consumableId',
|
||||||
width: 150,
|
width: 150,
|
||||||
render: (value, record) => (
|
render: value => <code>{value}</code>,
|
||||||
<Space>
|
|
||||||
<code>{value}</code>
|
|
||||||
{record.isConsumableDeleted && (
|
|
||||||
<Tooltip title="该耗材已被删除">
|
|
||||||
<Tag color="red">已删除</Tag>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '耗材名称',
|
title: '耗材名称',
|
||||||
dataIndex: 'consumableName',
|
dataIndex: 'consumableName',
|
||||||
key: 'consumableName',
|
key: 'consumableName',
|
||||||
width: 150,
|
width: 180,
|
||||||
render: (value, record) => (
|
render: (value, record) => (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={
|
title={
|
||||||
@@ -151,7 +151,12 @@ function ConsumableLogs() {
|
|||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
<span>{value}</span>
|
<span>{value}</span>
|
||||||
|
{record.isConsumableDeleted && (
|
||||||
|
<Tag color="red" size="small">已删除</Tag>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -159,8 +164,23 @@ function ConsumableLogs() {
|
|||||||
title: '操作类型',
|
title: '操作类型',
|
||||||
dataIndex: 'operationType',
|
dataIndex: 'operationType',
|
||||||
key: 'operationType',
|
key: 'operationType',
|
||||||
width: 100,
|
width: 120,
|
||||||
render: type => getOperationTag(type),
|
render: (type, record) => (
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
|
{getOperationTag(type)}
|
||||||
|
{type === 'delete' && record.relatedId && (
|
||||||
|
<Tooltip title="点击查看归档详情">
|
||||||
|
<Tag
|
||||||
|
color="blue"
|
||||||
|
style={{ cursor: 'pointer', fontSize: '11px' }}
|
||||||
|
onClick={() => handleViewArchive(record.relatedId)}
|
||||||
|
>
|
||||||
|
已归档
|
||||||
|
</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '变动数量',
|
title: '变动数量',
|
||||||
@@ -209,8 +229,19 @@ function ConsumableLogs() {
|
|||||||
dataIndex: 'notes',
|
dataIndex: 'notes',
|
||||||
key: 'notes',
|
key: 'notes',
|
||||||
width: 200,
|
width: 200,
|
||||||
render: value => value || '-',
|
render: value => (
|
||||||
ellipsis: true,
|
<Tooltip title={value || '-'}>
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
maxWidth: '180px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}>
|
||||||
|
{value || '-'}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
@@ -430,6 +461,21 @@ function ConsumableLogs() {
|
|||||||
setEditModalVisible(true);
|
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 => {
|
const handleEditSubmit = async values => {
|
||||||
if (!currentLog) return;
|
if (!currentLog) return;
|
||||||
@@ -720,6 +766,67 @@ function ConsumableLogs() {
|
|||||||
</Timeline>
|
</Timeline>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* 归档详情弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title="归档记录详情"
|
||||||
|
open={archiveModalVisible}
|
||||||
|
onCancel={() => {
|
||||||
|
setArchiveModalVisible(false);
|
||||||
|
setCurrentArchive(null);
|
||||||
|
}}
|
||||||
|
footer={null}
|
||||||
|
width={600}
|
||||||
|
>
|
||||||
|
{archiveLoading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px' }}>加载中...</div>
|
||||||
|
) : !currentArchive ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px', color: '#888' }}>
|
||||||
|
无法获取归档信息
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<Card size="small" title="基本信息" style={{ marginBottom: 16 }}>
|
||||||
|
<p><strong>归档ID:</strong> <code>{currentArchive.archiveId}</code></p>
|
||||||
|
<p><strong>耗材ID:</strong> <code>{currentArchive.consumableId}</code></p>
|
||||||
|
<p><strong>耗材名称:</strong> {currentArchive.consumableName}</p>
|
||||||
|
<p><strong>删除人:</strong> {currentArchive.deletedBy}</p>
|
||||||
|
<p><strong>删除时间:</strong> {dayjs(currentArchive.deletedAt).format('YYYY-MM-DD HH:mm:ss')}</p>
|
||||||
|
<p><strong>删除原因:</strong> {currentArchive.deleteReason || '-'}</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card size="small" title="操作统计" style={{ marginBottom: 16 }}>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={8}>
|
||||||
|
<Statistic title="总操作数" value={currentArchive.totalOperations} />
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Statistic title="总入库" value={currentArchive.totalInQuantity} />
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Statistic title="总出库" value={currentArchive.totalOutQuantity} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Divider style={{ margin: '12px 0' }} />
|
||||||
|
<p><strong>首次操作:</strong> {currentArchive.firstOperationAt ? dayjs(currentArchive.firstOperationAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</p>
|
||||||
|
<p><strong>最后操作:</strong> {currentArchive.lastOperationAt ? dayjs(currentArchive.lastOperationAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</p>
|
||||||
|
<p><strong>删除时库存:</strong> {currentArchive.finalStock}</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{currentArchive.consumableSnapshot && (
|
||||||
|
<Card size="small" title="耗材快照">
|
||||||
|
<p><strong>分类:</strong> {currentArchive.consumableSnapshot.category || '-'}</p>
|
||||||
|
<p><strong>单位:</strong> {currentArchive.consumableSnapshot.unit || '-'}</p>
|
||||||
|
<p><strong>单价:</strong> {currentArchive.consumableSnapshot.unitPrice || '-'}</p>
|
||||||
|
<p><strong>供应商:</strong> {currentArchive.consumableSnapshot.supplier || '-'}</p>
|
||||||
|
<p><strong>位置:</strong> {currentArchive.consumableSnapshot.location || '-'}</p>
|
||||||
|
<p><strong>最小库存:</strong> {currentArchive.consumableSnapshot.minStock || '-'}</p>
|
||||||
|
<p><strong>最大库存:</strong> {currentArchive.consumableSnapshot.maxStock || '-'}</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user