Compare commits

9 Commits

6 changed files with 288 additions and 179 deletions

View File

@ -1,8 +1,10 @@
# 文件路径: app/api/v1/inbound/base.py # 文件路径: app/api/v1/inbound/base.py
from flask import Blueprint, request, jsonify, send_file, g from flask import Blueprint, request, jsonify, send_file, g
from app.extensions import db
from app.services.inbound.base_service import MaterialBaseService from app.services.inbound.base_service import MaterialBaseService
from app.utils.decorators import login_required, permission_required, audit_log from app.utils.decorators import login_required, permission_required, audit_log
from app.models.base import MaterialBase, MaterialWarningSetting
import traceback import traceback
import datetime import datetime
import json import json
@ -129,6 +131,12 @@ def get_list():
} }
user_permissions = get_current_user_permissions() user_permissions = get_current_user_permissions()
# 自动拦截:如果用户有预警查看权限,且当前没有按特定列手动排序,则强制开启预警智能排序
has_warning_perm = 'material_list:view_warning' in user_permissions
if has_warning_perm and not filters.get('orderByColumn'):
filters['enableWarningSort'] = True
result = MaterialBaseService.get_list(page, limit, filters, user_permissions) result = MaterialBaseService.get_list(page, limit, filters, user_permissions)
# 字段级脱敏 # 字段级脱敏
user_permissions = get_current_user_permissions() user_permissions = get_current_user_permissions()
@ -348,8 +356,6 @@ def batch_set_warning():
if not isinstance(data, list): if not isinstance(data, list):
return jsonify({"code": 400, "msg": "请求体必须为数组"}) return jsonify({"code": 400, "msg": "请求体必须为数组"})
from app.models.base import MaterialWarningSetting
updated_count = 0 updated_count = 0
created_count = 0 created_count = 0
@ -371,18 +377,21 @@ def batch_set_warning():
# 更新现有记录 # 更新现有记录
if 'isEnabled' in item: if 'isEnabled' in item:
warning.is_enabled = bool(item['isEnabled']) warning.is_enabled = bool(item['isEnabled'])
if 'yellowThreshold' in item: # 安全转换阈值None 默认转为 0
warning.yellow_threshold = item['yellowThreshold'] yellow_val = item.get('yellowThreshold')
if 'redThreshold' in item: red_val = item.get('redThreshold')
warning.red_threshold = item['redThreshold'] warning.yellow_threshold = float(yellow_val) if yellow_val is not None else 0
warning.red_threshold = float(red_val) if red_val is not None else 0
updated_count += 1 updated_count += 1
else: else:
# 创建新记录 # 创建新记录
yellow_val = item.get('yellowThreshold')
red_val = item.get('redThreshold')
warning = MaterialWarningSetting( warning = MaterialWarningSetting(
base_id=base_id, base_id=base_id,
is_enabled=item.get('isEnabled', False), is_enabled=item.get('isEnabled', False),
yellow_threshold=item.get('yellowThreshold'), yellow_threshold=float(yellow_val) if yellow_val is not None else 0,
red_threshold=item.get('redThreshold') red_threshold=float(red_val) if red_val is not None else 0
) )
db.session.add(warning) db.session.add(warning)
created_count += 1 created_count += 1

View File

