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:
yueli
2026-09-17 09:17:57 +08:00
parent b998b00889
commit cccd6f6081
2 changed files with 662 additions and 13 deletions

View File

@ -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 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.utils.decorators import permission_required, prevent_double_submit, is_privileged_viewer
from app.services.auth_service import AuthService 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 from app.services.borrow_service import BorrowApprovalService
import traceback import traceback
@ -101,11 +101,30 @@ def scan_borrowed_item():
@jwt_required() @jwt_required()
@permission_required('op_return:operation') @permission_required('op_return:operation')
def submit_return(): 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 # ★ 归还人存"姓名",而非 JWT 数字 ID
operator_name = _current_username() operator_name = _current_username()
try: 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': '还库成功'}) return jsonify({'code': 200, 'msg': '还库成功'})
except Exception as e: except Exception as e:
return jsonify({'code': 400, 'msg': str(e)}), 400 return jsonify({'code': 400, 'msg': str(e)}), 400
@ -512,7 +531,8 @@ def dispatch_borrow():
], ],
// ★ 审批上限校验在 service 层完成:以 (name, spec_model) 为物料维度聚合 // ★ 审批上限校验在 service 层完成:以 (name, spec_model) 为物料维度聚合
// 锁定 stock 行后从 material_base 表取真实 (name, spec_model) 与审批单比对 // 锁定 stock 行后从 material_base 表取真实 (name, spec_model) 与审批单比对
borrower_name: str, borrower_id: int, // ★ 实际借用人ID(一期转交改造后必填)
borrower_name: str, // 仅作展示/兼容,落库姓名以 borrower_id 反查为准
signature_path: str, signature_path: str,
remark: str, remark: str,
expected_return_time: str expected_return_time: str
@ -529,6 +549,8 @@ def dispatch_borrow():
items=data.get('items', []), items=data.get('items', []),
operator_name=_current_username(), operator_name=_current_username(),
borrower_name=data.get('borrower_name'), borrower_name=data.get('borrower_name'),
# ★ 强制借用人ID:service 层缺失即拒绝(不静默回退到申请单姓名)
borrower_id=data.get('borrower_id'),
signature=data.get('signature_path'), signature=data.get('signature_path'),
remark=data.get('remark'), remark=data.get('remark'),
expected_return_time=data.get('expected_return_time') expected_return_time=data.get('expected_return_time')
@ -541,3 +563,131 @@ def dispatch_borrow():
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500 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})

View File

