feat(耗材): 增强耗材日志名称同步和导入功能

This commit is contained in:
zhang1106
2026-04-10 12:02:06 +08:00
parent 9421e34147
commit 618633bc6a
6 changed files with 1330 additions and 548 deletions
+5
View File
@@ -126,6 +126,11 @@ const ConsumableLog = sequelize.define(
allowNull: true,
comment: '机房名称',
},
lastNameSyncAt: {
type: DataTypes.DATE,
allowNull: true,
comment: '名称最后同步时间(名称变更时更新)',
},
},
{
tableName: 'consumable_logs',
+230 -24
View File
@@ -71,15 +71,22 @@ const MAX_EXPORT_SIZE = 50000;
router.get('/export', async (req, res) => {
try {
const { keyword, category, status } = req.query;
const {
keyword,
category,
status,
stockStatus, // 新增:warning/normal/all
ids, // 新增:耗材ID列表,逗号分隔
fields, // 新增:要导出的字段列表,逗号分隔
} = req.query;
// 构建查询条件
const where = {};
if (keyword) {
where[Op.or] = [
{ consumableId: { [Op.like]: `%${keyword}%` } },
{ name: { [Op.like]: `%${keyword}%` } },
{ category: { [Op.like]: `%${keyword}%` } },
{ consumableId: { [Op.like]: `%${keyword}%` } },
{ supplier: { [Op.like]: `%${keyword}%` } },
{ location: { [Op.like]: `%${keyword}%` } },
];
@@ -93,14 +100,70 @@ router.get('/export', async (req, res) => {
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']],
});
const result = consumables.map(item => {
const data = item.toJSON();
// 处理导出字段
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 = [];
}
@@ -112,6 +175,7 @@ router.get('/export', async (req, res) => {
total: result.length,
});
} catch (error) {
console.error('导出失败:', error);
res.status(500).json({ error: error.message });
}
});
@@ -294,7 +358,7 @@ router.post('/create-with-inbound', async (req, res) => {
router.post('/import', async (req, res) => {
const transaction = await sequelize.transaction();
try {
const { items, operator = '系统', mode = 'create' } = req.body;
const { items, operator = '系统', mode = 'create', stockMode = 'basic' } = req.body;
if (!items || !Array.isArray(items) || items.length === 0) {
await transaction.rollback();
@@ -327,7 +391,7 @@ router.post('/import', async (req, res) => {
}
let snList = [];
if (item.SN序列号 || item.snList) {
if (stockMode !== 'basic' && (item.SN序列号 || item.snList)) {
const snStr = item.SN序列号 || item.snList;
if (typeof snStr === 'string') {
snList = snStr
@@ -344,8 +408,10 @@ router.post('/import', async (req, res) => {
name,
category,
unit: item.单位 || item.unit || '个',
currentStock:
snList.length > 0 ? snList.length : parseInt(item.当前库存 || item.currentStock) || 0,
// 根据 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,
@@ -353,7 +419,7 @@ router.post('/import', async (req, res) => {
location: item.存放位置 || item.location || '',
description: item.描述 || item.description || '',
status: item.状态 || item.status || 'active',
snList,
snList: stockMode === 'basic' ? [] : snList, // basic模式:强制为空数组
};
let existingConsumable = null;
@@ -400,17 +466,113 @@ router.post('/import', async (req, res) => {
});
}
// 如果是 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,
quantity: consumable.currentStock,
previousStock,
currentStock: consumable.currentStock,
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 ? '更新现有耗材' : '',
notes: existingConsumable ? '更新现有耗材' : (stockMode === 'inbound' ? '导入并入库' : ''),
consumableSnapshot: {
category: consumable.category,
unit: consumable.unit,
@@ -444,19 +606,18 @@ router.post('/import', async (req, res) => {
router.get('/by-sn/:sn', async (req, res) => {
try {
const sn = req.params.sn;
// 使用数据库 LIKE 查询替代全表扫描
// 获取所有有 snList 的耗材
const consumables = await Consumable.findAll({
where: {
snList: {
[Op.like]: `%${sn}%`,
},
},
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 = [];
@@ -1077,9 +1238,30 @@ router.get('/logs', async (req, res) => {
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: rows,
logs: logsWithCurrentName,
page: parseInt(page),
pageSize: parseInt(pageSize),
});
@@ -1117,8 +1299,15 @@ router.get('/logs/export', async (req, res) => {
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';
'ID,耗材ID,耗材名称(历史),耗材名称(当前),操作类型,变动数量,操作前库存,操作后库存,操作人,原因,备注,耗材状态,分类,单位,单价,创建时间,更新时间\n';
const csvRows = logs
.map(log => {
const operationTypeMap = {
@@ -1131,10 +1320,13 @@ router.get('/logs/export', async (req, res) => {
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,
@@ -1392,12 +1584,26 @@ router.put('/:id', async (req, res) => {
}
const oldData = consumable.toJSON();
const oldName = oldData.name;
const updateData = { ...req.body };
// 禁止通过编辑接口修改库存和SN列表
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,
+1
View File
@@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router();
const { Op } = require('sequelize');
const { v4: uuidv4 } = require('uuid');
const { generateId } = require('../utils/idGenerator');
const { Ticket, TicketOperationRecord } = require('../models/ticketIndex');
const Device = require('../models/Device');
const User = require('../models/User');
+17
View File
@@ -113,6 +113,11 @@ const migrations = [
'为 consumable_logs 表添加 deviceId、deviceName、rackId、rackName、roomId、roomName 字段',
migrate: migrateConsumableLogDeviceAssociation,
},
{
name: '耗材日志名称同步',
description: '为 consumable_logs 表添加 lastNameSyncAt 字段,支持名称同步',
migrate: migrateConsumableLogNameSync,
},
];
async function runMigrations() {
@@ -845,6 +850,18 @@ async function migrateConsumableLogDeviceAssociation() {
console.log(' 耗材日志设备关联迁移完成');
}
async function migrateConsumableLogNameSync() {
const tableName = 'consumable_logs';
if (!(await tableExists(tableName))) {
console.log(` ${tableName} 表不存在,跳过`);
return;
}
await addColumnIfNotExists(tableName, 'lastNameSyncAt', 'DATETIME');
console.log(' 耗材日志名称同步迁移完成');
}
// 执行迁移
runMigrations().catch(error => {
console.error('迁移执行失败:', error);
+39 -23
View File
@@ -163,31 +163,46 @@ function ConsumableLogs() {
title: '耗材名称',
dataIndex: 'consumableName',
key: 'consumableName',
width: 180,
render: (value, record) => (
<Tooltip
title={
record.consumableSnapshot ? (
width: 200,
render: (value, record) => {
const currentName = record.currentConsumableName || value;
const nameChanged = currentName !== value && value;
return (
<Tooltip
title={
<div>
<div>分类: {record.consumableSnapshot.category || '-'}</div>
<div>单位: {record.consumableSnapshot.unit || '-'}</div>
<div>单价: {record.consumableSnapshot.unitPrice || '-'}</div>
<div>供应商: {record.consumableSnapshot.supplier || '-'}</div>
<div>位置: {record.consumableSnapshot.location || '-'}</div>
{record.consumableSnapshot && (
<div>
<div>分类: {record.consumableSnapshot.category || '-'}</div>
<div>单位: {record.consumableSnapshot.unit || '-'}</div>
<div>单价: {record.consumableSnapshot.unitPrice || '-'}</div>
<div>供应商: {record.consumableSnapshot.supplier || '-'}</div>
<div>位置: {record.consumableSnapshot.location || '-'}</div>
</div>
)}
{nameChanged && (
<div style={{ marginTop: '8px', color: '#faad14' }}>
曾用名: {value}
</div>
)}
</div>
) : null
}
>
<Space direction="vertical" size={0}>
<span>{value}</span>
{record.isConsumableDeleted && (
<Tag color="red" size="small">
已删除
</Tag>
)}
</Space>
</Tooltip>
),
}
>
<Space direction="vertical" size={0}>
<span>{currentName}</span>
{record.isConsumableDeleted ? (
<Tag color="red" size="small">
已删除
</Tag>
) : nameChanged ? (
<Tag color="orange" size="small">
已更名
</Tag>
) : null}
</Space>
</Tooltip>
);
},
},
{
title: '操作类型',
@@ -417,6 +432,7 @@ function ConsumableLogs() {
时间: dayjs(log.createdAt).format('YYYY-MM-DD HH:mm:ss'),
耗材ID: log.consumableId,
耗材名称: log.consumableName,
当前名称: log.currentConsumableName || log.consumableName,
操作类型: getOperationTypeText(log.operationType),
变动数量: log.quantity,
操作前库存: log.previousStock,
File diff suppressed because it is too large Load Diff