1
0
forked from erp-dev/erp

feat: mission category

This commit is contained in:
2026-04-14 00:03:22 +08:00
parent 9512132bb9
commit 0167478a25
18 changed files with 852 additions and 354 deletions

View File

@@ -1681,6 +1681,32 @@ class ShipmentQueryAPITestCase(TestCase):
self.assertIn("external_finished_products", item)
self.assertIsInstance(item["external_finished_products"], list)
def test_list_shipments_supports_only_address_null_filter(self):
self.shipment1.address = ""
self.shipment1.save(update_fields=["address", "updated_at"])
self.shipment2.address = ""
self.shipment2.save(update_fields=["address", "updated_at"])
shipment_with_address = shipment_models.Shipment.objects.create(
merchant=self.merchant1,
customer=self.customer1,
shipment_date="2026-01-16",
created_by=self.user1,
address="绍兴市测试路 8 号",
)
resp_true = self.client.get("/api/v1/shipment/shipments/?only_address_null=true")
self.assertEqual(resp_true.status_code, status.HTTP_200_OK)
true_ids = [it["id"] for it in resp_true.json()["results"]]
self.assertIn(self.shipment1.id, true_ids)
self.assertNotIn(shipment_with_address.id, true_ids)
resp_false = self.client.get("/api/v1/shipment/shipments/?only_address_null=false")
self.assertEqual(resp_false.status_code, status.HTTP_200_OK)
false_ids = [it["id"] for it in resp_false.json()["results"]]
self.assertIn(shipment_with_address.id, false_ids)
self.assertNotIn(self.shipment1.id, false_ids)
def test_retrieve_shipment_success(self):
resp = self.client.get(f"/api/v1/shipment/shipments/{self.shipment1.id}/")
self.assertEqual(resp.status_code, status.HTTP_200_OK)

View File

@@ -164,6 +164,14 @@ class ShipmentListCreateView(ListModelMixin, GenericAPIView):
# 通过 <= 过滤日期
qs = qs.filter(shipment_date__lte=date_to)
only_address_null = self.request.query_params.get("only_address_null")
if only_address_null is not None:
normalized = only_address_null.strip().lower()
if normalized in {"1", "true", "yes"}:
qs = qs.filter(address="")
elif normalized in {"0", "false", "no"}:
qs = qs.exclude(address="")
return qs.order_by("-created_at", "-id")
def get(self, request):

View File

@@ -194,3 +194,33 @@ class VehicleTransportRecordAdmin(AdminBase):
list_display = ('driver_name', 'contact_number', 'delivery_date')
search_fields = ('driver_name', 'contact_number')
list_filter = ('vehicle_type',)
class TransportVehicleMaterialCapacityInline(admin.TabularInline):
model = models.TransportVehicleMaterialCapacity
extra = 0
fields = ('material_name', 'capacity')
@admin.register(models.TransportVehicle)
class TransportVehicleAdmin(AdminBase):
inlines = [TransportVehicleMaterialCapacityInline]
list_display = ('name', 'license_plate')
search_fields = ('name', 'license_plate')
def save_formset(self, request, form, formset, change):
instances = formset.save(commit=False)
for obj in formset.deleted_objects:
obj.delete()
for instance in instances:
if isinstance(instance, models.TransportVehicleMaterialCapacity):
instance.merchant = form.instance.merchant
instance.save()
formset.save_m2m()
@admin.register(models.TransportVehicleMaterialCapacity)
class TransportVehicleMaterialCapacityAdmin(AdminBase):
list_display = ('transport_vehicle', 'material_name', 'capacity')
search_fields = ('transport_vehicle__name', 'transport_vehicle__license_plate', 'material_name')
list_filter = ('transport_vehicle',)

View File

@@ -0,0 +1,45 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('basic_info', '0025_customer_uniq_customer_merchant_name'),
]
operations = [
migrations.CreateModel(
name='TransportVehicle',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, verbose_name='车辆名称')),
('license_plate', models.CharField(max_length=20, verbose_name='车牌号码')),
('merchant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transport_vehicles', to='basic_info.merchant', verbose_name='所属商户')),
],
options={
'verbose_name': '运输车辆',
'verbose_name_plural': '运输车辆',
'unique_together': {('merchant', 'license_plate')},
},
),
migrations.CreateModel(
name='TransportVehicleMaterialCapacity',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('id', models.BigAutoField(primary_key=True, serialize=False)),
('material_name', models.CharField(max_length=100, verbose_name='物料名')),
('capacity', models.PositiveIntegerField(verbose_name='容量(条)')),
('merchant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transport_vehicle_material_capacities', to='basic_info.merchant', verbose_name='所属商户')),
('transport_vehicle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='material_capacities', to='basic_info.transportvehicle', verbose_name='运输车辆')),
],
options={
'verbose_name': '运输车辆物料容量',
'verbose_name_plural': '运输车辆物料容量',
'unique_together': {('transport_vehicle', 'material_name')},
},
),
]

View File

@@ -574,6 +574,42 @@ class VehicleTransportRecord(ModelBase):
verbose_name_plural = '司机车次'
class TransportVehicle(ModelBase):
id = models.BigAutoField(primary_key=True)
merchant = models.ForeignKey('Merchant', on_delete=models.PROTECT, related_name='transport_vehicles', verbose_name='所属商户')
name = models.CharField(max_length=100, verbose_name='车辆名称')
license_plate = models.CharField(max_length=20, verbose_name='车牌号码')
def __str__(self):
return f'{self.name} ({self.license_plate})'
class Meta:
verbose_name = '运输车辆'
verbose_name_plural = '运输车辆'
unique_together = ('merchant', 'license_plate')
class TransportVehicleMaterialCapacity(ModelBase):
id = models.BigAutoField(primary_key=True)
merchant = models.ForeignKey('Merchant', on_delete=models.PROTECT, related_name='transport_vehicle_material_capacities', verbose_name='所属商户')
transport_vehicle = models.ForeignKey(
TransportVehicle,
on_delete=models.CASCADE,
related_name='material_capacities',
verbose_name='运输车辆',
)
material_name = models.CharField(max_length=100, verbose_name='物料名')
capacity = models.PositiveIntegerField(verbose_name='容量(条)')
def __str__(self):
return f'{self.transport_vehicle.name}-{self.material_name}:{self.capacity}'
class Meta:
verbose_name = '运输车辆物料容量'
verbose_name_plural = '运输车辆物料容量'
unique_together = ('transport_vehicle', 'material_name')
class MerchantSettingTypeEnum(models.TextChoices):
"""商户设置类型枚举"""
STR = 'str', '字符串'

