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:
Oliver 2021-10-26 23:51:36 +11:00
parent dd5eeb7c61
commit bff9f0828a
6 changed files with 437 additions and 113 deletions

View File

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

View File

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

View File

@ -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,

View File

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

View File

@ -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)

View File

@ -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