1
0
forked from erp-dev/erp

feat: sales_order api

This commit is contained in:
2025-11-30 17:06:48 +08:00
parent f6f0ec2ae9
commit ce1954ad58
15 changed files with 1426 additions and 44 deletions

122
api_v1/business/README.md Normal file
View File

@@ -0,0 +1,122 @@
# 业务域 API 文档Purchase & Sales
所有接口均位于 `/api/v1/` 前缀下,要求用户已登录且具备员工身份。除特殊说明外,返回值为 JSON错误时返回 `{"error": "...", ...}`
## 通用约定
- **分页**:列表接口使用 `limit` / `offset`(默认 `limit=20`,最大 `100`)。
- **items 结构**:与仓库模式相关
- 严进/严进严出仓库:`numbers: [int, ...]`
- 宽进宽出仓库:`quantity: number` + `num_of_rolls: int`
- **状态枚举**`PENDING=1``APPROVED=2``CANCELLED=3`
---
## 1. 采购单PurchaseOrder
### 1.1 列表
- **GET** `/api/v1/purchase-orders/`
- **查询参数**`limit``offset`
- **返回**`{"count": int, "next": url|null, "previous": url|null, "results": [PurchaseOrder]}`
每个 `PurchaseOrder` 记录包含 `supplier_name / operator_name / warehouse_name / total_amount / total_quantity / items[...]` 等字段。
### 1.2 创建
- **POST** `/api/v1/purchase-orders/`
- **请求体**
| 字段 | 类型 | 说明 |
|------|------|------|
| `supplier` | int | 供应商 ID必填 |
| `warehouse` | int | 仓库 ID或使用 `warehouse_id`(必填) |
| `order_date` | str (`YYYY-MM-DD`) | 采购日期 |
| `items` | list | 产品明细(至少 1 条) |
| `remarks` | str | 备注,可选 |
明细字段:
| 仓库模式 | 必填字段 |
|----------|----------|
| 严进/严进严出 | `product_id`, `numbers` (list[int]), `price`, `unit` |
| 宽进宽出 | `product_id`, `quantity`, `num_of_rolls`, `price`, `unit` |
- **成功返回**`201` + `{"id": int, "status": 1, "message": "采购单创建成功,等待审批"}`
### 1.3 审批 / 作废
- **POST** `/api/v1/purchase-orders/<id>/review/`
- **请求体**`{"action": "approve" | "cancel"}`
- **返回**:最新的 `PurchaseOrder` 序列化结果。
- `approve`:当商户开启自动入库时,会异步创建入库任务。
- `cancel`:若已生成入库记录,返回 `400`
---
## 2. 销售单SalesOrder
接口与采购单保持一致,仅字段差异:
- 关联主体:`customer`(客户 ID
- 日期字段:`order_date` 映射到 `sales_date`
- 审批通过后触发**出库**任务,库存方向为 “出库/负数”。
### 2.1 列表
- **GET** `/api/v1/sales-orders/`
- **返回**:同采购列表,但字段为 `customer_name` 等。
### 2.2 创建
- **POST** `/api/v1/sales-orders/`
- **请求体**
| 字段 | 类型 | 说明 |
|------|------|------|
| `customer` | int | 客户 ID |
| `warehouse` | int | 仓库 ID`warehouse_id` |
| `order_date` | str | 销售日期 |
| `items` | list | 产品明细,与采购单格式一致 |
| `remarks` | str | 可选 |
`items` 结构(按仓库模式):
| 仓库模式 | 出库模式 | 必填字段 |
|----------|----------|----------|
| `UNRESTRICTED` | 宽出 | `product_id`, `quantity`, `num_of_rolls`, `price`, `unit` |
| `RESTRICT_IN` | 宽出 | `product_id`, `numbers` (list[int]), `price`, `unit` |
| `RESTRICT_IN_OUT` | 严出 | `product_id`, `consume_detail_ids` (list[int]), `quantity`, `price`, `unit` |
- **成功返回**`201` + `{"id": int, "status": 1, "message": "销售单创建成功,等待审批"}`
### 2.3 审批 / 作废
- **POST** `/api/v1/sales-orders/<id>/review/`
- **请求体**`{"action": "approve" | "cancel"}`
- **返回**`SalesOrder` 序列化数据。
- `approve`:若开启自动出库,则投递 `create_sales_order_stock_entries`
- `cancel`:若已生成出库记录(`StockChangeRecord`),返回 `400`
---
## 3. 响应字段说明(节选)
| 字段 | 说明 |
|------|------|
| `total_amount` | 明细金额合计(未带方向) |
| `diff_quantity` | 空差数量合计 |
| `total_quantity` | 原始数量合计 |
| `items[].quantity_of_rolls` | 严进模式下的各条数明细(字符串,以逗号分隔) |
| `items[].num_of_rolls` | 条数 |
| `status` | 1=审批中、2=通过、3=作废 |
---
## 4. 错误示例
| 场景 | HTTP | 返回体 |
|------|------|--------|
| 未登录 | 401 | `{"detail": "Authentication credentials were not provided."}` |
| 缺少必填字段 | 400 | `{"error": "缺少供应商 ID"}` 等 |
| 无权限访问他商户单据 | 403 | `{"error": "无权限访问"}` |
| 单据不存在 | 404 | `{"error": "采购单不存在"}` / `{"error": "销售单不存在"}` |
| 已有库存记录仍尝试作废 | 400 | `{"error": "采购单已生成出入库记录,无法作废"}`(销售单同理) |
---
如需对 `items` 结构、仓库模式或审批流程做深入了解,请参阅:
- `docs/purchase_order_approval_and_red_flush.md`
- `docs/sales_order_approval_and_red_flush.md`

View File

@@ -0,0 +1,6 @@
"""
业务域相关的 API 视图与工具。
该包下按业务对象分类,例如 purchase、sales 等。
"""

View File

@@ -0,0 +1,4 @@
"""
采购域 API 视图。
"""

View File

@@ -1,10 +1,11 @@
from rest_framework import status, views, serializers, pagination
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from basic_info import models as basic_models
from business import services as business_services
from business import models as business_models
from .stock_change_views.mixins import StockChangeViewMixin
from api_v1.views.stock_change_views.mixins import StockChangeViewMixin
class PurchaseOrderItemSerializer(serializers.ModelSerializer):
@@ -25,8 +26,10 @@ class PurchaseOrderSerializer(serializers.ModelSerializer):
def get_total_amount(self, obj: business_models.PurchaseOrder):
return obj.get_total_amount()
def get_diff_quantity(self, obj: business_models.PurchaseOrder):
return obj.get_total_diff_quantity()
def get_total_quantity(self, obj: business_models.PurchaseOrder):
return obj.get_total_quantity()
@@ -60,9 +63,11 @@ class PurchaseOrderView(StockChangeViewMixin, views.APIView):
def get(self, request):
if not self.check_employee_permission(request):
return self.permission_error_response('无权限访问')
merchant = request.user.employee.merchant
queryset = business_models.PurchaseOrder.objects.filter(merchant=merchant).prefetch_related('items', 'supplier', 'operator', 'warehouse')
queryset = business_models.PurchaseOrder.objects.filter(merchant=merchant).prefetch_related(
'items', 'supplier', 'operator', 'warehouse'
)
paginator = self.pagination_class()
page = paginator.paginate_queryset(queryset.order_by('-created_at'), request, view=self)
serializer = PurchaseOrderSerializer(page, many=True)
@@ -169,3 +174,4 @@ class PurchaseOrderReviewView(StockChangeViewMixin, views.APIView):
'supplier', 'operator', 'warehouse'
).prefetch_related('items').get(id=purchase_order.id)
return Response(PurchaseOrderSerializer(refreshed_order).data, status=status.HTTP_200_OK)

