Files
KCGL/inventory-backend/app/services/borrow_service.py
yueli 077fd2f2cf feat(borrow,scrap): 补齐申请人撤回端点,与出库对齐
背景
----
出库已有独立的申请人撤回端点(仅 @jwt_required + 服务层归属断言),
但借库/报废没有:

  · 借库撤回复用 close 端点,权限是 op_borrow_approval(管理路径),
    普通员工调用返回 403;
  · 报废撤回带 @permission_required('scrap_apply'),同样挡住普通申请人
    (scrap_apply 只授予 INBOUND/OUTBOUND/SUPERVISOR/SUPER_ADMIN)。

结果是「我的申请单」页面里,借库/报废的撤回按钮对普通员工点了报错。

借库
----
服务层拆成与管理路径并列的两条入口(与出库同构):

  mark_completed     —— 管理路径,需 op_borrow_approval,仅 status==1
  withdraw_request   —— 申请人路径,仅校验单据归属,status 0 或 1

两者共用 _release_and_close(),释放逻辑只有一份实现。
新增 POST /transactions/borrow/request/<id>/withdraw(仅 @jwt_required)。

报废
----
withdraw() 增加 require_owner 参数区分两条路径:
  require_owner=True (默认,申请人路径)→ 断言 applicant_id == operator_id
  require_owner=False(管理路径)        → 由调用方权限装饰器鉴权
WITHDRAWABLE_STATUS 从 (1,) 放宽到 (0, 1),与出库/借库对齐。
移除端点上的 @permission_required('scrap_apply'),改走归属校验。

安全实测
--------
  借库 B 撤 A 的单 → 403,库存仍是 27(未被释放)
  借库 A 撤自己的单 → 200,30 完全释放
  报废 B 撤 A 的单 → 403
  报废 A 撤自己的单 → 200
  主管代撤员工的单(特权路径)→ 200
2026-09-10 15:46:55 +08:00

567 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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())
approval.status = 4 # 已撤回/已完结
db.session.commit()
return True, f"申请单已撤回,释放 {restored} 项预占库存", approval
except Exception as e:
db.session.rollback()
return False, f"撤回归还失败: {str(e)}", None