Default receive location (#12911)

* Add global setting

* docs

* Dynamically set API field default

* UI elements

* regression testing

* Add CHANGELOG entry

* Fix unit test
This commit is contained in:
Oliver
2026-09-26 11:39:44 +10:00
committed by GitHub
parent 862c934e0d
commit 979ddb3f06
12 changed files with 105 additions and 9 deletions
+1
View File
@@ -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. - [#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. - [#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. - [#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. - [#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 ### Changed
+2 -1
View File
@@ -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: 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.* * **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_EDIT_COMPLETED_ORDERS") }}
{{ globalsetting("PURCHASEORDER_AUTO_COMPLETE") }} {{ globalsetting("PURCHASEORDER_AUTO_COMPLETE") }}
{{ globalsetting("PURCHASEORDER_MERGE_LINE_ITEMS") }} {{ globalsetting("PURCHASEORDER_MERGE_LINE_ITEMS") }}
{{ globalsetting("PURCHASEORDER_DEFAULT_RECEIVE_LOCATION") }}
+8 -1
View File
@@ -422,7 +422,14 @@ class InvenTreeMetadata(SimpleMetadata):
# If a default value is specified for the serializer field, add it! # If a default value is specified for the serializer field, add it!
if 'default' not in field_info and field.default != empty: 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" # Force non-nullable fields to read as "required"
# (even if there is a default value!) # (even if there is a default value!)
@@ -1031,6 +1031,15 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'default': True, 'default': True,
'validator': bool, '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 / SSO
'LOGIN_ENABLE_PWD_FORGOT': { 'LOGIN_ENABLE_PWD_FORGOT': {
'name': _('Enable password forgot'), 'name': _('Enable password forgot'),
+2
View File
@@ -503,6 +503,8 @@ class SettingsTest(InvenTreeTestCase):
'hidden', 'hidden',
'choices', 'choices',
'units', 'units',
'model',
'model_filters',
'requires_restart', 'requires_restart',
'after_save', 'after_save',
'before_save', 'before_save',
@@ -420,6 +420,22 @@ class PurchaseOrderSerializer(
return [*fields, 'duplicate'] 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( duplicate = DuplicateOptionsSerializer(
order.models.PurchaseOrder.objects.all(), order.models.PurchaseOrder.objects.all(),
copy_lines=True, copy_lines=True,
+47
View File
@@ -527,6 +527,53 @@ class PurchaseOrderTest(OrderTest):
# Revert the setting to previous value # Revert the setting to previous value
InvenTreeSetting.set_setting(setting, False) 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): def test_po_creation_date(self):
"""Test that we can create set the creation_date field of PurchaseOrder via the API.""" """Test that we can create set the creation_date field of PurchaseOrder via the API."""
self.assignRole('purchase_order.add') self.assignRole('purchase_order.add')
@@ -199,7 +199,8 @@ function SelectPartsStep({
const globalSettings = useGlobalSettingsState(); const globalSettings = useGlobalSettingsState();
const purchaseOrderFields = usePurchaseOrderFields({ const purchaseOrderFields = usePurchaseOrderFields({
supplierId: selectedRecord?.supplier_part?.supplier supplierId: selectedRecord?.supplier_part?.supplier,
create: true
}); });
const newPurchaseOrder = useCreateApiFormModal({ const newPurchaseOrder = useCreateApiFormModal({
+13 -3
View File
@@ -245,10 +245,12 @@ export function usePurchaseOrderLineItemFields({
*/ */
export function usePurchaseOrderFields({ export function usePurchaseOrderFields({
supplierId, supplierId,
duplicateOrderId duplicateOrderId,
create
}: { }: {
supplierId?: number; supplierId?: number;
duplicateOrderId?: number; duplicateOrderId?: number;
create?: boolean;
}): ApiFormFieldSet { }): ApiFormFieldSet {
const globalSettings = useGlobalSettingsState(); const globalSettings = useGlobalSettingsState();
@@ -285,7 +287,15 @@ export function usePurchaseOrderFields({
destination: { destination: {
filters: { filters: {
structural: false structural: false
} },
default: create
? toNumber(
globalSettings.getSetting(
'PURCHASEORDER_DEFAULT_RECEIVE_LOCATION'
),
null
)
: undefined
}, },
tags: TagsField({}), tags: TagsField({}),
link: {}, link: {},
@@ -336,7 +346,7 @@ export function usePurchaseOrderFields({
} }
return fields; return fields;
}, [duplicateOrderId, supplierId, globalSettings]); }, [duplicateOrderId, supplierId, create, globalSettings]);
} }
/** /**
@@ -356,7 +356,8 @@ export default function SystemSettings() {
'PURCHASEORDER_REQUIRE_RESPONSIBLE', 'PURCHASEORDER_REQUIRE_RESPONSIBLE',
'PURCHASEORDER_CONVERT_CURRENCY', 'PURCHASEORDER_CONVERT_CURRENCY',
'PURCHASEORDER_EDIT_COMPLETED_ORDERS', 'PURCHASEORDER_EDIT_COMPLETED_ORDERS',
'PURCHASEORDER_AUTO_COMPLETE' 'PURCHASEORDER_AUTO_COMPLETE',
'PURCHASEORDER_DEFAULT_RECEIVE_LOCATION'
]} ]}
/> />
) )
@@ -82,7 +82,8 @@ export default function PurchaseOrderDetail() {
const purchaseOrderFields = usePurchaseOrderFields({}); const purchaseOrderFields = usePurchaseOrderFields({});
const duplicatePurchaseOrderFields = usePurchaseOrderFields({ const duplicatePurchaseOrderFields = usePurchaseOrderFields({
duplicateOrderId: order.pk duplicateOrderId: order.pk,
create: true
}); });
const editPurchaseOrder = useEditApiFormModal({ const editPurchaseOrder = useEditApiFormModal({
@@ -111,7 +111,7 @@ export function PurchaseOrderTable({
]; ];
}, []); }, []);
const purchaseOrderFields = usePurchaseOrderFields({}); const purchaseOrderFields = usePurchaseOrderFields({ create: true });
const newPurchaseOrder = useCreateApiFormModal({ const newPurchaseOrder = useCreateApiFormModal({
url: ApiEndpoints.purchase_order_list, url: ApiEndpoints.purchase_order_list,