mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
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:
parent
004ced8030
commit
434f563a41
@ -322,6 +322,37 @@ class BuildFinish(generics.CreateAPIView):
|
|||||||
return ctx
|
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):
|
class BuildAllocate(generics.CreateAPIView):
|
||||||
"""
|
"""
|
||||||
API endpoint to allocate stock items to a build order
|
API endpoint to allocate stock items to a build order
|
||||||
@ -477,6 +508,7 @@ build_api_urls = [
|
|||||||
# Build Detail
|
# Build Detail
|
||||||
url(r'^(?P<pk>\d+)/', include([
|
url(r'^(?P<pk>\d+)/', include([
|
||||||
url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'),
|
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'^complete/', BuildOutputComplete.as_view(), name='api-build-output-complete'),
|
||||||
url(r'^create-output/', BuildOutputCreate.as_view(), name='api-build-output-create'),
|
url(r'^create-output/', BuildOutputCreate.as_view(), name='api-build-output-create'),
|
||||||
url(r'^delete-outputs/', BuildOutputDelete.as_view(), name='api-build-output-delete'),
|
url(r'^delete-outputs/', BuildOutputDelete.as_view(), name='api-build-output-delete'),
|
||||||
|
@ -25,6 +25,8 @@ from markdownx.models import MarkdownxField
|
|||||||
from mptt.models import MPTTModel, TreeForeignKey
|
from mptt.models import MPTTModel, TreeForeignKey
|
||||||
from mptt.exceptions import InvalidMove
|
from mptt.exceptions import InvalidMove
|
||||||
|
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode
|
from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode
|
||||||
from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode
|
from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode
|
||||||
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
|
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
|
||||||
@ -823,6 +825,86 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
|
|
||||||
self.save()
|
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):
|
def required_quantity(self, bom_item, output=None):
|
||||||
"""
|
"""
|
||||||
Get the quantity of a part required to complete the particular build output.
|
Get the quantity of a part required to complete the particular build output.
|
||||||
|
@ -709,6 +709,54 @@ class BuildAllocationSerializer(serializers.Serializer):
|
|||||||
raise ValidationError(detail=serializers.as_serializer_error(exc))
|
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):
|
class BuildItemSerializer(InvenTreeModelSerializer):
|
||||||
""" Serializes a BuildItem object """
|
""" Serializes a BuildItem object """
|
||||||
|
|
||||||
|
@ -177,7 +177,10 @@
|
|||||||
<button class='btn btn-danger' type='button' id='btn-unallocate' title='{% trans "Unallocate stock" %}'>
|
<button class='btn btn-danger' type='button' id='btn-unallocate' title='{% trans "Unallocate stock" %}'>
|
||||||
<span class='fas fa-minus-circle'></span> {% trans "Unallocate Stock" %}
|
<span class='fas fa-minus-circle'></span> {% trans "Unallocate Stock" %}
|
||||||
</button>
|
</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" %}
|
<span class='fas fa-sign-in-alt'></span> {% trans "Allocate Stock" %}
|
||||||
</button>
|
</button>
|
||||||
<!--
|
<!--
|
||||||
@ -485,8 +488,22 @@ function reloadTable() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
{% if build.active %}
|
{% if build.active %}
|
||||||
|
|
||||||
$("#btn-auto-allocate").on('click', function() {
|
$("#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 bom_items = $("#allocation-table-untracked").bootstrapTable("getData");
|
||||||
|
|
||||||
var incomplete_bom_items = [];
|
var incomplete_bom_items = [];
|
||||||
|
@ -2651,7 +2651,7 @@ class BomItem(models.Model, DataImportMixin):
|
|||||||
def get_api_url():
|
def get_api_url():
|
||||||
return reverse('api-bom-list')
|
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:
|
Return a list of valid parts which can be allocated against this BomItem:
|
||||||
|
|
||||||
@ -2666,11 +2666,12 @@ class BomItem(models.Model, DataImportMixin):
|
|||||||
parts.add(self.sub_part)
|
parts.add(self.sub_part)
|
||||||
|
|
||||||
# Variant parts (if allowed)
|
# 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):
|
for variant in self.sub_part.get_descendants(include_self=False):
|
||||||
parts.add(variant)
|
parts.add(variant)
|
||||||
|
|
||||||
# Substitute parts
|
# Substitute parts
|
||||||
|
if allow_substitutes:
|
||||||
for sub in self.substitutes.all():
|
for sub in self.substitutes.all():
|
||||||
parts.add(sub.part)
|
parts.add(sub.part)
|
||||||
|
|
||||||
|
@ -20,6 +20,7 @@
|
|||||||
|
|
||||||
/* exported
|
/* exported
|
||||||
allocateStockToBuild,
|
allocateStockToBuild,
|
||||||
|
autoAllocateStockToBuild,
|
||||||
completeBuildOrder,
|
completeBuildOrder,
|
||||||
createBuildOutput,
|
createBuildOutput,
|
||||||
editBuildOrder,
|
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
|
* Display a table of Build orders
|
||||||
*/
|
*/
|
||||||
|
Loading…
Reference in New Issue
Block a user