Merge pull request #810 from SchrodingersGat/serial-auto-fill

Serial auto fill
This commit is contained in:
Oliver 2020-05-16 19:30:18 +10:00 committed by GitHub
commit e550831efa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 788 additions and 145 deletions

View File

@ -7,13 +7,19 @@ from __future__ import unicode_literals
from django import forms from django import forms
from crispy_forms.helper import FormHelper from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout from crispy_forms.layout import Layout, Field
from crispy_forms.bootstrap import PrependedText, AppendedText, PrependedAppendedText
from django.contrib.auth.models import User from django.contrib.auth.models import User
class HelperForm(forms.ModelForm): class HelperForm(forms.ModelForm):
""" Provides simple integration of crispy_forms extension. """ """ Provides simple integration of crispy_forms extension. """
# Custom field decorations can be specified here, per form class
field_prefix = {}
field_suffix = {}
field_placeholder = {}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(forms.ModelForm, self).__init__(*args, **kwargs) super(forms.ModelForm, self).__init__(*args, **kwargs)
self.helper = FormHelper() self.helper = FormHelper()
@ -28,7 +34,62 @@ class HelperForm(forms.ModelForm):
Simply create a 'blank' layout for each available field. Simply create a 'blank' layout for each available field.
""" """
self.helper.layout = Layout(*self.fields.keys()) self.rebuild_layout()
def rebuild_layout(self):
layouts = []
for field in self.fields:
prefix = self.field_prefix.get(field, None)
suffix = self.field_suffix.get(field, None)
placeholder = self.field_placeholder.get(field, '')
# Look for font-awesome icons
if prefix and prefix.startswith('fa-'):
prefix = r"<i class='fas {fa}'/>".format(fa=prefix)
if suffix and suffix.startswith('fa-'):
suffix = r"<i class='fas {fa}'/>".format(fa=suffix)
if prefix and suffix:
layouts.append(
Field(
PrependedAppendedText(
field,
prepended_text=prefix,
appended_text=suffix,
placeholder=placeholder
)
)
)
elif prefix:
layouts.append(
Field(
PrependedText(
field,
prefix,
placeholder=placeholder
)
)
)
elif suffix:
layouts.append(
Field(
AppendedText(
field,
suffix,
placeholder=placeholder
)
)
)
else:
layouts.append(Field(field, placeholder=placeholder))
self.helper.layout = Layout(*layouts)
class DeleteForm(forms.Form): class DeleteForm(forms.Form):

View File

@ -46,12 +46,23 @@ class ConfirmBuildForm(HelperForm):
class CompleteBuildForm(HelperForm): class CompleteBuildForm(HelperForm):
""" Form for marking a Build as complete """ """ Form for marking a Build as complete """
field_prefix = {
'serial_numbers': 'fa-hashtag',
}
field_placeholder = {
}
location = forms.ModelChoiceField( location = forms.ModelChoiceField(
queryset=StockLocation.objects.all(), queryset=StockLocation.objects.all(),
help_text='Location of completed parts', help_text=_('Location of completed parts'),
) )
serial_numbers = forms.CharField(label='Serial numbers', required=False, help_text=_('Enter unique serial numbers (or leave blank)')) 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 completion')) confirm = forms.BooleanField(required=False, help_text=_('Confirm build completion'))

View File

