baseline: 批次A-D 成果 + membership 半成品(测试红)
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class NotifyConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.notify"
|
||||
verbose_name = "通知与预警中心"
|
||||
@@ -0,0 +1,75 @@
|
||||
# Generated by Django 5.2.12 on 2026-09-07 02:14
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('core', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AlertRule',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('ext_data', models.JSONField(blank=True, default=dict)),
|
||||
('source_channel', models.CharField(blank=True, default='manual', max_length=32)),
|
||||
('code', models.CharField(help_text='规则代码', max_length=64)),
|
||||
('name', models.CharField(help_text='预警规则名称', max_length=128)),
|
||||
('rule_type', models.CharField(choices=[('inventory_low', '库存缺货/下限预警'), ('receivable_overdue', '应收账款逾期催款预警'), ('stock_backlog', '商品呆滞积压预警')], max_length=32)),
|
||||
('threshold', models.DecimalField(decimal_places=4, default=10, help_text='预警阈值(如天数或数量)', max_digits=18)),
|
||||
('is_enabled', models.BooleanField(default=True)),
|
||||
('channels', models.JSONField(blank=True, default=list, help_text="通知渠道 ['in_app', 'webhook']")),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||
('org', models.ForeignKey(blank=True, help_text='所属组织(多机构预留)', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.org')),
|
||||
('tenant', models.ForeignKey(help_text='所属租户', on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.tenant')),
|
||||
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': '业务预警规则',
|
||||
'db_table': 'notify_alert_rule',
|
||||
'ordering': ['code'],
|
||||
'unique_together': {('tenant', 'code')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Notification',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('ext_data', models.JSONField(blank=True, default=dict)),
|
||||
('source_channel', models.CharField(blank=True, default='manual', max_length=32)),
|
||||
('title', models.CharField(max_length=128)),
|
||||
('content', models.TextField()),
|
||||
('category', models.CharField(choices=[('info', '系统通知'), ('warning', '业务预警'), ('error', '异常提醒'), ('audit', '审批待办')], default='info', max_length=16)),
|
||||
('link', models.CharField(blank=True, default='', help_text='跳转路由', max_length=255)),
|
||||
('is_read', models.BooleanField(default=False)),
|
||||
('read_at', models.DateTimeField(blank=True, null=True)),
|
||||
('extra_data', models.JSONField(blank=True, default=dict)),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||
('org', models.ForeignKey(blank=True, help_text='所属组织(多机构预留)', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.org')),
|
||||
('recipient', models.ForeignKey(blank=True, help_text='接收人(为空表示租户内广播)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL)),
|
||||
('tenant', models.ForeignKey(help_text='所属租户', on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.tenant')),
|
||||
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'notify_notification',
|
||||
'ordering': ['-created_at'],
|
||||
'indexes': [models.Index(fields=['tenant', 'recipient', 'is_read'], name='notify_noti_tenant__b3cbe4_idx'), models.Index(fields=['tenant', '-created_at'], name='notify_noti_tenant__f040f7_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.12 on 2026-09-08 08:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('notify', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='alertrule',
|
||||
name='rule_type',
|
||||
field=models.CharField(choices=[('inventory_low', '库存缺货/下限预警'), ('receivable_overdue', '应收账款逾期催款预警'), ('stock_backlog', '商品呆滞积压预警'), ('batch_expiry', '批次近效期预警')], max_length=32),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.12 on 2026-09-11 05:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('notify', '0002_alter_alertrule_rule_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='alertrule',
|
||||
name='rule_type',
|
||||
field=models.CharField(choices=[('inventory_low', '库存缺货/下限预警'), ('receivable_overdue', '应收账款逾期催款预警'), ('stock_backlog', '商品呆滞积压预警'), ('batch_expiry', '批次近效期预警'), ('risk_score', 'AI 应收风险预警(threshold=风险分数线)')], max_length=32),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,74 @@
|
||||
"""消息通知与智能预警模型:
|
||||
1. Notification 站内消息/通知
|
||||
2. AlertRule 业务预警规则(库存低限预警、应收账款逾期催收预警等)
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from apps.core.base_models import TenantScopedModel
|
||||
|
||||
|
||||
class Notification(TenantScopedModel):
|
||||
"""消息通知。"""
|
||||
|
||||
CATEGORY_CHOICES = [
|
||||
("info", "系统通知"),
|
||||
("warning", "业务预警"),
|
||||
("error", "异常提醒"),
|
||||
("audit", "审批待办"),
|
||||
]
|
||||
|
||||
recipient = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="notifications",
|
||||
help_text="接收人(为空表示租户内广播)",
|
||||
)
|
||||
title = models.CharField(max_length=128)
|
||||
content = models.TextField()
|
||||
category = models.CharField(max_length=16, choices=CATEGORY_CHOICES, default="info")
|
||||
link = models.CharField(max_length=255, blank=True, default="", help_text="跳转路由")
|
||||
is_read = models.BooleanField(default=False)
|
||||
read_at = models.DateTimeField(null=True, blank=True)
|
||||
extra_data = models.JSONField(default=dict, blank=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "notify_notification"
|
||||
indexes = [
|
||||
models.Index(fields=["tenant", "recipient", "is_read"]),
|
||||
models.Index(fields=["tenant", "-created_at"]),
|
||||
]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"[{self.get_category_display()}] {self.title} (已读: {self.is_read})"
|
||||
|
||||
|
||||
class AlertRule(TenantScopedModel):
|
||||
"""业务预警规则配置。"""
|
||||
|
||||
RULE_TYPE_CHOICES = [
|
||||
("inventory_low", "库存缺货/下限预警"),
|
||||
("receivable_overdue", "应收账款逾期催款预警"),
|
||||
("stock_backlog", "商品呆滞积压预警"),
|
||||
("batch_expiry", "批次近效期预警"),
|
||||
("risk_score", "AI 应收风险预警(threshold=风险分数线)"),
|
||||
]
|
||||
|
||||
code = models.CharField(max_length=64, help_text="规则代码")
|
||||
name = models.CharField(max_length=128, help_text="预警规则名称")
|
||||
rule_type = models.CharField(max_length=32, choices=RULE_TYPE_CHOICES)
|
||||
threshold = models.DecimalField(max_digits=18, decimal_places=4, default=10, help_text="预警阈值(如天数或数量)")
|
||||
is_enabled = models.BooleanField(default=True)
|
||||
channels = models.JSONField(default=list, blank=True, help_text="通知渠道 ['in_app', 'webhook']")
|
||||
|
||||
class Meta:
|
||||
db_table = "notify_alert_rule"
|
||||
unique_together = [("tenant", "code")]
|
||||
verbose_name_plural = "业务预警规则"
|
||||
ordering = ["code"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} (阈值: {self.threshold}) [{ '启用' if self.is_enabled else '停用' }]"
|
||||
@@ -0,0 +1,27 @@
|
||||
from rest_framework import serializers
|
||||
from apps.core.serializers import AsyncModelSerializer
|
||||
from .models import Notification, AlertRule
|
||||
|
||||
|
||||
class NotificationSerializer(AsyncModelSerializer):
|
||||
category_name = serializers.CharField(source="get_category_display", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Notification
|
||||
fields = [
|
||||
"id", "title", "content", "category", "category_name",
|
||||
"link", "is_read", "read_at", "extra_data", "created_at",
|
||||
]
|
||||
read_only_fields = ["id", "created_at"]
|
||||
|
||||
|
||||
class AlertRuleSerializer(AsyncModelSerializer):
|
||||
rule_type_name = serializers.CharField(source="get_rule_type_display", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = AlertRule
|
||||
fields = [
|
||||
"id", "code", "name", "rule_type", "rule_type_name",
|
||||
"threshold", "is_enabled", "channels", "created_at", "updated_at",
|
||||
]
|
||||
read_only_fields = ["id", "created_at", "updated_at"]
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"""预警调度任务 actors(Dramatiq)。"""
|
||||
|
||||
import dramatiq
|
||||
|
||||
from apps.core.models import Tenant
|
||||
from . import services as notify_services
|
||||
|
||||
|
||||
@dramatiq.actor(max_retries=3, time_limit=300_000)
|
||||
def run_alert_checks_for_tenant(tenant_id: int):
|
||||
"""单租户业务预警扫描(库存低限 + 应收逾期)。"""
|
||||
tenant = Tenant.objects.get(pk=tenant_id, is_active=True)
|
||||
result = notify_services.run_all_alert_checks(tenant)
|
||||
return result
|
||||
|
||||
|
||||
@dramatiq.actor(max_retries=3, time_limit=600_000)
|
||||
def run_all_alert_checks_for_all_tenants():
|
||||
"""遍历全部活跃租户执行业务预警扫描(由 crontab 每日 08:00 触发)。"""
|
||||
summary = []
|
||||
for tenant in Tenant.objects.filter(is_active=True):
|
||||
result = notify_services.run_all_alert_checks(tenant)
|
||||
summary.append({"tenant": tenant.code, **result})
|
||||
return summary
|
||||
@@ -0,0 +1,14 @@
|
||||
"""通知中心路由。"""
|
||||
|
||||
from django.urls import path, include
|
||||
from adrf.routers import DefaultRouter as ADRouter
|
||||
from .views import NotificationViewSet, AlertRuleViewSet
|
||||
|
||||
|
||||
router = ADRouter()
|
||||
router.register("messages", NotificationViewSet, basename="notify-message")
|
||||
router.register("rules", AlertRuleViewSet, basename="notify-rule")
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
@@ -0,0 +1,93 @@
|
||||
"""消息通知与预警 ViewSets:
|
||||
1. NotificationViewSet: 查询、标记已读、全部已读
|
||||
2. AlertRuleViewSet: 预警规则维护、触发全量检测
|
||||
"""
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from asgiref.sync import sync_to_async
|
||||
|
||||
from apps.core.viewset import BaseTenantViewSet
|
||||
from .models import Notification, AlertRule
|
||||
from .serializers import NotificationSerializer, AlertRuleSerializer
|
||||
from . import services as notify_services
|
||||
|
||||
|
||||
class NotificationViewSet(BaseTenantViewSet):
|
||||
model = Notification
|
||||
serializer_class = NotificationSerializer
|
||||
search_fields = ["title", "content"]
|
||||
|
||||
async def get_queryset(self):
|
||||
qs = await super().get_queryset()
|
||||
# 仅查全员广播或当前用户的通知
|
||||
user = self.request.user
|
||||
if user.is_authenticated:
|
||||
qs = qs.filter(Q(recipient__isnull=True) | Q(recipient=user))
|
||||
else:
|
||||
# Keep the queryset safe even if this view is reused without the
|
||||
# default IsAuthenticated permission.
|
||||
qs = qs.none()
|
||||
return qs
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="mark-read")
|
||||
async def mark_read(self, request, pk=None):
|
||||
"""标为已读。"""
|
||||
# Use the same tenant and recipient scope as list/retrieve. This
|
||||
# makes another user's notification indistinguishable from a missing
|
||||
# notification instead of allowing its state to be changed.
|
||||
queryset = await self.get_queryset()
|
||||
try:
|
||||
notification = await sync_to_async(queryset.get)(pk=pk)
|
||||
except self.model.DoesNotExist:
|
||||
return Response({"detail": "通知不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
notification.is_read = True
|
||||
notification.read_at = timezone.now()
|
||||
await sync_to_async(notification.save)(update_fields=["is_read", "read_at"])
|
||||
return Response({"ok": True, "id": notification.id, "is_read": True})
|
||||
|
||||
@action(detail=False, methods=["post"], url_path="mark-all-read")
|
||||
async def mark_all_read(self, request):
|
||||
"""全部标为已读。"""
|
||||
tenant = await self.get_tenant()
|
||||
if not tenant:
|
||||
raise ValidationError({"tenant": "无法识别租户"})
|
||||
|
||||
user = request.user
|
||||
if not user.is_authenticated:
|
||||
return Response({"ok": True, "updated_count": 0})
|
||||
updated = await sync_to_async(
|
||||
self.model.objects.filter(tenant=tenant, is_read=False)
|
||||
.filter(Q(recipient__isnull=True) | Q(recipient=user))
|
||||
.update
|
||||
)(is_read=True, read_at=timezone.now())
|
||||
return Response({"ok": True, "updated_count": updated})
|
||||
|
||||
|
||||
class AlertRuleViewSet(BaseTenantViewSet):
|
||||
model = AlertRule
|
||||
serializer_class = AlertRuleSerializer
|
||||
search_fields = ["code", "name"]
|
||||
|
||||
@action(detail=False, methods=["post"], url_path="init-default")
|
||||
async def init_default(self, request):
|
||||
"""初始化默认预警规则。"""
|
||||
tenant = await self.get_tenant()
|
||||
if not tenant:
|
||||
raise ValidationError({"tenant": "无法识别租户"})
|
||||
n = await sync_to_async(notify_services.init_default_alert_rules)(tenant)
|
||||
return Response({"ok": True, "created_count": n})
|
||||
|
||||
@action(detail=False, methods=["post"], url_path="run-checks")
|
||||
async def run_checks(self, request):
|
||||
"""主动触发全量业务智能预警扫描(库存低限、应收逾期)。"""
|
||||
tenant = await self.get_tenant()
|
||||
if not tenant:
|
||||
raise ValidationError({"tenant": "无法识别租户"})
|
||||
result = await sync_to_async(notify_services.run_all_alert_checks)(tenant)
|
||||
return Response({"ok": True, "result": result})
|
||||
Reference in New Issue
Block a user