feat(borrow): 转交接口、身份ID锚点与流转时间线后端
责任链收口
----
· execute_dispatch:强制 borrower_id,姓名一律由 sys_user 反查,**不回退**到
申请单姓名 —— 申请单上的姓名是「申请意向」,与扫码时实际来领的人本就可能
不同(库管代建场景尤甚),回退会让 current_holder 从第一刻就记错人。
同时落库 dispatch_operator,补齐「谁经手发货」。
· process_return:新增 returner_id,记录有 current_holder_id 时强校验二者相等,
不符即整单回滚(原实现只要持有 op_return:operation 即可归还任何人借出的
物品,函数内从不读取 borrower_name,归还环节责任链是断的);
每次归还写 trans_borrow_return 流水;全量归还清空 current_holder
(borrower_id 保留作历史)。
· transfer_borrow(新):整单全量转交,写转交流水 + 推进主表 current_holder。
为什么一期只允许整单全量转交
----
trans_borrow 是单行模型,只能存一个 current_holder_id。部分转交(借 10 转 5)
会让这一行同时表示两个人,语义直接撕裂,且归还时无法判定该由谁还。
若未来需支持,必须改为按数量**拆行**(新建一条承接转出量、原行扣减),
而不是在单行上加字段打补丁。
为什么转交绝不触碰库存
----
借出期间 available 已冻结、stock 仍含借出未还量(deduct_stock=False)。
转交若动库存,会同时破坏两个既有假设:「归还只加 available」会算多,
「借库转报废在确认损失时扣 stock」(scrap_sources.BorrowScrapAdapter)
会重复扣减。
接口
----
· POST /borrow/<id>/transfer 转交(borrow_transfer 权限 + 防抖锁 + 行锁)
· GET /borrow/<id>/history 单品流转历史(供精确追溯)
· GET /borrow/slip/<no>/history 整单时间线(实测单号最多 21 条明细,
逐条调用会产生 21 个请求,故聚合返回)
· GET /borrow/users 人员名单(借出/转交/归还共用,公司隔离)
一个隐蔽缺陷(本轮发现并修复)
----
整单时间线初版按展示用的时间字符串排序,而该字符串截断到**秒** —— 同一秒内的
连续动作(如「转交 A→B 紧接着转交 B→C」)会退化为并列,顺序取决于数据库返回
顺序,时间线随机错乱。已改为按真实 datetime 排序,并加回归用例锁定。
This commit is contained in:
@ -2,7 +2,7 @@ from flask import Blueprint, jsonify, request # .material -> .base refactor che
|
||||
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
|
||||
from app.services.trans_service import TransService, user_display_name
|
||||
from app.services.borrow_service import BorrowApprovalService
|
||||
import traceback
|
||||
|
||||
@ -101,11 +101,30 @@ def scan_borrowed_item():
|
||||
@jwt_required()
|
||||
@permission_required('op_return:operation')
|
||||
def submit_return():
|
||||
data = request.get_json()
|
||||
"""
|
||||
还库提交。
|
||||
|
||||
请求体:
|
||||
{
|
||||
"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)
|
||||
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
|
||||
@ -512,7 +531,8 @@ def dispatch_borrow():
|
||||
],
|
||||
// ★ 审批上限校验在 service 层完成:以 (name, spec_model) 为物料维度聚合
|
||||
// 锁定 stock 行后从 material_base 表取真实 (name, spec_model) 与审批单比对
|
||||
borrower_name: str,
|
||||
borrower_id: int, // ★ 实际借用人ID(一期转交改造后必填)
|
||||
borrower_name: str, // 仅作展示/兼容,落库姓名以 borrower_id 反查为准
|
||||
signature_path: str,
|
||||
remark: str,
|
||||
expected_return_time: str
|
||||
@ -529,6 +549,8 @@ def dispatch_borrow():
|
||||
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')
|
||||
@ -541,3 +563,131 @@ def dispatch_borrow():
|
||||
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/<int:borrow_id>/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/<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})
|
||||
|
||||
Reference in New Issue
Block a user