mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
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:
parent
05e67d310a
commit
37c1fe1ccb
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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'),
|
||||
|
@ -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 %}
|
||||
|
@ -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 = [
|
||||
|
@ -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'):
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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');
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
@ -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">
|
||||
|
@ -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={
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user