1937 lines
57 KiB
JavaScript
1937 lines
57 KiB
JavaScript
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;
|