feat(耗材管理): 添加SN序列号管理功能

This commit is contained in:
zhang1106
2026-03-05 09:57:44 +08:00
parent ef8f3ab565
commit 03d69456c0
7 changed files with 375 additions and 17 deletions
+6
View File
@@ -52,6 +52,12 @@ const Consumable = sequelize.define('Consumable', {
description: {
type: DataTypes.TEXT
},
snList: {
type: DataTypes.JSON,
allowNull: true,
defaultValue: [],
comment: 'SN序列号列表,JSON数组格式'
},
status: {
type: DataTypes.STRING,
defaultValue: 'active'
+6
View File
@@ -87,6 +87,12 @@ const ConsumableLog = sequelize.define('ConsumableLog', {
type: DataTypes.JSON,
allowNull: true,
comment: '耗材快照信息(分类、单位、供应商等),用于耗材删除后追溯'
},
snList: {
type: DataTypes.JSON,
allowNull: true,
defaultValue: [],
comment: '本次操作的SN序列号列表'
}
}, {
tableName: 'consumable_logs',
+6
View File
@@ -46,6 +46,12 @@ const ConsumableRecord = sequelize.define('ConsumableRecord', {
},
notes: {
type: DataTypes.TEXT
},
snList: {
type: DataTypes.JSON,
allowNull: true,
defaultValue: [],
comment: '本次操作的SN序列号列表'
}
}, {
tableName: 'consumable_records',
+55 -8
View File
@@ -287,7 +287,7 @@ router.post('/quick-inout', async (req, res) => {
while (attempt < MAX_RETRIES) {
const transaction = await sequelize.transaction();
try {
const { consumableId, type, quantity, operator, reason, notes } = req.body;
const { consumableId, type, quantity, operator, reason, notes, snList } = req.body;
const consumable = await Consumable.findByPk(consumableId, { transaction });
if (!consumable) {
@@ -297,15 +297,34 @@ router.post('/quick-inout', async (req, res) => {
const previousStock = parseFloat(consumable.currentStock);
let newStock;
let currentSnList = consumable.snList || [];
let updatedSnList = [...currentSnList];
let 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: '操作类型无效' });
@@ -314,6 +333,7 @@ router.post('/quick-inout', async (req, res) => {
const [affectedRows] = await Consumable.update(
{
currentStock: newStock,
snList: updatedSnList,
version: sequelize.literal('version + 1')
},
{
@@ -342,10 +362,10 @@ router.post('/quick-inout', async (req, res) => {
currentStock: newStock,
operator,
reason,
notes
notes,
snList: operationSnList
}, { transaction });
// 系统生成的出入库记录不可编辑
await ConsumableLog.create({
consumableId,
consumableName: consumable.name,
@@ -357,6 +377,7 @@ router.post('/quick-inout', async (req, res) => {
reason,
notes,
isEditable: false,
snList: operationSnList,
consumableSnapshot: {
category: consumable.category,
unit: consumable.unit,
@@ -391,7 +412,7 @@ router.post('/inout', async (req, res) => {
while (attempt < MAX_RETRIES) {
const transaction = await sequelize.transaction();
try {
const { consumableId, type, quantity, operator, reason, recipient, notes } = req.body;
const { consumableId, type, quantity, operator, reason, recipient, notes, snList } = req.body;
const consumable = await Consumable.findByPk(consumableId, { transaction });
if (!consumable) {
@@ -401,20 +422,40 @@ router.post('/inout', async (req, res) => {
const previousStock = parseFloat(consumable.currentStock);
let newStock;
let currentSnList = consumable.snList || [];
let updatedSnList = [...currentSnList];
let 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')
},
{
@@ -444,10 +485,10 @@ router.post('/inout', async (req, res) => {
operator,
reason,
recipient,
notes
notes,
snList: operationSnList
}, { transaction });
// 系统生成的出入库记录不可编辑
await ConsumableLog.create({
consumableId,
consumableName: consumable.name,
@@ -459,6 +500,7 @@ router.post('/inout', async (req, res) => {
reason,
notes,
isEditable: false,
snList: operationSnList,
consumableSnapshot: {
category: consumable.category,
unit: consumable.unit,
@@ -596,8 +638,13 @@ router.get('/logs', async (req, res) => {
where.consumableId = consumableId;
}
if (operationType && operationType !== 'all') {
where.operationType = operationType;
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) {
+35
View File
@@ -42,6 +42,11 @@ const migrations = [
name: '耗材日志归档表',
description: '创建 consumable_log_archives 归档表',
migrate: migrateConsumableLogArchive
},
{
name: '耗材SN序列号字段',
description: '为 consumables、consumable_records、consumable_logs 添加 snList 字段',
migrate: migrateSnList
}
];
@@ -366,6 +371,36 @@ async function migrateConsumableLogArchive() {
await sequelize.query(`CREATE INDEX idx_archive_deleted_at ON consumable_log_archives(deletedAt)`);
}
async function migrateSnList() {
const dialect = sequelize.getDialect();
const tables = ['consumables', 'consumable_records', 'consumable_logs'];
for (const table of tables) {
const tableInfo = await sequelize.query(
dialect === 'sqlite'
? `PRAGMA table_info(${table})`
: `SHOW COLUMNS FROM ${table}`,
{ type: sequelize.QueryTypes.SELECT }
);
const columns = dialect === 'sqlite'
? tableInfo.map(col => col.name)
: tableInfo.map(col => col.Field);
if (!columns.includes('snList')) {
if (dialect === 'sqlite') {
await sequelize.query(`ALTER TABLE ${table} ADD COLUMN snList TEXT DEFAULT '[]'`);
} else {
await sequelize.query(`ALTER TABLE ${table} ADD COLUMN snList JSON DEFAULT '[]'`);
}
console.log(` ${table} 表添加 snList 字段成功`);
} else {
console.log(` ${table} 表 snList 字段已存在,跳过`);
}
}
}
// 执行迁移
runMigrations().catch(error => {
console.error('迁移执行失败:', error);
+9 -6
View File
@@ -46,7 +46,7 @@ function ConsumableLogs() {
const [loading, setLoading] = useState(true);
const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0 });
const [filters, setFilters] = useState({
operationType: 'all',
operationType: ['in', 'out'],
consumableId: '',
dateRange: null,
});
@@ -73,8 +73,8 @@ function ConsumableLogs() {
setLoading(true);
const params = { page, pageSize };
if (currentFilters.operationType !== 'all') {
params.operationType = currentFilters.operationType;
if (currentFilters.operationType && currentFilters.operationType !== 'all' && currentFilters.operationType.length > 0) {
params.operationType = currentFilters.operationType.join(',');
}
if (currentFilters.consumableId) {
params.consumableId = currentFilters.consumableId;
@@ -541,11 +541,14 @@ function ConsumableLogs() {
prefix={<SearchOutlined />}
/>
<Select
mode="multiple"
value={filters.operationType}
onChange={value => handleFilterChange('operationType', value)}
style={{ width: 120 }}
style={{ width: 200 }}
placeholder="选择操作类型"
allowClear
maxTagCount="responsive"
>
<Option value="all">全部类型</Option>
<Option value="in">入库</Option>
<Option value="out">出库</Option>
<Option value="create">创建</Option>
@@ -562,7 +565,7 @@ function ConsumableLogs() {
<Button
icon={<HistoryOutlined />}
onClick={() => {
setFilters({ operationType: 'all', consumableId: '', dateRange: null });
setFilters({ operationType: ['in', 'out'], consumableId: '', dateRange: null });
fetchLogs(1, pagination.pageSize);
}}
>
+258 -3
View File
@@ -12,6 +12,7 @@ import {
Card,
Space,
Popconfirm,
Popover,
Upload,
Progress,
Checkbox,
@@ -166,6 +167,11 @@ function ConsumableManagement() {
const [stockType, setStockType] = useState('in');
const [stockForm] = Form.useForm();
const [maxStockUnlimited, setMaxStockUnlimited] = useState(false);
const [snList, setSnList] = useState([]);
const [snInputVisible, setSnInputVisible] = useState(false);
const [snInputValue, setSnInputValue] = useState('');
const [selectedSnList, setSelectedSnList] = useState([]);
const [snSearchKeyword, setSnSearchKeyword] = useState('');
const fetchConsumables = useCallback(
async (page = 1, pageSize = 10) => {
@@ -210,12 +216,14 @@ function ConsumableManagement() {
consumable.maxStock === null ||
consumable.maxStock === undefined;
setMaxStockUnlimited(isUnlimited);
setSnList(consumable.snList || []);
form.setFieldsValue({
...consumable,
maxStock: isUnlimited ? undefined : consumable.maxStock,
});
} else {
setMaxStockUnlimited(true);
setSnList([]);
form.resetFields();
form.setFieldsValue({
unit: '个',
@@ -233,6 +241,9 @@ function ConsumableManagement() {
const handleCancel = useCallback(() => {
setModalVisible(false);
setEditingConsumable(null);
setSnList([]);
setSnInputVisible(false);
setSnInputValue('');
}, []);
const handleSubmit = useCallback(
@@ -242,6 +253,7 @@ function ConsumableManagement() {
...values,
maxStock: maxStockUnlimited ? 0 : values.maxStock,
unitPrice: values.unitPrice || 0,
snList: snList,
};
if (editingConsumable) {
await axios.put(`/api/consumables/${editingConsumable.consumableId}`, submitData);
@@ -262,12 +274,13 @@ function ConsumableManagement() {
setModalVisible(false);
fetchConsumables();
setEditingConsumable(null);
setSnList([]);
} catch (error) {
message.error(editingConsumable ? '耗材更新失败' : '耗材创建失败');
console.error('提交失败:', error);
}
},
[editingConsumable, fetchConsumables, maxStockUnlimited]
[editingConsumable, fetchConsumables, maxStockUnlimited, snList]
);
const handleDelete = useCallback(
@@ -539,6 +552,8 @@ function ConsumableManagement() {
(record, type) => {
setStockRecord(record);
setStockType(type);
setSelectedSnList([]);
setSnSearchKeyword('');
stockForm.setFieldsValue({
consumableId: record.consumableId,
consumableName: record.name,
@@ -554,6 +569,8 @@ function ConsumableManagement() {
const handleStockCancel = useCallback(() => {
setStockModalVisible(false);
setStockRecord(null);
setSelectedSnList([]);
setSnSearchKeyword('');
}, []);
const handleStockSubmit = useCallback(
@@ -566,12 +583,14 @@ function ConsumableManagement() {
operator: values.operator || '系统管理员',
reason: values.reason,
notes: values.notes,
snList: stockType === 'out' ? selectedSnList : values.snList || [],
});
message.success({
content: `${stockType === 'in' ? '入库' : '出库'}操作成功`,
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
});
setStockModalVisible(false);
setSelectedSnList([]);
fetchConsumables();
} catch (error) {
message.error(
@@ -580,7 +599,7 @@ function ConsumableManagement() {
console.error('操作失败:', error);
}
},
[stockRecord, stockType, fetchConsumables]
[stockRecord, stockType, fetchConsumables, selectedSnList]
);
const columns = useMemo(
@@ -606,6 +625,50 @@ function ConsumableManagement() {
width: 80,
render: text => <Text type="secondary">{text}</Text>,
},
{
title: 'SN数量',
key: 'snCount',
width: 120,
render: (_, record) => {
const snList = record.snList || [];
const snCount = snList.length;
const stock = record.currentStock || 0;
if (snCount === 0) {
return (
<Tag color="default" style={{ borderRadius: '4px' }}>
0/{stock}
</Tag>
);
}
const snContent = (
<div style={{ maxHeight: '200px', overflowY: 'auto', maxWidth: '300px' }}>
{snList.map((sn, index) => (
<Tag key={index} style={{ marginBottom: '4px', marginRight: '4px' }}>
{sn}
</Tag>
))}
</div>
);
return (
<Popover
content={snContent}
title={`SN列表 (${snCount}个)`}
trigger="click"
placement="right"
>
<Tag
color="purple"
style={{ borderRadius: '4px', cursor: 'pointer' }}
>
{snCount}/{stock} 🔍
</Tag>
</Popover>
);
},
},
{
title: '当前库存',
dataIndex: 'currentStock',
@@ -1139,6 +1202,93 @@ function ConsumableManagement() {
<TextArea rows={3} placeholder="请输入描述信息" />
</Form.Item>
<Form.Item label={
<span>
SN序列号
<Text type="secondary" style={{ fontSize: '12px', marginLeft: '8px' }}>
(非必填已录入 {snList.length} )
</Text>
</span>
}>
<div style={{ marginBottom: '8px' }}>
<Space>
<Button
size="small"
icon={<PlusOutlined />}
onClick={() => setSnInputVisible(!snInputVisible)}
>
批量添加
</Button>
<Button
size="small"
danger
onClick={() => {
setSnList([]);
}}
disabled={snList.length === 0}
>
清空全部
</Button>
</Space>
</div>
{snInputVisible && (
<div style={{ marginBottom: '12px' }}>
<TextArea
rows={3}
placeholder="输入SN序列号,每行一个"
value={snInputValue}
onChange={e => setSnInputValue(e.target.value)}
style={{ marginBottom: '8px' }}
/>
<Button
type="primary"
size="small"
onClick={() => {
const newSns = snInputValue
.split('\n')
.map(s => s.trim())
.filter(s => s && !snList.includes(s));
if (newSns.length > 0) {
setSnList([...snList, ...newSns]);
setSnInputValue('');
message.success(`成功添加 ${newSns.length} 个SN`);
} else {
message.warning('没有新的SN可添加(可能已存在或为空)');
}
}}
>
确认添加
</Button>
</div>
)}
{snList.length > 0 && (
<div
style={{
maxHeight: '150px',
overflowY: 'auto',
border: `1px solid ${designTokens.colors.neutral[200]}`,
borderRadius: designTokens.borderRadius.sm,
padding: '8px'
}}
>
{snList.map((sn, index) => (
<Tag
key={index}
closable
onClose={() => {
setSnList(snList.filter((_, i) => i !== index));
}}
style={{ marginBottom: '4px' }}
>
{sn}
</Tag>
))}
</div>
)}
</Form.Item>
<Form.Item name="status" label="状态" rules={[{ required: true, message: '请选择状态' }]}>
<Select placeholder="请选择状态">
<Option value="active">启用</Option>
@@ -1333,12 +1483,117 @@ function ConsumableManagement() {
<Form.Item name="consumableName" label="耗材名称">
<Input disabled />
</Form.Item>
{stockType === 'out' && stockRecord?.snList && stockRecord.snList.length > 0 && (
<Form.Item label={
<span>
选择SN序列号
<Text type="secondary" style={{ fontSize: '12px', marginLeft: '8px' }}>
(已选 {selectedSnList.length} )
</Text>
</span>
}>
<Space direction="vertical" style={{ width: '100%' }} size="small">
<Input
placeholder="搜索SN序列号..."
prefix={<SearchOutlined />}
allowClear
value={snSearchKeyword}
onChange={e => setSnSearchKeyword(e.target.value)}
style={{ marginBottom: '8px' }}
/>
<Space size="small" style={{ marginBottom: '8px' }}>
<Button
size="small"
type="link"
onClick={() => {
const filtered = stockRecord.snList.filter(sn =>
sn.toLowerCase().includes(snSearchKeyword.toLowerCase())
);
setSelectedSnList([...new Set([...selectedSnList, ...filtered])]);
stockForm.setFieldsValue({ quantity: [...new Set([...selectedSnList, ...filtered])].length });
}}
>
全选过滤结果
</Button>
<Button
size="small"
type="link"
onClick={() => {
setSelectedSnList([]);
stockForm.setFieldsValue({ quantity: 1 });
}}
>
清空选择
</Button>
</Space>
<div
style={{
maxHeight: '150px',
overflowY: 'auto',
border: `1px solid ${designTokens.colors.neutral[200]}`,
borderRadius: designTokens.borderRadius.sm,
padding: '8px'
}}
>
{(() => {
const filteredSnList = stockRecord.snList.filter(sn =>
sn.toLowerCase().includes(snSearchKeyword.toLowerCase())
);
if (filteredSnList.length === 0) {
return <Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: '16px 0' }}>无匹配的SN</Text>;
}
return filteredSnList.map((sn, index) => (
<Tag.CheckableTag
key={index}
checked={selectedSnList.includes(sn)}
onChange={checked => {
let newSelected;
if (checked) {
newSelected = [...selectedSnList, sn];
} else {
newSelected = selectedSnList.filter(s => s !== sn);
}
setSelectedSnList(newSelected);
stockForm.setFieldsValue({ quantity: newSelected.length });
}}
style={{ marginBottom: '4px' }}
>
{sn}
</Tag.CheckableTag>
));
})()}
</div>
<Text type="secondary" style={{ fontSize: '12px' }}>
{snSearchKeyword ? `过滤结果: ${stockRecord.snList.filter(sn => sn.toLowerCase().includes(snSearchKeyword.toLowerCase())).length} 个SN` : `${stockRecord.snList.length} 个SN`}
点击SN进行选择
</Text>
</Space>
</Form.Item>
)}
{stockType === 'in' && (
<Form.Item name="snList" label="入库SN序列号(可选)">
<Select
mode="tags"
style={{ width: '100%' }}
placeholder="输入SN后按回车添加"
tokenSeparators={[',', '\n']}
/>
</Form.Item>
)}
<Form.Item
name="quantity"
label="数量"
rules={[{ required: true, message: '请输入数量' }]}
>
<InputNumber min={1} style={{ width: '100%' }} placeholder="请输入数量" />
<InputNumber
min={1}
max={stockType === 'out' && stockRecord?.snList?.length > 0 ? stockRecord.snList.length : undefined}
style={{ width: '100%' }}
placeholder="请输入数量"
/>
</Form.Item>
<Form.Item name="operator" label="操作人">
<Input placeholder="请输入操作人姓名" />