forked from erp-dev/erp
fin
This commit is contained in:
1
api_core/__init__.py
Normal file
1
api_core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
7
api_core/apps.py
Normal file
7
api_core/apps.py
Normal 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
26
api_core/permissions.py
Normal 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
171
api_core/serializers.py
Normal 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
174
api_core/tests.py
Normal 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
12
api_core/urls.py
Normal 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
75
api_core/views.py
Normal 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"]
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
57
api_v2/test_schema_docs_access.py
Normal file
57
api_v2/test_schema_docs_access.py
Normal 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, [])
|
||||
@@ -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/<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/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>/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'),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
332
docs/api_core_api_2026-07-03.md
Normal file
332
docs/api_core_api_2026-07-03.md
Normal 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/merchant:403
|
||||
```
|
||||
|
||||
认证方式:
|
||||
|
||||
```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
|
||||
137
docs/api_docs_schema_usage_2026-07-03.md
Normal file
137
docs/api_docs_schema_usage_2026-07-03.md
Normal 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
775
docs/api_v2_mission_api_2026-07-03.md
Normal file
775
docs/api_v2_mission_api_2026-07-03.md
Normal 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 | 否 | 是否返回详情。默认 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
|
||||
214
docs/api_v2_mission_my_api_2026-07-03.md
Normal file
214
docs/api_v2_mission_my_api_2026-07-03.md
Normal 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": "必须是日期或日期时间"
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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 相关端点
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user