Adds API endpoint for "auto allocating" stock items against a build order.

- If stock exists in multiple locations, and the user "does not care" where to take from, simply iterate through and take
This commit is contained in:
Oliver 2022-03-04 15:26:00 +11:00
parent 004ced8030
commit 434f563a41
6 changed files with 214 additions and 5 deletions

View File

@ -322,6 +322,37 @@ class BuildFinish(generics.CreateAPIView):
return ctx
class BuildAutoAllocate(generics.CreateAPIView):
"""
API endpoint for 'automatically' allocating stock against a build order.
- Only looks at 'untracked' parts
- If stock exists in a single location, easy!
- If user decides that stock items are "fungible", allocate against multiple stock items
- If the user wants to, allocate substite parts if the primary parts are not available.
"""
queryset = Build.objects.none()
serializer_class = build.serializers.BuildAutoAllocationSerializer
def get_serializer_context(self):
"""
Provide the Build object to the serializer context
"""
context = super().get_serializer_context()
try:
context['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
except:
pass
context['request'] = self.request
return context
class BuildAllocate(generics.CreateAPIView):
"""
API endpoint to allocate stock items to a build order
@ -477,6 +508,7 @@ build_api_urls = [
# Build Detail
url(r'^(?P<pk>\d+)/', include([
url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'),
url(r'^auto-allocate/', BuildAutoAllocate.as_view(), name='api-build-auto-allocate'),
url(r'^complete/', BuildOutputComplete.as_view(), name='api-build-output-complete'),
url(r'^create-output/', BuildOutputCreate.as_view(), name='api-build-output-create'),
url(r'^delete-outputs/', BuildOutputDelete.as_view(), name='api-build-output-delete'),

View File

@ -25,6 +25,8 @@ from markdownx.models import MarkdownxField
from mptt.models import MPTTModel, TreeForeignKey
from mptt.exceptions import InvalidMove
from rest_framework import serializers
from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode
from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
@ -823,6 +825,86 @@ class Build(MPTTModel, ReferenceIndexingMixin):
self.save()
@transaction.atomic
def auto_allocate_stock(self, user, **kwargs):
"""
Automatically allocate stock items against this build order,
following a number of 'guidelines':
- Only "untracked" BOM items are considered (tracked BOM items must be manually allocated)
- If a particular BOM item is already fully allocated, it is skipped
- Extract all available stock items for the BOM part
- If variant stock is allowed, extract stock for those too
- If substitute parts are available, extract stock for those also
- If a single stock item is found, we can allocate that and move on!
- If multiple stock items are found, we *may* be able to allocate:
- If the calling function has specified that items are interchangeable
"""
location = kwargs.get('location', None)
interchangeable = kwargs.get('interchangeable', False)
substitutes = kwargs.get('substitutes', True)
# Get a list of all 'untracked' BOM items
for bom_item in self.untracked_bom_items:
unallocated_quantity = self.unallocated_quantity(bom_item)
if unallocated_quantity <= 0:
# This BomItem is fully allocated, we can continue
continue
# Check which parts we can "use" (may include variants and substitutes)
available_parts = bom_item.get_valid_parts_for_allocation(
allow_variants=True,
allow_substitutes=substitutes,
)
# Look for available stock items
available_stock = StockModels.StockItem.objects.filter(StockModels.StockItem.IN_STOCK_FILTER)
# Filter by list of available parts
available_stock = available_stock.filter(
part__in=[p for p in available_parts],
)
if location:
# Filter only stock items located "below" the specified location
sublocations = location.get_descendants(include_self=True)
available_stock = available_stock.filter(location__in=[loc for loc in sublocations])
if available_stock.count() == 0:
# No stock items are available
continue
elif available_stock.count() == 1 or interchangeable:
# Either there is only a single stock item available,
# or all items are "interchangeable" and we don't care where we take stock from
for stock_item in available_stock:
# How much of the stock item is "available" for allocation?
quantity = min(unallocated_quantity, stock_item.unallocated_quantity())
if quantity > 0:
try:
BuildItem.objects.create(
build=self,
bom_item=bom_item,
stock_item=stock_item,
quantity=quantity,
)
# Subtract the required quantity
unallocated_quantity -= quantity
except (ValidationError, serializers.ValidationError) as exc:
# Catch model errors and re-throw as DRF errors
raise ValidationError(detail=serializers.as_serializer_error(exc))
if unallocated_quantity <= 0:
# We have now fully-allocated this BomItem - no need to continue!
break
def required_quantity(self, bom_item, output=None):
"""
Get the quantity of a part required to complete the particular build output.

View File

@ -709,6 +709,54 @@ class BuildAllocationSerializer(serializers.Serializer):
raise ValidationError(detail=serializers.as_serializer_error(exc))
class BuildAutoAllocationSerializer(serializers.Serializer):
"""
DRF serializer for auto allocating stock items against a build order
"""
class Meta:
fields = [
'location',
'interchangeable',
'substitutes',
]
location = serializers.PrimaryKeyRelatedField(
queryset=StockLocation.objects.all(),
many=False,
allow_null=True,
required=False,
label=_('Source Location'),
help_text=_('Stock location where parts are to be sourced (leave blank to take from any location)'),
)
interchangeable = serializers.BooleanField(
default=False,
label=_('Interchangeable Stock'),
help_text=_('Stock items in multiple locations can be used interchangeably'),
)
substitutes = serializers.BooleanField(
default=True,
label=_('Substitute Stock'),
help_text=_('Allow allocation of substitute parts'),
)
def save(self):
data = self.validated_data
request = self.context['request']
build = self.context['build']
build.auto_allocate_stock(
request.user,
location=data.get('location', None),
interchangeable=data['interchangeable'],
substitutes=data['substitutes'],
)
class BuildItemSerializer(InvenTreeModelSerializer):
""" Serializes a BuildItem object """

View File

@ -177,7 +177,10 @@
<button class='btn btn-danger' type='button' id='btn-unallocate' title='{% trans "Unallocate stock" %}'>
<span class='fas fa-minus-circle'></span> {% trans "Unallocate Stock" %}
</button>
<button class='btn btn-success' type='button' id='btn-auto-allocate' title='{% trans "Allocate stock to build" %}'>
<button class='btn btn-primary' type='button' id='btn-auto-allocate' title='{% trans "Automatically allocate stock to build" %}'>
<span class='fas fa-magic'></span> {% trans "Auto Allocate" %}
</button>
<button class='btn btn-success' type='button' id='btn-allocate' title='{% trans "Manually allocate stock to build" %}'>
<span class='fas fa-sign-in-alt'></span> {% trans "Allocate Stock" %}
</button>
<!--
@ -485,8 +488,22 @@ function reloadTable() {
}
{% if build.active %}
$("#btn-auto-allocate").on('click', function() {
autoAllocateStockToBuild(
{{ build.pk }},
[],
{
{% if build.take_from %}
location: {{ build.take_from.pk }},
{% endif %}
}
);
});
$("#btn-allocate").on('click', function() {
var bom_items = $("#allocation-table-untracked").bootstrapTable("getData");
var incomplete_bom_items = [];

View File

@ -2651,7 +2651,7 @@ class BomItem(models.Model, DataImportMixin):
def get_api_url():
return reverse('api-bom-list')
def get_valid_parts_for_allocation(self):
def get_valid_parts_for_allocation(self, allow_variants=True, allow_substitutes=True):
"""
Return a list of valid parts which can be allocated against this BomItem:
@ -2666,13 +2666,14 @@ class BomItem(models.Model, DataImportMixin):
parts.add(self.sub_part)
# Variant parts (if allowed)
if self.allow_variants:
if allow_variants and self.allow_variants:
for variant in self.sub_part.get_descendants(include_self=False):
parts.add(variant)
# Substitute parts
for sub in self.substitutes.all():
parts.add(sub.part)
if allow_substitutes:
for sub in self.substitutes.all():
parts.add(sub.part)
return parts

View File

@ -20,6 +20,7 @@
/* exported
allocateStockToBuild,
autoAllocateStockToBuild,
completeBuildOrder,
createBuildOutput,
editBuildOrder,
@ -1844,6 +1845,34 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
}
/**
* Automatically allocate stock items to a build
*/
function autoAllocateStockToBuild(build_id, bom_items=[], options={}) {
var html = '';
var fields = {
location: {
value: options.location,
},
interchangeable: {},
substitutes: {},
}
constructForm(`/api/build/${build_id}/auto-allocate/`, {
method: 'POST',
fields: fields,
title: '{% trans "Allocate Stock Items" %}',
confirm: true,
preFormContent: html,
onSuccess: function(response) {
// TODO - Reload the allocation table
}
});
}
/*
* Display a table of Build orders
*/