Merge pull request #2103 from SchrodingersGat/stock-adjustment-api

Stock adjustment api
This commit is contained in:
Oliver 2021-10-06 09:52:25 +11:00 committed by GitHub
commit c2ad9c4765
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 367 additions and 333 deletions

View File

@ -10,11 +10,15 @@ import common.models
INVENTREE_SW_VERSION = "0.6.0 dev" INVENTREE_SW_VERSION = "0.6.0 dev"
INVENTREE_API_VERSION = 13 INVENTREE_API_VERSION = 14
""" """
Increment this API version number whenever there is a significant change to the API that any clients need to know about Increment this API version number whenever there is a significant change to the API that any clients need to know about
v14 -> 2021-20-05
- Stock adjustment actions API is improved, using native DRF serializer support
- However adjustment actions now only support 'pk' as a lookup field
v13 -> 2021-10-05 v13 -> 2021-10-05
- Adds API endpoint to allocate stock items against a BuildOrder - Adds API endpoint to allocate stock items against a BuildOrder
- Updates StockItem API with improved filtering against BomItem data - Updates StockItem API with improved filtering against BomItem data

View File

@ -372,7 +372,7 @@
{ {
success: function(items) { success: function(items) {
adjustStock(action, items, { adjustStock(action, items, {
onSuccess: function() { success: function() {
location.reload(); location.reload();
} }
}); });

View File

@ -5,7 +5,6 @@ JSON API for the Stock app
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from decimal import Decimal, InvalidOperation
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
@ -17,7 +16,6 @@ from django.db.models import Q
from rest_framework import status from rest_framework import status
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
from rest_framework.views import APIView
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import generics, filters, permissions from rest_framework import generics, filters, permissions
@ -41,11 +39,7 @@ from order.serializers import POSerializer
import common.settings import common.settings
import common.models import common.models
from .serializers import StockItemSerializer import stock.serializers as StockSerializers
from .serializers import LocationSerializer, LocationBriefSerializer
from .serializers import StockTrackingSerializer
from .serializers import StockItemAttachmentSerializer
from .serializers import StockItemTestResultSerializer
from InvenTree.views import TreeSerializer from InvenTree.views import TreeSerializer
from InvenTree.helpers import str2bool, isNull from InvenTree.helpers import str2bool, isNull
@ -83,12 +77,12 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
""" """
queryset = StockItem.objects.all() queryset = StockItem.objects.all()
serializer_class = StockItemSerializer serializer_class = StockSerializers.StockItemSerializer
def get_queryset(self, *args, **kwargs): def get_queryset(self, *args, **kwargs):
queryset = super().get_queryset(*args, **kwargs) queryset = super().get_queryset(*args, **kwargs)
queryset = StockItemSerializer.annotate_queryset(queryset) queryset = StockSerializers.StockItemSerializer.annotate_queryset(queryset)
return queryset return queryset
@ -124,7 +118,7 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
instance.mark_for_deletion() instance.mark_for_deletion()
class StockAdjust(APIView): class StockAdjustView(generics.CreateAPIView):
""" """
A generic class for handling stocktake actions. A generic class for handling stocktake actions.
@ -138,184 +132,57 @@ class StockAdjust(APIView):
queryset = StockItem.objects.none() queryset = StockItem.objects.none()
allow_missing_quantity = False def get_serializer_context(self):
def get_items(self, request): context = super().get_serializer_context()
"""
Return a list of items posted to the endpoint.
Will raise validation errors if the items are not
correctly formatted.
"""
_items = [] context['request'] = self.request
if 'item' in request.data: return context
_items = [request.data['item']]
elif 'items' in request.data:
_items = request.data['items']
else:
_items = []
if len(_items) == 0:
raise ValidationError(_('Request must contain list of stock items'))
# List of validated items
self.items = []
for entry in _items:
if not type(entry) == dict:
raise ValidationError(_('Improperly formatted data'))
# Look for a 'pk' value (use 'id' as a backup)
pk = entry.get('pk', entry.get('id', None))
try:
pk = int(pk)
except (ValueError, TypeError):
raise ValidationError(_('Each entry must contain a valid integer primary-key'))
try:
item = StockItem.objects.get(pk=pk)
except (StockItem.DoesNotExist):
raise ValidationError({
pk: [_('Primary key does not match valid stock item')]
})
if self.allow_missing_quantity and 'quantity' not in entry:
entry['quantity'] = item.quantity
try:
quantity = Decimal(str(entry.get('quantity', None)))
except (ValueError, TypeError, InvalidOperation):
raise ValidationError({
pk: [_('Invalid quantity value')]
})
if quantity < 0:
raise ValidationError({
pk: [_('Quantity must not be less than zero')]
})
self.items.append({
'item': item,
'quantity': quantity
})
# Extract 'notes' field
self.notes = str(request.data.get('notes', ''))
class StockCount(StockAdjust): class StockCount(StockAdjustView):
""" """
Endpoint for counting stock (performing a stocktake). Endpoint for counting stock (performing a stocktake).
""" """
def post(self, request, *args, **kwargs): serializer_class = StockSerializers.StockCountSerializer
self.get_items(request)
n = 0
for item in self.items:
if item['item'].stocktake(item['quantity'], request.user, notes=self.notes):
n += 1
return Response({'success': _('Updated stock for {n} items').format(n=n)})
class StockAdd(StockAdjust): class StockAdd(StockAdjustView):
""" """
Endpoint for adding a quantity of stock to an existing StockItem Endpoint for adding a quantity of stock to an existing StockItem
""" """
def post(self, request, *args, **kwargs): serializer_class = StockSerializers.StockAddSerializer
self.get_items(request)
n = 0
for item in self.items:
if item['item'].add_stock(item['quantity'], request.user, notes=self.notes):
n += 1
return Response({"success": "Added stock for {n} items".format(n=n)})
class StockRemove(StockAdjust): class StockRemove(StockAdjustView):
""" """
Endpoint for removing a quantity of stock from an existing StockItem. Endpoint for removing a quantity of stock from an existing StockItem.
""" """
def post(self, request, *args, **kwargs): serializer_class = StockSerializers.StockRemoveSerializer
self.get_items(request)
n = 0
for item in self.items:
if item['quantity'] > item['item'].quantity:
raise ValidationError({
item['item'].pk: [_('Specified quantity exceeds stock quantity')]
})
if item['item'].take_stock(item['quantity'], request.user, notes=self.notes):
n += 1
return Response({"success": "Removed stock for {n} items".format(n=n)})
class StockTransfer(StockAdjust): class StockTransfer(StockAdjustView):
""" """
API endpoint for performing stock movements API endpoint for performing stock movements
""" """
allow_missing_quantity = True serializer_class = StockSerializers.StockTransferSerializer
def post(self, request, *args, **kwargs):
data = request.data
try:
location = StockLocation.objects.get(pk=data.get('location', None))
except (ValueError, StockLocation.DoesNotExist):
raise ValidationError({'location': [_('Valid location must be specified')]})
n = 0
self.get_items(request)
for item in self.items:
if item['quantity'] > item['item'].quantity:
raise ValidationError({
item['item'].pk: [_('Specified quantity exceeds stock quantity')]
})
# If quantity is not specified, move the entire stock
if item['quantity'] in [0, None]:
item['quantity'] = item['item'].quantity
if item['item'].move(location, self.notes, request.user, quantity=item['quantity']):
n += 1
return Response({'success': _('Moved {n} parts to {loc}').format(
n=n,
loc=str(location),
)})
class StockLocationList(generics.ListCreateAPIView): class StockLocationList(generics.ListCreateAPIView):
""" API endpoint for list view of StockLocation objects: """
API endpoint for list view of StockLocation objects:
- GET: Return list of StockLocation objects - GET: Return list of StockLocation objects
- POST: Create a new StockLocation - POST: Create a new StockLocation
""" """
queryset = StockLocation.objects.all() queryset = StockLocation.objects.all()
serializer_class = LocationSerializer serializer_class = StockSerializers.LocationSerializer
def filter_queryset(self, queryset): def filter_queryset(self, queryset):
""" """
@ -517,7 +384,7 @@ class StockList(generics.ListCreateAPIView):
- POST: Create a new StockItem - POST: Create a new StockItem
""" """
serializer_class = StockItemSerializer serializer_class = StockSerializers.StockItemSerializer
queryset = StockItem.objects.all() queryset = StockItem.objects.all()
filterset_class = StockFilter filterset_class = StockFilter
@ -639,7 +506,7 @@ class StockList(generics.ListCreateAPIView):
# Serialize each StockLocation object # Serialize each StockLocation object
for location in locations: for location in locations:
location_map[location.pk] = LocationBriefSerializer(location).data location_map[location.pk] = StockSerializers.LocationBriefSerializer(location).data
# Now update each StockItem with the related StockLocation data # Now update each StockItem with the related StockLocation data
for stock_item in data: for stock_item in data:
@ -665,7 +532,7 @@ class StockList(generics.ListCreateAPIView):
queryset = super().get_queryset(*args, **kwargs) queryset = super().get_queryset(*args, **kwargs)
queryset = StockItemSerializer.annotate_queryset(queryset) queryset = StockSerializers.StockItemSerializer.annotate_queryset(queryset)
# Do not expose StockItem objects which are scheduled for deletion # Do not expose StockItem objects which are scheduled for deletion
queryset = queryset.filter(scheduled_for_deletion=False) queryset = queryset.filter(scheduled_for_deletion=False)
@ -954,7 +821,7 @@ class StockAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
""" """
queryset = StockItemAttachment.objects.all() queryset = StockItemAttachment.objects.all()
serializer_class = StockItemAttachmentSerializer serializer_class = StockSerializers.StockItemAttachmentSerializer
filter_backends = [ filter_backends = [
DjangoFilterBackend, DjangoFilterBackend,
@ -973,7 +840,7 @@ class StockAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMix
""" """
queryset = StockItemAttachment.objects.all() queryset = StockItemAttachment.objects.all()
serializer_class = StockItemAttachmentSerializer serializer_class = StockSerializers.StockItemAttachmentSerializer
class StockItemTestResultDetail(generics.RetrieveUpdateDestroyAPIView): class StockItemTestResultDetail(generics.RetrieveUpdateDestroyAPIView):
@ -982,7 +849,7 @@ class StockItemTestResultDetail(generics.RetrieveUpdateDestroyAPIView):
""" """
queryset = StockItemTestResult.objects.all() queryset = StockItemTestResult.objects.all()
serializer_class = StockItemTestResultSerializer serializer_class = StockSerializers.StockItemTestResultSerializer
class StockItemTestResultList(generics.ListCreateAPIView): class StockItemTestResultList(generics.ListCreateAPIView):
@ -991,7 +858,7 @@ class StockItemTestResultList(generics.ListCreateAPIView):
""" """
queryset = StockItemTestResult.objects.all() queryset = StockItemTestResult.objects.all()
serializer_class = StockItemTestResultSerializer serializer_class = StockSerializers.StockItemTestResultSerializer
filter_backends = [ filter_backends = [
DjangoFilterBackend, DjangoFilterBackend,
@ -1039,7 +906,7 @@ class StockTrackingDetail(generics.RetrieveAPIView):
""" """
queryset = StockItemTracking.objects.all() queryset = StockItemTracking.objects.all()
serializer_class = StockTrackingSerializer serializer_class = StockSerializers.StockTrackingSerializer
class StockTrackingList(generics.ListAPIView): class StockTrackingList(generics.ListAPIView):
@ -1052,7 +919,7 @@ class StockTrackingList(generics.ListAPIView):
""" """
queryset = StockItemTracking.objects.all() queryset = StockItemTracking.objects.all()
serializer_class = StockTrackingSerializer serializer_class = StockSerializers.StockTrackingSerializer
def get_serializer(self, *args, **kwargs): def get_serializer(self, *args, **kwargs):
try: try:
@ -1088,7 +955,7 @@ class StockTrackingList(generics.ListAPIView):
if 'location' in deltas: if 'location' in deltas:
try: try:
location = StockLocation.objects.get(pk=deltas['location']) location = StockLocation.objects.get(pk=deltas['location'])
serializer = LocationSerializer(location) serializer = StockSerializers.LocationSerializer(location)
deltas['location_detail'] = serializer.data deltas['location_detail'] = serializer.data
except: except:
pass pass
@ -1097,7 +964,7 @@ class StockTrackingList(generics.ListAPIView):
if 'stockitem' in deltas: if 'stockitem' in deltas:
try: try:
stockitem = StockItem.objects.get(pk=deltas['stockitem']) stockitem = StockItem.objects.get(pk=deltas['stockitem'])
serializer = StockItemSerializer(stockitem) serializer = StockSerializers.StockItemSerializer(stockitem)
deltas['stockitem_detail'] = serializer.data deltas['stockitem_detail'] = serializer.data
except: except:
pass pass
@ -1179,7 +1046,7 @@ class LocationDetail(generics.RetrieveUpdateDestroyAPIView):
""" """
queryset = StockLocation.objects.all() queryset = StockLocation.objects.all()
serializer_class = LocationSerializer serializer_class = StockSerializers.LocationSerializer
stock_api_urls = [ stock_api_urls = [

View File

@ -2,27 +2,29 @@
JSON serializers for Stock app JSON serializers for Stock app
""" """
from rest_framework import serializers # -*- coding: utf-8 -*-
from __future__ import unicode_literals
from decimal import Decimal
from datetime import datetime, timedelta
from django.db import transaction
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.db.models.functions import Coalesce
from django.db.models import Case, When, Value
from django.db.models import BooleanField
from django.db.models import Q
from rest_framework import serializers
from rest_framework.serializers import ValidationError
from sql_util.utils import SubquerySum, SubqueryCount
from .models import StockItem, StockLocation from .models import StockItem, StockLocation
from .models import StockItemTracking from .models import StockItemTracking
from .models import StockItemAttachment from .models import StockItemAttachment
from .models import StockItemTestResult from .models import StockItemTestResult
from django.db.models.functions import Coalesce
from django.db.models import Case, When, Value
from django.db.models import BooleanField
from django.db.models import Q
from sql_util.utils import SubquerySum, SubqueryCount
from decimal import Decimal
from datetime import datetime, timedelta
import common.models import common.models
from common.settings import currency_code_default, currency_code_mappings from common.settings import currency_code_default, currency_code_mappings
@ -396,3 +398,196 @@ class StockTrackingSerializer(InvenTreeModelSerializer):
'label', 'label',
'tracking_type', 'tracking_type',
] ]
class StockAdjustmentItemSerializer(serializers.Serializer):
"""
Serializer for a single StockItem within a stock adjument request.
Fields:
- item: StockItem object
- quantity: Numerical quantity
"""
class Meta:
fields = [
'item',
'quantity'
]
pk = serializers.PrimaryKeyRelatedField(
queryset=StockItem.objects.all(),
many=False,
allow_null=False,
required=True,
label='stock_item',
help_text=_('StockItem primary key value')
)
quantity = serializers.DecimalField(
max_digits=15,
decimal_places=5,
min_value=0,
required=True
)
class StockAdjustmentSerializer(serializers.Serializer):
"""
Base class for managing stock adjustment actions via the API
"""
class Meta:
fields = [
'items',
'notes',
]
items = StockAdjustmentItemSerializer(many=True)
notes = serializers.CharField(
required=False,
allow_blank=True,
label=_("Notes"),
help_text=_("Stock transaction notes"),
)
def validate(self, data):
super().validate(data)
items = data.get('items', [])
if len(items) == 0:
raise ValidationError(_("A list of stock items must be provided"))
return data
class StockCountSerializer(StockAdjustmentSerializer):
"""
Serializer for counting stock items
"""
def save(self):
request = self.context['request']
data = self.validated_data
items = data['items']
notes = data.get('notes', '')
with transaction.atomic():
for item in items:
stock_item = item['pk']
quantity = item['quantity']
stock_item.stocktake(
quantity,
request.user,
notes=notes
)
class StockAddSerializer(StockAdjustmentSerializer):
"""
Serializer for adding stock to stock item(s)
"""
def save(self):
request = self.context['request']
data = self.validated_data
notes = data.get('notes', '')
with transaction.atomic():
for item in data['items']:
stock_item = item['pk']
quantity = item['quantity']
stock_item.add_stock(
quantity,
request.user,
notes=notes
)
class StockRemoveSerializer(StockAdjustmentSerializer):
"""
Serializer for removing stock from stock item(s)
"""
def save(self):
request = self.context['request']
data = self.validated_data
notes = data.get('notes', '')
with transaction.atomic():
for item in data['items']:
stock_item = item['pk']
quantity = item['quantity']
stock_item.take_stock(
quantity,
request.user,
notes=notes
)
class StockTransferSerializer(StockAdjustmentSerializer):
"""
Serializer for transferring (moving) stock item(s)
"""
location = serializers.PrimaryKeyRelatedField(
queryset=StockLocation.objects.all(),
many=False,
required=True,
allow_null=False,
label=_('Location'),
help_text=_('Destination stock location'),
)
class Meta:
fields = [
'items',
'notes',
'location',
]
def validate(self, data):
super().validate(data)
# TODO: Any specific validation of location field?
return data
def save(self):
request = self.context['request']
data = self.validated_data
items = data['items']
notes = data.get('notes', '')
location = data['location']
with transaction.atomic():
for item in items:
stock_item = item['pk']
quantity = item['quantity']
stock_item.move(
location,
notes,
request.user,
quantity=quantity
)

View File

@ -561,7 +561,7 @@ function itemAdjust(action) {
{ {
success: function(item) { success: function(item) {
adjustStock(action, [item], { adjustStock(action, [item], {
onSuccess: function() { success: function() {
location.reload(); location.reload();
} }
}); });

View File

@ -287,7 +287,7 @@
{ {
success: function(items) { success: function(items) {
adjustStock(action, items, { adjustStock(action, items, {
onSuccess: function() { success: function() {
location.reload(); location.reload();
} }
}); });

View File

@ -513,31 +513,34 @@ class StocktakeTest(StockAPITestCase):
# POST with a valid action # POST with a valid action
response = self.post(url, data) response = self.post(url, data)
self.assertContains(response, "must contain list", status_code=status.HTTP_400_BAD_REQUEST)
self.assertIn("This field is required", str(response.data["items"]))
data['items'] = [{ data['items'] = [{
'no': 'aa' 'no': 'aa'
}] }]
# POST without a PK # POST without a PK
response = self.post(url, data) response = self.post(url, data, expected_code=400)
self.assertContains(response, 'must contain a valid integer primary-key', status_code=status.HTTP_400_BAD_REQUEST)
self.assertIn('This field is required', str(response.data))
# POST with an invalid PK # POST with an invalid PK
data['items'] = [{ data['items'] = [{
'pk': 10 'pk': 10
}] }]
response = self.post(url, data) response = self.post(url, data, expected_code=400)
self.assertContains(response, 'does not match valid stock item', status_code=status.HTTP_400_BAD_REQUEST)
self.assertContains(response, 'object does not exist', status_code=status.HTTP_400_BAD_REQUEST)
# POST with missing quantity value # POST with missing quantity value
data['items'] = [{ data['items'] = [{
'pk': 1234 'pk': 1234
}] }]
response = self.post(url, data) response = self.post(url, data, expected_code=400)
self.assertContains(response, 'Invalid quantity value', status_code=status.HTTP_400_BAD_REQUEST) self.assertContains(response, 'This field is required', status_code=status.HTTP_400_BAD_REQUEST)
# POST with an invalid quantity value # POST with an invalid quantity value
data['items'] = [{ data['items'] = [{
@ -546,7 +549,7 @@ class StocktakeTest(StockAPITestCase):
}] }]
response = self.post(url, data) response = self.post(url, data)
self.assertContains(response, 'Invalid quantity value', status_code=status.HTTP_400_BAD_REQUEST) self.assertContains(response, 'A valid number is required', status_code=status.HTTP_400_BAD_REQUEST)
data['items'] = [{ data['items'] = [{
'pk': 1234, 'pk': 1234,
@ -554,18 +557,7 @@ class StocktakeTest(StockAPITestCase):
}] }]
response = self.post(url, data) response = self.post(url, data)
self.assertContains(response, 'must not be less than zero', status_code=status.HTTP_400_BAD_REQUEST) self.assertContains(response, 'Ensure this value is greater than or equal to 0', status_code=status.HTTP_400_BAD_REQUEST)
# Test with a single item
data = {
'item': {
'pk': 1234,
'quantity': '10',
}
}
response = self.post(url, data)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_transfer(self): def test_transfer(self):
""" """
@ -573,24 +565,27 @@ class StocktakeTest(StockAPITestCase):
""" """
data = { data = {
'item': { 'items': [
{
'pk': 1234, 'pk': 1234,
'quantity': 10, 'quantity': 10,
}, }
],
'location': 1, 'location': 1,
'notes': "Moving to a new location" 'notes': "Moving to a new location"
} }
url = reverse('api-stock-transfer') url = reverse('api-stock-transfer')
response = self.post(url, data) # This should succeed
self.assertContains(response, "Moved 1 parts to", status_code=status.HTTP_200_OK) response = self.post(url, data, expected_code=201)
# Now try one which will fail due to a bad location # Now try one which will fail due to a bad location
data['location'] = 'not a location' data['location'] = 'not a location'
response = self.post(url, data) response = self.post(url, data, expected_code=400)
self.assertContains(response, 'Valid location must be specified', status_code=status.HTTP_400_BAD_REQUEST)
self.assertContains(response, 'Incorrect type. Expected pk value', status_code=status.HTTP_400_BAD_REQUEST)
class StockItemDeletionTest(StockAPITestCase): class StockItemDeletionTest(StockAPITestCase):

View File

@ -7,7 +7,7 @@ from __future__ import unicode_literals
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.views.generic.edit import FormMixin from django.views.generic.edit import FormMixin
from django.views.generic import DetailView, ListView, UpdateView from django.views.generic import DetailView, ListView
from django.forms.models import model_to_dict from django.forms.models import model_to_dict
from django.forms import HiddenInput from django.forms import HiddenInput
from django.urls import reverse from django.urls import reverse
@ -145,29 +145,6 @@ class StockItemDetail(InvenTreeRoleMixin, DetailView):
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
class StockItemNotes(InvenTreeRoleMixin, UpdateView):
""" View for editing the 'notes' field of a StockItem object """
context_object_name = 'item'
template_name = 'stock/item_notes.html'
model = StockItem
role_required = 'stock.view'
fields = ['notes']
def get_success_url(self):
return reverse('stock-item-notes', kwargs={'pk': self.get_object().id})
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['editing'] = str2bool(self.request.GET.get('edit', ''))
return ctx
class StockLocationEdit(AjaxUpdateView): class StockLocationEdit(AjaxUpdateView):
""" """
View for editing details of a StockLocation. View for editing details of a StockLocation.

View File

@ -4,15 +4,12 @@
/* globals /* globals
attachSelect, attachSelect,
attachToggle,
blankImage,
enableField, enableField,
clearField, clearField,
clearFieldOptions, clearFieldOptions,
closeModal, closeModal,
constructField,
constructFormBody, constructFormBody,
constructNumberInput,
createNewModal,
getFormFieldValue, getFormFieldValue,
global_settings, global_settings,
handleFormErrors, handleFormErrors,
@ -247,7 +244,7 @@ function adjustStock(action, items, options={}) {
break; break;
} }
var image = item.part_detail.thumbnail || item.part_detail.image || blankImage(); var thumb = thumbnailImage(item.part_detail.thumbnail || item.part_detail.image);
var status = stockStatusDisplay(item.status, { var status = stockStatusDisplay(item.status, {
classes: 'float-right' classes: 'float-right'
@ -268,14 +265,18 @@ function adjustStock(action, items, options={}) {
var actionInput = ''; var actionInput = '';
if (actionTitle != null) { if (actionTitle != null) {
actionInput = constructNumberInput( actionInput = constructField(
item.pk, `items_quantity_${pk}`,
{ {
value: value, type: 'decimal',
min_value: minValue, min_value: minValue,
max_value: maxValue, max_value: maxValue,
read_only: readonly, value: value,
title: readonly ? '{% trans "Quantity cannot be adjusted for serialized stock" %}' : '{% trans "Specify stock quantity" %}', title: readonly ? '{% trans "Quantity cannot be adjusted for serialized stock" %}' : '{% trans "Specify stock quantity" %}',
required: true,
},
{
hideLabels: true,
} }
); );
} }
@ -293,7 +294,7 @@ function adjustStock(action, items, options={}) {
html += ` html += `
<tr id='stock_item_${pk}' class='stock-item-row'> <tr id='stock_item_${pk}' class='stock-item-row'>
<td id='part_${pk}'><img src='${image}' class='hover-img-thumb'> ${item.part_detail.full_name}</td> <td id='part_${pk}'>${thumb} ${item.part_detail.full_name}</td>
<td id='stock_${pk}'>${quantity}${status}</td> <td id='stock_${pk}'>${quantity}${status}</td>
<td id='location_${pk}'>${location}</td> <td id='location_${pk}'>${location}</td>
<td id='action_${pk}'> <td id='action_${pk}'>
@ -319,50 +320,89 @@ function adjustStock(action, items, options={}) {
html += `</tbody></table>`; html += `</tbody></table>`;
var modal = createNewModal({ var extraFields = {};
title: formTitle,
});
// Extra fields if (specifyLocation) {
var extraFields = { extraFields.location = {};
location: {
label: '{% trans "Location" %}',
help_text: '{% trans "Select destination stock location" %}',
type: 'related field',
required: true,
api_url: `/api/stock/location/`,
model: 'stocklocation',
name: 'location',
},
notes: {
label: '{% trans "Notes" %}',
help_text: '{% trans "Stock transaction notes" %}',
type: 'string',
name: 'notes',
}
};
if (!specifyLocation) {
delete extraFields.location;
} }
constructFormBody({}, { if (action != 'delete') {
preFormContent: html, extraFields.notes = {};
}
constructForm(url, {
method: 'POST',
fields: extraFields, fields: extraFields,
preFormContent: html,
confirm: true, confirm: true,
confirmMessage: '{% trans "Confirm stock adjustment" %}', confirmMessage: '{% trans "Confirm stock adjustment" %}',
modal: modal, title: formTitle,
onSubmit: function(fields) { afterRender: function(fields, opts) {
// Add button callbacks to remove rows
$(opts.modal).find('.button-stock-item-remove').click(function() {
var pk = $(this).attr('pk');
// "Delete" action gets handled differently $(opts.modal).find(`#stock_item_${pk}`).remove();
if (action == 'delete') { });
var requests = []; // Initialize "location" field
if (specifyLocation) {
initializeRelatedField(
{
name: 'location',
type: 'related field',
model: 'stocklocation',
required: true,
},
null,
opts
);
}
},
onSubmit: function(fields, opts) {
// Extract data elements from the form
var data = {
items: [],
};
if (action != 'delete') {
data.notes = getFormFieldValue('notes', {}, opts);
}
if (specifyLocation) {
data.location = getFormFieldValue('location', {}, opts);
}
var item_pk_values = [];
items.forEach(function(item) { items.forEach(function(item) {
var pk = item.pk;
// Does the row exist in the form?
var row = $(opts.modal).find(`#stock_item_${pk}`);
if (row) {
item_pk_values.push(pk);
var quantity = getFormFieldValue(`items_quantity_${pk}`, {}, opts);
data.items.push({
pk: pk,
quantity: quantity,
});
}
});
// Delete action is handled differently
if (action == 'delete') {
var requests = [];
item_pk_values.forEach(function(pk) {
requests.push( requests.push(
inventreeDelete( inventreeDelete(
`/api/stock/${item.pk}/`, `/api/stock/${pk}/`,
) )
); );
}); });
@ -370,72 +410,40 @@ function adjustStock(action, items, options={}) {
// Wait for *all* the requests to complete // Wait for *all* the requests to complete
$.when.apply($, requests).done(function() { $.when.apply($, requests).done(function() {
// Destroy the modal window // Destroy the modal window
$(modal).modal('hide'); $(opts.modal).modal('hide');
if (options.onSuccess) { if (options.success) {
options.onSuccess(); options.success();
} }
}); });
return; return;
} }
// Data to transmit opts.nested = {
var data = { 'items': item_pk_values,
items: [],
}; };
// Add values for each selected stock item
items.forEach(function(item) {
var q = getFormFieldValue(item.pk, {}, {modal: modal});
if (q != null) {
data.items.push({pk: item.pk, quantity: q});
}
});
// Add in extra field data
for (var field_name in extraFields) {
data[field_name] = getFormFieldValue(
field_name,
fields[field_name],
{
modal: modal,
}
);
}
inventreePut( inventreePut(
url, url,
data, data,
{ {
method: 'POST', method: 'POST',
success: function() { success: function(response) {
// Hide the modal
$(opts.modal).modal('hide');
// Destroy the modal window if (options.success) {
$(modal).modal('hide'); options.success(response);
if (options.onSuccess) {
options.onSuccess();
} }
}, },
error: function(xhr) { error: function(xhr) {
switch (xhr.status) { switch (xhr.status) {
case 400: case 400:
handleFormErrors(xhr.responseJSON, fields, opts);
// Handle errors for standard fields
handleFormErrors(
xhr.responseJSON,
extraFields,
{
modal: modal,
}
);
break; break;
default: default:
$(modal).modal('hide'); $(opts.modal).modal('hide');
showApiError(xhr); showApiError(xhr);
break; break;
} }
@ -444,18 +452,6 @@ function adjustStock(action, items, options={}) {
); );
} }
}); });
// Attach callbacks for the action buttons
$(modal).find('.button-stock-item-remove').click(function() {
var pk = $(this).attr('pk');
$(modal).find(`#stock_item_${pk}`).remove();
});
attachToggle(modal);
$(modal + ' .select2-container').addClass('select-full-width');
$(modal + ' .select2-container').css('width', '100%');
} }
@ -1258,7 +1254,7 @@ function loadStockTable(table, options) {
var items = $(table).bootstrapTable('getSelections'); var items = $(table).bootstrapTable('getSelections');
adjustStock(action, items, { adjustStock(action, items, {
onSuccess: function() { success: function() {
$(table).bootstrapTable('refresh'); $(table).bootstrapTable('refresh');
} }
}); });