From ba321382f1bfe91ad734cc1f4217730c3d5140bf Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 31 May 2026 04:06:40 +0000 Subject: [PATCH 01/21] Allow 'company' field of Address model to be null --- .../migrations/0080_alter_address_company.py | 27 +++++++++++++++++++ src/backend/InvenTree/company/models.py | 4 ++- 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 src/backend/InvenTree/company/migrations/0080_alter_address_company.py diff --git a/src/backend/InvenTree/company/migrations/0080_alter_address_company.py b/src/backend/InvenTree/company/migrations/0080_alter_address_company.py new file mode 100644 index 000000000000..a3ba65ea0496 --- /dev/null +++ b/src/backend/InvenTree/company/migrations/0080_alter_address_company.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.14 on 2026-05-31 03:39 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("company", "0079_auto_20260212_1054"), + ] + + operations = [ + migrations.AlterField( + model_name="address", + name="company", + field=models.ForeignKey( + blank=True, + help_text="Select company", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="addresses", + to="company.company", + verbose_name="Company", + ), + ), + ] diff --git a/src/backend/InvenTree/company/models.py b/src/backend/InvenTree/company/models.py index 431f7ae11b58..f0d2fe7e23eb 100644 --- a/src/backend/InvenTree/company/models.py +++ b/src/backend/InvenTree/company/models.py @@ -332,7 +332,7 @@ class Address(InvenTree.models.InvenTreeModel): """An address represents a physical location where the company is located. It is possible for a company to have multiple locations. Attributes: - company: Company link for this address + company: Company link for this address (null if this is an *internal* address, not associated with a company) title: Human-readable name for the address primary: True if this is the company's primary address line1: First line of address @@ -403,6 +403,8 @@ def get_api_url(): on_delete=models.CASCADE, verbose_name=_('Company'), help_text=_('Select company'), + null=True, + blank=True, ) title = models.CharField( From 2384a9965c0b243ac5380b0db3eedda1a45c6b7b Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 31 May 2026 04:09:20 +0000 Subject: [PATCH 02/21] Add API filtering --- src/backend/InvenTree/company/api.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/backend/InvenTree/company/api.py b/src/backend/InvenTree/company/api.py index 3702c0c71e69..7b82fd8545f8 100644 --- a/src/backend/InvenTree/company/api.py +++ b/src/backend/InvenTree/company/api.py @@ -105,16 +105,29 @@ class ContactDetail(RetrieveUpdateDestroyAPI): serializer_class = ContactSerializer +class AddressFilter(FilterSet): + """Custom API filters for the Address list endpoint.""" + + class Meta: + """Metaclass options.""" + + model = Address + fields = ['company'] + + internal = rest_filters.BooleanFilter( + label=_('Internal Address'), field_name='company', lookup_expr='isnull' + ) + + class AddressList(DataExportViewMixin, ListCreateDestroyAPIView): """API endpoint for list view of Address model.""" queryset = Address.objects.all() serializer_class = AddressSerializer + filterset_class = AddressFilter filter_backends = SEARCH_ORDER_FILTER - filterset_fields = ['company'] - ordering_fields = ['title'] ordering = 'title' From bb39b19c30787008e1df48f5d04bc92af281f198 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 31 May 2026 04:15:40 +0000 Subject: [PATCH 03/21] Migrate address fields for order models --- ...20_alter_purchaseorder_address_and_more.py | 67 +++++++++++++++++++ src/backend/InvenTree/order/models.py | 51 ++++++++++---- 2 files changed, 105 insertions(+), 13 deletions(-) create mode 100644 src/backend/InvenTree/order/migrations/0120_alter_purchaseorder_address_and_more.py diff --git a/src/backend/InvenTree/order/migrations/0120_alter_purchaseorder_address_and_more.py b/src/backend/InvenTree/order/migrations/0120_alter_purchaseorder_address_and_more.py new file mode 100644 index 000000000000..66929f40b026 --- /dev/null +++ b/src/backend/InvenTree/order/migrations/0120_alter_purchaseorder_address_and_more.py @@ -0,0 +1,67 @@ +# Generated by Django 5.2.14 on 2026-05-31 04:14 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("company", "0080_alter_address_company"), + ("order", "0119_transferorderlineitem_line_int"), + ] + + operations = [ + migrations.AlterField( + model_name="purchaseorder", + name="address", + field=models.ForeignKey( + blank=True, + help_text="Address for this order", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="company.address", + verbose_name="Address", + ), + ), + migrations.AlterField( + model_name="returnorder", + name="address", + field=models.ForeignKey( + blank=True, + help_text="Address for this order", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="company.address", + verbose_name="Address", + ), + ), + migrations.AlterField( + model_name="salesorder", + name="address", + field=models.ForeignKey( + blank=True, + help_text="Address for this order", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="company.address", + verbose_name="Address", + ), + ), + migrations.AlterField( + model_name="transferorder", + name="address", + field=models.ForeignKey( + blank=True, + help_text="Address for this order", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="company.address", + verbose_name="Address", + ), + ), + ] diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index ba587a9719d6..f343d55e85af 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -311,6 +311,7 @@ class Order( - PurchaseOrder - SalesOrder + - ReturnOrder Attributes: reference: Unique order number / reference / code @@ -328,6 +329,7 @@ class Order( REQUIRE_RESPONSIBLE_SETTING = None UNLOCK_SETTING = None IMPORT_ID_FIELDS = ['reference'] + INTERNAL_ADDRESS: bool = True class Meta: """Metaclass options. Abstract ensures no database table is created.""" @@ -420,16 +422,29 @@ def clean(self): 'start_date': _('Start date must be before target date'), }) - # Check that the referenced 'address' matches the correct 'company' - if ( - hasattr(self, 'company') - and self.company - and self.address - and (self.address.company != self.company) - ): - raise ValidationError({ - 'address': _('Address does not match selected company') - }) + self.clean_address() + + def clean_address(self): + """Check that the address field is valid.""" + if self.INTERNAL_ADDRESS: + # Check that the address is an 'internal' address (i.e. not linked to a company) + if self.address and self.address.company: + raise ValidationError({ + 'address': _( + 'Address must be an internal address (not linked to a company)' + ) + }) + else: + # Check that the referenced 'address' matches the correct 'company' + if ( + hasattr(self, 'company') + and self.company + and self.address + and (self.address.company != self.company) + ): + raise ValidationError({ + 'address': _('Address does not match selected company') + }) def clean_line_item(self, line): """Clean a line item for this order. @@ -572,7 +587,7 @@ def is_overdue(self): blank=True, null=True, verbose_name=_('Address'), - help_text=_('Company address for this order'), + help_text=_('Address for this order'), related_name='+', ) @@ -586,8 +601,15 @@ def company(self): @property def order_address(self): - """Return the Address associated with this order.""" - return self.address or self.company.primary_address + """Return the Address associated with this order. + + - If this is an 'internal' order (i.e. INTERNAL_ADDRESS = True), then the 'address' field is returned directly. + - Otherwise, we can fall back to the primary address of the associated company if no address is specified on the order itself. + """ + if self.INTERNAL_ADDRESS: + return self.address + else: + return self.address or self.company.primary_address @classmethod def get_status_class(cls): @@ -1337,6 +1359,9 @@ class SalesOrder(TotalPriceMixin, Order): STATUS_CLASS = SalesOrderStatus UNLOCK_SETTING = 'SALESORDER_EDIT_COMPLETED_ORDERS' + # SalesOrder address must point to the external company (customer) + INTERNAL_ADDRESS = False + class Meta: """Model meta options.""" From 2b917d0f7854133dfd460094cc4ddba08cb0e7ad Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 31 May 2026 04:18:22 +0000 Subject: [PATCH 04/21] Adjust UI forms --- src/frontend/src/forms/PurchaseOrderForms.tsx | 7 ++----- src/frontend/src/forms/ReturnOrderForms.tsx | 7 ++----- src/frontend/src/forms/TransferOrderForms.tsx | 8 +++++++- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/frontend/src/forms/PurchaseOrderForms.tsx b/src/frontend/src/forms/PurchaseOrderForms.tsx index d12916a49bec..528e5b38b4d3 100644 --- a/src/frontend/src/forms/PurchaseOrderForms.tsx +++ b/src/frontend/src/forms/PurchaseOrderForms.tsx @@ -299,11 +299,8 @@ export function usePurchaseOrderFields({ }, address: { icon: , - adjustFilters: (value: ApiFormAdjustFilterType) => { - return { - ...value.filters, - company: value.data.supplier - }; + filters: { + internal: true } }, responsible: { diff --git a/src/frontend/src/forms/ReturnOrderForms.tsx b/src/frontend/src/forms/ReturnOrderForms.tsx index 2f5be88aa88d..5467282b514c 100644 --- a/src/frontend/src/forms/ReturnOrderForms.tsx +++ b/src/frontend/src/forms/ReturnOrderForms.tsx @@ -63,11 +63,8 @@ export function useReturnOrderFields({ }, address: { icon: , - adjustFilters: (value: ApiFormAdjustFilterType) => { - return { - ...value.filters, - company: value.data.customer - }; + filters: { + internal: true } }, responsible: { diff --git a/src/frontend/src/forms/TransferOrderForms.tsx b/src/frontend/src/forms/TransferOrderForms.tsx index a7e3c8be3f50..bccb107c57d9 100644 --- a/src/frontend/src/forms/TransferOrderForms.tsx +++ b/src/frontend/src/forms/TransferOrderForms.tsx @@ -2,7 +2,7 @@ import { ApiEndpoints, ModelType, ProgressBar, apiUrl } from '@lib/index'; import type { ApiFormFieldSet, ApiFormFieldType } from '@lib/types/Forms'; import { t } from '@lingui/core/macro'; import { Table } from '@mantine/core'; -import { IconCalendar, IconUsers } from '@tabler/icons-react'; +import { IconAddressBook, IconCalendar, IconUsers } from '@tabler/icons-react'; import { useMemo, useState } from 'react'; import RemoveRowButton from '../components/buttons/RemoveRowButton'; import { StandaloneField } from '../components/forms/StandaloneField'; @@ -37,6 +37,12 @@ export function useTransferOrderFields({ }, consume: {}, link: {}, + address: { + icon: , + filters: { + internal: true + } + }, responsible: { filters: { is_active: true From cb4113b0c58bf2415e5e97743e69488941825ed3 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 31 May 2026 04:21:46 +0000 Subject: [PATCH 05/21] Refactor address table --- .../src/tables/company/AddressTable.tsx | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/frontend/src/tables/company/AddressTable.tsx b/src/frontend/src/tables/company/AddressTable.tsx index 8114bf1f24ca..615520aa71b7 100644 --- a/src/frontend/src/tables/company/AddressTable.tsx +++ b/src/frontend/src/tables/company/AddressTable.tsx @@ -25,10 +25,10 @@ import { InvenTreeTable } from '../InvenTreeTable'; export function AddressTable({ companyId, - params + internal }: Readonly<{ - companyId: number; - params?: any; + companyId?: number; + internal?: boolean; }>) { const user = useUserState(); @@ -101,7 +101,7 @@ export function AddressTable({ }, []); const addressFields: ApiFormFieldSet = useMemo(() => { - return { + const fields: ApiFormFieldSet = { company: {}, title: {}, primary: {}, @@ -115,14 +115,21 @@ export function AddressTable({ internal_shipping_notes: {}, link: {} }; - }, []); + + if (internal) { + fields.company.value = null; + fields.company.hidden = true; + } + + return fields; + }, [internal]); const newAddress = useCreateApiFormModal({ url: ApiEndpoints.address_list, title: t`Add Address`, fields: addressFields, initialData: { - company: companyId + company: internal ? null : companyId }, successMessage: t`Address created`, table: table @@ -205,7 +212,7 @@ export function AddressTable({ rowActions: rowActions, tableActions: tableActions, params: { - ...params, + internal: internal, company: companyId } }} From 3b77c4169ad2791601c84a6e5e963e836a7f0dc1 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 31 May 2026 04:26:56 +0000 Subject: [PATCH 06/21] Admin table for internal addresses --- .../Index/Settings/AdminCenter/Index.tsx | 27 ++++++++++++++++++- .../src/pages/company/CompanyDetail.tsx | 4 +-- .../src/tables/company/AddressTable.tsx | 7 +++-- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx index 214f6c49d3b9..0ceab9ff05a3 100644 --- a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx @@ -1,6 +1,7 @@ import { t } from '@lingui/core/macro'; -import { Stack } from '@mantine/core'; +import { Alert, Stack } from '@mantine/core'; import { + IconAddressBook, IconCoins, IconCpu, IconDevicesPc, @@ -8,6 +9,7 @@ import { IconFileDownload, IconFileUpload, IconHome, + IconInfoCircle, IconList, IconListDetails, IconMail, @@ -32,6 +34,7 @@ import { PanelGroup } from '../../../../components/panels/PanelGroup'; import { GlobalSettingList } from '../../../../components/settings/SettingList'; import { Loadable } from '../../../../functions/loading'; import { useUserState } from '../../../../states/UserState'; +import { AddressTable } from '../../../../tables/company/AddressTable'; const ReportTemplatePanel = Loadable( lazy(() => import('./ReportTemplatePanel')) @@ -141,6 +144,23 @@ export default function AdminCenter() { icon: , content: }, + { + name: 'addresses', + label: t`Addresses`, + icon: , + content: ( + + } + title={t`Internal Addresses`} + color='blue' + > + {t`Internal addresses are used for your locations, and are not linked to any external company.`} + + + + ) + }, { name: 'barcode-history', label: t`Barcode Scans`, @@ -240,6 +260,11 @@ export default function AdminCenter() { const grouping: PanelGroupType[] = useMemo(() => { return [ { id: 'home', label: '', panelIDs: ['home'] }, + { + id: 'company', + label: t`Company Data`, + panelIDs: ['addresses'] + }, { id: 'ops', label: t`Operations`, diff --git a/src/frontend/src/pages/company/CompanyDetail.tsx b/src/frontend/src/pages/company/CompanyDetail.tsx index b23b8c19fd72..991c98911b7c 100644 --- a/src/frontend/src/pages/company/CompanyDetail.tsx +++ b/src/frontend/src/pages/company/CompanyDetail.tsx @@ -1,9 +1,9 @@ import { t } from '@lingui/core/macro'; import { Grid, Skeleton, Stack } from '@mantine/core'; import { + IconAddressBook, IconBuildingWarehouse, IconInfoCircle, - IconMap2, IconPackageExport, IconPackages, IconShoppingCart, @@ -264,7 +264,7 @@ export default function CompanyDetail(props: Readonly) { { name: 'addresses', label: t`Addresses`, - icon: , + icon: , content: company?.pk && }, ParametersPanel({ diff --git a/src/frontend/src/tables/company/AddressTable.tsx b/src/frontend/src/tables/company/AddressTable.tsx index 615520aa71b7..437658007e6c 100644 --- a/src/frontend/src/tables/company/AddressTable.tsx +++ b/src/frontend/src/tables/company/AddressTable.tsx @@ -102,7 +102,9 @@ export function AddressTable({ const addressFields: ApiFormFieldSet = useMemo(() => { const fields: ApiFormFieldSet = { - company: {}, + company: { + required: true + }, title: {}, primary: {}, line1: {}, @@ -117,6 +119,7 @@ export function AddressTable({ }; if (internal) { + fields.company.required = false; fields.company.value = null; fields.company.hidden = true; } @@ -142,7 +145,7 @@ export function AddressTable({ pk: selectedAddress, title: t`Edit Address`, fields: addressFields, - table: table + onFormSuccess: table.refreshTable }); const deleteAddress = useDeleteApiFormModal({ From 97caa209d796947888bccaa13c69e0c8ae40f6e6 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 31 May 2026 04:41:06 +0000 Subject: [PATCH 07/21] New unit tests for order addresses --- src/backend/InvenTree/order/test_api.py | 110 +++++++++++++++++- .../InvenTree/order/test_sales_order.py | 19 ++- 2 files changed, 124 insertions(+), 5 deletions(-) diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index 508d1849ee2c..9d357c819cab 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -18,7 +18,7 @@ from common.currency import currency_codes from common.models import InvenTreeCustomUserStateModel, InvenTreeSetting from common.settings import set_global_setting -from company.models import Company, SupplierPart, SupplierPriceBreak +from company.models import Address, Company, SupplierPart, SupplierPriceBreak from InvenTree.unit_test import InvenTreeAPITestCase from order import models from order.models import SalesOrderAllocation, SalesOrderLineItem, SalesOrderShipment @@ -440,6 +440,28 @@ def test_po_create(self): # Revert the setting to previous value InvenTreeSetting.set_setting(setting, False) + def test_po_address(self): + """Test that the PurchaseOrder 'address' field must be an internal address.""" + po = models.PurchaseOrder.objects.get(pk=1) + url = reverse('api-po-detail', kwargs={'pk': po.pk}) + + # An internal address (company=None) must be accepted + internal_address = Address.objects.create( + company=None, title='Warehouse', line1='1 Internal St', country='AU' + ) + response = self.patch(url, {'address': internal_address.pk}, expected_code=200) + self.assertEqual(response.data['address'], internal_address.pk) + + # An external address (linked to a company) must be rejected + external_address = Address.objects.create( + company=Company.objects.first(), + title='Supplier site', + line1='2 External St', + country='AU', + ) + response = self.patch(url, {'address': external_address.pk}, expected_code=400) + self.assertIn('Address must be an internal address', str(response.data)) + 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') @@ -1687,6 +1709,49 @@ def test_so_create(self): expected_code=201, ) + def test_so_address(self): + """Test that the SalesOrder 'address' field must match the customer company.""" + self.assignRole('sales_order.add') + + customer = Company.objects.filter(is_customer=True).first() + + # Create a fresh SO so that full_clean passes reference validation on PATCH + so = self.post( + reverse('api-so-list'), + { + 'customer': customer.pk, + 'reference': 'SO-88881', + 'description': 'addr test', + }, + expected_code=201, + ).data + url = reverse('api-so-detail', kwargs={'pk': so['pk']}) + + # Address belonging to the order's customer must be accepted + customer_address = Address.objects.create( + company=customer, title='Customer site', line1='1 Customer St', country='AU' + ) + response = self.patch(url, {'address': customer_address.pk}, expected_code=200) + self.assertEqual(response.data['address'], customer_address.pk) + + # Address belonging to a different company must be rejected + other_company = Company.objects.exclude(pk=customer.pk).first() + wrong_address = Address.objects.create( + company=other_company, + title='Wrong company', + line1='2 Other St', + country='AU', + ) + response = self.patch(url, {'address': wrong_address.pk}, expected_code=400) + self.assertIn('Address does not match selected company', str(response.data)) + + # An internal address (company=None) must also be rejected for a SalesOrder + internal_address = Address.objects.create( + company=None, title='Internal', line1='3 Internal St', country='AU' + ) + response = self.patch(url, {'address': internal_address.pk}, expected_code=400) + self.assertIn('Address does not match selected company', str(response.data)) + def test_so_duplicate(self): """Test SalesOrder duplication via the API.""" from common.models import Parameter, ParameterTemplate @@ -2648,6 +2713,28 @@ def test_update(self): rma = models.ReturnOrder.objects.get(pk=1) self.assertEqual(rma.customer_reference, 'customer ref') + def test_ro_address(self): + """Test that the ReturnOrder 'address' field must be an internal address.""" + self.assignRole('return_order.change') + url = reverse('api-return-order-detail', kwargs={'pk': 1}) + + # An internal address (company=None) must be accepted + internal_address = Address.objects.create( + company=None, title='Goods-in bay', line1='1 Internal St', country='AU' + ) + response = self.patch(url, {'address': internal_address.pk}, expected_code=200) + self.assertEqual(response.data['address'], internal_address.pk) + + # An external address (linked to a company) must be rejected + external_address = Address.objects.create( + company=Company.objects.first(), + title='Customer site', + line1='2 External St', + country='AU', + ) + response = self.patch(url, {'address': external_address.pk}, expected_code=400) + self.assertIn('Address must be an internal address', str(response.data)) + def test_ro_issue(self): """Test the 'issue' order for a ReturnOrder.""" order = models.ReturnOrder.objects.get(pk=1) @@ -3123,6 +3210,27 @@ def test_transfer_order_create(self): expected_code=201, ) + def test_to_address(self): + """Test that the TransferOrder 'address' field must be an internal address.""" + url = reverse('api-transfer-order-detail', kwargs={'pk': 1}) + + # An internal address (company=None) must be accepted + internal_address = Address.objects.create( + company=None, title='Dispatch bay', line1='1 Internal St', country='AU' + ) + response = self.patch(url, {'address': internal_address.pk}, expected_code=200) + self.assertEqual(response.data['address'], internal_address.pk) + + # An external address (linked to a company) must be rejected + external_address = Address.objects.create( + company=Company.objects.first(), + title='External site', + line1='2 External St', + country='AU', + ) + response = self.patch(url, {'address': external_address.pk}, expected_code=400) + self.assertIn('Address must be an internal address', str(response.data)) + def test_transfer_order_cancel(self): """Test API endpoint for cancelling a TransferOrder.""" to = models.TransferOrder.objects.get(pk=1) diff --git a/src/backend/InvenTree/order/test_sales_order.py b/src/backend/InvenTree/order/test_sales_order.py index 5e078fa1c5e8..23ba9ad50cd9 100644 --- a/src/backend/InvenTree/order/test_sales_order.py +++ b/src/backend/InvenTree/order/test_sales_order.py @@ -78,10 +78,10 @@ def setUpTestData(cls): ) def test_validate_address(self): - """Test validation of the linked Address.""" + """Test validation of the linked Address for a SalesOrder (INTERNAL_ADDRESS=False).""" order = SalesOrder.objects.first() - # Create an address for a different company + # An address belonging to a different company must be rejected company = Company.objects.exclude(pk=order.customer.pk).first() self.assertIsNotNone(company) address = Address.objects.create( @@ -100,11 +100,22 @@ def test_validate_address(self): self.assertIn('Address does not match selected company', str(err.exception)) - # Update the address to match the correct company + # An internal address (company=None) must also be rejected for a SalesOrder + internal_address = Address.objects.create( + company=None, primary=False, line1='1 Internal St', country='AU' + ) + + order.address = internal_address + + with self.assertRaises(ValidationError) as err: + order.clean() + + self.assertIn('Address does not match selected company', str(err.exception)) + + # An address matching the customer company must be accepted address.company = order.customer address.save() - # Now validation should pass order.address = address order.clean() order.save() From a92e2f7cfe5166dfdfa0b83f0d47ff4907434476 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 31 May 2026 04:43:21 +0000 Subject: [PATCH 08/21] Adjust admin interface --- src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx index 0ceab9ff05a3..2db670cbe3dd 100644 --- a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx @@ -263,13 +263,12 @@ export default function AdminCenter() { { id: 'company', label: t`Company Data`, - panelIDs: ['addresses'] + panelIDs: ['user', 'addresses'] }, { id: 'ops', label: t`Operations`, panelIDs: [ - 'user', 'barcode-history', 'background', 'errors', From 28ef78694c8dfa1b98b4421d8fe075f8c363ada2 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 31 May 2026 04:48:30 +0000 Subject: [PATCH 09/21] Documentation updates --- docs/docs/concepts/company.md | 27 ++++++++++++++++++- .../Index/Settings/AdminCenter/Index.tsx | 11 ++++++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/docs/docs/concepts/company.md b/docs/docs/concepts/company.md index 80da97e2dd65..afddd6b0d43d 100644 --- a/docs/docs/concepts/company.md +++ b/docs/docs/concepts/company.md @@ -56,7 +56,7 @@ A *contact* can be assigned to orders, (such as [purchase orders](../purchasing/ ### Addresses A company can have multiple registered addresses for use with all types of orders. -An address is broken down to internationally recognised elements that are designed to allow for formatting an address according to user needs. +An address is broken down to internationally recognized elements that are designed to allow for formatting an address according to user needs. Addresses are composed differently across the world, and InvenTree reflects this by splitting addresses into components: | Field | Description | @@ -104,3 +104,28 @@ Addresses can be accessed by the {{ icon( Here, the addresses associated with the company are listed, and can be added, edited, or deleted. {{ image("concepts/edit_address.png", "Edit Address") }} + +### Internal Addresses + +In addition to addresses linked to external companies, InvenTree supports **internal addresses** — addresses that belong to your own organization rather than to any external company. + +Internal addresses are used when the delivery destination is one of your own sites rather than a customer or supplier location. Orders that use internal addresses include: + +| Order Type | Address Meaning | +| --- | --- | +| [Purchase Order](../purchasing/purchase_order.md) | Optional delivery destination for incoming goods | +| [Return Order](../sales/return_order.md) | Optional location to which returned items are sent | +| [Transfer Order](../stock/transfer_order.md) | Optional destination for an internal stock transfer | + +!!! info "Sales Orders use external addresses" + [Sales Orders](../sales/sales_order.md) are addressed to the *customer*, so they use addresses linked to the customer company, not internal addresses. + +#### Managing Internal Addresses + +Internal addresses are managed from the **Admin Center**, under the *Internal Addresses* panel. Only staff users can create, edit, or delete internal addresses. + +Each internal address uses the same fields as a company address (Title, Line 1, Line 2, Postal Code, City, Province, Country). + +#### Primary Internal Address + +One internal address can be marked as the **primary** address. This address is the default suggestion when assigning a delivery address to a new order. Marking a different address as primary will automatically remove the mark from the previous primary address. diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx index 2db670cbe3dd..6eed91c698a8 100644 --- a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx @@ -1,5 +1,5 @@ import { t } from '@lingui/core/macro'; -import { Alert, Stack } from '@mantine/core'; +import { Alert, Stack, Text } from '@mantine/core'; import { IconAddressBook, IconCoins, @@ -155,7 +155,14 @@ export default function AdminCenter() { title={t`Internal Addresses`} color='blue' > - {t`Internal addresses are used for your locations, and are not linked to any external company.`} + + + {t`Internal addresses are used for locations associated with your organization.`} + + + {t`Internal addresses are not linked to any external company.`} + + From 6f4312d5fa0409d69b6e52ef841b88c53d33586b Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 31 May 2026 04:49:41 +0000 Subject: [PATCH 10/21] Tweak text --- .../src/pages/Index/Settings/AdminCenter/Index.tsx | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx index 6eed91c698a8..b1f151779e23 100644 --- a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx @@ -155,14 +155,10 @@ export default function AdminCenter() { title={t`Internal Addresses`} color='blue' > - - - {t`Internal addresses are used for locations associated with your organization.`} - - - {t`Internal addresses are not linked to any external company.`} - - + + {t`Internal addresses are used for locations associated with your organization.`}{' '} + {t`Internal addresses are not linked to any external company.`} + From 8afae9d3300c2f6bbcf77094bef34674abb52185 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 31 May 2026 05:04:39 +0000 Subject: [PATCH 11/21] Add fallback for primary address --- docs/docs/concepts/company.md | 6 ++++++ src/backend/InvenTree/order/models.py | 7 ++++-- src/backend/InvenTree/order/tests.py | 31 ++++++++++++++++++++++++++- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/docs/docs/concepts/company.md b/docs/docs/concepts/company.md index afddd6b0d43d..3a1921766969 100644 --- a/docs/docs/concepts/company.md +++ b/docs/docs/concepts/company.md @@ -97,6 +97,9 @@ Each company can have exactly one (1) primary address. This address is the default shown on the company profile, and the one that is automatically suggested when creating an order. Marking a new address as primary will remove the mark from the old primary address. +!!! info "Effective order address" + When an order ([Sales Order](../sales/sales_order.md)) has no explicit address assigned, InvenTree uses the customer's primary address as the effective delivery address. This fallback ensures that reports and shipping labels always have an address to render, even if none was explicitly chosen on the order. + #### Managing Addresses Addresses can be accessed by the {{ icon("map-2") }} Addresses navigation tab, from the company detail page. @@ -129,3 +132,6 @@ Each internal address uses the same fields as a company address (Title, Line 1, #### Primary Internal Address One internal address can be marked as the **primary** address. This address is the default suggestion when assigning a delivery address to a new order. Marking a different address as primary will automatically remove the mark from the previous primary address. + +!!! info "Effective order address" + When a Purchase Order, Return Order, or Transfer Order has no explicit address assigned, InvenTree falls back to the primary internal address as the effective delivery address. This ensures that reports and shipping documents always have an address to render. If no internal address exists at all, the effective address is blank. diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index f343d55e85af..d304e4bbe8a5 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -603,11 +603,14 @@ def company(self): def order_address(self): """Return the Address associated with this order. - - If this is an 'internal' order (i.e. INTERNAL_ADDRESS = True), then the 'address' field is returned directly. + - If this is an 'internal' order (i.e. INTERNAL_ADDRESS = True), fall back to the primary internal address. - Otherwise, we can fall back to the primary address of the associated company if no address is specified on the order itself. """ if self.INTERNAL_ADDRESS: - return self.address + return ( + self.address + or Address.objects.filter(company=None, primary=True).first() + ) else: return self.address or self.company.primary_address diff --git a/src/backend/InvenTree/order/tests.py b/src/backend/InvenTree/order/tests.py index e4e04c82eeb8..1d1488a8c191 100644 --- a/src/backend/InvenTree/order/tests.py +++ b/src/backend/InvenTree/order/tests.py @@ -13,7 +13,7 @@ import common.models import order.tasks from common.settings import get_global_setting, set_global_setting -from company.models import Company, Contact, SupplierPart +from company.models import Address, Company, Contact, SupplierPart from InvenTree.helpers import current_date from InvenTree.unit_test import ( ExchangeRateMixin, @@ -109,6 +109,35 @@ def test_validate_contact(self): order.clean() # Should not raise order.save() + def test_order_address(self): + """Test the order_address property for INTERNAL_ADDRESS orders (PO/RO/TO).""" + po = PurchaseOrder.objects.first() + + # With no address set and no primary internal address, returns None + po.address = None + self.assertIsNone(po.order_address) + + # Creating a primary internal address makes it the fallback + primary = Address.objects.create( + company=None, primary=True, title='HQ', line1='1 Main St', country='AU' + ) + self.assertEqual(po.order_address, primary) + + # An explicit address on the order takes precedence over the primary fallback + explicit = Address.objects.create( + company=None, + primary=False, + title='Warehouse', + line1='2 Depot Rd', + country='AU', + ) + po.address = explicit + self.assertEqual(po.order_address, explicit) + + # Clearing the explicit address falls back to primary again + po.address = None + self.assertEqual(po.order_address, primary) + def test_rebuild_reference(self): """Test that the reference_int field is correctly updated when the model is saved.""" order = PurchaseOrder.objects.get(pk=1) From 801d244cd597654acd11577341dcf775f6ff6fcb Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 31 May 2026 05:05:59 +0000 Subject: [PATCH 12/21] Update CHANGELOG --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78500f9f4aa1..57590838fdf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#9604](https://github.com/inventree/InvenTree/pull/9604) - refactors user API endpoint to be less ambiguous - [#11893](https://github.com/inventree/InvenTree/pull/11893) bumps Node environment to version 24 LTS - this is only relevant if you build the frontend assets yourself +- [#9303](https://github.com/inventree/InvenTree/pull/9303) - Purchase Orders, Return Orders, and Transfer Orders now require an *internal* address (one not linked to any company) for their `address` field. Previously these orders accepted any company-linked address. Any existing orders with a company-linked address set will fail validation on next save and must have their address field cleared or updated to an internal address. ### Added +- [#9303](https://github.com/inventree/InvenTree/pull/9303) - adds support for *internal addresses* — addresses that belong to your own organisation rather than to an external company. Internal addresses are managed from the Admin Center and are used as delivery addresses on Purchase Orders, Return Orders, and Transfer Orders. A new `?internal=true/false` filter has been added to the address list API endpoint to distinguish internal from company-linked addresses. If no explicit address is set on an order, the primary internal address is used as a fallback. + +### Changed + - [#12019](https://github.com/inventree/InvenTree/pull/12019) adds a "location" field to the StockCount API endpoint, allowing users to specify a location when performing a stock count. This field is optional, and if not provided, the stock count will be performed without changing the location of the stock item. If a location is provided, the stock item(s) will be moved to the specified location as part of the stock count operation. - [#12011](https://github.com/inventree/InvenTree/pull/12011) adds a "creation_date" field to the StockItem API endpoint, allowing users to track when each stock item was created. This field is read-only and is automatically set to the current date and time when a new stock item is created. - [#12000](https://github.com/inventree/InvenTree/pull/12000) adds support for auto-allocation of stock items against sales orders. This includes both backend and frontend changes, allowing users to trigger auto-allocation via the API or through the UI. The auto-allocation process will attempt to allocate available stock items to the sales order line items, based on the specified stock sorting and allocation rules. From 22f94d3528d0b7e2ec96987d58f29bc94e2ed5e7 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 31 May 2026 05:07:49 +0000 Subject: [PATCH 13/21] lazy load address table --- .../src/pages/Index/Settings/AdminCenter/Index.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx index b1f151779e23..ee7d7d8747b8 100644 --- a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx @@ -34,7 +34,14 @@ import { PanelGroup } from '../../../../components/panels/PanelGroup'; import { GlobalSettingList } from '../../../../components/settings/SettingList'; import { Loadable } from '../../../../functions/loading'; import { useUserState } from '../../../../states/UserState'; -import { AddressTable } from '../../../../tables/company/AddressTable'; + +const AddressTable = Loadable( + lazy(() => + import('../../../../tables/company/AddressTable').then((m) => ({ + default: m.AddressTable + })) + ) +); const ReportTemplatePanel = Loadable( lazy(() => import('./ReportTemplatePanel')) From 10b93d2c618245a87e87381ac8466bb15ff581ec Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 31 May 2026 05:23:50 +0000 Subject: [PATCH 14/21] Display address on PurchaseOrderDetail page --- .../src/pages/purchasing/PurchaseOrderDetail.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx index db4fa6107fbf..de2c4a82c22b 100644 --- a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx +++ b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx @@ -33,6 +33,7 @@ import AttachmentPanel from '../../components/panels/AttachmentPanel'; import NotesPanel from '../../components/panels/NotesPanel'; import { PanelGroup } from '../../components/panels/PanelGroup'; import ParametersPanel from '../../components/panels/ParametersPanel'; +import { RenderAddress } from '../../components/render/Company'; import { StatusRenderer } from '../../components/render/StatusRenderer'; import { formatCurrency } from '../../defaults/formatters'; import { usePurchaseOrderFields } from '../../forms/PurchaseOrderForms'; @@ -224,6 +225,17 @@ export default function PurchaseOrderDetail() { copy: true, hidden: !order.link }, + { + type: 'text', + name: 'address', + label: t`Delivery Address`, + icon: 'address', + hidden: !order.address, + value_formatter: () => + order.address_detail ? ( + + ) : undefined + }, { type: 'text', name: 'contact_detail.name', From 296ee101806af23812032feb16ef779367adf91c Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 31 May 2026 05:29:09 +0000 Subject: [PATCH 15/21] Bump API version --- src/backend/InvenTree/InvenTree/api_version.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index f13f695f0c22..6ad91ef50a0f 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 498 +INVENTREE_API_VERSION = 499 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v499 -> 2026-05-31 : https://github.com/inventree/InvenTree/pull/12056 + - Allow null values for the 'company' field on the Address model + v498 -> 2026-05-31 : https://github.com/inventree/InvenTree/pull/12055 - Updates the "status_text" field for models which support custom status values From e1acec9a8eb8f0278141bb5c53b0b2b0d33fdb91 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 31 May 2026 05:32:50 +0000 Subject: [PATCH 16/21] only staff users can manipulate internal addresses --- src/backend/InvenTree/company/api.py | 28 +++++ src/backend/InvenTree/company/test_api.py | 134 ++++++++++++++++++++++ 2 files changed, 162 insertions(+) diff --git a/src/backend/InvenTree/company/api.py b/src/backend/InvenTree/company/api.py index 7b82fd8545f8..2a1fe707ae92 100644 --- a/src/backend/InvenTree/company/api.py +++ b/src/backend/InvenTree/company/api.py @@ -6,6 +6,7 @@ import django_filters.rest_framework.filters as rest_filters from django_filters.rest_framework.filterset import FilterSet +from rest_framework.exceptions import PermissionDenied import part.models from data_exporter.mixins import DataExportViewMixin @@ -119,6 +120,12 @@ class Meta: ) +def _require_staff_for_internal_address(request, address_company) -> None: + """Raise PermissionDenied if the address is internal and the user is not staff.""" + if address_company is None and not request.user.is_staff: + raise PermissionDenied(_('Only staff users can modify internal addresses')) + + class AddressList(DataExportViewMixin, ListCreateDestroyAPIView): """API endpoint for list view of Address model.""" @@ -132,6 +139,17 @@ class AddressList(DataExportViewMixin, ListCreateDestroyAPIView): ordering = 'title' + def perform_create(self, serializer): + """Enforce staff-only creation of internal addresses.""" + company = serializer.validated_data.get('company', None) + _require_staff_for_internal_address(self.request, company) + super().perform_create(serializer) + + def validate_delete(self, queryset, request) -> None: + """Prevent non-staff users from bulk-deleting internal addresses.""" + if not request.user.is_staff and queryset.filter(company__isnull=True).exists(): + raise PermissionDenied(_('Only staff users can delete internal addresses')) + class AddressDetail(RetrieveUpdateDestroyAPI): """API endpoint for a single Address object.""" @@ -139,6 +157,16 @@ class AddressDetail(RetrieveUpdateDestroyAPI): queryset = Address.objects.all() serializer_class = AddressSerializer + def perform_update(self, serializer): + """Enforce staff-only updates to internal addresses.""" + _require_staff_for_internal_address(self.request, serializer.instance.company) + super().perform_update(serializer) + + def perform_destroy(self, instance): + """Enforce staff-only deletion of internal addresses.""" + _require_staff_for_internal_address(self.request, instance.company) + super().perform_destroy(instance) + class ManufacturerPartFilter(FilterSet): """Custom API filters for the ManufacturerPart list endpoint.""" diff --git a/src/backend/InvenTree/company/test_api.py b/src/backend/InvenTree/company/test_api.py index 8570991643f5..46ac2f5a884a 100644 --- a/src/backend/InvenTree/company/test_api.py +++ b/src/backend/InvenTree/company/test_api.py @@ -466,6 +466,140 @@ def test_delete(self): self.get(url, expected_code=404) +class InternalAddressPermissionTest(InvenTreeAPITestCase): + """Tests that internal addresses (company=None) require staff for write operations.""" + + # Start as non-staff so most tests can verify the 403 path first + is_staff = False + roles = ['purchase_order.add', 'purchase_order.change', 'purchase_order.delete'] + + @classmethod + def setUpTestData(cls): + """Create one company address and one internal address for use across tests.""" + super().setUpTestData() + + cls.company = Company.objects.create( + name='Test Co', description='A test company' + ) + cls.company_address = Address.objects.create( + company=cls.company, title='Company HQ' + ) + cls.internal_address = Address.objects.create( + company=None, title='Our Warehouse' + ) + + cls.list_url = reverse('api-address-list') + cls.internal_url = reverse( + 'api-address-detail', kwargs={'pk': cls.internal_address.pk} + ) + cls.company_url = reverse( + 'api-address-detail', kwargs={'pk': cls.company_address.pk} + ) + + # --- Create --- + + def test_create_internal_address_non_staff_forbidden(self): + """Non-staff cannot create an internal address (no company field).""" + self.post(self.list_url, {'title': 'New Depot'}, expected_code=403) + + def test_create_internal_address_staff_allowed(self): + """Staff users can create an internal address.""" + self.user.is_staff = True + self.user.save() + try: + response = self.post( + self.list_url, {'title': 'Staff Depot'}, expected_code=201 + ) + self.assertIsNone(response.data['company']) + finally: + self.user.is_staff = False + self.user.save() + + def test_create_company_address_non_staff_allowed(self): + """Non-staff can still create a regular company-linked address.""" + self.post( + self.list_url, + {'company': self.company.pk, 'title': 'Branch Office'}, + expected_code=201, + ) + + # --- Update --- + + def test_update_internal_address_non_staff_forbidden(self): + """Non-staff cannot PATCH an internal address.""" + self.patch(self.internal_url, {'title': 'Renamed'}, expected_code=403) + + def test_update_internal_address_staff_allowed(self): + """Staff users can PATCH an internal address.""" + self.user.is_staff = True + self.user.save() + try: + self.patch( + self.internal_url, {'title': 'Updated Warehouse'}, expected_code=200 + ) + finally: + self.user.is_staff = False + self.user.save() + + def test_update_company_address_non_staff_allowed(self): + """Non-staff can PATCH a regular company-linked address.""" + self.patch(self.company_url, {'title': 'Updated HQ'}, expected_code=200) + + # --- Delete (detail) --- + + def test_delete_internal_address_non_staff_forbidden(self): + """Non-staff cannot DELETE an internal address.""" + self.delete(self.internal_url, expected_code=403) + # Confirm the object still exists + self.assertTrue(Address.objects.filter(pk=self.internal_address.pk).exists()) + + def test_delete_internal_address_staff_allowed(self): + """Staff users can DELETE an internal address.""" + addr = Address.objects.create(company=None, title='Temporary') + url = reverse('api-address-detail', kwargs={'pk': addr.pk}) + self.user.is_staff = True + self.user.save() + try: + self.delete(url, expected_code=204) + self.assertFalse(Address.objects.filter(pk=addr.pk).exists()) + finally: + self.user.is_staff = False + self.user.save() + + def test_delete_company_address_non_staff_allowed(self): + """Non-staff can DELETE a regular company-linked address.""" + addr = Address.objects.create(company=self.company, title='Temporary Branch') + url = reverse('api-address-detail', kwargs={'pk': addr.pk}) + self.delete(url, expected_code=204) + + # --- Bulk delete --- + + def test_bulk_delete_internal_address_non_staff_forbidden(self): + """Non-staff cannot bulk-delete a set that contains internal addresses.""" + addr = Address.objects.create(company=None, title='Bulk Target') + self.delete(self.list_url, data={'items': [addr.pk]}, expected_code=403) + self.assertTrue(Address.objects.filter(pk=addr.pk).exists()) + + def test_bulk_delete_internal_address_staff_allowed(self): + """Staff users can bulk-delete internal addresses.""" + addr = Address.objects.create(company=None, title='Bulk Internal') + self.user.is_staff = True + self.user.save() + try: + self.delete(self.list_url, data={'items': [addr.pk]}, expected_code=200) + self.assertFalse(Address.objects.filter(pk=addr.pk).exists()) + finally: + self.user.is_staff = False + self.user.save() + + def test_bulk_delete_company_addresses_non_staff_allowed(self): + """Non-staff can bulk-delete a set that contains only company-linked addresses.""" + a1 = Address.objects.create(company=self.company, title='Bulk A') + a2 = Address.objects.create(company=self.company, title='Bulk B') + self.delete(self.list_url, data={'items': [a1.pk, a2.pk]}, expected_code=200) + self.assertFalse(Address.objects.filter(pk__in=[a1.pk, a2.pk]).exists()) + + class ManufacturerTest(InvenTreeAPITestCase): """Series of tests for the Manufacturer DRF API.""" From 755a72445dd4696b8a3a611a3254d8ca24691db8 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 31 May 2026 05:34:30 +0000 Subject: [PATCH 17/21] Fix CHANGELOG --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57590838fdf4..44947623d007 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,11 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#9604](https://github.com/inventree/InvenTree/pull/9604) - refactors user API endpoint to be less ambiguous - [#11893](https://github.com/inventree/InvenTree/pull/11893) bumps Node environment to version 24 LTS - this is only relevant if you build the frontend assets yourself -- [#9303](https://github.com/inventree/InvenTree/pull/9303) - Purchase Orders, Return Orders, and Transfer Orders now require an *internal* address (one not linked to any company) for their `address` field. Previously these orders accepted any company-linked address. Any existing orders with a company-linked address set will fail validation on next save and must have their address field cleared or updated to an internal address. +- [#12056](https://github.com/inventree/InvenTree/pull/12056) - Purchase Orders, Return Orders, and Transfer Orders now require an *internal* address (one not linked to any company) for their `address` field. Previously these orders accepted any company-linked address. Any existing orders with a company-linked address set will fail validation on next save and must have their address field cleared or updated to an internal address. ### Added -- [#9303](https://github.com/inventree/InvenTree/pull/9303) - adds support for *internal addresses* — addresses that belong to your own organisation rather than to an external company. Internal addresses are managed from the Admin Center and are used as delivery addresses on Purchase Orders, Return Orders, and Transfer Orders. A new `?internal=true/false` filter has been added to the address list API endpoint to distinguish internal from company-linked addresses. If no explicit address is set on an order, the primary internal address is used as a fallback. +- [#12056](https://github.com/inventree/InvenTree/pull/12056) - adds support for *internal addresses* — addresses that belong to your own organisation rather than to an external company. Internal addresses are managed from the Admin Center and are used as delivery addresses on Purchase Orders, Return Orders, and Transfer Orders. A new `?internal=true/false` filter has been added to the address list API endpoint to distinguish internal from company-linked addresses. If no explicit address is set on an order, the primary internal address is used as a fallback. ### Changed From 3b1ba842af0e01b0743631b0a1d9f8284a9da410 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 31 May 2026 05:35:24 +0000 Subject: [PATCH 18/21] Test for new API filter --- src/backend/InvenTree/company/test_api.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/backend/InvenTree/company/test_api.py b/src/backend/InvenTree/company/test_api.py index 46ac2f5a884a..83bfba933846 100644 --- a/src/backend/InvenTree/company/test_api.py +++ b/src/backend/InvenTree/company/test_api.py @@ -397,6 +397,28 @@ def test_filter_list(self): self.assertEqual(len(response.data), self.num_addr) + def test_filter_internal(self): + """Test the ?internal= filter returns only internal / only company addresses.""" + n_internal = 2 + Address.objects.bulk_create([ + Address(company=None, title=f'Internal {idx}') for idx in range(n_internal) + ]) + + total = Address.objects.count() + n_company = total - n_internal + + # internal=true → only addresses with company=None + response = self.get(self.url, {'internal': True}, expected_code=200) + self.assertEqual(len(response.data), n_internal) + for item in response.data: + self.assertIsNone(item['company']) + + # internal=false → only addresses with a company set + response = self.get(self.url, {'internal': False}, expected_code=200) + self.assertEqual(len(response.data), n_company) + for item in response.data: + self.assertIsNotNone(item['company']) + def test_create(self): """Test creating a new address.""" company = Company.objects.first() From 4a7ba6a21728d5c8c56515cec3bd2e760b0b5e10 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 31 May 2026 05:35:46 +0000 Subject: [PATCH 19/21] docs fix --- docs/docs/concepts/company.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/concepts/company.md b/docs/docs/concepts/company.md index 3a1921766969..b560e293b360 100644 --- a/docs/docs/concepts/company.md +++ b/docs/docs/concepts/company.md @@ -125,7 +125,7 @@ Internal addresses are used when the delivery destination is one of your own sit #### Managing Internal Addresses -Internal addresses are managed from the **Admin Center**, under the *Internal Addresses* panel. Only staff users can create, edit, or delete internal addresses. +Internal addresses are managed from the **Admin Center**, under the *Addresses* panel. Only staff users can create, edit, or delete internal addresses. Each internal address uses the same fields as a company address (Title, Line 1, Line 2, Postal Code, City, Province, Country). From 273077504ca08e00aced1e07f67b8b9b74217e8e Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 31 May 2026 05:42:39 +0000 Subject: [PATCH 20/21] Adjust address fallback --- src/backend/InvenTree/order/models.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index d304e4bbe8a5..ca5a16f4fbe6 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -606,13 +606,17 @@ def order_address(self): - If this is an 'internal' order (i.e. INTERNAL_ADDRESS = True), fall back to the primary internal address. - Otherwise, we can fall back to the primary address of the associated company if no address is specified on the order itself. """ + # Return address if directly specified + if address := self.address: + return address + if self.INTERNAL_ADDRESS: - return ( - self.address - or Address.objects.filter(company=None, primary=True).first() - ) + # Return the primary internal address (i.e. where company is null) + return Address.objects.filter(company=None).order_by('-primary').first() + elif self.company: + return self.company.primary_address else: - return self.address or self.company.primary_address + return None @classmethod def get_status_class(cls): From 90fba9603f9248eb6a1a7c225801fc342c728d8d Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 1 Jun 2026 02:24:46 +0000 Subject: [PATCH 21/21] Update migration --- .../migrations/0080_alter_address_company.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/backend/InvenTree/company/migrations/0080_alter_address_company.py b/src/backend/InvenTree/company/migrations/0080_alter_address_company.py index a3ba65ea0496..d93dd1bb6862 100644 --- a/src/backend/InvenTree/company/migrations/0080_alter_address_company.py +++ b/src/backend/InvenTree/company/migrations/0080_alter_address_company.py @@ -4,8 +4,22 @@ from django.db import migrations, models +def remove_orphaned_addresses(apps, schema_editor): + """Remove any Address objects which are not linked to a Company.""" + + Address = apps.get_model("company", "Address") + + orphaned_addresses = Address.objects.filter(company=None) + + if len(orphaned_addresses) > 0: + print(f"Removing {len(orphaned_addresses)} orphaned Address objects") + orphaned_addresses.delete() + + class Migration(migrations.Migration): + atomic = False + dependencies = [ ("company", "0079_auto_20260212_1054"), ] @@ -24,4 +38,8 @@ class Migration(migrations.Migration): verbose_name="Company", ), ), + migrations.RunPython( + code=migrations.RunPython.noop, + reverse_code=remove_orphaned_addresses, + ) ]