Files
KCGL/inventory-backend/app/api/v1/transactions.py
yueli 27e5589a5e 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

826 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from flask import Blueprint, jsonify, request # .material -> .base refactor checked
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
from app.utils.decorators import permission_required, prevent_double_submit, is_privileged_viewer
from app.services.auth_service import AuthService
from app.services.trans_service import TransService, user_display_name
from app.services.borrow_service import BorrowApprovalService
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
# --- 借库接口 ---
@trans_bp.route('/borrow', methods=['POST'])
@jwt_required()
@permission_required('op_borrow:operation')
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')
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')
def submit_return():
"""
还库提交。
请求体:
{
"items": [...], # 待还明细,含 trans_borrow.id 与 return_qty
"signature_path": "...", # 库管签字
"returner_id": 12 # ★ 实际归还人ID(一期转交改造新增)
}
★ operator_name(库管)与 returner_id(归还人)是**两个人**:
前者是窗口经手人,取自 JWT;后者是实际把物品交回来的人,由前端选择。
记录有 current_holder_id 时,service 层强校验 returner_id 必须等于它,
不匹配即整单回滚 —— 这是转交上线后责任链的关键一环。
"""
data = request.get_json() or {}
# ★ 归还人存"姓名",而非 JWT 数字 ID
operator_name = _current_username()
try:
TransService.process_return(
data,
operator_name=operator_name,
returner_id=data.get('returner_id'),
)
return jsonify({'code': 200, 'msg': '还库成功'})
except Exception as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
# --- 借库报废申请(未归还 → 提交报废申请,需审批)---
@trans_bp.route('/borrow/scrap-request', methods=['POST'])
@jwt_required()
@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():
"""
提交「借出未归还」的**报废申请**(需审批人审批,通过后由库管执行报废)。
请求体:
{
"record_ids": [1, 2, 3], # 必填,trans_borrow.id 列表(按整条待还量报废)
"reason": "物品丢失", # 可选,写入申请单备注
"approver_id": 7 # 必填,指定审批人
}
★ 为什么改走审批:
原先 POST /borrow/scrap 直接写 trans_scrap 并扣总库存,绕过审批,与系统
自陈的「报废一律需审批」冲突,构成职责分离漏洞 —— 同一个库管可自行宣告
实物损失而无人复核。现统一走:申请 → 审批 → 执行。
★ 执行方式:本来源为「免扫码」—— 东西在借用人手上,物理上不可能扫码;
且执行只改台账与总库存,不产生任何可被挪用的可用库存。
"""
data = request.get_json() or {}
record_ids = data.get('record_ids') or []
reason = (data.get('reason') or '').strip()
approver_id = data.get('approver_id')
if not record_ids:
return jsonify({'code': 400, 'msg': '请选择要申请报废的借出记录'}), 400
if not approver_id:
return jsonify({'code': 400, 'msg': '请选择审批人'}), 400
try:
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()
return jsonify({'code': 500, 'msg': f'提交报废申请失败: {str(e)}'}), 500
# --- 记录列表 ---
@trans_bp.route('/records', methods=['GET'])
@jwt_required()
@permission_required('op_records')
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', '')
# ★ 高级筛选:JSON 字符串 → 条件列表
from app.utils.advanced_filter import parse_advanced_filters
advanced_filters = parse_advanced_filters(request.args.get('advancedFilters', ''))
# ★ 数据权限:普通用户只看与自己有关的记录(借用人 / 当前持有人 / 待我接收的
# 转交),管理者看全部。两个口径都要传下去:
# viewer_user_id —— 精确锚点,覆盖转交接收人(此前只按姓名过滤,
# 接收人在自己的列表里看不到东西)
# borrower_name —— 姓名口径,兼容只有姓名、没有 ID 的历史行
borrower_name = None
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))
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,
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()
@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
@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) 与审批单比对
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'),
# ★ 强制借用人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
# ==============================================================================
# 借库转交(Borrow Transfer)一期
# ==============================================================================
# --- 借库链路人员选择器(借用人 / 转交接收人 / 实际归还人)---
@trans_bp.route('/borrow/users', methods=['GET'])
@jwt_required()
def get_borrow_user_options():
"""
借库责任链上的人员名单:借用人、转交接收人、实际归还人共用一个数据源。
★ 为什么只做 @jwt_required() 而不加 permission_required:
同一份名单被三个页面共用 —— 借出(op_borrow:operation)、
归还(op_return:operation)、转交(发起人是**持有人本人**,可能不具备任何
库管权限)。绑定其中任一权限码,
另外两个页面都会 403。此处沿用 /auth/users/approvers 的既有处理,
且**只返回 id 与姓名**,不含邮箱/角色/部门等字段,最小披露。
★ 公司隔离与借用台账同口径(get_current_company_filter):
否则 A 公司库管能在选择器里看到 B 公司人员,虽转交时会被 company
校验二次拦截,但名单本身已属越权披露。
★ 实现已抽到 common.active_user_options(),与 /common/active-users 共用同一份
逻辑 —— 出库补发等场景走那条中性路径,避免跨模块引用借库接口。
"""
from app.api.v1.common.users import active_user_options
return jsonify({'code': 200, 'msg': 'success', 'data': active_user_options()})
# --- 发起借库转交(双向握手第一步)---
@trans_bp.route('/borrow/<int:borrow_id>/transfer', methods=['POST'])
@jwt_required()
@prevent_double_submit(lock_timeout=5)
def transfer_borrow(borrow_id):
# ★ 为什么不加 permission_required('borrow_transfer'):
# 转交的责任链隔离规则是「**只有当前持有人本人**可以发起」,而持有人是
# 普通员工,通常并不持有库管权限。若再挂一道库管权限,实际能发起的人
# 变成「持有人 ∩ 库管」,绝大多数持有人反而发不了 —— 功能形同虚设。
# 这与 accept / reject 同级:员工处置自己名下资产,不是库管职权。
# 真正的边界在 service 层的 caller_user_id 强校验。
"""
发起借库转交:把一张借出单的持有权**整单**转给另一人,等待对方确认。
请求体:
{
"transfer_qty": 10, # 可选,传入时须等于整单待还量(一致性校验)
"to_user_id": 12, # 必需,接收人ID(唯一身份锚点)
"to_user_name": "张三", # 可选,仅作兼容;落库姓名以 to_user_id 反查为准
"remark": "..." # 可选
}
★ 双向握手:本接口**只落一条 PENDING 流水,不改主表 current_holder**。
东西还没到接收人手上,责任仍归原持有人 —— 接收人在自己的列表里确认
(POST /borrow/transfer/<id>/accept)后才真正转移。
★ 转交粒度 = **明细行**(传入的 borrow_id 就是目标)。同一张单的其他明细
不受影响,故「借 2 件只转 1 件」得到天然支持;同单不同明细归属不同持有人
是正常业务形态。
★ 责任链隔离:**只有该物品的当前持有人本人**可以发起转交 —— 物品在谁手上,
就只能由谁把它交出去。这不是库管代办的场景(那是借出环节的职责),
否则任何人都能把别人保管的资产「转」给第三方。
★ 严禁触碰库存:转交是纯持有权变更,实物不出入库,
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'),
# ★ 责任链隔离:service 层强校验调用者就是该物品的当前持有人本人。
# 前端隐藏按钮只是降噪,这里才是真正的边界。
caller_user_id=get_jwt_identity(),
)
return jsonify({
'code': 200,
'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 本人。
★ 副作用:**仅**该转交指向的那一条明细的 current_holder 改为接收人。
同单的其他明细可能挂在别人名下(部分转交),一律不动。
"""
try:
transfer, record = TransService.accept_transfer(
transfer_id=transfer_id,
user_id=get_jwt_identity(),
)
return jsonify({
'code': 200,
'msg': f'已接收,物品【{record.sku}】的持有权已转移到您名下',
'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>/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
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:
identity = get_jwt_identity()
count = TransService.count_pending_transfers(identity)
# ★ 一并返回「我发起、被对方拒绝、尚未告知我」的转交:
# 被拒时物品责任仍在我手上,不告知就会误以为已经交接出去。
# 与待接收数量合并进同一次轮询,避免前端多打一个请求。
rejects = TransService.get_unseen_rejects(identity)
return jsonify({
'code': 200,
'msg': 'success',
'count': count,
'rejects': rejects,
'data': {'count': count, 'rejects': rejects},
}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
# --- 确认已知悉「转交被拒」(清除发起方的待告知提醒)---
@trans_bp.route('/borrow/transfer/reject-ack', methods=['POST'])
@jwt_required()
def ack_transfer_rejects():
"""
发起方在前端看到「您的转交被拒绝」提醒并确认后调用,写 reject_seen_at。
★ 为什么需要这个接口:提醒必须能标记「已告知」,否则发起方每次登录都会
收到同一条 —— 从「提醒」退化成「骚扰」。
★ 无 permission_required:同 accept/reject,是员工处置自己名下资产。
请求体:{ ids: [12, 13] },留空表示该用户全部待告知的拒绝。
"""
try:
data = request.get_json() or {}
marked = TransService.ack_rejects(get_jwt_identity(), data.get('ids'))
return jsonify({'code': 200, 'msg': 'success', 'data': {'marked': marked}}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
# --- 借出单的流转历史(转交链 + 逐次归还)---
@trans_bp.route('/borrow/<int:borrow_id>/history', methods=['GET'])
@jwt_required()
@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})