baseline: 批次A-D 成果 + membership 半成品(测试红)
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
"""通知与预警服务:
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user