4.29扫码获取库位小工具接口
This commit is contained in:
@ -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:
|
||||
|
||||
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)
|
||||
|
||||
# ★ 出库后检查低库存预警
|
||||
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-已完成
|
||||
|
||||
Reference in New Issue
Block a user