2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-29 12:06:44 +00:00

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
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 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 v116 -> 2023-05-18 : https://github.com/inventree/InvenTree/pull/4823
- Updates to part parameter implementation, to use physical units - 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 import transaction
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
import InvenTree.conversion
import InvenTree.tasks import InvenTree.tasks
from InvenTree.config import get_setting from InvenTree.config import get_setting
from InvenTree.ready import canAppAccessDatabase, isInTestMode from InvenTree.ready import canAppAccessDatabase, isInTestMode
@ -46,6 +47,9 @@ class InvenTreeConfig(AppConfig):
self.collect_notification_methods() self.collect_notification_methods()
# Ensure the unit registry is loaded
InvenTree.conversion.reload_unit_registry()
if canAppAccessDatabase() or settings.TESTING_ENV: if canAppAccessDatabase() or settings.TESTING_ENV:
self.add_user_on_startup() self.add_user_on_startup()

View File

@ -15,13 +15,31 @@ def get_unit_registry():
# Cache the unit registry for speedier access # Cache the unit registry for speedier access
if _unit_registry is None: if _unit_registry is None:
_unit_registry = pint.UnitRegistry() reload_unit_registry()
# TODO: Allow for custom units to be defined in the database
return _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): def convert_physical_value(value: str, unit: str = None):
"""Validate that the provided value is a valid physical quantity. """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 unit: Optional unit to convert to, and validate against
Raises: Raises:
ValidationError: If the value is invalid ValidationError: If the value is invalid or cannot be converted to the specified unit
Returns: Returns:
The converted quantity, in the specified units 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 # At this point we *should* have a valid pint value
# To double check, look at the maginitude # To double check, look at the maginitude
float(val.magnitude) float(val.magnitude)
except ValueError: except (TypeError, ValueError):
error = _('Provided value is not a valid number') error = _('Provided value is not a valid number')
except pint.errors.UndefinedUnitError: except (pint.errors.UndefinedUnitError, pint.errors.DefinitionSyntaxError):
error = _('Provided value has an invalid unit')
except pint.errors.DefinitionSyntaxError:
error = _('Provided value has an invalid unit') error = _('Provided value has an invalid unit')
except pint.errors.DimensionalityError: except pint.errors.DimensionalityError:
error = _('Provided value could not be converted to the specified unit') 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.contrib.exchange.models import Rate, convert_money
from djmoney.money import Money from djmoney.money import Money
import InvenTree.conversion
import InvenTree.format import InvenTree.format
import InvenTree.helpers import InvenTree.helpers
import InvenTree.tasks import InvenTree.tasks
@ -33,6 +34,28 @@ from .tasks import offload_task
from .validators import validate_overage 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): class ValidatorTest(TestCase):
"""Simple tests for custom field validators.""" """Simple tests for custom field validators."""

View File

@ -390,7 +390,7 @@ class SupplierPartList(ListCreateDestroyAPIView):
'manufacturer', 'manufacturer',
'MPN', 'MPN',
'packaging', 'packaging',
'pack_size', 'pack_quantity',
'in_stock', 'in_stock',
'updated', 'updated',
] ]
@ -400,6 +400,7 @@ class SupplierPartList(ListCreateDestroyAPIView):
'supplier': 'supplier__name', 'supplier': 'supplier__name',
'manufacturer': 'manufacturer_part__manufacturer__name', 'manufacturer': 'manufacturer_part__manufacturer__name',
'MPN': 'manufacturer_part__MPN', 'MPN': 'manufacturer_part__MPN',
'pack_quantity': ['pack_quantity_native', 'pack_quantity'],
} }
search_fields = [ search_fields = [

View File

@ -66,4 +66,5 @@
part: 4 part: 4
supplier: 2 supplier: 2
SKU: 'R_4K7_0603.100PCK' 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 import os
from datetime import datetime from datetime import datetime
from decimal import Decimal
from django.apps import apps from django.apps import apps
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@ -19,6 +20,7 @@ from taggit.managers import TaggableManager
import common.models import common.models
import common.settings import common.settings
import InvenTree.conversion
import InvenTree.fields import InvenTree.fields
import InvenTree.helpers import InvenTree.helpers
import InvenTree.ready import InvenTree.ready
@ -436,7 +438,8 @@ class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin
multiple: Multiple that the part is provided in multiple: Multiple that the part is provided in
lead_time: Supplier lead time lead_time: Supplier lead time
packaging: packaging that the part is supplied in, e.g. "Reel" 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 updated: Date that the SupplierPart was last updated
""" """
@ -475,6 +478,40 @@ class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin
""" """
super().clean() 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! # Ensure that the linked manufacturer_part points to the same part!
if self.manufacturer_part and self.part: if self.manufacturer_part and self.part:
@ -510,7 +547,8 @@ class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin
super().save(*args, **kwargs) super().save(*args, **kwargs)
part = models.ForeignKey('part.Part', on_delete=models.CASCADE, part = models.ForeignKey(
'part.Part', on_delete=models.CASCADE,
related_name='supplier_parts', related_name='supplier_parts',
verbose_name=_('Base Part'), verbose_name=_('Base Part'),
limit_choices_to={ limit_choices_to={
@ -519,7 +557,8 @@ class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin
help_text=_('Select part'), help_text=_('Select part'),
) )
supplier = models.ForeignKey(Company, on_delete=models.CASCADE, supplier = models.ForeignKey(
Company, on_delete=models.CASCADE,
related_name='supplied_parts', related_name='supplied_parts',
limit_choices_to={'is_supplier': True}, limit_choices_to={'is_supplier': True},
verbose_name=_('Supplier'), verbose_name=_('Supplier'),
@ -532,7 +571,8 @@ class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin
help_text=_('Supplier stock keeping unit') help_text=_('Supplier stock keeping unit')
) )
manufacturer_part = models.ForeignKey(ManufacturerPart, on_delete=models.CASCADE, manufacturer_part = models.ForeignKey(
ManufacturerPart, on_delete=models.CASCADE,
blank=True, null=True, blank=True, null=True,
related_name='supplier_parts', related_name='supplier_parts',
verbose_name=_('Manufacturer Part'), verbose_name=_('Manufacturer Part'),
@ -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')) 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'), verbose_name=_('Pack Quantity'),
help_text=_('Unit quantity supplied in a single pack'), help_text=_('Total quantity supplied in a single pack. Leave empty for single items.'),
default=1, blank=True,
max_digits=15, decimal_places=5,
validators=[MinValueValidator(0.001)],
) )
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')) 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). # TODO - Reimplement lead-time as a charfield with special validation (pattern matching).

View File

@ -265,7 +265,8 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer):
'pk', 'pk',
'barcode_hash', 'barcode_hash',
'packaging', 'packaging',
'pack_size', 'pack_quantity',
'pack_quantity_native',
'part', 'part',
'part_detail', 'part_detail',
'pretty_name', 'pretty_name',
@ -327,8 +328,6 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer):
pretty_name = serializers.CharField(read_only=True) pretty_name = serializers.CharField(read_only=True)
pack_size = serializers.FloatField(label=_('Pack Quantity'))
supplier = serializers.PrimaryKeyRelatedField(queryset=Company.objects.filter(is_supplier=True)) supplier = serializers.PrimaryKeyRelatedField(queryset=Company.objects.filter(is_supplier=True))
manufacturer = serializers.CharField(read_only=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> <td>{{ part.packaging }}{% include "clip.html" %}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if part.pack_size != 1.0 %} {% if part.pack_quantity %}
<tr> <tr>
<td><span class='fas fa-box'></span></td> <td><span class='fas fa-box'></span></td>
<td>{% trans "Pack Quantity" %}</td> <td>
<td>{% decimal part.pack_size %} {% include "part/part_units.html" with part=part.part %}</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> </tr>
{% endif %} {% endif %}
{% if part.note %} {% if part.note %}

View File

@ -277,3 +277,51 @@ class TestCurrencyMigration(MigratorTestCase):
for pb in PB.objects.all(): for pb in PB.objects.all():
# Test that a price has been assigned # Test that a price has been assigned
self.assertIsNotNone(pb.price) 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 # Create a new stock item
if line.part and quantity > 0: if line.part and quantity > 0:
# Take the 'pack_size' of the SupplierPart into account # Calculate received quantity in base units
pack_quantity = Decimal(quantity) * Decimal(line.part.pack_size) stock_quantity = line.part.base_quantity(quantity)
# Calculate unit purchase price (in base units)
if line.purchase_price: 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: else:
unit_purchase_price = None unit_purchase_price = None
@ -623,7 +625,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
part=line.part.part, part=line.part.part,
supplier_part=line.part, supplier_part=line.part,
location=location, location=location,
quantity=1 if serialize else pack_quantity, quantity=1 if serialize else stock_quantity,
purchase_order=self, purchase_order=self,
status=status, status=status,
batch=batch_code, batch=batch_code,
@ -656,7 +658,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
) )
# Update the number of parts received against the particular line item # 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.received += quantity
line.save() line.save()

