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):