Merge pull request #2110 from SchrodingersGat/sales-order-allocation-fixes

Sales order allocation fixes
This commit is contained in:
Oliver 2021-10-06 20:51:23 +11:00 committed by GitHub
commit 8ed81bdb01
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 141 additions and 199 deletions

View File

@ -10,12 +10,16 @@ import common.models
INVENTREE_SW_VERSION = "0.6.0 dev" 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 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 - Stock adjustment actions API is improved, using native DRF serializer support
- However adjustment actions now only support 'pk' as a lookup field - However adjustment actions now only support 'pk' as a lookup field

View File

@ -631,6 +631,15 @@ class SOLineItemDetail(generics.RetrieveUpdateDestroyAPIView):
serializer_class = SOLineItemSerializer 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): class SOAllocationList(generics.ListCreateAPIView):
""" """
API endpoint for listing SalesOrderAllocation objects API endpoint for listing SalesOrderAllocation objects
@ -743,8 +752,10 @@ order_api_urls = [
])), ])),
# API endpoints for purchase order line items # API endpoints for purchase order line items
url(r'^po-line/(?P<pk>\d+)/$', POLineItemDetail.as_view(), name='api-po-line-detail'), url(r'^po-line/', include([
url(r'^po-line/$', POLineItemList.as_view(), name='api-po-line-list'), url(r'^(?P<pk>\d+)/$', POLineItemDetail.as_view(), name='api-po-line-detail'),
url(r'^.*$', POLineItemList.as_view(), name='api-po-line-list'),
])),
# API endpoints for sales ordesr # API endpoints for sales ordesr
url(r'^so/', include([ url(r'^so/', include([
@ -764,9 +775,8 @@ order_api_urls = [
])), ])),
# API endpoints for sales order allocations # API endpoints for sales order allocations
url(r'^so-allocation', include([ url(r'^so-allocation/', include([
url(r'^(?P<pk>\d+)/$', SOAllocationDetail.as_view(), name='api-so-allocation-detail'),
# List all sales order allocations
url(r'^.*$', SOAllocationList.as_view(), name='api-so-allocation-list'), url(r'^.*$', SOAllocationList.as_view(), name='api-so-allocation-list'),
])), ])),
] ]

View File

@ -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): class EditSalesOrderAllocationForm(HelperForm):
""" """
Form for editing a SalesOrderAllocation item Form for editing a SalesOrderAllocation item

View File

