mirror of
https://github.com/inventree/InvenTree.git
synced 2026-04-08 20:41:11 +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:
@@ -1038,6 +1038,29 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
|||||||
|
|
||||||
base_part = supplier_part.part
|
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()
|
stock_location = item.get('location', location) or line.get_destination()
|
||||||
|
|
||||||
# Calculate the received quantity in base part units
|
# Calculate the received quantity in base part units
|
||||||
@@ -1052,15 +1075,6 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
|||||||
else:
|
else:
|
||||||
purchase_price = None
|
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
|
# Construct dataset for creating a new StockItem instances
|
||||||
stock_data = {
|
stock_data = {
|
||||||
'part': supplier_part.part,
|
'part': supplier_part.part,
|
||||||
@@ -1144,10 +1158,6 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
|||||||
|
|
||||||
bulk_create_items.append(new_item)
|
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
|
# Bulk create new stock items
|
||||||
if len(bulk_create_items) > 0:
|
if len(bulk_create_items) > 0:
|
||||||
stock.models.StockItem.objects.bulk_create(bulk_create_items)
|
stock.models.StockItem.objects.bulk_create(bulk_create_items)
|
||||||
|
|||||||
@@ -1336,6 +1336,47 @@ class PurchaseOrderReceiveTest(OrderTest):
|
|||||||
# Check that the expected number of stock items has been created
|
# Check that the expected number of stock items has been created
|
||||||
self.assertEqual(n + 4, StockItem.objects.count())
|
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):
|
class SalesOrderTest(OrderTest):
|
||||||
"""Tests for the SalesOrder API."""
|
"""Tests for the SalesOrder API."""
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export function usePartFields({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Additional fields for creation
|
// Additional fields for creation
|
||||||
if (create) {
|
if (create && !virtual) {
|
||||||
fields.copy_category_parameters = {};
|
fields.copy_category_parameters = {};
|
||||||
|
|
||||||
if (virtual != false) {
|
if (virtual != false) {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { t } from '@lingui/core/macro';
|
import { t } from '@lingui/core/macro';
|
||||||
import {
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Alert,
|
||||||
Container,
|
Container,
|
||||||
Flex,
|
Flex,
|
||||||
FocusTrap,
|
FocusTrap,
|
||||||
Group,
|
Group,
|
||||||
|
HoverCard,
|
||||||
Modal,
|
Modal,
|
||||||
Table,
|
Table,
|
||||||
TextInput
|
TextInput
|
||||||
@@ -15,6 +18,7 @@ import {
|
|||||||
IconCoins,
|
IconCoins,
|
||||||
IconCurrencyDollar,
|
IconCurrencyDollar,
|
||||||
IconHash,
|
IconHash,
|
||||||
|
IconInfoCircle,
|
||||||
IconLink,
|
IconLink,
|
||||||
IconList,
|
IconList,
|
||||||
IconNotes,
|
IconNotes,
|
||||||
@@ -488,6 +492,12 @@ function LineItemFormRow({
|
|||||||
return text;
|
return text;
|
||||||
}, [location]);
|
}, [location]);
|
||||||
|
|
||||||
|
// Handle virtual parts
|
||||||
|
const virtual = useMemo(
|
||||||
|
() => record.part_detail?.virtual ?? false,
|
||||||
|
[record.part_detail]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal
|
<Modal
|
||||||
@@ -506,14 +516,30 @@ function LineItemFormRow({
|
|||||||
</Modal>
|
</Modal>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Flex gap='sm' align='center'>
|
<Group gap='xs' justify='space-between'>
|
||||||
<Thumbnail
|
<Group gap='xs' justify='left'>
|
||||||
size={40}
|
<Thumbnail
|
||||||
src={record.part_detail.thumbnail}
|
size={40}
|
||||||
align='center'
|
src={record.part_detail.thumbnail}
|
||||||
/>
|
align='center'
|
||||||
<div>{record.part_detail.name}</div>
|
/>
|
||||||
</Flex>
|
<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>
|
||||||
<Table.Td>{record.supplier_part_detail.SKU}</Table.Td>
|
<Table.Td>{record.supplier_part_detail.SKU}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
@@ -546,6 +572,7 @@ function LineItemFormRow({
|
|||||||
tooltip={t`Set Location`}
|
tooltip={t`Set Location`}
|
||||||
tooltipAlignment='top'
|
tooltipAlignment='top'
|
||||||
variant={locationOpen ? 'outline' : 'transparent'}
|
variant={locationOpen ? 'outline' : 'transparent'}
|
||||||
|
disabled={virtual}
|
||||||
/>
|
/>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
size='sm'
|
size='sm'
|
||||||
@@ -554,6 +581,7 @@ function LineItemFormRow({
|
|||||||
tooltip={t`Assign Batch Code`}
|
tooltip={t`Assign Batch Code`}
|
||||||
tooltipAlignment='top'
|
tooltipAlignment='top'
|
||||||
variant={batchOpen ? 'outline' : 'transparent'}
|
variant={batchOpen ? 'outline' : 'transparent'}
|
||||||
|
disabled={virtual}
|
||||||
/>
|
/>
|
||||||
{trackable && (
|
{trackable && (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
@@ -563,6 +591,7 @@ function LineItemFormRow({
|
|||||||
tooltip={t`Assign Serial Numbers`}
|
tooltip={t`Assign Serial Numbers`}
|
||||||
tooltipAlignment='top'
|
tooltipAlignment='top'
|
||||||
variant={serialOpen ? 'outline' : 'transparent'}
|
variant={serialOpen ? 'outline' : 'transparent'}
|
||||||
|
disabled={virtual}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -574,6 +603,7 @@ function LineItemFormRow({
|
|||||||
tooltip={t`Set Expiry Date`}
|
tooltip={t`Set Expiry Date`}
|
||||||
tooltipAlignment='top'
|
tooltipAlignment='top'
|
||||||
variant={expiryDateOpen ? 'outline' : 'transparent'}
|
variant={expiryDateOpen ? 'outline' : 'transparent'}
|
||||||
|
disabled={virtual}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ActionButton
|
<ActionButton
|
||||||
@@ -583,6 +613,7 @@ function LineItemFormRow({
|
|||||||
tooltipAlignment='top'
|
tooltipAlignment='top'
|
||||||
onClick={() => packagingHandlers.toggle()}
|
onClick={() => packagingHandlers.toggle()}
|
||||||
variant={packagingOpen ? 'outline' : 'transparent'}
|
variant={packagingOpen ? 'outline' : 'transparent'}
|
||||||
|
disabled={virtual}
|
||||||
/>
|
/>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
onClick={() => statusHandlers.toggle()}
|
onClick={() => statusHandlers.toggle()}
|
||||||
@@ -590,6 +621,7 @@ function LineItemFormRow({
|
|||||||
tooltip={t`Change Status`}
|
tooltip={t`Change Status`}
|
||||||
tooltipAlignment='top'
|
tooltipAlignment='top'
|
||||||
variant={statusOpen ? 'outline' : 'transparent'}
|
variant={statusOpen ? 'outline' : 'transparent'}
|
||||||
|
disabled={virtual}
|
||||||
/>
|
/>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={<InvenTreeIcon icon='note' />}
|
icon={<InvenTreeIcon icon='note' />}
|
||||||
@@ -597,6 +629,7 @@ function LineItemFormRow({
|
|||||||
tooltipAlignment='top'
|
tooltipAlignment='top'
|
||||||
variant={noteOpen ? 'outline' : 'transparent'}
|
variant={noteOpen ? 'outline' : 'transparent'}
|
||||||
onClick={() => noteHandlers.toggle()}
|
onClick={() => noteHandlers.toggle()}
|
||||||
|
disabled={virtual}
|
||||||
/>
|
/>
|
||||||
{barcode ? (
|
{barcode ? (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
@@ -606,6 +639,7 @@ function LineItemFormRow({
|
|||||||
variant='filled'
|
variant='filled'
|
||||||
color='red'
|
color='red'
|
||||||
onClick={() => setBarcode(undefined)}
|
onClick={() => setBarcode(undefined)}
|
||||||
|
disabled={virtual}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
@@ -614,6 +648,7 @@ function LineItemFormRow({
|
|||||||
tooltipAlignment='top'
|
tooltipAlignment='top'
|
||||||
variant='transparent'
|
variant='transparent'
|
||||||
onClick={() => open()}
|
onClick={() => open()}
|
||||||
|
disabled={virtual}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -281,11 +281,7 @@ function partTableFilters(): TableFilter[] {
|
|||||||
name: 'virtual',
|
name: 'virtual',
|
||||||
label: t`Virtual`,
|
label: t`Virtual`,
|
||||||
description: t`Filter by parts which are virtual`,
|
description: t`Filter by parts which are virtual`,
|
||||||
type: 'choice',
|
type: 'boolean'
|
||||||
choices: [
|
|
||||||
{ value: 'true', label: t`Virtual` },
|
|
||||||
{ value: 'false', label: t`Not Virtual` }
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'is_template',
|
name: 'is_template',
|
||||||
|
|||||||
@@ -82,13 +82,13 @@ test('Company - Supplier Parts', async ({ browser }) => {
|
|||||||
await loadTab(page, 'Supplier Parts');
|
await loadTab(page, 'Supplier Parts');
|
||||||
await clearTableFilters(page);
|
await clearTableFilters(page);
|
||||||
|
|
||||||
await page.getByText('- 25 / 777').waitFor();
|
await page.getByText(/1 \- 25 \/ 77\d/).waitFor();
|
||||||
|
|
||||||
await setTableChoiceFilter(page, 'Primary', 'Yes');
|
await setTableChoiceFilter(page, 'Primary', 'Yes');
|
||||||
await page.getByText('- 25 / 318').waitFor();
|
await page.getByText(/1 \- 25 \/ 31\d/).waitFor();
|
||||||
|
|
||||||
await clearTableFilters(page);
|
await clearTableFilters(page);
|
||||||
|
|
||||||
await setTableChoiceFilter(page, 'Primary', 'No');
|
await setTableChoiceFilter(page, 'Primary', 'No');
|
||||||
await page.getByText('- 25 / 459').waitFor();
|
await page.getByText(/1 \- 25 \/ 45\d/).waitFor();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -500,6 +500,39 @@ test('Purchase Orders - Receive Items', async ({ browser }) => {
|
|||||||
await page.getByRole('cell', { name: 'my-batch-code' }).first().waitFor();
|
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 }) => {
|
test('Purchase Orders - Duplicate', async ({ browser }) => {
|
||||||
const page = await doCachedLogin(browser, {
|
const page = await doCachedLogin(browser, {
|
||||||
url: 'purchasing/purchase-order/13/detail'
|
url: 'purchasing/purchase-order/13/detail'
|
||||||
|
|||||||
Reference in New Issue
Block a user