Files
KCGL/inventory-backend/app/api/v1/transactions.py

440 lines
17 KiB
Python
Raw Normal View History

from flask import Blueprint, jsonify, request # .material -> .base refactor checked
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
from app.utils.decorators import permission_required, audit_log, prevent_double_submit, is_privileged_viewer
from app.services.auth_service import AuthService
2026-02-06 17:11:47 +08:00
from app.services.trans_service import TransService
from app.services.borrow_service import BorrowApprovalService
2026-02-06 17:11:47 +08:00
import traceback
trans_bp = Blueprint('transactions', __name__, url_prefix='/transactions')
# ==============================================================================
# 辅助函数:获取当前用户的完整权限列表(基于角色查询)
# ==============================================================================
def get_current_user_permissions():
"""
返回当前用户拥有的所有权限码列表(包括菜单和元素)
此函数根据角色查询数据库得到权限。
"""
claims = get_jwt()
user_role = claims.get('role')
user_company = claims.get('company_name', '')
if not user_role:
return []
# 超级管理员返回所有字段权限 (忽略大小写)
if user_role.upper() == 'SUPER_ADMIN':
return ['*']
perm_dict = AuthService.get_user_permissions(user_role, company_name=user_company)
# 合并菜单和元素权限
perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
return perms
def get_current_user_info():
"""获取当前用户信息和角色"""
from app.models.system import SysUser
identity = get_jwt_identity()
if not identity:
return None, None
user = SysUser.query.get(identity)
return user.id if user else None, user.role if user else None
def _current_username():
"""获取当前登录用户的用户名(姓名/账号),用于操作人展示;避免把 JWT 数字 ID 存进记录"""
identity = get_jwt_identity()
if not identity:
return 'System'
from app.models.system import SysUser
user = SysUser.query.get(identity)
return user.username if user else str(identity)
def filter_item_by_permissions(item_dict, user_permissions, prefix='op_records'):
"""
根据用户权限过滤 item 字典,无权限的字段值置为 None
★ Fail-Closed: 字段映射默认为完整列表,不再为空字典。
"""
# sys_element 补齐前不做字段级过滤
field_to_perm = {}
if '*' in user_permissions or f'{prefix}:*' in user_permissions:
return item_dict
for field, perm_code in field_to_perm.items():
if field in item_dict and perm_code not in user_permissions:
item_dict[field] = None
return item_dict
2026-02-06 17:11:47 +08:00
# --- 借库接口 ---
@trans_bp.route('/borrow', methods=['POST'])
@jwt_required()
@permission_required('op_borrow:operation')
2026-03-10 17:27:54 +08:00
@audit_log(
module='借库管理',
action='新增',
get_target_name_fn=lambda: request.get_json().get('borrow_no') if request.get_json() else None
)
2026-02-06 17:11:47 +08:00
def create_borrow():
data = request.get_json()
try:
no = TransService.create_borrow(data)
return jsonify({'code': 200, 'msg': '借用成功', 'data': {'borrow_no': no}})
except Exception as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
# --- 还库辅助:扫码查找借出记录 ---
@trans_bp.route('/return/scan', methods=['GET'])
@jwt_required()
@permission_required('op_return')
2026-02-06 17:11:47 +08:00
def scan_borrowed_item():
barcode = request.args.get('barcode')
if not barcode:
return jsonify({'code': 400, 'msg': '无条码'}), 400
res = TransService.scan_for_return(barcode)
if res:
return jsonify({'code': 200, 'data': res})
else:
return jsonify({'code': 404, 'msg': '未找到该物品的未还记录'}), 404
# --- 还库提交 ---
@trans_bp.route('/return', methods=['POST'])
@jwt_required()
@permission_required('op_return:operation')
2026-03-10 17:27:54 +08:00
@audit_log(
module='借库管理',
action='归还',
get_target_name_fn=lambda: request.get_json().get('borrow_no') if request.get_json() else None
)
2026-02-06 17:11:47 +08:00
def submit_return():
data = request.get_json()
# ★ 归还人存"姓名",而非 JWT 数字 ID
operator_name = _current_username()
2026-02-06 17:11:47 +08:00
try:
TransService.process_return(data, operator_name=operator_name)
2026-02-06 17:11:47 +08:00
return jsonify({'code': 200, 'msg': '还库成功'})
except Exception as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
# --- 借库报废(未归还直接报废,关联报废单流程)---
@trans_bp.route('/borrow/scrap', methods=['POST'])
@jwt_required()
@permission_required('op_return:operation') # 复用归还权限:能归还的库管即可报废
@audit_log(
module='借库管理',
action='借库报废',
get_target_name_fn=lambda: request.get_json().get('reason') if request.get_json() else None
)
def scrap_borrow():
"""
借库未归还直接报废(库管/主管操作)
请求体: { "record_ids": [1, 2, 3], "reason": "物品丢失" }
"""
data = request.get_json() or {}
record_ids = data.get('record_ids', [])
reason = data.get('reason', '')
if not record_ids:
return jsonify({'code': 400, 'msg': '请选择要报废的借出记录'}), 400
operator_name = _current_username() or 'Unknown'
try:
result = TransService.scrap_borrow(record_ids, operator_name=operator_name, reason=reason)
return jsonify({'code': 200, 'msg': f'已报废 {result["count"]} 条借出记录', 'data': result})
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
2026-02-06 17:11:47 +08:00
# --- 记录列表 ---
@trans_bp.route('/records', methods=['GET'])
@jwt_required()
@permission_required('op_records')
2026-02-06 17:11:47 +08:00
def get_records():
status = request.args.get('status', 'all')
page = int(request.args.get('page', 1))
keyword = request.args.get('keyword', '')
search_type = request.args.get('search_type', 'all')
2026-02-06 17:11:47 +08:00
# ★ 数据权限:普通用户只看“借用人=本人姓名(不含账号前缀)”的借还记录;管理者看全部
borrower_name = None
if not is_privileged_viewer():
_identity = get_jwt_identity()
if _identity:
from app.models.system import SysUser
_u = SysUser.query.get(int(_identity))
_uname = _u.username if _u else ''
borrower_name = _uname.split('/')[0].strip() if _uname else None
res = TransService.get_records(
page=page, limit=10, status=status, keyword=keyword,
search_type=search_type, borrower_name=borrower_name
)
# ★ service 层异常时:code==500 的字典(带 traceback),需要直通到前端,便于排查
if isinstance(res, dict) and res.get('code') == 500:
return jsonify({
'code': 500,
'msg': res.get('msg', '服务内部错误'),
'trace': res.get('trace', '')
}), 500
# 字段级脱敏
user_permissions = get_current_user_permissions()
if res.get('items'):
res['items'] = [filter_item_by_permissions(item, user_permissions, 'op_records') for item in res['items']]
return jsonify({'code': 200, 'data': res})
# ==============================================================================
# 借库审批流 API(与出库审批流平行)
# ==============================================================================
# --- 提交借库申请 ---
@trans_bp.route('/borrow/request', methods=['POST'])
@jwt_required()
perf: 综合安全加固 — RBAC严格映射+异步邮件+字段权限白名单+前端对齐+导入模板 本次提交包含本会话所有修改的最终统一提交 ## 权限系统重构 - permission_service.py: 添加入库/采购操作元素 + ensure_default_permissions - field_permissions.py: 严格1-to-1 Default Deny 字段映射(StockBuy/Semi/Product/MaterialBase) - decorators.py: _expand_operation_perms 双向粒度桥接 + prevent_double_submit - deploy_production.sql: 修复 sys_element 别名码(qty_inbound→in_quantity) ## 采购模块 - purchase.py: 权限驱动可见性 + inbound_purchase独立权限 + 价格字段过滤 - purchase_service.py: 异步邮件 + 三阶段批量模糊匹配防N+1 - purchase/index.vue: canApprove严格操作权限 + upload重复修复 ## 导出/入 - base_service.py: export_excel 流式写入防OOM + get_latest_specs 优化 - import_service.py + import_api.py: Excel批量导入(模板+预览+执行) - ImportDialog.vue: 三步骤导入弹窗 ## 异步邮件 - email_service.py: send_email_async (守护线程) - inventory_task.py: send_email→send_email_async ## 前端对齐 - product/semi/buy.vue: 列对齐in_quantity/stock_quantity/available_quantity + localStorage缓存V2 - buyOdoo.vue: 排序修复 + 导入按钮 + 移除点击展开加载 - BomManage.vue: 懒加载分组 + 导入按钮 - list.vue: 导入按钮 - Selection.vue + borrow/apply: BOM匹配修复 + 导入按钮 - outbound/create.vue: 出库类型必选 - AppMain.vue: 移除transition白屏修复 - material_base.ts, outbound.ts, bom.ts, stock.ts: 新增API函数
2026-07-17 13:07:12 +08:00
@permission_required('op_borrow_apply')
def submit_borrow_request():
"""
提交借库申请(仅存储意向,不扣库存)
请求体: { items: [...], allowed_approvers: [...], remark: '', approver_id: int }
"""
try:
user_id, user_role = get_current_user_info()
if not user_id:
return jsonify({'code': 401, 'msg': '用户未登录'}), 401
from app.models.system import SysUser
current_user = SysUser.query.get(user_id)
current_username = current_user.username if current_user else None
data = request.get_json() or {}
items = data.get('items', [])
if not items:
return jsonify({'code': 400, 'msg': '借库物品列表不能为空'}), 400
required_fields = ['name', 'spec_model', 'quantity']
for idx, item in enumerate(items):
missing = [f for f in required_fields if f not in item or str(item.get(f) or '').strip() == '']
if missing:
return jsonify({
'code': 400,
'msg': f'第{idx + 1}条物品缺少必填字段: {", ".join(missing)}'
}), 400
try:
qty = float(item.get('quantity', 0))
if qty <= 0:
return jsonify({'code': 400, 'msg': f'第{idx + 1}条物品的借库数量必须大于0'}), 400
except (TypeError, ValueError):
return jsonify({'code': 400, 'msg': f'第{idx + 1}条物品的 quantity 格式无效'}), 400
approver_id = data.get('approver_id')
_default_approvers = [
{"type": "role", "value": "SUPERVISOR"},
{"type": "role", "value": "SUPER_ADMIN"}
]
allowed_approvers = data.get('allowed_approvers') or _default_approvers
approval = BorrowApprovalService.submit_approval(
applicant_id=user_id,
items=items,
allowed_approvers=allowed_approvers,
remark=data.get('remark'),
approver_id=approver_id,
borrower_name=current_username,
force_approval=((user_role or '').upper() == 'WAREHOUSE_MGR') # 库管代建 → 强制审批
)
return jsonify({'code': 200, 'msg': '借库申请已提交', 'data': approval.to_dict()}), 200
except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
except Exception as e:
return jsonify({'code': 500, 'msg': f"接口内部报错: {str(e)}", 'trace': traceback.format_exc()}), 500
# --- 审批借库申请 ---
@trans_bp.route('/borrow/request/<int:request_id>/approve', methods=['PATCH'])
@jwt_required()
@permission_required('op_borrow_approval')
def approve_borrow_request(request_id):
"""
审批借库申请
请求体: {"action": "approve" | "reject", "reject_reason": "驳回原因"}
"""
try:
user_id, user_role = get_current_user_info()
if not user_id:
return jsonify({'code': 401, 'msg': '用户未登录'}), 401
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': '无效的审批操作,仅支持 approve 或 reject'}), 400
if action == 'reject' and not reject_reason:
return jsonify({'code': 400, 'msg': '驳回时必须提供原因'}), 400
success, message, approval = BorrowApprovalService.approve(
request_id=request_id,
user_id=user_id,
user_role=user_role,
action=action,
reject_reason=reject_reason
)
if not success:
return jsonify({'code': 400, 'msg': message}), 400
return jsonify({'code': 200, 'msg': message, 'data': approval.to_dict() if approval else None}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
@trans_bp.route('/borrow/request/<int:request_id>/close', methods=['POST'])
@jwt_required()
@permission_required('op_borrow_approval')
def close_borrow_request(request_id):
"""
手动完结已通过的借库审批单(status 1-已通过 → 3-已完成)
参照出库审批的完结逻辑,供库管/主管在未走扫码借出时强制完结
"""
try:
user_id, _ = get_current_user_info()
if not user_id:
return jsonify({'code': 401, 'msg': '用户未登录'}), 401
success, message, approval = BorrowApprovalService.mark_completed(request_id)
if not success:
return jsonify({'code': 400, 'msg': message}), 400
return jsonify({'code': 200, 'msg': message, 'data': approval.to_dict() if approval else None}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
# --- 借库申请预检(判断所选物料是否需审批,驱动前端是否显示审批人) ---
@trans_bp.route('/borrow/request/check-approval', methods=['POST'])
@jwt_required()
@permission_required('op_borrow_apply')
def check_borrow_approval():
try:
data = request.get_json() or {}
items = data.get('items', []) or []
from app.services.approval_control import resolve_approval_control
need_approval, flagged = resolve_approval_control(items)
return jsonify({
"code": 200, "msg": "success",
"data": {"need_approval": need_approval, "materials": flagged}
}), 200
except Exception as e:
import traceback; traceback.print_exc()
return jsonify({"code": 500, "msg": f"预检失败: {str(e)}"}), 500
# --- 获取借库审批单列表 ---
@trans_bp.route('/borrow/request', methods=['GET'])
@jwt_required()
@permission_required('op_borrow_approval')
def get_borrow_request_list():
"""
获取借库审批单列表
Query参数: page, limit, applicant_id, status
"""
try:
page = int(request.args.get('page', 1))
limit = int(request.args.get('limit', 10))
applicant_id = request.args.get('applicant_id')
if applicant_id:
applicant_id = int(applicant_id)
status = request.args.get('status')
if status is not None:
status = int(status)
# ★ 数据权限:普通申请人只能看“自己的”借还记录;库管/主管/超管(或跨域)才可看他人
if not is_privileged_viewer():
identity = get_jwt_identity()
applicant_id = int(identity) if identity else None
result = BorrowApprovalService.get_request_list(
page=page, per_page=limit, applicant_id=applicant_id, status=status
)
return jsonify({'code': 200, 'msg': '获取成功', 'data': result}), 200
except Exception as e:
return jsonify({'code': 500, 'msg': str(e)}), 500
# --- 借库选单:库存查询(独立权限)---
@trans_bp.route('/borrow/stock-list', methods=['GET'])
@jwt_required()
@permission_required('op_borrow_apply')
def get_borrow_stock_list():
"""借库选单专用库存列表 — Fail-Closed: 剥离价格字段"""
from app.api.v1.inbound.stock import _do_get_stock_list
return _do_get_stock_list(permission_prefix='op_borrow_apply')
# --- 执行借库扣减(审批通过后调用)---
@trans_bp.route('/borrow/dispatch', methods=['POST'])
@jwt_required()
@prevent_double_submit(lock_timeout=5)
@permission_required('op_borrow:operation')
def dispatch_borrow():
"""
执行借库扣减
请求体: {
approval_id: int, // 关联的审批单ID
items: [ // 扫码选中的库存物品
{
id: int, // 库存主键(按 source_table 路由到 StockBuy/StockSemi/StockProduct)
source_table: str, // 'stock_buy' | 'stock_semi' | 'stock_product'
sku: str, // 可选;不参与审批上限校验
out_quantity: float
}
],
// ★ 审批上限校验在 service 层完成:以 (name, spec_model) 为物料维度聚合
// 锁定 stock 行后从 material_base 表取真实 (name, spec_model) 与审批单比对
borrower_name: str,
signature_path: str,
remark: str,
expected_return_time: str
}
"""
try:
data = request.get_json() or {}
approval_id = data.get('approval_id')
if not approval_id:
return jsonify({'code': 400, 'msg': '缺少 approval_id'}), 400
borrow_no = TransService.execute_dispatch(
approval_id=approval_id,
items=data.get('items', []),
operator_name=_current_username(),
borrower_name=data.get('borrower_name'),
signature=data.get('signature_path'),
remark=data.get('remark'),
expected_return_time=data.get('expected_return_time')
)
return jsonify({'code': 200, 'msg': '借库成功', 'data': {'borrow_no': borrow_no}}), 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