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})
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import uuid # .material -> .base refactor checked
|
||||
from datetime import datetime
|
||||
from app.extensions import db
|
||||
from app.models.transaction import TransBorrow
|
||||
from app.extensions import db, beijing_time
|
||||
from app.models.transaction import TransBorrow, TransBorrowTransfer, TransBorrowReturn
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from app.models.inbound.semi import StockSemi
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
|
||||
@staticmethod
|
||||
@ -32,7 +75,8 @@ class TransService:
|
||||
|
||||
@staticmethod
|
||||
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 记录 → 标记审批单完成
|
||||
@ -41,6 +85,10 @@ class TransService:
|
||||
借库申请是按【名称 + 规格型号】发起的(borrow_service 强制要求 name/spec_model/quantity 三字段),
|
||||
申请时尚未绑定具体库存行;扫码出库时通过锁定 stock 行回查 material_base 表,
|
||||
用 (name, spec_model) 与审批单做物料维度聚合比对,避免 sku 维度坍塌或绕过。
|
||||
|
||||
★ borrower_id(一期转交改造后为**必填**):
|
||||
实际借用人ID。borrower_name 参数仅为向后兼容而保留 —— 落库时一律
|
||||
以 borrower_id 反查 sys_user 得到的姓名为准,传参中的姓名被忽略。
|
||||
"""
|
||||
from app.models.borrow import BorrowApproval
|
||||
|
||||
@ -57,11 +105,30 @@ class TransService:
|
||||
status_map = {0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成'}
|
||||
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:
|
||||
borrower_name = approval.borrower_name
|
||||
if not borrower_name:
|
||||
raise ValueError("审批单中未记录借库人姓名,请联系管理员补录")
|
||||
raise ValueError(f"借用人(ID:{borrower_id})用户名为空,无法生成台账快照")
|
||||
|
||||
# ==============================================
|
||||
# ★ 防线2:构建审批上限字典
|
||||
@ -178,8 +245,19 @@ class TransService:
|
||||
stock_id=stock.id,
|
||||
barcode=stock.barcode,
|
||||
quantity=qty,
|
||||
# ★ 借出即由借用人持有:borrower_id 为初始借用人(写一次不再变),
|
||||
# current_holder_* 为当前持有人 —— 转交会在此之上继续推进。
|
||||
borrower_id=int(borrower_id),
|
||||
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,
|
||||
# ★ 发货操作人:执行本次借出的库管。operator_name 此前被接收
|
||||
# 却从未落库,责任链上「谁经手发货」一直缺失,此处补齐。
|
||||
dispatch_operator=operator_name,
|
||||
remark=remark,
|
||||
expected_return_time=expected_return_time,
|
||||
# [新增] 记录借出时的库位快照
|
||||
@ -210,6 +288,7 @@ class TransService:
|
||||
items=data.get('items', []),
|
||||
operator_name=operator_name,
|
||||
borrower_name=data.get('borrower_name'),
|
||||
borrower_id=data.get('borrower_id'),
|
||||
signature=data.get('signature_path'),
|
||||
remark=data.get('remark'),
|
||||
expected_return_time=data.get('expected_return_time')
|
||||
@ -242,7 +321,7 @@ class TransService:
|
||||
return res_dict
|
||||
|
||||
@staticmethod
|
||||
def process_return(data, operator_name):
|
||||
def process_return(data, operator_name, returner_id=None):
|
||||
"""
|
||||
还库逻辑(支持部分归还)- 已优化,消除 N+1 和长事务死锁风险
|
||||
四步走策略:
|
||||
@ -250,6 +329,13 @@ class TransService:
|
||||
2. 批量锁定借用记录
|
||||
3. 收集库存ID并批量锁定库存
|
||||
4. 内存中完成业务逻辑
|
||||
|
||||
参数
|
||||
----
|
||||
operator_name : 经手办理还库的**库管**姓名(窗口操作人)
|
||||
returner_id : 实际把物品交回窗口的**归还人**ID(一期转交改造新增)。
|
||||
记录有 current_holder_id 时强制校验二者一致 ——
|
||||
详见循环内的持有人校验块。
|
||||
"""
|
||||
items = data.get('items', [])
|
||||
signature = data.get('signature_path') # 库管签字
|
||||
@ -309,6 +395,11 @@ class TransService:
|
||||
# ==========================================
|
||||
# ★ 优化步骤 4:内存中完成业务逻辑
|
||||
# ==========================================
|
||||
# ★ 时间口径修复:原实现用 datetime.now()(容器本地时间,Docker 下为
|
||||
# UTC),而同一行的 borrow_time 由 beijing_time 写入 —— 两个字段
|
||||
# 差 8 小时,台账时间线自相矛盾。统一取北京时间,并与下方归还流水
|
||||
# 共用同一个时间戳,保证主表快照与流水完全对齐。
|
||||
return_now = beijing_time()
|
||||
for borrow_id, item_data in item_map.items():
|
||||
return_qty = item_data['return_qty']
|
||||
final_location = item_data['final_location']
|
||||
@ -317,6 +408,35 @@ class TransService:
|
||||
if not record:
|
||||
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
|
||||
total_qty = float(record.quantity) if record.quantity else 0
|
||||
@ -337,28 +457,407 @@ class TransService:
|
||||
if 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
|
||||
record.returned_quantity = new_returned_qty
|
||||
|
||||
if new_returned_qty >= total_qty:
|
||||
record.is_returned = True
|
||||
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:
|
||||
record.is_returned = False
|
||||
record.status = 'partial_returned'
|
||||
|
||||
record.return_time = datetime.now()
|
||||
record.return_time = return_now
|
||||
record.return_operator = operator_name
|
||||
record.return_signature = signature
|
||||
if 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()
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
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
|
||||
def get_records(page=1, limit=10, status='all', keyword=None, search_type='all',
|
||||
borrower_name=None, start_date=None, end_date=None,
|
||||
|
||||
Reference in New Issue
Block a user