diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py index b5edec386a..bb6cb40889 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,7 +213,65 @@ class EditSalesOrderLineItemForm(HelperForm): ] +class AllocateSerialsToSalesOrderForm(forms.Form): + """ + Form for assigning stock to a sales order, + by serial number lookup + """ + + 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 CreateSalesOrderAllocationForm(HelperForm): + """ + Form for creating a SalesOrderAllocation item. + """ + + quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5) + + 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/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 c3b33eaace..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): @@ -732,6 +731,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/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/test_sales_order.py b/InvenTree/order/test_sales_order.py index c619aec5bc..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 @@ -73,10 +72,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): 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 2cd8e8a4cb..bdf0407603 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -7,9 +7,11 @@ 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 +from django.views.generic.edit import FormMixin from django.forms import HiddenInput import logging @@ -30,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 @@ -1291,11 +1294,179 @@ class SOLineItemDelete(AjaxDeleteView): } +class SalesOrderAssignSerials(AjaxView, FormMixin): + """ + View for assigning stock items to a sales order, + by serial number lookup. + """ + + 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") + f" {len(self.stock_items)} " + _("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') + 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): + + 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 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):