import uuid # .material -> .base refactor checked from datetime import datetime from app.extensions import db from app.models.transaction import TransBorrow from app.models.inbound.buy import StockBuy from app.models.inbound.semi import StockSemi from app.models.inbound.product import StockProduct from app.models.base import MaterialBase from sqlalchemy import desc, func, nullslast, asc, or_, and_ class TransService: @staticmethod def generate_borrow_no(): """ 生成借用单号: BOR-yyyyMMdd-0001 (按日流水) 逻辑:统计当天已存在的不同借用单号数量,+1 作为新序号 """ now = datetime.now() date_str = now.strftime('%Y%m%d') prefix = f"BOR-{date_str}-" # 使用 count distinct 来计算当天有多少个不同的借用单 (因为一单多货会占多行) count = db.session.query(func.count(func.distinct(TransBorrow.borrow_no))) \ .filter(TransBorrow.borrow_no.like(f"{prefix}%")).scalar() sequence = count + 1 return f"{prefix}{sequence:04d}" @staticmethod def create_borrow(data, operator_name='System'): """ 借库逻辑:减少可用库存,不减总库存 """ items = data.get('items', []) borrower_name = data.get('borrower_name') signature = data.get('signature_path') # 借用人签字 if not items: raise ValueError("物品列表为空") if not borrower_name: raise ValueError("请输入借用人") if not signature: raise ValueError("借用人必须签字") borrow_no = TransService.generate_borrow_no() model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct} try: for item in items: source_table = item.get('source_table') stock_id = item.get('id') qty = float(item.get('out_quantity', 0)) ModelClass = model_map.get(source_table) if not ModelClass: continue stock = ModelClass.query.with_for_update().get(stock_id) if not stock: raise ValueError(f"库存不存在 ID:{stock_id}") if float(stock.available_quantity) < qty: raise ValueError(f"SKU {stock.sku} 可用库存不足") # 1. 冻结库存 (只减可用) stock.available_quantity = float(stock.available_quantity) - qty # 2. 创建借用单 record = TransBorrow( borrow_no=borrow_no, sku=stock.sku, source_table=source_table, stock_id=stock.id, barcode=stock.barcode, quantity=qty, borrower_name=borrower_name, borrow_signature=signature, remark=data.get('remark'), expected_return_time=data.get('expected_return_time'), status='borrowed', is_returned=False ) db.session.add(record) db.session.commit() return borrow_no except Exception as e: db.session.rollback() raise e @staticmethod def scan_for_return(barcode): """ 扫码还库:查找未归还记录,并返回当前物品的库位 """ records = TransBorrow.query.filter_by(barcode=barcode, is_returned=False).all() if not records: return None # 取第一条未还记录 record = records[0] # 获取当前库存表中的实时库位 current_location = "" model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct} ModelClass = model_map.get(record.source_table) if ModelClass: stock = ModelClass.query.get(record.stock_id) if stock: current_location = stock.warehouse_location res_dict = record.to_dict() res_dict['current_location'] = current_location # 用于前端对比和预填 return res_dict @staticmethod def process_return(data, operator_name): """ 还库逻辑(支持部分归还)- 已优化,消除 N+1 和长事务死锁风险 四步走策略: 1. 收集所有 borrow_id 2. 批量锁定借用记录 3. 收集库存ID并批量锁定库存 4. 内存中完成业务逻辑 """ items = data.get('items', []) signature = data.get('signature_path') # 库管签字 if not items: raise ValueError("还库列表为空") if not signature: raise ValueError("库管必须签字确认") model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct} try: # ========================================== # ★ 优化步骤 1:收集所有 borrow_id # ========================================== borrow_ids = [] item_map = {} # 存储原始 item 数据,key=borrow_id for item in items: borrow_id = item.get('id') if borrow_id: borrow_ids.append(borrow_id) item_map[borrow_id] = { 'return_qty': float(item.get('return_qty', 0)), 'final_location': item.get('return_location') } if not borrow_ids: raise ValueError("没有有效的归还记录") # ========================================== # ★ 优化步骤 2:批量锁定借用记录 # ========================================== borrow_records = TransBorrow.query.with_for_update().filter( TransBorrow.id.in_(borrow_ids) ).all() borrow_map = {r.id: r for r in borrow_records} # ========================================== # ★ 优化步骤 3:收集库存ID并批量锁定库存 # ========================================== stock_ids_by_table = {'stock_buy': set(), 'stock_semi': set(), 'stock_product': set()} for borrow_id, record in borrow_map.items(): if record.source_table in stock_ids_by_table and record.stock_id: stock_ids_by_table[record.source_table].add(record.stock_id) stock_map = {} # 格式: { ('stock_buy', 101): stock_obj } for table_name, ids in stock_ids_by_table.items(): if not ids: continue ModelClass = model_map[table_name] stocks = ModelClass.query.with_for_update().filter( ModelClass.id.in_(ids) ).all() for stock in stocks: stock_map[(table_name, stock.id)] = stock # ========================================== # ★ 优化步骤 4:内存中完成业务逻辑 # ========================================== for borrow_id, item_data in item_map.items(): return_qty = item_data['return_qty'] final_location = item_data['final_location'] record = borrow_map.get(borrow_id) if not record: continue # 计算待还数量 returned_qty = float(record.returned_quantity) if record.returned_quantity else 0 total_qty = float(record.quantity) if record.quantity else 0 pending_qty = total_qty - returned_qty # 校验归还数量 if return_qty <= 0: raise ValueError(f"归还数量必须大于0") if return_qty > pending_qty: raise ValueError(f"本次归还数量({return_qty})不能大于待还数量({pending_qty})") # 更新库存 stock = stock_map.get((record.source_table, record.stock_id)) if stock: # 恢复可用库存 stock.available_quantity = float(stock.available_quantity) + return_qty # 更新库位 if final_location: stock.warehouse_location = final_location # 更新归还数量和状态 new_returned_qty = returned_qty + return_qty record.returned_quantity = new_returned_qty if new_returned_qty >= total_qty: record.is_returned = True record.status = 'returned' else: record.is_returned = False record.status = 'partial_returned' record.return_time = datetime.now() record.return_operator = operator_name record.return_signature = signature if final_location: record.return_location = final_location db.session.commit() except Exception as e: db.session.rollback() raise e @staticmethod def get_records(page=1, limit=10, status='all', keyword=None, search_type='all'): q = TransBorrow.query # 如果有关键词,需要联表搜索物料名称和规格型号 if keyword: # 根据 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_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() # 合并三种来源的匹配 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) ) 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': q = q.filter(TransBorrow.is_returned == False) elif status == 'returned': q = q.filter(TransBorrow.is_returned == True) # 使用 distinct 防止跨表查询产生重复记录 q = q.distinct() q = q.order_by(nullslast(asc(TransBorrow.expected_return_time))) pagination = q.paginate(page=page, per_page=limit, error_out=False) return { 'items': [r.to_dict() for r in pagination.items], 'total': pagination.total, 'page': page, 'limit': limit }