Compare commits
53 Commits
8291a89898
...
2.0权限管理
| Author | SHA1 | Date | |
|---|---|---|---|
| 259f3a7e0d | |||
| 00839863f5 | |||
| 8276597a67 | |||
| 6ef425b9e4 | |||
| ccbce82c2e | |||
| 183b93012e | |||
| 62c0e3738e | |||
| 97e7618bf3 | |||
| e08eaff40a | |||
| 40e405becd | |||
| d6ae9499db | |||
| ec71cb24f4 | |||
| 9fa471f68a | |||
| b002c50d81 | |||
| c0175e13fe | |||
| fa0af40ec7 | |||
| 1499d2d45c | |||
| 605462cc33 | |||
| 48f2011a38 | |||
| 996056d46a | |||
| f0c200a15f | |||
| 3c9cb06dbc | |||
| d9c95084ad | |||
| 1e17547c6e | |||
| 03518c99f3 | |||
| 1205d9c7e8 | |||
| 4b794b9bcc | |||
| ab353e5b34 | |||
| 5334be0cfa | |||
| 2006b7275f | |||
| dd54e047dd | |||
| f53b16f512 | |||
| 8583b811e1 | |||
| 6f5a7cf0db | |||
| 01ce9c1432 | |||
| 0ab7050e03 | |||
| cd714d0c16 | |||
| a6409ac091 | |||
| eba558c9d9 | |||
| a5a35777b5 | |||
| 466e94c4dd | |||
| 59a6a10803 | |||
| f8f5b05d7d | |||
| a849e14b2c | |||
| 7e72c12f30 | |||
| decb7f5e1f | |||
| 1c8def7e6f | |||
| 9a0982e76d | |||
| 381d1fa675 | |||
| becd3cb010 | |||
| 7d683f3e65 | |||
| 772f3f45f4 | |||
| d651d19e86 |
@ -2,7 +2,10 @@
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git add *)",
|
||||
"Bash(git commit *)"
|
||||
"Bash(git commit *)",
|
||||
"Bash(git *)",
|
||||
"Bash(del *)",
|
||||
"Bash(rm *)"
|
||||
]
|
||||
},
|
||||
"$version": 3
|
||||
|
||||
BIN
db_sync.sql.gz
BIN
db_sync.sql.gz
Binary file not shown.
BIN
deploy.tar.gz
BIN
deploy.tar.gz
Binary file not shown.
0
deploy_code.sh
Normal file → Executable file
0
deploy_code.sh
Normal file → Executable file
0
deploy_full.sh
Normal file → Executable file
0
deploy_full.sh
Normal file → Executable file
Binary file not shown.
@ -3,6 +3,7 @@
|
||||
from flask import Flask
|
||||
from config import Config
|
||||
from app.extensions import db, migrate, cors, jwt
|
||||
from app.api.v1.scan import scan_bp
|
||||
import os
|
||||
|
||||
|
||||
@ -20,6 +21,17 @@ def create_app():
|
||||
# 允许所有 /api/ 开头的请求跨域,支持 credentials
|
||||
cors.init_app(app, resources={r"/*": {"origins": "*"}}, supports_credentials=True)
|
||||
|
||||
# =========================================================
|
||||
# 1.1 注册全局审计日志监听器
|
||||
# =========================================================
|
||||
with app.app_context():
|
||||
try:
|
||||
from app.utils.audit_events import register_audit_events
|
||||
register_audit_events(db)
|
||||
print("✅ 审计事件监听器注册成功")
|
||||
except Exception as e:
|
||||
print(f"⚠️ 审计事件监听器注册失败: {e}")
|
||||
|
||||
# =========================================================
|
||||
# 2. 注册蓝图 (Blueprints)
|
||||
# ---------------------------------------------------------
|
||||
@ -189,6 +201,28 @@ def create_app():
|
||||
except ImportError as e:
|
||||
print(f"❌ 错误: Warehouse 模块导入失败: {e}")
|
||||
|
||||
# -----------------------------------------------------
|
||||
# 2.12 注册通用聚合搜索模块 (Common - Global Search)
|
||||
# -----------------------------------------------------
|
||||
try:
|
||||
from app.api.v1.common import common_bp
|
||||
# 标准: /api/v1/common/global-search
|
||||
app.register_blueprint(common_bp, url_prefix='/api/v1/common')
|
||||
# 兼容: /api/common/global-search
|
||||
app.register_blueprint(common_bp, url_prefix='/api/common', name='common_legacy')
|
||||
print("✅ Common 模块注册成功")
|
||||
except ImportError as e:
|
||||
print(f"❌ 错误: Common 模块导入失败: {e}")
|
||||
|
||||
# -----------------------------------------------------
|
||||
# 2.13 注册扫码查库存模块 (Scan)
|
||||
# -----------------------------------------------------
|
||||
try:
|
||||
app.register_blueprint(scan_bp, url_prefix='/api/v1/scan')
|
||||
print("✅ Scan 模块注册成功")
|
||||
except Exception as e:
|
||||
print(f"❌ 错误: Scan 模块注册失败: {e}")
|
||||
|
||||
# =========================================================
|
||||
# 3. 预加载数据模型
|
||||
# =========================================================
|
||||
@ -216,4 +250,4 @@ def create_app():
|
||||
except Exception as e:
|
||||
print(f"⚠️ 模型预加载发生未知错误: {e}")
|
||||
|
||||
return app
|
||||
return app
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
from flask import Blueprint
|
||||
from .inbound import inbound_bp
|
||||
from .bom import bom_bp
|
||||
from .common import common_bp
|
||||
from .scan import scan_bp
|
||||
|
||||
v1_bp = Blueprint('v1', __name__)
|
||||
v1_bp.register_blueprint(inbound_bp, url_prefix='/inbound')
|
||||
v1_bp.register_blueprint(bom_bp, url_prefix='/bom')
|
||||
v1_bp.register_blueprint(common_bp, url_prefix='/common')
|
||||
v1_bp.register_blueprint(scan_bp, url_prefix='/scan')
|
||||
|
||||
@ -174,6 +174,49 @@ def create_user():
|
||||
return jsonify({'msg': str(e)}), 400
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 批量创建用户
|
||||
# ==============================================================================
|
||||
@auth_bp.route('/user/batch', methods=['POST'])
|
||||
@jwt_required()
|
||||
def batch_create_user():
|
||||
try:
|
||||
data_list = request.get_json()
|
||||
if not data_list or not isinstance(data_list, list):
|
||||
return jsonify({'msg': '请求数据必须是用户数组'}), 400
|
||||
|
||||
# 数据清洗:移除用户没有权限的字段
|
||||
user_permissions = get_current_user_permissions()
|
||||
for data in data_list:
|
||||
if 'system_user:*' not in user_permissions:
|
||||
field_to_perm = {
|
||||
'cn_name': 'system_user:username',
|
||||
'username': 'system_user:username',
|
||||
'password': 'system_user:password',
|
||||
'department': 'system_user:department',
|
||||
'role': 'system_user:role',
|
||||
'email': 'system_user:email',
|
||||
}
|
||||
for field in list(data.keys()):
|
||||
perm_code = field_to_perm.get(field)
|
||||
if field == 'password':
|
||||
if 'system_user:operation' not in user_permissions:
|
||||
data.pop(field, None)
|
||||
continue
|
||||
if perm_code and perm_code not in user_permissions:
|
||||
data.pop(field, None)
|
||||
|
||||
claims = get_jwt()
|
||||
operator_role = claims.get('role')
|
||||
|
||||
results = AuthService.batch_create_users(data_list, operator_role)
|
||||
return jsonify({'msg': '批量处理完成', 'data': results}), 200
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Batch User Create Failed: {str(e)}")
|
||||
return jsonify({'msg': str(e)}), 500
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 更新用户(管理员)
|
||||
# ==============================================================================
|
||||
@ -275,6 +318,41 @@ def get_my_permissions():
|
||||
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
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 获取当前用户个人资料(自我查看)
|
||||
# ==============================================================================
|
||||
@ -371,3 +449,55 @@ def change_my_password():
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Change Password Failed: {str(e)}")
|
||||
return jsonify({'msg': f'密码修改失败: {str(e)}'}), 500
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 自我更新邮箱
|
||||
# ==============================================================================
|
||||
@auth_bp.route('/me/email', methods=['PUT'])
|
||||
@jwt_required()
|
||||
def update_my_email():
|
||||
"""
|
||||
自我更新邮箱接口
|
||||
- 仅更新 email 字段,与密码修改完全隔离
|
||||
- 防止后端意外清空用户密码
|
||||
"""
|
||||
try:
|
||||
from app.models.system import SysUser
|
||||
|
||||
user_id = get_jwt_identity()
|
||||
|
||||
# 超级管理员(user_id=0)不允许修改邮箱
|
||||
if user_id == 0:
|
||||
return jsonify({'msg': '超级管理员邮箱由系统管理员管理'}), 400
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'msg': '无效的请求数据'}), 400
|
||||
|
||||
email = data.get('email')
|
||||
if not email:
|
||||
return jsonify({'msg': '邮箱不能为空'}), 400
|
||||
|
||||
# 简单的邮箱格式校验
|
||||
import re
|
||||
if not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', email):
|
||||
return jsonify({'msg': '邮箱格式不正确'}), 400
|
||||
|
||||
user = SysUser.query.get(user_id)
|
||||
if not user:
|
||||
return jsonify({'msg': '用户不存在'}), 404
|
||||
|
||||
# 检查邮箱是否已被其他用户使用
|
||||
existing = SysUser.query.filter(SysUser.email == email, SysUser.id != user_id).first()
|
||||
if existing:
|
||||
return jsonify({'msg': '该邮箱已被其他用户使用'}), 400
|
||||
|
||||
user.email = email
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'msg': '邮箱更新成功'}), 200
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Update Email Failed: {str(e)}")
|
||||
return jsonify({'msg': f'邮箱更新失败: {str(e)}'}), 500
|
||||
|
||||
@ -214,12 +214,16 @@ def delete_bom(bom_no):
|
||||
if version:
|
||||
query = query.filter_by(version=version)
|
||||
|
||||
exist = query.first()
|
||||
if not exist:
|
||||
# 【核心修复】:使用 .all() 查出该 BOM 版本下的所有子件记录
|
||||
records = query.all()
|
||||
|
||||
if not records:
|
||||
return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404
|
||||
|
||||
# 删除
|
||||
query.delete()
|
||||
# 循环删除所有关联记录(逐个 delete 可触发 SQLAlchemy 监听器记录审计日志)
|
||||
for rec in records:
|
||||
db.session.delete(rec)
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
# inventory-backend/app/api/v1/common/__init__.py
|
||||
from flask import Blueprint
|
||||
|
||||
common_bp = Blueprint('common', __name__)
|
||||
|
||||
# 导入子模块,使其路由装饰器注册到 common_bp
|
||||
from . import search
|
||||
|
||||
99
inventory-backend/app/api/v1/common/search.py
Normal file
99
inventory-backend/app/api/v1/common/search.py
Normal file
@ -0,0 +1,99 @@
|
||||
# inventory-backend/app/api/v1/common/search.py
|
||||
from flask import jsonify, request
|
||||
from . import common_bp
|
||||
from app.models import MaterialBase
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from app.models.bom import BomTable
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
@common_bp.route('/global-search', methods=['GET'])
|
||||
def global_search():
|
||||
"""
|
||||
全局聚合搜索接口(多词 AND 模式,无数量限制)
|
||||
入参: keyword (字符串,支持空格分词,多词必须同时匹配)
|
||||
搜索范围: 基础物料、采购库、BOM配方
|
||||
"""
|
||||
keyword = request.args.get('keyword', request.args.get('q', '')).strip()
|
||||
keywords = keyword.split()
|
||||
|
||||
if not keywords:
|
||||
return jsonify({"code": 200, "data": []})
|
||||
|
||||
merged_list = []
|
||||
|
||||
# ── 1. 基础物料 (MaterialBase) ──────────────────────────
|
||||
# 真实字段: name, common_name, spec_model, category
|
||||
material_conditions = []
|
||||
for kw in keywords:
|
||||
kw_term = f'%{kw}%'
|
||||
material_conditions.append(
|
||||
db.or_(
|
||||
MaterialBase.name.ilike(kw_term),
|
||||
MaterialBase.common_name.ilike(kw_term),
|
||||
MaterialBase.spec_model.ilike(kw_term),
|
||||
MaterialBase.category.ilike(kw_term)
|
||||
)
|
||||
)
|
||||
bases = MaterialBase.query.filter(db.and_(*material_conditions)).all()
|
||||
for b in bases:
|
||||
merged_list.append({
|
||||
"id": b.id,
|
||||
"type": "material",
|
||||
"title": b.name,
|
||||
"subtitle": b.spec_model or b.common_name or '无规格型号',
|
||||
"badge": "基础物料",
|
||||
"extra": {"category": b.category or ''}
|
||||
})
|
||||
|
||||
# ── 2. 采购库 (StockBuy) ─────────────────────────────────
|
||||
# 真实字段: barcode, sku (通过 join 搜索关联的 MaterialBase.name)
|
||||
stock_conditions = []
|
||||
for kw in keywords:
|
||||
kw_term = f'%{kw}%'
|
||||
stock_conditions.append(
|
||||
db.or_(
|
||||
MaterialBase.name.ilike(kw_term),
|
||||
StockBuy.barcode.ilike(kw_term),
|
||||
StockBuy.sku.ilike(kw_term)
|
||||
)
|
||||
)
|
||||
stocks = StockBuy.query.join(MaterialBase, StockBuy.base_id == MaterialBase.id).filter(
|
||||
db.and_(*stock_conditions)
|
||||
).all()
|
||||
for s in stocks:
|
||||
merged_list.append({
|
||||
"id": s.base_id,
|
||||
"stock_id": s.id,
|
||||
"type": "stock_buy",
|
||||
"title": s.base.name if s.base else '未知物料',
|
||||
"subtitle": f"条码: {s.barcode or '无'} | 库存: {s.stock_quantity}",
|
||||
"badge": "采购库",
|
||||
"extra": {"barcode": s.barcode or '', "status": s.status or ''}
|
||||
})
|
||||
|
||||
# ── 3. BOM 配方 (BomTable) ──────────────────────────────
|
||||
# 真实字段: bom_no, version
|
||||
bom_conditions = []
|
||||
for kw in keywords:
|
||||
kw_term = f'%{kw}%'
|
||||
bom_conditions.append(
|
||||
db.or_(
|
||||
BomTable.bom_no.ilike(kw_term),
|
||||
BomTable.version.ilike(kw_term)
|
||||
)
|
||||
)
|
||||
boms = BomTable.query.filter(db.and_(*bom_conditions)).all()
|
||||
for bom in boms:
|
||||
parent_name = bom.parent.name if bom.parent else ''
|
||||
merged_list.append({
|
||||
"id": bom.id,
|
||||
"bom_no": bom.bom_no,
|
||||
"type": "bom",
|
||||
"title": f"{bom.bom_no} ({bom.version})",
|
||||
"subtitle": f"父件: {parent_name}" if parent_name else f"版本: {bom.version}",
|
||||
"badge": "配方BOM",
|
||||
"extra": {"version": bom.version, "parent_id": bom.parent_id}
|
||||
})
|
||||
|
||||
return jsonify({"code": 200, "data": merged_list})
|
||||
@ -381,6 +381,8 @@ def batch_set_warning():
|
||||
red_val = item.get('redThreshold')
|
||||
warning.yellow_threshold = float(yellow_val) if yellow_val is not None else 0
|
||||
warning.red_threshold = float(red_val) if red_val is not None else 0
|
||||
warning.yellow_emails = item.get('yellowEmails', warning.yellow_emails)
|
||||
warning.red_emails = item.get('redEmails', warning.red_emails)
|
||||
updated_count += 1
|
||||
else:
|
||||
# 创建新记录
|
||||
@ -390,7 +392,9 @@ def batch_set_warning():
|
||||
base_id=base_id,
|
||||
is_enabled=item.get('isEnabled', False),
|
||||
yellow_threshold=float(yellow_val) if yellow_val is not None else 0,
|
||||
red_threshold=float(red_val) if red_val is not None else 0
|
||||
red_threshold=float(red_val) if red_val is not None else 0,
|
||||
yellow_emails=item.get('yellowEmails', ''),
|
||||
red_emails=item.get('redEmails', '')
|
||||
)
|
||||
db.session.add(warning)
|
||||
created_count += 1
|
||||
@ -412,7 +416,48 @@ def batch_set_warning():
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 2.6 批量设置强制质检 API (POST /api/v1/inbound/base/batch-inspection)
|
||||
# 2.6 标记已采购 API (POST /api/v1/inbound/base/warning/mark-ordered)
|
||||
# ==============================================================================
|
||||
@inbound_base_bp.route('/warning/mark-ordered', methods=['POST'])
|
||||
@permission_required('material_list:edit_warning')
|
||||
def mark_warning_ordered():
|
||||
"""
|
||||
前端标记预警物料已处理采购(标记 is_ordered)
|
||||
请求体格式: {"baseId": 123, "isOrdered": true}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"code": 400, "msg": "No data provided"}), 400
|
||||
|
||||
base_id = data.get('baseId')
|
||||
if not base_id:
|
||||
return jsonify({"code": 400, "msg": "baseId 不能为空"}), 400
|
||||
|
||||
is_ordered = bool(data.get('isOrdered', False))
|
||||
|
||||
warning = MaterialWarningSetting.query.filter_by(base_id=base_id).first()
|
||||
if not warning:
|
||||
return jsonify({"code": 404, "msg": f"物料ID {base_id} 的预警配置不存在"}), 404
|
||||
|
||||
warning.is_ordered = is_ordered
|
||||
db.session.commit()
|
||||
|
||||
status_text = "已标记为已采购" if is_ordered else "已重置为未采购"
|
||||
return jsonify({
|
||||
"code": 200,
|
||||
"msg": status_text,
|
||||
"data": warning.to_dict()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f"标记已采购失败: {str(e)}")
|
||||
return jsonify({"code": 500, "msg": f"标记已采购失败: {str(e)}"}), 500
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 2.7 批量设置强制质检 API (POST /api/v1/inbound/base/batch-inspection)
|
||||
# ==============================================================================
|
||||
@inbound_base_bp.route('/batch-inspection', methods=['POST'])
|
||||
@permission_required('material_list:operation')
|
||||
|
||||
@ -22,14 +22,18 @@ except ImportError:
|
||||
SysUser = None
|
||||
|
||||
# 尝试导入半成品和成品
|
||||
import logging
|
||||
|
||||
try:
|
||||
from app.models.inbound.semi import StockSemi
|
||||
except ImportError:
|
||||
except Exception as e:
|
||||
logging.error(f"❌ 致命错误:StockSemi 模型导入失败: {e}")
|
||||
StockSemi = None
|
||||
|
||||
try:
|
||||
from app.models.inbound.product import StockProduct
|
||||
except ImportError:
|
||||
except Exception as e:
|
||||
logging.error(f"❌ 致命错误:StockProduct 模型导入失败: {e}")
|
||||
StockProduct = None
|
||||
|
||||
|
||||
@ -79,28 +83,50 @@ def get_stock_info(uuid_or_barcode):
|
||||
根据 uuid 或 barcode 查询库存信息
|
||||
返回: (item, source_table, stock_id)
|
||||
"""
|
||||
# 清洗输入:去掉前后空格和换行符
|
||||
uuid_or_barcode = str(uuid_or_barcode).strip()
|
||||
|
||||
# 1. 成品
|
||||
if StockProduct:
|
||||
print(f"🔍 [QUERY DEBUG] 正在成品表搜关键词: {uuid_or_barcode}")
|
||||
item = StockProduct.query.filter(
|
||||
db.or_(StockProduct.barcode == uuid_or_barcode, StockProduct.sku == uuid_or_barcode)
|
||||
db.or_(
|
||||
StockProduct.barcode.ilike(f"%{uuid_or_barcode}%"),
|
||||
StockProduct.sku.ilike(f"%{uuid_or_barcode}%"),
|
||||
StockProduct.serial_number.ilike(f"%{uuid_or_barcode}%")
|
||||
)
|
||||
).first()
|
||||
if item:
|
||||
print(f"✅ [QUERY DEBUG] 命中成品! ID={item.id}, SKU={item.sku}")
|
||||
return (item, 'stock_product', item.id)
|
||||
else:
|
||||
print(f"❌ [QUERY DEBUG] 成品表查询结束,无匹配项")
|
||||
|
||||
# 2. 半成品
|
||||
if StockSemi:
|
||||
print(f"🔍 [QUERY DEBUG] 正在半成品表搜关键词: {uuid_or_barcode}")
|
||||
item = StockSemi.query.filter(
|
||||
db.or_(StockSemi.barcode == uuid_or_barcode, StockSemi.sku == uuid_or_barcode)
|
||||
db.or_(
|
||||
StockSemi.barcode.ilike(f"%{uuid_or_barcode}%"),
|
||||
StockSemi.sku.ilike(f"%{uuid_or_barcode}%"),
|
||||
StockSemi.serial_number.ilike(f"%{uuid_or_barcode}%")
|
||||
)
|
||||
).first()
|
||||
if item:
|
||||
print(f"✅ [QUERY DEBUG] 命中半成品! ID={item.id}, SKU={item.sku}")
|
||||
return (item, 'stock_semi', item.id)
|
||||
|
||||
# 3. 采购件
|
||||
if StockBuy:
|
||||
print(f"🔍 [QUERY DEBUG] 正在采购件表搜关键词: {uuid_or_barcode}")
|
||||
item = StockBuy.query.filter(
|
||||
db.or_(StockBuy.barcode == uuid_or_barcode, StockBuy.sku == uuid_or_barcode)
|
||||
db.or_(
|
||||
StockBuy.barcode.ilike(f"%{uuid_or_barcode}%"),
|
||||
StockBuy.sku.ilike(f"%{uuid_or_barcode}%")
|
||||
)
|
||||
).first()
|
||||
if item:
|
||||
print(f"✅ [QUERY DEBUG] 命中采购件! ID={item.id}, SKU={item.sku}")
|
||||
return (item, 'stock_buy', item.id)
|
||||
|
||||
return (None, None, None)
|
||||
@ -216,42 +242,71 @@ def get_stock_list():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. 成品
|
||||
# 3. 成品
|
||||
if StockProduct:
|
||||
try:
|
||||
q = StockProduct.query.filter(StockProduct.stock_quantity > 0)
|
||||
if keyword:
|
||||
q = q.filter(
|
||||
db.or_(
|
||||
StockProduct.product_name.ilike(f'%{keyword}%'),
|
||||
StockProduct.spec_model.ilike(f'%{keyword}%'),
|
||||
StockProduct.sku.ilike(f'%{keyword}%')
|
||||
)
|
||||
q = StockProduct.query.filter(StockProduct.stock_quantity > 0)
|
||||
if keyword:
|
||||
q = q.filter(
|
||||
db.or_(
|
||||
StockProduct.base.has(MaterialBase.name.ilike(f'%{keyword}%')),
|
||||
StockProduct.base.has(MaterialBase.spec_model.ilike(f'%{keyword}%')),
|
||||
StockProduct.sku.ilike(f'%{keyword}%'),
|
||||
StockProduct.barcode.ilike(f'%{keyword}%'),
|
||||
StockProduct.serial_number.ilike(f'%{keyword}%')
|
||||
)
|
||||
rows = q.all()
|
||||
for item in rows:
|
||||
d = item.to_dict()
|
||||
d['stock_type'] = 'product'
|
||||
d['type'] = 'product'
|
||||
d['typeLabel'] = '成品'
|
||||
d['name'] = d.get('product_name', d.get('name', ''))
|
||||
d['standard'] = d.get('spec_model', d.get('standard', ''))
|
||||
d['available_quantity'] = d.get('qty_available', d.get('available_quantity', 0))
|
||||
all_items.append(d)
|
||||
except Exception:
|
||||
pass
|
||||
)
|
||||
rows = q.all()
|
||||
for item in rows:
|
||||
d = item.to_dict()
|
||||
d['stock_type'] = 'product'
|
||||
d['type'] = 'product'
|
||||
d['typeLabel'] = '成品'
|
||||
d['name'] = d.get('material_name', d.get('name', ''))
|
||||
d['standard'] = d.get('spec_model', d.get('standard', ''))
|
||||
d['available_quantity'] = d.get('qty_available', d.get('available_quantity', 0))
|
||||
all_items.append(d)
|
||||
|
||||
# ── 按规格+库位聚合(出库选单合并同类项)───────────────────────
|
||||
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
|
||||
end = start + pageSize
|
||||
end = start + pageSize
|
||||
paged = all_items[start:end]
|
||||
|
||||
return jsonify({
|
||||
'msg': '获取成功',
|
||||
'data': {
|
||||
'list': paged,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'list': paged,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'pageSize': pageSize
|
||||
}
|
||||
}), 200
|
||||
@ -325,11 +380,22 @@ def get_drafts():
|
||||
total = len(items)
|
||||
start = (page - 1) * limit
|
||||
end = start + limit
|
||||
|
||||
# 计算真实的去重"已盘数量"
|
||||
counted_items_set = set()
|
||||
for draft_item in items:
|
||||
# 兼容判断 quantity 或 qty_actual
|
||||
if draft_item.get('quantity') is not None or draft_item.get('qty_actual') is not None:
|
||||
unique_key = f"{draft_item.get('source_table', '')}_{draft_item.get('stock_id', '')}"
|
||||
counted_items_set.add(unique_key)
|
||||
total_scanned_unique = len(counted_items_set)
|
||||
|
||||
paginated_items = items[start:end]
|
||||
|
||||
return jsonify({
|
||||
'items': paginated_items,
|
||||
'total': total,
|
||||
'total_scanned': total_scanned_unique,
|
||||
'page': page,
|
||||
'limit': limit
|
||||
}), 200
|
||||
@ -350,6 +416,7 @@ def add_draft():
|
||||
data = request.json
|
||||
user_id = _normalize_user_id()
|
||||
uuid = data.get('uuid')
|
||||
print(f"🚀 [SCAN DEBUG] 后端实际接收到的 UUID 原文: |{uuid}| (长度: {len(str(uuid)) if uuid else 0})")
|
||||
quantity = float(data.get('quantity', 1))
|
||||
session_id = data.get('session_id')
|
||||
# ★ 新增: 提取备注字段
|
||||
@ -444,7 +511,11 @@ def clear_draft():
|
||||
# 清除指定会话
|
||||
query = query.filter_by(session_id=session_id)
|
||||
|
||||
count = query.delete()
|
||||
# 改为对象级删除以触发审计事件
|
||||
records = query.all()
|
||||
count = len(records)
|
||||
for rec in records:
|
||||
db.session.delete(rec)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({"message": f"已清除 {count} 条记录", "count": count}), 200
|
||||
@ -461,8 +532,11 @@ def start_new_session():
|
||||
清空整张草稿表,返回新的 session_id
|
||||
"""
|
||||
try:
|
||||
# 清空整张草稿表
|
||||
deleted_count = StocktakeDraft.query.delete()
|
||||
# 清空整张草稿表(改为对象级删除以触发审计事件)
|
||||
all_records = StocktakeDraft.query.all()
|
||||
deleted_count = len(all_records)
|
||||
for rec in all_records:
|
||||
db.session.delete(rec)
|
||||
db.session.commit()
|
||||
|
||||
# 生成新的 session_id
|
||||
@ -789,8 +863,8 @@ def export_stocktake():
|
||||
user = SysUser.query.get(int(user_id))
|
||||
if not user:
|
||||
user = SysUser.query.filter(SysUser.username.like(f"%/{user_id}")).first()
|
||||
if not user:
|
||||
user = SysUser.query.filter_by(username=str(user_id)).first()
|
||||
# 注意:此处不再 fallback filter_by(username=...),
|
||||
# 避免 PostgreSQL 将 user_id 数字与 username 字符串列做类型比较导致报错
|
||||
|
||||
if not user:
|
||||
return str(user_id)
|
||||
@ -1148,10 +1222,14 @@ def generate_missing_stocktake():
|
||||
|
||||
# ★ 幂等性保护:先删除当前 session 下系统自动生成的漏盘记录
|
||||
# 特征:user_id == 'system' (表示由系统自动生成)
|
||||
deleted_count = StocktakeDraft.query.filter(
|
||||
# 改为对象级删除以触发审计事件
|
||||
system_records = StocktakeDraft.query.filter(
|
||||
StocktakeDraft.session_id == session_id,
|
||||
StocktakeDraft.user_id == 'system'
|
||||
).delete()
|
||||
).all()
|
||||
deleted_count = len(system_records)
|
||||
for rec in system_records:
|
||||
db.session.delete(rec)
|
||||
if deleted_count > 0:
|
||||
db.session.commit()
|
||||
print(f"[generate_missing] 已清理 {deleted_count} 条历史漏盘记录")
|
||||
|
||||
@ -148,44 +148,6 @@ def create_outbound():
|
||||
if not data.get('consumer_name') or not data.get('signature_path'):
|
||||
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:
|
||||
# ★ [修改] 调用批量创建服务
|
||||
outbound_no = OutboundService.create_outbound_batch(data, operator_name=final_operator)
|
||||
@ -233,3 +195,244 @@ def get_outbound_list():
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
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
|
||||
|
||||
69
inventory-backend/app/api/v1/scan/__init__.py
Normal file
69
inventory-backend/app/api/v1/scan/__init__.py
Normal file
@ -0,0 +1,69 @@
|
||||
"""
|
||||
扫码查库存接口(移动端专用)
|
||||
GET /api/v1/scan/inventory?barcode=xxx
|
||||
"""
|
||||
from flask import Blueprint, jsonify, request
|
||||
from app.extensions import db
|
||||
from app.models.base import MaterialBase
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from app.models.inbound.product import StockProduct
|
||||
from app.models.inbound.semi import StockSemi
|
||||
|
||||
scan_bp = Blueprint('scan', __name__, url_prefix='/scan')
|
||||
|
||||
|
||||
def _build_response(stock_record, stock_type: str) -> dict:
|
||||
"""联表 MaterialBase 提取物料信息并组装返回结构"""
|
||||
material = MaterialBase.query.get(stock_record.base_id)
|
||||
return {
|
||||
'code': 200,
|
||||
'data': {
|
||||
'materialName': material.name if material else '未知物料',
|
||||
'spec': material.spec_model if material else '',
|
||||
'location': stock_record.warehouse_location or '',
|
||||
'quantity': float(stock_record.available_quantity) if stock_record.available_quantity else 0.0,
|
||||
'stockType': stock_type
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@scan_bp.route('/inventory', methods=['GET'])
|
||||
def scan_inventory():
|
||||
"""
|
||||
扫码精确查找库存
|
||||
入参: barcode (query string)
|
||||
逻辑: 在 StockBuy / StockProduct / StockSemi 三表中精确匹配,只要命中一张即返回
|
||||
"""
|
||||
barcode = (request.args.get('barcode') or '').strip()
|
||||
if not barcode:
|
||||
return jsonify({'code': 400, 'msg': 'barcode 参数不能为空'}), 400
|
||||
|
||||
# 1. 采购库
|
||||
buy = StockBuy.query.filter(
|
||||
StockBuy.barcode == barcode,
|
||||
StockBuy.stock_quantity > 0
|
||||
).first()
|
||||
if buy:
|
||||
return jsonify(_build_response(buy, '采购库'))
|
||||
|
||||
# 2. 成品库
|
||||
product = StockProduct.query.filter(
|
||||
StockProduct.barcode == barcode,
|
||||
StockProduct.stock_quantity > 0
|
||||
).first()
|
||||
if product:
|
||||
return jsonify(_build_response(product, '成品库'))
|
||||
|
||||
# 3. 半成品库
|
||||
semi = StockSemi.query.filter(
|
||||
StockSemi.barcode == barcode,
|
||||
StockSemi.stock_quantity > 0
|
||||
).first()
|
||||
if semi:
|
||||
return jsonify(_build_response(semi, '半成品库'))
|
||||
|
||||
# 4. 全部未命中
|
||||
return jsonify({
|
||||
'code': 404,
|
||||
'msg': f'未找到条码 [{barcode}] 对应的库存记录,或该物料当前库存为零'
|
||||
}), 404
|
||||
@ -66,26 +66,6 @@ def filter_item_by_permissions(item_dict, user_permissions, prefix='op_records')
|
||||
)
|
||||
def create_borrow():
|
||||
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:
|
||||
no = TransService.create_borrow(data)
|
||||
return jsonify({'code': 200, 'msg': '借用成功', 'data': {'borrow_no': no}})
|
||||
@ -120,26 +100,6 @@ def scan_borrowed_item():
|
||||
)
|
||||
def submit_return():
|
||||
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() # 库管
|
||||
try:
|
||||
TransService.process_return(data, operator_name=user)
|
||||
|
||||
210
inventory-backend/app/core/audit_listener.py
Normal file
210
inventory-backend/app/core/audit_listener.py
Normal file
@ -0,0 +1,210 @@
|
||||
# inventory-backend/app/core/audit_listener.py
|
||||
"""
|
||||
SQLAlchemy Event Listener 审计监听器(单体架构版)
|
||||
监听器亲自完成入库,不依赖 g 对象,不依赖装饰器回调。
|
||||
只要模型发生 INSERT/UPDATE/DELETE,监听器直接创建 AuditLog 并挂载到当前事务 session。
|
||||
"""
|
||||
from sqlalchemy import event, inspect
|
||||
from flask import current_app, request, has_request_context
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
IGNORE_FIELDS = {
|
||||
'updated_at', 'update_time', 'modified_time', 'last_modified',
|
||||
'created_at', 'create_time', 'created_on',
|
||||
}
|
||||
|
||||
|
||||
def _serialize_value(value):
|
||||
"""序列化值确保 JSON 兼容"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value.strftime('%Y-%m-%d %H:%M:%S')
|
||||
if isinstance(value, (bytes, bytearray)):
|
||||
try:
|
||||
return value.decode('utf-8')
|
||||
except Exception:
|
||||
return '[二进制数据]'
|
||||
if hasattr(value, '__class__') and value.__class__.__name__ in ('InstanceState', 'LazyLoader'):
|
||||
return str(value)
|
||||
return value
|
||||
|
||||
|
||||
def _is_audit_model(mapper):
|
||||
"""判断模型是否需要审计"""
|
||||
if hasattr(mapper.class_, 'audit_enabled') and mapper.class_.audit_enabled is False:
|
||||
return False
|
||||
|
||||
AUDIT_WHITELIST = {
|
||||
'MaterialBase', 'MaterialWarningSetting',
|
||||
'StockBuy', 'StockSemi', 'StockProduct', 'StockService',
|
||||
'RepairRecord', 'TransOutbound', 'TransBorrow', 'TransReturn',
|
||||
'BomTable', 'StockTake', 'StockAdjust',
|
||||
'TransScrap', 'SysUser'
|
||||
}
|
||||
return mapper.class_.__name__ in AUDIT_WHITELIST
|
||||
|
||||
|
||||
def _get_module_name(mapper):
|
||||
"""根据模型类名推断所属模块"""
|
||||
name = mapper.class_.__name__
|
||||
if 'Stock' in name or 'Buy' in name:
|
||||
return '入库管理'
|
||||
if 'Outbound' in name or 'TransOut' in name:
|
||||
return '出库管理'
|
||||
if 'Borrow' in name or 'Return' in name:
|
||||
return '借还管理'
|
||||
if 'Bom' in name:
|
||||
return 'BOM管理'
|
||||
if 'StockTake' in name or 'Adjust' in name or 'Scrap' in name:
|
||||
return '盘点管理'
|
||||
if 'Repair' in name:
|
||||
return '维修管理'
|
||||
if 'SysUser' in name or 'SysMenu' in name or 'SysRole' in name:
|
||||
return '系统管理'
|
||||
if 'Material' in name:
|
||||
return '基础数据'
|
||||
return '未知模块'
|
||||
|
||||
|
||||
def _get_request_user_info():
|
||||
"""从当前 HTTP 请求中尽力提取用户信息,获取不到拉倒"""
|
||||
user_id, username, ip = None, 'system', ''
|
||||
if has_request_context():
|
||||
try:
|
||||
from flask_jwt_extended import get_jwt_identity, get_jwt
|
||||
user_id = get_jwt_identity()
|
||||
claims = get_jwt()
|
||||
username = claims.get('username', 'system')
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ip = request.headers.get('X-Forwarded-For', '') or request.remote_addr or ''
|
||||
if ip and ',' in ip:
|
||||
ip = ip.split(',')[0].strip()
|
||||
except Exception:
|
||||
pass
|
||||
return user_id, username, ip
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 核心:监听器内部直接创建并挂载日志
|
||||
# ============================================================
|
||||
|
||||
def _create_audit_log(session, mapper, target, action, details):
|
||||
"""
|
||||
监听器内部直接实例化 AuditLog 并加入当前事务 session。
|
||||
由 SQLAlchemy 生命周期保证随主事务一同提交或回滚。
|
||||
"""
|
||||
try:
|
||||
from app.models.audit import AuditLog
|
||||
|
||||
user_id, username, ip = _get_request_user_info()
|
||||
module = _get_module_name(mapper)
|
||||
|
||||
target_id = None
|
||||
if hasattr(target, 'id'):
|
||||
target_id = target.id
|
||||
elif hasattr(target, 'stock_id'):
|
||||
target_id = target.stock_id
|
||||
elif hasattr(target, 'bom_no'):
|
||||
target_id = target.bom_no
|
||||
|
||||
log = AuditLog(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
action=action,
|
||||
module=module,
|
||||
target_id=str(target_id) if target_id else '0',
|
||||
details=details,
|
||||
ip_address=ip
|
||||
)
|
||||
session.add(log)
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Audit log auto-creation failed: {e}")
|
||||
|
||||
|
||||
def before_update_listener(mapper, connection, target):
|
||||
"""UPDATE 事件:抓取字段变更明细"""
|
||||
if not _is_audit_model(mapper): return
|
||||
try:
|
||||
state = inspect(target)
|
||||
changes = {}
|
||||
for attr in state.attrs:
|
||||
if attr.key in IGNORE_FIELDS: continue
|
||||
if attr.history.has_changes():
|
||||
old_val = attr.history.deleted[0] if attr.history.deleted else None
|
||||
new_val = attr.history.added[0] if attr.history.added else None
|
||||
changes[attr.key] = {
|
||||
'old': _serialize_value(old_val),
|
||||
'new': _serialize_value(new_val)
|
||||
}
|
||||
if changes:
|
||||
_create_audit_log(connection, mapper, target, 'update', {'changes': changes})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Audit Update Error: {e}")
|
||||
|
||||
|
||||
def before_delete_listener(mapper, connection, target):
|
||||
"""DELETE 事件:抓取被删除对象的完整快照"""
|
||||
if not _is_audit_model(mapper): return
|
||||
try:
|
||||
state = inspect(target)
|
||||
snap = {}
|
||||
for attr in state.attrs:
|
||||
val = getattr(target, attr.key, None)
|
||||
snap[attr.key] = _serialize_value(val)
|
||||
_create_audit_log(connection, mapper, target, 'delete', {'deleted_snapshot': snap})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Audit Delete Error: {e}")
|
||||
|
||||
|
||||
def after_insert_listener(mapper, connection, target):
|
||||
"""INSERT 事件:抓取新增对象的完整快照"""
|
||||
if not _is_audit_model(mapper): return
|
||||
try:
|
||||
state = inspect(target)
|
||||
snap = {}
|
||||
for attr in state.attrs:
|
||||
val = getattr(target, attr.key, None)
|
||||
snap[attr.key] = _serialize_value(val)
|
||||
_create_audit_log(connection, mapper, target, 'insert', {'created': snap})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 注册函数
|
||||
# ============================================================
|
||||
|
||||
def register_audit_listeners(db):
|
||||
"""向所有需要审计的模型注册事件监听器"""
|
||||
from app.models import (
|
||||
MaterialBase, MaterialWarningSetting,
|
||||
StockBuy, StockSemi, StockProduct, StockService,
|
||||
RepairRecord, TransOutbound, TransBorrow, TransReturn,
|
||||
BomTable, StockTake, StockAdjust,
|
||||
TransScrap, SysUser
|
||||
)
|
||||
|
||||
audit_models = [
|
||||
MaterialBase, MaterialWarningSetting,
|
||||
StockBuy, StockSemi, StockProduct, StockService,
|
||||
RepairRecord, TransOutbound, TransBorrow, TransReturn,
|
||||
BomTable, StockTake, StockAdjust,
|
||||
TransScrap, SysUser
|
||||
]
|
||||
|
||||
audit_models = [m for m in audit_models if m is not None]
|
||||
count = 0
|
||||
for model in audit_models:
|
||||
try:
|
||||
event.listen(model, 'before_update', before_update_listener, propagate=True)
|
||||
event.listen(model, 'before_delete', before_delete_listener, propagate=True)
|
||||
event.listen(model, 'after_insert', after_insert_listener, propagate=True)
|
||||
count += 1
|
||||
except Exception:
|
||||
pass
|
||||
return count
|
||||
@ -46,4 +46,14 @@ def init_extensions(app):
|
||||
redis_client.ping()
|
||||
app.logger.info("✅ Redis connected successfully")
|
||||
except Exception as e:
|
||||
app.logger.warning(f"⚠️ Redis connection failed: {e}, single-device login will be disabled")
|
||||
app.logger.warning(f"⚠️ Redis connection failed: {e}, single-device login will be disabled")
|
||||
|
||||
# ★ 注册 SQLAlchemy 审计监听器
|
||||
# 必须在 db.init_app 之后调用,确保所有模型已映射
|
||||
try:
|
||||
from app.core.audit_listener import register_audit_listeners
|
||||
with app.app_context():
|
||||
count = register_audit_listeners(db)
|
||||
app.logger.info(f"✅ 审计监听器注册成功,共绑定 {count} 个模型")
|
||||
except Exception as e:
|
||||
app.logger.error(f"⚠️ 审计监听器注册失败: {e}")
|
||||
@ -14,6 +14,6 @@ except ImportError:
|
||||
|
||||
# 4. 出库记录 (如果有,BuyService 用到了 TransOutbound)
|
||||
try:
|
||||
from app.models.outbound import TransOutbound
|
||||
from app.models.outbound import TransOutbound, OutboundApproval
|
||||
except ImportError:
|
||||
pass
|
||||
@ -101,6 +101,10 @@ class MaterialWarningSetting(db.Model):
|
||||
is_enabled = db.Column(db.Boolean, default=False, comment='是否启用预警')
|
||||
yellow_threshold = db.Column(db.Numeric(10, 2), nullable=True, comment='黄色预警阈值')
|
||||
red_threshold = db.Column(db.Numeric(10, 2), nullable=True, comment='红色预警阈值')
|
||||
yellow_emails = db.Column(db.String(500), nullable=True, comment='黄色预警通知邮箱')
|
||||
red_emails = db.Column(db.String(500), nullable=True, comment='红色预警通知邮箱')
|
||||
is_ordered = db.Column(db.Boolean, default=False, comment='是否已处理采购')
|
||||
last_notified_at = db.Column(db.DateTime, nullable=True, comment='上次邮件通知时间')
|
||||
|
||||
# 关联关系
|
||||
material = db.relationship('MaterialBase', back_populates='warning_settings')
|
||||
@ -111,5 +115,9 @@ class MaterialWarningSetting(db.Model):
|
||||
'baseId': self.base_id,
|
||||
'isEnabled': bool(self.is_enabled),
|
||||
'yellowThreshold': float(self.yellow_threshold) if self.yellow_threshold is not None else None,
|
||||
'redThreshold': float(self.red_threshold) if self.red_threshold is not None else None
|
||||
'redThreshold': float(self.red_threshold) if self.red_threshold is not None else None,
|
||||
'yellowEmails': self.yellow_emails or '',
|
||||
'redEmails': self.red_emails or '',
|
||||
'isOrdered': bool(self.is_ordered),
|
||||
'lastNotifiedAt': self.last_notified_at.strftime('%Y-%m-%d %H:%M:%S') if self.last_notified_at else None
|
||||
}
|
||||
@ -1,5 +1,110 @@
|
||||
from app.extensions import db, beijing_time
|
||||
from app.models.system import SysUser
|
||||
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):
|
||||
|
||||
@ -57,7 +57,7 @@ def _get_token_from_redis(user_id):
|
||||
class AuthService:
|
||||
# 硬编码的超级管理员凭证
|
||||
SUPER_ADMIN_USER = "IRIS"
|
||||
SUPER_ADMIN_PASS = "licahk"
|
||||
SUPER_ADMIN_PASS = "123321"
|
||||
|
||||
@staticmethod
|
||||
def login(data):
|
||||
@ -205,6 +205,16 @@ class AuthService:
|
||||
if not cn_name or not pinyin_base:
|
||||
raise Exception("姓名和账号不能为空")
|
||||
|
||||
# 后端兜底正则校验:允许中英数,禁止纯数字,无特殊字符
|
||||
import re
|
||||
name_pattern = re.compile(r'^(?!\d+$)[a-zA-Z0-9\u4e00-\u9fa5]+$')
|
||||
|
||||
if not name_pattern.match(cn_name):
|
||||
raise Exception("姓名格式错误:仅支持中英文和数字,不能为纯数字,且不支持特殊字符")
|
||||
|
||||
if not name_pattern.match(pinyin_base):
|
||||
raise Exception("账号格式错误:仅支持中英文和数字,不能为纯数字,且不支持特殊字符")
|
||||
|
||||
role_raw = data.get('role')
|
||||
role = role_raw.upper() if role_raw else None
|
||||
|
||||
@ -220,7 +230,7 @@ class AuthService:
|
||||
if operator_role_upper == UserRole.SUPERVISOR and role == UserRole.SUPER_ADMIN:
|
||||
raise Exception("权限不足:主管无法创建超级管理员")
|
||||
|
||||
email = data.get('email', '')
|
||||
email = data.get('email', '') or None # 空字符串转 None,避免 unique 冲突
|
||||
if email and SysUser.query.filter_by(email=email).first():
|
||||
raise Exception("邮箱已被使用")
|
||||
|
||||
@ -260,6 +270,29 @@ class AuthService:
|
||||
# 返回时,最好把生成的ID告诉前端
|
||||
return new_user.to_dict()
|
||||
|
||||
@staticmethod
|
||||
def batch_create_users(data_list, operator_role):
|
||||
"""
|
||||
批量创建新用户。复用 create_user 的核心防重逻辑。
|
||||
"""
|
||||
results = []
|
||||
for data in data_list:
|
||||
try:
|
||||
# 复用单条创建逻辑,它自带张三/zhangsan1的防重机制
|
||||
new_user_dict = AuthService.create_user(data, operator_role)
|
||||
results.append({
|
||||
"cn_name": data.get('cn_name'),
|
||||
"account_id": new_user_dict.get('account_id'),
|
||||
"status": "success"
|
||||
})
|
||||
except Exception as e:
|
||||
results.append({
|
||||
"cn_name": data.get('cn_name'),
|
||||
"error": str(e),
|
||||
"status": "fail"
|
||||
})
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def update_user(user_id, data, operator_role):
|
||||
"""
|
||||
|
||||
@ -189,8 +189,13 @@ class BomService:
|
||||
raise ValueError(f'保存失败!当前子件配置与已有版本 {ver} 完全一致,请勿重复保存')
|
||||
|
||||
# ===== 执行保存 =====
|
||||
# 仅删除当前版本的旧记录
|
||||
BomTable.query.filter_by(bom_no=bom_no, version=version).delete()
|
||||
# 仅删除当前版本的旧记录(改为对象级删除以触发审计事件)
|
||||
old_records = BomTable.query.filter_by(bom_no=bom_no, version=version).all()
|
||||
for rec in old_records:
|
||||
db.session.delete(rec)
|
||||
|
||||
# 【核心修复】:强制立即执行 DELETE 语句,为后续的 INSERT 腾出唯一键空间
|
||||
db.session.flush()
|
||||
|
||||
for child in children:
|
||||
bom = BomTable(
|
||||
@ -260,7 +265,11 @@ class BomService:
|
||||
existing = BomTable.query.filter_by(parent_id=parent_id).first()
|
||||
bom_no = existing.bom_no if existing else BomService.generate_bom_no()
|
||||
|
||||
BomTable.query.filter_by(bom_no=bom_no, version=version).delete()
|
||||
# 改为对象级删除以触发审计事件
|
||||
old_records = BomTable.query.filter_by(bom_no=bom_no, version=version).all()
|
||||
for rec in old_records:
|
||||
db.session.delete(rec)
|
||||
|
||||
for item in child_list:
|
||||
bom = BomTable(
|
||||
bom_no=bom_no, version=version, parent_id=parent_id,
|
||||
|
||||
@ -375,34 +375,29 @@ class MaterialBaseService:
|
||||
|
||||
if enable_warning_sort:
|
||||
print("====== [DEBUG] 成功进入预警强排逻辑 ======")
|
||||
# 直接在 order_by 中进行计算排序,不污染 select 列
|
||||
inv_val = inner_sub.c.total_inv
|
||||
red_val = cast(MaterialWarningSetting.red_threshold, Numeric)
|
||||
yellow_val = cast(MaterialWarningSetting.yellow_threshold, Numeric)
|
||||
|
||||
# 预警等级计算:红=2, 黄=1, 正常=0
|
||||
warning_level = case(
|
||||
(and_(MaterialWarningSetting.is_enabled.is_(True), inv_val <= red_val), 2),
|
||||
(and_(MaterialWarningSetting.is_enabled.is_(True), inv_val <= yellow_val), 1),
|
||||
(and_(MaterialWarningSetting.is_enabled.is_(True), red_val.isnot(None), inv_val <= red_val), 2),
|
||||
(and_(MaterialWarningSetting.is_enabled.is_(True), yellow_val.isnot(None), inv_val <= yellow_val), 1),
|
||||
else_=0
|
||||
)
|
||||
|
||||
# 统一计算缺口 (Shortage) = 目标阈值 - 当前库存
|
||||
# 红色算红色的缺口,黄色算黄色的缺口,越大说明缺的越多
|
||||
shortage = case(
|
||||
(and_(MaterialWarningSetting.is_enabled.is_(True), red_val.isnot(None), inv_val <= red_val), red_val - inv_val),
|
||||
(and_(MaterialWarningSetting.is_enabled.is_(True), yellow_val.isnot(None), inv_val <= yellow_val), yellow_val - inv_val),
|
||||
else_=0
|
||||
)
|
||||
# 红色预警时的缺口
|
||||
red_shortage = case(
|
||||
(and_(MaterialWarningSetting.is_enabled.is_(True), inv_val <= red_val), red_val - inv_val),
|
||||
else_=0
|
||||
)
|
||||
# 黄色预警时的缺口
|
||||
yellow_distance = case(
|
||||
(and_(MaterialWarningSetting.is_enabled.is_(True), inv_val > red_val, inv_val <= yellow_val), inv_val - red_val),
|
||||
else_=999999
|
||||
)
|
||||
|
||||
# 直接在 order_by 中使用 case() 表达式
|
||||
query = query.order_by(
|
||||
desc(warning_level),
|
||||
desc(red_shortage),
|
||||
asc(yellow_distance),
|
||||
desc(inv_val),
|
||||
desc(warning_level), # 1. 先按红、黄、正常排
|
||||
desc(shortage), # 2. 同级别内,缺口越大的排越上面
|
||||
desc(inv_val), # 3. 缺口一样,库存多的排上面
|
||||
desc(MaterialBase.id)
|
||||
)
|
||||
elif order_by_column:
|
||||
@ -462,12 +457,18 @@ class MaterialBaseService:
|
||||
item_dict['warningRed'] = float(warning_red) if warning_red is not None else None
|
||||
|
||||
# 计算预警状态
|
||||
if warning_enabled and warning_red is not None:
|
||||
if warning_enabled:
|
||||
invQty = item_dict['inventoryCount']
|
||||
if invQty <= warning_red:
|
||||
|
||||
# 优先判断红色预警(如果设置了红阈值,且库存 <= 红阈值)
|
||||
if warning_red is not None and invQty <= warning_red:
|
||||
item_dict['warningStatus'] = 2 # 红色
|
||||
|
||||
# 其次判断黄色预警(如果设置了黄阈值,且库存 <= 黄阈值)
|
||||
elif warning_yellow is not None and invQty <= warning_yellow:
|
||||
item_dict['warningStatus'] = 1 # 黄色
|
||||
|
||||
# 都不满足则正常
|
||||
else:
|
||||
item_dict['warningStatus'] = 0 # 正常
|
||||
else:
|
||||
|
||||
199
inventory-backend/app/services/inventory_task.py
Normal file
199
inventory-backend/app/services/inventory_task.py
Normal file
@ -0,0 +1,199 @@
|
||||
"""
|
||||
库存预警扫描与邮件通知服务
|
||||
|
||||
定时(或手动触发)扫描所有 is_enabled=True 且 is_ordered=False 的预警配置,
|
||||
按物料配置的邮箱独立发送,不依赖 SysUser 角色。
|
||||
|
||||
- 库存 <= red_threshold → 红色预警邮件(发 setting.red_emails)
|
||||
- red_threshold < 库存 <= yellow_threshold → 黄色预警邮件(发 setting.yellow_emails)
|
||||
- 同一收件人在多条记录中出现 → 聚合为一封邮件
|
||||
- 发送成功后更新 last_notified_at
|
||||
"""
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from collections import defaultdict
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.extensions import db
|
||||
from app.models.base import MaterialBase, MaterialWarningSetting
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from app.models.inbound.semi import StockSemi
|
||||
from app.models.inbound.product import StockProduct
|
||||
|
||||
|
||||
class InventoryWarningService:
|
||||
|
||||
@staticmethod
|
||||
def _get_total_inventory(base_id: int) -> float:
|
||||
"""
|
||||
计算指定物料在所有库存表(采购件 + 半成品 + 成品)中的总库存量
|
||||
"""
|
||||
buy_q = db.session.query(func.sum(StockBuy.stock_quantity)).filter(
|
||||
StockBuy.base_id == base_id
|
||||
).scalar() or 0
|
||||
semi_q = db.session.query(func.sum(StockSemi.stock_quantity)).filter(
|
||||
StockSemi.base_id == base_id
|
||||
).scalar() or 0
|
||||
prod_q = db.session.query(func.sum(StockProduct.stock_quantity)).filter(
|
||||
StockProduct.base_id == base_id
|
||||
).scalar() or 0
|
||||
return float(buy_q) + float(semi_q) + float(prod_q)
|
||||
|
||||
@staticmethod
|
||||
def _parse_emails(email_str: str) -> list:
|
||||
"""从逗号分隔字符串中提取并清洗有效邮箱列表"""
|
||||
if not email_str or not email_str.strip():
|
||||
return []
|
||||
return [e.strip() for e in email_str.split(',') if e.strip() and '@' in e.strip()]
|
||||
|
||||
@staticmethod
|
||||
def _build_text_table(rows: list, level: str) -> str:
|
||||
"""
|
||||
构建纯文本物料清单表格
|
||||
|
||||
Args:
|
||||
rows: [{"name": ..., "spec": ..., "qty": ..., "threshold": ...}, ...]
|
||||
level: "red" 或 "yellow",决定阈值列标题
|
||||
"""
|
||||
threshold_label = "红色阈值" if level == "red" else "黄色阈值"
|
||||
lines = [
|
||||
"名称 | 规格 | 当前库存 | " + threshold_label,
|
||||
"-" * 60,
|
||||
]
|
||||
for r in rows:
|
||||
name = r.get('name', '-') or '-'
|
||||
spec = r.get('spec', '-') or '-'
|
||||
qty = r.get('qty', '-')
|
||||
th = r.get('threshold', '-')
|
||||
lines.append(f"{name} | {spec} | {qty} | {th}")
|
||||
return '\n'.join(lines)
|
||||
|
||||
@staticmethod
|
||||
def check_and_send_warning_emails() -> dict:
|
||||
"""
|
||||
执行库存预警扫描与邮件发送
|
||||
|
||||
1. 查询所有 is_enabled=True 且 is_ordered=False 的预警配置
|
||||
2. 按 level 归类物料,按邮箱聚合(同一邮箱 → 一封邮件)
|
||||
3. 调用 send_email 发送,更新 last_notified_at
|
||||
|
||||
Returns:
|
||||
{
|
||||
"red_count": N, # 触发红色预警的物料数
|
||||
"yellow_count": N, # 触发黄色预警的物料数
|
||||
"red_sent": True/False,
|
||||
"yellow_sent": True/False,
|
||||
"timestamp": "..."
|
||||
}
|
||||
"""
|
||||
from app.utils.email_service import send_email
|
||||
|
||||
beijing_tz = timezone(timedelta(hours=8))
|
||||
now = datetime.now(beijing_tz)
|
||||
|
||||
# 查询启用了预警且未标记采购的配置
|
||||
settings = MaterialWarningSetting.query.filter(
|
||||
MaterialWarningSetting.is_enabled == True,
|
||||
MaterialWarningSetting.is_ordered == False
|
||||
).all()
|
||||
|
||||
red_rows_by_email = defaultdict(list) # email -> [物料row, ...]
|
||||
yellow_rows_by_email = defaultdict(list)
|
||||
|
||||
total_red = 0
|
||||
total_yellow = 0
|
||||
sent_red = False
|
||||
sent_yellow = False
|
||||
processed_settings = []
|
||||
|
||||
for setting in settings:
|
||||
base_id = setting.base_id
|
||||
material = MaterialBase.query.get(base_id)
|
||||
if not material:
|
||||
continue
|
||||
|
||||
name = material.name
|
||||
spec = material.spec_model or ''
|
||||
red_th = float(setting.red_threshold) if setting.red_threshold is not None else None
|
||||
yellow_th = float(setting.yellow_threshold) if setting.yellow_threshold is not None else None
|
||||
inv = InventoryWarningService._get_total_inventory(base_id)
|
||||
|
||||
# ★ 红色预警:库存 <= red_threshold,走 setting.red_emails ★
|
||||
if red_th is not None and inv <= red_th:
|
||||
total_red += 1
|
||||
red_emails = InventoryWarningService._parse_emails(setting.red_emails)
|
||||
if red_emails:
|
||||
processed_settings.append(setting)
|
||||
row = {
|
||||
'name': name,
|
||||
'spec': spec,
|
||||
'qty': round(inv, 2),
|
||||
'threshold': round(red_th, 2),
|
||||
}
|
||||
for email in red_emails:
|
||||
red_rows_by_email[email].append(row)
|
||||
else:
|
||||
print(f"[InventoryWarning] 物料「{name}」红单跳过:无 red_emails 配置")
|
||||
|
||||
# ★ 黄色预警:red_threshold < 库存 <= yellow_threshold,走 setting.yellow_emails ★
|
||||
elif (
|
||||
(red_th is not None and yellow_th is not None and red_th < inv <= yellow_th)
|
||||
or (red_th is None and yellow_th is not None and inv <= yellow_th)
|
||||
):
|
||||
total_yellow += 1
|
||||
yellow_emails = InventoryWarningService._parse_emails(setting.yellow_emails)
|
||||
if yellow_emails:
|
||||
processed_settings.append(setting)
|
||||
row = {
|
||||
'name': name,
|
||||
'spec': spec,
|
||||
'qty': round(inv, 2),
|
||||
'threshold': round(yellow_th, 2),
|
||||
}
|
||||
for email in yellow_emails:
|
||||
yellow_rows_by_email[email].append(row)
|
||||
else:
|
||||
print(f"[InventoryWarning] 物料「{name}」黄单跳过:无 yellow_emails 配置")
|
||||
else:
|
||||
continue
|
||||
|
||||
# ★ 按邮箱聚合,批量发送红色预警邮件 ★
|
||||
for email, rows in red_rows_by_email.items():
|
||||
table = InventoryWarningService._build_text_table(rows, 'red')
|
||||
subject = f"【红色预警】库存告急(共 {len(rows)} 条)"
|
||||
content = (
|
||||
f"您好,\n\n"
|
||||
f"以下物料当前库存已达到红色预警阈值,请立即处理采购:\n\n"
|
||||
f"{table}\n\n"
|
||||
"详情请登录仓库管理系统查看。\n\n"
|
||||
"此邮件由系统自动发送,请勿回复。"
|
||||
)
|
||||
send_email(email, subject, content)
|
||||
sent_red = True
|
||||
|
||||
# ★ 按邮箱聚合,批量发送黄色预警邮件 ★
|
||||
for email, rows in yellow_rows_by_email.items():
|
||||
table = InventoryWarningService._build_text_table(rows, 'yellow')
|
||||
subject = f"【黄色预警】库存偏低(共 {len(rows)} 条)"
|
||||
content = (
|
||||
f"您好,\n\n"
|
||||
f"以下物料当前库存已达到黄色预警阈值,请关注采购进度:\n\n"
|
||||
f"{table}\n\n"
|
||||
"详情请登录仓库管理系统查看。\n\n"
|
||||
"此邮件由系统自动发送,请勿回复。"
|
||||
)
|
||||
send_email(email, subject, content)
|
||||
sent_yellow = True
|
||||
|
||||
# ★ 批量更新 last_notified_at ★
|
||||
if processed_settings:
|
||||
for s in processed_settings:
|
||||
s.last_notified_at = now
|
||||
db.session.commit()
|
||||
|
||||
return {
|
||||
'red_count': total_red,
|
||||
'yellow_count': total_yellow,
|
||||
'red_sent': sent_red,
|
||||
'yellow_sent': sent_yellow,
|
||||
'timestamp': now.strftime('%Y-%m-%d %H:%M:%S')
|
||||
}
|
||||
@ -2,7 +2,7 @@ import uuid # .material -> .base refactor checked
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from sqlalchemy import or_, func, desc, and_
|
||||
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
|
||||
@ -12,6 +12,8 @@ from app.models.inbound.product import StockProduct
|
||||
from app.models.base import MaterialBase
|
||||
# 引入维修单表
|
||||
from app.models.transaction import TransRepair
|
||||
# 引入系统用户表
|
||||
from app.models.system import SysUser
|
||||
|
||||
|
||||
class OutboundService:
|
||||
@ -169,6 +171,22 @@ class OutboundService:
|
||||
beijing_tz = timezone(timedelta(hours=8))
|
||||
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 = {
|
||||
'stock_buy': StockBuy,
|
||||
'stock_semi': StockSemi,
|
||||
@ -190,11 +208,11 @@ class OutboundService:
|
||||
repair = TransRepair.query.with_for_update().get(stock_id)
|
||||
if not repair:
|
||||
raise ValueError(f"维修单不存在 (ID: {stock_id})")
|
||||
|
||||
|
||||
# 更新维修单状态为已出库
|
||||
repair.repair_status = '已出库'
|
||||
repair.shipping_date = current_time
|
||||
|
||||
|
||||
# 创建出库记录
|
||||
new_record = TransOutbound(
|
||||
sku=item.get('sku'),
|
||||
@ -235,6 +253,18 @@ class OutboundService:
|
||||
)
|
||||
db.session.add(new_record)
|
||||
|
||||
# ★ 出库后检查低库存预警
|
||||
try:
|
||||
from app.utils.stock_alert import check_and_alert
|
||||
check_and_alert(stock_record.base_id)
|
||||
except Exception as e:
|
||||
current_app.logger.warning(f"⚠️ 低库存预警检查失败: {e}")
|
||||
|
||||
# ★ 如果关联了审批单,出库成功后更新审批单状态为"已完成"
|
||||
if approval:
|
||||
approval.status = 3 # 3-已完成
|
||||
# updated_at 会在 commit 时由 SQLAlchemy 自动更新
|
||||
|
||||
db.session.commit()
|
||||
return outbound_no
|
||||
|
||||
@ -469,8 +499,6 @@ class OutboundService:
|
||||
|
||||
ModelClass = model_map.get(d.source_table)
|
||||
if ModelClass and d.stock_id:
|
||||
# 注意:这里在循环中查询可能会有N+1问题,但考虑到单页数据量(通常每单条目不多),暂时可接受
|
||||
# 生产环境建议优化为预加载或批量查询
|
||||
try:
|
||||
stock_item = ModelClass.query.get(d.stock_id)
|
||||
if stock_item:
|
||||
@ -525,3 +553,388 @@ class OutboundService:
|
||||
'pages': pagination.pages,
|
||||
'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))
|
||||
|
||||
# ★ 发送通知,附完整物料清单
|
||||
items = approval.get_items()
|
||||
send_new_request_notify(
|
||||
to_emails=emails,
|
||||
request_no=approval.request_no,
|
||||
applicant_name=applicant_name,
|
||||
remark=approval.remark or '',
|
||||
items=items
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# ★ 捕获所有异常,确保邮件发送失败不阻断主流程
|
||||
try:
|
||||
from flask import current_app
|
||||
current_app.logger.error(f"[Email] 发送新申请通知邮件失败: {e}")
|
||||
except RuntimeError:
|
||||
# 如果不在 Flask 应用上下文内,降级为标准日志
|
||||
import logging
|
||||
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):
|
||||
"""发送审批结果通知邮件(静默处理,不阻断主流程)"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from app.utils.email_service import send_approval_result_notify, send_warehouse_dispatch_notify
|
||||
from app.models.system import SysUser as SU
|
||||
|
||||
# 1. 提取申请人信息(供两个分支使用)
|
||||
applicant_name = ''
|
||||
applicant_emails = []
|
||||
if approval.applicant_id:
|
||||
user = SU.query.get(approval.applicant_id)
|
||||
if user:
|
||||
applicant_name = str(user.username).split('/')[0] if '/' in (user.username or '') else (user.username or '')
|
||||
if user.email:
|
||||
applicant_emails.append(user.email)
|
||||
|
||||
# 2. 提取物料明细(供通过分支使用)
|
||||
items = approval.items_json if approval.items_json else []
|
||||
|
||||
# 3. 分支逻辑
|
||||
if action == 'approve':
|
||||
# 3.1 通知库管(带明细)
|
||||
warehouse_role_codes = ['WAREHOUSE_MGR', 'OUTBOUND']
|
||||
warehouse_emails = OutboundApprovalService._get_emails_by_identifiers(role_codes=warehouse_role_codes)
|
||||
|
||||
if warehouse_emails:
|
||||
try:
|
||||
send_warehouse_dispatch_notify(
|
||||
to_emails=warehouse_emails,
|
||||
request_no=approval.request_no,
|
||||
applicant_name=applicant_name,
|
||||
items=items
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[Email] 通知库管失败: {e}")
|
||||
|
||||
# 3.2 通知申请人(已通过)
|
||||
if applicant_emails:
|
||||
try:
|
||||
send_approval_result_notify(
|
||||
to_emails=applicant_emails,
|
||||
request_no=approval.request_no,
|
||||
is_passed=True,
|
||||
applicant_name=applicant_name
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[Email] 通知申请人通过失败: {e}")
|
||||
|
||||
elif action == 'reject':
|
||||
# 3.3 通知申请人(已驳回)
|
||||
if applicant_emails:
|
||||
try:
|
||||
send_approval_result_notify(
|
||||
to_emails=applicant_emails,
|
||||
request_no=approval.request_no,
|
||||
is_passed=False,
|
||||
reject_reason=approval.reject_reason or '未说明原因',
|
||||
applicant_name=applicant_name
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[Email] 通知申请人驳回失败: {e}")
|
||||
else:
|
||||
logger.warning("[Email] 申请人无邮箱,无法发送驳回通知")
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
logger.error(f"[Email] 外层发送异常: {e}")
|
||||
|
||||
@staticmethod
|
||||
def 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)
|
||||
|
||||
@ -109,8 +109,10 @@ class PermissionService:
|
||||
try:
|
||||
# 1. 开启事务 (Flask-SQLAlchemy 自动管理,但明确逻辑更好)
|
||||
|
||||
# 2. 删除该角色旧的所有权限
|
||||
SysRolePermission.query.filter_by(role_code=role_code).delete()
|
||||
# 2. 删除该角色旧的所有权限(改为对象级删除以触发审计事件)
|
||||
old_perms = SysRolePermission.query.filter_by(role_code=role_code).all()
|
||||
for p in old_perms:
|
||||
db.session.delete(p)
|
||||
|
||||
# 3. 准备新数据
|
||||
if permissions:
|
||||
@ -374,10 +376,14 @@ class PermissionService:
|
||||
).all()
|
||||
|
||||
for menu in legacy_menus:
|
||||
# 删除关联的权限
|
||||
SysRolePermission.query.filter_by(target_code=menu.code).delete()
|
||||
# 删除关联的元素
|
||||
SysElement.query.filter_by(menu_code=menu.code).delete()
|
||||
# 删除关联的权限(改为对象级删除以触发审计事件)
|
||||
old_perms = SysRolePermission.query.filter_by(target_code=menu.code).all()
|
||||
for p in old_perms:
|
||||
db.session.delete(p)
|
||||
# 删除关联的元素(改为对象级删除以触发审计事件)
|
||||
old_elements = SysElement.query.filter_by(menu_code=menu.code).all()
|
||||
for e in old_elements:
|
||||
db.session.delete(e)
|
||||
# 删除菜单
|
||||
db.session.delete(menu)
|
||||
print(f"🗑️ 已清理旧版库存盘点菜单: {menu.code} ({menu.name})")
|
||||
@ -429,6 +435,7 @@ class PermissionService:
|
||||
('outbound_selection', '出库选单', '/outbound/selection', 'outbound_mgmt', 1),
|
||||
('outbound_create', '扫码出库', '/outbound/create', 'outbound_mgmt', 2),
|
||||
('outbound_list', '出库记录', '/outbound/index', 'outbound_mgmt', 3),
|
||||
('outbound_approval', '出库审批', '/outbound/approval', 'outbound_mgmt', 4),
|
||||
|
||||
# BOM管理子菜单
|
||||
('bom_manage', 'BOM配方管理', '/bom/manage', 'bom_mgmt', 1),
|
||||
@ -456,8 +463,10 @@ class PermissionService:
|
||||
).all()
|
||||
for menu in orphaned_menus:
|
||||
print(f"🗑️ 清理根级别冗余菜单: {menu.code} ({menu.name})")
|
||||
# 删除关联的权限
|
||||
SysRolePermission.query.filter_by(target_code=menu.code).delete()
|
||||
# 删除关联的权限(改为对象级删除以触发审计事件)
|
||||
old_perms = SysRolePermission.query.filter_by(target_code=menu.code).all()
|
||||
for p in old_perms:
|
||||
db.session.delete(p)
|
||||
db.session.delete(menu)
|
||||
|
||||
# 第二步:清理重复菜单(同一个 code 存在多条记录,保留 ID 最小的)
|
||||
@ -473,13 +482,20 @@ class PermissionService:
|
||||
# 保留第一条,删除其他
|
||||
for dup in duplicates[1:]:
|
||||
print(f"🗑️ 清理重复菜单: {dup.code} (id={dup.id}, name={dup.name})")
|
||||
SysRolePermission.query.filter_by(target_code=dup.code).delete()
|
||||
SysElement.query.filter_by(menu_code=dup.code).delete()
|
||||
# 改为对象级删除以触发审计事件
|
||||
old_perms = SysRolePermission.query.filter_by(target_code=dup.code).all()
|
||||
for p in old_perms:
|
||||
db.session.delete(p)
|
||||
old_elements = SysElement.query.filter_by(menu_code=dup.code).all()
|
||||
for e in old_elements:
|
||||
db.session.delete(e)
|
||||
db.session.delete(dup)
|
||||
|
||||
# 第三步:强制重新设置所有子菜单的 parent_id,确保没有遗漏
|
||||
# 先将所有子菜单的 parent_id 设为 None,然后重新设置
|
||||
SysMenu.query.filter(SysMenu.code.in_(child_codes)).update({SysMenu.parent_id: None})
|
||||
# 改为对象级更新以触发审计事件
|
||||
child_menus = SysMenu.query.filter(SysMenu.code.in_(child_codes)).all()
|
||||
for m in child_menus:
|
||||
m.parent_id = None
|
||||
|
||||
# 创建或更新菜单
|
||||
menu_map = {} # code -> menu obj
|
||||
|
||||
323
inventory-backend/app/utils/audit_events.py
Normal file
323
inventory-backend/app/utils/audit_events.py
Normal file
@ -0,0 +1,323 @@
|
||||
# inventory-backend/app/utils/audit_events.py
|
||||
"""
|
||||
全局无侵入的审计日志拦截器
|
||||
监听所有模型的增删改操作,自动提取旧值和新值存入 audit_logs 表
|
||||
完美对接前端 AuditLog.vue 的解析逻辑 (changes, deleted_snapshot, created)
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal
|
||||
from flask import request, has_request_context
|
||||
from sqlalchemy import event, text
|
||||
|
||||
|
||||
class AuditJSONEncoder(json.JSONEncoder):
|
||||
"""JSON 序列化增强器,支持 datetime/Decimal 等特殊类型"""
|
||||
def default(self, obj):
|
||||
if isinstance(obj, (datetime, date)):
|
||||
return obj.isoformat()
|
||||
if isinstance(obj, Decimal):
|
||||
return float(obj)
|
||||
return str(obj)
|
||||
|
||||
|
||||
def model_to_dict(obj):
|
||||
"""将 SQLAlchemy 模型实例转换为字典"""
|
||||
return {c.name: getattr(obj, c.name) for c in obj.__table__.columns}
|
||||
|
||||
|
||||
def get_current_user_info():
|
||||
"""
|
||||
从当前 HTTP 请求上下文中提取用户信息
|
||||
兼容 JWT 和匿名访问
|
||||
"""
|
||||
user_info = {
|
||||
'user_id': 'system',
|
||||
'username': 'system',
|
||||
'display_name': 'System',
|
||||
'ip_address': '127.0.0.1',
|
||||
'method': 'SYSTEM',
|
||||
'url': ''
|
||||
}
|
||||
|
||||
if has_request_context():
|
||||
# 获取 IP 地址
|
||||
user_info['ip_address'] = request.headers.get('X-Forwarded-For', '') or request.remote_addr or '127.0.0.1'
|
||||
if ',' in user_info['ip_address']:
|
||||
user_info['ip_address'] = user_info['ip_address'].split(',')[0].strip()
|
||||
|
||||
user_info['method'] = request.method
|
||||
user_info['url'] = request.path
|
||||
|
||||
# 尝试从 JWT 获取用户信息
|
||||
try:
|
||||
from flask_jwt_extended import get_jwt_identity, get_jwt
|
||||
user_id = get_jwt_identity()
|
||||
claims = get_jwt()
|
||||
|
||||
if user_id:
|
||||
user_info['user_id'] = str(user_id)
|
||||
if claims:
|
||||
user_info['username'] = claims.get('username', 'unknown')
|
||||
user_info['display_name'] = claims.get('display_name', claims.get('username', 'Unknown'))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return user_info
|
||||
|
||||
|
||||
def serialize_value(value):
|
||||
"""序列化单个值,确保 JSON 兼容"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (datetime, date)):
|
||||
return value.strftime('%Y-%m-%d %H:%M:%S')
|
||||
if isinstance(value, Decimal):
|
||||
return float(value)
|
||||
if isinstance(value, (bytes, bytearray)):
|
||||
try:
|
||||
return value.decode('utf-8')
|
||||
except Exception:
|
||||
return '[二进制数据]'
|
||||
return value
|
||||
|
||||
|
||||
# 需要忽略的审计字段(时间戳等自动维护字段)
|
||||
IGNORE_FIELDS = {
|
||||
'updated_at', 'update_time', 'modified_time', 'last_modified',
|
||||
'created_at', 'create_time', 'created_on', 'version',
|
||||
}
|
||||
|
||||
# 审计日志表名
|
||||
AUDIT_TABLE = 'audit_logs'
|
||||
|
||||
# 不需要审计的表
|
||||
IGNORE_TABLES = {'audit_logs', 'sys_log', 'syslog', 'alembic_version'}
|
||||
|
||||
|
||||
def insert_audit_log(connection, action, target, details):
|
||||
"""
|
||||
使用 connection.execute 直接插入审计日志
|
||||
避免干扰当前 session 事务,自动随主事务一起提交/回滚
|
||||
"""
|
||||
tablename = target.__tablename__
|
||||
|
||||
# 严禁监听日志表本身,防止无限递归
|
||||
if tablename in IGNORE_TABLES:
|
||||
return
|
||||
|
||||
# 获取目标 ID
|
||||
target_id = ''
|
||||
if hasattr(target, 'id'):
|
||||
target_id = str(target.id)
|
||||
elif hasattr(target, 'stock_id'):
|
||||
target_id = str(target.stock_id)
|
||||
elif hasattr(target, 'uuid'):
|
||||
target_id = str(target.uuid)
|
||||
elif hasattr(target, 'bom_no'):
|
||||
target_id = str(target.bom_no)
|
||||
|
||||
# 获取目标名称(用于展示)
|
||||
target_name = ''
|
||||
for name_field in ['name', 'title', 'material_name', 'product_name', 'display_name', 'username']:
|
||||
if hasattr(target, name_field):
|
||||
val = getattr(target, name_field)
|
||||
if val:
|
||||
target_name = str(val)
|
||||
break
|
||||
|
||||
# 如果当前表没名字,但它有关联的物料对象 (比如 material.name)
|
||||
if not target_name and hasattr(target, 'material') and target.material:
|
||||
target_name = getattr(target.material, 'name', '')
|
||||
|
||||
# 如果当前表有 material_id,尝试从关联的 material 表查询名称
|
||||
if not target_name and hasattr(target, 'material_id') and target.material_id:
|
||||
try:
|
||||
# 使用 connection 查询物料表获取名称
|
||||
result = connection.execute(
|
||||
text("SELECT name FROM material_base WHERE id = :id"),
|
||||
{'id': target.material_id}
|
||||
).fetchone()
|
||||
if result:
|
||||
target_name = str(result[0])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 如果实在找不到名字,再用 表名 + ID 兜底
|
||||
if not target_name:
|
||||
target_name = f"{tablename} ID:{target_id}"
|
||||
|
||||
user_info = get_current_user_info()
|
||||
|
||||
# 推断模块名称
|
||||
module = _infer_module_name(tablename, target)
|
||||
|
||||
# 使用原始 SQL 插入,确保事务一致性
|
||||
sql = text("""
|
||||
INSERT INTO audit_logs
|
||||
(user_id, username, display_name, action, module, target_id, target_name, details, ip_address, method, url, created_at)
|
||||
VALUES
|
||||
(:user_id, :username, :display_name, :action, :module, :target_id, :target_name, :details, :ip_address, :method, :url, :created_at)
|
||||
""")
|
||||
|
||||
connection.execute(sql, {
|
||||
'user_id': user_info['user_id'],
|
||||
'username': user_info['username'],
|
||||
'display_name': user_info['display_name'],
|
||||
'action': action,
|
||||
'module': module,
|
||||
'target_id': target_id,
|
||||
'target_name': target_name,
|
||||
'details': json.dumps(details, cls=AuditJSONEncoder),
|
||||
'ip_address': user_info['ip_address'],
|
||||
'method': user_info['method'],
|
||||
'url': user_info['url'],
|
||||
'created_at': datetime.now()
|
||||
})
|
||||
|
||||
|
||||
def _infer_module_name(tablename, target):
|
||||
"""根据表名或模型类推断所属模块"""
|
||||
class_name = target.__class__.__name__
|
||||
|
||||
if any(kw in class_name for kw in ['Stock', 'Buy', 'Inbound']):
|
||||
return '入库管理'
|
||||
if any(kw in class_name for kw in ['Outbound']):
|
||||
return '出库管理'
|
||||
if any(kw in class_name for kw in ['Borrow', 'Return']):
|
||||
return '借还管理'
|
||||
if any(kw in class_name for kw in ['Repair']):
|
||||
return '维修管理'
|
||||
if any(kw in class_name for kw in ['Scrap']):
|
||||
return '报废管理'
|
||||
if any(kw in class_name for kw in ['Bom', 'BOM']):
|
||||
return 'BOM管理'
|
||||
if any(kw in class_name for kw in ['StockTake', 'StockAdjust', 'Adjustment']):
|
||||
return '盘点管理'
|
||||
if any(kw in class_name for kw in ['Material', 'Base']):
|
||||
return '基础数据'
|
||||
if any(kw in class_name for kw in ['SysUser', 'SysMenu', 'SysRole', 'SysPermission']):
|
||||
return '系统管理'
|
||||
if any(kw in class_name for kw in ['Warehouse', 'Location']):
|
||||
return '库位管理'
|
||||
|
||||
return tablename or '未知模块'
|
||||
|
||||
|
||||
def _has_changes(history):
|
||||
"""检查历史记录对象是否有变更"""
|
||||
return history.has_changes()
|
||||
|
||||
|
||||
def register_audit_events(db):
|
||||
"""
|
||||
全局注册审计事件监听器
|
||||
监听所有模型的 INSERT/UPDATE/DELETE 事件
|
||||
"""
|
||||
from sqlalchemy import inspect
|
||||
|
||||
@event.listens_for(db.Model, 'before_update', propagate=True)
|
||||
def before_update_listener(mapper, connection, target):
|
||||
"""UPDATE 事件:抓取字段变更明细"""
|
||||
if target.__tablename__ in IGNORE_TABLES:
|
||||
return
|
||||
|
||||
try:
|
||||
state = inspect(target)
|
||||
changes = {}
|
||||
|
||||
for attr in state.attrs:
|
||||
prop = attr.key
|
||||
|
||||
# 跳过忽略字段
|
||||
if prop in IGNORE_FIELDS:
|
||||
continue
|
||||
|
||||
# 跳过关系属性
|
||||
if hasattr(attr, 'property') and hasattr(attr.property, 'direction'):
|
||||
continue
|
||||
|
||||
if _has_changes(attr.history):
|
||||
old_value = attr.history.deleted[0] if attr.history.deleted else None
|
||||
new_value = attr.history.added[0] if attr.history.added else None
|
||||
|
||||
# 序列化值
|
||||
old_serialized = serialize_value(old_value)
|
||||
new_serialized = serialize_value(new_value)
|
||||
|
||||
# 只记录真正变化的字段
|
||||
if old_serialized != new_serialized:
|
||||
changes[prop] = {
|
||||
'old': old_serialized,
|
||||
'new': new_serialized
|
||||
}
|
||||
|
||||
if changes:
|
||||
insert_audit_log(connection, 'UPDATE', target, {'changes': changes})
|
||||
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.error(f"Audit Update Error: {e}")
|
||||
|
||||
@event.listens_for(db.Model, 'before_delete', propagate=True)
|
||||
def before_delete_listener(mapper, connection, target):
|
||||
"""DELETE 事件:抓取被删除对象的完整快照"""
|
||||
if target.__tablename__ in IGNORE_TABLES:
|
||||
return
|
||||
|
||||
try:
|
||||
state = inspect(target)
|
||||
snapshot = {}
|
||||
|
||||
for attr in state.attrs:
|
||||
prop = attr.key
|
||||
|
||||
# 跳过忽略字段
|
||||
if prop in IGNORE_FIELDS:
|
||||
continue
|
||||
|
||||
# 跳过关系属性
|
||||
if hasattr(attr, 'property') and hasattr(attr.property, 'direction'):
|
||||
continue
|
||||
|
||||
value = getattr(target, prop, None)
|
||||
snapshot[prop] = serialize_value(value)
|
||||
|
||||
insert_audit_log(connection, 'DELETE', target, {'deleted_snapshot': snapshot})
|
||||
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.error(f"Audit Delete Error: {e}")
|
||||
|
||||
@event.listens_for(db.Model, 'after_insert', propagate=True)
|
||||
def after_insert_listener(mapper, connection, target):
|
||||
"""INSERT 事件:抓取新增对象的完整快照"""
|
||||
if target.__tablename__ in IGNORE_TABLES:
|
||||
return
|
||||
|
||||
try:
|
||||
state = inspect(target)
|
||||
snapshot = {}
|
||||
|
||||
for attr in state.attrs:
|
||||
prop = attr.key
|
||||
|
||||
# 跳过忽略字段
|
||||
if prop in IGNORE_FIELDS:
|
||||
continue
|
||||
|
||||
# 跳过关系属性
|
||||
if hasattr(attr, 'property') and hasattr(attr.property, 'direction'):
|
||||
continue
|
||||
|
||||
value = getattr(target, prop, None)
|
||||
snapshot[prop] = serialize_value(value)
|
||||
|
||||
insert_audit_log(connection, 'CREATE', target, {'created': snapshot})
|
||||
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.error(f"Audit Insert Error: {e}")
|
||||
|
||||
# 返回注册成功信息
|
||||
return True
|
||||
@ -1,11 +1,10 @@
|
||||
# app/utils/decorators.py
|
||||
from functools import wraps
|
||||
from flask_jwt_extended import get_jwt, verify_jwt_in_request, get_jwt_identity
|
||||
from flask import jsonify, g, request
|
||||
from flask import jsonify, g, request, current_app, has_request_context
|
||||
import logging
|
||||
import json
|
||||
|
||||
|
||||
def _verify_token_in_redis():
|
||||
"""
|
||||
验证当前 Token 是否与 Redis 中存储的 Token 一致(单设备登录互踢)
|
||||
@ -14,31 +13,23 @@ def _verify_token_in_redis():
|
||||
from flask import current_app
|
||||
|
||||
if redis_client is None:
|
||||
# Redis 不可用,跳过验证
|
||||
return True
|
||||
|
||||
try:
|
||||
# 获取请求中的 Token
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
if not auth_header.startswith('Bearer '):
|
||||
return True
|
||||
|
||||
request_token = auth_header[7:] # 去掉 'Bearer ' 前缀
|
||||
|
||||
# 获取当前用户 ID
|
||||
request_token = auth_header[7:]
|
||||
claims = get_jwt()
|
||||
user_id = claims.get('sub')
|
||||
if user_id is None:
|
||||
return True
|
||||
|
||||
# 从 Redis 获取存储的 Token
|
||||
stored_token = redis_client.get(f"user_token_{user_id}")
|
||||
|
||||
# 如果 Redis 中没有存储的 Token(可能是旧登录或 Redis 重启),允许通过
|
||||
if stored_token is None:
|
||||
return True
|
||||
|
||||
# 比较 Token 是否一致
|
||||
if request_token != stored_token:
|
||||
current_app.logger.warning(f"Token mismatch for user {user_id}: request token != stored token")
|
||||
return False
|
||||
@ -46,25 +37,18 @@ def _verify_token_in_redis():
|
||||
return True
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Redis token verification error: {e}")
|
||||
# 出错时默认放行,避免影响正常业务
|
||||
return True
|
||||
|
||||
|
||||
def _raise_token_mismatch_error():
|
||||
"""抛出 Token 不一致的错误(用于单设备登录互踢)"""
|
||||
"""抛出 Token 不一致的错误"""
|
||||
return jsonify({
|
||||
'msg': '您的账号已在其他设备登录,请重新登录',
|
||||
'code': 401,
|
||||
'reason': 'token_mismatch'
|
||||
}), 401
|
||||
|
||||
|
||||
def role_required(*roles):
|
||||
"""
|
||||
自定义装饰器:检查用户角色
|
||||
使用方法: @role_required('super_admin', 'finance')
|
||||
"""
|
||||
|
||||
"""自定义装饰器:检查用户角色"""
|
||||
def wrapper(fn):
|
||||
@wraps(fn)
|
||||
def decorator(*args, **kwargs):
|
||||
@ -72,7 +56,6 @@ def role_required(*roles):
|
||||
user_role = claims.get('role')
|
||||
user_role_upper = user_role.upper() if user_role else None
|
||||
|
||||
# 如果是超级管理员,拥有上帝视角,直接放行 (可选)
|
||||
if user_role_upper == 'SUPER_ADMIN':
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
@ -80,16 +63,11 @@ def role_required(*roles):
|
||||
return jsonify(msg='权限不足:您没有访问此资源的权限'), 403
|
||||
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return decorator
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def login_required(fn):
|
||||
"""
|
||||
验证 JWT 令牌是否存在且有效
|
||||
"""
|
||||
"""验证 JWT 令牌是否存在且有效"""
|
||||
@wraps(fn)
|
||||
def decorator(*args, **kwargs):
|
||||
try:
|
||||
@ -98,40 +76,31 @@ def login_required(fn):
|
||||
logging.warning(f"JWT verification failed: {e}")
|
||||
return jsonify(msg='登录已过期,请重新登录'), 401
|
||||
|
||||
# 单设备登录互踢检查
|
||||
if not _verify_token_in_redis():
|
||||
return _raise_token_mismatch_error()
|
||||
|
||||
return fn(*args, **kwargs)
|
||||
return decorator
|
||||
|
||||
|
||||
def permission_required(permission_code):
|
||||
"""
|
||||
检查当前用户是否拥有指定权限码
|
||||
使用方法: @permission_required('material:base:read')
|
||||
"""
|
||||
"""检查当前用户是否拥有指定权限码"""
|
||||
def wrapper(fn):
|
||||
@wraps(fn)
|
||||
def decorator(*args, **kwargs):
|
||||
# 首先验证 JWT
|
||||
try:
|
||||
verify_jwt_in_request()
|
||||
except Exception as e:
|
||||
logging.warning(f"JWT verification failed: {e}")
|
||||
return jsonify(msg='登录已过期,请重新登录'), 401
|
||||
|
||||
# 单设备登录互踢检查
|
||||
if not _verify_token_in_redis():
|
||||
return _raise_token_mismatch_error()
|
||||
|
||||
claims = get_jwt()
|
||||
user_role = claims.get('role')
|
||||
# 超级管理员放行 (忽略大小写)
|
||||
if user_role and user_role.upper() == 'SUPER_ADMIN':
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
# 根据角色查询数据库中的权限
|
||||
try:
|
||||
from app.services.auth_service import AuthService
|
||||
perm_dict = AuthService.get_user_permissions(user_role)
|
||||
@ -139,192 +108,24 @@ def permission_required(permission_code):
|
||||
logging.warning(f"Failed to fetch permissions for role {user_role}: {e}")
|
||||
return jsonify(msg='权限查询失败'), 403
|
||||
|
||||
# 合并菜单和元素权限
|
||||
all_perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
|
||||
if permission_code not in all_perms:
|
||||
# 详细的调试日志
|
||||
print(f"🔴 [权限拦截] 角色 '{user_role}' 访问被拒!需要权限码: '{permission_code}', 但该角色实际拥有: {all_perms}")
|
||||
logging.warning(
|
||||
f"权限检查失败: 角色={user_role}, 所需权限={permission_code}, 实际权限列表={all_perms}")
|
||||
logging.warning(f"权限检查失败: 角色={user_role}, 所需权限={permission_code}")
|
||||
return jsonify(msg='权限不足:您没有访问此资源的权限'), 403
|
||||
return fn(*args, **kwargs)
|
||||
return decorator
|
||||
return wrapper
|
||||
|
||||
|
||||
def audit_log(module: str, action: str = None, get_target_id_fn=None, get_target_name_fn=None, get_details_fn=None):
|
||||
def audit_log(module: str = None, action: str = None, get_target_id_fn=None, get_target_name_fn=None, get_details_fn=None):
|
||||
"""
|
||||
审计日志装饰器
|
||||
用法: @audit_log(module='inbound_buy', action='create')
|
||||
@audit_log(module='bom', action='update', get_target_id_fn=lambda: ..., get_details_fn=lambda req, resp: ...)
|
||||
|
||||
升级特性:
|
||||
- 自动捕获请求 Payload 作为变更明细
|
||||
- 自动过滤过长的 Base64 图片数据
|
||||
- 支持自定义 get_details_fn 覆盖默认行为
|
||||
已废弃!
|
||||
由 SQLAlchemy 底层监听器(app/core/audit_listener.py)全面接管审计日志入库。
|
||||
此装饰器保留空壳以防项目中其他文件 import 引用时报错。
|
||||
"""
|
||||
# 需要过滤的图片字段
|
||||
IMAGE_FIELDS = {'arrival_photo', 'product_photo', 'photo', 'image', 'signature', 'borrow_signature', 'return_signature'}
|
||||
|
||||
def _filter_payload(payload):
|
||||
"""过滤 Payload 中的大字段,防止数据库膨胀"""
|
||||
if not payload or not isinstance(payload, dict):
|
||||
return payload
|
||||
filtered = {}
|
||||
for key, value in payload.items():
|
||||
if key.lower() in IMAGE_FIELDS and isinstance(value, str) and len(value) > 100:
|
||||
filtered[key] = '[图片数据已省略]'
|
||||
elif isinstance(value, dict):
|
||||
filtered[key] = _filter_payload(value)
|
||||
elif isinstance(value, list):
|
||||
filtered[key] = [
|
||||
_filter_payload(item) if isinstance(item, dict) else item
|
||||
for item in value
|
||||
]
|
||||
else:
|
||||
filtered[key] = value
|
||||
return filtered
|
||||
|
||||
def _get_payload():
|
||||
"""自动获取请求 Payload"""
|
||||
# 尝试 JSON
|
||||
payload = request.get_json(silent=True)
|
||||
if payload:
|
||||
return payload
|
||||
# 尝试 Form Data
|
||||
if request.form:
|
||||
return request.form.to_dict()
|
||||
return None
|
||||
|
||||
def wrapper(fn):
|
||||
from functools import wraps
|
||||
@wraps(fn)
|
||||
def decorator(*args, **kwargs):
|
||||
# 获取请求上下文
|
||||
claims = get_jwt()
|
||||
user_id = get_jwt_identity()
|
||||
username = claims.get('username', '')
|
||||
display_name = claims.get('display_name', '')
|
||||
|
||||
# ★ 修复 DetachedInstanceError:在 fn() 执行前预先获取用户完整信息
|
||||
# 这样可以避免在 fn() 提交 session 后再访问 User 对象导致游离
|
||||
if not display_name and user_id:
|
||||
try:
|
||||
from app.models.system import SysUser
|
||||
user = SysUser.query.get(user_id)
|
||||
if user:
|
||||
display_name = user.display_name or username
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 预先获取 IP(避免后续访问 request 对象异常)
|
||||
ip_address = request.headers.get('X-Forwarded-For') or request.remote_addr or ''
|
||||
if ip_address and ',' in ip_address:
|
||||
ip_address = ip_address.split(',')[0].strip()
|
||||
|
||||
# 获取请求信息
|
||||
http_method = request.method
|
||||
url = request.url
|
||||
user_agent = request.headers.get('User-Agent', '')[:500]
|
||||
|
||||
# 解析 action(支持动态)
|
||||
final_action = action
|
||||
if callable(action):
|
||||
final_action = action()
|
||||
|
||||
# 预先获取 Payload(用于后续 details 记录)
|
||||
raw_payload = _get_payload()
|
||||
filtered_payload = _filter_payload(raw_payload) if raw_payload else None
|
||||
|
||||
# 执行原函数(此时 Session 可能被提交或回滚)
|
||||
response = fn(*args, **kwargs)
|
||||
|
||||
# 只记录成功的请求(响应状态码 200/201)
|
||||
status_code = 200
|
||||
if hasattr(response, 'status_code'):
|
||||
status_code = response.status_code
|
||||
|
||||
if status_code in [200, 201]:
|
||||
try:
|
||||
from app.models.audit import AuditLog
|
||||
from app.extensions import db
|
||||
from flask import current_app
|
||||
|
||||
# ★ 已在上方预先获取 display_name,此处无需再查询 User 对象
|
||||
# 使用预先获取的字符串数据,避免 DetachedInstanceError
|
||||
|
||||
# 获取 target_id
|
||||
target_id = None
|
||||
if get_target_id_fn:
|
||||
try:
|
||||
target_id = get_target_id_fn()
|
||||
except Exception:
|
||||
pass
|
||||
if not target_id and hasattr(response, 'json'):
|
||||
resp_data = response.get_json()
|
||||
if resp_data and isinstance(resp_data, dict):
|
||||
target_id = resp_data.get('id')
|
||||
|
||||
# 获取 target_name
|
||||
target_name = None
|
||||
if get_target_name_fn:
|
||||
try:
|
||||
target_name = get_target_name_fn()
|
||||
except Exception:
|
||||
pass
|
||||
# 如果仍未获取到目标名称,尝试从响应 JSON 中常见字段获取
|
||||
if not target_name and hasattr(response, 'json'):
|
||||
resp_data = response.get_json()
|
||||
if resp_data and isinstance(resp_data, dict):
|
||||
# 优先从顶层获取
|
||||
for field in ['order_no', 'outbound_no', 'borrow_no', 'adjustment_no', 'material_name']:
|
||||
if field in resp_data:
|
||||
target_name = resp_data[field]
|
||||
break
|
||||
# 再尝试从 data 字段获取(部分 API 返回格式)
|
||||
if not target_name and 'data' in resp_data:
|
||||
data = resp_data['data']
|
||||
if isinstance(data, dict):
|
||||
for field in ['order_no', 'outbound_no', 'borrow_no', 'adjustment_no', 'material_name']:
|
||||
if field in data:
|
||||
target_name = data[field]
|
||||
break
|
||||
|
||||
# 获取 details
|
||||
details = None
|
||||
if get_details_fn:
|
||||
# 优先使用自定义差异对比函数
|
||||
try:
|
||||
details = get_details_fn(request, response)
|
||||
except Exception:
|
||||
pass
|
||||
elif filtered_payload:
|
||||
# 默认:记录请求 Payload
|
||||
details = {'payload': filtered_payload}
|
||||
|
||||
# 保存日志
|
||||
log_entry = AuditLog(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
display_name=display_name,
|
||||
action=final_action or http_method.lower(),
|
||||
module=module,
|
||||
target_id=str(target_id) if target_id else None,
|
||||
target_name=target_name,
|
||||
details=details,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
method=http_method,
|
||||
url=url,
|
||||
status_code=status_code
|
||||
)
|
||||
db.session.add(log_entry)
|
||||
db.session.commit()
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"审计日志记录失败: {str(e)}")
|
||||
db.session.rollback()
|
||||
|
||||
return response
|
||||
|
||||
def decorator(*inner_args, **inner_kwargs):
|
||||
return fn(*inner_args, **inner_kwargs)
|
||||
return decorator
|
||||
return wrapper
|
||||
return wrapper
|
||||
256
inventory-backend/app/utils/email_service.py
Normal file
256
inventory-backend/app/utils/email_service.py
Normal file
@ -0,0 +1,256 @@
|
||||
"""
|
||||
邮件通知服务
|
||||
使用 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()
|
||||
|
||||
print(f"[DEBUG send_email] cfg = {cfg}")
|
||||
|
||||
# 发送总开关
|
||||
if not cfg.get('enabled'):
|
||||
print(f"[Email] 邮件功能已禁用 (MAIL_ENABLED=false),跳过发送: {subject}")
|
||||
logger.info(f"[Email] 邮件功能已禁用 (MAIL_ENABLED=false),跳过发送: {subject}")
|
||||
return
|
||||
|
||||
# 配置完整性检查
|
||||
if not cfg.get('server') or not cfg.get('username') or not cfg.get('password'):
|
||||
print(f"[Email] 邮件配置不完整 server={cfg.get('server')} username={cfg.get('username')} password={'已设' if cfg.get('password') else '空'},跳过发送")
|
||||
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:
|
||||
print("[Email] 收件人地址为空,跳过发送")
|
||||
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'))
|
||||
|
||||
print(f"DEBUG: 准备向服务器提交发信请求,收件人: {recipients} 发件人: {cfg['username']}")
|
||||
|
||||
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:
|
||||
print(f"!!! 邮件发送核心报错: SMTPAuthenticationError - 邮箱认证失败,请检查 MAIL_USERNAME / MAIL_PASSWORD(授权码)")
|
||||
logger.error("[Email] 邮箱认证失败,请检查 MAIL_USERNAME / MAIL_PASSWORD(授权码)")
|
||||
except smtplib.SMTPRecipientsRefused as e:
|
||||
print(f"!!! 邮件发送核心报错: SMTPRecipientsRefused - 收件人被服务器拒绝: {e}")
|
||||
logger.error(f"[Email] 收件人被服务器拒绝: {e}")
|
||||
except smtplib.SMTPException as e:
|
||||
print(f"!!! 邮件发送核心报错: SMTPException - {e}")
|
||||
logger.error(f"[Email] SMTP 异常: {e}")
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
print(f"!!! 邮件发送核心报错: {type(e).__name__} - {e}")
|
||||
logger.error(f"[Email] 发送邮件时发生未知异常: {e}")
|
||||
|
||||
|
||||
def send_new_request_notify(to_emails: List[str], request_no: str,
|
||||
applicant_name: str = '', remark: str = '',
|
||||
items: list = None):
|
||||
"""
|
||||
通知审批人有新的出库申请单待审批(可附带物料清单)
|
||||
|
||||
Args:
|
||||
to_emails: 审批人邮箱列表
|
||||
request_no: 审批单号
|
||||
applicant_name: 申请人姓名
|
||||
remark: 申请备注
|
||||
items: 物料明细列表(可选)
|
||||
"""
|
||||
print(f"[DEBUG send_new_request_notify] 入参 items={items}")
|
||||
print(f"[DEBUG send_new_request_notify] items 类型={type(items)}, 长度={len(items) if items else 0}")
|
||||
|
||||
# 拼装物料表格
|
||||
rows = []
|
||||
rows.append("名称 | 规格 | 计划数量")
|
||||
rows.append("-" * 40)
|
||||
if items:
|
||||
for item in items:
|
||||
name = item.get('name', '-') or '-'
|
||||
spec = item.get('spec_model', '-') or '-'
|
||||
qty = item.get('quantity', '-') or '-'
|
||||
rows.append(f"{name} | {spec} | {qty}")
|
||||
else:
|
||||
rows.append("(无物料明细)")
|
||||
|
||||
subject = f"【待审批】出库申请单 {request_no}"
|
||||
content = f"""您好,
|
||||
|
||||
您有一笔新的出库审批申请待处理:
|
||||
|
||||
申请单号:{request_no}
|
||||
申请人:{applicant_name or '未知'}
|
||||
备注说明:{remark or '无'}
|
||||
|
||||
物料清单如下:
|
||||
{chr(10).join(rows)}
|
||||
|
||||
---
|
||||
⚡ 快速通道:
|
||||
请点击下方链接直接进入系统审批:
|
||||
https://172.16.0.198/outbound/approval
|
||||
---
|
||||
|
||||
请登录仓库管理系统进行审批。
|
||||
|
||||
此邮件由系统自动发送,请勿回复。
|
||||
"""
|
||||
send_email(to_emails, subject, content)
|
||||
|
||||
|
||||
def send_approval_result_notify(to_emails: List[str], request_no: str,
|
||||
is_passed: bool, reject_reason: str = '',
|
||||
applicant_name: str = ''):
|
||||
"""
|
||||
通知审批结果
|
||||
|
||||
Args:
|
||||
to_emails: 收件人邮箱列表
|
||||
request_no: 审批单号
|
||||
is_passed: 是否通过(通过时发给库管,驳回时发给申请人)
|
||||
reject_reason: 驳回原因(仅 is_passed=False 时使用)
|
||||
applicant_name: 申请人姓名(仅驳回通知时使用)
|
||||
"""
|
||||
if is_passed:
|
||||
# ★ 发给申请人:告知已通过,去领料
|
||||
subject = f"【已通过】出库申请单 {request_no}"
|
||||
content = f"""{"尊敬的 " + applicant_name + ",您好" if applicant_name else "您好"},
|
||||
|
||||
您的出库申请单 {request_no} 已审批通过,请联系仓库管理员领取物料。
|
||||
|
||||
请登录仓库管理系统查看详情。
|
||||
|
||||
此邮件由系统自动发送,请勿回复。
|
||||
"""
|
||||
else:
|
||||
# ★ 发给申请人:告知被驳回
|
||||
subject = f"【已驳回】出库申请单 {request_no}"
|
||||
content = f"""{"尊敬的 " + applicant_name + ",您好" if applicant_name else "您好"},
|
||||
|
||||
出库申请单 {request_no} 已被审批驳回。
|
||||
|
||||
驳回原因:{reject_reason or '未填写'}
|
||||
|
||||
请登录仓库管理系统查看详情,并根据驳回原因调整后重新提交申请。
|
||||
|
||||
此邮件由系统自动发送,请勿回复。
|
||||
"""
|
||||
send_email(to_emails, subject, content)
|
||||
|
||||
|
||||
def send_warehouse_dispatch_notify(to_emails: List[str], request_no: str,
|
||||
applicant_name: str = '', items: list = None):
|
||||
"""
|
||||
通知库管备货出库(包含完整物料清单)
|
||||
|
||||
Args:
|
||||
to_emails: 库管邮箱列表
|
||||
request_no: 审批单号
|
||||
applicant_name: 申请人姓名
|
||||
items: 物料明细列表,每个元素包含 name/spec_model/warehouse_location/quantity
|
||||
"""
|
||||
print(f"[DEBUG send_warehouse_dispatch_notify] 入参 items={items}")
|
||||
print(f"[DEBUG send_warehouse_dispatch_notify] items 类型={type(items)}, 长度={len(items) if items else 0}")
|
||||
|
||||
rows = []
|
||||
rows.append("名称 | 规格 | 库位 | 计划数量")
|
||||
rows.append("-" * 50)
|
||||
if items:
|
||||
for item in items:
|
||||
name = item.get('name', '-') or '-'
|
||||
spec = item.get('spec_model', '-') or '-'
|
||||
loc = item.get('warehouse_location', '-') or '-'
|
||||
qty = item.get('quantity', '-') or '-'
|
||||
rows.append(f"{name} | {spec} | {loc} | {qty}")
|
||||
else:
|
||||
rows.append("(无物料明细)")
|
||||
|
||||
subject = f"【待出库】出库申请单 {request_no} 已审批通过"
|
||||
content = f"""您好,
|
||||
|
||||
出库申请单 {request_no} 已审批通过,请按以下清单准备备货:
|
||||
|
||||
{chr(10).join(rows)}
|
||||
|
||||
申请人:{applicant_name or '未知'}
|
||||
|
||||
请登录仓库管理系统执行"按单出库"操作。
|
||||
|
||||
此邮件由系统自动发送,请勿回复。
|
||||
"""
|
||||
send_email(to_emails, subject, content)
|
||||
print(f"DEBUG: 准备向服务器提交发信请求,收件人: {to_emails}")
|
||||
@ -48,4 +48,24 @@ class Config:
|
||||
# =========================================================
|
||||
# 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@iris-rs.cn')
|
||||
# 是否启用邮件发送功能(开发环境可设为 false 禁用)
|
||||
MAIL_ENABLED = os.getenv('MAIL_ENABLED', 'true').lower() in ('true', '1', 'yes')
|
||||
|
||||
@ -4,7 +4,7 @@ import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessageBox, ElMessage } from 'element-plus'
|
||||
import { InfoFilled, SwitchButton, UserFilled, Lock, User, ArrowDown } from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { getMyProfile, changeMyPassword } from '@/api/auth'
|
||||
import { getMyProfile, changeMyPassword, updateMyEmail } from '@/api/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@ -34,13 +34,15 @@ interface ProfileData {
|
||||
username: string
|
||||
display_name: string
|
||||
department: string
|
||||
email: string
|
||||
}
|
||||
|
||||
const profileForm = ref<ProfileData>({
|
||||
id: 0,
|
||||
username: '',
|
||||
display_name: '',
|
||||
department: ''
|
||||
department: '',
|
||||
email: ''
|
||||
})
|
||||
|
||||
const passwordForm = ref({
|
||||
@ -50,6 +52,62 @@ const passwordForm = ref({
|
||||
|
||||
const passwordFormRef = ref()
|
||||
|
||||
// ================================================================
|
||||
// 绑定/修改邮箱
|
||||
// ================================================================
|
||||
const emailDialogVisible = ref(false)
|
||||
const emailLoading = ref(false)
|
||||
const emailFormRef = ref()
|
||||
|
||||
interface EmailForm {
|
||||
email: string
|
||||
}
|
||||
|
||||
const emailForm = ref<EmailForm>({
|
||||
email: ''
|
||||
})
|
||||
|
||||
const emailRules = {
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱地址', trigger: 'blur' },
|
||||
{ type: 'email', message: '请输入正确的邮箱格式', trigger: ['blur', 'change'] }
|
||||
]
|
||||
}
|
||||
|
||||
// 打开邮箱弹窗
|
||||
const openEmailDialog = () => {
|
||||
emailForm.value.email = profileForm.value.email || ''
|
||||
emailDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 提交邮箱修改
|
||||
const submitEmailUpdate = async () => {
|
||||
const formRef = emailFormRef.value
|
||||
if (!formRef) return
|
||||
|
||||
await formRef.validate(async (valid: boolean) => {
|
||||
if (valid) {
|
||||
emailLoading.value = true
|
||||
try {
|
||||
await updateMyEmail({ email: emailForm.value.email })
|
||||
ElMessage.success('邮箱绑定成功')
|
||||
emailDialogVisible.value = false
|
||||
// 刷新个人资料
|
||||
openProfileDialog()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.msg || e?.message || '绑定失败')
|
||||
} finally {
|
||||
emailLoading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetEmailForm = () => {
|
||||
emailFormRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 打开个人中心弹窗
|
||||
const openProfileDialog = async () => {
|
||||
profileDialogVisible.value = true
|
||||
@ -176,7 +234,7 @@ const handleLogout = () => {
|
||||
<footer v-if="!isLoginPage" class="app-footer">
|
||||
<span class="version-tag">
|
||||
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
|
||||
当前版本:V3.11(4.8部署)
|
||||
当前版本:V3.16(4.29部署)
|
||||
</span>
|
||||
</footer>
|
||||
|
||||
@ -210,6 +268,12 @@ const handleLogout = () => {
|
||||
<!-- 【严格脱敏】系统角色字段已移除,不在此展示 -->
|
||||
</div>
|
||||
|
||||
<div style="margin: 16px 0; text-align: center;">
|
||||
<el-button type="primary" plain @click="openEmailDialog">
|
||||
绑定/修改邮箱
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-divider>
|
||||
<el-icon><Lock /></el-icon> 修改密码
|
||||
</el-divider>
|
||||
@ -260,6 +324,19 @@ const handleLogout = () => {
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 绑定/修改邮箱弹窗 -->
|
||||
<el-dialog v-model="emailDialogVisible" title="绑定/修改邮箱" width="400px" @close="resetEmailForm">
|
||||
<el-form :model="emailForm" :rules="emailRules" ref="emailFormRef" label-width="80px">
|
||||
<el-form-item label="新邮箱" prop="email">
|
||||
<el-input v-model="emailForm.email" placeholder="请输入有效邮箱地址" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="emailDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="emailLoading" @click="submitEmailUpdate">确认</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@ -66,4 +66,30 @@ export function changeMyPassword(data: { new_password: string; confirm_password:
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 【新增】自我更新邮箱(与密码修改完全隔离)
|
||||
export function updateMyEmail(data: { email: string }) {
|
||||
return request({
|
||||
url: '/v1/auth/me/email',
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 【新增】批量创建用户
|
||||
export function batchCreateUser(data: any[]) {
|
||||
return request({
|
||||
url: '/v1/auth/user/batch',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// ★ 获取可指定审批人列表(SUPERVISOR / SUPER_ADMIN 且 status=active)
|
||||
export function getApproversList() {
|
||||
return request({
|
||||
url: '/v1/auth/users/approvers',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
@ -77,4 +77,13 @@ export function getLatestSpecs() {
|
||||
url: '/inbound/base/spec-latest',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 8. 标记预警物料已采购
|
||||
export function markWarningOrdered(data: { baseId: number; isOrdered: boolean }) {
|
||||
return request({
|
||||
url: '/inbound/base/warning/mark-ordered',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
@ -77,4 +77,49 @@ export function getOutboundList(params: any) {
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交出库申请单(申请人 → 审批流)
|
||||
*/
|
||||
export function submitOutboundRequest(data: {
|
||||
items: Array<{
|
||||
material_type?: string
|
||||
name: string
|
||||
spec_model: string
|
||||
warehouse_location?: string
|
||||
quantity: number
|
||||
}>
|
||||
remark: string
|
||||
}) {
|
||||
return request({
|
||||
url: '/v1/outbound/request',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取出库审批申请单列表
|
||||
* @param params 支持 status, page, limit
|
||||
*/
|
||||
export function getApprovalRequestList(params: { status?: number | ''; page?: number; limit?: number }) {
|
||||
return request({
|
||||
url: '/v1/outbound/request',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 审批(通过 / 驳回)出库申请单
|
||||
* @param id 审批单ID
|
||||
* @param data action: 'approve' | 'reject',reject 时需传 reject_reason
|
||||
*/
|
||||
export function approveRequest(id: number, data: { action: 'approve' | 'reject'; reject_reason?: string }) {
|
||||
return request({
|
||||
url: `/v1/outbound/request/${id}/approve`,
|
||||
method: 'patch',
|
||||
data
|
||||
})
|
||||
}
|
||||
@ -150,6 +150,16 @@ const routes: Array<RouteRecordRaw> = [
|
||||
name: 'OutboundList',
|
||||
component: () => import('@/views/outbound/index.vue'),
|
||||
meta: { title: '出库记录' }
|
||||
},
|
||||
{
|
||||
path: 'approval',
|
||||
name: 'OutboundApproval',
|
||||
component: () => import('@/views/outbound/approval/index.vue'),
|
||||
meta: {
|
||||
title: '出库审批',
|
||||
icon: 'Stamp',
|
||||
roles: ['SUPER_ADMIN', 'SUPERVISOR']
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@ -69,6 +69,7 @@
|
||||
class="beautified-select"
|
||||
popper-class="bom-loadmore-popper parent-popper"
|
||||
@visible-change="(visible: boolean) => handleVisibleChange(visible, 'parent')"
|
||||
@change="onParentChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in parentOptions"
|
||||
@ -82,13 +83,26 @@
|
||||
</div>
|
||||
</el-option>
|
||||
</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-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<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-form-item>
|
||||
</el-col>
|
||||
<el-col :span="16"></el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
@ -134,34 +148,45 @@
|
||||
/>
|
||||
|
||||
<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 }">
|
||||
<!-- ====== 改造:子件下拉 - 远程搜索 + 懒加载 ====== -->
|
||||
<el-select
|
||||
v-model="row.child_id"
|
||||
placeholder="请搜索原料"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
:remote-method="(q: string) => handleRemoteSearch(q, 'child', $index)"
|
||||
:loading="selectLoading"
|
||||
style="width: 100%"
|
||||
:loading-text="`正在加载第 ${childQueryParams.page} 页...`"
|
||||
:popper-class="`bom-loadmore-popper child-popper-${$index}`"
|
||||
@visible-change="(visible: boolean) => handleVisibleChange(visible, 'child', $index)"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in getChildOptions($index)"
|
||||
:key="item.id"
|
||||
:label="`${item.name} (${item.spec})`"
|
||||
:value="item.id"
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<el-select
|
||||
v-model="row.child_id"
|
||||
placeholder="请搜索原料"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
style="flex: 1;"
|
||||
:remote-method="(q: string) => handleRemoteSearch(q, 'child', $index)"
|
||||
:loading="selectLoading"
|
||||
:loading-text="`正在加载第 ${childQueryParams.page} 页...`"
|
||||
:popper-class="`bom-loadmore-popper child-popper-${$index}`"
|
||||
@visible-change="(visible: boolean) => handleVisibleChange(visible, 'child', $index)"
|
||||
>
|
||||
<div class="option-row">
|
||||
<span class="option-name">{{ item.name }}</span>
|
||||
<span class="option-spec">{{ item.spec }}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<el-option
|
||||
v-for="item in getChildOptions($index)"
|
||||
:key="item.id"
|
||||
:label="`${item.name} (${item.spec})`"
|
||||
:value="item.id"
|
||||
>
|
||||
<div class="option-row">
|
||||
<span class="option-name">{{ item.name }}</span>
|
||||
<span class="option-spec">{{ item.spec }}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</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>
|
||||
</el-table-column>
|
||||
|
||||
@ -201,8 +226,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
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 { getMaterialBaseList } from '@/api/inbound/stock'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
@ -230,6 +257,8 @@ interface ChildRow {
|
||||
}
|
||||
|
||||
const userStore = useUserStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
@ -464,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)
|
||||
const permissionMap: Record<string, string> = {
|
||||
bom_no: 'bom_manage:bom_no',
|
||||
@ -603,16 +657,40 @@ const loadDetail = async (bomNo: string, version: string) => {
|
||||
const res = await getBomDetail(bomNo, version)
|
||||
if (res.code === 200) {
|
||||
const data = res.data
|
||||
form.parent_id = data.parent_id
|
||||
form.version = data.version
|
||||
form.is_enabled = data.is_enabled
|
||||
// 1. 映射子件基本数据
|
||||
form.children = data.children.map((child: any) => ({
|
||||
child_id: child.child_id,
|
||||
dosage: child.dosage,
|
||||
remark: child.remark || ''
|
||||
}))
|
||||
// 为每个子件行初始化下拉状态
|
||||
form.children.forEach((_, idx) => initChildDropdownState(idx))
|
||||
|
||||
// 2. 初始化子件下拉状态,并预填充 options 解决回显显示 ID 的问题
|
||||
form.children.forEach((child, idx) => {
|
||||
initChildDropdownState(idx)
|
||||
|
||||
if (child.child_id) {
|
||||
const state = childDropdownStates.value.get(idx)!
|
||||
// 从原始 data.children 中取对应的名称和规格注入 options
|
||||
const rawChildData = data.children[idx]
|
||||
state.options = [{
|
||||
id: rawChildData.child_id,
|
||||
name: rawChildData.child_name || '未知物料', // 依赖后端返回 child_name
|
||||
spec: rawChildData.child_spec || '' // 依赖后端返回 child_spec
|
||||
}]
|
||||
state.hasMore = false
|
||||
}
|
||||
})
|
||||
|
||||
// 3. 处理父件回显,预填充 parentOptions
|
||||
if (data.parent_id) {
|
||||
form.parent_id = data.parent_id
|
||||
parentOptions.value = [{
|
||||
id: data.parent_id,
|
||||
name: data.parent_name || '未知产品', // 依赖后端返回 parent_name
|
||||
spec: data.parent_spec || '' // 依赖后端返回 parent_spec
|
||||
}]
|
||||
}
|
||||
|
||||
if (data.parent_spec) {
|
||||
form.bom_no = (data.parent_spec || '').split('/')[0].trim()
|
||||
} else {
|
||||
@ -721,6 +799,55 @@ const submitForm = async () => {
|
||||
|
||||
onMounted(() => {
|
||||
fetchBomList()
|
||||
|
||||
// 【新增】:处理外部跳转自动打开 BOM(带查重保护)
|
||||
if (route.query.create_for_id) {
|
||||
const parentId = Number(route.query.create_for_id);
|
||||
const parentName = (route.query.parent_name as string) || '';
|
||||
const parentSpec = (route.query.parent_spec as string) || '';
|
||||
|
||||
// 把名称填入背景搜索框,让背后的表格也只显示相关的BOM
|
||||
searchKeyword.value = parentName;
|
||||
|
||||
// 延迟等待基础渲染
|
||||
setTimeout(() => {
|
||||
// 1. 先用 keyword 查询是否已有该父件的 BOM
|
||||
getBomList({ keyword: parentName }).then((res: any) => {
|
||||
const rows = res.data || [];
|
||||
// 严格校验 parent_id
|
||||
const existingBom = rows.find((b: any) => b.parent_id === parentId);
|
||||
|
||||
if (existingBom) {
|
||||
// ★ 情况 A:已经有BOM了,直接打开编辑弹窗并拉取历史数据
|
||||
ElMessage.success('检测到该物料已有 BOM,已自动为您打开编辑');
|
||||
handleEdit(existingBom);
|
||||
} else {
|
||||
// ★ 情况 B:还没建过BOM,打开新建并注入父件
|
||||
handleCreate();
|
||||
|
||||
// 强行注入父件远程搜索选项
|
||||
parentOptions.value = [{
|
||||
id: parentId,
|
||||
name: parentName,
|
||||
spec: parentSpec
|
||||
}];
|
||||
|
||||
// 给表单赋值
|
||||
form.parent_id = parentId;
|
||||
|
||||
// 触发联动逻辑(自动带出版本和生成编号)
|
||||
if (typeof onParentChange === 'function') {
|
||||
setTimeout(() => {
|
||||
onParentChange(parentId);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('BOM 查重失败', err);
|
||||
ElMessage.error('获取 BOM 状态失败,请手动操作');
|
||||
});
|
||||
}, 300);
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@ -18,6 +18,32 @@
|
||||
|
||||
<div class="card-body">
|
||||
<h2>IRIS 库存管理系统</h2>
|
||||
|
||||
<div style="display: flex; justify-content: center; margin: 20px 0 30px;">
|
||||
<el-autocomplete
|
||||
v-model="globalSearchText"
|
||||
:fetch-suggestions="queryGlobalSearch"
|
||||
placeholder="全局搜索:输入物料名称、规格、条码或 BOM 编号..."
|
||||
style="width: 60%; max-width: 600px;"
|
||||
size="large"
|
||||
clearable
|
||||
@select="handleSearchSelect"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
<template #default="{ item }">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; line-height: 1.5; padding: 4px 0;">
|
||||
<div>
|
||||
<div style="font-size: 14px; font-weight: bold; color: #303133;">{{ item.title }}</div>
|
||||
<div style="font-size: 12px; color: #909399;">{{ item.subtitle }}</div>
|
||||
</div>
|
||||
<el-tag size="small" :type="getBadgeType(item.type)">{{ item.badge }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-autocomplete>
|
||||
</div>
|
||||
|
||||
<p class="subtitle">请选择您要进行的业务操作:</p>
|
||||
|
||||
<div class="action-buttons">
|
||||
@ -215,8 +241,9 @@ import { useRouter } from 'vue-router'
|
||||
// 1. 引入 User Store
|
||||
import { useUserStore } from '@/stores/user'
|
||||
// 引入需要的图标
|
||||
import { Box, TrendCharts, ShoppingCart, Operation, Setting, Location, Plus, Edit, Delete, Close } from '@element-plus/icons-vue'
|
||||
import { Box, TrendCharts, ShoppingCart, Operation, Setting, Location, Plus, Edit, Delete, Close, Search } from '@element-plus/icons-vue'
|
||||
import { getPrinterConfig, updatePrinterConfig } from '@/api/common/print'
|
||||
import request from '@/utils/request'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getWarehouseTree, createWarehouse, updateWarehouse, deleteWarehouse, batchDeleteWarehouse, batchGenerateWarehouse } from '@/api/common/warehouse'
|
||||
|
||||
@ -234,6 +261,61 @@ const printerForm = reactive({
|
||||
})
|
||||
const loading = ref(false)
|
||||
|
||||
// 全局搜索相关
|
||||
const globalSearchText = ref('')
|
||||
|
||||
const getBadgeType = (type: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'material': 'success',
|
||||
'stock_buy': 'primary',
|
||||
'bom': 'warning'
|
||||
}
|
||||
return map[type] || 'info'
|
||||
}
|
||||
|
||||
const queryGlobalSearch = async (queryString: string, cb: (data: any[]) => void) => {
|
||||
if (!queryString || queryString.trim() === '') {
|
||||
cb([])
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res: any = await request({
|
||||
url: '/v1/common/global-search',
|
||||
method: 'get',
|
||||
params: { keyword: queryString.trim() }
|
||||
})
|
||||
if (res.code === 200 && res.data) {
|
||||
cb(res.data)
|
||||
} else {
|
||||
cb([])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('全局搜索失败:', error)
|
||||
cb([])
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearchSelect = (item: any) => {
|
||||
globalSearchText.value = ''
|
||||
|
||||
if (item.type === 'material') {
|
||||
router.push({
|
||||
path: '/material/index',
|
||||
query: { edit_id: item.id, keyword: item.title }
|
||||
})
|
||||
} else if (item.type === 'stock_buy') {
|
||||
router.push({
|
||||
path: '/inventory/buy',
|
||||
query: { keyword: item.title }
|
||||
})
|
||||
} else if (item.type === 'bom') {
|
||||
router.push({
|
||||
path: '/bom',
|
||||
query: { keyword: item.title }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const openPrinterDialog = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
@ -254,23 +254,28 @@
|
||||
<span v-if="getImagesOnly(row.generalImage).length > 1" class="more-badge">+{{getImagesOnly(row.generalImage).length}}</span>
|
||||
</div>
|
||||
|
||||
<el-popover v-if="row.generalManual && row.generalManual.length > 0" placement="top" trigger="hover" width="200">
|
||||
<el-popover v-if="row.generalManual && row.generalManual.length > 0" placement="top" trigger="hover" width="260">
|
||||
<template #reference>
|
||||
<el-button link type="primary" :icon="Document" />
|
||||
<el-button link type="primary" :icon="row.generalManual.some(l => !isExternalLink(l) && !isImageFile(l)) ? Files : Document" />
|
||||
</template>
|
||||
<div style="display: flex; flex-direction: column; gap: 5px;">
|
||||
<div v-for="(link, idx) in row.generalManual" :key="idx">
|
||||
<el-link v-if="isExternalLink(link)" :href="link" target="_blank" type="primary" :underline="false">
|
||||
说明书 {{idx+1}} <el-icon><Link /></el-icon>
|
||||
</el-link>
|
||||
<el-image v-else-if="isImageFile(link)"
|
||||
style="width: 100px; height: 100px"
|
||||
:src="getImageUrl(link)"
|
||||
:preview-src-list="[getImageUrl(link)]"
|
||||
fit="cover"
|
||||
preview-teleported
|
||||
<!-- 图片文件 -->
|
||||
<div v-for="(link, idx) in row.generalManual.filter(l => !isExternalLink(l) && isImageFile(l))" :key="'img-' + idx">
|
||||
<el-image
|
||||
style="width: 80px; height: 80px; cursor: pointer;"
|
||||
:src="getImageUrl(link)"
|
||||
:preview-src-list="row.generalManual.filter(l => !isExternalLink(l) && isImageFile(l)).map(u => getImageUrl(u))"
|
||||
fit="cover"
|
||||
preview-teleported
|
||||
/>
|
||||
<el-link v-else :href="getImageUrl(link)" target="_blank" type="info">PDF 文件 {{idx+1}}</el-link>
|
||||
<span style="font-size: 12px; color: #999;">图片 {{idx+1}}</span>
|
||||
</div>
|
||||
<!-- 非图片文件 -->
|
||||
<div v-for="(link, idx) in row.generalManual.filter(l => !isExternalLink(l) && !isImageFile(l))" :key="'file-' + idx">
|
||||
<el-link @click.prevent="handleDownloadConfirm(link)" type="info" :underline="false">
|
||||
<el-icon><Files /></el-icon>
|
||||
{{ link.split('/').pop() }}
|
||||
</el-link>
|
||||
</div>
|
||||
</div>
|
||||
</el-popover>
|
||||
@ -297,10 +302,14 @@
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="userStore.hasPermission('material_list:operation')" label="操作" min-width="200" fixed="right" align="center">
|
||||
<el-table-column v-if="userStore.hasPermission('material_list:operation')" label="操作" width="280" fixed="right" align="center">
|
||||
<template #default="scope">
|
||||
<el-button v-if="userStore.hasPermission('material_list:operation')" link type="primary" size="small" @click="handleEdit(scope.row)">编辑</el-button>
|
||||
<el-button v-if="userStore.hasPermission('material_list:edit_warning')" link type="warning" size="small" @click="handleSetSingleWarning(scope.row)">设置预警</el-button>
|
||||
<template v-if="userStore.hasPermission('material_list:edit_warning') && scope.row.warningStatus > 0">
|
||||
<el-button v-if="scope.row.warningOrdered" disabled size="small" type="info">采购在途</el-button>
|
||||
<el-button v-else link type="success" size="small" @click="handleMarkOrdered(scope.row)">标记已采购</el-button>
|
||||
</template>
|
||||
<el-button v-if="userStore.hasPermission('material_list:operation')" link type="danger" size="small" @click="handleDelete(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@ -321,8 +330,7 @@
|
||||
|
||||
<el-dialog
|
||||
v-model="dialog.visible"
|
||||
:title="dialog.title"
|
||||
width="700px"
|
||||
width="1200px"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
@close="cancel"
|
||||
@ -330,6 +338,20 @@
|
||||
:close-on-press-escape="!isUploading"
|
||||
:show-close="!isUploading"
|
||||
>
|
||||
<template #header>
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; padding-right: 20px;">
|
||||
<span style="font-size: 18px; font-weight: 500;">{{ dialog.title }}</span>
|
||||
<el-link
|
||||
v-if="form.id"
|
||||
type="success"
|
||||
:underline="false"
|
||||
style="font-size: 14px;"
|
||||
@click="createBomForMaterial"
|
||||
>
|
||||
<el-icon style="margin-right: 4px"><Plus /></el-icon>加入或查看BOM
|
||||
</el-link>
|
||||
</div>
|
||||
</template>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
|
||||
|
||||
<el-row>
|
||||
@ -361,6 +383,7 @@
|
||||
<el-form-item label="类别" prop="category" v-if="hasFieldPermission('category')">
|
||||
<div style="display: flex; width: 100%; align-items: center;">
|
||||
<el-cascader
|
||||
ref="categoryCascaderRef"
|
||||
v-model="tempCategoryPrefix"
|
||||
:options="categoryTreeOptions"
|
||||
:props="{ expandTrigger: 'hover', checkStrictly: true, emitPath: true }"
|
||||
@ -368,6 +391,7 @@
|
||||
filterable
|
||||
clearable
|
||||
style="width: 50%;"
|
||||
@change="onCategoryChange"
|
||||
/>
|
||||
<div style="padding: 0 8px; font-weight: bold; color: #909399;">/</div>
|
||||
<el-input
|
||||
@ -377,9 +401,7 @@
|
||||
style="width: 50%;"
|
||||
/>
|
||||
</div>
|
||||
<div style="font-size: 12px; color: #E6A23C; margin-top: 4px; line-height: 1.2;">
|
||||
* 必须构成4层结构
|
||||
</div>
|
||||
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@ -457,7 +479,32 @@
|
||||
:on-remove="(file) => handleRemoveImage(file, 'generalManual')"
|
||||
:before-upload="beforeAvatarUpload"
|
||||
>
|
||||
<el-icon><Plus /></el-icon>
|
||||
<template #default>
|
||||
<div v-if="!fileListManual.length" class="upload-add-trigger">
|
||||
<el-icon><Plus /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
<template #file="{ file }">
|
||||
<div class="upload-file-item">
|
||||
<template v-if="isImageFile(file.url)">
|
||||
<img class="el-upload-list__item-thumbnail" :src="file.url" alt="" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="file-thumbnail">
|
||||
<el-icon size="28"><Document /></el-icon>
|
||||
<span class="file-name">{{ truncateFileName(file.name) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<span class="el-upload-list__item-actions">
|
||||
<span class="el-upload-list__item-preview" @click="handlePreviewPicture(file)">
|
||||
<el-icon><ZoomIn /></el-icon>
|
||||
</span>
|
||||
<span class="el-upload-list__item-delete" @click="() => handleRemoveImage(file, 'generalManual')">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
<div class="camera-card" @click="triggerCamera('generalManual')">
|
||||
<el-icon><Camera /></el-icon><span class="text">拍照</span>
|
||||
@ -517,10 +564,16 @@
|
||||
<el-input-number v-model="warningForm.redThreshold" :min="0" :precision="0" step="1" placeholder="库存≤此值为红色预警" style="width: 100%" />
|
||||
<div class="form-tip">库存数量 ≤ 此值时显示红色预警</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="红色预警邮箱" v-if="warningForm.isEnabled">
|
||||
<el-input v-model="warningForm.redEmails" placeholder="逗号分隔多个邮箱" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="黄色阈值" prop="yellowThreshold" v-if="warningForm.isEnabled">
|
||||
<el-input-number v-model="warningForm.yellowThreshold" :min="0" :precision="0" step="1" placeholder="库存≤此值为黄色预警" style="width: 100%" />
|
||||
<div class="form-tip">红色阈值 < 库存 ≤ 此值时显示黄色预警</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="黄色预警邮箱" v-if="warningForm.isEnabled">
|
||||
<el-input v-model="warningForm.yellowEmails" placeholder="逗号分隔多个邮箱" clearable />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
@ -563,10 +616,13 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, nextTick, computed } from 'vue';
|
||||
import { Plus, Document, Refresh, Setting, Rank, Camera, Link, Download, Bell, CircleCheck } from '@element-plus/icons-vue';
|
||||
import { Plus, Document, Refresh, Setting, Rank, Camera, Link, Download, Bell, CircleCheck, Files, ZoomIn, Delete } from '@element-plus/icons-vue';
|
||||
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus';
|
||||
import type { FormInstance, FormRules } from 'element-plus';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
import {
|
||||
listMaterialBase,
|
||||
@ -576,7 +632,8 @@ import {
|
||||
getMaterialBaseOptions,
|
||||
exportAssetStatistics,
|
||||
batchSetWarning,
|
||||
batchSetInspection
|
||||
batchSetInspection,
|
||||
markWarningOrdered
|
||||
} from '@/api/material_base';
|
||||
import { uploadFile, deleteFile } from '@/api/common/upload';
|
||||
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue';
|
||||
@ -600,6 +657,8 @@ interface MaterialBaseVO {
|
||||
statusLoading?: boolean;
|
||||
inventoryCount?: number;
|
||||
availableCount?: number;
|
||||
warningStatus?: number;
|
||||
warningOrdered?: boolean;
|
||||
}
|
||||
|
||||
interface QueryParams {
|
||||
@ -673,6 +732,9 @@ const cameraDialogVisible = ref(false);
|
||||
const cameraRef = ref<InstanceType<typeof WebRtcCamera> | null>(null);
|
||||
const currentCameraField = ref<'generalImage' | 'generalManual'>('generalImage');
|
||||
|
||||
// 脏检查 - 记录编辑前的原始数据
|
||||
const originalForm = ref<any>(null);
|
||||
|
||||
// 复选框选中数据
|
||||
const selectedItems = ref<MaterialBaseVO[]>([]);
|
||||
const handleSelectionChange = (selection: MaterialBaseVO[]) => {
|
||||
@ -742,7 +804,9 @@ const warningLoading = ref(false);
|
||||
const warningForm = reactive({
|
||||
isEnabled: false,
|
||||
redThreshold: undefined as number | undefined,
|
||||
yellowThreshold: undefined as number | undefined
|
||||
yellowThreshold: undefined as number | undefined,
|
||||
redEmails: '',
|
||||
yellowEmails: ''
|
||||
});
|
||||
const warningRules = {
|
||||
yellowThreshold: [
|
||||
@ -842,6 +906,16 @@ const categoryOptions = ref<string[]>([]);
|
||||
const typeOptions = ref<string[]>([]);
|
||||
const categoryTreeOptions = ref<CascaderOption[]>([]);
|
||||
|
||||
// 类别级联选择器的 ref
|
||||
const categoryCascaderRef = ref<any>(null);
|
||||
|
||||
// 选中类别后自动收起下拉面板
|
||||
const onCategoryChange = () => {
|
||||
if (categoryCascaderRef.value) {
|
||||
categoryCascaderRef.value.togglePopperVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
const tempCategoryPrefix = ref<string[]>([]);
|
||||
const tempCategorySuffix = ref<string>('');
|
||||
|
||||
@ -891,19 +965,8 @@ const validateCategoryLevel = (rule: any, value: any, callback: any) => {
|
||||
|
||||
if (!prefixStr && !suffixStr) {
|
||||
callback(new Error('请填写或选择类别'));
|
||||
return;
|
||||
}
|
||||
|
||||
let fullPath = '';
|
||||
if (prefixStr && suffixStr) fullPath = prefixStr + '/' + suffixStr;
|
||||
else if (prefixStr) fullPath = prefixStr;
|
||||
else fullPath = suffixStr;
|
||||
|
||||
const levels = fullPath.split('/').filter(p => p.trim() !== '').length;
|
||||
|
||||
if (levels !== 4) {
|
||||
callback(new Error(`必须严格满足4层结构,当前为 ${levels} 层`));
|
||||
} else {
|
||||
// 只要有前缀或后缀,就直接放行,不再限制必须是4层
|
||||
callback();
|
||||
}
|
||||
};
|
||||
@ -1116,6 +1179,9 @@ const handleEdit = (row: MaterialBaseVO) => {
|
||||
const data = JSON.parse(JSON.stringify(row));
|
||||
Object.assign(form.value, data);
|
||||
|
||||
// 深拷贝保存原始数据用于脏检查
|
||||
originalForm.value = JSON.parse(JSON.stringify(data));
|
||||
|
||||
if (data.category) {
|
||||
const parts = data.category.split('/');
|
||||
if (parts.length > 0) {
|
||||
@ -1164,6 +1230,33 @@ const checkDuplicate = async (name: string, spec: string): Promise<boolean> => {
|
||||
return false;
|
||||
};
|
||||
|
||||
const isArraysEqual = (a: any[], b: any[]): boolean => {
|
||||
if (a.length !== b.length) return false;
|
||||
const sortedA = [...a].sort();
|
||||
const sortedB = [...b].sort();
|
||||
return sortedA.every((val, idx) => val === sortedB[idx]);
|
||||
};
|
||||
|
||||
const buildPartialPayload = (current: any, original: any): any => {
|
||||
const payload: any = { id: current.id };
|
||||
const compareFields = ['name', 'commonName', 'category', 'type', 'spec', 'unit', 'visibilityLevel', 'isEnabled', 'isInspectionRequired', 'generalImage', 'generalManual', 'companyName'];
|
||||
|
||||
for (const key of compareFields) {
|
||||
const currentVal = current[key];
|
||||
const originalVal = original[key];
|
||||
|
||||
// 处理数组比较(generalImage, generalManual)
|
||||
if (Array.isArray(currentVal) && Array.isArray(originalVal)) {
|
||||
if (!isArraysEqual(currentVal, originalVal)) {
|
||||
payload[key] = currentVal;
|
||||
}
|
||||
} else if (currentVal !== originalVal) {
|
||||
payload[key] = currentVal;
|
||||
}
|
||||
}
|
||||
return payload;
|
||||
};
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!formRef.value) return;
|
||||
|
||||
@ -1189,19 +1282,44 @@ const submitForm = async () => {
|
||||
if (prefixStr && suffixStr) fullCategory = prefixStr + '/' + suffixStr;
|
||||
else fullCategory = prefixStr || suffixStr;
|
||||
|
||||
const payload = {
|
||||
// 构建最终表单数据
|
||||
const finalForm = {
|
||||
...form.value,
|
||||
category: fullCategory,
|
||||
generalImage: finalImageList,
|
||||
generalManual: finalManualList
|
||||
};
|
||||
|
||||
// 脏检查:只提交变更的字段
|
||||
let payload: any;
|
||||
if (form.value.id && originalForm.value) {
|
||||
// 编辑模式:生成部分更新 payload
|
||||
payload = buildPartialPayload(finalForm, originalForm.value);
|
||||
// 如果分类被修改,需要确保包含在 payload 中
|
||||
if (payload.category === undefined && fullCategory !== originalForm.value.category) {
|
||||
payload.category = fullCategory;
|
||||
}
|
||||
} else {
|
||||
// 新增模式:提交完整数据
|
||||
payload = finalForm;
|
||||
}
|
||||
|
||||
// 如果没有变更,提示用户
|
||||
const changedKeys = Object.keys(payload).filter(k => k !== 'id');
|
||||
if (changedKeys.length === 0) {
|
||||
ElMessage.info('没有检测到数据变更,无需保存');
|
||||
submitLoading.value = false;
|
||||
dialog.visible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const requestApi = form.value.id ? updateMaterialBase : addMaterialBase;
|
||||
const actionText = form.value.id ? '修改' : '新增';
|
||||
await requestApi(payload);
|
||||
|
||||
ElMessage.success(`${actionText}成功`);
|
||||
dialog.visible = false;
|
||||
originalForm.value = null;
|
||||
getList();
|
||||
getOptionsList();
|
||||
} catch (error: any) {
|
||||
@ -1218,6 +1336,22 @@ const cancel = () => {
|
||||
resetForm();
|
||||
};
|
||||
|
||||
// 快速基于此物料查看/创建 BOM
|
||||
const createBomForMaterial = () => {
|
||||
if (!form.value.id) {
|
||||
return ElMessage.warning('请先保存物料基础信息后再操作');
|
||||
}
|
||||
const routeUrl = router.resolve({
|
||||
path: '/bom',
|
||||
query: {
|
||||
create_for_id: form.value.id,
|
||||
parent_name: form.value.name,
|
||||
parent_spec: form.value.spec
|
||||
}
|
||||
});
|
||||
window.open(routeUrl.href, '_blank');
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
form.value = JSON.parse(JSON.stringify(initForm));
|
||||
fileListImage.value = [];
|
||||
@ -1228,6 +1362,7 @@ const resetForm = () => {
|
||||
|
||||
imageExternalUrl.value = '';
|
||||
manualExternalUrl.value = '';
|
||||
originalForm.value = null;
|
||||
if (formRef.value) formRef.value.resetFields();
|
||||
};
|
||||
|
||||
@ -1268,11 +1403,34 @@ const handleSetSingleWarning = (row: MaterialBaseVO) => {
|
||||
warningForm.isEnabled = row.warningEnabled || false;
|
||||
warningForm.redThreshold = row.warningRed;
|
||||
warningForm.yellowThreshold = row.warningYellow;
|
||||
warningForm.redEmails = (row as any).redEmails || '';
|
||||
warningForm.yellowEmails = (row as any).yellowEmails || '';
|
||||
|
||||
warningDialog.title = '设置预警';
|
||||
warningDialog.visible = true;
|
||||
};
|
||||
|
||||
// 标记预警物料已采购
|
||||
const handleMarkOrdered = (row: MaterialBaseVO) => {
|
||||
ElMessageBox.confirm(
|
||||
'确认已对该预警物料下单?标记后在途期间将不再发送预警邮件。',
|
||||
'确认标记已采购',
|
||||
{
|
||||
confirmButtonText: '确认',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
).then(async () => {
|
||||
try {
|
||||
await markWarningOrdered({ baseId: row.id, isOrdered: true });
|
||||
ElMessage.success('已标记为已采购');
|
||||
getList();
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.msg || '标记失败');
|
||||
}
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
// 提交预警设置
|
||||
const submitWarning = async () => {
|
||||
if (!warningFormRef.value) return;
|
||||
@ -1295,7 +1453,9 @@ const submitWarning = async () => {
|
||||
baseId,
|
||||
isEnabled: warningForm.isEnabled,
|
||||
redThreshold: red,
|
||||
yellowThreshold: yellow
|
||||
yellowThreshold: yellow,
|
||||
redEmails: warningForm.redEmails || '',
|
||||
yellowEmails: warningForm.yellowEmails || ''
|
||||
}));
|
||||
|
||||
await batchSetWarning(data);
|
||||
@ -1343,24 +1503,33 @@ const submitBatchInspection = async () => {
|
||||
|
||||
// 表格行样式(根据预警状态)
|
||||
const tableRowClassName = ({ row }: { row: MaterialBaseVO }) => {
|
||||
// 只有拥有 view_warning 权限且有预警状态时才显示特殊样式
|
||||
if (!userStore.hasPermission('material_list:view_warning')) return '';
|
||||
|
||||
const status = (row as any).warningStatus;
|
||||
if (status === 2) {
|
||||
return 'warning-row-red'; // 红色预警
|
||||
} else if (status === 1) {
|
||||
return 'warning-row-yellow'; // 黄色预警
|
||||
if (row.warningStatus === 2) {
|
||||
return 'danger-row'; // 红色预警
|
||||
} else if (row.warningStatus === 1) {
|
||||
return 'warning-row'; // 黄色预警
|
||||
}
|
||||
return '';
|
||||
};
|
||||
}
|
||||
|
||||
// --- 文件上传辅助函数 ---
|
||||
|
||||
const getImageUrl = (url: string) => { return !url ? '' : (url.startsWith('http') ? url : url) }
|
||||
const isExternalLink = (str: string) => { return str && (str.startsWith('http://') || str.startsWith('https://')) && !str.includes('/api/v1/common/files') }
|
||||
const getImagesOnly = (list: string[]) => { return !list ? [] : list.filter(item => !isExternalLink(item)) }
|
||||
const isImageFile = (url: string) => { return /\.(jpg|jpeg|png|gif|webp)$/i.test(url) }
|
||||
const isImageFile = (url: string) => { return /\.(jpg|jpeg|png|gif|webp|bmp)$/i.test(url) }
|
||||
const getImagesOnly = (list: string[]) => { return !list ? [] : list.filter(item => !isExternalLink(item) && isImageFile(item)) }
|
||||
const getNonImagesOnly = (list: string[]) => { return !list ? [] : list.filter(item => !isExternalLink(item) && !isImageFile(item)) }
|
||||
const truncateFileName = (name: string, maxLen = 12) => { return name.length > maxLen ? name.slice(0, maxLen - 3) + '...' : name }
|
||||
|
||||
const handleDownloadConfirm = (link: string) => {
|
||||
const fileName = link.split('/').pop() || '文件';
|
||||
ElMessageBox.confirm(`确认要下载/查看「${fileName}」吗?`, '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'info'
|
||||
}).then(() => {
|
||||
window.open(getImageUrl(link), '_blank');
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
const beforeAvatarUpload = (rawFile: any) => {
|
||||
const isTypeValid = ['image/jpeg', 'image/png', 'application/pdf'].includes(rawFile.type);
|
||||
@ -1411,8 +1580,13 @@ const handleRemoveImage = async (uploadFile: any, targetField: 'generalImage' |
|
||||
}
|
||||
|
||||
const handlePreviewPicture = (uploadFile: any) => {
|
||||
dialogImageUrl.value = uploadFile.url!;
|
||||
dialogVisibleImage.value = true
|
||||
const fileUrl = uploadFile.url || uploadFile.response?.url || '';
|
||||
if (isImageFile(fileUrl)) {
|
||||
dialogImageUrl.value = getImageUrl(fileUrl);
|
||||
dialogVisibleImage.value = true;
|
||||
} else {
|
||||
window.open(getImageUrl(fileUrl), '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
const triggerCamera = (field: 'generalImage' | 'generalManual') => {
|
||||
@ -1479,10 +1653,48 @@ const resetAdvancedFilter = () => {
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// 1. 修复背景联动:直接对 reactive 对象赋值
|
||||
if (route.query.keyword) {
|
||||
queryParams.keyword = route.query.keyword as string;
|
||||
queryParams.searchField = 'all';
|
||||
}
|
||||
|
||||
// 先根据权限初始化列显示状态
|
||||
initColumnPermissions();
|
||||
// 此时 getList 会带着正确的 keyword 向后端请求过滤后的数据
|
||||
getList();
|
||||
getOptionsList();
|
||||
|
||||
// 2. 修复弹窗锁定逻辑
|
||||
console.log('--- 准备检测外部跳转参数 ---', route.query);
|
||||
if (route.query.edit_id) {
|
||||
const editId = Number(route.query.edit_id);
|
||||
const searchKeyword = (route.query.keyword as string) || '';
|
||||
console.log('检测到 edit_id:', editId, '使用 keyword 搜索:', searchKeyword);
|
||||
|
||||
// 改用 keyword 而不是无效的 id 去向后端请求数据,确保目标物料在返回的列表中
|
||||
listMaterialBase({ page: 1, pageSize: 50, keyword: searchKeyword }).then((res: any) => {
|
||||
let rawData = res?.data?.list ?? res?.data?.items ?? res?.data ?? [];
|
||||
if (!Array.isArray(rawData) && typeof rawData === 'object' && rawData !== null) {
|
||||
rawData = [rawData];
|
||||
}
|
||||
const rows = Array.isArray(rawData) ? rawData : [];
|
||||
|
||||
// 3. 去掉危险的 rows[0] 兜底,严格匹配 ID
|
||||
const targetRow = rows.find((r: any) => r.id === editId);
|
||||
|
||||
if (targetRow) {
|
||||
console.log('找到精准目标物料,准备弹窗:', targetRow);
|
||||
setTimeout(() => {
|
||||
handleEdit(targetRow);
|
||||
}, 800);
|
||||
} else {
|
||||
console.warn('未能在搜索结果中匹配到对应 ID 的物料,可能 keyword 与 ID 不匹配');
|
||||
}
|
||||
}).catch((error: any) => {
|
||||
console.error('自动获取物料详情失败', error);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -1529,34 +1741,42 @@ onMounted(() => {
|
||||
.file-preview-cell { display: flex; align-items: center; justify-content: center; position: relative; }
|
||||
.more-badge { position: absolute; top: -5px; right: -5px; background: #909399; color: #fff; border-radius: 10px; padding: 0 4px; font-size: 10px; transform: scale(0.9); }
|
||||
|
||||
/* 预警行样式 - 加深颜色 */
|
||||
:deep(.warning-row-red) {
|
||||
--el-table-tr-bg-color: #ffcdd2 !important;
|
||||
background-color: #ffcdd2 !important;
|
||||
}
|
||||
:deep(.warning-row-red td) {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
:deep(.warning-row-yellow) {
|
||||
--el-table-tr-bg-color: #fff59d !important;
|
||||
background-color: #fff59d !important;
|
||||
}
|
||||
:deep(.warning-row-yellow td) {
|
||||
background-color: transparent !important;
|
||||
/* 上传文件项样式 - 非图片文件显示 */
|
||||
.upload-file-item { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden; }
|
||||
.upload-file-item .el-upload-list__item-thumbnail { width: 100%; height: 100%; object-fit: cover; }
|
||||
.upload-file-item .file-thumbnail { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; height: 100%; background: #f5f7fa; color: #606266; }
|
||||
.upload-file-item .file-thumbnail .file-name { font-size: 10px; margin-top: 4px; text-align: center; padding: 0 4px; word-break: break-all; max-width: 90px; }
|
||||
.upload-file-item .el-upload-list__item-actions { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.6); opacity: 0; transition: opacity 0.3s; }
|
||||
.upload-file-item:hover .el-upload-list__item-actions { opacity: 1; }
|
||||
.upload-file-item .el-upload-list__item-actions .el-icon { color: #fff; font-size: 20px; cursor: pointer; margin: 0 4px; }
|
||||
.upload-add-trigger { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
|
||||
|
||||
/* ================================================================
|
||||
Element Plus 表格预警行样式 & 固定列重叠修复
|
||||
================================================================ */
|
||||
|
||||
/* 黄色预警行底色 (全覆盖) */
|
||||
:deep(.el-table .warning-row),
|
||||
:deep(.el-table .warning-row > td.el-table__cell) {
|
||||
background-color: #fcedc4 !important; /* 明显的黄色 */
|
||||
}
|
||||
|
||||
/* 表单提示文字 */
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
/* 红色预警行底色 (全覆盖) */
|
||||
:deep(.el-table .danger-row),
|
||||
:deep(.el-table .danger-row > td.el-table__cell) {
|
||||
background-color: #fcd3d3 !important; /* 明显的红色 */
|
||||
}
|
||||
|
||||
/* 固定列的按钮容器底色跟随所在行的背景色,视觉无缝融合 */
|
||||
:deep(.el-table .el-table__cell.is-fixed) {
|
||||
background-color: inherit !important;
|
||||
}
|
||||
|
||||
/* 按钮间距微调,更紧凑 */
|
||||
:deep(.el-table .el-table__cell.is-fixed .cell) {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: flex-start; /* 左对齐更自然 */
|
||||
flex-wrap: nowrap; /* 尽量不换行 */
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 增加下拉框的最大高度,使其能容纳更多选项而不必频繁滚动 */
|
||||
.long-dropdown .el-select-dropdown__wrap {
|
||||
max-height: 600px !important; /* 可以根据屏幕大小适当调整 */
|
||||
}
|
||||
</style>
|
||||
@ -8,16 +8,38 @@
|
||||
<span class="subtitle">(请添加需要出库的物品)</span>
|
||||
</div>
|
||||
<div>
|
||||
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="primary" :icon="Plus" @click="openManualSelect">
|
||||
手动添加库存
|
||||
</el-button>
|
||||
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="warning" :icon="List" @click="openBomSelect">
|
||||
按 BOM 套餐添加
|
||||
</el-button>
|
||||
<!-- 批量模式 -->
|
||||
<template v-if="isBulkMode">
|
||||
<el-button @click="cancelBulkMode">
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="danger" :disabled="selectedRows.length === 0" @click="batchRemove">
|
||||
移除选中 ({{ selectedRows.length }})
|
||||
</el-button>
|
||||
</template>
|
||||
<!-- 普通模式 -->
|
||||
<template v-else>
|
||||
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="warning" plain :disabled="selectedItems.length === 0" @click="isBulkMode = true">
|
||||
批量操作
|
||||
</el-button>
|
||||
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="danger" :disabled="selectedItems.length === 0" @click="clearAll">
|
||||
清空列表
|
||||
</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="primary" :icon="Plus" @click="openManualSelect">
|
||||
手动添加库存
|
||||
</el-button>
|
||||
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="warning" :icon="List" @click="openBomSelect">
|
||||
按 BOM 套餐添加
|
||||
</el-button>
|
||||
</template>
|
||||
<el-divider direction="vertical" />
|
||||
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="success" :icon="Printer" :disabled="selectedItems.length === 0" @click="handlePreview">
|
||||
生成预览 & 打印
|
||||
</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>
|
||||
</template>
|
||||
@ -33,12 +55,17 @@
|
||||
/>
|
||||
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
v-else
|
||||
:data="sortedSelectedItems"
|
||||
border
|
||||
style="width: 100%"
|
||||
row-key="uniqueKey"
|
||||
@selection-change="handleSelectionChange"
|
||||
@row-click="handleBulkRowClick"
|
||||
:row-class-name="getRowClassName"
|
||||
>
|
||||
<el-table-column v-if="isBulkMode" type="selection" width="55" align="center" />
|
||||
<el-table-column type="index" label="序号" width="50" align="center" />
|
||||
|
||||
<el-table-column label="类型" width="100" align="center">
|
||||
@ -79,7 +106,7 @@
|
||||
|
||||
<el-table-column label="操作" width="80" align="center" fixed="right">
|
||||
<template #default="{ $index }">
|
||||
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="danger" link @click="removeRow($index)">移除</el-button>
|
||||
<el-button v-if="!isBulkMode && userStore.hasPermission('outbound_selection:operation')" type="danger" link @click="removeRow($index)">移除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@ -213,13 +240,12 @@
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="bomSelectVisible = false">取消</el-button>
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('outbound_selection:operation')"
|
||||
type="primary"
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('outbound_selection:operation')"
|
||||
type="primary"
|
||||
@click="confirmBomAdd"
|
||||
:disabled="hasShortage"
|
||||
>
|
||||
{{ hasShortage ? '库存不足,无法添加' : '一键计算并添加' }}
|
||||
{{ hasShortage ? '仅添加现有库存' : '确认添加' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
@ -266,6 +292,80 @@
|
||||
</template>
|
||||
</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 class="print-header">
|
||||
<h1>IRIS出库拣货确认单</h1>
|
||||
@ -335,11 +435,15 @@ import { ElMessage, ElTable, ElMessageBox } from 'element-plus'
|
||||
import { getAllStock, getStockList, printSelectionList } from '@/api/inbound/stock'
|
||||
import { getBomList, getBomDetail } from '@/api/bom'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { submitOutboundRequest } from '@/api/outbound'
|
||||
import { getApproversList } from '@/api/auth'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// --- 状态变量 ---
|
||||
const selectedItems = ref<any[]>([])
|
||||
const selectedRows = ref<any[]>([])
|
||||
const isBulkMode = ref(false)
|
||||
|
||||
// 按库位路径自然升序排序(优化拣货路径)
|
||||
const sortedSelectedItems = computed(() => {
|
||||
@ -356,6 +460,13 @@ const previewVisible = ref(false)
|
||||
const exportLoading = 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 stockList = ref<any[]>([]) // 服务端分页数据
|
||||
const stockTotal = ref(0)
|
||||
@ -368,6 +479,7 @@ let stockSearchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// 表格引用
|
||||
const manualTableRef = ref<InstanceType<typeof ElTable>>()
|
||||
const tableRef = ref<InstanceType<typeof ElTable>>()
|
||||
|
||||
// BOM 相关
|
||||
const bomOptions = ref<any[]>([])
|
||||
@ -451,6 +563,31 @@ const getTypeTag = (type: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 核心逻辑 0:加载全量库存数据(BOM 齐套计算依赖此数据) ---
|
||||
const ensureAllStockLoaded = async () => {
|
||||
if (allStockData.value.length === 0) {
|
||||
try {
|
||||
const res: any = await getAllStock()
|
||||
const rawMaterials = (res.materials || []).map((i: any) => ({ ...i, type: 'material', typeLabel: '采购件' }))
|
||||
const rawSemis = (res.semis || []).map((i: any) => ({ ...i, type: 'semi', typeLabel: '半成品' }))
|
||||
const rawProducts = (res.products || []).map((i: any) => ({ ...i, type: 'product', typeLabel: '成品' }))
|
||||
const list = [...rawMaterials, ...rawSemis, ...rawProducts]
|
||||
allStockData.value = list.map((i: any) => ({
|
||||
...i,
|
||||
name: i.name || i.material_name || i.product_name || '未知名称',
|
||||
standard: i.standard || i.spec_model || '',
|
||||
warehouse_location: i.warehouse_location || i.warehouse_loc || i.full_path || '',
|
||||
uniqueKey: `${i.type}_${i.id}`,
|
||||
available_quantity: parseFloat(i.available_quantity) || 0,
|
||||
availableCount: parseFloat(i.available_quantity) || 0,
|
||||
export_quantity: 1
|
||||
}))
|
||||
} catch (e) {
|
||||
ElMessage.error('加载全量库存数据失败(BOM 功能可能受影响)')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 核心逻辑 1:手动添加库存 ---
|
||||
|
||||
// 服务端加载库存列表
|
||||
@ -460,7 +597,8 @@ const loadStockList = async () => {
|
||||
const res: any = await getStockList({
|
||||
page: stockPage.value,
|
||||
pageSize: stockPageSize.value,
|
||||
keyword: searchKeyword.value.trim()
|
||||
keyword: searchKeyword.value.trim(),
|
||||
is_aggregated: true // ★ 触发后端按规格+库位合并
|
||||
})
|
||||
// 为每个item添加uniqueKey和确保warehouse_location字段正确映射
|
||||
stockList.value = (res.data?.list || []).map((item: any) => ({
|
||||
@ -482,30 +620,8 @@ const openManualSelect = async () => {
|
||||
stockPage.value = 1
|
||||
searchKeyword.value = ''
|
||||
await loadStockList()
|
||||
|
||||
// 仅在 BOM 关联查询需要时加载全量(一次性缓存)
|
||||
if (allStockData.value.length === 0) {
|
||||
try {
|
||||
const res: any = await getAllStock()
|
||||
const rawMaterials = (res.materials || []).map((i: any) => ({ ...i, type: 'material', typeLabel: '采购件' }))
|
||||
const rawSemis = (res.semis || []).map((i: any) => ({ ...i, type: 'semi', typeLabel: '半成品' }))
|
||||
const rawProducts = (res.products || []).map((i: any) => ({ ...i, type: 'product', typeLabel: '成品' }))
|
||||
const list = [...rawMaterials, ...rawSemis, ...rawProducts]
|
||||
allStockData.value = list.map((i: any) => ({
|
||||
...i,
|
||||
name: i.name || i.material_name || i.product_name || '未知名称',
|
||||
standard: i.standard || i.spec_model || '',
|
||||
warehouse_location: i.warehouse_location || i.warehouse_loc || i.full_path || '',
|
||||
uniqueKey: `${i.type}_${i.id}`,
|
||||
available_quantity: parseFloat(i.available_quantity) || 0,
|
||||
export_quantity: 1
|
||||
}))
|
||||
} catch (e) {
|
||||
ElMessage.error('加载全量库存数据失败(BOM 功能可能受影响)')
|
||||
}
|
||||
} else {
|
||||
allStockData.value.forEach(item => item.export_quantity = 0)
|
||||
}
|
||||
await ensureAllStockLoaded()
|
||||
allStockData.value.forEach(item => item.export_quantity = 0)
|
||||
}
|
||||
|
||||
// 搜索框防抖触发服务端过滤
|
||||
@ -610,6 +726,7 @@ const openBomSelect = async () => {
|
||||
} catch (e) {
|
||||
ElMessage.error('加载 BOM 列表失败')
|
||||
}
|
||||
await ensureAllStockLoaded()
|
||||
}
|
||||
|
||||
// 监听 BOM 选择变化,自动加载明细并计算齐套性
|
||||
@ -619,7 +736,7 @@ watch(selectedBomNo, async (newBomNo) => {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const detailRes = await getBomDetail(newBomNo)
|
||||
const detailRes: any = await getBomDetail(newBomNo)
|
||||
currentBomDetail.value = detailRes.data?.children || []
|
||||
} catch (e) {
|
||||
ElMessage.error('加载 BOM 明细失败')
|
||||
@ -628,55 +745,128 @@ watch(selectedBomNo, async (newBomNo) => {
|
||||
})
|
||||
|
||||
const confirmBomAdd = async () => {
|
||||
if(!selectedBomNo.value) return ElMessage.warning('请选择 BOM');
|
||||
if (!selectedBomNo.value) return ElMessage.warning('请选择 BOM')
|
||||
|
||||
if (allStockData.value.length === 0) {
|
||||
await openManualSelect()
|
||||
manualDialogVisible.value = false
|
||||
await ensureAllStockLoaded()
|
||||
}
|
||||
|
||||
try {
|
||||
const detailRes = await getBomDetail(selectedBomNo.value)
|
||||
const bomRows = detailRes.data?.children || []
|
||||
if (currentBomDetail.value.length === 0) {
|
||||
try {
|
||||
const detailRes: any = await getBomDetail(selectedBomNo.value)
|
||||
currentBomDetail.value = detailRes.data?.children || []
|
||||
} catch (e) {
|
||||
ElMessage.error('获取 BOM 详情失败')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let addedCount = 0;
|
||||
bomRows.forEach((bomItem: any) => {
|
||||
const needQty = (parseFloat(bomItem.dosage) || 0) * bomSets.value
|
||||
// ★ BOM 添加时,匹配本地库存数据,带入库位信息
|
||||
const stockCandidate = allStockData.value.find(s =>
|
||||
(s.base_id && s.base_id == bomItem.child_id)
|
||||
)
|
||||
const bomRows = currentBomDetail.value
|
||||
let addedCount = 0
|
||||
let skippedCount = 0
|
||||
|
||||
if (stockCandidate) {
|
||||
bomRows.forEach((bomItem: any) => {
|
||||
const dosage = parseFloat(bomItem.dosage) || 0
|
||||
const needQty = dosage * bomSets.value
|
||||
|
||||
const stockCandidate = allStockData.value.find(s =>
|
||||
(s.base_id && s.base_id == bomItem.child_id)
|
||||
)
|
||||
|
||||
if (stockCandidate) {
|
||||
const availableQty = stockCandidate.availableCount || 0
|
||||
const actualAddQty = Math.min(needQty, availableQty)
|
||||
|
||||
if (actualAddQty > 0) {
|
||||
const existing = selectedItems.value.find(e => e.uniqueKey === stockCandidate.uniqueKey)
|
||||
if (existing) {
|
||||
existing.export_quantity += needQty
|
||||
existing.export_quantity += actualAddQty
|
||||
} else {
|
||||
const newItem = JSON.parse(JSON.stringify(stockCandidate))
|
||||
// 如果后端 BomService 也返回了 warehouse_location (聚合的),这里优先使用
|
||||
if (bomItem.warehouse_location) {
|
||||
newItem.warehouse_location = bomItem.warehouse_location
|
||||
}
|
||||
newItem.export_quantity = needQty
|
||||
newItem.export_quantity = actualAddQty
|
||||
selectedItems.value.push(newItem)
|
||||
}
|
||||
addedCount++
|
||||
} else {
|
||||
skippedCount++
|
||||
}
|
||||
})
|
||||
|
||||
if(addedCount > 0) {
|
||||
ElMessage.success(`成功添加 BOM 相关物料,共 ${addedCount} 类`)
|
||||
bomSelectVisible.value = false
|
||||
} else {
|
||||
ElMessage.warning('库存中未找到该 BOM 所需的任何原料')
|
||||
skippedCount++
|
||||
}
|
||||
} catch(e) {
|
||||
ElMessage.error('获取 BOM 详情失败')
|
||||
})
|
||||
|
||||
if (addedCount > 0) {
|
||||
const tip = skippedCount > 0 ? `(跳过 ${skippedCount} 种缺货物料)` : ''
|
||||
ElMessage.success(`成功添加 ${addedCount} 类物料${tip}`)
|
||||
bomSelectVisible.value = false
|
||||
} else {
|
||||
ElMessage.warning('该 BOM 所有物料库存均为 0')
|
||||
}
|
||||
}
|
||||
|
||||
// --- 通用逻辑 ---
|
||||
|
||||
const handleSelectionChange = (val: any[]) => {
|
||||
selectedRows.value = val
|
||||
if (val.length === 0 && isBulkMode.value) {
|
||||
isBulkMode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkRowClick = (row: any) => {
|
||||
if (isBulkMode.value && tableRef.value) {
|
||||
tableRef.value.toggleRowSelection(row, undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const getRowClassName = () => {
|
||||
return isBulkMode.value ? 'bulk-clickable-row' : ''
|
||||
}
|
||||
|
||||
const cancelBulkMode = () => {
|
||||
isBulkMode.value = false
|
||||
selectedRows.value = []
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
ElMessageBox.confirm(
|
||||
'确定要清空当前拣货车中的所有物品吗?',
|
||||
'清空确认',
|
||||
{
|
||||
confirmButtonText: '确定清空',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}
|
||||
).then(() => {
|
||||
selectedItems.value = []
|
||||
selectedRows.value = []
|
||||
isBulkMode.value = false
|
||||
ElMessage.success('已清空拣货车')
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const batchRemove = () => {
|
||||
if (selectedRows.value.length === 0) return
|
||||
ElMessageBox.confirm(
|
||||
`确定要移除选中的 ${selectedRows.value.length} 项物品吗?`,
|
||||
'批量移除确认',
|
||||
{
|
||||
confirmButtonText: '确定移除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}
|
||||
).then(() => {
|
||||
const keysToRemove = new Set(selectedRows.value.map(row => row.uniqueKey))
|
||||
selectedItems.value = selectedItems.value.filter(item => !keysToRemove.has(item.uniqueKey))
|
||||
selectedRows.value = []
|
||||
isBulkMode.value = false
|
||||
ElMessage.success(`已移除 ${keysToRemove.size} 项物品`)
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const removeRow = (index: number) => {
|
||||
selectedItems.value.splice(index, 1)
|
||||
}
|
||||
@ -691,9 +881,70 @@ const handlePreview = () => {
|
||||
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 () => {
|
||||
previewVisible.value = false;
|
||||
// 记录日志
|
||||
|
||||
try {
|
||||
const payload = validSelectedItems.value.map(item => ({
|
||||
name: item.name, standard: item.standard, quantity: item.export_quantity
|
||||
@ -702,7 +953,87 @@ const confirmPrint = async () => {
|
||||
} catch (e) {}
|
||||
|
||||
setTimeout(() => {
|
||||
window.print();
|
||||
// 1. 获取要打印的区域 DOM
|
||||
const printElement = document.getElementById('print-area');
|
||||
if (!printElement) return;
|
||||
|
||||
// 2. 创建并挂载隐藏的 iframe
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.style.position = 'fixed';
|
||||
iframe.style.right = '0';
|
||||
iframe.style.bottom = '0';
|
||||
iframe.style.width = '0';
|
||||
iframe.style.height = '0';
|
||||
iframe.style.border = '0';
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
const iframeDoc = iframe.contentWindow?.document || iframe.contentDocument;
|
||||
if (!iframeDoc) return;
|
||||
|
||||
// 3. 安全初始化 iframe 骨架(只写基本结构,不拼接任何业务代码)
|
||||
iframeDoc.open();
|
||||
iframeDoc.write('<!DOCTYPE html><html><head><title>出库单打印</title></head><body></body></html>');
|
||||
iframeDoc.close();
|
||||
|
||||
// 4. 【核心修复】安全克隆所有样式节点,彻底告别乱码
|
||||
const styles = document.querySelectorAll('style, link[rel="stylesheet"]');
|
||||
styles.forEach(styleNode => {
|
||||
iframeDoc.head.appendChild(styleNode.cloneNode(true));
|
||||
});
|
||||
|
||||
// 5. 动态追加针对打印的强制分页 CSS
|
||||
const customStyle = iframeDoc.createElement('style');
|
||||
customStyle.innerHTML = `
|
||||
/* 重置基础布局,解除所有高度死锁 */
|
||||
html, body {
|
||||
height: auto !important;
|
||||
min-height: 100% !important;
|
||||
overflow: visible !important;
|
||||
background: white !important;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
/* 规范 A4 纸张 */
|
||||
@page {
|
||||
size: A4 portrait;
|
||||
margin: 10mm;
|
||||
}
|
||||
/* 确保打印区正常流式显示 */
|
||||
#print-area {
|
||||
display: block !important;
|
||||
position: static !important;
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
}
|
||||
/* 核心:保护表格不被跨页截断 */
|
||||
.print-table {
|
||||
width: 100% !important;
|
||||
table-layout: auto !important;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.print-table tr, .print-table td, .print-table th {
|
||||
page-break-inside: avoid !important;
|
||||
break-inside: avoid !important;
|
||||
}
|
||||
/* 隐藏不需要的全局 UI */
|
||||
.el-overlay, .el-dialog__wrapper, .no-print-content {
|
||||
display: none !important;
|
||||
}
|
||||
`;
|
||||
iframeDoc.head.appendChild(customStyle);
|
||||
|
||||
// 6. 【核心修复】安全克隆打印区域到 body 中
|
||||
iframeDoc.body.appendChild(printElement.cloneNode(true));
|
||||
|
||||
// 7. 延迟触发打印,等待样式完全渲染
|
||||
setTimeout(() => {
|
||||
iframe.contentWindow?.focus();
|
||||
iframe.contentWindow?.print();
|
||||
// 打印结束后清理 iframe
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(iframe);
|
||||
}, 1000);
|
||||
}, 500); // 预留 500ms 渲染时间
|
||||
}, 300);
|
||||
}
|
||||
|
||||
@ -745,28 +1076,90 @@ const confirmExport = () => {
|
||||
::v-deep(.el-card__body) { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
|
||||
/* ================= ★★★ 打印专用样式 ★★★ ================= */
|
||||
/* ================= ★★★ 打印区域排版样式 ★★★ ================= */
|
||||
#print-area { display: none; }
|
||||
@media print {
|
||||
@page { margin: 0; size: auto; }
|
||||
body * { visibility: hidden; }
|
||||
.el-dialog__wrapper, .v-modal, .el-message, .no-print-content { display: none !important; }
|
||||
#print-area, #print-area * { visibility: visible; }
|
||||
#print-area {
|
||||
position: fixed; left: 0; top: 0; width: 100%; height: 100%;
|
||||
margin: 0; padding: 20mm; background-color: white;
|
||||
display: block !important; z-index: 99999;
|
||||
}
|
||||
.print-header { text-align: center; margin-bottom: 20px; }
|
||||
.print-header h1 { font-size: 24px; margin: 0 0 10px 0; font-weight: bold; color: #000; }
|
||||
.print-meta-row { display: flex; justify-content: flex-start; font-size: 12px; margin-bottom: 5px; }
|
||||
.header-line { border-bottom: 2px solid #000; margin-top: 5px; }
|
||||
.print-table { width: 100%; border-collapse: collapse; margin-bottom: 40px; border: 1px solid #000; }
|
||||
.print-table th, .print-table td { border: 1px solid #000; padding: 12px 8px; text-align: left; font-size: 14px; color: #000; }
|
||||
.print-table th { text-align: center; font-weight: bold; }
|
||||
.cell-padding { padding-left: 10px; }
|
||||
.print-footer { display: flex; justify-content: space-between; margin-top: 60px; padding: 0 20px; }
|
||||
.signature-item { display: flex; flex-direction: column; align-items: center; width: 30%; }
|
||||
.sig-label { font-size: 14px; margin-bottom: 40px; text-align: left; width: 100%; }
|
||||
.sig-line { border-bottom: 1px solid #000; width: 100%; height: 1px; display: block; }
|
||||
.print-header { text-align: center; margin-bottom: 20px; }
|
||||
.print-header h1 { font-size: 24px; margin: 0 0 10px 0; font-weight: bold; color: #000; }
|
||||
.print-meta-row { display: flex; justify-content: flex-start; font-size: 12px; margin-bottom: 5px; }
|
||||
.header-line { border-bottom: 2px solid #000; margin-top: 5px; }
|
||||
.print-table { width: 100%; border-collapse: collapse; margin-bottom: 40px; border: 1px solid #000; }
|
||||
.print-table th, .print-table td { border: 1px solid #000; padding: 12px 8px; text-align: left; font-size: 14px; color: #000; }
|
||||
.print-table th { text-align: center; font-weight: bold; }
|
||||
.cell-padding { padding-left: 10px; }
|
||||
.print-table tr {
|
||||
page-break-inside: avoid !important;
|
||||
break-inside: avoid !important;
|
||||
}
|
||||
.print-footer { display: flex; justify-content: space-between; margin-top: 60px; padding: 0 20px; }
|
||||
.signature-item { display: flex; flex-direction: column; align-items: center; width: 30%; }
|
||||
.sig-label { font-size: 14px; margin-bottom: 40px; text-align: left; width: 100%; }
|
||||
.sig-line { border-bottom: 1px solid #000; width: 100%; height: 1px; display: block; }
|
||||
|
||||
/* ★★★ 修复预览弹窗中 el-table 打印分页截断问题 ★★★ */
|
||||
.print-preview-content {
|
||||
height: auto !important;
|
||||
max-height: none !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
.print-preview-content .el-table,
|
||||
.print-preview-content .el-table__inner-wrapper,
|
||||
.print-preview-content .el-table__body-wrapper,
|
||||
.print-preview-content .el-table__body {
|
||||
height: auto !important;
|
||||
max-height: none !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
.print-preview-content .el-scrollbar__wrap {
|
||||
overflow: visible !important;
|
||||
}
|
||||
.print-preview-content .el-table tr {
|
||||
page-break-inside: avoid !important;
|
||||
break-inside: avoid !important;
|
||||
}
|
||||
.print-preview-content .el-table__body-wrapper is-scrollable-none {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
:deep(.bulk-clickable-row) {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
@media print {
|
||||
@page { margin: 10mm; size: auto; }
|
||||
|
||||
/* 1. 保留原始:隐藏系统全局的无关元素 */
|
||||
body * { visibility: hidden; }
|
||||
.el-dialog__wrapper, .v-modal, .el-message, .no-print-content { display: none !important; }
|
||||
|
||||
/* 2. 【核心修复】:打通 Vue 和 Element 框架的所有父级容器,解除裁剪和定位死锁 */
|
||||
html, body, #app, .el-container, .el-main, .el-scrollbar__wrap {
|
||||
position: static !important;
|
||||
overflow: visible !important;
|
||||
height: auto !important;
|
||||
min-height: auto !important;
|
||||
}
|
||||
|
||||
/* 3. 保留原始:让打印区域显形,并用 absolute 顶至左上角,允许自然向下分页 */
|
||||
#print-area, #print-area * { visibility: visible; }
|
||||
#print-area {
|
||||
position: absolute !important;
|
||||
left: 0 !important;
|
||||
top: 0 !important;
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
background-color: white;
|
||||
display: block !important;
|
||||
z-index: 99999;
|
||||
}
|
||||
|
||||
/* 4. 【核心修复】:防止表格行在跨页时被水平拦腰切断 */
|
||||
.print-table tr, .print-table td, .print-table th {
|
||||
page-break-inside: avoid !important;
|
||||
break-inside: avoid !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
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>
|
||||
</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 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 { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select } from '@element-plus/icons-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 { useUserStore } from '@/stores/user'
|
||||
|
||||
@ -228,6 +290,12 @@ const showCamera = ref(false)
|
||||
const barcodeRef = 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 signaturePreviewUrl = ref('')
|
||||
@ -258,8 +326,95 @@ const totalAmount = computed(() => {
|
||||
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(() => {
|
||||
// 加载已审批通过的申请单列表
|
||||
loadApprovalRequests()
|
||||
|
||||
if (userStore.username) {
|
||||
form.operator_name = userStore.username
|
||||
operatorOptions.value.push(userStore.username)
|
||||
@ -313,15 +468,32 @@ const handleManualInput = async () => {
|
||||
const code = barcodeInput.value.trim()
|
||||
if (!code) return
|
||||
|
||||
// ★ 按单出库模式:必须先选择申请单
|
||||
if (outboundMode.value === 'by-request' && !selectedRequest.value) {
|
||||
ElMessage.warning('请先选择要出库的审批申请单')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
// 1. 检查购物车重复
|
||||
// 1. 检查购物车重复(直接模式走旧的追加逻辑,按单模式也复用但后续会校验)
|
||||
const existIndex = cartItems.value.findIndex(item => item.barcode === code || item.sku === code)
|
||||
if (existIndex > -1) {
|
||||
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) {
|
||||
item.out_quantity++
|
||||
ElMessage.success(`数量+1 (当前: ${item.out_quantity})`)
|
||||
@ -343,16 +515,29 @@ const handleManualInput = async () => {
|
||||
if (availQty <= 0) {
|
||||
ElMessage.warning(`库存不足或已出库 (余: ${availQty})`)
|
||||
if (navigator.vibrate) navigator.vibrate([100, 50, 100])
|
||||
} else {
|
||||
// 加入购物车
|
||||
cartItems.value.push({
|
||||
...item,
|
||||
out_quantity: 1,
|
||||
price: parseFloat(item.price || 0)
|
||||
})
|
||||
ElMessage.success(`添加成功: ${item.name}`)
|
||||
if (navigator.vibrate) navigator.vibrate(100)
|
||||
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({
|
||||
...item,
|
||||
out_quantity: 1,
|
||||
price: parseFloat(item.price || 0)
|
||||
})
|
||||
ElMessage.success(`添加成功: ${item.name}`)
|
||||
if (navigator.vibrate) navigator.vibrate(100)
|
||||
barcodeInput.value = ''
|
||||
}
|
||||
} catch (error: any) {
|
||||
@ -393,6 +578,7 @@ const clearAll = () => {
|
||||
signatureFile.value = null
|
||||
signaturePreviewUrl.value = ''
|
||||
barcodeInput.value = ''
|
||||
// ★ 按单模式:仅清空购物车,保留申请单选择
|
||||
})
|
||||
}
|
||||
|
||||
@ -416,40 +602,67 @@ const submitForm = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
// 上传签名
|
||||
// 1. 上传签名
|
||||
const uploadRes = await uploadFile(signatureFile.value)
|
||||
const signatureUrl = uploadRes.data.url
|
||||
|
||||
const itemsPayload = cartItems.value.map(item => ({
|
||||
stock_id: item.id,
|
||||
source_table: item.source_table,
|
||||
sku: item.sku,
|
||||
barcode: item.barcode,
|
||||
quantity: item.out_quantity,
|
||||
price: item.price
|
||||
}))
|
||||
// 2. 核心保护:坚决杜绝 undefined、null 和 0
|
||||
const itemsPayload = cartItems.value.map(item => {
|
||||
// 强制确保出库数量是一个大于 0 的有效数字
|
||||
let safeQuantity = Number(item.out_quantity)
|
||||
if (isNaN(safeQuantity) || safeQuantity <= 0) {
|
||||
safeQuantity = 1 // 兜底:只要扫了码,最少出 1 个
|
||||
}
|
||||
|
||||
await submitOutbound({
|
||||
items: itemsPayload,
|
||||
return {
|
||||
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,
|
||||
request_id: outboundMode.value === 'by-request' && selectedRequest.value ? selectedRequest.value.id : null,
|
||||
consumer_name: form.consumer_name,
|
||||
operator_name: form.operator_name,
|
||||
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('出库成功')
|
||||
// 重置
|
||||
|
||||
// 5. 成功后重置页面
|
||||
cartItems.value = []
|
||||
form.consumer_name = ''
|
||||
form.remark = ''
|
||||
signatureFile.value = null
|
||||
signaturePreviewUrl.value = ''
|
||||
loadHistoryOperators()
|
||||
|
||||
// 根据你的项目实际变量重置签名组件,如果没有这句可以删掉
|
||||
if (typeof signaturePreviewUrl !== 'undefined') {
|
||||
signaturePreviewUrl.value = ''
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
ElMessage.error('提交失败')
|
||||
console.error('出库报错:', error)
|
||||
ElMessage.error('提交失败,请检查数据')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@ -547,6 +760,39 @@ onUnmounted(() => {
|
||||
.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; }
|
||||
|
||||
/* ★ 双轨制模式切换 */
|
||||
.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; }
|
||||
.camera-placeholder {
|
||||
|
||||
@ -266,6 +266,16 @@
|
||||
<div style="display: flex; align-items: center;">
|
||||
<el-icon class="icon"><Box/></el-icon>
|
||||
<span>1. 基础信息</span>
|
||||
|
||||
<el-link
|
||||
v-if="form.base_id"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
style="margin-left: 15px; font-size: 13px;"
|
||||
@click="openMaterialInNewTab"
|
||||
>
|
||||
<el-icon style="margin-right: 4px"><EditPen /></el-icon>前往修改基础信息
|
||||
</el-link>
|
||||
</div>
|
||||
<span class="sub-title" v-if="dialogStatus === 'create'"> (请先搜索锁定物料)</span>
|
||||
</div>
|
||||
@ -666,7 +676,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, reactive, onMounted, watch, computed} from 'vue'
|
||||
import {Plus, Setting, Refresh, Search, Lock, Box, House, InfoFilled, Link, Printer, Camera, Delete, Picture} from '@element-plus/icons-vue'
|
||||
import {Plus, Setting, Refresh, Search, Lock, Box, House, InfoFilled, Link, Printer, Camera, Delete, Picture, EditPen} from '@element-plus/icons-vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
const router = useRouter()
|
||||
import {ElMessage, ElMessageBox, ElLoading} from 'element-plus'
|
||||
import dayjs from 'dayjs'
|
||||
import request from '@/utils/request'
|
||||
@ -1394,6 +1406,22 @@ const handleUpdate = (row: any) => {
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
// 在新标签页打开基础信息编辑
|
||||
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 submitForm = async () => {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid: boolean) => {
|
||||
|
||||
@ -250,9 +250,18 @@
|
||||
|
||||
<div class="form-card basic-card">
|
||||
<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>
|
||||
<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 class="card-content">
|
||||
@ -421,7 +430,12 @@
|
||||
</div>
|
||||
|
||||
<div class="form-card production-card">
|
||||
<div class="card-title"><el-icon class="icon"><Setting /></el-icon><span>3. 生产与销售信息</span></div>
|
||||
<div class="card-title">
|
||||
<el-icon class="icon"><Setting /></el-icon><span>3. 生产与销售信息</span>
|
||||
<el-link type="success" :underline="false" style="margin-left: 15px; font-size: 13px;" @click="createBomForMaterial">
|
||||
<el-icon style="margin-right: 4px"><Plus /></el-icon>加入或查看BOM
|
||||
</el-link>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<el-row :gutter="24">
|
||||
|
||||
@ -556,7 +570,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
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 { ElMessage, ElLoading } from 'element-plus'
|
||||
import dayjs from 'dayjs'
|
||||
import request from '@/utils/request'
|
||||
@ -614,6 +629,21 @@ const vLoadmore = {
|
||||
}
|
||||
|
||||
const userStore = useUserStore()
|
||||
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 submitting = ref(false)
|
||||
const visible = ref(false)
|
||||
@ -1263,6 +1293,20 @@ const handleScannerConfirm = (result: string) => {
|
||||
ElMessage.success('序列号已提取')
|
||||
}
|
||||
|
||||
// 快速基于此物料创建 BOM
|
||||
const createBomForMaterial = () => {
|
||||
if (!form.base_id) return ElMessage.warning('请先锁定物料基础信息')
|
||||
const routeUrl = router.resolve({
|
||||
path: '/bom',
|
||||
query: {
|
||||
create_for_id: form.base_id,
|
||||
parent_name: form.material_name,
|
||||
parent_spec: form.spec_model
|
||||
}
|
||||
})
|
||||
window.open(routeUrl.href, '_blank')
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
await formRef.value.validate(async (valid: boolean) => {
|
||||
if(valid) {
|
||||
|
||||
@ -287,9 +287,18 @@
|
||||
|
||||
<div class="form-card basic-card">
|
||||
<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>
|
||||
<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>
|
||||
<span class="sub-title" v-if="dialogStatus === 'create'"> (请先搜索选择半成品物料)</span>
|
||||
</div>
|
||||
@ -489,6 +498,9 @@
|
||||
<div style="display: flex; align-items: center;">
|
||||
<el-icon class="icon"><Setting/></el-icon>
|
||||
<span>3. 生产与成本信息</span>
|
||||
<el-link type="success" :underline="false" style="margin-left: 15px; font-size: 13px;" @click="createBomForMaterial">
|
||||
<el-icon style="margin-right: 4px"><Plus /></el-icon>加入或查看BOM
|
||||
</el-link>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
@ -613,7 +625,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
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 {ElMessage, ElLoading} from 'element-plus'
|
||||
import dayjs from 'dayjs'
|
||||
import request from '@/utils/request'
|
||||
@ -673,6 +686,21 @@ const vLoadmore = {
|
||||
// 状态与变量
|
||||
// ------------------------------------
|
||||
const userStore = useUserStore()
|
||||
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 submitting = ref(false)
|
||||
const visible = ref(false)
|
||||
@ -1346,6 +1374,20 @@ const handleScannerConfirm = (result: string) => {
|
||||
ElMessage.success('序列号已提取')
|
||||
}
|
||||
|
||||
// 快速基于此物料创建 BOM
|
||||
const createBomForMaterial = () => {
|
||||
if (!form.base_id) return ElMessage.warning('请先锁定物料基础信息')
|
||||
const routeUrl = router.resolve({
|
||||
path: '/bom',
|
||||
query: {
|
||||
create_for_id: form.base_id,
|
||||
parent_name: form.material_name,
|
||||
parent_spec: form.spec_model
|
||||
}
|
||||
})
|
||||
window.open(routeUrl.href, '_blank')
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid: boolean) => {
|
||||
|
||||
@ -486,6 +486,7 @@ const listData = ref<any[]>([])
|
||||
const listStatusFilter = ref<'all' | 'counted' | 'uncounted'>('all')
|
||||
const allStockItems = ref<any[]>([]) // 全量应盘物资(盘点基数)
|
||||
const totalStockCount = ref(0) // ★ 全量应盘物资总数(不受limit限制)
|
||||
const totalScannedCount = ref(0) // ★ 后端去重的真实已盘数量
|
||||
const allScannedDrafts = ref<any[]>([]) // 全量草稿记录(脱离分页和过滤)
|
||||
const listTotalFiltered = ref(0) // 过滤后的总数
|
||||
|
||||
@ -515,18 +516,9 @@ const stats = computed(() => {
|
||||
const total = allStockItems.value.length
|
||||
if (total === 0) return { total: 0, scanned: 0, varianceItems: 0 }
|
||||
|
||||
// 使用完整的 allScannedDrafts 来计算"已盘"数量,绝对不依赖视图数据
|
||||
const countedItems = new Set()
|
||||
allScannedDrafts.value.forEach((d: any) => {
|
||||
// 只要有实盘记录就算已盘
|
||||
if (d.quantity !== undefined && d.quantity !== null) {
|
||||
countedItems.add(`${d.source_table}-${d.stock_id}`)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
total,
|
||||
scanned: countedItems.size,
|
||||
scanned: totalScannedCount.value,
|
||||
varianceItems: 0
|
||||
}
|
||||
})
|
||||
@ -665,7 +657,7 @@ const resumeSession = async () => {
|
||||
const res: any = await request({
|
||||
url: '/v1/inbound/stock/draft/list',
|
||||
method: 'get',
|
||||
params: { page: 1, limit: 500 } // ★ 限制单次加载数量,防止内存溢出
|
||||
params: { page: 1, limit: 99999 } // ★ 获取全量草稿数据
|
||||
})
|
||||
|
||||
const drafts = res && res.items ? res.items : []
|
||||
@ -722,6 +714,9 @@ const onScanSuccess = async (code: string) => {
|
||||
if (!code || loading.value) return
|
||||
const trimCode = code.trim()
|
||||
|
||||
// 将扫到的条码同步显示在输入框中
|
||||
barcodeInput.value = trimCode
|
||||
|
||||
if (!/^[A-Za-z0-9\-\.]+$/.test(trimCode)) {
|
||||
ElMessage.warning(`识别到异常字符:${trimCode}`)
|
||||
return
|
||||
@ -990,7 +985,7 @@ const fetchInventoryList = async (silent = false) => {
|
||||
method: 'get',
|
||||
params: {
|
||||
page: 1,
|
||||
limit: 500, // ★ 限制单次加载数量,防止内存溢出
|
||||
limit: 99999, // ★ 获取全量草稿数据
|
||||
keyword: listKeyword.value,
|
||||
session_id: currentSessionId.value // ★ 必须传递 session_id 隔离会话
|
||||
}
|
||||
@ -1002,6 +997,8 @@ const fetchInventoryList = async (silent = false) => {
|
||||
|
||||
// 保存全量草稿记录用于全局统计
|
||||
allScannedDrafts.value = scannedDrafts
|
||||
// 直接读取后端算好的去重已盘数
|
||||
totalScannedCount.value = res?.total_scanned || 0
|
||||
|
||||
// 2. 使用全量应盘物资列表
|
||||
// 对于每个应盘物资,检查是否有对应的盘点记录
|
||||
@ -1020,7 +1017,7 @@ const fetchInventoryList = async (silent = false) => {
|
||||
material_name: item.material_name,
|
||||
spec_model: item.spec_model,
|
||||
stock_qty: item.stock_qty, // 账面数(盲盘时隐藏)
|
||||
quantity: draft?.quantity || 0, // 实盘数
|
||||
quantity: draft?.quantity ?? draft?.qty_actual ?? 0, // 兼容后端字段名
|
||||
diff_qty: draft ? (draft.quantity - item.stock_qty) : -item.stock_qty, // 差异
|
||||
remark: draft?.remark || '',
|
||||
warehouse_location: item.warehouse_location
|
||||
|
||||
@ -55,15 +55,25 @@
|
||||
</el-table-column>
|
||||
<el-table-column prop="action" label="操作类型" width="100">
|
||||
<template #default="scope">
|
||||
<el-tag :type="getActionType(scope.row.action)">{{ scope.row.action }}</el-tag>
|
||||
<el-tag :type="getActionType(scope.row.action)">{{ actionMap[scope.row.action] || scope.row.action }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="target_name" label="操作对象" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="ip_address" label="IP地址" width="130" />
|
||||
<el-table-column prop="created_at" label="操作时间" width="170" />
|
||||
<el-table-column prop="created_at" label="操作时间" width="170">
|
||||
<template #default="scope">
|
||||
{{ formatLocalTime(scope.row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button v-if="scope.row.details && Object.keys(scope.row.details).length > 0" link type="primary" size="small" @click="handleViewDetails(scope.row)">
|
||||
<el-button
|
||||
v-if="hasDetailContent(scope.row.details)"
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleViewDetails(scope.row)"
|
||||
>
|
||||
查看详情
|
||||
</el-button>
|
||||
<span v-else style="color: #909399; font-size: 12px;">无变更明细</span>
|
||||
@ -84,36 +94,108 @@
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<el-dialog v-model="detailDialogVisible" title="操作详情" width="700px" destroy-on-close>
|
||||
<el-descriptions :column="2" border>
|
||||
<!-- ★ 重写的详情弹窗:支持三种高级结构 ★ -->
|
||||
<el-dialog v-model="detailDialogVisible" title="操作详情" width="750px" destroy-on-close :close-on-click-modal="false">
|
||||
<!-- 基本信息 -->
|
||||
<el-descriptions :column="2" border class="base-info">
|
||||
<el-descriptions-item label="ID">{{ currentLog.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="操作人">{{ currentLog.username }} ({{ currentLog.display_name }})</el-descriptions-item>
|
||||
<el-descriptions-item label="模块">{{ currentLog.module }}</el-descriptions-item>
|
||||
<el-descriptions-item label="模块">
|
||||
<el-tag>{{ currentLog.module }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="操作类型">
|
||||
<el-tag :type="getActionType(currentLog.action)">{{ currentLog.action }}</el-tag>
|
||||
<el-tag :type="getActionType(currentLog.action)">{{ actionMap[currentLog.action] || currentLog.action }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="操作对象" :span="2">{{ currentLog.target_name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="IP地址">{{ currentLog.ip_address }}</el-descriptions-item>
|
||||
<el-descriptions-item label="请求方式">{{ currentLog.method }}</el-descriptions-item>
|
||||
<el-descriptions-item label="操作时间" :span="2">{{ currentLog.created_at }}</el-descriptions-item>
|
||||
<el-descriptions-item label="请求URL" :span="2">
|
||||
<el-text size="small">{{ currentLog.url }}</el-text>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="操作时间" :span="2">{{ formatLocalTime(currentLog.created_at) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div v-if="currentLog.details" class="details-box">
|
||||
<div class="details-title">变更内容 (JSON)</div>
|
||||
<pre class="json-content">{{ formatDetails(currentLog.details) }}</pre>
|
||||
<!-- ★ 变更明细区域(支持同时展示多种结构)★ -->
|
||||
<div class="details-section">
|
||||
|
||||
<!-- 情况1:UPDATE - 变更对比表 -->
|
||||
<div v-if="hasChanges" class="changes-box">
|
||||
<div class="section-title">
|
||||
<el-icon><EditPen /></el-icon>
|
||||
字段变更详情(共 {{ changesList.length }} 处变更)
|
||||
</div>
|
||||
<el-table :data="changesList" border stripe size="small" max-height="350">
|
||||
<el-table-column label="字段名" width="150">
|
||||
<template #default="{ row }">
|
||||
<span class="field-name">{{ fieldMap[row.field] || row.field }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="修改前" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<span class="old-value">{{ row.old ?? '空' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="修改后" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<span class="new-value">{{ row.new ?? '空' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 情况2:DELETE - 删除快照 -->
|
||||
<div v-if="hasDeletedSnapshot" class="snapshot-box">
|
||||
<div class="section-title">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除前的数据快照
|
||||
</div>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item
|
||||
v-for="(value, key) in deletedSnapshot"
|
||||
:key="String(key)"
|
||||
:label="String(key)"
|
||||
:span="isLongValue(value) ? 2 : 1"
|
||||
>
|
||||
<span class="snapshot-value">{{ formatValue(value) }}</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<!-- 情况3:CREATE - 新增详情 -->
|
||||
<div v-if="hasCreated" class="snapshot-box">
|
||||
<div class="section-title">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增的数据详情
|
||||
</div>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item
|
||||
v-for="(value, key) in createdData"
|
||||
:key="String(key)"
|
||||
:label="String(key)"
|
||||
:span="isLongValue(value) ? 2 : 1"
|
||||
>
|
||||
<span class="snapshot-value">{{ formatValue(value) }}</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<!-- 兜底:原始 JSON(仅在没有任何高级结构时显示) -->
|
||||
<div v-if="showRawJson" class="raw-json-box">
|
||||
<div class="section-title">
|
||||
<el-icon><Document /></el-icon>
|
||||
原始数据
|
||||
</div>
|
||||
<pre class="raw-json">{{ rawJson }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="detailDialogVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { Search, Refresh, EditPen, Delete, Plus, Document } from '@element-plus/icons-vue'
|
||||
import { getAuditLogs, getAuditModules } from '@/api/audit'
|
||||
|
||||
// 表格数据
|
||||
@ -140,11 +222,139 @@ const dateRange = ref<[string, string] | null>(null)
|
||||
const moduleOptions = ref<string[]>([])
|
||||
const actionOptions = ref<string[]>(['create', 'update', 'delete', 'export', 'import'])
|
||||
|
||||
// ============================================================
|
||||
// 中文化映射
|
||||
// ============================================================
|
||||
|
||||
// 操作类型中文化映射
|
||||
const actionMap: Record<string, string> = {
|
||||
'UPDATE': '修改',
|
||||
'CREATE': '新增',
|
||||
'DELETE': '删除',
|
||||
'LOGIN': '登录',
|
||||
'LOGOUT': '登出'
|
||||
};
|
||||
|
||||
// 字段名中文化映射 (常见业务字段)
|
||||
const fieldMap: Record<string, string> = {
|
||||
'available_quantity': '可用库存',
|
||||
'in_quantity': '入库数量',
|
||||
'stock_quantity': '总库存',
|
||||
'out_quantity': '出库数量',
|
||||
'name': '名称',
|
||||
'material_name': '物料名称',
|
||||
'spec_model': '规格型号',
|
||||
'category': '类别',
|
||||
'status': '状态',
|
||||
'remark': '备注',
|
||||
'is_active': '是否启用'
|
||||
};
|
||||
|
||||
// 时间格式化:将后端的 UTC 时间字符串转换为本地时间 (UTC+8)
|
||||
const formatLocalTime = (timeStr: string) => {
|
||||
if (!timeStr) return '-'
|
||||
// 补全 'Z' 让浏览器识别为 UTC 时间,自动转为当前系统的时区
|
||||
const date = new Date(timeStr.replace(' ', 'T') + 'Z')
|
||||
if (isNaN(date.getTime())) return timeStr
|
||||
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
const h = String(date.getHours()).padStart(2, '0')
|
||||
const min = String(date.getMinutes()).padStart(2, '0')
|
||||
const s = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${y}-${m}-${d} ${h}:${min}:${s}`
|
||||
}
|
||||
|
||||
// 详情弹窗
|
||||
const detailDialogVisible = ref(false)
|
||||
const currentLog = ref<any>({})
|
||||
|
||||
// 获取操作类型对应的标签样式
|
||||
// ============================================================
|
||||
// 详情解析逻辑
|
||||
// ============================================================
|
||||
|
||||
// 辅助函数:判断 details 是否有可显示内容
|
||||
const hasDetailContent = (details: any): boolean => {
|
||||
if (!details || typeof details !== 'object') return false
|
||||
if (Object.keys(details).length === 0) return false
|
||||
return !!(
|
||||
details.changes ||
|
||||
details.deleted_snapshot ||
|
||||
details.created ||
|
||||
details.payload
|
||||
)
|
||||
}
|
||||
|
||||
// 判断是否存在各高级结构
|
||||
const hasChanges = computed(() => {
|
||||
const details = currentLog.value.details
|
||||
return !!(details?.changes && typeof details.changes === 'object')
|
||||
})
|
||||
|
||||
const hasDeletedSnapshot = computed(() => {
|
||||
const details = currentLog.value.details
|
||||
return !!(details?.deleted_snapshot && typeof details.deleted_snapshot === 'object')
|
||||
})
|
||||
|
||||
const hasCreated = computed(() => {
|
||||
const details = currentLog.value.details
|
||||
return !!(details?.created && typeof details.created === 'object')
|
||||
})
|
||||
|
||||
const showRawJson = computed(() => {
|
||||
return !hasChanges.value && !hasDeletedSnapshot.value && !hasCreated.value
|
||||
})
|
||||
|
||||
// 解析 changes 为表格数据
|
||||
const changesList = computed(() => {
|
||||
const details = currentLog.value.details
|
||||
if (!details?.changes) return []
|
||||
|
||||
return Object.entries(details.changes).map(([field, values]: [string, any]) => ({
|
||||
field,
|
||||
old: values?.old,
|
||||
new: values?.new
|
||||
}))
|
||||
})
|
||||
|
||||
// 解析 deleted_snapshot
|
||||
const deletedSnapshot = computed(() => {
|
||||
const details = currentLog.value.details
|
||||
return details?.deleted_snapshot || {}
|
||||
})
|
||||
|
||||
// 解析 created
|
||||
const createdData = computed(() => {
|
||||
const details = currentLog.value.details
|
||||
return details?.created || {}
|
||||
})
|
||||
|
||||
// 原始 JSON
|
||||
const rawJson = computed(() => {
|
||||
const details = currentLog.value.details
|
||||
if (!details) return ''
|
||||
return JSON.stringify(details, null, 2)
|
||||
})
|
||||
|
||||
// 辅助函数:格式化值
|
||||
const formatValue = (value: any): string => {
|
||||
if (value === null || value === undefined) return '-'
|
||||
if (typeof value === 'object') return JSON.stringify(value)
|
||||
return String(value)
|
||||
}
|
||||
|
||||
// 辅助函数:判断是否是长值
|
||||
const isLongValue = (value: any): boolean => {
|
||||
if (value === null || value === undefined) return false
|
||||
const str = typeof value === 'object' ? JSON.stringify(value) : String(value)
|
||||
return str.length > 50
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 其他方法
|
||||
// ============================================================
|
||||
|
||||
const getActionType = (action: string) => {
|
||||
const typeMap: Record<string, string> = {
|
||||
'create': 'success',
|
||||
@ -158,11 +368,9 @@ const getActionType = (action: string) => {
|
||||
return typeMap[action?.toLowerCase()] || 'info'
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
const getList = async () => {
|
||||
tableLoading.value = true
|
||||
try {
|
||||
// 处理日期范围
|
||||
if (dateRange.value && dateRange.value.length === 2) {
|
||||
queryParams.start_date = dateRange.value[0]
|
||||
queryParams.end_date = dateRange.value[1]
|
||||
@ -175,7 +383,6 @@ const getList = async () => {
|
||||
if (res.code === 200) {
|
||||
tableData.value = res.data.list
|
||||
total.value = res.data.total
|
||||
// 更新选项
|
||||
if (res.data.modules) {
|
||||
moduleOptions.value = res.data.modules
|
||||
}
|
||||
@ -190,13 +397,11 @@ const getList = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleQuery = () => {
|
||||
queryParams.page = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
queryParams.page = 1
|
||||
queryParams.username = ''
|
||||
@ -209,29 +414,13 @@ const handleReset = () => {
|
||||
getList()
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
const handleViewDetails = (row: any) => {
|
||||
currentLog.value = row
|
||||
detailDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 格式化详情 JSON
|
||||
const formatDetails = (details: any) => {
|
||||
if (!details) return ''
|
||||
if (typeof details === 'string') {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(details), null, 2)
|
||||
} catch {
|
||||
return details
|
||||
}
|
||||
}
|
||||
return JSON.stringify(details, null, 2)
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
getList()
|
||||
// 获取可选模块
|
||||
getAuditModules().then(res => {
|
||||
if (res.code === 200 && res.data) {
|
||||
moduleOptions.value = res.data
|
||||
@ -251,26 +440,79 @@ onMounted(() => {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.text-gray {
|
||||
color: #999;
|
||||
.base-info {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.details-box {
|
||||
margin-top: 20px;
|
||||
.details-section {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.details-title {
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.json-content {
|
||||
background-color: #f5f7fa;
|
||||
padding: 15px;
|
||||
/* 变更表格样式 */
|
||||
.changes-box {
|
||||
background: #fef0f0;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #fbc4c4;
|
||||
}
|
||||
|
||||
.field-name {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.old-value {
|
||||
color: #f56c6c;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.new-value {
|
||||
color: #67c23a;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 快照样式 */
|
||||
.snapshot-box {
|
||||
background: #f5f7fa;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.snapshot-value {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* 原始 JSON */
|
||||
.raw-json-box {
|
||||
background: #f5f7fa;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.raw-json {
|
||||
background: #2d2d2d;
|
||||
color: #67c23a;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
max-height: 400px;
|
||||
overflow: auto;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@ -62,6 +62,28 @@
|
||||
<el-tag type="warning" effect="plain" round>当前配置: {{ getRoleLabel(currentRole) }}</el-tag>
|
||||
</div>
|
||||
|
||||
<!-- 新增:拉取其他角色权限的操作区 -->
|
||||
<div class="pull-permission-bar">
|
||||
<span class="pull-label">从其他角色复制权限:</span>
|
||||
<el-select
|
||||
v-model="sourceRoleForClone"
|
||||
placeholder="选择源角色..."
|
||||
clearable
|
||||
size="default"
|
||||
@change="handleClonePermissions"
|
||||
class="clone-select"
|
||||
>
|
||||
<el-option
|
||||
v-for="role in availableSourceRoles"
|
||||
:key="role.value"
|
||||
:label="role.label"
|
||||
:value="role.value"
|
||||
:disabled="role.value === currentRole"
|
||||
/>
|
||||
</el-select>
|
||||
<span class="pull-hint" v-if="!sourceRoleForClone">选择后将覆盖当前未保存的勾选状态</span>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
:data="tableData"
|
||||
row-key="id"
|
||||
@ -78,20 +100,25 @@
|
||||
|
||||
<el-table-column label="访问权限" width="150" align="center">
|
||||
<template #default="{ row }">
|
||||
<!-- 父级目录隐藏复选框,仅叶子节点可操作 -->
|
||||
<el-checkbox
|
||||
v-if="row.type !== 'menu' || !row.children?.length"
|
||||
v-model="row.hasRead"
|
||||
@change="(val) => handleReadChange(val, row)"
|
||||
class="custom-checkbox"
|
||||
>
|
||||
<span :class="{ 'text-active': row.hasRead }">可见 (Read)</span>
|
||||
</el-checkbox>
|
||||
<span v-else class="text-gray">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作权限" width="180" align="center">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.operationCode">
|
||||
<!-- 父级目录隐藏操作权限列 -->
|
||||
<div v-if="row.type !== 'menu' || !row.children?.length">
|
||||
<el-checkbox
|
||||
v-if="row.operationCode"
|
||||
v-model="row.hasWrite"
|
||||
:disabled="!row.hasRead"
|
||||
@change="(val) => handleWriteChange(val, row)"
|
||||
@ -99,6 +126,7 @@
|
||||
>
|
||||
<span :class="{ 'text-active': row.hasWrite, 'text-disabled': !row.hasRead }">可编辑 (Write)</span>
|
||||
</el-checkbox>
|
||||
<span v-else class="text-gray">-</span>
|
||||
</div>
|
||||
<span v-else class="text-gray">-</span>
|
||||
</template>
|
||||
@ -153,8 +181,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { User, UserFilled, ArrowRight, Setting, Check, Avatar } from '@element-plus/icons-vue'
|
||||
import { getAllPermissionTree, getRolePermissions, saveRolePermissions } from '@/api/system/permission'
|
||||
|
||||
@ -180,6 +208,7 @@ interface PermissionNode {
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const currentRole = ref('')
|
||||
const sourceRoleForClone = ref('') // 用于克隆权限的源角色
|
||||
const roleList = [
|
||||
{ label: '超级管理员', value: 'SUPER_ADMIN' },
|
||||
{ label: '主管', value: 'SUPERVISOR' },
|
||||
@ -269,6 +298,8 @@ const transformData = (nodes: any[]): PermissionNode[] => {
|
||||
// 2. 切换角色:回显权限
|
||||
const handleRoleSelect = async (roleCode: string) => {
|
||||
currentRole.value = roleCode
|
||||
// 切换角色时清空克隆选择
|
||||
sourceRoleForClone.value = ''
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
@ -286,6 +317,53 @@ const handleRoleSelect = async (roleCode: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 可用的源角色列表(排除当前已选角色)
|
||||
const availableSourceRoles = computed(() => {
|
||||
return roleList.filter(r => r.value !== currentRole.value)
|
||||
})
|
||||
|
||||
// ★ 新增:从其他角色拉取权限
|
||||
const handleClonePermissions = async (sourceRole: string) => {
|
||||
if (!sourceRole) {
|
||||
sourceRoleForClone.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
// 确认提示
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`将从角色【${getRoleLabel(sourceRole)}】复制权限到【${getRoleLabel(currentRole.value)}】,覆盖当前未保存的勾选状态,是否继续?`,
|
||||
'权限拉取确认',
|
||||
{
|
||||
confirmButtonText: '确认拉取',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
// 用户取消
|
||||
sourceRoleForClone.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
// 调用后端 API 获取源角色的权限
|
||||
const res: any = await getRolePermissions(sourceRole)
|
||||
if (res.code === 200) {
|
||||
const perms = new Set([...(res.data.menus || []), ...(res.data.elements || [])])
|
||||
// 递归设置表格每一行的状态
|
||||
setRowStatus(tableData.value, perms)
|
||||
ElMessage.success(`已从【${getRoleLabel(sourceRole)}】拉取权限,请修改后点击保存`)
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('拉取权限失败')
|
||||
} finally {
|
||||
sourceRoleForClone.value = '' // 清空选择器
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 递归回显状态
|
||||
const setRowStatus = (rows: PermissionNode[], perms: Set<any>) => {
|
||||
rows.forEach(row => {
|
||||
@ -599,6 +677,33 @@ onMounted(() => {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* 新增:拉取权限操作条 */
|
||||
.pull-permission-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 15px;
|
||||
margin-bottom: 15px;
|
||||
background: #f0f7ff;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #d9ecff;
|
||||
}
|
||||
|
||||
.pull-label {
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.clone-select {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.pull-hint {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
/* 表格内样式 */
|
||||
.custom-checkbox {
|
||||
height: auto;
|
||||
|
||||
@ -4,9 +4,14 @@
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span style="font-weight: bold;">员工账号管理</span>
|
||||
<el-button v-if="userStore.hasPermission('system_user:operation')" type="primary" @click="handleCreate">
|
||||
+ 新增员工
|
||||
</el-button>
|
||||
<div>
|
||||
<el-button v-if="userStore.hasPermission('system_user:operation')" type="success" @click="batchDialogVisible = true">
|
||||
批量新增
|
||||
</el-button>
|
||||
<el-button v-if="userStore.hasPermission('system_user:operation')" type="primary" @click="handleCreate">
|
||||
+ 新增员工
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -138,12 +143,52 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 批量新增弹窗 -->
|
||||
<el-dialog v-model="batchDialogVisible" title="批量新增员工" width="600px" destroy-on-close @close="batchForm.namesText = ''">
|
||||
<el-form :model="batchForm" label-width="100px">
|
||||
<el-form-item label="所属部门" required>
|
||||
<el-select v-model="batchForm.department" style="width: 100%" placeholder="请选择部门">
|
||||
<el-option v-for="d in departmentOptions" :key="d" :label="d" :value="d" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="系统角色" required>
|
||||
<el-select v-model="batchForm.role" style="width: 100%" placeholder="请选择角色">
|
||||
<el-option v-for="r in roleOptions" :key="r.value" :label="r.label" :value="r.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="员工名单" required>
|
||||
<el-input type="textarea" v-model="batchForm.namesText" :rows="8" placeholder="请输入真实姓名,每行一个。密码默认统一为 123456" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="batchDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="batchSubmitting" @click="handleBatchSubmit">确认创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 批量创建结果弹窗 -->
|
||||
<el-dialog v-model="batchResultVisible" title="批量创建结果" width="600px">
|
||||
<div style="margin-bottom: 10px; color: #67C23A; font-weight: bold;">请复制以下账号分发给员工:</div>
|
||||
<el-table :data="batchResults" border height="400px">
|
||||
<el-table-column prop="cn_name" label="姓名" width="150" />
|
||||
<el-table-column label="生成账号">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.status === 'success'" style="color: #409EFF; font-weight: bold;">{{ row.account_id }}</span>
|
||||
<span v-else style="color: #F56C6C">{{ row.error }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button @click="batchResultVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, onMounted, computed } from 'vue'
|
||||
import { createUser, updateUser, getUserList, deleteUser } from '@/api/auth'
|
||||
import { createUser, updateUser, getUserList, deleteUser, batchCreateUser } from '@/api/auth'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { pinyin } from 'pinyin-pro' // ★ 务必安装: npm install pinyin-pro
|
||||
@ -199,6 +244,17 @@ const form = reactive({
|
||||
email: ''
|
||||
})
|
||||
|
||||
// 批量新增相关状态
|
||||
const batchDialogVisible = ref(false)
|
||||
const batchResultVisible = ref(false)
|
||||
const batchSubmitting = ref(false)
|
||||
const batchForm = reactive({
|
||||
namesText: '',
|
||||
department: '',
|
||||
role: ''
|
||||
})
|
||||
const batchResults = ref<any[]>([])
|
||||
|
||||
// ★ 监听中文输入,自动转拼音
|
||||
const handleNameInput = (val: string) => {
|
||||
if (isEdit.value) return // 编辑模式下不联动
|
||||
@ -246,10 +302,30 @@ const roleOptions = computed(() => {
|
||||
return options
|
||||
})
|
||||
|
||||
// 自定义校验:仅支持中英文、数字,禁止纯数字,禁止特殊字符
|
||||
const validateNameStrict = (rule: any, value: string, callback: any) => {
|
||||
if (!value) {
|
||||
callback(new Error('该字段不能为空'));
|
||||
return;
|
||||
}
|
||||
const reg = /^(?!\d+$)[a-zA-Z0-9\u4e00-\u9fa5]+$/;
|
||||
if (!reg.test(value)) {
|
||||
callback(new Error('仅支持中英文和数字,不能为纯数字,且不支持特殊字符'));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
const rules = computed(() => {
|
||||
const commonRules: any = {
|
||||
cn_name: [{ required: true, message: '请输入真实姓名', trigger: 'blur' }],
|
||||
username: [{ required: true, message: '账号不能为空', trigger: 'blur' }],
|
||||
cn_name: [
|
||||
{ required: true, message: '请输入真实姓名', trigger: 'blur' },
|
||||
{ validator: validateNameStrict, trigger: 'blur' }
|
||||
],
|
||||
username: [
|
||||
{ required: true, message: '账号不能为空', trigger: 'blur' },
|
||||
{ validator: validateNameStrict, trigger: 'blur' }
|
||||
],
|
||||
role: [{ required: true, message: '请选择角色', trigger: 'change' }],
|
||||
department: [{ required: true, message: '请输入或选择部门', trigger: ['blur', 'change'] }],
|
||||
email: [
|
||||
@ -370,6 +446,43 @@ const onSubmit = async () => {
|
||||
})
|
||||
}
|
||||
|
||||
// 批量提交逻辑
|
||||
const handleBatchSubmit = async () => {
|
||||
if (!batchForm.department || !batchForm.role || !batchForm.namesText.trim()) {
|
||||
return ElMessage.warning('请填写完整部门、角色及员工名单')
|
||||
}
|
||||
|
||||
const names = batchForm.namesText.split('\n').map(n => n.trim()).filter(n => n)
|
||||
if (names.length === 0) return ElMessage.warning('未能识别到有效姓名')
|
||||
|
||||
const payload = names.map(name => {
|
||||
const pinyinStr = pinyin(name, { toneType: 'none', type: 'array' }).join('').toLowerCase()
|
||||
return {
|
||||
cn_name: name,
|
||||
username: pinyinStr, // 拼音基础串,后端会自动防重
|
||||
password: '123456',
|
||||
department: batchForm.department,
|
||||
role: batchForm.role,
|
||||
email: ''
|
||||
}
|
||||
})
|
||||
|
||||
batchSubmitting.value = true
|
||||
try {
|
||||
const res: any = await batchCreateUser(payload)
|
||||
if (res.code === 200 || res.msg === '批量处理完成') {
|
||||
batchResults.value = res.data
|
||||
batchDialogVisible.value = false
|
||||
batchResultVisible.value = true
|
||||
getList() // 刷新底层列表
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('批量创建遇到错误')
|
||||
} finally {
|
||||
batchSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
if (!formRef.value) return
|
||||
formRef.value.resetFields()
|
||||
|
||||
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()
|
||||
@ -12,7 +12,7 @@ DB_CONFIG = {
|
||||
}
|
||||
|
||||
# 2. Excel 文件路径
|
||||
EXCEL_FILE = 'product.template.xlsx'
|
||||
EXCEL_FILE = '../product.template.xlsx'
|
||||
|
||||
|
||||
def process_excel_to_db():
|
||||
Reference in New Issue
Block a user