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) { console.error('导出失败:', error); 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(); console.error('创建耗材错误:', error); 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(); console.error('创建耗材并入库错误:', error); 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) { console.error('搜索设备失败:', error); 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) { console.error('查询设备失败:', error); res.status(500).json({ error: error.message }); } }); module.exports = router;