baseline: 批次A-D 成果 + membership 半成品(测试红)

This commit is contained in:
agent
2026-09-11 23:11:35 +08:00
commit b3f3095d53
311 changed files with 40540 additions and 0 deletions
View File
+7
View File
@@ -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),
),
]
+74
View File
@@ -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 '停用' }]"
+27
View File
@@ -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"]
+300
View File
@@ -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,
}
+24
View File
@@ -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
+14
View File
@@ -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)),
]
+93
View File
@@ -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})