diff --git a/InvenTree/InvenTree/status_codes.py b/InvenTree/InvenTree/status_codes.py index edee876c5c..98452e8fa2 100644 --- a/InvenTree/InvenTree/status_codes.py +++ b/InvenTree/InvenTree/status_codes.py @@ -131,6 +131,7 @@ class SalesOrderStatus(StatusCode): """Defines a set of status codes for a SalesOrder.""" PENDING = 10 # Order is pending + IN_PROGRESS = 15 # Order has been issued, and is in progress SHIPPED = 20 # Order has been shipped to customer CANCELLED = 40 # Order has been cancelled LOST = 50 # Order was lost @@ -138,6 +139,7 @@ class SalesOrderStatus(StatusCode): options = { PENDING: _("Pending"), + IN_PROGRESS: _("In Progress"), SHIPPED: _("Shipped"), CANCELLED: _("Cancelled"), LOST: _("Lost"), @@ -146,6 +148,7 @@ class SalesOrderStatus(StatusCode): colors = { PENDING: 'secondary', + IN_PROGRESS: 'primary', SHIPPED: 'success', CANCELLED: 'danger', LOST: 'warning', @@ -155,6 +158,7 @@ class SalesOrderStatus(StatusCode): # Open orders OPEN = [ PENDING, + IN_PROGRESS, ] # Completed orders diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index 7bc49cb26b..4d5b0e5503 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -918,6 +918,8 @@ class SalesOrderExtraLineItemMetadata(RetrieveUpdateAPI): class SalesOrderContextMixin: """Mixin to add sales order object as serializer context variable.""" + queryset = models.SalesOrder.objects.all() + def get_serializer_context(self): """Add the 'order' reference to the serializer context for any classes which inherit this mixin""" ctx = super().get_serializer_context() @@ -934,15 +936,17 @@ class SalesOrderContextMixin: class SalesOrderCancel(SalesOrderContextMixin, CreateAPI): """API endpoint to cancel a SalesOrder""" - - queryset = models.SalesOrder.objects.all() serializer_class = serializers.SalesOrderCancelSerializer +class SalesOrderIssue(SalesOrderContextMixin, CreateAPI): + """API endpoint to issue a SalesOrder""" + serializer_class = serializers.SalesOrderIssueSerializer + + class SalesOrderComplete(SalesOrderContextMixin, CreateAPI): """API endpoint for manually marking a SalesOrder as "complete".""" - queryset = models.SalesOrder.objects.all() serializer_class = serializers.SalesOrderCompleteSerializer @@ -1649,6 +1653,7 @@ order_api_urls = [ re_path(r'^allocate/', SalesOrderAllocate.as_view(), name='api-so-allocate'), re_path(r'^allocate-serials/', SalesOrderAllocateSerials.as_view(), name='api-so-allocate-serials'), re_path(r'^cancel/', SalesOrderCancel.as_view(), name='api-so-cancel'), + re_path(r'^issue/', SalesOrderIssue.as_view(), name='api-so-issue'), re_path(r'^complete/', SalesOrderComplete.as_view(), name='api-so-complete'), re_path(r'^metadata/', SalesOrderMetadata.as_view(), name='api-so-metadata'), diff --git a/InvenTree/order/migrations/0087_alter_salesorder_status.py b/InvenTree/order/migrations/0087_alter_salesorder_status.py new file mode 100644 index 0000000000..eaf4029917 --- /dev/null +++ b/InvenTree/order/migrations/0087_alter_salesorder_status.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.18 on 2023-03-30 11:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0086_auto_20230323_1108'), + ] + + operations = [ + migrations.AlterField( + model_name='salesorder', + name='status', + field=models.PositiveIntegerField(choices=[(10, 'Pending'), (15, 'In Progress'), (20, 'Shipped'), (40, 'Cancelled'), (50, 'Lost'), (60, 'Returned')], default=10, help_text='Purchase order status', verbose_name='Status'), + ), + ] diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index ac7499735e..18bb37aa8e 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -770,6 +770,11 @@ class SalesOrder(TotalPriceMixin, Order): """Return True if this order is 'pending'""" return self.status == SalesOrderStatus.PENDING + @property + def is_open(self): + """Return True if this order is 'open' (either 'pending' or 'in_progress')""" + return self.status in SalesOrderStatus.OPEN + @property def stock_allocations(self): """Return a queryset containing all allocations for this order.""" @@ -827,6 +832,21 @@ class SalesOrder(TotalPriceMixin, Order): return True + def place_order(self): + """Deprecated version of 'issue_order'""" + self.issue_order() + + @transaction.atomic + def issue_order(self): + """Change this order from 'PENDING' to 'IN_PROGRESS'""" + + if self.status == SalesOrderStatus.PENDING: + self.status = SalesOrderStatus.IN_PROGRESS + self.issue_date = datetime.now().date() + self.save() + + trigger_event('salesorder.issued', id=self.pk) + def complete_order(self, user, **kwargs): """Mark this order as "complete.""" if not self.can_complete(**kwargs): @@ -1717,8 +1737,12 @@ class ReturnOrder(TotalPriceMixin, Order): trigger_event('returnorder.completed', id=self.pk) - @transaction.atomic def place_order(self): + """Deprecated version of 'issue_order""" + self.issue_order() + + @transaction.atomic + def issue_order(self): """Issue this ReturnOrder (if currently pending)""" if self.status == ReturnOrderStatus.PENDING: @@ -1726,7 +1750,7 @@ class ReturnOrder(TotalPriceMixin, Order): self.issue_date = datetime.now().date() self.save() - trigger_event('returnorder.placed', id=self.pk) + trigger_event('returnorder.issued', id=self.pk) @transaction.atomic def receive_line_item(self, line, location, user, note=''): diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index b992665157..939697b67c 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -731,6 +731,19 @@ class SalesOrderSerializer(TotalPriceMixin, AbstractOrderSerializer, InvenTreeMo customer_detail = CompanyBriefSerializer(source='customer', many=False, read_only=True) +class SalesOrderIssueSerializer(serializers.Serializer): + """Serializer for issuing a SalesOrder""" + + class Meta: + """Metaclass options""" + fields = [] + + def save(self): + """Save the serializer to 'issue' the order""" + order = self.context['order'] + order.issue_order() + + class SalesOrderAllocationSerializer(InvenTreeModelSerializer): """Serializer for the SalesOrderAllocation model. @@ -1461,7 +1474,7 @@ class ReturnOrderIssueSerializer(serializers.Serializer): def save(self): """Save the serializer to 'issue' the order""" order = self.context['order'] - order.place_order() + order.issue_order() class ReturnOrderCancelSerializer(serializers.Serializer): diff --git a/InvenTree/order/templates/order/return_order_base.html b/InvenTree/order/templates/order/return_order_base.html index 48574b7646..88cdcb4d40 100644 --- a/InvenTree/order/templates/order/return_order_base.html +++ b/InvenTree/order/templates/order/return_order_base.html @@ -62,8 +62,8 @@ src="{% static 'img/blank_image.png' %}" {% endif %} {% if order.status == ReturnOrderStatus.PENDING %} - {% elif order.status == ReturnOrderStatus.IN_PROGRESS %} - -{% if order.status == SalesOrderStatus.PENDING %} - -{% endif %} +
+ {% if order.is_pending %} + + {% elif order.status == SalesOrderStatus.IN_PROGRESS %} + {% if not order.is_completed %} + + {% endif %} + + {% endif %} +
{% endif %} {% endblock actions %} @@ -232,6 +241,15 @@ $("#complete-order-shipments").click(function() { ); }); +$('#issue-order').click(function() { + issueSalesOrder( + {{ order.pk }}, + { + reload: true + } + ); +}); + $("#cancel-order").click(function() { cancelSalesOrder( diff --git a/InvenTree/order/templates/order/sales_order_detail.html b/InvenTree/order/templates/order/sales_order_detail.html index bcd96796d5..d484c42b41 100644 --- a/InvenTree/order/templates/order/sales_order_detail.html +++ b/InvenTree/order/templates/order/sales_order_detail.html @@ -19,7 +19,7 @@ {% include "spacer.html" %}
{% if roles.sales_order.add %} - {% if order.is_pending or allow_extra_editing %} + {% if order.is_open or allow_extra_editing %} @@ -44,7 +44,7 @@ {% include "spacer.html" %}
{% if roles.sales_order.change %} - {% if order.is_pending or allow_extra_editing %} + {% if order.is_open or allow_extra_editing %} @@ -253,11 +253,18 @@ order: {{ order.pk }}, reference: '{{ order.reference }}', status: {{ order.status }}, + open: {% js_bool order.is_open %}, {% if roles.sales_order.change %} + {% settings_value "SALESORDER_EDIT_COMPLETED_ORDERS" as allow_edit %} + {% if allow_edit or order.is_open %} allow_edit: true, {% endif %} - {% if order.is_pending %} - pending: true, + {% if order.status == SalesOrderStatus.IN_PROGRESS %} + allow_ship: true, + {% endif %} + {% endif %} + {% if roles.sales_order.delete %} + allow_delete: true, {% endif %} } ); @@ -281,7 +288,7 @@ name: 'salesorderextraline', filtertarget: '#filter-list-sales-order-extra-lines', {% settings_value "SALESORDER_EDIT_COMPLETED_ORDERS" as allow_edit %} - {% if order.is_pending or allow_edit %} + {% if order.is_open or allow_edit %} allow_edit: {% js_bool roles.sales_order.change %}, allow_delete: {% js_bool roles.sales_order.delete %}, {% else %} diff --git a/InvenTree/templates/js/translated/model_renderers.js b/InvenTree/templates/js/translated/model_renderers.js index 4edb13d165..f69c0ac531 100644 --- a/InvenTree/templates/js/translated/model_renderers.js +++ b/InvenTree/templates/js/translated/model_renderers.js @@ -362,10 +362,16 @@ function renderSalesOrder(data, parameters={}) { image = data.customer_detail.thumbnail || data.customer_detail.image || blankImage(); } + let text = data.reference; + + if (data.customer_detail) { + text += ` - ${data.customer_detail.name}`; + } + return renderModel( { image: image, - text: `${data.reference} - ${data.customer_detail.name}`, + text: text, textSecondary: shortenString(data.description), url: data.url || `/order/sales-order/${data.pk}/`, }, diff --git a/InvenTree/templates/js/translated/sales_order.js b/InvenTree/templates/js/translated/sales_order.js index 763718d100..af8488f6b1 100644 --- a/InvenTree/templates/js/translated/sales_order.js +++ b/InvenTree/templates/js/translated/sales_order.js @@ -27,6 +27,7 @@ createSalesOrderShipment, editSalesOrder, exportOrder, + issueSalesOrder, loadSalesOrderAllocationTable, loadSalesOrderLineItemTable, loadSalesOrderShipmentTable, @@ -456,6 +457,28 @@ function completeSalesOrder(order_id, options={}) { } +/* + * Launches sa modal form to mark a SalesOrder as "issued" + */ +function issueSalesOrder(order_id, options={}) { + + let html = ` +
+ {% trans "Issue this Sales Order?" %} +
`; + + constructForm(`{% url "api-so-list" %}${order_id}/issue/`, { + method: 'POST', + title: '{% trans "Issue Sales Order" %}', + confirm: true, + preFormContent: html, + onSuccess: function(response) { + handleFormSuccess(response, options); + } + }); +} + + /* * Launches a modal form to mark a SalesOrder as "cancelled" */ @@ -1597,10 +1620,6 @@ function loadSalesOrderLineItemTable(table, options={}) { options.table = table; - if (!options.pending && !global_settings.SALESORDER_EDIT_COMPLETED_ORDERS) { - options.allow_edit = false; - } - options.params = options.params || {}; if (!options.order) { @@ -1632,14 +1651,7 @@ function loadSalesOrderLineItemTable(table, options={}) { } ); - // Is the order pending? - var pending = options.pending; - - // Has the order shipped? - var shipped = options.status == {{ SalesOrderStatus.SHIPPED }}; - - // Show detail view if the PurchaseOrder is PENDING or SHIPPED - var show_detail = pending || shipped; + var show_detail = true; // Add callbacks for expand / collapse buttons $('#sales-lines-expand').click(function() { @@ -1750,7 +1762,7 @@ function loadSalesOrderLineItemTable(table, options={}) { } ]; - if (pending) { + if (options.open) { columns.push( { field: 'stock', @@ -1843,25 +1855,22 @@ function loadSalesOrderLineItemTable(table, options={}) { title: '{% trans "Notes" %}', }); - if (pending) { - columns.push({ - field: 'buttons', - switchable: false, - formatter: function(value, row, index, field) { + columns.push({ + field: 'buttons', + switchable: false, + formatter: function(value, row, index, field) { + let pk = row.pk; + let buttons = ''; - let buttons = ''; - - var pk = row.pk; - - if (row.part) { - var part = row.part_detail; + // Construct a set of buttons to display + if (row.part && row.part_detail) { + let part = row.part_detail; + if (options.allow_edit && !row.shipped) { if (part.trackable) { buttons += makeIconButton('fa-hashtag icon-green', 'button-add-by-sn', pk, '{% trans "Allocate serial numbers" %}'); } - buttons += makeIconButton('fa-sign-in-alt icon-green', 'button-add', pk, '{% trans "Allocate stock" %}'); - if (part.purchaseable) { buttons += makeIconButton('fa-shopping-cart', 'button-buy', row.part, '{% trans "Purchase stock" %}'); } @@ -1869,13 +1878,17 @@ function loadSalesOrderLineItemTable(table, options={}) { if (part.assembly) { buttons += makeIconButton('fa-tools', 'button-build', row.part, '{% trans "Build stock" %}'); } - - buttons += makeIconButton('fa-dollar-sign icon-green', 'button-price', pk, '{% trans "Calculate price" %}'); } + } + buttons += makeIconButton('fa-dollar-sign icon-green', 'button-price', pk, '{% trans "Calculate price" %}'); + + if (options.allow_edit) { buttons += makeCopyButton('button-duplicate', pk, '{% trans "Duplicate line item" %}'); buttons += makeEditButton('button-edit', pk, '{% trans "Edit line item" %}'); + } + if (options.allow_delete) { var delete_disabled = false; var title = '{% trans "Delete line item" %}'; @@ -1890,11 +1903,11 @@ function loadSalesOrderLineItemTable(table, options={}) { // Prevent deletion of the line item if items have been allocated or shipped! buttons += makeDeleteButton('button-delete', pk, title, {disabled: delete_disabled}); - - return wrapButtons(buttons); } - }); - } + + return wrapButtons(buttons); + } + }); function reloadTable() { $(table).bootstrapTable('refresh'); @@ -1954,7 +1967,7 @@ function loadSalesOrderLineItemTable(table, options={}) { { success: function(response) { - constructForm(`{% url "api-so-line-list" %}${options.order}/allocate-serials/`, { + constructForm(`{% url "api-so-list" %}${options.order}/allocate-serials/`, { method: 'POST', title: '{% trans "Allocate Serial Numbers" %}', fields: { @@ -2088,7 +2101,7 @@ function loadSalesOrderLineItemTable(table, options={}) { detailViewByClick: false, buttons: constructExpandCollapseButtons(table), detailFilter: function(index, row) { - if (pending) { + if (options.open) { // Order is pending return row.allocated > 0; } else { @@ -2096,7 +2109,7 @@ function loadSalesOrderLineItemTable(table, options={}) { } }, detailFormatter: function(index, row, element) { - if (pending) { + if (options.open) { return showAllocationSubTable(index, row, element, options); } else { return showFulfilledSubTable(index, row, element, options);