From 7b38fa30bbed0f070b9695eeb7ce58f65e725032 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 19 Nov 2025 15:40:41 +1100 Subject: [PATCH] Fix for shipping virtual parts (#10853) * Additional checks for virtual parts in sales order process * Prevent allocation against virtual parts * Fix order of operations * Adjust part form fields based on selections * Prevent order locking * Updated playwright tests * Add unit test --- src/backend/InvenTree/order/models.py | 40 +++++++--- .../InvenTree/order/test_sales_order.py | 42 +++++++++++ src/frontend/src/forms/PartForms.tsx | 75 ++++++++++++------- .../tables/sales/SalesOrderLineItemTable.tsx | 4 +- .../tests/pages/pui_sales_order.spec.ts | 37 ++++++++- 5 files changed, 155 insertions(+), 43 deletions(-) diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index 81f5878f8d..4efa189e71 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -1388,8 +1388,13 @@ class SalesOrder(TotalPriceMixin, Order): return any(line.is_overallocated() for line in self.lines.all()) def is_completed(self) -> bool: - """Check if this order is "shipped" (all line items delivered).""" - return all(line.is_completed() for line in self.lines.all()) + """Check if this order is "shipped" (all line items delivered). + + Note: Any "virtual" parts are ignored in this calculation. + """ + lines = self.lines.all().filter(part__virtual=False) + + return all(line.is_completed() for line in lines) def can_complete( self, raise_error: bool = False, allow_incomplete_lines: bool = False @@ -1424,10 +1429,15 @@ class SalesOrder(TotalPriceMixin, Order): _('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') - ) + if not allow_incomplete_lines: + pending_lines = self.pending_line_items().exclude(part__virtual=True) + + if pending_lines.count() > 0: + raise ValidationError( + _( + 'Order cannot be completed as there are incomplete line items' + ) + ) except ValidationError as e: if raise_error: @@ -1484,6 +1494,7 @@ class SalesOrder(TotalPriceMixin, Order): trigger_event(SalesOrderEvents.HOLD, id=self.pk) + @transaction.atomic def _action_complete(self, *args, **kwargs): """Mark this order as "complete.""" user = kwargs.pop('user', None) @@ -1495,6 +1506,16 @@ class SalesOrder(TotalPriceMixin, Order): get_global_setting('SALESORDER_SHIP_COMPLETE') ) + # Update line items + for line in self.lines.all(): + # Mark any "virtual" parts as shipped at this point + if line.part and line.part.virtual and line.shipped != line.quantity: + line.shipped = line.quantity + line.save() + + if line.part: + line.part.schedule_pricing_update(create=True) + if bypass_shipped or self.status == SalesOrderStatus.SHIPPED: self.status = SalesOrderStatus.COMPLETE.value else: @@ -1506,11 +1527,6 @@ class SalesOrder(TotalPriceMixin, Order): self.save() - # Schedule pricing update for any referenced parts - for line in self.lines.all(): - if line.part: - line.part.schedule_pricing_update(create=True) - trigger_event(SalesOrderEvents.COMPLETED, id=self.pk) return True @@ -1574,7 +1590,7 @@ class SalesOrder(TotalPriceMixin, Order): """Attempt to transition to COMPLETED status.""" return self.handle_transition( self.status, - SalesOrderStatus.COMPLETED.value, + SalesOrderStatus.COMPLETE.value, self, self._action_complete, user=user, diff --git a/src/backend/InvenTree/order/test_sales_order.py b/src/backend/InvenTree/order/test_sales_order.py index c4cc9d4dc9..1a42a7549a 100644 --- a/src/backend/InvenTree/order/test_sales_order.py +++ b/src/backend/InvenTree/order/test_sales_order.py @@ -488,3 +488,45 @@ class SalesOrderTest(InvenTreeTestCase): p.set_metadata(k, k) self.assertEqual(len(p.metadata.keys()), 4) + + def test_virtual_parts(self): + """Test shipment of virtual parts against an order.""" + vp = Part.objects.create( + name='Virtual Part', + salable=True, + virtual=True, + description='A virtual part that I sell', + ) + + so = SalesOrder.objects.create( + customer=self.customer, + reference='SO-VIRTUAL-1', + customer_reference='VIRT-001', + ) + + for qty in [5, 10, 15]: + SalesOrderLineItem.objects.create(order=so, part=vp, quantity=qty) + + # Delete pending shipments (if any) + so.shipments.all().delete() + + for line in so.lines.all(): + self.assertEqual(line.part.virtual, True) + self.assertEqual(line.shipped, 0) + self.assertGreater(line.quantity, 0) + self.assertTrue(line.is_fully_allocated()) + self.assertTrue(line.is_completed()) + + # Complete the order + so.ship_order(None) + + so.refresh_from_db() + self.assertEqual(so.status, status.SalesOrderStatus.SHIPPED) + + so.complete_order(None) + so.refresh_from_db() + self.assertEqual(so.status, status.SalesOrderStatus.COMPLETE) + + # Ensure that virtual line item quantity values have been updated + for line in so.lines.all(): + self.assertEqual(line.shipped, line.quantity) diff --git a/src/frontend/src/forms/PartForms.tsx b/src/frontend/src/forms/PartForms.tsx index d5059f9826..4125d00788 100644 --- a/src/frontend/src/forms/PartForms.tsx +++ b/src/frontend/src/forms/PartForms.tsx @@ -20,6 +20,9 @@ export function usePartFields({ }): ApiFormFieldSet { const settings = useGlobalSettingsState(); + const [virtual, setVirtual] = useState(false); + const [purchaseable, setPurchaseable] = useState(false); + return useMemo(() => { const fields: ApiFormFieldSet = { category: { @@ -62,9 +65,19 @@ export function usePartFields({ is_template: {}, testable: {}, trackable: {}, - purchaseable: {}, + purchaseable: { + value: purchaseable, + onValueChange: (value: boolean) => { + setPurchaseable(value); + } + }, salable: {}, - virtual: {}, + virtual: { + value: virtual, + onValueChange: (value: boolean) => { + setVirtual(value); + } + }, locked: {}, active: {}, starred: { @@ -80,33 +93,37 @@ export function usePartFields({ if (create) { fields.copy_category_parameters = {}; - fields.initial_stock = { - icon: , - children: { - quantity: { - value: 0 - }, - location: {} - } - }; + if (!virtual) { + fields.initial_stock = { + icon: , + children: { + quantity: { + value: 0 + }, + location: {} + } + }; + } - fields.initial_supplier = { - icon: , - children: { - supplier: { - filters: { - is_supplier: true - } - }, - sku: {}, - manufacturer: { - filters: { - is_manufacturer: true - } - }, - mpn: {} - } - }; + if (purchaseable) { + fields.initial_supplier = { + icon: , + children: { + supplier: { + filters: { + is_supplier: true + } + }, + sku: {}, + manufacturer: { + filters: { + is_manufacturer: true + } + }, + mpn: {} + } + }; + } } // Additional fields for part duplication @@ -159,7 +176,7 @@ export function usePartFields({ } return fields; - }, [create, duplicatePartInstance, settings]); + }, [virtual, purchaseable, create, duplicatePartInstance, settings]); } /** diff --git a/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx b/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx index 87876ae635..6826ac2a7f 100644 --- a/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx +++ b/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx @@ -320,7 +320,9 @@ export default function SalesOrderLineItemTable({ const allocateStock = useAllocateToSalesOrderForm({ orderId: orderId, - lineItems: selectedItems, + lineItems: selectedItems.filter( + (item) => item.part_detail?.virtual !== true + ), onFormSuccess: () => { table.refreshTable(); table.clearSelectedRecords(); diff --git a/src/frontend/tests/pages/pui_sales_order.spec.ts b/src/frontend/tests/pages/pui_sales_order.spec.ts index 5efea50a48..6370b972af 100644 --- a/src/frontend/tests/pages/pui_sales_order.spec.ts +++ b/src/frontend/tests/pages/pui_sales_order.spec.ts @@ -225,7 +225,7 @@ test('Sales Orders - Shipments', async ({ browser }) => { test('Sales Orders - Duplicate', async ({ browser }) => { const page = await doCachedLogin(browser, { - url: 'sales/sales-order/11/detail' + url: 'sales/sales-order/14/detail' }); await page.getByLabel('action-menu-order-actions').click(); @@ -243,4 +243,39 @@ test('Sales Orders - Duplicate', async ({ browser }) => { await page.getByRole('tab', { name: 'Order Details' }).click(); await page.getByText('Pending').first().waitFor(); + + // Issue the order + await page.getByRole('button', { name: 'Issue Order' }).click(); + await page.getByRole('button', { name: 'Submit' }).click(); + await page.getByText('In Progress').first().waitFor(); + + // Cancel the outstanding shipment + await loadTab(page, 'Shipments'); + await clearTableFilters(page); + const cell = await page.getByRole('cell', { name: '1', exact: true }); + await clickOnRowMenu(cell); + await page.getByRole('menuitem', { name: 'Cancel' }).click(); + await page.getByRole('button', { name: 'Delete' }).click(); + + // Check for expected line items + await loadTab(page, 'Line Items'); + await page.getByRole('cell', { name: 'SW-001' }).waitFor(); + await page.getByRole('cell', { name: 'SW-002' }).waitFor(); + await page.getByText('1 - 2 / 2').waitFor(); + + // Ship the order + await page.getByRole('button', { name: 'Ship Order' }).click(); + await page.getByRole('button', { name: 'Submit' }).click(); + + // Complete the order + await page.getByRole('button', { name: 'Complete Order' }).click(); + await page.getByRole('button', { name: 'Submit' }).click(); + + // Go to the "details" tab + await loadTab(page, 'Order Details'); + + // Check for expected results + // 2 line items completed, as they are both virtual (no stock) + await page.getByText('Complete').first().waitFor(); + await page.getByText('2 / 2').waitFor(); });