From e1cc6df12285683dee22309d189324279a743426 Mon Sep 17 00:00:00 2001 From: colaftc Date: Sun, 5 Jul 2026 13:53:17 +0800 Subject: [PATCH] fin --- api_core/__init__.py | 1 + api_core/apps.py | 7 + api_core/permissions.py | 26 + api_core/serializers.py | 171 +++ api_core/tests.py | 174 +++ api_core/urls.py | 12 + api_core/views.py | 75 ++ api_v1/views/printing/serializers.py | 4 +- .../views/printing/test_printing_job_api.py | 66 + api_v1/views/printing/views.py | 26 +- api_v2/test_mission_api.py | 144 ++- api_v2/test_schema_docs_access.py | 57 + api_v2/urls.py | 4 + api_v2/views/__init__.py | 4 + api_v2/views/mission.py | 103 +- docs/api_core_api_2026-07-03.md | 332 +++++ docs/api_docs_schema_usage_2026-07-03.md | 137 +++ docs/api_v2_mission_api.md | 1075 +++++++++-------- docs/api_v2_mission_api_2026-07-03.md | 775 ++++++++++++ docs/api_v2_mission_my_api_2026-07-03.md | 214 ++++ flower/settings.py | 7 + flower/urls.py | 1 + mission/services.py | 42 +- mission/tests.py | 68 +- 24 files changed, 3030 insertions(+), 495 deletions(-) create mode 100644 api_core/__init__.py create mode 100644 api_core/apps.py create mode 100644 api_core/permissions.py create mode 100644 api_core/serializers.py create mode 100644 api_core/tests.py create mode 100644 api_core/urls.py create mode 100644 api_core/views.py create mode 100644 api_v2/test_schema_docs_access.py create mode 100644 docs/api_core_api_2026-07-03.md create mode 100644 docs/api_docs_schema_usage_2026-07-03.md create mode 100644 docs/api_v2_mission_api_2026-07-03.md create mode 100644 docs/api_v2_mission_my_api_2026-07-03.md diff --git a/api_core/__init__.py b/api_core/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/api_core/__init__.py @@ -0,0 +1 @@ + diff --git a/api_core/apps.py b/api_core/apps.py new file mode 100644 index 0000000..52f4758 --- /dev/null +++ b/api_core/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ApiCoreConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "api_core" + diff --git a/api_core/permissions.py b/api_core/permissions.py new file mode 100644 index 0000000..083b3df --- /dev/null +++ b/api_core/permissions.py @@ -0,0 +1,26 @@ +from rest_framework.permissions import BasePermission + + +class IsCoreSuperAdmin(BasePermission): + """ + Core APIs are reserved for Django superusers with a bound Employee merchant. + """ + + message = "需要 superadmin 权限并绑定有效员工商户" + + def has_permission(self, request, view): + user = request.user + if not user or not user.is_authenticated: + return False + if not user.is_superuser: + return False + + employee = getattr(user, "employee", None) + if employee is None: + return False + if getattr(employee, "merchant_id", None) is None: + return False + + request.core_merchant = employee.merchant + return True + diff --git a/api_core/serializers.py b/api_core/serializers.py new file mode 100644 index 0000000..9e56065 --- /dev/null +++ b/api_core/serializers.py @@ -0,0 +1,171 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group, Permission +from django.db import transaction +from rest_framework import serializers +from drf_spectacular.utils import extend_schema_field + +from basic_info.models import UserProfile + + +User = get_user_model() + + +class CorePermissionSerializer(serializers.ModelSerializer): + app_label = serializers.CharField(source="content_type.app_label", read_only=True) + model = serializers.CharField(source="content_type.model", read_only=True) + full_code = serializers.SerializerMethodField() + + class Meta: + model = Permission + fields = [ + "id", + "name", + "codename", + "content_type", + "app_label", + "model", + "full_code", + ] + read_only_fields = fields + + def get_full_code(self, obj) -> str: + return f"{obj.content_type.app_label}.{obj.codename}" + + +class CoreGroupSerializer(serializers.ModelSerializer): + permissions = serializers.PrimaryKeyRelatedField( + many=True, + queryset=Permission.objects.select_related("content_type").all(), + required=False, + ) + permission_details = CorePermissionSerializer( + source="permissions", + many=True, + read_only=True, + ) + + class Meta: + model = Group + fields = ["id", "name", "permissions", "permission_details"] + + +class CoreUserSerializer(serializers.ModelSerializer): + groups = serializers.PrimaryKeyRelatedField( + many=True, + queryset=Group.objects.all(), + required=False, + ) + user_permissions = serializers.PrimaryKeyRelatedField( + many=True, + queryset=Permission.objects.select_related("content_type").all(), + required=False, + ) + password = serializers.CharField(write_only=True, required=False, min_length=6) + merchant_id = serializers.IntegerField(source="profile.merchant_id", read_only=True) + employee_id = serializers.IntegerField(source="employee.id", read_only=True) + employee_name = serializers.CharField(source="employee.name", read_only=True) + group_details = serializers.SerializerMethodField() + permission_details = serializers.SerializerMethodField() + + class Meta: + model = User + fields = [ + "id", + "username", + "email", + "first_name", + "last_name", + "is_active", + "is_staff", + "is_superuser", + "last_login", + "date_joined", + "merchant_id", + "employee_id", + "employee_name", + "groups", + "group_details", + "user_permissions", + "permission_details", + "password", + ] + read_only_fields = [ + "id", + "last_login", + "date_joined", + "merchant_id", + "employee_id", + "employee_name", + "group_details", + "permission_details", + ] + + @extend_schema_field(serializers.ListField(child=serializers.DictField())) + def get_group_details(self, obj) -> list[dict[str, object]]: + return [{"id": group.id, "name": group.name} for group in obj.groups.all()] + + @extend_schema_field(CorePermissionSerializer(many=True)) + def get_permission_details(self, obj) -> list[dict[str, object]]: + permissions = obj.user_permissions.select_related("content_type").all() + return CorePermissionSerializer(permissions, many=True).data + + def validate_username(self, value): + queryset = User.objects.filter(username=value) + if self.instance is not None: + queryset = queryset.exclude(pk=self.instance.pk) + if queryset.exists(): + raise serializers.ValidationError("用户名已存在") + return value + + def validate(self, attrs): + request = self.context.get("request") + current_user = getattr(request, "user", None) + target_user = self.instance + + if current_user is not None and target_user is not None and current_user.pk == target_user.pk: + if attrs.get("is_active") is False: + raise serializers.ValidationError({"is_active": "不能停用当前登录用户"}) + if attrs.get("is_superuser") is False: + raise serializers.ValidationError({"is_superuser": "不能取消当前登录用户的 superuser 权限"}) + + return attrs + + def create(self, validated_data): + groups = validated_data.pop("groups", []) + user_permissions = validated_data.pop("user_permissions", []) + password = validated_data.pop("password", None) + merchant = self.context["merchant"] + + with transaction.atomic(): + user = User(**validated_data) + if password: + user.set_password(password) + else: + user.set_unusable_password() + user.save() + if groups: + user.groups.set(groups) + if user_permissions: + user.user_permissions.set(user_permissions) + UserProfile.objects.create(user=user, merchant=merchant) + + return user + + def update(self, instance, validated_data): + groups = validated_data.pop("groups", None) + user_permissions = validated_data.pop("user_permissions", None) + password = validated_data.pop("password", None) + + with transaction.atomic(): + for attr, value in validated_data.items(): + setattr(instance, attr, value) + if password: + instance.set_password(password) + instance.save() + if groups is not None: + instance.groups.set(groups) + if user_permissions is not None: + instance.user_permissions.set(user_permissions) + + return instance + diff --git a/api_core/tests.py b/api_core/tests.py new file mode 100644 index 0000000..32fae57 --- /dev/null +++ b/api_core/tests.py @@ -0,0 +1,174 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group, Permission +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase +from rest_framework.test import APIClient + +from basic_info import models as basic_models + + +User = get_user_model() + + +class CoreAPITestCase(TestCase): + def setUp(self): + self.client = APIClient() + self.merchant = basic_models.Merchant.objects.create( + name="核心商户", + type=basic_models.MerchantTypeEnum.STORE, + ) + self.other_merchant = basic_models.Merchant.objects.create( + name="其他商户", + type=basic_models.MerchantTypeEnum.STORE, + ) + self.super_user = User.objects.create_superuser( + username="core_admin", + password="pass12345", + email="core@example.com", + ) + self.employee = basic_models.Employee.objects.create( + merchant=self.merchant, + sys_user=self.super_user, + name="核心管理员", + ) + self.normal_user = User.objects.create_user(username="normal", password="pass12345") + self.no_employee_super_user = User.objects.create_superuser( + username="no_employee_admin", + password="pass12345", + ) + + def authenticate_super_admin(self): + self.client.force_authenticate(user=self.super_user) + + def test_core_api_requires_superuser_and_employee_merchant(self): + response = self.client.get("/api/core/users/") + self.assertEqual(response.status_code, 401) + + self.client.force_authenticate(user=self.normal_user) + response = self.client.get("/api/core/users/") + self.assertEqual(response.status_code, 403) + + self.client.force_authenticate(user=self.no_employee_super_user) + response = self.client.get("/api/core/users/") + self.assertEqual(response.status_code, 403) + + def test_user_list_is_merchant_scoped(self): + same_user = User.objects.create_user(username="same_merchant", password="pass12345") + basic_models.UserProfile.objects.create(user=same_user, merchant=self.merchant) + + other_user = User.objects.create_user(username="other_merchant", password="pass12345") + basic_models.UserProfile.objects.create(user=other_user, merchant=self.other_merchant) + + self.authenticate_super_admin() + response = self.client.get("/api/core/users/", {"limit": 100}) + self.assertEqual(response.status_code, 200) + usernames = {item["username"] for item in response.data["results"]} + self.assertIn("core_admin", usernames) + self.assertIn("same_merchant", usernames) + self.assertNotIn("other_merchant", usernames) + + detail_response = self.client.get(f"/api/core/users/{other_user.id}/") + self.assertEqual(detail_response.status_code, 404) + + def test_create_user_creates_profile_but_not_employee(self): + group = Group.objects.create(name="核心角色") + self.authenticate_super_admin() + response = self.client.post( + "/api/core/users/", + { + "username": "core_created", + "password": "pass12345", + "email": "created@example.com", + "first_name": "Core", + "is_active": True, + "groups": [group.id], + }, + format="json", + ) + self.assertEqual(response.status_code, 201) + + user = User.objects.get(username="core_created") + self.assertEqual(user.profile.merchant, self.merchant) + self.assertFalse(hasattr(user, "employee")) + self.assertIn(group, user.groups.all()) + + def test_user_cannot_be_deleted_and_self_cannot_be_deactivated_or_demoted(self): + self.authenticate_super_admin() + response = self.client.delete(f"/api/core/users/{self.super_user.id}/") + self.assertEqual(response.status_code, 405) + + response = self.client.patch( + f"/api/core/users/{self.super_user.id}/", + {"is_active": False}, + format="json", + ) + self.assertEqual(response.status_code, 400) + self.assertIn("is_active", response.data) + + response = self.client.patch( + f"/api/core/users/{self.super_user.id}/", + {"is_superuser": False}, + format="json", + ) + self.assertEqual(response.status_code, 400) + self.assertIn("is_superuser", response.data) + + def test_groups_are_global_full_crud(self): + content_type = ContentType.objects.get_for_model(User) + permission = Permission.objects.get(content_type=content_type, codename="view_user") + self.authenticate_super_admin() + + create_response = self.client.post( + "/api/core/groups/", + {"name": "全局核心组", "permissions": [permission.id]}, + format="json", + ) + self.assertEqual(create_response.status_code, 201) + group_id = create_response.data["id"] + + detail_response = self.client.get(f"/api/core/groups/{group_id}/") + self.assertEqual(detail_response.status_code, 200) + self.assertEqual(detail_response.data["name"], "全局核心组") + self.assertEqual(detail_response.data["permissions"], [permission.id]) + + patch_response = self.client.patch( + f"/api/core/groups/{group_id}/", + {"name": "已更新核心组"}, + format="json", + ) + self.assertEqual(patch_response.status_code, 200) + self.assertEqual(patch_response.data["name"], "已更新核心组") + + delete_response = self.client.delete(f"/api/core/groups/{group_id}/") + self.assertEqual(delete_response.status_code, 204) + self.assertFalse(Group.objects.filter(id=group_id).exists()) + + def test_permissions_are_read_only(self): + content_type = ContentType.objects.get_for_model(User) + permission = Permission.objects.get(content_type=content_type, codename="view_user") + self.authenticate_super_admin() + + list_response = self.client.get("/api/core/permissions/", {"limit": 100}) + self.assertEqual(list_response.status_code, 200) + self.assertTrue(any(item["id"] == permission.id for item in list_response.data["results"])) + + detail_response = self.client.get(f"/api/core/permissions/{permission.id}/") + self.assertEqual(detail_response.status_code, 200) + self.assertEqual(detail_response.data["full_code"], "auth.view_user") + + create_response = self.client.post( + "/api/core/permissions/", + {"name": "fake", "codename": "fake_permission", "content_type": content_type.id}, + format="json", + ) + self.assertEqual(create_response.status_code, 405) + + patch_response = self.client.patch( + f"/api/core/permissions/{permission.id}/", + {"name": "renamed"}, + format="json", + ) + self.assertEqual(patch_response.status_code, 405) + + delete_response = self.client.delete(f"/api/core/permissions/{permission.id}/") + self.assertEqual(delete_response.status_code, 405) diff --git a/api_core/urls.py b/api_core/urls.py new file mode 100644 index 0000000..affcd6f --- /dev/null +++ b/api_core/urls.py @@ -0,0 +1,12 @@ +from rest_framework.routers import DefaultRouter + +from api_core.views import CoreGroupViewSet, CorePermissionViewSet, CoreUserViewSet + + +router = DefaultRouter() +router.register("users", CoreUserViewSet, basename="core-user") +router.register("groups", CoreGroupViewSet, basename="core-group") +router.register("permissions", CorePermissionViewSet, basename="core-permission") + +urlpatterns = router.urls + diff --git a/api_core/views.py b/api_core/views.py new file mode 100644 index 0000000..512b248 --- /dev/null +++ b/api_core/views.py @@ -0,0 +1,75 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group, Permission +from django.db.models import Q +from rest_framework import filters +from rest_framework import viewsets +from rest_framework.exceptions import MethodNotAllowed + +from api_core.permissions import IsCoreSuperAdmin +from api_core.serializers import ( + CoreGroupSerializer, + CorePermissionSerializer, + CoreUserSerializer, +) +from flower.viewsets import LimitedLimitOffsetPagination + + +User = get_user_model() + + +class CoreUserViewSet(viewsets.ModelViewSet): + serializer_class = CoreUserSerializer + permission_classes = [IsCoreSuperAdmin] + pagination_class = LimitedLimitOffsetPagination + filter_backends = [filters.SearchFilter, filters.OrderingFilter] + search_fields = ["username", "email", "first_name", "last_name", "employee__name"] + ordering_fields = ["id", "username", "date_joined", "last_login"] + ordering = ["id"] + + def get_queryset(self): + merchant = getattr(self.request, "core_merchant", None) + if merchant is None: + return User.objects.none() + return ( + User.objects.filter( + Q(employee__merchant=merchant) | Q(profile__merchant=merchant) + ) + .distinct() + .prefetch_related("groups", "user_permissions__content_type") + .select_related("employee", "profile") + .order_by("id") + ) + + def get_serializer_context(self): + context = super().get_serializer_context() + context["merchant"] = self.request.core_merchant + return context + + def destroy(self, request, *args, **kwargs): + raise MethodNotAllowed("DELETE", detail="Core users 不允许删除,请通过 is_active=false 停用") + + +class CoreGroupViewSet(viewsets.ModelViewSet): + queryset = Group.objects.prefetch_related("permissions__content_type").order_by("id") + serializer_class = CoreGroupSerializer + permission_classes = [IsCoreSuperAdmin] + pagination_class = LimitedLimitOffsetPagination + filter_backends = [filters.SearchFilter, filters.OrderingFilter] + search_fields = ["name"] + ordering_fields = ["id", "name"] + ordering = ["id"] + + +class CorePermissionViewSet(viewsets.ReadOnlyModelViewSet): + queryset = Permission.objects.select_related("content_type").order_by( + "content_type__app_label", + "content_type__model", + "codename", + ) + serializer_class = CorePermissionSerializer + permission_classes = [IsCoreSuperAdmin] + pagination_class = LimitedLimitOffsetPagination + filter_backends = [filters.SearchFilter, filters.OrderingFilter] + search_fields = ["name", "codename", "content_type__app_label", "content_type__model"] + ordering_fields = ["id", "name", "codename"] + diff --git a/api_v1/views/printing/serializers.py b/api_v1/views/printing/serializers.py index 3b44ff0..c715642 100644 --- a/api_v1/views/printing/serializers.py +++ b/api_v1/views/printing/serializers.py @@ -368,6 +368,7 @@ class PrintingJobListSerializer(serializers.ModelSerializer): printing_order_id = serializers.CharField(source='printing_order.human_id', read_only=True) external_order_id = serializers.CharField(source='printing_order.external_order_id', read_only=True) + customer_name = serializers.CharField(source='printing_order.customer.name', read_only=True, allow_null=True) product_name = serializers.CharField(source='product.name', read_only=True) product_image_url = serializers.SerializerMethodField() status = serializers.CharField(read_only=True) @@ -388,7 +389,8 @@ class PrintingJobListSerializer(serializers.ModelSerializer): class Meta: model = models.PrintingJob fields = [ - 'id', 'original_id', 'sub_id', 'merchant_id', 'printing_order', 'printing_order_id', 'external_order_id', 'product', 'product_name', + 'id', 'original_id', 'sub_id', 'merchant_id', 'printing_order', 'printing_order_id', 'external_order_id', + 'customer_name', 'product', 'product_name', 'product_image_url', 'has_started', 'quantity', 'billed_quantity', 'unit', 'size', 'pieces', 'description', 'work_state', 'work_state_display', diff --git a/api_v1/views/printing/test_printing_job_api.py b/api_v1/views/printing/test_printing_job_api.py index e06f2a5..fe4da3b 100644 --- a/api_v1/views/printing/test_printing_job_api.py +++ b/api_v1/views/printing/test_printing_job_api.py @@ -1,10 +1,12 @@ """ PrintingJob API 测试 """ +from datetime import datetime from decimal import Decimal from django.test import TestCase from django.conf import settings +from django.utils import timezone from rest_framework.test import APIClient from rest_framework import status from django.contrib.auth import get_user_model @@ -187,10 +189,74 @@ class PrintingJobAPITestCase(TestCase): # 新增字段:批量推进记录(稳定输出 key) for item in response.data['results']: + self.assertIn('customer_name', item) + self.assertEqual(item['customer_name'], self.customer.name) self.assertIn('batch_advance_records', item) self.assertIsInstance(item['batch_advance_records'], list) self.assertEqual(len(item['batch_advance_records']), 0) + def test_filter_printing_jobs_by_customer_name(self): + """测试按客户名称筛选款式明细列表""" + other_customer = basic_models.Customer.objects.create( + merchant=self.merchant, + name='其他客户', + mobile='13900139001', + area='测试地区' + ) + other_order = printing_models.PrintingOrder.objects.create( + customer=other_customer, + fabric='其他布料', + width='150cm', + process=self.process, + ) + matched_job = printing_models.PrintingJob.objects.create( + printing_order=self.printing_order, + product=self.product, + quantity=100, + unit='米', + ) + other_job = printing_models.PrintingJob.objects.create( + printing_order=other_order, + product=self.product, + quantity=200, + unit='米', + ) + + response = self.client.get('/api/v1/printing-jobs/?customer_name=测试客') + self.assertEqual(response.status_code, status.HTTP_200_OK) + ids = {item['id'] for item in response.data['results']} + self.assertIn(matched_job.id, ids) + self.assertNotIn(other_job.id, ids) + + def test_filter_printing_jobs_by_created_at_date_range_includes_whole_to_day(self): + """测试 created_at_to 传日期时包含整天""" + tz = timezone.get_current_timezone() + in_range = timezone.make_aware(datetime(2026, 7, 3, 23, 30, 0), tz) + out_range = timezone.make_aware(datetime(2026, 7, 4, 0, 0, 0), tz) + + matched_job = printing_models.PrintingJob.objects.create( + printing_order=self.printing_order, + product=self.product, + quantity=100, + unit='米', + ) + out_job = printing_models.PrintingJob.objects.create( + printing_order=self.printing_order, + product=self.product, + quantity=200, + unit='米', + ) + printing_models.PrintingJob.objects.filter(id=matched_job.id).update(created_at=in_range) + printing_models.PrintingJob.objects.filter(id=out_job.id).update(created_at=out_range) + + response = self.client.get( + '/api/v1/printing-jobs/?created_at_from=2026-07-03&created_at_to=2026-07-03' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + ids = {item['id'] for item in response.data['results']} + self.assertIn(matched_job.id, ids) + self.assertNotIn(out_job.id, ids) + def test_list_printing_jobs_includes_is_sales_order_bound(self): """测试列表返回是否已绑定销售单字段,并支持关闭该字段""" unbound_job = printing_models.PrintingJob.objects.create( diff --git a/api_v1/views/printing/views.py b/api_v1/views/printing/views.py index 2e86b86..c8bf737 100644 --- a/api_v1/views/printing/views.py +++ b/api_v1/views/printing/views.py @@ -444,6 +444,9 @@ class PrintingJobFilterSet(django_filters.FilterSet): external_order_id = django_filters.CharFilter( field_name="printing_order__external_order_id", lookup_expr="icontains" ) + customer_name = django_filters.CharFilter( + field_name="printing_order__customer__name", lookup_expr="icontains" + ) product = django_filters.NumberFilter() product_name = django_filters.CharFilter( field_name="product__name", lookup_expr="icontains" @@ -453,12 +456,30 @@ class PrintingJobFilterSet(django_filters.FilterSet): quantity_max = django_filters.NumberFilter(field_name="quantity", lookup_expr="lte") pieces_min = django_filters.NumberFilter(field_name="pieces", lookup_expr="gte") pieces_max = django_filters.NumberFilter(field_name="pieces", lookup_expr="lte") + created_at_from = django_filters.CharFilter(method="filter_created_at_from") + created_at_to = django_filters.CharFilter(method="filter_created_at_to") is_production_completed = django_filters.BooleanFilter() class Meta: model = models.PrintingJob fields = ["printing_order", "product", "external_order_id", "is_production_completed"] + def filter_created_at_from(self, queryset, name, value): + dt, _has_time = PrintingOrderFilterSet._parse_date_or_datetime(value) + if dt is None: + raise ValidationError("created_at_from 必须是日期或日期时间") + return queryset.filter(created_at__gte=dt) + + def filter_created_at_to(self, queryset, name, value): + dt, has_time = PrintingOrderFilterSet._parse_date_or_datetime(value) + if dt is None: + raise ValidationError("created_at_to 必须是日期或日期时间") + + if not has_time: + return queryset.filter(created_at__lt=dt + timedelta(days=1)) + + return queryset.filter(created_at__lte=dt) + class PrintingJobViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet): """ @@ -484,6 +505,7 @@ class PrintingJobViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet): 查询参数: - printing_order: 印染订单ID - external_order_id: 外部订单编号(按所属订单模糊查询) + - customer_name: 客户名称(按所属订单客户名称模糊查询) - product: 产品ID - product_name: 产品名称(模糊查询) - unit: 单位(模糊查询) @@ -491,6 +513,8 @@ class PrintingJobViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet): - quantity_max: 最大数量 - pieces_min: 最小件数 - pieces_max: 最大件数 + - created_at_from: 创建时间开始(支持 YYYY-MM-DD 或日期时间) + - created_at_to: 创建时间结束(YYYY-MM-DD 会包含整天) - is_production_completed: 是否已标记完成生产(true/false) - search: 全文搜索(产品名称、单位、尺寸、备注) - ordering: 排序字段 @@ -529,7 +553,7 @@ class PrintingJobViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet): if self.action in ["list", "retrieve"]: from business.models import SalesOrderItem, SalesOrderStatusEnum - queryset = queryset.select_related("printing_order", "product") + queryset = queryset.select_related("printing_order", "printing_order__customer", "product") queryset = queryset.annotate( annotated_is_sales_order_bound=Exists( SalesOrderItem.objects.filter( diff --git a/api_v2/test_mission_api.py b/api_v2/test_mission_api.py index 9e4118a..b44fc16 100644 --- a/api_v2/test_mission_api.py +++ b/api_v2/test_mission_api.py @@ -1,8 +1,11 @@ +from datetime import datetime + from django.core.cache import cache from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission from django.contrib.contenttypes.models import ContentType from django.test import TestCase +from django.utils import timezone from rest_framework.test import APIClient from basic_info import models as basic_models @@ -258,7 +261,146 @@ class MissionV2APITest(TestCase): resp = self.client.get("/api/v2/missions/") self.assertEqual(resp.status_code, 200) - self.assertEqual([item["id"] for item in resp.data], [visible.id]) + self.assertEqual([item["id"] for item in resp.data["results"]], [visible.id]) + self.assertEqual(resp.data["count"], 1) + + def test_list_missions_supports_limit_offset_pagination(self): + _first = self._create_mission(description="第一条任务") + second = self._create_mission(description="第二条任务") + + resp = self.client.get("/api/v2/missions/?limit=1&offset=0") + + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.data["count"], 2) + self.assertEqual(len(resp.data["results"]), 1) + self.assertEqual(resp.data["results"][0]["id"], second.id) + self.assertIsNotNone(resp.data["next"]) + + def test_my_only_returns_related_missions_by_default(self): + created_by_me = self._create_mission(description="我创建的未完结任务") + participant_mission = mission_models.Mission.objects.create( + merchant=self.merchant, + category=self.default_category, + creator=self.participant, + description="我参与的未完结任务", + ) + mission_models.MissionParticipant.objects.create( + merchant=self.merchant, + mission=participant_mission, + employee=self.employee, + ) + unrelated = mission_models.Mission.objects.create( + merchant=self.merchant, + category=self.default_category, + creator=self.participant, + description="同商户但与我无关", + ) + completed = self._create_mission(description="已完成任务") + completed.is_completed = True + completed.save(update_fields=["is_completed", "updated_at"]) + cancelled = self._create_mission(description="已取消任务") + cancelled.is_cancelled = True + cancelled.save(update_fields=["is_cancelled", "updated_at"]) + cross_merchant = mission_models.Mission.objects.create( + merchant=self.other_merchant, + category=self.other_category, + creator=self.other_employee, + description="跨商户任务", + ) + + resp = self.client.get("/api/v2/missions/my/") + + self.assertEqual(resp.status_code, 200) + ids = {item["id"] for item in resp.data["results"]} + self.assertEqual(ids, {created_by_me.id, participant_mission.id, completed.id, cancelled.id}) + self.assertNotIn(unrelated.id, ids) + self.assertNotIn(cross_merchant.id, ids) + self.assertEqual(resp.data["count"], 4) + + def test_my_supports_status_urgent_created_by_me_created_at_range_and_pagination(self): + tz = timezone.get_current_timezone() + old_time = timezone.make_aware(datetime(2026, 7, 2, 10, 0, 0), tz) + day_time = timezone.make_aware(datetime(2026, 7, 3, 23, 30, 0), tz) + next_day_time = timezone.make_aware(datetime(2026, 7, 4, 0, 0, 0), tz) + + old_urgent = self._create_mission(description="更早的紧急任务") + old_urgent.is_urgent = True + old_urgent.save(update_fields=["is_urgent", "updated_at"]) + in_range_urgent = self._create_mission(description="当天紧急任务") + in_range_urgent.is_urgent = True + in_range_urgent.save(update_fields=["is_urgent", "updated_at"]) + in_range_normal = self._create_mission(description="当天普通任务") + next_day_urgent = self._create_mission(description="次日紧急任务") + next_day_urgent.is_urgent = True + next_day_urgent.save(update_fields=["is_urgent", "updated_at"]) + completed_urgent = self._create_mission(description="当天已完成紧急任务") + completed_urgent.is_urgent = True + completed_urgent.is_completed = True + completed_urgent.save(update_fields=["is_urgent", "is_completed", "updated_at"]) + + mission_models.Mission.objects.filter(id=old_urgent.id).update(created_at=old_time) + mission_models.Mission.objects.filter(id=in_range_urgent.id).update(created_at=day_time) + mission_models.Mission.objects.filter(id=in_range_normal.id).update(created_at=day_time) + mission_models.Mission.objects.filter(id=next_day_urgent.id).update(created_at=next_day_time) + mission_models.Mission.objects.filter(id=completed_urgent.id).update(created_at=day_time) + + resp = self.client.get( + "/api/v2/missions/my/?created_by_me=true&is_completed=false&is_cancelled=false&is_urgent=true" + "&created_at_from=2026-07-03&created_at_to=2026-07-03&limit=1&offset=0" + ) + + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.data["count"], 1) + self.assertEqual([item["id"] for item in resp.data["results"]], [in_range_urgent.id]) + + def test_my_status_counts_groups_related_missions_without_duplicates(self): + open_created = self._create_mission(description="我创建的未完成任务") + mission_models.MissionParticipant.objects.create( + merchant=self.merchant, + mission=open_created, + employee=self.employee, + ) + mission_models.MissionParticipant.objects.create( + merchant=self.merchant, + mission=open_created, + employee=self.participant, + ) + + completed_created = self._create_mission(description="我创建的已完成任务") + completed_created.is_completed = True + completed_created.save(update_fields=["is_completed", "updated_at"]) + + cancelled_participant = mission_models.Mission.objects.create( + merchant=self.merchant, + category=self.default_category, + creator=self.participant, + description="我参与的已取消任务", + is_cancelled=True, + ) + mission_models.MissionParticipant.objects.create( + merchant=self.merchant, + mission=cancelled_participant, + employee=self.employee, + ) + + mission_models.Mission.objects.create( + merchant=self.merchant, + category=self.default_category, + creator=self.participant, + description="同商户但与我无关", + ) + mission_models.Mission.objects.create( + merchant=self.other_merchant, + category=self.other_category, + creator=self.other_employee, + description="跨商户任务", + is_completed=True, + ) + + resp = self.client.get("/api/v2/missions/my/status-counts/") + + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.data, {"total": 3, "open": 1, "completed": 1, "cancelled": 1}) def test_list_missions_by_printing_order_returns_nested_replies_and_extra(self): mission = mission_models.Mission.objects.create( diff --git a/api_v2/test_schema_docs_access.py b/api_v2/test_schema_docs_access.py new file mode 100644 index 0000000..db549c8 --- /dev/null +++ b/api_v2/test_schema_docs_access.py @@ -0,0 +1,57 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework.test import APIClient +from rest_framework_simplejwt.tokens import RefreshToken + +from basic_info.models import Employee, Merchant, MerchantTypeEnum + + +class APIDocsSchemaAccessTest(TestCase): + def setUp(self): + self.merchant = Merchant.objects.create(name='文档审查商户', type=MerchantTypeEnum.STORE) + self.user = get_user_model().objects.create_user(username='schema-docs-user', password='pass12345') + self.employee = Employee.objects.create( + merchant=self.merchant, + sys_user=self.user, + name='文档审查员工', + ) + + def _auth_client(self): + token = str(RefreshToken.for_user(self.user).access_token) + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}') + return client + + def test_schema_and_docs_require_jwt(self): + client = APIClient() + + schema_response = client.get('/api/schema/', HTTP_ACCEPT='application/json') + docs_response = client.get('/api/docs/') + + self.assertEqual(schema_response.status_code, 401) + self.assertEqual(docs_response.status_code, 401) + + def test_authenticated_user_can_access_schema_and_docs(self): + client = self._auth_client() + + schema_response = client.get('/api/schema/', HTTP_ACCEPT='application/json') + docs_response = client.get('/api/docs/') + + self.assertEqual(schema_response.status_code, 200) + self.assertEqual(docs_response.status_code, 200) + + def test_schema_contains_latest_mission_api(self): + client = self._auth_client() + + response = client.get('/api/schema/', HTTP_ACCEPT='application/json') + + self.assertEqual(response.status_code, 200) + schema_text = response.content.decode('utf-8') + expected_fragments = [ + 'employee_type_ids', + '/api/v2/missions/', + '/api/v2/missions/by-printing-order/{printing_order_id}/', + '/api/v2/mission-replies/{reply_id}/reject/', + ] + missing = [fragment for fragment in expected_fragments if fragment not in schema_text] + self.assertEqual(missing, []) diff --git a/api_v2/urls.py b/api_v2/urls.py index f18ebdd..bc3f090 100644 --- a/api_v2/urls.py +++ b/api_v2/urls.py @@ -23,6 +23,8 @@ from api_v2.views import ( MissionCategoryListCreateView, MissionDetailView, MissionListCreateView, + MissionMyStatusCountsView, + MissionMyView, MissionReopenView, MissionReplyListCreateView, MissionReplyRejectView, @@ -79,6 +81,8 @@ urlpatterns = [ path('mission-categories//', MissionCategoryDetailView.as_view(), name='api_v2_mission_category_detail'), path('missions/', MissionListCreateView.as_view(), name='api_v2_mission_list_create'), path('missions/by-printing-order//', MissionByPrintingOrderView.as_view(), name='api_v2_mission_by_printing_order'), + path('missions/my/', MissionMyView.as_view(), name='api_v2_mission_my'), + path('missions/my/status-counts/', MissionMyStatusCountsView.as_view(), name='api_v2_mission_my_status_counts'), path('missions//', MissionDetailView.as_view(), name='api_v2_mission_detail'), path('missions//replies/', MissionReplyListCreateView.as_view(), name='api_v2_mission_reply_list_create'), path('missions//reopen/', MissionReopenView.as_view(), name='api_v2_mission_reopen'), diff --git a/api_v2/views/__init__.py b/api_v2/views/__init__.py index d8eb113..8f955c8 100644 --- a/api_v2/views/__init__.py +++ b/api_v2/views/__init__.py @@ -27,6 +27,8 @@ from .mission import ( MissionCategoryListCreateView, MissionDetailView, MissionListCreateView, + MissionMyStatusCountsView, + MissionMyView, MissionReopenView, MissionReplyListCreateView, MissionReplyRejectView, @@ -79,6 +81,8 @@ __all__ = [ 'MissionCategoryListCreateView', 'MissionDetailView', 'MissionListCreateView', + 'MissionMyStatusCountsView', + 'MissionMyView', 'MissionReopenView', 'MissionReplyListCreateView', 'MissionReplyRejectView', diff --git a/api_v2/views/mission.py b/api_v2/views/mission.py index 0028d23..79ceb33 100644 --- a/api_v2/views/mission.py +++ b/api_v2/views/mission.py @@ -1,13 +1,19 @@ +from datetime import datetime, time, timedelta + from django.contrib.contenttypes.models import ContentType from django.core.cache import cache from django.db import IntegrityError -from django.db.models import Exists, OuterRef, Prefetch, Q +from django.db.models import Count, Exists, OuterRef, Prefetch, Q from django.db.models import ProtectedError from django.shortcuts import get_object_or_404 +from django.utils import timezone +from django.utils.dateparse import parse_date, parse_datetime +from drf_spectacular.utils import extend_schema from rest_framework import permissions, serializers, status from rest_framework.response import Response from rest_framework.views import APIView +from flower.viewsets import LimitedLimitOffsetPagination from mission import models as mission_models from mission import services as mission_services @@ -47,6 +53,11 @@ class MissionWriteSerializer(serializers.Serializer): required=False, allow_empty=True, ) + employee_type_ids = serializers.ListField( + child=serializers.IntegerField(min_value=1), + required=False, + allow_empty=True, + ) def __init__(self, *args, **kwargs): self.is_create = kwargs.pop("is_create", False) @@ -358,6 +369,27 @@ def _parse_bool_query_param(request, param_name: str, default: bool) -> bool: raise serializers.ValidationError({param_name: "必须是布尔值"}) +def _parse_date_or_datetime_query_param(value: str | None) -> tuple[datetime | None, bool]: + raw_value = (value or "").strip() + if not raw_value: + return None, False + + tz = timezone.get_current_timezone() + date_value = parse_date(raw_value) + if date_value is not None: + return timezone.make_aware(datetime.combine(date_value, time.min), tz), False + + datetime_value = parse_datetime(raw_value) + if datetime_value is not None: + if timezone.is_naive(datetime_value): + datetime_value = timezone.make_aware(datetime_value, tz) + else: + datetime_value = timezone.localtime(datetime_value, tz) + return datetime_value, True + + return None, False + + def _mission_queryset_for_employee(employee, *, include_details: bool = True): has_ending_reply_subquery = mission_models.MissionReply.objects.filter( mission_id=OuterRef("pk"), @@ -402,6 +434,7 @@ class MissionCategoryListCreateView(APIView): queryset = _mission_category_queryset_for_employee(employee) return Response(MissionCategorySerializer(queryset, many=True).data) + @extend_schema(request=MissionWriteSerializer, responses={201: MissionSerializer}) def post(self, request): employee = _get_employee(request) serializer = MissionCategoryWriteSerializer(data=request.data, context={"employee": employee}) @@ -466,6 +499,7 @@ class MissionCategoryDetailView(APIView): class MissionListCreateView(APIView): permission_classes = [permissions.IsAuthenticated] + pagination_class = LimitedLimitOffsetPagination def get(self, request): employee = _get_employee(request) @@ -482,7 +516,10 @@ class MissionListCreateView(APIView): if request.query_params.get("content_id"): queryset = queryset.filter(content_id=request.query_params["content_id"]) - return Response(MissionSerializer(queryset, many=True).data) + paginator = self.pagination_class() + page = paginator.paginate_queryset(queryset, request, view=self) + serializer = MissionSerializer(page, many=True) + return paginator.get_paginated_response(serializer.data) def post(self, request): employee = _get_employee(request) @@ -498,6 +535,7 @@ class MissionListCreateView(APIView): content_id=data.get("content_id"), extra=data.get("extra"), participant_ids=data.get("participant_ids"), + employee_type_ids=data.get("employee_type_ids"), notify_if_unreplied=data.get("notify_if_unreplied", False), unreplied_notify_interval_minutes=data.get("unreplied_notify_interval_minutes"), unreplied_notify_max_count=data.get("unreplied_notify_max_count", 5), @@ -507,6 +545,65 @@ class MissionListCreateView(APIView): return Response(MissionSerializer(mission).data, status=status.HTTP_201_CREATED) +class MissionMyView(APIView): + permission_classes = [permissions.IsAuthenticated] + pagination_class = LimitedLimitOffsetPagination + + def get(self, request): + employee = _get_employee(request) + queryset = ( + _mission_queryset_for_employee(employee) + .filter(Q(creator=employee) | Q(participants__employee=employee)) + .distinct() + ) + + if _parse_bool_query_param(request, "created_by_me", default=False): + queryset = queryset.filter(creator=employee) + + for field in ["is_completed", "is_cancelled", "is_urgent"]: + if request.query_params.get(field) is not None: + queryset = queryset.filter(**{field: _parse_bool_query_param(request, field, default=False)}) + + created_at_from = request.query_params.get("created_at_from") + if created_at_from is not None: + dt, _has_time = _parse_date_or_datetime_query_param(created_at_from) + if dt is None: + raise serializers.ValidationError({"created_at_from": "必须是日期或日期时间"}) + queryset = queryset.filter(created_at__gte=dt) + + created_at_to = request.query_params.get("created_at_to") + if created_at_to is not None: + dt, has_time = _parse_date_or_datetime_query_param(created_at_to) + if dt is None: + raise serializers.ValidationError({"created_at_to": "必须是日期或日期时间"}) + if has_time: + queryset = queryset.filter(created_at__lte=dt) + else: + queryset = queryset.filter(created_at__lt=dt + timedelta(days=1)) + + paginator = self.pagination_class() + page = paginator.paginate_queryset(queryset, request, view=self) + serializer = MissionSerializer(page, many=True) + return paginator.get_paginated_response(serializer.data) + + +class MissionMyStatusCountsView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request): + employee = _get_employee(request) + queryset = mission_models.Mission.objects.filter(merchant=employee.merchant).filter( + Q(creator=employee) | Q(participants__employee=employee) + ) + counts = queryset.aggregate( + total=Count("id", distinct=True), + open=Count("id", filter=Q(is_completed=False, is_cancelled=False), distinct=True), + completed=Count("id", filter=Q(is_completed=True, is_cancelled=False), distinct=True), + cancelled=Count("id", filter=Q(is_cancelled=True), distinct=True), + ) + return Response(counts) + + class MissionByPrintingOrderView(APIView): permission_classes = [permissions.IsAuthenticated] @@ -568,6 +665,7 @@ class MissionDetailView(APIView): mission = self.get_object(request, mission_id) return Response(MissionSerializer(mission).data) + @extend_schema(request=MissionWriteSerializer, responses=MissionSerializer) def patch(self, request, mission_id): employee = _get_employee(request) mission = self.get_object(request, mission_id) @@ -589,6 +687,7 @@ class MissionDetailView(APIView): extra=(data["extra"] if "extra" in data else mission_services.UNSET), update_content_object=("content_type" in request.data or "content_id" in request.data), participant_ids=data.get("participant_ids"), + employee_type_ids=data.get("employee_type_ids"), notify_if_unreplied=( data["notify_if_unreplied"] if "notify_if_unreplied" in data diff --git a/docs/api_core_api_2026-07-03.md b/docs/api_core_api_2026-07-03.md new file mode 100644 index 0000000..ff3f533 --- /dev/null +++ b/docs/api_core_api_2026-07-03.md @@ -0,0 +1,332 @@ +# /api/core/ 核心权限 API 文档 + +`/api/core/` 是系统核心权限管理 API 集合,用于管理 Django auth 层的 `User`、`Group` 和 `Permission`。 + +这组接口权限极高,必须谨慎使用。一般业务开通用户、员工绑定、商户业务身份维护,不应该优先使用这里的用户创建接口,而应该使用业务侧 Employee / UserProfile / 员工开通流程。 + +## 访问控制 + +所有 `/api/core/` 接口都必须同时满足: + +```text +1. 已登录 +2. request.user.is_superuser == True +3. request.user.employee 存在 +4. request.user.employee.merchant 存在 +``` + +不满足时返回: + +```text +未登录:401 +已登录但不是 superadmin,或没有 employee/merchant:403 +``` + +认证方式: + +```http +Authorization: Bearer +``` + +登录接口仍为: + +```http +POST /api/auth/login/ +``` + +## Merchant 隔离 + +`users` 接口引入 merchant 隔离。 + +当前 merchant 来自: + +```text +request.user.employee.merchant +``` + +`/api/core/users/` 只返回和操作当前 merchant 范围内的用户: + +```text +User.employee.merchant == 当前 merchant +或 +User.profile.merchant == 当前 merchant +``` + +`groups` 暂时是全局资源,不做 merchant 隔离。 + +`permissions` 是 Django 全局权限,只读,不做 merchant 隔离。 + +## Users + +### 列表 + +```http +GET /api/core/users/ +``` + +支持分页: + +```http +GET /api/core/users/?limit=100&offset=0 +``` + +支持搜索和排序: + +```http +GET /api/core/users/?search=admin +GET /api/core/users/?ordering=username +GET /api/core/users/?ordering=-date_joined +``` + +返回字段包含: + +```json +{ + "id": 1, + "username": "admin", + "email": "admin@example.com", + "first_name": "", + "last_name": "", + "is_active": true, + "is_staff": true, + "is_superuser": true, + "last_login": "2026-07-03T10:00:00+08:00", + "date_joined": "2026-07-03T09:00:00+08:00", + "merchant_id": 1, + "employee_id": 10, + "employee_name": "管理员", + "groups": [1, 2], + "group_details": [ + {"id": 1, "name": "管理员"} + ], + "user_permissions": [101, 102], + "permission_details": [] +} +``` + +### 详情 + +```http +GET /api/core/users/{id}/ +``` + +如果目标用户不属于当前 merchant 范围,返回 `404`。 + +### 创建 + +```http +POST /api/core/users/ +``` + +示例: + +```json +{ + "username": "core_user", + "password": "strongPass123", + "email": "core@example.com", + "first_name": "Core", + "last_name": "User", + "is_active": true, + "is_staff": false, + "is_superuser": false, + "groups": [1], + "user_permissions": [101] +} +``` + +创建行为: + +```text +1. 创建 Django User +2. 创建 UserProfile,并将 merchant 设置为当前 superadmin 的 merchant +3. 不创建 Employee +4. 不绑定业务身份 +``` + +重要说明: + +```text +/api/core/users/ 创建出来的是 core auth 用户,不是完整业务用户。 +因为不会创建 Employee,新用户通常不能直接登录业务系统。 +一般情况下,不建议在这里创建业务用户。 +``` + +### 更新 + +```http +PATCH /api/core/users/{id}/ +PUT /api/core/users/{id}/ +``` + +可更新字段包括: + +```text +username +email +first_name +last_name +is_active +is_staff +is_superuser +groups +user_permissions +password +``` + +安全限制: + +```text +不允许停用当前登录用户自己 +不允许取消当前登录用户自己的 is_superuser +``` + +### 停用用户 + +用户不允许删除,只能停用: + +```http +PATCH /api/core/users/{id}/ +Content-Type: application/json + +{ + "is_active": false +} +``` + +### 删除用户 + +不允许: + +```http +DELETE /api/core/users/{id}/ +``` + +返回: + +```text +405 Method Not Allowed +``` + +## Groups + +`groups` 使用 Django `Group`,暂时是全局资源,不做 merchant 隔离。 + +但是所有 group API 仍然必须通过 `/api/core/` 的 superadmin 访问控制。 + +### 列表 + +```http +GET /api/core/groups/ +``` + +### 详情 + +```http +GET /api/core/groups/{id}/ +``` + +### 创建 + +```http +POST /api/core/groups/ +``` + +```json +{ + "name": "财务管理员", + "permissions": [101, 102, 103] +} +``` + +### 更新 + +```http +PATCH /api/core/groups/{id}/ +PUT /api/core/groups/{id}/ +``` + +设置 permissions 时传完整权限 id 列表: + +```json +{ + "permissions": [101, 102, 103] +} +``` + +### 删除 + +```http +DELETE /api/core/groups/{id}/ +``` + +删除 group 会影响已分配该 group 的用户,请谨慎操作。 + +## Permissions + +`permissions` 使用 Django `Permission`,只读。 + +权限通常来自 Django model 默认权限和代码中的 `Meta.permissions`,不应该由前端任意创建、修改或删除。 + +### 列表 + +```http +GET /api/core/permissions/ +``` + +支持搜索: + +```http +GET /api/core/permissions/?search=salesorder +GET /api/core/permissions/?search=business +``` + +返回字段: + +```json +{ + "id": 101, + "name": "Can view sales order", + "codename": "view_salesorder", + "content_type": 12, + "app_label": "business", + "model": "salesorder", + "full_code": "business.view_salesorder" +} +``` + +### 详情 + +```http +GET /api/core/permissions/{id}/ +``` + +### 不允许写操作 + +以下操作不允许: + +```http +POST /api/core/permissions/ +PATCH /api/core/permissions/{id}/ +PUT /api/core/permissions/{id}/ +DELETE /api/core/permissions/{id}/ +``` + +返回: + +```text +405 Method Not Allowed +``` + +## 前端使用建议 + +```text +1. /api/core/ 只给最高权限管理界面使用。 +2. 普通业务用户开通不要默认走 /api/core/users/。 +3. 创建业务用户应优先走员工/业务身份流程。 +4. 修改 is_superuser、is_staff、groups、user_permissions 时必须二次确认。 +5. permissions 只作为可分配权限源,不允许前端创建权限。 +6. group 暂时是全局资源,删除或改名会影响所有 merchant。 +``` + +更新日期:2026-07-03 diff --git a/docs/api_docs_schema_usage_2026-07-03.md b/docs/api_docs_schema_usage_2026-07-03.md new file mode 100644 index 0000000..2203264 --- /dev/null +++ b/docs/api_docs_schema_usage_2026-07-03.md @@ -0,0 +1,137 @@ +# API Docs 与 Schema 使用说明 + +更新日期:2026-07-03 + +本文档说明 `/api/schema/` 和 `/api/docs/` 的用途、登录方式和前端审查使用流程。 + +## 访问保护 + +当前 API 文档端点已启用 JWT 登录保护: + +- 未登录访问 `/api/schema/` 会返回 401。 +- 未登录访问 `/api/docs/` 会返回 401。 +- 只要是有效登录用户即可访问,不额外要求管理员权限。 + +## 两个端点的区别 + +### `/api/schema/` + +机器可读的 OpenAPI schema。 + +用途: + +- 给 Apifox、Postman、Swagger Editor 导入。 +- 给前端生成接口类型或客户端代码。 +- 用于审查当前后端实际暴露的 API 路径、请求参数、响应结构和认证方式。 + +返回格式通常是 OpenAPI JSON。 + +### `/api/docs/` + +浏览器可读的 Swagger UI 页面。 + +用途: + +- 在浏览器里查看接口文档。 +- 在页面中通过 Authorize 填入 JWT 后调试接口。 + +注意:Swagger UI 页面本身也需要登录保护。浏览器地址栏不能直接携带 `Authorization` header,因此最顺手的方式通常是先用工具拿到 token,再在 Swagger UI 的 Authorize 里填入 token。 + +## 登录获取 JWT + +登录接口: + +```http +POST /api/auth/login/ +``` + +请求体: + +```json +{ + "username": "frontend-reviewer", + "password": "your-password" +} +``` + +成功响应: + +```json +{ + "refresh": "refresh-token", + "access": "access-token" +} +``` + +说明: + +- 登录用户必须绑定 `Employee`。 +- 未绑定员工身份的用户会登录失败。 +- 当前 access token 有效期由后端 `SIMPLE_JWT` 配置控制。 + +## 访问 `/api/schema/` + +请求: + +```http +GET /api/schema/ +Authorization: Bearer {access-token} +``` + +curl 示例: + +```bash +curl -H "Authorization: Bearer ${ACCESS_TOKEN}" https://your-domain/api/schema/ +``` + +Apifox/Postman 使用方式: + +1. 先调用 `/api/auth/login/` 获取 `access`。 +2. 导入 OpenAPI URL:`https://your-domain/api/schema/`。 +3. 给导入请求添加 Header: + +```http +Authorization: Bearer {access-token} +``` + +## 访问 `/api/docs/` + +浏览器直接访问: + +```text +https://your-domain/api/docs/ +``` + +如果浏览器没有携带 JWT,会返回 401。 + +推荐审查流程: + +1. 调用 `/api/auth/login/` 获取 `access`。 +2. 打开 `/api/docs/`。 +3. 点击 Swagger UI 页面右上角 `Authorize`。 +4. 输入: + +```text +Bearer {access-token} +``` + +5. 之后即可在 Swagger UI 中查看并调试接口。 + +## 前端审查建议 + +- 如果目标是审查接口结构,优先使用 `/api/schema/` 导入 Apifox/Postman。 +- 如果目标是临时浏览和手动调试,使用 `/api/docs/`。 +- 不要把 `/api/schema/` 的内容提交到前端仓库作为长期静态副本;schema 会随着后端代码变化而变化。 +- 审查前请确认使用的是目标环境的域名,因为 `content_type` 等 ID 在不同环境可能不同。 + +## 当前抽检重点 + +本次配置变更后需要确认: + +- 匿名访问 `/api/schema/` 返回 401。 +- 匿名访问 `/api/docs/` 返回 401。 +- 登录后访问 `/api/schema/` 返回 200。 +- 登录后访问 `/api/docs/` 返回 200。 +- `/api/schema/` 中包含最新 mission API 变更,例如 `employee_type_ids`。 + +更新日期:2026-07-03 diff --git a/docs/api_v2_mission_api.md b/docs/api_v2_mission_api.md index 994cd3c..2fa3a7c 100644 --- a/docs/api_v2_mission_api.md +++ b/docs/api_v2_mission_api.md @@ -1,95 +1,52 @@ -# API v2 Mission 任务模块接口文档 +# Mission 任务模块 API 文档 -本文档面向前端,描述 `mission` 中立任务模块的 API。 +更新日期:2026-07-03 -## 基本约定 +本文档面向前端,描述 `mission` 中立任务模块的对外 API。当前 mission API 仅在 `api_v2` 提供;已检查 `api_v1/urls.py`,`api_v1` 暂无 mission 相关接口。 + +## 1. 基本约定 - Base URL: `/api/v2` -- 认证:所有接口都需要登录。 -- 人员身份:后端使用 `request.user.employee` 作为当前员工身份。 -- 多商户隔离:所有任务、任务分类、参与者、回应都只允许访问当前员工所属商户的数据。 -- 删除能力:不提供任务删除接口;需要结束业务时使用 `cancel` 取消任务。 -- 状态字段:普通创建/更新接口不允许修改状态字段,状态变化统一走独立接口。 +- 认证:所有 mission 接口均需要登录。 +- 当前员工:后端使用 `request.user.employee` 作为当前业务员工。 +- 商户隔离:任务、任务分类、参与者、回应均按当前员工所属 `merchant` 隔离。 +- 参与者对象:任务参与者是 `basic_info.Employee`,不是 Django `User`。 +- 删除任务:不提供物理删除任务接口;业务结束请使用取消接口。 +- 普通创建/更新接口不允许直接修改状态字段,状态变化必须走独立状态接口。 -普通 CRUD 禁止提交这些字段: +普通创建/更新禁止提交字段: | 字段 | 说明 | -|------|------| +|---|---| | `is_urgent` | 是否紧急,使用 `set-urgent` 接口修改 | -| `is_completed` | 是否完成,由结束回应或 reopen/reject 流程维护 | +| `is_completed` | 是否完成,由结束回应、reopen、reject 流程维护 | | `is_cancelled` | 是否取消,使用 `cancel` 接口修改 | | `cancelled_by` | 取消人,由后端写入 | | `cancelled_at` | 取消时间,由后端写入 | -| `rejected_by` | 撤销人,由后端写入 | -| `rejected_at` | 撤销时间,由后端写入 | +| `rejected_by` | 回应撤销人,由后端写入 | +| `rejected_at` | 回应撤销时间,由后端写入 | -说明: +## 2. API v1 状态 -- 本次新增的“未回复提醒配置字段”不属于状态字段,允许通过普通创建/更新接口维护 +已检查 `api_v1/urls.py` 和 `api_v1/views`,当前没有 mission 模块接口。 -## content_type 说明 +如前端需要任务模块,请使用本文档中的 `/api/v2/...` 接口。 -`content_type` 和 `content_id` 是可选的"关联业务对象"字段,用于把任务挂靠到系统中的某个具体业务单据或对象上。 +## 3. 核心数据结构 -### content_type 是什么 +### 3.1 EmployeePayload -`content_type` 是一个整数,对应后端数据库 `django_content_type` 表的主键(`id`)。 -该表记录了系统中所有 Django 模型的元信息,每一行代表一个模型,格式为 `app_label` + `model`。 - -**前端不应硬编码 content_type 整数 ID**,因为这个 ID 在不同部署环境中可能不同。 -正确做法:调用 `/api/v2/content-types/` 接口(见下方"查询可用 content_type"一节)获取当前环境的实际 ID。 - -### content_type_label 字段 - -响应体中的 `content_type_label` 字段是人类可读的模型标识,格式为 `"app_label.model"`,例如: - -- `"printing.plateorder"` — 开版单 -- `"printing.printingjob"` — 印刷任务 -- `"business.purchaseorder"` — 采购单 - -该字段**只读**,用于前端展示或调试,不作为提交 content_type 时的值。 - -### 当前系统中可关联的主要业务对象 - -| `content_type_label` | 中文说明 | -|---|---| -| `printing.plateorder` | 开版单 | -| `printing.printingorder` | 印刷单 | -| `printing.printingjob` | 印刷任务 | -| `business.purchaseorder` | 采购单 | -| `business.presalesorder` | 预销售单 | -| `business.salesorder` | 销售单 | -| `business.prepurchaseorder` | 预采购单 | -| `business.purchasereturnorder` | 采购退货单 | -| `business.salesreturnorder` | 销售退货单 | -| `business.paymentorder` | 付款单 | -| `business.receiptorder` | 收款单 | -| `stock.transferorder` | 调拨单 | -| `shipment.shipment` | 发货单 | -| `stateflow.process` | 工艺流程实例 | - -> 上述列表是"有意义的"业务关联对象;系统本身不限制可关联的模型类型,技术上任何有效 ContentType ID 都被接受。 - -### 查询可用 content_type - -- URL: `/api/v2/content-types/` -- Method: `GET` - -响应示例: +任务模块中所有人员字段均使用员工对象摘要: ```json -[ - {"id": 7, "app_label": "business", "model": "paymentorder", "label": "business.paymentorder", "name": "付款单"}, - {"id": 8, "app_label": "business", "model": "presalesorder", "label": "business.presalesorder", "name": "预销售单"}, - ... -] +{ + "id": 21, + "name": "李四", + "merchant_id": 10 +} ``` -前端应在运行时调用此接口获取 `id`,不应硬编码,因为不同部署环境的 ID 可能不同。 - -## 数据结构 - -### MissionCategory +### 3.2 MissionCategory ```json { @@ -98,25 +55,37 @@ "name": "通用", "payload_processor": "", "speech_enabled": false, - "created_at": "2026-04-10T12:00:00+08:00", - "updated_at": "2026-04-10T12:00:00+08:00" + "created_at": "2026-07-03T10:00:00+08:00", + "updated_at": "2026-07-03T10:00:00+08:00" } ``` -### Mission +字段说明: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | number | 任务分类 ID | +| `merchant` | number | 所属商户 ID | +| `name` | string | 分类名称,同商户内唯一 | +| `payload_processor` | string | payload 增强器,可为空;当前可选 `structured_description_v1` | +| `speech_enabled` | boolean | 是否启用任务播报 | +| `created_at` | string | 创建时间 | +| `updated_at` | string | 更新时间 | + +### 3.3 Mission ```json { "id": 1, "merchant": 10, - "description": "跟进客户问题", + "description": "请跟进这张生产单", "category": 1, "category_name": "通用", "is_urgent": false, "is_completed": false, "is_cancelled": false, - "notify_if_unreplied": false, - "unreplied_notify_interval_minutes": null, + "notify_if_unreplied": true, + "unreplied_notify_interval_minutes": 30, "unreplied_notify_max_count": 5, "unreplied_notify_sent_count": 0, "unreplied_last_notified_at": null, @@ -134,21 +103,54 @@ "merchant_id": 10 } ], - "content_type": null, - "content_type_label": null, - "content_id": null, + "content_type": 33, + "content_type_label": "printing.printingorder", + "content_id": 123, "extra": { - "source": "wechat", - "ticket_no": "TK-001" + "source": "printing-order" }, "has_ending_reply": false, "can_reply": true, - "created_at": "2026-04-10T12:00:00+08:00", - "updated_at": "2026-04-10T12:00:00+08:00" + "created_at": "2026-07-03T10:00:00+08:00", + "updated_at": "2026-07-03T10:00:00+08:00" } ``` -### MissionReply +字段说明: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | number | 任务 ID | +| `merchant` | number | 所属商户 ID | +| `description` | string | 任务描述 | +| `category` | number | MissionCategory.id | +| `category_name` | string | 分类名称 | +| `is_urgent` | boolean | 是否紧急 | +| `is_completed` | boolean | 是否完成 | +| `is_cancelled` | boolean | 是否取消 | +| `notify_if_unreplied` | boolean | 是否开启未回复提醒 | +| `unreplied_notify_interval_minutes` | number/null | 未回复提醒间隔,单位分钟 | +| `unreplied_notify_max_count` | number | 未回复最大提醒次数,默认 5 | +| `unreplied_notify_sent_count` | number | 已发送未回复提醒次数 | +| `unreplied_last_notified_at` | string/null | 上一次未回复提醒时间 | +| `cancelled_at` | string/null | 取消时间 | +| `creator` | EmployeePayload | 创建员工 | +| `cancelled_by` | EmployeePayload/null | 取消员工 | +| `participants` | EmployeePayload[] | 最终展开后的任务参与员工列表 | +| `content_type` | number/null | django_content_type.id | +| `content_type_label` | string/null | 可读模型标识,例如 `printing.printingorder` | +| `content_id` | number/null | 关联对象 ID | +| `extra` | object/null | 扩展字段 | +| `has_ending_reply` | boolean | 是否已有结束任务的有效回应 | +| `can_reply` | boolean | 当前是否允许继续回应 | +| `created_at` | string | 创建时间 | +| `updated_at` | string | 更新时间 | + +### 3.4 MissionLite + +`MissionLite` 用于轻量查询,字段与 `Mission` 基本一致,但不返回 `participants`、`has_ending_reply`、`can_reply`。 + +### 3.5 MissionReply ```json { @@ -164,446 +166,553 @@ "extra": { "attachment_ids": [1001, 1002] }, - "replied_at": "2026-04-10T12:10:00+08:00", + "replied_at": "2026-07-03T10:10:00+08:00", "ends_task": true, "is_rejected": false, "rejected_by": null, "rejected_at": null, - "created_at": "2026-04-10T12:10:00+08:00", - "updated_at": "2026-04-10T12:10:00+08:00" + "created_at": "2026-07-03T10:10:00+08:00", + "updated_at": "2026-07-03T10:10:00+08:00" } ``` -### MissionWithReplies +字段说明: -在 `Mission` 结构基础上,额外返回 `replies` 字段: +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | number | 回应 ID | +| `mission` | number | Mission.id | +| `merchant` | number | 所属商户 ID | +| `responder` | EmployeePayload | 回应员工 | +| `content` | string | 回应内容 | +| `extra` | object/null | 扩展字段 | +| `replied_at` | string | 回应时间 | +| `ends_task` | boolean | 是否结束任务 | +| `is_rejected` | boolean | 是否已被撤销 | +| `rejected_by` | EmployeePayload/null | 撤销员工 | +| `rejected_at` | string/null | 撤销时间 | +| `created_at` | string | 创建时间 | +| `updated_at` | string | 更新时间 | + +### 3.6 MissionWithReplies + +在 `Mission` 结构基础上额外返回 `replies`: ```json { "id": 1, - "merchant": 10, - "description": "跟进客户问题", - "category": 1, - "category_name": "通用", - "is_urgent": false, - "is_completed": false, - "is_cancelled": false, - "notify_if_unreplied": false, - "unreplied_notify_interval_minutes": null, - "unreplied_notify_max_count": 5, - "unreplied_notify_sent_count": 0, - "unreplied_last_notified_at": null, - "cancelled_at": null, - "creator": { - "id": 20, - "name": "张三", - "merchant_id": 10 - }, - "cancelled_by": null, + "description": "请跟进这张生产单", "participants": [], - "content_type": 33, - "content_type_label": "printing.printingorder", - "content_id": 123, - "extra": { - "source": "printing-order" - }, - "has_ending_reply": false, - "can_reply": true, - "created_at": "2026-04-10T12:00:00+08:00", - "updated_at": "2026-04-10T12:00:00+08:00", "replies": [ { "id": 100, "mission": 1, - "merchant": 10, - "responder": { - "id": 20, - "name": "张三", - "merchant_id": 10 - }, "content": "已处理", - "extra": { - "attachment_ids": [1001, 1002] - }, - "replied_at": "2026-04-10T12:10:00+08:00", - "ends_task": false, - "is_rejected": false, - "rejected_by": null, - "rejected_at": null, - "created_at": "2026-04-10T12:10:00+08:00", - "updated_at": "2026-04-10T12:10:00+08:00" + "ends_task": true, + "is_rejected": false } ] } ``` -### MissionLite +## 4. 参与者参数规则 -用于按印花订单查询任务时的轻量响应结构,不返回参与者、结束回应状态和回复列表: +任务创建和任务更新支持两类参与者参数: + +| 参数 | 类型 | 说明 | +|---|---|---| +| `participant_ids` | number[] | 显式参与员工列表,传 `Employee.id` | +| `employee_type_ids` | number[] | 职位列表,传 `EmployeeType.id`,后端会展开为该职位下的在职员工 | + +最终参与者计算规则: + +```text +最终参与者 = participant_ids 中的员工 + employee_type_ids 对应职位下的在职员工 +``` + +详细规则: + +- `participant_ids` 传的是 `Employee.id`。 +- `employee_type_ids` 传的是 `EmployeeType.id`。 +- `employee_type_ids` 只允许传当前商户下的职位。 +- 职位展开时只加入 `status=ACTIVE` 的员工。 +- 重复员工会自动去重。 +- 职位下没有员工不会报错。 +- 传入不存在或跨商户的 `EmployeeType.id` 会返回 400。 +- `PATCH /missions/{mission_id}/` 中,只要传了 `participant_ids` 或 `employee_type_ids`,即表示重新设置最终参与者列表,不是增量追加。 +- `PATCH /missions/{mission_id}/` 中,如果两个参数都不传,则不修改原参与者。 + +清空参与者: ```json { - "id": 1, - "merchant": 10, - "description": "跟进客户问题", + "participant_ids": [], + "employee_type_ids": [] +} +``` + +## 5. content_type 规则 + +`content_type` 和 `content_id` 用于把任务挂靠到业务对象上。 + +- `content_type` 是 `django_content_type.id`。 +- `content_id` 是对应业务对象主键。 +- 两者必须同时提交或同时省略。 +- 前端不应硬编码 `content_type`,不同环境的 ID 可能不同。 +- 推荐通过 `GET /api/v2/content-types/` 获取可用值。 + +响应中的 `content_type_label` 是只读辅助字段,格式为: + +```text +app_label.model +``` + +例如: + +```text +printing.printingorder +business.salesorder +``` + +当前允许前端用于 mission 关联的业务对象: + +| content_type_label | 中文说明 | +|---|---| +| `printing.plateorder` | 开版单 | +| `printing.printingorder` | 印刷单 | +| `printing.printingjob` | 印刷任务 | +| `business.purchaseorder` | 采购单 | +| `business.presalesorder` | 预销售单 | +| `business.salesorder` | 销售单 | +| `business.prepurchaseorder` | 预采购单 | +| `business.purchasereturnorder` | 采购退货单 | +| `business.salesreturnorder` | 销售退货单 | +| `business.paymentorder` | 付款单 | +| `business.receiptorder` | 收款单 | +| `stock.transferorder` | 调拨单 | +| `shipment.shipment` | 发货单 | +| `stateflow.process` | 工艺流程实例 | + +## 6. 接口列表 + +### 6.1 查询可用 content_type + +```http +GET /api/v2/content-types/ +``` + +响应:`200 OK` + +```json +[ + { + "id": 33, + "app_label": "printing", + "model": "printingorder", + "label": "printing.printingorder", + "name": "印刷单" + } +] +``` + +### 6.2 查询任务分类列表 + +```http +GET /api/v2/mission-categories/ +``` + +响应:`200 OK` + +```json +[ + { + "id": 1, + "merchant": 10, + "name": "通用", + "payload_processor": "", + "speech_enabled": false, + "created_at": "2026-07-03T10:00:00+08:00", + "updated_at": "2026-07-03T10:00:00+08:00" + } +] +``` + +### 6.3 创建任务分类 + +```http +POST /api/v2/mission-categories/ +``` + +请求体: + +```json +{ + "name": "生产跟进", + "speech_enabled": true, + "payload_processor": "structured_description_v1" +} +``` + +请求字段: + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `name` | string | 是 | 分类名称,同商户内唯一 | +| `speech_enabled` | boolean | 否 | 是否启用播报,默认 false | +| `payload_processor` | string | 否 | 可为空;可选 `structured_description_v1` | + +响应:`201 Created`,返回 `MissionCategory`。 + +常见错误: + +```json +{"name": ["分类名称已存在"]} +``` + +### 6.4 查询任务分类详情 + +```http +GET /api/v2/mission-categories/{category_id}/ +``` + +响应:`200 OK`,返回 `MissionCategory`。 + +### 6.5 更新任务分类 + +```http +PATCH /api/v2/mission-categories/{category_id}/ +``` + +请求体: + +```json +{ + "name": "售后跟进", + "speech_enabled": false, + "payload_processor": "" +} +``` + +响应:`200 OK`,返回 `MissionCategory`。 + +### 6.6 删除任务分类 + +```http +DELETE /api/v2/mission-categories/{category_id}/ +``` + +响应: + +- `204 No Content`:删除成功。 +- `400 Bad Request`:分类已被任务使用,不能删除。 + +错误示例: + +```json +{"detail": "任务分类已被使用,不能删除"} +``` + +### 6.7 查询任务列表 + +```http +GET /api/v2/missions/ +``` + +查询参数: + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `category` | number | 否 | 按任务分类 ID 过滤 | +| `is_urgent` | boolean | 否 | 按是否紧急过滤,支持 `1/0/true/false/yes/no` | +| `is_completed` | boolean | 否 | 按是否完成过滤 | +| `is_cancelled` | boolean | 否 | 按是否取消过滤 | +| `content_type` | number | 否 | 按 content_type ID 过滤 | +| `content_id` | number | 否 | 按关联对象 ID 过滤 | + +响应:`200 OK`,返回 `Mission[]`。 + +```json +[ + { + "id": 1, + "description": "请跟进这张生产单", + "category": 1, + "category_name": "通用", + "participants": [], + "can_reply": true + } +] +``` + +### 6.8 创建任务 + +```http +POST /api/v2/missions/ +``` + +请求体: + +```json +{ + "description": "请跟进这张生产单", "category": 1, - "category_name": "通用", - "is_urgent": false, - "is_completed": false, - "is_cancelled": false, - "notify_if_unreplied": false, - "unreplied_notify_interval_minutes": null, - "unreplied_notify_max_count": 5, - "unreplied_notify_sent_count": 0, - "unreplied_last_notified_at": null, - "cancelled_at": null, - "creator": { - "id": 20, - "name": "张三", - "merchant_id": 10 - }, - "cancelled_by": null, "content_type": 33, - "content_type_label": "printing.printingorder", "content_id": 123, "extra": { "source": "printing-order" }, - "created_at": "2026-04-10T12:00:00+08:00", - "updated_at": "2026-04-10T12:00:00+08:00" -} -``` - -## 任务分类列表 - -- URL: `/api/v2/mission-categories/` -- Method: `GET` - -响应:`MissionCategory[]` - -`MissionCategory` 当前包含以下字段: - -| 字段 | 类型 | 说明 | -|------|------|------| -| `id` | int | 分类 ID | -| `merchant` | int | 所属商户 ID | -| `name` | string | 分类名称 | -| `payload_processor` | string | payload 增强器标识,未启用时为空字符串 | -| `speech_enabled` | boolean | 该分类创建任务时是否触发语音播报;默认 `false` | -| `created_at` | datetime | 创建时间 | -| `updated_at` | datetime | 更新时间 | - -## 创建任务分类 - -- URL: `/api/v2/mission-categories/` -- Method: `POST` - -请求参数: - -| 参数 | 类型 | 必填 | 说明 | -|------|------|------|------| -| `name` | string | 是 | 分类名称,同商户下唯一 | -| `payload_processor` | string | 否 | payload 增强器标识;当前可选值:`structured_description_v1` | -| `speech_enabled` | boolean | 否 | 是否启用该分类的任务创建语音播报;默认 `false` | - -请求示例: - -```json -{ - "name": "售后", - "payload_processor": "structured_description_v1", - "speech_enabled": true -} -``` - -成功响应:`201 Created`,返回 `MissionCategory` - -## 任务分类详情 - -- URL: `/api/v2/mission-categories//` -- Method: `GET` - -成功响应:`MissionCategory` - -## 更新任务分类 - -- URL: `/api/v2/mission-categories//` -- Method: `PATCH` - -允许更新: - -| 参数 | 类型 | 说明 | -|------|------|------| -| `name` | string | 分类名称,同商户下唯一 | -| `payload_processor` | string | payload 增强器标识;传空字符串表示清空 | -| `speech_enabled` | boolean | 是否启用该分类的任务创建语音播报 | - -成功响应:`MissionCategory` - -## 删除任务分类 - -- URL: `/api/v2/mission-categories//` -- Method: `DELETE` - -说明: - -- 如果该分类已经被任务使用,则删除会被拒绝,返回 `400` - -成功响应:`204 No Content` - -## 任务列表 - -- URL: `/api/v2/missions/` -- Method: `GET` - -查询参数: - -| 参数 | 类型 | 说明 | -|------|------|------| -| `category` | int | 任务分类 ID | -| `is_urgent` | boolean | `true` / `false` | -| `is_completed` | boolean | `true` / `false` | -| `is_cancelled` | boolean | `true` / `false` | -| `content_type` | int | Django ContentType ID | -| `content_id` | int | 关联业务对象 ID | - -响应:`Mission[]` - -## 创建任务 - -- URL: `/api/v2/missions/` -- Method: `POST` - -请求参数: - -| 参数 | 类型 | 必填 | 说明 | -|------|------|------|------| -| `description` | string | 是 | 任务描述 | -| `category` | int | 否 | 任务分类 ID;不传时默认使用当前商户下名称为“通用”的分类,不存在则自动创建 | -| `content_type` | int/null | 否 | Django ContentType ID;必须与 `content_id` 同时提供或同时省略 | -| `content_id` | int/null | 否 | 关联业务对象 ID;必须与 `content_type` 同时提供或同时省略 | -| `extra` | object/null | 否 | 任务扩展字段,原样保存为 JSON;可传 `null` | -| `notify_if_unreplied` | boolean | 否 | 是否开启“未回复持续提醒”,默认 `false` | -| `unreplied_notify_interval_minutes` | int/null | 否 | 未回复提醒间隔(分钟);开启未回复提醒时必填 | -| `unreplied_notify_max_count` | int | 否 | 最大提醒次数,默认 `5` | -| `participant_ids` | int[] | 否 | 参与者员工 ID 列表,必须属于当前商户 | - -说明: - -- `creator`、`merchant` 由当前登录用户的 employee 自动写入 -- `category_name` 为只读字段,由后端根据分类表返回 -- `is_urgent`、`is_completed`、`is_cancelled` 均按默认值创建,不接受请求参数 -- 若关联对象存在 `merchant_id` 字段,后端会校验它必须属于当前商户 -- 若 `notify_if_unreplied=true`,则必须同时传入 `unreplied_notify_interval_minutes` -- `unreplied_notify_sent_count` 与 `unreplied_last_notified_at` 为只读运行时字段,由后端维护 - -请求示例: - -```json -{ - "description": "跟进客户问题", - "category": 1, - "extra": { - "source": "wechat", - "ticket_no": "TK-001" - }, + "participant_ids": [101, 102], + "employee_type_ids": [3, 4], "notify_if_unreplied": true, "unreplied_notify_interval_minutes": 30, - "unreplied_notify_max_count": 5, - "participant_ids": [21, 22] + "unreplied_notify_max_count": 5 } ``` -成功响应:`201 Created`,返回 `Mission` +请求字段: -## 任务详情 +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `description` | string | 是 | 任务描述,不能为空 | +| `category` | number | 否 | MissionCategory.id;不传则使用默认“通用”分类 | +| `content_type` | number/null | 否 | django_content_type.id;与 `content_id` 必须同时提供或同时省略 | +| `content_id` | number/null | 否 | 关联业务对象 ID;与 `content_type` 必须同时提供或同时省略 | +| `extra` | object/null | 否 | 扩展字段 | +| `participant_ids` | number[] | 否 | 显式参与员工 ID 列表,传 Employee.id | +| `employee_type_ids` | number[] | 否 | 参与职位 ID 列表,传 EmployeeType.id;展开为在职员工 | +| `notify_if_unreplied` | boolean | 否 | 是否开启未回复提醒,默认 false | +| `unreplied_notify_interval_minutes` | number/null | 否 | 未回复提醒间隔;开启提醒时必填 | +| `unreplied_notify_max_count` | number | 否 | 最大提醒次数,默认 5 | -- URL: `/api/v2/missions//` -- Method: `GET` +响应:`201 Created`,返回 `Mission`。 -成功响应:`Mission` +常见错误: -跨商户访问返回 `404` - -## 按印花订单查询任务及回复 - -- URL: `/api/v2/missions/by-printing-order//` -- Method: `GET` - -查询参数: - -| 参数 | 类型 | 说明 | -|------|------|------| -| `category_ids` | int[] / comma-separated string | 可选任务分类筛选。支持重复参数 `?category_ids=1&category_ids=2`,也支持逗号分隔 `?category_ids=1,2` | -| `include_details` | boolean | 是否返回完整详情,默认 `true`。传 `false` 时返回轻量结构,不带 `participants`、`has_ending_reply`、`can_reply`、`replies` | - -说明: - -- 只查询 `content_type=printing.printingorder` 且 `content_id=` 的任务 -- 只返回当前登录员工所属商户下的任务 -- `include_details=true` 时返回 `MissionWithReplies[]` -- `include_details=false` 时返回 `MissionLite[]` -- 完整模式下每个任务会内嵌其全部回复,按 `replied_at`、`id` 升序返回 -- `Mission.extra` 与 `MissionReply.extra` 都会原样返回 -- 该接口启用了 180 秒的低层缓存;缓存键会区分当前商户、`printing_order_id`、`category_ids`、`include_details` -- 在缓存有效期内,如果任务、参与者或回复刚发生变化,接口结果最多可能延迟约 3 分钟刷新 -- 若该 `printing_order_id` 不属于当前商户,返回 `404` - -请求示例: - -```http -GET /api/v2/missions/by-printing-order/123/ -GET /api/v2/missions/by-printing-order/123/?category_ids=1,2 -GET /api/v2/missions/by-printing-order/123/?include_details=false -GET /api/v2/missions/by-printing-order/123/?category_ids=2&include_details=false +```json +{"detail": "参与者不存在或不属于任务所属商户"} ``` -成功响应: +```json +{"detail": "员工职位不存在或不属于任务所属商户"} +``` -- `include_details=true`:`MissionWithReplies[]` -- `include_details=false`:`MissionLite[]` +```json +{"unreplied_notify_interval_minutes": ["开启未回复提醒时必须设置提醒间隔"]} +``` -## 更新任务 +### 6.9 查询任务详情 -- URL: `/api/v2/missions//` -- Method: `PATCH` +```http +GET /api/v2/missions/{mission_id}/ +``` -允许更新: +响应:`200 OK`,返回 `Mission`。 -| 参数 | 类型 | 说明 | -|------|------|------| -| `description` | string | 任务描述 | -| `category` | int | 任务分类 ID | -| `content_type` | int/null | 关联对象类型;必须与 `content_id` 同时提供 | -| `content_id` | int/null | 关联对象 ID;必须与 `content_type` 同时提供 | -| `extra` | object/null | 任务扩展字段,原样保存为 JSON;可传 `null` | -| `notify_if_unreplied` | boolean | 是否开启“未回复持续提醒” | -| `unreplied_notify_interval_minutes` | int/null | 未回复提醒间隔(分钟) | -| `unreplied_notify_max_count` | int | 最大提醒次数 | -| `participant_ids` | int[] | 重置参与者列表 | +### 6.10 更新任务 -禁止更新状态字段,见“基本约定” +```http +PATCH /api/v2/missions/{mission_id}/ +``` -补充说明: - -- 更新 `notify_if_unreplied`、`unreplied_notify_interval_minutes` 时,后端会重置当前任务的未回复提醒计数与上次提醒时间 -- 更新 `unreplied_notify_max_count` 不会重置已提醒次数 - -成功响应:`Mission` - -## 删除任务 - -- URL: `/api/v2/missions//` -- Method: `DELETE` - -当前不提供删除能力,固定返回: +请求体示例: ```json { - "detail": "Mission 删除接口未提供,请使用 cancel 接口取消任务" + "description": "更新后的任务描述", + "category": 2, + "extra": { + "channel": "wechat" + }, + "participant_ids": [101], + "employee_type_ids": [3], + "notify_if_unreplied": true, + "unreplied_notify_interval_minutes": 20, + "unreplied_notify_max_count": 8 } ``` -HTTP 状态码:`405 Method Not Allowed` +可更新字段: -## 获取任务回应列表 +| 字段 | 类型 | 说明 | +|---|---|---| +| `description` | string | 任务描述,不能为空 | +| `category` | number | MissionCategory.id | +| `content_type` | number/null | 与 `content_id` 必须同时提交;可提交 null 清空关联 | +| `content_id` | number/null | 与 `content_type` 必须同时提交;可提交 null 清空关联 | +| `extra` | object/null | 扩展字段;提交 null 可清空 | +| `participant_ids` | number[] | 显式员工参与者列表 | +| `employee_type_ids` | number[] | 按职位展开的参与者列表 | +| `notify_if_unreplied` | boolean | 是否开启未回复提醒 | +| `unreplied_notify_interval_minutes` | number/null | 未回复提醒间隔 | +| `unreplied_notify_max_count` | number | 未回复最大提醒次数 | -- URL: `/api/v2/missions//replies/` -- Method: `GET` +响应:`200 OK`,返回 `Mission`。 + +注意: + +- `participant_ids` 和 `employee_type_ids` 表示重新设置最终参与者列表,不是增量追加。 +- 如果两个字段都不传,则不修改参与者。 +- 若提交 `content_type` 或 `content_id`,两者必须同时出现在请求体中。 +- 状态字段不能通过该接口更新。 + +清空任务关联对象: + +```json +{ + "content_type": null, + "content_id": null +} +``` + +清空参与者: + +```json +{ + "participant_ids": [], + "employee_type_ids": [] +} +``` + +### 6.11 删除任务 + +```http +DELETE /api/v2/missions/{mission_id}/ +``` + +不支持。 + +响应:`405 Method Not Allowed` + +```json +{"detail": "Mission 删除接口未提供,请使用 cancel 接口取消任务"} +``` + +### 6.12 按印刷单查询任务 + +```http +GET /api/v2/missions/by-printing-order/{printing_order_id}/ +``` + +查询指定印刷单关联的任务。 查询参数: -| 参数 | 类型 | 说明 | -|------|------|------| -| `ends_task` | boolean | `true` / `false`,筛选是否为结束回应 | -| `is_rejected` | boolean | `true` / `false`,筛选是否已被撤销 | - -响应:`MissionReply[]`,按 `replied_at` 升序排列 - -## 创建任务回应 - -- URL: `/api/v2/missions//replies/` -- Method: `POST` - -请求参数: - | 参数 | 类型 | 必填 | 说明 | -|------|------|------|------| -| `content` | string | 是 | 回应内容 | -| `ends_task` | boolean | 否 | 是否结束任务,默认 `false` | -| `extra` | object/null | 否 | 回应扩展字段,原样保存为 JSON;可传 `null` | +|---|---|---|---| +| `category_ids` | number[]/string | 否 | 分类过滤。支持重复参数或逗号分隔,例如 `?category_ids=1,2` 或 `?category_ids=1&category_ids=2` | +| `include_details` | boolean | 否 | 是否返回详情。默认 true;false 时返回 MissionLite[] | -说明: +响应: -- `responder` 使用当前登录用户的 employee -- `extra` 由后端原样存储并原样返回,不做结构校验 -- 如果 `ends_task=true`,后端会同步设置 `Mission.is_completed=true` -- 已取消任务、或已有有效结束回应的任务不允许继续回应 +- `include_details=true` 或未传:`200 OK`,返回 `MissionWithReplies[]`。 +- `include_details=false`:`200 OK`,返回 `MissionLite[]`。 -成功响应:`201 Created`,返回 `MissionReply` +示例: -## 重新打开任务 - -- URL: `/api/v2/missions//reopen/` -- Method: `POST` -- 额外权限:`mission.reopen_mission` - -请求体可为空: - -```json -{} +```http +GET /api/v2/missions/by-printing-order/123/?category_ids=1,2&include_details=true ``` -前置条件: - -- 当前用户必须具备 `mission.reopen_mission` -- 任务未取消 -- 任务已完成 -- 任务存在有效的 `ends_task=true` 回应 - -执行效果: - -- `Mission.is_completed=false` -- 相关结束回应被标记为 `ends_task=false` -- 相关结束回应记录 `is_rejected=true`、`rejected_by=当前员工`、`rejected_at=当前时间` - -成功响应:`Mission` - -无权限响应:`403 Forbidden` - -## 取消任务 - -- URL: `/api/v2/missions//cancel/` -- Method: `POST` - -请求体可为空: +常见错误: ```json -{} +{"include_details": "必须是布尔值"} ``` -执行效果: +```json +{"category_ids": "必须是整数 ID 列表"} +``` -- `Mission.is_cancelled=true` -- `Mission.cancelled_by=当前员工` -- `Mission.cancelled_at=当前时间` -- 不强行修改 `Mission.is_completed` -- 后端会停止当前任务后续的未回复提醒,并保留任务上的提醒历史字段供查看 +### 6.13 查询任务回应列表 -成功响应:`Mission` +```http +GET /api/v2/missions/{mission_id}/replies/ +``` -## 设置紧急状态 - -- URL: `/api/v2/missions//set-urgent/` -- Method: `POST` - -请求参数: +查询参数: | 参数 | 类型 | 必填 | 说明 | -|------|------|------|------| -| `is_urgent` | boolean | 是 | 新的紧急状态 | +|---|---|---|---| +| `ends_task` | boolean | 否 | 按是否结束任务过滤 | +| `is_rejected` | boolean | 否 | 按是否已撤销过滤 | -请求示例: +响应:`200 OK`,返回 `MissionReply[]`。 + +### 6.14 创建任务回应 + +```http +POST /api/v2/missions/{mission_id}/replies/ +``` + +请求体: + +```json +{ + "content": "已处理完成", + "ends_task": true, + "extra": { + "attachment_ids": [1001, 1002] + } +} +``` + +请求字段: + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `content` | string | 是 | 回应内容,不能为空 | +| `ends_task` | boolean | 否 | 是否结束任务,默认 false | +| `extra` | object/null | 否 | 扩展字段 | + +响应:`201 Created`,返回 `MissionReply`。 + +行为说明: + +- 回应人固定为当前登录用户关联的 Employee。 +- 如果 `ends_task=true`,任务会被标记为完成。 +- 已取消任务或已有未撤销结束回应的任务不可继续回应。 + +### 6.15 重新打开任务 + +```http +POST /api/v2/missions/{mission_id}/reopen/ +``` + +权限要求:当前用户需要 Django 权限 `mission.reopen_mission`。 + +响应:`200 OK`,返回 `Mission`。 + +行为说明: + +- 用于重新打开已完成任务。 +- 会撤销已有结束任务效果,使任务回到未完成状态。 + +常见错误: + +```json +{"detail": "缺少重新打开任务权限"} +``` + +### 6.16 取消任务 + +```http +POST /api/v2/missions/{mission_id}/cancel/ +``` + +响应:`200 OK`,返回 `Mission`。 + +行为说明: + +- 取消人固定为当前登录用户关联的 Employee。 +- 取消后 `is_cancelled=true`,`cancelled_by` 和 `cancelled_at` 由后端写入。 + +### 6.17 设置任务紧急状态 + +```http +POST /api/v2/missions/{mission_id}/set-urgent/ +``` + +请求体: ```json { @@ -611,56 +720,56 @@ HTTP 状态码:`405 Method Not Allowed` } ``` -成功响应:`Mission` +请求字段: -## 撤销任务回应 +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `is_urgent` | boolean | 是 | 是否紧急 | -- URL: `/api/v2/mission-replies//reject/` -- Method: `POST` -- 额外权限:`mission.reject_mission_reply` +响应:`200 OK`,返回 `Mission`。 -请求体可为空: +### 6.18 撤销任务回应 -```json -{} +```http +POST /api/v2/mission-replies/{reply_id}/reject/ ``` -执行效果: +权限要求:当前用户需要 Django 权限 `mission.reject_mission_reply`。 -- 当前回应标记为 `is_rejected=true` -- 当前回应的 `rejected_by`、`rejected_at` 由后端写入 -- 如果当前回应原本是唯一有效的结束回应,则对应任务会被重新置为未完成 -- 如果任务重新回到“没有任何有效回复”的状态,后端会重置未回复提醒计数,并从新的空窗期重新开始计算后续提醒 +响应:`200 OK`,返回 `MissionReply`。 -成功响应:`MissionReply` +行为说明: -无权限响应:`403 Forbidden` +- 撤销人固定为当前登录用户关联的 Employee。 +- 如果撤销的是结束任务回应,任务可能会被重新打开。 -## 未回复提醒规则 +常见错误: -当任务开启 `notify_if_unreplied=true` 时,系统会通过每分钟一次的后台定时任务检查是否需要发送“未回复提醒”。 +```json +{"detail": "缺少撤销任务回应权限"} +``` -判定规则: +## 7. 常见状态码 -1. 任务开启了未回复提醒 -2. 任务未完成 -3. 任务未取消 -4. 任务当前没有任何有效回复(`is_rejected=false` 的回复) -5. `unreplied_notify_sent_count < unreplied_notify_max_count` -6. 到达提醒时间: - - 从未提醒过:`created_at + unreplied_notify_interval_minutes` - - 已提醒过:`unreplied_last_notified_at + unreplied_notify_interval_minutes` +| 状态码 | 场景 | +|---|---| +| `200 OK` | 查询、更新、状态操作成功 | +| `201 Created` | 创建任务、分类、回应成功 | +| `204 No Content` | 删除任务分类成功 | +| `400 Bad Request` | 参数非法、跨商户、业务状态不允许 | +| `401 Unauthorized` | 未登录 | +| `403 Forbidden` | 缺少 `reopen_mission` 或 `reject_mission_reply` 等权限 | +| `404 Not Found` | 对象不存在或不属于当前商户 | +| `405 Method Not Allowed` | 不支持的删除任务操作 | -发送成功后: +## 8. 前端接入建议 -- `unreplied_notify_sent_count` 自增 1 -- `unreplied_last_notified_at` 更新为本次成功发送时间 +- 创建/更新任务时,`participant_ids` 使用 `Employee.id`,`employee_type_ids` 使用 `EmployeeType.id`。 +- 如果只想按职位添加参与者,可以只传 `employee_type_ids`。 +- 如果 PATCH 不想改变参与者,不要传 `participant_ids` 和 `employee_type_ids`。 +- 如果 PATCH 想清空参与者,两个字段都传空数组。 +- `content_type` 不要硬编码,应从 `/api/v2/content-types/` 获取。 +- 普通任务编辑页不要提交状态字段,紧急、取消、重新打开、撤销回应都走独立接口。 +- 响应中的 `participants` 是后端最终展开后的员工列表,前端展示以该字段为准。 -收到有效回复后: - -- 当前未回复提醒周期会被停止 -- `unreplied_notify_sent_count` 与 `unreplied_last_notified_at` 会被重置 - -如果后续因为撤销回复或 reopen 重新回到“无有效回复”状态: - -- 系统会把该任务视为一个新的未回复周期重新开始计时 +更新日期:2026-07-03 diff --git a/docs/api_v2_mission_api_2026-07-03.md b/docs/api_v2_mission_api_2026-07-03.md new file mode 100644 index 0000000..2fa3a7c --- /dev/null +++ b/docs/api_v2_mission_api_2026-07-03.md @@ -0,0 +1,775 @@ +# Mission 任务模块 API 文档 + +更新日期:2026-07-03 + +本文档面向前端,描述 `mission` 中立任务模块的对外 API。当前 mission API 仅在 `api_v2` 提供;已检查 `api_v1/urls.py`,`api_v1` 暂无 mission 相关接口。 + +## 1. 基本约定 + +- Base URL: `/api/v2` +- 认证:所有 mission 接口均需要登录。 +- 当前员工:后端使用 `request.user.employee` 作为当前业务员工。 +- 商户隔离:任务、任务分类、参与者、回应均按当前员工所属 `merchant` 隔离。 +- 参与者对象:任务参与者是 `basic_info.Employee`,不是 Django `User`。 +- 删除任务:不提供物理删除任务接口;业务结束请使用取消接口。 +- 普通创建/更新接口不允许直接修改状态字段,状态变化必须走独立状态接口。 + +普通创建/更新禁止提交字段: + +| 字段 | 说明 | +|---|---| +| `is_urgent` | 是否紧急,使用 `set-urgent` 接口修改 | +| `is_completed` | 是否完成,由结束回应、reopen、reject 流程维护 | +| `is_cancelled` | 是否取消,使用 `cancel` 接口修改 | +| `cancelled_by` | 取消人,由后端写入 | +| `cancelled_at` | 取消时间,由后端写入 | +| `rejected_by` | 回应撤销人,由后端写入 | +| `rejected_at` | 回应撤销时间,由后端写入 | + +## 2. API v1 状态 + +已检查 `api_v1/urls.py` 和 `api_v1/views`,当前没有 mission 模块接口。 + +如前端需要任务模块,请使用本文档中的 `/api/v2/...` 接口。 + +## 3. 核心数据结构 + +### 3.1 EmployeePayload + +任务模块中所有人员字段均使用员工对象摘要: + +```json +{ + "id": 21, + "name": "李四", + "merchant_id": 10 +} +``` + +### 3.2 MissionCategory + +```json +{ + "id": 1, + "merchant": 10, + "name": "通用", + "payload_processor": "", + "speech_enabled": false, + "created_at": "2026-07-03T10:00:00+08:00", + "updated_at": "2026-07-03T10:00:00+08:00" +} +``` + +字段说明: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | number | 任务分类 ID | +| `merchant` | number | 所属商户 ID | +| `name` | string | 分类名称,同商户内唯一 | +| `payload_processor` | string | payload 增强器,可为空;当前可选 `structured_description_v1` | +| `speech_enabled` | boolean | 是否启用任务播报 | +| `created_at` | string | 创建时间 | +| `updated_at` | string | 更新时间 | + +### 3.3 Mission + +```json +{ + "id": 1, + "merchant": 10, + "description": "请跟进这张生产单", + "category": 1, + "category_name": "通用", + "is_urgent": false, + "is_completed": false, + "is_cancelled": false, + "notify_if_unreplied": true, + "unreplied_notify_interval_minutes": 30, + "unreplied_notify_max_count": 5, + "unreplied_notify_sent_count": 0, + "unreplied_last_notified_at": null, + "cancelled_at": null, + "creator": { + "id": 20, + "name": "张三", + "merchant_id": 10 + }, + "cancelled_by": null, + "participants": [ + { + "id": 21, + "name": "李四", + "merchant_id": 10 + } + ], + "content_type": 33, + "content_type_label": "printing.printingorder", + "content_id": 123, + "extra": { + "source": "printing-order" + }, + "has_ending_reply": false, + "can_reply": true, + "created_at": "2026-07-03T10:00:00+08:00", + "updated_at": "2026-07-03T10:00:00+08:00" +} +``` + +字段说明: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | number | 任务 ID | +| `merchant` | number | 所属商户 ID | +| `description` | string | 任务描述 | +| `category` | number | MissionCategory.id | +| `category_name` | string | 分类名称 | +| `is_urgent` | boolean | 是否紧急 | +| `is_completed` | boolean | 是否完成 | +| `is_cancelled` | boolean | 是否取消 | +| `notify_if_unreplied` | boolean | 是否开启未回复提醒 | +| `unreplied_notify_interval_minutes` | number/null | 未回复提醒间隔,单位分钟 | +| `unreplied_notify_max_count` | number | 未回复最大提醒次数,默认 5 | +| `unreplied_notify_sent_count` | number | 已发送未回复提醒次数 | +| `unreplied_last_notified_at` | string/null | 上一次未回复提醒时间 | +| `cancelled_at` | string/null | 取消时间 | +| `creator` | EmployeePayload | 创建员工 | +| `cancelled_by` | EmployeePayload/null | 取消员工 | +| `participants` | EmployeePayload[] | 最终展开后的任务参与员工列表 | +| `content_type` | number/null | django_content_type.id | +| `content_type_label` | string/null | 可读模型标识,例如 `printing.printingorder` | +| `content_id` | number/null | 关联对象 ID | +| `extra` | object/null | 扩展字段 | +| `has_ending_reply` | boolean | 是否已有结束任务的有效回应 | +| `can_reply` | boolean | 当前是否允许继续回应 | +| `created_at` | string | 创建时间 | +| `updated_at` | string | 更新时间 | + +### 3.4 MissionLite + +`MissionLite` 用于轻量查询,字段与 `Mission` 基本一致,但不返回 `participants`、`has_ending_reply`、`can_reply`。 + +### 3.5 MissionReply + +```json +{ + "id": 100, + "mission": 1, + "merchant": 10, + "responder": { + "id": 20, + "name": "张三", + "merchant_id": 10 + }, + "content": "已处理", + "extra": { + "attachment_ids": [1001, 1002] + }, + "replied_at": "2026-07-03T10:10:00+08:00", + "ends_task": true, + "is_rejected": false, + "rejected_by": null, + "rejected_at": null, + "created_at": "2026-07-03T10:10:00+08:00", + "updated_at": "2026-07-03T10:10:00+08:00" +} +``` + +字段说明: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | number | 回应 ID | +| `mission` | number | Mission.id | +| `merchant` | number | 所属商户 ID | +| `responder` | EmployeePayload | 回应员工 | +| `content` | string | 回应内容 | +| `extra` | object/null | 扩展字段 | +| `replied_at` | string | 回应时间 | +| `ends_task` | boolean | 是否结束任务 | +| `is_rejected` | boolean | 是否已被撤销 | +| `rejected_by` | EmployeePayload/null | 撤销员工 | +| `rejected_at` | string/null | 撤销时间 | +| `created_at` | string | 创建时间 | +| `updated_at` | string | 更新时间 | + +### 3.6 MissionWithReplies + +在 `Mission` 结构基础上额外返回 `replies`: + +```json +{ + "id": 1, + "description": "请跟进这张生产单", + "participants": [], + "replies": [ + { + "id": 100, + "mission": 1, + "content": "已处理", + "ends_task": true, + "is_rejected": false + } + ] +} +``` + +## 4. 参与者参数规则 + +任务创建和任务更新支持两类参与者参数: + +| 参数 | 类型 | 说明 | +|---|---|---| +| `participant_ids` | number[] | 显式参与员工列表,传 `Employee.id` | +| `employee_type_ids` | number[] | 职位列表,传 `EmployeeType.id`,后端会展开为该职位下的在职员工 | + +最终参与者计算规则: + +```text +最终参与者 = participant_ids 中的员工 + employee_type_ids 对应职位下的在职员工 +``` + +详细规则: + +- `participant_ids` 传的是 `Employee.id`。 +- `employee_type_ids` 传的是 `EmployeeType.id`。 +- `employee_type_ids` 只允许传当前商户下的职位。 +- 职位展开时只加入 `status=ACTIVE` 的员工。 +- 重复员工会自动去重。 +- 职位下没有员工不会报错。 +- 传入不存在或跨商户的 `EmployeeType.id` 会返回 400。 +- `PATCH /missions/{mission_id}/` 中,只要传了 `participant_ids` 或 `employee_type_ids`,即表示重新设置最终参与者列表,不是增量追加。 +- `PATCH /missions/{mission_id}/` 中,如果两个参数都不传,则不修改原参与者。 + +清空参与者: + +```json +{ + "participant_ids": [], + "employee_type_ids": [] +} +``` + +## 5. content_type 规则 + +`content_type` 和 `content_id` 用于把任务挂靠到业务对象上。 + +- `content_type` 是 `django_content_type.id`。 +- `content_id` 是对应业务对象主键。 +- 两者必须同时提交或同时省略。 +- 前端不应硬编码 `content_type`,不同环境的 ID 可能不同。 +- 推荐通过 `GET /api/v2/content-types/` 获取可用值。 + +响应中的 `content_type_label` 是只读辅助字段,格式为: + +```text +app_label.model +``` + +例如: + +```text +printing.printingorder +business.salesorder +``` + +当前允许前端用于 mission 关联的业务对象: + +| content_type_label | 中文说明 | +|---|---| +| `printing.plateorder` | 开版单 | +| `printing.printingorder` | 印刷单 | +| `printing.printingjob` | 印刷任务 | +| `business.purchaseorder` | 采购单 | +| `business.presalesorder` | 预销售单 | +| `business.salesorder` | 销售单 | +| `business.prepurchaseorder` | 预采购单 | +| `business.purchasereturnorder` | 采购退货单 | +| `business.salesreturnorder` | 销售退货单 | +| `business.paymentorder` | 付款单 | +| `business.receiptorder` | 收款单 | +| `stock.transferorder` | 调拨单 | +| `shipment.shipment` | 发货单 | +| `stateflow.process` | 工艺流程实例 | + +## 6. 接口列表 + +### 6.1 查询可用 content_type + +```http +GET /api/v2/content-types/ +``` + +响应:`200 OK` + +```json +[ + { + "id": 33, + "app_label": "printing", + "model": "printingorder", + "label": "printing.printingorder", + "name": "印刷单" + } +] +``` + +### 6.2 查询任务分类列表 + +```http +GET /api/v2/mission-categories/ +``` + +响应:`200 OK` + +```json +[ + { + "id": 1, + "merchant": 10, + "name": "通用", + "payload_processor": "", + "speech_enabled": false, + "created_at": "2026-07-03T10:00:00+08:00", + "updated_at": "2026-07-03T10:00:00+08:00" + } +] +``` + +### 6.3 创建任务分类 + +```http +POST /api/v2/mission-categories/ +``` + +请求体: + +```json +{ + "name": "生产跟进", + "speech_enabled": true, + "payload_processor": "structured_description_v1" +} +``` + +请求字段: + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `name` | string | 是 | 分类名称,同商户内唯一 | +| `speech_enabled` | boolean | 否 | 是否启用播报,默认 false | +| `payload_processor` | string | 否 | 可为空;可选 `structured_description_v1` | + +响应:`201 Created`,返回 `MissionCategory`。 + +常见错误: + +```json +{"name": ["分类名称已存在"]} +``` + +### 6.4 查询任务分类详情 + +```http +GET /api/v2/mission-categories/{category_id}/ +``` + +响应:`200 OK`,返回 `MissionCategory`。 + +### 6.5 更新任务分类 + +```http +PATCH /api/v2/mission-categories/{category_id}/ +``` + +请求体: + +```json +{ + "name": "售后跟进", + "speech_enabled": false, + "payload_processor": "" +} +``` + +响应:`200 OK`,返回 `MissionCategory`。 + +### 6.6 删除任务分类 + +```http +DELETE /api/v2/mission-categories/{category_id}/ +``` + +响应: + +- `204 No Content`:删除成功。 +- `400 Bad Request`:分类已被任务使用,不能删除。 + +错误示例: + +```json +{"detail": "任务分类已被使用,不能删除"} +``` + +### 6.7 查询任务列表 + +```http +GET /api/v2/missions/ +``` + +查询参数: + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `category` | number | 否 | 按任务分类 ID 过滤 | +| `is_urgent` | boolean | 否 | 按是否紧急过滤,支持 `1/0/true/false/yes/no` | +| `is_completed` | boolean | 否 | 按是否完成过滤 | +| `is_cancelled` | boolean | 否 | 按是否取消过滤 | +| `content_type` | number | 否 | 按 content_type ID 过滤 | +| `content_id` | number | 否 | 按关联对象 ID 过滤 | + +响应:`200 OK`,返回 `Mission[]`。 + +```json +[ + { + "id": 1, + "description": "请跟进这张生产单", + "category": 1, + "category_name": "通用", + "participants": [], + "can_reply": true + } +] +``` + +### 6.8 创建任务 + +```http +POST /api/v2/missions/ +``` + +请求体: + +```json +{ + "description": "请跟进这张生产单", + "category": 1, + "content_type": 33, + "content_id": 123, + "extra": { + "source": "printing-order" + }, + "participant_ids": [101, 102], + "employee_type_ids": [3, 4], + "notify_if_unreplied": true, + "unreplied_notify_interval_minutes": 30, + "unreplied_notify_max_count": 5 +} +``` + +请求字段: + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `description` | string | 是 | 任务描述,不能为空 | +| `category` | number | 否 | MissionCategory.id;不传则使用默认“通用”分类 | +| `content_type` | number/null | 否 | django_content_type.id;与 `content_id` 必须同时提供或同时省略 | +| `content_id` | number/null | 否 | 关联业务对象 ID;与 `content_type` 必须同时提供或同时省略 | +| `extra` | object/null | 否 | 扩展字段 | +| `participant_ids` | number[] | 否 | 显式参与员工 ID 列表,传 Employee.id | +| `employee_type_ids` | number[] | 否 | 参与职位 ID 列表,传 EmployeeType.id;展开为在职员工 | +| `notify_if_unreplied` | boolean | 否 | 是否开启未回复提醒,默认 false | +| `unreplied_notify_interval_minutes` | number/null | 否 | 未回复提醒间隔;开启提醒时必填 | +| `unreplied_notify_max_count` | number | 否 | 最大提醒次数,默认 5 | + +响应:`201 Created`,返回 `Mission`。 + +常见错误: + +```json +{"detail": "参与者不存在或不属于任务所属商户"} +``` + +```json +{"detail": "员工职位不存在或不属于任务所属商户"} +``` + +```json +{"unreplied_notify_interval_minutes": ["开启未回复提醒时必须设置提醒间隔"]} +``` + +### 6.9 查询任务详情 + +```http +GET /api/v2/missions/{mission_id}/ +``` + +响应:`200 OK`,返回 `Mission`。 + +### 6.10 更新任务 + +```http +PATCH /api/v2/missions/{mission_id}/ +``` + +请求体示例: + +```json +{ + "description": "更新后的任务描述", + "category": 2, + "extra": { + "channel": "wechat" + }, + "participant_ids": [101], + "employee_type_ids": [3], + "notify_if_unreplied": true, + "unreplied_notify_interval_minutes": 20, + "unreplied_notify_max_count": 8 +} +``` + +可更新字段: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `description` | string | 任务描述,不能为空 | +| `category` | number | MissionCategory.id | +| `content_type` | number/null | 与 `content_id` 必须同时提交;可提交 null 清空关联 | +| `content_id` | number/null | 与 `content_type` 必须同时提交;可提交 null 清空关联 | +| `extra` | object/null | 扩展字段;提交 null 可清空 | +| `participant_ids` | number[] | 显式员工参与者列表 | +| `employee_type_ids` | number[] | 按职位展开的参与者列表 | +| `notify_if_unreplied` | boolean | 是否开启未回复提醒 | +| `unreplied_notify_interval_minutes` | number/null | 未回复提醒间隔 | +| `unreplied_notify_max_count` | number | 未回复最大提醒次数 | + +响应:`200 OK`,返回 `Mission`。 + +注意: + +- `participant_ids` 和 `employee_type_ids` 表示重新设置最终参与者列表,不是增量追加。 +- 如果两个字段都不传,则不修改参与者。 +- 若提交 `content_type` 或 `content_id`,两者必须同时出现在请求体中。 +- 状态字段不能通过该接口更新。 + +清空任务关联对象: + +```json +{ + "content_type": null, + "content_id": null +} +``` + +清空参与者: + +```json +{ + "participant_ids": [], + "employee_type_ids": [] +} +``` + +### 6.11 删除任务 + +```http +DELETE /api/v2/missions/{mission_id}/ +``` + +不支持。 + +响应:`405 Method Not Allowed` + +```json +{"detail": "Mission 删除接口未提供,请使用 cancel 接口取消任务"} +``` + +### 6.12 按印刷单查询任务 + +```http +GET /api/v2/missions/by-printing-order/{printing_order_id}/ +``` + +查询指定印刷单关联的任务。 + +查询参数: + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `category_ids` | number[]/string | 否 | 分类过滤。支持重复参数或逗号分隔,例如 `?category_ids=1,2` 或 `?category_ids=1&category_ids=2` | +| `include_details` | boolean | 否 | 是否返回详情。默认 true;false 时返回 MissionLite[] | + +响应: + +- `include_details=true` 或未传:`200 OK`,返回 `MissionWithReplies[]`。 +- `include_details=false`:`200 OK`,返回 `MissionLite[]`。 + +示例: + +```http +GET /api/v2/missions/by-printing-order/123/?category_ids=1,2&include_details=true +``` + +常见错误: + +```json +{"include_details": "必须是布尔值"} +``` + +```json +{"category_ids": "必须是整数 ID 列表"} +``` + +### 6.13 查询任务回应列表 + +```http +GET /api/v2/missions/{mission_id}/replies/ +``` + +查询参数: + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `ends_task` | boolean | 否 | 按是否结束任务过滤 | +| `is_rejected` | boolean | 否 | 按是否已撤销过滤 | + +响应:`200 OK`,返回 `MissionReply[]`。 + +### 6.14 创建任务回应 + +```http +POST /api/v2/missions/{mission_id}/replies/ +``` + +请求体: + +```json +{ + "content": "已处理完成", + "ends_task": true, + "extra": { + "attachment_ids": [1001, 1002] + } +} +``` + +请求字段: + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `content` | string | 是 | 回应内容,不能为空 | +| `ends_task` | boolean | 否 | 是否结束任务,默认 false | +| `extra` | object/null | 否 | 扩展字段 | + +响应:`201 Created`,返回 `MissionReply`。 + +行为说明: + +- 回应人固定为当前登录用户关联的 Employee。 +- 如果 `ends_task=true`,任务会被标记为完成。 +- 已取消任务或已有未撤销结束回应的任务不可继续回应。 + +### 6.15 重新打开任务 + +```http +POST /api/v2/missions/{mission_id}/reopen/ +``` + +权限要求:当前用户需要 Django 权限 `mission.reopen_mission`。 + +响应:`200 OK`,返回 `Mission`。 + +行为说明: + +- 用于重新打开已完成任务。 +- 会撤销已有结束任务效果,使任务回到未完成状态。 + +常见错误: + +```json +{"detail": "缺少重新打开任务权限"} +``` + +### 6.16 取消任务 + +```http +POST /api/v2/missions/{mission_id}/cancel/ +``` + +响应:`200 OK`,返回 `Mission`。 + +行为说明: + +- 取消人固定为当前登录用户关联的 Employee。 +- 取消后 `is_cancelled=true`,`cancelled_by` 和 `cancelled_at` 由后端写入。 + +### 6.17 设置任务紧急状态 + +```http +POST /api/v2/missions/{mission_id}/set-urgent/ +``` + +请求体: + +```json +{ + "is_urgent": true +} +``` + +请求字段: + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `is_urgent` | boolean | 是 | 是否紧急 | + +响应:`200 OK`,返回 `Mission`。 + +### 6.18 撤销任务回应 + +```http +POST /api/v2/mission-replies/{reply_id}/reject/ +``` + +权限要求:当前用户需要 Django 权限 `mission.reject_mission_reply`。 + +响应:`200 OK`,返回 `MissionReply`。 + +行为说明: + +- 撤销人固定为当前登录用户关联的 Employee。 +- 如果撤销的是结束任务回应,任务可能会被重新打开。 + +常见错误: + +```json +{"detail": "缺少撤销任务回应权限"} +``` + +## 7. 常见状态码 + +| 状态码 | 场景 | +|---|---| +| `200 OK` | 查询、更新、状态操作成功 | +| `201 Created` | 创建任务、分类、回应成功 | +| `204 No Content` | 删除任务分类成功 | +| `400 Bad Request` | 参数非法、跨商户、业务状态不允许 | +| `401 Unauthorized` | 未登录 | +| `403 Forbidden` | 缺少 `reopen_mission` 或 `reject_mission_reply` 等权限 | +| `404 Not Found` | 对象不存在或不属于当前商户 | +| `405 Method Not Allowed` | 不支持的删除任务操作 | + +## 8. 前端接入建议 + +- 创建/更新任务时,`participant_ids` 使用 `Employee.id`,`employee_type_ids` 使用 `EmployeeType.id`。 +- 如果只想按职位添加参与者,可以只传 `employee_type_ids`。 +- 如果 PATCH 不想改变参与者,不要传 `participant_ids` 和 `employee_type_ids`。 +- 如果 PATCH 想清空参与者,两个字段都传空数组。 +- `content_type` 不要硬编码,应从 `/api/v2/content-types/` 获取。 +- 普通任务编辑页不要提交状态字段,紧急、取消、重新打开、撤销回应都走独立接口。 +- 响应中的 `participants` 是后端最终展开后的员工列表,前端展示以该字段为准。 + +更新日期:2026-07-03 diff --git a/docs/api_v2_mission_my_api_2026-07-03.md b/docs/api_v2_mission_my_api_2026-07-03.md new file mode 100644 index 0000000..1c1533d --- /dev/null +++ b/docs/api_v2_mission_my_api_2026-07-03.md @@ -0,0 +1,214 @@ +# GET /api/v2/missions/my/ + +获取当前登录用户相关的任务列表。 + +## Authentication + +需要 JWT 登录。 + +Authorization: Bearer + +当前用户必须绑定 Employee: + +request.user.employee 必须存在 + +## Query Params + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---:|---|---| +| `limit` | number | 否 | 800 | 分页条数,最大 1000 | +| `offset` | number | 否 | 0 | 分页偏移 | +| `created_by_me` | boolean | 否 | false | 是否只返回我创建的任务。`false` 表示不限定创建者,仍返回“我创建或我参与”的任务 | +| `is_completed` | boolean | 否 | 不限制 | 是否完成 | +| `is_cancelled` | boolean | 否 | 不限制 | 是否取消 | +| `is_urgent` | boolean | 否 | 不限制 | 是否紧急 | +| `created_at_from` | string | 否 | 不限制 | 创建时间开始,支持 `YYYY-MM-DD` 或日期时间 | +| `created_at_to` | string | 否 | 不限制 | 创建时间结束,支持 `YYYY-MM-DD` 或日期时间;传纯日期时包含整天 | + +boolean 参数支持: + +true 值:`true` / `1` / `yes` / `y` / `on` + +false 值:`false` / `0` / `no` / `n` / `off` + +## Filter Logic + +接口会根据当前 JWT 用户找到: + +current_employee = request.user.employee +current_merchant = current_employee.merchant + +基础范围: + +mission.merchant == current_merchant + +默认返回“与我相关”的任务,即满足以下任一条件: + +mission.creator == current_employee +或 +mission.participants 包含 current_employee + +当 `created_by_me=true` 时,只返回: + +mission.creator == current_employee + +状态筛选只有在显式传参时才生效: + +is_completed=false +=> 只返回未完成任务 + +is_cancelled=false +=> 只返回未取消任务 + +is_urgent=true +=> 只返回紧急任务 + +## Date Filter + +created_at_from=2026-07-03 +=> created_at >= 2026-07-03 00:00:00 + +created_at_to=2026-07-03 +=> created_at < 2026-07-04 00:00:00 + +如果传日期时间,则按传入的具体时间筛选: + +created_at_from=2026-07-03T08:30:00+08:00 +=> created_at >= 2026-07-03 08:30:00+08:00 + +created_at_to=2026-07-03T18:00:00+08:00 +=> created_at <= 2026-07-03 18:00:00+08:00 + +## Common Examples + +获取我相关的所有任务: + +GET /api/v2/missions/my/ + +获取我相关的未完成、未取消任务: + +GET /api/v2/missions/my/?is_completed=false&is_cancelled=false + +获取我创建的未完成、未取消任务: + +GET /api/v2/missions/my/?created_by_me=true&is_completed=false&is_cancelled=false + +获取我相关的紧急任务: + +GET /api/v2/missions/my/?is_urgent=true + +获取我相关的某天创建的任务: + +GET /api/v2/missions/my/?created_at_from=2026-07-03&created_at_to=2026-07-03 + +分页: + +GET /api/v2/missions/my/?limit=20&offset=0 + +## Response + +{ + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 1, + "merchant": 1, + "description": "跟进客户问题", + "category": 1, + "category_name": "通用", + "is_urgent": false, + "is_completed": false, + "is_cancelled": false, + "notify_if_unreplied": false, + "unreplied_notify_interval_minutes": null, + "unreplied_notify_max_count": 5, + "unreplied_notify_sent_count": 0, + "unreplied_last_notified_at": null, + "cancelled_at": null, + "creator": { + "id": 10, + "name": "张三", + "merchant_id": 1 + }, + "cancelled_by": null, + "participants": [ + { + "id": 11, + "name": "李四", + "merchant_id": 1 + } + ], + "content_type": 23, + "content_type_label": "printing.printingorder", + "content_id": 1001, + "extra": { + "source": "printing-order" + }, + "has_ending_reply": false, + "can_reply": true, + "created_at": "2026-07-03T15:40:00+08:00", + "updated_at": "2026-07-03T15:40:00+08:00" + } + ] +} + +## Empty Response + +{ + "count": 0, + "next": null, + "previous": null, + "results": [] +} + +## Error Responses + +### 未登录 + +401 Unauthorized + +### 当前用户未绑定 Employee + +{ + "non_field_errors": [ + "当前用户未关联员工" + ] +} + +### boolean 参数格式错误 + +{ + "is_completed": "必须是布尔值" +} + +或: + +{ + "is_cancelled": "必须是布尔值" +} + +或: + +{ + "is_urgent": "必须是布尔值" +} + +或: + +{ + "created_by_me": "必须是布尔值" +} + +### created_at_from 格式错误 + +{ + "created_at_from": "必须是日期或日期时间" +} + +### created_at_to 格式错误 + +{ + "created_at_to": "必须是日期或日期时间" +} diff --git a/flower/settings.py b/flower/settings.py index ad8ba71..e695d7e 100644 --- a/flower/settings.py +++ b/flower/settings.py @@ -175,6 +175,7 @@ INSTALLED_APPS = [ 'stateflow', 'sse', 'api_v2', + 'api_core', 'shipment', 'settlement', 'mission', @@ -218,6 +219,12 @@ SPECTACULAR_SETTINGS = { 'DESCRIPTION': 'API documentation for the Flower project', 'VERSION': '1.0.0', 'SERVE_INCLUDE_SCHEMA': False, + 'SERVE_AUTHENTICATION': [ + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ], + 'SERVE_PERMISSIONS': [ + 'rest_framework.permissions.IsAuthenticated', + ], } ROOT_URLCONF = 'flower.urls' diff --git a/flower/urls.py b/flower/urls.py index 4949157..530ef30 100644 --- a/flower/urls.py +++ b/flower/urls.py @@ -61,6 +61,7 @@ urlpatterns = [ path('admin/', admin.site.urls), path('api/v1/', include('api_v1.urls')), path('api/v2/', include('api_v2.urls')), + path('api/core/', include('api_core.urls')), path('api/backend/', include('api_man.urls')), # sse 相关端点 diff --git a/mission/services.py b/mission/services.py index 7dff5b5..b99ce82 100644 --- a/mission/services.py +++ b/mission/services.py @@ -4,6 +4,7 @@ from datetime import timedelta from django.db import transaction from django.utils import timezone +from basic_info.models import Employee, EmployeeStatusEnum, EmployeeType from mission.models import Mission, MissionCategory, MissionParticipant, MissionReply from mission.signals import ( mission_cancelled, @@ -104,11 +105,30 @@ def _clear_unreplied_notification_state_if_active(*, mission: Mission) -> None: @transaction.atomic -def set_mission_participants(*, mission: Mission, participant_ids: list[int]) -> Mission: +def set_mission_participants( + *, + mission: Mission, + participant_ids: list[int] | None = None, + employee_type_ids: list[int] | None = None, +) -> Mission: mission = Mission.objects.select_for_update().get(pk=mission.pk) participant_ids = list(dict.fromkeys(participant_ids or [])) + employee_type_ids = list(dict.fromkeys(employee_type_ids or [])) - from basic_info.models import Employee + if employee_type_ids: + employee_type_count = EmployeeType.objects.filter( + id__in=employee_type_ids, + merchant=mission.merchant, + ).count() + if employee_type_count != len(employee_type_ids): + raise ValueError("员工职位不存在或不属于任务所属商户") + + position_employee_ids = Employee.objects.filter( + merchant=mission.merchant, + position_id__in=employee_type_ids, + status=EmployeeStatusEnum.ACTIVE, + ).values_list("id", flat=True) + participant_ids = list(dict.fromkeys([*participant_ids, *position_employee_ids])) employees = list(Employee.objects.filter(id__in=participant_ids, merchant=mission.merchant)) if len(employees) != len(participant_ids): @@ -138,6 +158,7 @@ def create_mission( content_type=None, content_id: int | None = None, participant_ids: list[int] | None = None, + employee_type_ids: list[int] | None = None, notify_if_unreplied: bool = False, unreplied_notify_interval_minutes: int | None = None, unreplied_notify_max_count: int = 5, @@ -166,8 +187,12 @@ def create_mission( unreplied_notify_interval_minutes=unreplied_notify_interval_minutes, unreplied_notify_max_count=unreplied_notify_max_count, ) - if participant_ids is not None: - set_mission_participants(mission=mission, participant_ids=participant_ids) + if participant_ids is not None or employee_type_ids is not None: + set_mission_participants( + mission=mission, + participant_ids=participant_ids, + employee_type_ids=employee_type_ids, + ) _send_signal_on_commit( mission_created, sender=Mission, @@ -188,6 +213,7 @@ def update_mission( content_id: int | None = None, update_content_object: bool = False, participant_ids: list[int] | None = None, + employee_type_ids: list[int] | None = None, notify_if_unreplied=UNSET, unreplied_notify_interval_minutes=UNSET, unreplied_notify_max_count=UNSET, @@ -266,8 +292,12 @@ def update_mission( if update_fields: mission.save(update_fields=[*dict.fromkeys(update_fields), "updated_at"]) - if participant_ids is not None: - set_mission_participants(mission=mission, participant_ids=participant_ids) + if participant_ids is not None or employee_type_ids is not None: + set_mission_participants( + mission=mission, + participant_ids=participant_ids, + employee_type_ids=employee_type_ids, + ) return mission diff --git a/mission/tests.py b/mission/tests.py index 27c5af5..671e47a 100644 --- a/mission/tests.py +++ b/mission/tests.py @@ -4,7 +4,7 @@ from types import SimpleNamespace from unittest.mock import patch from django.utils import timezone -from basic_info.models import Employee, Merchant, MerchantTypeEnum +from basic_info.models import Employee, EmployeeStatusEnum, EmployeeType, Merchant, MerchantTypeEnum from mission.models import Mission, MissionCategory, MissionParticipant, MissionReply from mission.services import ( cancel_mission, @@ -25,6 +25,7 @@ from mission.signals import ( mission_reply_rejected, ) from mission.payload_processors import _split_structured_description +from api_v2.views.mission import MissionWriteSerializer from notifier.models import NotificationEventKeyEnum, Notifier, NotifierChannelEnum, NotifierRoute from notifier.services import dispatch_notification_event @@ -36,8 +37,22 @@ class MissionModelTestCase(TestCase): self.default_category = MissionCategory.objects.create(merchant=self.merchant, name="通用") self.custom_category = MissionCategory.objects.create(merchant=self.merchant, name="售后") self.other_category = MissionCategory.objects.create(merchant=self.other_merchant, name="通用") + self.printer_type = EmployeeType.objects.create(merchant=self.merchant, title="打纸") + self.empty_type = EmployeeType.objects.create(merchant=self.merchant, title="空职位") + self.other_type = EmployeeType.objects.create(merchant=self.other_merchant, title="外部职位") self.creator = Employee.objects.create(merchant=self.merchant, name="创建者") self.responder = Employee.objects.create(merchant=self.merchant, name="回应者") + self.position_employee = Employee.objects.create( + merchant=self.merchant, + name="职位参与者", + position=self.printer_type, + ) + self.inactive_position_employee = Employee.objects.create( + merchant=self.merchant, + name="离职职位参与者", + position=self.printer_type, + status=EmployeeStatusEnum.INACTIVE, + ) self.other_employee = Employee.objects.create(merchant=self.other_merchant, name="外部员工") self.mission = Mission.objects.create( merchant=self.merchant, @@ -165,6 +180,32 @@ class MissionModelTestCase(TestCase): self.assertFalse(mission.is_urgent) self.assertEqual(list(mission.participants.values_list("employee_id", flat=True)), [self.responder.id]) + def test_create_mission_adds_active_employees_from_employee_types(self): + mission = create_mission( + creator=self.creator, + description="按职位添加参与者", + participant_ids=[self.responder.id, self.position_employee.id], + employee_type_ids=[self.printer_type.id, self.empty_type.id], + ) + + self.assertEqual( + set(mission.participants.values_list("employee_id", flat=True)), + {self.responder.id, self.position_employee.id}, + ) + + def test_mission_write_serializer_accepts_employee_type_ids(self): + serializer = MissionWriteSerializer( + data={ + "description": "接口创建任务", + "employee_type_ids": [self.printer_type.id], + }, + is_create=True, + context={"employee": self.creator}, + ) + + self.assertTrue(serializer.is_valid(), serializer.errors) + self.assertEqual(serializer.validated_data["employee_type_ids"], [self.printer_type.id]) + def test_create_mission_supports_unreplied_notification_fields(self): mission = create_mission( creator=self.creator, @@ -249,6 +290,24 @@ class MissionModelTestCase(TestCase): self.assertEqual(self.mission.content_id, related.id) self.assertEqual(list(self.mission.participants.values_list("employee_id", flat=True)), [self.responder.id]) + def test_update_mission_sets_participants_from_employee_types(self): + MissionParticipant.objects.create( + merchant=self.merchant, + mission=self.mission, + employee=self.responder, + ) + + update_mission( + mission=self.mission, + updated_by=self.creator, + employee_type_ids=[self.printer_type.id], + ) + + self.assertEqual( + list(self.mission.participants.values_list("employee_id", flat=True)), + [self.position_employee.id], + ) + def test_update_mission_updates_unreplied_notification_fields(self): update_mission( mission=self.mission, @@ -286,6 +345,13 @@ class MissionModelTestCase(TestCase): participant_ids=[self.other_employee.id], ) + def test_set_participants_rejects_cross_merchant_employee_type(self): + with self.assertRaises(ValueError): + set_mission_participants( + mission=self.mission, + employee_type_ids=[self.other_type.id], + ) + def test_update_mission_rejects_cross_merchant_category(self): with self.assertRaises(ValueError): update_mission(