mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +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:
parent
a8a543755f
commit
258957c14c
@ -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.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 %}',
|
||||
|
@ -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}/`);
|
||||
|
Loading…
Reference in New Issue
Block a user