BOM / Build Updates (#6604)

* Fix for build line table

- Prefill source location correctly

* Refactor API filtering for BomList

- Make use of RestFilter class

* Add "external stock" field to BomItem serializer

* Simplify custom filtering

* Add "structural" column to part table

* Update BOM tables:

- Display indication of "external stock"

* Annotate "external_stock" to part serializer

- Update PartTable [PUI]

* Annotate BuildLine serializer too

* BuildLine endpoint - filter available stock based on source build order

- If build order is specified, and has a source location, use that to filter available stock!

* Add message above build line table

* Update BuildLineTable

* Bump API version
This commit is contained in:
Oliver 2024-02-29 16:16:28 +11:00 committed by GitHub
parent 05e67d310a
commit 37c1fe1ccb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 183 additions and 79 deletions

View File

@ -1,11 +1,17 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 177
INVENTREE_API_VERSION = 178
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v178 - 2024-02-29 : https://github.com/inventree/InvenTree/pull/6604
- Adds "external_stock" field to the Part API endpoint
- Adds "external_stock" field to the BomItem API endpoint
- Adds "external_stock" field to the BuildLine API endpoint
- Stock quantites represented in the BuildLine API endpoint are now filtered by Build.source_location
v177 - 2024-02-27 : https://github.com/inventree/InvenTree/pull/6581
- Adds "subcategoies" count to PartCategoryTree serializer
- Adds "sublocations" count to StockLocationTree serializer

View File

@ -314,11 +314,21 @@ class BuildLineEndpoint:
queryset = BuildLine.objects.all()
serializer_class = build.serializers.BuildLineSerializer
def get_source_build(self) -> Build:
"""Return the source Build object for the BuildLine queryset.
This source build is used to filter the available stock for each BuildLine.
- If this is a "detail" view, use the build associated with the line
- If this is a "list" view, use the build associated with the request
"""
raise NotImplementedError("get_source_build must be implemented in the child class")
def get_queryset(self):
"""Override queryset to select-related and annotate"""
queryset = super().get_queryset()
queryset = build.serializers.BuildLineSerializer.annotate_queryset(queryset)
source_build = self.get_source_build()
queryset = build.serializers.BuildLineSerializer.annotate_queryset(queryset, build=source_build)
return queryset
@ -353,10 +363,26 @@ class BuildLineList(BuildLineEndpoint, ListCreateAPI):
'bom_item__reference',
]
def get_source_build(self) -> Build:
"""Return the target build for the BuildLine queryset."""
try:
build_id = self.request.query_params.get('build', None)
if build_id:
build = Build.objects.get(pk=build_id)
return build
except (Build.DoesNotExist, AttributeError, ValueError):
pass
return None
class BuildLineDetail(BuildLineEndpoint, RetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a BuildLine object."""
pass
def get_source_build(self) -> Build:
"""Return the target source location for the BuildLine queryset."""
return None
class BuildOrderContextMixin:

View File

@ -1083,6 +1083,7 @@ class BuildLineSerializer(InvenTreeModelSerializer):
'available_substitute_stock',
'available_variant_stock',
'total_available_stock',
'external_stock',
]
read_only_fields = [
@ -1124,15 +1125,23 @@ class BuildLineSerializer(InvenTreeModelSerializer):
available_substitute_stock = serializers.FloatField(read_only=True)
available_variant_stock = serializers.FloatField(read_only=True)
total_available_stock = serializers.FloatField(read_only=True)
external_stock = serializers.FloatField(read_only=True)
@staticmethod
def annotate_queryset(queryset):
def annotate_queryset(queryset, build=None):
"""Add extra annotations to the queryset:
- allocated: Total stock quantity allocated against this build line
- available: Total stock available for allocation against this build line
- on_order: Total stock on order for this build line
- in_production: Total stock currently in production for this build line
Arguments:
queryset: The queryset to annotate
build: The build order to filter against (optional)
Note: If the 'build' is provided, we can use it to filter available stock, depending on the specified location for the build
"""
queryset = queryset.select_related(
'build', 'bom_item',
@ -1169,6 +1178,18 @@ class BuildLineSerializer(InvenTreeModelSerializer):
ref = 'bom_item__sub_part__'
stock_filter = None
if build is not None and build.take_from is not None:
location = build.take_from
# Filter by locations below the specified location
stock_filter = Q(
location__tree_id=location.tree_id,
location__lft__gte=location.lft,
location__rght__lte=location.rght,
location__level__gte=location.level,
)
# Annotate the "in_production" quantity
queryset = queryset.annotate(
in_production=part.filters.annotate_in_production_quantity(reference=ref)
@ -1181,10 +1202,8 @@ class BuildLineSerializer(InvenTreeModelSerializer):
)
# Annotate the "available" quantity
# TODO: In the future, this should be refactored.
# TODO: Note that part.serializers.BomItemSerializer also has a similar annotation
queryset = queryset.alias(
total_stock=part.filters.annotate_total_stock(reference=ref),
total_stock=part.filters.annotate_total_stock(reference=ref, filter=stock_filter),
allocated_to_sales_orders=part.filters.annotate_sales_order_allocations(reference=ref),
allocated_to_build_orders=part.filters.annotate_build_order_allocations(reference=ref),
)
@ -1197,11 +1216,21 @@ class BuildLineSerializer(InvenTreeModelSerializer):
)
)
external_stock_filter = Q(location__external=True)
if stock_filter:
external_stock_filter &= stock_filter
# Add 'external stock' annotations
queryset = queryset.annotate(
external_stock=part.filters.annotate_total_stock(reference=ref, filter=external_stock_filter)
)
ref = 'bom_item__substitutes__part__'
# Extract similar information for any 'substitute' parts
queryset = queryset.alias(
substitute_stock=part.filters.annotate_total_stock(reference=ref),
substitute_stock=part.filters.annotate_total_stock(reference=ref, filter=stock_filter),
substitute_build_allocations=part.filters.annotate_build_order_allocations(reference=ref),
substitute_sales_allocations=part.filters.annotate_sales_order_allocations(reference=ref)
)
@ -1215,7 +1244,7 @@ class BuildLineSerializer(InvenTreeModelSerializer):
)
# Annotate the queryset with 'available variant stock' information
variant_stock_query = part.filters.variant_stock_query(reference='bom_item__sub_part__')
variant_stock_query = part.filters.variant_stock_query(reference='bom_item__sub_part__', filter=stock_filter)
queryset = queryset.alias(
variant_stock_total=part.filters.annotate_variant_quantity(variant_stock_query, reference='quantity'),

View File

@ -200,6 +200,11 @@
<div id='build-lines-toolbar'>
{% include "filter_list.html" with id='buildlines' %}
</div>
{% if build.take_from %}
<div class='alert alert-block alert-info'>
{% trans "Available stock has been filtered based on specified source location for this build order" %}
</div>
{% endif %}
<table class='table table-striped table-condensed' id='build-lines-table' data-toolbar='#build-lines-toolbar'></table>
</div>
</div>
@ -374,6 +379,9 @@ onPanelLoad('allocate', function() {
"#build-lines-table",
{{ build.pk }},
{
{% if build.take_from %}
location: {{ build.take_from.pk }},
{% endif %}
{% if build.project_code %}
project_code: {{ build.project_code.pk }},
{% endif %}

View File

@ -1767,6 +1767,7 @@ class BomFilter(rest_filters.FilterSet):
part_active = rest_filters.BooleanFilter(
label='Master part is active', field_name='part__active'
)
part_trackable = rest_filters.BooleanFilter(
label='Master part is trackable', field_name='part__trackable'
)
@ -1775,6 +1776,7 @@ class BomFilter(rest_filters.FilterSet):
sub_part_trackable = rest_filters.BooleanFilter(
label='Sub part is trackable', field_name='sub_part__trackable'
)
sub_part_assembly = rest_filters.BooleanFilter(
label='Sub part is an assembly', field_name='sub_part__assembly'
)
@ -1814,6 +1816,22 @@ class BomFilter(rest_filters.FilterSet):
return queryset.filter(q_a | q_b).distinct()
part = rest_filters.ModelChoiceFilter(
queryset=Part.objects.all(), method='filter_part', label=_('Part')
)
def filter_part(self, queryset, name, part):
"""Filter the queryset based on the specified part."""
return queryset.filter(part.get_bom_item_filter())
uses = rest_filters.ModelChoiceFilter(
queryset=Part.objects.all(), method='filter_uses', label=_('Uses')
)
def filter_uses(self, queryset, name, part):
"""Filter the queryset based on the specified part."""
return queryset.filter(part.get_used_in_bom_item_filter())
class BomMixin:
"""Mixin class for BomItem API endpoints."""
@ -1889,62 +1907,6 @@ class BomList(BomMixin, ListCreateDestroyAPIView):
return JsonResponse(data, safe=False)
return Response(data)
def filter_queryset(self, queryset):
"""Custom query filtering for the BomItem list API."""
queryset = super().filter_queryset(queryset)
params = self.request.query_params
# Filter by part?
part = params.get('part', None)
if part is not None:
"""
If we are filtering by "part", there are two cases to consider:
a) Bom items which are defined for *this* part
b) Inherited parts which are defined for a *parent* part
So we need to construct two queries!
"""
# First, check that the part is actually valid!
try:
part = Part.objects.get(pk=part)
queryset = queryset.filter(part.get_bom_item_filter())
except (ValueError, Part.DoesNotExist):
pass
"""
Filter by 'uses'?
Here we pass a part ID and return BOM items for any assemblies which "use" (or "require") that part.
There are multiple ways that an assembly can "use" a sub-part:
A) Directly specifying the sub_part in a BomItem field
B) Specifying a "template" part with inherited=True
C) Allowing variant parts to be substituted
D) Allowing direct substitute parts to be specified
- BOM items which are "inherited" by parts which are variants of the master BomItem
"""
uses = params.get('uses', None)
if uses is not None:
try:
# Extract the part we are interested in
uses_part = Part.objects.get(pk=uses)
queryset = queryset.filter(uses_part.get_used_in_bom_item_filter())
except (ValueError, Part.DoesNotExist):
pass
return queryset
filter_backends = SEARCH_ORDER_FILTER_ALIAS
search_fields = [

View File

@ -107,7 +107,7 @@ def annotate_on_order_quantity(reference: str = ''):
)
def annotate_total_stock(reference: str = ''):
def annotate_total_stock(reference: str = '', filter: Q = None):
"""Annotate 'total stock' quantity against a queryset.
- This function calculates the 'total stock' for a given part
@ -121,6 +121,9 @@ def annotate_total_stock(reference: str = ''):
# Stock filter only returns 'in stock' items
stock_filter = stock.models.StockItem.IN_STOCK_FILTER
if filter is not None:
stock_filter &= filter
return Coalesce(
SubquerySum(f'{reference}stock_items__quantity', filter=stock_filter),
Decimal(0),
@ -216,9 +219,7 @@ def annotate_sales_order_allocations(reference: str = ''):
)
def variant_stock_query(
reference: str = '', filter: Q = stock.models.StockItem.IN_STOCK_FILTER
):
def variant_stock_query(reference: str = '', filter: Q = None):
"""Create a queryset to retrieve all stock items for variant parts under the specified part.
- Useful for annotating a queryset with aggregated information about variant parts
@ -227,11 +228,16 @@ def variant_stock_query(
reference: The relationship reference of the part from the current model
filter: Q object which defines how to filter the returned StockItem instances
"""
stock_filter = stock.models.StockItem.IN_STOCK_FILTER
if filter:
stock_filter &= filter
return stock.models.StockItem.objects.filter(
part__tree_id=OuterRef(f'{reference}tree_id'),
part__lft__gt=OuterRef(f'{reference}lft'),
part__rght__lt=OuterRef(f'{reference}rght'),
).filter(filter)
).filter(stock_filter)
def annotate_variant_quantity(subquery: Q, reference: str = 'quantity'):

View File

@ -610,6 +610,7 @@ class PartSerializer(
'stock_item_count',
'suppliers',
'total_in_stock',
'external_stock',
'unallocated_stock',
'variant_stock',
# Fields only used for Part creation
@ -734,6 +735,12 @@ class PartSerializer(
)
)
queryset = queryset.annotate(
external_stock=part.filters.annotate_total_stock(
filter=Q(location__external=True)
)
)
# Annotate with the total 'available stock' quantity
# This is the current stock, minus any allocations
queryset = queryset.annotate(
@ -780,14 +787,17 @@ class PartSerializer(
allocated_to_sales_orders = serializers.FloatField(read_only=True)
building = serializers.FloatField(read_only=True)
in_stock = serializers.FloatField(read_only=True)
ordering = serializers.FloatField(read_only=True)
ordering = serializers.FloatField(read_only=True, label=_('On Order'))
required_for_build_orders = serializers.IntegerField(read_only=True)
required_for_sales_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)
stock_item_count = serializers.IntegerField(read_only=True, label=_('Stock Items'))
suppliers = serializers.IntegerField(read_only=True, label=_('Suppliers'))
total_in_stock = serializers.FloatField(read_only=True, label=_('Total Stock'))
external_stock = serializers.FloatField(read_only=True, label=_('External Stock'))
unallocated_stock = serializers.FloatField(
read_only=True, label=_('Unallocated Stock')
)
variant_stock = serializers.FloatField(read_only=True, label=_('Variant Stock'))
minimum_stock = serializers.FloatField()
@ -1387,6 +1397,7 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
'available_stock',
'available_substitute_stock',
'available_variant_stock',
'external_stock',
# Annotated field describing quantity on order
'on_order',
# Annotated field describing quantity being built
@ -1456,6 +1467,8 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
available_substitute_stock = serializers.FloatField(read_only=True)
available_variant_stock = serializers.FloatField(read_only=True)
external_stock = serializers.FloatField(read_only=True)
@staticmethod
def setup_eager_loading(queryset):
"""Prefetch against the provided queryset to speed up database access."""
@ -1534,6 +1547,13 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
)
)
# Calculate 'external_stock'
queryset = queryset.annotate(
external_stock=part.filters.annotate_total_stock(
reference=ref, filter=Q(location__external=True)
)
)
ref = 'substitutes__part__'
# Extract similar information for any 'substitute' parts

