67 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
dxc
1e341ab6aa 版本变更3.20 2026-05-15 10:07:12 +08:00
DXC
27683d2a8b fix: 借用记录列表移除 sku 字段的权限过滤,所有角色均可正常查看 2026-05-15 09:57:55 +08:00
DXC
33879c33f7 fix: BomManage 修复外部跳转时查重逻辑 - 遍历分组 items 查找 parent_id,并主动触发背景列表刷新 2026-05-15 09:48:52 +08:00
DXC
950b8dd671 fix: BOM列表搜索去除外层 % 通配符,改为小写忽略大小写匹配 2026-05-15 09:37:21 +08:00
dxc
857ff958bc 版本变更3.19 2026-05-14 18:01:28 +08:00
DXC
1a76c4853e feat(purchase): 物料搜索分页+价格半联动+图片必填校验 2026-05-12 17:48:29 +08:00
dxc
3c9d7a999d fix(purchase): 审批人下拉隐藏角色/物料搜索下拉/价格弹窗确认/图片必填校验 2026-05-12 17:27:51 +08:00
dxc
8ec6ca5944 feat(purchase): 新增采购管理前端页面与路由(列表+新建+审批) 2026-05-12 16:36:57 +08:00
dxc
9dfcb93146 feat(purchase): 新增采购申请模块后端(模型+Service+API路由) 2026-05-12 16:33:18 +08:00
dxc
f2f9409206 feat: 更新系统名称为MOM/BOM分类分组展示/上传组件增强(删除确认+压缩包150MB) 2026-05-12 15:45:41 +08:00
DXC
6b4ebfa24f feat(upload): 上传组件删除前需二次确认,支持ZIP/RAR/7Z压缩包上传 2026-05-12 15:34:26 +08:00
DXC
1edd471208 feat(bom): BOM列表按物料类别分组展示,支持折叠展开与数量统计 2026-05-12 15:27:47 +08:00
dxc
f3c4dc8d39 版本变更3.18 2026-05-12 15:17:42 +08:00
DXC
e88de1d408 feat(warning): 库存预警邮件表格增加缺少数量列(当前库存 | 缺少数量) 2026-05-12 15:15:46 +08:00
DXC
d4bf7c5e99 feat(material): 物料列表接口返回预警邮箱字段,弹窗打开时可正确回显 2026-05-12 14:59:31 +08:00
DXC
8356774a8a feat(warning): 库存预警邮件支持顺延发送,前端物料列表新增预警状态展示列 2026-05-12 14:44:23 +08:00
dxc
9a96fad3cf 版本变更3.17 2026-05-12 14:43:11 +08:00
DXC
5ef98ef5b3 feat(scheduler): 增加库存预警每日 9:30 定时邮件发送任务 2026-05-12 14:16:15 +08:00
dxc
e1e0bc1104 版本变更3.17 2026-05-12 14:07:26 +08:00
DXC
048317ee5e feat(branding): 更新系统名称为 MOM / Manufacturing Operations Management 2026-05-12 14:01:20 +08:00
DXC
3dae206828 feat(outbound): 完善出库审批邮件通知逻辑,支持申请人与审批人同时收到邮件(带物料明细),审批通过后申请人和库管均收到带物料明细的通知 2026-05-12 13:42:15 +08:00
DXC
c86da38a70 chore(pre-change): 准备修改出库审批邮件通知逻辑与自审批能力 2026-05-12 13:36:18 +08:00
dxc
259f3a7e0d 4.29扫码获取库位小工具接口 2026-04-29 15:40:43 +08:00
dxc
00839863f5 4.29出库审批流程完善,通知申请人以及库管邮件功能 2026-04-29 09:11:37 +08:00
DXC
8276597a67 fix(email): 审批通知逻辑重构 - 通过时同时通知库管和申请人,驳回仅通知申请人;精简 DEBUG 日志 2026-04-29 09:10:34 +08:00
dxc
6ef425b9e4 4.28出库审批 2026-04-28 16:55:18 +08:00
DXC
ccbce82c2e fix(email): 审批通过后库管通知增加明细+DEBUG日志,修复MAIL_DEFAULT_SENDER格式问题 2026-04-28 16:46:12 +08:00
dxc
183b93012e 4.28 2026-04-28 16:07:11 +08:00
DXC
62c0e3738e fix(outbound+trans): 修复POST接口错误数据清洗导致的sku/quantity字段被清除Bug,并新增出库审批工作流全链路 2026-04-28 16:02:34 +08:00
DXC
97e7618bf3 feat(bom+inbound): BOM子件跳转规格修复 + 成品/半成品按钮迁移到标题行 2026-04-28 10:10:45 +08:00
DXC
e08eaff40a feat(outbound): 库存列表按规格+库位聚合 + BOM明细类型修复 2026-04-28 09:23:59 +08:00
dxc
40e405becd 4.27 2026-04-27 16:33:54 +08:00
DXC
d6ae9499db feat: 新增首页全局搜索功能,支持跨模块多词搜索 2026-04-27 15:57:26 +08:00
DXC
ec71cb24f4 feat: 新增物料/成品/半成品页面一键直达BOM管理功能 2026-04-27 15:24:07 +08:00
DXC
9fa471f68a fix: 修复物料列表跳转联动与弹窗定位逻辑 2026-04-27 14:41:26 +08:00
DXC
b002c50d81 fix(material): 修复rows.find数据结构兼容问题 2026-04-24 15:03:01 +08:00
DXC
c0175e13fe fix(material): 增强edit_id自动弹窗的调试日志和容错逻辑 2026-04-24 14:52:45 +08:00
DXC
fa0af40ec7 feat(material): 物料列表页支持URL参数edit_id自动弹出编辑框 2026-04-24 14:47:59 +08:00
DXC
1499d2d45c feat(buy): 入库编辑弹窗增加'前往修改基础信息'跳转按钮 2026-04-24 14:45:43 +08:00
DXC
605462cc33 fix: 解除库存盘点弹窗500条限制并修复字段匹配 2026-04-24 13:40:08 +08:00
DXC
48f2011a38 fix: 盘点草稿已盘数量统计兼容字段名 quantity 2026-04-24 13:32:46 +08:00
DXC
996056d46a fix: 修复库存盘点已盘数量卡在500的问题 2026-04-24 13:19:57 +08:00
DXC
f0c200a15f fix: delete_bom use .all() instead of .first() to delete all child records 2026-04-24 11:42:42 +08:00
72 changed files with 6638 additions and 589 deletions

View File

@ -2,7 +2,10 @@
"permissions": {
"allow": [
"Bash(git add *)",
"Bash(git commit *)"
"Bash(git commit *)",
"Bash(git *)",
"Bash(del *)",
"Bash(rm *)"
]
},
"$version": 3

Binary file not shown.

Binary file not shown.

0
deploy_code.sh Normal file → Executable file
View File

0
deploy_full.sh Normal file → Executable file
View File

Binary file not shown.

View File

@ -3,6 +3,7 @@
from flask import Flask
from config import Config
from app.extensions import db, migrate, cors, jwt
from app.api.v1.scan import scan_bp
import os
@ -125,6 +126,17 @@ def create_app():
except ImportError as e:
print(f"❌ 错误: Scrap 模块导入失败: {e}")
# -----------------------------------------------------
# 2.8 注册采购管理模块
# -----------------------------------------------------
try:
from app.api.v1.purchase import purchase_bp
app.register_blueprint(purchase_bp, url_prefix='/api/v1/purchase')
app.register_blueprint(purchase_bp, url_prefix='/api/purchase', name='purchase_legacy')
print("✅ Purchase 模块注册成功")
except ImportError as e:
print(f"❌ 错误: Purchase 模块导入失败: {e}")
# -----------------------------------------------------
# 2.7 注册 BOM 模块
# -----------------------------------------------------
@ -200,6 +212,39 @@ def create_app():
except ImportError as e:
print(f"❌ 错误: Warehouse 模块导入失败: {e}")
# -----------------------------------------------------
# 2.12 注册通用聚合搜索模块 (Common - Global Search)
# -----------------------------------------------------
try:
from app.api.v1.common import common_bp
# 标准: /api/v1/common/global-search
app.register_blueprint(common_bp, url_prefix='/api/v1/common')
# 兼容: /api/common/global-search
app.register_blueprint(common_bp, url_prefix='/api/common', name='common_legacy')
print("✅ Common 模块注册成功")
except ImportError as e:
print(f"❌ 错误: Common 模块导入失败: {e}")
# -----------------------------------------------------
# 2.13 注册扫码查库存模块 (Scan)
# -----------------------------------------------------
try:
app.register_blueprint(scan_bp, url_prefix='/api/v1/scan')
print("✅ Scan 模块注册成功")
except Exception as e:
print(f"❌ 错误: Scan 模块注册失败: {e}")
# -----------------------------------------------------
# 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,7 +1,11 @@
from flask import Blueprint
from .inbound import inbound_bp
from .bom import bom_bp
from .common import common_bp
from .scan import scan_bp
v1_bp = Blueprint('v1', __name__)
v1_bp.register_blueprint(inbound_bp, url_prefix='/inbound')
v1_bp.register_blueprint(bom_bp, url_prefix='/bom')
v1_bp.register_blueprint(common_bp, url_prefix='/common')
v1_bp.register_blueprint(scan_bp, url_prefix='/scan')

View File

@ -318,6 +318,45 @@ def get_my_permissions():
return jsonify({'msg': f'获取权限失败: {str(e)}'}), 500
# ==============================================================================
# 获取可指定审批人列表SUPERVISOR / SUPER_ADMIN 且 status=active
# ==============================================================================
@auth_bp.route('/users/approvers', methods=['GET'])
@jwt_required()
def get_approvers():
"""
查询角色为 SUPER_ADMIN 或 SUPERVISOR 且状态为活跃的用户列表
返回: [{id, username, email, role, is_self}]
其中 is_self=true 表示当前登录用户本人(用于前端标记)
"""
try:
from app.models.system import SysUser
current_user_id = get_jwt_identity()
users = SysUser.query.filter(
SysUser.role.in_(['SUPER_ADMIN', 'SUPERVISOR']),
SysUser.status == 'active'
).all()
return jsonify({
'msg': '获取成功',
'data': [
{
'id': u.id,
'username': u.username,
'email': u.email or '',
'role': u.role,
'is_self': (u.id == current_user_id)
} for u in users
]
}), 200
except Exception as e:
current_app.logger.error(f"Get Approvers Failed: {str(e)}")
return jsonify({'msg': f'获取审批人列表失败: {str(e)}'}), 500
# ==============================================================================
# 获取当前用户个人资料(自我查看)
# ==============================================================================

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
@ -214,13 +214,22 @@ def delete_bom(bom_no):
if version:
query = query.filter_by(version=version)
exist = query.first()
if not exist:
# 【核心修复】:使用 .all() 查出该 BOM 版本下的所有子件记录
records = query.all()
if not records:
return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404
# 删除(改为对象级删除以触发审计事件
db.session.delete(exist)
# 循环删除所有关联记录(逐个 delete 可触发 SQLAlchemy 监听器记录审计日志
for rec in records:
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,7 @@
# inventory-backend/app/api/v1/common/__init__.py
from flask import Blueprint
common_bp = Blueprint('common', __name__)
# 导入子模块,使其路由装饰器注册到 common_bp
from . import search

View File

@ -0,0 +1,99 @@
# inventory-backend/app/api/v1/common/search.py
from flask import jsonify, request
from . import common_bp
from app.models import MaterialBase
from app.models.inbound.buy import StockBuy
from app.models.bom import BomTable
from app.extensions import db
@common_bp.route('/global-search', methods=['GET'])
def global_search():
"""
全局聚合搜索接口(多词 AND 模式,无数量限制)
入参: keyword (字符串,支持空格分词,多词必须同时匹配)
搜索范围: 基础物料、采购库、BOM配方
"""
keyword = request.args.get('keyword', request.args.get('q', '')).strip()
keywords = keyword.split()
if not keywords:
return jsonify({"code": 200, "data": []})
merged_list = []
# ── 1. 基础物料 (MaterialBase) ──────────────────────────
# 真实字段: name, common_name, spec_model, category
material_conditions = []
for kw in keywords:
kw_term = f'%{kw}%'
material_conditions.append(
db.or_(
MaterialBase.name.ilike(kw_term),
MaterialBase.common_name.ilike(kw_term),
MaterialBase.spec_model.ilike(kw_term),
MaterialBase.category.ilike(kw_term)
)
)
bases = MaterialBase.query.filter(db.and_(*material_conditions)).all()
for b in bases:
merged_list.append({
"id": b.id,
"type": "material",
"title": b.name,
"subtitle": b.spec_model or b.common_name or '无规格型号',
"badge": "基础物料",
"extra": {"category": b.category or ''}
})
# ── 2. 采购库 (StockBuy) ─────────────────────────────────
# 真实字段: barcode, sku (通过 join 搜索关联的 MaterialBase.name)
stock_conditions = []
for kw in keywords:
kw_term = f'%{kw}%'
stock_conditions.append(
db.or_(
MaterialBase.name.ilike(kw_term),
StockBuy.barcode.ilike(kw_term),
StockBuy.sku.ilike(kw_term)
)
)
stocks = StockBuy.query.join(MaterialBase, StockBuy.base_id == MaterialBase.id).filter(
db.and_(*stock_conditions)
).all()
for s in stocks:
merged_list.append({
"id": s.base_id,
"stock_id": s.id,
"type": "stock_buy",
"title": s.base.name if s.base else '未知物料',
"subtitle": f"条码: {s.barcode or ''} | 库存: {s.stock_quantity}",
"badge": "采购库",
"extra": {"barcode": s.barcode or '', "status": s.status or ''}
})
# ── 3. BOM 配方 (BomTable) ──────────────────────────────
# 真实字段: bom_no, version
bom_conditions = []
for kw in keywords:
kw_term = f'%{kw}%'
bom_conditions.append(
db.or_(
BomTable.bom_no.ilike(kw_term),
BomTable.version.ilike(kw_term)
)
)
boms = BomTable.query.filter(db.and_(*bom_conditions)).all()
for bom in boms:
parent_name = bom.parent.name if bom.parent else ''
merged_list.append({
"id": bom.id,
"bom_no": bom.bom_no,
"type": "bom",
"title": f"{bom.bom_no} ({bom.version})",
"subtitle": f"父件: {parent_name}" if parent_name else f"版本: {bom.version}",
"badge": "配方BOM",
"extra": {"version": bom.version, "parent_id": bom.parent_id}
})
return jsonify({"code": 200, "data": merged_list})

View File

@ -25,10 +25,10 @@ BASE_DIR = get_project_root()
UPLOAD_FOLDER = os.path.join(BASE_DIR, 'uploads')
# 允许上传的文件后缀
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'pdf', 'doc', 'docx', 'xls', 'xlsx'}
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'zip', 'rar', '7z'}
# ★ 文件上传安全加固:限制最大文件大小 (10MB)
MAX_CONTENT_LENGTH = 10 * 1024 * 1024 # 10MB
# ★ 文件上传安全加固:限制最大文件大小 (50MB,支持压缩包)
MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50MB
def allowed_file(filename):
@ -68,7 +68,7 @@ def upload_file():
if file_size > MAX_CONTENT_LENGTH:
return jsonify({
"code": 400,
"msg": f"文件大小超过限制 ({MAX_CONTENT_LENGTH // (1024*1024)}MB)"
"msg": f"文件大小超过限制(最大 50MB"
}), 400
if file and allowed_file(file.filename):

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

@ -381,6 +381,8 @@ def batch_set_warning():
red_val = item.get('redThreshold')
warning.yellow_threshold = float(yellow_val) if yellow_val is not None else 0
warning.red_threshold = float(red_val) if red_val is not None else 0
warning.yellow_emails = item.get('yellowEmails', warning.yellow_emails)
warning.red_emails = item.get('redEmails', warning.red_emails)
updated_count += 1
else:
# 创建新记录
@ -390,7 +392,9 @@ def batch_set_warning():
base_id=base_id,
is_enabled=item.get('isEnabled', False),
yellow_threshold=float(yellow_val) if yellow_val is not None else 0,
red_threshold=float(red_val) if red_val is not None else 0
red_threshold=float(red_val) if red_val is not None else 0,
yellow_emails=item.get('yellowEmails', ''),
red_emails=item.get('redEmails', '')
)
db.session.add(warning)
created_count += 1
@ -412,7 +416,48 @@ def batch_set_warning():
# ==============================================================================
# 2.6 批量设置强制质检 API (POST /api/v1/inbound/base/batch-inspection)
# 2.6 标记已采购 API (POST /api/v1/inbound/base/warning/mark-ordered)
# ==============================================================================
@inbound_base_bp.route('/warning/mark-ordered', methods=['POST'])
@permission_required('material_list:edit_warning')
def mark_warning_ordered():
"""
前端标记预警物料已处理采购(标记 is_ordered
请求体格式: {"baseId": 123, "isOrdered": true}
"""
try:
data = request.get_json()
if not data:
return jsonify({"code": 400, "msg": "No data provided"}), 400
base_id = data.get('baseId')
if not base_id:
return jsonify({"code": 400, "msg": "baseId 不能为空"}), 400
is_ordered = bool(data.get('isOrdered', False))
warning = MaterialWarningSetting.query.filter_by(base_id=base_id).first()
if not warning:
return jsonify({"code": 404, "msg": f"物料ID {base_id} 的预警配置不存在"}), 404
warning.is_ordered = is_ordered
db.session.commit()
status_text = "已标记为已采购" if is_ordered else "已重置为未采购"
return jsonify({
"code": 200,
"msg": status_text,
"data": warning.to_dict()
})
except Exception as e:
db.session.rollback()
current_app.logger.error(f"标记已采购失败: {str(e)}")
return jsonify({"code": 500, "msg": f"标记已采购失败: {str(e)}"}), 500
# ==============================================================================
# 2.7 批量设置强制质检 API (POST /api/v1/inbound/base/batch-inspection)
# ==============================================================================
@inbound_base_bp.route('/batch-inspection', methods=['POST'])
@permission_required('material_list:operation')

View File

