from datetime import datetime import pytz from app.extensions import db, beijing_time from app.models.borrow import BorrowApproval from app.models.system import SysUser class BorrowApprovalService: """借库审批服务""" @staticmethod def generate_request_no(): """ 生成审批单号: APR-BOR-yyyyMMdd-HHmm-当日流水(4位) """ beijing_tz = pytz.timezone('Asia/Shanghai') now = datetime.now(beijing_tz) date_str = now.strftime('%Y%m%d') time_str = now.strftime('%H%M') prefix = f"APR-BOR-{date_str}-" latest = db.session.query(BorrowApproval.request_no).filter( BorrowApproval.request_no.like(f"{prefix}%") ).order_by(BorrowApproval.id.desc()).first() if latest: last_seq = int(latest[0].split('-')[-1]) sequence = last_seq + 1 else: sequence = 1 return f"APR-BOR-{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 submit_approval(applicant_id, items, allowed_approvers, remark=None, approver_id=None, borrower_name=None, force_approval=False): """ 提交借库申请(仅存储意向,不扣库存) Args: applicant_id: 申请人ID items: 借库物品明细列表,每个物品应包含: - name: 物料名称 (必填) - spec_model: 规格型号 (必填) - quantity: 计划借库数量 (必填) - warehouse_location: 库位 (可选) - remark: 物品备注 (可选) allowed_approvers: 允许审批的人员/角色列表 approver_id: 指定审批人ID(可选) remark: 申请说明 borrower_name: 借库人姓名(必填) Returns: BorrowApproval 实例 Raises: ValueError: 当 items 为空或缺少必填字段时抛出 """ if not items: raise ValueError("借库物品列表不能为空") # ★ 借库申请必填:申请原因;预计归还日期(除非勾选长期借用/不限归还) if not str(remark or '').strip(): raise ValueError("借库申请原因不能为空") _has_indefinite = any(bool(item.get('is_indefinite')) for item in items) _has_date = any(str(item.get('expected_return_time') or '').strip() for item in items) if not _has_indefinite and not _has_date: raise ValueError("请填写预计归还日期,或勾选长期借用(不限归还)") 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}。请选择审批人后再提交") if approver_id: allowed_approvers = [{"type": "user", "value": int(approver_id)}] elif not need_approval: allowed_approvers = [] # 免审批单不绑定审批人 request_no = BorrowApprovalService.generate_request_no() if need_approval: approval = BorrowApproval( request_no=request_no, applicant_id=applicant_id, remark=remark, borrower_name=borrower_name, status=0, # 待审批 ) else: # 默认不审批:创建即已通过(status=1),直接进入“待库管执行” # ★ approved_at 必须与 created_at(beijing_time) 同为 naive 本地时间: # approved_at 列是 timestamp without time zone,传 aware 时间会被驱动转成 UTC 存库,导致早 8 小时 approval = BorrowApproval( request_no=request_no, applicant_id=applicant_id, remark=remark, borrower_name=borrower_name, status=1, actual_approver_id=applicant_id, approved_at=beijing_time(), ) # ================================================================== # ★ Phase 1:库存预占 # 借出同样会占用库存(借出期间该批货不可再被他人领用), # 因此与出库一致:提交即锁定具体批次并扣减 available_quantity。 # 借出归还时再归还,见 process_return()。 # ================================================================== 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: BorrowApprovalService._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: 角色代码列表,如 ['SUPERVISOR', 'SUPER_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_borrow_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 = BorrowApprovalService._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_borrow_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_borrow_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 traceback traceback.print_exc() @staticmethod def _notify_approval_result(approval, approver_id, action): """发送借库审批结果通知邮件(静默处理,不阻断主流程)""" import logging logger = logging.getLogger(__name__) try: from app.utils.email_service import send_borrow_approval_result_notify, send_borrow_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 通知申请人(审批已通过,明确告知结果) if applicant_emails: try: send_borrow_approval_result_notify( to_emails=applicant_emails, request_no=approval.request_no, is_passed=True, reject_reason='', applicant_name=applicant_name ) except Exception as e: logger.error(f"[Email] 通知申请人(通过)失败: {e}") else: logger.warning("[Email] 申请人无邮箱,无法发送审批通过通知") # 3.2 通知库管(请备货) warehouse_role_codes = ['WAREHOUSE_MGR', 'OUTBOUND'] warehouse_emails = BorrowApprovalService._get_emails_by_identifiers(role_codes=warehouse_role_codes) if warehouse_emails: try: send_borrow_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}") else: logger.warning("[Email] 无库管角色邮箱,无法发送备货通知") elif action == 'reject': # 3.3 通知申请人(已驳回) if applicant_emails: try: send_borrow_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 can_approve(approval, user_id, user_role): """ 检查用户是否有权限审批 """ approvers = approval.get_allowed_approvers() if user_role and user_role.upper() == 'SUPER_ADMIN': return True # 拥有 op_borrow_approval:operation 权限的用户可以审批任意借库单 # (前端按钮以此权限控制显示,后端也必须对齐) from app.models.system import SysRolePermission has_op = SysRolePermission.query.filter( SysRolePermission.role_code == user_role, SysRolePermission.target_code.in_(['op_borrow_approval:operation', 'op_borrow_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): """ 执行审批操作 Returns: (success: bool, message: str, approval: BorrowApproval or None) """ # ★ 统一取 naive 北京时间,与 created_at(beijing_time) 同口径 current_time = beijing_time() approval = BorrowApproval.query.get(request_id) if not approval: return False, "审批单不存在", None if approval.status != 0: return False, f"审批单状态已更新,无法重复审批 (当前状态: {approval.status})", None if not BorrowApprovalService.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:驳回即释放预占,避免货被永不执行的单占住 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() # ★ 审批后,发送邮件通知(静默处理,不阻断主流程) BorrowApprovalService._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 get_request_list(page=1, per_page=10, applicant_id=None, status=None): """ 获取审批单列表 """ from app.models.system import SysUser from app.utils.decorators import get_current_company_filter from sqlalchemy import desc query = BorrowApproval.query if applicant_id: query = query.filter(BorrowApproval.applicant_id == applicant_id) if status is not None: query = query.filter(BorrowApproval.status == status) # 【行级数据隔离】通过申请人关联用户表过滤公司 company_limit = get_current_company_filter() if company_limit is not None: query = query.join(SysUser, BorrowApproval.applicant_id == SysUser.id) \ .filter(SysUser.department == company_limit) query = query.order_by(desc(BorrowApproval.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获取审批单""" return BorrowApproval.query.get(request_id) @staticmethod def mark_completed(request_id): """ 撤回 / 作废已通过的借库审批单(status 1-已通过 → 4-已完结)。 注意:真正「扫码借出执行完成」走 trans_service.execute_dispatch, 会将 status 置为 3(已完成);本方法只处理「已通过但尚未执行」的撤回。 ★ 修复:原先只改状态、不释放预占 —— 申请阶段 reserve_for_items() 扣掉的 available_quantity 会永久泄漏,货被一张作废单锁死。 现调用 release_reserved() 按 items_json 原样归还。 执行守卫:执行成功后 status 会被置为 3,故「仅 status==1 可撤回」 本身就是执行守卫,已执行或已撤回的单都无法再次操作。 """ approval = BorrowApproval.query.get(request_id) if not approval: return False, "审批单不存在", None if approval.status != 1: status_map = {0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成', 4: '已撤回'} return False, ( f"只有已通过(待执行)的审批单才能撤回 " f"(当前状态: {status_map.get(approval.status, approval.status)})" ), None return BorrowApprovalService._release_and_close(approval) # ------------------------------------------------------------------ # ★ 申请人撤回自己的借库申请单(严格职责分离) # # 与 mark_completed(管理路径)的区别: # · mark_completed —— 需 op_borrow_approval 权限,仅 status==1 # · withdraw_request —— 仅校验「单据归属」,status 0 或 1 均可 # # 两条路径共用底层 _release_and_close,释放逻辑只有一份实现。 # ------------------------------------------------------------------ WITHDRAWABLE_STATUS = (0, 1) @staticmethod def withdraw_request(request_id, user_id): """ 申请人撤回自己的借库申请单。 权限:仅单据本人;库管/主管/超管(is_privileged_viewer)可代撤。 返回 (success, message, approval) """ from app.utils.decorators import is_privileged_viewer approval = BorrowApproval.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 BorrowApprovalService.WITHDRAWABLE_STATUS: status_map = {0: '待审批', 1: '已通过(待执行)', 2: '已驳回', 3: '已完成', 4: '已撤回'} return False, ( f"当前状态不可撤回:{status_map.get(approval.status, approval.status)}" ), None return BorrowApprovalService._release_and_close(approval) @staticmethod def _release_and_close(approval): """ 撤回的共用底层:释放预占 + 置为已撤回。 两条入口(管理路径 mark_completed / 申请人路径 withdraw_request) 各自完成权限与状态校验后调用本函数,确保释放逻辑不会因修改 其中一处而漏掉另一处。 """ try: # ★ 释放全部预占,把 available_quantity 还回池子 from app.services.inventory_reservation import release_reserved restored = release_reserved(approval.get_items()) # ★ 清理该单据的扫码草稿(含其他用户的)。 # 撤回后单据状态变为 4,不再出现在扫码页的下拉里(那里只拉 status=1), # 草稿从此成为永远读不到的死数据。 from app.models.scan_draft import ScanDraft ScanDraft.query.filter_by( biz_type='borrow', request_id=approval.id ).delete(synchronize_session=False) approval.status = 4 # 已撤回/已完结 db.session.commit() return True, f"申请单已撤回,释放 {restored} 项预占库存", approval except Exception as e: db.session.rollback() return False, f"撤回归还失败: {str(e)}", None