diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index 2d6f93c932..e32caf693f 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -1038,6 +1038,29 @@ class PurchaseOrder(TotalPriceMixin, Order): base_part = supplier_part.part + # Update the line item quantity + line.received += quantity + line_items_to_update.append(line) + + # Extract optional serial numbers + serials = item.get('serials', None) + + if serials and type(serials) is list and len(serials) > 0: + serialize = True + else: + serialize = False + serials = [None] + + if base_part.virtual: + # Virtual parts are not received into stock, so skip the rest of the loop + + if serialize: + raise ValidationError( + _('Serial numbers cannot be assigned to virtual parts') + ) + + continue + stock_location = item.get('location', location) or line.get_destination() # Calculate the received quantity in base part units @@ -1052,15 +1075,6 @@ class PurchaseOrder(TotalPriceMixin, Order): else: purchase_price = None - # Extract optional serial numbers - serials = item.get('serials', None) - - if serials and type(serials) is list and len(serials) > 0: - serialize = True - else: - serialize = False - serials = [None] - # Construct dataset for creating a new StockItem instances stock_data = { 'part': supplier_part.part, @@ -1144,10 +1158,6 @@ class PurchaseOrder(TotalPriceMixin, Order): bulk_create_items.append(new_item) - # Update the line item quantity - line.received += quantity - line_items_to_update.append(line) - # Bulk create new stock items if len(bulk_create_items) > 0: stock.models.StockItem.objects.bulk_create(bulk_create_items) diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index 3151570a29..7996301b1d 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -1336,6 +1336,47 @@ class PurchaseOrderReceiveTest(OrderTest): # Check that the expected number of stock items has been created self.assertEqual(n + 4, StockItem.objects.count()) + def test_virtual(self): + """Test receipt of "virtual" items (i.e. items which do not create a StockItem).""" + line = models.PurchaseOrderLineItem.objects.get(pk=1) + base_part = line.part.part + base_part.virtual = True + base_part.save() + + self.assertEqual(line.received, 0) + + N_ITEMS = base_part.stock_entries().count() + N_STOCK = base_part.get_stock_count() + + # Try with serial numbers (expect to fail) + data = { + 'items': [{'line_item': line.pk, 'quantity': 1, 'serial_numbers': '999'}], + 'location': 1, + } + + response = self.post(self.url, data, expected_code=400) + + self.assertIn( + 'Serial numbers cannot be assigned to virtual parts', + str(response.data['non_field_errors']), + ) + + # Try without serial numbers (expect to succeed) + data = { + 'items': [{'line_item': line.pk, 'quantity': line.quantity}], + 'location': 1, + } + + self.post(self.url, data, expected_code=201) + + # No new stock items should have been created + self.assertEqual(base_part.stock_entries().count(), N_ITEMS) + self.assertEqual(base_part.get_stock_count(), N_STOCK) + + # Check that the line item has been fully received + line.refresh_from_db() + self.assertEqual(line.received, line.quantity) + class SalesOrderTest(OrderTest): """Tests for the SalesOrder API.""" diff --git a/src/frontend/src/forms/PartForms.tsx b/src/frontend/src/forms/PartForms.tsx index 2a54f9a10d..705ed9da43 100644 --- a/src/frontend/src/forms/PartForms.tsx +++ b/src/frontend/src/forms/PartForms.tsx @@ -106,7 +106,7 @@ export function usePartFields({ }; // Additional fields for creation - if (create) { + if (create && !virtual) { fields.copy_category_parameters = {}; if (virtual != false) { diff --git a/src/frontend/src/forms/PurchaseOrderForms.tsx b/src/frontend/src/forms/PurchaseOrderForms.tsx index 27dc17d086..65e2ebc34e 100644 --- a/src/frontend/src/forms/PurchaseOrderForms.tsx +++ b/src/frontend/src/forms/PurchaseOrderForms.tsx @@ -1,9 +1,12 @@ import { t } from '@lingui/core/macro'; import { + ActionIcon, + Alert, Container, Flex, FocusTrap, Group, + HoverCard, Modal, Table, TextInput @@ -15,6 +18,7 @@ import { IconCoins, IconCurrencyDollar, IconHash, + IconInfoCircle, IconLink, IconList, IconNotes, @@ -488,6 +492,12 @@ function LineItemFormRow({ return text; }, [location]); + // Handle virtual parts + const virtual = useMemo( + () => record.part_detail?.virtual ?? false, + [record.part_detail] + ); + return ( <> - - -
{record.part_detail.name}
-
+ + + +
{record.part_detail.name}
+
+ {virtual && ( + + + + + + + + + {t`This part is virtual, no physical stock will be received.`} + + + + )} +
{record.supplier_part_detail.SKU} @@ -546,6 +572,7 @@ function LineItemFormRow({ tooltip={t`Set Location`} tooltipAlignment='top' variant={locationOpen ? 'outline' : 'transparent'} + disabled={virtual} /> {trackable && ( )} @@ -574,6 +603,7 @@ function LineItemFormRow({ tooltip={t`Set Expiry Date`} tooltipAlignment='top' variant={expiryDateOpen ? 'outline' : 'transparent'} + disabled={virtual} /> )} packagingHandlers.toggle()} variant={packagingOpen ? 'outline' : 'transparent'} + disabled={virtual} /> statusHandlers.toggle()} @@ -590,6 +621,7 @@ function LineItemFormRow({ tooltip={t`Change Status`} tooltipAlignment='top' variant={statusOpen ? 'outline' : 'transparent'} + disabled={virtual} /> } @@ -597,6 +629,7 @@ function LineItemFormRow({ tooltipAlignment='top' variant={noteOpen ? 'outline' : 'transparent'} onClick={() => noteHandlers.toggle()} + disabled={virtual} /> {barcode ? ( setBarcode(undefined)} + disabled={virtual} /> ) : ( open()} + disabled={virtual} /> )} diff --git a/src/frontend/src/tables/part/PartTable.tsx b/src/frontend/src/tables/part/PartTable.tsx index ab4ab27fe7..2c7defb844 100644 --- a/src/frontend/src/tables/part/PartTable.tsx +++ b/src/frontend/src/tables/part/PartTable.tsx @@ -281,11 +281,7 @@ function partTableFilters(): TableFilter[] { name: 'virtual', label: t`Virtual`, description: t`Filter by parts which are virtual`, - type: 'choice', - choices: [ - { value: 'true', label: t`Virtual` }, - { value: 'false', label: t`Not Virtual` } - ] + type: 'boolean' }, { name: 'is_template', diff --git a/src/frontend/tests/pages/pui_company.spec.ts b/src/frontend/tests/pages/pui_company.spec.ts index d4e80c169e..cd86544b22 100644 --- a/src/frontend/tests/pages/pui_company.spec.ts +++ b/src/frontend/tests/pages/pui_company.spec.ts @@ -82,13 +82,13 @@ test('Company - Supplier Parts', async ({ browser }) => { await loadTab(page, 'Supplier Parts'); await clearTableFilters(page); - await page.getByText('- 25 / 777').waitFor(); + await page.getByText(/1 \- 25 \/ 77\d/).waitFor(); await setTableChoiceFilter(page, 'Primary', 'Yes'); - await page.getByText('- 25 / 318').waitFor(); + await page.getByText(/1 \- 25 \/ 31\d/).waitFor(); await clearTableFilters(page); await setTableChoiceFilter(page, 'Primary', 'No'); - await page.getByText('- 25 / 459').waitFor(); + await page.getByText(/1 \- 25 \/ 45\d/).waitFor(); }); diff --git a/src/frontend/tests/pages/pui_purchase_order.spec.ts b/src/frontend/tests/pages/pui_purchase_order.spec.ts index 0823be6689..81c582ed59 100644 --- a/src/frontend/tests/pages/pui_purchase_order.spec.ts +++ b/src/frontend/tests/pages/pui_purchase_order.spec.ts @@ -500,6 +500,39 @@ test('Purchase Orders - Receive Items', async ({ browser }) => { await page.getByRole('cell', { name: 'my-batch-code' }).first().waitFor(); }); +test('Purchase Orders - Receive Virtual Items', async ({ browser }) => { + const page = await doCachedLogin(browser, { + url: 'purchasing/purchase-order/19' + }); + + // Duplicate this order + await page.getByRole('button', { name: 'action-menu-order-actions' }).click(); + await page.getByRole('menuitem', { name: 'Duplicate' }).click(); + await page.getByRole('button', { name: 'Submit' }).click(); + await page.getByRole('tab', { name: 'Order Details' }).waitFor(); + + // Issue the new order + await page.getByRole('button', { name: 'Issue Order' }).click(); + await page.getByRole('button', { name: 'Submit' }).click(); + + // Receive the line item + await loadTab(page, 'Line Items'); + await page.getByRole('checkbox', { name: 'Select all records' }).click(); + await page + .getByRole('button', { name: 'action-button-receive-items' }) + .click(); + + await page + .getByRole('combobox', { name: 'related-field-location' }) + .fill('factory'); + await page.getByText('Factory/Storage Room A').click(); + + await page.getByRole('button', { name: 'Submit' }).click(); + + // 1/1 items received + await page.getByText('1 / 1', { exact: true }).waitFor(); +}); + test('Purchase Orders - Duplicate', async ({ browser }) => { const page = await doCachedLogin(browser, { url: 'purchasing/purchase-order/13/detail'