From dd4428464ddb4b3be9eaf3f12956e45a38afbe75 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 5 Oct 2021 16:05:18 +1100 Subject: [PATCH] Receiving items against a purchase order now makes use of the API forms - Delete old unused code - Improve serializer validation --- InvenTree/order/forms.py | 16 -- InvenTree/order/serializers.py | 51 +++-- .../order/templates/order/order_base.html | 17 +- .../order/templates/order/receive_parts.html | 81 -------- InvenTree/order/urls.py | 1 - InvenTree/order/views.py | 196 ------------------ InvenTree/part/serializers.py | 1 + InvenTree/templates/js/translated/forms.js | 1 + InvenTree/templates/js/translated/order.js | 160 +++++++++++--- 9 files changed, 168 insertions(+), 356 deletions(-) delete mode 100644 InvenTree/order/templates/order/receive_parts.html diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py index e0c500e5e3..15b9e740d8 100644 --- a/InvenTree/order/forms.py +++ b/InvenTree/order/forms.py @@ -80,22 +80,6 @@ class ShipSalesOrderForm(HelperForm): ] -class ReceivePurchaseOrderForm(HelperForm): - - location = TreeNodeChoiceField( - queryset=StockLocation.objects.all(), - required=False, - label=_("Destination"), - help_text=_("Set all received parts listed above to this location (if left blank, use \"Destination\" column value in above table)"), - ) - - class Meta: - model = PurchaseOrder - fields = [ - "location", - ] - - class AllocateSerialsToSalesOrderForm(forms.Form): """ Form for assigning stock to a sales order, diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 842d422678..04ded4fbbb 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -225,6 +225,13 @@ class POLineItemReceiveSerializer(serializers.Serializer): required=True, ) + def validate_quantity(self, quantity): + + if quantity <= 0: + raise ValidationError(_("Quantity must be greater than zero")) + + return quantity + status = serializers.ChoiceField( choices=list(StockStatus.items()), default=StockStatus.OK, @@ -246,7 +253,7 @@ class POLineItemReceiveSerializer(serializers.Serializer): # Ignore empty barcode values if not barcode or barcode.strip() == '': - return + return None if stock.models.StockItem.objects.filter(uid=barcode).exists(): raise ValidationError(_('Barcode is already in use')) @@ -284,10 +291,28 @@ class POReceiveSerializer(serializers.Serializer): items = data.get('items', []) + location = data.get('location', None) + if len(items) == 0: - raise ValidationError({ - 'items': _('Line items must be provided') - }) + raise ValidationError(_('Line items must be provided')) + + # 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"), + }) # Ensure barcodes are unique unique_barcodes = set() @@ -313,24 +338,6 @@ class POReceiveSerializer(serializers.Serializer): items = data['items'] location = data.get('location', None) - # 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 into stock with transaction.atomic(): for item in items: diff --git a/InvenTree/order/templates/order/order_base.html b/InvenTree/order/templates/order/order_base.html index 6f01d0a172..69e972da6c 100644 --- a/InvenTree/order/templates/order/order_base.html +++ b/InvenTree/order/templates/order/order_base.html @@ -204,22 +204,11 @@ $("#receive-order").click(function() { {{ order.id }}, items_to_receive, { + success: function() { + $("#po-line-table").bootstrapTable('refresh'); + } } ); - - return; - - launchModalForm("{% url 'po-receive' order.id %}", { - reload: true, - secondary: [ - { - field: 'location', - label: '{% trans "New Location" %}', - title: '{% trans "Create new stock location" %}', - url: "{% url 'stock-location-create' %}", - }, - ] - }); }); $("#complete-order").click(function() { diff --git a/InvenTree/order/templates/order/receive_parts.html b/InvenTree/order/templates/order/receive_parts.html deleted file mode 100644 index 7b12101f7f..0000000000 --- a/InvenTree/order/templates/order/receive_parts.html +++ /dev/null @@ -1,81 +0,0 @@ -{% extends "modal_form.html" %} -{% load i18n %} -{% load inventree_extras %} -{% load status_codes %} - -{% block form %} - -{% blocktrans with desc=order.description %}Receive outstanding parts for {{order}} - {{desc}}{% endblocktrans %} - -
- {% csrf_token %} - {% load crispy_forms_tags %} - - -

{% trans "Fill out number of parts received, the status and destination" %}

- - - - - - - - - - - - - {% for line in lines %} - - {% if line.part %} - - - {% else %} - - {% endif %} - - - - - - - - {% endfor %} -
{% trans "Part" %}{% trans "Order Code" %}{% trans "On Order" %}{% trans "Received" %}{% trans "Receive" %}{% trans "Status" %}{% trans "Destination" %}
- {% include "hover_image.html" with image=line.part.part.image hover=False %} - {{ line.part.part.full_name }} - {{ line.part.SKU }}{% trans "Error: Referenced part has been removed" %}{% decimal line.quantity %}{% decimal line.received %} -
-
- -
-
-
-
- -
-
-
- -
-
- -
- - {% crispy form %} - -
{{ form_errors }}
-
- -{% endblock %} \ No newline at end of file diff --git a/InvenTree/order/urls.py b/InvenTree/order/urls.py index 2ce90f1f81..5ea9a56867 100644 --- a/InvenTree/order/urls.py +++ b/InvenTree/order/urls.py @@ -13,7 +13,6 @@ purchase_order_detail_urls = [ url(r'^cancel/', views.PurchaseOrderCancel.as_view(), name='po-cancel'), url(r'^issue/', views.PurchaseOrderIssue.as_view(), name='po-issue'), - url(r'^receive/', views.PurchaseOrderReceive.as_view(), name='po-receive'), url(r'^complete/', views.PurchaseOrderComplete.as_view(), name='po-complete'), url(r'^upload/', views.PurchaseOrderUpload.as_view(), name='po-upload'), diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index e8b0dc03e9..a953324b47 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -468,202 +468,6 @@ class PurchaseOrderExport(AjaxView): return DownloadFile(filedata, filename) -class PurchaseOrderReceive(AjaxUpdateView): - """ View for receiving parts which are outstanding against a PurchaseOrder. - - Any parts which are outstanding are listed. - If all parts are marked as received, the order is closed out. - - """ - - form_class = order_forms.ReceivePurchaseOrderForm - ajax_form_title = _("Receive Parts") - ajax_template_name = "order/receive_parts.html" - - # Specify role as we do not specify a Model against this view - role_required = 'purchase_order.change' - - # Where the parts will be going (selected in POST request) - destination = None - - def get_context_data(self): - - ctx = { - 'order': self.order, - 'lines': self.lines, - 'stock_locations': StockLocation.objects.all(), - } - - return ctx - - def get_lines(self): - """ - Extract particular line items from the request, - or default to *all* pending line items if none are provided - """ - - lines = None - - if 'line' in self.request.GET: - line_id = self.request.GET.get('line') - - try: - lines = PurchaseOrderLineItem.objects.filter(pk=line_id) - except (PurchaseOrderLineItem.DoesNotExist, ValueError): - pass - - # TODO - Option to pass multiple lines? - - # No lines specified - default selection - if lines is None: - lines = self.order.pending_line_items() - - return lines - - def get(self, request, *args, **kwargs): - """ Respond to a GET request. Determines which parts are outstanding, - and presents a list of these parts to the user. - """ - - self.request = request - self.order = get_object_or_404(PurchaseOrder, pk=self.kwargs['pk']) - - self.lines = self.get_lines() - - for line in self.lines: - # Pre-fill the remaining quantity - line.receive_quantity = line.remaining() - - return self.renderJsonResponse(request, form=self.get_form()) - - def post(self, request, *args, **kwargs): - """ Respond to a POST request. Data checking and error handling. - If the request is valid, new StockItem objects will be made - for each received item. - """ - - self.request = request - self.order = get_object_or_404(PurchaseOrder, pk=self.kwargs['pk']) - errors = False - - self.lines = [] - self.destination = None - - msg = _("Items received") - - # Extract the destination for received parts - if 'location' in request.POST: - pk = request.POST['location'] - try: - self.destination = StockLocation.objects.get(id=pk) - except (StockLocation.DoesNotExist, ValueError): - pass - - # Extract information on all submitted line items - for item in request.POST: - if item.startswith('line-'): - pk = item.replace('line-', '') - - try: - line = PurchaseOrderLineItem.objects.get(id=pk) - except (PurchaseOrderLineItem.DoesNotExist, ValueError): - continue - - # Check that the StockStatus was set - status_key = 'status-{pk}'.format(pk=pk) - status = request.POST.get(status_key, StockStatus.OK) - - try: - status = int(status) - except ValueError: - status = StockStatus.OK - - if status in StockStatus.RECEIVING_CODES: - line.status_code = status - else: - line.status_code = StockStatus.OK - - # Check the destination field - line.destination = None - if self.destination: - # If global destination is set, overwrite line value - line.destination = self.destination - else: - destination_key = f'destination-{pk}' - destination = request.POST.get(destination_key, None) - - if destination: - try: - line.destination = StockLocation.objects.get(pk=destination) - except (StockLocation.DoesNotExist, ValueError): - pass - - # Check that line matches the order - if not line.order == self.order: - # TODO - Display a non-field error? - continue - - # Ignore a part that doesn't map to a SupplierPart - try: - if line.part is None: - continue - except SupplierPart.DoesNotExist: - continue - - receive = self.request.POST[item] - - try: - receive = Decimal(receive) - except InvalidOperation: - # In the case on an invalid input, reset to default - receive = line.remaining() - msg = _("Error converting quantity to number") - errors = True - - if receive < 0: - receive = 0 - errors = True - msg = _("Receive quantity less than zero") - - line.receive_quantity = receive - self.lines.append(line) - - if len(self.lines) == 0: - msg = _("No lines specified") - errors = True - - # No errors? Receive the submitted parts! - if errors is False: - self.receive_parts() - - data = { - 'form_valid': errors is False, - 'success': msg, - } - - return self.renderJsonResponse(request, data=data, form=self.get_form()) - - @transaction.atomic - def receive_parts(self): - """ Called once the form has been validated. - Create new stockitems against received parts. - """ - - for line in self.lines: - - if not line.part: - continue - - self.order.receive_line_item( - line, - line.destination, - line.receive_quantity, - self.request.user, - status=line.status_code, - purchase_price=line.purchase_price, - ) - - class OrderParts(AjaxView): """ View for adding various SupplierPart items to a Purchase Order. diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 060faf8b0d..4f1ba8cc8b 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -193,6 +193,7 @@ class PartBriefSerializer(InvenTreeModelSerializer): fields = [ 'pk', 'IPN', + 'default_location', 'name', 'revision', 'full_name', diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index bffb42403b..b43ce0cb2d 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -1515,6 +1515,7 @@ function initializeChoiceField(field, fields, options) { select.select2({ dropdownAutoWidth: false, dropdownParent: $(options.modal), + width: '100%', }); } diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index ff41ca610a..8a41082cb8 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -264,13 +264,19 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { // Part thumbnail + description var thumb = thumbnailImage(line_item.part_detail.thumbnail); + var quantity = (line_item.quantity || 0) - (line_item.received || 0); + + if (quantity < 0) { + quantity = 0; + } + // Quantity to Receive var quantity_input = constructField( `items_quantity_${pk}`, { type: 'decimal', min_value: 0, - value: opts.quantity || 0, + value: quantity, title: '{% trans "Quantity to receive" %}', required: true, }, @@ -279,6 +285,16 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { } ); + // Construct list of StockItem status codes + var choices = []; + + for (var key in stockCodes) { + choices.push({ + value: key, + display_name: stockCodes[key].value, + }); + } + var destination_input = constructField( `items_location_${pk}`, { @@ -291,6 +307,20 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { } ); + var status_input = constructField( + `items_status_${pk}`, + { + type: 'choice', + label: '{% trans "Stock Status" %}', + required: true, + choices: choices, + value: 10, // OK + }, + { + hideLabels: true, + } + ); + // Button to remove the row var delete_button = `
`; @@ -321,7 +351,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { ${quantity_input} - STATUS + ${status_input} ${destination_input} @@ -349,11 +379,11 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { {% trans "Part" %} {% trans "Order Code" %} - {% trans "On Order" %} + {% trans "Ordered" %} {% trans "Received" %} {% trans "Receive" %} - {% trans "Status" %} - {% trans "Destination" %} + {% trans "Status" %} + {% trans "Destination" %} @@ -390,7 +420,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { model: 'stocklocation', required: false, auto_fill: false, - value: item.destination, + value: item.destination || item.part_detail.default_location, render_description: false, }; @@ -405,10 +435,86 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { field_details, opts ); + + initializeChoiceField( + { + name: `items_status_${pk}`, + }, + null, + opts + ); + }); + + // Add callbacks to remove rows + $(opts.modal).find('.button-row-remove').click(function() { + var pk = $(this).attr('pk'); + + $(opts.modal).find(`#receive_row_${pk}`).remove(); }); }, onSubmit: function(fields, opts) { - // TODO + // Extract data elements from the form + var data = { + items: [], + location: getFormFieldValue('location', {}, opts), + }; + + var item_pk_values = []; + + line_items.forEach(function(item) { + + var pk = item.pk; + + var quantity = getFormFieldValue(`items_quantity_${pk}`, {}, opts); + + var status = getFormFieldValue(`items_status_${pk}`, {}, opts); + + var location = getFormFieldValue(`items_location_${pk}`, {}, opts); + + if (quantity != null) { + data.items.push({ + line_item: pk, + quantity: quantity, + status: status, + location: location, + }); + + item_pk_values.push(pk); + } + + }); + + // Provide list of nested values + opts.nested = { + 'items': item_pk_values, + }; + + inventreePut( + opts.url, + data, + { + method: 'POST', + success: function(response) { + // Hide the modal + $(opts.modal).modal('hide'); + + if (options.success) { + options.success(response); + } + }, + error: function(xhr) { + switch (xhr.status) { + case 400: + handleFormErrors(xhr.responseJSON, fields, opts); + break; + default: + $(opts.modal).modal('hide'); + showApiError(xhr); + break; + } + } + } + ) } }); } @@ -604,22 +710,24 @@ function loadPurchaseOrderLineItemTable(table, options={}) { $(table).find('.button-line-receive').click(function() { var pk = $(this).attr('pk'); - launchModalForm(`/order/purchase-order/${options.order}/receive/`, { - success: function() { - $(table).bootstrapTable('refresh'); - }, - data: { - line: pk, - }, - secondary: [ - { - field: 'location', - label: '{% trans "New Location" %}', - title: '{% trans "Create new stock location" %}', - url: '{% url "stock-location-create" %}', - }, - ] - }); + var line_item = $(table).bootstrapTable('getRowByUniqueId', pk); + + if (!line_item) { + console.log('WARNING: getRowByUniqueId returned null'); + return; + } + + receivePurchaseOrderItems( + options.order, + [ + line_item, + ], + { + success: function() { + $(table).bootstrapTable('refresh'); + } + } + ); }); } } @@ -637,11 +745,11 @@ function loadPurchaseOrderLineItemTable(table, options={}) { }, url: '{% url "api-po-line-list" %}', showFooter: true, + uniqueId: 'pk', columns: [ { - field: 'pk', - title: 'ID', - visible: false, + checkbox: true, + visible: true, switchable: false, }, {