diff --git a/CHANGELOG.md b/CHANGELOG.md index 78500f9f4aa1..44947623d007 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 +- [#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 +- [#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 + - [#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. diff --git a/docs/docs/concepts/company.md b/docs/docs/concepts/company.md index 80da97e2dd65..b560e293b360 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 | @@ -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. @@ -104,3 +107,31 @@ 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 *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. + +!!! 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/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 6997504b6f47..3c1abb07ef48 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 = 499 +INVENTREE_API_VERSION = 500 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v500 -> 2026-05-31 : https://github.com/inventree/InvenTree/pull/12056 + - Allow null values for the 'company' field on the Address model + v499 -> 2026-06-01 : https://github.com/inventree/InvenTree/pull/12057 - Fixes search field issues on the BarcodeScanHistory API endpoint diff --git a/src/backend/InvenTree/company/api.py b/src/backend/InvenTree/company/api.py index 3702c0c71e69..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 @@ -105,20 +106,50 @@ 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' + ) + + +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.""" queryset = Address.objects.all() serializer_class = AddressSerializer + filterset_class = AddressFilter filter_backends = SEARCH_ORDER_FILTER - filterset_fields = ['company'] - ordering_fields = ['title'] 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.""" @@ -126,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/migrations/0080_alter_address_company.py b/src/backend/InvenTree/company/migrations/0080_alter_address_company.py new file mode 100644 index 000000000000..d93dd1bb6862 --- /dev/null +++ b/src/backend/InvenTree/company/migrations/0080_alter_address_company.py @@ -0,0 +1,45 @@ +# Generated by Django 5.2.14 on 2026-05-31 03:39 + +import django.db.models.deletion +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"), + ] + + 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", + ), + ), + migrations.RunPython( + code=migrations.RunPython.noop, + reverse_code=remove_orphaned_addresses, + ) + ] 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( diff --git a/src/backend/InvenTree/company/test_api.py b/src/backend/InvenTree/company/test_api.py index 8570991643f5..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() @@ -466,6 +488,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.""" 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..ca5a16f4fbe6 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,22 @@ 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), 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 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 None @classmethod def get_status_class(cls): @@ -1337,6 +1366,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.""" 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() 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) 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 diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx index 214f6c49d3b9..ee7d7d8747b8 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, Text } from '@mantine/core'; import { + IconAddressBook, IconCoins, IconCpu, IconDevicesPc, @@ -8,6 +9,7 @@ import { IconFileDownload, IconFileUpload, IconHome, + IconInfoCircle, IconList, IconListDetails, IconMail, @@ -33,6 +35,14 @@ import { GlobalSettingList } from '../../../../components/settings/SettingList'; import { Loadable } from '../../../../functions/loading'; import { useUserState } from '../../../../states/UserState'; +const AddressTable = Loadable( + lazy(() => + import('../../../../tables/company/AddressTable').then((m) => ({ + default: m.AddressTable + })) + ) +); + const ReportTemplatePanel = Loadable( lazy(() => import('./ReportTemplatePanel')) ); @@ -141,6 +151,26 @@ 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 locations associated with your organization.`}{' '} + {t`Internal addresses are not linked to any external company.`} + + + + + ) + }, { name: 'barcode-history', label: t`Barcode Scans`, @@ -240,11 +270,15 @@ export default function AdminCenter() { const grouping: PanelGroupType[] = useMemo(() => { return [ { id: 'home', label: '', panelIDs: ['home'] }, + { + id: 'company', + label: t`Company Data`, + panelIDs: ['user', 'addresses'] + }, { id: 'ops', label: t`Operations`, panelIDs: [ - 'user', 'barcode-history', 'background', 'errors', 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/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', diff --git a/src/frontend/src/tables/company/AddressTable.tsx b/src/frontend/src/tables/company/AddressTable.tsx index 8114bf1f24ca..437658007e6c 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,8 +101,10 @@ export function AddressTable({ }, []); const addressFields: ApiFormFieldSet = useMemo(() => { - return { - company: {}, + const fields: ApiFormFieldSet = { + company: { + required: true + }, title: {}, primary: {}, line1: {}, @@ -115,14 +117,22 @@ export function AddressTable({ internal_shipping_notes: {}, link: {} }; - }, []); + + if (internal) { + fields.company.required = false; + 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 @@ -135,7 +145,7 @@ export function AddressTable({ pk: selectedAddress, title: t`Edit Address`, fields: addressFields, - table: table + onFormSuccess: table.refreshTable }); const deleteAddress = useDeleteApiFormModal({ @@ -205,7 +215,7 @@ export function AddressTable({ rowActions: rowActions, tableActions: tableActions, params: { - ...params, + internal: internal, company: companyId } }}