Merge branch 'inventree:master' into matmair/issue2694

This commit is contained in:
Matthias Mair 2022-03-07 00:03:33 +01:00 committed by GitHub
commit b9ee436110
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 398 additions and 23 deletions

View File

@ -12,11 +12,15 @@ import common.models
INVENTREE_SW_VERSION = "0.7.0 dev"
# InvenTree API version
INVENTREE_API_VERSION = 27
INVENTREE_API_VERSION = 28
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v28 -> 2022-03-04
- Adds an API endpoint for auto allocation of stock items against a build order
- Ref: https://github.com/inventree/InvenTree/pull/2713
v27 -> 2022-02-28
- Adds target_date field to individual line items for purchase orders and sales orders

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,106 @@ class Build(MPTTModel, ReferenceIndexingMixin):
self.save()
@transaction.atomic
def auto_allocate_stock(self, **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:
variant_parts = bom_item.sub_part.get_descendants(include_self=False)
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])
"""
Next, we sort the available stock items with the following priority:
1. Direct part matches (+1)
2. Variant part matches (+2)
3. Substitute part matches (+3)
This ensures that allocation priority is first given to "direct" parts
"""
def stock_sort(item):
if item.part == bom_item.sub_part:
return 1
elif item.part in variant_parts:
return 2
else:
return 3
available_stock = sorted(available_stock, key=stock_sort)
if len(available_stock) == 0:
# No stock items are available
continue
elif len(available_stock) == 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,52 @@ 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
build = self.context['build']
build.auto_allocate_stock(
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

@ -8,11 +8,11 @@ from django.db.utils import IntegrityError
from InvenTree import status_codes as status
from build.models import Build, BuildItem, get_next_build_number
from part.models import Part, BomItem
from part.models import Part, BomItem, BomItemSubstitute
from stock.models import StockItem
class BuildTest(TestCase):
class BuildTestBase(TestCase):
"""
Run some tests to ensure that the Build model is working properly.
"""
@ -107,13 +107,20 @@ class BuildTest(TestCase):
)
# Create some stock items to assign to the build
self.stock_1_1 = StockItem.objects.create(part=self.sub_part_1, quantity=1000)
self.stock_1_1 = StockItem.objects.create(part=self.sub_part_1, quantity=3)
self.stock_1_2 = StockItem.objects.create(part=self.sub_part_1, quantity=100)
self.stock_2_1 = StockItem.objects.create(part=self.sub_part_2, quantity=5000)
self.stock_2_1 = StockItem.objects.create(part=self.sub_part_2, quantity=5)
self.stock_2_2 = StockItem.objects.create(part=self.sub_part_2, quantity=5)
self.stock_2_2 = StockItem.objects.create(part=self.sub_part_2, quantity=5)
self.stock_2_2 = StockItem.objects.create(part=self.sub_part_2, quantity=5)
self.stock_2_2 = StockItem.objects.create(part=self.sub_part_2, quantity=5)
self.stock_3_1 = StockItem.objects.create(part=self.sub_part_3, quantity=1000)
class BuildTest(BuildTestBase):
def test_ref_int(self):
"""
Test the "integer reference" field used for natural sorting
@ -137,7 +144,7 @@ class BuildTest(TestCase):
def test_init(self):
# Perform some basic tests before we start the ball rolling
self.assertEqual(StockItem.objects.count(), 6)
self.assertEqual(StockItem.objects.count(), 10)
# Build is PENDING
self.assertEqual(self.build.status, status.BuildStatus.PENDING)
@ -183,7 +190,7 @@ class BuildTest(TestCase):
b.clean()
# Ok, what about we make one that does *not* fail?
b = BuildItem(stock_item=self.stock_1_1, build=self.build, install_into=self.output_1, quantity=10)
b = BuildItem(stock_item=self.stock_1_2, build=self.build, install_into=self.output_1, quantity=10)
b.save()
def test_duplicate_bom_line(self):
@ -274,11 +281,14 @@ class BuildTest(TestCase):
self.assertFalse(self.build.are_untracked_parts_allocated())
self.stock_2_1.quantity = 500
self.stock_2_1.save()
# Now we "fully" allocate the untracked untracked items
self.allocate_stock(
None,
{
self.stock_1_1: 50,
self.stock_1_2: 50,
self.stock_2_1: 50,
}
)
@ -305,6 +315,12 @@ class BuildTest(TestCase):
Test completion of a build output
"""
self.stock_1_1.quantity = 1000
self.stock_1_1.save()
self.stock_2_1.quantity = 30
self.stock_2_1.save()
# Allocate non-tracked parts
self.allocate_stock(
None,
@ -351,16 +367,15 @@ class BuildTest(TestCase):
self.assertEqual(BuildItem.objects.count(), 0)
# New stock items should have been created!
self.assertEqual(StockItem.objects.count(), 7)
self.assertEqual(StockItem.objects.count(), 10)
# This stock item has been depleted!
with self.assertRaises(StockItem.DoesNotExist):
StockItem.objects.get(pk=self.stock_1_1.pk)
# This stock item has *not* been depleted
x = StockItem.objects.get(pk=self.stock_2_1.pk)
self.assertEqual(x.quantity, 4970)
# This stock item has also been depleted
with self.assertRaises(StockItem.DoesNotExist):
StockItem.objects.get(pk=self.stock_2_1.pk)
# And 10 new stock items created for the build output
outputs = StockItem.objects.filter(build=self.build)
@ -369,3 +384,108 @@ class BuildTest(TestCase):
for output in outputs:
self.assertFalse(output.is_building)
class AutoAllocationTests(BuildTestBase):
"""
Tests for auto allocating stock against a build order
"""
def setUp(self):
super().setUp()
# Add a "substitute" part for bom_item_2
alt_part = Part.objects.create(
name="alt part",
description="An alternative part!",
component=True,
)
BomItemSubstitute.objects.create(
bom_item=self.bom_item_2,
part=alt_part,
)
StockItem.objects.create(
part=alt_part,
quantity=500,
)
def test_auto_allocate(self):
"""
Run the 'auto-allocate' function. What do we expect to happen?
There are two "untracked" parts:
- sub_part_1 (quantity 5 per BOM = 50 required total) / 103 in stock (2 items)
- sub_part_2 (quantity 3 per BOM = 30 required total) / 25 in stock (5 items)
A "fully auto" allocation should allocate *all* of these stock items to the build
"""
# No build item allocations have been made against the build
self.assertEqual(self.build.allocated_stock.count(), 0)
self.assertFalse(self.build.are_untracked_parts_allocated())
# Stock is not interchangeable, nothing will happen
self.build.auto_allocate_stock(
interchangeable=False,
substitutes=False,
)
self.assertFalse(self.build.are_untracked_parts_allocated())
self.assertEqual(self.build.allocated_stock.count(), 0)
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_1))
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2))
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 50)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 30)
# This time we expect stock to be allocated!
self.build.auto_allocate_stock(
interchangeable=True,
substitutes=False,
)
self.assertFalse(self.build.are_untracked_parts_allocated())
self.assertEqual(self.build.allocated_stock.count(), 7)
self.assertTrue(self.build.is_bom_item_allocated(self.bom_item_1))
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2))
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 5)
# This time, allow substitue parts to be used!
self.build.auto_allocate_stock(
interchangeable=True,
substitutes=True,
)
# self.assertTrue(self.build.are_untracked_parts_allocated())
# self.assertEqual(self.build.allocated_stock.count(), 8)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 0)
self.assertTrue(self.build.is_bom_item_allocated(self.bom_item_1))
self.assertTrue(self.build.is_bom_item_allocated(self.bom_item_2))
def test_fully_auto(self):
"""
We should be able to auto-allocate against a build in a single go
"""
self.build.auto_allocate_stock(
interchangeable=True,
substitutes=True
)
self.assertTrue(self.build.are_untracked_parts_allocated())
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 0)

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

