From 979ddb3f0611fb2c04e194737fe16271fcb59d43 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 26 Sep 2026 11:09:44 +0930 Subject: [PATCH] Default receive location (#12911) * Add global setting * docs * Dynamically set API field default * UI elements * regression testing * Add CHANGELOG entry * Fix unit test --- CHANGELOG.md | 1 + docs/docs/purchasing/purchase_order.md | 3 +- src/backend/InvenTree/InvenTree/metadata.py | 9 +++- .../InvenTree/common/setting/system.py | 9 ++++ src/backend/InvenTree/common/tests.py | 2 + src/backend/InvenTree/order/serializers.py | 16 +++++++ src/backend/InvenTree/order/test_api.py | 47 +++++++++++++++++++ .../components/wizards/OrderPartsWizard.tsx | 3 +- src/frontend/src/forms/PurchaseOrderForms.tsx | 16 +++++-- .../pages/Index/Settings/SystemSettings.tsx | 3 +- .../pages/purchasing/PurchaseOrderDetail.tsx | 3 +- .../tables/purchasing/PurchaseOrderTable.tsx | 2 +- 12 files changed, 105 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e8bb83f05..29242dd482 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#12713](https://github.com/inventree/InvenTree/pull/12713) adds SCIM 2 provisioning support, allowing InvenTree to be integrated with external identity providers for user management. - [#12731](https://github.com/inventree/InvenTree/pull/12731) adds OIDC provider settings to the Admin Center - making all Identity Federation settings now available in one place without the need to use the database admin interface. - [#12837](https://github.com/inventree/InvenTree/pull/12837) adds a user setting `ROTATE_TABLE_HEADERS` which rotates table headers by 90 degrees, improving readability for tables with long column titles. +- [#12911](https://github.com/inventree/InvenTree/pull/12911) adds global setting for default receive location against purchase orders - [#12914](https://github.com/inventree/InvenTree/pull/12914) adds the AllocateMixin, allowing plugins to customize automatic stock allocation for build orders and sales orders. ### Changed diff --git a/docs/docs/purchasing/purchase_order.md b/docs/docs/purchasing/purchase_order.md index 4506a683c8..71680ae34b 100644 --- a/docs/docs/purchasing/purchase_order.md +++ b/docs/docs/purchasing/purchase_order.md @@ -109,7 +109,7 @@ There are two options to mark items as "received": When receiving items from a purchase order, the location of the items must be specified. There are multiple ways to specify the location: -* **Order Destination**: The *destination* field of the purchase order can be set to a specific location. When receiving items, the location will default to the destination location. +* **Order Destination**: The *destination* field of the purchase order can be set to a specific location. When receiving items, the location will default to the destination location. If the [Default Receive Location](#purchase-order-settings) setting is configured, the *destination* field will be pre-filled with this location when creating a new purchase order. * **Line Item Location**: Each line item can have a specific location set. When receiving items, the location will default to the line item location. *Note: A destination specified at the line item level will override the destination specified at the order level.* @@ -213,3 +213,4 @@ The following [global settings](../settings/global.md) are available for purchas {{ globalsetting("PURCHASEORDER_EDIT_COMPLETED_ORDERS") }} {{ globalsetting("PURCHASEORDER_AUTO_COMPLETE") }} {{ globalsetting("PURCHASEORDER_MERGE_LINE_ITEMS") }} +{{ globalsetting("PURCHASEORDER_DEFAULT_RECEIVE_LOCATION") }} diff --git a/src/backend/InvenTree/InvenTree/metadata.py b/src/backend/InvenTree/InvenTree/metadata.py index 3b2c7e0838..a7a2768984 100644 --- a/src/backend/InvenTree/InvenTree/metadata.py +++ b/src/backend/InvenTree/InvenTree/metadata.py @@ -422,7 +422,14 @@ class InvenTreeMetadata(SimpleMetadata): # If a default value is specified for the serializer field, add it! if 'default' not in field_info and field.default != empty: - field_info['default'] = field.get_default() + default = field.get_default() + + # Related fields resolve their default to a model instance, + # which is not JSON serializable - reduce it to its primitive representation + if isinstance(field, serializers.RelatedField) and default is not None: + default = field.to_representation(default) + + field_info['default'] = default # Force non-nullable fields to read as "required" # (even if there is a default value!) diff --git a/src/backend/InvenTree/common/setting/system.py b/src/backend/InvenTree/common/setting/system.py index 79cbb4fd06..86e694c9fe 100644 --- a/src/backend/InvenTree/common/setting/system.py +++ b/src/backend/InvenTree/common/setting/system.py @@ -1031,6 +1031,15 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'default': True, 'validator': bool, }, + 'PURCHASEORDER_DEFAULT_RECEIVE_LOCATION': { + 'name': _('Default Receive Location'), + 'description': _( + 'Default destination location for received goods on new purchase orders' + ), + 'default': '', + 'model': 'stock.stocklocation', + 'model_filters': {'structural': False}, + }, # login / SSO 'LOGIN_ENABLE_PWD_FORGOT': { 'name': _('Enable password forgot'), diff --git a/src/backend/InvenTree/common/tests.py b/src/backend/InvenTree/common/tests.py index 4774d7c20a..96ee1e31ab 100644 --- a/src/backend/InvenTree/common/tests.py +++ b/src/backend/InvenTree/common/tests.py @@ -503,6 +503,8 @@ class SettingsTest(InvenTreeTestCase): 'hidden', 'choices', 'units', + 'model', + 'model_filters', 'requires_restart', 'after_save', 'before_save', diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index f19c73a172..b3d82d14ed 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -420,6 +420,22 @@ class PurchaseOrderSerializer( return [*fields, 'duplicate'] + def __init__(self, *args, **kwargs): + """Set a dynamic default for the 'destination' field, on creation only.""" + super().__init__(*args, **kwargs) + + if self.instance is None: + location_pk = get_global_setting( + 'PURCHASEORDER_DEFAULT_RECEIVE_LOCATION', backup_value=None + ) + + if location_pk: + self.fields[ + 'destination' + ].default = stock.models.StockLocation.objects.filter( + pk=location_pk + ).first() + duplicate = DuplicateOptionsSerializer( order.models.PurchaseOrder.objects.all(), copy_lines=True, diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index a8f63cb96c..e199f524fb 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -527,6 +527,53 @@ class PurchaseOrderTest(OrderTest): # Revert the setting to previous value InvenTreeSetting.set_setting(setting, False) + def test_po_create_default_destination(self): + """Test that the PURCHASEORDER_DEFAULT_RECEIVE_LOCATION setting is applied on creation.""" + self.assignRole('purchase_order.add') + + url = reverse('api-po-list') + location = StockLocation.objects.first() + assert location + + # By default, no destination is set on the setting - so the field is left blank + set_global_setting('PURCHASEORDER_DEFAULT_RECEIVE_LOCATION', '') + + data = { + 'reference': 'PO-99990001', + 'supplier': 1, + 'description': 'A test purchase order', + } + + response = self.post(url, data, expected_code=201) + self.assertIsNone(response.data['destination']) + + # Now, set the global default - newly created orders should inherit it + set_global_setting('PURCHASEORDER_DEFAULT_RECEIVE_LOCATION', location.pk) + + # The OPTIONS metadata for the 'destination' field should reflect the default location + response = self.options(url, expected_code=200) + self.assertEqual( + response.data['actions']['POST']['destination']['default'], location.pk + ) + + data['reference'] = 'PO-99990002' + + response = self.post(url, data, expected_code=201) + self.assertEqual(response.data['destination'], location.pk) + + # An explicitly provided destination should always take priority + other_location = StockLocation.objects.exclude(pk=location.pk).first() + assert other_location + + data['reference'] = 'PO-99990003' + data['destination'] = other_location.pk + + response = self.post(url, data, expected_code=201) + self.assertEqual(response.data['destination'], other_location.pk) + + # Revert the setting to its previous value + set_global_setting('PURCHASEORDER_DEFAULT_RECEIVE_LOCATION', '') + def test_po_creation_date(self): """Test that we can create set the creation_date field of PurchaseOrder via the API.""" self.assignRole('purchase_order.add') diff --git a/src/frontend/src/components/wizards/OrderPartsWizard.tsx b/src/frontend/src/components/wizards/OrderPartsWizard.tsx index 990d2cef19..d8330f6666 100644 --- a/src/frontend/src/components/wizards/OrderPartsWizard.tsx +++ b/src/frontend/src/components/wizards/OrderPartsWizard.tsx @@ -199,7 +199,8 @@ function SelectPartsStep({ const globalSettings = useGlobalSettingsState(); const purchaseOrderFields = usePurchaseOrderFields({ - supplierId: selectedRecord?.supplier_part?.supplier + supplierId: selectedRecord?.supplier_part?.supplier, + create: true }); const newPurchaseOrder = useCreateApiFormModal({ diff --git a/src/frontend/src/forms/PurchaseOrderForms.tsx b/src/frontend/src/forms/PurchaseOrderForms.tsx index 023a4be345..4ffe28dc83 100644 --- a/src/frontend/src/forms/PurchaseOrderForms.tsx +++ b/src/frontend/src/forms/PurchaseOrderForms.tsx @@ -245,10 +245,12 @@ export function usePurchaseOrderLineItemFields({ */ export function usePurchaseOrderFields({ supplierId, - duplicateOrderId + duplicateOrderId, + create }: { supplierId?: number; duplicateOrderId?: number; + create?: boolean; }): ApiFormFieldSet { const globalSettings = useGlobalSettingsState(); @@ -285,7 +287,15 @@ export function usePurchaseOrderFields({ destination: { filters: { structural: false - } + }, + default: create + ? toNumber( + globalSettings.getSetting( + 'PURCHASEORDER_DEFAULT_RECEIVE_LOCATION' + ), + null + ) + : undefined }, tags: TagsField({}), link: {}, @@ -336,7 +346,7 @@ export function usePurchaseOrderFields({ } return fields; - }, [duplicateOrderId, supplierId, globalSettings]); + }, [duplicateOrderId, supplierId, create, globalSettings]); } /** diff --git a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx index ad74003707..fec3611353 100644 --- a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx +++ b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx @@ -356,7 +356,8 @@ export default function SystemSettings() { 'PURCHASEORDER_REQUIRE_RESPONSIBLE', 'PURCHASEORDER_CONVERT_CURRENCY', 'PURCHASEORDER_EDIT_COMPLETED_ORDERS', - 'PURCHASEORDER_AUTO_COMPLETE' + 'PURCHASEORDER_AUTO_COMPLETE', + 'PURCHASEORDER_DEFAULT_RECEIVE_LOCATION' ]} /> ) diff --git a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx index 5effc11264..05ad1fa814 100644 --- a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx +++ b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx @@ -82,7 +82,8 @@ export default function PurchaseOrderDetail() { const purchaseOrderFields = usePurchaseOrderFields({}); const duplicatePurchaseOrderFields = usePurchaseOrderFields({ - duplicateOrderId: order.pk + duplicateOrderId: order.pk, + create: true }); const editPurchaseOrder = useEditApiFormModal({ diff --git a/src/frontend/src/tables/purchasing/PurchaseOrderTable.tsx b/src/frontend/src/tables/purchasing/PurchaseOrderTable.tsx index 42c5c67211..5a9fa47098 100644 --- a/src/frontend/src/tables/purchasing/PurchaseOrderTable.tsx +++ b/src/frontend/src/tables/purchasing/PurchaseOrderTable.tsx @@ -111,7 +111,7 @@ export function PurchaseOrderTable({ ]; }, []); - const purchaseOrderFields = usePurchaseOrderFields({}); + const purchaseOrderFields = usePurchaseOrderFields({ create: true }); const newPurchaseOrder = useCreateApiFormModal({ url: ApiEndpoints.purchase_order_list,