From cffe2ba84b4c4aafdd332e8fa3a9be11f4fda211 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 29 Mar 2021 16:43:26 +1100 Subject: [PATCH 1/8] Add a separate form for creating a sales order allocation --- InvenTree/order/forms.py | 29 +++++++++++++++++++++++++++++ InvenTree/order/views.py | 2 +- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py index b5edec386a..06764f2bc9 100644 --- a/InvenTree/order/forms.py +++ b/InvenTree/order/forms.py @@ -211,7 +211,36 @@ class EditSalesOrderLineItemForm(HelperForm): ] +class CreateSalesOrderAllocationForm(HelperForm): + """ + Form for creating a SalesOrderAllocation item. + + This can be allocated by selecting a specific stock item, + or by providing a sequence of serial numbers + """ + + quantity = RoundingDecimalFormField(max_digits = 10, decimal_places=5) + + serials = forms.CharField( + label=_("Serial Numbers"), + required=False, + help_text=_('Enter stock serial numbers'), + ) + + class Meta: + model = SalesOrderAllocation + + fields = [ + 'line', + 'item', + 'quantity', + ] + + class EditSalesOrderAllocationForm(HelperForm): + """ + Form for editing a SalesOrderAllocation item + """ quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5) diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index 2cd8e8a4cb..8b40d6d724 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -1295,7 +1295,7 @@ class SalesOrderAllocationCreate(AjaxCreateView): """ View for creating a new SalesOrderAllocation """ model = SalesOrderAllocation - form_class = order_forms.EditSalesOrderAllocationForm + form_class = order_forms.CreateSalesOrderAllocationForm ajax_form_title = _('Allocate Stock to Order') def get_initial(self): From bd87f4c733918965fcfb431cc7b5633c0c5f1c76 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 29 Mar 2021 23:10:36 +1100 Subject: [PATCH 2/8] Adds form to assign stock item by serial numbers --- InvenTree/order/forms.py | 37 ++++++++-- InvenTree/order/models.py | 6 ++ .../templates/order/sales_order_detail.html | 31 +++++++-- InvenTree/order/urls.py | 1 + InvenTree/order/views.py | 69 +++++++++++++++++++ 5 files changed, 133 insertions(+), 11 deletions(-) diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py index 06764f2bc9..0a015e2b66 100644 --- a/InvenTree/order/forms.py +++ b/InvenTree/order/forms.py @@ -14,6 +14,8 @@ from InvenTree.forms import HelperForm from InvenTree.fields import RoundingDecimalFormField from InvenTree.fields import DatePickerFormField +import part.models + from stock.models import StockLocation from .models import PurchaseOrder, PurchaseOrderLineItem, PurchaseOrderAttachment from .models import SalesOrder, SalesOrderLineItem, SalesOrderAttachment @@ -211,22 +213,43 @@ class EditSalesOrderLineItemForm(HelperForm): ] -class CreateSalesOrderAllocationForm(HelperForm): +class AllocateSerialsToSalesOrderForm(HelperForm): """ - Form for creating a SalesOrderAllocation item. - - This can be allocated by selecting a specific stock item, - or by providing a sequence of serial numbers + Form for assigning stock to a sales order, + by serial number lookup """ - quantity = RoundingDecimalFormField(max_digits = 10, decimal_places=5) + line = forms.ModelChoiceField( + queryset = SalesOrderLineItem.objects.all(), + ) + + part = forms.ModelChoiceField( + queryset = part.models.Part.objects.all(), + ) serials = forms.CharField( label=_("Serial Numbers"), required=False, - help_text=_('Enter stock serial numbers'), + help_text=_('Enter stock item serial numbers'), ) + class Meta: + model = SalesOrderAllocation + + fields = [ + 'line', + 'part', + 'serials', + ] + + +class CreateSalesOrderAllocationForm(HelperForm): + """ + Form for creating a SalesOrderAllocation item. + """ + + quantity = RoundingDecimalFormField(max_digits = 10, decimal_places=5) + class Meta: model = SalesOrderAllocation diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index c3b33eaace..b621cda156 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -732,6 +732,12 @@ class SalesOrderAllocation(models.Model): errors = {} + try: + if not self.item: + raise ValidationError({'item': _('Stock item has not been assigned')}) + except stock_models.StockItem.DoesNotExist: + raise ValidationError({'item': _('Stock item has not been assigned')}) + try: if not self.line.part == self.item.part: errors['item'] = _('Cannot allocate stock item to a line with a different part') diff --git a/InvenTree/order/templates/order/sales_order_detail.html b/InvenTree/order/templates/order/sales_order_detail.html index 27bbd542dd..7b4a4ed4f7 100644 --- a/InvenTree/order/templates/order/sales_order_detail.html +++ b/InvenTree/order/templates/order/sales_order_detail.html @@ -275,15 +275,20 @@ $("#so-lines-table").inventreeTable({ if (row.part) { var part = row.part_detail; + if (part.trackable) { + html += makeIconButton('fa-hashtag icon-green', 'button-add-by-sn', pk, '{% trans "Allocate serial numbers" %}'); + } + + html += makeIconButton('fa-sign-in-alt icon-green', 'button-add', pk, '{% trans "Allocate stock" %}'); + if (part.purchaseable) { - html += makeIconButton('fa-shopping-cart', 'button-buy', row.part, '{% trans "Buy parts" %}'); + html += makeIconButton('fa-shopping-cart', 'button-buy', row.part, '{% trans "Purchase stock" %}'); } if (part.assembly) { - html += makeIconButton('fa-tools', 'button-build', row.part, '{% trans "Build parts" %}'); + html += makeIconButton('fa-tools', 'button-build', row.part, '{% trans "Build stock" %}'); } - html += makeIconButton('fa-plus icon-green', 'button-add', pk, '{% trans "Allocate parts" %}'); } html += makeIconButton('fa-edit icon-blue', 'button-edit', pk, '{% trans "Edit line item" %}'); @@ -316,10 +321,28 @@ function setupCallbacks() { var pk = $(this).attr('pk'); launchModalForm(`/order/sales-order/line/${pk}/delete/`, { - reload: true, + success: reloadTable, }); }); + table.find(".button-add-by-sn").click(function() { + var pk = $(this).attr('pk'); + + inventreeGet(`/api/order/so-line/${pk}/`, {}, + { + success: function(response) { + launchModalForm('{% url "so-assign-serials" %}', { + success: reloadTable, + data: { + line: pk, + part: response.part, + } + }); + } + } + ); + }); + table.find(".button-add").click(function() { var pk = $(this).attr('pk'); diff --git a/InvenTree/order/urls.py b/InvenTree/order/urls.py index 7707b73f37..97903d81c1 100644 --- a/InvenTree/order/urls.py +++ b/InvenTree/order/urls.py @@ -81,6 +81,7 @@ sales_order_urls = [ # URLs for sales order allocations url(r'^allocation/', include([ url(r'^new/', views.SalesOrderAllocationCreate.as_view(), name='so-allocation-create'), + url(r'^assign-serials/', views.SalesOrderAssignSerials.as_view(), name='so-assign-serials'), url(r'(?P\d+)/', include([ url(r'^edit/', views.SalesOrderAllocationEdit.as_view(), name='so-allocation-edit'), url(r'^delete/', views.SalesOrderAllocationDelete.as_view(), name='so-allocation-delete'), diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index 8b40d6d724..eed4868557 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -1291,6 +1291,75 @@ class SOLineItemDelete(AjaxDeleteView): } +class SalesOrderAssignSerials(AjaxCreateView): + """ + View for assigning stock items to a sales order, + by serial number lookup. + """ + + model = SalesOrderAllocation + role_required = 'sales_order.change' + 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 get_form(self): + + form = super().get_form() + + if self.line is not None: + form.fields['line'].widget = HiddenInput() + + # Hide the 'part' field if value provided + try: + print(form['part']) + # self.part = Part.objects.get(form['part'].value()) + except (ValueError, Part.DoesNotExist): + self.part = None + + if self.part is not None: + 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 SalesOrderAllocationCreate(AjaxCreateView): """ View for creating a new SalesOrderAllocation """ From d64dd6840326f2c76cd734ef335af474e592b43f Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 29 Mar 2021 23:32:41 +1100 Subject: [PATCH 3/8] Agk, working out forms is hard --- InvenTree/order/forms.py | 3 +-- InvenTree/order/views.py | 44 ++++++++++++++++++++++++++++++++-------- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py index 0a015e2b66..8a1b8ce8ba 100644 --- a/InvenTree/order/forms.py +++ b/InvenTree/order/forms.py @@ -213,7 +213,7 @@ class EditSalesOrderLineItemForm(HelperForm): ] -class AllocateSerialsToSalesOrderForm(HelperForm): +class AllocateSerialsToSalesOrderForm(forms.Form): """ Form for assigning stock to a sales order, by serial number lookup @@ -234,7 +234,6 @@ class AllocateSerialsToSalesOrderForm(HelperForm): ) class Meta: - model = SalesOrderAllocation fields = [ 'line', diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index eed4868557..53e7e34d80 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -10,6 +10,7 @@ from django.shortcuts import get_object_or_404 from django.urls import reverse from django.utils.translation import ugettext as _ from django.views.generic import DetailView, ListView, UpdateView +from django.views.generic.edit import FormMixin from django.forms import HiddenInput import logging @@ -1291,7 +1292,7 @@ class SOLineItemDelete(AjaxDeleteView): } -class SalesOrderAssignSerials(AjaxCreateView): +class SalesOrderAssignSerials(AjaxView, FormMixin): """ View for assigning stock items to a sales order, by serial number lookup. @@ -1327,6 +1328,40 @@ class SalesOrderAssignSerials(AjaxCreateView): 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() + + data = { + 'form_valid': valid, + 'form_errors': self.form.errors.as_json(), + 'non_field_errors': self.form.non_field_errors().as_json(), + } + + return self.renderJsonResponse(request, self.get_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 not self.line: + self.form.add_error('line', _('Select line item')) + + if not self.part: + self.form.add_error('part', _('Select part')) + + self.form.add_error(None, 'abcde') + def get_form(self): form = super().get_form() @@ -1334,13 +1369,6 @@ class SalesOrderAssignSerials(AjaxCreateView): if self.line is not None: form.fields['line'].widget = HiddenInput() - # Hide the 'part' field if value provided - try: - print(form['part']) - # self.part = Part.objects.get(form['part'].value()) - except (ValueError, Part.DoesNotExist): - self.part = None - if self.part is not None: form.fields['part'].widget = HiddenInput() From 19059ea4cfd5f134700fbac01dc25cabff9a3d3f Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 29 Mar 2021 23:38:38 +1100 Subject: [PATCH 4/8] Tweaks --- InvenTree/order/views.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index 53e7e34d80..02b9c10e8b 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -1344,7 +1344,7 @@ class SalesOrderAssignSerials(AjaxView, FormMixin): 'non_field_errors': self.form.non_field_errors().as_json(), } - return self.renderJsonResponse(request, self.get_form(), data) + return self.renderJsonResponse(request, self.form, data) def validate(self): @@ -1354,22 +1354,26 @@ class SalesOrderAssignSerials(AjaxView, FormMixin): self.line = data.get('line', None) self.part = data.get('part', None) - if not self.line: + if self.line: + self.form.fields['line'].widget = HiddenInput() + else: self.form.add_error('line', _('Select line item')) - if not self.part: + if self.part: + self.form.fields['part'].widget = HiddenInput() + else: self.form.add_error('part', _('Select part')) - self.form.add_error(None, 'abcde') + self.form.add_error('serials', 'abcde') def get_form(self): form = super().get_form() - if self.line is not None: + if self.line: form.fields['line'].widget = HiddenInput() - if self.part is not None: + if self.part: form.fields['part'].widget = HiddenInput() return form @@ -1381,6 +1385,7 @@ class SalesOrderAssignSerials(AjaxView, FormMixin): } def get(self, request, *args, **kwargs): + return self.renderJsonResponse( request, self.get_form(), From 217097c9d3cf32281f01da8ef17166bd0e4b4ffd Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 30 Mar 2021 00:10:28 +1100 Subject: [PATCH 5/8] Add custom form template --- InvenTree/order/forms.py | 17 +++-- InvenTree/order/models.py | 2 +- .../order/so_allocate_by_serial.html | 12 ++++ InvenTree/order/views.py | 71 ++++++++++++++++++- 4 files changed, 96 insertions(+), 6 deletions(-) create mode 100644 InvenTree/order/templates/order/so_allocate_by_serial.html diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py index 8a1b8ce8ba..bb6cb40889 100644 --- a/InvenTree/order/forms.py +++ b/InvenTree/order/forms.py @@ -220,25 +220,34 @@ class AllocateSerialsToSalesOrderForm(forms.Form): """ line = forms.ModelChoiceField( - queryset = SalesOrderLineItem.objects.all(), + queryset=SalesOrderLineItem.objects.all(), ) part = forms.ModelChoiceField( - queryset = part.models.Part.objects.all(), + queryset=part.models.Part.objects.all(), ) serials = forms.CharField( label=_("Serial Numbers"), - required=False, + 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', ] @@ -247,7 +256,7 @@ class CreateSalesOrderAllocationForm(HelperForm): Form for creating a SalesOrderAllocation item. """ - quantity = RoundingDecimalFormField(max_digits = 10, decimal_places=5) + quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5) class Meta: model = SalesOrderAllocation diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index b621cda156..6bd79c20ed 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -736,7 +736,7 @@ class SalesOrderAllocation(models.Model): if not self.item: raise ValidationError({'item': _('Stock item has not been assigned')}) except stock_models.StockItem.DoesNotExist: - raise ValidationError({'item': _('Stock item has not been assigned')}) + raise ValidationError({'item': _('Stock item has not been assigned')}) try: if not self.line.part == self.item.part: diff --git a/InvenTree/order/templates/order/so_allocate_by_serial.html b/InvenTree/order/templates/order/so_allocate_by_serial.html new file mode 100644 index 0000000000..3e11d658c7 --- /dev/null +++ b/InvenTree/order/templates/order/so_allocate_by_serial.html @@ -0,0 +1,12 @@ +{% 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/views.py b/InvenTree/order/views.py index 02b9c10e8b..bdf0407603 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -7,6 +7,7 @@ from __future__ import unicode_literals from django.db import transaction from django.shortcuts import get_object_or_404 +from django.core.exceptions import ValidationError from django.urls import reverse from django.utils.translation import ugettext as _ from django.views.generic import DetailView, ListView, UpdateView @@ -31,6 +32,7 @@ from . import forms as order_forms from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView from InvenTree.helpers import DownloadFile, str2bool +from InvenTree.helpers import extract_serial_numbers from InvenTree.views import InvenTreeRoleMixin from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus @@ -1300,6 +1302,7 @@ class SalesOrderAssignSerials(AjaxView, FormMixin): 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 @@ -1338,10 +1341,14 @@ class SalesOrderAssignSerials(AjaxView, FormMixin): 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") + f" {len(self.stock_items)} " + _("items") } return self.renderJsonResponse(request, self.form, data) @@ -1364,7 +1371,69 @@ class SalesOrderAssignSerials(AjaxView, FormMixin): else: self.form.add_error('part', _('Select part')) - self.form.add_error('serials', 'abcde') + 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') + f" '{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', + f"'{serial}' " + _("is not in stock") + ) + continue + + # Already allocated to an order + if stock_item.is_allocated(): + self.form.add_error( + 'serials', + f"'{serial}' " + _("already allocated to an order") + ) + 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): From 709bfb1bd2e0967a0b2df2a5ef345a2ea018ada6 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 30 Mar 2021 00:14:47 +1100 Subject: [PATCH 6/8] Remove "unique" constraint for part / order relationship --- .../order/migrations/0043_auto_20210330_0013.py | 17 +++++++++++++++++ InvenTree/order/models.py | 1 - 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 InvenTree/order/migrations/0043_auto_20210330_0013.py diff --git a/InvenTree/order/migrations/0043_auto_20210330_0013.py b/InvenTree/order/migrations/0043_auto_20210330_0013.py new file mode 100644 index 0000000000..35c4b99bcb --- /dev/null +++ b/InvenTree/order/migrations/0043_auto_20210330_0013.py @@ -0,0 +1,17 @@ +# Generated by Django 3.0.7 on 2021-03-29 13:13 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0042_auto_20210310_1619'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='salesorderlineitem', + unique_together=set(), + ), + ] diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 6bd79c20ed..cb78eabc6a 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -663,7 +663,6 @@ class SalesOrderLineItem(OrderLineItem): class Meta: unique_together = [ - ('order', 'part'), ] def fulfilled_quantity(self): From 408b9d5e5bdad1e63a960fd8946a835ee9bfd435 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 30 Mar 2021 08:08:55 +1100 Subject: [PATCH 7/8] Fix for unit testing --- InvenTree/order/test_sales_order.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/InvenTree/order/test_sales_order.py b/InvenTree/order/test_sales_order.py index c619aec5bc..fce6fcb904 100644 --- a/InvenTree/order/test_sales_order.py +++ b/InvenTree/order/test_sales_order.py @@ -73,10 +73,10 @@ class SalesOrderTest(TestCase): self.assertFalse(self.order.is_fully_allocated()) def test_add_duplicate_line_item(self): - # Adding a duplicate line item to a SalesOrder must throw an error + # Adding a duplicate line item to a SalesOrder is accepted - with self.assertRaises(IntegrityError): - SalesOrderLineItem.objects.create(order=self.order, part=self.part) + for ii in range(1, 5): + SalesOrderLineItem.objects.create(order=self.order, part=self.part, quantity=ii) def allocate_stock(self, full=True): From bfbdd72306047a9758f7e22e2eb203a817837e36 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 30 Mar 2021 08:43:09 +1100 Subject: [PATCH 8/8] Remove unused import --- InvenTree/order/test_sales_order.py | 1 - 1 file changed, 1 deletion(-) diff --git a/InvenTree/order/test_sales_order.py b/InvenTree/order/test_sales_order.py index fce6fcb904..0b37b96409 100644 --- a/InvenTree/order/test_sales_order.py +++ b/InvenTree/order/test_sales_order.py @@ -3,7 +3,6 @@ from django.test import TestCase from django.core.exceptions import ValidationError -from django.db.utils import IntegrityError from datetime import datetime, timedelta