1
0
forked from erp-dev/erp

feat: agent api complete

This commit is contained in:
2026-04-15 00:20:13 +08:00
parent 714d6287fe
commit 4f3109546d
9 changed files with 553 additions and 6 deletions

1
.env
View File

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

View File

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

View File

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

View File

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

View File

@@ -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',
]

View File

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

160
docs/api_v2_agent_api.md Normal file
View File

@@ -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>
```
`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/<license_plate>/`
- 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`

View File

@@ -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='地理坐标'),
),
]

View File

@@ -198,6 +198,13 @@ class Shipment(ModelBase):
verbose_name='创建人'
)
geo_coordinates = models.JSONField(
null=True,
blank=True,
verbose_name='地理坐标',
help_text='由 Geo 系统返回的坐标信息,结构由调用方决定,后端不作校验',
)
class Meta:
db_table = 'shipment'
verbose_name = '出货单'