5 Commits

20 changed files with 633 additions and 110 deletions

View File

@ -4,7 +4,8 @@
"Bash(git add *)", "Bash(git add *)",
"Bash(git commit *)", "Bash(git commit *)",
"Bash(git *)", "Bash(git *)",
"Bash(del *)" "Bash(del *)",
"Bash(rm *)"
] ]
}, },
"$version": 3 "$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 flask import Flask
from config import Config from config import Config
from app.extensions import db, migrate, cors, jwt from app.extensions import db, migrate, cors, jwt
from app.api.v1.scan import scan_bp
import os import os
@ -213,6 +214,15 @@ def create_app():
except ImportError as e: except ImportError as e:
print(f"❌ 错误: Common 模块导入失败: {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. 预加载数据模型 # 3. 预加载数据模型
# ========================================================= # =========================================================

View File

@ -2,8 +2,10 @@ from flask import Blueprint
from .inbound import inbound_bp from .inbound import inbound_bp
from .bom import bom_bp from .bom import bom_bp
from .common import common_bp from .common import common_bp
from .scan import scan_bp
v1_bp = Blueprint('v1', __name__) v1_bp = Blueprint('v1', __name__)
v1_bp.register_blueprint(inbound_bp, url_prefix='/inbound') v1_bp.register_blueprint(inbound_bp, url_prefix='/inbound')
v1_bp.register_blueprint(bom_bp, url_prefix='/bom') v1_bp.register_blueprint(bom_bp, url_prefix='/bom')
v1_bp.register_blueprint(common_bp, url_prefix='/common') v1_bp.register_blueprint(common_bp, url_prefix='/common')
v1_bp.register_blueprint(scan_bp, url_prefix='/scan')

View File

@ -381,6 +381,8 @@ def batch_set_warning():
red_val = item.get('redThreshold') red_val = item.get('redThreshold')
warning.yellow_threshold = float(yellow_val) if yellow_val is not None else 0 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.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 updated_count += 1
else: else:
# 创建新记录 # 创建新记录
@ -390,7 +392,9 @@ def batch_set_warning():
base_id=base_id, base_id=base_id,
is_enabled=item.get('isEnabled', False), is_enabled=item.get('isEnabled', False),
yellow_threshold=float(yellow_val) if yellow_val is not None else 0, 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) db.session.add(warning)
created_count += 1 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']) @inbound_base_bp.route('/batch-inspection', methods=['POST'])
@permission_required('material_list:operation') @permission_required('material_list:operation')

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

@ -101,6 +101,10 @@ class MaterialWarningSetting(db.Model):
is_enabled = db.Column(db.Boolean, default=False, comment='是否启用预警') is_enabled = db.Column(db.Boolean, default=False, comment='是否启用预警')
yellow_threshold = db.Column(db.Numeric(10, 2), nullable=True, comment='黄色预警阈值') yellow_threshold = db.Column(db.Numeric(10, 2), nullable=True, comment='黄色预警阈值')
red_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') material = db.relationship('MaterialBase', back_populates='warning_settings')
@ -111,5 +115,9 @@ class MaterialWarningSetting(db.Model):
'baseId': self.base_id, 'baseId': self.base_id,
'isEnabled': bool(self.is_enabled), 'isEnabled': bool(self.is_enabled),
'yellowThreshold': float(self.yellow_threshold) if self.yellow_threshold is not None else None, '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

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

View File

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

