import uuid # .material -> .base refactor checked from datetime import datetime from app.extensions import db, beijing_time from app.models.transaction import TransBorrow, TransBorrowTransfer, TransBorrowReturn 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 app.utils.decorators import get_current_company_filter from sqlalchemy import desc, func, nullslast, asc, or_, and_, case from sqlalchemy.orm import joinedload def user_display_name(user): """ 用户名展示口径:'姓名/拼音' → '姓名'。 ★ 与迁移脚本 phase4_borrow_transfer.sql 的回填口径 (split_part(username,'/',1))及 borrow_service 的通知口径三处保持一致 —— 否则同一人在台账里会出现「石利」与「石利/shili」两种写法,按姓名检索即失准。 """ if not user: return '' username = str(user.username or '') return username.split('/')[0] if '/' in username else username def _assert_borrow_company_visible(record): """ 行级公司隔离:确认一条借用记录属于当前用户可见的公司。 ★ 隔离链路与 get_records 完全一致: trans_borrow → (source_table, stock_id) → 库存表 → material_base.company_name 不另起一套口径,避免列表能看到的单、接口却判定为越权(或反之)。 ★ Fail-Closed:链路断裂(源库存行被入库模块物理删除,实测已有先例)时 **拒绝**而不是放行 —— 持有权变更直接改变责任归属,宁可让库管走人工, 也不在无法判定归属时越权操作。 """ company_limit = get_current_company_filter() if company_limit is None: # 超管 / crossDomain → 全量跨域 return model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct} ModelClass = model_map.get(record.source_table) stock = ModelClass.query.get(record.stock_id) if (ModelClass and record.stock_id) else None base = stock.base if stock else None if base is None: raise ValueError( "该借用记录的来源库存已不存在,无法完成公司隔离校验,请联系管理员处理" ) if (base.company_name or '') != company_limit: raise ValueError("无权操作其他公司的借用记录") 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 execute_dispatch(approval_id, items, operator_name='System', borrower_name=None, signature=None, remark=None, expected_return_time=None, borrower_id=None): """ 执行借库扣减(审批通过后调用) 流程:锁审批单 → 构建审批上限字典 → 锁库存行 → 名称规格校验 → 扣减库存 → 生成 TransBorrow 记录 → 标记审批单完成 ★ 关键设计:审批维度是 (name, spec_model) 而非 SKU 借库申请是按【名称 + 规格型号】发起的(borrow_service 强制要求 name/spec_model/quantity 三字段), 申请时尚未绑定具体库存行;扫码出库时通过锁定 stock 行回查 material_base 表, 用 (name, spec_model) 与审批单做物料维度聚合比对,避免 sku 维度坍塌或绕过。 ★ borrower_id(一期转交改造后为**必填**): 实际借用人ID。borrower_name 参数仅为向后兼容而保留 —— 落库时一律 以 borrower_id 反查 sys_user 得到的姓名为准,传参中的姓名被忽略。 """ from app.models.borrow import BorrowApproval if not items: raise ValueError("物品列表为空") if not signature: raise ValueError("借用人必须签字") # ============================================== # ★ 防线1:并发防重复执行 - 用 SELECT FOR UPDATE 锁住审批单 # ============================================== approval = BorrowApproval.query.with_for_update().get(approval_id) if not approval: raise ValueError("审批单不存在") if approval.status != 1: status_map = {0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成'} raise ValueError(f"审批单状态为【{status_map.get(approval.status, approval.status)}】,无法执行借库") # ============================================== # ★ 借用人身份锚定(一期转交改造):强制 ID # # 原实现只收 borrower_name 字符串,责任链从落库那刻起就存在重名歧义 # (实测 85 行 / 18 个姓名,纯姓名无法唯一锚定一个人)。 # 现强制要求 borrower_id,并以 sys_user 为**唯一事实来源**反查姓名: # · borrower_name 降级为展示快照,不再接受前端自由输入; # · 传了 borrower_id 但用户不存在 → 直接拒绝,不静默放行。 # # ★ 为什么不回退到 approval.borrower_name: # 申请单上的姓名是「申请意向」,与「扫码时实际来领的人」本就可能不同 # (库管代建场景尤甚)。若回退,current_holder_id 会从第一刻就记错人, # 转交与归还的整条责任链都会建立在错误的锚点上。宁可让库管重选。 # ============================================== if not borrower_id: raise ValueError("缺少借用人 borrower_id,请重新选择借用人后再提交") from app.models.system import SysUser borrower = SysUser.query.get(int(borrower_id)) if not borrower: raise ValueError(f"借用人不存在(ID:{borrower_id}),请重新选择") borrower_name = user_display_name(borrower) if not borrower_name: raise ValueError(f"借用人(ID:{borrower_id})用户名为空,无法生成台账快照") # ============================================== # ★ 防线2:构建审批上限字典 # # 改造说明:原先以 (name, spec_model) 聚合,但本系统中 SKU 是**批次级** # 编号(同一物料不同批次 SKU 不同),而 name+spec 又是字符串比较, # 易受空格/别名影响。现统一改用 identity_key(base_id 主键 + # name/spec 兜底),与出库、报废三个模块共用同一套身份语义。 # ============================================== approved_items = approval.get_items() if not approved_items: raise ValueError("审批单中无物料明细,请联系管理员检查") from app.services.inventory_reservation import ( build_approval_index, verify_scanned, restore_then_deduct, identity_label, ) approval_idx = build_approval_index(approved_items) borrow_no = TransService.generate_borrow_no() model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct} # ★ 防止死锁:按 (source_table, id) 排序,保证所有并发请求以相同顺序获取行锁 items.sort(key=lambda x: (x.get('source_table', ''), x.get('id', 0))) # ============================================================== # ★ Phase 3:预占再平衡 # 借库申请阶段已预占具体批次;工人实扫的可能是同物料的另一批次。 # 1. 校验实扫身份/数量未超批准范围(base_id 主键,允许换批次) # 2. 释放全部预占 # 3. 对实扫批次扣减 available_quantity(★ 不动 stock_quantity) # 下方主循环只写 TransBorrow 流水,不再重复扣库存。 # # ★ deduct_stock=False:借出是**可逆的**(会归还),只冻结可用数。 # 若此处扣了实物,会与两处冲突: # · 归还(process_return)只加 available —— 一借一还后 stock 永久少一份; # · 借库转报废在确认损失时扣 stock,其实现明确假设「可用库存已在 # 借出时冻结」—— 若借出已扣会重复扣减。 # (该实现原为 TransService.scrap_borrow,现已迁入 # app/services/scrap_sources.py 的 BorrowScrapAdapter.deduct) # ============================================================== # ★ 防线 2.5(Fail-Closed):实扫明细的 source_table 必须全部可识别。 # # 原先此处写作 `if _scanned_for_check:` —— 不在 model_map 的明细会被 # 静默过滤。若整批明细都不可识别,校验与释放被整体跳过,下方主循环 # 也逐条 `continue`,末尾却照常把 approval.status 置为 3,于是: # · 申请阶段预占的 available_quantity 无人释放 → 幽灵库存永久锁死; # · 单据已非 status=1,/close 与 /withdraw 都拒绝再释放; # · 且没有任何 TransBorrow 流水可供追溯。 # 现在改为整单失败:事务回滚,单据保持 status=1、预占原样保留, # 库管可重试执行,或走驳回/撤回 —— 任一路径都能正常归还预占。 # ============================================================== unknown_source = sorted({ str(i.get('source_table')) for i in items if not isinstance(i.get('source_table'), str) or i.get('source_table') not in model_map }) if unknown_source: raise ValueError( f"实扫明细的库存来源不合法:{'、'.join(unknown_source)}," f"仅支持 {'、'.join(sorted(model_map))}" ) # 经上方校验后 source_table 必然合法,故本列表与 items 一一对应, # 不存在「被过滤掉的明细」。空列表由 verify_scanned 兜底报错。 _scanned_for_check = [ {'source_table': i.get('source_table'), 'stock_id': i.get('id'), 'quantity': i.get('out_quantity')} for i in items ] verify_scanned(_scanned_for_check, approved_items) restore_then_deduct(_scanned_for_check, approved_items, deduct_stock=False) # 累计本次扫码出库量(用于下方防线4的二次校验) dispatch_acc = {} 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 # ============================================== # ★ 防线3:并发超卖与负库存 - 锁行后再查可用库存 # ⚠️ 不要在此加 joinedload(ModelClass.base)!PG 禁止 FOR UPDATE # 应用到 outer join 的 nullable 侧,会报 FeatureNotSupported # 并有死锁风险。stock.base 走单条 lazy 加载是已知取舍。 # ============================================== stock = ModelClass.query.with_for_update().get(stock_id) if not stock: raise ValueError(f"库存不存在 ID:{stock_id}") # ============================================== # ★ 身份与数量校验已由上方 verify_scanned() 统一完成 # (base_id 主键匹配,允许同物料换批次;累计量不得超批准量) # # 此处仅做一次「本次扫码累计」的防御性复核,防止并发下 # 同一请求内重复 stock_id 被重复计数。 # 库存扣减也已在 restore_then_deduct() 完成 —— # 下方**不再**扣减 available_quantity,否则会扣两次。 # ============================================== stock_name = (stock.base.name or '').strip() if stock.base else '' stock_spec = (stock.base.spec_model or '').strip() if stock.base else '' key = (stock_name, stock_spec) dispatch_acc[key] = dispatch_acc.get(key, 0) + qty # 创建借用记录 record = TransBorrow( borrow_no=borrow_no, sku=stock.sku, source_table=source_table, stock_id=stock.id, barcode=stock.barcode, quantity=qty, # ★ 借出即由借用人持有:borrower_id 为初始借用人(写一次不再变), # current_holder_* 为当前持有人 —— 转交会在此之上继续推进。 borrower_id=int(borrower_id), borrower_name=borrower_name, current_holder_id=int(borrower_id), current_holder_name=borrower_name, # ★ 显式置 0:归还逻辑按 returned_quantity 累加并判断是否还清, # 不依赖 DB 列默认值(避免 ORM/DB 默认值口径不一致时算错待还量)。 returned_quantity=0, borrow_signature=signature, # ★ 发货操作人:执行本次借出的库管。operator_name 此前被接收 # 却从未落库,责任链上「谁经手发货」一直缺失,此处补齐。 dispatch_operator=operator_name, remark=remark, expected_return_time=expected_return_time, # [新增] 记录借出时的库位快照 location=getattr(stock, 'warehouse_location', None), status='borrowed', is_returned=False ) db.session.add(record) # ★ 3. 标记审批单为已完成 approval.status = 3 db.session.commit() return borrow_no except Exception as e: db.session.rollback() raise e # ★ 兼容旧入口(不走审批流的直接借库,保留以便平滑过渡) @staticmethod def create_borrow(data, operator_name='System'): """ 借库逻辑(兼容旧模式):减少可用库存,不减总库存 @deprecated 请优先使用 execute_dispatch 走审批流 """ return TransService.execute_dispatch( approval_id=0, items=data.get('items', []), operator_name=operator_name, borrower_name=data.get('borrower_name'), borrower_id=data.get('borrower_id'), signature=data.get('signature_path'), remark=data.get('remark'), expected_return_time=data.get('expected_return_time') ) @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, returner_id=None): """ 还库逻辑(支持部分归还)- 已优化,消除 N+1 和长事务死锁风险 四步走策略: 1. 收集所有 borrow_id 2. 批量锁定借用记录 3. 收集库存ID并批量锁定库存 4. 内存中完成业务逻辑 参数 ---- operator_name : 经手办理还库的**库管**姓名(窗口操作人) returner_id : 实际把物品交回窗口的**归还人**ID(一期转交改造新增)。 记录有 current_holder_id 时强制校验二者一致 —— 详见循环内的持有人校验块。 """ 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:内存中完成业务逻辑 # ========================================== # ★ 时间口径修复:原实现用 datetime.now()(容器本地时间,Docker 下为 # UTC),而同一行的 borrow_time 由 beijing_time 写入 —— 两个字段 # 差 8 小时,台账时间线自相矛盾。统一取北京时间,并与下方归还流水 # 共用同一个时间戳,保证主表快照与流水完全对齐。 return_now = beijing_time() 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 # ========================================== # ★ 持有人强校验(一期转交改造) # # 原实现只要持有 op_return:operation 权限的库管即可归还**任何人** # 借出的物品,函数内从不读取 record.borrower_name,归还环节的责任链 # 是断的。转交上线后物品会在 A→B→C 之间流转,若不校验归还人, # 「谁还的」将与「谁该还」彻底脱钩。 # # 规则:记录有 current_holder_id(= 物品仍在某人手上)时, # 归还人必须**就是**该持有人。 # # ★ 为什么 current_holder_id 为 NULL 时跳过: # 仅迁移前无法锚定姓名的历史行会是 NULL,跳过以兼容存量数据、 # 不阻断其正常归还;新数据(execute_dispatch 落库)必有值, # 即新流程**不存在**绕过校验的路径。 # ========================================== if record.current_holder_id is not None: holder_label = record.current_holder_name or f"用户({record.current_holder_id})" if returner_id is None: raise ValueError( f"缺少归还人:物品【{record.sku}】当前由【{holder_label}】持有," f"请选择归还人后再提交" ) if int(returner_id) != int(record.current_holder_id): raise ValueError( f"归还人与当前持有人不符,禁止归还:物品【{record.sku}】的" f"当前持有人为【{holder_label}】,而非所选归还人" ) # 计算待还数量 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 # ========================================== # 更新归还数量和状态 # # ★ 主表字段的定位(一期转交改造后): # returned_quantity / is_returned / status 是**累计快照**, # 聚合语义正确,列表页「未还/已还」判定依赖它们 → 继续维护; # return_time / return_operator / return_signature 降级为 # 「最近一次归还」**展示快照**(records.vue 的归还人/归还时间列 # 依赖它们)→ 继续刷新; # 逐次归还的**权威明细**改由 trans_borrow_return 承载 —— # 原先只写主表时,部分归还下「谁在什么时候还了多少」会被 # 逐次覆盖而永久丢失(失忆症),流水表根治该问题。 # ========================================== 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' # ★ 已全部还清:物品回到仓库,无人持有。 # 清空 current_holder 使「current_holder_id IS NOT NULL」 # 成为「仍在某人手上」的有效信号(与迁移脚本对已归还历史行 # 不回填 holder 的口径一致)。借款人仍留在 borrower_id 作历史。 record.current_holder_id = None record.current_holder_name = None else: record.is_returned = False record.status = 'partial_returned' record.return_time = return_now record.return_operator = operator_name record.return_signature = signature if final_location: record.return_location = final_location # ★ 逐次归还落流水(治失忆症) # returner_id 与 operator_name 是两个人:前者是交回物品的人, # 后者是经手办理的库管。上面已强校验前者 == current_holder_id。 db.session.add(TransBorrowReturn( borrow_id=record.id, returner_id=int(returner_id) if returner_id is not None else None, return_qty=return_qty, return_time=return_now, operator_name=operator_name, )) db.session.commit() except Exception as e: db.session.rollback() raise e # ========================================================================== # 借库转交(一期) # ========================================================================== @staticmethod def transfer_borrow(borrow_id, to_user_id, transfer_qty, operator_name='System', remark=None): """ 把一张借出单的**持有权**从当前持有人整单转给另一人。 与 execute_dispatch / process_return 的根本区别 ------------------------------------------------ 转交是**纯持有权变更**:实物不出入库,库存账目分毫不动。 本方法全程不触碰 stock_buy / stock_semi / stock_product 的任何字段 (available_quantity 与 stock_quantity 都不动)。 理由见 restore_then_deduct 的 deduct_stock 说明:借出期间 available 已冻结、stock 仍含借出未还量。转交若去动库存,会同时破坏两个既有假设 —— 「归还只加 available」会算多,而「借库转报废在确认损失时扣 stock」 (scrap_sources.BorrowScrapAdapter)会重复扣减。 为什么一期只允许整单全量转交 ---------------------------- trans_borrow 是**单行**模型,只能存一个 current_holder_id。 若允许部分转交(借 10 个转 5 个出去),这一行的 current_holder 就必须 同时表示两个人,语义直接撕裂,且归还时无法判定该由谁还。 故 transfer_qty 必须严格等于待还量(quantity - returned_quantity)。 ★ 未来若需部分转交:应改为按数量**拆行**(新建一条 trans_borrow 承接 转出量、原行扣减),而不是在本行上加字段打补丁 —— 单行模型无论如何 扩展都无法同时表达两个持有人。 参数 ---- borrow_id : trans_borrow.id to_user_id : 接收人(转交后的 current_holder)ID transfer_qty : 转交数量,一期必须等于待还量 operator_name: 执行转交操作的库管姓名 remark : 转交备注 返回 ---- TransBorrowTransfer 实例(已 commit) 异常 ---- ValueError: 任何校验不通过(调用方整单回滚,不会留下半转状态) """ from app.models.system import SysUser if to_user_id is None: raise ValueError("缺少接收人 to_user_id") if transfer_qty is None: raise ValueError("缺少转交数量 transfer_qty") try: transfer_qty = float(transfer_qty) except (TypeError, ValueError): raise ValueError("转交数量格式无效,应为数字") # ================================================================== # ★ 防线1:锁行 —— 并发下防止同一张单被同时转给两个人 # (后到的事务会阻塞在此,拿到锁后读到已推进的 current_holder_id, # 从而在下方「接收人 == 当前持有人」校验处被拒绝) # ================================================================== record = TransBorrow.query.with_for_update().get(borrow_id) if not record: raise ValueError("借出记录不存在") # --- 1. 状态准入 --- total_qty = float(record.quantity or 0) returned_qty = float(record.returned_quantity or 0) pending_qty = total_qty - returned_qty # ★ 顺序有意义:报废流程(scrap_sources)会同时置 is_returned=True 与 # status='scrapped',若先判 is_returned 会让报废单收到「已归还」的 # 误导性提示。故先判报废,给出准确原因。 if record.status == 'scrapped': raise ValueError("该借出记录已转入报废流程,不可再转交") if record.is_returned or pending_qty <= 0: raise ValueError("该借出记录已全部归还,无可转交的实物") # --- 2. 数量校验:一期必须整单全量转交 --- if transfer_qty <= 0: raise ValueError("转交数量必须大于0") if transfer_qty > pending_qty: raise ValueError( f"转交数量({transfer_qty})不能大于待还数量({pending_qty})" ) # 浮点容差:quantity/returned_quantity 是 numeric(19,4),差值应精确, # 但仍用容差比较,避免二进制浮点表示误差造成误拒。 if abs(transfer_qty - pending_qty) > 1e-6: raise ValueError( f"目前仅支持整单全部转交:本单待还 {pending_qty}," f"本次仅转交 {transfer_qty}。部分转交会导致当前持有人语义撕裂," f"请整单转交,或先办理部分归还后再转交。" ) # --- 3. 转出方必须已锚定 --- # 历史行(迁移前无法用姓名唯一映射到 sys_user 的)holder 为 NULL, # 此时「从谁转出」无从确定,Fail-Closed 拒绝。 if record.current_holder_id is None: raise ValueError( "该借出记录的当前持有人未锚定(历史数据),无法转交,请先办理归还" ) # --- 4. 接收人校验 --- try: to_user_id = int(to_user_id) except (TypeError, ValueError): # 不直接 int() 抛裸异常:原生报错信息(invalid literal for int()...) # 会原样透给前端,对库管毫无指导意义。 raise ValueError("接收人 to_user_id 格式无效,应为数字ID") to_user = SysUser.query.get(to_user_id) if not to_user: raise ValueError(f"接收人不存在(ID:{to_user_id})") to_user_name = user_display_name(to_user) if to_user_id == int(record.current_holder_id): raise ValueError(f"接收人与当前持有人同为【{to_user_name}】,无需转交") # --- 5. 行级公司隔离(Fail-Closed)--- _assert_borrow_company_visible(record) # ================================================================== # ★ 防线2:以下只写台账,绝不触碰任何库存字段 # ================================================================== from_name = record.current_holder_name or user_display_name( SysUser.query.get(record.current_holder_id) ) from_id = record.current_holder_id transfer = TransBorrowTransfer( borrow_id=record.id, from_user_id=from_id, from_user_name=from_name, to_user_id=to_user_id, to_user_name=to_user_name, transfer_qty=transfer_qty, transfer_time=beijing_time(), operator_name=operator_name, remark=remark, ) db.session.add(transfer) # 推进当前持有人。borrower_id(初始借用人)保持不动 —— 它回答的是 # 「这单最初谁借的」,不应被转交改写。 record.current_holder_id = to_user_id record.current_holder_name = to_user_name try: db.session.commit() except Exception as e: db.session.rollback() raise e return transfer @staticmethod def get_transfer_history(borrow_id): """某条借出记录的转交历史(按时间正序,便于还原 A→B→C 链路)""" rows = TransBorrowTransfer.query.filter_by(borrow_id=borrow_id) \ .order_by(asc(TransBorrowTransfer.transfer_time), asc(TransBorrowTransfer.id)).all() return [r.to_dict() for r in rows] @staticmethod def get_return_history(borrow_id): """某条借出记录的归还历史(按时间正序,替代被覆盖的主表字段)""" rows = TransBorrowReturn.query.filter_by(borrow_id=borrow_id) \ .order_by(asc(TransBorrowReturn.return_time), asc(TransBorrowReturn.id)).all() return [r.to_dict() for r in rows] @staticmethod def get_slip_history(borrow_no): """ 整张借用单(borrow_no 维度)的完整生命周期事件流,按时间**倒序**返回。 事件来源 -------- borrow ← trans_borrow 自身(借出时间 / 借用人 / 发货操作人 / 数量) transfer ← trans_borrow_transfer(转出人 → 接收人、经手库管、备注) return ← trans_borrow_return(实际归还人、经手库管、数量) scrap ← trans_borrow.status == 'scrapped'(报废是终态,没有独立流水表, 由主表的终态字段 + return_time/return_operator 还原) ★ 为什么整单聚合,而不是让前端逐条明细调用单品接口: 借用记录列表是 borrow_no 主子表结构,**实测单张单最多 21 条明细**, 逐条调用会产生 21 个请求,且各条时间线无法全局排序。此处一次合并。 ★ 公司隔离:任一条明细不可见即整单拒绝(Fail-Closed),与转交同口径。 异常:ValueError(单据不存在 / 越权 / 隔离链路断裂) """ from app.models.system import SysUser records = TransBorrow.query.filter_by(borrow_no=borrow_no).all() if not records: raise ValueError("借用单不存在") # 任一条明细越权 → 整单拒绝(与转交同口径,避免两套可见性标准分叉) for r in records: _assert_borrow_company_visible(r) record_ids = [r.id for r in records] by_id = {r.id: r for r in records} # --- 批量取流水,避免逐条查询 --- transfers = TransBorrowTransfer.query.filter( TransBorrowTransfer.borrow_id.in_(record_ids) ).all() returns = TransBorrowReturn.query.filter( TransBorrowReturn.borrow_id.in_(record_ids) ).all() # --- 批量解析物料名(与 get_records 同口径,含 SKU 兜底) --- material_map = {} stock_ids_by_table = {} for r in records: if r.source_table and r.stock_id: stock_ids_by_table.setdefault(r.source_table, set()).add(r.stock_id) model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct} for table_name, ids in stock_ids_by_table.items(): ModelClass = model_map.get(table_name) if not ModelClass: continue for stock in ModelClass.query.options(joinedload(ModelClass.base)).filter( ModelClass.id.in_(ids)).all(): material_map[(table_name, stock.id)] = stock.base.name if stock.base else '' empty_sku = {r.sku for r in records if r.sku and not material_map.get((r.source_table, r.stock_id))} sku_name_map = {} if empty_sku: for ModelClass in (StockProduct, StockSemi, StockBuy): for stock in ModelClass.query.options(joinedload(ModelClass.base)).filter( ModelClass.sku.in_(empty_sku)).all(): if stock.sku not in sku_name_map and stock.base: sku_name_map[stock.sku] = stock.base.name # --- 批量解析归还人姓名(归还流水只存 returner_id,无姓名快照) --- returner_ids = {t.returner_id for t in returns if t.returner_id} user_name_map = {} if returner_ids: for u in SysUser.query.filter(SysUser.id.in_(returner_ids)).all(): user_name_map[u.id] = user_display_name(u) def _label(rec): name = material_map.get((rec.source_table, rec.stock_id)) or sku_name_map.get(rec.sku, '') return name or rec.sku or '' def _sort_key(e): # ★ 必须按**真实 datetime** 排序,不能用展示用的 '%Y-%m-%d %H:%M:%S' 字符串: # 后者截断到秒,同一秒内发生的多个动作(库管连续操作的常见情形, # 如「转交 A→B 紧接着转交 B→C」)会退化成并列,排序结果取决于 # 数据库返回顺序 —— 时间线会随机错乱。 # datetime.min 兜底:无时间的脏数据排到最后。 return (e['_dt'] or datetime.min, e['_seq']) events = [] for r in records: label = _label(r) qty = float(r.quantity or 0) # 借出事件 events.append({ 'type': 'borrow', 'time': r.borrow_time.strftime('%Y-%m-%d %H:%M:%S') if r.borrow_time else None, '_dt': r.borrow_time, 'borrow_id': r.id, 'sku': r.sku, 'material_name': label, 'quantity': qty, 'actor_name': r.borrower_name, # 借用人 'operator_name': r.dispatch_operator, # 发货库管 'remark': r.remark, '_seq': 0, }) # 报废事件(终态,无独立流水表) if r.status == 'scrapped': events.append({ 'type': 'scrap', 'time': r.return_time.strftime('%Y-%m-%d %H:%M:%S') if r.return_time else None, '_dt': r.return_time, 'borrow_id': r.id, 'sku': r.sku, 'material_name': label, 'quantity': qty - float(r.returned_quantity or 0), 'actor_name': r.borrower_name, 'operator_name': r.return_operator, 'remark': None, '_seq': 3, }) for t in transfers: rec = by_id.get(t.borrow_id) events.append({ 'type': 'transfer', 'time': t.transfer_time.strftime('%Y-%m-%d %H:%M:%S') if t.transfer_time else None, '_dt': t.transfer_time, 'borrow_id': t.borrow_id, 'sku': rec.sku if rec else None, 'material_name': _label(rec) if rec else '', 'quantity': float(t.transfer_qty or 0), 'actor_name': t.to_user_name, # 接收人(转交后的持有人) 'from_name': t.from_user_name, # 转出人 'operator_name': t.operator_name, 'remark': t.remark, '_seq': 1, }) for rt in returns: rec = by_id.get(rt.borrow_id) events.append({ 'type': 'return', 'time': rt.return_time.strftime('%Y-%m-%d %H:%M:%S') if rt.return_time else None, '_dt': rt.return_time, 'borrow_id': rt.borrow_id, 'sku': rec.sku if rec else None, 'material_name': _label(rec) if rec else '', 'quantity': float(rt.return_qty or 0), 'actor_name': user_name_map.get(rt.returner_id), # 实际归还人 'operator_name': rt.operator_name, 'remark': None, '_seq': 2, }) events.sort(key=_sort_key, reverse=True) for e in events: e.pop('_seq', None) e.pop('_dt', None) return { 'borrow_no': borrow_no, 'events': events, 'records': [r.to_dict() for r in records], } @staticmethod def get_borrow_history(borrow_id): """ 一张借出单的完整流转视图:主表快照 + 转交链 + 逐次归还明细。 ★ 含行级公司隔离(Fail-Closed),与转交同口径 —— 否则「能看到哪条记录」与「能转交哪条记录」两套标准会分叉。 异常:ValueError(记录不存在 / 越权 / 隔离链路断裂),由调用方转成 4xx。 """ record = TransBorrow.query.get(borrow_id) if not record: raise ValueError("借出记录不存在") _assert_borrow_company_visible(record) return { 'record': record.to_dict(), 'transfers': TransService.get_transfer_history(borrow_id), 'returns': TransService.get_return_history(borrow_id), } @staticmethod def get_records(page=1, limit=10, status='all', keyword=None, search_type='all', borrower_name=None, start_date=None, end_date=None, advanced_filters=None): """ 获取借还记录列表(按单号 borrow_no 维度分页,避免明细撑爆 pageSize) 实现思路(三步走): 步骤 1: 构造 GROUP BY borrow_no 的"单号维度视图" subquery (包含 borrow_no + sort_key + 状态聚合,全部聚合都在这里完成) 步骤 2: 用一个【纯净的列查询】从 subquery 中分页得到 page_borrow_nos → SELECT 只有 borrow_no 一列,【主查询无 GROUP BY】 → 避免触发 PG "column must appear in GROUP BY" 严格模式 步骤 3: 用 page_borrow_nos 拉明细 + 预加载 material_name 状态过滤按"单号聚合"判定: - borrowed: 单号下至少有一条 is_returned=False - returned: 单号下所有明细 is_returned=True 日期范围按「借出时间」过滤,边界补全时分秒以解决零点截断问题。 """ # 日期补全:与出库记录同口径 if end_date and len(str(end_date).strip()) == 10: end_date = f"{str(end_date).strip()} 23:59:59" if start_date and len(str(start_date).strip()) == 10: start_date = f"{str(start_date).strip()} 00:00:00" try: # ==================================================================== # 步骤 1a:构造"单号维度"基础子查询(GROUP BY borrow_no 在这里完成) # ==================================================================== # 单号 + 排序键(最早 expected_return_time)—— 这一层只含 2 列 + GROUP BY order_subq = ( db.session.query( TransBorrow.borrow_no.label('borrow_no'), # 有限期梯队内:单号内最早预计归还时间 func.min(TransBorrow.expected_return_time).label('sort_key'), # 无限期梯队内:单号内最早借出时间(呆滞借用盘点) func.min(TransBorrow.borrow_time).label('min_borrow_time'), # 单内是否含"有限期"明细:任一 expected_return_time 非空 → 1(有限期单排前) func.max(case((TransBorrow.expected_return_time.isnot(None), 1), else_=0)).label('has_finite') ) .group_by(TransBorrow.borrow_no) .subquery() ) # 状态聚合子查询(也是 GROUP BY borrow_no) status_subq = ( db.session.query( TransBorrow.borrow_no.label('borrow_no'), func.sum( case((TransBorrow.is_returned == False, 1), else_=0) ).label('unreturned_count') ) .group_by(TransBorrow.borrow_no) .subquery() ) # ==================================================================== # 步骤 1b:构造关键词命中单号子查询(保留原全部 search_type 逻辑) # ==================================================================== keyword_conditions = None 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) # 把"命中的单号"独立成 subquery,供主查询做 IN 过滤 keyword_borrow_nos_subq = None if keyword_conditions is not None: keyword_borrow_nos_subq = ( db.session.query(TransBorrow.borrow_no) .filter(keyword_conditions) .distinct() .subquery() ) # ==================================================================== # 【行级数据隔离】基于 JWT 多租户公司过滤 # 通过 stock 表关联到 MaterialBase,确保只返回本公司借还记录 # ==================================================================== company_borrow_nos_subq = None company_limit = get_current_company_filter() if company_limit is not None: buy_nos = db.session.query(TransBorrow.borrow_no).join( StockBuy, and_(TransBorrow.stock_id == StockBuy.id, TransBorrow.source_table == 'stock_buy') ).join(MaterialBase, StockBuy.base_id == MaterialBase.id).filter( MaterialBase.company_name == company_limit ) semi_nos = db.session.query(TransBorrow.borrow_no).join( StockSemi, and_(TransBorrow.stock_id == StockSemi.id, TransBorrow.source_table == 'stock_semi') ).join(MaterialBase, StockSemi.base_id == MaterialBase.id).filter( MaterialBase.company_name == company_limit ) product_nos = db.session.query(TransBorrow.borrow_no).join( StockProduct, and_(TransBorrow.stock_id == StockProduct.id, TransBorrow.source_table == 'stock_product') ).join(MaterialBase, StockProduct.base_id == MaterialBase.id).filter( MaterialBase.company_name == company_limit ) company_borrow_nos_subq = buy_nos.union( semi_nos, product_nos ).subquery() # ==================================================================== # 步骤 2:纯净列查询分页(SELECT 只有 order_subq.c.borrow_no 一列) # ==================================================================== borrow_no_q = db.session.query(order_subq.c.borrow_no) # ★ 数据权限:普通用户只看“借用人=本人姓名(不含账号前缀)”的借还记录; # 兼容库里存成“姓名/xiaolongxia”全名(姓名 + '/' 前缀)的情况 if borrower_name: own_borrow_nos_subq = ( db.session.query(TransBorrow.borrow_no) .filter(or_( TransBorrow.borrower_name == borrower_name, TransBorrow.borrower_name.like(f"{borrower_name}/%") )) .distinct() .subquery() ) borrow_no_q = borrow_no_q.filter( order_subq.c.borrow_no.in_(own_borrow_nos_subq) ) # 关键词过滤 if keyword_borrow_nos_subq is not None: borrow_no_q = borrow_no_q.filter( order_subq.c.borrow_no.in_(keyword_borrow_nos_subq) ) # 公司隔离过滤 if company_borrow_nos_subq is not None: borrow_no_q = borrow_no_q.filter( order_subq.c.borrow_no.in_(company_borrow_nos_subq) ) # ==================================================================== # ★ 高级筛选:父级字段直接过滤;子级字段(SKU/物料名称)走 # 「命中单号子查询 → 按单号 IN」的 EXISTS 语义, # 避免在 GROUP BY 前收窄明细范围而丢失同单的兄弟明细。 # ==================================================================== if advanced_filters: from app.utils.advanced_filter import ( build_predicate, apply_child_condition, ) parent_map = { 'no': TransBorrow.borrow_no, 'operator': TransBorrow.borrower_name, 'borrower_name': TransBorrow.borrower_name, } child_map = {'sku': TransBorrow.sku} material_stock_models = [ (StockBuy, 'stock_buy'), (StockSemi, 'stock_semi'), (StockProduct, 'stock_product'), ] for cond in advanced_filters: field = cond.get('field') if field in parent_map: # 父级字段:标准 SQL 谓词即可 p = build_predicate(cond, parent_map) if p is not None: borrow_no_q = borrow_no_q.filter( order_subq.c.borrow_no.in_( db.session.query(TransBorrow.borrow_no) .filter(p).distinct() ) ) continue if field in child_map or field == 'material_name': # ★ 子级字段:正/负操作符语义分派(否定 → 整单排除) borrow_no_q = apply_child_condition( borrow_no_q, order_subq.c.borrow_no, TransBorrow, cond, child_map, material_stock_models, ) continue # 日期范围过滤(按借出时间;边界已在入口补全时分秒) if start_date: borrow_no_q = borrow_no_q.filter(order_subq.c.borrow_no.in_( db.session.query(TransBorrow.borrow_no) .filter(TransBorrow.borrow_time >= start_date) .distinct() )) if end_date: borrow_no_q = borrow_no_q.filter(order_subq.c.borrow_no.in_( db.session.query(TransBorrow.borrow_no) .filter(TransBorrow.borrow_time <= end_date) .distinct() )) # 状态过滤(按"单号聚合"判定) if status == 'borrowed': # 单号下至少一条未还 borrow_no_q = borrow_no_q.filter( order_subq.c.borrow_no.in_( db.session.query(status_subq.c.borrow_no) .filter(status_subq.c.unreturned_count > 0) ) ) elif status == 'returned': # 单号下所有明细都已归还 borrow_no_q = borrow_no_q.filter( order_subq.c.borrow_no.in_( db.session.query(status_subq.c.borrow_no) .filter(status_subq.c.unreturned_count == 0) ) ) # ★ 默认排序(多级复合,符合"优先关注快到期/逾期"业务): # 1) 有限期单(含 expected_return_time)排前,无限期单排后 # 2) 有限期内按最早预计归还时间 ASC(越快到期/逾期越久越靠前) # 3) 无限期内按最早借出时间 ASC(借出越久越靠前,暴露长期未还的呆滞借用) borrow_no_q = borrow_no_q.order_by( case((order_subq.c.has_finite == 0, 1), else_=0).asc(), nullslast(asc(order_subq.c.sort_key)), # ★ 无限期梯队内按借出时间**从近到远**(desc)。 # 原实现是 asc「借出越久越靠前」,设计意图是暴露呆滞借用; # 业务方明确要求改为从近到远,故反转。 desc(order_subq.c.min_borrow_time) ) # 分页(基准 = borrow_no 单号数) pagination = borrow_no_q.paginate(page=page, per_page=limit, error_out=False) # ★ pagination.items 是 SQLAlchemy Row 对象,psycopg2 无法直接 adapt Row # 用 isinstance(row, tuple) 不够(2.x 的 Row 不一定继承 tuple) # 用 hasattr(row, '_mapping') 兜底,强制提取 row[0] 拿到纯字符串 page_borrow_nos = [ row[0] if isinstance(row, tuple) or hasattr(row, '_mapping') else row for row in pagination.items ] total_orders = pagination.total # ★ 单号总数(修复前是明细数,分页错乱根因) if not page_borrow_nos: return { 'items': [], 'total': total_orders, 'page': page, 'limit': limit } # ==================================================================== # 步骤 3:按当前页 borrow_no 集合一次性拉出所有明细 # ==================================================================== detail_records = ( TransBorrow.query .filter(TransBorrow.borrow_no.in_(page_borrow_nos)) .order_by(TransBorrow.borrow_no.asc(), TransBorrow.id.asc()) .all() ) # ============================================================ # ★ 批量预加载物料名称(三步:收集ID → 批量JOIN → SKU兜底) # ============================================================ items_with_names = [] items = detail_records if items: # 步骤 1:收集所有 (source_table, stock_id) 对 stock_ids_by_table = {'stock_buy': set(), 'stock_semi': set(), 'stock_product': set()} for item in items: if item.source_table in stock_ids_by_table and item.stock_id: stock_ids_by_table[item.source_table].add(item.stock_id) # 步骤 2:批量查询库存表并 JOIN MaterialBase stock_map = {} # { ('stock_buy', 101): '物料名称', ... } model_map = { 'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct } for table_name, ids in stock_ids_by_table.items(): if not ids: continue ModelClass = model_map.get(table_name) if not ModelClass: continue stocks = ModelClass.query.options( joinedload(ModelClass.base) ).filter(ModelClass.id.in_(ids)).all() for stock in stocks: name = stock.base.name if stock.base else '' stock_map[(table_name, stock.id)] = name # 步骤 3(前置):收集 SKU 兜底候选集 empty_sku_set = set() for item in items: name = stock_map.get((item.source_table, item.stock_id), '') if not name and item.sku: empty_sku_set.add(item.sku) # 步骤 3(前置):SKU 兜底批量查询 # 场景:库存记录被跨表转移(删旧建新)时,trans_borrow.stock_id 指向孤立记录 # 通过 sku 在三张库存表中查找任意匹配,再通过 base_id 获取 MaterialBase.name sku_name_map = {} if empty_sku_set: for ModelClass in [StockProduct, StockSemi, StockBuy]: stocks = ModelClass.query.options( joinedload(ModelClass.base) ).filter( ModelClass.sku.in_(empty_sku_set) ).all() for stock in stocks: if stock.sku not in sku_name_map and stock.base: sku_name_map[stock.sku] = stock.base.name # 步骤 3:为每条记录注入 material_name(含 SKU 兜底) for item in items: item_dict = item.to_dict() material_name = stock_map.get((item.source_table, item.stock_id), '') if not material_name and item.sku: material_name = sku_name_map.get(item.sku, '') item_dict['material_name'] = material_name items_with_names.append(item_dict) # ==================================================================== # ★ 恢复业务排序(此前被静默丢弃) # # detail_records 是按 `borrow_no ASC` 重新拉取的,而单号形如 # BOR-YYYYMMDD-NNNN —— 它的**字母序恰好等于借出日期序**。 # 于是上面步骤 2 辛苦算出的「逾期优先」分页顺序(page_borrow_nos) # 被这次重排**整套覆盖**:无限期单排到了最前,有限期里 10-01 排在 # 11-01 之后,看起来完全随机。那份 ORDER BY 一直是死代码。 # # 这里按 page_borrow_nos 的顺序还原输出。明细内部仍按 id 升序 # (同一次发货写入的明细,id 序即扫码顺序,便于阅读)。 # ==================================================================== _order_idx = {bn: i for i, bn in enumerate(page_borrow_nos)} items_with_names.sort( key=lambda d: (_order_idx.get(d.get('borrow_no'), len(_order_idx)), d.get('id') or 0) ) return { 'items': items_with_names, 'total': total_orders, 'page': page, 'limit': limit } except Exception as e: # ★ 捕鼠器:把任何 SQL/运行时错误以 500 + traceback 返回,避免静默吞噬 import traceback return { 'code': 500, 'msg': str(e), 'trace': traceback.format_exc(), 'items': [], 'total': 0, 'page': page, 'limit': limit }