Files
KCGL/inventory-backend/app/api/v1/purchase.py
yueli e93107458a feat(purchase): 采购列表支持关键词搜索与日期范围
背景
----
采购管理此前只有状态筛选,无法按单号/物料/申请人检索,也无法按采购
日期收窄范围。现对齐出库/报废记录的搜索语义,便于用户迁移使用习惯。

后端
----
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 单。
2026-09-10 17:41:22 +08:00

426 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 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_keyrequest_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