diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py index 34cc5a1d7b..333f11898b 100644 --- a/InvenTree/InvenTree/views.py +++ b/InvenTree/InvenTree/views.py @@ -346,6 +346,8 @@ class AjaxUpdateView(AjaxMixin, UpdateView): # Include context data about the updated object data['pk'] = obj.id + self.post_save(obj) + try: data['url'] = obj.get_absolute_url() except AttributeError: @@ -353,6 +355,13 @@ class AjaxUpdateView(AjaxMixin, UpdateView): return self.renderJsonResponse(request, form, data) + def post_save(self, obj, *args, **kwargs): + """ + Hook called after the form data is saved. + (Optional) + """ + pass + class AjaxDeleteView(AjaxMixin, UpdateView): diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 2ebc6a6793..b607d314a0 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -632,24 +632,14 @@ class SalesOrderAllocation(models.Model): order = self.line.order - item = self.item + item = self.item.allocateToCustomer( + order.customer, + quantity=self.quantity, + order=order, + user=user + ) - # If the allocated quantity is less than the amount available, - # then split the stock item into two lots - if item.quantity > self.quantity: - - # Grab a copy of the new stock item (which will keep track of its "parent") - item = item.splitStock(self.quantity, None, user) - - # Update our own reference to the new item - self.item = item - self.save() - - # Assign the StockItem to the SalesOrder customer - item.sales_order = order - - # Clear the location - item.location = None - item.status = StockStatus.SHIPPED - - item.save() + # Update our own reference to the StockItem + # (It may have changed if the stock was split) + self.item = item + self.save() diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index ddd7390246..bb403d837d 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -34,6 +34,18 @@ class EditStockItemAttachmentForm(HelperForm): ] +class AssignStockItemToCustomerForm(HelperForm): + """ + Form for manually assigning a StockItem to a Customer + """ + + class Meta: + model = StockItem + fields = [ + 'customer', + ] + + class EditStockItemTestResultForm(HelperForm): """ Form for creating / editing a StockItemTestResult object. diff --git a/InvenTree/stock/migrations/0045_stockitem_customer.py b/InvenTree/stock/migrations/0045_stockitem_customer.py new file mode 100644 index 0000000000..497d6b7353 --- /dev/null +++ b/InvenTree/stock/migrations/0045_stockitem_customer.py @@ -0,0 +1,20 @@ +# Generated by Django 3.0.5 on 2020-06-04 03:20 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('company', '0021_remove_supplierpart_manufacturer_name'), + ('stock', '0044_auto_20200528_1036'), + ] + + operations = [ + migrations.AddField( + model_name='stockitem', + name='customer', + field=models.ForeignKey(help_text='Customer', limit_choices_to={'is_customer': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_stock', to='company.Company', verbose_name='Customer'), + ), + ] diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 7bebdefaba..e59af0e2e9 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -32,6 +32,7 @@ from InvenTree.status_codes import StockStatus from InvenTree.models import InvenTreeTree, InvenTreeAttachment from InvenTree.fields import InvenTreeURLField +from company import models as CompanyModels from part import models as PartModels @@ -217,12 +218,6 @@ class StockItem(MPTTModel): super().clean() - if self.status == StockStatus.SHIPPED and self.sales_order is None: - raise ValidationError({ - 'sales_order': "SalesOrder must be specified as status is marked as SHIPPED", - 'status': "Status cannot be marked as SHIPPED if the Customer is not set", - }) - if self.status == StockStatus.ASSIGNED_TO_OTHER_ITEM and self.belongs_to is None: raise ValidationError({ 'belongs_to': "Belongs_to field must be specified as statis is marked as ASSIGNED_TO_OTHER_ITEM", @@ -352,6 +347,16 @@ class StockItem(MPTTModel): help_text=_('Is this item installed in another item?') ) + customer = models.ForeignKey( + CompanyModels.Company, + on_delete=models.SET_NULL, + null=True, + limit_choices_to={'is_customer': True}, + related_name='assigned_stock', + help_text=_("Customer"), + verbose_name=_("Customer"), + ) + serial = models.PositiveIntegerField( verbose_name=_('Serial Number'), blank=True, null=True, @@ -431,6 +436,64 @@ class StockItem(MPTTModel): help_text=_('Stock Item Notes') ) + def clearAllocations(self): + """ + Clear all order allocations for this StockItem: + + - SalesOrder allocations + - Build allocations + """ + + # Delete outstanding SalesOrder allocations + self.sales_order_allocations.all().delete() + + # Delete outstanding BuildOrder allocations + self.allocations.all().delete() + + def allocateToCustomer(self, customer, quantity=None, order=None, user=None, notes=None): + """ + Allocate a StockItem to a customer. + + This action can be called by the following processes: + - Completion of a SalesOrder + - User manually assigns a StockItem to the customer + + Args: + customer: The customer (Company) to assign the stock to + quantity: Quantity to assign (if not supplied, total quantity is used) + order: SalesOrder reference + user: User that performed the action + notes: Notes field + """ + + if quantity is None: + quantity = self.quantity + + if quantity >= self.quantity: + item = self + else: + item = self.splitStock(quantity, None, user) + + # Update StockItem fields with new information + item.sales_order = order + item.status = StockStatus.SHIPPED + item.customer = customer + item.location = None + + item.save() + + # TODO - Remove any stock item allocations from this stock item + + item.addTransactionNote( + _("Assigned to Customer"), + user, + notes=_("Manually assigned to customer") + " " + customer.name, + system=True + ) + + # Return the reference to the stock item + return item + # If stock item is incoming, an (optional) ETA field # expected_arrival = models.DateField(null=True, blank=True) diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 56ef8a3370..657154c446 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -71,43 +71,48 @@ InvenTree | {% trans "Stock Item" %} - {{ item }} {% include "qr_button.html" %} {% if item.in_stock %} {% if not item.serialized %} - - - {% if item.part.trackable %} - {% endif %} + {% if item.part.salable %} + {% endif %} - - {% endif %} {% if item.part.has_variants %} - {% endif %} {% if item.part.has_test_report_templates %} - {% endif %} - {% if item.can_delete %} - {% endif %} @@ -126,6 +131,13 @@ InvenTree | {% trans "Stock Item" %} - {{ item }} {{ item.part.full_name }} + {% if item.customer %} + + + {% trans "Customer" %} + {{ item.customer.name }} + + {% endif %} {% if item.belongs_to %} @@ -320,6 +332,16 @@ $("#show-qr-code").click(function() { {% if item.in_stock %} +{% if item.part.salable %} +$("#stock-assign-to-customer").click(function() { + launchModalForm("{% url 'stock-item-assign' item.id %}", + { + reload: true, + } + ); +}); +{% endif %} + function itemAdjust(action) { launchModalForm("/stock/adjust/", { diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index 7742f96fe2..65e9c6742b 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -23,6 +23,7 @@ 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'^add_tracking/', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'), diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 8d3e5ebbf1..6f9eeeec2e 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -223,6 +223,43 @@ class StockItemAttachmentDelete(AjaxDeleteView): } +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 post(self, request, *args, **kwargs): + + customer = request.POST.get('customer', None) + + if customer: + try: + customer = Company.objects.get(pk=customer) + except (ValueError, Company.DoesNotExist): + customer = None + + if customer is not None: + stock_item = self.get_object() + + item = stock_item.allocateToCustomer( + customer, + user=request.user + ) + + item.clearAllocations() + + data = { + 'form_valid': True, + } + + return self.renderJsonResponse(request, self.get_form(), data) + + class StockItemDeleteTestData(AjaxUpdateView): """ View for deleting all test data