40 Commits

Author SHA1 Message Date
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
DXC
3c9cb06dbc fix: save_bom add db.session.flush() to fix unique constraint conflict 2026-04-23 13:34:41 +08:00
dxc
d9c95084ad 4.23基础西悉尼图像补足 2026-04-23 12:48:34 +08:00
DXC
1e17547c6e feat: 文件展示与提交逻辑优化 2026-04-23 10:25:28 +08:00
dxc
03518c99f3 库存盘点添加扫码保留数值的功能 2026-04-22 11:59:50 +08:00
DXC
1205d9c7e8 feat(audit): 优化审计日志的人性化展示 2026-04-22 10:44:13 +08:00
DXC
4b794b9bcc feat(audit): 添加全局无侵入审计日志拦截器 2026-04-22 10:41:15 +08:00
dxc
ab353e5b34 库存盘点添加扫码保留数值的功能 2026-04-22 10:05:25 +08:00
dxc
5334be0cfa 基础信息修改针对于类别删除4层限制 2026-04-21 16:33:55 +08:00
DXC
2006b7275f fix(outbound): 终极护航版confirmPrint,iframe克隆样式+强制分页CSS 2026-04-21 14:39:48 +08:00
DXC
dd54e047dd fix(outbound): 重构confirmPrint为iframe打印模式,彻底粉碎全局CSS高度锁死 2026-04-21 14:35:34 +08:00
DXC
f53b16f512 fix(outbound): 重构@media print,解除el-container/el-main/el-scrollbar父级裁剪锁定 2026-04-21 14:26:11 +08:00
DXC
8583b811e1 fix(outbound): 将@media print移至非scoped样式,解除body/html高度锁死实现自由分页 2026-04-21 14:01:20 +08:00
DXC
6f5a7cf0db fix(outbound): #print-area改为absolute解除高度锁死,新增行防断裂保护 2026-04-21 13:55:26 +08:00
DXC
01ce9c1432 fix(outbound): 修复预览弹窗el-table打印分页截断问题 2026-04-21 13:25:15 +08:00
DXC
0ab7050e03 feat(outbound): 批量模式下支持点击整行切换选中状态 2026-04-21 13:20:13 +08:00
DXC
cd714d0c16 feat(outbound): 优化批量操作UX,改为进入/退出批量模式设计 2026-04-21 13:14:50 +08:00
DXC
a6409ac091 feat(outbound): 添加批量移除和一键清空功能 2026-04-21 13:11:29 +08:00
DXC
eba558c9d9 feat(outbound): 优化按 BOM 添加逻辑,支持缺件时按现有库存部分出库 2026-04-21 13:06:03 +08:00
DXC
a5a35777b5 fix(inventory): 修复 BOM 齐套分析时 allStockData 未加载导致可用库存为 0 2026-04-20 18:44:35 +08:00
49 changed files with 4085 additions and 397 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
@ -20,6 +21,17 @@ def create_app():
# 允许所有 /api/ 开头的请求跨域,支持 credentials
cors.init_app(app, resources={r"/*": {"origins": "*"}}, supports_credentials=True)
# =========================================================
# 1.1 注册全局审计日志监听器
# =========================================================
with app.app_context():
try:
from app.utils.audit_events import register_audit_events
register_audit_events(db)
print("✅ 审计事件监听器注册成功")
except Exception as e:
print(f"⚠️ 审计事件监听器注册失败: {e}")
# =========================================================
# 2. 注册蓝图 (Blueprints)
# ---------------------------------------------------------
@ -189,6 +201,28 @@ def create_app():
except ImportError as e:
print(f"❌ 错误: Warehouse 模块导入失败: {e}")
# -----------------------------------------------------
# 2.12 注册通用聚合搜索模块 (Common - Global Search)
# -----------------------------------------------------
try:
from app.api.v1.common import common_bp
# 标准: /api/v1/common/global-search
app.register_blueprint(common_bp, url_prefix='/api/v1/common')
# 兼容: /api/common/global-search
app.register_blueprint(common_bp, url_prefix='/api/common', name='common_legacy')
print("✅ Common 模块注册成功")
except ImportError as e:
print(f"❌ 错误: Common 模块导入失败: {e}")
# -----------------------------------------------------
# 2.13 注册扫码查库存模块 (Scan)
# -----------------------------------------------------
try:
app.register_blueprint(scan_bp, url_prefix='/api/v1/scan')
print("✅ Scan 模块注册成功")
except Exception as e:
print(f"❌ 错误: Scan 模块注册失败: {e}")
# =========================================================
# 3. 预加载数据模型
# =========================================================
@ -216,4 +250,4 @@ def create_app():
except Exception as e:
print(f"⚠️ 模型预加载发生未知错误: {e}")
return app
return app

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

View File

