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 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
class HelperForm(forms.ModelForm):
""" 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):
super(forms.ModelForm, self).__init__(*args, **kwargs)
self.helper = FormHelper()
@ -28,7 +34,62 @@ class HelperForm(forms.ModelForm):
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):

View File

@ -46,12 +46,23 @@ class ConfirmBuildForm(HelperForm):
class CompleteBuildForm(HelperForm):
""" Form for marking a Build as complete """
field_prefix = {
'serial_numbers': 'fa-hashtag',
}
field_placeholder = {
}
location = forms.ModelChoiceField(
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'))

View File

@ -53,6 +53,22 @@ class Build(MPTTModel):
def get_absolute_url(self):
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(
verbose_name=_('Build Title'),
blank=False,

View File

@ -196,6 +196,15 @@ class BuildComplete(AjaxUpdateView):
if not build.part.trackable:
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
@ -208,6 +217,7 @@ class BuildComplete(AjaxUpdateView):
initials = super(BuildComplete, self).get_initial().copy()
build = self.get_object()
if build.part.default_location is not None:
try:
location = StockLocation.objects.get(pk=build.part.default_location.id)
@ -282,7 +292,7 @@ class BuildComplete(AjaxUpdateView):
existing = []
for serial in serials:
if not StockItem.check_serial_number(build.part, serial):
if build.part.checkIfSerialNumberExists(serial):
existing.append(serial)
if len(existing) > 0:

View File

@ -16,6 +16,14 @@ from .models import SupplierPriceBreak
class EditCompanyForm(HelperForm):
""" 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:
model = Company
fields = [
@ -45,6 +53,13 @@ class CompanyImageForm(HelperForm):
class EditSupplierPartForm(HelperForm):
""" Form for editing a SupplierPart object """
field_prefix = {
'link': 'fa-link',
'MPN': 'fa-hashtag',
'SKU': 'fa-hashtag',
'note': 'fa-pencil-alt',
}
class Meta:
model = SupplierPart
fields = [

View File

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

View File

@ -7,6 +7,10 @@
description: 'M2x4 low profile head screw'
category: 8
link: www.acme.com/parts/m2x4lphs
tree_id: 0
level: 0
lft: 0
rght: 0
- model: part.part
pk: 2
@ -14,6 +18,10 @@
name: 'M3x12 SHCS'
description: 'M3x12 socket head cap screw'
category: 8
tree_id: 0
level: 0
lft: 0
rght: 0
# Create some resistors
@ -23,6 +31,11 @@
name: 'R_2K2_0805'
description: '2.2kOhm resistor in 0805 package'
category: 2
tree_id: 0
level: 0
lft: 0
rght: 0
- model: part.part
fields:
@ -30,6 +43,10 @@
description: '4.7kOhm resistor in 0603 package'
category: 2
default_location: 2 # Home/Bathroom
tree_id: 0
level: 0
lft: 0
rght: 0
# Create some capacitors
- model: part.part
@ -37,6 +54,10 @@
name: 'C_22N_0805'
description: '22nF capacitor in 0805 package'
category: 3
tree_id: 0
level: 0
lft: 0
rght: 0
- model: part.part
pk: 25
@ -45,6 +66,10 @@
description: 'A watchamacallit'
category: 7
trackable: true
tree_id: 0
level: 0
lft: 0
rght: 0
- model: part.part
pk: 50
@ -52,6 +77,10 @@
name: 'Orphan'
description: 'A part without a category'
category: null
tree_id: 0
level: 0
lft: 0
rght: 0
# A part that can be made from other parts
- model: part.part
@ -64,4 +93,65 @@
category: 7
active: False
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):
""" Form for editing a Part object """
field_prefix = {
'keywords': 'fa-key',
'link': 'fa-link',
'IPN': 'fa-hashtag',
}
deep_copy = forms.BooleanField(required=False,
initial=True,
help_text=_("Perform 'deep copy' which will duplicate all BOM data for this part"),
@ -155,6 +161,10 @@ class EditPartParameterForm(HelperForm):
class EditCategoryForm(HelperForm):
""" Form for editing a PartCategory object """
field_prefix = {
'default_keywords': 'fa-key',
}
class Meta:
model = PartCategory
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 mptt.models import TreeForeignKey
from mptt.models import TreeForeignKey, MPTTModel
from stdimage.models import StdImageField
@ -39,7 +39,7 @@ from InvenTree.models import InvenTreeTree, InvenTreeAttachment
from InvenTree.fields import InvenTreeURLField
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 order import models as OrderModels
@ -200,7 +200,7 @@ def match_part_names(match, threshold=80, reverse=True, compare_length=False):
@cleanup.ignore
class Part(models.Model):
class Part(MPTTModel):
""" 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.
@ -236,8 +236,12 @@ class Part(models.Model):
"""
class Meta:
verbose_name = "Part"
verbose_name_plural = "Parts"
verbose_name = _("Part")
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):
"""
@ -262,6 +266,66 @@ class Part(models.Model):
def __str__(self):
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
def full_name(self):
""" Format a 'full name' for this Part.
@ -638,32 +702,40 @@ class Part(models.Model):
self.sales_order_allocation_count(),
])
@property
def stock_entries(self):
""" Return all 'in stock' items. To be in stock:
def stock_entries(self, include_variants=True, in_stock=None):
""" Return all stock entries for this Part.
- build_order is None
- sales_order is None
- belongs_to is None
- If this is a template part, include variants underneath this.
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
def total_stock(self):
""" 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:
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']
entries = self.stock_entries(in_stock=True)
if total:
return total
else:
return Decimal(0)
query = entries.aggregate(t=Coalesce(Sum('quantity'), Decimal(0)))
return query['t']
@property
def has_bom(self):

View File

@ -85,7 +85,7 @@ class PartAPITest(APITestCase):
data = {'cascade': True}
response = self.client.get(url, data, format='json')
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):
url = reverse('api-part-list')

View File

@ -88,9 +88,9 @@ class CategoryTest(TestCase):
self.assertEqual(self.electronics.partcount(), 3)
self.assertEqual(self.mechanical.partcount(), 4)
self.assertEqual(self.mechanical.partcount(active=True), 3)
self.assertEqual(self.mechanical.partcount(False), 2)
self.assertEqual(self.mechanical.partcount(), 8)
self.assertEqual(self.mechanical.partcount(active=True), 7)
self.assertEqual(self.mechanical.partcount(False), 6)
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')
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):
p = Part.objects.get(pk=100)
self.assertEqual(str(p), "BOB | Bob | A2 - Can we build it?")

View File

@ -68,4 +68,160 @@
level: 0
tree_id: 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

View File

@ -13,6 +13,8 @@ from mptt.fields import TreeNodeChoiceField
from InvenTree.helpers import GetExportFormats
from InvenTree.forms import HelperForm
from InvenTree.fields import RoundingDecimalFormField
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)'))
def __init__(self, *args, **kwargs):
self.field_prefix = {
'serial_numbers': 'fa-hashtag',
'link': 'fa-link',
}
super().__init__(*args, **kwargs)
class Meta:
model = StockItem
fields = [
@ -69,6 +80,7 @@ class CreateStockItemForm(HelperForm):
return
self.cleaned_data = {}
# If the form is permitted to be empty, and none of the form data has
# changed from the initial data, short circuit any validation.
if self.empty_permitted and not self.has_changed():
@ -79,7 +91,7 @@ class CreateStockItemForm(HelperForm):
self._clean_form()
class SerializeStockForm(forms.ModelForm):
class SerializeStockForm(HelperForm):
""" 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)')
@ -88,6 +100,18 @@ class SerializeStockForm(forms.ModelForm):
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:
model = StockItem

