修改semi,product,service的搜索逻辑

This commit is contained in:
dxc
2026-02-11 15:12:20 +08:00
parent 5513e4cd81
commit e900326571
4 changed files with 154 additions and 143 deletions

View File

@ -1,7 +1,7 @@
# inventory-backend/app/api/v1/inbound/service.py
from flask import request, jsonify, current_app from flask import request, jsonify, current_app
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required
from . import inbound_bp from . import inbound_bp
from app.schemas.stock_schema import stock_service_schema
from app.services.inbound.service_service import ServiceService from app.services.inbound.service_service import ServiceService
from app.utils.decorators import role_required from app.utils.decorators import role_required
import traceback import traceback
@ -23,6 +23,7 @@ def search_base():
current_app.logger.error(f'搜索基础物料失败: {str(e)}') current_app.logger.error(f'搜索基础物料失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500 return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
@inbound_bp.route('/service', methods=['GET']) @inbound_bp.route('/service', methods=['GET'])
@jwt_required() @jwt_required()
def get_service_list(): def get_service_list():
@ -62,20 +63,25 @@ def create_service():
data = request.get_json() data = request.get_json()
if not data: if not data:
return jsonify({'code': 400, 'msg': '请求数据为空'}), 400 return jsonify({'code': 400, 'msg': '请求数据为空'}), 400
errors = stock_service_schema.validate(data)
if errors: # 基础校验
return jsonify({'code': 400, 'msg': '数据校验失败', 'errors': errors}), 400 if not data.get('base_id'):
return jsonify({'code': 400, 'msg': '请选择基础物料'}), 400
if data.get('sale_price') is None:
return jsonify({'code': 400, 'msg': '请输入售价'}), 400
try: try:
service = ServiceService.create_service(data) service = ServiceService.create_service(data)
return jsonify({ return jsonify({
'code': 201, 'code': 201,
'msg': '创建成功', 'msg': '创建成功',
'data': stock_service_schema.dump(service) 'data': service.to_dict()
}), 201 }), 201
except ValueError as e: except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400 return jsonify({'code': 400, 'msg': str(e)}), 400
except Exception as e: except Exception as e:
current_app.logger.error(f'创建服务权益失败: {str(e)}') current_app.logger.error(f'创建服务权益失败: {str(e)}')
traceback.print_exc()
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500 return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
@ -88,7 +94,7 @@ def get_service(service_id):
return jsonify({ return jsonify({
'code': 200, 'code': 200,
'msg': 'success', 'msg': 'success',
'data': stock_service_schema.dump(service) 'data': service.to_dict()
}) })
except ValueError as e: except ValueError as e:
return jsonify({'code': 404, 'msg': str(e)}), 404 return jsonify({'code': 404, 'msg': str(e)}), 404
@ -105,17 +111,23 @@ def update_service(service_id):
data = request.get_json() data = request.get_json()
if not data: if not data:
return jsonify({'code': 400, 'msg': '请求数据为空'}), 400 return jsonify({'code': 400, 'msg': '请求数据为空'}), 400
# 部分字段不允许更新,可在此过滤
allowed_fields = {'sale_price', 'provider_name', 'description'} # 允许更新的字段
allowed_fields = {
'sale_price', 'provider_name', 'description',
'cost_price', 'contract_id', 'contact_person', 'valid_period'
}
filtered_data = {k: v for k, v in data.items() if k in allowed_fields} filtered_data = {k: v for k, v in data.items() if k in allowed_fields}
if not filtered_data: if not filtered_data:
return jsonify({'code': 400, 'msg': '无有效更新字段'}), 400 return jsonify({'code': 400, 'msg': '无有效更新字段'}), 400
try: try:
service = ServiceService.update_service(service_id, filtered_data) service = ServiceService.update_service(service_id, filtered_data)
return jsonify({ return jsonify({
'code': 200, 'code': 200,
'msg': '更新成功', 'msg': '更新成功',
'data': stock_service_schema.dump(service) 'data': service.to_dict()
}) })
except ValueError as e: except ValueError as e:
return jsonify({'code': 404, 'msg': str(e)}), 404 return jsonify({'code': 404, 'msg': str(e)}), 404
@ -142,9 +154,6 @@ def delete_service(service_id):
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500 return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
# ------------------------------------------------------------------
# 供应商建议
# ------------------------------------------------------------------
@inbound_bp.route('/service/suggestions/providers', methods=['GET']) @inbound_bp.route('/service/suggestions/providers', methods=['GET'])
@jwt_required() @jwt_required()
def get_provider_suggestions(): def get_provider_suggestions():
@ -155,9 +164,6 @@ def get_provider_suggestions():
return jsonify({'code': 200, 'msg': 'success', 'data': data}) return jsonify({'code': 200, 'msg': 'success', 'data': data})
# ------------------------------------------------------------------
# 系统用户建议
# ------------------------------------------------------------------
@inbound_bp.route('/service/suggestions/users', methods=['GET']) @inbound_bp.route('/service/suggestions/users', methods=['GET'])
@jwt_required() @jwt_required()
def get_user_suggestions(): def get_user_suggestions():
@ -166,9 +172,6 @@ def get_user_suggestions():
return jsonify({'code': 200, 'msg': 'success', 'data': data}) return jsonify({'code': 200, 'msg': 'success', 'data': data})
# ------------------------------------------------------------------
# 获取筛选选项
# ------------------------------------------------------------------
@inbound_bp.route('/service/options', methods=['GET']) @inbound_bp.route('/service/options', methods=['GET'])
@jwt_required() @jwt_required()
def get_options(): def get_options():
@ -176,4 +179,4 @@ def get_options():
data = ServiceService.get_filter_options() data = ServiceService.get_filter_options()
return jsonify({'code': 200, 'msg': 'success', 'data': data}) return jsonify({'code': 200, 'msg': 'success', 'data': data})
except Exception as e: except Exception as e:
return jsonify({'code': 500, 'msg': str(e)}), 500 return jsonify({'code': 500, 'msg': str(e)}), 500

View File

@ -7,64 +7,67 @@ class StockService(db.Model):
""" """
服务权益库存表 服务权益库存表
对应数据库表: stock_service 对应数据库表: stock_service
说明:服务权益通常为虚拟资产,不进行具体的库存数量(actual_quantity)管理
""" """
__tablename__ = 'stock_service' __tablename__ = 'stock_service'
id = db.Column(db.Integer, primary_key=True, autoincrement=True) id = db.Column(db.Integer, primary_key=True, autoincrement=True)
# 关联基础物料信息 # 外键关联基础物料
# 注意:这里使用了 db.ForeignKey 指向 material_base 表的 id
base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False) base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False)
# 系统生成的SKU格式 SRV-YYYYMMDD-XXXX # 核心业务字段
sku = db.Column(db.String(64), unique=True, nullable=False) sku = db.Column(db.String(100), unique=True, nullable=False)
# 售价 # 扩展字段 (对应您的数据库建表脚本)
sale_price = db.Column(db.Numeric(10, 2), nullable=False) service_category = db.Column(db.String(100), comment='服务类别')
# 服务商名称
provider_name = db.Column(db.String(255), nullable=False, default='') provider_name = db.Column(db.String(255), nullable=False, default='')
contract_id = db.Column(db.String(100), comment='合同号')
contact_person = db.Column(db.String(100), comment='联系人')
# 服务详情/简介 # 价格相关
cost_price = db.Column(db.Numeric(19, 4), default=0)
sale_price = db.Column(db.Numeric(19, 4), nullable=False, default=0)
# 描述与状态
description = db.Column(db.Text, default='') description = db.Column(db.Text, default='')
valid_period = db.Column(db.String(100), comment='有效期')
status = db.Column(db.String(20), default='active')
# ========================================================================== # 时间与系统字段
# 【新增】库存数量字段
# 上一轮的 Service 代码中尝试累加这两个字段,如果模型里没有,程序会报错
# ==========================================================================
actual_quantity = db.Column(db.Integer, default=0, nullable=False, comment='库存数量')
available_quantity = db.Column(db.Integer, default=0, nullable=False, comment='可用数量')
# 创建时间与更新时间
created_at = db.Column(db.DateTime, default=datetime.now, nullable=False) created_at = db.Column(db.DateTime, default=datetime.now, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
is_deleted = db.Column(db.Boolean, default=False)
# 软删除标志
is_deleted = db.Column(db.Boolean, default=False, nullable=False)
# ========================================================================== # ==========================================================================
# 【修复】关系映射 # 关联关系设置
# 1. 属性名必须叫 'base',因为 MaterialBase 定义了 back_populates='base' # MaterialBase 定义了 back_populates='stock_services'
# 2. back_populates 指向 MaterialBase 里的属性名 'stock_services' # 因此这里必须定义 base 属性指向 'stock_services'
# ========================================================================== # ==========================================================================
base = db.relationship('MaterialBase', back_populates='stock_services', lazy='joined') base = db.relationship('MaterialBase', back_populates='stock_services')
def to_dict(self): def to_dict(self):
"""转为字典,用于 API 响应""" """序列化为字典"""
return { return {
'id': self.id, 'id': self.id,
'base_id': self.base_id, 'base_id': self.base_id,
'sku': self.sku, 'sku': self.sku,
'sale_price': float(self.sale_price) if self.sale_price is not None else 0, 'service_category': self.service_category,
'provider_name': self.provider_name, 'provider_name': self.provider_name,
'contract_id': self.contract_id,
'contact_person': self.contact_person,
'sale_price': float(self.sale_price) if self.sale_price is not None else 0,
'cost_price': float(self.cost_price) if self.cost_price is not None else 0,
'description': self.description, 'description': self.description,
'actual_quantity': self.actual_quantity, # 返回库存数 'valid_period': self.valid_period,
'available_quantity': self.available_quantity, # 返回可用数 'status': self.status,
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None, 'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None,
'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S') if self.updated_at else None, 'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S') if self.updated_at else None,
# 注意:这里通过 self.base 访问关联对象,而不是 self.material_base # 关联的基础信息 (Flattened)
'material_name': self.base.name if self.base else None, 'material_name': self.base.name if self.base else None,
'spec_model': self.base.spec_model if self.base else None, 'spec_model': self.base.spec_model if self.base else None,
'unit': self.base.unit if self.base else None, 'unit': self.base.unit if self.base else None,
'category': self.base.category if self.base else None,
'material_type': self.base.material_type if self.base else None,
} }

