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 均安全返回;
库存零副作用、数据零残留。
This commit is contained in:
yueli
2026-09-17 10:44:00 +08:00
parent b53e22f536
commit f24797ff1f
4 changed files with 215 additions and 2 deletions

View File

@ -752,18 +752,45 @@ def get_pending_transfer_count():
<int:transfer_id>Werkzeug 会按转换器精确分派。
"""
try:
count = TransService.count_pending_transfers(get_jwt_identity())
identity = get_jwt_identity()
count = TransService.count_pending_transfers(identity)
# ★ 一并返回「我发起、被对方拒绝、尚未告知我」的转交:
# 被拒时物品责任仍在我手上,不告知就会误以为已经交接出去。
# 与待接收数量合并进同一次轮询,避免前端多打一个请求。
rejects = TransService.get_unseen_rejects(identity)
return jsonify({
'code': 200,
'msg': 'success',
'count': count,
'data': {'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()

View File

@ -164,6 +164,11 @@ class TransBorrowTransfer(db.Model):
borrow_no = db.Column(db.String(100), index=True)
# ★ 状态机:见文件顶部常量
status = db.Column(db.String(20), nullable=False, default=TRANSFER_STATUS_PENDING, index=True)
# ★ 发起方看到「被拒绝」提醒并确认的时间。
# 被拒绝时物品责任仍在发起方手上 —— 他若不查列表就会误以为已经交接出去,
# 责任链出现静默断点。故必须告知,且必须能标记「已告知」,
# 否则发起方每次登录都收到同一条提醒,从提醒退化成骚扰。
reject_seen_at = db.Column(db.DateTime)
# 转出方(= 转交前的 current_holder
from_user_id = db.Column(db.Integer)
@ -191,6 +196,8 @@ class TransBorrowTransfer(db.Model):
TRANSFER_STATUS_ACCEPTED: '已接收',
TRANSFER_STATUS_REJECTED: '已拒绝',
}.get(self.status, self.status),
# 仅供发起方「被拒绝」提醒使用,判断是否需要告知由 reject_seen_at 决定
'reject_seen': self.reject_seen_at is not None,
'from_user_id': self.from_user_id,
'from_user_name': self.from_user_name,
'to_user_id': self.to_user_id,

View File

@ -744,6 +744,106 @@ class TransService:
except (TypeError, ValueError):
return 0
@staticmethod
def get_unseen_rejects(user_id, limit=20):
"""
「我发起、被对方拒绝、且尚未告知我」的转交 —— 供全局提醒使用。
为什么必须告知发起方
-------------------
双向握手补上了「接收人确认」,但只做了单向告知:接收人能看到待办,
发起方却对结果一无所知。**被拒绝时物品责任仍在发起方手上** ——
他若不主动查列表,就会误以为已经交接出去,责任链出现静默断点。
ACCEPTED 不需要告知:东西已经交出去了,发起方无需动作。)
★ 为什么用持久标记而不是前端去重:
换台电脑、换个浏览器就会重新提醒;而这条信息的分量(责任归属)
值得一个持久标记。前端确认后调 ack_rejects 写 reject_seen_at。
★ 同时解析出物料名:只说「某笔转交被拒」发起方仍不知是哪件东西还在
自己手上,必须让他一眼认出来。批量查一次,不做 N+1。
"""
if user_id is None:
return []
try:
uid = int(user_id)
except (TypeError, ValueError):
return []
rows = (TransBorrowTransfer.query
.filter(TransBorrowTransfer.from_user_id == uid,
TransBorrowTransfer.status == TRANSFER_STATUS_REJECTED,
TransBorrowTransfer.reject_seen_at.is_(None))
.order_by(TransBorrowTransfer.id.asc())
.limit(limit)
.all())
if not rows:
return []
# 批量解析物料名(含 SKU 兜底),与列表页同口径
records = {r.id: r for r in TransBorrow.query.filter(
TransBorrow.id.in_({t.borrow_id for t in rows if t.borrow_id})
).all()}
stock_ids_by_table = {}
for r in records.values():
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}
name_map = {}
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():
name_map[(table_name, stock.id)] = stock.base.name if stock.base else ''
out = []
for t in rows:
d = t.to_dict()
rec = records.get(t.borrow_id)
d['sku'] = rec.sku if rec else None
d['material_name'] = (
name_map.get((rec.source_table, rec.stock_id), '') if rec else ''
) or (rec.sku if rec else '')
out.append(d)
return out
@staticmethod
def ack_rejects(user_id, ids=None):
"""
标记「被拒绝」提醒已告知 —— 由发起方在前端确认后调用。
ids 为空则标记该用户全部待告知的拒绝(前端一次确认通常就是全部)。
返回本次标记的条数。不做「未告知就重复弹」以外的任何副作用。
"""
if user_id is None:
return 0
try:
uid = int(user_id)
except (TypeError, ValueError):
return 0
q = TransBorrowTransfer.query.filter(
TransBorrowTransfer.from_user_id == uid,
TransBorrowTransfer.status == TRANSFER_STATUS_REJECTED,
TransBorrowTransfer.reject_seen_at.is_(None),
)
if ids:
q = q.filter(TransBorrowTransfer.id.in_(ids))
now = beijing_time()
marked = 0
for t in q.all():
t.reject_seen_at = now
marked += 1
try:
db.session.commit()
except Exception as e:
db.session.rollback()
raise e
return marked
@staticmethod
def get_transfer_history(borrow_id):
"""某条借出记录的转交历史(按时间正序,便于还原 A→B→C 链路)"""
@ -892,6 +992,10 @@ class TransService:
'from_name': t.from_user_name, # 转出人
'operator_name': t.operator_name,
'remark': t.remark,
# ★ 转交状态:不带出来的话,被拒绝的转交在时间线上与成功的
# 长得一模一样,发起方翻记录时会以为已经交接出去。
'status': t.status,
'status_text': t.to_dict().get('status_text'),
'_seq': 1,
})