diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index a34199d38c..887b443544 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -131,11 +131,21 @@ def ExtractSerialNumbers(serials, expected_quantity): expected_quantity: The number of (unique) serial numbers we expect """ + serials = serials.strip() + groups = re.split("[\s,]+", serials) numbers = [] 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: group = group.strip() @@ -155,34 +165,34 @@ def ExtractSerialNumbers(serials, expected_quantity): if a < b: for n in range(a, b + 1): if n in numbers: - errors.append('Duplicate serial: {n}'.format(n=n)) + errors.append(_('Duplicate serial: {n}'.format(n=n))) else: numbers.append(n) else: - errors.append("Invalid group: {g}".format(g=group)) + errors.append(_("Invalid group: {g}".format(g=group))) except ValueError: - errors.append("Invalid group: {g}".format(g=group)) + errors.append(_("Invalid group: {g}".format(g=group))) continue else: - errors.append("Invalid group: {g}".format(g=group)) + errors.append(_("Invalid group: {g}".format(g=group))) continue else: try: n = int(group) if n in numbers: - errors.append("Duplicate serial: {n}".format(n=n)) + errors.append(_("Duplicate serial: {n}".format(n=n))) else: numbers.append(n) except ValueError: - errors.append("Invalid group: {g}".format(g=group)) + errors.append(_("Invalid group: {g}".format(g=group))) if len(errors) > 0: raise ValidationError(errors) 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 if not expected_quantity == len(numbers): diff --git a/InvenTree/build/forms.py b/InvenTree/build/forms.py index 101903e1e8..54f651eade 100644 --- a/InvenTree/build/forms.py +++ b/InvenTree/build/forms.py @@ -5,6 +5,8 @@ Django Forms for interacting with Build objects # -*- coding: utf-8 -*- from __future__ import unicode_literals +from django.utils.translation import ugettext as _ + from InvenTree.forms import HelperForm from django import forms from .models import Build, BuildItem @@ -31,7 +33,7 @@ class EditBuildForm(HelperForm): class ConfirmBuildForm(HelperForm): """ 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: model = Build @@ -48,9 +50,9 @@ class CompleteBuildForm(HelperForm): 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: model = Build diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py index 7a1dceaf23..4fa6be696a 100644 --- a/InvenTree/build/views.py +++ b/InvenTree/build/views.py @@ -270,25 +270,29 @@ class BuildComplete(AjaxUpdateView): sn = request.POST.get('serial_numbers', '') - try: - # Exctract a list of provided serial numbers - serials = ExtractSerialNumbers(sn, build.quantity) + sn = str(sn).strip() - 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: - if not StockItem.check_serial_number(build.part, serial): - existing.append(serial) + existing = [] - 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))] + for serial in serials: + if not StockItem.check_serial_number(build.part, serial): + 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 - except ValidationError as e: - form.errors['serial_numbers'] = e.messages - valid = False - if valid: build.completeBuild(location, serials, request.user) diff --git a/InvenTree/part/fixtures/part.yaml b/InvenTree/part/fixtures/part.yaml index 3f7fbb9886..77b96a3471 100644 --- a/InvenTree/part/fixtures/part.yaml +++ b/InvenTree/part/fixtures/part.yaml @@ -43,6 +43,7 @@ name: 'Widget' description: 'A watchamacallit' category: 7 + trackable: true - model: part.part pk: 50 diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 382eccdf07..4a4c5fb606 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -843,7 +843,8 @@ class Part(models.Model): # Copy the BOM data if kwargs.get('bom', False): 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.pk = None item.save() diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index 46a50521dd..02c52defe1 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -7,6 +7,7 @@ from __future__ import unicode_literals from django import forms from django.forms.utils import ErrorDict +from django.utils.translation import ugettext as _ from InvenTree.forms import HelperForm from .models import StockLocation, StockItem, StockItemTracking @@ -27,7 +28,7 @@ class EditStockLocationForm(HelperForm): class CreateStockItemForm(HelperForm): """ 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: model = StockItem @@ -62,6 +63,39 @@ class CreateStockItemForm(HelperForm): 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): """ Form for performing simple stock adjustments. diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index b5a88d4e66..9aa8595c49 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -138,6 +138,12 @@ class StockItem(models.Model): if not part.trackable: 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) # 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: + self.quantity = 1 + + elif self.quantity > 1: raise ValidationError({ 'quantity': _('Quantity must be 1 for item with a serial number') }) - if self.delete_on_deplete: - raise ValidationError({'delete_on_deplete': _("Must be set to False for item with a serial number")}) + # Serial numbered items cannot be deleted on depletion + self.delete_on_deplete = False # A template part cannot be instantiated as a StockItem if self.part.is_template: @@ -350,6 +359,7 @@ class StockItem(models.Model): Brief automated note detailing a movement or quantity change. """ + track = StockItemTracking.objects.create( item=self, title=title, @@ -364,12 +374,88 @@ class StockItem(models.Model): track.save() @transaction.atomic - def serializeStock(self, serials, user): + def serializeStock(self, quantity, serials, user, notes='', location=None): """ 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) + user: User object associated with action + notes: Optional notes for tracking + location: If specified, serialized items will be placed in the given location """ - # TODO - pass + # Cannot serialize stock that is already serialized! + 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 def splitStock(self, quantity, user): @@ -411,6 +497,9 @@ class StockItem(models.Model): new_stock.save() + # Copy the transaction history + new_stock.copyHistoryFrom(self) + # Add a new tracking item for the new stock item new_stock.addTransactionNote( "Split from existing stock", diff --git a/InvenTree/stock/templates/stock/item.html b/InvenTree/stock/templates/stock/item.html index 62e46c8f84..1e7e44046c 100644 --- a/InvenTree/stock/templates/stock/item.html +++ b/InvenTree/stock/templates/stock/item.html @@ -24,6 +24,11 @@ + {% if item.part.trackable %} + + {% endif %} {% endif %}