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:
@@ -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"""
|
||||
|
||||
Reference in New Issue
Block a user