fix(outbound+trans): 修复POST接口错误数据清洗导致的sku/quantity字段被清除Bug,并新增出库审批工作流全链路

This commit is contained in:
DXC
2026-04-28 16:02:34 +08:00
parent 97e7618bf3
commit 62c0e3738e
12 changed files with 1248 additions and 112 deletions

View File

@ -318,6 +318,41 @@ def get_my_permissions():
return jsonify({'msg': f'获取权限失败: {str(e)}'}), 500
# ==============================================================================
# 获取可指定审批人列表SUPERVISOR / SUPER_ADMIN 且 status=active
# ==============================================================================
@auth_bp.route('/users/approvers', methods=['GET'])
@jwt_required()
def get_approvers():
"""
查询角色为 SUPER_ADMIN 或 SUPERVISOR 且状态为活跃的用户列表
返回: [{id, username, email, role}]
"""
try:
from app.models.system import SysUser
users = SysUser.query.filter(
SysUser.role.in_(['SUPER_ADMIN', 'SUPERVISOR']),
SysUser.status == 'active'
).all()
return jsonify({
'msg': '获取成功',
'data': [
{
'id': u.id,
'username': u.username,
'email': u.email or '',
'role': u.role
} for u in users
]
}), 200
except Exception as e:
current_app.logger.error(f"Get Approvers Failed: {str(e)}")
return jsonify({'msg': f'获取审批人列表失败: {str(e)}'}), 500
# ==============================================================================
# 获取当前用户个人资料(自我查看)
# ==============================================================================

View File

