2
0
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:
Oliver
2025-11-19 15:40:41 +11:00
committed by GitHub
parent 40fbb4d810
commit 7b38fa30bb
5 changed files with 155 additions and 43 deletions

View File

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

View File

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

View File

@@ -20,6 +20,9 @@ export function usePartFields({
}): ApiFormFieldSet {
const settings = useGlobalSettingsState();
const [virtual, setVirtual] = useState<boolean>(false);
const [purchaseable, setPurchaseable] = useState<boolean>(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: <IconPackages />,
children: {
quantity: {
value: 0
},
location: {}
}
};
if (!virtual) {
fields.initial_stock = {
icon: <IconPackages />,
children: {
quantity: {
value: 0
},
location: {}
}
};
}
fields.initial_supplier = {
icon: <IconBuildingStore />,
children: {
supplier: {
filters: {
is_supplier: true
}
},
sku: {},
manufacturer: {
filters: {
is_manufacturer: true
}
},
mpn: {}
}
};
if (purchaseable) {
fields.initial_supplier = {
icon: <IconBuildingStore />,
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]);
}
/**

View File

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

View File

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