From f24797ff1fd904085c3f23210c967f4b0d5be27a Mon Sep 17 00:00:00 2001 From: yueli Date: Thu, 17 Sep 2026 10:44:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(borrow):=20=E6=8B=92=E6=94=B6=E9=A1=BB?= =?UTF-8?q?=E5=91=8A=E7=9F=A5=E5=8F=91=E8=B5=B7=E6=96=B9=EF=BC=88=E8=B4=A3?= =?UTF-8?q?=E4=BB=BB=E5=9B=9E=E5=88=B0=E4=BB=96=E6=89=8B=E4=B8=8A=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E8=83=BD=E9=9D=99=E9=BB=98=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景 ---- 双向握手补上了「接收人确认」,却只做了单向告知:接收人能看到待办,发起方却 对结果一无所知。**被拒绝时物品责任仍在发起方手上** —— 他若不主动查列表, 就会误以为已经交接出去,责任链出现静默断点。 (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 均安全返回; 库存零副作用、数据零残留。 --- .../phase4e_borrow_transfer_reject_notice.sql | 75 +++++++++++++ inventory-backend/app/api/v1/transactions.py | 31 +++++- inventory-backend/app/models/transaction.py | 7 ++ .../app/services/trans_service.py | 104 ++++++++++++++++++ 4 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 db_migrations/phase4e_borrow_transfer_reject_notice.sql diff --git a/db_migrations/phase4e_borrow_transfer_reject_notice.sql b/db_migrations/phase4e_borrow_transfer_reject_notice.sql new file mode 100644 index 0000000..8ab94fa --- /dev/null +++ b/db_migrations/phase4e_borrow_transfer_reject_notice.sql @@ -0,0 +1,75 @@ +-- ============================================================================= +-- 借库转交 · 拒收须告知发起方 +-- +-- 背景 +-- 双向握手补上了「接收人确认」这一环,但只做了单向告知:接收人能看到待办, +-- 发起方却对结果一无所知 —— 尤其是**被拒绝**时,物品责任仍在发起方手上, +-- 他若不查列表就会误以为已经交接出去,责任链出现静默断点。 +-- +-- --------------------------------------------------------------------------- +-- 为什么需要 reject_seen_at 这一列 +-- ACCEPTED 不需要告知(东西已经交出去了,发起方无需动作); +-- REJECTED 必须告知,且**必须能标记「已告知」** —— 否则发起方每次登录 +-- 都会收到同一条提醒,从「提醒」退化成「骚扰」。 +-- +-- 不宜用前端 sessionStorage 去重:换台电脑、换个浏览器就会重新提醒, +-- 而这条信息的分量(责任归属)值得一个持久标记。 +-- +-- 语义 +-- reject_seen_at IS NULL 且 status='REJECTED' → 尚未告知发起方 +-- 发起方在前端看到提醒并确认后,写当前时间 +-- +-- ★ 存量已拒绝的流水一律标记为「已告知」 +-- 它们产生于本功能上线之前,追溯提醒只会打扰(实测仅 1 条: +-- #22 测试 → 小龙虾,是验收时的测试数据)。新产生的拒绝才会触发提醒。 +-- +-- 幂等:带 IF NOT EXISTS,可重复执行。 +-- 执行:docker exec -i inventory_db psql -U test -d inventory_system < 本文件 +-- ============================================================================= + +BEGIN; + +ALTER TABLE trans_borrow_transfer + ADD COLUMN IF NOT EXISTS reject_seen_at timestamp without time zone; + +COMMENT ON COLUMN trans_borrow_transfer.reject_seen_at IS + '发起方看到「被拒绝」提醒并确认的时间。NULL 且 status=REJECTED 表示尚未告知'; + +-- 存量已拒绝的流转为已告知(本功能上线前的历史,不追溯打扰) +UPDATE trans_borrow_transfer + SET reject_seen_at = CURRENT_TIMESTAMP + WHERE status = 'REJECTED' + AND reject_seen_at IS NULL; + +-- 支撑「待告知发起方的拒绝」查询 +CREATE INDEX IF NOT EXISTS ix_trans_borrow_transfer_from_status + ON trans_borrow_transfer (from_user_id, status); + +COMMIT; + + +-- ============================================================================= +-- 执行后核对 +-- ============================================================================= +\echo '--- 1) 新列已就位 ---' +SELECT column_name, data_type FROM information_schema.columns + WHERE table_name = 'trans_borrow_transfer' AND column_name = 'reject_seen_at'; + +\echo '--- 2) 拒绝流水均已标记为已告知(应为 0 行待告知)---' +SELECT id, borrow_no, status, from_user_name, to_user_name, reject_seen_at + FROM trans_borrow_transfer + WHERE status = 'REJECTED' AND reject_seen_at IS NULL; + +\echo '--- 3) 全部流水一览 ---' +SELECT id, status, from_user_name, to_user_name, + CASE WHEN reject_seen_at IS NULL THEN '' ELSE '已告知' END AS 拒绝告知 + FROM trans_borrow_transfer ORDER BY id; + + +-- ============================================================================= +-- 回滚段 +-- ============================================================================= +-- BEGIN; +-- DROP INDEX IF EXISTS ix_trans_borrow_transfer_from_status; +-- ALTER TABLE trans_borrow_transfer DROP COLUMN IF EXISTS reject_seen_at; +-- COMMIT; diff --git a/inventory-backend/app/api/v1/transactions.py b/inventory-backend/app/api/v1/transactions.py index 1925230..c6b8d53 100644 --- a/inventory-backend/app/api/v1/transactions.py +++ b/inventory-backend/app/api/v1/transactions.py @@ -752,18 +752,45 @@ def get_pending_transfer_count(): ,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//history', methods=['GET']) @jwt_required() diff --git a/inventory-backend/app/models/transaction.py b/inventory-backend/app/models/transaction.py index fcf3028..fb918da 100644 --- a/inventory-backend/app/models/transaction.py +++ b/inventory-backend/app/models/transaction.py @@ -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, diff --git a/inventory-backend/app/services/trans_service.py b/inventory-backend/app/services/trans_service.py index 20f8767..132d39e 100644 --- a/inventory-backend/app/services/trans_service.py +++ b/inventory-backend/app/services/trans_service.py @@ -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, })