From 008c52ef39bf71738fa8eefd74464aaff638445f Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 4 Dec 2021 13:08:00 +1100 Subject: [PATCH] Allocation by serial number now moved to the API --- InvenTree/order/api.py | 25 +++ InvenTree/order/forms.py | 44 +---- InvenTree/order/models.py | 1 + InvenTree/order/serializers.py | 172 ++++++++++++++++- .../order/so_allocate_by_serial.html | 12 -- InvenTree/order/urls.py | 5 - InvenTree/order/views.py | 173 ------------------ InvenTree/templates/js/translated/order.js | 27 ++- 8 files changed, 213 insertions(+), 246 deletions(-) delete mode 100644 InvenTree/order/templates/order/so_allocate_by_serial.html diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index 4a06bd76d5..a46215fb0d 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -699,6 +699,30 @@ class SalesOrderComplete(generics.CreateAPIView): return ctx +class SalesOrderAllocateSerials(generics.CreateAPIView): + """ + API endpoint to allocation stock items against a SalesOrder, + by specifying serial numbers. + """ + + queryset = models.SalesOrder.objects.none() + serializer_class = serializers.SOSerialAllocationSerializer + + 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 SalesOrderAllocate(generics.CreateAPIView): """ API endpoint to allocate stock items against a SalesOrder @@ -944,6 +968,7 @@ order_api_urls = [ url(r'^(?P\d+)/', include([ url(r'^complete/', SalesOrderComplete.as_view(), name='api-so-complete'), url(r'^allocate/', SalesOrderAllocate.as_view(), name='api-so-allocate'), + url(r'^allocate-serials/', SalesOrderAllocateSerials.as_view(), name='api-so-allocate-serials'), url(r'^.*$', SODetail.as_view(), name='api-so-detail'), ])), diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py index a5a3ddc0f1..3eb5566a1e 100644 --- a/InvenTree/order/forms.py +++ b/InvenTree/order/forms.py @@ -15,10 +15,8 @@ from InvenTree.helpers import clean_decimal from common.forms import MatchItemForm -import part.models - from .models import PurchaseOrder -from .models import SalesOrder, SalesOrderLineItem +from .models import SalesOrder class IssuePurchaseOrderForm(HelperForm): @@ -65,46 +63,6 @@ class CancelSalesOrderForm(HelperForm): ] -class AllocateSerialsToSalesOrderForm(forms.Form): - """ - Form for assigning stock to a sales order, - by serial number lookup - - TODO: Refactor this form / view to use the new API forms interface - """ - - line = forms.ModelChoiceField( - queryset=SalesOrderLineItem.objects.all(), - ) - - part = forms.ModelChoiceField( - queryset=part.models.Part.objects.all(), - ) - - serials = forms.CharField( - label=_("Serial Numbers"), - required=True, - help_text=_('Enter stock item serial numbers'), - ) - - quantity = forms.IntegerField( - label=_('Quantity'), - required=True, - help_text=_('Enter quantity of stock items'), - initial=1, - min_value=1 - ) - - class Meta: - - fields = [ - 'line', - 'part', - 'serials', - 'quantity', - ] - - class OrderMatchItemForm(MatchItemForm): """ Override MatchItemForm fields """ diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 4ab0d6621c..c036e15190 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -725,6 +725,7 @@ class SalesOrder(Order): def pending_shipment_count(self): return self.pending_shipments().count() + class PurchaseOrderAttachment(InvenTreeAttachment): """ Model for storing file attachments against a PurchaseOrder object diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 1c2e9c76bd..fd43164833 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -21,7 +21,7 @@ from common.settings import currency_code_mappings from company.serializers import CompanyBriefSerializer, SupplierPartSerializer from InvenTree.serializers import InvenTreeAttachmentSerializer -from InvenTree.helpers import normalize +from InvenTree.helpers import normalize, extract_serial_numbers from InvenTree.serializers import InvenTreeModelSerializer from InvenTree.serializers import InvenTreeDecimalField from InvenTree.serializers import InvenTreeMoneySerializer @@ -724,7 +724,7 @@ class SOShipmentAllocationItemSerializer(serializers.Serializer): def validate(self, data): - super().validate(data) + data = super().validate(data) stock_item = data['stock_item'] quantity = data['quantity'] @@ -760,6 +760,169 @@ class SalesOrderCompleteSerializer(serializers.Serializer): order.complete_order(user) +class SOSerialAllocationSerializer(serializers.Serializer): + """ + DRF serializer for allocation of serial numbers against a sales order / shipment + """ + + class Meta: + fields = [ + 'line_item', + 'quantity', + 'serial_numbers', + 'shipment', + ] + + line_item = serializers.PrimaryKeyRelatedField( + queryset=order.models.SalesOrderLineItem.objects.all(), + many=False, + required=True, + allow_null=False, + label=_('Line Item'), + ) + + def validate_line_item(self, line_item): + """ + Ensure that the line_item is valid + """ + + 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 + + quantity = serializers.IntegerField( + min_value=1, + required=True, + allow_null=False, + label=_('Quantity'), + ) + + serial_numbers = serializers.CharField( + label=_("Serial Numbers"), + help_text=_("Enter serial numbers to allocate"), + required=True, + allow_blank=False, + ) + + shipment = serializers.PrimaryKeyRelatedField( + queryset=order.models.SalesOrderShipment.objects.all(), + many=False, + allow_null=False, + required=True, + label=_('Shipment'), + ) + + def validate_shipment(self, shipment): + """ + Validate the shipment: + + - Must point to the same order + - Must not be shipped + """ + + 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): + """ + Validation for the serializer: + + - Ensure the serial_numbers and quantity fields match + - Check that all serial numbers exist + - Check that the serial numbers are not yet allocated + """ + + data = super().validate(data) + + line_item = data['line_item'] + quantity = data['quantity'] + serial_numbers = data['serial_numbers'] + + part = line_item.part + + try: + data['serials'] = extract_serial_numbers(serial_numbers, quantity) + except DjangoValidationError as e: + raise ValidationError({ + 'serial_numbers': e.messages, + }) + + serials_not_exist = [] + serials_allocated = [] + stock_items_to_allocate = [] + + for serial in data['serials']: + items = stock.models.StockItem.objects.filter( + part=part, + serial=serial, + quantity=1, + ) + + if not items.exists(): + serials_not_exist.append(str(serial)) + continue + + stock_item = items[0] + + if stock_item.unallocated_quantity() == 1: + stock_items_to_allocate.append(stock_item) + else: + serials_allocated.append(str(serial)) + + if len(serials_not_exist) > 0: + + error_msg = _("No match found for the following serial numbers") + error_msg += ": " + error_msg += ",".join(serials_not_exist) + + raise ValidationError({ + 'serial_numbers': error_msg + }) + + if len(serials_allocated) > 0: + + error_msg = _("The following serial numbers are already allocated") + error_msg += ": " + error_msg += ",".join(serials_allocated) + + raise ValidationError({ + 'serial_numbers': error_msg, + }) + + data['stock_items'] = stock_items_to_allocate + + return data + + def save(self): + + data = self.validated_data + + line_item = data['line_item'] + stock_items = data['stock_items'] + shipment = data['shipment'] + + with transaction.atomic(): + for stock_item in stock_items: + # Create a new SalesOrderAllocation + order.models.SalesOrderAllocation.objects.create( + line=line_item, + item=stock_item, + quantity=1, + shipment=shipment + ) + + class SOShipmentAllocationSerializer(serializers.Serializer): """ DRF serializer for allocation of stock items against a sales order / shipment @@ -833,11 +996,6 @@ class SOShipmentAllocationSerializer(serializers.Serializer): shipment=shipment, ) - try: - pass - except (ValidationError, DjangoValidationError) as exc: - raise ValidationError(detail=serializers.as_serializer_error(exc)) - class SOAttachmentSerializer(InvenTreeAttachmentSerializer): """ diff --git a/InvenTree/order/templates/order/so_allocate_by_serial.html b/InvenTree/order/templates/order/so_allocate_by_serial.html deleted file mode 100644 index 3e11d658c7..0000000000 --- a/InvenTree/order/templates/order/so_allocate_by_serial.html +++ /dev/null @@ -1,12 +0,0 @@ -{% extends "modal_form.html" %} -{% load i18n %} - -{% block pre_form_content %} - -
- {% include "hover_image.html" with image=part.image hover=true %}{{ part }} -
- {% trans "Allocate stock items by serial number" %} -
- -{% endblock %} \ No newline at end of file diff --git a/InvenTree/order/urls.py b/InvenTree/order/urls.py index 8cf472e0ae..504145892a 100644 --- a/InvenTree/order/urls.py +++ b/InvenTree/order/urls.py @@ -41,11 +41,6 @@ sales_order_detail_urls = [ ] sales_order_urls = [ - # URLs for sales order allocations - url(r'^allocation/', include([ - url(r'^assign-serials/', views.SalesOrderAssignSerials.as_view(), name='so-assign-serials'), - ])), - # Display detail view for a single SalesOrder url(r'^(?P\d+)/', include(sales_order_detail_urls)), diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index 6eec7145ee..c89d2a77b1 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -9,12 +9,10 @@ from django.db import transaction from django.db.utils import IntegrityError from django.http.response import JsonResponse from django.shortcuts import get_object_or_404 -from django.core.exceptions import ValidationError from django.urls import reverse from django.http import HttpResponseRedirect from django.utils.translation import ugettext_lazy as _ from django.views.generic import DetailView, ListView -from django.views.generic.edit import FormMixin from django.forms import HiddenInput, IntegerField import logging @@ -22,7 +20,6 @@ from decimal import Decimal, InvalidOperation from .models import PurchaseOrder, PurchaseOrderLineItem from .models import SalesOrder, SalesOrderLineItem -from .models import SalesOrderAllocation from .admin import POLineItemResource, SOLineItemResource from build.models import Build from company.models import Company, SupplierPart # ManufacturerPart @@ -38,7 +35,6 @@ from part.views import PartPricing from InvenTree.views import AjaxView, AjaxUpdateView from InvenTree.helpers import DownloadFile, str2bool -from InvenTree.helpers import extract_serial_numbers from InvenTree.views import InvenTreeRoleMixin from InvenTree.status_codes import PurchaseOrderStatus @@ -792,175 +788,6 @@ class OrderParts(AjaxView): order.add_line_item(supplier_part, quantity, purchase_price=purchase_price) -class SalesOrderAssignSerials(AjaxView, FormMixin): - """ - View for assigning stock items to a sales order, - by serial number lookup. - """ - # TODO: Remove this class and replace with an API endpoint - - model = SalesOrderAllocation - role_required = 'sales_order.change' - ajax_template_name = 'order/so_allocate_by_serial.html' - ajax_form_title = _('Allocate Serial Numbers') - form_class = order_forms.AllocateSerialsToSalesOrderForm - - # Keep track of SalesOrderLineItem and Part references - line = None - part = None - - def get_initial(self): - """ - Initial values are passed as query params - """ - - initials = super().get_initial() - - try: - self.line = SalesOrderLineItem.objects.get(pk=self.request.GET.get('line', None)) - initials['line'] = self.line - except (ValueError, SalesOrderLineItem.DoesNotExist): - pass - - try: - self.part = Part.objects.get(pk=self.request.GET.get('part', None)) - initials['part'] = self.part - except (ValueError, Part.DoesNotExist): - pass - - return initials - - def post(self, request, *args, **kwargs): - - self.form = self.get_form() - - # Validate the form - self.form.is_valid() - self.validate() - - valid = self.form.is_valid() - - if valid: - self.allocate_items() - - data = { - 'form_valid': valid, - 'form_errors': self.form.errors.as_json(), - 'non_field_errors': self.form.non_field_errors().as_json(), - 'success': _("Allocated {n} items").format(n=len(self.stock_items)) - } - - return self.renderJsonResponse(request, self.form, data) - - def validate(self): - - data = self.form.cleaned_data - - # Extract hidden fields from posted data - self.line = data.get('line', None) - self.part = data.get('part', None) - - if self.line: - self.form.fields['line'].widget = HiddenInput() - else: - self.form.add_error('line', _('Select line item')) - - if self.part: - self.form.fields['part'].widget = HiddenInput() - else: - self.form.add_error('part', _('Select part')) - - if not self.form.is_valid(): - return - - # Form is otherwise valid - check serial numbers - serials = data.get('serials', '') - quantity = data.get('quantity', 1) - - # Save a list of serial_numbers - self.serial_numbers = None - self.stock_items = [] - - try: - self.serial_numbers = extract_serial_numbers(serials, quantity) - - for serial in self.serial_numbers: - try: - # Find matching stock item - stock_item = StockItem.objects.get( - part=self.part, - serial=serial - ) - except StockItem.DoesNotExist: - self.form.add_error( - 'serials', - _('No matching item for serial {serial}').format(serial=serial) - ) - continue - - # Now we have a valid stock item - but can it be added to the sales order? - - # If not in stock, cannot be added to the order - if not stock_item.in_stock: - self.form.add_error( - 'serials', - _('{serial} is not in stock').format(serial=serial) - ) - continue - - # Already allocated to an order - if stock_item.is_allocated(): - self.form.add_error( - 'serials', - _('{serial} already allocated to an order').format(serial=serial) - ) - continue - - # Add it to the list! - self.stock_items.append(stock_item) - - except ValidationError as e: - self.form.add_error('serials', e.messages) - - def allocate_items(self): - """ - Create stock item allocations for each selected serial number - """ - - for stock_item in self.stock_items: - SalesOrderAllocation.objects.create( - item=stock_item, - line=self.line, - quantity=1, - ) - - def get_form(self): - - form = super().get_form() - - if self.line: - form.fields['line'].widget = HiddenInput() - - if self.part: - form.fields['part'].widget = HiddenInput() - - return form - - def get_context_data(self): - return { - 'line': self.line, - 'part': self.part, - } - - def get(self, request, *args, **kwargs): - - return self.renderJsonResponse( - request, - self.get_form(), - context=self.get_context_data(), - ) - - class LineItemPricing(PartPricing): """ View for inspecting part pricing information """ diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 58c86d1894..df8821fe07 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -2357,15 +2357,30 @@ function loadSalesOrderLineItemTable(table, options={}) { $(table).find('.button-add-by-sn').click(function() { var pk = $(this).attr('pk'); - // TODO: Migrate this form to the API forms inventreeGet(`/api/order/so-line/${pk}/`, {}, { success: function(response) { - launchModalForm('{% url "so-assign-serials" %}', { - success: reloadTable, - data: { - line: pk, - part: response.part, + + constructForm(`/api/order/so/${options.order}/allocate-serials/`, { + method: 'POST', + title: '{% trans "Allocate Serial Numbers" %}', + fields: { + line_item: { + value: pk, + hidden: true, + }, + quantity: {}, + serial_numbers: {}, + shipment: { + filters: { + order: options.order, + shipped: false, + }, + auto_fill: true, + } + }, + onSuccess: function() { + $(table).bootstrapTable('refresh'); } }); }