@ -214,12 +214,16 @@ def delete_bom(bom_no):
if version:
query = query.filter_by(version=version)
exist = query.first()
if not exist:
# 【核心修复】:使用 .all() 查出该 BOM 版本下的所有子件记录
records = query.all()
if not records:
return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404
# 删除(改为对象级删除以触发审计事件
db.session.delete(exist)
# 循环删除所有关联记录(逐个 delete 可触发 SQLAlchemy 监听器记录审计日志
for rec in records:
db.session.delete(rec)
db.session.commit()
return jsonify({
'code': 200,

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

@ -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)
@ -216,42 +242,71 @@ def get_stock_list():
except Exception:
pass
# 3. 成品
# 3. 成品
if StockProduct:
try:
q = StockProduct.query.filter(StockProduct.stock_quantity > 0)
if keyword:
q = q.filter(
db.or_(
StockProduct.product_name.ilike(f'%{keyword}%'),
StockProduct.spec_model.ilike(f'%{keyword}%'),
StockProduct.sku.ilike(f'%{keyword}%')
)
q = StockProduct.query.filter(StockProduct.stock_quantity > 0)
if keyword:
q = q.filter(
db.or_(
StockProduct.base.has(MaterialBase.name.ilike(f'%{keyword}%')),
StockProduct.base.has(MaterialBase.spec_model.ilike(f'%{keyword}%')),
StockProduct.sku.ilike(f'%{keyword}%'),
StockProduct.barcode.ilike(f'%{keyword}%'),
StockProduct.serial_number.ilike(f'%{keyword}%')
)
rows = q.all()
for item in rows:
d = item.to_dict()
d['stock_type'] = 'product'
d['type'] = 'product'
d['typeLabel'] = '成品'
d['name'] = d.get('product_name', d.get('name', ''))
d['standard'] = d.get('spec_model', d.get('standard', ''))
d['available_quantity'] = d.get('qty_available', d.get('available_quantity', 0))
all_items.append(d)
except Exception:
pass
)
rows = q.all()
for item in rows:
d = item.to_dict()
d['stock_type'] = 'product'
d['type'] = 'product'
d['typeLabel'] = '成品'
d['name'] = d.get('material_name', d.get('name', ''))
d['standard'] = d.get('spec_model', d.get('standard', ''))
d['available_quantity'] = d.get('qty_available', d.get('available_quantity', 0))
all_items.append(d)
# ── 按规格+库位聚合(出库选单合并同类项)───────────────────────
is_aggregated = request.args.get('is_aggregated', 'false').lower() == 'true'
if is_aggregated:
grouped_dict = {}
for item in all_items:
# 核心聚合键:类型 + 规格型号 + 库位
group_key = f"{item.get('type')}_{item.get('standard')}_{item.get('warehouse_location', '')}"
if group_key in grouped_dict:
# 累加数量
existing = grouped_dict[group_key]
existing['available_quantity'] = float(existing.get('available_quantity', 0)) + float(item.get('available_quantity', 0))
existing['stock_quantity'] = float(existing.get('stock_quantity', 0)) + float(item.get('stock_quantity', 0))
# 保留 id 列表(出库提交时需用到)
existing_ids = existing.get('_ids', [])
existing_ids.append(item.get('id'))
existing['_ids'] = existing_ids
else:
# 存入代表项
grouped_dict[group_key] = item.copy()
# 强制统一数据类型以便前端处理
grouped_dict[group_key]['available_quantity'] = float(item.get('available_quantity', 0))
grouped_dict[group_key]['stock_quantity'] = float(item.get('stock_quantity', 0))
grouped_dict[group_key]['_ids'] = [item.get('id')]
# 替换原列表为聚合后的列表
all_items = list(grouped_dict.values())
# ── 手动切片分页 ────────────────────────────────────────────
total = len(all_items)
start = (page - 1) * pageSize
end = start + pageSize
end = start + pageSize
paged = all_items[start:end]
return jsonify({
'msg': '获取成功',
'data': {
'list': paged,
'total': total,
'page': page,
'list': paged,
'total': total,
'page': page,
'pageSize': pageSize
}
}), 200
@ -325,11 +380,22 @@ def get_drafts():
total = len(items)
start = (page - 1) * limit
end = start + limit
# 计算真实的去重"已盘数量"
counted_items_set = set()
for draft_item in items:
# 兼容判断 quantity 或 qty_actual
if draft_item.get('quantity') is not None or draft_item.get('qty_actual') is not None:
unique_key = f"{draft_item.get('source_table', '')}_{draft_item.get('stock_id', '')}"
counted_items_set.add(unique_key)
total_scanned_unique = len(counted_items_set)
paginated_items = items[start:end]
return jsonify({
'items': paginated_items,
'total': total,
'total_scanned': total_scanned_unique,
'page': page,
'limit': limit
}), 200
@ -350,6 +416,7 @@ def add_draft():
data = request.json
user_id = _normalize_user_id()
uuid = data.get('uuid')
print(f"🚀 [SCAN DEBUG] 后端实际接收到的 UUID 原文: |{uuid}| (长度: {len(str(uuid)) if uuid else 0})")
quantity = float(data.get('quantity', 1))
session_id = data.get('session_id')
# ★ 新增: 提取备注字段
@ -796,8 +863,8 @@ def export_stocktake():
user = SysUser.query.get(int(user_id))
if not user:
user = SysUser.query.filter(SysUser.username.like(f"%/{user_id}")).first()
if not user:
user = SysUser.query.filter_by(username=str(user_id)).first()
# 注意:此处不再 fallback filter_by(username=...)
# 避免 PostgreSQL 将 user_id 数字与 username 字符串列做类型比较导致报错
if not user:
return str(user_id)

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,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

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

View File

@ -14,6 +14,6 @@ except ImportError:
# 4. 出库记录 (如果有BuyService 用到了 TransOutbound)
try:
from app.models.outbound import TransOutbound
from app.models.outbound import TransOutbound, OutboundApproval
except ImportError:
pass

View File

@ -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

@ -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

@ -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):

View File

@ -194,6 +194,9 @@ class BomService:
for rec in old_records:
db.session.delete(rec)
# 【核心修复】:强制立即执行 DELETE 语句,为后续的 INSERT 腾出唯一键空间
db.session.flush()
for child in children:
bom = BomTable(
bom_no=bom_no,

View File

@ -375,34 +375,29 @@ class MaterialBaseService:
if enable_warning_sort:
print("====== [DEBUG] 成功进入预警强排逻辑 ======")
# 直接在 order_by 中进行计算排序,不污染 select 列
inv_val = inner_sub.c.total_inv
red_val = cast(MaterialWarningSetting.red_threshold, Numeric)
yellow_val = cast(MaterialWarningSetting.yellow_threshold, Numeric)
# 预警等级计算:红=2, 黄=1, 正常=0
warning_level = case(
(and_(MaterialWarningSetting.is_enabled.is_(True), inv_val <= red_val), 2),
(and_(MaterialWarningSetting.is_enabled.is_(True), inv_val <= yellow_val), 1),
(and_(MaterialWarningSetting.is_enabled.is_(True), red_val.isnot(None), inv_val <= red_val), 2),
(and_(MaterialWarningSetting.is_enabled.is_(True), yellow_val.isnot(None), inv_val <= yellow_val), 1),
else_=0
)
# 统一计算缺口 (Shortage) = 目标阈值 - 当前库存
# 红色算红色的缺口,黄色算黄色的缺口,越大说明缺的越多
shortage = case(
(and_(MaterialWarningSetting.is_enabled.is_(True), red_val.isnot(None), inv_val <= red_val), red_val - inv_val),
(and_(MaterialWarningSetting.is_enabled.is_(True), yellow_val.isnot(None), inv_val <= yellow_val), yellow_val - inv_val),
else_=0
)
# 红色预警时的缺口
red_shortage = case(
(and_(MaterialWarningSetting.is_enabled.is_(True), inv_val <= red_val), red_val - inv_val),
else_=0
)
# 黄色预警时的缺口
yellow_distance = case(
(and_(MaterialWarningSetting.is_enabled.is_(True), inv_val > red_val, inv_val <= yellow_val), inv_val - red_val),
else_=999999
)
# 直接在 order_by 中使用 case() 表达式
query = query.order_by(
desc(warning_level),
desc(red_shortage),
asc(yellow_distance),
desc(inv_val),
desc(warning_level), # 1. 先按红、黄、正常排
desc(shortage), # 2. 同级别内,缺口越大的排越上面
desc(inv_val), # 3. 缺口一样,库存多的排上面
desc(MaterialBase.id)
)
elif order_by_column:
@ -462,12 +457,18 @@ class MaterialBaseService:
item_dict['warningRed'] = float(warning_red) if warning_red is not None else None
# 计算预警状态
if warning_enabled and warning_red is not None:
if warning_enabled:
invQty = item_dict['inventoryCount']
if invQty <= warning_red:
# 优先判断红色预警(如果设置了红阈值,且库存 <= 红阈值)
if warning_red is not None and invQty <= warning_red:
item_dict['warningStatus'] = 2 # 红色
# 其次判断黄色预警(如果设置了黄阈值,且库存 <= 黄阈值)
elif warning_yellow is not None and invQty <= warning_yellow:
item_dict['warningStatus'] = 1 # 黄色
# 都不满足则正常
else:
item_dict['warningStatus'] = 0 # 正常
else:

View File

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

View File

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

View File

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

View File

@ -0,0 +1,323 @@
# inventory-backend/app/utils/audit_events.py
"""
全局无侵入的审计日志拦截器
监听所有模型的增删改操作,自动提取旧值和新值存入 audit_logs 表
完美对接前端 AuditLog.vue 的解析逻辑 (changes, deleted_snapshot, created)
"""
import json
from datetime import datetime, date
from decimal import Decimal
from flask import request, has_request_context
from sqlalchemy import event, text
class AuditJSONEncoder(json.JSONEncoder):
"""JSON 序列化增强器,支持 datetime/Decimal 等特殊类型"""
def default(self, obj):
if isinstance(obj, (datetime, date)):
return obj.isoformat()
if isinstance(obj, Decimal):
return float(obj)
return str(obj)
def model_to_dict(obj):
"""将 SQLAlchemy 模型实例转换为字典"""
return {c.name: getattr(obj, c.name) for c in obj.__table__.columns}
def get_current_user_info():
"""
从当前 HTTP 请求上下文中提取用户信息
兼容 JWT 和匿名访问
"""
user_info = {
'user_id': 'system',
'username': 'system',
'display_name': 'System',
'ip_address': '127.0.0.1',
'method': 'SYSTEM',
'url': ''
}
if has_request_context():
# 获取 IP 地址
user_info['ip_address'] = request.headers.get('X-Forwarded-For', '') or request.remote_addr or '127.0.0.1'
if ',' in user_info['ip_address']:
user_info['ip_address'] = user_info['ip_address'].split(',')[0].strip()
user_info['method'] = request.method
user_info['url'] = request.path
# 尝试从 JWT 获取用户信息
try:
from flask_jwt_extended import get_jwt_identity, get_jwt
user_id = get_jwt_identity()
claims = get_jwt()
if user_id:
user_info['user_id'] = str(user_id)
if claims:
user_info['username'] = claims.get('username', 'unknown')
user_info['display_name'] = claims.get('display_name', claims.get('username', 'Unknown'))
except Exception:
pass
return user_info
def serialize_value(value):
"""序列化单个值,确保 JSON 兼容"""
if value is None:
return None
if isinstance(value, (datetime, date)):
return value.strftime('%Y-%m-%d %H:%M:%S')
if isinstance(value, Decimal):
return float(value)
if isinstance(value, (bytes, bytearray)):
try:
return value.decode('utf-8')
except Exception:
return '[二进制数据]'
return value
# 需要忽略的审计字段(时间戳等自动维护字段)
IGNORE_FIELDS = {
'updated_at', 'update_time', 'modified_time', 'last_modified',
'created_at', 'create_time', 'created_on', 'version',
}
# 审计日志表名
AUDIT_TABLE = 'audit_logs'
# 不需要审计的表
IGNORE_TABLES = {'audit_logs', 'sys_log', 'syslog', 'alembic_version'}
def insert_audit_log(connection, action, target, details):
"""
使用 connection.execute 直接插入审计日志
避免干扰当前 session 事务,自动随主事务一起提交/回滚
"""
tablename = target.__tablename__
# 严禁监听日志表本身,防止无限递归
if tablename in IGNORE_TABLES:
return
# 获取目标 ID
target_id = ''
if hasattr(target, 'id'):
target_id = str(target.id)
elif hasattr(target, 'stock_id'):
target_id = str(target.stock_id)
elif hasattr(target, 'uuid'):
target_id = str(target.uuid)
elif hasattr(target, 'bom_no'):
target_id = str(target.bom_no)
# 获取目标名称(用于展示)
target_name = ''
for name_field in ['name', 'title', 'material_name', 'product_name', 'display_name', 'username']:
if hasattr(target, name_field):
val = getattr(target, name_field)
if val:
target_name = str(val)
break
# 如果当前表没名字,但它有关联的物料对象 (比如 material.name)
if not target_name and hasattr(target, 'material') and target.material:
target_name = getattr(target.material, 'name', '')
# 如果当前表有 material_id尝试从关联的 material 表查询名称
if not target_name and hasattr(target, 'material_id') and target.material_id:
try:
# 使用 connection 查询物料表获取名称
result = connection.execute(
text("SELECT name FROM material_base WHERE id = :id"),
{'id': target.material_id}
).fetchone()
if result:
target_name = str(result[0])
except Exception:
pass
# 如果实在找不到名字,再用 表名 + ID 兜底
if not target_name:
target_name = f"{tablename} ID:{target_id}"
user_info = get_current_user_info()
# 推断模块名称
module = _infer_module_name(tablename, target)
# 使用原始 SQL 插入,确保事务一致性
sql = text("""
INSERT INTO audit_logs
(user_id, username, display_name, action, module, target_id, target_name, details, ip_address, method, url, created_at)
VALUES
(:user_id, :username, :display_name, :action, :module, :target_id, :target_name, :details, :ip_address, :method, :url, :created_at)
""")
connection.execute(sql, {
'user_id': user_info['user_id'],
'username': user_info['username'],
'display_name': user_info['display_name'],
'action': action,
'module': module,
'target_id': target_id,
'target_name': target_name,
'details': json.dumps(details, cls=AuditJSONEncoder),
'ip_address': user_info['ip_address'],
'method': user_info['method'],
'url': user_info['url'],
'created_at': datetime.now()
})
def _infer_module_name(tablename, target):
"""根据表名或模型类推断所属模块"""
class_name = target.__class__.__name__
if any(kw in class_name for kw in ['Stock', 'Buy', 'Inbound']):
return '入库管理'
if any(kw in class_name for kw in ['Outbound']):
return '出库管理'
if any(kw in class_name for kw in ['Borrow', 'Return']):
return '借还管理'
if any(kw in class_name for kw in ['Repair']):
return '维修管理'
if any(kw in class_name for kw in ['Scrap']):
return '报废管理'
if any(kw in class_name for kw in ['Bom', 'BOM']):
return 'BOM管理'
if any(kw in class_name for kw in ['StockTake', 'StockAdjust', 'Adjustment']):
return '盘点管理'
if any(kw in class_name for kw in ['Material', 'Base']):
return '基础数据'
if any(kw in class_name for kw in ['SysUser', 'SysMenu', 'SysRole', 'SysPermission']):
return '系统管理'
if any(kw in class_name for kw in ['Warehouse', 'Location']):
return '库位管理'
return tablename or '未知模块'
def _has_changes(history):
"""检查历史记录对象是否有变更"""
return history.has_changes()
def register_audit_events(db):
"""
全局注册审计事件监听器
监听所有模型的 INSERT/UPDATE/DELETE 事件
"""
from sqlalchemy import inspect
@event.listens_for(db.Model, 'before_update', propagate=True)
def before_update_listener(mapper, connection, target):
"""UPDATE 事件:抓取字段变更明细"""
if target.__tablename__ in IGNORE_TABLES:
return
try:
state = inspect(target)
changes = {}
for attr in state.attrs:
prop = attr.key
# 跳过忽略字段
if prop in IGNORE_FIELDS:
continue
# 跳过关系属性
if hasattr(attr, 'property') and hasattr(attr.property, 'direction'):
continue
if _has_changes(attr.history):
old_value = attr.history.deleted[0] if attr.history.deleted else None
new_value = attr.history.added[0] if attr.history.added else None
# 序列化值
old_serialized = serialize_value(old_value)
new_serialized = serialize_value(new_value)
# 只记录真正变化的字段
if old_serialized != new_serialized:
changes[prop] = {
'old': old_serialized,
'new': new_serialized
}
if changes:
insert_audit_log(connection, 'UPDATE', target, {'changes': changes})
except Exception as e:
import logging
logging.error(f"Audit Update Error: {e}")
@event.listens_for(db.Model, 'before_delete', propagate=True)
def before_delete_listener(mapper, connection, target):
"""DELETE 事件:抓取被删除对象的完整快照"""
if target.__tablename__ in IGNORE_TABLES:
return
try:
state = inspect(target)
snapshot = {}
for attr in state.attrs:
prop = attr.key
# 跳过忽略字段
if prop in IGNORE_FIELDS:
continue
# 跳过关系属性
if hasattr(attr, 'property') and hasattr(attr.property, 'direction'):
continue
value = getattr(target, prop, None)
snapshot[prop] = serialize_value(value)
insert_audit_log(connection, 'DELETE', target, {'deleted_snapshot': snapshot})
except Exception as e:
import logging
logging.error(f"Audit Delete Error: {e}")
@event.listens_for(db.Model, 'after_insert', propagate=True)
def after_insert_listener(mapper, connection, target):
"""INSERT 事件:抓取新增对象的完整快照"""
if target.__tablename__ in IGNORE_TABLES:
return
try:
state = inspect(target)
snapshot = {}
for attr in state.attrs:
prop = attr.key
# 跳过忽略字段
if prop in IGNORE_FIELDS:
continue
# 跳过关系属性
if hasattr(attr, 'property') and hasattr(attr.property, 'direction'):
continue
value = getattr(target, prop, None)
snapshot[prop] = serialize_value(value)
insert_audit_log(connection, 'CREATE', target, {'created': snapshot})
except Exception as e:
import logging
logging.error(f"Audit Insert Error: {e}")
# 返回注册成功信息
return True

View File

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

View File

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

View File

@ -234,7 +234,7 @@ const handleLogout = () => {
<footer v-if="!isLoginPage" class="app-footer">
<span class="version-tag">
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
当前版本:V3.11(4.8部署
当前版本:V3.16(4.29部署
</span>
</footer>

View File

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

View File

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

View File

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

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']
}
}
]
},

View File

@ -83,13 +83,26 @@
</div>
</el-option>
</el-select>
<el-link
v-if="form.parent_id"
type="primary"
:underline="false"
style="margin-left: 12px; font-size: 13px;"
@click="openParentMaterial"
>
<el-icon style="margin-right: 4px"><EditPen /></el-icon>前往修改基础信息
</el-link>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="是否启用" prop="is_enabled" v-if="hasFormFieldPermission('is_enabled')">
<el-switch v-model="form.is_enabled" active-text="启用" inactive-text="禁用" :disabled="!userStore.hasPermission('bom_manage:operation')" />
</el-form-item>
</el-col>
<el-col :span="16"></el-col>
</el-row>
<el-row :gutter="20">
@ -135,34 +148,45 @@
/>
<el-table :data="filteredChildren" border style="width: 100%; margin-bottom: 15px" max-height="300">
<el-table-column label="子件物料" min-width="280" v-if="hasFormFieldPermission('child_id')">
<el-table-column label="子件物料" min-width="250" v-if="hasFormFieldPermission('child_id')">
<template #default="{ row, $index }">
<!-- ====== 改造:子件下拉 - 远程搜索 + 懒加载 ====== -->
<el-select
v-model="row.child_id"
placeholder="请搜索原料"
filterable
remote
reserve-keyword
:remote-method="(q: string) => handleRemoteSearch(q, 'child', $index)"
:loading="selectLoading"
style="width: 100%"
:loading-text="`正在加载第 ${childQueryParams.page} 页...`"
:popper-class="`bom-loadmore-popper child-popper-${$index}`"
@visible-change="(visible: boolean) => handleVisibleChange(visible, 'child', $index)"
>
<el-option
v-for="item in getChildOptions($index)"
:key="item.id"
:label="`${item.name} (${item.spec})`"
:value="item.id"
<div style="display: flex; align-items: center; gap: 8px;">
<el-select
v-model="row.child_id"
placeholder="请搜索原料"
filterable
remote
reserve-keyword
style="flex: 1;"
:remote-method="(q: string) => handleRemoteSearch(q, 'child', $index)"
:loading="selectLoading"
:loading-text="`正在加载第 ${childQueryParams.page} 页...`"
:popper-class="`bom-loadmore-popper child-popper-${$index}`"
@visible-change="(visible: boolean) => handleVisibleChange(visible, 'child', $index)"
>
<div class="option-row">
<span class="option-name">{{ item.name }}</span>
<span class="option-spec">{{ item.spec }}</span>
</div>
</el-option>
</el-select>
<el-option
v-for="item in getChildOptions($index)"
:key="item.id"
:label="`${item.name} (${item.spec})`"
:value="item.id"
>
<div class="option-row">
<span class="option-name">{{ item.name }}</span>
<span class="option-spec">{{ item.spec }}</span>
</div>
</el-option>
</el-select>
<el-tooltip content="前往修改基础信息" placement="top" v-if="row.child_id">
<el-button
type="primary"
link
:icon="EditPen"
@click.stop="openMaterialInNewTab(row.child_id, getChildSpec($index))"
style="font-size: 16px; padding: 4px;"
/>
</el-tooltip>
</div>
</template>
</el-table-column>
@ -202,8 +226,10 @@
<script setup lang="ts">
import { ref, reactive, onMounted, computed, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus'
import { Plus, Search } from '@element-plus/icons-vue'
import { Plus, Search, EditPen } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import { getBomList, getBomDetail, saveBom, deleteBom } from '@/api/bom'
import { getMaterialBaseList } from '@/api/inbound/stock'
import { useUserStore } from '@/stores/user'
@ -231,6 +257,8 @@ interface ChildRow {
}
const userStore = useUserStore()
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const dialogVisible = ref(false)
const saving = ref(false)
@ -465,6 +493,31 @@ const filteredChildren = computed(() => {
})
})
// 获取子件规格(从 childDropdownStates 缓存中查找)
const getChildSpec = (index: number): string => {
const state = childDropdownStates.value.get(index)
if (!state || !form.children[index]?.child_id) return ''
const material = state.options.find((m: MaterialBase) => m.id === form.children[index].child_id)
return material?.spec || ''
}
// 在新标签页打开基础信息编辑
const openMaterialInNewTab = (targetId: number | null, keyword: string = '') => {
if (!targetId) return ElMessage.warning('请先选择物料')
const routeUrl = router.resolve({
path: '/material',
query: { edit_id: targetId, keyword }
})
window.open(routeUrl.href, '_blank')
}
const openParentMaterial = () => {
if (!form.parent_id) return ElMessage.warning('请先选择父件')
const parent = parentOptions.value.find((p: MaterialBase) => p.id === form.parent_id)
const keyword = parent?.spec || parent?.name || ''
openMaterialInNewTab(form.parent_id, keyword)
}
// 列与权限Code的映射关系数据库中的code
const permissionMap: Record<string, string> = {
bom_no: 'bom_manage:bom_no',
@ -746,6 +799,55 @@ const submitForm = async () => {
onMounted(() => {
fetchBomList()
// 【新增】:处理外部跳转自动打开 BOM带查重保护
if (route.query.create_for_id) {
const parentId = Number(route.query.create_for_id);
const parentName = (route.query.parent_name as string) || '';
const parentSpec = (route.query.parent_spec as string) || '';
// 把名称填入背景搜索框让背后的表格也只显示相关的BOM
searchKeyword.value = parentName;
// 延迟等待基础渲染
setTimeout(() => {
// 1. 先用 keyword 查询是否已有该父件的 BOM
getBomList({ keyword: parentName }).then((res: any) => {
const rows = res.data || [];
// 严格校验 parent_id
const existingBom = rows.find((b: any) => b.parent_id === parentId);
if (existingBom) {
// ★ 情况 A已经有BOM了直接打开编辑弹窗并拉取历史数据
ElMessage.success('检测到该物料已有 BOM已自动为您打开编辑');
handleEdit(existingBom);
} else {
// ★ 情况 B还没建过BOM打开新建并注入父件
handleCreate();
// 强行注入父件远程搜索选项
parentOptions.value = [{
id: parentId,
name: parentName,
spec: parentSpec
}];
// 给表单赋值
form.parent_id = parentId;
// 触发联动逻辑(自动带出版本和生成编号)
if (typeof onParentChange === 'function') {
setTimeout(() => {
onParentChange(parentId);
}, 100);
}
}
}).catch(err => {
console.error('BOM 查重失败', err);
ElMessage.error('获取 BOM 状态失败,请手动操作');
});
}, 300);
}
})
</script>

View File

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

View File

@ -254,23 +254,28 @@
<span v-if="getImagesOnly(row.generalImage).length > 1" class="more-badge">+{{getImagesOnly(row.generalImage).length}}</span>
</div>
<el-popover v-if="row.generalManual && row.generalManual.length > 0" placement="top" trigger="hover" width="200">
<el-popover v-if="row.generalManual && row.generalManual.length > 0" placement="top" trigger="hover" width="260">
<template #reference>
<el-button link type="primary" :icon="Document" />
<el-button link type="primary" :icon="row.generalManual.some(l => !isExternalLink(l) && !isImageFile(l)) ? Files : Document" />
</template>
<div style="display: flex; flex-direction: column; gap: 5px;">
<div v-for="(link, idx) in row.generalManual" :key="idx">
<el-link v-if="isExternalLink(link)" :href="link" target="_blank" type="primary" :underline="false">
说明书 {{idx+1}} <el-icon><Link /></el-icon>
</el-link>
<el-image v-else-if="isImageFile(link)"
style="width: 100px; height: 100px"
:src="getImageUrl(link)"
:preview-src-list="[getImageUrl(link)]"
fit="cover"
preview-teleported
<!-- 图片文件 -->
<div v-for="(link, idx) in row.generalManual.filter(l => !isExternalLink(l) && isImageFile(l))" :key="'img-' + idx">
<el-image
style="width: 80px; height: 80px; cursor: pointer;"
:src="getImageUrl(link)"
:preview-src-list="row.generalManual.filter(l => !isExternalLink(l) && isImageFile(l)).map(u => getImageUrl(u))"
fit="cover"
preview-teleported
/>
<el-link v-else :href="getImageUrl(link)" target="_blank" type="info">PDF 文件 {{idx+1}}</el-link>
<span style="font-size: 12px; color: #999;">图片 {{idx+1}}</span>
</div>
<!-- 非图片文件 -->
<div v-for="(link, idx) in row.generalManual.filter(l => !isExternalLink(l) && !isImageFile(l))" :key="'file-' + idx">
<el-link @click.prevent="handleDownloadConfirm(link)" type="info" :underline="false">
<el-icon><Files /></el-icon>
{{ link.split('/').pop() }}
</el-link>
</div>
</div>
</el-popover>
@ -297,10 +302,14 @@
</el-tag>
</template>
</el-table-column>
<el-table-column v-if="userStore.hasPermission('material_list:operation')" label="操作" min-width="200" fixed="right" align="center">
<el-table-column v-if="userStore.hasPermission('material_list:operation')" label="操作" width="280" fixed="right" align="center">
<template #default="scope">
<el-button v-if="userStore.hasPermission('material_list:operation')" link type="primary" size="small" @click="handleEdit(scope.row)">编辑</el-button>
<el-button v-if="userStore.hasPermission('material_list:edit_warning')" link type="warning" size="small" @click="handleSetSingleWarning(scope.row)">设置预警</el-button>
<template v-if="userStore.hasPermission('material_list:edit_warning') && scope.row.warningStatus > 0">
<el-button v-if="scope.row.warningOrdered" disabled size="small" type="info">采购在途</el-button>
<el-button v-else link type="success" size="small" @click="handleMarkOrdered(scope.row)">标记已采购</el-button>
</template>
<el-button v-if="userStore.hasPermission('material_list:operation')" link type="danger" size="small" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
@ -321,8 +330,7 @@
<el-dialog
v-model="dialog.visible"
:title="dialog.title"
width="700px"
width="1200px"
append-to-body
destroy-on-close
@close="cancel"
@ -330,6 +338,20 @@
:close-on-press-escape="!isUploading"
:show-close="!isUploading"
>
<template #header>
<div style="display: flex; align-items: center; justify-content: space-between; padding-right: 20px;">
<span style="font-size: 18px; font-weight: 500;">{{ dialog.title }}</span>
<el-link
v-if="form.id"
type="success"
:underline="false"
style="font-size: 14px;"
@click="createBomForMaterial"
>
<el-icon style="margin-right: 4px"><Plus /></el-icon>加入或查看BOM
</el-link>
</div>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
<el-row>
@ -361,6 +383,7 @@
<el-form-item label="类别" prop="category" v-if="hasFieldPermission('category')">
<div style="display: flex; width: 100%; align-items: center;">
<el-cascader
ref="categoryCascaderRef"
v-model="tempCategoryPrefix"
:options="categoryTreeOptions"
:props="{ expandTrigger: 'hover', checkStrictly: true, emitPath: true }"
@ -368,6 +391,7 @@
filterable
clearable
style="width: 50%;"
@change="onCategoryChange"
/>
<div style="padding: 0 8px; font-weight: bold; color: #909399;">/</div>
<el-input
@ -377,9 +401,7 @@
style="width: 50%;"
/>
</div>
<div style="font-size: 12px; color: #E6A23C; margin-top: 4px; line-height: 1.2;">
* 必须构成4层结构
</div>
</el-form-item>
</el-col>
</el-row>
@ -457,7 +479,32 @@
:on-remove="(file) => handleRemoveImage(file, 'generalManual')"
:before-upload="beforeAvatarUpload"
>
<el-icon><Plus /></el-icon>
<template #default>
<div v-if="!fileListManual.length" class="upload-add-trigger">
<el-icon><Plus /></el-icon>
</div>
</template>
<template #file="{ file }">
<div class="upload-file-item">
<template v-if="isImageFile(file.url)">
<img class="el-upload-list__item-thumbnail" :src="file.url" alt="" />
</template>
<template v-else>
<div class="file-thumbnail">
<el-icon size="28"><Document /></el-icon>
<span class="file-name">{{ truncateFileName(file.name) }}</span>
</div>
</template>
<span class="el-upload-list__item-actions">
<span class="el-upload-list__item-preview" @click="handlePreviewPicture(file)">
<el-icon><ZoomIn /></el-icon>
</span>
<span class="el-upload-list__item-delete" @click="() => handleRemoveImage(file, 'generalManual')">
<el-icon><Delete /></el-icon>
</span>
</span>
</div>
</template>
</el-upload>
<div class="camera-card" @click="triggerCamera('generalManual')">
<el-icon><Camera /></el-icon><span class="text">拍照</span>
@ -517,10 +564,16 @@
<el-input-number v-model="warningForm.redThreshold" :min="0" :precision="0" step="1" placeholder="库存此值为红色预警" style="width: 100%" />
<div class="form-tip">库存数量 ≤ 此值时显示红色预警</div>
</el-form-item>
<el-form-item label="红色预警邮箱" v-if="warningForm.isEnabled">
<el-input v-model="warningForm.redEmails" placeholder="逗号分隔多个邮箱" clearable />
</el-form-item>
<el-form-item label="黄色阈值" prop="yellowThreshold" v-if="warningForm.isEnabled">
<el-input-number v-model="warningForm.yellowThreshold" :min="0" :precision="0" step="1" placeholder="库存此值为黄色预警" style="width: 100%" />
<div class="form-tip">红色阈值 &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">
@ -563,10 +616,13 @@
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick, computed } from 'vue';
import { Plus, Document, Refresh, Setting, Rank, Camera, Link, Download, Bell, CircleCheck } from '@element-plus/icons-vue';
import { Plus, Document, Refresh, Setting, Rank, Camera, Link, Download, Bell, CircleCheck, Files, ZoomIn, Delete } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus';
import type { FormInstance, FormRules } from 'element-plus';
import { useUserStore } from '@/stores/user';
import { useRoute, useRouter } from 'vue-router';
const route = useRoute();
const router = useRouter();
import {
listMaterialBase,
@ -576,7 +632,8 @@ import {
getMaterialBaseOptions,
exportAssetStatistics,
batchSetWarning,
batchSetInspection
batchSetInspection,
markWarningOrdered
} from '@/api/material_base';
import { uploadFile, deleteFile } from '@/api/common/upload';
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue';
@ -600,6 +657,8 @@ interface MaterialBaseVO {
statusLoading?: boolean;
inventoryCount?: number;
availableCount?: number;
warningStatus?: number;
warningOrdered?: boolean;
}
interface QueryParams {
@ -673,6 +732,9 @@ const cameraDialogVisible = ref(false);
const cameraRef = ref<InstanceType<typeof WebRtcCamera> | null>(null);
const currentCameraField = ref<'generalImage' | 'generalManual'>('generalImage');
// 脏检查 - 记录编辑前的原始数据
const originalForm = ref<any>(null);
// 复选框选中数据
const selectedItems = ref<MaterialBaseVO[]>([]);
const handleSelectionChange = (selection: MaterialBaseVO[]) => {
@ -742,7 +804,9 @@ const warningLoading = ref(false);
const warningForm = reactive({
isEnabled: false,
redThreshold: undefined as number | undefined,
yellowThreshold: undefined as number | undefined
yellowThreshold: undefined as number | undefined,
redEmails: '',
yellowEmails: ''
});
const warningRules = {
yellowThreshold: [
@ -842,6 +906,16 @@ const categoryOptions = ref<string[]>([]);
const typeOptions = ref<string[]>([]);
const categoryTreeOptions = ref<CascaderOption[]>([]);
// 类别级联选择器的 ref
const categoryCascaderRef = ref<any>(null);
// 选中类别后自动收起下拉面板
const onCategoryChange = () => {
if (categoryCascaderRef.value) {
categoryCascaderRef.value.togglePopperVisible(false);
}
};
const tempCategoryPrefix = ref<string[]>([]);
const tempCategorySuffix = ref<string>('');
@ -891,19 +965,8 @@ const validateCategoryLevel = (rule: any, value: any, callback: any) => {
if (!prefixStr && !suffixStr) {
callback(new Error('请填写或选择类别'));
return;
}
let fullPath = '';
if (prefixStr && suffixStr) fullPath = prefixStr + '/' + suffixStr;
else if (prefixStr) fullPath = prefixStr;
else fullPath = suffixStr;
const levels = fullPath.split('/').filter(p => p.trim() !== '').length;
if (levels !== 4) {
callback(new Error(`必须严格满足4层结构当前为 ${levels} 层`));
} else {
// 只要有前缀或后缀就直接放行不再限制必须是4层
callback();
}
};
@ -1116,6 +1179,9 @@ const handleEdit = (row: MaterialBaseVO) => {
const data = JSON.parse(JSON.stringify(row));
Object.assign(form.value, data);
// 深拷贝保存原始数据用于脏检查
originalForm.value = JSON.parse(JSON.stringify(data));
if (data.category) {
const parts = data.category.split('/');
if (parts.length > 0) {
@ -1164,6 +1230,33 @@ const checkDuplicate = async (name: string, spec: string): Promise<boolean> => {
return false;
};
const isArraysEqual = (a: any[], b: any[]): boolean => {
if (a.length !== b.length) return false;
const sortedA = [...a].sort();
const sortedB = [...b].sort();
return sortedA.every((val, idx) => val === sortedB[idx]);
};
const buildPartialPayload = (current: any, original: any): any => {
const payload: any = { id: current.id };
const compareFields = ['name', 'commonName', 'category', 'type', 'spec', 'unit', 'visibilityLevel', 'isEnabled', 'isInspectionRequired', 'generalImage', 'generalManual', 'companyName'];
for (const key of compareFields) {
const currentVal = current[key];
const originalVal = original[key];
// 处理数组比较generalImage, generalManual
if (Array.isArray(currentVal) && Array.isArray(originalVal)) {
if (!isArraysEqual(currentVal, originalVal)) {
payload[key] = currentVal;
}
} else if (currentVal !== originalVal) {
payload[key] = currentVal;
}
}
return payload;
};
const submitForm = async () => {
if (!formRef.value) return;
@ -1189,19 +1282,44 @@ const submitForm = async () => {
if (prefixStr && suffixStr) fullCategory = prefixStr + '/' + suffixStr;
else fullCategory = prefixStr || suffixStr;
const payload = {
// 构建最终表单数据
const finalForm = {
...form.value,
category: fullCategory,
generalImage: finalImageList,
generalManual: finalManualList
};
// 脏检查:只提交变更的字段
let payload: any;
if (form.value.id && originalForm.value) {
// 编辑模式:生成部分更新 payload
payload = buildPartialPayload(finalForm, originalForm.value);
// 如果分类被修改,需要确保包含在 payload 中
if (payload.category === undefined && fullCategory !== originalForm.value.category) {
payload.category = fullCategory;
}
} else {
// 新增模式:提交完整数据
payload = finalForm;
}
// 如果没有变更,提示用户
const changedKeys = Object.keys(payload).filter(k => k !== 'id');
if (changedKeys.length === 0) {
ElMessage.info('没有检测到数据变更,无需保存');
submitLoading.value = false;
dialog.visible = false;
return;
}
const requestApi = form.value.id ? updateMaterialBase : addMaterialBase;
const actionText = form.value.id ? '修改' : '新增';
await requestApi(payload);
ElMessage.success(`${actionText}成功`);
dialog.visible = false;
originalForm.value = null;
getList();
getOptionsList();
} catch (error: any) {
@ -1218,6 +1336,22 @@ const cancel = () => {
resetForm();
};
// 快速基于此物料查看/创建 BOM
const createBomForMaterial = () => {
if (!form.value.id) {
return ElMessage.warning('请先保存物料基础信息后再操作');
}
const routeUrl = router.resolve({
path: '/bom',
query: {
create_for_id: form.value.id,
parent_name: form.value.name,
parent_spec: form.value.spec
}
});
window.open(routeUrl.href, '_blank');
};
const resetForm = () => {
form.value = JSON.parse(JSON.stringify(initForm));
fileListImage.value = [];
@ -1228,6 +1362,7 @@ const resetForm = () => {
imageExternalUrl.value = '';
manualExternalUrl.value = '';
originalForm.value = null;
if (formRef.value) formRef.value.resetFields();
};
@ -1268,11 +1403,34 @@ const handleSetSingleWarning = (row: MaterialBaseVO) => {
warningForm.isEnabled = row.warningEnabled || false;
warningForm.redThreshold = row.warningRed;
warningForm.yellowThreshold = row.warningYellow;
warningForm.redEmails = (row as any).redEmails || '';
warningForm.yellowEmails = (row as any).yellowEmails || '';
warningDialog.title = '设置预警';
warningDialog.visible = true;
};
// 标记预警物料已采购
const handleMarkOrdered = (row: MaterialBaseVO) => {
ElMessageBox.confirm(
'确认已对该预警物料下单?标记后在途期间将不再发送预警邮件。',
'确认标记已采购',
{
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning'
}
).then(async () => {
try {
await markWarningOrdered({ baseId: row.id, isOrdered: true });
ElMessage.success('已标记为已采购');
getList();
} catch (error: any) {
ElMessage.error(error?.msg || '标记失败');
}
}).catch(() => {});
};
// 提交预警设置
const submitWarning = async () => {
if (!warningFormRef.value) return;
@ -1295,7 +1453,9 @@ const submitWarning = async () => {
baseId,
isEnabled: warningForm.isEnabled,
redThreshold: red,
yellowThreshold: yellow
yellowThreshold: yellow,
redEmails: warningForm.redEmails || '',
yellowEmails: warningForm.yellowEmails || ''
}));
await batchSetWarning(data);
@ -1343,24 +1503,33 @@ const submitBatchInspection = async () => {
// 表格行样式(根据预警状态)
const tableRowClassName = ({ row }: { row: MaterialBaseVO }) => {
// 只有拥有 view_warning 权限且有预警状态时才显示特殊样式
if (!userStore.hasPermission('material_list:view_warning')) return '';
const status = (row as any).warningStatus;
if (status === 2) {
return 'warning-row-red'; // 红色预警
} else if (status === 1) {
return 'warning-row-yellow'; // 黄色预警
if (row.warningStatus === 2) {
return 'danger-row'; // 红色预警
} else if (row.warningStatus === 1) {
return 'warning-row'; // 黄色预警
}
return '';
};
}
// --- 文件上传辅助函数 ---
const getImageUrl = (url: string) => { return !url ? '' : (url.startsWith('http') ? url : url) }
const isExternalLink = (str: string) => { return str && (str.startsWith('http://') || str.startsWith('https://')) && !str.includes('/api/v1/common/files') }
const getImagesOnly = (list: string[]) => { return !list ? [] : list.filter(item => !isExternalLink(item)) }
const isImageFile = (url: string) => { return /\.(jpg|jpeg|png|gif|webp)$/i.test(url) }
const isImageFile = (url: string) => { return /\.(jpg|jpeg|png|gif|webp|bmp)$/i.test(url) }
const getImagesOnly = (list: string[]) => { return !list ? [] : list.filter(item => !isExternalLink(item) && isImageFile(item)) }
const getNonImagesOnly = (list: string[]) => { return !list ? [] : list.filter(item => !isExternalLink(item) && !isImageFile(item)) }
const truncateFileName = (name: string, maxLen = 12) => { return name.length > maxLen ? name.slice(0, maxLen - 3) + '...' : name }
const handleDownloadConfirm = (link: string) => {
const fileName = link.split('/').pop() || '文件';
ElMessageBox.confirm(`确认要下载/查看「${fileName}」吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
}).then(() => {
window.open(getImageUrl(link), '_blank');
}).catch(() => {});
}
const beforeAvatarUpload = (rawFile: any) => {
const isTypeValid = ['image/jpeg', 'image/png', 'application/pdf'].includes(rawFile.type);
@ -1411,8 +1580,13 @@ const handleRemoveImage = async (uploadFile: any, targetField: 'generalImage' |
}
const handlePreviewPicture = (uploadFile: any) => {
dialogImageUrl.value = uploadFile.url!;
dialogVisibleImage.value = true
const fileUrl = uploadFile.url || uploadFile.response?.url || '';
if (isImageFile(fileUrl)) {
dialogImageUrl.value = getImageUrl(fileUrl);
dialogVisibleImage.value = true;
} else {
window.open(getImageUrl(fileUrl), '_blank');
}
}
const triggerCamera = (field: 'generalImage' | 'generalManual') => {
@ -1479,10 +1653,48 @@ const resetAdvancedFilter = () => {
};
onMounted(() => {
// 1. 修复背景联动:直接对 reactive 对象赋值
if (route.query.keyword) {
queryParams.keyword = route.query.keyword as string;
queryParams.searchField = 'all';
}
// 先根据权限初始化列显示状态
initColumnPermissions();
// 此时 getList 会带着正确的 keyword 向后端请求过滤后的数据
getList();
getOptionsList();
// 2. 修复弹窗锁定逻辑
console.log('--- 准备检测外部跳转参数 ---', route.query);
if (route.query.edit_id) {
const editId = Number(route.query.edit_id);
const searchKeyword = (route.query.keyword as string) || '';
console.log('检测到 edit_id:', editId, '使用 keyword 搜索:', searchKeyword);
// 改用 keyword 而不是无效的 id 去向后端请求数据,确保目标物料在返回的列表中
listMaterialBase({ page: 1, pageSize: 50, keyword: searchKeyword }).then((res: any) => {
let rawData = res?.data?.list ?? res?.data?.items ?? res?.data ?? [];
if (!Array.isArray(rawData) && typeof rawData === 'object' && rawData !== null) {
rawData = [rawData];
}
const rows = Array.isArray(rawData) ? rawData : [];
// 3. 去掉危险的 rows[0] 兜底,严格匹配 ID
const targetRow = rows.find((r: any) => r.id === editId);
if (targetRow) {
console.log('找到精准目标物料,准备弹窗:', targetRow);
setTimeout(() => {
handleEdit(targetRow);
}, 800);
} else {
console.warn('未能在搜索结果中匹配到对应 ID 的物料,可能 keyword 与 ID 不匹配');
}
}).catch((error: any) => {
console.error('自动获取物料详情失败', error);
});
}
});
</script>
@ -1529,34 +1741,42 @@ onMounted(() => {
.file-preview-cell { display: flex; align-items: center; justify-content: center; position: relative; }
.more-badge { position: absolute; top: -5px; right: -5px; background: #909399; color: #fff; border-radius: 10px; padding: 0 4px; font-size: 10px; transform: scale(0.9); }
/* 预警行样式 - 加深颜色 */
:deep(.warning-row-red) {
--el-table-tr-bg-color: #ffcdd2 !important;
background-color: #ffcdd2 !important;
}
:deep(.warning-row-red td) {
background-color: transparent !important;
}
:deep(.warning-row-yellow) {
--el-table-tr-bg-color: #fff59d !important;
background-color: #fff59d !important;
}
:deep(.warning-row-yellow td) {
background-color: transparent !important;
/* 上传文件项样式 - 非图片文件显示 */
.upload-file-item { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden; }
.upload-file-item .el-upload-list__item-thumbnail { width: 100%; height: 100%; object-fit: cover; }
.upload-file-item .file-thumbnail { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; height: 100%; background: #f5f7fa; color: #606266; }
.upload-file-item .file-thumbnail .file-name { font-size: 10px; margin-top: 4px; text-align: center; padding: 0 4px; word-break: break-all; max-width: 90px; }
.upload-file-item .el-upload-list__item-actions { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.6); opacity: 0; transition: opacity 0.3s; }
.upload-file-item:hover .el-upload-list__item-actions { opacity: 1; }
.upload-file-item .el-upload-list__item-actions .el-icon { color: #fff; font-size: 20px; cursor: pointer; margin: 0 4px; }
.upload-add-trigger { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
/* ================================================================
Element Plus 表格预警行样式 & 固定列重叠修复
================================================================ */
/* 黄色预警行底色 (全覆盖) */
:deep(.el-table .warning-row),
:deep(.el-table .warning-row > td.el-table__cell) {
background-color: #fcedc4 !important; /* 明显的黄色 */
}
/* 表单提示文字 */
.form-tip {
font-size: 12px;
color: #909399;
margin-top: 4px;
line-height: 1.4;
/* 红色预警行底色 (全覆盖) */
:deep(.el-table .danger-row),
:deep(.el-table .danger-row > td.el-table__cell) {
background-color: #fcd3d3 !important; /* 明显的红色 */
}
/* 固定列的按钮容器底色跟随所在行的背景色,视觉无缝融合 */
:deep(.el-table .el-table__cell.is-fixed) {
background-color: inherit !important;
}
/* 按钮间距微调,更紧凑 */
:deep(.el-table .el-table__cell.is-fixed .cell) {
display: flex;
gap: 6px;
justify-content: flex-start; /* 左对齐更自然 */
flex-wrap: nowrap; /* 尽量不换行 */
}
</style>
<style>
/* 增加下拉框的最大高度,使其能容纳更多选项而不必频繁滚动 */
.long-dropdown .el-select-dropdown__wrap {
max-height: 600px !important; /* 可以根据屏幕大小适当调整 */
}
</style>

View File

@ -8,16 +8,38 @@
<span class="subtitle">(请添加需要出库的物品)</span>
</div>
<div>
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="primary" :icon="Plus" @click="openManualSelect">
手动添加库存
</el-button>
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="warning" :icon="List" @click="openBomSelect">
BOM 套餐添加
</el-button>
<!-- 批量模式 -->
<template v-if="isBulkMode">
<el-button @click="cancelBulkMode">
取消
</el-button>
<el-button type="danger" :disabled="selectedRows.length === 0" @click="batchRemove">
移除选中 ({{ selectedRows.length }})
</el-button>
</template>
<!-- 普通模式 -->
<template v-else>
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="warning" plain :disabled="selectedItems.length === 0" @click="isBulkMode = true">
批量操作
</el-button>
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="danger" :disabled="selectedItems.length === 0" @click="clearAll">
清空列表
</el-button>
<el-divider direction="vertical" />
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="primary" :icon="Plus" @click="openManualSelect">
手动添加库存
</el-button>
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="warning" :icon="List" @click="openBomSelect">
BOM 套餐添加
</el-button>
</template>
<el-divider direction="vertical" />
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="success" :icon="Printer" :disabled="selectedItems.length === 0" @click="handlePreview">
生成预览 & 打印
</el-button>
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="primary" :icon="Plus" :disabled="selectedItems.length === 0" @click="openRequestDialog">
提交出库申请
</el-button>
</div>
</div>
</template>
@ -33,12 +55,17 @@
/>
<el-table
ref="tableRef"
v-else
:data="sortedSelectedItems"
border
style="width: 100%"
row-key="uniqueKey"
@selection-change="handleSelectionChange"
@row-click="handleBulkRowClick"
:row-class-name="getRowClassName"
>
<el-table-column v-if="isBulkMode" type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="50" align="center" />
<el-table-column label="类型" width="100" align="center">
@ -79,7 +106,7 @@
<el-table-column label="操作" width="80" align="center" fixed="right">
<template #default="{ $index }">
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="danger" link @click="removeRow($index)">移除</el-button>
<el-button v-if="!isBulkMode && userStore.hasPermission('outbound_selection:operation')" type="danger" link @click="removeRow($index)">移除</el-button>
</template>
</el-table-column>
</el-table>
@ -213,13 +240,12 @@
</div>
<template #footer>
<el-button @click="bomSelectVisible = false">取消</el-button>
<el-button
v-if="userStore.hasPermission('outbound_selection:operation')"
type="primary"
<el-button
v-if="userStore.hasPermission('outbound_selection:operation')"
type="primary"
@click="confirmBomAdd"
:disabled="hasShortage"
>
{{ hasShortage ? '库存不足,无法添加' : '一键计算并添加' }}
{{ hasShortage ? '仅添加现有库存' : '确认添加' }}
</el-button>
</template>
</el-dialog>
@ -266,6 +292,80 @@
</template>
</el-dialog>
<!-- 出库申请 Dialog -->
<el-dialog
v-model="requestDialogVisible"
title="提交出库申请"
width="700px"
destroy-on-close
class="no-print-content"
>
<el-alert
title="请确认以下物料申请清单,填写申请原因后提交"
type="info"
:closable="false"
style="margin-bottom: 16px;"
/>
<el-table :data="validSelectedItems" border size="small" style="margin-bottom: 16px;">
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column label="类型" width="80" align="center">
<template #default="{ row }">
<el-tag size="small" :type="getTypeTag(row.type)">{{ row.typeLabel }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="standard" label="规格" min-width="120" show-overflow-tooltip />
<el-table-column prop="warehouse_location" label="库位" width="120" show-overflow-tooltip />
<el-table-column prop="export_quantity" label="计划数量" width="100" align="center">
<template #default="{ row }">
<span style="color: #F56C6C; font-weight: bold;">{{ row.export_quantity }}</span>
</template>
</el-table-column>
</el-table>
<el-form label-width="80px">
<el-form-item label="* 指定审批人" required>
<el-select
v-model="requestApproverId"
placeholder="请选择审批人"
style="width: 100%"
filterable
>
<el-option
v-for="user in approvers"
:key="user.id"
:label="`${user.username} (${user.role === 'SUPER_ADMIN' ? '超级管理员' : '主管'})`"
:value="user.id"
/>
</el-select>
</el-form-item>
<el-form-item label="申请原因" required>
<el-input
v-model="requestRemark"
type="textarea"
:rows="3"
placeholder="请填写出库申请原因(必填)"
maxlength="200"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="requestDialogVisible = false">取消</el-button>
<el-button
type="primary"
:loading="requestSubmitting"
@click="confirmSubmitRequest"
>
确认提交
</el-button>
</span>
</template>
</el-dialog>
<div id="print-area">
<div class="print-header">
<h1>IRIS出库拣货确认单</h1>
@ -335,11 +435,15 @@ import { ElMessage, ElTable, ElMessageBox } from 'element-plus'
import { getAllStock, getStockList, printSelectionList } from '@/api/inbound/stock'
import { getBomList, getBomDetail } from '@/api/bom'
import { useUserStore } from '@/stores/user'
import { submitOutboundRequest } from '@/api/outbound'
import { getApproversList } from '@/api/auth'
const userStore = useUserStore()
// --- 状态变量 ---
const selectedItems = ref<any[]>([])
const selectedRows = ref<any[]>([])
const isBulkMode = ref(false)
// 按库位路径自然升序排序(优化拣货路径)
const sortedSelectedItems = computed(() => {
@ -356,6 +460,13 @@ const previewVisible = ref(false)
const exportLoading = ref(false)
const printLoading = ref(false)
// ★ 出库申请相关
const requestDialogVisible = ref(false)
const requestRemark = ref('')
const requestApproverId = ref<number | null>(null)
const approvers = ref<any[]>([])
const requestSubmitting = ref(false)
const allStockData = ref<any[]>([])
const stockList = ref<any[]>([]) // 服务端分页数据
const stockTotal = ref(0)
@ -368,6 +479,7 @@ let stockSearchTimer: ReturnType<typeof setTimeout> | null = null
// 表格引用
const manualTableRef = ref<InstanceType<typeof ElTable>>()
const tableRef = ref<InstanceType<typeof ElTable>>()
// BOM 相关
const bomOptions = ref<any[]>([])
@ -451,6 +563,31 @@ const getTypeTag = (type: string) => {
}
}
// --- 核心逻辑 0加载全量库存数据BOM 齐套计算依赖此数据) ---
const ensureAllStockLoaded = async () => {
if (allStockData.value.length === 0) {
try {
const res: any = await getAllStock()
const rawMaterials = (res.materials || []).map((i: any) => ({ ...i, type: 'material', typeLabel: '采购件' }))
const rawSemis = (res.semis || []).map((i: any) => ({ ...i, type: 'semi', typeLabel: '半成品' }))
const rawProducts = (res.products || []).map((i: any) => ({ ...i, type: 'product', typeLabel: '成品' }))
const list = [...rawMaterials, ...rawSemis, ...rawProducts]
allStockData.value = list.map((i: any) => ({
...i,
name: i.name || i.material_name || i.product_name || '未知名称',
standard: i.standard || i.spec_model || '',
warehouse_location: i.warehouse_location || i.warehouse_loc || i.full_path || '',
uniqueKey: `${i.type}_${i.id}`,
available_quantity: parseFloat(i.available_quantity) || 0,
availableCount: parseFloat(i.available_quantity) || 0,
export_quantity: 1
}))
} catch (e) {
ElMessage.error('加载全量库存数据失败BOM 功能可能受影响)')
}
}
}
// --- 核心逻辑 1手动添加库存 ---
// 服务端加载库存列表
@ -460,7 +597,8 @@ const loadStockList = async () => {
const res: any = await getStockList({
page: stockPage.value,
pageSize: stockPageSize.value,
keyword: searchKeyword.value.trim()
keyword: searchKeyword.value.trim(),
is_aggregated: true // ★ 触发后端按规格+库位合并
})
// 为每个item添加uniqueKey和确保warehouse_location字段正确映射
stockList.value = (res.data?.list || []).map((item: any) => ({
@ -482,31 +620,8 @@ const openManualSelect = async () => {
stockPage.value = 1
searchKeyword.value = ''
await loadStockList()
// 仅在 BOM 关联查询需要时加载全量(一次性缓存)
if (allStockData.value.length === 0) {
try {
const res: any = await getAllStock()
const rawMaterials = (res.materials || []).map((i: any) => ({ ...i, type: 'material', typeLabel: '采购件' }))
const rawSemis = (res.semis || []).map((i: any) => ({ ...i, type: 'semi', typeLabel: '半成品' }))
const rawProducts = (res.products || []).map((i: any) => ({ ...i, type: 'product', typeLabel: '成品' }))
const list = [...rawMaterials, ...rawSemis, ...rawProducts]
allStockData.value = list.map((i: any) => ({
...i,
name: i.name || i.material_name || i.product_name || '未知名称',
standard: i.standard || i.spec_model || '',
warehouse_location: i.warehouse_location || i.warehouse_loc || i.full_path || '',
uniqueKey: `${i.type}_${i.id}`,
available_quantity: parseFloat(i.available_quantity) || 0,
availableCount: parseFloat(i.available_quantity) || 0,
export_quantity: 1
}))
} catch (e) {
ElMessage.error('加载全量库存数据失败BOM 功能可能受影响)')
}
} else {
allStockData.value.forEach(item => item.export_quantity = 0)
}
await ensureAllStockLoaded()
allStockData.value.forEach(item => item.export_quantity = 0)
}
// 搜索框防抖触发服务端过滤
@ -611,6 +726,7 @@ const openBomSelect = async () => {
} catch (e) {
ElMessage.error('加载 BOM 列表失败')
}
await ensureAllStockLoaded()
}
// 监听 BOM 选择变化,自动加载明细并计算齐套性
@ -620,7 +736,7 @@ watch(selectedBomNo, async (newBomNo) => {
return
}
try {
const detailRes = await getBomDetail(newBomNo)
const detailRes: any = await getBomDetail(newBomNo)
currentBomDetail.value = detailRes.data?.children || []
} catch (e) {
ElMessage.error('加载 BOM 明细失败')
@ -629,55 +745,128 @@ watch(selectedBomNo, async (newBomNo) => {
})
const confirmBomAdd = async () => {
if(!selectedBomNo.value) return ElMessage.warning('请选择 BOM');
if (!selectedBomNo.value) return ElMessage.warning('请选择 BOM')
if (allStockData.value.length === 0) {
await openManualSelect()
manualDialogVisible.value = false
await ensureAllStockLoaded()
}
try {
const detailRes = await getBomDetail(selectedBomNo.value)
const bomRows = detailRes.data?.children || []
if (currentBomDetail.value.length === 0) {
try {
const detailRes: any = await getBomDetail(selectedBomNo.value)
currentBomDetail.value = detailRes.data?.children || []
} catch (e) {
ElMessage.error('获取 BOM 详情失败')
return
}
}
let addedCount = 0;
bomRows.forEach((bomItem: any) => {
const needQty = (parseFloat(bomItem.dosage) || 0) * bomSets.value
// ★ BOM 添加时,匹配本地库存数据,带入库位信息
const stockCandidate = allStockData.value.find(s =>
(s.base_id && s.base_id == bomItem.child_id)
)
const bomRows = currentBomDetail.value
let addedCount = 0
let skippedCount = 0
if (stockCandidate) {
bomRows.forEach((bomItem: any) => {
const dosage = parseFloat(bomItem.dosage) || 0
const needQty = dosage * bomSets.value
const stockCandidate = allStockData.value.find(s =>
(s.base_id && s.base_id == bomItem.child_id)
)
if (stockCandidate) {
const availableQty = stockCandidate.availableCount || 0
const actualAddQty = Math.min(needQty, availableQty)
if (actualAddQty > 0) {
const existing = selectedItems.value.find(e => e.uniqueKey === stockCandidate.uniqueKey)
if (existing) {
existing.export_quantity += needQty
existing.export_quantity += actualAddQty
} else {
const newItem = JSON.parse(JSON.stringify(stockCandidate))
// 如果后端 BomService 也返回了 warehouse_location (聚合的),这里优先使用
if (bomItem.warehouse_location) {
newItem.warehouse_location = bomItem.warehouse_location
}
newItem.export_quantity = needQty
newItem.export_quantity = actualAddQty
selectedItems.value.push(newItem)
}
addedCount++
} else {
skippedCount++
}
})
if(addedCount > 0) {
ElMessage.success(`成功添加 BOM 相关物料,共 ${addedCount}`)
bomSelectVisible.value = false
} else {
ElMessage.warning('库存中未找到该 BOM 所需的任何原料')
skippedCount++
}
} catch(e) {
ElMessage.error('获取 BOM 详情失败')
})
if (addedCount > 0) {
const tip = skippedCount > 0 ? `(跳过 ${skippedCount} 种缺货物料)` : ''
ElMessage.success(`成功添加 ${addedCount} 类物料${tip}`)
bomSelectVisible.value = false
} else {
ElMessage.warning('该 BOM 所有物料库存均为 0')
}
}
// --- 通用逻辑 ---
const handleSelectionChange = (val: any[]) => {
selectedRows.value = val
if (val.length === 0 && isBulkMode.value) {
isBulkMode.value = false
}
}
const handleBulkRowClick = (row: any) => {
if (isBulkMode.value && tableRef.value) {
tableRef.value.toggleRowSelection(row, undefined)
}
}
const getRowClassName = () => {
return isBulkMode.value ? 'bulk-clickable-row' : ''
}
const cancelBulkMode = () => {
isBulkMode.value = false
selectedRows.value = []
}
const clearAll = () => {
ElMessageBox.confirm(
'确定要清空当前拣货车中的所有物品吗?',
'清空确认',
{
confirmButtonText: '确定清空',
cancelButtonText: '取消',
type: 'warning',
}
).then(() => {
selectedItems.value = []
selectedRows.value = []
isBulkMode.value = false
ElMessage.success('已清空拣货车')
}).catch(() => {})
}
const batchRemove = () => {
if (selectedRows.value.length === 0) return
ElMessageBox.confirm(
`确定要移除选中的 ${selectedRows.value.length} 项物品吗?`,
'批量移除确认',
{
confirmButtonText: '确定移除',
cancelButtonText: '取消',
type: 'warning',
}
).then(() => {
const keysToRemove = new Set(selectedRows.value.map(row => row.uniqueKey))
selectedItems.value = selectedItems.value.filter(item => !keysToRemove.has(item.uniqueKey))
selectedRows.value = []
isBulkMode.value = false
ElMessage.success(`已移除 ${keysToRemove.size} 项物品`)
}).catch(() => {})
}
const removeRow = (index: number) => {
selectedItems.value.splice(index, 1)
}
@ -692,9 +881,70 @@ const handlePreview = () => {
previewVisible.value = true
}
// ★ 出库申请
const openRequestDialog = () => {
if (validSelectedItems.value.length === 0) {
ElMessage.warning('请先添加物品并填写计划出库数量')
return
}
requestRemark.value = ''
requestApproverId.value = null
loadApprovers()
requestDialogVisible.value = true
}
// ★ 加载可指定审批人列表
const loadApprovers = async () => {
try {
const res: any = await getApproversList()
approvers.value = res.data || []
} catch (e) {
console.error('加载审批人列表失败', e)
approvers.value = []
}
}
const confirmSubmitRequest = async () => {
const trimmed = requestRemark.value.trim()
if (!trimmed) {
ElMessage.warning('请填写申请原因')
return
}
if (!requestApproverId.value) {
ElMessage.warning('请选择指定审批人')
return
}
requestSubmitting.value = true
try {
const payload: any = {
items: validSelectedItems.value.map(item => ({
material_type: item.typeLabel || item.type || '',
name: item.name || '',
spec_model: item.standard || '',
warehouse_location: item.warehouse_location || '',
quantity: item.export_quantity || 0
})),
remark: trimmed,
approver_id: requestApproverId.value
}
await submitOutboundRequest(payload)
// 成功:关闭弹窗、清空列表、提示
requestDialogVisible.value = false
selectedItems.value = []
ElMessage.success('出库申请已提交,等待主管审批!')
} catch (err: any) {
ElMessage.error(err?.message || err?.msg || '提交申请失败,请重试')
} finally {
requestSubmitting.value = false
}
}
const confirmPrint = async () => {
previewVisible.value = false;
// 记录日志
try {
const payload = validSelectedItems.value.map(item => ({
name: item.name, standard: item.standard, quantity: item.export_quantity
@ -703,7 +953,87 @@ const confirmPrint = async () => {
} catch (e) {}
setTimeout(() => {
window.print();
// 1. 获取要打印的区域 DOM
const printElement = document.getElementById('print-area');
if (!printElement) return;
// 2. 创建并挂载隐藏的 iframe
const iframe = document.createElement('iframe');
iframe.style.position = 'fixed';
iframe.style.right = '0';
iframe.style.bottom = '0';
iframe.style.width = '0';
iframe.style.height = '0';
iframe.style.border = '0';
document.body.appendChild(iframe);
const iframeDoc = iframe.contentWindow?.document || iframe.contentDocument;
if (!iframeDoc) return;
// 3. 安全初始化 iframe 骨架(只写基本结构,不拼接任何业务代码)
iframeDoc.open();
iframeDoc.write('<!DOCTYPE html><html><head><title>出库单打印</title></head><body></body></html>');
iframeDoc.close();
// 4. 【核心修复】安全克隆所有样式节点,彻底告别乱码
const styles = document.querySelectorAll('style, link[rel="stylesheet"]');
styles.forEach(styleNode => {
iframeDoc.head.appendChild(styleNode.cloneNode(true));
});
// 5. 动态追加针对打印的强制分页 CSS
const customStyle = iframeDoc.createElement('style');
customStyle.innerHTML = `
/* 重置基础布局,解除所有高度死锁 */
html, body {
height: auto !important;
min-height: 100% !important;
overflow: visible !important;
background: white !important;
margin: 0;
padding: 0;
}
/* 规范 A4 纸张 */
@page {
size: A4 portrait;
margin: 10mm;
}
/* 确保打印区正常流式显示 */
#print-area {
display: block !important;
position: static !important;
width: 100% !important;
height: auto !important;
}
/* 核心:保护表格不被跨页截断 */
.print-table {
width: 100% !important;
table-layout: auto !important;
border-collapse: collapse;
}
.print-table tr, .print-table td, .print-table th {
page-break-inside: avoid !important;
break-inside: avoid !important;
}
/* 隐藏不需要的全局 UI */
.el-overlay, .el-dialog__wrapper, .no-print-content {
display: none !important;
}
`;
iframeDoc.head.appendChild(customStyle);
// 6. 【核心修复】安全克隆打印区域到 body 中
iframeDoc.body.appendChild(printElement.cloneNode(true));
// 7. 延迟触发打印,等待样式完全渲染
setTimeout(() => {
iframe.contentWindow?.focus();
iframe.contentWindow?.print();
// 打印结束后清理 iframe
setTimeout(() => {
document.body.removeChild(iframe);
}, 1000);
}, 500); // 预留 500ms 渲染时间
}, 300);
}
@ -746,28 +1076,90 @@ const confirmExport = () => {
::v-deep(.el-card__body) { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
/* ================= ★★★ 打印专用样式 ★★★ ================= */
/* ================= ★★★ 打印区域排版样式 ★★★ ================= */
#print-area { display: none; }
@media print {
@page { margin: 0; size: auto; }
body * { visibility: hidden; }
.el-dialog__wrapper, .v-modal, .el-message, .no-print-content { display: none !important; }
#print-area, #print-area * { visibility: visible; }
#print-area {
position: fixed; left: 0; top: 0; width: 100%; height: 100%;
margin: 0; padding: 20mm; background-color: white;
display: block !important; z-index: 99999;
}
.print-header { text-align: center; margin-bottom: 20px; }
.print-header h1 { font-size: 24px; margin: 0 0 10px 0; font-weight: bold; color: #000; }
.print-meta-row { display: flex; justify-content: flex-start; font-size: 12px; margin-bottom: 5px; }
.header-line { border-bottom: 2px solid #000; margin-top: 5px; }
.print-table { width: 100%; border-collapse: collapse; margin-bottom: 40px; border: 1px solid #000; }
.print-table th, .print-table td { border: 1px solid #000; padding: 12px 8px; text-align: left; font-size: 14px; color: #000; }
.print-table th { text-align: center; font-weight: bold; }
.cell-padding { padding-left: 10px; }
.print-footer { display: flex; justify-content: space-between; margin-top: 60px; padding: 0 20px; }
.signature-item { display: flex; flex-direction: column; align-items: center; width: 30%; }
.sig-label { font-size: 14px; margin-bottom: 40px; text-align: left; width: 100%; }
.sig-line { border-bottom: 1px solid #000; width: 100%; height: 1px; display: block; }
.print-header { text-align: center; margin-bottom: 20px; }
.print-header h1 { font-size: 24px; margin: 0 0 10px 0; font-weight: bold; color: #000; }
.print-meta-row { display: flex; justify-content: flex-start; font-size: 12px; margin-bottom: 5px; }
.header-line { border-bottom: 2px solid #000; margin-top: 5px; }
.print-table { width: 100%; border-collapse: collapse; margin-bottom: 40px; border: 1px solid #000; }
.print-table th, .print-table td { border: 1px solid #000; padding: 12px 8px; text-align: left; font-size: 14px; color: #000; }
.print-table th { text-align: center; font-weight: bold; }
.cell-padding { padding-left: 10px; }
.print-table tr {
page-break-inside: avoid !important;
break-inside: avoid !important;
}
.print-footer { display: flex; justify-content: space-between; margin-top: 60px; padding: 0 20px; }
.signature-item { display: flex; flex-direction: column; align-items: center; width: 30%; }
.sig-label { font-size: 14px; margin-bottom: 40px; text-align: left; width: 100%; }
.sig-line { border-bottom: 1px solid #000; width: 100%; height: 1px; display: block; }
/* ★★★ 修复预览弹窗中 el-table 打印分页截断问题 ★★★ */
.print-preview-content {
height: auto !important;
max-height: none !important;
overflow: visible !important;
}
.print-preview-content .el-table,
.print-preview-content .el-table__inner-wrapper,
.print-preview-content .el-table__body-wrapper,
.print-preview-content .el-table__body {
height: auto !important;
max-height: none !important;
overflow: visible !important;
}
.print-preview-content .el-scrollbar__wrap {
overflow: visible !important;
}
.print-preview-content .el-table tr {
page-break-inside: avoid !important;
break-inside: avoid !important;
}
.print-preview-content .el-table__body-wrapper is-scrollable-none {
overflow: visible !important;
}
:deep(.bulk-clickable-row) {
cursor: pointer;
}
</style>
<style>
@media print {
@page { margin: 10mm; size: auto; }
/* 1. 保留原始:隐藏系统全局的无关元素 */
body * { visibility: hidden; }
.el-dialog__wrapper, .v-modal, .el-message, .no-print-content { display: none !important; }
/* 2. 【核心修复】:打通 Vue 和 Element 框架的所有父级容器,解除裁剪和定位死锁 */
html, body, #app, .el-container, .el-main, .el-scrollbar__wrap {
position: static !important;
overflow: visible !important;
height: auto !important;
min-height: auto !important;
}
/* 3. 保留原始:让打印区域显形,并用 absolute 顶至左上角,允许自然向下分页 */
#print-area, #print-area * { visibility: visible; }
#print-area {
position: absolute !important;
left: 0 !important;
top: 0 !important;
width: 100% !important;
height: auto !important;
margin: 0 !important;
padding: 0 !important;
background-color: white;
display: block !important;
z-index: 99999;
}
/* 4. 【核心修复】:防止表格行在跨页时被水平拦腰切断 */
.print-table tr, .print-table td, .print-table th {
page-break-inside: avoid !important;
break-inside: avoid !important;
}
}
</style>

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

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

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

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

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 : []
@ -722,6 +714,9 @@ const onScanSuccess = async (code: string) => {
if (!code || loading.value) return
const trimCode = code.trim()
// 将扫到的条码同步显示在输入框中
barcodeInput.value = trimCode
if (!/^[A-Za-z0-9\-\.]+$/.test(trimCode)) {
ElMessage.warning(`识别到异常字符:${trimCode}`)
return
@ -990,7 +985,7 @@ const fetchInventoryList = async (silent = false) => {
method: 'get',
params: {
page: 1,
limit: 500, // ★ 限制单次加载数量,防止内存溢出
limit: 99999, // ★ 获取全量草稿数据
keyword: listKeyword.value,
session_id: currentSessionId.value // ★ 必须传递 session_id 隔离会话
}
@ -1002,6 +997,8 @@ const fetchInventoryList = async (silent = false) => {
// 保存全量草稿记录用于全局统计
allScannedDrafts.value = scannedDrafts
// 直接读取后端算好的去重已盘数
totalScannedCount.value = res?.total_scanned || 0
// 2. 使用全量应盘物资列表
// 对于每个应盘物资,检查是否有对应的盘点记录
@ -1020,7 +1017,7 @@ const fetchInventoryList = async (silent = false) => {
material_name: item.material_name,
spec_model: item.spec_model,
stock_qty: item.stock_qty, // 账面数(盲盘时隐藏)
quantity: draft?.quantity || 0, // 实盘数
quantity: draft?.quantity ?? draft?.qty_actual ?? 0, // 兼容后端字段名
diff_qty: draft ? (draft.quantity - item.stock_qty) : -item.stock_qty, // 差异
remark: draft?.remark || '',
warehouse_location: item.warehouse_location

View File

@ -55,12 +55,16 @@
</el-table-column>
<el-table-column prop="action" label="操作类型" width="100">
<template #default="scope">
<el-tag :type="getActionType(scope.row.action)">{{ scope.row.action }}</el-tag>
<el-tag :type="getActionType(scope.row.action)">{{ actionMap[scope.row.action] || scope.row.action }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="target_name" label="操作对象" min-width="150" show-overflow-tooltip />
<el-table-column prop="ip_address" label="IP地址" width="130" />
<el-table-column prop="created_at" label="操作时间" width="170" />
<el-table-column prop="created_at" label="操作时间" width="170">
<template #default="scope">
{{ formatLocalTime(scope.row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="scope">
<el-button
@ -100,12 +104,12 @@
<el-tag>{{ currentLog.module }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="操作类型">
<el-tag :type="getActionType(currentLog.action)">{{ currentLog.action }}</el-tag>
<el-tag :type="getActionType(currentLog.action)">{{ actionMap[currentLog.action] || currentLog.action }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="操作对象" :span="2">{{ currentLog.target_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="IP地址">{{ currentLog.ip_address }}</el-descriptions-item>
<el-descriptions-item label="请求方式">{{ currentLog.method }}</el-descriptions-item>
<el-descriptions-item label="操作时间" :span="2">{{ currentLog.created_at }}</el-descriptions-item>
<el-descriptions-item label="操作时间" :span="2">{{ formatLocalTime(currentLog.created_at) }}</el-descriptions-item>
</el-descriptions>
<!-- 变更明细区域支持同时展示多种结构 -->
@ -118,9 +122,9 @@
字段变更详情 {{ changesList.length }} 处变更
</div>
<el-table :data="changesList" border stripe size="small" max-height="350">
<el-table-column prop="field" label="字段名" width="150">
<el-table-column label="字段名" width="150">
<template #default="{ row }">
<span class="field-name">{{ row.field }}</span>
<span class="field-name">{{ fieldMap[row.field] || row.field }}</span>
</template>
</el-table-column>
<el-table-column label="修改前" min-width="200">
@ -218,6 +222,50 @@ const dateRange = ref<[string, string] | null>(null)
const moduleOptions = ref<string[]>([])
const actionOptions = ref<string[]>(['create', 'update', 'delete', 'export', 'import'])
// ============================================================
// 中文化映射
// ============================================================
// 操作类型中文化映射
const actionMap: Record<string, string> = {
'UPDATE': '修改',
'CREATE': '新增',
'DELETE': '删除',
'LOGIN': '登录',
'LOGOUT': '登出'
};
// 字段名中文化映射 (常见业务字段)
const fieldMap: Record<string, string> = {
'available_quantity': '可用库存',
'in_quantity': '入库数量',
'stock_quantity': '总库存',
'out_quantity': '出库数量',
'name': '名称',
'material_name': '物料名称',
'spec_model': '规格型号',
'category': '类别',
'status': '状态',
'remark': '备注',
'is_active': '是否启用'
};
// 时间格式化:将后端的 UTC 时间字符串转换为本地时间 (UTC+8)
const formatLocalTime = (timeStr: string) => {
if (!timeStr) return '-'
// 补全 'Z' 让浏览器识别为 UTC 时间,自动转为当前系统的时区
const date = new Date(timeStr.replace(' ', 'T') + 'Z')
if (isNaN(date.getTime())) return timeStr
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
const h = String(date.getHours()).padStart(2, '0')
const min = String(date.getMinutes()).padStart(2, '0')
const s = String(date.getSeconds()).padStart(2, '0')
return `${y}-${m}-${d} ${h}:${min}:${s}`
}
// 详情弹窗
const detailDialogVisible = ref(false)
const currentLog = ref<any>({})

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()

View File

@ -12,7 +12,7 @@ DB_CONFIG = {
}
# 2. Excel 文件路径
EXCEL_FILE = 'product.template.xlsx'
EXCEL_FILE = '../product.template.xlsx'
def process_excel_to_db():