View File

@ -142,9 +142,21 @@ class StockItem(MPTTModel):
)
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:
# StockItem has not yet been saved
add_note = True
else:
# StockItem has already been saved
add_note = False
user = kwargs.pop('user', None)
@ -172,59 +184,26 @@ class StockItem(MPTTModel):
""" Return True if this StockItem is serialized """
return self.serial is not None and self.quantity == 1
@classmethod
def check_serial_number(cls, part, serial_number):
""" Check if a new stock item can be created with the provided part_id
Args:
part: The part to be checked
def validate_unique(self, exclude=None):
"""
Test that this StockItem is "unique".
If the StockItem is serialized, the same serial number.
cannot exist for the same part (or part tree).
"""
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)
# If the Part object is a variant (of a template part),
# ensure that the serial number is unique
# across all variants of the same template part
if self.serial is not None:
# Query to look for duplicate serial numbers
parts = PartModels.Part.objects.filter(tree_id=self.part.tree_id)
stock = StockItem.objects.filter(part__in=parts, serial=self.serial)
try:
if self.serial is not None:
# This is a variant part (check S/N across all sibling variants)
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():
raise ValidationError({
'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
# Exclude myself from the search
if self.pk is not None:
stock = stock.exclude(pk=self.pk)
if stock.exists():
raise ValidationError({"serial": _("StockItem with this serial number already exists")})
def clean(self):
""" 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
"""
super().clean()
if self.status == StockStatus.SHIPPED and self.sales_order is None:
raise ValidationError({
'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',
})
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!
try:
if self.supplier_part is not None:
@ -599,6 +598,9 @@ class StockItem(MPTTModel):
if self.serialized:
return
if not self.part.trackable:
raise ValidationError({"part": _("Part is not set as trackable")})
# Quantity must be a valid integer value
try:
quantity = int(quantity)
@ -624,7 +626,7 @@ class StockItem(MPTTModel):
existing = []
for serial in serials:
if not StockItem.check_serial_number(self.part, serial):
if self.part.checkIfSerialNumberExists(serial):
existing.append(serial)
if len(existing) > 0:

View File

@ -38,6 +38,10 @@ class StockTest(TestCase):
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):
self.assertEqual(StockLocation.objects.count(), 7)
@ -91,13 +95,16 @@ class StockTest(TestCase):
self.assertFalse(self.drawer2.has_items())
# Drawer 3 should have three stock items
self.assertEqual(self.drawer3.stock_items.count(), 3)
self.assertEqual(self.drawer3.item_count, 3)
self.assertEqual(self.drawer3.stock_items.count(), 15)
self.assertEqual(self.drawer3.item_count, 15)
def test_stock_count(self):
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)
# There should be 18 widgets in stock
@ -327,3 +334,73 @@ class StockTest(TestCase):
# Serialize the remainder of the stock
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)
form.fields['supplier_part'].queryset = query
if not item.part.trackable:
if not item.part.trackable or not item.serialized:
form.fields.pop('serial')
return form
@ -757,6 +757,17 @@ class StockItemSerialize(AjaxUpdateView):
ajax_form_title = _('Serialize Stock')
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):
initials = super().get_initial().copy()
@ -764,6 +775,7 @@ class StockItemSerialize(AjaxUpdateView):
item = self.get_object()
initials['quantity'] = item.quantity
initials['serial_numbers'] = item.part.getSerialNumberString(item.quantity)
initials['destination'] = item.location.pk
return initials
@ -844,6 +856,8 @@ class StockItemCreate(AjaxCreateView):
form = super().get_form()
part = None
# If the user has selected a Part, limit choices for SupplierPart
if form['part'].value():
part_id = form['part'].value()
@ -851,6 +865,11 @@ class StockItemCreate(AjaxCreateView):
try:
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)
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
all_parts = parts.all()
if len(all_parts) == 1:
# 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!
elif form['supplier_part'].value() is not None:
pass
return form
def get_initial(self):
@ -917,6 +937,7 @@ class StockItemCreate(AjaxCreateView):
if part_id:
try:
part = Part.objects.get(pk=part_id)
# Check that the supplied part is 'valid'
if not part.is_template and part.active and not part.virtual:
initials['part'] = part
@ -954,6 +975,8 @@ class StockItemCreate(AjaxCreateView):
- Manage serial-number valdiation for tracked parts
"""
part = None
form = self.get_form()
data = {}
@ -965,12 +988,22 @@ class StockItemCreate(AjaxCreateView):
try:
part = Part.objects.get(id=part_id)
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):
part = None
quantity = 1
valid = False
form.errors['quantity'] = [_('Invalid quantity')]
if quantity <= 0:
form.errors['quantity'] = [_('Quantity must be greater than zero')]
valid = False
if part is None:
form.errors['part'] = [_('Invalid part selection')]
else:
@ -988,7 +1021,7 @@ class StockItemCreate(AjaxCreateView):
existing = []
for serial in serials:
if not StockItem.check_serial_number(part, serial):
if part.checkIfSerialNumberExists(serial):
existing.append(serial)
if len(existing) > 0:
@ -1003,24 +1036,26 @@ class StockItemCreate(AjaxCreateView):
form_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=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'),
)
if form.is_valid():
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)))
valid = True
item.save(user=request.user)
data['success'] = _('Created {n} new stock items'.format(n=len(serials)))
valid = True
except ValidationError as e:
form.errors['serial_numbers'] = e.messages
@ -1031,6 +1066,24 @@ class StockItemCreate(AjaxCreateView):
form.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.save(user=request.user)
@ -1038,20 +1091,9 @@ class StockItemCreate(AjaxCreateView):
data['url'] = item.get_absolute_url()
data['success'] = _("Created new stock item")
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()
item = form.save(commit=False)
item.save(user=request.user)
valid = True
data['pk'] = item.pk
data['url'] = item.get_absolute_url()
data['success'] = _("Created new stock item")
data['form_valid'] = valid
data['form_valid'] = valid and form.is_valid()
return self.renderJsonResponse(request, form, data=data)

View File

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