1
0
forked from erp-dev/erp

feat: support pre_sales_order convert to sales_order api

This commit is contained in:
2026-02-02 15:58:29 +08:00
parent 2572c5aab4
commit fb93582b06
13 changed files with 379 additions and 7 deletions

View File

@@ -136,6 +136,8 @@ class PreSalesAllocationAPITestCase(TestCase):
) )
self.assertEqual(delete_resp.status_code, status.HTTP_200_OK) self.assertEqual(delete_resp.status_code, status.HTTP_200_OK)
self.assertEqual(delete_resp.data['status'], business_models.AllocationRecordStatusEnum.CANCELLED) 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): def test_create_allocation_with_invalid_stock(self):
order = self._create_order() order = self._create_order()

View File

@@ -18,6 +18,8 @@ from basic_info.models import (
EmployeeStatusEnum, EmployeeStatusEnum,
) )
from business import models as business_models from business import models as business_models
from business import allocation_services
from stock import models as stock_models
@override_settings( @override_settings(
@@ -37,6 +39,11 @@ class PreSalesOrderAPITestCase(TestCase):
name='预销售仓库', name='预销售仓库',
mode=WareHouseModeEnum.UNRESTRICTED, mode=WareHouseModeEnum.UNRESTRICTED,
) )
self.warehouse_strict_out = WareHouse.objects.create(
merchant=self.merchant,
name='预销售严出仓',
mode=WareHouseModeEnum.RESTRICT_IN_OUT,
)
category = ProductCategory.objects.create( category = ProductCategory.objects.create(
merchant=self.merchant, merchant=self.merchant,
name='预销售品类', 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: def _create(self, payload=None) -> int:
resp = self.client.post('/api/v1/pre-sales-orders/', payload or self.payload, format='json') resp = self.client.post('/api/v1/pre-sales-orders/', payload or self.payload, format='json')
self.assertEqual(resp.status_code, status.HTTP_201_CREATED) 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}/') resp = self.client.delete(f'/api/v1/pre-sales-orders/{order_id}/')
self.assertEqual(resp.status_code, status.HTTP_204_NO_CONTENT) self.assertEqual(resp.status_code, status.HTTP_204_NO_CONTENT)
self.assertFalse(business_models.PreSalesOrder.objects.filter(id=order_id).exists()) 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)

View File

@@ -91,6 +91,11 @@ urlpatterns = [
path('sales-return-orders/<int:pk>/review/', sales_return_views.SalesReturnOrderReviewView.as_view(), name='sales_return_order_review'), path('sales-return-orders/<int:pk>/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.PreSalesOrderView.as_view(), name='pre_sales_orders'),
path('pre-sales-orders/<int:pk>/', pre_sales_views.PreSalesOrderDetailView.as_view(), name='pre_sales_order_detail'), path('pre-sales-orders/<int:pk>/', pre_sales_views.PreSalesOrderDetailView.as_view(), name='pre_sales_order_detail'),
path(
'pre-sales-orders/<int:pk>/convert-to-sales/',
pre_sales_views.PreSalesOrderConvertToSalesOrderView.as_view(),
name='pre_sales_order_convert_to_sales',
),
path( path(
'pre-sales-order-items/<int:item_id>/allocations/', 'pre-sales-order-items/<int:item_id>/allocations/',
pre_sales_views.PreSalesOrderItemAllocationView.as_view(), pre_sales_views.PreSalesOrderItemAllocationView.as_view(),

View File

@@ -279,6 +279,41 @@ class PreSalesOrderDetailView(StockChangeViewMixin, views.APIView):
return Response(status=status.HTTP_204_NO_CONTENT) 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): class PreSalesOrderItemAllocationView(StockChangeViewMixin, views.APIView):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
@@ -336,5 +371,8 @@ class PreSalesOrderItemAllocationDetailView(StockChangeViewMixin, views.APIView)
except business_models.AllocationRecord.DoesNotExist: except business_models.AllocationRecord.DoesNotExist:
return self.not_found_response('配货记录不存在') 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) return Response(AllocationRecordSerializer(updated).data, status=status.HTTP_200_OK)

View File

@@ -4,6 +4,7 @@ from decimal import Decimal
from typing import Iterable, List from typing import Iterable, List
from django.db import transaction from django.db import transaction
from django.utils import timezone
from stock import models as stock_models from stock import models as stock_models
from . import models from . import models
@@ -133,8 +134,28 @@ def create_allocation_record(
def cancel_allocation_record(*, record: models.AllocationRecord) -> models.AllocationRecord: def cancel_allocation_record(*, record: models.AllocationRecord) -> models.AllocationRecord:
if record.status == models.AllocationRecordStatusEnum.CANCELLED: return cancel_allocation_record_with_unfreeze(record=record)
return record
record.status = models.AllocationRecordStatusEnum.CANCELLED
record.save(update_fields=['status', 'updated_at']) 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 return record

View File

@@ -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'),
),
]

View File

@@ -273,6 +273,11 @@ class SalesOrder(OrderItemsAggregationMixin, OrderDirectionMixin, OrderCounterpa
verbose_name='状态', verbose_name='状态',
) )
remarks = models.TextField(blank=True, null=True, 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): def __str__(self):
order_id = self.human_id or str(self.id) order_id = self.human_id or str(self.id)

