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 = ` +
{% trans "Part" %} | +{% trans "Stock Item" %} | +{% trans "Location" %} | ++ |
---|---|---|---|
${thumbnail} ${part.full_name} | +
+
+ ${quantity}
+
+
+ |
+ ${location} | +${buttons} | +