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 diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index eb8ba22ad0..d97da75b73 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -5,12 +5,16 @@ 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 import rest_framework as rest_filters from rest_framework import generics from rest_framework import filters, status from rest_framework.response import Response +from rest_framework.serializers import ValidationError + from InvenTree.filters import InvenTreeOrderingFilter from InvenTree.helpers import str2bool @@ -28,6 +32,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 class POList(generics.ListCreateAPIView): @@ -205,6 +210,111 @@ 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_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 + """ + + pk = self.kwargs.get('pk', None) + + if pk is None: + return None + else: + order = PurchaseOrder.objects.get(pk=self.kwargs['pk']) + 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) + + # 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) + + @transaction.atomic + def receive_items(self, serializer): + """ + Receive the items + + 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'] + + items = data['items'] + + # Check if the location is not specified for any particular item + for item in items: + + 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'], + item['quantity'], + self.request.user, + status=item['status'], + barcode=item.get('barcode', ''), + ) + + class POLineItemList(generics.ListCreateAPIView): """ API endpoint for accessing a list of POLineItem objects @@ -641,13 +751,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/models.py b/InvenTree/order/models.py index 2c2fd7c232..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'")}) @@ -433,7 +438,8 @@ class PurchaseOrder(Order): quantity=quantity, purchase_order=self, status=status, - purchase_price=purchase_price, + 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 fe23bd2a17..da2d23cd0d 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -12,6 +12,8 @@ from django.db.models import Case, When, Value from django.db.models import BooleanField, ExpressionWrapper, F from rest_framework import serializers +from rest_framework.serializers import ValidationError + from sql_util.utils import SubqueryCount from InvenTree.serializers import InvenTreeModelSerializer @@ -19,8 +21,13 @@ 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 + from part.serializers import PartBriefSerializer + +import stock.models from stock.serializers import LocationBriefSerializer, StockItemSerializer, LocationSerializer from .models import PurchaseOrder, PurchaseOrderLineItem @@ -137,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) @@ -182,6 +188,131 @@ class POLineItemSerializer(InvenTreeModelSerializer): ] +class POLineItemReceiveSerializer(serializers.Serializer): + """ + A serializer for receiving a single purchase order line item against a purchase order + """ + + line_item = serializers.PrimaryKeyRelatedField( + queryset=PurchaseOrderLineItem.objects.all(), + many=False, + 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')) + + 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'), + ) + + quantity = serializers.DecimalField( + max_digits=15, + decimal_places=5, + min_value=0, + required=True, + ) + + status = serializers.ChoiceField( + choices=list(StockStatus.items()), + default=StockStatus.OK, + label=_('Status'), + ) + + barcode = serializers.CharField( + label=_('Barcode Hash'), + help_text=_('Unique identifier field'), + default='', + required=False, + ) + + 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')) + + return barcode + + class Meta: + fields = [ + 'barcode', + 'line_item', + 'location', + 'quantity', + 'status', + ] + + +class POReceiveSerializer(serializers.Serializer): + """ + Serializer for receiving items against a purchase order + """ + + items = POLineItemReceiveSerializer(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: + self._errors['items'] = _('Line items must be provided') + else: + # Ensure barcodes are unique + unique_barcodes = set() + + for item in items: + barcode = item.get('barcode', '') + + 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) + + return not bool(self._errors) + + class Meta: + fields = [ + 'items', + 'location', + ] + + class POAttachmentSerializer(InvenTreeAttachmentSerializer): """ Serializers for the PurchaseOrderAttachment model diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py index 24ca8581d9..8476a9c668 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -9,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): @@ -201,6 +204,250 @@ 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}) + + # 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 + """ + + 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'])) + + # 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 + """ + + data = self.post( + self.url, + { + "items": [], + "location": None, + }, + expected_code=400 + ).data + + 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 + """ + + data = self.post( + self.url, + { + "items": [ + { + "line_item": 12345, + "location": 12345 + } + ] + }, + expected_code=400 + ).data + + items = data['items'][0] + + 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 + """ + + 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)) + + # 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 + """ + + 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 + self.post( + self.url, + { + 'items': [ + { + '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 + }, + expected_code=201, + ) + + # 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) + + # 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): """ Tests for the SalesOrder API diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index a3d1946beb..0e815f8c6d 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -286,6 +286,8 @@ function constructForm(url, options) { constructFormBody({}, options); } + options.fields = options.fields || {}; + // Save the URL options.url = url; @@ -545,6 +547,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); } @@ -1542,7 +1549,9 @@ function constructField(name, parameters, options) { html += `
`; // Add a label - html += constructLabel(name, parameters); + if (!options.hideLabels) { + html += constructLabel(name, parameters); + } html += `
`; @@ -1589,7 +1598,7 @@ function constructField(name, parameters, options) { html += `
`; // input-group } - if (parameters.help_text) { + if (parameters.help_text && !options.hideLabels) { html += constructHelpText(name, parameters, options); } diff --git a/InvenTree/templates/js/translated/helpers.js b/InvenTree/templates/js/translated/helpers.js index 008c3efaca..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, */ @@ -56,6 +57,26 @@ function imageHoverIcon(url) { } +/** + * Renders a simple thumbnail image + * @param {String} url is the image URL + * @returns html tag + */ +function thumbnailImage(url) { + + if (!url) { + url = '/static/img/blank_img.png'; + } + + // TODO: Support insertion of custom classes + + var html = ``; + + return html; + +} + + // Render a select2 thumbnail image function select2Thumbnail(image) { if (!image) {