View File

@ -558,14 +558,12 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
serial_numbers = data.get('serial_numbers', '').strip() serial_numbers = data.get('serial_numbers', '').strip()
base_part = line_item.part.part base_part = line_item.part.part
pack_size = line_item.part.pack_size base_quantity = line_item.part.base_quantity(quantity)
pack_quantity = pack_size * quantity
# Does the quantity need to be "integer" (for trackable parts?) # Does the quantity need to be "integer" (for trackable parts?)
if base_part.trackable: if base_part.trackable:
if Decimal(pack_quantity) != int(pack_quantity): if Decimal(base_quantity) != int(base_quantity):
raise ValidationError({ raise ValidationError({
'quantity': _('An integer quantity must be provided for trackable parts'), '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 # Pass the serial numbers through to the parent serializer once validated
data['serials'] = extract_serial_numbers( data['serials'] = extract_serial_numbers(
serial_numbers, serial_numbers,
pack_quantity, base_quantity,
base_part.get_latest_serial_number() base_part.get_latest_serial_number()
) )
except DjangoValidationError as e: except DjangoValidationError as e:

View File

@ -228,7 +228,7 @@ class OrderTest(TestCase):
part=prt, part=prt,
supplier=sup, supplier=sup,
SKU='SKUx10', SKU='SKUx10',
pack_size=10, pack_quantity='10',
) )
# Create a new supplier part with smaller pack size # Create a new supplier part with smaller pack size
@ -236,7 +236,7 @@ class OrderTest(TestCase):
part=prt, part=prt,
supplier=sup, supplier=sup,
SKU='SKUx0.1', SKU='SKUx0.1',
pack_size=0.1, pack_quantity='0.1',
) )
# Record values before we start # Record values before we start

View File

@ -486,10 +486,10 @@ class PartScheduling(RetrieveAPI):
target_date = line.target_date or line.order.target_date 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 # Multiply by the pack quantity of the SupplierPart
quantity *= line.part.pack_size quantity = line.part.base_quantity(line_quantity)
add_schedule_entry( add_schedule_entry(
target_date, target_date,
@ -804,19 +804,31 @@ class PartFilter(rest_filters.FilterSet):
Uses the django_filters extension framework 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 # Filter by parts which have (or not) an IPN value
has_ipn = rest_filters.BooleanFilter(label='Has IPN', method='filter_has_ipn') has_ipn = rest_filters.BooleanFilter(label='Has IPN', method='filter_has_ipn')
def filter_has_ipn(self, queryset, name, value): def filter_has_ipn(self, queryset, name, value):
"""Filter by whether the Part has an IPN (internal part number) or not""" """Filter by whether the Part has an IPN (internal part number) or not"""
value = str2bool(value)
if value: if str2bool(value):
queryset = queryset.exclude(IPN='') return queryset.exclude(IPN='')
else: else:
queryset = queryset.filter(IPN='') return queryset.filter(IPN='')
return queryset
# Regex filter for name # Regex filter for name
name_regex = rest_filters.CharFilter(label='Filter by name (regex)', field_name='name', lookup_expr='iregex') 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): def filter_low_stock(self, queryset, name, value):
"""Filter by "low stock" status.""" """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 # 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' # 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: else:
# Filter items which have an 'in_stock' level higher than 'minimum_stock' # Filter items which have an 'in_stock' level higher than 'minimum_stock'
queryset = queryset.filter(Q(in_stock__gte=F('minimum_stock'))) return queryset.filter(Q(in_stock__gte=F('minimum_stock')))
return queryset
# has_stock filter # has_stock filter
has_stock = rest_filters.BooleanFilter(label='Has stock', method='filter_has_stock') has_stock = rest_filters.BooleanFilter(label='Has stock', method='filter_has_stock')
def filter_has_stock(self, queryset, name, value): def filter_has_stock(self, queryset, name, value):
"""Filter by whether the Part has any stock""" """Filter by whether the Part has any stock"""
value = str2bool(value)
if value: if str2bool(value):
queryset = queryset.filter(Q(in_stock__gt=0)) return queryset.filter(Q(in_stock__gt=0))
else: else:
queryset = queryset.filter(Q(in_stock__lte=0)) return queryset.filter(Q(in_stock__lte=0))
return queryset
# unallocated_stock filter # unallocated_stock filter
unallocated_stock = rest_filters.BooleanFilter(label='Unallocated stock', method='filter_unallocated_stock') unallocated_stock = rest_filters.BooleanFilter(label='Unallocated stock', method='filter_unallocated_stock')
def filter_unallocated_stock(self, queryset, name, value): def filter_unallocated_stock(self, queryset, name, value):
"""Filter by whether the Part has unallocated stock""" """Filter by whether the Part has unallocated stock"""
value = str2bool(value)
if value: if str2bool(value):
queryset = queryset.filter(Q(unallocated_stock__gt=0)) return queryset.filter(Q(unallocated_stock__gt=0))
else: else:
queryset = queryset.filter(Q(unallocated_stock__lte=0)) return queryset.filter(Q(unallocated_stock__lte=0))
return queryset
convert_from = rest_filters.ModelChoiceFilter(label="Can convert from", queryset=Part.objects.all(), method='filter_convert_from') 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) children = part.get_descendants(include_self=True)
queryset = queryset.exclude(id__in=children) return queryset.exclude(id__in=children)
return queryset
ancestor = rest_filters.ModelChoiceFilter(label='Ancestor', queryset=Part.objects.all(), method='filter_ancestor') 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""" """Limit queryset to descendants of the specified ancestor part"""
descendants = part.get_descendants(include_self=False) descendants = part.get_descendants(include_self=False)
queryset = queryset.filter(id__in=descendants) return queryset.filter(id__in=descendants)
return queryset
variant_of = rest_filters.ModelChoiceFilter(label='Variant Of', queryset=Part.objects.all(), method='filter_variant_of') variant_of = rest_filters.ModelChoiceFilter(label='Variant Of', queryset=Part.objects.all(), method='filter_variant_of')
def filter_variant_of(self, queryset, name, part): def filter_variant_of(self, queryset, name, part):
"""Limit queryset to direct children (variants) of the specified part""" """Limit queryset to direct children (variants) of the specified part"""
queryset = queryset.filter(id__in=part.get_children()) return queryset.filter(id__in=part.get_children())
return queryset
in_bom_for = rest_filters.ModelChoiceFilter(label='In BOM Of', queryset=Part.objects.all(), method='filter_in_bom') 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""" """Limit queryset to parts in the BOM for the specified part"""
bom_parts = part.get_parts_in_bom() bom_parts = part.get_parts_in_bom()
queryset = queryset.filter(id__in=[p.pk for p in bom_parts]) return queryset.filter(id__in=[p.pk for p in bom_parts])
return queryset
has_pricing = rest_filters.BooleanFilter(label="Has Pricing", method="filter_has_pricing") has_pricing = rest_filters.BooleanFilter(label="Has Pricing", method="filter_has_pricing")
def filter_has_pricing(self, queryset, name, value): def filter_has_pricing(self, queryset, name, value):
"""Filter the queryset based on whether pricing information is available for the sub_part""" """Filter the queryset based on whether pricing information is available for the sub_part"""
value = str2bool(value)
q_a = Q(pricing_data=None) q_a = Q(pricing_data=None)
q_b = Q(pricing_data__overall_min=None, pricing_data__overall_max=None) q_b = Q(pricing_data__overall_min=None, pricing_data__overall_max=None)
if value: if str2bool(value):
queryset = queryset.exclude(q_a | q_b) return queryset.exclude(q_a | q_b)
else: else:
queryset = queryset.filter(q_a | q_b) return queryset.filter(q_a | q_b)
return queryset
stocktake = rest_filters.BooleanFilter(label="Has stocktake", method='filter_has_stocktake') stocktake = rest_filters.BooleanFilter(label="Has stocktake", method='filter_has_stocktake')
def filter_has_stocktake(self, queryset, name, value): def filter_has_stocktake(self, queryset, name, value):
"""Filter the queryset based on whether stocktake data is available""" """Filter the queryset based on whether stocktake data is available"""
value = str2bool(value) if str2bool(value):
return queryset.exclude(last_stocktake=None)
if (value):
queryset = queryset.exclude(last_stocktake=None)
else: else:
queryset = queryset.filter(last_stocktake=None) return queryset.filter(last_stocktake=None)
return queryset
is_template = rest_filters.BooleanFilter() is_template = rest_filters.BooleanFilter()
@ -1259,6 +1247,7 @@ class PartList(PartMixin, APIDownloadMixin, ListCreateAPI):
'unallocated_stock', 'unallocated_stock',
'category', 'category',
'last_stocktake', 'last_stocktake',
'units',
] ]
# Default ordering # Default ordering

View File

@ -40,7 +40,7 @@ def annotate_on_order_quantity(reference: str = ''):
- Purchase order must be 'active' or 'pending' - Purchase order must be 'active' or 'pending'
- Received quantity must be less than line item quantity - 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 # Filter only 'active' purhase orders
@ -53,7 +53,7 @@ def annotate_on_order_quantity(reference: str = ''):
return Coalesce( return Coalesce(
SubquerySum( SubquerySum(
ExpressionWrapper( 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(), output_field=DecimalField(),
), ),
filter=order_filter filter=order_filter
@ -63,7 +63,7 @@ def annotate_on_order_quantity(reference: str = ''):
) - Coalesce( ) - Coalesce(
SubquerySum( SubquerySum(
ExpressionWrapper( 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(), output_field=DecimalField(),
), ),
filter=order_filter filter=order_filter

View File

@ -19,10 +19,14 @@ def update_template_units(apps, schema_editor):
n_templates = PartParameterTemplate.objects.count() n_templates = PartParameterTemplate.objects.count()
if n_templates == 0:
# Escape early
return
ureg = InvenTree.conversion.get_unit_registry() ureg = InvenTree.conversion.get_unit_registry()
n_converted = 0 n_converted = 0
invalid_units = [] invalid_units = set()
for template in PartParameterTemplate.objects.all(): for template in PartParameterTemplate.objects.all():
@ -69,8 +73,8 @@ def update_template_units(apps, schema_editor):
break break
if not found: if not found:
print(f"warningCould not find unit match for {template.units}") print(f"warning: Could not find unit match for {template.units}")
invalid_units.append(template.units) invalid_units.add(template.units)
print(f"Updated units for {n_templates} parameter templates") 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, blank=True, null=True,
verbose_name=_('Units'), verbose_name=_('Units'),
help_text=_('Units of measure for this part'), help_text=_('Units of measure for this part'),
validators=[
validators.validate_physical_units,
]
) )
assembly = models.BooleanField( assembly = models.BooleanField(
@ -2141,7 +2144,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel)
def on_order(self): def on_order(self):
"""Return the total number of items on order for this part. """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! and this needs to be taken into account!
""" """
@ -2160,7 +2163,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel)
remaining = line.quantity - line.received remaining = line.quantity - line.received
if remaining > 0: if remaining > 0:
quantity += remaining * sp.pack_size quantity += sp.base_quantity(remaining)
return quantity return quantity
@ -2291,6 +2294,13 @@ def after_save_part(sender, instance: Part, created, **kwargs):
# Can sometimes occur if the referenced Part has issues # Can sometimes occur if the referenced Part has issues
pass 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): class PartPricing(common.models.MetaMixin):
"""Model for caching min/max pricing information for a particular Part """Model for caching min/max pricing information for a particular Part
@ -2560,7 +2570,7 @@ class PartPricing(common.models.MetaMixin):
continue continue
# Take supplier part pack size into account # 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: if purchase_cost is None:
continue continue
@ -2651,7 +2661,7 @@ class PartPricing(common.models.MetaMixin):
continue continue
# Ensure we take supplier part pack size into account # 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: if cost is None:
continue continue
@ -3359,8 +3369,8 @@ def post_save_part_parameter_template(sender, instance, created, **kwargs):
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData(): if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
# Schedule a background task to rebuild the parameters against this template
if not created: if not created:
# Schedule a background task to rebuild the parameters against this template
InvenTree.tasks.offload_task( InvenTree.tasks.offload_task(
part_tasks.rebuild_parameters, part_tasks.rebuild_parameters,
instance.pk, instance.pk,

View File

@ -20,21 +20,11 @@ from taggit.serializers import TagListSerializerField
import common.models import common.models
import company.models import company.models
import InvenTree.helpers import InvenTree.helpers
import InvenTree.serializers
import InvenTree.status import InvenTree.status
import part.filters import part.filters
import part.tasks import part.tasks
import stock.models 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.status_codes import BuildStatus
from InvenTree.tasks import offload_task from InvenTree.tasks import offload_task
@ -48,7 +38,7 @@ from .models import (BomItem, BomItemSubstitute, Part, PartAttachment,
logger = logging.getLogger("inventree") logger = logging.getLogger("inventree")
class CategorySerializer(InvenTreeModelSerializer): class CategorySerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for PartCategory.""" """Serializer for PartCategory."""
class Meta: class Meta:
@ -94,7 +84,7 @@ class CategorySerializer(InvenTreeModelSerializer):
starred = serializers.SerializerMethodField() starred = serializers.SerializerMethodField()
class CategoryTree(InvenTreeModelSerializer): class CategoryTree(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for PartCategory tree.""" """Serializer for PartCategory tree."""
class Meta: class Meta:
@ -108,19 +98,19 @@ class CategoryTree(InvenTreeModelSerializer):
] ]
class PartAttachmentSerializer(InvenTreeAttachmentSerializer): class PartAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSerializer):
"""Serializer for the PartAttachment class.""" """Serializer for the PartAttachment class."""
class Meta: class Meta:
"""Metaclass defining serializer fields""" """Metaclass defining serializer fields"""
model = PartAttachment model = PartAttachment
fields = InvenTreeAttachmentSerializer.attachment_fields([ fields = InvenTree.serializers.InvenTreeAttachmentSerializer.attachment_fields([
'part', 'part',
]) ])
class PartTestTemplateSerializer(InvenTreeModelSerializer): class PartTestTemplateSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for the PartTestTemplate class.""" """Serializer for the PartTestTemplate class."""
class Meta: class Meta:
@ -141,7 +131,7 @@ class PartTestTemplateSerializer(InvenTreeModelSerializer):
key = serializers.CharField(read_only=True) key = serializers.CharField(read_only=True)
class PartSalePriceSerializer(InvenTreeModelSerializer): class PartSalePriceSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for sale prices for Part model.""" """Serializer for sale prices for Part model."""
class Meta: class Meta:
@ -155,14 +145,14 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
'price_currency', '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.""" """Serializer for internal prices for Part model."""
class Meta: class Meta:
@ -176,13 +166,13 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
'price_currency', 'price_currency',
] ]
quantity = InvenTreeDecimalField() quantity = InvenTree.serializers.InvenTreeDecimalField()
price = InvenTreeMoneySerializer( price = InvenTree.serializers.InvenTreeMoneySerializer(
allow_null=True 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): class PartThumbSerializer(serializers.Serializer):
@ -195,7 +185,7 @@ class PartThumbSerializer(serializers.Serializer):
count = serializers.IntegerField(read_only=True) count = serializers.IntegerField(read_only=True)
class PartThumbSerializerUpdate(InvenTreeModelSerializer): class PartThumbSerializerUpdate(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for updating Part thumbnail.""" """Serializer for updating Part thumbnail."""
class Meta: class Meta:
@ -212,10 +202,10 @@ class PartThumbSerializerUpdate(InvenTreeModelSerializer):
raise serializers.ValidationError("File is not an image") raise serializers.ValidationError("File is not an image")
return value 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.""" """JSON serializer for the PartParameterTemplate model."""
class Meta: class Meta:
@ -229,7 +219,7 @@ class PartParameterTemplateSerializer(InvenTreeModelSerializer):
] ]
class PartParameterSerializer(InvenTreeModelSerializer): class PartParameterSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""JSON serializers for the PartParameter model.""" """JSON serializers for the PartParameter model."""
class Meta: class Meta:
@ -260,7 +250,7 @@ class PartParameterSerializer(InvenTreeModelSerializer):
template_detail = PartParameterTemplateSerializer(source='template', many=False, read_only=True) template_detail = PartParameterTemplateSerializer(source='template', many=False, read_only=True)
class PartBriefSerializer(InvenTreeModelSerializer): class PartBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for Part (brief detail)""" """Serializer for Part (brief detail)"""
class Meta: class Meta:
@ -295,8 +285,8 @@ class PartBriefSerializer(InvenTreeModelSerializer):
thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True) thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
# Pricing fields # Pricing fields
pricing_min = InvenTreeMoneySerializer(source='pricing_data.overall_min', allow_null=True, read_only=True) pricing_min = InvenTree.serializers.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_max = InvenTree.serializers.InvenTreeMoneySerializer(source='pricing_data.overall_max', allow_null=True, read_only=True)
class DuplicatePartSerializer(serializers.Serializer): class DuplicatePartSerializer(serializers.Serializer):
@ -406,7 +396,7 @@ class InitialSupplierSerializer(serializers.Serializer):
return data return data
class PartSerializer(RemoteImageMixin, InvenTreeTagModelSerializer): class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serializers.InvenTreeTagModelSerializer):
"""Serializer for complete detail information of a part. """Serializer for complete detail information of a part.
Used when displaying all details of a single component. 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) stock_item_count = serializers.IntegerField(read_only=True)
suppliers = 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) thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
starred = serializers.SerializerMethodField() starred = serializers.SerializerMethodField()
@ -615,8 +605,8 @@ class PartSerializer(RemoteImageMixin, InvenTreeTagModelSerializer):
category = serializers.PrimaryKeyRelatedField(queryset=PartCategory.objects.all()) category = serializers.PrimaryKeyRelatedField(queryset=PartCategory.objects.all())
# Pricing fields # Pricing fields
pricing_min = InvenTreeMoneySerializer(source='pricing_data.overall_min', allow_null=True, read_only=True) pricing_min = InvenTree.serializers.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_max = InvenTree.serializers.InvenTreeMoneySerializer(source='pricing_data.overall_max', allow_null=True, read_only=True)
parameters = PartParameterSerializer( parameters = PartParameterSerializer(
many=True, many=True,
@ -771,7 +761,7 @@ class PartSerializer(RemoteImageMixin, InvenTreeTagModelSerializer):
return self.instance return self.instance
class PartStocktakeSerializer(InvenTreeModelSerializer): class PartStocktakeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for the PartStocktake model""" """Serializer for the PartStocktake model"""
class Meta: class Meta:
@ -800,13 +790,13 @@ class PartStocktakeSerializer(InvenTreeModelSerializer):
quantity = serializers.FloatField() 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 = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True)
cost_min_currency = InvenTreeCurrencySerializer() cost_min_currency = InvenTree.serializers.InvenTreeCurrencySerializer()
cost_max = InvenTreeMoneySerializer(allow_null=True) cost_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True)
cost_max_currency = InvenTreeCurrencySerializer() cost_max_currency = InvenTree.serializers.InvenTreeCurrencySerializer()
def save(self): def save(self):
"""Called when this serializer is saved""" """Called when this serializer is saved"""
@ -820,7 +810,7 @@ class PartStocktakeSerializer(InvenTreeModelSerializer):
super().save() super().save()
class PartStocktakeReportSerializer(InvenTreeModelSerializer): class PartStocktakeReportSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for stocktake report class""" """Serializer for stocktake report class"""
class Meta: class Meta:
@ -836,9 +826,9 @@ class PartStocktakeReportSerializer(InvenTreeModelSerializer):
'user_detail', '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): 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""" """Serializer for Part pricing information"""
class Meta: class Meta:
@ -942,29 +932,29 @@ class PartPricingSerializer(InvenTreeModelSerializer):
scheduled_for_update = serializers.BooleanField(read_only=True) scheduled_for_update = serializers.BooleanField(read_only=True)
# Custom serializers # Custom serializers
bom_cost_min = InvenTreeMoneySerializer(allow_null=True, read_only=True) bom_cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
bom_cost_max = 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_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
purchase_cost_max = 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_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
internal_cost_max = 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_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
supplier_price_max = 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_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
variant_cost_max = 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_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
overall_max = 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_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
sale_price_max = 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_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
sale_history_max = InvenTreeMoneySerializer(allow_null=True, read_only=True) sale_history_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
update = serializers.BooleanField( update = serializers.BooleanField(
write_only=True, write_only=True,
@ -984,7 +974,7 @@ class PartPricingSerializer(InvenTreeModelSerializer):
pricing.update_pricing() pricing.update_pricing()
class PartRelationSerializer(InvenTreeModelSerializer): class PartRelationSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for a PartRelated model.""" """Serializer for a PartRelated model."""
class Meta: class Meta:
@ -1002,7 +992,7 @@ class PartRelationSerializer(InvenTreeModelSerializer):
part_2_detail = PartSerializer(source='part_2', read_only=True, many=False) 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.""" """Serializer for a PartStar object."""
class Meta: class Meta:
@ -1020,7 +1010,7 @@ class PartStarSerializer(InvenTreeModelSerializer):
username = serializers.CharField(source='user.username', read_only=True) username = serializers.CharField(source='user.username', read_only=True)
class BomItemSubstituteSerializer(InvenTreeModelSerializer): class BomItemSubstituteSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for the BomItemSubstitute class.""" """Serializer for the BomItemSubstitute class."""
class Meta: class Meta:
@ -1036,7 +1026,7 @@ class BomItemSubstituteSerializer(InvenTreeModelSerializer):
part_detail = PartBriefSerializer(source='part', read_only=True, many=False) part_detail = PartBriefSerializer(source='part', read_only=True, many=False)
class BomItemSerializer(InvenTreeModelSerializer): class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for BomItem object.""" """Serializer for BomItem object."""
class Meta: class Meta:
@ -1087,7 +1077,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
if sub_part_detail is not True: if sub_part_detail is not True:
self.fields.pop('sub_part_detail') self.fields.pop('sub_part_detail')
quantity = InvenTreeDecimalField(required=True) quantity = InvenTree.serializers.InvenTreeDecimalField(required=True)
def validate_quantity(self, quantity): def validate_quantity(self, quantity):
"""Perform validation for the BomItem quantity field""" """Perform validation for the BomItem quantity field"""
@ -1109,8 +1099,8 @@ class BomItemSerializer(InvenTreeModelSerializer):
on_order = serializers.FloatField(read_only=True) on_order = serializers.FloatField(read_only=True)
# Cached pricing fields # Cached pricing fields
pricing_min = InvenTreeMoneySerializer(source='sub_part.pricing.overall_min', 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 = InvenTreeMoneySerializer(source='sub_part.pricing.overall_max', 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 # Annotated fields for available stock
available_stock = serializers.FloatField(read_only=True) available_stock = serializers.FloatField(read_only=True)
@ -1212,7 +1202,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
return queryset return queryset
class CategoryParameterTemplateSerializer(InvenTreeModelSerializer): class CategoryParameterTemplateSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for the PartCategoryParameterTemplate model.""" """Serializer for the PartCategoryParameterTemplate model."""
class Meta: 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.""" """Serializer for uploading a file and extracting data from it."""
TARGET_MODEL = BomItem TARGET_MODEL = BomItem
@ -1333,7 +1323,7 @@ class BomImportUploadSerializer(DataFileUploadSerializer):
part.bom_items.all().delete() part.bom_items.all().delete()
class BomImportExtractSerializer(DataFileExtractSerializer): class BomImportExtractSerializer(InvenTree.serializers.DataFileExtractSerializer):
"""Serializer class for exatracting BOM data from an uploaded file. """Serializer class for exatracting BOM data from an uploaded file.
The parent class DataFileExtractSerializer does most of the heavy lifting here. 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 datetime import datetime, timedelta
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -18,6 +19,7 @@ from djmoney.money import Money
import common.models import common.models
import common.notifications import common.notifications
import common.settings import common.settings
import company.models
import InvenTree.helpers import InvenTree.helpers
import InvenTree.tasks import InvenTree.tasks
import part.models import part.models
@ -433,7 +435,7 @@ def scheduled_stocktake_reports():
def rebuild_parameters(template_id): def rebuild_parameters(template_id):
"""Rebuild all parameters for a given template. """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. which may cause the base unit to be adjusted.
""" """
@ -452,7 +454,35 @@ def rebuild_parameters(template_id):
parameter.calculate_numeric_value() parameter.calculate_numeric_value()
if value_old != parameter.data_numeric: if value_old != parameter.data_numeric:
parameter.full_clean()
parameter.save() parameter.save()
n += 1 n += 1
logger.info(f"Rebuilt {n} parameters for template '{template.name}'") 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, part=p,
supplier=supplier, supplier=supplier,
SKU=f"PNT-{color}-{pk_sz}L", SKU=f"PNT-{color}-{pk_sz}L",
pack_size=pk_sz, pack_quantity=str(pk_sz),
) )
self.assertEqual(p.supplier_parts.count(), 4) self.assertEqual(p.supplier_parts.count(), 4)
@ -2206,7 +2206,7 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
remaining = line_item.quantity - line_item.received remaining = line_item.quantity - line_item.received
if remaining > 0: 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 # The annotated quantity must be equal to the hand-calculated quantity
self.assertEqual(on_order, item['ordering']) self.assertEqual(on_order, item['ordering'])

View File

@ -88,7 +88,7 @@ class TestParameterMigrations(MigratorTestCase):
"""Unit test for part parameter migrations""" """Unit test for part parameter migrations"""
migrate_from = ('part', '0106_part_tags') migrate_from = ('part', '0106_part_tags')
migrate_to = ('part', '0109_auto_20230517_1048') migrate_to = ('part', unit_test.getNewestMigrationFile('part'))
def prepare(self): def prepare(self):
"""Create some parts, and templates with parameters""" """Create some parts, and templates with parameters"""
@ -154,3 +154,38 @@ class TestParameterMigrations(MigratorTestCase):
p4 = PartParameter.objects.get(part=b, template=t2) p4 = PartParameter.objects.get(part=b, template=t2)
self.assertEqual(p4.data, 'abc') self.assertEqual(p4.data, 'abc')
self.assertEqual(p4.data_numeric, None) 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 django.core.exceptions import ObjectDoesNotExist
from djmoney.contrib.exchange.models import convert_money
from djmoney.money import Money from djmoney.money import Money
import common.models import common.models
@ -25,10 +26,13 @@ class PartPricingTests(InvenTreeTestCase):
self.generate_exchange_rates() self.generate_exchange_rates()
# Create a new part for performing pricing calculations # 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( self.part = part.models.Part.objects.create(
name='PP', name='PP',
description='A part with pricing', description='A part with pricing, measured in metres',
assembly=True assembly=True,
units='m'
) )
def create_price_breaks(self): def create_price_breaks(self):
@ -44,8 +48,12 @@ class PartPricingTests(InvenTreeTestCase):
supplier=self.supplier_1, supplier=self.supplier_1,
part=self.part, part=self.part,
SKU='SUP_1', 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( company.models.SupplierPriceBreak.objects.create(
part=self.sp_1, part=self.sp_1,
quantity=1, quantity=1,
@ -63,16 +71,22 @@ class PartPricingTests(InvenTreeTestCase):
supplier=self.supplier_2, supplier=self.supplier_2,
part=self.part, part=self.part,
SKU='SUP_2', 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( self.sp_3 = company.models.SupplierPart.objects.create(
supplier=self.supplier_2, supplier=self.supplier_2,
part=self.part, part=self.part,
SKU='SUP_3', 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( company.models.SupplierPriceBreak.objects.create(
part=self.sp_2, part=self.sp_2,
quantity=5, quantity=5,
@ -162,8 +176,8 @@ class PartPricingTests(InvenTreeTestCase):
pricing.update_pricing() pricing.update_pricing()
self.assertEqual(pricing.overall_min, Money('2.014667', 'USD')) self.assertAlmostEqual(float(pricing.overall_min.amount), 2.015, places=2)
self.assertEqual(pricing.overall_max, Money('6.117647', 'USD')) self.assertAlmostEqual(float(pricing.overall_max.amount), 3.06, places=2)
# Delete all supplier parts and re-calculate # Delete all supplier parts and re-calculate
self.part.supplier_parts.all().delete() self.part.supplier_parts.all().delete()
@ -319,11 +333,11 @@ class PartPricingTests(InvenTreeTestCase):
# Add some line items to the order # 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')) 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) # $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(30, 'CAD')) line_2 = po.add_line_item(self.sp_3, quantity=5, purchase_price=Money(3, 'CAD'))
pricing.update_purchase_cost() pricing.update_purchase_cost()
@ -349,8 +363,20 @@ class PartPricingTests(InvenTreeTestCase):
pricing.update_purchase_cost() pricing.update_purchase_cost()
self.assertEqual(pricing.purchase_cost_min, Money('1.333333', 'USD')) min_cost_aud = convert_money(pricing.purchase_cost_min, 'AUD')
self.assertEqual(pricing.purchase_cost_max, Money('1.764706', 'USD')) 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): def test_delete_with_pricing(self):
"""Test for deleting a part which has pricing information""" """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'), '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 # Skip this check if pack size is 1 - makes no difference
# use_pack_size = True -> Multiply quantity by pack size # use_pack_size = True -> Multiply quantity by pack size
# use_pack_size = False -> Use quantity as is # use_pack_size = False -> Use quantity as is
@ -623,10 +623,9 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView):
}) })
else: else:
if bool(data.get('use_pack_size')): if bool(data.get('use_pack_size')):
data['quantity'] = int(quantity) * float(supplier_part.pack_size) quantity = data['quantity'] = supplier_part.base_quantity(quantity)
quantity = data.get('quantity', None)
# Divide purchase price by pack size, to save correct price per stock item # 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 # Now remove the flag from data, so that it doesn't interfere with saving
# Do this regardless of results above # Do this regardless of results above

View File

@ -2,6 +2,7 @@
import logging import logging
from django.core.exceptions import FieldError
from django.db import migrations from django.db import migrations
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
@ -38,10 +39,13 @@ def fix_purchase_price(apps, schema_editor):
supplier_part=None supplier_part=None
).exclude( ).exclude(
purchase_price=None purchase_price=None
).exclude(
supplier_part__pack_size=1
) )
try:
items = items.exclude(supplier_part__pack_size=1)
except FieldError:
pass
n_updated = 0 n_updated = 0
for item in items: for item in items:

View File

@ -698,6 +698,7 @@ class StockItemTest(StockAPITestCase):
}, },
expected_code=201 expected_code=201
) )
# Reload part, count stock again # Reload part, count stock again
part_4 = part.models.Part.objects.get(pk=4) part_4 = part.models.Part.objects.get(pk=4)
self.assertEqual(part_4.available_stock, current_count + 3) self.assertEqual(part_4.available_stock, current_count + 3)

View File

@ -138,7 +138,7 @@ function supplierPartFields(options={}) {
packaging: { packaging: {
icon: 'fa-box', icon: 'fa-box',
}, },
pack_size: {}, pack_quantity: {},
}; };
if (options.part) { if (options.part) {
@ -1242,17 +1242,24 @@ function loadSupplierPartTable(table, url, options) {
sortable: true, sortable: true,
}, },
{ {
field: 'pack_size', field: 'pack_quantity',
title: '{% trans "Pack Quantity" %}', title: '{% trans "Pack Quantity" %}',
sortable: true, sortable: true,
formatter: function(value, row) { formatter: function(value, row) {
var output = `${value}`;
if (row.part_detail && row.part_detail.units) { let html = '';
output += ` ${row.part_detail.units}`;
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) { formatter: function(value, row) {
let data = value; let data = value;
if (row.supplier_part_detail.pack_size != 1.0) { if (row.supplier_part_detail.pack_quantity_native != 1.0) {
let pack_size = row.supplier_part_detail.pack_size; let total = value * row.supplier_part_detail.pack_quantity_native;
let total = value * pack_size;
data += makeIconBadge( data += makeIconBadge(
'fa-info-circle icon-blue', '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) { formatter: function(value, row) {
var data = value; var data = value;
if (value > 0 && row.supplier_part_detail.pack_size != 1.0) { if (value > 0 && row.supplier_part_detail.pack_quantity_native != 1.0) {
var pack_size = row.supplier_part_detail.pack_size; let total = value * row.supplier_part_detail.pack_quantity_native;
var total = value * pack_size; data += `<span class='fas fa-info-circle icon-blue float-right' title='{% trans "Pack Quantity" %}: ${row.pack_quantity} - {% trans "Total Quantity" %}: ${total}'></span>`;
data += `<span class='fas fa-info-circle icon-blue float-right' title='{% trans "Pack Quantity" %}: ${pack_size} - {% trans "Total Quantity" %}: ${total}'></span>`;
} }
return data; return data;
@ -2038,6 +2036,12 @@ function loadPartTable(table, url, options={}) {
} }
}); });
columns.push({
field: 'units',
title: '{% trans "Units" %}',
sortable: true,
});
columns.push({ columns.push({
sortName: 'category', sortName: 'category',
field: 'category_detail', field: 'category_detail',

View File

@ -458,7 +458,7 @@ function loadPartSupplierPricingTable(options={}) {
data = data.sort((a, b) => (a.quantity - b.quantity)); data = data.sort((a, b) => (a.quantity - b.quantity));
var graphLabels = Array.from(data, (x) => (`${x.part_detail.SKU} - {% trans "Quantity" %} ${x.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) { if (chart) {
chart.destroy(); chart.destroy();
@ -518,7 +518,7 @@ function loadPartSupplierPricingTable(options={}) {
} }
// Convert to unit pricing // 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, { var html = formatCurrency(unit_price, {
currency: row.price_currency currency: row.price_currency
@ -811,9 +811,12 @@ function loadPurchasePriceHistoryTable(options={}) {
return '-'; return '-';
} }
return formatCurrency(row.purchase_price / row.supplier_part_detail.pack_size, { return formatCurrency(
row.purchase_price / row.supplier_part_detail.pack_quantity_native,
{
currency: row.purchase_price_currency currency: row.purchase_price_currency
}); }
);
} }
}, },
] ]

View File

@ -229,8 +229,8 @@ function poLineItemFields(options={}) {
supplier: options.supplier, supplier: options.supplier,
}, },
onEdit: function(value, name, field, opts) { onEdit: function(value, name, field, opts) {
// If the pack_size != 1, add a note to the field // If the pack_quantity != 1, add a note to the field
var pack_size = 1; var pack_quantity = 1;
var units = ''; var units = '';
var supplier_part_id = value; var supplier_part_id = value;
var quantity = getFormFieldValue('quantity', {}, opts); var quantity = getFormFieldValue('quantity', {}, opts);
@ -250,14 +250,14 @@ function poLineItemFields(options={}) {
{ {
success: function(response) { success: function(response) {
// Extract information from the returned query // Extract information from the returned query
pack_size = response.pack_size || 1; pack_quantity = response.pack_quantity_native || 1;
units = response.part_detail.units || ''; units = response.part_detail.units || '';
}, },
} }
).then(function() { ).then(function() {
// Update pack size information // Update pack size information
if (pack_size != 1) { if (pack_quantity != 1) {
var txt = `<span class='fas fa-info-circle icon-blue'></span> {% trans "Pack Quantity" %}: ${pack_size} ${units}`; 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>`); $(opts.modal).find('#hint_id_quantity').after(`<div class='form-info-message' id='info-pack-size'>${txt}</div>`);
} }
}).then(function() { }).then(function() {
@ -766,7 +766,7 @@ function orderParts(parts_list, options) {
// Callback function when supplier part is changed // Callback function when supplier part is changed
// This is used to update the "pack size" attribute // This is used to update the "pack size" attribute
var onSupplierPartChanged = function(value, name, field, opts) { var onSupplierPartChanged = function(value, name, field, opts) {
var pack_size = 1; var pack_quantity = 1;
var units = ''; var units = '';
$(opts.modal).find(`#info-pack-size-${pk}`).remove(); $(opts.modal).find(`#info-pack-size-${pk}`).remove();
@ -779,13 +779,13 @@ function orderParts(parts_list, options) {
}, },
{ {
success: function(response) { success: function(response) {
pack_size = response.pack_size || 1; pack_quantity = response.pack_quantity_native || 1;
units = response.part_detail.units || ''; units = response.part_detail.units || '';
} }
} }
).then(function() { ).then(function() {
if (pack_size != 1) { if (pack_quantity != 1) {
var txt = `<span class='fas fa-info-circle icon-blue'></span> {% trans "Pack Quantity" %}: ${pack_size} ${units}`; 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>`); $(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 units = line_item.part_detail.units || '';
var pack_size = line_item.supplier_part_detail.pack_size || 1; let pack_quantity = line_item.supplier_part_detail.pack_quantity;
var pack_size_div = ''; 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 = ` pack_size_div = `
<div class='alert alert-small alert-block alert-info'> <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} {% trans "Received Quantity" %}: <span class='pack_received_quantity' id='items_received_quantity_${pk}'>${received}</span> ${units}
</div>`; </div>`;
} }
@ -1304,13 +1306,13 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
); );
// Add change callback for quantity field // 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() { $(opts.modal).find(`#id_items_quantity_${pk}`).change(function() {
var value = $(opts.modal).find(`#id_items_quantity_${pk}`).val(); var value = $(opts.modal).find(`#id_items_quantity_${pk}`).val();
var el = $(opts.modal).find(`#quantity_${pk}`).find('.pack_received_quantity'); 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); actual = formatDecimal(actual);
el.text(actual); el.text(actual);
}); });
@ -2005,10 +2007,10 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
let data = value; let data = value;
if (row.supplier_part_detail && row.supplier_part_detail.pack_size != 1.0) { if (row.supplier_part_detail && row.supplier_part_detail.pack_quantity_native != 1.0) {
var pack_size = row.supplier_part_detail.pack_size; let pack_quantity = row.supplier_part_detail.pack_quantity;
var total = value * pack_size; 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_size}${units} - {% trans "Total Quantity" %}: ${total}${units}'></span>`; 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; return data;
@ -2024,7 +2026,7 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
{ {
sortable: false, sortable: false,
switchable: true, switchable: true,
field: 'supplier_part_detail.pack_size', field: 'supplier_part_detail.pack_quantity',
title: '{% trans "Pack Quantity" %}', title: '{% trans "Pack Quantity" %}',
formatter: function(value, row) { formatter: function(value, row) {
var units = row.part_detail.units; var units = row.part_detail.units;

View File

@ -626,6 +626,11 @@ function getPartTableFilters() {
type: 'bool', type: 'bool',
title: '{% trans "Component" %}', title: '{% trans "Component" %}',
}, },
has_units: {
type: 'bool',
title: '{% trans "Has Units" %}',
description: '{% trans "Part has defined units" %}',
},
has_ipn: { has_ipn: {
type: 'bool', type: 'bool',
title: '{% trans "Has IPN" %}', 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 ### 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 ### 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. 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 ## 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: 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: