2026-02-10 11:34:50 +08:00
|
|
|
|
from flask import Blueprint, jsonify, request # .material -> .base refactor checked
|
2026-02-27 14:05:52 +08:00
|
|
|
|
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
|
2026-09-10 14:16:27 +08:00
|
|
|
|
from app.utils.decorators import permission_required, prevent_double_submit, is_privileged_viewer
|
2026-02-27 14:05:52 +08:00
|
|
|
|
from app.services.auth_service import AuthService
|
2026-09-17 09:17:57 +08:00
|
|
|
|
from app.services.trans_service import TransService, user_display_name
|
2026-06-12 14:06:17 +08:00
|
|
|
|
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')
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-27 14:05:52 +08:00
|
|
|
|
# ==============================================================================
|
|
|
|
|
|
# 辅助函数:获取当前用户的完整权限列表(基于角色查询)
|
|
|
|
|
|
# ==============================================================================
|
|
|
|
|
|
def get_current_user_permissions():
|
|
|
|
|
|
"""
|
|
|
|
|
|
返回当前用户拥有的所有权限码列表(包括菜单和元素)
|
|
|
|
|
|
此函数根据角色查询数据库得到权限。
|
|
|
|
|
|
"""
|
|
|
|
|
|
claims = get_jwt()
|
|
|
|
|
|
user_role = claims.get('role')
|
2026-07-13 15:12:22 +08:00
|
|
|
|
user_company = claims.get('company_name', '')
|
2026-02-27 14:05:52 +08:00
|
|
|
|
if not user_role:
|
|
|
|
|
|
return []
|
2026-02-27 17:04:22 +08:00
|
|
|
|
# 超级管理员返回所有字段权限 (忽略大小写)
|
|
|
|
|
|
if user_role.upper() == 'SUPER_ADMIN':
|
2026-02-27 14:05:52 +08:00
|
|
|
|
return ['*']
|
2026-07-13 15:12:22 +08:00
|
|
|
|
perm_dict = AuthService.get_user_permissions(user_role, company_name=user_company)
|
2026-02-27 14:05:52 +08:00
|
|
|
|
# 合并菜单和元素权限
|
|
|
|
|
|
perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
|
|
|
|
|
|
return perms
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-12 14:06:17 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-04 16:10:00 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-27 14:05:52 +08:00
|
|
|
|
def filter_item_by_permissions(item_dict, user_permissions, prefix='op_records'):
|
|
|
|
|
|
"""
|
|
|
|
|
|
根据用户权限过滤 item 字典,无权限的字段值置为 None
|
2026-07-16 11:26:05 +08:00
|
|
|
|
|
|
|
|
|
|
★ Fail-Closed: 字段映射默认为完整列表,不再为空字典。
|
2026-02-27 14:05:52 +08:00
|
|
|
|
"""
|
2026-07-21 09:59:14 +08:00
|
|
|
|
# sys_element 补齐前不做字段级过滤
|
|
|
|
|
|
field_to_perm = {}
|
2026-07-16 11:26:05 +08:00
|
|
|
|
if '*' in user_permissions or f'{prefix}:*' in user_permissions:
|
2026-02-27 14:05:52 +08:00
|
|
|
|
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()
|
2026-02-27 14:05:52 +08:00
|
|
|
|
@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()
|
2026-02-27 14:05:52 +08:00
|
|
|
|
@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()
|
2026-02-27 14:05:52 +08:00
|
|
|
|
@permission_required('op_return:operation')
|
2026-02-06 17:11:47 +08:00
|
|
|
|
def submit_return():
|
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 {}
|
2026-09-04 16:10:00 +08:00
|
|
|
|
# ★ 归还人存"姓名",而非 JWT 数字 ID
|
|
|
|
|
|
operator_name = _current_username()
|
2026-02-06 17:11:47 +08:00
|
|
|
|
try:
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-16 17:14:17 +08:00
|
|
|
|
# --- 借库报废申请(未归还 → 提交报废申请,需审批)---
|
|
|
|
|
|
@trans_bp.route('/borrow/scrap-request', methods=['POST'])
|
2026-08-31 10:21:28 +08:00
|
|
|
|
@jwt_required()
|
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():
|
2026-08-31 10:21:28 +08:00
|
|
|
|
"""
|
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 并扣总库存,绕过审批,与系统
|
|
|
|
|
|
自陈的「报废一律需审批」冲突,构成职责分离漏洞 —— 同一个库管可自行宣告
|
|
|
|
|
|
实物损失而无人复核。现统一走:申请 → 审批 → 执行。
|
|
|
|
|
|
|
|
|
|
|
|
★ 执行方式:本来源为「免扫码」—— 东西在借用人手上,物理上不可能扫码;
|
|
|
|
|
|
且执行只改台账与总库存,不产生任何可被挪用的可用库存。
|
2026-08-31 10:21:28 +08:00
|
|
|
|
"""
|
|
|
|
|
|
data = request.get_json() or {}
|
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')
|
2026-08-31 10:21:28 +08:00
|
|
|
|
|
|
|
|
|
|
if not record_ids:
|
2026-09-16 17:14:17 +08:00
|
|
|
|
return jsonify({'code': 400, 'msg': '请选择要申请报废的借出记录'}), 400
|
|
|
|
|
|
if not approver_id:
|
|
|
|
|
|
return jsonify({'code': 400, 'msg': '请选择审批人'}), 400
|
2026-08-31 10:21:28 +08:00
|
|
|
|
|
|
|
|
|
|
try:
|
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
|
|
|
|
|
|
|
2026-08-31 10:21:28 +08:00
|
|
|
|
except ValueError as e:
|
|
|
|
|
|
return jsonify({'code': 400, 'msg': str(e)}), 400
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
traceback.print_exc()
|
2026-09-16 17:14:17 +08:00
|
|
|
|
return jsonify({'code': 500, 'msg': f'提交报废申请失败: {str(e)}'}), 500
|
2026-08-31 10:21:28 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-02-06 17:11:47 +08:00
|
|
|
|
# --- 记录列表 ---
|
|
|
|
|
|
@trans_bp.route('/records', methods=['GET'])
|
|
|
|
|
|
@jwt_required()
|
2026-02-27 14:05:52 +08:00
|
|
|
|
@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', '')
|
2026-03-20 10:26:45 +08:00
|
|
|
|
search_type = request.args.get('search_type', 'all')
|
2026-09-10 12:06:08 +08:00
|
|
|
|
start_date = request.args.get('start_date', '')
|
|
|
|
|
|
end_date = request.args.get('end_date', '')
|
2026-02-06 17:11:47 +08:00
|
|
|
|
|
2026-09-10 13:06:03 +08:00
|
|
|
|
# ★ 高级筛选:JSON 字符串 → 条件列表
|
|
|
|
|
|
from app.utils.advanced_filter import parse_advanced_filters
|
|
|
|
|
|
advanced_filters = parse_advanced_filters(request.args.get('advancedFilters', ''))
|
|
|
|
|
|
|
2026-09-17 10:04:12 +08:00
|
|
|
|
# ★ 数据权限:普通用户只看与自己有关的记录(借用人 / 当前持有人 / 待我接收的
|
|
|
|
|
|
# 转交),管理者看全部。两个口径都要传下去:
|
|
|
|
|
|
# viewer_user_id —— 精确锚点,覆盖转交接收人(此前只按姓名过滤,
|
|
|
|
|
|
# 接收人在自己的列表里看不到东西)
|
|
|
|
|
|
# borrower_name —— 姓名口径,兼容只有姓名、没有 ID 的历史行
|
2026-09-09 11:19:15 +08:00
|
|
|
|
borrower_name = None
|
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) # 供「是否待我接收」判定,与可见性无关
|
2026-09-09 11:19:15 +08:00
|
|
|
|
if not is_privileged_viewer():
|
|
|
|
|
|
if _identity:
|
|
|
|
|
|
from app.models.system import SysUser
|
|
|
|
|
|
_u = SysUser.query.get(int(_identity))
|
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
|
2026-09-09 11:19:15 +08:00
|
|
|
|
|
|
|
|
|
|
res = TransService.get_records(
|
|
|
|
|
|
page=page, limit=10, status=status, keyword=keyword,
|
2026-09-10 12:06:08 +08:00
|
|
|
|
search_type=search_type, borrower_name=borrower_name,
|
|
|
|
|
|
start_date=start_date, end_date=end_date,
|
2026-09-17 10:04:12 +08:00
|
|
|
|
advanced_filters=advanced_filters, viewer_user_id=viewer_user_id,
|
|
|
|
|
|
current_user_id=current_user_id,
|
2026-09-09 11:19:15 +08:00
|
|
|
|
)
|
2026-06-16 14:53:42 +08:00
|
|
|
|
|
|
|
|
|
|
# ★ 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
|
|
|
|
|
|
|
2026-02-27 14:05:52 +08:00
|
|
|
|
# 字段级脱敏
|
|
|
|
|
|
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']]
|
2026-02-10 11:34:50 +08:00
|
|
|
|
return jsonify({'code': 200, 'data': res})
|
2026-06-12 14:06:17 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ==============================================================================
|
|
|
|
|
|
# 借库审批流 API(与出库审批流平行)
|
|
|
|
|
|
# ==============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
# --- 提交借库申请 ---
|
|
|
|
|
|
@trans_bp.route('/borrow/request', methods=['POST'])
|
|
|
|
|
|
@jwt_required()
|
2026-07-17 13:07:12 +08:00
|
|
|
|
@permission_required('op_borrow_apply')
|
2026-06-12 14:06:17 +08:00
|
|
|
|
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,
|
2026-09-09 16:19:38 +08:00
|
|
|
|
borrower_name=current_username,
|
|
|
|
|
|
force_approval=((user_role or '').upper() == 'WAREHOUSE_MGR') # 库管代建 → 强制审批
|
2026-06-12 14:06:17 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
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()
|
2026-07-15 14:33:11 +08:00
|
|
|
|
@permission_required('op_borrow_approval')
|
2026-06-12 14:06:17 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-04 14:52:31 +08:00
|
|
|
|
@trans_bp.route('/borrow/request/<int:request_id>/close', methods=['POST'])
|
|
|
|
|
|
@jwt_required()
|
|
|
|
|
|
@permission_required('op_borrow_approval')
|
|
|
|
|
|
def close_borrow_request(request_id):
|
|
|
|
|
|
"""
|
2026-09-10 09:47:07 +08:00
|
|
|
|
手动完结已通过的借库审批单(status 1-已通过 → 4-已完结)
|
2026-09-04 14:52:31 +08:00
|
|
|
|
参照出库审批的完结逻辑,供库管/主管在未走扫码借出时强制完结
|
|
|
|
|
|
"""
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-09 10:47:29 +08:00
|
|
|
|
# --- 借库申请预检(判断所选物料是否需审批,驱动前端是否显示审批人) ---
|
|
|
|
|
|
@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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-12 14:06:17 +08:00
|
|
|
|
# --- 获取借库审批单列表 ---
|
|
|
|
|
|
@trans_bp.route('/borrow/request', methods=['GET'])
|
|
|
|
|
|
@jwt_required()
|
2026-07-15 14:33:11 +08:00
|
|
|
|
@permission_required('op_borrow_approval')
|
2026-06-12 14:06:17 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-09-09 09:31:01 +08:00
|
|
|
|
# ★ 数据权限:普通申请人只能看“自己的”借还记录;库管/主管/超管(或跨域)才可看他人
|
|
|
|
|
|
if not is_privileged_viewer():
|
|
|
|
|
|
identity = get_jwt_identity()
|
|
|
|
|
|
applicant_id = int(identity) if identity else None
|
|
|
|
|
|
|
2026-06-12 14:06:17 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-15 14:33:11 +08:00
|
|
|
|
# --- 借库选单:库存查询(独立权限)---
|
|
|
|
|
|
@trans_bp.route('/borrow/stock-list', methods=['GET'])
|
|
|
|
|
|
@jwt_required()
|
|
|
|
|
|
@permission_required('op_borrow_apply')
|
|
|
|
|
|
def get_borrow_stock_list():
|
2026-07-16 11:26:05 +08:00
|
|
|
|
"""借库选单专用库存列表 — Fail-Closed: 剥离价格字段"""
|
2026-07-15 14:33:11 +08:00
|
|
|
|
from app.api.v1.inbound.stock import _do_get_stock_list
|
2026-07-16 11:26:05 +08:00
|
|
|
|
return _do_get_stock_list(permission_prefix='op_borrow_apply')
|
2026-07-15 14:33:11 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-12 14:06:17 +08:00
|
|
|
|
# --- 执行借库扣减(审批通过后调用)---
|
|
|
|
|
|
@trans_bp.route('/borrow/dispatch', methods=['POST'])
|
|
|
|
|
|
@jwt_required()
|
2026-07-16 13:08:37 +08:00
|
|
|
|
@prevent_double_submit(lock_timeout=5)
|
2026-06-12 14:06:17 +08:00
|
|
|
|
@permission_required('op_borrow:operation')
|
|
|
|
|
|
def dispatch_borrow():
|
|
|
|
|
|
"""
|
|
|
|
|
|
执行借库扣减
|
|
|
|
|
|
请求体: {
|
|
|
|
|
|
approval_id: int, // 关联的审批单ID
|
2026-06-16 13:38:51 +08:00
|
|
|
|
items: [ // 扫码选中的库存物品
|
|
|
|
|
|
{
|
2026-06-16 13:50:49 +08:00
|
|
|
|
id: int, // 库存主键(按 source_table 路由到 StockBuy/StockSemi/StockProduct)
|
|
|
|
|
|
source_table: str, // 'stock_buy' | 'stock_semi' | 'stock_product'
|
|
|
|
|
|
sku: str, // 可选;不参与审批上限校验
|
2026-06-16 13:38:51 +08:00
|
|
|
|
out_quantity: float
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
2026-06-16 13:50:49 +08:00
|
|
|
|
// ★ 审批上限校验在 service 层完成:以 (name, spec_model) 为物料维度聚合
|
|
|
|
|
|
// 锁定 stock 行后从 material_base 表取真实 (name, spec_model) 与审批单比对
|
2026-09-17 09:17:57 +08:00
|
|
|
|
borrower_id: int, // ★ 实际借用人ID(一期转交改造后必填)
|
|
|
|
|
|
borrower_name: str, // 仅作展示/兼容,落库姓名以 borrower_id 反查为准
|
2026-06-12 14:06:17 +08:00
|
|
|
|
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', []),
|
2026-09-04 16:10:00 +08:00
|
|
|
|
operator_name=_current_username(),
|
2026-06-12 14:06:17 +08:00
|
|
|
|
borrower_name=data.get('borrower_name'),
|
2026-09-17 09:17:57 +08:00
|
|
|
|
# ★ 强制借用人ID:service 层缺失即拒绝(不静默回退到申请单姓名)
|
|
|
|
|
|
borrower_id=data.get('borrower_id'),
|
2026-06-12 14:06:17 +08:00
|
|
|
|
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
|
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)、
|
2026-09-17 10:20:34 +08:00
|
|
|
|
归还(op_return:operation)、转交(发起人是**持有人本人**,可能不具备任何
|
|
|
|
|
|
库管权限)。绑定其中任一权限码,
|
2026-09-17 09:17:57 +08:00
|
|
|
|
另外两个页面都会 403。此处沿用 /auth/users/approvers 的既有处理,
|
|
|
|
|
|
且**只返回 id 与姓名**,不含邮箱/角色/部门等字段,最小披露。
|
|
|
|
|
|
|
|
|
|
|
|
★ 公司隔离与借用台账同口径(get_current_company_filter):
|
|
|
|
|
|
否则 A 公司库管能在选择器里看到 B 公司人员,虽转交时会被 company
|
|
|
|
|
|
校验二次拦截,但名单本身已属越权披露。
|
2026-09-17 12:01:11 +08:00
|
|
|
|
|
|
|
|
|
|
★ 实现已抽到 common.active_user_options(),与 /common/active-users 共用同一份
|
|
|
|
|
|
逻辑 —— 出库补发等场景走那条中性路径,避免跨模块引用借库接口。
|
2026-09-17 09:17:57 +08:00
|
|
|
|
"""
|
2026-09-17 12:01:11 +08:00
|
|
|
|
from app.api.v1.common.users import active_user_options
|
2026-09-17 09:17:57 +08:00
|
|
|
|
|
2026-09-17 12:01:11 +08:00
|
|
|
|
return jsonify({'code': 200, 'msg': 'success', 'data': active_user_options()})
|
2026-09-17 09:17:57 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-09-17 10:04:12 +08:00
|
|
|
|
# --- 发起借库转交(双向握手第一步)---
|
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):
|
2026-09-17 10:20:34 +08:00
|
|
|
|
# ★ 为什么不加 permission_required('borrow_transfer'):
|
|
|
|
|
|
# 转交的责任链隔离规则是「**只有当前持有人本人**可以发起」,而持有人是
|
|
|
|
|
|
# 普通员工,通常并不持有库管权限。若再挂一道库管权限,实际能发起的人
|
|
|
|
|
|
# 变成「持有人 ∩ 库管」,绝大多数持有人反而发不了 —— 功能形同虚设。
|
|
|
|
|
|
# 这与 accept / reject 同级:员工处置自己名下资产,不是库管职权。
|
|
|
|
|
|
# 真正的边界在 service 层的 caller_user_id 强校验。
|
2026-09-17 09:17:57 +08:00
|
|
|
|
"""
|
2026-09-17 10:04:12 +08:00
|
|
|
|
发起借库转交:把一张借出单的持有权**整单**转给另一人,等待对方确认。
|
2026-09-17 09:17:57 +08:00
|
|
|
|
|
|
|
|
|
|
请求体:
|
|
|
|
|
|
{
|
2026-09-17 10:04:12 +08:00
|
|
|
|
"transfer_qty": 10, # 可选,传入时须等于整单待还量(一致性校验)
|
2026-09-17 09:17:57 +08:00
|
|
|
|
"to_user_id": 12, # 必需,接收人ID(唯一身份锚点)
|
|
|
|
|
|
"to_user_name": "张三", # 可选,仅作兼容;落库姓名以 to_user_id 反查为准
|
|
|
|
|
|
"remark": "..." # 可选
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-17 10:04:12 +08:00
|
|
|
|
★ 双向握手:本接口**只落一条 PENDING 流水,不改主表 current_holder**。
|
|
|
|
|
|
东西还没到接收人手上,责任仍归原持有人 —— 接收人在自己的列表里确认
|
|
|
|
|
|
(POST /borrow/transfer/<id>/accept)后才真正转移。
|
|
|
|
|
|
|
2026-09-17 10:12:31 +08:00
|
|
|
|
★ 转交粒度 = **明细行**(传入的 borrow_id 就是目标)。同一张单的其他明细
|
|
|
|
|
|
不受影响,故「借 2 件只转 1 件」得到天然支持;同单不同明细归属不同持有人
|
|
|
|
|
|
是正常业务形态。
|
2026-09-17 10:04:12 +08:00
|
|
|
|
|
2026-09-17 10:20:34 +08:00
|
|
|
|
★ 责任链隔离:**只有该物品的当前持有人本人**可以发起转交 —— 物品在谁手上,
|
|
|
|
|
|
就只能由谁把它交出去。这不是库管代办的场景(那是借出环节的职责),
|
|
|
|
|
|
否则任何人都能把别人保管的资产「转」给第三方。
|
|
|
|
|
|
|
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'),
|
2026-09-17 10:20:34 +08:00
|
|
|
|
# ★ 责任链隔离:service 层强校验调用者就是该物品的当前持有人本人。
|
|
|
|
|
|
# 前端隐藏按钮只是降噪,这里才是真正的边界。
|
|
|
|
|
|
caller_user_id=get_jwt_identity(),
|
2026-09-17 09:17:57 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return jsonify({
|
|
|
|
|
|
'code': 200,
|
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 本人。
|
|
|
|
|
|
|
2026-09-17 10:12:31 +08:00
|
|
|
|
★ 副作用:**仅**该转交指向的那一条明细的 current_holder 改为接收人。
|
|
|
|
|
|
同单的其他明细可能挂在别人名下(部分转交),一律不动。
|
2026-09-17 10:04:12 +08:00
|
|
|
|
"""
|
|
|
|
|
|
try:
|
2026-09-17 10:12:31 +08:00
|
|
|
|
transfer, record = TransService.accept_transfer(
|
2026-09-17 10:04:12 +08:00
|
|
|
|
transfer_id=transfer_id,
|
|
|
|
|
|
user_id=get_jwt_identity(),
|
|
|
|
|
|
)
|
|
|
|
|
|
return jsonify({
|
|
|
|
|
|
'code': 200,
|
2026-09-17 10:12:31 +08:00
|
|
|
|
'msg': f'已接收,物品【{record.sku}】的持有权已转移到您名下',
|
2026-09-17 09:17:57 +08:00
|
|
|
|
'data': transfer.to_dict(),
|
|
|
|
|
|
}), 200
|
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
|
2026-09-17 09:17:57 +08:00
|
|
|
|
|
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
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-17 10:30:07 +08:00
|
|
|
|
# --- 待我接收的转交数量(全局待办提醒)---
|
|
|
|
|
|
@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:
|
2026-09-17 10:44:00 +08:00
|
|
|
|
identity = get_jwt_identity()
|
|
|
|
|
|
count = TransService.count_pending_transfers(identity)
|
|
|
|
|
|
# ★ 一并返回「我发起、被对方拒绝、尚未告知我」的转交:
|
|
|
|
|
|
# 被拒时物品责任仍在我手上,不告知就会误以为已经交接出去。
|
|
|
|
|
|
# 与待接收数量合并进同一次轮询,避免前端多打一个请求。
|
|
|
|
|
|
rejects = TransService.get_unseen_rejects(identity)
|
2026-09-17 10:30:07 +08:00
|
|
|
|
return jsonify({
|
|
|
|
|
|
'code': 200,
|
|
|
|
|
|
'msg': 'success',
|
|
|
|
|
|
'count': count,
|
2026-09-17 10:44:00 +08:00
|
|
|
|
'rejects': rejects,
|
|
|
|
|
|
'data': {'count': count, 'rejects': rejects},
|
2026-09-17 10:30:07 +08:00
|
|
|
|
}), 200
|
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
|
2026-09-17 10:30:07 +08:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
traceback.print_exc()
|
|
|
|
|
|
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
|
|
|
|
|
|
|
|
|
|
|
|
|
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})
|