@ -22,14 +22,18 @@ except ImportError:
SysUser = None
# 尝试导入半成品和成品
import logging
try:
from app.models.inbound.semi import StockSemi
except ImportError:
except Exception as e:
logging.error(f"❌ 致命错误StockSemi 模型导入失败: {e}")
StockSemi = None
try:
from app.models.inbound.product import StockProduct
except ImportError:
except Exception as e:
logging.error(f"❌ 致命错误StockProduct 模型导入失败: {e}")
StockProduct = None
@ -79,28 +83,50 @@ def get_stock_info(uuid_or_barcode):
根据 uuid 或 barcode 查询库存信息
返回: (item, source_table, stock_id)
"""
# 清洗输入:去掉前后空格和换行符
uuid_or_barcode = str(uuid_or_barcode).strip()
# 1. 成品
if StockProduct:
print(f"🔍 [QUERY DEBUG] 正在成品表搜关键词: {uuid_or_barcode}")
item = StockProduct.query.filter(
db.or_(StockProduct.barcode == uuid_or_barcode, StockProduct.sku == uuid_or_barcode)
db.or_(
StockProduct.barcode.ilike(f"%{uuid_or_barcode}%"),
StockProduct.sku.ilike(f"%{uuid_or_barcode}%"),
StockProduct.serial_number.ilike(f"%{uuid_or_barcode}%")
)
).first()
if item:
print(f"✅ [QUERY DEBUG] 命中成品! ID={item.id}, SKU={item.sku}")
return (item, 'stock_product', item.id)
else:
print(f"❌ [QUERY DEBUG] 成品表查询结束,无匹配项")
# 2. 半成品
if StockSemi:
print(f"🔍 [QUERY DEBUG] 正在半成品表搜关键词: {uuid_or_barcode}")
item = StockSemi.query.filter(
db.or_(StockSemi.barcode == uuid_or_barcode, StockSemi.sku == uuid_or_barcode)
db.or_(
StockSemi.barcode.ilike(f"%{uuid_or_barcode}%"),
StockSemi.sku.ilike(f"%{uuid_or_barcode}%"),
StockSemi.serial_number.ilike(f"%{uuid_or_barcode}%")
)
).first()
if item:
print(f"✅ [QUERY DEBUG] 命中半成品! ID={item.id}, SKU={item.sku}")
return (item, 'stock_semi', item.id)
# 3. 采购件
if StockBuy:
print(f"🔍 [QUERY DEBUG] 正在采购件表搜关键词: {uuid_or_barcode}")
item = StockBuy.query.filter(
db.or_(StockBuy.barcode == uuid_or_barcode, StockBuy.sku == uuid_or_barcode)
db.or_(
StockBuy.barcode.ilike(f"%{uuid_or_barcode}%"),
StockBuy.sku.ilike(f"%{uuid_or_barcode}%")
)
).first()
if item:
print(f"✅ [QUERY DEBUG] 命中采购件! ID={item.id}, SKU={item.sku}")
return (item, 'stock_buy', item.id)
return (None, None, None)
@ -111,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
@ -216,42 +278,71 @@ def get_stock_list():
except Exception:
pass
# 3. 成品
# 3. 成品
if StockProduct:
try:
q = StockProduct.query.filter(StockProduct.stock_quantity > 0)
if keyword:
q = q.filter(
db.or_(
StockProduct.product_name.ilike(f'%{keyword}%'),
StockProduct.spec_model.ilike(f'%{keyword}%'),
StockProduct.sku.ilike(f'%{keyword}%')
)
q = StockProduct.query.filter(StockProduct.stock_quantity > 0)
if keyword:
q = q.filter(
db.or_(
StockProduct.base.has(MaterialBase.name.ilike(f'%{keyword}%')),
StockProduct.base.has(MaterialBase.spec_model.ilike(f'%{keyword}%')),
StockProduct.sku.ilike(f'%{keyword}%'),
StockProduct.barcode.ilike(f'%{keyword}%'),
StockProduct.serial_number.ilike(f'%{keyword}%')
)
rows = q.all()
for item in rows:
d = item.to_dict()
d['stock_type'] = 'product'
d['type'] = 'product'
d['typeLabel'] = '成品'
d['name'] = d.get('product_name', d.get('name', ''))
d['standard'] = d.get('spec_model', d.get('standard', ''))
d['available_quantity'] = d.get('qty_available', d.get('available_quantity', 0))
all_items.append(d)
except Exception:
pass
)
rows = q.all()
for item in rows:
d = item.to_dict()
d['stock_type'] = 'product'
d['type'] = 'product'
d['typeLabel'] = '成品'
d['name'] = d.get('material_name', d.get('name', ''))
d['standard'] = d.get('spec_model', d.get('standard', ''))
d['available_quantity'] = d.get('qty_available', d.get('available_quantity', 0))
all_items.append(d)
# ── 按规格+库位聚合(出库选单合并同类项)───────────────────────
is_aggregated = request.args.get('is_aggregated', 'false').lower() == 'true'
if is_aggregated:
grouped_dict = {}
for item in all_items:
# 核心聚合键:类型 + 规格型号 + 库位
group_key = f"{item.get('type')}_{item.get('standard')}_{item.get('warehouse_location', '')}"
if group_key in grouped_dict:
# 累加数量
existing = grouped_dict[group_key]
existing['available_quantity'] = float(existing.get('available_quantity', 0)) + float(item.get('available_quantity', 0))
existing['stock_quantity'] = float(existing.get('stock_quantity', 0)) + float(item.get('stock_quantity', 0))
# 保留 id 列表(出库提交时需用到)
existing_ids = existing.get('_ids', [])
existing_ids.append(item.get('id'))
existing['_ids'] = existing_ids
else:
# 存入代表项
grouped_dict[group_key] = item.copy()
# 强制统一数据类型以便前端处理
grouped_dict[group_key]['available_quantity'] = float(item.get('available_quantity', 0))
grouped_dict[group_key]['stock_quantity'] = float(item.get('stock_quantity', 0))
grouped_dict[group_key]['_ids'] = [item.get('id')]
# 替换原列表为聚合后的列表
all_items = list(grouped_dict.values())
# ── 手动切片分页 ────────────────────────────────────────────
total = len(all_items)
start = (page - 1) * pageSize
end = start + pageSize
end = start + pageSize
paged = all_items[start:end]
return jsonify({
'msg': '获取成功',
'data': {
'list': paged,
'total': total,
'page': page,
'list': paged,
'total': total,
'page': page,
'pageSize': pageSize
}
}), 200
@ -325,11 +416,22 @@ def get_drafts():
total = len(items)
start = (page - 1) * limit
end = start + limit
# 计算真实的去重"已盘数量"
counted_items_set = set()
for draft_item in items:
# 兼容判断 quantity 或 qty_actual
if draft_item.get('quantity') is not None or draft_item.get('qty_actual') is not None:
unique_key = f"{draft_item.get('source_table', '')}_{draft_item.get('stock_id', '')}"
counted_items_set.add(unique_key)
total_scanned_unique = len(counted_items_set)
paginated_items = items[start:end]
return jsonify({
'items': paginated_items,
'total': total,
'total_scanned': total_scanned_unique,
'page': page,
'limit': limit
}), 200
@ -350,6 +452,7 @@ def add_draft():
data = request.json
user_id = _normalize_user_id()
uuid = data.get('uuid')
print(f"🚀 [SCAN DEBUG] 后端实际接收到的 UUID 原文: |{uuid}| (长度: {len(str(uuid)) if uuid else 0})")
quantity = float(data.get('quantity', 1))
session_id = data.get('session_id')
# ★ 新增: 提取备注字段
@ -796,8 +899,8 @@ def export_stocktake():
user = SysUser.query.get(int(user_id))
if not user:
user = SysUser.query.filter(SysUser.username.like(f"%/{user_id}")).first()
if not user:
user = SysUser.query.filter_by(username=str(user_id)).first()
# 注意:此处不再 fallback filter_by(username=...)
# 避免 PostgreSQL 将 user_id 数字与 username 字符串列做类型比较导致报错
if not user:
return str(user_id)

View File

@ -148,44 +148,6 @@ def create_outbound():
if not data.get('consumer_name') or not data.get('signature_path'):
return jsonify({'code': 400, 'msg': '领用人及签名信息缺失'}), 400
# 数据清洗:移除用户没有权限的字段
user_permissions = get_current_user_permissions()
# 超级管理员不过滤
if 'outbound_list:*' not in user_permissions:
# 字段名到权限码的映射(与前端 permissionMap 保持一致)
field_to_perm = {
'outbound_no': 'outbound_list:outbound_no',
'outbound_time': 'outbound_list:outbound_time',
'outbound_type': 'outbound_list:outbound_type',
'total_amount': 'outbound_list:total_amount',
'consumer_name': 'outbound_list:consumer_name',
'operator_name': 'outbound_list:operator_name',
'remark': 'outbound_list:remark',
'signature_path': 'outbound_list:signature_path',
# 明细字段
'sku': 'outbound_list:sku',
'name': 'outbound_list:name',
'material_type': 'outbound_list:material_type',
'category': 'outbound_list:category',
'spec_model': 'outbound_list:spec_model',
'quantity': 'outbound_list:quantity',
'unit_price': 'outbound_list:unit_price',
'price': 'outbound_list:unit_price', # 兼容 price 字段
'subtotal': 'outbound_list:subtotal',
}
# 清洗顶层字段
for field in list(data.keys()):
perm_code = field_to_perm.get(field)
if perm_code and perm_code not in user_permissions:
data.pop(field, None)
# 清洗 items 中的每个商品字段
if 'items' in data and isinstance(data['items'], list):
for item in data['items']:
for field in list(item.keys()):
perm_code = field_to_perm.get(field)
if perm_code and perm_code not in user_permissions:
item.pop(field, None)
try:
# ★ [修改] 调用批量创建服务
outbound_no = OutboundService.create_outbound_batch(data, operator_name=final_operator)
@ -233,3 +195,244 @@ def get_outbound_list():
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': str(e)}), 500
# ==============================================================================
# 出库审批相关接口
# ==============================================================================
from app.services.outbound_service import OutboundApprovalService
def get_current_user_id():
"""获取当前用户ID"""
from app.models.system import SysUser
identity = get_jwt_identity()
if not identity:
return None
# JWT identity 是数据库主键整数,直接用 .get() 查询
user = SysUser.query.get(identity)
return user.id if user else None
def get_current_user_info():
"""获取当前用户信息和角色"""
from app.models.system import SysUser
identity = get_jwt_identity()
if not identity:
return None, None
# JWT identity 是数据库主键整数,直接用 .get() 查询
user = SysUser.query.get(identity)
return user.id if user else None, user.role if user else None
# --------------------------------------------------------
# 4. 创建出库审批单
# POST /api/v1/outbound/request
# --------------------------------------------------------
@outbound_bp.route('/request', methods=['POST'])
@jwt_required()
def create_outbound_request():
"""
创建出库审批单(申请阶段,用户只需提交宏观物料信息,无需关联具体库存记录)
请求体示例:
{
"items": [
{
"name": "物料A", // 物料名称 (必填)
"spec_model": "规格1", // 规格型号 (必填)
"quantity": 10, // 计划出库数量 (必填)
"warehouse_location": "A区-01-01", // 库位 (可选)
"remark": "备注信息" // 物品备注 (可选)
}
],
"allowed_approvers": [
{"type": "role", "value": "SUPERVISOR"},
{"type": "role", "value": "SUPER_ADMIN"}
],
"remark": "紧急出库申请"
}
"""
try:
user_id, user_role = get_current_user_info()
if not user_id:
return jsonify({'code': 401, 'msg': '用户未登录'}), 401
data = request.get_json()
if not data:
return jsonify({'code': 400, 'msg': '无有效数据'}), 400
items = data.get('items', [])
if not items:
return jsonify({'code': 400, 'msg': '出库物品列表不能为空'}), 400
# ★ 申请阶段仅校验宏观字段:名称、规格、数量
required_fields = ['name', 'spec_model', 'quantity']
for idx, item in enumerate(items):
missing = [f for f in required_fields if f not in item or item.get(f) is None or str(item.get(f)).strip() == '']
if missing:
return jsonify({
'code': 400,
'msg': f'{idx + 1}条物品缺少必填字段: {", ".join(missing)}'
f'必须包含: name(名称), spec_model(规格), quantity(数量)'
}), 400
try:
qty = float(item.get('quantity', 0))
if qty <= 0:
return jsonify({'code': 400, 'msg': f'{idx + 1}条物品的出库数量必须大于0'}), 400
except (TypeError, ValueError):
return jsonify({'code': 400, 'msg': f'{idx + 1}条物品的 quantity 格式无效'}), 400
# ★ 指定审批人:前端传 approver_id 则精准通知,否则用默认角色规则
approver_id = data.get('approver_id')
_default_approvers = [
{"type": "role", "value": "SUPERVISOR"},
{"type": "role", "value": "SUPER_ADMIN"}
]
allowed_approvers = data.get('allowed_approvers') or _default_approvers
# 创建审批单(直接存储前端传来的宏观信息快照,不查询库存)
approval = OutboundApprovalService.create_request(
applicant_id=user_id,
items=items,
allowed_approvers=allowed_approvers,
remark=data.get('remark'),
approver_id=approver_id
)
return jsonify({
'code': 200,
'msg': '审批单创建成功',
'data': approval.to_dict()
}), 200
except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
# --------------------------------------------------------
# 5. 审批出库申请
# PATCH /api/v1/outbound/request/<id>/approve
# --------------------------------------------------------
@outbound_bp.route('/request/<int:request_id>/approve', methods=['PATCH'])
@jwt_required()
def approve_outbound_request(request_id):
"""
审批出库申请
请求体示例:
{
"action": "approve", // "approve" 通过, "reject" 驳回
"reject_reason": "库存不足" // 仅在驳回时需要
}
"""
try:
user_id, user_role = get_current_user_info()
if not user_id:
return jsonify({'code': 401, 'msg': '用户未登录'}), 401
data = request.get_json() or {}
action = data.get('action', 'approve')
reject_reason = data.get('reject_reason')
if action not in ('approve', 'reject'):
return jsonify({'code': 400, 'msg': '无效的审批操作,仅支持 approve 或 reject'}), 400
if action == 'reject' and not reject_reason:
return jsonify({'code': 400, 'msg': '驳回时必须提供原因'}), 400
success, message, approval = OutboundApprovalService.approve(
request_id=request_id,
user_id=user_id,
user_role=user_role,
action=action,
reject_reason=reject_reason
)
if not success:
return jsonify({'code': 400, 'msg': message}), 400
return jsonify({
'code': 200,
'msg': message,
'data': approval.to_dict() if approval else None
}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
# --------------------------------------------------------
# 6. 获取审批单列表
# GET /api/v1/outbound/request
# --------------------------------------------------------
@outbound_bp.route('/request', methods=['GET'])
@jwt_required()
def get_outbound_request_list():
"""
获取出库审批单列表
Query参数:
- page: 页码 (默认1)
- limit: 每页数量 (默认10)
- applicant_id: 按申请人筛选 (可选)
- status: 按状态筛选 (0待审/1通过/2驳回/3完成, 可选)
"""
try:
page = int(request.args.get('page', 1))
limit = int(request.args.get('limit', 10))
applicant_id = request.args.get('applicant_id')
if applicant_id:
applicant_id = int(applicant_id)
status = request.args.get('status')
if status is not None:
status = int(status)
result = OutboundApprovalService.get_request_list(
page=page,
per_page=limit,
applicant_id=applicant_id,
status=status
)
return jsonify({
'code': 200,
'msg': '获取成功',
'data': result
}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': str(e)}), 500
# --------------------------------------------------------
# 7. 获取单个审批单详情
# GET /api/v1/outbound/request/<id>
# --------------------------------------------------------
@outbound_bp.route('/request/<int:request_id>', methods=['GET'])
@jwt_required()
def get_outbound_request_detail(request_id):
"""获取出库审批单详情"""
try:
approval = OutboundApprovalService.get_request_by_id(request_id)
if not approval:
return jsonify({'code': 404, 'msg': '审批单不存在'}), 404
return jsonify({
'code': 200,
'msg': '获取成功',
'data': approval.to_dict()
}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': str(e)}), 500

View File

@ -0,0 +1,233 @@
import traceback
from flask import Blueprint, request, jsonify, current_app
from flask_jwt_extended import jwt_required, get_jwt_identity
from app.services.purchase_service import PurchaseService
from app.utils.decorators import permission_required
purchase_bp = Blueprint('purchase', __name__, url_prefix='/api/v1/purchase')
def get_current_user_id():
"""获取当前登录用户ID"""
identity = get_jwt_identity()
return identity
def get_current_user_role():
"""获取当前用户角色"""
from flask_jwt_extended import get_jwt
claims = get_jwt()
return claims.get('role')
# --------------------------------------------------------
# 1. 采购申请列表
# GET /api/v1/purchase
# --------------------------------------------------------
@purchase_bp.route('', methods=['GET'])
@jwt_required()
def get_purchase_list():
"""获取采购申请列表"""
try:
page = int(request.args.get('page', 1))
per_page = int(request.args.get('limit', 20))
status = request.args.get('status')
status = int(status) if status is not None else None
user_id = get_current_user_id()
role = get_current_user_role()
# 普通用户SUPERVISOR 和 SUPER_ADMIN 除外)只看自己提交的
is_admin = role in ('SUPERVISOR', 'SUPER_ADMIN')
result = PurchaseService.get_purchase_list(
page=page,
per_page=per_page,
requester_id=None if is_admin else user_id,
status=status
)
return jsonify({'code': 200, 'msg': '获取成功', 'data': result})
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'获取失败: {str(e)}'}), 500
# --------------------------------------------------------
# 2. 创建采购申请
# POST /api/v1/purchase
# --------------------------------------------------------
@purchase_bp.route('', methods=['POST'])
@jwt_required()
def create_purchase_request():
"""创建采购申请"""
try:
data = request.get_json()
if not data:
return jsonify({'code': 400, 'msg': '无有效数据'}), 400
user_id = get_current_user_id()
# 必填校验
required = ['name', 'quantity', 'purchase_date', 'approver_id']
for field in required:
if field not in data or str(data.get(field, '')).strip() == '':
return jsonify({'code': 400, 'msg': f'缺少必填字段: {field}'}), 400
# 图片必填强校验
images = data.get('images')
if not images or (isinstance(images, list) and len(images) == 0):
return jsonify({'code': 400, 'msg': '请上传采购凭证/物品图片'}), 400
purchase = PurchaseService.create_purchase_request(data, requester_id=user_id)
return jsonify({
'code': 200,
'msg': '创建成功',
'data': purchase.to_dict()
}), 200
except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
# --------------------------------------------------------
# 3. 获取采购申请详情
# GET /api/v1/purchase/<id>
# --------------------------------------------------------
@purchase_bp.route('/<int:purchase_id>', methods=['GET'])
@jwt_required()
def get_purchase_detail(purchase_id):
"""获取采购申请详情"""
try:
purchase = PurchaseService.get_purchase_by_id(purchase_id)
if not purchase:
return jsonify({'code': 404, 'msg': '采购申请不存在'}), 404
# 普通用户只能看自己的
user_id = get_current_user_id()
role = get_current_user_role()
is_admin = role in ('SUPERVISOR', 'SUPER_ADMIN')
if not is_admin and purchase['requester_id'] != user_id:
return jsonify({'code': 403, 'msg': '无权查看此申请'}), 403
return jsonify({'code': 200, 'msg': '获取成功', 'data': purchase}), 200
except Exception as e:
return jsonify({'code': 500, 'msg': str(e)}), 500
# --------------------------------------------------------
# 4. 审批采购申请
# PATCH /api/v1/purchase/<id>/approve
# --------------------------------------------------------
@purchase_bp.route('/<int:purchase_id>/approve', methods=['PATCH'])
@jwt_required()
def approve_purchase_request(purchase_id):
"""审批采购申请"""
try:
user_id = get_current_user_id()
role = get_current_user_role()
if role not in ('SUPERVISOR', 'SUPER_ADMIN'):
return jsonify({'code': 403, 'msg': '只有主管或超级管理员可以审批'}), 403
data = request.get_json() or {}
action = data.get('action', 'approve')
reject_reason = data.get('reject_reason')
if action not in ('approve', 'reject'):
return jsonify({'code': 400, 'msg': '无效的审批操作'}), 400
if action == 'reject' and not reject_reason:
return jsonify({'code': 400, 'msg': '驳回时必须提供原因'}), 400
purchase = PurchaseService.approve_purchase_request(
purchase_id=purchase_id,
user_id=user_id,
action=action,
reject_reason=reject_reason
)
msg = '审批通过' if action == 'approve' else '已驳回'
return jsonify({'code': 200, 'msg': msg, 'data': purchase.to_dict()}), 200
except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
# --------------------------------------------------------
# 5. 获取可选审批人列表
# GET /api/v1/purchase/approvers
# --------------------------------------------------------
@purchase_bp.route('/approvers', methods=['GET'])
@jwt_required()
def get_purchase_approvers():
"""获取可选审批人列表(主管+超管)"""
try:
from app.models.system import SysUser
users = SysUser.query.filter(
SysUser.role.in_(['SUPER_ADMIN', 'SUPERVISOR']),
SysUser.status == 'active'
).all()
return jsonify({
'code': 200,
'msg': '获取成功',
'data': [
{'id': u.id, 'username': u.username, 'email': u.email or '', 'role': u.role}
for u in users
]
}), 200
except Exception as e:
return jsonify({'code': 500, 'msg': str(e)}), 500
# --------------------------------------------------------
# 6. 根据名称/规格自动补全
# GET /api/v1/purchase/auto-fill?keyword=xxx
# --------------------------------------------------------
@purchase_bp.route('/auto-fill', methods=['GET'])
@jwt_required()
def auto_fill_purchase():
"""根据名称或规格自动补全另一个字段"""
try:
keyword = request.args.get('keyword', '').strip()
if not keyword:
return jsonify({'code': 200, 'msg': 'ok', 'data': None}), 200
result = PurchaseService.auto_fill_from_material(keyword)
return jsonify({'code': 200, 'msg': 'ok', 'data': result}), 200
except Exception as e:
return jsonify({'code': 500, 'msg': str(e)}), 500
# --------------------------------------------------------
# 7. 物料基础信息搜索(分页)
# GET /api/v1/purchase/search-material?keyword=xxx&page=1
# --------------------------------------------------------
@purchase_bp.route('/search-material', methods=['GET'])
@jwt_required()
def search_material_for_purchase():
"""物料基础信息搜索接口,支持分页,用于采购申请弹窗"""
try:
keyword = request.args.get('keyword', '')
page = request.args.get('page', 1, type=int)
limit = 20
result = PurchaseService.search_base_material(keyword, page, limit)
return jsonify({
'code': 200,
'msg': 'success',
'data': result['items'],
'total': result['total'],
'has_next': result['has_next']
}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': str(e)}), 500

View File

@ -0,0 +1,69 @@
"""
扫码查库存接口(移动端专用)
GET /api/v1/scan/inventory?barcode=xxx
"""
from flask import Blueprint, jsonify, request
from app.extensions import db
from app.models.base import MaterialBase
from app.models.inbound.buy import StockBuy
from app.models.inbound.product import StockProduct
from app.models.inbound.semi import StockSemi
scan_bp = Blueprint('scan', __name__, url_prefix='/scan')
def _build_response(stock_record, stock_type: str) -> dict:
"""联表 MaterialBase 提取物料信息并组装返回结构"""
material = MaterialBase.query.get(stock_record.base_id)
return {
'code': 200,
'data': {
'materialName': material.name if material else '未知物料',
'spec': material.spec_model if material else '',
'location': stock_record.warehouse_location or '',
'quantity': float(stock_record.available_quantity) if stock_record.available_quantity else 0.0,
'stockType': stock_type
}
}
@scan_bp.route('/inventory', methods=['GET'])
def scan_inventory():
"""
扫码精确查找库存
入参: barcode (query string)
逻辑: 在 StockBuy / StockProduct / StockSemi 三表中精确匹配,只要命中一张即返回
"""
barcode = (request.args.get('barcode') or '').strip()
if not barcode:
return jsonify({'code': 400, 'msg': 'barcode 参数不能为空'}), 400
# 1. 采购库
buy = StockBuy.query.filter(
StockBuy.barcode == barcode,
StockBuy.stock_quantity > 0
).first()
if buy:
return jsonify(_build_response(buy, '采购库'))
# 2. 成品库
product = StockProduct.query.filter(
StockProduct.barcode == barcode,
StockProduct.stock_quantity > 0
).first()
if product:
return jsonify(_build_response(product, '成品库'))
# 3. 半成品库
semi = StockSemi.query.filter(
StockSemi.barcode == barcode,
StockSemi.stock_quantity > 0
).first()
if semi:
return jsonify(_build_response(semi, '半成品库'))
# 4. 全部未命中
return jsonify({
'code': 404,
'msg': f'未找到条码 [{barcode}] 对应的库存记录,或该物料当前库存为零'
}), 404

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',
'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:
@ -66,26 +67,6 @@ def filter_item_by_permissions(item_dict, user_permissions, prefix='op_records')
)
def create_borrow():
data = request.get_json()
# 数据清洗:移除用户没有权限的字段
user_permissions = get_current_user_permissions()
# 超级管理员不过滤
if '*' not in user_permissions:
field_to_perm = {
'borrow_no': 'op_records:borrow_no',
'borrower_name': 'op_records:borrower_name',
'sku': 'op_records:sku',
'borrow_time': 'op_records:borrow_time',
'return_time': 'op_records:return_time',
'status': 'op_records:status',
'expected_return_time': 'op_records:expected_return_time',
'return_location': 'op_records:return_location',
'borrow_signature': 'op_records:borrow_signature',
'return_signature': 'op_records:return_signature',
}
for field in list(data.keys()):
perm_code = field_to_perm.get(field)
if perm_code and perm_code not in user_permissions:
data.pop(field, None)
try:
no = TransService.create_borrow(data)
return jsonify({'code': 200, 'msg': '借用成功', 'data': {'borrow_no': no}})
@ -120,26 +101,6 @@ def scan_borrowed_item():
)
def submit_return():
data = request.get_json()
# 数据清洗:移除用户没有权限的字段
user_permissions = get_current_user_permissions()
# 超级管理员不过滤
if '*' not in user_permissions:
field_to_perm = {
'borrow_no': 'op_records:borrow_no',
'borrower_name': 'op_records:borrower_name',
'sku': 'op_records:sku',
'borrow_time': 'op_records:borrow_time',
'return_time': 'op_records:return_time',
'status': 'op_records:status',
'expected_return_time': 'op_records:expected_return_time',
'return_location': 'op_records:return_location',
'borrow_signature': 'op_records:borrow_signature',
'return_signature': 'op_records:return_signature',
}
for field in list(data.keys()):
perm_code = field_to_perm.get(field)
if perm_code and perm_code not in user_permissions:
data.pop(field, None)
user = get_jwt_identity() # 库管
try:
TransService.process_return(data, operator_name=user)

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,6 +14,12 @@ except ImportError:
# 4. 出库记录 (如果有BuyService 用到了 TransOutbound)
try:
from app.models.outbound import TransOutbound
from app.models.outbound import TransOutbound, OutboundApproval
except ImportError:
pass
# 5. 采购申请
try:
from app.models.purchase import PurchaseRequest
except ImportError:
pass

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='计量单位')
# 可见等级
@ -101,6 +101,10 @@ class MaterialWarningSetting(db.Model):
is_enabled = db.Column(db.Boolean, default=False, comment='是否启用预警')
yellow_threshold = db.Column(db.Numeric(10, 2), nullable=True, comment='黄色预警阈值')
red_threshold = db.Column(db.Numeric(10, 2), nullable=True, comment='红色预警阈值')
yellow_emails = db.Column(db.String(500), nullable=True, comment='黄色预警通知邮箱')
red_emails = db.Column(db.String(500), nullable=True, comment='红色预警通知邮箱')
is_ordered = db.Column(db.Boolean, default=False, comment='是否已处理采购')
last_notified_at = db.Column(db.DateTime, nullable=True, comment='上次邮件通知时间')
# 关联关系
material = db.relationship('MaterialBase', back_populates='warning_settings')
@ -111,5 +115,9 @@ class MaterialWarningSetting(db.Model):
'baseId': self.base_id,
'isEnabled': bool(self.is_enabled),
'yellowThreshold': float(self.yellow_threshold) if self.yellow_threshold is not None else None,
'redThreshold': float(self.red_threshold) if self.red_threshold is not None else None
'redThreshold': float(self.red_threshold) if self.red_threshold is not None else None,
'yellowEmails': self.yellow_emails or '',
'redEmails': self.red_emails or '',
'isOrdered': bool(self.is_ordered),
'lastNotifiedAt': self.last_notified_at.strftime('%Y-%m-%d %H:%M:%S') if self.last_notified_at else None
}

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,5 +1,110 @@
from app.extensions import db, beijing_time
from app.models.system import SysUser
from datetime import datetime
import json
class OutboundApproval(db.Model):
"""
出库审批单模型
用于管理出库申请的多级审批流程
"""
__tablename__ = 'outbound_approval'
id = db.Column(db.Integer, primary_key=True)
# 审批单号
request_no = db.Column(db.String(100), unique=True, nullable=False, index=True)
# 申请人ID
applicant_id = db.Column(db.Integer, nullable=False, index=True)
# 申请说明
remark = db.Column(db.Text)
# 状态: 0-待审批, 1-已通过, 2-已驳回, 3-已完成(已出库)
status = db.Column(db.Integer, default=0, nullable=False)
# 允许审批的人员列表 (JSON格式: [{"type": "role", "value": "admin"}, {"type": "user", "value": "123"}])
allowed_approvers = db.Column(db.Text)
# 实际审批人ID (多人审批时记录第一个通过的)
actual_approver_id = db.Column(db.Integer, index=True)
# 审批时间
approved_at = db.Column(db.DateTime)
# 驳回原因
reject_reason = db.Column(db.Text)
# 明细快照 (存储出库物品的名称、规格、库位、数量等信息无SKU字段)
items_json = db.Column(db.Text)
# 创建时间和更新时间
created_at = db.Column(db.DateTime, default=beijing_time, nullable=False)
updated_at = db.Column(db.DateTime, default=beijing_time, onupdate=beijing_time, nullable=False)
def _safe_parse_json(self, value):
"""
安全解析 JSON 字段:
- 如果 value 已是 list/dict直接返回
- 如果是 str尝试 json.loads()
- 解析失败或为 None/空,均返回 []
"""
if value is None:
return []
if isinstance(value, (list, dict)):
return value
if isinstance(value, str):
val = value.strip()
if not val:
return []
try:
parsed = json.loads(val)
return parsed if isinstance(parsed, list) else []
except (json.JSONDecodeError, TypeError, ValueError):
return []
return []
def get_items(self):
"""解析 items_json返回物品列表"""
return self._safe_parse_json(self.items_json)
def set_items(self, items):
"""设置 items_json"""
self.items_json = json.dumps(items, ensure_ascii=False) if items else '[]'
def get_allowed_approvers(self):
"""解析 allowed_approvers返回审批人列表"""
return self._safe_parse_json(self.allowed_approvers)
def set_allowed_approvers(self, approvers):
"""设置 allowed_approvers"""
self.allowed_approvers = json.dumps(approvers, ensure_ascii=False) if approvers else '[]'
def to_dict(self):
return {
'id': self.id,
'request_no': self.request_no,
'applicant_id': self.applicant_id,
'applicant_name': self._get_user_name(self.applicant_id),
'remark': self.remark,
'status': self.status,
'status_text': ['待审批', '已通过', '已驳回', '已完成'][self.status] if self.status in [0, 1, 2, 3] else '未知',
'allowed_approvers': self.get_allowed_approvers(),
'actual_approver_id': self.actual_approver_id,
'approver_name': self._get_user_name(self.actual_approver_id) if self.actual_approver_id else None,
'approved_at': self.approved_at.strftime('%Y-%m-%d %H:%M:%S') if self.approved_at else None,
'reject_reason': self.reject_reason,
'items': self.get_items(),
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None,
'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S') if self.updated_at else None,
}
def _get_user_name(self, user_id):
"""根据用户ID获取用户名"""
if not user_id:
return ""
from app.models.system import SysUser
try:
# ★ 必须用 .get() 按主键 ID 查询,千万不能用 username=user_id 去查
user = SysUser.query.get(user_id)
return user.username if user else f"未知用户({user_id})"
except Exception as e:
return f"用户({user_id})"
class TransOutbound(db.Model):

View File

@ -0,0 +1,86 @@
import json
from app.extensions import db, beijing_time
from datetime import datetime
class PurchaseRequest(db.Model):
"""
采购申请表
"""
__tablename__ = 'purchase_request'
id = db.Column(db.Integer, primary_key=True)
request_no = db.Column(db.String(100), unique=True, nullable=False, index=True)
name = db.Column(db.String(255), nullable=False, comment='采购名称')
spec_model = db.Column(db.String(255), comment='规格型号')
quantity = db.Column(db.Numeric(19, 4), nullable=False, comment='采购数量')
purchase_date = db.Column(db.Date, nullable=False, comment='采购时间')
supplier_link = db.Column(db.String(500), comment='商家地址链接')
remark = db.Column(db.Text, comment='备注信息')
images = db.Column(db.Text, comment='图片列表JSON')
unit_price = db.Column(db.Numeric(19, 4), default=0, comment='单价')
total_price = db.Column(db.Numeric(19, 4), default=0, comment='总价')
status = db.Column(db.Integer, default=0, nullable=False)
requester_id = db.Column(db.Integer, nullable=False, index=True)
approver_id = db.Column(db.Integer, index=True)
approved_at = db.Column(db.DateTime)
reject_reason = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=beijing_time, nullable=False)
updated_at = db.Column(db.DateTime, default=beijing_time, onupdate=beijing_time, nullable=False)
def _safe_parse_json(self, value):
if value is None:
return []
if isinstance(value, (list, dict)):
return value
if isinstance(value, str):
val = value.strip()
if not val:
return []
try:
parsed = json.loads(val)
return parsed if isinstance(parsed, list) else []
except Exception:
return []
return []
def get_images(self):
return self._safe_parse_json(self.images)
def set_images(self, image_list):
self.images = json.dumps(image_list, ensure_ascii=False) if image_list else '[]'
def _get_user_name(self, user_id):
if not user_id:
return ""
from app.models.system import SysUser
try:
user = db.session.get(SysUser, user_id)
return user.username if user else f"未知用户({user_id})"
except Exception:
return f"用户({user_id})"
def to_dict(self):
return {
'id': self.id,
'request_no': self.request_no,
'name': self.name,
'spec_model': self.spec_model or '',
'quantity': float(self.quantity) if self.quantity else 0,
'purchase_date': self.purchase_date.strftime('%Y-%m-%d') if self.purchase_date else None,
'supplier_link': self.supplier_link or '',
'remark': self.remark or '',
'images': self.get_images(),
'unit_price': float(self.unit_price) if self.unit_price else 0,
'total_price': float(self.total_price) if self.total_price else 0,
'status': self.status,
'status_text': ['待审批', '已通过', '已驳回', '已完成'][self.status] if self.status in [0, 1, 2, 3] else '未知',
'requester_id': self.requester_id,
'requester_name': self._get_user_name(self.requester_id),
'approver_id': self.approver_id,
'approver_name': self._get_user_name(self.approver_id) if self.approver_id else None,
'approved_at': self.approved_at.strftime('%Y-%m-%d %H:%M:%S') if self.approved_at else None,
'reject_reason': self.reject_reason or '',
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None,
'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S') if self.updated_at else None,
}

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
@ -57,7 +57,7 @@ def _get_token_from_redis(user_id):
class AuthService:
# 硬编码的超级管理员凭证
SUPER_ADMIN_USER = "IRIS"
SUPER_ADMIN_PASS = "licahk"
SUPER_ADMIN_PASS = "123321"
@staticmethod
def login(data):
@ -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

@ -3,9 +3,98 @@ from app.models.bom import BomTable
from app.models.base import MaterialBase
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 ======================
@ -64,6 +153,7 @@ class BomService:
BomTable.parent_id,
MaterialBase.name.label('parent_name'),
MaterialBase.spec_model.label('parent_spec'),
MaterialBase.category.label('parent_category'),
BomTable.is_enabled,
func.count(BomTable.child_id).label('child_count')
).join(
@ -72,7 +162,7 @@ class BomService:
BomTable.bom_no == bom_no,
BomTable.version == version
).group_by(
BomTable.parent_id, MaterialBase.name, MaterialBase.spec_model, BomTable.is_enabled
BomTable.parent_id, MaterialBase.name, MaterialBase.spec_model, MaterialBase.category, BomTable.is_enabled
).first()
if summary:
@ -82,18 +172,62 @@ class BomService:
'parent_id': summary.parent_id,
'parent_name': summary.parent_name,
'parent_spec': summary.parent_spec or '',
'parent_category': summary.parent_category or '',
'is_enabled': summary.is_enabled,
'child_count': summary.child_count
})
results.sort(key=lambda x: (x['bom_no'], x['version']), reverse=True)
return results
# 如果有关键词,二次过滤结果(忽略大小写)
if keyword:
kw = keyword.lower()
results = [
r for r in results
if kw in (r.get('parent_name') or '').lower()
or kw in (r.get('parent_spec') or '').lower()
or kw in (r.get('bom_no') or '').lower()
or kw in (r.get('parent_category') or '').lower()
]
# 按 parent_category 分组
grouped = defaultdict(list)
for item in results:
cat = item.get('parent_category') or '未分类'
grouped[cat].append(item)
grouped_list = []
for cat, items in sorted(grouped.items(), key=lambda x: x[0]):
grouped_list.append({
'category': cat,
'count': len(items),
'items': items
})
return grouped_list
@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'),
@ -112,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:
@ -131,7 +267,7 @@ class BomService:
'remark': bom.remark or ''
})
return {
result = {
'bom_no': bom_no,
'version': first.BomTable.version,
'parent_id': parent_id,
@ -141,6 +277,11 @@ class BomService:
'children': children
}
# ===== 第三步:写入 Redis 缓存TTL=12h失败只打日志不阻断 =====
_cache_set(bom_no, version, result)
return result
@staticmethod
def save_bom(data):
"""保存 BOM (支持多版本),新增跨版本内容查重"""
@ -210,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
@ -277,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

@ -187,7 +187,9 @@ class MaterialBaseService:
inner_sub.c.total_avail,
MaterialWarningSetting.is_enabled.label('warning_enabled'),
MaterialWarningSetting.yellow_threshold.label('warning_yellow'),
MaterialWarningSetting.red_threshold.label('warning_red')
MaterialWarningSetting.red_threshold.label('warning_red'),
MaterialWarningSetting.red_emails.label('warning_red_emails'),
MaterialWarningSetting.yellow_emails.label('warning_yellow_emails')
).outerjoin(inner_sub, MaterialBase.id == inner_sub.c.base_id) \
.outerjoin(MaterialWarningSetting, MaterialBase.id == MaterialWarningSetting.base_id)
@ -375,34 +377,29 @@ class MaterialBaseService:
if enable_warning_sort:
print("====== [DEBUG] 成功进入预警强排逻辑 ======")
# 直接在 order_by 中进行计算排序,不污染 select 列
inv_val = inner_sub.c.total_inv
red_val = cast(MaterialWarningSetting.red_threshold, Numeric)
yellow_val = cast(MaterialWarningSetting.yellow_threshold, Numeric)
# 预警等级计算:红=2, 黄=1, 正常=0
warning_level = case(
(and_(MaterialWarningSetting.is_enabled.is_(True), inv_val <= red_val), 2),
(and_(MaterialWarningSetting.is_enabled.is_(True), inv_val <= yellow_val), 1),
(and_(MaterialWarningSetting.is_enabled.is_(True), red_val.isnot(None), inv_val <= red_val), 2),
(and_(MaterialWarningSetting.is_enabled.is_(True), yellow_val.isnot(None), inv_val <= yellow_val), 1),
else_=0
)
# 统一计算缺口 (Shortage) = 目标阈值 - 当前库存
# 红色算红色的缺口,黄色算黄色的缺口,越大说明缺的越多
shortage = case(
(and_(MaterialWarningSetting.is_enabled.is_(True), red_val.isnot(None), inv_val <= red_val), red_val - inv_val),
(and_(MaterialWarningSetting.is_enabled.is_(True), yellow_val.isnot(None), inv_val <= yellow_val), yellow_val - inv_val),
else_=0
)
# 红色预警时的缺口
red_shortage = case(
(and_(MaterialWarningSetting.is_enabled.is_(True), inv_val <= red_val), red_val - inv_val),
else_=0
)
# 黄色预警时的缺口
yellow_distance = case(
(and_(MaterialWarningSetting.is_enabled.is_(True), inv_val > red_val, inv_val <= yellow_val), inv_val - red_val),
else_=999999
)
# 直接在 order_by 中使用 case() 表达式
query = query.order_by(
desc(warning_level),
desc(red_shortage),
asc(yellow_distance),
desc(inv_val),
desc(warning_level), # 1. 先按红、黄、正常排
desc(shortage), # 2. 同级别内,缺口越大的排越上面
desc(inv_val), # 3. 缺口一样,库存多的排上面
desc(MaterialBase.id)
)
elif order_by_column:
@ -446,6 +443,8 @@ class MaterialBaseService:
warning_enabled = row[3] if len(row) > 3 else False
warning_yellow = row[4] if len(row) > 4 else 0
warning_red = row[5] if len(row) > 5 else 0
warning_red_emails = row[6] if len(row) > 6 else None
warning_yellow_emails = row[7] if len(row) > 7 else None
# 安全兜底
if not hasattr(item, 'to_dict'):
@ -460,14 +459,22 @@ class MaterialBaseService:
item_dict['warningEnabled'] = bool(warning_enabled) if warning_enabled is not None else False
item_dict['warningYellow'] = float(warning_yellow) if warning_yellow is not None else None
item_dict['warningRed'] = float(warning_red) if warning_red is not None else None
item_dict['warningRedEmails'] = warning_red_emails or ''
item_dict['warningYellowEmails'] = warning_yellow_emails or ''
# 计算预警状态
if warning_enabled and warning_red is not None:
if warning_enabled:
invQty = item_dict['inventoryCount']
if invQty <= warning_red:
# 优先判断红色预警(如果设置了红阈值,且库存 <= 红阈值)
if warning_red is not None and invQty <= warning_red:
item_dict['warningStatus'] = 2 # 红色
# 其次判断黄色预警(如果设置了黄阈值,且库存 <= 黄阈值)
elif warning_yellow is not None and invQty <= warning_yellow:
item_dict['warningStatus'] = 1 # 黄色
# 都不满足则正常
else:
item_dict['warningStatus'] = 0 # 正常
else:
@ -586,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

@ -0,0 +1,234 @@
"""
库存预警扫描与邮件通知服务
定时(或手动触发)扫描所有 is_enabled=True 且 is_ordered=False 的预警配置,
按物料配置的邮箱独立发送,不依赖 SysUser 角色。
- 库存 <= red_threshold → 红色预警邮件(发 setting.red_emails
- red_threshold < 库存 <= yellow_threshold → 黄色预警邮件(发 setting.yellow_emails
- 同一收件人在多条记录中出现 → 聚合为一封邮件
- 发送成功后更新 last_notified_at
"""
from datetime import datetime, timezone, timedelta
from collections import defaultdict
from sqlalchemy import func
from app.extensions import db
from app.models.base import MaterialBase, MaterialWarningSetting
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
class InventoryWarningService:
@staticmethod
def _get_total_inventory(base_id: int) -> float:
"""
计算指定物料在所有库存表(采购件 + 半成品 + 成品)中的总库存量
"""
buy_q = db.session.query(func.sum(StockBuy.stock_quantity)).filter(
StockBuy.base_id == base_id
).scalar() or 0
semi_q = db.session.query(func.sum(StockSemi.stock_quantity)).filter(
StockSemi.base_id == base_id
).scalar() or 0
prod_q = db.session.query(func.sum(StockProduct.stock_quantity)).filter(
StockProduct.base_id == base_id
).scalar() or 0
return float(buy_q) + float(semi_q) + float(prod_q)
@staticmethod
def _parse_emails(email_str: str) -> list:
"""从逗号分隔字符串中提取并清洗有效邮箱列表"""
if not email_str or not email_str.strip():
return []
return [e.strip() for e in email_str.split(',') if e.strip() and '@' in e.strip()]
@staticmethod
def _build_text_table(rows: list, level: str) -> str:
"""
构建纯文本物料清单表格
Args:
rows: [{"name": ..., "spec": ..., "qty": ..., "threshold": ..., "shortfall": ...}, ...]
level: "red""yellow",决定阈值列标题
"""
lines = [
"名称 | 规格 | 当前库存 | 缺少数量",
"-" * 55,
]
for r in rows:
name = r.get('name', '-') or '-'
spec = r.get('spec', '-') or '-'
qty = r.get('qty', '-')
shortfall = r.get('shortfall', '-')
lines.append(f"{name} | {spec} | {qty} | 差{shortfall}")
return '\n'.join(lines)
@staticmethod
def check_and_send_warning_emails() -> dict:
"""
执行库存预警扫描与邮件发送
1. 查询所有 is_enabled=True 且 is_ordered=False 的预警配置
2. 按 level 归类物料,按邮箱聚合(同一邮箱 → 一封邮件)
3. 调用 send_email 发送,更新 last_notified_at
Returns:
{
"red_count": N, # 触发红色预警的物料数
"yellow_count": N, # 触发黄色预警的物料数
"red_sent": True/False,
"yellow_sent": True/False,
"timestamp": "..."
}
"""
from app.utils.email_service import send_email
beijing_tz = timezone(timedelta(hours=8))
now = datetime.now(beijing_tz)
# 查询启用了预警且未标记采购的配置
settings = MaterialWarningSetting.query.filter(
MaterialWarningSetting.is_enabled == True,
MaterialWarningSetting.is_ordered == False
).all()
red_rows_by_email = defaultdict(list) # email -> [物料row, ...]
yellow_rows_by_email = defaultdict(list)
total_red = 0
total_yellow = 0
total_red_cascaded = 0 # 红色顺延到黄色
total_yellow_cascaded = 0 # 黄色顺延到红色
sent_red = False
sent_yellow = False
processed_settings = []
for setting in settings:
base_id = setting.base_id
material = MaterialBase.query.get(base_id)
if not material:
continue
name = material.name
spec = material.spec_model or ''
red_th = float(setting.red_threshold) if setting.red_threshold is not None else None
yellow_th = float(setting.yellow_threshold) if setting.yellow_threshold is not None else None
inv = InventoryWarningService._get_total_inventory(base_id)
# ★ 红色预警:库存 <= red_threshold走 setting.red_emails ★
if red_th is not None and inv <= red_th:
total_red += 1
red_emails = InventoryWarningService._parse_emails(setting.red_emails)
emails_to_use = red_emails
use_yellow_channel = False # 是否走黄色通道(顺延时为 True
if not emails_to_use:
# ★ 红色预警但无 red_emails顺延使用 yellow_emails ★
emails_to_use = InventoryWarningService._parse_emails(setting.yellow_emails)
if emails_to_use:
total_yellow += 1
total_red_cascaded += 1
use_yellow_channel = True
print(f"[InventoryWarning] 物料「{name}」红色预警触发,但 red_emails 为空,顺延使用 yellow_emails 发黄色预警")
if emails_to_use:
processed_settings.append(setting)
row = {
'name': name,
'spec': spec,
'qty': round(inv, 2),
'threshold': round(red_th, 2),
'shortfall': round(red_th - inv, 2),
}
if use_yellow_channel:
for email in emails_to_use:
yellow_rows_by_email[email].append(row)
else:
for email in emails_to_use:
red_rows_by_email[email].append(row)
else:
print(f"[InventoryWarning] 物料「{name}」红单跳过:无 red_emails 且 yellow_emails 也为空")
# ★ 黄色预警red_threshold < 库存 <= yellow_threshold走 setting.yellow_emails ★
elif (
(red_th is not None and yellow_th is not None and red_th < inv <= yellow_th)
or (red_th is None and yellow_th is not None and inv <= yellow_th)
):
total_yellow += 1
yellow_emails = InventoryWarningService._parse_emails(setting.yellow_emails)
emails_to_use = yellow_emails
use_red_channel = False # 是否走红色通道(顺延时为 True
if not emails_to_use:
# ★ 黄色预警但无 yellow_emails顺延使用 red_emails ★
emails_to_use = InventoryWarningService._parse_emails(setting.red_emails)
if emails_to_use:
total_red += 1
total_yellow_cascaded += 1
use_red_channel = True
print(f"[InventoryWarning] 物料「{name}」黄色预警触发,但 yellow_emails 为空,顺延使用 red_emails 发红色预警")
if emails_to_use:
processed_settings.append(setting)
row = {
'name': name,
'spec': spec,
'qty': round(inv, 2),
'threshold': round(yellow_th, 2),
'shortfall': round(yellow_th - inv, 2),
}
if use_red_channel:
for email in emails_to_use:
red_rows_by_email[email].append(row)
else:
for email in emails_to_use:
yellow_rows_by_email[email].append(row)
else:
print(f"[InventoryWarning] 物料「{name}」黄单跳过:无 yellow_emails 且 red_emails 也为空")
else:
continue
# ★ 按邮箱聚合,批量发送红色预警邮件 ★
for email, rows in red_rows_by_email.items():
table = InventoryWarningService._build_text_table(rows, 'red')
subject = f"【红色预警】库存告急(共 {len(rows)} 条)"
content = (
f"您好,\n\n"
f"以下物料当前库存已达到红色预警阈值,请立即处理采购:\n\n"
f"{table}\n\n"
"详情请登录仓库管理系统查看。\n\n"
"此邮件由系统自动发送,请勿回复。"
)
send_email(email, subject, content)
sent_red = True
# ★ 按邮箱聚合,批量发送黄色预警邮件 ★
for email, rows in yellow_rows_by_email.items():
table = InventoryWarningService._build_text_table(rows, 'yellow')
subject = f"【黄色预警】库存偏低(共 {len(rows)} 条)"
content = (
f"您好,\n\n"
f"以下物料当前库存已达到黄色预警阈值,请关注采购进度:\n\n"
f"{table}\n\n"
"详情请登录仓库管理系统查看。\n\n"
"此邮件由系统自动发送,请勿回复。"
)
send_email(email, subject, content)
sent_yellow = True
# ★ 批量更新 last_notified_at ★
if processed_settings:
for s in processed_settings:
s.last_notified_at = now
db.session.commit()
return {
'red_count': total_red,
'yellow_count': total_yellow,
'red_cascaded_count': total_red_cascaded,
'yellow_cascaded_count': total_yellow_cascaded,
'red_sent': sent_red,
'yellow_sent': sent_yellow,
'timestamp': now.strftime('%Y-%m-%d %H:%M:%S')
}

View File

@ -1,8 +1,9 @@
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
from app.models.outbound import TransOutbound, OutboundApproval
# 引入所有库存模型以进行查询
from app.models.inbound.buy import StockBuy
@ -12,6 +13,8 @@ from app.models.inbound.product import StockProduct
from app.models.base import MaterialBase
# 引入维修单表
from app.models.transaction import TransRepair
# 引入系统用户表
from app.models.system import SysUser
class OutboundService:
@ -169,6 +172,22 @@ class OutboundService:
beijing_tz = timezone(timedelta(hours=8))
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
# ★ 审批单相关逻辑
request_id = data.get('request_id')
approval = None
if request_id:
# 根据 request_id 查询审批单
approval = OutboundApproval.query.get(request_id)
if not approval:
raise ValueError(f"关联的审批单不存在 (ID: {request_id})")
if approval.status != 1:
status_map = {0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成'}
current_status = status_map.get(approval.status, str(approval.status))
raise ValueError(
f"关联的审批单状态不允许出库 (当前状态: {current_status})"
f"仅已通过的审批单方可执行出库"
)
model_map = {
'stock_buy': StockBuy,
'stock_semi': StockSemi,
@ -235,6 +254,18 @@ class OutboundService:
)
db.session.add(new_record)
# ★ 出库后检查低库存预警
try:
from app.services.inventory_task import InventoryWarningService
InventoryWarningService.check_and_send_warning_emails()
except Exception as e:
current_app.logger.warning(f"⚠️ 低库存预警检查失败: {e}")
# ★ 如果关联了审批单,出库成功后更新审批单状态为"已完成"
if approval:
approval.status = 3 # 3-已完成
# updated_at 会在 commit 时由 SQLAlchemy 自动更新
db.session.commit()
return outbound_no
@ -445,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:
@ -460,36 +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:
# 注意这里在循环中查询可能会有N+1问题但考虑到单页数据量通常每单条目不多暂时可接受
# 生产环境建议优化为预加载或批量查询
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
@ -525,3 +571,410 @@ class OutboundService:
'pages': pagination.pages,
'current_page': page
}
class OutboundApprovalService:
"""出库审批服务"""
@staticmethod
def generate_request_no():
"""
生成审批单号: APR-OUT-yyyyMMdd-HHmm-当日流水(4位)
"""
beijing_tz = timezone(timedelta(hours=8))
now = datetime.now(beijing_tz)
date_str = now.strftime('%Y%m%d')
time_str = now.strftime('%H%M')
prefix = f"APR-OUT-{date_str}-"
from app.models.outbound import OutboundApproval
latest = db.session.query(OutboundApproval.request_no).filter(
OutboundApproval.request_no.like(f"{prefix}%")
).order_by(OutboundApproval.id.desc()).first()
if latest:
last_seq = int(latest[0].split('-')[-1])
sequence = last_seq + 1
else:
sequence = 1
return f"APR-OUT-{date_str}-{time_str}-{sequence:04d}"
@staticmethod
def create_request(applicant_id, items, allowed_approvers, remark=None, approver_id=None):
"""
创建出库审批单(申请阶段,直接存储前端传来的物料信息快照,不关联具体库存记录)
Args:
applicant_id: 申请人ID
items: 出库物品明细列表,每个物品应包含:
- name: 物料名称 (必填)
- spec_model: 规格型号 (必填)
- quantity: 计划出库数量 (必填)
- warehouse_location: 库位 (可选)
- remark: 物品备注 (可选)
allowed_approvers: 允许审批的人员/角色列表
approver_id: 指定审批人ID可选传则覆盖 allowed_approvers
remark: 申请说明
Returns:
OutboundApproval 实例
Raises:
ValueError: 当 items 为空或缺少必填字段时抛出
"""
from app.models.outbound import OutboundApproval
# 校验 items 非空
if not items:
raise ValueError("出库物品列表不能为空")
# 校验每个物品的宏观字段 (name, spec_model, quantity)
required_fields = ['name', 'spec_model', 'quantity']
for idx, item in enumerate(items):
missing_fields = [f for f in required_fields if f not in item or str(item.get(f) or '').strip() == '']
if missing_fields:
raise ValueError(
f"{idx + 1} 条物品缺少必填字段: {', '.join(missing_fields)}"
f"必须包含: name, spec_model, quantity"
)
try:
qty = float(item.get('quantity', 0))
if qty <= 0:
raise ValueError(f"{idx + 1} 条物品的出库数量必须大于0")
except (TypeError, ValueError) as e:
raise ValueError(f"{idx + 1} 条物品的 quantity 格式无效: {str(e)}")
# ★ 校验 allowed_approvers 非空
if not allowed_approvers:
raise ValueError("必须指定至少一位审批人")
# ★ 指定审批人模式approver_id 覆盖 allowed_approvers
if approver_id:
allowed_approvers = [{"type": "user", "value": int(approver_id)}]
request_no = OutboundApprovalService.generate_request_no()
approval = OutboundApproval(
request_no=request_no,
applicant_id=applicant_id,
remark=remark,
status=0, # 待审批
)
# 直接存储前端传来的物料信息快照,不查询/不关联具体库存记录
approval.set_items(items)
approval.set_allowed_approvers(allowed_approvers)
db.session.add(approval)
db.session.commit()
# ★ 创建成功后,发送邮件通知审批人(精确通知 approver_id 对应的邮箱)
OutboundApprovalService._notify_new_request(approval, applicant_id, approver_id=approver_id)
return approval
@staticmethod
def _get_emails_by_identifiers(applicant_id=None, role_codes=None):
"""
根据用户ID或角色列表查询邮箱地址
Args:
applicant_id: 用户ID (按 SysUser.id 查找)
role_codes: 角色代码列表,如 ['ADMIN', 'WAREHOUSE_ADMIN']
Returns:
去重后的邮箱地址列表
"""
emails = []
if applicant_id:
user = SysUser.query.get(int(applicant_id))
if user and user.email:
emails.append(user.email)
if role_codes:
for code in role_codes:
users = SysUser.query.filter_by(role=code).all()
for u in users:
if u.email:
emails.append(u.email)
return list(set(emails))
@staticmethod
def _notify_new_request(approval, applicant_id, approver_id=None):
"""发送新申请通知邮件给审批人和申请人(静默处理,不阻断主流程)"""
try:
from flask import current_app
from app.utils.email_service import send_new_request_notify
from app.models.system import SysUser
applicant_name = ''
applicant_emails = []
# 1. 收集申请人信息
if applicant_id:
user = SysUser.query.get(int(applicant_id))
if user and user.email:
applicant_emails.append(user.email)
applicant_name = str(user.username).split('/')[0] if '/' in (user.username or '') else (user.username or str(applicant_id))
# 2. 收集审批人信息
approver_emails = []
if approver_id:
user = SysUser.query.get(int(approver_id))
if user and user.email:
approver_emails.append(user.email)
else:
# 兜底:按角色查询
approvers = approval.get_allowed_approvers()
role_codes = []
for a in approvers:
if a.get('type') == 'role':
role_codes.append(a.get('value', ''))
approver_emails = OutboundApprovalService._get_emails_by_identifiers(role_codes=role_codes)
# 去重
all_emails = list(set(applicant_emails + approver_emails))
if not all_emails:
current_app.logger.info(f"[Email] 审批单 {approval.request_no} 无收件人邮箱,跳过通知")
return
# 3. 获取物料明细
items = approval.get_items()
# 4. 分别发送邮件
if applicant_emails:
try:
send_new_request_notify(
to_emails=applicant_emails,
request_no=approval.request_no,
applicant_name=applicant_name,
remark=f"您的出库申请已提交,等待审批。{approval.remark or ''}",
items=items,
is_applicant_notify=True
)
except Exception as e:
current_app.logger.error(f"[Email] 通知申请人失败: {e}")
if approver_emails:
try:
send_new_request_notify(
to_emails=approver_emails,
request_no=approval.request_no,
applicant_name=applicant_name,
remark=approval.remark or '',
items=items,
is_applicant_notify=False
)
except Exception as e:
current_app.logger.error(f"[Email] 通知审批人失败: {e}")
except Exception as e:
try:
from flask import current_app
current_app.logger.error(f"[Email] 发送新申请通知邮件失败: {e}")
except RuntimeError:
import logging
logging.getLogger(__name__).error(f"[Email] 发送新申请通知邮件失败: {e}")
@staticmethod
def can_approve(approval, user_id, user_role):
"""
检查用户是否有权限审批
Args:
approval: OutboundApproval 实例
user_id: 用户ID
user_role: 用户角色
Returns:
bool, 是否有权限
"""
approvers = approval.get_allowed_approvers()
# 超级管理员可以直接审批
if user_role and user_role.upper() == 'SUPER_ADMIN':
return True
for approver in approvers:
approver_type = approver.get('type', '')
approver_value = approver.get('value', '')
if approver_type == 'user' and str(approver_value) == str(user_id):
return True
if approver_type == 'role' and approver_value == user_role:
return True
return False
@staticmethod
def approve(request_id, user_id, user_role, action='approve', reject_reason=None):
"""
执行审批操作
Args:
request_id: 审批单ID
user_id: 审批人ID
user_role: 审批人角色
action: 'approve' 通过, 'reject' 驳回
reject_reason: 驳回原因
Returns:
(success: bool, message: str, approval: OutboundApproval or None)
"""
from app.models.outbound import OutboundApproval
beijing_tz = timezone(timedelta(hours=8))
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
approval = OutboundApproval.query.get(request_id)
if not approval:
return False, "审批单不存在", None
if approval.status != 0:
return False, f"审批单状态已更新,无法重复审批 (当前状态: {approval.status})", None
if not OutboundApprovalService.can_approve(approval, user_id, user_role):
return False, "您没有审批此单的权限", None
try:
if action == 'approve':
approval.status = 1 # 已通过
approval.actual_approver_id = user_id
approval.approved_at = current_time
elif action == 'reject':
approval.status = 2 # 已驳回
approval.reject_reason = reject_reason
else:
return False, "无效的审批操作", None
db.session.commit()
# ★ 审批成功后,发送邮件通知仓库管理员
OutboundApprovalService._notify_approval_result(approval, user_id, action)
return True, "审批成功", approval
except Exception as e:
db.session.rollback()
return False, f"审批失败: {str(e)}", None
@staticmethod
def _notify_approval_result(approval, approver_id, action):
"""发送审批结果通知邮件(静默处理,不阻断主流程)"""
import logging
logger = logging.getLogger(__name__)
try:
from app.utils.email_service import send_approval_result_notify, send_warehouse_dispatch_notify
from app.models.system import SysUser as SU
# 1. 提取申请人信息(供两个分支使用)
applicant_name = ''
applicant_emails = []
if approval.applicant_id:
user = SU.query.get(approval.applicant_id)
if user:
applicant_name = str(user.username).split('/')[0] if '/' in (user.username or '') else (user.username or '')
if user.email:
applicant_emails.append(user.email)
# 2. 提取物料明细(供通过分支使用)
items = approval.items_json if approval.items_json else []
# 3. 分支逻辑
if action == 'approve':
# 3.1 通知库管(带明细)
warehouse_role_codes = ['WAREHOUSE_MGR', 'OUTBOUND']
warehouse_emails = OutboundApprovalService._get_emails_by_identifiers(role_codes=warehouse_role_codes)
if warehouse_emails:
try:
send_warehouse_dispatch_notify(
to_emails=warehouse_emails,
request_no=approval.request_no,
applicant_name=applicant_name,
items=items
)
except Exception as e:
logger.error(f"[Email] 通知库管失败: {e}")
# 3.2 通知申请人(审批通过,带完整物料清单)
if applicant_emails:
try:
send_warehouse_dispatch_notify(
to_emails=applicant_emails,
request_no=approval.request_no,
applicant_name=applicant_name,
items=items
)
except Exception as e:
logger.error(f"[Email] 通知申请人(通过)失败: {e}")
elif action == 'reject':
# 3.3 通知申请人(已驳回)
if applicant_emails:
try:
send_approval_result_notify(
to_emails=applicant_emails,
request_no=approval.request_no,
is_passed=False,
reject_reason=approval.reject_reason or '未说明原因',
applicant_name=applicant_name
)
except Exception as e:
logger.error(f"[Email] 通知申请人驳回失败: {e}")
else:
logger.warning("[Email] 申请人无邮箱,无法发送驳回通知")
except Exception as e:
import traceback
traceback.print_exc()
logger.error(f"[Email] 外层发送异常: {e}")
@staticmethod
def get_request_list(page=1, per_page=10, applicant_id=None, status=None):
"""
获取审批单列表
Args:
page: 页码
per_page: 每页数量
applicant_id: 按申请人筛选 (可选)
status: 按状态筛选 (可选)
Returns:
分页结果
"""
from app.models.outbound import OutboundApproval
from sqlalchemy import desc
query = OutboundApproval.query
if applicant_id:
query = query.filter(OutboundApproval.applicant_id == applicant_id)
if status is not None:
query = query.filter(OutboundApproval.status == status)
query = query.order_by(desc(OutboundApproval.created_at))
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
return {
'items': [item.to_dict() for item in pagination.items],
'total': pagination.total,
'pages': pagination.pages,
'current_page': page
}
@staticmethod
def get_request_by_id(request_id):
"""根据ID获取审批单"""
from app.models.outbound import OutboundApproval
return OutboundApproval.query.get(request_id)

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]
@ -435,6 +487,7 @@ class PermissionService:
('outbound_selection', '出库选单', '/outbound/selection', 'outbound_mgmt', 1),
('outbound_create', '扫码出库', '/outbound/create', 'outbound_mgmt', 2),
('outbound_list', '出库记录', '/outbound/index', 'outbound_mgmt', 3),
('outbound_approval', '出库审批', '/outbound/approval', 'outbound_mgmt', 4),
# BOM管理子菜单
('bom_manage', 'BOM配方管理', '/bom/manage', 'bom_mgmt', 1),
@ -490,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:
@ -507,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

@ -0,0 +1,274 @@
import json
from datetime import datetime, timezone, timedelta, date
from sqlalchemy import func
from app.extensions import db
from app.models.purchase import PurchaseRequest
from app.models.base import MaterialBase
class PurchaseService:
@staticmethod
def generate_request_no():
"""生成采购单号: PUR-yyyyMMdd-HHmm-当日流水(4位)"""
beijing_tz = timezone(timedelta(hours=8))
now = datetime.now(beijing_tz)
date_str = now.strftime('%Y%m%d')
time_str = now.strftime('%H%M')
prefix = f"PUR-{date_str}-{time_str}-"
existing_count = db.session.query(func.count(func.distinct(PurchaseRequest.request_no))) \
.filter(PurchaseRequest.request_no.like(f"{prefix}%")).scalar()
return f"{prefix}{(existing_count + 1):04d}"
@staticmethod
def auto_fill_from_material(keyword: str):
"""
根据 name 或 spec_model 自动补全另一个字段
keyword: 用户输入的名称或规格
返回: {'name': ..., 'spec_model': ...} 或 None
"""
if not keyword:
return None
material = MaterialBase.query.filter(
(MaterialBase.name.ilike(f'%{keyword}%')) |
(MaterialBase.spec_model.ilike(f'%{keyword}%'))
).first()
if material:
return {
'name': material.name,
'spec_model': material.spec_model or ''
}
return None
@staticmethod
def create_purchase_request(data: dict, requester_id: int):
"""
创建采购申请
data 包含: name, spec_model, quantity, purchase_date, supplier_link, remark, images,
unit_price, total_price, approver_id
"""
request_no = PurchaseService.generate_request_no()
purchase_date = data.get('purchase_date')
if isinstance(purchase_date, str):
purchase_date = datetime.strptime(purchase_date, '%Y-%m-%d').date()
elif isinstance(purchase_date, datetime):
purchase_date = purchase_date.date()
purchase = PurchaseRequest(
request_no=request_no,
name=data['name'],
spec_model=data.get('spec_model', ''),
quantity=float(data['quantity']),
purchase_date=purchase_date,
supplier_link=data.get('supplier_link', ''),
remark=data.get('remark', ''),
images=json.dumps(data.get('images', []), ensure_ascii=False) if data.get('images') else '[]',
unit_price=float(data.get('unit_price', 0) or 0),
total_price=float(data.get('total_price', 0) or 0),
requester_id=requester_id,
approver_id=data.get('approver_id'),
status=0
)
db.session.add(purchase)
db.session.commit()
# 发送邮件给审批人
PurchaseService._notify_new_request(purchase)
return purchase
@staticmethod
def approve_purchase_request(purchase_id: int, user_id: int, action: str, reject_reason: str = None):
"""
审批采购申请
action: 'approve''reject'
"""
purchase = db.session.get(PurchaseRequest, purchase_id)
if not purchase:
raise ValueError("采购申请不存在")
if purchase.status != 0:
raise ValueError("当前状态不允许审批")
beijing_tz = timezone(timedelta(hours=8))
now = datetime.now(beijing_tz)
if action == 'approve':
purchase.status = 1
purchase.approver_id = user_id
purchase.approved_at = now
db.session.commit()
PurchaseService._notify_approved(purchase)
elif action == 'reject':
purchase.status = 2
purchase.approver_id = user_id
purchase.approved_at = now
purchase.reject_reason = reject_reason or ''
db.session.commit()
PurchaseService._notify_rejected(purchase)
else:
raise ValueError("无效的审批操作")
return purchase
@staticmethod
def get_purchase_list(page=1, per_page=20, requester_id=None, status=None):
"""获取采购申请列表,普通用户只看自己的,主管/超管看全部"""
query = PurchaseRequest.query
if requester_id is not None:
query = query.filter(PurchaseRequest.requester_id == requester_id)
if status is not None:
query = query.filter(PurchaseRequest.status == status)
query = query.order_by(PurchaseRequest.created_at.desc())
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
return {
'items': [p.to_dict() for p in pagination.items],
'total': pagination.total,
'pages': pagination.pages,
'current_page': page
}
@staticmethod
def get_purchase_by_id(purchase_id: int):
purchase = db.session.get(PurchaseRequest, purchase_id)
return purchase.to_dict() if purchase else None
@staticmethod
def search_base_material(keyword: str, page: int = 1, limit: int = 20):
"""
物料基础信息搜索,支持 name/spec_model/company_name 模糊匹配,返回分页结果
用于采购申请弹窗的物料远程搜索
"""
from sqlalchemy import and_, or_
query = MaterialBase.query.filter(MaterialBase.is_enabled == True)
if keyword:
k = keyword.strip()
k_str = f'%{k}%'
query = query.filter(or_(
MaterialBase.name.ilike(k_str),
MaterialBase.spec_model.ilike(k_str),
MaterialBase.company_name.ilike(k_str)
))
query = query.order_by(MaterialBase.id.desc())
pagination = query.paginate(page=page, per_page=limit, error_out=False)
items = []
for item in pagination.items:
items.append({
'id': item.id,
'company_name': item.company_name,
'name': item.name,
'spec_model': item.spec_model,
'category': item.category,
'unit': item.unit,
'type': item.material_type,
'pinyin': getattr(item, 'pinyin', ''),
'status': '启用'
})
return {
'items': items,
'total': pagination.total,
'page': page,
'has_next': pagination.has_next
}
@staticmethod
def _notify_new_request(purchase):
"""发送新申请邮件给审批人"""
try:
from app.utils.email_service import send_email
from app.models.system import SysUser
if not purchase.approver_id:
return
approver = db.session.get(SysUser, purchase.approver_id)
if not approver or not approver.email:
return
subject = f"【待审批】采购申请单 {purchase.request_no}"
content = f"""您好,
您有一笔新的采购申请待审批:
申请单号:{purchase.request_no}
采购物品:{purchase.name}
规格型号:{purchase.spec_model or '-'}
采购数量:{float(purchase.quantity)}
申请时间:{purchase.created_at.strftime('%Y-%m-%d %H:%M') if purchase.created_at else '-'}
备注说明:{purchase.remark or ''}
请登录仓库管理系统进行审批。
此邮件由系统自动发送,请勿回复。
"""
send_email(approver.email, subject, content)
except Exception as e:
try:
from flask import current_app
current_app.logger.error(f"[Email] 采购申请通知审批人失败: {e}")
except Exception:
print(f"[Email] 采购申请通知审批人失败: {e}")
@staticmethod
def _notify_approved(purchase):
"""审批通过后通知申请人"""
try:
from app.utils.email_service import send_email
from app.models.system import SysUser
requester = db.session.get(SysUser, purchase.requester_id)
if not requester or not requester.email:
return
subject = f"【已通过】采购申请单 {purchase.request_no}"
content = f"""{"尊敬的 " + requester.username + ",您好" if requester.username else "您好"}
您的采购申请单 {purchase.request_no}{purchase.name})已审批通过,现已交给库管。
待库管完成入库后,您可在系统中查询采购记录。
此邮件由系统自动发送,请勿回复。
"""
send_email(requester.email, subject, content)
except Exception as e:
try:
from flask import current_app
current_app.logger.error(f"[Email] 采购申请通过通知申请人失败: {e}")
except Exception:
print(f"[Email] 采购申请通过通知申请人失败: {e}")
@staticmethod
def _notify_rejected(purchase):
"""审批驳回后通知申请人"""
try:
from app.utils.email_service import send_email
from app.models.system import SysUser
requester = db.session.get(SysUser, purchase.requester_id)
if not requester or not requester.email:
return
subject = f"【已驳回】采购申请单 {purchase.request_no}"
content = f"""{"尊敬的 " + requester.username + ",您好" if requester.username else "您好"}
您的采购申请单 {purchase.request_no}{purchase.name})已被驳回。
驳回原因:{purchase.reject_reason or '未说明'}
请登录仓库管理系统查看详情。
此邮件由系统自动发送,请勿回复。
"""
send_email(requester.email, subject, content)
except Exception as e:
print(f"[Email] 采购申请驳回通知失败: {e}")

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

@ -0,0 +1,278 @@
"""
邮件通知服务
使用 Python smtplib + email.mime 实现,支持 TLS/SSL SMTP 连接
从环境变量或 Flask config 读取邮件配置
"""
import os
import smtplib
import ssl
import logging
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.header import Header
from typing import List, Union
logger = logging.getLogger(__name__)
def _get_config():
"""
读取邮件配置,优先从 Flask app config回退到环境变量
"""
try:
from flask import current_app
return {
'server': current_app.config.get('MAIL_SERVER', os.getenv('MAIL_SERVER')),
'port': current_app.config.get('MAIL_PORT', int(os.getenv('MAIL_PORT', 587))),
'username': current_app.config.get('MAIL_USERNAME', os.getenv('MAIL_USERNAME')),
'password': current_app.config.get('MAIL_PASSWORD', os.getenv('MAIL_PASSWORD')),
'sender': current_app.config.get('MAIL_DEFAULT_SENDER', os.getenv('MAIL_DEFAULT_SENDER')),
'use_tls': current_app.config.get('MAIL_USE_TLS', os.getenv('MAIL_USE_TLS', 'true').lower() in ('true', '1', 'yes')),
'use_ssl': current_app.config.get('MAIL_USE_SSL', os.getenv('MAIL_USE_SSL', 'false').lower() in ('true', '1', 'yes')),
'enabled': current_app.config.get('MAIL_ENABLED', os.getenv('MAIL_ENABLED', 'false').lower() in ('true', '1', 'yes')),
}
except RuntimeError:
# 不在 Flask 上下文时,直接读环境变量
return {
'server': os.getenv('MAIL_SERVER'),
'port': int(os.getenv('MAIL_PORT', 587)),
'username': os.getenv('MAIL_USERNAME'),
'password': os.getenv('MAIL_PASSWORD'),
'sender': os.getenv('MAIL_DEFAULT_SENDER'),
'use_tls': os.getenv('MAIL_USE_TLS', 'true').lower() in ('true', '1', 'yes'),
'use_ssl': os.getenv('MAIL_USE_SSL', 'false').lower() in ('true', '1', 'yes'),
'enabled': os.getenv('MAIL_ENABLED', 'false').lower() in ('true', '1', 'yes'),
}
def send_email(to_email: Union[str, List[str]], subject: str, content: str):
"""
通用邮件发送函数
Args:
to_email: 收件人,单个邮箱字符串或列表
subject: 邮件主题
content: 邮件正文(纯文本)
发送失败时打印日志,不抛出异常
"""
cfg = _get_config()
print(f"[DEBUG send_email] cfg = {cfg}")
# 发送总开关
if not cfg.get('enabled'):
print(f"[Email] 邮件功能已禁用 (MAIL_ENABLED=false),跳过发送: {subject}")
logger.info(f"[Email] 邮件功能已禁用 (MAIL_ENABLED=false),跳过发送: {subject}")
return
# 配置完整性检查
if not cfg.get('server') or not cfg.get('username') or not cfg.get('password'):
print(f"[Email] 邮件配置不完整 server={cfg.get('server')} username={cfg.get('username')} password={'已设' if cfg.get('password') else ''},跳过发送")
logger.warning("[Email] 邮件配置不完整 (MAIL_SERVER/USERNAME/PASSWORD 缺失),跳过发送")
return
# 标准化收件人列表
recipients = [to_email] if isinstance(to_email, str) else [r.strip() for r in to_email if r.strip()]
if not recipients:
print("[Email] 收件人地址为空,跳过发送")
logger.warning("[Email] 收件人地址为空,跳过发送")
return
try:
msg = MIMEMultipart()
msg['From'] = cfg['sender']
msg['To'] = ', '.join(recipients)
msg['Subject'] = Header(subject, 'utf-8')
msg.attach(MIMEText(content, 'plain', 'utf-8'))
print(f"DEBUG: 准备向服务器提交发信请求,收件人: {recipients} 发件人: {cfg['username']}")
if cfg.get('use_ssl'):
context = ssl.create_default_context()
with smtplib.SMTP_SSL(cfg['server'], cfg.get('port', 465), context=context) as server:
server.login(cfg['username'], cfg['password'])
server.sendmail(cfg['username'], recipients, msg.as_string())
else:
with smtplib.SMTP(cfg['server'], cfg.get('port', 587)) as server:
if cfg.get('use_tls'):
server.starttls(context=ssl.create_default_context())
server.login(cfg['username'], cfg['password'])
server.sendmail(cfg['username'], recipients, msg.as_string())
logger.info(f"[Email] 发送成功 -> {recipients}: {subject}")
except smtplib.SMTPAuthenticationError:
print(f"!!! 邮件发送核心报错: SMTPAuthenticationError - 邮箱认证失败,请检查 MAIL_USERNAME / MAIL_PASSWORD授权码")
logger.error("[Email] 邮箱认证失败,请检查 MAIL_USERNAME / MAIL_PASSWORD授权码")
except smtplib.SMTPRecipientsRefused as e:
print(f"!!! 邮件发送核心报错: SMTPRecipientsRefused - 收件人被服务器拒绝: {e}")
logger.error(f"[Email] 收件人被服务器拒绝: {e}")
except smtplib.SMTPException as e:
print(f"!!! 邮件发送核心报错: SMTPException - {e}")
logger.error(f"[Email] SMTP 异常: {e}")
except Exception as e:
import traceback
traceback.print_exc()
print(f"!!! 邮件发送核心报错: {type(e).__name__} - {e}")
logger.error(f"[Email] 发送邮件时发生未知异常: {e}")
def send_new_request_notify(to_emails: List[str], request_no: str,
applicant_name: str = '', remark: str = '',
items: list = None, is_applicant_notify: bool = False):
"""
通知审批人有新的出库申请单待审批(可附带物料清单)
或通知申请人其申请已提交is_applicant_notify=True 时)
Args:
to_emails: 审批人邮箱列表
request_no: 审批单号
applicant_name: 申请人姓名
remark: 申请备注
items: 物料明细列表(可选)
is_applicant_notify: True=通知申请人标题您的出库申请已提交False=通知审批人(标题:您有一笔新的出库审批待处理)
"""
print(f"[DEBUG send_new_request_notify] 入参 items={items}, is_applicant_notify={is_applicant_notify}")
# 拼装物料表格
rows = []
rows.append("名称 | 规格 | 计划数量")
rows.append("-" * 40)
if items:
for item in items:
name = item.get('name', '-') or '-'
spec = item.get('spec_model', '-') or '-'
qty = item.get('quantity', '-') or '-'
rows.append(f"{name} | {spec} | {qty}")
else:
rows.append("(无物料明细)")
if is_applicant_notify:
subject = f"【已提交】您的出库申请单 {request_no} 已提交"
content = f"""您好,
您的出库申请单 {request_no} 已成功提交,等待审批。
申请单号:{request_no}
申请人:{applicant_name or '未知'}
备注说明:{remark or ''}
物料清单如下:
{chr(10).join(rows)}
---
您可以点击下方链接查看申请状态:
https://172.16.0.198/outbound/selection
---
此邮件由系统自动发送,请勿回复。
"""
else:
subject = f"【待审批】出库申请单 {request_no}"
content = f"""您好,
您有一笔新的出库审批申请待处理:
申请单号:{request_no}
申请人:{applicant_name or '未知'}
备注说明:{remark or ''}
物料清单如下:
{chr(10).join(rows)}
---
⚡ 快速通道:
请点击下方链接直接进入系统审批:
https://172.16.0.198/outbound/approval
---
请登录仓库管理系统进行审批。
此邮件由系统自动发送,请勿回复。
"""
send_email(to_emails, subject, content)
def send_approval_result_notify(to_emails: List[str], request_no: str,
is_passed: bool, reject_reason: str = '',
applicant_name: str = ''):
"""
通知审批结果
Args:
to_emails: 收件人邮箱列表
request_no: 审批单号
is_passed: 是否通过(通过时发给库管,驳回时发给申请人)
reject_reason: 驳回原因(仅 is_passed=False 时使用)
applicant_name: 申请人姓名(仅驳回通知时使用)
"""
if is_passed:
# ★ 发给申请人:告知已通过,去领料
subject = f"【已通过】出库申请单 {request_no}"
content = f"""{"尊敬的 " + applicant_name + ",您好" if applicant_name else "您好"}
您的出库申请单 {request_no} 已审批通过,请联系仓库管理员领取物料。
请登录仓库管理系统查看详情。
此邮件由系统自动发送,请勿回复。
"""
else:
# ★ 发给申请人:告知被驳回
subject = f"【已驳回】出库申请单 {request_no}"
content = f"""{"尊敬的 " + applicant_name + ",您好" if applicant_name else "您好"}
出库申请单 {request_no} 已被审批驳回。
驳回原因:{reject_reason or '未填写'}
请登录仓库管理系统查看详情,并根据驳回原因调整后重新提交申请。
此邮件由系统自动发送,请勿回复。
"""
send_email(to_emails, subject, content)
def send_warehouse_dispatch_notify(to_emails: List[str], request_no: str,
applicant_name: str = '', items: list = None):
"""
通知库管备货出库(包含完整物料清单)
Args:
to_emails: 库管邮箱列表
request_no: 审批单号
applicant_name: 申请人姓名
items: 物料明细列表,每个元素包含 name/spec_model/warehouse_location/quantity
"""
print(f"[DEBUG send_warehouse_dispatch_notify] 入参 items={items}")
print(f"[DEBUG send_warehouse_dispatch_notify] items 类型={type(items)}, 长度={len(items) if items else 0}")
rows = []
rows.append("名称 | 规格 | 库位 | 计划数量")
rows.append("-" * 50)
if items:
for item in items:
name = item.get('name', '-') or '-'
spec = item.get('spec_model', '-') or '-'
loc = item.get('warehouse_location', '-') or '-'
qty = item.get('quantity', '-') or '-'
rows.append(f"{name} | {spec} | {loc} | {qty}")
else:
rows.append("(无物料明细)")
subject = f"【待出库】出库申请单 {request_no} 已审批通过"
content = f"""您好,
出库申请单 {request_no} 已审批通过,请按以下清单准备备货:
{chr(10).join(rows)}
申请人:{applicant_name or '未知'}
请登录仓库管理系统执行"按单出库"操作。
此邮件由系统自动发送,请勿回复。
"""
send_email(to_emails, subject, content)
print(f"DEBUG: 准备向服务器提交发信请求,收件人: {to_emails}")

View File

@ -49,3 +49,23 @@ class Config:
# 5. Redis 配置 (用于单设备登录互踢)
# =========================================================
REDIS_URL = os.getenv('REDIS_URL', 'redis://localhost:6379/0')
# =========================================================
# 6. 邮件配置
# =========================================================
# 发件人邮箱(阿里企业邮箱)
MAIL_USERNAME = os.getenv('MAIL_USERNAME', 'wms@iris-rs.cn')
# 发件人邮箱密码 / 授权码
MAIL_PASSWORD = os.getenv('MAIL_PASSWORD', 'Q7nYyyESWlaThKjx')
# SMTP 服务器地址(阿里企业邮发信服务器)
MAIL_SERVER = os.getenv('MAIL_SERVER', 'smtp.mxhichina.com')
# SMTP 端口(阿里邮箱使用 SSL 465
MAIL_PORT = int(os.getenv('MAIL_PORT', 465))
# 是否启用 TLS (587 端口通常需要)
MAIL_USE_TLS = os.getenv('MAIL_USE_TLS', 'false').lower() in ('true', '1', 'yes')
# 是否启用 SSL (465 端口通常需要,阿里邮箱必须启用 SSL)
MAIL_USE_SSL = os.getenv('MAIL_USE_SSL', 'true').lower() in ('true', '1', 'yes')
# 默认发件人(★ 必须与 MAIL_USERNAME 完全一致,否则阿里邮件服务器会拒绝)
MAIL_DEFAULT_SENDER = os.getenv('MAIL_DEFAULT_SENDER', 'wms@iris-rs.cn')
# 是否启用邮件发送功能(开发环境可设为 false 禁用)
MAIL_ENABLED = os.getenv('MAIL_ENABLED', 'true').lower() in ('true', '1', 'yes')

View File

@ -18,3 +18,7 @@ qrcode[pil]>=7.4.2
Flask-JWT-Extended==4.6.0
# [新增] Excel 处理库 (解决 No module named 'openpyxl' 报错)
openpyxl>=3.1.2
# [新增] 定时任务调度器 (库存预警每日邮件)
APScheduler==3.10.4
# [新增] 时区处理 (APScheduler 需要)
pytz

View File

@ -1,9 +1,39 @@
# inventory-backend/run.py
from app import create_app
# Gunicorn 或 uWSGI 会寻找名为 'app' 的实例
app = create_app()
# =========================================================
# 启动时注册库存预警定时任务(每天 9:30 北京时)
# =========================================================
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
import pytz
beijing_tz = pytz.timezone('Asia/Shanghai')
def _run_warning_job():
with app.app_context():
try:
from app.services.inventory_task import InventoryWarningService
result = InventoryWarningService.check_and_send_warning_emails()
print(f"[Scheduler] 库存预警扫描完成: red={result['red_count']}, yellow={result['yellow_count']}")
except Exception as e:
print(f"[Scheduler] 库存预警任务失败: {e}")
scheduler = BackgroundScheduler(timezone=beijing_tz)
scheduler.add_job(
func=_run_warning_job,
trigger=CronTrigger(hour=9, minute=30, timezone=beijing_tz),
id='inventory_warning_daily',
name='库存预警每日邮件发送',
replace_existing=True
)
scheduler.start()
print("✅ 库存预警定时任务已启动(每天 9:30 北京时间执行)")
if __name__ == '__main__':
# =================================================
# 路由打印调试 (启动时会在控制台列出所有 URL)

View File

@ -4,10 +4,91 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/jetbrains://idea/navigate/reference?project=inventory-web&path=public%2Firis.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>inventory-web</title>
<title>MOM</title>
</head>
<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

@ -201,7 +201,7 @@ const handleLogout = () => {
<div class="logo-container">
<router-link to="/" class="home-link">
<img src="@/assets/iris.png" class="logo" alt="Logo" />
<span class="system-title">IRIS 库存管理系统</span>
<span class="system-title">MOM</span>
</router-link>
</div>
@ -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.13(4.23部署基础信息导入
当前版本:V3.26添加AI助手版
</span>
</footer>

View File

@ -85,3 +85,11 @@ export function batchCreateUser(data: any[]) {
data
})
}
// ★ 获取可指定审批人列表SUPERVISOR / SUPER_ADMIN 且 status=active
export function getApproversList() {
return request({
url: '/v1/auth/users/approvers',
method: 'get'
})
}

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

@ -78,3 +78,12 @@ export function getLatestSpecs() {
method: 'get'
})
}
// 8. 标记预警物料已采购
export function markWarningOrdered(data: { baseId: number; isOrdered: boolean }) {
return request({
url: '/inbound/base/warning/mark-ordered',
method: 'post',
data
})
}

View File

@ -78,3 +78,48 @@ export function getOutboundList(params: any) {
params
})
}
/**
* 提交出库申请单(申请人 → 审批流)
*/
export function submitOutboundRequest(data: {
items: Array<{
material_type?: string
name: string
spec_model: string
warehouse_location?: string
quantity: number
}>
remark: string
}) {
return request({
url: '/v1/outbound/request',
method: 'post',
data
})
}
/**
* 获取出库审批申请单列表
* @param params 支持 status, page, limit
*/
export function getApprovalRequestList(params: { status?: number | ''; page?: number; limit?: number }) {
return request({
url: '/v1/outbound/request',
method: 'get',
params
})
}
/**
* 审批(通过 / 驳回)出库申请单
* @param id 审批单ID
* @param data action: 'approve' | 'reject'reject 时需传 reject_reason
*/
export function approveRequest(id: number, data: { action: 'approve' | 'reject'; reject_reason?: string }) {
return request({
url: `/v1/outbound/request/${id}/approve`,
method: 'patch',
data
})
}

View File

@ -0,0 +1,100 @@
import request from '@/utils/request'
export interface PurchaseItem {
id?: number
request_no?: string
name: string
spec_model?: string
quantity: number
purchase_date: string
supplier_link?: string
remark?: string
images?: string[]
unit_price?: number
total_price?: number
status: number
status_text?: string
requester_id?: number
requester_name?: string
approver_id?: number
approver_name?: string
approved_at?: string
reject_reason?: string
created_at?: string
updated_at?: string
}
export interface Approver {
id: number
username: string
email: string
role: string
}
// 获取采购申请列表
export function getPurchaseList(params: {
page?: number
limit?: number
status?: number
}) {
return request({
url: '/purchase',
method: 'get',
params
})
}
// 创建采购申请
export function createPurchase(data: PurchaseItem) {
return request({
url: '/purchase',
method: 'post',
data
})
}
// 获取采购申请详情
export function getPurchaseDetail(id: number) {
return request({
url: `/purchase/${id}`,
method: 'get'
})
}
// 审批采购申请
export function approvePurchase(id: number, data: {
action: 'approve' | 'reject'
reject_reason?: string
}) {
return request({
url: `/purchase/${id}/approve`,
method: 'patch',
data
})
}
// 获取可选审批人列表
export function getPurchaseApprovers() {
return request({
url: '/purchase/approvers',
method: 'get'
})
}
// 根据名称/规格自动补全
export function autoFillPurchase(keyword: string) {
return request({
url: '/purchase/auto-fill',
method: 'get',
params: { keyword }
})
}
// 物料基础信息搜索(分页),用于采购申请弹窗
export function searchMaterialPurchase(keyword: string, page: number = 1) {
return request({
url: '/purchase/search-material',
method: 'get',
params: { keyword, page }
})
}

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

@ -150,6 +150,16 @@ const routes: Array<RouteRecordRaw> = [
name: 'OutboundList',
component: () => import('@/views/outbound/index.vue'),
meta: { title: '出库记录' }
},
{
path: 'approval',
name: 'OutboundApproval',
component: () => import('@/views/outbound/approval/index.vue'),
meta: {
title: '出库审批',
icon: 'Stamp',
roles: ['SUPER_ADMIN', 'SUPERVISOR']
}
}
]
},
@ -170,6 +180,21 @@ const routes: Array<RouteRecordRaw> = [
]
},
// 5.1 采购管理
{
path: '/purchase',
component: Layout,
meta: { title: '采购管理', icon: 'ShoppingCart' },
children: [
{
path: '',
name: 'PurchaseList',
component: () => import('@/views/purchase/index.vue'),
meta: { title: '采购申请' }
}
]
},
// 6. 借库管理
{
path: '/operation',

View File

@ -8,7 +8,7 @@
<el-input
v-model="searchKeyword"
placeholder="搜索 编号/名称/规格/子件..."
style="width: 300px; margin-right: 15px;"
style="width: 300px; margin-right: 10px;"
clearable
@clear="fetchBomList"
@keyup.enter="fetchBomList"
@ -17,36 +17,51 @@
<el-button :icon="Search" @click="fetchBomList" />
</template>
</el-input>
<el-button @click="activeCategories = bomGroups.map((g: any) => g.category)" size="small" style="margin-right: 6px;">全部展开</el-button>
<el-button @click="activeCategories = []" size="small" style="margin-right: 10px;">全部折叠</el-button>
<el-button v-if="userStore.hasPermission('bom_manage:operation')" type="primary" :icon="Plus" @click="handleCreate">新建 BOM</el-button>
</div>
</div>
</template>
<el-table v-loading="loading" :data="bomList" border style="width: 100%">
<el-table-column v-if="hasColumnPermission('bom_no')" prop="bom_no" label="BOM编号" min-width="180" sortable />
<el-table-column v-if="hasColumnPermission('parent_name')" prop="parent_name" label="父件名称" min-width="150" />
<el-table-column v-if="hasColumnPermission('parent_spec')" prop="parent_spec" label="父件规格" min-width="150" />
<el-table-column v-if="hasColumnPermission('version')" prop="version" label="版本" width="100" align="center">
<template #default="{ row }">
<el-tag>{{ row.version }}</el-tag>
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('status')" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.is_enabled ? 'success' : 'danger'">
{{ row.is_enabled ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('child_count')" prop="child_count" label="子件数" width="80" align="center" />
<el-table-column v-if="userStore.hasPermission('bom_manage:operation')" label="操作" width="250" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button type="success" link @click="handleSaveAs(row)">另存为</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-loading="loading">
<el-collapse v-model="activeCategories" class="bom-category-collapse">
<el-collapse-item
v-for="group in bomGroups"
:key="group.category"
:title="group.category + ' (' + group.count + ')'"
:name="group.category"
>
<el-table :data="group.items" border style="width: 100%">
<el-table-column v-if="hasColumnPermission('bom_no')" prop="bom_no" label="BOM编号" min-width="180" sortable>
<template #default="{ row }">
<span style="cursor: pointer; color: #409EFF;" @click="handleEdit(row)">{{ row.bom_no }}</span>
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('parent_name')" prop="parent_name" label="父件名称" min-width="150" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('parent_spec')" prop="parent_spec" label="父件规格" min-width="150" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('version')" label="版本" width="100" align="center">
<template #default="{ row }">
<el-tag>{{ row.version }}</el-tag>
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('status')" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.is_enabled ? 'success' : 'danger'">{{ row.is_enabled ? '启用' : '禁用' }}</el-tag>
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('child_count')" prop="child_count" label="子件数" width="80" align="center" />
<el-table-column v-if="userStore.hasPermission('bom_manage:operation')" label="操作" width="250" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button type="success" link @click="handleSaveAs(row)">另存为</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-collapse-item>
</el-collapse>
</div>
</el-card>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="850px" destroy-on-close :close-on-click-modal="false">
@ -83,13 +98,26 @@
</div>
</el-option>
</el-select>
<el-link
v-if="form.parent_id"
type="primary"
:underline="false"
style="margin-left: 12px; font-size: 13px;"
@click="openParentMaterial"
>
<el-icon style="margin-right: 4px"><EditPen /></el-icon>前往修改基础信息
</el-link>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="是否启用" prop="is_enabled" v-if="hasFormFieldPermission('is_enabled')">
<el-switch v-model="form.is_enabled" active-text="启用" inactive-text="禁用" :disabled="!userStore.hasPermission('bom_manage:operation')" />
</el-form-item>
</el-col>
<el-col :span="16"></el-col>
</el-row>
<el-row :gutter="20">
@ -135,34 +163,45 @@
/>
<el-table :data="filteredChildren" border style="width: 100%; margin-bottom: 15px" max-height="300">
<el-table-column label="子件物料" min-width="280" v-if="hasFormFieldPermission('child_id')">
<el-table-column label="子件物料" min-width="250" v-if="hasFormFieldPermission('child_id')">
<template #default="{ row, $index }">
<!-- ====== 改造:子件下拉 - 远程搜索 + 懒加载 ====== -->
<el-select
v-model="row.child_id"
placeholder="请搜索原料"
filterable
remote
reserve-keyword
:remote-method="(q: string) => handleRemoteSearch(q, 'child', $index)"
:loading="selectLoading"
style="width: 100%"
:loading-text="`正在加载第 ${childQueryParams.page} 页...`"
:popper-class="`bom-loadmore-popper child-popper-${$index}`"
@visible-change="(visible: boolean) => handleVisibleChange(visible, 'child', $index)"
>
<el-option
v-for="item in getChildOptions($index)"
:key="item.id"
:label="`${item.name} (${item.spec})`"
:value="item.id"
<div style="display: flex; align-items: center; gap: 8px;">
<el-select
v-model="row.child_id"
placeholder="请搜索原料"
filterable
remote
reserve-keyword
style="flex: 1;"
:remote-method="(q: string) => handleRemoteSearch(q, 'child', $index)"
:loading="selectLoading"
:loading-text="`正在加载第 ${childQueryParams.page} 页...`"
:popper-class="`bom-loadmore-popper child-popper-${$index}`"
@visible-change="(visible: boolean) => handleVisibleChange(visible, 'child', $index)"
>
<div class="option-row">
<span class="option-name">{{ item.name }}</span>
<span class="option-spec">{{ item.spec }}</span>
</div>
</el-option>
</el-select>
<el-option
v-for="item in getChildOptions($index)"
:key="item.id"
:label="`${item.name} (${item.spec})`"
:value="item.id"
>
<div class="option-row">
<span class="option-name">{{ item.name }}</span>
<span class="option-spec">{{ item.spec }}</span>
</div>
</el-option>
</el-select>
<el-tooltip content="前往修改基础信息" placement="top" v-if="row.child_id">
<el-button
type="primary"
link
:icon="EditPen"
@click.stop="openMaterialInNewTab(row.child_id, getChildSpec($index))"
style="font-size: 16px; padding: 4px;"
/>
</el-tooltip>
</div>
</template>
</el-table-column>
@ -202,8 +241,10 @@
<script setup lang="ts">
import { ref, reactive, onMounted, computed, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus'
import { Plus, Search } from '@element-plus/icons-vue'
import { Plus, Search, EditPen } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import { getBomList, getBomDetail, saveBom, deleteBom } from '@/api/bom'
import { getMaterialBaseList } from '@/api/inbound/stock'
import { useUserStore } from '@/stores/user'
@ -231,6 +272,8 @@ interface ChildRow {
}
const userStore = useUserStore()
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const dialogVisible = ref(false)
const saving = ref(false)
@ -240,7 +283,8 @@ let originalVersion = ''
let currentBomNo = ''
let originalChildren: ChildRow[] = []
const bomList = ref<BomItem[]>([])
const bomGroups = ref([]) // 分组结构: [{category, count, items[]}]
const activeCategories = ref([]) // 默认全部展开
const searchKeyword = ref('')
const childSearchKeyword = ref('')
@ -465,6 +509,31 @@ const filteredChildren = computed(() => {
})
})
// 获取子件规格(从 childDropdownStates 缓存中查找)
const getChildSpec = (index: number): string => {
const state = childDropdownStates.value.get(index)
if (!state || !form.children[index]?.child_id) return ''
const material = state.options.find((m: MaterialBase) => m.id === form.children[index].child_id)
return material?.spec || ''
}
// 在新标签页打开基础信息编辑
const openMaterialInNewTab = (targetId: number | null, keyword: string = '') => {
if (!targetId) return ElMessage.warning('请先选择物料')
const routeUrl = router.resolve({
path: '/material',
query: { edit_id: targetId, keyword }
})
window.open(routeUrl.href, '_blank')
}
const openParentMaterial = () => {
if (!form.parent_id) return ElMessage.warning('请先选择父件')
const parent = parentOptions.value.find((p: MaterialBase) => p.id === form.parent_id)
const keyword = parent?.spec || parent?.name || ''
openMaterialInNewTab(form.parent_id, keyword)
}
// 列与权限Code的映射关系数据库中的code
const permissionMap: Record<string, string> = {
bom_no: 'bom_manage:bom_no',
@ -507,8 +576,9 @@ const pureBomNo = computed(() => form.bom_no)
const versionOptions = computed(() => {
const ver = originalVersion || 'V1.0'
const allItems = bomGroups.value.flatMap((g: any) => g.items)
const occupiedVersions = new Set(
bomList.value.filter(item => item.bom_no === currentBomNo).map(item => item.version)
allItems.filter((item: any) => item.bom_no === currentBomNo).map((item: any) => item.version)
)
const getNextMinor = (baseMajor: number, baseMinor: number): string => {
let minor = baseMinor + 1
@ -544,7 +614,10 @@ const fetchBomList = async () => {
loading.value = true
try {
const res = await getBomList({ keyword: searchKeyword.value })
if (res.code === 200) bomList.value = res.data
if (res.code === 200) {
bomGroups.value = res.data
activeCategories.value = []
}
} finally { loading.value = false }
}
@ -745,7 +818,64 @@ const submitForm = async () => {
}
onMounted(() => {
fetchBomList()
// 处理外部跳转自动打开 BOM带查重保护
if (route.query.create_for_id) {
const parentId = Number(route.query.create_for_id);
const parentName = (route.query.parent_name as string) || '';
const parentSpec = (route.query.parent_spec as string) || '';
// 1. 把名称填入背景搜索框,并真正触发一次列表搜索,让背景列表也只显示该物料
searchKeyword.value = parentName;
fetchBomList();
// 2. 延迟等待基础渲染后进行查重
setTimeout(() => {
getBomList({ keyword: parentName }).then((res: any) => {
const groups = res.data || [];
let existingBom = null;
// ★ 修复点:遍历分组 (groups) 里的 items 来查找正确的 parent_id
for (const group of groups) {
if (group.items && group.items.length > 0) {
const found = group.items.find((b: any) => b.parent_id === parentId);
if (found) {
existingBom = found;
break; // 找到了就跳出循环
}
}
}
if (existingBom) {
// ★ 情况 A已经有BOM了直接打开编辑(查看)弹窗
ElMessage.success('检测到该物料已有 BOM已自动为您打开');
handleEdit(existingBom);
} else {
// ★ 情况 B还没建过BOM打开新建并注入父件
handleCreate();
// 强行注入父件远程搜索选项
parentOptions.value = [{
id: parentId,
name: parentName,
spec: parentSpec
}];
// 给表单赋值
form.parent_id = parentId;
// 触发联动逻辑(自动带出版本和生成编号)
if (typeof onParentChange === 'function') {
setTimeout(() => {
onParentChange(parentId);
}, 100);
}
}
});
}, 300);
} else {
// 如果不是从其他页面跳转过来的,直接正常加载全部列表
fetchBomList();
}
})
</script>

View File

@ -17,7 +17,33 @@
</template>
<div class="card-body">
<h2>IRIS 库存管理系统</h2>
<h2>Manufacturing Operations Management制造运营管理</h2>
<div style="display: flex; justify-content: center; margin: 20px 0 30px;">
<el-autocomplete
v-model="globalSearchText"
:fetch-suggestions="queryGlobalSearch"
placeholder="全局搜索:输入物料名称、规格、条码或 BOM 编号..."
style="width: 60%; max-width: 600px;"
size="large"
clearable
@select="handleSearchSelect"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
<template #default="{ item }">
<div style="display: flex; justify-content: space-between; align-items: center; line-height: 1.5; padding: 4px 0;">
<div>
<div style="font-size: 14px; font-weight: bold; color: #303133;">{{ item.title }}</div>
<div style="font-size: 12px; color: #909399;">{{ item.subtitle }}</div>
</div>
<el-tag size="small" :type="getBadgeType(item.type)">{{ item.badge }}</el-tag>
</div>
</template>
</el-autocomplete>
</div>
<p class="subtitle">请选择您要进行的业务操作</p>
<div class="action-buttons">
@ -215,8 +241,9 @@ import { useRouter } from 'vue-router'
// 1. 引入 User Store
import { useUserStore } from '@/stores/user'
// 引入需要的图标
import { Box, TrendCharts, ShoppingCart, Operation, Setting, Location, Plus, Edit, Delete, Close } from '@element-plus/icons-vue'
import { Box, TrendCharts, ShoppingCart, Operation, Setting, Location, Plus, Edit, Delete, Close, Search } from '@element-plus/icons-vue'
import { getPrinterConfig, updatePrinterConfig } from '@/api/common/print'
import request from '@/utils/request'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getWarehouseTree, createWarehouse, updateWarehouse, deleteWarehouse, batchDeleteWarehouse, batchGenerateWarehouse } from '@/api/common/warehouse'
@ -234,6 +261,61 @@ const printerForm = reactive({
})
const loading = ref(false)
// 全局搜索相关
const globalSearchText = ref('')
const getBadgeType = (type: string) => {
const map: Record<string, string> = {
'material': 'success',
'stock_buy': 'primary',
'bom': 'warning'
}
return map[type] || 'info'
}
const queryGlobalSearch = async (queryString: string, cb: (data: any[]) => void) => {
if (!queryString || queryString.trim() === '') {
cb([])
return
}
try {
const res: any = await request({
url: '/v1/common/global-search',
method: 'get',
params: { keyword: queryString.trim() }
})
if (res.code === 200 && res.data) {
cb(res.data)
} else {
cb([])
}
} catch (error) {
console.error('全局搜索失败:', error)
cb([])
}
}
const handleSearchSelect = (item: any) => {
globalSearchText.value = ''
if (item.type === 'material') {
router.push({
path: '/material/index',
query: { edit_id: item.id, keyword: item.title }
})
} else if (item.type === 'stock_buy') {
router.push({
path: '/inventory/buy',
query: { keyword: item.title }
})
} else if (item.type === 'bom') {
router.push({
path: '/bom',
query: { keyword: item.title }
})
}
}
const openPrinterDialog = async () => {
try {
loading.value = true

View File

@ -273,7 +273,8 @@
<!-- 非图片文件 -->
<div v-for="(link, idx) in row.generalManual.filter(l => !isExternalLink(l) && !isImageFile(l))" :key="'file-' + idx">
<el-link @click.prevent="handleDownloadConfirm(link)" type="info" :underline="false">
<el-icon><Files /></el-icon>
<el-icon v-if="isCompressedFile(link)"><Zipper /></el-icon>
<el-icon v-else><Files /></el-icon>
{{ link.split('/').pop() }}
</el-link>
</div>
@ -302,10 +303,30 @@
</el-tag>
</template>
</el-table-column>
<el-table-column v-if="userStore.hasPermission('material_list:operation')" label="操作" min-width="200" fixed="right" align="center">
<el-table-column v-if="userStore.hasPermission('material_list:view_warning')" label="预警状态" width="120" align="center">
<template #default="{ row }">
<template v-if="row.warningStatus === 2">
<el-tag type="danger" size="small">红色预警</el-tag>
<div style="font-size: 11px; color: #999;">阈值: {{ row.warningRed }}</div>
</template>
<template v-else-if="row.warningStatus === 1">
<el-tag type="warning" size="small">黄色预警</el-tag>
<div style="font-size: 11px; color: #999;">阈值: {{ row.warningYellow }}</div>
</template>
<template v-else-if="row.warningEnabled">
<el-tag type="success" size="small">已配置</el-tag>
</template>
<span v-else style="color: #c0c4cc;">-</span>
</template>
</el-table-column>
<el-table-column v-if="userStore.hasPermission('material_list:operation')" label="操作" width="280" fixed="right" align="center">
<template #default="scope">
<el-button v-if="userStore.hasPermission('material_list:operation')" link type="primary" size="small" @click="handleEdit(scope.row)">编辑</el-button>
<el-button v-if="userStore.hasPermission('material_list:edit_warning')" link type="warning" size="small" @click="handleSetSingleWarning(scope.row)">设置预警</el-button>
<template v-if="userStore.hasPermission('material_list:edit_warning') && scope.row.warningStatus > 0">
<el-button v-if="scope.row.warningOrdered" disabled size="small" type="info">采购在途</el-button>
<el-button v-else link type="success" size="small" @click="handleMarkOrdered(scope.row)">标记已采购</el-button>
</template>
<el-button v-if="userStore.hasPermission('material_list:operation')" link type="danger" size="small" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
@ -326,8 +347,7 @@
<el-dialog
v-model="dialog.visible"
:title="dialog.title"
width="700px"
width="1200px"
append-to-body
destroy-on-close
@close="cancel"
@ -335,6 +355,20 @@
:close-on-press-escape="!isUploading"
:show-close="!isUploading"
>
<template #header>
<div style="display: flex; align-items: center; justify-content: space-between; padding-right: 20px;">
<span style="font-size: 18px; font-weight: 500;">{{ dialog.title }}</span>
<el-link
v-if="form.id"
type="success"
:underline="false"
style="font-size: 14px;"
@click="createBomForMaterial"
>
<el-icon style="margin-right: 4px"><Plus /></el-icon>加入或查看BOM
</el-link>
</div>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
<el-row>
@ -423,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="#"
@ -451,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="#"
@ -482,7 +516,7 @@
<span class="el-upload-list__item-preview" @click="handlePreviewPicture(file)">
<el-icon><ZoomIn /></el-icon>
</span>
<span class="el-upload-list__item-delete" @click="() => handleRemoveImage(file, 'generalManual')">
<span class="el-upload-list__item-delete" @click.stop.prevent="() => handleRemoveImage(file, 'generalManual')">
<el-icon><Delete /></el-icon>
</span>
</span>
@ -547,10 +581,16 @@
<el-input-number v-model="warningForm.redThreshold" :min="0" :precision="0" step="1" placeholder="库存此值为红色预警" style="width: 100%" />
<div class="form-tip">库存数量 ≤ 此值时显示红色预警</div>
</el-form-item>
<el-form-item label="红色预警邮箱" v-if="warningForm.isEnabled">
<el-input v-model="warningForm.redEmails" placeholder="逗号分隔多个邮箱" clearable />
</el-form-item>
<el-form-item label="黄色阈值" prop="yellowThreshold" v-if="warningForm.isEnabled">
<el-input-number v-model="warningForm.yellowThreshold" :min="0" :precision="0" step="1" placeholder="库存此值为黄色预警" style="width: 100%" />
<div class="form-tip">红色阈值 &lt; 库存 ≤ 此值时显示黄色预警</div>
</el-form-item>
<el-form-item label="黄色预警邮箱" v-if="warningForm.isEnabled">
<el-input v-model="warningForm.yellowEmails" placeholder="逗号分隔多个邮箱" clearable />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
@ -597,6 +637,9 @@ import { Plus, Document, Refresh, Setting, Rank, Camera, Link, Download, Bell, C
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus';
import type { FormInstance, FormRules } from 'element-plus';
import { useUserStore } from '@/stores/user';
import { useRoute, useRouter } from 'vue-router';
const route = useRoute();
const router = useRouter();
import {
listMaterialBase,
@ -606,9 +649,11 @@ import {
getMaterialBaseOptions,
exportAssetStatistics,
batchSetWarning,
batchSetInspection
batchSetInspection,
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();
@ -630,6 +675,10 @@ interface MaterialBaseVO {
statusLoading?: boolean;
inventoryCount?: number;
availableCount?: number;
warningStatus?: number;
warningOrdered?: boolean;
warningRedEmails?: string;
warningYellowEmails?: string;
}
interface QueryParams {
@ -775,7 +824,9 @@ const warningLoading = ref(false);
const warningForm = reactive({
isEnabled: false,
redThreshold: undefined as number | undefined,
yellowThreshold: undefined as number | undefined
yellowThreshold: undefined as number | undefined,
redEmails: '',
yellowEmails: ''
});
const warningRules = {
yellowThreshold: [
@ -1305,6 +1356,22 @@ const cancel = () => {
resetForm();
};
// 快速基于此物料查看/创建 BOM
const createBomForMaterial = () => {
if (!form.value.id) {
return ElMessage.warning('请先保存物料基础信息后再操作');
}
const routeUrl = router.resolve({
path: '/bom',
query: {
create_for_id: form.value.id,
parent_name: form.value.name,
parent_spec: form.value.spec
}
});
window.open(routeUrl.href, '_blank');
};
const resetForm = () => {
form.value = JSON.parse(JSON.stringify(initForm));
fileListImage.value = [];
@ -1356,11 +1423,34 @@ const handleSetSingleWarning = (row: MaterialBaseVO) => {
warningForm.isEnabled = row.warningEnabled || false;
warningForm.redThreshold = row.warningRed;
warningForm.yellowThreshold = row.warningYellow;
warningForm.redEmails = (row as any).warningRedEmails || (row as any).redEmails || '';
warningForm.yellowEmails = (row as any).warningYellowEmails || (row as any).yellowEmails || '';
warningDialog.title = '设置预警';
warningDialog.visible = true;
};
// 标记预警物料已采购
const handleMarkOrdered = (row: MaterialBaseVO) => {
ElMessageBox.confirm(
'确认已对该预警物料下单?标记后在途期间将不再发送预警邮件。',
'确认标记已采购',
{
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning'
}
).then(async () => {
try {
await markWarningOrdered({ baseId: row.id, isOrdered: true });
ElMessage.success('已标记为已采购');
getList();
} catch (error: any) {
ElMessage.error(error?.msg || '标记失败');
}
}).catch(() => {});
};
// 提交预警设置
const submitWarning = async () => {
if (!warningFormRef.value) return;
@ -1383,7 +1473,9 @@ const submitWarning = async () => {
baseId,
isEnabled: warningForm.isEnabled,
redThreshold: red,
yellowThreshold: yellow
yellowThreshold: yellow,
redEmails: warningForm.redEmails || '',
yellowEmails: warningForm.yellowEmails || ''
}));
await batchSetWarning(data);
@ -1431,23 +1523,20 @@ const submitBatchInspection = async () => {
// 表格行样式(根据预警状态)
const tableRowClassName = ({ row }: { row: MaterialBaseVO }) => {
// 只有拥有 view_warning 权限且有预警状态时才显示特殊样式
if (!userStore.hasPermission('material_list:view_warning')) return '';
const status = (row as any).warningStatus;
if (status === 2) {
return 'warning-row-red'; // 红色预警
} else if (status === 1) {
return 'warning-row-yellow'; // 黄色预警
if (row.warningStatus === 2) {
return 'danger-row'; // 红色预警
} else if (row.warningStatus === 1) {
return 'warning-row'; // 黄色预警
}
return '';
};
}
// --- 文件上传辅助函数 ---
const getImageUrl = (url: string) => { return !url ? '' : (url.startsWith('http') ? url : url) }
const isExternalLink = (str: string) => { return str && (str.startsWith('http://') || str.startsWith('https://')) && !str.includes('/api/v1/common/files') }
const isImageFile = (url: string) => { return /\.(jpg|jpeg|png|gif|webp|bmp)$/i.test(url) }
const isCompressedFile = (url: string) => { return /\.(zip|rar|7z)$/i.test(url) }
const getImagesOnly = (list: string[]) => { return !list ? [] : list.filter(item => !isExternalLink(item) && isImageFile(item)) }
const getNonImagesOnly = (list: string[]) => { return !list ? [] : list.filter(item => !isExternalLink(item) && !isImageFile(item)) }
const truncateFileName = (name: string, maxLen = 12) => { return name.length > maxLen ? name.slice(0, maxLen - 3) + '...' : name }
@ -1464,13 +1553,23 @@ const handleDownloadConfirm = (link: string) => {
}
const beforeAvatarUpload = (rawFile: any) => {
const isTypeValid = ['image/jpeg', 'image/png', 'application/pdf'].includes(rawFile.type);
const isTypeValid = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp',
'application/pdf',
'application/zip', 'application/x-zip-compressed',
'application/x-rar-compressed',
'application/vnd.rar',
'application/x-7z-compressed',
'application/octet-stream' // 兼容某些浏览器对 .zip/.rar 的错误识别
].includes(rawFile.type);
if (!isTypeValid) {
ElMessage.error('仅支持 JPG/PNG/PDF');
ElMessage.error('仅支持 JPG/PNG/GIF/PDF/ZIP/RAR/7Z');
return false;
}
if (rawFile.size / 1024 / 1024 > 10) {
ElMessage.error('文件不能超过 10MB');
const isCompressed = ['application/zip', 'application/x-zip-compressed', 'application/x-rar-compressed', 'application/vnd.rar', 'application/x-7z-compressed'].includes(rawFile.type);
const maxMB = 150;
if (rawFile.size / 1024 / 1024 > maxMB) {
ElMessage.error(`文件不能超过 ${maxMB}MB`);
return false;
}
return true;
@ -1486,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 {
@ -1499,7 +1605,29 @@ 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 {
await ElMessageBox.confirm(
`确认要删除「${fileName}」吗?删除后不可恢复。`,
'删除确认',
{
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning'
}
)
} catch {
return // 用户取消,不删除
}
try {
const urlToRemove = form.value[targetField].find(u => getImageUrl(u) === uploadFile.url) || uploadFile.url
form.value[targetField] = form.value[targetField].filter(u => u !== urlToRemove)
@ -1507,8 +1635,11 @@ const handleRemoveImage = async (uploadFile: any, targetField: 'generalImage' |
const filename = urlToRemove.split('/').pop();
if (filename) await deleteFile(filename)
}
ElMessage.success('已从列表移除')
} catch (e) { console.error(e) }
ElMessage.success('已除')
} catch (e) {
console.error(e)
ElMessage.error('删除失败')
}
}
const handlePreviewPicture = (uploadFile: any) => {
@ -1585,10 +1716,48 @@ const resetAdvancedFilter = () => {
};
onMounted(() => {
// 1. 修复背景联动:直接对 reactive 对象赋值
if (route.query.keyword) {
queryParams.keyword = route.query.keyword as string;
queryParams.searchField = 'all';
}
// 先根据权限初始化列显示状态
initColumnPermissions();
// 此时 getList 会带着正确的 keyword 向后端请求过滤后的数据
getList();
getOptionsList();
// 2. 修复弹窗锁定逻辑
console.log('--- 准备检测外部跳转参数 ---', route.query);
if (route.query.edit_id) {
const editId = Number(route.query.edit_id);
const searchKeyword = (route.query.keyword as string) || '';
console.log('检测到 edit_id:', editId, '使用 keyword 搜索:', searchKeyword);
// 改用 keyword 而不是无效的 id 去向后端请求数据,确保目标物料在返回的列表中
listMaterialBase({ page: 1, pageSize: 50, keyword: searchKeyword }).then((res: any) => {
let rawData = res?.data?.list ?? res?.data?.items ?? res?.data ?? [];
if (!Array.isArray(rawData) && typeof rawData === 'object' && rawData !== null) {
rawData = [rawData];
}
const rows = Array.isArray(rawData) ? rawData : [];
// 3. 去掉危险的 rows[0] 兜底,严格匹配 ID
const targetRow = rows.find((r: any) => r.id === editId);
if (targetRow) {
console.log('找到精准目标物料,准备弹窗:', targetRow);
setTimeout(() => {
handleEdit(targetRow);
}, 800);
} else {
console.warn('未能在搜索结果中匹配到对应 ID 的物料,可能 keyword 与 ID 不匹配');
}
}).catch((error: any) => {
console.error('自动获取物料详情失败', error);
});
}
});
</script>
@ -1645,34 +1814,32 @@ onMounted(() => {
.upload-file-item .el-upload-list__item-actions .el-icon { color: #fff; font-size: 20px; cursor: pointer; margin: 0 4px; }
.upload-add-trigger { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
/* 预警行样式 - 加深颜色 */
:deep(.warning-row-red) {
--el-table-tr-bg-color: #ffcdd2 !important;
background-color: #ffcdd2 !important;
}
:deep(.warning-row-red td) {
background-color: transparent !important;
}
:deep(.warning-row-yellow) {
--el-table-tr-bg-color: #fff59d !important;
background-color: #fff59d !important;
}
:deep(.warning-row-yellow td) {
background-color: transparent !important;
/* ================================================================
Element Plus 表格预警行样式 & 固定列重叠修复
================================================================ */
/* 黄色预警行底色 (全覆盖) */
:deep(.el-table .warning-row),
:deep(.el-table .warning-row > td.el-table__cell) {
background-color: #fcedc4 !important; /* 明显的黄色 */
}
/* 表单提示文字 */
.form-tip {
font-size: 12px;
color: #909399;
margin-top: 4px;
line-height: 1.4;
/* 红色预警行底色 (全覆盖) */
:deep(.el-table .danger-row),
:deep(.el-table .danger-row > td.el-table__cell) {
background-color: #fcd3d3 !important; /* 明显的红色 */
}
</style>
<style>
/* 增加下拉框的最大高度,使其能容纳更多选项而不必频繁滚动 */
.long-dropdown .el-select-dropdown__wrap {
max-height: 600px !important; /* 可以根据屏幕大小适当调整 */
/* 固定列的按钮容器底色跟随所在行的背景色,视觉无缝融合 */
:deep(.el-table .el-table__cell.is-fixed) {
background-color: inherit !important;
}
/* 按钮间距微调,更紧凑 */
:deep(.el-table .el-table__cell.is-fixed .cell) {
display: flex;
gap: 6px;
justify-content: flex-start; /* 左对齐更自然 */
flex-wrap: nowrap; /* 尽量不换行 */
}
</style>

View File

@ -37,6 +37,9 @@
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="success" :icon="Printer" :disabled="selectedItems.length === 0" @click="handlePreview">
生成预览 & 打印
</el-button>
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="primary" :icon="Plus" :disabled="selectedItems.length === 0" @click="openRequestDialog">
提交出库申请
</el-button>
</div>
</div>
</template>
@ -189,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')" />
@ -289,9 +292,83 @@
</template>
</el-dialog>
<!-- 出库申请 Dialog -->
<el-dialog
v-model="requestDialogVisible"
title="提交出库申请"
width="700px"
destroy-on-close
class="no-print-content"
>
<el-alert
title="请确认以下物料申请清单,填写申请原因后提交"
type="info"
:closable="false"
style="margin-bottom: 16px;"
/>
<el-table :data="validSelectedItems" border size="small" style="margin-bottom: 16px;">
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column label="类型" width="80" align="center">
<template #default="{ row }">
<el-tag size="small" :type="getTypeTag(row.type)">{{ row.typeLabel }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="standard" label="规格" min-width="120" show-overflow-tooltip />
<el-table-column prop="warehouse_location" label="库位" width="120" show-overflow-tooltip />
<el-table-column prop="export_quantity" label="计划数量" width="100" align="center">
<template #default="{ row }">
<span style="color: #F56C6C; font-weight: bold;">{{ row.export_quantity }}</span>
</template>
</el-table-column>
</el-table>
<el-form label-width="80px">
<el-form-item label="* 指定审批人" required>
<el-select
v-model="requestApproverId"
placeholder="请选择审批人"
style="width: 100%"
filterable
>
<el-option
v-for="user in approvers"
:key="user.id"
:label="`${user.username}`"
:value="user.id"
/>
</el-select>
</el-form-item>
<el-form-item label="申请原因" required>
<el-input
v-model="requestRemark"
type="textarea"
:rows="3"
placeholder="请填写出库申请原因(必填)"
maxlength="200"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="requestDialogVisible = false">取消</el-button>
<el-button
type="primary"
:loading="requestSubmitting"
@click="confirmSubmitRequest"
>
确认提交
</el-button>
</span>
</template>
</el-dialog>
<div id="print-area">
<div class="print-header">
<h1>IRIS出库拣货确认单</h1>
<h1>出库拣货确认单</h1>
<div class="print-meta-row">
<span>打印时间: {{ currentTime }}</span>
</div>
@ -355,9 +432,11 @@
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'
import { getApproversList } from '@/api/auth'
const userStore = useUserStore()
@ -381,7 +460,13 @@ const previewVisible = ref(false)
const exportLoading = ref(false)
const printLoading = ref(false)
const allStockData = ref<any[]>([])
// ★ 出库申请相关
const requestDialogVisible = ref(false)
const requestRemark = ref('')
const requestApproverId = ref<number | null>(null)
const approvers = ref<any[]>([])
const requestSubmitting = ref(false)
const stockList = ref<any[]>([]) // 服务端分页数据
const stockTotal = ref(0)
const stockPage = ref(1)
@ -401,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('')
@ -426,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)
@ -477,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手动添加库存 ---
// 服务端加载库存列表
@ -511,7 +576,8 @@ const loadStockList = async () => {
const res: any = await getStockList({
page: stockPage.value,
pageSize: stockPageSize.value,
keyword: searchKeyword.value.trim()
keyword: searchKeyword.value.trim(),
is_aggregated: true // ★ 触发后端按规格+库位合并
})
// 为每个item添加uniqueKey和确保warehouse_location字段正确映射
stockList.value = (res.data?.list || []).map((item: any) => ({
@ -533,8 +599,6 @@ const openManualSelect = async () => {
stockPage.value = 1
searchKeyword.value = ''
await loadStockList()
await ensureAllStockLoaded()
allStockData.value.forEach(item => item.export_quantity = 0)
}
// 搜索框防抖触发服务端过滤
@ -639,7 +703,6 @@ const openBomSelect = async () => {
} catch (e) {
ElMessage.error('加载 BOM 列表失败')
}
await ensureAllStockLoaded()
}
// 监听 BOM 选择变化,自动加载明细并计算齐套性
@ -649,7 +712,7 @@ watch(selectedBomNo, async (newBomNo) => {
return
}
try {
const detailRes = await getBomDetail(newBomNo)
const detailRes: any = await getBomDetail(newBomNo)
currentBomDetail.value = detailRes.data?.children || []
} catch (e) {
ElMessage.error('加载 BOM 明细失败')
@ -666,7 +729,7 @@ const confirmBomAdd = async () => {
if (currentBomDetail.value.length === 0) {
try {
const detailRes = await getBomDetail(selectedBomNo.value)
const detailRes: any = await getBomDetail(selectedBomNo.value)
currentBomDetail.value = detailRes.data?.children || []
} catch (e) {
ElMessage.error('获取 BOM 详情失败')
@ -794,6 +857,67 @@ const handlePreview = () => {
previewVisible.value = true
}
// ★ 出库申请
const openRequestDialog = () => {
if (validSelectedItems.value.length === 0) {
ElMessage.warning('请先添加物品并填写计划出库数量')
return
}
requestRemark.value = ''
requestApproverId.value = null
loadApprovers()
requestDialogVisible.value = true
}
// ★ 加载可指定审批人列表
const loadApprovers = async () => {
try {
const res: any = await getApproversList()
approvers.value = res.data || []
} catch (e) {
console.error('加载审批人列表失败', e)
approvers.value = []
}
}
const confirmSubmitRequest = async () => {
const trimmed = requestRemark.value.trim()
if (!trimmed) {
ElMessage.warning('请填写申请原因')
return
}
if (!requestApproverId.value) {
ElMessage.warning('请选择指定审批人')
return
}
requestSubmitting.value = true
try {
const payload: any = {
items: validSelectedItems.value.map(item => ({
material_type: item.typeLabel || item.type || '',
name: item.name || '',
spec_model: item.standard || '',
warehouse_location: item.warehouse_location || '',
quantity: item.export_quantity || 0
})),
remark: trimmed,
approver_id: requestApproverId.value
}
await submitOutboundRequest(payload)
// 成功:关闭弹窗、清空列表、提示
requestDialogVisible.value = false
selectedItems.value = []
ElMessage.success('出库申请已提交,等待主管审批!')
} catch (err: any) {
ElMessage.error(err?.message || err?.msg || '提交申请失败,请重试')
} finally {
requestSubmitting.value = false
}
}
const confirmPrint = async () => {
previewVisible.value = false;

View File

@ -0,0 +1,375 @@
<template>
<div class="app-container">
<!-- 顶部工具栏 -->
<div class="filter-container">
<span style="font-weight: bold; font-size: 15px; margin-right: 8px;">审批状态:</span>
<el-radio-group v-model="filterStatus" size="default" @change="handleStatusChange">
<el-radio-button label="">全部</el-radio-button>
<el-radio-button :label="0">待审批</el-radio-button>
<el-radio-button :label="1">已通过</el-radio-button>
<el-radio-button :label="2">已驳回</el-radio-button>
<el-radio-button :label="3">已完成</el-radio-button>
</el-radio-group>
<el-button type="primary" :icon="Refresh" @click="fetchData">刷新</el-button>
</div>
<!-- 数据表格 -->
<el-table
v-loading="loading"
:data="list"
border
stripe
style="margin-top: 16px;"
row-key="id"
:expand-row-keys="expandedRows"
@expand-change="handleExpandChange"
>
<!-- 展开行 -->
<el-table-column type="expand" width="60" align="center">
<template #default="{ row }">
<div style="padding: 12px 24px; background: #f5f7fa;">
<p style="margin: 0 0 10px 0; font-weight: bold; font-size: 13px; color: #606266;">
物料明细 {{ row.items?.length || 0 }}
</p>
<el-table
v-if="row.items?.length"
:data="row.items"
border
size="small"
style="width: 100%;"
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column label="类型" width="90" align="center">
<template #default="{ row: item }">
<el-tag size="small">{{ item.material_type || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="spec_model" label="规格型号" min-width="120" show-overflow-tooltip />
<el-table-column prop="warehouse_location" label="库位" width="120" show-overflow-tooltip />
<el-table-column prop="quantity" label="计划数量" width="100" align="center">
<template #default="{ row: item }">
<span style="color: #F56C6C; font-weight: bold;">{{ item.quantity ?? '-' }}</span>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无物料明细" :image-size="60" />
</div>
</template>
</el-table-column>
<el-table-column prop="request_no" label="申请单号" width="180">
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="toggleExpand(row)">
{{ row.request_no }}
</el-link>
</template>
</el-table-column>
<el-table-column label="申请人" width="140">
<template #default="{ row }">
{{ getApplicantName(row.applicant_id) }}
</template>
</el-table-column>
<el-table-column prop="remark" label="申请原因" min-width="180" show-overflow-tooltip />
<el-table-column label="物料种类" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" type="info">{{ row.items?.length || 0 }} </el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="申请时间" width="170" />
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">
{{ statusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="审批信息" width="180">
<template #default="{ row }">
<template v-if="row.status === 1">
<span style="color: #67C23A;">{{ getApproverName(row.actual_approver_id) }}</span>
<br />
<span style="font-size: 12px; color: #909399;">{{ row.approved_at || '' }}</span>
</template>
<template v-else-if="row.status === 2">
<span style="color: #F56C6C;">已驳回</span>
<el-tooltip v-if="row.reject_reason" :content="row.reject_reason" placement="top">
<el-icon style="margin-left: 4px; cursor: pointer;"><Warning /></el-icon>
</el-tooltip>
</template>
<template v-else-if="row.status === 3">
<span style="color: #909399;">{{ getApproverName(row.actual_approver_id) }}</span>
</template>
<span v-else style="color: #c0c4cc;">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right" align="center">
<template #default="{ row }">
<template v-if="row.status === 0">
<el-button
v-if="userStore.hasPermission('outbound_approval:operation')"
type="success"
size="small"
:loading="row._approving"
@click="handleApprove(row)"
>
通过
</el-button>
<el-button
v-if="userStore.hasPermission('outbound_approval:operation')"
type="danger"
size="small"
:loading="row._approving"
@click="openRejectDialog(row)"
>
驳回
</el-button>
</template>
<span v-else style="color: #c0c4cc;">-</span>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
background
style="margin-top: 16px; justify-content: flex-end; display: flex;"
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, prev, pager, next, sizes"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
<!-- 驳回原因 Dialog -->
<el-dialog v-model="rejectDialogVisible" title="驳回申请" width="480px" destroy-on-close>
<el-form label-width="80px">
<el-form-item label="申请单号">
<span style="font-weight: bold; color: #409EFF;">{{ currentRejectRow?.request_no }}</span>
</el-form-item>
<el-form-item label="驳回原因" required>
<el-input
v-model="rejectReason"
type="textarea"
:rows="4"
placeholder="请填写驳回原因(必填)"
maxlength="200"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="rejectDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="rejectLoading" @click="confirmReject">确认驳回</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Refresh, Warning } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getApprovalRequestList, approveRequest } from '@/api/outbound'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// --- 状态 ---
const list = ref<any[]>([])
const loading = ref(false)
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const filterStatus = ref<number | ''>(0) // 默认筛选待审批
const expandedRows = ref<string[]>([])
// 驳回 Dialog
const rejectDialogVisible = ref(false)
const currentRejectRow = ref<any>(null)
const rejectReason = ref('')
const rejectLoading = ref(false)
// 申请人 / 审批人名称缓存(避免重复查询)
const userNameCache = ref<Record<number, string>>({})
// --- 工具函数 ---
const statusText = (status: number) => {
const map: Record<number, string> = {
0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成'
}
return map[status] ?? '-'
}
const statusTagType = (status: number) => {
const map: Record<number, string> = {
0: 'warning', 1: 'success', 2: 'danger', 3: 'info'
}
return map[status] ?? 'info'
}
const getApplicantName = (id: number | null) => {
if (!id) return '-'
return userNameCache.value[id] ?? `用户 #${id}`
}
const getApproverName = (id: number | null) => {
if (!id) return '-'
return userNameCache.value[id] ?? `用户 #${id}`
}
// --- 展开行 ---
const toggleExpand = (row: any) => {
const idx = expandedRows.value.indexOf(row.id)
if (idx > -1) {
expandedRows.value.splice(idx, 1)
} else {
expandedRows.value.push(row.id)
}
}
const handleExpandChange = () => {
// expand 状态由 expandedRows 响应式控制,无需额外处理
}
// --- 数据获取 ---
const fetchData = async () => {
loading.value = true
try {
const params: any = {
page: page.value,
limit: pageSize.value
}
if (filterStatus.value !== '') {
params.status = filterStatus.value
}
const res: any = await getApprovalRequestList(params)
// 追加申请人名称缓存
const records = res.data?.items || []
records.forEach((r: any) => {
if (r.applicant_id && !userNameCache.value[r.applicant_id]) {
// 后端已返回 applicant_name 字段时直接用,否则标记待解析
if (r.applicant_name) {
userNameCache.value[r.applicant_id] = r.applicant_name
}
}
if (r.actual_approver_id && !userNameCache.value[r.actual_approver_id]) {
if (r.approver_name) {
userNameCache.value[r.actual_approver_id] = r.approver_name
}
}
// 附加空标记,防止重复请求
r._approving = false
})
list.value = records
total.value = res.data?.total || records.length || 0
} catch (err: any) {
ElMessage.error(err?.msg || '加载审批列表失败')
} finally {
loading.value = false
}
}
// --- 筛选 ---
const handleStatusChange = () => {
page.value = 1
expandedRows.value = []
fetchData()
}
// --- 分页 ---
const handlePageChange = (p: number) => {
page.value = p
fetchData()
}
const handleSizeChange = (s: number) => {
pageSize.value = s
page.value = 1
fetchData()
}
// --- 审批操作 ---
const handleApprove = async (row: any) => {
try {
await ElMessageBox.confirm(
`确定要通过出库申请单 【${row.request_no}】 吗?`,
'审批确认',
{ confirmButtonText: '确定通过', cancelButtonText: '取消', type: 'info' }
)
} catch {
return
}
row._approving = true
try {
await approveRequest(row.id, { action: 'approve' })
ElMessage.success(`申请单 ${row.request_no} 已通过`)
await fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || '审批操作失败')
} finally {
row._approving = false
}
}
const openRejectDialog = (row: any) => {
currentRejectRow.value = row
rejectReason.value = ''
rejectDialogVisible.value = true
}
const confirmReject = async () => {
const reason = rejectReason.value.trim()
if (!reason) {
ElMessage.warning('请填写驳回原因')
return
}
rejectLoading.value = true
try {
await approveRequest(currentRejectRow.value.id, {
action: 'reject',
reject_reason: reason
})
ElMessage.success(`申请单 ${currentRejectRow.value.request_no} 已驳回`)
rejectDialogVisible.value = false
await fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || '驳回操作失败')
} finally {
rejectLoading.value = false
}
}
// --- 初始化 ---
onMounted(() => {
fetchData()
})
</script>
<style scoped>
.app-container {
padding: 20px;
}
.filter-container {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
</style>

View File

@ -15,6 +15,68 @@
</div>
</template>
<!-- 出库模式切换 -->
<div class="mode-switch-bar">
<el-radio-group v-model="outboundMode" size="default" @change="handleModeChange">
<el-radio-button value="by-request">按单出库</el-radio-button>
<el-radio-button value="direct">直接出库</el-radio-button>
</el-radio-group>
<span class="mode-hint">
{{ outboundMode === 'by-request' ? '需先选择已审批通过的申请单' : '无需审批单,自由扫码出库' }}
</span>
</div>
<!-- 按单出库审批单选择 -->
<div v-if="outboundMode === 'by-request'" class="approval-request-select">
<el-select
v-model="selectedRequestId"
placeholder="请选择已审批通过的出库申请单"
filterable
clearable
style="width: 100%"
:loading="requestsLoading"
@change="handleRequestChange"
>
<el-option
v-for="req in approvalRequests"
:key="req.id"
:value="req.id"
:label="req.request_no"
>
<span>{{ req.request_no }}</span>
<el-divider direction="vertical" />
<span>{{ req.applicant_name || '未知申请人' }}</span>
<el-divider direction="vertical" />
<span style="color: #909399; font-size: 13px">{{ req.remark || '无备注' }}</span>
</el-option>
</el-select>
<p class="select-tip">仅显示已通过status=1的审批单</p>
</div>
<!-- 按单出库计划清单预览 -->
<div v-if="outboundMode === 'by-request' && selectedRequest" class="planned-items-section">
<div class="planned-header">
<span class="planned-title">计划出库清单</span>
<el-tag type="success" size="small">{{ selectedRequest.items?.length || 0 }} </el-tag>
</div>
<el-table :data="selectedRequest.items || []" border size="small" style="width: 100%;">
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column label="类型" width="80" align="center">
<template #default="{ row }">
<el-tag size="small" type="info">{{ row.material_type || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="spec_model" label="规格" min-width="100" show-overflow-tooltip />
<el-table-column prop="warehouse_location" label="库位" width="100" show-overflow-tooltip />
<el-table-column label="计划数量" width="90" align="center">
<template #default="{ row }">
<span style="color: #E6A23C; font-weight: bold;">{{ row.quantity ?? '-' }}</span>
</template>
</el-table-column>
</el-table>
</div>
<div class="scan-section">
<div v-if="userStore.hasPermission('outbound_create:operation')" class="camera-placeholder" @click="showCamera = true">
@ -214,7 +276,7 @@ import { ref, reactive, nextTick, onUnmounted, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select } from '@element-plus/icons-vue'
import QrScanner from '@/components/QrScanner/index.vue'
import { getStockByBarcode, submitOutbound, getOutboundList } from '@/api/outbound'
import { getStockByBarcode, submitOutbound, getOutboundList, getApprovalRequestList } from '@/api/outbound'
import { uploadFile } from '@/api/common/upload'
import { useUserStore } from '@/stores/user'
@ -228,6 +290,12 @@ const showCamera = ref(false)
const barcodeRef = ref()
const formRef = ref()
// ★ 双轨制模式
const outboundMode = ref<'by-request' | 'direct'>('by-request') // 'by-request' | 'direct'
const approvalRequests = ref<any[]>([])
const selectedRequest = ref<any>(null)
const requestsLoading = ref(false)
// 签名相关
const showSignatureDialog = ref(false)
const signaturePreviewUrl = ref('')
@ -258,8 +326,95 @@ const totalAmount = computed(() => {
return cartItems.value.reduce((sum, item) => sum + (item.price * item.out_quantity), 0)
})
// ★ 双轨制 computed
const selectedRequestId = computed({
get: () => selectedRequest.value?.id ?? null,
set: (val) => {
if (!val) {
selectedRequest.value = null
} else {
selectedRequest.value = approvalRequests.value.find(r => r.id === val) ?? null
}
}
})
const plannedItems = computed(() => selectedRequest.value?.items ?? [])
// ★ 模式切换
const handleModeChange = () => {
selectedRequest.value = null
selectedRequestId.value = null
cartItems.value = []
form.consumer_name = ''
form.remark = ''
signatureFile.value = null
signaturePreviewUrl.value = ''
barcodeInput.value = ''
}
// ★ 加载已审批通过的申请单
const loadApprovalRequests = async () => {
requestsLoading.value = true
try {
const res: any = await getApprovalRequestList({ status: 1, page: 1, pageSize: 100 })
approvalRequests.value = res.data?.items || []
} catch (e) {
console.error('加载审批单列表失败', e)
} finally {
requestsLoading.value = false
}
}
const handleRequestChange = (val: number | null) => {
if (!val) {
selectedRequest.value = null
} else {
selectedRequest.value = approvalRequests.value.find(r => r.id === val) ?? null
}
// 切换申请单时清空购物车,防止已扫物品与新单据混淆
cartItems.value = []
signatureFile.value = null
signaturePreviewUrl.value = ''
}
// ★ 按单出库模式:校验扫码是否在计划内
const validateAgainstPlan = (scannedName: string, scannedSpec: string, scannedQty: number): string | null => {
const normalizedName = scannedName.trim()
const normalizedSpec = (scannedSpec || '').trim()
const matchedPlan = plannedItems.value.find(plan => {
const planName = (plan.name || '').trim()
const planSpec = (plan.spec_model || '').trim()
return planName === normalizedName && planSpec === normalizedSpec
})
if (!matchedPlan) {
return `该物料【${normalizedName} × ${normalizedSpec}】不在计划清单中,请检查`
}
const planQty = matchedPlan.quantity ?? 0
// 已扫数量(去重合并)
const alreadyScanned = cartItems.value
.filter(ci => {
const ciName = (ci.name || '').trim()
const ciSpec = (ci.spec_model || '').trim()
return ciName === normalizedName && ciSpec === normalizedSpec
})
.reduce((sum, ci) => sum + (ci.out_quantity || 0), 0)
if (alreadyScanned + scannedQty > planQty) {
return `${normalizedName} × ${normalizedSpec}】超出计划数量(计划: ${planQty},已扫: ${alreadyScanned},本次: ${scannedQty}`
}
return null // 通过
}
// --- 初始化 ---
onMounted(() => {
// 加载已审批通过的申请单列表
loadApprovalRequests()
if (userStore.username) {
form.operator_name = userStore.username
operatorOptions.value.push(userStore.username)
@ -313,15 +468,32 @@ const handleManualInput = async () => {
const code = barcodeInput.value.trim()
if (!code) return
// ★ 按单出库模式:必须先选择申请单
if (outboundMode.value === 'by-request' && !selectedRequest.value) {
ElMessage.warning('请先选择要出库的审批申请单')
return
}
try {
loading.value = true
// 1. 检查购物车重复
// 1. 检查购物车重复(直接模式走旧的追加逻辑,按单模式也复用但后续会校验)
const existIndex = cartItems.value.findIndex(item => item.barcode === code || item.sku === code)
if (existIndex > -1) {
const item = cartItems.value[existIndex]
const maxQty = parseFloat(item.available_quantity)
// ★ 按单模式:追加时仍需校验计划数量
if (outboundMode.value === 'by-request') {
const err = validateAgainstPlan(item.name, item.spec_model, 1)
if (err) {
ElMessage.error(err)
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
barcodeInput.value = ''
return
}
}
const maxQty = parseFloat(item.available_quantity)
if (item.out_quantity < maxQty) {
item.out_quantity++
ElMessage.success(`数量+1 (当前: ${item.out_quantity})`)
@ -343,16 +515,29 @@ const handleManualInput = async () => {
if (availQty <= 0) {
ElMessage.warning(`库存不足或已出库 (余: ${availQty})`)
if (navigator.vibrate) navigator.vibrate([100, 50, 100])
} else {
// 加入购物车
cartItems.value.push({
...item,
out_quantity: 1,
price: parseFloat(item.price || 0)
})
ElMessage.success(`添加成功: ${item.name}`)
if (navigator.vibrate) navigator.vibrate(100)
barcodeInput.value = ''
return
}
// ★ 按单模式:扫码加入前校验是否在计划清单内
if (outboundMode.value === 'by-request') {
const err = validateAgainstPlan(item.name, item.spec_model, 1)
if (err) {
ElMessage.error(err)
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
barcodeInput.value = ''
return
}
}
// 加入购物车
cartItems.value.push({
...item,
out_quantity: 1,
price: parseFloat(item.price || 0)
})
ElMessage.success(`添加成功: ${item.name}`)
if (navigator.vibrate) navigator.vibrate(100)
barcodeInput.value = ''
}
} catch (error: any) {
@ -393,6 +578,7 @@ const clearAll = () => {
signatureFile.value = null
signaturePreviewUrl.value = ''
barcodeInput.value = ''
// ★ 按单模式:仅清空购物车,保留申请单选择
})
}
@ -416,40 +602,67 @@ const submitForm = async () => {
try {
loading.value = true
// 上传签名
// 1. 上传签名
const uploadRes = await uploadFile(signatureFile.value)
const signatureUrl = uploadRes.data.url
const itemsPayload = cartItems.value.map(item => ({
stock_id: item.id,
source_table: item.source_table,
sku: item.sku,
barcode: item.barcode,
quantity: item.out_quantity,
price: item.price
}))
// 2. 核心保护:坚决杜绝 undefined、null 和 0
const itemsPayload = cartItems.value.map(item => {
// 强制确保出库数量是一个大于 0 的有效数字
let safeQuantity = Number(item.out_quantity)
if (isNaN(safeQuantity) || safeQuantity <= 0) {
safeQuantity = 1 // 兜底:只要扫了码,最少出 1 个
}
await submitOutbound({
items: itemsPayload,
return {
stock_id: item.id || 0,
source_table: item.source_table || '',
// 如果原数据 sku 是空,强制塞一个默认字符串,绝不传空值给后端引发 None 报错
sku: item.sku ? String(item.sku) : (item.barcode ? String(item.barcode) : 'NO_SKU'),
barcode: item.barcode ? String(item.barcode) : '',
quantity: safeQuantity,
price: item.price ? Number(item.price) : 0
}
})
if (itemsPayload.length === 0) {
ElMessage.warning('请至少扫描一件物料后再提交出库')
return
}
// 3. 组装发给后端的包
const submitPayload: any = {
outbound_type: form.outbound_type,
request_id: outboundMode.value === 'by-request' && selectedRequest.value ? selectedRequest.value.id : null,
consumer_name: form.consumer_name,
operator_name: form.operator_name,
remark: form.remark,
signature_path: signatureUrl
})
signature_path: signatureUrl,
items: itemsPayload
}
// 打印在前端控制台,你可以按 F12 在 Console 里核对这把"铁证"
console.log('准备提交给后端的最终数据:', JSON.parse(JSON.stringify(submitPayload)))
// 4. 发送请求
await submitOutbound(submitPayload)
ElMessage.success('出库成功')
// 重置
// 5. 成功后重置页面
cartItems.value = []
form.consumer_name = ''
form.remark = ''
signatureFile.value = null
signaturePreviewUrl.value = ''
loadHistoryOperators()
// 根据你的项目实际变量重置签名组件,如果没有这句可以删掉
if (typeof signaturePreviewUrl !== 'undefined') {
signaturePreviewUrl.value = ''
}
} catch (error) {
console.error(error)
ElMessage.error('提交失败')
console.error('出库报错:', error)
ElMessage.error('提交失败,请检查数据')
} finally {
loading.value = false
}
@ -547,6 +760,39 @@ onUnmounted(() => {
.title-box { font-size: 16px; font-weight: bold; display: flex; align-items: center; gap: 8px; }
.header-price { font-size: 18px; color: #F56C6C; font-weight: bold; }
/* ★ 双轨制模式切换 */
.mode-switch-bar {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
padding: 12px 16px;
background: #f5f7fa;
border-radius: 8px;
border: 1px solid #e4e7ed;
}
.mode-hint { color: #909399; font-size: 13px; }
/* ★ 审批单选择 */
.approval-request-select { margin-bottom: 16px; }
.select-tip { margin: 6px 0 0 0; color: #909399; font-size: 12px; }
/* ★ 计划清单 */
.planned-items-section {
margin-bottom: 16px;
padding: 12px;
background: #f0f9eb;
border: 1px solid #e1f3d8;
border-radius: 8px;
}
.planned-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.planned-title { font-weight: bold; font-size: 14px; color: #67C23A; }
/* 扫码区(卡片内触发器) */
.scan-section { margin-bottom: 20px; }
.camera-placeholder {

View File

@ -1,5 +1,5 @@
<template>
<div v-if="userStore.hasPermission('outbound_list:view')" class="app-container">
<div class="app-container">
<div class="filter-container">
<el-input
v-model="listQuery.keyword"

View File

@ -0,0 +1,605 @@
<template>
<div class="app-container">
<!-- 顶部工具栏 -->
<div class="filter-container">
<span style="font-weight: bold; font-size: 15px; margin-right: 8px;">审批状态:</span>
<el-radio-group v-model="filterStatus" size="default" @change="handleStatusChange">
<el-radio-button label="">全部</el-radio-button>
<el-radio-button :label="0">待审批</el-radio-button>
<el-radio-button :label="1">已通过</el-radio-button>
<el-radio-button :label="2">已驳回</el-radio-button>
<el-radio-button :label="3">已完成</el-radio-button>
</el-radio-group>
<el-button type="primary" :icon="Refresh" @click="fetchData">刷新</el-button>
<el-button type="success" :icon="Plus" @click="openCreateDialog">
新建采购申请
</el-button>
</div>
<!-- 数据表格 -->
<el-table v-loading="loading" :data="list" border stripe style="margin-top: 16px;" row-key="id">
<el-table-column prop="request_no" label="申请单号" width="180" />
<el-table-column prop="name" label="采购物品" min-width="150" show-overflow-tooltip />
<el-table-column prop="spec_model" label="规格型号" min-width="120" show-overflow-tooltip />
<el-table-column prop="quantity" label="数量" width="80" align="center" />
<el-table-column prop="purchase_date" label="采购日期" width="110" />
<el-table-column label="单价/总价" width="120" align="right">
<template #default="{ row }">
<span v-if="row.unit_price || row.total_price">
{{ row.unit_price || '-' }} / {{ row.total_price || '-' }}
</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="requester_name" label="申请人" width="100" />
<el-table-column prop="approver_name" label="审批人" width="100" />
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">{{ row.status_text }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right" align="center">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="openDetailDialog(row)">详情</el-button>
<template v-if="row.status === 0 && canApprove">
<el-button type="success" link size="small" @click="handleApprove(row)">通过</el-button>
<el-button type="danger" link size="small" @click="openRejectDialog(row)">驳回</el-button>
</template>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
background
style="margin-top: 16px; justify-content: flex-end; display: flex;"
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, prev, pager, next, sizes"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
<!-- ========== 新建/编辑弹窗 ========== -->
<el-dialog v-model="formDialogVisible" :title="dialogTitle" width="700px" destroy-on-close :close-on-click-modal="false">
<el-form ref="formRef" :model="form" label-width="110px">
<el-row :gutter="20">
<el-col :span="24">
<el-form-item label="采购物品" required>
<el-select
v-model="materialBaseId"
filterable
remote
reserve-keyword
clearable
placeholder="输入名称或规格搜索..."
:remote-method="handleSearchMaterialDebounced"
:loading="searchLoading"
style="width: 100%"
@change="onMaterialSelected"
default-first-option
popper-class="long-dropdown"
v-loadmore="handleLoadMoreMaterials"
@visible-change="onMaterialDropdownVisibleChange"
>
<el-option
v-for="item in materialOptions"
:key="item.id"
:label="item.name"
:value="item.id"
>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>{{ item.name }}</span>
<span style="color: #999; font-size: 12px;">{{ item.spec_model || '-' }}</span>
</div>
</el-option>
<li v-if="loadingMore" class="el-select-dropdown__item loading-more" style="text-align:center;color:#999;cursor:default;">
<span>加载中...</span>
</li>
</el-select>
<div v-if="autoFillHint" style="font-size: 12px; color: #67C23A; margin-top: 4px;">{{ autoFillHint }}</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="规格型号">
<el-input v-model="form.spec_model" placeholder="从物料列表选择后自动填充" clearable />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="采购数量" required>
<el-input-number v-model="form.quantity" :min="1" style="width: 100%;" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="采购日期" required>
<el-date-picker v-model="form.purchase_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%;" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="审批人" required>
<el-select v-model="form.approver_id" placeholder="请选择审批人" style="width: 100%;" filterable>
<el-option v-for="u in approvers" :key="u.id" :label="u.username" :value="u.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="单价">
<el-input-number v-model="form.unit_price" :min="0" :precision="2" style="width: 100%;" @change="onUnitPriceChange" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="总价">
<el-input-number v-model="form.total_price" :min="0" :precision="2" style="width: 100%;" @change="onTotalPriceChange" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="商家链接">
<el-input v-model="form.supplier_link" placeholder="商家地址链接(选填)" clearable />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="备注信息(选填)" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="图片上传" required id="upload-purchase-images">
<el-upload
v-model:file-list="fileList"
:http-request="customUpload"
:on-remove="handleRemoveImage"
:before-upload="beforeAvatarUpload"
accept="image/jpeg,image/png,image/gif,image/webp"
multiple
list-type="picture-card"
>
<el-icon><Plus /></el-icon>
</el-upload>
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="formDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitForm">确认提交</el-button>
</template>
</el-dialog>
<!-- ========== 详情弹窗 ========== -->
<el-dialog v-model="detailDialogVisible" title="采购申请详情" width="700px" destroy-on-close>
<el-descriptions :column="2" border>
<el-descriptions-item label="申请单号">{{ detail.request_no }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusTagType(detail.status)" size="small">{{ detail.status_text }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="采购物品">{{ detail.name }}</el-descriptions-item>
<el-descriptions-item label="规格型号">{{ detail.spec_model || '-' }}</el-descriptions-item>
<el-descriptions-item label="采购数量">{{ detail.quantity }}</el-descriptions-item>
<el-descriptions-item label="采购日期">{{ detail.purchase_date }}</el-descriptions-item>
<el-descriptions-item label="单价">{{ detail.unit_price || '-' }}</el-descriptions-item>
<el-descriptions-item label="总价">{{ detail.total_price || '-' }}</el-descriptions-item>
<el-descriptions-item label="申请人">{{ detail.requester_name }}</el-descriptions-item>
<el-descriptions-item label="审批人">{{ detail.approver_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="审批时间">{{ detail.approved_at || '-' }}</el-descriptions-item>
<el-descriptions-item label="商家链接">
<a v-if="detail.supplier_link" :href="detail.supplier_link" target="_blank" style="color: #409EFF;">
{{ detail.supplier_link }}
</a>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ detail.remark || '-' }}</el-descriptions-item>
<el-descriptions-item label="驳回原因" :span="2" v-if="detail.reject_reason">
<span style="color: #F56C6C;">{{ detail.reject_reason }}</span>
</el-descriptions-item>
</el-descriptions>
<!-- 图片展示 -->
<div v-if="detail.images && detail.images.length > 0" style="margin-top: 16px;">
<div style="font-weight: bold; margin-bottom: 8px;">图片</div>
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<el-image
v-for="(img, idx) in detail.images"
:key="idx"
:src="getImageUrl(img)"
:preview-src-list="detail.images.map(u => getImageUrl(u))"
fit="cover"
style="width: 100px; height: 100px; border-radius: 4px; cursor: pointer;"
/>
</div>
</div>
</el-dialog>
<!-- ========== 驳回原因弹窗 ========== -->
<el-dialog v-model="rejectDialogVisible" title="驳回申请" width="480px" destroy-on-close>
<el-form label-width="80px">
<el-form-item label="申请单号">
<span style="font-weight: bold; color: #409EFF;">{{ currentRejectRow?.request_no }}</span>
</el-form-item>
<el-form-item label="驳回原因" required>
<el-input v-model="rejectReason" type="textarea" :rows="4" placeholder="请填写驳回原因必填" maxlength="200" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="rejectDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="rejectLoading" @click="confirmReject">确认驳回</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Refresh, Plus } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useUserStore } from '@/stores/user'
import { searchMaterialPurchase } from '@/api/purchase'
import {
getPurchaseList, createPurchase, getPurchaseDetail,
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()
// --- 状态 ---
const list = ref<any[]>([])
const loading = ref(false)
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const filterStatus = ref<number | ''>(0) // 默认筛选待审批
const submitLoading = ref(false)
const rejectLoading = ref(false)
const fileList = ref<any[]>([])
// 驳回
const rejectDialogVisible = ref(false)
const currentRejectRow = ref<any>(null)
const rejectReason = ref('')
// 详情
const detailDialogVisible = ref(false)
const detail = ref<any>({})
// 创建弹窗
const formDialogVisible = ref(false)
const dialogTitle = ref('新建采购申请')
const formRef = ref<FormInstance>()
const isUploading = ref(false)
const autoFillHint = ref('')
// 审批人
const approvers = ref<any[]>([])
// 物料搜索
const materialOptions = ref<any[]>([])
const searchLoading = ref(false)
const materialBaseId = ref<number | null>(null)
const searchPage = ref(1)
const searchKeyword = ref('')
const hasNextPage = ref(true)
const loadingMore = ref(false)
// v-loadmore 指令:监听 el-select 下拉滚动,滚动到底部时触发回调
const vLoadmore = {
mounted(el: any, binding: any) {
const dropdown = el.querySelector?.('.el-select-dropdown__wrap')
if (!dropdown) return
dropdown.addEventListener('scroll', function (this: any) {
const condition = this.scrollHeight - this.scrollTop <= this.clientHeight + 2
if (condition) binding.value()
})
}
}
// 表单
const form = ref({
name: '',
spec_model: '',
quantity: 1,
purchase_date: '',
supplier_link: '',
remark: '',
unit_price: undefined as number | undefined,
total_price: undefined as number | undefined,
approver_id: undefined as number | undefined,
images: [] as string[]
})
// --- 计算属性 ---
const canApprove = computed(() => {
return userStore.role === 'SUPER_ADMIN' || userStore.role === 'SUPERVISOR'
})
// --- 工具函数 ---
const statusTagType = (status: number) => {
const map: Record<number, string> = {
0: 'warning', 1: 'success', 2: 'danger', 3: 'info'
}
return map[status] ?? 'info'
}
const getImageUrl = (url: string) => {
if (!url) return ''
if (url.startsWith('http')) return url
return `/api/v1/common/files/${url}`
}
const formatDate = (d: string) => d ? d.split(' ')[0] : ''
// --- 数据获取 ---
const fetchData = async () => {
loading.value = true
try {
const params: any = { page: page.value, limit: pageSize.value }
if (filterStatus.value !== '') params.status = filterStatus.value
const res: any = await getPurchaseList(params)
list.value = res.data?.items || []
total.value = res.data?.total || 0
} catch (err: any) {
ElMessage.error(err?.msg || '加载失败')
} finally {
loading.value = false
}
}
const fetchApprovers = async () => {
try {
const res: any = await getPurchaseApprovers()
approvers.value = res.data || []
} catch (e) {
console.error(e)
}
}
// --- 筛选 & 分页 ---
const handleStatusChange = () => {
page.value = 1
fetchData()
}
const handlePageChange = (p: number) => { page.value = p; fetchData() }
const handleSizeChange = (s: number) => { pageSize.value = s; page.value = 1; fetchData() }
// --- 新建 ---
const openCreateDialog = () => {
dialogTitle.value = '新建采购申请'
form.value = {
name: '', spec_model: '', quantity: 1,
purchase_date: new Date().toISOString().split('T')[0],
supplier_link: '', remark: '',
unit_price: undefined, total_price: undefined,
approver_id: undefined, images: []
}
materialBaseId.value = null
materialOptions.value = []
autoFillHint.value = ''
fileList.value = []
totalPriceManuallyEdited.value = false
formDialogVisible.value = true
}
// --- 物料搜索(分页) ---
let searchTimer: any = null
const handleSearchMaterialDebounced = (query: string) => {
clearTimeout(searchTimer)
searchTimer = setTimeout(() => handleSearchMaterial(query), 300)
}
const handleSearchMaterial = async (query: string) => {
searchLoading.value = true
searchKeyword.value = query
searchPage.value = 1
materialOptions.value = []
hasNextPage.value = true
try {
const res: any = await searchMaterialPurchase(query, 1)
materialOptions.value = res.data || []
hasNextPage.value = res.has_next !== false
} finally {
searchLoading.value = false
}
}
const handleLoadMoreMaterials = async () => {
if (searchLoading.value || loadingMore.value || !hasNextPage.value) return
loadingMore.value = true
searchPage.value += 1
try {
const res: any = await searchMaterialPurchase(searchKeyword.value, searchPage.value)
if (res.data && res.data.length > 0) {
materialOptions.value = [...materialOptions.value, ...res.data]
hasNextPage.value = res.has_next !== false
} else {
hasNextPage.value = false
}
} catch {
searchPage.value -= 1
} finally {
loadingMore.value = false
}
}
const onMaterialDropdownVisibleChange = (visible: boolean) => {
if (visible && materialOptions.value.length === 0) {
handleSearchMaterial('')
}
}
const onMaterialSelected = (id: number | null) => {
if (!id) {
materialBaseId.value = null
return
}
const item = materialOptions.value.find(i => i.id === id)
if (item) {
form.value.name = item.name || item.material_name || ''
form.value.spec_model = item.spec_model || item.spec || ''
materialBaseId.value = id
autoFillHint.value = ''
}
}
// --- 价格半联动逻辑 ---
// totalPriceManuallyEdited标记总价是否被用户手动修改过修改后不再自动覆盖
const totalPriceManuallyEdited = ref(false)
const onUnitPriceChange = (val: number | undefined) => {
// 用户修改单价时:如果总价尚未被手动修改,则自动计算总价
if (val !== undefined && val > 0 && form.value.quantity > 0 && !totalPriceManuallyEdited.value) {
form.value.total_price = +(val * form.value.quantity).toFixed(2)
}
}
const onTotalPriceChange = (_val: number | undefined) => {
// 用户手动修改总价时:标记为已手动修改,不再反向强制覆盖单价
// 允许用户输入打包一口价如单价3元买2个算5元保留用户填入的单价和总价
totalPriceManuallyEdited.value = true
}
// --- 上传 ---
const beforeAvatarUpload = (rawFile: any) => {
const isTypeValid = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'].includes(rawFile.type)
if (!isTypeValid) {
ElMessage.error('仅支持 JPG/PNG/GIF/WEBP 图片')
return false
}
if (rawFile.size / 1024 / 1024 > 10) {
ElMessage.error('图片不能超过 10MB')
return false
}
return true
}
const customUpload = async (options: any) => {
const { file, onSuccess, onError } = options
const formData = new FormData()
formData.append('file', file)
isUploading.value = true
try {
const res: any = await uploadFile(formData)
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 || '上传失败')
onError(new Error(res.msg))
}
} catch (e) {
ElMessage.error('网络错误')
onError(e)
} finally {
isUploading.value = false
}
}
const handleRemoveImage = async (uploadFile: any) => {
const urlToRemove = form.value.images!.find(u => getImageUrl(u) === uploadFile.url) || uploadFile.url
form.value.images = form.value.images!.filter(u => u !== urlToRemove)
const filename = urlToRemove.split('/').pop()
if (filename && !urlToRemove.startsWith('http')) {
await deleteFile(filename).catch(() => {})
}
}
// 粘贴上传处理器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 }
if (!form.value.approver_id) { ElMessage.warning('请选择审批人'); return }
if (!form.value.purchase_date) { ElMessage.warning('请选择采购日期'); return }
if (!form.value.images || form.value.images.length === 0) { ElMessage.warning('请上传至少一张图片'); return }
submitLoading.value = true
try {
await createPurchase(form.value as any)
ElMessage.success('提交成功,审批人将收到邮件通知')
formDialogVisible.value = false
await fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || '提交失败')
} finally {
submitLoading.value = false
}
}
// --- 详情 ---
const openDetailDialog = async (row: any) => {
try {
const res: any = await getPurchaseDetail(row.id)
detail.value = res.data || {}
detailDialogVisible.value = true
} catch (e) {
ElMessage.error('获取详情失败')
}
}
// --- 审批操作 ---
const handleApprove = async (row: any) => {
try {
await ElMessageBox.confirm(`确定要通过采购申请「${row.request_no}」吗?`, '审批确认', {
confirmButtonText: '确定通过', cancelButtonText: '取消', type: 'info'
})
} catch { return }
try {
await approvePurchase(row.id, { action: 'approve' })
ElMessage.success('已通过')
await fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || '操作失败')
}
}
const openRejectDialog = (row: any) => {
currentRejectRow.value = row
rejectReason.value = ''
rejectDialogVisible.value = true
}
const confirmReject = async () => {
if (!rejectReason.value.trim()) { ElMessage.warning('请填写驳回原因'); return }
rejectLoading.value = true
try {
await approvePurchase(currentRejectRow.value.id, {
action: 'reject', reject_reason: rejectReason.value
})
ElMessage.success('已驳回')
rejectDialogVisible.value = false
await fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || '操作失败')
} finally {
rejectLoading.value = false
}
}
// --- 初始化 ---
onMounted(() => {
fetchData()
fetchApprovers()
})
</script>
<style scoped>
.app-container { padding: 20px; }
.filter-container { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
</style>

View File

@ -266,6 +266,16 @@
<div style="display: flex; align-items: center;">
<el-icon class="icon"><Box/></el-icon>
<span>1. 基础信息</span>
<el-link
v-if="form.base_id"
type="primary"
:underline="false"
style="margin-left: 15px; font-size: 13px;"
@click="openMaterialInNewTab"
>
<el-icon style="margin-right: 4px"><EditPen /></el-icon>前往修改基础信息
</el-link>
</div>
<span class="sub-title" v-if="dialogStatus === 'create'"> (请先搜索锁定物料)</span>
</div>
@ -437,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="#"
@ -456,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="#"
@ -666,7 +676,9 @@
<script setup lang="ts">
import {ref, reactive, onMounted, watch, computed} from 'vue'
import {Plus, Setting, Refresh, Search, Lock, Box, House, InfoFilled, Link, Printer, Camera, Delete, Picture} from '@element-plus/icons-vue'
import {Plus, Setting, Refresh, Search, Lock, Box, House, InfoFilled, Link, Printer, Camera, Delete, Picture, EditPen} from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
const router = useRouter()
import {ElMessage, ElMessageBox, ElLoading} from 'element-plus'
import dayjs from 'dayjs'
import request from '@/utils/request'
@ -686,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'
@ -1394,6 +1407,22 @@ const handleUpdate = (row: any) => {
visible.value = true
}
// 在新标签页打开基础信息编辑
const openMaterialInNewTab = () => {
if (!form.base_id) {
return ElMessage.warning('请先选择一个物料')
}
const routeUrl = router.resolve({
path: '/material',
query: {
edit_id: form.base_id,
// 【新增】:优先传递规格型号,如果没有则传名称,用于背景表格过滤
keyword: form.spec_model || form.material_name || ''
}
})
window.open(routeUrl.href, '_blank')
}
const submitForm = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid: boolean) => {
@ -1504,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

@ -250,9 +250,18 @@
<div class="form-card basic-card">
<div class="card-title">
<div style="display: flex; align-items: center;">
<div style="display: flex; align-items: center; gap: 8px;">
<el-icon class="icon"><Box /></el-icon>
<span>1. 基础信息</span>
<el-link
v-if="form.base_id"
type="primary"
:underline="false"
style="font-size: 13px;"
@click="openMaterialInNewTab"
>
<el-icon style="margin-right: 4px"><EditPen /></el-icon>前往修改基础信息
</el-link>
</div>
</div>
<div class="card-content">
@ -379,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>
@ -393,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>
@ -406,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>
@ -421,7 +430,12 @@
</div>
<div class="form-card production-card">
<div class="card-title"><el-icon class="icon"><Setting /></el-icon><span>3. 生产与销售信息</span></div>
<div class="card-title">
<el-icon class="icon"><Setting /></el-icon><span>3. 生产与销售信息</span>
<el-link type="success" :underline="false" style="margin-left: 15px; font-size: 13px;" @click="createBomForMaterial">
<el-icon style="margin-right: 4px"><Plus /></el-icon>加入或查看BOM
</el-link>
</div>
<div class="card-content">
<el-row :gutter="24">
@ -556,7 +570,8 @@
<script setup lang="ts">
import { ref, reactive, onMounted, watch, computed } from 'vue'
import { Plus, Setting, Refresh, Search, Box, House, Link, InfoFilled, Printer, Camera, Picture } from '@element-plus/icons-vue'
import { Plus, Setting, Refresh, Search, Box, House, Link, InfoFilled, Printer, Camera, Picture, EditPen } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElLoading } from 'element-plus'
import dayjs from 'dayjs'
import request from '@/utils/request'
@ -577,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'
// ------------------------------------
@ -614,6 +630,21 @@ const vLoadmore = {
}
const userStore = useUserStore()
const router = useRouter()
// 在新标签页打开基础信息编辑
const openMaterialInNewTab = () => {
if (!form.base_id) return ElMessage.warning('请先选择物料')
const routeUrl = router.resolve({
path: '/material',
query: {
edit_id: form.base_id,
keyword: form.spec_model || form.material_name || ''
}
})
window.open(routeUrl.href, '_blank')
}
const loading = ref(false)
const submitting = ref(false)
const visible = ref(false)
@ -1205,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;
@ -1263,6 +1303,20 @@ const handleScannerConfirm = (result: string) => {
ElMessage.success('序列号已提取')
}
// 快速基于此物料创建 BOM
const createBomForMaterial = () => {
if (!form.base_id) return ElMessage.warning('请先锁定物料基础信息')
const routeUrl = router.resolve({
path: '/bom',
query: {
create_for_id: form.base_id,
parent_name: form.material_name,
parent_spec: form.spec_model
}
})
window.open(routeUrl.href, '_blank')
}
const submitForm = async () => {
await formRef.value.validate(async (valid: boolean) => {
if(valid) {

View File

@ -287,9 +287,18 @@
<div class="form-card basic-card">
<div class="card-title">
<div style="display: flex; align-items: center;">
<div style="display: flex; align-items: center; gap: 8px;">
<el-icon class="icon"><Box/></el-icon>
<span>1. 基础信息</span>
<el-link
v-if="form.base_id"
type="primary"
:underline="false"
style="font-size: 13px;"
@click="openMaterialInNewTab"
>
<el-icon style="margin-right: 4px"><EditPen /></el-icon>前往修改基础信息
</el-link>
</div>
<span class="sub-title" v-if="dialogStatus === 'create'"> (请先搜索选择半成品物料)</span>
</div>
@ -458,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>
@ -470,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>
@ -489,6 +498,9 @@
<div style="display: flex; align-items: center;">
<el-icon class="icon"><Setting/></el-icon>
<span>3. 生产与成本信息</span>
<el-link type="success" :underline="false" style="margin-left: 15px; font-size: 13px;" @click="createBomForMaterial">
<el-icon style="margin-right: 4px"><Plus /></el-icon>加入或查看BOM
</el-link>
</div>
</div>
<div class="card-content">
@ -613,7 +625,8 @@
<script setup lang="ts">
import {ref, reactive, onMounted, watch, computed} from 'vue'
import {Plus, Setting, Refresh, Search, Lock, Box, House, InfoFilled, Link, Printer, Camera, Picture} from '@element-plus/icons-vue'
import {Plus, Setting, Refresh, Search, Lock, Box, House, InfoFilled, Link, Printer, Camera, Picture, EditPen} from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import {ElMessage, ElLoading} from 'element-plus'
import dayjs from 'dayjs'
import request from '@/utils/request'
@ -634,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'
// ------------------------------------
@ -673,6 +687,21 @@ const vLoadmore = {
// 状态与变量
// ------------------------------------
const userStore = useUserStore()
const router = useRouter()
// 在新标签页打开基础信息编辑
const openMaterialInNewTab = () => {
if (!form.base_id) return ElMessage.warning('请先选择物料')
const routeUrl = router.resolve({
path: '/material',
query: {
edit_id: form.base_id,
keyword: form.spec_model || form.material_name || ''
}
})
window.open(routeUrl.href, '_blank')
}
const loading = ref(false)
const submitting = ref(false)
const visible = ref(false)
@ -1296,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;
@ -1346,6 +1383,20 @@ const handleScannerConfirm = (result: string) => {
ElMessage.success('序列号已提取')
}
// 快速基于此物料创建 BOM
const createBomForMaterial = () => {
if (!form.base_id) return ElMessage.warning('请先锁定物料基础信息')
const routeUrl = router.resolve({
path: '/bom',
query: {
create_for_id: form.base_id,
parent_name: form.material_name,
parent_spec: form.spec_model
}
})
window.open(routeUrl.href, '_blank')
}
const submitForm = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid: boolean) => {

View File

@ -486,6 +486,7 @@ const listData = ref<any[]>([])
const listStatusFilter = ref<'all' | 'counted' | 'uncounted'>('all')
const allStockItems = ref<any[]>([]) // 全量应盘物资(盘点基数)
const totalStockCount = ref(0) // ★ 全量应盘物资总数不受limit限制
const totalScannedCount = ref(0) // ★ 后端去重的真实已盘数量
const allScannedDrafts = ref<any[]>([]) // 全量草稿记录(脱离分页和过滤)
const listTotalFiltered = ref(0) // 过滤后的总数
@ -515,18 +516,9 @@ const stats = computed(() => {
const total = allStockItems.value.length
if (total === 0) return { total: 0, scanned: 0, varianceItems: 0 }
// 使用完整的 allScannedDrafts 来计算"已盘"数量,绝对不依赖视图数据
const countedItems = new Set()
allScannedDrafts.value.forEach((d: any) => {
// 只要有实盘记录就算已盘
if (d.quantity !== undefined && d.quantity !== null) {
countedItems.add(`${d.source_table}-${d.stock_id}`)
}
})
return {
total,
scanned: countedItems.size,
scanned: totalScannedCount.value,
varianceItems: 0
}
})
@ -665,7 +657,7 @@ const resumeSession = async () => {
const res: any = await request({
url: '/v1/inbound/stock/draft/list',
method: 'get',
params: { page: 1, limit: 500 } // ★ 限制单次加载数量,防止内存溢出
params: { page: 1, limit: 99999 } // ★ 获取全量草稿数据
})
const drafts = res && res.items ? res.items : []
@ -993,7 +985,7 @@ const fetchInventoryList = async (silent = false) => {
method: 'get',
params: {
page: 1,
limit: 500, // ★ 限制单次加载数量,防止内存溢出
limit: 99999, // ★ 获取全量草稿数据
keyword: listKeyword.value,
session_id: currentSessionId.value // ★ 必须传递 session_id 隔离会话
}
@ -1005,6 +997,8 @@ const fetchInventoryList = async (silent = false) => {
// 保存全量草稿记录用于全局统计
allScannedDrafts.value = scannedDrafts
// 直接读取后端算好的去重已盘数
totalScannedCount.value = res?.total_scanned || 0
// 2. 使用全量应盘物资列表
// 对于每个应盘物资,检查是否有对应的盘点记录
@ -1023,7 +1017,7 @@ const fetchInventoryList = async (silent = false) => {
material_name: item.material_name,
spec_model: item.spec_model,
stock_qty: item.stock_qty, // 账面数(盲盘时隐藏)
quantity: draft?.quantity || 0, // 实盘数
quantity: draft?.quantity ?? draft?.qty_actual ?? 0, // 兼容后端字段名
diff_qty: draft ? (draft.quantity - item.stock_qty) : -item.stock_qty, // 差异
remark: draft?.remark || '',
warehouse_location: item.warehouse_location

View File

@ -1,5 +1,5 @@
<template>
<div v-if="userStore.hasPermission('transaction_records:view')" class="app-container">
<div v-if="userStore.hasPermission('op_records')" class="app-container">
<div class="filter-container">
<el-radio-group v-model="status" @change="fetchData" style="margin-right: 20px">
<el-radio-button label="all">全部</el-radio-button>
@ -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,6 +174,11 @@ const hasColumnPermission = (prop: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true
}
// 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]
return code ? userStore.hasPermission(code) : false
}

35
query_audit.py Normal file
View File

@ -0,0 +1,35 @@
import psycopg2
import json
try:
conn = psycopg2.connect(
host='localhost',
port=5432,
database='inventory_system',
user='test',
password='1234'
)
cur = conn.cursor()
cur.execute('SELECT id, action, target_name, details FROM audit_logs ORDER BY id DESC LIMIT 3')
rows = cur.fetchall()
print('=== 最新3条审计日志 ===')
for row in rows:
print(f'ID: {row[0]}')
print(f'Action: {row[1]}')
print(f'Target: {row[2]}')
details = row[3]
if details:
# 格式化显示
if isinstance(details, str):
try:
details = json.loads(details)
except:
pass
print(f'Details: {json.dumps(details, indent=2, ensure_ascii=False)}')
else:
print(f'Details: None')
print('---')
cur.close()
conn.close()
except Exception as e:
print(f'Error: {e}')

38
upload_odoo_files.sh Executable file
View File

@ -0,0 +1,38 @@
#!/bin/bash
# === 配置项 ===
SERVER="dxc@172.16.0.198"
LOCAL_DIR="Odoo_Archive"
REMOTE_TARGET_DIR="/opt/inventory-app/uploads_prod"
ARCHIVE_NAME="odoo_images_upload.tar.gz"
echo "🚀 开始将本地图像及附件同步至线上存储目录..."
# 1. 检查本地文件夹
if [ ! -d "$LOCAL_DIR" ]; then
echo "❌ 找不到本地文件夹 $LOCAL_DIR,请确保脚本与该文件夹在同一层级!"
exit 1
fi
# 2. 本地打包 (使用 -C 保证解压后没有多余的外层文件夹)
echo "[1/4] 正在本地打包所有图片和文件..."
tar -czf $ARCHIVE_NAME -C $LOCAL_DIR .
# 3. 传输到生产环境的 /tmp 目录
echo "[2/4] 正在传输到服务器临时目录 /tmp (可能需要输入服务器密码)..."
scp $ARCHIVE_NAME $SERVER:/tmp/$ARCHIVE_NAME
# 4. 服务器解压并设置权限 (核心:纯文件覆盖/追加,不碰数据库)
echo "[3/4] 正在远端部署图像到目标文件夹 (可能需要输入 sudo 密码)..."
ssh -t $SERVER "sudo mkdir -p $REMOTE_TARGET_DIR && \
echo '>> 正在将图像释放到 $REMOTE_TARGET_DIR ...' && \
sudo tar -xzf /tmp/$ARCHIVE_NAME -C $REMOTE_TARGET_DIR && \
echo '>> 正在重置文件读写权限,确保线上服务可以正常显示图片...' && \
sudo chmod -R 755 $REMOTE_TARGET_DIR && \
sudo rm /tmp/$ARCHIVE_NAME"
# 5. 清理本地压缩包
echo "[4/4] 正在清理本地临时文件..."
rm $ARCHIVE_NAME
echo "✅ 图像及附件物理转移全部完成!线上存储内容已更新。"

108
图像信息导入.py Executable file
View File

@ -0,0 +1,108 @@
import pandas as pd
import psycopg2
import json
import os
# ================= 配置区 =================
DB_CONFIG = {
'dbname': 'inventory_system',
'user': 'test',
'password': '1234',
'host': 'localhost',
'port': '5435'
}
EXCEL_FILE = "Odoo_Archive/Odoo产品_终极大满贯版.xlsx"
# ================= 辅助函数 =================
def process_paths_only(json_str):
"""
将爬虫的绝对路径,转换为现有后端接口完美支持的纯文件名格式!
"""
if not json_str or str(json_str).strip() in ['[]', 'nan', 'None']:
return '[]'
try:
paths = json.loads(json_str)
new_paths = []
for path in paths:
if path.startswith('http://') or path.startswith('https://'):
new_paths.append(path)
else:
filename = os.path.basename(path)
# 【终极修复】去掉中间的子文件夹,直接请求文件名!
web_path = f"/api/v1/common/files/{filename}"
new_paths.append(web_path)
return json.dumps(new_paths, ensure_ascii=False)
except Exception as e:
return '[]'
# ================= 主程序 =================
def process_excel_to_db():
if not os.path.exists(EXCEL_FILE):
print(f"❌ 找不到 Excel 文件: {EXCEL_FILE}")
return
try:
df = pd.read_excel(EXCEL_FILE, dtype=str)
df = df.where(pd.notnull(df), None)
print(f"✅ 成功读取 Excel{len(df)} 行数据。")
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
success_count = 0
for index, row in df.iterrows():
internal_ref = row.get('内部参考')
barcode = row.get('条码')
spec_model = ""
if barcode and internal_ref:
spec_model = f"{barcode}/{internal_ref}"
elif barcode:
spec_model = f"{barcode}"
elif internal_ref:
spec_model = f"{internal_ref}"
else:
continue
raw_image_json = row.get('generalImage')
raw_manual_json = row.get('generalManual')
if (not raw_image_json or raw_image_json == '[]') and (not raw_manual_json or raw_manual_json == '[]'):
continue
product_image = process_paths_only(raw_image_json)
manual_link = process_paths_only(raw_manual_json)
update_query = """
UPDATE material_base
SET product_image = %s, \
manual_link = %s
WHERE spec_model = %s
"""
cur.execute(update_query, (product_image, manual_link, spec_model))
if cur.rowcount > 0:
success_count += 1
conn.commit()
print(f"\n🎉 导入完成!成功更新了 {success_count} 条数据的正确路径。")
print("💡 赶快去刷新前端看看吧!这次图片一定能刷出来!")
except Exception as e:
print(f"❌ 发生致命错误: {e}")
if 'conn' in locals() and conn: conn.rollback()
finally:
if 'cur' in locals() and cur: cur.close()
if 'conn' in locals() and conn: conn.close()
if __name__ == "__main__":
process_excel_to_db()