Files
KCGL/inventory-backend/app/api/v1/transactions.py
yueli 1a8e3e3dc0 feat(borrow): 转交改为整单覆盖 + 双向握手,并修复接收人可见性
一、整单覆盖(修复漏行 / 单内撕裂)
----
transfer_borrow 现在按 borrow_no 定位**整张单**,覆盖全部未还明细;accept 时
整批转移持有权。原先只改传入的那一行,2 明细的单转完会出现两个持有人。

新增 _load_slip_for_update():按单号锁整单,且按 id 升序取行锁 —— 并发下所有
事务以相同顺序加锁,避免与归还/转交交叉加锁死锁。

二、双向握手
----
· transfer_borrow(发起):只落一条 PENDING 流水,**不再改主表 current_holder**。
  东西还没到对方手上,责任仍归原持有人 —— 这是与旧实现最本质的区别。
  同一单号已有 PENDING 时拒绝再次发起,避免两个接收人争抢同一批实物。
· accept_transfer:流水置 ACCEPTED,把该单**全部未还明细**的持有人改为接收人。
. reject_transfer:流水置 REJECTED,主表不动。
  两者都强校验「当前登录人 == to_user_id 本人」。

★ accept/reject 刻意**不加 permission_required**:这不是库管职权,而是员工对
  自己名下资产的确认动作,加库管权限会把接收人挡在门外。

三、接收人可见性(OR 过滤)
----
get_records 普通用户过滤原先只比对 borrower_name,接收人在自己的列表里看不到
已经接收的东西。现改为三种关系任一成立:
    ① 我是借用人
    ② 我是**当前持有人**(转交接收后)
    ③ 有一条**待我接收**的 PENDING 转交 —— 东西还在对方手上、主表尚未转移,
       ② 匹配不到,必须单独并入,否则接收人看不到待办、无从确认
ID 与姓名双口径并存,兼容只有姓名没有 ID 的历史行。

列表项附加 pending_transfer(含后端判定的 is_mine)—— 前端 localStorage 里
只有 username 没有 user_id,靠姓名比对既有歧义又不可靠,故由后端标记。

四、验证(合成 2 明细单,25 项断言全通过)
----
· 发起后两条明细持有人均未变(责任未转移)
· 非接收人无法 accept / reject;重复发起被拒
· ★ accept 后**两条明细**持有人一并转移(漏行修复的核心)
· 接收前凭 PENDING 分支可见、接收后凭 current_holder 可见
· reject 后主表持有人不变
· 全程 available_quantity 无变化,库存精确还原、零残留数据
2026-09-17 10:04:12 +08:00

769 lines
31 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'),
# ★ 强制借用人IDservice 层缺失即拒绝(不静默回退到申请单姓名)
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/<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": "..." # 可选
}
★ 双向握手:本接口**只落一条 PENDING 流水,不改主表 current_holder**。
东西还没到接收人手上,责任仍归原持有人 —— 接收人在自己的列表里确认
POST /borrow/transfer/<id>/accept后才真正转移。
★ 覆盖范围是**整张单**borrow_no的全部未还明细不是传入的这一行
避免同一张单出现两个持有人。
★ 严禁触碰库存:转交是纯持有权变更,实物不出入库,
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'),
)
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, affected = TransService.accept_transfer(
transfer_id=transfer_id,
user_id=get_jwt_identity(),
)
return jsonify({
'code': 200,
'msg': f'已接收,{affected} 项资产的持有权已转移到您名下',
'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/<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})