2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-16 20:15:44 +00:00

Sales order improvements (#8445)

* Migration for SalesOrderAllocation

- Allow allocation against order with null shipment

* Enhaced query efficiency

* Further API cleanup

* Adjust serializer

* PUI updates

* Enable editing of allocation shipment

* Improve shipment filtering

* Add sub-table for salesorderlineitem

* Add helper method to SalesOrder to return pending SalesOrderAllocations

* Fix for CUI

* Update form for CUI

* Prevent SalesOrder completion with incomplete allocations

* Fixes for StockItem API

* Frontend refactoring

* Code cleanup

* Annotate shipment information to SalesOrder API endpoint

* Update frontend PUI

* Additional filtering for SalesOrderAllocation

* Bump API version

* Hide panel based on user permissions

* js linting

* Unit test fix

* Update playwright tests

* Revert diff

* Disable playwright test (temporary)

* View output from build table
This commit is contained in:
Oliver
2024-11-08 23:05:24 +11:00
committed by GitHub
parent 656950aea3
commit 2c294d6ebe
22 changed files with 478 additions and 245 deletions

View File

@ -1,13 +1,19 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 277
INVENTREE_API_VERSION = 278
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v278 - 2024-11-07 : https://github.com/inventree/InvenTree/pull/8445
- Updates to the SalesOrder API endpoints
- Add "shipment count" information to the SalesOrder API endpoints
- Allow null value for SalesOrderAllocation.shipment field
- Additional filtering options for allocation endpoints
v277 - 2024-11-01 : https://github.com/inventree/InvenTree/pull/8278
- Allow build order list to be filtered by "outstanding" (alias for "active")

View File

@ -978,7 +978,7 @@ class SalesOrderAllocationFilter(rest_filters.FilterSet):
"""Metaclass options."""
model = models.SalesOrderAllocation
fields = ['shipment', 'item']
fields = ['shipment', 'line', 'item']
order = rest_filters.ModelChoiceFilter(
queryset=models.SalesOrder.objects.all(),
@ -1034,6 +1034,16 @@ class SalesOrderAllocationFilter(rest_filters.FilterSet):
line__order__status__in=SalesOrderStatusGroups.OPEN,
)
assigned_to_shipment = rest_filters.BooleanFilter(
label=_('Has Shipment'), method='filter_assigned_to_shipment'
)
def filter_assigned_to_shipment(self, queryset, name, value):
"""Filter by whether or not the allocation has been assigned to a shipment."""
if str2bool(value):
return queryset.exclude(shipment=None)
return queryset.filter(shipment=None)
class SalesOrderAllocationMixin:
"""Mixin class for SalesOrderAllocation endpoints."""
@ -1049,12 +1059,16 @@ class SalesOrderAllocationMixin:
'item',
'item__sales_order',
'item__part',
'line__part',
'item__location',
'line__order',
'line__part',
'line__order__responsible',
'line__order__project_code',
'line__order__project_code__responsible',
'shipment',
'shipment__order',
)
'shipment__checked_by',
).select_related('line__part__pricing_data', 'item__part__pricing_data')
return queryset
@ -1065,7 +1079,15 @@ class SalesOrderAllocationList(SalesOrderAllocationMixin, ListAPI):
filterset_class = SalesOrderAllocationFilter
filter_backends = SEARCH_ORDER_FILTER_ALIAS
ordering_fields = ['quantity', 'part', 'serial', 'batch', 'location', 'order']
ordering_fields = [
'quantity',
'part',
'serial',
'batch',
'location',
'order',
'shipment_date',
]
ordering_field_aliases = {
'part': 'item__part__name',
@ -1073,6 +1095,7 @@ class SalesOrderAllocationList(SalesOrderAllocationMixin, ListAPI):
'batch': 'item__batch',
'location': 'item__location__name',
'order': 'line__order__reference',
'shipment_date': 'shipment__shipment_date',
}
search_fields = {'item__part__name', 'item__serial', 'item__batch'}

View File

@ -0,0 +1,19 @@
# Generated by Django 4.2.16 on 2024-11-06 04:46
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('order', '0102_purchaseorder_destination_and_more'),
]
operations = [
migrations.AlterField(
model_name='salesorderallocation',
name='shipment',
field=models.ForeignKey(blank=True, help_text='Sales order shipment reference', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='allocations', to='order.salesordershipment', verbose_name='Shipment'),
),
]

