Compare commits
17 Commits
d1e49c343c
...
2.0权限管理
| Author | SHA1 | Date | |
|---|---|---|---|
| 117bd003a9 | |||
| 75705d31c9 | |||
| dd84ad828d | |||
| 7d02da2f5c | |||
| 2a6e3979e8 | |||
| e331236a6e | |||
| e977ffc42d | |||
| 4d81056075 | |||
| 6e1e1aa998 | |||
| c60112f5f8 | |||
| c0ab3ce6d2 | |||
| cf55c94826 | |||
| 48651ffd01 | |||
| d60e1c5188 | |||
| 79fccdc24c | |||
| e67e965d8f | |||
| 3cb31c2b67 |
@ -234,6 +234,17 @@ def create_app():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ 错误: Scan 模块注册失败: {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. 预加载数据模型
|
# 3. 预加载数据模型
|
||||||
# =========================================================
|
# =========================================================
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
from flask import Blueprint, request, jsonify, current_app
|
from flask import Blueprint, request, jsonify, current_app
|
||||||
from sqlalchemy import or_
|
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.base import MaterialBase
|
||||||
from app.models.bom import BomTable
|
from app.models.bom import BomTable
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
@ -225,6 +225,11 @@ def delete_bom(bom_no):
|
|||||||
db.session.delete(rec)
|
db.session.delete(rec)
|
||||||
|
|
||||||
db.session.commit()
|
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({
|
return jsonify({
|
||||||
'code': 200,
|
'code': 200,
|
||||||
'msg': '删除成功',
|
'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():
|
def get_all_stock():
|
||||||
"""
|
"""
|
||||||
获取所有库存 > 0 的物品
|
获取所有库存 > 0 的物品
|
||||||
|
支持 AI 极简模式: ?ai_mode=true
|
||||||
|
- 只返回 name / spec / availableQuantity 三个字段
|
||||||
|
- 键名压缩为 n / s / c
|
||||||
"""
|
"""
|
||||||
|
ai_mode = request.args.get('ai_mode', '').lower() == 'true'
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
all_items = []
|
||||||
|
|
||||||
# 1. 采购件
|
# 1. 采购件
|
||||||
materials = []
|
|
||||||
if StockBuy:
|
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. 半成品
|
# 2. 半成品
|
||||||
semis = []
|
|
||||||
if StockSemi:
|
if StockSemi:
|
||||||
try:
|
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:
|
except Exception:
|
||||||
semis = []
|
pass
|
||||||
|
|
||||||
# 3. 成品
|
# 3. 成品
|
||||||
products = []
|
|
||||||
if StockProduct:
|
if StockProduct:
|
||||||
try:
|
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:
|
except Exception:
|
||||||
products = []
|
pass
|
||||||
|
|
||||||
return jsonify({
|
return jsonify(all_items), 200
|
||||||
"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
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error: {e}")
|
print(f"Error: {e}")
|
||||||
return jsonify({"message": f"查询库存失败: {str(e)}"}), 500
|
return jsonify({"message": f"查询库存失败: {str(e)}"}), 500
|
||||||
|
|||||||
@ -41,7 +41,8 @@ def _is_audit_model(mapper):
|
|||||||
'StockBuy', 'StockSemi', 'StockProduct', 'StockService',
|
'StockBuy', 'StockSemi', 'StockProduct', 'StockService',
|
||||||
'RepairRecord', 'TransOutbound', 'TransBorrow', 'TransReturn',
|
'RepairRecord', 'TransOutbound', 'TransBorrow', 'TransReturn',
|
||||||
'BomTable', 'StockTake', 'StockAdjust',
|
'BomTable', 'StockTake', 'StockAdjust',
|
||||||
'TransScrap', 'SysUser'
|
'TransScrap',
|
||||||
|
'SysUser', 'SysMenu', 'SysElement', 'SysRolePermission', # ★ 新增:系统管理三表纳入审计
|
||||||
}
|
}
|
||||||
return mapper.class_.__name__ in AUDIT_WHITELIST
|
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):
|
def before_update_listener(mapper, connection, target):
|
||||||
"""UPDATE 事件:抓取字段变更明细"""
|
"""UPDATE 事件:抓取字段变更明细"""
|
||||||
if not _is_audit_model(mapper): return
|
if not _is_audit_model(mapper): return
|
||||||
|
|
||||||
|
# ★★★ 关键修复:系统初始化(PermissionService.init_all_menus 等)时,
|
||||||
|
# username='system' 且 has_request_context()=False,
|
||||||
|
# 这类非用户发起的变更不应产生审计日志,直接跳过。
|
||||||
|
if not has_request_context():
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
state = inspect(target)
|
state = inspect(target)
|
||||||
changes = {}
|
changes = {}
|
||||||
@ -150,6 +158,8 @@ def before_update_listener(mapper, connection, target):
|
|||||||
def before_delete_listener(mapper, connection, target):
|
def before_delete_listener(mapper, connection, target):
|
||||||
"""DELETE 事件:抓取被删除对象的完整快照"""
|
"""DELETE 事件:抓取被删除对象的完整快照"""
|
||||||
if not _is_audit_model(mapper): return
|
if not _is_audit_model(mapper): return
|
||||||
|
# ★★★ 关键修复:非 HTTP 请求上下文下的初始化操作(如 PermissionService)
|
||||||
|
if not has_request_context(): return
|
||||||
try:
|
try:
|
||||||
state = inspect(target)
|
state = inspect(target)
|
||||||
snap = {}
|
snap = {}
|
||||||
@ -164,6 +174,8 @@ def before_delete_listener(mapper, connection, target):
|
|||||||
def after_insert_listener(mapper, connection, target):
|
def after_insert_listener(mapper, connection, target):
|
||||||
"""INSERT 事件:抓取新增对象的完整快照"""
|
"""INSERT 事件:抓取新增对象的完整快照"""
|
||||||
if not _is_audit_model(mapper): return
|
if not _is_audit_model(mapper): return
|
||||||
|
# ★★★ 关键修复:非 HTTP 请求上下文下的初始化操作(如 PermissionService)
|
||||||
|
if not has_request_context(): return
|
||||||
try:
|
try:
|
||||||
state = inspect(target)
|
state = inspect(target)
|
||||||
snap = {}
|
snap = {}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ from flask_sqlalchemy import SQLAlchemy
|
|||||||
from flask_migrate import Migrate
|
from flask_migrate import Migrate
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
from flask_jwt_extended import JWTManager # 确保引入了 JWTManager
|
from flask_jwt_extended import JWTManager # 确保引入了 JWTManager
|
||||||
|
from flask import current_app
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
import redis
|
import redis
|
||||||
|
|
||||||
@ -11,15 +12,78 @@ migrate = Migrate()
|
|||||||
cors = CORS()
|
cors = CORS()
|
||||||
jwt = JWTManager() # 必须实例化
|
jwt = JWTManager() # 必须实例化
|
||||||
|
|
||||||
# Redis 客户端 (单设备登录互踢用)
|
# Redis 客户端 (单设备登录互踢 + JWT Token 黑名单用)
|
||||||
redis_client = None
|
redis_client = None
|
||||||
|
|
||||||
|
# Redis Key 前缀
|
||||||
|
_JWT_BLOCKED_USER_PREFIX = "jwt_blocked_user:" # 存储被删除/禁用的 user_id
|
||||||
|
|
||||||
|
|
||||||
def beijing_time():
|
def beijing_time():
|
||||||
"""获取北京时间 (UTC+8),剥离时区信息以兼容数据库 naive DateTime 字段"""
|
"""获取北京时间 (UTC+8),剥离时区信息以兼容数据库 naive DateTime 字段"""
|
||||||
return datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
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 调用)
|
# 2. 定义初始化函数 (供工厂函数 create_app 调用)
|
||||||
def init_extensions(app):
|
def init_extensions(app):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -14,11 +14,11 @@ class MaterialBase(db.Model):
|
|||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
company_name = db.Column(db.String(255), comment='所属公司')
|
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='俗名')
|
common_name = db.Column(db.String(255), comment='俗名')
|
||||||
category = db.Column(db.String(100), comment='类别')
|
category = db.Column(db.String(100), index=True, comment='类别') # ★ 分类统计/过滤高频列
|
||||||
material_type = db.Column(db.String(100), comment='类型')
|
material_type = db.Column(db.String(100), index=True, comment='类型') # ★ 类型分组/过滤高频列
|
||||||
spec_model = db.Column(db.String(255), comment='规格型号')
|
spec_model = db.Column(db.String(255), index=True, comment='规格型号') # ★ 模糊搜索/精确匹配高频列
|
||||||
unit = db.Column(db.String(50), comment='计量单位')
|
unit = db.Column(db.String(50), comment='计量单位')
|
||||||
|
|
||||||
# 可见等级
|
# 可见等级
|
||||||
|
|||||||
@ -5,11 +5,11 @@ class BomTable(db.Model):
|
|||||||
__tablename__ = 'bom_table'
|
__tablename__ = 'bom_table'
|
||||||
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
parent_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)
|
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编号')
|
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', comment='版本')
|
version = db.Column(db.String(50), nullable=False, default='V1.0', index=True, comment='版本') # ★ 配合 bom_no 做唯一性约束
|
||||||
|
|
||||||
dosage = db.Column(db.Numeric(19, 4), comment='个数')
|
dosage = db.Column(db.Numeric(19, 4), comment='个数')
|
||||||
loss_rate = db.Column(db.Numeric(5, 2), comment='损耗率%', default=0, nullable=True)
|
loss_rate = db.Column(db.Numeric(5, 2), comment='损耗率%', default=0, nullable=True)
|
||||||
|
|||||||
@ -13,19 +13,19 @@ class StockBuy(db.Model):
|
|||||||
__tablename__ = 'stock_buy'
|
__tablename__ = 'stock_buy'
|
||||||
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
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)
|
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))
|
serial_number = db.Column(db.String(100))
|
||||||
batch_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))
|
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)
|
in_quantity = db.Column(db.Numeric(19, 4), default=0)
|
||||||
|
|||||||
@ -12,12 +12,12 @@ class StockProduct(db.Model):
|
|||||||
__tablename__ = 'stock_product'
|
__tablename__ = 'stock_product'
|
||||||
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
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)
|
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))
|
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)
|
available_quantity = db.Column(db.Numeric(19, 4), default=0)
|
||||||
|
|
||||||
# 状态与位置
|
# 状态与位置
|
||||||
status = db.Column(db.String(50))
|
status = db.Column(db.String(50), index=True) # ★ 在库/锁定 过滤条件
|
||||||
warehouse_location = db.Column(db.String(100))
|
warehouse_location = db.Column(db.String(100), index=True) # ★ 按库位分组/过滤
|
||||||
|
|
||||||
# 生产与成本
|
# 生产与成本
|
||||||
bom_code = db.Column('bom_id', db.String(100))
|
bom_code = db.Column('bom_id', db.String(100))
|
||||||
|
|||||||
@ -11,11 +11,11 @@ class StockSemi(db.Model):
|
|||||||
__tablename__ = 'stock_semi'
|
__tablename__ = 'stock_semi'
|
||||||
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
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)
|
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))
|
serial_number = db.Column(db.String(100))
|
||||||
batch_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)
|
available_quantity = db.Column(db.Numeric(19, 4), default=0)
|
||||||
|
|
||||||
# 状态与位置
|
# 状态与位置
|
||||||
status = db.Column(db.String(50))
|
status = db.Column(db.String(50), index=True) # ★ 在库/锁定 过滤条件
|
||||||
warehouse_location = db.Column(db.String(100))
|
warehouse_location = db.Column(db.String(100), index=True) # ★ 按库位分组/过滤
|
||||||
|
|
||||||
# 半成品特有字段
|
# 半成品特有字段
|
||||||
bom_code = db.Column('bom_id', db.String(100))
|
bom_code = db.Column('bom_id', db.String(100))
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# app/services/auth_service.py
|
# app/services/auth_service.py
|
||||||
from app.models.system import SysUser, SysRolePermission # <== 引入 SysRolePermission
|
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 sqlalchemy import func
|
||||||
from flask_jwt_extended import create_access_token, create_refresh_token, get_jwt_identity
|
from flask_jwt_extended import create_access_token, create_refresh_token, get_jwt_identity
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
@ -334,7 +334,11 @@ class AuthService:
|
|||||||
user.email = email
|
user.email = email
|
||||||
|
|
||||||
if 'status' in data:
|
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')
|
new_password = data.get('password')
|
||||||
if new_password and str(new_password).strip():
|
if new_password and str(new_password).strip():
|
||||||
@ -353,7 +357,7 @@ class AuthService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def delete_user(user_id, operator_role):
|
def delete_user(user_id, operator_role):
|
||||||
"""删除用户"""
|
"""删除用户:删除前自动吊销该用户所有 JWT Token"""
|
||||||
# 标准化操作者角色为全大写
|
# 标准化操作者角色为全大写
|
||||||
operator_role_upper = operator_role.upper() if operator_role else None
|
operator_role_upper = operator_role.upper() if operator_role else None
|
||||||
if operator_role_upper != UserRole.SUPER_ADMIN:
|
if operator_role_upper != UserRole.SUPER_ADMIN:
|
||||||
@ -365,6 +369,18 @@ class AuthService:
|
|||||||
|
|
||||||
# 提前获取用户名用于审计日志
|
# 提前获取用户名用于审计日志
|
||||||
username = user.username
|
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.delete(user)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return username
|
return username
|
||||||
|
|||||||
@ -5,8 +5,96 @@ from app.models.inbound.buy import StockBuy
|
|||||||
from sqlalchemy import func, distinct, or_, case
|
from sqlalchemy import func, distinct, or_, case
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
import uuid
|
import uuid
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
from datetime import datetime
|
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:
|
class BomService:
|
||||||
# ====================== 新版 BOM 逻辑(基于 bom_no) ======================
|
# ====================== 新版 BOM 逻辑(基于 bom_no) ======================
|
||||||
@ -121,8 +209,25 @@ class BomService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def get_bom_detail(bom_no, version=None):
|
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(
|
query = db.session.query(
|
||||||
BomTable,
|
BomTable,
|
||||||
MaterialBase.name.label('child_name'),
|
MaterialBase.name.label('child_name'),
|
||||||
@ -141,6 +246,8 @@ class BomService:
|
|||||||
if not latest_ver:
|
if not latest_ver:
|
||||||
return None
|
return None
|
||||||
query = query.filter(BomTable.version == latest_ver)
|
query = query.filter(BomTable.version == latest_ver)
|
||||||
|
# 记录本次实际查的版本,用于缓存键
|
||||||
|
version = latest_ver
|
||||||
|
|
||||||
rows = query.all()
|
rows = query.all()
|
||||||
if not rows:
|
if not rows:
|
||||||
@ -160,7 +267,7 @@ class BomService:
|
|||||||
'remark': bom.remark or ''
|
'remark': bom.remark or ''
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
result = {
|
||||||
'bom_no': bom_no,
|
'bom_no': bom_no,
|
||||||
'version': first.BomTable.version,
|
'version': first.BomTable.version,
|
||||||
'parent_id': parent_id,
|
'parent_id': parent_id,
|
||||||
@ -170,6 +277,11 @@ class BomService:
|
|||||||
'children': children
|
'children': children
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ===== 第三步:写入 Redis 缓存(TTL=12h),失败只打日志不阻断 =====
|
||||||
|
_cache_set(bom_no, version, result)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def save_bom(data):
|
def save_bom(data):
|
||||||
"""保存 BOM (支持多版本),新增跨版本内容查重"""
|
"""保存 BOM (支持多版本),新增跨版本内容查重"""
|
||||||
@ -239,45 +351,57 @@ class BomService:
|
|||||||
db.session.add(bom)
|
db.session.add(bom)
|
||||||
|
|
||||||
db.session.commit()
|
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
|
return bom_no
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_bom_with_stock_by_bom_no(bom_no):
|
def get_bom_with_stock_by_bom_no(bom_no):
|
||||||
"""
|
"""
|
||||||
根据 bom_no 获取配方详情,并计算:
|
根据 bom_no 获取配方详情,并计算(已修复 N+1 性能问题)
|
||||||
1. 总可用库存
|
|
||||||
2. 最大可生产套数
|
|
||||||
3. ★ 聚合库位信息 (warehouse_locations)
|
|
||||||
"""
|
"""
|
||||||
detail = BomService.get_bom_detail(bom_no)
|
detail = BomService.get_bom_detail(bom_no)
|
||||||
if not detail:
|
if not detail or not detail.get('children'):
|
||||||
return None
|
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']:
|
for child in detail['children']:
|
||||||
# 1. 查询该子件的总库存
|
base_id = child['child_id']
|
||||||
stock_qty = db.session.query(
|
stat = stock_map.get(base_id, {'qty': 0, 'loc': ''})
|
||||||
func.coalesce(func.sum(StockBuy.available_quantity), 0)
|
|
||||||
).filter(
|
|
||||||
StockBuy.base_id == child['child_id']
|
|
||||||
).scalar() or 0
|
|
||||||
|
|
||||||
# 2. ★ 查询该子件涉及的所有库位,并去重拼接 (PostgreSQL 使用 string_agg)
|
stock_qty = float(stat['qty'])
|
||||||
# 注意:这里假设主要是 stock_buy 表,如果是成品或半成品也需要做类似 Union 查询
|
dosage = float(child['dosage']) if child.get('dosage') else 0
|
||||||
# 为简化,这里演示只查 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()
|
|
||||||
|
|
||||||
child['current_stock'] = float(stock_qty)
|
child['current_stock'] = stock_qty
|
||||||
child['warehouse_location'] = locations or '' # 返回给前端
|
child['warehouse_location'] = stat['loc']
|
||||||
|
|
||||||
dosage = child['dosage']
|
|
||||||
child['max_producible'] = int(stock_qty // dosage) if dosage > 0 else 0
|
child['max_producible'] = int(stock_qty // dosage) if dosage > 0 else 0
|
||||||
|
|
||||||
return detail
|
return detail
|
||||||
@ -306,6 +430,11 @@ class BomService:
|
|||||||
)
|
)
|
||||||
db.session.add(bom)
|
db.session.add(bom)
|
||||||
db.session.commit()
|
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
|
return True
|
||||||
|
|
||||||
@staticmethod
|
@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']
|
material.is_enabled = str(raw_enabled).lower() in ['1', 'true', 'yes', 't']
|
||||||
|
|
||||||
db.session.commit()
|
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
|
return material
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import uuid # .material -> .base refactor checked
|
import uuid # .material -> .base refactor checked
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from sqlalchemy import or_, func, desc, and_
|
from sqlalchemy import or_, func, desc, and_
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models.outbound import TransOutbound, OutboundApproval
|
from app.models.outbound import TransOutbound, OutboundApproval
|
||||||
|
|
||||||
@ -475,6 +476,37 @@ class OutboundService:
|
|||||||
'stock_product': StockProduct
|
'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:
|
for d in details:
|
||||||
ono = d.outbound_no
|
ono = d.outbound_no
|
||||||
if ono not in grouped_map:
|
if ono not in grouped_map:
|
||||||
@ -490,34 +522,20 @@ class OutboundService:
|
|||||||
'items': []
|
'items': []
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- 查询物品详细信息 (名称, 规格, 类型, 类别, 批号/SN) ---
|
# --- 直接从内存字典中获取,O(1) 复杂度,绝对不触发 SQL ---
|
||||||
item_name = "未知物品"
|
item_name, item_spec, item_cat, item_type, batch_sn = "未知物品", "", "", "", "-"
|
||||||
item_spec = ""
|
|
||||||
item_cat = ""
|
stock_item = preloaded_stocks.get((d.source_table, d.stock_id))
|
||||||
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:
|
if stock_item:
|
||||||
# 获取批号/序列号用于追溯
|
|
||||||
batch_sn = getattr(stock_item, 'batch_number', None) or getattr(stock_item, 'serial_number', None) or '-'
|
batch_sn = getattr(stock_item, 'batch_number', None) or getattr(stock_item, 'serial_number', None) or '-'
|
||||||
|
|
||||||
|
# 因为前面用了 joinedload,这里调用 .base 瞬间返回,不会去查数据库
|
||||||
if stock_item.base:
|
if stock_item.base:
|
||||||
item_name = stock_item.base.name
|
item_name = stock_item.base.name
|
||||||
item_spec = stock_item.base.spec_model
|
item_spec = stock_item.base.spec_model
|
||||||
item_cat = stock_item.base.category
|
item_cat = stock_item.base.category
|
||||||
item_type = stock_item.base.material_type
|
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}")
|
|
||||||
|
|
||||||
# 计算金额
|
# 计算金额
|
||||||
price = float(d.unit_price) if d.unit_price else 0
|
price = float(d.unit_price) if d.unit_price else 0
|
||||||
|
|||||||
@ -176,7 +176,28 @@ class PermissionService:
|
|||||||
db.session.flush() # 获取新插入的 ID
|
db.session.flush() # 获取新插入的 ID
|
||||||
print(f"✅ 审计日志菜单已创建 (code: {menu_code})")
|
print(f"✅ 审计日志菜单已创建 (code: {menu_code})")
|
||||||
else:
|
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. 为超级管理员赋予审计日志菜单权限
|
# 2. 为超级管理员赋予审计日志菜单权限
|
||||||
role_code = 'SUPER_ADMIN'
|
role_code = 'SUPER_ADMIN'
|
||||||
@ -230,7 +251,14 @@ class PermissionService:
|
|||||||
db.session.flush()
|
db.session.flush()
|
||||||
print(f"✅ 盘点管理顶级菜单已创建")
|
print(f"✅ 盘点管理顶级菜单已创建")
|
||||||
else:
|
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. 创建子菜单:盲盘作业
|
# 2. 创建子菜单:盲盘作业
|
||||||
stocktake_op_code = 'inventory_stocktake'
|
stocktake_op_code = 'inventory_stocktake'
|
||||||
@ -248,7 +276,13 @@ class PermissionService:
|
|||||||
db.session.flush()
|
db.session.flush()
|
||||||
print(f"✅ 盲盘作业菜单已创建")
|
print(f"✅ 盲盘作业菜单已创建")
|
||||||
else:
|
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. 为盲盘作业添加操作权限元素
|
# 3. 为盲盘作业添加操作权限元素
|
||||||
stocktake_op_element = SysElement.query.filter_by(
|
stocktake_op_element = SysElement.query.filter_by(
|
||||||
@ -265,7 +299,11 @@ class PermissionService:
|
|||||||
db.session.add(stocktake_op_element)
|
db.session.add(stocktake_op_element)
|
||||||
print(f"✅ 盲盘作业操作权限已创建")
|
print(f"✅ 盲盘作业操作权限已创建")
|
||||||
else:
|
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. 创建子菜单:盈亏调整
|
# 4. 创建子菜单:盈亏调整
|
||||||
adjustment_code = 'stock_adjustment'
|
adjustment_code = 'stock_adjustment'
|
||||||
@ -283,7 +321,13 @@ class PermissionService:
|
|||||||
db.session.flush()
|
db.session.flush()
|
||||||
print(f"✅ 盈亏调整菜单已创建")
|
print(f"✅ 盈亏调整菜单已创建")
|
||||||
else:
|
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)
|
# 5. 为盈亏调整添加列表权限元素 (stock_adjustment:list)
|
||||||
adjustment_list_element = SysElement.query.filter_by(
|
adjustment_list_element = SysElement.query.filter_by(
|
||||||
@ -300,7 +344,11 @@ class PermissionService:
|
|||||||
db.session.add(adjustment_list_element)
|
db.session.add(adjustment_list_element)
|
||||||
print(f"✅ 盈亏调整列表权限已创建")
|
print(f"✅ 盈亏调整列表权限已创建")
|
||||||
else:
|
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)
|
# 6. 为盈亏调整添加操作权限元素 (stock_adjustment:operation)
|
||||||
adjustment_op_element = SysElement.query.filter_by(
|
adjustment_op_element = SysElement.query.filter_by(
|
||||||
@ -317,7 +365,11 @@ class PermissionService:
|
|||||||
db.session.add(adjustment_op_element)
|
db.session.add(adjustment_op_element)
|
||||||
print(f"✅ 盈亏调整操作权限已创建")
|
print(f"✅ 盈亏调整操作权限已创建")
|
||||||
else:
|
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. 为超级管理员分配所有盘点相关权限
|
# 7. 为超级管理员分配所有盘点相关权限
|
||||||
menu_codes = [stocktake_mgmt_code, stocktake_op_code, adjustment_code]
|
menu_codes = [stocktake_mgmt_code, stocktake_op_code, adjustment_code]
|
||||||
@ -491,13 +543,19 @@ class PermissionService:
|
|||||||
db.session.delete(e)
|
db.session.delete(e)
|
||||||
db.session.delete(dup)
|
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()
|
child_menus = SysMenu.query.filter(SysMenu.code.in_(child_codes)).all()
|
||||||
for m in child_menus:
|
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
|
menu_map = {} # code -> menu obj
|
||||||
|
|
||||||
for code, name, path, parent_code, sort_order in menu_defs:
|
for code, name, path, parent_code, sort_order in menu_defs:
|
||||||
@ -508,21 +566,38 @@ class PermissionService:
|
|||||||
db.session.flush()
|
db.session.flush()
|
||||||
print(f"✅ 菜单已创建: {name} ({code})")
|
print(f"✅ 菜单已创建: {name} ({code})")
|
||||||
else:
|
else:
|
||||||
# 更新已有菜单的属性
|
# ★★★ 字段级 Dirty Check:逐字段比较,仅在值真正变化时赋值
|
||||||
|
is_dirty = False
|
||||||
|
|
||||||
|
if menu.name != name:
|
||||||
menu.name = name
|
menu.name = name
|
||||||
|
is_dirty = True
|
||||||
|
if menu.path != path:
|
||||||
menu.path = path
|
menu.path = path
|
||||||
|
is_dirty = True
|
||||||
|
if menu.sort_order != sort_order:
|
||||||
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
|
menu_map[code] = menu
|
||||||
|
|
||||||
# 设置 parent_id
|
# 第五步:设置 parent_id(带 Dirty Check,只在值真正变化时更新)
|
||||||
for code, name, path, parent_code, sort_order in menu_defs:
|
for code, name, path, parent_code, sort_order in menu_defs:
|
||||||
if parent_code and parent_code in menu_map:
|
if parent_code and parent_code in menu_map:
|
||||||
menu = menu_map[code]
|
menu = menu_map[code]
|
||||||
parent = menu_map[parent_code]
|
parent = menu_map[parent_code]
|
||||||
|
# 只有 parent_id 实际变化了才赋值,避免重复触发 UPDATE
|
||||||
|
if menu.parent_id != parent.id:
|
||||||
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:
|
for code, name, path, parent_code, sort_order in menu_defs:
|
||||||
if parent_code is None: # 只分配顶级菜单
|
if parent_code is None: # 只分配顶级菜单
|
||||||
existing_perm = SysRolePermission.query.filter_by(
|
existing_perm = SysRolePermission.query.filter_by(
|
||||||
|
|||||||
@ -114,12 +114,12 @@ class TransService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def process_return(data, operator_name):
|
def process_return(data, operator_name):
|
||||||
"""
|
"""
|
||||||
还库逻辑(支持部分归还):
|
还库逻辑(支持部分归还)- 已优化,消除 N+1 和长事务死锁风险
|
||||||
1. 校验本次归还数量不能大于待还数量
|
四步走策略:
|
||||||
2. 恢复可用库存(按本次归还数量)
|
1. 收集所有 borrow_id
|
||||||
3. 更新库位 (如果有变动)
|
2. 批量锁定借用记录
|
||||||
4. 记录库管签字
|
3. 收集库存ID并批量锁定库存
|
||||||
5. 更新归还数量和状态(部分归还/全部归还)
|
4. 内存中完成业务逻辑
|
||||||
"""
|
"""
|
||||||
items = data.get('items', [])
|
items = data.get('items', [])
|
||||||
signature = data.get('signature_path') # 库管签字
|
signature = data.get('signature_path') # 库管签字
|
||||||
@ -130,15 +130,60 @@ class TransService:
|
|||||||
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
|
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# ==========================================
|
||||||
|
# ★ 优化步骤 1:收集所有 borrow_id
|
||||||
|
# ==========================================
|
||||||
|
borrow_ids = []
|
||||||
|
item_map = {} # 存储原始 item 数据,key=borrow_id
|
||||||
for item in items:
|
for item in items:
|
||||||
borrow_id = item.get('id')
|
borrow_id = item.get('id')
|
||||||
# 前端传入的本次归还数量
|
if borrow_id:
|
||||||
return_qty = float(item.get('return_qty', 0))
|
borrow_ids.append(borrow_id)
|
||||||
# 前端如果没有填 return_location,应该在提交前处理好,或者这里做 fallback
|
item_map[borrow_id] = {
|
||||||
# 这里假设前端传来的 return_location 就是最终要保存的库位
|
'return_qty': float(item.get('return_qty', 0)),
|
||||||
final_location = item.get('return_location')
|
'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:
|
if not record:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@ -153,22 +198,19 @@ class TransService:
|
|||||||
if return_qty > pending_qty:
|
if return_qty > pending_qty:
|
||||||
raise ValueError(f"本次归还数量({return_qty})不能大于待还数量({pending_qty})")
|
raise ValueError(f"本次归还数量({return_qty})不能大于待还数量({pending_qty})")
|
||||||
|
|
||||||
ModelClass = model_map.get(record.source_table)
|
# 更新库存
|
||||||
if ModelClass:
|
stock = stock_map.get((record.source_table, record.stock_id))
|
||||||
stock = ModelClass.query.with_for_update().get(record.stock_id)
|
|
||||||
if stock:
|
if stock:
|
||||||
# 1. 恢复可用库存(按本次归还数量)
|
# 恢复可用库存
|
||||||
stock.available_quantity = float(stock.available_quantity) + return_qty
|
stock.available_quantity = float(stock.available_quantity) + return_qty
|
||||||
|
# 更新库位
|
||||||
# 2. 更新库位 (如果提供了有效值)
|
|
||||||
if final_location:
|
if final_location:
|
||||||
stock.warehouse_location = final_location
|
stock.warehouse_location = final_location
|
||||||
|
|
||||||
# 3. 更新归还数量
|
# 更新归还数量和状态
|
||||||
new_returned_qty = returned_qty + return_qty
|
new_returned_qty = returned_qty + return_qty
|
||||||
record.returned_quantity = new_returned_qty
|
record.returned_quantity = new_returned_qty
|
||||||
|
|
||||||
# 4. 更新状态
|
|
||||||
if new_returned_qty >= total_qty:
|
if new_returned_qty >= total_qty:
|
||||||
record.is_returned = True
|
record.is_returned = True
|
||||||
record.status = 'returned'
|
record.status = 'returned'
|
||||||
|
|||||||
@ -5,6 +5,43 @@ from flask import jsonify, g, request, current_app, has_request_context
|
|||||||
import logging
|
import logging
|
||||||
import json
|
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():
|
def _verify_token_in_redis():
|
||||||
"""
|
"""
|
||||||
验证当前 Token 是否与 Redis 中存储的 Token 一致(单设备登录互踢)
|
验证当前 Token 是否与 Redis 中存储的 Token 一致(单设备登录互踢)
|
||||||
@ -67,7 +104,10 @@ def role_required(*roles):
|
|||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
def login_required(fn):
|
def login_required(fn):
|
||||||
"""验证 JWT 令牌是否存在且有效"""
|
"""
|
||||||
|
验证 JWT 令牌是否存在且有效,并检查用户是否仍在数据库中且未被禁用。
|
||||||
|
双重防护:1) Token 签名验证 2) 数据库用户存在性 3) Redis 单设备互踢
|
||||||
|
"""
|
||||||
@wraps(fn)
|
@wraps(fn)
|
||||||
def decorator(*args, **kwargs):
|
def decorator(*args, **kwargs):
|
||||||
try:
|
try:
|
||||||
@ -76,6 +116,10 @@ def login_required(fn):
|
|||||||
logging.warning(f"JWT verification failed: {e}")
|
logging.warning(f"JWT verification failed: {e}")
|
||||||
return jsonify(msg='登录已过期,请重新登录'), 401
|
return jsonify(msg='登录已过期,请重新登录'), 401
|
||||||
|
|
||||||
|
# ★ 幽灵令牌漏洞修复:检查用户是否已从数据库删除或被禁用
|
||||||
|
if not _verify_user_active():
|
||||||
|
return jsonify(msg='账号已失效(已删除或已禁用),请重新登录'), 401
|
||||||
|
|
||||||
if not _verify_token_in_redis():
|
if not _verify_token_in_redis():
|
||||||
return _raise_token_mismatch_error()
|
return _raise_token_mismatch_error()
|
||||||
|
|
||||||
@ -83,7 +127,7 @@ def login_required(fn):
|
|||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
def permission_required(permission_code):
|
def permission_required(permission_code):
|
||||||
"""检查当前用户是否拥有指定权限码"""
|
"""检查当前用户是否拥有指定权限码,同时检查用户是否仍然有效"""
|
||||||
def wrapper(fn):
|
def wrapper(fn):
|
||||||
@wraps(fn)
|
@wraps(fn)
|
||||||
def decorator(*args, **kwargs):
|
def decorator(*args, **kwargs):
|
||||||
@ -93,6 +137,10 @@ def permission_required(permission_code):
|
|||||||
logging.warning(f"JWT verification failed: {e}")
|
logging.warning(f"JWT verification failed: {e}")
|
||||||
return jsonify(msg='登录已过期,请重新登录'), 401
|
return jsonify(msg='登录已过期,请重新登录'), 401
|
||||||
|
|
||||||
|
# ★ 幽灵令牌漏洞修复:检查用户是否已从数据库删除或被禁用
|
||||||
|
if not _verify_user_active():
|
||||||
|
return jsonify(msg='账号已失效(已删除或已禁用),请重新登录'), 401
|
||||||
|
|
||||||
if not _verify_token_in_redis():
|
if not _verify_token_in_redis():
|
||||||
return _raise_token_mismatch_error()
|
return _raise_token_mismatch_error()
|
||||||
|
|
||||||
|
|||||||
@ -10,56 +10,85 @@
|
|||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
<script>
|
<script>
|
||||||
|
// 获取当前用户的登录凭证 (Token)
|
||||||
|
var currentToken = localStorage.getItem('access_token') || localStorage.getItem('token') || '';
|
||||||
|
|
||||||
window.difyChatbotConfig = {
|
window.difyChatbotConfig = {
|
||||||
token: 'Zp6B44AgCUPKprFG',
|
token: '6T0eTgukUEqzK0iW',
|
||||||
baseUrl: 'http://172.16.0.198:8080',
|
baseUrl: 'http://172.16.0.198:8080',
|
||||||
inputs: {},
|
inputs: {
|
||||||
|
"user_token": currentToken
|
||||||
|
},
|
||||||
systemVariables: {},
|
systemVariables: {},
|
||||||
userVariables: {},
|
userVariables: {},
|
||||||
};
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script
|
<script
|
||||||
src="http://172.16.0.198:8080/embed.min.js"
|
src="http://172.16.0.198:8080/embed.min.js"
|
||||||
id="Zp6B44AgCUPKprFG"
|
id="6T0eTgukUEqzK0iW"
|
||||||
defer>
|
defer>
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
#dify-chatbot-bubble-button {
|
#dify-chatbot-bubble-button {
|
||||||
background-color: #409EFF !important;
|
background-color: #409EFF !important;
|
||||||
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.4) !important;
|
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.4) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 变成"独立悬浮窗口" */
|
||||||
#dify-chatbot-bubble-window {
|
#dify-chatbot-bubble-window {
|
||||||
width: 28rem !important;
|
/* 👇 解除原本锁定在右下角的限制,将其定位在屏幕中间偏左上 */
|
||||||
height: 42rem !important;
|
top: 15vh !important;
|
||||||
border-radius: 12px !important;
|
left: 20vw !important;
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12) !important;
|
bottom: auto !important;
|
||||||
}
|
right: auto !important;
|
||||||
</style>
|
|
||||||
|
|
||||||
<script>
|
/* 设置初始宽高为半个屏幕左右 */
|
||||||
// 等待页面加载完毕
|
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('DOMContentLoaded', function() {
|
||||||
// 给整个网页添加点击监听器
|
|
||||||
document.addEventListener('click', function(event) {
|
document.addEventListener('click', function(event) {
|
||||||
// 获取 Dify 的聊天窗口和按钮元素
|
|
||||||
var bubbleWindow = document.getElementById('dify-chatbot-bubble-window');
|
var bubbleWindow = document.getElementById('dify-chatbot-bubble-window');
|
||||||
var bubbleButton = document.getElementById('dify-chatbot-bubble-button');
|
var bubbleButton = document.getElementById('dify-chatbot-bubble-button');
|
||||||
|
|
||||||
if (bubbleWindow && bubbleButton) {
|
if (bubbleWindow && bubbleButton) {
|
||||||
// 判断窗口当前是否处于打开状态 (不为 none 说明是打开的)
|
|
||||||
var isWindowOpen = window.getComputedStyle(bubbleWindow).display !== 'none';
|
var isWindowOpen = window.getComputedStyle(bubbleWindow).display !== 'none';
|
||||||
|
|
||||||
// 如果窗口是打开的,并且点击的位置既不在窗口内,也不在按钮上
|
|
||||||
if (isWindowOpen && !bubbleWindow.contains(event.target) && !bubbleButton.contains(event.target)) {
|
if (isWindowOpen && !bubbleWindow.contains(event.target) && !bubbleButton.contains(event.target)) {
|
||||||
// 模拟点击按钮,关闭窗口
|
|
||||||
bubbleButton.click();
|
bubbleButton.click();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -234,7 +234,7 @@ const handleLogout = () => {
|
|||||||
<footer v-if="!isLoginPage" class="app-footer">
|
<footer v-if="!isLoginPage" class="app-footer">
|
||||||
<span class="version-tag">
|
<span class="version-tag">
|
||||||
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
|
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
|
||||||
当前版本:V3.23(添加AI助手版)
|
当前版本:V3.26(添加AI助手版)
|
||||||
</span>
|
</span>
|
||||||
</footer>
|
</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({
|
return request({
|
||||||
url: '/v1/inbound/summary/export',
|
url: '/v1/export/inventory',
|
||||||
method: 'get',
|
method: 'post',
|
||||||
params,
|
data: filters
|
||||||
responseType: 'blob'
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轮询导出任务状态
|
||||||
|
* 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'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -432,7 +432,7 @@
|
|||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { Printer, Search, Plus, Download, List } from '@element-plus/icons-vue'
|
import { Printer, Search, Plus, Download, List } from '@element-plus/icons-vue'
|
||||||
import { ElMessage, ElTable, ElMessageBox } from 'element-plus'
|
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 { getBomList, getBomDetail } from '@/api/bom'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { submitOutboundRequest } from '@/api/outbound'
|
import { submitOutboundRequest } from '@/api/outbound'
|
||||||
@ -467,7 +467,6 @@ const requestApproverId = ref<number | null>(null)
|
|||||||
const approvers = ref<any[]>([])
|
const approvers = ref<any[]>([])
|
||||||
const requestSubmitting = ref(false)
|
const requestSubmitting = ref(false)
|
||||||
|
|
||||||
const allStockData = ref<any[]>([])
|
|
||||||
const stockList = ref<any[]>([]) // 服务端分页数据
|
const stockList = ref<any[]>([]) // 服务端分页数据
|
||||||
const stockTotal = ref(0)
|
const stockTotal = ref(0)
|
||||||
const stockPage = ref(1)
|
const stockPage = ref(1)
|
||||||
@ -525,43 +524,35 @@ const totalExportCount = computed(() => {
|
|||||||
return validSelectedItems.value.reduce((sum, item) => sum + (item.export_quantity || 0), 0)
|
return validSelectedItems.value.reduce((sum, item) => sum + (item.export_quantity || 0), 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- BOM 齐套性分析计算属性 ---
|
// --- BOM 齐套性分析计算属性(使用后端已计算的 current_stock,O(N),无嵌套循环)---
|
||||||
const maxBuildableSets = computed(() => {
|
const maxBuildableSets = computed(() => {
|
||||||
if (currentBomDetail.value.length === 0 || allStockData.value.length === 0) return 0
|
if (!currentBomDetail.value?.length) return 0
|
||||||
let minSets = Infinity
|
return currentBomDetail.value.reduce((minSets, bomItem: any) => {
|
||||||
currentBomDetail.value.forEach((bomItem: any) => {
|
const dosage = parseFloat(bomItem.dosage) || 0
|
||||||
const dosage = parseFloat(bomItem.dosage) || 0 // 单套需求量
|
if (dosage <= 0) return minSets
|
||||||
if (dosage <= 0) return
|
const stock = parseFloat(bomItem.current_stock) || 0
|
||||||
// 匹配库存中的可用数量
|
return Math.min(minSets, Math.floor(stock / dosage))
|
||||||
const stockItem = allStockData.value.find((s: any) => s.base_id && s.base_id == bomItem.child_id)
|
}, Infinity)
|
||||||
const available = stockItem ? (stockItem.availableCount || 0) : 0
|
|
||||||
const buildable = Math.floor(available / dosage)
|
|
||||||
if (buildable < minSets) minSets = buildable
|
|
||||||
})
|
|
||||||
return minSets === Infinity ? 0 : minSets
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const shortageList = computed(() => {
|
const shortageList = computed(() => {
|
||||||
if (currentBomDetail.value.length === 0 || allStockData.value.length === 0 || bomSets.value <= 0) return []
|
if (!currentBomDetail.value?.length || bomSets.value <= 0) return []
|
||||||
const target = bomSets.value
|
return currentBomDetail.value
|
||||||
const shortages: any[] = []
|
.map((bomItem: any) => {
|
||||||
currentBomDetail.value.forEach((bomItem: any) => {
|
const dosage = parseFloat(bomItem.dosage) || 0
|
||||||
const dosage = parseFloat(bomItem.dosage) || 0 // 单套需求量
|
const totalNeed = dosage * bomSets.value
|
||||||
const totalNeed = dosage * target
|
const stock = parseFloat(bomItem.current_stock) || 0
|
||||||
const stockItem = allStockData.value.find((s: any) => s.base_id && s.base_id == bomItem.child_id)
|
const shortage = Math.max(0, totalNeed - stock)
|
||||||
const available = stockItem ? (stockItem.availableCount || 0) : 0
|
return { ...bomItem, shortage, available: stock }
|
||||||
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
|
|
||||||
})
|
})
|
||||||
}
|
.filter((item: any) => item.shortage > 0)
|
||||||
})
|
.map((item: any) => ({
|
||||||
return shortages
|
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)
|
const hasShortage = computed(() => shortageList.value.length > 0 && bomSets.value > maxBuildableSets.value)
|
||||||
@ -576,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:手动添加库存 ---
|
// --- 核心逻辑 1:手动添加库存 ---
|
||||||
|
|
||||||
// 服务端加载库存列表
|
// 服务端加载库存列表
|
||||||
@ -633,8 +599,6 @@ const openManualSelect = async () => {
|
|||||||
stockPage.value = 1
|
stockPage.value = 1
|
||||||
searchKeyword.value = ''
|
searchKeyword.value = ''
|
||||||
await loadStockList()
|
await loadStockList()
|
||||||
await ensureAllStockLoaded()
|
|
||||||
allStockData.value.forEach(item => item.export_quantity = 0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 搜索框防抖触发服务端过滤
|
// 搜索框防抖触发服务端过滤
|
||||||
@ -739,7 +703,6 @@ const openBomSelect = async () => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error('加载 BOM 列表失败')
|
ElMessage.error('加载 BOM 列表失败')
|
||||||
}
|
}
|
||||||
await ensureAllStockLoaded()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听 BOM 选择变化,自动加载明细并计算齐套性
|
// 监听 BOM 选择变化,自动加载明细并计算齐套性
|
||||||
|
|||||||
@ -103,10 +103,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { getInboundSummaryList, exportInboundSummary } from '@/api/inbound/inbound_summary'
|
import { getInboundSummaryList, submitExportTask, checkExportStatus } from '@/api/inbound/inbound_summary'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElLoading } from 'element-plus'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
@ -153,45 +153,97 @@ const handleFilter = () => {
|
|||||||
fetchData()
|
fetchData()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 导出 Excel
|
// ============================================================
|
||||||
|
// 异步导出定时器(组件级别,需在组件销毁时强制清理)
|
||||||
|
// ============================================================
|
||||||
|
let exportTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
// 组件销毁前,强制清理"幽灵定时器"(防止用户切换路由后定时器仍在跑)
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (exportTimer) clearInterval(exportTimer)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 导出 Excel(后端异步轮询模式)
|
||||||
|
// ============================================================
|
||||||
const handleExport = () => {
|
const handleExport = () => {
|
||||||
|
// 防抖:已有任务在执行中直接跳过
|
||||||
|
if (exportLoading.value) return
|
||||||
exportLoading.value = true
|
exportLoading.value = true
|
||||||
const params = {
|
|
||||||
|
const filters = {
|
||||||
keyword: listQuery.keyword,
|
keyword: listQuery.keyword,
|
||||||
source_type: listQuery.source_type,
|
source_type: listQuery.source_type,
|
||||||
start_date: listQuery.dateRange ? listQuery.dateRange[0] : null,
|
start_date: listQuery.dateRange ? listQuery.dateRange[0] : null,
|
||||||
end_date: listQuery.dateRange ? listQuery.dateRange[1] : null
|
end_date: listQuery.dateRange ? listQuery.dateRange[1] : null
|
||||||
}
|
}
|
||||||
|
|
||||||
exportInboundSummary(params)
|
const loadingInstance = ElLoading.service({
|
||||||
.then((response: any) => {
|
text: '正在后台生成报表,请稍候...',
|
||||||
const blob = new Blob([response], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
|
background: 'rgba(0, 0, 0, 0.6)'
|
||||||
const url = window.URL.createObjectURL(blob)
|
})
|
||||||
|
|
||||||
|
submitExportTask(filters)
|
||||||
|
.then((res: any) => {
|
||||||
|
const taskId: string = res.data?.task_id
|
||||||
|
if (!taskId) {
|
||||||
|
loadingInstance.close()
|
||||||
|
exportLoading.value = false
|
||||||
|
ElMessage.error('任务提交失败,未获取到 task_id')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 赋值给外部变量,供 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')
|
const link = document.createElement('a')
|
||||||
link.href = url
|
link.href = downloadUrl
|
||||||
|
link.setAttribute('download', '')
|
||||||
const now = new Date()
|
link.style.display = 'none'
|
||||||
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`
|
|
||||||
|
|
||||||
link.setAttribute('download', filename)
|
|
||||||
document.body.appendChild(link)
|
document.body.appendChild(link)
|
||||||
link.click()
|
link.click()
|
||||||
document.body.removeChild(link)
|
document.body.removeChild(link)
|
||||||
window.URL.revokeObjectURL(url)
|
|
||||||
ElMessage.success('导出成功')
|
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) => {
|
.catch((err: any) => {
|
||||||
console.error('导出失败', err)
|
console.error('提交导出任务失败', err)
|
||||||
ElMessage.error('导出失败')
|
loadingInstance.close()
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
exportLoading.value = false
|
exportLoading.value = false
|
||||||
|
ElMessage.error('提交导出任务失败')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user