Compare commits
24 Commits
1e341ab6aa
...
2.0权限管理
| Author | SHA1 | Date | |
|---|---|---|---|
| 117bd003a9 | |||
| 75705d31c9 | |||
| dd84ad828d | |||
| 7d02da2f5c | |||
| 2a6e3979e8 | |||
| e331236a6e | |||
| e977ffc42d | |||
| 4d81056075 | |||
| 6e1e1aa998 | |||
| c60112f5f8 | |||
| c0ab3ce6d2 | |||
| cf55c94826 | |||
| 48651ffd01 | |||
| d60e1c5188 | |||
| 79fccdc24c | |||
| e67e965d8f | |||
| 3cb31c2b67 | |||
| d1e49c343c | |||
| a625189375 | |||
| ee893485bb | |||
| 1ec1bc34eb | |||
| f9dd8b6536 | |||
| 38c71147a2 | |||
| d736d5d4a9 |
@ -234,6 +234,17 @@ def create_app():
|
||||
except Exception as e:
|
||||
print(f"❌ 错误: Scan 模块注册失败: {e}")
|
||||
|
||||
# -----------------------------------------------------
|
||||
# 2.x 注册异步导出模块 (Export)
|
||||
# -----------------------------------------------------
|
||||
try:
|
||||
from app.api.v1.export import export_bp
|
||||
app.register_blueprint(export_bp, url_prefix='/api/v1/export')
|
||||
app.register_blueprint(export_bp, url_prefix='/api/export', name='export_legacy')
|
||||
print("✅ Export 模块注册成功")
|
||||
except Exception as e:
|
||||
print(f"❌ 错误: Export 模块注册失败: {e}")
|
||||
|
||||
# =========================================================
|
||||
# 3. 预加载数据模型
|
||||
# =========================================================
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from sqlalchemy import or_
|
||||
from app.services.bom_service import BomService
|
||||
from app.services.bom_service import BomService, _cache_delete
|
||||
from app.models.base import MaterialBase
|
||||
from app.models.bom import BomTable
|
||||
from app.extensions import db
|
||||
@ -225,6 +225,11 @@ def delete_bom(bom_no):
|
||||
db.session.delete(rec)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# ===== 删除成功后立刻清除缓存(Cache Invalidation) =====
|
||||
_cache_delete(bom_no, version)
|
||||
current_app.logger.info(f"[BOM Cache] delete_bom → 缓存已失效 bom_no={bom_no} version={version}")
|
||||
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': '删除成功',
|
||||
|
||||
10
inventory-backend/app/api/v1/export/__init__.py
Normal file
10
inventory-backend/app/api/v1/export/__init__.py
Normal file
@ -0,0 +1,10 @@
|
||||
"""
|
||||
app/api/v1/export/__init__.py
|
||||
导出模块 Blueprint 注册文件
|
||||
"""
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
export_bp = Blueprint('export', __name__, url_prefix='/api/v1/export')
|
||||
|
||||
from app.api.v1.export import inventory_export
|
||||
144
inventory-backend/app/api/v1/export/inventory_export.py
Normal file
144
inventory-backend/app/api/v1/export/inventory_export.py
Normal file
@ -0,0 +1,144 @@
|
||||
"""
|
||||
app/api/v1/export/inventory_export.py
|
||||
异步导出核心接口
|
||||
|
||||
提供三个端点:
|
||||
POST /api/v1/export/inventory → 提交导出任务,返回 task_id
|
||||
GET /api/v1/export/status/<task_id> → 查询任务状态(轮询)
|
||||
GET /api/v1/export/download/<task_id> → 下载已生成的 Excel 文件
|
||||
"""
|
||||
|
||||
import os
|
||||
from flask import Blueprint, request, jsonify, send_file, current_app
|
||||
from flask_jwt_extended import jwt_required, get_jwt
|
||||
from app.services.export_service.excel_task import (
|
||||
submit_export_task,
|
||||
get_task_status,
|
||||
get_export_filepath,
|
||||
)
|
||||
from app.utils.decorators import permission_required
|
||||
|
||||
export_bp = Blueprint('export', __name__, url_prefix='/api/v1/export')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 任务提交接口
|
||||
# =============================================================================
|
||||
|
||||
@export_bp.route('/inventory', methods=['POST'])
|
||||
@jwt_required()
|
||||
@permission_required('inventory_manage')
|
||||
def submit_export():
|
||||
"""
|
||||
接收前端导出请求,生成 task_id,立即返回。
|
||||
|
||||
请求体(JSON):
|
||||
{
|
||||
"keyword": "螺丝",
|
||||
"category": "原材料",
|
||||
"status": "在库"
|
||||
}
|
||||
|
||||
响应:
|
||||
{ "code": 200, "msg": "success", "data": { "task_id": "xxx" } }
|
||||
"""
|
||||
try:
|
||||
filters = request.get_json() or {}
|
||||
|
||||
# 生成 task_id 并启动后台任务(同步返回,不阻塞)
|
||||
task_id = submit_export_task(filters)
|
||||
|
||||
current_app.logger.info(
|
||||
f"[Export] 用户 {get_jwt().get('username')} 提交导出任务 task_id={task_id}"
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': '导出任务已创建',
|
||||
'data': {
|
||||
'task_id': task_id, # 前端用此 ID 轮询 /export/status/<task_id>
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"[Export] 提交导出任务失败: {e}")
|
||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 进度查询接口(前端轮询)
|
||||
# =============================================================================
|
||||
|
||||
@export_bp.route('/status/<task_id>', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_export_status(task_id: str):
|
||||
"""
|
||||
从 Redis 读取任务状态,供前端轮询。
|
||||
|
||||
响应示例(处理中):
|
||||
{
|
||||
"code": 200,
|
||||
"data": { "status": "processing", "progress": 45, "url": "", "error": "" }
|
||||
}
|
||||
|
||||
响应示例(已完成):
|
||||
{
|
||||
"code": 200,
|
||||
"data": { "status": "completed", "progress": 100, "url": "/api/v1/export/download/xxx", "error": "" }
|
||||
}
|
||||
"""
|
||||
try:
|
||||
status = get_task_status(task_id)
|
||||
|
||||
if status.get('status') == 'not_found':
|
||||
return jsonify({'code': 404, 'msg': '任务不存在或已过期'}), 404
|
||||
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': 'success',
|
||||
'data': status
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"[Export] 查询任务状态失败: task_id={task_id}, err={e}")
|
||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 文件下载接口
|
||||
# =============================================================================
|
||||
|
||||
@export_bp.route('/download/<task_id>', methods=['GET'])
|
||||
@jwt_required()
|
||||
def download_export_file(task_id: str):
|
||||
"""
|
||||
下载已生成的 Excel 文件。
|
||||
|
||||
前端轮询发现 status=completed 后,
|
||||
取 data.url 拼接完整下载地址,发起下载请求。
|
||||
|
||||
安全:只允许下载已完成且未过期的文件(TTL=1h)。
|
||||
"""
|
||||
try:
|
||||
# 再次确认任务状态,防止下载不存在的文件
|
||||
status = get_task_status(task_id)
|
||||
if status.get('status') != 'completed':
|
||||
return jsonify({'code': 400, 'msg': '文件未就绪,请稍后'}), 400
|
||||
|
||||
filepath = get_export_filepath(task_id)
|
||||
if not filepath:
|
||||
return jsonify({'code': 404, 'msg': '文件不存在或已过期'}), 404
|
||||
|
||||
current_app.logger.info(f"[Export] 用户 {get_jwt().get('username')} 下载 task_id={task_id}")
|
||||
|
||||
# send_file 自动设置 Content-Disposition: attachment(触发浏览器下载)
|
||||
return send_file(
|
||||
filepath,
|
||||
as_attachment=True,
|
||||
download_name=f"库存导出_{task_id[:8]}.xlsx",
|
||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"[Export] 下载失败: task_id={task_id}, err={e}")
|
||||
return jsonify({'code': 500, 'msg': '下载失败,请重试'}), 500
|
||||
@ -137,34 +137,70 @@ def get_stock_info(uuid_or_barcode):
|
||||
def get_all_stock():
|
||||
"""
|
||||
获取所有库存 > 0 的物品
|
||||
支持 AI 极简模式: ?ai_mode=true
|
||||
- 只返回 name / spec / availableQuantity 三个字段
|
||||
- 键名压缩为 n / s / c
|
||||
"""
|
||||
ai_mode = request.args.get('ai_mode', '').lower() == 'true'
|
||||
|
||||
try:
|
||||
all_items = []
|
||||
|
||||
# 1. 采购件
|
||||
materials = []
|
||||
if StockBuy:
|
||||
materials = StockBuy.query.filter(StockBuy.stock_quantity > 0).all()
|
||||
rows = StockBuy.query.filter(
|
||||
StockBuy.stock_quantity > 0
|
||||
).options(joinedload(StockBuy.base)).all()
|
||||
for item in rows:
|
||||
if ai_mode:
|
||||
b = item.base
|
||||
all_items.append({
|
||||
'n': b.name if b else '',
|
||||
's': b.spec_model if b else '',
|
||||
'c': float(item.available_quantity or 0)
|
||||
})
|
||||
else:
|
||||
all_items.append(item.to_dict())
|
||||
|
||||
# 2. 半成品
|
||||
semis = []
|
||||
if StockSemi:
|
||||
try:
|
||||
semis = StockSemi.query.filter(StockSemi.stock_quantity > 0).all()
|
||||
rows = StockSemi.query.filter(
|
||||
StockSemi.stock_quantity > 0
|
||||
).options(joinedload(StockSemi.base)).all()
|
||||
for item in rows:
|
||||
if ai_mode:
|
||||
b = item.base
|
||||
all_items.append({
|
||||
'n': b.name if b else '',
|
||||
's': b.spec_model if b else '',
|
||||
'c': float(item.available_quantity or 0)
|
||||
})
|
||||
else:
|
||||
all_items.append(item.to_dict())
|
||||
except Exception:
|
||||
semis = []
|
||||
pass
|
||||
|
||||
# 3. 成品
|
||||
products = []
|
||||
if StockProduct:
|
||||
try:
|
||||
products = StockProduct.query.filter(StockProduct.stock_quantity > 0).all()
|
||||
rows = StockProduct.query.filter(
|
||||
StockProduct.stock_quantity > 0
|
||||
).options(joinedload(StockProduct.base)).all()
|
||||
for item in rows:
|
||||
if ai_mode:
|
||||
b = item.base
|
||||
all_items.append({
|
||||
'n': b.name if b else '',
|
||||
's': b.spec_model if b else '',
|
||||
'c': float(item.available_quantity or 0)
|
||||
})
|
||||
else:
|
||||
all_items.append(item.to_dict())
|
||||
except Exception:
|
||||
products = []
|
||||
pass
|
||||
|
||||
return jsonify({
|
||||
"materials": [item.to_dict() for item in materials],
|
||||
"semis": [item.to_dict() for item in semis],
|
||||
"products": [item.to_dict() for item in products]
|
||||
}), 200
|
||||
return jsonify(all_items), 200
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
return jsonify({"message": f"查询库存失败: {str(e)}"}), 500
|
||||
|
||||
@ -35,16 +35,17 @@ def filter_item_by_permissions(item_dict, user_permissions, prefix='op_records')
|
||||
"""
|
||||
# 字段名到权限码的映射(与前端 permissionMap 保持一致)
|
||||
field_to_perm = {
|
||||
'borrow_no': f'{prefix}:borrow_no',
|
||||
'borrower_name': f'{prefix}:borrower_name',
|
||||
# 'sku': f'{prefix}:sku', # SKU 字段不再参与权限过滤,直接返回
|
||||
'borrow_time': f'{prefix}:borrow_time',
|
||||
'return_time': f'{prefix}:return_time',
|
||||
'status': f'{prefix}:status',
|
||||
'expected_return_time': f'{prefix}:expected_return_time',
|
||||
'return_location': f'{prefix}:return_location',
|
||||
'borrow_signature': f'{prefix}:borrow_signature',
|
||||
'return_signature': f'{prefix}:return_signature',
|
||||
# 'borrow_no': f'{prefix}:borrow_no',
|
||||
# 'borrower_name': f'{prefix}:borrower_name',
|
||||
# 'sku': f'{prefix}:sku',
|
||||
# 'borrow_time': f'{prefix}:borrow_time',
|
||||
# 'return_time': f'{prefix}:return_time',
|
||||
# 'return_operator': f'{prefix}:return_operator',
|
||||
# 'status': f'{prefix}:status',
|
||||
# 'expected_return_time': f'{prefix}:expected_return_time',
|
||||
# 'return_location': f'{prefix}:return_location',
|
||||
# 'borrow_signature': f'{prefix}:borrow_signature',
|
||||
# 'return_signature': f'{prefix}:return_signature',
|
||||
}
|
||||
# 如果用户是超级管理员且有 '*',则不过滤
|
||||
if '*' in user_permissions:
|
||||
|
||||
@ -41,7 +41,8 @@ def _is_audit_model(mapper):
|
||||
'StockBuy', 'StockSemi', 'StockProduct', 'StockService',
|
||||
'RepairRecord', 'TransOutbound', 'TransBorrow', 'TransReturn',
|
||||
'BomTable', 'StockTake', 'StockAdjust',
|
||||
'TransScrap', 'SysUser'
|
||||
'TransScrap',
|
||||
'SysUser', 'SysMenu', 'SysElement', 'SysRolePermission', # ★ 新增:系统管理三表纳入审计
|
||||
}
|
||||
return mapper.class_.__name__ in AUDIT_WHITELIST
|
||||
|
||||
@ -129,6 +130,13 @@ def _create_audit_log(session, mapper, target, action, details):
|
||||
def before_update_listener(mapper, connection, target):
|
||||
"""UPDATE 事件:抓取字段变更明细"""
|
||||
if not _is_audit_model(mapper): return
|
||||
|
||||
# ★★★ 关键修复:系统初始化(PermissionService.init_all_menus 等)时,
|
||||
# username='system' 且 has_request_context()=False,
|
||||
# 这类非用户发起的变更不应产生审计日志,直接跳过。
|
||||
if not has_request_context():
|
||||
return
|
||||
|
||||
try:
|
||||
state = inspect(target)
|
||||
changes = {}
|
||||
@ -150,6 +158,8 @@ def before_update_listener(mapper, connection, target):
|
||||
def before_delete_listener(mapper, connection, target):
|
||||
"""DELETE 事件:抓取被删除对象的完整快照"""
|
||||
if not _is_audit_model(mapper): return
|
||||
# ★★★ 关键修复:非 HTTP 请求上下文下的初始化操作(如 PermissionService)
|
||||
if not has_request_context(): return
|
||||
try:
|
||||
state = inspect(target)
|
||||
snap = {}
|
||||
@ -164,6 +174,8 @@ def before_delete_listener(mapper, connection, target):
|
||||
def after_insert_listener(mapper, connection, target):
|
||||
"""INSERT 事件:抓取新增对象的完整快照"""
|
||||
if not _is_audit_model(mapper): return
|
||||
# ★★★ 关键修复:非 HTTP 请求上下文下的初始化操作(如 PermissionService)
|
||||
if not has_request_context(): return
|
||||
try:
|
||||
state = inspect(target)
|
||||
snap = {}
|
||||
|
||||
@ -2,6 +2,7 @@ from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
from flask_cors import CORS
|
||||
from flask_jwt_extended import JWTManager # 确保引入了 JWTManager
|
||||
from flask import current_app
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import redis
|
||||
|
||||
@ -11,15 +12,78 @@ migrate = Migrate()
|
||||
cors = CORS()
|
||||
jwt = JWTManager() # 必须实例化
|
||||
|
||||
# Redis 客户端 (单设备登录互踢用)
|
||||
# Redis 客户端 (单设备登录互踢 + JWT Token 黑名单用)
|
||||
redis_client = None
|
||||
|
||||
# Redis Key 前缀
|
||||
_JWT_BLOCKED_USER_PREFIX = "jwt_blocked_user:" # 存储被删除/禁用的 user_id
|
||||
|
||||
|
||||
def beijing_time():
|
||||
"""获取北京时间 (UTC+8),剥离时区信息以兼容数据库 naive DateTime 字段"""
|
||||
return datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 全局 JWT Token 黑名单拦截器
|
||||
# 原理:Flask-JWT-Extended 在每次 @jwt_required() 验证时,
|
||||
# 会自动触发 token_in_blocklist_loader 回调。
|
||||
# 若该回调返回 True(命中黑名单),请求直接被 401 拒绝,后续代码不会执行。
|
||||
# =============================================================================
|
||||
@jwt.token_in_blocklist_loader
|
||||
def check_if_token_is_revoked(jwt_header, jwt_payload):
|
||||
"""
|
||||
全局 JWT 黑名单检查:每次 @jwt_required() 调用时自动触发。
|
||||
无论 AI(Dify)还是人类用户调用的接口,均受此拦截。
|
||||
|
||||
检查逻辑:
|
||||
1. 通过 jwt_payload['sub'](user_id)查询 Redis 黑名单
|
||||
2. 若 user_id 存在于黑名单 → 返回 True → 请求被 401 拒绝
|
||||
3. 若 Redis 不可用(fail-open)→ 放行(不影响正常业务)
|
||||
"""
|
||||
user_id = jwt_payload.get('sub')
|
||||
if user_id is None:
|
||||
return False
|
||||
|
||||
global redis_client
|
||||
if redis_client is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
blocked_key = f"{_JWT_BLOCKED_USER_PREFIX}{user_id}"
|
||||
is_blocked = redis_client.exists(blocked_key)
|
||||
if is_blocked:
|
||||
current_app.logger.warning(
|
||||
f"🚫 JWT revoked for deleted/disabled user: user_id={user_id}"
|
||||
)
|
||||
return bool(is_blocked)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"JWT blocklist check error: {e}")
|
||||
return False # Redis 出错时 fail-open,不阻断正常业务
|
||||
|
||||
|
||||
def revoke_all_tokens_for_user(user_id):
|
||||
"""
|
||||
将指定用户的 ID 加入 JWT 黑名单(14 天)。
|
||||
效果:该用户的所有已发放 Token(无论是否过期)瞬间失效。
|
||||
由 delete_user() / update_user(status!='active') 时调用。
|
||||
"""
|
||||
global redis_client
|
||||
if redis_client is None:
|
||||
current_app.logger.warning(
|
||||
f"Redis unavailable, cannot revoke tokens for user_id={user_id}"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
blocked_key = f"{_JWT_BLOCKED_USER_PREFIX}{user_id}"
|
||||
ttl_seconds = 14 * 24 * 3600 # 14 天,与 Refresh Token 有效期对齐
|
||||
redis_client.setex(blocked_key, ttl_seconds, "1")
|
||||
current_app.logger.info(f"✅ User {user_id} added to JWT blocklist (TTL={ttl_seconds}s)")
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Failed to revoke tokens for user_id={user_id}: {e}")
|
||||
|
||||
|
||||
# 2. 定义初始化函数 (供工厂函数 create_app 调用)
|
||||
def init_extensions(app):
|
||||
"""
|
||||
|
||||
@ -14,11 +14,11 @@ class MaterialBase(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
company_name = db.Column(db.String(255), comment='所属公司')
|
||||
|
||||
name = db.Column(db.String(255), nullable=False, comment='名称')
|
||||
name = db.Column(db.String(255), nullable=False, index=True, comment='名称') # ★ 模糊搜索/精确定位高频列
|
||||
common_name = db.Column(db.String(255), comment='俗名')
|
||||
category = db.Column(db.String(100), comment='类别')
|
||||
material_type = db.Column(db.String(100), comment='类型')
|
||||
spec_model = db.Column(db.String(255), comment='规格型号')
|
||||
category = db.Column(db.String(100), index=True, comment='类别') # ★ 分类统计/过滤高频列
|
||||
material_type = db.Column(db.String(100), index=True, comment='类型') # ★ 类型分组/过滤高频列
|
||||
spec_model = db.Column(db.String(255), index=True, comment='规格型号') # ★ 模糊搜索/精确匹配高频列
|
||||
unit = db.Column(db.String(50), comment='计量单位')
|
||||
|
||||
# 可见等级
|
||||
|
||||
@ -5,11 +5,11 @@ class BomTable(db.Model):
|
||||
__tablename__ = 'bom_table'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
parent_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False)
|
||||
child_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False)
|
||||
parent_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False, index=True) # ★ 父子件关联高频列
|
||||
child_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False, index=True) # ★ 子件过滤高频列
|
||||
|
||||
bom_no = db.Column(db.String(100), nullable=False, comment='BOM编号')
|
||||
version = db.Column(db.String(50), nullable=False, default='V1.0', comment='版本')
|
||||
bom_no = db.Column(db.String(100), nullable=False, index=True, comment='BOM编号') # ★ Redis 缓存 Key + 列表查询核心列
|
||||
version = db.Column(db.String(50), nullable=False, default='V1.0', index=True, comment='版本') # ★ 配合 bom_no 做唯一性约束
|
||||
|
||||
dosage = db.Column(db.Numeric(19, 4), comment='个数')
|
||||
loss_rate = db.Column(db.Numeric(5, 2), comment='损耗率%', default=0, nullable=True)
|
||||
|
||||
@ -13,19 +13,19 @@ class StockBuy(db.Model):
|
||||
__tablename__ = 'stock_buy'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False)
|
||||
base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False, index=True) # ★ 批量 IN 查询高频列
|
||||
|
||||
# 身份标识
|
||||
sku = db.Column(db.String(100))
|
||||
sku = db.Column(db.String(100), index=True) # ★ 条码/SKU 快速定位
|
||||
in_date = db.Column(db.DateTime)
|
||||
barcode = db.Column(db.String(100))
|
||||
barcode = db.Column(db.String(100), index=True) # ★ 条码扫码查询高频列
|
||||
serial_number = db.Column(db.String(100))
|
||||
batch_number = db.Column(db.String(100))
|
||||
|
||||
# 状态
|
||||
status = db.Column(db.String(50), default='在库')
|
||||
status = db.Column(db.String(50), index=True) # ★ 在库/锁定 过滤条件
|
||||
inspection_status = db.Column(db.String(50))
|
||||
warehouse_location = db.Column(db.String(100))
|
||||
warehouse_location = db.Column(db.String(100), index=True) # ★ 按库位分组/过滤
|
||||
|
||||
# 数量
|
||||
in_quantity = db.Column(db.Numeric(19, 4), default=0)
|
||||
|
||||
@ -12,12 +12,12 @@ class StockProduct(db.Model):
|
||||
__tablename__ = 'stock_product'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False)
|
||||
base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False, index=True) # ★ 批量 IN 查询高频列
|
||||
|
||||
# 身份标识
|
||||
sku = db.Column(db.String(100))
|
||||
sku = db.Column(db.String(100), index=True) # ★ SKU 快速定位
|
||||
production_date = db.Column(db.DateTime)
|
||||
barcode = db.Column(db.String(100))
|
||||
barcode = db.Column(db.String(100), index=True) # ★ 条码扫码查询高频列
|
||||
serial_number = db.Column(db.String(100))
|
||||
|
||||
# 数量
|
||||
@ -26,8 +26,8 @@ class StockProduct(db.Model):
|
||||
available_quantity = db.Column(db.Numeric(19, 4), default=0)
|
||||
|
||||
# 状态与位置
|
||||
status = db.Column(db.String(50))
|
||||
warehouse_location = db.Column(db.String(100))
|
||||
status = db.Column(db.String(50), index=True) # ★ 在库/锁定 过滤条件
|
||||
warehouse_location = db.Column(db.String(100), index=True) # ★ 按库位分组/过滤
|
||||
|
||||
# 生产与成本
|
||||
bom_code = db.Column('bom_id', db.String(100))
|
||||
|
||||
@ -11,11 +11,11 @@ class StockSemi(db.Model):
|
||||
__tablename__ = 'stock_semi'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False)
|
||||
base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False, index=True) # ★ 批量 IN 查询高频列
|
||||
|
||||
sku = db.Column(db.String(100))
|
||||
sku = db.Column(db.String(100), index=True) # ★ SKU 快速定位
|
||||
production_date = db.Column(db.DateTime)
|
||||
barcode = db.Column(db.String(100))
|
||||
barcode = db.Column(db.String(100), index=True) # ★ 条码扫码查询高频列
|
||||
serial_number = db.Column(db.String(100))
|
||||
batch_number = db.Column(db.String(100))
|
||||
|
||||
@ -25,8 +25,8 @@ class StockSemi(db.Model):
|
||||
available_quantity = db.Column(db.Numeric(19, 4), default=0)
|
||||
|
||||
# 状态与位置
|
||||
status = db.Column(db.String(50))
|
||||
warehouse_location = db.Column(db.String(100))
|
||||
status = db.Column(db.String(50), index=True) # ★ 在库/锁定 过滤条件
|
||||
warehouse_location = db.Column(db.String(100), index=True) # ★ 按库位分组/过滤
|
||||
|
||||
# 半成品特有字段
|
||||
bom_code = db.Column('bom_id', db.String(100))
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# app/services/auth_service.py
|
||||
from app.models.system import SysUser, SysRolePermission # <== 引入 SysRolePermission
|
||||
from app.extensions import db, redis_client
|
||||
from app.extensions import db, redis_client, revoke_all_tokens_for_user
|
||||
from sqlalchemy import func
|
||||
from flask_jwt_extended import create_access_token, create_refresh_token, get_jwt_identity
|
||||
from flask import current_app
|
||||
@ -334,7 +334,11 @@ class AuthService:
|
||||
user.email = email
|
||||
|
||||
if 'status' in data:
|
||||
user.status = data['status']
|
||||
new_status = data['status']
|
||||
# ★ 幽灵令牌漏洞修复:用户被禁用时,立即吊销其所有 Token
|
||||
if new_status != 'active' and user.status == 'active':
|
||||
revoke_all_tokens_for_user(user_id)
|
||||
user.status = new_status
|
||||
|
||||
new_password = data.get('password')
|
||||
if new_password and str(new_password).strip():
|
||||
@ -353,7 +357,7 @@ class AuthService:
|
||||
|
||||
@staticmethod
|
||||
def delete_user(user_id, operator_role):
|
||||
"""删除用户"""
|
||||
"""删除用户:删除前自动吊销该用户所有 JWT Token"""
|
||||
# 标准化操作者角色为全大写
|
||||
operator_role_upper = operator_role.upper() if operator_role else None
|
||||
if operator_role_upper != UserRole.SUPER_ADMIN:
|
||||
@ -365,6 +369,18 @@ class AuthService:
|
||||
|
||||
# 提前获取用户名用于审计日志
|
||||
username = user.username
|
||||
|
||||
# ★ 幽灵令牌漏洞修复:删除用户前,先将 user_id 加入 JWT 黑名单
|
||||
# 效果:该用户持有的所有 Token 瞬间失效,无论是否已过期
|
||||
revoke_all_tokens_for_user(user_id)
|
||||
|
||||
# 清除 Redis 中的单设备登录 Token(防止残留)
|
||||
if redis_client is not None:
|
||||
try:
|
||||
redis_client.delete(f"user_token_{user_id}")
|
||||
except Exception as e:
|
||||
current_app.logger.warning(f"Failed to delete user token from Redis: {e}")
|
||||
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
return username
|
||||
|
||||
@ -5,8 +5,96 @@ from app.models.inbound.buy import StockBuy
|
||||
from sqlalchemy import func, distinct, or_, case
|
||||
from collections import defaultdict
|
||||
import uuid
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Redis 缓存键前缀 + TTL(秒)
|
||||
BOM_CACHE_PREFIX = 'bom:tree'
|
||||
BOM_CACHE_TTL = 43200 # 12小时
|
||||
|
||||
|
||||
def _get_redis():
|
||||
"""
|
||||
获取 Redis 客户端实例,带容错保护。
|
||||
若 extensions 中没有 redis_client 或连接失败,返回 None。
|
||||
绝不抛出异常,确保业务不因此中断。
|
||||
"""
|
||||
try:
|
||||
from app.extensions import redis_client
|
||||
return redis_client
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _cache_get(bom_no, version=None):
|
||||
"""
|
||||
从 Redis 读取 BOM 缓存。
|
||||
键 = bom:tree:{bom_no} 或 bom:tree:{bom_no}:{version}
|
||||
返回:反序列化后的 dict 或 None
|
||||
"""
|
||||
client = _get_redis()
|
||||
if not client:
|
||||
return None
|
||||
|
||||
key = f"{BOM_CACHE_PREFIX}:{bom_no}" + (f":{version}" if version else "")
|
||||
try:
|
||||
raw = client.get(key)
|
||||
if raw:
|
||||
logger.debug(f"[BOM Cache] HIT key={key}")
|
||||
return json.loads(raw)
|
||||
logger.debug(f"[BOM Cache] MISS key={key}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"[BOM Cache] GET 失败,降级查库. key={key}, err={e}")
|
||||
return None
|
||||
|
||||
|
||||
def _cache_set(bom_no, version, data):
|
||||
"""
|
||||
将 BOM 数据写入 Redis,设置 12 小时 TTL。
|
||||
即使写入失败也只是打日志,不阻断业务流程。
|
||||
"""
|
||||
client = _get_redis()
|
||||
if not client:
|
||||
return
|
||||
|
||||
key = f"{BOM_CACHE_PREFIX}:{bom_no}" + (f":{version}" if version else "")
|
||||
try:
|
||||
client.setex(key, BOM_CACHE_TTL, json.dumps(data, ensure_ascii=False))
|
||||
logger.debug(f"[BOM Cache] SET key={key} ttl={BOM_CACHE_TTL}s")
|
||||
except Exception as e:
|
||||
logger.warning(f"[BOM Cache] SET 失败,已忽略. key={key}, err={e}")
|
||||
|
||||
|
||||
def _cache_delete(bom_no, version=None):
|
||||
"""
|
||||
删除 Redis 中指定 BOM 的缓存条目。
|
||||
在写操作(增/改/删)成功后调用,确保后续读请求拿到最新数据。
|
||||
"""
|
||||
client = _get_redis()
|
||||
if not client:
|
||||
return
|
||||
|
||||
# 删除版本级缓存
|
||||
if version:
|
||||
key = f"{BOM_CACHE_PREFIX}:{bom_no}:{version}"
|
||||
try:
|
||||
client.delete(key)
|
||||
logger.debug(f"[BOM Cache] DEL key={key}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[BOM Cache] DEL 失败,已忽略. key={key}, err={e}")
|
||||
|
||||
# 同时删除"最新版"缓存(不带版本后缀),避免缓存不一致
|
||||
key_latest = f"{BOM_CACHE_PREFIX}:{bom_no}"
|
||||
try:
|
||||
client.delete(key_latest)
|
||||
logger.debug(f"[BOM Cache] DEL key={key_latest}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[BOM Cache] DEL 失败,已忽略. key={key_latest}, err={e}")
|
||||
|
||||
|
||||
class BomService:
|
||||
# ====================== 新版 BOM 逻辑(基于 bom_no) ======================
|
||||
@ -121,8 +209,25 @@ class BomService:
|
||||
@staticmethod
|
||||
def get_bom_detail(bom_no, version=None):
|
||||
"""
|
||||
根据 bom_no (和 version) 获取配方详情
|
||||
根据 bom_no (和 version) 获取配方详情。
|
||||
|
||||
Cache-Aside 模式(三步走):
|
||||
1. 先查 Redis,有值直接返回(Cache Hit)
|
||||
2. 无值或 Redis 报错,查数据库(Cache Miss → Fallback)
|
||||
3. 数据库查好后写入 Redis,TTL=12h,供下次命中
|
||||
|
||||
注意:查询"最新版"时(version=None),缓存键不带版本后缀;
|
||||
写入时也写入不带版本的键,这样无需指定 version 就能命中。
|
||||
"""
|
||||
# ===== 第一步:尝试从 Redis 读取缓存 =====
|
||||
cached = _cache_get(bom_no, version if version else None)
|
||||
if cached is not None:
|
||||
# Cache Hit:直接返回缓存数据,不再查库
|
||||
logger.debug(f"[BOM] get_bom_detail bom_no={bom_no} version={version} → 命中缓存")
|
||||
return cached
|
||||
|
||||
# ===== 第二步:Cache Miss → 查数据库 =====
|
||||
logger.debug(f"[BOM] get_bom_detail bom_no={bom_no} version={version} → 查询数据库")
|
||||
query = db.session.query(
|
||||
BomTable,
|
||||
MaterialBase.name.label('child_name'),
|
||||
@ -141,6 +246,8 @@ class BomService:
|
||||
if not latest_ver:
|
||||
return None
|
||||
query = query.filter(BomTable.version == latest_ver)
|
||||
# 记录本次实际查的版本,用于缓存键
|
||||
version = latest_ver
|
||||
|
||||
rows = query.all()
|
||||
if not rows:
|
||||
@ -160,7 +267,7 @@ class BomService:
|
||||
'remark': bom.remark or ''
|
||||
})
|
||||
|
||||
return {
|
||||
result = {
|
||||
'bom_no': bom_no,
|
||||
'version': first.BomTable.version,
|
||||
'parent_id': parent_id,
|
||||
@ -170,6 +277,11 @@ class BomService:
|
||||
'children': children
|
||||
}
|
||||
|
||||
# ===== 第三步:写入 Redis 缓存(TTL=12h),失败只打日志不阻断 =====
|
||||
_cache_set(bom_no, version, result)
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def save_bom(data):
|
||||
"""保存 BOM (支持多版本),新增跨版本内容查重"""
|
||||
@ -239,45 +351,57 @@ class BomService:
|
||||
db.session.add(bom)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# ===== 写入后立刻清除缓存(Cache Invalidation) =====
|
||||
# 确保后续 get_bom_detail 读取到最新数据,而不是 stale cache
|
||||
_cache_delete(bom_no, version)
|
||||
logger.info(f"[BOM Cache] save_bom → 缓存已失效 bom_no={bom_no} version={version}")
|
||||
|
||||
return bom_no
|
||||
|
||||
@staticmethod
|
||||
def get_bom_with_stock_by_bom_no(bom_no):
|
||||
"""
|
||||
根据 bom_no 获取配方详情,并计算:
|
||||
1. 总可用库存
|
||||
2. 最大可生产套数
|
||||
3. ★ 聚合库位信息 (warehouse_locations)
|
||||
根据 bom_no 获取配方详情,并计算(已修复 N+1 性能问题)
|
||||
"""
|
||||
detail = BomService.get_bom_detail(bom_no)
|
||||
if not detail:
|
||||
return None
|
||||
if not detail or not detail.get('children'):
|
||||
return detail
|
||||
|
||||
# 1. 提取所有子件的 ID 列表
|
||||
child_ids = [child['child_id'] for child in detail['children']]
|
||||
|
||||
# 2. 用一条 IN 语句批量查出所有相关子件的库存和库位
|
||||
stock_stats = db.session.query(
|
||||
StockBuy.base_id,
|
||||
func.coalesce(func.sum(StockBuy.available_quantity), 0).label('total_qty'),
|
||||
func.string_agg(distinct(StockBuy.warehouse_location), ', ').label('locations')
|
||||
).filter(
|
||||
StockBuy.base_id.in_(child_ids),
|
||||
StockBuy.available_quantity > 0
|
||||
).group_by(
|
||||
StockBuy.base_id
|
||||
).all()
|
||||
|
||||
# 3. 将查询结果转换为字典 (Map),方便后续 O(1) 极速匹配
|
||||
stock_map = {
|
||||
stat.base_id: {
|
||||
'qty': stat.total_qty,
|
||||
'loc': stat.locations if stat.locations else ''
|
||||
}
|
||||
for stat in stock_stats
|
||||
}
|
||||
|
||||
# 4. 遍历组装数据(纯内存操作,极快)
|
||||
for child in detail['children']:
|
||||
# 1. 查询该子件的总库存
|
||||
stock_qty = db.session.query(
|
||||
func.coalesce(func.sum(StockBuy.available_quantity), 0)
|
||||
).filter(
|
||||
StockBuy.base_id == child['child_id']
|
||||
).scalar() or 0
|
||||
base_id = child['child_id']
|
||||
stat = stock_map.get(base_id, {'qty': 0, 'loc': ''})
|
||||
|
||||
# 2. ★ 查询该子件涉及的所有库位,并去重拼接 (PostgreSQL 使用 string_agg)
|
||||
# 注意:这里假设主要是 stock_buy 表,如果是成品或半成品也需要做类似 Union 查询
|
||||
# 为简化,这里演示只查 stock_buy 的库位
|
||||
locations = db.session.query(
|
||||
# 去除空值和重复值
|
||||
func.string_agg(distinct(StockBuy.warehouse_location), ', ')
|
||||
).filter(
|
||||
StockBuy.base_id == child['child_id'],
|
||||
StockBuy.available_quantity > 0, # 只看有货的库位
|
||||
StockBuy.warehouse_location != None,
|
||||
StockBuy.warehouse_location != ''
|
||||
).scalar()
|
||||
stock_qty = float(stat['qty'])
|
||||
dosage = float(child['dosage']) if child.get('dosage') else 0
|
||||
|
||||
child['current_stock'] = float(stock_qty)
|
||||
child['warehouse_location'] = locations or '' # 返回给前端
|
||||
|
||||
dosage = child['dosage']
|
||||
child['current_stock'] = stock_qty
|
||||
child['warehouse_location'] = stat['loc']
|
||||
child['max_producible'] = int(stock_qty // dosage) if dosage > 0 else 0
|
||||
|
||||
return detail
|
||||
@ -306,6 +430,11 @@ class BomService:
|
||||
)
|
||||
db.session.add(bom)
|
||||
db.session.commit()
|
||||
|
||||
# ===== 写入后立刻清除缓存(Cache Invalidation) =====
|
||||
_cache_delete(bom_no, version)
|
||||
logger.info(f"[BOM Cache] create_or_update_bom → 缓存已失效 bom_no={bom_no} version={version}")
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
|
||||
284
inventory-backend/app/services/dify_permission_service.py
Normal file
284
inventory-backend/app/services/dify_permission_service.py
Normal file
@ -0,0 +1,284 @@
|
||||
# app/services/dify_permission_service.py
|
||||
"""
|
||||
Dify 智能客服权限服务层
|
||||
|
||||
职责:
|
||||
1. 从 JWT Token / g.dify_user_role 解析用户角色
|
||||
2. 根据角色查询 sys_role_permission 表,获取用户拥有的 target_code
|
||||
3. AI 专属的动态脱敏策略:
|
||||
- SUPER_ADMIN:无条件放行所有数据
|
||||
- SALES / INBOUND:剔除 price, cost, supplier 等敏感字段
|
||||
- 跨模块越权查询:直接阻断,返回角色专属的错误信息给大模型
|
||||
"""
|
||||
|
||||
from flask import g, current_app
|
||||
from flask_jwt_extended import decode_token
|
||||
from app.models.system import SysRolePermission
|
||||
from app.services.auth_service import AuthService
|
||||
from app.utils.constants import UserRole
|
||||
from sqlalchemy import func
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 角色敏感字段定义(按角色黑名单)
|
||||
# ==============================================================================
|
||||
# 每个角色在 AI 对话场景下,禁止查看的字段集合
|
||||
ROLE_SENSITIVE_FIELDS = {
|
||||
# 入库员:禁止查看采购相关金额
|
||||
'INBOUND': frozenset([
|
||||
'purchase_price',
|
||||
'cost',
|
||||
'price',
|
||||
'supplier',
|
||||
'supplier_id',
|
||||
'unit_price',
|
||||
'total_price',
|
||||
'order_price',
|
||||
'last_purchase_price',
|
||||
'avg_purchase_price',
|
||||
'supplier_name',
|
||||
]),
|
||||
# 销售员:禁止查看成本/采购相关数据
|
||||
'SALES': frozenset([
|
||||
'purchase_price',
|
||||
'cost',
|
||||
'unit_cost',
|
||||
'supplier',
|
||||
'supplier_id',
|
||||
'unit_price',
|
||||
'last_purchase_price',
|
||||
'avg_purchase_price',
|
||||
'supplier_name',
|
||||
'stock_cost',
|
||||
'total_cost',
|
||||
'supply_price',
|
||||
]),
|
||||
# 采购员:禁止查看销售毛利相关数据
|
||||
'PURCHASER': frozenset([
|
||||
'sale_price',
|
||||
'retail_price',
|
||||
'suggested_price',
|
||||
'margin',
|
||||
'profit',
|
||||
'profit_rate',
|
||||
]),
|
||||
}
|
||||
|
||||
# 跨模块越权查询拦截配置
|
||||
# key: 角色, value: { 尝试查询的字段: 给 AI 的回复 }
|
||||
ROLE_FORBIDDEN_QUERIES = {
|
||||
'INBOUND': {
|
||||
'purchase_price': '抱歉,您当前的角色(入库员)无权查看采购金额数据。',
|
||||
'cost': '抱歉,您当前的角色(入库员)无权查看采购成本数据。',
|
||||
'supplier': '抱歉,您当前的角色(入库员)无权查看供应商数据。',
|
||||
'unit_price': '抱歉,您当前的角色(入库员)无权查看采购单价数据。',
|
||||
'last_purchase_price': '抱歉,您当前的角色(入库员)无权查看历史采购价数据。',
|
||||
},
|
||||
'SALES': {
|
||||
'purchase_price': '抱歉,您当前的角色(销售员)无权查看采购成本数据。',
|
||||
'cost': '抱歉,您当前的角色(销售员)无权查看成本数据。',
|
||||
'supplier': '抱歉,您当前的角色(销售员)无权查看供应商信息。',
|
||||
'unit_cost': '抱歉,您当前的角色(销售员)无权查看单位成本数据。',
|
||||
'last_purchase_price': '抱歉,您当前的角色(销售员)无权查看历史采购价数据。',
|
||||
},
|
||||
'PURCHASER': {
|
||||
'sale_price': '抱歉,您当前的角色(采购员)无权查看销售价格数据。',
|
||||
'retail_price': '抱歉,您当前的角色(采购员)无权查看零售价数据。',
|
||||
'margin': '抱歉,您当前的角色(采购员)无权查看毛利数据。',
|
||||
'profit': '抱歉,您当前的角色(采购员)无权查看利润数据。',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class DifyPermissionService:
|
||||
"""Dify AI 专属权限服务"""
|
||||
|
||||
@staticmethod
|
||||
def get_user_role(token: str = None) -> str:
|
||||
"""
|
||||
从 JWT Token 或 g.dify_user_role 解析用户角色
|
||||
返回标准化的大写角色码(如 'INBOUND', 'SALES', 'SUPER_ADMIN')
|
||||
"""
|
||||
user_role = None
|
||||
|
||||
# 优先从 g 对象获取(由 dify_auth_required 存入)
|
||||
if hasattr(g, 'dify_user_role') and g.dify_user_role:
|
||||
return g.dify_user_role
|
||||
|
||||
# 从传入的 token 解码
|
||||
if token:
|
||||
try:
|
||||
claims = decode_token(token)
|
||||
user_role = claims.get('role')
|
||||
except Exception as e:
|
||||
current_app.logger.warning(f"[DifyPermission] token 解码失败: {e}")
|
||||
return ''
|
||||
|
||||
return (user_role or '').upper()
|
||||
|
||||
@staticmethod
|
||||
def is_super_admin(role: str = None) -> bool:
|
||||
"""判断是否为超级管理员"""
|
||||
if not role:
|
||||
role = DifyPermissionService.get_user_role()
|
||||
return role.upper() == UserRole.SUPER_ADMIN if role else False
|
||||
|
||||
@staticmethod
|
||||
def get_role_permissions(role: str = None) -> dict:
|
||||
"""
|
||||
获取指定角色的所有权限代码列表
|
||||
内部调用 AuthService.get_user_permissions()
|
||||
"""
|
||||
if not role:
|
||||
role = DifyPermissionService.get_user_role()
|
||||
return AuthService.get_user_permissions(role)
|
||||
|
||||
@staticmethod
|
||||
def get_target_codes(role: str = None) -> list:
|
||||
"""
|
||||
获取用户角色在 sys_role_permission 表中的所有 target_code
|
||||
包括 menu 和 element 两种类型
|
||||
"""
|
||||
if not role:
|
||||
role = DifyPermissionService.get_user_role()
|
||||
|
||||
if not role:
|
||||
return []
|
||||
|
||||
# 超级管理员拥有所有权限
|
||||
if DifyPermissionService.is_super_admin(role):
|
||||
return ['*']
|
||||
|
||||
# 从数据库查询
|
||||
try:
|
||||
perms = SysRolePermission.query.filter(
|
||||
func.upper(SysRolePermission.role_code) == role.upper()
|
||||
).all()
|
||||
return [p.target_code for p in perms]
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"[DifyPermission] 查询 target_code 失败: {e}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def has_permission(permission_code: str, role: str = None) -> bool:
|
||||
"""
|
||||
检查用户是否拥有指定权限码
|
||||
超级管理员永远返回 True
|
||||
"""
|
||||
if DifyPermissionService.is_super_admin(role):
|
||||
return True
|
||||
if not role:
|
||||
role = DifyPermissionService.get_user_role()
|
||||
perms = DifyPermissionService.get_role_permissions(role)
|
||||
all_perms = perms.get('menus', []) + perms.get('elements', [])
|
||||
return permission_code in all_perms
|
||||
|
||||
@staticmethod
|
||||
def check_forbidden_query(query_fields: list, role: str = None) -> dict:
|
||||
"""
|
||||
检查用户尝试查询的字段是否越权。
|
||||
|
||||
参数:
|
||||
query_fields: 用户查询涉及的字段名列表(可能包含敏感字段)
|
||||
role: 用户角色(可选,默认从 token/g 解析)
|
||||
|
||||
返回:
|
||||
{
|
||||
'blocked': bool, # 是否被拦截
|
||||
'message': str | None, # AI 应返回给用户的错误信息(如果有)
|
||||
}
|
||||
"""
|
||||
if DifyPermissionService.is_super_admin(role):
|
||||
return {'blocked': False, 'message': None}
|
||||
|
||||
if not role:
|
||||
role = DifyPermissionService.get_user_role()
|
||||
|
||||
if not role:
|
||||
return {
|
||||
'blocked': True,
|
||||
'message': '无法识别您的身份,请重新登录后再试。'
|
||||
}
|
||||
|
||||
# 获取该角色的越权拦截配置
|
||||
forbidden_map = ROLE_FORBIDDEN_QUERIES.get(role.upper(), {})
|
||||
if not forbidden_map:
|
||||
return {'blocked': False, 'message': None}
|
||||
|
||||
# 规范化字段名(小写比较)
|
||||
query_fields_lower = {f.lower() for f in query_fields}
|
||||
|
||||
# 检查是否命中越权字段
|
||||
for forbidden_field, msg in forbidden_map.items():
|
||||
if forbidden_field.lower() in query_fields_lower:
|
||||
current_app.logger.warning(
|
||||
f"[DifyPermission] 越权查询拦截: role={role}, field={forbidden_field}"
|
||||
)
|
||||
return {'blocked': True, 'message': msg}
|
||||
|
||||
return {'blocked': False, 'message': None}
|
||||
|
||||
@staticmethod
|
||||
def filter_sensitive_fields(data: dict, role: str = None) -> dict:
|
||||
"""
|
||||
根据用户角色对字典数据进行敏感字段脱敏。
|
||||
|
||||
- SUPER_ADMIN:不过滤,返回原数据
|
||||
- SALES / INBOUND / PURCHASER:按角色黑名单剔除敏感字段
|
||||
|
||||
参数:
|
||||
data: 原始数据字典
|
||||
role: 用户角色
|
||||
|
||||
返回:
|
||||
脱敏后的数据字典(敏感字段被置为 None)
|
||||
"""
|
||||
if DifyPermissionService.is_super_admin(role):
|
||||
return data
|
||||
|
||||
if not role:
|
||||
role = DifyPermissionService.get_user_role()
|
||||
|
||||
if not role:
|
||||
return data
|
||||
|
||||
# 获取该角色的敏感字段黑名单
|
||||
sensitive = ROLE_SENSITIVE_FIELDS.get(role.upper(), frozenset())
|
||||
|
||||
# 如果没有敏感字段定义,不过滤
|
||||
if not sensitive:
|
||||
return data
|
||||
|
||||
# 深拷贝,避免修改原数据
|
||||
import copy
|
||||
result = copy.deepcopy(data)
|
||||
|
||||
# 将敏感字段置为 None
|
||||
for field in sensitive:
|
||||
if field in result:
|
||||
result[field] = None
|
||||
|
||||
# 如果有 children 数组(子件列表),递归脱敏
|
||||
if isinstance(result.get('children'), list):
|
||||
result['children'] = [
|
||||
DifyPermissionService.filter_sensitive_fields(child, role)
|
||||
for child in result['children']
|
||||
]
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def filter_sensitive_fields_in_list(data_list: list, role: str = None) -> list:
|
||||
"""
|
||||
对列表数据批量应用敏感字段脱敏
|
||||
"""
|
||||
if DifyPermissionService.is_super_admin(role):
|
||||
return data_list
|
||||
|
||||
if not role:
|
||||
role = DifyPermissionService.get_user_role()
|
||||
|
||||
return [
|
||||
DifyPermissionService.filter_sensitive_fields(item, role)
|
||||
for item in data_list
|
||||
]
|
||||
358
inventory-backend/app/services/export_service/excel_task.py
Normal file
358
inventory-backend/app/services/export_service/excel_task.py
Normal file
@ -0,0 +1,358 @@
|
||||
"""
|
||||
app/services/export_service/excel_task.py
|
||||
异步导出核心任务逻辑
|
||||
|
||||
Redis 中的任务状态键格式:export:task:{task_id}
|
||||
TTL = 1 小时(3600 秒),超时自动清理
|
||||
|
||||
状态流转:
|
||||
提交任务 → status=processing, progress=0
|
||||
写入中 → status=processing, progress=N (10~90)
|
||||
完成 → status=completed, progress=100, url=下载路径
|
||||
失败 → status=failed, error=具体原因
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
from threading import Thread
|
||||
from datetime import datetime
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 导出文件存放根目录(相对于项目根目录)
|
||||
EXPORT_DIR = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
'uploads', 'exports'
|
||||
)
|
||||
|
||||
# Redis 键前缀 + TTL
|
||||
TASK_KEY_PREFIX = 'export:task:'
|
||||
TASK_TTL = 3600 # 1小时
|
||||
|
||||
|
||||
def _redis():
|
||||
"""获取 Redis 客户端,带容错保护。"""
|
||||
try:
|
||||
from app.extensions import redis_client
|
||||
return redis_client
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _update_task(task_id: str, **kwargs):
|
||||
"""
|
||||
原子更新 Redis 中的任务状态。
|
||||
|
||||
使用 setex 分两步:
|
||||
1. 保存最新状态 JSON
|
||||
2. 重置 TTL 为 1 小时
|
||||
|
||||
即使 Redis 写入失败也不阻断业务流程。
|
||||
"""
|
||||
client = _redis()
|
||||
if not client:
|
||||
return
|
||||
key = f"{TASK_KEY_PREFIX}{task_id}"
|
||||
try:
|
||||
client.setex(key, TASK_TTL, json.dumps(kwargs, ensure_ascii=False))
|
||||
logger.debug(f"[Export] 更新任务状态 task_id={task_id} → {kwargs}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[Export] Redis 更新任务状态失败: task_id={task_id}, err={e}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 对外入口:提交导出任务(启动后台线程)
|
||||
# =============================================================================
|
||||
|
||||
def submit_export_task(filters: dict) -> str:
|
||||
"""
|
||||
接收前端过滤参数,生成 task_id,写入 Redis 初始状态,
|
||||
然后启动后台线程执行 Excel 生成。
|
||||
|
||||
参数:
|
||||
filters: dict,任意查询参数(category, keyword, status 等)
|
||||
|
||||
返回:
|
||||
str: task_id(UUID),前端用此 ID 轮询进度
|
||||
"""
|
||||
task_id = str(uuid.uuid4())
|
||||
|
||||
# 写入 Redis:初始状态
|
||||
_update_task(task_id, status='processing', progress=0, url='', error='')
|
||||
|
||||
# 立即启动后台线程执行(daemon=True 使主进程退出时自动终止)
|
||||
t = Thread(
|
||||
target=generate_excel_task,
|
||||
args=(task_id, filters),
|
||||
daemon=True
|
||||
)
|
||||
t.start()
|
||||
logger.info(f"[Export] 任务已提交 task_id={task_id}, filters={filters}")
|
||||
|
||||
return task_id
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 后台任务:生成 Excel 文件
|
||||
# =============================================================================
|
||||
|
||||
def generate_excel_task(task_id: str, filters: dict):
|
||||
"""
|
||||
在后台线程中执行 Excel 生成。
|
||||
|
||||
流程:
|
||||
1. 更新进度 10% → 开始查询
|
||||
2. 根据 filters 查询数据库(可复用现有 Service)
|
||||
3. 用 openpyxl 构建 Workbook,写入数据
|
||||
4. 保存到 uploads/exports/{task_id}.xlsx
|
||||
5. 更新进度 100% + status=completed + url
|
||||
|
||||
任何异常被捕获,不会导致主进程崩溃。
|
||||
"""
|
||||
logger.info(f"[Export] 任务开始 task_id={task_id}")
|
||||
try:
|
||||
# ===== 阶段1:查询数据(模拟 + 实际) =====
|
||||
_update_task(task_id, status='processing', progress=10)
|
||||
|
||||
# 延迟导入:在子线程中加载 App Context,避免主线程时序问题
|
||||
from flask import current_app
|
||||
from app.extensions import db
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from app.models.inbound.semi import StockSemi
|
||||
from app.models.inbound.product import StockProduct
|
||||
|
||||
records = _query_inventory(filters)
|
||||
|
||||
# ===== 阶段2:写入 Excel =====
|
||||
_update_task(task_id, status='processing', progress=40)
|
||||
|
||||
filename = f"{task_id}.xlsx"
|
||||
filepath = os.path.join(EXPORT_DIR, filename)
|
||||
_write_excel(filepath, records, task_id)
|
||||
|
||||
# ===== 阶段3:完成 =====
|
||||
_update_task(
|
||||
task_id,
|
||||
status='completed',
|
||||
progress=100,
|
||||
url=f"/api/v1/export/download/{task_id}",
|
||||
error=''
|
||||
)
|
||||
logger.info(f"[Export] 任务完成 task_id={task_id}, file={filename}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Export] 任务失败 task_id={task_id}, err={e}")
|
||||
_update_task(task_id, status='failed', error=str(e))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 查询层:根据 filters 聚合库存数据
|
||||
# =============================================================================
|
||||
|
||||
def _query_inventory(filters: dict) -> list:
|
||||
"""
|
||||
根据过滤条件查询三张库存表,返回标准化记录列表。
|
||||
进度更新策略:在主线程(后台线程)内,每处理 1000 条回调一次 Redis。
|
||||
"""
|
||||
from app.extensions import db
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from app.models.inbound.semi import StockSemi
|
||||
from app.models.inbound.product import StockProduct
|
||||
from app.models.base import MaterialBase
|
||||
|
||||
results = []
|
||||
|
||||
# ---------- 采购件 ----------
|
||||
query = db.session.query(
|
||||
MaterialBase.name.label('material_name'),
|
||||
MaterialBase.spec_model.label('spec_model'),
|
||||
StockBuy.barcode,
|
||||
StockBuy.sku,
|
||||
StockBuy.status,
|
||||
StockBuy.warehouse_location,
|
||||
StockBuy.available_quantity,
|
||||
StockBuy.supplier_name,
|
||||
StockBuy.in_date,
|
||||
).join(MaterialBase, StockBuy.base_id == MaterialBase.id)
|
||||
|
||||
if filters.get('keyword'):
|
||||
kw = f"%{filters['keyword']}%"
|
||||
query = query.filter(
|
||||
(MaterialBase.name.ilike(kw)) |
|
||||
(MaterialBase.spec_model.ilike(kw)) |
|
||||
(StockBuy.barcode.ilike(kw)) |
|
||||
(StockBuy.sku.ilike(kw))
|
||||
)
|
||||
if filters.get('status'):
|
||||
query = query.filter(StockBuy.status == filters['status'])
|
||||
|
||||
all_rows = query.order_by(StockBuy.id.desc()).limit(10000).all()
|
||||
total = len(all_rows)
|
||||
|
||||
for idx, row in enumerate(all_rows):
|
||||
results.append({
|
||||
'type': '采购件',
|
||||
'material_name': row.material_name or '',
|
||||
'spec_model': row.spec_model or '',
|
||||
'barcode': row.barcode or '',
|
||||
'sku': row.sku or '',
|
||||
'status': row.status or '',
|
||||
'warehouse_location': row.warehouse_location or '',
|
||||
'available_quantity': float(row.available_quantity or 0),
|
||||
'supplier_name': row.supplier_name or '',
|
||||
'in_date': row.in_date.strftime('%Y-%m-%d') if row.in_date else '',
|
||||
})
|
||||
|
||||
# 每 1000 条更新一次 Redis 进度(40%~80% 之间)
|
||||
if idx > 0 and idx % 1000 == 0:
|
||||
pct = 40 + int(40 * idx / total) if total else 80
|
||||
# 注意:这里的 task_id 由外层 generate_excel_task 持有,
|
||||
# 进度更新在 _write_excel 中进行,此处仅做占位说明
|
||||
logger.debug(f"[Export] 采购件已处理 {idx}/{total} 条, 估算进度 {pct}%")
|
||||
|
||||
# ---------- 半成品 + 成品(可同理扩展) ----------
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Excel 写入层:使用 openpyxl 构建 .xlsx 文件
|
||||
# =============================================================================
|
||||
|
||||
def _write_excel(filepath: str, records: list, task_id: str):
|
||||
"""
|
||||
使用 openpyxl 将记录列表写入 Excel 文件。
|
||||
包含表头样式(加粗、背景色)、自适应列宽、边框。
|
||||
|
||||
参数:
|
||||
filepath: 完整保存路径(含 .xlsx 后缀)
|
||||
records: 标准化后的数据列表(dict)
|
||||
task_id: 用于增量进度更新
|
||||
"""
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "库存导出"
|
||||
|
||||
if not records:
|
||||
ws.append(['暂无数据'])
|
||||
wb.save(filepath)
|
||||
return
|
||||
|
||||
# ---------- 表头 ----------
|
||||
headers = [
|
||||
'类型', '物料名称', '规格型号', '条码', 'SKU',
|
||||
'状态', '库位', '可用数量', '供应商', '入库日期'
|
||||
]
|
||||
ws.append(headers)
|
||||
|
||||
# 表头样式:深蓝色背景 + 白色加粗字体
|
||||
header_fill = PatternFill("solid", fgColor="1F4E79")
|
||||
header_font = Font(bold=True, color="FFFFFF", size=11)
|
||||
header_align = Alignment(horizontal='center', vertical='center', wrap_text=True)
|
||||
thin = Side(style='thin', color='BFBFBF')
|
||||
border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||
|
||||
for col_idx, _ in enumerate(headers, start=1):
|
||||
cell = ws.cell(row=1, column=col_idx)
|
||||
cell.fill = header_fill
|
||||
cell.font = header_font
|
||||
cell.alignment = header_align
|
||||
cell.border = border
|
||||
|
||||
# ---------- 数据行 ----------
|
||||
even_fill = PatternFill("solid", fgColor="DEEAF1") # 浅蓝隔行底色
|
||||
data_align = Alignment(horizontal='left', vertical='center')
|
||||
data_font = Font(size=10)
|
||||
|
||||
total = len(records)
|
||||
for idx, rec in enumerate(records):
|
||||
ws.append([
|
||||
rec.get('type', ''),
|
||||
rec.get('material_name', ''),
|
||||
rec.get('spec_model', ''),
|
||||
rec.get('barcode', ''),
|
||||
rec.get('sku', ''),
|
||||
rec.get('status', ''),
|
||||
rec.get('warehouse_location', ''),
|
||||
rec.get('available_quantity', 0),
|
||||
rec.get('supplier_name', ''),
|
||||
rec.get('in_date', ''),
|
||||
])
|
||||
|
||||
# 每 1000 行更新一次 Redis 进度(80%~95%)
|
||||
row_num = idx + 2
|
||||
for col_idx in range(1, len(headers) + 1):
|
||||
cell = ws.cell(row=row_num, column=col_idx)
|
||||
cell.alignment = data_align
|
||||
cell.font = data_font
|
||||
cell.border = border
|
||||
if idx % 2 == 1:
|
||||
cell.fill = even_fill
|
||||
|
||||
if idx > 0 and idx % 1000 == 0:
|
||||
pct = 80 + int(15 * idx / total)
|
||||
_update_task(task_id, status='processing', progress=min(pct, 95))
|
||||
|
||||
# ---------- 自适应列宽 ----------
|
||||
for col in ws.columns:
|
||||
max_len = 0
|
||||
col_letter = col[0].column_letter
|
||||
for cell in col:
|
||||
if cell.value:
|
||||
max_len = max(max_len, len(str(cell.value)))
|
||||
ws.column_dimensions[col_letter].width = min(max_len + 4, 40)
|
||||
|
||||
# ---------- 冻结首行 ----------
|
||||
ws.freeze_panes = 'A2'
|
||||
|
||||
# ---------- 保存 ----------
|
||||
wb.save(filepath)
|
||||
logger.info(f"[Export] Excel 已保存: {filepath}, 共 {total} 行")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 查询任务状态(供 API 层调用)
|
||||
# =============================================================================
|
||||
|
||||
def get_task_status(task_id: str) -> dict:
|
||||
"""
|
||||
从 Redis 读取任务状态字典。
|
||||
若任务不存在或 Redis 不可用,返回默认 pending 状态。
|
||||
"""
|
||||
client = _redis()
|
||||
if not client:
|
||||
return {'status': 'unknown', 'progress': 0, 'url': '', 'error': ''}
|
||||
|
||||
key = f"{TASK_KEY_PREFIX}{task_id}"
|
||||
try:
|
||||
raw = client.get(key)
|
||||
if raw:
|
||||
return json.loads(raw)
|
||||
return {'status': 'not_found', 'progress': 0, 'url': '', 'error': ''}
|
||||
except Exception as e:
|
||||
logger.warning(f"[Export] 读取任务状态失败: task_id={task_id}, err={e}")
|
||||
return {'status': 'unknown', 'progress': 0, 'url': '', 'error': ''}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 获取导出文件路径(供下载接口调用)
|
||||
# =============================================================================
|
||||
|
||||
def get_export_filepath(task_id: str) -> str | None:
|
||||
"""
|
||||
根据 task_id 返回已生成文件的完整路径。
|
||||
未完成或不存在返回 None。
|
||||
"""
|
||||
filename = f"{task_id}.xlsx"
|
||||
filepath = os.path.join(EXPORT_DIR, filename)
|
||||
if os.path.exists(filepath):
|
||||
return filepath
|
||||
return None
|
||||
@ -593,6 +593,31 @@ class MaterialBaseService:
|
||||
material.is_enabled = str(raw_enabled).lower() in ['1', 'true', 'yes', 't']
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# ★★★ 级联缓存失效:物料信息变更后,清除所有涉及该物料的 BOM 树缓存
|
||||
try:
|
||||
from app.models.bom import BomTable
|
||||
from app.extensions import redis_client
|
||||
|
||||
# 查出所有以该物料为父件或子件的 bom_no(去重)
|
||||
affected = db.session.query(BomTable.bom_no).filter(
|
||||
or_(BomTable.parent_id == m_id, BomTable.child_id == m_id)
|
||||
).distinct().all()
|
||||
affected_bom_nos = [r[0] for r in affected]
|
||||
|
||||
if affected_bom_nos:
|
||||
for bom_no in affected_bom_nos:
|
||||
# 清除最新版缓存
|
||||
redis_client.delete(f'bom:tree:{bom_no}')
|
||||
# 清除所有版本缓存(通配符)
|
||||
for key in redis_client.scan_iter(f'bom:tree:{bom_no}:*'):
|
||||
redis_client.delete(key)
|
||||
print(f"🔁 物料 {m_id} 变更,已级联清除 {len(affected_bom_nos)} 个 BOM 缓存: {affected_bom_nos}")
|
||||
|
||||
except Exception as cache_err:
|
||||
# Redis 报错不阻断业务返回
|
||||
print(f"⚠️ BOM 缓存级联清除失败(不阻断业务): {cache_err}")
|
||||
|
||||
return material
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import uuid # .material -> .base refactor checked
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from sqlalchemy import or_, func, desc, and_
|
||||
from sqlalchemy.orm import joinedload
|
||||
from app.extensions import db
|
||||
from app.models.outbound import TransOutbound, OutboundApproval
|
||||
|
||||
@ -475,6 +476,37 @@ class OutboundService:
|
||||
'stock_product': StockProduct
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# ★ 优化步骤 1:第一遍循环,单纯收集所有的 stock_id
|
||||
# ==========================================
|
||||
stock_ids_by_table = {'stock_buy': set(), 'stock_semi': set(), 'stock_product': set()}
|
||||
|
||||
for d in details:
|
||||
if d.source_table in stock_ids_by_table and d.stock_id:
|
||||
stock_ids_by_table[d.source_table].add(d.stock_id)
|
||||
|
||||
# ==========================================
|
||||
# ★ 优化步骤 2:发起批量查询,并强制 JOIN 基础物料表
|
||||
# ==========================================
|
||||
# 格式: { ('stock_buy', 101): stock_obj, ... }
|
||||
preloaded_stocks = {}
|
||||
|
||||
for table_name, ids in stock_ids_by_table.items():
|
||||
if not ids:
|
||||
continue
|
||||
|
||||
ModelClass = model_map[table_name]
|
||||
# 魔法在这里:in_() 一次性查出所有库存,joinedload 顺便把 base 表的数据一起拉回来
|
||||
items = ModelClass.query.options(
|
||||
joinedload(ModelClass.base)
|
||||
).filter(ModelClass.id.in_(ids)).all()
|
||||
|
||||
for item in items:
|
||||
preloaded_stocks[(table_name, item.id)] = item
|
||||
|
||||
# ==========================================
|
||||
# ★ 优化步骤 3:第二遍循环,纯内存拼装(极速)
|
||||
# ==========================================
|
||||
for d in details:
|
||||
ono = d.outbound_no
|
||||
if ono not in grouped_map:
|
||||
@ -490,34 +522,20 @@ class OutboundService:
|
||||
'items': []
|
||||
}
|
||||
|
||||
# --- 查询物品详细信息 (名称, 规格, 类型, 类别, 批号/SN) ---
|
||||
item_name = "未知物品"
|
||||
item_spec = ""
|
||||
item_cat = ""
|
||||
item_type = ""
|
||||
batch_sn = "-"
|
||||
# --- 直接从内存字典中获取,O(1) 复杂度,绝对不触发 SQL ---
|
||||
item_name, item_spec, item_cat, item_type, batch_sn = "未知物品", "", "", "", "-"
|
||||
|
||||
ModelClass = model_map.get(d.source_table)
|
||||
if ModelClass and d.stock_id:
|
||||
try:
|
||||
stock_item = ModelClass.query.get(d.stock_id)
|
||||
if stock_item:
|
||||
# 获取批号/序列号用于追溯
|
||||
batch_sn = getattr(stock_item, 'batch_number', None) or getattr(stock_item, 'serial_number', None) or '-'
|
||||
if stock_item.base:
|
||||
item_name = stock_item.base.name
|
||||
item_spec = stock_item.base.spec_model
|
||||
item_cat = stock_item.base.category
|
||||
item_type = stock_item.base.material_type
|
||||
elif stock_item and hasattr(stock_item, 'base_id') and stock_item.base_id:
|
||||
base_info = MaterialBase.query.get(stock_item.base_id)
|
||||
if base_info:
|
||||
item_name = base_info.name
|
||||
item_spec = base_info.spec_model
|
||||
item_cat = base_info.category
|
||||
item_type = base_info.material_type
|
||||
except Exception as e:
|
||||
print(f"Error fetching detail for stock_id {d.stock_id}: {e}")
|
||||
stock_item = preloaded_stocks.get((d.source_table, d.stock_id))
|
||||
|
||||
if stock_item:
|
||||
batch_sn = getattr(stock_item, 'batch_number', None) or getattr(stock_item, 'serial_number', None) or '-'
|
||||
|
||||
# 因为前面用了 joinedload,这里调用 .base 瞬间返回,不会去查数据库
|
||||
if stock_item.base:
|
||||
item_name = stock_item.base.name
|
||||
item_spec = stock_item.base.spec_model
|
||||
item_cat = stock_item.base.category
|
||||
item_type = stock_item.base.material_type
|
||||
|
||||
# 计算金额
|
||||
price = float(d.unit_price) if d.unit_price else 0
|
||||
|
||||
@ -176,7 +176,28 @@ class PermissionService:
|
||||
db.session.flush() # 获取新插入的 ID
|
||||
print(f"✅ 审计日志菜单已创建 (code: {menu_code})")
|
||||
else:
|
||||
print(f"ℹ️ 审计日志菜单已存在 (code: {menu_code})")
|
||||
# ★★★ Dirty Check:仅当字段真正变化时才 add,避免触发 UPDATE 事件
|
||||
is_dirty = False
|
||||
if existing_menu.parent_id != 0:
|
||||
existing_menu.parent_id = 0
|
||||
is_dirty = True
|
||||
if existing_menu.name != '审计日志':
|
||||
existing_menu.name = '审计日志'
|
||||
is_dirty = True
|
||||
if existing_menu.path != '/system/audit':
|
||||
existing_menu.path = '/system/audit'
|
||||
is_dirty = True
|
||||
if existing_menu.sort_order != 110:
|
||||
existing_menu.sort_order = 110
|
||||
is_dirty = True
|
||||
if existing_menu.is_visible != True:
|
||||
existing_menu.is_visible = True
|
||||
is_dirty = True
|
||||
if is_dirty:
|
||||
db.session.add(existing_menu)
|
||||
print(f"🔄 审计日志菜单已更新 (code: {menu_code})")
|
||||
else:
|
||||
print(f"ℹ️ 审计日志菜单已存在,无需更新 (code: {menu_code})")
|
||||
|
||||
# 2. 为超级管理员赋予审计日志菜单权限
|
||||
role_code = 'SUPER_ADMIN'
|
||||
@ -230,7 +251,14 @@ class PermissionService:
|
||||
db.session.flush()
|
||||
print(f"✅ 盘点管理顶级菜单已创建")
|
||||
else:
|
||||
print(f"ℹ️ 盘点管理顶级菜单已存在")
|
||||
is_dirty = False
|
||||
if stocktake_menu.parent_id != 0: stocktake_menu.parent_id = 0; is_dirty = True
|
||||
if stocktake_menu.name != '盘点管理': stocktake_menu.name = '盘点管理'; is_dirty = True
|
||||
if stocktake_menu.path != '/stocktake': stocktake_menu.path = '/stocktake'; is_dirty = True
|
||||
if stocktake_menu.sort_order != 30: stocktake_menu.sort_order = 30; is_dirty = True
|
||||
if stocktake_menu.is_visible != True: stocktake_menu.is_visible = True; is_dirty = True
|
||||
if is_dirty: db.session.add(stocktake_menu); print(f"🔄 盘点管理顶级菜单已更新")
|
||||
else: print(f"ℹ️ 盘点管理顶级菜单已存在")
|
||||
|
||||
# 2. 创建子菜单:盲盘作业
|
||||
stocktake_op_code = 'inventory_stocktake'
|
||||
@ -248,7 +276,13 @@ class PermissionService:
|
||||
db.session.flush()
|
||||
print(f"✅ 盲盘作业菜单已创建")
|
||||
else:
|
||||
print(f"ℹ️ 盲盘作业菜单已存在")
|
||||
is_dirty = False
|
||||
if stocktake_op_menu.name != '盲盘作业': stocktake_op_menu.name = '盲盘作业'; is_dirty = True
|
||||
if stocktake_op_menu.path != '/stocktake/operation': stocktake_op_menu.path = '/stocktake/operation'; is_dirty = True
|
||||
if stocktake_op_menu.sort_order != 1: stocktake_op_menu.sort_order = 1; is_dirty = True
|
||||
if stocktake_op_menu.is_visible != True: stocktake_op_menu.is_visible = True; is_dirty = True
|
||||
if is_dirty: db.session.add(stocktake_op_menu); print(f"🔄 盲盘作业菜单已更新")
|
||||
else: print(f"ℹ️ 盲盘作业菜单已存在")
|
||||
|
||||
# 3. 为盲盘作业添加操作权限元素
|
||||
stocktake_op_element = SysElement.query.filter_by(
|
||||
@ -265,7 +299,11 @@ class PermissionService:
|
||||
db.session.add(stocktake_op_element)
|
||||
print(f"✅ 盲盘作业操作权限已创建")
|
||||
else:
|
||||
print(f"ℹ️ 盲盘作业操作权限已存在")
|
||||
is_dirty = False
|
||||
if stocktake_op_element.name != '盲盘操作': stocktake_op_element.name = '盲盘操作'; is_dirty = True
|
||||
if stocktake_op_element.element_type != 'operation': stocktake_op_element.element_type = 'operation'; is_dirty = True
|
||||
if is_dirty: db.session.add(stocktake_op_element); print(f"🔄 盲盘作业操作权限已更新")
|
||||
else: print(f"ℹ️ 盲盘作业操作权限已存在")
|
||||
|
||||
# 4. 创建子菜单:盈亏调整
|
||||
adjustment_code = 'stock_adjustment'
|
||||
@ -283,7 +321,13 @@ class PermissionService:
|
||||
db.session.flush()
|
||||
print(f"✅ 盈亏调整菜单已创建")
|
||||
else:
|
||||
print(f"ℹ️ 盈亏调整菜单已存在")
|
||||
is_dirty = False
|
||||
if adjustment_menu.name != '盈亏调整': adjustment_menu.name = '盈亏调整'; is_dirty = True
|
||||
if adjustment_menu.path != '/stocktake/adjustment': adjustment_menu.path = '/stocktake/adjustment'; is_dirty = True
|
||||
if adjustment_menu.sort_order != 2: adjustment_menu.sort_order = 2; is_dirty = True
|
||||
if adjustment_menu.is_visible != True: adjustment_menu.is_visible = True; is_dirty = True
|
||||
if is_dirty: db.session.add(adjustment_menu); print(f"🔄 盈亏调整菜单已更新")
|
||||
else: print(f"ℹ️ 盈亏调整菜单已存在")
|
||||
|
||||
# 5. 为盈亏调整添加列表权限元素 (stock_adjustment:list)
|
||||
adjustment_list_element = SysElement.query.filter_by(
|
||||
@ -300,7 +344,11 @@ class PermissionService:
|
||||
db.session.add(adjustment_list_element)
|
||||
print(f"✅ 盈亏调整列表权限已创建")
|
||||
else:
|
||||
print(f"ℹ️ 盈亏调整列表权限已存在")
|
||||
is_dirty = False
|
||||
if adjustment_list_element.name != '盈亏列表': adjustment_list_element.name = '盈亏列表'; is_dirty = True
|
||||
if adjustment_list_element.element_type != 'element': adjustment_list_element.element_type = 'element'; is_dirty = True
|
||||
if is_dirty: db.session.add(adjustment_list_element); print(f"🔄 盈亏调整列表权限已更新")
|
||||
else: print(f"ℹ️ 盈亏调整列表权限已存在")
|
||||
|
||||
# 6. 为盈亏调整添加操作权限元素 (stock_adjustment:operation)
|
||||
adjustment_op_element = SysElement.query.filter_by(
|
||||
@ -317,7 +365,11 @@ class PermissionService:
|
||||
db.session.add(adjustment_op_element)
|
||||
print(f"✅ 盈亏调整操作权限已创建")
|
||||
else:
|
||||
print(f"ℹ️ 盈亏调整操作权限已存在")
|
||||
is_dirty = False
|
||||
if adjustment_op_element.name != '盈亏操作': adjustment_op_element.name = '盈亏操作'; is_dirty = True
|
||||
if adjustment_op_element.element_type != 'operation': adjustment_op_element.element_type = 'operation'; is_dirty = True
|
||||
if is_dirty: db.session.add(adjustment_op_element); print(f"🔄 盈亏调整操作权限已更新")
|
||||
else: print(f"ℹ️ 盈亏调整操作权限已存在")
|
||||
|
||||
# 7. 为超级管理员分配所有盘点相关权限
|
||||
menu_codes = [stocktake_mgmt_code, stocktake_op_code, adjustment_code]
|
||||
@ -491,13 +543,19 @@ class PermissionService:
|
||||
db.session.delete(e)
|
||||
db.session.delete(dup)
|
||||
|
||||
# 第三步:强制重新设置所有子菜单的 parent_id,确保没有遗漏
|
||||
# 改为对象级更新以触发审计事件
|
||||
# 第三步:仅当子菜单 parent_id 有误时才更新(Dirty Check)
|
||||
# 遍历所有子菜单,只在 parent_id 需要修正时才触碰对象
|
||||
child_codes = [m[0] for m in menu_defs if m[3] is not None]
|
||||
child_menus = SysMenu.query.filter(SysMenu.code.in_(child_codes)).all()
|
||||
for m in child_menus:
|
||||
m.parent_id = None
|
||||
# 只有 parent_id 为 0 或 None(即没有正确挂载父菜单)时才更新
|
||||
if m.parent_id == 0 or m.parent_id is None:
|
||||
m.parent_id = None # SQLAlchemy 设为 None 表示挂载到根(parent_id=None)
|
||||
db.session.add(m)
|
||||
print(f"🔧 修正子菜单 parent_id: {m.code} (parent_id → None)")
|
||||
|
||||
# 创建或更新菜单
|
||||
# 第四步:创建或更新菜单(带字段级 Dirty Check)
|
||||
# 只有至少有一个字段真正变化了才 add,避免 SQLAlchemy 产生 UPDATE 事件
|
||||
menu_map = {} # code -> menu obj
|
||||
|
||||
for code, name, path, parent_code, sort_order in menu_defs:
|
||||
@ -508,21 +566,38 @@ class PermissionService:
|
||||
db.session.flush()
|
||||
print(f"✅ 菜单已创建: {name} ({code})")
|
||||
else:
|
||||
# 更新已有菜单的属性
|
||||
menu.name = name
|
||||
menu.path = path
|
||||
menu.sort_order = sort_order
|
||||
# ★★★ 字段级 Dirty Check:逐字段比较,仅在值真正变化时赋值
|
||||
is_dirty = False
|
||||
|
||||
if menu.name != name:
|
||||
menu.name = name
|
||||
is_dirty = True
|
||||
if menu.path != path:
|
||||
menu.path = path
|
||||
is_dirty = True
|
||||
if menu.sort_order != sort_order:
|
||||
menu.sort_order = sort_order
|
||||
is_dirty = True
|
||||
|
||||
# 只有至少一个字段变化了才 add(触发 UPDATE)
|
||||
if is_dirty:
|
||||
db.session.add(menu)
|
||||
print(f"🔄 菜单已更新: {name} ({code})")
|
||||
|
||||
menu_map[code] = menu
|
||||
|
||||
# 设置 parent_id
|
||||
# 第五步:设置 parent_id(带 Dirty Check,只在值真正变化时更新)
|
||||
for code, name, path, parent_code, sort_order in menu_defs:
|
||||
if parent_code and parent_code in menu_map:
|
||||
menu = menu_map[code]
|
||||
parent = menu_map[parent_code]
|
||||
menu.parent_id = parent.id
|
||||
# 只有 parent_id 实际变化了才赋值,避免重复触发 UPDATE
|
||||
if menu.parent_id != parent.id:
|
||||
menu.parent_id = parent.id
|
||||
db.session.add(menu)
|
||||
print(f"🔗 菜单 {code} 已挂载到父菜单 {parent_code} (id={parent.id})")
|
||||
|
||||
# 为超级管理员分配所有菜单权限
|
||||
# 第六步:为超级管理员分配顶级菜单权限(只做 INSERT,不触碰已存在的记录)
|
||||
for code, name, path, parent_code, sort_order in menu_defs:
|
||||
if parent_code is None: # 只分配顶级菜单
|
||||
existing_perm = SysRolePermission.query.filter_by(
|
||||
|
||||
@ -114,12 +114,12 @@ class TransService:
|
||||
@staticmethod
|
||||
def process_return(data, operator_name):
|
||||
"""
|
||||
还库逻辑(支持部分归还):
|
||||
1. 校验本次归还数量不能大于待还数量
|
||||
2. 恢复可用库存(按本次归还数量)
|
||||
3. 更新库位 (如果有变动)
|
||||
4. 记录库管签字
|
||||
5. 更新归还数量和状态(部分归还/全部归还)
|
||||
还库逻辑(支持部分归还)- 已优化,消除 N+1 和长事务死锁风险
|
||||
四步走策略:
|
||||
1. 收集所有 borrow_id
|
||||
2. 批量锁定借用记录
|
||||
3. 收集库存ID并批量锁定库存
|
||||
4. 内存中完成业务逻辑
|
||||
"""
|
||||
items = data.get('items', [])
|
||||
signature = data.get('signature_path') # 库管签字
|
||||
@ -130,15 +130,60 @@ class TransService:
|
||||
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
|
||||
|
||||
try:
|
||||
# ==========================================
|
||||
# ★ 优化步骤 1:收集所有 borrow_id
|
||||
# ==========================================
|
||||
borrow_ids = []
|
||||
item_map = {} # 存储原始 item 数据,key=borrow_id
|
||||
for item in items:
|
||||
borrow_id = item.get('id')
|
||||
# 前端传入的本次归还数量
|
||||
return_qty = float(item.get('return_qty', 0))
|
||||
# 前端如果没有填 return_location,应该在提交前处理好,或者这里做 fallback
|
||||
# 这里假设前端传来的 return_location 就是最终要保存的库位
|
||||
final_location = item.get('return_location')
|
||||
if borrow_id:
|
||||
borrow_ids.append(borrow_id)
|
||||
item_map[borrow_id] = {
|
||||
'return_qty': float(item.get('return_qty', 0)),
|
||||
'final_location': item.get('return_location')
|
||||
}
|
||||
|
||||
record = TransBorrow.query.with_for_update().get(borrow_id)
|
||||
if not borrow_ids:
|
||||
raise ValueError("没有有效的归还记录")
|
||||
|
||||
# ==========================================
|
||||
# ★ 优化步骤 2:批量锁定借用记录
|
||||
# ==========================================
|
||||
borrow_records = TransBorrow.query.with_for_update().filter(
|
||||
TransBorrow.id.in_(borrow_ids)
|
||||
).all()
|
||||
|
||||
borrow_map = {r.id: r for r in borrow_records}
|
||||
|
||||
# ==========================================
|
||||
# ★ 优化步骤 3:收集库存ID并批量锁定库存
|
||||
# ==========================================
|
||||
stock_ids_by_table = {'stock_buy': set(), 'stock_semi': set(), 'stock_product': set()}
|
||||
|
||||
for borrow_id, record in borrow_map.items():
|
||||
if record.source_table in stock_ids_by_table and record.stock_id:
|
||||
stock_ids_by_table[record.source_table].add(record.stock_id)
|
||||
|
||||
stock_map = {} # 格式: { ('stock_buy', 101): stock_obj }
|
||||
for table_name, ids in stock_ids_by_table.items():
|
||||
if not ids:
|
||||
continue
|
||||
ModelClass = model_map[table_name]
|
||||
stocks = ModelClass.query.with_for_update().filter(
|
||||
ModelClass.id.in_(ids)
|
||||
).all()
|
||||
for stock in stocks:
|
||||
stock_map[(table_name, stock.id)] = stock
|
||||
|
||||
# ==========================================
|
||||
# ★ 优化步骤 4:内存中完成业务逻辑
|
||||
# ==========================================
|
||||
for borrow_id, item_data in item_map.items():
|
||||
return_qty = item_data['return_qty']
|
||||
final_location = item_data['final_location']
|
||||
|
||||
record = borrow_map.get(borrow_id)
|
||||
if not record:
|
||||
continue
|
||||
|
||||
@ -153,22 +198,19 @@ class TransService:
|
||||
if return_qty > pending_qty:
|
||||
raise ValueError(f"本次归还数量({return_qty})不能大于待还数量({pending_qty})")
|
||||
|
||||
ModelClass = model_map.get(record.source_table)
|
||||
if ModelClass:
|
||||
stock = ModelClass.query.with_for_update().get(record.stock_id)
|
||||
if stock:
|
||||
# 1. 恢复可用库存(按本次归还数量)
|
||||
stock.available_quantity = float(stock.available_quantity) + return_qty
|
||||
# 更新库存
|
||||
stock = stock_map.get((record.source_table, record.stock_id))
|
||||
if stock:
|
||||
# 恢复可用库存
|
||||
stock.available_quantity = float(stock.available_quantity) + return_qty
|
||||
# 更新库位
|
||||
if final_location:
|
||||
stock.warehouse_location = final_location
|
||||
|
||||
# 2. 更新库位 (如果提供了有效值)
|
||||
if final_location:
|
||||
stock.warehouse_location = final_location
|
||||
|
||||
# 3. 更新归还数量
|
||||
# 更新归还数量和状态
|
||||
new_returned_qty = returned_qty + return_qty
|
||||
record.returned_quantity = new_returned_qty
|
||||
|
||||
# 4. 更新状态
|
||||
if new_returned_qty >= total_qty:
|
||||
record.is_returned = True
|
||||
record.status = 'returned'
|
||||
|
||||
@ -5,6 +5,43 @@ from flask import jsonify, g, request, current_app, has_request_context
|
||||
import logging
|
||||
import json
|
||||
|
||||
def _verify_user_active():
|
||||
"""
|
||||
JWT「幽灵令牌」安全漏洞修复:
|
||||
在 Token 签名验证通过之后,进一步检查用户在数据库中是否仍然存在且未被禁用。
|
||||
|
||||
调用时机:login_required / permission_required 装饰器中,
|
||||
在 verify_jwt_in_request() 成功之后立即调用。
|
||||
|
||||
返回 True → 用户正常,放行
|
||||
返回 False → 用户已从数据库删除或被禁用,阻断请求
|
||||
"""
|
||||
try:
|
||||
claims = get_jwt()
|
||||
user_id = claims.get('sub')
|
||||
if user_id is None:
|
||||
return True
|
||||
|
||||
from app.models.system import SysUser
|
||||
user = SysUser.query.get(user_id)
|
||||
if user is None:
|
||||
current_app.logger.warning(
|
||||
f"🚫 [Ghost Token Blocked] user_id={user_id} not found in database (deleted account)"
|
||||
)
|
||||
return False
|
||||
|
||||
if user.status != 'active':
|
||||
current_app.logger.warning(
|
||||
f"🚫 [Token Blocked] user_id={user_id} status={user.status} (disabled account)"
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"User active check error: {e}")
|
||||
return True # 出错时 fail-open,避免数据库故障导致全站不可用
|
||||
|
||||
|
||||
def _verify_token_in_redis():
|
||||
"""
|
||||
验证当前 Token 是否与 Redis 中存储的 Token 一致(单设备登录互踢)
|
||||
@ -67,7 +104,10 @@ def role_required(*roles):
|
||||
return wrapper
|
||||
|
||||
def login_required(fn):
|
||||
"""验证 JWT 令牌是否存在且有效"""
|
||||
"""
|
||||
验证 JWT 令牌是否存在且有效,并检查用户是否仍在数据库中且未被禁用。
|
||||
双重防护:1) Token 签名验证 2) 数据库用户存在性 3) Redis 单设备互踢
|
||||
"""
|
||||
@wraps(fn)
|
||||
def decorator(*args, **kwargs):
|
||||
try:
|
||||
@ -76,6 +116,10 @@ def login_required(fn):
|
||||
logging.warning(f"JWT verification failed: {e}")
|
||||
return jsonify(msg='登录已过期,请重新登录'), 401
|
||||
|
||||
# ★ 幽灵令牌漏洞修复:检查用户是否已从数据库删除或被禁用
|
||||
if not _verify_user_active():
|
||||
return jsonify(msg='账号已失效(已删除或已禁用),请重新登录'), 401
|
||||
|
||||
if not _verify_token_in_redis():
|
||||
return _raise_token_mismatch_error()
|
||||
|
||||
@ -83,7 +127,7 @@ def login_required(fn):
|
||||
return decorator
|
||||
|
||||
def permission_required(permission_code):
|
||||
"""检查当前用户是否拥有指定权限码"""
|
||||
"""检查当前用户是否拥有指定权限码,同时检查用户是否仍然有效"""
|
||||
def wrapper(fn):
|
||||
@wraps(fn)
|
||||
def decorator(*args, **kwargs):
|
||||
@ -93,6 +137,10 @@ def permission_required(permission_code):
|
||||
logging.warning(f"JWT verification failed: {e}")
|
||||
return jsonify(msg='登录已过期,请重新登录'), 401
|
||||
|
||||
# ★ 幽灵令牌漏洞修复:检查用户是否已从数据库删除或被禁用
|
||||
if not _verify_user_active():
|
||||
return jsonify(msg='账号已失效(已删除或已禁用),请重新登录'), 401
|
||||
|
||||
if not _verify_token_in_redis():
|
||||
return _raise_token_mismatch_error()
|
||||
|
||||
|
||||
@ -9,5 +9,86 @@
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<script>
|
||||
// 获取当前用户的登录凭证 (Token)
|
||||
var currentToken = localStorage.getItem('access_token') || localStorage.getItem('token') || '';
|
||||
|
||||
window.difyChatbotConfig = {
|
||||
token: '6T0eTgukUEqzK0iW',
|
||||
baseUrl: 'http://172.16.0.198:8080',
|
||||
inputs: {
|
||||
"user_token": currentToken
|
||||
},
|
||||
systemVariables: {},
|
||||
userVariables: {},
|
||||
}
|
||||
</script>
|
||||
|
||||
<script
|
||||
src="http://172.16.0.198:8080/embed.min.js"
|
||||
id="6T0eTgukUEqzK0iW"
|
||||
defer>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#dify-chatbot-bubble-button {
|
||||
background-color: #409EFF !important;
|
||||
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.4) !important;
|
||||
}
|
||||
|
||||
/* 变成"独立悬浮窗口" */
|
||||
#dify-chatbot-bubble-window {
|
||||
/* 👇 解除原本锁定在右下角的限制,将其定位在屏幕中间偏左上 */
|
||||
top: 15vh !important;
|
||||
left: 20vw !important;
|
||||
bottom: auto !important;
|
||||
right: auto !important;
|
||||
|
||||
/* 设置初始宽高为半个屏幕左右 */
|
||||
width: 60vw !important;
|
||||
height: 70vh !important;
|
||||
|
||||
border-radius: 12px !important;
|
||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.2) !important; /* 增加超大弥散阴影,浮现感更强 */
|
||||
|
||||
/* 👇 开启右下角拖拽,并强制留出 16px 的白边给拖拽手柄 */
|
||||
resize: both !important;
|
||||
overflow: hidden !important;
|
||||
padding-bottom: 16px !important;
|
||||
padding-right: 16px !important;
|
||||
background-color: #ffffff !important;
|
||||
|
||||
/* 极限尺寸防崩 */
|
||||
min-width: 300px !important;
|
||||
min-height: 400px !important;
|
||||
max-width: 95vw !important;
|
||||
max-height: 90vh !important;
|
||||
}
|
||||
|
||||
/* 内层 iframe 填满剩余空间,加上圆角更好看 */
|
||||
#dify-chatbot-bubble-window iframe {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
border: none !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.addEventListener('click', function(event) {
|
||||
var bubbleWindow = document.getElementById('dify-chatbot-bubble-window');
|
||||
var bubbleButton = document.getElementById('dify-chatbot-bubble-button');
|
||||
|
||||
if (bubbleWindow && bubbleButton) {
|
||||
var isWindowOpen = window.getComputedStyle(bubbleWindow).display !== 'none';
|
||||
|
||||
if (isWindowOpen && !bubbleWindow.contains(event.target) && !bubbleButton.contains(event.target)) {
|
||||
bubbleButton.click();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -234,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.20
|
||||
当前版本:V3.26(添加AI助手版)
|
||||
</span>
|
||||
</footer>
|
||||
|
||||
|
||||
@ -17,11 +17,43 @@ export function getInboundSummaryList(params: InboundSummaryQuery) {
|
||||
})
|
||||
}
|
||||
|
||||
export function exportInboundSummary(params: any) {
|
||||
// ============================================================
|
||||
// 旧版:前端直接处理 Excel blob(已废弃,保留用于参考)
|
||||
// ============================================================
|
||||
// export function exportInboundSummary(params: any) {
|
||||
// return request({
|
||||
// url: '/v1/inbound/summary/export',
|
||||
// method: 'get',
|
||||
// params,
|
||||
// responseType: 'blob'
|
||||
// })
|
||||
// }
|
||||
|
||||
// ============================================================
|
||||
// 新版:异步导出 API(后端生成 + 轮询任务状态)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 提交异步导出任务
|
||||
* POST /api/v1/export/inventory
|
||||
* 返回 { task_id: string }
|
||||
*/
|
||||
export function submitExportTask(filters: Record<string, any>) {
|
||||
return request({
|
||||
url: '/v1/inbound/summary/export',
|
||||
method: 'get',
|
||||
params,
|
||||
responseType: 'blob'
|
||||
url: '/v1/export/inventory',
|
||||
method: 'post',
|
||||
data: filters
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询导出任务状态
|
||||
* GET /api/v1/export/status/<taskId>
|
||||
* 返回 { status: 'processing'|'completed'|'failed', progress: number, url: string, error: string }
|
||||
*/
|
||||
export function checkExportStatus(taskId: string) {
|
||||
return request({
|
||||
url: `/v1/export/status/${taskId}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
81
inventory-web/src/hooks/usePasteUpload.ts
Normal file
81
inventory-web/src/hooks/usePasteUpload.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
export function usePasteUpload(
|
||||
customUpload: Function,
|
||||
targetField: string,
|
||||
containerSelector: string,
|
||||
onLinkFound?: (link: string, field: string) => void
|
||||
) {
|
||||
const isHovering = ref(false)
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
const target = event.target as Element
|
||||
if (!target) return
|
||||
const isInsideTarget = !!target.closest(containerSelector)
|
||||
if (isInsideTarget !== isHovering.value) {
|
||||
isHovering.value = isInsideTarget
|
||||
}
|
||||
}
|
||||
|
||||
const handleGlobalPaste = (event: ClipboardEvent) => {
|
||||
if (!isHovering.value) return
|
||||
|
||||
const activeElement = document.activeElement
|
||||
const activeTag = activeElement?.tagName.toLowerCase()
|
||||
if (activeTag === 'input' || activeTag === 'textarea') return
|
||||
|
||||
const clipboardData = event.clipboardData
|
||||
if (!clipboardData) return
|
||||
|
||||
// 1. 优先获取真实文件(本地截图/复制文件)
|
||||
let imageFile: File | null = null
|
||||
for (let i = 0; i < clipboardData.items.length; i++) {
|
||||
if (clipboardData.items[i].type.indexOf('image') !== -1) {
|
||||
const rawFile = clipboardData.items[i].getAsFile()
|
||||
if (rawFile) {
|
||||
const extension = rawFile.type.split('/')[1] || 'png'
|
||||
const fileName = `paste_${targetField}_${new Date().getTime()}.${extension}`
|
||||
imageFile = new File([rawFile], fileName, { type: rawFile.type })
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (imageFile) {
|
||||
event.preventDefault()
|
||||
customUpload({ file: imageFile, onSuccess: () => {}, onError: () => {} }, targetField)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 没有真实文件时,解析 HTML 或文本里的图片 URL(网页复制场景)
|
||||
const htmlData = clipboardData.getData('text/html')
|
||||
const textData = clipboardData.getData('text/plain')
|
||||
|
||||
let imgUrl = ''
|
||||
if (htmlData) {
|
||||
const match = htmlData.match(/<img[^>]+src="([^">]+)"/)
|
||||
if (match && match[1]) imgUrl = match[1]
|
||||
}
|
||||
if (!imgUrl && textData && textData.startsWith('http')) {
|
||||
imgUrl = textData
|
||||
}
|
||||
|
||||
if (imgUrl && onLinkFound) {
|
||||
event.preventDefault()
|
||||
ElMessage.success('检测到网页图片,已自动填入外部链接')
|
||||
onLinkFound(imgUrl, targetField)
|
||||
} else if (!imgUrl) {
|
||||
ElMessage.warning('剪贴板中未检测到有效图片或链接')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('paste', handleGlobalPaste)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('paste', handleGlobalPaste)
|
||||
})
|
||||
}
|
||||
@ -457,7 +457,7 @@
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="产品图" prop="generalImage" v-if="hasFieldPermission('files')">
|
||||
<div class="upload-container">
|
||||
<div class="upload-container" id="upload-generalImage">
|
||||
<el-upload
|
||||
v-model:file-list="fileListImage"
|
||||
action="#"
|
||||
@ -485,7 +485,7 @@
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="说明书" prop="generalManual" v-if="hasFieldPermission('files')">
|
||||
<div class="upload-container">
|
||||
<div class="upload-container" id="upload-generalManual">
|
||||
<el-upload
|
||||
v-model:file-list="fileListManual"
|
||||
action="#"
|
||||
@ -653,6 +653,7 @@ import {
|
||||
markWarningOrdered
|
||||
} from '@/api/material_base';
|
||||
import { uploadFile, deleteFile } from '@/api/common/upload';
|
||||
import { usePasteUpload } from '@/hooks/usePasteUpload';
|
||||
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue';
|
||||
|
||||
const userStore = useUserStore();
|
||||
@ -1584,6 +1585,13 @@ const customUpload = async (options: any, targetField: 'generalImage' | 'general
|
||||
if (res.code === 200) {
|
||||
const newUrl = res.data.url
|
||||
form.value[targetField].push(newUrl)
|
||||
// 同步更新 fileList,触发 el-upload UI 刷新
|
||||
const fileObj = { name: newUrl.split('/').pop(), url: getImageUrl(newUrl) }
|
||||
if (targetField === 'generalImage') {
|
||||
fileListImage.value.push(fileObj)
|
||||
} else {
|
||||
fileListManual.value.push(fileObj)
|
||||
}
|
||||
ElMessage.success('上传成功')
|
||||
onSuccess(res)
|
||||
} else {
|
||||
@ -1597,6 +1605,13 @@ const customUpload = async (options: any, targetField: 'generalImage' | 'general
|
||||
finally { isUploading.value = false }
|
||||
}
|
||||
|
||||
// 粘贴上传处理器(PC 端:鼠标悬停 + Ctrl+V 直接粘贴图片)
|
||||
const handlePasteLink = (link: string, field: string) => {
|
||||
imageExternalUrl.value = link
|
||||
}
|
||||
usePasteUpload(customUpload, 'generalImage', '#upload-generalImage', handlePasteLink)
|
||||
usePasteUpload(customUpload, 'generalManual', '#upload-generalManual', handlePasteLink)
|
||||
|
||||
const handleRemoveImage = async (uploadFile: any, targetField: 'generalImage' | 'generalManual') => {
|
||||
const fileName = uploadFile.name || uploadFile.url?.split('/').pop() || '此文件'
|
||||
try {
|
||||
|
||||
@ -192,14 +192,14 @@
|
||||
<el-dialog v-model="bomSelectVisible" title="按 BOM 套餐添加" width="700px" destroy-on-close :close-on-click-modal="false">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="选择产品">
|
||||
<el-select v-model="selectedBomNo" filterable placeholder="请选择启用状态的 BOM 配方" style="width: 100%" @change="() => {}">
|
||||
<el-option
|
||||
v-for="b in bomOptions"
|
||||
:key="`${b.bom_no}_${b.version}`"
|
||||
:label="`${b.parent_name} - ${b.version}`"
|
||||
:value="b.bom_no"
|
||||
/>
|
||||
</el-select>
|
||||
<el-tree-select
|
||||
v-model="selectedBomNo"
|
||||
:data="treeData"
|
||||
filterable
|
||||
placeholder="请选择启用的 BOM 配方"
|
||||
:render-after-expand="false"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="生产套数">
|
||||
<el-input-number v-model="bomSets" :min="1" label="套" style="width: 200px;" :disabled="!userStore.hasPermission('outbound_selection:operation')" />
|
||||
@ -432,7 +432,7 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Printer, Search, Plus, Download, List } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElTable, ElMessageBox } from 'element-plus'
|
||||
import { getAllStock, getStockList, printSelectionList } from '@/api/inbound/stock'
|
||||
import { getStockList, printSelectionList } from '@/api/inbound/stock'
|
||||
import { getBomList, getBomDetail } from '@/api/bom'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { submitOutboundRequest } from '@/api/outbound'
|
||||
@ -467,7 +467,6 @@ 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)
|
||||
const stockPage = ref(1)
|
||||
@ -487,6 +486,19 @@ const selectedBomNo = ref('')
|
||||
const bomSets = ref(1)
|
||||
const currentBomDetail = ref<any[]>([]) // 当前选中的BOM明细
|
||||
|
||||
// BOM 树形数据(将分组数据映射为 el-tree-select 需要的结构)
|
||||
const treeData = computed(() => {
|
||||
return (bomOptions.value || []).map(group => ({
|
||||
value: `group_${group.category}`, // 仅作唯一标识
|
||||
label: `${group.category} (${group.count})`,
|
||||
disabled: true, // 禁止选中分类本身
|
||||
children: (group.items || []).map((b: any) => ({
|
||||
value: b.bom_no,
|
||||
label: `${b.parent_name} - ${b.version}`
|
||||
}))
|
||||
}))
|
||||
})
|
||||
|
||||
// 打印相关
|
||||
const currentTime = ref('')
|
||||
|
||||
@ -512,43 +524,35 @@ const totalExportCount = computed(() => {
|
||||
return validSelectedItems.value.reduce((sum, item) => sum + (item.export_quantity || 0), 0)
|
||||
})
|
||||
|
||||
// --- BOM 齐套性分析计算属性 ---
|
||||
// --- BOM 齐套性分析计算属性(使用后端已计算的 current_stock,O(N),无嵌套循环)---
|
||||
const maxBuildableSets = computed(() => {
|
||||
if (currentBomDetail.value.length === 0 || allStockData.value.length === 0) return 0
|
||||
let minSets = Infinity
|
||||
currentBomDetail.value.forEach((bomItem: any) => {
|
||||
const dosage = parseFloat(bomItem.dosage) || 0 // 单套需求量
|
||||
if (dosage <= 0) return
|
||||
// 匹配库存中的可用数量
|
||||
const stockItem = allStockData.value.find((s: any) => s.base_id && s.base_id == bomItem.child_id)
|
||||
const available = stockItem ? (stockItem.availableCount || 0) : 0
|
||||
const buildable = Math.floor(available / dosage)
|
||||
if (buildable < minSets) minSets = buildable
|
||||
})
|
||||
return minSets === Infinity ? 0 : minSets
|
||||
if (!currentBomDetail.value?.length) return 0
|
||||
return currentBomDetail.value.reduce((minSets, bomItem: any) => {
|
||||
const dosage = parseFloat(bomItem.dosage) || 0
|
||||
if (dosage <= 0) return minSets
|
||||
const stock = parseFloat(bomItem.current_stock) || 0
|
||||
return Math.min(minSets, Math.floor(stock / dosage))
|
||||
}, Infinity)
|
||||
})
|
||||
|
||||
const shortageList = computed(() => {
|
||||
if (currentBomDetail.value.length === 0 || allStockData.value.length === 0 || bomSets.value <= 0) return []
|
||||
const target = bomSets.value
|
||||
const shortages: any[] = []
|
||||
currentBomDetail.value.forEach((bomItem: any) => {
|
||||
const dosage = parseFloat(bomItem.dosage) || 0 // 单套需求量
|
||||
const totalNeed = dosage * target
|
||||
const stockItem = allStockData.value.find((s: any) => s.base_id && s.base_id == bomItem.child_id)
|
||||
const available = stockItem ? (stockItem.availableCount || 0) : 0
|
||||
const shortage = totalNeed - available
|
||||
if (shortage > 0) {
|
||||
shortages.push({
|
||||
name: bomItem.child_name || bomItem.name || '未知物料',
|
||||
sku: bomItem.child_sku || bomItem.sku || '-',
|
||||
need: totalNeed,
|
||||
available: available,
|
||||
shortage: shortage
|
||||
if (!currentBomDetail.value?.length || bomSets.value <= 0) return []
|
||||
return currentBomDetail.value
|
||||
.map((bomItem: any) => {
|
||||
const dosage = parseFloat(bomItem.dosage) || 0
|
||||
const totalNeed = dosage * bomSets.value
|
||||
const stock = parseFloat(bomItem.current_stock) || 0
|
||||
const shortage = Math.max(0, totalNeed - stock)
|
||||
return { ...bomItem, shortage, available: stock }
|
||||
})
|
||||
}
|
||||
})
|
||||
return shortages
|
||||
.filter((item: any) => item.shortage > 0)
|
||||
.map((item: any) => ({
|
||||
name: item.child_name || item.name || '未知物料',
|
||||
sku: item.child_sku || item.sku || '-',
|
||||
need: item.dosage * bomSets.value,
|
||||
available: item.available,
|
||||
shortage: item.shortage
|
||||
}))
|
||||
})
|
||||
|
||||
const hasShortage = computed(() => shortageList.value.length > 0 && bomSets.value > maxBuildableSets.value)
|
||||
@ -563,31 +567,6 @@ 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:手动添加库存 ---
|
||||
|
||||
// 服务端加载库存列表
|
||||
@ -620,8 +599,6 @@ const openManualSelect = async () => {
|
||||
stockPage.value = 1
|
||||
searchKeyword.value = ''
|
||||
await loadStockList()
|
||||
await ensureAllStockLoaded()
|
||||
allStockData.value.forEach(item => item.export_quantity = 0)
|
||||
}
|
||||
|
||||
// 搜索框防抖触发服务端过滤
|
||||
@ -726,7 +703,6 @@ const openBomSelect = async () => {
|
||||
} catch (e) {
|
||||
ElMessage.error('加载 BOM 列表失败')
|
||||
}
|
||||
await ensureAllStockLoaded()
|
||||
}
|
||||
|
||||
// 监听 BOM 选择变化,自动加载明细并计算齐套性
|
||||
|
||||
@ -147,7 +147,7 @@
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="图片上传" required>
|
||||
<el-form-item label="图片上传" required id="upload-purchase-images">
|
||||
<el-upload
|
||||
v-model:file-list="fileList"
|
||||
:http-request="customUpload"
|
||||
@ -244,6 +244,7 @@ import {
|
||||
approvePurchase, getPurchaseApprovers, autoFillPurchase
|
||||
} from '@/api/purchase'
|
||||
import { uploadFile, deleteFile } from '@/api/common/upload'
|
||||
import { usePasteUpload } from '@/hooks/usePasteUpload'
|
||||
import type { FormInstance } from 'element-plus'
|
||||
|
||||
const userStore = useUserStore()
|
||||
@ -490,6 +491,8 @@ const customUpload = async (options: any) => {
|
||||
if (res.code === 200) {
|
||||
const newUrl = res.data.url
|
||||
form.value.images!.push(newUrl)
|
||||
// 同步更新 fileList,触发 el-upload UI 刷新
|
||||
fileList.value.push({ name: newUrl.split('/').pop(), url: getImageUrl(newUrl) })
|
||||
onSuccess(res)
|
||||
} else {
|
||||
ElMessage.error(res.msg || '上传失败')
|
||||
@ -512,6 +515,12 @@ const handleRemoveImage = async (uploadFile: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 粘贴上传处理器(PC 端:鼠标悬停 + Ctrl+V 直接粘贴图片)
|
||||
const handlePasteLink = (link: string, field: string) => {
|
||||
// 采购单没有独立的外链输入框,暂不支持网页图片链接自动填入
|
||||
}
|
||||
usePasteUpload(customUpload, 'images', '#upload-purchase-images', handlePasteLink)
|
||||
|
||||
// --- 提交 ---
|
||||
const submitForm = async () => {
|
||||
if (!form.value.name.trim()) { ElMessage.warning('请选择或填写采购物品'); return }
|
||||
|
||||
@ -447,7 +447,7 @@
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="24">
|
||||
<el-form-item label="到货图片" prop="arrival_photo">
|
||||
<div class="upload-container">
|
||||
<div class="upload-container" id="upload-arrival_photo">
|
||||
<el-upload
|
||||
v-model:file-list="arrivalFileList"
|
||||
action="#"
|
||||
@ -466,7 +466,7 @@
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="检测报告" prop="inspection_report">
|
||||
<div class="upload-container">
|
||||
<div class="upload-container" id="upload-inspection_report">
|
||||
<el-upload
|
||||
v-model:file-list="reportFileList"
|
||||
action="#"
|
||||
@ -698,6 +698,7 @@ import {
|
||||
} from '@/api/inbound/buy'
|
||||
import {getLabelPreview, executePrint} from '@/api/common/print'
|
||||
import { getWarehouseTree } from '@/api/common/warehouse'
|
||||
import { usePasteUpload } from '@/hooks/usePasteUpload'
|
||||
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'
|
||||
import WarehouseSelector from '@/components/WarehouseSelector.vue'
|
||||
import SmartScannerDialog from '@/components/SmartScannerDialog.vue'
|
||||
@ -1532,6 +1533,16 @@ const handleRemoveImage = async (uploadFile: any, targetField: 'arrival_photo' |
|
||||
ElMessage.success('已删除')
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
// 粘贴上传处理器(PC 端:鼠标悬停 + Ctrl+V 直接粘贴图片)
|
||||
const handlePasteLink = (link: string, field: string) => {
|
||||
if (field === 'inspection_report') {
|
||||
inspection_report_url.value = link
|
||||
}
|
||||
}
|
||||
usePasteUpload(customUpload, 'arrival_photo', '#upload-arrival_photo', handlePasteLink)
|
||||
usePasteUpload(customUpload, 'inspection_report', '#upload-inspection_report', handlePasteLink)
|
||||
|
||||
const handlePreviewPicture = (uploadFile: any) => { dialogImageUrl.value = uploadFile.url!; dialogVisibleImage.value = true }
|
||||
const triggerCamera = (field: 'arrival_photo' | 'inspection_report') => {
|
||||
currentCameraField.value = field;
|
||||
|
||||
@ -103,10 +103,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { getInboundSummaryList, exportInboundSummary } from '@/api/inbound/inbound_summary'
|
||||
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { getInboundSummaryList, submitExportTask, checkExportStatus } from '@/api/inbound/inbound_summary'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElLoading } from 'element-plus'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
@ -153,45 +153,97 @@ const handleFilter = () => {
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 导出 Excel
|
||||
// ============================================================
|
||||
// 异步导出定时器(组件级别,需在组件销毁时强制清理)
|
||||
// ============================================================
|
||||
let exportTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// 组件销毁前,强制清理"幽灵定时器"(防止用户切换路由后定时器仍在跑)
|
||||
onBeforeUnmount(() => {
|
||||
if (exportTimer) clearInterval(exportTimer)
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// 导出 Excel(后端异步轮询模式)
|
||||
// ============================================================
|
||||
const handleExport = () => {
|
||||
// 防抖:已有任务在执行中直接跳过
|
||||
if (exportLoading.value) return
|
||||
exportLoading.value = true
|
||||
const params = {
|
||||
|
||||
const filters = {
|
||||
keyword: listQuery.keyword,
|
||||
source_type: listQuery.source_type,
|
||||
start_date: listQuery.dateRange ? listQuery.dateRange[0] : null,
|
||||
end_date: listQuery.dateRange ? listQuery.dateRange[1] : null
|
||||
}
|
||||
|
||||
exportInboundSummary(params)
|
||||
.then((response: any) => {
|
||||
const blob = new Blob([response], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
const loadingInstance = ElLoading.service({
|
||||
text: '正在后台生成报表,请稍候...',
|
||||
background: 'rgba(0, 0, 0, 0.6)'
|
||||
})
|
||||
|
||||
const now = new Date()
|
||||
const year = now.getFullYear()
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(now.getDate()).padStart(2, '0')
|
||||
const hour = String(now.getHours()).padStart(2, '0')
|
||||
const minute = String(now.getMinutes()).padStart(2, '0')
|
||||
const second = String(now.getSeconds()).padStart(2, '0')
|
||||
const filename = `入库记录_${year}${month}${day}_${hour}${minute}${second}.xlsx`
|
||||
submitExportTask(filters)
|
||||
.then((res: any) => {
|
||||
const taskId: string = res.data?.task_id
|
||||
if (!taskId) {
|
||||
loadingInstance.close()
|
||||
exportLoading.value = false
|
||||
ElMessage.error('任务提交失败,未获取到 task_id')
|
||||
return
|
||||
}
|
||||
|
||||
link.setAttribute('download', filename)
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
ElMessage.success('导出成功')
|
||||
// 赋值给外部变量,供 onBeforeUnmount 清理
|
||||
exportTimer = setInterval(() => {
|
||||
checkExportStatus(taskId)
|
||||
.then((statusRes: any) => {
|
||||
const { status, progress, url, error } = statusRes.data || {}
|
||||
|
||||
// 实时更新 Loading 提示文字(显示后端返回的进度百分比)
|
||||
if (progress != null) {
|
||||
loadingInstance.setText(`正在生成报表... ${progress}%`)
|
||||
}
|
||||
|
||||
if (status === 'completed') {
|
||||
clearInterval(exportTimer!)
|
||||
loadingInstance.close()
|
||||
exportLoading.value = false
|
||||
|
||||
// 触发浏览器下载(注意:/download/<taskId> 接口在 Flask 侧不过滤 JWT,
|
||||
// 若需要 Token 验证下载,请改用 window.open 或 iframe 下载方式)
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin
|
||||
const downloadUrl = url.startsWith('http') ? url : `${baseUrl}${url}`
|
||||
const link = document.createElement('a')
|
||||
link.href = downloadUrl
|
||||
link.setAttribute('download', '')
|
||||
link.style.display = 'none'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
|
||||
ElMessage.success('报表已生成,正在下载')
|
||||
} else if (status === 'failed') {
|
||||
clearInterval(exportTimer!)
|
||||
loadingInstance.close()
|
||||
exportLoading.value = false
|
||||
ElMessage.error(`生成失败:${error || '未知错误'}`)
|
||||
}
|
||||
// 'processing' → 继续轮询
|
||||
})
|
||||
.catch(() => {
|
||||
// 轮询请求本身失败(网络中断),停止轮询,提示用户
|
||||
clearInterval(exportTimer!)
|
||||
loadingInstance.close()
|
||||
exportLoading.value = false
|
||||
ElMessage.error('查询进度失败,请检查网络或稍后重试')
|
||||
})
|
||||
}, 1500)
|
||||
})
|
||||
.catch((err: any) => {
|
||||
console.error('导出失败', err)
|
||||
ElMessage.error('导出失败')
|
||||
})
|
||||
.finally(() => {
|
||||
console.error('提交导出任务失败', err)
|
||||
loadingInstance.close()
|
||||
exportLoading.value = false
|
||||
ElMessage.error('提交导出任务失败')
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -388,7 +388,7 @@
|
||||
</el-col>
|
||||
<el-col :span="dialogStatus === 'update' ? 12 : 18">
|
||||
<el-form-item label="成品实拍" prop="product_photo">
|
||||
<div class="upload-container">
|
||||
<div class="upload-container" id="upload-product_photo">
|
||||
<el-upload v-model:file-list="productPhotoList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'product_photo')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'product_photo')" :before-upload="beforeAvatarUpload">
|
||||
<el-icon><Plus /></el-icon>
|
||||
</el-upload>
|
||||
@ -402,7 +402,7 @@
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="质量报告" prop="quality_report_link">
|
||||
<div class="upload-container">
|
||||
<div class="upload-container" id="upload-quality_report_link">
|
||||
<el-upload v-model:file-list="qualityFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'quality_report_link')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'quality_report_link')" :before-upload="beforeAvatarUpload">
|
||||
<el-icon><Plus /></el-icon>
|
||||
</el-upload>
|
||||
@ -415,7 +415,7 @@
|
||||
|
||||
<el-col :span="12">
|
||||
<el-form-item label="检测报告" prop="inspection_report_link">
|
||||
<div class="upload-container">
|
||||
<div class="upload-container" id="upload-inspection_report_link">
|
||||
<el-upload v-model:file-list="inspectionFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'inspection_report_link')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'inspection_report_link')" :before-upload="beforeAvatarUpload">
|
||||
<el-icon><Plus /></el-icon>
|
||||
</el-upload>
|
||||
@ -592,6 +592,7 @@ import WarehouseSelector from '@/components/WarehouseSelector.vue'
|
||||
import SmartScannerDialog from '@/components/SmartScannerDialog.vue'
|
||||
import { getLabelPreview, executePrint } from '@/api/common/print'
|
||||
import { getWarehouseTree } from '@/api/common/warehouse'
|
||||
import { usePasteUpload } from '@/hooks/usePasteUpload'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
// ------------------------------------
|
||||
@ -1235,6 +1236,15 @@ const handleRemoveImage = async (uploadFile: any, targetField: 'product_photo' |
|
||||
}
|
||||
const handlePreviewPicture = (uploadFile: any) => { dialogImageUrl.value = uploadFile.url!; dialogVisibleImage.value = true }
|
||||
|
||||
// 粘贴上传处理器(PC 端:鼠标悬停 + Ctrl+V 直接粘贴图片)
|
||||
const handlePasteLink = (link: string, field: string) => {
|
||||
if (field === 'quality_report_link') quality_url.value = link
|
||||
else if (field === 'inspection_report_link') inspection_url.value = link
|
||||
}
|
||||
usePasteUpload(customUpload, 'product_photo', '#upload-product_photo', handlePasteLink)
|
||||
usePasteUpload(customUpload, 'quality_report_link', '#upload-quality_report_link', handlePasteLink)
|
||||
usePasteUpload(customUpload, 'inspection_report_link', '#upload-inspection_report_link', handlePasteLink)
|
||||
|
||||
const triggerCamera = (field: any) => {
|
||||
currentCameraField.value = field;
|
||||
cameraDialogVisible.value = true;
|
||||
|
||||
@ -467,7 +467,7 @@
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="24">
|
||||
<el-form-item label="到货图片" prop="arrival_photo">
|
||||
<div class="upload-container">
|
||||
<div class="upload-container" id="upload-arrival_photo">
|
||||
<el-upload v-model:file-list="arrivalFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'arrival_photo')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'arrival_photo')" :before-upload="beforeAvatarUpload">
|
||||
<el-icon><Plus /></el-icon>
|
||||
</el-upload>
|
||||
@ -479,7 +479,7 @@
|
||||
|
||||
<el-col :span="24">
|
||||
<el-form-item label="质量报告" prop="quality_report_link">
|
||||
<div class="upload-container">
|
||||
<div class="upload-container" id="upload-quality_report_link">
|
||||
<el-upload v-model:file-list="reportFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'quality_report_link')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'quality_report_link')" :before-upload="beforeAvatarUpload">
|
||||
<el-icon><Plus /></el-icon>
|
||||
</el-upload>
|
||||
@ -647,6 +647,7 @@ import WarehouseSelector from '@/components/WarehouseSelector.vue'
|
||||
import SmartScannerDialog from '@/components/SmartScannerDialog.vue'
|
||||
import {getLabelPreview, executePrint} from '@/api/common/print'
|
||||
import { getWarehouseTree } from '@/api/common/warehouse'
|
||||
import { usePasteUpload } from '@/hooks/usePasteUpload'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
// ------------------------------------
|
||||
@ -1324,6 +1325,14 @@ const handleRemoveImage = async (uploadFile: any, targetField: 'arrival_photo' |
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
const handlePreviewPicture = (uploadFile: any) => { dialogImageUrl.value = uploadFile.url!; dialogVisibleImage.value = true }
|
||||
|
||||
// 粘贴上传处理器(PC 端:鼠标悬停 + Ctrl+V 直接粘贴图片)
|
||||
const handlePasteLink = (link: string, field: string) => {
|
||||
if (field === 'quality_report_link') quality_report_url.value = link
|
||||
}
|
||||
usePasteUpload(customUpload, 'arrival_photo', '#upload-arrival_photo', handlePasteLink)
|
||||
usePasteUpload(customUpload, 'quality_report_link', '#upload-quality_report_link', handlePasteLink)
|
||||
|
||||
const triggerCamera = (field: 'arrival_photo' | 'quality_report_link') => {
|
||||
currentCameraField.value = field;
|
||||
cameraDialogVisible.value = true;
|
||||
|
||||
@ -56,6 +56,7 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="hasColumnPermission('borrow_time')" prop="borrow_time" label="借出时间" width="160" sortable />
|
||||
<el-table-column v-if="hasColumnPermission('return_operator')" prop="return_operator" label="归还人" width="100" />
|
||||
|
||||
<el-table-column v-if="hasColumnPermission('expected_return_time') || hasColumnPermission('return_time')" label="归还时间 / 预计" min-width="200">
|
||||
<template #default="{row}">
|
||||
@ -173,8 +174,9 @@ const hasColumnPermission = (prop: string) => {
|
||||
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
|
||||
return true
|
||||
}
|
||||
// SKU 字段不再参与权限过滤,始终可见
|
||||
if (prop === 'sku') {
|
||||
// SKU / return_time / return_operator 不再参与权限过滤,始终可见
|
||||
const alwaysVisible = ['sku', 'return_time', 'return_operator', 'expected_return_time', 'status', 'return_location', 'borrow_no', 'borrower_name', 'borrow_time']
|
||||
if (alwaysVisible.includes(prop)) {
|
||||
return true
|
||||
}
|
||||
const code = permissionMap[prop]
|
||||
|
||||
Reference in New Issue
Block a user