diff --git a/api_v1/urls.py b/api_v1/urls.py index 5ca50fd..b03ac7c 100644 --- a/api_v1/urls.py +++ b/api_v1/urls.py @@ -25,7 +25,12 @@ from .views.products import ProductQuickViewSet from .views.parameters import StateParameterViewSet from .views.users import CreateUserWithProfileView from .views.mingdaoyun import MDYPlateOrderStagingViewSet -from .views.shipment import SalesItemByPrintingOrderView, ShipmentCreateView, ShipmentExternalCreateView +from .views.shipment import ( + SalesItemByPrintingOrderView, + ShipmentListCreateView, + ShipmentDetailView, + ShipmentExternalCreateView, +) # 创建 DRF Router for Stateflow stateflow_router = DefaultRouter() @@ -102,8 +107,13 @@ urlpatterns = [ # Shipment API path( 'shipment/shipments/', - ShipmentCreateView.as_view(), - name='shipment_create' + ShipmentListCreateView.as_view(), + name='shipment_list_create' + ), + path( + 'shipment/shipments//', + ShipmentDetailView.as_view(), + name='shipment_detail' ), path( 'shipment/shipments/external/', diff --git a/api_v1/views/shipment/__init__.py b/api_v1/views/shipment/__init__.py index a06e782..4525094 100644 --- a/api_v1/views/shipment/__init__.py +++ b/api_v1/views/shipment/__init__.py @@ -3,6 +3,16 @@ Shipment API 模块 提供出货单和销售品相关的 API 接口 """ -from .views import SalesItemByPrintingOrderView, ShipmentCreateView, ShipmentExternalCreateView +from .views import ( + SalesItemByPrintingOrderView, + ShipmentListCreateView, + ShipmentDetailView, + ShipmentExternalCreateView, +) -__all__ = ['SalesItemByPrintingOrderView', 'ShipmentCreateView', 'ShipmentExternalCreateView'] +__all__ = [ + 'SalesItemByPrintingOrderView', + 'ShipmentListCreateView', + 'ShipmentDetailView', + 'ShipmentExternalCreateView', +] diff --git a/api_v1/views/shipment/test_api.py b/api_v1/views/shipment/test_api.py index c9e1048..e08caef 100644 --- a/api_v1/views/shipment/test_api.py +++ b/api_v1/views/shipment/test_api.py @@ -553,3 +553,101 @@ class ShipmentExternalCreateAPITestCase(TestCase): } response = self.client.post('/api/v1/shipment/shipments/external/', data, format='json') self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + +class ShipmentQueryAPITestCase(TestCase): + """测试出货单查询 API(列表/详情)""" + + def setUp(self): + self.client = APIClient() + + self.merchant1 = basic_models.Merchant.objects.create( + name='商户1', + type=basic_models.MerchantTypeEnum.FACTORY + ) + self.merchant2 = basic_models.Merchant.objects.create( + name='商户2', + type=basic_models.MerchantTypeEnum.FACTORY + ) + + self.user1 = User.objects.create_user( + username='u1', + password='pass123', + email='u1@example.com' + ) + self.emp1 = basic_models.Employee.objects.create( + sys_user=self.user1, + merchant=self.merchant1, + name='员工1', + mobile='13800138010', + status=basic_models.EmployeeStatusEnum.ACTIVE + ) + + self.user2 = User.objects.create_user( + username='u2', + password='pass123', + email='u2@example.com' + ) + self.emp2 = basic_models.Employee.objects.create( + sys_user=self.user2, + merchant=self.merchant2, + name='员工2', + mobile='13800138011', + status=basic_models.EmployeeStatusEnum.ACTIVE + ) + + self.customer1 = basic_models.Customer.objects.create( + merchant=self.merchant1, + name='客户1', + mobile='13900139010', + area='A' + ) + self.customer2 = basic_models.Customer.objects.create( + merchant=self.merchant2, + name='客户2', + mobile='13900139011', + area='B' + ) + + self.shipment1 = shipment_models.Shipment.objects.create( + merchant=self.merchant1, + customer=self.customer1, + shipment_date='2026-01-15', + created_by=self.user1, + remark='s1', + ) + self.shipment2 = shipment_models.Shipment.objects.create( + merchant=self.merchant2, + customer=self.customer2, + shipment_date='2026-01-15', + created_by=self.user2, + remark='s2', + ) + + self.client.force_authenticate(user=self.user1) + + def test_list_shipments_only_current_merchant(self): + resp = self.client.get('/api/v1/shipment/shipments/') + self.assertEqual(resp.status_code, status.HTTP_200_OK) + data = resp.json() + + # LimitOffsetPagination 结构:count/next/previous/results + self.assertIn('count', data) + self.assertIn('results', data) + ids = [it['id'] for it in data['results']] + self.assertIn(self.shipment1.id, ids) + self.assertNotIn(self.shipment2.id, ids) + + def test_retrieve_shipment_success(self): + resp = self.client.get(f'/api/v1/shipment/shipments/{self.shipment1.id}/') + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(resp.json()['id'], self.shipment1.id) + + def test_retrieve_other_merchant_shipment_404(self): + resp = self.client.get(f'/api/v1/shipment/shipments/{self.shipment2.id}/') + self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND) + + def test_list_shipments_unauthenticated(self): + self.client.logout() + resp = self.client.get('/api/v1/shipment/shipments/') + self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) diff --git a/api_v1/views/shipment/views.py b/api_v1/views/shipment/views.py index 93c0d62..669b151 100644 --- a/api_v1/views/shipment/views.py +++ b/api_v1/views/shipment/views.py @@ -5,6 +5,11 @@ from rest_framework import status from rest_framework.views import APIView from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated +from rest_framework.generics import GenericAPIView +from rest_framework.mixins import ListModelMixin, RetrieveModelMixin + +from flower.viewsets import LimitedLimitOffsetPagination +from shipment.models import Shipment from .serializers import ( SalesItemSerializer, @@ -14,11 +19,12 @@ from .serializers import ( ) -class ShipmentCreateView(APIView): +class ShipmentListCreateView(ListModelMixin, GenericAPIView): """ - 创建出货单 + 出货单:查询列表 / 创建 - POST /api/v1/shipment/shipments/ + - GET /api/v1/shipment/shipments/ + - POST /api/v1/shipment/shipments/ 请求体: { @@ -43,6 +49,61 @@ class ShipmentCreateView(APIView): } """ permission_classes = [IsAuthenticated] + serializer_class = ShipmentSerializer + pagination_class = LimitedLimitOffsetPagination + + def get_queryset(self): + """ + 仅返回当前用户所属商户的出货单(merchant 隔离)。 + + 支持过滤参数(可选): + - customer: 客户ID + - status: 状态(1/2/3) + - external_id: 外部订单号(精确匹配) + - shipment_date_from: 出货日期起始(YYYY-MM-DD) + - shipment_date_to: 出货日期结束(YYYY-MM-DD,包含整天) + """ + qs = Shipment.objects.all().select_related( + 'merchant', 'customer', 'created_by', 'cancelled_by' + ).prefetch_related( + 'items', + 'external_finished_products', + ) + + user = self.request.user + if not getattr(user, 'is_superuser', False): + emp = getattr(user, 'employee', None) + merchant = getattr(emp, 'merchant', None) if emp else None + if not merchant: + return Shipment.objects.none() + qs = qs.filter(merchant=merchant) + + # optional filters + customer_id = self.request.query_params.get('customer') + if customer_id: + qs = qs.filter(customer_id=customer_id) + + status_val = self.request.query_params.get('status') + if status_val: + qs = qs.filter(status=status_val) + + external_id = self.request.query_params.get('external_id') + if external_id: + qs = qs.filter(external_id=external_id) + + date_from = self.request.query_params.get('shipment_date_from') + if date_from: + qs = qs.filter(shipment_date__gte=date_from) + + date_to = self.request.query_params.get('shipment_date_to') + if date_to: + # 通过 <= 过滤日期 + qs = qs.filter(shipment_date__lte=date_to) + + return qs.order_by('-created_at', '-id') + + def get(self, request): + return self.list(request) def post(self, request): # 验证请求数据 @@ -70,6 +131,37 @@ class ShipmentCreateView(APIView): return Response(response_serializer.data, status=status.HTTP_201_CREATED) +class ShipmentDetailView(RetrieveModelMixin, GenericAPIView): + """ + 出货单详情 + + GET /api/v1/shipment/shipments// + """ + permission_classes = [IsAuthenticated] + serializer_class = ShipmentSerializer + + def get_queryset(self): + qs = Shipment.objects.all().select_related( + 'merchant', 'customer', 'created_by', 'cancelled_by' + ).prefetch_related( + 'items', + 'external_finished_products', + ) + + user = self.request.user + if getattr(user, 'is_superuser', False): + return qs + + emp = getattr(user, 'employee', None) + merchant = getattr(emp, 'merchant', None) if emp else None + if not merchant: + return Shipment.objects.none() + return qs.filter(merchant=merchant) + + def get(self, request, pk: int): + return self.retrieve(request, pk=pk) + + class ShipmentExternalCreateView(APIView): """ 创建出货单(external 版) diff --git a/docs/2026-01-15_summary.md b/docs/2026-01-15_summary.md index cb7af0d..3f87d22 100644 --- a/docs/2026-01-15_summary.md +++ b/docs/2026-01-15_summary.md @@ -33,3 +33,12 @@ - 新增独立说明文档:`docs/business_object_relink_fix.md`(背景、风险、修正案与规则) +--- + +## Shipment 模块补充 + +- 补齐出货单查询接口: + - `GET /api/v1/shipment/shipments/`:出货单列表(merchant 隔离 + LimitOffsetPagination) + - `GET /api/v1/shipment/shipments//`:出货单详情(merchant 隔离) +- 更新 `docs/shipment_api.md`:补充“查询出货单(列表/详情)”说明 +- 新增 API 测试:覆盖列表/详情的商户隔离与未登录行为 diff --git a/docs/shipment_api.md b/docs/shipment_api.md index e7721e9..c876357 100644 --- a/docs/shipment_api.md +++ b/docs/shipment_api.md @@ -4,11 +4,68 @@ ## 目录 +- [查询出货单](#查询出货单) - [创建出货单](#创建出货单) - [通过生产订单查询销售品](#通过生产订单查询销售品) --- +## 查询出货单 + +### 出货单列表 + +- **URL**: `/api/v1/shipment/shipments/` +- **Method**: `GET` +- **认证**: 需要登录(JWT Token) + +#### 查询参数(可选) + +| 参数 | 类型 | 说明 | +|------|------|------| +| limit | int | 分页大小(LimitOffsetPagination) | +| offset | int | 偏移量 | +| customer | int | 客户ID | +| status | int | 状态(1=待送货, 2=已交付, 3=已取消) | +| external_id | string | 外部订单号(精确匹配) | +| shipment_date_from | string | 出货日期起始(YYYY-MM-DD) | +| shipment_date_to | string | 出货日期结束(YYYY-MM-DD) | + +#### 响应格式 + +使用 LimitOffsetPagination: + +```json +{ + "count": 2, + "next": null, + "previous": null, + "results": [ + { + "id": 1, + "merchant_id": 1, + "merchant_name": "测试印花厂", + "customer": 1, + "customer_name": "客户A", + "shipment_date": "2026-01-14", + "remark": "备注信息", + "items_count": 3, + "created_by_id": 1, + "created_by_name": "张三", + "created_at": "2026-01-14T10:00:00Z", + "updated_at": "2026-01-14T10:00:00Z" + } + ] +} +``` + +### 出货单详情 + +- **URL**: `/api/v1/shipment/shipments//` +- **Method**: `GET` +- **认证**: 需要登录(JWT Token) + +--- + ## 创建出货单 创建出货单并关联销售品。