1
0
forked from erp-dev/erp
Files
erpnew/docs/mission_module_design.md
2026-04-13 12:30:33 +08:00

337 lines
9.8 KiB
Markdown
Raw 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.
# Mission 模块内部设计说明
本文档面向后端维护者,不作为对外 API 文档。
## 模块定位
`mission` 是一个中立任务及跟进模块,用于承载系统内任意业务对象上的任务、参与者和回应。
命名使用 `Mission`,不使用 `Task`,主要原因是避免和 Celery task 以及 Python/业务语义中的 task 混淆。
## 当前模型
核心模型:
- `Mission`
- `MissionParticipant`
- `MissionReply`
### Mission
职责:
- 表示一个任务。
- 可选关联任意业务对象。
- 保存任务状态。
- 保存创建人、取消人、所属商户。
关键设计:
- `merchant` 是必填外键,用于严格多商户隔离。
- `creator` 指向 `basic_info.Employee`,不直接关联 `auth.User`
- `cancelled_by` 指向 `basic_info.Employee`,可空。
- `content_type + content_id` 都可空,用 Django `GenericForeignKey` 表示可选业务关联。
- `has_ending_reply` 是计算属性,只统计 `ends_task=True``is_rejected=False` 的回应。
- `can_reply` 是计算属性:任务未取消,且不存在有效结束回应。
自定义权限:
- `mission.reopen_mission`
- `mission.reject_mission_reply`
### MissionParticipant
职责:
- 表示任务参与者。
关键设计:
- 通过中间表维护参与者,而不是裸 ManyToMany。
- `merchant` 是冗余必填字段,用于隔离和后续高频查询。
- `(mission, employee)` 有唯一约束,避免同一员工重复加入同一任务。
### MissionReply
职责:
- 表示任务回应。
关键设计:
- `responder` 指向 `basic_info.Employee`,不可空。
- `ends_task=True` 表示这条回应触发结束任务。
- `is_rejected/rejected_by/rejected_at` 记录回应撤销行为。
- `merchant` 是冗余必填字段,用于隔离和后续高频查询。
## 多商户隔离
当前策略是“显式 merchant 冗余 + service/API 双层限制”。
模型层:
- `Mission.merchant`
- `MissionParticipant.merchant`
- `MissionReply.merchant`
API 层:
- 所有 mission 查询都限制为 `merchant=request.user.employee.merchant`
- 所有 reply 操作都限制为当前商户。
Service 层:
- 校验操作员工属于任务所属商户。
- 创建/更新关联业务对象时,如果目标对象存在 `merchant_id` 字段,则要求目标对象商户与任务商户一致。
- 设置参与者时,所有参与者必须属于任务所属商户。
说明:
- `GenericForeignKey` 本身没有数据库级外键约束,因此 service 层需要负责对象存在性和商户一致性校验。
- `MissionParticipant.merchant``MissionReply.merchant` 是有意冗余字段,后续代码应通过 service 创建和维护,避免绕过造成数据不一致。
## Service 层现状
当前 service 函数位于 `mission/services.py`
公开业务函数:
- `create_mission(...)`
- `update_mission(...)`
- `set_mission_participants(...)`
- `create_mission_reply(...)`
- `reopen_mission(...)`
- `reject_reply(...)`
- `cancel_mission(...)`
- `set_mission_urgent(...)`
内部辅助函数:
- `_assert_employee_belongs_to_mission(...)`
- `_validate_content_object_merchant(...)`
### create_mission
创建任务。
约定:
- `creator` 必须是 `Employee`
- `merchant` 来自 `creator.merchant`
- `is_urgent/is_completed/is_cancelled` 不接受外部参数,按模型默认值创建。
- 可选设置 `content_type/content_id`
- 可选设置参与者列表。
- 成功提交事务后发送 `mission_created` 信号。
### update_mission
普通任务更新。
允许更新:
- `description`
- `category`
- `content_type/content_id`
- `participant_ids`
不负责更新状态字段。
### set_mission_participants
重置任务参与者。
约定:
- 参与者 ID 会去重。
- 所有参与者必须属于任务所属商户。
- 未在新列表中的旧参与者会被删除。
### create_mission_reply
创建任务回应。
约定:
- `responder` 必须属于任务所属商户。
- 任务必须 `can_reply=True`
- 如果 `ends_task=True`,同步设置 `Mission.is_completed=True`
- 成功提交事务后发送 `mission_replied` 信号。
-`ends_task=True` 且任务从未完成变为完成时,同时发送 `mission_completed` 信号。
### reopen_mission
重新打开任务。
API 层需要先校验权限:
- `mission.reopen_mission`
Service 层前置条件:
- 操作员工属于任务所属商户。
- 任务未取消。
- 任务已完成。
- 存在有效的 `ends_task=True, is_rejected=False` 回应。
执行效果:
- `Mission.is_completed=False`
- 相关结束回应设置为 `ends_task=False`
- 相关结束回应记录 `is_rejected=True``rejected_by=reopened_by``rejected_at=now`
- 成功提交事务后发送 `mission_reopened` 信号。
- 被 reopen 撤销的结束回应会发送 `mission_reply_rejected` 信号,`reason="reopen"`
### reject_reply
撤销任务回应。
API 层需要先校验权限:
- `mission.reject_mission_reply`
Service 层前置条件:
- 操作员工属于任务所属商户。
- 任务未取消。
- 回应未被撤销。
执行效果:
- `MissionReply.ends_task=False`
- `MissionReply.is_rejected=True`
- `MissionReply.rejected_by=rejected_by`
- `MissionReply.rejected_at=now`
- 如果被撤销的是结束回应,且任务没有其他有效结束回应,则同步 `Mission.is_completed=False`
- 成功提交事务后发送 `mission_reply_rejected` 信号,`reason="reject_reply"`
### cancel_mission
取消任务。
约定:
- 操作员工必须属于任务所属商户。
- 已取消任务不能重复取消。
- 只设置 `is_cancelled/cancelled_by/cancelled_at`
- 不强行修改 `is_completed`,展示层应让取消状态优先于完成状态。
- 成功提交事务后发送 `mission_cancelled` 信号。
### set_mission_urgent
设置紧急状态。
约定:
- 操作员工必须属于任务所属商户。
- `is_urgent` 不允许通过普通 CRUD 更新,只能通过独立状态接口更新。
## 领域信号
信号定义位于 `mission/signals.py`,空 handler 接入口位于 `mission/handlers.py`,注册逻辑位于 `mission/apps.py`
发送原则:
- 只从 service 层发送,不从 model `post_save` 自动发送。
- 使用 `transaction.on_commit(...)`,确保事务提交成功后 handler 才运行。
- signal 发送异常只记录日志,不反向影响主业务流程。
当前信号:
| 信号 | sender | 触发时机 | 主要 payload |
|------|--------|----------|--------------|
| `mission_created` | `Mission` | 任务创建成功 | `instance`, `created_by` |
| `mission_replied` | `MissionReply` | 任务回应创建成功 | `instance`, `mission`, `responder` |
| `mission_completed` | `Mission` | 结束回应使任务变为完成 | `instance`, `completed_by`, `reply` |
| `mission_reply_rejected` | `MissionReply` | 回应被撤销 | `instance`, `mission`, `rejected_by`, `reason` |
| `mission_reopened` | `Mission` | 任务被 reopen | `instance`, `reopened_by`, `rejected_reply_ids` |
| `mission_cancelled` | `Mission` | 任务被取消 | `instance`, `cancelled_by` |
`mission_reply_rejected.reason` 当前取值:
- `reject_reply`:显式调用 `reject_reply(...)` 撤销回应。
- `reopen``reopen_mission(...)` 内部撤销结束回应。
## API 层现状
API 位于 `api_v2/views/mission.py`,路由位于 `api_v2/urls.py`
对外接口文档见:
- `docs/api_v2_mission_api.md`
当前 API 风格沿用 `api_v2` 现有结构:显式 `APIView + path(...)`,未引入 ViewSet/Router。
普通 CRUD
- `GET /api/v2/mission-categories/`
- `POST /api/v2/mission-categories/`
- `GET /api/v2/mission-categories/<category_id>/`
- `PATCH /api/v2/mission-categories/<category_id>/`
- `DELETE /api/v2/mission-categories/<category_id>/`
- `GET /api/v2/missions/`
- `POST /api/v2/missions/`
- `GET /api/v2/missions/<mission_id>/`
- `PATCH /api/v2/missions/<mission_id>/`
- `DELETE /api/v2/missions/<mission_id>/` 返回 405不提供删除能力
状态和 service 对应接口:
- `POST /api/v2/missions/<mission_id>/replies/`
- `POST /api/v2/missions/<mission_id>/reopen/`
- `POST /api/v2/missions/<mission_id>/cancel/`
- `POST /api/v2/missions/<mission_id>/set-urgent/`
- `POST /api/v2/mission-replies/<reply_id>/reject/`
## 当前测试
测试文件:
- `mission/tests.py`
- `api_v2/test_mission_api.py`
覆盖重点:
- 参与者便捷方法。
- 创建结束回应时任务自动完成。
- reopen 撤销结束回应。
- cancel 记录取消人和取消时间。
- reject reply 撤销回应并按需要恢复任务未完成。
- 普通 CRUD 禁止状态字段。
- 多商户隔离。
- reopen/reject 权限校验。
## 当前覆盖率
最近一次统计命令:
```bash
docker compose exec -T -e DB_HOST=postgres -e DB_PORT=5432 web \
uv run coverage run --source=mission manage.py test --keepdb --noinput \
api_v2.test_mission_api mission
docker compose exec -T web uv run coverage report -m
```
统计结果:
| 范围 | 覆盖率 |
|------|--------|
| `mission` 总体 | 96% |
| `mission/models.py` | 95% |
| `mission/services.py` | 99% |
| `mission/admin.py` | 100% |
| `mission/tests.py` | 100% |
说明:
- 当前按 `--source=mission` 统计,只计算 `mission` 模块本身,不包含 `api_v2/views/mission.py`
- 当前 `mission/services.py` 覆盖率为 99%,剩余未覆盖为 signal 发送异常保护分支。
- `mission` 总体剩余未覆盖主要来自迁移回填函数分支和空的 `mission/views.py`
## 已知边界
- 当前没有 Mission 的前端分页;列表直接返回数组。若任务量增大,应补分页。
- 当前 `MissionCategory` 已独立成表Mission 对外返回 `category=id``category_name`
- 当前没有删除能力,取消是唯一业务关闭入口之一。
- 当前 `GenericForeignKey` 只做对象存在性与可选商户一致性校验,不做目标对象的复杂业务权限校验。