@ -244,7 +244,7 @@
{% for allocation in item.allocations.all %}
<div class='alert alert-block alert-info'>
{% object_link 'build-detail' allocation.build.id allocation.build %}
{% object_link 'build-detail' allocation.build.id allocation.build as link %}
{% decimal allocation.quantity as qty %}
{% trans "This stock item is allocated to Build Order" %} {{ link }} {% if qty < item.quantity %}({% trans "Quantity" %}: {{ qty }}){% endif %}
</div>

View File

@ -20,6 +20,7 @@
/* exported
allocateStockToBuild,
autoAllocateStockToBuild,
completeBuildOrder,
createBuildOutput,
editBuildOrder,
@ -754,7 +755,7 @@ function loadBuildOutputTable(build_info, options={}) {
filters[key] = params[key];
}
// TODO: Initialize filter list
setupFilterList('builditems', $(table), options.filterTarget || '#filter-list-incompletebuilditems');
function setupBuildOutputButtonCallbacks() {
@ -999,7 +1000,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
filters[key] = params[key];
}
setupFilterList('builditems', $(table), options.filterTarget || null);
setupFilterList('builditems', $(table), options.filterTarget);
// If an "output" is specified, then only "trackable" parts are allocated
// Otherwise, only "untrackable" parts are allowed
@ -1512,6 +1513,16 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
*/
function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
if (bom_items.length == 0) {
showAlertDialog(
'{% trans "Select Parts" %}',
'{% trans "You must select at least one part to allocate" %}',
);
return;
}
// ID of the associated "build output" (or null)
var output_id = options.output || null;
@ -1626,8 +1637,8 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
if (table_entries.length == 0) {
showAlertDialog(
'{% trans "Select Parts" %}',
'{% trans "You must select at least one part to allocate" %}',
'{% trans "All Parts Allocated" %}',
'{% trans "All selected parts have been fully allocated" %}',
);
return;
@ -1844,6 +1855,48 @@ 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 = `
<div class='alert alert-block alert-info'>
<strong>{% trans "Automatic Stock Allocation" %}</strong><br>
{% trans "Stock items will be automatically allocated to this build order, according to the provided guidelines" %}:
<ul>
<li>{% trans "If a location is specifed, stock will only be allocated from that location" %}</li>
<li>{% trans "If stock is considered interchangeable, it will be allocated from the first location it is found" %}</li>
<li>{% trans "If substitute stock is allowed, it will be used where stock of the primary part cannot be found" %}</li>
</ul>
</div>
`;
var fields = {
location: {
value: options.location,
},
interchangeable: {
value: true,
},
substitutes: {
value: true,
},
};
constructForm(`/api/build/${build_id}/auto-allocate/`, {
method: 'POST',
fields: fields,
title: '{% trans "Allocate Stock Items" %}',
confirm: true,
preFormContent: html,
onSuccess: function(response) {
$('#allocation-table-untracked').bootstrapTable('refresh');
}
});
}
/*
* Display a table of Build orders
*/