Files

301 lines
9.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""通知与预警服务:
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,
}