2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-29 12:06:44 +00:00

Sales order status update (#4542)

* Add extra status code "IN PROGRESS" for sales order

* Add method to 'issue' a SalesOrder

* Updates to salesorder template

* Add API endpoint and serializer for issuing a SalesOrder

* javascript for issuing order

* Cleanup buttons for SalesOrderLineItem table

* Bug fixes
This commit is contained in:
Oliver 2023-03-31 00:12:07 +11:00 committed by GitHub
parent d0bf4cb81a
commit f4f7803e96
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 166 additions and 58 deletions

View File

@ -131,6 +131,7 @@ class SalesOrderStatus(StatusCode):
"""Defines a set of status codes for a SalesOrder.""" """Defines a set of status codes for a SalesOrder."""
PENDING = 10 # Order is pending PENDING = 10 # Order is pending
IN_PROGRESS = 15 # Order has been issued, and is in progress
SHIPPED = 20 # Order has been shipped to customer SHIPPED = 20 # Order has been shipped to customer
CANCELLED = 40 # Order has been cancelled CANCELLED = 40 # Order has been cancelled
LOST = 50 # Order was lost LOST = 50 # Order was lost
@ -138,6 +139,7 @@ class SalesOrderStatus(StatusCode):
options = { options = {
PENDING: _("Pending"), PENDING: _("Pending"),
IN_PROGRESS: _("In Progress"),
SHIPPED: _("Shipped"), SHIPPED: _("Shipped"),
CANCELLED: _("Cancelled"), CANCELLED: _("Cancelled"),
LOST: _("Lost"), LOST: _("Lost"),
@ -146,6 +148,7 @@ class SalesOrderStatus(StatusCode):
colors = { colors = {
PENDING: 'secondary', PENDING: 'secondary',
IN_PROGRESS: 'primary',
SHIPPED: 'success', SHIPPED: 'success',
CANCELLED: 'danger', CANCELLED: 'danger',
LOST: 'warning', LOST: 'warning',
@ -155,6 +158,7 @@ class SalesOrderStatus(StatusCode):
# Open orders # Open orders
OPEN = [ OPEN = [
PENDING, PENDING,
IN_PROGRESS,
] ]
# Completed orders # Completed orders

View File

@ -918,6 +918,8 @@ class SalesOrderExtraLineItemMetadata(RetrieveUpdateAPI):
class SalesOrderContextMixin: class SalesOrderContextMixin:
"""Mixin to add sales order object as serializer context variable.""" """Mixin to add sales order object as serializer context variable."""
queryset = models.SalesOrder.objects.all()
def get_serializer_context(self): def get_serializer_context(self):
"""Add the 'order' reference to the serializer context for any classes which inherit this mixin""" """Add the 'order' reference to the serializer context for any classes which inherit this mixin"""
ctx = super().get_serializer_context() ctx = super().get_serializer_context()
@ -934,15 +936,17 @@ class SalesOrderContextMixin:
class SalesOrderCancel(SalesOrderContextMixin, CreateAPI): class SalesOrderCancel(SalesOrderContextMixin, CreateAPI):
"""API endpoint to cancel a SalesOrder""" """API endpoint to cancel a SalesOrder"""
queryset = models.SalesOrder.objects.all()
serializer_class = serializers.SalesOrderCancelSerializer serializer_class = serializers.SalesOrderCancelSerializer
class SalesOrderIssue(SalesOrderContextMixin, CreateAPI):
"""API endpoint to issue a SalesOrder"""
serializer_class = serializers.SalesOrderIssueSerializer
class SalesOrderComplete(SalesOrderContextMixin, CreateAPI): class SalesOrderComplete(SalesOrderContextMixin, CreateAPI):
"""API endpoint for manually marking a SalesOrder as "complete".""" """API endpoint for manually marking a SalesOrder as "complete"."""
queryset = models.SalesOrder.objects.all()
serializer_class = serializers.SalesOrderCompleteSerializer 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/', SalesOrderAllocate.as_view(), name='api-so-allocate'),
re_path(r'^allocate-serials/', SalesOrderAllocateSerials.as_view(), name='api-so-allocate-serials'), 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'^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'^complete/', SalesOrderComplete.as_view(), name='api-so-complete'),
re_path(r'^metadata/', SalesOrderMetadata.as_view(), name='api-so-metadata'), re_path(r'^metadata/', SalesOrderMetadata.as_view(), name='api-so-metadata'),

View File

@ -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'),
),
]

View File

@ -770,6 +770,11 @@ class SalesOrder(TotalPriceMixin, Order):
"""Return True if this order is 'pending'""" """Return True if this order is 'pending'"""
return self.status == SalesOrderStatus.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 @property
def stock_allocations(self): def stock_allocations(self):
"""Return a queryset containing all allocations for this order.""" """Return a queryset containing all allocations for this order."""
@ -827,6 +832,21 @@ class SalesOrder(TotalPriceMixin, Order):
return True 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): def complete_order(self, user, **kwargs):
"""Mark this order as "complete.""" """Mark this order as "complete."""
if not self.can_complete(**kwargs): if not self.can_complete(**kwargs):
@ -1717,8 +1737,12 @@ class ReturnOrder(TotalPriceMixin, Order):
trigger_event('returnorder.completed', id=self.pk) trigger_event('returnorder.completed', id=self.pk)
@transaction.atomic
def place_order(self): def place_order(self):
"""Deprecated version of 'issue_order"""
self.issue_order()
@transaction.atomic
def issue_order(self):
"""Issue this ReturnOrder (if currently pending)""" """Issue this ReturnOrder (if currently pending)"""
if self.status == ReturnOrderStatus.PENDING: if self.status == ReturnOrderStatus.PENDING:
@ -1726,7 +1750,7 @@ class ReturnOrder(TotalPriceMixin, Order):
self.issue_date = datetime.now().date() self.issue_date = datetime.now().date()
self.save() self.save()
trigger_event('returnorder.placed', id=self.pk) trigger_event('returnorder.issued', id=self.pk)
@transaction.atomic @transaction.atomic
def receive_line_item(self, line, location, user, note=''): def receive_line_item(self, line, location, user, note=''):

View File

@ -731,6 +731,19 @@ class SalesOrderSerializer(TotalPriceMixin, AbstractOrderSerializer, InvenTreeMo
customer_detail = CompanyBriefSerializer(source='customer', many=False, read_only=True) 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): class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
"""Serializer for the SalesOrderAllocation model. """Serializer for the SalesOrderAllocation model.
@ -1461,7 +1474,7 @@ class ReturnOrderIssueSerializer(serializers.Serializer):
def save(self): def save(self):
"""Save the serializer to 'issue' the order""" """Save the serializer to 'issue' the order"""
order = self.context['order'] order = self.context['order']
order.place_order() order.issue_order()
class ReturnOrderCancelSerializer(serializers.Serializer): class ReturnOrderCancelSerializer(serializers.Serializer):

View File

@ -62,8 +62,8 @@ src="{% static 'img/blank_image.png' %}"
{% endif %} {% endif %}
</ul> </ul>
{% if order.status == ReturnOrderStatus.PENDING %} {% if order.status == ReturnOrderStatus.PENDING %}
<button type='button' class='btn btn-primary' id='submit-order' title='{% trans "Submit Order" %}'> <button type='button' class='btn btn-primary' id='issue-order' title='{% trans "Issue Order" %}'>
<span class='fas fa-paper-plane'></span> {% trans "Submit Order" %} <span class='fas fa-paper-plane'></span> {% trans "Issue Order" %}
</button> </button>
{% elif order.status == ReturnOrderStatus.IN_PROGRESS %} {% elif order.status == ReturnOrderStatus.IN_PROGRESS %}
<button type='button' class='btn btn-success' id='complete-order' title='{% trans "Mark order as complete" %}'> <button type='button' class='btn btn-success' id='complete-order' title='{% trans "Mark order as complete" %}'>
@ -186,7 +186,7 @@ src="{% static 'img/blank_image.png' %}"
{% if roles.return_order.change %} {% if roles.return_order.change %}
{% if order.status == ReturnOrderStatus.PENDING %} {% if order.status == ReturnOrderStatus.PENDING %}
$('#submit-order').click(function() { $('#issue-order').click(function() {
issueReturnOrder({{ order.pk }}, { issueReturnOrder({{ order.pk }}, {
reload: true, reload: true,
}); });

View File

@ -56,18 +56,27 @@ src="{% static 'img/blank_image.png' %}"
</button> </button>
<ul class='dropdown-menu' role='menu'> <ul class='dropdown-menu' role='menu'>
<li><a class='dropdown-item' href='#' id='edit-order'><span class='fas fa-edit icon-green'></span> {% trans "Edit order" %}</a></li> <li><a class='dropdown-item' href='#' id='edit-order'><span class='fas fa-edit icon-green'></span> {% trans "Edit order" %}</a></li>
{% if order.status == SalesOrderStatus.PENDING %} {% if order.is_open %}
<li><a class='dropdown-item' href='#' id='complete-order-shipments'><span class='fas fa-truck'></span> {% trans "Complete Shipments" %}</a></li>
<li><a class='dropdown-item' href='#' id='cancel-order'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel order" %}</a></li> <li><a class='dropdown-item' href='#' id='cancel-order'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel order" %}</a></li>
{% endif %} {% endif %}
</ul> </ul>
</div> </div>
{% if order.status == SalesOrderStatus.PENDING %} <div class='btn-group' role='group'>
<button type='button' class='btn btn-success' id='complete-order' title='{% trans "Complete Sales Order" %}'> {% if order.is_pending %}
<span class='fas fa-check-circle'></span> {% trans "Complete Order" %} <button type='button' class='btn btn-primary' id='issue-order' title='{% trans "Issue Order" %}'>
</button> <span class='fas fa-paper-plane'></span> {% trans "Issue Order" %}
{% endif %} </button>
{% elif order.status == SalesOrderStatus.IN_PROGRESS %}
{% if not order.is_completed %}
<button type='button' class='btn btn-success' id='complete-order-shipments' title='{% trans "Ship Items" %}'>
<span class='fas fa-truck'></span> {% trans "Ship Items" %}
</button>
{% endif %}
<button type='button' class='btn btn-success' id='complete-order' title='{% trans "Complete Sales Order" %}'>
<span class='fas fa-check-circle'></span> {% trans "Complete Order" %}
</button>
{% endif %}
</div>
{% endif %} {% endif %}
{% endblock actions %} {% endblock actions %}
@ -232,6 +241,15 @@ $("#complete-order-shipments").click(function() {
); );
}); });
$('#issue-order').click(function() {
issueSalesOrder(
{{ order.pk }},
{
reload: true
}
);
});
$("#cancel-order").click(function() { $("#cancel-order").click(function() {
cancelSalesOrder( cancelSalesOrder(

View File

@ -19,7 +19,7 @@
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
{% if roles.sales_order.add %} {% if roles.sales_order.add %}
{% if order.is_pending or allow_extra_editing %} {% if order.is_open or allow_extra_editing %}
<button type='button' class='btn btn-success' id='new-so-line'> <button type='button' class='btn btn-success' id='new-so-line'>
<span class='fas fa-plus-circle'></span> {% trans "Add Line Item" %} <span class='fas fa-plus-circle'></span> {% trans "Add Line Item" %}
</button> </button>
@ -44,7 +44,7 @@
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
{% if roles.sales_order.change %} {% if roles.sales_order.change %}
{% if order.is_pending or allow_extra_editing %} {% if order.is_open or allow_extra_editing %}
<button type='button' class='btn btn-success' id='new-so-extra-line'> <button type='button' class='btn btn-success' id='new-so-extra-line'>
<span class='fas fa-plus-circle'></span> {% trans "Add Extra Line" %} <span class='fas fa-plus-circle'></span> {% trans "Add Extra Line" %}
</button> </button>
@ -253,11 +253,18 @@
order: {{ order.pk }}, order: {{ order.pk }},
reference: '{{ order.reference }}', reference: '{{ order.reference }}',
status: {{ order.status }}, status: {{ order.status }},
open: {% js_bool order.is_open %},
{% if roles.sales_order.change %} {% if roles.sales_order.change %}
{% settings_value "SALESORDER_EDIT_COMPLETED_ORDERS" as allow_edit %}
{% if allow_edit or order.is_open %}
allow_edit: true, allow_edit: true,
{% endif %} {% endif %}
{% if order.is_pending %} {% if order.status == SalesOrderStatus.IN_PROGRESS %}
pending: true, allow_ship: true,
{% endif %}
{% endif %}
{% if roles.sales_order.delete %}
allow_delete: true,
{% endif %} {% endif %}
} }
); );
@ -281,7 +288,7 @@
name: 'salesorderextraline', name: 'salesorderextraline',
filtertarget: '#filter-list-sales-order-extra-lines', filtertarget: '#filter-list-sales-order-extra-lines',
{% settings_value "SALESORDER_EDIT_COMPLETED_ORDERS" as allow_edit %} {% 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_edit: {% js_bool roles.sales_order.change %},
allow_delete: {% js_bool roles.sales_order.delete %}, allow_delete: {% js_bool roles.sales_order.delete %},
{% else %} {% else %}

View File

@ -362,10 +362,16 @@ function renderSalesOrder(data, parameters={}) {
image = data.customer_detail.thumbnail || data.customer_detail.image || blankImage(); 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( return renderModel(
{ {
image: image, image: image,
text: `${data.reference} - ${data.customer_detail.name}`, text: text,
textSecondary: shortenString(data.description), textSecondary: shortenString(data.description),
url: data.url || `/order/sales-order/${data.pk}/`, url: data.url || `/order/sales-order/${data.pk}/`,
}, },

View File

@ -27,6 +27,7 @@
createSalesOrderShipment, createSalesOrderShipment,
editSalesOrder, editSalesOrder,
exportOrder, exportOrder,
issueSalesOrder,
loadSalesOrderAllocationTable, loadSalesOrderAllocationTable,
loadSalesOrderLineItemTable, loadSalesOrderLineItemTable,
loadSalesOrderShipmentTable, 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 = `
<div class='alert alert-block alert-info'>
{% trans "Issue this Sales Order?" %}
</div>`;
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" * Launches a modal form to mark a SalesOrder as "cancelled"
*/ */
@ -1597,10 +1620,6 @@ function loadSalesOrderLineItemTable(table, options={}) {
options.table = table; options.table = table;
if (!options.pending && !global_settings.SALESORDER_EDIT_COMPLETED_ORDERS) {
options.allow_edit = false;
}
options.params = options.params || {}; options.params = options.params || {};
if (!options.order) { if (!options.order) {
@ -1632,14 +1651,7 @@ function loadSalesOrderLineItemTable(table, options={}) {
} }
); );
// Is the order pending? var show_detail = true;
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;
// Add callbacks for expand / collapse buttons // Add callbacks for expand / collapse buttons
$('#sales-lines-expand').click(function() { $('#sales-lines-expand').click(function() {
@ -1750,7 +1762,7 @@ function loadSalesOrderLineItemTable(table, options={}) {
} }
]; ];
if (pending) { if (options.open) {
columns.push( columns.push(
{ {
field: 'stock', field: 'stock',
@ -1843,25 +1855,22 @@ function loadSalesOrderLineItemTable(table, options={}) {
title: '{% trans "Notes" %}', title: '{% trans "Notes" %}',
}); });
if (pending) { columns.push({
columns.push({ field: 'buttons',
field: 'buttons', switchable: false,
switchable: false, formatter: function(value, row, index, field) {
formatter: function(value, row, index, field) { let pk = row.pk;
let buttons = '';
let buttons = ''; // Construct a set of buttons to display
if (row.part && row.part_detail) {
var pk = row.pk; let part = row.part_detail;
if (row.part) {
var part = row.part_detail;
if (options.allow_edit && !row.shipped) {
if (part.trackable) { if (part.trackable) {
buttons += makeIconButton('fa-hashtag icon-green', 'button-add-by-sn', pk, '{% trans "Allocate serial numbers" %}'); 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" %}'); buttons += makeIconButton('fa-sign-in-alt icon-green', 'button-add', pk, '{% trans "Allocate stock" %}');
if (part.purchaseable) { if (part.purchaseable) {
buttons += makeIconButton('fa-shopping-cart', 'button-buy', row.part, '{% trans "Purchase stock" %}'); buttons += makeIconButton('fa-shopping-cart', 'button-buy', row.part, '{% trans "Purchase stock" %}');
} }
@ -1869,13 +1878,17 @@ function loadSalesOrderLineItemTable(table, options={}) {
if (part.assembly) { if (part.assembly) {
buttons += makeIconButton('fa-tools', 'button-build', row.part, '{% trans "Build stock" %}'); 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 += makeCopyButton('button-duplicate', pk, '{% trans "Duplicate line item" %}');
buttons += makeEditButton('button-edit', pk, '{% trans "Edit line item" %}'); buttons += makeEditButton('button-edit', pk, '{% trans "Edit line item" %}');
}
if (options.allow_delete) {
var delete_disabled = false; var delete_disabled = false;
var title = '{% trans "Delete line item" %}'; 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! // Prevent deletion of the line item if items have been allocated or shipped!
buttons += makeDeleteButton('button-delete', pk, title, {disabled: delete_disabled}); buttons += makeDeleteButton('button-delete', pk, title, {disabled: delete_disabled});
return wrapButtons(buttons);
} }
});
} return wrapButtons(buttons);
}
});
function reloadTable() { function reloadTable() {
$(table).bootstrapTable('refresh'); $(table).bootstrapTable('refresh');
@ -1954,7 +1967,7 @@ function loadSalesOrderLineItemTable(table, options={}) {
{ {
success: function(response) { success: function(response) {
constructForm(`{% url "api-so-line-list" %}${options.order}/allocate-serials/`, { constructForm(`{% url "api-so-list" %}${options.order}/allocate-serials/`, {
method: 'POST', method: 'POST',
title: '{% trans "Allocate Serial Numbers" %}', title: '{% trans "Allocate Serial Numbers" %}',
fields: { fields: {
@ -2088,7 +2101,7 @@ function loadSalesOrderLineItemTable(table, options={}) {
detailViewByClick: false, detailViewByClick: false,
buttons: constructExpandCollapseButtons(table), buttons: constructExpandCollapseButtons(table),
detailFilter: function(index, row) { detailFilter: function(index, row) {
if (pending) { if (options.open) {
// Order is pending // Order is pending
return row.allocated > 0; return row.allocated > 0;
} else { } else {
@ -2096,7 +2109,7 @@ function loadSalesOrderLineItemTable(table, options={}) {
} }
}, },
detailFormatter: function(index, row, element) { detailFormatter: function(index, row, element) {
if (pending) { if (options.open) {
return showAllocationSubTable(index, row, element, options); return showAllocationSubTable(index, row, element, options);
} else { } else {
return showFulfilledSubTable(index, row, element, options); return showFulfilledSubTable(index, row, element, options);