View File

@ -1105,6 +1105,11 @@ class SalesOrder(TotalPriceMixin, Order):
_('Order cannot be completed as there are incomplete shipments')
)
if self.pending_allocation_count > 0:
raise ValidationError(
_('Order cannot be completed as there are incomplete allocations')
)
if not allow_incomplete_lines and self.pending_line_count > 0:
raise ValidationError(
_('Order cannot be completed as there are incomplete line items')
@ -1297,6 +1302,23 @@ class SalesOrder(TotalPriceMixin, Order):
"""Return a queryset of the pending shipments for this order."""
return self.shipments.filter(shipment_date=None)
def allocations(self):
"""Return a queryset of all allocations for this order."""
return SalesOrderAllocation.objects.filter(line__order=self)
def pending_allocations(self):
"""Return a queryset of any pending allocations for this order.
Allocations are pending if:
a) They are not associated with a SalesOrderShipment
b) The linked SalesOrderShipment has not been shipped
"""
Q1 = Q(shipment=None)
Q2 = Q(shipment__shipment_date=None)
return self.allocations().filter(Q1 | Q2).distinct()
@property
def shipment_count(self):
"""Return the total number of shipments associated with this order."""
@ -1312,6 +1334,11 @@ class SalesOrder(TotalPriceMixin, Order):
"""Return the number of pending shipments associated with this order."""
return self.pending_shipments().count()
@property
def pending_allocation_count(self):
"""Return the number of pending (non-shipped) allocations."""
return self.pending_allocations().count()
@receiver(post_save, sender=SalesOrder, dispatch_uid='sales_order_post_save')
def after_save_sales_order(sender, instance: SalesOrder, created: bool, **kwargs):
@ -2030,7 +2057,7 @@ class SalesOrderAllocation(models.Model):
if self.item.serial and self.quantity != 1:
errors['quantity'] = _('Quantity must be 1 for serialized stock item')
if self.line.order != self.shipment.order:
if self.shipment and self.line.order != self.shipment.order:
errors['line'] = _('Sales order does not match shipment')
errors['shipment'] = _('Shipment does not match sales order')
@ -2047,6 +2074,8 @@ class SalesOrderAllocation(models.Model):
shipment = models.ForeignKey(
SalesOrderShipment,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='allocations',
verbose_name=_('Shipment'),
help_text=_('Sales order shipment reference'),

View File

@ -990,6 +990,8 @@ class SalesOrderSerializer(
'shipment_date',
'total_price',
'order_currency',
'shipments_count',
'completed_shipments_count',
])
read_only_fields = ['status', 'creation_date', 'shipment_date']
@ -1035,12 +1037,26 @@ class SalesOrderSerializer(
)
)
# Annotate shipment details
queryset = queryset.annotate(
shipments_count=SubqueryCount('shipments'),
completed_shipments_count=SubqueryCount(
'shipments', filter=Q(shipment_date__isnull=False)
),
)
return queryset
customer_detail = CompanyBriefSerializer(
source='customer', many=False, read_only=True
)
shipments_count = serializers.IntegerField(read_only=True, label=_('Shipments'))
completed_shipments_count = serializers.IntegerField(
read_only=True, label=_('Completed Shipments')
)
class SalesOrderIssueSerializer(OrderAdjustSerializer):
"""Serializer for issuing a SalesOrder."""
@ -1246,6 +1262,15 @@ class SalesOrderShipmentSerializer(NotesFieldMixin, InvenTreeModelSerializer):
'notes',
]
def __init__(self, *args, **kwargs):
"""Initialization routine for the serializer."""
order_detail = kwargs.pop('order_detail', True)
super().__init__(*args, **kwargs)
if not order_detail:
self.fields.pop('order_detail', None)
@staticmethod
def annotate_queryset(queryset):
"""Annotate the queryset with extra information."""
@ -1276,22 +1301,26 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
fields = [
'pk',
'line',
'customer_detail',
'serial',
'quantity',
'location',
'location_detail',
'item',
'item_detail',
'order',
'order_detail',
'part',
'part_detail',
'quantity',
'shipment',
# Annotated read-only fields
'line',
'part',
'order',
'serial',
'location',
# Extra detail fields
'item_detail',
'part_detail',
'order_detail',
'customer_detail',
'location_detail',
'shipment_detail',
]
read_only_fields = ['line', '']
def __init__(self, *args, **kwargs):
"""Initialization routine for the serializer."""
order_detail = kwargs.pop('order_detail', False)
@ -1341,7 +1370,7 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
)
shipment_detail = SalesOrderShipmentSerializer(
source='shipment', many=False, read_only=True
source='shipment', order_detail=False, many=False, read_only=True
)
@ -1596,8 +1625,8 @@ class SalesOrderSerialAllocationSerializer(serializers.Serializer):
shipment = serializers.PrimaryKeyRelatedField(
queryset=order.models.SalesOrderShipment.objects.all(),
many=False,
allow_null=False,
required=True,
required=False,
allow_null=True,
label=_('Shipment'),
)
@ -1609,10 +1638,10 @@ class SalesOrderSerialAllocationSerializer(serializers.Serializer):
"""
order = self.context['order']
if shipment.shipment_date is not None:
if shipment and shipment.shipment_date is not None:
raise ValidationError(_('Shipment has already been shipped'))
if shipment.order != order:
if shipment and shipment.order != order:
raise ValidationError(_('Shipment is not associated with this order'))
return shipment
@ -1720,8 +1749,8 @@ class SalesOrderShipmentAllocationSerializer(serializers.Serializer):
shipment = serializers.PrimaryKeyRelatedField(
queryset=order.models.SalesOrderShipment.objects.all(),
many=False,
allow_null=False,
required=True,
required=False,
allow_null=True,
label=_('Shipment'),
)
@ -1756,7 +1785,7 @@ class SalesOrderShipmentAllocationSerializer(serializers.Serializer):
data = self.validated_data
items = data['items']
shipment = data['shipment']
shipment = data.get('shipment')
with transaction.atomic():
for entry in items:

View File

@ -1877,7 +1877,6 @@ class SalesOrderAllocateTest(OrderTest):
response = self.post(self.url, {}, expected_code=400)
self.assertIn('This field is required', str(response.data['items']))
self.assertIn('This field is required', str(response.data['shipment']))
# Test with a single line items
line = self.order.lines.first()

View File

@ -50,7 +50,7 @@ from InvenTree.mixins import (
RetrieveAPI,
RetrieveUpdateDestroyAPI,
)
from order.models import PurchaseOrder, ReturnOrder, SalesOrder, SalesOrderAllocation
from order.models import PurchaseOrder, ReturnOrder, SalesOrder
from order.serializers import (
PurchaseOrderSerializer,
ReturnOrderSerializer,
@ -101,55 +101,6 @@ class GenerateSerialNumber(GenericAPIView):
return Response(data, status=status.HTTP_201_CREATED)
class StockDetail(RetrieveUpdateDestroyAPI):
"""API detail endpoint for Stock object.
get:
Return a single StockItem object
post:
Update a StockItem
delete:
Remove a StockItem
"""
queryset = StockItem.objects.all()
serializer_class = StockSerializers.StockItemSerializer
def get_queryset(self, *args, **kwargs):
"""Annotate queryset."""
queryset = super().get_queryset(*args, **kwargs)
queryset = StockSerializers.StockItemSerializer.annotate_queryset(queryset)
return queryset
def get_serializer_context(self):
"""Extend serializer context."""
ctx = super().get_serializer_context()
ctx['user'] = getattr(self.request, 'user', None)
return ctx
def get_serializer(self, *args, **kwargs):
"""Set context before returning serializer."""
kwargs['context'] = self.get_serializer_context()
try:
params = self.request.query_params
kwargs['part_detail'] = str2bool(params.get('part_detail', True))
kwargs['location_detail'] = str2bool(params.get('location_detail', True))
kwargs['supplier_part_detail'] = str2bool(
params.get('supplier_part_detail', True)
)
kwargs['path_detail'] = str2bool(params.get('path_detail', False))
except AttributeError: # pragma: no cover
pass
return self.serializer_class(*args, **kwargs)
class StockItemContextMixin:
"""Mixin class for adding StockItem object to serializer context."""
@ -531,54 +482,88 @@ class StockFilter(rest_filters.FilterSet):
)
supplier = rest_filters.ModelChoiceFilter(
label='Supplier',
label=_('Supplier'),
queryset=Company.objects.filter(is_supplier=True),
field_name='supplier_part__supplier',
)
include_variants = rest_filters.BooleanFilter(
label=_('Include Variants'), method='filter_include_variants'
)
def filter_include_variants(self, queryset, name, value):
"""Filter by whether or not to include variants of the selected part.
Note:
- This filter does nothing by itself, and requires the 'part' filter to be set.
- Refer to the 'filter_part' method for more information.
"""
return queryset
part = rest_filters.ModelChoiceFilter(
label=_('Part'), queryset=Part.objects.all(), method='filter_part'
)
def filter_part(self, queryset, name, part):
"""Filter StockItem list by provided Part instance.
Note:
- If "part" is a variant, include all variants of the selected part
- Otherwise, filter by the selected part
"""
include_variants = str2bool(self.data.get('include_variants', True))
if include_variants:
return queryset.filter(part__in=part.get_descendants(include_self=True))
else:
return queryset.filter(part=part)
# Part name filters
name = rest_filters.CharFilter(
label='Part name (case insensitive)',
label=_('Part name (case insensitive)'),
field_name='part__name',
lookup_expr='iexact',
)
name_contains = rest_filters.CharFilter(
label='Part name contains (case insensitive)',
label=_('Part name contains (case insensitive)'),
field_name='part__name',
lookup_expr='icontains',
)
name_regex = rest_filters.CharFilter(
label='Part name (regex)', field_name='part__name', lookup_expr='iregex'
label=_('Part name (regex)'), field_name='part__name', lookup_expr='iregex'
)
# Part IPN filters
IPN = rest_filters.CharFilter(
label='Part IPN (case insensitive)',
label=_('Part IPN (case insensitive)'),
field_name='part__IPN',
lookup_expr='iexact',
)
IPN_contains = rest_filters.CharFilter(
label='Part IPN contains (case insensitive)',
label=_('Part IPN contains (case insensitive)'),
field_name='part__IPN',
lookup_expr='icontains',
)
IPN_regex = rest_filters.CharFilter(
label='Part IPN (regex)', field_name='part__IPN', lookup_expr='iregex'
label=_('Part IPN (regex)'), field_name='part__IPN', lookup_expr='iregex'
)
# Part attribute filters
assembly = rest_filters.BooleanFilter(label='Assembly', field_name='part__assembly')
active = rest_filters.BooleanFilter(label='Active', field_name='part__active')
salable = rest_filters.BooleanFilter(label='Salable', field_name='part__salable')
assembly = rest_filters.BooleanFilter(
label=_('Assembly'), field_name='part__assembly'
)
active = rest_filters.BooleanFilter(label=_('Active'), field_name='part__active')
salable = rest_filters.BooleanFilter(label=_('Salable'), field_name='part__salable')
min_stock = rest_filters.NumberFilter(
label='Minimum stock', field_name='quantity', lookup_expr='gte'
label=_('Minimum stock'), field_name='quantity', lookup_expr='gte'
)
max_stock = rest_filters.NumberFilter(
label='Maximum stock', field_name='quantity', lookup_expr='lte'
label=_('Maximum stock'), field_name='quantity', lookup_expr='lte'
)
status = rest_filters.NumberFilter(label='Status Code', method='filter_status')
status = rest_filters.NumberFilter(label=_('Status Code'), method='filter_status')
def filter_status(self, queryset, name, value):
"""Filter by integer status code."""
@ -860,17 +845,25 @@ class StockFilter(rest_filters.FilterSet):
return queryset.exclude(stale_filter)
class StockList(DataExportViewMixin, ListCreateDestroyAPIView):
"""API endpoint for list view of Stock objects.
- GET: Return a list of all StockItem objects (with optional query filters)
- POST: Create a new StockItem
- DELETE: Delete multiple StockItem objects
"""
class StockApiMixin:
"""Mixin class for StockItem API endpoints."""
serializer_class = StockSerializers.StockItemSerializer
queryset = StockItem.objects.all()
filterset_class = StockFilter
def get_queryset(self, *args, **kwargs):
"""Annotate queryset."""
queryset = super().get_queryset(*args, **kwargs)
queryset = StockSerializers.StockItemSerializer.annotate_queryset(queryset)
return queryset
def get_serializer_context(self):
"""Extend serializer context."""
ctx = super().get_serializer_context()
ctx['user'] = getattr(self.request, 'user', None)
return ctx
def get_serializer(self, *args, **kwargs):
"""Set context before returning serializer.
@ -899,12 +892,16 @@ class StockList(DataExportViewMixin, ListCreateDestroyAPIView):
return self.serializer_class(*args, **kwargs)
def get_serializer_context(self):
"""Extend serializer context."""
ctx = super().get_serializer_context()
ctx['user'] = getattr(self.request, 'user', None)
return ctx
class StockList(DataExportViewMixin, StockApiMixin, ListCreateDestroyAPIView):
"""API endpoint for list view of Stock objects.
- GET: Return a list of all StockItem objects (with optional query filters)
- POST: Create a new StockItem
- DELETE: Delete multiple StockItem objects
"""
filterset_class = StockFilter
def create(self, request, *args, **kwargs):
"""Create a new StockItem object via the API.
@ -1079,14 +1076,6 @@ class StockList(DataExportViewMixin, ListCreateDestroyAPIView):
headers=self.get_success_headers(serializer.data),
)
def get_queryset(self, *args, **kwargs):
"""Annotate queryset before returning."""
queryset = super().get_queryset(*args, **kwargs)
queryset = StockSerializers.StockItemSerializer.annotate_queryset(queryset)
return queryset
def filter_queryset(self, queryset):
"""Custom filtering for the StockItem queryset."""
params = self.request.query_params
@ -1107,46 +1096,6 @@ class StockList(DataExportViewMixin, ListCreateDestroyAPIView):
except (ValueError, StockItem.DoesNotExist): # pragma: no cover
pass
# 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): # pragma: no cover
pass
# Does the client wish to filter by the Part ID?
part_id = params.get('part', None)
if part_id:
try:
part = Part.objects.get(pk=part_id)
# Do we wish to filter *just* for this part, or also for parts *under* this one?
include_variants = str2bool(params.get('include_variants', True))
if include_variants:
# Filter by any parts "under" the given part
parts = part.get_descendants(include_self=True)
queryset = queryset.filter(part__in=parts)
else:
queryset = queryset.filter(part=part)
except (ValueError, Part.DoesNotExist):
raise ValidationError({'part': 'Invalid Part ID specified'})
# Does the client wish to filter by stock location?
loc_id = params.get('location', None)
@ -1212,6 +1161,10 @@ class StockList(DataExportViewMixin, ListCreateDestroyAPIView):
]
class StockDetail(StockApiMixin, RetrieveUpdateDestroyAPI):
"""API detail endpoint for a single StockItem instance."""
class StockItemTestResultMixin:
"""Mixin class for the StockItemTestResult API endpoints."""

View File

@ -11,6 +11,8 @@
constructField,
constructForm,
constructOrderTableButtons,
disableFormInput,
enableFormInput,
endDate,
formatCurrency,
FullCalendar,
@ -1559,17 +1561,35 @@ function showAllocationSubTable(index, row, element, options) {
// Add callbacks for 'edit' buttons
table.find('.button-allocation-edit').click(function() {
var pk = $(this).attr('pk');
let pk = $(this).attr('pk');
let allocation = table.bootstrapTable('getRowByUniqueId', pk);
let disableShipment = allocation && allocation.shipment_detail?.shipment_date;
// Edit the sales order allocation
constructForm(
`/api/order/so-allocation/${pk}/`,
{
fields: {
item: {},
quantity: {},
shipment: {
filters: {
order: allocation.order,
shipped: false,
}
}
},
title: '{% trans "Edit Stock Allocation" %}',
refreshTable: options.table,
afterRender: function(fields, opts) {
disableFormInput('item', opts);
if (disableShipment) {
disableFormInput('shipment', opts);
} else {
enableFormInput('shipment', opts);
}
}
},
);
});
@ -1593,6 +1613,8 @@ function showAllocationSubTable(index, row, element, options) {
table.bootstrapTable({
url: '{% url "api-so-allocation-list" %}',
onPostBody: setupCallbacks,
uniqueId: 'pk',
idField: 'pk',
queryParams: {
...options.queryParams,
part_detail: true,
@ -1614,7 +1636,11 @@ function showAllocationSubTable(index, row, element, options) {
field: 'shipment',
title: '{% trans "Shipment" %}',
formatter: function(value, row) {
return row.shipment_detail.reference;
if (row.shipment_detail) {
return row.shipment_detail.reference;
} else {
return '{% trans "No shipment" %}';
}
}
},
{