feat(purchase): 新增采购申请模块后端(模型+Service+API路由)

This commit is contained in:
dxc
2026-05-12 16:33:18 +08:00
parent f2f9409206
commit 9dfcb93146
4 changed files with 530 additions and 0 deletions

View File

@ -126,6 +126,17 @@ def create_app():
except ImportError as e:
print(f"❌ 错误: Scrap 模块导入失败: {e}")
# -----------------------------------------------------
# 2.8 注册采购管理模块
# -----------------------------------------------------
try:
from app.api.v1.purchase import purchase_bp
app.register_blueprint(purchase_bp, url_prefix='/api/v1/purchase')
app.register_blueprint(purchase_bp, url_prefix='/api/purchase', name='purchase_legacy')
print("✅ Purchase 模块注册成功")
except ImportError as e:
print(f"❌ 错误: Purchase 模块导入失败: {e}")
# -----------------------------------------------------
# 2.7 注册 BOM 模块
# -----------------------------------------------------

View File

@ -0,0 +1,202 @@
import traceback
from flask import Blueprint, request, jsonify, current_app
from flask_jwt_extended import jwt_required, get_jwt_identity
from app.services.purchase_service import PurchaseService
from app.utils.decorators import permission_required
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():
"""获取当前用户角色"""
from flask_jwt_extended import get_jwt
claims = get_jwt()
return claims.get('role')
# --------------------------------------------------------
# 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 is not None else None
user_id = get_current_user_id()
role = get_current_user_role()
# 普通用户SUPERVISOR 和 SUPER_ADMIN 除外)只看自己提交的
is_admin = role in ('SUPERVISOR', 'SUPER_ADMIN')
result = PurchaseService.get_purchase_list(
page=page,
per_page=per_page,
requester_id=None if is_admin else user_id,
status=status
)
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()
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
purchase = PurchaseService.create_purchase_request(data, requester_id=user_id)
return jsonify({
'code': 200,
'msg': '创建成功',
'data': purchase.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
# --------------------------------------------------------
# 3. 获取采购申请详情
# GET /api/v1/purchase/<id>
# --------------------------------------------------------
@purchase_bp.route('/<int:purchase_id>', methods=['GET'])
@jwt_required()
def get_purchase_detail(purchase_id):
"""获取采购申请详情"""
try:
purchase = PurchaseService.get_purchase_by_id(purchase_id)
if not purchase:
return jsonify({'code': 404, 'msg': '采购申请不存在'}), 404
# 普通用户只能看自己的
user_id = get_current_user_id()
role = get_current_user_role()
is_admin = role in ('SUPERVISOR', 'SUPER_ADMIN')
if not is_admin and purchase['requester_id'] != user_id:
return jsonify({'code': 403, 'msg': '无权查看此申请'}), 403
return jsonify({'code': 200, 'msg': '获取成功', 'data': purchase}), 200
except Exception as e:
return jsonify({'code': 500, 'msg': str(e)}), 500
# --------------------------------------------------------
# 4. 审批采购申请
# PATCH /api/v1/purchase/<id>/approve
# --------------------------------------------------------
@purchase_bp.route('/<int:purchase_id>/approve', methods=['PATCH'])
@jwt_required()
def approve_purchase_request(purchase_id):
"""审批采购申请"""
try:
user_id = get_current_user_id()
role = get_current_user_role()
if role not in ('SUPERVISOR', 'SUPER_ADMIN'):
return jsonify({'code': 403, 'msg': '只有主管或超级管理员可以审批'}), 403
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': '无效的审批操作'}), 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 '已驳回'
return jsonify({'code': 200, 'msg': msg, 'data': purchase.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. 获取可选审批人列表
# GET /api/v1/purchase/approvers
# --------------------------------------------------------
@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

View File

@ -0,0 +1,86 @@
import json
from app.extensions import db, beijing_time
from datetime import datetime
class PurchaseRequest(db.Model):
"""
采购申请表
"""
__tablename__ = 'purchase_request'
id = db.Column(db.Integer, primary_key=True)
request_no = db.Column(db.String(100), unique=True, nullable=False, index=True)
name = db.Column(db.String(255), nullable=False, comment='采购名称')
spec_model = db.Column(db.String(255), comment='规格型号')
quantity = db.Column(db.Numeric(19, 4), nullable=False, comment='采购数量')
purchase_date = db.Column(db.Date, nullable=False, comment='采购时间')
supplier_link = db.Column(db.String(500), comment='商家地址链接')
remark = db.Column(db.Text, comment='备注信息')
images = db.Column(db.Text, comment='图片列表JSON')
unit_price = db.Column(db.Numeric(19, 4), default=0, comment='单价')
total_price = db.Column(db.Numeric(19, 4), default=0, comment='总价')
status = db.Column(db.Integer, default=0, nullable=False)
requester_id = db.Column(db.Integer, nullable=False, index=True)
approver_id = db.Column(db.Integer, index=True)
approved_at = db.Column(db.DateTime)
reject_reason = 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):
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 Exception:
return []
return []
def get_images(self):
return self._safe_parse_json(self.images)
def set_images(self, image_list):
self.images = json.dumps(image_list, ensure_ascii=False) if image_list else '[]'
def _get_user_name(self, user_id):
if not user_id:
return ""
from app.models.system import SysUser
try:
user = db.session.get(SysUser, user_id)
return user.username if user else f"未知用户({user_id})"
except Exception:
return f"用户({user_id})"
def to_dict(self):
return {
'id': self.id,
'request_no': self.request_no,
'name': self.name,
'spec_model': self.spec_model or '',
'quantity': float(self.quantity) if self.quantity else 0,
'purchase_date': self.purchase_date.strftime('%Y-%m-%d') if self.purchase_date else None,
'supplier_link': self.supplier_link or '',
'remark': self.remark or '',
'images': self.get_images(),
'unit_price': float(self.unit_price) if self.unit_price else 0,
'total_price': float(self.total_price) if self.total_price else 0,
'status': self.status,
'status_text': ['待审批', '已通过', '已驳回', '已完成'][self.status] if self.status in [0, 1, 2, 3] else '未知',
'requester_id': self.requester_id,
'requester_name': self._get_user_name(self.requester_id),
'approver_id': self.approver_id,
'approver_name': self._get_user_name(self.approver_id) if self.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 or '',
'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,
}

View File

@ -0,0 +1,231 @@
import json
from datetime import datetime, timezone, timedelta, date
from sqlalchemy import func
from app.extensions import db
from app.models.purchase import PurchaseRequest
from app.models.base import MaterialBase
class PurchaseService:
@staticmethod
def generate_request_no():
"""生成采购单号: PUR-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"PUR-{date_str}-{time_str}-"
existing_count = db.session.query(func.count(func.distinct(PurchaseRequest.request_no))) \
.filter(PurchaseRequest.request_no.like(f"{prefix}%")).scalar()
return f"{prefix}{(existing_count + 1):04d}"
@staticmethod
def auto_fill_from_material(keyword: str):
"""
根据 name 或 spec_model 自动补全另一个字段
keyword: 用户输入的名称或规格
返回: {'name': ..., 'spec_model': ...} 或 None
"""
if not keyword:
return None
material = MaterialBase.query.filter(
(MaterialBase.name.ilike(f'%{keyword}%')) |
(MaterialBase.spec_model.ilike(f'%{keyword}%'))
).first()
if material:
return {
'name': material.name,
'spec_model': material.spec_model or ''
}
return None
@staticmethod
def create_purchase_request(data: dict, requester_id: int):
"""
创建采购申请
data 包含: name, spec_model, quantity, purchase_date, supplier_link, remark, images,
unit_price, total_price, approver_id
"""
request_no = PurchaseService.generate_request_no()
purchase_date = data.get('purchase_date')
if isinstance(purchase_date, str):
purchase_date = datetime.strptime(purchase_date, '%Y-%m-%d').date()
elif isinstance(purchase_date, datetime):
purchase_date = purchase_date.date()
purchase = PurchaseRequest(
request_no=request_no,
name=data['name'],
spec_model=data.get('spec_model', ''),
quantity=float(data['quantity']),
purchase_date=purchase_date,
supplier_link=data.get('supplier_link', ''),
remark=data.get('remark', ''),
images=json.dumps(data.get('images', []), ensure_ascii=False) if data.get('images') else '[]',
unit_price=float(data.get('unit_price', 0) or 0),
total_price=float(data.get('total_price', 0) or 0),
requester_id=requester_id,
approver_id=data.get('approver_id'),
status=0
)
db.session.add(purchase)
db.session.commit()
# 发送邮件给审批人
PurchaseService._notify_new_request(purchase)
return purchase
@staticmethod
def approve_purchase_request(purchase_id: int, user_id: int, action: str, reject_reason: str = None):
"""
审批采购申请
action: 'approve''reject'
"""
purchase = db.session.get(PurchaseRequest, purchase_id)
if not purchase:
raise ValueError("采购申请不存在")
if purchase.status != 0:
raise ValueError("当前状态不允许审批")
beijing_tz = timezone(timedelta(hours=8))
now = datetime.now(beijing_tz)
if action == 'approve':
purchase.status = 1
purchase.approver_id = user_id
purchase.approved_at = now
db.session.commit()
PurchaseService._notify_approved(purchase)
elif action == 'reject':
purchase.status = 2
purchase.approver_id = user_id
purchase.approved_at = now
purchase.reject_reason = reject_reason or ''
db.session.commit()
PurchaseService._notify_rejected(purchase)
else:
raise ValueError("无效的审批操作")
return purchase
@staticmethod
def get_purchase_list(page=1, per_page=20, requester_id=None, status=None):
"""获取采购申请列表,普通用户只看自己的,主管/超管看全部"""
query = PurchaseRequest.query
if requester_id is not None:
query = query.filter(PurchaseRequest.requester_id == requester_id)
if status is not None:
query = query.filter(PurchaseRequest.status == status)
query = query.order_by(PurchaseRequest.created_at.desc())
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
return {
'items': [p.to_dict() for p in pagination.items],
'total': pagination.total,
'pages': pagination.pages,
'current_page': page
}
@staticmethod
def get_purchase_by_id(purchase_id: int):
purchase = db.session.get(PurchaseRequest, purchase_id)
return purchase.to_dict() if purchase else None
@staticmethod
def _notify_new_request(purchase):
"""发送新申请邮件给审批人"""
try:
from app.utils.email_service import send_email
from app.models.system import SysUser
if not purchase.approver_id:
return
approver = db.session.get(SysUser, purchase.approver_id)
if not approver or not approver.email:
return
subject = f"【待审批】采购申请单 {purchase.request_no}"
content = f"""您好,
您有一笔新的采购申请待审批:
申请单号:{purchase.request_no}
采购物品:{purchase.name}
规格型号:{purchase.spec_model or '-'}
采购数量:{float(purchase.quantity)}
申请时间:{purchase.created_at.strftime('%Y-%m-%d %H:%M') if purchase.created_at else '-'}
备注说明:{purchase.remark or ''}
请登录仓库管理系统进行审批。
此邮件由系统自动发送,请勿回复。
"""
send_email(approver.email, subject, content)
except Exception as e:
try:
from flask import current_app
current_app.logger.error(f"[Email] 采购申请通知审批人失败: {e}")
except Exception:
print(f"[Email] 采购申请通知审批人失败: {e}")
@staticmethod
def _notify_approved(purchase):
"""审批通过后通知申请人"""
try:
from app.utils.email_service import send_email
from app.models.system import SysUser
requester = db.session.get(SysUser, purchase.requester_id)
if not requester or not requester.email:
return
subject = f"【已通过】采购申请单 {purchase.request_no}"
content = f"""{"尊敬的 " + requester.username + ",您好" if requester.username else "您好"}
您的采购申请单 {purchase.request_no}{purchase.name})已审批通过,现已交给库管。
待库管完成入库后,您可在系统中查询采购记录。
此邮件由系统自动发送,请勿回复。
"""
send_email(requester.email, subject, content)
except Exception as e:
try:
from flask import current_app
current_app.logger.error(f"[Email] 采购申请通过通知申请人失败: {e}")
except Exception:
print(f"[Email] 采购申请通过通知申请人失败: {e}")
@staticmethod
def _notify_rejected(purchase):
"""审批驳回后通知申请人"""
try:
from app.utils.email_service import send_email
from app.models.system import SysUser
requester = db.session.get(SysUser, purchase.requester_id)
if not requester or not requester.email:
return
subject = f"【已驳回】采购申请单 {purchase.request_no}"
content = f"""{"尊敬的 " + requester.username + ",您好" if requester.username else "您好"}
您的采购申请单 {purchase.request_no}{purchase.name})已被驳回。
驳回原因:{purchase.reject_reason or '未说明'}
请登录仓库管理系统查看详情。
此邮件由系统自动发送,请勿回复。
"""
send_email(requester.email, subject, content)
except Exception as e:
print(f"[Email] 采购申请驳回通知失败: {e}")