mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +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:
parent
717bb07dcf
commit
5dd6f18495
@ -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
|
||||
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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')
|
||||
|
@ -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."""
|
||||
|
||||
|
@ -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 = [
|
||||
|
@ -66,4 +66,5 @@
|
||||
part: 4
|
||||
supplier: 2
|
||||
SKU: 'R_4K7_0603.100PCK'
|
||||
pack_size: 100
|
||||
pack_quantity: '100'
|
||||
pack_quantity_native: 100
|
||||
|
25
InvenTree/company/migrations/0059_supplierpart_pack_units.py
Normal file
25
InvenTree/company/migrations/0059_supplierpart_pack_units.py
Normal 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),
|
||||
),
|
||||
]
|
51
InvenTree/company/migrations/0060_auto_20230519_0344.py
Normal file
51
InvenTree/company/migrations/0060_auto_20230519_0344.py
Normal 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,
|
||||
)
|
||||
]
|
@ -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',
|
||||
),
|
||||
]
|
@ -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).
|
||||
|
@ -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)
|
||||
|
@ -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 %}
|
||||
|
@ -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
|
||||
|
114
InvenTree/company/test_supplier_parts.py
Normal file
114
InvenTree/company/test_supplier_parts.py
Normal 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()
|
@ -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()
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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")
|
||||
|
||||
|
19
InvenTree/part/migrations/0110_alter_part_units.py
Normal file
19
InvenTree/part/migrations/0110_alter_part_units.py
Normal 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'),
|
||||
),
|
||||
]
|
93
InvenTree/part/migrations/0111_auto_20230521_1350.py
Normal file
93
InvenTree/part/migrations/0111_auto_20230521_1350.py
Normal 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)
|
||||
]
|
@ -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,
|
||||
|
@ -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.
|
||||
|
@ -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}'")
|
||||
|
@ -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'])
|
||||
|
@ -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')
|
||||
|
@ -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"""
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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)
|
||||
|
@ -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;
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -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',
|
||||
|
@ -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
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
]
|
||||
|
@ -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;
|
||||
|
@ -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" %}',
|
||||
|
BIN
docs/docs/assets/images/part/part_units.png
Normal file
BIN
docs/docs/assets/images/part/part_units.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 171 KiB |
BIN
docs/docs/assets/images/part/part_units_invalid.png
Normal file
BIN
docs/docs/assets/images/part/part_units_invalid.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 26 KiB |
@ -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:
|
||||
|
Loading…
x
Reference in New Issue
Block a user