2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-04-02 01:21:17 +00:00

Receive virtual parts (#11627)

* Handle receive of virtual parts

- Update line item quantity
- Do not add any stock

* Add unit test

* Additional unit test

* UI form improvements

* Add playwright test

* Updated playwright tests
This commit is contained in:
Oliver
2026-03-30 19:10:56 +11:00
committed by GitHub
parent b4f230753f
commit dab4319033
7 changed files with 145 additions and 30 deletions

View File

@@ -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)

View File

@@ -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."""

View File

@@ -106,7 +106,7 @@ export function usePartFields({
};
// Additional fields for creation
if (create) {
if (create && !virtual) {
fields.copy_category_parameters = {};
if (virtual != false) {

View File

@@ -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 (
<>
<Modal
@@ -506,14 +516,30 @@ function LineItemFormRow({
</Modal>
<Table.Tr>
<Table.Td>
<Flex gap='sm' align='center'>
<Thumbnail
size={40}
src={record.part_detail.thumbnail}
align='center'
/>
<div>{record.part_detail.name}</div>
</Flex>
<Group gap='xs' justify='space-between'>
<Group gap='xs' justify='left'>
<Thumbnail
size={40}
src={record.part_detail.thumbnail}
align='center'
/>
<div>{record.part_detail.name}</div>
</Group>
{virtual && (
<HoverCard>
<HoverCard.Target>
<ActionIcon color='blue' variant='transparent'>
<IconInfoCircle />
</ActionIcon>
</HoverCard.Target>
<HoverCard.Dropdown>
<Alert color='blue' title={t`Virtual Part`}>
{t`This part is virtual, no physical stock will be received.`}
</Alert>
</HoverCard.Dropdown>
</HoverCard>
)}
</Group>
</Table.Td>
<Table.Td>{record.supplier_part_detail.SKU}</Table.Td>
<Table.Td>
@@ -546,6 +572,7 @@ function LineItemFormRow({
tooltip={t`Set Location`}
tooltipAlignment='top'
variant={locationOpen ? 'outline' : 'transparent'}
disabled={virtual}
/>
<ActionButton
size='sm'
@@ -554,6 +581,7 @@ function LineItemFormRow({
tooltip={t`Assign Batch Code`}
tooltipAlignment='top'
variant={batchOpen ? 'outline' : 'transparent'}
disabled={virtual}
/>
{trackable && (
<ActionButton
@@ -563,6 +591,7 @@ function LineItemFormRow({
tooltip={t`Assign Serial Numbers`}
tooltipAlignment='top'
variant={serialOpen ? 'outline' : 'transparent'}
disabled={virtual}
/>
)}
@@ -574,6 +603,7 @@ function LineItemFormRow({
tooltip={t`Set Expiry Date`}
tooltipAlignment='top'
variant={expiryDateOpen ? 'outline' : 'transparent'}
disabled={virtual}
/>
)}
<ActionButton
@@ -583,6 +613,7 @@ function LineItemFormRow({
tooltipAlignment='top'
onClick={() => packagingHandlers.toggle()}
variant={packagingOpen ? 'outline' : 'transparent'}
disabled={virtual}
/>
<ActionButton
onClick={() => statusHandlers.toggle()}
@@ -590,6 +621,7 @@ function LineItemFormRow({
tooltip={t`Change Status`}
tooltipAlignment='top'
variant={statusOpen ? 'outline' : 'transparent'}
disabled={virtual}
/>
<ActionButton
icon={<InvenTreeIcon icon='note' />}
@@ -597,6 +629,7 @@ function LineItemFormRow({
tooltipAlignment='top'
variant={noteOpen ? 'outline' : 'transparent'}
onClick={() => noteHandlers.toggle()}
disabled={virtual}
/>
{barcode ? (
<ActionButton
@@ -606,6 +639,7 @@ function LineItemFormRow({
variant='filled'
color='red'
onClick={() => setBarcode(undefined)}
disabled={virtual}
/>
) : (
<ActionButton
@@ -614,6 +648,7 @@ function LineItemFormRow({
tooltipAlignment='top'
variant='transparent'
onClick={() => open()}
disabled={virtual}
/>
)}
</Flex>

View File

@@ -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',

View File

@@ -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();
});

View File

@@ -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'