diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 9e22618957..b112c93fa9 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -2,11 +2,16 @@ # InvenTree API version -INVENTREE_API_VERSION = 116 +INVENTREE_API_VERSION = 117 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v117 -> 2023-05-22 : https://github.com/inventree/InvenTree/pull/4854 + - Part.units model now supports physical units (e.g. "kg", "m", "mm", etc) + - Replaces SupplierPart "pack_size" field with "pack_quantity" + - New field supports physical units, and allows for conversion between compatible units + v116 -> 2023-05-18 : https://github.com/inventree/InvenTree/pull/4823 - Updates to part parameter implementation, to use physical units diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py index c61ccba725..27f212f038 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -11,6 +11,7 @@ from django.core.exceptions import AppRegistryNotReady from django.db import transaction from django.db.utils import IntegrityError +import InvenTree.conversion import InvenTree.tasks from InvenTree.config import get_setting from InvenTree.ready import canAppAccessDatabase, isInTestMode @@ -46,6 +47,9 @@ class InvenTreeConfig(AppConfig): self.collect_notification_methods() + # Ensure the unit registry is loaded + InvenTree.conversion.reload_unit_registry() + if canAppAccessDatabase() or settings.TESTING_ENV: self.add_user_on_startup() diff --git a/InvenTree/InvenTree/conversion.py b/InvenTree/InvenTree/conversion.py index 29d9816950..e2d114c728 100644 --- a/InvenTree/InvenTree/conversion.py +++ b/InvenTree/InvenTree/conversion.py @@ -15,13 +15,31 @@ def get_unit_registry(): # Cache the unit registry for speedier access if _unit_registry is None: - _unit_registry = pint.UnitRegistry() - - # TODO: Allow for custom units to be defined in the database + reload_unit_registry() return _unit_registry +def reload_unit_registry(): + """Reload the unit registry from the database. + + This function is called at startup, and whenever the database is updated. + """ + + global _unit_registry + + _unit_registry = pint.UnitRegistry() + + # Define some "standard" additional units + _unit_registry.define('piece = 1') + _unit_registry.define('each = 1 = ea') + _unit_registry.define('dozen = 12 = dz') + _unit_registry.define('hundred = 100') + _unit_registry.define('thousand = 1000') + + # TODO: Allow for custom units to be defined in the database + + def convert_physical_value(value: str, unit: str = None): """Validate that the provided value is a valid physical quantity. @@ -30,7 +48,7 @@ def convert_physical_value(value: str, unit: str = None): unit: Optional unit to convert to, and validate against Raises: - ValidationError: If the value is invalid + ValidationError: If the value is invalid or cannot be converted to the specified unit Returns: The converted quantity, in the specified units @@ -62,11 +80,9 @@ def convert_physical_value(value: str, unit: str = None): # At this point we *should* have a valid pint value # To double check, look at the maginitude float(val.magnitude) - except ValueError: + except (TypeError, ValueError): error = _('Provided value is not a valid number') - except pint.errors.UndefinedUnitError: - error = _('Provided value has an invalid unit') - except pint.errors.DefinitionSyntaxError: + except (pint.errors.UndefinedUnitError, pint.errors.DefinitionSyntaxError): error = _('Provided value has an invalid unit') except pint.errors.DimensionalityError: error = _('Provided value could not be converted to the specified unit') diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index 83027fe4ef..a107bb934a 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -18,6 +18,7 @@ from djmoney.contrib.exchange.exceptions import MissingRate from djmoney.contrib.exchange.models import Rate, convert_money from djmoney.money import Money +import InvenTree.conversion import InvenTree.format import InvenTree.helpers import InvenTree.tasks @@ -33,6 +34,28 @@ from .tasks import offload_task from .validators import validate_overage +class ConversionTest(TestCase): + """Tests for conversion of physical units""" + + def test_dimensionless_units(self): + """Tests for 'dimensonless' unit quantities""" + + # Test some dimensionless units + tests = { + 'ea': 1, + 'each': 1, + '3 piece': 3, + '5 dozen': 60, + '3 hundred': 300, + '2 thousand': 2000, + '12 pieces': 12, + } + + for val, expected in tests.items(): + q = InvenTree.conversion.convert_physical_value(val).to_base_units() + self.assertEqual(q.magnitude, expected) + + class ValidatorTest(TestCase): """Simple tests for custom field validators.""" diff --git a/InvenTree/company/api.py b/InvenTree/company/api.py index bd35fcc4ef..aee0d59ae8 100644 --- a/InvenTree/company/api.py +++ b/InvenTree/company/api.py @@ -390,7 +390,7 @@ class SupplierPartList(ListCreateDestroyAPIView): 'manufacturer', 'MPN', 'packaging', - 'pack_size', + 'pack_quantity', 'in_stock', 'updated', ] @@ -400,6 +400,7 @@ class SupplierPartList(ListCreateDestroyAPIView): 'supplier': 'supplier__name', 'manufacturer': 'manufacturer_part__manufacturer__name', 'MPN': 'manufacturer_part__MPN', + 'pack_quantity': ['pack_quantity_native', 'pack_quantity'], } search_fields = [ diff --git a/InvenTree/company/fixtures/supplier_part.yaml b/InvenTree/company/fixtures/supplier_part.yaml index 0ebc07d8d9..96d678a68f 100644 --- a/InvenTree/company/fixtures/supplier_part.yaml +++ b/InvenTree/company/fixtures/supplier_part.yaml @@ -66,4 +66,5 @@ part: 4 supplier: 2 SKU: 'R_4K7_0603.100PCK' - pack_size: 100 + pack_quantity: '100' + pack_quantity_native: 100 diff --git a/InvenTree/company/migrations/0059_supplierpart_pack_units.py b/InvenTree/company/migrations/0059_supplierpart_pack_units.py new file mode 100644 index 0000000000..864ebad772 --- /dev/null +++ b/InvenTree/company/migrations/0059_supplierpart_pack_units.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.19 on 2023-05-19 03:41 + +from django.db import migrations, models + +import InvenTree.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('company', '0058_auto_20230515_0004'), + ] + + operations = [ + migrations.AddField( + model_name='supplierpart', + name='pack_quantity', + field=models.CharField(blank=True, help_text='Total quantity supplied in a single pack. Leave empty for single items.', max_length=25, verbose_name='Pack Quantity'), + ), + migrations.AddField( + model_name='supplierpart', + name='pack_quantity_native', + field=InvenTree.fields.RoundingDecimalField(decimal_places=10, default=1, max_digits=20, null=True), + ), + ] diff --git a/InvenTree/company/migrations/0060_auto_20230519_0344.py b/InvenTree/company/migrations/0060_auto_20230519_0344.py new file mode 100644 index 0000000000..34e6d2fc69 --- /dev/null +++ b/InvenTree/company/migrations/0060_auto_20230519_0344.py @@ -0,0 +1,51 @@ +# Generated by Django 3.2.19 on 2023-05-19 03:44 + +from django.db import migrations + +from InvenTree.helpers import normalize + + +def update_supplier_part_units(apps, schema_editor): + """Migrate existing supplier part units to new field""" + + SupplierPart = apps.get_model('company', 'SupplierPart') + + supplier_parts = SupplierPart.objects.all() + + for sp in supplier_parts: + pack_size = normalize(sp.pack_size) + sp.pack_quantity = str(pack_size) + sp.pack_quantity_native = pack_size + sp.save() + + if supplier_parts.count() > 0: + print(f"Updated {supplier_parts.count()} supplier part units") + + +def reverse_pack_quantity(apps, schema_editor): + """Reverse the migrations""" + + SupplierPart = apps.get_model('company', 'SupplierPart') + + supplier_parts = SupplierPart.objects.all() + + for sp in supplier_parts: + sp.pack_size = sp.pack_quantity_native + sp.save() + + if supplier_parts.count() > 0: + print(f"Updated {supplier_parts.count()} supplier part units") + +class Migration(migrations.Migration): + + dependencies = [ + ('company', '0059_supplierpart_pack_units'), + ('part', '0111_auto_20230521_1350'), + ] + + operations = [ + migrations.RunPython( + code=update_supplier_part_units, + reverse_code=reverse_pack_quantity, + ) + ] diff --git a/InvenTree/company/migrations/0061_remove_supplierpart_pack_size.py b/InvenTree/company/migrations/0061_remove_supplierpart_pack_size.py new file mode 100644 index 0000000000..0ae404be05 --- /dev/null +++ b/InvenTree/company/migrations/0061_remove_supplierpart_pack_size.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.19 on 2023-05-19 04:03 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('company', '0060_auto_20230519_0344'), + ] + + operations = [ + migrations.RemoveField( + model_name='supplierpart', + name='pack_size', + ), + ] diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py index 7e03e7187a..3cd94c855d 100644 --- a/InvenTree/company/models.py +++ b/InvenTree/company/models.py @@ -2,6 +2,7 @@ import os from datetime import datetime +from decimal import Decimal from django.apps import apps from django.core.exceptions import ValidationError @@ -19,6 +20,7 @@ from taggit.managers import TaggableManager import common.models import common.settings +import InvenTree.conversion import InvenTree.fields import InvenTree.helpers import InvenTree.ready @@ -436,7 +438,8 @@ class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin multiple: Multiple that the part is provided in lead_time: Supplier lead time packaging: packaging that the part is supplied in, e.g. "Reel" - pack_size: Quantity of item supplied in a single pack (e.g. 30ml in a single tube) + pack_quantity: Quantity of item supplied in a single pack (e.g. 30ml in a single tube) + pack_quantity_native: Pack quantity, converted to "native" units of the referenced part updated: Date that the SupplierPart was last updated """ @@ -475,6 +478,40 @@ class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin """ super().clean() + self.pack_quantity = self.pack_quantity.strip() + + # An empty 'pack_quantity' value is equivalent to '1' + if self.pack_quantity == '': + self.pack_quantity = '1' + + # Validate that the UOM is compatible with the base part + if self.pack_quantity and self.part: + try: + # Attempt conversion to specified unit + native_value = InvenTree.conversion.convert_physical_value( + self.pack_quantity, self.part.units + ) + + # If part units are not provided, value must be dimensionless + if not self.part.units and native_value.units not in ['', 'dimensionless']: + raise ValidationError({ + 'pack_quantity': _("Pack units must be compatible with the base part units") + }) + + # Native value must be greater than zero + if float(native_value.magnitude) <= 0: + raise ValidationError({ + 'pack_quantity': _("Pack units must be greater than zero") + }) + + # Update native pack units value + self.pack_quantity_native = Decimal(native_value.magnitude) + + except ValidationError as e: + raise ValidationError({ + 'pack_quantity': e.messages + }) + # Ensure that the linked manufacturer_part points to the same part! if self.manufacturer_part and self.part: @@ -510,21 +547,23 @@ class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin super().save(*args, **kwargs) - part = models.ForeignKey('part.Part', on_delete=models.CASCADE, - related_name='supplier_parts', - verbose_name=_('Base Part'), - limit_choices_to={ - 'purchaseable': True, - }, - help_text=_('Select part'), - ) + part = models.ForeignKey( + 'part.Part', on_delete=models.CASCADE, + related_name='supplier_parts', + verbose_name=_('Base Part'), + limit_choices_to={ + 'purchaseable': True, + }, + help_text=_('Select part'), + ) - supplier = models.ForeignKey(Company, on_delete=models.CASCADE, - related_name='supplied_parts', - limit_choices_to={'is_supplier': True}, - verbose_name=_('Supplier'), - help_text=_('Select supplier'), - ) + supplier = models.ForeignKey( + Company, on_delete=models.CASCADE, + related_name='supplied_parts', + limit_choices_to={'is_supplier': True}, + verbose_name=_('Supplier'), + help_text=_('Select supplier'), + ) SKU = models.CharField( max_length=100, @@ -532,12 +571,13 @@ class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin help_text=_('Supplier stock keeping unit') ) - manufacturer_part = models.ForeignKey(ManufacturerPart, on_delete=models.CASCADE, - blank=True, null=True, - related_name='supplier_parts', - verbose_name=_('Manufacturer Part'), - help_text=_('Select manufacturer part'), - ) + manufacturer_part = models.ForeignKey( + ManufacturerPart, on_delete=models.CASCADE, + blank=True, null=True, + related_name='supplier_parts', + verbose_name=_('Manufacturer Part'), + help_text=_('Select manufacturer part'), + ) link = InvenTreeURLField( blank=True, null=True, @@ -561,14 +601,26 @@ class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin packaging = models.CharField(max_length=50, blank=True, null=True, verbose_name=_('Packaging'), help_text=_('Part packaging')) - pack_size = RoundingDecimalField( + pack_quantity = models.CharField( + max_length=25, verbose_name=_('Pack Quantity'), - help_text=_('Unit quantity supplied in a single pack'), - default=1, - max_digits=15, decimal_places=5, - validators=[MinValueValidator(0.001)], + help_text=_('Total quantity supplied in a single pack. Leave empty for single items.'), + blank=True, ) + pack_quantity_native = RoundingDecimalField( + max_digits=20, decimal_places=10, default=1, + null=True, + ) + + def base_quantity(self, quantity=1) -> Decimal: + """Calculate the base unit quantiy for a given quantity.""" + + q = Decimal(quantity) * Decimal(self.pack_quantity_native) + q = round(q, 10).normalize() + + return q + multiple = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], verbose_name=_('multiple'), help_text=_('Order multiple')) # TODO - Reimplement lead-time as a charfield with special validation (pattern matching). diff --git a/InvenTree/company/serializers.py b/InvenTree/company/serializers.py index 3835371947..f0833d18b0 100644 --- a/InvenTree/company/serializers.py +++ b/InvenTree/company/serializers.py @@ -265,7 +265,8 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer): 'pk', 'barcode_hash', 'packaging', - 'pack_size', + 'pack_quantity', + 'pack_quantity_native', 'part', 'part_detail', 'pretty_name', @@ -327,8 +328,6 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer): pretty_name = serializers.CharField(read_only=True) - pack_size = serializers.FloatField(label=_('Pack Quantity')) - supplier = serializers.PrimaryKeyRelatedField(queryset=Company.objects.filter(is_supplier=True)) manufacturer = serializers.CharField(read_only=True) diff --git a/InvenTree/company/templates/company/supplier_part.html b/InvenTree/company/templates/company/supplier_part.html index 1e88babae3..4fc29abe38 100644 --- a/InvenTree/company/templates/company/supplier_part.html +++ b/InvenTree/company/templates/company/supplier_part.html @@ -162,11 +162,24 @@ src="{% static 'img/blank_image.png' %}" {{ part.packaging }}{% include "clip.html" %} {% endif %} - {% if part.pack_size != 1.0 %} + {% if part.pack_quantity %} - {% trans "Pack Quantity" %} - {% decimal part.pack_size %} {% include "part/part_units.html" with part=part.part %} + + {% trans "Units" %} + {% if part.part.units %} + + [ {% include "part/part_units.html" with part=part.part %}] + + {% endif %} + + + {{ part.pack_quantity }} + {% include "clip.html" %} + {% if part.part.units and part.pack_quantity_native %} + + {% endif %} + {% endif %} {% if part.note %} diff --git a/InvenTree/company/test_migrations.py b/InvenTree/company/test_migrations.py index f957d17da3..fb38e9e695 100644 --- a/InvenTree/company/test_migrations.py +++ b/InvenTree/company/test_migrations.py @@ -277,3 +277,51 @@ class TestCurrencyMigration(MigratorTestCase): for pb in PB.objects.all(): # Test that a price has been assigned self.assertIsNotNone(pb.price) + + +class TestSupplierPartQuantity(MigratorTestCase): + """Test that the supplier part quantity is correctly migrated.""" + + migrate_from = ('company', '0058_auto_20230515_0004') + migrate_to = ('company', unit_test.getNewestMigrationFile('company')) + + def prepare(self): + """Prepare a number of SupplierPart objects""" + + Part = self.old_state.apps.get_model('part', 'part') + Company = self.old_state.apps.get_model('company', 'company') + SupplierPart = self.old_state.apps.get_model('company', 'supplierpart') + + self.part = Part.objects.create( + name="PART", description="A purchaseable part", + purchaseable=True, + level=0, tree_id=0, lft=0, rght=0 + ) + + self.supplier = Company.objects.create(name='Supplier', description='A supplier', is_supplier=True) + + self.supplier_parts = [] + + for i in range(10): + self.supplier_parts.append( + SupplierPart.objects.create( + part=self.part, + supplier=self.supplier, + SKU=f'SKU-{i}', + pack_size=i + 1, + ) + ) + + def test_supplier_part_quantity(self): + """Test that the supplier part quantity is correctly migrated.""" + + SupplierPart = self.new_state.apps.get_model('company', 'supplierpart') + + for i, sp in enumerate(SupplierPart.objects.all()): + + self.assertEqual(sp.pack_quantity, str(i + 1)) + self.assertEqual(sp.pack_quantity_native, i + 1) + + # And the 'pack_size' attribute has been removed + with self.assertRaises(AttributeError): + sp.pack_size diff --git a/InvenTree/company/test_supplier_parts.py b/InvenTree/company/test_supplier_parts.py new file mode 100644 index 0000000000..60fef4c799 --- /dev/null +++ b/InvenTree/company/test_supplier_parts.py @@ -0,0 +1,114 @@ +"""Unit tests specific to the SupplierPart model""" + +from decimal import Decimal + +from django.core.exceptions import ValidationError + +from company.models import Company, SupplierPart +from InvenTree.unit_test import InvenTreeTestCase +from part.models import Part + + +class SupplierPartPackUnitsTests(InvenTreeTestCase): + """Unit tests for the SupplierPart pack_quantity field""" + + def test_pack_quantity_dimensionless(self): + """Test valid values for the 'pack_quantity' field""" + + # Create a part without units (dimensionless) + part = Part.objects.create(name='Test Part', description='Test part description', component=True) + + # Create a supplier (company) + company = Company.objects.create(name='Test Company', is_supplier=True) + + # Create a supplier part for this part + sp = SupplierPart.objects.create( + part=part, + supplier=company, + SKU='TEST-SKU' + ) + + # All these values are valid for a dimensionless part + pass_tests = { + '': 1, + '1': 1, + '1.01': 1.01, + '12.000001': 12.000001, + '99.99': 99.99, + } + + # All these values are invalid for a dimensionless part + fail_tests = [ + '1.2m', + '-1', + '0', + '0.0', + '100 feet', + '0 amps' + ] + + for test, expected in pass_tests.items(): + sp.pack_quantity = test + sp.full_clean() + self.assertEqual(sp.pack_quantity_native, expected) + + for test in fail_tests: + sp.pack_quantity = test + with self.assertRaises(ValidationError): + sp.full_clean() + + def test_pack_quantity(self): + """Test pack_quantity for a part with a specified dimension""" + + # Create a part with units 'm' + part = Part.objects.create(name='Test Part', description='Test part description', component=True, units='m') + + # Create a supplier (company) + company = Company.objects.create(name='Test Company', is_supplier=True) + + # Create a supplier part for this part + sp = SupplierPart.objects.create( + part=part, + supplier=company, + SKU='TEST-SKU' + ) + + # All these values are valid for a part with dimesion 'm' + pass_tests = { + '': 1, + '1': 1, + '1m': 1, + '1.01m': 1.01, + '1.01': 1.01, + '5 inches': 0.127, + '27 cm': 0.27, + '3km': 3000, + '14 feet': 4.2672, + '0.5 miles': 804.672, + } + + # All these values are invalid for a part with dimension 'm' + # Either the values are invalid, or the units are incomaptible + fail_tests = [ + '-1', + '-1m', + '0', + '0m', + '12 deg', + '57 amps', + '-12 oz', + '17 yaks', + ] + + for test, expected in pass_tests.items(): + sp.pack_quantity = test + sp.full_clean() + self.assertEqual( + round(Decimal(sp.pack_quantity_native), 10), + round(Decimal(str(expected)), 10) + ) + + for test in fail_tests: + sp.pack_quantity = test + with self.assertRaises(ValidationError): + sp.full_clean() diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 5fdd4d337d..f3dc54f16a 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -602,11 +602,13 @@ class PurchaseOrder(TotalPriceMixin, Order): # Create a new stock item if line.part and quantity > 0: - # Take the 'pack_size' of the SupplierPart into account - pack_quantity = Decimal(quantity) * Decimal(line.part.pack_size) + # Calculate received quantity in base units + stock_quantity = line.part.base_quantity(quantity) + # Calculate unit purchase price (in base units) if line.purchase_price: - unit_purchase_price = line.purchase_price / line.part.pack_size + unit_purchase_price = line.purchase_price + unit_purchase_price /= line.part.base_quantity(1) else: unit_purchase_price = None @@ -623,7 +625,7 @@ class PurchaseOrder(TotalPriceMixin, Order): part=line.part.part, supplier_part=line.part, location=location, - quantity=1 if serialize else pack_quantity, + quantity=1 if serialize else stock_quantity, purchase_order=self, status=status, batch=batch_code, @@ -656,7 +658,7 @@ class PurchaseOrder(TotalPriceMixin, Order): ) # Update the number of parts received against the particular line item - # Note that this quantity does *not* take the pack_size into account, it is "number of packs" + # Note that this quantity does *not* take the pack_quantity into account, it is "number of packs" line.received += quantity line.save() diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index e016219b1e..64ca1aaf32 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -558,14 +558,12 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer): serial_numbers = data.get('serial_numbers', '').strip() base_part = line_item.part.part - pack_size = line_item.part.pack_size - - pack_quantity = pack_size * quantity + base_quantity = line_item.part.base_quantity(quantity) # Does the quantity need to be "integer" (for trackable parts?) if base_part.trackable: - if Decimal(pack_quantity) != int(pack_quantity): + if Decimal(base_quantity) != int(base_quantity): raise ValidationError({ 'quantity': _('An integer quantity must be provided for trackable parts'), }) @@ -576,7 +574,7 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer): # Pass the serial numbers through to the parent serializer once validated data['serials'] = extract_serial_numbers( serial_numbers, - pack_quantity, + base_quantity, base_part.get_latest_serial_number() ) except DjangoValidationError as e: diff --git a/InvenTree/order/tests.py b/InvenTree/order/tests.py index b3870f1fb4..1f27ef0b9a 100644 --- a/InvenTree/order/tests.py +++ b/InvenTree/order/tests.py @@ -228,7 +228,7 @@ class OrderTest(TestCase): part=prt, supplier=sup, SKU='SKUx10', - pack_size=10, + pack_quantity='10', ) # Create a new supplier part with smaller pack size @@ -236,7 +236,7 @@ class OrderTest(TestCase): part=prt, supplier=sup, SKU='SKUx0.1', - pack_size=0.1, + pack_quantity='0.1', ) # Record values before we start diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index b9a390d9d5..b9a5b4575c 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -486,10 +486,10 @@ class PartScheduling(RetrieveAPI): target_date = line.target_date or line.order.target_date - quantity = max(line.quantity - line.received, 0) + line_quantity = max(line.quantity - line.received, 0) - # Multiply by the pack_size of the SupplierPart - quantity *= line.part.pack_size + # Multiply by the pack quantity of the SupplierPart + quantity = line.part.base_quantity(line_quantity) add_schedule_entry( target_date, @@ -804,19 +804,31 @@ class PartFilter(rest_filters.FilterSet): Uses the django_filters extension framework """ + class Meta: + """Metaclass options for this filter set""" + model = Part + fields = [] + + has_units = rest_filters.BooleanFilter(label='Has units', method='filter_has_units') + + def filter_has_units(self, queryset, name, value): + """Filter by whether the Part has units or not""" + + if str2bool(value): + return queryset.exclude(units='') + else: + return queryset.filter(units='') + # Filter by parts which have (or not) an IPN value has_ipn = rest_filters.BooleanFilter(label='Has IPN', method='filter_has_ipn') def filter_has_ipn(self, queryset, name, value): """Filter by whether the Part has an IPN (internal part number) or not""" - value = str2bool(value) - if value: - queryset = queryset.exclude(IPN='') + if str2bool(value): + return queryset.exclude(IPN='') else: - queryset = queryset.filter(IPN='') - - return queryset + return queryset.filter(IPN='') # Regex filter for name name_regex = rest_filters.CharFilter(label='Filter by name (regex)', field_name='name', lookup_expr='iregex') @@ -836,46 +848,36 @@ class PartFilter(rest_filters.FilterSet): def filter_low_stock(self, queryset, name, value): """Filter by "low stock" status.""" - value = str2bool(value) - if value: + if str2bool(value): # Ignore any parts which do not have a specified 'minimum_stock' level - queryset = queryset.exclude(minimum_stock=0) # Filter items which have an 'in_stock' level lower than 'minimum_stock' - queryset = queryset.filter(Q(in_stock__lt=F('minimum_stock'))) + return queryset.exclude(minimum_stock=0).filter(Q(in_stock__lt=F('minimum_stock'))) else: # Filter items which have an 'in_stock' level higher than 'minimum_stock' - queryset = queryset.filter(Q(in_stock__gte=F('minimum_stock'))) - - return queryset + return queryset.filter(Q(in_stock__gte=F('minimum_stock'))) # has_stock filter has_stock = rest_filters.BooleanFilter(label='Has stock', method='filter_has_stock') def filter_has_stock(self, queryset, name, value): """Filter by whether the Part has any stock""" - value = str2bool(value) - if value: - queryset = queryset.filter(Q(in_stock__gt=0)) + if str2bool(value): + return queryset.filter(Q(in_stock__gt=0)) else: - queryset = queryset.filter(Q(in_stock__lte=0)) - - return queryset + return queryset.filter(Q(in_stock__lte=0)) # unallocated_stock filter unallocated_stock = rest_filters.BooleanFilter(label='Unallocated stock', method='filter_unallocated_stock') def filter_unallocated_stock(self, queryset, name, value): """Filter by whether the Part has unallocated stock""" - value = str2bool(value) - if value: - queryset = queryset.filter(Q(unallocated_stock__gt=0)) + if str2bool(value): + return queryset.filter(Q(unallocated_stock__gt=0)) else: - queryset = queryset.filter(Q(unallocated_stock__lte=0)) - - return queryset + return queryset.filter(Q(unallocated_stock__lte=0)) convert_from = rest_filters.ModelChoiceFilter(label="Can convert from", queryset=Part.objects.all(), method='filter_convert_from') @@ -894,9 +896,7 @@ class PartFilter(rest_filters.FilterSet): children = part.get_descendants(include_self=True) - queryset = queryset.exclude(id__in=children) - - return queryset + return queryset.exclude(id__in=children) ancestor = rest_filters.ModelChoiceFilter(label='Ancestor', queryset=Part.objects.all(), method='filter_ancestor') @@ -904,17 +904,14 @@ class PartFilter(rest_filters.FilterSet): """Limit queryset to descendants of the specified ancestor part""" descendants = part.get_descendants(include_self=False) - queryset = queryset.filter(id__in=descendants) - - return queryset + return queryset.filter(id__in=descendants) variant_of = rest_filters.ModelChoiceFilter(label='Variant Of', queryset=Part.objects.all(), method='filter_variant_of') def filter_variant_of(self, queryset, name, part): """Limit queryset to direct children (variants) of the specified part""" - queryset = queryset.filter(id__in=part.get_children()) - return queryset + return queryset.filter(id__in=part.get_children()) in_bom_for = rest_filters.ModelChoiceFilter(label='In BOM Of', queryset=Part.objects.all(), method='filter_in_bom') @@ -922,39 +919,30 @@ class PartFilter(rest_filters.FilterSet): """Limit queryset to parts in the BOM for the specified part""" bom_parts = part.get_parts_in_bom() - queryset = queryset.filter(id__in=[p.pk for p in bom_parts]) - return queryset + return queryset.filter(id__in=[p.pk for p in bom_parts]) has_pricing = rest_filters.BooleanFilter(label="Has Pricing", method="filter_has_pricing") def filter_has_pricing(self, queryset, name, value): """Filter the queryset based on whether pricing information is available for the sub_part""" - value = str2bool(value) - q_a = Q(pricing_data=None) q_b = Q(pricing_data__overall_min=None, pricing_data__overall_max=None) - if value: - queryset = queryset.exclude(q_a | q_b) + if str2bool(value): + return queryset.exclude(q_a | q_b) else: - queryset = queryset.filter(q_a | q_b) - - return queryset + return queryset.filter(q_a | q_b) stocktake = rest_filters.BooleanFilter(label="Has stocktake", method='filter_has_stocktake') def filter_has_stocktake(self, queryset, name, value): """Filter the queryset based on whether stocktake data is available""" - value = str2bool(value) - - if (value): - queryset = queryset.exclude(last_stocktake=None) + if str2bool(value): + return queryset.exclude(last_stocktake=None) else: - queryset = queryset.filter(last_stocktake=None) - - return queryset + return queryset.filter(last_stocktake=None) is_template = rest_filters.BooleanFilter() @@ -1259,6 +1247,7 @@ class PartList(PartMixin, APIDownloadMixin, ListCreateAPI): 'unallocated_stock', 'category', 'last_stocktake', + 'units', ] # Default ordering diff --git a/InvenTree/part/filters.py b/InvenTree/part/filters.py index 8b690f56cd..7d12eed71f 100644 --- a/InvenTree/part/filters.py +++ b/InvenTree/part/filters.py @@ -40,7 +40,7 @@ def annotate_on_order_quantity(reference: str = ''): - Purchase order must be 'active' or 'pending' - Received quantity must be less than line item quantity - Note that in addition to the 'quantity' on order, we must also take into account 'pack_size'. + Note that in addition to the 'quantity' on order, we must also take into account 'pack_quantity'. """ # Filter only 'active' purhase orders @@ -53,7 +53,7 @@ def annotate_on_order_quantity(reference: str = ''): return Coalesce( SubquerySum( ExpressionWrapper( - F(f'{reference}supplier_parts__purchase_order_line_items__quantity') * F(f'{reference}supplier_parts__pack_size'), + F(f'{reference}supplier_parts__purchase_order_line_items__quantity') * F(f'{reference}supplier_parts__pack_quantity_native'), output_field=DecimalField(), ), filter=order_filter @@ -63,7 +63,7 @@ def annotate_on_order_quantity(reference: str = ''): ) - Coalesce( SubquerySum( ExpressionWrapper( - F(f'{reference}supplier_parts__purchase_order_line_items__received') * F(f'{reference}supplier_parts__pack_size'), + F(f'{reference}supplier_parts__purchase_order_line_items__received') * F(f'{reference}supplier_parts__pack_quantity_native'), output_field=DecimalField(), ), filter=order_filter diff --git a/InvenTree/part/migrations/0109_auto_20230517_1048.py b/InvenTree/part/migrations/0109_auto_20230517_1048.py index abb359119b..01e3ad0026 100644 --- a/InvenTree/part/migrations/0109_auto_20230517_1048.py +++ b/InvenTree/part/migrations/0109_auto_20230517_1048.py @@ -19,10 +19,14 @@ def update_template_units(apps, schema_editor): n_templates = PartParameterTemplate.objects.count() + if n_templates == 0: + # Escape early + return + ureg = InvenTree.conversion.get_unit_registry() n_converted = 0 - invalid_units = [] + invalid_units = set() for template in PartParameterTemplate.objects.all(): @@ -69,8 +73,8 @@ def update_template_units(apps, schema_editor): break if not found: - print(f"warningCould not find unit match for {template.units}") - invalid_units.append(template.units) + print(f"warning: Could not find unit match for {template.units}") + invalid_units.add(template.units) print(f"Updated units for {n_templates} parameter templates") diff --git a/InvenTree/part/migrations/0110_alter_part_units.py b/InvenTree/part/migrations/0110_alter_part_units.py new file mode 100644 index 0000000000..4ddad84858 --- /dev/null +++ b/InvenTree/part/migrations/0110_alter_part_units.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.19 on 2023-05-19 03:31 + +import InvenTree.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0109_auto_20230517_1048'), + ] + + operations = [ + migrations.AlterField( + model_name='part', + name='units', + field=models.CharField(blank=True, default='', help_text='Units of measure for this part', max_length=20, null=True, validators=[InvenTree.validators.validate_physical_units], verbose_name='Units'), + ), + ] diff --git a/InvenTree/part/migrations/0111_auto_20230521_1350.py b/InvenTree/part/migrations/0111_auto_20230521_1350.py new file mode 100644 index 0000000000..10b1a66f69 --- /dev/null +++ b/InvenTree/part/migrations/0111_auto_20230521_1350.py @@ -0,0 +1,93 @@ +# Generated by Django 3.2.19 on 2023-05-21 13:50 + +import pint + +from django.core.exceptions import ValidationError +from django.db import migrations + +import InvenTree.conversion + + +def migrate_part_units(apps, schema_editor): + """Update the units field for each Part object: + + - Check if the units are valid + - Attempt to convert to valid units (if possible) + """ + + Part = apps.get_model('part', 'Part') + + parts = Part.objects.exclude(units=None).exclude(units='') + n_parts = parts.count() + + if n_parts == 0: + # Escape early + return + + ureg = InvenTree.conversion.get_unit_registry() + + invalid_units = set() + n_converted = 0 + + for part in parts: + + # Override '%' units (which are invalid) + if part.units == '%': + part.units = 'percent' + part.save() + continue + + # Test if unit is 'valid' + try: + ureg.Unit(part.units) + continue + except pint.errors.UndefinedUnitError: + pass + + # Check a lower-case version + try: + ureg.Unit(part.units.lower()) + print(f"Found unit match: {part.units} -> {part.units.lower()}") + part.units = part.units.lower() + part.save() + n_converted += 1 + continue + except pint.errors.UndefinedUnitError: + pass + + found = False + + # Attempt to convert to a valid unit + for unit in ureg: + if unit.lower() == part.units.lower(): + print("Found unit match: {part.units} -> {unit}") + part.units = str(unit) + part.save() + n_converted += 1 + found = True + break + + if not found: + print(f"Warning: Invalid units for part '{part}': {part.units}") + invalid_units.add(part.units) + + print(f"Updated units for {n_parts} parts") + + if n_converted > 0: + print(f"Converted units for {n_converted} parts") + + if len(invalid_units) > 0: + print(f"Found {len(invalid_units)} invalid units:") + for unit in invalid_units: + print(f" - {unit}") + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0110_alter_part_units'), + ] + + operations = [ + migrations.RunPython(code=migrate_part_units, reverse_code=migrations.RunPython.noop) + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 4e0accdfdf..8e8b7569ef 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -984,6 +984,9 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) blank=True, null=True, verbose_name=_('Units'), help_text=_('Units of measure for this part'), + validators=[ + validators.validate_physical_units, + ] ) assembly = models.BooleanField( @@ -2141,7 +2144,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) def on_order(self): """Return the total number of items on order for this part. - Note that some supplier parts may have a different pack_size attribute, + Note that some supplier parts may have a different pack_quantity attribute, and this needs to be taken into account! """ @@ -2160,7 +2163,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) remaining = line.quantity - line.received if remaining > 0: - quantity += remaining * sp.pack_size + quantity += sp.base_quantity(remaining) return quantity @@ -2291,6 +2294,13 @@ def after_save_part(sender, instance: Part, created, **kwargs): # Can sometimes occur if the referenced Part has issues pass + # Schedule a background task to rebuild any supplier parts + InvenTree.tasks.offload_task( + part_tasks.rebuild_supplier_parts, + instance.pk, + force_async=True + ) + class PartPricing(common.models.MetaMixin): """Model for caching min/max pricing information for a particular Part @@ -2560,7 +2570,7 @@ class PartPricing(common.models.MetaMixin): continue # Take supplier part pack size into account - purchase_cost = self.convert(line.purchase_price / line.part.pack_size) + purchase_cost = self.convert(line.purchase_price / line.part.pack_quantity_native) if purchase_cost is None: continue @@ -2651,7 +2661,7 @@ class PartPricing(common.models.MetaMixin): continue # Ensure we take supplier part pack size into account - cost = self.convert(pb.price / sp.pack_size) + cost = self.convert(pb.price / sp.pack_quantity_native) if cost is None: continue @@ -3359,8 +3369,8 @@ def post_save_part_parameter_template(sender, instance, created, **kwargs): if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData(): - # Schedule a background task to rebuild the parameters against this template if not created: + # Schedule a background task to rebuild the parameters against this template InvenTree.tasks.offload_task( part_tasks.rebuild_parameters, instance.pk, diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 9d7b76275e..98f97906c4 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -20,21 +20,11 @@ from taggit.serializers import TagListSerializerField import common.models import company.models import InvenTree.helpers +import InvenTree.serializers import InvenTree.status import part.filters import part.tasks import stock.models -from InvenTree.serializers import (DataFileExtractSerializer, - DataFileUploadSerializer, - InvenTreeAttachmentSerializer, - InvenTreeAttachmentSerializerField, - InvenTreeCurrencySerializer, - InvenTreeDecimalField, - InvenTreeImageSerializerField, - InvenTreeModelSerializer, - InvenTreeMoneySerializer, - InvenTreeTagModelSerializer, - RemoteImageMixin, UserSerializer) from InvenTree.status_codes import BuildStatus from InvenTree.tasks import offload_task @@ -48,7 +38,7 @@ from .models import (BomItem, BomItemSubstitute, Part, PartAttachment, logger = logging.getLogger("inventree") -class CategorySerializer(InvenTreeModelSerializer): +class CategorySerializer(InvenTree.serializers.InvenTreeModelSerializer): """Serializer for PartCategory.""" class Meta: @@ -94,7 +84,7 @@ class CategorySerializer(InvenTreeModelSerializer): starred = serializers.SerializerMethodField() -class CategoryTree(InvenTreeModelSerializer): +class CategoryTree(InvenTree.serializers.InvenTreeModelSerializer): """Serializer for PartCategory tree.""" class Meta: @@ -108,19 +98,19 @@ class CategoryTree(InvenTreeModelSerializer): ] -class PartAttachmentSerializer(InvenTreeAttachmentSerializer): +class PartAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSerializer): """Serializer for the PartAttachment class.""" class Meta: """Metaclass defining serializer fields""" model = PartAttachment - fields = InvenTreeAttachmentSerializer.attachment_fields([ + fields = InvenTree.serializers.InvenTreeAttachmentSerializer.attachment_fields([ 'part', ]) -class PartTestTemplateSerializer(InvenTreeModelSerializer): +class PartTestTemplateSerializer(InvenTree.serializers.InvenTreeModelSerializer): """Serializer for the PartTestTemplate class.""" class Meta: @@ -141,7 +131,7 @@ class PartTestTemplateSerializer(InvenTreeModelSerializer): key = serializers.CharField(read_only=True) -class PartSalePriceSerializer(InvenTreeModelSerializer): +class PartSalePriceSerializer(InvenTree.serializers.InvenTreeModelSerializer): """Serializer for sale prices for Part model.""" class Meta: @@ -155,14 +145,14 @@ class PartSalePriceSerializer(InvenTreeModelSerializer): 'price_currency', ] - quantity = InvenTreeDecimalField() + quantity = InvenTree.serializers.InvenTreeDecimalField() - price = InvenTreeMoneySerializer(allow_null=True) + price = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True) - price_currency = InvenTreeCurrencySerializer(help_text=_('Purchase currency of this stock item')) + price_currency = InvenTree.serializers.InvenTreeCurrencySerializer(help_text=_('Purchase currency of this stock item')) -class PartInternalPriceSerializer(InvenTreeModelSerializer): +class PartInternalPriceSerializer(InvenTree.serializers.InvenTreeModelSerializer): """Serializer for internal prices for Part model.""" class Meta: @@ -176,13 +166,13 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer): 'price_currency', ] - quantity = InvenTreeDecimalField() + quantity = InvenTree.serializers.InvenTreeDecimalField() - price = InvenTreeMoneySerializer( + price = InvenTree.serializers.InvenTreeMoneySerializer( allow_null=True ) - price_currency = InvenTreeCurrencySerializer(help_text=_('Purchase currency of this stock item')) + price_currency = InvenTree.serializers.InvenTreeCurrencySerializer(help_text=_('Purchase currency of this stock item')) class PartThumbSerializer(serializers.Serializer): @@ -195,7 +185,7 @@ class PartThumbSerializer(serializers.Serializer): count = serializers.IntegerField(read_only=True) -class PartThumbSerializerUpdate(InvenTreeModelSerializer): +class PartThumbSerializerUpdate(InvenTree.serializers.InvenTreeModelSerializer): """Serializer for updating Part thumbnail.""" class Meta: @@ -212,10 +202,10 @@ class PartThumbSerializerUpdate(InvenTreeModelSerializer): raise serializers.ValidationError("File is not an image") return value - image = InvenTreeAttachmentSerializerField(required=True) + image = InvenTree.serializers.InvenTreeAttachmentSerializerField(required=True) -class PartParameterTemplateSerializer(InvenTreeModelSerializer): +class PartParameterTemplateSerializer(InvenTree.serializers.InvenTreeModelSerializer): """JSON serializer for the PartParameterTemplate model.""" class Meta: @@ -229,7 +219,7 @@ class PartParameterTemplateSerializer(InvenTreeModelSerializer): ] -class PartParameterSerializer(InvenTreeModelSerializer): +class PartParameterSerializer(InvenTree.serializers.InvenTreeModelSerializer): """JSON serializers for the PartParameter model.""" class Meta: @@ -260,7 +250,7 @@ class PartParameterSerializer(InvenTreeModelSerializer): template_detail = PartParameterTemplateSerializer(source='template', many=False, read_only=True) -class PartBriefSerializer(InvenTreeModelSerializer): +class PartBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer): """Serializer for Part (brief detail)""" class Meta: @@ -295,8 +285,8 @@ class PartBriefSerializer(InvenTreeModelSerializer): thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True) # Pricing fields - pricing_min = InvenTreeMoneySerializer(source='pricing_data.overall_min', allow_null=True, read_only=True) - pricing_max = InvenTreeMoneySerializer(source='pricing_data.overall_max', allow_null=True, read_only=True) + pricing_min = InvenTree.serializers.InvenTreeMoneySerializer(source='pricing_data.overall_min', allow_null=True, read_only=True) + pricing_max = InvenTree.serializers.InvenTreeMoneySerializer(source='pricing_data.overall_max', allow_null=True, read_only=True) class DuplicatePartSerializer(serializers.Serializer): @@ -406,7 +396,7 @@ class InitialSupplierSerializer(serializers.Serializer): return data -class PartSerializer(RemoteImageMixin, InvenTreeTagModelSerializer): +class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serializers.InvenTreeTagModelSerializer): """Serializer for complete detail information of a part. Used when displaying all details of a single component. @@ -607,7 +597,7 @@ class PartSerializer(RemoteImageMixin, InvenTreeTagModelSerializer): stock_item_count = serializers.IntegerField(read_only=True) suppliers = serializers.IntegerField(read_only=True) - image = InvenTreeImageSerializerField(required=False, allow_null=True) + image = InvenTree.serializers.InvenTreeImageSerializerField(required=False, allow_null=True) thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True) starred = serializers.SerializerMethodField() @@ -615,8 +605,8 @@ class PartSerializer(RemoteImageMixin, InvenTreeTagModelSerializer): category = serializers.PrimaryKeyRelatedField(queryset=PartCategory.objects.all()) # Pricing fields - pricing_min = InvenTreeMoneySerializer(source='pricing_data.overall_min', allow_null=True, read_only=True) - pricing_max = InvenTreeMoneySerializer(source='pricing_data.overall_max', allow_null=True, read_only=True) + pricing_min = InvenTree.serializers.InvenTreeMoneySerializer(source='pricing_data.overall_min', allow_null=True, read_only=True) + pricing_max = InvenTree.serializers.InvenTreeMoneySerializer(source='pricing_data.overall_max', allow_null=True, read_only=True) parameters = PartParameterSerializer( many=True, @@ -771,7 +761,7 @@ class PartSerializer(RemoteImageMixin, InvenTreeTagModelSerializer): return self.instance -class PartStocktakeSerializer(InvenTreeModelSerializer): +class PartStocktakeSerializer(InvenTree.serializers.InvenTreeModelSerializer): """Serializer for the PartStocktake model""" class Meta: @@ -800,13 +790,13 @@ class PartStocktakeSerializer(InvenTreeModelSerializer): quantity = serializers.FloatField() - user_detail = UserSerializer(source='user', read_only=True, many=False) + user_detail = InvenTree.serializers.UserSerializer(source='user', read_only=True, many=False) - cost_min = InvenTreeMoneySerializer(allow_null=True) - cost_min_currency = InvenTreeCurrencySerializer() + cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True) + cost_min_currency = InvenTree.serializers.InvenTreeCurrencySerializer() - cost_max = InvenTreeMoneySerializer(allow_null=True) - cost_max_currency = InvenTreeCurrencySerializer() + cost_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True) + cost_max_currency = InvenTree.serializers.InvenTreeCurrencySerializer() def save(self): """Called when this serializer is saved""" @@ -820,7 +810,7 @@ class PartStocktakeSerializer(InvenTreeModelSerializer): super().save() -class PartStocktakeReportSerializer(InvenTreeModelSerializer): +class PartStocktakeReportSerializer(InvenTree.serializers.InvenTreeModelSerializer): """Serializer for stocktake report class""" class Meta: @@ -836,9 +826,9 @@ class PartStocktakeReportSerializer(InvenTreeModelSerializer): 'user_detail', ] - user_detail = UserSerializer(source='user', read_only=True, many=False) + user_detail = InvenTree.serializers.UserSerializer(source='user', read_only=True, many=False) - report = InvenTreeAttachmentSerializerField(read_only=True) + report = InvenTree.serializers.InvenTreeAttachmentSerializerField(read_only=True) class PartStocktakeReportGenerateSerializer(serializers.Serializer): @@ -906,7 +896,7 @@ class PartStocktakeReportGenerateSerializer(serializers.Serializer): ) -class PartPricingSerializer(InvenTreeModelSerializer): +class PartPricingSerializer(InvenTree.serializers.InvenTreeModelSerializer): """Serializer for Part pricing information""" class Meta: @@ -942,29 +932,29 @@ class PartPricingSerializer(InvenTreeModelSerializer): scheduled_for_update = serializers.BooleanField(read_only=True) # Custom serializers - bom_cost_min = InvenTreeMoneySerializer(allow_null=True, read_only=True) - bom_cost_max = InvenTreeMoneySerializer(allow_null=True, read_only=True) + bom_cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True) + bom_cost_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True) - purchase_cost_min = InvenTreeMoneySerializer(allow_null=True, read_only=True) - purchase_cost_max = InvenTreeMoneySerializer(allow_null=True, read_only=True) + purchase_cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True) + purchase_cost_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True) - internal_cost_min = InvenTreeMoneySerializer(allow_null=True, read_only=True) - internal_cost_max = InvenTreeMoneySerializer(allow_null=True, read_only=True) + internal_cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True) + internal_cost_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True) - supplier_price_min = InvenTreeMoneySerializer(allow_null=True, read_only=True) - supplier_price_max = InvenTreeMoneySerializer(allow_null=True, read_only=True) + supplier_price_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True) + supplier_price_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True) - variant_cost_min = InvenTreeMoneySerializer(allow_null=True, read_only=True) - variant_cost_max = InvenTreeMoneySerializer(allow_null=True, read_only=True) + variant_cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True) + variant_cost_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True) - overall_min = InvenTreeMoneySerializer(allow_null=True, read_only=True) - overall_max = InvenTreeMoneySerializer(allow_null=True, read_only=True) + overall_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True) + overall_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True) - sale_price_min = InvenTreeMoneySerializer(allow_null=True, read_only=True) - sale_price_max = InvenTreeMoneySerializer(allow_null=True, read_only=True) + sale_price_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True) + sale_price_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True) - sale_history_min = InvenTreeMoneySerializer(allow_null=True, read_only=True) - sale_history_max = InvenTreeMoneySerializer(allow_null=True, read_only=True) + sale_history_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True) + sale_history_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True) update = serializers.BooleanField( write_only=True, @@ -984,7 +974,7 @@ class PartPricingSerializer(InvenTreeModelSerializer): pricing.update_pricing() -class PartRelationSerializer(InvenTreeModelSerializer): +class PartRelationSerializer(InvenTree.serializers.InvenTreeModelSerializer): """Serializer for a PartRelated model.""" class Meta: @@ -1002,7 +992,7 @@ class PartRelationSerializer(InvenTreeModelSerializer): part_2_detail = PartSerializer(source='part_2', read_only=True, many=False) -class PartStarSerializer(InvenTreeModelSerializer): +class PartStarSerializer(InvenTree.serializers.InvenTreeModelSerializer): """Serializer for a PartStar object.""" class Meta: @@ -1020,7 +1010,7 @@ class PartStarSerializer(InvenTreeModelSerializer): username = serializers.CharField(source='user.username', read_only=True) -class BomItemSubstituteSerializer(InvenTreeModelSerializer): +class BomItemSubstituteSerializer(InvenTree.serializers.InvenTreeModelSerializer): """Serializer for the BomItemSubstitute class.""" class Meta: @@ -1036,7 +1026,7 @@ class BomItemSubstituteSerializer(InvenTreeModelSerializer): part_detail = PartBriefSerializer(source='part', read_only=True, many=False) -class BomItemSerializer(InvenTreeModelSerializer): +class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer): """Serializer for BomItem object.""" class Meta: @@ -1087,7 +1077,7 @@ class BomItemSerializer(InvenTreeModelSerializer): if sub_part_detail is not True: self.fields.pop('sub_part_detail') - quantity = InvenTreeDecimalField(required=True) + quantity = InvenTree.serializers.InvenTreeDecimalField(required=True) def validate_quantity(self, quantity): """Perform validation for the BomItem quantity field""" @@ -1109,8 +1099,8 @@ class BomItemSerializer(InvenTreeModelSerializer): on_order = serializers.FloatField(read_only=True) # Cached pricing fields - pricing_min = InvenTreeMoneySerializer(source='sub_part.pricing.overall_min', allow_null=True, read_only=True) - pricing_max = InvenTreeMoneySerializer(source='sub_part.pricing.overall_max', allow_null=True, read_only=True) + pricing_min = InvenTree.serializers.InvenTreeMoneySerializer(source='sub_part.pricing.overall_min', allow_null=True, read_only=True) + pricing_max = InvenTree.serializers.InvenTreeMoneySerializer(source='sub_part.pricing.overall_max', allow_null=True, read_only=True) # Annotated fields for available stock available_stock = serializers.FloatField(read_only=True) @@ -1212,7 +1202,7 @@ class BomItemSerializer(InvenTreeModelSerializer): return queryset -class CategoryParameterTemplateSerializer(InvenTreeModelSerializer): +class CategoryParameterTemplateSerializer(InvenTree.serializers.InvenTreeModelSerializer): """Serializer for the PartCategoryParameterTemplate model.""" class Meta: @@ -1297,7 +1287,7 @@ class PartCopyBOMSerializer(serializers.Serializer): ) -class BomImportUploadSerializer(DataFileUploadSerializer): +class BomImportUploadSerializer(InvenTree.serializers.DataFileUploadSerializer): """Serializer for uploading a file and extracting data from it.""" TARGET_MODEL = BomItem @@ -1333,7 +1323,7 @@ class BomImportUploadSerializer(DataFileUploadSerializer): part.bom_items.all().delete() -class BomImportExtractSerializer(DataFileExtractSerializer): +class BomImportExtractSerializer(InvenTree.serializers.DataFileExtractSerializer): """Serializer class for exatracting BOM data from an uploaded file. The parent class DataFileExtractSerializer does most of the heavy lifting here. diff --git a/InvenTree/part/tasks.py b/InvenTree/part/tasks.py index e089852906..69ec85485f 100644 --- a/InvenTree/part/tasks.py +++ b/InvenTree/part/tasks.py @@ -7,6 +7,7 @@ import time from datetime import datetime, timedelta from django.contrib.auth.models import User +from django.core.exceptions import ValidationError from django.core.files.base import ContentFile from django.utils.translation import gettext_lazy as _ @@ -18,6 +19,7 @@ from djmoney.money import Money import common.models import common.notifications import common.settings +import company.models import InvenTree.helpers import InvenTree.tasks import part.models @@ -433,7 +435,7 @@ def scheduled_stocktake_reports(): def rebuild_parameters(template_id): """Rebuild all parameters for a given template. - This method is called when a base template is changed, + This function is called when a base template is changed, which may cause the base unit to be adjusted. """ @@ -452,7 +454,35 @@ def rebuild_parameters(template_id): parameter.calculate_numeric_value() if value_old != parameter.data_numeric: + parameter.full_clean() parameter.save() n += 1 logger.info(f"Rebuilt {n} parameters for template '{template.name}'") + + +def rebuild_supplier_parts(part_id): + """Rebuild all SupplierPart objects for a given part. + + This function is called when a bart part is changed, + which may cause the native units of any supplier parts to be updated + """ + + try: + prt = part.models.Part.objects.get(pk=part_id) + except part.models.Part.DoesNotExist: + return + + supplier_parts = company.models.SupplierPart.objects.filter(part=prt) + + n = supplier_parts.count() + + for supplier_part in supplier_parts: + # Re-save the part, to ensure that the units have updated correctly + try: + supplier_part.full_clean() + supplier_part.save() + except ValidationError: + pass + + logger.info(f"Rebuilt {n} supplier parts for part '{prt.name}'") diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index e2bfbb99ec..3ee62f88ff 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -2144,7 +2144,7 @@ class PartAPIAggregationTest(InvenTreeAPITestCase): part=p, supplier=supplier, SKU=f"PNT-{color}-{pk_sz}L", - pack_size=pk_sz, + pack_quantity=str(pk_sz), ) self.assertEqual(p.supplier_parts.count(), 4) @@ -2206,7 +2206,7 @@ class PartAPIAggregationTest(InvenTreeAPITestCase): remaining = line_item.quantity - line_item.received if remaining > 0: - on_order += remaining * sp.pack_size + on_order += sp.base_quantity(remaining) # The annotated quantity must be equal to the hand-calculated quantity self.assertEqual(on_order, item['ordering']) diff --git a/InvenTree/part/test_migrations.py b/InvenTree/part/test_migrations.py index cf51d67bee..7f58c4f102 100644 --- a/InvenTree/part/test_migrations.py +++ b/InvenTree/part/test_migrations.py @@ -88,7 +88,7 @@ class TestParameterMigrations(MigratorTestCase): """Unit test for part parameter migrations""" migrate_from = ('part', '0106_part_tags') - migrate_to = ('part', '0109_auto_20230517_1048') + migrate_to = ('part', unit_test.getNewestMigrationFile('part')) def prepare(self): """Create some parts, and templates with parameters""" @@ -154,3 +154,38 @@ class TestParameterMigrations(MigratorTestCase): p4 = PartParameter.objects.get(part=b, template=t2) self.assertEqual(p4.data, 'abc') self.assertEqual(p4.data_numeric, None) + + +class PartUnitsMigrationTest(MigratorTestCase): + """Test for data migration of Part.units field""" + + migrate_from = ('part', '0109_auto_20230517_1048') + migrate_to = ('part', unit_test.getNewestMigrationFile('part')) + + def prepare(self): + """Prepare some parts with units""" + + Part = self.old_state.apps.get_model('part', 'part') + + units = ['mm', 'INCH', '', '%'] + + for idx, unit in enumerate(units): + Part.objects.create( + name=f'Part {idx + 1}', description=f'My part at index {idx}', units=unit, + level=0, lft=0, rght=0, tree_id=0, + ) + + def test_units_migration(self): + """Test that the units have migrated OK""" + + Part = self.new_state.apps.get_model('part', 'part') + + part_1 = Part.objects.get(name='Part 1') + part_2 = Part.objects.get(name='Part 2') + part_3 = Part.objects.get(name='Part 3') + part_4 = Part.objects.get(name='Part 4') + + self.assertEqual(part_1.units, 'mm') + self.assertEqual(part_2.units, 'inch') + self.assertEqual(part_3.units, '') + self.assertEqual(part_4.units, 'percent') diff --git a/InvenTree/part/test_pricing.py b/InvenTree/part/test_pricing.py index 8859acca47..9c62cba977 100644 --- a/InvenTree/part/test_pricing.py +++ b/InvenTree/part/test_pricing.py @@ -2,6 +2,7 @@ from django.core.exceptions import ObjectDoesNotExist +from djmoney.contrib.exchange.models import convert_money from djmoney.money import Money import common.models @@ -25,10 +26,13 @@ class PartPricingTests(InvenTreeTestCase): self.generate_exchange_rates() # Create a new part for performing pricing calculations + # We will use 'metres' for the UOM here + # Some SupplierPart instances will have different units! self.part = part.models.Part.objects.create( name='PP', - description='A part with pricing', - assembly=True + description='A part with pricing, measured in metres', + assembly=True, + units='m' ) def create_price_breaks(self): @@ -44,8 +48,12 @@ class PartPricingTests(InvenTreeTestCase): supplier=self.supplier_1, part=self.part, SKU='SUP_1', + pack_quantity='200 cm', ) + # Native pack quantity should be 2m + self.assertEqual(self.sp_1.pack_quantity_native, 2) + company.models.SupplierPriceBreak.objects.create( part=self.sp_1, quantity=1, @@ -63,16 +71,22 @@ class PartPricingTests(InvenTreeTestCase): supplier=self.supplier_2, part=self.part, SKU='SUP_2', - pack_size=2.5, + pack_quantity='2.5', ) + # Native pack quantity should be 2.5m + self.assertEqual(self.sp_2.pack_quantity_native, 2.5) + self.sp_3 = company.models.SupplierPart.objects.create( supplier=self.supplier_2, part=self.part, SKU='SUP_3', - pack_size=10 + pack_quantity='10 inches', ) + # Native pack quantity should be 0.254m + self.assertEqual(self.sp_3.pack_quantity_native, 0.254) + company.models.SupplierPriceBreak.objects.create( part=self.sp_2, quantity=5, @@ -162,8 +176,8 @@ class PartPricingTests(InvenTreeTestCase): pricing.update_pricing() - self.assertEqual(pricing.overall_min, Money('2.014667', 'USD')) - self.assertEqual(pricing.overall_max, Money('6.117647', 'USD')) + self.assertAlmostEqual(float(pricing.overall_min.amount), 2.015, places=2) + self.assertAlmostEqual(float(pricing.overall_max.amount), 3.06, places=2) # Delete all supplier parts and re-calculate self.part.supplier_parts.all().delete() @@ -319,11 +333,11 @@ class PartPricingTests(InvenTreeTestCase): # Add some line items to the order - # $5 AUD each + # $5 AUD each @ 2.5m per unit = $2 AUD per metre line_1 = po.add_line_item(self.sp_2, quantity=10, purchase_price=Money(5, 'AUD')) - # $30 CAD each (but pack_size is 10, so really $3 CAD each) - line_2 = po.add_line_item(self.sp_3, quantity=5, purchase_price=Money(30, 'CAD')) + # $3 CAD each @ 10 inches per unit = $0.3 CAD per inch = $11.81 CAD per metre + line_2 = po.add_line_item(self.sp_3, quantity=5, purchase_price=Money(3, 'CAD')) pricing.update_purchase_cost() @@ -349,8 +363,20 @@ class PartPricingTests(InvenTreeTestCase): pricing.update_purchase_cost() - self.assertEqual(pricing.purchase_cost_min, Money('1.333333', 'USD')) - self.assertEqual(pricing.purchase_cost_max, Money('1.764706', 'USD')) + min_cost_aud = convert_money(pricing.purchase_cost_min, 'AUD') + max_cost_cad = convert_money(pricing.purchase_cost_max, 'CAD') + + # Min cost in AUD = $2 AUD per metre + self.assertAlmostEqual(float(min_cost_aud.amount), 2, places=2) + + # Min cost in USD + self.assertAlmostEqual(float(pricing.purchase_cost_min.amount), 1.3333, places=2) + + # Max cost in CAD = $11.81 CAD per metre + self.assertAlmostEqual(float(max_cost_cad.amount), 11.81, places=2) + + # Max cost in USD + self.assertAlmostEqual(float(pricing.purchase_cost_max.amount), 6.95, places=2) def test_delete_with_pricing(self): """Test for deleting a part which has pricing information""" diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 803ccb979f..c5a8cb838c 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -613,7 +613,7 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView): 'supplier_part': _('The given supplier part does not exist'), }) - if supplier_part.pack_size != 1: + if supplier_part.base_quantity() != 1: # Skip this check if pack size is 1 - makes no difference # use_pack_size = True -> Multiply quantity by pack size # use_pack_size = False -> Use quantity as is @@ -623,10 +623,9 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView): }) else: if bool(data.get('use_pack_size')): - data['quantity'] = int(quantity) * float(supplier_part.pack_size) - quantity = data.get('quantity', None) + quantity = data['quantity'] = supplier_part.base_quantity(quantity) # Divide purchase price by pack size, to save correct price per stock item - data['purchase_price'] = float(data['purchase_price']) / float(supplier_part.pack_size) + data['purchase_price'] = float(data['purchase_price']) / float(supplier_part.pack_quantity_native) # Now remove the flag from data, so that it doesn't interfere with saving # Do this regardless of results above diff --git a/InvenTree/stock/migrations/0094_auto_20230220_0025.py b/InvenTree/stock/migrations/0094_auto_20230220_0025.py index 9d4de5a438..79c08f0953 100644 --- a/InvenTree/stock/migrations/0094_auto_20230220_0025.py +++ b/InvenTree/stock/migrations/0094_auto_20230220_0025.py @@ -2,6 +2,7 @@ import logging +from django.core.exceptions import FieldError from django.db import migrations logger = logging.getLogger('inventree') @@ -38,10 +39,13 @@ def fix_purchase_price(apps, schema_editor): supplier_part=None ).exclude( purchase_price=None - ).exclude( - supplier_part__pack_size=1 ) + try: + items = items.exclude(supplier_part__pack_size=1) + except FieldError: + pass + n_updated = 0 for item in items: diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index 38fb8f912b..886678d5be 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -698,6 +698,7 @@ class StockItemTest(StockAPITestCase): }, expected_code=201 ) + # Reload part, count stock again part_4 = part.models.Part.objects.get(pk=4) self.assertEqual(part_4.available_stock, current_count + 3) diff --git a/InvenTree/templates/js/translated/company.js b/InvenTree/templates/js/translated/company.js index 53180d26c7..d187d582e4 100644 --- a/InvenTree/templates/js/translated/company.js +++ b/InvenTree/templates/js/translated/company.js @@ -138,7 +138,7 @@ function supplierPartFields(options={}) { packaging: { icon: 'fa-box', }, - pack_size: {}, + pack_quantity: {}, }; if (options.part) { @@ -1242,17 +1242,24 @@ function loadSupplierPartTable(table, url, options) { sortable: true, }, { - field: 'pack_size', + field: 'pack_quantity', title: '{% trans "Pack Quantity" %}', sortable: true, formatter: function(value, row) { - var output = `${value}`; - if (row.part_detail && row.part_detail.units) { - output += ` ${row.part_detail.units}`; + let html = ''; + + if (value) { + html = value; + } else { + html = '-'; } - return output; + if (row.part_detail && row.part_detail.units) { + html += ``; + } + + return html; } }, { diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index 9c629caf62..ca828f70bc 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -1588,13 +1588,12 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) { formatter: function(value, row) { let data = value; - if (row.supplier_part_detail.pack_size != 1.0) { - let pack_size = row.supplier_part_detail.pack_size; - let total = value * pack_size; + if (row.supplier_part_detail.pack_quantity_native != 1.0) { + let total = value * row.supplier_part_detail.pack_quantity_native; data += makeIconBadge( 'fa-info-circle icon-blue', - `{% trans "Pack Quantity" %}: ${pack_size} - {% trans "Total Quantity" %}: ${total}` + `{% trans "Pack Quantity" %}: ${formatDecimal(row.pack_quantity)} - {% trans "Total Quantity" %}: ${total}` ); } @@ -1647,10 +1646,9 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) { formatter: function(value, row) { var data = value; - if (value > 0 && row.supplier_part_detail.pack_size != 1.0) { - var pack_size = row.supplier_part_detail.pack_size; - var total = value * pack_size; - data += ``; + if (value > 0 && row.supplier_part_detail.pack_quantity_native != 1.0) { + let total = value * row.supplier_part_detail.pack_quantity_native; + data += ``; } return data; @@ -2038,6 +2036,12 @@ function loadPartTable(table, url, options={}) { } }); + columns.push({ + field: 'units', + title: '{% trans "Units" %}', + sortable: true, + }); + columns.push({ sortName: 'category', field: 'category_detail', diff --git a/InvenTree/templates/js/translated/pricing.js b/InvenTree/templates/js/translated/pricing.js index 61be8796e2..37bec92137 100644 --- a/InvenTree/templates/js/translated/pricing.js +++ b/InvenTree/templates/js/translated/pricing.js @@ -458,7 +458,7 @@ function loadPartSupplierPricingTable(options={}) { data = data.sort((a, b) => (a.quantity - b.quantity)); var graphLabels = Array.from(data, (x) => (`${x.part_detail.SKU} - {% trans "Quantity" %} ${x.quantity}`)); - var graphValues = Array.from(data, (x) => (x.price / x.part_detail.pack_size)); + var graphValues = Array.from(data, (x) => (x.price / x.part_detail.pack_quantity_native)); if (chart) { chart.destroy(); @@ -518,7 +518,7 @@ function loadPartSupplierPricingTable(options={}) { } // Convert to unit pricing - var unit_price = row.price / row.part_detail.pack_size; + var unit_price = row.price / row.part_detail.pack_quantity_native; var html = formatCurrency(unit_price, { currency: row.price_currency @@ -811,9 +811,12 @@ function loadPurchasePriceHistoryTable(options={}) { return '-'; } - return formatCurrency(row.purchase_price / row.supplier_part_detail.pack_size, { - currency: row.purchase_price_currency - }); + return formatCurrency( + row.purchase_price / row.supplier_part_detail.pack_quantity_native, + { + currency: row.purchase_price_currency + } + ); } }, ] diff --git a/InvenTree/templates/js/translated/purchase_order.js b/InvenTree/templates/js/translated/purchase_order.js index 51967b32c2..868ed657cd 100644 --- a/InvenTree/templates/js/translated/purchase_order.js +++ b/InvenTree/templates/js/translated/purchase_order.js @@ -229,8 +229,8 @@ function poLineItemFields(options={}) { supplier: options.supplier, }, onEdit: function(value, name, field, opts) { - // If the pack_size != 1, add a note to the field - var pack_size = 1; + // If the pack_quantity != 1, add a note to the field + var pack_quantity = 1; var units = ''; var supplier_part_id = value; var quantity = getFormFieldValue('quantity', {}, opts); @@ -250,14 +250,14 @@ function poLineItemFields(options={}) { { success: function(response) { // Extract information from the returned query - pack_size = response.pack_size || 1; + pack_quantity = response.pack_quantity_native || 1; units = response.part_detail.units || ''; }, } ).then(function() { // Update pack size information - if (pack_size != 1) { - var txt = ` {% trans "Pack Quantity" %}: ${pack_size} ${units}`; + if (pack_quantity != 1) { + var txt = ` {% trans "Pack Quantity" %}: ${formatDecimal(pack_quantity)} ${units}`; $(opts.modal).find('#hint_id_quantity').after(`
${txt}
`); } }).then(function() { @@ -766,7 +766,7 @@ function orderParts(parts_list, options) { // Callback function when supplier part is changed // This is used to update the "pack size" attribute var onSupplierPartChanged = function(value, name, field, opts) { - var pack_size = 1; + var pack_quantity = 1; var units = ''; $(opts.modal).find(`#info-pack-size-${pk}`).remove(); @@ -779,13 +779,13 @@ function orderParts(parts_list, options) { }, { success: function(response) { - pack_size = response.pack_size || 1; + pack_quantity = response.pack_quantity_native || 1; units = response.part_detail.units || ''; } } ).then(function() { - if (pack_size != 1) { - var txt = ` {% trans "Pack Quantity" %}: ${pack_size} ${units}`; + if (pack_quantity != 1) { + var txt = ` {% trans "Pack Quantity" %}: ${pack_quantity} ${units}`; $(opts.modal).find(`#id_quantity_${pk}`).after(`
${txt}
`); } }); @@ -1021,15 +1021,17 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { } var units = line_item.part_detail.units || ''; - var pack_size = line_item.supplier_part_detail.pack_size || 1; - var pack_size_div = ''; + let pack_quantity = line_item.supplier_part_detail.pack_quantity; + let native_pack_quantity = line_item.supplier_part_detail.pack_quantity_native || 1; - var received = quantity * pack_size; + let pack_size_div = ''; - if (pack_size != 1) { + var received = quantity * native_pack_quantity; + + if (native_pack_quantity != 1) { pack_size_div = `
- {% trans "Pack Quantity" %}: ${pack_size} ${units}
+ {% trans "Pack Quantity" %}: ${formatDecimal(pack_quantity)}
{% trans "Received Quantity" %}: ${received} ${units}
`; } @@ -1304,13 +1306,13 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { ); // Add change callback for quantity field - if (item.supplier_part_detail.pack_size != 1) { + if (item.supplier_part_detail.pack_quantity_native != 1) { $(opts.modal).find(`#id_items_quantity_${pk}`).change(function() { var value = $(opts.modal).find(`#id_items_quantity_${pk}`).val(); var el = $(opts.modal).find(`#quantity_${pk}`).find('.pack_received_quantity'); - var actual = value * item.supplier_part_detail.pack_size; + var actual = value * item.supplier_part_detail.pack_quantity_native; actual = formatDecimal(actual); el.text(actual); }); @@ -2005,10 +2007,10 @@ function loadPurchaseOrderLineItemTable(table, options={}) { let data = value; - if (row.supplier_part_detail && row.supplier_part_detail.pack_size != 1.0) { - var pack_size = row.supplier_part_detail.pack_size; - var total = value * pack_size; - data += ``; + if (row.supplier_part_detail && row.supplier_part_detail.pack_quantity_native != 1.0) { + let pack_quantity = row.supplier_part_detail.pack_quantity; + let total = value * row.supplier_part_detail.pack_quantity_native; + data += ``; } return data; @@ -2024,7 +2026,7 @@ function loadPurchaseOrderLineItemTable(table, options={}) { { sortable: false, switchable: true, - field: 'supplier_part_detail.pack_size', + field: 'supplier_part_detail.pack_quantity', title: '{% trans "Pack Quantity" %}', formatter: function(value, row) { var units = row.part_detail.units; diff --git a/InvenTree/templates/js/translated/table_filters.js b/InvenTree/templates/js/translated/table_filters.js index f9b925bd0f..b2567743c9 100644 --- a/InvenTree/templates/js/translated/table_filters.js +++ b/InvenTree/templates/js/translated/table_filters.js @@ -626,6 +626,11 @@ function getPartTableFilters() { type: 'bool', title: '{% trans "Component" %}', }, + has_units: { + type: 'bool', + title: '{% trans "Has Units" %}', + description: '{% trans "Part has defined units" %}', + }, has_ipn: { type: 'bool', title: '{% trans "Has IPN" %}', diff --git a/docs/docs/assets/images/part/part_units.png b/docs/docs/assets/images/part/part_units.png new file mode 100644 index 0000000000..4e952e8004 Binary files /dev/null and b/docs/docs/assets/images/part/part_units.png differ diff --git a/docs/docs/assets/images/part/part_units_invalid.png b/docs/docs/assets/images/part/part_units_invalid.png new file mode 100644 index 0000000000..e7a6f83e60 Binary files /dev/null and b/docs/docs/assets/images/part/part_units_invalid.png differ diff --git a/docs/docs/part/part.md b/docs/docs/part/part.md index d8a4a2f982..2ef5a80fd0 100644 --- a/docs/docs/part/part.md +++ b/docs/docs/part/part.md @@ -55,7 +55,19 @@ Trackable parts can be assigned batch numbers or serial numbers which uniquely i ### Purchaseable -If a part is designated as *Purchaseable* it can be purchased from external suppliers. Setting this flag allows parts to be added to [purchase orders](../order/purchase_order.md). +If a part is designated as *Purchaseable* it can be purchased from external suppliers. Setting this flag allows parts be linked to supplier parts and procured via purchase orders. + +#### Suppliers + +A [Supplier](../order/company.md#suppliers) is an external vendor who provides goods or services. + +#### Supplier Parts + +Purchaseable parts can be linked to [Supplier Parts](../order/company.md#supplier-parts). A supplier part represents an individual piece or unit that is procured from an external vendor. + +#### Purchase Orders + +A [Purchase Order](../order/purchase_order.md) allows parts to be ordered from an external supplier. ### Salable @@ -65,6 +77,31 @@ If a part is designated as *Salable* it can be sold to external customers. Setti By default, all parts are *Active*. Marking a part as inactive means it is not available for many actions, but the part remains in the database. If a part becomes obsolete, it is recommended that it is marked as inactive, rather than deleting it from the database. +## Units of Measure + +Each type of part can define a custom "unit of measure" which is a standardized unit which is used to track quantities for a particular part. By default, the "unit of measure" for each part is blank, which means that each part is tracked in dimensionless quantities of "pieces". + +### Physical Units + +It is possible to track parts using physical quantity values, such as *metres* or *litres*. For example, it would make sense to track a "wire" in units of "metres": + +{% with id="part_units", url="part/part_units.png", description="Parts units" %} +{% include 'img.html' %} +{% endwith %} + +### Supplier Part Units + +By default, units of measure for [supplier parts](../order/company.md#supplier-parts) are specified in the same unit as their base part. However, supplier part units can be changed to any unit *which is compatible with the base unit*. + +!!! info "Example: Supplier Part Units" + If the base part has a unit of `metres` then valid units for any supplier parts would include `feet`, `cm`, `inches` (etc) + +If an incompatible unit type is specified, an error will be displayed: + +{% with id="part_units_invalid", url="part/part_units_invalid.png", description="Invalid supplier part units" %} +{% include 'img.html' %} +{% endwith %} + ## Part Images Each part can have an associated image, which is used for display purposes throughout the InvenTree interface. A prominent example is on the part detail page itself: