mirror of
https://github.com/inventree/InvenTree.git
synced 2025-12-16 17:28:11 +00:00
Build order consume (#8191)
* Adds "consumed" field to BuildLine model * Expose new field to serializer * Add "consumed" column to BuildLineTable * Boolean column tweaks * Increase consumed count when completing allocation * Add comment * Update migration * Add serializer for consuming build items * Improve build-line sub-table * Refactor BuildItem.complete_allocation method - Allow optional quantity to be specified - Adjust the allocated quantity when consuming * Perform consumption * Add "BuildConsume" API endpoint * Implement frontend form * Fixes for serializer * Enhance front-end form * Fix rendering of BuildLineTable * Further improve rendering * Bump API version * Update API description * Add option to consume by specifying a list of BuildLine objects * Add form to consume stock via BuildLine reference * Fix api_version * Fix backup colors * Fix typo * Fix migrations * Fix build forms * Forms fixes * Fix formatting * Fixes for BuildLineTable * Account for consumed stock in requirements calculation * Reduce API requirements for BuildLineTable * Docs updates * Updated playwright testing * Update src/frontend/src/forms/BuildForms.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/frontend/src/tables/build/BuildLineTable.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Add unit test for filters * Add functional tests * Tweak query count * Increase max query time for testing * adjust unit test again * Prevent consumption of "tracked" items * Adjust playwright tests * Fix table * Fix rendering --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,11 +1,15 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 385
|
||||
INVENTREE_API_VERSION = 386
|
||||
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
v386 -> 2025-08-11 : https://github.com/inventree/InvenTree/pull/8191
|
||||
- Adds "consumed" field to the BuildItem API
|
||||
- Adds API endpoint to consume stock against a BuildOrder
|
||||
|
||||
v385 -> 2025-08-15 : https://github.com/inventree/InvenTree/pull/10174
|
||||
- Adjust return type of PurchaseOrderReceive API serializer
|
||||
- Now returns list of of the created stock items when receiving
|
||||
|
||||
@@ -479,6 +479,14 @@ class BuildLineFilter(rest_filters.FilterSet):
|
||||
return queryset.filter(allocated__gte=F('quantity'))
|
||||
return queryset.filter(allocated__lt=F('quantity'))
|
||||
|
||||
consumed = rest_filters.BooleanFilter(label=_('Consumed'), method='filter_consumed')
|
||||
|
||||
def filter_consumed(self, queryset, name, value):
|
||||
"""Filter by whether each BuildLine is fully consumed."""
|
||||
if str2bool(value):
|
||||
return queryset.filter(consumed__gte=F('quantity'))
|
||||
return queryset.filter(consumed__lt=F('quantity'))
|
||||
|
||||
available = rest_filters.BooleanFilter(
|
||||
label=_('Available'), method='filter_available'
|
||||
)
|
||||
@@ -494,6 +502,7 @@ class BuildLineFilter(rest_filters.FilterSet):
|
||||
"""
|
||||
flt = Q(
|
||||
quantity__lte=F('allocated')
|
||||
+ F('consumed')
|
||||
+ F('available_stock')
|
||||
+ F('available_substitute_stock')
|
||||
+ F('available_variant_stock')
|
||||
@@ -504,7 +513,7 @@ class BuildLineFilter(rest_filters.FilterSet):
|
||||
return queryset.exclude(flt)
|
||||
|
||||
|
||||
class BuildLineEndpoint:
|
||||
class BuildLineMixin:
|
||||
"""Mixin class for BuildLine API endpoints."""
|
||||
|
||||
queryset = BuildLine.objects.all()
|
||||
@@ -553,7 +562,7 @@ class BuildLineEndpoint:
|
||||
)
|
||||
|
||||
|
||||
class BuildLineList(BuildLineEndpoint, DataExportViewMixin, ListCreateAPI):
|
||||
class BuildLineList(BuildLineMixin, DataExportViewMixin, ListCreateAPI):
|
||||
"""API endpoint for accessing a list of BuildLine objects."""
|
||||
|
||||
filterset_class = BuildLineFilter
|
||||
@@ -562,6 +571,7 @@ class BuildLineList(BuildLineEndpoint, DataExportViewMixin, ListCreateAPI):
|
||||
ordering_fields = [
|
||||
'part',
|
||||
'allocated',
|
||||
'consumed',
|
||||
'reference',
|
||||
'quantity',
|
||||
'consumable',
|
||||
@@ -605,7 +615,7 @@ class BuildLineList(BuildLineEndpoint, DataExportViewMixin, ListCreateAPI):
|
||||
return source_build
|
||||
|
||||
|
||||
class BuildLineDetail(BuildLineEndpoint, RetrieveUpdateDestroyAPI):
|
||||
class BuildLineDetail(BuildLineMixin, RetrieveUpdateDestroyAPI):
|
||||
"""API endpoint for detail view of a BuildLine object."""
|
||||
|
||||
def get_source_build(self) -> Build | None:
|
||||
@@ -734,6 +744,13 @@ class BuildAllocate(BuildOrderContextMixin, CreateAPI):
|
||||
serializer_class = build.serializers.BuildAllocationSerializer
|
||||
|
||||
|
||||
class BuildConsume(BuildOrderContextMixin, CreateAPI):
|
||||
"""API endpoint to consume stock against a build order."""
|
||||
|
||||
queryset = Build.objects.none()
|
||||
serializer_class = build.serializers.BuildConsumeSerializer
|
||||
|
||||
|
||||
class BuildIssue(BuildOrderContextMixin, CreateAPI):
|
||||
"""API endpoint for issuing a BuildOrder."""
|
||||
|
||||
@@ -953,6 +970,7 @@ build_api_urls = [
|
||||
'<int:pk>/',
|
||||
include([
|
||||
path('allocate/', BuildAllocate.as_view(), name='api-build-allocate'),
|
||||
path('consume/', BuildConsume.as_view(), name='api-build-consume'),
|
||||
path(
|
||||
'auto-allocate/',
|
||||
BuildAutoAllocate.as_view(),
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 4.2.15 on 2024-09-26 10:11
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('build', '0057_build_external'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='buildline',
|
||||
name='consumed',
|
||||
field=models.DecimalField(decimal_places=5, default=0, help_text='Quantity of consumed stock', max_digits=15, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Consumed'),
|
||||
),
|
||||
]
|
||||
@@ -1106,7 +1106,7 @@ class Build(
|
||||
|
||||
# Remove stock
|
||||
for item in items:
|
||||
item.complete_allocation(user)
|
||||
item.complete_allocation(user=user)
|
||||
|
||||
# Delete allocation
|
||||
items.all().delete()
|
||||
@@ -1151,7 +1151,7 @@ class Build(
|
||||
# Complete or discard allocations
|
||||
for build_item in allocated_items:
|
||||
if not discard_allocations:
|
||||
build_item.complete_allocation(user)
|
||||
build_item.complete_allocation(user=user)
|
||||
|
||||
# Delete allocations
|
||||
allocated_items.delete()
|
||||
@@ -1200,7 +1200,7 @@ class Build(
|
||||
|
||||
for build_item in allocated_items:
|
||||
# Complete the allocation of stock for that item
|
||||
build_item.complete_allocation(user)
|
||||
build_item.complete_allocation(user=user)
|
||||
|
||||
# Delete the BuildItem objects from the database
|
||||
allocated_items.all().delete()
|
||||
@@ -1569,6 +1569,7 @@ class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeMo
|
||||
build: Link to a Build object
|
||||
bom_item: Link to a BomItem object
|
||||
quantity: Number of units required for the Build
|
||||
consumed: Number of units which have been consumed against this line item
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
@@ -1614,6 +1615,15 @@ class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeMo
|
||||
help_text=_('Required quantity for build order'),
|
||||
)
|
||||
|
||||
consumed = models.DecimalField(
|
||||
decimal_places=5,
|
||||
max_digits=15,
|
||||
default=0,
|
||||
validators=[MinValueValidator(0)],
|
||||
verbose_name=_('Consumed'),
|
||||
help_text=_('Quantity of consumed stock'),
|
||||
)
|
||||
|
||||
@property
|
||||
def part(self):
|
||||
"""Return the sub_part reference from the link bom_item."""
|
||||
@@ -1645,6 +1655,10 @@ class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeMo
|
||||
"""Return True if this BuildLine is over-allocated."""
|
||||
return self.allocated_quantity() > self.quantity
|
||||
|
||||
def is_fully_consumed(self):
|
||||
"""Return True if this BuildLine is fully consumed."""
|
||||
return self.consumed >= self.quantity
|
||||
|
||||
|
||||
class BuildItem(InvenTree.models.InvenTreeMetadataModel):
|
||||
"""A BuildItem links multiple StockItem objects to a Build.
|
||||
@@ -1812,20 +1826,36 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel):
|
||||
return self.build_line.bom_item if self.build_line else None
|
||||
|
||||
@transaction.atomic
|
||||
def complete_allocation(self, user, notes=''):
|
||||
def complete_allocation(self, quantity=None, notes='', user=None):
|
||||
"""Complete the allocation of this BuildItem into the output stock item.
|
||||
|
||||
Arguments:
|
||||
quantity: The quantity to allocate (default is the full quantity)
|
||||
notes: Additional notes to add to the transaction
|
||||
user: The user completing the allocation
|
||||
|
||||
- If the referenced part is trackable, the stock item will be *installed* into the build output
|
||||
- If the referenced part is *not* trackable, the stock item will be *consumed* by the build order
|
||||
|
||||
TODO: This is quite expensive (in terms of number of database hits) - and requires some thought
|
||||
TODO: Revisit, and refactor!
|
||||
|
||||
"""
|
||||
# If the quantity is not provided, use the quantity of this BuildItem
|
||||
if quantity is None:
|
||||
quantity = self.quantity
|
||||
|
||||
item = self.stock_item
|
||||
|
||||
# Ensure we are not allocating more than available
|
||||
if quantity > item.quantity:
|
||||
raise ValidationError({
|
||||
'quantity': _('Allocated quantity exceeds available stock quantity')
|
||||
})
|
||||
|
||||
# Split the allocated stock if there are more available than allocated
|
||||
if item.quantity > self.quantity:
|
||||
item = item.splitStock(self.quantity, None, user, notes=notes)
|
||||
if item.quantity > quantity:
|
||||
item = item.splitStock(quantity, None, user, notes=notes)
|
||||
|
||||
# For a trackable part, special consideration needed!
|
||||
if item.part.trackable:
|
||||
@@ -1835,7 +1865,7 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel):
|
||||
|
||||
# Install the stock item into the output
|
||||
self.install_into.installStockItem(
|
||||
item, self.quantity, user, notes, build=self.build
|
||||
item, quantity, user, notes, build=self.build
|
||||
)
|
||||
|
||||
else:
|
||||
@@ -1851,6 +1881,18 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel):
|
||||
deltas={'buildorder': self.build.pk, 'quantity': float(item.quantity)},
|
||||
)
|
||||
|
||||
# Increase the "consumed" count for the associated BuildLine
|
||||
self.build_line.consumed += quantity
|
||||
self.build_line.save()
|
||||
|
||||
# Decrease the allocated quantity
|
||||
self.quantity = max(0, self.quantity - quantity)
|
||||
|
||||
if self.quantity <= 0:
|
||||
self.delete()
|
||||
else:
|
||||
self.save()
|
||||
|
||||
build_line = models.ForeignKey(
|
||||
BuildLine, on_delete=models.CASCADE, null=True, related_name='allocations'
|
||||
)
|
||||
|
||||
@@ -171,6 +171,9 @@ class BuildSerializer(
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Determine if extra serializer fields are required."""
|
||||
part_detail = kwargs.pop('part_detail', True)
|
||||
user_detail = kwargs.pop('user_detail', True)
|
||||
project_code_detail = kwargs.pop('project_code_detail', True)
|
||||
|
||||
kwargs.pop('create', False)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -181,6 +184,15 @@ class BuildSerializer(
|
||||
if not part_detail:
|
||||
self.fields.pop('part_detail', None)
|
||||
|
||||
if not user_detail:
|
||||
self.fields.pop('issued_by_detail', None)
|
||||
self.fields.pop('responsible_detail', None)
|
||||
|
||||
if not project_code_detail:
|
||||
self.fields.pop('project_code', None)
|
||||
self.fields.pop('project_code_label', None)
|
||||
self.fields.pop('project_code_detail', None)
|
||||
|
||||
def validate_reference(self, reference):
|
||||
"""Custom validation for the Build reference field."""
|
||||
# Ensure the reference matches the required pattern
|
||||
@@ -1000,7 +1012,7 @@ class BuildAllocationItemSerializer(serializers.Serializer):
|
||||
|
||||
|
||||
class BuildAllocationSerializer(serializers.Serializer):
|
||||
"""DRF serializer for allocation stock items against a build order."""
|
||||
"""Serializer for allocating stock items against a build order."""
|
||||
|
||||
class Meta:
|
||||
"""Serializer metaclass."""
|
||||
@@ -1302,6 +1314,8 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
||||
'build',
|
||||
'bom_item',
|
||||
'quantity',
|
||||
'consumed',
|
||||
'allocations',
|
||||
'part',
|
||||
# Build detail fields
|
||||
'build_reference',
|
||||
@@ -1353,6 +1367,7 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
||||
|
||||
if not part_detail:
|
||||
self.fields.pop('part_detail', None)
|
||||
self.fields.pop('part_category_name', None)
|
||||
|
||||
if not build_detail:
|
||||
self.fields.pop('build_detail', None)
|
||||
@@ -1379,7 +1394,7 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
||||
read_only=True,
|
||||
)
|
||||
|
||||
allocations = BuildItemSerializer(many=True, read_only=True)
|
||||
allocations = BuildItemSerializer(many=True, read_only=True, build_detail=False)
|
||||
|
||||
# BOM item info fields
|
||||
reference = serializers.CharField(
|
||||
@@ -1405,6 +1420,7 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
||||
)
|
||||
|
||||
quantity = serializers.FloatField(label=_('Quantity'))
|
||||
consumed = serializers.FloatField(label=_('Consumed'))
|
||||
|
||||
bom_item = serializers.PrimaryKeyRelatedField(label=_('BOM Item'), read_only=True)
|
||||
|
||||
@@ -1437,17 +1453,23 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
||||
read_only=True,
|
||||
pricing=False,
|
||||
)
|
||||
|
||||
build_detail = BuildSerializer(
|
||||
label=_('Build'),
|
||||
source='build',
|
||||
part_detail=False,
|
||||
many=False,
|
||||
read_only=True,
|
||||
allow_null=True,
|
||||
part_detail=False,
|
||||
user_detail=False,
|
||||
project_code_detail=False,
|
||||
)
|
||||
|
||||
# Annotated (calculated) fields
|
||||
allocated = serializers.FloatField(label=_('Allocated Stock'), read_only=True)
|
||||
|
||||
# Total quantity of allocated stock
|
||||
allocated = serializers.FloatField(label=_('Allocated'), read_only=True)
|
||||
|
||||
on_order = serializers.FloatField(label=_('On Order'), read_only=True)
|
||||
in_production = serializers.FloatField(label=_('In Production'), read_only=True)
|
||||
scheduled_to_build = serializers.FloatField(
|
||||
@@ -1498,6 +1520,9 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
||||
'allocations__stock_item',
|
||||
'allocations__stock_item__part',
|
||||
'allocations__stock_item__location',
|
||||
'bom_item',
|
||||
'bom_item__part',
|
||||
'bom_item__sub_part',
|
||||
'bom_item__sub_part__stock_items',
|
||||
'bom_item__sub_part__stock_items__allocations',
|
||||
'bom_item__sub_part__stock_items__sales_order_allocations',
|
||||
@@ -1518,7 +1543,6 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
||||
'build__destination',
|
||||
'build__take_from',
|
||||
'build__completed_by',
|
||||
'build__issued_by',
|
||||
'build__sales_order',
|
||||
'build__parent',
|
||||
'build__notes',
|
||||
@@ -1692,3 +1716,177 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class BuildConsumeAllocationSerializer(serializers.Serializer):
|
||||
"""Serializer for an individual BuildItem to be consumed against a BuildOrder."""
|
||||
|
||||
class Meta:
|
||||
"""Serializer metaclass."""
|
||||
|
||||
fields = ['build_item', 'quantity']
|
||||
|
||||
build_item = serializers.PrimaryKeyRelatedField(
|
||||
queryset=BuildItem.objects.all(), many=False, allow_null=False, required=True
|
||||
)
|
||||
|
||||
quantity = serializers.DecimalField(
|
||||
max_digits=15, decimal_places=5, min_value=Decimal(0), required=True
|
||||
)
|
||||
|
||||
def validate_quantity(self, quantity):
|
||||
"""Perform validation on the 'quantity' field."""
|
||||
if quantity <= 0:
|
||||
raise ValidationError(_('Quantity must be greater than zero'))
|
||||
|
||||
return quantity
|
||||
|
||||
def validate(self, data):
|
||||
"""Validate the serializer data."""
|
||||
data = super().validate(data)
|
||||
|
||||
build_item = data['build_item']
|
||||
quantity = data['quantity']
|
||||
|
||||
if quantity > build_item.quantity:
|
||||
raise ValidationError({
|
||||
'quantity': _('Consumed quantity exceeds allocated quantity')
|
||||
})
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class BuildConsumeLineItemSerializer(serializers.Serializer):
|
||||
"""Serializer for an individual BuildLine to be consumed against a BuildOrder."""
|
||||
|
||||
class Meta:
|
||||
"""Serializer metaclass."""
|
||||
|
||||
fields = ['build_line']
|
||||
|
||||
build_line = serializers.PrimaryKeyRelatedField(
|
||||
queryset=BuildLine.objects.all(), many=False, allow_null=False, required=True
|
||||
)
|
||||
|
||||
|
||||
class BuildConsumeSerializer(serializers.Serializer):
|
||||
"""Serializer for consuming allocations against a BuildOrder.
|
||||
|
||||
- Consumes allocated stock items, increasing the 'consumed' field for each BuildLine.
|
||||
- Stock can be consumed by passing either a list of BuildItem objects, or a list of BuildLine objects.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Serializer metaclass."""
|
||||
|
||||
fields = ['items', 'lines', 'notes']
|
||||
|
||||
items = BuildConsumeAllocationSerializer(many=True, required=False)
|
||||
|
||||
lines = BuildConsumeLineItemSerializer(many=True, required=False)
|
||||
|
||||
notes = serializers.CharField(
|
||||
label=_('Notes'),
|
||||
help_text=_('Optional notes for the stock consumption'),
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
)
|
||||
|
||||
def validate_items(self, items):
|
||||
"""Validate the BuildItem list passed to the serializer."""
|
||||
build_order = self.context['build']
|
||||
|
||||
seen = set()
|
||||
|
||||
for item in items:
|
||||
build_item = item['build_item']
|
||||
|
||||
# BuildItem must point to the correct build order
|
||||
if build_item.build != build_order:
|
||||
raise ValidationError(
|
||||
_('Build item must point to the correct build order')
|
||||
)
|
||||
|
||||
# Prevent duplicate item allocation
|
||||
if build_item.pk in seen:
|
||||
raise ValidationError(_('Duplicate build item allocation'))
|
||||
|
||||
seen.add(build_item.pk)
|
||||
|
||||
return items
|
||||
|
||||
def validate_lines(self, lines):
|
||||
"""Validate the BuildLine list passed to the serializer."""
|
||||
build_order = self.context['build']
|
||||
|
||||
seen = set()
|
||||
|
||||
for line in lines:
|
||||
build_line = line['build_line']
|
||||
|
||||
# BuildLine must point to the correct build order
|
||||
if build_line.build != build_order:
|
||||
raise ValidationError(
|
||||
_('Build line must point to the correct build order')
|
||||
)
|
||||
|
||||
# Prevent duplicate line allocation
|
||||
if build_line.pk in seen:
|
||||
raise ValidationError(_('Duplicate build line allocation'))
|
||||
|
||||
seen.add(build_line.pk)
|
||||
|
||||
return lines
|
||||
|
||||
def validate(self, data):
|
||||
"""Validate the serializer data."""
|
||||
items = data.get('items', [])
|
||||
lines = data.get('lines', [])
|
||||
|
||||
if len(items) == 0 and len(lines) == 0:
|
||||
raise ValidationError(_('At least one item or line must be provided'))
|
||||
|
||||
return data
|
||||
|
||||
def save(self):
|
||||
"""Perform the stock consumption step."""
|
||||
data = self.validated_data
|
||||
request = self.context.get('request')
|
||||
notes = data.get('notes', '')
|
||||
|
||||
items = data.get('items', [])
|
||||
lines = data.get('lines', [])
|
||||
|
||||
with transaction.atomic():
|
||||
# Process the provided BuildItem objects
|
||||
for item in items:
|
||||
build_item = item['build_item']
|
||||
quantity = item['quantity']
|
||||
|
||||
if build_item.install_into:
|
||||
# If the build item is tracked into an output, we do not consume now
|
||||
# Instead, it gets consumed when the output is completed
|
||||
continue
|
||||
|
||||
build_item.complete_allocation(
|
||||
quantity=quantity,
|
||||
notes=notes,
|
||||
user=request.user if request else None,
|
||||
)
|
||||
|
||||
# Process the provided BuildLine objects
|
||||
for line in lines:
|
||||
build_line = line['build_line']
|
||||
|
||||
# In this case, perform full consumption of all allocated stock
|
||||
for item in build_line.allocations.all():
|
||||
# If the build item is tracked into an output, we do not consume now
|
||||
# Instead, it gets consumed when the output is completed
|
||||
if item.install_into:
|
||||
continue
|
||||
|
||||
item.complete_allocation(
|
||||
quantity=item.quantity,
|
||||
notes=notes,
|
||||
user=request.user if request else None,
|
||||
)
|
||||
|
||||
@@ -990,11 +990,12 @@ class BuildOverallocationTest(BuildAPITest):
|
||||
self.assertEqual(si.quantity, oq)
|
||||
|
||||
# Accept overallocated stock
|
||||
# TODO: (2025-07-16) Look into optimizing this API query to reduce DB hits
|
||||
self.post(
|
||||
self.url,
|
||||
{'accept_overallocated': 'accept'},
|
||||
expected_code=201,
|
||||
max_query_count=375,
|
||||
max_query_count=400,
|
||||
)
|
||||
|
||||
self.build.refresh_from_db()
|
||||
@@ -1009,11 +1010,12 @@ class BuildOverallocationTest(BuildAPITest):
|
||||
|
||||
def test_overallocated_can_trim(self):
|
||||
"""Test build order will trim/de-allocate overallocated stock when requested."""
|
||||
# TODO: (2025-07-16) Look into optimizing this API query to reduce DB hits
|
||||
self.post(
|
||||
self.url,
|
||||
{'accept_overallocated': 'trim'},
|
||||
expected_code=201,
|
||||
max_query_count=400,
|
||||
max_query_count=450,
|
||||
)
|
||||
|
||||
# Note: Large number of queries is due to pricing recalculation for each stock item
|
||||
@@ -1323,3 +1325,177 @@ class BuildLineTests(BuildAPITest):
|
||||
self.assertGreater(n_f, 0)
|
||||
|
||||
self.assertEqual(n_t + n_f, BuildLine.objects.count())
|
||||
|
||||
def test_filter_consumed(self):
|
||||
"""Filter for the 'consumed' status."""
|
||||
# Create a new build order
|
||||
assembly = Part.objects.create(
|
||||
name='Test Assembly',
|
||||
description='Test Assembly Description',
|
||||
assembly=True,
|
||||
trackable=True,
|
||||
)
|
||||
|
||||
for idx in range(3):
|
||||
component = Part.objects.create(
|
||||
name=f'Test Component {idx}',
|
||||
description=f'Test Component Description {idx}',
|
||||
trackable=True,
|
||||
component=True,
|
||||
)
|
||||
|
||||
# Create a BOM item for the assembly
|
||||
BomItem.objects.create(part=assembly, sub_part=component, quantity=10)
|
||||
|
||||
build = Build.objects.create(
|
||||
part=assembly, reference='BO-12348', quantity=10, title='Test Build'
|
||||
)
|
||||
|
||||
lines = list(build.build_lines.all())
|
||||
self.assertEqual(len(lines), 3)
|
||||
|
||||
for line in lines:
|
||||
self.assertEqual(line.quantity, 100)
|
||||
self.assertEqual(line.consumed, 0)
|
||||
|
||||
# Artificially "consume" some of the build lines
|
||||
lines[0].consumed = 1
|
||||
lines[0].save()
|
||||
|
||||
lines[1].consumed = 50
|
||||
lines[1].save()
|
||||
|
||||
lines[2].consumed = 100
|
||||
lines[2].save()
|
||||
|
||||
url = reverse('api-build-line-list')
|
||||
|
||||
response = self.get(url, {'build': build.pk, 'consumed': True})
|
||||
|
||||
self.assertEqual(len(response.data), 1)
|
||||
self.assertEqual(response.data[0]['pk'], lines[2].pk)
|
||||
self.assertEqual(response.data[0]['consumed'], 100)
|
||||
self.assertEqual(response.data[0]['quantity'], 100)
|
||||
|
||||
response = self.get(url, {'build': build.pk, 'consumed': False})
|
||||
|
||||
self.assertEqual(len(response.data), 2)
|
||||
self.assertEqual(response.data[0]['pk'], lines[0].pk)
|
||||
self.assertEqual(response.data[0]['consumed'], 1)
|
||||
self.assertEqual(response.data[0]['quantity'], 100)
|
||||
self.assertEqual(response.data[1]['pk'], lines[1].pk)
|
||||
self.assertEqual(response.data[1]['consumed'], 50)
|
||||
self.assertEqual(response.data[1]['quantity'], 100)
|
||||
|
||||
# Check that the 'available' filter works correctly also when lines are partially consumed
|
||||
for line in lines:
|
||||
StockItem.objects.create(part=line.bom_item.sub_part, quantity=60)
|
||||
|
||||
# Note: The max_query_time is bumped up here, as postgresql backend has some strange issues (only during testing)
|
||||
response = self.get(
|
||||
url, {'build': build.pk, 'available': True}, max_query_time=15
|
||||
)
|
||||
|
||||
# We expect 2 lines to have "available" stock
|
||||
self.assertEqual(len(response.data), 2)
|
||||
|
||||
# Note: The max_query_time is bumped up here, as postgresql backend has some strange issues (only during testing)
|
||||
response = self.get(
|
||||
url, {'build': build.pk, 'available': False}, max_query_time=15
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 1)
|
||||
self.assertEqual(response.data[0]['pk'], lines[0].pk)
|
||||
|
||||
|
||||
class BuildConsumeTest(BuildAPITest):
|
||||
"""Test consuming allocated stock."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
super().setUp()
|
||||
|
||||
self.assembly = Part.objects.create(
|
||||
name='Test Assembly', description='Test Assembly Description', assembly=True
|
||||
)
|
||||
|
||||
self.components = [
|
||||
Part.objects.create(
|
||||
name=f'Test Component {i}',
|
||||
description=f'Test Component Description {i}',
|
||||
component=True,
|
||||
)
|
||||
for i in range(3)
|
||||
]
|
||||
|
||||
self.stock_items = [
|
||||
StockItem.objects.create(part=component, quantity=1000)
|
||||
for component in self.components
|
||||
]
|
||||
|
||||
self.bom_items = [
|
||||
BomItem.objects.create(part=self.assembly, sub_part=component, quantity=10)
|
||||
for component in self.components
|
||||
]
|
||||
|
||||
self.build = Build.objects.create(
|
||||
part=self.assembly, reference='BO-12349', quantity=10, title='Test Build'
|
||||
)
|
||||
|
||||
def allocate_stock(self):
|
||||
"""Allocate stock items to the build."""
|
||||
data = {
|
||||
'items': [
|
||||
{'build_line': line.pk, 'stock_item': si.pk, 'quantity': 100}
|
||||
for line, si in zip(self.build.build_lines.all(), self.stock_items)
|
||||
]
|
||||
}
|
||||
|
||||
self.post(
|
||||
reverse('api-build-allocate', kwargs={'pk': self.build.pk}),
|
||||
data,
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
def test_consume_lines(self):
|
||||
"""Test consuming against build lines."""
|
||||
self.allocate_stock()
|
||||
|
||||
self.assertEqual(self.build.allocated_stock.count(), 3)
|
||||
self.assertEqual(self.build.consumed_stock.count(), 0)
|
||||
url = reverse('api-build-consume', kwargs={'pk': self.build.pk})
|
||||
|
||||
data = {
|
||||
'lines': [{'build_line': line.pk} for line in self.build.build_lines.all()]
|
||||
}
|
||||
|
||||
self.post(url, data, expected_code=201)
|
||||
|
||||
self.assertEqual(self.build.allocated_stock.count(), 0)
|
||||
self.assertEqual(self.build.consumed_stock.count(), 3)
|
||||
|
||||
for line in self.build.build_lines.all():
|
||||
self.assertEqual(line.consumed, 100)
|
||||
|
||||
def test_consume_items(self):
|
||||
"""Test consuming against build items."""
|
||||
self.allocate_stock()
|
||||
|
||||
self.assertEqual(self.build.allocated_stock.count(), 3)
|
||||
self.assertEqual(self.build.consumed_stock.count(), 0)
|
||||
url = reverse('api-build-consume', kwargs={'pk': self.build.pk})
|
||||
|
||||
data = {
|
||||
'items': [
|
||||
{'build_item': item.pk, 'quantity': item.quantity}
|
||||
for item in self.build.allocated_stock.all()
|
||||
]
|
||||
}
|
||||
|
||||
self.post(url, data, expected_code=201)
|
||||
|
||||
self.assertEqual(self.build.allocated_stock.count(), 0)
|
||||
self.assertEqual(self.build.consumed_stock.count(), 3)
|
||||
|
||||
for line in self.build.build_lines.all():
|
||||
self.assertEqual(line.consumed, 100)
|
||||
|
||||
@@ -1400,9 +1400,10 @@ class Part(
|
||||
|
||||
# Match BOM item to build
|
||||
for bom_item in bom_items:
|
||||
build_quantity = build.quantity * bom_item.quantity
|
||||
build_line = build.build_lines.filter(bom_item=bom_item).first()
|
||||
|
||||
quantity += build_quantity
|
||||
line_quantity = max(0, build_line.quantity - build_line.consumed)
|
||||
quantity += line_quantity
|
||||
|
||||
return quantity
|
||||
|
||||
|
||||
Reference in New Issue
Block a user