forked from erp-dev/erp
feat: added shipments list api
This commit is contained in:
@@ -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/',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 版)
|
||||||
|
|||||||
@@ -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 测试:覆盖列表/详情的商户隔离与未登录行为
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 创建出货单
|
## 创建出货单
|
||||||
|
|
||||||
创建出货单并关联销售品。
|
创建出货单并关联销售品。
|
||||||
|
|||||||
Reference in New Issue
Block a user