Compare commits
6 Commits
9e83c31f39
...
dcef91c3b1
| Author | SHA1 | Date | |
|---|---|---|---|
| dcef91c3b1 | |||
| 5c0c1632c3 | |||
| 6f5652b90e | |||
| 7ef22a3830 | |||
| 941bd20fbd | |||
| 7ee6b0e02f |
@ -3,6 +3,7 @@ from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
|
|||||||
from app.utils.decorators import permission_required, audit_log
|
from app.utils.decorators import permission_required, audit_log
|
||||||
from app.services.auth_service import AuthService
|
from app.services.auth_service import AuthService
|
||||||
from app.services.trans_service import TransService
|
from app.services.trans_service import TransService
|
||||||
|
from app.services.borrow_service import BorrowApprovalService
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
trans_bp = Blueprint('transactions', __name__, url_prefix='/transactions')
|
trans_bp = Blueprint('transactions', __name__, url_prefix='/transactions')
|
||||||
@ -29,6 +30,16 @@ def get_current_user_permissions():
|
|||||||
return perms
|
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 filter_item_by_permissions(item_dict, user_permissions, prefix='op_records'):
|
def filter_item_by_permissions(item_dict, user_permissions, prefix='op_records'):
|
||||||
"""
|
"""
|
||||||
根据用户权限过滤 item 字典,无权限的字段值置为 None
|
根据用户权限过滤 item 字典,无权限的字段值置为 None
|
||||||
@ -125,3 +136,178 @@ def get_records():
|
|||||||
if res.get('items'):
|
if res.get('items'):
|
||||||
res['items'] = [filter_item_by_permissions(item, user_permissions, 'op_records') for item in res['items']]
|
res['items'] = [filter_item_by_permissions(item, user_permissions, 'op_records') for item in res['items']]
|
||||||
return jsonify({'code': 200, 'data': res})
|
return jsonify({'code': 200, 'data': res})
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# 借库审批流 API(与出库审批流平行)
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# --- 提交借库申请 ---
|
||||||
|
@trans_bp.route('/borrow/request', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
|
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', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
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)
|
||||||
|
|
||||||
|
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/dispatch', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
@permission_required('op_borrow:operation')
|
||||||
|
def dispatch_borrow():
|
||||||
|
"""
|
||||||
|
执行借库扣减
|
||||||
|
请求体: {
|
||||||
|
approval_id: int, // 关联的审批单ID
|
||||||
|
items: [...], // 扫码选中的库存物品(含 id, source_table, out_quantity)
|
||||||
|
borrower_name: str,
|
||||||
|
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=get_jwt_identity(),
|
||||||
|
borrower_name=data.get('borrower_name'),
|
||||||
|
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
|
||||||
|
|||||||
96
inventory-backend/app/models/borrow.py
Normal file
96
inventory-backend/app/models/borrow.py
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
from app.extensions import db, beijing_time
|
||||||
|
from app.models.system import SysUser
|
||||||
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
class BorrowApproval(db.Model):
|
||||||
|
"""
|
||||||
|
借库审批单模型
|
||||||
|
用于管理借库申请的多级审批流程
|
||||||
|
"""
|
||||||
|
__tablename__ = 'borrow_approval'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
# 审批单号
|
||||||
|
request_no = db.Column(db.String(100), unique=True, nullable=False, index=True)
|
||||||
|
# 申请人ID
|
||||||
|
applicant_id = db.Column(db.Integer, nullable=False, index=True)
|
||||||
|
# 申请说明
|
||||||
|
remark = db.Column(db.Text)
|
||||||
|
# 状态: 0-待审批, 1-已通过, 2-已驳回, 3-已完成(已借出)
|
||||||
|
status = db.Column(db.Integer, default=0, nullable=False)
|
||||||
|
# 允许审批的人员列表 (JSON格式)
|
||||||
|
allowed_approvers = db.Column(db.Text)
|
||||||
|
# 实际审批人ID
|
||||||
|
actual_approver_id = db.Column(db.Integer, index=True)
|
||||||
|
# 审批时间
|
||||||
|
approved_at = db.Column(db.DateTime)
|
||||||
|
# 驳回原因
|
||||||
|
reject_reason = db.Column(db.Text)
|
||||||
|
# 借库人姓名(申请时填写,审批通过后流转至 TransBorrow)
|
||||||
|
borrower_name = db.Column(db.String(100))
|
||||||
|
|
||||||
|
# 明细快照 (存储借库物品的名称、规格、库位、数量等信息)
|
||||||
|
items_json = db.Column(db.Text, nullable=False)
|
||||||
|
|
||||||
|
# 创建时间和更新时间
|
||||||
|
created_at = db.Column(db.DateTime, default=beijing_time, nullable=False)
|
||||||
|
updated_at = db.Column(db.DateTime, default=beijing_time, onupdate=beijing_time, nullable=False)
|
||||||
|
|
||||||
|
def _safe_parse_json(self, value):
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, (list, dict)):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
val = value.strip()
|
||||||
|
if not val:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
parsed = json.loads(val)
|
||||||
|
return parsed if isinstance(parsed, list) else []
|
||||||
|
except (json.JSONDecodeError, TypeError, ValueError):
|
||||||
|
return []
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_items(self):
|
||||||
|
return self._safe_parse_json(self.items_json)
|
||||||
|
|
||||||
|
def set_items(self, items):
|
||||||
|
self.items_json = json.dumps(items, ensure_ascii=False) if items else '[]'
|
||||||
|
|
||||||
|
def get_allowed_approvers(self):
|
||||||
|
return self._safe_parse_json(self.allowed_approvers)
|
||||||
|
|
||||||
|
def set_allowed_approvers(self, approvers):
|
||||||
|
self.allowed_approvers = json.dumps(approvers, ensure_ascii=False) if approvers else '[]'
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'request_no': self.request_no,
|
||||||
|
'applicant_id': self.applicant_id,
|
||||||
|
'applicant_name': self._get_user_name(self.applicant_id),
|
||||||
|
'remark': self.remark,
|
||||||
|
'status': self.status,
|
||||||
|
'status_text': ['待审批', '已通过', '已驳回', '已完成'][self.status] if self.status in [0, 1, 2, 3] else '未知',
|
||||||
|
'allowed_approvers': self.get_allowed_approvers(),
|
||||||
|
'actual_approver_id': self.actual_approver_id,
|
||||||
|
'approver_name': self._get_user_name(self.actual_approver_id) if self.actual_approver_id else None,
|
||||||
|
'approved_at': self.approved_at.strftime('%Y-%m-%d %H:%M:%S') if self.approved_at else None,
|
||||||
|
'reject_reason': self.reject_reason,
|
||||||
|
'borrower_name': self.borrower_name,
|
||||||
|
'items': self.get_items(),
|
||||||
|
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None,
|
||||||
|
'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S') if self.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_user_name(self, user_id):
|
||||||
|
if not user_id:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
user = SysUser.query.get(user_id)
|
||||||
|
return user.username if user else f"未知用户({user_id})"
|
||||||
|
except Exception:
|
||||||
|
return f"用户({user_id})"
|
||||||
401
inventory-backend/app/services/borrow_service.py
Normal file
401
inventory-backend/app/services/borrow_service.py
Normal file
@ -0,0 +1,401 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
import pytz
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.borrow import BorrowApproval
|
||||||
|
from app.models.system import SysUser
|
||||||
|
|
||||||
|
|
||||||
|
class BorrowApprovalService:
|
||||||
|
"""借库审批服务"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_request_no():
|
||||||
|
"""
|
||||||
|
生成审批单号: APR-BOR-yyyyMMdd-HHmm-当日流水(4位)
|
||||||
|
"""
|
||||||
|
beijing_tz = pytz.timezone('Asia/Shanghai')
|
||||||
|
now = datetime.now(beijing_tz)
|
||||||
|
|
||||||
|
date_str = now.strftime('%Y%m%d')
|
||||||
|
time_str = now.strftime('%H%M')
|
||||||
|
|
||||||
|
prefix = f"APR-BOR-{date_str}-"
|
||||||
|
|
||||||
|
latest = db.session.query(BorrowApproval.request_no).filter(
|
||||||
|
BorrowApproval.request_no.like(f"{prefix}%")
|
||||||
|
).order_by(BorrowApproval.id.desc()).first()
|
||||||
|
|
||||||
|
if latest:
|
||||||
|
last_seq = int(latest[0].split('-')[-1])
|
||||||
|
sequence = last_seq + 1
|
||||||
|
else:
|
||||||
|
sequence = 1
|
||||||
|
|
||||||
|
return f"APR-BOR-{date_str}-{time_str}-{sequence:04d}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def submit_approval(applicant_id, items, allowed_approvers, remark=None, approver_id=None,
|
||||||
|
borrower_name=None):
|
||||||
|
"""
|
||||||
|
提交借库申请(仅存储意向,不扣库存)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
applicant_id: 申请人ID
|
||||||
|
items: 借库物品明细列表,每个物品应包含:
|
||||||
|
- name: 物料名称 (必填)
|
||||||
|
- spec_model: 规格型号 (必填)
|
||||||
|
- quantity: 计划借库数量 (必填)
|
||||||
|
- warehouse_location: 库位 (可选)
|
||||||
|
- remark: 物品备注 (可选)
|
||||||
|
allowed_approvers: 允许审批的人员/角色列表
|
||||||
|
approver_id: 指定审批人ID(可选)
|
||||||
|
remark: 申请说明
|
||||||
|
borrower_name: 借库人姓名(必填)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BorrowApproval 实例
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: 当 items 为空或缺少必填字段时抛出
|
||||||
|
"""
|
||||||
|
if not items:
|
||||||
|
raise ValueError("借库物品列表不能为空")
|
||||||
|
|
||||||
|
required_fields = ['name', 'spec_model', 'quantity']
|
||||||
|
for idx, item in enumerate(items):
|
||||||
|
missing_fields = [f for f in required_fields if f not in item or str(item.get(f) or '').strip() == '']
|
||||||
|
if missing_fields:
|
||||||
|
raise ValueError(
|
||||||
|
f"第 {idx + 1} 条物品缺少必填字段: {', '.join(missing_fields)}。"
|
||||||
|
f"必须包含: name, spec_model, quantity"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
qty = float(item.get('quantity', 0))
|
||||||
|
if qty <= 0:
|
||||||
|
raise ValueError(f"第 {idx + 1} 条物品的借库数量必须大于0")
|
||||||
|
except (TypeError, ValueError) as e:
|
||||||
|
raise ValueError(f"第 {idx + 1} 条物品的 quantity 格式无效: {str(e)}")
|
||||||
|
|
||||||
|
if not allowed_approvers:
|
||||||
|
raise ValueError("必须指定至少一位审批人")
|
||||||
|
|
||||||
|
if approver_id:
|
||||||
|
allowed_approvers = [{"type": "user", "value": int(approver_id)}]
|
||||||
|
|
||||||
|
request_no = BorrowApprovalService.generate_request_no()
|
||||||
|
|
||||||
|
approval = BorrowApproval(
|
||||||
|
request_no=request_no,
|
||||||
|
applicant_id=applicant_id,
|
||||||
|
remark=remark,
|
||||||
|
borrower_name=borrower_name,
|
||||||
|
status=0, # 待审批
|
||||||
|
)
|
||||||
|
|
||||||
|
approval.set_items(items)
|
||||||
|
approval.set_allowed_approvers(allowed_approvers)
|
||||||
|
|
||||||
|
db.session.add(approval)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# ★ 创建成功后,发送邮件通知审批人(静默处理,不阻断主流程)
|
||||||
|
BorrowApprovalService._notify_new_request(approval, applicant_id, approver_id=approver_id)
|
||||||
|
|
||||||
|
return approval
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_emails_by_identifiers(applicant_id=None, role_codes=None):
|
||||||
|
"""
|
||||||
|
根据用户ID或角色列表查询邮箱地址
|
||||||
|
|
||||||
|
Args:
|
||||||
|
applicant_id: 用户ID (按 SysUser.id 查找)
|
||||||
|
role_codes: 角色代码列表,如 ['SUPERVISOR', 'SUPER_ADMIN']
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
去重后的邮箱地址列表
|
||||||
|
"""
|
||||||
|
emails = []
|
||||||
|
|
||||||
|
if applicant_id:
|
||||||
|
user = SysUser.query.get(int(applicant_id))
|
||||||
|
if user and user.email:
|
||||||
|
emails.append(user.email)
|
||||||
|
|
||||||
|
if role_codes:
|
||||||
|
for code in role_codes:
|
||||||
|
users = SysUser.query.filter_by(role=code).all()
|
||||||
|
for u in users:
|
||||||
|
if u.email:
|
||||||
|
emails.append(u.email)
|
||||||
|
|
||||||
|
return list(set(emails))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _notify_new_request(approval, applicant_id, approver_id=None):
|
||||||
|
"""发送新借库申请通知邮件给审批人和申请人(静默处理,不阻断主流程)"""
|
||||||
|
try:
|
||||||
|
from flask import current_app
|
||||||
|
from app.utils.email_service import send_borrow_new_request_notify
|
||||||
|
from app.models.system import SysUser
|
||||||
|
|
||||||
|
applicant_name = ''
|
||||||
|
applicant_emails = []
|
||||||
|
|
||||||
|
# 1. 收集申请人信息
|
||||||
|
if applicant_id:
|
||||||
|
user = SysUser.query.get(int(applicant_id))
|
||||||
|
if user and user.email:
|
||||||
|
applicant_emails.append(user.email)
|
||||||
|
applicant_name = str(user.username).split('/')[0] if '/' in (user.username or '') else (user.username or str(applicant_id))
|
||||||
|
|
||||||
|
# 2. 收集审批人信息
|
||||||
|
approver_emails = []
|
||||||
|
if approver_id:
|
||||||
|
user = SysUser.query.get(int(approver_id))
|
||||||
|
if user and user.email:
|
||||||
|
approver_emails.append(user.email)
|
||||||
|
else:
|
||||||
|
# 兜底:按角色查询
|
||||||
|
approvers = approval.get_allowed_approvers()
|
||||||
|
role_codes = []
|
||||||
|
for a in approvers:
|
||||||
|
if a.get('type') == 'role':
|
||||||
|
role_codes.append(a.get('value', ''))
|
||||||
|
approver_emails = BorrowApprovalService._get_emails_by_identifiers(role_codes=role_codes)
|
||||||
|
|
||||||
|
# 去重
|
||||||
|
all_emails = list(set(applicant_emails + approver_emails))
|
||||||
|
if not all_emails:
|
||||||
|
current_app.logger.info(f"[Email] 借库审批单 {approval.request_no} 无收件人邮箱,跳过通知")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 3. 获取物料明细
|
||||||
|
items = approval.get_items()
|
||||||
|
|
||||||
|
# 4. 分别发送邮件
|
||||||
|
if applicant_emails:
|
||||||
|
try:
|
||||||
|
send_borrow_new_request_notify(
|
||||||
|
to_emails=applicant_emails,
|
||||||
|
request_no=approval.request_no,
|
||||||
|
applicant_name=applicant_name,
|
||||||
|
remark=f"您的借库申请已提交,等待审批。{approval.remark or ''}",
|
||||||
|
items=items,
|
||||||
|
is_applicant_notify=True
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f"[Email] 通知申请人失败: {e}")
|
||||||
|
|
||||||
|
if approver_emails:
|
||||||
|
try:
|
||||||
|
send_borrow_new_request_notify(
|
||||||
|
to_emails=approver_emails,
|
||||||
|
request_no=approval.request_no,
|
||||||
|
applicant_name=applicant_name,
|
||||||
|
remark=approval.remark or '',
|
||||||
|
items=items,
|
||||||
|
is_applicant_notify=False
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f"[Email] 通知审批人失败: {e}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
try:
|
||||||
|
from flask import current_app
|
||||||
|
current_app.logger.error(f"[Email] 发送新借库申请通知邮件失败: {e}")
|
||||||
|
except RuntimeError:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _notify_approval_result(approval, approver_id, action):
|
||||||
|
"""发送借库审批结果通知邮件(静默处理,不阻断主流程)"""
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.utils.email_service import send_borrow_approval_result_notify, send_borrow_dispatch_notify
|
||||||
|
from app.models.system import SysUser as SU
|
||||||
|
|
||||||
|
# 1. 提取申请人信息
|
||||||
|
applicant_name = ''
|
||||||
|
applicant_emails = []
|
||||||
|
if approval.applicant_id:
|
||||||
|
user = SU.query.get(approval.applicant_id)
|
||||||
|
if user:
|
||||||
|
applicant_name = str(user.username).split('/')[0] if '/' in (user.username or '') else (user.username or '')
|
||||||
|
if user.email:
|
||||||
|
applicant_emails.append(user.email)
|
||||||
|
|
||||||
|
# 2. 提取物料明细
|
||||||
|
items = approval.get_items() if approval else []
|
||||||
|
|
||||||
|
# 3. 分支逻辑
|
||||||
|
if action == 'approve':
|
||||||
|
# 3.1 通知申请人(审批已通过,明确告知结果)
|
||||||
|
if applicant_emails:
|
||||||
|
try:
|
||||||
|
send_borrow_approval_result_notify(
|
||||||
|
to_emails=applicant_emails,
|
||||||
|
request_no=approval.request_no,
|
||||||
|
is_passed=True,
|
||||||
|
reject_reason='',
|
||||||
|
applicant_name=applicant_name
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Email] 通知申请人(通过)失败: {e}")
|
||||||
|
else:
|
||||||
|
logger.warning("[Email] 申请人无邮箱,无法发送审批通过通知")
|
||||||
|
|
||||||
|
# 3.2 通知库管(请备货)
|
||||||
|
warehouse_role_codes = ['WAREHOUSE_MGR', 'OUTBOUND']
|
||||||
|
warehouse_emails = BorrowApprovalService._get_emails_by_identifiers(role_codes=warehouse_role_codes)
|
||||||
|
|
||||||
|
if warehouse_emails:
|
||||||
|
try:
|
||||||
|
send_borrow_dispatch_notify(
|
||||||
|
to_emails=warehouse_emails,
|
||||||
|
request_no=approval.request_no,
|
||||||
|
applicant_name=applicant_name,
|
||||||
|
items=items
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Email] 通知库管失败: {e}")
|
||||||
|
else:
|
||||||
|
logger.warning("[Email] 无库管角色邮箱,无法发送备货通知")
|
||||||
|
|
||||||
|
elif action == 'reject':
|
||||||
|
# 3.3 通知申请人(已驳回)
|
||||||
|
if applicant_emails:
|
||||||
|
try:
|
||||||
|
send_borrow_approval_result_notify(
|
||||||
|
to_emails=applicant_emails,
|
||||||
|
request_no=approval.request_no,
|
||||||
|
is_passed=False,
|
||||||
|
reject_reason=approval.reject_reason or '未说明原因',
|
||||||
|
applicant_name=applicant_name
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Email] 通知申请人驳回失败: {e}")
|
||||||
|
else:
|
||||||
|
logger.warning("[Email] 申请人无邮箱,无法发送驳回通知")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
logger.error(f"[Email] 外层发送异常: {e}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def can_approve(approval, user_id, user_role):
|
||||||
|
"""
|
||||||
|
检查用户是否有权限审批
|
||||||
|
"""
|
||||||
|
approvers = approval.get_allowed_approvers()
|
||||||
|
|
||||||
|
if user_role and user_role.upper() == 'SUPER_ADMIN':
|
||||||
|
return True
|
||||||
|
|
||||||
|
for approver in approvers:
|
||||||
|
approver_type = approver.get('type', '')
|
||||||
|
approver_value = approver.get('value', '')
|
||||||
|
|
||||||
|
if approver_type == 'user' and str(approver_value) == str(user_id):
|
||||||
|
return True
|
||||||
|
|
||||||
|
if approver_type == 'role' and approver_value == user_role:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def approve(request_id, user_id, user_role, action='approve', reject_reason=None):
|
||||||
|
"""
|
||||||
|
执行审批操作
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(success: bool, message: str, approval: BorrowApproval or None)
|
||||||
|
"""
|
||||||
|
beijing_tz = pytz.timezone('Asia/Shanghai')
|
||||||
|
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
|
||||||
|
|
||||||
|
approval = BorrowApproval.query.get(request_id)
|
||||||
|
if not approval:
|
||||||
|
return False, "审批单不存在", None
|
||||||
|
|
||||||
|
if approval.status != 0:
|
||||||
|
return False, f"审批单状态已更新,无法重复审批 (当前状态: {approval.status})", None
|
||||||
|
|
||||||
|
if not BorrowApprovalService.can_approve(approval, user_id, user_role):
|
||||||
|
return False, "您没有审批此单的权限", None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if action == 'approve':
|
||||||
|
approval.status = 1 # 已通过
|
||||||
|
approval.actual_approver_id = user_id
|
||||||
|
approval.approved_at = current_time
|
||||||
|
elif action == 'reject':
|
||||||
|
approval.status = 2 # 已驳回
|
||||||
|
approval.reject_reason = reject_reason
|
||||||
|
else:
|
||||||
|
return False, "无效的审批操作", None
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# ★ 审批后,发送邮件通知(静默处理,不阻断主流程)
|
||||||
|
BorrowApprovalService._notify_approval_result(approval, user_id, action)
|
||||||
|
|
||||||
|
return True, "审批成功", approval
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return False, f"审批失败: {str(e)}", None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_request_list(page=1, per_page=10, applicant_id=None, status=None):
|
||||||
|
"""
|
||||||
|
获取审批单列表
|
||||||
|
"""
|
||||||
|
from sqlalchemy import desc
|
||||||
|
|
||||||
|
query = BorrowApproval.query
|
||||||
|
|
||||||
|
if applicant_id:
|
||||||
|
query = query.filter(BorrowApproval.applicant_id == applicant_id)
|
||||||
|
|
||||||
|
if status is not None:
|
||||||
|
query = query.filter(BorrowApproval.status == status)
|
||||||
|
|
||||||
|
query = query.order_by(desc(BorrowApproval.created_at))
|
||||||
|
|
||||||
|
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'items': [item.to_dict() for item in pagination.items],
|
||||||
|
'total': pagination.total,
|
||||||
|
'pages': pagination.pages,
|
||||||
|
'current_page': page
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_request_by_id(request_id):
|
||||||
|
"""根据ID获取审批单"""
|
||||||
|
return BorrowApproval.query.get(request_id)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def mark_completed(request_id):
|
||||||
|
"""标记审批单为已完成(借库执行完成后调用)"""
|
||||||
|
approval = BorrowApproval.query.get(request_id)
|
||||||
|
if not approval:
|
||||||
|
return False, "审批单不存在", None
|
||||||
|
|
||||||
|
if approval.status != 1:
|
||||||
|
return False, f"只有已通过的审批单才能标记为完成 (当前状态: {approval.status})", None
|
||||||
|
|
||||||
|
try:
|
||||||
|
approval.status = 3 # 已完成
|
||||||
|
db.session.commit()
|
||||||
|
return True, "审批单已完成", approval
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return False, f"操作失败: {str(e)}", None
|
||||||
@ -709,7 +709,7 @@ class OutboundApprovalService:
|
|||||||
"""发送新申请通知邮件给审批人和申请人(静默处理,不阻断主流程)"""
|
"""发送新申请通知邮件给审批人和申请人(静默处理,不阻断主流程)"""
|
||||||
try:
|
try:
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
from app.utils.email_service import send_new_request_notify
|
from app.utils.email_service import send_outbound_new_request_notify
|
||||||
from app.models.system import SysUser
|
from app.models.system import SysUser
|
||||||
|
|
||||||
applicant_name = ''
|
applicant_name = ''
|
||||||
@ -749,7 +749,7 @@ class OutboundApprovalService:
|
|||||||
# 4. 分别发送邮件
|
# 4. 分别发送邮件
|
||||||
if applicant_emails:
|
if applicant_emails:
|
||||||
try:
|
try:
|
||||||
send_new_request_notify(
|
send_outbound_new_request_notify(
|
||||||
to_emails=applicant_emails,
|
to_emails=applicant_emails,
|
||||||
request_no=approval.request_no,
|
request_no=approval.request_no,
|
||||||
applicant_name=applicant_name,
|
applicant_name=applicant_name,
|
||||||
@ -762,7 +762,7 @@ class OutboundApprovalService:
|
|||||||
|
|
||||||
if approver_emails:
|
if approver_emails:
|
||||||
try:
|
try:
|
||||||
send_new_request_notify(
|
send_outbound_new_request_notify(
|
||||||
to_emails=approver_emails,
|
to_emails=approver_emails,
|
||||||
request_no=approval.request_no,
|
request_no=approval.request_no,
|
||||||
applicant_name=applicant_name,
|
applicant_name=applicant_name,
|
||||||
@ -871,7 +871,7 @@ class OutboundApprovalService:
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from app.utils.email_service import send_approval_result_notify, send_warehouse_dispatch_notify
|
from app.utils.email_service import send_outbound_approval_result_notify, send_outbound_dispatch_notify
|
||||||
from app.models.system import SysUser as SU
|
from app.models.system import SysUser as SU
|
||||||
|
|
||||||
# 1. 提取申请人信息(供两个分支使用)
|
# 1. 提取申请人信息(供两个分支使用)
|
||||||
@ -885,7 +885,7 @@ class OutboundApprovalService:
|
|||||||
applicant_emails.append(user.email)
|
applicant_emails.append(user.email)
|
||||||
|
|
||||||
# 2. 提取物料明细(供通过分支使用)
|
# 2. 提取物料明细(供通过分支使用)
|
||||||
items = approval.items_json if approval.items_json else []
|
items = approval.get_items() if approval else []
|
||||||
|
|
||||||
# 3. 分支逻辑
|
# 3. 分支逻辑
|
||||||
if action == 'approve':
|
if action == 'approve':
|
||||||
@ -895,7 +895,7 @@ class OutboundApprovalService:
|
|||||||
|
|
||||||
if warehouse_emails:
|
if warehouse_emails:
|
||||||
try:
|
try:
|
||||||
send_warehouse_dispatch_notify(
|
send_outbound_dispatch_notify(
|
||||||
to_emails=warehouse_emails,
|
to_emails=warehouse_emails,
|
||||||
request_no=approval.request_no,
|
request_no=approval.request_no,
|
||||||
applicant_name=applicant_name,
|
applicant_name=applicant_name,
|
||||||
@ -907,7 +907,7 @@ class OutboundApprovalService:
|
|||||||
# 3.2 通知申请人(审批通过,带完整物料清单)
|
# 3.2 通知申请人(审批通过,带完整物料清单)
|
||||||
if applicant_emails:
|
if applicant_emails:
|
||||||
try:
|
try:
|
||||||
send_warehouse_dispatch_notify(
|
send_outbound_dispatch_notify(
|
||||||
to_emails=applicant_emails,
|
to_emails=applicant_emails,
|
||||||
request_no=approval.request_no,
|
request_no=approval.request_no,
|
||||||
applicant_name=applicant_name,
|
applicant_name=applicant_name,
|
||||||
@ -920,7 +920,7 @@ class OutboundApprovalService:
|
|||||||
# 3.3 通知申请人(已驳回)
|
# 3.3 通知申请人(已驳回)
|
||||||
if applicant_emails:
|
if applicant_emails:
|
||||||
try:
|
try:
|
||||||
send_approval_result_notify(
|
send_outbound_approval_result_notify(
|
||||||
to_emails=applicant_emails,
|
to_emails=applicant_emails,
|
||||||
request_no=approval.request_no,
|
request_no=approval.request_no,
|
||||||
is_passed=False,
|
is_passed=False,
|
||||||
|
|||||||
@ -30,18 +30,65 @@ class TransService:
|
|||||||
return f"{prefix}{sequence:04d}"
|
return f"{prefix}{sequence:04d}"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create_borrow(data, operator_name='System'):
|
def execute_dispatch(approval_id, items, operator_name='System', borrower_name=None,
|
||||||
|
signature=None, remark=None, expected_return_time=None):
|
||||||
"""
|
"""
|
||||||
借库逻辑:减少可用库存,不减总库存
|
执行借库扣减(审批通过后调用)
|
||||||
|
流程:锁审批单 → 超额校验 → 锁库存行 → 扣减库存 → 生成 TransBorrow 记录 → 标记审批单完成
|
||||||
"""
|
"""
|
||||||
items = data.get('items', [])
|
from app.models.borrow import BorrowApproval
|
||||||
borrower_name = data.get('borrower_name')
|
|
||||||
signature = data.get('signature_path') # 借用人签字
|
|
||||||
|
|
||||||
if not items: raise ValueError("物品列表为空")
|
if not items: raise ValueError("物品列表为空")
|
||||||
if not borrower_name: raise ValueError("请输入借用人")
|
|
||||||
if not signature: raise ValueError("借用人必须签字")
|
if not signature: raise ValueError("借用人必须签字")
|
||||||
|
|
||||||
|
# ==============================================
|
||||||
|
# ★ 防线1:并发防重复执行 - 用 SELECT FOR UPDATE 锁住审批单
|
||||||
|
# ==============================================
|
||||||
|
approval = BorrowApproval.query.with_for_update().get(approval_id)
|
||||||
|
if not approval:
|
||||||
|
raise ValueError("审批单不存在")
|
||||||
|
if approval.status != 1:
|
||||||
|
status_map = {0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成'}
|
||||||
|
raise ValueError(f"审批单状态为【{status_map.get(approval.status, approval.status)}】,无法执行借库")
|
||||||
|
|
||||||
|
# ★ borrower_name 兜底:优先用前端传参,其次从审批单读取(申请时填写的姓名)
|
||||||
|
if not borrower_name:
|
||||||
|
borrower_name = approval.borrower_name
|
||||||
|
if not borrower_name:
|
||||||
|
raise ValueError("审批单中未记录借库人姓名,请联系管理员补录")
|
||||||
|
|
||||||
|
# ==============================================
|
||||||
|
# ★ 防线2:超额篡改校验 - 交叉比对前端传来的实际扣减量与审批单上限
|
||||||
|
# ==============================================
|
||||||
|
approved_items = approval.get_items()
|
||||||
|
if not approved_items:
|
||||||
|
raise ValueError("审批单中无物料明细,请联系管理员检查")
|
||||||
|
|
||||||
|
# 构建审批上限字典:key=(source_table, sku) → approved_total_qty
|
||||||
|
approved_limit = {}
|
||||||
|
for ai in approved_items:
|
||||||
|
key = (ai.get('source_table', ''), ai.get('sku', ''))
|
||||||
|
qty = float(ai.get('quantity', 0))
|
||||||
|
approved_limit[key] = approved_limit.get(key, 0) + qty
|
||||||
|
|
||||||
|
# 汇总前端传来的实际出库量(按 source_table+sku 聚合)
|
||||||
|
dispatch_qty = {}
|
||||||
|
for item in items:
|
||||||
|
key = (item.get('source_table', ''), item.get('sku', ''))
|
||||||
|
qty = float(item.get('out_quantity', 0))
|
||||||
|
dispatch_qty[key] = dispatch_qty.get(key, 0) + qty
|
||||||
|
|
||||||
|
# 逐条比对:任意一条实际出库量 > 审批上限 → 直接拒绝
|
||||||
|
for key, actual_qty in dispatch_qty.items():
|
||||||
|
limit_qty = approved_limit.get(key, 0)
|
||||||
|
if actual_qty > limit_qty:
|
||||||
|
source_table, sku = key
|
||||||
|
raise ValueError(
|
||||||
|
f"实际出库数量超出了审批单允许的上限: "
|
||||||
|
f"SKU={sku or '(无)'}({source_table}) "
|
||||||
|
f"审批上限={limit_qty}, 实际出库={actual_qty}"
|
||||||
|
)
|
||||||
|
|
||||||
borrow_no = TransService.generate_borrow_no()
|
borrow_no = TransService.generate_borrow_no()
|
||||||
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
|
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
|
||||||
|
|
||||||
@ -54,6 +101,9 @@ class TransService:
|
|||||||
ModelClass = model_map.get(source_table)
|
ModelClass = model_map.get(source_table)
|
||||||
if not ModelClass: continue
|
if not ModelClass: continue
|
||||||
|
|
||||||
|
# ==============================================
|
||||||
|
# ★ 防线3:并发超卖与负库存 - 锁行后再查可用库存
|
||||||
|
# ==============================================
|
||||||
stock = ModelClass.query.with_for_update().get(stock_id)
|
stock = ModelClass.query.with_for_update().get(stock_id)
|
||||||
if not stock: raise ValueError(f"库存不存在 ID:{stock_id}")
|
if not stock: raise ValueError(f"库存不存在 ID:{stock_id}")
|
||||||
|
|
||||||
@ -63,7 +113,7 @@ class TransService:
|
|||||||
# 1. 冻结库存 (只减可用)
|
# 1. 冻结库存 (只减可用)
|
||||||
stock.available_quantity = float(stock.available_quantity) - qty
|
stock.available_quantity = float(stock.available_quantity) - qty
|
||||||
|
|
||||||
# 2. 创建借用单
|
# 2. 创建借用记录
|
||||||
record = TransBorrow(
|
record = TransBorrow(
|
||||||
borrow_no=borrow_no,
|
borrow_no=borrow_no,
|
||||||
sku=stock.sku,
|
sku=stock.sku,
|
||||||
@ -73,19 +123,39 @@ class TransService:
|
|||||||
quantity=qty,
|
quantity=qty,
|
||||||
borrower_name=borrower_name,
|
borrower_name=borrower_name,
|
||||||
borrow_signature=signature,
|
borrow_signature=signature,
|
||||||
remark=data.get('remark'),
|
remark=remark,
|
||||||
expected_return_time=data.get('expected_return_time'),
|
expected_return_time=expected_return_time,
|
||||||
status='borrowed',
|
status='borrowed',
|
||||||
is_returned=False
|
is_returned=False
|
||||||
)
|
)
|
||||||
db.session.add(record)
|
db.session.add(record)
|
||||||
|
|
||||||
|
# ★ 3. 标记审批单为已完成
|
||||||
|
approval.status = 3
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return borrow_no
|
return borrow_no
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
# ★ 兼容旧入口(不走审批流的直接借库,保留以便平滑过渡)
|
||||||
|
@staticmethod
|
||||||
|
def create_borrow(data, operator_name='System'):
|
||||||
|
"""
|
||||||
|
借库逻辑(兼容旧模式):减少可用库存,不减总库存
|
||||||
|
@deprecated 请优先使用 execute_dispatch 走审批流
|
||||||
|
"""
|
||||||
|
return TransService.execute_dispatch(
|
||||||
|
approval_id=0,
|
||||||
|
items=data.get('items', []),
|
||||||
|
operator_name=operator_name,
|
||||||
|
borrower_name=data.get('borrower_name'),
|
||||||
|
signature=data.get('signature_path'),
|
||||||
|
remark=data.get('remark'),
|
||||||
|
expected_return_time=data.get('expected_return_time')
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def scan_for_return(barcode):
|
def scan_for_return(barcode):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -118,24 +118,13 @@ def send_email(to_email: Union[str, List[str]], subject: str, content: str):
|
|||||||
logger.error(f"[Email] 发送邮件时发生未知异常: {e}")
|
logger.error(f"[Email] 发送邮件时发生未知异常: {e}")
|
||||||
|
|
||||||
|
|
||||||
def send_new_request_notify(to_emails: List[str], request_no: str,
|
def send_outbound_new_request_notify(to_emails: List[str], request_no: str,
|
||||||
applicant_name: str = '', remark: str = '',
|
applicant_name: str = '', remark: str = '',
|
||||||
items: list = None, is_applicant_notify: bool = False):
|
items: list = None, is_applicant_notify: bool = False):
|
||||||
"""
|
"""
|
||||||
通知审批人有新的出库申请单待审批(可附带物料清单)
|
通知审批人有新的出库申请单待审批(可附带物料清单)
|
||||||
或通知申请人其申请已提交(is_applicant_notify=True 时)
|
或通知申请人其申请已提交(is_applicant_notify=True 时)
|
||||||
|
|
||||||
Args:
|
|
||||||
to_emails: 审批人邮箱列表
|
|
||||||
request_no: 审批单号
|
|
||||||
applicant_name: 申请人姓名
|
|
||||||
remark: 申请备注
|
|
||||||
items: 物料明细列表(可选)
|
|
||||||
is_applicant_notify: True=通知申请人(标题:您的出库申请已提交),False=通知审批人(标题:您有一笔新的出库审批待处理)
|
|
||||||
"""
|
"""
|
||||||
print(f"[DEBUG send_new_request_notify] 入参 items={items}, is_applicant_notify={is_applicant_notify}")
|
|
||||||
|
|
||||||
# 拼装物料表格
|
|
||||||
rows = []
|
rows = []
|
||||||
rows.append("名称 | 规格 | 计划数量")
|
rows.append("名称 | 规格 | 计划数量")
|
||||||
rows.append("-" * 40)
|
rows.append("-" * 40)
|
||||||
@ -194,21 +183,78 @@ https://172.16.0.198/outbound/approval
|
|||||||
send_email(to_emails, subject, content)
|
send_email(to_emails, subject, content)
|
||||||
|
|
||||||
|
|
||||||
def send_approval_result_notify(to_emails: List[str], request_no: str,
|
def send_borrow_new_request_notify(to_emails: List[str], request_no: str,
|
||||||
|
applicant_name: str = '', remark: str = '',
|
||||||
|
items: list = None, is_applicant_notify: bool = False):
|
||||||
|
"""
|
||||||
|
通知审批人有新的借库申请单待审批(可附带物料清单)
|
||||||
|
或通知申请人其申请已提交(is_applicant_notify=True 时)
|
||||||
|
"""
|
||||||
|
rows = []
|
||||||
|
rows.append("名称 | 规格 | 计划数量")
|
||||||
|
rows.append("-" * 40)
|
||||||
|
if items:
|
||||||
|
for item in items:
|
||||||
|
name = item.get('name', '-') or '-'
|
||||||
|
spec = item.get('spec_model', '-') or '-'
|
||||||
|
qty = item.get('quantity', '-') or '-'
|
||||||
|
rows.append(f"{name} | {spec} | {qty}")
|
||||||
|
else:
|
||||||
|
rows.append("(无物料明细)")
|
||||||
|
|
||||||
|
if is_applicant_notify:
|
||||||
|
subject = f"【已提交】您的借库申请单 {request_no} 已提交"
|
||||||
|
content = f"""您好,
|
||||||
|
|
||||||
|
您的借库申请单 {request_no} 已成功提交,等待审批。
|
||||||
|
|
||||||
|
申请单号:{request_no}
|
||||||
|
申请人:{applicant_name or '未知'}
|
||||||
|
备注说明:{remark or '无'}
|
||||||
|
|
||||||
|
物料清单如下:
|
||||||
|
{chr(10).join(rows)}
|
||||||
|
|
||||||
|
---
|
||||||
|
您可以点击下方链接查看申请状态:
|
||||||
|
https://172.16.0.198/operation/borrow_apply
|
||||||
|
---
|
||||||
|
|
||||||
|
此邮件由系统自动发送,请勿回复。
|
||||||
|
"""
|
||||||
|
else:
|
||||||
|
subject = f"【待审批】借库申请单 {request_no}"
|
||||||
|
content = f"""您好,
|
||||||
|
|
||||||
|
您有一笔新的借库审批申请待处理:
|
||||||
|
|
||||||
|
申请单号:{request_no}
|
||||||
|
申请人:{applicant_name or '未知'}
|
||||||
|
备注说明:{remark or '无'}
|
||||||
|
|
||||||
|
物料清单如下:
|
||||||
|
{chr(10).join(rows)}
|
||||||
|
|
||||||
|
---
|
||||||
|
⚡ 快速通道:
|
||||||
|
请点击下方链接直接进入系统审批:
|
||||||
|
https://172.16.0.198/operation/borrow_approval
|
||||||
|
---
|
||||||
|
|
||||||
|
请登录仓库管理系统进行审批。
|
||||||
|
|
||||||
|
此邮件由系统自动发送,请勿回复。
|
||||||
|
"""
|
||||||
|
send_email(to_emails, subject, content)
|
||||||
|
|
||||||
|
|
||||||
|
def send_outbound_approval_result_notify(to_emails: List[str], request_no: str,
|
||||||
is_passed: bool, reject_reason: str = '',
|
is_passed: bool, reject_reason: str = '',
|
||||||
applicant_name: str = ''):
|
applicant_name: str = ''):
|
||||||
"""
|
"""
|
||||||
通知审批结果
|
通知出库审批结果
|
||||||
|
|
||||||
Args:
|
|
||||||
to_emails: 收件人邮箱列表
|
|
||||||
request_no: 审批单号
|
|
||||||
is_passed: 是否通过(通过时发给库管,驳回时发给申请人)
|
|
||||||
reject_reason: 驳回原因(仅 is_passed=False 时使用)
|
|
||||||
applicant_name: 申请人姓名(仅驳回通知时使用)
|
|
||||||
"""
|
"""
|
||||||
if is_passed:
|
if is_passed:
|
||||||
# ★ 发给申请人:告知已通过,去领料
|
|
||||||
subject = f"【已通过】出库申请单 {request_no}"
|
subject = f"【已通过】出库申请单 {request_no}"
|
||||||
content = f"""{"尊敬的 " + applicant_name + ",您好" if applicant_name else "您好"},
|
content = f"""{"尊敬的 " + applicant_name + ",您好" if applicant_name else "您好"},
|
||||||
|
|
||||||
@ -219,7 +265,6 @@ def send_approval_result_notify(to_emails: List[str], request_no: str,
|
|||||||
此邮件由系统自动发送,请勿回复。
|
此邮件由系统自动发送,请勿回复。
|
||||||
"""
|
"""
|
||||||
else:
|
else:
|
||||||
# ★ 发给申请人:告知被驳回
|
|
||||||
subject = f"【已驳回】出库申请单 {request_no}"
|
subject = f"【已驳回】出库申请单 {request_no}"
|
||||||
content = f"""{"尊敬的 " + applicant_name + ",您好" if applicant_name else "您好"},
|
content = f"""{"尊敬的 " + applicant_name + ",您好" if applicant_name else "您好"},
|
||||||
|
|
||||||
@ -234,19 +279,43 @@ def send_approval_result_notify(to_emails: List[str], request_no: str,
|
|||||||
send_email(to_emails, subject, content)
|
send_email(to_emails, subject, content)
|
||||||
|
|
||||||
|
|
||||||
def send_warehouse_dispatch_notify(to_emails: List[str], request_no: str,
|
def send_borrow_approval_result_notify(to_emails: List[str], request_no: str,
|
||||||
|
is_passed: bool, reject_reason: str = '',
|
||||||
|
applicant_name: str = ''):
|
||||||
|
"""
|
||||||
|
通知借库审批结果
|
||||||
|
"""
|
||||||
|
if is_passed:
|
||||||
|
subject = f"【已通过】借库申请单 {request_no}"
|
||||||
|
content = f"""{"尊敬的 " + applicant_name + ",您好" if applicant_name else "您好"},
|
||||||
|
|
||||||
|
您的借库申请单 {request_no} 已审批通过,请前往仓库扫码借出。
|
||||||
|
|
||||||
|
请登录仓库管理系统查看详情。
|
||||||
|
|
||||||
|
此邮件由系统自动发送,请勿回复。
|
||||||
|
"""
|
||||||
|
else:
|
||||||
|
subject = f"【已驳回】借库申请单 {request_no}"
|
||||||
|
content = f"""{"尊敬的 " + applicant_name + ",您好" if applicant_name else "您好"},
|
||||||
|
|
||||||
|
借库申请单 {request_no} 已被审批驳回。
|
||||||
|
|
||||||
|
驳回原因:{reject_reason or '未填写'}
|
||||||
|
|
||||||
|
请登录仓库管理系统查看详情,并根据驳回原因调整后重新提交申请。
|
||||||
|
|
||||||
|
此邮件由系统自动发送,请勿回复。
|
||||||
|
"""
|
||||||
|
send_email(to_emails, subject, content)
|
||||||
|
|
||||||
|
|
||||||
|
def send_outbound_dispatch_notify(to_emails: List[str], request_no: str,
|
||||||
applicant_name: str = '', items: list = None):
|
applicant_name: str = '', items: list = None):
|
||||||
"""
|
"""
|
||||||
通知库管备货出库(包含完整物料清单)
|
通知库管备货出库(包含完整物料清单)
|
||||||
|
|
||||||
Args:
|
|
||||||
to_emails: 库管邮箱列表
|
|
||||||
request_no: 审批单号
|
|
||||||
applicant_name: 申请人姓名
|
|
||||||
items: 物料明细列表,每个元素包含 name/spec_model/warehouse_location/quantity
|
|
||||||
"""
|
"""
|
||||||
print(f"[DEBUG send_warehouse_dispatch_notify] 入参 items={items}")
|
print(f"[DEBUG send_outbound_dispatch_notify] 入参 items={items}")
|
||||||
print(f"[DEBUG send_warehouse_dispatch_notify] items 类型={type(items)}, 长度={len(items) if items else 0}")
|
|
||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
rows.append("名称 | 规格 | 库位 | 计划数量")
|
rows.append("名称 | 规格 | 库位 | 计划数量")
|
||||||
@ -275,4 +344,39 @@ def send_warehouse_dispatch_notify(to_emails: List[str], request_no: str,
|
|||||||
此邮件由系统自动发送,请勿回复。
|
此邮件由系统自动发送,请勿回复。
|
||||||
"""
|
"""
|
||||||
send_email(to_emails, subject, content)
|
send_email(to_emails, subject, content)
|
||||||
print(f"DEBUG: 准备向服务器提交发信请求,收件人: {to_emails}")
|
|
||||||
|
|
||||||
|
def send_borrow_dispatch_notify(to_emails: List[str], request_no: str,
|
||||||
|
applicant_name: str = '', items: list = None):
|
||||||
|
"""
|
||||||
|
通知库管备货借库(包含完整物料清单)
|
||||||
|
"""
|
||||||
|
print(f"[DEBUG send_borrow_dispatch_notify] 入参 items={items}")
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
rows.append("名称 | 规格 | 库位 | 计划数量")
|
||||||
|
rows.append("-" * 50)
|
||||||
|
if items:
|
||||||
|
for item in items:
|
||||||
|
name = item.get('name', '-') or '-'
|
||||||
|
spec = item.get('spec_model', '-') or '-'
|
||||||
|
loc = item.get('warehouse_location', '-') or '-'
|
||||||
|
qty = item.get('quantity', '-') or '-'
|
||||||
|
rows.append(f"{name} | {spec} | {loc} | {qty}")
|
||||||
|
else:
|
||||||
|
rows.append("(无物料明细)")
|
||||||
|
|
||||||
|
subject = f"【待借库】借库申请单 {request_no} 已审批通过"
|
||||||
|
content = f"""您好,
|
||||||
|
|
||||||
|
借库申请单 {request_no} 已审批通过,请按以下清单准备备货:
|
||||||
|
|
||||||
|
{chr(10).join(rows)}
|
||||||
|
|
||||||
|
申请人:{applicant_name or '未知'}
|
||||||
|
|
||||||
|
请登录仓库管理系统执行"扫码借库"操作。
|
||||||
|
|
||||||
|
此邮件由系统自动发送,请勿回复。
|
||||||
|
"""
|
||||||
|
send_email(to_emails, subject, content)
|
||||||
|
|||||||
@ -239,7 +239,7 @@ const handleLogout = () => {
|
|||||||
<footer v-if="!isLoginPage" class="app-footer">
|
<footer v-if="!isLoginPage" class="app-footer">
|
||||||
<span class="version-tag">
|
<span class="version-tag">
|
||||||
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
|
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
|
||||||
当前版本:V3.46
|
当前版本:V3.47
|
||||||
</span>
|
</span>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,194 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
// 购物车商品项接口
|
||||||
|
export interface CartItem {
|
||||||
|
id: number
|
||||||
|
sku: string
|
||||||
|
name: string
|
||||||
|
spec_model: string
|
||||||
|
source_table: string
|
||||||
|
stock_quantity: number
|
||||||
|
available_quantity: number
|
||||||
|
barcode: string
|
||||||
|
price: number // 单价
|
||||||
|
out_quantity: number // 本次出库数量
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交出库单的数据结构
|
||||||
|
export interface OutboundSubmitData {
|
||||||
|
items: Array<{
|
||||||
|
sku: string
|
||||||
|
source_table: string
|
||||||
|
stock_id: number
|
||||||
|
barcode: string
|
||||||
|
quantity: number
|
||||||
|
price: number
|
||||||
|
}>
|
||||||
|
outbound_type: string
|
||||||
|
consumer_name: string
|
||||||
|
operator_name: string
|
||||||
|
signature_path: string // 上传后返回的图片路径
|
||||||
|
remark?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanResult {
|
||||||
|
id: number
|
||||||
|
sku: string
|
||||||
|
name: string
|
||||||
|
spec_model: string
|
||||||
|
source_table: string // 'stock_buy' | 'stock_product' ...
|
||||||
|
stock_quantity: number
|
||||||
|
available_quantity: number
|
||||||
|
batch_number?: string
|
||||||
|
warehouse_location?: string
|
||||||
|
barcode?: string
|
||||||
|
price?: number // 扫描返回的价格
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据条码获取库存物品详情
|
||||||
|
* @param barcode 扫描到的条码
|
||||||
|
*/
|
||||||
|
export function getStockByBarcode(barcode: string) {
|
||||||
|
return request<any, ScanResult>({
|
||||||
|
url: '/v1/outbound/scan',
|
||||||
|
method: 'get',
|
||||||
|
params: { barcode }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交出库单 (批量)
|
||||||
|
*/
|
||||||
|
export function submitOutbound(data: OutboundSubmitData) {
|
||||||
|
return request({
|
||||||
|
url: '/v1/outbound',
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取出库记录列表
|
||||||
|
*/
|
||||||
|
export function getOutboundList(params: any) {
|
||||||
|
return request({
|
||||||
|
url: '/v1/outbound',
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交出库申请单(申请人 → 审批流)
|
||||||
|
*/
|
||||||
|
export function submitOutboundRequest(data: {
|
||||||
|
items: Array<{
|
||||||
|
material_type?: string
|
||||||
|
name: string
|
||||||
|
spec_model: string
|
||||||
|
warehouse_location?: string
|
||||||
|
quantity: number
|
||||||
|
}>
|
||||||
|
remark: string
|
||||||
|
}) {
|
||||||
|
return request({
|
||||||
|
url: '/v1/outbound/request',
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取出库审批申请单列表
|
||||||
|
* @param params 支持 status, page, limit
|
||||||
|
*/
|
||||||
|
export function getApprovalRequestList(params: { status?: number | ''; page?: number; limit?: number }) {
|
||||||
|
return request({
|
||||||
|
url: '/v1/outbound/request',
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批(通过 / 驳回)出库申请单
|
||||||
|
* @param id 审批单ID
|
||||||
|
* @param data action: 'approve' | 'reject',reject 时需传 reject_reason
|
||||||
|
*/
|
||||||
|
export function approveRequest(id: number, data: { action: 'approve' | 'reject'; reject_reason?: string }) {
|
||||||
|
return request({
|
||||||
|
url: `/v1/outbound/request/${id}/approve`,
|
||||||
|
method: 'patch',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==============================================================================
|
||||||
|
// 借库审批流 API
|
||||||
|
// ==============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交借库申请单(申请人 → 审批流)
|
||||||
|
*/
|
||||||
|
export function submitBorrowRequest(data: {
|
||||||
|
items: Array<{
|
||||||
|
name: string
|
||||||
|
spec_model: string
|
||||||
|
warehouse_location?: string
|
||||||
|
quantity: number
|
||||||
|
}>
|
||||||
|
remark?: string
|
||||||
|
allowed_approvers?: Array<{ type: string; value: string }>
|
||||||
|
approver_id?: number
|
||||||
|
}) {
|
||||||
|
return request({
|
||||||
|
url: '/v1/transactions/borrow/request',
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取借库审批申请单列表
|
||||||
|
* @param params 支持 status, page, limit
|
||||||
|
*/
|
||||||
|
export function getBorrowApprovalList(params: { status?: number | ''; page?: number; limit?: number }) {
|
||||||
|
return request({
|
||||||
|
url: '/v1/transactions/borrow/request',
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批(通过 / 驳回)借库申请单
|
||||||
|
* @param id 审批单ID
|
||||||
|
* @param data action: 'approve' | 'reject',reject 时需传 reject_reason
|
||||||
|
*/
|
||||||
|
export function approveBorrowRequest(id: number, data: { action: 'approve' | 'reject'; reject_reason?: string }) {
|
||||||
|
return request({
|
||||||
|
url: `/v1/transactions/borrow/request/${id}/approve`,
|
||||||
|
method: 'patch',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行借库扣减(审批通过后调用)
|
||||||
|
* @param data approval_id + 扫码选中的物品 + 借用人信息 + 签名
|
||||||
|
*/
|
||||||
|
export function dispatchBorrow(data: {
|
||||||
|
approval_id: number
|
||||||
|
items: Array<any>
|
||||||
|
borrower_name: string
|
||||||
|
signature_path: string
|
||||||
|
remark?: string
|
||||||
|
expected_return_time?: string | null
|
||||||
|
}) {
|
||||||
|
return request({
|
||||||
|
url: '/v1/transactions/borrow/dispatch',
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -209,11 +209,17 @@ const routes: Array<RouteRecordRaw> = [
|
|||||||
meta: { title: '借库管理', icon: 'Operation' },
|
meta: { title: '借库管理', icon: 'Operation' },
|
||||||
redirect: '/operation/borrow',
|
redirect: '/operation/borrow',
|
||||||
children: [
|
children: [
|
||||||
|
{
|
||||||
|
path: 'borrow_apply',
|
||||||
|
name: 'BorrowApply',
|
||||||
|
component: () => import('@/views/borrow/apply/index.vue'),
|
||||||
|
meta: { title: '借库选单' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'borrow',
|
path: 'borrow',
|
||||||
name: 'OpBorrow',
|
name: 'OpBorrow',
|
||||||
component: () => import('@/views/transaction/borrow.vue'),
|
component: () => import('@/views/transaction/borrow.vue'),
|
||||||
meta: { title: '借库' }
|
meta: { title: '扫码借库' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'repair',
|
path: 'repair',
|
||||||
@ -226,6 +232,16 @@ const routes: Array<RouteRecordRaw> = [
|
|||||||
name: 'OpRecords',
|
name: 'OpRecords',
|
||||||
component: () => import('@/views/transaction/records.vue'),
|
component: () => import('@/views/transaction/records.vue'),
|
||||||
meta: { title: '借还记录' }
|
meta: { title: '借还记录' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'borrow_approval',
|
||||||
|
name: 'BorrowApproval',
|
||||||
|
component: () => import('@/views/borrow/approval/index.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '借库审批',
|
||||||
|
icon: 'Stamp',
|
||||||
|
roles: ['SUPER_ADMIN', 'SUPERVISOR']
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
1106
inventory-web/src/views/borrow/apply/index.vue
Normal file
1106
inventory-web/src/views/borrow/apply/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
365
inventory-web/src/views/borrow/approval/index.vue
Normal file
365
inventory-web/src/views/borrow/approval/index.vue
Normal file
@ -0,0 +1,365 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-container">
|
||||||
|
|
||||||
|
<!-- 顶部工具栏 -->
|
||||||
|
<div class="filter-container">
|
||||||
|
<span style="font-weight: bold; font-size: 15px; margin-right: 8px;">审批状态:</span>
|
||||||
|
<el-radio-group v-model="filterStatus" size="default" @change="handleStatusChange">
|
||||||
|
<el-radio-button label="">全部</el-radio-button>
|
||||||
|
<el-radio-button :label="0">待审批</el-radio-button>
|
||||||
|
<el-radio-button :label="1">已通过</el-radio-button>
|
||||||
|
<el-radio-button :label="2">已驳回</el-radio-button>
|
||||||
|
<el-radio-button :label="3">已完成</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
<el-button type="primary" :icon="Refresh" @click="fetchData">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 数据表格 -->
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="list"
|
||||||
|
border
|
||||||
|
stripe
|
||||||
|
style="margin-top: 16px;"
|
||||||
|
row-key="id"
|
||||||
|
:expand-row-keys="expandedRows"
|
||||||
|
@expand-change="handleExpandChange"
|
||||||
|
>
|
||||||
|
<!-- 展开行 -->
|
||||||
|
<el-table-column type="expand" width="60" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div style="padding: 12px 24px; background: #f5f7fa;">
|
||||||
|
<p style="margin: 0 0 10px 0; font-weight: bold; font-size: 13px; color: #606266;">
|
||||||
|
物料明细(共 {{ row.items?.length || 0 }} 项)
|
||||||
|
</p>
|
||||||
|
<el-table
|
||||||
|
v-if="row.items?.length"
|
||||||
|
:data="row.items"
|
||||||
|
border
|
||||||
|
size="small"
|
||||||
|
style="width: 100%;"
|
||||||
|
>
|
||||||
|
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||||
|
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="spec_model" label="规格型号" min-width="120" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="warehouse_location" label="库位" width="120" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="quantity" label="计划数量" width="100" align="center">
|
||||||
|
<template #default="{ row: item }">
|
||||||
|
<span style="color: #F56C6C; font-weight: bold;">{{ item.quantity ?? '-' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-empty v-else description="暂无物料明细" :image-size="60" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column prop="request_no" label="申请单号" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link type="primary" :underline="false" @click="toggleExpand(row)">
|
||||||
|
{{ row.request_no }}
|
||||||
|
</el-link>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="申请人" width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ getApplicantName(row.applicant_id) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column prop="remark" label="申请原因" min-width="180" show-overflow-tooltip />
|
||||||
|
|
||||||
|
<el-table-column label="物料种类" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" type="info">{{ row.items?.length || 0 }} 种</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column prop="created_at" label="申请时间" width="170" />
|
||||||
|
|
||||||
|
<el-table-column label="状态" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="statusTagType(row.status)" size="small">
|
||||||
|
{{ statusText(row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="审批信息" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<template v-if="row.status === 1">
|
||||||
|
<span style="color: #67C23A;">{{ getApproverName(row.actual_approver_id) }}</span>
|
||||||
|
<br />
|
||||||
|
<span style="font-size: 12px; color: #909399;">{{ row.approved_at || '' }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="row.status === 2">
|
||||||
|
<span style="color: #F56C6C;">已驳回</span>
|
||||||
|
<el-tooltip v-if="row.reject_reason" :content="row.reject_reason" placement="top">
|
||||||
|
<el-icon style="margin-left: 4px; cursor: pointer;"><Warning /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="row.status === 3">
|
||||||
|
<span style="color: #909399;">{{ getApproverName(row.actual_approver_id) }}</span>
|
||||||
|
</template>
|
||||||
|
<span v-else style="color: #c0c4cc;">-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="操作" width="180" fixed="right" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<template v-if="row.status === 0">
|
||||||
|
<el-button
|
||||||
|
v-if="userStore.hasPermission('borrow_approval:operation')"
|
||||||
|
type="success"
|
||||||
|
size="small"
|
||||||
|
:loading="row._approving"
|
||||||
|
@click="handleApprove(row)"
|
||||||
|
>
|
||||||
|
通过
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="userStore.hasPermission('borrow_approval:operation')"
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
:loading="row._approving"
|
||||||
|
@click="openRejectDialog(row)"
|
||||||
|
>
|
||||||
|
驳回
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
<span v-else style="color: #c0c4cc;">-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<el-pagination
|
||||||
|
background
|
||||||
|
style="margin-top: 16px; justify-content: flex-end; display: flex;"
|
||||||
|
v-model:current-page="page"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, prev, pager, next, sizes"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 驳回原因 Dialog -->
|
||||||
|
<el-dialog v-model="rejectDialogVisible" title="驳回申请" width="480px" destroy-on-close>
|
||||||
|
<el-form label-width="80px">
|
||||||
|
<el-form-item label="申请单号">
|
||||||
|
<span style="font-weight: bold; color: #409EFF;">{{ currentRejectRow?.request_no }}</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="驳回原因" required>
|
||||||
|
<el-input
|
||||||
|
v-model="rejectReason"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
placeholder="请填写驳回原因(必填)"
|
||||||
|
maxlength="200"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="rejectDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="danger" :loading="rejectLoading" @click="confirmReject">确认驳回</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { Refresh, Warning } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { getBorrowApprovalList, approveBorrowRequest } from '@/api/transaction'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
// --- 状态 ---
|
||||||
|
const list = ref<any[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const filterStatus = ref<number | ''>(0) // 默认筛选待审批
|
||||||
|
const expandedRows = ref<string[]>([])
|
||||||
|
|
||||||
|
// 驳回 Dialog
|
||||||
|
const rejectDialogVisible = ref(false)
|
||||||
|
const currentRejectRow = ref<any>(null)
|
||||||
|
const rejectReason = ref('')
|
||||||
|
const rejectLoading = ref(false)
|
||||||
|
|
||||||
|
// 申请人 / 审批人名称缓存
|
||||||
|
const userNameCache = ref<Record<number, string>>({})
|
||||||
|
|
||||||
|
// --- 工具函数 ---
|
||||||
|
const statusText = (status: number) => {
|
||||||
|
const map: Record<number, string> = {
|
||||||
|
0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成'
|
||||||
|
}
|
||||||
|
return map[status] ?? '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusTagType = (status: number) => {
|
||||||
|
const map: Record<number, string> = {
|
||||||
|
0: 'warning', 1: 'success', 2: 'danger', 3: 'info'
|
||||||
|
}
|
||||||
|
return map[status] ?? 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getApplicantName = (id: number | null) => {
|
||||||
|
if (!id) return '-'
|
||||||
|
return userNameCache.value[id] ?? `用户 #${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getApproverName = (id: number | null) => {
|
||||||
|
if (!id) return '-'
|
||||||
|
return userNameCache.value[id] ?? `用户 #${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 展开行 ---
|
||||||
|
const toggleExpand = (row: any) => {
|
||||||
|
const idx = expandedRows.value.indexOf(row.id)
|
||||||
|
if (idx > -1) {
|
||||||
|
expandedRows.value.splice(idx, 1)
|
||||||
|
} else {
|
||||||
|
expandedRows.value.push(row.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExpandChange = () => {}
|
||||||
|
|
||||||
|
// --- 数据获取 ---
|
||||||
|
const fetchData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params: any = {
|
||||||
|
page: page.value,
|
||||||
|
limit: pageSize.value
|
||||||
|
}
|
||||||
|
if (filterStatus.value !== '') {
|
||||||
|
params.status = filterStatus.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const res: any = await getBorrowApprovalList(params)
|
||||||
|
|
||||||
|
const records = res.data?.items || []
|
||||||
|
records.forEach((r: any) => {
|
||||||
|
if (r.applicant_id && !userNameCache.value[r.applicant_id]) {
|
||||||
|
if (r.applicant_name) {
|
||||||
|
userNameCache.value[r.applicant_id] = r.applicant_name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (r.actual_approver_id && !userNameCache.value[r.actual_approver_id]) {
|
||||||
|
if (r.approver_name) {
|
||||||
|
userNameCache.value[r.actual_approver_id] = r.approver_name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r._approving = false
|
||||||
|
})
|
||||||
|
|
||||||
|
list.value = records
|
||||||
|
total.value = res.data?.total || records.length || 0
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage.error(err?.msg || '加载审批列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 筛选 ---
|
||||||
|
const handleStatusChange = () => {
|
||||||
|
page.value = 1
|
||||||
|
expandedRows.value = []
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 分页 ---
|
||||||
|
const handlePageChange = (p: number) => {
|
||||||
|
page.value = p
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSizeChange = (s: number) => {
|
||||||
|
pageSize.value = s
|
||||||
|
page.value = 1
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 审批操作 ---
|
||||||
|
const handleApprove = async (row: any) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定要通过借库申请单 【${row.request_no}】 吗?`,
|
||||||
|
'审批确认',
|
||||||
|
{ confirmButtonText: '确定通过', cancelButtonText: '取消', type: 'info' }
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
row._approving = true
|
||||||
|
try {
|
||||||
|
await approveBorrowRequest(row.id, { action: 'approve' })
|
||||||
|
ElMessage.success(`申请单 ${row.request_no} 已通过`)
|
||||||
|
await fetchData()
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage.error(err?.msg || '审批操作失败')
|
||||||
|
} finally {
|
||||||
|
row._approving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openRejectDialog = (row: any) => {
|
||||||
|
currentRejectRow.value = row
|
||||||
|
rejectReason.value = ''
|
||||||
|
rejectDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmReject = async () => {
|
||||||
|
const reason = rejectReason.value.trim()
|
||||||
|
if (!reason) {
|
||||||
|
ElMessage.warning('请填写驳回原因')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectLoading.value = true
|
||||||
|
try {
|
||||||
|
await approveBorrowRequest(currentRejectRow.value.id, {
|
||||||
|
action: 'reject',
|
||||||
|
reject_reason: reason
|
||||||
|
})
|
||||||
|
ElMessage.success(`申请单 ${currentRejectRow.value.request_no} 已驳回`)
|
||||||
|
rejectDialogVisible.value = false
|
||||||
|
await fetchData()
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage.error(err?.msg || '驳回操作失败')
|
||||||
|
} finally {
|
||||||
|
rejectLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 初始化 ---
|
||||||
|
onMounted(() => {
|
||||||
|
fetchData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-container {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.filter-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -12,6 +12,57 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- ★ 审批单选择下拉框 -->
|
||||||
|
<div class="approval-request-select">
|
||||||
|
<el-select
|
||||||
|
v-model="selectedApprovalId"
|
||||||
|
placeholder="请选择已通过审批的借库申请单"
|
||||||
|
filterable
|
||||||
|
clearable
|
||||||
|
style="width: 100%"
|
||||||
|
:loading="requestsLoading"
|
||||||
|
@change="handleApprovalChange"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="req in approvalRequests"
|
||||||
|
:key="req.id"
|
||||||
|
:value="req.id"
|
||||||
|
:label="req.request_no"
|
||||||
|
>
|
||||||
|
<span>{{ req.request_no }}</span>
|
||||||
|
<el-divider direction="vertical" />
|
||||||
|
<span>{{ req.borrower_name || '未知借库人' }}</span>
|
||||||
|
<el-divider direction="vertical" />
|
||||||
|
<span style="color: #909399; font-size: 13px">{{ req.remark || '无备注' }}</span>
|
||||||
|
</el-option>
|
||||||
|
</el-select>
|
||||||
|
<p class="select-tip">仅显示已通过(status=1)的审批单</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ★ 审批计划清单预览 -->
|
||||||
|
<div v-if="selectedApproval" class="planned-items-section">
|
||||||
|
<div class="planned-header">
|
||||||
|
<span class="planned-title">审批计划清单</span>
|
||||||
|
<el-tag type="success" size="small">{{ plannedItems.length }} 种</el-tag>
|
||||||
|
</div>
|
||||||
|
<el-table :data="plannedItems" border size="small" style="width: 100%;">
|
||||||
|
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||||
|
<el-table-column label="类型" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" type="info">{{ row.material_type || '-' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="name" label="名称" min-width="120" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="spec_model" label="规格" min-width="100" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="warehouse_location" label="库位" width="100" show-overflow-tooltip />
|
||||||
|
<el-table-column label="审批数量" width="90" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span style="color: #E6A23C; font-weight: bold;">{{ row.quantity ?? '-' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="scan-section">
|
<div class="scan-section">
|
||||||
<div v-if="userStore.hasPermission('op_borrow:operation')" class="camera-placeholder" @click="showCamera = true">
|
<div v-if="userStore.hasPermission('op_borrow:operation')" class="camera-placeholder" @click="showCamera = true">
|
||||||
<el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon>
|
<el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon>
|
||||||
@ -204,26 +255,22 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
|||||||
import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select } from '@element-plus/icons-vue'
|
import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select } from '@element-plus/icons-vue'
|
||||||
import QrScanner from '@/components/QrScanner/index.vue'
|
import QrScanner from '@/components/QrScanner/index.vue'
|
||||||
import { getStockByBarcode } from '@/api/outbound'
|
import { getStockByBarcode } from '@/api/outbound'
|
||||||
import request from '@/utils/request'
|
import { dispatchBorrow, getBorrowApprovalList } from '@/api/transaction'
|
||||||
import { uploadFile } from '@/api/common/upload'
|
import { uploadFile } from '@/api/common/upload'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
// 列与权限Code的映射关系(数据库中的code)
|
// 列与权限Code的映射关系
|
||||||
const permissionMap: Record<string, string> = {
|
const permissionMap: Record<string, string> = {
|
||||||
borrower_name: 'op_borrow:borrower_name',
|
borrower_name: 'op_borrow:borrower_name',
|
||||||
sku: 'op_borrow:sku',
|
sku: 'op_borrow:sku',
|
||||||
available_quantity: 'op_borrow:available_quantity',
|
available_quantity: 'op_borrow:available_quantity',
|
||||||
out_quantity: 'op_borrow:out_quantity',
|
out_quantity: 'op_borrow:out_quantity',
|
||||||
// 其他字段可根据需要添加
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查列权限
|
|
||||||
const hasColumnPermission = (prop: string) => {
|
const hasColumnPermission = (prop: string) => {
|
||||||
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
|
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') return true
|
||||||
return true
|
|
||||||
}
|
|
||||||
const code = permissionMap[prop]
|
const code = permissionMap[prop]
|
||||||
return code ? userStore.hasPermission(code) : false
|
return code ? userStore.hasPermission(code) : false
|
||||||
}
|
}
|
||||||
@ -236,6 +283,76 @@ const showCamera = ref(false)
|
|||||||
const barcodeRef = ref()
|
const barcodeRef = ref()
|
||||||
const formRef = ref()
|
const formRef = ref()
|
||||||
|
|
||||||
|
// ★ 审批单选择
|
||||||
|
const approvalRequests = ref<any[]>([])
|
||||||
|
const selectedApprovalId = ref<number | null>(null)
|
||||||
|
const requestsLoading = ref(false)
|
||||||
|
|
||||||
|
const selectedApproval = computed(() =>
|
||||||
|
selectedApprovalId.value
|
||||||
|
? approvalRequests.value.find(r => r.id === selectedApprovalId.value) ?? null
|
||||||
|
: null
|
||||||
|
)
|
||||||
|
|
||||||
|
const plannedItems = computed(() => selectedApproval.value?.items ?? [])
|
||||||
|
|
||||||
|
// ★ 加载已通过审批的借库申请单列表
|
||||||
|
const loadApprovalRequests = async () => {
|
||||||
|
requestsLoading.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await getBorrowApprovalList({ status: 1, page: 1, limit: 100 })
|
||||||
|
approvalRequests.value = res.data?.items || []
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载借库审批单列表失败', e)
|
||||||
|
} finally {
|
||||||
|
requestsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 切换审批单时:清空购物车和签名,防止跨单据污染
|
||||||
|
const handleApprovalChange = (val: number | null) => {
|
||||||
|
if (!val) {
|
||||||
|
selectedApprovalId.value = null
|
||||||
|
}
|
||||||
|
cartItems.value = []
|
||||||
|
signatureFile.value = null
|
||||||
|
signaturePreviewUrl.value = ''
|
||||||
|
barcodeInput.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 扫码校验:比对扫描物料是否在审批计划清单内,且累计数量不超过审批上限
|
||||||
|
const validateAgainstPlan = (scannedName: string, scannedSpec: string, scannedQty: number): string | null => {
|
||||||
|
const normalizedName = scannedName.trim()
|
||||||
|
const normalizedSpec = (scannedSpec || '').trim()
|
||||||
|
|
||||||
|
const matchedPlan = plannedItems.value.find(plan => {
|
||||||
|
const planName = (plan.name || '').trim()
|
||||||
|
const planSpec = (plan.spec_model || '').trim()
|
||||||
|
return planName === normalizedName && planSpec === normalizedSpec
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!matchedPlan) {
|
||||||
|
return `该物料【${normalizedName} × ${normalizedSpec}】不在审批计划清单中,请检查`
|
||||||
|
}
|
||||||
|
|
||||||
|
const planQty = matchedPlan.quantity ?? 0
|
||||||
|
|
||||||
|
// 购物车中已扫的同名同规格物料累计数量
|
||||||
|
const alreadyScanned = cartItems.value
|
||||||
|
.filter(ci => {
|
||||||
|
const ciName = (ci.name || '').trim()
|
||||||
|
const ciSpec = (ci.spec_model || '').trim()
|
||||||
|
return ciName === normalizedName && ciSpec === normalizedSpec
|
||||||
|
})
|
||||||
|
.reduce((sum, ci) => sum + (ci.out_quantity || 0), 0)
|
||||||
|
|
||||||
|
if (alreadyScanned + scannedQty > planQty) {
|
||||||
|
return `【${normalizedName} × ${normalizedSpec}】超出审批数量(审批: ${planQty},已扫: ${alreadyScanned},本次: ${scannedQty})`
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
// 签名相关
|
// 签名相关
|
||||||
const showSignatureDialog = ref(false)
|
const showSignatureDialog = ref(false)
|
||||||
const signaturePreviewUrl = ref('')
|
const signaturePreviewUrl = ref('')
|
||||||
@ -254,9 +371,7 @@ const form = reactive({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const rules = computed(() => ({
|
const rules = computed(() => ({
|
||||||
borrower_name: [
|
borrower_name: [{ required: true, message: '请输入借用人姓名', trigger: 'blur' }],
|
||||||
{ required: true, message: '请输入借用人姓名', trigger: 'blur' }
|
|
||||||
],
|
|
||||||
expected_return_time: [
|
expected_return_time: [
|
||||||
{ required: !isIndefinite.value, message: '请选择预计归还日期', trigger: 'change' }
|
{ required: !isIndefinite.value, message: '请选择预计归还日期', trigger: 'change' }
|
||||||
]
|
]
|
||||||
@ -265,9 +380,7 @@ const rules = computed(() => ({
|
|||||||
const isIndefinite = ref(false)
|
const isIndefinite = ref(false)
|
||||||
|
|
||||||
const handleIndefiniteChange = (val: boolean) => {
|
const handleIndefiniteChange = (val: boolean) => {
|
||||||
if (val) {
|
if (val) form.expected_return_time = ''
|
||||||
form.expected_return_time = ''
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const disabledDate = (time: Date) => {
|
const disabledDate = (time: Date) => {
|
||||||
@ -278,35 +391,51 @@ const disabledDate = (time: Date) => {
|
|||||||
const onScanSuccess = (code: string) => {
|
const onScanSuccess = (code: string) => {
|
||||||
if (!code) return
|
if (!code) return
|
||||||
const trimCode = code.trim()
|
const trimCode = code.trim()
|
||||||
|
|
||||||
const validPattern = /^[A-Za-z0-9\-\.]+$/
|
const validPattern = /^[A-Za-z0-9\-\.]+$/
|
||||||
if (!validPattern.test(trimCode)) {
|
if (!validPattern.test(trimCode)) {
|
||||||
ElMessage.warning(`识别到异常符号,已忽略:${trimCode}`)
|
ElMessage.warning(`识别到异常符号,已忽略:${trimCode}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trimCode.length < 3) {
|
if (trimCode.length < 3) {
|
||||||
ElMessage.warning('扫描结果过短,请对准重试')
|
ElMessage.warning('扫描结果过短,请对准重试')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading.value) return
|
if (loading.value) return
|
||||||
|
|
||||||
barcodeInput.value = trimCode
|
barcodeInput.value = trimCode
|
||||||
handleManualInput()
|
handleManualInput()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleManualInput = async () => {
|
const handleManualInput = async () => {
|
||||||
|
if (!userStore.hasPermission('op_borrow:operation')) {
|
||||||
|
ElMessage.warning('无操作权限')
|
||||||
|
return
|
||||||
|
}
|
||||||
const code = barcodeInput.value.trim()
|
const code = barcodeInput.value.trim()
|
||||||
if (!code) return
|
if (!code) return
|
||||||
|
|
||||||
|
// ★ 必须先选择审批单
|
||||||
|
if (!selectedApproval.value) {
|
||||||
|
ElMessage.warning('请先选择要执行借库的审批申请单')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
||||||
// 查重
|
// 查重:条码或 SKU 匹配已扫记录
|
||||||
const existIndex = cartItems.value.findIndex(item => item.barcode === code || item.sku === code)
|
const existIndex = cartItems.value.findIndex(item => item.barcode === code || item.sku === code)
|
||||||
if (existIndex > -1) {
|
if (existIndex > -1) {
|
||||||
const item = cartItems.value[existIndex]
|
const item = cartItems.value[existIndex]
|
||||||
|
|
||||||
|
// ★ 追加前仍需校验审批数量上限
|
||||||
|
const err = validateAgainstPlan(item.name, item.spec_model, 1)
|
||||||
|
if (err) {
|
||||||
|
ElMessage.error(err)
|
||||||
|
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
|
||||||
|
barcodeInput.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const maxQty = parseFloat(item.available_quantity)
|
const maxQty = parseFloat(item.available_quantity)
|
||||||
if (item.out_quantity < maxQty) {
|
if (item.out_quantity < maxQty) {
|
||||||
item.out_quantity++
|
item.out_quantity++
|
||||||
@ -314,6 +443,7 @@ const handleManualInput = async () => {
|
|||||||
if (navigator.vibrate) navigator.vibrate(50)
|
if (navigator.vibrate) navigator.vibrate(50)
|
||||||
} else {
|
} else {
|
||||||
ElMessage.warning(`库存不足 (余: ${maxQty})`)
|
ElMessage.warning(`库存不足 (余: ${maxQty})`)
|
||||||
|
if (navigator.vibrate) navigator.vibrate([100, 50, 100])
|
||||||
}
|
}
|
||||||
barcodeInput.value = ''
|
barcodeInput.value = ''
|
||||||
return
|
return
|
||||||
@ -326,16 +456,28 @@ const handleManualInput = async () => {
|
|||||||
const availQty = parseFloat(item.available_quantity || 0)
|
const availQty = parseFloat(item.available_quantity || 0)
|
||||||
|
|
||||||
if (availQty <= 0) {
|
if (availQty <= 0) {
|
||||||
ElMessage.warning(`库存不足 (余: ${availQty})`)
|
ElMessage.warning(`库存不足或已借出 (余: ${availQty})`)
|
||||||
} else {
|
if (navigator.vibrate) navigator.vibrate([100, 50, 100])
|
||||||
cartItems.value.push({
|
barcodeInput.value = ''
|
||||||
...item,
|
return
|
||||||
out_quantity: 1,
|
|
||||||
price: 0
|
|
||||||
})
|
|
||||||
ElMessage.success(`添加成功: ${item.name}`)
|
|
||||||
if (navigator.vibrate) navigator.vibrate(100)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ★ 扫码加入前强校验:不在清单内或超量直接阻断
|
||||||
|
const err = validateAgainstPlan(item.name, item.spec_model, 1)
|
||||||
|
if (err) {
|
||||||
|
ElMessage.error(err)
|
||||||
|
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
|
||||||
|
barcodeInput.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cartItems.value.push({
|
||||||
|
...item,
|
||||||
|
out_quantity: 1,
|
||||||
|
price: 0
|
||||||
|
})
|
||||||
|
ElMessage.success(`添加成功: ${item.name}`)
|
||||||
|
if (navigator.vibrate) navigator.vibrate(100)
|
||||||
barcodeInput.value = ''
|
barcodeInput.value = ''
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -344,9 +486,9 @@ const handleManualInput = async () => {
|
|||||||
} else {
|
} else {
|
||||||
ElMessage.error('查询出错')
|
ElMessage.error('查询出错')
|
||||||
}
|
}
|
||||||
|
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
// ★ 核心修改:只有当非全屏模式时,才自动聚焦输入框
|
|
||||||
if (!showCamera.value) {
|
if (!showCamera.value) {
|
||||||
nextTick(() => { barcodeRef.value?.focus() })
|
nextTick(() => { barcodeRef.value?.focus() })
|
||||||
}
|
}
|
||||||
@ -354,10 +496,18 @@ const handleManualInput = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const removeFromCart = (index: number) => {
|
const removeFromCart = (index: number) => {
|
||||||
|
if (!userStore.hasPermission('op_borrow:operation')) {
|
||||||
|
ElMessage.warning('无操作权限')
|
||||||
|
return
|
||||||
|
}
|
||||||
cartItems.value.splice(index, 1)
|
cartItems.value.splice(index, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearAll = () => {
|
const clearAll = () => {
|
||||||
|
if (!userStore.hasPermission('op_borrow:operation')) {
|
||||||
|
ElMessage.warning('无操作权限')
|
||||||
|
return
|
||||||
|
}
|
||||||
ElMessageBox.confirm('确定清空所有已选物品吗?', '提示', { type: 'warning' })
|
ElMessageBox.confirm('确定清空所有已选物品吗?', '提示', { type: 'warning' })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
cartItems.value = []
|
cartItems.value = []
|
||||||
@ -368,13 +518,19 @@ const clearAll = () => {
|
|||||||
signaturePreviewUrl.value = ''
|
signaturePreviewUrl.value = ''
|
||||||
barcodeInput.value = ''
|
barcodeInput.value = ''
|
||||||
isIndefinite.value = false
|
isIndefinite.value = false
|
||||||
|
// 仅清空购物车,保留审批单选择
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 提交逻辑 ---
|
// --- 提交逻辑 ---
|
||||||
const submitForm = async () => {
|
const submitForm = async () => {
|
||||||
|
if (!userStore.hasPermission('op_borrow:operation')) {
|
||||||
|
ElMessage.warning('无操作权限')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!formRef.value) return
|
if (!formRef.value) return
|
||||||
if (cartItems.value.length === 0) return ElMessage.warning('请先添加物品')
|
if (cartItems.value.length === 0) return ElMessage.warning('请先添加物品')
|
||||||
|
if (!selectedApprovalId.value) return ElMessage.warning('请选择关联的审批申请单')
|
||||||
|
|
||||||
await formRef.value.validate(async (valid: boolean) => {
|
await formRef.value.validate(async (valid: boolean) => {
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
@ -382,7 +538,6 @@ const submitForm = async () => {
|
|||||||
ElMessage.error(requiredMsg)
|
ElMessage.error(requiredMsg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!signatureFile.value) {
|
if (!signatureFile.value) {
|
||||||
ElMessage.error('请领用人进行电子签名')
|
ElMessage.error('请领用人进行电子签名')
|
||||||
return
|
return
|
||||||
@ -395,20 +550,32 @@ const submitForm = async () => {
|
|||||||
const uploadRes = await uploadFile(signatureFile.value)
|
const uploadRes = await uploadFile(signatureFile.value)
|
||||||
const signatureUrl = uploadRes.data.url
|
const signatureUrl = uploadRes.data.url
|
||||||
|
|
||||||
// 处理无限期借用:如果选择了无限期,将预计归还时间置为空
|
// ★ 规范 Payload:只包含后端需要的最小字段
|
||||||
const submitData = {
|
const itemsPayload = cartItems.value.map(item => {
|
||||||
...form,
|
let safeQty = Number(item.out_quantity)
|
||||||
expected_return_time: isIndefinite.value ? null : form.expected_return_time
|
if (isNaN(safeQty) || safeQty <= 0) safeQty = 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
stock_id: item.id || 0,
|
||||||
|
source_table: item.source_table || '',
|
||||||
|
sku: item.sku ? String(item.sku) : (item.barcode ? String(item.barcode) : 'NO_SKU'),
|
||||||
|
barcode: item.barcode ? String(item.barcode) : '',
|
||||||
|
out_quantity: safeQty
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (itemsPayload.length === 0) {
|
||||||
|
ElMessage.warning('请至少扫描一件物料后再提交')
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await request({
|
await dispatchBorrow({
|
||||||
url: '/v1/transactions/borrow',
|
approval_id: selectedApprovalId.value,
|
||||||
method: 'post',
|
items: itemsPayload,
|
||||||
data: {
|
borrower_name: form.borrower_name,
|
||||||
items: cartItems.value,
|
signature_path: signatureUrl,
|
||||||
...submitData,
|
remark: form.remark,
|
||||||
signature_path: signatureUrl
|
expected_return_time: isIndefinite.value ? null : form.expected_return_time
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
ElMessage.success('借用成功')
|
ElMessage.success('借用成功')
|
||||||
@ -431,13 +598,18 @@ const submitForm = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- 签名逻辑 ---
|
// --- 签名逻辑 ---
|
||||||
const openSignatureDialog = () => { showSignatureDialog.value = true }
|
const openSignatureDialog = () => {
|
||||||
|
if (!userStore.hasPermission('op_borrow:operation')) {
|
||||||
|
ElMessage.warning('无签名权限')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showSignatureDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
const initCanvas = async () => {
|
const initCanvas = async () => {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
const canvas = nativeCanvasRef.value
|
const canvas = nativeCanvasRef.value
|
||||||
const container = canvasContainerRef.value
|
const container = canvasContainerRef.value
|
||||||
|
|
||||||
if (canvas && container) {
|
if (canvas && container) {
|
||||||
canvas.width = container.clientWidth
|
canvas.width = container.clientWidth
|
||||||
canvas.height = container.clientHeight
|
canvas.height = container.clientHeight
|
||||||
@ -500,6 +672,12 @@ const handleSignConfirm = () => {
|
|||||||
|
|
||||||
const handleSignCancel = () => { showSignatureDialog.value = false }
|
const handleSignCancel = () => { showSignatureDialog.value = false }
|
||||||
|
|
||||||
|
// --- 初始化 ---
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
onMounted(() => {
|
||||||
|
loadApprovalRequests()
|
||||||
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (signaturePreviewUrl.value) URL.revokeObjectURL(signaturePreviewUrl.value)
|
if (signaturePreviewUrl.value) URL.revokeObjectURL(signaturePreviewUrl.value)
|
||||||
})
|
})
|
||||||
@ -514,7 +692,16 @@ onUnmounted(() => {
|
|||||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||||
.title-box { font-size: 16px; font-weight: bold; display: flex; align-items: center; gap: 8px; }
|
.title-box { font-size: 16px; font-weight: bold; display: flex; align-items: center; gap: 8px; }
|
||||||
|
|
||||||
/* 扫码区(卡片内触发器) */
|
/* 审批单选择 */
|
||||||
|
.approval-request-select { margin-bottom: 16px; }
|
||||||
|
.select-tip { color: #909399; font-size: 12px; margin: 4px 0 0 0; }
|
||||||
|
|
||||||
|
/* 计划清单 */
|
||||||
|
.planned-items-section { margin-bottom: 16px; background: #f5f7fa; border-radius: 6px; padding: 12px; }
|
||||||
|
.planned-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||||
|
.planned-title { font-size: 13px; font-weight: bold; color: #606266; }
|
||||||
|
|
||||||
|
/* 扫码区 */
|
||||||
.scan-section { margin-bottom: 20px; }
|
.scan-section { margin-bottom: 20px; }
|
||||||
.camera-placeholder {
|
.camera-placeholder {
|
||||||
height: 120px; background: #f5f7fa; border: 1px dashed #dcdfe6; border-radius: 8px;
|
height: 120px; background: #f5f7fa; border: 1px dashed #dcdfe6; border-radius: 8px;
|
||||||
@ -525,59 +712,26 @@ onUnmounted(() => {
|
|||||||
.camera-placeholder:active { background: #e6e8eb; }
|
.camera-placeholder:active { background: #e6e8eb; }
|
||||||
.camera-placeholder .text { margin-top: 5px; font-size: 13px; }
|
.camera-placeholder .text { margin-top: 5px; font-size: 13px; }
|
||||||
|
|
||||||
/* ★ 全屏扫码层样式 */
|
/* 全屏扫码层 */
|
||||||
.fullscreen-scanner-overlay {
|
.fullscreen-scanner-overlay {
|
||||||
position: fixed;
|
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
|
||||||
top: 0;
|
background: #000; z-index: 9999; display: flex; flex-direction: column;
|
||||||
left: 0;
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
background: #000;
|
|
||||||
z-index: 9999;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.scanner-header {
|
.scanner-header {
|
||||||
height: 60px;
|
height: 60px; display: flex; align-items: center; justify-content: space-between;
|
||||||
display: flex;
|
padding: 0 15px; background: rgba(0,0,0,0.6); color: #fff;
|
||||||
align-items: center;
|
position: absolute; top: 0; width: 100%; z-index: 10;
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0 15px;
|
|
||||||
background: rgba(0,0,0,0.6);
|
|
||||||
color: #fff;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
width: 100%;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
}
|
||||||
.scanner-title { font-size: 16px; font-weight: bold; }
|
.scanner-title { font-size: 16px; font-weight: bold; }
|
||||||
.close-btn { background: rgba(255,255,255,0.2); border: none; color: #fff; }
|
.close-btn { background: rgba(255,255,255,0.2); border: none; color: #fff; }
|
||||||
|
|
||||||
.scanner-body {
|
.scanner-body {
|
||||||
flex: 1;
|
flex: 1; width: 100%; position: relative; display: flex;
|
||||||
width: 100%;
|
align-items: center; justify-content: center;
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
/* 强制子组件(QrScanner)填满容器 */
|
:deep(.qr-scanner-container) { width: 100% !important; height: 100% !important; border-radius: 0 !important; }
|
||||||
:deep(.qr-scanner-container) {
|
|
||||||
width: 100% !important;
|
|
||||||
height: 100% !important;
|
|
||||||
border-radius: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scanner-footer {
|
.scanner-footer {
|
||||||
position: absolute;
|
position: absolute; bottom: 0; width: 100%; padding: 20px;
|
||||||
bottom: 0;
|
background: rgba(0,0,0,0.6); color: #fff; text-align: center; z-index: 10;
|
||||||
width: 100%;
|
|
||||||
padding: 20px;
|
|
||||||
background: rgba(0,0,0,0.6);
|
|
||||||
color: #fff;
|
|
||||||
text-align: center;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
}
|
||||||
.current-count { color: #67c23a; font-weight: bold; margin-top: 5px; font-size: 16px; }
|
.current-count { color: #67c23a; font-weight: bold; margin-top: 5px; font-size: 16px; }
|
||||||
|
|
||||||
@ -591,7 +745,6 @@ onUnmounted(() => {
|
|||||||
.unsigned-placeholder { display: flex; flex-direction: column; align-items: center; color: #909399; font-size: 13px; }
|
.unsigned-placeholder { display: flex; flex-direction: column; align-items: center; color: #909399; font-size: 13px; }
|
||||||
.signed-img img { max-height: 90px; }
|
.signed-img img { max-height: 90px; }
|
||||||
.re-sign-tip { display: block; text-align: center; font-size: 12px; color: #409EFF; margin-top: 2px; }
|
.re-sign-tip { display: block; text-align: center; font-size: 12px; color: #409EFF; margin-top: 2px; }
|
||||||
|
|
||||||
.bottom-actions { display: flex; justify-content: space-between; margin-top: 30px; }
|
.bottom-actions { display: flex; justify-content: space-between; margin-top: 30px; }
|
||||||
.bottom-actions .el-button { width: 48%; }
|
.bottom-actions .el-button { width: 48%; }
|
||||||
|
|
||||||
@ -621,4 +774,4 @@ onUnmounted(() => {
|
|||||||
.sidebar-actions { flex-direction: row; width: 100%; gap: 10px; }
|
.sidebar-actions { flex-direction: row; width: 100%; gap: 10px; }
|
||||||
.sidebar-actions .el-button { flex: 1; height: 40px; }
|
.sidebar-actions .el-button { flex: 1; height: 40px; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
Reference in New Issue
Block a user