View File

@@ -7,7 +7,7 @@ from django.core.exceptions import ValidationError
from .models import (
Merchant, MerchantTypeEnum, WareHouse, WarehouseTypeEnum,
WareHouseModeEnum, EmployeeType, Employee, EmployeeStatusEnum,
FrontendPage, FrontendPageTypeEnum
FrontendPage, FrontendPageTypeEnum, TransportVehicle, TransportVehicleMaterialCapacity
)
@@ -171,6 +171,45 @@ class EmployeeTypeTestCase(TestCase):
self.assertEqual(emp_type.employees.count(), 2)
class TransportVehicleTestCase(TestCase):
def setUp(self):
self.merchant = Merchant.objects.create(
name='运输商户',
type=MerchantTypeEnum.STORE
)
def test_create_transport_vehicle_with_material_capacity(self):
vehicle = TransportVehicle.objects.create(
merchant=self.merchant,
name='9.6米厢车',
license_plate='浙A12345'
)
capacity = TransportVehicleMaterialCapacity.objects.create(
merchant=self.merchant,
transport_vehicle=vehicle,
material_name='棉布',
capacity=120
)
self.assertEqual(vehicle.license_plate, '浙A12345')
self.assertEqual(str(vehicle), '9.6米厢车 (浙A12345)')
self.assertEqual(capacity.capacity, 120)
self.assertEqual(vehicle.material_capacities.count(), 1)
def test_transport_vehicle_license_plate_unique_per_merchant(self):
TransportVehicle.objects.create(
merchant=self.merchant,
name='一号车',
license_plate='浙A99999'
)
with self.assertRaises(Exception):
TransportVehicle.objects.create(
merchant=self.merchant,
name='二号车',
license_plate='浙A99999'
)
class FrontendPageTestCase(TestCase):
"""测试前端页面模型"""

View File

