diff --git a/backend/models/Consumable.js b/backend/models/Consumable.js index 2cdaa1b..6d16a55 100644 --- a/backend/models/Consumable.js +++ b/backend/models/Consumable.js @@ -33,8 +33,9 @@ const Consumable = sequelize.define('Consumable', { }, maxStock: { type: DataTypes.INTEGER, - allowNull: false, - defaultValue: 100 + allowNull: true, + defaultValue: null, + comment: '最大库存,null表示无限制' }, unitPrice: { type: DataTypes.DECIMAL(10, 2), diff --git a/backend/models/ConsumableLog.js b/backend/models/ConsumableLog.js index c470f29..f0d6d95 100644 --- a/backend/models/ConsumableLog.js +++ b/backend/models/ConsumableLog.js @@ -53,6 +53,31 @@ const ConsumableLog = sequelize.define('ConsumableLog', { relatedId: { type: DataTypes.STRING, comment: '关联ID(如订单号、盘点ID等)' + }, + isEditable: { + type: DataTypes.BOOLEAN, + defaultValue: true, + comment: '是否可编辑(创建、导入的记录可编辑,系统生成的出入库记录不可编辑)' + }, + originalLogId: { + type: DataTypes.INTEGER, + allowNull: true, + comment: '原始日志ID(用于追踪修改历史链)' + }, + modifiedBy: { + type: DataTypes.STRING, + allowNull: true, + comment: '修改人' + }, + modifiedAt: { + type: DataTypes.DATE, + allowNull: true, + comment: '修改时间' + }, + modificationReason: { + type: DataTypes.STRING, + allowNull: true, + comment: '修改原因' } }, { tableName: 'consumable_logs', @@ -62,7 +87,9 @@ const ConsumableLog = sequelize.define('ConsumableLog', { { fields: ['consumableId'] }, { fields: ['operationType'] }, { fields: ['createdAt'] }, - { fields: ['consumableId', 'createdAt'] } + { fields: ['consumableId', 'createdAt'] }, + { fields: ['originalLogId'] }, + { fields: ['isEditable'] } ] }); diff --git a/backend/routes/consumables.js b/backend/routes/consumables.js index a558d4d..a0cf1fa 100644 --- a/backend/routes/consumables.js +++ b/backend/routes/consumables.js @@ -252,21 +252,21 @@ router.get('/inout/records', async (req, res) => { router.post('/quick-inout', async (req, res) => { const MAX_RETRIES = 3; let attempt = 0; - + while (attempt < MAX_RETRIES) { const transaction = await sequelize.transaction(); try { const { consumableId, type, quantity, operator, reason, notes } = req.body; - + const consumable = await Consumable.findByPk(consumableId, { transaction }); if (!consumable) { await transaction.rollback(); return res.status(404).json({ error: '耗材不存在' }); } - + const previousStock = parseFloat(consumable.currentStock); let newStock; - + if (type === 'in') { newStock = previousStock + parseFloat(quantity); } else if (type === 'out') { @@ -279,21 +279,21 @@ router.post('/quick-inout', async (req, res) => { await transaction.rollback(); return res.status(400).json({ error: '操作类型无效' }); } - + const [affectedRows] = await Consumable.update( - { + { currentStock: newStock, version: sequelize.literal('version + 1') }, - { - where: { + { + where: { consumableId, version: consumable.version }, - transaction + transaction } ); - + if (affectedRows === 0) { await transaction.rollback(); attempt++; @@ -302,7 +302,7 @@ router.post('/quick-inout', async (req, res) => { } continue; } - + const record = await ConsumableRecord.create({ consumableId, type, @@ -313,7 +313,8 @@ router.post('/quick-inout', async (req, res) => { reason, notes }, { transaction }); - + + // 系统生成的出入库记录不可编辑 await ConsumableLog.create({ consumableId, consumableName: consumable.name, @@ -323,11 +324,12 @@ router.post('/quick-inout', async (req, res) => { currentStock: newStock, operator, reason, - notes + notes, + isEditable: false }, { transaction }); - + await transaction.commit(); - + res.json({ message: '操作成功', record, @@ -407,6 +409,7 @@ router.post('/inout', async (req, res) => { notes }, { transaction }); + // 系统生成的出入库记录不可编辑 await ConsumableLog.create({ consumableId, consumableName: consumable.name, @@ -416,11 +419,12 @@ router.post('/inout', async (req, res) => { currentStock: newStock, operator, reason, - notes + notes, + isEditable: false }, { transaction }); - + await transaction.commit(); - + res.json({ message: '操作成功', record, @@ -499,6 +503,7 @@ router.post('/adjust', async (req, res) => { const changeQuantity = newStock - previousStock; + // 系统生成的调整记录不可编辑 await ConsumableLog.create({ consumableId, consumableName: consumable.name, @@ -508,7 +513,8 @@ router.post('/adjust', async (req, res) => { currentStock: newStock, operator, reason: reason || (adjustType === 'set' ? '库存调整为 ' + newStock : reason), - notes: adjustType === 'set' ? `库存从 ${previousStock} 调整为 ${newStock}` : notes + notes: adjustType === 'set' ? `库存从 ${previousStock} 调整为 ${newStock}` : notes, + isEditable: false }, { transaction }); await transaction.commit(); @@ -755,23 +761,23 @@ router.delete('/:id', async (req, res) => { await transaction.rollback(); return res.status(404).json({ error: '耗材不存在' }); } - + const consumableId = consumable.consumableId; const consumableName = consumable.name; const currentStock = consumable.currentStock; - + await ConsumableRecord.destroy({ where: { consumableId }, transaction }); - + await ConsumableLog.destroy({ where: { consumableId }, transaction }); - + await consumable.destroy({ transaction }); - + await transaction.commit(); res.json({ message: '删除成功' }); } catch (error) { @@ -780,4 +786,79 @@ router.delete('/:id', async (req, res) => { } }); +// 修改日志记录 +router.put('/logs/:id', async (req, res) => { + const transaction = await sequelize.transaction(); + try { + const { id } = req.params; + const { reason, notes, operator, modificationReason } = req.body; + + const log = await ConsumableLog.findByPk(id, { transaction }); + if (!log) { + await transaction.rollback(); + return res.status(404).json({ error: '日志记录不存在' }); + } + + // 检查是否可编辑 + if (!log.isEditable) { + await transaction.rollback(); + return res.status(403).json({ error: '该记录为系统自动生成,不可修改' }); + } + + // 保存原始日志ID(用于追踪修改历史) + const originalLogId = log.originalLogId || log.id; + + // 更新当前记录,并标记为已修改 + await log.update({ + reason: reason !== undefined ? reason : log.reason, + notes: notes !== undefined ? notes : log.notes, + modifiedBy: operator || '系统', + modifiedAt: new Date(), + modificationReason: modificationReason || '用户修改' + }, { transaction }); + + await transaction.commit(); + + res.json({ + message: '日志修改成功', + log: await ConsumableLog.findByPk(id) + }); + } catch (error) { + await transaction.rollback(); + res.status(500).json({ error: error.message }); + } +}); + +// 获取日志修改历史 +router.get('/logs/:id/history', async (req, res) => { + try { + const { id } = req.params; + + const log = await ConsumableLog.findByPk(id); + if (!log) { + return res.status(404).json({ error: '日志记录不存在' }); + } + + // 查询该日志的所有修改历史(包括原始记录) + const originalLogId = log.originalLogId || log.id; + + const history = await ConsumableLog.findAll({ + where: { + [Op.or]: [ + { id: originalLogId }, + { originalLogId: originalLogId } + ] + }, + order: [['createdAt', 'ASC']] + }); + + res.json({ + current: log, + history: history + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + module.exports = router; diff --git a/backend/scripts/migrate-consumable-logs.js b/backend/scripts/migrate-consumable-logs.js new file mode 100644 index 0000000..bae7f6c --- /dev/null +++ b/backend/scripts/migrate-consumable-logs.js @@ -0,0 +1,118 @@ +/** + * 耗材操作日志表结构迁移脚本 + * 添加修改记录相关字段 + */ + +const { sequelize } = require('../db'); + +async function migrate() { + try { + console.log('开始迁移耗材操作日志表...'); + + // 检查并添加 isEditable 字段 + try { + await sequelize.query(` + ALTER TABLE consumable_logs ADD COLUMN isEditable BOOLEAN DEFAULT 1 + `); + console.log('✓ 添加 isEditable 字段成功'); + } catch (err) { + if (err.message.includes('duplicate column name')) { + console.log('✓ isEditable 字段已存在'); + } else { + throw err; + } + } + + // 检查并添加 originalLogId 字段 + try { + await sequelize.query(` + ALTER TABLE consumable_logs ADD COLUMN originalLogId INTEGER + `); + console.log('✓ 添加 originalLogId 字段成功'); + } catch (err) { + if (err.message.includes('duplicate column name')) { + console.log('✓ originalLogId 字段已存在'); + } else { + throw err; + } + } + + // 检查并添加 modifiedBy 字段 + try { + await sequelize.query(` + ALTER TABLE consumable_logs ADD COLUMN modifiedBy VARCHAR(255) + `); + console.log('✓ 添加 modifiedBy 字段成功'); + } catch (err) { + if (err.message.includes('duplicate column name')) { + console.log('✓ modifiedBy 字段已存在'); + } else { + throw err; + } + } + + // 检查并添加 modifiedAt 字段 + try { + await sequelize.query(` + ALTER TABLE consumable_logs ADD COLUMN modifiedAt DATETIME + `); + console.log('✓ 添加 modifiedAt 字段成功'); + } catch (err) { + if (err.message.includes('duplicate column name')) { + console.log('✓ modifiedAt 字段已存在'); + } else { + throw err; + } + } + + // 检查并添加 modificationReason 字段 + try { + await sequelize.query(` + ALTER TABLE consumable_logs ADD COLUMN modificationReason VARCHAR(255) + `); + console.log('✓ 添加 modificationReason 字段成功'); + } catch (err) { + if (err.message.includes('duplicate column name')) { + console.log('✓ modificationReason 字段已存在'); + } else { + throw err; + } + } + + // 创建索引 + try { + await sequelize.query(` + CREATE INDEX IF NOT EXISTS idx_consumable_logs_original_log_id ON consumable_logs(originalLogId) + `); + console.log('✓ 创建 originalLogId 索引成功'); + } catch (err) { + console.log('! originalLogId 索引创建失败:', err.message); + } + + try { + await sequelize.query(` + CREATE INDEX IF NOT EXISTS idx_consumable_logs_is_editable ON consumable_logs(isEditable) + `); + console.log('✓ 创建 isEditable 索引成功'); + } catch (err) { + console.log('! isEditable 索引创建失败:', err.message); + } + + // 更新现有记录:将出入库、调整等系统生成的记录标记为不可编辑 + const [result] = await sequelize.query(` + UPDATE consumable_logs + SET isEditable = 0 + WHERE operationType IN ('in', 'out', 'adjust') + AND (isEditable IS NULL OR isEditable = 1) + `); + console.log(`✓ 更新 ${result.changes || 0} 条系统生成记录为不可编辑状态`); + + console.log('\n迁移完成!'); + process.exit(0); + } catch (error) { + console.error('迁移失败:', error); + process.exit(1); + } +} + +migrate(); diff --git a/frontend/index.html b/frontend/index.html index 9de6262..9d62953 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,8 +4,6 @@ IDC设备管理系统 - -
diff --git a/frontend/src/pages/ConsumableLogs.jsx b/frontend/src/pages/ConsumableLogs.jsx index 2ef2ca4..7eb3fa2 100644 --- a/frontend/src/pages/ConsumableLogs.jsx +++ b/frontend/src/pages/ConsumableLogs.jsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef } from 'react'; -import { Table, Card, Space, Select, DatePicker, Input, Tag, Button, message, Modal, Upload, Radio, Dropdown } from 'antd'; -import { HistoryOutlined, SearchOutlined, FileTextOutlined, DownloadOutlined, UploadOutlined, FileExcelOutlined, FileOutlined, DownOutlined } from '@ant-design/icons'; +import { Table, Card, Space, Select, DatePicker, Input, Tag, Button, message, Modal, Upload, Radio, Dropdown, Form, Tooltip, Timeline } from 'antd'; +import { HistoryOutlined, SearchOutlined, FileTextOutlined, DownloadOutlined, UploadOutlined, FileExcelOutlined, FileOutlined, DownOutlined, EditOutlined, EyeOutlined } from '@ant-design/icons'; import axios from 'axios'; import dayjs from 'dayjs'; import * as XLSX from 'xlsx'; @@ -20,6 +20,13 @@ function ConsumableLogs() { const [importModalVisible, setImportModalVisible] = useState(false); const [importType, setImportType] = useState('excel'); const [importing, setImporting] = useState(false); + const [editModalVisible, setEditModalVisible] = useState(false); + const [historyModalVisible, setHistoryModalVisible] = useState(false); + const [currentLog, setCurrentLog] = useState(null); + const [logHistory, setLogHistory] = useState([]); + const [editLoading, setEditLoading] = useState(false); + const [historyLoading, setHistoryLoading] = useState(false); + const [form] = Form.useForm(); const fileInputRef = useRef(null); const fetchLogs = async (page = 1, pageSize = 10, currentFilters = filters) => { @@ -147,6 +154,34 @@ function ConsumableLogs() { width: 200, render: (value) => value || '-', ellipsis: true + }, + { + title: '操作', + key: 'action', + width: 120, + fixed: 'right', + render: (_, record) => ( + + {record.isEditable && ( + +