diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index 03ee877cb2..39d071e182 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -10,12 +10,16 @@ import common.models INVENTREE_SW_VERSION = "0.6.0 dev" -INVENTREE_API_VERSION = 14 +INVENTREE_API_VERSION = 15 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about -v14 -> 2021-20-05 +v15 -> 2021-10-06 + - Adds detail endpoint for SalesOrderAllocation model + - Allows use of the API forms interface for adjusting SalesOrderAllocation objects + +v14 -> 2021-10-05 - Stock adjustment actions API is improved, using native DRF serializer support - However adjustment actions now only support 'pk' as a lookup field diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index 26e6ed3546..af30a3a5c5 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -631,6 +631,15 @@ class SOLineItemDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = SOLineItemSerializer +class SOAllocationDetail(generics.RetrieveUpdateDestroyAPIView): + """ + API endpoint for detali view of a SalesOrderAllocation object + """ + + queryset = SalesOrderAllocation.objects.all() + serializer_class = SalesOrderAllocationSerializer + + class SOAllocationList(generics.ListCreateAPIView): """ API endpoint for listing SalesOrderAllocation objects @@ -743,8 +752,10 @@ order_api_urls = [ ])), # API endpoints for purchase order line items - url(r'^po-line/(?P\d+)/$', POLineItemDetail.as_view(), name='api-po-line-detail'), - url(r'^po-line/$', POLineItemList.as_view(), name='api-po-line-list'), + url(r'^po-line/', include([ + url(r'^(?P\d+)/$', POLineItemDetail.as_view(), name='api-po-line-detail'), + url(r'^.*$', POLineItemList.as_view(), name='api-po-line-list'), + ])), # API endpoints for sales ordesr url(r'^so/', include([ @@ -764,9 +775,8 @@ order_api_urls = [ ])), # API endpoints for sales order allocations - url(r'^so-allocation', include([ - - # List all sales order allocations + url(r'^so-allocation/', include([ + url(r'^(?P\d+)/$', SOAllocationDetail.as_view(), name='api-so-allocation-detail'), url(r'^.*$', SOAllocationList.as_view(), name='api-so-allocation-list'), ])), ] diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py index 87e042f4f3..227109c46c 100644 --- a/InvenTree/order/forms.py +++ b/InvenTree/order/forms.py @@ -115,23 +115,6 @@ class AllocateSerialsToSalesOrderForm(forms.Form): ] -class CreateSalesOrderAllocationForm(HelperForm): - """ - Form for creating a SalesOrderAllocation item. - """ - - quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity')) - - class Meta: - model = SalesOrderAllocation - - fields = [ - 'line', - 'item', - 'quantity', - ] - - class EditSalesOrderAllocationForm(HelperForm): """ Form for editing a SalesOrderAllocation item diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 495ea2d333..4ac8925259 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -840,7 +840,13 @@ class SalesOrderLineItem(OrderLineItem): def get_api_url(): return reverse('api-so-line-list') - order = models.ForeignKey(SalesOrder, on_delete=models.CASCADE, related_name='lines', verbose_name=_('Order'), help_text=_('Sales Order')) + order = models.ForeignKey( + SalesOrder, + on_delete=models.CASCADE, + related_name='lines', + verbose_name=_('Order'), + help_text=_('Sales Order') + ) part = models.ForeignKey('part.Part', on_delete=models.SET_NULL, related_name='sales_order_line_items', null=True, verbose_name=_('Part'), help_text=_('Part'), limit_choices_to={'salable': True}) @@ -954,7 +960,11 @@ class SalesOrderAllocation(models.Model): if len(errors) > 0: raise ValidationError(errors) - line = models.ForeignKey(SalesOrderLineItem, on_delete=models.CASCADE, verbose_name=_('Line'), related_name='allocations') + line = models.ForeignKey( + SalesOrderLineItem, + on_delete=models.CASCADE, + verbose_name=_('Line'), + related_name='allocations') item = models.ForeignKey( 'stock.StockItem', diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index cc4812d1ca..40cd2def58 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -478,7 +478,7 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer): part = serializers.PrimaryKeyRelatedField(source='item.part', read_only=True) order = serializers.PrimaryKeyRelatedField(source='line.order', many=False, read_only=True) serial = serializers.CharField(source='get_serial', read_only=True) - quantity = serializers.FloatField(read_only=True) + quantity = serializers.FloatField(read_only=False) location = serializers.PrimaryKeyRelatedField(source='item.location', many=False, read_only=True) # Extra detail fields diff --git a/InvenTree/order/templates/order/so_allocation_delete.html b/InvenTree/order/templates/order/so_allocation_delete.html deleted file mode 100644 index 34cf20083b..0000000000 --- a/InvenTree/order/templates/order/so_allocation_delete.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends "modal_delete_form.html" %} -{% load i18n %} -{% load inventree_extras %} - -{% block pre_form_content %} -
- {% trans "This action will unallocate the following stock from the Sales Order" %}: -
- - {% decimal allocation.get_allocated %} x {{ allocation.line.part.full_name }} - {% if allocation.item.location %} ({{ allocation.get_location }}){% endif %} - -
-{% endblock %} \ No newline at end of file diff --git a/InvenTree/order/urls.py b/InvenTree/order/urls.py index 5ea9a56867..37433e02f0 100644 --- a/InvenTree/order/urls.py +++ b/InvenTree/order/urls.py @@ -43,12 +43,7 @@ sales_order_detail_urls = [ sales_order_urls = [ # URLs for sales order allocations url(r'^allocation/', include([ - url(r'^new/', views.SalesOrderAllocationCreate.as_view(), name='so-allocation-create'), url(r'^assign-serials/', views.SalesOrderAssignSerials.as_view(), name='so-assign-serials'), - url(r'(?P\d+)/', include([ - url(r'^edit/', views.SalesOrderAllocationEdit.as_view(), name='so-allocation-edit'), - url(r'^delete/', views.SalesOrderAllocationDelete.as_view(), name='so-allocation-delete'), - ])), ])), # Display detail view for a single SalesOrder diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index 8a5e709926..5bb6d161b6 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -29,7 +29,6 @@ from company.models import Company, SupplierPart # ManufacturerPart from stock.models import StockItem from part.models import Part -from common.models import InvenTreeSetting from common.forms import UploadFileForm, MatchFieldForm from common.views import FileManagementFormView from common.files import FileManager @@ -37,7 +36,7 @@ from common.files import FileManager from . import forms as order_forms from part.views import PartPricing -from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView +from InvenTree.views import AjaxView, AjaxUpdateView from InvenTree.helpers import DownloadFile, str2bool from InvenTree.helpers import extract_serial_numbers from InvenTree.views import InvenTreeRoleMixin @@ -976,105 +975,6 @@ class SalesOrderAssignSerials(AjaxView, FormMixin): ) -class SalesOrderAllocationCreate(AjaxCreateView): - """ View for creating a new SalesOrderAllocation """ - - model = SalesOrderAllocation - form_class = order_forms.CreateSalesOrderAllocationForm - ajax_form_title = _('Allocate Stock to Order') - - def get_initial(self): - initials = super().get_initial().copy() - - line_id = self.request.GET.get('line', None) - - if line_id is not None: - line = SalesOrderLineItem.objects.get(pk=line_id) - - initials['line'] = line - - # Search for matching stock items, pre-fill if there is only one - items = StockItem.objects.filter(part=line.part) - - quantity = line.quantity - line.allocated_quantity() - - if quantity < 0: - quantity = 0 - - if items.count() == 1: - item = items.first() - initials['item'] = item - - # Reduce the quantity IF there is not enough stock - qmax = item.quantity - item.allocation_count() - - if qmax < quantity: - quantity = qmax - - initials['quantity'] = quantity - - return initials - - def get_form(self): - - form = super().get_form() - - line_id = form['line'].value() - - # If a line item has been specified, reduce the queryset for the stockitem accordingly - try: - line = SalesOrderLineItem.objects.get(pk=line_id) - - # Construct a queryset for allowable stock items - queryset = StockItem.objects.filter(StockItem.IN_STOCK_FILTER) - - # Ensure the part reference matches - queryset = queryset.filter(part=line.part) - - # Exclude StockItem which are already allocated to this order - allocated = [allocation.item.pk for allocation in line.allocations.all()] - - queryset = queryset.exclude(pk__in=allocated) - - # Exclude stock items which have expired - if not InvenTreeSetting.get_setting('STOCK_ALLOW_EXPIRED_SALE'): - queryset = queryset.exclude(StockItem.EXPIRED_FILTER) - - form.fields['item'].queryset = queryset - - # Hide the 'line' field - form.fields['line'].widget = HiddenInput() - - except (ValueError, SalesOrderLineItem.DoesNotExist): - pass - - return form - - -class SalesOrderAllocationEdit(AjaxUpdateView): - - model = SalesOrderAllocation - form_class = order_forms.EditSalesOrderAllocationForm - ajax_form_title = _('Edit Allocation Quantity') - - def get_form(self): - form = super().get_form() - - # Prevent the user from editing particular fields - form.fields.pop('item') - form.fields.pop('line') - - return form - - -class SalesOrderAllocationDelete(AjaxDeleteView): - - model = SalesOrderAllocation - ajax_form_title = _("Remove allocation") - context_object_name = 'allocation' - ajax_template_name = "order/so_allocation_delete.html" - - class LineItemPricing(PartPricing): """ View for inspecting part pricing information """ diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index ad487c7a5a..d848f0e6b9 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -34,6 +34,7 @@ from company.models import Company, SupplierPart from company.serializers import CompanySerializer, SupplierPartSerializer from order.models import PurchaseOrder +from order.models import SalesOrder, SalesOrderAllocation from order.serializers import POSerializer import common.settings @@ -645,6 +646,31 @@ class StockList(generics.ListCreateAPIView): # Filter StockItem without build allocations or sales order allocations queryset = queryset.filter(Q(sales_order_allocations__isnull=True) & Q(allocations__isnull=True)) + # Exclude StockItems which are already allocated to a particular SalesOrder + exclude_so_allocation = params.get('exclude_so_allocation', None) + + if exclude_so_allocation is not None: + + try: + order = SalesOrder.objects.get(pk=exclude_so_allocation) + + # Grab all the active SalesOrderAllocations for this order + allocations = SalesOrderAllocation.objects.filter( + line__pk__in=[ + line.pk for line in order.lines.all() + ] + ) + + # Exclude any stock item which is already allocated to the sales order + queryset = queryset.exclude( + pk__in=[ + a.item.pk for a in allocations + ] + ) + + except (ValueError, SalesOrder.DoesNotExist): + pass + # Does the client wish to filter by the Part ID? part_id = params.get('part', None) diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 38554f8fcb..f4b1d0b997 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -532,6 +532,7 @@ function editPurchaseOrderLineItem(e) { var url = $(src).attr('url'); + // TODO: Migrate this to the API forms launchModalForm(url, { reload: true, }); @@ -547,7 +548,8 @@ function removePurchaseOrderLineItem(e) { var src = e.target || e.srcElement; var url = $(src).attr('url'); - + + // TODO: Migrate this to the API forms launchModalForm(url, { reload: true, }); @@ -1151,31 +1153,44 @@ function showAllocationSubTable(index, row, element, options) { // Is the parent SalesOrder pending? var pending = options.status == {{ SalesOrderStatus.PENDING }}; - // Function to reload the allocation table - function reloadTable() { - table.bootstrapTable('refresh'); - } - function setupCallbacks() { // Add callbacks for 'edit' buttons table.find('.button-allocation-edit').click(function() { var pk = $(this).attr('pk'); - // TODO: Migrate to API forms - launchModalForm(`/order/sales-order/allocation/${pk}/edit/`, { - success: reloadTable, - }); + // Edit the sales order alloction + constructForm( + `/api/order/so-allocation/${pk}/`, + { + fields: { + quantity: {}, + }, + title: '{% trans "Edit Stock Allocation" %}', + onSuccess: function() { + // Refresh the parent table + $(options.table).bootstrapTable('refresh'); + }, + }, + ); }); // Add callbacks for 'delete' buttons table.find('.button-allocation-delete').click(function() { var pk = $(this).attr('pk'); - // TODO: Migrate to API forms - launchModalForm(`/order/sales-order/allocation/${pk}/delete/`, { - success: reloadTable, - }); + constructForm( + `/api/order/so-allocation/${pk}/`, + { + method: 'DELETE', + confirmMessage: '{% trans "Confirm Delete Operation" %}', + title: '{% trans "Delete Stock Allocation" %}', + onSuccess: function() { + // Refresh the parent table + $(options.table).bootstrapTable('refresh'); + } + } + ); }); } @@ -1308,6 +1323,8 @@ function showFulfilledSubTable(index, row, element, options) { */ function loadSalesOrderLineItemTable(table, options={}) { + options.table = table; + options.params = options.params || {}; if (!options.order) { @@ -1433,13 +1450,21 @@ function loadSalesOrderLineItemTable(table, options={}) { return formatter.format(total); } }, - { - field: 'stock', - title: '{% trans "In Stock" %}', - formatter: function(value, row) { - return row.part_detail.stock; + ]; + + if (pending) { + columns.push( + { + field: 'stock', + title: '{% trans "In Stock" %}', + formatter: function(value, row) { + return row.part_detail.stock; + }, }, - }, + ); + } + + columns.push( { field: 'allocated', title: pending ? '{% trans "Allocated" %}' : '{% trans "Fulfilled" %}', @@ -1470,29 +1495,7 @@ function loadSalesOrderLineItemTable(table, options={}) { field: 'notes', title: '{% trans "Notes" %}', }, - // TODO: Re-introduce the "PO" field, once it is fixed - /* - { - field: 'po', - title: '{% trans "PO" %}', - formatter: function(value, row, index, field) { - var po_name = ""; - if (row.allocated) { - row.allocations.forEach(function(allocation) { - if (allocation.po != po_name) { - if (po_name) { - po_name = "-"; - } else { - po_name = allocation.po - } - } - }) - } - return `
` + po_name + `
`; - } - }, - */ - ]; + ); if (pending) { columns.push({ @@ -1531,9 +1534,6 @@ function loadSalesOrderLineItemTable(table, options={}) { return html; } }); - } else { - // Remove the "in stock" column - delete columns['stock']; } function reloadTable() { @@ -1595,13 +1595,41 @@ function loadSalesOrderLineItemTable(table, options={}) { $(table).find('.button-add').click(function() { var pk = $(this).attr('pk'); - // TODO: Migrate this form to the API forms - launchModalForm(`/order/sales-order/allocation/new/`, { - success: reloadTable, - data: { - line: pk, + var line_item = $(table).bootstrapTable('getRowByUniqueId', pk); + + var fields = { + // SalesOrderLineItem reference + line: { + hidden: true, + value: pk, }, - }); + item: { + filters: { + part_detail: true, + location_detail: true, + in_stock: true, + part: line_item.part, + exclude_so_allocation: options.order, + } + }, + quantity: { + }, + }; + + // Exclude expired stock? + if (global_settings.STOCK_ENABLE_EXPIRY && !global_settings.STOCK_ALLOW_EXPIRED_SALE) { + fields.item.filters.expired = false; + } + + constructForm( + `/api/order/so-allocation/`, + { + method: 'POST', + fields: fields, + title: '{% trans "Allocate Stock Item" %}', + onSuccess: reloadTable, + } + ); }); // Callback for creating a new build