mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 13:15:43 +00:00 
			
		
		
		
	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 commit0989c308d0. * 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 commit3f98037f79.
This commit is contained in:
		| @@ -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 | ||||
|   | ||||
							
								
								
									
										24
									
								
								InvenTree/company/migrations/0044_auto_20220607_2204.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								InvenTree/company/migrations/0044_auto_20220607_2204.py
									
									
									
									
									
										Normal 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'), | ||||
|         ), | ||||
|     ] | ||||
| @@ -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. | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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.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 %} | ||||
| <button type='button' class='btn btn-outline-secondary' id='order-part' title='{% trans "Order part" %}'> | ||||
|     <span class='fas fa-shopping-cart'></span> | ||||
| </button> | ||||
|         <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 %} | ||||
| <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> | ||||
|         <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 %}', | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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) { | ||||
|   | ||||
| @@ -189,14 +189,16 @@ function createSupplierPart(options={}) { | ||||
|  | ||||
| function editSupplierPart(part, options={}) { | ||||
|  | ||||
|     var fields = supplierPartFields(); | ||||
|     var fields = options.fields || supplierPartFields(); | ||||
|  | ||||
|     // Hide the "part" field | ||||
|     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: '', | ||||
|   | ||||
| @@ -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`; | ||||
| } | ||||
|   | ||||
| @@ -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}/`); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user