forked from erp-dev/erp
feat: agent api complete
This commit is contained in:
205
api_v2/tests.py
205
api_v2/tests.py
@@ -352,9 +352,81 @@ class AgentUnshippedShipmentListAPITest(TestCase):
|
||||
)
|
||||
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'])
|
||||
result = response.data['results'][0]
|
||||
self.assertEqual(result['id'], self.unshipped_target.id)
|
||||
self.assertEqual(result['customer_name'], '客户A')
|
||||
self.assertIsNone(result['delivery'])
|
||||
self.assertIn('sales_items', result)
|
||||
|
||||
def test_list_unshipped_shipments_includes_sales_items_with_printing_job_width(self):
|
||||
category = basic_models.ProductCategory.objects.create(
|
||||
merchant=self.merchant, name='品类', product_prefix='T',
|
||||
)
|
||||
product = basic_models.Product.objects.create(
|
||||
merchant=self.merchant, category=category, name='产品甲',
|
||||
)
|
||||
printing_order = printing_models.PrintingOrder.objects.create(
|
||||
customer=self.customer,
|
||||
fabric='棉',
|
||||
width='150cm',
|
||||
)
|
||||
printing_job = printing_models.PrintingJob.objects.create(
|
||||
printing_order=printing_order,
|
||||
product=product,
|
||||
quantity=100,
|
||||
unit='米',
|
||||
)
|
||||
item_with_job = shipment_models.SalesItem.objects.create(
|
||||
merchant=self.merchant,
|
||||
shipment=self.unshipped_target,
|
||||
name='销售品甲',
|
||||
quantity=50,
|
||||
unit=shipment_models.UnitChoices.PIECE,
|
||||
printing_job_id=printing_job.id,
|
||||
)
|
||||
item_no_job = shipment_models.SalesItem.objects.create(
|
||||
merchant=self.merchant,
|
||||
shipment=self.unshipped_target,
|
||||
name='销售品乙',
|
||||
quantity=10,
|
||||
unit=shipment_models.UnitChoices.PIECE,
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
self.url,
|
||||
{'merchant_id': self.merchant.id, 'area': '华东'},
|
||||
**self._headers(),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
result = response.data['results'][0]
|
||||
items_by_id = {i['id']: i for i in result['sales_items']}
|
||||
|
||||
self.assertIn(item_with_job.id, items_by_id)
|
||||
self.assertEqual(items_by_id[item_with_job.id]['printing_job_width'], '150cm')
|
||||
self.assertEqual(items_by_id[item_with_job.id]['printing_job_id'], printing_job.id)
|
||||
|
||||
self.assertIn(item_no_job.id, items_by_id)
|
||||
self.assertIsNone(items_by_id[item_no_job.id]['printing_job_width'])
|
||||
|
||||
def test_list_unshipped_shipments_excludes_soft_deleted_sales_items(self):
|
||||
item = shipment_models.SalesItem.objects.create(
|
||||
merchant=self.merchant,
|
||||
shipment=self.unshipped_target,
|
||||
name='待删除品',
|
||||
quantity=5,
|
||||
unit=shipment_models.UnitChoices.PIECE,
|
||||
delete_at=datetime.datetime(2026, 4, 14, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
self.url,
|
||||
{'merchant_id': self.merchant.id, 'area': '华东'},
|
||||
**self._headers(),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
result = response.data['results'][0]
|
||||
item_ids = [i['id'] for i in result['sales_items']]
|
||||
self.assertNotIn(item.id, item_ids)
|
||||
|
||||
def test_list_unshipped_shipments_requires_access_key(self):
|
||||
response = self.client.get(
|
||||
@@ -2093,3 +2165,130 @@ class PrintingJobBatchAddParametersAPITest(TestCase):
|
||||
format='json'
|
||||
)
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
|
||||
@override_settings(AGENT_ACCESS_KEY='agent-test-key')
|
||||
class AgentTransportVehicleAPITest(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.list_url = '/api/v2/ai/transport-vehicles/'
|
||||
|
||||
self.merchant = basic_models.Merchant.objects.create(
|
||||
name='车辆商户',
|
||||
type=basic_models.MerchantTypeEnum.FACTORY,
|
||||
)
|
||||
self.other_merchant = basic_models.Merchant.objects.create(
|
||||
name='其他商户',
|
||||
type=basic_models.MerchantTypeEnum.FACTORY,
|
||||
)
|
||||
|
||||
self.vehicle_a = basic_models.TransportVehicle.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='大卡车',
|
||||
license_plate='粤A12345',
|
||||
)
|
||||
self.cap_a1 = basic_models.TransportVehicleMaterialCapacity.objects.create(
|
||||
merchant=self.merchant,
|
||||
transport_vehicle=self.vehicle_a,
|
||||
material_name='坯布',
|
||||
capacity=500,
|
||||
)
|
||||
self.cap_a2 = basic_models.TransportVehicleMaterialCapacity.objects.create(
|
||||
merchant=self.merchant,
|
||||
transport_vehicle=self.vehicle_a,
|
||||
material_name='成品',
|
||||
capacity=300,
|
||||
)
|
||||
self.vehicle_b = basic_models.TransportVehicle.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='小货车',
|
||||
license_plate='粤B67890',
|
||||
)
|
||||
self.other_vehicle = basic_models.TransportVehicle.objects.create(
|
||||
merchant=self.other_merchant,
|
||||
name='他商户车',
|
||||
license_plate='粤A12345', # same plate, different merchant
|
||||
)
|
||||
|
||||
def _headers(self, key='agent-test-key'):
|
||||
return {'HTTP_AUTHORIZATION': key}
|
||||
|
||||
# --- list ---
|
||||
|
||||
def test_list_vehicles_success(self):
|
||||
response = self.client.get(
|
||||
self.list_url,
|
||||
{'merchant_id': self.merchant.id},
|
||||
**self._headers(),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data['count'], 2)
|
||||
ids = [r['id'] for r in response.data['results']]
|
||||
self.assertIn(self.vehicle_a.id, ids)
|
||||
self.assertIn(self.vehicle_b.id, ids)
|
||||
|
||||
def test_list_vehicles_includes_material_capacities(self):
|
||||
response = self.client.get(
|
||||
self.list_url,
|
||||
{'merchant_id': self.merchant.id},
|
||||
**self._headers(),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
vehicle_a_data = next(r for r in response.data['results'] if r['id'] == self.vehicle_a.id)
|
||||
material_names = {c['material_name'] for c in vehicle_a_data['material_capacities']}
|
||||
self.assertEqual(material_names, {'坯布', '成品'})
|
||||
|
||||
def test_list_vehicles_isolates_by_merchant(self):
|
||||
response = self.client.get(
|
||||
self.list_url,
|
||||
{'merchant_id': self.other_merchant.id},
|
||||
**self._headers(),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data['count'], 1)
|
||||
self.assertEqual(response.data['results'][0]['id'], self.other_vehicle.id)
|
||||
|
||||
def test_list_vehicles_requires_merchant_id(self):
|
||||
response = self.client.get(self.list_url, **self._headers())
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn('merchant_id', response.data)
|
||||
|
||||
def test_list_vehicles_rejects_nonexistent_merchant(self):
|
||||
response = self.client.get(
|
||||
self.list_url,
|
||||
{'merchant_id': 999999},
|
||||
**self._headers(),
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_list_vehicles_requires_access_key(self):
|
||||
response = self.client.get(self.list_url, {'merchant_id': self.merchant.id})
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
# --- detail ---
|
||||
|
||||
def test_detail_vehicle_success(self):
|
||||
url = f'/api/v2/ai/transport-vehicles/{self.vehicle_a.license_plate}/'
|
||||
response = self.client.get(url, {'merchant_id': self.merchant.id}, **self._headers())
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data['id'], self.vehicle_a.id)
|
||||
self.assertEqual(response.data['license_plate'], '粤A12345')
|
||||
capacities = {c['material_name']: c['capacity'] for c in response.data['material_capacities']}
|
||||
self.assertEqual(capacities, {'坯布': 500, '成品': 300})
|
||||
|
||||
def test_detail_vehicle_same_plate_different_merchant_returns_404(self):
|
||||
# other_merchant also has '粤A12345', but querying with self.merchant should return vehicle_a
|
||||
url = f'/api/v2/ai/transport-vehicles/{self.vehicle_a.license_plate}/'
|
||||
response = self.client.get(url, {'merchant_id': self.other_merchant.id}, **self._headers())
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data['id'], self.other_vehicle.id)
|
||||
|
||||
def test_detail_vehicle_not_found_returns_404(self):
|
||||
url = '/api/v2/ai/transport-vehicles/粤Z99999/'
|
||||
response = self.client.get(url, {'merchant_id': self.merchant.id}, **self._headers())
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_detail_vehicle_requires_access_key(self):
|
||||
url = f'/api/v2/ai/transport-vehicles/{self.vehicle_a.license_plate}/'
|
||||
response = self.client.get(url, {'merchant_id': self.merchant.id})
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
@@ -25,6 +25,8 @@ from api_v2.views import (
|
||||
MissionReplyRejectView,
|
||||
MissionSetUrgentView,
|
||||
AgentUnshippedShipmentListView,
|
||||
AgentTransportVehicleListView,
|
||||
AgentTransportVehicleDetailView,
|
||||
)
|
||||
from api_v2.views.basic_info import CustomerEmployeeBindingView, MyVisiblePagesView
|
||||
|
||||
@@ -46,6 +48,8 @@ urlpatterns = [
|
||||
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('ai/shipments/unshipped/', AgentUnshippedShipmentListView.as_view(), name='api_v2_ai_unshipped_shipment_list'),
|
||||
path('ai/transport-vehicles/', AgentTransportVehicleListView.as_view(), name='api_v2_ai_transport_vehicle_list'),
|
||||
path('ai/transport-vehicles/<str:license_plate>/', AgentTransportVehicleDetailView.as_view(), name='api_v2_ai_transport_vehicle_detail'),
|
||||
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('missions/', MissionListCreateView.as_view(), name='api_v2_mission_list_create'),
|
||||
|
||||
@@ -29,7 +29,7 @@ from .mission import (
|
||||
MissionReplyRejectView,
|
||||
MissionSetUrgentView,
|
||||
)
|
||||
from .ai import AgentUnshippedShipmentListView
|
||||
from .ai import AgentUnshippedShipmentListView, AgentTransportVehicleListView, AgentTransportVehicleDetailView
|
||||
|
||||
__all__ = [
|
||||
'HealthCheckView',
|
||||
@@ -57,4 +57,6 @@ __all__ = [
|
||||
'MissionSetUrgentView',
|
||||
'ContentTypeListView',
|
||||
'AgentUnshippedShipmentListView',
|
||||
'AgentTransportVehicleListView',
|
||||
'AgentTransportVehicleDetailView',
|
||||
]
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import Http404
|
||||
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 rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from basic_info import models as basic_models
|
||||
from flower.viewsets import LimitedLimitOffsetPagination
|
||||
from shipment.models import Shipment
|
||||
from printing.models import PrintingJob
|
||||
from shipment.models import SalesItem, Shipment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -71,9 +75,33 @@ class AgentUnshippedShipmentQuerySerializer(serializers.Serializer):
|
||||
return attrs
|
||||
|
||||
|
||||
class AgentSalesItemSerializer(serializers.ModelSerializer):
|
||||
unit_display = serializers.CharField(source="get_unit_display", read_only=True)
|
||||
printing_job_width = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = SalesItem
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"quantity",
|
||||
"unit",
|
||||
"unit_display",
|
||||
"position",
|
||||
"remark",
|
||||
"printing_job_id",
|
||||
"printing_job_width",
|
||||
]
|
||||
|
||||
def get_printing_job_width(self, obj):
|
||||
width_map = self.context.get("printing_job_width_map", {})
|
||||
return width_map.get(obj.printing_job_id)
|
||||
|
||||
|
||||
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)
|
||||
sales_items = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Shipment
|
||||
@@ -91,11 +119,17 @@ class AgentShipmentListSerializer(serializers.ModelSerializer):
|
||||
"status",
|
||||
"status_display",
|
||||
"external_id",
|
||||
"geo_coordinates",
|
||||
"delivery",
|
||||
"sales_items",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
def get_sales_items(self, obj):
|
||||
items = [i for i in obj.items.all() if i.delete_at is None]
|
||||
return AgentSalesItemSerializer(items, many=True, context=self.context).data
|
||||
|
||||
|
||||
class AgentUnshippedShipmentListView(mixins.ListModelMixin, GenericAPIView):
|
||||
"""
|
||||
@@ -115,6 +149,31 @@ class AgentUnshippedShipmentListView(mixins.ListModelMixin, GenericAPIView):
|
||||
self.validated_query = query_serializer.validated_data
|
||||
return self.list(request)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
page = self.paginate_queryset(queryset)
|
||||
shipments = page if page is not None else queryset
|
||||
|
||||
# Batch-fetch printing job widths to avoid N+1
|
||||
pj_ids = {
|
||||
item.printing_job_id
|
||||
for shipment in shipments
|
||||
for item in shipment.items.all()
|
||||
if item.printing_job_id and item.delete_at is None
|
||||
}
|
||||
width_map = {}
|
||||
if pj_ids:
|
||||
width_map = {
|
||||
job.id: job.printing_order.width
|
||||
for job in PrintingJob.objects.filter(id__in=pj_ids).select_related("printing_order")
|
||||
}
|
||||
|
||||
ctx = {**self.get_serializer_context(), "printing_job_width_map": width_map}
|
||||
serializer = self.get_serializer(shipments, many=True, context=ctx)
|
||||
if page is not None:
|
||||
return self.get_paginated_response(serializer.data)
|
||||
return Response(serializer.data)
|
||||
|
||||
def get_queryset(self):
|
||||
validated_query = getattr(self, "validated_query", None)
|
||||
if validated_query is None:
|
||||
@@ -132,5 +191,96 @@ class AgentUnshippedShipmentListView(mixins.ListModelMixin, GenericAPIView):
|
||||
delivery__isnull=True,
|
||||
)
|
||||
.select_related("customer", "merchant")
|
||||
.prefetch_related("items")
|
||||
.order_by("-created_at", "-id")
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vehicle views
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class AgentMerchantQuerySerializer(serializers.Serializer):
|
||||
"""Base serializer that validates merchant_id and returns merchant instance."""
|
||||
|
||||
merchant_id = serializers.IntegerField(min_value=1)
|
||||
|
||||
def validate_merchant_id(self, value):
|
||||
merchant = basic_models.Merchant.objects.filter(id=value).first()
|
||||
if merchant is None:
|
||||
raise serializers.ValidationError("商户不存在")
|
||||
return merchant
|
||||
|
||||
|
||||
class AgentTransportVehicleCapacitySerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = basic_models.TransportVehicleMaterialCapacity
|
||||
fields = ["id", "material_name", "capacity"]
|
||||
|
||||
|
||||
class AgentTransportVehicleSerializer(serializers.ModelSerializer):
|
||||
material_capacities = AgentTransportVehicleCapacitySerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = basic_models.TransportVehicle
|
||||
fields = ["id", "merchant_id", "name", "license_plate", "material_capacities", "created_at", "updated_at"]
|
||||
|
||||
|
||||
class AgentTransportVehicleListView(mixins.ListModelMixin, GenericAPIView):
|
||||
"""
|
||||
Agent 专用:查询商户下的所有运输车辆及其物料容量。
|
||||
|
||||
GET /api/v2/ai/transport-vehicles/?merchant_id=<id>
|
||||
"""
|
||||
|
||||
authentication_classes = [AgentAccessKeyAuthentication]
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = AgentTransportVehicleSerializer
|
||||
pagination_class = LimitedLimitOffsetPagination
|
||||
|
||||
def get(self, request):
|
||||
query_serializer = AgentMerchantQuerySerializer(data=request.query_params)
|
||||
query_serializer.is_valid(raise_exception=True)
|
||||
self.merchant = query_serializer.validated_data["merchant_id"]
|
||||
return self.list(request)
|
||||
|
||||
def get_queryset(self):
|
||||
merchant = getattr(self, "merchant", None)
|
||||
if merchant is None:
|
||||
query_serializer = AgentMerchantQuerySerializer(data=self.request.query_params)
|
||||
query_serializer.is_valid(raise_exception=True)
|
||||
merchant = query_serializer.validated_data["merchant_id"]
|
||||
return (
|
||||
basic_models.TransportVehicle.objects.filter(merchant=merchant)
|
||||
.prefetch_related("material_capacities")
|
||||
.order_by("id")
|
||||
)
|
||||
|
||||
|
||||
class AgentTransportVehicleDetailView(APIView):
|
||||
"""
|
||||
Agent 专用:通过车牌号查询运输车辆详情。
|
||||
|
||||
GET /api/v2/ai/transport-vehicles/<license_plate>/?merchant_id=<id>
|
||||
|
||||
license_plate 在同一商户内唯一(unique_together: merchant + license_plate)。
|
||||
"""
|
||||
|
||||
authentication_classes = [AgentAccessKeyAuthentication]
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, license_plate):
|
||||
query_serializer = AgentMerchantQuerySerializer(data=request.query_params)
|
||||
query_serializer.is_valid(raise_exception=True)
|
||||
merchant = query_serializer.validated_data["merchant_id"]
|
||||
|
||||
try:
|
||||
vehicle = (
|
||||
basic_models.TransportVehicle.objects
|
||||
.prefetch_related("material_capacities")
|
||||
.get(merchant=merchant, license_plate=license_plate)
|
||||
)
|
||||
except basic_models.TransportVehicle.DoesNotExist:
|
||||
raise Http404
|
||||
|
||||
return Response(AgentTransportVehicleSerializer(vehicle).data)
|
||||
|
||||
Reference in New Issue
Block a user