Part units (#4854)

* Add validation to part units field

* Add "pack_units" field to the SupplierPart model

* Migrate old units to new units, and remove old field

* Table fix

* Fixture fix

* Update migration

* Improve "hook" for loading custom unit database

* Display part units column in part table

- Also allow ordering by part units
- Allow filtering to show parts which have defined units

* Adds data migration for converting units to valid values

* Add "pack_units_native" field to company.SupplierPart model

* Clean pack units when saving a SupplierPart

- Convert to native part units
- Handle empty units value
- Add unit tests

* Add background function to rebuild supplier parts when a part is saved

- Required to ensure that the "pack_size_native" is up to date

* Template updates

* Sort by native units first

* Bump API version

* Rename "pack_units" to "pack_quantity"

* Update migration file

- Allow reverse migration

* Fix for currency migration

- Handle case where no currencies are provided
- Handle case where base currency is not in provided options

* Adds unit test for data migration

* Add unit test for part.units data migration

- Check that units fields are updated correctly

* Add some extra "default units"

- each / piece
- dozen / hundred / thousand
- Add unit testing also

* Update references to "pack_size"

- Replace with "pack_quantity" or "pack_quantity_native" as appropriate

* Improvements based on unit testing

* catch error

* Docs updates

* Fixes for pricing tests

* Update unit tests for part migrations · 1b6b6d9d

* Bug fix for conversion code

* javascript updates

* JS formatting fix
This commit is contained in:
Oliver 2023-05-26 16:57:23 +10:00 committed by GitHub
parent 717bb07dcf
commit 5dd6f18495
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 878 additions and 251 deletions

View File

@ -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

View File

@ -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()

View File

@ -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')

View File

@ -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."""

View File

@ -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 = [

View File

@ -66,4 +66,5 @@
part: 4
supplier: 2
SKU: 'R_4K7_0603.100PCK'
pack_size: 100
pack_quantity: '100'
pack_quantity_native: 100

View File

@ -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),
),
]

View File

@ -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,
)
]

View File

@ -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',
),
]

View File

@ -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).

View File

@ -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)

View File

@ -162,11 +162,24 @@ src="{% static 'img/blank_image.png' %}"
<td>{{ part.packaging }}{% include "clip.html" %}</td>
</tr>
{% endif %}
{% if part.pack_size != 1.0 %}
{% if part.pack_quantity %}
<tr>
<td><span class='fas fa-box'></span></td>
<td>{% trans "Pack Quantity" %}</td>
<td>{% decimal part.pack_size %} {% include "part/part_units.html" with part=part.part %}</td>
<td>
{% trans "Units" %}
{% if part.part.units %}
<span class='float-right'>
<em>[ {% include "part/part_units.html" with part=part.part %}]</em>
</span>
{% endif %}
</td>
<td>
{{ part.pack_quantity }}
{% include "clip.html" %}
{% if part.part.units and part.pack_quantity_native %}
<span class='fas fa-info-circle float-right' title='{% decimal part.pack_quantity_native %} {{ part.part.units }}'></span>
{% endif %}
</td>
</tr>
{% endif %}
{% if part.note %}

View File

@ -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

View File

@ -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()

View File

@ -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()

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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")

View File

@ -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'),
),
]

View File

@ -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)
]

View File

@ -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,

View File

@ -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.

View File

@ -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}'")

View File

@ -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'])

View File

@ -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')

View File

@ -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"""

View File

@ -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

View File

@ -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:

View File

@ -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)

View File

@ -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 += `<span class='fas fa-info-circle float-right' title='{% trans "Base Units" %}: ${row.part_detail.units}'></span>`;
}
return html;
}
},
{

View File

@ -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 += `<span class='fas fa-info-circle icon-blue float-right' title='{% trans "Pack Quantity" %}: ${pack_size} - {% trans "Total Quantity" %}: ${total}'></span>`;
if (value > 0 && row.supplier_part_detail.pack_quantity_native != 1.0) {
let total = value * row.supplier_part_detail.pack_quantity_native;
data += `<span class='fas fa-info-circle icon-blue float-right' title='{% trans "Pack Quantity" %}: ${row.pack_quantity} - {% trans "Total Quantity" %}: ${total}'></span>`;
}
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',

View File

@ -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
}
);
}
},
]

View File

@ -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 = `<span class='fas fa-info-circle icon-blue'></span> {% trans "Pack Quantity" %}: ${pack_size} ${units}`;
if (pack_quantity != 1) {
var txt = `<span class='fas fa-info-circle icon-blue'></span> {% trans "Pack Quantity" %}: ${formatDecimal(pack_quantity)} ${units}`;
$(opts.modal).find('#hint_id_quantity').after(`<div class='form-info-message' id='info-pack-size'>${txt}</div>`);
}
}).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 = `<span class='fas fa-info-circle icon-blue'></span> {% trans "Pack Quantity" %}: ${pack_size} ${units}`;
if (pack_quantity != 1) {
var txt = `<span class='fas fa-info-circle icon-blue'></span> {% trans "Pack Quantity" %}: ${pack_quantity} ${units}`;
$(opts.modal).find(`#id_quantity_${pk}`).after(`<div class='form-info-message' id='info-pack-size-${pk}'>${txt}</div>`);
}
});
@ -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 = `
<div class='alert alert-small alert-block alert-info'>
{% trans "Pack Quantity" %}: ${pack_size} ${units}<br>
{% trans "Pack Quantity" %}: ${formatDecimal(pack_quantity)}<br>
{% trans "Received Quantity" %}: <span class='pack_received_quantity' id='items_received_quantity_${pk}'>${received}</span> ${units}
</div>`;
}
@ -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 += `<span class='fas fa-info-circle icon-blue float-right' title='{% trans "Pack Quantity" %}: ${pack_size}${units} - {% trans "Total Quantity" %}: ${total}${units}'></span>`;
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 += `<span class='fas fa-info-circle icon-blue float-right' title='{% trans "Pack Quantity" %}: ${pack_quantity} - {% trans "Total Quantity" %}: ${total}${units}'></span>`;
}
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;

View File

@ -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" %}',

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -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: