2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-07-01 11:10:54 +00:00

Merge pull request #1804 from SchrodingersGat/api-stock-adjustments

Api stock adjustments
This commit is contained in:
Oliver
2021-07-12 22:16:56 +10:00
committed by GitHub
13 changed files with 656 additions and 673 deletions

View File

@ -120,9 +120,6 @@ class StockAdjust(APIView):
- StockAdd: add stock items
- StockRemove: remove stock items
- StockTransfer: transfer stock items
# TODO - This needs serious refactoring!!!
"""
queryset = StockItem.objects.none()
@ -143,7 +140,10 @@ class StockAdjust(APIView):
elif 'items' in request.data:
_items = request.data['items']
else:
raise ValidationError({'items': 'Request must contain list of stock items'})
_items = []
if len(_items) == 0:
raise ValidationError(_('Request must contain list of stock items'))
# List of validated items
self.items = []
@ -151,13 +151,22 @@ class StockAdjust(APIView):
for entry in _items:
if not type(entry) == dict:
raise ValidationError({'error': 'Improperly formatted data'})
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:
pk = entry.get('pk', None)
item = StockItem.objects.get(pk=pk)
except (ValueError, StockItem.DoesNotExist):
raise ValidationError({'pk': 'Each entry must contain a valid pk field'})
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
@ -165,16 +174,21 @@ class StockAdjust(APIView):
try:
quantity = Decimal(str(entry.get('quantity', None)))
except (ValueError, TypeError, InvalidOperation):
raise ValidationError({'quantity': "Each entry must contain a valid quantity value"})
raise ValidationError({
pk: [_('Invalid quantity value')]
})
if quantity < 0:
raise ValidationError({'quantity': 'Quantity field must not be less than zero'})
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', ''))
@ -228,6 +242,11 @@ class StockRemove(StockAdjust):
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
@ -243,19 +262,24 @@ class StockTransfer(StockAdjust):
def post(self, request, *args, **kwargs):
self.get_items(request)
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'})
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
@ -454,13 +478,6 @@ class StockList(generics.ListCreateAPIView):
- GET: Return a list of all StockItem objects (with optional query filters)
- POST: Create a new StockItem
Additional query parameters are available:
- location: Filter stock by location
- category: Filter by parts belonging to a certain category
- supplier: Filter by supplier
- ancestor: Filter by an 'ancestor' StockItem
- status: Filter by the StockItem status
"""
serializer_class = StockItemSerializer
@ -482,7 +499,6 @@ class StockList(generics.ListCreateAPIView):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
# TODO - Save the user who created this item
item = serializer.save()
# A location was *not* specified - try to infer it
@ -1092,47 +1108,41 @@ class LocationDetail(generics.RetrieveUpdateDestroyAPIView):
serializer_class = LocationSerializer
stock_endpoints = [
url(r'^$', StockDetail.as_view(), name='api-stock-detail'),
]
location_endpoints = [
url(r'^(?P<pk>\d+)/', LocationDetail.as_view(), name='api-location-detail'),
url(r'^.*$', StockLocationList.as_view(), name='api-location-list'),
]
stock_api_urls = [
url(r'location/', include(location_endpoints)),
url(r'^location/', include([
url(r'^(?P<pk>\d+)/', LocationDetail.as_view(), name='api-location-detail'),
url(r'^.*$', StockLocationList.as_view(), name='api-location-list'),
])),
# These JSON endpoints have been replaced (for now) with server-side form rendering - 02/06/2019
# TODO: Remove server-side forms for stock adjustment!!!
url(r'count/?', StockCount.as_view(), name='api-stock-count'),
url(r'add/?', StockAdd.as_view(), name='api-stock-add'),
url(r'remove/?', StockRemove.as_view(), name='api-stock-remove'),
url(r'transfer/?', StockTransfer.as_view(), name='api-stock-transfer'),
# Endpoints for bulk stock adjustment actions
url(r'^count/', StockCount.as_view(), name='api-stock-count'),
url(r'^add/', StockAdd.as_view(), name='api-stock-add'),
url(r'^remove/', StockRemove.as_view(), name='api-stock-remove'),
url(r'^transfer/', StockTransfer.as_view(), name='api-stock-transfer'),
# Base URL for StockItemAttachment API endpoints
# StockItemAttachment API endpoints
url(r'^attachment/', include([
url(r'^(?P<pk>\d+)/', StockAttachmentDetail.as_view(), name='api-stock-attachment-detail'),
url(r'^$', StockAttachmentList.as_view(), name='api-stock-attachment-list'),
])),
# Base URL for StockItemTestResult API endpoints
# StockItemTestResult API endpoints
url(r'^test/', include([
url(r'^(?P<pk>\d+)/', StockItemTestResultDetail.as_view(), name='api-stock-test-result-detail'),
url(r'^.*$', StockItemTestResultList.as_view(), name='api-stock-test-result-list'),
])),
# StockItemTracking API endpoints
url(r'^track/', include([
url(r'^(?P<pk>\d+)/', StockTrackingDetail.as_view(), name='api-stock-tracking-detail'),
url(r'^.*$', StockTrackingList.as_view(), name='api-stock-tracking-list'),
])),
url(r'^tree/?', StockCategoryTree.as_view(), name='api-stock-tree'),
url(r'^tree/', StockCategoryTree.as_view(), name='api-stock-tree'),
# Detail for a single stock item
url(r'^(?P<pk>\d+)/', include(stock_endpoints)),
url(r'^(?P<pk>\d+)/', StockDetail.as_view(), name='api-stock-detail'),
# Anything else
url(r'^.*$', StockList.as_view(), name='api-stock-list'),
]