@@ -1,84 +1,83 @@
# Notifier 管理人员配置说明
本文档面向后台管理人员,说明如何在 Django Admin 中配置 `Notifier`,让系统在指定业务事件发生时自动发送通知。
本文档面向后台管理人员,说明如何在 Django Admin 中配置 `Notifier``NotifierRoute`,让系统在指定业务事件发生时自动发送通知,并支持按任务分类分发
## 1. Notifier 是什么
## 1. 两层结构
`Notifier` 可以理解为一条“通知规则”。
当前通知配置分成两层:
当某个业务事件发生时,系统会根据以下条件查找可用的通知器:
- `Notifier`:通知器本体,负责“发到哪里、用什么模板、走什么渠道”
- `NotifierRoute`:通知路由,负责“什么事件、什么任务分类,应该命中哪个 Notifier”
- 商户一致
- `event_key` 一致
- `is_enabled=True`
可以这样理解:
找到后,系统会自动:
- `Notifier` 是“发信工具”
- `NotifierRoute` 是“分发规则”
1. 使用对应模板渲染消息内容
2. 按配置的渠道发送
3. 记录发送日志
当前已支持的渠道:
- 企业微信机器人 `wecom_webhook`
## 2. 当前已支持的事件
## 2. 当前已支持的 mission 事件
目前 `mission` 模块已接入以下事件:
- `mission.created`:任务创建
- `mission.replied`:任务有新回应
- `mission.completed`:任务完成
- `mission.reply_rejected`:任务回应被撤销
- `mission.reopened`:任务被重新打开
- `mission.cancelled`:任务被取消
- `mission.created`
- `mission.replied`
- `mission.completed`
- `mission.reply_rejected`
- `mission.reopened`
- `mission.cancelled`
如果要让某个事件发送通知,只需要在后台新增对应 `event_key``Notifier`
所有这些事件都支持通过 `NotifierRoute` 进行分类路由
## 3. 在哪里配置
## 3. 当前支持的分类路由能力
进入 Django Admin 后,找到
路由匹配规则如下
1. 先按 `merchant + event_key + is_enabled=True` 匹配启用中的路由
2. 如果当前任务带有分类:
- 优先匹配该分类的专用路由
- 同时允许匹配“任务分类为空”的通配路由
3. 如果同一个 `Notifier` 同时命中了专用路由和通配路由,只发送一次,优先使用专用路由
这意味着你可以实现:
- 某个分类发到专门群
- 未单独配置的分类发到通用群
- 同一事件同时发多个群
## 4. Admin 中的两个入口
进入 Django Admin 后,主要会看到两个对象:
- `通知器`
- `通知路由`
然后点击“新增”即可。
推荐的管理方式:
## 4. 字段说明
1. 先创建 `Notifier`
2. 再创建或维护它的 `NotifierRoute`
创建 `Notifier` 时,需要填写以下字段
`Notifier` 详情页中,也可以直接通过 inline 管理该通知器下的路由
### 4.1 merchant
## 5. Notifier 字段说明
### 5.1 merchant
所属商户。
通知器只会匹配当前商户下发生的事件。
不同商户如果都需要通知,需要分别创建各自的 `Notifier`
通知器只会服务于该商户下的路由和事件。
### 4.2 name
### 5.2 name
通知器名称,仅用于后台识别和管理。
建议命名方式:
- `任务创建通知-生产群`
- `任务完成通知-老板群`
- `任务取消通知-客服群`
- `任务通知-生产群`
- `任务通知-售后群`
- `任务通知-管理群`
同一商户下名称不能重复。
### 4.3 event_key
要监听的业务事件标识。
这是最核心的绑定字段。
它决定这条 `Notifier` 绑定到哪个业务信号。
例如:
- `mission.completed` 表示“任务完成时发送”
- `mission.cancelled` 表示“任务取消时发送”
### 4.4 channel
### 5.3 channel
通知渠道。
@@ -86,12 +85,12 @@
- `wecom_webhook`
### 4.5 template_key
### 5.4 template_key
消息模板标识。
模板标识。
系统会根据这个字段去固定目录查找模板文件。
当前模板目录
当前模板目录:
`notifier/templates/notifier/events/`
@@ -100,18 +99,18 @@
- `template_key = mission_completed`
- 对应模板文件:`notifier/templates/notifier/events/mission_completed.md`
### 4.6 is_enabled
### 5.5 is_enabled
是否启用。
是否启用通知器本体
- 勾选:该 `Notifier` 生效
- 不勾选:`Notifier` 不会参与匹配和发送
- 勾选:该通知器可被路由命中
- 不勾选:即使路由存在,也不会发送
### 4.7 config
### 5.6 config
渠道配置,使用 JSON 格式填写
渠道配置JSON 格式。
当前企业微信机器人建议配置如下
当前企业微信机器人建议配置:
```json
{
@@ -121,105 +120,134 @@
}
```
字段说明:
- `key`:企业微信机器人 webhook key
- `msgtype`:消息类型,建议用 `markdown`
- `timeout_seconds`:请求超时时间,单位秒
### 4.8 description
### 5.7 description
备注说明,非必填。
建议写清楚这条通知器的用途,例如:
## 6. NotifierRoute 字段说明
- `用于生产部任务完成通知`
- `用于客服查看任务取消`
### 6.1 merchant
## 5. 配置步骤
所属商户。
以“任务完成时发送企业微信通知”为例:
必须与关联的 `Notifier` 属于同一商户。
1. 进入 Admin 的 `通知器`
2. 点击“新增”
3. 选择 `merchant`
4. 填写 `name`
5. 选择 `event_key = mission.completed`
6. 选择 `channel = wecom_webhook`
7. 填写 `template_key = mission_completed`
8.`config` 中填写 webhook 参数
9. 勾选 `is_enabled`
10. 保存
### 6.2 notifier
保存后,只要该商户下发生“任务完成”事件,系统就会自动尝试发送通知。
要使用的通知
## 6. 推荐配置示例
### 6.3 event_key
### 6.1 示例一:任务创建通知
要监听的业务事件。
适合发到内部任务协作群。
例如:
字段建议:
- `mission.created`
- `mission.completed`
- `name`: `任务创建通知-协作群`
- `event_key`: `mission.created`
- `channel`: `wecom_webhook`
- `template_key`: `mission_created`
- `is_enabled`: 勾选
### 6.4 mission_category
`config` 示例:
任务分类路由条件。
```json
{
"key": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"msgtype": "markdown",
"timeout_seconds": 10
}
```
- 为空:表示该事件的通配路由,适用于所有未被更具体路由覆盖的任务分类
- 不为空:表示只处理该任务分类下的任务事件
### 6.2 示例二:任务完成通知
### 6.5 is_enabled
适合发到管理群或老板群
是否启用该路由
字段建议:
- 勾选:路由参与匹配
- 不勾选:路由不会命中
### 6.6 description
备注说明,建议写清楚用途,例如:
- `任务创建-生产分类专用路由`
- `任务完成-所有分类默认路由`
## 7. 推荐配置步骤
以“任务创建时,生产分类发生产群,其他分类发管理群”为例:
### 第一步:创建两个 Notifier
1. 创建 `Notifier A`
- `name = 任务通知-生产群`
- `channel = wecom_webhook`
- `template_key = mission_created`
- `config` 填生产群机器人 key
- `is_enabled = True`
2. 创建 `Notifier B`
- `name = 任务通知-管理群`
- `channel = wecom_webhook`
- `template_key = mission_created`
- `config` 填管理群机器人 key
- `is_enabled = True`
### 第二步:创建路由
1. 创建 `NotifierRoute A1`
- `notifier = Notifier A`
- `event_key = mission.created`
- `mission_category = 生产`
- `is_enabled = True`
2. 创建 `NotifierRoute B1`
- `notifier = Notifier B`
- `event_key = mission.created`
- `mission_category = 空`
- `is_enabled = True`
这样配置后:
- 生产分类任务创建时,优先命中生产群路由
- 其他分类任务创建时,走管理群的通配路由
## 8. 典型配置示例
### 8.1 示例一:所有任务完成统一发管理群
`Notifier`
- `name`: `任务完成通知-管理群`
- `event_key`: `mission.completed`
- `channel`: `wecom_webhook`
- `template_key`: `mission_completed`
- `is_enabled`: 勾选
### 6.3 示例三:任务取消通知
`NotifierRoute`
适合发到客服或跟单群。
字段建议:
- `name`: `任务取消通知-客服群`
- `event_key`: `mission.cancelled`
- `channel`: `wecom_webhook`
- `template_key`: `mission_cancelled`
- `event_key`: `mission.completed`
- `mission_category`: 留空
- `is_enabled`: 勾选
## 7. 一个事件是否可以绑定多个 Notifier
### 8.2 示例二:售后分类任务创建发售后群
可以。
`Notifier`
例如同一个商户下,`mission.completed` 可以同时配置:
- `name`: `任务创建通知-售后群`
- `channel`: `wecom_webhook`
- `template_key`: `mission_created`
- `is_enabled`: 勾选
- 一条发到生产群
- 一条发到老板群
- 一条发到客服群
`NotifierRoute`
只要它们满足:
- `event_key`: `mission.created`
- `mission_category`: `售后`
- `is_enabled`: 勾选
- `merchant` 相同
- `event_key` 相同
- `is_enabled=True`
### 8.3 示例三:同一个事件同时发多个群
系统就会逐条发送。
例如 `mission.cancelled` 同时发客服群和管理群:
## 8. 模板如何对应
- 创建两个不同的 `Notifier`
- 分别为它们配置两条 `event_key = mission.cancelled` 的路由
- 两条路由都可以是 `mission_category` 为空的通配路由
系统会分别发送到两个群。
## 9. 模板如何对应
当前系统已内置以下模板:
@@ -233,61 +261,84 @@
管理人员通常只需要填 `template_key`,不需要改代码。
如果后续要新增模板内容或调整文案,需要由开发人员修改模板文件。
## 9. 如何停用某条通知
## 10. 如何停用
如果暂时不想让某条通知继续发送,不需要删除,只需要:
### 停用整个通知器
1. 打开该 `Notifier`
适用于:
- 这个群临时不用
- 机器人 key 暂时不可用
- 该通知器下的所有路由都不想生效
操作方式:
1. 打开 `Notifier`
2. 取消勾选 `is_enabled`
3. 保存
这样最安全,也方便后续恢复。
### 停用某一条路由
## 10. 常见问题
适用于:
### 10.1 为什么事件发生了,但没有收到通知
- 只是不想处理某个事件
- 只是不想处理某个任务分类
操作方式:
1. 打开对应 `NotifierRoute`
2. 取消勾选 `is_enabled`
3. 保存
## 11. 常见问题
### 11.1 为什么事件发生了,但没有收到通知
请依次检查:
1. `Notifier` 是否已勾选 `is_enabled`
2. `merchant` 是否配置正确
1. `Notifier` 是否启用
2. `NotifierRoute` 是否启用
3. `event_key` 是否选对
4. `template_key` 是否与现有模板匹配
5. `config.key` 是否填写正确
6. Celery worker 是否已启动
4. `mission_category` 是否与实际任务分类匹配
5. `template_key` 是否对应现有模板
6. `config.key` 是否填写正确
7. Celery worker 是否已启动
### 10.2 为什么同一个事件发了多次
### 11.2 为什么某个任务分类没有走专门群
通常是因为配置了多条相同 `merchant + event_key` 的启用状态通知器。
常见原因:
1. 没有为该分类配置专用路由
2. 专用路由被停用
3. 该任务分类本身不是你以为的那个分类
### 11.3 为什么同一个事件发了多次
通常是因为配置了多个不同的 `NotifierRoute`,分别绑定到了不同的 `Notifier`
这不一定是错误,也可能是有意发往多个群。
如果不希望多发,请检查是否存在重复配置。
### 11.4 同一个通知器会不会因为“专用路由 + 通配路由”重复发送两次
### 10.3 是否建议删除 Notifier
不会。
系统会自动去重,并优先使用更具体的分类路由。
一般不建议优先删除,建议先停用:
## 12. 管理建议
- 更安全
- 方便回滚
- 方便排查历史配置
## 11. 管理建议
建议按下面的方式维护:
- 名称中写清楚用途和群目标
- 先建 `Notifier`,再建 `NotifierRoute`
- 通知器名称中写清楚目标群
- 路由备注中写清楚事件和分类用途
- 先停用再删除
- 一个事件先配一条验证,确认无误后再扩展到多个群
- `description` 中注明负责人或用途
- 先配一条路由做验证,再批量扩展
## 12. 给管理人员的最简操作结论
## 13. 最简操作结论
如果你只想快速配置一条通知,记住这 5 个关键点就够了:
如果你只想快速配置一条通知,记住这 6 个关键点就够了:
1. 选对 `merchant`
2. 选对 `event_key`
3. `channel = wecom_webhook`
4. 填对 `template_key`
5. `config` 填正确的企业微信机器人 `key`
1. 先创建 `Notifier`
2. 填好 `channel`
3. 填好 `template_key`
4. `config` 中填好企业微信机器人 `key`
5. 再创建 `NotifierRoute`
6. 选对 `event_key``mission_category`
这样保存后,对应事件发生时就会自动发送。
这样保存后,对应任务事件发生时就会自动按路由发送。

