diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index a18f58fb76..29257341b2 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -422,7 +422,7 @@ class BuildAllocationSerializer(serializers.Serializer): Validation """ - super().validate(data) + data = super().validate(data) items = data.get('items', []) diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index 954295ef8e..037224a855 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -13,25 +13,17 @@ from rest_framework import generics from rest_framework import filters, status from rest_framework.response import Response +from company.models import SupplierPart from InvenTree.filters import InvenTreeOrderingFilter from InvenTree.helpers import str2bool from InvenTree.api import AttachmentMixin from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus +import order.models as models +import order.serializers as serializers + 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): @@ -41,8 +33,8 @@ class POList(generics.ListCreateAPIView): - POST: Create a new PurchaseOrder object """ - queryset = PurchaseOrder.objects.all() - serializer_class = POSerializer + queryset = models.PurchaseOrder.objects.all() + serializer_class = serializers.POSerializer def create(self, request, *args, **kwargs): """ @@ -79,7 +71,7 @@ class POList(generics.ListCreateAPIView): 'lines', ) - queryset = POSerializer.annotate_queryset(queryset) + queryset = serializers.POSerializer.annotate_queryset(queryset) return queryset @@ -108,9 +100,9 @@ class POList(generics.ListCreateAPIView): overdue = str2bool(overdue) if overdue: - queryset = queryset.filter(PurchaseOrder.OVERDUE_FILTER) + queryset = queryset.filter(models.PurchaseOrder.OVERDUE_FILTER) else: - queryset = queryset.exclude(PurchaseOrder.OVERDUE_FILTER) + queryset = queryset.exclude(models.PurchaseOrder.OVERDUE_FILTER) # Special filtering for 'status' field status = params.get('status', None) @@ -144,7 +136,7 @@ class POList(generics.ListCreateAPIView): max_date = params.get('max_date', 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 @@ -184,8 +176,8 @@ class POList(generics.ListCreateAPIView): class PODetail(generics.RetrieveUpdateDestroyAPIView): """ API endpoint for detail view of a PurchaseOrder object """ - queryset = PurchaseOrder.objects.all() - serializer_class = POSerializer + queryset = models.PurchaseOrder.objects.all() + serializer_class = serializers.POSerializer def get_serializer(self, *args, **kwargs): @@ -208,7 +200,7 @@ class PODetail(generics.RetrieveUpdateDestroyAPIView): 'lines', ) - queryset = POSerializer.annotate_queryset(queryset) + queryset = serializers.POSerializer.annotate_queryset(queryset) return queryset @@ -226,9 +218,9 @@ class POReceive(generics.CreateAPIView): - 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): @@ -236,7 +228,7 @@ class POReceive(generics.CreateAPIView): # Pass the purchase order through to the serializer for validation 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: pass @@ -251,7 +243,7 @@ class POLineItemFilter(rest_filters.FilterSet): """ class Meta: - model = PurchaseOrderLineItem + model = models.PurchaseOrderLineItem fields = [ 'order', 'part' @@ -285,15 +277,15 @@ class POLineItemList(generics.ListCreateAPIView): - POST: Create a new PurchaseOrderLineItem object """ - queryset = PurchaseOrderLineItem.objects.all() - serializer_class = POLineItemSerializer + queryset = models.PurchaseOrderLineItem.objects.all() + serializer_class = serializers.POLineItemSerializer filterset_class = POLineItemFilter def get_queryset(self, *args, **kwargs): queryset = super().get_queryset(*args, **kwargs) - queryset = POLineItemSerializer.annotate_queryset(queryset) + queryset = serializers.POLineItemSerializer.annotate_queryset(queryset) return queryset @@ -350,14 +342,14 @@ class POLineItemDetail(generics.RetrieveUpdateDestroyAPIView): Detail API endpoint for PurchaseOrderLineItem object """ - queryset = PurchaseOrderLineItem.objects.all() - serializer_class = POLineItemSerializer + queryset = models.PurchaseOrderLineItem.objects.all() + serializer_class = serializers.POLineItemSerializer def get_queryset(self): queryset = super().get_queryset() - queryset = POLineItemSerializer.annotate_queryset(queryset) + queryset = serializers.POLineItemSerializer.annotate_queryset(queryset) return queryset @@ -367,8 +359,8 @@ class SOAttachmentList(generics.ListCreateAPIView, AttachmentMixin): API endpoint for listing (and creating) a SalesOrderAttachment (file upload) """ - queryset = SalesOrderAttachment.objects.all() - serializer_class = SOAttachmentSerializer + queryset = models.SalesOrderAttachment.objects.all() + serializer_class = serializers.SOAttachmentSerializer filter_backends = [ rest_filters.DjangoFilterBackend, @@ -384,8 +376,8 @@ class SOAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin) Detail endpoint for SalesOrderAttachment """ - queryset = SalesOrderAttachment.objects.all() - serializer_class = SOAttachmentSerializer + queryset = models.SalesOrderAttachment.objects.all() + serializer_class = serializers.SOAttachmentSerializer class SOList(generics.ListCreateAPIView): @@ -396,8 +388,8 @@ class SOList(generics.ListCreateAPIView): - POST: Create a new SalesOrder """ - queryset = SalesOrder.objects.all() - serializer_class = SalesOrderSerializer + queryset = models.SalesOrder.objects.all() + serializer_class = serializers.SalesOrderSerializer def create(self, request, *args, **kwargs): """ @@ -434,7 +426,7 @@ class SOList(generics.ListCreateAPIView): 'lines' ) - queryset = SalesOrderSerializer.annotate_queryset(queryset) + queryset = serializers.SalesOrderSerializer.annotate_queryset(queryset) return queryset @@ -454,9 +446,9 @@ class SOList(generics.ListCreateAPIView): outstanding = str2bool(outstanding) if outstanding: - queryset = queryset.filter(status__in=SalesOrderStatus.OPEN) + queryset = queryset.filter(status__in=models.SalesOrderStatus.OPEN) else: - queryset = queryset.exclude(status__in=SalesOrderStatus.OPEN) + queryset = queryset.exclude(status__in=models.SalesOrderStatus.OPEN) # Filter by 'overdue' status overdue = params.get('overdue', None) @@ -465,9 +457,9 @@ class SOList(generics.ListCreateAPIView): overdue = str2bool(overdue) if overdue: - queryset = queryset.filter(SalesOrder.OVERDUE_FILTER) + queryset = queryset.filter(models.SalesOrder.OVERDUE_FILTER) else: - queryset = queryset.exclude(SalesOrder.OVERDUE_FILTER) + queryset = queryset.exclude(models.SalesOrder.OVERDUE_FILTER) status = params.get('status', None) @@ -490,7 +482,7 @@ class SOList(generics.ListCreateAPIView): max_date = params.get('max_date', 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 @@ -534,8 +526,8 @@ class SODetail(generics.RetrieveUpdateDestroyAPIView): API endpoint for detail view of a SalesOrder object. """ - queryset = SalesOrder.objects.all() - serializer_class = SalesOrderSerializer + queryset = models.SalesOrder.objects.all() + serializer_class = serializers.SalesOrderSerializer def get_serializer(self, *args, **kwargs): @@ -554,7 +546,7 @@ class SODetail(generics.RetrieveUpdateDestroyAPIView): queryset = queryset.prefetch_related('customer', 'lines') - queryset = SalesOrderSerializer.annotate_queryset(queryset) + queryset = serializers.SalesOrderSerializer.annotate_queryset(queryset) return queryset @@ -564,8 +556,8 @@ class SOLineItemList(generics.ListCreateAPIView): API endpoint for accessing a list of SalesOrderLineItem objects. """ - queryset = SalesOrderLineItem.objects.all() - serializer_class = SOLineItemSerializer + queryset = models.SalesOrderLineItem.objects.all() + serializer_class = serializers.SOLineItemSerializer def get_serializer(self, *args, **kwargs): @@ -624,8 +616,34 @@ class SOLineItemList(generics.ListCreateAPIView): class SOLineItemDetail(generics.RetrieveUpdateDestroyAPIView): """ API endpoint for detail view of a SalesOrderLineItem object """ - queryset = SalesOrderLineItem.objects.all() - serializer_class = SOLineItemSerializer + queryset = models.SalesOrderLineItem.objects.all() + 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): @@ -633,17 +651,17 @@ class SOAllocationDetail(generics.RetrieveUpdateDestroyAPIView): API endpoint for detali view of a SalesOrderAllocation object """ - queryset = SalesOrderAllocation.objects.all() - serializer_class = SalesOrderAllocationSerializer + queryset = models.SalesOrderAllocation.objects.all() + serializer_class = serializers.SalesOrderAllocationSerializer -class SOAllocationList(generics.ListCreateAPIView): +class SOAllocationList(generics.ListAPIView): """ API endpoint for listing SalesOrderAllocation objects """ - queryset = SalesOrderAllocation.objects.all() - serializer_class = SalesOrderAllocationSerializer + queryset = models.SalesOrderAllocation.objects.all() + serializer_class = serializers.SalesOrderAllocationSerializer def get_serializer(self, *args, **kwargs): @@ -720,7 +738,7 @@ class SOShipmentFilter(rest_filters.FilterSet): return queryset class Meta: - model = SalesOrderShipment + model = models.SalesOrderShipment fields = [ 'order', ] @@ -731,8 +749,8 @@ class SOShipmentList(generics.ListCreateAPIView): API list endpoint for SalesOrderShipment model """ - queryset = SalesOrderShipment.objects.all() - serializer_class = SalesOrderShipmentSerializer + queryset = models.SalesOrderShipment.objects.all() + serializer_class = serializers.SalesOrderShipmentSerializer filterset_class = SOShipmentFilter filter_backends = [ @@ -745,8 +763,8 @@ class SOShipmentDetail(generics.RetrieveUpdateAPIView): API detail endpooint for SalesOrderShipment model """ - queryset = SalesOrderShipment.objects.all() - serializer_class = SalesOrderShipmentSerializer + queryset = models.SalesOrderShipment.objects.all() + serializer_class = serializers.SalesOrderShipmentSerializer class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin): @@ -754,8 +772,8 @@ class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin): API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload) """ - queryset = PurchaseOrderAttachment.objects.all() - serializer_class = POAttachmentSerializer + queryset = models.PurchaseOrderAttachment.objects.all() + serializer_class = serializers.POAttachmentSerializer filter_backends = [ rest_filters.DjangoFilterBackend, @@ -771,8 +789,8 @@ class POAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin) Detail endpoint for a PurchaseOrderAttachment """ - queryset = PurchaseOrderAttachment.objects.all() - serializer_class = POAttachmentSerializer + queryset = models.PurchaseOrderAttachment.objects.all() + serializer_class = serializers.POAttachmentSerializer order_api_urls = [ @@ -816,7 +834,13 @@ order_api_urls = [ url(r'^.*$', SOShipmentList.as_view(), name='api-so-shipment-list'), ])), - url(r'^(?P\d+)/$', SODetail.as_view(), name='api-so-detail'), + # Sales order detail view + url(r'^(?P\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'), ])), diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index b0b0d82221..05eabe3788 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -567,6 +567,16 @@ class SalesOrder(Order): def is_pending(self): 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): """ Return True if all line items are fully allocated """ @@ -910,6 +920,10 @@ class SalesOrderShipment(models.Model): notes: Custom notes field for this shipment """ + @staticmethod + def get_api_url(): + return reverse('api-so-shipment-list') + order = models.ForeignKey( SalesOrder, on_delete=models.CASCADE, @@ -1014,10 +1028,9 @@ class SalesOrderAllocation(models.Model): if self.item.serial and not self.quantity == 1: errors['quantity'] = _('Quantity must be 1 for serialized stock item') - - - # TODO: Ensure that the "shipment" points to the same "order"! - + if self.line.order != self.shipment.order: + errors['line'] = _('Sales order does not match shipment') + errors['shipment'] = _('Shipment does not match sales order') if len(errors) > 0: raise ValidationError(errors) @@ -1026,7 +1039,8 @@ class SalesOrderAllocation(models.Model): SalesOrderLineItem, on_delete=models.CASCADE, verbose_name=_('Line'), - related_name='allocations') + related_name='allocations' + ) shipment = models.ForeignKey( SalesOrderShipment, diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 70bef86ae4..34032d758b 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -17,30 +17,29 @@ from rest_framework.serializers import ValidationError 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 InvenTreeAttachmentSerializer from InvenTree.serializers import InvenTreeMoneySerializer from InvenTree.serializers import InvenTreeAttachmentSerializerField - from InvenTree.status_codes import StockStatus -from company.serializers import CompanyBriefSerializer, SupplierPartSerializer +import order.models from part.serializers import PartBriefSerializer import stock.models -from stock.serializers import LocationBriefSerializer, StockItemSerializer, LocationSerializer - -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 +import stock.serializers class POSerializer(InvenTreeModelSerializer): - """ Serializer for a PurchaseOrder object """ + """ + Serializer for a PurchaseOrder object + """ def __init__(self, *args, **kwargs): @@ -67,7 +66,7 @@ class POSerializer(InvenTreeModelSerializer): queryset = queryset.annotate( overdue=Case( 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()) ) @@ -86,7 +85,7 @@ class POSerializer(InvenTreeModelSerializer): reference = serializers.CharField(required=True) class Meta: - model = PurchaseOrder + model = order.models.PurchaseOrder fields = [ 'pk', @@ -160,7 +159,7 @@ class POLineItemSerializer(InvenTreeModelSerializer): 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( choices=currency_code_mappings(), @@ -168,7 +167,7 @@ class POLineItemSerializer(InvenTreeModelSerializer): ) class Meta: - model = PurchaseOrderLineItem + model = order.models.PurchaseOrderLineItem fields = [ 'pk', @@ -195,7 +194,7 @@ class POLineItemReceiveSerializer(serializers.Serializer): """ line_item = serializers.PrimaryKeyRelatedField( - queryset=PurchaseOrderLineItem.objects.all(), + queryset=order.models.PurchaseOrderLineItem.objects.all(), many=False, allow_null=False, required=True, @@ -376,7 +375,7 @@ class POAttachmentSerializer(InvenTreeAttachmentSerializer): attachment = InvenTreeAttachmentSerializerField(required=True) class Meta: - model = PurchaseOrderAttachment + model = order.models.PurchaseOrderAttachment fields = [ 'pk', @@ -422,7 +421,7 @@ class SalesOrderSerializer(InvenTreeModelSerializer): queryset = queryset.annotate( overdue=Case( 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()) ) @@ -441,7 +440,7 @@ class SalesOrderSerializer(InvenTreeModelSerializer): reference = serializers.CharField(required=True) class Meta: - model = SalesOrder + model = order.models.SalesOrder fields = [ 'pk', @@ -484,8 +483,8 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer): # Extra detail fields order_detail = SalesOrderSerializer(source='line.order', 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) - location_detail = LocationSerializer(source='item.location', many=False, read_only=True) + item_detail = stock.serializers.StockItemSerializer(source='item', many=False, read_only=True) + location_detail = stock.serializers.LocationSerializer(source='item.location', many=False, read_only=True) def __init__(self, *args, **kwargs): @@ -509,7 +508,7 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer): self.fields.pop('location_detail') class Meta: - model = SalesOrderAllocation + model = order.models.SalesOrderAllocation fields = [ 'pk', @@ -570,7 +569,7 @@ class SOLineItemSerializer(InvenTreeModelSerializer): ) class Meta: - model = SalesOrderLineItem + model = order.models.SalesOrderLineItem fields = [ 'pk', @@ -598,7 +597,7 @@ class SalesOrderShipmentSerializer(InvenTreeModelSerializer): allocations = SalesOrderAllocationSerializer(many=True, read_only=True, location_detail=True) class Meta: - model = SalesOrderShipment + model = order.models.SalesOrderShipment fields = [ '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): """ Serializers for the SalesOrderAttachment model @@ -619,7 +772,7 @@ class SOAttachmentSerializer(InvenTreeAttachmentSerializer): attachment = InvenTreeAttachmentSerializerField(required=True) class Meta: - model = SalesOrderAttachment + model = order.models.SalesOrderAttachment fields = [ 'pk', diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py index 899fa9a6fc..0f29e5bd16 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -11,9 +11,10 @@ from django.urls import reverse from InvenTree.api_tester import InvenTreeAPITestCase from InvenTree.status_codes import PurchaseOrderStatus +from part.models import Part from stock.models import StockItem -from .models import PurchaseOrder, PurchaseOrderLineItem, SalesOrder +import order.models as models class OrderTest(InvenTreeAPITestCase): @@ -85,7 +86,7 @@ class PurchaseOrderTest(OrderTest): self.filter({'overdue': True}, 0) 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.save() @@ -118,7 +119,7 @@ class PurchaseOrderTest(OrderTest): 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') @@ -135,7 +136,7 @@ class PurchaseOrderTest(OrderTest): ) # 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 self.assignRole('purchase_order.add') @@ -152,7 +153,7 @@ class PurchaseOrderTest(OrderTest): expected_code=201 ) - self.assertEqual(PurchaseOrder.objects.count(), n + 1) + self.assertEqual(models.PurchaseOrder.objects.count(), n + 1) pk = response.data['pk'] @@ -167,7 +168,7 @@ class PurchaseOrderTest(OrderTest): 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}) @@ -198,7 +199,7 @@ class PurchaseOrderTest(OrderTest): response = self.delete(url, expected_code=204) # 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 response = self.get(url, expected_code=404) @@ -237,7 +238,7 @@ class PurchaseOrderReceiveTest(OrderTest): self.n = StockItem.objects.count() # 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.save() @@ -409,8 +410,8 @@ class PurchaseOrderReceiveTest(OrderTest): Test receipt of valid data """ - line_1 = PurchaseOrderLineItem.objects.get(pk=1) - line_2 = PurchaseOrderLineItem.objects.get(pk=2) + line_1 = models.PurchaseOrderLineItem.objects.get(pk=1) + 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_2.part).count(), 0) @@ -437,7 +438,7 @@ class PurchaseOrderReceiveTest(OrderTest): # Before posting "valid" data, we will mark the purchase order as "pending" # 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.save() @@ -463,8 +464,8 @@ class PurchaseOrderReceiveTest(OrderTest): # There should be two newly created stock items self.assertEqual(self.n + 2, StockItem.objects.count()) - line_1 = PurchaseOrderLineItem.objects.get(pk=1) - line_2 = PurchaseOrderLineItem.objects.get(pk=2) + line_1 = models.PurchaseOrderLineItem.objects.get(pk=1) + line_2 = models.PurchaseOrderLineItem.objects.get(pk=2) self.assertEqual(line_1.received, 50) self.assertEqual(line_2.received, 250) @@ -519,7 +520,7 @@ class SalesOrderTest(OrderTest): self.filter({'overdue': False}, 5) 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.save() @@ -547,7 +548,7 @@ class SalesOrderTest(OrderTest): 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') @@ -577,7 +578,7 @@ class SalesOrderTest(OrderTest): ) # 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 pk = response.data['pk'] @@ -620,7 +621,7 @@ class SalesOrderTest(OrderTest): response = self.delete(url, expected_code=204) # 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 response = self.get(url, expected_code=404) @@ -641,3 +642,131 @@ class SalesOrderTest(OrderTest): }, 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) diff --git a/InvenTree/part/fixtures/part.yaml b/InvenTree/part/fixtures/part.yaml index 3c24690efc..77e808fd7f 100644 --- a/InvenTree/part/fixtures/part.yaml +++ b/InvenTree/part/fixtures/part.yaml @@ -69,6 +69,7 @@ name: 'Widget' description: 'A watchamacallit' category: 7 + salable: true assembly: true trackable: true tree_id: 0 @@ -83,6 +84,7 @@ name: 'Orphan' description: 'A part without a category' category: null + salable: true tree_id: 0 level: 0 lft: 0 @@ -95,6 +97,7 @@ name: 'Bob' description: 'Can we build it?' assembly: true + salable: true purchaseable: false category: 7 active: False @@ -113,6 +116,7 @@ description: 'A chair' is_template: True trackable: true + salable: true category: 7 tree_id: 1 level: 0