@ -840,7 +840,13 @@ class SalesOrderLineItem(OrderLineItem):
def get_api_url(): def get_api_url():
return reverse('api-so-line-list') 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}) 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: if len(errors) > 0:
raise ValidationError(errors) 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( item = models.ForeignKey(
'stock.StockItem', 'stock.StockItem',

View File

@ -478,7 +478,7 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
part = serializers.PrimaryKeyRelatedField(source='item.part', read_only=True) part = serializers.PrimaryKeyRelatedField(source='item.part', read_only=True)
order = serializers.PrimaryKeyRelatedField(source='line.order', many=False, read_only=True) order = serializers.PrimaryKeyRelatedField(source='line.order', many=False, read_only=True)
serial = serializers.CharField(source='get_serial', 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) location = serializers.PrimaryKeyRelatedField(source='item.location', many=False, read_only=True)
# Extra detail fields # Extra detail fields

View File

@ -1,14 +0,0 @@
{% extends "modal_delete_form.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block pre_form_content %}
<div class='alert alert-block alert-warning'>
{% trans "This action will unallocate the following stock from the Sales Order" %}:
<br>
<strong>
{% decimal allocation.get_allocated %} x {{ allocation.line.part.full_name }}
{% if allocation.item.location %} ({{ allocation.get_location }}){% endif %}
</strong>
</div>
{% endblock %}

View File

@ -43,12 +43,7 @@ sales_order_detail_urls = [
sales_order_urls = [ sales_order_urls = [
# URLs for sales order allocations # URLs for sales order allocations
url(r'^allocation/', include([ 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'^assign-serials/', views.SalesOrderAssignSerials.as_view(), name='so-assign-serials'),
url(r'(?P<pk>\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 # Display detail view for a single SalesOrder

View File

@ -29,7 +29,6 @@ from company.models import Company, SupplierPart # ManufacturerPart
from stock.models import StockItem from stock.models import StockItem
from part.models import Part from part.models import Part
from common.models import InvenTreeSetting
from common.forms import UploadFileForm, MatchFieldForm from common.forms import UploadFileForm, MatchFieldForm
from common.views import FileManagementFormView from common.views import FileManagementFormView
from common.files import FileManager from common.files import FileManager
@ -37,7 +36,7 @@ from common.files import FileManager
from . import forms as order_forms from . import forms as order_forms
from part.views import PartPricing 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 DownloadFile, str2bool
from InvenTree.helpers import extract_serial_numbers from InvenTree.helpers import extract_serial_numbers
from InvenTree.views import InvenTreeRoleMixin 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): class LineItemPricing(PartPricing):
""" View for inspecting part pricing information """ """ View for inspecting part pricing information """

View File

@ -34,6 +34,7 @@ from company.models import Company, SupplierPart
from company.serializers import CompanySerializer, SupplierPartSerializer from company.serializers import CompanySerializer, SupplierPartSerializer
from order.models import PurchaseOrder from order.models import PurchaseOrder
from order.models import SalesOrder, SalesOrderAllocation
from order.serializers import POSerializer from order.serializers import POSerializer
import common.settings import common.settings
@ -645,6 +646,31 @@ class StockList(generics.ListCreateAPIView):
# Filter StockItem without build allocations or sales order allocations # Filter StockItem without build allocations or sales order allocations
queryset = queryset.filter(Q(sales_order_allocations__isnull=True) & Q(allocations__isnull=True)) 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? # Does the client wish to filter by the Part ID?
part_id = params.get('part', None) part_id = params.get('part', None)

View File

@ -532,6 +532,7 @@ function editPurchaseOrderLineItem(e) {
var url = $(src).attr('url'); var url = $(src).attr('url');
// TODO: Migrate this to the API forms
launchModalForm(url, { launchModalForm(url, {
reload: true, reload: true,
}); });
@ -547,7 +548,8 @@ function removePurchaseOrderLineItem(e) {
var src = e.target || e.srcElement; var src = e.target || e.srcElement;
var url = $(src).attr('url'); var url = $(src).attr('url');
// TODO: Migrate this to the API forms
launchModalForm(url, { launchModalForm(url, {
reload: true, reload: true,
}); });
@ -1151,31 +1153,44 @@ function showAllocationSubTable(index, row, element, options) {
// Is the parent SalesOrder pending? // Is the parent SalesOrder pending?
var pending = options.status == {{ SalesOrderStatus.PENDING }}; var pending = options.status == {{ SalesOrderStatus.PENDING }};
// Function to reload the allocation table
function reloadTable() {
table.bootstrapTable('refresh');
}
function setupCallbacks() { function setupCallbacks() {
// Add callbacks for 'edit' buttons // Add callbacks for 'edit' buttons
table.find('.button-allocation-edit').click(function() { table.find('.button-allocation-edit').click(function() {
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');
// TODO: Migrate to API forms // Edit the sales order alloction
launchModalForm(`/order/sales-order/allocation/${pk}/edit/`, { constructForm(
success: reloadTable, `/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 // Add callbacks for 'delete' buttons
table.find('.button-allocation-delete').click(function() { table.find('.button-allocation-delete').click(function() {
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');
// TODO: Migrate to API forms constructForm(
launchModalForm(`/order/sales-order/allocation/${pk}/delete/`, { `/api/order/so-allocation/${pk}/`,
success: reloadTable, {
}); 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={}) { function loadSalesOrderLineItemTable(table, options={}) {
options.table = table;
options.params = options.params || {}; options.params = options.params || {};
if (!options.order) { if (!options.order) {
@ -1433,13 +1450,21 @@ function loadSalesOrderLineItemTable(table, options={}) {
return formatter.format(total); return formatter.format(total);
} }
}, },
{ ];
field: 'stock',
title: '{% trans "In Stock" %}', if (pending) {
formatter: function(value, row) { columns.push(
return row.part_detail.stock; {
field: 'stock',
title: '{% trans "In Stock" %}',
formatter: function(value, row) {
return row.part_detail.stock;
},
}, },
}, );
}
columns.push(
{ {
field: 'allocated', field: 'allocated',
title: pending ? '{% trans "Allocated" %}' : '{% trans "Fulfilled" %}', title: pending ? '{% trans "Allocated" %}' : '{% trans "Fulfilled" %}',
@ -1470,29 +1495,7 @@ function loadSalesOrderLineItemTable(table, options={}) {
field: 'notes', field: 'notes',
title: '{% trans "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 `<div>` + po_name + `</div>`;
}
},
*/
];
if (pending) { if (pending) {
columns.push({ columns.push({
@ -1531,9 +1534,6 @@ function loadSalesOrderLineItemTable(table, options={}) {
return html; return html;
} }
}); });
} else {
// Remove the "in stock" column
delete columns['stock'];
} }
function reloadTable() { function reloadTable() {
@ -1595,13 +1595,41 @@ function loadSalesOrderLineItemTable(table, options={}) {
$(table).find('.button-add').click(function() { $(table).find('.button-add').click(function() {
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');
// TODO: Migrate this form to the API forms var line_item = $(table).bootstrapTable('getRowByUniqueId', pk);
launchModalForm(`/order/sales-order/allocation/new/`, {
success: reloadTable, var fields = {
data: { // SalesOrderLineItem reference
line: pk, 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 // Callback for creating a new build