Files
KCGL/inventory-backend/app/api/v1/transactions.py
yueli f24797ff1f feat(borrow): 拒收须告知发起方(责任回到他手上,不能静默)
背景
----
双向握手补上了「接收人确认」,却只做了单向告知:接收人能看到待办,发起方却
对结果一无所知。**被拒绝时物品责任仍在发起方手上** —— 他若不主动查列表,
就会误以为已经交接出去,责任链出现静默断点。

(ACCEPTED 不需要告知:东西已经交出去了,发起方无需动作。)

改动
----
· trans_borrow_transfer 新增 reject_seen_at(NULL 且 REJECTED = 尚未告知)。
  ★ 为什么需要持久标记而不是前端去重:换台电脑、换个浏览器就会重新提醒;
    而这条信息的分量(责任归属)值得一个持久标记。
  ★ 存量已拒绝的流水一律标记为已告知:它们产生于本功能上线之前,
    追溯提醒只会打扰(实测仅 1 条:#22,验收时的测试数据)。
· get_unseen_rejects(user_id):返回「我发起、被拒、尚未告知我」的转交,
  并批量解析物料名 —— 只说「某笔转交被拒」发起方仍不知是哪件东西还在
  自己手上,必须让他一眼认出来。
· ack_rejects(user_id, ids):发起方确认后写 reject_seen_at,幂等。
· GET .../transfer/pending-count 的响应并入 rejects:与待接收数量共用同一次
  轮询,前端不必多打一个请求。
· POST .../transfer/reject-ack:无 permission_required,同 accept/reject。

顺带补一处同源显示缺口
----
流转时间线里,被拒绝的转交与成功的长得一模一样 —— 发起方翻记录时同样会
误判。现将转交状态一并带出时间线事件。

验证(15 项断言全通过)
----
发起方收到待告知的拒绝(含物料名/接收人/拒绝原因);接收人与无关人看不到;
ack 后不再提醒且幂等;ACCEPTED 不产生告知;None/非法 user_id 均安全返回;
库存零副作用、数据零残留。
2026-09-17 10:44:00 +08:00

835 lines
34 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.

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', ''))
# ★ 数据权限:普通用户只看与自己有关的记录(借用人 / 当前持有人 / 待我接收的
# 转交),管理者看全部。两个口径都要传下去:
# viewer_user_id —— 精确锚点,覆盖转交接收人(此前只按姓名过滤,
# 接收人在自己的列表里看不到东西)
# borrower_name —— 姓名口径,兼容只有姓名、没有 ID 的历史行
borrower_name = None
viewer_user_id = None
current_user_id = None
_identity = get_jwt_identity()
if _identity:
current_user_id = int(_identity) # 供「是否待我接收」判定,与可见性无关
if not is_privileged_viewer():
if _identity:
from app.models.system import SysUser
_u = SysUser.query.get(int(_identity))
if _u:
viewer_user_id = _u.id
_uname = _u.username or ''
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, viewer_user_id=viewer_user_id,
current_user_id=current_user_id,
)
# ★ 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/<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-已通过 → 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/<int:request_id>/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)、转交(发起人是**持有人本人**,可能不具备任何
库管权限)。绑定其中任一权限码,
另外两个页面都会 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/<int:borrow_id>/transfer', methods=['POST'])
@jwt_required()
@prevent_double_submit(lock_timeout=5)
def transfer_borrow(borrow_id):
# ★ 为什么不加 permission_required('borrow_transfer'):
# 转交的责任链隔离规则是「**只有当前持有人本人**可以发起」,而持有人是
# 普通员工,通常并不持有库管权限。若再挂一道库管权限,实际能发起的人
# 变成「持有人 ∩ 库管」,绝大多数持有人反而发不了 —— 功能形同虚设。
# 这与 accept / reject 同级:员工处置自己名下资产,不是库管职权。
# 真正的边界在 service 层的 caller_user_id 强校验。
"""
发起借库转交:把一张借出单的持有权**整单**转给另一人,等待对方确认。
请求体:
{
"transfer_qty": 10, # 可选,传入时须等于整单待还量(一致性校验)
"to_user_id": 12, # 必需,接收人ID(唯一身份锚点)
"to_user_name": "张三", # 可选,仅作兼容;落库姓名以 to_user_id 反查为准
"remark": "..." # 可选
}
★ 双向握手:本接口**只落一条 PENDING 流水,不改主表 current_holder**。
东西还没到接收人手上,责任仍归原持有人 —— 接收人在自己的列表里确认
(POST /borrow/transfer/<id>/accept)后才真正转移。
★ 转交粒度 = **明细行**(传入的 borrow_id 就是目标)。同一张单的其他明细
不受影响,故「借 2 件只转 1 件」得到天然支持;同单不同明细归属不同持有人
是正常业务形态。
★ 责任链隔离:**只有该物品的当前持有人本人**可以发起转交 —— 物品在谁手上,
就只能由谁把它交出去。这不是库管代办的场景(那是借出环节的职责),
否则任何人都能把别人保管的资产「转」给第三方。
★ 严禁触碰库存:转交是纯持有权变更,实物不出入库,
stock_buy / stock_semi / stock_product 的任何字段都不会被修改。
"""
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'),
# ★ 责任链隔离:service 层强校验调用者就是该物品的当前持有人本人。
# 前端隐藏按钮只是降噪,这里才是真正的边界。
caller_user_id=get_jwt_identity(),
)
return jsonify({
'code': 200,
'msg': f'已发起转交,等待【{transfer.to_user_name}】确认接收',
'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/transfer/<int:transfer_id>/accept', methods=['POST'])
@jwt_required()
@prevent_double_submit(lock_timeout=5)
def accept_borrow_transfer(transfer_id):
"""
接收人确认接收转交 —— 责任正式转移。
★ 权限:**不加 permission_required**。这不是库管职权,而是员工对自己名下
资产的确认动作;service 层强校验当前登录人 == to_user_id 本人。
★ 副作用:**仅**该转交指向的那一条明细的 current_holder 改为接收人。
同单的其他明细可能挂在别人名下(部分转交),一律不动。
"""
try:
transfer, record = TransService.accept_transfer(
transfer_id=transfer_id,
user_id=get_jwt_identity(),
)
return jsonify({
'code': 200,
'msg': f'已接收,物品【{record.sku}】的持有权已转移到您名下',
'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/transfer/<int:transfer_id>/reject', methods=['POST'])
@jwt_required()
@prevent_double_submit(lock_timeout=5)
def reject_borrow_transfer(transfer_id):
"""
接收人拒绝转交 —— 主表不动,责任仍在原持有人。
权限同 accept:仅 to_user_id 本人。
"""
try:
data = request.get_json() or {}
transfer = TransService.reject_transfer(
transfer_id=transfer_id,
user_id=get_jwt_identity(),
reason=data.get('reason'),
)
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/transfer/pending-count', methods=['GET'])
@jwt_required()
def get_pending_transfer_count():
"""
待我接收的转交数量,供前端全局待办强提醒使用。
★ 无 permission_required:接收人可能是普通员工,待办提醒必须人人可见
(与 accept/reject 同级的理由 —— 这是员工处置自己名下资产,非库管职权)。
★ 路由不与 /borrow/<int:borrow_id>/transfer 等冲突:
「transfer」无法匹配 <int:borrow_id>,「pending-count」也无法匹配
<int:transfer_id>,Werkzeug 会按转换器精确分派。
"""
try:
identity = get_jwt_identity()
count = TransService.count_pending_transfers(identity)
# ★ 一并返回「我发起、被对方拒绝、尚未告知我」的转交:
# 被拒时物品责任仍在我手上,不告知就会误以为已经交接出去。
# 与待接收数量合并进同一次轮询,避免前端多打一个请求。
rejects = TransService.get_unseen_rejects(identity)
return jsonify({
'code': 200,
'msg': 'success',
'count': count,
'rejects': rejects,
'data': {'count': count, 'rejects': rejects},
}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
# --- 确认已知悉「转交被拒」(清除发起方的待告知提醒)---
@trans_bp.route('/borrow/transfer/reject-ack', methods=['POST'])
@jwt_required()
def ack_transfer_rejects():
"""
发起方在前端看到「您的转交被拒绝」提醒并确认后调用,写 reject_seen_at。
★ 为什么需要这个接口:提醒必须能标记「已告知」,否则发起方每次登录都会
收到同一条 —— 从「提醒」退化成「骚扰」。
★ 无 permission_required:同 accept/reject,是员工处置自己名下资产。
请求体:{ ids: [12, 13] },留空表示该用户全部待告知的拒绝。
"""
try:
data = request.get_json() or {}
marked = TransService.ack_rejects(get_jwt_identity(), data.get('ids'))
return jsonify({'code': 200, 'msg': 'success', 'data': {'marked': marked}}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
# --- 借出单的流转历史(转交链 + 逐次归还)---
@trans_bp.route('/borrow/<int:borrow_id>/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/<borrow_no>/history', methods=['GET'])
@jwt_required()
@permission_required('op_records')
def get_borrow_slip_history(borrow_no):
"""
一张借用单(borrow_no)的完整生命周期事件流,按时间倒序。
与 /borrow/<id>/history 的分工:
· /borrow/<id>/history —— **单品**维度,用于精确追溯某个序列号/批次;
· /borrow/slip/<no>/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})