mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge pull request #1804 from SchrodingersGat/api-stock-adjustments
Api stock adjustments
This commit is contained in:
commit
bd8b52d7d2
@ -49,9 +49,25 @@
|
||||
<span id='part-price-icon' class='fas fa-dollar-sign'/>
|
||||
</button>
|
||||
{% if roles.stock.change %}
|
||||
<button type='button' class='btn btn-default' id='part-count' title='{% trans "Count part stock" %}'>
|
||||
<span class='fas fa-clipboard-list'/>
|
||||
</button>
|
||||
<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>
|
||||
<ul class='dropdown-menu'>
|
||||
<li>
|
||||
<a href='#' id='part-count'>
|
||||
<span class='fas fa-clipboard-list'></span>
|
||||
{% trans "Count part stock" %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href='#' id='part-move'>
|
||||
<span class='fas fa-exchange-alt'></span>
|
||||
{% trans "Transfer part stock" %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if part.purchaseable %}
|
||||
{% if roles.purchase_order.add %}
|
||||
@ -272,14 +288,34 @@
|
||||
printPartLabels([{{ part.pk }}]);
|
||||
});
|
||||
|
||||
$("#part-count").click(function() {
|
||||
launchModalForm("/stock/adjust/", {
|
||||
data: {
|
||||
action: "count",
|
||||
function adjustPartStock(action) {
|
||||
inventreeGet(
|
||||
'{% url "api-stock-list" %}',
|
||||
{
|
||||
part: {{ part.id }},
|
||||
in_stock: true,
|
||||
allow_variants: true,
|
||||
part_detail: true,
|
||||
location_detail: true,
|
||||
},
|
||||
reload: true,
|
||||
});
|
||||
{
|
||||
success: function(items) {
|
||||
adjustStock(action, items, {
|
||||
onSuccess: function() {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
$("#part-move").click(function() {
|
||||
adjustPartStock('move');
|
||||
});
|
||||
|
||||
$("#part-count").click(function() {
|
||||
adjustPartStock('count');
|
||||
});
|
||||
|
||||
$("#price-button").click(function() {
|
||||
|
@ -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'),
|
||||
]
|
||||
|
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
@ -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() {
|
||||
|
@ -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,
|
||||
|
@ -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')
|
||||
|
@ -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'),
|
||||
|
||||
|
@ -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
|
||||
|
@ -1,3 +1,6 @@
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
var jQuery = window.$;
|
||||
|
||||
// using jQuery
|
||||
@ -139,3 +142,48 @@ function inventreeDelete(url, options={}) {
|
||||
inventreePut(url, {}, options);
|
||||
|
||||
}
|
||||
|
||||
|
||||
function showApiError(xhr) {
|
||||
|
||||
var title = null;
|
||||
var message = null;
|
||||
|
||||
switch (xhr.status) {
|
||||
case 0: // No response
|
||||
title = '{% trans "No Response" %}';
|
||||
message = '{% trans "No response from the InvenTree server" %}';
|
||||
break;
|
||||
case 400: // Bad request
|
||||
// Note: Normally error code 400 is handled separately,
|
||||
// and should now be shown here!
|
||||
title = '{% trans "Error 400: Bad request" %}';
|
||||
message = '{% trans "API request returned error code 400" %}';
|
||||
break;
|
||||
case 401: // Not authenticated
|
||||
title = '{% trans "Error 401: Not Authenticated" %}';
|
||||
message = '{% trans "Authentication credentials not supplied" %}';
|
||||
break;
|
||||
case 403: // Permission denied
|
||||
title = '{% trans "Error 403: Permission Denied" %}';
|
||||
message = '{% trans "You do not have the required permissions to access this function" %}';
|
||||
break;
|
||||
case 404: // Resource not found
|
||||
title = '{% trans "Error 404: Resource Not Found" %}';
|
||||
message = '{% trans "The requested resource could not be located on the server" %}';
|
||||
break;
|
||||
case 408: // Timeout
|
||||
title = '{% trans "Error 408: Timeout" %}';
|
||||
message = '{% trans "Connection timeout while requesting data from server" %}';
|
||||
break;
|
||||
default:
|
||||
title = '{% trans "Unhandled Error Code" %}';
|
||||
message = `{% trans "Error code" %}: ${xhr.status}`;
|
||||
break;
|
||||
}
|
||||
|
||||
message += "<hr>";
|
||||
message += renderErrorMessage(xhr);
|
||||
|
||||
showAlertDialog(title, message);
|
||||
}
|
@ -395,11 +395,11 @@ function constructFormBody(fields, options) {
|
||||
|
||||
for (var name in displayed_fields) {
|
||||
|
||||
// Only push names which are actually in the set of fields
|
||||
if (name in fields) {
|
||||
field_names.push(name);
|
||||
} else {
|
||||
console.log(`WARNING: '${name}' does not match a valid field name.`);
|
||||
field_names.push(name);
|
||||
|
||||
// Field not specified in the API, but the client wishes to add it!
|
||||
if (!(name in fields)) {
|
||||
fields[name] = displayed_fields[name];
|
||||
}
|
||||
}
|
||||
|
||||
@ -423,9 +423,7 @@ function constructFormBody(fields, options) {
|
||||
break;
|
||||
}
|
||||
|
||||
var f = constructField(name, field, options);
|
||||
|
||||
html += f;
|
||||
html += constructField(name, field, options);
|
||||
}
|
||||
|
||||
// TODO: Dynamically create the modals,
|
||||
@ -441,7 +439,15 @@ function constructFormBody(fields, options) {
|
||||
modalEnable(modal, true);
|
||||
|
||||
// Insert generated form content
|
||||
$(modal).find('.modal-form-content').html(html);
|
||||
$(modal).find('#form-content').html(html);
|
||||
|
||||
if (options.preFormContent) {
|
||||
$(modal).find('#pre-form-content').html(options.preFormContent);
|
||||
}
|
||||
|
||||
if (options.postFormContent) {
|
||||
$(modal).find('#post-form-content').html(options.postFormContent);
|
||||
}
|
||||
|
||||
// Clear any existing buttons from the modal
|
||||
$(modal).find('#modal-footer-buttons').html('');
|
||||
@ -474,7 +480,21 @@ function constructFormBody(fields, options) {
|
||||
|
||||
$(modal).on('click', '#modal-form-submit', function() {
|
||||
|
||||
submitFormData(fields, options);
|
||||
// Immediately disable the "submit" button,
|
||||
// to prevent the form being submitted multiple times!
|
||||
$(options.modal).find('#modal-form-submit').prop('disabled', true);
|
||||
|
||||
// Run custom code before normal form submission
|
||||
if (options.beforeSubmit) {
|
||||
options.beforeSubmit(fields, options);
|
||||
}
|
||||
|
||||
// Run custom code instead of normal form submission
|
||||
if (options.onSubmit) {
|
||||
options.onSubmit(fields, options);
|
||||
} else {
|
||||
submitFormData(fields, options);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -511,10 +531,6 @@ function insertConfirmButton(options) {
|
||||
*/
|
||||
function submitFormData(fields, options) {
|
||||
|
||||
// Immediately disable the "submit" button,
|
||||
// to prevent the form being submitted multiple times!
|
||||
$(options.modal).find('#modal-form-submit').prop('disabled', true);
|
||||
|
||||
// Form data to be uploaded to the server
|
||||
// Only used if file / image upload is required
|
||||
var form_data = new FormData();
|
||||
@ -581,47 +597,9 @@ function submitFormData(fields, options) {
|
||||
case 400: // Bad request
|
||||
handleFormErrors(xhr.responseJSON, fields, options);
|
||||
break;
|
||||
case 0: // No response
|
||||
$(options.modal).modal('hide');
|
||||
showAlertDialog(
|
||||
'{% trans "No Response" %}',
|
||||
'{% trans "No response from the InvenTree server" %}',
|
||||
);
|
||||
break;
|
||||
case 401: // Not authenticated
|
||||
$(options.modal).modal('hide');
|
||||
showAlertDialog(
|
||||
'{% trans "Error 401: Not Authenticated" %}',
|
||||
'{% trans "Authentication credentials not supplied" %}',
|
||||
);
|
||||
break;
|
||||
case 403: // Permission denied
|
||||
$(options.modal).modal('hide');
|
||||
showAlertDialog(
|
||||
'{% trans "Error 403: Permission Denied" %}',
|
||||
'{% trans "You do not have the required permissions to access this function" %}',
|
||||
);
|
||||
break;
|
||||
case 404: // Resource not found
|
||||
$(options.modal).modal('hide');
|
||||
showAlertDialog(
|
||||
'{% trans "Error 404: Resource Not Found" %}',
|
||||
'{% trans "The requested resource could not be located on the server" %}',
|
||||
);
|
||||
break;
|
||||
case 408: // Timeout
|
||||
$(options.modal).modal('hide');
|
||||
showAlertDialog(
|
||||
'{% trans "Error 408: Timeout" %}',
|
||||
'{% trans "Connection timeout while requesting data from server" %}',
|
||||
);
|
||||
break;
|
||||
default:
|
||||
$(options.modal).modal('hide');
|
||||
|
||||
showAlertDialog('{% trans "Error requesting form data" %}', renderErrorMessage(xhr));
|
||||
|
||||
console.log(`WARNING: Unhandled response code - ${xhr.status}`);
|
||||
showApiError(xhr);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -697,6 +675,10 @@ function getFormFieldValue(name, field, options) {
|
||||
// Find the HTML element
|
||||
var el = $(options.modal).find(`#id_${name}`);
|
||||
|
||||
if (!el) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var value = null;
|
||||
|
||||
switch (field.type) {
|
||||
@ -834,33 +816,27 @@ function handleFormErrors(errors, fields, options) {
|
||||
}
|
||||
|
||||
for (field_name in errors) {
|
||||
if (field_name in fields) {
|
||||
|
||||
// Add the 'has-error' class
|
||||
$(options.modal).find(`#div_id_${field_name}`).addClass('has-error');
|
||||
// Add the 'has-error' class
|
||||
$(options.modal).find(`#div_id_${field_name}`).addClass('has-error');
|
||||
|
||||
var field_dom = $(options.modal).find(`#errors-${field_name}`); // $(options.modal).find(`#id_${field_name}`);
|
||||
var field_dom = $(options.modal).find(`#errors-${field_name}`); // $(options.modal).find(`#id_${field_name}`);
|
||||
|
||||
var field_errors = errors[field_name];
|
||||
var field_errors = errors[field_name];
|
||||
|
||||
// Add an entry for each returned error message
|
||||
for (var idx = field_errors.length-1; idx >= 0; idx--) {
|
||||
// Add an entry for each returned error message
|
||||
for (var idx = field_errors.length-1; idx >= 0; idx--) {
|
||||
|
||||
var error_text = field_errors[idx];
|
||||
var error_text = field_errors[idx];
|
||||
|
||||
var html = `
|
||||
<span id='error_${idx+1}_id_${field_name}' class='help-block form-error-message'>
|
||||
<strong>${error_text}</strong>
|
||||
</span>`;
|
||||
var html = `
|
||||
<span id='error_${idx+1}_id_${field_name}' class='help-block form-error-message'>
|
||||
<strong>${error_text}</strong>
|
||||
</span>`;
|
||||
|
||||
field_dom.append(html);
|
||||
}
|
||||
|
||||
} else {
|
||||
console.log(`WARNING: handleFormErrors found no match for field '${field_name}'`);
|
||||
field_dom.append(html);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@ -1464,21 +1440,21 @@ function constructInputOptions(name, classes, type, parameters) {
|
||||
opts.push(`readonly=''`);
|
||||
}
|
||||
|
||||
if (parameters.value) {
|
||||
if (parameters.value != null) {
|
||||
// Existing value?
|
||||
opts.push(`value='${parameters.value}'`);
|
||||
} else if (parameters.default) {
|
||||
} else if (parameters.default != null) {
|
||||
// Otherwise, a defualt value?
|
||||
opts.push(`value='${parameters.default}'`);
|
||||
}
|
||||
|
||||
// Maximum input length
|
||||
if (parameters.max_length) {
|
||||
if (parameters.max_length != null) {
|
||||
opts.push(`maxlength='${parameters.max_length}'`);
|
||||
}
|
||||
|
||||
// Minimum input length
|
||||
if (parameters.min_length) {
|
||||
if (parameters.min_length != null) {
|
||||
opts.push(`minlength='${parameters.min_length}'`);
|
||||
}
|
||||
|
||||
@ -1497,8 +1473,13 @@ function constructInputOptions(name, classes, type, parameters) {
|
||||
opts.push(`required=''`);
|
||||
}
|
||||
|
||||
// Custom mouseover title?
|
||||
if (parameters.title != null) {
|
||||
opts.push(`title='${parameters.title}'`);
|
||||
}
|
||||
|
||||
// Placeholder?
|
||||
if (parameters.placeholder) {
|
||||
if (parameters.placeholder != null) {
|
||||
opts.push(`placeholder='${parameters.placeholder}'`);
|
||||
}
|
||||
|
||||
|
@ -12,7 +12,6 @@
|
||||
*/
|
||||
function createNewModal(options={}) {
|
||||
|
||||
|
||||
var id = 1;
|
||||
|
||||
// Check out what modal forms are already being displayed
|
||||
@ -39,12 +38,13 @@ function createNewModal(options={}) {
|
||||
</h3>
|
||||
</div>
|
||||
<div class='modal-form-content-wrapper'>
|
||||
<div id='pre-form-content'>
|
||||
<!-- Content can be inserted here *before* the form fields -->
|
||||
</div>
|
||||
<div id='non-field-errors'>
|
||||
<!-- Form error messages go here -->
|
||||
</div>
|
||||
<div id='pre-form-content'>
|
||||
<!-- Content can be inserted here *before* the form fields -->
|
||||
</div>
|
||||
|
||||
<div id='form-content' class='modal-form-content'>
|
||||
<!-- Form content will be injected here-->
|
||||
</div>
|
||||
@ -102,6 +102,14 @@ function createNewModal(options={}) {
|
||||
modalSetSubmitText(modal_name, options.submitText || '{% trans "Submit" %}');
|
||||
modalSetCloseText(modal_name, options.cancelText || '{% trans "Cancel" %}');
|
||||
|
||||
if (options.hideSubmitButton) {
|
||||
$(modal_name).find('#modal-form-submit').hide();
|
||||
}
|
||||
|
||||
if (options.hideCloseButton) {
|
||||
$(modal_name).find('#modal-form-cancel').hide();
|
||||
}
|
||||
|
||||
// Return the "name" of the modal
|
||||
return modal_name;
|
||||
}
|
||||
@ -551,25 +559,18 @@ function showAlertDialog(title, content, options={}) {
|
||||
*
|
||||
* title - Title text
|
||||
* content - HTML content of the dialog window
|
||||
* options:
|
||||
* modal - modal form to use (default = '#modal-alert-dialog')
|
||||
*/
|
||||
|
||||
var modal = options.modal || '#modal-alert-dialog';
|
||||
|
||||
$(modal).on('shown.bs.modal', function() {
|
||||
$(modal + ' .modal-form-content').scrollTop(0);
|
||||
var modal = createNewModal({
|
||||
title: title,
|
||||
cancelText: '{% trans "Close" %}',
|
||||
hideSubmitButton: true,
|
||||
});
|
||||
|
||||
modalSetTitle(modal, title);
|
||||
modalSetContent(modal, content);
|
||||
modalSetContent(modal, content);
|
||||
|
||||
$(modal).modal({
|
||||
backdrop: 'static',
|
||||
keyboard: false,
|
||||
});
|
||||
|
||||
$(modal).modal('show');
|
||||
$(modal).modal('show');
|
||||
}
|
||||
|
||||
|
||||
@ -586,22 +587,15 @@ function showQuestionDialog(title, content, options={}) {
|
||||
* cancel - Functino to run if the user presses 'Cancel'
|
||||
*/
|
||||
|
||||
var modal = options.modal || '#modal-question-dialog';
|
||||
|
||||
$(modal).on('shown.bs.modal', function() {
|
||||
$(modal + ' .modal-form-content').scrollTop(0);
|
||||
var modal = createNewModal({
|
||||
title: title,
|
||||
submitText: options.accept_text || '{% trans "Accept" %}',
|
||||
cancelText: options.cancel_text || '{% trans "Cancel" %}',
|
||||
});
|
||||
|
||||
modalSetTitle(modal, title);
|
||||
modalSetContent(modal, content);
|
||||
|
||||
var accept_text = options.accept_text || '{% trans "Accept" %}';
|
||||
var cancel_text = options.cancel_text || '{% trans "Cancel" %}';
|
||||
|
||||
$(modal).find('#modal-form-cancel').html(cancel_text);
|
||||
$(modal).find('#modal-form-accept').html(accept_text);
|
||||
|
||||
$(modal).on('click', '#modal-form-accept', function() {
|
||||
$(modal).on('click', "#modal-form-submit", function() {
|
||||
$(modal).modal('hide');
|
||||
|
||||
if (options.accept) {
|
||||
@ -609,14 +603,6 @@ function showQuestionDialog(title, content, options={}) {
|
||||
}
|
||||
});
|
||||
|
||||
$(modal).on('click', 'modal-form-cancel', function() {
|
||||
$(modal).modal('hide');
|
||||
|
||||
if (options.cancel) {
|
||||
options.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
$(modal).modal('show');
|
||||
}
|
||||
|
||||
|
@ -20,6 +20,315 @@ function stockStatusCodes() {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Perform stock adjustments
|
||||
*/
|
||||
function adjustStock(action, items, options={}) {
|
||||
|
||||
var formTitle = 'Form Title Here';
|
||||
var actionTitle = null;
|
||||
|
||||
// API url
|
||||
var url = null;
|
||||
|
||||
var specifyLocation = false;
|
||||
var allowSerializedStock = false;
|
||||
|
||||
switch (action) {
|
||||
case 'move':
|
||||
formTitle = '{% trans "Transfer Stock" %}';
|
||||
actionTitle = '{% trans "Move" %}';
|
||||
specifyLocation = true;
|
||||
allowSerializedStock = true;
|
||||
url = '{% url "api-stock-transfer" %}';
|
||||
break;
|
||||
case 'count':
|
||||
formTitle = '{% trans "Count Stock" %}';
|
||||
actionTitle = '{% trans "Count" %}';
|
||||
url = '{% url "api-stock-count" %}';
|
||||
break;
|
||||
case 'take':
|
||||
formTitle = '{% trans "Remove Stock" %}';
|
||||
actionTitle = '{% trans "Take" %}';
|
||||
url = '{% url "api-stock-remove" %}';
|
||||
break;
|
||||
case 'add':
|
||||
formTitle = '{% trans "Add Stock" %}';
|
||||
actionTitle = '{% trans "Add" %}';
|
||||
url = '{% url "api-stock-add" %}';
|
||||
break;
|
||||
case 'delete':
|
||||
formTitle = '{% trans "Delete Stock" %}';
|
||||
allowSerializedStock = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Generate modal HTML content
|
||||
var html = `
|
||||
<table class='table table-striped table-condensed' id='stock-adjust-table'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Part" %}</th>
|
||||
<th>{% trans "Stock" %}</th>
|
||||
<th>{% trans "Location" %}</th>
|
||||
<th>${actionTitle || ''}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
var itemCount = 0;
|
||||
|
||||
for (var idx = 0; idx < items.length; idx++) {
|
||||
|
||||
var item = items[idx];
|
||||
|
||||
if ((item.serial != null) && !allowSerializedStock) {
|
||||
continue;
|
||||
}
|
||||
|
||||
var pk = item.pk;
|
||||
|
||||
var readonly = (item.serial != null);
|
||||
var minValue = null;
|
||||
var maxValue = null;
|
||||
var value = null;
|
||||
|
||||
switch (action) {
|
||||
case 'move':
|
||||
minValue = 0;
|
||||
maxValue = item.quantity;
|
||||
value = item.quantity;
|
||||
break;
|
||||
case 'add':
|
||||
minValue = 0;
|
||||
value = 0;
|
||||
break;
|
||||
case 'take':
|
||||
minValue = 0;
|
||||
value = 0;
|
||||
break;
|
||||
case 'count':
|
||||
minValue = 0;
|
||||
value = item.quantity;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
var image = item.part_detail.thumbnail || item.part_detail.image || blankImage();
|
||||
|
||||
var status = stockStatusDisplay(item.status, {
|
||||
classes: 'float-right'
|
||||
});
|
||||
|
||||
var quantity = item.quantity;
|
||||
|
||||
var location = locationDetail(item, false);
|
||||
|
||||
if (item.location_detail) {
|
||||
location = item.location_detail.pathstring;
|
||||
}
|
||||
|
||||
if (item.serial != null) {
|
||||
quantity = `#${item.serial}`;
|
||||
}
|
||||
|
||||
var actionInput = '';
|
||||
|
||||
if (actionTitle != null) {
|
||||
actionInput = constructNumberInput(
|
||||
item.pk,
|
||||
{
|
||||
value: value,
|
||||
min_value: minValue,
|
||||
max_value: maxValue,
|
||||
read_only: readonly,
|
||||
title: readonly ? '{% trans "Quantity cannot be adjusted for serialized stock" %}' : '{% trans "Specify stock quantity" %}',
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
var buttons = `<div class='btn-group float-right' role='group'>`;
|
||||
|
||||
buttons += makeIconButton(
|
||||
'fa-times icon-red',
|
||||
'button-stock-item-remove',
|
||||
pk,
|
||||
'{% trans "Remove stock item" %}',
|
||||
);
|
||||
|
||||
buttons += `</div>`;
|
||||
|
||||
html += `
|
||||
<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='stock_${pk}'>${quantity}${status}</td>
|
||||
<td id='location_${pk}'>${location}</td>
|
||||
<td id='action_${pk}'>
|
||||
<div id='div_id_${pk}'>
|
||||
${actionInput}
|
||||
<div id='errors-${pk}'></div>
|
||||
</div>
|
||||
</td>
|
||||
<td id='buttons_${pk}'>${buttons}</td>
|
||||
</tr>`;
|
||||
|
||||
itemCount += 1;
|
||||
}
|
||||
|
||||
if (itemCount == 0) {
|
||||
showAlertDialog(
|
||||
'{% trans "Select Stock Items" %}',
|
||||
'{% trans "You must select at least one available stock item" %}',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
html += `</tbody></table>`;
|
||||
|
||||
var modal = createNewModal({
|
||||
title: formTitle,
|
||||
});
|
||||
|
||||
// Extra fields
|
||||
var extraFields = {
|
||||
location: {
|
||||
label: '{% trans "Location" %}',
|
||||
help_text: '{% trans "Select destination stock location" %}',
|
||||
type: 'related field',
|
||||
required: true,
|
||||
api_url: `/api/stock/location/`,
|
||||
model: 'stocklocation',
|
||||
},
|
||||
notes: {
|
||||
label: '{% trans "Notes" %}',
|
||||
help_text: '{% trans "Stock transaction notes" %}',
|
||||
type: 'string',
|
||||
}
|
||||
};
|
||||
|
||||
if (!specifyLocation) {
|
||||
delete extraFields.location;
|
||||
}
|
||||
|
||||
constructFormBody({}, {
|
||||
preFormContent: html,
|
||||
fields: extraFields,
|
||||
confirm: true,
|
||||
confirmMessage: '{% trans "Confirm stock adjustment" %}',
|
||||
modal: modal,
|
||||
onSubmit: function(fields, opts) {
|
||||
|
||||
// "Delete" action gets handled differently
|
||||
if (action == 'delete') {
|
||||
|
||||
var requests = [];
|
||||
|
||||
items.forEach(function(item) {
|
||||
requests.push(
|
||||
inventreeDelete(
|
||||
`/api/stock/${item.pk}/`,
|
||||
)
|
||||
)
|
||||
});
|
||||
|
||||
// Wait for *all* the requests to complete
|
||||
$.when.apply($, requests).then(function() {
|
||||
// Destroy the modal window
|
||||
$(modal).modal('hide');
|
||||
|
||||
if (options.onSuccess) {
|
||||
options.onSuccess();
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Data to transmit
|
||||
var data = {
|
||||
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 (field_name in extraFields) {
|
||||
data[field_name] = getFormFieldValue(
|
||||
field_name,
|
||||
fields[field_name],
|
||||
{
|
||||
modal: modal,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
inventreePut(
|
||||
url,
|
||||
data,
|
||||
{
|
||||
method: 'POST',
|
||||
success: function(response, status) {
|
||||
|
||||
// Destroy the modal window
|
||||
$(modal).modal('hide');
|
||||
|
||||
if (options.onSuccess) {
|
||||
options.onSuccess();
|
||||
}
|
||||
},
|
||||
error: function(xhr) {
|
||||
switch (xhr.status) {
|
||||
case 400:
|
||||
|
||||
// Handle errors for standard fields
|
||||
handleFormErrors(
|
||||
xhr.responseJSON,
|
||||
extraFields,
|
||||
{
|
||||
modal: modal,
|
||||
}
|
||||
)
|
||||
|
||||
break;
|
||||
default:
|
||||
$(modal).modal('hide');
|
||||
showApiError(xhr);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 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%');
|
||||
}
|
||||
|
||||
|
||||
function removeStockRow(e) {
|
||||
// Remove a selected row from a stock modal form
|
||||
|
||||
@ -228,6 +537,58 @@ function loadStockTestResultsTable(table, options) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
function locationDetail(row, showLink=true) {
|
||||
/*
|
||||
* Function to display a "location" of a StockItem.
|
||||
*
|
||||
* Complicating factors: A StockItem may not actually *be* in a location!
|
||||
* - Could be at a customer
|
||||
* - Could be installed in another stock item
|
||||
* - Could be assigned to a sales order
|
||||
* - Could be currently in production!
|
||||
*
|
||||
* So, instead of being naive, we'll check!
|
||||
*/
|
||||
|
||||
// Display text
|
||||
var text = '';
|
||||
|
||||
// URL (optional)
|
||||
var url = '';
|
||||
|
||||
if (row.is_building && row.build) {
|
||||
// StockItem is currently being built!
|
||||
text = '{% trans "In production" %}';
|
||||
url = `/build/${row.build}/`;
|
||||
} else if (row.belongs_to) {
|
||||
// StockItem is installed inside a different StockItem
|
||||
text = `{% trans "Installed in Stock Item" %} ${row.belongs_to}`;
|
||||
url = `/stock/item/${row.belongs_to}/installed/`;
|
||||
} else if (row.customer) {
|
||||
// StockItem has been assigned to a customer
|
||||
text = '{% trans "Shipped to customer" %}';
|
||||
url = `/company/${row.customer}/assigned-stock/`;
|
||||
} else if (row.sales_order) {
|
||||
// StockItem has been assigned to a sales order
|
||||
text = '{% trans "Assigned to Sales Order" %}';
|
||||
url = `/order/sales-order/${row.sales_order}/`;
|
||||
} else if (row.location) {
|
||||
text = row.location_detail.pathstring;
|
||||
url = `/stock/location/${row.location}/`;
|
||||
} else {
|
||||
text = '<i>{% trans "No stock location set" %}</i>';
|
||||
url = '';
|
||||
}
|
||||
|
||||
if (showLink && url) {
|
||||
return renderLink(text, url);
|
||||
} else {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function loadStockTable(table, options) {
|
||||
/* Load data into a stock table with adjustable options.
|
||||
* Fetches data (via AJAX) and loads into a bootstrap table.
|
||||
@ -271,56 +632,6 @@ function loadStockTable(table, options) {
|
||||
filters[key] = params[key];
|
||||
}
|
||||
|
||||
function locationDetail(row) {
|
||||
/*
|
||||
* Function to display a "location" of a StockItem.
|
||||
*
|
||||
* Complicating factors: A StockItem may not actually *be* in a location!
|
||||
* - Could be at a customer
|
||||
* - Could be installed in another stock item
|
||||
* - Could be assigned to a sales order
|
||||
* - Could be currently in production!
|
||||
*
|
||||
* So, instead of being naive, we'll check!
|
||||
*/
|
||||
|
||||
// Display text
|
||||
var text = '';
|
||||
|
||||
// URL (optional)
|
||||
var url = '';
|
||||
|
||||
if (row.is_building && row.build) {
|
||||
// StockItem is currently being built!
|
||||
text = '{% trans "In production" %}';
|
||||
url = `/build/${row.build}/`;
|
||||
} else if (row.belongs_to) {
|
||||
// StockItem is installed inside a different StockItem
|
||||
text = `{% trans "Installed in Stock Item" %} ${row.belongs_to}`;
|
||||
url = `/stock/item/${row.belongs_to}/installed/`;
|
||||
} else if (row.customer) {
|
||||
// StockItem has been assigned to a customer
|
||||
text = '{% trans "Shipped to customer" %}';
|
||||
url = `/company/${row.customer}/assigned-stock/`;
|
||||
} else if (row.sales_order) {
|
||||
// StockItem has been assigned to a sales order
|
||||
text = '{% trans "Assigned to Sales Order" %}';
|
||||
url = `/order/sales-order/${row.sales_order}/`;
|
||||
} else if (row.location) {
|
||||
text = row.location_detail.pathstring;
|
||||
url = `/stock/location/${row.location}/`;
|
||||
} else {
|
||||
text = '<i>{% trans "No stock location set" %}</i>';
|
||||
url = '';
|
||||
}
|
||||
|
||||
if (url) {
|
||||
return renderLink(text, url);
|
||||
} else {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
var grouping = true;
|
||||
|
||||
if ('grouping' in options) {
|
||||
@ -741,39 +1052,15 @@ function loadStockTable(table, options) {
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
function stockAdjustment(action) {
|
||||
var items = $("#stock-table").bootstrapTable("getSelections");
|
||||
|
||||
var stock = [];
|
||||
|
||||
items.forEach(function(item) {
|
||||
stock.push(item.pk);
|
||||
});
|
||||
|
||||
// Buttons for launching secondary modals
|
||||
var secondary = [];
|
||||
|
||||
if (action == 'move') {
|
||||
secondary.push({
|
||||
field: 'destination',
|
||||
label: '{% trans "New Location" %}',
|
||||
title: '{% trans "Create new location" %}',
|
||||
url: "/stock/location/new/",
|
||||
});
|
||||
}
|
||||
|
||||
launchModalForm("/stock/adjust/",
|
||||
{
|
||||
data: {
|
||||
action: action,
|
||||
stock: stock,
|
||||
},
|
||||
success: function() {
|
||||
$("#stock-table").bootstrapTable('refresh');
|
||||
},
|
||||
secondary: secondary,
|
||||
adjustStock(action, items, {
|
||||
onSuccess: function() {
|
||||
$('#stock-table').bootstrapTable('refresh');
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Automatically link button callbacks
|
||||
|
@ -57,45 +57,3 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='modal fade modal-fixed-footer' role='dialog' id='modal-question-dialog'>
|
||||
<div class='modal-dialog'>
|
||||
<div class='modal-content'>
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
<h3 id='modal-title'>Question Here</h3>
|
||||
</div>
|
||||
<div class='modal-form-content'>
|
||||
</div>
|
||||
<div class='modal-footer'>
|
||||
<div id='modal-footer-buttons'></div>
|
||||
<button type='button' class='btn btn-default' id='modal-form-cancel' data-dismiss='modal'>{% trans "Cancel" %}</button>
|
||||
<button type='button' class='btn btn-primary' id='modal-form-accept'>{% trans "Accept" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='modal fade modal-fixed-footer' role='dialog' id='modal-alert-dialog'>
|
||||
<div class='modal-dialog'>
|
||||
<div class='modal-content'>
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
<h3 id='modal-title'>Alert Information</h3>
|
||||
</div>
|
||||
<div class='modal-form-content-wrapper'>
|
||||
<div class='modal-form-content'>
|
||||
</div>
|
||||
</div>
|
||||
<div class='modal-footer'>
|
||||
<div id='modal-footer-buttons'></div>
|
||||
<button type='button' class='btn btn-default' data-dismiss='modal'>{% trans "Close" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
Loading…
Reference in New Issue
Block a user