feat(repair): decouple material base, sync global sku sequence and add scan/print features
This commit is contained in:
BIN
deploy.tar.gz
BIN
deploy.tar.gz
Binary file not shown.
0
deploy_code.sh
Executable file → Normal file
0
deploy_code.sh
Executable file → Normal 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,
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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,37 +78,15 @@
|
|||||||
</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"
|
|
||||||
filterable
|
|
||||||
remote
|
|
||||||
reserve-keyword
|
|
||||||
placeholder="请输入关键词搜索物料"
|
|
||||||
:remote-method="handleSearchMaterialDebounced"
|
|
||||||
:loading="searchLoading"
|
|
||||||
style="width: 100%"
|
|
||||||
@change="onMaterialSelected"
|
|
||||||
>
|
|
||||||
<el-option
|
|
||||||
v-for="item in materialOptions"
|
|
||||||
:key="item.id"
|
|
||||||
:label="item.name"
|
|
||||||
:value="item.id"
|
|
||||||
>
|
|
||||||
<span style="float: left">{{ item.name }}</span>
|
|
||||||
<span style="float: right; color: #8492a6; font-size: 13px">{{ item.company_name }}</span>
|
|
||||||
</el-option>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="物料名称" prop="material_name">
|
<el-form-item label="物料名称" prop="material_name">
|
||||||
<el-input v-model="form.material_name" disabled />
|
<el-input v-model="form.material_name" placeholder="请输入物料名称" />
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="序列号SN" prop="serial_number">
|
|
||||||
<el-input v-model="form.serial_number" placeholder="请输入序列号" />
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
<el-form-item label="来源类型" prop="source_table">
|
<el-form-item label="来源类型" prop="source_table">
|
||||||
<el-select v-model="form.source_table" placeholder="请选择来源类型" style="width: 100%">
|
<el-select v-model="form.source_table" placeholder="请选择来源类型" style="width: 100%">
|
||||||
<el-option label="采购入库" value="stock_buy" />
|
<el-option label="采购入库" value="stock_buy" />
|
||||||
@ -112,6 +95,19 @@
|
|||||||
<el-option label="独立录入" value="independent" />
|
<el-option label="独立录入" value="independent" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="序列号SN" prop="serial_number">
|
||||||
|
<el-input v-model="form.serial_number" placeholder="请输入或扫描序列号">
|
||||||
|
<template #append>
|
||||||
|
<el-button :icon="Camera" @click="openScanner" title="智能扫码" />
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
<el-form-item label="接收时间" prop="arrival_date">
|
<el-form-item label="接收时间" prop="arrival_date">
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
v-model="form.arrival_date"
|
v-model="form.arrival_date"
|
||||||
@ -121,22 +117,35 @@
|
|||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="客户名称" prop="customer_name">
|
||||||
|
<el-input v-model="form.customer_name" placeholder="请输入客户名称" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<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-row :gutter="20">
|
||||||
|
<el-col :span="8">
|
||||||
<el-form-item label="维修人" prop="repair_manager">
|
<el-form-item label="维修人" prop="repair_manager">
|
||||||
<el-input v-model="form.repair_manager" placeholder="请输入维修人" />
|
<el-input v-model="form.repair_manager" placeholder="请输入维修人" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="客户/来源" prop="related_contract_id">
|
</el-col>
|
||||||
<el-input v-model="form.related_contract_id" placeholder="请输入客户名称或来源" />
|
<el-col :span="8">
|
||||||
</el-form-item>
|
|
||||||
<el-row :gutter="20">
|
|
||||||
<el-col :span="12">
|
|
||||||
<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
219
全局系统体检报告.md
Normal 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 全局静态扫描生成,仅供参考。实际修复请结合业务场景进行测试验证。*
|
||||||
Reference in New Issue
Block a user