feat(consumables): 添加耗材管理功能模块
添加耗材管理相关功能,包括: 1. 后端模型、路由和控制器 2. 前端页面和组件 3. 耗材分类管理 4. 耗材出入库记录 5. 耗材统计和日志功能 6. 集成到现有系统菜单
This commit is contained in:
+4
-1
@@ -24,7 +24,10 @@ if (DB_TYPE === 'mysql') {
|
|||||||
sequelize = new Sequelize({
|
sequelize = new Sequelize({
|
||||||
dialect: 'sqlite',
|
dialect: 'sqlite',
|
||||||
storage: process.env.DB_PATH || './idc_management.db',
|
storage: process.env.DB_PATH || './idc_management.db',
|
||||||
logging: process.env.NODE_ENV === 'development' ? console.log : false
|
logging: process.env.NODE_ENV === 'development' ? console.log : false,
|
||||||
|
dialectOptions: {
|
||||||
|
charset: 'utf8mb4'
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, 'idc_management.db');
|
||||||
|
|
||||||
|
const db = new sqlite3.Database(dbPath, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('无法打开数据库:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('开始修复数据库表结构...');
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
db.run(`ALTER TABLE consumables ADD COLUMN location TEXT;`, function(err) {
|
||||||
|
if (err) {
|
||||||
|
if (err.message.includes('duplicate column name: location')) {
|
||||||
|
console.log('location 列已存在,无需添加');
|
||||||
|
} else {
|
||||||
|
console.error('添加 location 列失败:', err.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('成功添加 location 列到 consumables 表');
|
||||||
|
}
|
||||||
|
|
||||||
|
db.close((closeErr) => {
|
||||||
|
if (closeErr) {
|
||||||
|
console.error('关闭数据库失败:', closeErr);
|
||||||
|
} else {
|
||||||
|
console.log('数据库修复完成');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
const { sequelize } = require('../db');
|
||||||
|
|
||||||
|
const Consumable = sequelize.define('Consumable', {
|
||||||
|
consumableId: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
primaryKey: true,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
category: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
unit: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: '个'
|
||||||
|
},
|
||||||
|
currentStock: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0
|
||||||
|
},
|
||||||
|
minStock: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 10
|
||||||
|
},
|
||||||
|
maxStock: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 100
|
||||||
|
},
|
||||||
|
unitPrice: {
|
||||||
|
type: DataTypes.DECIMAL(10, 2),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0
|
||||||
|
},
|
||||||
|
supplier: {
|
||||||
|
type: DataTypes.STRING
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
comment: '存放位置'
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: DataTypes.TEXT
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
defaultValue: 'active'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'consumables',
|
||||||
|
timestamps: true
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = Consumable;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
const { sequelize } = require('../db');
|
||||||
|
|
||||||
|
const ConsumableCategory = sequelize.define('ConsumableCategory', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true,
|
||||||
|
comment: '分类名称'
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
comment: '分类描述'
|
||||||
|
},
|
||||||
|
sortOrder: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
defaultValue: 0,
|
||||||
|
comment: '排序顺序'
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
defaultValue: 'active',
|
||||||
|
comment: '状态: active-启用, inactive-停用'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'consumable_categories',
|
||||||
|
timestamps: true
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = ConsumableCategory;
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
const { sequelize } = require('../db');
|
||||||
|
const Consumable = require('./Consumable');
|
||||||
|
|
||||||
|
const ConsumableLog = sequelize.define('ConsumableLog', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true
|
||||||
|
},
|
||||||
|
consumableId: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
comment: '耗材ID'
|
||||||
|
},
|
||||||
|
consumableName: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
comment: '耗材名称'
|
||||||
|
},
|
||||||
|
operationType: {
|
||||||
|
type: DataTypes.ENUM('in', 'out', 'create', 'update', 'delete', 'adjust', 'import'),
|
||||||
|
allowNull: false,
|
||||||
|
comment: '操作类型'
|
||||||
|
},
|
||||||
|
quantity: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
defaultValue: 0,
|
||||||
|
comment: '变动数量(入库为正,出库为负)'
|
||||||
|
},
|
||||||
|
previousStock: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
comment: '操作前库存'
|
||||||
|
},
|
||||||
|
currentStock: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
comment: '操作后库存'
|
||||||
|
},
|
||||||
|
operator: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
comment: '操作人'
|
||||||
|
},
|
||||||
|
reason: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
comment: '操作原因'
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
comment: '备注'
|
||||||
|
},
|
||||||
|
relatedId: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
comment: '关联ID(如订单号、盘点ID等)'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'consumable_logs',
|
||||||
|
timestamps: true,
|
||||||
|
comment: '耗材操作日志表'
|
||||||
|
});
|
||||||
|
|
||||||
|
ConsumableLog.belongsTo(Consumable, {
|
||||||
|
foreignKey: 'consumableId',
|
||||||
|
onDelete: 'CASCADE'
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = ConsumableLog;
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
const { sequelize } = require('../db');
|
||||||
|
const Consumable = require('./Consumable');
|
||||||
|
|
||||||
|
const ConsumableRecord = sequelize.define('ConsumableRecord', {
|
||||||
|
recordId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
primaryKey: true,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true
|
||||||
|
},
|
||||||
|
consumableId: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: Consumable,
|
||||||
|
key: 'consumableId'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: DataTypes.ENUM('in', 'out'),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
quantity: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
previousStock: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
currentStock: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
operator: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
reason: {
|
||||||
|
type: DataTypes.STRING
|
||||||
|
},
|
||||||
|
recipient: {
|
||||||
|
type: DataTypes.STRING
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
type: DataTypes.TEXT
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'consumable_records',
|
||||||
|
timestamps: true
|
||||||
|
});
|
||||||
|
|
||||||
|
ConsumableRecord.belongsTo(Consumable, {
|
||||||
|
foreignKey: 'consumableId',
|
||||||
|
onDelete: 'CASCADE'
|
||||||
|
});
|
||||||
|
Consumable.hasMany(ConsumableRecord, {
|
||||||
|
foreignKey: 'consumableId',
|
||||||
|
onDelete: 'CASCADE'
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = ConsumableRecord;
|
||||||
Generated
+7
@@ -11,6 +11,7 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"csv-parser": "^3.2.0",
|
"csv-parser": "^3.2.0",
|
||||||
"csv-writer": "^1.6.0",
|
"csv-writer": "^1.6.0",
|
||||||
|
"dayjs": "^1.11.19",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-fileupload": "^1.5.2",
|
"express-fileupload": "^1.5.2",
|
||||||
@@ -645,6 +646,12 @@
|
|||||||
"integrity": "sha512-NOx7YDFWEsM/fTRAJjRpPp8t+MKRVvniAg9wQlUKx20MFrPs73WLJhFf5iteqrxNYnsy924K3Iroh3yNHeYd2g==",
|
"integrity": "sha512-NOx7YDFWEsM/fTRAJjRpPp8t+MKRVvniAg9wQlUKx20MFrPs73WLJhFf5iteqrxNYnsy924K3Iroh3yNHeYd2g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/dayjs": {
|
||||||
|
"version": "1.11.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
|
||||||
|
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "2.6.9",
|
"version": "2.6.9",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"csv-parser": "^3.2.0",
|
"csv-parser": "^3.2.0",
|
||||||
"csv-writer": "^1.6.0",
|
"csv-writer": "^1.6.0",
|
||||||
|
"dayjs": "^1.11.19",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-fileupload": "^1.5.2",
|
"express-fileupload": "^1.5.2",
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { Op } = require('sequelize');
|
||||||
|
const ConsumableCategory = require('../models/ConsumableCategory');
|
||||||
|
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { keyword, status, page = 1, pageSize = 10 } = req.query;
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const where = {};
|
||||||
|
|
||||||
|
if (keyword) {
|
||||||
|
where[Op.or] = [
|
||||||
|
{ name: { [Op.like]: `%${keyword}%` } },
|
||||||
|
{ description: { [Op.like]: `%${keyword}%` } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status && status !== 'all') {
|
||||||
|
where.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { count, rows } = await ConsumableCategory.findAndCountAll({
|
||||||
|
where,
|
||||||
|
order: [['sortOrder', 'ASC'], ['id', 'DESC']],
|
||||||
|
offset,
|
||||||
|
limit: parseInt(pageSize)
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
categories: rows,
|
||||||
|
total: count,
|
||||||
|
currentPage: parseInt(page),
|
||||||
|
pageSize: parseInt(pageSize),
|
||||||
|
totalPages: Math.ceil(count / pageSize)
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/list', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const categories = await ConsumableCategory.findAll({
|
||||||
|
where: { status: 'active' },
|
||||||
|
order: [['sortOrder', 'ASC'], ['name', 'ASC']]
|
||||||
|
});
|
||||||
|
res.json(categories);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const category = await ConsumableCategory.findByPk(req.params.id);
|
||||||
|
if (!category) {
|
||||||
|
return res.status(404).json({ error: '分类不存在' });
|
||||||
|
}
|
||||||
|
res.json(category);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, description, sortOrder, status } = req.body;
|
||||||
|
|
||||||
|
const existing = await ConsumableCategory.findOne({ where: { name } });
|
||||||
|
if (existing) {
|
||||||
|
return res.status(400).json({ error: '分类名称已存在' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = await ConsumableCategory.create({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
sortOrder: sortOrder || 0,
|
||||||
|
status: status || 'active'
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(category);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, description, sortOrder, status } = req.body;
|
||||||
|
const category = await ConsumableCategory.findByPk(req.params.id);
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
return res.status(404).json({ error: '分类不存在' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name && name !== category.name) {
|
||||||
|
const existing = await ConsumableCategory.findOne({ where: { name } });
|
||||||
|
if (existing) {
|
||||||
|
return res.status(400).json({ error: '分类名称已存在' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await category.update({
|
||||||
|
name: name || category.name,
|
||||||
|
description: description !== undefined ? description : category.description,
|
||||||
|
sortOrder: sortOrder !== undefined ? sortOrder : category.sortOrder,
|
||||||
|
status: status || category.status
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(category);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const category = await ConsumableCategory.findByPk(req.params.id);
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
return res.status(404).json({ error: '分类不存在' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await category.destroy();
|
||||||
|
res.json({ message: '删除成功' });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { Op } = require('sequelize');
|
||||||
|
const { sequelize } = require('../db');
|
||||||
|
const Consumable = require('../models/Consumable');
|
||||||
|
const ConsumableRecord = require('../models/ConsumableRecord');
|
||||||
|
const ConsumableLog = require('../models/ConsumableLog');
|
||||||
|
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { consumableId, type, startDate, endDate, page = 1, pageSize = 10 } = req.query;
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const where = {};
|
||||||
|
|
||||||
|
if (consumableId) {
|
||||||
|
where.consumableId = consumableId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type && type !== 'all') {
|
||||||
|
where.type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate && endDate) {
|
||||||
|
where.createdAt = {
|
||||||
|
[Op.between]: [new Date(startDate), new Date(endDate)]
|
||||||
|
};
|
||||||
|
} else if (startDate) {
|
||||||
|
where.createdAt = { [Op.gte]: new Date(startDate) };
|
||||||
|
} else if (endDate) {
|
||||||
|
where.createdAt = { [Op.lte]: new Date(endDate) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { count, rows } = await ConsumableRecord.findAndCountAll({
|
||||||
|
where,
|
||||||
|
include: [
|
||||||
|
{ model: Consumable, attributes: ['name', 'category', 'unit'] }
|
||||||
|
],
|
||||||
|
offset,
|
||||||
|
limit: parseInt(pageSize),
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
total: count,
|
||||||
|
records: rows,
|
||||||
|
page: parseInt(page),
|
||||||
|
pageSize: parseInt(pageSize)
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
const transaction = await sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { consumableId, type, quantity, operator, reason, recipient, notes } = req.body;
|
||||||
|
|
||||||
|
const consumable = await Consumable.findByPk(consumableId);
|
||||||
|
if (!consumable) {
|
||||||
|
await transaction.rollback();
|
||||||
|
return res.status(404).json({ error: '耗材不存在' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousStock = consumable.currentStock;
|
||||||
|
let newStock;
|
||||||
|
|
||||||
|
if (type === 'in') {
|
||||||
|
newStock = previousStock + quantity;
|
||||||
|
} else if (type === 'out') {
|
||||||
|
if (previousStock < quantity) {
|
||||||
|
await transaction.rollback();
|
||||||
|
return res.status(400).json({ error: '库存不足' });
|
||||||
|
}
|
||||||
|
newStock = previousStock - quantity;
|
||||||
|
} else {
|
||||||
|
await transaction.rollback();
|
||||||
|
return res.status(400).json({ error: '操作类型无效' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await consumable.update({ currentStock: newStock }, { transaction });
|
||||||
|
|
||||||
|
const record = await ConsumableRecord.create({
|
||||||
|
consumableId,
|
||||||
|
type,
|
||||||
|
quantity,
|
||||||
|
previousStock,
|
||||||
|
currentStock: newStock,
|
||||||
|
operator,
|
||||||
|
reason,
|
||||||
|
recipient,
|
||||||
|
notes
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
await ConsumableLog.create({
|
||||||
|
consumableId,
|
||||||
|
consumableName: consumable.name,
|
||||||
|
operationType: type,
|
||||||
|
quantity: type === 'in' ? parseFloat(quantity) : -parseFloat(quantity),
|
||||||
|
previousStock,
|
||||||
|
currentStock: newStock,
|
||||||
|
operator,
|
||||||
|
reason,
|
||||||
|
notes
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
record,
|
||||||
|
consumable: {
|
||||||
|
previousStock,
|
||||||
|
currentStock: newStock
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
res.status(400).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/statistics', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { startDate, endDate } = req.query;
|
||||||
|
|
||||||
|
const dateWhere = {};
|
||||||
|
if (startDate && endDate) {
|
||||||
|
dateWhere.createdAt = {
|
||||||
|
[Op.between]: [new Date(startDate), new Date(endDate)]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const inCount = await ConsumableRecord.count({
|
||||||
|
where: { ...dateWhere, type: 'in' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const outCount = await ConsumableRecord.count({
|
||||||
|
where: { ...dateWhere, type: 'out' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const inQuantity = await ConsumableRecord.sum('quantity', {
|
||||||
|
where: { ...dateWhere, type: 'in' }
|
||||||
|
}) || 0;
|
||||||
|
|
||||||
|
const outQuantity = await ConsumableRecord.sum('quantity', {
|
||||||
|
where: { ...dateWhere, type: 'out' }
|
||||||
|
}) || 0;
|
||||||
|
|
||||||
|
const byType = await ConsumableRecord.findAll({
|
||||||
|
where: dateWhere,
|
||||||
|
attributes: [
|
||||||
|
'type',
|
||||||
|
[sequelize.fn('SUM', sequelize.col('quantity')), 'totalQuantity'],
|
||||||
|
[sequelize.fn('COUNT', '*'), 'count']
|
||||||
|
],
|
||||||
|
group: ['type']
|
||||||
|
});
|
||||||
|
|
||||||
|
const recentRecords = await ConsumableRecord.findAll({
|
||||||
|
where: dateWhere,
|
||||||
|
include: [
|
||||||
|
{ model: Consumable, attributes: ['name', 'category'] }
|
||||||
|
],
|
||||||
|
order: [['createdAt', 'DESC']],
|
||||||
|
limit: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
inCount,
|
||||||
|
outCount,
|
||||||
|
inQuantity,
|
||||||
|
outQuantity,
|
||||||
|
netQuantity: inQuantity - outQuantity,
|
||||||
|
byType: byType.map(item => ({
|
||||||
|
type: item.type,
|
||||||
|
totalQuantity: item.dataValues.totalQuantity,
|
||||||
|
count: item.dataValues.count
|
||||||
|
})),
|
||||||
|
recentRecords
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,680 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { Op } = require('sequelize');
|
||||||
|
const { sequelize } = require('../db');
|
||||||
|
const dayjs = require('dayjs');
|
||||||
|
const Consumable = require('../models/Consumable');
|
||||||
|
const ConsumableRecord = require('../models/ConsumableRecord');
|
||||||
|
const ConsumableLog = require('../models/ConsumableLog');
|
||||||
|
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { keyword, category, status, page = 1, pageSize = 10 } = req.query;
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const where = {};
|
||||||
|
|
||||||
|
if (keyword) {
|
||||||
|
where[Op.or] = [
|
||||||
|
{ consumableId: { [Op.like]: `%${keyword}%` } },
|
||||||
|
{ name: { [Op.like]: `%${keyword}%` } },
|
||||||
|
{ category: { [Op.like]: `%${keyword}%` } },
|
||||||
|
{ supplier: { [Op.like]: `%${keyword}%` } },
|
||||||
|
{ location: { [Op.like]: `%${keyword}%` } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category && category !== 'all') {
|
||||||
|
where.category = category;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status && status !== 'all') {
|
||||||
|
where.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { count, rows } = await Consumable.findAndCountAll({
|
||||||
|
where,
|
||||||
|
offset,
|
||||||
|
limit: parseInt(pageSize),
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
total: count,
|
||||||
|
consumables: rows,
|
||||||
|
page: parseInt(page),
|
||||||
|
pageSize: parseInt(pageSize)
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
const transaction = await sequelize.transaction();
|
||||||
|
try {
|
||||||
|
const consumable = await Consumable.create(req.body, { transaction });
|
||||||
|
|
||||||
|
await ConsumableLog.create({
|
||||||
|
consumableId: consumable.consumableId,
|
||||||
|
consumableName: consumable.name,
|
||||||
|
operationType: 'create',
|
||||||
|
quantity: consumable.currentStock,
|
||||||
|
previousStock: 0,
|
||||||
|
currentStock: consumable.currentStock,
|
||||||
|
operator: req.body.operator || req.body.operatorName || '系统',
|
||||||
|
reason: '新建耗材',
|
||||||
|
notes: req.body.description || ''
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
res.status(201).json(consumable);
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
res.status(400).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/import', async (req, res) => {
|
||||||
|
const transaction = await sequelize.transaction();
|
||||||
|
try {
|
||||||
|
const { items, operator = '系统' } = req.body;
|
||||||
|
|
||||||
|
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||||
|
await transaction.rollback();
|
||||||
|
return res.status(400).json({ error: '没有导入数据' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = {
|
||||||
|
success: 0,
|
||||||
|
failed: 0,
|
||||||
|
errors: []
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const item = items[i];
|
||||||
|
try {
|
||||||
|
const consumableData = {
|
||||||
|
consumableId: item.耗材ID || item.consumableId || `CON${Date.now()}${i}`,
|
||||||
|
name: item.名称 || item.name,
|
||||||
|
category: item.分类 || item.category,
|
||||||
|
unit: item.单位 || item.unit || '个',
|
||||||
|
currentStock: parseInt(item.当前库存 || item.currentStock) || 0,
|
||||||
|
minStock: parseInt(item.最小库存 || item.minStock) || 10,
|
||||||
|
maxStock: parseInt(item.最大库存 || item.maxStock) || 100,
|
||||||
|
unitPrice: parseFloat(item.单价 || item.unitPrice) || 0,
|
||||||
|
supplier: item.供应商 || item.supplier || '',
|
||||||
|
location: item.存放位置 || item.location || '',
|
||||||
|
description: item.描述 || item.description || '',
|
||||||
|
status: item.状态 || item.status || 'active'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!consumableData.name || !consumableData.category) {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push(`第 ${i + 1} 行: 名称和分类为必填项`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const consumable = await Consumable.create(consumableData, { transaction });
|
||||||
|
|
||||||
|
await ConsumableLog.create({
|
||||||
|
consumableId: consumable.consumableId,
|
||||||
|
consumableName: consumable.name,
|
||||||
|
operationType: 'import',
|
||||||
|
quantity: consumable.currentStock,
|
||||||
|
previousStock: 0,
|
||||||
|
currentStock: consumable.currentStock,
|
||||||
|
operator,
|
||||||
|
reason: '批量导入',
|
||||||
|
notes: ''
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
results.success++;
|
||||||
|
} catch (error) {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push(`第 ${i + 1} 行: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
res.json({
|
||||||
|
message: `导入完成,成功 ${results.success} 条,失败 ${results.failed} 条`,
|
||||||
|
results
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/categories/list', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const categories = await Consumable.findAll({
|
||||||
|
attributes: ['category'],
|
||||||
|
group: ['category']
|
||||||
|
});
|
||||||
|
const categoryList = categories.map(item => item.category).filter(Boolean);
|
||||||
|
res.json(categoryList);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/low-stock', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const consumables = await Consumable.findAll({
|
||||||
|
where: {
|
||||||
|
[Op.and]: [
|
||||||
|
sequelize.where(sequelize.col('currentStock'), {
|
||||||
|
[Op.lte]: sequelize.col('minStock')
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.json(consumables);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/statistics/summary', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const total = await Consumable.count();
|
||||||
|
const lowStock = await Consumable.count({
|
||||||
|
where: sequelize.where(sequelize.col('currentStock'), {
|
||||||
|
[Op.lte]: sequelize.col('minStock')
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const consumables = await Consumable.findAll({
|
||||||
|
attributes: ['currentStock', 'unitPrice']
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalValue = consumables.reduce((sum, item) => {
|
||||||
|
return sum + (parseFloat(item.currentStock) || 0) * (parseFloat(item.unitPrice) || 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const byCategory = await Consumable.findAll({
|
||||||
|
attributes: ['category', [sequelize.fn('COUNT', '*'), 'count']],
|
||||||
|
group: ['category']
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
total,
|
||||||
|
lowStock,
|
||||||
|
totalValue: totalValue.toFixed(2),
|
||||||
|
byCategory: byCategory.map(item => ({
|
||||||
|
category: item.category,
|
||||||
|
count: item.dataValues.count
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/inout/records', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, pageSize = 10 } = req.query;
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const { count, rows } = await ConsumableRecord.findAndCountAll({
|
||||||
|
offset,
|
||||||
|
limit: parseInt(pageSize),
|
||||||
|
order: [['createdAt', 'DESC']],
|
||||||
|
include: [{
|
||||||
|
model: Consumable,
|
||||||
|
as: 'Consumable',
|
||||||
|
attributes: ['consumableId', 'name', 'category']
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
total: count,
|
||||||
|
records: rows,
|
||||||
|
page: parseInt(page),
|
||||||
|
pageSize: parseInt(pageSize)
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/quick-inout', async (req, res) => {
|
||||||
|
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') {
|
||||||
|
newStock = previousStock - parseFloat(quantity);
|
||||||
|
if (newStock < 0) {
|
||||||
|
await transaction.rollback();
|
||||||
|
return res.status(400).json({ error: '库存不足' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await transaction.rollback();
|
||||||
|
return res.status(400).json({ error: '操作类型无效' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await consumable.update({ currentStock: newStock }, { transaction });
|
||||||
|
|
||||||
|
const record = await ConsumableRecord.create({
|
||||||
|
consumableId,
|
||||||
|
type,
|
||||||
|
quantity,
|
||||||
|
previousStock,
|
||||||
|
currentStock: newStock,
|
||||||
|
operator,
|
||||||
|
reason,
|
||||||
|
notes
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
await ConsumableLog.create({
|
||||||
|
consumableId,
|
||||||
|
consumableName: consumable.name,
|
||||||
|
operationType: type,
|
||||||
|
quantity: type === 'in' ? parseFloat(quantity) : -parseFloat(quantity),
|
||||||
|
previousStock,
|
||||||
|
currentStock: newStock,
|
||||||
|
operator,
|
||||||
|
reason,
|
||||||
|
notes
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: '操作成功',
|
||||||
|
record,
|
||||||
|
consumable: await consumable.reload()
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/inout', async (req, res) => {
|
||||||
|
const transaction = await sequelize.transaction();
|
||||||
|
try {
|
||||||
|
const { consumableId, type, quantity, operator, reason, recipient, 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 {
|
||||||
|
newStock = previousStock - parseFloat(quantity);
|
||||||
|
if (newStock < 0) {
|
||||||
|
await transaction.rollback();
|
||||||
|
return res.status(400).json({ error: '库存不足' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await consumable.update({ currentStock: newStock }, { transaction });
|
||||||
|
|
||||||
|
const record = await ConsumableRecord.create({
|
||||||
|
consumableId,
|
||||||
|
type,
|
||||||
|
quantity,
|
||||||
|
previousStock,
|
||||||
|
currentStock: newStock,
|
||||||
|
operator,
|
||||||
|
reason,
|
||||||
|
recipient,
|
||||||
|
notes
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
await ConsumableLog.create({
|
||||||
|
consumableId,
|
||||||
|
consumableName: consumable.name,
|
||||||
|
operationType: type,
|
||||||
|
quantity: type === 'in' ? parseFloat(quantity) : -parseFloat(quantity),
|
||||||
|
previousStock,
|
||||||
|
currentStock: newStock,
|
||||||
|
operator,
|
||||||
|
reason,
|
||||||
|
notes
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: '操作成功',
|
||||||
|
record,
|
||||||
|
consumable: await consumable.reload()
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/adjust', async (req, res) => {
|
||||||
|
const transaction = await sequelize.transaction();
|
||||||
|
try {
|
||||||
|
const { consumableId, adjustType, 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 (adjustType === 'add') {
|
||||||
|
newStock = previousStock + parseFloat(quantity);
|
||||||
|
} else if (adjustType === 'subtract') {
|
||||||
|
newStock = previousStock - parseFloat(quantity);
|
||||||
|
if (newStock < 0) {
|
||||||
|
await transaction.rollback();
|
||||||
|
return res.status(400).json({ error: '调整后库存不能为负' });
|
||||||
|
}
|
||||||
|
} else if (adjustType === 'set') {
|
||||||
|
newStock = parseFloat(quantity);
|
||||||
|
} else {
|
||||||
|
await transaction.rollback();
|
||||||
|
return res.status(400).json({ error: '调整类型无效' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await consumable.update({ currentStock: newStock }, { transaction });
|
||||||
|
|
||||||
|
const changeQuantity = newStock - previousStock;
|
||||||
|
|
||||||
|
await ConsumableLog.create({
|
||||||
|
consumableId,
|
||||||
|
consumableName: consumable.name,
|
||||||
|
operationType: 'adjust',
|
||||||
|
quantity: changeQuantity,
|
||||||
|
previousStock,
|
||||||
|
currentStock: newStock,
|
||||||
|
operator,
|
||||||
|
reason: reason || (adjustType === 'set' ? '库存调整为 ' + newStock : reason),
|
||||||
|
notes: adjustType === 'set' ? `库存从 ${previousStock} 调整为 ${newStock}` : notes
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: '调整成功',
|
||||||
|
consumable: await consumable.reload()
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/logs', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { consumableId, operationType, startDate, endDate, page = 1, pageSize = 20 } = req.query;
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const where = {};
|
||||||
|
|
||||||
|
if (consumableId) {
|
||||||
|
where.consumableId = consumableId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operationType && operationType !== 'all') {
|
||||||
|
where.operationType = operationType;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate && endDate) {
|
||||||
|
where.createdAt = {
|
||||||
|
[Op.between]: [new Date(startDate), new Date(endDate)]
|
||||||
|
};
|
||||||
|
} else if (startDate) {
|
||||||
|
where.createdAt = { [Op.gte]: new Date(startDate) };
|
||||||
|
} else if (endDate) {
|
||||||
|
where.createdAt = { [Op.lte]: new Date(endDate) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { count, rows } = await ConsumableLog.findAndCountAll({
|
||||||
|
where,
|
||||||
|
offset,
|
||||||
|
limit: parseInt(pageSize),
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
total: count,
|
||||||
|
logs: rows,
|
||||||
|
page: parseInt(page),
|
||||||
|
pageSize: parseInt(pageSize)
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/logs/export', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { consumableId, operationType, startDate, endDate } = req.query;
|
||||||
|
|
||||||
|
const where = {};
|
||||||
|
|
||||||
|
if (consumableId) {
|
||||||
|
where.consumableId = consumableId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operationType && operationType !== 'all') {
|
||||||
|
where.operationType = operationType;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate && endDate) {
|
||||||
|
where.createdAt = {
|
||||||
|
[Op.between]: [new Date(startDate), new Date(endDate)]
|
||||||
|
};
|
||||||
|
} else if (startDate) {
|
||||||
|
where.createdAt = { [Op.gte]: new Date(startDate) };
|
||||||
|
} else if (endDate) {
|
||||||
|
where.createdAt = { [Op.lte]: new Date(endDate) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const logs = await ConsumableLog.findAll({
|
||||||
|
where,
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
const csvHeader = 'ID,耗材ID,耗材名称,操作类型,变动数量,操作前库存,操作后库存,操作人,原因,备注,创建时间,更新时间\n';
|
||||||
|
const csvRows = logs.map(log => {
|
||||||
|
const operationTypeMap = {
|
||||||
|
'in': '入库',
|
||||||
|
'out': '出库',
|
||||||
|
'create': '创建',
|
||||||
|
'update': '更新',
|
||||||
|
'delete': '删除',
|
||||||
|
'adjust': '调整',
|
||||||
|
'import': '导入'
|
||||||
|
};
|
||||||
|
return [
|
||||||
|
log.id,
|
||||||
|
log.consumableId,
|
||||||
|
log.consumableName,
|
||||||
|
operationTypeMap[log.operationType] || log.operationType,
|
||||||
|
log.quantity,
|
||||||
|
log.previousStock,
|
||||||
|
log.currentStock,
|
||||||
|
log.operator,
|
||||||
|
log.reason || '',
|
||||||
|
log.notes || '',
|
||||||
|
dayjs(log.createdAt).format('YYYY-MM-DD HH:mm:ss'),
|
||||||
|
dayjs(log.updatedAt).format('YYYY-MM-DD HH:mm:ss')
|
||||||
|
].map(v => `"${String(v).replace(/"/g, '""')}"`).join(',');
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
const csv = csvHeader + csvRows;
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/csv;charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename=consumable_logs_${dayjs().format('YYYYMMDD_HHmmss')}.csv`);
|
||||||
|
res.send(csv);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/logs/import', async (req, res) => {
|
||||||
|
const transaction = await sequelize.transaction();
|
||||||
|
try {
|
||||||
|
const { logs: logItems, operator = '系统导入' } = req.body;
|
||||||
|
|
||||||
|
if (!logItems || !Array.isArray(logItems) || logItems.length === 0) {
|
||||||
|
await transaction.rollback();
|
||||||
|
return res.status(400).json({ error: '没有导入数据' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = {
|
||||||
|
success: 0,
|
||||||
|
failed: 0,
|
||||||
|
errors: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const operationTypeMap = {
|
||||||
|
'入库': 'in',
|
||||||
|
'出库': 'out',
|
||||||
|
'创建': 'create',
|
||||||
|
'更新': 'update',
|
||||||
|
'删除': 'delete',
|
||||||
|
'调整': 'adjust',
|
||||||
|
'导入': 'import'
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < logItems.length; i++) {
|
||||||
|
const item = logItems[i];
|
||||||
|
try {
|
||||||
|
const consumableId = item.耗材ID || item.consumableId || item['consumableId'];
|
||||||
|
const consumableName = item.耗材名称 || item.consumableName || item['consumableName'];
|
||||||
|
const operationType = operationTypeMap[item.操作类型 || item.operationType] || item.operationType || item['operationType'];
|
||||||
|
|
||||||
|
if (!consumableId || !operationType) {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push(`第 ${i + 1} 行: 缺少耗材ID或操作类型`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ConsumableLog.create({
|
||||||
|
consumableId,
|
||||||
|
consumableName: consumableName || '',
|
||||||
|
operationType,
|
||||||
|
quantity: parseFloat(item.变动数量 || item.quantity || item['quantity']) || 0,
|
||||||
|
previousStock: parseFloat(item.操作前库存 || item.previousStock || item['previousStock']) || 0,
|
||||||
|
currentStock: parseFloat(item.操作后库存 || item.currentStock || item['currentStock']) || 0,
|
||||||
|
operator: item.操作人 || item.operator || operator,
|
||||||
|
reason: item.原因 || item.reason || '',
|
||||||
|
notes: item.备注 || item.notes || ''
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
results.success++;
|
||||||
|
} catch (err) {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push(`第 ${i + 1} 行: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
res.json(results);
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const consumable = await Consumable.findByPk(req.params.id);
|
||||||
|
if (!consumable) {
|
||||||
|
return res.status(404).json({ error: '耗材不存在' });
|
||||||
|
}
|
||||||
|
res.json(consumable);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', async (req, res) => {
|
||||||
|
const transaction = await sequelize.transaction();
|
||||||
|
try {
|
||||||
|
const consumable = await Consumable.findByPk(req.params.id, { transaction });
|
||||||
|
if (!consumable) {
|
||||||
|
await transaction.rollback();
|
||||||
|
return res.status(404).json({ error: '耗材不存在' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldData = consumable.toJSON();
|
||||||
|
await consumable.update(req.body, { transaction });
|
||||||
|
|
||||||
|
await ConsumableLog.create({
|
||||||
|
consumableId: consumable.consumableId,
|
||||||
|
consumableName: consumable.name,
|
||||||
|
operationType: 'update',
|
||||||
|
quantity: 0,
|
||||||
|
previousStock: consumable.currentStock,
|
||||||
|
currentStock: consumable.currentStock,
|
||||||
|
operator: req.body.operator || req.body.operatorName || '系统',
|
||||||
|
reason: '信息更新',
|
||||||
|
notes: `更新字段: ${Object.keys(req.body).join(', ')}`
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
res.json(consumable);
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
res.status(400).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', async (req, res) => {
|
||||||
|
const transaction = await sequelize.transaction();
|
||||||
|
try {
|
||||||
|
const consumable = await Consumable.findByPk(req.params.id, { transaction });
|
||||||
|
if (!consumable) {
|
||||||
|
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) {
|
||||||
|
await transaction.rollback();
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -39,6 +39,9 @@ const rackRoutes = require('./routes/racks');
|
|||||||
const roomRoutes = require('./routes/rooms');
|
const roomRoutes = require('./routes/rooms');
|
||||||
const deviceFieldRoutes = require('./routes/deviceFields');
|
const deviceFieldRoutes = require('./routes/deviceFields');
|
||||||
const backgroundRoutes = require('./routes/background');
|
const backgroundRoutes = require('./routes/background');
|
||||||
|
const consumableRoutes = require('./routes/consumables');
|
||||||
|
const consumableRecordRoutes = require('./routes/consumableRecords');
|
||||||
|
const consumableCategoryRoutes = require('./routes/consumableCategories');
|
||||||
|
|
||||||
// 使用路由
|
// 使用路由
|
||||||
app.use('/api/devices', deviceRoutes);
|
app.use('/api/devices', deviceRoutes);
|
||||||
@@ -46,6 +49,9 @@ app.use('/api/racks', rackRoutes);
|
|||||||
app.use('/api/rooms', roomRoutes);
|
app.use('/api/rooms', roomRoutes);
|
||||||
app.use('/api/deviceFields', deviceFieldRoutes);
|
app.use('/api/deviceFields', deviceFieldRoutes);
|
||||||
app.use('/api/background', backgroundRoutes);
|
app.use('/api/background', backgroundRoutes);
|
||||||
|
app.use('/api/consumables', consumableRoutes);
|
||||||
|
app.use('/api/consumable-records', consumableRecordRoutes);
|
||||||
|
app.use('/api/consumable-categories', consumableCategoryRoutes);
|
||||||
|
|
||||||
// 静态文件服务
|
// 静态文件服务
|
||||||
app.use('/uploads', express.static('uploads'));
|
app.use('/uploads', express.static('uploads'));
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
$postData = @{
|
||||||
|
consumableId = "CON1766493042245"
|
||||||
|
type = "in"
|
||||||
|
quantity = 5
|
||||||
|
operator = "测试管理员"
|
||||||
|
reason = "测试入库"
|
||||||
|
notes = "测试日志记录功能"
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
$response = Invoke-RestMethod -Uri "http://localhost:8000/api/consumables/quick-inout" -Method Post -Body $postData -ContentType "application/json"
|
||||||
|
|
||||||
|
Write-Host "入库API响应:"
|
||||||
|
$response | ConvertTo-Json -Depth 5
|
||||||
Generated
+1
@@ -11,6 +11,7 @@
|
|||||||
"@ant-design/icons": "^6.1.0",
|
"@ant-design/icons": "^6.1.0",
|
||||||
"antd": "^5.8.6",
|
"antd": "^5.8.6",
|
||||||
"axios": "^1.5.0",
|
"axios": "^1.5.0",
|
||||||
|
"dayjs": "^1.11.19",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.15.0",
|
"react-router-dom": "^6.15.0",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"@ant-design/icons": "^6.1.0",
|
"@ant-design/icons": "^6.1.0",
|
||||||
"antd": "^5.8.6",
|
"antd": "^5.8.6",
|
||||||
"axios": "^1.5.0",
|
"axios": "^1.5.0",
|
||||||
|
"dayjs": "^1.11.19",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.15.0",
|
"react-router-dom": "^6.15.0",
|
||||||
|
|||||||
+94
-50
@@ -1,14 +1,17 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Layout, Menu, theme, Button } from 'antd';
|
import { Layout, Menu, theme, Button } from 'antd';
|
||||||
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
|
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
|
||||||
import { BarChartOutlined, DatabaseOutlined, CloudServerOutlined, MenuUnfoldOutlined, MenuFoldOutlined, EyeOutlined } from '@ant-design/icons';
|
import { BarChartOutlined, DatabaseOutlined, CloudServerOutlined, MenuUnfoldOutlined, MenuFoldOutlined, EyeOutlined, BuildOutlined, HomeOutlined, ShoppingCartOutlined, InboxOutlined, ImportOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
import DeviceManagement from './pages/DeviceManagement';
|
import DeviceManagement from './pages/DeviceManagement';
|
||||||
import RackManagement from './pages/RackManagement';
|
import RackManagement from './pages/RackManagement';
|
||||||
import RoomManagement from './pages/RoomManagement';
|
import RoomManagement from './pages/RoomManagement';
|
||||||
import DeviceFieldManagement from './pages/DeviceFieldManagement';
|
import DeviceFieldManagement from './pages/DeviceFieldManagement';
|
||||||
import RackVisualization from './pages/RackVisualization';
|
import RackVisualization from './pages/RackVisualization';
|
||||||
|
import ConsumableManagement from './pages/ConsumableManagement';
|
||||||
|
import ConsumableStatistics from './pages/ConsumableStatistics';
|
||||||
|
import ConsumableLogs from './pages/ConsumableLogs';
|
||||||
|
import CategoryManagement from './pages/CategoryManagement';
|
||||||
|
|
||||||
const { Content, Sider } = Layout;
|
const { Content, Sider } = Layout;
|
||||||
|
|
||||||
@@ -22,7 +25,7 @@ function App() {
|
|||||||
<Router>
|
<Router>
|
||||||
<Layout>
|
<Layout>
|
||||||
<Sider
|
<Sider
|
||||||
width={200}
|
width={220}
|
||||||
collapsedWidth={80}
|
collapsedWidth={80}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
style={{
|
style={{
|
||||||
@@ -30,7 +33,6 @@ function App() {
|
|||||||
boxShadow: '2px 0 8px rgba(0,0,0,0.08)'
|
boxShadow: '2px 0 8px rgba(0,0,0,0.08)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 自定义收起/展开按钮 */}
|
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
@@ -47,55 +49,93 @@ function App() {
|
|||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
padding: '8px',
|
padding: '8px',
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
transition: 'all 0.3s',
|
transition: 'all 0.3s'
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: '#e6f7ff'
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Menu
|
<Menu
|
||||||
mode="inline"
|
mode="inline"
|
||||||
defaultSelectedKeys={['1']}
|
defaultSelectedKeys={['dashboard']}
|
||||||
style={{
|
style={{
|
||||||
height: 'calc(100% - 80px)',
|
height: 'calc(100% - 80px)',
|
||||||
borderRight: 0,
|
borderRight: 0,
|
||||||
backgroundColor: 'transparent'
|
backgroundColor: 'transparent'
|
||||||
}}
|
}}
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
key: '1',
|
key: 'dashboard',
|
||||||
icon: <BarChartOutlined />,
|
icon: <BarChartOutlined />,
|
||||||
label: <Link to="/">仪表盘</Link>,
|
label: <Link to="/">仪表盘</Link>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: '2',
|
key: 'room-management',
|
||||||
icon: <CloudServerOutlined />,
|
icon: <HomeOutlined />,
|
||||||
label: <Link to="/devices">设备管理</Link>,
|
label: '机房管理',
|
||||||
},
|
children: [
|
||||||
{
|
{
|
||||||
key: '3',
|
key: 'rooms',
|
||||||
icon: <DatabaseOutlined />,
|
icon: <HomeOutlined />,
|
||||||
label: <Link to="/racks">机柜管理</Link>,
|
label: <Link to="/rooms">机房管理</Link>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: '4',
|
key: 'racks',
|
||||||
icon: <DatabaseOutlined />,
|
icon: <DatabaseOutlined />,
|
||||||
label: <Link to="/rooms">机房管理</Link>,
|
label: <Link to="/racks">机柜管理</Link>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: '5',
|
key: 'visualization',
|
||||||
icon: <BarChartOutlined />,
|
icon: <EyeOutlined />,
|
||||||
label: <Link to="/fields">字段管理</Link>,
|
label: <Link to="/visualization">机柜可视化</Link>,
|
||||||
},
|
},
|
||||||
{
|
],
|
||||||
key: '6',
|
},
|
||||||
icon: <EyeOutlined />,
|
{
|
||||||
label: <Link to="/visualization">机柜可视化</Link>,
|
key: 'asset-management',
|
||||||
},
|
icon: <BuildOutlined />,
|
||||||
]}
|
label: '资产管理',
|
||||||
/>
|
children: [
|
||||||
</Sider>
|
{
|
||||||
|
key: 'devices',
|
||||||
|
icon: <CloudServerOutlined />,
|
||||||
|
label: <Link to="/devices">设备管理</Link>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'fields',
|
||||||
|
icon: <DatabaseOutlined />,
|
||||||
|
label: <Link to="/fields">字段管理</Link>,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'consumables-management',
|
||||||
|
icon: <ShoppingCartOutlined />,
|
||||||
|
label: '耗材管理',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: 'consumables-stats',
|
||||||
|
icon: <BarChartOutlined />,
|
||||||
|
label: <Link to="/consumables-stats">耗材统计</Link>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'consumables',
|
||||||
|
icon: <DatabaseOutlined />,
|
||||||
|
label: <Link to="/consumables">耗材列表</Link>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'consumables-categories',
|
||||||
|
icon: <ImportOutlined />,
|
||||||
|
label: <Link to="/consumables-categories">分类管理</Link>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'consumables-logs',
|
||||||
|
icon: <FileTextOutlined />,
|
||||||
|
label: <Link to="/consumables-logs">操作日志</Link>,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Sider>
|
||||||
<Layout style={{ padding: '0 24px 24px' }}>
|
<Layout style={{ padding: '0 24px 24px' }}>
|
||||||
<Content
|
<Content
|
||||||
style={{
|
style={{
|
||||||
@@ -113,6 +153,10 @@ function App() {
|
|||||||
<Route path="/rooms" element={<RoomManagement />} />
|
<Route path="/rooms" element={<RoomManagement />} />
|
||||||
<Route path="/fields" element={<DeviceFieldManagement />} />
|
<Route path="/fields" element={<DeviceFieldManagement />} />
|
||||||
<Route path="/visualization" element={<RackVisualization />} />
|
<Route path="/visualization" element={<RackVisualization />} />
|
||||||
|
<Route path="/consumables" element={<ConsumableManagement />} />
|
||||||
|
<Route path="/consumables-categories" element={<CategoryManagement />} />
|
||||||
|
<Route path="/consumables-stats" element={<ConsumableStatistics />} />
|
||||||
|
<Route path="/consumables-logs" element={<ConsumableLogs />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Content>
|
</Content>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Table, Button, Modal, Form, Input, InputNumber, Select, message, Card, Space, Popconfirm, Tag } from 'antd';
|
||||||
|
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined } from '@ant-design/icons';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const { Option } = Select;
|
||||||
|
|
||||||
|
function CategoryManagement() {
|
||||||
|
const [categories, setCategories] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const [editingCategory, setEditingCategory] = useState(null);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [pagination, setPagination] = useState({
|
||||||
|
current: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
total: 0,
|
||||||
|
showTotal: (total) => `共 ${total} 条记录`
|
||||||
|
});
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [status, setStatus] = useState('all');
|
||||||
|
|
||||||
|
const fetchCategories = async (page = 1, pageSize = 10) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await axios.get('/api/consumable-categories', {
|
||||||
|
params: { page, pageSize, keyword, status }
|
||||||
|
});
|
||||||
|
setCategories(response.data.categories);
|
||||||
|
setPagination(prev => ({ ...prev, current: page, pageSize, total: response.data.total }));
|
||||||
|
} catch (error) {
|
||||||
|
message.error('获取分类列表失败');
|
||||||
|
console.error('获取分类列表失败:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCategories();
|
||||||
|
}, [keyword, status]);
|
||||||
|
|
||||||
|
const showModal = (category = null) => {
|
||||||
|
setEditingCategory(category);
|
||||||
|
if (category) {
|
||||||
|
form.setFieldsValue(category);
|
||||||
|
} else {
|
||||||
|
form.resetFields();
|
||||||
|
form.setFieldsValue({ status: 'active', sortOrder: 0 });
|
||||||
|
}
|
||||||
|
setModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setModalVisible(false);
|
||||||
|
setEditingCategory(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (values) => {
|
||||||
|
try {
|
||||||
|
if (editingCategory) {
|
||||||
|
await axios.put(`/api/consumable-categories/${editingCategory.id}`, values);
|
||||||
|
message.success('分类更新成功');
|
||||||
|
} else {
|
||||||
|
await axios.post('/api/consumable-categories', values);
|
||||||
|
message.success('分类创建成功');
|
||||||
|
}
|
||||||
|
setModalVisible(false);
|
||||||
|
fetchCategories();
|
||||||
|
setEditingCategory(null);
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error.response?.data?.error || (editingCategory ? '分类更新失败' : '分类创建失败'));
|
||||||
|
console.error('提交失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/consumable-categories/${id}`);
|
||||||
|
message.success('删除成功');
|
||||||
|
fetchCategories();
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error.response?.data?.error || '删除失败');
|
||||||
|
console.error('删除失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: 'ID',
|
||||||
|
dataIndex: 'id',
|
||||||
|
key: 'id',
|
||||||
|
width: 80
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '分类名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
width: 150
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '描述',
|
||||||
|
dataIndex: 'description',
|
||||||
|
key: 'description',
|
||||||
|
width: 200,
|
||||||
|
render: (value) => value || '-'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '排序',
|
||||||
|
dataIndex: 'sortOrder',
|
||||||
|
key: 'sortOrder',
|
||||||
|
width: 80
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
width: 100,
|
||||||
|
render: (value) => (
|
||||||
|
<Tag color={value === 'active' ? 'green' : 'red'}>
|
||||||
|
{value === 'active' ? '启用' : '停用'}
|
||||||
|
</Tag>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
key: 'createdAt',
|
||||||
|
width: 180,
|
||||||
|
render: (value) => value ? new Date(value).toLocaleString('zh-CN') : '-'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 150,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" icon={<EditOutlined />} size="small" onClick={() => showModal(record)}>编辑</Button>
|
||||||
|
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(record.id)}>
|
||||||
|
<Button danger icon={<DeleteOutlined />} size="small">删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Card title="耗材分类管理" extra={
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => showModal()}>添加分类</Button>
|
||||||
|
}>
|
||||||
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
|
<Space>
|
||||||
|
<Input.Search
|
||||||
|
placeholder="搜索分类名称、描述"
|
||||||
|
style={{ width: 300 }}
|
||||||
|
onSearch={(value) => setKeyword(value)}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
<Select value={status} onChange={setStatus} style={{ width: 120 }}>
|
||||||
|
<Option value="all">所有状态</Option>
|
||||||
|
<Option value="active">启用</Option>
|
||||||
|
<Option value="inactive">停用</Option>
|
||||||
|
</Select>
|
||||||
|
<Button onClick={() => fetchCategories()}>刷新</Button>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={categories}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={pagination}
|
||||||
|
onChange={(pagination) => fetchCategories(pagination.current, pagination.pageSize)}
|
||||||
|
scroll={{ x: 1000 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editingCategory ? '编辑分类' : '添加分类'}
|
||||||
|
open={modalVisible}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
footer={null}
|
||||||
|
width={500}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||||
|
<Form.Item name="name" label="分类名称" rules={[{ required: true, message: '请输入分类名称' }, { max: 50, message: '分类名称不能超过50个字符' }]}>
|
||||||
|
<Input placeholder="请输入分类名称" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description" label="描述">
|
||||||
|
<Input.TextArea rows={3} placeholder="请输入分类描述" maxLength={200} showCount />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="sortOrder" label="排序">
|
||||||
|
<InputNumber min={0} placeholder="数值越小越靠前" style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="status" label="状态" rules={[{ required: true, message: '请选择状态' }]}>
|
||||||
|
<Select>
|
||||||
|
<Option value="active">启用</Option>
|
||||||
|
<Option value="inactive">停用</Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" htmlType="submit">{editingCategory ? '更新' : '创建'}</Button>
|
||||||
|
<Button onClick={handleCancel}>取消</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CategoryManagement;
|
||||||
@@ -0,0 +1,469 @@
|
|||||||
|
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 axios from 'axios';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
|
||||||
|
const { RangePicker } = DatePicker;
|
||||||
|
const { Option } = Select;
|
||||||
|
|
||||||
|
function ConsumableLogs() {
|
||||||
|
const [logs, setLogs] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0 });
|
||||||
|
const [filters, setFilters] = useState({
|
||||||
|
operationType: 'all',
|
||||||
|
consumableId: '',
|
||||||
|
dateRange: null
|
||||||
|
});
|
||||||
|
const [importModalVisible, setImportModalVisible] = useState(false);
|
||||||
|
const [importType, setImportType] = useState('excel');
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
|
const fetchLogs = async (page = 1, pageSize = 10, currentFilters = filters) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const params = { page, pageSize };
|
||||||
|
|
||||||
|
if (currentFilters.operationType !== 'all') {
|
||||||
|
params.operationType = currentFilters.operationType;
|
||||||
|
}
|
||||||
|
if (currentFilters.consumableId) {
|
||||||
|
params.consumableId = currentFilters.consumableId;
|
||||||
|
}
|
||||||
|
if (currentFilters.dateRange) {
|
||||||
|
params.startDate = currentFilters.dateRange[0].format('YYYY-MM-DD');
|
||||||
|
params.endDate = currentFilters.dateRange[1].format('YYYY-MM-DD');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.get('/api/consumables/logs', { params });
|
||||||
|
setLogs(response.data.logs);
|
||||||
|
setPagination(prev => ({ ...prev, current: page, total: response.data.total }));
|
||||||
|
} catch (error) {
|
||||||
|
message.error('获取操作日志失败');
|
||||||
|
console.error('获取操作日志失败:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLogs(1, pagination.pageSize, filters);
|
||||||
|
}, [filters.operationType, filters.consumableId, filters.dateRange]);
|
||||||
|
|
||||||
|
const handleFilterChange = (key, value) => {
|
||||||
|
setFilters(prev => ({ ...prev, [key]: value }));
|
||||||
|
fetchLogs(1, pagination.pageSize);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOperationTag = (type) => {
|
||||||
|
const config = {
|
||||||
|
in: { color: 'green', text: '入库' },
|
||||||
|
out: { color: 'red', text: '出库' },
|
||||||
|
create: { color: 'blue', text: '创建' },
|
||||||
|
update: { color: 'orange', text: '更新' },
|
||||||
|
delete: { color: 'magenta', text: '删除' },
|
||||||
|
adjust: { color: 'purple', text: '调整' },
|
||||||
|
import: { color: 'cyan', text: '导入' }
|
||||||
|
};
|
||||||
|
const { color, text } = config[type] || { color: 'default', text: type };
|
||||||
|
return <Tag color={color}>{text}</Tag>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '时间',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
key: 'createdAt',
|
||||||
|
width: 180,
|
||||||
|
sorter: (a, b) => new Date(a.createdAt) - new Date(b.createdAt),
|
||||||
|
render: (date) => dayjs(date).format('YYYY-MM-DD HH:mm:ss')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '耗材ID',
|
||||||
|
dataIndex: 'consumableId',
|
||||||
|
key: 'consumableId',
|
||||||
|
width: 150,
|
||||||
|
render: (value) => <code>{value}</code>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '耗材名称',
|
||||||
|
dataIndex: 'consumableName',
|
||||||
|
key: 'consumableName',
|
||||||
|
width: 150
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作类型',
|
||||||
|
dataIndex: 'operationType',
|
||||||
|
key: 'operationType',
|
||||||
|
width: 100,
|
||||||
|
render: (type) => getOperationTag(type)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '变动数量',
|
||||||
|
dataIndex: 'quantity',
|
||||||
|
key: 'quantity',
|
||||||
|
width: 100,
|
||||||
|
render: (value, record) => (
|
||||||
|
<span style={{
|
||||||
|
color: value > 0 ? '#52c41a' : value < 0 ? '#ff4d4f' : '#888',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
}}>
|
||||||
|
{value > 0 ? '+' : ''}{value}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作前库存',
|
||||||
|
dataIndex: 'previousStock',
|
||||||
|
key: 'previousStock',
|
||||||
|
width: 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作后库存',
|
||||||
|
dataIndex: 'currentStock',
|
||||||
|
key: 'currentStock',
|
||||||
|
width: 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作人',
|
||||||
|
dataIndex: 'operator',
|
||||||
|
key: 'operator',
|
||||||
|
width: 120
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '原因',
|
||||||
|
dataIndex: 'reason',
|
||||||
|
key: 'reason',
|
||||||
|
width: 150,
|
||||||
|
render: (value) => value || '-'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '备注',
|
||||||
|
dataIndex: 'notes',
|
||||||
|
key: 'notes',
|
||||||
|
width: 200,
|
||||||
|
render: (value) => value || '-',
|
||||||
|
ellipsis: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleExport = async (currentFilters = filters) => {
|
||||||
|
try {
|
||||||
|
const params = {};
|
||||||
|
if (currentFilters.operationType !== 'all') {
|
||||||
|
params.operationType = currentFilters.operationType;
|
||||||
|
}
|
||||||
|
if (currentFilters.consumableId) {
|
||||||
|
params.consumableId = currentFilters.consumableId;
|
||||||
|
}
|
||||||
|
if (currentFilters.dateRange) {
|
||||||
|
params.startDate = currentFilters.dateRange[0].format('YYYY-MM-DD');
|
||||||
|
params.endDate = currentFilters.dateRange[1].format('YYYY-MM-DD');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.get('/api/consumables/logs/export', {
|
||||||
|
params,
|
||||||
|
responseType: 'blob'
|
||||||
|
});
|
||||||
|
|
||||||
|
const blob = new Blob([response.data], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = window.URL.createObjectURL(blob);
|
||||||
|
link.download = `耗材操作日志_${dayjs().format('YYYYMMDD_HHmmss')}.csv`;
|
||||||
|
link.click();
|
||||||
|
|
||||||
|
message.success('导出成功');
|
||||||
|
} catch (error) {
|
||||||
|
message.error('导出失败');
|
||||||
|
console.error('导出失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportExcel = async (currentFilters = filters) => {
|
||||||
|
try {
|
||||||
|
const params = {};
|
||||||
|
if (currentFilters.operationType !== 'all') {
|
||||||
|
params.operationType = currentFilters.operationType;
|
||||||
|
}
|
||||||
|
if (currentFilters.consumableId) {
|
||||||
|
params.consumableId = currentFilters.consumableId;
|
||||||
|
}
|
||||||
|
if (currentFilters.dateRange) {
|
||||||
|
params.startDate = currentFilters.dateRange[0].format('YYYY-MM-DD');
|
||||||
|
params.endDate = currentFilters.dateRange[1].format('YYYY-MM-DD');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.get('/api/consumables/logs', {
|
||||||
|
params: { ...params, page: 1, pageSize: 10000 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const exportData = response.data.logs.map(log => ({
|
||||||
|
'时间': dayjs(log.createdAt).format('YYYY-MM-DD HH:mm:ss'),
|
||||||
|
'耗材ID': log.consumableId,
|
||||||
|
'耗材名称': log.consumableName,
|
||||||
|
'操作类型': getOperationTypeText(log.operationType),
|
||||||
|
'变动数量': log.quantity,
|
||||||
|
'操作前库存': log.previousStock,
|
||||||
|
'操作后库存': log.currentStock,
|
||||||
|
'操作人': log.operator,
|
||||||
|
'原因': log.reason || '',
|
||||||
|
'备注': log.notes || ''
|
||||||
|
}));
|
||||||
|
|
||||||
|
const ws = XLSX.utils.json_to_sheet(exportData);
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, '操作日志');
|
||||||
|
XLSX.writeFile(wb, `耗材操作日志_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`);
|
||||||
|
|
||||||
|
message.success('导出Excel成功');
|
||||||
|
} catch (error) {
|
||||||
|
message.error('导出Excel失败');
|
||||||
|
console.error('导出Excel失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOperationTypeText = (type) => {
|
||||||
|
const map = {
|
||||||
|
'in': '入库',
|
||||||
|
'out': '出库',
|
||||||
|
'create': '创建',
|
||||||
|
'update': '更新',
|
||||||
|
'delete': '删除',
|
||||||
|
'adjust': '调整',
|
||||||
|
'import': '导入'
|
||||||
|
};
|
||||||
|
return map[type] || type;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = async (file) => {
|
||||||
|
setImporting(true);
|
||||||
|
try {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (e) => {
|
||||||
|
try {
|
||||||
|
let logItems = [];
|
||||||
|
|
||||||
|
if (importType === 'excel') {
|
||||||
|
const workbook = XLSX.read(e.target.result, { type: 'array' });
|
||||||
|
const sheetName = workbook.SheetNames[0];
|
||||||
|
const worksheet = workbook.Sheets[sheetName];
|
||||||
|
logItems = XLSX.utils.sheet_to_json(worksheet);
|
||||||
|
} else {
|
||||||
|
const text = e.target.result;
|
||||||
|
const lines = text.split('\n').filter(line => line.trim());
|
||||||
|
const headers = lines[0].split(',').map(h => h.replace(/"/g, ''));
|
||||||
|
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const values = lines[i].split(',').map(v => v.replace(/"/g, ''));
|
||||||
|
const item = {};
|
||||||
|
headers.forEach((h, idx) => {
|
||||||
|
item[h] = values[idx];
|
||||||
|
});
|
||||||
|
logItems.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.post('/api/consumables/logs/import', {
|
||||||
|
logs: logItems,
|
||||||
|
operator: '前端导入'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.success > 0) {
|
||||||
|
message.success(`成功导入 ${response.data.success} 条记录`);
|
||||||
|
}
|
||||||
|
if (response.data.failed > 0) {
|
||||||
|
message.warning(`导入失败 ${response.data.failed} 条`);
|
||||||
|
response.data.errors.forEach(err => console.error(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
setImportModalVisible(false);
|
||||||
|
fetchLogs(1, pagination.pageSize);
|
||||||
|
} catch (err) {
|
||||||
|
message.error('解析文件失败: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
setImporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (importType === 'excel') {
|
||||||
|
reader.readAsArrayBuffer(file);
|
||||||
|
} else {
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('导入失败');
|
||||||
|
setImporting(false);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadTemplate = () => {
|
||||||
|
const template = [
|
||||||
|
{
|
||||||
|
'耗材ID': 'CON123456',
|
||||||
|
'耗材名称': '示例耗材',
|
||||||
|
'操作类型': '入库',
|
||||||
|
'变动数量': 10,
|
||||||
|
'操作前库存': 100,
|
||||||
|
'操作后库存': 110,
|
||||||
|
'操作人': '管理员',
|
||||||
|
'原因': '示例原因',
|
||||||
|
'备注': '示例备注'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const ws = XLSX.utils.json_to_sheet(template);
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, '日志模板');
|
||||||
|
XLSX.writeFile(wb, '耗材操作日志导入模板.xlsx');
|
||||||
|
|
||||||
|
message.success('模板下载成功');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<FileTextOutlined />
|
||||||
|
<span>耗材操作日志</span>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
|
<Space wrap>
|
||||||
|
<Input.Search
|
||||||
|
placeholder="搜索耗材ID"
|
||||||
|
style={{ width: 200 }}
|
||||||
|
allowClear
|
||||||
|
onSearch={(value) => handleFilterChange('consumableId', value)}
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={filters.operationType}
|
||||||
|
onChange={(value) => handleFilterChange('operationType', value)}
|
||||||
|
style={{ width: 120 }}
|
||||||
|
>
|
||||||
|
<Option value="all">全部类型</Option>
|
||||||
|
<Option value="in">入库</Option>
|
||||||
|
<Option value="out">出库</Option>
|
||||||
|
<Option value="create">创建</Option>
|
||||||
|
<Option value="update">更新</Option>
|
||||||
|
<Option value="delete">删除</Option>
|
||||||
|
<Option value="adjust">调整</Option>
|
||||||
|
<Option value="import">导入</Option>
|
||||||
|
</Select>
|
||||||
|
<RangePicker
|
||||||
|
value={filters.dateRange}
|
||||||
|
onChange={(dates) => handleFilterChange('dateRange', dates)}
|
||||||
|
placeholder={['开始日期', '结束日期']}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon={<HistoryOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setFilters({ operationType: 'all', consumableId: '', dateRange: null });
|
||||||
|
fetchLogs(1, pagination.pageSize);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重置筛选
|
||||||
|
</Button>
|
||||||
|
<Dropdown
|
||||||
|
menu={{
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'csv',
|
||||||
|
icon: <FileOutlined />,
|
||||||
|
label: '导出CSV',
|
||||||
|
onClick: () => handleExport(filters)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'excel',
|
||||||
|
icon: <FileExcelOutlined />,
|
||||||
|
label: '导出Excel',
|
||||||
|
onClick: () => handleExportExcel(filters)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button icon={<DownloadOutlined />}>
|
||||||
|
导出 <DownOutlined />
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
<Button icon={<UploadOutlined />} onClick={() => setImportModalVisible(true)}>
|
||||||
|
导入
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={logs}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={{
|
||||||
|
...pagination,
|
||||||
|
showTotal: (total) => `共 ${total} 条记录`,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showQuickJumper: true
|
||||||
|
}}
|
||||||
|
onChange={(pagination) => fetchLogs(pagination.current, pagination.pageSize)}
|
||||||
|
scroll={{ x: 1500 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="导入操作日志"
|
||||||
|
open={importModalVisible}
|
||||||
|
onCancel={() => {
|
||||||
|
setImportModalVisible(false);
|
||||||
|
setImportType('excel');
|
||||||
|
}}
|
||||||
|
footer={null}
|
||||||
|
width={500}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Radio.Group
|
||||||
|
value={importType}
|
||||||
|
onChange={(e) => setImportType(e.target.value)}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
>
|
||||||
|
<Radio.Button value="excel">Excel文件</Radio.Button>
|
||||||
|
<Radio.Button value="csv">CSV文件</Radio.Button>
|
||||||
|
</Radio.Group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Button type="link" onClick={downloadTemplate}>
|
||||||
|
下载导入模板
|
||||||
|
</Button>
|
||||||
|
<span style={{ color: '#888', marginLeft: 8 }}>(建议先下载模板填写)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Upload
|
||||||
|
accept={importType === 'excel' ? '.xlsx,.xls' : '.csv'}
|
||||||
|
showUploadList={false}
|
||||||
|
beforeUpload={handleImport}
|
||||||
|
>
|
||||||
|
<Button type="primary" icon={<UploadOutlined />} loading={importing}>
|
||||||
|
选择{importType === 'excel' ? 'Excel' : 'CSV'}文件并导入
|
||||||
|
</Button>
|
||||||
|
</Upload>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 16, color: '#888', fontSize: 12 }}>
|
||||||
|
<p>注意事项:</p>
|
||||||
|
<ul>
|
||||||
|
<li>支持 Excel (.xlsx, .xls) 或 CSV 格式</li>
|
||||||
|
<li>文件列名必须包含:耗材ID、操作类型</li>
|
||||||
|
<li>操作类型支持:入库、出库、创建、更新、删除、调整、导入</li>
|
||||||
|
<li>系统将根据筛选条件过滤需要导出的数据</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ConsumableLogs;
|
||||||
@@ -0,0 +1,611 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Table, Button, Modal, Form, Input, Select, InputNumber, message, Card, Space, Popconfirm, Upload, Table as AntTable } from 'antd';
|
||||||
|
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ExportOutlined, ImportOutlined, UploadOutlined, FileExcelOutlined, InboxOutlined } from '@ant-design/icons';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const { Option } = Select;
|
||||||
|
|
||||||
|
function ConsumableManagement() {
|
||||||
|
const [consumables, setConsumables] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const [editingConsumable, setEditingConsumable] = useState(null);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [categories, setCategories] = useState([]);
|
||||||
|
const [pagination, setPagination] = useState({
|
||||||
|
current: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
total: 0,
|
||||||
|
showTotal: (total) => `共 ${total} 条记录`
|
||||||
|
});
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [category, setCategory] = useState('all');
|
||||||
|
const [status, setStatus] = useState('all');
|
||||||
|
const [importModalVisible, setImportModalVisible] = useState(false);
|
||||||
|
const [importPreview, setImportPreview] = useState([]);
|
||||||
|
const [importFile, setImportFile] = useState(null);
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
const importFormRef = useRef(null);
|
||||||
|
const [stockModalVisible, setStockModalVisible] = useState(false);
|
||||||
|
const [stockRecord, setStockRecord] = useState(null);
|
||||||
|
const [stockType, setStockType] = useState('in');
|
||||||
|
const [stockForm] = Form.useForm();
|
||||||
|
|
||||||
|
const fetchConsumables = async (page = 1, pageSize = 10) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await axios.get('/api/consumables', {
|
||||||
|
params: { page, pageSize, keyword, category, status }
|
||||||
|
});
|
||||||
|
setConsumables(response.data.consumables);
|
||||||
|
setPagination(prev => ({ ...prev, current: page, pageSize, total: response.data.total }));
|
||||||
|
} catch (error) {
|
||||||
|
message.error('获取耗材列表失败');
|
||||||
|
console.error('获取耗材列表失败:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchCategories = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/consumable-categories/list');
|
||||||
|
setCategories(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取分类列表失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConsumables();
|
||||||
|
fetchCategories();
|
||||||
|
}, [keyword, category, status]);
|
||||||
|
|
||||||
|
const showModal = (consumable = null) => {
|
||||||
|
setEditingConsumable(consumable);
|
||||||
|
if (consumable) {
|
||||||
|
form.setFieldsValue(consumable);
|
||||||
|
} else {
|
||||||
|
form.resetFields();
|
||||||
|
}
|
||||||
|
setModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setModalVisible(false);
|
||||||
|
setEditingConsumable(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (values) => {
|
||||||
|
try {
|
||||||
|
if (editingConsumable) {
|
||||||
|
await axios.put(`/api/consumables/${editingConsumable.consumableId}`, values);
|
||||||
|
message.success('耗材更新成功');
|
||||||
|
} else {
|
||||||
|
await axios.post('/api/consumables', {
|
||||||
|
...values,
|
||||||
|
consumableId: `CON${Date.now()}`
|
||||||
|
});
|
||||||
|
message.success('耗材创建成功');
|
||||||
|
}
|
||||||
|
setModalVisible(false);
|
||||||
|
fetchConsumables();
|
||||||
|
setEditingConsumable(null);
|
||||||
|
} catch (error) {
|
||||||
|
message.error(editingConsumable ? '耗材更新失败' : '耗材创建失败');
|
||||||
|
console.error('提交失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (consumableId) => {
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/consumables/${consumableId}`);
|
||||||
|
message.success('删除成功');
|
||||||
|
fetchConsumables();
|
||||||
|
} catch (error) {
|
||||||
|
message.error('删除失败');
|
||||||
|
console.error('删除失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (value) => {
|
||||||
|
setKeyword(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportToCSV = (data, filename) => {
|
||||||
|
const headers = ['耗材ID', '名称', '分类', '单位', '当前库存', '最小库存', '最大库存', '单价', '供应商', '存放位置', '状态'];
|
||||||
|
const keys = ['consumableId', 'name', 'category', 'unit', 'currentStock', 'minStock', 'maxStock', 'unitPrice', 'supplier', 'location', 'status'];
|
||||||
|
|
||||||
|
const csvContent = [
|
||||||
|
headers.join(','),
|
||||||
|
...data.map(row => keys.map(key => {
|
||||||
|
let value = row[key];
|
||||||
|
if (key === 'unitPrice') value = `¥${parseFloat(value || 0).toFixed(2)}`;
|
||||||
|
if (key === 'status') value = value === 'active' ? '启用' : '停用';
|
||||||
|
if (value === null || value === undefined) value = '';
|
||||||
|
const str = String(value);
|
||||||
|
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
||||||
|
return `"${str.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
}).join(','))
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/consumables', {
|
||||||
|
params: { keyword, category, status, pageSize: 1000 }
|
||||||
|
});
|
||||||
|
const consumables = response.data.consumables;
|
||||||
|
exportToCSV(consumables, `consumables_${new Date().toISOString().split('T')[0]}.csv`);
|
||||||
|
message.success('导出成功');
|
||||||
|
} catch (error) {
|
||||||
|
message.error('导出失败');
|
||||||
|
console.error('导出失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseCSV = (text) => {
|
||||||
|
const lines = text.trim().split('\n');
|
||||||
|
if (lines.length < 2) return [];
|
||||||
|
|
||||||
|
const headers = lines[0].split(',').map(h => h.trim().replace(/^"|"$/g, ''));
|
||||||
|
const data = [];
|
||||||
|
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const line = lines[i].trim();
|
||||||
|
if (!line) continue;
|
||||||
|
|
||||||
|
let values = [];
|
||||||
|
let inQuotes = false;
|
||||||
|
let current = '';
|
||||||
|
|
||||||
|
for (let j = 0; j < line.length; j++) {
|
||||||
|
const char = line[j];
|
||||||
|
if (char === '"') {
|
||||||
|
inQuotes = !inQuotes;
|
||||||
|
} else if (char === ',' && !inQuotes) {
|
||||||
|
values.push(current.trim().replace(/^"|"$/g, ''));
|
||||||
|
current = '';
|
||||||
|
} else {
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
values.push(current.trim().replace(/^"|"$/g, ''));
|
||||||
|
|
||||||
|
const row = {};
|
||||||
|
headers.forEach((header, idx) => {
|
||||||
|
row[header] = values[idx] || '';
|
||||||
|
});
|
||||||
|
data.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (info) => {
|
||||||
|
const file = info.fileList[info.fileList.length - 1];
|
||||||
|
if (file && file.originFileObj) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const text = e.target.result;
|
||||||
|
const parsedData = parseCSV(text);
|
||||||
|
setImportPreview(parsedData.slice(0, 10));
|
||||||
|
setImportFile(file.originFileObj);
|
||||||
|
};
|
||||||
|
reader.readAsText(file.originFileObj);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showImportModal = () => {
|
||||||
|
setImportPreview([]);
|
||||||
|
setImportFile(null);
|
||||||
|
setImportModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportCancel = () => {
|
||||||
|
setImportModalVisible(false);
|
||||||
|
setImportPreview([]);
|
||||||
|
setImportFile(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
|
if (!importFile) {
|
||||||
|
message.warning('请先选择文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setImporting(true);
|
||||||
|
try {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (e) => {
|
||||||
|
const text = e.target.result;
|
||||||
|
const items = parseCSV(text);
|
||||||
|
|
||||||
|
const response = await axios.post('/api/consumables/import', { items });
|
||||||
|
message.success(response.data.message);
|
||||||
|
|
||||||
|
if (response.data.results.failed > 0) {
|
||||||
|
response.data.results.errors.forEach(err => console.error(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
setImportModalVisible(false);
|
||||||
|
setImportPreview([]);
|
||||||
|
setImportFile(null);
|
||||||
|
fetchConsumables();
|
||||||
|
setImporting(false);
|
||||||
|
};
|
||||||
|
reader.readAsText(importFile);
|
||||||
|
} catch (error) {
|
||||||
|
message.error('导入失败');
|
||||||
|
console.error('导入失败:', error);
|
||||||
|
setImporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadTemplate = () => {
|
||||||
|
const template = '耗材ID,名称,分类,单位,当前库存,最小库存,最大库存,单价,供应商,存放位置,描述,状态\n,测试耗材,办公用品,个,100,10,500,5.00,XX公司,A柜-01层,测试数据,active';
|
||||||
|
const blob = new Blob([template], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = '耗材导入模板.csv';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showStockModal = (record, type) => {
|
||||||
|
setStockRecord(record);
|
||||||
|
setStockType(type);
|
||||||
|
stockForm.setFieldsValue({
|
||||||
|
consumableId: record.consumableId,
|
||||||
|
consumableName: record.name,
|
||||||
|
quantity: 1,
|
||||||
|
reason: '',
|
||||||
|
notes: ''
|
||||||
|
});
|
||||||
|
setStockModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStockCancel = () => {
|
||||||
|
setStockModalVisible(false);
|
||||||
|
setStockRecord(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStockSubmit = async (values) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post('/api/consumables/quick-inout', {
|
||||||
|
consumableId: stockRecord.consumableId,
|
||||||
|
type: stockType,
|
||||||
|
quantity: values.quantity,
|
||||||
|
operator: values.operator || '系统管理员',
|
||||||
|
reason: values.reason,
|
||||||
|
notes: values.notes
|
||||||
|
});
|
||||||
|
message.success(`${stockType === 'in' ? '入库' : '出库'}操作成功`);
|
||||||
|
setStockModalVisible(false);
|
||||||
|
fetchConsumables();
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error.response?.data?.error || `${stockType === 'in' ? '入库' : '出库'}操作失败`);
|
||||||
|
console.error('操作失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '耗材ID',
|
||||||
|
dataIndex: 'consumableId',
|
||||||
|
key: 'consumableId',
|
||||||
|
width: 150
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
width: 150
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '分类',
|
||||||
|
dataIndex: 'category',
|
||||||
|
key: 'category',
|
||||||
|
width: 120
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '单位',
|
||||||
|
dataIndex: 'unit',
|
||||||
|
key: 'unit',
|
||||||
|
width: 80
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '当前库存',
|
||||||
|
dataIndex: 'currentStock',
|
||||||
|
key: 'currentStock',
|
||||||
|
width: 100,
|
||||||
|
render: (value, record) => {
|
||||||
|
const isLow = value <= record.minStock;
|
||||||
|
return (
|
||||||
|
<span style={{ color: isLow ? '#ff4d4f' : '#52c41a', fontWeight: 'bold' }}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '最小库存',
|
||||||
|
dataIndex: 'minStock',
|
||||||
|
key: 'minStock',
|
||||||
|
width: 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '最大库存',
|
||||||
|
dataIndex: 'maxStock',
|
||||||
|
key: 'maxStock',
|
||||||
|
width: 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '单价(元)',
|
||||||
|
dataIndex: 'unitPrice',
|
||||||
|
key: 'unitPrice',
|
||||||
|
width: 100,
|
||||||
|
render: (value) => `¥${parseFloat(value || 0).toFixed(2)}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '供应商',
|
||||||
|
dataIndex: 'supplier',
|
||||||
|
key: 'supplier',
|
||||||
|
width: 150,
|
||||||
|
render: (value) => value || '-'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '位置',
|
||||||
|
dataIndex: 'location',
|
||||||
|
key: 'location',
|
||||||
|
width: 120,
|
||||||
|
render: (value) => value || '-'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
width: 100,
|
||||||
|
render: (value) => (
|
||||||
|
<span style={{ color: value === 'active' ? '#52c41a' : '#ff4d4f' }}>
|
||||||
|
{value === 'active' ? '启用' : '停用'}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 200,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" icon={<EditOutlined />} size="small" onClick={() => showModal(record)}>编辑</Button>
|
||||||
|
<Button type="default" icon={<InboxOutlined />} size="small" style={{ background: '#f6ffed', borderColor: '#b7eb8f', color: '#52c41a' }} onClick={() => showStockModal(record, 'in')}>入库</Button>
|
||||||
|
<Button type="default" icon={<ExportOutlined />} size="small" style={{ background: '#fff2f0', borderColor: '#ffccc7', color: '#ff4d4f' }} onClick={() => showStockModal(record, 'out')}>出库</Button>
|
||||||
|
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(record.consumableId)}>
|
||||||
|
<Button danger icon={<DeleteOutlined />} size="small">删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const previewColumns = [
|
||||||
|
{ title: '名称', dataIndex: '名称', key: 'name', width: 120 },
|
||||||
|
{ title: '分类', dataIndex: '分类', key: 'category', width: 100 },
|
||||||
|
{ title: '单位', dataIndex: '单位', key: 'unit', width: 80 },
|
||||||
|
{ title: '当前库存', dataIndex: '当前库存', key: 'currentStock', width: 90 },
|
||||||
|
{ title: '单价', dataIndex: '单价', key: 'unitPrice', width: 80 }
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Card title="耗材管理" extra={
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => showModal()}>添加耗材</Button>
|
||||||
|
<Button icon={<ImportOutlined />} onClick={showImportModal}>导入</Button>
|
||||||
|
<Button icon={<ExportOutlined />} onClick={handleExport}>导出</Button>
|
||||||
|
</Space>
|
||||||
|
}>
|
||||||
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
|
<Space>
|
||||||
|
<Input.Search
|
||||||
|
placeholder="搜索耗材ID、名称、分类、供应商"
|
||||||
|
style={{ width: 300 }}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
<Select value={category} onChange={setCategory} style={{ width: 150 }}>
|
||||||
|
<Option value="all">所有分类</Option>
|
||||||
|
{categories.map(cat => (
|
||||||
|
<Option key={cat.id} value={cat.name}>{cat.name}</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Select value={status} onChange={setStatus} style={{ width: 120 }}>
|
||||||
|
<Option value="all">所有状态</Option>
|
||||||
|
<Option value="active">启用</Option>
|
||||||
|
<Option value="inactive">停用</Option>
|
||||||
|
</Select>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={consumables}
|
||||||
|
rowKey="consumableId"
|
||||||
|
loading={loading}
|
||||||
|
pagination={pagination}
|
||||||
|
onChange={(pagination) => fetchConsumables(pagination.current, pagination.pageSize)}
|
||||||
|
scroll={{ x: 1300 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editingConsumable ? '编辑耗材' : '添加耗材'}
|
||||||
|
open={modalVisible}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
footer={null}
|
||||||
|
width={600}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||||
|
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||||
|
<Input placeholder="请输入耗材名称" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="category" label="分类" rules={[{ required: true, message: '请选择分类' }]}>
|
||||||
|
<Select
|
||||||
|
placeholder="请选择分类"
|
||||||
|
allowClear
|
||||||
|
>
|
||||||
|
{categories.map(cat => (
|
||||||
|
<Option key={cat.id} value={cat.name}>{cat.name}</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="unit" label="单位" rules={[{ required: true, message: '请输入单位' }]} initialValue="个">
|
||||||
|
<Input placeholder="如: 个、盒、卷、箱" />
|
||||||
|
</Form.Item>
|
||||||
|
<Space style={{ width: '100%' }}>
|
||||||
|
<Form.Item name="currentStock" label="当前库存" rules={[{ required: true, message: '请输入当前库存' }]}>
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="minStock" label="最小库存" rules={[{ required: true, message: '请输入最小库存' }]}>
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="maxStock" label="最大库存" rules={[{ required: true, message: '请输入最大库存' }]}>
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
<Form.Item name="unitPrice" label="单价(元)">
|
||||||
|
<InputNumber min={0} step={0.01} precision={2} style={{ width: '100%' }} placeholder="请输入单价" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="supplier" label="供应商">
|
||||||
|
<Input placeholder="请输入供应商" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="location" label="存放位置">
|
||||||
|
<Input placeholder="如: A柜-01层、B区货架3" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description" label="描述">
|
||||||
|
<Input.TextArea rows={3} placeholder="请输入描述信息" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="status" label="状态" rules={[{ required: true, message: '请选择状态' }]}>
|
||||||
|
<Select>
|
||||||
|
<Option value="active">启用</Option>
|
||||||
|
<Option value="inactive">停用</Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" htmlType="submit">{editingConsumable ? '更新' : '创建'}</Button>
|
||||||
|
<Button onClick={handleCancel}>取消</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="导入耗材"
|
||||||
|
open={importModalVisible}
|
||||||
|
onCancel={handleImportCancel}
|
||||||
|
footer={null}
|
||||||
|
width={700}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<Card size="small" style={{ background: '#f5f5f5' }}>
|
||||||
|
<Space>
|
||||||
|
<Button icon={<FileExcelOutlined />} onClick={downloadTemplate}>下载模板</Button>
|
||||||
|
<span style={{ color: '#888', fontSize: 12 }}>请下载模板后填写数据再导入</span>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Upload
|
||||||
|
accept=".csv"
|
||||||
|
maxCount={1}
|
||||||
|
beforeUpload={() => false}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
>
|
||||||
|
<Button icon={<UploadOutlined />}>选择CSV文件</Button>
|
||||||
|
</Upload>
|
||||||
|
|
||||||
|
{importPreview.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 8, fontWeight: 'bold' }}>预览 (前10条):</div>
|
||||||
|
<Table
|
||||||
|
columns={previewColumns}
|
||||||
|
dataSource={importPreview}
|
||||||
|
rowKey={(record, index) => index}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
scroll={{ x: 500 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<Space>
|
||||||
|
<Button onClick={handleImportCancel}>取消</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={handleImport}
|
||||||
|
loading={importing}
|
||||||
|
disabled={!importFile}
|
||||||
|
>
|
||||||
|
开始导入
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={stockType === 'in' ? '耗材入库' : '耗材出库'}
|
||||||
|
open={stockModalVisible}
|
||||||
|
onCancel={handleStockCancel}
|
||||||
|
footer={null}
|
||||||
|
width={500}
|
||||||
|
>
|
||||||
|
<Form form={stockForm} layout="vertical" onFinish={handleStockSubmit}>
|
||||||
|
<Form.Item name="consumableId" label="耗材ID">
|
||||||
|
<Input disabled />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="consumableName" label="耗材名称">
|
||||||
|
<Input disabled />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="quantity" label="数量" rules={[{ required: true, message: '请输入数量' }]}>
|
||||||
|
<InputNumber min={1} style={{ width: '100%' }} placeholder="请输入数量" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="operator" label="操作人">
|
||||||
|
<Input placeholder="请输入操作人姓名" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="reason" label="入库/出库原因">
|
||||||
|
<Input placeholder="如: 采购入库、部门领用、报损出库" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="notes" label="备注">
|
||||||
|
<Input.TextArea rows={2} placeholder="请输入备注信息" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" htmlType="submit">{stockType === 'in' ? '确认入库' : '确认出库'}</Button>
|
||||||
|
<Button onClick={handleStockCancel}>取消</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ConsumableManagement;
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Card, Row, Col, Statistic, Table, Tag, DatePicker, Space, Select, Progress, message } from 'antd';
|
||||||
|
import { InboxOutlined, ExportOutlined, WarningOutlined, DollarOutlined, ShoppingCartOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
|
||||||
|
import axios from 'axios';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
|
function ConsumableStatistics() {
|
||||||
|
const [summary, setSummary] = useState({ total: 0, lowStock: 0, totalValue: 0, byCategory: [] });
|
||||||
|
const [lowStockItems, setLowStockItems] = useState([]);
|
||||||
|
const [stats, setStats] = useState({ inCount: 0, outCount: 0, inQuantity: 0, outQuantity: 0, recentRecords: [] });
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [dateRange, setDateRange] = useState([]);
|
||||||
|
|
||||||
|
const fetchSummary = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/consumables/statistics/summary');
|
||||||
|
setSummary(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
message.error('获取统计信息失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchLowStock = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/consumables/low-stock');
|
||||||
|
setLowStockItems(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
message.error('获取低库存信息失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchInOutStats = async () => {
|
||||||
|
try {
|
||||||
|
const params = {};
|
||||||
|
if (dateRange && dateRange.length === 2) {
|
||||||
|
params.startDate = dateRange[0].toISOString();
|
||||||
|
params.endDate = dateRange[1].toISOString();
|
||||||
|
}
|
||||||
|
const response = await axios.get('/api/consumable-records/statistics', { params });
|
||||||
|
setStats(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
message.error('获取出入库统计失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSummary();
|
||||||
|
fetchLowStock();
|
||||||
|
fetchInOutStats();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchInOutStats();
|
||||||
|
}, [dateRange]);
|
||||||
|
|
||||||
|
const lowStockColumns = [
|
||||||
|
{
|
||||||
|
title: '耗材名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
width: 150
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '分类',
|
||||||
|
dataIndex: 'category',
|
||||||
|
key: 'category',
|
||||||
|
width: 120
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '当前库存',
|
||||||
|
dataIndex: 'currentStock',
|
||||||
|
key: 'currentStock',
|
||||||
|
width: 100,
|
||||||
|
render: (value) => (
|
||||||
|
<span style={{ color: '#ff4d4f', fontWeight: 'bold' }}>{value}</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '最小库存',
|
||||||
|
dataIndex: 'minStock',
|
||||||
|
key: 'minStock',
|
||||||
|
width: 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '单位',
|
||||||
|
dataIndex: 'unit',
|
||||||
|
key: 'unit',
|
||||||
|
width: 80
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '库存充足率',
|
||||||
|
key: 'rate',
|
||||||
|
width: 150,
|
||||||
|
render: (_, record) => {
|
||||||
|
const rate = Math.min(100, Math.round((record.currentStock / record.maxStock) * 100));
|
||||||
|
const status = rate < 30 ? 'exception' : rate < 60 ? 'active' : 'success';
|
||||||
|
return <Progress percent={rate} size="small" status={status} />;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '供应商',
|
||||||
|
dataIndex: 'supplier',
|
||||||
|
key: 'supplier',
|
||||||
|
width: 150,
|
||||||
|
render: (value) => value || '-'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const recentColumns = [
|
||||||
|
{
|
||||||
|
title: '时间',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
key: 'createdAt',
|
||||||
|
width: 180,
|
||||||
|
render: (date) => dayjs(date).format('YYYY-MM-DD HH:mm:ss')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '耗材名称',
|
||||||
|
dataIndex: ['Consumable', 'name'],
|
||||||
|
key: 'consumableName',
|
||||||
|
width: 150
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '类型',
|
||||||
|
dataIndex: 'type',
|
||||||
|
key: 'type',
|
||||||
|
width: 100,
|
||||||
|
render: (type) => (
|
||||||
|
<Tag color={type === 'in' ? 'green' : 'red'}>
|
||||||
|
{type === 'in' ? '入库' : '出库'}
|
||||||
|
</Tag>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '数量',
|
||||||
|
dataIndex: 'quantity',
|
||||||
|
key: 'quantity',
|
||||||
|
width: 100,
|
||||||
|
render: (value, record) => (
|
||||||
|
<span style={{ color: record.type === 'in' ? '#52c41a' : '#ff4d4f' }}>
|
||||||
|
{record.type === 'in' ? '+' : '-'}{value}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作人',
|
||||||
|
dataIndex: 'operator',
|
||||||
|
key: 'operator',
|
||||||
|
width: 120
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '原因',
|
||||||
|
dataIndex: 'reason',
|
||||||
|
key: 'reason',
|
||||||
|
width: 150,
|
||||||
|
render: (value) => value || '-'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="耗材种类总数"
|
||||||
|
value={summary.total}
|
||||||
|
prefix={<ShoppingCartOutlined style={{ color: '#1890ff' }} />}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="低库存预警"
|
||||||
|
value={summary.lowStock}
|
||||||
|
prefix={<WarningOutlined style={{ color: summary.lowStock > 0 ? '#ff4d4f' : '#52c41a' }} />}
|
||||||
|
valueStyle={{ color: summary.lowStock > 0 ? '#ff4d4f' : '#52c41a' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="库存总价值"
|
||||||
|
value={parseFloat(summary.totalValue || 0)}
|
||||||
|
prefix={<DollarOutlined style={{ color: '#52c41a' }} />}
|
||||||
|
precision={2}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="净入库量"
|
||||||
|
value={stats.inQuantity - stats.outQuantity}
|
||||||
|
prefix={<InboxOutlined style={{ color: stats.inQuantity - stats.outQuantity >= 0 ? '#52c41a' : '#ff4d4f' }} />}
|
||||||
|
valueStyle={{ color: stats.inQuantity - stats.outQuantity >= 0 ? '#52c41a' : '#ff4d4f' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Card title="入库出库统计" extra={
|
||||||
|
<RangePicker value={dateRange} onChange={setDateRange} />
|
||||||
|
}>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Card size="small" style={{ background: '#f6ffed', borderColor: '#b7eb8f' }}>
|
||||||
|
<Statistic
|
||||||
|
title="入库次数"
|
||||||
|
value={stats.inCount}
|
||||||
|
prefix={<InboxOutlined style={{ color: '#52c41a' }} />}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 8, color: '#52c41a', fontSize: 20, fontWeight: 'bold' }}>
|
||||||
|
+{stats.inQuantity}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Card size="small" style={{ background: '#fff1f0', borderColor: '#ffa39e' }}>
|
||||||
|
<Statistic
|
||||||
|
title="出库次数"
|
||||||
|
value={stats.outCount}
|
||||||
|
prefix={<ExportOutlined style={{ color: '#ff4d4f' }} />}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 8, color: '#ff4d4f', fontSize: 20, fontWeight: 'bold' }}>
|
||||||
|
-{stats.outQuantity}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Card title="分类统计">
|
||||||
|
<Space wrap>
|
||||||
|
{summary.byCategory?.map(item => (
|
||||||
|
<Card size="small" key={item.category} style={{ width: 140 }}>
|
||||||
|
<Statistic
|
||||||
|
title={item.category}
|
||||||
|
value={item.count}
|
||||||
|
valueStyle={{ fontSize: 24 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Card
|
||||||
|
title={<span><ExclamationCircleOutlined style={{ color: '#ff4d4f', marginRight: 8 }} />低库存预警</span>}
|
||||||
|
extra={<Tag color="red">{lowStockItems.length}项</Tag>}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
columns={lowStockColumns}
|
||||||
|
dataSource={lowStockItems}
|
||||||
|
rowKey="consumableId"
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
scroll={{ x: 800 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Card title="最近出入库记录">
|
||||||
|
<Table
|
||||||
|
columns={recentColumns}
|
||||||
|
dataSource={stats.recentRecords}
|
||||||
|
rowKey="recordId"
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
scroll={{ x: 900 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ConsumableStatistics;
|
||||||
Generated
+13
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "idc-device-management",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "idc-device-management",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user