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

[PUI] Sales order shipments (#8250)

* Refactor AttachmentPanel into common component

* Remove unused imports

* Add very basic implementation for SalesOrderShipmentDetail page

* Refactor NotesPanel into common component

* Fetch customer data

* Add some placeholder actions

* Updates for shipment detail page

* Adjust SalesOrderShipment API

* Add badges

* Implement API filter for SalesOrderAllocation

* Display allocation table on shipment page

* Add placeholder action to edit allocations

* Improvements for SalesOrderAllocationTable

* Improve API db fetch efficiency

* Edit / delete pending allocations

* Fix for legacy CUI tables

* API tweaks

* Revert custom attachment code for SalesOrderShipment

* Implement "complete shipment" form

* Allocate stock item(s) to sales order

* Fixes for TableField rendering

* Reset sourceLocation when form opens

* Updated playwrigh tests

* Tweak branch (will be reverted)

* Revert github workflow
This commit is contained in:
Oliver
2024-10-10 22:43:22 +11:00
committed by GitHub
parent 35969b11a5
commit 33eba14d3f
44 changed files with 1370 additions and 536 deletions

View File

@ -1,13 +1,17 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 266
INVENTREE_API_VERSION = 267
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
267 - 2024-10-8 : https://github.com/inventree/InvenTree/pull/8250
- Remove "allocations" field from the SalesOrderShipment API endpoint(s)
- Add "allocated_items" field to the SalesOrderShipment API endpoint(s)
266 - 2024-10-07 : https://github.com/inventree/InvenTree/pull/8249
- Tweak SalesOrderShipment API for more efficient data retrieval

View File

@ -749,7 +749,6 @@ class SalesOrderLineItemMixin:
kwargs['part_detail'] = str2bool(params.get('part_detail', False))
kwargs['order_detail'] = str2bool(params.get('order_detail', False))
kwargs['allocations'] = str2bool(params.get('allocations', False))
kwargs['customer_detail'] = str2bool(params.get('customer_detail', False))
except AttributeError:
@ -889,18 +888,83 @@ class SalesOrderAllocate(SalesOrderContextMixin, CreateAPI):
serializer_class = serializers.SalesOrderShipmentAllocationSerializer
class SalesOrderAllocationDetail(RetrieveUpdateDestroyAPI):
"""API endpoint for detali view of a SalesOrderAllocation object."""
class SalesOrderAllocationFilter(rest_filters.FilterSet):
"""Custom filterset for the SalesOrderAllocationList endpoint."""
class Meta:
"""Metaclass options."""
model = models.SalesOrderAllocation
fields = ['shipment', 'item']
order = rest_filters.ModelChoiceFilter(
queryset=models.SalesOrder.objects.all(),
field_name='line__order',
label=_('Order'),
)
part = rest_filters.ModelChoiceFilter(
queryset=Part.objects.all(), field_name='item__part', label=_('Part')
)
outstanding = rest_filters.BooleanFilter(
label=_('Outstanding'), method='filter_outstanding'
)
def filter_outstanding(self, queryset, name, value):
"""Filter by "outstanding" status (boolean)."""
if str2bool(value):
return queryset.filter(
line__order__status__in=SalesOrderStatusGroups.OPEN,
shipment__shipment_date=None,
)
return queryset.exclude(
shipment__shipment_date=None,
line__order__status__in=SalesOrderStatusGroups.OPEN,
)
class SalesOrderAllocationMixin:
"""Mixin class for SalesOrderAllocation endpoints."""
queryset = models.SalesOrderAllocation.objects.all()
serializer_class = serializers.SalesOrderAllocationSerializer
def get_queryset(self, *args, **kwargs):
"""Annotate the queryset for this endpoint."""
queryset = super().get_queryset(*args, **kwargs)
class SalesOrderAllocationList(ListAPI):
queryset = queryset.prefetch_related(
'item',
'item__sales_order',
'item__part',
'item__location',
'line__order',
'line__part',
'shipment',
'shipment__order',
)
return queryset
class SalesOrderAllocationList(SalesOrderAllocationMixin, ListAPI):
"""API endpoint for listing SalesOrderAllocation objects."""
queryset = models.SalesOrderAllocation.objects.all()
serializer_class = serializers.SalesOrderAllocationSerializer
filterset_class = SalesOrderAllocationFilter
filter_backends = SEARCH_ORDER_FILTER_ALIAS
ordering_fields = ['quantity', 'part', 'serial', 'batch', 'location', 'order']
ordering_field_aliases = {
'part': 'item__part__name',
'serial': ['item__serial_int', 'item__serial'],
'batch': 'item__batch',
'location': 'item__location__name',
'order': 'line__order__reference',
}
search_fields = {'item__part__name', 'item__serial', 'item__batch'}
def get_serializer(self, *args, **kwargs):
"""Return the serializer instance for this endpoint.
@ -920,53 +984,9 @@ class SalesOrderAllocationList(ListAPI):
return self.serializer_class(*args, **kwargs)
def filter_queryset(self, queryset):
"""Custom queryset filtering."""
queryset = super().filter_queryset(queryset)
# Filter by order
params = self.request.query_params
# Filter by "part" reference
part = params.get('part', None)
if part is not None:
queryset = queryset.filter(item__part=part)
# Filter by "order" reference
order = params.get('order', None)
if order is not None:
queryset = queryset.filter(line__order=order)
# Filter by "stock item"
item = params.get('item', params.get('stock_item', None))
if item is not None:
queryset = queryset.filter(item=item)
# Filter by "outstanding" order status
outstanding = params.get('outstanding', None)
if outstanding is not None:
outstanding = str2bool(outstanding)
if outstanding:
# Filter only "open" orders
# Filter only allocations which have *not* shipped
queryset = queryset.filter(
line__order__status__in=SalesOrderStatusGroups.OPEN,
shipment__shipment_date=None,
)
else:
queryset = queryset.exclude(
line__order__status__in=SalesOrderStatusGroups.OPEN,
shipment__shipment_date=None,
)
return queryset
filter_backends = [rest_filters.DjangoFilterBackend]
class SalesOrderAllocationDetail(SalesOrderAllocationMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for detali view of a SalesOrderAllocation object."""
class SalesOrderShipmentFilter(rest_filters.FilterSet):
@ -1005,13 +1025,7 @@ class SalesOrderShipmentMixin:
"""Return annotated queryset for this endpoint."""
queryset = super().get_queryset(*args, **kwargs)
queryset = queryset.prefetch_related(
'order',
'order__customer',
'allocations',
'allocations__item',
'allocations__item__part',
)
queryset = serializers.SalesOrderShipmentSerializer.annotate_queryset(queryset)
return queryset
@ -1020,10 +1034,8 @@ class SalesOrderShipmentList(SalesOrderShipmentMixin, ListCreateAPI):
"""API list endpoint for SalesOrderShipment model."""
filterset_class = SalesOrderShipmentFilter
filter_backends = SEARCH_ORDER_FILTER_ALIAS
ordering_fields = ['delivery_date', 'shipment_date']
ordering_fields = ['reference', 'delivery_date', 'shipment_date', 'allocated_items']
class SalesOrderShipmentDetail(SalesOrderShipmentMixin, RetrieveUpdateDestroyAPI):

View File

@ -1923,13 +1923,6 @@ class SalesOrderShipment(
trigger_event('salesordershipment.completed', id=self.pk)
def create_attachment(self, *args, **kwargs):
"""Create an attachment / link on parent order.
This will only be called when a generated report should be attached to this instance.
"""
return self.order.create_attachment(*args, **kwargs)
class SalesOrderExtraLine(OrderExtraLine):
"""Model for a single ExtraLine in a SalesOrder.

View File

@ -1027,88 +1027,6 @@ class SalesOrderIssueSerializer(OrderAdjustSerializer):
self.order.issue_order()
class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
"""Serializer for the SalesOrderAllocation model.
This includes some fields from the related model objects.
"""
class Meta:
"""Metaclass options."""
model = order.models.SalesOrderAllocation
fields = [
'pk',
'line',
'customer_detail',
'serial',
'quantity',
'location',
'location_detail',
'item',
'item_detail',
'order',
'order_detail',
'part',
'part_detail',
'shipment',
'shipment_date',
]
def __init__(self, *args, **kwargs):
"""Initialization routine for the serializer."""
order_detail = kwargs.pop('order_detail', False)
part_detail = kwargs.pop('part_detail', True)
item_detail = kwargs.pop('item_detail', True)
location_detail = kwargs.pop('location_detail', False)
customer_detail = kwargs.pop('customer_detail', False)
super().__init__(*args, **kwargs)
if not order_detail:
self.fields.pop('order_detail', None)
if not part_detail:
self.fields.pop('part_detail', None)
if not item_detail:
self.fields.pop('item_detail', None)
if not location_detail:
self.fields.pop('location_detail', None)
if not customer_detail:
self.fields.pop('customer_detail', None)
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=False)
location = serializers.PrimaryKeyRelatedField(
source='item.location', many=False, read_only=True
)
# Extra detail fields
order_detail = SalesOrderSerializer(source='line.order', many=False, read_only=True)
part_detail = PartBriefSerializer(source='item.part', many=False, read_only=True)
item_detail = stock.serializers.StockItemSerializerBrief(
source='item', many=False, read_only=True
)
location_detail = stock.serializers.LocationBriefSerializer(
source='item.location', many=False, read_only=True
)
customer_detail = CompanyBriefSerializer(
source='line.order.customer', many=False, read_only=True
)
shipment_date = serializers.DateField(
source='shipment.shipment_date', read_only=True
)
@register_importer()
class SalesOrderLineItemSerializer(
DataImportExportSerializerMixin,
@ -1125,7 +1043,6 @@ class SalesOrderLineItemSerializer(
fields = [
'pk',
'allocated',
'allocations',
'customer_detail',
'quantity',
'reference',
@ -1154,7 +1071,6 @@ class SalesOrderLineItemSerializer(
"""
part_detail = kwargs.pop('part_detail', False)
order_detail = kwargs.pop('order_detail', False)
allocations = kwargs.pop('allocations', False)
customer_detail = kwargs.pop('customer_detail', False)
super().__init__(*args, **kwargs)
@ -1165,9 +1081,6 @@ class SalesOrderLineItemSerializer(
if order_detail is not True:
self.fields.pop('order_detail', None)
if allocations is not True:
self.fields.pop('allocations', None)
if customer_detail is not True:
self.fields.pop('customer_detail', None)
@ -1251,13 +1164,10 @@ class SalesOrderLineItemSerializer(
return queryset
customer_detail = CompanyBriefSerializer(
source='order.customer', many=False, read_only=True
)
order_detail = SalesOrderSerializer(source='order', many=False, read_only=True)
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
allocations = SalesOrderAllocationSerializer(
many=True, read_only=True, location_detail=True
customer_detail = CompanyBriefSerializer(
source='order.customer', many=False, read_only=True
)
# Annotated fields
@ -1293,7 +1203,7 @@ class SalesOrderShipmentSerializer(NotesFieldMixin, InvenTreeModelSerializer):
'pk',
'order',
'order_detail',
'allocations',
'allocated_items',
'shipment_date',
'delivery_date',
'checked_by',
@ -1304,13 +1214,105 @@ class SalesOrderShipmentSerializer(NotesFieldMixin, InvenTreeModelSerializer):
'notes',
]
allocations = SalesOrderAllocationSerializer(
many=True, read_only=True, location_detail=True
@staticmethod
def annotate_queryset(queryset):
"""Annotate the queryset with extra information."""
# Prefetch related objects
queryset = queryset.prefetch_related('order', 'order__customer', 'allocations')
queryset = queryset.annotate(allocated_items=SubqueryCount('allocations'))
return queryset
allocated_items = serializers.IntegerField(
read_only=True, label=_('Allocated Items')
)
order_detail = SalesOrderSerializer(source='order', read_only=True, many=False)
class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
"""Serializer for the SalesOrderAllocation model.
This includes some fields from the related model objects.
"""
class Meta:
"""Metaclass options."""
model = order.models.SalesOrderAllocation
fields = [
'pk',
'line',
'customer_detail',
'serial',
'quantity',
'location',
'location_detail',
'item',
'item_detail',
'order',
'order_detail',
'part',
'part_detail',
'shipment',
'shipment_detail',
]
def __init__(self, *args, **kwargs):
"""Initialization routine for the serializer."""
order_detail = kwargs.pop('order_detail', False)
part_detail = kwargs.pop('part_detail', True)
item_detail = kwargs.pop('item_detail', True)
location_detail = kwargs.pop('location_detail', False)
customer_detail = kwargs.pop('customer_detail', False)
super().__init__(*args, **kwargs)
if not order_detail:
self.fields.pop('order_detail', None)
if not part_detail:
self.fields.pop('part_detail', None)
if not item_detail:
self.fields.pop('item_detail', None)
if not location_detail:
self.fields.pop('location_detail', None)
if not customer_detail:
self.fields.pop('customer_detail', None)
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=False)
location = serializers.PrimaryKeyRelatedField(
source='item.location', many=False, read_only=True
)
# Extra detail fields
order_detail = SalesOrderSerializer(source='line.order', many=False, read_only=True)
part_detail = PartBriefSerializer(source='item.part', many=False, read_only=True)
item_detail = stock.serializers.StockItemSerializerBrief(
source='item', many=False, read_only=True
)
location_detail = stock.serializers.LocationBriefSerializer(
source='item.location', many=False, read_only=True
)
customer_detail = CompanyBriefSerializer(
source='line.order.customer', many=False, read_only=True
)
shipment_detail = SalesOrderShipmentSerializer(
source='shipment', many=False, read_only=True
)
class SalesOrderShipmentCompleteSerializer(serializers.ModelSerializer):
"""Serializer for completing (shipping) a SalesOrderShipment."""

View File

@ -951,7 +951,7 @@ function loadSalesOrderShipmentTable(table, options={}) {
html += makeIconButton('fa-truck icon-green', 'button-shipment-ship', pk, '{% trans "Complete shipment" %}');
}
var enable_delete = row.allocations && row.allocations.length == 0;
var enable_delete = row.allocated_items == 0;
html += makeDeleteButton('button-shipment-delete', pk, '{% trans "Delete shipment" %}', {disabled: !enable_delete});
@ -1004,10 +1004,19 @@ function loadSalesOrderShipmentTable(table, options={}) {
detailViewByClick: false,
buttons: constructExpandCollapseButtons(table),
detailFilter: function(index, row) {
return row.allocations.length > 0;
return row.allocated_items > 0;
},
detailFormatter: function(index, row, element) {
return showAllocationSubTable(index, row, element, options);
return showAllocationSubTable(
index, row, element,
{
...options,
queryParams: {
shipment: row.pk,
order: row.order,
}
}
);
},
onPostBody: function() {
setupShipmentCallbacks();
@ -1048,17 +1057,10 @@ function loadSalesOrderShipmentTable(table, options={}) {
switchable: false,
},
{
field: 'allocations',
field: 'allocated_items',
title: '{% trans "Items" %}',
switchable: false,
sortable: true,
formatter: function(value, row) {
if (row && row.allocations) {
return row.allocations.length;
} else {
return '-';
}
}
},
{
field: 'shipment_date',
@ -1630,7 +1632,14 @@ function showAllocationSubTable(index, row, element, options) {
}
table.bootstrapTable({
url: '{% url "api-so-allocation-list" %}',
onPostBody: setupCallbacks,
queryParams: {
...options.queryParams,
part_detail: true,
location_detail: true,
order_detail: true,
},
data: row.allocations,
showHeader: true,
columns: [
@ -1641,6 +1650,13 @@ function showAllocationSubTable(index, row, element, options) {
return imageHoverIcon(part.thumbnail) + renderLink(part.full_name, `/part/${part.pk}/`);
}
},
{
field: 'shipment',
title: '{% trans "Shipment" %}',
formatter: function(value, row) {
return row.shipment_detail.reference;
}
},
{
field: 'allocated',
title: '{% trans "Stock Item" %}',
@ -2289,7 +2305,16 @@ function loadSalesOrderLineItemTable(table, options={}) {
},
detailFormatter: function(index, row, element) {
if (options.open) {
return showAllocationSubTable(index, row, element, options);
return showAllocationSubTable(
index, row, element,
{
...options,
queryParams: {
part: row.part,
order: row.order,
}
}
);
} else {
return showFulfilledSubTable(index, row, element, options);
}