From 258957c14c371b16578337bb8a440279d4b8fa74 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 8 Jun 2022 21:49:07 +1000 Subject: [PATCH] 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 0989c308d0cea9b9405a1338d257b542c6d33d73. * 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 3f98037f7947aa4c85cf4008c2d216d034987f2e. --- InvenTree/InvenTree/api_version.py | 5 +- .../migrations/0044_auto_20220607_2204.py | 24 ++++ InvenTree/company/models.py | 20 ++++ InvenTree/company/serializers.py | 29 +++++ .../templates/company/supplier_part.html | 61 ++++++++-- InvenTree/company/test_api.py | 111 +++++++++++++++++- InvenTree/templates/js/translated/bom.js | 2 +- InvenTree/templates/js/translated/build.js | 7 +- InvenTree/templates/js/translated/company.js | 23 +++- InvenTree/templates/js/translated/helpers.js | 8 ++ InvenTree/templates/js/translated/stock.js | 8 +- 11 files changed, 272 insertions(+), 26 deletions(-) create mode 100644 InvenTree/company/migrations/0044_auto_20220607_2204.py diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index e90f60d1b7..2daa9e3da7 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/InvenTree/company/migrations/0044_auto_20220607_2204.py b/InvenTree/company/migrations/0044_auto_20220607_2204.py new file mode 100644 index 0000000000..2a8f4890bf --- /dev/null +++ b/InvenTree/company/migrations/0044_auto_20220607_2204.py @@ -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'), + ), + ] diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py index 0af1ec73bc..26c7cca506 100644 --- a/InvenTree/company/models.py +++ b/InvenTree/company/models.py @@ -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. diff --git a/InvenTree/company/serializers.py b/InvenTree/company/serializers.py index a24945e949..08947605ab 100644 --- a/InvenTree/company/serializers.py +++ b/InvenTree/company/serializers.py @@ -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) diff --git a/InvenTree/company/templates/company/supplier_part.html b/InvenTree/company/templates/company/supplier_part.html index eac1586bad..5b8045cba4 100644 --- a/InvenTree/company/templates/company/supplier_part.html +++ b/InvenTree/company/templates/company/supplier_part.html @@ -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 %} - -{% endif %} - -{% if roles.purchase_order.delete %} - +{% if roles.purchase_order.change or roles.purchase_order.add or roles.purchase_order.delete %} +
+ + +
{% endif %} {% endblock actions %} @@ -74,6 +88,13 @@ src="{% static 'img/blank_image.png' %}" {{ part.description }}{% include "clip.html"%} {% endif %} + {% if part.availability_updated %} + + + {% trans "Available" %} + {% decimal part.available %}{% render_date part.availability_updated %} + + {% endif %} {% 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 %}', diff --git a/InvenTree/company/test_api.py b/InvenTree/company/test_api.py index d3dd12eb81..38ee466a35 100644 --- a/InvenTree/company/test_api.py +++ b/InvenTree/company/test_api.py @@ -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) diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js index b0f0999f35..f800c10d1b 100644 --- a/InvenTree/templates/js/translated/bom.js +++ b/InvenTree/templates/js/translated/bom.js @@ -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 diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index b17c6282a0..bde3b61242 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -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) { diff --git a/InvenTree/templates/js/translated/company.js b/InvenTree/templates/js/translated/company.js index 7254fe461b..dd87c2ef83 100644 --- a/InvenTree/templates/js/translated/company.js +++ b/InvenTree/templates/js/translated/company.js @@ -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 += ``; + return html; + } else { + return '-'; + } + } + }, { field: 'actions', title: '', diff --git a/InvenTree/templates/js/translated/helpers.js b/InvenTree/templates/js/translated/helpers.js index 5747c2f71e..ddd3678e3b 100644 --- a/InvenTree/templates/js/translated/helpers.js +++ b/InvenTree/templates/js/translated/helpers.js @@ -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`; } diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index ec5d74f1de..4af520eca4 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -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 += `${row.user_detail.username}`; @@ -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}/`);