@ -53,6 +53,22 @@ class Build(MPTTModel):
def get_absolute_url(self): def get_absolute_url(self):
return reverse('build-detail', kwargs={'pk': self.id}) return reverse('build-detail', kwargs={'pk': self.id})
def clean(self):
"""
Validation for Build object.
"""
super().clean()
try:
if self.part.trackable:
if not self.quantity == int(self.quantity):
raise ValidationError({
'quantity': _("Build quantity must be integer value for trackable parts")
})
except PartModels.Part.DoesNotExist:
pass
title = models.CharField( title = models.CharField(
verbose_name=_('Build Title'), verbose_name=_('Build Title'),
blank=False, blank=False,

View File

@ -196,6 +196,15 @@ class BuildComplete(AjaxUpdateView):
if not build.part.trackable: if not build.part.trackable:
form.fields.pop('serial_numbers') form.fields.pop('serial_numbers')
else:
if build.quantity == 1:
text = _('Next available serial number is')
else:
text = _('Next available serial numbers are')
form.field_placeholder['serial_numbers'] = text + " " + build.part.getSerialNumberString(build.quantity)
form.rebuild_layout()
return form return form
@ -208,6 +217,7 @@ class BuildComplete(AjaxUpdateView):
initials = super(BuildComplete, self).get_initial().copy() initials = super(BuildComplete, self).get_initial().copy()
build = self.get_object() build = self.get_object()
if build.part.default_location is not None: if build.part.default_location is not None:
try: try:
location = StockLocation.objects.get(pk=build.part.default_location.id) location = StockLocation.objects.get(pk=build.part.default_location.id)
@ -282,7 +292,7 @@ class BuildComplete(AjaxUpdateView):
existing = [] existing = []
for serial in serials: for serial in serials:
if not StockItem.check_serial_number(build.part, serial): if build.part.checkIfSerialNumberExists(serial):
existing.append(serial) existing.append(serial)
if len(existing) > 0: if len(existing) > 0:

View File

@ -16,6 +16,14 @@ from .models import SupplierPriceBreak
class EditCompanyForm(HelperForm): class EditCompanyForm(HelperForm):
""" Form for editing a Company object """ """ Form for editing a Company object """
field_prefix = {
'website': 'fa-globe-asia',
'email': 'fa-at',
'address': 'fa-envelope',
'contact': 'fa-user-tie',
'phone': 'fa-phone',
}
class Meta: class Meta:
model = Company model = Company
fields = [ fields = [
@ -45,6 +53,13 @@ class CompanyImageForm(HelperForm):
class EditSupplierPartForm(HelperForm): class EditSupplierPartForm(HelperForm):
""" Form for editing a SupplierPart object """ """ Form for editing a SupplierPart object """
field_prefix = {
'link': 'fa-link',
'MPN': 'fa-hashtag',
'SKU': 'fa-hashtag',
'note': 'fa-pencil-alt',
}
class Meta: class Meta:
model = SupplierPart model = SupplierPart
fields = [ fields = [

View File

@ -8,9 +8,6 @@ from __future__ import unicode_literals
from django import forms from django import forms
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from crispy_forms.layout import Field, Layout
from crispy_forms.bootstrap import PrependedText
from mptt.fields import TreeNodeChoiceField from mptt.fields import TreeNodeChoiceField
from InvenTree.forms import HelperForm from InvenTree.forms import HelperForm
@ -93,20 +90,16 @@ class EditPurchaseOrderForm(HelperForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) self.field_prefix = {
'reference': 'PO',
'link': 'fa-link',
}
# TODO - Refactor this? self.field_placeholder = {
self.helper.layout = Layout( 'reference': _('Enter purchase order number'),
Field(PrependedText( }
'reference',
'PO', super().__init__(*args, **kwargs)
placeholder=_("Purchase Order")
)),
Field('supplier'),
Field('supplier_reference'),
Field('description'),
Field('link'),
)
class Meta: class Meta:
model = PurchaseOrder model = PurchaseOrder
@ -124,20 +117,16 @@ class EditSalesOrderForm(HelperForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) self.field_prefix = {
'reference': 'SO',
'link': 'fa-link',
}
# TODO - Refactor? self.field_placeholder = {
self.helper.layout = Layout( 'reference': _('Enter sales order number'),
Field(PrependedText( }
'reference',
'SO', super().__init__(*args, **kwargs)
placeholder=_("Sales Order")
)),
Field('customer'),
Field('customer_reference'),
Field('description'),
Field('link'),
)
class Meta: class Meta:
model = SalesOrder model = SalesOrder

View File

@ -7,6 +7,10 @@
description: 'M2x4 low profile head screw' description: 'M2x4 low profile head screw'
category: 8 category: 8
link: www.acme.com/parts/m2x4lphs link: www.acme.com/parts/m2x4lphs
tree_id: 0
level: 0
lft: 0
rght: 0
- model: part.part - model: part.part
pk: 2 pk: 2
@ -14,6 +18,10 @@
name: 'M3x12 SHCS' name: 'M3x12 SHCS'
description: 'M3x12 socket head cap screw' description: 'M3x12 socket head cap screw'
category: 8 category: 8
tree_id: 0
level: 0
lft: 0
rght: 0
# Create some resistors # Create some resistors
@ -23,6 +31,11 @@
name: 'R_2K2_0805' name: 'R_2K2_0805'
description: '2.2kOhm resistor in 0805 package' description: '2.2kOhm resistor in 0805 package'
category: 2 category: 2
tree_id: 0
level: 0
lft: 0
rght: 0
- model: part.part - model: part.part
fields: fields:
@ -30,6 +43,10 @@
description: '4.7kOhm resistor in 0603 package' description: '4.7kOhm resistor in 0603 package'
category: 2 category: 2
default_location: 2 # Home/Bathroom default_location: 2 # Home/Bathroom
tree_id: 0
level: 0
lft: 0
rght: 0
# Create some capacitors # Create some capacitors
- model: part.part - model: part.part
@ -37,6 +54,10 @@
name: 'C_22N_0805' name: 'C_22N_0805'
description: '22nF capacitor in 0805 package' description: '22nF capacitor in 0805 package'
category: 3 category: 3
tree_id: 0
level: 0
lft: 0
rght: 0
- model: part.part - model: part.part
pk: 25 pk: 25
@ -45,6 +66,10 @@
description: 'A watchamacallit' description: 'A watchamacallit'
category: 7 category: 7
trackable: true trackable: true
tree_id: 0
level: 0
lft: 0
rght: 0
- model: part.part - model: part.part
pk: 50 pk: 50
@ -52,6 +77,10 @@
name: 'Orphan' name: 'Orphan'
description: 'A part without a category' description: 'A part without a category'
category: null category: null
tree_id: 0
level: 0
lft: 0
rght: 0
# A part that can be made from other parts # A part that can be made from other parts
- model: part.part - model: part.part
@ -64,4 +93,65 @@
category: 7 category: 7
active: False active: False
IPN: BOB IPN: BOB
revision: A2 revision: A2
tree_id: 0
level: 0
lft: 0
rght: 0
# A 'template' part
- model: part.part
pk: 10000
fields:
name: 'Chair Template'
description: 'A chair'
is_template: True
category: 7
tree_id: 1
level: 0
lft: 0
rght: 0
- model: part.part
pk: 10001
fields:
name: 'Blue Chair'
variant_of: 10000
category: 7
tree_id: 1
level: 0
lft: 0
rght: 0
- model: part.part
pk: 10002
fields:
name: 'Red chair'
variant_of: 10000
category: 7
tree_id: 1
level: 0
lft: 0
rght: 0
- model: part.part
pk: 10003
fields:
name: 'Green chair'
variant_of: 10000
category: 7
tree_id: 1
level: 0
lft: 0
rght: 0
- model: part.part
pk: 10004
fields:
name: 'Green chair variant'
variant_of: 10003
category:
tree_id: 1
level: 0
lft: 0
rght: 0

View File

@ -97,6 +97,12 @@ class SetPartCategoryForm(forms.Form):
class EditPartForm(HelperForm): class EditPartForm(HelperForm):
""" Form for editing a Part object """ """ Form for editing a Part object """
field_prefix = {
'keywords': 'fa-key',
'link': 'fa-link',
'IPN': 'fa-hashtag',
}
deep_copy = forms.BooleanField(required=False, deep_copy = forms.BooleanField(required=False,
initial=True, initial=True,
help_text=_("Perform 'deep copy' which will duplicate all BOM data for this part"), help_text=_("Perform 'deep copy' which will duplicate all BOM data for this part"),
@ -155,6 +161,10 @@ class EditPartParameterForm(HelperForm):
class EditCategoryForm(HelperForm): class EditCategoryForm(HelperForm):
""" Form for editing a PartCategory object """ """ Form for editing a PartCategory object """
field_prefix = {
'default_keywords': 'fa-key',
}
class Meta: class Meta:
model = PartCategory model = PartCategory
fields = [ fields = [

View File

@ -0,0 +1,50 @@
# Generated by Django 3.0.5 on 2020-05-15 11:27
from django.db import migrations, models
from part.models import Part
def update_tree(apps, schema_editor):
# Update the MPTT for Part model
Part.objects.rebuild()
def nupdate_tree(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
('part', '0038_auto_20200513_0016'),
]
operations = [
migrations.AddField(
model_name='part',
name='level',
field=models.PositiveIntegerField(default=0, editable=False),
preserve_default=False,
),
migrations.AddField(
model_name='part',
name='lft',
field=models.PositiveIntegerField(default=0, editable=False),
preserve_default=False,
),
migrations.AddField(
model_name='part',
name='rght',
field=models.PositiveIntegerField(default=0, editable=False),
preserve_default=False,
),
migrations.AddField(
model_name='part',
name='tree_id',
field=models.PositiveIntegerField(db_index=True, default=0, editable=False),
preserve_default=False,
),
migrations.RunPython(update_tree, reverse_code=nupdate_tree)
]

View File

@ -24,7 +24,7 @@ from markdownx.models import MarkdownxField
from django_cleanup import cleanup from django_cleanup import cleanup
from mptt.models import TreeForeignKey from mptt.models import TreeForeignKey, MPTTModel
from stdimage.models import StdImageField from stdimage.models import StdImageField
@ -39,7 +39,7 @@ from InvenTree.models import InvenTreeTree, InvenTreeAttachment
from InvenTree.fields import InvenTreeURLField from InvenTree.fields import InvenTreeURLField
from InvenTree.helpers import decimal2string, normalize from InvenTree.helpers import decimal2string, normalize
from InvenTree.status_codes import BuildStatus, StockStatus, PurchaseOrderStatus from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
from build import models as BuildModels from build import models as BuildModels
from order import models as OrderModels from order import models as OrderModels
@ -200,7 +200,7 @@ def match_part_names(match, threshold=80, reverse=True, compare_length=False):
@cleanup.ignore @cleanup.ignore
class Part(models.Model): class Part(MPTTModel):
""" The Part object represents an abstract part, the 'concept' of an actual entity. """ The Part object represents an abstract part, the 'concept' of an actual entity.
An actual physical instance of a Part is a StockItem which is treated separately. An actual physical instance of a Part is a StockItem which is treated separately.
@ -236,8 +236,12 @@ class Part(models.Model):
""" """
class Meta: class Meta:
verbose_name = "Part" verbose_name = _("Part")
verbose_name_plural = "Parts" verbose_name_plural = _("Parts")
class MPTTMeta:
# For legacy reasons the 'variant_of' field is used to indicate the MPTT parent
parent_attr = 'variant_of'
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
""" """
@ -262,6 +266,66 @@ class Part(models.Model):
def __str__(self): def __str__(self):
return "{n} - {d}".format(n=self.full_name, d=self.description) return "{n} - {d}".format(n=self.full_name, d=self.description)
def checkIfSerialNumberExists(self, sn):
"""
Check if a serial number exists for this Part.
Note: Serial numbers must be unique across an entire Part "tree",
so here we filter by the entire tree.
"""
parts = Part.objects.filter(tree_id=self.tree_id)
stock = StockModels.StockItem.objects.filter(part__in=parts, serial=sn)
return stock.exists()
def getHighestSerialNumber(self):
"""
Return the highest serial number for this Part.
Note: Serial numbers must be unique across an entire Part "tree",
so we filter by the entire tree.
"""
parts = Part.objects.filter(tree_id=self.tree_id)
stock = StockModels.StockItem.objects.filter(part__in=parts).exclude(serial=None).order_by('-serial')
if stock.count() > 0:
return stock.first().serial
# No serial numbers found
return None
def getNextSerialNumber(self):
"""
Return the next-available serial number for this Part.
"""
n = self.getHighestSerialNumber()
if n is None:
return 1
else:
return n + 1
def getSerialNumberString(self, quantity):
"""
Return a formatted string representing the next available serial numbers,
given a certain quantity of items.
"""
sn = self.getNextSerialNumber()
if quantity >= 2:
sn = "{n}-{m}".format(
n=sn,
m=int(sn + quantity - 1)
)
else:
sn = str(sn)
return sn
@property @property
def full_name(self): def full_name(self):
""" Format a 'full name' for this Part. """ Format a 'full name' for this Part.
@ -638,32 +702,40 @@ class Part(models.Model):
self.sales_order_allocation_count(), self.sales_order_allocation_count(),
]) ])
@property def stock_entries(self, include_variants=True, in_stock=None):
def stock_entries(self): """ Return all stock entries for this Part.
""" Return all 'in stock' items. To be in stock:
- build_order is None - If this is a template part, include variants underneath this.
- sales_order is None
- belongs_to is None Note: To return all stock-entries for all part variants under this one,
we need to be creative with the filtering.
""" """
return self.stock_items.filter(StockModels.StockItem.IN_STOCK_FILTER) if include_variants:
query = StockModels.StockItem.objects.filter(part__in=self.get_descendants(include_self=True))
else:
query = self.stock_items
if in_stock is True:
query = query.filter(StockModels.StockItem.IN_STOCK_FILTER)
elif in_stock is False:
query = query.exclude(StockModels.StockItem.IN_STOCK_FILTER)
return query
@property @property
def total_stock(self): def total_stock(self):
""" Return the total stock quantity for this part. """ Return the total stock quantity for this part.
Part may be stored in multiple locations
- Part may be stored in multiple locations
- If this part is a "template" (variants exist) then these are counted too
""" """
if self.is_template: entries = self.stock_entries(in_stock=True)
total = sum([variant.total_stock for variant in self.variants.all()])
else:
total = self.stock_entries.filter(status__in=StockStatus.AVAILABLE_CODES).aggregate(total=Sum('quantity'))['total']
if total: query = entries.aggregate(t=Coalesce(Sum('quantity'), Decimal(0)))
return total
else: return query['t']
return Decimal(0)
@property @property
def has_bom(self): def has_bom(self):

View File

@ -85,7 +85,7 @@ class PartAPITest(APITestCase):
data = {'cascade': True} data = {'cascade': True}
response = self.client.get(url, data, format='json') response = self.client.get(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 8) self.assertEqual(len(response.data), 13)
def test_get_parts_by_cat(self): def test_get_parts_by_cat(self):
url = reverse('api-part-list') url = reverse('api-part-list')

View File

@ -88,9 +88,9 @@ class CategoryTest(TestCase):
self.assertEqual(self.electronics.partcount(), 3) self.assertEqual(self.electronics.partcount(), 3)
self.assertEqual(self.mechanical.partcount(), 4) self.assertEqual(self.mechanical.partcount(), 8)
self.assertEqual(self.mechanical.partcount(active=True), 3) self.assertEqual(self.mechanical.partcount(active=True), 7)
self.assertEqual(self.mechanical.partcount(False), 2) self.assertEqual(self.mechanical.partcount(False), 6)
self.assertEqual(self.electronics.item_count, self.electronics.partcount()) self.assertEqual(self.electronics.item_count, self.electronics.partcount())

View File

@ -52,6 +52,20 @@ class PartTest(TestCase):
self.C1 = Part.objects.get(name='C_22N_0805') self.C1 = Part.objects.get(name='C_22N_0805')
Part.objects.rebuild()
def test_tree(self):
# Test that the part variant tree is working properly
chair = Part.objects.get(pk=10000)
self.assertEqual(chair.get_children().count(), 3)
self.assertEqual(chair.get_descendant_count(), 4)
green = Part.objects.get(pk=10004)
self.assertEqual(green.get_ancestors().count(), 2)
self.assertEqual(green.get_root(), chair)
self.assertEqual(green.get_family().count(), 3)
self.assertEqual(Part.objects.filter(tree_id=chair.tree_id).count(), 5)
def test_str(self): def test_str(self):
p = Part.objects.get(pk=100) p = Part.objects.get(pk=100)
self.assertEqual(str(p), "BOB | Bob | A2 - Can we build it?") self.assertEqual(str(p), "BOB | Bob | A2 - Can we build it?")

View File

@ -68,4 +68,160 @@
level: 0 level: 0
tree_id: 0 tree_id: 0
lft: 0 lft: 0
rght: 0
# Stock items for template / variant parts
- model: stock.stockitem
pk: 500
fields:
part: 10001
location: 7
quantity: 5
level: 0
tree_id: 0
lft: 0
rght: 0
- model: stock.stockitem
pk: 501
fields:
part: 10001
location: 7
quantity: 1
serial: 1
level: 0
tree_id: 0
lft: 0
rght: 0
- model: stock.stockitem
pk: 501
fields:
part: 10001
location: 7
quantity: 1
serial: 1
level: 0
tree_id: 0
lft: 0
rght: 0
- model: stock.stockitem
pk: 502
fields:
part: 10001
location: 7
quantity: 1
serial: 2
level: 0
tree_id: 0
lft: 0
rght: 0
- model: stock.stockitem
pk: 503
fields:
part: 10001
location: 7
quantity: 1
serial: 3
level: 0
tree_id: 0
lft: 0
rght: 0
- model: stock.stockitem
pk: 504
fields:
part: 10001
location: 7
quantity: 1
serial: 4
level: 0
tree_id: 0
lft: 0
rght: 0
- model: stock.stockitem
pk: 505
fields:
part: 10001
location: 7
quantity: 1
serial: 5
level: 0
tree_id: 0
lft: 0
rght: 0
- model: stock.stockitem
pk: 510
fields:
part: 10002
location: 7
quantity: 1
serial: 10
level: 0
tree_id: 0
lft: 0
rght: 0
- model: stock.stockitem
pk: 511
fields:
part: 10002
location: 7
quantity: 1
serial: 11
level: 0
tree_id: 0
lft: 0
rght: 0
- model: stock.stockitem
pk: 512
fields:
part: 10002
location: 7
quantity: 1
serial: 12
level: 0
tree_id: 0
lft: 0
rght: 0
- model: stock.stockitem
pk: 520
fields:
part: 10004
location: 7
quantity: 1
serial: 20
level: 0
tree_id: 0
lft: 0
rght: 0
- model: stock.stockitem
pk: 521
fields:
part: 10004
location: 7
quantity: 1
serial: 21
level: 0
tree_id: 0
lft: 0
rght: 0
- model: stock.stockitem
pk: 522
fields:
part: 10004
location: 7
quantity: 1
serial: 22
level: 0
tree_id: 0
lft: 0
rght: 0 rght: 0

View File

@ -13,6 +13,8 @@ from mptt.fields import TreeNodeChoiceField
from InvenTree.helpers import GetExportFormats from InvenTree.helpers import GetExportFormats
from InvenTree.forms import HelperForm from InvenTree.forms import HelperForm
from InvenTree.fields import RoundingDecimalFormField
from .models import StockLocation, StockItem, StockItemTracking, StockItemAttachment from .models import StockLocation, StockItem, StockItemTracking, StockItemAttachment
@ -47,6 +49,15 @@ class CreateStockItemForm(HelperForm):
serial_numbers = forms.CharField(label='Serial numbers', required=False, help_text=_('Enter unique serial numbers (or leave blank)')) serial_numbers = forms.CharField(label='Serial numbers', required=False, help_text=_('Enter unique serial numbers (or leave blank)'))
def __init__(self, *args, **kwargs):
self.field_prefix = {
'serial_numbers': 'fa-hashtag',
'link': 'fa-link',
}
super().__init__(*args, **kwargs)
class Meta: class Meta:
model = StockItem model = StockItem
fields = [ fields = [
@ -69,6 +80,7 @@ class CreateStockItemForm(HelperForm):
return return
self.cleaned_data = {} self.cleaned_data = {}
# If the form is permitted to be empty, and none of the form data has # If the form is permitted to be empty, and none of the form data has
# changed from the initial data, short circuit any validation. # changed from the initial data, short circuit any validation.
if self.empty_permitted and not self.has_changed(): if self.empty_permitted and not self.has_changed():
@ -79,7 +91,7 @@ class CreateStockItemForm(HelperForm):
self._clean_form() self._clean_form()
class SerializeStockForm(forms.ModelForm): class SerializeStockForm(HelperForm):
""" Form for serializing a StockItem. """ """ Form for serializing a StockItem. """
destination = TreeNodeChoiceField(queryset=StockLocation.objects.all(), label='Destination', required=True, help_text='Destination for serialized stock (by default, will remain in current location)') destination = TreeNodeChoiceField(queryset=StockLocation.objects.all(), label='Destination', required=True, help_text='Destination for serialized stock (by default, will remain in current location)')
@ -88,6 +100,18 @@ class SerializeStockForm(forms.ModelForm):
note = forms.CharField(label='Notes', required=False, help_text='Add transaction note (optional)') note = forms.CharField(label='Notes', required=False, help_text='Add transaction note (optional)')
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5)
def __init__(self, *args, **kwargs):
# Extract the stock item
item = kwargs.pop('item', None)
if item:
self.field_placeholder['serial_numbers'] = item.part.getSerialNumberString(item.quantity)
super().__init__(*args, **kwargs)
class Meta: class Meta:
model = StockItem model = StockItem

View File

@ -142,9 +142,21 @@ class StockItem(MPTTModel):
) )
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""
Save this StockItem to the database. Performs a number of checks:
- Unique serial number requirement
- Adds a transaction note when the item is first created.
"""
self.validate_unique()
self.clean()
if not self.pk: if not self.pk:
# StockItem has not yet been saved
add_note = True add_note = True
else: else:
# StockItem has already been saved
add_note = False add_note = False
user = kwargs.pop('user', None) user = kwargs.pop('user', None)
@ -172,59 +184,26 @@ class StockItem(MPTTModel):
""" Return True if this StockItem is serialized """ """ Return True if this StockItem is serialized """
return self.serial is not None and self.quantity == 1 return self.serial is not None and self.quantity == 1
@classmethod def validate_unique(self, exclude=None):
def check_serial_number(cls, part, serial_number): """
""" Check if a new stock item can be created with the provided part_id Test that this StockItem is "unique".
If the StockItem is serialized, the same serial number.
Args: cannot exist for the same part (or part tree).
part: The part to be checked
""" """
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
if part.variant_of is not None:
items = items.filter(part__variant_of=part.variant_of)
else:
items = items.filter(part=part)
# An existing serial number exists
if items.exists():
return False
return True
def validate_unique(self, exclude=None):
super(StockItem, self).validate_unique(exclude) super(StockItem, self).validate_unique(exclude)
# If the Part object is a variant (of a template part), if self.serial is not None:
# ensure that the serial number is unique # Query to look for duplicate serial numbers
# across all variants of the same template part parts = PartModels.Part.objects.filter(tree_id=self.part.tree_id)
stock = StockItem.objects.filter(part__in=parts, serial=self.serial)
try: # Exclude myself from the search
if self.serial is not None: if self.pk is not None:
# This is a variant part (check S/N across all sibling variants) stock = stock.exclude(pk=self.pk)
if self.part.variant_of is not None:
if StockItem.objects.filter(part__variant_of=self.part.variant_of, serial=self.serial).exclude(id=self.id).exists(): if stock.exists():
raise ValidationError({ raise ValidationError({"serial": _("StockItem with this serial number already exists")})
'serial': _('A stock item with this serial number already exists for template part {part}'.format(part=self.part.variant_of))
})
else:
if StockItem.objects.filter(part=self.part, serial=self.serial).exclude(id=self.id).exists():
raise ValidationError({
'serial': _('A stock item with this serial number already exists')
})
except PartModels.Part.DoesNotExist:
pass
def clean(self): def clean(self):
""" Validate the StockItem object (separate to field validation) """ Validate the StockItem object (separate to field validation)
@ -236,6 +215,8 @@ class StockItem(MPTTModel):
- Quantity must be 1 if the StockItem has a serial number - Quantity must be 1 if the StockItem has a serial number
""" """
super().clean()
if self.status == StockStatus.SHIPPED and self.sales_order is None: if self.status == StockStatus.SHIPPED and self.sales_order is None:
raise ValidationError({ raise ValidationError({
'sales_order': "SalesOrder must be specified as status is marked as SHIPPED", 'sales_order': "SalesOrder must be specified as status is marked as SHIPPED",
@ -248,6 +229,24 @@ class StockItem(MPTTModel):
'status': 'Status cannot be marked as ASSIGNED_TO_OTHER_ITEM if the belongs_to field is not set', 'status': 'Status cannot be marked as ASSIGNED_TO_OTHER_ITEM if the belongs_to field is not set',
}) })
try:
if self.part.trackable:
# Trackable parts must have integer values for quantity field!
if not self.quantity == int(self.quantity):
raise ValidationError({
'quantity': _('Quantity must be integer value for trackable parts')
})
except PartModels.Part.DoesNotExist:
# For some reason the 'clean' process sometimes throws errors because self.part does not exist
# It *seems* that this only occurs in unit testing, though.
# Probably should investigate this at some point.
pass
if self.quantity < 0:
raise ValidationError({
'quantity': _('Quantity must be greater than zero')
})
# The 'supplier_part' field must point to the same part! # The 'supplier_part' field must point to the same part!
try: try:
if self.supplier_part is not None: if self.supplier_part is not None:
@ -599,6 +598,9 @@ class StockItem(MPTTModel):
if self.serialized: if self.serialized:
return return
if not self.part.trackable:
raise ValidationError({"part": _("Part is not set as trackable")})
# Quantity must be a valid integer value # Quantity must be a valid integer value
try: try:
quantity = int(quantity) quantity = int(quantity)
@ -624,7 +626,7 @@ class StockItem(MPTTModel):
existing = [] existing = []
for serial in serials: for serial in serials:
if not StockItem.check_serial_number(self.part, serial): if self.part.checkIfSerialNumberExists(serial):
existing.append(serial) existing.append(serial)
if len(existing) > 0: if len(existing) > 0:

View File

@ -38,6 +38,10 @@ class StockTest(TestCase):
self.user = User.objects.get(username='username') self.user = User.objects.get(username='username')
# Ensure the MPTT objects are correctly rebuild
Part.objects.rebuild()
StockItem.objects.rebuild()
def test_loc_count(self): def test_loc_count(self):
self.assertEqual(StockLocation.objects.count(), 7) self.assertEqual(StockLocation.objects.count(), 7)
@ -91,13 +95,16 @@ class StockTest(TestCase):
self.assertFalse(self.drawer2.has_items()) self.assertFalse(self.drawer2.has_items())
# Drawer 3 should have three stock items # Drawer 3 should have three stock items
self.assertEqual(self.drawer3.stock_items.count(), 3) self.assertEqual(self.drawer3.stock_items.count(), 15)
self.assertEqual(self.drawer3.item_count, 3) self.assertEqual(self.drawer3.item_count, 15)
def test_stock_count(self): def test_stock_count(self):
part = Part.objects.get(pk=1) part = Part.objects.get(pk=1)
entries = part.stock_entries()
# There should be 5000 screws in stock self.assertEqual(entries.count(), 2)
# There should be 9000 screws in stock
self.assertEqual(part.total_stock, 9000) self.assertEqual(part.total_stock, 9000)
# There should be 18 widgets in stock # There should be 18 widgets in stock
@ -327,3 +334,73 @@ class StockTest(TestCase):
# Serialize the remainder of the stock # Serialize the remainder of the stock
item.serializeStock(2, [99, 100], self.user) item.serializeStock(2, [99, 100], self.user)
class VariantTest(StockTest):
"""
Tests for calculation stock counts against templates / variants
"""
def test_variant_stock(self):
# Check the 'Chair' variant
chair = Part.objects.get(pk=10000)
# No stock items for the variant part itself
self.assertEqual(chair.stock_entries(include_variants=False).count(), 0)
self.assertEqual(chair.stock_entries().count(), 12)
green = Part.objects.get(pk=10003)
self.assertEqual(green.stock_entries(include_variants=False).count(), 0)
self.assertEqual(green.stock_entries().count(), 3)
def test_serial_numbers(self):
# Test serial number functionality for variant / template parts
chair = Part.objects.get(pk=10000)
# Operations on the top-level object
self.assertTrue(chair.checkIfSerialNumberExists(1))
self.assertTrue(chair.checkIfSerialNumberExists(2))
self.assertTrue(chair.checkIfSerialNumberExists(3))
self.assertTrue(chair.checkIfSerialNumberExists(4))
self.assertTrue(chair.checkIfSerialNumberExists(5))
self.assertTrue(chair.checkIfSerialNumberExists(20))
self.assertTrue(chair.checkIfSerialNumberExists(21))
self.assertTrue(chair.checkIfSerialNumberExists(22))
self.assertFalse(chair.checkIfSerialNumberExists(30))
self.assertEqual(chair.getNextSerialNumber(), 23)
# Same operations on a sub-item
variant = Part.objects.get(pk=10003)
self.assertEqual(variant.getNextSerialNumber(), 23)
# Create a new serial number
n = variant.getHighestSerialNumber()
item = StockItem(
part=variant,
quantity=1,
serial=n
)
# This should fail
with self.assertRaises(ValidationError):
item.save()
# This should pass
item.serial = n + 1
item.save()
# Attempt to create the same serial number but for a variant (should fail!)
item.pk = None
item.part = Part.objects.get(pk=10004)
with self.assertRaises(ValidationError):
item.save()
item.serial += 1
item.save()

View File

@ -717,7 +717,7 @@ class StockItemEdit(AjaxUpdateView):
query = query.filter(part=item.part.id) query = query.filter(part=item.part.id)
form.fields['supplier_part'].queryset = query form.fields['supplier_part'].queryset = query
if not item.part.trackable: if not item.part.trackable or not item.serialized:
form.fields.pop('serial') form.fields.pop('serial')
return form return form
@ -757,6 +757,17 @@ class StockItemSerialize(AjaxUpdateView):
ajax_form_title = _('Serialize Stock') ajax_form_title = _('Serialize Stock')
form_class = SerializeStockForm form_class = SerializeStockForm
def get_form(self):
context = self.get_form_kwargs()
# Pass the StockItem object through to the form
context['item'] = self.get_object()
form = SerializeStockForm(**context)
return form
def get_initial(self): def get_initial(self):
initials = super().get_initial().copy() initials = super().get_initial().copy()
@ -764,6 +775,7 @@ class StockItemSerialize(AjaxUpdateView):
item = self.get_object() item = self.get_object()
initials['quantity'] = item.quantity initials['quantity'] = item.quantity
initials['serial_numbers'] = item.part.getSerialNumberString(item.quantity)
initials['destination'] = item.location.pk initials['destination'] = item.location.pk
return initials return initials
@ -844,6 +856,8 @@ class StockItemCreate(AjaxCreateView):
form = super().get_form() form = super().get_form()
part = None
# If the user has selected a Part, limit choices for SupplierPart # If the user has selected a Part, limit choices for SupplierPart
if form['part'].value(): if form['part'].value():
part_id = form['part'].value() part_id = form['part'].value()
@ -851,6 +865,11 @@ class StockItemCreate(AjaxCreateView):
try: try:
part = Part.objects.get(id=part_id) part = Part.objects.get(id=part_id)
sn = part.getNextSerialNumber()
form.field_placeholder['serial_numbers'] = _('Next available serial number is') + ' ' + str(sn)
form.rebuild_layout()
# Hide the 'part' field (as a valid part is selected) # Hide the 'part' field (as a valid part is selected)
form.fields['part'].widget = HiddenInput() form.fields['part'].widget = HiddenInput()
@ -873,6 +892,7 @@ class StockItemCreate(AjaxCreateView):
# If there is one (and only one) supplier part available, pre-select it # If there is one (and only one) supplier part available, pre-select it
all_parts = parts.all() all_parts = parts.all()
if len(all_parts) == 1: if len(all_parts) == 1:
# TODO - This does NOT work for some reason? Ref build.views.BuildItemCreate # TODO - This does NOT work for some reason? Ref build.views.BuildItemCreate
@ -884,7 +904,7 @@ class StockItemCreate(AjaxCreateView):
# Otherwise if the user has selected a SupplierPart, we know what Part they meant! # Otherwise if the user has selected a SupplierPart, we know what Part they meant!
elif form['supplier_part'].value() is not None: elif form['supplier_part'].value() is not None:
pass pass
return form return form
def get_initial(self): def get_initial(self):
@ -917,6 +937,7 @@ class StockItemCreate(AjaxCreateView):
if part_id: if part_id:
try: try:
part = Part.objects.get(pk=part_id) part = Part.objects.get(pk=part_id)
# Check that the supplied part is 'valid' # Check that the supplied part is 'valid'
if not part.is_template and part.active and not part.virtual: if not part.is_template and part.active and not part.virtual:
initials['part'] = part initials['part'] = part
@ -954,6 +975,8 @@ class StockItemCreate(AjaxCreateView):
- Manage serial-number valdiation for tracked parts - Manage serial-number valdiation for tracked parts
""" """
part = None
form = self.get_form() form = self.get_form()
data = {} data = {}
@ -965,12 +988,22 @@ class StockItemCreate(AjaxCreateView):
try: try:
part = Part.objects.get(id=part_id) part = Part.objects.get(id=part_id)
quantity = Decimal(form['quantity'].value()) quantity = Decimal(form['quantity'].value())
sn = part.getNextSerialNumber()
form.field_placeholder['serial_numbers'] = _("Next available serial number is") + " " + str(sn)
form.rebuild_layout()
except (Part.DoesNotExist, ValueError, InvalidOperation): except (Part.DoesNotExist, ValueError, InvalidOperation):
part = None part = None
quantity = 1 quantity = 1
valid = False valid = False
form.errors['quantity'] = [_('Invalid quantity')] form.errors['quantity'] = [_('Invalid quantity')]
if quantity <= 0:
form.errors['quantity'] = [_('Quantity must be greater than zero')]
valid = False
if part is None: if part is None:
form.errors['part'] = [_('Invalid part selection')] form.errors['part'] = [_('Invalid part selection')]
else: else:
@ -988,7 +1021,7 @@ class StockItemCreate(AjaxCreateView):
existing = [] existing = []
for serial in serials: for serial in serials:
if not StockItem.check_serial_number(part, serial): if part.checkIfSerialNumberExists(serial):
existing.append(serial) existing.append(serial)
if len(existing) > 0: if len(existing) > 0:
@ -1003,24 +1036,26 @@ class StockItemCreate(AjaxCreateView):
form_data = form.cleaned_data form_data = form.cleaned_data
for serial in serials: if form.is_valid():
# Create a new stock item for each serial number
item = StockItem(
part=part,
quantity=1,
serial=serial,
supplier_part=form_data.get('supplier_part'),
location=form_data.get('location'),
batch=form_data.get('batch'),
delete_on_deplete=False,
status=form_data.get('status'),
link=form_data.get('link'),
)
item.save(user=request.user) for serial in serials:
# Create a new stock item for each serial number
item = StockItem(
part=part,
quantity=1,
serial=serial,
supplier_part=form_data.get('supplier_part'),
location=form_data.get('location'),
batch=form_data.get('batch'),
delete_on_deplete=False,
status=form_data.get('status'),
link=form_data.get('link'),
)
data['success'] = _('Created {n} new stock items'.format(n=len(serials))) item.save(user=request.user)
valid = True
data['success'] = _('Created {n} new stock items'.format(n=len(serials)))
valid = True
except ValidationError as e: except ValidationError as e:
form.errors['serial_numbers'] = e.messages form.errors['serial_numbers'] = e.messages
@ -1031,6 +1066,24 @@ class StockItemCreate(AjaxCreateView):
form.clean() form.clean()
form._post_clean() form._post_clean()
if form.is_valid():
item = form.save(commit=False)
item.save(user=request.user)
data['pk'] = item.pk
data['url'] = item.get_absolute_url()
data['success'] = _("Created new stock item")
valid = True
else: # Referenced Part object is not marked as "trackable"
# For non-serialized items, simply save the form.
# We need to call _post_clean() here because it is prevented in the form implementation
form.clean()
form._post_clean()
if form.is_valid:
item = form.save(commit=False) item = form.save(commit=False)
item.save(user=request.user) item.save(user=request.user)
@ -1038,20 +1091,9 @@ class StockItemCreate(AjaxCreateView):
data['url'] = item.get_absolute_url() data['url'] = item.get_absolute_url()
data['success'] = _("Created new stock item") data['success'] = _("Created new stock item")
else: # Referenced Part object is not marked as "trackable" valid = True
# For non-serialized items, simply save the form.
# We need to call _post_clean() here because it is prevented in the form implementation
form.clean()
form._post_clean()
item = form.save(commit=False)
item.save(user=request.user)
data['pk'] = item.pk data['form_valid'] = valid and form.is_valid()
data['url'] = item.get_absolute_url()
data['success'] = _("Created new stock item")
data['form_valid'] = valid
return self.renderJsonResponse(request, form, data=data) return self.renderJsonResponse(request, form, data=data)

View File

@ -110,6 +110,10 @@ function getAvailableTableFilters(tableKey) {
type: 'bool', type: 'bool',
title: '{% trans "Salable" %}', title: '{% trans "Salable" %}',
}, },
trackable: {
type: 'bool',
title: '{% trans "Trackable" %}',
},
purchaseable: { purchaseable: {
type: 'bool', type: 'bool',
title: '{% trans "Purchasable" %}', title: '{% trans "Purchasable" %}',