feat(repair): decouple material base, sync global sku sequence and add scan/print features

This commit is contained in:
DXC
2026-04-08 19:36:14 +08:00
parent cf7dc04db7
commit 3085d9f447
6 changed files with 464 additions and 136 deletions

Binary file not shown.

0
deploy_code.sh Executable file → Normal file
View File

View File

@ -81,6 +81,9 @@ class TransRepair(db.Model):
# SKU 保留 # SKU 保留
sku = db.Column(db.String(100)) sku = db.Column(db.String(100))
# 物料名称 (独立录入时使用非关联base_id)
material_name = db.Column(db.String(200))
# 序列号SN (新增,用于单台追溯) # 序列号SN (新增,用于单台追溯)
serial_number = db.Column(db.String(100), nullable=True) serial_number = db.Column(db.String(100), nullable=True)
@ -114,6 +117,12 @@ class TransRepair(db.Model):
# 客户名/来源 # 客户名/来源
related_contract_id = db.Column(db.String(100)) related_contract_id = db.Column(db.String(100))
# 客户名称 (新增)
customer_name = db.Column(db.String(100))
# 客户所在地 (新增)
customer_location = db.Column(db.String(255))
# 成本与售价 # 成本与售价
cost_price = db.Column(db.Numeric(19, 4)) cost_price = db.Column(db.Numeric(19, 4))
sale_price = db.Column(db.Numeric(19, 4)) sale_price = db.Column(db.Numeric(19, 4))
@ -130,6 +139,7 @@ class TransRepair(db.Model):
'repair_no': self.repair_no, 'repair_no': self.repair_no,
'base_id': self.base_id, 'base_id': self.base_id,
'sku': self.sku, 'sku': self.sku,
'material_name': self.material_name,
'serial_number': self.serial_number, 'serial_number': self.serial_number,
'source_table': self.source_table, 'source_table': self.source_table,
'stock_id': self.stock_id, 'stock_id': self.stock_id,
@ -140,6 +150,8 @@ class TransRepair(db.Model):
'is_self_made': self.is_self_made, 'is_self_made': self.is_self_made,
'related_product_id': self.related_product_id, 'related_product_id': self.related_product_id,
'related_contract_id': self.related_contract_id, 'related_contract_id': self.related_contract_id,
'customer_name': self.customer_name,
'customer_location': self.customer_location,
'repair_manager': self.repair_manager, 'repair_manager': self.repair_manager,
'fault_description': self.fault_description, 'fault_description': self.fault_description,
'repair_result': self.repair_result, 'repair_result': self.repair_result,

View File

@ -3,8 +3,7 @@ from app.extensions import db
from app.models.transaction import TransRepair from app.models.transaction import TransRepair
from app.models.base import MaterialBase from app.models.base import MaterialBase
from datetime import datetime, timezone from datetime import datetime, timezone
import random from sqlalchemy import text
import string
class RepairInboundService: class RepairInboundService:
@ -13,27 +12,42 @@ class RepairInboundService:
def _generate_repair_no(): def _generate_repair_no():
""" """
生成唯一的维修单号 生成唯一的维修单号
格式: REP-YYYYMMDD-XXXX (X为随机大写字母或数字) 格式: REP-YYYYMMDD-0001 (按天递增)
防重复策略: 策略: 查询当天最大的repair_no提取流水号+1
1. 先尝试生成4位随机序列
2. 检查数据库中是否存在该单号
3. 如果冲突重试最多10次
4. 如果10次都冲突加入时间戳毫秒数确保唯一
""" """
for _ in range(10): today = datetime.now().strftime('%Y%m%d')
date_str = datetime.now().strftime('%Y%m%d') prefix = f"REP-{today}-"
random_str = ''.join(random.choices(string.ascii_uppercase + string.digits, k=4))
repair_no = f"REP-{date_str}-{random_str}"
# 检查是否已存在 # 查询当天最大的维修单号
exists = TransRepair.query.filter_by(repair_no=repair_no).first() latest = TransRepair.query.filter(
if not exists: TransRepair.repair_no.like(f"{prefix}%")
return repair_no ).order_by(TransRepair.repair_no.desc()).first()
# 兜底策略:使用时间戳毫秒 if latest and latest.repair_no:
timestamp = int(datetime.now().timestamp() * 1000) % 100000 try:
date_str = datetime.now().strftime('%Y%m%d') # 提取最后的流水号
return f"REP-{date_str}-{timestamp:05d}" last_seq = int(latest.repair_no.split('-')[-1])
new_seq = last_seq + 1
except (ValueError, IndexError):
new_seq = 1
else:
new_seq = 1
return f"{prefix}{new_seq:04d}"
@staticmethod
def _generate_sku():
"""
获取全局自增序列号生成10位SKU
格式: str(seq).zfill(10)
"""
try:
seq_sql = text("SELECT nextval('global_print_seq')")
result = db.session.execute(seq_sql)
next_global_id = result.scalar()
return str(next_global_id).zfill(10) if next_global_id else None
except:
return None
@staticmethod @staticmethod
def get_list(params): def get_list(params):
@ -57,10 +71,15 @@ class RepairInboundService:
if params.get('repair_status'): if params.get('repair_status'):
query = query.filter(TransRepair.repair_status == params['repair_status']) query = query.filter(TransRepair.repair_status == params['repair_status'])
# 关联 MaterialBase 查询物料名称 # 关联 MaterialBase 查询物料名称 或 直接搜索 TransRepair.material_name
if params.get('material_name'): if params.get('material_name'):
query = query.join(MaterialBase, TransRepair.base_id == MaterialBase.id).filter( material_name_filter = params['material_name']
MaterialBase.name.ilike(f"%{params['material_name']}%") # 优先搜索直接存储的 material_name其次搜索关联的 base.name
query = query.outerjoin(MaterialBase, TransRepair.base_id == MaterialBase.id).filter(
db.or_(
TransRepair.material_name.ilike(f"%{material_name_filter}%"),
MaterialBase.name.ilike(f"%{material_name_filter}%")
)
) )
# 按创建时间倒序 # 按创建时间倒序
@ -92,20 +111,26 @@ class RepairInboundService:
""" """
新增维修单 新增维修单
核心要求: 核心要求:
1. 生成以 REP- 打头的自增维修单号 1. 生成以 REP- 打头的自增维修单号 (按天递增)
2. 支持自动获取传入的 base_id 获取基础物料名称 2. 从全局序列获取10位SKU (global_print_seq)
3. 支持不关联 base_id (独立录入模式)
4. 新增客户名称和客户所在地字段
""" """
# 生成维修单号 # 生成维修单号
repair_no = RepairInboundService._generate_repair_no() repair_no = RepairInboundService._generate_repair_no()
# 获取物料信息 # 获取全局SKU
material_name = None
company_name = None
sku = data.get('sku') sku = data.get('sku')
if not sku:
sku = RepairInboundService._generate_sku()
# 获取物料信息 (可选)
material_name = data.get('material_name')
company_name = None
if data.get('base_id'): if data.get('base_id'):
base = MaterialBase.query.get(data['base_id']) base = MaterialBase.query.get(data['base_id'])
if base: if base:
material_name = base.name material_name = base.name or material_name
company_name = base.company_name company_name = base.company_name
if not sku: if not sku:
sku = base.code sku = base.code
@ -113,7 +138,8 @@ class RepairInboundService:
repair = TransRepair( repair = TransRepair(
repair_no=repair_no, repair_no=repair_no,
base_id=data.get('base_id'), base_id=data.get('base_id'),
sku=sku or data.get('sku'), sku=sku,
material_name=material_name,
serial_number=data.get('serial_number'), serial_number=data.get('serial_number'),
arrival_date=data.get('arrival_date'), arrival_date=data.get('arrival_date'),
repair_status=data.get('repair_status', '待检测'), repair_status=data.get('repair_status', '待检测'),
@ -123,6 +149,9 @@ class RepairInboundService:
repair_manager=data.get('repair_manager'), repair_manager=data.get('repair_manager'),
shipping_date=data.get('shipping_date'), shipping_date=data.get('shipping_date'),
related_contract_id=data.get('related_contract_id'), related_contract_id=data.get('related_contract_id'),
# 新增客户字段
customer_name=data.get('customer_name'),
customer_location=data.get('customer_location'),
cost_price=data.get('cost_price'), cost_price=data.get('cost_price'),
sale_price=data.get('sale_price'), sale_price=data.get('sale_price'),
company_id=data.get('company_id'), company_id=data.get('company_id'),
@ -151,10 +180,10 @@ class RepairInboundService:
# 可更新字段 # 可更新字段
updatable_fields = [ updatable_fields = [
'base_id', 'sku', 'serial_number', 'arrival_date', 'repair_status', 'base_id', 'sku', 'material_name', 'serial_number', 'arrival_date', 'repair_status',
'fault_description', 'expected_repair_time', 'repair_result', 'fault_description', 'expected_repair_time', 'repair_result',
'repair_manager', 'shipping_date', 'related_contract_id', 'repair_manager', 'shipping_date', 'related_contract_id',
'cost_price', 'sale_price', 'company_id' 'customer_name', 'customer_location', 'cost_price', 'sale_price', 'company_id'
] ]
for field in updatable_fields: for field in updatable_fields:

View File

@ -37,18 +37,23 @@
<!-- 数据表格 --> <!-- 数据表格 -->
<el-table :data="tableData" v-loading="loading" border stripe style="width: 100%"> <el-table :data="tableData" v-loading="loading" border stripe style="width: 100%">
<el-table-column prop="repair_no" label="维修单号" width="180" /> <el-table-column prop="repair_no" label="维修单号" width="180" />
<el-table-column prop="sku" label="全局SKU(系统条码)" width="140" />
<el-table-column prop="material_name" label="物料名称" width="150" /> <el-table-column prop="material_name" label="物料名称" width="150" />
<el-table-column prop="sku" label="规格型号" width="120" />
<el-table-column prop="serial_number" label="序列号(SN)" width="150" /> <el-table-column prop="serial_number" label="序列号(SN)" width="150" />
<el-table-column prop="customer_name" label="客户名称" width="120" />
<el-table-column prop="customer_location" label="所在地" width="150" show-overflow-tooltip />
<el-table-column label="状态" width="100" align="center"> <el-table-column label="状态" width="100" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="getStatusType(row.repair_status)">{{ row.repair_status || '待检测' }}</el-tag> <el-tag :type="getStatusType(row.repair_status)">{{ row.repair_status || '待检测' }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="arrival_date" label="接收时间" width="120" /> <el-table-column prop="arrival_date" label="接收时间" width="120" />
<el-table-column prop="fault_description" label="故障描述" min-width="200" show-overflow-tooltip /> <el-table-column prop="fault_description" label="故障描述" min-width="150" show-overflow-tooltip />
<el-table-column label="操作" width="180" fixed="right" align="center"> <el-table-column label="操作" width="220" fixed="right" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-button v-if="userStore.hasPermission('inbound_repair:edit')" type="warning" link size="small" @click="handlePrint(row)">
<el-icon><Printer /></el-icon> 打印
</el-button>
<el-button v-if="userStore.hasPermission('inbound_repair:edit')" type="primary" link size="small" @click="handleUpdateStatus(row)"> <el-button v-if="userStore.hasPermission('inbound_repair:edit')" type="primary" link size="small" @click="handleUpdateStatus(row)">
更新状态 更新状态
</el-button> </el-button>
@ -73,70 +78,74 @@
</div> </div>
<!-- 新增维修单弹窗 --> <!-- 新增维修单弹窗 -->
<el-dialog v-model="dialogVisible" title="新增维修单" width="600px" destroy-on-close :close-on-click-modal="false"> <el-dialog v-model="dialogVisible" title="新增维修单" width="650px" destroy-on-close :close-on-click-modal="false">
<el-form ref="formRef" :model="form" :rules="formRules" label-width="100px"> <el-form ref="formRef" :model="form" :rules="formRules" label-width="100px">
<el-form-item label="物料选择" prop="base_id"> <el-row :gutter="20">
<el-select <el-col :span="12">
v-model="form.base_id" <el-form-item label="物料名称" prop="material_name">
filterable <el-input v-model="form.material_name" placeholder="请输入物料名称" />
remote </el-form-item>
reserve-keyword </el-col>
placeholder="请输入关键词搜索物料" <el-col :span="12">
:remote-method="handleSearchMaterialDebounced" <el-form-item label="来源类型" prop="source_table">
:loading="searchLoading" <el-select v-model="form.source_table" placeholder="请选择来源类型" style="width: 100%">
style="width: 100%" <el-option label="采购入库" value="stock_buy" />
@change="onMaterialSelected" <el-option label="成品入库" value="stock_product" />
> <el-option label="半成品入库" value="stock_semi" />
<el-option <el-option label="独立录入" value="independent" />
v-for="item in materialOptions" </el-select>
:key="item.id" </el-form-item>
:label="item.name" </el-col>
:value="item.id" </el-row>
> <el-row :gutter="20">
<span style="float: left">{{ item.name }}</span> <el-col :span="12">
<span style="float: right; color: #8492a6; font-size: 13px">{{ item.company_name }}</span> <el-form-item label="序列号SN" prop="serial_number">
</el-option> <el-input v-model="form.serial_number" placeholder="请输入或扫描序列号">
</el-select> <template #append>
</el-form-item> <el-button :icon="Camera" @click="openScanner" title="智能扫码" />
<el-form-item label="物料名称" prop="material_name"> </template>
<el-input v-model="form.material_name" disabled /> </el-input>
</el-form-item> </el-form-item>
<el-form-item label="序列号SN" prop="serial_number"> </el-col>
<el-input v-model="form.serial_number" placeholder="请输入序列号" /> <el-col :span="12">
</el-form-item> <el-form-item label="接收时间" prop="arrival_date">
<el-form-item label="来源类型" prop="source_table"> <el-date-picker
<el-select v-model="form.source_table" placeholder="请选择来源类型" style="width: 100%"> v-model="form.arrival_date"
<el-option label="采购入库" value="stock_buy" /> type="date"
<el-option label="成品入库" value="stock_product" /> placeholder="选择接收时间"
<el-option label="半成品入库" value="stock_semi" /> value-format="YYYY-MM-DD"
<el-option label="独立录入" value="independent" /> style="width: 100%"
</el-select> />
</el-form-item> </el-form-item>
<el-form-item label="接收时间" prop="arrival_date"> </el-col>
<el-date-picker </el-row>
v-model="form.arrival_date" <el-row :gutter="20">
type="date" <el-col :span="12">
placeholder="选择接收时间" <el-form-item label="客户名称" prop="customer_name">
value-format="YYYY-MM-DD" <el-input v-model="form.customer_name" placeholder="请输入客户名称" />
style="width: 100%" </el-form-item>
/> </el-col>
</el-form-item> <el-col :span="12">
<el-form-item label="所在地" prop="customer_location">
<el-input v-model="form.customer_location" placeholder="请输入客户所在地" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="故障描述" prop="fault_description"> <el-form-item label="故障描述" prop="fault_description">
<el-input v-model="form.fault_description" type="textarea" :rows="3" placeholder="请输入客户反馈的故障描述" /> <el-input v-model="form.fault_description" type="textarea" :rows="3" placeholder="请输入客户反馈的故障描述" />
</el-form-item> </el-form-item>
<el-form-item label="维修人" prop="repair_manager">
<el-input v-model="form.repair_manager" placeholder="请输入维修人" />
</el-form-item>
<el-form-item label="客户/来源" prop="related_contract_id">
<el-input v-model="form.related_contract_id" placeholder="请输入客户名称或来源" />
</el-form-item>
<el-row :gutter="20"> <el-row :gutter="20">
<el-col :span="12"> <el-col :span="8">
<el-form-item label="维修人" prop="repair_manager">
<el-input v-model="form.repair_manager" placeholder="请输入维修人" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="成本价" prop="cost_price"> <el-form-item label="成本价" prop="cost_price">
<el-input-number v-model="form.cost_price" :precision="2" :min="0" :controls="false" style="width: 100%" /> <el-input-number v-model="form.cost_price" :precision="2" :min="0" :controls="false" style="width: 100%" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="8">
<el-form-item label="销售价" prop="sale_price"> <el-form-item label="销售价" prop="sale_price">
<el-input-number v-model="form.sale_price" :precision="2" :min="0" :controls="false" style="width: 100%" /> <el-input-number v-model="form.sale_price" :precision="2" :min="0" :controls="false" style="width: 100%" />
</el-form-item> </el-form-item>
@ -177,16 +186,40 @@
<el-button type="primary" :loading="statusSubmitLoading" @click="handleStatusSubmit">确定</el-button> <el-button type="primary" :loading="statusSubmitLoading" @click="handleStatusSubmit">确定</el-button>
</template> </template>
</el-dialog> </el-dialog>
<!-- 智能扫码弹窗 -->
<SmartScannerDialog v-model="scannerDialogVisible" @confirm="handleScannerConfirm" />
<!-- 打印预览弹窗 -->
<el-dialog v-model="printVisible" title="标签打印预览" width="400px" destroy-on-close append-to-body>
<div v-loading="printLoading" class="preview-box">
<img v-if="previewUrl" :src="previewUrl" alt="打印预览" style="width: 100%" />
</div>
<p>打印机 IP: 192.168.9.205</p>
<div style="margin: 15px 0;">
<span style="font-weight: bold; color: #303133;">打印份数:</span>
<el-input-number v-model="printCopies" :min="1" :max="100" size="default" style="width: 120px; margin-left: 10px;" />
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="printVisible = false">取消</el-button>
<el-button type="primary" :loading="printing" @click="confirmPrint">
<el-icon><Printer /></el-icon>确认打印
</el-button>
</div>
</template>
</el-dialog>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { Plus, Search, Refresh } from '@element-plus/icons-vue' import { Plus, Search, Refresh, Printer, Camera } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox, ElFormRules } from 'element-plus' import { ElMessage, ElMessageBox, ElFormRules } from 'element-plus'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { getRepairList, createRepair, updateRepairStatus, deleteRepair } from '@/api/inbound/repair' import { getRepairList, createRepair, updateRepairStatus, deleteRepair } from '@/api/inbound/repair'
import { searchMaterialBase } from '@/api/inbound/buy' import { getLabelPreview, executePrint } from '@/api/common/print'
import SmartScannerDialog from '@/components/SmartScannerDialog.vue'
const userStore = useUserStore() const userStore = useUserStore()
@ -212,26 +245,21 @@ const dialogVisible = ref(false)
const submitLoading = ref(false) const submitLoading = ref(false)
const formRef = ref() const formRef = ref()
const form = reactive({ const form = reactive({
base_id: null as number | null,
material_name: '', material_name: '',
serial_number: '', serial_number: '',
source_table: 'independent', source_table: 'independent',
arrival_date: '', arrival_date: '',
fault_description: '', fault_description: '',
customer_name: '',
customer_location: '',
repair_manager: '', repair_manager: '',
related_contract_id: '',
cost_price: undefined as number | undefined, cost_price: undefined as number | undefined,
sale_price: undefined as number | undefined sale_price: undefined as number | undefined
}) })
// 物料搜索
const materialOptions = ref<any[]>([])
const searchLoading = ref(false)
const searchKeyword = ref('')
// 表单校验 // 表单校验
const formRules: ElFormRules = [ const formRules: ElFormRules = [
{ required: true, message: '请选择物料', trigger: 'change', field: 'base_id' }, { required: true, message: '请输入物料名称', trigger: 'blur', field: 'material_name' },
{ required: true, message: '请输入序列号', trigger: 'blur', field: 'serial_number' } { required: true, message: '请输入序列号', trigger: 'blur', field: 'serial_number' }
] ]
@ -251,6 +279,26 @@ const statusFormRules: ElFormRules = [
{ required: true, message: '请选择新状态', trigger: 'change', field: 'status' } { required: true, message: '请选择新状态', trigger: 'change', field: 'status' }
] ]
// 智能扫码
const scannerDialogVisible = ref(false)
const openScanner = () => {
scannerDialogVisible.value = true
}
const handleScannerConfirm = (result: string) => {
form.serial_number = result
scannerDialogVisible.value = false
}
// 打印相关
const printVisible = ref(false)
const printLoading = ref(false)
const printing = ref(false)
const previewUrl = ref('')
const printCopies = ref(1)
const currentPrintData = ref<any>({})
// 状态颜色映射 // 状态颜色映射
const getStatusType = (status: string) => { const getStatusType = (status: string) => {
const map: Record<string, string> = { const map: Record<string, string> = {
@ -298,51 +346,19 @@ const handleReset = () => {
handleSearch() handleSearch()
} }
// 物料搜索防抖
let searchTimer: ReturnType<typeof setTimeout> | null = null
const handleSearchMaterialDebounced = (query: string) => {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
handleSearchMaterial(query)
}, 300)
}
const handleSearchMaterial = async (query: string) => {
if (!query) return
searchLoading.value = true
searchKeyword.value = query
try {
const res: any = await searchMaterialBase(query, 1)
if (res.data) {
materialOptions.value = res.data || []
}
} finally {
searchLoading.value = false
}
}
// 物料选中
const onMaterialSelected = (val: number) => {
const item = materialOptions.value.find(i => i.id === val)
if (item) {
form.material_name = item.name
}
}
// 新增 // 新增
const handleCreate = () => { const handleCreate = () => {
// 重置表单 // 重置表单
form.base_id = null
form.material_name = '' form.material_name = ''
form.serial_number = '' form.serial_number = ''
form.source_table = 'independent' form.source_table = 'independent'
form.arrival_date = '' form.arrival_date = ''
form.fault_description = '' form.fault_description = ''
form.customer_name = ''
form.customer_location = ''
form.repair_manager = '' form.repair_manager = ''
form.related_contract_id = ''
form.cost_price = undefined form.cost_price = undefined
form.sale_price = undefined form.sale_price = undefined
materialOptions.value = []
dialogVisible.value = true dialogVisible.value = true
} }
@ -417,6 +433,43 @@ const handleDelete = (row: any) => {
}).catch(() => {}) }).catch(() => {})
} }
// 打印标签
const handlePrint = async (row: any) => {
printVisible.value = true
printLoading.value = true
printCopies.value = 1
currentPrintData.value = {
sku: row.sku,
material_name: row.material_name,
serial_number: row.serial_number,
repair_no: row.repair_no
}
try {
const res: any = await getLabelPreview(currentPrintData.value)
previewUrl.value = res.data
} catch (e) {
ElMessage.error('预览失败')
} finally {
printLoading.value = false
}
}
// 确认打印
const confirmPrint = async () => {
printing.value = true
try {
await executePrint({ ...currentPrintData.value, copies: printCopies.value })
ElMessage.success(`打印指令已发送 (x${printCopies.value})`)
printVisible.value = false
} catch (e: any) {
ElMessage.error(e.msg || '打印失败')
} finally {
printing.value = false
}
}
onMounted(() => { onMounted(() => {
fetchData() fetchData()
}) })
@ -444,4 +497,19 @@ onMounted(() => {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
} }
.preview-box {
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f7fa;
margin-bottom: 15px;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style> </style>

219
全局系统体检报告.md Normal file
View File

@ -0,0 +1,219 @@
# IRIS 库存管理系统 - 全局系统体检报告
> 审查日期2026-04-02
> 审查范围inventory-web (Vue3 + Element Plus) + inventory-backend (Flask + SQLAlchemy)
> 审查模式:静态代码分析
---
## 一、前端状态与渲染漏洞 (Vue3 + Element Plus)
### [🚨 高危漏洞]
#### 1. el-table 缺少 reserve-selection 导致分页勾选丢失
- **模块/文件**: `inventory-web/src/views/materiel/list.vue` (及其他多个页面)
- **问题描述**: 大多数表格使用了 `type="selection"` 但未设置 `:reserve-selection="true"`
- **代码行数**: 该问题影响 30+ 个 el-table 组件
- **根因分析**:
- 分页后切换页面时,之前选中的行会丢失
- 只有 Selection.vue 的手动选择弹窗表格(第118行)添加了 `:reserve-selection="true"`
- **验证方法**: 进入物料列表勾选第1页的某几项切换到第2页再返回第1页确认勾选状态
#### 2. el-table 缺少 row-key 导致全选/渲染异常
- **模块/文件**: `inventory-web/src/views/stock/stocktake/index.vue`
- **代码行数**: 第240行
- **根因分析**: 该表格使用的 row-key="id"但如果存在跨表数据如物料、半成品、成品混排id 可能会冲突
#### 3. 盘点列表一次性加载 10000 条数据
- **模块/文件**: `inventory-web/src/views/stock/stocktake/index.vue`
- **代码行数**: 第662行、第985行
- **问题代码**:
```javascript
params: { page: 1, limit: 10000 } // 获取足够多的数据
limit: 10000, // 获取全部已盘点记录
```
- **风险**: 大量盘点记录时会导致前端内存溢出、页面卡死
---
### [⚠️ 交互/逻辑隐患]
#### 4. el-dialog 缺少 destroy-on-close 导致表单残留
- **模块/文件**: `inventory-web/src/views/outbound/create.vue`
- **代码行数**: 第174行
- **问题代码**: `<el-dialog v-model="showDialog" title="新建出库单" width="75%" :close-on-click-modal="false">`
- **根因分析**:
- 弹窗关闭后,数据未重置
- 再次打开弹窗会看到上一次填写的数据
- **建议**: 添加 `destroy-on-close` 属性或手动在关闭回调中重置表单
#### 5. formRef.resetFields() 调用不足
- **模块/文件**: 多个视图文件
- **根因分析**: 大多数弹窗表单没有调用 resetFields() 进行彻底重置
- **对比**:
- material/list.vue: 第1228行 ✅ 有 resetFields
- bom/BomManage.vue: 第656行 ✅ 有 resetFields
- system/UserCreate.vue: 第374行 ✅ 有 resetFields
- 其他大多数 ❌ 无重置逻辑
#### 6. 响应式解构潜在风险
- **模块/文件**: `inventory-web/src/App.vue`
- **代码行数**: 第80行
- **问题代码**: `const { new_password, confirm_password } = passwordForm.value`
- **说明**: 这种写法会失去响应性绑定,属于潜在风险(当前代码未直接修改解构后的变量,风险较低)
---
## 二、后端 ORM 与生命周期陷阱 (Flask + SQLAlchemy)
### [🚨 高危漏洞]
#### 7. @audit_log 装饰器中的 DetachedInstanceError 风险
- **模块/文件**: `inventory-backend/app/utils/decorators.py`
- **代码行数**: 第211-260行、第318行
- **问题代码**:
```python
# 第211-217行查询用户
user = SysUser.query.get(user_id)
if user:
user_info = user.to_dict() # 可能返回 DetachedInstanceError
display_name = user_info.get('display_name', username)
# 第248行添加审计日志
log_entry = AuditLog(...)
db.session.add(log_entry)
# 第318行在请求结束后 commit
db.session.commit()
```
- **根因分析**:
- 装饰器在 db.session.commit() 后访问已游离的对象属性
- 特别是在请求返回后session 可能已关闭,再次访问 user 对象会触发 DetachedInstanceError
#### 8. N+1 查询问题 - 库位树/库存列表
- **模块/文件**: `inventory-backend/app/api/v1/warehouse.py`, `inventory-backend/app/api/v1/inbound/stock.py`
- **代码行数**:
- warehouse.py 第118-132行循环查询每条库存记录
- stock.py 第1113-1138行全量查询后循环处理
- **问题代码**:
```python
# stock.py 第1113行
for item in StockBuy.query.filter(StockBuy.stock_quantity > 0).all():
# 每次循环访问 item.base 时会触发新的 SQL 查询 (Lazy Load)
'material_name': item.base.name
```
- **根因分析**: 没有使用 joinedload 或 eager loading 预加载关联关系
#### 9. 批量操作无最大数量限制
- **模块/文件**: `inventory-backend/app/api/v1/inbound/buy.py`
- **根因分析**:
- 批量创建物料/成品/半成品时没有限制最大条目数
- 前端子啊 stocktake/index.vue 设置了 limit=10000
- 后端如果接收大量数据会导致内存溢出
---
### [⚠️ 交互/逻辑隐患]
#### 10. 出库扣减逻辑中的可用库存竞态
- **模块/文件**: `inventory-backend/app/services/outbound_service.py`
- **代码行数**: 第169-173行
- **问题代码**:
```python
stock_record = ModelClass.query.with_for_update().get(stock_id) # 使用了悲观锁 ✅
if float(stock_record.available_quantity) < quantity:
raise ValueError(...)
stock_record.available_quantity = float(stock_record.available_quantity) - quantity
```
- **分析**: 已使用 `with_for_update()` 悲观锁,但需要确认数据库连接是否支持行级锁
- **建议**: 建议增加版本号字段实现乐观锁,作为双重保险
#### 11. 文件上传无大小限制
- **模块/文件**: `inventory-backend/app/api/v1/common/upload.py`
- **根因分析**:
- 没有检查文件大小
- 没有限制同时上传的文件数量
- **建议**: 添加 MAX_FILE_SIZE 和并发限制
---
## 三、业务并发与数据一致性
### [🚨 高危漏洞]
#### 12. 库存扣减无乐观锁
- **模块/文件**: 所有库存相关表 (StockBuy, StockSemi, StockProduct)
- **根因分析**:
- 库存表中没有 version 字段
- 仅依赖数据库行锁with_for_update()
- 高并发场景下可能出现库存扣为负数
- **影响范围**: 出库、报废、借用、盘点调整等所有减少库存的操作
#### 13. 盘点实盘数更新竞态
- **模块/文件**: `inventory-backend/app/api/v1/stock/adjustment.py`
- **代码行数**: 第226-250行
- **问题代码**:
```python
for stock in pagination.items:
new_qty = float(stock.stock_quantity)
# 读取和写入之间存在时间窗口,可能被其他请求修改
stock.stock_quantity = new_qty
db.session.add(stock)
db.session.commit()
```
- **根因分析**:
- 循环中的每条记录没有悲观锁
- 并发盘点可能导致数据覆盖
---
### [⚠️ 交互/逻辑隐患]
#### 14. 唯一键判断不严谨 - 购物车追加
- **模块/文件**: `inventory-web/src/views/outbound/Selection.vue`
- **根因分析**:
- 前端购物车基于 `type_id` 判断是否重复
- 如果 type 相同但 id 不同,仍会误判
- **当前状态**: ✅ 代码已修复,使用 `${item.type}_${item.id}` 格式
#### 15. BOM 批量导入无校验
- **模块/文件**: `inventory-backend/app/api/v1/bom.py`
- **代码行数**: 第278-340行
- **根因分析**:
- children 数据直接写入,不检查是否有重复子件
- 不验证子件是否存在
---
## 四、报告总结
### 漏洞统计
| 严重程度 | 数量 |
|---------|------|
| 🚨 高危漏洞 | 7 |
| ⚠️ 交互/逻辑隐患 | 8 |
| ✅ 状态良好 | 15+ |
### 优先修复建议(按优先级排序)
1. **[P0] 前端**:为所有分页表格添加 `:reserve-selection="true"` 和唯一 `row-key`
2. **[P0] 前端**:修复 stocktake <20><> 10000 条限制,改用分页或虚拟滚动
3. **[P0] 后端**:修复 @audit_log 的 DetachedInstanceError分两次 commit 或刷新对象)
4. **[P1] 后端**:为库存表添加 version 字段实现乐观锁
5. **[P1] 后端**:为盘点实盘更新添加 with_for_update()
6. **[P1] 后端**:添加批量操作最大限制(如 500 条/请求)
7. **[P2] 前端**:为所有表单弹窗添加 destroy-on-close 或手动重置
8. **[P2] 后端**:优化 N+1 查询,添加 joinedload
### ✅ 状态良好的核心链路
- ✅ 出库单创建使用了悲观锁 (with_for_update)
- ✅ 物料基础信息管理使用 visibilityLevel 控制
- ✅ 登录 Token 验证与 Redis 单设备登录互踢
- ✅ 文件上传使用 UUID 生成唯一文件名
- ✅ 出库选单唯一键已修复为 `${type}_${id}` 格式
- ✅ 库位路径 natural sorting 已实现
---
*本报告由 Qwen Code 全局静态扫描生成,仅供参考。实际修复请结合业务场景进行测试验证。*