Part table stock filtering (#4462)

* Update PartSerializer queryset annotation

- Add 'total_stock' (in_stock + variant_stock)
- Update 'unallocated_stock' to use total_stock

* Allow API filtering by total_in_stock value

* Refactor partStockLabel method

- We'll use this in the partTable also
- Allow us to prevent further API calls

* Cleanup loadPartTable

* Refactor part variant table

* More updates to part badge function

* Bump API version

* js linting
This commit is contained in:
Oliver 2023-03-08 13:59:51 +11:00 committed by GitHub
parent 106c238af5
commit 9c594ed52b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 111 additions and 125 deletions

View File

@ -2,11 +2,14 @@
# InvenTree API version
INVENTREE_API_VERSION = 100
INVENTREE_API_VERSION = 101
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v101 -> 2023-03-07 : https://github.com/inventree/InvenTree/pull/4462
- Adds 'total_in_stock' to Part serializer, and supports API ordering
v100 -> 2023-03-04 : https://github.com/inventree/InvenTree/pull/4452
- Adds bulk delete of PurchaseOrderLineItems to API

View File

@ -1337,6 +1337,7 @@ class PartList(APIDownloadMixin, ListCreateAPI):
'creation_date',
'IPN',
'in_stock',
'total_in_stock',
'unallocated_stock',
'category',
'last_stocktake',

View File

@ -423,7 +423,6 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
'full_name',
'image',
'in_stock',
'variant_stock',
'ordering',
'building',
'IPN',
@ -444,10 +443,12 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
'stock_item_count',
'suppliers',
'thumbnail',
'total_in_stock',
'trackable',
'unallocated_stock',
'units',
'variant_of',
'variant_stock',
'virtual',
'pricing_min',
'pricing_max',
@ -554,11 +555,20 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
allocated_to_build_orders=part.filters.annotate_build_order_allocations(),
)
# Annotate the queryset with the 'total_in_stock' quantity
# This is the 'in_stock' quantity summed with the 'variant_stock' quantity
queryset = queryset.annotate(
total_in_stock=ExpressionWrapper(
F('in_stock') + F('variant_stock'),
output_field=models.DecimalField(),
)
)
# Annotate with the total 'available stock' quantity
# This is the current stock, minus any allocations
queryset = queryset.annotate(
unallocated_stock=ExpressionWrapper(
F('in_stock') - F('allocated_to_sales_orders') - F('allocated_to_build_orders'),
F('total_in_stock') - F('allocated_to_sales_orders') - F('allocated_to_build_orders'),
output_field=models.DecimalField(),
)
)
@ -579,6 +589,7 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
building = serializers.FloatField(read_only=True)
in_stock = serializers.FloatField(read_only=True)
variant_stock = serializers.FloatField(read_only=True)
total_in_stock = serializers.FloatField(read_only=True)
ordering = serializers.FloatField(read_only=True)
stock_item_count = serializers.IntegerField(read_only=True)
suppliers = serializers.IntegerField(read_only=True)

View File

@ -215,7 +215,7 @@
</tr>
{% endif %}
{% if part.component %}
{% if required_build_order_quantity > 0 %}
{% if required_build_order_quantity > 0 or allocated_build_order_quantity > 0 %}
<tr>
<td><span class='fas fa-tools'></span></td>
<td>{% trans "Allocated to Build Orders" %}</td>
@ -224,7 +224,7 @@
{% endif %}
{% endif %}
{% if part.salable %}
{% if required_sales_order_quantity > 0 %}
{% if required_sales_order_quantity > 0 or allocated_sales_order_quantity > 0 %}
<tr>
<td><span class='fas fa-truck'></span></td>
<td>{% trans "Allocated to Sales Orders" %}</td>

View File

@ -404,7 +404,10 @@ def progress_bar(val, max_val, *args, **kwargs):
else:
style = ''
if max_val != 0:
percent = float(val / max_val) * 100
else:
percent = 0
if percent > 100:
percent = 100

View File