@ -253,6 +253,13 @@ class OutboundService:
) )
db.session.add(new_record) 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: if approval:
approval.status = 3 # 3-已完成 approval.status = 3 # 3-已完成
@ -492,8 +499,6 @@ class OutboundService:
ModelClass = model_map.get(d.source_table) ModelClass = model_map.get(d.source_table)
if ModelClass and d.stock_id: if ModelClass and d.stock_id:
# 注意这里在循环中查询可能会有N+1问题但考虑到单页数据量通常每单条目不多暂时可接受
# 生产环境建议优化为预加载或批量查询
try: try:
stock_item = ModelClass.query.get(d.stock_id) stock_item = ModelClass.query.get(d.stock_id)
if stock_item: if stock_item:
@ -716,12 +721,16 @@ class OutboundApprovalService:
# username 格式为 "姓名/账号",取姓名部分 # username 格式为 "姓名/账号",取姓名部分
applicant_name = str(u.username).split('/')[0] if '/' in u.username else (u.username or str(applicant_id)) 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( send_new_request_notify(
to_emails=emails, to_emails=emails,
request_no=approval.request_no, request_no=approval.request_no,
applicant_name=applicant_name, applicant_name=applicant_name,
remark=approval.remark or '' remark=approval.remark or '',
items=items
) )
except Exception as e: except Exception as e:
# ★ 捕获所有异常,确保邮件发送失败不阻断主流程 # ★ 捕获所有异常,确保邮件发送失败不阻断主流程
try: try:
@ -729,6 +738,7 @@ class OutboundApprovalService:
current_app.logger.error(f"[Email] 发送新申请通知邮件失败: {e}") current_app.logger.error(f"[Email] 发送新申请通知邮件失败: {e}")
except RuntimeError: except RuntimeError:
# 如果不在 Flask 应用上下文内,降级为标准日志 # 如果不在 Flask 应用上下文内,降级为标准日志
import logging
logging.getLogger(__name__).error(f"[Email] 发送新申请通知邮件失败: {e}") logging.getLogger(__name__).error(f"[Email] 发送新申请通知邮件失败: {e}")
@staticmethod @staticmethod
@ -817,28 +827,75 @@ class OutboundApprovalService:
@staticmethod @staticmethod
def _notify_approval_result(approval, approver_id, action): def _notify_approval_result(approval, approver_id, action):
"""发送审批结果通知邮件(静默处理,不阻断主流程)""" """发送审批结果通知邮件(静默处理,不阻断主流程)"""
import logging
logger = logging.getLogger(__name__)
try: try:
from app.utils.email_service import send_approval_result_notify 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_role_codes = ['WAREHOUSE_MGR', 'OUTBOUND']
warehouse_emails = OutboundApprovalService._get_emails_by_identifiers(role_codes=warehouse_role_codes)
# 查询库管邮箱 + 申请人本人邮箱 if warehouse_emails:
emails = OutboundApprovalService._get_emails_by_identifiers( try:
applicant_id=approval.applicant_id, send_warehouse_dispatch_notify(
role_codes=warehouse_role_codes to_emails=warehouse_emails,
)
if not emails:
return
send_approval_result_notify(
to_emails=emails,
request_no=approval.request_no, request_no=approval.request_no,
is_passed=(action == 'approve'), applicant_name=applicant_name,
reject_reason=approval.reject_reason or '' items=items
) )
except Exception as e: except Exception as e:
logging.getLogger(__name__).warning(f"[Email] 发送审批结果通知邮件失败: {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 @staticmethod
def get_request_list(page=1, per_page=10, applicant_id=None, status=None): def get_request_list(page=1, per_page=10, applicant_id=None, status=None):

View File

@ -58,19 +58,24 @@ def send_email(to_email: Union[str, List[str]], subject: str, content: str):
""" """
cfg = _get_config() cfg = _get_config()
print(f"[DEBUG send_email] cfg = {cfg}")
# 发送总开关 # 发送总开关
if not cfg.get('enabled'): if not cfg.get('enabled'):
print(f"[Email] 邮件功能已禁用 (MAIL_ENABLED=false),跳过发送: {subject}")
logger.info(f"[Email] 邮件功能已禁用 (MAIL_ENABLED=false),跳过发送: {subject}") logger.info(f"[Email] 邮件功能已禁用 (MAIL_ENABLED=false),跳过发送: {subject}")
return return
# 配置完整性检查 # 配置完整性检查
if not cfg.get('server') or not cfg.get('username') or not cfg.get('password'): 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 缺失),跳过发送") logger.warning("[Email] 邮件配置不完整 (MAIL_SERVER/USERNAME/PASSWORD 缺失),跳过发送")
return return
# 标准化收件人列表 # 标准化收件人列表
recipients = [to_email] if isinstance(to_email, str) else [r.strip() for r in to_email if r.strip()] recipients = [to_email] if isinstance(to_email, str) else [r.strip() for r in to_email if r.strip()]
if not recipients: if not recipients:
print("[Email] 收件人地址为空,跳过发送")
logger.warning("[Email] 收件人地址为空,跳过发送") logger.warning("[Email] 收件人地址为空,跳过发送")
return return
@ -81,6 +86,8 @@ def send_email(to_email: Union[str, List[str]], subject: str, content: str):
msg['Subject'] = Header(subject, 'utf-8') msg['Subject'] = Header(subject, 'utf-8')
msg.attach(MIMEText(content, 'plain', 'utf-8')) msg.attach(MIMEText(content, 'plain', 'utf-8'))
print(f"DEBUG: 准备向服务器提交发信请求,收件人: {recipients} 发件人: {cfg['username']}")
if cfg.get('use_ssl'): if cfg.get('use_ssl'):
context = ssl.create_default_context() context = ssl.create_default_context()
with smtplib.SMTP_SSL(cfg['server'], cfg.get('port', 465), context=context) as server: with smtplib.SMTP_SSL(cfg['server'], cfg.get('port', 465), context=context) as server:
@ -96,26 +103,50 @@ def send_email(to_email: Union[str, List[str]], subject: str, content: str):
logger.info(f"[Email] 发送成功 -> {recipients}: {subject}") logger.info(f"[Email] 发送成功 -> {recipients}: {subject}")
except smtplib.SMTPAuthenticationError: except smtplib.SMTPAuthenticationError:
print(f"!!! 邮件发送核心报错: SMTPAuthenticationError - 邮箱认证失败,请检查 MAIL_USERNAME / MAIL_PASSWORD授权码")
logger.error("[Email] 邮箱认证失败,请检查 MAIL_USERNAME / MAIL_PASSWORD授权码") logger.error("[Email] 邮箱认证失败,请检查 MAIL_USERNAME / MAIL_PASSWORD授权码")
except smtplib.SMTPRecipientsRefused as e: except smtplib.SMTPRecipientsRefused as e:
print(f"!!! 邮件发送核心报错: SMTPRecipientsRefused - 收件人被服务器拒绝: {e}")
logger.error(f"[Email] 收件人被服务器拒绝: {e}") logger.error(f"[Email] 收件人被服务器拒绝: {e}")
except smtplib.SMTPException as e: except smtplib.SMTPException as e:
print(f"!!! 邮件发送核心报错: SMTPException - {e}")
logger.error(f"[Email] SMTP 异常: {e}") logger.error(f"[Email] SMTP 异常: {e}")
except Exception as e: except Exception as e:
import traceback
traceback.print_exc()
print(f"!!! 邮件发送核心报错: {type(e).__name__} - {e}")
logger.error(f"[Email] 发送邮件时发生未知异常: {e}") logger.error(f"[Email] 发送邮件时发生未知异常: {e}")
def send_new_request_notify(to_emails: List[str], request_no: str, def send_new_request_notify(to_emails: List[str], request_no: str,
applicant_name: str = '', remark: str = ''): applicant_name: str = '', remark: str = '',
items: list = None):
""" """
通知审批人有新的出库申请单待审批 通知审批人有新的出库申请单待审批(可附带物料清单)
Args: Args:
to_emails: 审批人邮箱列表 to_emails: 审批人邮箱列表
request_no: 审批单号 request_no: 审批单号
applicant_name: 申请人姓名 applicant_name: 申请人姓名
remark: 申请备注 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}" subject = f"【待审批】出库申请单 {request_no}"
content = f"""您好, content = f"""您好,
@ -125,6 +156,15 @@ def send_new_request_notify(to_emails: List[str], request_no: str,
申请人:{applicant_name or '未知'} 申请人:{applicant_name or '未知'}
备注说明:{remark or ''} 备注说明:{remark or ''}
物料清单如下:
{chr(10).join(rows)}
---
⚡ 快速通道:
请点击下方链接直接进入系统审批:
https://172.16.0.198/outbound/approval
---
请登录仓库管理系统进行审批。 请登录仓库管理系统进行审批。
此邮件由系统自动发送,请勿回复。 此邮件由系统自动发送,请勿回复。
@ -133,36 +173,84 @@ def send_new_request_notify(to_emails: List[str], request_no: str,
def send_approval_result_notify(to_emails: List[str], request_no: str, def send_approval_result_notify(to_emails: List[str], request_no: str,
is_passed: bool, reject_reason: str = ''): is_passed: bool, reject_reason: str = '',
applicant_name: str = ''):
""" """
通知库管和申请人审批结果 通知审批结果
Args: Args:
to_emails: 收件人邮箱列表(库管 + 申请人) to_emails: 收件人邮箱列表
request_no: 审批单号 request_no: 审批单号
is_passed: 是否通过 is_passed: 是否通过(通过时发给库管,驳回时发给申请人)
reject_reason: 驳回原因(仅 is_passed=False 时使用) reject_reason: 驳回原因(仅 is_passed=False 时使用)
applicant_name: 申请人姓名(仅驳回通知时使用)
""" """
if is_passed: if is_passed:
# ★ 发给申请人:告知已通过,去领料
subject = f"【已通过】出库申请单 {request_no}" subject = f"【已通过】出库申请单 {request_no}"
content = f"""您好 content = f"""{"尊敬的 " + applicant_name + ",您好" if applicant_name else "您好"}
出库申请单 {request_no} 已审批通过,请准备备货 您的出库申请单 {request_no} 已审批通过,请联系仓库管理员领取物料
请尽快安排出库操作。
此邮件由系统自动发送,请勿回复。
"""
else:
subject = f"【已驳回】出库申请单 {request_no}"
content = f"""您好,
出库申请单 {request_no} 已被驳回。
驳回原因:{reject_reason or '未填写'}
请登录仓库管理系统查看详情。 请登录仓库管理系统查看详情。
此邮件由系统自动发送,请勿回复。
"""
else:
# ★ 发给申请人:告知被驳回
subject = f"【已驳回】出库申请单 {request_no}"
content = f"""{"尊敬的 " + applicant_name + ",您好" if applicant_name else "您好"}
出库申请单 {request_no} 已被审批驳回。
驳回原因:{reject_reason or '未填写'}
请登录仓库管理系统查看详情,并根据驳回原因调整后重新提交申请。
此邮件由系统自动发送,请勿回复。 此邮件由系统自动发送,请勿回复。
""" """
send_email(to_emails, subject, content) 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

@ -65,7 +65,7 @@ class Config:
MAIL_USE_TLS = os.getenv('MAIL_USE_TLS', 'false').lower() in ('true', '1', 'yes') MAIL_USE_TLS = os.getenv('MAIL_USE_TLS', 'false').lower() in ('true', '1', 'yes')
# 是否启用 SSL (465 端口通常需要,阿里邮箱必须启用 SSL) # 是否启用 SSL (465 端口通常需要,阿里邮箱必须启用 SSL)
MAIL_USE_SSL = os.getenv('MAIL_USE_SSL', 'true').lower() in ('true', '1', 'yes') MAIL_USE_SSL = os.getenv('MAIL_USE_SSL', 'true').lower() in ('true', '1', 'yes')
# 默认发件人(显示名称 <邮箱地址>,阿里要求 MAIL_USERNAME 与此处邮箱地址完全一致 # 默认发件人(★ 必须与 MAIL_USERNAME 完全一致,否则阿里邮件服务器会拒绝
MAIL_DEFAULT_SENDER = os.getenv('MAIL_DEFAULT_SENDER', 'WMS系统 <wms@iris-rs.cn>') MAIL_DEFAULT_SENDER = os.getenv('MAIL_DEFAULT_SENDER', 'wms@iris-rs.cn')
# 是否启用邮件发送功能(开发环境可设为 false 禁用) # 是否启用邮件发送功能(开发环境可设为 false 禁用)
MAIL_ENABLED = os.getenv('MAIL_ENABLED', 'true').lower() in ('true', '1', 'yes') 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"> <footer v-if="!isLoginPage" class="app-footer">
<span class="version-tag"> <span class="version-tag">
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon> <el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
当前版本:V3.14(4.27部署 当前版本:V3.16(4.29部署
</span> </span>
</footer> </footer>

View File

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

View File

@ -302,10 +302,14 @@
</el-tag> </el-tag>
</template> </template>
</el-table-column> </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"> <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: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> <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> <el-button v-if="userStore.hasPermission('material_list:operation')" link type="danger" size="small" @click="handleDelete(scope.row)">删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
@ -560,10 +564,16 @@
<el-input-number v-model="warningForm.redThreshold" :min="0" :precision="0" step="1" placeholder="库存此值为红色预警" style="width: 100%" /> <el-input-number v-model="warningForm.redThreshold" :min="0" :precision="0" step="1" placeholder="库存此值为红色预警" style="width: 100%" />
<div class="form-tip">库存数量 ≤ 此值时显示红色预警</div> <div class="form-tip">库存数量 ≤ 此值时显示红色预警</div>
</el-form-item> </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-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%" /> <el-input-number v-model="warningForm.yellowThreshold" :min="0" :precision="0" step="1" placeholder="库存此值为黄色预警" style="width: 100%" />
<div class="form-tip">红色阈值 &lt; 库存 ≤ 此值时显示黄色预警</div> <div class="form-tip">红色阈值 &lt; 库存 ≤ 此值时显示黄色预警</div>
</el-form-item> </el-form-item>
<el-form-item label="黄色预警邮箱" v-if="warningForm.isEnabled">
<el-input v-model="warningForm.yellowEmails" placeholder="逗号分隔多个邮箱" clearable />
</el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<div class="dialog-footer"> <div class="dialog-footer">
@ -622,7 +632,8 @@ import {
getMaterialBaseOptions, getMaterialBaseOptions,
exportAssetStatistics, exportAssetStatistics,
batchSetWarning, batchSetWarning,
batchSetInspection batchSetInspection,
markWarningOrdered
} from '@/api/material_base'; } from '@/api/material_base';
import { uploadFile, deleteFile } from '@/api/common/upload'; import { uploadFile, deleteFile } from '@/api/common/upload';
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'; import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue';
@ -646,6 +657,8 @@ interface MaterialBaseVO {
statusLoading?: boolean; statusLoading?: boolean;
inventoryCount?: number; inventoryCount?: number;
availableCount?: number; availableCount?: number;
warningStatus?: number;
warningOrdered?: boolean;
} }
interface QueryParams { interface QueryParams {
@ -791,7 +804,9 @@ const warningLoading = ref(false);
const warningForm = reactive({ const warningForm = reactive({
isEnabled: false, isEnabled: false,
redThreshold: undefined as number | undefined, redThreshold: undefined as number | undefined,
yellowThreshold: undefined as number | undefined yellowThreshold: undefined as number | undefined,
redEmails: '',
yellowEmails: ''
}); });
const warningRules = { const warningRules = {
yellowThreshold: [ yellowThreshold: [
@ -1388,11 +1403,34 @@ const handleSetSingleWarning = (row: MaterialBaseVO) => {
warningForm.isEnabled = row.warningEnabled || false; warningForm.isEnabled = row.warningEnabled || false;
warningForm.redThreshold = row.warningRed; warningForm.redThreshold = row.warningRed;
warningForm.yellowThreshold = row.warningYellow; warningForm.yellowThreshold = row.warningYellow;
warningForm.redEmails = (row as any).redEmails || '';
warningForm.yellowEmails = (row as any).yellowEmails || '';
warningDialog.title = '设置预警'; warningDialog.title = '设置预警';
warningDialog.visible = true; 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 () => { const submitWarning = async () => {
if (!warningFormRef.value) return; if (!warningFormRef.value) return;
@ -1415,7 +1453,9 @@ const submitWarning = async () => {
baseId, baseId,
isEnabled: warningForm.isEnabled, isEnabled: warningForm.isEnabled,
redThreshold: red, redThreshold: red,
yellowThreshold: yellow yellowThreshold: yellow,
redEmails: warningForm.redEmails || '',
yellowEmails: warningForm.yellowEmails || ''
})); }));
await batchSetWarning(data); await batchSetWarning(data);
@ -1463,17 +1503,13 @@ const submitBatchInspection = async () => {
// 表格行样式(根据预警状态) // 表格行样式(根据预警状态)
const tableRowClassName = ({ row }: { row: MaterialBaseVO }) => { const tableRowClassName = ({ row }: { row: MaterialBaseVO }) => {
// 只有拥有 view_warning 权限且有预警状态时才显示特殊样式 if (row.warningStatus === 2) {
if (!userStore.hasPermission('material_list:view_warning')) return ''; return 'danger-row'; // 红色预警
} else if (row.warningStatus === 1) {
const status = (row as any).warningStatus; return 'warning-row'; // 黄色预警
if (status === 2) {
return 'warning-row-red'; // 红色预警
} else if (status === 1) {
return 'warning-row-yellow'; // 黄色预警
} }
return ''; return '';
}; }
// --- 文件上传辅助函数 --- // --- 文件上传辅助函数 ---
@ -1715,34 +1751,32 @@ onMounted(() => {
.upload-file-item .el-upload-list__item-actions .el-icon { color: #fff; font-size: 20px; cursor: pointer; margin: 0 4px; } .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%; } .upload-add-trigger { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
/* 预警行样式 - 加深颜色 */ /* ================================================================
:deep(.warning-row-red) { Element Plus 表格预警行样式 & 固定列重叠修复
--el-table-tr-bg-color: #ffcdd2 !important; ================================================================ */
background-color: #ffcdd2 !important;
} /* 黄色预警行底色 (全覆盖) */
:deep(.warning-row-red td) { :deep(.el-table .warning-row),
background-color: transparent !important; :deep(.el-table .warning-row > td.el-table__cell) {
} background-color: #fcedc4 !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;
} }
/* 表单提示文字 */ /* 红色预警行底色 (全覆盖) */
.form-tip { :deep(.el-table .danger-row),
font-size: 12px; :deep(.el-table .danger-row > td.el-table__cell) {
color: #909399; background-color: #fcd3d3 !important; /* 明显的红色 */
margin-top: 4px;
line-height: 1.4;
} }
</style>
<style> /* 固定列的按钮容器底色跟随所在行的背景色,视觉无缝融合 */
/* 增加下拉框的最大高度,使其能容纳更多选项而不必频繁滚动 */ :deep(.el-table .el-table__cell.is-fixed) {
.long-dropdown .el-select-dropdown__wrap { background-color: inherit !important;
max-height: 600px !important; /* 可以根据屏幕大小适当调整 */ }
/* 按钮间距微调,更紧凑 */
:deep(.el-table .el-table__cell.is-fixed .cell) {
display: flex;
gap: 6px;
justify-content: flex-start; /* 左对齐更自然 */
flex-wrap: nowrap; /* 尽量不换行 */
} }
</style> </style>