@ -1,12 +1,12 @@
# 文件路径: app/services/inbound/base_service.py # 文件路径: app/services/inbound/base_service.py
from app.extensions import db from app.extensions import db
from app.models.base import MaterialBase from app.models.base import MaterialBase, MaterialWarningSetting
from app.models.inbound.buy import StockBuy from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct from app.models.inbound.product import StockProduct
# from app.models.inbound.service import StockService # from app.models.inbound.service import StockService
from sqlalchemy import or_, and_, func from sqlalchemy import or_, and_, func, case, desc, asc, cast, Numeric, text
import traceback import traceback
import json import json
import io import io
@ -114,6 +114,10 @@ class MaterialBaseService:
""" """
获取基础信息列表 (带分页、高级筛选和全字段排序) 获取基础信息列表 (带分页、高级筛选和全字段排序)
支持库存预警功能(如果用户有 view_warning 权限) 支持库存预警功能(如果用户有 view_warning 权限)
修复说明:
1. 使用子查询方案确保聚合列(total_inv)能正确参与预警排序计算
2. 高级筛选中针对聚合字段使用 HAVING 而非 WHERE
""" """
try: try:
# 检查用户是否有查看预警的权限 # 检查用户是否有查看预警的权限
@ -144,7 +148,7 @@ class MaterialBaseService:
func.sum(StockProduct.available_quantity).label('prod_avail') func.sum(StockProduct.available_quantity).label('prod_avail')
).group_by(StockProduct.base_id).subquery() ).group_by(StockProduct.base_id).subquery()
# 总库存和可用数的 SQL 表达式 # 总库存和可用数的 SQL 表达式(用于后续计算)
total_inv = func.coalesce(buy_sub.c.buy_inv, 0) + \ total_inv = func.coalesce(buy_sub.c.buy_inv, 0) + \
func.coalesce(semi_sub.c.semi_inv, 0) + \ func.coalesce(semi_sub.c.semi_inv, 0) + \
func.coalesce(prod_sub.c.prod_inv, 0) func.coalesce(prod_sub.c.prod_inv, 0)
@ -155,218 +159,224 @@ class MaterialBaseService:
# 导入预警设置模型 # 导入预警设置模型
from app.models.base import MaterialWarningSetting from app.models.base import MaterialWarningSetting
# 主查询,关联聚合子查询和预警设置 # ============================================================
query = db.session.query( # 【核心修复】使用子查询方案:将聚合结果作为子查询
# 这样 total_inv 在外层查询中是一个普通列,可安全参与计算
# ============================================================
# 内层子查询:基础数据 + 聚合库存
inner_sub = db.session.query(
MaterialBase.id.label('base_id'),
MaterialBase, MaterialBase,
total_inv.label('total_inv'), total_inv.label('total_inv'),
total_avail.label('total_avail'), total_avail.label('total_avail')
MaterialWarningSetting.is_enabled.label('warning_enabled'),
MaterialWarningSetting.yellow_threshold.label('warning_yellow'),
MaterialWarningSetting.red_threshold.label('warning_red')
).outerjoin(buy_sub, MaterialBase.id == buy_sub.c.base_id) \ ).outerjoin(buy_sub, MaterialBase.id == buy_sub.c.base_id) \
.outerjoin(semi_sub, MaterialBase.id == semi_sub.c.base_id) \ .outerjoin(semi_sub, MaterialBase.id == semi_sub.c.base_id) \
.outerjoin(prod_sub, MaterialBase.id == prod_sub.c.base_id) \ .outerjoin(prod_sub, MaterialBase.id == prod_sub.c.base_id)
.outerjoin(MaterialWarningSetting, MaterialBase.id == MaterialWarningSetting.base_id)
# 关键词精确搜索(需要在子查询层完成)
if filters: if filters:
# 1. 关键词精准搜索(支持指定字段)
search_field = filters.get('searchField', 'all') search_field = filters.get('searchField', 'all')
keyword = filters.get('keyword') keyword = filters.get('keyword')
if keyword: if keyword:
kw = f"%{keyword}%" kw = f"%{keyword}%"
if search_field == 'name': if search_field == 'name':
query = query.filter(MaterialBase.name.ilike(kw)) inner_sub = inner_sub.filter(MaterialBase.name.ilike(kw))
elif search_field == 'common_name': elif search_field == 'common_name':
query = query.filter(MaterialBase.common_name.ilike(kw)) inner_sub = inner_sub.filter(MaterialBase.common_name.ilike(kw))
elif search_field == 'spec': elif search_field == 'spec':
query = query.filter(MaterialBase.spec_model.ilike(kw)) inner_sub = inner_sub.filter(MaterialBase.spec_model.ilike(kw))
else: # 'all' 默认全局模糊匹配 else:
query = query.filter(or_( inner_sub = inner_sub.filter(or_(
MaterialBase.name.ilike(kw), MaterialBase.name.ilike(kw),
MaterialBase.common_name.ilike(kw), MaterialBase.common_name.ilike(kw),
MaterialBase.spec_model.ilike(kw) MaterialBase.spec_model.ilike(kw)
)) ))
# 2. 精确筛选
company = filters.get('company') company = filters.get('company')
if company is not None and company != '': if company is not None and company != '':
query = query.filter(MaterialBase.company_name.ilike(company.strip())) inner_sub = inner_sub.filter(MaterialBase.company_name.ilike(company.strip()))
category = filters.get('category') category = filters.get('category')
if category is not None and category != '': if category is not None and category != '':
query = query.filter(MaterialBase.category.ilike(category.strip())) inner_sub = inner_sub.filter(MaterialBase.category.ilike(category.strip()))
type_val = filters.get('type') type_val = filters.get('type')
if type_val is not None and type_val != '': if type_val is not None and type_val != '':
query = query.filter(MaterialBase.material_type.ilike(type_val.strip())) inner_sub = inner_sub.filter(MaterialBase.material_type.ilike(type_val.strip()))
# 【核心修改】:增强布尔值解析
if filters.get('isEnabled') is not None: if filters.get('isEnabled') is not None:
val_str = str(filters['isEnabled']).lower() val_str = str(filters['isEnabled']).lower()
is_active = val_str in ['1', 'true', 'yes', 't'] is_active = val_str in ['1', 'true', 'yes', 't']
# 必须使用 filter() 而非 filter_by(),因为 query 是 join 后的复杂查询 inner_sub = inner_sub.filter(MaterialBase.is_enabled == is_active)
query = query.filter(MaterialBase.is_enabled == is_active)
# 【新增】:库存状态筛选 (has_stock) # 库存状态筛选 (has_stock) - 使用子查询列
has_stock = filters.get('has_stock') has_stock = filters.get('has_stock')
if has_stock and str(has_stock).lower() in ['true', '1', 'yes']: if has_stock and str(has_stock).lower() in ['true', '1', 'yes']:
query = query.filter(total_inv > 0) inner_sub = inner_sub.filter(total_inv > 0)
# 3. 高级动态筛选 # 将内层子查询具体化
advanced_filters = filters.get('advancedFilters', []) inner_sub = inner_sub.subquery()
if advanced_filters:
allowed_fields = { # 外层查询:关联预警设置,并在外层处理排序和高级筛选
'companyName': 'company_name', query = db.session.query(
'name': 'name', MaterialBase,
'commonName': 'common_name', inner_sub.c.total_inv,
'category': 'category', inner_sub.c.total_avail,
'type': 'material_type', MaterialWarningSetting.is_enabled.label('warning_enabled'),
'spec': 'spec_model', MaterialWarningSetting.yellow_threshold.label('warning_yellow'),
'unit': 'unit', MaterialWarningSetting.red_threshold.label('warning_red')
'inventoryCount': total_inv, ).outerjoin(inner_sub, MaterialBase.id == inner_sub.c.base_id) \
'availableCount': total_avail .outerjoin(MaterialWarningSetting, MaterialBase.id == MaterialWarningSetting.base_id)
}
# 字段到权限码的映射 # ============================================================
field_permission_map = { # 【修复3】高级筛选聚合字段使用 HAVING非聚合字段使用 WHERE
'companyName': 'material_list:companyName', # ============================================================
'name': 'material_list:name', advanced_filters = filters.get('advancedFilters', []) if filters else []
'commonName': 'material_list:commonName', having_conditions = []
'category': 'material_list:category', filter_conditions = []
'type': 'material_list:type',
'spec': 'material_list:spec', if advanced_filters:
'unit': 'material_list:unit', allowed_fields = {
'inventoryCount': 'material_list:inventoryCount', 'companyName': 'company_name',
'availableCount': 'material_list:availableCount' 'name': 'name',
} 'commonName': 'common_name',
filter_conditions = [] 'category': 'category',
for condition in advanced_filters: 'type': 'material_type',
field = condition.get('field') 'spec': 'spec_model',
operator = condition.get('operator') 'unit': 'unit',
value = condition.get('value') 'inventoryCount': 'total_inv',
if not field or not operator or value is None: 'availableCount': 'total_avail'
}
# 聚合字段列表(这些需要用 HAVING
aggregate_fields = {'inventoryCount', 'availableCount'}
field_permission_map = {
'companyName': 'material_list:companyName',
'name': 'material_list:name',
'commonName': 'material_list:commonName',
'category': 'material_list:category',
'type': 'material_list:type',
'spec': 'material_list:spec',
'unit': 'material_list:unit',
'inventoryCount': 'material_list:inventoryCount',
'availableCount': 'material_list:availableCount'
}
for condition in advanced_filters:
field = condition.get('field')
operator = condition.get('operator')
value = condition.get('value')
if not field or not operator or value is None:
continue
db_field = allowed_fields.get(field)
if not db_field:
continue
# 权限校验
if user_permissions is not None:
perm_code = field_permission_map.get(field)
if 'material_list:*' in user_permissions:
pass
elif perm_code and perm_code not in user_permissions:
continue continue
db_field = allowed_fields.get(field)
if not db_field: # 判断是否为聚合字段
is_aggregate = field in aggregate_fields
if is_aggregate:
# 聚合字段:使用 HAVING - 通过子查询列进行比较
col = inner_sub.c.total_inv if field == 'inventoryCount' else inner_sub.c.total_avail
try:
num_val = float(value)
except ValueError:
continue continue
# 权限校验
if user_permissions is not None: if operator == 'eq':
perm_code = field_permission_map.get(field) having_conditions.append(col == num_val)
if 'material_list:*' in user_permissions: elif operator == 'ne':
# 超级管理员拥有全部权限 having_conditions.append(col != num_val)
pass elif operator == 'ge':
elif perm_code and perm_code not in user_permissions: having_conditions.append(col >= num_val)
# 无权限,跳过该条件 elif operator == 'le':
continue having_conditions.append(col <= num_val)
# 对于聚合字段 (inventoryCount, availableCount),需要使用子查询别名 else:
if isinstance(db_field, type(total_inv)): # 非聚合字段:使用 WHERE
column = db_field column = getattr(MaterialBase, db_field, None)
else:
column = getattr(MaterialBase, db_field, None)
if column is None: if column is None:
continue continue
# 处理操作符
# 对于数值型列(聚合字段)只支持 eq, ne, ge, le if operator == 'eq':
if isinstance(column, type(total_inv)): filter_conditions.append(column == value)
# 数值型列 elif operator == 'ne':
filter_conditions.append(column != value)
elif operator == 'contains':
filter_conditions.append(column.ilike(f'%{value}%'))
elif operator == 'ge':
try: try:
num_val = float(value) num_val = float(value)
except ValueError:
# 转换失败则跳过该条件
continue
if operator == 'eq':
filter_conditions.append(column == num_val)
elif operator == 'ne':
filter_conditions.append(column != num_val)
elif operator == 'ge':
filter_conditions.append(column >= num_val) filter_conditions.append(column >= num_val)
elif operator == 'le': except ValueError:
continue
elif operator == 'le':
try:
num_val = float(value)
filter_conditions.append(column <= num_val) filter_conditions.append(column <= num_val)
# 对于 contains 操作符,数值型列不支持,忽略 except ValueError:
else: continue
# 字符串型列
if operator == 'eq': # 应用 WHERE 条件
filter_conditions.append(column == value) if filter_conditions:
elif operator == 'ne': query = query.filter(and_(*filter_conditions))
filter_conditions.append(column != value)
elif operator == 'contains': # 应用 HAVING 条件(聚合字段筛选)
filter_conditions.append(column.ilike(f'%{value}%')) if having_conditions:
elif operator == 'ge': query = query.having(and_(*having_conditions))
try:
num_val = float(value)
filter_conditions.append(column >= num_val)
except ValueError:
continue
elif operator == 'le':
try:
num_val = float(value)
filter_conditions.append(column <= num_val)
except ValueError:
continue
if filter_conditions:
query = query.filter(and_(*filter_conditions))
# 排序处理(支持全字段) # 排序处理(支持全字段)
order_by_column = filters.get('orderByColumn', '') order_by_column = filters.get('orderByColumn', '')
is_asc = filters.get('isAsc', None) is_asc = filters.get('isAsc', None)
# 检查是否启用了预警智能排序 # 信任前端传递的预警排序开关(前端已基于权限注入)
enable_warning_sort = has_warning_permission and filters.get('enableWarningSort', False) enable_warning_sort = filters.get('enableWarningSort', False)
if enable_warning_sort: if enable_warning_sort:
# 预警智能排序:先按预警状态排序,再按缺口/余量排序 print("====== [DEBUG] 成功进入预警强排逻辑 (SQLA 2.0) ======")
# 使用 CASE 表达式计算预警状态 # 【核心修复】使用子查询列进行预警排序计算
# 状态: 2=红(库存<=red), 1=黄(red<库存<=yellow), 0=正常/未开启 # total_inv 现在是 inner_sub.c.total_inv可以安全参与 case 计算
from sqlalchemy import case inv_val = inner_sub.c.total_inv
red_val = cast(MaterialWarningSetting.red_threshold, Numeric)
yellow_val = cast(MaterialWarningSetting.yellow_threshold, Numeric)
# 计算预警状态 # 预警等级计算:红=2, 黄=1, 正常=0
warning_status = case( warning_level = case(
( (and_(MaterialWarningSetting.is_enabled.is_(True), inv_val <= red_val), 2),
(MaterialWarningSetting.is_enabled == True) & (and_(MaterialWarningSetting.is_enabled.is_(True), inv_val <= yellow_val), 1),
(total_inv <= MaterialWarningSetting.red_threshold),
2 # 红色预警
),
(
(MaterialWarningSetting.is_enabled == True) &
(total_inv > MaterialWarningSetting.red_threshold) &
(total_inv <= MaterialWarningSetting.yellow_threshold),
1 # 黄色预警
),
else_=0 # 正常或未开启
).label('warning_status')
# 红色组内部:按缺口降序 (red_threshold - inventory)
red_gap = case(
(
(MaterialWarningSetting.is_enabled == True) &
(total_inv <= MaterialWarningSetting.red_threshold),
MaterialWarningSetting.red_threshold - total_inv
),
else_=0 else_=0
).label('red_gap') ).label('sort_level')
# 黄色组内部:按余量升序 (inventory - red_threshold),离红线越近越靠前 # 红色预警时的缺口(库存与红色阈值的差距)
yellow_gap = case( red_shortage = case(
( (and_(MaterialWarningSetting.is_enabled.is_(True), inv_val <= red_val), red_val - inv_val),
(MaterialWarningSetting.is_enabled == True) & else_=0
(total_inv > MaterialWarningSetting.red_threshold) & ).label('sort_red')
(total_inv <= MaterialWarningSetting.yellow_threshold),
total_inv - MaterialWarningSetting.red_threshold # 黄色预警时的缺口
), yellow_distance = case(
else_=999999 # 正常物料放在最后 (and_(MaterialWarningSetting.is_enabled.is_(True), inv_val > red_val, inv_val <= yellow_val), inv_val - red_val),
).label('yellow_gap') else_=999999
).label('sort_yellow')
# 添加排序字段到查询
query = query.add_columns(warning_status, red_gap, yellow_gap) query = query.add_columns(warning_level, red_shortage, yellow_distance)
query = query.order_by(None).order_by(
# 强制排序规则:预警状态降序 -> 红色缺口降序 -> 黄色余量升序 -> 规格型号升序 text("sort_level DESC"),
query = query.order_by( text("sort_red DESC"),
warning_status.desc(), text("sort_yellow ASC"),
red_gap.desc(), MaterialBase.id.desc()
yellow_gap.asc(),
MaterialBase.spec_model.asc()
) )
elif order_by_column: elif order_by_column:
# 字段映射 # 字段映射 - 使用子查询列进行排序
sort_field_map = { sort_field_map = {
'companyName': MaterialBase.company_name, 'companyName': MaterialBase.company_name,
'name': MaterialBase.name, 'name': MaterialBase.name,
@ -375,8 +385,8 @@ class MaterialBaseService:
'type': MaterialBase.material_type, 'type': MaterialBase.material_type,
'spec': MaterialBase.spec_model, 'spec': MaterialBase.spec_model,
'unit': MaterialBase.unit, 'unit': MaterialBase.unit,
'inventoryCount': total_inv, 'inventoryCount': inner_sub.c.total_inv,
'availableCount': total_avail 'availableCount': inner_sub.c.total_avail
} }
sort_column = sort_field_map.get(order_by_column) sort_column = sort_field_map.get(order_by_column)
if sort_column is not None: if sort_column is not None:
@ -386,7 +396,7 @@ class MaterialBaseService:
query = query.order_by(sort_column.desc()) query = query.order_by(sort_column.desc())
else: else:
# 默认排序:优先按总库存数降序,当库存相同时,再按规格型号升序 # 默认排序:优先按总库存数降序,当库存相同时,再按规格型号升序
query = query.order_by(total_inv.desc(), MaterialBase.spec_model.asc()) query = query.order_by(inner_sub.c.total_inv.desc(), MaterialBase.spec_model.asc())
# 分页 # 分页
pagination = query.paginate(page=page, per_page=limit, error_out=False) pagination = query.paginate(page=page, per_page=limit, error_out=False)

View File

@ -580,6 +580,7 @@ interface QueryParams {
isAsc: string | undefined; isAsc: string | undefined;
advancedFilters?: any[]; advancedFilters?: any[];
has_stock?: string; has_stock?: string;
enableWarningSort?: boolean;
} }
interface CascaderOption { interface CascaderOption {
@ -880,6 +881,8 @@ const querySearchType = (queryString: string, cb: any) => {
const getList = () => { const getList = () => {
loading.value = true; loading.value = true;
// 仅当用户没有进行手动表头排序时才开启预警排序
queryParams.enableWarningSort = userStore.hasPermission('material_list:view_warning') && !queryParams.orderByColumn;
// Stringify advancedFilters to JSON string as backend expects // Stringify advancedFilters to JSON string as backend expects
const params = { const params = {
...queryParams, ...queryParams,
@ -1204,13 +1207,23 @@ const submitWarning = async () => {
await warningFormRef.value.validate(); await warningFormRef.value.validate();
// 安全转换数值null/undefined 默认转为 0
const yellow = Number(warningForm.yellowThreshold) || 0;
const red = Number(warningForm.redThreshold) || 0;
// 逻辑校验:启用预警时,黄色阈值必须大于红色阈值
if (warningForm.isEnabled && yellow !== 0 && red !== 0 && yellow <= red) {
ElMessage.warning('黄色阈值必须大于红色阈值');
return;
}
warningLoading.value = true; warningLoading.value = true;
try { try {
const data = warningDialog.selectedIds.map(baseId => ({ const data = warningDialog.selectedIds.map(baseId => ({
baseId, baseId,
isEnabled: warningForm.isEnabled, isEnabled: warningForm.isEnabled,
redThreshold: warningForm.redThreshold, redThreshold: red,
yellowThreshold: warningForm.yellowThreshold yellowThreshold: yellow
})); }));
await batchSetWarning(data); await batchSetWarning(data);

View File

@ -376,7 +376,15 @@
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="序列号" prop="serial_number"> <el-form-item prop="serial_number">
<template #label>
<div class="flex items-center justify-between w-full">
<span>序列号</span>
<el-link type="primary" :underline="false" @click="openScanner" title="开启摄像头智能扫码" :disabled="entryMode === 'batch'">
<el-icon><Camera /></el-icon> 智能扫码
</el-link>
</div>
</template>
<el-input v-model="form.serial_number" :placeholder="entryMode === 'serial' ? '请扫描 SN...' : '不可用'" :disabled="entryMode === 'batch'" clearable> <el-input v-model="form.serial_number" :placeholder="entryMode === 'serial' ? '请扫描 SN...' : '不可用'" :disabled="entryMode === 'batch'" clearable>
<template #prefix><span class="prefix-tag sn">SN</span></template> <template #prefix><span class="prefix-tag sn">SN</span></template>
</el-input> </el-input>
@ -646,6 +654,9 @@
<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> <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> </template>
</el-dialog> </el-dialog>
<!-- 智能扫码弹窗 -->
<SmartScannerDialog v-model="scannerDialogVisible" @confirm="handleScannerConfirm" />
</div> </div>
</template> </template>
@ -672,6 +683,7 @@ import {getLabelPreview, executePrint} from '@/api/common/print'
import { getWarehouseTree } from '@/api/common/warehouse' import { getWarehouseTree } from '@/api/common/warehouse'
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue' import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'
import WarehouseSelector from '@/components/WarehouseSelector.vue' import WarehouseSelector from '@/components/WarehouseSelector.vue'
import SmartScannerDialog from '@/components/SmartScannerDialog.vue'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
// ------------------------------------ // ------------------------------------
@ -813,6 +825,9 @@ const cameraRef = ref<InstanceType<typeof WebRtcCamera> | null>(null)
const currentCameraField = ref<'arrival_photo' | 'inspection_report'>('arrival_photo') const currentCameraField = ref<'arrival_photo' | 'inspection_report'>('arrival_photo')
const inspection_report_url = ref('') const inspection_report_url = ref('')
// 智能扫码弹窗
const scannerDialogVisible = ref(false)
// 库位级联选择器数据 // 库位级联选择器数据
const warehouseOptions = ref<any[]>([]) const warehouseOptions = ref<any[]>([])
@ -1488,6 +1503,17 @@ const handleCameraConfirm = async (file: File) => {
} }
} }
// 智能扫码
const openScanner = () => {
scannerDialogVisible.value = true
}
const handleScannerConfirm = (result: string) => {
form.serial_number = result
scannerDialogVisible.value = false
ElMessage.success('序列号已提取')
}
const addCondition = () => { const addCondition = () => {
advancedConditions.value.push({ field: '', operator: '', value: '' }) advancedConditions.value.push({ field: '', operator: '', value: '' })
} }

View File

@ -320,7 +320,15 @@
<div class="identity-panel"> <div class="identity-panel">
<el-row :gutter="24"> <el-row :gutter="24">
<el-col :span="12"> <el-col :span="12">
<el-form-item label="序列号(SN)" prop="serial_number"> <el-form-item prop="serial_number">
<template #label>
<div class="flex items-center justify-between w-full">
<span>序列号(SN)</span>
<el-link type="primary" :underline="false" @click="openScanner" title="开启摄像头智能扫码">
<el-icon><Camera /></el-icon> 智能扫码
</el-link>
</div>
</template>
<el-input v-model="form.serial_number" placeholder="必填: 唯一序列号" clearable><template #prefix><span class="prefix-tag sn">SN</span></template></el-input> <el-input v-model="form.serial_number" placeholder="必填: 唯一序列号" clearable><template #prefix><span class="prefix-tag sn">SN</span></template></el-input>
</el-form-item> </el-form-item>
</el-col> </el-col>
@ -518,6 +526,8 @@
</template> </template>
</el-dialog> </el-dialog>
<!-- 智能扫码弹窗 -->
<SmartScannerDialog v-model="scannerDialogVisible" @confirm="handleScannerConfirm" />
</div> </div>
</template> </template>
@ -540,6 +550,7 @@ import {
import { uploadFile, deleteFile } from '@/api/inbound/buy' import { uploadFile, deleteFile } from '@/api/inbound/buy'
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue' import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'
import WarehouseSelector from '@/components/WarehouseSelector.vue' import WarehouseSelector from '@/components/WarehouseSelector.vue'
import SmartScannerDialog from '@/components/SmartScannerDialog.vue'
import { getLabelPreview, executePrint } from '@/api/common/print' import { getLabelPreview, executePrint } from '@/api/common/print'
import { getWarehouseTree } from '@/api/common/warehouse' import { getWarehouseTree } from '@/api/common/warehouse'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
@ -668,6 +679,9 @@ const currentCameraField = ref<'product_photo' | 'quality_report_link' | 'inspec
const quality_url = ref('') const quality_url = ref('')
const inspection_url = ref('') const inspection_url = ref('')
// 智能扫码弹窗
const scannerDialogVisible = ref(false)
// 库位级联选择器数据 // 库位级联选择器数据
const warehouseOptions = ref<any[]>([]) const warehouseOptions = ref<any[]>([])
@ -1187,6 +1201,17 @@ const handleCameraConfirm = async (file: File) => {
} }
}; };
// 智能扫码
const openScanner = () => {
scannerDialogVisible.value = true
}
const handleScannerConfirm = (result: string) => {
form.serial_number = result
scannerDialogVisible.value = false
ElMessage.success('序列号已提取')
}
const submitForm = async () => { const submitForm = async () => {
await formRef.value.validate(async (valid: boolean) => { await formRef.value.validate(async (valid: boolean) => {
if(valid) { if(valid) {

View File

@ -395,7 +395,15 @@
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="序列号" prop="serial_number"> <el-form-item prop="serial_number">
<template #label>
<div class="flex items-center justify-between w-full">
<span>序列号</span>
<el-link type="primary" :underline="false" @click="openScanner" title="开启摄像头智能扫码" :disabled="entryMode === 'batch'">
<el-icon><Camera /></el-icon> 智能扫码
</el-link>
</div>
</template>
<el-input v-model="form.serial_number" :placeholder="entryMode === 'serial' ? '请扫描 SN...' : '不可用'" :disabled="entryMode === 'batch'" clearable> <el-input v-model="form.serial_number" :placeholder="entryMode === 'serial' ? '请扫描 SN...' : '不可用'" :disabled="entryMode === 'batch'" clearable>
<template #prefix><span class="prefix-tag sn">SN</span></template> <template #prefix><span class="prefix-tag sn">SN</span></template>
</el-input> </el-input>
@ -582,6 +590,9 @@
<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> <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> </template>
</el-dialog> </el-dialog>
<!-- 智能扫码弹窗 -->
<SmartScannerDialog v-model="scannerDialogVisible" @confirm="handleScannerConfirm" />
</div> </div>
</template> </template>
@ -604,6 +615,7 @@ import {
import { uploadFile, deleteFile } from '@/api/inbound/buy' import { uploadFile, deleteFile } from '@/api/inbound/buy'
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue' import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'
import WarehouseSelector from '@/components/WarehouseSelector.vue' import WarehouseSelector from '@/components/WarehouseSelector.vue'
import SmartScannerDialog from '@/components/SmartScannerDialog.vue'
import {getLabelPreview, executePrint} from '@/api/common/print' import {getLabelPreview, executePrint} from '@/api/common/print'
import { getWarehouseTree } from '@/api/common/warehouse' import { getWarehouseTree } from '@/api/common/warehouse'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
@ -731,6 +743,9 @@ const cameraRef = ref<InstanceType<typeof WebRtcCamera> | null>(null)
const currentCameraField = ref<'arrival_photo' | 'quality_report_link'>('arrival_photo') const currentCameraField = ref<'arrival_photo' | 'quality_report_link'>('arrival_photo')
const quality_report_url = ref('') const quality_report_url = ref('')
// 智能扫码弹窗
const scannerDialogVisible = ref(false)
// 库位级联选择器数据 // 库位级联选择器数据
const warehouseOptions = ref<any[]>([]) const warehouseOptions = ref<any[]>([])
@ -1309,6 +1324,17 @@ const handleCameraConfirm = async (file: File) => {
} }
}; };
// 智能扫码
const openScanner = () => {
scannerDialogVisible.value = true
}
const handleScannerConfirm = (result: string) => {
form.serial_number = result
scannerDialogVisible.value = false
ElMessage.success('序列号已提取')
}
const submitForm = async () => { const submitForm = async () => {
if (!formRef.value) return if (!formRef.value) return
await formRef.value.validate(async (valid: boolean) => { await formRef.value.validate(async (valid: boolean) => {