diff --git a/inventory-backend/app/api/v1/auth.py b/inventory-backend/app/api/v1/auth.py index 4a3b512..6feb10e 100644 --- a/inventory-backend/app/api/v1/auth.py +++ b/inventory-backend/app/api/v1/auth.py @@ -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 + + # ============================================================================== # 获取当前用户个人资料(自我查看) # ============================================================================== diff --git a/inventory-backend/app/api/v1/outbound.py b/inventory-backend/app/api/v1/outbound.py index 72f6d2d..6439ec3 100644 --- a/inventory-backend/app/api/v1/outbound.py +++ b/inventory-backend/app/api/v1/outbound.py @@ -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//approve +# -------------------------------------------------------- +@outbound_bp.route('/request//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/ +# -------------------------------------------------------- +@outbound_bp.route('/request/', 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 diff --git a/inventory-backend/app/api/v1/transactions.py b/inventory-backend/app/api/v1/transactions.py index 573de83..7e82f0a 100644 --- a/inventory-backend/app/api/v1/transactions.py +++ b/inventory-backend/app/api/v1/transactions.py @@ -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) diff --git a/inventory-backend/app/models/outbound.py b/inventory-backend/app/models/outbound.py index 4ff4622..e747499 100644 --- a/inventory-backend/app/models/outbound.py +++ b/inventory-backend/app/models/outbound.py @@ -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): diff --git a/inventory-backend/app/services/outbound_service.py b/inventory-backend/app/services/outbound_service.py index 0075de0..0b01d4b 100644 --- a/inventory-backend/app/services/outbound_service.py +++ b/inventory-backend/app/services/outbound_service.py @@ -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, @@ -190,11 +208,11 @@ class OutboundService: 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 - + # 创建出库记录 new_record = TransOutbound( sku=item.get('sku'), @@ -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) diff --git a/inventory-backend/app/services/permission_service.py b/inventory-backend/app/services/permission_service.py index 0124ca1..02783ce 100644 --- a/inventory-backend/app/services/permission_service.py +++ b/inventory-backend/app/services/permission_service.py @@ -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), diff --git a/inventory-backend/config.py b/inventory-backend/config.py index 908c85f..12b164a 100644 --- a/inventory-backend/config.py +++ b/inventory-backend/config.py @@ -48,4 +48,24 @@ class Config: # ========================================================= # 5. Redis 配置 (用于单设备登录互踢) # ========================================================= - REDIS_URL = os.getenv('REDIS_URL', 'redis://localhost:6379/0') \ No newline at end of file + 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系统 ') + # 是否启用邮件发送功能(开发环境可设为 false 禁用) + MAIL_ENABLED = os.getenv('MAIL_ENABLED', 'true').lower() in ('true', '1', 'yes') \ No newline at end of file diff --git a/inventory-web/src/api/auth.ts b/inventory-web/src/api/auth.ts index d03a6c7..52ddf83 100644 --- a/inventory-web/src/api/auth.ts +++ b/inventory-web/src/api/auth.ts @@ -84,4 +84,12 @@ export function batchCreateUser(data: any[]) { method: 'post', data }) +} + +// ★ 获取可指定审批人列表(SUPERVISOR / SUPER_ADMIN 且 status=active) +export function getApproversList() { + return request({ + url: '/v1/auth/users/approvers', + method: 'get' + }) } \ No newline at end of file diff --git a/inventory-web/src/api/outbound.ts b/inventory-web/src/api/outbound.ts index 387f513..220142b 100644 --- a/inventory-web/src/api/outbound.ts +++ b/inventory-web/src/api/outbound.ts @@ -77,4 +77,49 @@ export function getOutboundList(params: any) { method: 'get', 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 + }) } \ No newline at end of file diff --git a/inventory-web/src/router/index.ts b/inventory-web/src/router/index.ts index a9ce21b..fd87421 100644 --- a/inventory-web/src/router/index.ts +++ b/inventory-web/src/router/index.ts @@ -150,6 +150,16 @@ const routes: Array = [ 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'] + } } ] }, diff --git a/inventory-web/src/views/outbound/Selection.vue b/inventory-web/src/views/outbound/Selection.vue index 5fb0c57..c4959ae 100644 --- a/inventory-web/src/views/outbound/Selection.vue +++ b/inventory-web/src/views/outbound/Selection.vue @@ -37,6 +37,9 @@ 生成预览 & 打印 + + 提交出库申请 + @@ -289,6 +292,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +