背景
----
采购管理此前只有状态筛选,无法按单号/物料/申请人检索,也无法按采购
日期收窄范围。现对齐出库/报废记录的搜索语义,便于用户迁移使用习惯。
后端
----
PurchaseService.get_purchase_list 新增参数:
keyword / search_type / start_date / end_date
search_type 取值(与出库、报废一致):
all 单号 | 物料名称 | 规格型号 | 备注 | 申请人
no 单号
name 物料名称
spec_model 规格型号
requester 申请人
实现要点
--------
· 申请人姓名存在 SysUser.username,格式「姓名/账号」(如 韩善龙/hanshanlong),
ilike 直接匹配整串即可命中;
· 该类字段只在 SysUser 上,故**按需 join** —— 单号/名称/规格分支不联表,
避免无谓开销;
· 公司隔离分支也需 join SysUser,与关键词分支可能重复 join 同一目标,
会产生笛卡尔积导致结果翻倍,故按需补 join 并用 distinct 兜底;
· 日期范围按 purchase_date 过滤(该列是 Date,无需补时分秒)。
实测:名称'充电器'→1 单,申请人'韩善龙'→21 单,日期 05-13→2 单,
关键词不存在→0 单,状态+搜索组合→14 单。
426 lines
16 KiB
Python
426 lines
16 KiB
Python
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/<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', '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
|