View File

@ -534,14 +534,21 @@ $("#barcode-scan-into-location").click(function() {
});
function itemAdjust(action) {
launchModalForm("/stock/adjust/",
inventreeGet(
'{% url "api-stock-detail" item.pk %}',
{
data: {
action: action,
item: {{ item.id }},
},
reload: true,
follow: true,
part_detail: true,
location_detail: true,
},
{
success: function(item) {
adjustStock(action, [item], {
onSuccess: function() {
location.reload();
}
});
}
}
);
}

View File

@ -59,11 +59,23 @@
{% if owner_control.value == "False" or owner_control.value == "True" and user in owners or user.is_superuser %}
{% if roles.stock.change %}
<div class='btn-group'>
<button id='stock-actions' title='{% trans "Stock actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'><span class='fas fa-boxes'></span> <span class='caret'></span></button>
<button id='stock-actions' title='{% trans "Stock actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
<span class='fas fa-boxes'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu' role='menu'>
<li><a href='#' id='location-count'><span class='fas fa-clipboard-list'></span>
{% trans "Count stock" %}</a></li>
</ul>
<li>
<a href='#' id='location-count'>
<span class='fas fa-clipboard-list'></span>
{% trans "Count stock" %}
</a>
</li>
<li>
<a href='#' id='location-move'>
<span class='fas fa-exchange-alt'></span>
{% trans "Transfer stock" %}
</a>
</li>
</ul>
</div>
{% endif %}
{% if roles.stock_location.change %}
@ -215,14 +227,34 @@
});
{% if location %}
$("#location-count").click(function() {
launchModalForm("/stock/adjust/", {
data: {
action: "count",
function adjustLocationStock(action) {
inventreeGet(
'{% url "api-stock-list" %}',
{
location: {{ location.id }},
reload: true,
in_stock: true,
part_detail: true,
location_detail: true,
},
{
success: function(items) {
adjustStock(action, items, {
onSuccess: function() {
location.reload();
}
});
}
}
});
);
}
$("#location-count").click(function() {
adjustLocationStock('count');
});
$("#location-move").click(function() {
adjustLocationStock('move');
});
$('#print-label').click(function() {

View File

@ -7,8 +7,8 @@ from __future__ import unicode_literals
from datetime import datetime, timedelta
from rest_framework import status
from django.urls import reverse
from rest_framework import status
from InvenTree.status_codes import StockStatus
from InvenTree.api_tester import InvenTreeAPITestCase
@ -456,30 +456,32 @@ class StocktakeTest(StockAPITestCase):
# POST without a PK
response = self.post(url, data)
self.assertContains(response, 'must contain a valid pk', status_code=status.HTTP_400_BAD_REQUEST)
self.assertContains(response, 'must contain a valid integer primary-key', status_code=status.HTTP_400_BAD_REQUEST)
# POST with a PK but no quantity
# POST with an invalid PK
data['items'] = [{
'pk': 10
}]
response = self.post(url, data)
self.assertContains(response, 'must contain a valid pk', status_code=status.HTTP_400_BAD_REQUEST)
self.assertContains(response, 'does not match valid stock item', status_code=status.HTTP_400_BAD_REQUEST)
# POST with missing quantity value
data['items'] = [{
'pk': 1234
}]
response = self.post(url, data)
self.assertContains(response, 'must contain a valid quantity', status_code=status.HTTP_400_BAD_REQUEST)
self.assertContains(response, 'Invalid quantity value', status_code=status.HTTP_400_BAD_REQUEST)
# POST with an invalid quantity value
data['items'] = [{
'pk': 1234,
'quantity': '10x0d'
}]
response = self.post(url, data)
self.assertContains(response, 'must contain a valid quantity', status_code=status.HTTP_400_BAD_REQUEST)
self.assertContains(response, 'Invalid quantity value', status_code=status.HTTP_400_BAD_REQUEST)
data['items'] = [{
'pk': 1234,

View File

@ -105,31 +105,6 @@ class StockItemTest(StockViewTestCase):
response = self.client.get(reverse('stock-item-qr', args=(9999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
def test_adjust_items(self):
url = reverse('stock-adjust')
# Move items
response = self.client.get(url, {'stock[]': [1, 2, 3, 4, 5], 'action': 'move'}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# Count part
response = self.client.get(url, {'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# Remove items
response = self.client.get(url, {'location': 1, 'action': 'take'}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# Add items
response = self.client.get(url, {'item': 1, 'action': 'add'}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# Blank response
response = self.client.get(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# TODO - Tests for POST data
def test_edit_item(self):
# Test edit view for StockItem
response = self.client.get(reverse('stock-item-edit', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')

View File

@ -64,8 +64,6 @@ stock_urls = [
url(r'^track/', include(stock_tracking_urls)),
url(r'^adjust/?', views.StockAdjust.as_view(), name='stock-adjust'),
url(r'^export-options/?', views.StockExportOptions.as_view(), name='stock-export-options'),
url(r'^export/?', views.StockExport.as_view(), name='stock-export'),

View File

@ -749,343 +749,6 @@ class StockItemUninstall(AjaxView, FormMixin):
return context
class StockAdjust(AjaxView, FormMixin):
""" View for enacting simple stock adjustments:
- Take items from stock
- Add items to stock
- Count items
- Move stock
- Delete stock items
"""
ajax_template_name = 'stock/stock_adjust.html'
ajax_form_title = _('Adjust Stock')
form_class = StockForms.AdjustStockForm
stock_items = []
role_required = 'stock.change'
def get_GET_items(self):
""" Return list of stock items initally requested using GET.
Items can be retrieved by:
a) List of stock ID - stock[]=1,2,3,4,5
b) Parent part - part=3
c) Parent location - location=78
d) Single item - item=2
"""
# Start with all 'in stock' items
items = StockItem.objects.filter(StockItem.IN_STOCK_FILTER)
# Client provides a list of individual stock items
if 'stock[]' in self.request.GET:
items = items.filter(id__in=self.request.GET.getlist('stock[]'))
# Client provides a PART reference
elif 'part' in self.request.GET:
items = items.filter(part=self.request.GET.get('part'))
# Client provides a LOCATION reference
elif 'location' in self.request.GET:
items = items.filter(location=self.request.GET.get('location'))
# Client provides a single StockItem lookup
elif 'item' in self.request.GET:
items = [StockItem.objects.get(id=self.request.GET.get('item'))]
# Unsupported query (no items)
else:
items = []
for item in items:
# Initialize quantity to zero for addition/removal
if self.stock_action in ['take', 'add']:
item.new_quantity = 0
# Initialize quantity at full amount for counting or moving
else:
item.new_quantity = item.quantity
return items
def get_POST_items(self):
""" Return list of stock items sent back by client on a POST request """
items = []
for item in self.request.POST:
if item.startswith('stock-id-'):
pk = item.replace('stock-id-', '')
q = self.request.POST[item]
try:
stock_item = StockItem.objects.get(pk=pk)
except StockItem.DoesNotExist:
continue
stock_item.new_quantity = q
items.append(stock_item)
return items
def get_stock_action_titles(self):
# Choose form title and action column based on the action
titles = {
'move': [_('Move Stock Items'), _('Move')],
'count': [_('Count Stock Items'), _('Count')],
'take': [_('Remove From Stock'), _('Take')],
'add': [_('Add Stock Items'), _('Add')],
'delete': [_('Delete Stock Items'), _('Delete')],
}
self.ajax_form_title = titles[self.stock_action][0]
self.stock_action_title = titles[self.stock_action][1]
def get_context_data(self):
context = super().get_context_data()
context['stock_items'] = self.stock_items
context['stock_action'] = self.stock_action.strip().lower()
self.get_stock_action_titles()
context['stock_action_title'] = self.stock_action_title
# Quantity column will be read-only in some circumstances
context['edit_quantity'] = not self.stock_action == 'delete'
return context
def get_form(self):
form = super().get_form()
if not self.stock_action == 'move':
form.fields.pop('destination')
form.fields.pop('set_loc')
return form
def get(self, request, *args, **kwargs):
self.request = request
# Action
self.stock_action = request.GET.get('action', '').lower()
# Pick a default action...
if self.stock_action not in ['move', 'count', 'take', 'add', 'delete']:
self.stock_action = 'count'
# Save list of items!
self.stock_items = self.get_GET_items()
return self.renderJsonResponse(request, self.get_form())
def post(self, request, *args, **kwargs):
self.request = request
self.stock_action = request.POST.get('stock_action', 'invalid').strip().lower()
# Update list of stock items
self.stock_items = self.get_POST_items()
form = self.get_form()
valid = form.is_valid()
for item in self.stock_items:
try:
item.new_quantity = Decimal(item.new_quantity)
except ValueError:
item.error = _('Must enter integer value')
valid = False
continue
if item.new_quantity < 0:
item.error = _('Quantity must be positive')
valid = False
continue
if self.stock_action in ['move', 'take']:
if item.new_quantity > item.quantity:
item.error = _('Quantity must not exceed {x}').format(x=item.quantity)
valid = False
continue
confirmed = str2bool(request.POST.get('confirm'))
if not confirmed:
valid = False
form.add_error('confirm', _('Confirm stock adjustment'))
data = {
'form_valid': valid,
}
if valid:
result = self.do_action(note=form.cleaned_data['note'])
data['success'] = result
# Special case - Single Stock Item
# If we deplete the stock item, we MUST redirect to a new view
single_item = len(self.stock_items) == 1
if result and single_item:
# Was the entire stock taken?
item = self.stock_items[0]
if item.quantity == 0:
# Instruct the form to redirect
data['url'] = reverse('stock-index')
return self.renderJsonResponse(request, form, data=data, context=self.get_context_data())
def do_action(self, note=None):
""" Perform stock adjustment action """
if self.stock_action == 'move':
destination = None
set_default_loc = str2bool(self.request.POST.get('set_loc', False))
try:
destination = StockLocation.objects.get(id=self.request.POST.get('destination'))
except StockLocation.DoesNotExist:
pass
except ValueError:
pass
return self.do_move(destination, set_default_loc, note=note)
elif self.stock_action == 'add':
return self.do_add(note=note)
elif self.stock_action == 'take':
return self.do_take(note=note)
elif self.stock_action == 'count':
return self.do_count(note=note)
elif self.stock_action == 'delete':
return self.do_delete(note=note)
else:
return _('No action performed')
def do_add(self, note=None):
count = 0
for item in self.stock_items:
if item.new_quantity <= 0:
continue
item.add_stock(item.new_quantity, self.request.user, notes=note)
count += 1
return _('Added stock to {n} items').format(n=count)
def do_take(self, note=None):
count = 0
for item in self.stock_items:
if item.new_quantity <= 0:
continue
item.take_stock(item.new_quantity, self.request.user, notes=note)
count += 1
return _('Removed stock from {n} items').format(n=count)
def do_count(self, note=None):
count = 0
for item in self.stock_items:
item.stocktake(item.new_quantity, self.request.user, notes=note)
count += 1
return _("Counted stock for {n} items".format(n=count))
def do_move(self, destination, set_loc=None, note=None):
""" Perform actual stock movement """
count = 0
for item in self.stock_items:
# Avoid moving zero quantity
if item.new_quantity <= 0:
continue
# If we wish to set the destination location to the default one
if set_loc:
item.part.default_location = destination
item.part.save()
# Do not move to the same location (unless the quantity is different)
if destination == item.location and item.new_quantity == item.quantity:
continue
item.move(destination, note, self.request.user, quantity=item.new_quantity)
count += 1
# Is ownership control enabled?
stock_ownership_control = InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL')
if stock_ownership_control:
# Fetch destination owner
destination_owner = destination.owner
if destination_owner:
# Update owner
item.owner = destination_owner
item.save()
if count == 0:
return _('No items were moved')
else:
return _('Moved {n} items to {dest}').format(
n=count,
dest=destination.pathstring)
def do_delete(self):
""" Delete multiple stock items """
count = 0
# note = self.request.POST['note']
for item in self.stock_items:
# TODO - In the future, StockItems should not be 'deleted'
# TODO - Instead, they should be marked as "inactive"
item.delete()
count += 1
return _("Deleted {n} stock items").format(n=count)
class StockItemEdit(AjaxUpdateView):
"""
View for editing details of a single StockItem