Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2019-08-29 08:21:21 +10:00
commit 7ae7c19a24
12 changed files with 388 additions and 71 deletions

View File

@ -131,11 +131,21 @@ def ExtractSerialNumbers(serials, expected_quantity):
expected_quantity: The number of (unique) serial numbers we expect expected_quantity: The number of (unique) serial numbers we expect
""" """
serials = serials.strip()
groups = re.split("[\s,]+", serials) groups = re.split("[\s,]+", serials)
numbers = [] numbers = []
errors = [] errors = []
try:
expected_quantity = int(expected_quantity)
except ValueError:
raise ValidationError([_("Invalid quantity provided")])
if len(serials) == 0:
raise ValidationError([_("Empty serial number string")])
for group in groups: for group in groups:
group = group.strip() group = group.strip()
@ -155,34 +165,34 @@ def ExtractSerialNumbers(serials, expected_quantity):
if a < b: if a < b:
for n in range(a, b + 1): for n in range(a, b + 1):
if n in numbers: if n in numbers:
errors.append('Duplicate serial: {n}'.format(n=n)) errors.append(_('Duplicate serial: {n}'.format(n=n)))
else: else:
numbers.append(n) numbers.append(n)
else: else:
errors.append("Invalid group: {g}".format(g=group)) errors.append(_("Invalid group: {g}".format(g=group)))
except ValueError: except ValueError:
errors.append("Invalid group: {g}".format(g=group)) errors.append(_("Invalid group: {g}".format(g=group)))
continue continue
else: else:
errors.append("Invalid group: {g}".format(g=group)) errors.append(_("Invalid group: {g}".format(g=group)))
continue continue
else: else:
try: try:
n = int(group) n = int(group)
if n in numbers: if n in numbers:
errors.append("Duplicate serial: {n}".format(n=n)) errors.append(_("Duplicate serial: {n}".format(n=n)))
else: else:
numbers.append(n) numbers.append(n)
except ValueError: except ValueError:
errors.append("Invalid group: {g}".format(g=group)) errors.append(_("Invalid group: {g}".format(g=group)))
if len(errors) > 0: if len(errors) > 0:
raise ValidationError(errors) raise ValidationError(errors)
if len(numbers) == 0: if len(numbers) == 0:
raise ValidationError(["No serial numbers found"]) raise ValidationError([_("No serial numbers found")])
# The number of extracted serial numbers must match the expected quantity # The number of extracted serial numbers must match the expected quantity
if not expected_quantity == len(numbers): if not expected_quantity == len(numbers):

View File

@ -5,6 +5,8 @@ Django Forms for interacting with Build objects
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from django.utils.translation import ugettext as _
from InvenTree.forms import HelperForm from InvenTree.forms import HelperForm
from django import forms from django import forms
from .models import Build, BuildItem from .models import Build, BuildItem
@ -31,7 +33,7 @@ class EditBuildForm(HelperForm):
class ConfirmBuildForm(HelperForm): class ConfirmBuildForm(HelperForm):
""" Form for auto-allocation of stock to a build """ """ Form for auto-allocation of stock to a build """
confirm = forms.BooleanField(required=False, help_text='Confirm') confirm = forms.BooleanField(required=False, help_text=_('Confirm'))
class Meta: class Meta:
model = Build model = Build
@ -48,9 +50,9 @@ class CompleteBuildForm(HelperForm):
help_text='Location of completed parts', help_text='Location of completed parts',
) )
serial_numbers = forms.CharField(label='Serial numbers', help_text='Enter unique serial numbers') serial_numbers = forms.CharField(label='Serial numbers', required=False, help_text=_('Enter unique serial numbers (or leave blank)'))
confirm = forms.BooleanField(required=False, help_text='Confirm build submission') confirm = forms.BooleanField(required=False, help_text=_('Confirm build completion'))
class Meta: class Meta:
model = Build model = Build

View File

@ -270,25 +270,29 @@ class BuildComplete(AjaxUpdateView):
sn = request.POST.get('serial_numbers', '') sn = request.POST.get('serial_numbers', '')
try: sn = str(sn).strip()
# Exctract a list of provided serial numbers
serials = ExtractSerialNumbers(sn, build.quantity)
existing = [] # If the user has specified serial numbers, check they are valid
if len(sn) > 0:
try:
# Exctract a list of provided serial numbers
serials = ExtractSerialNumbers(sn, build.quantity)
for serial in serials: existing = []
if not StockItem.check_serial_number(build.part, serial):
existing.append(serial)
if len(existing) > 0: for serial in serials:
exists = ",".join([str(x) for x in existing]) if not StockItem.check_serial_number(build.part, serial):
form.errors['serial_numbers'] = [_('The following serial numbers already exist: ({sn})'.format(sn=exists))] existing.append(serial)
if len(existing) > 0:
exists = ",".join([str(x) for x in existing])
form.errors['serial_numbers'] = [_('The following serial numbers already exist: ({sn})'.format(sn=exists))]
valid = False
except ValidationError as e:
form.errors['serial_numbers'] = e.messages
valid = False valid = False
except ValidationError as e:
form.errors['serial_numbers'] = e.messages
valid = False
if valid: if valid:
build.completeBuild(location, serials, request.user) build.completeBuild(location, serials, request.user)

View File

@ -43,6 +43,7 @@
name: 'Widget' name: 'Widget'
description: 'A watchamacallit' description: 'A watchamacallit'
category: 7 category: 7
trackable: true
- model: part.part - model: part.part
pk: 50 pk: 50

View File

@ -843,7 +843,8 @@ class Part(models.Model):
# Copy the BOM data # Copy the BOM data
if kwargs.get('bom', False): if kwargs.get('bom', False):
for item in other.bom_items.all(): for item in other.bom_items.all():
# Point the item to THIS part # Point the item to THIS part.
# Set the pk to None so a new entry is created.
item.part = self item.part = self
item.pk = None item.pk = None
item.save() item.save()

View File

@ -7,6 +7,7 @@ from __future__ import unicode_literals
from django import forms from django import forms
from django.forms.utils import ErrorDict from django.forms.utils import ErrorDict
from django.utils.translation import ugettext as _
from InvenTree.forms import HelperForm from InvenTree.forms import HelperForm
from .models import StockLocation, StockItem, StockItemTracking from .models import StockLocation, StockItem, StockItemTracking
@ -27,7 +28,7 @@ class EditStockLocationForm(HelperForm):
class CreateStockItemForm(HelperForm): class CreateStockItemForm(HelperForm):
""" Form for creating a new StockItem """ """ Form for creating a new StockItem """
serial_numbers = forms.CharField(label='Serial numbers', required=False, help_text='Enter unique serial numbers') serial_numbers = forms.CharField(label='Serial numbers', required=False, help_text=_('Enter unique serial numbers (or leave blank)'))
class Meta: class Meta:
model = StockItem model = StockItem
@ -62,6 +63,39 @@ class CreateStockItemForm(HelperForm):
self._clean_form() self._clean_form()
class SerializeStockForm(forms.ModelForm):
""" Form for serializing a StockItem. """
destination = forms.ChoiceField(label='Destination', required=True, help_text='Destination for serialized stock (by default, will remain in current location)')
serial_numbers = forms.CharField(label='Serial numbers', required=True, help_text='Unique serial numbers (must match quantity)')
note = forms.CharField(label='Notes', required=False, help_text='Add transaction note (optional)')
def get_location_choices(self):
locs = StockLocation.objects.all()
choices = [(None, '---------')]
for loc in locs:
choices.append((loc.pk, loc.pathstring + ' - ' + loc.description))
return choices
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['destination'].choices = self.get_location_choices()
class Meta:
model = StockItem
fields = [
'quantity',
'serial_numbers',
'destination',
'note',
]
class AdjustStockForm(forms.ModelForm): class AdjustStockForm(forms.ModelForm):
""" Form for performing simple stock adjustments. """ Form for performing simple stock adjustments.

