forked from erp-dev/erp
feat: agent api
This commit is contained in:
140
api_v2/tests.py
140
api_v2/tests.py
@@ -10,9 +10,11 @@ from rest_framework.test import APIClient, APIRequestFactory
|
|||||||
from basic_info import models as basic_models
|
from basic_info import models as basic_models
|
||||||
from printing import models as printing_models
|
from printing import models as printing_models
|
||||||
from business import models as business_models
|
from business import models as business_models
|
||||||
|
from shipment import models as shipment_models
|
||||||
from api_v2.views.printing import PrintingJobByCustomerView
|
from api_v2.views.printing import PrintingJobByCustomerView
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from stateflow import models as stateflow_models
|
from stateflow import models as stateflow_models
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
|
||||||
|
|
||||||
class QuickCreateEmployeeUserAPITest(TestCase):
|
class QuickCreateEmployeeUserAPITest(TestCase):
|
||||||
@@ -277,6 +279,144 @@ class PrintingJobByCustomerAPITest(TestCase):
|
|||||||
self.assertEqual(data['billed_quantity'], '20.00')
|
self.assertEqual(data['billed_quantity'], '20.00')
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(AGENT_ACCESS_KEY='agent-test-key')
|
||||||
|
class AgentUnshippedShipmentListAPITest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.url = '/api/v2/ai/shipments/unshipped/'
|
||||||
|
|
||||||
|
self.merchant = basic_models.Merchant.objects.create(
|
||||||
|
name='Agent商户',
|
||||||
|
type=basic_models.MerchantTypeEnum.FACTORY,
|
||||||
|
)
|
||||||
|
self.other_merchant = basic_models.Merchant.objects.create(
|
||||||
|
name='其他商户',
|
||||||
|
type=basic_models.MerchantTypeEnum.FACTORY,
|
||||||
|
)
|
||||||
|
self.customer = basic_models.Customer.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
name='客户A',
|
||||||
|
area='华东',
|
||||||
|
created_by=None,
|
||||||
|
)
|
||||||
|
self.other_customer = basic_models.Customer.objects.create(
|
||||||
|
merchant=self.other_merchant,
|
||||||
|
name='客户B',
|
||||||
|
area='华东',
|
||||||
|
created_by=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.unshipped_target = shipment_models.Shipment.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=self.customer,
|
||||||
|
shipment_date=datetime.date(2026, 4, 14),
|
||||||
|
area='华东',
|
||||||
|
address='',
|
||||||
|
contact_name='张三',
|
||||||
|
contact_phone='13800000000',
|
||||||
|
remark='目标记录',
|
||||||
|
)
|
||||||
|
self.unshipped_other_area = shipment_models.Shipment.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=self.customer,
|
||||||
|
shipment_date=datetime.date(2026, 4, 14),
|
||||||
|
area='华南',
|
||||||
|
)
|
||||||
|
delivery = shipment_models.ShipmentDelivery.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
driver_name='李司机',
|
||||||
|
vehicle_trip='TRIP-001',
|
||||||
|
)
|
||||||
|
self.shipped_shipment = shipment_models.Shipment.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=self.customer,
|
||||||
|
shipment_date=datetime.date(2026, 4, 14),
|
||||||
|
area='华东',
|
||||||
|
delivery=delivery,
|
||||||
|
)
|
||||||
|
self.other_merchant_shipment = shipment_models.Shipment.objects.create(
|
||||||
|
merchant=self.other_merchant,
|
||||||
|
customer=self.other_customer,
|
||||||
|
shipment_date=datetime.date(2026, 4, 14),
|
||||||
|
area='华东',
|
||||||
|
)
|
||||||
|
|
||||||
|
def _headers(self, key='agent-test-key'):
|
||||||
|
return {'HTTP_AUTHORIZATION': key}
|
||||||
|
|
||||||
|
def test_list_unshipped_shipments_success(self):
|
||||||
|
response = self.client.get(
|
||||||
|
self.url,
|
||||||
|
{'merchant_id': self.merchant.id, 'area': '华东'},
|
||||||
|
**self._headers(),
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data['count'], 1)
|
||||||
|
self.assertEqual(response.data['results'][0]['id'], self.unshipped_target.id)
|
||||||
|
self.assertEqual(response.data['results'][0]['customer_name'], '客户A')
|
||||||
|
self.assertIsNone(response.data['results'][0]['delivery'])
|
||||||
|
|
||||||
|
def test_list_unshipped_shipments_requires_access_key(self):
|
||||||
|
response = self.client.get(
|
||||||
|
self.url,
|
||||||
|
{'merchant_id': self.merchant.id, 'area': '华东'},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 401)
|
||||||
|
|
||||||
|
def test_list_unshipped_shipments_rejects_invalid_access_key(self):
|
||||||
|
response = self.client.get(
|
||||||
|
self.url,
|
||||||
|
{'merchant_id': self.merchant.id, 'area': '华东'},
|
||||||
|
**self._headers(key='wrong-key'),
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 401)
|
||||||
|
|
||||||
|
def test_list_unshipped_shipments_requires_merchant_and_area(self):
|
||||||
|
response = self.client.get(
|
||||||
|
self.url,
|
||||||
|
{'area': '华东'},
|
||||||
|
**self._headers(),
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertIn('merchant_id', response.data)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
self.url,
|
||||||
|
{'merchant_id': self.merchant.id},
|
||||||
|
**self._headers(),
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertIn('area', response.data)
|
||||||
|
|
||||||
|
def test_list_unshipped_shipments_filters_by_merchant(self):
|
||||||
|
response = self.client.get(
|
||||||
|
self.url,
|
||||||
|
{'merchant_id': self.other_merchant.id, 'area': '华东'},
|
||||||
|
**self._headers(),
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data['count'], 1)
|
||||||
|
self.assertEqual(response.data['results'][0]['id'], self.other_merchant_shipment.id)
|
||||||
|
|
||||||
|
def test_list_unshipped_shipments_supports_limit_offset(self):
|
||||||
|
shipment_models.Shipment.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=self.customer,
|
||||||
|
shipment_date=datetime.date(2026, 4, 15),
|
||||||
|
area='华东',
|
||||||
|
remark='第二条',
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
self.url,
|
||||||
|
{'merchant_id': self.merchant.id, 'area': '华东', 'limit': 1, 'offset': 0},
|
||||||
|
**self._headers(),
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data['count'], 2)
|
||||||
|
self.assertEqual(len(response.data['results']), 1)
|
||||||
|
|
||||||
|
|
||||||
class PrintingJobBatchAdvanceV2APITest(TestCase):
|
class PrintingJobBatchAdvanceV2APITest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.client = APIClient()
|
self.client = APIClient()
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from api_v2.views import (
|
|||||||
MissionReplyListCreateView,
|
MissionReplyListCreateView,
|
||||||
MissionReplyRejectView,
|
MissionReplyRejectView,
|
||||||
MissionSetUrgentView,
|
MissionSetUrgentView,
|
||||||
|
AgentUnshippedShipmentListView,
|
||||||
)
|
)
|
||||||
from api_v2.views.basic_info import CustomerEmployeeBindingView, MyVisiblePagesView
|
from api_v2.views.basic_info import CustomerEmployeeBindingView, MyVisiblePagesView
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ urlpatterns = [
|
|||||||
path('plate-orders/batch-update/', PlateOrderBatchUpdateView.as_view(), name='api_v2_plate_order_batch_update'),
|
path('plate-orders/batch-update/', PlateOrderBatchUpdateView.as_view(), name='api_v2_plate_order_batch_update'),
|
||||||
path('stateflow/business-objects/clone/', BusinessObjectCloneView.as_view(), name='api_v2_stateflow_business_object_clone'),
|
path('stateflow/business-objects/clone/', BusinessObjectCloneView.as_view(), name='api_v2_stateflow_business_object_clone'),
|
||||||
path('content-types/', ContentTypeListView.as_view(), name='api_v2_content_type_list'),
|
path('content-types/', ContentTypeListView.as_view(), name='api_v2_content_type_list'),
|
||||||
|
path('ai/shipments/unshipped/', AgentUnshippedShipmentListView.as_view(), name='api_v2_ai_unshipped_shipment_list'),
|
||||||
path('mission-categories/', MissionCategoryListCreateView.as_view(), name='api_v2_mission_category_list_create'),
|
path('mission-categories/', MissionCategoryListCreateView.as_view(), name='api_v2_mission_category_list_create'),
|
||||||
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'),
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from .mission import (
|
|||||||
MissionReplyRejectView,
|
MissionReplyRejectView,
|
||||||
MissionSetUrgentView,
|
MissionSetUrgentView,
|
||||||
)
|
)
|
||||||
|
from .ai import AgentUnshippedShipmentListView
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'HealthCheckView',
|
'HealthCheckView',
|
||||||
@@ -55,4 +56,5 @@ __all__ = [
|
|||||||
'MissionReplyRejectView',
|
'MissionReplyRejectView',
|
||||||
'MissionSetUrgentView',
|
'MissionSetUrgentView',
|
||||||
'ContentTypeListView',
|
'ContentTypeListView',
|
||||||
|
'AgentUnshippedShipmentListView',
|
||||||
]
|
]
|
||||||
|
|||||||
136
api_v2/views/ai.py
Normal file
136
api_v2/views/ai.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from rest_framework import authentication, mixins, serializers
|
||||||
|
from rest_framework.authentication import get_authorization_header
|
||||||
|
from rest_framework.exceptions import AuthenticationFailed
|
||||||
|
from rest_framework.generics import GenericAPIView
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
|
||||||
|
from basic_info import models as basic_models
|
||||||
|
from flower.viewsets import LimitedLimitOffsetPagination
|
||||||
|
from shipment.models import Shipment
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAccessPrincipal:
|
||||||
|
id = None
|
||||||
|
username = "agent"
|
||||||
|
is_staff = False
|
||||||
|
is_superuser = False
|
||||||
|
is_anonymous = False
|
||||||
|
is_authenticated = True
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAccessKeyAuthentication(authentication.BaseAuthentication):
|
||||||
|
"""
|
||||||
|
使用固定 AGENT_ACCESS_KEY 的轻量认证。
|
||||||
|
|
||||||
|
约定:
|
||||||
|
- 通过 Authorization 头直接传入密钥
|
||||||
|
- 不使用 Bearer 前缀
|
||||||
|
"""
|
||||||
|
|
||||||
|
def authenticate(self, request):
|
||||||
|
configured_key = str(getattr(settings, "AGENT_ACCESS_KEY", "") or "").strip()
|
||||||
|
if not configured_key:
|
||||||
|
logger.warning("Agent API authentication failed: AGENT_ACCESS_KEY is not configured")
|
||||||
|
raise AuthenticationFailed("AGENT_ACCESS_KEY 未配置")
|
||||||
|
|
||||||
|
provided_key = get_authorization_header(request).decode("utf-8").strip()
|
||||||
|
if not provided_key:
|
||||||
|
raise AuthenticationFailed("缺少 Authorization 请求头")
|
||||||
|
|
||||||
|
if provided_key != configured_key:
|
||||||
|
raise AuthenticationFailed("AGENT_ACCESS_KEY 无效")
|
||||||
|
|
||||||
|
return AgentAccessPrincipal(), None
|
||||||
|
|
||||||
|
def authenticate_header(self, request):
|
||||||
|
return "AgentAccessKey"
|
||||||
|
|
||||||
|
|
||||||
|
class AgentUnshippedShipmentQuerySerializer(serializers.Serializer):
|
||||||
|
merchant_id = serializers.IntegerField(min_value=1)
|
||||||
|
area = serializers.CharField(allow_blank=False, max_length=30)
|
||||||
|
limit = serializers.IntegerField(required=False, min_value=1)
|
||||||
|
offset = serializers.IntegerField(required=False, min_value=0)
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
area = attrs["area"].strip()
|
||||||
|
if not area:
|
||||||
|
raise serializers.ValidationError({"area": "地区不能为空"})
|
||||||
|
|
||||||
|
merchant = basic_models.Merchant.objects.filter(id=attrs["merchant_id"]).first()
|
||||||
|
if merchant is None:
|
||||||
|
raise serializers.ValidationError({"merchant_id": "商户不存在"})
|
||||||
|
|
||||||
|
attrs["area"] = area
|
||||||
|
attrs["merchant"] = merchant
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
|
class AgentShipmentListSerializer(serializers.ModelSerializer):
|
||||||
|
customer_name = serializers.CharField(source="customer.name", read_only=True)
|
||||||
|
status_display = serializers.CharField(source="get_status_display", read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Shipment
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"merchant_id",
|
||||||
|
"customer",
|
||||||
|
"customer_name",
|
||||||
|
"shipment_date",
|
||||||
|
"address",
|
||||||
|
"contact_name",
|
||||||
|
"contact_phone",
|
||||||
|
"area",
|
||||||
|
"remark",
|
||||||
|
"status",
|
||||||
|
"status_display",
|
||||||
|
"external_id",
|
||||||
|
"delivery",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class AgentUnshippedShipmentListView(mixins.ListModelMixin, GenericAPIView):
|
||||||
|
"""
|
||||||
|
Agent 专用:查询尚未进入送货单的出货单列表。
|
||||||
|
|
||||||
|
GET /api/v2/ai/shipments/unshipped/?merchant_id=<id>&area=<地区>&limit=<n>&offset=<n>
|
||||||
|
"""
|
||||||
|
|
||||||
|
authentication_classes = [AgentAccessKeyAuthentication]
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
serializer_class = AgentShipmentListSerializer
|
||||||
|
pagination_class = LimitedLimitOffsetPagination
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
query_serializer = AgentUnshippedShipmentQuerySerializer(data=request.query_params)
|
||||||
|
query_serializer.is_valid(raise_exception=True)
|
||||||
|
self.validated_query = query_serializer.validated_data
|
||||||
|
return self.list(request)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
validated_query = getattr(self, "validated_query", None)
|
||||||
|
if validated_query is None:
|
||||||
|
query_serializer = AgentUnshippedShipmentQuerySerializer(data=self.request.query_params)
|
||||||
|
query_serializer.is_valid(raise_exception=True)
|
||||||
|
validated_query = query_serializer.validated_data
|
||||||
|
|
||||||
|
merchant = validated_query["merchant"]
|
||||||
|
area = validated_query["area"]
|
||||||
|
|
||||||
|
return (
|
||||||
|
Shipment.objects.filter(
|
||||||
|
merchant=merchant,
|
||||||
|
area=area,
|
||||||
|
delivery__isnull=True,
|
||||||
|
)
|
||||||
|
.select_related("customer", "merchant")
|
||||||
|
.order_by("-created_at", "-id")
|
||||||
|
)
|
||||||
93
docs/api_v2_ai_api.md
Normal file
93
docs/api_v2_ai_api.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# API v2 Agent 接口
|
||||||
|
|
||||||
|
本文档描述 `api/v2/ai/...` 下供内部 agent 调用的简化鉴权接口。
|
||||||
|
|
||||||
|
## 鉴权方式
|
||||||
|
|
||||||
|
- 使用请求头 `Authorization`
|
||||||
|
- 直接传入固定密钥
|
||||||
|
- 不使用 `Bearer` 前缀
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET \
|
||||||
|
'https://api.example.com/api/v2/ai/shipments/unshipped/?merchant_id=1&area=华东' \
|
||||||
|
-H 'Authorization: your-agent-access-key'
|
||||||
|
```
|
||||||
|
|
||||||
|
配置项:
|
||||||
|
|
||||||
|
- [`AGENT_ACCESS_KEY`](/home/f/coding/flower/flower/settings.py)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 查询未进入送货单的出货单
|
||||||
|
|
||||||
|
- **URL**: `/api/v2/ai/shipments/unshipped/`
|
||||||
|
- **Method**: `GET`
|
||||||
|
|
||||||
|
### 查询参数
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `merchant_id` | int | 是 | 商户 ID |
|
||||||
|
| `area` | string | 是 | 地区,精确匹配 |
|
||||||
|
| `limit` | int | 否 | 分页大小 |
|
||||||
|
| `offset` | int | 否 | 分页偏移 |
|
||||||
|
|
||||||
|
### 业务定义
|
||||||
|
|
||||||
|
这里的“未出货”定义为:
|
||||||
|
|
||||||
|
- `Shipment.delivery_id is null`
|
||||||
|
|
||||||
|
也就是该出货单尚未进入送货单。
|
||||||
|
|
||||||
|
### 响应示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"count": 1,
|
||||||
|
"next": null,
|
||||||
|
"previous": null,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": 101,
|
||||||
|
"merchant_id": 1,
|
||||||
|
"customer": 12,
|
||||||
|
"customer_name": "客户A",
|
||||||
|
"shipment_date": "2026-04-14",
|
||||||
|
"address": "",
|
||||||
|
"contact_name": "张三",
|
||||||
|
"contact_phone": "13800000000",
|
||||||
|
"area": "华东",
|
||||||
|
"remark": "目标记录",
|
||||||
|
"status": 1,
|
||||||
|
"status_display": "草稿(未发布)",
|
||||||
|
"external_id": null,
|
||||||
|
"delivery": null,
|
||||||
|
"created_at": "2026-04-14T10:00:00Z",
|
||||||
|
"updated_at": "2026-04-14T10:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 错误响应
|
||||||
|
|
||||||
|
#### 401 Unauthorized
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "AGENT_ACCESS_KEY 无效"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 400 Bad Request
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"merchant_id": ["This field is required."]
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -86,6 +86,7 @@ WECOM_WEBHOOK_BASE_URL = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send'
|
|||||||
WECOM_WEBHOOK_KEY = env('WECOM_WEBHOOK_KEY', default='')
|
WECOM_WEBHOOK_KEY = env('WECOM_WEBHOOK_KEY', default='')
|
||||||
SPEAK_ENDPOINT = env('SPEAK_ENDPOINT', default='http://8.148.215.233:9004/speak')
|
SPEAK_ENDPOINT = env('SPEAK_ENDPOINT', default='http://8.148.215.233:9004/speak')
|
||||||
PRINTING_ORDER_CREATED_SPEECH_ENABLED = False
|
PRINTING_ORDER_CREATED_SPEECH_ENABLED = False
|
||||||
|
AGENT_ACCESS_KEY = env('AGENT_ACCESS_KEY', default='')
|
||||||
|
|
||||||
|
|
||||||
# CORS 配置
|
# CORS 配置
|
||||||
|
|||||||
Reference in New Issue
Block a user