View File

@ -1172,12 +1172,18 @@ function loadBomTable(table, options={}) {
var available_stock = availableQuantity(row);
var external_stock = row.external_stock ?? 0;
var text = renderLink(`${available_stock}`, url);
if (row.sub_part_detail && row.sub_part_detail.units) {
text += ` <small>${row.sub_part_detail.units}</small>`;
}
if (external_stock > 0) {
text += makeIconBadge('fa-sitemap', `{% trans "External stock" %}: ${external_stock}`);
}
if (available_stock <= 0) {
text += makeIconBadge('fa-times-circle icon-red', '{% trans "No Stock Available" %}');
} else {

View File

@ -2618,6 +2618,10 @@ function loadBuildLineTable(table, build_id, options={}) {
icons += makeIconBadge('fa-tools icon-blue', `{% trans "In Production" %}: ${formatDecimal(row.in_production)}`);
}
if (row.external_stock > 0) {
icons += makeIconBadge('fa-sitemap', `{% trans "External stock" %}: ${row.external_stock}`);
}
return renderLink(text, url) + icons;
}
},
@ -2730,6 +2734,7 @@ function loadBuildLineTable(table, build_id, options={}) {
allocateStockToBuild(build_id, [row], {
output: options.output,
source_location: options.location,
success: function() {
$(table).bootstrapTable('refresh');
}

View File

@ -2804,6 +2804,15 @@ function loadPartCategoryTable(table, options) {
title: '{% trans "Parts" %}',
switchable: true,
sortable: true,
},
{
field: 'structural',
title: '{% trans "Structural" %}',
switchable: true,
sortable: true,
formatter: function(value) {
return yesNoLabel(value);
}
}
]
});

View File

@ -142,7 +142,7 @@ export function BomTable({
},
{
accessor: 'available_stock',
sortable: true,
render: (record) => {
let extra: ReactNode[] = [];
@ -157,6 +157,14 @@ export function BomTable({
available_stock
);
if (record.external_stock > 0) {
extra.push(
<Text key="external">
{t`External stock`}: {record.external_stock}
</Text>
);
}
if (record.available_substitute_stock > 0) {
extra.push(
<Text key="substitute">

View File

@ -94,6 +94,15 @@ export default function BuildLineTable({ params = {} }: { params?: any }) {
);
}
// Account for "external" stock
if (record.external_stock > 0) {
extra.push(
<Text key="external" size="sm">
{t`External stock`}: {record.external_stock}
</Text>
);
}
return (
<TableHoverCard
value={

View File

@ -113,7 +113,17 @@ function partTableColumns(): TableColumn[] {
if (available != stock) {
extra.push(
<Text key="available">{t`Available` + `: ${available}`}</Text>
<Text key="available">
{t`Available`}: {available}
</Text>
);
}
if (record.external_stock > 0) {
extra.push(
<Text key="external">
{t`External stock`}: {record.external_stock}
</Text>
);
}