forked from erp-dev/erp
feat: allocation record for pre sales order
This commit is contained in:
154
api_v1/test_pre_sales_allocation_api.py
Normal file
154
api_v1/test_pre_sales_allocation_api.py
Normal file
@@ -0,0 +1,154 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase, override_settings
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from basic_info.models import (
|
||||
Merchant,
|
||||
MerchantTypeEnum,
|
||||
Customer,
|
||||
WareHouse,
|
||||
WareHouseModeEnum,
|
||||
ProductCategory,
|
||||
Product,
|
||||
ProductUnitEnum,
|
||||
Employee,
|
||||
EmployeeStatusEnum,
|
||||
)
|
||||
from business import models as business_models
|
||||
from stock import models as stock_models
|
||||
|
||||
|
||||
@override_settings(
|
||||
CELERY_TASK_ALWAYS_EAGER=True,
|
||||
CELERY_TASK_EAGER_PROPAGATES=True,
|
||||
)
|
||||
class PreSalesAllocationAPITestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.merchant = Merchant.objects.create(name='配货商户', type=MerchantTypeEnum.FACTORY)
|
||||
self.customer = Customer.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='配货客户',
|
||||
created_by=None,
|
||||
)
|
||||
self.warehouse = WareHouse.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='配货仓库',
|
||||
mode=WareHouseModeEnum.UNRESTRICTED,
|
||||
)
|
||||
category = ProductCategory.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='配货品类',
|
||||
product_prefix='ALC',
|
||||
)
|
||||
self.product = Product.objects.create(
|
||||
merchant=self.merchant,
|
||||
category=category,
|
||||
name='配货产品',
|
||||
human_id='ALC-001',
|
||||
unit=ProductUnitEnum.METER,
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(username='allocation_user', password='pass123')
|
||||
self.employee = Employee.objects.create(
|
||||
merchant=self.merchant,
|
||||
sys_user=self.user,
|
||||
name='配货员',
|
||||
status=EmployeeStatusEnum.ACTIVE,
|
||||
)
|
||||
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
self.order_payload = {
|
||||
'customer': self.customer.id,
|
||||
'warehouse': self.warehouse.id,
|
||||
'kind': business_models.SalesOrderKindEnum.WHOLESALE,
|
||||
'remarks': '预销售单备注',
|
||||
'items': [
|
||||
{
|
||||
'product_id': self.product.id,
|
||||
'quantity': '12.5',
|
||||
'unit': '米',
|
||||
'order_quantity': 7,
|
||||
'remarks': '明细备注',
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
self.stock_record = stock_models.StockChangeRecord.objects.create(
|
||||
merchant=self.merchant,
|
||||
type=stock_models.StockChangeTypeEnum.ADD,
|
||||
warehouse=self.warehouse,
|
||||
source_type=stock_models.StockChangeSourceEnum.PURCHASE,
|
||||
created_by=self.user,
|
||||
)
|
||||
self.stock_detail = stock_models.StockChangeDetail.objects.create(
|
||||
merchant=self.merchant,
|
||||
product=self.product,
|
||||
unit=ProductUnitEnum.METER,
|
||||
stock_change_record=self.stock_record,
|
||||
quantity='12.5',
|
||||
)
|
||||
|
||||
def _create_order(self):
|
||||
resp = self.client.post('/api/v1/pre-sales-orders/', self.order_payload, format='json')
|
||||
self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
|
||||
return resp.data
|
||||
|
||||
def test_create_allocation_record(self):
|
||||
order = self._create_order()
|
||||
item_id = order['items'][0]['id']
|
||||
|
||||
payload = {
|
||||
'stock_ids': [self.stock_detail.id],
|
||||
'quantity': '5.5',
|
||||
'unit': '米',
|
||||
}
|
||||
resp = self.client.post(
|
||||
f'/api/v1/pre-sales-order-items/{item_id}/allocations/',
|
||||
payload,
|
||||
format='json',
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(resp.data['stock_ids'], [self.stock_detail.id])
|
||||
self.assertEqual(str(resp.data['quantity']), '5.50')
|
||||
|
||||
def test_cancel_allocation_record(self):
|
||||
order = self._create_order()
|
||||
item_id = order['items'][0]['id']
|
||||
|
||||
create_resp = self.client.post(
|
||||
f'/api/v1/pre-sales-order-items/{item_id}/allocations/',
|
||||
{
|
||||
'stock_ids': [self.stock_detail.id],
|
||||
'quantity': '5.5',
|
||||
'unit': '米',
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
self.assertEqual(create_resp.status_code, status.HTTP_201_CREATED)
|
||||
record_id = create_resp.data['id']
|
||||
|
||||
delete_resp = self.client.delete(
|
||||
f'/api/v1/pre-sales-order-items/{item_id}/allocations/{record_id}/'
|
||||
)
|
||||
self.assertEqual(delete_resp.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(delete_resp.data['status'], business_models.AllocationRecordStatusEnum.CANCELLED)
|
||||
|
||||
def test_create_allocation_with_invalid_stock(self):
|
||||
order = self._create_order()
|
||||
item_id = order['items'][0]['id']
|
||||
|
||||
resp = self.client.post(
|
||||
f'/api/v1/pre-sales-order-items/{item_id}/allocations/',
|
||||
{
|
||||
'stock_ids': [999999],
|
||||
'quantity': '5.5',
|
||||
'unit': '米',
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn('库存明细不存在', resp.data['error'])
|
||||
@@ -91,6 +91,16 @@ urlpatterns = [
|
||||
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/<int:pk>/', pre_sales_views.PreSalesOrderDetailView.as_view(), name='pre_sales_order_detail'),
|
||||
path(
|
||||
'pre-sales-order-items/<int:item_id>/allocations/',
|
||||
pre_sales_views.PreSalesOrderItemAllocationView.as_view(),
|
||||
name='pre_sales_order_item_allocations',
|
||||
),
|
||||
path(
|
||||
'pre-sales-order-items/<int:item_id>/allocations/<int:pk>/',
|
||||
pre_sales_views.PreSalesOrderItemAllocationDetailView.as_view(),
|
||||
name='pre_sales_order_item_allocation_detail',
|
||||
),
|
||||
path('pre-purchase-orders/', pre_purchase_views.PrePurchaseOrderView.as_view(), name='pre_purchase_orders'),
|
||||
path('pre-purchase-orders/<int:pk>/', pre_purchase_views.PrePurchaseOrderDetailView.as_view(), name='pre_purchase_order_detail'),
|
||||
path('payment-orders/', payment_views.PaymentOrderView.as_view(), name='payment_orders'),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from rest_framework import status, views, serializers, pagination
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from django.utils import dateparse, timezone
|
||||
|
||||
from api_v1.views.stock_change_views.mixins import StockChangeViewMixin
|
||||
from business import models as business_models
|
||||
@@ -18,22 +19,15 @@ class PrePurchaseOrderItemSerializer(serializers.ModelSerializer):
|
||||
'quantity',
|
||||
'unit',
|
||||
'spec',
|
||||
'quantity_of_rolls',
|
||||
'num_of_rolls',
|
||||
'order_quantity',
|
||||
'remarks',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
read_only_fields = ['id']
|
||||
|
||||
|
||||
class PrePurchaseOrderSerializer(serializers.ModelSerializer):
|
||||
human_id = serializers.CharField(read_only=True)
|
||||
merchant_name = serializers.CharField(source='merchant.name', read_only=True)
|
||||
supplier_name = serializers.CharField(source='supplier.name', read_only=True)
|
||||
warehouse_name = serializers.CharField(source='warehouse.name', read_only=True)
|
||||
created_by_username = serializers.CharField(source='created_by.username', read_only=True)
|
||||
operator_name = serializers.CharField(source='operator.name', read_only=True)
|
||||
items = PrePurchaseOrderItemSerializer(many=True, read_only=True)
|
||||
|
||||
@@ -43,30 +37,23 @@ class PrePurchaseOrderSerializer(serializers.ModelSerializer):
|
||||
'id',
|
||||
'human_id',
|
||||
'merchant',
|
||||
'merchant_name',
|
||||
'supplier',
|
||||
'supplier_name',
|
||||
'warehouse',
|
||||
'warehouse_name',
|
||||
'created_by',
|
||||
'created_by_username',
|
||||
'operator',
|
||||
'operator_name',
|
||||
'kind',
|
||||
'remarks',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'items',
|
||||
]
|
||||
read_only_fields = [
|
||||
'id',
|
||||
'human_id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'merchant_name',
|
||||
'supplier_name',
|
||||
'warehouse_name',
|
||||
'created_by_username',
|
||||
'operator_name',
|
||||
'items',
|
||||
]
|
||||
@@ -90,6 +77,49 @@ class PrePurchaseOrderView(StockChangeViewMixin, views.APIView):
|
||||
merchant = request.user.employee.merchant
|
||||
queryset = pre_order_services.list_pre_purchase_orders(merchant=merchant)
|
||||
|
||||
supplier_id = request.query_params.get('supplier') or request.query_params.get('supplier_id')
|
||||
warehouse_id = request.query_params.get('warehouse') or request.query_params.get('warehouse_id')
|
||||
kind = request.query_params.get('kind')
|
||||
human_id_contains = request.query_params.get('human_id__icontains')
|
||||
created_from = request.query_params.get('created_at_from')
|
||||
created_to = request.query_params.get('created_at_to')
|
||||
|
||||
try:
|
||||
if supplier_id:
|
||||
queryset = queryset.filter(supplier_id=int(supplier_id))
|
||||
if warehouse_id:
|
||||
queryset = queryset.filter(warehouse_id=int(warehouse_id))
|
||||
if kind:
|
||||
queryset = queryset.filter(kind=int(kind))
|
||||
except (TypeError, ValueError):
|
||||
return Response({'error': '筛选参数必须为整数'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if human_id_contains:
|
||||
queryset = queryset.filter(human_id__icontains=str(human_id_contains).strip())
|
||||
|
||||
def _parse_datetime(value: str):
|
||||
dt = dateparse.parse_datetime(value)
|
||||
if dt is None:
|
||||
d = dateparse.parse_date(value)
|
||||
if d is None:
|
||||
return None
|
||||
dt = timezone.datetime.combine(d, timezone.datetime.min.time())
|
||||
if timezone.is_naive(dt):
|
||||
dt = timezone.make_aware(dt, timezone.get_current_timezone())
|
||||
return dt
|
||||
|
||||
if created_from:
|
||||
dt_from = _parse_datetime(created_from)
|
||||
if dt_from is None:
|
||||
return Response({'error': 'created_at_from 格式不正确'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
queryset = queryset.filter(created_at__gte=dt_from)
|
||||
|
||||
if created_to:
|
||||
dt_to = _parse_datetime(created_to)
|
||||
if dt_to is None:
|
||||
return Response({'error': 'created_at_to 格式不正确'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
queryset = queryset.filter(created_at__lte=dt_to)
|
||||
|
||||
paginator = self.pagination_class()
|
||||
page = paginator.paginate_queryset(queryset.order_by('-created_at'), request, view=self)
|
||||
serializer = PrePurchaseOrderSerializer(page, many=True)
|
||||
|
||||
@@ -1,13 +1,55 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from rest_framework import status, views, serializers, pagination
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from django.db.models import Sum
|
||||
from django.utils import dateparse, timezone
|
||||
|
||||
from api_v1.views.stock_change_views.mixins import StockChangeViewMixin
|
||||
from business import models as business_models
|
||||
from business import pre_order_services
|
||||
from business import allocation_services
|
||||
|
||||
|
||||
class AllocationRecordSerializer(serializers.ModelSerializer):
|
||||
stock_ids = serializers.SerializerMethodField(read_only=True)
|
||||
scanned_by_name = serializers.CharField(source='scanned_by.name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = business_models.AllocationRecord
|
||||
fields = [
|
||||
'id',
|
||||
'pre_sales_order_item',
|
||||
'stock_ids',
|
||||
'quantity',
|
||||
'unit',
|
||||
'status',
|
||||
'scanned_by',
|
||||
'scanned_by_name',
|
||||
'scanned_at',
|
||||
'remarks',
|
||||
'created_at',
|
||||
]
|
||||
read_only_fields = [
|
||||
'id',
|
||||
'pre_sales_order_item',
|
||||
'scanned_by',
|
||||
'scanned_by_name',
|
||||
'scanned_at',
|
||||
'created_at',
|
||||
]
|
||||
|
||||
def get_stock_ids(self, obj: business_models.AllocationRecord):
|
||||
return obj.stock_id_list
|
||||
|
||||
|
||||
class PreSalesOrderItemSerializer(serializers.ModelSerializer):
|
||||
allocation_records = AllocationRecordSerializer(many=True, read_only=True)
|
||||
allocated_quantity = serializers.SerializerMethodField(read_only=True)
|
||||
progress = serializers.SerializerMethodField(read_only=True)
|
||||
allocation_status = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = business_models.PreSalesOrderItem
|
||||
fields = [
|
||||
@@ -18,22 +60,44 @@ class PreSalesOrderItemSerializer(serializers.ModelSerializer):
|
||||
'quantity',
|
||||
'unit',
|
||||
'spec',
|
||||
'quantity_of_rolls',
|
||||
'num_of_rolls',
|
||||
'order_quantity',
|
||||
'remarks',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'allocated_quantity',
|
||||
'progress',
|
||||
'allocation_status',
|
||||
'allocation_records',
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
read_only_fields = ['id']
|
||||
|
||||
def _get_allocated_quantity(self, obj: business_models.PreSalesOrderItem) -> Decimal:
|
||||
result = obj.allocation_records.filter(
|
||||
status=business_models.AllocationRecordStatusEnum.ACTIVE
|
||||
).aggregate(total=Sum('quantity'))
|
||||
return result.get('total') or Decimal('0')
|
||||
|
||||
def get_allocated_quantity(self, obj: business_models.PreSalesOrderItem) -> str:
|
||||
return str(self._get_allocated_quantity(obj))
|
||||
|
||||
def get_progress(self, obj: business_models.PreSalesOrderItem) -> str:
|
||||
required = obj.quantity or Decimal('0')
|
||||
allocated = self._get_allocated_quantity(obj)
|
||||
if required <= 0:
|
||||
return '0'
|
||||
return str((allocated / required).quantize(Decimal('0.0001')))
|
||||
|
||||
def get_allocation_status(self, obj: business_models.PreSalesOrderItem) -> int:
|
||||
required = obj.quantity or Decimal('0')
|
||||
allocated = self._get_allocated_quantity(obj)
|
||||
if allocated <= 0:
|
||||
return 1
|
||||
if allocated < required:
|
||||
return 2
|
||||
return 3
|
||||
|
||||
|
||||
class PreSalesOrderSerializer(serializers.ModelSerializer):
|
||||
human_id = serializers.CharField(read_only=True)
|
||||
merchant_name = serializers.CharField(source='merchant.name', read_only=True)
|
||||
customer_name = serializers.CharField(source='customer.name', read_only=True)
|
||||
warehouse_name = serializers.CharField(source='warehouse.name', read_only=True)
|
||||
created_by_username = serializers.CharField(source='created_by.username', read_only=True)
|
||||
operator_name = serializers.CharField(source='operator.name', read_only=True)
|
||||
items = PreSalesOrderItemSerializer(many=True, read_only=True)
|
||||
|
||||
@@ -43,30 +107,23 @@ class PreSalesOrderSerializer(serializers.ModelSerializer):
|
||||
'id',
|
||||
'human_id',
|
||||
'merchant',
|
||||
'merchant_name',
|
||||
'customer',
|
||||
'customer_name',
|
||||
'warehouse',
|
||||
'warehouse_name',
|
||||
'created_by',
|
||||
'created_by_username',
|
||||
'operator',
|
||||
'operator_name',
|
||||
'kind',
|
||||
'remarks',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'items',
|
||||
]
|
||||
read_only_fields = [
|
||||
'id',
|
||||
'human_id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'merchant_name',
|
||||
'customer_name',
|
||||
'warehouse_name',
|
||||
'created_by_username',
|
||||
'operator_name',
|
||||
'items',
|
||||
]
|
||||
@@ -90,6 +147,49 @@ class PreSalesOrderView(StockChangeViewMixin, views.APIView):
|
||||
merchant = request.user.employee.merchant
|
||||
queryset = pre_order_services.list_pre_sales_orders(merchant=merchant)
|
||||
|
||||
customer_id = request.query_params.get('customer') or request.query_params.get('customer_id')
|
||||
warehouse_id = request.query_params.get('warehouse') or request.query_params.get('warehouse_id')
|
||||
kind = request.query_params.get('kind')
|
||||
human_id_contains = request.query_params.get('human_id__icontains')
|
||||
created_from = request.query_params.get('created_at_from')
|
||||
created_to = request.query_params.get('created_at_to')
|
||||
|
||||
try:
|
||||
if customer_id:
|
||||
queryset = queryset.filter(customer_id=int(customer_id))
|
||||
if warehouse_id:
|
||||
queryset = queryset.filter(warehouse_id=int(warehouse_id))
|
||||
if kind:
|
||||
queryset = queryset.filter(kind=int(kind))
|
||||
except (TypeError, ValueError):
|
||||
return Response({'error': '筛选参数必须为整数'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if human_id_contains:
|
||||
queryset = queryset.filter(human_id__icontains=str(human_id_contains).strip())
|
||||
|
||||
def _parse_datetime(value: str):
|
||||
dt = dateparse.parse_datetime(value)
|
||||
if dt is None:
|
||||
d = dateparse.parse_date(value)
|
||||
if d is None:
|
||||
return None
|
||||
dt = timezone.datetime.combine(d, timezone.datetime.min.time())
|
||||
if timezone.is_naive(dt):
|
||||
dt = timezone.make_aware(dt, timezone.get_current_timezone())
|
||||
return dt
|
||||
|
||||
if created_from:
|
||||
dt_from = _parse_datetime(created_from)
|
||||
if dt_from is None:
|
||||
return Response({'error': 'created_at_from 格式不正确'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
queryset = queryset.filter(created_at__gte=dt_from)
|
||||
|
||||
if created_to:
|
||||
dt_to = _parse_datetime(created_to)
|
||||
if dt_to is None:
|
||||
return Response({'error': 'created_at_to 格式不正确'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
queryset = queryset.filter(created_at__lte=dt_to)
|
||||
|
||||
paginator = self.pagination_class()
|
||||
page = paginator.paginate_queryset(queryset.order_by('-created_at'), request, view=self)
|
||||
serializer = PreSalesOrderSerializer(page, many=True)
|
||||
@@ -177,3 +277,64 @@ class PreSalesOrderDetailView(StockChangeViewMixin, views.APIView):
|
||||
|
||||
pre_order_services.delete_pre_sales_order(pre_sales_order=order)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class PreSalesOrderItemAllocationView(StockChangeViewMixin, views.APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def _get_item(self, request, item_id: int):
|
||||
if not self.check_employee_permission(request):
|
||||
return None, self.permission_error_response('无权限访问')
|
||||
|
||||
merchant = request.user.employee.merchant
|
||||
try:
|
||||
item = business_models.PreSalesOrderItem.objects.select_related(
|
||||
'pre_sales_order',
|
||||
).get(id=item_id, pre_sales_order__merchant=merchant)
|
||||
return item, None
|
||||
except business_models.PreSalesOrderItem.DoesNotExist:
|
||||
return None, self.not_found_response('预销售单明细不存在')
|
||||
|
||||
def post(self, request, item_id: int):
|
||||
item, error_response = self._get_item(request, item_id)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
data = request.data or {}
|
||||
try:
|
||||
record = allocation_services.create_allocation_record(
|
||||
item=item,
|
||||
stock_ids=data.get('stock_ids') or [],
|
||||
quantity=data.get('quantity'),
|
||||
unit=data.get('unit'),
|
||||
scanned_by=request.user.employee,
|
||||
remarks=data.get('remarks'),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
return Response(AllocationRecordSerializer(record).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class PreSalesOrderItemAllocationDetailView(StockChangeViewMixin, views.APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def delete(self, request, item_id: int, pk: int):
|
||||
if not self.check_employee_permission(request):
|
||||
return self.permission_error_response('无权限访问')
|
||||
|
||||
merchant = request.user.employee.merchant
|
||||
try:
|
||||
record = business_models.AllocationRecord.objects.select_related(
|
||||
'pre_sales_order_item',
|
||||
'pre_sales_order_item__pre_sales_order',
|
||||
).get(
|
||||
id=pk,
|
||||
pre_sales_order_item_id=item_id,
|
||||
pre_sales_order_item__pre_sales_order__merchant=merchant,
|
||||
)
|
||||
except business_models.AllocationRecord.DoesNotExist:
|
||||
return self.not_found_response('配货记录不存在')
|
||||
|
||||
updated = allocation_services.cancel_allocation_record(record=record)
|
||||
return Response(AllocationRecordSerializer(updated).data, status=status.HTTP_200_OK)
|
||||
|
||||
Reference in New Issue
Block a user