View File

@@ -0,0 +1,177 @@
from rest_framework import status, views, serializers, pagination
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from basic_info import models as basic_models
from business import services as business_services
from business import models as business_models
from api_v1.views.stock_change_views.mixins import StockChangeViewMixin
class SalesOrderItemSerializer(serializers.ModelSerializer):
class Meta:
model = business_models.SalesOrderItem
fields = [
'id', 'product', 'price', 'color', 'quantity', 'unit',
'empty_diff_percent', 'quantity_of_rolls', 'num_of_rolls',
'consume_detail_ids', 'batch_number', 'remarks', 'created_at', 'updated_at', 'spec',
]
read_only_fields = ['id', 'created_at', 'updated_at', 'total_amount', 'diff_quantity', 'real_quantity']
class SalesOrderSerializer(serializers.ModelSerializer):
total_amount = serializers.SerializerMethodField(read_only=True)
diff_quantity = serializers.SerializerMethodField(read_only=True)
total_quantity = serializers.SerializerMethodField(read_only=True)
def get_total_amount(self, obj: business_models.SalesOrder):
return obj.get_total_amount()
def get_diff_quantity(self, obj: business_models.SalesOrder):
return obj.get_total_diff_quantity()
def get_total_quantity(self, obj: business_models.SalesOrder):
return obj.get_total_quantity()
customer_name = serializers.CharField(source='customer.name', read_only=True)
operator_name = serializers.CharField(source='operator.name', read_only=True)
warehouse_name = serializers.CharField(source='warehouse.name', read_only=True)
items = SalesOrderItemSerializer(many=True, read_only=True)
class Meta:
model = business_models.SalesOrder
fields = [
'id', 'customer', 'customer_name', 'sales_date', 'kind',
'total_amount', 'diff_quantity', 'total_quantity',
'operator', 'operator_name', 'warehouse', 'warehouse_name',
'status', 'remarks', 'created_at', 'updated_at', 'items',
]
read_only_fields = ['id', 'created_at', 'updated_at', 'items', 'customer_name', 'operator_name', 'warehouse_name']
class SalesOrderPagination(pagination.LimitOffsetPagination):
default_limit = 20
max_limit = 100
class SalesOrderView(StockChangeViewMixin, views.APIView):
"""销售订单查询与创建"""
permission_classes = [IsAuthenticated]
pagination_class = SalesOrderPagination
def get(self, request):
if not self.check_employee_permission(request):
return self.permission_error_response('无权限访问')
merchant = request.user.employee.merchant
queryset = business_models.SalesOrder.objects.filter(merchant=merchant).prefetch_related(
'items', 'customer', 'operator', 'warehouse'
)
paginator = self.pagination_class()
page = paginator.paginate_queryset(queryset.order_by('-created_at'), request, view=self)
serializer = SalesOrderSerializer(page, many=True)
return paginator.get_paginated_response(serializer.data)
def post(self, request):
if not self.check_employee_permission(request):
return self.permission_error_response('无权限访问')
merchant = request.user.employee.merchant
data = request.data or {}
customer_id = data.get('customer')
warehouse_id = data.get('warehouse_id') or data.get('warehouse')
order_date = data.get('order_date')
items = data.get('items', [])
remarks = data.get('remarks', '')
if not customer_id:
return Response({'error': '缺少客户 ID'}, status=status.HTTP_400_BAD_REQUEST)
if not warehouse_id:
return Response({'error': '缺少仓库 ID'}, status=status.HTTP_400_BAD_REQUEST)
if not order_date:
return Response({'error': '缺少 order_date'}, status=status.HTTP_400_BAD_REQUEST)
if not isinstance(items, list) or not items:
return Response({'error': 'items 需要为非空数组'}, status=status.HTTP_400_BAD_REQUEST)
try:
customer = basic_models.Customer.objects.get(id=customer_id, merchant=merchant)
except basic_models.Customer.DoesNotExist:
return Response({'error': f'客户 {customer_id} 不存在'}, status=status.HTTP_400_BAD_REQUEST)
try:
warehouse = basic_models.WareHouse.objects.get(id=warehouse_id, merchant=merchant)
except basic_models.WareHouse.DoesNotExist:
return Response({'error': f'仓库 {warehouse_id} 不存在'}, status=status.HTTP_400_BAD_REQUEST)
operator = request.user.employee
try:
sales_order = business_services.create_sales_order(
merchant=merchant,
customer=customer,
order_date=order_date,
warehouse=warehouse,
operator=operator,
items=items,
remarks=remarks,
created_by=request.user,
)
except ValueError as exc:
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(
{
'id': sales_order.id,
'status': sales_order.status,
'message': '销售单创建成功,等待审批',
},
status=status.HTTP_201_CREATED,
)
class SalesOrderReviewSerializer(serializers.Serializer):
action = serializers.ChoiceField(choices=[('approve', '审批通过'), ('cancel', '作废')])
class SalesOrderReviewView(StockChangeViewMixin, views.APIView):
"""销售单审批 / 作废"""
permission_classes = [IsAuthenticated]
ACTION_STATUS_MAP = {
'approve': business_models.SalesOrderStatusEnum.APPROVED,
'cancel': business_models.SalesOrderStatusEnum.CANCELLED,
}
def post(self, request, pk: int):
if not self.check_employee_permission(request):
return self.permission_error_response('无权限访问')
merchant = request.user.employee.merchant
try:
sales_order = business_models.SalesOrder.objects.select_related(
'customer', 'operator', 'warehouse'
).prefetch_related('items').get(id=pk, merchant=merchant)
except business_models.SalesOrder.DoesNotExist:
return self.not_found_response('销售单不存在')
serializer = SalesOrderReviewSerializer(data=request.data or {})
serializer.is_valid(raise_exception=True)
action = serializer.validated_data['action']
target_status = self.ACTION_STATUS_MAP[action]
try:
business_services.review_sales_order(
sales_order=sales_order,
target_status=target_status,
reviewed_by=request.user,
)
except ValueError as exc:
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
refreshed_order = business_models.SalesOrder.objects.select_related(
'customer', 'operator', 'warehouse'
).prefetch_related('items').get(id=sales_order.id)
return Response(SalesOrderSerializer(refreshed_order).data, status=status.HTTP_200_OK)

