SupplierPart availability (#3148)

* Adds new fields to the SupplierPart model:

- available
- availability_updated

* Allow availability_updated field to be blank

* Revert "Remove stat context variables"

This reverts commit 0989c308d0.

* Increment API version

* Adds availability information to the SupplierPart API serializer

- If the 'available' field is updated, the current date is added to the availability_updated field

* Add 'available' field to SupplierPart table

* More JS refactoring

* Add unit testing for specifying availability via the API

* Display availability data on the SupplierPart detail page

* Add ability to set 'available' quantity from the SupplierPart detail page

* Revert "Revert "Remove stat context variables""

This reverts commit 3f98037f79.
This commit is contained in:
Oliver 2022-06-08 21:49:07 +10:00 committed by GitHub
parent a8a543755f
commit 258957c14c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 272 additions and 26 deletions

View File

@ -2,11 +2,14 @@
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 59 INVENTREE_API_VERSION = 60
""" """
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
v60 -> 2022-06-08 : https://github.com/inventree/InvenTree/pull/3148
- Add availability data fields to the SupplierPart model
v59 -> 2022-06-07 : https://github.com/inventree/InvenTree/pull/3154 v59 -> 2022-06-07 : https://github.com/inventree/InvenTree/pull/3154
- Adds further improvements to BulkDelete mixin class - Adds further improvements to BulkDelete mixin class
- Fixes multiple bugs in custom OPTIONS metadata implementation - Fixes multiple bugs in custom OPTIONS metadata implementation

View File

@ -0,0 +1,24 @@
# Generated by Django 3.2.13 on 2022-06-07 22:04
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('company', '0043_manufacturerpartattachment'),
]
operations = [
migrations.AddField(
model_name='supplierpart',
name='availability_updated',
field=models.DateTimeField(blank=True, help_text='Date of last update of availability data', null=True, verbose_name='Availability Updated'),
),
migrations.AddField(
model_name='supplierpart',
name='available',
field=models.DecimalField(decimal_places=3, default=0, help_text='Quantity available from supplier', max_digits=10, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Available'),
),
]

View File

@ -1,6 +1,7 @@
"""Company database model definitions.""" """Company database model definitions."""
import os import os
from datetime import datetime
from django.apps import apps from django.apps import apps
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@ -528,6 +529,25 @@ class SupplierPart(models.Model):
# TODO - Reimplement lead-time as a charfield with special validation (pattern matching). # TODO - Reimplement lead-time as a charfield with special validation (pattern matching).
# lead_time = models.DurationField(blank=True, null=True) # lead_time = models.DurationField(blank=True, null=True)
available = models.DecimalField(
max_digits=10, decimal_places=3, default=0,
validators=[MinValueValidator(0)],
verbose_name=_('Available'),
help_text=_('Quantity available from supplier'),
)
availability_updated = models.DateTimeField(
null=True, blank=True, verbose_name=_('Availability Updated'),
help_text=_('Date of last update of availability data'),
)
def update_available_quantity(self, quantity):
"""Update the available quantity for this SupplierPart"""
self.available = quantity
self.availability_updated = datetime.now()
self.save()
@property @property
def manufacturer_string(self): def manufacturer_string(self):
"""Format a MPN string for this SupplierPart. """Format a MPN string for this SupplierPart.

View File

@ -209,6 +209,10 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Initialize this serializer with extra detail fields as required""" """Initialize this serializer with extra detail fields as required"""
# Check if 'available' quantity was supplied
self.has_available_quantity = 'available' in kwargs.get('data', {})
part_detail = kwargs.pop('part_detail', True) part_detail = kwargs.pop('part_detail', True)
supplier_detail = kwargs.pop('supplier_detail', True) supplier_detail = kwargs.pop('supplier_detail', True)
manufacturer_detail = kwargs.pop('manufacturer_detail', True) manufacturer_detail = kwargs.pop('manufacturer_detail', True)
@ -242,6 +246,8 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
model = SupplierPart model = SupplierPart
fields = [ fields = [
'available',
'availability_updated',
'description', 'description',
'link', 'link',
'manufacturer', 'manufacturer',
@ -260,11 +266,34 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
'supplier_detail', 'supplier_detail',
] ]
read_only_fields = [
'availability_updated',
]
def update(self, supplier_part, data):
"""Custom update functionality for the serializer"""
available = data.pop('available', None)
response = super().update(supplier_part, data)
if available is not None and self.has_available_quantity:
supplier_part.update_available_quantity(available)
return response
def create(self, validated_data): def create(self, validated_data):
"""Extract manufacturer data and process ManufacturerPart.""" """Extract manufacturer data and process ManufacturerPart."""
# Extract 'available' quantity from the serializer
available = validated_data.pop('available', None)
# Create SupplierPart # Create SupplierPart
supplier_part = super().create(validated_data) supplier_part = super().create(validated_data)
if available is not None and self.has_available_quantity:
supplier_part.update_available_quantity(available)
# Get ManufacturerPart raw data (unvalidated) # Get ManufacturerPart raw data (unvalidated)
manufacturer = self.initial_data.get('manufacturer', None) manufacturer = self.initial_data.get('manufacturer', None)
MPN = self.initial_data.get('MPN', None) MPN = self.initial_data.get('MPN', None)

View File

@ -30,18 +30,32 @@
{% url 'admin:company_supplierpart_change' part.pk as url %} {% url 'admin:company_supplierpart_change' part.pk as url %}
{% include "admin_button.html" with url=url %} {% include "admin_button.html" with url=url %}
{% endif %} {% endif %}
{% if roles.purchase_order.add %} {% if roles.purchase_order.change or roles.purchase_order.add or roles.purchase_order.delete %}
<button type='button' class='btn btn-outline-secondary' id='order-part' title='{% trans "Order part" %}'> <div class='btn-group'>
<span class='fas fa-shopping-cart'></span> <button id='supplier-part-actions' title='{% trans "Supplier part actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'>
</button> <span class='fas fa-tools'></span> <span class='caret'></span>
{% endif %} </button>
<button type='button' class='btn btn-outline-secondary' id='edit-part' title='{% trans "Edit supplier part" %}'> <ul class='dropdown-menu'>
<span class='fas fa-edit icon-green'/> {% if roles.purchase_order.add %}
</button> <li><a class='dropdown-item' href='#' id='order-part' title='{% trans "Order Part" %}'>
{% if roles.purchase_order.delete %} <span class='fas fa-shopping-cart'></span> {% trans "Order Part" %}
<button type='button' class='btn btn-outline-secondary' id='delete-part' title='{% trans "Delete supplier part" %}'> </a></li>
<span class='fas fa-trash-alt icon-red'/> {% endif %}
</button> {% if roles.purchase_order.change %}
<li><a class='dropdown-item' href='#' id='update-part-availability' title='{% trans "Update Availability" %}'>
<span class='fas fa-building'></span> {% trans "Update Availability" %}
</a></li>
<li><a class='dropdown-item' href='#' id='edit-part' title='{% trans "Edit Supplier Part" %}'>
<span class='fas fa-edit icon-green'></span> {% trans "Edit Supplier Part" %}
</a></li>
{% endif %}
{% if roles.purchase_order.delete %}
<li><a class='dropdown-item' href='#' id='delete-part' title='{% trans "Delete Supplier Part" %}'>
<span class='fas fa-trash-alt icon-red'></span> {% trans "Delete Supplier Part" %}
</a></li>
{% endif %}
</ul>
</div>
{% endif %} {% endif %}
{% endblock actions %} {% endblock actions %}
@ -74,6 +88,13 @@ src="{% static 'img/blank_image.png' %}"
<td>{{ part.description }}{% include "clip.html"%}</td> <td>{{ part.description }}{% include "clip.html"%}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if part.availability_updated %}
<tr>
<td></td>
<td>{% trans "Available" %}</td>
<td>{% decimal part.available %}<span class='badge bg-dark rounded-pill float-right'>{% render_date part.availability_updated %}</span></td>
</tr>
{% endif %}
</table> </table>
{% endblock details %} {% endblock details %}
@ -351,6 +372,20 @@ $('#order-part, #order-part2').click(function() {
); );
}); });
{% if roles.purchase_order.change %}
$('#update-part-availability').click(function() {
editSupplierPart({{ part.pk }}, {
fields: {
available: {},
},
title: '{% trans "Update Part Availability" %}',
onSuccess: function() {
location.reload();
}
});
});
$('#edit-part').click(function () { $('#edit-part').click(function () {
editSupplierPart({{ part.pk }}, { editSupplierPart({{ part.pk }}, {
@ -360,6 +395,8 @@ $('#edit-part').click(function () {
}); });
}); });
{% endif %}
$('#delete-part').click(function() { $('#delete-part').click(function() {
inventreeGet( inventreeGet(
'{% url "api-supplier-part-detail" part.pk %}', '{% url "api-supplier-part-detail" part.pk %}',

View File

@ -6,7 +6,7 @@ from rest_framework import status
from InvenTree.api_tester import InvenTreeAPITestCase from InvenTree.api_tester import InvenTreeAPITestCase
from .models import Company from .models import Company, SupplierPart
class CompanyTest(InvenTreeAPITestCase): class CompanyTest(InvenTreeAPITestCase):
@ -146,6 +146,7 @@ class ManufacturerTest(InvenTreeAPITestCase):
'location', 'location',
'company', 'company',
'manufacturer_part', 'manufacturer_part',
'supplier_part',
] ]
roles = [ roles = [
@ -238,3 +239,111 @@ class ManufacturerTest(InvenTreeAPITestCase):
url = reverse('api-manufacturer-part-detail', kwargs={'pk': manufacturer_part_id}) url = reverse('api-manufacturer-part-detail', kwargs={'pk': manufacturer_part_id})
response = self.get(url) response = self.get(url)
self.assertEqual(response.data['MPN'], 'PART_NUMBER') self.assertEqual(response.data['MPN'], 'PART_NUMBER')
class SupplierPartTest(InvenTreeAPITestCase):
"""Unit tests for the SupplierPart API endpoints"""
fixtures = [
'category',
'part',
'location',
'company',
'manufacturer_part',
'supplier_part',
]
roles = [
'part.add',
'part.change',
'part.add',
'purchase_order.change',
]
def test_supplier_part_list(self):
"""Test the SupplierPart API list functionality"""
url = reverse('api-supplier-part-list')
# Return *all* SupplierParts
response = self.get(url, {}, expected_code=200)
self.assertEqual(len(response.data), SupplierPart.objects.count())
# Filter by Supplier reference
for supplier in Company.objects.filter(is_supplier=True):
response = self.get(url, {'supplier': supplier.pk}, expected_code=200)
self.assertEqual(len(response.data), supplier.supplied_parts.count())
# Filter by Part reference
expected = {
1: 4,
25: 2,
}
for pk, n in expected.items():
response = self.get(url, {'part': pk}, expected_code=200)
self.assertEqual(len(response.data), n)
def test_available(self):
"""Tests for updating the 'available' field"""
url = reverse('api-supplier-part-list')
# Should fail when sending an invalid 'available' field
response = self.post(
url,
{
'part': 1,
'supplier': 2,
'SKU': 'QQ',
'available': 'not a number',
},
expected_code=400,
)
self.assertIn('A valid number is required', str(response.data))
# Create a SupplierPart without specifying available quantity
response = self.post(
url,
{
'part': 1,
'supplier': 2,
'SKU': 'QQ',
},
expected_code=201
)
sp = SupplierPart.objects.get(pk=response.data['pk'])
self.assertIsNone(sp.availability_updated)
self.assertEqual(sp.available, 0)
# Now, *update* the availabile quantity via the API
self.patch(
reverse('api-supplier-part-detail', kwargs={'pk': sp.pk}),
{
'available': 1234,
},
expected_code=200,
)
sp.refresh_from_db()
self.assertIsNotNone(sp.availability_updated)
self.assertEqual(sp.available, 1234)
# We should also be able to create a SupplierPart with initial 'available' quantity
response = self.post(
url,
{
'part': 1,
'supplier': 2,
'SKU': 'QQQ',
'available': 999,
},
expected_code=201,
)
sp = SupplierPart.objects.get(pk=response.data['pk'])
self.assertEqual(sp.available, 999)
self.assertIsNotNone(sp.availability_updated)

View File

@ -1010,7 +1010,7 @@ function loadBomTable(table, options={}) {
can_build = available / row.quantity; can_build = available / row.quantity;
} }
return +can_build.toFixed(2); return formatDecimal(can_build, 2);
}, },
sorter: function(valA, valB, rowA, rowB) { sorter: function(valA, valB, rowA, rowB) {
// Function to sort the "can build" quantity // Function to sort the "can build" quantity

View File

@ -795,7 +795,7 @@ function sumAllocationsForBomRow(bom_row, allocations) {
quantity += allocation.quantity; quantity += allocation.quantity;
}); });
return parseFloat(quantity).toFixed(15); return formatDecimal(quantity, 10);
} }
@ -1490,8 +1490,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
// Store the required quantity in the row data // Store the required quantity in the row data
// Prevent weird rounding issues // Prevent weird rounding issues
row.required = parseFloat(quantity.toFixed(15)); row.required = formatDecimal(quantity, 15);
return row.required; return row.required;
} }
@ -2043,7 +2042,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
} }
// Ensure the quantity sent to the form field is correctly formatted // Ensure the quantity sent to the form field is correctly formatted
remaining = parseFloat(remaining.toFixed(15)); remaining = formatDecimal(remaining, 15);
// We only care about entries which are not yet fully allocated // We only care about entries which are not yet fully allocated
if (remaining > 0) { if (remaining > 0) {

View File

@ -189,14 +189,16 @@ function createSupplierPart(options={}) {
function editSupplierPart(part, options={}) { function editSupplierPart(part, options={}) {
var fields = supplierPartFields(); var fields = options.fields || supplierPartFields();
// Hide the "part" field // Hide the "part" field
fields.part.hidden = true; if (fields.part) {
fields.part.hidden = true;
}
constructForm(`/api/company/part/${part}/`, { constructForm(`/api/company/part/${part}/`, {
fields: fields, fields: fields,
title: '{% trans "Edit Supplier Part" %}', title: options.title || '{% trans "Edit Supplier Part" %}',
onSuccess: options.onSuccess onSuccess: options.onSuccess
}); });
} }
@ -952,6 +954,21 @@ function loadSupplierPartTable(table, url, options) {
title: '{% trans "Packaging" %}', title: '{% trans "Packaging" %}',
sortable: false, sortable: false,
}, },
{
field: 'available',
title: '{% trans "Available" %}',
sortable: true,
formatter: function(value, row) {
if (row.availability_updated) {
var html = formatDecimal(value);
var date = renderDate(row.availability_updated, {showTime: true});
html += `<span class='fas fa-info-circle float-right' title='{% trans "Last Updated" %}: ${date}'></span>`;
return html;
} else {
return '-';
}
}
},
{ {
field: 'actions', field: 'actions',
title: '', title: '',

View File

@ -4,6 +4,7 @@
blankImage, blankImage,
deleteButton, deleteButton,
editButton, editButton,
formatDecimal,
imageHoverIcon, imageHoverIcon,
makeIconBadge, makeIconBadge,
makeIconButton, makeIconButton,
@ -34,6 +35,13 @@ function deleteButton(url, text='{% trans "Delete" %}') {
} }
/* Format a decimal (floating point) number, to strip trailing zeros
*/
function formatDecimal(number, places=5) {
return +parseFloat(number).toFixed(places);
}
function blankImage() { function blankImage() {
return `/static/img/blank_image.png`; return `/static/img/blank_image.png`;
} }

View File

@ -1191,7 +1191,7 @@ function noResultBadge() {
function formatDate(row) { function formatDate(row) {
// Function for formatting date field // Function for formatting date field
var html = row.date; var html = renderDate(row.date);
if (row.user_detail) { if (row.user_detail) {
html += `<span class='badge badge-right rounded-pill bg-secondary'>${row.user_detail.username}</span>`; html += `<span class='badge badge-right rounded-pill bg-secondary'>${row.user_detail.username}</span>`;
@ -1707,13 +1707,13 @@ function loadStockTable(table, options) {
val = '# ' + row.serial; val = '# ' + row.serial;
} else if (row.quantity != available) { } else if (row.quantity != available) {
// Some quantity is available, show available *and* quantity // Some quantity is available, show available *and* quantity
var ava = +parseFloat(available).toFixed(5); var ava = formatDecimal(available);
var tot = +parseFloat(row.quantity).toFixed(5); var tot = formatDecimal(row.quantity);
val = `${ava} / ${tot}`; val = `${ava} / ${tot}`;
} else { } else {
// Format floating point numbers with this one weird trick // Format floating point numbers with this one weird trick
val = +parseFloat(value).toFixed(5); val = formatDecimal(value);
} }
var html = renderLink(val, `/stock/item/${row.pk}/`); var html = renderLink(val, `/stock/item/${row.pk}/`);