2
0
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:
Oliver
2023-06-13 20:18:32 +10:00
committed by GitHub
parent 98bddd32d0
commit 6ba777d363
54 changed files with 2193 additions and 1903 deletions

View File

@ -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

View File

@ -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(

View File

@ -100,7 +100,7 @@
salable: true
purchaseable: false
category: 7
active: False
active: True
IPN: BOB
revision: A2
tree_id: 0

View File

@ -8,6 +8,7 @@ import common.settings
class Migration(migrations.Migration):
dependencies = [
('common', '0004_inventreesetting'),
('part', '0054_auto_20201109_1246'),
]

View File

@ -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

View File

@ -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)

View File

@ -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,
)

View File

@ -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())

View File

@ -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())