@ -1,7 +1,7 @@
import uuid # .material -> .base refactor checked import uuid # .material -> .base refactor checked
from datetime import datetime from datetime import datetime
from app.extensions import db from app.extensions import db, beijing_time
from app.models.transaction import TransBorrow from app.models.transaction import TransBorrow, TransBorrowTransfer, TransBorrowReturn
from app.models.inbound.buy import StockBuy from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct from app.models.inbound.product import StockProduct
@ -11,6 +11,49 @@ from sqlalchemy import desc, func, nullslast, asc, or_, and_, case
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
def user_display_name(user):
"""
用户名展示口径:'姓名/拼音' → '姓名'。
★ 与迁移脚本 phase4_borrow_transfer.sql 的回填口径
(split_part(username,'/',1))及 borrow_service 的通知口径三处保持一致 ——
否则同一人在台账里会出现「石利」与「石利/shili」两种写法,按姓名检索即失准。
"""
if not user:
return ''
username = str(user.username or '')
return username.split('/')[0] if '/' in username else username
def _assert_borrow_company_visible(record):
"""
行级公司隔离:确认一条借用记录属于当前用户可见的公司。
★ 隔离链路与 get_records 完全一致:
trans_borrow → (source_table, stock_id) → 库存表 → material_base.company_name
不另起一套口径,避免列表能看到的单、接口却判定为越权(或反之)。
★ Fail-Closed:链路断裂(源库存行被入库模块物理删除,实测已有先例)时
**拒绝**而不是放行 —— 持有权变更直接改变责任归属,宁可让库管走人工,
也不在无法判定归属时越权操作。
"""
company_limit = get_current_company_filter()
if company_limit is None: # 超管 / crossDomain → 全量跨域
return
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
ModelClass = model_map.get(record.source_table)
stock = ModelClass.query.get(record.stock_id) if (ModelClass and record.stock_id) else None
base = stock.base if stock else None
if base is None:
raise ValueError(
"该借用记录的来源库存已不存在,无法完成公司隔离校验,请联系管理员处理"
)
if (base.company_name or '') != company_limit:
raise ValueError("无权操作其他公司的借用记录")
class TransService: class TransService:
@staticmethod @staticmethod
@ -32,7 +75,8 @@ class TransService:
@staticmethod @staticmethod
def execute_dispatch(approval_id, items, operator_name='System', borrower_name=None, def execute_dispatch(approval_id, items, operator_name='System', borrower_name=None,
signature=None, remark=None, expected_return_time=None): signature=None, remark=None, expected_return_time=None,
borrower_id=None):
""" """
执行借库扣减(审批通过后调用) 执行借库扣减(审批通过后调用)
流程:锁审批单 → 构建审批上限字典 → 锁库存行 → 名称规格校验 → 扣减库存 → 生成 TransBorrow 记录 → 标记审批单完成 流程:锁审批单 → 构建审批上限字典 → 锁库存行 → 名称规格校验 → 扣减库存 → 生成 TransBorrow 记录 → 标记审批单完成
@ -41,6 +85,10 @@ class TransService:
借库申请是按【名称 + 规格型号】发起的(borrow_service 强制要求 name/spec_model/quantity 三字段), 借库申请是按【名称 + 规格型号】发起的(borrow_service 强制要求 name/spec_model/quantity 三字段),
申请时尚未绑定具体库存行;扫码出库时通过锁定 stock 行回查 material_base 表, 申请时尚未绑定具体库存行;扫码出库时通过锁定 stock 行回查 material_base 表,
用 (name, spec_model) 与审批单做物料维度聚合比对,避免 sku 维度坍塌或绕过。 用 (name, spec_model) 与审批单做物料维度聚合比对,避免 sku 维度坍塌或绕过。
★ borrower_id(一期转交改造后为**必填**):
实际借用人ID。borrower_name 参数仅为向后兼容而保留 —— 落库时一律
以 borrower_id 反查 sys_user 得到的姓名为准,传参中的姓名被忽略。
""" """
from app.models.borrow import BorrowApproval from app.models.borrow import BorrowApproval
@ -57,11 +105,30 @@ class TransService:
status_map = {0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成'} status_map = {0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成'}
raise ValueError(f"审批单状态为【{status_map.get(approval.status, approval.status)}】,无法执行借库") raise ValueError(f"审批单状态为【{status_map.get(approval.status, approval.status)}】,无法执行借库")
# ★ borrower_name 兜底:优先用前端传参,其次从审批单读取(申请时填写的姓名) # ==============================================
# ★ 借用人身份锚定(一期转交改造):强制 ID
#
# 原实现只收 borrower_name 字符串,责任链从落库那刻起就存在重名歧义
# (实测 85 行 / 18 个姓名,纯姓名无法唯一锚定一个人)。
# 现强制要求 borrower_id,并以 sys_user 为**唯一事实来源**反查姓名:
# · borrower_name 降级为展示快照,不再接受前端自由输入;
# · 传了 borrower_id 但用户不存在 → 直接拒绝,不静默放行。
#
# ★ 为什么不回退到 approval.borrower_name:
# 申请单上的姓名是「申请意向」,与「扫码时实际来领的人」本就可能不同
# (库管代建场景尤甚)。若回退,current_holder_id 会从第一刻就记错人,
# 转交与归还的整条责任链都会建立在错误的锚点上。宁可让库管重选。
# ==============================================
if not borrower_id:
raise ValueError("缺少借用人 borrower_id,请重新选择借用人后再提交")
from app.models.system import SysUser
borrower = SysUser.query.get(int(borrower_id))
if not borrower:
raise ValueError(f"借用人不存在(ID:{borrower_id}),请重新选择")
borrower_name = user_display_name(borrower)
if not borrower_name: if not borrower_name:
borrower_name = approval.borrower_name raise ValueError(f"借用人(ID:{borrower_id})用户名为空,无法生成台账快照")
if not borrower_name:
raise ValueError("审批单中未记录借库人姓名,请联系管理员补录")
# ============================================== # ==============================================
# ★ 防线2:构建审批上限字典 # ★ 防线2:构建审批上限字典
@ -178,8 +245,19 @@ class TransService:
stock_id=stock.id, stock_id=stock.id,
barcode=stock.barcode, barcode=stock.barcode,
quantity=qty, quantity=qty,
# ★ 借出即由借用人持有:borrower_id 为初始借用人(写一次不再变),
# current_holder_* 为当前持有人 —— 转交会在此之上继续推进。
borrower_id=int(borrower_id),
borrower_name=borrower_name, borrower_name=borrower_name,
current_holder_id=int(borrower_id),
current_holder_name=borrower_name,
# ★ 显式置 0:归还逻辑按 returned_quantity 累加并判断是否还清,
# 不依赖 DB 列默认值(避免 ORM/DB 默认值口径不一致时算错待还量)。
returned_quantity=0,
borrow_signature=signature, borrow_signature=signature,
# ★ 发货操作人:执行本次借出的库管。operator_name 此前被接收
# 却从未落库,责任链上「谁经手发货」一直缺失,此处补齐。
dispatch_operator=operator_name,
remark=remark, remark=remark,
expected_return_time=expected_return_time, expected_return_time=expected_return_time,
# [新增] 记录借出时的库位快照 # [新增] 记录借出时的库位快照
@ -210,6 +288,7 @@ class TransService:
items=data.get('items', []), items=data.get('items', []),
operator_name=operator_name, operator_name=operator_name,
borrower_name=data.get('borrower_name'), borrower_name=data.get('borrower_name'),
borrower_id=data.get('borrower_id'),
signature=data.get('signature_path'), signature=data.get('signature_path'),
remark=data.get('remark'), remark=data.get('remark'),
expected_return_time=data.get('expected_return_time') expected_return_time=data.get('expected_return_time')
@ -242,7 +321,7 @@ class TransService:
return res_dict return res_dict
@staticmethod @staticmethod
def process_return(data, operator_name): def process_return(data, operator_name, returner_id=None):
""" """
还库逻辑(支持部分归还)- 已优化,消除 N+1 和长事务死锁风险 还库逻辑(支持部分归还)- 已优化,消除 N+1 和长事务死锁风险
四步走策略: 四步走策略:
@ -250,6 +329,13 @@ class TransService:
2. 批量锁定借用记录 2. 批量锁定借用记录
3. 收集库存ID并批量锁定库存 3. 收集库存ID并批量锁定库存
4. 内存中完成业务逻辑 4. 内存中完成业务逻辑
参数
----
operator_name : 经手办理还库的**库管**姓名(窗口操作人)
returner_id : 实际把物品交回窗口的**归还人**ID(一期转交改造新增)。
记录有 current_holder_id 时强制校验二者一致 ——
详见循环内的持有人校验块。
""" """
items = data.get('items', []) items = data.get('items', [])
signature = data.get('signature_path') # 库管签字 signature = data.get('signature_path') # 库管签字
@ -309,6 +395,11 @@ class TransService:
# ========================================== # ==========================================
# ★ 优化步骤 4:内存中完成业务逻辑 # ★ 优化步骤 4:内存中完成业务逻辑
# ========================================== # ==========================================
# ★ 时间口径修复:原实现用 datetime.now()(容器本地时间,Docker 下为
# UTC),而同一行的 borrow_time 由 beijing_time 写入 —— 两个字段
# 差 8 小时,台账时间线自相矛盾。统一取北京时间,并与下方归还流水
# 共用同一个时间戳,保证主表快照与流水完全对齐。
return_now = beijing_time()
for borrow_id, item_data in item_map.items(): for borrow_id, item_data in item_map.items():
return_qty = item_data['return_qty'] return_qty = item_data['return_qty']
final_location = item_data['final_location'] final_location = item_data['final_location']
@ -317,6 +408,35 @@ class TransService:
if not record: if not record:
continue continue
# ==========================================
# ★ 持有人强校验(一期转交改造)
#
# 原实现只要持有 op_return:operation 权限的库管即可归还**任何人**
# 借出的物品,函数内从不读取 record.borrower_name,归还环节的责任链
# 是断的。转交上线后物品会在 A→B→C 之间流转,若不校验归还人,
# 「谁还的」将与「谁该还」彻底脱钩。
#
# 规则:记录有 current_holder_id(= 物品仍在某人手上)时,
# 归还人必须**就是**该持有人。
#
# ★ 为什么 current_holder_id 为 NULL 时跳过:
# 仅迁移前无法锚定姓名的历史行会是 NULL,跳过以兼容存量数据、
# 不阻断其正常归还;新数据(execute_dispatch 落库)必有值,
# 即新流程**不存在**绕过校验的路径。
# ==========================================
if record.current_holder_id is not None:
holder_label = record.current_holder_name or f"用户({record.current_holder_id})"
if returner_id is None:
raise ValueError(
f"缺少归还人:物品【{record.sku}】当前由【{holder_label}】持有,"
f"请选择归还人后再提交"
)
if int(returner_id) != int(record.current_holder_id):
raise ValueError(
f"归还人与当前持有人不符,禁止归还:物品【{record.sku}】的"
f"当前持有人为【{holder_label}】,而非所选归还人"
)
# 计算待还数量 # 计算待还数量
returned_qty = float(record.returned_quantity) if record.returned_quantity else 0 returned_qty = float(record.returned_quantity) if record.returned_quantity else 0
total_qty = float(record.quantity) if record.quantity else 0 total_qty = float(record.quantity) if record.quantity else 0
@ -337,28 +457,407 @@ class TransService:
if final_location: if final_location:
stock.warehouse_location = final_location stock.warehouse_location = final_location
# ==========================================
# 更新归还数量和状态 # 更新归还数量和状态
#
# ★ 主表字段的定位(一期转交改造后):
# returned_quantity / is_returned / status 是**累计快照**,
# 聚合语义正确,列表页「未还/已还」判定依赖它们 → 继续维护;
# return_time / return_operator / return_signature 降级为
# 「最近一次归还」**展示快照**(records.vue 的归还人/归还时间列
# 依赖它们)→ 继续刷新;
# 逐次归还的**权威明细**改由 trans_borrow_return 承载 ——
# 原先只写主表时,部分归还下「谁在什么时候还了多少」会被
# 逐次覆盖而永久丢失(失忆症),流水表根治该问题。
# ==========================================
new_returned_qty = returned_qty + return_qty new_returned_qty = returned_qty + return_qty
record.returned_quantity = new_returned_qty record.returned_quantity = new_returned_qty
if new_returned_qty >= total_qty: if new_returned_qty >= total_qty:
record.is_returned = True record.is_returned = True
record.status = 'returned' record.status = 'returned'
# ★ 已全部还清:物品回到仓库,无人持有。
# 清空 current_holder 使「current_holder_id IS NOT NULL」
# 成为「仍在某人手上」的有效信号(与迁移脚本对已归还历史行
# 不回填 holder 的口径一致)。借款人仍留在 borrower_id 作历史。
record.current_holder_id = None
record.current_holder_name = None
else: else:
record.is_returned = False record.is_returned = False
record.status = 'partial_returned' record.status = 'partial_returned'
record.return_time = datetime.now() record.return_time = return_now
record.return_operator = operator_name record.return_operator = operator_name
record.return_signature = signature record.return_signature = signature
if final_location: if final_location:
record.return_location = final_location record.return_location = final_location
# ★ 逐次归还落流水(治失忆症)
# returner_id 与 operator_name 是两个人:前者是交回物品的人,
# 后者是经手办理的库管。上面已强校验前者 == current_holder_id。
db.session.add(TransBorrowReturn(
borrow_id=record.id,
returner_id=int(returner_id) if returner_id is not None else None,
return_qty=return_qty,
return_time=return_now,
operator_name=operator_name,
))
db.session.commit() db.session.commit()
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
raise e raise e
# ==========================================================================
# 借库转交(一期)
# ==========================================================================
@staticmethod
def transfer_borrow(borrow_id, to_user_id, transfer_qty, operator_name='System', remark=None):
"""
把一张借出单的**持有权**从当前持有人整单转给另一人。
与 execute_dispatch / process_return 的根本区别
------------------------------------------------
转交是**纯持有权变更**:实物不出入库,库存账目分毫不动。
本方法全程不触碰 stock_buy / stock_semi / stock_product 的任何字段
(available_quantity 与 stock_quantity 都不动)。
理由见 restore_then_deduct 的 deduct_stock 说明:借出期间 available
已冻结、stock 仍含借出未还量。转交若去动库存,会同时破坏两个既有假设 ——
「归还只加 available」会算多,而「借库转报废在确认损失时扣 stock」
(scrap_sources.BorrowScrapAdapter)会重复扣减。
为什么一期只允许整单全量转交
----------------------------
trans_borrow 是**单行**模型,只能存一个 current_holder_id。
若允许部分转交(借 10 个转 5 个出去),这一行的 current_holder 就必须
同时表示两个人,语义直接撕裂,且归还时无法判定该由谁还。
故 transfer_qty 必须严格等于待还量(quantity - returned_quantity)。
★ 未来若需部分转交:应改为按数量**拆行**(新建一条 trans_borrow 承接
转出量、原行扣减),而不是在本行上加字段打补丁 —— 单行模型无论如何
扩展都无法同时表达两个持有人。
参数
----
borrow_id : trans_borrow.id
to_user_id : 接收人(转交后的 current_holder)ID
transfer_qty : 转交数量,一期必须等于待还量
operator_name: 执行转交操作的库管姓名
remark : 转交备注
返回
----
TransBorrowTransfer 实例(已 commit)
异常
----
ValueError: 任何校验不通过(调用方整单回滚,不会留下半转状态)
"""
from app.models.system import SysUser
if to_user_id is None:
raise ValueError("缺少接收人 to_user_id")
if transfer_qty is None:
raise ValueError("缺少转交数量 transfer_qty")
try:
transfer_qty = float(transfer_qty)
except (TypeError, ValueError):
raise ValueError("转交数量格式无效,应为数字")
# ==================================================================
# ★ 防线1:锁行 —— 并发下防止同一张单被同时转给两个人
# (后到的事务会阻塞在此,拿到锁后读到已推进的 current_holder_id,
# 从而在下方「接收人 == 当前持有人」校验处被拒绝)
# ==================================================================
record = TransBorrow.query.with_for_update().get(borrow_id)
if not record:
raise ValueError("借出记录不存在")
# --- 1. 状态准入 ---
total_qty = float(record.quantity or 0)
returned_qty = float(record.returned_quantity or 0)
pending_qty = total_qty - returned_qty
# ★ 顺序有意义:报废流程(scrap_sources)会同时置 is_returned=True 与
# status='scrapped',若先判 is_returned 会让报废单收到「已归还」的
# 误导性提示。故先判报废,给出准确原因。
if record.status == 'scrapped':
raise ValueError("该借出记录已转入报废流程,不可再转交")
if record.is_returned or pending_qty <= 0:
raise ValueError("该借出记录已全部归还,无可转交的实物")
# --- 2. 数量校验:一期必须整单全量转交 ---
if transfer_qty <= 0:
raise ValueError("转交数量必须大于0")
if transfer_qty > pending_qty:
raise ValueError(
f"转交数量({transfer_qty})不能大于待还数量({pending_qty})"
)
# 浮点容差:quantity/returned_quantity 是 numeric(19,4),差值应精确,
# 但仍用容差比较,避免二进制浮点表示误差造成误拒。
if abs(transfer_qty - pending_qty) > 1e-6:
raise ValueError(
f"目前仅支持整单全部转交:本单待还 {pending_qty},"
f"本次仅转交 {transfer_qty}。部分转交会导致当前持有人语义撕裂,"
f"请整单转交,或先办理部分归还后再转交。"
)
# --- 3. 转出方必须已锚定 ---
# 历史行(迁移前无法用姓名唯一映射到 sys_user 的)holder 为 NULL,
# 此时「从谁转出」无从确定,Fail-Closed 拒绝。
if record.current_holder_id is None:
raise ValueError(
"该借出记录的当前持有人未锚定(历史数据),无法转交,请先办理归还"
)
# --- 4. 接收人校验 ---
try:
to_user_id = int(to_user_id)
except (TypeError, ValueError):
# 不直接 int() 抛裸异常:原生报错信息(invalid literal for int()...)
# 会原样透给前端,对库管毫无指导意义。
raise ValueError("接收人 to_user_id 格式无效,应为数字ID")
to_user = SysUser.query.get(to_user_id)
if not to_user:
raise ValueError(f"接收人不存在(ID:{to_user_id})")
to_user_name = user_display_name(to_user)
if to_user_id == int(record.current_holder_id):
raise ValueError(f"接收人与当前持有人同为【{to_user_name}】,无需转交")
# --- 5. 行级公司隔离(Fail-Closed)---
_assert_borrow_company_visible(record)
# ==================================================================
# ★ 防线2:以下只写台账,绝不触碰任何库存字段
# ==================================================================
from_name = record.current_holder_name or user_display_name(
SysUser.query.get(record.current_holder_id)
)
from_id = record.current_holder_id
transfer = TransBorrowTransfer(
borrow_id=record.id,
from_user_id=from_id,
from_user_name=from_name,
to_user_id=to_user_id,
to_user_name=to_user_name,
transfer_qty=transfer_qty,
transfer_time=beijing_time(),
operator_name=operator_name,
remark=remark,
)
db.session.add(transfer)
# 推进当前持有人。borrower_id(初始借用人)保持不动 —— 它回答的是
# 「这单最初谁借的」,不应被转交改写。
record.current_holder_id = to_user_id
record.current_holder_name = to_user_name
try:
db.session.commit()
except Exception as e:
db.session.rollback()
raise e
return transfer
@staticmethod
def get_transfer_history(borrow_id):
"""某条借出记录的转交历史(按时间正序,便于还原 A→B→C 链路)"""
rows = TransBorrowTransfer.query.filter_by(borrow_id=borrow_id) \
.order_by(asc(TransBorrowTransfer.transfer_time), asc(TransBorrowTransfer.id)).all()
return [r.to_dict() for r in rows]
@staticmethod
def get_return_history(borrow_id):
"""某条借出记录的归还历史(按时间正序,替代被覆盖的主表字段)"""
rows = TransBorrowReturn.query.filter_by(borrow_id=borrow_id) \
.order_by(asc(TransBorrowReturn.return_time), asc(TransBorrowReturn.id)).all()
return [r.to_dict() for r in rows]
@staticmethod
def get_slip_history(borrow_no):
"""
整张借用单(borrow_no 维度)的完整生命周期事件流,按时间**倒序**返回。
事件来源
--------
borrow ← trans_borrow 自身(借出时间 / 借用人 / 发货操作人 / 数量)
transfer ← trans_borrow_transfer(转出人 → 接收人、经手库管、备注)
return ← trans_borrow_return(实际归还人、经手库管、数量)
scrap ← trans_borrow.status == 'scrapped'(报废是终态,没有独立流水表,
由主表的终态字段 + return_time/return_operator 还原)
★ 为什么整单聚合,而不是让前端逐条明细调用单品接口:
借用记录列表是 borrow_no 主子表结构,**实测单张单最多 21 条明细**,
逐条调用会产生 21 个请求,且各条时间线无法全局排序。此处一次合并。
★ 公司隔离:任一条明细不可见即整单拒绝(Fail-Closed),与转交同口径。
异常:ValueError(单据不存在 / 越权 / 隔离链路断裂)
"""
from app.models.system import SysUser
records = TransBorrow.query.filter_by(borrow_no=borrow_no).all()
if not records:
raise ValueError("借用单不存在")
# 任一条明细越权 → 整单拒绝(与转交同口径,避免两套可见性标准分叉)
for r in records:
_assert_borrow_company_visible(r)
record_ids = [r.id for r in records]
by_id = {r.id: r for r in records}
# --- 批量取流水,避免逐条查询 ---
transfers = TransBorrowTransfer.query.filter(
TransBorrowTransfer.borrow_id.in_(record_ids)
).all()
returns = TransBorrowReturn.query.filter(
TransBorrowReturn.borrow_id.in_(record_ids)
).all()
# --- 批量解析物料名(与 get_records 同口径,含 SKU 兜底) ---
material_map = {}
stock_ids_by_table = {}
for r in records:
if r.source_table and r.stock_id:
stock_ids_by_table.setdefault(r.source_table, set()).add(r.stock_id)
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
for table_name, ids in stock_ids_by_table.items():
ModelClass = model_map.get(table_name)
if not ModelClass:
continue
for stock in ModelClass.query.options(joinedload(ModelClass.base)).filter(
ModelClass.id.in_(ids)).all():
material_map[(table_name, stock.id)] = stock.base.name if stock.base else ''
empty_sku = {r.sku for r in records
if r.sku and not material_map.get((r.source_table, r.stock_id))}
sku_name_map = {}
if empty_sku:
for ModelClass in (StockProduct, StockSemi, StockBuy):
for stock in ModelClass.query.options(joinedload(ModelClass.base)).filter(
ModelClass.sku.in_(empty_sku)).all():
if stock.sku not in sku_name_map and stock.base:
sku_name_map[stock.sku] = stock.base.name
# --- 批量解析归还人姓名(归还流水只存 returner_id,无姓名快照) ---
returner_ids = {t.returner_id for t in returns if t.returner_id}
user_name_map = {}
if returner_ids:
for u in SysUser.query.filter(SysUser.id.in_(returner_ids)).all():
user_name_map[u.id] = user_display_name(u)
def _label(rec):
name = material_map.get((rec.source_table, rec.stock_id)) or sku_name_map.get(rec.sku, '')
return name or rec.sku or ''
def _sort_key(e):
# ★ 必须按**真实 datetime** 排序,不能用展示用的 '%Y-%m-%d %H:%M:%S' 字符串:
# 后者截断到秒,同一秒内发生的多个动作(库管连续操作的常见情形,
# 如「转交 A→B 紧接着转交 B→C」)会退化成并列,排序结果取决于
# 数据库返回顺序 —— 时间线会随机错乱。
# datetime.min 兜底:无时间的脏数据排到最后。
return (e['_dt'] or datetime.min, e['_seq'])
events = []
for r in records:
label = _label(r)
qty = float(r.quantity or 0)
# 借出事件
events.append({
'type': 'borrow',
'time': r.borrow_time.strftime('%Y-%m-%d %H:%M:%S') if r.borrow_time else None,
'_dt': r.borrow_time,
'borrow_id': r.id,
'sku': r.sku,
'material_name': label,
'quantity': qty,
'actor_name': r.borrower_name, # 借用人
'operator_name': r.dispatch_operator, # 发货库管
'remark': r.remark,
'_seq': 0,
})
# 报废事件(终态,无独立流水表)
if r.status == 'scrapped':
events.append({
'type': 'scrap',
'time': r.return_time.strftime('%Y-%m-%d %H:%M:%S') if r.return_time else None,
'_dt': r.return_time,
'borrow_id': r.id,
'sku': r.sku,
'material_name': label,
'quantity': qty - float(r.returned_quantity or 0),
'actor_name': r.borrower_name,
'operator_name': r.return_operator,
'remark': None,
'_seq': 3,
})
for t in transfers:
rec = by_id.get(t.borrow_id)
events.append({
'type': 'transfer',
'time': t.transfer_time.strftime('%Y-%m-%d %H:%M:%S') if t.transfer_time else None,
'_dt': t.transfer_time,
'borrow_id': t.borrow_id,
'sku': rec.sku if rec else None,
'material_name': _label(rec) if rec else '',
'quantity': float(t.transfer_qty or 0),
'actor_name': t.to_user_name, # 接收人(转交后的持有人)
'from_name': t.from_user_name, # 转出人
'operator_name': t.operator_name,
'remark': t.remark,
'_seq': 1,
})
for rt in returns:
rec = by_id.get(rt.borrow_id)
events.append({
'type': 'return',
'time': rt.return_time.strftime('%Y-%m-%d %H:%M:%S') if rt.return_time else None,
'_dt': rt.return_time,
'borrow_id': rt.borrow_id,
'sku': rec.sku if rec else None,
'material_name': _label(rec) if rec else '',
'quantity': float(rt.return_qty or 0),
'actor_name': user_name_map.get(rt.returner_id), # 实际归还人
'operator_name': rt.operator_name,
'remark': None,
'_seq': 2,
})
events.sort(key=_sort_key, reverse=True)
for e in events:
e.pop('_seq', None)
e.pop('_dt', None)
return {
'borrow_no': borrow_no,
'events': events,
'records': [r.to_dict() for r in records],
}
@staticmethod
def get_borrow_history(borrow_id):
"""
一张借出单的完整流转视图:主表快照 + 转交链 + 逐次归还明细。
★ 含行级公司隔离(Fail-Closed),与转交同口径 ——
否则「能看到哪条记录」与「能转交哪条记录」两套标准会分叉。
异常:ValueError(记录不存在 / 越权 / 隔离链路断裂),由调用方转成 4xx。
"""
record = TransBorrow.query.get(borrow_id)
if not record:
raise ValueError("借出记录不存在")
_assert_borrow_company_visible(record)
return {
'record': record.to_dict(),
'transfers': TransService.get_transfer_history(borrow_id),
'returns': TransService.get_return_history(borrow_id),
}
@staticmethod @staticmethod
def get_records(page=1, limit=10, status='all', keyword=None, search_type='all', def get_records(page=1, limit=10, status='all', keyword=None, search_type='all',
borrower_name=None, start_date=None, end_date=None, borrower_name=None, start_date=None, end_date=None,