mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Adds API endpoint to allocate stock items against a SalesOrder
- SalesOrderAllocations are no longer created manually - API endpoint performs data validation - Multiple line items can be allocated at once - Adds unit testing for new API endpoint
This commit is contained in:
parent
dd5eeb7c61
commit
bff9f0828a
@ -422,7 +422,7 @@ class BuildAllocationSerializer(serializers.Serializer):
|
|||||||
Validation
|
Validation
|
||||||
"""
|
"""
|
||||||
|
|
||||||
super().validate(data)
|
data = super().validate(data)
|
||||||
|
|
||||||
items = data.get('items', [])
|
items = data.get('items', [])
|
||||||
|
|
||||||
|
@ -13,25 +13,17 @@ from rest_framework import generics
|
|||||||
from rest_framework import filters, status
|
from rest_framework import filters, status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from company.models import SupplierPart
|
||||||
|
|
||||||
from InvenTree.filters import InvenTreeOrderingFilter
|
from InvenTree.filters import InvenTreeOrderingFilter
|
||||||
from InvenTree.helpers import str2bool
|
from InvenTree.helpers import str2bool
|
||||||
from InvenTree.api import AttachmentMixin
|
from InvenTree.api import AttachmentMixin
|
||||||
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus
|
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus
|
||||||
|
|
||||||
|
import order.models as models
|
||||||
|
import order.serializers as serializers
|
||||||
|
|
||||||
from part.models import Part
|
from part.models import Part
|
||||||
from company.models import SupplierPart
|
|
||||||
|
|
||||||
from .models import PurchaseOrder, PurchaseOrderLineItem
|
|
||||||
from .models import PurchaseOrderAttachment
|
|
||||||
from .serializers import POSerializer, POLineItemSerializer, POAttachmentSerializer
|
|
||||||
|
|
||||||
from .models import SalesOrder, SalesOrderLineItem, SalesOrderShipment, SalesOrderAllocation
|
|
||||||
from .models import SalesOrderAttachment
|
|
||||||
|
|
||||||
from .serializers import SalesOrderSerializer, SOLineItemSerializer, SOAttachmentSerializer
|
|
||||||
from .serializers import SalesOrderShipmentSerializer, SalesOrderAllocationSerializer
|
|
||||||
from .serializers import POReceiveSerializer
|
|
||||||
|
|
||||||
|
|
||||||
class POList(generics.ListCreateAPIView):
|
class POList(generics.ListCreateAPIView):
|
||||||
@ -41,8 +33,8 @@ class POList(generics.ListCreateAPIView):
|
|||||||
- POST: Create a new PurchaseOrder object
|
- POST: Create a new PurchaseOrder object
|
||||||
"""
|
"""
|
||||||
|
|
||||||
queryset = PurchaseOrder.objects.all()
|
queryset = models.PurchaseOrder.objects.all()
|
||||||
serializer_class = POSerializer
|
serializer_class = serializers.POSerializer
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -79,7 +71,7 @@ class POList(generics.ListCreateAPIView):
|
|||||||
'lines',
|
'lines',
|
||||||
)
|
)
|
||||||
|
|
||||||
queryset = POSerializer.annotate_queryset(queryset)
|
queryset = serializers.POSerializer.annotate_queryset(queryset)
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
@ -108,9 +100,9 @@ class POList(generics.ListCreateAPIView):
|
|||||||
overdue = str2bool(overdue)
|
overdue = str2bool(overdue)
|
||||||
|
|
||||||
if overdue:
|
if overdue:
|
||||||
queryset = queryset.filter(PurchaseOrder.OVERDUE_FILTER)
|
queryset = queryset.filter(models.PurchaseOrder.OVERDUE_FILTER)
|
||||||
else:
|
else:
|
||||||
queryset = queryset.exclude(PurchaseOrder.OVERDUE_FILTER)
|
queryset = queryset.exclude(models.PurchaseOrder.OVERDUE_FILTER)
|
||||||
|
|
||||||
# Special filtering for 'status' field
|
# Special filtering for 'status' field
|
||||||
status = params.get('status', None)
|
status = params.get('status', None)
|
||||||
@ -144,7 +136,7 @@ class POList(generics.ListCreateAPIView):
|
|||||||
max_date = params.get('max_date', None)
|
max_date = params.get('max_date', None)
|
||||||
|
|
||||||
if min_date is not None and max_date is not None:
|
if min_date is not None and max_date is not None:
|
||||||
queryset = PurchaseOrder.filterByDate(queryset, min_date, max_date)
|
queryset = models.PurchaseOrder.filterByDate(queryset, min_date, max_date)
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
@ -184,8 +176,8 @@ class POList(generics.ListCreateAPIView):
|
|||||||
class PODetail(generics.RetrieveUpdateDestroyAPIView):
|
class PODetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
""" API endpoint for detail view of a PurchaseOrder object """
|
""" API endpoint for detail view of a PurchaseOrder object """
|
||||||
|
|
||||||
queryset = PurchaseOrder.objects.all()
|
queryset = models.PurchaseOrder.objects.all()
|
||||||
serializer_class = POSerializer
|
serializer_class = serializers.POSerializer
|
||||||
|
|
||||||
def get_serializer(self, *args, **kwargs):
|
def get_serializer(self, *args, **kwargs):
|
||||||
|
|
||||||
@ -208,7 +200,7 @@ class PODetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
'lines',
|
'lines',
|
||||||
)
|
)
|
||||||
|
|
||||||
queryset = POSerializer.annotate_queryset(queryset)
|
queryset = serializers.POSerializer.annotate_queryset(queryset)
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
@ -226,9 +218,9 @@ class POReceive(generics.CreateAPIView):
|
|||||||
- A global location can also be specified
|
- A global location can also be specified
|
||||||
"""
|
"""
|
||||||
|
|
||||||
queryset = PurchaseOrderLineItem.objects.none()
|
queryset = models.PurchaseOrderLineItem.objects.none()
|
||||||
|
|
||||||
serializer_class = POReceiveSerializer
|
serializer_class = serializers.POReceiveSerializer
|
||||||
|
|
||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
|
|
||||||
@ -236,7 +228,7 @@ class POReceive(generics.CreateAPIView):
|
|||||||
|
|
||||||
# Pass the purchase order through to the serializer for validation
|
# Pass the purchase order through to the serializer for validation
|
||||||
try:
|
try:
|
||||||
context['order'] = PurchaseOrder.objects.get(pk=self.kwargs.get('pk', None))
|
context['order'] = models.PurchaseOrder.objects.get(pk=self.kwargs.get('pk', None))
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -251,7 +243,7 @@ class POLineItemFilter(rest_filters.FilterSet):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = PurchaseOrderLineItem
|
model = models.PurchaseOrderLineItem
|
||||||
fields = [
|
fields = [
|
||||||
'order',
|
'order',
|
||||||
'part'
|
'part'
|
||||||
@ -285,15 +277,15 @@ class POLineItemList(generics.ListCreateAPIView):
|
|||||||
- POST: Create a new PurchaseOrderLineItem object
|
- POST: Create a new PurchaseOrderLineItem object
|
||||||
"""
|
"""
|
||||||
|
|
||||||
queryset = PurchaseOrderLineItem.objects.all()
|
queryset = models.PurchaseOrderLineItem.objects.all()
|
||||||
serializer_class = POLineItemSerializer
|
serializer_class = serializers.POLineItemSerializer
|
||||||
filterset_class = POLineItemFilter
|
filterset_class = POLineItemFilter
|
||||||
|
|
||||||
def get_queryset(self, *args, **kwargs):
|
def get_queryset(self, *args, **kwargs):
|
||||||
|
|
||||||
queryset = super().get_queryset(*args, **kwargs)
|
queryset = super().get_queryset(*args, **kwargs)
|
||||||
|
|
||||||
queryset = POLineItemSerializer.annotate_queryset(queryset)
|
queryset = serializers.POLineItemSerializer.annotate_queryset(queryset)
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
@ -350,14 +342,14 @@ class POLineItemDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
Detail API endpoint for PurchaseOrderLineItem object
|
Detail API endpoint for PurchaseOrderLineItem object
|
||||||
"""
|
"""
|
||||||
|
|
||||||
queryset = PurchaseOrderLineItem.objects.all()
|
queryset = models.PurchaseOrderLineItem.objects.all()
|
||||||
serializer_class = POLineItemSerializer
|
serializer_class = serializers.POLineItemSerializer
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
|
|
||||||
queryset = super().get_queryset()
|
queryset = super().get_queryset()
|
||||||
|
|
||||||
queryset = POLineItemSerializer.annotate_queryset(queryset)
|
queryset = serializers.POLineItemSerializer.annotate_queryset(queryset)
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
@ -367,8 +359,8 @@ class SOAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
|||||||
API endpoint for listing (and creating) a SalesOrderAttachment (file upload)
|
API endpoint for listing (and creating) a SalesOrderAttachment (file upload)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
queryset = SalesOrderAttachment.objects.all()
|
queryset = models.SalesOrderAttachment.objects.all()
|
||||||
serializer_class = SOAttachmentSerializer
|
serializer_class = serializers.SOAttachmentSerializer
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = [
|
||||||
rest_filters.DjangoFilterBackend,
|
rest_filters.DjangoFilterBackend,
|
||||||
@ -384,8 +376,8 @@ class SOAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin)
|
|||||||
Detail endpoint for SalesOrderAttachment
|
Detail endpoint for SalesOrderAttachment
|
||||||
"""
|
"""
|
||||||
|
|
||||||
queryset = SalesOrderAttachment.objects.all()
|
queryset = models.SalesOrderAttachment.objects.all()
|
||||||
serializer_class = SOAttachmentSerializer
|
serializer_class = serializers.SOAttachmentSerializer
|
||||||
|
|
||||||
|
|
||||||
class SOList(generics.ListCreateAPIView):
|
class SOList(generics.ListCreateAPIView):
|
||||||
@ -396,8 +388,8 @@ class SOList(generics.ListCreateAPIView):
|
|||||||
- POST: Create a new SalesOrder
|
- POST: Create a new SalesOrder
|
||||||
"""
|
"""
|
||||||
|
|
||||||
queryset = SalesOrder.objects.all()
|
queryset = models.SalesOrder.objects.all()
|
||||||
serializer_class = SalesOrderSerializer
|
serializer_class = serializers.SalesOrderSerializer
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -434,7 +426,7 @@ class SOList(generics.ListCreateAPIView):
|
|||||||
'lines'
|
'lines'
|
||||||
)
|
)
|
||||||
|
|
||||||
queryset = SalesOrderSerializer.annotate_queryset(queryset)
|
queryset = serializers.SalesOrderSerializer.annotate_queryset(queryset)
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
@ -454,9 +446,9 @@ class SOList(generics.ListCreateAPIView):
|
|||||||
outstanding = str2bool(outstanding)
|
outstanding = str2bool(outstanding)
|
||||||
|
|
||||||
if outstanding:
|
if outstanding:
|
||||||
queryset = queryset.filter(status__in=SalesOrderStatus.OPEN)
|
queryset = queryset.filter(status__in=models.SalesOrderStatus.OPEN)
|
||||||
else:
|
else:
|
||||||
queryset = queryset.exclude(status__in=SalesOrderStatus.OPEN)
|
queryset = queryset.exclude(status__in=models.SalesOrderStatus.OPEN)
|
||||||
|
|
||||||
# Filter by 'overdue' status
|
# Filter by 'overdue' status
|
||||||
overdue = params.get('overdue', None)
|
overdue = params.get('overdue', None)
|
||||||
@ -465,9 +457,9 @@ class SOList(generics.ListCreateAPIView):
|
|||||||
overdue = str2bool(overdue)
|
overdue = str2bool(overdue)
|
||||||
|
|
||||||
if overdue:
|
if overdue:
|
||||||
queryset = queryset.filter(SalesOrder.OVERDUE_FILTER)
|
queryset = queryset.filter(models.SalesOrder.OVERDUE_FILTER)
|
||||||
else:
|
else:
|
||||||
queryset = queryset.exclude(SalesOrder.OVERDUE_FILTER)
|
queryset = queryset.exclude(models.SalesOrder.OVERDUE_FILTER)
|
||||||
|
|
||||||
status = params.get('status', None)
|
status = params.get('status', None)
|
||||||
|
|
||||||
@ -490,7 +482,7 @@ class SOList(generics.ListCreateAPIView):
|
|||||||
max_date = params.get('max_date', None)
|
max_date = params.get('max_date', None)
|
||||||
|
|
||||||
if min_date is not None and max_date is not None:
|
if min_date is not None and max_date is not None:
|
||||||
queryset = SalesOrder.filterByDate(queryset, min_date, max_date)
|
queryset = models.SalesOrder.filterByDate(queryset, min_date, max_date)
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
@ -534,8 +526,8 @@ class SODetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
API endpoint for detail view of a SalesOrder object.
|
API endpoint for detail view of a SalesOrder object.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
queryset = SalesOrder.objects.all()
|
queryset = models.SalesOrder.objects.all()
|
||||||
serializer_class = SalesOrderSerializer
|
serializer_class = serializers.SalesOrderSerializer
|
||||||
|
|
||||||
def get_serializer(self, *args, **kwargs):
|
def get_serializer(self, *args, **kwargs):
|
||||||
|
|
||||||
@ -554,7 +546,7 @@ class SODetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
|
|
||||||
queryset = queryset.prefetch_related('customer', 'lines')
|
queryset = queryset.prefetch_related('customer', 'lines')
|
||||||
|
|
||||||
queryset = SalesOrderSerializer.annotate_queryset(queryset)
|
queryset = serializers.SalesOrderSerializer.annotate_queryset(queryset)
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
@ -564,8 +556,8 @@ class SOLineItemList(generics.ListCreateAPIView):
|
|||||||
API endpoint for accessing a list of SalesOrderLineItem objects.
|
API endpoint for accessing a list of SalesOrderLineItem objects.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
queryset = SalesOrderLineItem.objects.all()
|
queryset = models.SalesOrderLineItem.objects.all()
|
||||||
serializer_class = SOLineItemSerializer
|
serializer_class = serializers.SOLineItemSerializer
|
||||||
|
|
||||||
def get_serializer(self, *args, **kwargs):
|
def get_serializer(self, *args, **kwargs):
|
||||||
|
|
||||||
@ -624,8 +616,34 @@ class SOLineItemList(generics.ListCreateAPIView):
|
|||||||
class SOLineItemDetail(generics.RetrieveUpdateDestroyAPIView):
|
class SOLineItemDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
""" API endpoint for detail view of a SalesOrderLineItem object """
|
""" API endpoint for detail view of a SalesOrderLineItem object """
|
||||||
|
|
||||||
queryset = SalesOrderLineItem.objects.all()
|
queryset = models.SalesOrderLineItem.objects.all()
|
||||||
serializer_class = SOLineItemSerializer
|
serializer_class = serializers.SOLineItemSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class SalesOrderAllocate(generics.CreateAPIView):
|
||||||
|
"""
|
||||||
|
API endpoint to allocate stock items against a SalesOrder
|
||||||
|
|
||||||
|
- The SalesOrder is specified in the URL
|
||||||
|
- See the SOShipmentAllocationSerializer class
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = models.SalesOrder.objects.none()
|
||||||
|
serializer_class = serializers.SOShipmentAllocationSerializer
|
||||||
|
|
||||||
|
def get_serializer_context(self):
|
||||||
|
|
||||||
|
ctx = super().get_serializer_context()
|
||||||
|
|
||||||
|
# Pass through the SalesOrder object to the serializer
|
||||||
|
try:
|
||||||
|
ctx['order'] = models.SalesOrder.objects.get(pk=self.kwargs.get('pk', None))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
ctx['request'] = self.request
|
||||||
|
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
class SOAllocationDetail(generics.RetrieveUpdateDestroyAPIView):
|
class SOAllocationDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
@ -633,17 +651,17 @@ class SOAllocationDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
API endpoint for detali view of a SalesOrderAllocation object
|
API endpoint for detali view of a SalesOrderAllocation object
|
||||||
"""
|
"""
|
||||||
|
|
||||||
queryset = SalesOrderAllocation.objects.all()
|
queryset = models.SalesOrderAllocation.objects.all()
|
||||||
serializer_class = SalesOrderAllocationSerializer
|
serializer_class = serializers.SalesOrderAllocationSerializer
|
||||||
|
|
||||||
|
|
||||||
class SOAllocationList(generics.ListCreateAPIView):
|
class SOAllocationList(generics.ListAPIView):
|
||||||
"""
|
"""
|
||||||
API endpoint for listing SalesOrderAllocation objects
|
API endpoint for listing SalesOrderAllocation objects
|
||||||
"""
|
"""
|
||||||
|
|
||||||
queryset = SalesOrderAllocation.objects.all()
|
queryset = models.SalesOrderAllocation.objects.all()
|
||||||
serializer_class = SalesOrderAllocationSerializer
|
serializer_class = serializers.SalesOrderAllocationSerializer
|
||||||
|
|
||||||
def get_serializer(self, *args, **kwargs):
|
def get_serializer(self, *args, **kwargs):
|
||||||
|
|
||||||
@ -720,7 +738,7 @@ class SOShipmentFilter(rest_filters.FilterSet):
|
|||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SalesOrderShipment
|
model = models.SalesOrderShipment
|
||||||
fields = [
|
fields = [
|
||||||
'order',
|
'order',
|
||||||
]
|
]
|
||||||
@ -731,8 +749,8 @@ class SOShipmentList(generics.ListCreateAPIView):
|
|||||||
API list endpoint for SalesOrderShipment model
|
API list endpoint for SalesOrderShipment model
|
||||||
"""
|
"""
|
||||||
|
|
||||||
queryset = SalesOrderShipment.objects.all()
|
queryset = models.SalesOrderShipment.objects.all()
|
||||||
serializer_class = SalesOrderShipmentSerializer
|
serializer_class = serializers.SalesOrderShipmentSerializer
|
||||||
filterset_class = SOShipmentFilter
|
filterset_class = SOShipmentFilter
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = [
|
||||||
@ -745,8 +763,8 @@ class SOShipmentDetail(generics.RetrieveUpdateAPIView):
|
|||||||
API detail endpooint for SalesOrderShipment model
|
API detail endpooint for SalesOrderShipment model
|
||||||
"""
|
"""
|
||||||
|
|
||||||
queryset = SalesOrderShipment.objects.all()
|
queryset = models.SalesOrderShipment.objects.all()
|
||||||
serializer_class = SalesOrderShipmentSerializer
|
serializer_class = serializers.SalesOrderShipmentSerializer
|
||||||
|
|
||||||
|
|
||||||
class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
||||||
@ -754,8 +772,8 @@ class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
|||||||
API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload)
|
API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
queryset = PurchaseOrderAttachment.objects.all()
|
queryset = models.PurchaseOrderAttachment.objects.all()
|
||||||
serializer_class = POAttachmentSerializer
|
serializer_class = serializers.POAttachmentSerializer
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = [
|
||||||
rest_filters.DjangoFilterBackend,
|
rest_filters.DjangoFilterBackend,
|
||||||
@ -771,8 +789,8 @@ class POAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin)
|
|||||||
Detail endpoint for a PurchaseOrderAttachment
|
Detail endpoint for a PurchaseOrderAttachment
|
||||||
"""
|
"""
|
||||||
|
|
||||||
queryset = PurchaseOrderAttachment.objects.all()
|
queryset = models.PurchaseOrderAttachment.objects.all()
|
||||||
serializer_class = POAttachmentSerializer
|
serializer_class = serializers.POAttachmentSerializer
|
||||||
|
|
||||||
|
|
||||||
order_api_urls = [
|
order_api_urls = [
|
||||||
@ -816,7 +834,13 @@ order_api_urls = [
|
|||||||
url(r'^.*$', SOShipmentList.as_view(), name='api-so-shipment-list'),
|
url(r'^.*$', SOShipmentList.as_view(), name='api-so-shipment-list'),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
url(r'^(?P<pk>\d+)/$', SODetail.as_view(), name='api-so-detail'),
|
# Sales order detail view
|
||||||
|
url(r'^(?P<pk>\d+)/', include([
|
||||||
|
url(r'^allocate/', SalesOrderAllocate.as_view(), name='api-so-allocate'),
|
||||||
|
url(r'^.*$', SODetail.as_view(), name='api-so-detail'),
|
||||||
|
])),
|
||||||
|
|
||||||
|
# Sales order list view
|
||||||
url(r'^.*$', SOList.as_view(), name='api-so-list'),
|
url(r'^.*$', SOList.as_view(), name='api-so-list'),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
|
@ -567,6 +567,16 @@ class SalesOrder(Order):
|
|||||||
def is_pending(self):
|
def is_pending(self):
|
||||||
return self.status == SalesOrderStatus.PENDING
|
return self.status == SalesOrderStatus.PENDING
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stock_allocations(self):
|
||||||
|
"""
|
||||||
|
Return a queryset containing all allocations for this order
|
||||||
|
"""
|
||||||
|
|
||||||
|
return SalesOrderAllocation.objects.filter(
|
||||||
|
line__in=[line.pk for line in self.lines.all()]
|
||||||
|
)
|
||||||
|
|
||||||
def is_fully_allocated(self):
|
def is_fully_allocated(self):
|
||||||
""" Return True if all line items are fully allocated """
|
""" Return True if all line items are fully allocated """
|
||||||
|
|
||||||
@ -910,6 +920,10 @@ class SalesOrderShipment(models.Model):
|
|||||||
notes: Custom notes field for this shipment
|
notes: Custom notes field for this shipment
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_api_url():
|
||||||
|
return reverse('api-so-shipment-list')
|
||||||
|
|
||||||
order = models.ForeignKey(
|
order = models.ForeignKey(
|
||||||
SalesOrder,
|
SalesOrder,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
@ -1014,10 +1028,9 @@ class SalesOrderAllocation(models.Model):
|
|||||||
if self.item.serial and not self.quantity == 1:
|
if self.item.serial and not self.quantity == 1:
|
||||||
errors['quantity'] = _('Quantity must be 1 for serialized stock item')
|
errors['quantity'] = _('Quantity must be 1 for serialized stock item')
|
||||||
|
|
||||||
|
if self.line.order != self.shipment.order:
|
||||||
|
errors['line'] = _('Sales order does not match shipment')
|
||||||
# TODO: Ensure that the "shipment" points to the same "order"!
|
errors['shipment'] = _('Shipment does not match sales order')
|
||||||
|
|
||||||
|
|
||||||
if len(errors) > 0:
|
if len(errors) > 0:
|
||||||
raise ValidationError(errors)
|
raise ValidationError(errors)
|
||||||
@ -1026,7 +1039,8 @@ class SalesOrderAllocation(models.Model):
|
|||||||
SalesOrderLineItem,
|
SalesOrderLineItem,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
verbose_name=_('Line'),
|
verbose_name=_('Line'),
|
||||||
related_name='allocations')
|
related_name='allocations'
|
||||||
|
)
|
||||||
|
|
||||||
shipment = models.ForeignKey(
|
shipment = models.ForeignKey(
|
||||||
SalesOrderShipment,
|
SalesOrderShipment,
|
||||||
|
@ -17,30 +17,29 @@ from rest_framework.serializers import ValidationError
|
|||||||
|
|
||||||
from sql_util.utils import SubqueryCount
|
from sql_util.utils import SubqueryCount
|
||||||
|
|
||||||
|
from common.settings import currency_code_mappings
|
||||||
|
|
||||||
|
from company.serializers import CompanyBriefSerializer, SupplierPartSerializer
|
||||||
|
|
||||||
|
from InvenTree.helpers import normalize
|
||||||
from InvenTree.serializers import InvenTreeModelSerializer
|
from InvenTree.serializers import InvenTreeModelSerializer
|
||||||
from InvenTree.serializers import InvenTreeAttachmentSerializer
|
from InvenTree.serializers import InvenTreeAttachmentSerializer
|
||||||
from InvenTree.serializers import InvenTreeMoneySerializer
|
from InvenTree.serializers import InvenTreeMoneySerializer
|
||||||
from InvenTree.serializers import InvenTreeAttachmentSerializerField
|
from InvenTree.serializers import InvenTreeAttachmentSerializerField
|
||||||
|
|
||||||
from InvenTree.status_codes import StockStatus
|
from InvenTree.status_codes import StockStatus
|
||||||
|
|
||||||
from company.serializers import CompanyBriefSerializer, SupplierPartSerializer
|
import order.models
|
||||||
|
|
||||||
from part.serializers import PartBriefSerializer
|
from part.serializers import PartBriefSerializer
|
||||||
|
|
||||||
import stock.models
|
import stock.models
|
||||||
from stock.serializers import LocationBriefSerializer, StockItemSerializer, LocationSerializer
|
import stock.serializers
|
||||||
|
|
||||||
from .models import PurchaseOrder, PurchaseOrderLineItem
|
|
||||||
from .models import PurchaseOrderAttachment, SalesOrderAttachment
|
|
||||||
from .models import SalesOrder, SalesOrderLineItem
|
|
||||||
from .models import SalesOrderShipment, SalesOrderAllocation
|
|
||||||
|
|
||||||
from common.settings import currency_code_mappings
|
|
||||||
|
|
||||||
|
|
||||||
class POSerializer(InvenTreeModelSerializer):
|
class POSerializer(InvenTreeModelSerializer):
|
||||||
""" Serializer for a PurchaseOrder object """
|
"""
|
||||||
|
Serializer for a PurchaseOrder object
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
|
||||||
@ -67,7 +66,7 @@ class POSerializer(InvenTreeModelSerializer):
|
|||||||
queryset = queryset.annotate(
|
queryset = queryset.annotate(
|
||||||
overdue=Case(
|
overdue=Case(
|
||||||
When(
|
When(
|
||||||
PurchaseOrder.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()),
|
order.models.PurchaseOrder.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()),
|
||||||
),
|
),
|
||||||
default=Value(False, output_field=BooleanField())
|
default=Value(False, output_field=BooleanField())
|
||||||
)
|
)
|
||||||
@ -86,7 +85,7 @@ class POSerializer(InvenTreeModelSerializer):
|
|||||||
reference = serializers.CharField(required=True)
|
reference = serializers.CharField(required=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = PurchaseOrder
|
model = order.models.PurchaseOrder
|
||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
'pk',
|
'pk',
|
||||||
@ -160,7 +159,7 @@ class POLineItemSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
purchase_price_string = serializers.CharField(source='purchase_price', read_only=True)
|
purchase_price_string = serializers.CharField(source='purchase_price', read_only=True)
|
||||||
|
|
||||||
destination_detail = LocationBriefSerializer(source='get_destination', read_only=True)
|
destination_detail = stock.serializers.LocationBriefSerializer(source='get_destination', read_only=True)
|
||||||
|
|
||||||
purchase_price_currency = serializers.ChoiceField(
|
purchase_price_currency = serializers.ChoiceField(
|
||||||
choices=currency_code_mappings(),
|
choices=currency_code_mappings(),
|
||||||
@ -168,7 +167,7 @@ class POLineItemSerializer(InvenTreeModelSerializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = PurchaseOrderLineItem
|
model = order.models.PurchaseOrderLineItem
|
||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
'pk',
|
'pk',
|
||||||
@ -195,7 +194,7 @@ class POLineItemReceiveSerializer(serializers.Serializer):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
line_item = serializers.PrimaryKeyRelatedField(
|
line_item = serializers.PrimaryKeyRelatedField(
|
||||||
queryset=PurchaseOrderLineItem.objects.all(),
|
queryset=order.models.PurchaseOrderLineItem.objects.all(),
|
||||||
many=False,
|
many=False,
|
||||||
allow_null=False,
|
allow_null=False,
|
||||||
required=True,
|
required=True,
|
||||||
@ -376,7 +375,7 @@ class POAttachmentSerializer(InvenTreeAttachmentSerializer):
|
|||||||
attachment = InvenTreeAttachmentSerializerField(required=True)
|
attachment = InvenTreeAttachmentSerializerField(required=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = PurchaseOrderAttachment
|
model = order.models.PurchaseOrderAttachment
|
||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
'pk',
|
'pk',
|
||||||
@ -422,7 +421,7 @@ class SalesOrderSerializer(InvenTreeModelSerializer):
|
|||||||
queryset = queryset.annotate(
|
queryset = queryset.annotate(
|
||||||
overdue=Case(
|
overdue=Case(
|
||||||
When(
|
When(
|
||||||
SalesOrder.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()),
|
order.models.SalesOrder.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()),
|
||||||
),
|
),
|
||||||
default=Value(False, output_field=BooleanField())
|
default=Value(False, output_field=BooleanField())
|
||||||
)
|
)
|
||||||
@ -441,7 +440,7 @@ class SalesOrderSerializer(InvenTreeModelSerializer):
|
|||||||
reference = serializers.CharField(required=True)
|
reference = serializers.CharField(required=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SalesOrder
|
model = order.models.SalesOrder
|
||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
'pk',
|
'pk',
|
||||||
@ -484,8 +483,8 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
|
|||||||
# Extra detail fields
|
# Extra detail fields
|
||||||
order_detail = SalesOrderSerializer(source='line.order', many=False, read_only=True)
|
order_detail = SalesOrderSerializer(source='line.order', many=False, read_only=True)
|
||||||
part_detail = PartBriefSerializer(source='item.part', many=False, read_only=True)
|
part_detail = PartBriefSerializer(source='item.part', many=False, read_only=True)
|
||||||
item_detail = StockItemSerializer(source='item', many=False, read_only=True)
|
item_detail = stock.serializers.StockItemSerializer(source='item', many=False, read_only=True)
|
||||||
location_detail = LocationSerializer(source='item.location', many=False, read_only=True)
|
location_detail = stock.serializers.LocationSerializer(source='item.location', many=False, read_only=True)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
|
||||||
@ -509,7 +508,7 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
|
|||||||
self.fields.pop('location_detail')
|
self.fields.pop('location_detail')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SalesOrderAllocation
|
model = order.models.SalesOrderAllocation
|
||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
'pk',
|
'pk',
|
||||||
@ -570,7 +569,7 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SalesOrderLineItem
|
model = order.models.SalesOrderLineItem
|
||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
'pk',
|
'pk',
|
||||||
@ -598,7 +597,7 @@ class SalesOrderShipmentSerializer(InvenTreeModelSerializer):
|
|||||||
allocations = SalesOrderAllocationSerializer(many=True, read_only=True, location_detail=True)
|
allocations = SalesOrderAllocationSerializer(many=True, read_only=True, location_detail=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SalesOrderShipment
|
model = order.models.SalesOrderShipment
|
||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
'pk',
|
'pk',
|
||||||
@ -611,6 +610,160 @@ class SalesOrderShipmentSerializer(InvenTreeModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class SOShipmentAllocationItemSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
A serializer for allocating a single stock-item against a SalesOrder shipment
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
fields = [
|
||||||
|
'line_item',
|
||||||
|
'stock_item',
|
||||||
|
'quantity',
|
||||||
|
]
|
||||||
|
|
||||||
|
line_item = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=order.models.SalesOrderLineItem.objects.all(),
|
||||||
|
many=False,
|
||||||
|
allow_null=False,
|
||||||
|
required=True,
|
||||||
|
label=_('Stock Item'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_line_item(self, line_item):
|
||||||
|
|
||||||
|
order = self.context['order']
|
||||||
|
|
||||||
|
# Ensure that the line item points to the correct order
|
||||||
|
if line_item.order != order:
|
||||||
|
raise ValidationError(_("Line item is not associated with this order"))
|
||||||
|
|
||||||
|
return line_item
|
||||||
|
|
||||||
|
stock_item = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=stock.models.StockItem.objects.all(),
|
||||||
|
many=False,
|
||||||
|
allow_null=False,
|
||||||
|
required=True,
|
||||||
|
label=_('Stock Item'),
|
||||||
|
)
|
||||||
|
|
||||||
|
quantity = serializers.DecimalField(
|
||||||
|
max_digits=15,
|
||||||
|
decimal_places=5,
|
||||||
|
min_value=0,
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_quantity(self, quantity):
|
||||||
|
|
||||||
|
if quantity <= 0:
|
||||||
|
raise ValidationError(_("Quantity must be positive"))
|
||||||
|
|
||||||
|
return quantity
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
|
||||||
|
super().validate(data)
|
||||||
|
|
||||||
|
stock_item = data['stock_item']
|
||||||
|
quantity = data['quantity']
|
||||||
|
|
||||||
|
if stock_item.serialized and quantity != 1:
|
||||||
|
raise ValidationError({
|
||||||
|
'quantity': _("Quantity must be 1 for serialized stock item"),
|
||||||
|
})
|
||||||
|
|
||||||
|
q = normalize(stock_item.unallocated_quantity())
|
||||||
|
|
||||||
|
if quantity > q:
|
||||||
|
raise ValidationError({
|
||||||
|
'quantity': _(f"Available quantity ({q}) exceeded")
|
||||||
|
})
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class SOShipmentAllocationSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
DRF serializer for allocation of stock items against a sales order / shipment
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
fields = [
|
||||||
|
'items',
|
||||||
|
'shipment',
|
||||||
|
]
|
||||||
|
|
||||||
|
items = SOShipmentAllocationItemSerializer(many=True)
|
||||||
|
|
||||||
|
shipment = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=order.models.SalesOrderShipment.objects.all(),
|
||||||
|
many=False,
|
||||||
|
allow_null=False,
|
||||||
|
required=True,
|
||||||
|
label=_('Shipment'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_shipment(self, shipment):
|
||||||
|
"""
|
||||||
|
Run validation against the provided shipment instance
|
||||||
|
"""
|
||||||
|
|
||||||
|
order = self.context['order']
|
||||||
|
|
||||||
|
if shipment.shipment_date is not None:
|
||||||
|
raise ValidationError(_("Shipment has already been shipped"))
|
||||||
|
|
||||||
|
if shipment.order != order:
|
||||||
|
raise ValidationError(_("Shipment is not associated with this order"))
|
||||||
|
|
||||||
|
return shipment
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
"""
|
||||||
|
Serializer validation
|
||||||
|
"""
|
||||||
|
|
||||||
|
data = super().validate(data)
|
||||||
|
|
||||||
|
# Extract SalesOrder from serializer context
|
||||||
|
# order = self.context['order']
|
||||||
|
|
||||||
|
items = data.get('items', [])
|
||||||
|
|
||||||
|
if len(items) == 0:
|
||||||
|
raise ValidationError(_('Allocation items must be provided'))
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
"""
|
||||||
|
Perform the allocation of items against this order
|
||||||
|
"""
|
||||||
|
|
||||||
|
data = self.validated_data
|
||||||
|
|
||||||
|
items = data['items']
|
||||||
|
shipment = data['shipment']
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
for entry in items:
|
||||||
|
|
||||||
|
# Create a new SalesOrderAllocation
|
||||||
|
order.models.SalesOrderAllocation.objects.create(
|
||||||
|
line=entry.get('line_item'),
|
||||||
|
item=entry.get('stock_item'),
|
||||||
|
quantity=entry.get('quantity'),
|
||||||
|
shipment=shipment,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
pass
|
||||||
|
except (ValidationError, DjangoValidationError) as exc:
|
||||||
|
raise ValidationError(detail=serializers.as_serializer_error(exc))
|
||||||
|
|
||||||
|
|
||||||
class SOAttachmentSerializer(InvenTreeAttachmentSerializer):
|
class SOAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||||
"""
|
"""
|
||||||
Serializers for the SalesOrderAttachment model
|
Serializers for the SalesOrderAttachment model
|
||||||
@ -619,7 +772,7 @@ class SOAttachmentSerializer(InvenTreeAttachmentSerializer):
|
|||||||
attachment = InvenTreeAttachmentSerializerField(required=True)
|
attachment = InvenTreeAttachmentSerializerField(required=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SalesOrderAttachment
|
model = order.models.SalesOrderAttachment
|
||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
'pk',
|
'pk',
|
||||||
|
@ -11,9 +11,10 @@ from django.urls import reverse
|
|||||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||||
from InvenTree.status_codes import PurchaseOrderStatus
|
from InvenTree.status_codes import PurchaseOrderStatus
|
||||||
|
|
||||||
|
from part.models import Part
|
||||||
from stock.models import StockItem
|
from stock.models import StockItem
|
||||||
|
|
||||||
from .models import PurchaseOrder, PurchaseOrderLineItem, SalesOrder
|
import order.models as models
|
||||||
|
|
||||||
|
|
||||||
class OrderTest(InvenTreeAPITestCase):
|
class OrderTest(InvenTreeAPITestCase):
|
||||||
@ -85,7 +86,7 @@ class PurchaseOrderTest(OrderTest):
|
|||||||
self.filter({'overdue': True}, 0)
|
self.filter({'overdue': True}, 0)
|
||||||
self.filter({'overdue': False}, 7)
|
self.filter({'overdue': False}, 7)
|
||||||
|
|
||||||
order = PurchaseOrder.objects.get(pk=1)
|
order = models.PurchaseOrder.objects.get(pk=1)
|
||||||
order.target_date = datetime.now().date() - timedelta(days=10)
|
order.target_date = datetime.now().date() - timedelta(days=10)
|
||||||
order.save()
|
order.save()
|
||||||
|
|
||||||
@ -118,7 +119,7 @@ class PurchaseOrderTest(OrderTest):
|
|||||||
Test that we can create / edit and delete a PurchaseOrder via the API
|
Test that we can create / edit and delete a PurchaseOrder via the API
|
||||||
"""
|
"""
|
||||||
|
|
||||||
n = PurchaseOrder.objects.count()
|
n = models.PurchaseOrder.objects.count()
|
||||||
|
|
||||||
url = reverse('api-po-list')
|
url = reverse('api-po-list')
|
||||||
|
|
||||||
@ -135,7 +136,7 @@ class PurchaseOrderTest(OrderTest):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# And no new PurchaseOrder objects should have been created
|
# And no new PurchaseOrder objects should have been created
|
||||||
self.assertEqual(PurchaseOrder.objects.count(), n)
|
self.assertEqual(models.PurchaseOrder.objects.count(), n)
|
||||||
|
|
||||||
# Ok, now let's give this user the correct permission
|
# Ok, now let's give this user the correct permission
|
||||||
self.assignRole('purchase_order.add')
|
self.assignRole('purchase_order.add')
|
||||||
@ -152,7 +153,7 @@ class PurchaseOrderTest(OrderTest):
|
|||||||
expected_code=201
|
expected_code=201
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(PurchaseOrder.objects.count(), n + 1)
|
self.assertEqual(models.PurchaseOrder.objects.count(), n + 1)
|
||||||
|
|
||||||
pk = response.data['pk']
|
pk = response.data['pk']
|
||||||
|
|
||||||
@ -167,7 +168,7 @@ class PurchaseOrderTest(OrderTest):
|
|||||||
expected_code=400
|
expected_code=400
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(PurchaseOrder.objects.count(), n + 1)
|
self.assertEqual(models.PurchaseOrder.objects.count(), n + 1)
|
||||||
|
|
||||||
url = reverse('api-po-detail', kwargs={'pk': pk})
|
url = reverse('api-po-detail', kwargs={'pk': pk})
|
||||||
|
|
||||||
@ -198,7 +199,7 @@ class PurchaseOrderTest(OrderTest):
|
|||||||
response = self.delete(url, expected_code=204)
|
response = self.delete(url, expected_code=204)
|
||||||
|
|
||||||
# Number of PurchaseOrder objects should have decreased
|
# Number of PurchaseOrder objects should have decreased
|
||||||
self.assertEqual(PurchaseOrder.objects.count(), n)
|
self.assertEqual(models.PurchaseOrder.objects.count(), n)
|
||||||
|
|
||||||
# And if we try to access the detail view again, it has gone
|
# And if we try to access the detail view again, it has gone
|
||||||
response = self.get(url, expected_code=404)
|
response = self.get(url, expected_code=404)
|
||||||
@ -237,7 +238,7 @@ class PurchaseOrderReceiveTest(OrderTest):
|
|||||||
self.n = StockItem.objects.count()
|
self.n = StockItem.objects.count()
|
||||||
|
|
||||||
# Mark the order as "placed" so we can receive line items
|
# Mark the order as "placed" so we can receive line items
|
||||||
order = PurchaseOrder.objects.get(pk=1)
|
order = models.PurchaseOrder.objects.get(pk=1)
|
||||||
order.status = PurchaseOrderStatus.PLACED
|
order.status = PurchaseOrderStatus.PLACED
|
||||||
order.save()
|
order.save()
|
||||||
|
|
||||||
@ -409,8 +410,8 @@ class PurchaseOrderReceiveTest(OrderTest):
|
|||||||
Test receipt of valid data
|
Test receipt of valid data
|
||||||
"""
|
"""
|
||||||
|
|
||||||
line_1 = PurchaseOrderLineItem.objects.get(pk=1)
|
line_1 = models.PurchaseOrderLineItem.objects.get(pk=1)
|
||||||
line_2 = PurchaseOrderLineItem.objects.get(pk=2)
|
line_2 = models.PurchaseOrderLineItem.objects.get(pk=2)
|
||||||
|
|
||||||
self.assertEqual(StockItem.objects.filter(supplier_part=line_1.part).count(), 0)
|
self.assertEqual(StockItem.objects.filter(supplier_part=line_1.part).count(), 0)
|
||||||
self.assertEqual(StockItem.objects.filter(supplier_part=line_2.part).count(), 0)
|
self.assertEqual(StockItem.objects.filter(supplier_part=line_2.part).count(), 0)
|
||||||
@ -437,7 +438,7 @@ class PurchaseOrderReceiveTest(OrderTest):
|
|||||||
|
|
||||||
# Before posting "valid" data, we will mark the purchase order as "pending"
|
# Before posting "valid" data, we will mark the purchase order as "pending"
|
||||||
# In this case we do expect an error!
|
# In this case we do expect an error!
|
||||||
order = PurchaseOrder.objects.get(pk=1)
|
order = models.PurchaseOrder.objects.get(pk=1)
|
||||||
order.status = PurchaseOrderStatus.PENDING
|
order.status = PurchaseOrderStatus.PENDING
|
||||||
order.save()
|
order.save()
|
||||||
|
|
||||||
@ -463,8 +464,8 @@ class PurchaseOrderReceiveTest(OrderTest):
|
|||||||
# There should be two newly created stock items
|
# There should be two newly created stock items
|
||||||
self.assertEqual(self.n + 2, StockItem.objects.count())
|
self.assertEqual(self.n + 2, StockItem.objects.count())
|
||||||
|
|
||||||
line_1 = PurchaseOrderLineItem.objects.get(pk=1)
|
line_1 = models.PurchaseOrderLineItem.objects.get(pk=1)
|
||||||
line_2 = PurchaseOrderLineItem.objects.get(pk=2)
|
line_2 = models.PurchaseOrderLineItem.objects.get(pk=2)
|
||||||
|
|
||||||
self.assertEqual(line_1.received, 50)
|
self.assertEqual(line_1.received, 50)
|
||||||
self.assertEqual(line_2.received, 250)
|
self.assertEqual(line_2.received, 250)
|
||||||
@ -519,7 +520,7 @@ class SalesOrderTest(OrderTest):
|
|||||||
self.filter({'overdue': False}, 5)
|
self.filter({'overdue': False}, 5)
|
||||||
|
|
||||||
for pk in [1, 2]:
|
for pk in [1, 2]:
|
||||||
order = SalesOrder.objects.get(pk=pk)
|
order = models.SalesOrder.objects.get(pk=pk)
|
||||||
order.target_date = datetime.now().date() - timedelta(days=10)
|
order.target_date = datetime.now().date() - timedelta(days=10)
|
||||||
order.save()
|
order.save()
|
||||||
|
|
||||||
@ -547,7 +548,7 @@ class SalesOrderTest(OrderTest):
|
|||||||
Test that we can create / edit and delete a SalesOrder via the API
|
Test that we can create / edit and delete a SalesOrder via the API
|
||||||
"""
|
"""
|
||||||
|
|
||||||
n = SalesOrder.objects.count()
|
n = models.SalesOrder.objects.count()
|
||||||
|
|
||||||
url = reverse('api-so-list')
|
url = reverse('api-so-list')
|
||||||
|
|
||||||
@ -577,7 +578,7 @@ class SalesOrderTest(OrderTest):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Check that the new order has been created
|
# Check that the new order has been created
|
||||||
self.assertEqual(SalesOrder.objects.count(), n + 1)
|
self.assertEqual(models.SalesOrder.objects.count(), n + 1)
|
||||||
|
|
||||||
# Grab the PK for the newly created SalesOrder
|
# Grab the PK for the newly created SalesOrder
|
||||||
pk = response.data['pk']
|
pk = response.data['pk']
|
||||||
@ -620,7 +621,7 @@ class SalesOrderTest(OrderTest):
|
|||||||
response = self.delete(url, expected_code=204)
|
response = self.delete(url, expected_code=204)
|
||||||
|
|
||||||
# Check that the number of sales orders has decreased
|
# Check that the number of sales orders has decreased
|
||||||
self.assertEqual(SalesOrder.objects.count(), n)
|
self.assertEqual(models.SalesOrder.objects.count(), n)
|
||||||
|
|
||||||
# And the resource should no longer be available
|
# And the resource should no longer be available
|
||||||
response = self.get(url, expected_code=404)
|
response = self.get(url, expected_code=404)
|
||||||
@ -641,3 +642,131 @@ class SalesOrderTest(OrderTest):
|
|||||||
},
|
},
|
||||||
expected_code=201
|
expected_code=201
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SalesOrderAllocateTest(OrderTest):
|
||||||
|
"""
|
||||||
|
Unit tests for allocating stock items against a SalesOrder
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
self.assignRole('sales_order.add')
|
||||||
|
|
||||||
|
self.url = reverse('api-so-allocate', kwargs={'pk': 1})
|
||||||
|
|
||||||
|
self.order = models.SalesOrder.objects.get(pk=1)
|
||||||
|
|
||||||
|
# Create some line items for this purchase order
|
||||||
|
parts = Part.objects.filter(salable=True)
|
||||||
|
|
||||||
|
for part in parts:
|
||||||
|
|
||||||
|
# Create a new line item
|
||||||
|
models.SalesOrderLineItem.objects.create(
|
||||||
|
order=self.order,
|
||||||
|
part=part,
|
||||||
|
quantity=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure we have stock!
|
||||||
|
StockItem.objects.create(
|
||||||
|
part=part,
|
||||||
|
quantity=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a new shipment against this SalesOrder
|
||||||
|
self.shipment = models.SalesOrderShipment.objects.create(
|
||||||
|
order=self.order,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_invalid(self):
|
||||||
|
"""
|
||||||
|
Test POST with invalid data
|
||||||
|
"""
|
||||||
|
|
||||||
|
# No data
|
||||||
|
response = self.post(self.url, {}, expected_code=400)
|
||||||
|
|
||||||
|
self.assertIn('This field is required', str(response.data['items']))
|
||||||
|
self.assertIn('This field is required', str(response.data['shipment']))
|
||||||
|
|
||||||
|
# Test with a single line items
|
||||||
|
line = self.order.lines.first()
|
||||||
|
part = line.part
|
||||||
|
|
||||||
|
# Valid stock_item, but quantity is invalid
|
||||||
|
data = {
|
||||||
|
'items': [{
|
||||||
|
"line_item": line.pk,
|
||||||
|
"stock_item": part.stock_items.last().pk,
|
||||||
|
"quantity": 0,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.post(self.url, data, expected_code=400)
|
||||||
|
|
||||||
|
self.assertIn('Quantity must be positive', str(response.data['items']))
|
||||||
|
|
||||||
|
# Valid stock item, too much quantity
|
||||||
|
data['items'][0]['quantity'] = 250
|
||||||
|
|
||||||
|
response = self.post(self.url, data, expected_code=400)
|
||||||
|
|
||||||
|
self.assertIn('Available quantity (100) exceeded', str(response.data['items']))
|
||||||
|
|
||||||
|
# Valid stock item, valid quantity
|
||||||
|
data['items'][0]['quantity'] = 50
|
||||||
|
|
||||||
|
# Invalid shipment!
|
||||||
|
data['shipment'] = 9999
|
||||||
|
|
||||||
|
response = self.post(self.url, data, expected_code=400)
|
||||||
|
|
||||||
|
self.assertIn('does not exist', str(response.data['shipment']))
|
||||||
|
|
||||||
|
# Valid shipment, but points to the wrong order
|
||||||
|
shipment = models.SalesOrderShipment.objects.create(
|
||||||
|
order=models.SalesOrder.objects.get(pk=2),
|
||||||
|
)
|
||||||
|
|
||||||
|
data['shipment'] = shipment.pk
|
||||||
|
|
||||||
|
response = self.post(self.url, data, expected_code=400)
|
||||||
|
|
||||||
|
self.assertIn('Shipment is not associated with this order', str(response.data['shipment']))
|
||||||
|
|
||||||
|
def test_allocate(self):
|
||||||
|
"""
|
||||||
|
Test the the allocation endpoint acts as expected,
|
||||||
|
when provided with valid data!
|
||||||
|
"""
|
||||||
|
|
||||||
|
# First, check that there are no line items allocated against this SalesOrder
|
||||||
|
self.assertEqual(self.order.stock_allocations.count(), 0)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"items": [],
|
||||||
|
"shipment": self.shipment.pk,
|
||||||
|
}
|
||||||
|
|
||||||
|
for line in self.order.lines.all():
|
||||||
|
stock_item = line.part.stock_items.last()
|
||||||
|
|
||||||
|
# Fully-allocate each line
|
||||||
|
data['items'].append({
|
||||||
|
"line_item": line.pk,
|
||||||
|
"stock_item": stock_item.pk,
|
||||||
|
"quantity": 5
|
||||||
|
})
|
||||||
|
|
||||||
|
self.post(self.url, data, expected_code=201)
|
||||||
|
|
||||||
|
# There should have been 1 stock item allocated against each line item
|
||||||
|
n_lines = self.order.lines.count()
|
||||||
|
|
||||||
|
self.assertEqual(self.order.stock_allocations.count(), n_lines)
|
||||||
|
|
||||||
|
for line in self.order.lines.all():
|
||||||
|
self.assertEqual(line.allocations.count(), 1)
|
||||||
|
@ -69,6 +69,7 @@
|
|||||||
name: 'Widget'
|
name: 'Widget'
|
||||||
description: 'A watchamacallit'
|
description: 'A watchamacallit'
|
||||||
category: 7
|
category: 7
|
||||||
|
salable: true
|
||||||
assembly: true
|
assembly: true
|
||||||
trackable: true
|
trackable: true
|
||||||
tree_id: 0
|
tree_id: 0
|
||||||
@ -83,6 +84,7 @@
|
|||||||
name: 'Orphan'
|
name: 'Orphan'
|
||||||
description: 'A part without a category'
|
description: 'A part without a category'
|
||||||
category: null
|
category: null
|
||||||
|
salable: true
|
||||||
tree_id: 0
|
tree_id: 0
|
||||||
level: 0
|
level: 0
|
||||||
lft: 0
|
lft: 0
|
||||||
@ -95,6 +97,7 @@
|
|||||||
name: 'Bob'
|
name: 'Bob'
|
||||||
description: 'Can we build it?'
|
description: 'Can we build it?'
|
||||||
assembly: true
|
assembly: true
|
||||||
|
salable: true
|
||||||
purchaseable: false
|
purchaseable: false
|
||||||
category: 7
|
category: 7
|
||||||
active: False
|
active: False
|
||||||
@ -113,6 +116,7 @@
|
|||||||
description: 'A chair'
|
description: 'A chair'
|
||||||
is_template: True
|
is_template: True
|
||||||
trackable: true
|
trackable: true
|
||||||
|
salable: true
|
||||||
category: 7
|
category: 7
|
||||||
tree_id: 1
|
tree_id: 1
|
||||||
level: 0
|
level: 0
|
||||||
|
Loading…
Reference in New Issue
Block a user