1
0
forked from erp-dev/erp
This commit is contained in:
2026-07-05 13:53:17 +08:00
parent 4538e51ad5
commit e1cc6df122
24 changed files with 3030 additions and 495 deletions

1
api_core/__init__.py Normal file
View File

@@ -0,0 +1 @@

7
api_core/apps.py Normal file
View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class ApiCoreConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "api_core"

26
api_core/permissions.py Normal file
View File

@@ -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

171
api_core/serializers.py Normal file
View File

@@ -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

174
api_core/tests.py Normal file
View File

@@ -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)

12
api_core/urls.py Normal file
View File

@@ -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

75
api_core/views.py Normal file
View File

@@ -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"]

View File

@@ -368,6 +368,7 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
printing_order_id = serializers.CharField(source='printing_order.human_id', read_only=True) 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) 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_name = serializers.CharField(source='product.name', read_only=True)
product_image_url = serializers.SerializerMethodField() product_image_url = serializers.SerializerMethodField()
status = serializers.CharField(read_only=True) status = serializers.CharField(read_only=True)
@@ -388,7 +389,8 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = models.PrintingJob model = models.PrintingJob
fields = [ 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', 'product_image_url', 'has_started',
'quantity', 'billed_quantity', 'unit', 'size', 'pieces', 'description', 'quantity', 'billed_quantity', 'unit', 'size', 'pieces', 'description',
'work_state', 'work_state_display', 'work_state', 'work_state_display',

View File

@@ -1,10 +1,12 @@
""" """
PrintingJob API 测试 PrintingJob API 测试
""" """
from datetime import datetime
from decimal import Decimal from decimal import Decimal
from django.test import TestCase from django.test import TestCase
from django.conf import settings from django.conf import settings
from django.utils import timezone
from rest_framework.test import APIClient from rest_framework.test import APIClient
from rest_framework import status from rest_framework import status
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
@@ -187,10 +189,74 @@ class PrintingJobAPITestCase(TestCase):
# 新增字段:批量推进记录(稳定输出 key # 新增字段:批量推进记录(稳定输出 key
for item in response.data['results']: 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.assertIn('batch_advance_records', item)
self.assertIsInstance(item['batch_advance_records'], list) self.assertIsInstance(item['batch_advance_records'], list)
self.assertEqual(len(item['batch_advance_records']), 0) 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): def test_list_printing_jobs_includes_is_sales_order_bound(self):
"""测试列表返回是否已绑定销售单字段,并支持关闭该字段""" """测试列表返回是否已绑定销售单字段,并支持关闭该字段"""
unbound_job = printing_models.PrintingJob.objects.create( unbound_job = printing_models.PrintingJob.objects.create(

View File

@@ -444,6 +444,9 @@ class PrintingJobFilterSet(django_filters.FilterSet):
external_order_id = django_filters.CharFilter( external_order_id = django_filters.CharFilter(
field_name="printing_order__external_order_id", lookup_expr="icontains" 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 = django_filters.NumberFilter()
product_name = django_filters.CharFilter( product_name = django_filters.CharFilter(
field_name="product__name", lookup_expr="icontains" 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") quantity_max = django_filters.NumberFilter(field_name="quantity", lookup_expr="lte")
pieces_min = django_filters.NumberFilter(field_name="pieces", lookup_expr="gte") pieces_min = django_filters.NumberFilter(field_name="pieces", lookup_expr="gte")
pieces_max = django_filters.NumberFilter(field_name="pieces", lookup_expr="lte") 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() is_production_completed = django_filters.BooleanFilter()
class Meta: class Meta:
model = models.PrintingJob model = models.PrintingJob
fields = ["printing_order", "product", "external_order_id", "is_production_completed"] 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): class PrintingJobViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet):
""" """
@@ -484,6 +505,7 @@ class PrintingJobViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet):
查询参数: 查询参数:
- printing_order: 印染订单ID - printing_order: 印染订单ID
- external_order_id: 外部订单编号(按所属订单模糊查询) - external_order_id: 外部订单编号(按所属订单模糊查询)
- customer_name: 客户名称(按所属订单客户名称模糊查询)
- product: 产品ID - product: 产品ID
- product_name: 产品名称(模糊查询) - product_name: 产品名称(模糊查询)
- unit: 单位(模糊查询) - unit: 单位(模糊查询)
@@ -491,6 +513,8 @@ class PrintingJobViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet):
- quantity_max: 最大数量 - quantity_max: 最大数量
- pieces_min: 最小件数 - pieces_min: 最小件数
- pieces_max: 最大件数 - pieces_max: 最大件数
- created_at_from: 创建时间开始(支持 YYYY-MM-DD 或日期时间)
- created_at_to: 创建时间结束YYYY-MM-DD 会包含整天)
- is_production_completed: 是否已标记完成生产true/false - is_production_completed: 是否已标记完成生产true/false
- search: 全文搜索(产品名称、单位、尺寸、备注) - search: 全文搜索(产品名称、单位、尺寸、备注)
- ordering: 排序字段 - ordering: 排序字段
@@ -529,7 +553,7 @@ class PrintingJobViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet):
if self.action in ["list", "retrieve"]: if self.action in ["list", "retrieve"]:
from business.models import SalesOrderItem, SalesOrderStatusEnum 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( queryset = queryset.annotate(
annotated_is_sales_order_bound=Exists( annotated_is_sales_order_bound=Exists(
SalesOrderItem.objects.filter( SalesOrderItem.objects.filter(

View File

@@ -1,8 +1,11 @@
from datetime import datetime
from django.core.cache import cache from django.core.cache import cache
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.test import TestCase from django.test import TestCase
from django.utils import timezone
from rest_framework.test import APIClient from rest_framework.test import APIClient
from basic_info import models as basic_models from basic_info import models as basic_models
@@ -258,7 +261,146 @@ class MissionV2APITest(TestCase):
resp = self.client.get("/api/v2/missions/") resp = self.client.get("/api/v2/missions/")
self.assertEqual(resp.status_code, 200) 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): def test_list_missions_by_printing_order_returns_nested_replies_and_extra(self):
mission = mission_models.Mission.objects.create( mission = mission_models.Mission.objects.create(

View File

@@ -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, [])

View File

@@ -23,6 +23,8 @@ from api_v2.views import (
MissionCategoryListCreateView, MissionCategoryListCreateView,
MissionDetailView, MissionDetailView,
MissionListCreateView, MissionListCreateView,
MissionMyStatusCountsView,
MissionMyView,
MissionReopenView, MissionReopenView,
MissionReplyListCreateView, MissionReplyListCreateView,
MissionReplyRejectView, MissionReplyRejectView,
@@ -79,6 +81,8 @@ urlpatterns = [
path('mission-categories/<int:category_id>/', MissionCategoryDetailView.as_view(), name='api_v2_mission_category_detail'), path('mission-categories/<int:category_id>/', MissionCategoryDetailView.as_view(), name='api_v2_mission_category_detail'),
path('missions/', MissionListCreateView.as_view(), name='api_v2_mission_list_create'), path('missions/', MissionListCreateView.as_view(), name='api_v2_mission_list_create'),
path('missions/by-printing-order/<int:printing_order_id>/', MissionByPrintingOrderView.as_view(), name='api_v2_mission_by_printing_order'), path('missions/by-printing-order/<int:printing_order_id>/', 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/<int:mission_id>/', MissionDetailView.as_view(), name='api_v2_mission_detail'), path('missions/<int:mission_id>/', MissionDetailView.as_view(), name='api_v2_mission_detail'),
path('missions/<int:mission_id>/replies/', MissionReplyListCreateView.as_view(), name='api_v2_mission_reply_list_create'), path('missions/<int:mission_id>/replies/', MissionReplyListCreateView.as_view(), name='api_v2_mission_reply_list_create'),
path('missions/<int:mission_id>/reopen/', MissionReopenView.as_view(), name='api_v2_mission_reopen'), path('missions/<int:mission_id>/reopen/', MissionReopenView.as_view(), name='api_v2_mission_reopen'),

View File

@@ -27,6 +27,8 @@ from .mission import (
MissionCategoryListCreateView, MissionCategoryListCreateView,
MissionDetailView, MissionDetailView,
MissionListCreateView, MissionListCreateView,
MissionMyStatusCountsView,
MissionMyView,
MissionReopenView, MissionReopenView,
MissionReplyListCreateView, MissionReplyListCreateView,
MissionReplyRejectView, MissionReplyRejectView,
@@ -79,6 +81,8 @@ __all__ = [
'MissionCategoryListCreateView', 'MissionCategoryListCreateView',
'MissionDetailView', 'MissionDetailView',
'MissionListCreateView', 'MissionListCreateView',
'MissionMyStatusCountsView',
'MissionMyView',
'MissionReopenView', 'MissionReopenView',
'MissionReplyListCreateView', 'MissionReplyListCreateView',
'MissionReplyRejectView', 'MissionReplyRejectView',

View File

@@ -1,13 +1,19 @@
from datetime import datetime, time, timedelta
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache from django.core.cache import cache
from django.db import IntegrityError 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.db.models import ProtectedError
from django.shortcuts import get_object_or_404 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 import permissions, serializers, status
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from flower.viewsets import LimitedLimitOffsetPagination
from mission import models as mission_models from mission import models as mission_models
from mission import services as mission_services from mission import services as mission_services
@@ -47,6 +53,11 @@ class MissionWriteSerializer(serializers.Serializer):
required=False, required=False,
allow_empty=True, allow_empty=True,
) )
employee_type_ids = serializers.ListField(
child=serializers.IntegerField(min_value=1),
required=False,
allow_empty=True,
)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.is_create = kwargs.pop("is_create", False) 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: "必须是布尔值"}) 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): def _mission_queryset_for_employee(employee, *, include_details: bool = True):
has_ending_reply_subquery = mission_models.MissionReply.objects.filter( has_ending_reply_subquery = mission_models.MissionReply.objects.filter(
mission_id=OuterRef("pk"), mission_id=OuterRef("pk"),
@@ -402,6 +434,7 @@ class MissionCategoryListCreateView(APIView):
queryset = _mission_category_queryset_for_employee(employee) queryset = _mission_category_queryset_for_employee(employee)
return Response(MissionCategorySerializer(queryset, many=True).data) return Response(MissionCategorySerializer(queryset, many=True).data)
@extend_schema(request=MissionWriteSerializer, responses={201: MissionSerializer})
def post(self, request): def post(self, request):
employee = _get_employee(request) employee = _get_employee(request)
serializer = MissionCategoryWriteSerializer(data=request.data, context={"employee": employee}) serializer = MissionCategoryWriteSerializer(data=request.data, context={"employee": employee})
@@ -466,6 +499,7 @@ class MissionCategoryDetailView(APIView):
class MissionListCreateView(APIView): class MissionListCreateView(APIView):
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
pagination_class = LimitedLimitOffsetPagination
def get(self, request): def get(self, request):
employee = _get_employee(request) employee = _get_employee(request)
@@ -482,7 +516,10 @@ class MissionListCreateView(APIView):
if request.query_params.get("content_id"): if request.query_params.get("content_id"):
queryset = queryset.filter(content_id=request.query_params["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): def post(self, request):
employee = _get_employee(request) employee = _get_employee(request)
@@ -498,6 +535,7 @@ class MissionListCreateView(APIView):
content_id=data.get("content_id"), content_id=data.get("content_id"),
extra=data.get("extra"), extra=data.get("extra"),
participant_ids=data.get("participant_ids"), participant_ids=data.get("participant_ids"),
employee_type_ids=data.get("employee_type_ids"),
notify_if_unreplied=data.get("notify_if_unreplied", False), notify_if_unreplied=data.get("notify_if_unreplied", False),
unreplied_notify_interval_minutes=data.get("unreplied_notify_interval_minutes"), unreplied_notify_interval_minutes=data.get("unreplied_notify_interval_minutes"),
unreplied_notify_max_count=data.get("unreplied_notify_max_count", 5), 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) 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): class MissionByPrintingOrderView(APIView):
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
@@ -568,6 +665,7 @@ class MissionDetailView(APIView):
mission = self.get_object(request, mission_id) mission = self.get_object(request, mission_id)
return Response(MissionSerializer(mission).data) return Response(MissionSerializer(mission).data)
@extend_schema(request=MissionWriteSerializer, responses=MissionSerializer)
def patch(self, request, mission_id): def patch(self, request, mission_id):
employee = _get_employee(request) employee = _get_employee(request)
mission = self.get_object(request, mission_id) 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), 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), update_content_object=("content_type" in request.data or "content_id" in request.data),
participant_ids=data.get("participant_ids"), participant_ids=data.get("participant_ids"),
employee_type_ids=data.get("employee_type_ids"),
notify_if_unreplied=( notify_if_unreplied=(
data["notify_if_unreplied"] data["notify_if_unreplied"]
if "notify_if_unreplied" in data if "notify_if_unreplied" in data

View File

@@ -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/merchant403
```
认证方式:
```http
Authorization: Bearer <access_token>
```
登录接口仍为:
```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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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 | 否 | 是否返回详情。默认 truefalse 时返回 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

View File

@@ -0,0 +1,214 @@
# GET /api/v2/missions/my/
获取当前登录用户相关的任务列表。
## Authentication
需要 JWT 登录。
Authorization: Bearer <access_token>
当前用户必须绑定 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": "必须是日期或日期时间"
}

View File

@@ -175,6 +175,7 @@ INSTALLED_APPS = [
'stateflow', 'stateflow',
'sse', 'sse',
'api_v2', 'api_v2',
'api_core',
'shipment', 'shipment',
'settlement', 'settlement',
'mission', 'mission',
@@ -218,6 +219,12 @@ SPECTACULAR_SETTINGS = {
'DESCRIPTION': 'API documentation for the Flower project', 'DESCRIPTION': 'API documentation for the Flower project',
'VERSION': '1.0.0', 'VERSION': '1.0.0',
'SERVE_INCLUDE_SCHEMA': False, 'SERVE_INCLUDE_SCHEMA': False,
'SERVE_AUTHENTICATION': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
],
'SERVE_PERMISSIONS': [
'rest_framework.permissions.IsAuthenticated',
],
} }
ROOT_URLCONF = 'flower.urls' ROOT_URLCONF = 'flower.urls'

View File

@@ -61,6 +61,7 @@ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('api/v1/', include('api_v1.urls')), path('api/v1/', include('api_v1.urls')),
path('api/v2/', include('api_v2.urls')), path('api/v2/', include('api_v2.urls')),
path('api/core/', include('api_core.urls')),
path('api/backend/', include('api_man.urls')), path('api/backend/', include('api_man.urls')),
# sse 相关端点 # sse 相关端点

View File

@@ -4,6 +4,7 @@ from datetime import timedelta
from django.db import transaction from django.db import transaction
from django.utils import timezone from django.utils import timezone
from basic_info.models import Employee, EmployeeStatusEnum, EmployeeType
from mission.models import Mission, MissionCategory, MissionParticipant, MissionReply from mission.models import Mission, MissionCategory, MissionParticipant, MissionReply
from mission.signals import ( from mission.signals import (
mission_cancelled, mission_cancelled,
@@ -104,11 +105,30 @@ def _clear_unreplied_notification_state_if_active(*, mission: Mission) -> None:
@transaction.atomic @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) mission = Mission.objects.select_for_update().get(pk=mission.pk)
participant_ids = list(dict.fromkeys(participant_ids or [])) 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)) employees = list(Employee.objects.filter(id__in=participant_ids, merchant=mission.merchant))
if len(employees) != len(participant_ids): if len(employees) != len(participant_ids):
@@ -138,6 +158,7 @@ def create_mission(
content_type=None, content_type=None,
content_id: int | None = None, content_id: int | None = None,
participant_ids: list[int] | None = None, participant_ids: list[int] | None = None,
employee_type_ids: list[int] | None = None,
notify_if_unreplied: bool = False, notify_if_unreplied: bool = False,
unreplied_notify_interval_minutes: int | None = None, unreplied_notify_interval_minutes: int | None = None,
unreplied_notify_max_count: int = 5, unreplied_notify_max_count: int = 5,
@@ -166,8 +187,12 @@ def create_mission(
unreplied_notify_interval_minutes=unreplied_notify_interval_minutes, unreplied_notify_interval_minutes=unreplied_notify_interval_minutes,
unreplied_notify_max_count=unreplied_notify_max_count, unreplied_notify_max_count=unreplied_notify_max_count,
) )
if participant_ids is not None: if participant_ids is not None or employee_type_ids is not None:
set_mission_participants(mission=mission, participant_ids=participant_ids) set_mission_participants(
mission=mission,
participant_ids=participant_ids,
employee_type_ids=employee_type_ids,
)
_send_signal_on_commit( _send_signal_on_commit(
mission_created, mission_created,
sender=Mission, sender=Mission,
@@ -188,6 +213,7 @@ def update_mission(
content_id: int | None = None, content_id: int | None = None,
update_content_object: bool = False, update_content_object: bool = False,
participant_ids: list[int] | None = None, participant_ids: list[int] | None = None,
employee_type_ids: list[int] | None = None,
notify_if_unreplied=UNSET, notify_if_unreplied=UNSET,
unreplied_notify_interval_minutes=UNSET, unreplied_notify_interval_minutes=UNSET,
unreplied_notify_max_count=UNSET, unreplied_notify_max_count=UNSET,
@@ -266,8 +292,12 @@ def update_mission(
if update_fields: if update_fields:
mission.save(update_fields=[*dict.fromkeys(update_fields), "updated_at"]) mission.save(update_fields=[*dict.fromkeys(update_fields), "updated_at"])
if participant_ids is not None: if participant_ids is not None or employee_type_ids is not None:
set_mission_participants(mission=mission, participant_ids=participant_ids) set_mission_participants(
mission=mission,
participant_ids=participant_ids,
employee_type_ids=employee_type_ids,
)
return mission return mission

View File

@@ -4,7 +4,7 @@ from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
from django.utils import timezone 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.models import Mission, MissionCategory, MissionParticipant, MissionReply
from mission.services import ( from mission.services import (
cancel_mission, cancel_mission,
@@ -25,6 +25,7 @@ from mission.signals import (
mission_reply_rejected, mission_reply_rejected,
) )
from mission.payload_processors import _split_structured_description 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.models import NotificationEventKeyEnum, Notifier, NotifierChannelEnum, NotifierRoute
from notifier.services import dispatch_notification_event 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.default_category = MissionCategory.objects.create(merchant=self.merchant, name="通用")
self.custom_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.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.creator = Employee.objects.create(merchant=self.merchant, name="创建者")
self.responder = 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.other_employee = Employee.objects.create(merchant=self.other_merchant, name="外部员工")
self.mission = Mission.objects.create( self.mission = Mission.objects.create(
merchant=self.merchant, merchant=self.merchant,
@@ -165,6 +180,32 @@ class MissionModelTestCase(TestCase):
self.assertFalse(mission.is_urgent) self.assertFalse(mission.is_urgent)
self.assertEqual(list(mission.participants.values_list("employee_id", flat=True)), [self.responder.id]) 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): def test_create_mission_supports_unreplied_notification_fields(self):
mission = create_mission( mission = create_mission(
creator=self.creator, creator=self.creator,
@@ -249,6 +290,24 @@ class MissionModelTestCase(TestCase):
self.assertEqual(self.mission.content_id, related.id) self.assertEqual(self.mission.content_id, related.id)
self.assertEqual(list(self.mission.participants.values_list("employee_id", flat=True)), [self.responder.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): def test_update_mission_updates_unreplied_notification_fields(self):
update_mission( update_mission(
mission=self.mission, mission=self.mission,
@@ -286,6 +345,13 @@ class MissionModelTestCase(TestCase):
participant_ids=[self.other_employee.id], 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): def test_update_mission_rejects_cross_merchant_category(self):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
update_mission( update_mission(