View File

@ -1,5 +1,5 @@
# app/services/inbound/service_service.py # inventory-backend/app/services/inbound/service_service.py
from app import db from app.extensions import db
from app.models.inbound.service import StockService from app.models.inbound.service import StockService
from app.models.base import MaterialBase from app.models.base import MaterialBase
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -19,18 +19,24 @@ class ServiceService:
"""生成唯一SKU格式 SRV-YYYYMMDD-XXXX""" """生成唯一SKU格式 SRV-YYYYMMDD-XXXX"""
today_str = datetime.now().strftime(cls.SKU_DATE_FORMAT) today_str = datetime.now().strftime(cls.SKU_DATE_FORMAT)
prefix = f'{cls.SKU_PREFIX}-{today_str}-' prefix = f'{cls.SKU_PREFIX}-{today_str}-'
# 查找今天已有的最大后缀 # 查找今天已有的最大后缀
max_sku = db.session.query(db.func.max(StockService.sku)).filter( max_sku = db.session.query(db.func.max(StockService.sku)).filter(
StockService.sku.like(f'{prefix}%') StockService.sku.like(f'{prefix}%')
).scalar() ).scalar()
if not max_sku: if not max_sku:
suffix_num = 1 suffix_num = 1
else: else:
# 提取后缀数字 # 提取后缀数字
suffix_part = max_sku.replace(prefix, '') suffix_part = max_sku.replace(prefix, '')
match = re.match(r'^(\d+)', suffix_part) try:
suffix_num = int(match.group(1)) if match else 0 match = re.search(r'(\d+)$', suffix_part)
suffix_num = int(match.group(1)) if match else 0
except:
suffix_num = 0
suffix_num += 1 suffix_num += 1
# 格式化为4位数字左侧补零 # 格式化为4位数字左侧补零
suffix = str(suffix_num).zfill(cls.SKU_SUFFIX_LEN) suffix = str(suffix_num).zfill(cls.SKU_SUFFIX_LEN)
return f'{prefix}{suffix}' return f'{prefix}{suffix}'
@ -39,7 +45,7 @@ class ServiceService:
def search_base_material(cls, keyword): def search_base_material(cls, keyword):
"""搜索基础物料,供前端远程选择""" """搜索基础物料,供前端远程选择"""
try: try:
# [核心修改] 只查询已启用的物料 # 只查询已启用的物料
query = MaterialBase.query.filter(MaterialBase.is_enabled == True) query = MaterialBase.query.filter(MaterialBase.is_enabled == True)
if keyword: if keyword:
@ -50,6 +56,7 @@ class ServiceService:
) )
) )
query = query.order_by(MaterialBase.id.desc()).limit(20) query = query.order_by(MaterialBase.id.desc()).limit(20)
results = [] results = []
for item in query.all(): for item in query.all():
results.append({ results.append({
@ -61,40 +68,48 @@ class ServiceService:
'type': item.material_type, 'type': item.material_type,
}) })
return results return results
except Exception as e: except Exception:
import traceback
traceback.print_exc() traceback.print_exc()
return [] return []
@classmethod @classmethod
def create_service(cls, data): def create_service(cls, data):
"""创建服务权益记录""" """创建服务权益记录"""
# 检查基础物料是否存在 # 1. 检查基础物料
base_id = data.get('base_id') base_id = data.get('base_id')
base = MaterialBase.query.get(base_id) base = MaterialBase.query.get(base_id)
if not base: if not base:
raise ValueError('基础物料不存在') raise ValueError('基础物料不存在')
# [核心修改] 后端二次校验:如果物料已停用,禁止创建服务权益
if not base.is_enabled: if not base.is_enabled:
raise ValueError(f"物料【{base.name}】已停用,无法创建新的服务权益。") raise ValueError(f"物料【{base.name}】已停用,无法创建新的服务权益。")
# 生成SKU # 2. 生成SKU
sku = cls._generate_sku() sku = cls._generate_sku()
# 3. 创建对象 (不包含库存数量字段)
service = StockService( service = StockService(
base_id=data['base_id'], base_id=data['base_id'],
sku=sku, sku=sku,
sale_price=data['sale_price'], sale_price=data.get('sale_price', 0),
provider_name=data['provider_name'], provider_name=data.get('provider_name', ''),
description=data.get('description', '') description=data.get('description', ''),
# 可选字段映射
service_category=data.get('service_category', ''),
contract_id=data.get('contract_id', ''),
contact_person=data.get('contact_person', ''),
valid_period=data.get('valid_period', ''),
cost_price=data.get('cost_price', 0)
) )
db.session.add(service) db.session.add(service)
db.session.commit() db.session.commit()
return service return service
@classmethod @classmethod
def get_service(cls, service_id): def get_service(cls, service_id):
"""获取单个服务权益""" """获取单个详情"""
service = StockService.query.filter_by(id=service_id, is_deleted=False).first() service = StockService.query.filter_by(id=service_id, is_deleted=False).first()
if not service: if not service:
raise ValueError('服务权益记录不存在') raise ValueError('服务权益记录不存在')
@ -102,22 +117,32 @@ class ServiceService:
@classmethod @classmethod
def update_service(cls, service_id, data): def update_service(cls, service_id, data):
"""更新服务权益记录""" """更新服务权益"""
service = cls.get_service(service_id) service = cls.get_service(service_id)
# 不允许修改 base_id 和 sku业务上不允许变更基础物料
# 允许更新的字段
if 'sale_price' in data: if 'sale_price' in data:
service.sale_price = data['sale_price'] service.sale_price = data['sale_price']
if 'provider_name' in data: if 'provider_name' in data:
service.provider_name = data['provider_name'] service.provider_name = data['provider_name']
if 'description' in data: if 'description' in data:
service.description = data.get('description', '') service.description = data.get('description', '')
if 'cost_price' in data:
service.cost_price = data.get('cost_price', 0)
if 'contract_id' in data:
service.contract_id = data.get('contract_id', '')
if 'contact_person' in data:
service.contact_person = data.get('contact_person', '')
if 'valid_period' in data:
service.valid_period = data.get('valid_period', '')
service.updated_at = datetime.now() service.updated_at = datetime.now()
db.session.commit() db.session.commit()
return service return service
@classmethod @classmethod
def delete_service(cls, service_id): def delete_service(cls, service_id):
"""软删除服务权益""" """软删除"""
service = cls.get_service(service_id) service = cls.get_service(service_id)
service.is_deleted = True service.is_deleted = True
service.updated_at = datetime.now() service.updated_at = datetime.now()
@ -127,97 +152,79 @@ class ServiceService:
@classmethod @classmethod
def get_service_list(cls, page=1, per_page=20, keyword=None, def get_service_list(cls, page=1, per_page=20, keyword=None,
start_date=None, end_date=None, provider_name=None): start_date=None, end_date=None, provider_name=None):
"""分页查询服务权益列表""" """分页查询列表"""
query = StockService.query.filter_by(is_deleted=False) try:
# 关键词搜索:可搜索 SKU 或 关联物料名称 query = StockService.query.filter_by(is_deleted=False)
if keyword:
# 直接子查询 # 关键词联表搜索
query = query.filter( if keyword:
db.or_( query = query.join(StockService.base).filter(
StockService.sku.ilike(f'%{keyword}%'), db.or_(
StockService.base_id.in_( StockService.sku.ilike(f'%{keyword}%'),
db.session.query(MaterialBase.id).filter(MaterialBase.name.ilike(f'%{keyword}%')) MaterialBase.name.ilike(f'%{keyword}%'),
MaterialBase.spec_model.ilike(f'%{keyword}%')
) )
) )
)
if start_date:
try:
start = datetime.strptime(start_date, '%Y-%m-%d')
query = query.filter(StockService.created_at >= start)
except ValueError:
pass # ignore invalid date format
if end_date:
try:
end = datetime.strptime(end_date, '%Y-%m-%d')
# 包含当天
end = end + timedelta(days=1) - timedelta(seconds=1)
query = query.filter(StockService.created_at <= end)
except ValueError:
pass
if provider_name:
query = query.filter(StockService.provider_name.ilike(f'%{provider_name}%'))
# 总数
total = query.count()
# 分页
items = query.order_by(StockService.created_at.desc()) \
.offset((page - 1) * per_page) \
.limit(per_page).all()
return {
'items': [item.to_dict() for item in items],
'total': total,
'page': page,
'per_page': per_page
}
# ============================================================ # 日期过滤
# 供应商历史查询 if start_date:
# ============================================================ try:
start = datetime.strptime(start_date, '%Y-%m-%d')
query = query.filter(StockService.created_at >= start)
except ValueError:
pass
if end_date:
try:
end = datetime.strptime(end_date, '%Y-%m-%d')
# 包含当天结束
end = end + timedelta(days=1) - timedelta(seconds=1)
query = query.filter(StockService.created_at <= end)
except ValueError:
pass
# 服务商过滤
if provider_name:
query = query.filter(StockService.provider_name.ilike(f'%{provider_name}%'))
total = query.count()
items = query.order_by(StockService.created_at.desc()) \
.offset((page - 1) * per_page) \
.limit(per_page).all()
return {
'items': [item.to_dict() for item in items],
'total': total,
'page': page,
'per_page': per_page
}
except Exception as e:
traceback.print_exc()
raise e
@classmethod @classmethod
def get_history_providers(cls, base_id): def get_history_providers(cls, base_id):
"""返回该物料关联的服务商列表(去重)""" """获取历史供应商"""
try: try:
query = db.session.query(StockService.provider_name).filter( query = db.session.query(StockService.provider_name).filter(
StockService.base_id == base_id, StockService.base_id == base_id,
StockService.provider_name.isnot(None) StockService.provider_name.isnot(None),
StockService.provider_name != ''
).distinct().order_by(StockService.provider_name) ).distinct().order_by(StockService.provider_name)
providers = [row[0] for row in query.all()] return [row[0] for row in query.all()]
return providers
except Exception: except Exception:
return [] return []
# ============================================================
# 系统用户搜索
# ============================================================
@classmethod @classmethod
def search_system_users(cls, keyword): def search_system_users(cls, keyword):
"""搜索系统用户(活跃状态)""" """搜索系统用户(占位)"""
from app.models.system import SysUser return []
try:
query = SysUser.query.filter(SysUser.status == 'active')
if keyword:
kw = f'%{keyword}%'
query = query.filter(db.or_(
SysUser.username.ilike(kw),
SysUser.email.ilike(kw)
))
query = query.order_by(SysUser.username)
users = []
for u in query.limit(20).all():
users.append({
'value': u.username,
'email': u.email
})
return users
except Exception:
return []
# ============================================================
# 获取筛选选项(类别、类型)
# ============================================================
@classmethod @classmethod
def get_filter_options(cls): def get_filter_options(cls):
"""获取筛选下拉选项"""
try: try:
from app.models.base import MaterialBase
categories = db.session.query(MaterialBase.category) \ categories = db.session.query(MaterialBase.category) \
.filter(MaterialBase.category != None, MaterialBase.category != '') \ .filter(MaterialBase.category != None, MaterialBase.category != '') \
.distinct().all() .distinct().all()
@ -229,6 +236,4 @@ class ServiceService:
"types": [r[0] for r in types] "types": [r[0] for r in types]
} }
except Exception: except Exception:
import traceback return {"categories": [], "types": []}
traceback.print_exc()
return {"categories": [], "types": []}

View File

@ -369,7 +369,7 @@ const dialogStatus = ref<'create' | 'update'>('create')
const tableData = ref([]) const tableData = ref([])
const total = ref(0) const total = ref(0)
const formRef = ref() const formRef = ref()
const queryParams = reactive({ page: 1, pageSize: 15, keyword: '', statuses: ['在库', '借库'] }) const queryParams = reactive({ page: 1, pageSize: 100, keyword: '', statuses: ['在库', '借库'] })
const materialOptions = ref<any[]>([]) const materialOptions = ref<any[]>([])
// 打印相关变量 // 打印相关变量