diff --git a/.env b/.env index ade33ed..4eebf53 100644 --- a/.env +++ b/.env @@ -9,3 +9,4 @@ DB_PASSWORD=flower_pg_zuowei1216 PRINTING_SALES_ITEM_SOURCE_STATE_ID=2 WECOM_WEBHOOK_KEY=cc88bdef-a13f-4d7e-bdb6-ee51b68b8205 PRINTING_JOB_STATE_ADVANCED_FOLLOWUP_URL_TEMPLATE=https://app.yuwen.cloud/workstation/production/batch-advance?orderId={order_id} +AGENT_ACCESS_KEY=hophopkk diff --git a/api_v1/views/shipment/serializers.py b/api_v1/views/shipment/serializers.py index 057542f..7a31da2 100644 --- a/api_v1/views/shipment/serializers.py +++ b/api_v1/views/shipment/serializers.py @@ -105,6 +105,7 @@ class ShipmentSerializer(serializers.ModelSerializer): "status", "status_display", "external_id", + "geo_coordinates", "status_modified_at", "cancelled_by_id", "cancelled_by_name", @@ -360,6 +361,11 @@ class ShipmentUpdateSerializer(serializers.Serializer): allow_null=True, help_text="外部订单号(可选)", ) + geo_coordinates = serializers.JSONField( + required=False, + allow_null=True, + help_text="Geo 系统返回的坐标信息(可空,结构不作校验)", + ) class ShipmentStatusUpdateSerializer(serializers.Serializer): diff --git a/api_v2/tests.py b/api_v2/tests.py index e770acd..662652a 100644 --- a/api_v2/tests.py +++ b/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) diff --git a/api_v2/urls.py b/api_v2/urls.py index 33d6682..8bd99ea 100644 --- a/api_v2/urls.py +++ b/api_v2/urls.py @@ -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//', 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//', MissionCategoryDetailView.as_view(), name='api_v2_mission_category_detail'), path('missions/', MissionListCreateView.as_view(), name='api_v2_mission_list_create'), diff --git a/api_v2/views/__init__.py b/api_v2/views/__init__.py index 6e38be1..2e3c028 100644 --- a/api_v2/views/__init__.py +++ b/api_v2/views/__init__.py @@ -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', ] diff --git a/api_v2/views/ai.py b/api_v2/views/ai.py index 1669a58..5e08ae8 100644 --- a/api_v2/views/ai.py +++ b/api_v2/views/ai.py @@ -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= + """ + + 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//?merchant_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) diff --git a/docs/api_v2_agent_api.md b/docs/api_v2_agent_api.md new file mode 100644 index 0000000..8df6db0 --- /dev/null +++ b/docs/api_v2_agent_api.md @@ -0,0 +1,160 @@ +# API v2 Agent 接口文档 + +本文档面向 AI Agent 及外部自动化调用方,描述 `/api/v2/ai/` 前缀下的所有接口。 + +## 基本约定 + +- Base URL: `/api/v2/ai` +- 认证:所有接口使用固定 API Key,通过 `Authorization` 请求头直接传入(无 `Bearer` 前缀) +- 多商户隔离:每个接口均需传入 `merchant_id` 查询参数,后端以此确定数据范围 + +### 认证方式 + +``` +Authorization: +``` + +`AGENT_ACCESS_KEY` 由后端部署时通过同名环境变量配置。未配置时所有请求均返回 `401`。 + +--- + +## 数据结构 + +### TransportVehicle + +```json +{ + "id": 1, + "merchant_id": 10, + "name": "大卡车", + "license_plate": "粤A12345", + "material_capacities": [ + {"id": 1, "material_name": "坯布", "capacity": 500}, + {"id": 2, "material_name": "成品", "capacity": 300} + ], + "created_at": "2026-04-01T08:00:00+08:00", + "updated_at": "2026-04-01T08:00:00+08:00" +} +``` + +### Shipment(出货单) + +```json +{ + "id": 100, + "merchant_id": 10, + "customer": 5, + "customer_name": "客户A", + "shipment_date": "2026-04-14", + "address": "广州市天河区", + "contact_name": "张三", + "contact_phone": "13800000000", + "area": "华东", + "remark": "备注", + "status": "pending", + "status_display": "待出货", + "external_id": null, + "geo_coordinates": {"lat": 23.1291, "lng": 113.2644}, + "delivery": null, + "sales_items": [ + { + "id": 201, + "name": "销售品甲", + "quantity": "50.00", + "unit": 1, + "unit_display": "件", + "position": "A-01", + "remark": "", + "printing_job_id": 88, + "printing_job_width": "150cm" + }, + { + "id": 202, + "name": "销售品乙", + "quantity": "10.00", + "unit": 1, + "unit_display": "件", + "position": "", + "remark": "", + "printing_job_id": null, + "printing_job_width": null + } + ], + "created_at": "2026-04-14T10:00:00+08:00", + "updated_at": "2026-04-14T10:00:00+08:00" +} +``` + +--- + +## 运输车辆列表 + +- URL: `/api/v2/ai/transport-vehicles/` +- Method: `GET` + +查询参数: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `merchant_id` | int | 是 | 商户 ID | +| `limit` | int | 否 | 分页每页数量(默认由服务端决定) | +| `offset` | int | 否 | 分页偏移量 | + +响应为分页结构,`results` 中每条为 `TransportVehicle`,包含该车辆的所有物料容量。 + +说明: + +- `material_capacities` 表示该车辆对不同物料的最大装载量(单位:条) +- 只返回 `merchant_id` 对应商户的车辆 +- `VehicleType`(旧版车辆类型字典)和 `VehicleTransportRecord`(旧版司机车次记录)是已废弃的模型,不在此接口返回 + +## 运输车辆详情 + +- URL: `/api/v2/ai/transport-vehicles//` +- Method: `GET` + +路径参数: + +| 参数 | 说明 | +|------|------| +| `license_plate` | 车牌号码(URL 编码,如 `粤A12345` → `%E7%B2%A4A12345`) | + +查询参数: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `merchant_id` | int | 是 | 商户 ID | + +说明: + +- 同一商户内 `license_plate` 唯一(数据库 `unique_together: merchant + license_plate`),因此 `merchant_id + license_plate` 可精确定位一辆车 +- 不同商户可能存在相同车牌号,`merchant_id` 参数是必须的 +- 车辆不存在时返回 `404` + +成功响应:`TransportVehicle` + +--- + +## 未出货出货单列表 + +- URL: `/api/v2/ai/shipments/unshipped/` +- Method: `GET` + +查询参数: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `merchant_id` | int | 是 | 商户 ID | +| `area` | string | 是 | 地区筛选(精确匹配) | +| `limit` | int | 否 | 分页每页数量 | +| `offset` | int | 否 | 分页偏移量 | + +说明: + +- "未出货"定义:`delivery` 为空,即尚未关联送货单 +- 结果按 `created_at` 降序排列 +- 只返回 `merchant_id` 对应商户的数据 +- `sales_items` 包含该出货单的所有销售品(软删除的条目自动排除) +- `sales_items[].printing_job_width`:对应 `PrintingJob → PrintingOrder.width`;若无关联印染任务则为 `null` + +响应为分页结构,`results` 中每条为 `Shipment`。 diff --git a/shipment/migrations/0021_geo_coordinates.py b/shipment/migrations/0021_geo_coordinates.py new file mode 100644 index 0000000..ca27b79 --- /dev/null +++ b/shipment/migrations/0021_geo_coordinates.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.8 on 2026-04-14 11:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shipment', '0020_salesitem_merge_remark'), + ] + + operations = [ + migrations.AddField( + model_name='shipment', + name='geo_coordinates', + field=models.JSONField(blank=True, help_text='由 Geo 系统返回的坐标信息,结构由调用方决定,后端不作校验', null=True, verbose_name='地理坐标'), + ), + ] diff --git a/shipment/models.py b/shipment/models.py index 2dd727e..0c7a630 100644 --- a/shipment/models.py +++ b/shipment/models.py @@ -197,7 +197,14 @@ class Shipment(ModelBase): related_name='created_shipments', verbose_name='创建人' ) - + + geo_coordinates = models.JSONField( + null=True, + blank=True, + verbose_name='地理坐标', + help_text='由 Geo 系统返回的坐标信息,结构由调用方决定,后端不作校验', + ) + class Meta: db_table = 'shipment' verbose_name = '出货单'