mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
commit
7ae7c19a24
@ -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):
|
||||
|
@ -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
|
||||
|
@ -270,6 +270,10 @@ class BuildComplete(AjaxUpdateView):
|
||||
|
||||
sn = request.POST.get('serial_numbers', '')
|
||||
|
||||
sn = str(sn).strip()
|
||||
|
||||
# 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)
|
||||
|
@ -43,6 +43,7 @@
|
||||
name: 'Widget'
|
||||
description: 'A watchamacallit'
|
||||
category: 7
|
||||
trackable: true
|
||||
|
||||
- model: part.part
|
||||
pk: 50
|
||||
|
@ -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()
|
||||
|
@ -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.
|
||||
|
||||
|
@ -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<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
|
||||
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",
|
||||
|
@ -24,6 +24,11 @@
|
||||
<button type='button' class='btn btn-default btn-glyph' id='stock-count' title='Count stock'>
|
||||
<span class='glyphicon glyphicon-ok-circle'/>
|
||||
</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 %}
|
||||
<button type='button' class='btn btn-default btn-glyph' id='stock-move' title='Transfer stock'>
|
||||
<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() {
|
||||
launchModalForm(
|
||||
"{% url 'stock-item-create' %}",
|
||||
|
6
InvenTree/stock/templates/stock/item_serialize.html
Normal file
6
InvenTree/stock/templates/stock/item_serialize.html
Normal 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 %}
|
@ -1,5 +1,7 @@
|
||||
from django.test import TestCase
|
||||
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 part.models import Part
|
||||
@ -28,6 +30,14 @@ class StockTest(TestCase):
|
||||
self.drawer2 = StockLocation.objects.get(name='Drawer_2')
|
||||
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):
|
||||
self.assertEqual(StockLocation.objects.count(), 7)
|
||||
|
||||
@ -244,3 +254,71 @@ class StockTest(TestCase):
|
||||
|
||||
with self.assertRaises(StockItem.DoesNotExist):
|
||||
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)
|
||||
|
@ -17,11 +17,12 @@ stock_location_detail_urls = [
|
||||
]
|
||||
|
||||
stock_item_detail_urls = [
|
||||
url(r'^edit/?', views.StockItemEdit.as_view(), name='stock-item-edit'),
|
||||
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'^edit/', views.StockItemEdit.as_view(), name='stock-item-edit'),
|
||||
url(r'^serialize/', views.StockItemSerialize.as_view(), name='stock-item-serialize'),
|
||||
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'),
|
||||
]
|
||||
|
@ -29,6 +29,7 @@ from .forms import CreateStockItemForm
|
||||
from .forms import EditStockItemForm
|
||||
from .forms import AdjustStockForm
|
||||
from .forms import TrackingEntryForm
|
||||
from .forms import SerializeStockForm
|
||||
|
||||
|
||||
class StockIndex(ListView):
|
||||
@ -461,12 +462,84 @@ class StockLocationCreate(AjaxCreateView):
|
||||
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):
|
||||
"""
|
||||
View for creating a new StockItem
|
||||
Parameters can be pre-filled by passing query items:
|
||||
- part: The part of which the new StockItem is an instance
|
||||
- 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
|
||||
@ -593,6 +666,10 @@ class StockItemCreate(AjaxCreateView):
|
||||
if part.trackable:
|
||||
sn = request.POST.get('serial_numbers', '')
|
||||
|
||||
sn = str(sn).strip()
|
||||
|
||||
# If user has specified a range of serial numbers
|
||||
if len(sn) > 0:
|
||||
try:
|
||||
serials = ExtractSerialNumbers(sn, quantity)
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user