From ce1954ad583722d3a7f2e63ef783b02635fda05f Mon Sep 17 00:00:00 2001 From: colaftc Date: Sun, 30 Nov 2025 17:06:48 +0800 Subject: [PATCH] feat: sales_order api --- api_v1/business/README.md | 122 ++++++++ api_v1/business/__init__.py | 6 + api_v1/business/purchase/__init__.py | 4 + .../purchase/views.py} | 12 +- api_v1/business/sales/views.py | 177 +++++++++++ api_v1/tests.py | 173 +++++++++- api_v1/urls.py | 9 +- business/ARCHITECTURE.md | 61 ++++ .../0010_salesorder_salesorderitem.py | 62 ++++ business/models.py | 208 +++++++++++- business/services.py | 296 ++++++++++++++++-- business/tasks.py | 19 ++ business/tests.py | 252 ++++++++++++++- docs/purchase_order_approval_and_red_flush.md | 2 +- docs/sales_order_approval_and_red_flush.md | 67 ++++ 15 files changed, 1426 insertions(+), 44 deletions(-) create mode 100644 api_v1/business/README.md create mode 100644 api_v1/business/__init__.py create mode 100644 api_v1/business/purchase/__init__.py rename api_v1/{views/purchase_order.py => business/purchase/views.py} (97%) create mode 100644 api_v1/business/sales/views.py create mode 100644 business/ARCHITECTURE.md create mode 100644 business/migrations/0010_salesorder_salesorderitem.py create mode 100644 docs/sales_order_approval_and_red_flush.md diff --git a/api_v1/business/README.md b/api_v1/business/README.md new file mode 100644 index 0000000..ad1308f --- /dev/null +++ b/api_v1/business/README.md @@ -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//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//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` + diff --git a/api_v1/business/__init__.py b/api_v1/business/__init__.py new file mode 100644 index 0000000..5db3289 --- /dev/null +++ b/api_v1/business/__init__.py @@ -0,0 +1,6 @@ +""" +业务域相关的 API 视图与工具。 + +该包下按业务对象分类,例如 purchase、sales 等。 +""" + diff --git a/api_v1/business/purchase/__init__.py b/api_v1/business/purchase/__init__.py new file mode 100644 index 0000000..a919fbe --- /dev/null +++ b/api_v1/business/purchase/__init__.py @@ -0,0 +1,4 @@ +""" +采购域 API 视图。 +""" + diff --git a/api_v1/views/purchase_order.py b/api_v1/business/purchase/views.py similarity index 97% rename from api_v1/views/purchase_order.py rename to api_v1/business/purchase/views.py index 2add73b..98f4656 100644 --- a/api_v1/views/purchase_order.py +++ b/api_v1/business/purchase/views.py @@ -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) + diff --git a/api_v1/business/sales/views.py b/api_v1/business/sales/views.py new file mode 100644 index 0000000..1e2e1c7 --- /dev/null +++ b/api_v1/business/sales/views.py @@ -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) + diff --git a/api_v1/tests.py b/api_v1/tests.py index c16f052..3461c4d 100644 --- a/api_v1/tests.py +++ b/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, diff --git a/api_v1/urls.py b/api_v1/urls.py index 9e754fc..aacefe4 100644 --- a/api_v1/urls.py +++ b/api_v1/urls.py @@ -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//review/', purchase_order.PurchaseOrderReviewView.as_view(), name='purchase_order_review'), + path('purchase-orders/', purchase_views.PurchaseOrderView.as_view(), name='purchase_orders'), + path('purchase-orders//review/', purchase_views.PurchaseOrderReviewView.as_view(), name='purchase_order_review'), + path('sales-orders/', sales_views.SalesOrderView.as_view(), name='sales_orders'), + path('sales-orders//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'), diff --git a/business/ARCHITECTURE.md b/business/ARCHITECTURE.md new file mode 100644 index 0000000..feb73ed --- /dev/null +++ b/business/ARCHITECTURE.md @@ -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 行为),请在评估后更新本文件,明确变化原因与迁移方案。 + diff --git a/business/migrations/0010_salesorder_salesorderitem.py b/business/migrations/0010_salesorder_salesorderitem.py new file mode 100644 index 0000000..033696e --- /dev/null +++ b/business/migrations/0010_salesorder_salesorderitem.py @@ -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': '销售单明细', + }, + ), + ] diff --git a/business/models.py b/business/models.py index d08896e..c070334 100644 --- a/business/models.py +++ b/business/models.py @@ -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) diff --git a/business/services.py b/business/services.py index 428a4d7..0ac14d6 100644 --- a/business/services.py +++ b/business/services.py @@ -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)) diff --git a/business/tasks.py b/business/tasks.py index 05f4591..3447969 100644 --- a/business/tasks.py +++ b/business/tasks.py @@ -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 + diff --git a/business/tests.py b/business/tests.py index 15bc0a6..680de31 100644 --- a/business/tests.py +++ b/business/tests.py @@ -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) diff --git a/docs/purchase_order_approval_and_red_flush.md b/docs/purchase_order_approval_and_red_flush.md index ce2912d..8a9dcc5 100644 --- a/docs/purchase_order_approval_and_red_flush.md +++ b/docs/purchase_order_approval_and_red_flush.md @@ -4,7 +4,7 @@ - 采购单在创建时默认进入 `PENDING`(审批中)状态,不再立即创建出入库记录。 - `business.services.review_purchase_order` 统一处理审批通过 (`APPROVED`) 与作废 (`CANCELLED`) 的业务规则。 -- `api_v1/views/purchase_order.py` 暴露 `POST /api/v1/purchase-orders//review/` 接口作为唯一入口,便于前端、自动化流程和第三方系统统一调用。 +- `api_v1/business/purchase/views.py` 暴露 `POST /api/v1/purchase-orders//review/` 接口作为唯一入口,便于前端、自动化流程和第三方系统统一调用。 ## 2. 审批流程总览 diff --git a/docs/sales_order_approval_and_red_flush.md b/docs/sales_order_approval_and_red_flush.md new file mode 100644 index 0000000..29e9a96 --- /dev/null +++ b/docs/sales_order_approval_and_red_flush.md @@ -0,0 +1,67 @@ +# 销售单审批流程与红冲规划 + +## 1. 背景 + +- 销售单在创建时同样进入 `PENDING` 状态,审批通过后才会触发出库流程。 +- `business.services.review_sales_order` 统一处理审批通过 (`APPROVED`) 与作废 (`CANCELLED`) 的业务规则。 +- `api_v1/business/sales/views.py` 暴露 `POST /api/v1/sales-orders//review/` 接口,供前端、自动化和第三方系统一致调用。 + +## 2. 审批流程总览 + +| 步骤 | 说明 | +|------|------| +| 1 | 前端调用 `POST /api/v1/sales-orders//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//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`,保证审计可追溯。 +- 红冲需与业务对象绑定,避免孤立库存操作。 + +文档与测试应在红冲能力落地时同步更新,确保审批、出库、红冲形成闭环。 +