import uuid # .material -> .base refactor checked from datetime import datetime, timezone, timedelta from sqlalchemy import or_, func, desc, and_ from sqlalchemy.orm import joinedload from app.extensions import db, beijing_time from app.models.outbound import TransOutbound, OutboundApproval # 引入所有库存模型以进行查询 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.models.transaction import TransRepair # 引入系统用户表 from app.models.system import SysUser # Track 联动通知(出库 → Track 标记"已出库") from app.services.track_webhook_service import notify_track, get_current_operator class OutboundService: @staticmethod def generate_outbound_no(): """ 生成出库单号: OUT-yyyyMMdd-HHmm-当日流水(4位) 例如: OUT-20260205-1558-0001 """ beijing_tz = timezone(timedelta(hours=8)) now = datetime.now(beijing_tz) date_str = now.strftime('%Y%m%d') time_str = now.strftime('%H%M') prefix = f"OUT-{date_str}-" existing_count = db.session.query(func.count(func.distinct(TransOutbound.outbound_no))) \ .filter(TransOutbound.outbound_no.like(f"{prefix}%")).scalar() sequence = existing_count + 1 return f"OUT-{date_str}-{time_str}-{sequence:04d}" @staticmethod def get_stock_by_barcode(barcode, reserved_map=None): """ 根据扫码内容查找对应的库存物品,并附带价格信息 reserved_map: {(source_table, stock_id): 本单预占量},可选。 传入后 available_quantity 返回「有效可用量」 (= 实时值 + 本单预占),见 _format_scan_result。 """ if not barcode: return None clean_code = barcode.strip() def get_price(item, table_type): if table_type == 'stock_product': return float(item.sale_price) if item.sale_price else 0 elif table_type == 'stock_buy': return float(item.pre_tax_unit_price) if item.pre_tax_unit_price else 0 return 0 # ★ 行级公司隔离:扫码出库/借库只能命中本公司的库存(超管/跨域不受限) from app.utils.decorators import get_current_company_filter from app.models.base import MaterialBase company_limit = get_current_company_filter() prod_q = StockProduct.query.filter( or_(StockProduct.barcode == clean_code, StockProduct.sku == clean_code) ) if company_limit is not None: prod_q = prod_q.join(MaterialBase, StockProduct.base_id == MaterialBase.id) \ .filter(MaterialBase.company_name == company_limit) prod = prod_q.first() if prod: OutboundService._assert_scan_allocatable(prod) res = OutboundService._format_scan_result(prod, 'stock_product', reserved_map) res['price'] = get_price(prod, 'stock_product') return res semi_q = StockSemi.query.filter( or_(StockSemi.barcode == clean_code, StockSemi.sku == clean_code) ) if company_limit is not None: semi_q = semi_q.join(MaterialBase, StockSemi.base_id == MaterialBase.id) \ .filter(MaterialBase.company_name == company_limit) semi = semi_q.first() if semi: OutboundService._assert_scan_allocatable(semi) res = OutboundService._format_scan_result(semi, 'stock_semi', reserved_map) res['price'] = 0 return res buy_q = StockBuy.query.filter( or_(StockBuy.barcode == clean_code, StockBuy.sku == clean_code) ) if company_limit is not None: buy_q = buy_q.join(MaterialBase, StockBuy.base_id == MaterialBase.id) \ .filter(MaterialBase.company_name == company_limit) buy = buy_q.first() if buy: OutboundService._assert_scan_allocatable(buy) res = OutboundService._format_scan_result(buy, 'stock_buy', reserved_map) res['price'] = get_price(buy, 'stock_buy') return res # 查询维修单表 (按SKU或序列号查询) # ★ 维修单不走库存 status 体系,用自身的 repair_status 做同等准入判断: # 已出库(货已交回客户)、报废转出(实物已销毁)都不该再被扫出库。 # 原实现只排除 '已出库',报废转出的维修件仍可被扫走。 repair = TransRepair.query.filter( or_(TransRepair.sku == clean_code, TransRepair.serial_number == clean_code) ).filter( TransRepair.repair_status.notin_(['已出库', '报废转出']) ).first() if repair: res = { 'id': repair.id, 'sku': repair.sku, 'name': repair.material_name or "维修件", 'spec_model': "", 'category': "", 'material_type': "", 'source_table': 'trans_repair', 'stock_quantity': 1, # 维修单不参与预占,有效可用量恒等于实时值; # 字段形状与 _format_scan_result 对齐,避免前端拿到 undefined 'available_quantity': 1, 'raw_available_quantity': 1, 'reserved_quantity': 0, 'batch_number': repair.serial_number or '', 'serial_number': repair.serial_number or '', 'warehouse_location': repair.customer_location or '', 'barcode': repair.sku, 'price': float(repair.sale_price) if repair.sale_price else 0 } return res return None @staticmethod def _assert_scan_allocatable(item): """ 扫码准入校验:状态异常的实物一律拦在扫码环节。 ★ 为什么扫码就拦,而不只在提交时拦(verify_scanned 已有一道): 提交时的校验是整单粒度的,报错只说「物料 X 状态异常」,工人得自己 在一整单里找是哪一件。扫码即拦能立刻定位到手上这一件。 两道校验用同一个 is_allocatable() 判定,语义不会分叉。 ★ 与分配器的关系:分配器只决定"申请时能不能把这行算进候选", 而冻结可能发生在「申请已通过、工人正在扫」的窗口内 —— 扫码这道是覆盖该窗口的必要补充。 """ from app.services.inventory_reservation import is_allocatable if not is_allocatable(item): # status 为空时给出可读文案,避免报出 "当前为 " 这种半截话 status = (getattr(item, 'status', None) or '').strip() or '未设置' raise ValueError(f"物料状态异常(当前为 {status}),禁止出库") @staticmethod def _format_scan_result(item, table_name, reserved_map=None): """ reserved_map: {(source_table, stock_id): 本单预占量},由调用方按 request_id 查得(见 api/v1/outbound.py 的 _own_reserved_index)。 ★ available_quantity 返回的是「有效可用量」= 实时可用量 + 本单预占量。 本单自己锁掉的货当然应该能扫 —— 若直接返回实时值,被本单占满的行 会显示 0,工人扫不进去。该值与后端执行阶段 restore_then_deduct() 释放预占后用于校验的数字精确相等。 实时原值另以 raw_available_quantity 返回备查。 """ base_name = "" base_spec = "" base_cat = "" base_type = "" if hasattr(item, 'base') and item.base: base_name = item.base.name base_spec = item.base.spec_model base_cat = item.base.category base_type = item.base.material_type if not base_name and hasattr(item, 'base_id') and item.base_id: try: base_info = MaterialBase.query.get(item.base_id) if base_info: base_name = base_info.name base_spec = base_info.spec_model base_cat = base_info.category base_type = base_info.material_type except Exception: pass if not base_name and hasattr(item, 'base') and item.base: base_name = item.base.name stock_qty = float(item.stock_quantity) if item.stock_quantity else 0 raw_avail = float(item.available_quantity) if item.available_quantity else 0 # ★ 本单预占回加(别人单子的预占不回加,防超卖能力不丢) reserved = float((reserved_map or {}).get((table_name, item.id), 0) or 0) return { 'id': item.id, 'sku': item.sku, 'name': base_name or "未知物品", 'spec_model': base_spec or "", 'category': base_cat or "", 'material_type': base_type or "", 'source_table': table_name, 'stock_quantity': stock_qty, # 有效可用量:本单可扫的上限 'available_quantity': raw_avail + reserved, # 实时原值与本单预占量,备查/展示用 'raw_available_quantity': raw_avail, 'reserved_quantity': reserved, 'batch_number': getattr(item, 'batch_number', ''), 'warehouse_location': getattr(item, 'warehouse_location', ''), 'barcode': getattr(item, 'barcode', '') } @staticmethod def create_outbound_batch(data, operator_name='System'): items = data.get('items', []) if not items: raise ValueError("出库商品列表不能为空") outbound_no = OutboundService.generate_outbound_no() common_data = { 'outbound_no': outbound_no, 'consumer_name': data.get('consumer_name'), 'outbound_type': data.get('outbound_type', 'SALES'), 'signature_path': data.get('signature_path'), 'operator_name': operator_name, 'remark': data.get('remark'), # ⚠ applicant_id 不在这里赋值 —— 此刻 approval 尚未查询(见下方 # 第 2 步「强制按单出库」),在这里引用它会直接 UnboundLocalError。 # 待 approval 取出并校验后再补进本字典。 } beijing_tz = timezone(timedelta(hours=8)) current_time = datetime.now(beijing_tz).replace(tzinfo=None) # ================================================================== # ★ 强制按单出库(Fail-Closed):所有出库必须关联有效的出库申请单 # # 背景:改造前 request_id 为空时会跳过预占再平衡,落到所谓「散单」 # 分支,而该分支的库存扣减逻辑从未落地 —— 旧注释点名的 # _apply_reservation_override() 在全仓并不存在。实测后果:散单出库 # 只写 TransOutbound 台账,stock_quantity / available_quantity # 分毫不动,同一批货可被反复出库而不减库存。 # # 前端已彻底移除散单入口(views/outbound/create.vue 在未选单时把 # 扫码框整块 disabled),但 HTTP 接口仍可被直接调用绕过 —— 故在此 # 后端硬阻断,防 API 级绕过。 # # ★ 强制按单同时保证库存扣减路径唯一: # 申请预占 → 扫码校验 → restore_then_deduct() # status 硬隔离(仅有「在库」可被分配)也依赖这条路径才全程有效。 # ================================================================== request_id = data.get('request_id') if not request_id: raise ValueError("非法操作:所有出库必须关联有效的出库申请单") approval = OutboundApproval.query.get(request_id) if not approval: raise ValueError(f"关联的审批单不存在 (ID: {request_id})") if approval.status != 1: status_map = {0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成'} current_status = status_map.get(approval.status, str(approval.status)) raise ValueError( f"关联的审批单状态不允许出库 (当前状态: {current_status})," f"仅已通过的审批单方可执行出库" ) # ★ 申请人从**关联审批单**带出。落这一列是为了让后续「退回 → 补发」能把 # 补发单挂回真正该拿东西的人名下 —— consumer_name 是扫码时自由填写的 # 领用人/客户名,不能作为依据。 # ⚠ 必须放在 approval 取出并校验**之后**:common_data 在上面就已构造, # 在那里引用 approval 会直接 UnboundLocalError(上线时踩过)。 common_data['applicant_id'] = approval.applicant_id model_map = { 'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct } # ★ Track 联动收集:(serial_number, source_table, quantity) track_notifications = [] # ================================================================== # ★ Phase 3:预占再平衡 —— 库存扣减的唯一路径 # # 申请阶段已把货预占在「申请时选定的批次」上。工人实际扫的可能是 # 同物料的**另一个批次**(物理覆盖),因此这里: # 1. 校验实扫的身份/数量未超出批准范围(base_id 主键,允许换批次) # 2. 释放全部预占 # 3. 对实扫批次扣减 available_quantity 与 stock_quantity # 之后主循环只写 TransOutbound 流水,不再重复扣库存。 # # ★ 本段已改为**无条件执行**:上方强制 request_id 必填,approval 恒非 # None。原「无关联审批单(散单)时跳过,走原有逐行扣减逻辑」的分支 # 已删除 —— 它正是「散单出库不扣库存」的成因(被跳过的这里就是全仓 # 唯一的扣减入口,而所谓的"原有逐行扣减逻辑"并不存在)。 # ================================================================== # ★ 自带 rollback:restore_then_deduct() 先释放预占、后校验可用量, # 中途抛错时 session 已带脏改动。它自身不 commit,若此处不兜底, # 脏状态会一路带到请求结束。校验失败一律整单回滚,单据保持 # status=1 可重试(或走撤回/驳回),不会产生半扣留状态。 try: from app.services.inventory_reservation import ( verify_scanned, restore_then_deduct, ) _approved = approval.get_items() # ★ Fail-Closed:只认库存类来源。只要一条库存物料都没扫到就整单失败。 # # 旧代码写作 `_scanned = [... if source_table != 'trans_repair']` # 再 `if _scanned:` —— 一旦为假,校验与释放被整体跳过,而下方仍 # 无条件执行 approval.status = 3。此时申请阶段 reserve_for_items() # 扣掉的 available_quantity 无人归还,且 status=3 没有任何释放入口 # (/close 要求 status==1、/withdraw 要求 status∈(0,1)),预占即 # 永久锁死 —— 幽灵库存。 # 现在直接要求 _scanned 非空:维修单等非库存来源一并被挡在门外。 _scanned = [i for i in items if i.get('source_table') in model_map] if not _scanned: raise ValueError("未扫描到有效的库存物料,出库失败,请检查扫码内容") verify_scanned(_scanned, _approved) restore_then_deduct(_scanned, _approved) except Exception: db.session.rollback() raise try: for item in items: source_table = item.get('source_table') stock_id = item.get('stock_id') quantity = float(item.get('quantity', 0)) unit_price = float(item.get('price', 0)) if quantity <= 0: raise ValueError(f"SKU {item.get('sku')} 的出库数量必须大于0") # 处理维修单出库 if source_table == 'trans_repair': repair = TransRepair.query.with_for_update().get(stock_id) if not repair: raise ValueError(f"维修单不存在 (ID: {stock_id})") # 更新维修单状态为已出库 repair.repair_status = '已出库' repair.shipping_date = current_time # 收集 Track 联动信息(维修单带 serial_number) track_notifications.append((getattr(repair, 'serial_number', None), source_table, quantity)) # 创建出库记录 new_record = TransOutbound( sku=item.get('sku'), source_table=source_table, stock_id=stock_id, barcode=item.get('barcode'), quantity=quantity, unit_price=unit_price, outbound_time=current_time, **common_data ) db.session.add(new_record) continue ModelClass = model_map.get(source_table) if not ModelClass: continue # ========================================================== # ★ Phase 3:库存扣减已由「预占 + 再平衡」统一处理 # # 流程(在下方 _apply_reservation_override 中完成): # 1. 校验实扫身份/数量落在批准范围内(base_id 主键匹配,允许换批次) # 2. 释放申请时锁定的全部批次(available_quantity 还回池子) # 3. 对实扫批次扣减 available_quantity 与 stock_quantity # # 因此此处**不再**直接扣减库存,避免与再平衡逻辑重复扣两次。 # ========================================================== stock_record = ModelClass.query.get(stock_id) if not stock_record: raise ValueError(f"库存记录不存在 (ID: {stock_id})") # 收集 Track 联动信息(库存表 serial_number = Track 身份证) track_notifications.append((getattr(stock_record, 'serial_number', None), source_table, quantity)) new_record = TransOutbound( sku=item.get('sku'), source_table=source_table, stock_id=stock_id, barcode=item.get('barcode'), quantity=quantity, unit_price=unit_price, outbound_time=current_time, # [新增] 记录出库时的库位快照 warehouse_location=getattr(stock_record, 'warehouse_location', None), **common_data ) db.session.add(new_record) # ★ 出库成功后更新审批单状态为"已完成" # approval 恒非 None(上方已强制 request_id 必填),无需再判空 approval.status = 3 # 3-已完成 # updated_at 会在 commit 时由 SQLAlchemy 自动更新 # ★ 先提交事务,释放所有行锁,避免 SMTP 调用延长锁持有时间 db.session.commit() # ★ 出库后通知 Track(发货出库 → Track 标记"已出库") # 仅在配置 TRACK_OUTBOUND_WEBHOOK_URL 时生效;notify_track 自身容错不阻断业务 try: from flask import current_app outbound_url = current_app.config.get('TRACK_OUTBOUND_WEBHOOK_URL') for sn, src_tbl, qty in track_notifications: if not sn: continue notify_track({ 'event': 'outbound.created', 'source_table': src_tbl, 'serial_number': str(sn), 'quantity': float(qty or 0), 'outbound_type': common_data['outbound_type'], 'operator': get_current_operator(), }, url=outbound_url) except Exception as e: import logging logging.getLogger(__name__).warning(f"⚠️ Track 出库通知失败: {e}") # ★ 出库后检查低库存预警(移到 commit 之后,避免事务内网络调用) try: from app.services.inventory_task import InventoryWarningService InventoryWarningService.check_and_send_warning_emails() except Exception as e: import logging logging.getLogger(__name__).warning(f"⚠️ 低库存预警检查失败: {e}") return outbound_no except Exception as e: db.session.rollback() raise e @staticmethod def get_grouped_list(page=1, per_page=10, keyword=None, search_type='all', start_date=None, end_date=None, company=None, consumer_name=None, advanced_filters=None): """ 查询出库记录(按出库单号分组),包含详细物品信息 支持跨表搜索:单号、领用人、SKU、物料名称、规格型号 search_type: all, no, name, sku, material_name, spec_model company: 可选的公司过滤参数 advanced_filters: 高级筛选条件列表 [{'field','operator','value'}, ...] """ # 日期补全:解决零点截断问题 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" # 1. 构建基础查询 # 如果有关键词,需要联表搜索物料名称和规格型号 if keyword: # 根据 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_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() # 合并三种来源的匹配单号 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) ) 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 # 【行级数据隔离】基于 JWT 多租户公司过滤 # 通过三个库存表路径,找到匹配公司的出库单号(排除 trans_repair,因其无 MaterialBase 关联) from app.utils.decorators import get_current_company_filter company_limit = get_current_company_filter() if company_limit is not None: buy_comp = 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.company_name == company_limit ).subquery() semi_comp = 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.company_name == company_limit ).subquery() prod_comp = 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.company_name == company_limit ).subquery() comp_all = db.session.query(buy_comp.c.outbound_no).union( db.session.query(semi_comp.c.outbound_no), db.session.query(prod_comp.c.outbound_no) ).subquery() stmt = db.session.query( TransOutbound.outbound_no, func.max(TransOutbound.outbound_time).label('max_time') ).group_by(TransOutbound.outbound_no) if keyword_conditions is not None: stmt = stmt.filter(keyword_conditions) # ==================================================================== # ★ 高级筛选:父级字段直接过滤,子级字段(SKU/物料名称)走 # 「命中单号子查询 → 按单号 IN」的 EXISTS 语义。 # # 绝不能写成 stmt.filter(TransOutbound.sku.ilike(...)): # 那样会在 GROUP BY 前收窄明细范围,展开行里的兄弟明细会丢失。 # ==================================================================== if advanced_filters: from app.utils.advanced_filter import ( build_predicate, apply_child_condition, ) # 父级字段:单号/操作人本身就在流水表上,直接 filter(否定操作符 # 走标准 SQL != / NOT LIKE 即可,语义无歧义) parent_field_map = { 'no': TransOutbound.outbound_no, 'operator': TransOutbound.operator_name, 'consumer_name': TransOutbound.consumer_name, 'outbound_type': TransOutbound.outbound_type, } # 子级字段:SKU 直接列 + 物料名称(需三表联查) child_field_map = {'sku': TransOutbound.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_field_map: p = build_predicate(cond, parent_field_map) if p is not None: stmt = stmt.filter(p) continue if field in child_field_map or field == 'material_name': # ★ 正/负操作符语义分派:否定 → 整单排除(NOT IN) stmt = apply_child_condition( stmt, TransOutbound.outbound_no, TransOutbound, cond, child_field_map, material_stock_models, ) continue # ★ 日期边界分别判断:用 BETWEEN 时若只传一侧会整段静默失效 if start_date: stmt = stmt.filter(TransOutbound.outbound_time >= start_date) if end_date: stmt = stmt.filter(TransOutbound.outbound_time <= end_date) # 【行级数据隔离】应用公司过滤到主查询 if company_limit is not None: stmt = stmt.filter(TransOutbound.outbound_no.in_(comp_all)) # ★ 数据权限:普通用户只看“领用人=本人姓名(不含账号前缀)”的出库记录; # 同时兼容库里存成“姓名/xiaolongxia”全名的记录(姓名 + '/' 前缀也命中) if consumer_name: # 注意:or_ 已在模块顶部导入,此处绝不可再写 `from sqlalchemy import or_` # —— 函数内出现对 or_ 的赋值(import 即赋值)会让 Python 把 or_ 视为 # 整个函数的局部变量,导致本函数中**位于该行之前**的所有 or_ 调用 # (keyword 搜索分支)抛 UnboundLocalError: referenced before assignment。 _own_out_nos = ( db.session.query(TransOutbound.outbound_no) .filter(or_( TransOutbound.consumer_name == consumer_name, TransOutbound.consumer_name.like(f"{consumer_name}/%") )) .distinct() .subquery() ) stmt = stmt.filter(TransOutbound.outbound_no.in_(_own_out_nos)) stmt = stmt.order_by(desc('max_time')) # 使用 distinct 确保跨表查询不重复 stmt = stmt.distinct() pagination = stmt.paginate(page=page, per_page=per_page, error_out=False) outbound_nos = [row.outbound_no for row in pagination.items] if not outbound_nos: return { 'items': [], 'total': 0, 'pages': 0, 'current_page': page } # 2. 查询详细记录 details = TransOutbound.query.filter(TransOutbound.outbound_no.in_(outbound_nos)).all() # 3. 组装数据并查询物品详情 grouped_map = {} # 映射表模型以便查询 model_map = { 'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct } # ========================================== # ★ 优化步骤 1:第一遍循环,单纯收集所有的 stock_id # ========================================== stock_ids_by_table = {'stock_buy': set(), 'stock_semi': set(), 'stock_product': set()} for d in details: if d.source_table in stock_ids_by_table and d.stock_id: stock_ids_by_table[d.source_table].add(d.stock_id) # ========================================== # ★ 优化步骤 2:发起批量查询,并强制 JOIN 基础物料表 # ========================================== # 格式: { ('stock_buy', 101): stock_obj, ... } preloaded_stocks = {} for table_name, ids in stock_ids_by_table.items(): if not ids: continue ModelClass = model_map[table_name] # 魔法在这里:in_() 一次性查出所有库存,joinedload 顺便把 base 表的数据一起拉回来 items = ModelClass.query.options( joinedload(ModelClass.base) ).filter(ModelClass.id.in_(ids)).all() for item in items: preloaded_stocks[(table_name, item.id)] = item # ========================================== # ★ 优化步骤 3:第二遍循环,纯内存拼装(极速) # ========================================== for d in details: ono = d.outbound_no if ono not in grouped_map: grouped_map[ono] = { 'outbound_no': ono, 'outbound_time': d.outbound_time.strftime('%Y-%m-%d %H:%M:%S'), 'outbound_type': d.outbound_type, 'consumer_name': d.consumer_name, 'operator_name': d.operator_name, 'signature_path': d.signature_path, 'remark': d.remark, 'total_amount': 0.0, 'items': [] } # --- 直接从内存字典中获取,O(1) 复杂度,绝对不触发 SQL --- item_name, item_spec, item_cat, item_type, batch_sn = "未知物品", "", "", "", "-" stock_item = preloaded_stocks.get((d.source_table, d.stock_id)) if stock_item: batch_sn = getattr(stock_item, 'batch_number', None) or getattr(stock_item, 'serial_number', None) or '-' # 因为前面用了 joinedload,这里调用 .base 瞬间返回,不会去查数据库 if stock_item.base: item_name = stock_item.base.name item_spec = stock_item.base.spec_model item_cat = stock_item.base.category item_type = stock_item.base.material_type # 计算金额 price = float(d.unit_price) if d.unit_price else 0 qty = float(d.quantity) subtotal = price * qty grouped_map[ono]['total_amount'] += subtotal returned = float(d.returned_quantity or 0) grouped_map[ono]['items'].append({ # ★ 退回功能所需:前端「退回」按钮要把本字段回传给 # POST /api/v1/inbound/stock/return-from-outbound 的 outbound_id。 # 注意它是**出库明细行**的主键,不是出库单号。 'id': d.id, 'sku': d.sku, 'name': item_name, 'spec_model': item_spec, 'category': item_cat, 'material_type': item_type, 'quantity': qty, # ★ 退回额度三件套:前端据此显示「已退 / 可退」并置灰已退满的行 'returned_quantity': returned, 'returnable_quantity': qty - returned, # ★ 原申请人ID:退回弹窗勾选「需要补发」时据此**精确预填**「补发给谁」。 # 为 NULL 表示该出库产生于本列上线之前(历史无从回填), # 前端不预填,由库管在选择器里明确指定。 'applicant_id': d.applicant_id, 'unit_price': price, 'subtotal': subtotal, 'batch_sn': batch_sn, # [新增] 出库时的库位快照(展示用) 'warehouse_location': getattr(d, 'warehouse_location', None) or '' }) # 4. 排序输出 result_list = [] for ono in outbound_nos: if ono in grouped_map: obj = grouped_map[ono] obj['items'].sort(key=lambda x: x['unit_price'], reverse=True) obj['total_amount'] = round(obj['total_amount'], 2) result_list.append(obj) return { 'items': result_list, 'total': pagination.total, 'pages': pagination.pages, 'current_page': page } class OutboundApprovalService: """出库审批服务""" @staticmethod def generate_request_no(): """ 生成审批单号: APR-OUT-yyyyMMdd-HHmm-当日流水(4位) """ beijing_tz = timezone(timedelta(hours=8)) now = datetime.now(beijing_tz) date_str = now.strftime('%Y%m%d') time_str = now.strftime('%H%M') prefix = f"APR-OUT-{date_str}-" from app.models.outbound import OutboundApproval latest = db.session.query(OutboundApproval.request_no).filter( OutboundApproval.request_no.like(f"{prefix}%") ).order_by(OutboundApproval.id.desc()).first() if latest: last_seq = int(latest[0].split('-')[-1]) sequence = last_seq + 1 else: sequence = 1 return f"APR-OUT-{date_str}-{time_str}-{sequence:04d}" @staticmethod def _items_require_approval(items): """ 明细中任一物料命中“出库/借库需审批”→ 需要审批。 申请明细只存 name/spec_model,故按 (name, spec_model) 反查启用物料判定。 """ from app.models.base import MaterialBase seen = set() for item in items: name = str(item.get('name') or '').strip() spec = str(item.get('spec_model') or '').strip() if not name: continue key = (name, spec) if key in seen: continue seen.add(key) hit = MaterialBase.query.filter( MaterialBase.name == name, MaterialBase.spec_model == spec, MaterialBase.is_enabled == True, MaterialBase.is_approval_required == True ).first() if hit: return True return False @staticmethod def create_request(applicant_id, items, allowed_approvers, remark=None, approver_id=None, outbound_type=None, force_approval=False): """ 创建出库审批单(申请阶段,直接存储前端传来的物料信息快照,不关联具体库存记录) Args: applicant_id: 申请人ID items: 出库物品明细列表,每个物品应包含: - name: 物料名称 (必填) - spec_model: 规格型号 (必填) - quantity: 计划出库数量 (必填) - warehouse_location: 库位 (可选) - remark: 物品备注 (可选) allowed_approvers: 允许审批的人员/角色列表 approver_id: 指定审批人ID(可选,传则覆盖 allowed_approvers) remark: 申请说明 Returns: OutboundApproval 实例 Raises: ValueError: 当 items 为空或缺少必填字段时抛出 """ from app.models.outbound import OutboundApproval # 校验 items 非空 if not items: raise ValueError("出库物品列表不能为空") # 校验每个物品的宏观字段 (name, spec_model, quantity) required_fields = ['name', 'spec_model', 'quantity'] for idx, item in enumerate(items): missing_fields = [f for f in required_fields if f not in item or str(item.get(f) or '').strip() == ''] if missing_fields: raise ValueError( f"第 {idx + 1} 条物品缺少必填字段: {', '.join(missing_fields)}。" f"必须包含: name, spec_model, quantity" ) try: qty = float(item.get('quantity', 0)) if qty <= 0: raise ValueError(f"第 {idx + 1} 条物品的出库数量必须大于0") except (TypeError, ValueError) as e: raise ValueError(f"第 {idx + 1} 条物品的 quantity 格式无效: {str(e)}") # ★ 需审批判定:库管代建(force_approval)或明细含需审批物料 → 走审批;否则默认自动通过 from app.services.approval_control import resolve_approval_control need_approval, flagged_materials = resolve_approval_control(items) if force_approval: need_approval = True if need_approval and not approver_id: if force_approval: raise ValueError("库管代建出库申请必须选择审批人后再提交") _names = ";".join(f"{m['name']}({m['spec_model'] or '-'})" for m in flagged_materials) raise ValueError(f"以下物料需审批出库/借库:{_names}。请选择审批人后再提交") # ★ 指定审批人模式:approver_id 覆盖 allowed_approvers if approver_id: allowed_approvers = [{"type": "user", "value": int(approver_id)}] elif not need_approval: allowed_approvers = [] # 免审批单不绑定审批人 request_no = OutboundApprovalService.generate_request_no() if need_approval: approval = OutboundApproval( request_no=request_no, applicant_id=applicant_id, remark=remark, outbound_type=outbound_type, # 申请时确定的出库类型,扫码出库时带出 status=0, # 待审批 ) else: # 默认不审批:创建即已通过(status=1),直接进入“待库管执行” # ★ approved_at 必须与 created_at(beijing_time) 同为 naive 本地时间: # approved_at 列是 timestamp without time zone,传 aware 时间会被驱动转成 UTC 存库,导致早 8 小时 approval = OutboundApproval( request_no=request_no, applicant_id=applicant_id, remark=remark, outbound_type=outbound_type, status=1, actual_approver_id=applicant_id, approved_at=beijing_time(), ) # ================================================================== # ★ Phase 1:库存预占 # # 改造前此处只存「要什么、要多少」的快照,不绑库存行,真正的 # available_quantity 扣减留到执行阶段 —— 于是多张申请可以同时 # claim 同一批货,等到扫码时才发现已被领走(超卖)。 # # 现在提交即预占:分配器把需求落到具体库存行、立即扣减 # available_quantity,并把 (stock_id, source_table, allocated_qty) # 连同 reserved 标记写进 items_json,驳回/撤回时据此原样归还。 # ================================================================== from app.services.inventory_reservation import reserve_for_items from app.utils.decorators import get_current_company_filter reserved_items, _shortages = reserve_for_items( items, company_limit=get_current_company_filter(), strict=True, ) approval.set_items(reserved_items) if allowed_approvers: approval.set_allowed_approvers(allowed_approvers) else: approval.allowed_approvers = '[]' db.session.add(approval) db.session.commit() # 仅需审批单通知审批人;免审批单静默进入“待库管执行” if need_approval: OutboundApprovalService._notify_new_request(approval, applicant_id, approver_id=approver_id) return approval @staticmethod def _get_emails_by_identifiers(applicant_id=None, role_codes=None): """ 根据用户ID或角色列表查询邮箱地址 Args: applicant_id: 用户ID (按 SysUser.id 查找) role_codes: 角色代码列表,如 ['ADMIN', 'WAREHOUSE_ADMIN'] Returns: 去重后的邮箱地址列表 """ emails = [] if applicant_id: user = SysUser.query.get(int(applicant_id)) if user and user.email: emails.append(user.email) if role_codes: for code in role_codes: users = SysUser.query.filter_by(role=code).all() for u in users: if u.email: emails.append(u.email) return list(set(emails)) @staticmethod def _notify_new_request(approval, applicant_id, approver_id=None): """发送新申请通知邮件给审批人和申请人(静默处理,不阻断主流程)""" try: from flask import current_app from app.utils.email_service import send_outbound_new_request_notify from app.models.system import SysUser applicant_name = '' applicant_emails = [] # 1. 收集申请人信息 if applicant_id: user = SysUser.query.get(int(applicant_id)) if user and user.email: applicant_emails.append(user.email) applicant_name = str(user.username).split('/')[0] if '/' in (user.username or '') else (user.username or str(applicant_id)) # 2. 收集审批人信息 approver_emails = [] if approver_id: user = SysUser.query.get(int(approver_id)) if user and user.email: approver_emails.append(user.email) else: # 兜底:按角色查询 approvers = approval.get_allowed_approvers() role_codes = [] for a in approvers: if a.get('type') == 'role': role_codes.append(a.get('value', '')) approver_emails = OutboundApprovalService._get_emails_by_identifiers(role_codes=role_codes) # 去重 all_emails = list(set(applicant_emails + approver_emails)) if not all_emails: current_app.logger.info(f"[Email] 审批单 {approval.request_no} 无收件人邮箱,跳过通知") return # 3. 获取物料明细 items = approval.get_items() # 4. 分别发送邮件 if applicant_emails: try: send_outbound_new_request_notify( to_emails=applicant_emails, request_no=approval.request_no, applicant_name=applicant_name, remark=f"您的出库申请已提交,等待审批。{approval.remark or ''}", items=items, is_applicant_notify=True ) except Exception as e: current_app.logger.error(f"[Email] 通知申请人失败: {e}") if approver_emails: try: send_outbound_new_request_notify( to_emails=approver_emails, request_no=approval.request_no, applicant_name=applicant_name, remark=approval.remark or '', items=items, is_applicant_notify=False ) except Exception as e: current_app.logger.error(f"[Email] 通知审批人失败: {e}") except Exception as e: try: from flask import current_app current_app.logger.error(f"[Email] 发送新申请通知邮件失败: {e}") except RuntimeError: import logging logging.getLogger(__name__).error(f"[Email] 发送新申请通知邮件失败: {e}") @staticmethod def can_approve(approval, user_id, user_role): """ 检查用户是否有权限审批 Args: approval: OutboundApproval 实例 user_id: 用户ID user_role: 用户角色 Returns: bool, 是否有权限 """ approvers = approval.get_allowed_approvers() # 超级管理员可以直接审批 if user_role and user_role.upper() == 'SUPER_ADMIN': return True # 拥有 outbound_approval:operation 权限的用户可以审批任意出库单 # (前端按钮以此权限控制显示,后端也必须对齐) from app.models.system import SysRolePermission has_op = SysRolePermission.query.filter( SysRolePermission.role_code == user_role, SysRolePermission.target_code.in_(['outbound_approval:operation', 'outbound_approval:*']) ).first() is not None if has_op: return True for approver in approvers: approver_type = approver.get('type', '') approver_value = approver.get('value', '') if approver_type == 'user' and str(approver_value) == str(user_id): return True if approver_type == 'role' and approver_value == user_role: return True return False @staticmethod def approve(request_id, user_id, user_role, action='approve', reject_reason=None): """ 执行审批操作 Args: request_id: 审批单ID user_id: 审批人ID user_role: 审批人角色 action: 'approve' 通过, 'reject' 驳回 reject_reason: 驳回原因 Returns: (success: bool, message: str, approval: OutboundApproval or None) """ from app.models.outbound import OutboundApproval # ★ 统一取 naive 北京时间,与 created_at(beijing_time) 同口径 current_time = beijing_time() approval = OutboundApproval.query.get(request_id) if not approval: return False, "审批单不存在", None if approval.status != 0: return False, f"审批单状态已更新,无法重复审批 (当前状态: {approval.status})", None if not OutboundApprovalService.can_approve(approval, user_id, user_role): return False, "您没有审批此单的权限", None try: if action == 'approve': approval.status = 1 # 已通过 approval.actual_approver_id = user_id approval.approved_at = current_time # 通过后预占继续保留:货已被本单锁定,直到扫码执行时才释放并扣减 elif action == 'reject': # ★ Phase 2:驳回即释放预占,把 available_quantity 还回池子, # 否则这批货会被一张永远不会执行的单永久占住。 from app.services.inventory_reservation import release_reserved release_reserved(approval.get_items()) approval.status = 2 # 已驳回 approval.reject_reason = reject_reason else: return False, "无效的审批操作", None db.session.commit() # ★ 审批成功后,发送邮件通知仓库管理员 OutboundApprovalService._notify_approval_result(approval, user_id, action) return True, "审批成功", approval except Exception as e: db.session.rollback() return False, f"审批失败: {str(e)}", None @staticmethod def _notify_approval_result(approval, approver_id, action): """发送审批结果通知邮件(静默处理,不阻断主流程)""" import logging logger = logging.getLogger(__name__) try: from app.utils.email_service import send_outbound_approval_result_notify, send_outbound_dispatch_notify from app.models.system import SysUser as SU # 1. 提取申请人信息(供两个分支使用) applicant_name = '' applicant_emails = [] if approval.applicant_id: user = SU.query.get(approval.applicant_id) if user: applicant_name = str(user.username).split('/')[0] if '/' in (user.username or '') else (user.username or '') if user.email: applicant_emails.append(user.email) # 2. 提取物料明细(供通过分支使用) items = approval.get_items() if approval else [] # 3. 分支逻辑 if action == 'approve': # 3.1 通知库管(带明细) warehouse_role_codes = ['WAREHOUSE_MGR', 'OUTBOUND'] warehouse_emails = OutboundApprovalService._get_emails_by_identifiers(role_codes=warehouse_role_codes) if warehouse_emails: try: send_outbound_dispatch_notify( to_emails=warehouse_emails, request_no=approval.request_no, applicant_name=applicant_name, items=items ) except Exception as e: logger.error(f"[Email] 通知库管失败: {e}") # 3.2 通知申请人(审批通过,带完整物料清单) if applicant_emails: try: send_outbound_dispatch_notify( to_emails=applicant_emails, request_no=approval.request_no, applicant_name=applicant_name, items=items ) except Exception as e: logger.error(f"[Email] 通知申请人(通过)失败: {e}") elif action == 'reject': # 3.3 通知申请人(已驳回) if applicant_emails: try: send_outbound_approval_result_notify( to_emails=applicant_emails, request_no=approval.request_no, is_passed=False, reject_reason=approval.reject_reason or '未说明原因', applicant_name=applicant_name ) except Exception as e: logger.error(f"[Email] 通知申请人驳回失败: {e}") else: logger.warning("[Email] 申请人无邮箱,无法发送驳回通知") except Exception as e: import traceback traceback.print_exc() logger.error(f"[Email] 外层发送异常: {e}") @staticmethod def close_request(request_id, user_id, user_role): """ 撤回 / 作废审批单(状态 1-已通过 → 4-已完结) 适用场景:已通过但尚未(或未完整)执行的单据,申请人或管理者撤回, 使其从"已审批通过"列表中消失,并**释放预占的库存**。 ★ 修复说明(两处严重缺陷): 1. 原先只改状态、不释放预占 —— 申请阶段 reserve_for_items() 扣掉的 available_quantity 就此永久泄漏,货被一张作废单锁死,谁也领不走。 现调用 release_reserved() 按 items_json 原样归还。 2. 原先无执行守卫 —— status=1 的单无论是否已出过货都能被"完结", 会把一张已部分执行的单标成"已完结"。 现明确只允许「已通过且未执行」的单据撤回。 Args: request_id: 审批单ID user_id: 操作人ID user_role: 操作人角色 Returns: (success: bool, message: str, approval: OutboundApproval or None) """ from app.models.outbound import OutboundApproval approval = OutboundApproval.query.get(request_id) if not approval: return False, "审批单不存在", None # ★ 执行守卫:执行成功后 create_outbound_batch 会把 status 置为 3, # 故此处的「仅 status==1 可撤回」本身就是执行守卫 —— # 已执行(3)或已撤回(4)的单都无法再次撤回。 # 不额外查流水表:request_no(APR-OUT-…) 与 outbound_no(OUT-…) 格式不同、 # 无关联字段,按单号比对是无效的。 if approval.status != 1: status_map = {0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成', 4: '已完结'} return False, ( f"仅「已通过(待执行)」的申请单可撤回 " f"(当前状态: {status_map.get(approval.status, approval.status)})" ), None # 权限检查:超级管理员、审批人 或 拥有出库操作权限(库管/主管) if not OutboundApprovalService.can_approve(approval, user_id, user_role): # 放宽:拥有 outbound_create:operation 的用户(库管)也可撤回 from app.models.system import SysRolePermission has_outbound_op = SysRolePermission.query.filter( SysRolePermission.role_code == user_role, SysRolePermission.target_code.in_(['outbound_create:operation', 'outbound_create:*']) ).first() is not None if not has_outbound_op: return False, "您没有撤回此单的权限", None return OutboundApprovalService._release_and_close(approval, user_id) # ------------------------------------------------------------------ # ★ 申请人撤回自己的申请单(严格职责分离) # # 与 close_request 的区别: # · close_request —— 管理路径,需 outbound_approval 等权限,仅 status==1 # · withdraw_request —— 申请人路径,仅校验「本人」,status 0 或 1 均可 # # 为什么单独开一条路径而不是在 close_request 里加 if 分支: # 权限模型不同(管理角色 vs 单据归属),混在一个函数里容易在后续修改中 # 互相影响 —— 这正是需要避免的访问控制风险。两条路径共用底层释放逻辑 # _release_and_close,实现上不重复。 # # 状态说明: # · status==0(待审批):提交时已预占库存,撤回同样需要释放; # · status==1(已通过待执行):释放预占; # · status>=2(已驳回/已完成/已撤回):无可撤回内容,拒绝。 # ------------------------------------------------------------------ WITHDRAWABLE_STATUS = (0, 1) @staticmethod def withdraw_request(request_id, user_id, user_role=None): """ 申请人撤回自己的申请单。 权限:仅单据本人;库管/主管/超管(is_privileged_viewer)可代撤。 返回 (success, message, approval) """ from app.models.outbound import OutboundApproval from app.utils.decorators import is_privileged_viewer approval = OutboundApproval.query.get(request_id) if not approval: return False, "申请单不存在", None # ★ 归属校验:非本人且非特权 → 拒绝(不漏出任何单据信息) if not is_privileged_viewer() and int(approval.applicant_id) != int(user_id): return False, "无权撤回他人的申请单", None if approval.status not in OutboundApprovalService.WITHDRAWABLE_STATUS: status_map = {0: '待审批', 1: '已通过(待执行)', 2: '已驳回', 3: '已完成', 4: '已撤回'} return False, ( f"当前状态不可撤回:{status_map.get(approval.status, approval.status)}" ), None return OutboundApprovalService._release_and_close(approval, user_id) @staticmethod def _release_and_close(approval, user_id): """ 撤回的共用底层:释放预占 + 置为已撤回。 两条入口(管理路径 close_request / 申请人路径 withdraw_request) 各自完成权限与状态校验后调用本函数,确保释放逻辑只有一份实现, 不会因修改其中一处而漏掉另一处。 """ try: from app.services.inventory_reservation import release_reserved restored = release_reserved(approval.get_items()) # ★ 清理该单据的扫码草稿。 # 草稿按 (user_id, request_id) 隔离,撤回后单据状态变为 4, # 不会再出现在扫码页的单据下拉里(那里只拉 status=1), # 草稿从此成为永远读不到的死数据。此处一并清掉。 # # 注意:要清**所有用户**在这张单上的草稿,而非仅撤回者自己的 —— # 可能有多人扫过同一张单,只清自己的会留下他人的孤儿数据。 from app.models.scan_draft import ScanDraft ScanDraft.query.filter_by( biz_type='outbound', request_id=approval.id ).delete(synchronize_session=False) approval.status = 4 # 4-已撤回/已完结 approval.actual_approver_id = user_id approval.approved_at = None db.session.commit() return True, f"申请单已撤回,释放 {restored} 项预占库存", approval except Exception as e: db.session.rollback() return False, f"撤回归还失败: {str(e)}", None def get_request_list(page=1, per_page=10, applicant_id=None, status=None): """ 获取审批单列表 Args: page: 页码 per_page: 每页数量 applicant_id: 按申请人筛选 (可选) status: 按状态筛选 (可选) Returns: 分页结果 """ from app.models.outbound import OutboundApproval from app.models.system import SysUser from app.utils.decorators import get_current_company_filter from sqlalchemy import desc query = OutboundApproval.query if applicant_id: query = query.filter(OutboundApproval.applicant_id == applicant_id) if status is not None: query = query.filter(OutboundApproval.status == status) # 【行级数据隔离】通过申请人关联用户表过滤公司 company_limit = get_current_company_filter() if company_limit is not None: query = query.join(SysUser, OutboundApproval.applicant_id == SysUser.id) \ .filter(SysUser.department == company_limit) query = query.order_by(desc(OutboundApproval.created_at)) pagination = query.paginate(page=page, per_page=per_page, error_out=False) return { 'items': [item.to_dict() for item in pagination.items], 'total': pagination.total, 'pages': pagination.pages, 'current_page': page } @staticmethod def get_request_by_id(request_id): """根据ID获取审批单""" from app.models.outbound import OutboundApproval return OutboundApproval.query.get(request_id)