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:
@ -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
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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.
|
||||
|
@ -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."""
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
Reference in New Issue
Block a user