mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
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:
parent
106c238af5
commit
9c594ed52b
@ -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
|
||||
|
||||
|
@ -1337,6 +1337,7 @@ class PartList(APIDownloadMixin, ListCreateAPI):
|
||||
'creation_date',
|
||||
'IPN',
|
||||
'in_stock',
|
||||
'total_in_stock',
|
||||
'unallocated_stock',
|
||||
'category',
|
||||
'last_stocktake',
|
||||
|
@ -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)
|
||||
|
@ -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>
|
||||
|
@ -404,7 +404,10 @@ def progress_bar(val, max_val, *args, **kwargs):
|
||||
else:
|
||||
style = ''
|
||||
|
||||
percent = float(val / max_val) * 100
|
||||
if max_val != 0:
|
||||
percent = float(val / max_val) * 100
|
||||
else:
|
||||
percent = 0
|
||||
|
||||
if percent > 100:
|
||||
percent = 100
|
||||
|
@ -589,68 +589,85 @@ 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}`;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
required_build_order_quantity = 0;
|
||||
if (response.required_build_order_quantity) {
|
||||
required_build_order_quantity = response.required_build_order_quantity;
|
||||
}
|
||||
required_sales_order_quantity = 0;
|
||||
if (response.required_sales_order_quantity) {
|
||||
required_sales_order_quantity = response.required_sales_order_quantity;
|
||||
}
|
||||
}
|
||||
});
|
||||
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 "Building" %}: ${part.building}`);
|
||||
}
|
||||
|
||||
// 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 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) {
|
||||
required_build_order_quantity = 0;
|
||||
if (response.required_build_order_quantity) {
|
||||
required_build_order_quantity = response.required_build_order_quantity;
|
||||
}
|
||||
required_sales_order_quantity = 0;
|
||||
if (response.required_sales_order_quantity) {
|
||||
required_sales_order_quantity = response.required_sales_order_quantity;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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) {
|
||||
elements.push(`{% trans "Demand" %}: ${demand}`);
|
||||
}
|
||||
|
||||
stock_health -= (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
|
||||
return `${unit_badge}<span class='badge rounded-pill ${bg_class} ${classes}'>${text}</span>`;
|
||||
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" %}',
|
||||
|
Loading…
Reference in New Issue
Block a user