@ -148,44 +148,6 @@ def create_outbound():
if not data.get('consumer_name') or not data.get('signature_path'):
return jsonify({'code': 400, 'msg': '领用人及签名信息缺失'}), 400
# 数据清洗:移除用户没有权限的字段
user_permissions = get_current_user_permissions()
# 超级管理员不过滤
if 'outbound_list:*' not in user_permissions:
# 字段名到权限码的映射(与前端 permissionMap 保持一致)
field_to_perm = {
'outbound_no': 'outbound_list:outbound_no',
'outbound_time': 'outbound_list:outbound_time',
'outbound_type': 'outbound_list:outbound_type',
'total_amount': 'outbound_list:total_amount',
'consumer_name': 'outbound_list:consumer_name',
'operator_name': 'outbound_list:operator_name',
'remark': 'outbound_list:remark',
'signature_path': 'outbound_list:signature_path',
# 明细字段
'sku': 'outbound_list:sku',
'name': 'outbound_list:name',
'material_type': 'outbound_list:material_type',
'category': 'outbound_list:category',
'spec_model': 'outbound_list:spec_model',
'quantity': 'outbound_list:quantity',
'unit_price': 'outbound_list:unit_price',
'price': 'outbound_list:unit_price', # 兼容 price 字段
'subtotal': 'outbound_list:subtotal',
}
# 清洗顶层字段
for field in list(data.keys()):
perm_code = field_to_perm.get(field)
if perm_code and perm_code not in user_permissions:
data.pop(field, None)
# 清洗 items 中的每个商品字段
if 'items' in data and isinstance(data['items'], list):
for item in data['items']:
for field in list(item.keys()):
perm_code = field_to_perm.get(field)
if perm_code and perm_code not in user_permissions:
item.pop(field, None)
try:
# ★ [修改] 调用批量创建服务
outbound_no = OutboundService.create_outbound_batch(data, operator_name=final_operator)
@ -233,3 +195,244 @@ def get_outbound_list():
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': str(e)}), 500
# ==============================================================================
# 出库审批相关接口
# ==============================================================================
from app.services.outbound_service import OutboundApprovalService
def get_current_user_id():
"""获取当前用户ID"""
from app.models.system import SysUser
identity = get_jwt_identity()
if not identity:
return None
# JWT identity 是数据库主键整数,直接用 .get() 查询
user = SysUser.query.get(identity)
return user.id if user else None
def get_current_user_info():
"""获取当前用户信息和角色"""
from app.models.system import SysUser
identity = get_jwt_identity()
if not identity:
return None, None
# JWT identity 是数据库主键整数,直接用 .get() 查询
user = SysUser.query.get(identity)
return user.id if user else None, user.role if user else None
# --------------------------------------------------------
# 4. 创建出库审批单
# POST /api/v1/outbound/request
# --------------------------------------------------------
@outbound_bp.route('/request', methods=['POST'])
@jwt_required()
def create_outbound_request():
"""
创建出库审批单(申请阶段,用户只需提交宏观物料信息,无需关联具体库存记录)
请求体示例:
{
"items": [
{
"name": "物料A", // 物料名称 (必填)
"spec_model": "规格1", // 规格型号 (必填)
"quantity": 10, // 计划出库数量 (必填)
"warehouse_location": "A区-01-01", // 库位 (可选)
"remark": "备注信息" // 物品备注 (可选)
}
],
"allowed_approvers": [
{"type": "role", "value": "SUPERVISOR"},
{"type": "role", "value": "SUPER_ADMIN"}
],
"remark": "紧急出库申请"
}
"""
try:
user_id, user_role = get_current_user_info()
if not user_id:
return jsonify({'code': 401, 'msg': '用户未登录'}), 401
data = request.get_json()
if not data:
return jsonify({'code': 400, 'msg': '无有效数据'}), 400
items = data.get('items', [])
if not items:
return jsonify({'code': 400, 'msg': '出库物品列表不能为空'}), 400
# ★ 申请阶段仅校验宏观字段:名称、规格、数量
required_fields = ['name', 'spec_model', 'quantity']
for idx, item in enumerate(items):
missing = [f for f in required_fields if f not in item or item.get(f) is None or str(item.get(f)).strip() == '']
if missing:
return jsonify({
'code': 400,
'msg': f'{idx + 1}条物品缺少必填字段: {", ".join(missing)}'
f'必须包含: name(名称), spec_model(规格), quantity(数量)'
}), 400
try:
qty = float(item.get('quantity', 0))
if qty <= 0:
return jsonify({'code': 400, 'msg': f'{idx + 1}条物品的出库数量必须大于0'}), 400
except (TypeError, ValueError):
return jsonify({'code': 400, 'msg': f'{idx + 1}条物品的 quantity 格式无效'}), 400
# ★ 指定审批人:前端传 approver_id 则精准通知,否则用默认角色规则
approver_id = data.get('approver_id')
_default_approvers = [
{"type": "role", "value": "SUPERVISOR"},
{"type": "role", "value": "SUPER_ADMIN"}
]
allowed_approvers = data.get('allowed_approvers') or _default_approvers
# 创建审批单(直接存储前端传来的宏观信息快照,不查询库存)
approval = OutboundApprovalService.create_request(
applicant_id=user_id,
items=items,
allowed_approvers=allowed_approvers,
remark=data.get('remark'),
approver_id=approver_id
)
return jsonify({
'code': 200,
'msg': '审批单创建成功',
'data': approval.to_dict()
}), 200
except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
# --------------------------------------------------------
# 5. 审批出库申请
# PATCH /api/v1/outbound/request/<id>/approve
# --------------------------------------------------------
@outbound_bp.route('/request/<int:request_id>/approve', methods=['PATCH'])
@jwt_required()
def approve_outbound_request(request_id):
"""
审批出库申请
请求体示例:
{
"action": "approve", // "approve" 通过, "reject" 驳回
"reject_reason": "库存不足" // 仅在驳回时需要
}
"""
try:
user_id, user_role = get_current_user_info()
if not user_id:
return jsonify({'code': 401, 'msg': '用户未登录'}), 401
data = request.get_json() or {}
action = data.get('action', 'approve')
reject_reason = data.get('reject_reason')
if action not in ('approve', 'reject'):
return jsonify({'code': 400, 'msg': '无效的审批操作,仅支持 approve 或 reject'}), 400
if action == 'reject' and not reject_reason:
return jsonify({'code': 400, 'msg': '驳回时必须提供原因'}), 400
success, message, approval = OutboundApprovalService.approve(
request_id=request_id,
user_id=user_id,
user_role=user_role,
action=action,
reject_reason=reject_reason
)
if not success:
return jsonify({'code': 400, 'msg': message}), 400
return jsonify({
'code': 200,
'msg': message,
'data': approval.to_dict() if approval else None
}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
# --------------------------------------------------------
# 6. 获取审批单列表
# GET /api/v1/outbound/request
# --------------------------------------------------------
@outbound_bp.route('/request', methods=['GET'])
@jwt_required()
def get_outbound_request_list():
"""
获取出库审批单列表
Query参数:
- page: 页码 (默认1)
- limit: 每页数量 (默认10)
- applicant_id: 按申请人筛选 (可选)
- status: 按状态筛选 (0待审/1通过/2驳回/3完成, 可选)
"""
try:
page = int(request.args.get('page', 1))
limit = int(request.args.get('limit', 10))
applicant_id = request.args.get('applicant_id')
if applicant_id:
applicant_id = int(applicant_id)
status = request.args.get('status')
if status is not None:
status = int(status)
result = OutboundApprovalService.get_request_list(
page=page,
per_page=limit,
applicant_id=applicant_id,
status=status
)
return jsonify({
'code': 200,
'msg': '获取成功',
'data': result
}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': str(e)}), 500
# --------------------------------------------------------
# 7. 获取单个审批单详情
# GET /api/v1/outbound/request/<id>
# --------------------------------------------------------
@outbound_bp.route('/request/<int:request_id>', methods=['GET'])
@jwt_required()
def get_outbound_request_detail(request_id):
"""获取出库审批单详情"""
try:
approval = OutboundApprovalService.get_request_by_id(request_id)
if not approval:
return jsonify({'code': 404, 'msg': '审批单不存在'}), 404
return jsonify({
'code': 200,
'msg': '获取成功',
'data': approval.to_dict()
}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': str(e)}), 500

View File

@ -66,26 +66,6 @@ def filter_item_by_permissions(item_dict, user_permissions, prefix='op_records')
)
def create_borrow():
data = request.get_json()
# 数据清洗:移除用户没有权限的字段
user_permissions = get_current_user_permissions()
# 超级管理员不过滤
if '*' not in user_permissions:
field_to_perm = {
'borrow_no': 'op_records:borrow_no',
'borrower_name': 'op_records:borrower_name',
'sku': 'op_records:sku',
'borrow_time': 'op_records:borrow_time',
'return_time': 'op_records:return_time',
'status': 'op_records:status',
'expected_return_time': 'op_records:expected_return_time',
'return_location': 'op_records:return_location',
'borrow_signature': 'op_records:borrow_signature',
'return_signature': 'op_records:return_signature',
}
for field in list(data.keys()):
perm_code = field_to_perm.get(field)
if perm_code and perm_code not in user_permissions:
data.pop(field, None)
try:
no = TransService.create_borrow(data)
return jsonify({'code': 200, 'msg': '借用成功', 'data': {'borrow_no': no}})
@ -120,26 +100,6 @@ def scan_borrowed_item():
)
def submit_return():
data = request.get_json()
# 数据清洗:移除用户没有权限的字段
user_permissions = get_current_user_permissions()
# 超级管理员不过滤
if '*' not in user_permissions:
field_to_perm = {
'borrow_no': 'op_records:borrow_no',
'borrower_name': 'op_records:borrower_name',
'sku': 'op_records:sku',
'borrow_time': 'op_records:borrow_time',
'return_time': 'op_records:return_time',
'status': 'op_records:status',
'expected_return_time': 'op_records:expected_return_time',
'return_location': 'op_records:return_location',
'borrow_signature': 'op_records:borrow_signature',
'return_signature': 'op_records:return_signature',
}
for field in list(data.keys()):
perm_code = field_to_perm.get(field)
if perm_code and perm_code not in user_permissions:
data.pop(field, None)
user = get_jwt_identity() # 库管
try:
TransService.process_return(data, operator_name=user)

View File

@ -1,5 +1,110 @@
from app.extensions import db, beijing_time
from app.models.system import SysUser
from datetime import datetime
import json
class OutboundApproval(db.Model):
"""
出库审批单模型
用于管理出库申请的多级审批流程
"""
__tablename__ = 'outbound_approval'
id = db.Column(db.Integer, primary_key=True)
# 审批单号
request_no = db.Column(db.String(100), unique=True, nullable=False, index=True)
# 申请人ID
applicant_id = db.Column(db.Integer, nullable=False, index=True)
# 申请说明
remark = db.Column(db.Text)
# 状态: 0-待审批, 1-已通过, 2-已驳回, 3-已完成(已出库)
status = db.Column(db.Integer, default=0, nullable=False)
# 允许审批的人员列表 (JSON格式: [{"type": "role", "value": "admin"}, {"type": "user", "value": "123"}])
allowed_approvers = db.Column(db.Text)
# 实际审批人ID (多人审批时记录第一个通过的)
actual_approver_id = db.Column(db.Integer, index=True)
# 审批时间
approved_at = db.Column(db.DateTime)
# 驳回原因
reject_reason = db.Column(db.Text)
# 明细快照 (存储出库物品的名称、规格、库位、数量等信息无SKU字段)
items_json = db.Column(db.Text)
# 创建时间和更新时间
created_at = db.Column(db.DateTime, default=beijing_time, nullable=False)
updated_at = db.Column(db.DateTime, default=beijing_time, onupdate=beijing_time, nullable=False)
def _safe_parse_json(self, value):
"""
安全解析 JSON 字段:
- 如果 value 已是 list/dict直接返回
- 如果是 str尝试 json.loads()
- 解析失败或为 None/空,均返回 []
"""
if value is None:
return []
if isinstance(value, (list, dict)):
return value
if isinstance(value, str):
val = value.strip()
if not val:
return []
try:
parsed = json.loads(val)
return parsed if isinstance(parsed, list) else []
except (json.JSONDecodeError, TypeError, ValueError):
return []
return []
def get_items(self):
"""解析 items_json返回物品列表"""
return self._safe_parse_json(self.items_json)
def set_items(self, items):
"""设置 items_json"""
self.items_json = json.dumps(items, ensure_ascii=False) if items else '[]'
def get_allowed_approvers(self):
"""解析 allowed_approvers返回审批人列表"""
return self._safe_parse_json(self.allowed_approvers)
def set_allowed_approvers(self, approvers):
"""设置 allowed_approvers"""
self.allowed_approvers = json.dumps(approvers, ensure_ascii=False) if approvers else '[]'
def to_dict(self):
return {
'id': self.id,
'request_no': self.request_no,
'applicant_id': self.applicant_id,
'applicant_name': self._get_user_name(self.applicant_id),
'remark': self.remark,
'status': self.status,
'status_text': ['待审批', '已通过', '已驳回', '已完成'][self.status] if self.status in [0, 1, 2, 3] else '未知',
'allowed_approvers': self.get_allowed_approvers(),
'actual_approver_id': self.actual_approver_id,
'approver_name': self._get_user_name(self.actual_approver_id) if self.actual_approver_id else None,
'approved_at': self.approved_at.strftime('%Y-%m-%d %H:%M:%S') if self.approved_at else None,
'reject_reason': self.reject_reason,
'items': self.get_items(),
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None,
'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S') if self.updated_at else None,
}
def _get_user_name(self, user_id):
"""根据用户ID获取用户名"""
if not user_id:
return ""
from app.models.system import SysUser
try:
# ★ 必须用 .get() 按主键 ID 查询,千万不能用 username=user_id 去查
user = SysUser.query.get(user_id)
return user.username if user else f"未知用户({user_id})"
except Exception as e:
return f"用户({user_id})"
class TransOutbound(db.Model):

