From 20d862e350fb191a6f7624ca3dd711de7af67001 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 29 Nov 2024 17:06:35 +1100 Subject: [PATCH] Return Order - Improvements (#8590) * Increase query limit * Add "quantity" field to ReturnOrderLineItem model * Add 'quantity' to serializer * Optionally split stock when returning from customer * Update the line item when splitting * PUI updates * Bump API version * Add unit test --- .../InvenTree/InvenTree/api_version.py | 5 +- ...0104_alter_returnorderlineitem_quantity.py | 19 ++++++ src/backend/InvenTree/order/models.py | 36 +++++++++- src/backend/InvenTree/order/serializers.py | 7 ++ src/backend/InvenTree/order/test_api.py | 65 +++++++++++++++++++ src/frontend/src/forms/ReturnOrderForms.tsx | 16 +++-- .../tables/sales/ReturnOrderLineItemTable.tsx | 21 ++++-- 7 files changed, 156 insertions(+), 13 deletions(-) create mode 100644 src/backend/InvenTree/order/migrations/0104_alter_returnorderlineitem_quantity.py diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index c84d2cdb6c1c..55a4f0414bd6 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,13 +1,16 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 289 +INVENTREE_API_VERSION = 290 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v290 - 2024-11-29 : https://github.com/inventree/InvenTree/pull/8590 + - Adds "quantity" field to ReturnOrderLineItem model and API + v289 - 2024-11-27 : https://github.com/inventree/InvenTree/pull/8570 - Enable status change when transferring stock items diff --git a/src/backend/InvenTree/order/migrations/0104_alter_returnorderlineitem_quantity.py b/src/backend/InvenTree/order/migrations/0104_alter_returnorderlineitem_quantity.py new file mode 100644 index 000000000000..b77796bf715b --- /dev/null +++ b/src/backend/InvenTree/order/migrations/0104_alter_returnorderlineitem_quantity.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.16 on 2024-11-29 00:37 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0103_alter_salesorderallocation_shipment'), + ] + + operations = [ + migrations.AlterField( + model_name='returnorderlineitem', + name='quantity', + field=models.DecimalField(decimal_places=5, default=1, help_text='Quantity to return', max_digits=15, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Quantity'), + ), + ] diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index 654bac495474..82c551eeeabe 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -2391,6 +2391,14 @@ def receive_line_item(self, line, location, user, **kwargs): stock_item = line.item + if not stock_item.serialized and line.quantity < stock_item.quantity: + # Split the stock item if we are returning less than the full quantity + stock_item = stock_item.splitStock(line.quantity, user=user) + + # Update the line item to point to the *new* stock item + line.item = stock_item + line.save() + status = kwargs.get('status') if status is None: @@ -2423,7 +2431,7 @@ def receive_line_item(self, line, location, user, **kwargs): line.received_date = InvenTree.helpers.current_date() line.save() - trigger_event(ReturnOrderEvents.RECEIVED, id=self.pk) + trigger_event(ReturnOrderEvents.RECEIVED, id=self.pk, line_item_id=line.pk) # Notify responsible users notify_responsible( @@ -2452,9 +2460,22 @@ def clean(self): """Perform extra validation steps for the ReturnOrderLineItem model.""" super().clean() - if self.item and not self.item.serialized: + if not self.item: + raise ValidationError({'item': _('Stock item must be specified')}) + + if self.quantity > self.item.quantity: raise ValidationError({ - 'item': _('Only serialized items can be assigned to a Return Order') + 'quantity': _('Return quantity exceeds stock quantity') + }) + + if self.quantity <= 0: + raise ValidationError({ + 'quantity': _('Return quantity must be greater than zero') + }) + + if self.item.serialized and self.quantity != 1: + raise ValidationError({ + 'quantity': _('Invalid quantity for serialized stock item') }) order = models.ForeignKey( @@ -2473,6 +2494,15 @@ def clean(self): help_text=_('Select item to return from customer'), ) + quantity = models.DecimalField( + verbose_name=('Quantity'), + help_text=('Quantity to return'), + max_digits=15, + decimal_places=5, + validators=[MinValueValidator(0)], + default=1, + ) + received_date = models.DateField( null=True, blank=True, diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index 02955e957beb..bf6aa32fcfd9 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -2040,6 +2040,7 @@ class Meta: 'order_detail', 'item', 'item_detail', + 'quantity', 'received_date', 'outcome', 'part_detail', @@ -2070,9 +2071,15 @@ def __init__(self, *args, **kwargs): self.fields.pop('part_detail', None) order_detail = ReturnOrderSerializer(source='order', many=False, read_only=True) + + quantity = serializers.FloatField( + label=_('Quantity'), help_text=_('Quantity to return') + ) + item_detail = stock.serializers.StockItemSerializer( source='item', many=False, read_only=True ) + part_detail = PartBriefSerializer(source='item.part', many=False, read_only=True) price = InvenTreeMoneySerializer(allow_null=True) diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index 37dd173c838e..722c582965b6 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -2395,6 +2395,71 @@ def receive(items, location=None, expected_code=400): self.assertEqual(deltas['location'], 1) self.assertEqual(deltas['returnorder'], rma.pk) + def test_receive_untracked(self): + """Test that we can receive untracked items against a ReturnOrder. + + Ref: https://github.com/inventree/InvenTree/pull/8590 + """ + self.assignRole('return_order.add') + company = Company.objects.get(pk=4) + + # Create a new ReturnOrder + rma = models.ReturnOrder.objects.create( + customer=company, description='A return order' + ) + + rma.issue_order() + + # Create some new line items + part = Part.objects.get(pk=25) + + n_items = part.stock_entries().count() + + for idx in range(2): + stock_item = StockItem.objects.create( + part=part, customer=company, quantity=10 + ) + + models.ReturnOrderLineItem.objects.create( + order=rma, item=stock_item, quantity=(idx + 1) * 5 + ) + + self.assertEqual(part.stock_entries().count(), n_items + 2) + + line_items = rma.lines.all() + + # Receive items against the order + url = reverse('api-return-order-receive', kwargs={'pk': rma.pk}) + + LOCATION_ID = 1 + + self.post( + url, + { + 'items': [ + {'item': line.pk, 'status': StockStatus.DAMAGED.value} + for line in line_items + ], + 'location': LOCATION_ID, + }, + expected_code=201, + ) + + # Due to the quantities received, we should have created 1 new stock item + self.assertEqual(part.stock_entries().count(), n_items + 3) + + rma.refresh_from_db() + + for line in rma.lines.all(): + self.assertTrue(line.received) + self.assertIsNotNone(line.received_date) + + # Check that the associated StockItem has been updated correctly + self.assertEqual(line.item.status, StockStatus.DAMAGED) + self.assertIsNone(line.item.customer) + self.assertIsNone(line.item.sales_order) + self.assertEqual(line.item.location.pk, LOCATION_ID) + def test_ro_calendar(self): """Test the calendar export endpoint.""" # Full test is in test_po_calendar. Since these use the same backend, test only diff --git a/src/frontend/src/forms/ReturnOrderForms.tsx b/src/frontend/src/forms/ReturnOrderForms.tsx index 4fe37b66fdb2..80d0d1188c59 100644 --- a/src/frontend/src/forms/ReturnOrderForms.tsx +++ b/src/frontend/src/forms/ReturnOrderForms.tsx @@ -106,10 +106,10 @@ export function useReturnOrderLineItemFields({ item: { filters: { customer: customerId, - part_detail: true, - serialized: true + part_detail: true } }, + quantity: {}, reference: {}, outcome: { hidden: create == true @@ -147,6 +147,14 @@ function ReturnOrderLineItemFormRow({ ); }, []); + const quantityDisplay = useMemo(() => { + if (record.item_detail?.serial && record.quantity == 1) { + return `# ${record.item_detail.serial}`; + } else { + return record.quantity; + } + }, [record.quantity, record.item_detail]); + return ( <> @@ -160,7 +168,7 @@ function ReturnOrderLineItemFormRow({
{record.part_detail.name}
- # {record.item_detail.serial} + {quantityDisplay} ); }, - headers: [t`Part`, t`Stock Item`, t`Status`] + headers: [t`Part`, t`Quantity`, t`Status`] }, location: { filters: { diff --git a/src/frontend/src/tables/sales/ReturnOrderLineItemTable.tsx b/src/frontend/src/tables/sales/ReturnOrderLineItemTable.tsx index d770f7d10243..890967878d9c 100644 --- a/src/frontend/src/tables/sales/ReturnOrderLineItemTable.tsx +++ b/src/frontend/src/tables/sales/ReturnOrderLineItemTable.tsx @@ -111,13 +111,21 @@ export default function ReturnOrderLineItemTable({ }, { accessor: 'item_detail.serial', - title: t`Serial Number`, - switchable: false + title: t`Quantity`, + switchable: false, + render: (record: any) => { + if (record.item_detail.serial && record.quantity == 1) { + return `# ${record.item_detail.serial}`; + } else { + return record.quantity; + } + } }, StatusColumn({ model: ModelType.stockitem, sortable: false, - accessor: 'item_detail.status' + accessor: 'item_detail.status', + title: t`Status` }), ReferenceColumn({}), StatusColumn({ @@ -201,7 +209,10 @@ export default function ReturnOrderLineItemTable({ return [ { - hidden: received || !user.hasChangeRole(UserRoles.return_order), + hidden: + received || + !inProgress || + !user.hasChangeRole(UserRoles.return_order), title: t`Receive Item`, icon: , onClick: () => { @@ -225,7 +236,7 @@ export default function ReturnOrderLineItemTable({ }) ]; }, - [user] + [user, inProgress] ); return (