Compare commits
5 Commits
183b93012e
...
2.0权限管理
| Author | SHA1 | Date | |
|---|---|---|---|
| 259f3a7e0d | |||
| 00839863f5 | |||
| 8276597a67 | |||
| 6ef425b9e4 | |||
| ccbce82c2e |
@ -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
|
||||||
|
|||||||
BIN
db_sync.sql.gz
BIN
db_sync.sql.gz
Binary file not shown.
BIN
deploy.tar.gz
BIN
deploy.tar.gz
Binary file not shown.
0
deploy_code.sh
Normal file → Executable file
0
deploy_code.sh
Normal file → Executable file
0
deploy_full.sh
Normal file → Executable file
0
deploy_full.sh
Normal file → Executable file
Binary file not shown.
@ -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. 预加载数据模型
|
||||||
# =========================================================
|
# =========================================================
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
69
inventory-backend/app/api/v1/scan/__init__.py
Normal file
69
inventory-backend/app/api/v1/scan/__init__.py
Normal 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
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
@ -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):
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
199
inventory-backend/app/services/inventory_task.py
Normal file
199
inventory-backend/app/services/inventory_task.py
Normal 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')
|
||||||
|
}
|
||||||
@ -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):
|
||||||
|
|||||||
@ -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}")
|
||||||
|
|||||||
@ -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')
|
||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -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">红色阈值 < 库存 ≤ 此值时显示黄色预警</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.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>
|
||||||
Reference in New Issue
Block a user