301 lines
9.9 KiB
Python
301 lines
9.9 KiB
Python
"""通知与预警服务:
|
||
1. send_notification: 发送站内消息
|
||
2. init_default_alert_rules: 初始化默认预警规则
|
||
3. check_inventory_low_alerts: 智能库存缺货预警检测
|
||
4. check_receivable_overdue_alerts: 应收账款逾期催收预警
|
||
"""
|
||
|
||
from datetime import date, timedelta, datetime, timezone
|
||
from decimal import Decimal
|
||
from typing import Optional
|
||
|
||
from apps.inventory.models import Stock
|
||
from apps.finance.models import Receivable
|
||
from .models import Notification, AlertRule
|
||
|
||
|
||
DEFAULT_ALERT_RULES = [
|
||
{
|
||
"code": "stock_low_limit",
|
||
"name": "库存缺货预警(低于10件)",
|
||
"rule_type": "inventory_low",
|
||
"threshold": Decimal("10.00"),
|
||
"channels": ["in_app"],
|
||
},
|
||
{
|
||
"code": "receivable_overdue_30d",
|
||
"name": "应收账款逾期催收预警",
|
||
"rule_type": "receivable_overdue",
|
||
"threshold": Decimal("0.00"), # 到期日 < 今天 即告警
|
||
"channels": ["in_app"],
|
||
},
|
||
{
|
||
"code": "ai_risk_score",
|
||
"name": "AI 应收风险预警(风险分 ≥70)",
|
||
"rule_type": "risk_score",
|
||
"threshold": Decimal("70.00"), # high 档起点
|
||
"channels": ["in_app"],
|
||
},
|
||
]
|
||
|
||
|
||
def init_default_alert_rules(tenant) -> int:
|
||
"""初始化租户默认预警规则。"""
|
||
created_count = 0
|
||
for r in DEFAULT_ALERT_RULES:
|
||
_, was_created = AlertRule.objects.get_or_create(
|
||
tenant=tenant,
|
||
code=r["code"],
|
||
defaults={
|
||
"name": r["name"],
|
||
"rule_type": r["rule_type"],
|
||
"threshold": r["threshold"],
|
||
"channels": r["channels"],
|
||
"is_enabled": True,
|
||
},
|
||
)
|
||
if was_created:
|
||
created_count += 1
|
||
return created_count
|
||
|
||
|
||
def send_notification(
|
||
tenant,
|
||
title: str,
|
||
content: str,
|
||
*,
|
||
recipient=None,
|
||
category: str = "info",
|
||
link: str = "",
|
||
extra_data: Optional[dict] = None,
|
||
) -> Notification:
|
||
"""创建并发送一条消息通知。"""
|
||
return Notification.objects.create(
|
||
tenant=tenant,
|
||
recipient=recipient,
|
||
title=title,
|
||
content=content,
|
||
category=category,
|
||
link=link,
|
||
extra_data=extra_data or {},
|
||
)
|
||
|
||
|
||
def check_inventory_low_alerts(tenant) -> int:
|
||
"""扫描库存,对低于安全阈值的商品生成预警。"""
|
||
rule = AlertRule.objects.filter(
|
||
tenant=tenant, rule_type="inventory_low", is_enabled=True
|
||
).first()
|
||
threshold = rule.threshold if rule else Decimal("10.00")
|
||
|
||
# 查可用库存 (on_hand - locked) <= threshold 的记录
|
||
low_stocks = Stock.objects.filter(
|
||
tenant=tenant,
|
||
).select_related("product", "warehouse")
|
||
|
||
triggered_count = 0
|
||
for s in low_stocks:
|
||
available = s.on_hand - s.locked
|
||
if available <= threshold:
|
||
# 避免当天对同一商品重复发相同预警
|
||
today_str = date.today().strftime("%Y-%m-%d")
|
||
exists = Notification.objects.filter(
|
||
tenant=tenant,
|
||
category="warning",
|
||
extra_data__product_id=s.product_id,
|
||
extra_data__warehouse_id=s.warehouse_id,
|
||
extra_data__date=today_str,
|
||
).exists()
|
||
if not exists:
|
||
send_notification(
|
||
tenant=tenant,
|
||
title=f"【库存预警】{s.product.name} 可用库存偏低",
|
||
content=f"仓库 [{s.warehouse.name}] 内商品 [{s.product.code} {s.product.name}] 可用库存仅剩 {available}(安全阈值: {threshold}),请及时安排采购补货。",
|
||
category="warning",
|
||
link="/inventory/stocks/",
|
||
extra_data={
|
||
"product_id": s.product_id,
|
||
"warehouse_id": s.warehouse_id,
|
||
"date": today_str,
|
||
},
|
||
)
|
||
triggered_count += 1
|
||
|
||
return triggered_count
|
||
|
||
|
||
def check_receivable_overdue_alerts(tenant) -> int:
|
||
"""扫描应收账款,对超期未结清账款生成催款通知。"""
|
||
rule = AlertRule.objects.filter(
|
||
tenant=tenant, rule_type="receivable_overdue", is_enabled=True
|
||
).first()
|
||
if not rule:
|
||
return 0
|
||
|
||
today = date.today()
|
||
overdue_list = Receivable.objects.filter(
|
||
tenant=tenant,
|
||
status__in=["open", "partial"],
|
||
due_date__isnull=False,
|
||
due_date__lt=today,
|
||
).select_related("customer")
|
||
|
||
triggered = 0
|
||
for r in overdue_list:
|
||
today_str = today.strftime("%Y-%m-%d")
|
||
exists = Notification.objects.filter(
|
||
tenant=tenant,
|
||
category="warning",
|
||
extra_data__receivable_id=r.id,
|
||
extra_data__date=today_str,
|
||
).exists()
|
||
if not exists:
|
||
send_notification(
|
||
tenant=tenant,
|
||
title=f"【催款提醒】客户 {r.customer.name} 应收账款已逾期",
|
||
content=f"应收单 {r.bill_no}(金额 {r.total_amount},剩余未结 {r.balance})已于 {r.due_date} 到期,目前逾期未回款,请业务员及时跟进。",
|
||
category="warning",
|
||
link="/finance/receivables/",
|
||
extra_data={
|
||
"receivable_id": r.id,
|
||
"date": today_str,
|
||
},
|
||
)
|
||
triggered += 1
|
||
|
||
return triggered
|
||
|
||
|
||
def check_batch_expiry_alerts(tenant) -> int:
|
||
"""扫描批次库存,对 N 天内到期的在库批次生成近效期预警。
|
||
|
||
threshold 即预警提前天数(默认 30);按(批次+当日)去重。
|
||
"""
|
||
from apps.inventory.models import StockBatch
|
||
|
||
rule = AlertRule.objects.filter(
|
||
tenant=tenant, rule_type="batch_expiry", is_enabled=True
|
||
).first()
|
||
if not rule:
|
||
return 0
|
||
|
||
try:
|
||
days = int(rule.threshold)
|
||
except (TypeError, ValueError):
|
||
days = 30
|
||
today = date.today()
|
||
deadline = today + timedelta(days=days)
|
||
|
||
soon_expired = StockBatch.objects.filter(
|
||
tenant=tenant,
|
||
on_hand__gt=0,
|
||
expiry_date__isnull=False,
|
||
expiry_date__lte=deadline,
|
||
).select_related("product", "warehouse")
|
||
|
||
triggered = 0
|
||
for b in soon_expired:
|
||
today_str = today.strftime("%Y-%m-%d")
|
||
exists = Notification.objects.filter(
|
||
tenant=tenant,
|
||
category="warning",
|
||
extra_data__batch_id=b.id,
|
||
extra_data__date=today_str,
|
||
).exists()
|
||
if exists:
|
||
continue
|
||
days_left = (b.expiry_date - today).days
|
||
send_notification(
|
||
tenant=tenant,
|
||
title=f"【近效期】{b.product.name} 批次 {b.batch_no} 将于 {b.expiry_date} 到期",
|
||
content=(
|
||
f"仓库 {b.warehouse.name} 批次 {b.batch_no} 现存 {b.on_hand},"
|
||
f"距到期还有 {days_left} 天(预警提前 {days} 天),请优先出库或促销处理。"
|
||
),
|
||
category="warning",
|
||
link="/inventory/stocks/",
|
||
extra_data={
|
||
"batch_id": b.id,
|
||
"product_id": b.product_id,
|
||
"warehouse_id": b.warehouse_id,
|
||
"expiry_date": b.expiry_date.isoformat(),
|
||
"date": today_str,
|
||
},
|
||
)
|
||
triggered += 1
|
||
return triggered
|
||
|
||
|
||
def check_risk_score_alerts(tenant) -> int:
|
||
"""扫描客户应收风险(AI 统计评分),对超过规则分数线的高风险客户发预警。
|
||
|
||
threshold 即风险分数线(默认 70 = high 档起点);按(客户+当日)去重。
|
||
"""
|
||
from apps.ai import risk as ai_risk
|
||
|
||
rule = AlertRule.objects.filter(
|
||
tenant=tenant, rule_type="risk_score", is_enabled=True
|
||
).first()
|
||
if not rule:
|
||
return 0
|
||
|
||
try:
|
||
threshold = Decimal(str(rule.threshold))
|
||
except (TypeError, ValueError):
|
||
threshold = Decimal("70")
|
||
|
||
today = date.today()
|
||
today_str = today.strftime("%Y-%m-%d")
|
||
triggered = 0
|
||
|
||
for item in ai_risk.risk_ranking(tenant, top_n=50):
|
||
if Decimal(str(item["score"])) < threshold:
|
||
continue
|
||
exists = Notification.objects.filter(
|
||
tenant=tenant,
|
||
category="warning",
|
||
extra_data__customer_id=item["customer_id"],
|
||
extra_data__rule="risk_score",
|
||
extra_data__date=today_str,
|
||
).exists()
|
||
if exists:
|
||
continue
|
||
factors = item["factors"]
|
||
send_notification(
|
||
tenant=tenant,
|
||
title=f"【应收风险】客户 {item['customer_name']} 风险分 {item['score']:.0f}({item['level']})",
|
||
content=(
|
||
f"未结 ¥{item['outstanding']:.2f}。"
|
||
f"回款周期:{factors['cycle_drift']['reason']};"
|
||
f"欠款趋势:{factors['outstanding_trend']['reason']};"
|
||
f"开单频次:{factors['order_drop']['reason']}。建议尽快对账催收。"
|
||
),
|
||
category="warning",
|
||
link="/customers",
|
||
extra_data={
|
||
"customer_id": item["customer_id"],
|
||
"customer_code": item["customer_code"],
|
||
"score": item["score"],
|
||
"level": item["level"],
|
||
"rule": "risk_score",
|
||
"date": today_str,
|
||
},
|
||
)
|
||
triggered += 1
|
||
return triggered
|
||
|
||
|
||
def run_all_alert_checks(tenant) -> dict:
|
||
"""执行全部业务智能预警检查。"""
|
||
stock_alerts = check_inventory_low_alerts(tenant)
|
||
ar_alerts = check_receivable_overdue_alerts(tenant)
|
||
batch_alerts = check_batch_expiry_alerts(tenant)
|
||
risk_alerts = check_risk_score_alerts(tenant)
|
||
return {
|
||
"stock_alerts_count": stock_alerts,
|
||
"receivable_alerts_count": ar_alerts,
|
||
"batch_expiry_alerts_count": batch_alerts,
|
||
"risk_score_alerts_count": risk_alerts,
|
||
"total_alerts": stock_alerts + ar_alerts + batch_alerts + risk_alerts,
|
||
}
|