View File

@@ -11,6 +11,7 @@ from django.utils import timezone
from basic_info import models as basic_info_models from basic_info import models as basic_info_models
from . import models from . import models
from . import services as business_services
def render_pre_sales_order_created_markdown( 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() 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): def list_pre_purchase_orders(*, merchant: basic_info_models.Merchant):
return models.PrePurchaseOrder.objects.filter(merchant=merchant).select_related( return models.PrePurchaseOrder.objects.filter(merchant=merchant).select_related(
'merchant', 'supplier', 'warehouse', 'created_by' 'merchant', 'supplier', 'warehouse', 'created_by'

View File

@@ -310,6 +310,7 @@ def create_sales_order(
items: List[Dict[str, Any]], items: List[Dict[str, Any]],
remarks: str | None = '', remarks: str | None = '',
created_by=None, created_by=None,
from_pre_sales_order_id: int | None = None,
) -> models.SalesOrder: ) -> models.SalesOrder:
""" """
创建销售订单,后续审批通过后会触发出库任务。 创建销售订单,后续审批通过后会触发出库任务。
@@ -334,6 +335,7 @@ def create_sales_order(
operator=operator, operator=operator,
warehouse=warehouse, warehouse=warehouse,
remarks=remarks, remarks=remarks,
from_pre_sales_order_id=from_pre_sales_order_id,
) )
bulk_objects = [ bulk_objects = [
models.SalesOrderItem( models.SalesOrderItem(

View File

@@ -181,5 +181,11 @@ class AllocationServicesTestCase(TestCase):
unit='', unit='',
scanned_by=self.employee, 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) 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)

View File

@@ -6,6 +6,8 @@ from django.db import transaction
from basic_info import models as basic_models from basic_info import models as basic_models
from business import models as business_models from business import models as business_models
from business import pre_order_services from business import pre_order_services
from business import allocation_services
from stock import models as stock_models
from .fixtures import create_sales_fixtures 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) pre_order_services.delete_pre_sales_order(pre_sales_order=pre_sales_order)
self.assertFalse(business_models.PreSalesOrder.objects.filter(id=order_id).exists()) 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): class PreSalesOrderSignalTestCase(TransactionTestCase):
def setUp(self): def setUp(self):

View File

@@ -135,6 +135,73 @@
- `DELETE /api/v1/pre-sales-orders/{id}/` - `DELETE /api/v1/pre-sales-orders/{id}/`
- 响应:`204 NO CONTENT` - 响应:`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[]` 与详情一致): 预销售单对象字段(`results[]` 与详情一致):

View File

@@ -10,13 +10,16 @@ This module is intended for reusable business logic that may be called by:
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING
from urllib.parse import urlparse from urllib.parse import urlparse
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
from api_v1.utils.wecom_webhook import send_wecom_webhook_message 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) @dataclass(frozen=True)
@@ -188,6 +191,7 @@ def send_printing_job_latest_state_wecom(
""" """
Reusable entrypoint for command / future API. Reusable entrypoint for command / future API.
""" """
from printing.models import PrintingJob
job = ( job = (
PrintingJob.objects.select_related("printing_order", "business_object") PrintingJob.objects.select_related("printing_order", "business_object")
.filter(id=int(printing_job_id)) .filter(id=int(printing_job_id))