1
0
forked from erp-dev/erp

feat: added shipments list api

This commit is contained in:
2026-01-15 16:47:12 +08:00
parent ea9f378de9
commit a0cd168397
6 changed files with 284 additions and 8 deletions

View File

@@ -25,7 +25,12 @@ from .views.products import ProductQuickViewSet
from .views.parameters import StateParameterViewSet from .views.parameters import StateParameterViewSet
from .views.users import CreateUserWithProfileView from .views.users import CreateUserWithProfileView
from .views.mingdaoyun import MDYPlateOrderStagingViewSet from .views.mingdaoyun import MDYPlateOrderStagingViewSet
from .views.shipment import SalesItemByPrintingOrderView, ShipmentCreateView, ShipmentExternalCreateView from .views.shipment import (
SalesItemByPrintingOrderView,
ShipmentListCreateView,
ShipmentDetailView,
ShipmentExternalCreateView,
)
# 创建 DRF Router for Stateflow # 创建 DRF Router for Stateflow
stateflow_router = DefaultRouter() stateflow_router = DefaultRouter()
@@ -102,8 +107,13 @@ urlpatterns = [
# Shipment API # Shipment API
path( path(
'shipment/shipments/', 'shipment/shipments/',
ShipmentCreateView.as_view(), ShipmentListCreateView.as_view(),
name='shipment_create' name='shipment_list_create'
),
path(
'shipment/shipments/<int:pk>/',
ShipmentDetailView.as_view(),
name='shipment_detail'
), ),
path( path(
'shipment/shipments/external/', 'shipment/shipments/external/',

View File

@@ -3,6 +3,16 @@ Shipment API 模块
提供出货单和销售品相关的 API 接口 提供出货单和销售品相关的 API 接口
""" """
from .views import SalesItemByPrintingOrderView, ShipmentCreateView, ShipmentExternalCreateView from .views import (
SalesItemByPrintingOrderView,
ShipmentListCreateView,
ShipmentDetailView,
ShipmentExternalCreateView,
)
__all__ = ['SalesItemByPrintingOrderView', 'ShipmentCreateView', 'ShipmentExternalCreateView'] __all__ = [
'SalesItemByPrintingOrderView',
'ShipmentListCreateView',
'ShipmentDetailView',
'ShipmentExternalCreateView',
]

View File

@@ -553,3 +553,101 @@ class ShipmentExternalCreateAPITestCase(TestCase):
} }
response = self.client.post('/api/v1/shipment/shipments/external/', data, format='json') response = self.client.post('/api/v1/shipment/shipments/external/', data, format='json')
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) 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)

View File

@@ -5,6 +5,11 @@ from rest_framework import status
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated 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 ( from .serializers import (
SalesItemSerializer, 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] 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): def post(self, request):
# 验证请求数据 # 验证请求数据
@@ -70,6 +131,37 @@ class ShipmentCreateView(APIView):
return Response(response_serializer.data, status=status.HTTP_201_CREATED) return Response(response_serializer.data, status=status.HTTP_201_CREATED)
class ShipmentDetailView(RetrieveModelMixin, GenericAPIView):
"""
出货单详情
GET /api/v1/shipment/shipments/<id>/
"""
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): class ShipmentExternalCreateView(APIView):
""" """
创建出货单external 版) 创建出货单external 版)

View File

@@ -33,3 +33,12 @@
- 新增独立说明文档:`docs/business_object_relink_fix.md`(背景、风险、修正案与规则) - 新增独立说明文档:`docs/business_object_relink_fix.md`(背景、风险、修正案与规则)
---
## Shipment 模块补充
- 补齐出货单查询接口:
- `GET /api/v1/shipment/shipments/`出货单列表merchant 隔离 + LimitOffsetPagination
- `GET /api/v1/shipment/shipments/<id>/`出货单详情merchant 隔离)
- 更新 `docs/shipment_api.md`:补充“查询出货单(列表/详情)”说明
- 新增 API 测试:覆盖列表/详情的商户隔离与未登录行为

View File

@@ -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/<id>/`
- **Method**: `GET`
- **认证**: 需要登录JWT Token
---
## 创建出货单 ## 创建出货单
创建出货单并关联销售品。 创建出货单并关联销售品。