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: