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 = 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
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
- Adds further improvements to BulkDelete mixin class
- 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."""
import os
from datetime import datetime
from django.apps import apps
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).
# 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
def manufacturer_string(self):
"""Format a MPN string for this SupplierPart.

View File

@ -209,6 +209,10 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
def __init__(self, *args, **kwargs):
"""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)
supplier_detail = kwargs.pop('supplier_detail', True)
manufacturer_detail = kwargs.pop('manufacturer_detail', True)
@ -242,6 +246,8 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
model = SupplierPart
fields = [
'available',
'availability_updated',
'description',
'link',
'manufacturer',
@ -260,11 +266,34 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
'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):
"""Extract manufacturer data and process ManufacturerPart."""
# Extract 'available' quantity from the serializer
available = validated_data.pop('available', None)
# Create SupplierPart
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)
manufacturer = self.initial_data.get('manufacturer', None)
MPN = self.initial_data.get('MPN', None)

View File

@ -30,18 +30,32 @@
{% url 'admin:company_supplierpart_change' part.pk as url %}
{% include "admin_button.html" with url=url %}
{% endif %}
{% if roles.purchase_order.add %}
<button type='button' class='btn btn-outline-secondary' id='order-part' title='{% trans "Order part" %}'>
<span class='fas fa-shopping-cart'></span>
</button>
{% endif %}
<button type='button' class='btn btn-outline-secondary' id='edit-part' title='{% trans "Edit supplier part" %}'>
<span class='fas fa-edit icon-green'/>
</button>
{% if roles.purchase_order.delete %}
<button type='button' class='btn btn-outline-secondary' id='delete-part' title='{% trans "Delete supplier part" %}'>
<span class='fas fa-trash-alt icon-red'/>
</button>
{% if roles.purchase_order.change or roles.purchase_order.add or roles.purchase_order.delete %}
<div class='btn-group'>
<button id='supplier-part-actions' title='{% trans "Supplier part actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'>
<span class='fas fa-tools'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu'>
{% if roles.purchase_order.add %}
<li><a class='dropdown-item' href='#' id='order-part' title='{% trans "Order Part" %}'>
<span class='fas fa-shopping-cart'></span> {% trans "Order Part" %}
</a></li>
{% endif %}
{% 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 %}
{% endblock actions %}
@ -74,6 +88,13 @@ src="{% static 'img/blank_image.png' %}"
<td>{{ part.description }}{% include "clip.html"%}</td>
</tr>
{% 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>
{% 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 () {
editSupplierPart({{ part.pk }}, {
@ -360,6 +395,8 @@ $('#edit-part').click(function () {
});
});
{% endif %}
$('#delete-part').click(function() {
inventreeGet(
'{% 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 .models import Company
from .models import Company, SupplierPart
class CompanyTest(InvenTreeAPITestCase):
@ -146,6 +146,7 @@ class ManufacturerTest(InvenTreeAPITestCase):
'location',
'company',
'manufacturer_part',
'supplier_part',
]
roles = [
@ -238,3 +239,111 @@ class ManufacturerTest(InvenTreeAPITestCase):
url = reverse('api-manufacturer-part-detail', kwargs={'pk': manufacturer_part_id})
response = self.get(url)
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;
}
return +can_build.toFixed(2);
return formatDecimal(can_build, 2);
},
sorter: function(valA, valB, rowA, rowB) {
// Function to sort the "can build" quantity

View File

@ -795,7 +795,7 @@ function sumAllocationsForBomRow(bom_row, allocations) {
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
// Prevent weird rounding issues
row.required = parseFloat(quantity.toFixed(15));
row.required = formatDecimal(quantity, 15);
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
remaining = parseFloat(remaining.toFixed(15));
remaining = formatDecimal(remaining, 15);
// We only care about entries which are not yet fully allocated
if (remaining > 0) {

View File

@ -189,14 +189,16 @@ function createSupplierPart(options={}) {
function editSupplierPart(part, options={}) {
var fields = supplierPartFields();
var fields = options.fields || supplierPartFields();
// Hide the "part" field
fields.part.hidden = true;
if (fields.part) {
fields.part.hidden = true;
}
constructForm(`/api/company/part/${part}/`, {
fields: fields,
title: '{% trans "Edit Supplier Part" %}',
title: options.title || '{% trans "Edit Supplier Part" %}',
onSuccess: options.onSuccess
});
}
@ -952,6 +954,21 @@ function loadSupplierPartTable(table, url, options) {
title: '{% trans "Packaging" %}',
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',
title: '',

View File

@ -4,6 +4,7 @@
blankImage,
deleteButton,
editButton,
formatDecimal,
imageHoverIcon,
makeIconBadge,
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() {
return `/static/img/blank_image.png`;
}

View File

@ -1191,7 +1191,7 @@ function noResultBadge() {
function formatDate(row) {
// Function for formatting date field
var html = row.date;
var html = renderDate(row.date);
if (row.user_detail) {
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;
} else if (row.quantity != available) {
// Some quantity is available, show available *and* quantity
var ava = +parseFloat(available).toFixed(5);
var tot = +parseFloat(row.quantity).toFixed(5);
var ava = formatDecimal(available);
var tot = formatDecimal(row.quantity);
val = `${ava} / ${tot}`;
} else {
// 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}/`);