View File

@@ -9,6 +9,7 @@ from django.contrib.auth.models import User, Permission
from rest_framework.test import APIClient
from rest_framework import status
from basic_info.models import (
Customer,
Employee,
Merchant,
MerchantTypeEnum,
@@ -240,7 +241,7 @@ class PurchaseOrderAPITestCase(TestCase):
payload['warehouse'] = self.warehouse_strict.id # 严进仓却传宽进参数
response = self.client.post('/api/v1/purchase-orders/', payload, format='json')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn('严进模式', response.data['error'])
self.assertIn('numbers', response.data['error'])
def test_review_purchase_order_requires_action(self):
order_id = self._create_purchase_order(self.strict_payload)
@@ -279,6 +280,176 @@ class PurchaseOrderAPITestCase(TestCase):
self.assertEqual(order.status, business_models.PurchaseOrderStatusEnum.PENDING)
@override_settings(
CELERY_TASK_ALWAYS_EAGER=True,
CELERY_TASK_EAGER_PROPAGATES=True,
)
class SalesOrderAPITestCase(TestCase):
"""销售单 API 测试"""
def setUp(self):
self.merchant = Merchant.objects.create(name='销售商户', type=MerchantTypeEnum.FACTORY)
self.customer = Customer.objects.create(
merchant=self.merchant,
name='客户A',
mobile='13800000000',
created_by=None,
)
self.warehouse_strict = WareHouse.objects.create(
merchant=self.merchant,
name='销售严进仓',
mode=WareHouseModeEnum.RESTRICT_IN,
)
self.warehouse_relaxed = WareHouse.objects.create(
merchant=self.merchant,
name='销售宽进仓',
mode=WareHouseModeEnum.UNRESTRICTED,
)
self.warehouse_strict_out = WareHouse.objects.create(
merchant=self.merchant,
name='销售严出仓',
mode=WareHouseModeEnum.RESTRICT_IN_OUT,
)
category = ProductCategory.objects.create(
merchant=self.merchant,
name='品类',
product_prefix='SAL',
)
self.product = Product.objects.create(
merchant=self.merchant,
category=category,
name='销售产品1',
human_id='SAL-001',
unit=ProductUnitEnum.METER,
)
self.user = User.objects.create_user(username='sales_user', password='pass123')
self.employee = Employee.objects.create(
merchant=self.merchant,
sys_user=self.user,
name='销售员',
)
self.client = APIClient()
self.client.force_authenticate(user=self.user)
self.strict_in_payload = {
'customer': self.customer.id,
'warehouse': self.warehouse_strict.id,
'order_date': '2025-11-26',
'items': [
{
'product_id': self.product.id,
'numbers': [8, 4],
'price': '15.0',
'unit': '',
}
],
'remarks': '销售接口测试',
}
self.strict_out_payload = {
'customer': self.customer.id,
'warehouse': self.warehouse_strict_out.id,
'order_date': '2025-11-26',
'items': [
{
'product_id': self.product.id,
'consume_detail_ids': [101, 102],
'quantity': 30,
'price': '18.5',
'unit': '',
}
],
}
self.relaxed_payload = {
'customer': self.customer.id,
'warehouse': self.warehouse_relaxed.id,
'order_date': '2025-11-26',
'items': [
{
'product_id': self.product.id,
'quantity': 90,
'num_of_rolls': 3,
'price': '16.5',
}
],
}
def _create_sales_order(self, payload):
body = copy.deepcopy(payload)
with patch('business.services.create_sales_order_stock_entries.delay'):
response = self.client.post('/api/v1/sales-orders/', body, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
return response.data['id']
def test_create_sales_order_success_strict(self):
with patch('business.services.create_sales_order_stock_entries.delay') as mock_delay:
response = self.client.post('/api/v1/sales-orders/', self.strict_in_payload, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertIn('id', response.data)
self.assertEqual(response.data['status'], business_models.SalesOrderStatusEnum.PENDING)
mock_delay.assert_not_called()
def test_create_sales_order_invalid_customer(self):
payload = {**self.strict_in_payload, 'customer': 999}
response = self.client.post('/api/v1/sales-orders/', payload, format='json')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn('不存在', response.data['error'])
def test_create_sales_order_relaxed_mode(self):
with patch('business.services.create_sales_order_stock_entries.delay') as mock_delay:
response = self.client.post('/api/v1/sales-orders/', self.relaxed_payload, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data['status'], business_models.SalesOrderStatusEnum.PENDING)
mock_delay.assert_not_called()
def test_create_sales_order_strict_out_success(self):
with patch('business.services.create_sales_order_stock_entries.delay') as mock_delay:
response = self.client.post('/api/v1/sales-orders/', self.strict_out_payload, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data['status'], business_models.SalesOrderStatusEnum.PENDING)
mock_delay.assert_not_called()
def test_create_sales_order_strict_out_requires_consume_ids(self):
payload = copy.deepcopy(self.strict_out_payload)
payload['items'][0].pop('consume_detail_ids')
response = self.client.post('/api/v1/sales-orders/', payload, format='json')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn('consume_detail_ids', response.data['error'])
def test_review_sales_order_requires_action(self):
order_id = self._create_sales_order(self.strict_in_payload)
response = self.client.post(f'/api/v1/sales-orders/{order_id}/review/', {}, format='json')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn('action', response.data)
def test_review_sales_order_approve_success(self):
order_id = self._create_sales_order(self.strict_in_payload)
response = self.client.post(
f'/api/v1/sales-orders/{order_id}/review/',
{'action': 'approve'},
format='json',
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['status'], business_models.SalesOrderStatusEnum.APPROVED)
order = business_models.SalesOrder.objects.get(id=order_id)
self.assertEqual(order.status, business_models.SalesOrderStatusEnum.APPROVED)
def test_review_sales_order_cancel_blocked_after_stock_exists(self):
order_id = self._create_sales_order(self.relaxed_payload)
stock_models.StockChangeRecord.objects.create(
merchant=self.merchant,
type=stock_models.StockChangeTypeEnum.REMOVE,
warehouse=self.warehouse_relaxed,
source_type=stock_models.StockChangeSourceEnum.SALES,
source_id=order_id,
)
response = self.client.post(
f'/api/v1/sales-orders/{order_id}/review/',
{'action': 'cancel'},
format='json',
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
order = business_models.SalesOrder.objects.get(id=order_id)
self.assertEqual(order.status, business_models.SalesOrderStatusEnum.PENDING)
@override_settings(
CELERY_TASK_ALWAYS_EAGER=True,
CELERY_TASK_EAGER_PROPAGATES=True,

View File

@@ -2,7 +2,6 @@ from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import (
stock_change_views,
purchase_order,
healthy,
user_info,
inventory,
@@ -11,6 +10,8 @@ from .views import (
users,
print_count,
)
from .business.purchase import views as purchase_views
from .business.sales import views as sales_views
from .views.stock_change_views.snapshot import StockSnapshotListView
from .views.printing.views import PrintingOrderViewSet, PrintingJobViewSet, PlateOrderViewSet
from .views.upload import UploadFileViewSet
@@ -58,8 +59,10 @@ urlpatterns = [
# 库存查询 API
path('inventory/', inventory.InventoryAPIView.as_view(), name='inventory'),
path('purchase-orders/', purchase_order.PurchaseOrderView.as_view(), name='purchase_orders'),
path('purchase-orders/<int:pk>/review/', purchase_order.PurchaseOrderReviewView.as_view(), name='purchase_order_review'),
path('purchase-orders/', purchase_views.PurchaseOrderView.as_view(), name='purchase_orders'),
path('purchase-orders/<int:pk>/review/', purchase_views.PurchaseOrderReviewView.as_view(), name='purchase_order_review'),
path('sales-orders/', sales_views.SalesOrderView.as_view(), name='sales_orders'),
path('sales-orders/<int:pk>/review/', sales_views.SalesOrderReviewView.as_view(), name='sales_order_review'),
path('health/', healthy.HealthCheckView.as_view(), name='health_check'),
path('print-count/delta/', print_count.adjust_print_count, name='print_count_delta'),