feat(耗材管理): 新增创建耗材同时入库功能并优化库存编辑逻辑
添加创建耗材时同时入库的功能,包含入库数量、操作人和原因等字段 强制初始化新建耗材的库存为0和空SN列表 禁止通过编辑接口直接修改库存和SN列表 前端新增同时入库选项及相关表单字段 优化编辑模式下库存信息的展示方式
This commit is contained in:
+131
-10
@@ -122,14 +122,9 @@ router.post('/', async (req, res) => {
|
|||||||
const consumableData = {
|
const consumableData = {
|
||||||
...req.body,
|
...req.body,
|
||||||
consumableId: req.body.consumableId || `CON${Date.now()}`,
|
consumableId: req.body.consumableId || `CON${Date.now()}`,
|
||||||
|
currentStock: 0, // 强制设为0
|
||||||
|
snList: [], // 强制设为空数组
|
||||||
};
|
};
|
||||||
// SN 列表非空时,取手动填写的库存和 SN 数量中的较大值
|
|
||||||
if (Array.isArray(consumableData.snList) && consumableData.snList.length > 0) {
|
|
||||||
consumableData.currentStock = Math.max(
|
|
||||||
Number(consumableData.currentStock) || 0,
|
|
||||||
consumableData.snList.length
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const consumable = await Consumable.create(consumableData, { transaction });
|
const consumable = await Consumable.create(consumableData, { transaction });
|
||||||
|
|
||||||
await ConsumableLog.create(
|
await ConsumableLog.create(
|
||||||
@@ -170,6 +165,132 @@ router.post('/', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 创建耗材并同时入库
|
||||||
|
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) => {
|
router.post('/import', async (req, res) => {
|
||||||
const transaction = await sequelize.transaction();
|
const transaction = await sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
@@ -1272,9 +1393,9 @@ router.put('/:id', async (req, res) => {
|
|||||||
|
|
||||||
const oldData = consumable.toJSON();
|
const oldData = consumable.toJSON();
|
||||||
const updateData = { ...req.body };
|
const updateData = { ...req.body };
|
||||||
if (Array.isArray(updateData.snList)) {
|
// 禁止通过编辑接口修改库存和SN列表
|
||||||
updateData.currentStock = updateData.snList.length;
|
delete updateData.currentStock;
|
||||||
}
|
delete updateData.snList;
|
||||||
await consumable.update(updateData, { transaction });
|
await consumable.update(updateData, { transaction });
|
||||||
|
|
||||||
await ConsumableLog.create(
|
await ConsumableLog.create(
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import {
|
|||||||
QrcodeOutlined,
|
QrcodeOutlined,
|
||||||
DesktopOutlined,
|
DesktopOutlined,
|
||||||
HistoryOutlined,
|
HistoryOutlined,
|
||||||
|
EyeOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import * as XLSX from 'xlsx';
|
import * as XLSX from 'xlsx';
|
||||||
@@ -142,6 +143,13 @@ function ConsumableManagement() {
|
|||||||
const [snInputValue, setSnInputValue] = useState('');
|
const [snInputValue, setSnInputValue] = useState('');
|
||||||
const [selectedSnList, setSelectedSnList] = useState([]);
|
const [selectedSnList, setSelectedSnList] = useState([]);
|
||||||
const [snSearchKeyword, setSnSearchKeyword] = useState('');
|
const [snSearchKeyword, setSnSearchKeyword] = useState('');
|
||||||
|
const [createWithInbound, setCreateWithInbound] = useState(false);
|
||||||
|
const [inboundData, setInboundData] = useState({
|
||||||
|
quantity: 1,
|
||||||
|
operator: '',
|
||||||
|
reason: '',
|
||||||
|
});
|
||||||
|
const [inboundSnList, setInboundSnList] = useState([]);
|
||||||
const [scanModalVisible, setScanModalVisible] = useState(false);
|
const [scanModalVisible, setScanModalVisible] = useState(false);
|
||||||
const [scanMode, setScanMode] = useState('add');
|
const [scanMode, setScanMode] = useState('add');
|
||||||
const [scanValue, setScanValue] = useState('');
|
const [scanValue, setScanValue] = useState('');
|
||||||
@@ -243,10 +251,12 @@ function ConsumableManagement() {
|
|||||||
} else {
|
} else {
|
||||||
setMaxStockUnlimited(true);
|
setMaxStockUnlimited(true);
|
||||||
setSnList([]);
|
setSnList([]);
|
||||||
|
setCreateWithInbound(false);
|
||||||
|
setInboundData({ quantity: 1, operator: '', reason: '' });
|
||||||
|
setInboundSnList([]);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
unit: '个',
|
unit: '个',
|
||||||
currentStock: 0,
|
|
||||||
minStock: 0,
|
minStock: 0,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
unitPrice: 0,
|
unitPrice: 0,
|
||||||
@@ -263,6 +273,8 @@ function ConsumableManagement() {
|
|||||||
setSnList([]);
|
setSnList([]);
|
||||||
setSnInputVisible(false);
|
setSnInputVisible(false);
|
||||||
setSnInputValue('');
|
setSnInputValue('');
|
||||||
|
setCreateWithInbound(false);
|
||||||
|
setInboundData({ quantity: 1, operator: '', reason: '' });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
@@ -272,7 +284,6 @@ function ConsumableManagement() {
|
|||||||
...values,
|
...values,
|
||||||
maxStock: maxStockUnlimited ? 0 : values.maxStock,
|
maxStock: maxStockUnlimited ? 0 : values.maxStock,
|
||||||
unitPrice: values.unitPrice || 0,
|
unitPrice: values.unitPrice || 0,
|
||||||
snList: snList,
|
|
||||||
};
|
};
|
||||||
if (editingConsumable) {
|
if (editingConsumable) {
|
||||||
await axios.put(`/api/consumables/${editingConsumable.consumableId}`, submitData);
|
await axios.put(`/api/consumables/${editingConsumable.consumableId}`, submitData);
|
||||||
@@ -281,6 +292,22 @@ function ConsumableManagement() {
|
|||||||
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
|
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
if (createWithInbound) {
|
||||||
|
// 同时入库模式,调用创建并入库接口
|
||||||
|
await axios.post('/api/consumables/create-with-inbound', {
|
||||||
|
...submitData,
|
||||||
|
consumableId: `CON${Date.now()}`,
|
||||||
|
inboundQuantity: inboundData.quantity,
|
||||||
|
inboundOperator: inboundData.operator,
|
||||||
|
inboundReason: inboundData.reason,
|
||||||
|
inboundSnList: inboundSnList,
|
||||||
|
});
|
||||||
|
message.success({
|
||||||
|
content: '耗材创建成功并已入库',
|
||||||
|
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 普通创建模式
|
||||||
await axios.post('/api/consumables', {
|
await axios.post('/api/consumables', {
|
||||||
...submitData,
|
...submitData,
|
||||||
consumableId: `CON${Date.now()}`,
|
consumableId: `CON${Date.now()}`,
|
||||||
@@ -290,16 +317,20 @@ function ConsumableManagement() {
|
|||||||
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
|
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
setModalVisible(false);
|
setModalVisible(false);
|
||||||
fetchConsumables();
|
fetchConsumables();
|
||||||
setEditingConsumable(null);
|
setEditingConsumable(null);
|
||||||
setSnList([]);
|
setSnList([]);
|
||||||
|
setCreateWithInbound(false);
|
||||||
|
setInboundData({ quantity: 1, operator: '', reason: '' });
|
||||||
|
setInboundSnList([]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(editingConsumable ? '耗材更新失败' : '耗材创建失败');
|
message.error(editingConsumable ? '耗材更新失败' : '耗材创建失败');
|
||||||
console.error('提交失败:', error);
|
console.error('提交失败:', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[editingConsumable, fetchConsumables, maxStockUnlimited, snList]
|
[editingConsumable, fetchConsumables, maxStockUnlimited, createWithInbound, inboundData, inboundSnList]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
const handleDelete = useCallback(
|
||||||
@@ -1030,10 +1061,15 @@ function ConsumableManagement() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
if (!snList.includes(code)) {
|
// 区分添加到哪个列表
|
||||||
setSnList(prev => [...prev, code]);
|
const targetList = createWithInbound ? inboundSnList : snList;
|
||||||
const currentStock = form.getFieldValue('currentStock') || 0;
|
const setTargetList = createWithInbound ? setInboundSnList : setSnList;
|
||||||
form.setFieldsValue({ currentStock: currentStock + 1 });
|
if (!targetList.includes(code)) {
|
||||||
|
setTargetList(prev => [...prev, code]);
|
||||||
|
// 如果是同时入库模式,自动同步入库数量
|
||||||
|
if (createWithInbound) {
|
||||||
|
setInboundData(prev => ({ ...prev, quantity: targetList.length + 1 }));
|
||||||
|
}
|
||||||
message.success(`已添加SN: ${code}`);
|
message.success(`已添加SN: ${code}`);
|
||||||
} else {
|
} else {
|
||||||
message.warning(`SN已在列表中: ${code}`);
|
message.warning(`SN已在列表中: ${code}`);
|
||||||
@@ -1111,6 +1147,12 @@ function ConsumableManagement() {
|
|||||||
scanMode,
|
scanMode,
|
||||||
scanValue,
|
scanValue,
|
||||||
scannedSnList,
|
scannedSnList,
|
||||||
|
snList,
|
||||||
|
setSnList,
|
||||||
|
createWithInbound,
|
||||||
|
inboundSnList,
|
||||||
|
setInboundSnList,
|
||||||
|
setInboundData,
|
||||||
handleScanCancel,
|
handleScanCancel,
|
||||||
showModal,
|
showModal,
|
||||||
form,
|
form,
|
||||||
@@ -1974,13 +2016,14 @@ function ConsumableManagement() {
|
|||||||
</Row>
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 库存管理 */}
|
{/* 库存预警设置 */}
|
||||||
|
{!editingConsumable && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: 'linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%)',
|
background: 'linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%)',
|
||||||
borderRadius: designTokens.borderRadius.lg,
|
borderRadius: designTokens.borderRadius.lg,
|
||||||
padding: '20px',
|
padding: '20px',
|
||||||
marginBottom: '20px',
|
marginBottom: '16px',
|
||||||
border: '1px solid #86efac',
|
border: '1px solid #86efac',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -2010,29 +2053,13 @@ function ConsumableManagement() {
|
|||||||
>
|
>
|
||||||
2
|
2
|
||||||
</div>
|
</div>
|
||||||
库存管理
|
库存预警
|
||||||
<Text type="secondary" style={{ fontSize: '12px', marginLeft: 'auto' }}>
|
|
||||||
设置库存预警和上限
|
|
||||||
</Text>
|
|
||||||
</div>
|
</div>
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={8}>
|
<Col span={12}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="currentStock"
|
|
||||||
label="当前库存"
|
|
||||||
rules={[inputValidationRules.required('请输入当前库存')]}
|
|
||||||
>
|
|
||||||
<InputNumber
|
|
||||||
min={0}
|
|
||||||
style={{ ...inputNumberStyles.base, width: '100%' }}
|
|
||||||
placeholder={inputPlaceholders.stock}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
<Col span={8}>
|
|
||||||
<Form.Item
|
|
||||||
name="minStock"
|
|
||||||
label="最小库存(预警线)"
|
label="最小库存(预警线)"
|
||||||
|
name="minStock"
|
||||||
rules={[inputValidationRules.required('请输入最小库存')]}
|
rules={[inputValidationRules.required('请输入最小库存')]}
|
||||||
>
|
>
|
||||||
<InputNumber
|
<InputNumber
|
||||||
@@ -2042,7 +2069,7 @@ function ConsumableManagement() {
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={8}>
|
<Col span={12}>
|
||||||
<Form.Item label="最大库存">
|
<Form.Item label="最大库存">
|
||||||
<div style={{ marginBottom: '8px' }}>
|
<div style={{ marginBottom: '8px' }}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -2074,6 +2101,381 @@ function ConsumableManagement() {
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 初始入库(添加模式专用) */}
|
||||||
|
{!editingConsumable && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: createWithInbound
|
||||||
|
? 'linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%)'
|
||||||
|
: '#fafafa',
|
||||||
|
borderRadius: designTokens.borderRadius.lg,
|
||||||
|
padding: '16px 20px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
border: `1px solid ${createWithInbound ? '#93c5fd' : '#e5e7eb'}`,
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '12px' }}>
|
||||||
|
<Checkbox
|
||||||
|
checked={createWithInbound}
|
||||||
|
onChange={e => setCreateWithInbound(e.target.checked)}
|
||||||
|
style={{ marginTop: '4px' }}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
marginBottom: createWithInbound ? '12px' : '0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text strong style={{ fontSize: '15px', color: designTokens.colors.neutral[700] }}>
|
||||||
|
同时进行初始入库
|
||||||
|
</Text>
|
||||||
|
<Tag color={createWithInbound ? 'blue' : 'default'} style={{ borderRadius: '8px' }}>
|
||||||
|
推荐
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
<Text type="secondary" style={{ fontSize: '13px', display: 'block', marginBottom: createWithInbound ? '16px' : '0' }}>
|
||||||
|
创建耗材后立即录入初始库存数量,生成入库记录
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{createWithInbound && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '16px',
|
||||||
|
background: '#fff',
|
||||||
|
borderRadius: designTokens.borderRadius.md,
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={8}>
|
||||||
|
<Form.Item
|
||||||
|
label="入库数量"
|
||||||
|
rules={[inputValidationRules.required('请输入入库数量')]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
value={inboundData.quantity}
|
||||||
|
onChange={value =>
|
||||||
|
setInboundData({ ...inboundData, quantity: value || 0 })
|
||||||
|
}
|
||||||
|
style={{ ...inputNumberStyles.base, width: '100%' }}
|
||||||
|
placeholder="输入数量"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Form.Item label="操作人">
|
||||||
|
<Input
|
||||||
|
value={inboundData.operator}
|
||||||
|
onChange={e =>
|
||||||
|
setInboundData({ ...inboundData, operator: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="输入操作人"
|
||||||
|
style={inputStyles.form}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Form.Item label="原因">
|
||||||
|
<Input
|
||||||
|
value={inboundData.reason}
|
||||||
|
onChange={e =>
|
||||||
|
setInboundData({ ...inboundData, reason: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="输入入库原因"
|
||||||
|
style={inputStyles.form}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* SN序列号管理 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '16px',
|
||||||
|
paddingTop: '16px',
|
||||||
|
borderTop: '1px dashed #e5e7eb',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text strong style={{ fontSize: '14px', color: designTokens.colors.neutral[700] }}>
|
||||||
|
SN序列号(可选)
|
||||||
|
</Text>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
icon={<ScanOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setScanMode('add');
|
||||||
|
setScanValue('');
|
||||||
|
setScanModalVisible(true);
|
||||||
|
setTimeout(() => scanInputRef.current?.focus(), 100);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: designTokens.colors.primary.gradient,
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: designTokens.borderRadius.sm,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
扫码添加
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
const newSn = window.prompt('请输入SN序列号:');
|
||||||
|
if (newSn && newSn.trim() && !inboundSnList.includes(newSn.trim())) {
|
||||||
|
const newList = [...inboundSnList, newSn.trim()];
|
||||||
|
setInboundSnList(newList);
|
||||||
|
setInboundData(prev => ({ ...prev, quantity: newList.length }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ borderRadius: designTokens.borderRadius.sm }}
|
||||||
|
>
|
||||||
|
手动添加
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
{inboundSnList.length > 0 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxHeight: '120px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
border: `1px solid ${designTokens.colors.neutral[200]}`,
|
||||||
|
borderRadius: designTokens.borderRadius.sm,
|
||||||
|
padding: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{inboundSnList.map((sn, index) => (
|
||||||
|
<Tag
|
||||||
|
key={index}
|
||||||
|
closable
|
||||||
|
onClose={() => {
|
||||||
|
const newList = inboundSnList.filter((_, i) => i !== index);
|
||||||
|
setInboundSnList(newList);
|
||||||
|
setInboundData(prev => ({ ...prev, quantity: newList.length }));
|
||||||
|
}}
|
||||||
|
color="blue"
|
||||||
|
style={{ marginBottom: '4px', marginRight: '4px' }}
|
||||||
|
>
|
||||||
|
{sn}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||||
|
暂未添加SN序列号
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 编辑模式下的库存预警设置 */}
|
||||||
|
{editingConsumable && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%)',
|
||||||
|
borderRadius: designTokens.borderRadius.lg,
|
||||||
|
padding: '20px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
border: '1px solid #86efac',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
fontSize: '15px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: designTokens.colors.neutral[700],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '24px',
|
||||||
|
height: '24px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: designTokens.colors.success.gradient,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
2
|
||||||
|
</div>
|
||||||
|
库存预警
|
||||||
|
</div>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item
|
||||||
|
label="最小库存(预警线)"
|
||||||
|
name="minStock"
|
||||||
|
rules={[inputValidationRules.required('请输入最小库存')]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
style={{ ...inputNumberStyles.base, width: '100%' }}
|
||||||
|
placeholder={inputPlaceholders.minStock}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item label="最大库存">
|
||||||
|
<div style={{ marginBottom: '8px' }}>
|
||||||
|
<Checkbox
|
||||||
|
checked={maxStockUnlimited}
|
||||||
|
onChange={e => {
|
||||||
|
setMaxStockUnlimited(e.target.checked);
|
||||||
|
if (e.target.checked) {
|
||||||
|
form.setFieldsValue({ maxStock: undefined });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
无限制
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
{!maxStockUnlimited && (
|
||||||
|
<Form.Item
|
||||||
|
name="maxStock"
|
||||||
|
noStyle
|
||||||
|
rules={[inputValidationRules.required('请输入最大库存')]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
style={{ ...inputNumberStyles.base, width: '100%' }}
|
||||||
|
placeholder={inputPlaceholders.maxStock}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 编辑模式下的当前库存信息展示 */}
|
||||||
|
{editingConsumable && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: '#fafafa',
|
||||||
|
borderRadius: designTokens.borderRadius.lg,
|
||||||
|
padding: '16px 20px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '12px' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '24px',
|
||||||
|
height: '24px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'linear-gradient(135deg, #64748b 0%, #475569 100%)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '12px',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EyeOutlined style={{ fontSize: '12px' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
marginBottom: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text strong style={{ fontSize: '15px', color: designTokens.colors.neutral[700] }}>
|
||||||
|
当前库存信息
|
||||||
|
</Text>
|
||||||
|
<Tag color="default" style={{ borderRadius: '8px' }}>
|
||||||
|
只读
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
<Text type="secondary" style={{ fontSize: '13px', display: 'block', marginBottom: '12px' }}>
|
||||||
|
如需调整库存,请使用「调整库存」功能
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||||
|
gap: '16px',
|
||||||
|
padding: '16px',
|
||||||
|
background: '#fff',
|
||||||
|
borderRadius: designTokens.borderRadius.md,
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '24px', fontWeight: 'bold', color: designTokens.colors.primary.main }}>
|
||||||
|
{editingConsumable.currentStock || 0}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: designTokens.colors.neutral[500], marginTop: '4px' }}>
|
||||||
|
当前库存 ({editingConsumable.unit})
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '24px', fontWeight: 'bold', color: designTokens.colors.success.main }}>
|
||||||
|
{editingConsumable.snList?.length || 0}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: designTokens.colors.neutral[500], marginTop: '4px' }}>
|
||||||
|
已录入SN
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: (editingConsumable.currentStock || 0) <= (editingConsumable.minStock || 0)
|
||||||
|
? designTokens.colors.error.main
|
||||||
|
: designTokens.colors.neutral[700]
|
||||||
|
}}>
|
||||||
|
{editingConsumable.currentStock <= editingConsumable.minStock ? '⚠️ 低' : '✓ 正常'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: designTokens.colors.neutral[500], marginTop: '4px' }}>
|
||||||
|
库存状态
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 价格与状态 */}
|
{/* 价格与状态 */}
|
||||||
<div
|
<div
|
||||||
@@ -2207,222 +2609,6 @@ function ConsumableManagement() {
|
|||||||
</Row>
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SN序列号管理 */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: 'linear-gradient(135deg, #fff1f2 0%, #ffe4e6 100%)',
|
|
||||||
borderRadius: designTokens.borderRadius.lg,
|
|
||||||
padding: '20px',
|
|
||||||
marginBottom: '20px',
|
|
||||||
border: '1px solid #fda4af',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
marginBottom: '16px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px',
|
|
||||||
fontSize: '15px',
|
|
||||||
fontWeight: '600',
|
|
||||||
color: designTokens.colors.neutral[700],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '24px',
|
|
||||||
height: '24px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: designTokens.colors.error.gradient,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: '12px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
5
|
|
||||||
</div>
|
|
||||||
SN序列号管理
|
|
||||||
<Tag color="red" style={{ marginLeft: '8px', borderRadius: '12px' }}>
|
|
||||||
已录入 {snList.length} 个
|
|
||||||
</Tag>
|
|
||||||
</div>
|
|
||||||
<Space>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
size="small"
|
|
||||||
icon={<ScanOutlined />}
|
|
||||||
onClick={() => {
|
|
||||||
setScanMode('add');
|
|
||||||
setScanValue('');
|
|
||||||
setScanModalVisible(true);
|
|
||||||
setTimeout(() => scanInputRef.current?.focus(), 100);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
background: designTokens.colors.primary.gradient,
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: designTokens.borderRadius.sm,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
扫码添加
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
onClick={() => setSnInputVisible(!snInputVisible)}
|
|
||||||
style={{ borderRadius: designTokens.borderRadius.sm }}
|
|
||||||
>
|
|
||||||
批量添加
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
danger
|
|
||||||
icon={<DeleteOutlined />}
|
|
||||||
onClick={() => {
|
|
||||||
setSnList([]);
|
|
||||||
form.setFieldsValue({ currentStock: 0 });
|
|
||||||
}}
|
|
||||||
disabled={snList.length === 0}
|
|
||||||
style={{ borderRadius: designTokens.borderRadius.sm }}
|
|
||||||
>
|
|
||||||
清空全部
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 批量添加输入区 */}
|
|
||||||
{snInputVisible && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, height: 0 }}
|
|
||||||
animate={{ opacity: 1, height: 'auto' }}
|
|
||||||
exit={{ opacity: 0, height: 0 }}
|
|
||||||
style={{ marginBottom: '12px' }}
|
|
||||||
>
|
|
||||||
<Alert
|
|
||||||
message="批量添加提示"
|
|
||||||
description="每行输入一个SN序列号,系统会自动过滤重复项"
|
|
||||||
type="info"
|
|
||||||
showIcon
|
|
||||||
style={{ marginBottom: '12px', borderRadius: designTokens.borderRadius.md }}
|
|
||||||
/>
|
|
||||||
<TextArea
|
|
||||||
rows={4}
|
|
||||||
placeholder="输入SN序列号,每行一个 例如: SN001 SN002 SN003"
|
|
||||||
value={snInputValue}
|
|
||||||
onChange={e => setSnInputValue(e.target.value)}
|
|
||||||
style={{ ...textAreaStyles.base, marginBottom: '8px' }}
|
|
||||||
/>
|
|
||||||
<Space>
|
|
||||||
<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) {
|
|
||||||
const updatedSnList = [...snList, ...newSns];
|
|
||||||
setSnList(updatedSnList);
|
|
||||||
form.setFieldsValue({ currentStock: updatedSnList.length });
|
|
||||||
setSnInputValue('');
|
|
||||||
message.success(`成功添加 ${newSns.length} 个SN`);
|
|
||||||
} else {
|
|
||||||
message.warning('没有新的SN可添加(可能已存在或为空)');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
background: designTokens.colors.success.gradient,
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: designTokens.borderRadius.sm,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
确认添加
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
onClick={() => {
|
|
||||||
setSnInputValue('');
|
|
||||||
setSnInputVisible(false);
|
|
||||||
}}
|
|
||||||
style={{ borderRadius: designTokens.borderRadius.sm }}
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* SN列表展示 */}
|
|
||||||
{snList.length > 0 ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
maxHeight: '200px',
|
|
||||||
overflowY: 'auto',
|
|
||||||
border: `1px solid ${designTokens.colors.neutral[200]}`,
|
|
||||||
borderRadius: designTokens.borderRadius.md,
|
|
||||||
padding: '12px',
|
|
||||||
background: '#fff',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AnimatePresence>
|
|
||||||
{snList.map((sn, index) => (
|
|
||||||
<motion.div
|
|
||||||
key={sn}
|
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.8 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
style={{ display: 'inline-block', marginBottom: '8px', marginRight: '8px' }}
|
|
||||||
>
|
|
||||||
<Tag
|
|
||||||
closable
|
|
||||||
onClose={() => {
|
|
||||||
const newSnList = snList.filter((_, i) => i !== index);
|
|
||||||
setSnList(newSnList);
|
|
||||||
form.setFieldsValue({ currentStock: newSnList.length });
|
|
||||||
}}
|
|
||||||
color="blue"
|
|
||||||
style={{
|
|
||||||
padding: '4px 8px',
|
|
||||||
borderRadius: designTokens.borderRadius.sm,
|
|
||||||
fontSize: '13px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{sn}
|
|
||||||
</Tag>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
textAlign: 'center',
|
|
||||||
padding: '24px',
|
|
||||||
color: designTokens.colors.neutral[400],
|
|
||||||
background: '#fff',
|
|
||||||
borderRadius: designTokens.borderRadius.md,
|
|
||||||
border: `1px dashed ${designTokens.colors.neutral[300]}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<BarcodeOutlined
|
|
||||||
style={{ fontSize: '32px', marginBottom: '8px', display: 'block' }}
|
|
||||||
/>
|
|
||||||
<div>暂无SN序列号</div>
|
|
||||||
<div style={{ fontSize: '12px', marginTop: '4px' }}>点击上方按钮添加</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
{/* 操作按钮 */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -3817,7 +4003,6 @@ function ConsumableManagement() {
|
|||||||
onClose={() => {
|
onClose={() => {
|
||||||
const newSnList = snList.filter((_, i) => i !== index);
|
const newSnList = snList.filter((_, i) => i !== index);
|
||||||
setSnList(newSnList);
|
setSnList(newSnList);
|
||||||
form.setFieldsValue({ currentStock: newSnList.length });
|
|
||||||
}}
|
}}
|
||||||
color="blue"
|
color="blue"
|
||||||
style={{ marginBottom: '4px' }}
|
style={{ marginBottom: '4px' }}
|
||||||
|
|||||||
Reference in New Issue
Block a user