mirror of
https://github.com/inventree/InvenTree.git
synced 2025-05-01 21:16:46 +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
|
||||||
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
|
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
|
v100 -> 2023-03-04 : https://github.com/inventree/InvenTree/pull/4452
|
||||||
- Adds bulk delete of PurchaseOrderLineItems to API
|
- Adds bulk delete of PurchaseOrderLineItems to API
|
||||||
|
|
||||||
|
@ -1337,6 +1337,7 @@ class PartList(APIDownloadMixin, ListCreateAPI):
|
|||||||
'creation_date',
|
'creation_date',
|
||||||
'IPN',
|
'IPN',
|
||||||
'in_stock',
|
'in_stock',
|
||||||
|
'total_in_stock',
|
||||||
'unallocated_stock',
|
'unallocated_stock',
|
||||||
'category',
|
'category',
|
||||||
'last_stocktake',
|
'last_stocktake',
|
||||||
|
@ -423,7 +423,6 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
|
|||||||
'full_name',
|
'full_name',
|
||||||
'image',
|
'image',
|
||||||
'in_stock',
|
'in_stock',
|
||||||
'variant_stock',
|
|
||||||
'ordering',
|
'ordering',
|
||||||
'building',
|
'building',
|
||||||
'IPN',
|
'IPN',
|
||||||
@ -444,10 +443,12 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
|
|||||||
'stock_item_count',
|
'stock_item_count',
|
||||||
'suppliers',
|
'suppliers',
|
||||||
'thumbnail',
|
'thumbnail',
|
||||||
|
'total_in_stock',
|
||||||
'trackable',
|
'trackable',
|
||||||
'unallocated_stock',
|
'unallocated_stock',
|
||||||
'units',
|
'units',
|
||||||
'variant_of',
|
'variant_of',
|
||||||
|
'variant_stock',
|
||||||
'virtual',
|
'virtual',
|
||||||
'pricing_min',
|
'pricing_min',
|
||||||
'pricing_max',
|
'pricing_max',
|
||||||
@ -554,11 +555,20 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
|
|||||||
allocated_to_build_orders=part.filters.annotate_build_order_allocations(),
|
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
|
# Annotate with the total 'available stock' quantity
|
||||||
# This is the current stock, minus any allocations
|
# This is the current stock, minus any allocations
|
||||||
queryset = queryset.annotate(
|
queryset = queryset.annotate(
|
||||||
unallocated_stock=ExpressionWrapper(
|
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(),
|
output_field=models.DecimalField(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -579,6 +589,7 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
|
|||||||
building = serializers.FloatField(read_only=True)
|
building = serializers.FloatField(read_only=True)
|
||||||
in_stock = serializers.FloatField(read_only=True)
|
in_stock = serializers.FloatField(read_only=True)
|
||||||
variant_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)
|
ordering = serializers.FloatField(read_only=True)
|
||||||
stock_item_count = serializers.IntegerField(read_only=True)
|
stock_item_count = serializers.IntegerField(read_only=True)
|
||||||
suppliers = serializers.IntegerField(read_only=True)
|
suppliers = serializers.IntegerField(read_only=True)
|
||||||
|
@ -215,7 +215,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if part.component %}
|
{% if part.component %}
|
||||||
{% if required_build_order_quantity > 0 %}
|
{% if required_build_order_quantity > 0 or allocated_build_order_quantity > 0 %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class='fas fa-tools'></span></td>
|
<td><span class='fas fa-tools'></span></td>
|
||||||
<td>{% trans "Allocated to Build Orders" %}</td>
|
<td>{% trans "Allocated to Build Orders" %}</td>
|
||||||
@ -224,7 +224,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if part.salable %}
|
{% if part.salable %}
|
||||||
{% if required_sales_order_quantity > 0 %}
|
{% if required_sales_order_quantity > 0 or allocated_sales_order_quantity > 0 %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class='fas fa-truck'></span></td>
|
<td><span class='fas fa-truck'></span></td>
|
||||||
<td>{% trans "Allocated to Sales Orders" %}</td>
|
<td>{% trans "Allocated to Sales Orders" %}</td>
|
||||||
|
@ -404,7 +404,10 @@ def progress_bar(val, max_val, *args, **kwargs):
|
|||||||
else:
|
else:
|
||||||
style = ''
|
style = ''
|
||||||
|
|
||||||
percent = float(val / max_val) * 100
|
if max_val != 0:
|
||||||
|
percent = float(val / max_val) * 100
|
||||||
|
else:
|
||||||
|
percent = 0
|
||||||
|
|
||||||
if percent > 100:
|
if percent > 100:
|
||||||
percent = 100
|
percent = 100
|
||||||
|
@ -589,68 +589,85 @@ function partStockLabel(part, options={}) {
|
|||||||
// Prevent literal string 'null' from being displayed
|
// Prevent literal string 'null' from being displayed
|
||||||
var units = part.units || '';
|
var units = part.units || '';
|
||||||
|
|
||||||
var text = '';
|
let elements = [];
|
||||||
|
|
||||||
// Check for stock
|
// Check for stock
|
||||||
if (part.in_stock) {
|
if (part.total_in_stock) {
|
||||||
// There IS stock available for this part
|
// There IS stock available for this part
|
||||||
|
|
||||||
// Is stock "low" (below the 'minimum_stock' quantity)?
|
// Is stock "low" (below the 'minimum_stock' quantity)?
|
||||||
if ((part.minimum_stock > 0) && (part.minimum_stock > part.in_stock)) {
|
if ((part.minimum_stock > 0) && (part.minimum_stock > part.total_in_stock)) {
|
||||||
text += `{% trans "Low stock" %}: ${part.in_stock}`;
|
elements.push(`{% trans "Low stock" %}: ${part.total_in_stock}`);
|
||||||
} else if (part.unallocated_stock == 0) {
|
} else if (part.unallocated_stock <= 0) {
|
||||||
// There is no available stock at all
|
// 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) {
|
} else if (part.unallocated_stock < part.in_stock) {
|
||||||
// Unallocated quanttiy is less than total quantity
|
// Unallocated quantity is less than total quantity
|
||||||
text += `{% trans "Available" %}: ${part.unallocated_stock}/${part.in_stock}`;
|
if (options.hideTotalStock) {
|
||||||
|
elements.push(`{% trans "Available" %}: ${part.unallocated_stock}`);
|
||||||
|
} else {
|
||||||
|
elements.push(`{% trans "Available" %}: ${part.unallocated_stock}/${part.in_stock}`);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Stock is completely available
|
// Stock is completely available
|
||||||
text += `{% trans "Available" %}: ${part.unallocated_stock}`;
|
if (!options.hideTotalStock) {
|
||||||
|
elements.push(`{% trans "Available" %}: ${part.unallocated_stock}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// There IS NO stock available for this part
|
// There IS NO stock available for this part
|
||||||
text += `{% trans "No Stock" %}`;
|
elements.push(`{% trans "No Stock" %}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for items on order
|
// Check for items on order
|
||||||
if (part.ordering) {
|
if (part.ordering) {
|
||||||
text += ` | {% trans "On Order" %}: ${part.ordering}`;
|
elements.push(`{% trans "On Order" %}: ${part.ordering}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for items beeing built
|
// Check for items beeing built
|
||||||
if (part.building) {
|
if (part.building) {
|
||||||
text += ` | {% trans "Building" %}: ${part.building}`;
|
elements.push(`{% 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}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine badge color based on overall stock health
|
// 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 = '';
|
var bg_class = '';
|
||||||
|
|
||||||
if (stock_health < 0) {
|
if (stock_health < 0) {
|
||||||
// Unsatisfied demand and/or below minimum stock
|
// Unsatisfied demand and/or below minimum stock
|
||||||
bg_class = 'bg-danger';
|
bg_class = 'bg-danger';
|
||||||
@ -662,14 +679,19 @@ function partStockLabel(part, options={}) {
|
|||||||
bg_class = 'bg-success';
|
bg_class = 'bg-success';
|
||||||
}
|
}
|
||||||
|
|
||||||
// show units next to stock badge
|
// Display units next to stock badge
|
||||||
var unit_badge = '';
|
let unit_badge = '';
|
||||||
|
|
||||||
if (units && !options.no_units) {
|
if (units && !options.no_units) {
|
||||||
unit_badge = `<span class='badge rounded-pill text-muted bg-muted ${classes}'>{% trans "Unit" %}: ${units}</span> `;
|
unit_badge = `<span class='badge rounded-pill text-muted bg-muted ${classes}'>{% trans "Unit" %}: ${units}</span> `;
|
||||||
}
|
}
|
||||||
|
|
||||||
// return badge html
|
if (elements.length > 0) {
|
||||||
return `${unit_badge}<span class='badge rounded-pill ${bg_class} ${classes}'>${text}</span>`;
|
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" %}',
|
title: '{% trans "Description" %}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'in_stock',
|
field: 'total_in_stock',
|
||||||
title: '{% trans "Stock" %}',
|
title: '{% trans "Stock" %}',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
formatter: function(value, row) {
|
formatter: function(value, row) {
|
||||||
|
|
||||||
var base_stock = row.in_stock;
|
var text = renderLink(value, `/part/${row.pk}/?display=part-stock`);
|
||||||
var variant_stock = row.variant_stock || 0;
|
|
||||||
|
|
||||||
var total = base_stock + variant_stock;
|
text += partStockLabel(row, {
|
||||||
|
noDemandInfo: true,
|
||||||
|
hideTotalStock: true,
|
||||||
|
classes: 'float-right',
|
||||||
|
});
|
||||||
|
|
||||||
var text = `${total}`;
|
if (row.variant_stock > 0) {
|
||||||
|
|
||||||
if (variant_stock > 0) {
|
|
||||||
text = `<em>${text}</em>`;
|
text = `<em>${text}</em>`;
|
||||||
text += `<span title='{% trans "Includes variant stock" %}' class='fas fa-info-circle float-right icon-blue'></span>`;
|
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 filters = {};
|
||||||
|
|
||||||
var col = null;
|
|
||||||
|
|
||||||
if (!options.disableFilters) {
|
if (!options.disableFilters) {
|
||||||
filters = loadTableFilters('parts');
|
filters = loadTableFilters('parts');
|
||||||
}
|
}
|
||||||
@ -1884,10 +1905,11 @@ function loadPartTable(table, url, options={}) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
col = {
|
columns.push({
|
||||||
sortName: 'category',
|
sortName: 'category',
|
||||||
field: 'category_detail',
|
field: 'category_detail',
|
||||||
title: '{% trans "Category" %}',
|
title: '{% trans "Category" %}',
|
||||||
|
sortable: true,
|
||||||
formatter: function(value, row) {
|
formatter: function(value, row) {
|
||||||
if (row.category && row.category_detail) {
|
if (row.category && row.category_detail) {
|
||||||
var text = shortenString(row.category_detail.pathstring);
|
var text = shortenString(row.category_detail.pathstring);
|
||||||
@ -1896,81 +1918,26 @@ function loadPartTable(table, url, options={}) {
|
|||||||
return '<em>{% trans "No category" %}</em>';
|
return '<em>{% trans "No category" %}</em>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
if (!options.params.ordering) {
|
|
||||||
col['sortable'] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
columns.push(col);
|
columns.push({
|
||||||
|
field: 'total_in_stock',
|
||||||
col = {
|
|
||||||
field: 'unallocated_stock',
|
|
||||||
title: '{% trans "Stock" %}',
|
title: '{% trans "Stock" %}',
|
||||||
searchable: false,
|
sortable: true,
|
||||||
formatter: function(value, row) {
|
formatter: function(value, row) {
|
||||||
|
|
||||||
var text = '';
|
var text = renderLink(value, `/part/${row.pk}/?display=part-stock`);
|
||||||
|
|
||||||
var total_stock = row.in_stock;
|
text += partStockLabel(row, {
|
||||||
|
noDemandInfo: true,
|
||||||
if (row.variant_stock) {
|
hideTotalStock: true,
|
||||||
total_stock += row.variant_stock;
|
classes: 'float-right',
|
||||||
}
|
});
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
if (!options.params.ordering) {
|
|
||||||
col['sortable'] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
columns.push(col);
|
|
||||||
|
|
||||||
// Pricing information
|
// Pricing information
|
||||||
columns.push({
|
columns.push({
|
||||||
@ -1985,6 +1952,7 @@ function loadPartTable(table, url, options={}) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// External link / URL
|
||||||
columns.push({
|
columns.push({
|
||||||
field: 'link',
|
field: 'link',
|
||||||
title: '{% trans "Link" %}',
|
title: '{% trans "Link" %}',
|
||||||
|
Loading…
x
Reference in New Issue
Block a user