View File

@@ -2,131 +2,118 @@
本文档面向后端维护者,描述 `notifier` 模块当前的实现决策、已落地范围与后续扩展注意事项。
## 1. 背景与目标
## 1. 当前阶段
项目原有的通知能力主要以企业微信机器人为主,并且配置集中在 `settings.py` 中,属于静态配置方案。
新的 `mission` 模块希望从一开始就采用:
`notifier` 现已从第一阶段的“`Notifier` 直接绑定 `event_key`”升级为第二阶段的“`Notifier + NotifierRoute`”结构。
- 独立 Django app
- task 化投递
- 后台可配置
- signal 与 notifier 动态绑定
- 为后续增加其它通知渠道预留统一接口
这样做的原因是:
因此本次新增独立模块 `notifier`,并先将 `mission` 的新信号通知接入该模块。
- 一个通知器可能需要绑定多个事件
- 同一事件需要支持按任务分类路由
- 同一事件可能同时发往多个不同通知器
- 后续还可能出现更多路由维度
因此当前模块职责被拆成两层:
- `Notifier`:通知渠道配置、模板配置、启停控制
- `NotifierRoute`:事件匹配与业务路由规则
## 2. 当前范围
次实现属于 Phase 1范围有意收敛
阶段已实现
- 已新增独立 app`notifier`
- 已支持后台配置 `Notifier`
- 已支持按 `event_key` + `merchant` 动态匹配通知器
- 已支持 Celery task 化派发
- 已支持模板化内容渲染
- 已支持第一种渠道:企业微信机器人 webhook
- 已接入 `mission` 的 6 个业务信号
- 独立 app`notifier`
- Celery task 化投递
- 企业微信 webhook 渠道
- 模板化内容渲染
- Admin 可配置
- `NotifierRoute` 事件路由
- `mission` 相关事件按任务分类路由
次**没有**做的内容
阶段仍未做
- 没有改造旧模块静态通知逻辑
- 没有引入通知订阅/端点拆表
- 没有引入通知投递明细表(如 `NotificationDelivery`
- 没有做数据库级别审计
- 没有提供对外 API
- 旧模块静态通知逻辑迁移
- 外部 API
- 通知投递明细表
- 数据库级别审计
## 3. 核心模型
当前模型只有一个主模型:`notifier.Notifier`
### 3.1 Notifier
字段职责如下
`Notifier` 负责“怎么发”
- `merchant`: 多商户隔离
- `name`: 通知器名称,仅要求在同商户内唯一
- `event_key`: 事件标识,用于和业务 signal 对接
- `channel`: 通知渠道,当前仅实现 `wecom_webhook`
- `template_key`: 模板标识,对应固定目录中的模板文件
- `is_enabled`: 启用/停用
- `config`: 渠道配置,当前主要存放企业微信 webhook key、msgtype、timeout 等
- `description`: 备注
- `merchant`
- `name`
- `channel`
- `template_key`
- `is_enabled`
- `config`
- `description`
当前 `event_key` 直接放在 `Notifier` 上,而没有拆成“事件订阅 + 通知端点”两层,原因是现阶段追求低复杂度、可快速上线。
后续如果一个通知端点需要订阅多个事件,或者一个事件需要更复杂的启停/优先级/路由策略,再考虑拆模。
当前 `Notifier` 已不再直接持有 `event_key`
## 4. 目录结构
### 3.2 NotifierRoute
关键文件如下
`NotifierRoute` 负责“何时发、发给谁”
- `notifier/models.py`
- `notifier/admin.py`
- `notifier/services.py`
- `notifier/tasks.py`
- `notifier/backends.py`
- `notifier/registry.py`
- `notifier/templates/notifier/events/`
- `merchant`
- `notifier`
- `event_key`
- `mission_category`
- `is_enabled`
- `description`
模板固定目录为
其中
`notifier/templates/notifier/events/`
- `mission_category = null` 表示该事件的通配路由
- `mission_category != null` 表示任务分类专用路由
当前已提供的模板:
### 3.3 约束设计
- `mission_created.md`
- `mission_replied.md`
- `mission_completed.md`
- `mission_reply_rejected.md`
- `mission_reopened.md`
- `mission_cancelled.md`
当前约束:
`template_key` 与模板文件名一一对应,例如:
- `Notifier` 在同商户下 `name` 唯一
- `NotifierRoute` 在同一 `notifier + event_key + mission_category` 下唯一
- `NotifierRoute` 额外限制同一 `notifier + event_key` 只能有一条通配路由
- `template_key="mission_created"`
- 模板路径 `notifier/events/mission_created.md`
## 4. 路由匹配规则
## 5. 调用链路
当前 `dispatch_notification_event(...)` 的匹配规则为:
当前通知链路为:
1. 先按 `merchant + event_key + route.is_enabled=True + notifier.is_enabled=True` 查路由
2. 如果 payload 中带有 `category_id`
- 匹配该分类的专用路由
- 也允许匹配通配路由
3. 如果 payload 中没有 `category_id`
- 只匹配通配路由
4. 如果同一个 `Notifier` 同时命中专用路由和通配路由
- 只保留一条
- 优先保留专用路由
1. `mission.services` 在事务提交后发送业务 signal
2. `mission.handlers` 监听 signal
3. handler 将业务对象整理为纯字典 payload
4. handler 调用 `notifier.services.enqueue_notification_event(...)`
5. notifier 通过 Celery task 异步执行投递
6. task 内部调用 `dispatch_notification_event(...)`
7.`merchant_id + event_key + is_enabled=True` 查询匹配的 `Notifier`
8. 逐个渲染模板并调用对应 backend 的 `notify(...)`
9. 写详细日志
这样可以同时满足:
这里有两个关键约束:
- 分类专用通知
- 默认兜底通知
- 多群并发通知
- 同一通知器不重复发送
- handler 只做 payload 组装和入队,不做实际发送
- task 层才做真正的通知投递
## 5. 当前调用链
这样可以保持业务事务与外部通知解耦。
通知链路如下:
## 6. 渠道抽象
1. `mission.services` 在事务提交后发 signal
2. `mission.handlers` 构造 payload
3. payload 中已包含 `category_id``category_name`
4. handler 调用 `enqueue_notification_event(...)`
5. Celery task 调用 `dispatch_notification_event(...)`
6. notifier 根据 route 匹配命中的 `Notifier`
7. 渲染模板并调用 backend 发送
8. 记录路由日志和发送日志
当前 backend 接口约定为:
## 6. 已接入的事件
- `BaseNotifierBackend.notify(notifier, content, context) -> dict`
当前已实现:
- `WeComWebhookNotifierBackend`
其复用了现有工具:
- `api_v1.utils.wecom_webhook.send_wecom_webhook_message`
这样做的原因:
- 避免重复实现 webhook 发送逻辑
- 保持旧工具可复用
- 新模块只负责“编排”和“动态配置”
## 7. Mission 已接入事件
当前 `mission` 已接入以下事件:
当前 `mission` 已接入:
- `mission.created`
- `mission.replied`
@@ -135,95 +122,89 @@
- `mission.reopened`
- `mission.cancelled`
对应 handler 在:
这些事件全部支持按任务分类路由。
- `mission/handlers.py`
当前 handler 不再只是打日志,而是会构造 payload 并投递到 notifier task。
## 8. Admin 配置方式
`Notifier` 已接入 Django Admin可进行
- 添加
- 编辑
- 删除
- 启用/停用
当前推荐的使用方式:
1. 在 admin 中新建 `Notifier`
2. 选择所属商户
3. 选择 `event_key`
4. 选择 `channel=wecom_webhook`
5. 填写 `template_key`
6.`config` 中填写 webhook key 等参数
7. 启用 `is_enabled`
当前 `config` 示例:
```json
{
"key": "企业微信机器人key",
"msgtype": "markdown",
"timeout_seconds": 10
}
```
## 9. 日志策略
本阶段没有引入数据库投递明细表,因此发送明细主要依赖日志。
## 7. 日志策略
当前日志覆盖以下节点:
- 任务入队
- 事件入队
- 没有命中任何可用路由
- 路由命中成功
- backend 发送成功
- 单个 notifier 发送成功
- 单个 notifier 发送失败
- 某事件无匹配 notifier
- 单个通知发送失败
这满足当前“先可用、后增强”的目标,也符合“暂不做数据库级审计”的约束。
当前已增加 route 维度日志,重点字段包括:
## 10. 当前风险与注意点
- `event_key`
- `merchant_id`
- `route_id`
- `route_mission_category_id`
- `notifier_id`
### 10.1 配置合法性主要依赖管理规范
## 8. Admin 现状
当前 `config` 是自由 JSON没有做更强的结构化校验。
优点是灵活,缺点是后台录入错误会在发送时才暴露。
当前后台提供两个对象:
### 10.2 模板标识依赖文件存在
- `Notifier`
- `NotifierRoute`
`template_key` 对应的模板文件如果不存在,会在发送阶段报错并记录日志。
这在当前阶段是可接受的,但后续可以考虑在 admin 或 model clean 中增加校验。
并且:
### 10.3 目前仍是“单对象订阅”模型
- `Notifier` 页面支持 inline 维护其下路由
- `NotifierRoute` 也支持单独管理
一个 `Notifier` 对应一个 `event_key`
如果后续出现“一个群同时订阅多个事件”的强需求,可以考虑抽象出 Subscription 层。
`Notifier` inline 场景下route 的 `merchant` 会自动同步为当前 notifier 的商户,避免管理人员重复录入。
### 10.4 旧通知逻辑尚未迁移
## 9. 迁移策略
当前仅 `mission` 新通知走 `notifier`
`printing``shipment` 等旧逻辑仍保留原来的静态方式,不应在本次改动中混改。
本次从旧结构迁到新结构时,做了自动回填:
## 11. 后续建议
- 对每条旧 `Notifier(event_key=...)`
- 自动创建一条 `NotifierRoute`
- `event_key` 原样继承
- `mission_category = null`
- `description` 标记为自动迁移生成
按优先级建议如下:
这样旧配置不会因为结构调整而丢失。
1. 在 admin 使用中观察 `config``template_key` 是否已足够稳定
2. 若 notifier 数量增多,再决定是否拆分“通知端点”与“事件订阅”
3. 若需要追踪投递历史,再增加 `NotificationDelivery`
4. 当 mission 通知稳定后,再考虑逐步迁移新业务节点到 notifier
## 10. 测试覆盖重点
当前测试已覆盖:
- 模板渲染
- backend 发送
- 路由按事件匹配
- 分类专用路由优先于通配路由
- 无专用路由时回退到通配路由
- 任务事件 handler 正常入队
## 11. 当前风险与注意点
### 11.1 当前只对 mission 做了分类路由
当前 `mission_category` 是显式写进 `NotifierRoute` 的业务字段。
这适合当前目标,但如果未来要扩展到其它业务模型的复杂路由,可能需要更抽象的路由条件模型。
### 11.2 config 仍是自由 JSON
后台录入错误仍可能在发送时才暴露。
当前依赖日志排查,后续可增加更强校验。
### 11.3 模板校验仍在发送期暴露
`template_key` 如果写错,会在渲染阶段报错并记录日志。
后续可在 admin 或 model clean 中增强校验。
## 12. 当前结论
当前方案已经满足
当前 `notifier` 已具备
- 独立模块
- admin 配置
- task 化通知
- 模板化内容
- 动态 signal -> notifier 路由
- 后续可扩展到多渠道
- 通知器配置
- 路由配置
- 分类分发
- 任务事件按分类路由
- 管理后台操作支持
- 日志与测试保障
同时复杂度仍控制在较低水平,适合作为第一阶段正式实现
这已经足以支撑你当前提出的“所有任务事件都支持按任务分类路由”的要求

View File

@@ -1,20 +1,52 @@
from django.contrib import admin
from notifier.models import Notifier
from notifier.models import Notifier, NotifierRoute
class NotifierRouteInline(admin.TabularInline):
model = NotifierRoute
extra = 0
fields = ["event_key", "mission_category", "is_enabled", "description"]
@admin.register(Notifier)
class NotifierAdmin(admin.ModelAdmin):
inlines = [NotifierRouteInline]
list_display = [
"id",
"merchant",
"name",
"event_key",
"channel",
"template_key",
"is_enabled",
"created_at",
]
list_filter = ["merchant", "event_key", "channel", "is_enabled", "created_at"]
list_filter = ["merchant", "channel", "is_enabled", "created_at"]
search_fields = ["name", "template_key", "description"]
readonly_fields = ["created_at", "updated_at"]
def save_formset(self, request, form, formset, change):
instances = formset.save(commit=False)
for obj in formset.deleted_objects:
obj.delete()
for instance in instances:
if isinstance(instance, NotifierRoute):
instance.merchant = form.instance.merchant
instance.save()
formset.save_m2m()
@admin.register(NotifierRoute)
class NotifierRouteAdmin(admin.ModelAdmin):
list_display = [
"id",
"merchant",
"notifier",
"event_key",
"mission_category",
"is_enabled",
"created_at",
]
list_filter = ["merchant", "event_key", "mission_category", "is_enabled", "created_at"]
search_fields = ["notifier__name", "description"]
readonly_fields = ["created_at", "updated_at"]

View File

@@ -0,0 +1,65 @@
import django.db.models.deletion
from django.db import migrations, models
from django.db.models import Q
def forwards(apps, schema_editor):
Notifier = apps.get_model("notifier", "Notifier")
NotifierRoute = apps.get_model("notifier", "NotifierRoute")
db_alias = schema_editor.connection.alias
for notifier in Notifier.objects.using(db_alias).all().only("id", "merchant_id", "event_key").iterator():
NotifierRoute.objects.using(db_alias).get_or_create(
notifier_id=notifier.id,
event_key=notifier.event_key,
mission_category_id=None,
defaults={
"merchant_id": notifier.merchant_id,
"is_enabled": True,
"description": "由旧版 notifier.event_key 自动迁移生成",
},
)
class Migration(migrations.Migration):
dependencies = [
("mission", "0004_missioncategory_refactor"),
("notifier", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="NotifierRoute",
fields=[
("created_at", models.DateTimeField(auto_now_add=True, verbose_name="创建时间")),
("updated_at", models.DateTimeField(auto_now=True, verbose_name="更新时间")),
("id", models.BigAutoField(primary_key=True, serialize=False)),
("event_key", models.CharField(choices=[("mission.created", "任务已创建"), ("mission.replied", "任务有新回应"), ("mission.completed", "任务已完成"), ("mission.reply_rejected", "任务回应已撤销"), ("mission.reopened", "任务已重新打开"), ("mission.cancelled", "任务已取消")], db_index=True, max_length=100, verbose_name="事件标识")),
("is_enabled", models.BooleanField(db_index=True, default=True, verbose_name="是否启用")),
("description", models.TextField(blank=True, null=True, verbose_name="备注描述")),
("merchant", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="notifier_routes", to="basic_info.merchant", verbose_name="所属商户")),
("mission_category", models.ForeignKey(blank=True, help_text="为空时表示该事件的通配路由", null=True, on_delete=django.db.models.deletion.PROTECT, related_name="notifier_routes", to="mission.missioncategory", verbose_name="任务分类路由")),
("notifier", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="routes", to="notifier.notifier", verbose_name="通知器")),
],
options={
"verbose_name": "通知路由",
"verbose_name_plural": "通知路由",
"indexes": [models.Index(fields=["merchant", "event_key", "is_enabled"], name="notifier_no_merchan_58d463_idx"), models.Index(fields=["merchant", "mission_category", "is_enabled"], name="notifier_no_merchan_14b7f6_idx")],
"constraints": [models.UniqueConstraint(fields=("notifier", "event_key", "mission_category"), name="unique_notifier_route_per_scope"), models.UniqueConstraint(condition=Q(mission_category__isnull=True), fields=("notifier", "event_key"), name="unique_notifier_route_global_scope")],
},
),
migrations.RunPython(forwards, migrations.RunPython.noop),
migrations.RemoveIndex(
model_name="notifier",
name="notifier_no_merchan_c59a7a_idx",
),
migrations.RemoveField(
model_name="notifier",
name="event_key",
),
migrations.AddIndex(
model_name="notifier",
index=models.Index(fields=["merchant", "channel", "is_enabled"], name="notifier_no_merchan_532b51_idx"),
),
]

View File

@@ -1,4 +1,6 @@
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Q
from flower.common import ModelBase
@@ -25,12 +27,6 @@ class Notifier(ModelBase):
verbose_name="所属商户",
)
name = models.CharField(max_length=100, verbose_name="通知器名称")
event_key = models.CharField(
max_length=100,
choices=NotificationEventKeyEnum.choices,
db_index=True,
verbose_name="事件标识",
)
channel = models.CharField(
max_length=50,
choices=NotifierChannelEnum.choices,
@@ -50,7 +46,7 @@ class Notifier(ModelBase):
return config.get(key, default)
def __str__(self):
return f"{self.name} ({self.event_key})"
return self.name
class Meta:
verbose_name = "通知器"
@@ -59,5 +55,70 @@ class Notifier(ModelBase):
models.UniqueConstraint(fields=["merchant", "name"], name="unique_notifier_name_per_merchant"),
]
indexes = [
models.Index(fields=["merchant", "event_key", "is_enabled"]),
models.Index(fields=["merchant", "channel", "is_enabled"], name="notifier_no_merchan_532b51_idx"),
]
class NotifierRoute(ModelBase):
id = models.BigAutoField(primary_key=True)
merchant = models.ForeignKey(
"basic_info.Merchant",
on_delete=models.PROTECT,
related_name="notifier_routes",
verbose_name="所属商户",
)
notifier = models.ForeignKey(
Notifier,
on_delete=models.CASCADE,
related_name="routes",
verbose_name="通知器",
)
event_key = models.CharField(
max_length=100,
choices=NotificationEventKeyEnum.choices,
db_index=True,
verbose_name="事件标识",
)
mission_category = models.ForeignKey(
"mission.MissionCategory",
on_delete=models.PROTECT,
related_name="notifier_routes",
null=True,
blank=True,
verbose_name="任务分类路由",
help_text="为空时表示该事件的通配路由",
)
is_enabled = models.BooleanField(default=True, db_index=True, verbose_name="是否启用")
description = models.TextField(blank=True, null=True, verbose_name="备注描述")
def clean(self):
errors = {}
if self.notifier_id and self.merchant_id and self.notifier.merchant_id != self.merchant_id:
errors["merchant"] = "路由所属商户必须与通知器所属商户一致"
if self.mission_category_id and self.merchant_id and self.mission_category.merchant_id != self.merchant_id:
errors["mission_category"] = "任务分类必须属于当前路由商户"
if errors:
raise ValidationError(errors)
def __str__(self):
category_name = self.mission_category.name if self.mission_category_id else "全部分类"
return f"{self.notifier.name} -> {self.event_key} [{category_name}]"
class Meta:
verbose_name = "通知路由"
verbose_name_plural = "通知路由"
constraints = [
models.UniqueConstraint(
fields=["notifier", "event_key", "mission_category"],
name="unique_notifier_route_per_scope",
),
models.UniqueConstraint(
fields=["notifier", "event_key"],
condition=Q(mission_category__isnull=True),
name="unique_notifier_route_global_scope",
),
]
indexes = [
models.Index(fields=["merchant", "event_key", "is_enabled"], name="notifier_no_merchan_58d463_idx"),
models.Index(fields=["merchant", "mission_category", "is_enabled"], name="notifier_no_merchan_14b7f6_idx"),
]

