Compare commits
4 Commits
40e405becd
...
183b93012e
| Author | SHA1 | Date | |
|---|---|---|---|
| 183b93012e | |||
| 62c0e3738e | |||
| 97e7618bf3 | |||
| e08eaff40a |
@ -2,7 +2,9 @@
|
|||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(git add *)",
|
"Bash(git add *)",
|
||||||
"Bash(git commit *)"
|
"Bash(git commit *)",
|
||||||
|
"Bash(git *)",
|
||||||
|
"Bash(del *)"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"$version": 3
|
"$version": 3
|
||||||
|
|||||||
@ -318,6 +318,41 @@ def get_my_permissions():
|
|||||||
return jsonify({'msg': f'获取权限失败: {str(e)}'}), 500
|
return jsonify({'msg': f'获取权限失败: {str(e)}'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# 获取可指定审批人列表(SUPERVISOR / SUPER_ADMIN 且 status=active)
|
||||||
|
# ==============================================================================
|
||||||
|
@auth_bp.route('/users/approvers', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def get_approvers():
|
||||||
|
"""
|
||||||
|
查询角色为 SUPER_ADMIN 或 SUPERVISOR 且状态为活跃的用户列表
|
||||||
|
返回: [{id, username, email, role}]
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from app.models.system import SysUser
|
||||||
|
|
||||||
|
users = SysUser.query.filter(
|
||||||
|
SysUser.role.in_(['SUPER_ADMIN', 'SUPERVISOR']),
|
||||||
|
SysUser.status == 'active'
|
||||||
|
).all()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'msg': '获取成功',
|
||||||
|
'data': [
|
||||||
|
{
|
||||||
|
'id': u.id,
|
||||||
|
'username': u.username,
|
||||||
|
'email': u.email or '',
|
||||||
|
'role': u.role
|
||||||
|
} for u in users
|
||||||
|
]
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f"Get Approvers Failed: {str(e)}")
|
||||||
|
return jsonify({'msg': f'获取审批人列表失败: {str(e)}'}), 500
|
||||||
|
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# 获取当前用户个人资料(自我查看)
|
# 获取当前用户个人资料(自我查看)
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|||||||
@ -266,8 +266,37 @@ def get_stock_list():
|
|||||||
d['available_quantity'] = d.get('qty_available', d.get('available_quantity', 0))
|
d['available_quantity'] = d.get('qty_available', d.get('available_quantity', 0))
|
||||||
all_items.append(d)
|
all_items.append(d)
|
||||||
|
|
||||||
total = len(all_items)
|
# ── 按规格+库位聚合(出库选单合并同类项)───────────────────────
|
||||||
|
is_aggregated = request.args.get('is_aggregated', 'false').lower() == 'true'
|
||||||
|
|
||||||
|
if is_aggregated:
|
||||||
|
grouped_dict = {}
|
||||||
|
for item in all_items:
|
||||||
|
# 核心聚合键:类型 + 规格型号 + 库位
|
||||||
|
group_key = f"{item.get('type')}_{item.get('standard')}_{item.get('warehouse_location', '')}"
|
||||||
|
|
||||||
|
if group_key in grouped_dict:
|
||||||
|
# 累加数量
|
||||||
|
existing = grouped_dict[group_key]
|
||||||
|
existing['available_quantity'] = float(existing.get('available_quantity', 0)) + float(item.get('available_quantity', 0))
|
||||||
|
existing['stock_quantity'] = float(existing.get('stock_quantity', 0)) + float(item.get('stock_quantity', 0))
|
||||||
|
# 保留 id 列表(出库提交时需用到)
|
||||||
|
existing_ids = existing.get('_ids', [])
|
||||||
|
existing_ids.append(item.get('id'))
|
||||||
|
existing['_ids'] = existing_ids
|
||||||
|
else:
|
||||||
|
# 存入代表项
|
||||||
|
grouped_dict[group_key] = item.copy()
|
||||||
|
# 强制统一数据类型以便前端处理
|
||||||
|
grouped_dict[group_key]['available_quantity'] = float(item.get('available_quantity', 0))
|
||||||
|
grouped_dict[group_key]['stock_quantity'] = float(item.get('stock_quantity', 0))
|
||||||
|
grouped_dict[group_key]['_ids'] = [item.get('id')]
|
||||||
|
|
||||||
|
# 替换原列表为聚合后的列表
|
||||||
|
all_items = list(grouped_dict.values())
|
||||||
|
|
||||||
|
# ── 手动切片分页 ────────────────────────────────────────────
|
||||||
|
total = len(all_items)
|
||||||
start = (page - 1) * pageSize
|
start = (page - 1) * pageSize
|
||||||
end = start + pageSize
|
end = start + pageSize
|
||||||
paged = all_items[start:end]
|
paged = all_items[start:end]
|
||||||
@ -834,8 +863,8 @@ def export_stocktake():
|
|||||||
user = SysUser.query.get(int(user_id))
|
user = SysUser.query.get(int(user_id))
|
||||||
if not user:
|
if not user:
|
||||||
user = SysUser.query.filter(SysUser.username.like(f"%/{user_id}")).first()
|
user = SysUser.query.filter(SysUser.username.like(f"%/{user_id}")).first()
|
||||||
if not user:
|
# 注意:此处不再 fallback filter_by(username=...),
|
||||||
user = SysUser.query.filter_by(username=str(user_id)).first()
|
# 避免 PostgreSQL 将 user_id 数字与 username 字符串列做类型比较导致报错
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
return str(user_id)
|
return str(user_id)
|
||||||
|
|||||||
@ -148,44 +148,6 @@ def create_outbound():
|
|||||||
if not data.get('consumer_name') or not data.get('signature_path'):
|
if not data.get('consumer_name') or not data.get('signature_path'):
|
||||||
return jsonify({'code': 400, 'msg': '领用人及签名信息缺失'}), 400
|
return jsonify({'code': 400, 'msg': '领用人及签名信息缺失'}), 400
|
||||||
|
|
||||||
# 数据清洗:移除用户没有权限的字段
|
|
||||||
user_permissions = get_current_user_permissions()
|
|
||||||
# 超级管理员不过滤
|
|
||||||
if 'outbound_list:*' not in user_permissions:
|
|
||||||
# 字段名到权限码的映射(与前端 permissionMap 保持一致)
|
|
||||||
field_to_perm = {
|
|
||||||
'outbound_no': 'outbound_list:outbound_no',
|
|
||||||
'outbound_time': 'outbound_list:outbound_time',
|
|
||||||
'outbound_type': 'outbound_list:outbound_type',
|
|
||||||
'total_amount': 'outbound_list:total_amount',
|
|
||||||
'consumer_name': 'outbound_list:consumer_name',
|
|
||||||
'operator_name': 'outbound_list:operator_name',
|
|
||||||
'remark': 'outbound_list:remark',
|
|
||||||
'signature_path': 'outbound_list:signature_path',
|
|
||||||
# 明细字段
|
|
||||||
'sku': 'outbound_list:sku',
|
|
||||||
'name': 'outbound_list:name',
|
|
||||||
'material_type': 'outbound_list:material_type',
|
|
||||||
'category': 'outbound_list:category',
|
|
||||||
'spec_model': 'outbound_list:spec_model',
|
|
||||||
'quantity': 'outbound_list:quantity',
|
|
||||||
'unit_price': 'outbound_list:unit_price',
|
|
||||||
'price': 'outbound_list:unit_price', # 兼容 price 字段
|
|
||||||
'subtotal': 'outbound_list:subtotal',
|
|
||||||
}
|
|
||||||
# 清洗顶层字段
|
|
||||||
for field in list(data.keys()):
|
|
||||||
perm_code = field_to_perm.get(field)
|
|
||||||
if perm_code and perm_code not in user_permissions:
|
|
||||||
data.pop(field, None)
|
|
||||||
# 清洗 items 中的每个商品字段
|
|
||||||
if 'items' in data and isinstance(data['items'], list):
|
|
||||||
for item in data['items']:
|
|
||||||
for field in list(item.keys()):
|
|
||||||
perm_code = field_to_perm.get(field)
|
|
||||||
if perm_code and perm_code not in user_permissions:
|
|
||||||
item.pop(field, None)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# ★ [修改] 调用批量创建服务
|
# ★ [修改] 调用批量创建服务
|
||||||
outbound_no = OutboundService.create_outbound_batch(data, operator_name=final_operator)
|
outbound_no = OutboundService.create_outbound_batch(data, operator_name=final_operator)
|
||||||
@ -233,3 +195,244 @@ def get_outbound_list():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return jsonify({'code': 500, 'msg': str(e)}), 500
|
return jsonify({'code': 500, 'msg': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# 出库审批相关接口
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
from app.services.outbound_service import OutboundApprovalService
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user_id():
|
||||||
|
"""获取当前用户ID"""
|
||||||
|
from app.models.system import SysUser
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
if not identity:
|
||||||
|
return None
|
||||||
|
# JWT identity 是数据库主键整数,直接用 .get() 查询
|
||||||
|
user = SysUser.query.get(identity)
|
||||||
|
return user.id if user else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user_info():
|
||||||
|
"""获取当前用户信息和角色"""
|
||||||
|
from app.models.system import SysUser
|
||||||
|
identity = get_jwt_identity()
|
||||||
|
if not identity:
|
||||||
|
return None, None
|
||||||
|
# JWT identity 是数据库主键整数,直接用 .get() 查询
|
||||||
|
user = SysUser.query.get(identity)
|
||||||
|
return user.id if user else None, user.role if user else None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------
|
||||||
|
# 4. 创建出库审批单
|
||||||
|
# POST /api/v1/outbound/request
|
||||||
|
# --------------------------------------------------------
|
||||||
|
@outbound_bp.route('/request', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
def create_outbound_request():
|
||||||
|
"""
|
||||||
|
创建出库审批单(申请阶段,用户只需提交宏观物料信息,无需关联具体库存记录)
|
||||||
|
|
||||||
|
请求体示例:
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "物料A", // 物料名称 (必填)
|
||||||
|
"spec_model": "规格1", // 规格型号 (必填)
|
||||||
|
"quantity": 10, // 计划出库数量 (必填)
|
||||||
|
"warehouse_location": "A区-01-01", // 库位 (可选)
|
||||||
|
"remark": "备注信息" // 物品备注 (可选)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"allowed_approvers": [
|
||||||
|
{"type": "role", "value": "SUPERVISOR"},
|
||||||
|
{"type": "role", "value": "SUPER_ADMIN"}
|
||||||
|
],
|
||||||
|
"remark": "紧急出库申请"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user_id, user_role = get_current_user_info()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'code': 401, 'msg': '用户未登录'}), 401
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({'code': 400, 'msg': '无有效数据'}), 400
|
||||||
|
|
||||||
|
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 item.get(f) is None or str(item.get(f)).strip() == '']
|
||||||
|
if missing:
|
||||||
|
return jsonify({
|
||||||
|
'code': 400,
|
||||||
|
'msg': f'第{idx + 1}条物品缺少必填字段: {", ".join(missing)}。'
|
||||||
|
f'必须包含: name(名称), spec_model(规格), quantity(数量)'
|
||||||
|
}), 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 则精准通知,否则用默认角色规则
|
||||||
|
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 = OutboundApprovalService.create_request(
|
||||||
|
applicant_id=user_id,
|
||||||
|
items=items,
|
||||||
|
allowed_approvers=allowed_approvers,
|
||||||
|
remark=data.get('remark'),
|
||||||
|
approver_id=approver_id
|
||||||
|
)
|
||||||
|
|
||||||
|
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:
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------
|
||||||
|
# 5. 审批出库申请
|
||||||
|
# PATCH /api/v1/outbound/request/<id>/approve
|
||||||
|
# --------------------------------------------------------
|
||||||
|
@outbound_bp.route('/request/<int:request_id>/approve', methods=['PATCH'])
|
||||||
|
@jwt_required()
|
||||||
|
def approve_outbound_request(request_id):
|
||||||
|
"""
|
||||||
|
审批出库申请
|
||||||
|
|
||||||
|
请求体示例:
|
||||||
|
{
|
||||||
|
"action": "approve", // "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 = OutboundApprovalService.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
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------
|
||||||
|
# 6. 获取审批单列表
|
||||||
|
# GET /api/v1/outbound/request
|
||||||
|
# --------------------------------------------------------
|
||||||
|
@outbound_bp.route('/request', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def get_outbound_request_list():
|
||||||
|
"""
|
||||||
|
获取出库审批单列表
|
||||||
|
|
||||||
|
Query参数:
|
||||||
|
- page: 页码 (默认1)
|
||||||
|
- limit: 每页数量 (默认10)
|
||||||
|
- applicant_id: 按申请人筛选 (可选)
|
||||||
|
- status: 按状态筛选 (0待审/1通过/2驳回/3完成, 可选)
|
||||||
|
"""
|
||||||
|
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 = OutboundApprovalService.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:
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({'code': 500, 'msg': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------
|
||||||
|
# 7. 获取单个审批单详情
|
||||||
|
# GET /api/v1/outbound/request/<id>
|
||||||
|
# --------------------------------------------------------
|
||||||
|
@outbound_bp.route('/request/<int:request_id>', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def get_outbound_request_detail(request_id):
|
||||||
|
"""获取出库审批单详情"""
|
||||||
|
try:
|
||||||
|
approval = OutboundApprovalService.get_request_by_id(request_id)
|
||||||
|
|
||||||
|
if not approval:
|
||||||
|
return jsonify({'code': 404, 'msg': '审批单不存在'}), 404
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'code': 200,
|
||||||
|
'msg': '获取成功',
|
||||||
|
'data': approval.to_dict()
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({'code': 500, 'msg': str(e)}), 500
|
||||||
|
|||||||
@ -66,26 +66,6 @@ def filter_item_by_permissions(item_dict, user_permissions, prefix='op_records')
|
|||||||
)
|
)
|
||||||
def create_borrow():
|
def create_borrow():
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
# 数据清洗:移除用户没有权限的字段
|
|
||||||
user_permissions = get_current_user_permissions()
|
|
||||||
# 超级管理员不过滤
|
|
||||||
if '*' not in user_permissions:
|
|
||||||
field_to_perm = {
|
|
||||||
'borrow_no': 'op_records:borrow_no',
|
|
||||||
'borrower_name': 'op_records:borrower_name',
|
|
||||||
'sku': 'op_records:sku',
|
|
||||||
'borrow_time': 'op_records:borrow_time',
|
|
||||||
'return_time': 'op_records:return_time',
|
|
||||||
'status': 'op_records:status',
|
|
||||||
'expected_return_time': 'op_records:expected_return_time',
|
|
||||||
'return_location': 'op_records:return_location',
|
|
||||||
'borrow_signature': 'op_records:borrow_signature',
|
|
||||||
'return_signature': 'op_records:return_signature',
|
|
||||||
}
|
|
||||||
for field in list(data.keys()):
|
|
||||||
perm_code = field_to_perm.get(field)
|
|
||||||
if perm_code and perm_code not in user_permissions:
|
|
||||||
data.pop(field, None)
|
|
||||||
try:
|
try:
|
||||||
no = TransService.create_borrow(data)
|
no = TransService.create_borrow(data)
|
||||||
return jsonify({'code': 200, 'msg': '借用成功', 'data': {'borrow_no': no}})
|
return jsonify({'code': 200, 'msg': '借用成功', 'data': {'borrow_no': no}})
|
||||||
@ -120,26 +100,6 @@ def scan_borrowed_item():
|
|||||||
)
|
)
|
||||||
def submit_return():
|
def submit_return():
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
# 数据清洗:移除用户没有权限的字段
|
|
||||||
user_permissions = get_current_user_permissions()
|
|
||||||
# 超级管理员不过滤
|
|
||||||
if '*' not in user_permissions:
|
|
||||||
field_to_perm = {
|
|
||||||
'borrow_no': 'op_records:borrow_no',
|
|
||||||
'borrower_name': 'op_records:borrower_name',
|
|
||||||
'sku': 'op_records:sku',
|
|
||||||
'borrow_time': 'op_records:borrow_time',
|
|
||||||
'return_time': 'op_records:return_time',
|
|
||||||
'status': 'op_records:status',
|
|
||||||
'expected_return_time': 'op_records:expected_return_time',
|
|
||||||
'return_location': 'op_records:return_location',
|
|
||||||
'borrow_signature': 'op_records:borrow_signature',
|
|
||||||
'return_signature': 'op_records:return_signature',
|
|
||||||
}
|
|
||||||
for field in list(data.keys()):
|
|
||||||
perm_code = field_to_perm.get(field)
|
|
||||||
if perm_code and perm_code not in user_permissions:
|
|
||||||
data.pop(field, None)
|
|
||||||
user = get_jwt_identity() # 库管
|
user = get_jwt_identity() # 库管
|
||||||
try:
|
try:
|
||||||
TransService.process_return(data, operator_name=user)
|
TransService.process_return(data, operator_name=user)
|
||||||
|
|||||||
@ -14,6 +14,6 @@ except ImportError:
|
|||||||
|
|
||||||
# 4. 出库记录 (如果有,BuyService 用到了 TransOutbound)
|
# 4. 出库记录 (如果有,BuyService 用到了 TransOutbound)
|
||||||
try:
|
try:
|
||||||
from app.models.outbound import TransOutbound
|
from app.models.outbound import TransOutbound, OutboundApproval
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
@ -1,5 +1,110 @@
|
|||||||
from app.extensions import db, beijing_time
|
from app.extensions import db, beijing_time
|
||||||
|
from app.models.system import SysUser
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
class OutboundApproval(db.Model):
|
||||||
|
"""
|
||||||
|
出库审批单模型
|
||||||
|
用于管理出库申请的多级审批流程
|
||||||
|
"""
|
||||||
|
__tablename__ = 'outbound_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格式: [{"type": "role", "value": "admin"}, {"type": "user", "value": "123"}])
|
||||||
|
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)
|
||||||
|
|
||||||
|
# 明细快照 (存储出库物品的名称、规格、库位、数量等信息,无SKU字段)
|
||||||
|
items_json = db.Column(db.Text)
|
||||||
|
|
||||||
|
# 创建时间和更新时间
|
||||||
|
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):
|
||||||
|
"""
|
||||||
|
安全解析 JSON 字段:
|
||||||
|
- 如果 value 已是 list/dict,直接返回
|
||||||
|
- 如果是 str,尝试 json.loads()
|
||||||
|
- 解析失败或为 None/空,均返回 []
|
||||||
|
"""
|
||||||
|
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):
|
||||||
|
"""解析 items_json,返回物品列表"""
|
||||||
|
return self._safe_parse_json(self.items_json)
|
||||||
|
|
||||||
|
def set_items(self, items):
|
||||||
|
"""设置 items_json"""
|
||||||
|
self.items_json = json.dumps(items, ensure_ascii=False) if items else '[]'
|
||||||
|
|
||||||
|
def get_allowed_approvers(self):
|
||||||
|
"""解析 allowed_approvers,返回审批人列表"""
|
||||||
|
return self._safe_parse_json(self.allowed_approvers)
|
||||||
|
|
||||||
|
def set_allowed_approvers(self, approvers):
|
||||||
|
"""设置 allowed_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,
|
||||||
|
'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):
|
||||||
|
"""根据用户ID获取用户名"""
|
||||||
|
if not user_id:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
from app.models.system import SysUser
|
||||||
|
try:
|
||||||
|
# ★ 必须用 .get() 按主键 ID 查询,千万不能用 username=user_id 去查
|
||||||
|
user = SysUser.query.get(user_id)
|
||||||
|
return user.username if user else f"未知用户({user_id})"
|
||||||
|
except Exception as e:
|
||||||
|
return f"用户({user_id})"
|
||||||
|
|
||||||
|
|
||||||
class TransOutbound(db.Model):
|
class TransOutbound(db.Model):
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import uuid # .material -> .base refactor checked
|
|||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from sqlalchemy import or_, func, desc, and_
|
from sqlalchemy import or_, func, desc, and_
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models.outbound import TransOutbound
|
from app.models.outbound import TransOutbound, OutboundApproval
|
||||||
|
|
||||||
# 引入所有库存模型以进行查询
|
# 引入所有库存模型以进行查询
|
||||||
from app.models.inbound.buy import StockBuy
|
from app.models.inbound.buy import StockBuy
|
||||||
@ -12,6 +12,8 @@ from app.models.inbound.product import StockProduct
|
|||||||
from app.models.base import MaterialBase
|
from app.models.base import MaterialBase
|
||||||
# 引入维修单表
|
# 引入维修单表
|
||||||
from app.models.transaction import TransRepair
|
from app.models.transaction import TransRepair
|
||||||
|
# 引入系统用户表
|
||||||
|
from app.models.system import SysUser
|
||||||
|
|
||||||
|
|
||||||
class OutboundService:
|
class OutboundService:
|
||||||
@ -169,6 +171,22 @@ class OutboundService:
|
|||||||
beijing_tz = timezone(timedelta(hours=8))
|
beijing_tz = timezone(timedelta(hours=8))
|
||||||
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
|
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
|
||||||
|
|
||||||
|
# ★ 审批单相关逻辑
|
||||||
|
request_id = data.get('request_id')
|
||||||
|
approval = None
|
||||||
|
if request_id:
|
||||||
|
# 根据 request_id 查询审批单
|
||||||
|
approval = OutboundApproval.query.get(request_id)
|
||||||
|
if not approval:
|
||||||
|
raise ValueError(f"关联的审批单不存在 (ID: {request_id})")
|
||||||
|
if approval.status != 1:
|
||||||
|
status_map = {0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成'}
|
||||||
|
current_status = status_map.get(approval.status, str(approval.status))
|
||||||
|
raise ValueError(
|
||||||
|
f"关联的审批单状态不允许出库 (当前状态: {current_status}),"
|
||||||
|
f"仅已通过的审批单方可执行出库"
|
||||||
|
)
|
||||||
|
|
||||||
model_map = {
|
model_map = {
|
||||||
'stock_buy': StockBuy,
|
'stock_buy': StockBuy,
|
||||||
'stock_semi': StockSemi,
|
'stock_semi': StockSemi,
|
||||||
@ -235,6 +253,11 @@ class OutboundService:
|
|||||||
)
|
)
|
||||||
db.session.add(new_record)
|
db.session.add(new_record)
|
||||||
|
|
||||||
|
# ★ 如果关联了审批单,出库成功后更新审批单状态为"已完成"
|
||||||
|
if approval:
|
||||||
|
approval.status = 3 # 3-已完成
|
||||||
|
# updated_at 会在 commit 时由 SQLAlchemy 自动更新
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return outbound_no
|
return outbound_no
|
||||||
|
|
||||||
@ -525,3 +548,336 @@ class OutboundService:
|
|||||||
'pages': pagination.pages,
|
'pages': pagination.pages,
|
||||||
'current_page': page
|
'current_page': page
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class OutboundApprovalService:
|
||||||
|
"""出库审批服务"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_request_no():
|
||||||
|
"""
|
||||||
|
生成审批单号: APR-OUT-yyyyMMdd-HHmm-当日流水(4位)
|
||||||
|
"""
|
||||||
|
beijing_tz = timezone(timedelta(hours=8))
|
||||||
|
now = datetime.now(beijing_tz)
|
||||||
|
|
||||||
|
date_str = now.strftime('%Y%m%d')
|
||||||
|
time_str = now.strftime('%H%M')
|
||||||
|
|
||||||
|
prefix = f"APR-OUT-{date_str}-"
|
||||||
|
|
||||||
|
from app.models.outbound import OutboundApproval
|
||||||
|
latest = db.session.query(OutboundApproval.request_no).filter(
|
||||||
|
OutboundApproval.request_no.like(f"{prefix}%")
|
||||||
|
).order_by(OutboundApproval.id.desc()).first()
|
||||||
|
|
||||||
|
if latest:
|
||||||
|
last_seq = int(latest[0].split('-')[-1])
|
||||||
|
sequence = last_seq + 1
|
||||||
|
else:
|
||||||
|
sequence = 1
|
||||||
|
|
||||||
|
return f"APR-OUT-{date_str}-{time_str}-{sequence:04d}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_request(applicant_id, items, allowed_approvers, remark=None, approver_id=None):
|
||||||
|
"""
|
||||||
|
创建出库审批单(申请阶段,直接存储前端传来的物料信息快照,不关联具体库存记录)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
applicant_id: 申请人ID
|
||||||
|
items: 出库物品明细列表,每个物品应包含:
|
||||||
|
- name: 物料名称 (必填)
|
||||||
|
- spec_model: 规格型号 (必填)
|
||||||
|
- quantity: 计划出库数量 (必填)
|
||||||
|
- warehouse_location: 库位 (可选)
|
||||||
|
- remark: 物品备注 (可选)
|
||||||
|
allowed_approvers: 允许审批的人员/角色列表
|
||||||
|
approver_id: 指定审批人ID(可选,传则覆盖 allowed_approvers)
|
||||||
|
remark: 申请说明
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
OutboundApproval 实例
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: 当 items 为空或缺少必填字段时抛出
|
||||||
|
"""
|
||||||
|
from app.models.outbound import OutboundApproval
|
||||||
|
|
||||||
|
# 校验 items 非空
|
||||||
|
if not items:
|
||||||
|
raise ValueError("出库物品列表不能为空")
|
||||||
|
|
||||||
|
# 校验每个物品的宏观字段 (name, spec_model, quantity)
|
||||||
|
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)}")
|
||||||
|
|
||||||
|
# ★ 校验 allowed_approvers 非空
|
||||||
|
if not allowed_approvers:
|
||||||
|
raise ValueError("必须指定至少一位审批人")
|
||||||
|
|
||||||
|
# ★ 指定审批人模式:approver_id 覆盖 allowed_approvers
|
||||||
|
if approver_id:
|
||||||
|
allowed_approvers = [{"type": "user", "value": int(approver_id)}]
|
||||||
|
|
||||||
|
request_no = OutboundApprovalService.generate_request_no()
|
||||||
|
|
||||||
|
approval = OutboundApproval(
|
||||||
|
request_no=request_no,
|
||||||
|
applicant_id=applicant_id,
|
||||||
|
remark=remark,
|
||||||
|
status=0, # 待审批
|
||||||
|
)
|
||||||
|
|
||||||
|
# 直接存储前端传来的物料信息快照,不查询/不关联具体库存记录
|
||||||
|
approval.set_items(items)
|
||||||
|
approval.set_allowed_approvers(allowed_approvers)
|
||||||
|
|
||||||
|
db.session.add(approval)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# ★ 创建成功后,发送邮件通知审批人(精确通知 approver_id 对应的邮箱)
|
||||||
|
OutboundApprovalService._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: 角色代码列表,如 ['ADMIN', 'WAREHOUSE_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_new_request_notify
|
||||||
|
|
||||||
|
emails = []
|
||||||
|
|
||||||
|
if approver_id:
|
||||||
|
# ★ 精准通知模式:直接查询指定审批人
|
||||||
|
user = SysUser.query.get(int(approver_id))
|
||||||
|
if user and user.email:
|
||||||
|
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', ''))
|
||||||
|
emails = OutboundApprovalService._get_emails_by_identifiers(role_codes=role_codes)
|
||||||
|
|
||||||
|
if not emails:
|
||||||
|
current_app.logger.info(f"[Email] 审批单 {approval.request_no} 无审批人邮箱,跳过通知")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 获取申请人姓名
|
||||||
|
applicant_name = ''
|
||||||
|
if applicant_id:
|
||||||
|
u = SysUser.query.get(applicant_id)
|
||||||
|
if u:
|
||||||
|
# username 格式为 "姓名/账号",取姓名部分
|
||||||
|
applicant_name = str(u.username).split('/')[0] if '/' in u.username else (u.username or str(applicant_id))
|
||||||
|
|
||||||
|
send_new_request_notify(
|
||||||
|
to_emails=emails,
|
||||||
|
request_no=approval.request_no,
|
||||||
|
applicant_name=applicant_name,
|
||||||
|
remark=approval.remark or ''
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# ★ 捕获所有异常,确保邮件发送失败不阻断主流程
|
||||||
|
try:
|
||||||
|
from flask import current_app
|
||||||
|
current_app.logger.error(f"[Email] 发送新申请通知邮件失败: {e}")
|
||||||
|
except RuntimeError:
|
||||||
|
# 如果不在 Flask 应用上下文内,降级为标准日志
|
||||||
|
logging.getLogger(__name__).error(f"[Email] 发送新申请通知邮件失败: {e}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def can_approve(approval, user_id, user_role):
|
||||||
|
"""
|
||||||
|
检查用户是否有权限审批
|
||||||
|
|
||||||
|
Args:
|
||||||
|
approval: OutboundApproval 实例
|
||||||
|
user_id: 用户ID
|
||||||
|
user_role: 用户角色
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool, 是否有权限
|
||||||
|
"""
|
||||||
|
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):
|
||||||
|
"""
|
||||||
|
执行审批操作
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request_id: 审批单ID
|
||||||
|
user_id: 审批人ID
|
||||||
|
user_role: 审批人角色
|
||||||
|
action: 'approve' 通过, 'reject' 驳回
|
||||||
|
reject_reason: 驳回原因
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(success: bool, message: str, approval: OutboundApproval or None)
|
||||||
|
"""
|
||||||
|
from app.models.outbound import OutboundApproval
|
||||||
|
|
||||||
|
beijing_tz = timezone(timedelta(hours=8))
|
||||||
|
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
|
||||||
|
|
||||||
|
approval = OutboundApproval.query.get(request_id)
|
||||||
|
if not approval:
|
||||||
|
return False, "审批单不存在", None
|
||||||
|
|
||||||
|
if approval.status != 0:
|
||||||
|
return False, f"审批单状态已更新,无法重复审批 (当前状态: {approval.status})", None
|
||||||
|
|
||||||
|
if not OutboundApprovalService.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()
|
||||||
|
|
||||||
|
# ★ 审批成功后,发送邮件通知仓库管理员
|
||||||
|
OutboundApprovalService._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 _notify_approval_result(approval, approver_id, action):
|
||||||
|
"""发送审批结果通知邮件(静默处理,不阻断主流程)"""
|
||||||
|
try:
|
||||||
|
from app.utils.email_service import send_approval_result_notify
|
||||||
|
|
||||||
|
# 仓库管理员角色代码
|
||||||
|
warehouse_role_codes = ['WAREHOUSE_MGR', 'OUTBOUND']
|
||||||
|
|
||||||
|
# 查询库管邮箱 + 申请人本人邮箱
|
||||||
|
emails = OutboundApprovalService._get_emails_by_identifiers(
|
||||||
|
applicant_id=approval.applicant_id,
|
||||||
|
role_codes=warehouse_role_codes
|
||||||
|
)
|
||||||
|
if not emails:
|
||||||
|
return
|
||||||
|
|
||||||
|
send_approval_result_notify(
|
||||||
|
to_emails=emails,
|
||||||
|
request_no=approval.request_no,
|
||||||
|
is_passed=(action == 'approve'),
|
||||||
|
reject_reason=approval.reject_reason or ''
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logging.getLogger(__name__).warning(f"[Email] 发送审批结果通知邮件失败: {e}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_request_list(page=1, per_page=10, applicant_id=None, status=None):
|
||||||
|
"""
|
||||||
|
获取审批单列表
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page: 页码
|
||||||
|
per_page: 每页数量
|
||||||
|
applicant_id: 按申请人筛选 (可选)
|
||||||
|
status: 按状态筛选 (可选)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
分页结果
|
||||||
|
"""
|
||||||
|
from app.models.outbound import OutboundApproval
|
||||||
|
from sqlalchemy import desc
|
||||||
|
|
||||||
|
query = OutboundApproval.query
|
||||||
|
|
||||||
|
if applicant_id:
|
||||||
|
query = query.filter(OutboundApproval.applicant_id == applicant_id)
|
||||||
|
|
||||||
|
if status is not None:
|
||||||
|
query = query.filter(OutboundApproval.status == status)
|
||||||
|
|
||||||
|
query = query.order_by(desc(OutboundApproval.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获取审批单"""
|
||||||
|
from app.models.outbound import OutboundApproval
|
||||||
|
return OutboundApproval.query.get(request_id)
|
||||||
|
|||||||
@ -435,6 +435,7 @@ class PermissionService:
|
|||||||
('outbound_selection', '出库选单', '/outbound/selection', 'outbound_mgmt', 1),
|
('outbound_selection', '出库选单', '/outbound/selection', 'outbound_mgmt', 1),
|
||||||
('outbound_create', '扫码出库', '/outbound/create', 'outbound_mgmt', 2),
|
('outbound_create', '扫码出库', '/outbound/create', 'outbound_mgmt', 2),
|
||||||
('outbound_list', '出库记录', '/outbound/index', 'outbound_mgmt', 3),
|
('outbound_list', '出库记录', '/outbound/index', 'outbound_mgmt', 3),
|
||||||
|
('outbound_approval', '出库审批', '/outbound/approval', 'outbound_mgmt', 4),
|
||||||
|
|
||||||
# BOM管理子菜单
|
# BOM管理子菜单
|
||||||
('bom_manage', 'BOM配方管理', '/bom/manage', 'bom_mgmt', 1),
|
('bom_manage', 'BOM配方管理', '/bom/manage', 'bom_mgmt', 1),
|
||||||
|
|||||||
168
inventory-backend/app/utils/email_service.py
Normal file
168
inventory-backend/app/utils/email_service.py
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
"""
|
||||||
|
邮件通知服务
|
||||||
|
使用 Python smtplib + email.mime 实现,支持 TLS/SSL SMTP 连接
|
||||||
|
从环境变量或 Flask config 读取邮件配置
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import smtplib
|
||||||
|
import ssl
|
||||||
|
import logging
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.header import Header
|
||||||
|
from typing import List, Union
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_config():
|
||||||
|
"""
|
||||||
|
读取邮件配置,优先从 Flask app config,回退到环境变量
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from flask import current_app
|
||||||
|
return {
|
||||||
|
'server': current_app.config.get('MAIL_SERVER', os.getenv('MAIL_SERVER')),
|
||||||
|
'port': current_app.config.get('MAIL_PORT', int(os.getenv('MAIL_PORT', 587))),
|
||||||
|
'username': current_app.config.get('MAIL_USERNAME', os.getenv('MAIL_USERNAME')),
|
||||||
|
'password': current_app.config.get('MAIL_PASSWORD', os.getenv('MAIL_PASSWORD')),
|
||||||
|
'sender': current_app.config.get('MAIL_DEFAULT_SENDER', os.getenv('MAIL_DEFAULT_SENDER')),
|
||||||
|
'use_tls': current_app.config.get('MAIL_USE_TLS', os.getenv('MAIL_USE_TLS', 'true').lower() in ('true', '1', 'yes')),
|
||||||
|
'use_ssl': current_app.config.get('MAIL_USE_SSL', os.getenv('MAIL_USE_SSL', 'false').lower() in ('true', '1', 'yes')),
|
||||||
|
'enabled': current_app.config.get('MAIL_ENABLED', os.getenv('MAIL_ENABLED', 'false').lower() in ('true', '1', 'yes')),
|
||||||
|
}
|
||||||
|
except RuntimeError:
|
||||||
|
# 不在 Flask 上下文时,直接读环境变量
|
||||||
|
return {
|
||||||
|
'server': os.getenv('MAIL_SERVER'),
|
||||||
|
'port': int(os.getenv('MAIL_PORT', 587)),
|
||||||
|
'username': os.getenv('MAIL_USERNAME'),
|
||||||
|
'password': os.getenv('MAIL_PASSWORD'),
|
||||||
|
'sender': os.getenv('MAIL_DEFAULT_SENDER'),
|
||||||
|
'use_tls': os.getenv('MAIL_USE_TLS', 'true').lower() in ('true', '1', 'yes'),
|
||||||
|
'use_ssl': os.getenv('MAIL_USE_SSL', 'false').lower() in ('true', '1', 'yes'),
|
||||||
|
'enabled': os.getenv('MAIL_ENABLED', 'false').lower() in ('true', '1', 'yes'),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def send_email(to_email: Union[str, List[str]], subject: str, content: str):
|
||||||
|
"""
|
||||||
|
通用邮件发送函数
|
||||||
|
|
||||||
|
Args:
|
||||||
|
to_email: 收件人,单个邮箱字符串或列表
|
||||||
|
subject: 邮件主题
|
||||||
|
content: 邮件正文(纯文本)
|
||||||
|
|
||||||
|
发送失败时打印日志,不抛出异常
|
||||||
|
"""
|
||||||
|
cfg = _get_config()
|
||||||
|
|
||||||
|
# 发送总开关
|
||||||
|
if not cfg.get('enabled'):
|
||||||
|
logger.info(f"[Email] 邮件功能已禁用 (MAIL_ENABLED=false),跳过发送: {subject}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 配置完整性检查
|
||||||
|
if not cfg.get('server') or not cfg.get('username') or not cfg.get('password'):
|
||||||
|
logger.warning("[Email] 邮件配置不完整 (MAIL_SERVER/USERNAME/PASSWORD 缺失),跳过发送")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 标准化收件人列表
|
||||||
|
recipients = [to_email] if isinstance(to_email, str) else [r.strip() for r in to_email if r.strip()]
|
||||||
|
if not recipients:
|
||||||
|
logger.warning("[Email] 收件人地址为空,跳过发送")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg = MIMEMultipart()
|
||||||
|
msg['From'] = cfg['sender']
|
||||||
|
msg['To'] = ', '.join(recipients)
|
||||||
|
msg['Subject'] = Header(subject, 'utf-8')
|
||||||
|
msg.attach(MIMEText(content, 'plain', 'utf-8'))
|
||||||
|
|
||||||
|
if cfg.get('use_ssl'):
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
with smtplib.SMTP_SSL(cfg['server'], cfg.get('port', 465), context=context) as server:
|
||||||
|
server.login(cfg['username'], cfg['password'])
|
||||||
|
server.sendmail(cfg['username'], recipients, msg.as_string())
|
||||||
|
else:
|
||||||
|
with smtplib.SMTP(cfg['server'], cfg.get('port', 587)) as server:
|
||||||
|
if cfg.get('use_tls'):
|
||||||
|
server.starttls(context=ssl.create_default_context())
|
||||||
|
server.login(cfg['username'], cfg['password'])
|
||||||
|
server.sendmail(cfg['username'], recipients, msg.as_string())
|
||||||
|
|
||||||
|
logger.info(f"[Email] 发送成功 -> {recipients}: {subject}")
|
||||||
|
|
||||||
|
except smtplib.SMTPAuthenticationError:
|
||||||
|
logger.error("[Email] 邮箱认证失败,请检查 MAIL_USERNAME / MAIL_PASSWORD(授权码)")
|
||||||
|
except smtplib.SMTPRecipientsRefused as e:
|
||||||
|
logger.error(f"[Email] 收件人被服务器拒绝: {e}")
|
||||||
|
except smtplib.SMTPException as e:
|
||||||
|
logger.error(f"[Email] SMTP 异常: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Email] 发送邮件时发生未知异常: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def send_new_request_notify(to_emails: List[str], request_no: str,
|
||||||
|
applicant_name: str = '', remark: str = ''):
|
||||||
|
"""
|
||||||
|
通知审批人有新的出库申请单待审批
|
||||||
|
|
||||||
|
Args:
|
||||||
|
to_emails: 审批人邮箱列表
|
||||||
|
request_no: 审批单号
|
||||||
|
applicant_name: 申请人姓名
|
||||||
|
remark: 申请备注
|
||||||
|
"""
|
||||||
|
subject = f"【待审批】出库申请单 {request_no}"
|
||||||
|
content = f"""您好,
|
||||||
|
|
||||||
|
您有一笔新的出库审批申请待处理:
|
||||||
|
|
||||||
|
申请单号:{request_no}
|
||||||
|
申请人:{applicant_name or '未知'}
|
||||||
|
备注说明:{remark or '无'}
|
||||||
|
|
||||||
|
请登录仓库管理系统进行审批。
|
||||||
|
|
||||||
|
此邮件由系统自动发送,请勿回复。
|
||||||
|
"""
|
||||||
|
send_email(to_emails, subject, content)
|
||||||
|
|
||||||
|
|
||||||
|
def send_approval_result_notify(to_emails: List[str], request_no: str,
|
||||||
|
is_passed: bool, reject_reason: str = ''):
|
||||||
|
"""
|
||||||
|
通知库管和申请人审批结果
|
||||||
|
|
||||||
|
Args:
|
||||||
|
to_emails: 收件人邮箱列表(库管 + 申请人)
|
||||||
|
request_no: 审批单号
|
||||||
|
is_passed: 是否通过
|
||||||
|
reject_reason: 驳回原因(仅 is_passed=False 时使用)
|
||||||
|
"""
|
||||||
|
if is_passed:
|
||||||
|
subject = f"【已通过】出库申请单 {request_no}"
|
||||||
|
content = f"""您好,
|
||||||
|
|
||||||
|
出库申请单 {request_no} 已审批通过,请准备备货。
|
||||||
|
|
||||||
|
请尽快安排出库操作。
|
||||||
|
|
||||||
|
此邮件由系统自动发送,请勿回复。
|
||||||
|
"""
|
||||||
|
else:
|
||||||
|
subject = f"【已驳回】出库申请单 {request_no}"
|
||||||
|
content = f"""您好,
|
||||||
|
|
||||||
|
出库申请单 {request_no} 已被驳回。
|
||||||
|
|
||||||
|
驳回原因:{reject_reason or '未填写'}
|
||||||
|
|
||||||
|
请登录仓库管理系统查看详情。
|
||||||
|
|
||||||
|
此邮件由系统自动发送,请勿回复。
|
||||||
|
"""
|
||||||
|
send_email(to_emails, subject, content)
|
||||||
@ -49,3 +49,23 @@ class Config:
|
|||||||
# 5. Redis 配置 (用于单设备登录互踢)
|
# 5. Redis 配置 (用于单设备登录互踢)
|
||||||
# =========================================================
|
# =========================================================
|
||||||
REDIS_URL = os.getenv('REDIS_URL', 'redis://localhost:6379/0')
|
REDIS_URL = os.getenv('REDIS_URL', 'redis://localhost:6379/0')
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# 6. 邮件配置
|
||||||
|
# =========================================================
|
||||||
|
# 发件人邮箱(阿里企业邮箱)
|
||||||
|
MAIL_USERNAME = os.getenv('MAIL_USERNAME', 'wms@iris-rs.cn')
|
||||||
|
# 发件人邮箱密码 / 授权码
|
||||||
|
MAIL_PASSWORD = os.getenv('MAIL_PASSWORD', 'Q7nYyyESWlaThKjx')
|
||||||
|
# SMTP 服务器地址(阿里企业邮发信服务器)
|
||||||
|
MAIL_SERVER = os.getenv('MAIL_SERVER', 'smtp.mxhichina.com')
|
||||||
|
# SMTP 端口(阿里邮箱使用 SSL 465)
|
||||||
|
MAIL_PORT = int(os.getenv('MAIL_PORT', 465))
|
||||||
|
# 是否启用 TLS (587 端口通常需要)
|
||||||
|
MAIL_USE_TLS = os.getenv('MAIL_USE_TLS', 'false').lower() in ('true', '1', 'yes')
|
||||||
|
# 是否启用 SSL (465 端口通常需要,阿里邮箱必须启用 SSL)
|
||||||
|
MAIL_USE_SSL = os.getenv('MAIL_USE_SSL', 'true').lower() in ('true', '1', 'yes')
|
||||||
|
# 默认发件人(显示名称 <邮箱地址>,阿里要求 MAIL_USERNAME 与此处邮箱地址完全一致)
|
||||||
|
MAIL_DEFAULT_SENDER = os.getenv('MAIL_DEFAULT_SENDER', 'WMS系统 <wms@iris-rs.cn>')
|
||||||
|
# 是否启用邮件发送功能(开发环境可设为 false 禁用)
|
||||||
|
MAIL_ENABLED = os.getenv('MAIL_ENABLED', 'true').lower() in ('true', '1', 'yes')
|
||||||
@ -85,3 +85,11 @@ export function batchCreateUser(data: any[]) {
|
|||||||
data
|
data
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ★ 获取可指定审批人列表(SUPERVISOR / SUPER_ADMIN 且 status=active)
|
||||||
|
export function getApproversList() {
|
||||||
|
return request({
|
||||||
|
url: '/v1/auth/users/approvers',
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -78,3 +78,48 @@ export function getOutboundList(params: any) {
|
|||||||
params
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -150,6 +150,16 @@ const routes: Array<RouteRecordRaw> = [
|
|||||||
name: 'OutboundList',
|
name: 'OutboundList',
|
||||||
component: () => import('@/views/outbound/index.vue'),
|
component: () => import('@/views/outbound/index.vue'),
|
||||||
meta: { title: '出库记录' }
|
meta: { title: '出库记录' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'approval',
|
||||||
|
name: 'OutboundApproval',
|
||||||
|
component: () => import('@/views/outbound/approval/index.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '出库审批',
|
||||||
|
icon: 'Stamp',
|
||||||
|
roles: ['SUPER_ADMIN', 'SUPERVISOR']
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@ -83,13 +83,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-option>
|
</el-option>
|
||||||
</el-select>
|
</el-select>
|
||||||
|
<el-link
|
||||||
|
v-if="form.parent_id"
|
||||||
|
type="primary"
|
||||||
|
:underline="false"
|
||||||
|
style="margin-left: 12px; font-size: 13px;"
|
||||||
|
@click="openParentMaterial"
|
||||||
|
>
|
||||||
|
<el-icon style="margin-right: 4px"><EditPen /></el-icon>前往修改基础信息
|
||||||
|
</el-link>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="20">
|
||||||
<el-col :span="8">
|
<el-col :span="8">
|
||||||
<el-form-item label="是否启用" prop="is_enabled" v-if="hasFormFieldPermission('is_enabled')">
|
<el-form-item label="是否启用" prop="is_enabled" v-if="hasFormFieldPermission('is_enabled')">
|
||||||
<el-switch v-model="form.is_enabled" active-text="启用" inactive-text="禁用" :disabled="!userStore.hasPermission('bom_manage:operation')" />
|
<el-switch v-model="form.is_enabled" active-text="启用" inactive-text="禁用" :disabled="!userStore.hasPermission('bom_manage:operation')" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
|
<el-col :span="16"></el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
@ -135,18 +148,19 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<el-table :data="filteredChildren" border style="width: 100%; margin-bottom: 15px" max-height="300">
|
<el-table :data="filteredChildren" border style="width: 100%; margin-bottom: 15px" max-height="300">
|
||||||
<el-table-column label="子件物料" min-width="280" v-if="hasFormFieldPermission('child_id')">
|
<el-table-column label="子件物料" min-width="250" v-if="hasFormFieldPermission('child_id')">
|
||||||
<template #default="{ row, $index }">
|
<template #default="{ row, $index }">
|
||||||
<!-- ====== 改造:子件下拉 - 远程搜索 + 懒加载 ====== -->
|
<!-- ====== 改造:子件下拉 - 远程搜索 + 懒加载 ====== -->
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px;">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="row.child_id"
|
v-model="row.child_id"
|
||||||
placeholder="请搜索原料"
|
placeholder="请搜索原料"
|
||||||
filterable
|
filterable
|
||||||
remote
|
remote
|
||||||
reserve-keyword
|
reserve-keyword
|
||||||
|
style="flex: 1;"
|
||||||
:remote-method="(q: string) => handleRemoteSearch(q, 'child', $index)"
|
:remote-method="(q: string) => handleRemoteSearch(q, 'child', $index)"
|
||||||
:loading="selectLoading"
|
:loading="selectLoading"
|
||||||
style="width: 100%"
|
|
||||||
:loading-text="`正在加载第 ${childQueryParams.page} 页...`"
|
:loading-text="`正在加载第 ${childQueryParams.page} 页...`"
|
||||||
:popper-class="`bom-loadmore-popper child-popper-${$index}`"
|
:popper-class="`bom-loadmore-popper child-popper-${$index}`"
|
||||||
@visible-change="(visible: boolean) => handleVisibleChange(visible, 'child', $index)"
|
@visible-change="(visible: boolean) => handleVisibleChange(visible, 'child', $index)"
|
||||||
@ -163,6 +177,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-option>
|
</el-option>
|
||||||
</el-select>
|
</el-select>
|
||||||
|
<el-tooltip content="前往修改基础信息" placement="top" v-if="row.child_id">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
:icon="EditPen"
|
||||||
|
@click.stop="openMaterialInNewTab(row.child_id, getChildSpec($index))"
|
||||||
|
style="font-size: 16px; padding: 4px;"
|
||||||
|
/>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
@ -204,7 +228,8 @@
|
|||||||
import { ref, reactive, onMounted, computed, nextTick } from 'vue'
|
import { ref, reactive, onMounted, computed, nextTick } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus'
|
import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus'
|
||||||
import { Plus, Search } from '@element-plus/icons-vue'
|
import { Plus, Search, EditPen } from '@element-plus/icons-vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { getBomList, getBomDetail, saveBom, deleteBom } from '@/api/bom'
|
import { getBomList, getBomDetail, saveBom, deleteBom } from '@/api/bom'
|
||||||
import { getMaterialBaseList } from '@/api/inbound/stock'
|
import { getMaterialBaseList } from '@/api/inbound/stock'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
@ -233,6 +258,7 @@ interface ChildRow {
|
|||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const dialogVisible = ref(false)
|
const dialogVisible = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
@ -467,6 +493,31 @@ const filteredChildren = computed(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 获取子件规格(从 childDropdownStates 缓存中查找)
|
||||||
|
const getChildSpec = (index: number): string => {
|
||||||
|
const state = childDropdownStates.value.get(index)
|
||||||
|
if (!state || !form.children[index]?.child_id) return ''
|
||||||
|
const material = state.options.find((m: MaterialBase) => m.id === form.children[index].child_id)
|
||||||
|
return material?.spec || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在新标签页打开基础信息编辑
|
||||||
|
const openMaterialInNewTab = (targetId: number | null, keyword: string = '') => {
|
||||||
|
if (!targetId) return ElMessage.warning('请先选择物料')
|
||||||
|
const routeUrl = router.resolve({
|
||||||
|
path: '/material',
|
||||||
|
query: { edit_id: targetId, keyword }
|
||||||
|
})
|
||||||
|
window.open(routeUrl.href, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
const openParentMaterial = () => {
|
||||||
|
if (!form.parent_id) return ElMessage.warning('请先选择父件')
|
||||||
|
const parent = parentOptions.value.find((p: MaterialBase) => p.id === form.parent_id)
|
||||||
|
const keyword = parent?.spec || parent?.name || ''
|
||||||
|
openMaterialInNewTab(form.parent_id, keyword)
|
||||||
|
}
|
||||||
|
|
||||||
// 列与权限Code的映射关系(数据库中的code)
|
// 列与权限Code的映射关系(数据库中的code)
|
||||||
const permissionMap: Record<string, string> = {
|
const permissionMap: Record<string, string> = {
|
||||||
bom_no: 'bom_manage:bom_no',
|
bom_no: 'bom_manage:bom_no',
|
||||||
|
|||||||
@ -326,7 +326,7 @@
|
|||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="dialog.visible"
|
v-model="dialog.visible"
|
||||||
width="700px"
|
width="1200px"
|
||||||
append-to-body
|
append-to-body
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
@close="cancel"
|
@close="cancel"
|
||||||
|
|||||||
@ -37,6 +37,9 @@
|
|||||||
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="success" :icon="Printer" :disabled="selectedItems.length === 0" @click="handlePreview">
|
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="success" :icon="Printer" :disabled="selectedItems.length === 0" @click="handlePreview">
|
||||||
生成预览 & 打印
|
生成预览 & 打印
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="primary" :icon="Plus" :disabled="selectedItems.length === 0" @click="openRequestDialog">
|
||||||
|
提交出库申请
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -289,6 +292,80 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- ★ 出库申请 Dialog -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="requestDialogVisible"
|
||||||
|
title="提交出库申请"
|
||||||
|
width="700px"
|
||||||
|
destroy-on-close
|
||||||
|
class="no-print-content"
|
||||||
|
>
|
||||||
|
<el-alert
|
||||||
|
title="请确认以下物料申请清单,填写申请原因后提交"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
style="margin-bottom: 16px;"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-table :data="validSelectedItems" border size="small" style="margin-bottom: 16px;">
|
||||||
|
<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="getTypeTag(row.type)">{{ row.typeLabel }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="name" label="名称" min-width="120" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="standard" label="规格" min-width="120" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="warehouse_location" label="库位" width="120" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="export_quantity" label="计划数量" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span style="color: #F56C6C; font-weight: bold;">{{ row.export_quantity }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-form label-width="80px">
|
||||||
|
<el-form-item label="* 指定审批人" required>
|
||||||
|
<el-select
|
||||||
|
v-model="requestApproverId"
|
||||||
|
placeholder="请选择审批人"
|
||||||
|
style="width: 100%"
|
||||||
|
filterable
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="user in approvers"
|
||||||
|
:key="user.id"
|
||||||
|
:label="`${user.username} (${user.role === 'SUPER_ADMIN' ? '超级管理员' : '主管'})`"
|
||||||
|
:value="user.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="申请原因" required>
|
||||||
|
<el-input
|
||||||
|
v-model="requestRemark"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="请填写出库申请原因(必填)"
|
||||||
|
maxlength="200"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<span class="dialog-footer">
|
||||||
|
<el-button @click="requestDialogVisible = false">取消</el-button>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="requestSubmitting"
|
||||||
|
@click="confirmSubmitRequest"
|
||||||
|
>
|
||||||
|
确认提交
|
||||||
|
</el-button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<div id="print-area">
|
<div id="print-area">
|
||||||
<div class="print-header">
|
<div class="print-header">
|
||||||
<h1>IRIS出库拣货确认单</h1>
|
<h1>IRIS出库拣货确认单</h1>
|
||||||
@ -358,6 +435,8 @@ import { ElMessage, ElTable, ElMessageBox } from 'element-plus'
|
|||||||
import { getAllStock, getStockList, printSelectionList } from '@/api/inbound/stock'
|
import { getAllStock, getStockList, printSelectionList } from '@/api/inbound/stock'
|
||||||
import { getBomList, getBomDetail } from '@/api/bom'
|
import { getBomList, getBomDetail } from '@/api/bom'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
|
import { submitOutboundRequest } from '@/api/outbound'
|
||||||
|
import { getApproversList } from '@/api/auth'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
@ -381,6 +460,13 @@ const previewVisible = ref(false)
|
|||||||
const exportLoading = ref(false)
|
const exportLoading = ref(false)
|
||||||
const printLoading = ref(false)
|
const printLoading = ref(false)
|
||||||
|
|
||||||
|
// ★ 出库申请相关
|
||||||
|
const requestDialogVisible = ref(false)
|
||||||
|
const requestRemark = ref('')
|
||||||
|
const requestApproverId = ref<number | null>(null)
|
||||||
|
const approvers = ref<any[]>([])
|
||||||
|
const requestSubmitting = ref(false)
|
||||||
|
|
||||||
const allStockData = ref<any[]>([])
|
const allStockData = ref<any[]>([])
|
||||||
const stockList = ref<any[]>([]) // 服务端分页数据
|
const stockList = ref<any[]>([]) // 服务端分页数据
|
||||||
const stockTotal = ref(0)
|
const stockTotal = ref(0)
|
||||||
@ -511,7 +597,8 @@ const loadStockList = async () => {
|
|||||||
const res: any = await getStockList({
|
const res: any = await getStockList({
|
||||||
page: stockPage.value,
|
page: stockPage.value,
|
||||||
pageSize: stockPageSize.value,
|
pageSize: stockPageSize.value,
|
||||||
keyword: searchKeyword.value.trim()
|
keyword: searchKeyword.value.trim(),
|
||||||
|
is_aggregated: true // ★ 触发后端按规格+库位合并
|
||||||
})
|
})
|
||||||
// 为每个item添加uniqueKey和确保warehouse_location字段正确映射
|
// 为每个item添加uniqueKey和确保warehouse_location字段正确映射
|
||||||
stockList.value = (res.data?.list || []).map((item: any) => ({
|
stockList.value = (res.data?.list || []).map((item: any) => ({
|
||||||
@ -649,7 +736,7 @@ watch(selectedBomNo, async (newBomNo) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const detailRes = await getBomDetail(newBomNo)
|
const detailRes: any = await getBomDetail(newBomNo)
|
||||||
currentBomDetail.value = detailRes.data?.children || []
|
currentBomDetail.value = detailRes.data?.children || []
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error('加载 BOM 明细失败')
|
ElMessage.error('加载 BOM 明细失败')
|
||||||
@ -666,7 +753,7 @@ const confirmBomAdd = async () => {
|
|||||||
|
|
||||||
if (currentBomDetail.value.length === 0) {
|
if (currentBomDetail.value.length === 0) {
|
||||||
try {
|
try {
|
||||||
const detailRes = await getBomDetail(selectedBomNo.value)
|
const detailRes: any = await getBomDetail(selectedBomNo.value)
|
||||||
currentBomDetail.value = detailRes.data?.children || []
|
currentBomDetail.value = detailRes.data?.children || []
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error('获取 BOM 详情失败')
|
ElMessage.error('获取 BOM 详情失败')
|
||||||
@ -794,6 +881,67 @@ const handlePreview = () => {
|
|||||||
previewVisible.value = true
|
previewVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ★ 出库申请
|
||||||
|
const openRequestDialog = () => {
|
||||||
|
if (validSelectedItems.value.length === 0) {
|
||||||
|
ElMessage.warning('请先添加物品并填写计划出库数量')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
requestRemark.value = ''
|
||||||
|
requestApproverId.value = null
|
||||||
|
loadApprovers()
|
||||||
|
requestDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 加载可指定审批人列表
|
||||||
|
const loadApprovers = async () => {
|
||||||
|
try {
|
||||||
|
const res: any = await getApproversList()
|
||||||
|
approvers.value = res.data || []
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载审批人列表失败', e)
|
||||||
|
approvers.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmSubmitRequest = async () => {
|
||||||
|
const trimmed = requestRemark.value.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
ElMessage.warning('请填写申请原因')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!requestApproverId.value) {
|
||||||
|
ElMessage.warning('请选择指定审批人')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
requestSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const payload: any = {
|
||||||
|
items: validSelectedItems.value.map(item => ({
|
||||||
|
material_type: item.typeLabel || item.type || '',
|
||||||
|
name: item.name || '',
|
||||||
|
spec_model: item.standard || '',
|
||||||
|
warehouse_location: item.warehouse_location || '',
|
||||||
|
quantity: item.export_quantity || 0
|
||||||
|
})),
|
||||||
|
remark: trimmed,
|
||||||
|
approver_id: requestApproverId.value
|
||||||
|
}
|
||||||
|
|
||||||
|
await submitOutboundRequest(payload)
|
||||||
|
|
||||||
|
// 成功:关闭弹窗、清空列表、提示
|
||||||
|
requestDialogVisible.value = false
|
||||||
|
selectedItems.value = []
|
||||||
|
ElMessage.success('出库申请已提交,等待主管审批!')
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage.error(err?.message || err?.msg || '提交申请失败,请重试')
|
||||||
|
} finally {
|
||||||
|
requestSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const confirmPrint = async () => {
|
const confirmPrint = async () => {
|
||||||
previewVisible.value = false;
|
previewVisible.value = false;
|
||||||
|
|
||||||
|
|||||||
375
inventory-web/src/views/outbound/approval/index.vue
Normal file
375
inventory-web/src/views/outbound/approval/index.vue
Normal file
@ -0,0 +1,375 @@
|
|||||||
|
<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 label="类型" width="90" align="center">
|
||||||
|
<template #default="{ row: item }">
|
||||||
|
<el-tag size="small">{{ item.material_type || '-' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<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('outbound_approval:operation')"
|
||||||
|
type="success"
|
||||||
|
size="small"
|
||||||
|
:loading="row._approving"
|
||||||
|
@click="handleApprove(row)"
|
||||||
|
>
|
||||||
|
通过
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="userStore.hasPermission('outbound_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 { getApprovalRequestList, approveRequest } from '@/api/outbound'
|
||||||
|
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 = () => {
|
||||||
|
// expand 状态由 expandedRows 响应式控制,无需额外处理
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 数据获取 ---
|
||||||
|
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 getApprovalRequestList(params)
|
||||||
|
|
||||||
|
// 追加申请人名称缓存
|
||||||
|
const records = res.data?.items || []
|
||||||
|
records.forEach((r: any) => {
|
||||||
|
if (r.applicant_id && !userNameCache.value[r.applicant_id]) {
|
||||||
|
// 后端已返回 applicant_name 字段时直接用,否则标记待解析
|
||||||
|
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 approveRequest(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 approveRequest(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>
|
||||||
@ -15,6 +15,68 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- ★ 出库模式切换 -->
|
||||||
|
<div class="mode-switch-bar">
|
||||||
|
<el-radio-group v-model="outboundMode" size="default" @change="handleModeChange">
|
||||||
|
<el-radio-button value="by-request">按单出库</el-radio-button>
|
||||||
|
<el-radio-button value="direct">直接出库</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
<span class="mode-hint">
|
||||||
|
{{ outboundMode === 'by-request' ? '需先选择已审批通过的申请单' : '无需审批单,自由扫码出库' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ★ 按单出库:审批单选择 -->
|
||||||
|
<div v-if="outboundMode === 'by-request'" class="approval-request-select">
|
||||||
|
<el-select
|
||||||
|
v-model="selectedRequestId"
|
||||||
|
placeholder="请选择已审批通过的出库申请单"
|
||||||
|
filterable
|
||||||
|
clearable
|
||||||
|
style="width: 100%"
|
||||||
|
:loading="requestsLoading"
|
||||||
|
@change="handleRequestChange"
|
||||||
|
>
|
||||||
|
<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.applicant_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="outboundMode === 'by-request' && selectedRequest" class="planned-items-section">
|
||||||
|
<div class="planned-header">
|
||||||
|
<span class="planned-title">计划出库清单</span>
|
||||||
|
<el-tag type="success" size="small">{{ selectedRequest.items?.length || 0 }} 种</el-tag>
|
||||||
|
</div>
|
||||||
|
<el-table :data="selectedRequest.items || []" 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('outbound_create:operation')" class="camera-placeholder" @click="showCamera = true">
|
<div v-if="userStore.hasPermission('outbound_create:operation')" class="camera-placeholder" @click="showCamera = true">
|
||||||
@ -214,7 +276,7 @@ import { ref, reactive, nextTick, onUnmounted, onMounted, computed } from 'vue'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
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, submitOutbound, getOutboundList } from '@/api/outbound'
|
import { getStockByBarcode, submitOutbound, getOutboundList, getApprovalRequestList } from '@/api/outbound'
|
||||||
import { uploadFile } from '@/api/common/upload'
|
import { uploadFile } from '@/api/common/upload'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
@ -228,6 +290,12 @@ const showCamera = ref(false)
|
|||||||
const barcodeRef = ref()
|
const barcodeRef = ref()
|
||||||
const formRef = ref()
|
const formRef = ref()
|
||||||
|
|
||||||
|
// ★ 双轨制模式
|
||||||
|
const outboundMode = ref<'by-request' | 'direct'>('by-request') // 'by-request' | 'direct'
|
||||||
|
const approvalRequests = ref<any[]>([])
|
||||||
|
const selectedRequest = ref<any>(null)
|
||||||
|
const requestsLoading = ref(false)
|
||||||
|
|
||||||
// 签名相关
|
// 签名相关
|
||||||
const showSignatureDialog = ref(false)
|
const showSignatureDialog = ref(false)
|
||||||
const signaturePreviewUrl = ref('')
|
const signaturePreviewUrl = ref('')
|
||||||
@ -258,8 +326,95 @@ const totalAmount = computed(() => {
|
|||||||
return cartItems.value.reduce((sum, item) => sum + (item.price * item.out_quantity), 0)
|
return cartItems.value.reduce((sum, item) => sum + (item.price * item.out_quantity), 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ★ 双轨制 computed
|
||||||
|
const selectedRequestId = computed({
|
||||||
|
get: () => selectedRequest.value?.id ?? null,
|
||||||
|
set: (val) => {
|
||||||
|
if (!val) {
|
||||||
|
selectedRequest.value = null
|
||||||
|
} else {
|
||||||
|
selectedRequest.value = approvalRequests.value.find(r => r.id === val) ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const plannedItems = computed(() => selectedRequest.value?.items ?? [])
|
||||||
|
|
||||||
|
// ★ 模式切换
|
||||||
|
const handleModeChange = () => {
|
||||||
|
selectedRequest.value = null
|
||||||
|
selectedRequestId.value = null
|
||||||
|
cartItems.value = []
|
||||||
|
form.consumer_name = ''
|
||||||
|
form.remark = ''
|
||||||
|
signatureFile.value = null
|
||||||
|
signaturePreviewUrl.value = ''
|
||||||
|
barcodeInput.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 加载已审批通过的申请单
|
||||||
|
const loadApprovalRequests = async () => {
|
||||||
|
requestsLoading.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await getApprovalRequestList({ status: 1, page: 1, pageSize: 100 })
|
||||||
|
approvalRequests.value = res.data?.items || []
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载审批单列表失败', e)
|
||||||
|
} finally {
|
||||||
|
requestsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRequestChange = (val: number | null) => {
|
||||||
|
if (!val) {
|
||||||
|
selectedRequest.value = null
|
||||||
|
} else {
|
||||||
|
selectedRequest.value = approvalRequests.value.find(r => r.id === val) ?? null
|
||||||
|
}
|
||||||
|
// 切换申请单时清空购物车,防止已扫物品与新单据混淆
|
||||||
|
cartItems.value = []
|
||||||
|
signatureFile.value = null
|
||||||
|
signaturePreviewUrl.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 // 通过
|
||||||
|
}
|
||||||
|
|
||||||
// --- 初始化 ---
|
// --- 初始化 ---
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
// 加载已审批通过的申请单列表
|
||||||
|
loadApprovalRequests()
|
||||||
|
|
||||||
if (userStore.username) {
|
if (userStore.username) {
|
||||||
form.operator_name = userStore.username
|
form.operator_name = userStore.username
|
||||||
operatorOptions.value.push(userStore.username)
|
operatorOptions.value.push(userStore.username)
|
||||||
@ -313,15 +468,32 @@ const handleManualInput = async () => {
|
|||||||
const code = barcodeInput.value.trim()
|
const code = barcodeInput.value.trim()
|
||||||
if (!code) return
|
if (!code) return
|
||||||
|
|
||||||
|
// ★ 按单出库模式:必须先选择申请单
|
||||||
|
if (outboundMode.value === 'by-request' && !selectedRequest.value) {
|
||||||
|
ElMessage.warning('请先选择要出库的审批申请单')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
||||||
// 1. 检查购物车重复
|
// 1. 检查购物车重复(直接模式走旧的追加逻辑,按单模式也复用但后续会校验)
|
||||||
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 maxQty = parseFloat(item.available_quantity)
|
|
||||||
|
|
||||||
|
// ★ 按单模式:追加时仍需校验计划数量
|
||||||
|
if (outboundMode.value === 'by-request') {
|
||||||
|
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)
|
||||||
if (item.out_quantity < maxQty) {
|
if (item.out_quantity < maxQty) {
|
||||||
item.out_quantity++
|
item.out_quantity++
|
||||||
ElMessage.success(`数量+1 (当前: ${item.out_quantity})`)
|
ElMessage.success(`数量+1 (当前: ${item.out_quantity})`)
|
||||||
@ -343,7 +515,21 @@ const handleManualInput = async () => {
|
|||||||
if (availQty <= 0) {
|
if (availQty <= 0) {
|
||||||
ElMessage.warning(`库存不足或已出库 (余: ${availQty})`)
|
ElMessage.warning(`库存不足或已出库 (余: ${availQty})`)
|
||||||
if (navigator.vibrate) navigator.vibrate([100, 50, 100])
|
if (navigator.vibrate) navigator.vibrate([100, 50, 100])
|
||||||
} else {
|
barcodeInput.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 按单模式:扫码加入前校验是否在计划清单内
|
||||||
|
if (outboundMode.value === 'by-request') {
|
||||||
|
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({
|
cartItems.value.push({
|
||||||
...item,
|
...item,
|
||||||
@ -352,7 +538,6 @@ const handleManualInput = async () => {
|
|||||||
})
|
})
|
||||||
ElMessage.success(`添加成功: ${item.name}`)
|
ElMessage.success(`添加成功: ${item.name}`)
|
||||||
if (navigator.vibrate) navigator.vibrate(100)
|
if (navigator.vibrate) navigator.vibrate(100)
|
||||||
}
|
|
||||||
barcodeInput.value = ''
|
barcodeInput.value = ''
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -393,6 +578,7 @@ const clearAll = () => {
|
|||||||
signatureFile.value = null
|
signatureFile.value = null
|
||||||
signaturePreviewUrl.value = ''
|
signaturePreviewUrl.value = ''
|
||||||
barcodeInput.value = ''
|
barcodeInput.value = ''
|
||||||
|
// ★ 按单模式:仅清空购物车,保留申请单选择
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -416,40 +602,67 @@ const submitForm = async () => {
|
|||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
||||||
// 上传签名
|
// 1. 上传签名
|
||||||
const uploadRes = await uploadFile(signatureFile.value)
|
const uploadRes = await uploadFile(signatureFile.value)
|
||||||
const signatureUrl = uploadRes.data.url
|
const signatureUrl = uploadRes.data.url
|
||||||
|
|
||||||
const itemsPayload = cartItems.value.map(item => ({
|
// 2. 核心保护:坚决杜绝 undefined、null 和 0
|
||||||
stock_id: item.id,
|
const itemsPayload = cartItems.value.map(item => {
|
||||||
source_table: item.source_table,
|
// 强制确保出库数量是一个大于 0 的有效数字
|
||||||
sku: item.sku,
|
let safeQuantity = Number(item.out_quantity)
|
||||||
barcode: item.barcode,
|
if (isNaN(safeQuantity) || safeQuantity <= 0) {
|
||||||
quantity: item.out_quantity,
|
safeQuantity = 1 // 兜底:只要扫了码,最少出 1 个
|
||||||
price: item.price
|
}
|
||||||
}))
|
|
||||||
|
|
||||||
await submitOutbound({
|
return {
|
||||||
items: itemsPayload,
|
stock_id: item.id || 0,
|
||||||
|
source_table: item.source_table || '',
|
||||||
|
// 如果原数据 sku 是空,强制塞一个默认字符串,绝不传空值给后端引发 None 报错
|
||||||
|
sku: item.sku ? String(item.sku) : (item.barcode ? String(item.barcode) : 'NO_SKU'),
|
||||||
|
barcode: item.barcode ? String(item.barcode) : '',
|
||||||
|
quantity: safeQuantity,
|
||||||
|
price: item.price ? Number(item.price) : 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (itemsPayload.length === 0) {
|
||||||
|
ElMessage.warning('请至少扫描一件物料后再提交出库')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 组装发给后端的包
|
||||||
|
const submitPayload: any = {
|
||||||
outbound_type: form.outbound_type,
|
outbound_type: form.outbound_type,
|
||||||
|
request_id: outboundMode.value === 'by-request' && selectedRequest.value ? selectedRequest.value.id : null,
|
||||||
consumer_name: form.consumer_name,
|
consumer_name: form.consumer_name,
|
||||||
operator_name: form.operator_name,
|
operator_name: form.operator_name,
|
||||||
remark: form.remark,
|
remark: form.remark,
|
||||||
signature_path: signatureUrl
|
signature_path: signatureUrl,
|
||||||
})
|
items: itemsPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打印在前端控制台,你可以按 F12 在 Console 里核对这把"铁证"
|
||||||
|
console.log('准备提交给后端的最终数据:', JSON.parse(JSON.stringify(submitPayload)))
|
||||||
|
|
||||||
|
// 4. 发送请求
|
||||||
|
await submitOutbound(submitPayload)
|
||||||
|
|
||||||
ElMessage.success('出库成功')
|
ElMessage.success('出库成功')
|
||||||
// 重置
|
|
||||||
|
// 5. 成功后重置页面
|
||||||
cartItems.value = []
|
cartItems.value = []
|
||||||
form.consumer_name = ''
|
form.consumer_name = ''
|
||||||
form.remark = ''
|
form.remark = ''
|
||||||
signatureFile.value = null
|
signatureFile.value = null
|
||||||
|
|
||||||
|
// 根据你的项目实际变量重置签名组件,如果没有这句可以删掉
|
||||||
|
if (typeof signaturePreviewUrl !== 'undefined') {
|
||||||
signaturePreviewUrl.value = ''
|
signaturePreviewUrl.value = ''
|
||||||
loadHistoryOperators()
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error('出库报错:', error)
|
||||||
ElMessage.error('提交失败')
|
ElMessage.error('提交失败,请检查数据')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@ -547,6 +760,39 @@ onUnmounted(() => {
|
|||||||
.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; }
|
||||||
.header-price { font-size: 18px; color: #F56C6C; font-weight: bold; }
|
.header-price { font-size: 18px; color: #F56C6C; font-weight: bold; }
|
||||||
|
|
||||||
|
/* ★ 双轨制模式切换 */
|
||||||
|
.mode-switch-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #f5f7fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e4e7ed;
|
||||||
|
}
|
||||||
|
.mode-hint { color: #909399; font-size: 13px; }
|
||||||
|
|
||||||
|
/* ★ 审批单选择 */
|
||||||
|
.approval-request-select { margin-bottom: 16px; }
|
||||||
|
.select-tip { margin: 6px 0 0 0; color: #909399; font-size: 12px; }
|
||||||
|
|
||||||
|
/* ★ 计划清单 */
|
||||||
|
.planned-items-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f0f9eb;
|
||||||
|
border: 1px solid #e1f3d8;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.planned-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.planned-title { font-weight: bold; font-size: 14px; color: #67C23A; }
|
||||||
|
|
||||||
/* 扫码区(卡片内触发器) */
|
/* 扫码区(卡片内触发器) */
|
||||||
.scan-section { margin-bottom: 20px; }
|
.scan-section { margin-bottom: 20px; }
|
||||||
.camera-placeholder {
|
.camera-placeholder {
|
||||||
|
|||||||
@ -250,9 +250,18 @@
|
|||||||
|
|
||||||
<div class="form-card basic-card">
|
<div class="form-card basic-card">
|
||||||
<div class="card-title">
|
<div class="card-title">
|
||||||
<div style="display: flex; align-items: center;">
|
<div style="display: flex; align-items: center; gap: 8px;">
|
||||||
<el-icon class="icon"><Box /></el-icon>
|
<el-icon class="icon"><Box /></el-icon>
|
||||||
<span>1. 基础信息</span>
|
<span>1. 基础信息</span>
|
||||||
|
<el-link
|
||||||
|
v-if="form.base_id"
|
||||||
|
type="primary"
|
||||||
|
:underline="false"
|
||||||
|
style="font-size: 13px;"
|
||||||
|
@click="openMaterialInNewTab"
|
||||||
|
>
|
||||||
|
<el-icon style="margin-right: 4px"><EditPen /></el-icon>前往修改基础信息
|
||||||
|
</el-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
@ -561,7 +570,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted, watch, computed } from 'vue'
|
import { ref, reactive, onMounted, watch, computed } from 'vue'
|
||||||
import { Plus, Setting, Refresh, Search, Box, House, Link, InfoFilled, Printer, Camera, Picture } from '@element-plus/icons-vue'
|
import { Plus, Setting, Refresh, Search, Box, House, Link, InfoFilled, Printer, Camera, Picture, EditPen } from '@element-plus/icons-vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElLoading } from 'element-plus'
|
import { ElMessage, ElLoading } from 'element-plus'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
@ -621,6 +630,20 @@ const vLoadmore = {
|
|||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
// 在新标签页打开基础信息编辑
|
||||||
|
const openMaterialInNewTab = () => {
|
||||||
|
if (!form.base_id) return ElMessage.warning('请先选择物料')
|
||||||
|
const routeUrl = router.resolve({
|
||||||
|
path: '/material',
|
||||||
|
query: {
|
||||||
|
edit_id: form.base_id,
|
||||||
|
keyword: form.spec_model || form.material_name || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
window.open(routeUrl.href, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
|
|||||||
@ -287,9 +287,18 @@
|
|||||||
|
|
||||||
<div class="form-card basic-card">
|
<div class="form-card basic-card">
|
||||||
<div class="card-title">
|
<div class="card-title">
|
||||||
<div style="display: flex; align-items: center;">
|
<div style="display: flex; align-items: center; gap: 8px;">
|
||||||
<el-icon class="icon"><Box/></el-icon>
|
<el-icon class="icon"><Box/></el-icon>
|
||||||
<span>1. 基础信息</span>
|
<span>1. 基础信息</span>
|
||||||
|
<el-link
|
||||||
|
v-if="form.base_id"
|
||||||
|
type="primary"
|
||||||
|
:underline="false"
|
||||||
|
style="font-size: 13px;"
|
||||||
|
@click="openMaterialInNewTab"
|
||||||
|
>
|
||||||
|
<el-icon style="margin-right: 4px"><EditPen /></el-icon>前往修改基础信息
|
||||||
|
</el-link>
|
||||||
</div>
|
</div>
|
||||||
<span class="sub-title" v-if="dialogStatus === 'create'"> (请先搜索选择半成品物料)</span>
|
<span class="sub-title" v-if="dialogStatus === 'create'"> (请先搜索选择半成品物料)</span>
|
||||||
</div>
|
</div>
|
||||||
@ -616,7 +625,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {ref, reactive, onMounted, watch, computed} from 'vue'
|
import {ref, reactive, onMounted, watch, computed} from 'vue'
|
||||||
import {Plus, Setting, Refresh, Search, Lock, Box, House, InfoFilled, Link, Printer, Camera, Picture} from '@element-plus/icons-vue'
|
import {Plus, Setting, Refresh, Search, Lock, Box, House, InfoFilled, Link, Printer, Camera, Picture, EditPen} from '@element-plus/icons-vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import {ElMessage, ElLoading} from 'element-plus'
|
import {ElMessage, ElLoading} from 'element-plus'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
@ -678,6 +687,20 @@ const vLoadmore = {
|
|||||||
// ------------------------------------
|
// ------------------------------------
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
// 在新标签页打开基础信息编辑
|
||||||
|
const openMaterialInNewTab = () => {
|
||||||
|
if (!form.base_id) return ElMessage.warning('请先选择物料')
|
||||||
|
const routeUrl = router.resolve({
|
||||||
|
path: '/material',
|
||||||
|
query: {
|
||||||
|
edit_id: form.base_id,
|
||||||
|
keyword: form.spec_model || form.material_name || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
window.open(routeUrl.href, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
|
|||||||
35
query_audit.py
Normal file
35
query_audit.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import psycopg2
|
||||||
|
import json
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = psycopg2.connect(
|
||||||
|
host='localhost',
|
||||||
|
port=5432,
|
||||||
|
database='inventory_system',
|
||||||
|
user='test',
|
||||||
|
password='1234'
|
||||||
|
)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute('SELECT id, action, target_name, details FROM audit_logs ORDER BY id DESC LIMIT 3')
|
||||||
|
rows = cur.fetchall()
|
||||||
|
print('=== 最新3条审计日志 ===')
|
||||||
|
for row in rows:
|
||||||
|
print(f'ID: {row[0]}')
|
||||||
|
print(f'Action: {row[1]}')
|
||||||
|
print(f'Target: {row[2]}')
|
||||||
|
details = row[3]
|
||||||
|
if details:
|
||||||
|
# 格式化显示
|
||||||
|
if isinstance(details, str):
|
||||||
|
try:
|
||||||
|
details = json.loads(details)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
print(f'Details: {json.dumps(details, indent=2, ensure_ascii=False)}')
|
||||||
|
else:
|
||||||
|
print(f'Details: None')
|
||||||
|
print('---')
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f'Error: {e}')
|
||||||
38
upload_odoo_files.sh
Executable file
38
upload_odoo_files.sh
Executable file
@ -0,0 +1,38 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# === 配置项 ===
|
||||||
|
SERVER="dxc@172.16.0.198"
|
||||||
|
LOCAL_DIR="Odoo_Archive"
|
||||||
|
REMOTE_TARGET_DIR="/opt/inventory-app/uploads_prod"
|
||||||
|
ARCHIVE_NAME="odoo_images_upload.tar.gz"
|
||||||
|
|
||||||
|
echo "🚀 开始将本地图像及附件同步至线上存储目录..."
|
||||||
|
|
||||||
|
# 1. 检查本地文件夹
|
||||||
|
if [ ! -d "$LOCAL_DIR" ]; then
|
||||||
|
echo "❌ 找不到本地文件夹 $LOCAL_DIR,请确保脚本与该文件夹在同一层级!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. 本地打包 (使用 -C 保证解压后没有多余的外层文件夹)
|
||||||
|
echo "[1/4] 正在本地打包所有图片和文件..."
|
||||||
|
tar -czf $ARCHIVE_NAME -C $LOCAL_DIR .
|
||||||
|
|
||||||
|
# 3. 传输到生产环境的 /tmp 目录
|
||||||
|
echo "[2/4] 正在传输到服务器临时目录 /tmp (可能需要输入服务器密码)..."
|
||||||
|
scp $ARCHIVE_NAME $SERVER:/tmp/$ARCHIVE_NAME
|
||||||
|
|
||||||
|
# 4. 服务器解压并设置权限 (核心:纯文件覆盖/追加,不碰数据库)
|
||||||
|
echo "[3/4] 正在远端部署图像到目标文件夹 (可能需要输入 sudo 密码)..."
|
||||||
|
ssh -t $SERVER "sudo mkdir -p $REMOTE_TARGET_DIR && \
|
||||||
|
echo '>> 正在将图像释放到 $REMOTE_TARGET_DIR ...' && \
|
||||||
|
sudo tar -xzf /tmp/$ARCHIVE_NAME -C $REMOTE_TARGET_DIR && \
|
||||||
|
echo '>> 正在重置文件读写权限,确保线上服务可以正常显示图片...' && \
|
||||||
|
sudo chmod -R 755 $REMOTE_TARGET_DIR && \
|
||||||
|
sudo rm /tmp/$ARCHIVE_NAME"
|
||||||
|
|
||||||
|
# 5. 清理本地压缩包
|
||||||
|
echo "[4/4] 正在清理本地临时文件..."
|
||||||
|
rm $ARCHIVE_NAME
|
||||||
|
|
||||||
|
echo "✅ 图像及附件物理转移全部完成!线上存储内容已更新。"
|
||||||
108
图像信息导入.py
Executable file
108
图像信息导入.py
Executable file
@ -0,0 +1,108 @@
|
|||||||
|
import pandas as pd
|
||||||
|
import psycopg2
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
# ================= 配置区 =================
|
||||||
|
DB_CONFIG = {
|
||||||
|
'dbname': 'inventory_system',
|
||||||
|
'user': 'test',
|
||||||
|
'password': '1234',
|
||||||
|
'host': 'localhost',
|
||||||
|
'port': '5435'
|
||||||
|
}
|
||||||
|
|
||||||
|
EXCEL_FILE = "Odoo_Archive/Odoo产品_终极大满贯版.xlsx"
|
||||||
|
|
||||||
|
|
||||||
|
# ================= 辅助函数 =================
|
||||||
|
def process_paths_only(json_str):
|
||||||
|
"""
|
||||||
|
将爬虫的绝对路径,转换为现有后端接口完美支持的纯文件名格式!
|
||||||
|
"""
|
||||||
|
if not json_str or str(json_str).strip() in ['[]', 'nan', 'None']:
|
||||||
|
return '[]'
|
||||||
|
|
||||||
|
try:
|
||||||
|
paths = json.loads(json_str)
|
||||||
|
new_paths = []
|
||||||
|
|
||||||
|
for path in paths:
|
||||||
|
if path.startswith('http://') or path.startswith('https://'):
|
||||||
|
new_paths.append(path)
|
||||||
|
else:
|
||||||
|
filename = os.path.basename(path)
|
||||||
|
|
||||||
|
# 【终极修复】去掉中间的子文件夹,直接请求文件名!
|
||||||
|
web_path = f"/api/v1/common/files/{filename}"
|
||||||
|
new_paths.append(web_path)
|
||||||
|
|
||||||
|
return json.dumps(new_paths, ensure_ascii=False)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return '[]'
|
||||||
|
|
||||||
|
|
||||||
|
# ================= 主程序 =================
|
||||||
|
def process_excel_to_db():
|
||||||
|
if not os.path.exists(EXCEL_FILE):
|
||||||
|
print(f"❌ 找不到 Excel 文件: {EXCEL_FILE}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
df = pd.read_excel(EXCEL_FILE, dtype=str)
|
||||||
|
df = df.where(pd.notnull(df), None)
|
||||||
|
print(f"✅ 成功读取 Excel,共 {len(df)} 行数据。")
|
||||||
|
|
||||||
|
conn = psycopg2.connect(**DB_CONFIG)
|
||||||
|
cur = conn.cursor()
|
||||||
|
success_count = 0
|
||||||
|
|
||||||
|
for index, row in df.iterrows():
|
||||||
|
internal_ref = row.get('内部参考')
|
||||||
|
barcode = row.get('条码')
|
||||||
|
|
||||||
|
spec_model = ""
|
||||||
|
if barcode and internal_ref:
|
||||||
|
spec_model = f"{barcode}/{internal_ref}"
|
||||||
|
elif barcode:
|
||||||
|
spec_model = f"{barcode}"
|
||||||
|
elif internal_ref:
|
||||||
|
spec_model = f"{internal_ref}"
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
raw_image_json = row.get('generalImage')
|
||||||
|
raw_manual_json = row.get('generalManual')
|
||||||
|
|
||||||
|
if (not raw_image_json or raw_image_json == '[]') and (not raw_manual_json or raw_manual_json == '[]'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
product_image = process_paths_only(raw_image_json)
|
||||||
|
manual_link = process_paths_only(raw_manual_json)
|
||||||
|
|
||||||
|
update_query = """
|
||||||
|
UPDATE material_base
|
||||||
|
SET product_image = %s, \
|
||||||
|
manual_link = %s
|
||||||
|
WHERE spec_model = %s
|
||||||
|
"""
|
||||||
|
cur.execute(update_query, (product_image, manual_link, spec_model))
|
||||||
|
|
||||||
|
if cur.rowcount > 0:
|
||||||
|
success_count += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print(f"\n🎉 导入完成!成功更新了 {success_count} 条数据的正确路径。")
|
||||||
|
print("💡 赶快去刷新前端看看吧!这次图片一定能刷出来!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 发生致命错误: {e}")
|
||||||
|
if 'conn' in locals() and conn: conn.rollback()
|
||||||
|
finally:
|
||||||
|
if 'cur' in locals() and cur: cur.close()
|
||||||
|
if 'conn' in locals() and conn: conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
process_excel_to_db()
|
||||||
Reference in New Issue
Block a user