forked from erp-dev/erp
feat: sales_order api
This commit is contained in:
122
api_v1/business/README.md
Normal file
122
api_v1/business/README.md
Normal 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`
|
||||
|
||||
6
api_v1/business/__init__.py
Normal file
6
api_v1/business/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
业务域相关的 API 视图与工具。
|
||||
|
||||
该包下按业务对象分类,例如 purchase、sales 等。
|
||||
"""
|
||||
|
||||
4
api_v1/business/purchase/__init__.py
Normal file
4
api_v1/business/purchase/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
采购域 API 视图。
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
177
api_v1/business/sales/views.py
Normal file
177
api_v1/business/sales/views.py
Normal 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)
|
||||
|
||||
173
api_v1/tests.py
173
api_v1/tests.py
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
|
||||
61
business/ARCHITECTURE.md
Normal file
61
business/ARCHITECTURE.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# business 模块结构与设计说明
|
||||
|
||||
本文档记录业务模块当前的结构、关键约定以及未来演进需要遵循的决策背景,防止在迭代中遗失设计意图。
|
||||
|
||||
## 1. 模块职责
|
||||
|
||||
`business` 负责“业务单据”领域,目前包含采购单(PurchaseOrder)与销售单(SalesOrder),同时保留可扩展至退货单等更多对象的通用框架。核心职责:
|
||||
|
||||
- 聚合与校验业务明细数据
|
||||
- 通过服务层触发库存(stock)、财务等下游模块
|
||||
- 统一处理审批、作废等状态流转
|
||||
|
||||
## 2. 模型抽象
|
||||
|
||||
### 2.1 OrderItemsAggregationMixin
|
||||
封装订单明细聚合逻辑,默认访问 `items` 关联,可通过 `order_items_accessor` 指定其他关联名。提供:
|
||||
|
||||
- `get_total_amount()`
|
||||
- `get_total_quantity()`
|
||||
- `get_total_diff_quantity()`
|
||||
|
||||
这些方法仅关心明细结构,不关心订单方向。
|
||||
|
||||
### 2.2 OrderDirectionMixin
|
||||
用于获取带方向的金额,约定:
|
||||
|
||||
- `get_direction()` 返回 `1`(正向/入库)或 `-1`(负向/出库)
|
||||
- `get_signed_total_amount()` 在内部调用 `get_total_amount()` 并乘以方向
|
||||
|
||||
任何需要“正负金额”的业务(财务统计、库存红冲)都应依赖此能力,而不是重复编写正负逻辑。
|
||||
|
||||
### 2.3 OrderCounterpartyMixin
|
||||
对外暴露统一的 `get_counterparty()` 接口,通过 `get_counterparty_field_name()`(或 `counterparty_field_name` 属性)确定具体业务主体字段。这样采购单/销售单分别返回 `Supplier` 与 `Customer`,但调用方只需面对一个接口。
|
||||
|
||||
### 2.4 PurchaseOrder / SalesOrder
|
||||
两类订单模型均继承上述三个 Mixin:
|
||||
|
||||
- 采购单:`get_direction()` 返回 `1`,`get_counterparty_field_name()` 返回 `supplier`
|
||||
- 销售单:`get_direction()` 返回 `-1`,`get_counterparty_field_name()` 返回 `customer`
|
||||
- 其余字段(`merchant/warehouse/operator/status/items`)保持一致,便于服务、序列化与统计逻辑复用
|
||||
|
||||
通过 mixin,两个模型天然具备:
|
||||
- 金额聚合与带方向金额计算
|
||||
- 统一的业务主体读取接口
|
||||
- 与库存/财务交互时一致的 `StockFlowService` payload
|
||||
|
||||
## 3. 服务层约定
|
||||
|
||||
- `business.services` 负责 orchestration,与 API 解耦。审批、作废、触发库存等流程必须先在服务层实现,再暴露给 API。
|
||||
- 任何涉及库存的逻辑都必须通过 `stock.services.StockFlowService`,不得直接操作库存模型,保持模块边界清晰。
|
||||
- 红冲/对冲等高级动作应由业务模块提供入口(例如 `PurchaseOrder` 红冲),但最终仍调用库存服务完成实际库存变动。
|
||||
|
||||
## 4. 未来演进建议
|
||||
|
||||
1. **新增单据**:若未来出现调拨单、退货单等,优先继承 `OrderItemsAggregationMixin + OrderDirectionMixin + OrderCounterpartyMixin`,仅通过 `get_direction()` / `get_counterparty_field_name()` 区别方向与主体,减少重复实现。
|
||||
2. **审批/状态机**:采购与销售如需共享状态流转,可提炼状态机或 service 层 mixin,而无需在模型层合并。
|
||||
3. **统计与报表**:财务/库存统计应依赖 `get_signed_total_amount()` / `get_direction()`,确保采购/销售、退货/正向都能通过统一接口处理。
|
||||
4. **文档同步**:新增单据或服务时必须更新本文件,描述新增模型如何复用 mixin、如何影响下游模块,保持设计透明。
|
||||
|
||||
以上约定的目标是:**保持采购单与未来销售单等业务对象的独立性,同时通过 mixin/服务层抽象复用绝大多数公共逻辑**。如需变更此架构(例如重新合并模型或修改核心 mixin 行为),请在评估后更新本文件,明确变化原因与迁移方案。
|
||||
|
||||
62
business/migrations/0010_salesorder_salesorderitem.py
Normal file
62
business/migrations/0010_salesorder_salesorderitem.py
Normal file
@@ -0,0 +1,62 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-30 08:32
|
||||
|
||||
import business.models
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('basic_info', '0016_merchantsetting_type'),
|
||||
('business', '0009_purchaseorderitem_spec'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SalesOrder',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||
('sales_date', models.DateField(verbose_name='销售日期')),
|
||||
('kind', models.IntegerField(choices=[(1, '大货'), (2, '样板')], default=1, verbose_name='销售单类型')),
|
||||
('status', models.IntegerField(choices=[(1, '审批中'), (2, '审批通过'), (3, '作废')], default=1, verbose_name='状态')),
|
||||
('remarks', models.TextField(blank=True, null=True, verbose_name='备注')),
|
||||
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sales_orders', to='basic_info.customer', verbose_name='客户')),
|
||||
('merchant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sales_orders', to='basic_info.merchant', verbose_name='所属商户')),
|
||||
('operator', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sales_orders', to='basic_info.employee', verbose_name='经办人')),
|
||||
('warehouse', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sales_orders', to='basic_info.warehouse', verbose_name='仓库')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '销售单',
|
||||
'verbose_name_plural': '销售单',
|
||||
},
|
||||
bases=(business.models.OrderItemsAggregationMixin, business.models.OrderDirectionMixin, business.models.OrderCounterpartyMixin, models.Model),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SalesOrderItem',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||
('price', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='单价')),
|
||||
('color', models.CharField(blank=True, max_length=50, null=True, verbose_name='颜色')),
|
||||
('quantity', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='数量')),
|
||||
('unit', models.CharField(max_length=50, verbose_name='单位')),
|
||||
('spec', models.CharField(blank=True, max_length=100, null=True, verbose_name='规格')),
|
||||
('empty_diff_percent', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='空差百分比')),
|
||||
('quantity_of_rolls', models.CharField(blank=True, max_length=255, null=True, verbose_name='各条数数量')),
|
||||
('num_of_rolls', models.PositiveIntegerField(default=1, verbose_name='条数')),
|
||||
('consume_detail_ids', models.CharField(blank=True, max_length=255, null=True, verbose_name='消耗入库明细ID列表')),
|
||||
('batch_number', models.CharField(blank=True, max_length=100, null=True, verbose_name='批次号')),
|
||||
('remarks', models.TextField(blank=True, null=True, verbose_name='备注')),
|
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sales_order_items', to='basic_info.product', verbose_name='产品')),
|
||||
('sales_order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='business.salesorder', verbose_name='销售单')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '销售单明细',
|
||||
'verbose_name_plural': '销售单明细',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from django.db import models
|
||||
from flower.common import ModelBase
|
||||
@@ -17,7 +19,76 @@ class PurchaseOrderStatusEnum(models.IntegerChoices):
|
||||
CANCELLED = 3, '作废'
|
||||
|
||||
|
||||
class PurchaseOrder(ModelBase):
|
||||
class OrderItemsAggregationMixin:
|
||||
"""
|
||||
提供订单金额/数量聚合的通用实现。
|
||||
默认读取 `items` 关联管理器,可在子类通过 `order_items_accessor`
|
||||
覆盖以适配不同的关联名称。
|
||||
"""
|
||||
|
||||
order_items_accessor = 'items'
|
||||
|
||||
def _iter_order_items(self):
|
||||
return getattr(self, self.order_items_accessor).all()
|
||||
|
||||
def get_total_amount(self) -> Decimal:
|
||||
total = Decimal('0')
|
||||
for item in self._iter_order_items():
|
||||
total += Decimal(item.total_amount())
|
||||
return total
|
||||
|
||||
def get_total_quantity(self) -> Decimal:
|
||||
total = Decimal('0')
|
||||
for item in self._iter_order_items():
|
||||
total += Decimal(item.quantity)
|
||||
return total
|
||||
|
||||
def get_total_diff_quantity(self) -> Decimal:
|
||||
total = Decimal('0')
|
||||
for item in self._iter_order_items():
|
||||
total += Decimal(item.diff_quantity())
|
||||
return total
|
||||
|
||||
|
||||
class OrderDirectionMixin:
|
||||
"""提供带方向金额的通用实现,子类只需要覆盖方向。"""
|
||||
|
||||
def get_direction(self) -> int:
|
||||
"""
|
||||
返回订单方向:
|
||||
入库 / 正向金额: 1
|
||||
出库 / 负向金额: -1
|
||||
"""
|
||||
|
||||
def get_signed_total_amount(self) -> Decimal:
|
||||
return self.get_total_amount() * Decimal(self.get_direction())
|
||||
|
||||
|
||||
class OrderCounterpartyMixin:
|
||||
"""
|
||||
抽象出“订单关联业务主体”的能力,子类只需指出字段名称,
|
||||
即可通过统一接口获取 Supplier / Customer 等对象。
|
||||
"""
|
||||
|
||||
counterparty_field_name = ''
|
||||
|
||||
def get_counterparty_field_name(self) -> str:
|
||||
"""返回用于存放业务主体的字段名,如 supplier / customer。"""
|
||||
|
||||
def get_counterparty(self) -> basic_info_models.Supplier | basic_info_models.Customer:
|
||||
field_name = self.get_counterparty_field_name() or self.counterparty_field_name
|
||||
if not field_name:
|
||||
raise AttributeError('未定义 counterparty 字段名称')
|
||||
try:
|
||||
counterparty = getattr(self, field_name)
|
||||
except AttributeError as exc:
|
||||
raise AttributeError(f'找不到字段 {field_name}') from exc
|
||||
if counterparty is None:
|
||||
raise ValueError(f'{field_name} 未设置,无法获取业务主体')
|
||||
return counterparty
|
||||
|
||||
|
||||
class PurchaseOrder(OrderItemsAggregationMixin, OrderDirectionMixin, OrderCounterpartyMixin, ModelBase):
|
||||
"""采购单模型(业务模块)"""
|
||||
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
@@ -65,14 +136,11 @@ class PurchaseOrder(ModelBase):
|
||||
verbose_name = '采购单'
|
||||
verbose_name_plural = '采购单'
|
||||
|
||||
def get_total_amount(self) -> Decimal:
|
||||
return sum(item.total_amount() for item in self.items.all())
|
||||
def get_direction(self) -> int:
|
||||
return 1
|
||||
|
||||
def get_total_quantity(self) -> Decimal:
|
||||
return sum(item.quantity for item in self.items.all())
|
||||
|
||||
def get_total_diff_quantity(self) -> Decimal:
|
||||
return sum(item.diff_quantity() for item in self.items.all())
|
||||
def get_counterparty_field_name(self) -> str:
|
||||
return 'supplier'
|
||||
|
||||
|
||||
class PurchaseOrderItem(ModelBase):
|
||||
@@ -124,3 +192,127 @@ class PurchaseOrderItem(ModelBase):
|
||||
|
||||
def total_amount(self):
|
||||
return round(self.price * self.real_quantity(), 2)
|
||||
|
||||
|
||||
class SalesOrderKindEnum(models.IntegerChoices):
|
||||
"""销售单类型"""
|
||||
WHOLESALE = 1, '大货'
|
||||
SAMPLE = 2, '样板'
|
||||
|
||||
|
||||
class SalesOrderStatusEnum(models.IntegerChoices):
|
||||
"""销售单状态"""
|
||||
PENDING = 1, '审批中'
|
||||
APPROVED = 2, '审批通过'
|
||||
CANCELLED = 3, '作废'
|
||||
|
||||
|
||||
class SalesOrder(OrderItemsAggregationMixin, OrderDirectionMixin, OrderCounterpartyMixin, ModelBase):
|
||||
"""销售单模型"""
|
||||
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
merchant = models.ForeignKey(
|
||||
basic_info_models.Merchant,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='sales_orders',
|
||||
verbose_name='所属商户',
|
||||
)
|
||||
customer = models.ForeignKey(
|
||||
basic_info_models.Customer,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='sales_orders',
|
||||
verbose_name='客户',
|
||||
)
|
||||
sales_date = models.DateField(verbose_name='销售日期')
|
||||
kind = models.IntegerField(
|
||||
choices=SalesOrderKindEnum.choices,
|
||||
default=SalesOrderKindEnum.WHOLESALE,
|
||||
verbose_name='销售单类型',
|
||||
)
|
||||
operator = models.ForeignKey(
|
||||
basic_info_models.Employee,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='sales_orders',
|
||||
verbose_name='经办人',
|
||||
)
|
||||
warehouse = models.ForeignKey(
|
||||
basic_info_models.WareHouse,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='sales_orders',
|
||||
verbose_name='仓库',
|
||||
)
|
||||
status = models.IntegerField(
|
||||
choices=SalesOrderStatusEnum.choices,
|
||||
default=SalesOrderStatusEnum.PENDING,
|
||||
verbose_name='状态',
|
||||
)
|
||||
remarks = models.TextField(blank=True, null=True, verbose_name='备注')
|
||||
|
||||
def __str__(self):
|
||||
return f'销售订单 {self.id} - {self.customer.name}'
|
||||
|
||||
class Meta:
|
||||
verbose_name = '销售单'
|
||||
verbose_name_plural = '销售单'
|
||||
|
||||
def get_direction(self) -> int:
|
||||
return -1
|
||||
|
||||
def get_counterparty_field_name(self) -> str:
|
||||
return 'customer'
|
||||
|
||||
|
||||
class SalesOrderItem(ModelBase):
|
||||
"""销售单明细模型"""
|
||||
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
sales_order = models.ForeignKey(
|
||||
SalesOrder,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='items',
|
||||
verbose_name='销售单',
|
||||
)
|
||||
product = models.ForeignKey(
|
||||
basic_info_models.Product,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='sales_order_items',
|
||||
verbose_name='产品',
|
||||
)
|
||||
price = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='单价')
|
||||
color = models.CharField(max_length=50, null=True, blank=True, verbose_name='颜色')
|
||||
quantity = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='数量')
|
||||
unit = models.CharField(max_length=50, verbose_name='单位')
|
||||
spec = models.CharField(max_length=100, null=True, blank=True, verbose_name='规格')
|
||||
empty_diff_percent = models.DecimalField(
|
||||
max_digits=15,
|
||||
decimal_places=2,
|
||||
verbose_name='空差百分比',
|
||||
)
|
||||
quantity_of_rolls = models.CharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='各条数数量',
|
||||
)
|
||||
num_of_rolls = models.PositiveIntegerField(default=1, verbose_name='条数')
|
||||
consume_detail_ids = models.CharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='消耗入库明细ID列表',
|
||||
)
|
||||
batch_number = models.CharField(null=True, blank=True, max_length=100, verbose_name='批次号')
|
||||
remarks = models.TextField(blank=True, null=True, verbose_name='备注')
|
||||
|
||||
class Meta:
|
||||
verbose_name = '销售单明细'
|
||||
verbose_name_plural = '销售单明细'
|
||||
|
||||
def real_quantity(self):
|
||||
return round(self.quantity * (1 - self.empty_diff_percent / 100), 2)
|
||||
|
||||
def diff_quantity(self):
|
||||
return round(self.quantity * (self.empty_diff_percent / 100), 2)
|
||||
|
||||
def total_amount(self):
|
||||
return round(self.price * self.real_quantity(), 2)
|
||||
|
||||
@@ -14,7 +14,10 @@ from stock.services import StockFlowService
|
||||
from basic_info.services import MerchantSettingService
|
||||
|
||||
from . import models
|
||||
from .tasks import create_purchase_order_stock_entries
|
||||
from .tasks import (
|
||||
create_purchase_order_stock_entries,
|
||||
create_sales_order_stock_entries,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -61,10 +64,11 @@ def create_purchase_order(
|
||||
raise ValueError('items 不能为空')
|
||||
|
||||
normalized_date = _normalize_order_date(order_date)
|
||||
purchase_items, stock_flow_items = _normalize_purchase_items(
|
||||
purchase_items, stock_flow_items = _normalize_order_items(
|
||||
merchant=merchant,
|
||||
warehouse=warehouse,
|
||||
items=items,
|
||||
is_outgoing=False,
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
@@ -99,6 +103,64 @@ def create_purchase_order(
|
||||
return purchase_order
|
||||
|
||||
|
||||
def create_sales_order(
|
||||
*,
|
||||
merchant: basic_info_models.Merchant,
|
||||
customer: basic_info_models.Customer,
|
||||
order_date,
|
||||
warehouse: basic_info_models.WareHouse,
|
||||
operator: basic_info_models.Employee,
|
||||
items: List[Dict[str, Any]],
|
||||
remarks: str | None = '',
|
||||
created_by=None,
|
||||
) -> models.SalesOrder:
|
||||
"""
|
||||
创建销售订单,后续审批通过后会触发出库任务。
|
||||
"""
|
||||
if not items:
|
||||
raise ValueError('items 不能为空')
|
||||
|
||||
normalized_date = _normalize_order_date(order_date)
|
||||
sales_items, stock_flow_items = _normalize_order_items(
|
||||
merchant=merchant,
|
||||
warehouse=warehouse,
|
||||
items=items,
|
||||
is_outgoing=True,
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
sales_order = models.SalesOrder.objects.create(
|
||||
merchant=merchant,
|
||||
customer=customer,
|
||||
sales_date=normalized_date,
|
||||
operator=operator,
|
||||
warehouse=warehouse,
|
||||
remarks=remarks,
|
||||
)
|
||||
bulk_objects = [
|
||||
models.SalesOrderItem(
|
||||
sales_order=sales_order,
|
||||
product=item_data['product'],
|
||||
price=item_data['price'],
|
||||
color=item_data.get('color'),
|
||||
quantity=item_data['quantity'],
|
||||
unit=item_data['unit'],
|
||||
spec=item_data.get('spec'),
|
||||
empty_diff_percent=item_data['empty_diff_percent'],
|
||||
quantity_of_rolls=item_data.get('quantity_of_rolls'),
|
||||
num_of_rolls=item_data['num_of_rolls'],
|
||||
consume_detail_ids=item_data.get('consume_detail_ids'),
|
||||
batch_number=item_data.get('batch_number'),
|
||||
remarks=item_data.get('remarks'),
|
||||
)
|
||||
for item_data in sales_items
|
||||
]
|
||||
models.SalesOrderItem.objects.bulk_create(bulk_objects)
|
||||
|
||||
sales_order.refresh_from_db()
|
||||
return sales_order
|
||||
|
||||
|
||||
def review_purchase_order(
|
||||
*,
|
||||
purchase_order: models.PurchaseOrder | None = None,
|
||||
@@ -131,18 +193,49 @@ def review_purchase_order(
|
||||
return _cancel_purchase_order(order)
|
||||
|
||||
|
||||
def _normalize_purchase_items(
|
||||
def review_sales_order(
|
||||
*,
|
||||
sales_order: models.SalesOrder | None = None,
|
||||
sales_order_id: int | None = None,
|
||||
target_status: models.SalesOrderStatusEnum,
|
||||
reviewed_by=None,
|
||||
) -> models.SalesOrder:
|
||||
"""
|
||||
审批或作废销售单。
|
||||
"""
|
||||
order = _resolve_sales_order_instance(sales_order, sales_order_id)
|
||||
|
||||
if target_status not in {
|
||||
models.SalesOrderStatusEnum.APPROVED,
|
||||
models.SalesOrderStatusEnum.CANCELLED,
|
||||
}:
|
||||
raise ValueError('target_status 只能是 APPROVED 或 CANCELLED')
|
||||
|
||||
if order.status == target_status:
|
||||
return order
|
||||
|
||||
if target_status == models.SalesOrderStatusEnum.APPROVED:
|
||||
if order.status == models.SalesOrderStatusEnum.CANCELLED:
|
||||
raise ValueError('作废状态的销售单无法再次审批')
|
||||
return _approve_sales_order(order, reviewed_by)
|
||||
|
||||
return _cancel_sales_order(order)
|
||||
|
||||
|
||||
def _normalize_order_items(
|
||||
*,
|
||||
merchant: basic_info_models.Merchant,
|
||||
warehouse: basic_info_models.WareHouse,
|
||||
items: List[Dict[str, Any]],
|
||||
is_outgoing: bool,
|
||||
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||
"""
|
||||
根据仓库模式校验采购明细,并返回
|
||||
- purchase_items: 用于创建 PurchaseOrderItem
|
||||
根据仓库模式校验订单明细,并返回:
|
||||
- normalized_items: 用于创建订单明细模型
|
||||
- stock_flow_items: 传递给 StockFlowService 的 items 结构
|
||||
is_outgoing=True 表示出库(销售等),需要额外校验 consume_detail_ids。
|
||||
"""
|
||||
purchase_items: List[Dict[str, Any]] = []
|
||||
normalized_items: List[Dict[str, Any]] = []
|
||||
stock_flow_items: List[Dict[str, Any]] = []
|
||||
|
||||
warehouse_mode = warehouse.mode
|
||||
@@ -164,21 +257,25 @@ def _normalize_purchase_items(
|
||||
spec = raw_item.get('spec')
|
||||
unit = raw_item.get('unit') or product.get_unit_display() or '米'
|
||||
|
||||
quantity = 0
|
||||
num_of_rolls = 0
|
||||
quantity_of_rolls = None
|
||||
consume_detail_ids_str = None
|
||||
|
||||
if warehouse_mode == basic_info_models.WareHouseModeEnum.UNRESTRICTED:
|
||||
if 'numbers' in raw_item and raw_item['numbers']:
|
||||
raise ValueError(f'仓库为宽进模式,items[{index}] 不应提供 numbers')
|
||||
raise ValueError(f'仓库为宽进/宽出模式,items[{index}] 不应提供 numbers')
|
||||
quantity = _to_positive_int(raw_item.get('quantity'), f'items[{index}].quantity')
|
||||
num_of_rolls = _to_positive_int(raw_item.get('num_of_rolls'), f'items[{index}].num_of_rolls')
|
||||
quantity_of_rolls = None
|
||||
stock_flow_items.append({
|
||||
'product_id': product.id,
|
||||
'value': str(quantity),
|
||||
'num_of_rolls': num_of_rolls,
|
||||
})
|
||||
else:
|
||||
elif warehouse_mode == basic_info_models.WareHouseModeEnum.RESTRICT_IN:
|
||||
numbers = raw_item.get('numbers')
|
||||
if not numbers or not isinstance(numbers, list):
|
||||
raise ValueError(f'仓库为严进模式,items[{index}] 需要提供 numbers 数组')
|
||||
raise ValueError(f'仓库为严进宽出模式,items[{index}] 需要提供 numbers 数组')
|
||||
normalized_numbers = [
|
||||
str(_to_positive_int(value, f'items[{index}].numbers[{pos}]'))
|
||||
for pos, value in enumerate(numbers)
|
||||
@@ -190,8 +287,39 @@ def _normalize_purchase_items(
|
||||
'product_id': product.id,
|
||||
'quantities': normalized_numbers,
|
||||
})
|
||||
else: # WareHouseModeEnum.RESTRICT_IN_OUT
|
||||
if is_outgoing:
|
||||
consume_ids = raw_item.get('consume_detail_ids')
|
||||
if not consume_ids or not isinstance(consume_ids, list):
|
||||
raise ValueError(f'仓库为严进严出模式,items[{index}] 需要提供 consume_detail_ids 数组')
|
||||
normalized_ids = [
|
||||
_to_positive_int(value, f'items[{index}].consume_detail_ids[{pos}]')
|
||||
for pos, value in enumerate(consume_ids)
|
||||
]
|
||||
consume_detail_ids_str = ','.join(str(value) for value in normalized_ids)
|
||||
quantity = _to_positive_int(raw_item.get('quantity'), f'items[{index}].quantity')
|
||||
num_of_rolls = len(normalized_ids)
|
||||
stock_flow_items.append({
|
||||
'product_id': product.id,
|
||||
'consume_detail_ids': normalized_ids,
|
||||
})
|
||||
else:
|
||||
numbers = raw_item.get('numbers')
|
||||
if not numbers or not isinstance(numbers, list):
|
||||
raise ValueError(f'仓库为严进严出模式,items[{index}] 需要提供 numbers 数组')
|
||||
normalized_numbers = [
|
||||
str(_to_positive_int(value, f'items[{index}].numbers[{pos}]'))
|
||||
for pos, value in enumerate(numbers)
|
||||
]
|
||||
num_of_rolls = len(normalized_numbers)
|
||||
quantity = sum(int(val) for val in normalized_numbers)
|
||||
quantity_of_rolls = ','.join(normalized_numbers)
|
||||
stock_flow_items.append({
|
||||
'product_id': product.id,
|
||||
'quantities': normalized_numbers,
|
||||
})
|
||||
|
||||
purchase_items.append({
|
||||
normalized_items.append({
|
||||
'product': product,
|
||||
'price': price,
|
||||
'color': color,
|
||||
@@ -203,9 +331,10 @@ def _normalize_purchase_items(
|
||||
'batch_number': batch_number,
|
||||
'remarks': remarks,
|
||||
'spec': spec,
|
||||
'consume_detail_ids': consume_detail_ids_str,
|
||||
})
|
||||
|
||||
return purchase_items, stock_flow_items
|
||||
return normalized_items, stock_flow_items
|
||||
|
||||
|
||||
def _approve_purchase_order(
|
||||
@@ -233,7 +362,11 @@ def _approve_purchase_order(
|
||||
|
||||
|
||||
def _cancel_purchase_order(purchase_order: models.PurchaseOrder) -> models.PurchaseOrder:
|
||||
if _purchase_order_has_stock_records(purchase_order):
|
||||
if _order_has_stock_records(
|
||||
merchant_id=purchase_order.merchant_id,
|
||||
source_type=stock_models.StockChangeSourceEnum.PURCHASE,
|
||||
source_id=purchase_order.id,
|
||||
):
|
||||
raise ValueError('采购单已生成出入库记录,无法作废')
|
||||
|
||||
with transaction.atomic():
|
||||
@@ -244,6 +377,46 @@ def _cancel_purchase_order(purchase_order: models.PurchaseOrder) -> models.Purch
|
||||
return purchase_order
|
||||
|
||||
|
||||
def _approve_sales_order(
|
||||
sales_order: models.SalesOrder,
|
||||
reviewed_by,
|
||||
) -> models.SalesOrder:
|
||||
stock_flow_items = _build_stock_flow_items_from_order(sales_order)
|
||||
|
||||
with transaction.atomic():
|
||||
sales_order.status = models.SalesOrderStatusEnum.APPROVED
|
||||
sales_order.save(update_fields=['status', 'updated_at'])
|
||||
|
||||
created_by_id = getattr(reviewed_by, 'id', None)
|
||||
if _auto_stock_task_enabled(sales_order.merchant):
|
||||
logger.info('审批通过销售单 %s,触发出库任务', sales_order.id)
|
||||
create_sales_order_stock_entries.delay(
|
||||
sales_order_id=sales_order.id,
|
||||
warehouse_id=sales_order.warehouse_id,
|
||||
items=stock_flow_items,
|
||||
created_by_id=created_by_id,
|
||||
)
|
||||
|
||||
sales_order.refresh_from_db(fields=['status', 'updated_at'])
|
||||
return sales_order
|
||||
|
||||
|
||||
def _cancel_sales_order(sales_order: models.SalesOrder) -> models.SalesOrder:
|
||||
if _order_has_stock_records(
|
||||
merchant_id=sales_order.merchant_id,
|
||||
source_type=stock_models.StockChangeSourceEnum.SALES,
|
||||
source_id=sales_order.id,
|
||||
):
|
||||
raise ValueError('销售单已生成出入库记录,无法作废')
|
||||
|
||||
with transaction.atomic():
|
||||
sales_order.status = models.SalesOrderStatusEnum.CANCELLED
|
||||
sales_order.save(update_fields=['status', 'updated_at'])
|
||||
|
||||
sales_order.refresh_from_db(fields=['status', 'updated_at'])
|
||||
return sales_order
|
||||
|
||||
|
||||
def create_purchase_order_stock_entries_sync(
|
||||
*,
|
||||
purchase_order_id: int,
|
||||
@@ -284,16 +457,56 @@ def create_purchase_order_stock_entries_sync(
|
||||
return payload
|
||||
|
||||
|
||||
def _build_stock_flow_items_from_order(purchase_order: models.PurchaseOrder) -> List[Dict[str, Any]]:
|
||||
def create_sales_order_stock_entries_sync(
|
||||
*,
|
||||
sales_order_id: int,
|
||||
warehouse_id: int,
|
||||
items: List[Dict[str, Any]],
|
||||
created_by_id: int | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
根据采购单明细还原 StockFlowService 所需的 items 结构。
|
||||
根据销售单生成出库记录。
|
||||
"""
|
||||
warehouse_mode = purchase_order.warehouse.mode
|
||||
try:
|
||||
sales_order = models.SalesOrder.objects.select_related('merchant').get(id=sales_order_id)
|
||||
except models.SalesOrder.DoesNotExist:
|
||||
logger.error('SalesOrder %s 不存在,无法创建出库单', sales_order_id)
|
||||
return {'error': 'sales_order_not_found', 'sales_order_id': sales_order_id}
|
||||
|
||||
merchant = sales_order.merchant
|
||||
|
||||
created_by = None
|
||||
if created_by_id:
|
||||
UserModel = get_user_model()
|
||||
created_by = UserModel.objects.filter(id=created_by_id).first()
|
||||
|
||||
service = StockFlowService(merchant=merchant, created_by=created_by)
|
||||
record, details, created_count = service.stock_out(
|
||||
warehouse_id=warehouse_id,
|
||||
source_type=stock_models.StockChangeSourceEnum.SALES,
|
||||
source_id=sales_order.id,
|
||||
items=items,
|
||||
)
|
||||
|
||||
payload = {
|
||||
'sales_order_id': sales_order.id,
|
||||
'stock_change_record_id': getattr(record, 'id', None),
|
||||
'created_details_count': created_count,
|
||||
}
|
||||
logger.info('销售单 %s 出库任务完成: %s', sales_order.id, payload)
|
||||
return payload
|
||||
|
||||
|
||||
def _build_stock_flow_items_from_order(order) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
根据订单明细还原 StockFlowService 所需的 items 结构。
|
||||
"""
|
||||
warehouse_mode = order.warehouse.mode
|
||||
items_payload: List[Dict[str, Any]] = []
|
||||
|
||||
order_items = purchase_order.items.all()
|
||||
order_items = order.items.all()
|
||||
if not order_items:
|
||||
raise ValueError('采购单没有任何明细,无法生成入库记录')
|
||||
raise ValueError('订单没有任何明细,无法生成库存记录')
|
||||
|
||||
if warehouse_mode == basic_info_models.WareHouseModeEnum.UNRESTRICTED:
|
||||
for item in order_items:
|
||||
@@ -306,13 +519,24 @@ def _build_stock_flow_items_from_order(purchase_order: models.PurchaseOrder) ->
|
||||
|
||||
# 严进 / 严进严出模式
|
||||
for item in order_items:
|
||||
consume_ids_raw = getattr(item, 'consume_detail_ids', None)
|
||||
if consume_ids_raw:
|
||||
normalized_ids = [value.strip() for value in consume_ids_raw.split(',') if value.strip()]
|
||||
if not normalized_ids:
|
||||
raise ValueError('严进严出订单缺少 consume_detail_ids 数据,无法生成库存记录')
|
||||
items_payload.append({
|
||||
'product_id': item.product_id,
|
||||
'consume_detail_ids': [int(value) for value in normalized_ids],
|
||||
})
|
||||
continue
|
||||
|
||||
raw_numbers = (item.quantity_of_rolls or '').split(',')
|
||||
normalized = [value.strip() for value in raw_numbers if value.strip()]
|
||||
if not normalized:
|
||||
raise ValueError('严进仓采购单缺少 numbers 数据,无法生成入库记录')
|
||||
normalized_numbers = [value.strip() for value in raw_numbers if value.strip()]
|
||||
if not normalized_numbers:
|
||||
raise ValueError('严进仓订单缺少 numbers 数据,无法生成库存记录')
|
||||
items_payload.append({
|
||||
'product_id': item.product_id,
|
||||
'quantities': normalized,
|
||||
'quantities': normalized_numbers,
|
||||
})
|
||||
return items_payload
|
||||
|
||||
@@ -329,11 +553,16 @@ def _auto_stock_task_enabled(merchant: basic_info_models.Merchant) -> bool:
|
||||
return setting.value is True
|
||||
|
||||
|
||||
def _purchase_order_has_stock_records(purchase_order: models.PurchaseOrder) -> bool:
|
||||
def _order_has_stock_records(
|
||||
*,
|
||||
merchant_id: int,
|
||||
source_type: stock_models.StockChangeSourceEnum,
|
||||
source_id: int,
|
||||
) -> bool:
|
||||
return stock_models.StockChangeRecord.objects.filter(
|
||||
merchant_id=purchase_order.merchant_id,
|
||||
source_type=stock_models.StockChangeSourceEnum.PURCHASE,
|
||||
source_id=purchase_order.id,
|
||||
merchant_id=merchant_id,
|
||||
source_type=source_type,
|
||||
source_id=source_id,
|
||||
).exists()
|
||||
|
||||
|
||||
@@ -352,6 +581,21 @@ def _resolve_purchase_order_instance(
|
||||
)
|
||||
|
||||
|
||||
def _resolve_sales_order_instance(
|
||||
sales_order: models.SalesOrder | None,
|
||||
sales_order_id: int | None,
|
||||
) -> models.SalesOrder:
|
||||
if sales_order is None and sales_order_id is None:
|
||||
raise ValueError('必须提供 sales_order 或 sales_order_id')
|
||||
|
||||
if sales_order is not None:
|
||||
sales_order_id = sales_order.id
|
||||
|
||||
return models.SalesOrder.objects.select_related('merchant', 'warehouse').prefetch_related('items').get(
|
||||
id=sales_order_id
|
||||
)
|
||||
|
||||
|
||||
def _to_decimal(value, field_name: str) -> Decimal:
|
||||
try:
|
||||
return Decimal(str(value))
|
||||
|
||||
@@ -23,3 +23,22 @@ def create_purchase_order_stock_entries(
|
||||
payload['task_id'] = self.request.id
|
||||
return payload
|
||||
|
||||
|
||||
@shared_task(bind=True)
|
||||
def create_sales_order_stock_entries(
|
||||
self,
|
||||
*,
|
||||
sales_order_id: int,
|
||||
warehouse_id: int,
|
||||
items: List[Dict[str, Any]],
|
||||
created_by_id: int | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
payload = business_services.create_sales_order_stock_entries_sync(
|
||||
sales_order_id=sales_order_id,
|
||||
warehouse_id=warehouse_id,
|
||||
items=items,
|
||||
created_by_id=created_by_id,
|
||||
)
|
||||
payload['task_id'] = self.request.id
|
||||
return payload
|
||||
|
||||
|
||||
@@ -55,6 +55,58 @@ def create_basic_fixtures():
|
||||
return merchant, supplier, warehouse_strict, warehouse_relaxed, product, operator
|
||||
|
||||
|
||||
def create_sales_fixtures():
|
||||
merchant = basic_models.Merchant.objects.create(
|
||||
name='销售商户',
|
||||
type=basic_models.MerchantTypeEnum.FACTORY,
|
||||
)
|
||||
basic_models.MerchantSetting.objects.create(
|
||||
merchant=merchant,
|
||||
key=basic_models.MerchantSettingKeyEnum.AUTO_CREATE_STOCK_CHANGE_TASKS,
|
||||
type=basic_models.MerchantSettingTypeEnum.BOOL,
|
||||
val_bool=True,
|
||||
)
|
||||
customer = basic_models.Customer.objects.create(
|
||||
merchant=merchant,
|
||||
name='客户A',
|
||||
mobile='13800000000',
|
||||
created_by=None,
|
||||
)
|
||||
warehouse_strict = basic_models.WareHouse.objects.create(
|
||||
merchant=merchant,
|
||||
name='销售严进仓',
|
||||
mode=basic_models.WareHouseModeEnum.RESTRICT_IN,
|
||||
)
|
||||
warehouse_relaxed = basic_models.WareHouse.objects.create(
|
||||
merchant=merchant,
|
||||
name='销售宽进仓',
|
||||
mode=basic_models.WareHouseModeEnum.UNRESTRICTED,
|
||||
)
|
||||
warehouse_strict_out = basic_models.WareHouse.objects.create(
|
||||
merchant=merchant,
|
||||
name='销售严出仓',
|
||||
mode=basic_models.WareHouseModeEnum.RESTRICT_IN_OUT,
|
||||
)
|
||||
category = basic_models.ProductCategory.objects.create(
|
||||
merchant=merchant,
|
||||
name='销售品类',
|
||||
product_prefix='SAL',
|
||||
)
|
||||
product = basic_models.Product.objects.create(
|
||||
merchant=merchant,
|
||||
category=category,
|
||||
name='销售面料',
|
||||
human_id='SAL-001',
|
||||
unit=basic_models.ProductUnitEnum.METER,
|
||||
)
|
||||
operator = basic_models.Employee.objects.create(
|
||||
merchant=merchant,
|
||||
name='销售员',
|
||||
status=basic_models.EmployeeStatusEnum.ACTIVE,
|
||||
)
|
||||
return merchant, customer, warehouse_strict, warehouse_relaxed, warehouse_strict_out, product, operator
|
||||
|
||||
|
||||
class PurchaseOrderServiceTestCase(TestCase):
|
||||
def setUp(self):
|
||||
(
|
||||
@@ -201,6 +253,128 @@ class PurchaseOrderServiceTestCase(TestCase):
|
||||
)
|
||||
|
||||
|
||||
class SalesOrderServiceTestCase(TestCase):
|
||||
def setUp(self):
|
||||
(
|
||||
self.merchant,
|
||||
self.customer,
|
||||
self.warehouse_strict,
|
||||
self.warehouse_relaxed,
|
||||
self.warehouse_strict_out,
|
||||
self.product,
|
||||
self.operator,
|
||||
) = create_sales_fixtures()
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(username='sales-creator', password='pass123')
|
||||
self.strict_items = [
|
||||
{'product_id': self.product.id, 'numbers': [6, 4], 'price': '30.5', 'unit': '米'}
|
||||
]
|
||||
self.relaxed_items = [
|
||||
{'product_id': self.product.id, 'quantity': 80, 'num_of_rolls': 2, 'price': '28.0', 'unit': '米'}
|
||||
]
|
||||
self.strict_out_items = [
|
||||
{
|
||||
'product_id': self.product.id,
|
||||
'consume_detail_ids': [1, 2],
|
||||
'quantity': 40,
|
||||
'price': '32.0',
|
||||
'unit': '米',
|
||||
}
|
||||
]
|
||||
|
||||
def test_create_sales_order_success(self):
|
||||
with patch('business.services.create_sales_order_stock_entries.delay') as mock_delay:
|
||||
sales_order = services.create_sales_order(
|
||||
merchant=self.merchant,
|
||||
customer=self.customer,
|
||||
order_date=timezone.now().date(),
|
||||
warehouse=self.warehouse_strict,
|
||||
operator=self.operator,
|
||||
items=self.strict_items,
|
||||
remarks='销售测试',
|
||||
created_by=self.user,
|
||||
)
|
||||
|
||||
self.assertIsInstance(sales_order, business_models.SalesOrder)
|
||||
self.assertEqual(sales_order.status, business_models.SalesOrderStatusEnum.PENDING)
|
||||
self.assertEqual(sales_order.items.count(), 1)
|
||||
self.assertEqual(sales_order.items.first().quantity_of_rolls, '6,4')
|
||||
mock_delay.assert_not_called()
|
||||
|
||||
def test_review_sales_order_triggers_task(self):
|
||||
sales_order = services.create_sales_order(
|
||||
merchant=self.merchant,
|
||||
customer=self.customer,
|
||||
order_date=timezone.now().date(),
|
||||
warehouse=self.warehouse_strict,
|
||||
operator=self.operator,
|
||||
items=self.strict_items,
|
||||
created_by=self.user,
|
||||
)
|
||||
with patch('business.services.create_sales_order_stock_entries.delay') as mock_delay:
|
||||
reviewed = services.review_sales_order(
|
||||
sales_order=sales_order,
|
||||
target_status=business_models.SalesOrderStatusEnum.APPROVED,
|
||||
reviewed_by=self.user,
|
||||
)
|
||||
self.assertEqual(reviewed.status, business_models.SalesOrderStatusEnum.APPROVED)
|
||||
mock_delay.assert_called_once_with(
|
||||
sales_order_id=sales_order.id,
|
||||
warehouse_id=self.warehouse_strict.id,
|
||||
items=[{'product_id': self.product.id, 'quantities': ['6', '4']}],
|
||||
created_by_id=self.user.id,
|
||||
)
|
||||
|
||||
def test_sales_order_cancel_blocked_after_stock_created(self):
|
||||
sales_order = services.create_sales_order(
|
||||
merchant=self.merchant,
|
||||
customer=self.customer,
|
||||
order_date=timezone.now().date(),
|
||||
warehouse=self.warehouse_relaxed,
|
||||
operator=self.operator,
|
||||
items=self.relaxed_items,
|
||||
created_by=self.user,
|
||||
)
|
||||
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=sales_order.id,
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
services.review_sales_order(
|
||||
sales_order=sales_order,
|
||||
target_status=business_models.SalesOrderStatusEnum.CANCELLED,
|
||||
reviewed_by=self.user,
|
||||
)
|
||||
|
||||
def test_sales_order_requires_consume_ids_for_strict_out(self):
|
||||
with self.assertRaises(ValueError):
|
||||
services.create_sales_order(
|
||||
merchant=self.merchant,
|
||||
customer=self.customer,
|
||||
order_date=timezone.now().date(),
|
||||
warehouse=self.warehouse_strict_out,
|
||||
operator=self.operator,
|
||||
items=[{'product_id': self.product.id, 'quantity': 10, 'price': '20', 'unit': '米'}],
|
||||
created_by=self.user,
|
||||
)
|
||||
|
||||
def test_sales_order_stores_consume_ids(self):
|
||||
sales_order = services.create_sales_order(
|
||||
merchant=self.merchant,
|
||||
customer=self.customer,
|
||||
order_date=timezone.now().date(),
|
||||
warehouse=self.warehouse_strict_out,
|
||||
operator=self.operator,
|
||||
items=self.strict_out_items,
|
||||
created_by=self.user,
|
||||
)
|
||||
item = sales_order.items.first()
|
||||
self.assertEqual(item.consume_detail_ids, '1,2')
|
||||
|
||||
|
||||
class PurchaseOrderStockServiceTestCase(TestCase):
|
||||
def setUp(self):
|
||||
(
|
||||
@@ -245,6 +419,51 @@ class PurchaseOrderStockServiceTestCase(TestCase):
|
||||
self.assertEqual(payload['stock_change_record_id'], 321)
|
||||
|
||||
|
||||
class SalesOrderStockServiceTestCase(TestCase):
|
||||
def setUp(self):
|
||||
(
|
||||
self.merchant,
|
||||
self.customer,
|
||||
self.warehouse_strict,
|
||||
self.warehouse_relaxed,
|
||||
self.warehouse_strict_out,
|
||||
self.product,
|
||||
self.operator,
|
||||
) = create_sales_fixtures()
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(username='sales-svc', password='pass123')
|
||||
self.sales_order = business_models.SalesOrder.objects.create(
|
||||
merchant=self.merchant,
|
||||
customer=self.customer,
|
||||
sales_date=timezone.now().date(),
|
||||
operator=self.operator,
|
||||
warehouse=self.warehouse_strict,
|
||||
)
|
||||
self.items = [{'product_id': self.product.id, 'quantities': ['5']}]
|
||||
|
||||
def test_service_calls_stock_out(self):
|
||||
with patch('business.services.StockFlowService') as mock_flow_cls:
|
||||
mock_instance = mock_flow_cls.return_value
|
||||
mock_instance.stock_out.return_value = (MagicMock(id=654), [], 1)
|
||||
|
||||
payload = services.create_sales_order_stock_entries_sync(
|
||||
sales_order_id=self.sales_order.id,
|
||||
warehouse_id=self.warehouse_strict.id,
|
||||
items=self.items,
|
||||
created_by_id=self.user.id,
|
||||
)
|
||||
|
||||
mock_flow_cls.assert_called_once_with(merchant=self.merchant, created_by=self.user)
|
||||
mock_instance.stock_out.assert_called_once_with(
|
||||
warehouse_id=self.warehouse_strict.id,
|
||||
source_type=stock_models.StockChangeSourceEnum.SALES,
|
||||
source_id=self.sales_order.id,
|
||||
items=[{'product_id': self.product.id, 'quantities': ['5']}],
|
||||
)
|
||||
self.assertEqual(payload['sales_order_id'], self.sales_order.id)
|
||||
self.assertEqual(payload['stock_change_record_id'], 654)
|
||||
|
||||
|
||||
@override_settings(
|
||||
CELERY_TASK_ALWAYS_EAGER=True,
|
||||
CELERY_TASK_EAGER_PROPAGATES=True,
|
||||
@@ -275,6 +494,35 @@ class PurchaseOrderStockTaskTestCase(TestCase):
|
||||
)
|
||||
self.assertEqual(payload['purchase_order_id'], self.purchase_order_id)
|
||||
self.assertIn('task_id', payload)
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
|
||||
@override_settings(
|
||||
CELERY_TASK_ALWAYS_EAGER=True,
|
||||
CELERY_TASK_EAGER_PROPAGATES=True,
|
||||
)
|
||||
class SalesOrderStockTaskTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.sales_order_id = 321
|
||||
self.warehouse_id = 654
|
||||
self.items = [{'product_id': 2, 'quantities': ['4']}]
|
||||
|
||||
def test_task_delegates_to_service(self):
|
||||
with patch('business.tasks.business_services.create_sales_order_stock_entries_sync') as mock_sync:
|
||||
mock_sync.return_value = {'sales_order_id': self.sales_order_id}
|
||||
|
||||
async_result = tasks.create_sales_order_stock_entries.delay(
|
||||
sales_order_id=self.sales_order_id,
|
||||
warehouse_id=self.warehouse_id,
|
||||
items=self.items,
|
||||
created_by_id=777,
|
||||
)
|
||||
payload = async_result.get(timeout=5)
|
||||
|
||||
mock_sync.assert_called_once_with(
|
||||
sales_order_id=self.sales_order_id,
|
||||
warehouse_id=self.warehouse_id,
|
||||
items=self.items,
|
||||
created_by_id=777,
|
||||
)
|
||||
self.assertEqual(payload['sales_order_id'], self.sales_order_id)
|
||||
self.assertIn('task_id', payload)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
- 采购单在创建时默认进入 `PENDING`(审批中)状态,不再立即创建出入库记录。
|
||||
- `business.services.review_purchase_order` 统一处理审批通过 (`APPROVED`) 与作废 (`CANCELLED`) 的业务规则。
|
||||
- `api_v1/views/purchase_order.py` 暴露 `POST /api/v1/purchase-orders/<id>/review/` 接口作为唯一入口,便于前端、自动化流程和第三方系统统一调用。
|
||||
- `api_v1/business/purchase/views.py` 暴露 `POST /api/v1/purchase-orders/<id>/review/` 接口作为唯一入口,便于前端、自动化流程和第三方系统统一调用。
|
||||
|
||||
## 2. 审批流程总览
|
||||
|
||||
|
||||
67
docs/sales_order_approval_and_red_flush.md
Normal file
67
docs/sales_order_approval_and_red_flush.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# 销售单审批流程与红冲规划
|
||||
|
||||
## 1. 背景
|
||||
|
||||
- 销售单在创建时同样进入 `PENDING` 状态,审批通过后才会触发出库流程。
|
||||
- `business.services.review_sales_order` 统一处理审批通过 (`APPROVED`) 与作废 (`CANCELLED`) 的业务规则。
|
||||
- `api_v1/business/sales/views.py` 暴露 `POST /api/v1/sales-orders/<id>/review/` 接口,供前端、自动化和第三方系统一致调用。
|
||||
|
||||
## 2. 审批流程总览
|
||||
|
||||
| 步骤 | 说明 |
|
||||
|------|------|
|
||||
| 1 | 前端调用 `POST /api/v1/sales-orders/<id>/review/`, body: `{"action": "approve"}` 或 `{"action": "cancel"}` |
|
||||
| 2 | API 层校验员工身份、订单归属商户、`action` 可选项 |
|
||||
| 3 | API 调用 `review_sales_order`,根据 `action` 映射到 `SalesOrderStatusEnum.APPROVED` / `CANCELLED` |
|
||||
| 4 | Service 端根据状态调用 `_approve_sales_order` 或 `_cancel_sales_order`,并在必要时抛出业务异常(如重复审批、已有出入库记录等) |
|
||||
| 5 | API 返回最新的 `SalesOrderSerializer` 数据,供前端刷新详情 |
|
||||
|
||||
## 3. 审批通过触发的事务
|
||||
|
||||
1. `_approve_sales_order` 构建 `stock_flow_items`(结构与采购单一致)。
|
||||
2. 在事务内将 `sales_order.status` 更新为 `APPROVED`。
|
||||
3. 读取商户设置 `auto_create_stock_change_tasks`:
|
||||
- 未配置或为 `False` 时,仅更新状态。
|
||||
- 为 `True` 时投递 `business.tasks.create_sales_order_stock_entries` Celery 任务。
|
||||
4. Celery 任务执行 `StockFlowService.stock_out`:
|
||||
- 依据仓库模式创建出库 `StockChangeRecord` / `StockChangeDetail`。
|
||||
- `stock.services.make_stock_change_completed` 会在自动完成开启时写入 `Inventory` 并生成 `StockSnapshot`。
|
||||
5. 任务返回 `stock_change_record_id` 与明细信息,并写日志用于审计。
|
||||
|
||||
## 4. 作废流程约束
|
||||
|
||||
1. 仍通过 `POST /api/v1/sales-orders/<id>/review/`, body: `{"action": "cancel"}`。
|
||||
2. `_cancel_sales_order` 会检查 `source_type = SALES` 且 `source_id = 销售单ID` 的 `StockChangeRecord` 是否存在。
|
||||
3. 若已经生成出库记录,则抛出 `ValueError('销售单已生成出入库记录,无法作废')`,API 返回 `400` 提示前端。
|
||||
4. 若未生成记录,则在事务内将状态更新为 `CANCELLED`,且不会触发 Celery 任务。
|
||||
|
||||
## 5. 常见异常与返回
|
||||
|
||||
- 缺少 `customer` 字段:`400` + `{"error": "缺少客户 ID"}`。
|
||||
- 审批重复:保持幂等,直接返回当前状态。
|
||||
- 已作废单据审批:`ValueError('作废状态的销售单无法再次审批')` -> `400`。
|
||||
- 作废时已有出库记录:`ValueError('销售单已生成出入库记录,无法作废')` -> `400`。
|
||||
- ID 不存在或不属于当前商户:`404`。
|
||||
|
||||
## 6. 创建接口参数约定
|
||||
|
||||
销售单创建接口沿用采购单的参数格式,根据仓库模式填写 `items`:
|
||||
|
||||
| 仓库模式 | 出库模式 | `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` |
|
||||
|
||||
> 说明:在严出模式下,`consume_detail_ids` 列表中的 ID 必须为同仓库、同商户且未被消耗的入库明细 ID。系统会在审批通过时逐条扣减对应库存。
|
||||
|
||||
## 6. 红冲(对冲)规划
|
||||
|
||||
与采购单一致,后续将通过库存红冲入口(`StockFlowService.offset_stock_change` 占位方法)实现销售单出库的反向抵销。关键原则:
|
||||
|
||||
- 采用新增反向记录的方式,保留所有历史变动。
|
||||
- 红冲记录需要指向原 `StockChangeRecord` / `StockSnapshot`,保证审计可追溯。
|
||||
- 红冲需与业务对象绑定,避免孤立库存操作。
|
||||
|
||||
文档与测试应在红冲能力落地时同步更新,确保审批、出库、红冲形成闭环。
|
||||
|
||||
Reference in New Issue
Block a user