Return from customer (#3120)

* Adds ability to return item into stock via the API

* Remove old server-side form / view for returning stock from a customer

* Add unit tests for new API endpoint
This commit is contained in:
Oliver 2022-06-03 08:36:08 +10:00 committed by GitHub
parent 5fef6563d8
commit 4b3f77763d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 113 additions and 55 deletions

View File

@ -2,11 +2,14 @@
# InvenTree API version
INVENTREE_API_VERSION = 54
INVENTREE_API_VERSION = 55
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v55 -> 2022-06-02 : https://github.com/inventree/InvenTree/pull/3120
- Converts the 'StockItemReturn' functionality to make use of the API
v54 -> 2022-06-02 : https://github.com/inventree/InvenTree/pull/3117
- Adds 'available_stock' annotation on the SalesOrderLineItem API
- Adds (well, fixes) 'overdue' annotation on the SalesOrderLineItem API

View File

@ -106,7 +106,7 @@ class StockItemContextMixin:
class StockItemSerialize(StockItemContextMixin, generics.CreateAPIView):
"""API endpoint for serializing a stock item."""
queryset = StockItem.objects.none()
queryset = StockItem.objects.all()
serializer_class = StockSerializers.SerializeStockItemSerializer
@ -118,17 +118,24 @@ class StockItemInstall(StockItemContextMixin, generics.CreateAPIView):
- stock_item must be serialized (and not belong to another item)
"""
queryset = StockItem.objects.none()
queryset = StockItem.objects.all()
serializer_class = StockSerializers.InstallStockItemSerializer
class StockItemUninstall(StockItemContextMixin, generics.CreateAPIView):
"""API endpoint for removing (uninstalling) items from this item."""
queryset = StockItem.objects.none()
queryset = StockItem.objects.all()
serializer_class = StockSerializers.UninstallStockItemSerializer
class StockItemReturn(StockItemContextMixin, generics.CreateAPIView):
"""API endpoint for returning a stock item from a customer"""
queryset = StockItem.objects.all()
serializer_class = StockSerializers.ReturnStockItemSerializer
class StockAdjustView(generics.CreateAPIView):
"""A generic class for handling stocktake actions.
@ -1370,6 +1377,7 @@ stock_api_urls = [
re_path(r'^(?P<pk>\d+)/', include([
re_path(r'^install/', StockItemInstall.as_view(), name='api-stock-item-install'),
re_path(r'^metadata/', StockMetadata.as_view(), name='api-stock-item-metadata'),
re_path(r'^return/', StockItemReturn.as_view(), name='api-stock-item-return'),
re_path(r'^serialize/', StockItemSerialize.as_view(), name='api-stock-item-serialize'),
re_path(r'^uninstall/', StockItemUninstall.as_view(), name='api-stock-item-uninstall'),
re_path(r'^.*$', StockDetail.as_view(), name='api-stock-detail'),

View File

@ -5,21 +5,6 @@ from InvenTree.forms import HelperForm
from .models import StockItem, StockItemTracking
class ReturnStockItemForm(HelperForm):
"""Form for manually returning a StockItem into stock.
TODO: This could be a simple API driven form!
"""
class Meta:
"""Metaclass options."""
model = StockItem
fields = [
'location',
]
class ConvertStockItemForm(HelperForm):
"""Form for converting a StockItem to a variant of its current part.

View File

@ -894,7 +894,8 @@ class StockItem(MetadataMixin, MPTTModel):
# Return the reference to the stock item
return item
def returnFromCustomer(self, location, user=None, **kwargs):
@transaction.atomic
def return_from_customer(self, location, user=None, **kwargs):
"""Return stock item from customer, back into the specified location."""
notes = kwargs.get('notes', '')

View File

@ -464,6 +464,48 @@ class UninstallStockItemSerializer(serializers.Serializer):
)
class ReturnStockItemSerializer(serializers.Serializer):
"""DRF serializer for returning a stock item from a customer"""
class Meta:
"""Metaclass options"""
fields = [
'location',
'note',
]
location = serializers.PrimaryKeyRelatedField(
queryset=StockLocation.objects.all(),
many=False, required=True, allow_null=False,
label=_('Location'),
help_text=_('Destination location for returned item'),
)
notes = serializers.CharField(
label=_('Notes'),
help_text=_('Add transaction note (optional)'),
required=False, allow_blank=True,
)
def save(self):
"""Save the serialzier to return the item into stock"""
item = self.context['item']
request = self.context['request']
data = self.validated_data
location = data['location']
notes = data.get('notes', '')
item.return_from_customer(
location,
user=request.user,
notes=notes
)
class LocationTreeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for a simple tree view."""

View File

@ -632,11 +632,21 @@ $('#stock-remove').click(function() {
{% else %}
$("#stock-return-from-customer").click(function() {
launchModalForm("{% url 'stock-item-return' item.id %}",
{
reload: true,
}
);
constructForm('{% url "api-stock-item-return" item.pk %}', {
fields: {
location: {
{% if item.part.default_location %}
value: {{ item.part.default_location.pk }},
{% endif %}
},
notes: {},
},
method: 'POST',
title: '{% trans "Return to Stock" %}',
reload: true,
});
});
{% endif %}

View File

@ -663,6 +663,45 @@ class StockItemTest(StockAPITestCase):
self.assertIsNone(sub_item.belongs_to)
self.assertEqual(sub_item.location.pk, 1)
def test_return_from_customer(self):
"""Test that we can return a StockItem from a customer, via the API"""
# Assign item to customer
item = StockItem.objects.get(pk=521)
customer = company.models.Company.objects.get(pk=4)
item.customer = customer
item.save()
n_entries = item.tracking_info_count
url = reverse('api-stock-item-return', kwargs={'pk': item.pk})
# Empty POST will fail
response = self.post(
url, {},
expected_code=400
)
self.assertIn('This field is required', str(response.data['location']))
response = self.post(
url,
{
'location': '1',
'notes': 'Returned from this customer for testing',
},
expected_code=201,
)
item.refresh_from_db()
# A new stock tracking entry should have been created
self.assertEqual(n_entries + 1, item.tracking_info_count)
# The item is now in stock
self.assertIsNone(item.customer)
class StocktakeTest(StockAPITestCase):
"""Series of tests for the Stocktake API."""

View File

@ -21,7 +21,6 @@ stock_item_detail_urls = [
re_path(r'^delete/', views.StockItemDelete.as_view(), name='stock-item-delete'),
re_path(r'^qr_code/', views.StockItemQRCode.as_view(), name='stock-item-qr'),
re_path(r'^delete_test_data/', views.StockItemDeleteTestData.as_view(), name='stock-item-delete-test-data'),
re_path(r'^return/', views.StockItemReturnToStock.as_view(), name='stock-item-return'),
re_path(r'^add_tracking/', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'),

View File

@ -125,35 +125,6 @@ class StockLocationQRCode(QRCodeView):
return None
class StockItemReturnToStock(AjaxUpdateView):
"""View for returning a stock item (which is assigned to a customer) to stock."""
model = StockItem
ajax_form_title = _("Return to Stock")
context_object_name = "item"
form_class = StockForms.ReturnStockItemForm
def validate(self, item, form, **kwargs):
"""Make sure required data is there."""
location = form.cleaned_data.get('location', None)
if not location:
form.add_error('location', _('Specify a valid location'))
def save(self, item, form, **kwargs):
"""Return stock."""
location = form.cleaned_data.get('location', None)
if location:
item.returnFromCustomer(location, self.request.user)
def get_data(self):
"""Set success message."""
return {
'success': _('Stock item returned from customer')
}
class StockItemDeleteTestData(AjaxUpdateView):
"""View for deleting all test data."""