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({
|
||||
dialect: 'sqlite',
|
||||
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",
|
||||
"csv-parser": "^3.2.0",
|
||||
"csv-writer": "^1.6.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.18.2",
|
||||
"express-fileupload": "^1.5.2",
|
||||
@@ -645,6 +646,12 @@
|
||||
"integrity": "sha512-NOx7YDFWEsM/fTRAJjRpPp8t+MKRVvniAg9wQlUKx20MFrPs73WLJhFf5iteqrxNYnsy924K3Iroh3yNHeYd2g==",
|
||||
"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": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"cors": "^2.8.5",
|
||||
"csv-parser": "^3.2.0",
|
||||
"csv-writer": "^1.6.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.18.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 deviceFieldRoutes = require('./routes/deviceFields');
|
||||
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);
|
||||
@@ -46,6 +49,9 @@ app.use('/api/racks', rackRoutes);
|
||||
app.use('/api/rooms', roomRoutes);
|
||||
app.use('/api/deviceFields', deviceFieldRoutes);
|
||||
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'));
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user