View File

@ -138,6 +138,12 @@ class StockItem(models.Model):
if not part.trackable: if not part.trackable:
return False return False
# Return False if an invalid serial number is supplied
try:
serial_number = int(serial_number)
except ValueError:
return False
items = StockItem.objects.filter(serial=serial_number) items = StockItem.objects.filter(serial=serial_number)
# Is this part a variant? If so, check S/N across all sibling variants # Is this part a variant? If so, check S/N across all sibling variants
@ -204,12 +210,15 @@ class StockItem(models.Model):
}) })
if self.quantity == 0: if self.quantity == 0:
self.quantity = 1
elif self.quantity > 1:
raise ValidationError({ raise ValidationError({
'quantity': _('Quantity must be 1 for item with a serial number') 'quantity': _('Quantity must be 1 for item with a serial number')
}) })
if self.delete_on_deplete: # Serial numbered items cannot be deleted on depletion
raise ValidationError({'delete_on_deplete': _("Must be set to False for item with a serial number")}) self.delete_on_deplete = False
# A template part cannot be instantiated as a StockItem # A template part cannot be instantiated as a StockItem
if self.part.is_template: if self.part.is_template:
@ -350,6 +359,7 @@ class StockItem(models.Model):
Brief automated note detailing a movement or quantity change. Brief automated note detailing a movement or quantity change.
""" """
track = StockItemTracking.objects.create( track = StockItemTracking.objects.create(
item=self, item=self,
title=title, title=title,
@ -364,12 +374,88 @@ class StockItem(models.Model):
track.save() track.save()
@transaction.atomic @transaction.atomic
def serializeStock(self, serials, user): def serializeStock(self, quantity, serials, user, notes='', location=None):
""" Split this stock item into unique serial numbers. """ Split this stock item into unique serial numbers.
- Quantity can be less than or equal to the quantity of the stock item
- Number of serial numbers must match the quantity
- Provided serial numbers must not already be in use
Args:
quantity: Number of items to serialize (integer)
serials: List of serial numbers (list<int>)
user: User object associated with action
notes: Optional notes for tracking
location: If specified, serialized items will be placed in the given location
""" """
# TODO # Cannot serialize stock that is already serialized!
pass if self.serialized:
return
# Quantity must be a valid integer value
try:
quantity = int(quantity)
except ValueError:
raise ValidationError({"quantity": _("Quantity must be integer")})
if quantity <= 0:
raise ValidationError({"quantity": _("Quantity must be greater than zero")})
if quantity > self.quantity:
raise ValidationError({"quantity": _("Quantity must not exceed available stock quantity ({n})".format(n=self.quantity))})
if not type(serials) in [list, tuple]:
raise ValidationError({"serial_numbers": _("Serial numbers must be a list of integers")})
if any([type(i) is not int for i in serials]):
raise ValidationError({"serial_numbers": _("Serial numbers must be a list of integers")})
if not quantity == len(serials):
raise ValidationError({"quantity": _("Quantity does not match serial numbers")})
# Test if each of the serial numbers are valid
existing = []
for serial in serials:
if not StockItem.check_serial_number(self.part, serial):
existing.append(serial)
if len(existing) > 0:
raise ValidationError({"serial_numbers": _("Serial numbers already exist: ") + str(existing)})
# Create a new stock item for each unique serial number
for serial in serials:
# Create a copy of this StockItem
new_item = StockItem.objects.get(pk=self.pk)
new_item.quantity = 1
new_item.serial = serial
new_item.pk = None
if location:
new_item.location = location
new_item.save()
# Copy entire transaction history
new_item.copyHistoryFrom(self)
# Create a new stock tracking item
new_item.addTransactionNote(_('Add serial number'), user, notes=notes)
# Remove the equivalent number of items
self.take_stock(quantity, user, notes=_('Serialized {n} items'.format(n=quantity)))
@transaction.atomic
def copyHistoryFrom(self, other):
""" Copy stock history from another part """
for item in other.tracking_info.all():
item.item = self
item.pk = None
item.save()
@transaction.atomic @transaction.atomic
def splitStock(self, quantity, user): def splitStock(self, quantity, user):
@ -411,6 +497,9 @@ class StockItem(models.Model):
new_stock.save() new_stock.save()
# Copy the transaction history
new_stock.copyHistoryFrom(self)
# Add a new tracking item for the new stock item # Add a new tracking item for the new stock item
new_stock.addTransactionNote( new_stock.addTransactionNote(
"Split from existing stock", "Split from existing stock",

View File

@ -24,6 +24,11 @@
<button type='button' class='btn btn-default btn-glyph' id='stock-count' title='Count stock'> <button type='button' class='btn btn-default btn-glyph' id='stock-count' title='Count stock'>
<span class='glyphicon glyphicon-ok-circle'/> <span class='glyphicon glyphicon-ok-circle'/>
</button> </button>
{% if item.part.trackable %}
<button type='button' class='btn btn-default btn-glyph' id='stock-serialize' title='Serialize stock'>
<span class='glyphicon glyphicon-th-list'/>
</button>
{% endif %}
{% endif %} {% endif %}
<button type='button' class='btn btn-default btn-glyph' id='stock-move' title='Transfer stock'> <button type='button' class='btn btn-default btn-glyph' id='stock-move' title='Transfer stock'>
<span class='glyphicon glyphicon-transfer' style='color: #11a;'/> <span class='glyphicon glyphicon-transfer' style='color: #11a;'/>
@ -162,6 +167,15 @@
); );
}); });
$("#stock-serialize").click(function() {
launchModalForm(
"{% url 'stock-item-serialize' item.id %}",
{
reload: true,
}
);
});
$("#stock-duplicate").click(function() { $("#stock-duplicate").click(function() {
launchModalForm( launchModalForm(
"{% url 'stock-item-create' %}", "{% url 'stock-item-create' %}",

View File

@ -0,0 +1,6 @@
{% extends "modal_form.html" %}
{% block pre_form_content %}
Create serialized items from this stock item.<br>
Select quantity to serialize, and unique serial numbers.
{% endblock %}

View File

@ -1,5 +1,7 @@
from django.test import TestCase from django.test import TestCase
from django.db.models import Sum from django.db.models import Sum
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from .models import StockLocation, StockItem, StockItemTracking from .models import StockLocation, StockItem, StockItemTracking
from part.models import Part from part.models import Part
@ -28,6 +30,14 @@ class StockTest(TestCase):
self.drawer2 = StockLocation.objects.get(name='Drawer_2') self.drawer2 = StockLocation.objects.get(name='Drawer_2')
self.drawer3 = StockLocation.objects.get(name='Drawer_3') self.drawer3 = StockLocation.objects.get(name='Drawer_3')
# Create a user
User = get_user_model()
User.objects.create_user('username', 'user@email.com', 'password')
self.client.login(username='username', password='password')
self.user = User.objects.get(username='username')
def test_loc_count(self): def test_loc_count(self):
self.assertEqual(StockLocation.objects.count(), 7) self.assertEqual(StockLocation.objects.count(), 7)
@ -244,3 +254,71 @@ class StockTest(TestCase):
with self.assertRaises(StockItem.DoesNotExist): with self.assertRaises(StockItem.DoesNotExist):
w2 = StockItem.objects.get(pk=101) w2 = StockItem.objects.get(pk=101)
def test_serialize_stock_invalid(self):
"""
Test manual serialization of parts.
Each of these tests should fail
"""
# Test serialization of non-serializable part
item = StockItem.objects.get(pk=1234)
with self.assertRaises(ValidationError):
item.serializeStock(5, [1, 2, 3, 4, 5], self.user)
# Pick a StockItem which can actually be serialized
item = StockItem.objects.get(pk=100)
# Try an invalid quantity
with self.assertRaises(ValidationError):
item.serializeStock("k", [], self.user)
with self.assertRaises(ValidationError):
item.serializeStock(-1, [], self.user)
# Try invalid serial numbers
with self.assertRaises(ValidationError):
item.serializeStock(3, [1, 2, 'k'], self.user)
with self.assertRaises(ValidationError):
item.serializeStock(3, "hello", self.user)
def test_seiralize_stock_valid(self):
""" Perform valid stock serializations """
# There are 10 of these in stock
# Item will deplete when deleted
item = StockItem.objects.get(pk=100)
item.delete_on_deplete = True
item.save()
n = StockItem.objects.filter(part=25).count()
self.assertEqual(item.quantity, 10)
item.serializeStock(3, [1, 2, 3], self.user)
self.assertEqual(item.quantity, 7)
# Try to serialize again (with same serial numbers)
with self.assertRaises(ValidationError):
item.serializeStock(3, [1, 2, 3], self.user)
# Try to serialize too many items
with self.assertRaises(ValidationError):
item.serializeStock(13, [1, 2, 3], self.user)
# Serialize some more stock
item.serializeStock(5, [6, 7, 8, 9, 10], self.user)
self.assertEqual(item.quantity, 2)
# There should be 8 more items now
self.assertEqual(StockItem.objects.filter(part=25).count(), n + 8)
# Serialize the remainder of the stock
item.serializeStock(2, [99, 100], self.user)
# Two more items but the original has been deleted
self.assertEqual(StockItem.objects.filter(part=25).count(), n + 9)

View File

@ -17,11 +17,12 @@ stock_location_detail_urls = [
] ]
stock_item_detail_urls = [ stock_item_detail_urls = [
url(r'^edit/?', views.StockItemEdit.as_view(), name='stock-item-edit'), url(r'^edit/', views.StockItemEdit.as_view(), name='stock-item-edit'),
url(r'^delete/?', views.StockItemDelete.as_view(), name='stock-item-delete'), url(r'^serialize/', views.StockItemSerialize.as_view(), name='stock-item-serialize'),
url(r'^qr_code/?', views.StockItemQRCode.as_view(), name='stock-item-qr'), url(r'^delete/', views.StockItemDelete.as_view(), name='stock-item-delete'),
url(r'^qr_code/', views.StockItemQRCode.as_view(), name='stock-item-qr'),
url(r'^add_tracking/?', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'), url(r'^add_tracking/', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'),
url('^.*$', views.StockItemDetail.as_view(), name='stock-item-detail'), url('^.*$', views.StockItemDetail.as_view(), name='stock-item-detail'),
] ]

View File

@ -29,6 +29,7 @@ from .forms import CreateStockItemForm
from .forms import EditStockItemForm from .forms import EditStockItemForm
from .forms import AdjustStockForm from .forms import AdjustStockForm
from .forms import TrackingEntryForm from .forms import TrackingEntryForm
from .forms import SerializeStockForm
class StockIndex(ListView): class StockIndex(ListView):
@ -461,12 +462,84 @@ class StockLocationCreate(AjaxCreateView):
return initials return initials
class StockItemSerialize(AjaxUpdateView):
""" View for manually serializing a StockItem """
model = StockItem
ajax_template_name = 'stock/item_serialize.html'
ajax_form_title = 'Serialize Stock'
form_class = SerializeStockForm
def get_initial(self):
initials = super().get_initial().copy()
item = self.get_object()
initials['quantity'] = item.quantity
initials['destination'] = item.location.pk
return initials
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
form = self.get_form()
item = self.get_object()
quantity = request.POST.get('quantity', None)
serials = request.POST.get('serial_numbers', '')
dest_id = request.POST.get('destination', None)
notes = request.POST.get('note', None)
user = request.user
valid = True
try:
destination = StockLocation.objects.get(pk=dest_id)
except (ValueError, StockLocation.DoesNotExist):
destination = None
try:
numbers = ExtractSerialNumbers(serials, quantity)
except ValidationError as e:
form.errors['serial_numbers'] = e.messages
valid = False
if valid:
try:
item.serializeStock(quantity, numbers, user, notes=notes, location=destination)
except ValidationError as e:
messages = e.message_dict
for k in messages.keys():
if k in ['quantity', 'destionation', 'serial_numbers']:
form.errors[k] = messages[k]
else:
form.non_field_errors = messages[k]
valid = False
data = {
'form_valid': valid,
}
return self.renderJsonResponse(request, form, data=data)
class StockItemCreate(AjaxCreateView): class StockItemCreate(AjaxCreateView):
""" """
View for creating a new StockItem View for creating a new StockItem
Parameters can be pre-filled by passing query items: Parameters can be pre-filled by passing query items:
- part: The part of which the new StockItem is an instance - part: The part of which the new StockItem is an instance
- location: The location of the new StockItem - location: The location of the new StockItem
If the parent part is a "tracked" part, provide an option to create uniquely serialized items
rather than a bulk quantity of stock items
""" """
model = StockItem model = StockItem
@ -593,47 +666,51 @@ class StockItemCreate(AjaxCreateView):
if part.trackable: if part.trackable:
sn = request.POST.get('serial_numbers', '') sn = request.POST.get('serial_numbers', '')
try: sn = str(sn).strip()
serials = ExtractSerialNumbers(sn, quantity)
existing = [] # If user has specified a range of serial numbers
if len(sn) > 0:
try:
serials = ExtractSerialNumbers(sn, quantity)
for serial in serials: existing = []
if not StockItem.check_serial_number(part, serial):
existing.append(serial)
if len(existing) > 0: for serial in serials:
exists = ",".join([str(x) for x in existing]) if not StockItem.check_serial_number(part, serial):
form.errors['serial_numbers'] = [_('The following serial numbers already exist: ({sn})'.format(sn=exists))] existing.append(serial)
if len(existing) > 0:
exists = ",".join([str(x) for x in existing])
form.errors['serial_numbers'] = [_('The following serial numbers already exist: ({sn})'.format(sn=exists))]
valid = False
# At this point we have a list of serial numbers which we know are valid,
# and do not currently exist
form.clean()
data = form.cleaned_data
for serial in serials:
# Create a new stock item for each serial number
item = StockItem(
part=part,
quantity=1,
serial=serial,
supplier_part=data.get('supplier_part'),
location=data.get('location'),
batch=data.get('batch'),
delete_on_deplete=False,
status=data.get('status'),
notes=data.get('notes'),
URL=data.get('URL'),
)
item.save()
except ValidationError as e:
form.errors['serial_numbers'] = e.messages
valid = False valid = False
# At this point we have a list of serial numbers which we know are valid,
# and do not currently exist
form.clean()
data = form.cleaned_data
for serial in serials:
# Create a new stock item for each serial number
item = StockItem(
part=part,
quantity=1,
serial=serial,
supplier_part=data.get('supplier_part'),
location=data.get('location'),
batch=data.get('batch'),
delete_on_deplete=False,
status=data.get('status'),
notes=data.get('notes'),
URL=data.get('URL'),
)
item.save()
except ValidationError as e:
form.errors['serial_numbers'] = e.messages
valid = False
else: else:
# For non-serialized items, simply save the form. # For non-serialized items, simply save the form.
# We need to call _post_clean() here because it is prevented in the form implementation # We need to call _post_clean() here because it is prevented in the form implementation