Files
KCGL/inventory-backend/app/api/v1/transactions.py

826 lines
34 KiB
Python
Raw Normal View History

from flask import Blueprint, jsonify, request # .material -> .base refactor checked
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
refactor(audit): 审计架构清理——复活白名单监听器、停用噪声监听器、清除僵尸装饰器 一、统一为单一监听器实现 原先两套 SQLAlchemy 事件监听器并存: · app/utils/audit_events.py —— 全局监听 db.Model、无白名单、无请求上下文守卫(实际在跑) · app/core/audit_listener.py —— 白名单制、有守卫、有模型级开关(从未生效) 后者失效的根因:注册代码写在 extensions.py 的 init_extensions() 内, 而该函数全仓库只有定义、没有任何调用(create_app 直接内联调用 db.init_app 等)。 现统一由 app/core/audit_listener.py 承担,并在 create_app() 中显式注册。 extensions.py 的死函数 init_extensions 整体删除,避免后人误以为它是有效入口。 二、修复监听器三处致命缺陷(此前注册了也写不进数据) 1. 事件回调第二个参数是 Connection,原代码却调用 Connection.add()(不存在), 每次写日志都抛 AttributeError 并被 except 吞掉 → 改为 connection.execute() 2. register_audit_listeners 从 app.models 批量 import 多个未导出的模型, ImportError 被上层 try/except 吞掉 → 改为按表名从 db.metadata 取模型 3. 本项目有 31 处函数体内延迟导入模型(如 scrap.py 内部才 import ScrapApproval), 一次性注册会静默漏表 → 增加 ensure_audit_listeners() 惰性补绑, 并在模型预加载段补全审批单/BOM/采购等模型 三、强约束 · WHITELIST_TABLES:仅 18 张核心业务表,系统表/草稿表/向量表不再自审 · has_request_context() 守卫:系统初始化与后台定时任务不再产生 username=system 噪声 · IGNORE_FIELDS 增加 password/password_hash/salt/token/secret/api_key(安全红线) · created_at 显式写 beijing_time(),与全系统时间口径一致 四、清除僵尸装饰器 @audit_log 早已退化为直接透传的空壳(module/action 参数全被忽略, 数据库中零星的中文 action 即其历史遗留产物),却仍挂在 38 处路由上。 连同 13 个文件的 import 一并移除;audit_events.register_audit_events 改为空操作。 验证:应用上下文中的写操作不产生日志;HTTP 请求产生 5 条日志, 对象为业务单号(APR-SCRAP-... / SKU),模块中文,操作人真实,时间为北京时间。
2026-09-10 14:16:27 +08:00
from app.utils.decorators import permission_required, prevent_double_submit, is_privileged_viewer
from app.services.auth_service import AuthService
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 排序,并加回归用例锁定。
2026-09-17 09:17:57 +08:00
from app.services.trans_service import TransService, user_display_name
from app.services.borrow_service import BorrowApprovalService
2026-02-06 17:11:47 +08:00
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
2026-02-06 17:11:47 +08:00
# --- 借库接口 ---
@trans_bp.route('/borrow', methods=['POST'])
@jwt_required()
@permission_required('op_borrow:operation')
2026-02-06 17:11:47 +08:00
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')
2026-02-06 17:11:47 +08:00
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')
2026-02-06 17:11:47 +08:00
def submit_return():
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 排序,并加回归用例锁定。
2026-09-17 09:17:57 +08:00
"""
还库提交。
请求体:
{
"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()
2026-02-06 17:11:47 +08:00
try:
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 排序,并加回归用例锁定。
2026-09-17 09:17:57 +08:00
TransService.process_return(
data,
operator_name=operator_name,
returner_id=data.get('returner_id'),
)
2026-02-06 17:11:47 +08:00
return jsonify({'code': 200, 'msg': '还库成功'})
except Exception as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
feat(scrap): 报废全链路收口到审批流 系统自陈的规则是「报废一律需审批」(SCRAP_ALWAYS_REQUIRES_APPROVAL = True), 但实际有 4 条写 TransScrap 的路径,其中 3 条绕过审批。本提交把三条旁路 全部收口,只保留「申请 → 审批 → 执行」一条写入路径。 【删除】直接报废 POST /api/v1/scrap 同时移除 ScrapService.process_scrap()。该路径的一个连带影响是 「维修件报废」能力随之消失 —— process_scrap 的 trans_repair 分支是 repair_status='报废转出' 的唯一写入点。实测该能力零使用(报废转出 0 条、 trans_scrap 来源 0 条),且早已半死:审批流的扫码校验会拒绝 trans_* 来源。repair_service.py 的误导文案(原文指引操作员「前往报废管理进行 扫码操作」,而那条路根本不通)已改为「维修件报废暂未开放」。 权限元素 scrap_create:operation 保留不删 —— add_scrap_perm.sql 以它为 scrap_apply/scrap_execute 的授权来源。 【改造】借库转报废 POST /borrow/scrap → POST /borrow/scrap-request TransService.scrap_borrow() 删除,逻辑迁入 BorrowScrapAdapter。 沿用 op_return:operation 权限(零授权变更)。已归还/已报废的记录改为 **直接报错**,不再静默 continue 返回 count=0(原缺陷:用户以为成功)。 【改造】不良品报废 POST /defective/<id>/scrap → POST /defective/<id>/scrap-request 申请时**不预占** remaining_qty(与报废模块「仅锁定意向,不扣库存」的 既有哲学一致)。副作用:同一批坏件可重复提交多张申请单,执行期由适配器 按 Fail-Closed 拒绝超额的那几张。已在该接口注释与前端提示中写明。 【服务层】ScrapApprovalService 接入来源适配层 - submit_approval 改由 get_adapter() 分派,并新增整单 scrap_mode 一致性 校验(混合「需扫码/免扫码」两类来源直接拒绝,让 execute 分流保持简单) - _build_scanned_index 的来源校验改为 is_scan_source():语义上是**收窄** 而非放宽,扫码通道永远不接纳 trans_* 来源 - execute 按模式分流:scan 项走原有扫码匹配(索引只由 scan 项构建), auto 项按批准量执行。签名与调用契约不变。 - _match_key 加入来源表:不良品 SKU 是从原库存行复制的,不带来源时 同一张单里的两者会**必然串键**,扫码量算到错误对象上。纯库存单两侧 同源、键仍匹配,对既有流程零行为变更。 【修复】approve() 的 fail-open 原实现 `if user_entries and str(operator_id) not in user_entries` —— allowed_approvers 为空时条件短路为假,**任何登录用户都能审批**。 这与「报废一律需审批」直接矛盾,留这扇门等于没有审批。改为无名单即拒绝。 已实测存量「无审批人」的在途单为 0 张,不会卡死历史数据。 【扫码通道】scrap/scan 的 trans_repair 分支改为 trans_defective_goods 在管不良品按 SKU 匹配,在管量回填到既有字段形状,前端无需按来源分支。 【清理】scrap_approval_service 的 _stock_models 与 TransScrap 导入已移除 (来源差异全部收敛到适配层);trans_service / inventory_reservation 中 指向已删方法的注释已更新指向 BorrowScrapAdapter。
2026-09-16 17:14:17 +08:00
# --- 借库报废申请(未归还 → 提交报废申请,需审批)---
@trans_bp.route('/borrow/scrap-request', methods=['POST'])
@jwt_required()
feat(scrap): 报废全链路收口到审批流 系统自陈的规则是「报废一律需审批」(SCRAP_ALWAYS_REQUIRES_APPROVAL = True), 但实际有 4 条写 TransScrap 的路径,其中 3 条绕过审批。本提交把三条旁路 全部收口,只保留「申请 → 审批 → 执行」一条写入路径。 【删除】直接报废 POST /api/v1/scrap 同时移除 ScrapService.process_scrap()。该路径的一个连带影响是 「维修件报废」能力随之消失 —— process_scrap 的 trans_repair 分支是 repair_status='报废转出' 的唯一写入点。实测该能力零使用(报废转出 0 条、 trans_scrap 来源 0 条),且早已半死:审批流的扫码校验会拒绝 trans_* 来源。repair_service.py 的误导文案(原文指引操作员「前往报废管理进行 扫码操作」,而那条路根本不通)已改为「维修件报废暂未开放」。 权限元素 scrap_create:operation 保留不删 —— add_scrap_perm.sql 以它为 scrap_apply/scrap_execute 的授权来源。 【改造】借库转报废 POST /borrow/scrap → POST /borrow/scrap-request TransService.scrap_borrow() 删除,逻辑迁入 BorrowScrapAdapter。 沿用 op_return:operation 权限(零授权变更)。已归还/已报废的记录改为 **直接报错**,不再静默 continue 返回 count=0(原缺陷:用户以为成功)。 【改造】不良品报废 POST /defective/<id>/scrap → POST /defective/<id>/scrap-request 申请时**不预占** remaining_qty(与报废模块「仅锁定意向,不扣库存」的 既有哲学一致)。副作用:同一批坏件可重复提交多张申请单,执行期由适配器 按 Fail-Closed 拒绝超额的那几张。已在该接口注释与前端提示中写明。 【服务层】ScrapApprovalService 接入来源适配层 - submit_approval 改由 get_adapter() 分派,并新增整单 scrap_mode 一致性 校验(混合「需扫码/免扫码」两类来源直接拒绝,让 execute 分流保持简单) - _build_scanned_index 的来源校验改为 is_scan_source():语义上是**收窄** 而非放宽,扫码通道永远不接纳 trans_* 来源 - execute 按模式分流:scan 项走原有扫码匹配(索引只由 scan 项构建), auto 项按批准量执行。签名与调用契约不变。 - _match_key 加入来源表:不良品 SKU 是从原库存行复制的,不带来源时 同一张单里的两者会**必然串键**,扫码量算到错误对象上。纯库存单两侧 同源、键仍匹配,对既有流程零行为变更。 【修复】approve() 的 fail-open 原实现 `if user_entries and str(operator_id) not in user_entries` —— allowed_approvers 为空时条件短路为假,**任何登录用户都能审批**。 这与「报废一律需审批」直接矛盾,留这扇门等于没有审批。改为无名单即拒绝。 已实测存量「无审批人」的在途单为 0 张,不会卡死历史数据。 【扫码通道】scrap/scan 的 trans_repair 分支改为 trans_defective_goods 在管不良品按 SKU 匹配,在管量回填到既有字段形状,前端无需按来源分支。 【清理】scrap_approval_service 的 _stock_models 与 TransScrap 导入已移除 (来源差异全部收敛到适配层);trans_service / inventory_reservation 中 指向已删方法的注释已更新指向 BorrowScrapAdapter。
2026-09-16 17:14:17 +08:00
@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():
"""
feat(scrap): 报废全链路收口到审批流 系统自陈的规则是「报废一律需审批」(SCRAP_ALWAYS_REQUIRES_APPROVAL = True), 但实际有 4 条写 TransScrap 的路径,其中 3 条绕过审批。本提交把三条旁路 全部收口,只保留「申请 → 审批 → 执行」一条写入路径。 【删除】直接报废 POST /api/v1/scrap 同时移除 ScrapService.process_scrap()。该路径的一个连带影响是 「维修件报废」能力随之消失 —— process_scrap 的 trans_repair 分支是 repair_status='报废转出' 的唯一写入点。实测该能力零使用(报废转出 0 条、 trans_scrap 来源 0 条),且早已半死:审批流的扫码校验会拒绝 trans_* 来源。repair_service.py 的误导文案(原文指引操作员「前往报废管理进行 扫码操作」,而那条路根本不通)已改为「维修件报废暂未开放」。 权限元素 scrap_create:operation 保留不删 —— add_scrap_perm.sql 以它为 scrap_apply/scrap_execute 的授权来源。 【改造】借库转报废 POST /borrow/scrap → POST /borrow/scrap-request TransService.scrap_borrow() 删除,逻辑迁入 BorrowScrapAdapter。 沿用 op_return:operation 权限(零授权变更)。已归还/已报废的记录改为 **直接报错**,不再静默 continue 返回 count=0(原缺陷:用户以为成功)。 【改造】不良品报废 POST /defective/<id>/scrap → POST /defective/<id>/scrap-request 申请时**不预占** remaining_qty(与报废模块「仅锁定意向,不扣库存」的 既有哲学一致)。副作用:同一批坏件可重复提交多张申请单,执行期由适配器 按 Fail-Closed 拒绝超额的那几张。已在该接口注释与前端提示中写明。 【服务层】ScrapApprovalService 接入来源适配层 - submit_approval 改由 get_adapter() 分派,并新增整单 scrap_mode 一致性 校验(混合「需扫码/免扫码」两类来源直接拒绝,让 execute 分流保持简单) - _build_scanned_index 的来源校验改为 is_scan_source():语义上是**收窄** 而非放宽,扫码通道永远不接纳 trans_* 来源 - execute 按模式分流:scan 项走原有扫码匹配(索引只由 scan 项构建), auto 项按批准量执行。签名与调用契约不变。 - _match_key 加入来源表:不良品 SKU 是从原库存行复制的,不带来源时 同一张单里的两者会**必然串键**,扫码量算到错误对象上。纯库存单两侧 同源、键仍匹配,对既有流程零行为变更。 【修复】approve() 的 fail-open 原实现 `if user_entries and str(operator_id) not in user_entries` —— allowed_approvers 为空时条件短路为假,**任何登录用户都能审批**。 这与「报废一律需审批」直接矛盾,留这扇门等于没有审批。改为无名单即拒绝。 已实测存量「无审批人」的在途单为 0 张,不会卡死历史数据。 【扫码通道】scrap/scan 的 trans_repair 分支改为 trans_defective_goods 在管不良品按 SKU 匹配,在管量回填到既有字段形状,前端无需按来源分支。 【清理】scrap_approval_service 的 _stock_models 与 TransScrap 导入已移除 (来源差异全部收敛到适配层);trans_service / inventory_reservation 中 指向已删方法的注释已更新指向 BorrowScrapAdapter。
2026-09-16 17:14:17 +08:00
提交「借出未归还」的**报废申请**(需审批人审批,通过后由库管执行报废)。
请求体:
{
"record_ids": [1, 2, 3], # 必填,trans_borrow.id 列表(按整条待还量报废)
"reason": "物品丢失", # 可选,写入申请单备注
"approver_id": 7 # 必填,指定审批人
}
★ 为什么改走审批:
原先 POST /borrow/scrap 直接写 trans_scrap 并扣总库存,绕过审批,与系统
自陈的「报废一律需审批」冲突,构成职责分离漏洞 —— 同一个库管可自行宣告
实物损失而无人复核。现统一走:申请 → 审批 → 执行。
★ 执行方式:本来源为「免扫码」—— 东西在借用人手上,物理上不可能扫码;
且执行只改台账与总库存,不产生任何可被挪用的可用库存。
"""
data = request.get_json() or {}
feat(scrap): 报废全链路收口到审批流 系统自陈的规则是「报废一律需审批」(SCRAP_ALWAYS_REQUIRES_APPROVAL = True), 但实际有 4 条写 TransScrap 的路径,其中 3 条绕过审批。本提交把三条旁路 全部收口,只保留「申请 → 审批 → 执行」一条写入路径。 【删除】直接报废 POST /api/v1/scrap 同时移除 ScrapService.process_scrap()。该路径的一个连带影响是 「维修件报废」能力随之消失 —— process_scrap 的 trans_repair 分支是 repair_status='报废转出' 的唯一写入点。实测该能力零使用(报废转出 0 条、 trans_scrap 来源 0 条),且早已半死:审批流的扫码校验会拒绝 trans_* 来源。repair_service.py 的误导文案(原文指引操作员「前往报废管理进行 扫码操作」,而那条路根本不通)已改为「维修件报废暂未开放」。 权限元素 scrap_create:operation 保留不删 —— add_scrap_perm.sql 以它为 scrap_apply/scrap_execute 的授权来源。 【改造】借库转报废 POST /borrow/scrap → POST /borrow/scrap-request TransService.scrap_borrow() 删除,逻辑迁入 BorrowScrapAdapter。 沿用 op_return:operation 权限(零授权变更)。已归还/已报废的记录改为 **直接报错**,不再静默 continue 返回 count=0(原缺陷:用户以为成功)。 【改造】不良品报废 POST /defective/<id>/scrap → POST /defective/<id>/scrap-request 申请时**不预占** remaining_qty(与报废模块「仅锁定意向,不扣库存」的 既有哲学一致)。副作用:同一批坏件可重复提交多张申请单,执行期由适配器 按 Fail-Closed 拒绝超额的那几张。已在该接口注释与前端提示中写明。 【服务层】ScrapApprovalService 接入来源适配层 - submit_approval 改由 get_adapter() 分派,并新增整单 scrap_mode 一致性 校验(混合「需扫码/免扫码」两类来源直接拒绝,让 execute 分流保持简单) - _build_scanned_index 的来源校验改为 is_scan_source():语义上是**收窄** 而非放宽,扫码通道永远不接纳 trans_* 来源 - execute 按模式分流:scan 项走原有扫码匹配(索引只由 scan 项构建), auto 项按批准量执行。签名与调用契约不变。 - _match_key 加入来源表:不良品 SKU 是从原库存行复制的,不带来源时 同一张单里的两者会**必然串键**,扫码量算到错误对象上。纯库存单两侧 同源、键仍匹配,对既有流程零行为变更。 【修复】approve() 的 fail-open 原实现 `if user_entries and str(operator_id) not in user_entries` —— allowed_approvers 为空时条件短路为假,**任何登录用户都能审批**。 这与「报废一律需审批」直接矛盾,留这扇门等于没有审批。改为无名单即拒绝。 已实测存量「无审批人」的在途单为 0 张,不会卡死历史数据。 【扫码通道】scrap/scan 的 trans_repair 分支改为 trans_defective_goods 在管不良品按 SKU 匹配,在管量回填到既有字段形状,前端无需按来源分支。 【清理】scrap_approval_service 的 _stock_models 与 TransScrap 导入已移除 (来源差异全部收敛到适配层);trans_service / inventory_reservation 中 指向已删方法的注释已更新指向 BorrowScrapAdapter。
2026-09-16 17:14:17 +08:00
record_ids = data.get('record_ids') or []
reason = (data.get('reason') or '').strip()
approver_id = data.get('approver_id')
if not record_ids:
feat(scrap): 报废全链路收口到审批流 系统自陈的规则是「报废一律需审批」(SCRAP_ALWAYS_REQUIRES_APPROVAL = True), 但实际有 4 条写 TransScrap 的路径,其中 3 条绕过审批。本提交把三条旁路 全部收口,只保留「申请 → 审批 → 执行」一条写入路径。 【删除】直接报废 POST /api/v1/scrap 同时移除 ScrapService.process_scrap()。该路径的一个连带影响是 「维修件报废」能力随之消失 —— process_scrap 的 trans_repair 分支是 repair_status='报废转出' 的唯一写入点。实测该能力零使用(报废转出 0 条、 trans_scrap 来源 0 条),且早已半死:审批流的扫码校验会拒绝 trans_* 来源。repair_service.py 的误导文案(原文指引操作员「前往报废管理进行 扫码操作」,而那条路根本不通)已改为「维修件报废暂未开放」。 权限元素 scrap_create:operation 保留不删 —— add_scrap_perm.sql 以它为 scrap_apply/scrap_execute 的授权来源。 【改造】借库转报废 POST /borrow/scrap → POST /borrow/scrap-request TransService.scrap_borrow() 删除,逻辑迁入 BorrowScrapAdapter。 沿用 op_return:operation 权限(零授权变更)。已归还/已报废的记录改为 **直接报错**,不再静默 continue 返回 count=0(原缺陷:用户以为成功)。 【改造】不良品报废 POST /defective/<id>/scrap → POST /defective/<id>/scrap-request 申请时**不预占** remaining_qty(与报废模块「仅锁定意向,不扣库存」的 既有哲学一致)。副作用:同一批坏件可重复提交多张申请单,执行期由适配器 按 Fail-Closed 拒绝超额的那几张。已在该接口注释与前端提示中写明。 【服务层】ScrapApprovalService 接入来源适配层 - submit_approval 改由 get_adapter() 分派,并新增整单 scrap_mode 一致性 校验(混合「需扫码/免扫码」两类来源直接拒绝,让 execute 分流保持简单) - _build_scanned_index 的来源校验改为 is_scan_source():语义上是**收窄** 而非放宽,扫码通道永远不接纳 trans_* 来源 - execute 按模式分流:scan 项走原有扫码匹配(索引只由 scan 项构建), auto 项按批准量执行。签名与调用契约不变。 - _match_key 加入来源表:不良品 SKU 是从原库存行复制的,不带来源时 同一张单里的两者会**必然串键**,扫码量算到错误对象上。纯库存单两侧 同源、键仍匹配,对既有流程零行为变更。 【修复】approve() 的 fail-open 原实现 `if user_entries and str(operator_id) not in user_entries` —— allowed_approvers 为空时条件短路为假,**任何登录用户都能审批**。 这与「报废一律需审批」直接矛盾,留这扇门等于没有审批。改为无名单即拒绝。 已实测存量「无审批人」的在途单为 0 张,不会卡死历史数据。 【扫码通道】scrap/scan 的 trans_repair 分支改为 trans_defective_goods 在管不良品按 SKU 匹配,在管量回填到既有字段形状,前端无需按来源分支。 【清理】scrap_approval_service 的 _stock_models 与 TransScrap 导入已移除 (来源差异全部收敛到适配层);trans_service / inventory_reservation 中 指向已删方法的注释已更新指向 BorrowScrapAdapter。
2026-09-16 17:14:17 +08:00
return jsonify({'code': 400, 'msg': '请选择要申请报废的借出记录'}), 400
if not approver_id:
return jsonify({'code': 400, 'msg': '请选择审批人'}), 400
try:
feat(scrap): 报废全链路收口到审批流 系统自陈的规则是「报废一律需审批」(SCRAP_ALWAYS_REQUIRES_APPROVAL = True), 但实际有 4 条写 TransScrap 的路径,其中 3 条绕过审批。本提交把三条旁路 全部收口,只保留「申请 → 审批 → 执行」一条写入路径。 【删除】直接报废 POST /api/v1/scrap 同时移除 ScrapService.process_scrap()。该路径的一个连带影响是 「维修件报废」能力随之消失 —— process_scrap 的 trans_repair 分支是 repair_status='报废转出' 的唯一写入点。实测该能力零使用(报废转出 0 条、 trans_scrap 来源 0 条),且早已半死:审批流的扫码校验会拒绝 trans_* 来源。repair_service.py 的误导文案(原文指引操作员「前往报废管理进行 扫码操作」,而那条路根本不通)已改为「维修件报废暂未开放」。 权限元素 scrap_create:operation 保留不删 —— add_scrap_perm.sql 以它为 scrap_apply/scrap_execute 的授权来源。 【改造】借库转报废 POST /borrow/scrap → POST /borrow/scrap-request TransService.scrap_borrow() 删除,逻辑迁入 BorrowScrapAdapter。 沿用 op_return:operation 权限(零授权变更)。已归还/已报废的记录改为 **直接报错**,不再静默 continue 返回 count=0(原缺陷:用户以为成功)。 【改造】不良品报废 POST /defective/<id>/scrap → POST /defective/<id>/scrap-request 申请时**不预占** remaining_qty(与报废模块「仅锁定意向,不扣库存」的 既有哲学一致)。副作用:同一批坏件可重复提交多张申请单,执行期由适配器 按 Fail-Closed 拒绝超额的那几张。已在该接口注释与前端提示中写明。 【服务层】ScrapApprovalService 接入来源适配层 - submit_approval 改由 get_adapter() 分派,并新增整单 scrap_mode 一致性 校验(混合「需扫码/免扫码」两类来源直接拒绝,让 execute 分流保持简单) - _build_scanned_index 的来源校验改为 is_scan_source():语义上是**收窄** 而非放宽,扫码通道永远不接纳 trans_* 来源 - execute 按模式分流:scan 项走原有扫码匹配(索引只由 scan 项构建), auto 项按批准量执行。签名与调用契约不变。 - _match_key 加入来源表:不良品 SKU 是从原库存行复制的,不带来源时 同一张单里的两者会**必然串键**,扫码量算到错误对象上。纯库存单两侧 同源、键仍匹配,对既有流程零行为变更。 【修复】approve() 的 fail-open 原实现 `if user_entries and str(operator_id) not in user_entries` —— allowed_approvers 为空时条件短路为假,**任何登录用户都能审批**。 这与「报废一律需审批」直接矛盾,留这扇门等于没有审批。改为无名单即拒绝。 已实测存量「无审批人」的在途单为 0 张,不会卡死历史数据。 【扫码通道】scrap/scan 的 trans_repair 分支改为 trans_defective_goods 在管不良品按 SKU 匹配,在管量回填到既有字段形状,前端无需按来源分支。 【清理】scrap_approval_service 的 _stock_models 与 TransScrap 导入已移除 (来源差异全部收敛到适配层);trans_service / inventory_reservation 中 指向已删方法的注释已更新指向 BorrowScrapAdapter。
2026-09-16 17:14:17 +08:00
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()
feat(scrap): 报废全链路收口到审批流 系统自陈的规则是「报废一律需审批」(SCRAP_ALWAYS_REQUIRES_APPROVAL = True), 但实际有 4 条写 TransScrap 的路径,其中 3 条绕过审批。本提交把三条旁路 全部收口,只保留「申请 → 审批 → 执行」一条写入路径。 【删除】直接报废 POST /api/v1/scrap 同时移除 ScrapService.process_scrap()。该路径的一个连带影响是 「维修件报废」能力随之消失 —— process_scrap 的 trans_repair 分支是 repair_status='报废转出' 的唯一写入点。实测该能力零使用(报废转出 0 条、 trans_scrap 来源 0 条),且早已半死:审批流的扫码校验会拒绝 trans_* 来源。repair_service.py 的误导文案(原文指引操作员「前往报废管理进行 扫码操作」,而那条路根本不通)已改为「维修件报废暂未开放」。 权限元素 scrap_create:operation 保留不删 —— add_scrap_perm.sql 以它为 scrap_apply/scrap_execute 的授权来源。 【改造】借库转报废 POST /borrow/scrap → POST /borrow/scrap-request TransService.scrap_borrow() 删除,逻辑迁入 BorrowScrapAdapter。 沿用 op_return:operation 权限(零授权变更)。已归还/已报废的记录改为 **直接报错**,不再静默 continue 返回 count=0(原缺陷:用户以为成功)。 【改造】不良品报废 POST /defective/<id>/scrap → POST /defective/<id>/scrap-request 申请时**不预占** remaining_qty(与报废模块「仅锁定意向,不扣库存」的 既有哲学一致)。副作用:同一批坏件可重复提交多张申请单,执行期由适配器 按 Fail-Closed 拒绝超额的那几张。已在该接口注释与前端提示中写明。 【服务层】ScrapApprovalService 接入来源适配层 - submit_approval 改由 get_adapter() 分派,并新增整单 scrap_mode 一致性 校验(混合「需扫码/免扫码」两类来源直接拒绝,让 execute 分流保持简单) - _build_scanned_index 的来源校验改为 is_scan_source():语义上是**收窄** 而非放宽,扫码通道永远不接纳 trans_* 来源 - execute 按模式分流:scan 项走原有扫码匹配(索引只由 scan 项构建), auto 项按批准量执行。签名与调用契约不变。 - _match_key 加入来源表:不良品 SKU 是从原库存行复制的,不带来源时 同一张单里的两者会**必然串键**,扫码量算到错误对象上。纯库存单两侧 同源、键仍匹配,对既有流程零行为变更。 【修复】approve() 的 fail-open 原实现 `if user_entries and str(operator_id) not in user_entries` —— allowed_approvers 为空时条件短路为假,**任何登录用户都能审批**。 这与「报废一律需审批」直接矛盾,留这扇门等于没有审批。改为无名单即拒绝。 已实测存量「无审批人」的在途单为 0 张,不会卡死历史数据。 【扫码通道】scrap/scan 的 trans_repair 分支改为 trans_defective_goods 在管不良品按 SKU 匹配,在管量回填到既有字段形状,前端无需按来源分支。 【清理】scrap_approval_service 的 _stock_models 与 TransScrap 导入已移除 (来源差异全部收敛到适配层);trans_service / inventory_reservation 中 指向已删方法的注释已更新指向 BorrowScrapAdapter。
2026-09-16 17:14:17 +08:00
return jsonify({'code': 500, 'msg': f'提交报废申请失败: {str(e)}'}), 500
2026-02-06 17:11:47 +08:00
# --- 记录列表 ---
@trans_bp.route('/records', methods=['GET'])
@jwt_required()
@permission_required('op_records')
2026-02-06 17:11:47 +08:00
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', '')
2026-02-06 17:11:47 +08:00
# ★ 高级筛选:JSON 字符串 → 条件列表
from app.utils.advanced_filter import parse_advanced_filters
advanced_filters = parse_advanced_filters(request.args.get('advancedFilters', ''))
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
# ★ 数据权限:普通用户只看与自己有关的记录(借用人 / 当前持有人 / 待我接收的
# 转交),管理者看全部。两个口径都要传下去:
# viewer_user_id —— 精确锚点,覆盖转交接收人(此前只按姓名过滤,
# 接收人在自己的列表里看不到东西)
# borrower_name —— 姓名口径,兼容只有姓名、没有 ID 的历史行
borrower_name = None
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
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))
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
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,
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
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()
perf: 综合安全加固 — RBAC严格映射+异步邮件+字段权限白名单+前端对齐+导入模板 本次提交包含本会话所有修改的最终统一提交 ## 权限系统重构 - permission_service.py: 添加入库/采购操作元素 + ensure_default_permissions - field_permissions.py: 严格1-to-1 Default Deny 字段映射(StockBuy/Semi/Product/MaterialBase) - decorators.py: _expand_operation_perms 双向粒度桥接 + prevent_double_submit - deploy_production.sql: 修复 sys_element 别名码(qty_inbound→in_quantity) ## 采购模块 - purchase.py: 权限驱动可见性 + inbound_purchase独立权限 + 价格字段过滤 - purchase_service.py: 异步邮件 + 三阶段批量模糊匹配防N+1 - purchase/index.vue: canApprove严格操作权限 + upload重复修复 ## 导出/入 - base_service.py: export_excel 流式写入防OOM + get_latest_specs 优化 - import_service.py + import_api.py: Excel批量导入(模板+预览+执行) - ImportDialog.vue: 三步骤导入弹窗 ## 异步邮件 - email_service.py: send_email_async (守护线程) - inventory_task.py: send_email→send_email_async ## 前端对齐 - product/semi/buy.vue: 列对齐in_quantity/stock_quantity/available_quantity + localStorage缓存V2 - buyOdoo.vue: 排序修复 + 导入按钮 + 移除点击展开加载 - BomManage.vue: 懒加载分组 + 导入按钮 - list.vue: 导入按钮 - Selection.vue + borrow/apply: BOM匹配修复 + 导入按钮 - outbound/create.vue: 出库类型必选 - AppMain.vue: 移除transition白屏修复 - material_base.ts, outbound.ts, bom.ts, stock.ts: 新增API函数
2026-07-17 13:07:12 +08:00
@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
feat(borrow,scrap): 补齐申请人撤回端点,与出库对齐 背景 ---- 出库已有独立的申请人撤回端点(仅 @jwt_required + 服务层归属断言), 但借库/报废没有: · 借库撤回复用 close 端点,权限是 op_borrow_approval(管理路径), 普通员工调用返回 403; · 报废撤回带 @permission_required('scrap_apply'),同样挡住普通申请人 (scrap_apply 只授予 INBOUND/OUTBOUND/SUPERVISOR/SUPER_ADMIN)。 结果是「我的申请单」页面里,借库/报废的撤回按钮对普通员工点了报错。 借库 ---- 服务层拆成与管理路径并列的两条入口(与出库同构): mark_completed —— 管理路径,需 op_borrow_approval,仅 status==1 withdraw_request —— 申请人路径,仅校验单据归属,status 0 或 1 两者共用 _release_and_close(),释放逻辑只有一份实现。 新增 POST /transactions/borrow/request/<id>/withdraw(仅 @jwt_required)。 报废 ---- withdraw() 增加 require_owner 参数区分两条路径: require_owner=True (默认,申请人路径)→ 断言 applicant_id == operator_id require_owner=False(管理路径) → 由调用方权限装饰器鉴权 WITHDRAWABLE_STATUS 从 (1,) 放宽到 (0, 1),与出库/借库对齐。 移除端点上的 @permission_required('scrap_apply'),改走归属校验。 安全实测 -------- 借库 B 撤 A 的单 → 403,库存仍是 27(未被释放) 借库 A 撤自己的单 → 200,30 完全释放 报废 B 撤 A 的单 → 403 报废 A 撤自己的单 → 200 主管代撤员工的单(特权路径)→ 200
2026-09-10 15:46:55 +08:00
@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) 与审批单比对
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 排序,并加回归用例锁定。
2026-09-17 09:17:57 +08:00
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'),
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 排序,并加回归用例锁定。
2026-09-17 09:17:57 +08:00
# ★ 强制借用人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')
)
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
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 排序,并加回归用例锁定。
2026-09-17 09:17:57 +08:00
# ==============================================================================
# 借库转交(Borrow Transfer)一期
# ==============================================================================
# --- 借库链路人员选择器(借用人 / 转交接收人 / 实际归还人)---
@trans_bp.route('/borrow/users', methods=['GET'])
@jwt_required()
def get_borrow_user_options():
"""
借库责任链上的人员名单:借用人、转交接收人、实际归还人共用一个数据源。
★ 为什么只做 @jwt_required() 而不加 permission_required:
同一份名单被三个页面共用 —— 借出(op_borrow:operation)、
feat(borrow): 转交发起收紧为「仅当前持有人本人」(责任链隔离) 背景 ---- 此前【转交】只要持有 borrow_transfer 权限就可见可调,与「当前持有人」无关 —— 任何库管都能把别人保管的资产转给第三方,责任链形同虚设。业务方确认改为 **只有该物品的当前持有人本人可以发起**。 改动(前后端同改,缺一不可) ---- · service.transfer_borrow 新增 caller_user_id,强校验其 == 该明细 current_holder_id;传 None 一律拒绝,不做「系统内部调用」的隐式放行。 · get_records 为每条明细附加 can_transfer(当前持有人 == 我)—— 前端 localStorage 里只有 username 没有 user_id,故与 is_mine 一样由后端判定。 · 前端明细行【转交】改判 can_transfer;主行【转交】改为「该单下存在由我持有 的未还物品」时才出现;弹窗候选也过滤为「由我持有」,不是我的不列进来 (后端会拒,列出来只会误导)。 ★ 连带调整:移除 route 上的 permission_required('borrow_transfer') 责任链规则既然是「持有人本人」,而持有人是普通员工、通常不持有库管权限, 再加一道库管权限,实际能发起的人变成「持有人 ∩ 库管」,绝大多数持有人 反而发不了 —— 功能形同虚设。这与 accept/reject 同级:员工处置自己名下资产。 真正的边界是 service 层的 caller_user_id 强校验,不是界面遮挡。 ⚠ 由此 borrow_transfer 权限码已无任何代码引用(sys_element 中的定义与 4 个角色的授权仍在,属无害冗余)。若后续需要「管理员代办」入口, 可在此基础上加豁免;若确定不需要,该权限码可择期下线。 验证(13 项断言全通过) ---- · 非持有人发起被拒;未传调用者被拒;持有人转给自己被拒 · 持有人本人发起成功,from_user_id 正确记为持有人 · can_transfer:持有人 True / 接收人 False;接收转移后新持有人变 True · 接收环节不受影响;库存零副作用、数据零残留
2026-09-17 10:20:34 +08:00
归还(op_return:operation)、转交(发起人是**持有人本人**,可能不具备任何
库管权限)。绑定其中任一权限码,
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 排序,并加回归用例锁定。
2026-09-17 09:17:57 +08:00
另外两个页面都会 403。此处沿用 /auth/users/approvers 的既有处理,
且**只返回 id 与姓名**,不含邮箱/角色/部门等字段,最小披露。
★ 公司隔离与借用台账同口径(get_current_company_filter):
否则 A 公司库管能在选择器里看到 B 公司人员,虽转交时会被 company
校验二次拦截,但名单本身已属越权披露。
feat(outbound,common): 补发可指定「补发给谁」+ 抽出通用人员名单接口 一、补发申请人可选择(原单退回) 退回接口新增 reissue_applicant_id: ① 前端指定 → 校验用户存在后落库; ② 未指定 → 回退为**当前操作人**(原行为不变,向后兼容)。 为何不自动推断原申请人:trans_outbound **没有申请人字段,也没有指回原审批单 的关联**(扫码出库时只把审批单状态置为 3),按 consumer_name 反查会重蹈 「重名错绑」的覆辙(借用人姓名回填那轮刚踩过)。故把选择权交给现场,不猜。 二、抽出中性人员名单 GET /api/v1/common/active-users 实现抽到 common.active_user_options(),借库的 /transactions/borrow/users 改为调同一函数 —— 实现只有一份,但出库补发走**中性路径**,不再出现 「出库为什么在调借库的接口」这种跨模块语义错位。 仅要求登录、只返回 id 与姓名(与 /auth/users/approvers 同一处理)。 ★ 本次无需 DB 迁移:未新增任何列,补发申请人是复用已有的 outbound_approval.applicant_id。 验证(打桩/真实 token 直连接口,12 项断言全通过) · 名单只含 id/name,无邮箱/角色/部门;借库原路径返回值与新路径完全一致 · 指定「补发给谁」→ 补发单申请人 = 指定的人;备注仍含原领用人 · 不指定 → 回退为当前操作人 ★ 指定不存在的用户 → 被拒,且整笔退回回滚(流水未落库) 库存与数据零残留。
2026-09-17 12:01:11 +08:00
★ 实现已抽到 common.active_user_options(),与 /common/active-users 共用同一份
逻辑 —— 出库补发等场景走那条中性路径,避免跨模块引用借库接口。
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 排序,并加回归用例锁定。
2026-09-17 09:17:57 +08:00
"""
feat(outbound,common): 补发可指定「补发给谁」+ 抽出通用人员名单接口 一、补发申请人可选择(原单退回) 退回接口新增 reissue_applicant_id: ① 前端指定 → 校验用户存在后落库; ② 未指定 → 回退为**当前操作人**(原行为不变,向后兼容)。 为何不自动推断原申请人:trans_outbound **没有申请人字段,也没有指回原审批单 的关联**(扫码出库时只把审批单状态置为 3),按 consumer_name 反查会重蹈 「重名错绑」的覆辙(借用人姓名回填那轮刚踩过)。故把选择权交给现场,不猜。 二、抽出中性人员名单 GET /api/v1/common/active-users 实现抽到 common.active_user_options(),借库的 /transactions/borrow/users 改为调同一函数 —— 实现只有一份,但出库补发走**中性路径**,不再出现 「出库为什么在调借库的接口」这种跨模块语义错位。 仅要求登录、只返回 id 与姓名(与 /auth/users/approvers 同一处理)。 ★ 本次无需 DB 迁移:未新增任何列,补发申请人是复用已有的 outbound_approval.applicant_id。 验证(打桩/真实 token 直连接口,12 项断言全通过) · 名单只含 id/name,无邮箱/角色/部门;借库原路径返回值与新路径完全一致 · 指定「补发给谁」→ 补发单申请人 = 指定的人;备注仍含原领用人 · 不指定 → 回退为当前操作人 ★ 指定不存在的用户 → 被拒,且整笔退回回滚(流水未落库) 库存与数据零残留。
2026-09-17 12:01:11 +08:00
from app.api.v1.common.users import active_user_options
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 排序,并加回归用例锁定。
2026-09-17 09:17:57 +08:00
feat(outbound,common): 补发可指定「补发给谁」+ 抽出通用人员名单接口 一、补发申请人可选择(原单退回) 退回接口新增 reissue_applicant_id: ① 前端指定 → 校验用户存在后落库; ② 未指定 → 回退为**当前操作人**(原行为不变,向后兼容)。 为何不自动推断原申请人:trans_outbound **没有申请人字段,也没有指回原审批单 的关联**(扫码出库时只把审批单状态置为 3),按 consumer_name 反查会重蹈 「重名错绑」的覆辙(借用人姓名回填那轮刚踩过)。故把选择权交给现场,不猜。 二、抽出中性人员名单 GET /api/v1/common/active-users 实现抽到 common.active_user_options(),借库的 /transactions/borrow/users 改为调同一函数 —— 实现只有一份,但出库补发走**中性路径**,不再出现 「出库为什么在调借库的接口」这种跨模块语义错位。 仅要求登录、只返回 id 与姓名(与 /auth/users/approvers 同一处理)。 ★ 本次无需 DB 迁移:未新增任何列,补发申请人是复用已有的 outbound_approval.applicant_id。 验证(打桩/真实 token 直连接口,12 项断言全通过) · 名单只含 id/name,无邮箱/角色/部门;借库原路径返回值与新路径完全一致 · 指定「补发给谁」→ 补发单申请人 = 指定的人;备注仍含原领用人 · 不指定 → 回退为当前操作人 ★ 指定不存在的用户 → 被拒,且整笔退回回滚(流水未落库) 库存与数据零残留。
2026-09-17 12:01:11 +08:00
return jsonify({'code': 200, 'msg': 'success', 'data': active_user_options()})
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 排序,并加回归用例锁定。
2026-09-17 09:17:57 +08:00
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
# --- 发起借库转交(双向握手第一步)---
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 排序,并加回归用例锁定。
2026-09-17 09:17:57 +08:00
@trans_bp.route('/borrow/<int:borrow_id>/transfer', methods=['POST'])
@jwt_required()
@prevent_double_submit(lock_timeout=5)
def transfer_borrow(borrow_id):
feat(borrow): 转交发起收紧为「仅当前持有人本人」(责任链隔离) 背景 ---- 此前【转交】只要持有 borrow_transfer 权限就可见可调,与「当前持有人」无关 —— 任何库管都能把别人保管的资产转给第三方,责任链形同虚设。业务方确认改为 **只有该物品的当前持有人本人可以发起**。 改动(前后端同改,缺一不可) ---- · service.transfer_borrow 新增 caller_user_id,强校验其 == 该明细 current_holder_id;传 None 一律拒绝,不做「系统内部调用」的隐式放行。 · get_records 为每条明细附加 can_transfer(当前持有人 == 我)—— 前端 localStorage 里只有 username 没有 user_id,故与 is_mine 一样由后端判定。 · 前端明细行【转交】改判 can_transfer;主行【转交】改为「该单下存在由我持有 的未还物品」时才出现;弹窗候选也过滤为「由我持有」,不是我的不列进来 (后端会拒,列出来只会误导)。 ★ 连带调整:移除 route 上的 permission_required('borrow_transfer') 责任链规则既然是「持有人本人」,而持有人是普通员工、通常不持有库管权限, 再加一道库管权限,实际能发起的人变成「持有人 ∩ 库管」,绝大多数持有人 反而发不了 —— 功能形同虚设。这与 accept/reject 同级:员工处置自己名下资产。 真正的边界是 service 层的 caller_user_id 强校验,不是界面遮挡。 ⚠ 由此 borrow_transfer 权限码已无任何代码引用(sys_element 中的定义与 4 个角色的授权仍在,属无害冗余)。若后续需要「管理员代办」入口, 可在此基础上加豁免;若确定不需要,该权限码可择期下线。 验证(13 项断言全通过) ---- · 非持有人发起被拒;未传调用者被拒;持有人转给自己被拒 · 持有人本人发起成功,from_user_id 正确记为持有人 · can_transfer:持有人 True / 接收人 False;接收转移后新持有人变 True · 接收环节不受影响;库存零副作用、数据零残留
2026-09-17 10:20:34 +08:00
# ★ 为什么不加 permission_required('borrow_transfer'):
# 转交的责任链隔离规则是「**只有当前持有人本人**可以发起」,而持有人是
# 普通员工,通常并不持有库管权限。若再挂一道库管权限,实际能发起的人
# 变成「持有人 ∩ 库管」,绝大多数持有人反而发不了 —— 功能形同虚设。
# 这与 accept / reject 同级:员工处置自己名下资产,不是库管职权。
# 真正的边界在 service 层的 caller_user_id 强校验。
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 排序,并加回归用例锁定。
2026-09-17 09:17:57 +08:00
"""
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
发起借库转交:把一张借出单的持有权**整单**转给另一人,等待对方确认。
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 排序,并加回归用例锁定。
2026-09-17 09:17:57 +08:00
请求体:
{
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
"transfer_qty": 10, # 可选,传入时须等于整单待还量(一致性校验)
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 排序,并加回归用例锁定。
2026-09-17 09:17:57 +08:00
"to_user_id": 12, # 必需,接收人ID(唯一身份锚点)
"to_user_name": "张三", # 可选,仅作兼容;落库姓名以 to_user_id 反查为准
"remark": "..." # 可选
}
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
★ 双向握手:本接口**只落一条 PENDING 流水,不改主表 current_holder**。
东西还没到接收人手上,责任仍归原持有人 —— 接收人在自己的列表里确认
(POST /borrow/transfer/<id>/accept)后才真正转移。
feat(borrow): 转交粒度下沉到明细行,支持部分转交 背景(业务方推翻上一轮约束) ---- 上一轮按「一张单同时只能有一个持有人」实现了**整单转交**,并把「单内出现多个 持有人」当作 bug 去修。业务方验收后明确纠正: 物理现场经常只转交部分工具(借了 2 件、只把 1 件转给别人), 单内多持有人才是符合现实的正常状态。 故转交粒度从 borrow_no 下沉回 trans_borrow.id(明细行)。 改动 ---- · transfer_borrow:只操作传入的那**一行**明细,不再按单号整批覆盖。 转出方 = 该行当前持有人;数量 = 该行待还量。 · accept_transfer:只转移 transfer.borrow_id 指向的那一行 —— 整批改写会把别人手上的东西一并抢过来(部分转交下同单明细分属不同人)。 · 唯一性约束从「单号至多一条 PENDING」下沉为「明细行至多一条」: 同单的其他明细可以同时各自挂着待接收,互不阻塞 —— 这正是部分转交的语义。 · get_records 的 pending_transfer 改按 borrow_id 关联(原按 borrow_no), 否则同单多项待接收会互相覆盖。 · 删除已无用的 _load_slip_for_update。 ★ 数量粒度:一行只支持**整行转交**。一行只能有一个 current_holder_id, 「同一行只转一部分」需要把这行拆成两行 —— 经业务确认,现场场景中 「借 2 件转 1 件」的两件本就是两条明细行,故该限制不影响实际使用; 接口对传入的非整行数量会明确提示「应另立一条明细行」。 数据层 ---- 无需改表结构:borrow_id 本就是流水的关联列,borrow_no 退化为单据归属与 分组展示用。仅补 (borrow_id, status) 复合索引支撑新的查询路径。 存量撕裂数据(BOR-20260917-0001 的「测试 / 杜邢宸」)按业务方选择**保留不动** —— 它现在不再是 bug,而是部分转交的正常形态。 验证(合成 2 明细单,21 项断言全通过) ---- · 只转工具A:工具B 完全不受影响 · 同一张单可同时挂两条待接收,互不阻塞;同一明细重复发起被拒 · accept 工具A 后:A→测试,B 仍是杜邢宸(单内两个持有人) · 两个持有人、以及待接收人,三方各自都能在列表中看到该单 · pending_transfer 挂在正确的明细行上,is_mine 判定正确 · reject 后主表持有人不变;非整行数量被拒并提示拆行 · 全程 available_quantity 无变化,库存精确还原、零残留数据
2026-09-17 10:12:31 +08:00
★ 转交粒度 = **明细行**(传入的 borrow_id 就是目标)。同一张单的其他明细
不受影响,故「借 2 件只转 1 件」得到天然支持;同单不同明细归属不同持有人
是正常业务形态。
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
feat(borrow): 转交发起收紧为「仅当前持有人本人」(责任链隔离) 背景 ---- 此前【转交】只要持有 borrow_transfer 权限就可见可调,与「当前持有人」无关 —— 任何库管都能把别人保管的资产转给第三方,责任链形同虚设。业务方确认改为 **只有该物品的当前持有人本人可以发起**。 改动(前后端同改,缺一不可) ---- · service.transfer_borrow 新增 caller_user_id,强校验其 == 该明细 current_holder_id;传 None 一律拒绝,不做「系统内部调用」的隐式放行。 · get_records 为每条明细附加 can_transfer(当前持有人 == 我)—— 前端 localStorage 里只有 username 没有 user_id,故与 is_mine 一样由后端判定。 · 前端明细行【转交】改判 can_transfer;主行【转交】改为「该单下存在由我持有 的未还物品」时才出现;弹窗候选也过滤为「由我持有」,不是我的不列进来 (后端会拒,列出来只会误导)。 ★ 连带调整:移除 route 上的 permission_required('borrow_transfer') 责任链规则既然是「持有人本人」,而持有人是普通员工、通常不持有库管权限, 再加一道库管权限,实际能发起的人变成「持有人 ∩ 库管」,绝大多数持有人 反而发不了 —— 功能形同虚设。这与 accept/reject 同级:员工处置自己名下资产。 真正的边界是 service 层的 caller_user_id 强校验,不是界面遮挡。 ⚠ 由此 borrow_transfer 权限码已无任何代码引用(sys_element 中的定义与 4 个角色的授权仍在,属无害冗余)。若后续需要「管理员代办」入口, 可在此基础上加豁免;若确定不需要,该权限码可择期下线。 验证(13 项断言全通过) ---- · 非持有人发起被拒;未传调用者被拒;持有人转给自己被拒 · 持有人本人发起成功,from_user_id 正确记为持有人 · can_transfer:持有人 True / 接收人 False;接收转移后新持有人变 True · 接收环节不受影响;库存零副作用、数据零残留
2026-09-17 10:20:34 +08:00
★ 责任链隔离:**只有该物品的当前持有人本人**可以发起转交 —— 物品在谁手上,
就只能由谁把它交出去。这不是库管代办的场景(那是借出环节的职责),
否则任何人都能把别人保管的资产「转」给第三方。
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 排序,并加回归用例锁定。
2026-09-17 09:17:57 +08:00
★ 严禁触碰库存:转交是纯持有权变更,实物不出入库,
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'),
feat(borrow): 转交发起收紧为「仅当前持有人本人」(责任链隔离) 背景 ---- 此前【转交】只要持有 borrow_transfer 权限就可见可调,与「当前持有人」无关 —— 任何库管都能把别人保管的资产转给第三方,责任链形同虚设。业务方确认改为 **只有该物品的当前持有人本人可以发起**。 改动(前后端同改,缺一不可) ---- · service.transfer_borrow 新增 caller_user_id,强校验其 == 该明细 current_holder_id;传 None 一律拒绝,不做「系统内部调用」的隐式放行。 · get_records 为每条明细附加 can_transfer(当前持有人 == 我)—— 前端 localStorage 里只有 username 没有 user_id,故与 is_mine 一样由后端判定。 · 前端明细行【转交】改判 can_transfer;主行【转交】改为「该单下存在由我持有 的未还物品」时才出现;弹窗候选也过滤为「由我持有」,不是我的不列进来 (后端会拒,列出来只会误导)。 ★ 连带调整:移除 route 上的 permission_required('borrow_transfer') 责任链规则既然是「持有人本人」,而持有人是普通员工、通常不持有库管权限, 再加一道库管权限,实际能发起的人变成「持有人 ∩ 库管」,绝大多数持有人 反而发不了 —— 功能形同虚设。这与 accept/reject 同级:员工处置自己名下资产。 真正的边界是 service 层的 caller_user_id 强校验,不是界面遮挡。 ⚠ 由此 borrow_transfer 权限码已无任何代码引用(sys_element 中的定义与 4 个角色的授权仍在,属无害冗余)。若后续需要「管理员代办」入口, 可在此基础上加豁免;若确定不需要,该权限码可择期下线。 验证(13 项断言全通过) ---- · 非持有人发起被拒;未传调用者被拒;持有人转给自己被拒 · 持有人本人发起成功,from_user_id 正确记为持有人 · can_transfer:持有人 True / 接收人 False;接收转移后新持有人变 True · 接收环节不受影响;库存零副作用、数据零残留
2026-09-17 10:20:34 +08:00
# ★ 责任链隔离:service 层强校验调用者就是该物品的当前持有人本人。
# 前端隐藏按钮只是降噪,这里才是真正的边界。
caller_user_id=get_jwt_identity(),
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 排序,并加回归用例锁定。
2026-09-17 09:17:57 +08:00
)
return jsonify({
'code': 200,
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
'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 本人。
feat(borrow): 转交粒度下沉到明细行,支持部分转交 背景(业务方推翻上一轮约束) ---- 上一轮按「一张单同时只能有一个持有人」实现了**整单转交**,并把「单内出现多个 持有人」当作 bug 去修。业务方验收后明确纠正: 物理现场经常只转交部分工具(借了 2 件、只把 1 件转给别人), 单内多持有人才是符合现实的正常状态。 故转交粒度从 borrow_no 下沉回 trans_borrow.id(明细行)。 改动 ---- · transfer_borrow:只操作传入的那**一行**明细,不再按单号整批覆盖。 转出方 = 该行当前持有人;数量 = 该行待还量。 · accept_transfer:只转移 transfer.borrow_id 指向的那一行 —— 整批改写会把别人手上的东西一并抢过来(部分转交下同单明细分属不同人)。 · 唯一性约束从「单号至多一条 PENDING」下沉为「明细行至多一条」: 同单的其他明细可以同时各自挂着待接收,互不阻塞 —— 这正是部分转交的语义。 · get_records 的 pending_transfer 改按 borrow_id 关联(原按 borrow_no), 否则同单多项待接收会互相覆盖。 · 删除已无用的 _load_slip_for_update。 ★ 数量粒度:一行只支持**整行转交**。一行只能有一个 current_holder_id, 「同一行只转一部分」需要把这行拆成两行 —— 经业务确认,现场场景中 「借 2 件转 1 件」的两件本就是两条明细行,故该限制不影响实际使用; 接口对传入的非整行数量会明确提示「应另立一条明细行」。 数据层 ---- 无需改表结构:borrow_id 本就是流水的关联列,borrow_no 退化为单据归属与 分组展示用。仅补 (borrow_id, status) 复合索引支撑新的查询路径。 存量撕裂数据(BOR-20260917-0001 的「测试 / 杜邢宸」)按业务方选择**保留不动** —— 它现在不再是 bug,而是部分转交的正常形态。 验证(合成 2 明细单,21 项断言全通过) ---- · 只转工具A:工具B 完全不受影响 · 同一张单可同时挂两条待接收,互不阻塞;同一明细重复发起被拒 · accept 工具A 后:A→测试,B 仍是杜邢宸(单内两个持有人) · 两个持有人、以及待接收人,三方各自都能在列表中看到该单 · pending_transfer 挂在正确的明细行上,is_mine 判定正确 · reject 后主表持有人不变;非整行数量被拒并提示拆行 · 全程 available_quantity 无变化,库存精确还原、零残留数据
2026-09-17 10:12:31 +08:00
★ 副作用:**仅**该转交指向的那一条明细的 current_holder 改为接收人。
同单的其他明细可能挂在别人名下(部分转交),一律不动。
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
"""
try:
feat(borrow): 转交粒度下沉到明细行,支持部分转交 背景(业务方推翻上一轮约束) ---- 上一轮按「一张单同时只能有一个持有人」实现了**整单转交**,并把「单内出现多个 持有人」当作 bug 去修。业务方验收后明确纠正: 物理现场经常只转交部分工具(借了 2 件、只把 1 件转给别人), 单内多持有人才是符合现实的正常状态。 故转交粒度从 borrow_no 下沉回 trans_borrow.id(明细行)。 改动 ---- · transfer_borrow:只操作传入的那**一行**明细,不再按单号整批覆盖。 转出方 = 该行当前持有人;数量 = 该行待还量。 · accept_transfer:只转移 transfer.borrow_id 指向的那一行 —— 整批改写会把别人手上的东西一并抢过来(部分转交下同单明细分属不同人)。 · 唯一性约束从「单号至多一条 PENDING」下沉为「明细行至多一条」: 同单的其他明细可以同时各自挂着待接收,互不阻塞 —— 这正是部分转交的语义。 · get_records 的 pending_transfer 改按 borrow_id 关联(原按 borrow_no), 否则同单多项待接收会互相覆盖。 · 删除已无用的 _load_slip_for_update。 ★ 数量粒度:一行只支持**整行转交**。一行只能有一个 current_holder_id, 「同一行只转一部分」需要把这行拆成两行 —— 经业务确认,现场场景中 「借 2 件转 1 件」的两件本就是两条明细行,故该限制不影响实际使用; 接口对传入的非整行数量会明确提示「应另立一条明细行」。 数据层 ---- 无需改表结构:borrow_id 本就是流水的关联列,borrow_no 退化为单据归属与 分组展示用。仅补 (borrow_id, status) 复合索引支撑新的查询路径。 存量撕裂数据(BOR-20260917-0001 的「测试 / 杜邢宸」)按业务方选择**保留不动** —— 它现在不再是 bug,而是部分转交的正常形态。 验证(合成 2 明细单,21 项断言全通过) ---- · 只转工具A:工具B 完全不受影响 · 同一张单可同时挂两条待接收,互不阻塞;同一明细重复发起被拒 · accept 工具A 后:A→测试,B 仍是杜邢宸(单内两个持有人) · 两个持有人、以及待接收人,三方各自都能在列表中看到该单 · pending_transfer 挂在正确的明细行上,is_mine 判定正确 · reject 后主表持有人不变;非整行数量被拒并提示拆行 · 全程 available_quantity 无变化,库存精确还原、零残留数据
2026-09-17 10:12:31 +08:00
transfer, record = TransService.accept_transfer(
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
transfer_id=transfer_id,
user_id=get_jwt_identity(),
)
return jsonify({
'code': 200,
feat(borrow): 转交粒度下沉到明细行,支持部分转交 背景(业务方推翻上一轮约束) ---- 上一轮按「一张单同时只能有一个持有人」实现了**整单转交**,并把「单内出现多个 持有人」当作 bug 去修。业务方验收后明确纠正: 物理现场经常只转交部分工具(借了 2 件、只把 1 件转给别人), 单内多持有人才是符合现实的正常状态。 故转交粒度从 borrow_no 下沉回 trans_borrow.id(明细行)。 改动 ---- · transfer_borrow:只操作传入的那**一行**明细,不再按单号整批覆盖。 转出方 = 该行当前持有人;数量 = 该行待还量。 · accept_transfer:只转移 transfer.borrow_id 指向的那一行 —— 整批改写会把别人手上的东西一并抢过来(部分转交下同单明细分属不同人)。 · 唯一性约束从「单号至多一条 PENDING」下沉为「明细行至多一条」: 同单的其他明细可以同时各自挂着待接收,互不阻塞 —— 这正是部分转交的语义。 · get_records 的 pending_transfer 改按 borrow_id 关联(原按 borrow_no), 否则同单多项待接收会互相覆盖。 · 删除已无用的 _load_slip_for_update。 ★ 数量粒度:一行只支持**整行转交**。一行只能有一个 current_holder_id, 「同一行只转一部分」需要把这行拆成两行 —— 经业务确认,现场场景中 「借 2 件转 1 件」的两件本就是两条明细行,故该限制不影响实际使用; 接口对传入的非整行数量会明确提示「应另立一条明细行」。 数据层 ---- 无需改表结构:borrow_id 本就是流水的关联列,borrow_no 退化为单据归属与 分组展示用。仅补 (borrow_id, status) 复合索引支撑新的查询路径。 存量撕裂数据(BOR-20260917-0001 的「测试 / 杜邢宸」)按业务方选择**保留不动** —— 它现在不再是 bug,而是部分转交的正常形态。 验证(合成 2 明细单,21 项断言全通过) ---- · 只转工具A:工具B 完全不受影响 · 同一张单可同时挂两条待接收,互不阻塞;同一明细重复发起被拒 · accept 工具A 后:A→测试,B 仍是杜邢宸(单内两个持有人) · 两个持有人、以及待接收人,三方各自都能在列表中看到该单 · pending_transfer 挂在正确的明细行上,is_mine 判定正确 · reject 后主表持有人不变;非整行数量被拒并提示拆行 · 全程 available_quantity 无变化,库存精确还原、零残留数据
2026-09-17 10:12:31 +08:00
'msg': f'已接收,物品【{record.sku}】的持有权已转移到您名下',
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 排序,并加回归用例锁定。
2026-09-17 09:17:57 +08:00
'data': transfer.to_dict(),
}), 200
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
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
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 排序,并加回归用例锁定。
2026-09-17 09:17:57 +08:00
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
# --- 拒绝转交 ---
@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
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 排序,并加回归用例锁定。
2026-09-17 09:17:57 +08:00
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/pending-count', methods=['GET'])
@jwt_required()
def get_pending_transfer_count():
"""
待我接收的转交数量,供前端全局待办强提醒使用。
★ 无 permission_required:接收人可能是普通员工,待办提醒必须人人可见
(与 accept/reject 同级的理由 —— 这是员工处置自己名下资产,非库管职权)。
★ 路由不与 /borrow/<int:borrow_id>/transfer 等冲突:
「transfer」无法匹配 <int:borrow_id>,「pending-count」也无法匹配
<int:transfer_id>,Werkzeug 会按转换器精确分派。
"""
try:
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 均安全返回; 库存零副作用、数据零残留。
2026-09-17 10:44:00 +08:00
identity = get_jwt_identity()
count = TransService.count_pending_transfers(identity)
# ★ 一并返回「我发起、被对方拒绝、尚未告知我」的转交:
# 被拒时物品责任仍在我手上,不告知就会误以为已经交接出去。
# 与待接收数量合并进同一次轮询,避免前端多打一个请求。
rejects = TransService.get_unseen_rejects(identity)
return jsonify({
'code': 200,
'msg': 'success',
'count': count,
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 均安全返回; 库存零副作用、数据零残留。
2026-09-17 10:44:00 +08:00
'rejects': rejects,
'data': {'count': count, 'rejects': rejects},
}), 200
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 均安全返回; 库存零副作用、数据零残留。
2026-09-17 10:44:00 +08:00
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
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 排序,并加回归用例锁定。
2026-09-17 09:17:57 +08:00
# --- 借出单的流转历史(转交链 + 逐次归还)---
@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})