@ -589,43 +589,56 @@ function partStockLabel(part, options={}) {
// Prevent literal string 'null' from being displayed
var units = part.units || '';
var text = '';
let elements = [];
// Check for stock
if (part.in_stock) {
if (part.total_in_stock) {
// There IS stock available for this part
// Is stock "low" (below the 'minimum_stock' quantity)?
if ((part.minimum_stock > 0) && (part.minimum_stock > part.in_stock)) {
text += `{% trans "Low stock" %}: ${part.in_stock}`;
} else if (part.unallocated_stock == 0) {
if ((part.minimum_stock > 0) && (part.minimum_stock > part.total_in_stock)) {
elements.push(`{% trans "Low stock" %}: ${part.total_in_stock}`);
} else if (part.unallocated_stock <= 0) {
// There is no available stock at all
text += `{% trans "No stock available" %}`;
elements.push(`{% trans "No stock available" %}`);
} else if (part.unallocated_stock < part.in_stock) {
// Unallocated quanttiy is less than total quantity
text += `{% trans "Available" %}: ${part.unallocated_stock}/${part.in_stock}`;
// Unallocated quantity is less than total quantity
if (options.hideTotalStock) {
elements.push(`{% trans "Available" %}: ${part.unallocated_stock}`);
} else {
elements.push(`{% trans "Available" %}: ${part.unallocated_stock}/${part.in_stock}`);
}
} else {
// Stock is completely available
text += `{% trans "Available" %}: ${part.unallocated_stock}`;
if (!options.hideTotalStock) {
elements.push(`{% trans "Available" %}: ${part.unallocated_stock}`);
}
}
} else {
// There IS NO stock available for this part
text += `{% trans "No Stock" %}`;
elements.push(`{% trans "No Stock" %}`);
}
// Check for items on order
if (part.ordering) {
text += ` | {% trans "On Order" %}: ${part.ordering}`;
elements.push(`{% trans "On Order" %}: ${part.ordering}`);
}
// Check for items beeing built
if (part.building) {
text += ` | {% trans "Building" %}: ${part.building}`;
elements.push(`{% trans "Building" %}: ${part.building}`);
}
// Determine badge color based on overall stock health
var stock_health = part.unallocated_stock + part.building + part.ordering - part.minimum_stock;
// TODO: Refactor the API to include this information, so we don't have to request it!
if (!options.noDemandInfo) {
// Check for demand from unallocated build orders
var required_build_order_quantity = null;
var required_sales_order_quantity = null;
inventreeGet(`/api/part/${part.pk}/requirements/`, {}, {
async: false,
success: function(response) {
@ -639,18 +652,22 @@ function partStockLabel(part, options={}) {
}
}
});
if ((required_build_order_quantity == null) || (required_sales_order_quantity == null)) {
console.error(`Error loading part requirements for part ${part.pk}`);
return;
}
var demand = (required_build_order_quantity - part.allocated_to_build_orders) + (required_sales_order_quantity - part.allocated_to_sales_orders);
if (demand) {
text += ` | {% trans "Demand" %}: ${demand}`;
elements.push(`{% trans "Demand" %}: ${demand}`);
}
stock_health -= (required_build_order_quantity + required_sales_order_quantity);
}
// Determine badge color based on overall stock health
var stock_health = part.in_stock + part.building + part.ordering - part.minimum_stock - required_build_order_quantity - required_sales_order_quantity;
var bg_class = '';
if (stock_health < 0) {
// Unsatisfied demand and/or below minimum stock
bg_class = 'bg-danger';
@ -662,14 +679,19 @@ function partStockLabel(part, options={}) {
bg_class = 'bg-success';
}
// show units next to stock badge
var unit_badge = '';
// Display units next to stock badge
let unit_badge = '';
if (units && !options.no_units) {
unit_badge = `<span class='badge rounded-pill text-muted bg-muted ${classes}'>{% trans "Unit" %}: ${units}</span> `;
}
// return badge html
if (elements.length > 0) {
let text = elements.join(' | ');
return `${unit_badge}<span class='badge rounded-pill ${bg_class} ${classes}'>${text}</span>`;
} else {
return '';
}
}
@ -1150,24 +1172,25 @@ function loadPartVariantTable(table, partId, options={}) {
title: '{% trans "Description" %}',
},
{
field: 'in_stock',
field: 'total_in_stock',
title: '{% trans "Stock" %}',
sortable: true,
formatter: function(value, row) {
var base_stock = row.in_stock;
var variant_stock = row.variant_stock || 0;
var text = renderLink(value, `/part/${row.pk}/?display=part-stock`);
var total = base_stock + variant_stock;
text += partStockLabel(row, {
noDemandInfo: true,
hideTotalStock: true,
classes: 'float-right',
});
var text = `${total}`;
if (variant_stock > 0) {
if (row.variant_stock > 0) {
text = `<em>${text}</em>`;
text += `<span title='{% trans "Includes variant stock" %}' class='fas fa-info-circle float-right icon-blue'></span>`;
}
return renderLink(text, `/part/${row.pk}/?display=part-stock`);
return text;
}
},
{
@ -1815,8 +1838,6 @@ function loadPartTable(table, url, options={}) {
var filters = {};
var col = null;
if (!options.disableFilters) {
filters = loadTableFilters('parts');
}
@ -1884,10 +1905,11 @@ function loadPartTable(table, url, options={}) {
}
});
col = {
columns.push({
sortName: 'category',
field: 'category_detail',
title: '{% trans "Category" %}',
sortable: true,
formatter: function(value, row) {
if (row.category && row.category_detail) {
var text = shortenString(row.category_detail.pathstring);
@ -1896,81 +1918,26 @@ function loadPartTable(table, url, options={}) {
return '<em>{% trans "No category" %}</em>';
}
}
};
});
if (!options.params.ordering) {
col['sortable'] = true;
}
columns.push(col);
col = {
field: 'unallocated_stock',
columns.push({
field: 'total_in_stock',
title: '{% trans "Stock" %}',
searchable: false,
sortable: true,
formatter: function(value, row) {
var text = '';
var text = renderLink(value, `/part/${row.pk}/?display=part-stock`);
var total_stock = row.in_stock;
if (row.variant_stock) {
total_stock += row.variant_stock;
}
var text = `${total_stock}`;
// Construct extra informational badges
var badges = '';
if (total_stock == 0) {
badges += `<span class='fas fa-exclamation-circle icon-red float-right' title='{% trans "No stock" %}'></span>`;
} else if (total_stock < row.minimum_stock) {
badges += `<span class='fas fa-exclamation-circle icon-yellow float-right' title='{% trans "Low stock" %}'></span>`;
}
if (row.ordering && row.ordering > 0) {
badges += renderLink(
`<span class='fas fa-shopping-cart float-right' title='{% trans "On Order" %}: ${row.ordering}'></span>`,
`/part/${row.pk}/?display=purchase-orders`
);
}
if (row.building && row.building > 0) {
badges += renderLink(
`<span class='fas fa-tools float-right' title='{% trans "Building" %}: ${row.building}'></span>`,
`/part/${row.pk}/?display=build-orders`
);
}
if (row.variant_stock && row.variant_stock > 0) {
badges += `<span class='fas fa-info-circle float-right' title='{% trans "Includes variant stock" %}'></span>`;
}
if (row.allocated_to_build_orders > 0) {
badges += `<span class='fas fa-bookmark icon-yellow float-right' title='{% trans "Allocated to build orders" %}: ${row.allocated_to_build_orders}'></span>`;
}
if (row.allocated_to_sales_orders > 0) {
badges += `<span class='fas fa-bookmark icon-yellow float-right' title='{% trans "Allocated to sales orders" %}: ${row.allocated_to_sales_orders}'></span>`;
}
if (row.units) {
text += ` <small>${row.units}</small>`;
}
text = renderLink(text, `/part/${row.pk}/?display=part-stock`);
text += badges;
text += partStockLabel(row, {
noDemandInfo: true,
hideTotalStock: true,
classes: 'float-right',
});
return text;
}
};
if (!options.params.ordering) {
col['sortable'] = true;
}
columns.push(col);
});
// Pricing information
columns.push({
@ -1985,6 +1952,7 @@ function loadPartTable(table, url, options={}) {
}
});
// External link / URL
columns.push({
field: 'link',
title: '{% trans "Link" %}',