diff --git a/api_v1/test_pre_sales_allocation_api.py b/api_v1/test_pre_sales_allocation_api.py index 4481902..3cae02f 100644 --- a/api_v1/test_pre_sales_allocation_api.py +++ b/api_v1/test_pre_sales_allocation_api.py @@ -136,6 +136,8 @@ class PreSalesAllocationAPITestCase(TestCase): ) self.assertEqual(delete_resp.status_code, status.HTTP_200_OK) self.assertEqual(delete_resp.data['status'], business_models.AllocationRecordStatusEnum.CANCELLED) + freeze = stock_models.StockFreeze.objects.get(stock_detail=self.stock_detail) + self.assertEqual(freeze.status, stock_models.StockFreezeStatusEnum.CANCELLED) def test_create_allocation_with_invalid_stock(self): order = self._create_order() diff --git a/api_v1/test_pre_sales_order_api.py b/api_v1/test_pre_sales_order_api.py index eb9d46b..d9e6293 100644 --- a/api_v1/test_pre_sales_order_api.py +++ b/api_v1/test_pre_sales_order_api.py @@ -18,6 +18,8 @@ from basic_info.models import ( EmployeeStatusEnum, ) from business import models as business_models +from business import allocation_services +from stock import models as stock_models @override_settings( @@ -37,6 +39,11 @@ class PreSalesOrderAPITestCase(TestCase): 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='预销售品类', @@ -78,6 +85,20 @@ class PreSalesOrderAPITestCase(TestCase): ], } + self.strict_payload = { + 'customer': self.customer.id, + 'warehouse': self.warehouse_strict_out.id, + 'kind': business_models.SalesOrderKindEnum.WHOLESALE, + 'remarks': '预销售单备注', + 'items': [ + { + 'product_id': self.product.id, + 'quantity': '10', + 'unit': '米', + } + ], + } + def _create(self, payload=None) -> int: resp = self.client.post('/api/v1/pre-sales-orders/', payload or self.payload, format='json') self.assertEqual(resp.status_code, status.HTTP_201_CREATED) @@ -121,3 +142,50 @@ class PreSalesOrderAPITestCase(TestCase): resp = self.client.delete(f'/api/v1/pre-sales-orders/{order_id}/') self.assertEqual(resp.status_code, status.HTTP_204_NO_CONTENT) self.assertFalse(business_models.PreSalesOrder.objects.filter(id=order_id).exists()) + + def test_convert_to_sales_order_rejects_non_strict_out(self): + order_id = self._create() + resp = self.client.post(f'/api/v1/pre-sales-orders/{order_id}/convert-to-sales/', {}, format='json') + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) + self.assertIn('仅支持严进严出仓库模式', resp.data.get('error', '')) + + def test_convert_to_sales_order_success(self): + resp = self.client.post('/api/v1/pre-sales-orders/', self.strict_payload, format='json') + self.assertEqual(resp.status_code, status.HTTP_201_CREATED) + order_id = resp.data['id'] + + order = business_models.PreSalesOrder.objects.get(id=order_id) + item = order.items.first() + + stock_record = stock_models.StockChangeRecord.objects.create( + merchant=self.merchant, + type=stock_models.StockChangeTypeEnum.ADD, + warehouse=self.warehouse_strict_out, + source_type=stock_models.StockChangeSourceEnum.PURCHASE, + created_by=self.user, + ) + stock_detail = stock_models.StockChangeDetail.objects.create( + merchant=self.merchant, + product=self.product, + unit=ProductUnitEnum.METER, + stock_change_record=stock_record, + quantity='10', + ) + allocation_services.create_allocation_record( + item=item, + stock_ids=[stock_detail.id], + quantity='10', + unit='米', + scanned_by=self.employee, + ) + + convert_resp = self.client.post( + f'/api/v1/pre-sales-orders/{order_id}/convert-to-sales/', + {}, + format='json', + ) + self.assertEqual(convert_resp.status_code, status.HTTP_201_CREATED) + self.assertIn('id', convert_resp.data) + self.assertEqual(convert_resp.data['status'], business_models.SalesOrderStatusEnum.PENDING) + sales_order = business_models.SalesOrder.objects.get(id=convert_resp.data['id']) + self.assertEqual(sales_order.from_pre_sales_order_id, order_id) diff --git a/api_v1/urls.py b/api_v1/urls.py index 7494dd6..a45dfe1 100644 --- a/api_v1/urls.py +++ b/api_v1/urls.py @@ -91,6 +91,11 @@ urlpatterns = [ path('sales-return-orders//review/', sales_return_views.SalesReturnOrderReviewView.as_view(), name='sales_return_order_review'), path('pre-sales-orders/', pre_sales_views.PreSalesOrderView.as_view(), name='pre_sales_orders'), path('pre-sales-orders//', pre_sales_views.PreSalesOrderDetailView.as_view(), name='pre_sales_order_detail'), + path( + 'pre-sales-orders//convert-to-sales/', + pre_sales_views.PreSalesOrderConvertToSalesOrderView.as_view(), + name='pre_sales_order_convert_to_sales', + ), path( 'pre-sales-order-items//allocations/', pre_sales_views.PreSalesOrderItemAllocationView.as_view(), diff --git a/api_v1/views/business/pre_sales/views.py b/api_v1/views/business/pre_sales/views.py index 35f5c77..535e5de 100644 --- a/api_v1/views/business/pre_sales/views.py +++ b/api_v1/views/business/pre_sales/views.py @@ -279,6 +279,41 @@ class PreSalesOrderDetailView(StockChangeViewMixin, views.APIView): return Response(status=status.HTTP_204_NO_CONTENT) +class PreSalesOrderConvertToSalesOrderView(StockChangeViewMixin, views.APIView): + permission_classes = [IsAuthenticated] + + def post(self, request, pk: int): + if not self.check_employee_permission(request): + return self.permission_error_response('无权限访问') + + merchant = request.user.employee.merchant + employee = request.user.employee + + try: + sales_order = pre_order_services.convert_pre_sales_order_to_sales_order( + merchant=merchant, + pre_sales_order_id=pk, + operator=employee, + created_by=request.user, + ) + except PermissionError as exc: + return Response({'error': str(exc)}, status=status.HTTP_403_FORBIDDEN) + except business_models.PreSalesOrder.DoesNotExist: + return self.not_found_response('预销售单不存在') + except ValueError as exc: + return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST) + + return Response( + { + 'id': sales_order.id, + 'human_id': sales_order.human_id, + 'status': sales_order.status, + 'message': '预销售单已转换为销售单,等待审批', + }, + status=status.HTTP_201_CREATED, + ) + + class PreSalesOrderItemAllocationView(StockChangeViewMixin, views.APIView): permission_classes = [IsAuthenticated] @@ -336,5 +371,8 @@ class PreSalesOrderItemAllocationDetailView(StockChangeViewMixin, views.APIView) except business_models.AllocationRecord.DoesNotExist: return self.not_found_response('配货记录不存在') - updated = allocation_services.cancel_allocation_record(record=record) + updated = allocation_services.cancel_allocation_record_with_unfreeze( + record=record, + cancelled_by=request.user, + ) return Response(AllocationRecordSerializer(updated).data, status=status.HTTP_200_OK) diff --git a/business/allocation_services.py b/business/allocation_services.py index 7ce4be1..8716a34 100644 --- a/business/allocation_services.py +++ b/business/allocation_services.py @@ -4,6 +4,7 @@ from decimal import Decimal from typing import Iterable, List from django.db import transaction +from django.utils import timezone from stock import models as stock_models from . import models @@ -133,8 +134,28 @@ def create_allocation_record( def cancel_allocation_record(*, record: models.AllocationRecord) -> models.AllocationRecord: - if record.status == models.AllocationRecordStatusEnum.CANCELLED: - return record - record.status = models.AllocationRecordStatusEnum.CANCELLED - record.save(update_fields=['status', 'updated_at']) + return cancel_allocation_record_with_unfreeze(record=record) + + +def cancel_allocation_record_with_unfreeze( + *, + record: models.AllocationRecord, + cancelled_by=None, + reason: str | None = None, +) -> models.AllocationRecord: + with transaction.atomic(): + if record.status != models.AllocationRecordStatusEnum.CANCELLED: + record.status = models.AllocationRecordStatusEnum.CANCELLED + record.save(update_fields=['status', 'updated_at']) + + stock_models.StockFreeze.objects.filter( + frozen_with=record.id, + status=stock_models.StockFreezeStatusEnum.FROZEN, + ).update( + status=stock_models.StockFreezeStatusEnum.CANCELLED, + cancelled_by=cancelled_by, + cancelled_at=timezone.now(), + reason=reason, + ) + return record diff --git a/business/migrations/0026_salesorder_from_pre_sales_order.py b/business/migrations/0026_salesorder_from_pre_sales_order.py new file mode 100644 index 0000000..4a52870 --- /dev/null +++ b/business/migrations/0026_salesorder_from_pre_sales_order.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('business', '0025_allocationrecord'), + ] + + operations = [ + migrations.AddField( + model_name='salesorder', + name='from_pre_sales_order_id', + field=models.BigIntegerField(blank=True, null=True, verbose_name='来源预销售单ID'), + ), + ] diff --git a/business/models.py b/business/models.py index 634af03..8eb18a5 100644 --- a/business/models.py +++ b/business/models.py @@ -273,6 +273,11 @@ class SalesOrder(OrderItemsAggregationMixin, OrderDirectionMixin, OrderCounterpa verbose_name='状态', ) remarks = models.TextField(blank=True, null=True, verbose_name='备注') + from_pre_sales_order_id = models.BigIntegerField( + blank=True, + null=True, + verbose_name='来源预销售单ID', + ) def __str__(self): order_id = self.human_id or str(self.id) diff --git a/business/pre_order_services.py b/business/pre_order_services.py index ff5c571..0ce7564 100644 --- a/business/pre_order_services.py +++ b/business/pre_order_services.py @@ -11,6 +11,7 @@ from django.utils import timezone from basic_info import models as basic_info_models from . import models +from . import services as business_services def render_pre_sales_order_created_markdown( @@ -398,6 +399,73 @@ def delete_pre_sales_order(*, pre_sales_order: models.PreSalesOrder) -> None: pre_sales_order.delete() +def convert_pre_sales_order_to_sales_order( + *, + merchant: basic_info_models.Merchant, + pre_sales_order_id: int, + operator: basic_info_models.Employee, + created_by=None, + order_date=None, +) -> models.SalesOrder: + order = models.PreSalesOrder.objects.select_related( + 'customer', + 'warehouse', + 'operator', + 'created_by', + ).prefetch_related('items', 'items__allocation_records').get( + id=pre_sales_order_id, + merchant=merchant, + ) + + warehouse = order.warehouse + if warehouse.mode != basic_info_models.WareHouseModeEnum.RESTRICT_IN_OUT: + raise PermissionError('仅支持严进严出仓库模式') + + items_payload: List[Dict[str, Any]] = [] + for item in order.items.all(): + if not item.product_id: + raise ValueError(f'明细 {item.id} 缺少 product_id') + if item.quantity is None or str(item.quantity) == '': + raise ValueError(f'明细 {item.id} 缺少 quantity') + + consume_ids: List[int] = [] + for record in item.allocation_records.all(): + if record.status != models.AllocationRecordStatusEnum.ACTIVE: + continue + consume_ids.extend(record.stock_id_list) + + if not consume_ids: + raise ValueError(f'明细 {item.id} 缺少配货库存明细') + + items_payload.append( + { + 'product_id': item.product_id, + 'price': '0', + 'quantity': str(item.quantity), + 'unit': item.unit or None, + 'color': item.color, + 'spec': item.spec, + 'remarks': item.remarks, + 'order_quantity': item.order_quantity, + 'consume_detail_ids': consume_ids, + } + ) + + resolved_order_date = order_date or (order.created_at.date() if order.created_at else timezone.localdate()) + + return business_services.create_sales_order( + merchant=merchant, + customer=order.customer, + order_date=resolved_order_date, + warehouse=warehouse, + operator=operator, + items=items_payload, + remarks=order.remarks or '', + created_by=created_by, + from_pre_sales_order_id=order.id, + ) + + def list_pre_purchase_orders(*, merchant: basic_info_models.Merchant): return models.PrePurchaseOrder.objects.filter(merchant=merchant).select_related( 'merchant', 'supplier', 'warehouse', 'created_by' diff --git a/business/services.py b/business/services.py index 78b181f..dc97f95 100644 --- a/business/services.py +++ b/business/services.py @@ -310,6 +310,7 @@ def create_sales_order( items: List[Dict[str, Any]], remarks: str | None = '', created_by=None, + from_pre_sales_order_id: int | None = None, ) -> models.SalesOrder: """ 创建销售订单,后续审批通过后会触发出库任务。 @@ -334,6 +335,7 @@ def create_sales_order( operator=operator, warehouse=warehouse, remarks=remarks, + from_pre_sales_order_id=from_pre_sales_order_id, ) bulk_objects = [ models.SalesOrderItem( diff --git a/business/tests/test_allocation_services.py b/business/tests/test_allocation_services.py index 0f8a11d..ca26e50 100644 --- a/business/tests/test_allocation_services.py +++ b/business/tests/test_allocation_services.py @@ -181,5 +181,11 @@ class AllocationServicesTestCase(TestCase): unit='米', scanned_by=self.employee, ) - updated = allocation_services.cancel_allocation_record(record=record) + updated = allocation_services.cancel_allocation_record_with_unfreeze( + record=record, + cancelled_by=self.user, + reason='测试撤销', + ) self.assertEqual(updated.status, business_models.AllocationRecordStatusEnum.CANCELLED) + freeze = stock_models.StockFreeze.objects.get(stock_detail=self.stock_detail) + self.assertEqual(freeze.status, stock_models.StockFreezeStatusEnum.CANCELLED) diff --git a/business/tests/test_pre_sales_order.py b/business/tests/test_pre_sales_order.py index c6fabb6..931a070 100644 --- a/business/tests/test_pre_sales_order.py +++ b/business/tests/test_pre_sales_order.py @@ -6,6 +6,8 @@ from django.db import transaction from basic_info import models as basic_models from business import models as business_models from business import pre_order_services +from business import allocation_services +from stock import models as stock_models from .fixtures import create_sales_fixtures @@ -123,6 +125,74 @@ class PreSalesOrderServiceTestCase(TestCase): pre_order_services.delete_pre_sales_order(pre_sales_order=pre_sales_order) self.assertFalse(business_models.PreSalesOrder.objects.filter(id=order_id).exists()) + def test_convert_pre_sales_order_to_sales_order_rejects_non_strict_out(self): + pre_sales_order = pre_order_services.create_pre_sales_order( + merchant=self.merchant, + customer_id=self.customer.id, + warehouse_id=self.warehouse_relaxed.id, + created_by=self.user, + operator=self.operator, + items=[{'product_id': self.product.id, 'quantity': '10', 'unit': '米'}], + ) + + with self.assertRaises(PermissionError): + pre_order_services.convert_pre_sales_order_to_sales_order( + merchant=self.merchant, + pre_sales_order_id=pre_sales_order.id, + operator=self.operator, + created_by=self.user, + ) + + def test_convert_pre_sales_order_to_sales_order_success(self): + pre_sales_order = pre_order_services.create_pre_sales_order( + merchant=self.merchant, + customer_id=self.customer.id, + warehouse_id=self.warehouse_strict_out.id, + created_by=self.user, + operator=self.operator, + items=[{'product_id': self.product.id, 'quantity': '10', 'unit': '米'}], + ) + item = pre_sales_order.items.first() + + stock_record = stock_models.StockChangeRecord.objects.create( + merchant=self.merchant, + type=stock_models.StockChangeTypeEnum.ADD, + warehouse=self.warehouse_strict_out, + source_type=stock_models.StockChangeSourceEnum.PURCHASE, + created_by=self.user, + ) + stock_detail = stock_models.StockChangeDetail.objects.create( + merchant=self.merchant, + product=self.product, + unit=basic_models.ProductUnitEnum.METER, + stock_change_record=stock_record, + quantity='10', + ) + + allocation_services.create_allocation_record( + item=item, + stock_ids=[stock_detail.id], + quantity='10', + unit='米', + scanned_by=self.operator, + ) + + sales_order = pre_order_services.convert_pre_sales_order_to_sales_order( + merchant=self.merchant, + pre_sales_order_id=pre_sales_order.id, + operator=self.operator, + created_by=self.user, + ) + + self.assertEqual(sales_order.customer_id, self.customer.id) + self.assertEqual(sales_order.warehouse_id, self.warehouse_strict_out.id) + self.assertEqual(sales_order.from_pre_sales_order_id, pre_sales_order.id) + self.assertEqual(sales_order.status, business_models.SalesOrderStatusEnum.PENDING) + self.assertEqual(sales_order.items.count(), 1) + sales_item = sales_order.items.first() + self.assertEqual(sales_item.product_id, self.product.id) + self.assertEqual(sales_item.consume_detail_ids, str(stock_detail.id)) + class PreSalesOrderSignalTestCase(TransactionTestCase): def setUp(self): diff --git a/docs/api_v1_pre_order_api.md b/docs/api_v1_pre_order_api.md index b9e7339..1e3d962 100644 --- a/docs/api_v1_pre_order_api.md +++ b/docs/api_v1_pre_order_api.md @@ -135,6 +135,73 @@ - `DELETE /api/v1/pre-sales-orders/{id}/` - 响应:`204 NO CONTENT` +## 6) 转换为销售单(仅严进严出仓库) + +- `POST /api/v1/pre-sales-orders/{id}/convert-to-sales/` + +约束: + +- 仅支持仓库模式为 **严进严出(RESTRICT_IN_OUT)** 的预销售单 +- 若不是严进严出,返回 `403` +- 需要已存在有效配货记录(用于生成销售单明细的 `consume_detail_ids`) + +响应:`201 CREATED` + +```json +{ + "id": 1001, + "human_id": "XS20260202000001", + "status": 1, + "message": "预销售单已转换为销售单,等待审批" +} +``` + +错误示例(403): + +```json +{ + "error": "仅支持严进严出仓库模式" +} +``` + +错误示例(400): + +```json +{ + "error": "明细 123 缺少配货库存明细" +} + +## 7) 配货记录(Allocations) + +### 7.1 创建配货记录 + +- `POST /api/v1/pre-sales-order-items/{item_id}/allocations/` + +请求体参数(JSON): + +```json +{ + "stock_ids": [1, 2, 3], + "quantity": "10.00", + "unit": "KG", + "remarks": "备注信息" +} +``` + +响应:`201 CREATED`,返回配货记录详情。 + +### 7.2 撤销配货记录(含解冻库存) + +- `DELETE /api/v1/pre-sales-order-items/{item_id}/allocations/{pk}/` + +行为说明: + +- 将配货记录状态置为撤销(status=2) +- **同时解冻**该配货记录创建时冻结的库存(StockFreeze.status=取消) + +响应:`200 OK`,返回配货记录详情。 +``` + ## 预销售单字段说明(响应) 预销售单对象字段(`results[]` 与详情一致): diff --git a/printing/services.py b/printing/services.py index c907e87..28cfb34 100644 --- a/printing/services.py +++ b/printing/services.py @@ -10,13 +10,16 @@ This module is intended for reusable business logic that may be called by: from __future__ import annotations from dataclasses import dataclass +from typing import TYPE_CHECKING from urllib.parse import urlparse from django.conf import settings from django.utils import timezone from api_v1.utils.wecom_webhook import send_wecom_webhook_message -from printing.models import PrintingJob + +if TYPE_CHECKING: + from printing.models import PrintingJob @dataclass(frozen=True) @@ -188,6 +191,7 @@ def send_printing_job_latest_state_wecom( """ Reusable entrypoint for command / future API. """ + from printing.models import PrintingJob job = ( PrintingJob.objects.select_related("printing_order", "business_object") .filter(id=int(printing_job_id))