diff --git a/inventory-backend/app/api/v1/outbound.py b/inventory-backend/app/api/v1/outbound.py index 20821b4..72f6d2d 100644 --- a/inventory-backend/app/api/v1/outbound.py +++ b/inventory-backend/app/api/v1/outbound.py @@ -214,10 +214,11 @@ def get_outbound_list(): page = int(request.args.get('page', 1)) limit = int(request.args.get('limit', 10)) keyword = request.args.get('keyword', '') + search_type = request.args.get('search_type', 'all') # 如果前端传了日期范围,可以解析处理,这里暂略 - # ★ [修改] 调用分组查询服务 - result = OutboundService.get_grouped_list(page, limit, keyword) + # ★ [修改] 调用分组查询服务,支持搜索类型 + result = OutboundService.get_grouped_list(page, limit, keyword, search_type=search_type) # 字段级脱敏 user_permissions = get_current_user_permissions() diff --git a/inventory-backend/app/api/v1/transactions.py b/inventory-backend/app/api/v1/transactions.py index b4f5d35..573de83 100644 --- a/inventory-backend/app/api/v1/transactions.py +++ b/inventory-backend/app/api/v1/transactions.py @@ -156,8 +156,9 @@ def get_records(): status = request.args.get('status', 'all') page = int(request.args.get('page', 1)) keyword = request.args.get('keyword', '') + search_type = request.args.get('search_type', 'all') - res = TransService.get_records(page=page, limit=10, status=status, keyword=keyword) + res = TransService.get_records(page=page, limit=10, status=status, keyword=keyword, search_type=search_type) # 字段级脱敏 user_permissions = get_current_user_permissions() if res.get('items'): diff --git a/inventory-backend/app/services/outbound_service.py b/inventory-backend/app/services/outbound_service.py index 327ae2a..ecd2355 100644 --- a/inventory-backend/app/services/outbound_service.py +++ b/inventory-backend/app/services/outbound_service.py @@ -192,76 +192,159 @@ class OutboundService: raise e @staticmethod - def get_grouped_list(page=1, per_page=10, keyword=None, start_date=None, end_date=None): + def get_grouped_list(page=1, per_page=10, keyword=None, search_type='all', start_date=None, end_date=None): """ 查询出库记录(按出库单号分组),包含详细物品信息 支持跨表搜索:单号、领用人、SKU、物料名称、规格型号 + search_type: all, no, name, sku, material_name, spec_model """ # 1. 构建基础查询 # 如果有关键词,需要联表搜索物料名称和规格型号 if keyword: - # 由于 TransOutbound 通过 source_table + stock_id 关联到不同库存表,再关联到 MaterialBase - # 需要使用 union 或分别查询后合并 - # 方案:分别查询三种来源的 matching outbound_no,然后合并 + # 根据 search_type 构建不同的搜索条件 + if search_type == 'all': + # 原有逻辑:or_ 联表全局模糊搜索 + # 查询 stock_buy 路径匹配的名称/规格 + buy_match = db.session.query(TransOutbound.outbound_no).join( + StockBuy, and_( + TransOutbound.stock_id == StockBuy.id, + TransOutbound.source_table == 'stock_buy' + ) + ).join( + MaterialBase, StockBuy.base_id == MaterialBase.id + ).filter( + or_( + MaterialBase.name.ilike(f'%{keyword}%'), + MaterialBase.spec_model.ilike(f'%{keyword}%') + ) + ).subquery() - # 查询 stock_buy 路径匹配的名称/规格 - buy_match = db.session.query(TransOutbound.outbound_no).join( - StockBuy, and_( - TransOutbound.stock_id == StockBuy.id, - TransOutbound.source_table == 'stock_buy' - ) - ).join( - MaterialBase, StockBuy.base_id == MaterialBase.id - ).filter( - or_( - MaterialBase.name.ilike(f'%{keyword}%'), - MaterialBase.spec_model.ilike(f'%{keyword}%') - ) - ).subquery() + # 查询 stock_semi 路径匹配的名称/规格 + semi_match = db.session.query(TransOutbound.outbound_no).join( + StockSemi, and_( + TransOutbound.stock_id == StockSemi.id, + TransOutbound.source_table == 'stock_semi' + ) + ).join( + MaterialBase, StockSemi.base_id == MaterialBase.id + ).filter( + or_( + MaterialBase.name.ilike(f'%{keyword}%'), + MaterialBase.spec_model.ilike(f'%{keyword}%') + ) + ).subquery() - # 查询 stock_semi 路径匹配的名称/规格 - semi_match = db.session.query(TransOutbound.outbound_no).join( - StockSemi, and_( - TransOutbound.stock_id == StockSemi.id, - TransOutbound.source_table == 'stock_semi' - ) - ).join( - MaterialBase, StockSemi.base_id == MaterialBase.id - ).filter( - or_( - MaterialBase.name.ilike(f'%{keyword}%'), - MaterialBase.spec_model.ilike(f'%{keyword}%') - ) - ).subquery() + # 查询 stock_product 路径匹配的名称/规格 + product_match = db.session.query(TransOutbound.outbound_no).join( + StockProduct, and_( + TransOutbound.stock_id == StockProduct.id, + TransOutbound.source_table == 'stock_product' + ) + ).join( + MaterialBase, StockProduct.base_id == MaterialBase.id + ).filter( + or_( + MaterialBase.name.ilike(f'%{keyword}%'), + MaterialBase.spec_model.ilike(f'%{keyword}%') + ) + ).subquery() - # 查询 stock_product 路径匹配的名称/规格 - product_match = db.session.query(TransOutbound.outbound_no).join( - StockProduct, and_( - TransOutbound.stock_id == StockProduct.id, - TransOutbound.source_table == 'stock_product' - ) - ).join( - MaterialBase, StockProduct.base_id == MaterialBase.id - ).filter( - or_( - MaterialBase.name.ilike(f'%{keyword}%'), - MaterialBase.spec_model.ilike(f'%{keyword}%') - ) - ).subquery() + # 合并三种来源的匹配单号 + all_matches = db.session.query(buy_match.c.outbound_no).union( + db.session.query(semi_match.c.outbound_no), + db.session.query(product_match.c.outbound_no) + ).subquery() - # 合并三种来源的匹配单号 - all_matches = db.session.query(buy_match.c.outbound_no).union( - db.session.query(semi_match.c.outbound_no), - db.session.query(product_match.c.outbound_no) - ).subquery() + keyword_conditions = or_( + TransOutbound.outbound_no.ilike(f'%{keyword}%'), + TransOutbound.consumer_name.ilike(f'%{keyword}%'), + TransOutbound.sku.ilike(f'%{keyword}%'), + TransOutbound.outbound_no.in_(all_matches) + ) - # 主搜索条件:单号、领用人、SKU + 物料名称/规格匹配的单号 - keyword_conditions = or_( - TransOutbound.outbound_no.ilike(f'%{keyword}%'), - TransOutbound.consumer_name.ilike(f'%{keyword}%'), - TransOutbound.sku.ilike(f'%{keyword}%'), - TransOutbound.outbound_no.in_(all_matches) - ) + elif search_type == 'no': + keyword_conditions = TransOutbound.outbound_no.ilike(f'%{keyword}%') + + elif search_type == 'name': + keyword_conditions = TransOutbound.consumer_name.ilike(f'%{keyword}%') + + elif search_type == 'sku': + keyword_conditions = TransOutbound.sku.ilike(f'%{keyword}%') + + elif search_type == 'material_name': + # 联表查询物料名称 + buy_match = db.session.query(TransOutbound.outbound_no).join( + StockBuy, and_( + TransOutbound.stock_id == StockBuy.id, + TransOutbound.source_table == 'stock_buy' + ) + ).join( + MaterialBase, StockBuy.base_id == MaterialBase.id + ).filter(MaterialBase.name.ilike(f'%{keyword}%')).subquery() + + semi_match = db.session.query(TransOutbound.outbound_no).join( + StockSemi, and_( + TransOutbound.stock_id == StockSemi.id, + TransOutbound.source_table == 'stock_semi' + ) + ).join( + MaterialBase, StockSemi.base_id == MaterialBase.id + ).filter(MaterialBase.name.ilike(f'%{keyword}%')).subquery() + + product_match = db.session.query(TransOutbound.outbound_no).join( + StockProduct, and_( + TransOutbound.stock_id == StockProduct.id, + TransOutbound.source_table == 'stock_product' + ) + ).join( + MaterialBase, StockProduct.base_id == MaterialBase.id + ).filter(MaterialBase.name.ilike(f'%{keyword}%')).subquery() + + all_matches = db.session.query(buy_match.c.outbound_no).union( + db.session.query(semi_match.c.outbound_no), + db.session.query(product_match.c.outbound_no) + ).subquery() + + keyword_conditions = TransOutbound.outbound_no.in_(all_matches) + + elif search_type == 'spec_model': + # 联表查询规格型号 + buy_match = db.session.query(TransOutbound.outbound_no).join( + StockBuy, and_( + TransOutbound.stock_id == StockBuy.id, + TransOutbound.source_table == 'stock_buy' + ) + ).join( + MaterialBase, StockBuy.base_id == MaterialBase.id + ).filter(MaterialBase.spec_model.ilike(f'%{keyword}%')).subquery() + + semi_match = db.session.query(TransOutbound.outbound_no).join( + StockSemi, and_( + TransOutbound.stock_id == StockSemi.id, + TransOutbound.source_table == 'stock_semi' + ) + ).join( + MaterialBase, StockSemi.base_id == MaterialBase.id + ).filter(MaterialBase.spec_model.ilike(f'%{keyword}%')).subquery() + + product_match = db.session.query(TransOutbound.outbound_no).join( + StockProduct, and_( + TransOutbound.stock_id == StockProduct.id, + TransOutbound.source_table == 'stock_product' + ) + ).join( + MaterialBase, StockProduct.base_id == MaterialBase.id + ).filter(MaterialBase.spec_model.ilike(f'%{keyword}%')).subquery() + + all_matches = db.session.query(buy_match.c.outbound_no).union( + db.session.query(semi_match.c.outbound_no), + db.session.query(product_match.c.outbound_no) + ).subquery() + + keyword_conditions = TransOutbound.outbound_no.in_(all_matches) + + else: + keyword_conditions = None else: keyword_conditions = None diff --git a/inventory-backend/app/services/permission_service.py b/inventory-backend/app/services/permission_service.py index 2b168ea..9b37790 100644 --- a/inventory-backend/app/services/permission_service.py +++ b/inventory-backend/app/services/permission_service.py @@ -459,6 +459,27 @@ class PermissionService: SysRolePermission.query.filter_by(target_code=menu.code).delete() db.session.delete(menu) + # 第二步:清理重复菜单(同一个 code 存在多条记录,保留 ID 最小的) + # 先找出所有可能重复的 code + duplicate_check = db.session.query( + SysMenu.code, + func.count(SysMenu.id).label('cnt') + ).group_by(SysMenu.code).having(func.count(SysMenu.id) > 1).all() + + for code, cnt in duplicate_check: + # 获取该 code 的所有记录,按 id 排序,保留第一条,删除其余 + duplicates = SysMenu.query.filter_by(code=code).order_by(SysMenu.id).all() + # 保留第一条,删除其他 + for dup in duplicates[1:]: + print(f"🗑️ 清理重复菜单: {dup.code} (id={dup.id}, name={dup.name})") + SysRolePermission.query.filter_by(target_code=dup.code).delete() + SysElement.query.filter_by(menu_code=dup.code).delete() + db.session.delete(dup) + + # 第三步:强制重新设置所有子菜单的 parent_id,确保没有遗漏 + # 先将所有子菜单的 parent_id 设为 None,然后重新设置 + SysMenu.query.filter(SysMenu.code.in_(child_codes)).update({SysMenu.parent_id: None}) + # 创建或更新菜单 menu_map = {} # code -> menu obj diff --git a/inventory-backend/app/services/trans_service.py b/inventory-backend/app/services/trans_service.py index af17500..b4e6327 100644 --- a/inventory-backend/app/services/trans_service.py +++ b/inventory-backend/app/services/trans_service.py @@ -188,72 +188,159 @@ class TransService: raise e @staticmethod - def get_records(page=1, limit=10, status='all', keyword=None): + def get_records(page=1, limit=10, status='all', keyword=None, search_type='all'): q = TransBorrow.query # 如果有关键词,需要联表搜索物料名称和规格型号 if keyword: - # TransBorrow 通过 source_table + stock_id 关联到不同库存表,再关联到 MaterialBase - # 需要使用 union 或分别查询后合并 + # 根据 search_type 构建不同的搜索条件 + if search_type == 'all': + # 原有逻辑:or_ 联表全局模糊搜索 + # 查询 stock_buy 路径匹配的名称/规格 + buy_match = db.session.query(TransBorrow.id).join( + StockBuy, and_( + TransBorrow.stock_id == StockBuy.id, + TransBorrow.source_table == 'stock_buy' + ) + ).join( + MaterialBase, StockBuy.base_id == MaterialBase.id + ).filter( + or_( + MaterialBase.name.ilike(f'%{keyword}%'), + MaterialBase.spec_model.ilike(f'%{keyword}%') + ) + ).subquery() - # 查询 stock_buy 路径匹配的名称/规格 - buy_match = db.session.query(TransBorrow.id).join( - StockBuy, and_( - TransBorrow.stock_id == StockBuy.id, - TransBorrow.source_table == 'stock_buy' - ) - ).join( - MaterialBase, StockBuy.base_id == MaterialBase.id - ).filter( - or_( - MaterialBase.name.ilike(f'%{keyword}%'), - MaterialBase.spec_model.ilike(f'%{keyword}%') - ) - ).subquery() + # 查询 stock_semi 路径匹配的名称/规格 + semi_match = db.session.query(TransBorrow.id).join( + StockSemi, and_( + TransBorrow.stock_id == StockSemi.id, + TransBorrow.source_table == 'stock_semi' + ) + ).join( + MaterialBase, StockSemi.base_id == MaterialBase.id + ).filter( + or_( + MaterialBase.name.ilike(f'%{keyword}%'), + MaterialBase.spec_model.ilike(f'%{keyword}%') + ) + ).subquery() - # 查询 stock_semi 路径匹配的名称/规格 - semi_match = db.session.query(TransBorrow.id).join( - StockSemi, and_( - TransBorrow.stock_id == StockSemi.id, - TransBorrow.source_table == 'stock_semi' - ) - ).join( - MaterialBase, StockSemi.base_id == MaterialBase.id - ).filter( - or_( - MaterialBase.name.ilike(f'%{keyword}%'), - MaterialBase.spec_model.ilike(f'%{keyword}%') - ) - ).subquery() + # 查询 stock_product 路径匹配的名称/规格 + product_match = db.session.query(TransBorrow.id).join( + StockProduct, and_( + TransBorrow.stock_id == StockProduct.id, + TransBorrow.source_table == 'stock_product' + ) + ).join( + MaterialBase, StockProduct.base_id == MaterialBase.id + ).filter( + or_( + MaterialBase.name.ilike(f'%{keyword}%'), + MaterialBase.spec_model.ilike(f'%{keyword}%') + ) + ).subquery() - # 查询 stock_product 路径匹配的名称/规格 - product_match = db.session.query(TransBorrow.id).join( - StockProduct, and_( - TransBorrow.stock_id == StockProduct.id, - TransBorrow.source_table == 'stock_product' - ) - ).join( - MaterialBase, StockProduct.base_id == MaterialBase.id - ).filter( - or_( - MaterialBase.name.ilike(f'%{keyword}%'), - MaterialBase.spec_model.ilike(f'%{keyword}%') - ) - ).subquery() + # 合并三种来源的匹配 ID + all_matches = db.session.query(buy_match.c.id).union( + db.session.query(semi_match.c.id), + db.session.query(product_match.c.id) + ).subquery() - # 合并三种来源的匹配 ID - all_matches = db.session.query(buy_match.c.id).union( - db.session.query(semi_match.c.id), - db.session.query(product_match.c.id) - ).subquery() + keyword_conditions = or_( + TransBorrow.borrower_name.ilike(f'%{keyword}%'), + TransBorrow.sku.ilike(f'%{keyword}%'), + TransBorrow.borrow_no.ilike(f'%{keyword}%'), + TransBorrow.id.in_(all_matches) + ) - # 主搜索条件:借用人、SKU、单号 + 物料名称/规格匹配 - keyword_conditions = or_( - TransBorrow.borrower_name.ilike(f'%{keyword}%'), - TransBorrow.sku.ilike(f'%{keyword}%'), - TransBorrow.borrow_no.ilike(f'%{keyword}%'), - TransBorrow.id.in_(all_matches) - ) + elif search_type == 'no': + keyword_conditions = TransBorrow.borrow_no.ilike(f'%{keyword}%') + + elif search_type == 'name': + keyword_conditions = TransBorrow.borrower_name.ilike(f'%{keyword}%') + + elif search_type == 'sku': + keyword_conditions = TransBorrow.sku.ilike(f'%{keyword}%') + + elif search_type == 'material_name': + # 联表查询物料名称 + buy_match = db.session.query(TransBorrow.id).join( + StockBuy, and_( + TransBorrow.stock_id == StockBuy.id, + TransBorrow.source_table == 'stock_buy' + ) + ).join( + MaterialBase, StockBuy.base_id == MaterialBase.id + ).filter(MaterialBase.name.ilike(f'%{keyword}%')).subquery() + + semi_match = db.session.query(TransBorrow.id).join( + StockSemi, and_( + TransBorrow.stock_id == StockSemi.id, + TransBorrow.source_table == 'stock_semi' + ) + ).join( + MaterialBase, StockSemi.base_id == MaterialBase.id + ).filter(MaterialBase.name.ilike(f'%{keyword}%')).subquery() + + product_match = db.session.query(TransBorrow.id).join( + StockProduct, and_( + TransBorrow.stock_id == StockProduct.id, + TransBorrow.source_table == 'stock_product' + ) + ).join( + MaterialBase, StockProduct.base_id == MaterialBase.id + ).filter(MaterialBase.name.ilike(f'%{keyword}%')).subquery() + + all_matches = db.session.query(buy_match.c.id).union( + db.session.query(semi_match.c.id), + db.session.query(product_match.c.id) + ).subquery() + + keyword_conditions = TransBorrow.id.in_(all_matches) + + elif search_type == 'spec_model': + # 联表查询规格型号 + buy_match = db.session.query(TransBorrow.id).join( + StockBuy, and_( + TransBorrow.stock_id == StockBuy.id, + TransBorrow.source_table == 'stock_buy' + ) + ).join( + MaterialBase, StockBuy.base_id == MaterialBase.id + ).filter(MaterialBase.spec_model.ilike(f'%{keyword}%')).subquery() + + semi_match = db.session.query(TransBorrow.id).join( + StockSemi, and_( + TransBorrow.stock_id == StockSemi.id, + TransBorrow.source_table == 'stock_semi' + ) + ).join( + MaterialBase, StockSemi.base_id == MaterialBase.id + ).filter(MaterialBase.spec_model.ilike(f'%{keyword}%')).subquery() + + product_match = db.session.query(TransBorrow.id).join( + StockProduct, and_( + TransBorrow.stock_id == StockProduct.id, + TransBorrow.source_table == 'stock_product' + ) + ).join( + MaterialBase, StockProduct.base_id == MaterialBase.id + ).filter(MaterialBase.spec_model.ilike(f'%{keyword}%')).subquery() + + all_matches = db.session.query(buy_match.c.id).union( + db.session.query(semi_match.c.id), + db.session.query(product_match.c.id) + ).subquery() + + keyword_conditions = TransBorrow.id.in_(all_matches) + + else: + keyword_conditions = None + else: + keyword_conditions = None + + if keyword_conditions is not None: q = q.filter(keyword_conditions) if status == 'borrowed': diff --git a/inventory-web/src/views/outbound/index.vue b/inventory-web/src/views/outbound/index.vue index 3144366..46bae5c 100644 --- a/inventory-web/src/views/outbound/index.vue +++ b/inventory-web/src/views/outbound/index.vue @@ -3,13 +3,24 @@