diff --git a/InvenTree/build/migrations/0018_build_reference.py b/InvenTree/build/migrations/0018_build_reference.py index be4f7da36f..75abbfbc06 100644 --- a/InvenTree/build/migrations/0018_build_reference.py +++ b/InvenTree/build/migrations/0018_build_reference.py @@ -19,7 +19,8 @@ def add_default_reference(apps, schema_editor): build.save() count += 1 - print(f"\nUpdated build reference for {count} existing BuildOrder objects") + if count > 0: + print(f"\nUpdated build reference for {count} existing BuildOrder objects") def reverse_default_reference(apps, schema_editor): diff --git a/InvenTree/company/fixtures/company.yaml b/InvenTree/company/fixtures/company.yaml index 3113d6209f..02a0a4e830 100644 --- a/InvenTree/company/fixtures/company.yaml +++ b/InvenTree/company/fixtures/company.yaml @@ -17,6 +17,7 @@ fields: name: Zerg Corp description: We eat the competition + is_customer: False - model: company.company pk: 4 diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 8a2d2fa051..26787878a8 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -163,6 +163,23 @@ class StockTransfer(StockAdjustView): serializer_class = StockSerializers.StockTransferSerializer +class StockAssign(generics.CreateAPIView): + """ + API endpoint for assigning stock to a particular customer + """ + + queryset = StockItem.objects.all() + serializer_class = StockSerializers.StockAssignmentSerializer + + def get_serializer_context(self): + + ctx = super().get_serializer_context() + + ctx['request'] = self.request + + return ctx + + class StockLocationList(generics.ListCreateAPIView): """ API endpoint for list view of StockLocation objects: @@ -1174,6 +1191,7 @@ stock_api_urls = [ 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'), + url(r'^assign/', StockAssign.as_view(), name='api-stock-assign'), # StockItemAttachment API endpoints url(r'^attachment/', include([ diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index 8e998460ca..dcbf722997 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -21,20 +21,6 @@ from part.models import Part from .models import StockLocation, StockItem, StockItemTracking -class AssignStockItemToCustomerForm(HelperForm): - """ - Form for manually assigning a StockItem to a Customer - - TODO: This could be a simple API driven form! - """ - - class Meta: - model = StockItem - fields = [ - 'customer', - ] - - class ReturnStockItemForm(HelperForm): """ Form for manually returning a StockItem into stock diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 9bd5ea64be..fb78eaeaa0 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -28,6 +28,8 @@ from .models import StockItemTestResult import common.models from common.settings import currency_code_default, currency_code_mappings + +import company.models from company.serializers import SupplierPartSerializer import InvenTree.helpers @@ -537,6 +539,127 @@ class StockTrackingSerializer(InvenTree.serializers.InvenTreeModelSerializer): ] +class StockAssignmentItemSerializer(serializers.Serializer): + """ + Serializer for a single StockItem with in StockAssignment request. + + Here, the particular StockItem is being assigned (manually) to a customer + + Fields: + - item: StockItem object + """ + + class Meta: + fields = [ + 'item', + ] + + item = serializers.PrimaryKeyRelatedField( + queryset=StockItem.objects.all(), + many=False, + allow_null=False, + required=True, + label=_('Stock Item'), + ) + + def validate_item(self, item): + + # The item must currently be "in stock" + if not item.in_stock: + raise ValidationError(_("Item must be in stock")) + + # The base part must be "salable" + if not item.part.salable: + raise ValidationError(_("Part must be salable")) + + # The item must not be allocated to a sales order + if item.sales_order_allocations.count() > 0: + raise ValidationError(_("Item is allocated to a sales order")) + + # The item must not be allocated to a build order + if item.allocations.count() > 0: + raise ValidationError(_("Item is allocated to a build order")) + + return item + + +class StockAssignmentSerializer(serializers.Serializer): + """ + Serializer for assigning one (or more) stock items to a customer. + + This is a manual assignment process, separate for (for example) a Sales Order + """ + + class Meta: + fields = [ + 'items', + 'customer', + 'notes', + ] + + items = StockAssignmentItemSerializer( + many=True, + required=True, + ) + + customer = serializers.PrimaryKeyRelatedField( + queryset=company.models.Company.objects.all(), + many=False, + allow_null=False, + required=True, + label=_('Customer'), + help_text=_('Customer to assign stock items'), + ) + + def validate_customer(self, customer): + + if customer and not customer.is_customer: + raise ValidationError(_('Selected company is not a customer')) + + return customer + + notes = serializers.CharField( + required=False, + allow_blank=True, + label=_('Notes'), + help_text=_('Stock assignment notes'), + ) + + def validate(self, data): + + data = super().validate(data) + + items = data.get('items', []) + + if len(items) == 0: + raise ValidationError(_("A list of stock items must be provided")) + + return data + + def save(self): + + request = self.context['request'] + + user = getattr(request, 'user', None) + + data = self.validated_data + + items = data['items'] + customer = data['customer'] + notes = data.get('notes', '') + + with transaction.atomic(): + for item in items: + + stock_item = item['item'] + + stock_item.allocateToCustomer( + customer, + user=user, + notes=notes, + ) + + class StockAdjustmentItemSerializer(serializers.Serializer): """ Serializer for a single StockItem within a stock adjument request. diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 949c172e9e..64b45ed0c8 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -568,11 +568,19 @@ $("#stock-convert").click(function() { {% if item.in_stock %} $("#stock-assign-to-customer").click(function() { - launchModalForm("{% url 'stock-item-assign' item.id %}", - { - reload: true, + + inventreeGet('{% url "api-stock-detail" item.pk %}', {}, { + success: function(response) { + assignStockToCustomer( + [response], + { + success: function() { + location.reload(); + }, + } + ); } - ); + }); }); $("#stock-move").click(function() { diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index 522468a740..fe76e6c1c0 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -16,8 +16,9 @@ from InvenTree.status_codes import StockStatus from InvenTree.api_tester import InvenTreeAPITestCase from common.models import InvenTreeSetting - -from .models import StockItem, StockLocation +import company.models +import part.models +from stock.models import StockItem, StockLocation class StockAPITestCase(InvenTreeAPITestCase): @@ -732,3 +733,112 @@ class StockTestResultTest(StockAPITestCase): # Check that an attachment has been uploaded self.assertIsNotNone(response.data['attachment']) + + +class StockAssignTest(StockAPITestCase): + """ + Unit tests for the stock assignment API endpoint, + where stock items are manually assigned to a customer + """ + + URL = reverse('api-stock-assign') + + def test_invalid(self): + + # Test with empty data + response = self.post( + self.URL, + data={}, + expected_code=400, + ) + + self.assertIn('This field is required', str(response.data['items'])) + self.assertIn('This field is required', str(response.data['customer'])) + + # Test with an invalid customer + response = self.post( + self.URL, + data={ + 'customer': 999, + }, + expected_code=400, + ) + + self.assertIn('object does not exist', str(response.data['customer'])) + + # Test with a company which is *not* a customer + response = self.post( + self.URL, + data={ + 'customer': 3, + }, + expected_code=400, + ) + + self.assertIn('company is not a customer', str(response.data['customer'])) + + # Test with an empty items list + response = self.post( + self.URL, + data={ + 'items': [], + 'customer': 4, + }, + expected_code=400, + ) + + self.assertIn('A list of stock items must be provided', str(response.data)) + + stock_item = StockItem.objects.create( + part=part.models.Part.objects.get(pk=1), + status=StockStatus.DESTROYED, + quantity=5, + ) + + response = self.post( + self.URL, + data={ + 'items': [ + { + 'item': stock_item.pk, + }, + ], + 'customer': 4, + }, + expected_code=400, + ) + + self.assertIn('Item must be in stock', str(response.data['items'][0])) + + def test_valid(self): + + stock_items = [] + + for i in range(5): + + stock_item = StockItem.objects.create( + part=part.models.Part.objects.get(pk=25), + quantity=i + 5, + ) + + stock_items.append({ + 'item': stock_item.pk + }) + + customer = company.models.Company.objects.get(pk=4) + + self.assertEqual(customer.assigned_stock.count(), 0) + + response = self.post( + self.URL, + data={ + 'items': stock_items, + 'customer': 4, + }, + expected_code=201, + ) + + self.assertEqual(response.data['customer'], 4) + + # 5 stock items should now have been assigned to this customer + self.assertEqual(customer.assigned_stock.count(), 5) diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index eb4aa2e65c..7f35904b51 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -23,7 +23,6 @@ stock_item_detail_urls = [ url(r'^delete/', views.StockItemDelete.as_view(), name='stock-item-delete'), url(r'^qr_code/', views.StockItemQRCode.as_view(), name='stock-item-qr'), url(r'^delete_test_data/', views.StockItemDeleteTestData.as_view(), name='stock-item-delete-test-data'), - url(r'^assign/', views.StockItemAssignToCustomer.as_view(), name='stock-item-assign'), url(r'^return/', views.StockItemReturnToStock.as_view(), name='stock-item-return'), url(r'^install/', views.StockItemInstall.as_view(), name='stock-item-install'), diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 6d93ae47e0..27801f0ed6 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -294,39 +294,6 @@ class StockLocationQRCode(QRCodeView): return None -class StockItemAssignToCustomer(AjaxUpdateView): - """ - View for manually assigning a StockItem to a Customer - """ - - model = StockItem - ajax_form_title = _("Assign to Customer") - context_object_name = "item" - form_class = StockForms.AssignStockItemToCustomerForm - - def validate(self, item, form, **kwargs): - - customer = form.cleaned_data.get('customer', None) - - if not customer: - form.add_error('customer', _('Customer must be specified')) - - def save(self, item, form, **kwargs): - """ - Assign the stock item to the customer. - """ - - customer = form.cleaned_data.get('customer', None) - - if customer: - item = item.allocateToCustomer( - customer, - user=self.request.user - ) - - item.clearAllocations() - - class StockItemReturnToStock(AjaxUpdateView): """ View for returning a stock item (which is assigned to a customer) to stock. diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index d6de4fdd45..2541f77272 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -38,6 +38,7 @@ */ /* exported + assignStockToCustomer, createNewStockItem, createStockLocation, duplicateStockItem, @@ -533,13 +534,166 @@ function exportStock(params={}) { url += `&${key}=${params[key]}`; } - console.log(url); location.href = url; } }); } +/** + * Assign multiple stock items to a customer + */ +function assignStockToCustomer(items, options={}) { + + // Generate HTML content for the form + var html = ` + + + + + + + + + + + `; + + for (var idx = 0; idx < items.length; idx++) { + + var item = items[idx]; + + var pk = item.pk; + + var part = item.part_detail; + + var thumbnail = thumbnailImage(part.thumbnail || part.image); + + var status = stockStatusDisplay(item.status, {classes: 'float-right'}); + + var quantity = ''; + + if (item.serial && item.quantity == 1) { + quantity = `{% trans "Serial" %}: ${item.serial}`; + } else { + quantity = `{% trans "Quantity" %}: ${item.quantity}`; + } + + quantity += status; + + var location = locationDetail(item, false); + + var buttons = `
`; + + buttons += makeIconButton( + 'fa-times icon-red', + 'button-stock-item-remove', + pk, + '{% trans "Remove row" %}', + ); + + buttons += '
'; + + html += ` + + + + + + + `; + } + + html += `
{% trans "Part" %}{% trans "Stock Item" %}{% trans "Location" %}
${thumbnail} ${part.full_name} +
+ ${quantity} +
+
+
${location}${buttons}
`; + + constructForm('{% url "api-stock-assign" %}', { + method: 'POST', + preFormContent: html, + fields: { + 'customer': { + value: options.customer, + filters: { + is_customer: true, + }, + }, + 'notes': {}, + }, + confirm: true, + confirmMessage: '{% trans "Confirm stock assignment" %}', + title: '{% trans "Assign Stock to Customer" %}', + afterRender: function(fields, opts) { + // Add button callbacks to remove rows + $(opts.modal).find('.button-stock-item-remove').click(function() { + var pk = $(this).attr('pk'); + + $(opts.modal).find(`#stock_item_${pk}`).remove(); + }); + }, + onSubmit: function(fields, opts) { + + // Extract data elements from the form + var data = { + customer: getFormFieldValue('customer', {}, opts), + notes: getFormFieldValue('notes', {}, opts), + items: [], + }; + + var item_pk_values = []; + + 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.exists()) { + item_pk_values.push(pk); + + data.items.push({ + item: pk, + }); + } + }); + + opts.nested = { + 'items': item_pk_values, + }; + + inventreePut( + '{% url "api-stock-assign" %}', + data, + { + method: 'POST', + success: function(response) { + $(opts.modal).modal('hide'); + + if (options.success) { + options.success(response); + } + }, + error: function(xhr) { + switch (xhr.status) { + case 400: + handleFormErrors(xhr.responseJSON, fields, opts); + break; + default: + $(opts.modal).modal('hide'); + showApiError(xhr, opts.url); + break; + } + } + } + ); + } + }); +} + + /** * Perform stock adjustments */ @@ -777,7 +931,7 @@ function adjustStock(action, items, options={}) { // Does the row exist in the form? var row = $(opts.modal).find(`#stock_item_${pk}`); - if (row) { + if (row.exists()) { item_pk_values.push(pk); @@ -1098,7 +1252,7 @@ function locationDetail(row, showLink=true) { // 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) { + } else if (row.location && row.location_detail) { text = row.location_detail.pathstring; url = `/stock/location/${row.location}/`; } else { @@ -1721,6 +1875,17 @@ function loadStockTable(table, options) { stockAdjustment('move'); }); + $('#multi-item-assign').click(function() { + + var items = $(table).bootstrapTable('getSelections'); + + assignStockToCustomer(items, { + success: function() { + $(table).bootstrapTable('refresh'); + } + }); + }); + $('#multi-item-order').click(function() { var selections = $(table).bootstrapTable('getSelections'); diff --git a/InvenTree/templates/stock_table.html b/InvenTree/templates/stock_table.html index 71c6734818..1f873d7c58 100644 --- a/InvenTree/templates/stock_table.html +++ b/InvenTree/templates/stock_table.html @@ -50,6 +50,7 @@
  • {% trans "Count stock" %}
  • {% trans "Move stock" %}
  • {% trans "Order stock" %}
  • +
  • {% trans "Assign to customer" %}
  • {% trans "Change stock status" %}
  • {% endif %} {% if roles.stock.delete %}