Files
KCGL/inventory-backend/app/api/v1/purchase.py
yueli 1000deda2f feat(purchase): 采购单号改为 批次-批内序号 格式,显式体现同批
按用户原始逻辑还原并扩展:
- 单号格式: PUR-日期-时间-批次-批内序号
  例: PUR-20260831-1021-0001-0001(今日第1次采购的第1条)
- generate_request_no 支持 batch_seq 参数生成批次+批内序号
- 新增 GET /purchase/next-batch-seq 返回今日下一个批次号
- 前端提交时获取批次号,同批所有行共享,循环提交生成连续批内序号
- 批次识别: 单号去掉末尾 -批内序号 即为批次前缀
- 保留旧格式兼容(无 batch_seq 时回退 PUR-日期-时间-流水)
2026-08-31 13:54:03 +08:00

413 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

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 is not None else None
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
)
# ★ 字段级价格过滤
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/<id>
# --------------------------------------------------------
@purchase_bp.route('/<int:purchase_id>', 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/<batch_key>
# --------------------------------------------------------
@purchase_bp.route('/batch/<path:batch_key>', 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/<id>/approve
# --------------------------------------------------------
@purchase_bp.route('/<int:purchase_id>/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'):
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 '已驳回'
# ★ 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
records = PurchaseRequest.query.filter(
PurchaseRequest.request_no.like(f"{batch_key}-%"),
PurchaseRequest.status == 0
).all()
ids = [r.id for r in records]
if not ids:
return jsonify({'code': 400, 'msg': '该批次没有待审批的记录'}), 400
if not ids or not isinstance(ids, list):
return jsonify({'code': 400, 'msg': 'ids 或 batch_key 不能为空'}), 400
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
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