From 28cc24135401a01776cb30c4b4ea6a1829148fb2 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 23 Aug 2021 23:13:07 +1000 Subject: [PATCH 01/18] Custom DRF serializers for receiving line items against a purchase order --- InvenTree/order/serializers.py | 72 ++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index e97d19250a..dd35eb3cb8 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -11,6 +11,8 @@ from django.db.models import Case, When, Value from django.db.models import BooleanField from rest_framework import serializers +from rest_framework.serializers import ValidationError + from sql_util.utils import SubqueryCount from InvenTree.serializers import InvenTreeModelSerializer @@ -18,8 +20,12 @@ from InvenTree.serializers import InvenTreeAttachmentSerializer from InvenTree.serializers import InvenTreeMoneySerializer from InvenTree.serializers import InvenTreeAttachmentSerializerField +import company.models from company.serializers import CompanyBriefSerializer, SupplierPartSerializer + from part.serializers import PartBriefSerializer + +import stock.models from stock.serializers import LocationBriefSerializer, StockItemSerializer, LocationSerializer from .models import PurchaseOrder, PurchaseOrderLineItem @@ -161,6 +167,72 @@ class POLineItemSerializer(InvenTreeModelSerializer): ] +class POLineItemReceiveSerializer(serializers.Serializer): + """ + A serializer for receiving a single purchase order line item against a purchase order + """ + + supplier_part = serializers.PrimaryKeyRelatedField( + queryset=company.models.SupplierPart.objects.all(), + many=False, + label=_('Supplier Part'), + ) + + location = serializers.PrimaryKeyRelatedField( + queryset=stock.models.StockLocation.objects.all(), + many=False, + allow_null=True, + label=_('Location'), + help_text=_('Select destination location for received items'), + ) + + class Meta: + fields = [ + 'supplier_part', + 'location', + ] + + +class POReceiveSerializer(serializers.Serializer): + """ + Serializer for receiving items against a purchase order + """ + + items = serializers.StringRelatedField( + many=True + ) + + location = serializers.PrimaryKeyRelatedField( + queryset=stock.models.StockLocation.objects.all(), + many=False, + allow_null=True, + label=_('Location'), + help_text=_('Select destination location for received items'), + ) + + def is_valid(self, raise_exception=False): + + super().is_valid(raise_exception) + + # Custom validation + data = self.validated_data + + items = data.get('items', []) + + if len(items) == 0: + raise ValidationError({ + 'items': _('Line items must be provided'), + }) + + return not bool(self._errors) + + class Meta: + fields = [ + 'items', + 'location', + ] + + class POAttachmentSerializer(InvenTreeAttachmentSerializer): """ Serializers for the PurchaseOrderAttachment model From dc53a433a7cd62a8aef6024bcb99b25aa02f0f55 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 23 Aug 2021 23:35:22 +1000 Subject: [PATCH 02/18] Fix serializer nesting - Add new API endpoint to receive items - Add unit testing --- InvenTree/order/api.py | 61 ++++++++++++++++++++++++++++--- InvenTree/order/serializers.py | 18 ++++++--- InvenTree/order/test_api.py | 67 ++++++++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 10 deletions(-) diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index a834989fd9..40819a3bff 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -11,6 +11,9 @@ from django_filters.rest_framework import DjangoFilterBackend from rest_framework import generics from rest_framework import filters, status from rest_framework.response import Response +from rest_framework.serializers import ValidationError + +from django.utils.translation import ugettext_lazy as _ from InvenTree.helpers import str2bool from InvenTree.api import AttachmentMixin @@ -27,6 +30,7 @@ from .models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation from .models import SalesOrderAttachment from .serializers import SalesOrderSerializer, SOLineItemSerializer, SOAttachmentSerializer from .serializers import SalesOrderAllocationSerializer +from .serializers import POReceiveSerializer, POLineItemReceiveSerializer class POList(generics.ListCreateAPIView): @@ -204,6 +208,41 @@ class PODetail(generics.RetrieveUpdateDestroyAPIView): return queryset +class POReceive(generics.CreateAPIView): + """ + API endpoint to receive stock items against a purchase order. + + - The purchase order is specified in the URL. + - Items to receive are specified as a list called "items" with the following options: + - supplier_part: pk value of the supplier part + - quantity: quantity to receive + - status: stock item status + - location: destination for stock item (optional) + - A global location can also be specified + """ + + queryset = PurchaseOrderLineItem.objects.none() + + serializer_class = POReceiveSerializer + + def get_order(self): + """ + Returns the PurchaseOrder associated with this API endpoint + """ + + order = PurchaseOrder.objects.get(pk=self.kwargs['pk']) + + return order + + def create(self, request, *args, **kwargs): + # Validate the serialized data + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + + class POLineItemList(generics.ListCreateAPIView): """ API endpoint for accessing a list of POLineItem objects @@ -616,13 +655,25 @@ class POAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin) order_api_urls = [ + # API endpoints for purchase orders - url(r'po/attachment/', include([ - url(r'^(?P\d+)/$', POAttachmentDetail.as_view(), name='api-po-attachment-detail'), - url(r'^.*$', POAttachmentList.as_view(), name='api-po-attachment-list'), + url(r'^po/', include([ + + # Purchase order attachments + url(r'attachment/', include([ + url(r'^(?P\d+)/$', POAttachmentDetail.as_view(), name='api-po-attachment-detail'), + url(r'^.*$', POAttachmentList.as_view(), name='api-po-attachment-list'), + ])), + + # Individual purchase order detail URLs + url(r'^(?P\d+)/', include([ + url(r'^receive/', POReceive.as_view(), name='api-po-receive'), + url(r'.*$', PODetail.as_view(), name='api-po-detail'), + ])), + + # Purchase order list + url(r'^.*$', POList.as_view(), name='api-po-list'), ])), - url(r'^po/(?P\d+)/$', PODetail.as_view(), name='api-po-detail'), - url(r'^po/.*$', POList.as_view(), name='api-po-list'), # API endpoints for purchase order line items url(r'^po-line/(?P\d+)/$', POLineItemDetail.as_view(), name='api-po-line-detail'), diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index dd35eb3cb8..f090faf214 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -186,6 +186,13 @@ class POLineItemReceiveSerializer(serializers.Serializer): help_text=_('Select destination location for received items'), ) + quantity = serializers.DecimalField( + max_digits=15, + decimal_places=5, + min_value=0, + required=True, + ) + class Meta: fields = [ 'supplier_part', @@ -198,8 +205,8 @@ class POReceiveSerializer(serializers.Serializer): Serializer for receiving items against a purchase order """ - items = serializers.StringRelatedField( - many=True + items = POLineItemReceiveSerializer( + many=True, ) location = serializers.PrimaryKeyRelatedField( @@ -220,9 +227,10 @@ class POReceiveSerializer(serializers.Serializer): items = data.get('items', []) if len(items) == 0: - raise ValidationError({ - 'items': _('Line items must be provided'), - }) + self._errors['items'] = _('Line items must be provided') + + if self._errors and raise_exception: + raise ValidationError(self.errors) return not bool(self._errors) diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py index 24ca8581d9..5ab5230d8e 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -201,6 +201,73 @@ class PurchaseOrderTest(OrderTest): response = self.get(url, expected_code=404) +class PurchaseOrderReceiveTest(OrderTest): + """ + Unit tests for receiving items against a PurchaseOrder + """ + + def setUp(self): + super().setUp() + + self.assignRole('purchase_order.add') + + self.url = reverse('api-po-receive', kwargs={'pk': 1}) + + def test_empty(self): + """ + Test without any POST data + """ + + data = self.post(self.url, {}, expected_code=400).data + + self.assertIn('This field is required', str(data['items'])) + self.assertIn('This field is required', str(data['location'])) + + def test_no_items(self): + """ + Test with an empty list of items + """ + + data = self.post( + self.url, + { + "items": [], + "location": None, + }, + expected_code=400 + ).data + + self.assertIn('Line items must be provided', str(data['items'])) + + def test_invalid_items(self): + """ + Test than errors are returned as expected for invalid data + """ + + data = self.post( + self.url, + { + "items": [ + { + "supplier_part": 12345, + "location": 12345 + } + ] + }, + expected_code=400 + ).data + + items = data['items'] + + self.assertIn('Invalid pk "12345"', str(items['supplier_part'])) + self.assertIn("object does not exist", str(items['location'])) + + def test_mismatched_items(self): + """ + Test for supplier parts which *do* exist but do not match the order supplier + """ + + class SalesOrderTest(OrderTest): """ Tests for the SalesOrder API From 6091f2ba337991ec9e2e60ede22d6e2a55b6de57 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 24 Aug 2021 00:29:38 +1000 Subject: [PATCH 03/18] Serializer improvements - Pass the "order" down to the nested serializers for validation --- InvenTree/order/api.py | 47 ++++++++++++++++++++++++++++++++++ InvenTree/order/serializers.py | 17 +++++++----- 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index 40819a3bff..84c5c2ed3c 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -225,6 +225,15 @@ class POReceive(generics.CreateAPIView): serializer_class = POReceiveSerializer + def get_serializer_context(self): + + context = super().get_serializer_context() + + # Pass the purchase order through to the serializer for validation + context['order'] = self.get_order() + + return context + def get_order(self): """ Returns the PurchaseOrder associated with this API endpoint @@ -235,13 +244,51 @@ class POReceive(generics.CreateAPIView): return order def create(self, request, *args, **kwargs): + + # Which purchase order are we receiving against? + self.order = self.get_order() + # Validate the serialized data serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) + # Check that the received line items are indeed correct + self.validate(serializer.validated_data) + headers = self.get_success_headers(serializer.data) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + def validate(self, data): + """ + Validate the deserialized data. + + At this point, much of the heavy lifting has been done for us by DRF serializers + """ + + location = data['location'] + + # Keep track of validated data "on the fly" + self.items = [] + + for item in data['items']: + + supplier_part = item['supplier_part'] + + # Location specified for this part + item_location = item['location'] + + if not item_location: + + # Both item_location and location are not specified + if not location: + raise ValidationError({ + 'location': _("Destination location must be specified"), + }) + + item['location'] = location + + quantity = item['quantity'] + class POLineItemList(generics.ListCreateAPIView): """ API endpoint for accessing a list of POLineItem objects diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index f090faf214..cc40c321dc 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -172,12 +172,19 @@ class POLineItemReceiveSerializer(serializers.Serializer): A serializer for receiving a single purchase order line item against a purchase order """ - supplier_part = serializers.PrimaryKeyRelatedField( - queryset=company.models.SupplierPart.objects.all(), + line_item = serializers.PrimaryKeyRelatedField( + queryset=PurchaseOrderLineItem.objects.all(), many=False, - label=_('Supplier Part'), + allow_null=False, + required=True, + label=_('Line Item'), ) + def validate_line_item(self, item): + + if item.order != self.context['order']: + raise ValidationError(_('Line item does not match purchase order')) + location = serializers.PrimaryKeyRelatedField( queryset=stock.models.StockLocation.objects.all(), many=False, @@ -205,9 +212,7 @@ class POReceiveSerializer(serializers.Serializer): Serializer for receiving items against a purchase order """ - items = POLineItemReceiveSerializer( - many=True, - ) + items = POLineItemReceiveSerializer(many=True) location = serializers.PrimaryKeyRelatedField( queryset=stock.models.StockLocation.objects.all(), From 2aa505b2cbc23ed68084817a4e91e15b753b006e Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 24 Aug 2021 08:18:59 +1000 Subject: [PATCH 04/18] Fix unit tests to match new API format --- InvenTree/order/test_api.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py index 5ab5230d8e..6db4cbc47e 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -249,7 +249,7 @@ class PurchaseOrderReceiveTest(OrderTest): { "items": [ { - "supplier_part": 12345, + "line_item": 12345, "location": 12345 } ] @@ -257,9 +257,9 @@ class PurchaseOrderReceiveTest(OrderTest): expected_code=400 ).data - items = data['items'] + items = data['items'][0] - self.assertIn('Invalid pk "12345"', str(items['supplier_part'])) + self.assertIn('Invalid pk "12345"', str(items['line_item'])) self.assertIn("object does not exist", str(items['location'])) def test_mismatched_items(self): @@ -267,6 +267,23 @@ class PurchaseOrderReceiveTest(OrderTest): Test for supplier parts which *do* exist but do not match the order supplier """ + data = self.post( + self.url, + { + 'items': [ + { + 'line_item': 22, + 'quantity': 123, + 'location': 1, + } + ], + 'location': None, + }, + expected_code=400 + ).data + + self.assertIn('Line item does not match purchase order', str(data)) + class SalesOrderTest(OrderTest): """ From d30173132acedec6651de89f35212e46d105497c Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 24 Aug 2021 08:49:23 +1000 Subject: [PATCH 05/18] Actually receive items --- InvenTree/order/api.py | 38 ++++++++++++++++++++++--------- InvenTree/order/models.py | 2 +- InvenTree/order/serializers.py | 9 ++++++++ InvenTree/order/test_api.py | 41 ++++++++++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 11 deletions(-) diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index 84c5c2ed3c..648a4f4417 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -5,7 +5,9 @@ JSON API for the Order app # -*- coding: utf-8 -*- from __future__ import unicode_literals +from django.utils.translation import ugettext_lazy as _ from django.conf.urls import url, include +from django.db import transaction from django_filters.rest_framework import DjangoFilterBackend from rest_framework import generics @@ -13,7 +15,6 @@ from rest_framework import filters, status from rest_framework.response import Response from rest_framework.serializers import ValidationError -from django.utils.translation import ugettext_lazy as _ from InvenTree.helpers import str2bool from InvenTree.api import AttachmentMixin @@ -252,25 +253,34 @@ class POReceive(generics.CreateAPIView): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - # Check that the received line items are indeed correct - self.validate(serializer.validated_data) + # Receive the line items + self.receive_items(serializer) headers = self.get_success_headers(serializer.data) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) - def validate(self, data): + @transaction.atomic + def receive_items(self, serializer): """ - Validate the deserialized data. + Receive the items - At this point, much of the heavy lifting has been done for us by DRF serializers + At this point, much of the heavy lifting has been done for us by DRF serializers! + + We have a list of "items", each a dict which contains: + - line_item: A PurchaseOrderLineItem matching this order + - location: A destination location + - quantity: A validated numerical quantity + - status: The status code for the received item """ + data = serializer.validated_data + location = data['location'] - # Keep track of validated data "on the fly" - self.items = [] + items = data['items'] - for item in data['items']: + # Check if the location is not specified for any particular item + for item in items: supplier_part = item['supplier_part'] @@ -287,7 +297,15 @@ class POReceive(generics.CreateAPIView): item['location'] = location - quantity = item['quantity'] + # Now we can actually receive the items + for item in items: + self.order.receive_line_item( + item['line_item'], + item['location'], + item['quantity'], + self.request.user, + status=item['status'], + ) class POLineItemList(generics.ListCreateAPIView): diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index e55f5203ba..35982349d5 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -433,7 +433,7 @@ class PurchaseOrder(Order): quantity=quantity, purchase_order=self, status=status, - purchase_price=purchase_price, + purchase_price=line.purchase_price, ) stock.save(add_note=False) diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index cc40c321dc..2b3da3f87d 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -20,6 +20,8 @@ from InvenTree.serializers import InvenTreeAttachmentSerializer from InvenTree.serializers import InvenTreeMoneySerializer from InvenTree.serializers import InvenTreeAttachmentSerializerField +from InvenTree.status_codes import StockStatus + import company.models from company.serializers import CompanyBriefSerializer, SupplierPartSerializer @@ -200,10 +202,17 @@ class POLineItemReceiveSerializer(serializers.Serializer): required=True, ) + status = serializers.ChoiceField( + choices=StockStatus.options, + default=StockStatus.OK, + label=_('Status'), + ) + class Meta: fields = [ 'supplier_part', 'location', + 'status', ] diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py index 6db4cbc47e..0074442930 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -2,6 +2,7 @@ Tests for the Order API """ +from InvenTree.stock.models import StockItem from datetime import datetime, timedelta from rest_framework import status @@ -213,6 +214,9 @@ class PurchaseOrderReceiveTest(OrderTest): self.url = reverse('api-po-receive', kwargs={'pk': 1}) + # Number of stock items which exist at the start of each test + self.n = StockItem.objects.count() + def test_empty(self): """ Test without any POST data @@ -223,6 +227,9 @@ class PurchaseOrderReceiveTest(OrderTest): self.assertIn('This field is required', str(data['items'])) self.assertIn('This field is required', str(data['location'])) + # No new stock items have been created + self.assertEqual(self.n, StockItem.objects.count()) + def test_no_items(self): """ Test with an empty list of items @@ -239,6 +246,9 @@ class PurchaseOrderReceiveTest(OrderTest): self.assertIn('Line items must be provided', str(data['items'])) + # No new stock items have been created + self.assertEqual(self.n, StockItem.objects.count()) + def test_invalid_items(self): """ Test than errors are returned as expected for invalid data @@ -262,6 +272,34 @@ class PurchaseOrderReceiveTest(OrderTest): self.assertIn('Invalid pk "12345"', str(items['line_item'])) self.assertIn("object does not exist", str(items['location'])) + # No new stock items have been created + self.assertEqual(self.n, StockItem.objects.count()) + + def test_invalid_status(self): + """ + Test with an invalid StockStatus value + """ + + data = self.post( + self.url, + { + "items": [ + { + "line_item": 22, + "location": 1, + "status": 99999, + "quantity": 5, + } + ] + }, + expected_code=400 + ).data + + self.assertIn('"99999" is not a valid choice.', str(data)) + + # No new stock items have been created + self.assertEqual(self.n, StockItem.objects.count()) + def test_mismatched_items(self): """ Test for supplier parts which *do* exist but do not match the order supplier @@ -284,6 +322,9 @@ class PurchaseOrderReceiveTest(OrderTest): self.assertIn('Line item does not match purchase order', str(data)) + # No new stock items have been created + self.assertEqual(self.n, StockItem.objects.count()) + class SalesOrderTest(OrderTest): """ From 1b65fbad2c3f6d074b2704ca9e94e4ab64a637cd Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 24 Aug 2021 11:42:08 +1000 Subject: [PATCH 06/18] Update unit tests - Found some bugs too, thanks unit tests! --- InvenTree/order/api.py | 33 ++++++++++-------- InvenTree/order/serializers.py | 6 +++- InvenTree/order/test_api.py | 64 ++++++++++++++++++++++++++++++++-- 3 files changed, 86 insertions(+), 17 deletions(-) diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index 648a4f4417..f2b3879e30 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -240,9 +240,13 @@ class POReceive(generics.CreateAPIView): Returns the PurchaseOrder associated with this API endpoint """ - order = PurchaseOrder.objects.get(pk=self.kwargs['pk']) + pk = self.kwargs.get('pk', None) - return order + if pk is None: + return None + else: + order = PurchaseOrder.objects.get(pk=self.kwargs['pk']) + return order def create(self, request, *args, **kwargs): @@ -282,23 +286,24 @@ class POReceive(generics.CreateAPIView): # Check if the location is not specified for any particular item for item in items: - supplier_part = item['supplier_part'] - - # Location specified for this part - item_location = item['location'] - - if not item_location: - - # Both item_location and location are not specified - if not location: - raise ValidationError({ - 'location': _("Destination location must be specified"), - }) + line = item['line_item'] + if not item.get('location', None): + # If a global location is specified, use that item['location'] = location + if not item['location']: + # The line item specifies a location? + item['location'] = line.get_destination() + + if not item['location']: + raise ValidationError({ + 'location': _("Destination location must be specified"), + }) + # Now we can actually receive the items for item in items: + self.order.receive_line_item( item['line_item'], item['location'], diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 2b3da3f87d..881a0940c4 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -187,10 +187,13 @@ class POLineItemReceiveSerializer(serializers.Serializer): if item.order != self.context['order']: raise ValidationError(_('Line item does not match purchase order')) + return item + location = serializers.PrimaryKeyRelatedField( queryset=stock.models.StockLocation.objects.all(), many=False, allow_null=True, + required=False, label=_('Location'), help_text=_('Select destination location for received items'), ) @@ -210,8 +213,9 @@ class POLineItemReceiveSerializer(serializers.Serializer): class Meta: fields = [ - 'supplier_part', + 'line_item', 'location', + 'quantity', 'status', ] diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py index 0074442930..26f1249cf4 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -2,7 +2,6 @@ Tests for the Order API """ -from InvenTree.stock.models import StockItem from datetime import datetime, timedelta from rest_framework import status @@ -10,8 +9,11 @@ from rest_framework import status from django.urls import reverse from InvenTree.api_tester import InvenTreeAPITestCase +from InvenTree.status_codes import PurchaseOrderStatus -from .models import PurchaseOrder, SalesOrder +from stock.models import StockItem + +from .models import PurchaseOrder, PurchaseOrderLineItem, SalesOrder class OrderTest(InvenTreeAPITestCase): @@ -217,6 +219,11 @@ class PurchaseOrderReceiveTest(OrderTest): # Number of stock items which exist at the start of each test self.n = StockItem.objects.count() + # Mark the order as "placed" so we can receive line items + order = PurchaseOrder.objects.get(pk=1) + order.status = PurchaseOrderStatus.PLACED + order.save() + def test_empty(self): """ Test without any POST data @@ -325,6 +332,59 @@ class PurchaseOrderReceiveTest(OrderTest): # No new stock items have been created self.assertEqual(self.n, StockItem.objects.count()) + def test_valid(self): + """ + Test receipt of valid data + """ + + line_1 = PurchaseOrderLineItem.objects.get(pk=1) + line_2 = 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) + + self.assertEqual(line_1.received, 0) + self.assertEqual(line_2.received, 50) + + # Receive two separate line items against this order + data = self.post( + self.url, + { + 'items': [ + { + 'line_item': 1, + 'quantity': 50, + }, + { + 'line_item': 2, + 'quantity': 200, + 'location': 2, # Explicit location + } + ], + 'location': 1, # Default location + }, + ).data + + # 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) + + self.assertEqual(line_1.received, 50) + self.assertEqual(line_2.received, 250) + + stock_1 = StockItem.objects.filter(supplier_part=line_1.part) + stock_2 = StockItem.objects.filter(supplier_part=line_2.part) + + # 1 new stock item created for each supplier part + self.assertEqual(stock_1.count(), 1) + self.assertEqual(stock_2.count(), 1) + + # Different location for each received item + self.assertEqual(stock_1.last().location.pk, 1) + self.assertEqual(stock_2.last().location.pk, 2) + class SalesOrderTest(OrderTest): """ From d5e58fd79874668fdc920b20d8b58fb545dcd3f3 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 24 Aug 2021 22:03:52 +1000 Subject: [PATCH 07/18] Fix for status code serializer --- InvenTree/order/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 881a0940c4..8d4c05c0d7 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -206,7 +206,7 @@ class POLineItemReceiveSerializer(serializers.Serializer): ) status = serializers.ChoiceField( - choices=StockStatus.options, + choices=list(StockStatus.items()), default=StockStatus.OK, label=_('Status'), ) From e3605001e42261227eeb77f4ca831ac62bf7074c Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 24 Aug 2021 22:04:06 +1000 Subject: [PATCH 08/18] Simple function to render a thumbnail --- InvenTree/templates/js/dynamic/inventree.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/InvenTree/templates/js/dynamic/inventree.js b/InvenTree/templates/js/dynamic/inventree.js index acfd57762c..632c5650c1 100644 --- a/InvenTree/templates/js/dynamic/inventree.js +++ b/InvenTree/templates/js/dynamic/inventree.js @@ -315,6 +315,22 @@ function enableDragAndDrop(element, url, options) { }); } + +function thumbnailImage(url, options={}) { + /* Render a simple thumbnail image from the provided URL */ + + if (!url) { + url = '/static/img/blank_img.png'; + } + + // TODO: Support insertion of custom classes + + var html = ``; + + return html; + +} + function imageHoverIcon(url) { /* Render a small thumbnail icon for an image. * On mouseover, display a full-size version of the image From 1360b1592d0ac833979202aa1ef8f05cc4791b70 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 24 Aug 2021 22:04:43 +1000 Subject: [PATCH 09/18] Some convenience functions --- InvenTree/templates/js/translated/tables.js | 23 +++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/InvenTree/templates/js/translated/tables.js b/InvenTree/templates/js/translated/tables.js index 88d9a5f99a..b9a05689d0 100644 --- a/InvenTree/templates/js/translated/tables.js +++ b/InvenTree/templates/js/translated/tables.js @@ -1,6 +1,29 @@ {% load i18n %} +function tdWrap(html, options={}) { + /* Wraps provided html in .. elements + */ + + var colspan = ''; + + if (options.colspan) { + colspan = ` colspan=${options.colspan}`; + } + + return `${html}`; +} + + +function trWrap(html) { + /* Wraps provided html in .. elements + */ + + return `${html}`; +} + + + function reloadtable(table) { $(table).bootstrapTable('refresh'); } From d87ab0033a18b5399d48eceb75db20300a4c4613 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 24 Aug 2021 22:05:00 +1000 Subject: [PATCH 10/18] Add "afterRender" callback for modal forms --- InvenTree/templates/js/translated/forms.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 904053a423..292b1d89ad 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -258,6 +258,8 @@ function constructForm(url, options) { constructFormBody({}, options); } + options.fields = options.fields || {}; + // Save the URL options.url = url; @@ -517,6 +519,11 @@ function constructFormBody(fields, options) { initializeGroups(fields, options); + if (options.afterRender) { + // Custom callback function after form rendering + options.afterRender(fields, options); + } + // Scroll to the top $(options.modal).find('.modal-form-content-wrapper').scrollTop(0); } From 5275d3943b5445b126b3b96db7a54f796932691d Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 24 Aug 2021 22:13:07 +1000 Subject: [PATCH 11/18] Adds option to hide labels and help text --- InvenTree/templates/js/translated/forms.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 292b1d89ad..b4bbdc2cc4 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -1514,7 +1514,9 @@ function constructField(name, parameters, options) { html += `
`; // Add a label - html += constructLabel(name, parameters); + if (!options.hideLabels) { + html += constructLabel(name, parameters); + } html += `
`; @@ -1561,7 +1563,7 @@ function constructField(name, parameters, options) { html += `
`; // input-group } - if (parameters.help_text) { + if (parameters.help_text && !options.hideLabels) { html += constructHelpText(name, parameters, options); } From 0d7eb6b72c21a71d0c3e1b291c210a6e2f55a7c0 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 24 Aug 2021 22:25:25 +1000 Subject: [PATCH 12/18] Style fixes --- InvenTree/order/api.py | 2 +- InvenTree/order/serializers.py | 1 - InvenTree/order/test_api.py | 7 ++++--- InvenTree/templates/js/translated/forms.js | 1 - 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index f2b3879e30..e7deb36058 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -31,7 +31,7 @@ from .models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation from .models import SalesOrderAttachment from .serializers import SalesOrderSerializer, SOLineItemSerializer, SOAttachmentSerializer from .serializers import SalesOrderAllocationSerializer -from .serializers import POReceiveSerializer, POLineItemReceiveSerializer +from .serializers import POReceiveSerializer class POList(generics.ListCreateAPIView): diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 8d4c05c0d7..b60228116e 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -22,7 +22,6 @@ from InvenTree.serializers import InvenTreeAttachmentSerializerField from InvenTree.status_codes import StockStatus -import company.models from company.serializers import CompanyBriefSerializer, SupplierPartSerializer from part.serializers import PartBriefSerializer diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py index 26f1249cf4..5fab3d8bd9 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -258,7 +258,7 @@ class PurchaseOrderReceiveTest(OrderTest): def test_invalid_items(self): """ - Test than errors are returned as expected for invalid data + Test than errors are returned as expected for invalid data """ data = self.post( @@ -347,7 +347,7 @@ class PurchaseOrderReceiveTest(OrderTest): self.assertEqual(line_2.received, 50) # Receive two separate line items against this order - data = self.post( + self.post( self.url, { 'items': [ @@ -363,7 +363,8 @@ class PurchaseOrderReceiveTest(OrderTest): ], 'location': 1, # Default location }, - ).data + expected_code=201, + ) # There should be two newly created stock items self.assertEqual(self.n + 2, StockItem.objects.count()) diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index b4bbdc2cc4..f61bc0e2e5 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -1570,7 +1570,6 @@ function constructField(name, parameters, options) { // Div for error messages html += `
`; - html += `
`; // controls html += ``; // form-group From 42af52ee51a2bf402a80ea1b7d056ae4092f956c Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 24 Aug 2021 22:31:13 +1000 Subject: [PATCH 13/18] WIP --- .../order/templates/order/order_base.html | 5 + InvenTree/templates/js/translated/order.js | 141 +++++++++++++++++- 2 files changed, 145 insertions(+), 1 deletion(-) diff --git a/InvenTree/order/templates/order/order_base.html b/InvenTree/order/templates/order/order_base.html index 0d46207c33..e0228ac949 100644 --- a/InvenTree/order/templates/order/order_base.html +++ b/InvenTree/order/templates/order/order_base.html @@ -188,6 +188,11 @@ $("#edit-order").click(function() { }); $("#receive-order").click(function() { + + receivePurchaseOrder({{ order.pk }}); + + return; + launchModalForm("{% url 'po-receive' order.id %}", { reload: true, secondary: [ diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index c6dd773373..d304b1c42b 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -103,6 +103,146 @@ function removeOrderRowFromOrderWizard(e) { $('#' + row).remove(); } + +function receivePurchaseOrder(orderId, options={}) { + /* Receive parts against a purchase order. + * Launches a modal form. + * + * Required Args: + * - orderId: pk value for the PurchaseOrder + * + * Optional Args: + * - lines: List of purchase order lines to receive + * (If omitted, lines will be requested via the API) + */ + + // List of lines to receive + var selectedLines = options.lines || []; + + // List of line items to receive + var lineItems = []; + + inventreeGet( + '{% url "api-po-line-list" %}', + { + order: orderId, + part_detail: true, + }, + { + success: function(results) { + for (var idx = 0; idx < results.length; idx++) { + var line = results[idx]; + + // Skip / ignore lines missing part information + if (!line.part || !line.supplier_part_detail.part) { + continue; + } + + // Skip / ignore lines which are fully received + if (line.received >= line.quantity) { + continue; + } + + if (selectedLines.length == 0 || selectedLines.includes(line.pk)) { + lineItems.push(line); + } + } + + // Construct a form for processing + var html = ` + + + + + + + + + + + + + +
{% trans "Part" %}{% trans "SKU" %}{% trans "On Order" %}{% trans "Received" %}{% trans "Quantity" %}{% trans "Status" %}{% trans "Destination" %}
+ `; + + constructForm(`/api/order/po/${orderId}/receive/`, { + method: 'POST', + title: '{% trans "Receive Items" %}', + confirm: true, + fields: { + location: {}, + }, + preFormContent: html, + afterRender: function(fields, options) { + + var table = $(options.modal).find('#line-items-table').children('tbody'); + + options.hideLabels = true; + + var parameters = {}; + + // Insert a row for each item + lineItems.forEach(function(item) { + + var part = item.part_detail; + var supplier_part = item.supplier_part_detail; + + var row = ''; + + // Part detail + row += `${thumbnailImage(part.thumbnail)} ${part.full_name}`; + + // Order code + row += `${supplier_part.SKU}`; + + // Quantity on order + row += `${line.quantity}`; + + // Quantity received + row += `${line.received}`; + + parameters = fields.items.child.children.quantity; + parameters.value = line.quantity - line.received; + + // Quantity input + row += tdWrap(constructField( + `quantity_${line.pk}`, + parameters, + options + )); + + // Status input + row += tdWrap(constructField( + `status_${line.pk}`, + fields.items.child.children.status, + options + )); + + // Location input + row += tdWrap(constructField( + `location_${line.pk}`, + fields.items.child.children.location, + options + )); + + table.append(trWrap(row)); + + parameters = fields.items.child.children.location; + + parameters.name = `location_${line.pk}`; + + initializeRelatedField(parameters, fields, options); + + }); + } + }); + } + } + ); +} + + function newSupplierPartFromOrderWizard(e) { /* Create a new supplier part directly from an order form. * Launches a secondary modal and (if successful), @@ -117,7 +257,6 @@ function newSupplierPartFromOrderWizard(e) { if (!part) { part = $(src).closest('button').attr('part'); - console.log('parent: ' + part); } createSupplierPart({ From f6b9d9e6d05adc56488971bf3aba394fe2cfd859 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 24 Aug 2021 22:33:29 +1000 Subject: [PATCH 14/18] Revert "WIP" This reverts commit 42af52ee51a2bf402a80ea1b7d056ae4092f956c. --- .../order/templates/order/order_base.html | 5 - InvenTree/templates/js/translated/order.js | 141 +----------------- 2 files changed, 1 insertion(+), 145 deletions(-) diff --git a/InvenTree/order/templates/order/order_base.html b/InvenTree/order/templates/order/order_base.html index e0228ac949..0d46207c33 100644 --- a/InvenTree/order/templates/order/order_base.html +++ b/InvenTree/order/templates/order/order_base.html @@ -188,11 +188,6 @@ $("#edit-order").click(function() { }); $("#receive-order").click(function() { - - receivePurchaseOrder({{ order.pk }}); - - return; - launchModalForm("{% url 'po-receive' order.id %}", { reload: true, secondary: [ diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index d304b1c42b..c6dd773373 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -103,146 +103,6 @@ function removeOrderRowFromOrderWizard(e) { $('#' + row).remove(); } - -function receivePurchaseOrder(orderId, options={}) { - /* Receive parts against a purchase order. - * Launches a modal form. - * - * Required Args: - * - orderId: pk value for the PurchaseOrder - * - * Optional Args: - * - lines: List of purchase order lines to receive - * (If omitted, lines will be requested via the API) - */ - - // List of lines to receive - var selectedLines = options.lines || []; - - // List of line items to receive - var lineItems = []; - - inventreeGet( - '{% url "api-po-line-list" %}', - { - order: orderId, - part_detail: true, - }, - { - success: function(results) { - for (var idx = 0; idx < results.length; idx++) { - var line = results[idx]; - - // Skip / ignore lines missing part information - if (!line.part || !line.supplier_part_detail.part) { - continue; - } - - // Skip / ignore lines which are fully received - if (line.received >= line.quantity) { - continue; - } - - if (selectedLines.length == 0 || selectedLines.includes(line.pk)) { - lineItems.push(line); - } - } - - // Construct a form for processing - var html = ` - - - - - - - - - - - - - -
{% trans "Part" %}{% trans "SKU" %}{% trans "On Order" %}{% trans "Received" %}{% trans "Quantity" %}{% trans "Status" %}{% trans "Destination" %}
- `; - - constructForm(`/api/order/po/${orderId}/receive/`, { - method: 'POST', - title: '{% trans "Receive Items" %}', - confirm: true, - fields: { - location: {}, - }, - preFormContent: html, - afterRender: function(fields, options) { - - var table = $(options.modal).find('#line-items-table').children('tbody'); - - options.hideLabels = true; - - var parameters = {}; - - // Insert a row for each item - lineItems.forEach(function(item) { - - var part = item.part_detail; - var supplier_part = item.supplier_part_detail; - - var row = ''; - - // Part detail - row += `${thumbnailImage(part.thumbnail)} ${part.full_name}`; - - // Order code - row += `${supplier_part.SKU}`; - - // Quantity on order - row += `${line.quantity}`; - - // Quantity received - row += `${line.received}`; - - parameters = fields.items.child.children.quantity; - parameters.value = line.quantity - line.received; - - // Quantity input - row += tdWrap(constructField( - `quantity_${line.pk}`, - parameters, - options - )); - - // Status input - row += tdWrap(constructField( - `status_${line.pk}`, - fields.items.child.children.status, - options - )); - - // Location input - row += tdWrap(constructField( - `location_${line.pk}`, - fields.items.child.children.location, - options - )); - - table.append(trWrap(row)); - - parameters = fields.items.child.children.location; - - parameters.name = `location_${line.pk}`; - - initializeRelatedField(parameters, fields, options); - - }); - } - }); - } - } - ); -} - - function newSupplierPartFromOrderWizard(e) { /* Create a new supplier part directly from an order form. * Launches a secondary modal and (if successful), @@ -257,6 +117,7 @@ function newSupplierPartFromOrderWizard(e) { if (!part) { part = $(src).closest('button').attr('part'); + console.log('parent: ' + part); } createSupplierPart({ From ccb191e5b19fe7741e3786f8234f4d88304f2713 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 7 Sep 2021 23:06:17 +1000 Subject: [PATCH 15/18] Bump API version --- InvenTree/InvenTree/version.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index b53cacff79..62dd0287ad 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -10,11 +10,14 @@ import common.models INVENTREE_SW_VERSION = "0.5.0 pre" -INVENTREE_API_VERSION = 11 +INVENTREE_API_VERSION = 12 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v12 -> 2021-09-07 + - Adds API endpoint to receive stock items against a PurchaseOrder + v11 -> 2021-08-26 - Adds "units" field to PartBriefSerializer - This allows units to be introspected from the "part_detail" field in the StockItem serializer From bf05c9cfaedf44e332128feb6aa3610a5d88cdb1 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 7 Sep 2021 23:06:36 +1000 Subject: [PATCH 16/18] Adds "barcode" field to POLineItem receive serializer --- InvenTree/order/serializers.py | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 80f4790d9f..f3ec114305 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -144,7 +144,6 @@ class POLineItemSerializer(InvenTreeModelSerializer): self.fields.pop('part_detail') self.fields.pop('supplier_part_detail') - # TODO: Once https://github.com/inventree/InvenTree/issues/1687 is fixed, remove default values quantity = serializers.FloatField(default=1) received = serializers.FloatField(default=0) @@ -231,8 +230,26 @@ class POLineItemReceiveSerializer(serializers.Serializer): label=_('Status'), ) + barcode = serializers.CharField( + label=_('Barcode Hash'), + help_text=_('Unique identifier field'), + ) + + def validate_barcode(self, barcode): + """ + Cannot check in a LineItem with a barcode that is already assigned + """ + + # Ignore empty barcode values + if not barcode or barcode.strip() == '': + return + + if stock.models.StockItem.objects.filter(uid=barcode).exists(): + raise ValidationError(_('Barcode is already in use')) + class Meta: fields = [ + 'barcode', 'line_item', 'location', 'quantity', @@ -266,6 +283,19 @@ class POReceiveSerializer(serializers.Serializer): if len(items) == 0: self._errors['items'] = _('Line items must be provided') + else: + # Ensure barcodes are unique + unique_barcodes = set() + + for item in items: + barcode = item.get('barcode', None) + + if barcode: + if barcode in unique_barcodes: + self._errors['items'] = _('Supplied barcode values must be unique') + break + else: + unique_barcodes.add(barcode) if self._errors and raise_exception: raise ValidationError(self.errors) From f38bf6e20a32bc3aab4a3885aed5db0464dd3b7c Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 7 Sep 2021 23:34:14 +1000 Subject: [PATCH 17/18] Adds unit testing for barcode field --- InvenTree/order/api.py | 1 + InvenTree/order/models.py | 6 ++++ InvenTree/order/serializers.py | 6 +++- InvenTree/order/test_api.py | 61 ++++++++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 1 deletion(-) diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index c86fb6f178..d97da75b73 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -311,6 +311,7 @@ class POReceive(generics.CreateAPIView): item['quantity'], self.request.user, status=item['status'], + barcode=item.get('barcode', ''), ) diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index e2753f702c..a069cf126f 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -411,6 +411,11 @@ class PurchaseOrder(Order): """ notes = kwargs.get('notes', '') + barcode = kwargs.get('barcode', '') + + # Prevent null values for barcode + if barcode is None: + barcode = '' if not self.status == PurchaseOrderStatus.PLACED: raise ValidationError({"status": _("Lines can only be received against an order marked as 'Placed'")}) @@ -434,6 +439,7 @@ class PurchaseOrder(Order): purchase_order=self, status=status, purchase_price=line.purchase_price, + uid=barcode ) stock.save(add_note=False) diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index f3ec114305..da2d23cd0d 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -233,6 +233,8 @@ class POLineItemReceiveSerializer(serializers.Serializer): barcode = serializers.CharField( label=_('Barcode Hash'), help_text=_('Unique identifier field'), + default='', + required=False, ) def validate_barcode(self, barcode): @@ -247,6 +249,8 @@ class POLineItemReceiveSerializer(serializers.Serializer): if stock.models.StockItem.objects.filter(uid=barcode).exists(): raise ValidationError(_('Barcode is already in use')) + return barcode + class Meta: fields = [ 'barcode', @@ -288,7 +292,7 @@ class POReceiveSerializer(serializers.Serializer): unique_barcodes = set() for item in items: - barcode = item.get('barcode', None) + barcode = item.get('barcode', '') if barcode: if barcode in unique_barcodes: diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py index 5fab3d8bd9..8476a9c668 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -332,6 +332,61 @@ class PurchaseOrderReceiveTest(OrderTest): # No new stock items have been created self.assertEqual(self.n, StockItem.objects.count()) + def test_invalid_barcodes(self): + """ + Tests for checking in items with invalid barcodes: + + - Cannot check in "duplicate" barcodes + - Barcodes cannot match UID field for existing StockItem + """ + + # Set stock item barcode + item = StockItem.objects.get(pk=1) + item.uid = 'MY-BARCODE-HASH' + item.save() + + response = self.post( + self.url, + { + 'items': [ + { + 'line_item': 1, + 'quantity': 50, + 'barcode': 'MY-BARCODE-HASH', + } + ], + 'location': 1, + }, + expected_code=400 + ) + + self.assertIn('Barcode is already in use', str(response.data)) + + response = self.post( + self.url, + { + 'items': [ + { + 'line_item': 1, + 'quantity': 5, + 'barcode': 'MY-BARCODE-HASH-1', + }, + { + 'line_item': 1, + 'quantity': 5, + 'barcode': 'MY-BARCODE-HASH-1' + }, + ], + 'location': 1, + }, + expected_code=400 + ) + + self.assertIn('barcode values must be unique', str(response.data)) + + # No new stock items have been created + self.assertEqual(self.n, StockItem.objects.count()) + def test_valid(self): """ Test receipt of valid data @@ -354,11 +409,13 @@ class PurchaseOrderReceiveTest(OrderTest): { 'line_item': 1, 'quantity': 50, + 'barcode': 'MY-UNIQUE-BARCODE-123', }, { 'line_item': 2, 'quantity': 200, 'location': 2, # Explicit location + 'barcode': 'MY-UNIQUE-BARCODE-456', } ], 'location': 1, # Default location @@ -386,6 +443,10 @@ class PurchaseOrderReceiveTest(OrderTest): self.assertEqual(stock_1.last().location.pk, 1) self.assertEqual(stock_2.last().location.pk, 2) + # Barcodes should have been assigned to the stock items + self.assertTrue(StockItem.objects.filter(uid='MY-UNIQUE-BARCODE-123').exists()) + self.assertTrue(StockItem.objects.filter(uid='MY-UNIQUE-BARCODE-456').exists()) + class SalesOrderTest(OrderTest): """ From f349dc01ea35f4c2a6514051660978fe44dba031 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 7 Sep 2021 23:41:13 +1000 Subject: [PATCH 18/18] JS lint --- InvenTree/templates/js/translated/helpers.js | 1 + 1 file changed, 1 insertion(+) diff --git a/InvenTree/templates/js/translated/helpers.js b/InvenTree/templates/js/translated/helpers.js index 020f098d19..6e3f7f0c95 100644 --- a/InvenTree/templates/js/translated/helpers.js +++ b/InvenTree/templates/js/translated/helpers.js @@ -10,6 +10,7 @@ makeProgressBar, renderLink, select2Thumbnail, + thumbnailImage yesNoLabel, */