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}/`);