mirror of
https://github.com/inventree/InvenTree.git
synced 2025-11-30 09:20:03 +00:00
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
This commit is contained in:
@@ -1388,8 +1388,13 @@ class SalesOrder(TotalPriceMixin, Order):
|
|||||||
return any(line.is_overallocated() for line in self.lines.all())
|
return any(line.is_overallocated() for line in self.lines.all())
|
||||||
|
|
||||||
def is_completed(self) -> bool:
|
def is_completed(self) -> bool:
|
||||||
"""Check if this order is "shipped" (all line items delivered)."""
|
"""Check if this order is "shipped" (all line items delivered).
|
||||||
return all(line.is_completed() for line in self.lines.all())
|
|
||||||
|
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(
|
def can_complete(
|
||||||
self, raise_error: bool = False, allow_incomplete_lines: bool = False
|
self, raise_error: bool = False, allow_incomplete_lines: bool = False
|
||||||
@@ -1424,9 +1429,14 @@ class SalesOrder(TotalPriceMixin, Order):
|
|||||||
_('Order cannot be completed as there are incomplete allocations')
|
_('Order cannot be completed as there are incomplete allocations')
|
||||||
)
|
)
|
||||||
|
|
||||||
if not allow_incomplete_lines and self.pending_line_count > 0:
|
if not allow_incomplete_lines:
|
||||||
|
pending_lines = self.pending_line_items().exclude(part__virtual=True)
|
||||||
|
|
||||||
|
if pending_lines.count() > 0:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_('Order cannot be completed as there are incomplete line items')
|
_(
|
||||||
|
'Order cannot be completed as there are incomplete line items'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
@@ -1484,6 +1494,7 @@ class SalesOrder(TotalPriceMixin, Order):
|
|||||||
|
|
||||||
trigger_event(SalesOrderEvents.HOLD, id=self.pk)
|
trigger_event(SalesOrderEvents.HOLD, id=self.pk)
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
def _action_complete(self, *args, **kwargs):
|
def _action_complete(self, *args, **kwargs):
|
||||||
"""Mark this order as "complete."""
|
"""Mark this order as "complete."""
|
||||||
user = kwargs.pop('user', None)
|
user = kwargs.pop('user', None)
|
||||||
@@ -1495,6 +1506,16 @@ class SalesOrder(TotalPriceMixin, Order):
|
|||||||
get_global_setting('SALESORDER_SHIP_COMPLETE')
|
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:
|
if bypass_shipped or self.status == SalesOrderStatus.SHIPPED:
|
||||||
self.status = SalesOrderStatus.COMPLETE.value
|
self.status = SalesOrderStatus.COMPLETE.value
|
||||||
else:
|
else:
|
||||||
@@ -1506,11 +1527,6 @@ class SalesOrder(TotalPriceMixin, Order):
|
|||||||
|
|
||||||
self.save()
|
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)
|
trigger_event(SalesOrderEvents.COMPLETED, id=self.pk)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
@@ -1574,7 +1590,7 @@ class SalesOrder(TotalPriceMixin, Order):
|
|||||||
"""Attempt to transition to COMPLETED status."""
|
"""Attempt to transition to COMPLETED status."""
|
||||||
return self.handle_transition(
|
return self.handle_transition(
|
||||||
self.status,
|
self.status,
|
||||||
SalesOrderStatus.COMPLETED.value,
|
SalesOrderStatus.COMPLETE.value,
|
||||||
self,
|
self,
|
||||||
self._action_complete,
|
self._action_complete,
|
||||||
user=user,
|
user=user,
|
||||||
|
|||||||
@@ -488,3 +488,45 @@ class SalesOrderTest(InvenTreeTestCase):
|
|||||||
p.set_metadata(k, k)
|
p.set_metadata(k, k)
|
||||||
|
|
||||||
self.assertEqual(len(p.metadata.keys()), 4)
|
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)
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export function usePartFields({
|
|||||||
}): ApiFormFieldSet {
|
}): ApiFormFieldSet {
|
||||||
const settings = useGlobalSettingsState();
|
const settings = useGlobalSettingsState();
|
||||||
|
|
||||||
|
const [virtual, setVirtual] = useState<boolean>(false);
|
||||||
|
const [purchaseable, setPurchaseable] = useState<boolean>(false);
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const fields: ApiFormFieldSet = {
|
const fields: ApiFormFieldSet = {
|
||||||
category: {
|
category: {
|
||||||
@@ -62,9 +65,19 @@ export function usePartFields({
|
|||||||
is_template: {},
|
is_template: {},
|
||||||
testable: {},
|
testable: {},
|
||||||
trackable: {},
|
trackable: {},
|
||||||
purchaseable: {},
|
purchaseable: {
|
||||||
|
value: purchaseable,
|
||||||
|
onValueChange: (value: boolean) => {
|
||||||
|
setPurchaseable(value);
|
||||||
|
}
|
||||||
|
},
|
||||||
salable: {},
|
salable: {},
|
||||||
virtual: {},
|
virtual: {
|
||||||
|
value: virtual,
|
||||||
|
onValueChange: (value: boolean) => {
|
||||||
|
setVirtual(value);
|
||||||
|
}
|
||||||
|
},
|
||||||
locked: {},
|
locked: {},
|
||||||
active: {},
|
active: {},
|
||||||
starred: {
|
starred: {
|
||||||
@@ -80,6 +93,7 @@ export function usePartFields({
|
|||||||
if (create) {
|
if (create) {
|
||||||
fields.copy_category_parameters = {};
|
fields.copy_category_parameters = {};
|
||||||
|
|
||||||
|
if (!virtual) {
|
||||||
fields.initial_stock = {
|
fields.initial_stock = {
|
||||||
icon: <IconPackages />,
|
icon: <IconPackages />,
|
||||||
children: {
|
children: {
|
||||||
@@ -89,7 +103,9 @@ export function usePartFields({
|
|||||||
location: {}
|
location: {}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (purchaseable) {
|
||||||
fields.initial_supplier = {
|
fields.initial_supplier = {
|
||||||
icon: <IconBuildingStore />,
|
icon: <IconBuildingStore />,
|
||||||
children: {
|
children: {
|
||||||
@@ -108,6 +124,7 @@ export function usePartFields({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Additional fields for part duplication
|
// Additional fields for part duplication
|
||||||
if (create && duplicatePartInstance?.pk) {
|
if (create && duplicatePartInstance?.pk) {
|
||||||
@@ -159,7 +176,7 @@ export function usePartFields({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return fields;
|
return fields;
|
||||||
}, [create, duplicatePartInstance, settings]);
|
}, [virtual, purchaseable, create, duplicatePartInstance, settings]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -320,7 +320,9 @@ export default function SalesOrderLineItemTable({
|
|||||||
|
|
||||||
const allocateStock = useAllocateToSalesOrderForm({
|
const allocateStock = useAllocateToSalesOrderForm({
|
||||||
orderId: orderId,
|
orderId: orderId,
|
||||||
lineItems: selectedItems,
|
lineItems: selectedItems.filter(
|
||||||
|
(item) => item.part_detail?.virtual !== true
|
||||||
|
),
|
||||||
onFormSuccess: () => {
|
onFormSuccess: () => {
|
||||||
table.refreshTable();
|
table.refreshTable();
|
||||||
table.clearSelectedRecords();
|
table.clearSelectedRecords();
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ test('Sales Orders - Shipments', async ({ browser }) => {
|
|||||||
|
|
||||||
test('Sales Orders - Duplicate', async ({ browser }) => {
|
test('Sales Orders - Duplicate', async ({ browser }) => {
|
||||||
const page = await doCachedLogin(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();
|
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.getByRole('tab', { name: 'Order Details' }).click();
|
||||||
|
|
||||||
await page.getByText('Pending').first().waitFor();
|
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();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user