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, prevent_double_submit, is_privileged_viewer from app.services.auth_service import AuthService from app.services.trans_service import TransService, user_display_name from app.services.borrow_service import BorrowApprovalService 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 # --- 借库接口 --- @trans_bp.route('/borrow', methods=['POST']) @jwt_required() @permission_required('op_borrow:operation') 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') 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') def submit_return(): """ 还库提交。 请求体: { "items": [...], # 待还明细,含 trans_borrow.id 与 return_qty "signature_path": "...", # 库管签字 "returner_id": 12 # ★ 实际归还人ID(一期转交改造新增) } ★ operator_name(库管)与 returner_id(归还人)是**两个人**: 前者是窗口经手人,取自 JWT;后者是实际把物品交回来的人,由前端选择。 记录有 current_holder_id 时,service 层强校验 returner_id 必须等于它, 不匹配即整单回滚 —— 这是转交上线后责任链的关键一环。 """ data = request.get_json() or {} # ★ 归还人存"姓名",而非 JWT 数字 ID operator_name = _current_username() try: TransService.process_return( data, operator_name=operator_name, returner_id=data.get('returner_id'), ) return jsonify({'code': 200, 'msg': '还库成功'}) except Exception as e: return jsonify({'code': 400, 'msg': str(e)}), 400 # --- 借库报废申请(未归还 → 提交报废申请,需审批)--- @trans_bp.route('/borrow/scrap-request', methods=['POST']) @jwt_required() @permission_required('op_return:operation') # 复用归还权限:能归还的库管即可申请报废 # ★ 幂等锁置于 permission_required 内层:prevent_double_submit 依赖 # get_jwt_identity(),放外层会因 JWT 未验证而抛错、被自身 except 捕获后降级放行 @prevent_double_submit(lock_timeout=5) def submit_borrow_scrap_request(): """ 提交「借出未归还」的**报废申请**(需审批人审批,通过后由库管执行报废)。 请求体: { "record_ids": [1, 2, 3], # 必填,trans_borrow.id 列表(按整条待还量报废) "reason": "物品丢失", # 可选,写入申请单备注 "approver_id": 7 # 必填,指定审批人 } ★ 为什么改走审批: 原先 POST /borrow/scrap 直接写 trans_scrap 并扣总库存,绕过审批,与系统 自陈的「报废一律需审批」冲突,构成职责分离漏洞 —— 同一个库管可自行宣告 实物损失而无人复核。现统一走:申请 → 审批 → 执行。 ★ 执行方式:本来源为「免扫码」—— 东西在借用人手上,物理上不可能扫码; 且执行只改台账与总库存,不产生任何可被挪用的可用库存。 """ data = request.get_json() or {} record_ids = data.get('record_ids') or [] reason = (data.get('reason') or '').strip() approver_id = data.get('approver_id') if not record_ids: return jsonify({'code': 400, 'msg': '请选择要申请报废的借出记录'}), 400 if not approver_id: return jsonify({'code': 400, 'msg': '请选择审批人'}), 400 try: from app.models.transaction import TransBorrow # 逐条载入并校验。 # ★ 已归还/已报废的**直接报错**,不静默跳过 —— 旧实现是 `continue` # 然后返回 count=0,用户以为成功实则什么都没发生(缺陷)。 items = [] missing = [] for rid in record_ids: try: rid = int(rid) except (TypeError, ValueError): raise ValueError(f'借出记录 ID 无效:{rid}') record = TransBorrow.query.get(rid) if not record: missing.append(str(rid)) continue if record.is_returned: raise ValueError( f"借用记录【{record.borrow_no or rid}】已归还或已报废,不可再申请报废" ) pending = (float(record.quantity or 0) - float(record.returned_quantity or 0)) if pending <= 0: raise ValueError( f"借用记录【{record.borrow_no or rid}】无待还数量,无需报废" ) items.append({ 'source_table': 'trans_borrow', 'stock_id': record.id, 'scrap_qty': pending, }) if missing: raise ValueError(f'以下借出记录不存在:{"、".join(missing)}') if not items: raise ValueError('所选借出记录均无待还数量,无需报废') from app.services.scrap_approval_service import ScrapApprovalService req = ScrapApprovalService.submit_approval( applicant_id=get_jwt_identity(), items=items, remark=reason or None, approver_id=approver_id, ) return jsonify({ 'code': 200, 'msg': f'报废申请已提交({len(items)} 条明细),待审批人审批', 'data': { 'request_id': req.id, 'request_no': req.request_no, 'count': len(items), }, }), 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 # --- 记录列表 --- @trans_bp.route('/records', methods=['GET']) @jwt_required() @permission_required('op_records') 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') start_date = request.args.get('start_date', '') end_date = request.args.get('end_date', '') # ★ 高级筛选:JSON 字符串 → 条件列表 from app.utils.advanced_filter import parse_advanced_filters advanced_filters = parse_advanced_filters(request.args.get('advancedFilters', '')) # ★ 数据权限:普通用户只看“借用人=本人姓名(不含账号前缀)”的借还记录;管理者看全部 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, start_date=start_date, end_date=end_date, advanced_filters=advanced_filters, ) # ★ 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() @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//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//close', methods=['POST']) @jwt_required() @permission_required('op_borrow_approval') def close_borrow_request(request_id): """ 手动完结已通过的借库审批单(status 1-已通过 → 4-已完结) 参照出库审批的完结逻辑,供库管/主管在未走扫码借出时强制完结 """ 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//withdraw', methods=['POST']) @jwt_required() def withdraw_borrow_request(request_id): """ 申请人撤回自己的借库申请单(待审批 或 已通过但未执行)。 ★ 严格职责分离:本端点**不做模块权限校验**(@jwt_required 即可), 权限判定完全落在「单据归属」上 —— 服务层会断言 applicant_id == 当前用户,否则 403。库管/主管可代撤。 与 /close 的区别:/close 是管理路径(需 op_borrow_approval 权限), 本端点是申请人路径,两者共用底层释放逻辑。 """ try: identity = get_jwt_identity() if not identity: return jsonify({'code': 401, 'msg': '用户未登录'}), 401 success, message, approval = BorrowApprovalService.withdraw_request( request_id=request_id, user_id=int(identity), ) if not success: code = 403 if '无权' in message else 400 return jsonify({'code': code, 'msg': message}), code 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_id: int, // ★ 实际借用人ID(一期转交改造后必填) borrower_name: str, // 仅作展示/兼容,落库姓名以 borrower_id 反查为准 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'), # ★ 强制借用人ID:service 层缺失即拒绝(不静默回退到申请单姓名) borrower_id=data.get('borrower_id'), 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 # ============================================================================== # 借库转交(Borrow Transfer)一期 # ============================================================================== # --- 借库链路人员选择器(借用人 / 转交接收人 / 实际归还人)--- @trans_bp.route('/borrow/users', methods=['GET']) @jwt_required() def get_borrow_user_options(): """ 借库责任链上的人员名单:借用人、转交接收人、实际归还人共用一个数据源。 ★ 为什么只做 @jwt_required() 而不加 permission_required: 同一份名单被三个页面共用 —— 借出(op_borrow:operation)、 归还(op_return:operation)、转交(borrow_transfer)。绑定其中任一权限码, 另外两个页面都会 403。此处沿用 /auth/users/approvers 的既有处理, 且**只返回 id 与姓名**,不含邮箱/角色/部门等字段,最小披露。 ★ 公司隔离与借用台账同口径(get_current_company_filter): 否则 A 公司库管能在选择器里看到 B 公司人员,虽转交时会被 company 校验二次拦截,但名单本身已属越权披露。 """ from app.utils.decorators import get_current_company_filter from app.models.system import SysUser company_limit = get_current_company_filter() query = SysUser.query.filter(SysUser.status == 'active') if company_limit is not None: # 与 borrow_service.get_request_list 一致:SysUser.department 即公司维度 query = query.filter(SysUser.department == company_limit) users = query.order_by(SysUser.username).all() return jsonify({ 'code': 200, 'msg': 'success', 'data': [{'id': u.id, 'name': user_display_name(u)} for u in users], }) # --- 执行借库转交 --- @trans_bp.route('/borrow//transfer', methods=['POST']) @jwt_required() # ★ 幂等锁置于 permission_required 内层:prevent_double_submit 依赖 # get_jwt_identity(),放外层会因 JWT 未验证而抛错,被其自身 except 捕获后降级放行 @prevent_double_submit(lock_timeout=5) @permission_required('borrow_transfer') def transfer_borrow(borrow_id): """ 借库转交:把一张借出单的持有权从当前持有人**整单**转给另一人。 请求体: { "transfer_qty": 10, # 必填,一期必须严格等于待还量 "to_user_id": 12, # 必需,接收人ID(唯一身份锚点) "to_user_name": "张三", # 可选,仅作兼容;落库姓名以 to_user_id 反查为准 "remark": "..." # 可选 } ★ 严禁触碰库存:转交是纯持有权变更,实物不出入库, stock_buy / stock_semi / stock_product 的任何字段都不会被修改。 ★ 一期仅支持整单全量转交:部分转交会被拒绝(详见 service 层说明)。 """ try: data = request.get_json() or {} transfer = TransService.transfer_borrow( borrow_id=borrow_id, to_user_id=data.get('to_user_id'), transfer_qty=data.get('transfer_qty'), operator_name=_current_username(), remark=data.get('remark'), ) return jsonify({ 'code': 200, 'msg': '转交成功', 'data': transfer.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 # --- 借出单的流转历史(转交链 + 逐次归还)--- @trans_bp.route('/borrow//history', methods=['GET']) @jwt_required() @permission_required('op_records') def get_borrow_history(borrow_id): """ 查看一张借出单的**转交链**与**逐次归还明细**。 ★ 存在意义:转交与归还都是「流水式」记录,主表只保留最终快照 (current_holder / returned_quantity)。要回答「这台设备从 A 到 B 再到 C 都经过了谁的手」「分批归还时每一笔是谁还的」,只能查流水表 —— 这也正是本功能一期要解决的核心问题。 """ try: data = TransService.get_borrow_history(borrow_id) except ValueError as e: return jsonify({'code': 404, 'msg': str(e)}), 404 return jsonify({'code': 200, 'msg': 'success', 'data': data}) # --- 整单流转时间线(借出 → 转交(可多次) → 归还 → 报废)--- @trans_bp.route('/borrow/slip//history', methods=['GET']) @jwt_required() @permission_required('op_records') def get_borrow_slip_history(borrow_no): """ 一张借用单(borrow_no)的完整生命周期事件流,按时间倒序。 与 /borrow//history 的分工: · /borrow//history —— **单品**维度,用于精确追溯某个序列号/批次; · /borrow/slip//history —— **整单**维度,一次返回该单号下所有明细的 合并时间线。列表页是 borrow_no 主子表结构(实测单张单最多 21 条明细), 若逐条明细调用单品接口会产生 21 个请求,故提供此聚合入口。 """ try: data = TransService.get_slip_history(borrow_no) except ValueError as e: return jsonify({'code': 404, 'msg': str(e)}), 404 return jsonify({'code': 200, 'msg': 'success', 'data': data})