24 Commits

Author SHA1 Message Date
dxc
117bd003a9 版本变更V3.26体验优化 2026-05-19 12:31:54 +08:00
DXC
75705d31c9 fix: 物料修改后级联清除 BOM 树缓存,防止信息不一致 2026-05-19 11:40:43 +08:00
dxc
dd84ad828d 版本变更V3.25体验优化 2026-05-19 11:13:00 +08:00
DXC
7d02da2f5c fix: 所有 init_ 方法增加字段级 Dirty Check,相同值不赋值,防止 SQLAlchemy 触发 UPDATE 事件产生冗余审计日志 2026-05-19 11:10:41 +08:00
DXC
2a6e3979e8 fix: 审计监听器在非 HTTP 上下文的初始化操作(如 PermissionService)中直接跳过,避免产生大量 system 用户日志 2026-05-19 10:58:22 +08:00
DXC
e331236a6e fix: 为 handleExport 添加 onBeforeUnmount 幽灵定时器防护,并补充轮询失败时的兜底处理 2026-05-19 10:45:41 +08:00
DXC
e977ffc42d feat: 将入库汇总导出从本地 xlsx 重构为后端异步轮询模式(submitExportTask + checkExportStatus) 2026-05-19 10:41:21 +08:00
DXC
4d81056075 feat: 实现异步导出骨架(Threading + Redis 状态流转),支持 POST 提交/轮询状态/下载文件 2026-05-19 10:35:33 +08:00
DXC
6e1e1aa998 perf: 为库存三表/BOM/物料基础表补全高频查询列索引,防止全表扫描 2026-05-19 10:21:50 +08:00
DXC
c60112f5f8 perf: 引入 Redis Cache-Aside 模式优化 BOM 读取,TTL=12h,写操作后主动失效缓存 2026-05-19 10:14:55 +08:00
DXC
c0ab3ce6d2 perf: 消除 BOM 齐套分析的全量库存拉取和 O(N·M) 嵌套循环,改为使用后端返回的 current_stock 2026-05-19 10:07:05 +08:00
DXC
cf55c94826 feat: 库存接口增加 ai_mode=true 极简返回模式,键名压缩为 n/s/c 2026-05-19 09:53:06 +08:00
DXC
48651ffd01 perf: 消除出库列表和还库操作的 N+1 查询,改用批量 IN + joinedload 2026-05-19 09:49:30 +08:00
DXC
d60e1c5188 perf: 修复 get_bom_with_stock_by_bom_no N+1 查询问题,改为批量 IN + 内存字典匹配 2026-05-19 09:33:54 +08:00
DXC
79fccdc24c feat: Dify 聊天窗口升级为独立悬浮窗口(60vw×70vh,左上定位,拖拽缩放,白边手柄) 2026-05-18 17:18:23 +08:00
dxc
e67e965d8f 版本变更V3.24权限管理漏洞修复 2026-05-18 16:57:45 +08:00
DXC
3cb31c2b67 fix: 修复 JWT 幽灵令牌漏洞,新增 Dify 权限过滤服务 2026-05-18 16:16:50 +08:00
dxc
d1e49c343c 版本变更V3.23(添加AI助手版) 2026-05-18 14:18:29 +08:00
DXC
a625189375 feat: 全局接入 Dify 智能客服悬浮窗 2026-05-18 12:07:51 +08:00
DXC
ee893485bb feat: 推广粘贴上传功能至所有图片上传页面(purchase/buy/product/semi) 2026-05-15 14:29:25 +08:00
DXC
1ec1bc34eb feat: 新增 PC 端粘贴图片上传功能(hover 区域检测 + 网页图片链接自动填入) 2026-05-15 14:19:25 +08:00
dxc
f9dd8b6536 版本变更3.22 2026-05-15 13:03:13 +08:00
dxc
38c71147a2 版本变更3.21 2026-05-15 10:45:46 +08:00
DXC
d736d5d4a9 fix: 彻底移除 op_records 字段级权限过滤,所有字段对普通角色可见 2026-05-15 10:40:53 +08:00
34 changed files with 1826 additions and 270 deletions

View File

@ -234,6 +234,17 @@ def create_app():
except Exception as e:
print(f"❌ 错误: Scan 模块注册失败: {e}")
# -----------------------------------------------------
# 2.x 注册异步导出模块 (Export)
# -----------------------------------------------------
try:
from app.api.v1.export import export_bp
app.register_blueprint(export_bp, url_prefix='/api/v1/export')
app.register_blueprint(export_bp, url_prefix='/api/export', name='export_legacy')
print("✅ Export 模块注册成功")
except Exception as e:
print(f"❌ 错误: Export 模块注册失败: {e}")
# =========================================================
# 3. 预加载数据模型
# =========================================================

View File

