Files
yunrui_asset/backend/routes/consumables.js
T

1937 lines
57 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const logger = require('../utils/logger').module('ConsumablesRoute');
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');
const ConsumableLogArchive = require('../models/ConsumableLogArchive');
const { PAGINATION, RETRY } = require('../config');
const { generateId } = require('../utils/idGenerator');
router.get('/', async (req, res) => {
try {
const {
keyword,
category,
status,
page = 1,
pageSize = PAGINATION.DEFAULT_PAGE_SIZE,
} = 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']],
});
const consumables = rows.map(item => {
const data = item.toJSON();
if (!Array.isArray(data.snList)) {
data.snList = [];
}
return data;
});
res.json({
total: count,
consumables,
page: parseInt(page),
pageSize: parseInt(pageSize),
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
const MAX_EXPORT_SIZE = 50000;
router.get('/export', async (req, res) => {
try {
const {
keyword,
category,
status,
stockStatus, // 新增:warning/normal/all
ids, // 新增:耗材ID列表,逗号分隔
fields, // 新增:要导出的字段列表,逗号分隔
} = req.query;
// 构建查询条件
const where = {};
if (keyword) {
where[Op.or] = [
{ name: { [Op.like]: `%${keyword}%` } },
{ consumableId: { [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;
}
// 支持导出选中项
if (ids) {
const idList = ids.split(',').map(id => id.trim()).filter(Boolean);
if (idList.length > 0) {
where.consumableId = { [Op.in]: idList };
}
}
// 支持导出预警库存
if (stockStatus === 'warning') {
where[Op.and] = [
{
[Op.or]: [
{ currentStock: { [Op.lte]: sequelize.col('minStock') } },
{
[Op.and]: [
{ maxStock: { [Op.gt]: 0 } },
{ currentStock: { [Op.gte]: sequelize.col('maxStock') } },
],
},
],
},
];
} else if (stockStatus === 'normal') {
where[Op.and] = [
{ currentStock: { [Op.gt]: sequelize.col('minStock') } },
{
[Op.or]: [
{ maxStock: { [Op.eq]: 0 } },
{ currentStock: { [Op.lt]: sequelize.col('maxStock') } },
],
},
];
}
const consumables = await Consumable.findAll({
where,
limit: MAX_EXPORT_SIZE,
order: [['createdAt', 'DESC']],
});
// 处理导出字段
let exportData = consumables;
if (fields) {
const fieldList = fields.split(',').map(f => f.trim()).filter(Boolean);
if (fieldList.length > 0) {
exportData = consumables.map(c => {
const obj = {};
fieldList.forEach(field => {
if (c[field] !== undefined) {
obj[field] = c[field];
}
});
// 始终保留名称
if (!obj.name && c.name) {
obj.name = c.name;
}
return obj;
});
}
}
const result = exportData.map(item => {
const data = item.toJSON ? item.toJSON() : item;
if (!Array.isArray(data.snList)) {
data.snList = [];
}
return data;
});
res.json({
consumables: result,
total: result.length,
});
} catch (error) {
logger.error('导出失败', { error: error.message, stack: error.stack });
res.status(500).json({ error: error.message });
}
});
router.post('/', async (req, res) => {
const transaction = await sequelize.transaction();
try {
const consumableData = {
...req.body,
consumableId: req.body.consumableId || `CON${Date.now()}`,
currentStock: 0, // 强制设为0
snList: [], // 强制设为空数组
};
const consumable = await Consumable.create(consumableData, { 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 || '',
consumableSnapshot: {
category: consumable.category,
unit: consumable.unit,
unitPrice: consumable.unitPrice,
supplier: consumable.supplier,
location: consumable.location,
minStock: consumable.minStock,
maxStock: consumable.maxStock,
},
},
{ transaction }
);
await transaction.commit();
res.status(201).json(consumable);
} catch (error) {
await transaction.rollback();
logger.error('创建耗材错误', { error: error.message, stack: error.stack });
if (error.name === 'SequelizeValidationError') {
const messages = error.errors.map(e => `${e.path}: ${e.message}`).join(', ');
res.status(400).json({ error: `Validation error: ${messages}` });
} else {
res.status(400).json({ error: error.message });
}
}
});
// 创建耗材并同时入库
router.post('/create-with-inbound', async (req, res) => {
const transaction = await sequelize.transaction();
try {
const {
consumableId,
name,
category,
unit,
currentStock,
minStock,
maxStock,
unitPrice,
supplier,
location,
description,
status,
// 入库相关字段
inboundQuantity,
inboundOperator,
inboundReason,
inboundNotes,
inboundSnList,
} = req.body;
// 创建耗材
const consumable = await Consumable.create({
consumableId: consumableId || `CON${Date.now()}`,
name,
category,
unit: unit || '个',
currentStock: 0,
minStock: minStock || 10,
maxStock: maxStock || 0,
unitPrice: unitPrice || 0,
supplier: supplier || '',
location: location || '',
description: description || '',
status: status || 'active',
snList: [],
}, { transaction });
// 记录创建日志
await ConsumableLog.create({
consumableId: consumable.consumableId,
consumableName: consumable.name,
operationType: 'create',
quantity: 0,
previousStock: 0,
currentStock: 0,
operator: inboundOperator || '系统',
reason: '新建耗材',
notes: description || '',
consumableSnapshot: {
category: consumable.category,
unit: consumable.unit,
unitPrice: consumable.unitPrice,
supplier: consumable.supplier,
location: consumable.location,
minStock: consumable.minStock,
maxStock: consumable.maxStock,
},
}, { transaction });
// 执行入库操作
if (inboundQuantity && inboundQuantity > 0) {
const previousStock = 0;
const newStock = inboundQuantity;
let updatedSnList = [...(inboundSnList || [])];
await Consumable.update({
currentStock: newStock,
snList: updatedSnList,
version: sequelize.literal('version + 1'),
}, {
where: { consumableId: consumable.consumableId },
transaction,
});
await ConsumableRecord.create({
consumableId: consumable.consumableId,
type: 'in',
quantity: inboundQuantity,
previousStock,
currentStock: newStock,
operator: inboundOperator || '系统',
reason: inboundReason || '初始入库',
notes: inboundNotes || '',
snList: updatedSnList,
}, { transaction });
await ConsumableLog.create({
consumableId: consumable.consumableId,
consumableName: consumable.name,
operationType: 'in',
quantity: inboundQuantity,
previousStock,
currentStock: newStock,
operator: inboundOperator || '系统',
reason: inboundReason || '初始入库',
notes: inboundNotes || '',
snList: updatedSnList,
consumableSnapshot: {
category: consumable.category,
unit: consumable.unit,
unitPrice: consumable.unitPrice,
supplier: consumable.supplier,
location: consumable.location,
},
}, { transaction });
}
await transaction.commit();
res.status(201).json(consumable);
} catch (error) {
await transaction.rollback();
logger.error('创建耗材并入库错误', { error: error.message, stack: error.stack });
if (error.name === 'SequelizeValidationError') {
const messages = error.errors.map(e => `${e.path}: ${e.message}`).join(', ');
res.status(400).json({ error: `Validation error: ${messages}` });
} else {
res.status(400).json({ error: error.message });
}
}
});
router.post('/import', async (req, res) => {
const transaction = await sequelize.transaction();
try {
const { items, operator = '系统', mode = 'create', stockMode = 'basic' } = 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,
updated: 0,
skipped: 0,
errors: [],
details: [],
};
for (let i = 0; i < items.length; i++) {
const item = items[i];
const rowNumber = i + 1;
try {
const consumableId = item.耗材ID || item.consumableId;
const name = item.名称 || item.name;
const category = item.分类 || item.category;
if (!name || !category) {
results.failed++;
results.errors.push(`第 ${rowNumber} 行: 名称和分类为必填项`);
results.details.push({ row: rowNumber, status: 'failed', error: '名称和分类为必填项' });
continue;
}
let snList = [];
if (stockMode !== 'basic' && (item.SN序列号 || item.snList)) {
const snStr = item.SN序列号 || item.snList;
if (typeof snStr === 'string') {
snList = snStr
.split(/[,;\n]/)
.map(s => s.trim())
.filter(Boolean);
} else if (Array.isArray(snStr)) {
snList = snStr;
}
}
const consumableData = {
consumableId: consumableId || `CON${Date.now()}${i}`,
name,
category,
unit: item.单位 || item.unit || '个',
// 根据 stockMode 决定库存处理方式
currentStock: stockMode === 'basic'
? 0 // basic模式:强制为0
: (snList.length > 0 ? snList.length : parseInt(item.当前库存 || item.currentStock) || 0),
minStock: parseInt(item.最小库存 || item.minStock) || 10,
maxStock: parseInt(item.最大库存 || item.maxStock) || 0,
unitPrice: parseFloat(item.单价 || item.unitPrice) || 0,
supplier: item.供应商 || item.supplier || '',
location: item.存放位置 || item.location || '',
description: item.描述 || item.description || '',
status: item.状态 || item.status || 'active',
snList: stockMode === 'basic' ? [] : snList, // basic模式:强制为空数组
};
let existingConsumable = null;
if (consumableId) {
existingConsumable = await Consumable.findByPk(consumableId, { transaction });
}
let consumable;
let operationType;
let previousStock = 0;
if (existingConsumable) {
if (mode === 'update') {
previousStock = existingConsumable.currentStock;
await existingConsumable.update(consumableData, { transaction });
consumable = existingConsumable;
operationType = 'import_update';
results.updated++;
results.details.push({
row: rowNumber,
status: 'updated',
consumableId: consumable.consumableId,
name: consumable.name,
});
} else {
results.skipped++;
results.details.push({
row: rowNumber,
status: 'skipped',
reason: '耗材已存在',
consumableId: consumableId,
});
continue;
}
} else {
consumable = await Consumable.create(consumableData, { transaction });
operationType = 'import';
results.success++;
results.details.push({
row: rowNumber,
status: 'created',
consumableId: consumable.consumableId,
name: consumable.name,
});
}
// 如果是 inbound 模式且是新创建的耗材,执行入库操作
if (stockMode === 'inbound' && !existingConsumable) {
// 新建模式下的入库
const inboundQuantity = parseInt(item.入库数量 || item.inboundQuantity) || consumable.currentStock;
const inboundSnList = consumable.snList; // 使用处理后的SN列表
// 更新耗材库存
await consumable.update({
currentStock: inboundQuantity,
snList: inboundSnList,
version: sequelize.literal('version + 1'),
}, { transaction });
// 创建入库记录
await ConsumableRecord.create({
consumableId: consumable.consumableId,
type: 'in',
quantity: inboundQuantity,
previousStock: 0,
currentStock: inboundQuantity,
operator: item.操作人 || operator,
reason: item.原因 || '批量导入入库',
notes: '',
snList: inboundSnList,
}, { transaction });
// 创建入库日志
await ConsumableLog.create({
consumableId: consumable.consumableId,
consumableName: consumable.name,
operationType: 'in',
quantity: inboundQuantity,
previousStock: 0,
currentStock: inboundQuantity,
operator: item.操作人 || operator,
reason: item.原因 || '批量导入入库',
notes: '',
snList: inboundSnList,
consumableSnapshot: {
category: consumable.category,
unit: consumable.unit,
unitPrice: consumable.unitPrice,
supplier: consumable.supplier,
location: consumable.location,
},
}, { transaction });
}
// 对于 update 模式下的 inbound,计算库存差异
if (stockMode === 'inbound' && existingConsumable && mode === 'update') {
const inboundQuantity = parseInt(item.入库数量 || item.inboundQuantity) || 0;
if (inboundQuantity > 0) {
const prevStock = existingConsumable.currentStock;
const newStock = prevStock + inboundQuantity;
const inboundSnList = consumable.snList || [];
await consumable.update({
currentStock: newStock,
snList: inboundSnList,
version: sequelize.literal('version + 1'),
}, { transaction });
await ConsumableRecord.create({
consumableId: consumable.consumableId,
type: 'in',
quantity: inboundQuantity,
previousStock: prevStock,
currentStock: newStock,
operator: item.操作人 || operator,
reason: item.原因 || '批量导入入库',
notes: '',
snList: inboundSnList,
}, { transaction });
await ConsumableLog.create({
consumableId: consumable.consumableId,
consumableName: consumable.name,
operationType: 'in',
quantity: inboundQuantity,
previousStock: prevStock,
currentStock: newStock,
operator: item.操作人 || operator,
reason: item.原因 || '批量导入入库',
notes: '',
snList: inboundSnList,
consumableSnapshot: {
category: consumable.category,
unit: consumable.unit,
unitPrice: consumable.unitPrice,
supplier: consumable.supplier,
location: consumable.location,
},
}, { transaction });
}
}
await ConsumableLog.create(
{
consumableId: consumable.consumableId,
consumableName: consumable.name,
operationType: stockMode === 'inbound' && !existingConsumable ? 'import' : 'import_update',
quantity: stockMode === 'basic' ? 0 : consumable.currentStock,
previousStock: stockMode === 'basic' ? 0 : previousStock,
currentStock: stockMode === 'basic' ? 0 : consumable.currentStock,
operator,
reason: '批量导入',
notes: existingConsumable ? '更新现有耗材' : (stockMode === 'inbound' ? '导入并入库' : ''),
consumableSnapshot: {
category: consumable.category,
unit: consumable.unit,
unitPrice: consumable.unitPrice,
supplier: consumable.supplier,
location: consumable.location,
minStock: consumable.minStock,
maxStock: consumable.maxStock,
},
},
{ transaction }
);
} catch (error) {
results.failed++;
results.errors.push(`第 ${rowNumber} 行: ${error.message}`);
results.details.push({ row: rowNumber, status: 'failed', error: error.message });
}
}
await transaction.commit();
res.json({
message: `导入完成,成功 ${results.success} 条,更新 ${results.updated} 条,跳过 ${results.skipped} 条,失败 ${results.failed} 条`,
results,
});
} catch (error) {
await transaction.rollback();
res.status(500).json({ error: error.message });
}
});
router.get('/by-sn/:sn', async (req, res) => {
try {
const sn = req.params.sn;
// 获取所有有 snList 的耗材
const consumables = await Consumable.findAll({
attributes: ['consumableId', 'name', 'category', 'currentStock', 'unit', 'snList', 'location', 'status'],
});
// 精确匹配 SN(JSON 数组中的元素)
const consumable = consumables.find(c => {
const snList = Array.isArray(c.snList) ? c.snList : [];
return snList.includes(sn);
});
const result = consumable ? consumable.toJSON() : null;
if (result && !Array.isArray(result.snList)) {
result.snList = [];
}
res.json({
found: !!consumable,
consumable: result,
});
} catch (error) {
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: {
status: 'active',
[Op.and]: [
sequelize.where(sequelize.col('currentStock'), {
[Op.lte]: sequelize.col('minStock'),
}),
sequelize.where(sequelize.col('minStock'), {
[Op.gt]: 0,
}),
],
},
order: [['currentStock', 'ASC']],
});
res.json(consumables);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.get('/statistics/summary', async (req, res) => {
try {
const consumables = await Consumable.findAll({
attributes: ['currentStock', 'unitPrice', 'category', 'minStock', 'status'],
});
let total = 0;
let lowStock = 0;
let totalValue = 0;
const categoryMap = {};
consumables.forEach(item => {
if (item.status === 'inactive') {
return;
}
total++;
const currentStock = parseFloat(item.currentStock) || 0;
const unitPrice = parseFloat(item.unitPrice) || 0;
const minStockValue = parseFloat(item.minStock) || 0;
totalValue += currentStock * unitPrice;
if (minStockValue > 0 && currentStock <= minStockValue) {
lowStock++;
}
if (item.category) {
if (!categoryMap[item.category]) {
categoryMap[item.category] = { count: 0, totalQuantity: 0 };
}
categoryMap[item.category].count++;
categoryMap[item.category].totalQuantity += currentStock;
}
});
const byCategory = Object.entries(categoryMap).map(([category, data]) => ({
category,
count: data.count,
totalQuantity: data.totalQuantity,
}));
res.json({
total,
lowStock,
totalValue: totalValue.toFixed(2),
byCategory,
});
} 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) => {
let attempt = 0;
while (attempt < RETRY.MAX_RETRIES) {
const transaction = await sequelize.transaction();
try {
const { consumableId, type, quantity, operator, reason, notes, snList, deviceId } = req.body;
const consumable = await Consumable.findByPk(consumableId, { transaction });
if (!consumable) {
await transaction.rollback();
return res.status(404).json({ error: '耗材不存在' });
}
let deviceInfo = {};
if (deviceId && type === 'out') {
const Device = require('../models/Device');
const Rack = require('../models/Rack');
const Room = require('../models/Room');
const device = await Device.findByPk(deviceId, { transaction });
if (device) {
deviceInfo = {
deviceId: device.deviceId,
deviceName: device.name,
rackId: device.rackId,
rackName: null,
roomId: null,
roomName: null,
};
if (device.rackId) {
const rack = await Rack.findByPk(device.rackId, { transaction });
if (rack) {
deviceInfo.rackName = rack.name;
if (rack.roomId) {
const room = await Room.findByPk(rack.roomId, { transaction });
if (room) {
deviceInfo.roomId = room.roomId;
deviceInfo.roomName = room.name;
}
}
}
}
}
}
const previousStock = parseFloat(consumable.currentStock);
let newStock;
const currentSnList = consumable.snList || [];
let updatedSnList = [...currentSnList];
const operationSnList = snList || [];
if (type === 'in') {
newStock = previousStock + parseFloat(quantity);
if (operationSnList.length > 0) {
operationSnList.forEach(sn => {
if (!updatedSnList.includes(sn)) {
updatedSnList.push(sn);
}
});
}
} else if (type === 'out') {
newStock = previousStock - parseFloat(quantity);
if (newStock < 0) {
await transaction.rollback();
return res.status(400).json({ error: '库存不足' });
}
if (operationSnList.length > 0) {
for (const sn of operationSnList) {
if (!updatedSnList.includes(sn)) {
await transaction.rollback();
return res.status(400).json({ error: `SN "${sn}" 不存在于当前耗材中` });
}
}
updatedSnList = updatedSnList.filter(sn => !operationSnList.includes(sn));
}
} else {
await transaction.rollback();
return res.status(400).json({ error: '操作类型无效' });
}
const [affectedRows] = await Consumable.update(
{
currentStock: newStock,
snList: updatedSnList,
version: sequelize.literal('version + 1'),
},
{
where: {
consumableId,
version: consumable.version,
},
transaction,
}
);
if (affectedRows === 0) {
await transaction.rollback();
attempt++;
if (attempt >= RETRY.MAX_RETRIES) {
return res.status(409).json({ error: '并发冲突,请稍后重试' });
}
continue;
}
const record = await ConsumableRecord.create(
{
consumableId,
type,
quantity,
previousStock,
currentStock: newStock,
operator,
reason,
notes,
snList: operationSnList,
},
{ transaction }
);
await ConsumableLog.create(
{
consumableId,
consumableName: consumable.name,
operationType: type,
quantity: type === 'in' ? parseFloat(quantity) : -parseFloat(quantity),
previousStock,
currentStock: newStock,
operator,
reason,
notes,
isEditable: false,
snList: operationSnList,
deviceId: deviceInfo.deviceId || null,
deviceName: deviceInfo.deviceName || null,
rackId: deviceInfo.rackId || null,
rackName: deviceInfo.rackName || null,
roomId: deviceInfo.roomId || null,
roomName: deviceInfo.roomName || null,
consumableSnapshot: {
category: consumable.category,
unit: consumable.unit,
unitPrice: consumable.unitPrice,
supplier: consumable.supplier,
location: consumable.location,
},
},
{ transaction }
);
await transaction.commit();
res.json({
message: '操作成功',
record,
consumable: await Consumable.findByPk(consumableId),
deviceInfo: Object.keys(deviceInfo).length > 0 ? deviceInfo : null,
});
return;
} catch (error) {
await transaction.rollback();
if (attempt >= RETRY.MAX_RETRIES - 1) {
return res.status(500).json({ error: error.message });
}
attempt++;
}
}
});
router.post('/inout', async (req, res) => {
let attempt = 0;
while (attempt < RETRY.MAX_RETRIES) {
const transaction = await sequelize.transaction();
try {
const {
consumableId,
type,
quantity,
operator,
reason,
recipient,
notes,
snList,
deviceId,
deviceName,
} = req.body;
const consumable = await Consumable.findByPk(consumableId, { transaction });
if (!consumable) {
await transaction.rollback();
return res.status(404).json({ error: '耗材不存在' });
}
let deviceInfo = {};
if (deviceId && type === 'out') {
const Device = require('../models/Device');
const Rack = require('../models/Rack');
const Room = require('../models/Room');
const device = await Device.findByPk(deviceId, { transaction });
if (device) {
deviceInfo = {
deviceId: device.deviceId,
deviceName: device.name,
rackId: device.rackId,
rackName: null,
roomId: null,
roomName: null,
};
if (device.rackId) {
const rack = await Rack.findByPk(device.rackId, { transaction });
if (rack) {
deviceInfo.rackName = rack.name;
if (rack.roomId) {
const room = await Room.findByPk(rack.roomId, { transaction });
if (room) {
deviceInfo.roomId = room.roomId;
deviceInfo.roomName = room.name;
}
}
}
}
}
}
const previousStock = parseFloat(consumable.currentStock);
let newStock;
const currentSnList = consumable.snList || [];
let updatedSnList = [...currentSnList];
const operationSnList = snList || [];
if (type === 'in') {
newStock = previousStock + parseFloat(quantity);
if (operationSnList.length > 0) {
operationSnList.forEach(sn => {
if (!updatedSnList.includes(sn)) {
updatedSnList.push(sn);
}
});
}
} else {
newStock = previousStock - parseFloat(quantity);
if (newStock < 0) {
await transaction.rollback();
return res.status(400).json({ error: '库存不足' });
}
if (operationSnList.length > 0) {
for (const sn of operationSnList) {
if (!updatedSnList.includes(sn)) {
await transaction.rollback();
return res.status(400).json({ error: `SN "${sn}" 不存在于当前耗材中` });
}
}
updatedSnList = updatedSnList.filter(sn => !operationSnList.includes(sn));
}
}
const [affectedRows] = await Consumable.update(
{
currentStock: newStock,
snList: updatedSnList,
version: sequelize.literal('version + 1'),
},
{
where: {
consumableId,
version: consumable.version,
},
transaction,
}
);
if (affectedRows === 0) {
await transaction.rollback();
attempt++;
if (attempt >= RETRY.MAX_RETRIES) {
return res.status(409).json({ error: '并发冲突,请稍后重试' });
}
continue;
}
const record = await ConsumableRecord.create(
{
consumableId,
type,
quantity,
previousStock,
currentStock: newStock,
operator,
reason,
recipient,
notes,
snList: operationSnList,
},
{ transaction }
);
await ConsumableLog.create(
{
consumableId,
consumableName: consumable.name,
operationType: type,
quantity: type === 'in' ? parseFloat(quantity) : -parseFloat(quantity),
previousStock,
currentStock: newStock,
operator,
reason,
notes,
isEditable: false,
snList: operationSnList,
deviceId: deviceInfo.deviceId || null,
deviceName: deviceInfo.deviceName || null,
rackId: deviceInfo.rackId || null,
rackName: deviceInfo.rackName || null,
roomId: deviceInfo.roomId || null,
roomName: deviceInfo.roomName || null,
consumableSnapshot: {
category: consumable.category,
unit: consumable.unit,
unitPrice: consumable.unitPrice,
supplier: consumable.supplier,
location: consumable.location,
},
},
{ transaction }
);
await transaction.commit();
res.json({
message: '操作成功',
record,
consumable: await Consumable.findByPk(consumableId),
deviceInfo: Object.keys(deviceInfo).length > 0 ? deviceInfo : null,
});
return;
} catch (error) {
await transaction.rollback();
if (attempt >= RETRY.MAX_RETRIES - 1) {
return res.status(500).json({ error: error.message });
}
attempt++;
}
}
});
router.post('/adjust', async (req, res) => {
let attempt = 0;
while (attempt < RETRY.MAX_RETRIES) {
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);
if (newStock < 0) {
await transaction.rollback();
return res.status(400).json({ error: '库存不能设置为负数' });
}
} else {
await transaction.rollback();
return res.status(400).json({ error: '调整类型无效' });
}
const [affectedRows] = await Consumable.update(
{
currentStock: newStock,
version: sequelize.literal('version + 1'),
},
{
where: {
consumableId,
version: consumable.version,
},
transaction,
}
);
if (affectedRows === 0) {
await transaction.rollback();
attempt++;
if (attempt >= RETRY.MAX_RETRIES) {
return res.status(409).json({ error: '并发冲突,请稍后重试' });
}
continue;
}
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,
isEditable: false,
consumableSnapshot: {
category: consumable.category,
unit: consumable.unit,
unitPrice: consumable.unitPrice,
supplier: consumable.supplier,
location: consumable.location,
},
},
{ transaction }
);
await transaction.commit();
res.json({
message: '调整成功',
consumable: await Consumable.findByPk(consumableId),
});
return;
} catch (error) {
await transaction.rollback();
if (attempt >= RETRY.MAX_RETRIES - 1) {
return res.status(500).json({ error: error.message });
}
attempt++;
}
}
});
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) {
const types = operationType
.split(',')
.map(t => t.trim())
.filter(t => t);
if (types.length === 1) {
where.operationType = types[0];
} else if (types.length > 1) {
where.operationType = { [Op.in]: types };
}
}
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']],
});
const consumableIds = [...new Set(rows.map(log => log.consumableId).filter(Boolean))];
const consumables = await Consumable.findAll({
where: { consumableId: { [Op.in]: consumableIds } },
attributes: ['consumableId', 'name', 'status'],
});
const consumableMap = Object.fromEntries(consumables.map(c => [c.consumableId, c]));
const logsWithCurrentName = rows.map(log => {
const logData = log.toJSON();
const relatedConsumable = consumableMap[log.consumableId];
logData.currentConsumableName = relatedConsumable ? relatedConsumable.name : logData.consumableName;
if (relatedConsumable) {
logData.consumable = {
consumableId: relatedConsumable.consumableId,
name: relatedConsumable.name,
status: relatedConsumable.status,
};
}
return logData;
});
res.json({
total: count,
logs: logsWithCurrentName,
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 consumableIds = [...new Set(logs.map(log => log.consumableId).filter(Boolean))];
const consumables = await Consumable.findAll({
where: { consumableId: { [Op.in]: consumableIds } },
attributes: ['consumableId', 'name', 'status'],
});
const consumableMap = Object.fromEntries(consumables.map(c => [c.consumableId, c]));
const csvHeader =
'ID,耗材ID,耗材名称(历史),耗材名称(当前),操作类型,变动数量,操作前库存,操作后库存,操作人,原因,备注,耗材状态,分类,单位,单价,创建时间,更新时间\n';
const csvRows = logs
.map(log => {
const operationTypeMap = {
in: '入库',
out: '出库',
create: '创建',
update: '更新',
delete: '删除',
adjust: '调整',
import: '导入',
};
const snapshot = log.consumableSnapshot || {};
const relatedConsumable = consumableMap[log.consumableId];
const currentConsumableName = relatedConsumable ? relatedConsumable.name : log.consumableName;
return [
log.id,
log.consumableId,
log.consumableName,
currentConsumableName,
operationTypeMap[log.operationType] || log.operationType,
log.quantity,
log.previousStock,
log.currentStock,
log.operator,
log.reason || '',
log.notes || '',
log.isConsumableDeleted ? '已删除' : '正常',
snapshot.category || '',
snapshot.unit || '',
snapshot.unitPrice || '',
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', async (req, res) => {
const transaction = await sequelize.transaction();
try {
const {
consumableId,
consumableName,
operationType,
quantity,
previousStock,
currentStock,
operator,
reason,
notes,
snList,
deviceId,
deviceName,
rackId,
rackName,
roomId,
roomName,
createdAt,
} = req.body;
if (!consumableName || !operationType) {
await transaction.rollback();
return res.status(400).json({ error: '缺少必填字段:耗材名称、操作类型' });
}
const validOperationTypes = ['in', 'out', 'create', 'update', 'delete', 'adjust', 'import'];
if (!validOperationTypes.includes(operationType)) {
await transaction.rollback();
return res.status(400).json({ error: '无效的操作类型' });
}
const logData = {
consumableId: consumableId || generateId({ prefix: 'LOG' }),
consumableName,
operationType,
quantity: parseInt(quantity) || 0,
previousStock: parseInt(previousStock) || 0,
currentStock: parseInt(currentStock) || 0,
operator: operator || '',
reason: reason || '',
notes: notes || '',
snList: Array.isArray(snList) ? snList : [],
deviceId: deviceId || null,
deviceName: deviceName || null,
rackId: rackId || null,
rackName: rackName || null,
roomId: roomId || null,
roomName: roomName || null,
isEditable: true,
};
if (createdAt) {
const parsedDate = new Date(createdAt);
if (!isNaN(parsedDate.getTime())) {
logData.createdAt = parsedDate;
logData.updatedAt = parsedDate;
}
}
const log = await ConsumableLog.create(logData, { transaction });
await transaction.commit();
res.status(201).json({ message: '日志创建成功', log });
} catch (error) {
await transaction.rollback();
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('/archives', async (req, res) => {
try {
const { keyword, page = 1, pageSize = 10 } = req.query;
const offset = (page - 1) * pageSize;
const where = {};
if (keyword) {
where[Op.or] = [
{ consumableId: { [Op.like]: `%${keyword}%` } },
{ consumableName: { [Op.like]: `%${keyword}%` } },
{ archiveId: { [Op.like]: `%${keyword}%` } },
];
}
const { count, rows } = await ConsumableLogArchive.findAndCountAll({
where,
offset,
limit: parseInt(pageSize),
order: [['deletedAt', 'DESC']],
});
res.json({
total: count,
archives: rows,
page: parseInt(page),
pageSize: parseInt(pageSize),
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 查询单个归档记录详情
router.get('/archives/:archiveId', async (req, res) => {
try {
const { archiveId } = req.params;
const archive = await ConsumableLogArchive.findOne({
where: { archiveId },
});
if (!archive) {
return res.status(404).json({ error: '归档记录不存在' });
}
res.json(archive);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.get('/:id', async (req, res) => {
try {
const consumable = await Consumable.findByPk(req.params.id);
if (!consumable) {
return res.status(404).json({ error: '耗材不存在' });
}
const data = consumable.toJSON();
if (!Array.isArray(data.snList)) {
data.snList = [];
}
res.json(data);
} 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();
const oldName = oldData.name;
const updateData = { ...req.body };
delete updateData.currentStock;
delete updateData.snList;
await consumable.update(updateData, { transaction });
const newName = consumable.name;
if (oldName !== newName) {
await ConsumableLog.update(
{
consumableName: newName,
lastNameSyncAt: new Date(),
},
{
where: { consumableId: consumable.consumableId },
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(', ')}`,
consumableSnapshot: {
category: consumable.category,
unit: consumable.unit,
unitPrice: consumable.unitPrice,
supplier: consumable.supplier,
location: consumable.location,
},
},
{ 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;
const operator = req.body.operator || req.query.operator || '系统';
const consumableSnapshot = {
category: consumable.category,
unit: consumable.unit,
unitPrice: consumable.unitPrice,
supplier: consumable.supplier,
location: consumable.location,
description: consumable.description,
minStock: consumable.minStock,
maxStock: consumable.maxStock,
status: consumable.status,
};
// 查询该耗材的所有操作日志
const logs = await ConsumableLog.findAll({
where: { consumableId },
order: [['createdAt', 'ASC']],
transaction,
});
// 计算统计数据
const totalOperations = logs.length;
const totalInQuantity = logs
.filter(l => l.operationType === 'in')
.reduce((sum, l) => sum + (l.quantity || 0), 0);
const totalOutQuantity = logs
.filter(l => l.operationType === 'out')
.reduce((sum, l) => sum + Math.abs(l.quantity || 0), 0);
// 创建归档记录
const archiveId = `ARC${Date.now()}`;
await ConsumableLogArchive.create(
{
archiveId,
consumableId,
consumableName,
consumableSnapshot,
totalOperations,
firstOperationAt: logs.length > 0 ? logs[0].createdAt : null,
lastOperationAt: logs.length > 0 ? logs[logs.length - 1].createdAt : null,
totalInQuantity,
totalOutQuantity,
finalStock: currentStock,
deletedBy: operator,
deletedAt: new Date(),
deleteReason: req.body.reason || '删除耗材',
},
{ transaction }
);
// 创建一条汇总日志(用于在日志列表中显示)
await ConsumableLog.create(
{
consumableId,
consumableName,
operationType: 'delete',
quantity: -currentStock,
previousStock: currentStock,
currentStock: 0,
operator,
reason: '删除耗材',
notes: `删除耗材:${consumableName},共${totalOperations}条操作记录已归档(归档ID: ${archiveId}`,
isEditable: false,
isConsumableDeleted: true,
consumableSnapshot,
relatedId: archiveId, // 关联归档ID
},
{ transaction }
);
// 删除原日志记录(已归档)
await ConsumableLog.destroy({
where: { consumableId },
transaction,
});
await ConsumableRecord.destroy({
where: { consumableId },
transaction,
});
await consumable.destroy({ transaction });
await transaction.commit();
res.json({
message: '删除成功',
archiveId,
archivedLogs: totalOperations,
});
} catch (error) {
await transaction.rollback();
res.status(500).json({ error: error.message });
}
});
// 修改日志记录
router.put('/logs/:id', async (req, res) => {
const transaction = await sequelize.transaction();
try {
const { id } = req.params;
const { reason, notes, operator, modificationReason } = req.body;
const log = await ConsumableLog.findByPk(id, { transaction });
if (!log) {
await transaction.rollback();
return res.status(404).json({ error: '日志记录不存在' });
}
// 检查是否可编辑
if (!log.isEditable) {
await transaction.rollback();
return res.status(403).json({ error: '该记录为系统自动生成,不可修改' });
}
// 保存原始日志ID(用于追踪修改历史)
const originalLogId = log.originalLogId || log.id;
// 更新当前记录,并标记为已修改
await log.update(
{
reason: reason !== undefined ? reason : log.reason,
notes: notes !== undefined ? notes : log.notes,
modifiedBy: operator || '系统',
modifiedAt: new Date(),
modificationReason: modificationReason || '用户修改',
},
{ transaction }
);
await transaction.commit();
res.json({
message: '日志修改成功',
log: await ConsumableLog.findByPk(id),
});
} catch (error) {
await transaction.rollback();
res.status(500).json({ error: error.message });
}
});
// 删除日志记录
router.delete('/logs/:id', async (req, res) => {
const transaction = await sequelize.transaction();
try {
const { id } = req.params;
const log = await ConsumableLog.findByPk(id, { transaction });
if (!log) {
await transaction.rollback();
return res.status(404).json({ error: '日志记录不存在' });
}
await log.destroy({ transaction });
await transaction.commit();
res.json({ message: '日志删除成功' });
} catch (error) {
await transaction.rollback();
res.status(500).json({ error: error.message });
}
});
// 获取日志修改历史
router.get('/logs/:id/history', async (req, res) => {
try {
const { id } = req.params;
const log = await ConsumableLog.findByPk(id);
if (!log) {
return res.status(404).json({ error: '日志记录不存在' });
}
// 查询该日志的所有修改历史(包括原始记录)
const originalLogId = log.originalLogId || log.id;
const history = await ConsumableLog.findAll({
where: {
[Op.or]: [{ id: originalLogId }, { originalLogId: originalLogId }],
},
order: [['createdAt', 'ASC']],
});
res.json({
current: log,
history: history,
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.get('/devices/search', async (req, res) => {
try {
const { keyword, limit = 20 } = req.query;
if (!keyword) {
return res.json({ devices: [] });
}
const Device = require('../models/Device');
const Rack = require('../models/Rack');
const Room = require('../models/Room');
const escapedKeyword = keyword.replace(/'/g, "''");
const devices = await Device.findAll({
where: {
[Op.or]: [
{ deviceId: { [Op.like]: `%${escapedKeyword}%` } },
{ name: { [Op.like]: `%${escapedKeyword}%` } },
{ serialNumber: { [Op.like]: `%${escapedKeyword}%` } },
],
},
include: [
{
model: Rack,
as: 'rack',
include: [{ model: Room, as: 'room' }],
},
],
limit: parseInt(limit),
order: [['name', 'ASC']],
});
const result = devices.map(device => ({
deviceId: device.deviceId,
name: device.name,
type: device.type,
model: device.model,
serialNumber: device.serialNumber,
status: device.status,
location: device.rack
? {
rackId: device.rack.rackId,
rackName: device.rack.name,
roomId: device.rack.room ? device.rack.room.roomId : null,
roomName: device.rack.room ? device.rack.room.name : null,
}
: null,
}));
res.json({ devices: result });
} catch (error) {
logger.error('搜索设备失败', { error: error.message, stack: error.stack });
res.status(500).json({ error: error.message });
}
});
router.get('/devices/by-sn/:sn', async (req, res) => {
try {
const { sn } = req.params;
const Device = require('../models/Device');
const device = await Device.findOne({
where: {
[Op.or]: [
{ deviceId: { [Op.like]: `%${sn}%` } },
{ serialNumber: { [Op.like]: `%${sn}%` } },
{ name: { [Op.like]: `%${sn}%` } },
],
},
});
if (!device) {
return res.json({ found: false, device: null });
}
res.json({
found: true,
device: {
deviceId: device.deviceId,
name: device.name,
type: device.type,
model: device.model,
serialNumber: device.serialNumber,
status: device.status,
},
});
} catch (error) {
logger.error('查询设备失败', { error: error.message, stack: error.stack });
res.status(500).json({ error: error.message });
}
});
module.exports = router;