View File

@@ -1,8 +1,9 @@
import logging
from django.db.models import Case, IntegerField, Q, Value, When
from django.template.loader import render_to_string
from notifier.models import Notifier
from notifier.models import Notifier, NotifierRoute
from notifier.registry import get_notifier_backend
logger = logging.getLogger(__name__)
@@ -22,7 +23,6 @@ def send_notification_with_notifier(*, notifier: Notifier, payload: dict) -> dic
result = {
"notifier_id": notifier.id,
"notifier_name": notifier.name,
"event_key": notifier.event_key,
"channel": notifier.channel,
"template_key": notifier.template_key,
"status": "sent",
@@ -32,29 +32,78 @@ def send_notification_with_notifier(*, notifier: Notifier, payload: dict) -> dic
return result
def _match_notifier_routes(*, event_key: str, merchant_id: int, payload: dict | None = None) -> list[NotifierRoute]:
payload = payload or {}
category_id = payload.get("category_id")
queryset = NotifierRoute.objects.filter(
merchant_id=merchant_id,
event_key=event_key,
is_enabled=True,
notifier__is_enabled=True,
).select_related("notifier", "mission_category")
if category_id is not None:
queryset = queryset.filter(Q(mission_category_id=category_id) | Q(mission_category__isnull=True)).annotate(
route_priority=Case(
When(mission_category_id=category_id, then=Value(0)),
default=Value(1),
output_field=IntegerField(),
)
)
else:
queryset = queryset.filter(mission_category__isnull=True).annotate(
route_priority=Value(0, output_field=IntegerField())
)
queryset = queryset.order_by("notifier_id", "route_priority", "id")
matched_by_notifier_id = {}
for route in queryset:
matched_by_notifier_id.setdefault(route.notifier_id, route)
return list(matched_by_notifier_id.values())
def dispatch_notification_event(*, event_key: str, merchant_id: int, payload: dict | None = None) -> list[dict]:
notifiers = list(
Notifier.objects.filter(
merchant_id=merchant_id,
event_key=event_key,
is_enabled=True,
).order_by("id")
payload = payload or {}
routes = _match_notifier_routes(
event_key=event_key,
merchant_id=merchant_id,
payload=payload,
)
if not notifiers:
if not routes:
logger.info(
"[notifier.services] no enabled notifier matched: event_key=%s merchant_id=%s",
"[notifier.services] no enabled notifier route matched: event_key=%s merchant_id=%s category_id=%s",
event_key,
merchant_id,
payload.get("category_id"),
)
return []
results = []
for notifier in notifiers:
for route in routes:
notifier = route.notifier
try:
results.append(send_notification_with_notifier(notifier=notifier, payload=payload or {}))
item = send_notification_with_notifier(notifier=notifier, payload=payload)
item.update(
{
"event_key": event_key,
"route_id": route.id,
"route_event_key": route.event_key,
"route_mission_category_id": route.mission_category_id,
}
)
results.append(item)
logger.info(
"[notifier.services] notification routed: route_id=%s notifier_id=%s event_key=%s merchant_id=%s route_category_id=%s",
route.id,
notifier.id,
event_key,
merchant_id,
route.mission_category_id,
)
except Exception as exc:
logger.exception(
"[notifier.services] notification failed: notifier_id=%s event_key=%s merchant_id=%s",
"[notifier.services] notification failed: route_id=%s notifier_id=%s event_key=%s merchant_id=%s",
route.id,
notifier.id,
event_key,
merchant_id,
@@ -63,9 +112,12 @@ def dispatch_notification_event(*, event_key: str, merchant_id: int, payload: di
{
"notifier_id": notifier.id,
"notifier_name": notifier.name,
"event_key": notifier.event_key,
"event_key": event_key,
"channel": notifier.channel,
"template_key": notifier.template_key,
"route_id": route.id,
"route_event_key": route.event_key,
"route_mission_category_id": route.mission_category_id,
"status": "failed",
"error": str(exc),
}

View File

@@ -1,6 +1,7 @@
任务已取消
任务ID{{ mission_id }}
分类:{{ category_name }}
取消人:{{ cancelled_by_name }}
取消时间:{{ cancelled_at }}
任务描述:{{ description }}

View File

@@ -1,6 +1,7 @@
任务已完成
任务ID{{ mission_id }}
分类:{{ category_name }}
完成人:{{ completed_by_name }}
结束回应ID{{ reply_id|default:"" }}
结束回应内容:{{ reply_content|default:"" }}

View File

@@ -1,6 +1,7 @@
任务已重新打开
任务ID{{ mission_id }}
分类:{{ category_name }}
操作人:{{ reopened_by_name }}
被撤销的结束回应ID{{ rejected_reply_ids_display }}
任务描述:{{ description }}

View File

@@ -1,6 +1,7 @@
任务有新回应
任务ID{{ mission_id }}
分类:{{ category_name }}
回应ID{{ reply_id }}
回应者:{{ responder_name }}
是否结束任务:{% if ends_task %}是{% else %}否{% endif %}

View File

@@ -1,6 +1,7 @@
任务回应已撤销
任务ID{{ mission_id }}
分类:{{ category_name }}
回应ID{{ reply_id }}
撤销人:{{ rejected_by_name }}
撤销原因:{{ reason }}

View File

@@ -3,7 +3,8 @@ from unittest.mock import patch
from django.test import TestCase
from basic_info.models import Merchant, MerchantTypeEnum
from notifier.models import NotificationEventKeyEnum, Notifier, NotifierChannelEnum
from mission.models import MissionCategory
from notifier.models import NotificationEventKeyEnum, Notifier, NotifierChannelEnum, NotifierRoute
from notifier.services import (
dispatch_notification_event,
enqueue_notification_event,
@@ -15,14 +16,20 @@ from notifier.services import (
class NotifierServiceTestCase(TestCase):
def setUp(self):
self.merchant = Merchant.objects.create(name="通知商户", type=MerchantTypeEnum.STORE)
self.general_category = MissionCategory.objects.create(merchant=self.merchant, name="通用")
self.after_sale_category = MissionCategory.objects.create(merchant=self.merchant, name="售后")
self.notifier = Notifier.objects.create(
merchant=self.merchant,
name="任务创建通知",
event_key=NotificationEventKeyEnum.MISSION_CREATED,
channel=NotifierChannelEnum.WECOM_WEBHOOK,
template_key="mission_created",
config={"key": "abc123", "msgtype": "markdown"},
)
self.global_route = NotifierRoute.objects.create(
merchant=self.merchant,
notifier=self.notifier,
event_key=NotificationEventKeyEnum.MISSION_CREATED,
)
def test_render_notification_content(self):
content = render_notification_content(
@@ -66,28 +73,36 @@ class NotifierServiceTestCase(TestCase):
mock_send.assert_called_once()
@patch("notifier.services.send_notification_with_notifier")
def test_dispatch_notification_event_filters_by_event_and_enabled(self, mock_send):
def test_dispatch_notification_event_filters_by_route_event_and_enabled(self, mock_send):
mock_send.side_effect = lambda *, notifier, payload: {
"notifier_id": notifier.id,
"status": "sent",
}
Notifier.objects.create(
replied_notifier = Notifier.objects.create(
merchant=self.merchant,
name="任务回应通知",
event_key=NotificationEventKeyEnum.MISSION_REPLIED,
channel=NotifierChannelEnum.WECOM_WEBHOOK,
template_key="mission_replied",
config={"key": "def456"},
)
Notifier.objects.create(
NotifierRoute.objects.create(
merchant=self.merchant,
notifier=replied_notifier,
event_key=NotificationEventKeyEnum.MISSION_REPLIED,
)
disabled_notifier = Notifier.objects.create(
merchant=self.merchant,
name="停用通知器",
event_key=NotificationEventKeyEnum.MISSION_CREATED,
channel=NotifierChannelEnum.WECOM_WEBHOOK,
template_key="mission_created",
is_enabled=False,
config={"key": "ghi789"},
)
NotifierRoute.objects.create(
merchant=self.merchant,
notifier=disabled_notifier,
event_key=NotificationEventKeyEnum.MISSION_CREATED,
)
results = dispatch_notification_event(
event_key=NotificationEventKeyEnum.MISSION_CREATED,
@@ -95,10 +110,62 @@ class NotifierServiceTestCase(TestCase):
payload={"mission_id": 99},
)
self.assertEqual(results, [{"notifier_id": self.notifier.id, "status": "sent"}])
self.assertEqual(
results,
[
{
"notifier_id": self.notifier.id,
"status": "sent",
"event_key": NotificationEventKeyEnum.MISSION_CREATED,
"route_id": self.global_route.id,
"route_event_key": NotificationEventKeyEnum.MISSION_CREATED,
"route_mission_category_id": None,
}
],
)
self.assertEqual(mock_send.call_count, 1)
self.assertEqual(mock_send.call_args.kwargs["notifier"].id, self.notifier.id)
@patch("notifier.services.send_notification_with_notifier")
def test_dispatch_notification_event_prefers_category_specific_route(self, mock_send):
mock_send.side_effect = lambda *, notifier, payload: {
"notifier_id": notifier.id,
"status": "sent",
}
specific_route = NotifierRoute.objects.create(
merchant=self.merchant,
notifier=self.notifier,
event_key=NotificationEventKeyEnum.MISSION_CREATED,
mission_category=self.after_sale_category,
)
results = dispatch_notification_event(
event_key=NotificationEventKeyEnum.MISSION_CREATED,
merchant_id=self.merchant.id,
payload={"mission_id": 99, "category_id": self.after_sale_category.id},
)
self.assertEqual(mock_send.call_count, 1)
self.assertEqual(results[0]["route_id"], specific_route.id)
self.assertEqual(results[0]["route_mission_category_id"], self.after_sale_category.id)
@patch("notifier.services.send_notification_with_notifier")
def test_dispatch_notification_event_falls_back_to_global_route(self, mock_send):
mock_send.side_effect = lambda *, notifier, payload: {
"notifier_id": notifier.id,
"status": "sent",
}
results = dispatch_notification_event(
event_key=NotificationEventKeyEnum.MISSION_CREATED,
merchant_id=self.merchant.id,
payload={"mission_id": 99, "category_id": self.general_category.id},
)
self.assertEqual(mock_send.call_count, 1)
self.assertEqual(results[0]["route_id"], self.global_route.id)
self.assertIsNone(results[0]["route_mission_category_id"])
@patch("notifier.tasks.dispatch_notification_event_task.delay")
def test_enqueue_notification_event_returns_task_id(self, mock_delay):
mock_delay.return_value.id = "task-123"