import traceback from flask import Blueprint, request, jsonify, current_app from flask_jwt_extended import jwt_required, get_jwt, get_jwt_identity from app.services.purchase_service import PurchaseService from app.utils.decorators import permission_required, prevent_double_submit purchase_bp = Blueprint('purchase', __name__, url_prefix='/api/v1/purchase') def get_current_user_id(): """获取当前登录用户ID""" identity = get_jwt_identity() return identity def get_current_user_role(): """获取当前用户角色""" claims = get_jwt() return claims.get('role') def _user_has_purchase_perm(): """检查当前用户是否有 inbound_purchase 权限(菜单或任意操作权限)""" claims = get_jwt() role = claims.get('role', '') user_company = claims.get('company_name', '') if role.upper() in ('SUPER_ADMIN', 'SUPERVISOR'): return True from app.services.auth_service import AuthService perm_dict = AuthService.get_user_permissions(role, company_name=user_company) all_perms = perm_dict.get('menus', []) + perm_dict.get('elements', []) return 'inbound_purchase' in all_perms or any( p.startswith('inbound_purchase:') for p in all_perms ) def _filter_purchase_prices(item_dict): """Fail-Closed: 无价格权限则剥离采购价格字段""" from app.services.auth_service import AuthService claims = get_jwt() role = claims.get('role', '') if role.upper() in ('SUPER_ADMIN', 'SUPERVISOR'): return perm_dict = AuthService.get_user_permissions(role, company_name=claims.get('company_name', '')) all_perms = perm_dict.get('menus', []) + perm_dict.get('elements', []) if 'inbound_purchase:unit_price' not in all_perms: item_dict.pop('unit_price', None) item_dict.pop('pre_tax_unit_price', None) item_dict.pop('post_tax_unit_price', None) if 'inbound_purchase:total_price' not in all_perms: item_dict.pop('total_price', None) if 'inbound_purchase:tax_rate' not in all_perms: item_dict.pop('tax_rate', None) # -------------------------------------------------------- # 1. 采购申请列表 # GET /api/v1/purchase # -------------------------------------------------------- @purchase_bp.route('', methods=['GET']) @jwt_required() def get_purchase_list(): """采购申请列表:有权限看同公司全部,无权限只看自己的""" try: page = int(request.args.get('page', 1)) per_page = int(request.args.get('limit', 20)) status = request.args.get('status') status = int(status) if status not in (None, '', 'all') else None # 搜索参数(与出库/报废记录同一套语义,便于用户迁移习惯) keyword = (request.args.get('keyword') or '').strip() search_type = (request.args.get('search_type') or 'all').strip() start_date = (request.args.get('start_date') or '').strip() end_date = (request.args.get('end_date') or '').strip() user_id = get_current_user_id() can_view_all = _user_has_purchase_perm() result = PurchaseService.get_purchase_list( page=page, per_page=per_page, requester_id=None if can_view_all else user_id, status=status, keyword=keyword, search_type=search_type, start_date=start_date, end_date=end_date, ) # ★ 字段级价格过滤 for item in (result.get('items') or []): _filter_purchase_prices(item) return jsonify({'code': 200, 'msg': '获取成功', 'data': result}) except Exception as e: traceback.print_exc() return jsonify({'code': 500, 'msg': f'获取失败: {str(e)}'}), 500 # -------------------------------------------------------- # 2. 创建采购申请 # POST /api/v1/purchase # -------------------------------------------------------- @purchase_bp.route('', methods=['POST']) @jwt_required() @prevent_double_submit(lock_timeout=5) def create_purchase_request(): """创建采购申请(任何登录用户可提交)""" try: data = request.get_json() if not data: return jsonify({'code': 400, 'msg': '无有效数据'}), 400 user_id = get_current_user_id() # 必填校验 required = ['name', 'quantity', 'purchase_date', 'approver_id'] for field in required: if field not in data or str(data.get(field, '')).strip() == '': return jsonify({'code': 400, 'msg': f'缺少必填字段: {field}'}), 400 # 图片必填强校验 images = data.get('images') if not images or (isinstance(images, list) and len(images) == 0): return jsonify({'code': 400, 'msg': '请上传采购凭证/物品图片'}), 400 purchase = PurchaseService.create_purchase_request(data, requester_id=user_id) # ★ Fail-Closed: 创建响应剥离价格字段 resp = purchase.to_dict() for k in ('unit_price', 'total_price', 'tax_rate'): resp.pop(k, None) return jsonify({ 'code': 200, 'msg': '创建成功', 'data': resp }), 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 # -------------------------------------------------------- # 3. 获取采购申请详情 # GET /api/v1/purchase/ # -------------------------------------------------------- @purchase_bp.route('/', methods=['GET']) @jwt_required() def get_purchase_detail(purchase_id): """采购申请详情:自己的无需权限,别人的需要 inbound_purchase""" try: purchase = PurchaseService.get_purchase_by_id(purchase_id) if not purchase: return jsonify({'code': 404, 'msg': '采购申请不存在'}), 404 user_id = get_current_user_id() if purchase['requester_id'] != user_id and not _user_has_purchase_perm(): return jsonify({'code': 403, 'msg': '无权查看此申请'}), 403 _filter_purchase_prices(purchase) return jsonify({'code': 200, 'msg': '获取成功', 'data': purchase}), 200 except Exception as e: return jsonify({'code': 500, 'msg': str(e)}), 500 # -------------------------------------------------------- # 3.5 按批次查询采购申请(同一批提交的多条记录,批次=request_no去掉末尾流水号) # GET /api/v1/purchase/batch/ # -------------------------------------------------------- @purchase_bp.route('/batch/', methods=['GET']) @jwt_required() def get_purchase_by_batch(batch_key): """按批次前缀(request_no 去掉末尾4位流水号)查询同一批的所有记录""" try: from app.models.purchase import PurchaseRequest records = PurchaseRequest.query.filter( PurchaseRequest.request_no.like(f"{batch_key}-%") ).order_by(PurchaseRequest.request_no.asc()).all() user_id = get_current_user_id() has_perm = _user_has_purchase_perm() items = [] for r in records: d = r.to_dict() # 非本人且无权限时,价格字段剥离 if d['requester_id'] != user_id and not has_perm: for k in ('unit_price', 'total_price', 'tax_rate'): d.pop(k, None) items.append(d) return jsonify({'code': 200, 'msg': '获取成功', 'data': {'batch_key': batch_key, 'items': items}}), 200 except Exception as e: traceback.print_exc() return jsonify({'code': 500, 'msg': str(e)}), 500 # -------------------------------------------------------- # 4. 审批采购申请 # PATCH /api/v1/purchase//approve # -------------------------------------------------------- @purchase_bp.route('//approve', methods=['PATCH']) @jwt_required() @prevent_double_submit(lock_timeout=5) @permission_required('inbound_purchase:operation') def approve_purchase_request(purchase_id): """审批采购申请:必须有操作权限(inbound_purchase:operation)""" try: user_id = get_current_user_id() data = request.get_json() or {} action = data.get('action', 'approve') reject_reason = data.get('reject_reason') if action not in ('approve', 'reject', 'close'): return jsonify({'code': 400, 'msg': '无效的审批操作'}), 400 if action == 'reject' and not reject_reason: return jsonify({'code': 400, 'msg': '驳回时必须提供原因'}), 400 purchase = PurchaseService.approve_purchase_request( purchase_id=purchase_id, user_id=user_id, action=action, reject_reason=reject_reason ) msg = '审批通过' if action == 'approve' else ('已驳回' if action == 'reject' else '已完结') # ★ Fail-Closed: 审批响应剥离价格字段 resp = purchase.to_dict() for k in ('unit_price', 'total_price', 'tax_rate'): resp.pop(k, None) return jsonify({'code': 200, 'msg': msg, 'data': resp}), 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. 获取可选审批人列表 # GET /api/v1/purchase/approvers # -------------------------------------------------------- @purchase_bp.route('/next-batch-seq', methods=['GET']) @jwt_required() def get_next_batch_seq(): """获取今日下一个采购批次号(前端提交一批时调用,同批所有行共享)""" try: next_seq = PurchaseService.get_next_batch_seq() return jsonify({'code': 200, 'msg': '获取成功', 'data': {'batch_seq': next_seq}}), 200 except Exception as e: traceback.print_exc() return jsonify({'code': 500, 'msg': str(e)}), 500 @purchase_bp.route('/batch-approve', methods=['POST']) @jwt_required() @permission_required('inbound_purchase:operation') def batch_approve_purchase(): """ 批量审批采购申请(解决循环提交导致的多条记录需多次审批问题) 请求体: { "ids": [1, 2, 3], // 按 ID 列表审批 或 "batch_key": "PUR-20260831-1021", // 按批次前缀审批整批(同批流水号连续) "action": "approve" | "reject", "reject_reason": "驳回原因(reject 时必填)" } """ try: user_id = get_current_user_id() data = request.get_json() or {} ids = data.get('ids', []) batch_key = data.get('batch_key') action = data.get('action', 'approve') reject_reason = data.get('reject_reason') # 若传 batch_key(request_no 去掉末尾流水号的前缀),查出该批次对应状态的记录 if batch_key: from app.models.purchase import PurchaseRequest # 完结本批 → 找「已通过(1)」的单;审批/驳回 → 找「待审批(0)」的单 target_status = 1 if action == 'close' else 0 records = PurchaseRequest.query.filter( PurchaseRequest.request_no.like(f"{batch_key}-%"), PurchaseRequest.status == target_status ).all() ids = [r.id for r in records] if not ids: status_text = '已通过' if action == 'close' else '待审批' return jsonify({'code': 400, 'msg': f'该批次没有{status_text}的记录'}), 400 if not ids or not isinstance(ids, list): return jsonify({'code': 400, 'msg': 'ids 或 batch_key 不能为空'}), 400 if action not in ('approve', 'reject', 'close'): return jsonify({'code': 400, 'msg': '无效的审批操作'}), 400 if action == 'reject' and not reject_reason: return jsonify({'code': 400, 'msg': '驳回时必须提供原因'}), 400 success_ids = [] error_items = [] for pid in ids: try: purchase = PurchaseService.approve_purchase_request( purchase_id=pid, user_id=user_id, action=action, reject_reason=reject_reason ) success_ids.append(purchase.id) except ValueError as e: error_items.append({'id': pid, 'msg': str(e)}) except Exception as e: traceback.print_exc() error_items.append({'id': pid, 'msg': str(e)}) msg = f'批量审批完成: 成功 {len(success_ids)} 条' if error_items: msg += f', 失败 {len(error_items)} 条' return jsonify({'code': 200, 'msg': msg, 'data': {'success_ids': success_ids, 'errors': error_items}}), 200 except Exception as e: traceback.print_exc() return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500 @purchase_bp.route('/approvers', methods=['GET']) @jwt_required() def get_purchase_approvers(): """获取可选审批人列表(创建采购申请时选择审批人用)""" try: from app.models.system import SysUser users = SysUser.query.filter( SysUser.role.in_(['SUPER_ADMIN', 'SUPERVISOR']), SysUser.status == 'active' ).all() return jsonify({ 'code': 200, 'msg': '获取成功', 'data': [ {'id': u.id, 'username': u.username, 'email': u.email or '', 'role': u.role} for u in users ] }), 200 except Exception as e: return jsonify({'code': 500, 'msg': str(e)}), 500 # -------------------------------------------------------- # 6. 根据名称/规格自动补全 # GET /api/v1/purchase/auto-fill?keyword=xxx # -------------------------------------------------------- @purchase_bp.route('/auto-fill', methods=['GET']) @jwt_required() def auto_fill_purchase(): """根据名称或规格自动补全另一个字段""" try: keyword = request.args.get('keyword', '').strip() if not keyword: return jsonify({'code': 200, 'msg': 'ok', 'data': None}), 200 result = PurchaseService.auto_fill_from_material(keyword) return jsonify({'code': 200, 'msg': 'ok', 'data': result}), 200 except Exception as e: return jsonify({'code': 500, 'msg': str(e)}), 500 # -------------------------------------------------------- # 7. 已审批且未入库的采购单列表(供库管按单入库使用) # GET /api/v1/purchase/approved-unstocked?page=1&keyword=xxx # -------------------------------------------------------- @purchase_bp.route('/approved-unstocked', methods=['GET']) @jwt_required() @permission_required('inbound_purchase') def get_approved_unstocked_requests(): """获取已审批通过且未入库的采购申请列表""" try: page = int(request.args.get('page', 1)) per_page = int(request.args.get('limit', 20)) keyword = request.args.get('keyword', '').strip() or None result = PurchaseService.get_approved_requests( page=page, per_page=per_page, keyword=keyword ) # ★ 注意:不在此处过滤价格。此端点用于按单入库, # 价格数据需随响应传递到入库表单(前端通过 inbound_buy:unit_price 权限控制写入) return jsonify({'code': 200, 'msg': '获取成功', 'data': result}), 200 except Exception as e: traceback.print_exc() return jsonify({'code': 500, 'msg': f'获取失败: {str(e)}'}), 500 # -------------------------------------------------------- # 8. 物料基础信息搜索(分页) # GET /api/v1/purchase/search-material?keyword=xxx&page=1 # -------------------------------------------------------- @purchase_bp.route('/search-material', methods=['GET']) @jwt_required() def search_material_for_purchase(): """物料基础信息搜索接口,支持分页,用于采购申请弹窗""" try: keyword = request.args.get('keyword', '') page = request.args.get('page', 1, type=int) limit = 20 result = PurchaseService.search_base_material(keyword, page, limit) return jsonify({ 'code': 200, 'msg': 'success', 'data': result['items'], 'total': result['total'], 'has_next': result['has_next'] }), 200 except Exception as e: traceback.print_exc() return jsonify({'code': 500, 'msg': str(e)}), 500