mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-18 04:55:44 +00:00
Build Order Updates (#4855)
* Add new BuildLine model - Represents an instance of a BOM item against a BuildOrder * Create BuildLine instances automatically When a new Build is created, automatically generate new BuildLine items * Improve logic for handling exchange rate backends * logic fixes * Adds API endpoints Add list and detail API endpoints for new BuildLine model * update users/models.py - Add new model to roles definition * bulk-create on auto_allocate Save database hits by performing a bulk-create * Add skeleton data migration * Create BuildLines for existing orders * Working on building out BuildLine table * Adds link for "BuildLine" to "BuildItem" - A "BuildItem" will now be tracked against a BuildLine - Not tracked directly against a build - Not tracked directly against a BomItem - Add schema migration - Add data migration to update links * Adjust migration 0045 - bom_item and build fields are about to be removed - Set them to "nullable" so the data doesn't get removed * Remove old fields from BuildItem model - build fk - bom_item fk - A lot of other required changes too * Update BuildLine.bom_item field - Delete the BuildLine if the BomItem is removed - This is closer to current behaviour * Cleanup for Build model - tracked_bom_items -> tracked_line_items - untracked_bom_items -> tracked_bom_items - remove build.can_complete - move bom_item specific methods to the BuildLine model - Cleanup / consolidation * front-end work - Update javascript - Cleanup HTML templates * Add serializer annotation and filtering - Annotate 'allocated' quantity - Filter by allocated / trackable / optional / consumable * Make table sortable * Add buttons * Add callback for building new stock * Fix Part annotation * Adds callback to order parts * Allocation works again * template cleanup * Fix allocate / unallocate actions - Also turns out "unallocate" is not a word.. * auto-allocate works again * Fix call to build.is_over_allocated * Refactoring updates * Bump API version * Cleaner implementation of allocation sub-table * Fix rendering in build output table * Improvements to StockItem list API - Refactor very old code - Add option to include test results to queryset * Add TODO for later me * Fix for serializers.py * Working on cleaner implementation of build output table * Add function to determine if a single output is fully allocated * Updates to build.js - Button callbacks - Table rendering * Revert previous changes to build.serializers.py * Fix for forms.js * Rearrange code in build.js * Rebuild "allocated lines" for output table * Fix allocation calculation * Show or hide column for tracked parts * Improve debug messages * Refactor "loadBuildLineTable" - Allow it to also be used as output sub-table * Refactor "completed tests" column * Remove old javascript - Cleans up a *lot* of crusty old code * Annotate the available stock quantity to BuildLine serializer - Similar pattern to BomItem serializer - Needs refactoring in the future * Update available column * Fix build allocation table - Bug fix - Make pretty * linting fixes * Allow sorting by available stock * Tweak for "required tests" column * Bug fix for completing a build output * Fix for consumable stock * Fix for trim_allocated_stock * Fix for creating new build * Migration fix - Ensure initial django_q migrations are applied - Why on earth is this failing now? * Catch exception * Update for exception handling * Update migrations - Ensure inventreesetting is added * Catch all exceptions when getting default currency code * Bug fix for currency exchange rates update * Working on unit tests * Unit test fixes * More work on unit tests * Use bulk_create in unit test * Update required quantity when a BuildOrder is saved * Tweak overage display in BOM table * Fix icon in BOM table * Fix spelling error * More unit test fixes * Build reports - Add line_items - Update docs - Cleanup * Reimplement is_partially_allocated method * Update docs about overage * Unit testing for data migration * Add "required_for_build_orders" annotation - Makes API query *much* faster now - remove old "required_parts_to_complete_build" method - Cleanup part API filter code * Adjust order of fixture loading * Fix unit test * Prevent "schedule_pricing_update" in unit tests - Should cut down on DB hits significantly * Unit test updates * Improvements for unit test - Don't hard-code pk values - postgresql no likey * Better unit test
This commit is contained in:
@ -350,7 +350,10 @@ class PartTestTemplateDetail(RetrieveUpdateDestroyAPI):
|
||||
|
||||
|
||||
class PartTestTemplateList(ListCreateAPI):
|
||||
"""API endpoint for listing (and creating) a PartTestTemplate."""
|
||||
"""API endpoint for listing (and creating) a PartTestTemplate.
|
||||
|
||||
TODO: Add filterset class for this view
|
||||
"""
|
||||
|
||||
queryset = PartTestTemplate.objects.all()
|
||||
serializer_class = part_serializers.PartTestTemplateSerializer
|
||||
@ -945,6 +948,28 @@ class PartFilter(rest_filters.FilterSet):
|
||||
else:
|
||||
return queryset.filter(last_stocktake=None)
|
||||
|
||||
stock_to_build = rest_filters.BooleanFilter(label='Required for Build Order', method='filter_stock_to_build')
|
||||
|
||||
def filter_stock_to_build(self, queryset, name, value):
|
||||
"""Filter the queryset based on whether part stock is required for a pending BuildOrder"""
|
||||
|
||||
if str2bool(value):
|
||||
# Return parts which are required for a build order, but have not yet been allocated
|
||||
return queryset.filter(required_for_build_orders__gt=F('allocated_to_build_orders'))
|
||||
else:
|
||||
# Return parts which are not required for a build order, or have already been allocated
|
||||
return queryset.filter(required_for_build_orders__lte=F('allocated_to_build_orders'))
|
||||
|
||||
depleted_stock = rest_filters.BooleanFilter(label='Depleted Stock', method='filter_depleted_stock')
|
||||
|
||||
def filter_deployed_stock(self, queryset, name, value):
|
||||
"""Filter the queryset based on whether the part is fully depleted of stock"""
|
||||
|
||||
if str2bool(value):
|
||||
return queryset.filter(Q(in_stock=0) & ~Q(stock_item_count=0))
|
||||
else:
|
||||
return queryset.exclude(Q(in_stock=0) & ~Q(stock_item_count=0))
|
||||
|
||||
is_template = rest_filters.BooleanFilter()
|
||||
|
||||
assembly = rest_filters.BooleanFilter()
|
||||
@ -1181,32 +1206,6 @@ class PartList(PartMixin, APIDownloadMixin, ListCreateAPI):
|
||||
except (ValueError, PartCategory.DoesNotExist):
|
||||
pass
|
||||
|
||||
# Filer by 'depleted_stock' status -> has no stock and stock items
|
||||
depleted_stock = params.get('depleted_stock', None)
|
||||
|
||||
if depleted_stock is not None:
|
||||
depleted_stock = str2bool(depleted_stock)
|
||||
|
||||
if depleted_stock:
|
||||
queryset = queryset.filter(Q(in_stock=0) & ~Q(stock_item_count=0))
|
||||
|
||||
# Filter by "parts which need stock to complete build"
|
||||
stock_to_build = params.get('stock_to_build', None)
|
||||
|
||||
# TODO: This is super expensive, database query wise...
|
||||
# TODO: Need to figure out a cheaper way of making this filter query
|
||||
|
||||
if stock_to_build is not None:
|
||||
# Get active builds
|
||||
builds = Build.objects.filter(status__in=BuildStatusGroups.ACTIVE_CODES)
|
||||
# Store parts with builds needing stock
|
||||
parts_needed_to_complete_builds = []
|
||||
# Filter required parts
|
||||
for build in builds:
|
||||
parts_needed_to_complete_builds += [part.pk for part in build.required_parts_to_complete_build]
|
||||
|
||||
queryset = queryset.filter(pk__in=parts_needed_to_complete_builds)
|
||||
|
||||
queryset = self.filter_parameteric_data(queryset)
|
||||
|
||||
return queryset
|
||||
|
@ -99,6 +99,28 @@ def annotate_total_stock(reference: str = ''):
|
||||
)
|
||||
|
||||
|
||||
def annotate_build_order_requirements(reference: str = ''):
|
||||
"""Annotate the total quantity of each part required for build orders.
|
||||
|
||||
- Only interested in 'active' build orders
|
||||
- We are looking for any BuildLine items which required this part (bom_item.sub_part)
|
||||
- We are interested in the 'quantity' of each BuildLine item
|
||||
|
||||
"""
|
||||
|
||||
# Active build orders only
|
||||
build_filter = Q(build__status__in=BuildStatusGroups.ACTIVE_CODES)
|
||||
|
||||
return Coalesce(
|
||||
SubquerySum(
|
||||
f'{reference}used_in__build_lines__quantity',
|
||||
filter=build_filter,
|
||||
),
|
||||
Decimal(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
|
||||
|
||||
def annotate_build_order_allocations(reference: str = ''):
|
||||
"""Annotate the total quantity of each part allocated to build orders:
|
||||
|
||||
@ -112,7 +134,7 @@ def annotate_build_order_allocations(reference: str = ''):
|
||||
"""
|
||||
|
||||
# Build filter only returns 'active' build orders
|
||||
build_filter = Q(build__status__in=BuildStatusGroups.ACTIVE_CODES)
|
||||
build_filter = Q(build_line__build__status__in=BuildStatusGroups.ACTIVE_CODES)
|
||||
|
||||
return Coalesce(
|
||||
SubquerySum(
|
||||
|
@ -100,7 +100,7 @@
|
||||
salable: true
|
||||
purchaseable: false
|
||||
category: 7
|
||||
active: False
|
||||
active: True
|
||||
IPN: BOB
|
||||
revision: A2
|
||||
tree_id: 0
|
||||
|
@ -8,6 +8,7 @@ import common.settings
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('common', '0004_inventreesetting'),
|
||||
('part', '0054_auto_20201109_1246'),
|
||||
]
|
||||
|
||||
|
@ -10,6 +10,7 @@ import re
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinLengthValidator, MinValueValidator
|
||||
@ -1747,7 +1748,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel)
|
||||
|
||||
return pricing
|
||||
|
||||
def schedule_pricing_update(self, create: bool = False):
|
||||
def schedule_pricing_update(self, create: bool = False, test: bool = False):
|
||||
"""Helper function to schedule a pricing update.
|
||||
|
||||
Importantly, catches any errors which may occur during deletion of related objects,
|
||||
@ -1757,6 +1758,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel)
|
||||
|
||||
Arguments:
|
||||
create: Whether or not a new PartPricing object should be created if it does not already exist
|
||||
test: Whether or not the pricing update is allowed during unit tests
|
||||
"""
|
||||
|
||||
try:
|
||||
@ -1768,7 +1770,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel)
|
||||
pricing = self.pricing
|
||||
|
||||
if create or pricing.pk:
|
||||
pricing.schedule_for_update()
|
||||
pricing.schedule_for_update(test=test)
|
||||
except IntegrityError:
|
||||
# If this part instance has been deleted,
|
||||
# some post-delete or post-save signals may still be fired
|
||||
@ -2360,11 +2362,15 @@ class PartPricing(common.models.MetaMixin):
|
||||
|
||||
return result
|
||||
|
||||
def schedule_for_update(self, counter: int = 0):
|
||||
def schedule_for_update(self, counter: int = 0, test: bool = False):
|
||||
"""Schedule this pricing to be updated"""
|
||||
|
||||
import InvenTree.ready
|
||||
|
||||
# If we are running within CI, only schedule the update if the test flag is set
|
||||
if settings.TESTING and not test:
|
||||
return
|
||||
|
||||
# If importing data, skip pricing update
|
||||
if InvenTree.ready.isImportingData():
|
||||
return
|
||||
@ -3720,7 +3726,7 @@ class BomItem(DataImportMixin, MetadataMixin, models.Model):
|
||||
|
||||
Includes:
|
||||
- The referenced sub_part
|
||||
- Any directly specvified substitute parts
|
||||
- Any directly specified substitute parts
|
||||
- If allow_variants is True, all variants of sub_part
|
||||
"""
|
||||
# Set of parts we will allow
|
||||
@ -3741,11 +3747,6 @@ class BomItem(DataImportMixin, MetadataMixin, models.Model):
|
||||
valid_parts = []
|
||||
|
||||
for p in parts:
|
||||
|
||||
# Inactive parts cannot be 'auto allocated'
|
||||
if not p.active:
|
||||
continue
|
||||
|
||||
# Trackable status must be the same as the sub_part
|
||||
if p.trackable != self.sub_part.trackable:
|
||||
continue
|
||||
@ -3990,10 +3991,10 @@ class BomItem(DataImportMixin, MetadataMixin, models.Model):
|
||||
# Base quantity requirement
|
||||
base_quantity = self.quantity * build_quantity
|
||||
|
||||
# Overage requiremet
|
||||
ovrg_quantity = self.get_overage_quantity(base_quantity)
|
||||
# Overage requirement
|
||||
overage_quantity = self.get_overage_quantity(base_quantity)
|
||||
|
||||
required = float(base_quantity) + float(ovrg_quantity)
|
||||
required = float(base_quantity) + float(overage_quantity)
|
||||
|
||||
return required
|
||||
|
||||
|
@ -410,8 +410,6 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize
|
||||
partial = True
|
||||
fields = [
|
||||
'active',
|
||||
'allocated_to_build_orders',
|
||||
'allocated_to_sales_orders',
|
||||
'assembly',
|
||||
'barcode_hash',
|
||||
'category',
|
||||
@ -423,9 +421,6 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize
|
||||
'description',
|
||||
'full_name',
|
||||
'image',
|
||||
'in_stock',
|
||||
'ordering',
|
||||
'building',
|
||||
'IPN',
|
||||
'is_template',
|
||||
'keywords',
|
||||
@ -441,20 +436,28 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize
|
||||
'revision',
|
||||
'salable',
|
||||
'starred',
|
||||
'stock_item_count',
|
||||
'suppliers',
|
||||
'thumbnail',
|
||||
'total_in_stock',
|
||||
'trackable',
|
||||
'unallocated_stock',
|
||||
'units',
|
||||
'variant_of',
|
||||
'variant_stock',
|
||||
'virtual',
|
||||
'pricing_min',
|
||||
'pricing_max',
|
||||
'responsible',
|
||||
|
||||
# Annotated fields
|
||||
'allocated_to_build_orders',
|
||||
'allocated_to_sales_orders',
|
||||
'building',
|
||||
'in_stock',
|
||||
'ordering',
|
||||
'required_for_build_orders',
|
||||
'stock_item_count',
|
||||
'suppliers',
|
||||
'total_in_stock',
|
||||
'unallocated_stock',
|
||||
'variant_stock',
|
||||
|
||||
# Fields only used for Part creation
|
||||
'duplicate',
|
||||
'initial_stock',
|
||||
@ -553,6 +556,9 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize
|
||||
),
|
||||
)
|
||||
|
||||
# TODO: This could do with some refactoring
|
||||
# TODO: Note that BomItemSerializer and BuildLineSerializer have very similar code
|
||||
|
||||
queryset = queryset.annotate(
|
||||
ordering=part.filters.annotate_on_order_quantity(),
|
||||
in_stock=part.filters.annotate_total_stock(),
|
||||
@ -578,6 +584,11 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize
|
||||
)
|
||||
)
|
||||
|
||||
# Annotate with the total 'required for builds' quantity
|
||||
queryset = queryset.annotate(
|
||||
required_for_build_orders=part.filters.annotate_build_order_requirements(),
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_starred(self, part):
|
||||
@ -587,17 +598,18 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize
|
||||
# Extra detail for the category
|
||||
category_detail = CategorySerializer(source='category', many=False, read_only=True)
|
||||
|
||||
# Calculated fields
|
||||
# Annotated fields
|
||||
allocated_to_build_orders = serializers.FloatField(read_only=True)
|
||||
allocated_to_sales_orders = serializers.FloatField(read_only=True)
|
||||
unallocated_stock = serializers.FloatField(read_only=True)
|
||||
building = serializers.FloatField(read_only=True)
|
||||
in_stock = serializers.FloatField(read_only=True)
|
||||
variant_stock = serializers.FloatField(read_only=True)
|
||||
total_in_stock = serializers.FloatField(read_only=True)
|
||||
ordering = serializers.FloatField(read_only=True)
|
||||
required_for_build_orders = serializers.IntegerField(read_only=True)
|
||||
stock_item_count = serializers.IntegerField(read_only=True)
|
||||
suppliers = serializers.IntegerField(read_only=True)
|
||||
total_in_stock = serializers.FloatField(read_only=True)
|
||||
unallocated_stock = serializers.FloatField(read_only=True)
|
||||
variant_stock = serializers.FloatField(read_only=True)
|
||||
|
||||
image = InvenTree.serializers.InvenTreeImageSerializerField(required=False, allow_null=True)
|
||||
thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
|
||||
|
@ -1997,10 +1997,14 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
|
||||
|
||||
bom_item = BomItem.objects.get(pk=6)
|
||||
|
||||
line = build.models.BuildLine.objects.get(
|
||||
bom_item=bom_item,
|
||||
build=bo,
|
||||
)
|
||||
|
||||
# Allocate multiple stock items against this build order
|
||||
build.models.BuildItem.objects.create(
|
||||
build=bo,
|
||||
bom_item=bom_item,
|
||||
build_line=line,
|
||||
stock_item=StockItem.objects.get(pk=1000),
|
||||
quantity=10,
|
||||
)
|
||||
@ -2021,8 +2025,7 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
|
||||
|
||||
# Allocate further stock against the build
|
||||
build.models.BuildItem.objects.create(
|
||||
build=bo,
|
||||
bom_item=bom_item,
|
||||
build_line=line,
|
||||
stock_item=StockItem.objects.get(pk=1001),
|
||||
quantity=10,
|
||||
)
|
||||
|
@ -144,7 +144,15 @@ class CategoryTest(TestCase):
|
||||
self.assertEqual(self.electronics.partcount(), 3)
|
||||
|
||||
self.assertEqual(self.mechanical.partcount(), 9)
|
||||
self.assertEqual(self.mechanical.partcount(active=True), 9)
|
||||
|
||||
# Mark one part as inactive and retry
|
||||
part = Part.objects.get(pk=1)
|
||||
part.active = False
|
||||
part.save()
|
||||
|
||||
self.assertEqual(self.mechanical.partcount(active=True), 8)
|
||||
|
||||
self.assertEqual(self.mechanical.partcount(False), 7)
|
||||
|
||||
self.assertEqual(self.electronics.item_count, self.electronics.partcount())
|
||||
|
@ -444,11 +444,6 @@ class PartPricingTests(InvenTreeTestCase):
|
||||
# Check that PartPricing objects have been created
|
||||
self.assertEqual(part.models.PartPricing.objects.count(), 101)
|
||||
|
||||
# Check that background-tasks have been created
|
||||
from django_q.models import OrmQ
|
||||
|
||||
self.assertEqual(OrmQ.objects.count(), 101)
|
||||
|
||||
def test_delete_part_with_stock_items(self):
|
||||
"""Test deleting a part instance with stock items.
|
||||
|
||||
@ -473,6 +468,9 @@ class PartPricingTests(InvenTreeTestCase):
|
||||
purchase_price=Money(10, 'USD')
|
||||
)
|
||||
|
||||
# Manually schedule a pricing update (does not happen automatically in testing)
|
||||
p.schedule_pricing_update(create=True, test=True)
|
||||
|
||||
# Check that a PartPricing object exists
|
||||
self.assertTrue(part.models.PartPricing.objects.filter(part=p).exists())
|
||||
|
||||
@ -483,5 +481,5 @@ class PartPricingTests(InvenTreeTestCase):
|
||||
self.assertFalse(part.models.PartPricing.objects.filter(part=p).exists())
|
||||
|
||||
# Try to update pricing (should fail gracefully as the Part has been deleted)
|
||||
p.schedule_pricing_update(create=False)
|
||||
p.schedule_pricing_update(create=False, test=True)
|
||||
self.assertFalse(part.models.PartPricing.objects.filter(part=p).exists())
|
||||
|
Reference in New Issue
Block a user