View File

@ -2,7 +2,7 @@ import uuid # .material -> .base refactor checked
from datetime import datetime, timezone, timedelta
from sqlalchemy import or_, func, desc, and_
from app.extensions import db
from app.models.outbound import TransOutbound
from app.models.outbound import TransOutbound, OutboundApproval
# 引入所有库存模型以进行查询
from app.models.inbound.buy import StockBuy
@ -12,6 +12,8 @@ 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
class OutboundService:
@ -169,6 +171,22 @@ class OutboundService:
beijing_tz = timezone(timedelta(hours=8))
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
# ★ 审批单相关逻辑
request_id = data.get('request_id')
approval = None
if request_id:
# 根据 request_id 查询审批单
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"仅已通过的审批单方可执行出库"
)
model_map = {
'stock_buy': StockBuy,
'stock_semi': StockSemi,
@ -235,6 +253,11 @@ class OutboundService:
)
db.session.add(new_record)
# ★ 如果关联了审批单,出库成功后更新审批单状态为"已完成"
if approval:
approval.status = 3 # 3-已完成
# updated_at 会在 commit 时由 SQLAlchemy 自动更新
db.session.commit()
return outbound_no
@ -525,3 +548,336 @@ class OutboundService:
'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 create_request(applicant_id, items, allowed_approvers, remark=None, approver_id=None):
"""
创建出库审批单(申请阶段,直接存储前端传来的物料信息快照,不关联具体库存记录)
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)}")
# ★ 校验 allowed_approvers 非空
if not allowed_approvers:
raise ValueError("必须指定至少一位审批人")
# ★ 指定审批人模式approver_id 覆盖 allowed_approvers
if approver_id:
allowed_approvers = [{"type": "user", "value": int(approver_id)}]
request_no = OutboundApprovalService.generate_request_no()
approval = OutboundApproval(
request_no=request_no,
applicant_id=applicant_id,
remark=remark,
status=0, # 待审批
)
# 直接存储前端传来的物料信息快照,不查询/不关联具体库存记录
approval.set_items(items)
approval.set_allowed_approvers(allowed_approvers)
db.session.add(approval)
db.session.commit()
# ★ 创建成功后,发送邮件通知审批人(精确通知 approver_id 对应的邮箱)
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_new_request_notify
emails = []
if approver_id:
# ★ 精准通知模式:直接查询指定审批人
user = SysUser.query.get(int(approver_id))
if user and user.email:
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', ''))
emails = OutboundApprovalService._get_emails_by_identifiers(role_codes=role_codes)
if not emails:
current_app.logger.info(f"[Email] 审批单 {approval.request_no} 无审批人邮箱,跳过通知")
return
# 获取申请人姓名
applicant_name = ''
if applicant_id:
u = SysUser.query.get(applicant_id)
if u:
# username 格式为 "姓名/账号",取姓名部分
applicant_name = str(u.username).split('/')[0] if '/' in u.username else (u.username or str(applicant_id))
send_new_request_notify(
to_emails=emails,
request_no=approval.request_no,
applicant_name=applicant_name,
remark=approval.remark or ''
)
except Exception as e:
# ★ 捕获所有异常,确保邮件发送失败不阻断主流程
try:
from flask import current_app
current_app.logger.error(f"[Email] 发送新申请通知邮件失败: {e}")
except RuntimeError:
# 如果不在 Flask 应用上下文内,降级为标准日志
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
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
beijing_tz = timezone(timedelta(hours=8))
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
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':
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):
"""发送审批结果通知邮件(静默处理,不阻断主流程)"""
try:
from app.utils.email_service import send_approval_result_notify
# 仓库管理员角色代码
warehouse_role_codes = ['WAREHOUSE_MGR', 'OUTBOUND']
# 查询库管邮箱 + 申请人本人邮箱
emails = OutboundApprovalService._get_emails_by_identifiers(
applicant_id=approval.applicant_id,
role_codes=warehouse_role_codes
)
if not emails:
return
send_approval_result_notify(
to_emails=emails,
request_no=approval.request_no,
is_passed=(action == 'approve'),
reject_reason=approval.reject_reason or ''
)
except Exception as e:
logging.getLogger(__name__).warning(f"[Email] 发送审批结果通知邮件失败: {e}")
@staticmethod
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 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)
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)

View File

@ -435,6 +435,7 @@ class PermissionService:
('outbound_selection', '出库选单', '/outbound/selection', 'outbound_mgmt', 1),
('outbound_create', '扫码出库', '/outbound/create', 'outbound_mgmt', 2),
('outbound_list', '出库记录', '/outbound/index', 'outbound_mgmt', 3),
('outbound_approval', '出库审批', '/outbound/approval', 'outbound_mgmt', 4),
# BOM管理子菜单
('bom_manage', 'BOM配方管理', '/bom/manage', 'bom_mgmt', 1),

View File

@ -49,3 +49,23 @@ class Config:
# 5. Redis 配置 (用于单设备登录互踢)
# =========================================================
REDIS_URL = os.getenv('REDIS_URL', 'redis://localhost:6379/0')
# =========================================================
# 6. 邮件配置
# =========================================================
# 发件人邮箱(阿里企业邮箱)
MAIL_USERNAME = os.getenv('MAIL_USERNAME', 'wms@iris-rs.cn')
# 发件人邮箱密码 / 授权码
MAIL_PASSWORD = os.getenv('MAIL_PASSWORD', 'Q7nYyyESWlaThKjx')
# SMTP 服务器地址(阿里企业邮发信服务器)
MAIL_SERVER = os.getenv('MAIL_SERVER', 'smtp.mxhichina.com')
# SMTP 端口(阿里邮箱使用 SSL 465
MAIL_PORT = int(os.getenv('MAIL_PORT', 465))
# 是否启用 TLS (587 端口通常需要)
MAIL_USE_TLS = os.getenv('MAIL_USE_TLS', 'false').lower() in ('true', '1', 'yes')
# 是否启用 SSL (465 端口通常需要,阿里邮箱必须启用 SSL)
MAIL_USE_SSL = os.getenv('MAIL_USE_SSL', 'true').lower() in ('true', '1', 'yes')
# 默认发件人(显示名称 <邮箱地址>,阿里要求 MAIL_USERNAME 与此处邮箱地址完全一致)
MAIL_DEFAULT_SENDER = os.getenv('MAIL_DEFAULT_SENDER', 'WMS系统 <wms@iris-rs.cn>')
# 是否启用邮件发送功能(开发环境可设为 false 禁用)
MAIL_ENABLED = os.getenv('MAIL_ENABLED', 'true').lower() in ('true', '1', 'yes')

View File

@ -85,3 +85,11 @@ export function batchCreateUser(data: any[]) {
data
})
}
// ★ 获取可指定审批人列表SUPERVISOR / SUPER_ADMIN 且 status=active
export function getApproversList() {
return request({
url: '/v1/auth/users/approvers',
method: 'get'
})
}

View File

@ -78,3 +78,48 @@ export function getOutboundList(params: any) {
params
})
}
/**
* 提交出库申请单(申请人 → 审批流)
*/
export function submitOutboundRequest(data: {
items: Array<{
material_type?: string
name: string
spec_model: string
warehouse_location?: string
quantity: number
}>
remark: string
}) {
return request({
url: '/v1/outbound/request',
method: 'post',
data
})
}
/**
* 获取出库审批申请单列表
* @param params 支持 status, page, limit
*/
export function getApprovalRequestList(params: { status?: number | ''; page?: number; limit?: number }) {
return request({
url: '/v1/outbound/request',
method: 'get',
params
})
}
/**
* 审批(通过 / 驳回)出库申请单
* @param id 审批单ID
* @param data action: 'approve' | 'reject'reject 时需传 reject_reason
*/
export function approveRequest(id: number, data: { action: 'approve' | 'reject'; reject_reason?: string }) {
return request({
url: `/v1/outbound/request/${id}/approve`,
method: 'patch',
data
})
}

View File

@ -150,6 +150,16 @@ const routes: Array<RouteRecordRaw> = [
name: 'OutboundList',
component: () => import('@/views/outbound/index.vue'),
meta: { title: '出库记录' }
},
{
path: 'approval',
name: 'OutboundApproval',
component: () => import('@/views/outbound/approval/index.vue'),
meta: {
title: '出库审批',
icon: 'Stamp',
roles: ['SUPER_ADMIN', 'SUPERVISOR']
}
}
]
},

View File

@ -37,6 +37,9 @@
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="success" :icon="Printer" :disabled="selectedItems.length === 0" @click="handlePreview">
生成预览 & 打印
</el-button>
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="primary" :icon="Plus" :disabled="selectedItems.length === 0" @click="openRequestDialog">
提交出库申请
</el-button>
</div>
</div>
</template>
@ -289,6 +292,80 @@
</template>
</el-dialog>
<!-- 出库申请 Dialog -->
<el-dialog
v-model="requestDialogVisible"
title="提交出库申请"
width="700px"
destroy-on-close
class="no-print-content"
>
<el-alert
title="请确认以下物料申请清单,填写申请原因后提交"
type="info"
:closable="false"
style="margin-bottom: 16px;"
/>
<el-table :data="validSelectedItems" border size="small" style="margin-bottom: 16px;">
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column label="类型" width="80" align="center">
<template #default="{ row }">
<el-tag size="small" :type="getTypeTag(row.type)">{{ row.typeLabel }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="standard" label="规格" min-width="120" show-overflow-tooltip />
<el-table-column prop="warehouse_location" label="库位" width="120" show-overflow-tooltip />
<el-table-column prop="export_quantity" label="计划数量" width="100" align="center">
<template #default="{ row }">
<span style="color: #F56C6C; font-weight: bold;">{{ row.export_quantity }}</span>
</template>
</el-table-column>
</el-table>
<el-form label-width="80px">
<el-form-item label="* 指定审批人" required>
<el-select
v-model="requestApproverId"
placeholder="请选择审批人"
style="width: 100%"
filterable
>
<el-option
v-for="user in approvers"
:key="user.id"
:label="`${user.username} (${user.role === 'SUPER_ADMIN' ? '超级管理员' : '主管'})`"
:value="user.id"
/>
</el-select>
</el-form-item>
<el-form-item label="申请原因" required>
<el-input
v-model="requestRemark"
type="textarea"
:rows="3"
placeholder="请填写出库申请原因(必填)"
maxlength="200"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="requestDialogVisible = false">取消</el-button>
<el-button
type="primary"
:loading="requestSubmitting"
@click="confirmSubmitRequest"
>
确认提交
</el-button>
</span>
</template>
</el-dialog>
<div id="print-area">
<div class="print-header">
<h1>IRIS出库拣货确认单</h1>
@ -358,6 +435,8 @@ import { ElMessage, ElTable, ElMessageBox } from 'element-plus'
import { getAllStock, getStockList, printSelectionList } from '@/api/inbound/stock'
import { getBomList, getBomDetail } from '@/api/bom'
import { useUserStore } from '@/stores/user'
import { submitOutboundRequest } from '@/api/outbound'
import { getApproversList } from '@/api/auth'
const userStore = useUserStore()
@ -381,6 +460,13 @@ const previewVisible = ref(false)
const exportLoading = ref(false)
const printLoading = ref(false)
// ★ 出库申请相关
const requestDialogVisible = ref(false)
const requestRemark = ref('')
const requestApproverId = ref<number | null>(null)
const approvers = ref<any[]>([])
const requestSubmitting = ref(false)
const allStockData = ref<any[]>([])
const stockList = ref<any[]>([]) // 服务端分页数据
const stockTotal = ref(0)
@ -795,6 +881,67 @@ const handlePreview = () => {
previewVisible.value = true
}
// ★ 出库申请
const openRequestDialog = () => {
if (validSelectedItems.value.length === 0) {
ElMessage.warning('请先添加物品并填写计划出库数量')
return
}
requestRemark.value = ''
requestApproverId.value = null
loadApprovers()
requestDialogVisible.value = true
}
// ★ 加载可指定审批人列表
const loadApprovers = async () => {
try {
const res: any = await getApproversList()
approvers.value = res.data || []
} catch (e) {
console.error('加载审批人列表失败', e)
approvers.value = []
}
}
const confirmSubmitRequest = async () => {
const trimmed = requestRemark.value.trim()
if (!trimmed) {
ElMessage.warning('请填写申请原因')
return
}
if (!requestApproverId.value) {
ElMessage.warning('请选择指定审批人')
return
}
requestSubmitting.value = true
try {
const payload: any = {
items: validSelectedItems.value.map(item => ({
material_type: item.typeLabel || item.type || '',
name: item.name || '',
spec_model: item.standard || '',
warehouse_location: item.warehouse_location || '',
quantity: item.export_quantity || 0
})),
remark: trimmed,
approver_id: requestApproverId.value
}
await submitOutboundRequest(payload)
// 成功:关闭弹窗、清空列表、提示
requestDialogVisible.value = false
selectedItems.value = []
ElMessage.success('出库申请已提交,等待主管审批!')
} catch (err: any) {
ElMessage.error(err?.message || err?.msg || '提交申请失败,请重试')
} finally {
requestSubmitting.value = false
}
}
const confirmPrint = async () => {
previewVisible.value = false;

View File

@ -15,6 +15,68 @@
</div>
</template>
<!-- 出库模式切换 -->
<div class="mode-switch-bar">
<el-radio-group v-model="outboundMode" size="default" @change="handleModeChange">
<el-radio-button value="by-request">按单出库</el-radio-button>
<el-radio-button value="direct">直接出库</el-radio-button>
</el-radio-group>
<span class="mode-hint">
{{ outboundMode === 'by-request' ? '需先选择已审批通过的申请单' : '无需审批单,自由扫码出库' }}
</span>
</div>
<!-- 按单出库审批单选择 -->
<div v-if="outboundMode === 'by-request'" class="approval-request-select">
<el-select
v-model="selectedRequestId"
placeholder="请选择已审批通过的出库申请单"
filterable
clearable
style="width: 100%"
:loading="requestsLoading"
@change="handleRequestChange"
>
<el-option
v-for="req in approvalRequests"
:key="req.id"
:value="req.id"
:label="req.request_no"
>
<span>{{ req.request_no }}</span>
<el-divider direction="vertical" />
<span>{{ req.applicant_name || '未知申请人' }}</span>
<el-divider direction="vertical" />
<span style="color: #909399; font-size: 13px">{{ req.remark || '无备注' }}</span>
</el-option>
</el-select>
<p class="select-tip">仅显示已通过status=1的审批单</p>
</div>
<!-- 按单出库计划清单预览 -->
<div v-if="outboundMode === 'by-request' && selectedRequest" class="planned-items-section">
<div class="planned-header">
<span class="planned-title">计划出库清单</span>
<el-tag type="success" size="small">{{ selectedRequest.items?.length || 0 }} </el-tag>
</div>
<el-table :data="selectedRequest.items || []" border size="small" style="width: 100%;">
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column label="类型" width="80" align="center">
<template #default="{ row }">
<el-tag size="small" type="info">{{ row.material_type || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="spec_model" label="规格" min-width="100" show-overflow-tooltip />
<el-table-column prop="warehouse_location" label="库位" width="100" show-overflow-tooltip />
<el-table-column label="计划数量" width="90" align="center">
<template #default="{ row }">
<span style="color: #E6A23C; font-weight: bold;">{{ row.quantity ?? '-' }}</span>
</template>
</el-table-column>
</el-table>
</div>
<div class="scan-section">
<div v-if="userStore.hasPermission('outbound_create:operation')" class="camera-placeholder" @click="showCamera = true">
@ -214,7 +276,7 @@ import { ref, reactive, nextTick, onUnmounted, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select } from '@element-plus/icons-vue'
import QrScanner from '@/components/QrScanner/index.vue'
import { getStockByBarcode, submitOutbound, getOutboundList } from '@/api/outbound'
import { getStockByBarcode, submitOutbound, getOutboundList, getApprovalRequestList } from '@/api/outbound'
import { uploadFile } from '@/api/common/upload'
import { useUserStore } from '@/stores/user'
@ -228,6 +290,12 @@ const showCamera = ref(false)
const barcodeRef = ref()
const formRef = ref()
// ★ 双轨制模式
const outboundMode = ref<'by-request' | 'direct'>('by-request') // 'by-request' | 'direct'
const approvalRequests = ref<any[]>([])
const selectedRequest = ref<any>(null)
const requestsLoading = ref(false)
// 签名相关
const showSignatureDialog = ref(false)
const signaturePreviewUrl = ref('')
@ -258,8 +326,95 @@ const totalAmount = computed(() => {
return cartItems.value.reduce((sum, item) => sum + (item.price * item.out_quantity), 0)
})
// ★ 双轨制 computed
const selectedRequestId = computed({
get: () => selectedRequest.value?.id ?? null,
set: (val) => {
if (!val) {
selectedRequest.value = null
} else {
selectedRequest.value = approvalRequests.value.find(r => r.id === val) ?? null
}
}
})
const plannedItems = computed(() => selectedRequest.value?.items ?? [])
// ★ 模式切换
const handleModeChange = () => {
selectedRequest.value = null
selectedRequestId.value = null
cartItems.value = []
form.consumer_name = ''
form.remark = ''
signatureFile.value = null
signaturePreviewUrl.value = ''
barcodeInput.value = ''
}
// ★ 加载已审批通过的申请单
const loadApprovalRequests = async () => {
requestsLoading.value = true
try {
const res: any = await getApprovalRequestList({ status: 1, page: 1, pageSize: 100 })
approvalRequests.value = res.data?.items || []
} catch (e) {
console.error('加载审批单列表失败', e)
} finally {
requestsLoading.value = false
}
}
const handleRequestChange = (val: number | null) => {
if (!val) {
selectedRequest.value = null
} else {
selectedRequest.value = approvalRequests.value.find(r => r.id === val) ?? null
}
// 切换申请单时清空购物车,防止已扫物品与新单据混淆
cartItems.value = []
signatureFile.value = null
signaturePreviewUrl.value = ''
}
// ★ 按单出库模式:校验扫码是否在计划内
const validateAgainstPlan = (scannedName: string, scannedSpec: string, scannedQty: number): string | null => {
const normalizedName = scannedName.trim()
const normalizedSpec = (scannedSpec || '').trim()
const matchedPlan = plannedItems.value.find(plan => {
const planName = (plan.name || '').trim()
const planSpec = (plan.spec_model || '').trim()
return planName === normalizedName && planSpec === normalizedSpec
})
if (!matchedPlan) {
return `该物料【${normalizedName} × ${normalizedSpec}】不在计划清单中,请检查`
}
const planQty = matchedPlan.quantity ?? 0
// 已扫数量(去重合并)
const alreadyScanned = cartItems.value
.filter(ci => {
const ciName = (ci.name || '').trim()
const ciSpec = (ci.spec_model || '').trim()
return ciName === normalizedName && ciSpec === normalizedSpec
})
.reduce((sum, ci) => sum + (ci.out_quantity || 0), 0)
if (alreadyScanned + scannedQty > planQty) {
return `${normalizedName} × ${normalizedSpec}】超出计划数量(计划: ${planQty},已扫: ${alreadyScanned},本次: ${scannedQty}`
}
return null // 通过
}
// --- 初始化 ---
onMounted(() => {
// 加载已审批通过的申请单列表
loadApprovalRequests()
if (userStore.username) {
form.operator_name = userStore.username
operatorOptions.value.push(userStore.username)
@ -313,15 +468,32 @@ const handleManualInput = async () => {
const code = barcodeInput.value.trim()
if (!code) return
// ★ 按单出库模式:必须先选择申请单
if (outboundMode.value === 'by-request' && !selectedRequest.value) {
ElMessage.warning('请先选择要出库的审批申请单')
return
}
try {
loading.value = true
// 1. 检查购物车重复
// 1. 检查购物车重复(直接模式走旧的追加逻辑,按单模式也复用但后续会校验)
const existIndex = cartItems.value.findIndex(item => item.barcode === code || item.sku === code)
if (existIndex > -1) {
const item = cartItems.value[existIndex]
const maxQty = parseFloat(item.available_quantity)
// ★ 按单模式:追加时仍需校验计划数量
if (outboundMode.value === 'by-request') {
const err = validateAgainstPlan(item.name, item.spec_model, 1)
if (err) {
ElMessage.error(err)
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
barcodeInput.value = ''
return
}
}
const maxQty = parseFloat(item.available_quantity)
if (item.out_quantity < maxQty) {
item.out_quantity++
ElMessage.success(`数量+1 (当前: ${item.out_quantity})`)
@ -343,7 +515,21 @@ const handleManualInput = async () => {
if (availQty <= 0) {
ElMessage.warning(`库存不足或已出库 (余: ${availQty})`)
if (navigator.vibrate) navigator.vibrate([100, 50, 100])
} else {
barcodeInput.value = ''
return
}
// ★ 按单模式:扫码加入前校验是否在计划清单内
if (outboundMode.value === 'by-request') {
const err = validateAgainstPlan(item.name, item.spec_model, 1)
if (err) {
ElMessage.error(err)
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
barcodeInput.value = ''
return
}
}
// 加入购物车
cartItems.value.push({
...item,
@ -352,7 +538,6 @@ const handleManualInput = async () => {
})
ElMessage.success(`添加成功: ${item.name}`)
if (navigator.vibrate) navigator.vibrate(100)
}
barcodeInput.value = ''
}
} catch (error: any) {
@ -393,6 +578,7 @@ const clearAll = () => {
signatureFile.value = null
signaturePreviewUrl.value = ''
barcodeInput.value = ''
// ★ 按单模式:仅清空购物车,保留申请单选择
})
}
@ -416,40 +602,67 @@ const submitForm = async () => {
try {
loading.value = true
// 上传签名
// 1. 上传签名
const uploadRes = await uploadFile(signatureFile.value)
const signatureUrl = uploadRes.data.url
const itemsPayload = cartItems.value.map(item => ({
stock_id: item.id,
source_table: item.source_table,
sku: item.sku,
barcode: item.barcode,
quantity: item.out_quantity,
price: item.price
}))
// 2. 核心保护:坚决杜绝 undefined、null 和 0
const itemsPayload = cartItems.value.map(item => {
// 强制确保出库数量是一个大于 0 的有效数字
let safeQuantity = Number(item.out_quantity)
if (isNaN(safeQuantity) || safeQuantity <= 0) {
safeQuantity = 1 // 兜底:只要扫了码,最少出 1 个
}
await submitOutbound({
items: itemsPayload,
return {
stock_id: item.id || 0,
source_table: item.source_table || '',
// 如果原数据 sku 是空,强制塞一个默认字符串,绝不传空值给后端引发 None 报错
sku: item.sku ? String(item.sku) : (item.barcode ? String(item.barcode) : 'NO_SKU'),
barcode: item.barcode ? String(item.barcode) : '',
quantity: safeQuantity,
price: item.price ? Number(item.price) : 0
}
})
if (itemsPayload.length === 0) {
ElMessage.warning('请至少扫描一件物料后再提交出库')
return
}
// 3. 组装发给后端的包
const submitPayload: any = {
outbound_type: form.outbound_type,
request_id: outboundMode.value === 'by-request' && selectedRequest.value ? selectedRequest.value.id : null,
consumer_name: form.consumer_name,
operator_name: form.operator_name,
remark: form.remark,
signature_path: signatureUrl
})
signature_path: signatureUrl,
items: itemsPayload
}
// 打印在前端控制台,你可以按 F12 在 Console 里核对这把"铁证"
console.log('准备提交给后端的最终数据:', JSON.parse(JSON.stringify(submitPayload)))
// 4. 发送请求
await submitOutbound(submitPayload)
ElMessage.success('出库成功')
// 重置
// 5. 成功后重置页面
cartItems.value = []
form.consumer_name = ''
form.remark = ''
signatureFile.value = null
// 根据你的项目实际变量重置签名组件,如果没有这句可以删掉
if (typeof signaturePreviewUrl !== 'undefined') {
signaturePreviewUrl.value = ''
loadHistoryOperators()
}
} catch (error) {
console.error(error)
ElMessage.error('提交失败')
console.error('出库报错:', error)
ElMessage.error('提交失败,请检查数据')
} finally {
loading.value = false
}
@ -547,6 +760,39 @@ onUnmounted(() => {
.title-box { font-size: 16px; font-weight: bold; display: flex; align-items: center; gap: 8px; }
.header-price { font-size: 18px; color: #F56C6C; font-weight: bold; }
/* ★ 双轨制模式切换 */
.mode-switch-bar {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
padding: 12px 16px;
background: #f5f7fa;
border-radius: 8px;
border: 1px solid #e4e7ed;
}
.mode-hint { color: #909399; font-size: 13px; }
/* ★ 审批单选择 */
.approval-request-select { margin-bottom: 16px; }
.select-tip { margin: 6px 0 0 0; color: #909399; font-size: 12px; }
/* ★ 计划清单 */
.planned-items-section {
margin-bottom: 16px;
padding: 12px;
background: #f0f9eb;
border: 1px solid #e1f3d8;
border-radius: 8px;
}
.planned-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.planned-title { font-weight: bold; font-size: 14px; color: #67C23A; }
/* 扫码区(卡片内触发器) */
.scan-section { margin-bottom: 20px; }
.camera-placeholder {