2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-12-22 12:13:29 +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
39 changed files with 878 additions and 251 deletions

View File

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

View File

@@ -40,7 +40,7 @@ def annotate_on_order_quantity(reference: str = ''):
- Purchase order must be 'active' or 'pending'
- Received quantity must be less than line item quantity
Note that in addition to the 'quantity' on order, we must also take into account 'pack_size'.
Note that in addition to the 'quantity' on order, we must also take into account 'pack_quantity'.
"""
# Filter only 'active' purhase orders
@@ -53,7 +53,7 @@ def annotate_on_order_quantity(reference: str = ''):
return Coalesce(
SubquerySum(
ExpressionWrapper(
F(f'{reference}supplier_parts__purchase_order_line_items__quantity') * F(f'{reference}supplier_parts__pack_size'),
F(f'{reference}supplier_parts__purchase_order_line_items__quantity') * F(f'{reference}supplier_parts__pack_quantity_native'),
output_field=DecimalField(),
),
filter=order_filter
@@ -63,7 +63,7 @@ def annotate_on_order_quantity(reference: str = ''):
) - Coalesce(
SubquerySum(
ExpressionWrapper(
F(f'{reference}supplier_parts__purchase_order_line_items__received') * F(f'{reference}supplier_parts__pack_size'),
F(f'{reference}supplier_parts__purchase_order_line_items__received') * F(f'{reference}supplier_parts__pack_quantity_native'),
output_field=DecimalField(),
),
filter=order_filter

View File

@@ -19,10 +19,14 @@ def update_template_units(apps, schema_editor):
n_templates = PartParameterTemplate.objects.count()
if n_templates == 0:
# Escape early
return
ureg = InvenTree.conversion.get_unit_registry()
n_converted = 0
invalid_units = []
invalid_units = set()
for template in PartParameterTemplate.objects.all():
@@ -69,8 +73,8 @@ def update_template_units(apps, schema_editor):
break
if not found:
print(f"warningCould not find unit match for {template.units}")
invalid_units.append(template.units)
print(f"warning: Could not find unit match for {template.units}")
invalid_units.add(template.units)
print(f"Updated units for {n_templates} parameter templates")

View File

@@ -0,0 +1,19 @@
# Generated by Django 3.2.19 on 2023-05-19 03:31
import InvenTree.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0109_auto_20230517_1048'),
]
operations = [
migrations.AlterField(
model_name='part',
name='units',
field=models.CharField(blank=True, default='', help_text='Units of measure for this part', max_length=20, null=True, validators=[InvenTree.validators.validate_physical_units], verbose_name='Units'),
),
]

View File

@@ -0,0 +1,93 @@
# Generated by Django 3.2.19 on 2023-05-21 13:50
import pint
from django.core.exceptions import ValidationError
from django.db import migrations
import InvenTree.conversion
def migrate_part_units(apps, schema_editor):
"""Update the units field for each Part object:
- Check if the units are valid
- Attempt to convert to valid units (if possible)
"""
Part = apps.get_model('part', 'Part')
parts = Part.objects.exclude(units=None).exclude(units='')
n_parts = parts.count()
if n_parts == 0:
# Escape early
return
ureg = InvenTree.conversion.get_unit_registry()
invalid_units = set()
n_converted = 0
for part in parts:
# Override '%' units (which are invalid)
if part.units == '%':
part.units = 'percent'
part.save()
continue
# Test if unit is 'valid'
try:
ureg.Unit(part.units)
continue
except pint.errors.UndefinedUnitError:
pass
# Check a lower-case version
try:
ureg.Unit(part.units.lower())
print(f"Found unit match: {part.units} -> {part.units.lower()}")
part.units = part.units.lower()
part.save()
n_converted += 1
continue
except pint.errors.UndefinedUnitError:
pass
found = False
# Attempt to convert to a valid unit
for unit in ureg:
if unit.lower() == part.units.lower():
print("Found unit match: {part.units} -> {unit}")
part.units = str(unit)
part.save()
n_converted += 1
found = True
break
if not found:
print(f"Warning: Invalid units for part '{part}': {part.units}")
invalid_units.add(part.units)
print(f"Updated units for {n_parts} parts")
if n_converted > 0:
print(f"Converted units for {n_converted} parts")
if len(invalid_units) > 0:
print(f"Found {len(invalid_units)} invalid units:")
for unit in invalid_units:
print(f" - {unit}")
class Migration(migrations.Migration):
dependencies = [
('part', '0110_alter_part_units'),
]
operations = [
migrations.RunPython(code=migrate_part_units, reverse_code=migrations.RunPython.noop)
]

View File

@@ -984,6 +984,9 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel)
blank=True, null=True,
verbose_name=_('Units'),
help_text=_('Units of measure for this part'),
validators=[
validators.validate_physical_units,
]
)
assembly = models.BooleanField(
@@ -2141,7 +2144,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel)
def on_order(self):
"""Return the total number of items on order for this part.
Note that some supplier parts may have a different pack_size attribute,
Note that some supplier parts may have a different pack_quantity attribute,
and this needs to be taken into account!
"""
@@ -2160,7 +2163,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel)
remaining = line.quantity - line.received
if remaining > 0:
quantity += remaining * sp.pack_size
quantity += sp.base_quantity(remaining)
return quantity
@@ -2291,6 +2294,13 @@ def after_save_part(sender, instance: Part, created, **kwargs):
# Can sometimes occur if the referenced Part has issues
pass
# Schedule a background task to rebuild any supplier parts
InvenTree.tasks.offload_task(
part_tasks.rebuild_supplier_parts,
instance.pk,
force_async=True
)
class PartPricing(common.models.MetaMixin):
"""Model for caching min/max pricing information for a particular Part
@@ -2560,7 +2570,7 @@ class PartPricing(common.models.MetaMixin):
continue
# Take supplier part pack size into account
purchase_cost = self.convert(line.purchase_price / line.part.pack_size)
purchase_cost = self.convert(line.purchase_price / line.part.pack_quantity_native)
if purchase_cost is None:
continue
@@ -2651,7 +2661,7 @@ class PartPricing(common.models.MetaMixin):
continue
# Ensure we take supplier part pack size into account
cost = self.convert(pb.price / sp.pack_size)
cost = self.convert(pb.price / sp.pack_quantity_native)
if cost is None:
continue
@@ -3359,8 +3369,8 @@ def post_save_part_parameter_template(sender, instance, created, **kwargs):
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
# Schedule a background task to rebuild the parameters against this template
if not created:
# Schedule a background task to rebuild the parameters against this template
InvenTree.tasks.offload_task(
part_tasks.rebuild_parameters,
instance.pk,

View File

@@ -20,21 +20,11 @@ from taggit.serializers import TagListSerializerField
import common.models
import company.models
import InvenTree.helpers
import InvenTree.serializers
import InvenTree.status
import part.filters
import part.tasks
import stock.models
from InvenTree.serializers import (DataFileExtractSerializer,
DataFileUploadSerializer,
InvenTreeAttachmentSerializer,
InvenTreeAttachmentSerializerField,
InvenTreeCurrencySerializer,
InvenTreeDecimalField,
InvenTreeImageSerializerField,
InvenTreeModelSerializer,
InvenTreeMoneySerializer,
InvenTreeTagModelSerializer,
RemoteImageMixin, UserSerializer)
from InvenTree.status_codes import BuildStatus
from InvenTree.tasks import offload_task
@@ -48,7 +38,7 @@ from .models import (BomItem, BomItemSubstitute, Part, PartAttachment,
logger = logging.getLogger("inventree")
class CategorySerializer(InvenTreeModelSerializer):
class CategorySerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for PartCategory."""
class Meta:
@@ -94,7 +84,7 @@ class CategorySerializer(InvenTreeModelSerializer):
starred = serializers.SerializerMethodField()
class CategoryTree(InvenTreeModelSerializer):
class CategoryTree(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for PartCategory tree."""
class Meta:
@@ -108,19 +98,19 @@ class CategoryTree(InvenTreeModelSerializer):
]
class PartAttachmentSerializer(InvenTreeAttachmentSerializer):
class PartAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSerializer):
"""Serializer for the PartAttachment class."""
class Meta:
"""Metaclass defining serializer fields"""
model = PartAttachment
fields = InvenTreeAttachmentSerializer.attachment_fields([
fields = InvenTree.serializers.InvenTreeAttachmentSerializer.attachment_fields([
'part',
])
class PartTestTemplateSerializer(InvenTreeModelSerializer):
class PartTestTemplateSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for the PartTestTemplate class."""
class Meta:
@@ -141,7 +131,7 @@ class PartTestTemplateSerializer(InvenTreeModelSerializer):
key = serializers.CharField(read_only=True)
class PartSalePriceSerializer(InvenTreeModelSerializer):
class PartSalePriceSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for sale prices for Part model."""
class Meta:
@@ -155,14 +145,14 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
'price_currency',
]
quantity = InvenTreeDecimalField()
quantity = InvenTree.serializers.InvenTreeDecimalField()
price = InvenTreeMoneySerializer(allow_null=True)
price = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True)
price_currency = InvenTreeCurrencySerializer(help_text=_('Purchase currency of this stock item'))
price_currency = InvenTree.serializers.InvenTreeCurrencySerializer(help_text=_('Purchase currency of this stock item'))
class PartInternalPriceSerializer(InvenTreeModelSerializer):
class PartInternalPriceSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for internal prices for Part model."""
class Meta:
@@ -176,13 +166,13 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
'price_currency',
]
quantity = InvenTreeDecimalField()
quantity = InvenTree.serializers.InvenTreeDecimalField()
price = InvenTreeMoneySerializer(
price = InvenTree.serializers.InvenTreeMoneySerializer(
allow_null=True
)
price_currency = InvenTreeCurrencySerializer(help_text=_('Purchase currency of this stock item'))
price_currency = InvenTree.serializers.InvenTreeCurrencySerializer(help_text=_('Purchase currency of this stock item'))
class PartThumbSerializer(serializers.Serializer):
@@ -195,7 +185,7 @@ class PartThumbSerializer(serializers.Serializer):
count = serializers.IntegerField(read_only=True)
class PartThumbSerializerUpdate(InvenTreeModelSerializer):
class PartThumbSerializerUpdate(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for updating Part thumbnail."""
class Meta:
@@ -212,10 +202,10 @@ class PartThumbSerializerUpdate(InvenTreeModelSerializer):
raise serializers.ValidationError("File is not an image")
return value
image = InvenTreeAttachmentSerializerField(required=True)
image = InvenTree.serializers.InvenTreeAttachmentSerializerField(required=True)
class PartParameterTemplateSerializer(InvenTreeModelSerializer):
class PartParameterTemplateSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""JSON serializer for the PartParameterTemplate model."""
class Meta:
@@ -229,7 +219,7 @@ class PartParameterTemplateSerializer(InvenTreeModelSerializer):
]
class PartParameterSerializer(InvenTreeModelSerializer):
class PartParameterSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""JSON serializers for the PartParameter model."""
class Meta:
@@ -260,7 +250,7 @@ class PartParameterSerializer(InvenTreeModelSerializer):
template_detail = PartParameterTemplateSerializer(source='template', many=False, read_only=True)
class PartBriefSerializer(InvenTreeModelSerializer):
class PartBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for Part (brief detail)"""
class Meta:
@@ -295,8 +285,8 @@ class PartBriefSerializer(InvenTreeModelSerializer):
thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
# Pricing fields
pricing_min = InvenTreeMoneySerializer(source='pricing_data.overall_min', allow_null=True, read_only=True)
pricing_max = InvenTreeMoneySerializer(source='pricing_data.overall_max', allow_null=True, read_only=True)
pricing_min = InvenTree.serializers.InvenTreeMoneySerializer(source='pricing_data.overall_min', allow_null=True, read_only=True)
pricing_max = InvenTree.serializers.InvenTreeMoneySerializer(source='pricing_data.overall_max', allow_null=True, read_only=True)
class DuplicatePartSerializer(serializers.Serializer):
@@ -406,7 +396,7 @@ class InitialSupplierSerializer(serializers.Serializer):
return data
class PartSerializer(RemoteImageMixin, InvenTreeTagModelSerializer):
class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serializers.InvenTreeTagModelSerializer):
"""Serializer for complete detail information of a part.
Used when displaying all details of a single component.
@@ -607,7 +597,7 @@ class PartSerializer(RemoteImageMixin, InvenTreeTagModelSerializer):
stock_item_count = serializers.IntegerField(read_only=True)
suppliers = serializers.IntegerField(read_only=True)
image = InvenTreeImageSerializerField(required=False, allow_null=True)
image = InvenTree.serializers.InvenTreeImageSerializerField(required=False, allow_null=True)
thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
starred = serializers.SerializerMethodField()
@@ -615,8 +605,8 @@ class PartSerializer(RemoteImageMixin, InvenTreeTagModelSerializer):
category = serializers.PrimaryKeyRelatedField(queryset=PartCategory.objects.all())
# Pricing fields
pricing_min = InvenTreeMoneySerializer(source='pricing_data.overall_min', allow_null=True, read_only=True)
pricing_max = InvenTreeMoneySerializer(source='pricing_data.overall_max', allow_null=True, read_only=True)
pricing_min = InvenTree.serializers.InvenTreeMoneySerializer(source='pricing_data.overall_min', allow_null=True, read_only=True)
pricing_max = InvenTree.serializers.InvenTreeMoneySerializer(source='pricing_data.overall_max', allow_null=True, read_only=True)
parameters = PartParameterSerializer(
many=True,
@@ -771,7 +761,7 @@ class PartSerializer(RemoteImageMixin, InvenTreeTagModelSerializer):
return self.instance
class PartStocktakeSerializer(InvenTreeModelSerializer):
class PartStocktakeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for the PartStocktake model"""
class Meta:
@@ -800,13 +790,13 @@ class PartStocktakeSerializer(InvenTreeModelSerializer):
quantity = serializers.FloatField()
user_detail = UserSerializer(source='user', read_only=True, many=False)
user_detail = InvenTree.serializers.UserSerializer(source='user', read_only=True, many=False)
cost_min = InvenTreeMoneySerializer(allow_null=True)
cost_min_currency = InvenTreeCurrencySerializer()
cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True)
cost_min_currency = InvenTree.serializers.InvenTreeCurrencySerializer()
cost_max = InvenTreeMoneySerializer(allow_null=True)
cost_max_currency = InvenTreeCurrencySerializer()
cost_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True)
cost_max_currency = InvenTree.serializers.InvenTreeCurrencySerializer()
def save(self):
"""Called when this serializer is saved"""
@@ -820,7 +810,7 @@ class PartStocktakeSerializer(InvenTreeModelSerializer):
super().save()
class PartStocktakeReportSerializer(InvenTreeModelSerializer):
class PartStocktakeReportSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for stocktake report class"""
class Meta:
@@ -836,9 +826,9 @@ class PartStocktakeReportSerializer(InvenTreeModelSerializer):
'user_detail',
]
user_detail = UserSerializer(source='user', read_only=True, many=False)
user_detail = InvenTree.serializers.UserSerializer(source='user', read_only=True, many=False)
report = InvenTreeAttachmentSerializerField(read_only=True)
report = InvenTree.serializers.InvenTreeAttachmentSerializerField(read_only=True)
class PartStocktakeReportGenerateSerializer(serializers.Serializer):
@@ -906,7 +896,7 @@ class PartStocktakeReportGenerateSerializer(serializers.Serializer):
)
class PartPricingSerializer(InvenTreeModelSerializer):
class PartPricingSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for Part pricing information"""
class Meta:
@@ -942,29 +932,29 @@ class PartPricingSerializer(InvenTreeModelSerializer):
scheduled_for_update = serializers.BooleanField(read_only=True)
# Custom serializers
bom_cost_min = InvenTreeMoneySerializer(allow_null=True, read_only=True)
bom_cost_max = InvenTreeMoneySerializer(allow_null=True, read_only=True)
bom_cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
bom_cost_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
purchase_cost_min = InvenTreeMoneySerializer(allow_null=True, read_only=True)
purchase_cost_max = InvenTreeMoneySerializer(allow_null=True, read_only=True)
purchase_cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
purchase_cost_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
internal_cost_min = InvenTreeMoneySerializer(allow_null=True, read_only=True)
internal_cost_max = InvenTreeMoneySerializer(allow_null=True, read_only=True)
internal_cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
internal_cost_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
supplier_price_min = InvenTreeMoneySerializer(allow_null=True, read_only=True)
supplier_price_max = InvenTreeMoneySerializer(allow_null=True, read_only=True)
supplier_price_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
supplier_price_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
variant_cost_min = InvenTreeMoneySerializer(allow_null=True, read_only=True)
variant_cost_max = InvenTreeMoneySerializer(allow_null=True, read_only=True)
variant_cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
variant_cost_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
overall_min = InvenTreeMoneySerializer(allow_null=True, read_only=True)
overall_max = InvenTreeMoneySerializer(allow_null=True, read_only=True)
overall_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
overall_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
sale_price_min = InvenTreeMoneySerializer(allow_null=True, read_only=True)
sale_price_max = InvenTreeMoneySerializer(allow_null=True, read_only=True)
sale_price_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
sale_price_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
sale_history_min = InvenTreeMoneySerializer(allow_null=True, read_only=True)
sale_history_max = InvenTreeMoneySerializer(allow_null=True, read_only=True)
sale_history_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
sale_history_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
update = serializers.BooleanField(
write_only=True,
@@ -984,7 +974,7 @@ class PartPricingSerializer(InvenTreeModelSerializer):
pricing.update_pricing()
class PartRelationSerializer(InvenTreeModelSerializer):
class PartRelationSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for a PartRelated model."""
class Meta:
@@ -1002,7 +992,7 @@ class PartRelationSerializer(InvenTreeModelSerializer):
part_2_detail = PartSerializer(source='part_2', read_only=True, many=False)
class PartStarSerializer(InvenTreeModelSerializer):
class PartStarSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for a PartStar object."""
class Meta:
@@ -1020,7 +1010,7 @@ class PartStarSerializer(InvenTreeModelSerializer):
username = serializers.CharField(source='user.username', read_only=True)
class BomItemSubstituteSerializer(InvenTreeModelSerializer):
class BomItemSubstituteSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for the BomItemSubstitute class."""
class Meta:
@@ -1036,7 +1026,7 @@ class BomItemSubstituteSerializer(InvenTreeModelSerializer):
part_detail = PartBriefSerializer(source='part', read_only=True, many=False)
class BomItemSerializer(InvenTreeModelSerializer):
class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for BomItem object."""
class Meta:
@@ -1087,7 +1077,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
if sub_part_detail is not True:
self.fields.pop('sub_part_detail')
quantity = InvenTreeDecimalField(required=True)
quantity = InvenTree.serializers.InvenTreeDecimalField(required=True)
def validate_quantity(self, quantity):
"""Perform validation for the BomItem quantity field"""
@@ -1109,8 +1099,8 @@ class BomItemSerializer(InvenTreeModelSerializer):
on_order = serializers.FloatField(read_only=True)
# Cached pricing fields
pricing_min = InvenTreeMoneySerializer(source='sub_part.pricing.overall_min', allow_null=True, read_only=True)
pricing_max = InvenTreeMoneySerializer(source='sub_part.pricing.overall_max', allow_null=True, read_only=True)
pricing_min = InvenTree.serializers.InvenTreeMoneySerializer(source='sub_part.pricing.overall_min', allow_null=True, read_only=True)
pricing_max = InvenTree.serializers.InvenTreeMoneySerializer(source='sub_part.pricing.overall_max', allow_null=True, read_only=True)
# Annotated fields for available stock
available_stock = serializers.FloatField(read_only=True)
@@ -1212,7 +1202,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
return queryset
class CategoryParameterTemplateSerializer(InvenTreeModelSerializer):
class CategoryParameterTemplateSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for the PartCategoryParameterTemplate model."""
class Meta:
@@ -1297,7 +1287,7 @@ class PartCopyBOMSerializer(serializers.Serializer):
)
class BomImportUploadSerializer(DataFileUploadSerializer):
class BomImportUploadSerializer(InvenTree.serializers.DataFileUploadSerializer):
"""Serializer for uploading a file and extracting data from it."""
TARGET_MODEL = BomItem
@@ -1333,7 +1323,7 @@ class BomImportUploadSerializer(DataFileUploadSerializer):
part.bom_items.all().delete()
class BomImportExtractSerializer(DataFileExtractSerializer):
class BomImportExtractSerializer(InvenTree.serializers.DataFileExtractSerializer):
"""Serializer class for exatracting BOM data from an uploaded file.
The parent class DataFileExtractSerializer does most of the heavy lifting here.

View File

@@ -7,6 +7,7 @@ import time
from datetime import datetime, timedelta
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.utils.translation import gettext_lazy as _
@@ -18,6 +19,7 @@ from djmoney.money import Money
import common.models
import common.notifications
import common.settings
import company.models
import InvenTree.helpers
import InvenTree.tasks
import part.models
@@ -433,7 +435,7 @@ def scheduled_stocktake_reports():
def rebuild_parameters(template_id):
"""Rebuild all parameters for a given template.
This method is called when a base template is changed,
This function is called when a base template is changed,
which may cause the base unit to be adjusted.
"""
@@ -452,7 +454,35 @@ def rebuild_parameters(template_id):
parameter.calculate_numeric_value()
if value_old != parameter.data_numeric:
parameter.full_clean()
parameter.save()
n += 1
logger.info(f"Rebuilt {n} parameters for template '{template.name}'")
def rebuild_supplier_parts(part_id):
"""Rebuild all SupplierPart objects for a given part.
This function is called when a bart part is changed,
which may cause the native units of any supplier parts to be updated
"""
try:
prt = part.models.Part.objects.get(pk=part_id)
except part.models.Part.DoesNotExist:
return
supplier_parts = company.models.SupplierPart.objects.filter(part=prt)
n = supplier_parts.count()
for supplier_part in supplier_parts:
# Re-save the part, to ensure that the units have updated correctly
try:
supplier_part.full_clean()
supplier_part.save()
except ValidationError:
pass
logger.info(f"Rebuilt {n} supplier parts for part '{prt.name}'")

View File

@@ -2144,7 +2144,7 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
part=p,
supplier=supplier,
SKU=f"PNT-{color}-{pk_sz}L",
pack_size=pk_sz,
pack_quantity=str(pk_sz),
)
self.assertEqual(p.supplier_parts.count(), 4)
@@ -2206,7 +2206,7 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
remaining = line_item.quantity - line_item.received
if remaining > 0:
on_order += remaining * sp.pack_size
on_order += sp.base_quantity(remaining)
# The annotated quantity must be equal to the hand-calculated quantity
self.assertEqual(on_order, item['ordering'])

View File

@@ -88,7 +88,7 @@ class TestParameterMigrations(MigratorTestCase):
"""Unit test for part parameter migrations"""
migrate_from = ('part', '0106_part_tags')
migrate_to = ('part', '0109_auto_20230517_1048')
migrate_to = ('part', unit_test.getNewestMigrationFile('part'))
def prepare(self):
"""Create some parts, and templates with parameters"""
@@ -154,3 +154,38 @@ class TestParameterMigrations(MigratorTestCase):
p4 = PartParameter.objects.get(part=b, template=t2)
self.assertEqual(p4.data, 'abc')
self.assertEqual(p4.data_numeric, None)
class PartUnitsMigrationTest(MigratorTestCase):
"""Test for data migration of Part.units field"""
migrate_from = ('part', '0109_auto_20230517_1048')
migrate_to = ('part', unit_test.getNewestMigrationFile('part'))
def prepare(self):
"""Prepare some parts with units"""
Part = self.old_state.apps.get_model('part', 'part')
units = ['mm', 'INCH', '', '%']
for idx, unit in enumerate(units):
Part.objects.create(
name=f'Part {idx + 1}', description=f'My part at index {idx}', units=unit,
level=0, lft=0, rght=0, tree_id=0,
)
def test_units_migration(self):
"""Test that the units have migrated OK"""
Part = self.new_state.apps.get_model('part', 'part')
part_1 = Part.objects.get(name='Part 1')
part_2 = Part.objects.get(name='Part 2')
part_3 = Part.objects.get(name='Part 3')
part_4 = Part.objects.get(name='Part 4')
self.assertEqual(part_1.units, 'mm')
self.assertEqual(part_2.units, 'inch')
self.assertEqual(part_3.units, '')
self.assertEqual(part_4.units, 'percent')

View File

@@ -2,6 +2,7 @@
from django.core.exceptions import ObjectDoesNotExist
from djmoney.contrib.exchange.models import convert_money
from djmoney.money import Money
import common.models
@@ -25,10 +26,13 @@ class PartPricingTests(InvenTreeTestCase):
self.generate_exchange_rates()
# Create a new part for performing pricing calculations
# We will use 'metres' for the UOM here
# Some SupplierPart instances will have different units!
self.part = part.models.Part.objects.create(
name='PP',
description='A part with pricing',
assembly=True
description='A part with pricing, measured in metres',
assembly=True,
units='m'
)
def create_price_breaks(self):
@@ -44,8 +48,12 @@ class PartPricingTests(InvenTreeTestCase):
supplier=self.supplier_1,
part=self.part,
SKU='SUP_1',
pack_quantity='200 cm',
)
# Native pack quantity should be 2m
self.assertEqual(self.sp_1.pack_quantity_native, 2)
company.models.SupplierPriceBreak.objects.create(
part=self.sp_1,
quantity=1,
@@ -63,16 +71,22 @@ class PartPricingTests(InvenTreeTestCase):
supplier=self.supplier_2,
part=self.part,
SKU='SUP_2',
pack_size=2.5,
pack_quantity='2.5',
)
# Native pack quantity should be 2.5m
self.assertEqual(self.sp_2.pack_quantity_native, 2.5)
self.sp_3 = company.models.SupplierPart.objects.create(
supplier=self.supplier_2,
part=self.part,
SKU='SUP_3',
pack_size=10
pack_quantity='10 inches',
)
# Native pack quantity should be 0.254m
self.assertEqual(self.sp_3.pack_quantity_native, 0.254)
company.models.SupplierPriceBreak.objects.create(
part=self.sp_2,
quantity=5,
@@ -162,8 +176,8 @@ class PartPricingTests(InvenTreeTestCase):
pricing.update_pricing()
self.assertEqual(pricing.overall_min, Money('2.014667', 'USD'))
self.assertEqual(pricing.overall_max, Money('6.117647', 'USD'))
self.assertAlmostEqual(float(pricing.overall_min.amount), 2.015, places=2)
self.assertAlmostEqual(float(pricing.overall_max.amount), 3.06, places=2)
# Delete all supplier parts and re-calculate
self.part.supplier_parts.all().delete()
@@ -319,11 +333,11 @@ class PartPricingTests(InvenTreeTestCase):
# Add some line items to the order
# $5 AUD each
# $5 AUD each @ 2.5m per unit = $2 AUD per metre
line_1 = po.add_line_item(self.sp_2, quantity=10, purchase_price=Money(5, 'AUD'))
# $30 CAD each (but pack_size is 10, so really $3 CAD each)
line_2 = po.add_line_item(self.sp_3, quantity=5, purchase_price=Money(30, 'CAD'))
# $3 CAD each @ 10 inches per unit = $0.3 CAD per inch = $11.81 CAD per metre
line_2 = po.add_line_item(self.sp_3, quantity=5, purchase_price=Money(3, 'CAD'))
pricing.update_purchase_cost()
@@ -349,8 +363,20 @@ class PartPricingTests(InvenTreeTestCase):
pricing.update_purchase_cost()
self.assertEqual(pricing.purchase_cost_min, Money('1.333333', 'USD'))
self.assertEqual(pricing.purchase_cost_max, Money('1.764706', 'USD'))
min_cost_aud = convert_money(pricing.purchase_cost_min, 'AUD')
max_cost_cad = convert_money(pricing.purchase_cost_max, 'CAD')
# Min cost in AUD = $2 AUD per metre
self.assertAlmostEqual(float(min_cost_aud.amount), 2, places=2)
# Min cost in USD
self.assertAlmostEqual(float(pricing.purchase_cost_min.amount), 1.3333, places=2)
# Max cost in CAD = $11.81 CAD per metre
self.assertAlmostEqual(float(max_cost_cad.amount), 11.81, places=2)
# Max cost in USD
self.assertAlmostEqual(float(pricing.purchase_cost_max.amount), 6.95, places=2)
def test_delete_with_pricing(self):
"""Test for deleting a part which has pricing information"""