@ -1,6 +1,6 @@
from flask import Blueprint, request, jsonify, current_app
from sqlalchemy import or_
from app.services.bom_service import BomService
from app.services.bom_service import BomService, _cache_delete
from app.models.base import MaterialBase
from app.models.bom import BomTable
from app.extensions import db
@ -225,6 +225,11 @@ def delete_bom(bom_no):
db.session.delete(rec)
db.session.commit()
# ===== 删除成功后立刻清除缓存Cache Invalidation =====
_cache_delete(bom_no, version)
current_app.logger.info(f"[BOM Cache] delete_bom → 缓存已失效 bom_no={bom_no} version={version}")
return jsonify({
'code': 200,
'msg': '删除成功',

View 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

View 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

View File

@ -137,34 +137,70 @@ def get_stock_info(uuid_or_barcode):
def get_all_stock():
"""
获取所有库存 > 0 的物品
支持 AI 极简模式: ?ai_mode=true
- 只返回 name / spec / availableQuantity 三个字段
- 键名压缩为 n / s / c
"""
ai_mode = request.args.get('ai_mode', '').lower() == 'true'
try:
all_items = []
# 1. 采购件
materials = []
if StockBuy:
materials = StockBuy.query.filter(StockBuy.stock_quantity > 0).all()
rows = StockBuy.query.filter(
StockBuy.stock_quantity > 0
).options(joinedload(StockBuy.base)).all()
for item in rows:
if ai_mode:
b = item.base
all_items.append({
'n': b.name if b else '',
's': b.spec_model if b else '',
'c': float(item.available_quantity or 0)
})
else:
all_items.append(item.to_dict())
# 2. 半成品
semis = []
if StockSemi:
try:
semis = StockSemi.query.filter(StockSemi.stock_quantity > 0).all()
rows = StockSemi.query.filter(
StockSemi.stock_quantity > 0
).options(joinedload(StockSemi.base)).all()
for item in rows:
if ai_mode:
b = item.base
all_items.append({
'n': b.name if b else '',
's': b.spec_model if b else '',
'c': float(item.available_quantity or 0)
})
else:
all_items.append(item.to_dict())
except Exception:
semis = []
pass
# 3. 成品
products = []
if StockProduct:
try:
products = StockProduct.query.filter(StockProduct.stock_quantity > 0).all()
rows = StockProduct.query.filter(
StockProduct.stock_quantity > 0
).options(joinedload(StockProduct.base)).all()
for item in rows:
if ai_mode:
b = item.base
all_items.append({
'n': b.name if b else '',
's': b.spec_model if b else '',
'c': float(item.available_quantity or 0)
})
else:
all_items.append(item.to_dict())
except Exception:
products = []
pass
return jsonify({
"materials": [item.to_dict() for item in materials],
"semis": [item.to_dict() for item in semis],
"products": [item.to_dict() for item in products]
}), 200
return jsonify(all_items), 200
except Exception as e:
print(f"Error: {e}")
return jsonify({"message": f"查询库存失败: {str(e)}"}), 500

View File

@ -35,16 +35,17 @@ def filter_item_by_permissions(item_dict, user_permissions, prefix='op_records')
"""
# 字段名到权限码的映射(与前端 permissionMap 保持一致)
field_to_perm = {
'borrow_no': f'{prefix}:borrow_no',
'borrower_name': f'{prefix}:borrower_name',
# 'sku': f'{prefix}:sku', # SKU 字段不再参与权限过滤,直接返回
'borrow_time': f'{prefix}:borrow_time',
'return_time': f'{prefix}:return_time',
'status': f'{prefix}:status',
'expected_return_time': f'{prefix}:expected_return_time',
'return_location': f'{prefix}:return_location',
'borrow_signature': f'{prefix}:borrow_signature',
'return_signature': f'{prefix}:return_signature',
# 'borrow_no': f'{prefix}:borrow_no',
# 'borrower_name': f'{prefix}:borrower_name',
# 'sku': f'{prefix}:sku',
# 'borrow_time': f'{prefix}:borrow_time',
# 'return_time': f'{prefix}:return_time',
# 'return_operator': f'{prefix}:return_operator',
# 'status': f'{prefix}:status',
# 'expected_return_time': f'{prefix}:expected_return_time',
# 'return_location': f'{prefix}:return_location',
# 'borrow_signature': f'{prefix}:borrow_signature',
# 'return_signature': f'{prefix}:return_signature',
}
# 如果用户是超级管理员且有 '*',则不过滤
if '*' in user_permissions:

View File

@ -41,7 +41,8 @@ def _is_audit_model(mapper):
'StockBuy', 'StockSemi', 'StockProduct', 'StockService',
'RepairRecord', 'TransOutbound', 'TransBorrow', 'TransReturn',
'BomTable', 'StockTake', 'StockAdjust',
'TransScrap', 'SysUser'
'TransScrap',
'SysUser', 'SysMenu', 'SysElement', 'SysRolePermission', # ★ 新增:系统管理三表纳入审计
}
return mapper.class_.__name__ in AUDIT_WHITELIST
@ -129,6 +130,13 @@ def _create_audit_log(session, mapper, target, action, details):
def before_update_listener(mapper, connection, target):
"""UPDATE 事件:抓取字段变更明细"""
if not _is_audit_model(mapper): return
# ★★★ 关键修复系统初始化PermissionService.init_all_menus 等)时,
# username='system' 且 has_request_context()=False
# 这类非用户发起的变更不应产生审计日志,直接跳过。
if not has_request_context():
return
try:
state = inspect(target)
changes = {}
@ -150,6 +158,8 @@ def before_update_listener(mapper, connection, target):
def before_delete_listener(mapper, connection, target):
"""DELETE 事件:抓取被删除对象的完整快照"""
if not _is_audit_model(mapper): return
# ★★★ 关键修复:非 HTTP 请求上下文下的初始化操作(如 PermissionService
if not has_request_context(): return
try:
state = inspect(target)
snap = {}
@ -164,6 +174,8 @@ def before_delete_listener(mapper, connection, target):
def after_insert_listener(mapper, connection, target):
"""INSERT 事件:抓取新增对象的完整快照"""
if not _is_audit_model(mapper): return
# ★★★ 关键修复:非 HTTP 请求上下文下的初始化操作(如 PermissionService
if not has_request_context(): return
try:
state = inspect(target)
snap = {}

View File

@ -2,6 +2,7 @@ from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_cors import CORS
from flask_jwt_extended import JWTManager # 确保引入了 JWTManager
from flask import current_app
from datetime import datetime, timezone, timedelta
import redis
@ -11,15 +12,78 @@ migrate = Migrate()
cors = CORS()
jwt = JWTManager() # 必须实例化
# Redis 客户端 (单设备登录互踢用)
# Redis 客户端 (单设备登录互踢 + JWT Token 黑名单用)
redis_client = None
# Redis Key 前缀
_JWT_BLOCKED_USER_PREFIX = "jwt_blocked_user:" # 存储被删除/禁用的 user_id
def beijing_time():
"""获取北京时间 (UTC+8),剥离时区信息以兼容数据库 naive DateTime 字段"""
return datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
# =============================================================================
# 全局 JWT Token 黑名单拦截器
# 原理Flask-JWT-Extended 在每次 @jwt_required() 验证时,
# 会自动触发 token_in_blocklist_loader 回调。
# 若该回调返回 True命中黑名单请求直接被 401 拒绝,后续代码不会执行。
# =============================================================================
@jwt.token_in_blocklist_loader
def check_if_token_is_revoked(jwt_header, jwt_payload):
"""
全局 JWT 黑名单检查:每次 @jwt_required() 调用时自动触发。
无论 AIDify还是人类用户调用的接口均受此拦截。
检查逻辑:
1. 通过 jwt_payload['sub']user_id查询 Redis 黑名单
2. 若 user_id 存在于黑名单 → 返回 True → 请求被 401 拒绝
3. 若 Redis 不可用fail-open→ 放行(不影响正常业务)
"""
user_id = jwt_payload.get('sub')
if user_id is None:
return False
global redis_client
if redis_client is None:
return False
try:
blocked_key = f"{_JWT_BLOCKED_USER_PREFIX}{user_id}"
is_blocked = redis_client.exists(blocked_key)
if is_blocked:
current_app.logger.warning(
f"🚫 JWT revoked for deleted/disabled user: user_id={user_id}"
)
return bool(is_blocked)
except Exception as e:
current_app.logger.error(f"JWT blocklist check error: {e}")
return False # Redis 出错时 fail-open不阻断正常业务
def revoke_all_tokens_for_user(user_id):
"""
将指定用户的 ID 加入 JWT 黑名单14 天)。
效果:该用户的所有已发放 Token无论是否过期瞬间失效。
由 delete_user() / update_user(status!='active') 时调用。
"""
global redis_client
if redis_client is None:
current_app.logger.warning(
f"Redis unavailable, cannot revoke tokens for user_id={user_id}"
)
return
try:
blocked_key = f"{_JWT_BLOCKED_USER_PREFIX}{user_id}"
ttl_seconds = 14 * 24 * 3600 # 14 天,与 Refresh Token 有效期对齐
redis_client.setex(blocked_key, ttl_seconds, "1")
current_app.logger.info(f"✅ User {user_id} added to JWT blocklist (TTL={ttl_seconds}s)")
except Exception as e:
current_app.logger.error(f"Failed to revoke tokens for user_id={user_id}: {e}")
# 2. 定义初始化函数 (供工厂函数 create_app 调用)
def init_extensions(app):
"""

View File

@ -14,11 +14,11 @@ class MaterialBase(db.Model):
id = db.Column(db.Integer, primary_key=True)
company_name = db.Column(db.String(255), comment='所属公司')
name = db.Column(db.String(255), nullable=False, comment='名称')
name = db.Column(db.String(255), nullable=False, index=True, comment='名称') # ★ 模糊搜索/精确定位高频列
common_name = db.Column(db.String(255), comment='俗名')
category = db.Column(db.String(100), comment='类别')
material_type = db.Column(db.String(100), comment='类型')
spec_model = db.Column(db.String(255), comment='规格型号')
category = db.Column(db.String(100), index=True, comment='类别') # ★ 分类统计/过滤高频列
material_type = db.Column(db.String(100), index=True, comment='类型') # ★ 类型分组/过滤高频列
spec_model = db.Column(db.String(255), index=True, comment='规格型号') # ★ 模糊搜索/精确匹配高频列
unit = db.Column(db.String(50), comment='计量单位')
# 可见等级

View File

@ -5,11 +5,11 @@ class BomTable(db.Model):
__tablename__ = 'bom_table'
id = db.Column(db.Integer, primary_key=True)
parent_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False)
child_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False)
parent_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False, index=True) # ★ 父子件关联高频列
child_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False, index=True) # ★ 子件过滤高频列
bom_no = db.Column(db.String(100), nullable=False, comment='BOM编号')
version = db.Column(db.String(50), nullable=False, default='V1.0', comment='版本')
bom_no = db.Column(db.String(100), nullable=False, index=True, comment='BOM编号') # ★ Redis 缓存 Key + 列表查询核心列
version = db.Column(db.String(50), nullable=False, default='V1.0', index=True, comment='版本') # ★ 配合 bom_no 做唯一性约束
dosage = db.Column(db.Numeric(19, 4), comment='个数')
loss_rate = db.Column(db.Numeric(5, 2), comment='损耗率%', default=0, nullable=True)

View File

@ -13,19 +13,19 @@ class StockBuy(db.Model):
__tablename__ = 'stock_buy'
id = db.Column(db.Integer, primary_key=True)
base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False)
base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False, index=True) # ★ 批量 IN 查询高频列
# 身份标识
sku = db.Column(db.String(100))
sku = db.Column(db.String(100), index=True) # ★ 条码/SKU 快速定位
in_date = db.Column(db.DateTime)
barcode = db.Column(db.String(100))
barcode = db.Column(db.String(100), index=True) # ★ 条码扫码查询高频列
serial_number = db.Column(db.String(100))
batch_number = db.Column(db.String(100))
# 状态
status = db.Column(db.String(50), default='在库')
status = db.Column(db.String(50), index=True) # ★ 在库/锁定 过滤条件
inspection_status = db.Column(db.String(50))
warehouse_location = db.Column(db.String(100))
warehouse_location = db.Column(db.String(100), index=True) # ★ 按库位分组/过滤
# 数量
in_quantity = db.Column(db.Numeric(19, 4), default=0)

View File

@ -12,12 +12,12 @@ class StockProduct(db.Model):
__tablename__ = 'stock_product'
id = db.Column(db.Integer, primary_key=True)
base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False)
base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False, index=True) # ★ 批量 IN 查询高频列
# 身份标识
sku = db.Column(db.String(100))
sku = db.Column(db.String(100), index=True) # ★ SKU 快速定位
production_date = db.Column(db.DateTime)
barcode = db.Column(db.String(100))
barcode = db.Column(db.String(100), index=True) # ★ 条码扫码查询高频列
serial_number = db.Column(db.String(100))
# 数量
@ -26,8 +26,8 @@ class StockProduct(db.Model):
available_quantity = db.Column(db.Numeric(19, 4), default=0)
# 状态与位置
status = db.Column(db.String(50))
warehouse_location = db.Column(db.String(100))
status = db.Column(db.String(50), index=True) # ★ 在库/锁定 过滤条件
warehouse_location = db.Column(db.String(100), index=True) # ★ 按库位分组/过滤
# 生产与成本
bom_code = db.Column('bom_id', db.String(100))

View File

@ -11,11 +11,11 @@ class StockSemi(db.Model):
__tablename__ = 'stock_semi'
id = db.Column(db.Integer, primary_key=True)
base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False)
base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False, index=True) # ★ 批量 IN 查询高频列
sku = db.Column(db.String(100))
sku = db.Column(db.String(100), index=True) # ★ SKU 快速定位
production_date = db.Column(db.DateTime)
barcode = db.Column(db.String(100))
barcode = db.Column(db.String(100), index=True) # ★ 条码扫码查询高频列
serial_number = db.Column(db.String(100))
batch_number = db.Column(db.String(100))
@ -25,8 +25,8 @@ class StockSemi(db.Model):
available_quantity = db.Column(db.Numeric(19, 4), default=0)
# 状态与位置
status = db.Column(db.String(50))
warehouse_location = db.Column(db.String(100))
status = db.Column(db.String(50), index=True) # ★ 在库/锁定 过滤条件
warehouse_location = db.Column(db.String(100), index=True) # ★ 按库位分组/过滤
# 半成品特有字段
bom_code = db.Column('bom_id', db.String(100))

View File

@ -1,6 +1,6 @@
# app/services/auth_service.py
from app.models.system import SysUser, SysRolePermission # <== 引入 SysRolePermission
from app.extensions import db, redis_client
from app.extensions import db, redis_client, revoke_all_tokens_for_user
from sqlalchemy import func
from flask_jwt_extended import create_access_token, create_refresh_token, get_jwt_identity
from flask import current_app
@ -334,7 +334,11 @@ class AuthService:
user.email = email
if 'status' in data:
user.status = data['status']
new_status = data['status']
# ★ 幽灵令牌漏洞修复:用户被禁用时,立即吊销其所有 Token
if new_status != 'active' and user.status == 'active':
revoke_all_tokens_for_user(user_id)
user.status = new_status
new_password = data.get('password')
if new_password and str(new_password).strip():
@ -353,7 +357,7 @@ class AuthService:
@staticmethod
def delete_user(user_id, operator_role):
"""删除用户"""
"""删除用户:删除前自动吊销该用户所有 JWT Token"""
# 标准化操作者角色为全大写
operator_role_upper = operator_role.upper() if operator_role else None
if operator_role_upper != UserRole.SUPER_ADMIN:
@ -365,6 +369,18 @@ class AuthService:
# 提前获取用户名用于审计日志
username = user.username
# ★ 幽灵令牌漏洞修复:删除用户前,先将 user_id 加入 JWT 黑名单
# 效果:该用户持有的所有 Token 瞬间失效,无论是否已过期
revoke_all_tokens_for_user(user_id)
# 清除 Redis 中的单设备登录 Token防止残留
if redis_client is not None:
try:
redis_client.delete(f"user_token_{user_id}")
except Exception as e:
current_app.logger.warning(f"Failed to delete user token from Redis: {e}")
db.session.delete(user)
db.session.commit()
return username

View File

@ -5,8 +5,96 @@ from app.models.inbound.buy import StockBuy
from sqlalchemy import func, distinct, or_, case
from collections import defaultdict
import uuid
import json
import logging
from datetime import datetime
logger = logging.getLogger(__name__)
# Redis 缓存键前缀 + TTL
BOM_CACHE_PREFIX = 'bom:tree'
BOM_CACHE_TTL = 43200 # 12小时
def _get_redis():
"""
获取 Redis 客户端实例,带容错保护。
若 extensions 中没有 redis_client 或连接失败,返回 None。
绝不抛出异常,确保业务不因此中断。
"""
try:
from app.extensions import redis_client
return redis_client
except Exception:
return None
def _cache_get(bom_no, version=None):
"""
从 Redis 读取 BOM 缓存。
键 = bom:tree:{bom_no} 或 bom:tree:{bom_no}:{version}
返回:反序列化后的 dict 或 None
"""
client = _get_redis()
if not client:
return None
key = f"{BOM_CACHE_PREFIX}:{bom_no}" + (f":{version}" if version else "")
try:
raw = client.get(key)
if raw:
logger.debug(f"[BOM Cache] HIT key={key}")
return json.loads(raw)
logger.debug(f"[BOM Cache] MISS key={key}")
return None
except Exception as e:
logger.warning(f"[BOM Cache] GET 失败,降级查库. key={key}, err={e}")
return None
def _cache_set(bom_no, version, data):
"""
将 BOM 数据写入 Redis设置 12 小时 TTL。
即使写入失败也只是打日志,不阻断业务流程。
"""
client = _get_redis()
if not client:
return
key = f"{BOM_CACHE_PREFIX}:{bom_no}" + (f":{version}" if version else "")
try:
client.setex(key, BOM_CACHE_TTL, json.dumps(data, ensure_ascii=False))
logger.debug(f"[BOM Cache] SET key={key} ttl={BOM_CACHE_TTL}s")
except Exception as e:
logger.warning(f"[BOM Cache] SET 失败,已忽略. key={key}, err={e}")
def _cache_delete(bom_no, version=None):
"""
删除 Redis 中指定 BOM 的缓存条目。
在写操作(增/改/删)成功后调用,确保后续读请求拿到最新数据。
"""
client = _get_redis()
if not client:
return
# 删除版本级缓存
if version:
key = f"{BOM_CACHE_PREFIX}:{bom_no}:{version}"
try:
client.delete(key)
logger.debug(f"[BOM Cache] DEL key={key}")
except Exception as e:
logger.warning(f"[BOM Cache] DEL 失败,已忽略. key={key}, err={e}")
# 同时删除"最新版"缓存(不带版本后缀),避免缓存不一致
key_latest = f"{BOM_CACHE_PREFIX}:{bom_no}"
try:
client.delete(key_latest)
logger.debug(f"[BOM Cache] DEL key={key_latest}")
except Exception as e:
logger.warning(f"[BOM Cache] DEL 失败,已忽略. key={key_latest}, err={e}")
class BomService:
# ====================== 新版 BOM 逻辑(基于 bom_no ======================
@ -121,8 +209,25 @@ class BomService:
@staticmethod
def get_bom_detail(bom_no, version=None):
"""
根据 bom_no (和 version) 获取配方详情
根据 bom_no (和 version) 获取配方详情
Cache-Aside 模式(三步走):
1. 先查 Redis有值直接返回Cache Hit
2. 无值或 Redis 报错查数据库Cache Miss → Fallback
3. 数据库查好后写入 RedisTTL=12h供下次命中
注意:查询"最新版"version=None缓存键不带版本后缀
写入时也写入不带版本的键,这样无需指定 version 就能命中。
"""
# ===== 第一步:尝试从 Redis 读取缓存 =====
cached = _cache_get(bom_no, version if version else None)
if cached is not None:
# Cache Hit直接返回缓存数据不再查库
logger.debug(f"[BOM] get_bom_detail bom_no={bom_no} version={version} → 命中缓存")
return cached
# ===== 第二步Cache Miss → 查数据库 =====
logger.debug(f"[BOM] get_bom_detail bom_no={bom_no} version={version} → 查询数据库")
query = db.session.query(
BomTable,
MaterialBase.name.label('child_name'),
@ -141,6 +246,8 @@ class BomService:
if not latest_ver:
return None
query = query.filter(BomTable.version == latest_ver)
# 记录本次实际查的版本,用于缓存键
version = latest_ver
rows = query.all()
if not rows:
@ -160,7 +267,7 @@ class BomService:
'remark': bom.remark or ''
})
return {
result = {
'bom_no': bom_no,
'version': first.BomTable.version,
'parent_id': parent_id,
@ -170,6 +277,11 @@ class BomService:
'children': children
}
# ===== 第三步:写入 Redis 缓存TTL=12h失败只打日志不阻断 =====
_cache_set(bom_no, version, result)
return result
@staticmethod
def save_bom(data):
"""保存 BOM (支持多版本),新增跨版本内容查重"""
@ -239,45 +351,57 @@ class BomService:
db.session.add(bom)
db.session.commit()
# ===== 写入后立刻清除缓存Cache Invalidation =====
# 确保后续 get_bom_detail 读取到最新数据,而不是 stale cache
_cache_delete(bom_no, version)
logger.info(f"[BOM Cache] save_bom → 缓存已失效 bom_no={bom_no} version={version}")
return bom_no
@staticmethod
def get_bom_with_stock_by_bom_no(bom_no):
"""
根据 bom_no 获取配方详情,并计算
1. 总可用库存
2. 最大可生产套数
3. ★ 聚合库位信息 (warehouse_locations)
根据 bom_no 获取配方详情,并计算(已修复 N+1 性能问题)
"""
detail = BomService.get_bom_detail(bom_no)
if not detail:
return None
if not detail or not detail.get('children'):
return detail
# 1. 提取所有子件的 ID 列表
child_ids = [child['child_id'] for child in detail['children']]
# 2. 用一条 IN 语句批量查出所有相关子件的库存和库位
stock_stats = db.session.query(
StockBuy.base_id,
func.coalesce(func.sum(StockBuy.available_quantity), 0).label('total_qty'),
func.string_agg(distinct(StockBuy.warehouse_location), ', ').label('locations')
).filter(
StockBuy.base_id.in_(child_ids),
StockBuy.available_quantity > 0
).group_by(
StockBuy.base_id
).all()
# 3. 将查询结果转换为字典 (Map),方便后续 O(1) 极速匹配
stock_map = {
stat.base_id: {
'qty': stat.total_qty,
'loc': stat.locations if stat.locations else ''
}
for stat in stock_stats
}
# 4. 遍历组装数据(纯内存操作,极快)
for child in detail['children']:
# 1. 查询该子件的总库存
stock_qty = db.session.query(
func.coalesce(func.sum(StockBuy.available_quantity), 0)
).filter(
StockBuy.base_id == child['child_id']
).scalar() or 0
base_id = child['child_id']
stat = stock_map.get(base_id, {'qty': 0, 'loc': ''})
# 2. ★ 查询该子件涉及的所有库位,并去重拼接 (PostgreSQL 使用 string_agg)
# 注意:这里假设主要是 stock_buy 表,如果是成品或半成品也需要做类似 Union 查询
# 为简化,这里演示只查 stock_buy 的库位
locations = db.session.query(
# 去除空值和重复值
func.string_agg(distinct(StockBuy.warehouse_location), ', ')
).filter(
StockBuy.base_id == child['child_id'],
StockBuy.available_quantity > 0, # 只看有货的库位
StockBuy.warehouse_location != None,
StockBuy.warehouse_location != ''
).scalar()
stock_qty = float(stat['qty'])
dosage = float(child['dosage']) if child.get('dosage') else 0
child['current_stock'] = float(stock_qty)
child['warehouse_location'] = locations or '' # 返回给前端
dosage = child['dosage']
child['current_stock'] = stock_qty
child['warehouse_location'] = stat['loc']
child['max_producible'] = int(stock_qty // dosage) if dosage > 0 else 0
return detail
@ -306,6 +430,11 @@ class BomService:
)
db.session.add(bom)
db.session.commit()
# ===== 写入后立刻清除缓存Cache Invalidation =====
_cache_delete(bom_no, version)
logger.info(f"[BOM Cache] create_or_update_bom → 缓存已失效 bom_no={bom_no} version={version}")
return True
@staticmethod

View 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
]

View 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_idUUID前端用此 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

View File

@ -593,6 +593,31 @@ class MaterialBaseService:
material.is_enabled = str(raw_enabled).lower() in ['1', 'true', 'yes', 't']
db.session.commit()
# ★★★ 级联缓存失效:物料信息变更后,清除所有涉及该物料的 BOM 树缓存
try:
from app.models.bom import BomTable
from app.extensions import redis_client
# 查出所有以该物料为父件或子件的 bom_no去重
affected = db.session.query(BomTable.bom_no).filter(
or_(BomTable.parent_id == m_id, BomTable.child_id == m_id)
).distinct().all()
affected_bom_nos = [r[0] for r in affected]
if affected_bom_nos:
for bom_no in affected_bom_nos:
# 清除最新版缓存
redis_client.delete(f'bom:tree:{bom_no}')
# 清除所有版本缓存(通配符)
for key in redis_client.scan_iter(f'bom:tree:{bom_no}:*'):
redis_client.delete(key)
print(f"🔁 物料 {m_id} 变更,已级联清除 {len(affected_bom_nos)} 个 BOM 缓存: {affected_bom_nos}")
except Exception as cache_err:
# Redis 报错不阻断业务返回
print(f"⚠️ BOM 缓存级联清除失败(不阻断业务): {cache_err}")
return material
except Exception as e:

View File

@ -1,6 +1,7 @@
import uuid # .material -> .base refactor checked
from datetime import datetime, timezone, timedelta
from sqlalchemy import or_, func, desc, and_
from sqlalchemy.orm import joinedload
from app.extensions import db
from app.models.outbound import TransOutbound, OutboundApproval
@ -475,6 +476,37 @@ class OutboundService:
'stock_product': StockProduct
}
# ==========================================
# ★ 优化步骤 1第一遍循环单纯收集所有的 stock_id
# ==========================================
stock_ids_by_table = {'stock_buy': set(), 'stock_semi': set(), 'stock_product': set()}
for d in details:
if d.source_table in stock_ids_by_table and d.stock_id:
stock_ids_by_table[d.source_table].add(d.stock_id)
# ==========================================
# ★ 优化步骤 2发起批量查询并强制 JOIN 基础物料表
# ==========================================
# 格式: { ('stock_buy', 101): stock_obj, ... }
preloaded_stocks = {}
for table_name, ids in stock_ids_by_table.items():
if not ids:
continue
ModelClass = model_map[table_name]
# 魔法在这里in_() 一次性查出所有库存joinedload 顺便把 base 表的数据一起拉回来
items = ModelClass.query.options(
joinedload(ModelClass.base)
).filter(ModelClass.id.in_(ids)).all()
for item in items:
preloaded_stocks[(table_name, item.id)] = item
# ==========================================
# ★ 优化步骤 3第二遍循环纯内存拼装极速
# ==========================================
for d in details:
ono = d.outbound_no
if ono not in grouped_map:
@ -490,34 +522,20 @@ class OutboundService:
'items': []
}
# --- 查询物品详细信息 (名称, 规格, 类型, 类别, 批号/SN) ---
item_name = "未知物品"
item_spec = ""
item_cat = ""
item_type = ""
batch_sn = "-"
# --- 直接从内存字典中获取O(1) 复杂度,绝对不触发 SQL ---
item_name, item_spec, item_cat, item_type, batch_sn = "未知物品", "", "", "", "-"
ModelClass = model_map.get(d.source_table)
if ModelClass and d.stock_id:
try:
stock_item = ModelClass.query.get(d.stock_id)
if stock_item:
# 获取批号/序列号用于追溯
batch_sn = getattr(stock_item, 'batch_number', None) or getattr(stock_item, 'serial_number', None) or '-'
if stock_item.base:
item_name = stock_item.base.name
item_spec = stock_item.base.spec_model
item_cat = stock_item.base.category
item_type = stock_item.base.material_type
elif stock_item and hasattr(stock_item, 'base_id') and stock_item.base_id:
base_info = MaterialBase.query.get(stock_item.base_id)
if base_info:
item_name = base_info.name
item_spec = base_info.spec_model
item_cat = base_info.category
item_type = base_info.material_type
except Exception as e:
print(f"Error fetching detail for stock_id {d.stock_id}: {e}")
stock_item = preloaded_stocks.get((d.source_table, d.stock_id))
if stock_item:
batch_sn = getattr(stock_item, 'batch_number', None) or getattr(stock_item, 'serial_number', None) or '-'
# 因为前面用了 joinedload这里调用 .base 瞬间返回,不会去查数据库
if stock_item.base:
item_name = stock_item.base.name
item_spec = stock_item.base.spec_model
item_cat = stock_item.base.category
item_type = stock_item.base.material_type
# 计算金额
price = float(d.unit_price) if d.unit_price else 0

View File

@ -176,7 +176,28 @@ class PermissionService:
db.session.flush() # 获取新插入的 ID
print(f"✅ 审计日志菜单已创建 (code: {menu_code})")
else:
print(f" 审计日志菜单已存在 (code: {menu_code})")
# ★★★ Dirty Check仅当字段真正变化时才 add避免触发 UPDATE 事件
is_dirty = False
if existing_menu.parent_id != 0:
existing_menu.parent_id = 0
is_dirty = True
if existing_menu.name != '审计日志':
existing_menu.name = '审计日志'
is_dirty = True
if existing_menu.path != '/system/audit':
existing_menu.path = '/system/audit'
is_dirty = True
if existing_menu.sort_order != 110:
existing_menu.sort_order = 110
is_dirty = True
if existing_menu.is_visible != True:
existing_menu.is_visible = True
is_dirty = True
if is_dirty:
db.session.add(existing_menu)
print(f"🔄 审计日志菜单已更新 (code: {menu_code})")
else:
print(f" 审计日志菜单已存在,无需更新 (code: {menu_code})")
# 2. 为超级管理员赋予审计日志菜单权限
role_code = 'SUPER_ADMIN'
@ -230,7 +251,14 @@ class PermissionService:
db.session.flush()
print(f"✅ 盘点管理顶级菜单已创建")
else:
print(f" 盘点管理顶级菜单已存在")
is_dirty = False
if stocktake_menu.parent_id != 0: stocktake_menu.parent_id = 0; is_dirty = True
if stocktake_menu.name != '盘点管理': stocktake_menu.name = '盘点管理'; is_dirty = True
if stocktake_menu.path != '/stocktake': stocktake_menu.path = '/stocktake'; is_dirty = True
if stocktake_menu.sort_order != 30: stocktake_menu.sort_order = 30; is_dirty = True
if stocktake_menu.is_visible != True: stocktake_menu.is_visible = True; is_dirty = True
if is_dirty: db.session.add(stocktake_menu); print(f"🔄 盘点管理顶级菜单已更新")
else: print(f" 盘点管理顶级菜单已存在")
# 2. 创建子菜单:盲盘作业
stocktake_op_code = 'inventory_stocktake'
@ -248,7 +276,13 @@ class PermissionService:
db.session.flush()
print(f"✅ 盲盘作业菜单已创建")
else:
print(f" 盲盘作业菜单已存在")
is_dirty = False
if stocktake_op_menu.name != '盲盘作业': stocktake_op_menu.name = '盲盘作业'; is_dirty = True
if stocktake_op_menu.path != '/stocktake/operation': stocktake_op_menu.path = '/stocktake/operation'; is_dirty = True
if stocktake_op_menu.sort_order != 1: stocktake_op_menu.sort_order = 1; is_dirty = True
if stocktake_op_menu.is_visible != True: stocktake_op_menu.is_visible = True; is_dirty = True
if is_dirty: db.session.add(stocktake_op_menu); print(f"🔄 盲盘作业菜单已更新")
else: print(f" 盲盘作业菜单已存在")
# 3. 为盲盘作业添加操作权限元素
stocktake_op_element = SysElement.query.filter_by(
@ -265,7 +299,11 @@ class PermissionService:
db.session.add(stocktake_op_element)
print(f"✅ 盲盘作业操作权限已创建")
else:
print(f" 盲盘作业操作权限已存在")
is_dirty = False
if stocktake_op_element.name != '盲盘操作': stocktake_op_element.name = '盲盘操作'; is_dirty = True
if stocktake_op_element.element_type != 'operation': stocktake_op_element.element_type = 'operation'; is_dirty = True
if is_dirty: db.session.add(stocktake_op_element); print(f"🔄 盲盘作业操作权限已更新")
else: print(f" 盲盘作业操作权限已存在")
# 4. 创建子菜单:盈亏调整
adjustment_code = 'stock_adjustment'
@ -283,7 +321,13 @@ class PermissionService:
db.session.flush()
print(f"✅ 盈亏调整菜单已创建")
else:
print(f" 盈亏调整菜单已存在")
is_dirty = False
if adjustment_menu.name != '盈亏调整': adjustment_menu.name = '盈亏调整'; is_dirty = True
if adjustment_menu.path != '/stocktake/adjustment': adjustment_menu.path = '/stocktake/adjustment'; is_dirty = True
if adjustment_menu.sort_order != 2: adjustment_menu.sort_order = 2; is_dirty = True
if adjustment_menu.is_visible != True: adjustment_menu.is_visible = True; is_dirty = True
if is_dirty: db.session.add(adjustment_menu); print(f"🔄 盈亏调整菜单已更新")
else: print(f" 盈亏调整菜单已存在")
# 5. 为盈亏调整添加列表权限元素 (stock_adjustment:list)
adjustment_list_element = SysElement.query.filter_by(
@ -300,7 +344,11 @@ class PermissionService:
db.session.add(adjustment_list_element)
print(f"✅ 盈亏调整列表权限已创建")
else:
print(f" 盈亏调整列表权限已存在")
is_dirty = False
if adjustment_list_element.name != '盈亏列表': adjustment_list_element.name = '盈亏列表'; is_dirty = True
if adjustment_list_element.element_type != 'element': adjustment_list_element.element_type = 'element'; is_dirty = True
if is_dirty: db.session.add(adjustment_list_element); print(f"🔄 盈亏调整列表权限已更新")
else: print(f" 盈亏调整列表权限已存在")
# 6. 为盈亏调整添加操作权限元素 (stock_adjustment:operation)
adjustment_op_element = SysElement.query.filter_by(
@ -317,7 +365,11 @@ class PermissionService:
db.session.add(adjustment_op_element)
print(f"✅ 盈亏调整操作权限已创建")
else:
print(f" 盈亏调整操作权限已存在")
is_dirty = False
if adjustment_op_element.name != '盈亏操作': adjustment_op_element.name = '盈亏操作'; is_dirty = True
if adjustment_op_element.element_type != 'operation': adjustment_op_element.element_type = 'operation'; is_dirty = True
if is_dirty: db.session.add(adjustment_op_element); print(f"🔄 盈亏调整操作权限已更新")
else: print(f" 盈亏调整操作权限已存在")
# 7. 为超级管理员分配所有盘点相关权限
menu_codes = [stocktake_mgmt_code, stocktake_op_code, adjustment_code]
@ -491,13 +543,19 @@ class PermissionService:
db.session.delete(e)
db.session.delete(dup)
# 第三步:强制重新设置所有子菜单 parent_id,确保没有遗漏
# 改为对象级更新以触发审计事件
# 第三步:仅当子菜单 parent_id 有误时才更新Dirty Check
# 遍历所有子菜单,只在 parent_id 需要修正时才触碰对象
child_codes = [m[0] for m in menu_defs if m[3] is not None]
child_menus = SysMenu.query.filter(SysMenu.code.in_(child_codes)).all()
for m in child_menus:
m.parent_id = None
# 只有 parent_id 为 0 或 None即没有正确挂载父菜单时才更新
if m.parent_id == 0 or m.parent_id is None:
m.parent_id = None # SQLAlchemy 设为 None 表示挂载到根parent_id=None
db.session.add(m)
print(f"🔧 修正子菜单 parent_id: {m.code} (parent_id → None)")
# 创建或更新菜单
# 第四步:创建或更新菜单(带字段级 Dirty Check
# 只有至少有一个字段真正变化了才 add避免 SQLAlchemy 产生 UPDATE 事件
menu_map = {} # code -> menu obj
for code, name, path, parent_code, sort_order in menu_defs:
@ -508,21 +566,38 @@ class PermissionService:
db.session.flush()
print(f"✅ 菜单已创建: {name} ({code})")
else:
# 更新已有菜单的属性
menu.name = name
menu.path = path
menu.sort_order = sort_order
# ★★★ 字段级 Dirty Check逐字段比较仅在值真正变化时赋值
is_dirty = False
if menu.name != name:
menu.name = name
is_dirty = True
if menu.path != path:
menu.path = path
is_dirty = True
if menu.sort_order != sort_order:
menu.sort_order = sort_order
is_dirty = True
# 只有至少一个字段变化了才 add触发 UPDATE
if is_dirty:
db.session.add(menu)
print(f"🔄 菜单已更新: {name} ({code})")
menu_map[code] = menu
# 设置 parent_id
# 第五步:设置 parent_id(带 Dirty Check只在值真正变化时更新
for code, name, path, parent_code, sort_order in menu_defs:
if parent_code and parent_code in menu_map:
menu = menu_map[code]
parent = menu_map[parent_code]
menu.parent_id = parent.id
# 只有 parent_id 实际变化了才赋值,避免重复触发 UPDATE
if menu.parent_id != parent.id:
menu.parent_id = parent.id
db.session.add(menu)
print(f"🔗 菜单 {code} 已挂载到父菜单 {parent_code} (id={parent.id})")
# 为超级管理员分配所有菜单权限
# 第六步:为超级管理员分配顶级菜单权限(只做 INSERT不触碰已存在的记录
for code, name, path, parent_code, sort_order in menu_defs:
if parent_code is None: # 只分配顶级菜单
existing_perm = SysRolePermission.query.filter_by(

View File

@ -114,12 +114,12 @@ class TransService:
@staticmethod
def process_return(data, operator_name):
"""
还库逻辑(支持部分归还)
1. 校验本次归还数量不能大于待还数量
2. 恢复可用库存(按本次归还数量)
3. 更新库位 (如果有变动)
4. 记录库管签字
5. 更新归还数量和状态(部分归还/全部归还)
还库逻辑(支持部分归还)- 已优化,消除 N+1 和长事务死锁风险
四步走策略:
1. 收集所有 borrow_id
2. 批量锁定借用记录
3. 收集库存ID并批量锁定库存
4. 内存中完成业务逻辑
"""
items = data.get('items', [])
signature = data.get('signature_path') # 库管签字
@ -130,15 +130,60 @@ class TransService:
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
try:
# ==========================================
# ★ 优化步骤 1收集所有 borrow_id
# ==========================================
borrow_ids = []
item_map = {} # 存储原始 item 数据key=borrow_id
for item in items:
borrow_id = item.get('id')
# 前端传入的本次归还数量
return_qty = float(item.get('return_qty', 0))
# 前端如果没有填 return_location应该在提交前处理好或者这里做 fallback
# 这里假设前端传来的 return_location 就是最终要保存的库位
final_location = item.get('return_location')
if borrow_id:
borrow_ids.append(borrow_id)
item_map[borrow_id] = {
'return_qty': float(item.get('return_qty', 0)),
'final_location': item.get('return_location')
}
record = TransBorrow.query.with_for_update().get(borrow_id)
if not borrow_ids:
raise ValueError("没有有效的归还记录")
# ==========================================
# ★ 优化步骤 2批量锁定借用记录
# ==========================================
borrow_records = TransBorrow.query.with_for_update().filter(
TransBorrow.id.in_(borrow_ids)
).all()
borrow_map = {r.id: r for r in borrow_records}
# ==========================================
# ★ 优化步骤 3收集库存ID并批量锁定库存
# ==========================================
stock_ids_by_table = {'stock_buy': set(), 'stock_semi': set(), 'stock_product': set()}
for borrow_id, record in borrow_map.items():
if record.source_table in stock_ids_by_table and record.stock_id:
stock_ids_by_table[record.source_table].add(record.stock_id)
stock_map = {} # 格式: { ('stock_buy', 101): stock_obj }
for table_name, ids in stock_ids_by_table.items():
if not ids:
continue
ModelClass = model_map[table_name]
stocks = ModelClass.query.with_for_update().filter(
ModelClass.id.in_(ids)
).all()
for stock in stocks:
stock_map[(table_name, stock.id)] = stock
# ==========================================
# ★ 优化步骤 4内存中完成业务逻辑
# ==========================================
for borrow_id, item_data in item_map.items():
return_qty = item_data['return_qty']
final_location = item_data['final_location']
record = borrow_map.get(borrow_id)
if not record:
continue
@ -153,22 +198,19 @@ class TransService:
if return_qty > pending_qty:
raise ValueError(f"本次归还数量({return_qty})不能大于待还数量({pending_qty})")
ModelClass = model_map.get(record.source_table)
if ModelClass:
stock = ModelClass.query.with_for_update().get(record.stock_id)
if stock:
# 1. 恢复可用库存(按本次归还数量)
stock.available_quantity = float(stock.available_quantity) + return_qty
# 更新库存
stock = stock_map.get((record.source_table, record.stock_id))
if stock:
# 恢复可用库存
stock.available_quantity = float(stock.available_quantity) + return_qty
# 更新库位
if final_location:
stock.warehouse_location = final_location
# 2. 更新库位 (如果提供了有效值)
if final_location:
stock.warehouse_location = final_location
# 3. 更新归还数量
# 更新归还数量和状态
new_returned_qty = returned_qty + return_qty
record.returned_quantity = new_returned_qty
# 4. 更新状态
if new_returned_qty >= total_qty:
record.is_returned = True
record.status = 'returned'

View File

@ -5,6 +5,43 @@ from flask import jsonify, g, request, current_app, has_request_context
import logging
import json
def _verify_user_active():
"""
JWT「幽灵令牌」安全漏洞修复
在 Token 签名验证通过之后,进一步检查用户在数据库中是否仍然存在且未被禁用。
调用时机login_required / permission_required 装饰器中,
在 verify_jwt_in_request() 成功之后立即调用。
返回 True → 用户正常,放行
返回 False → 用户已从数据库删除或被禁用,阻断请求
"""
try:
claims = get_jwt()
user_id = claims.get('sub')
if user_id is None:
return True
from app.models.system import SysUser
user = SysUser.query.get(user_id)
if user is None:
current_app.logger.warning(
f"🚫 [Ghost Token Blocked] user_id={user_id} not found in database (deleted account)"
)
return False
if user.status != 'active':
current_app.logger.warning(
f"🚫 [Token Blocked] user_id={user_id} status={user.status} (disabled account)"
)
return False
return True
except Exception as e:
current_app.logger.error(f"User active check error: {e}")
return True # 出错时 fail-open避免数据库故障导致全站不可用
def _verify_token_in_redis():
"""
验证当前 Token 是否与 Redis 中存储的 Token 一致(单设备登录互踢)
@ -67,7 +104,10 @@ def role_required(*roles):
return wrapper
def login_required(fn):
"""验证 JWT 令牌是否存在且有效"""
"""
验证 JWT 令牌是否存在且有效,并检查用户是否仍在数据库中且未被禁用。
双重防护1) Token 签名验证 2) 数据库用户存在性 3) Redis 单设备互踢
"""
@wraps(fn)
def decorator(*args, **kwargs):
try:
@ -76,6 +116,10 @@ def login_required(fn):
logging.warning(f"JWT verification failed: {e}")
return jsonify(msg='登录已过期,请重新登录'), 401
# ★ 幽灵令牌漏洞修复:检查用户是否已从数据库删除或被禁用
if not _verify_user_active():
return jsonify(msg='账号已失效(已删除或已禁用),请重新登录'), 401
if not _verify_token_in_redis():
return _raise_token_mismatch_error()
@ -83,7 +127,7 @@ def login_required(fn):
return decorator
def permission_required(permission_code):
"""检查当前用户是否拥有指定权限码"""
"""检查当前用户是否拥有指定权限码,同时检查用户是否仍然有效"""
def wrapper(fn):
@wraps(fn)
def decorator(*args, **kwargs):
@ -93,6 +137,10 @@ def permission_required(permission_code):
logging.warning(f"JWT verification failed: {e}")
return jsonify(msg='登录已过期,请重新登录'), 401
# ★ 幽灵令牌漏洞修复:检查用户是否已从数据库删除或被禁用
if not _verify_user_active():
return jsonify(msg='账号已失效(已删除或已禁用),请重新登录'), 401
if not _verify_token_in_redis():
return _raise_token_mismatch_error()

View File

@ -9,5 +9,86 @@
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<script>
// 获取当前用户的登录凭证 (Token)
var currentToken = localStorage.getItem('access_token') || localStorage.getItem('token') || '';
window.difyChatbotConfig = {
token: '6T0eTgukUEqzK0iW',
baseUrl: 'http://172.16.0.198:8080',
inputs: {
"user_token": currentToken
},
systemVariables: {},
userVariables: {},
}
</script>
<script
src="http://172.16.0.198:8080/embed.min.js"
id="6T0eTgukUEqzK0iW"
defer>
</script>
<style>
#dify-chatbot-bubble-button {
background-color: #409EFF !important;
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.4) !important;
}
/* 变成"独立悬浮窗口" */
#dify-chatbot-bubble-window {
/* 👇 解除原本锁定在右下角的限制,将其定位在屏幕中间偏左上 */
top: 15vh !important;
left: 20vw !important;
bottom: auto !important;
right: auto !important;
/* 设置初始宽高为半个屏幕左右 */
width: 60vw !important;
height: 70vh !important;
border-radius: 12px !important;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.2) !important; /* 增加超大弥散阴影,浮现感更强 */
/* 👇 开启右下角拖拽,并强制留出 16px 的白边给拖拽手柄 */
resize: both !important;
overflow: hidden !important;
padding-bottom: 16px !important;
padding-right: 16px !important;
background-color: #ffffff !important;
/* 极限尺寸防崩 */
min-width: 300px !important;
min-height: 400px !important;
max-width: 95vw !important;
max-height: 90vh !important;
}
/* 内层 iframe 填满剩余空间,加上圆角更好看 */
#dify-chatbot-bubble-window iframe {
width: 100% !important;
height: 100% !important;
border: none !important;
border-radius: 8px !important;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('click', function(event) {
var bubbleWindow = document.getElementById('dify-chatbot-bubble-window');
var bubbleButton = document.getElementById('dify-chatbot-bubble-button');
if (bubbleWindow && bubbleButton) {
var isWindowOpen = window.getComputedStyle(bubbleWindow).display !== 'none';
if (isWindowOpen && !bubbleWindow.contains(event.target) && !bubbleButton.contains(event.target)) {
bubbleButton.click();
}
}
});
});
</script>
</body>
</html>

View File

@ -234,7 +234,7 @@ const handleLogout = () => {
<footer v-if="!isLoginPage" class="app-footer">
<span class="version-tag">
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
当前版本:V3.20
当前版本:V3.26添加AI助手版
</span>
</footer>

View File

@ -17,11 +17,43 @@ export function getInboundSummaryList(params: InboundSummaryQuery) {
})
}
export function exportInboundSummary(params: any) {
// ============================================================
// 旧版:前端直接处理 Excel blob已废弃保留用于参考
// ============================================================
// export function exportInboundSummary(params: any) {
// return request({
// url: '/v1/inbound/summary/export',
// method: 'get',
// params,
// responseType: 'blob'
// })
// }
// ============================================================
// 新版:异步导出 API后端生成 + 轮询任务状态)
// ============================================================
/**
* 提交异步导出任务
* POST /api/v1/export/inventory
* 返回 { task_id: string }
*/
export function submitExportTask(filters: Record<string, any>) {
return request({
url: '/v1/inbound/summary/export',
method: 'get',
params,
responseType: 'blob'
url: '/v1/export/inventory',
method: 'post',
data: filters
})
}
/**
* 轮询导出任务状态
* GET /api/v1/export/status/<taskId>
* 返回 { status: 'processing'|'completed'|'failed', progress: number, url: string, error: string }
*/
export function checkExportStatus(taskId: string) {
return request({
url: `/v1/export/status/${taskId}`,
method: 'get'
})
}

View File

@ -0,0 +1,81 @@
import { onMounted, onUnmounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
export function usePasteUpload(
customUpload: Function,
targetField: string,
containerSelector: string,
onLinkFound?: (link: string, field: string) => void
) {
const isHovering = ref(false)
const handleMouseMove = (event: MouseEvent) => {
const target = event.target as Element
if (!target) return
const isInsideTarget = !!target.closest(containerSelector)
if (isInsideTarget !== isHovering.value) {
isHovering.value = isInsideTarget
}
}
const handleGlobalPaste = (event: ClipboardEvent) => {
if (!isHovering.value) return
const activeElement = document.activeElement
const activeTag = activeElement?.tagName.toLowerCase()
if (activeTag === 'input' || activeTag === 'textarea') return
const clipboardData = event.clipboardData
if (!clipboardData) return
// 1. 优先获取真实文件(本地截图/复制文件)
let imageFile: File | null = null
for (let i = 0; i < clipboardData.items.length; i++) {
if (clipboardData.items[i].type.indexOf('image') !== -1) {
const rawFile = clipboardData.items[i].getAsFile()
if (rawFile) {
const extension = rawFile.type.split('/')[1] || 'png'
const fileName = `paste_${targetField}_${new Date().getTime()}.${extension}`
imageFile = new File([rawFile], fileName, { type: rawFile.type })
break
}
}
}
if (imageFile) {
event.preventDefault()
customUpload({ file: imageFile, onSuccess: () => {}, onError: () => {} }, targetField)
return
}
// 2. 没有真实文件时,解析 HTML 或文本里的图片 URL网页复制场景
const htmlData = clipboardData.getData('text/html')
const textData = clipboardData.getData('text/plain')
let imgUrl = ''
if (htmlData) {
const match = htmlData.match(/<img[^>]+src="([^">]+)"/)
if (match && match[1]) imgUrl = match[1]
}
if (!imgUrl && textData && textData.startsWith('http')) {
imgUrl = textData
}
if (imgUrl && onLinkFound) {
event.preventDefault()
ElMessage.success('检测到网页图片,已自动填入外部链接')
onLinkFound(imgUrl, targetField)
} else if (!imgUrl) {
ElMessage.warning('剪贴板中未检测到有效图片或链接')
}
}
onMounted(() => {
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('paste', handleGlobalPaste)
})
onUnmounted(() => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('paste', handleGlobalPaste)
})
}

View File

@ -457,7 +457,7 @@
</el-row>
<el-form-item label="产品图" prop="generalImage" v-if="hasFieldPermission('files')">
<div class="upload-container">
<div class="upload-container" id="upload-generalImage">
<el-upload
v-model:file-list="fileListImage"
action="#"
@ -485,7 +485,7 @@
</el-form-item>
<el-form-item label="说明书" prop="generalManual" v-if="hasFieldPermission('files')">
<div class="upload-container">
<div class="upload-container" id="upload-generalManual">
<el-upload
v-model:file-list="fileListManual"
action="#"
@ -653,6 +653,7 @@ import {
markWarningOrdered
} from '@/api/material_base';
import { uploadFile, deleteFile } from '@/api/common/upload';
import { usePasteUpload } from '@/hooks/usePasteUpload';
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue';
const userStore = useUserStore();
@ -1584,6 +1585,13 @@ const customUpload = async (options: any, targetField: 'generalImage' | 'general
if (res.code === 200) {
const newUrl = res.data.url
form.value[targetField].push(newUrl)
// 同步更新 fileList触发 el-upload UI 刷新
const fileObj = { name: newUrl.split('/').pop(), url: getImageUrl(newUrl) }
if (targetField === 'generalImage') {
fileListImage.value.push(fileObj)
} else {
fileListManual.value.push(fileObj)
}
ElMessage.success('上传成功')
onSuccess(res)
} else {
@ -1597,6 +1605,13 @@ const customUpload = async (options: any, targetField: 'generalImage' | 'general
finally { isUploading.value = false }
}
// 粘贴上传处理器PC 端:鼠标悬停 + Ctrl+V 直接粘贴图片)
const handlePasteLink = (link: string, field: string) => {
imageExternalUrl.value = link
}
usePasteUpload(customUpload, 'generalImage', '#upload-generalImage', handlePasteLink)
usePasteUpload(customUpload, 'generalManual', '#upload-generalManual', handlePasteLink)
const handleRemoveImage = async (uploadFile: any, targetField: 'generalImage' | 'generalManual') => {
const fileName = uploadFile.name || uploadFile.url?.split('/').pop() || '此文件'
try {

View File

@ -192,14 +192,14 @@
<el-dialog v-model="bomSelectVisible" title="按 BOM 套餐添加" width="700px" destroy-on-close :close-on-click-modal="false">
<el-form label-width="100px">
<el-form-item label="选择产品">
<el-select v-model="selectedBomNo" filterable placeholder="请选择启用状态的 BOM 配方" style="width: 100%" @change="() => {}">
<el-option
v-for="b in bomOptions"
:key="`${b.bom_no}_${b.version}`"
:label="`${b.parent_name} - ${b.version}`"
:value="b.bom_no"
/>
</el-select>
<el-tree-select
v-model="selectedBomNo"
:data="treeData"
filterable
placeholder="请选择启用的 BOM 配方"
:render-after-expand="false"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="生产套数">
<el-input-number v-model="bomSets" :min="1" label="套" style="width: 200px;" :disabled="!userStore.hasPermission('outbound_selection:operation')" />
@ -432,7 +432,7 @@
import { ref, computed, watch } from 'vue'
import { Printer, Search, Plus, Download, List } from '@element-plus/icons-vue'
import { ElMessage, ElTable, ElMessageBox } from 'element-plus'
import { getAllStock, getStockList, printSelectionList } from '@/api/inbound/stock'
import { getStockList, printSelectionList } from '@/api/inbound/stock'
import { getBomList, getBomDetail } from '@/api/bom'
import { useUserStore } from '@/stores/user'
import { submitOutboundRequest } from '@/api/outbound'
@ -467,7 +467,6 @@ const requestApproverId = ref<number | null>(null)
const approvers = ref<any[]>([])
const requestSubmitting = ref(false)
const allStockData = ref<any[]>([])
const stockList = ref<any[]>([]) // 服务端分页数据
const stockTotal = ref(0)
const stockPage = ref(1)
@ -487,6 +486,19 @@ const selectedBomNo = ref('')
const bomSets = ref(1)
const currentBomDetail = ref<any[]>([]) // 当前选中的BOM明细
// BOM 树形数据(将分组数据映射为 el-tree-select 需要的结构)
const treeData = computed(() => {
return (bomOptions.value || []).map(group => ({
value: `group_${group.category}`, // 仅作唯一标识
label: `${group.category} (${group.count})`,
disabled: true, // 禁止选中分类本身
children: (group.items || []).map((b: any) => ({
value: b.bom_no,
label: `${b.parent_name} - ${b.version}`
}))
}))
})
// 打印相关
const currentTime = ref('')
@ -512,43 +524,35 @@ const totalExportCount = computed(() => {
return validSelectedItems.value.reduce((sum, item) => sum + (item.export_quantity || 0), 0)
})
// --- BOM 齐套性分析计算属性 ---
// --- BOM 齐套性分析计算属性(使用后端已计算的 current_stockO(N),无嵌套循环)---
const maxBuildableSets = computed(() => {
if (currentBomDetail.value.length === 0 || allStockData.value.length === 0) return 0
let minSets = Infinity
currentBomDetail.value.forEach((bomItem: any) => {
const dosage = parseFloat(bomItem.dosage) || 0 // 单套需求量
if (dosage <= 0) return
// 匹配库存中的可用数量
const stockItem = allStockData.value.find((s: any) => s.base_id && s.base_id == bomItem.child_id)
const available = stockItem ? (stockItem.availableCount || 0) : 0
const buildable = Math.floor(available / dosage)
if (buildable < minSets) minSets = buildable
})
return minSets === Infinity ? 0 : minSets
if (!currentBomDetail.value?.length) return 0
return currentBomDetail.value.reduce((minSets, bomItem: any) => {
const dosage = parseFloat(bomItem.dosage) || 0
if (dosage <= 0) return minSets
const stock = parseFloat(bomItem.current_stock) || 0
return Math.min(minSets, Math.floor(stock / dosage))
}, Infinity)
})
const shortageList = computed(() => {
if (currentBomDetail.value.length === 0 || allStockData.value.length === 0 || bomSets.value <= 0) return []
const target = bomSets.value
const shortages: any[] = []
currentBomDetail.value.forEach((bomItem: any) => {
const dosage = parseFloat(bomItem.dosage) || 0 // 单套需求量
const totalNeed = dosage * target
const stockItem = allStockData.value.find((s: any) => s.base_id && s.base_id == bomItem.child_id)
const available = stockItem ? (stockItem.availableCount || 0) : 0
const shortage = totalNeed - available
if (shortage > 0) {
shortages.push({
name: bomItem.child_name || bomItem.name || '未知物料',
sku: bomItem.child_sku || bomItem.sku || '-',
need: totalNeed,
available: available,
shortage: shortage
if (!currentBomDetail.value?.length || bomSets.value <= 0) return []
return currentBomDetail.value
.map((bomItem: any) => {
const dosage = parseFloat(bomItem.dosage) || 0
const totalNeed = dosage * bomSets.value
const stock = parseFloat(bomItem.current_stock) || 0
const shortage = Math.max(0, totalNeed - stock)
return { ...bomItem, shortage, available: stock }
})
}
})
return shortages
.filter((item: any) => item.shortage > 0)
.map((item: any) => ({
name: item.child_name || item.name || '未知物料',
sku: item.child_sku || item.sku || '-',
need: item.dosage * bomSets.value,
available: item.available,
shortage: item.shortage
}))
})
const hasShortage = computed(() => shortageList.value.length > 0 && bomSets.value > maxBuildableSets.value)
@ -563,31 +567,6 @@ const getTypeTag = (type: string) => {
}
}
// --- 核心逻辑 0加载全量库存数据BOM 齐套计算依赖此数据) ---
const ensureAllStockLoaded = async () => {
if (allStockData.value.length === 0) {
try {
const res: any = await getAllStock()
const rawMaterials = (res.materials || []).map((i: any) => ({ ...i, type: 'material', typeLabel: '采购件' }))
const rawSemis = (res.semis || []).map((i: any) => ({ ...i, type: 'semi', typeLabel: '半成品' }))
const rawProducts = (res.products || []).map((i: any) => ({ ...i, type: 'product', typeLabel: '成品' }))
const list = [...rawMaterials, ...rawSemis, ...rawProducts]
allStockData.value = list.map((i: any) => ({
...i,
name: i.name || i.material_name || i.product_name || '未知名称',
standard: i.standard || i.spec_model || '',
warehouse_location: i.warehouse_location || i.warehouse_loc || i.full_path || '',
uniqueKey: `${i.type}_${i.id}`,
available_quantity: parseFloat(i.available_quantity) || 0,
availableCount: parseFloat(i.available_quantity) || 0,
export_quantity: 1
}))
} catch (e) {
ElMessage.error('加载全量库存数据失败BOM 功能可能受影响)')
}
}
}
// --- 核心逻辑 1手动添加库存 ---
// 服务端加载库存列表
@ -620,8 +599,6 @@ const openManualSelect = async () => {
stockPage.value = 1
searchKeyword.value = ''
await loadStockList()
await ensureAllStockLoaded()
allStockData.value.forEach(item => item.export_quantity = 0)
}
// 搜索框防抖触发服务端过滤
@ -726,7 +703,6 @@ const openBomSelect = async () => {
} catch (e) {
ElMessage.error('加载 BOM 列表失败')
}
await ensureAllStockLoaded()
}
// 监听 BOM 选择变化,自动加载明细并计算齐套性

View File

@ -147,7 +147,7 @@
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="图片上传" required>
<el-form-item label="图片上传" required id="upload-purchase-images">
<el-upload
v-model:file-list="fileList"
:http-request="customUpload"
@ -244,6 +244,7 @@ import {
approvePurchase, getPurchaseApprovers, autoFillPurchase
} from '@/api/purchase'
import { uploadFile, deleteFile } from '@/api/common/upload'
import { usePasteUpload } from '@/hooks/usePasteUpload'
import type { FormInstance } from 'element-plus'
const userStore = useUserStore()
@ -490,6 +491,8 @@ const customUpload = async (options: any) => {
if (res.code === 200) {
const newUrl = res.data.url
form.value.images!.push(newUrl)
// 同步更新 fileList触发 el-upload UI 刷新
fileList.value.push({ name: newUrl.split('/').pop(), url: getImageUrl(newUrl) })
onSuccess(res)
} else {
ElMessage.error(res.msg || '上传失败')
@ -512,6 +515,12 @@ const handleRemoveImage = async (uploadFile: any) => {
}
}
// 粘贴上传处理器PC 端:鼠标悬停 + Ctrl+V 直接粘贴图片)
const handlePasteLink = (link: string, field: string) => {
// 采购单没有独立的外链输入框,暂不支持网页图片链接自动填入
}
usePasteUpload(customUpload, 'images', '#upload-purchase-images', handlePasteLink)
// --- 提交 ---
const submitForm = async () => {
if (!form.value.name.trim()) { ElMessage.warning('请选择或填写采购物品'); return }

View File

@ -447,7 +447,7 @@
<el-row :gutter="20">
<el-col :span="24">
<el-form-item label="到货图片" prop="arrival_photo">
<div class="upload-container">
<div class="upload-container" id="upload-arrival_photo">
<el-upload
v-model:file-list="arrivalFileList"
action="#"
@ -466,7 +466,7 @@
</el-col>
<el-col :span="24">
<el-form-item label="检测报告" prop="inspection_report">
<div class="upload-container">
<div class="upload-container" id="upload-inspection_report">
<el-upload
v-model:file-list="reportFileList"
action="#"
@ -698,6 +698,7 @@ import {
} from '@/api/inbound/buy'
import {getLabelPreview, executePrint} from '@/api/common/print'
import { getWarehouseTree } from '@/api/common/warehouse'
import { usePasteUpload } from '@/hooks/usePasteUpload'
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'
import WarehouseSelector from '@/components/WarehouseSelector.vue'
import SmartScannerDialog from '@/components/SmartScannerDialog.vue'
@ -1532,6 +1533,16 @@ const handleRemoveImage = async (uploadFile: any, targetField: 'arrival_photo' |
ElMessage.success('已删除')
} catch (e) { console.error(e) }
}
// 粘贴上传处理器PC 端:鼠标悬停 + Ctrl+V 直接粘贴图片)
const handlePasteLink = (link: string, field: string) => {
if (field === 'inspection_report') {
inspection_report_url.value = link
}
}
usePasteUpload(customUpload, 'arrival_photo', '#upload-arrival_photo', handlePasteLink)
usePasteUpload(customUpload, 'inspection_report', '#upload-inspection_report', handlePasteLink)
const handlePreviewPicture = (uploadFile: any) => { dialogImageUrl.value = uploadFile.url!; dialogVisibleImage.value = true }
const triggerCamera = (field: 'arrival_photo' | 'inspection_report') => {
currentCameraField.value = field;

View File

@ -103,10 +103,10 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { getInboundSummaryList, exportInboundSummary } from '@/api/inbound/inbound_summary'
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'
import { getInboundSummaryList, submitExportTask, checkExportStatus } from '@/api/inbound/inbound_summary'
import { useUserStore } from '@/stores/user'
import { ElMessage } from 'element-plus'
import { ElMessage, ElLoading } from 'element-plus'
const userStore = useUserStore()
@ -153,45 +153,97 @@ const handleFilter = () => {
fetchData()
}
// 导出 Excel
// ============================================================
// 异步导出定时器(组件级别,需在组件销毁时强制清理)
// ============================================================
let exportTimer: ReturnType<typeof setInterval> | null = null
// 组件销毁前,强制清理"幽灵定时器"(防止用户切换路由后定时器仍在跑)
onBeforeUnmount(() => {
if (exportTimer) clearInterval(exportTimer)
})
// ============================================================
// 导出 Excel后端异步轮询模式
// ============================================================
const handleExport = () => {
// 防抖:已有任务在执行中直接跳过
if (exportLoading.value) return
exportLoading.value = true
const params = {
const filters = {
keyword: listQuery.keyword,
source_type: listQuery.source_type,
start_date: listQuery.dateRange ? listQuery.dateRange[0] : null,
end_date: listQuery.dateRange ? listQuery.dateRange[1] : null
}
exportInboundSummary(params)
.then((response: any) => {
const blob = new Blob([response], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
const loadingInstance = ElLoading.service({
text: '正在后台生成报表,请稍候...',
background: 'rgba(0, 0, 0, 0.6)'
})
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
const hour = String(now.getHours()).padStart(2, '0')
const minute = String(now.getMinutes()).padStart(2, '0')
const second = String(now.getSeconds()).padStart(2, '0')
const filename = `入库记录_${year}${month}${day}_${hour}${minute}${second}.xlsx`
submitExportTask(filters)
.then((res: any) => {
const taskId: string = res.data?.task_id
if (!taskId) {
loadingInstance.close()
exportLoading.value = false
ElMessage.error('任务提交失败,未获取到 task_id')
return
}
link.setAttribute('download', filename)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success('导出成功')
// 赋值给外部变量,供 onBeforeUnmount 清理
exportTimer = setInterval(() => {
checkExportStatus(taskId)
.then((statusRes: any) => {
const { status, progress, url, error } = statusRes.data || {}
// 实时更新 Loading 提示文字(显示后端返回的进度百分比)
if (progress != null) {
loadingInstance.setText(`正在生成报表... ${progress}%`)
}
if (status === 'completed') {
clearInterval(exportTimer!)
loadingInstance.close()
exportLoading.value = false
// 触发浏览器下载(注意:/download/<taskId> 接口在 Flask 侧不过滤 JWT
// 若需要 Token 验证下载,请改用 window.open 或 iframe 下载方式)
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin
const downloadUrl = url.startsWith('http') ? url : `${baseUrl}${url}`
const link = document.createElement('a')
link.href = downloadUrl
link.setAttribute('download', '')
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
ElMessage.success('报表已生成,正在下载')
} else if (status === 'failed') {
clearInterval(exportTimer!)
loadingInstance.close()
exportLoading.value = false
ElMessage.error(`生成失败:${error || '未知错误'}`)
}
// 'processing' → 继续轮询
})
.catch(() => {
// 轮询请求本身失败(网络中断),停止轮询,提示用户
clearInterval(exportTimer!)
loadingInstance.close()
exportLoading.value = false
ElMessage.error('查询进度失败,请检查网络或稍后重试')
})
}, 1500)
})
.catch((err: any) => {
console.error('导出失败', err)
ElMessage.error('导出失败')
})
.finally(() => {
console.error('提交导出任务失败', err)
loadingInstance.close()
exportLoading.value = false
ElMessage.error('提交导出任务失败')
})
}

View File

@ -388,7 +388,7 @@
</el-col>
<el-col :span="dialogStatus === 'update' ? 12 : 18">
<el-form-item label="成品实拍" prop="product_photo">
<div class="upload-container">
<div class="upload-container" id="upload-product_photo">
<el-upload v-model:file-list="productPhotoList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'product_photo')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'product_photo')" :before-upload="beforeAvatarUpload">
<el-icon><Plus /></el-icon>
</el-upload>
@ -402,7 +402,7 @@
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="质量报告" prop="quality_report_link">
<div class="upload-container">
<div class="upload-container" id="upload-quality_report_link">
<el-upload v-model:file-list="qualityFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'quality_report_link')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'quality_report_link')" :before-upload="beforeAvatarUpload">
<el-icon><Plus /></el-icon>
</el-upload>
@ -415,7 +415,7 @@
<el-col :span="12">
<el-form-item label="检测报告" prop="inspection_report_link">
<div class="upload-container">
<div class="upload-container" id="upload-inspection_report_link">
<el-upload v-model:file-list="inspectionFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'inspection_report_link')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'inspection_report_link')" :before-upload="beforeAvatarUpload">
<el-icon><Plus /></el-icon>
</el-upload>
@ -592,6 +592,7 @@ import WarehouseSelector from '@/components/WarehouseSelector.vue'
import SmartScannerDialog from '@/components/SmartScannerDialog.vue'
import { getLabelPreview, executePrint } from '@/api/common/print'
import { getWarehouseTree } from '@/api/common/warehouse'
import { usePasteUpload } from '@/hooks/usePasteUpload'
import { useUserStore } from '@/stores/user'
// ------------------------------------
@ -1235,6 +1236,15 @@ const handleRemoveImage = async (uploadFile: any, targetField: 'product_photo' |
}
const handlePreviewPicture = (uploadFile: any) => { dialogImageUrl.value = uploadFile.url!; dialogVisibleImage.value = true }
// 粘贴上传处理器PC 端:鼠标悬停 + Ctrl+V 直接粘贴图片)
const handlePasteLink = (link: string, field: string) => {
if (field === 'quality_report_link') quality_url.value = link
else if (field === 'inspection_report_link') inspection_url.value = link
}
usePasteUpload(customUpload, 'product_photo', '#upload-product_photo', handlePasteLink)
usePasteUpload(customUpload, 'quality_report_link', '#upload-quality_report_link', handlePasteLink)
usePasteUpload(customUpload, 'inspection_report_link', '#upload-inspection_report_link', handlePasteLink)
const triggerCamera = (field: any) => {
currentCameraField.value = field;
cameraDialogVisible.value = true;

View File

@ -467,7 +467,7 @@
<el-row :gutter="24">
<el-col :span="24">
<el-form-item label="到货图片" prop="arrival_photo">
<div class="upload-container">
<div class="upload-container" id="upload-arrival_photo">
<el-upload v-model:file-list="arrivalFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'arrival_photo')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'arrival_photo')" :before-upload="beforeAvatarUpload">
<el-icon><Plus /></el-icon>
</el-upload>
@ -479,7 +479,7 @@
<el-col :span="24">
<el-form-item label="质量报告" prop="quality_report_link">
<div class="upload-container">
<div class="upload-container" id="upload-quality_report_link">
<el-upload v-model:file-list="reportFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'quality_report_link')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'quality_report_link')" :before-upload="beforeAvatarUpload">
<el-icon><Plus /></el-icon>
</el-upload>
@ -647,6 +647,7 @@ import WarehouseSelector from '@/components/WarehouseSelector.vue'
import SmartScannerDialog from '@/components/SmartScannerDialog.vue'
import {getLabelPreview, executePrint} from '@/api/common/print'
import { getWarehouseTree } from '@/api/common/warehouse'
import { usePasteUpload } from '@/hooks/usePasteUpload'
import { useUserStore } from '@/stores/user'
// ------------------------------------
@ -1324,6 +1325,14 @@ const handleRemoveImage = async (uploadFile: any, targetField: 'arrival_photo' |
} catch (e) { console.error(e) }
}
const handlePreviewPicture = (uploadFile: any) => { dialogImageUrl.value = uploadFile.url!; dialogVisibleImage.value = true }
// 粘贴上传处理器PC 端:鼠标悬停 + Ctrl+V 直接粘贴图片)
const handlePasteLink = (link: string, field: string) => {
if (field === 'quality_report_link') quality_report_url.value = link
}
usePasteUpload(customUpload, 'arrival_photo', '#upload-arrival_photo', handlePasteLink)
usePasteUpload(customUpload, 'quality_report_link', '#upload-quality_report_link', handlePasteLink)
const triggerCamera = (field: 'arrival_photo' | 'quality_report_link') => {
currentCameraField.value = field;
cameraDialogVisible.value = true;

View File

@ -56,6 +56,7 @@
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('borrow_time')" prop="borrow_time" label="借出时间" width="160" sortable />
<el-table-column v-if="hasColumnPermission('return_operator')" prop="return_operator" label="归还人" width="100" />
<el-table-column v-if="hasColumnPermission('expected_return_time') || hasColumnPermission('return_time')" label="归还时间 / 预计" min-width="200">
<template #default="{row}">
@ -173,8 +174,9 @@ const hasColumnPermission = (prop: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true
}
// SKU 字段不再参与权限过滤,始终可见
if (prop === 'sku') {
// SKU / return_time / return_operator 不再参与权限过滤,始终可见
const alwaysVisible = ['sku', 'return_time', 'return_operator', 'expected_return_time', 'status', 'return_location', 'borrow_no', 'borrower_name', 'borrow_time']
if (alwaysVisible.includes(prop)) {
return true
}
const code = permissionMap[prop]