diff --git a/CHANGELOG.md b/CHANGELOG.md
index 10e13b4cab..a9a2bd0b90 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- [#12900](https://github.com/Inventree/InvenTree/pull/12900) adds a UI feature `route` to the `UIMixin`, giving plugins the ability to register custom pages.
- [#12911](https://github.com/inventree/InvenTree/pull/12911) adds global setting for default receive location against purchase orders
- [#12914](https://github.com/inventree/InvenTree/pull/12914) adds the AllocateMixin, allowing plugins to customize automatic stock allocation for build orders and sales orders.
+- [#9768](https://github.com/inventree/InvenTree/pull/9768) adds receiving company and delivery address fields to purchase orders. The receiving company must be an [internal company](https://docs.inventree.org/en/latest/concepts/company/#internal-companies), and defaults to the default internal company.
- [#13017](https://github.com/inventree/InvenTree/pull/13017) adds "internal" companies, which represent your own organization, along with a `INVENTREE_DEFAULT_INTERNAL_COMPANY` setting. The default internal company is available to all report templates as `internal_company`.
- [#13016](https://github.com/inventree/InvenTree/pull/13016) adds a "contact" field to the SalesOrderShipment model and API endpoint.
- [#13016](https://github.com/inventree/InvenTree/pull/13016) adds "phone" and "email" fields to the Address model and API endpoint.
diff --git a/docs/docs/purchasing/purchase_order.md b/docs/docs/purchasing/purchase_order.md
index 71680ae34b..08cee1d4bb 100644
--- a/docs/docs/purchasing/purchase_order.md
+++ b/docs/docs/purchasing/purchase_order.md
@@ -56,6 +56,14 @@ A purchase order is linked to a specific supplier, select one in the list of exi
Fill out the rest of the form with the purchase order information then click on Submit
+### Receiving Company and Delivery Address
+
+The *receiving company* field specifies which [internal company](../concepts/company.md#internal-companies) is receiving the items in this order. Only companies marked as internal can be selected.
+
+When a new purchase order is created, the receiving company defaults to the [default internal company](../concepts/company.md#default-internal-company), if one is configured. If no default is configured, and exactly one internal company exists, that company is selected automatically.
+
+The *delivery address* field specifies where the items should be delivered. The selected address must belong to the receiving company.
+
### Purchase Order Reference
Each Purchase Order is uniquely identified by its *Reference* field. Read more about [reference fields](../settings/reference.md).
diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py
index 2177d7b844..51d49770ac 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 = 557
+INVENTREE_API_VERSION = 558
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
+v558 -> 2026-10-15 : https://github.com/inventree/InvenTree/pull/9768
+ - Adds "receiving_company" and "delivery_address" fields to the PurchaseOrder API endpoint
+
v557 -> 2026-10-15 : https://github.com/inventree/InvenTree/pull/12978
- Adds tips endpoints
diff --git a/src/backend/InvenTree/company/models.py b/src/backend/InvenTree/company/models.py
index ecea00dc7a..3efe6d844a 100644
--- a/src/backend/InvenTree/company/models.py
+++ b/src/backend/InvenTree/company/models.py
@@ -76,6 +76,15 @@ class CompanyReportContext(report.mixins.BaseReportContext, TypedDict):
address: str
+def default_internal_company() -> Optional[int]:
+ """Return the primary key of the default internal company (if available).
+
+ Used as a callable default for fields which reference an internal company.
+ """
+ company = Company.get_default_internal()
+ return company.pk if company else None
+
+
class Company(
InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeParameterMixin,
diff --git a/src/backend/InvenTree/order/admin.py b/src/backend/InvenTree/order/admin.py
index 4059e4f1ee..d10d5fc921 100644
--- a/src/backend/InvenTree/order/admin.py
+++ b/src/backend/InvenTree/order/admin.py
@@ -46,6 +46,8 @@ class PurchaseOrderAdmin(admin.ModelAdmin):
autocomplete_fields = [
'address',
+ 'receiving_company',
+ 'delivery_address',
'contact',
'created_by',
'destination',
diff --git a/src/backend/InvenTree/order/migrations/0124_purchaseorder_delivery_address.py b/src/backend/InvenTree/order/migrations/0124_purchaseorder_delivery_address.py
new file mode 100644
index 0000000000..749391bf88
--- /dev/null
+++ b/src/backend/InvenTree/order/migrations/0124_purchaseorder_delivery_address.py
@@ -0,0 +1,39 @@
+# Generated by Django 4.2.21 on 2025-06-13 09:56
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+import company.models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('company', '0083_company_is_internal'),
+ ('order', '0123_salesordershipment_contact'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='purchaseorder',
+ name='receiving_company',
+ field=models.ForeignKey(blank=True, help_text='Internal company which is receiving the items', limit_choices_to={'is_internal': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='receiving_purchase_orders', to='company.company', verbose_name='Receiving Company'),
+ ),
+ migrations.AddField(
+ model_name='purchaseorder',
+ name='delivery_address',
+ field=models.ForeignKey(blank=True, help_text='Delivery address for this order', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='delivery_purchase_orders', to='company.address', verbose_name='Delivery Address'),
+ ),
+ # The default is applied separately (via AlterField),
+ # so that it is not evaluated for existing rows during migration
+ migrations.AlterField(
+ model_name='purchaseorder',
+ name='receiving_company',
+ field=models.ForeignKey(blank=True, default=company.models.default_internal_company, help_text='Internal company which is receiving the items', limit_choices_to={'is_internal': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='receiving_purchase_orders', to='company.company', verbose_name='Receiving Company'),
+ ),
+ migrations.AlterField(
+ model_name='purchaseorder',
+ name='address',
+ field=models.ForeignKey(blank=True, help_text='Supplier address for this order', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='company.address', verbose_name='Supplier Address'),
+ ),
+ ]
diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py
index cc4bd4e73d..edd84a6a4f 100644
--- a/src/backend/InvenTree/order/models.py
+++ b/src/backend/InvenTree/order/models.py
@@ -36,7 +36,13 @@ from build.status_codes import BuildStatus
from common.currency import currency_code_default
from common.notifications import InvenTreeNotificationBodies
from common.settings import get_global_setting
-from company.models import Address, Company, Contact, SupplierPart
+from company.models import (
+ Address,
+ Company,
+ Contact,
+ SupplierPart,
+ default_internal_company,
+)
from generic.states import (
RETURN_VALUE,
Deprecations,
@@ -644,6 +650,8 @@ class PurchaseOrder(TotalPriceMixin, Order):
supplier_reference: Optional field for supplier order reference code
received_by: User that received the goods
target_date: Expected delivery target date for PurchaseOrder completion (optional)
+ receiving_company: Internal company which is receiving the goods
+ delivery_address: Address (of the receiving company) to which the goods are delivered
"""
REFERENCE_PATTERN_SETTING = 'PURCHASEORDER_REFERENCE_PATTERN'
@@ -656,6 +664,30 @@ class PurchaseOrder(TotalPriceMixin, Order):
verbose_name = _('Purchase Order')
+ def clean(self):
+ """Custom clean method for the PurchaseOrder model."""
+ super().clean()
+
+ if self.receiving_company and not self.receiving_company.is_internal:
+ raise ValidationError({
+ 'receiving_company': _('Receiving company must be an internal company')
+ })
+
+ if self.delivery_address:
+ if not self.receiving_company:
+ raise ValidationError({
+ 'receiving_company': _(
+ 'Receiving company must be specified when a delivery address is provided'
+ )
+ })
+
+ if self.delivery_address.company != self.receiving_company:
+ raise ValidationError({
+ 'delivery_address': _(
+ 'Delivery address does not match selected receiving company'
+ )
+ })
+
def clean_line_item(self, line):
"""Clean a line item for this PurchaseOrder."""
super().clean_line_item(line)
@@ -689,6 +721,9 @@ class PurchaseOrder(TotalPriceMixin, Order):
'reference': order.validators.generate_next_purchase_order_reference()
}
+ if company_pk := default_internal_company():
+ defaults['receiving_company'] = company_pk
+
return defaults
@classmethod
@@ -748,6 +783,16 @@ class PurchaseOrder(TotalPriceMixin, Order):
"""Accessor helper for Order base class."""
return self.supplier
+ address = models.ForeignKey(
+ Address,
+ on_delete=models.SET_NULL,
+ blank=True,
+ null=True,
+ verbose_name=_('Supplier Address'),
+ help_text=_('Supplier address for this order'),
+ related_name='+',
+ )
+
supplier_reference = models.CharField(
max_length=64,
blank=True,
@@ -781,6 +826,28 @@ class PurchaseOrder(TotalPriceMixin, Order):
help_text=_('Destination for received items'),
)
+ receiving_company = models.ForeignKey(
+ Company,
+ on_delete=models.SET_NULL,
+ blank=True,
+ null=True,
+ default=default_internal_company,
+ limit_choices_to={'is_internal': True},
+ related_name='receiving_purchase_orders',
+ verbose_name=_('Receiving Company'),
+ help_text=_('Internal company which is receiving the items'),
+ )
+
+ delivery_address = models.ForeignKey(
+ Address,
+ on_delete=models.SET_NULL,
+ blank=True,
+ null=True,
+ related_name='delivery_purchase_orders',
+ verbose_name=_('Delivery Address'),
+ help_text=_('Delivery address for this order'),
+ )
+
@transaction.atomic
def add_line_item(
self,
diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py
index f59a2ca76b..c40907df49 100644
--- a/src/backend/InvenTree/order/serializers.py
+++ b/src/backend/InvenTree/order/serializers.py
@@ -401,6 +401,10 @@ class PurchaseOrderSerializer(
'total_price',
'order_currency',
'destination',
+ 'receiving_company',
+ 'receiving_company_detail',
+ 'delivery_address',
+ 'delivery_address_detail',
'updated_at',
])
read_only_fields = [
@@ -488,6 +492,29 @@ class PurchaseOrderSerializer(
prefetch_fields=['supplier'],
)
+ receiving_company_detail = OptionalField(
+ serializer_class=CompanyBriefSerializer,
+ serializer_kwargs={
+ 'source': 'receiving_company',
+ 'many': False,
+ 'read_only': True,
+ 'allow_null': True,
+ },
+ prefetch_fields=['receiving_company'],
+ )
+
+ delivery_address_detail = OptionalField(
+ serializer_class=AddressBriefSerializer,
+ serializer_kwargs={
+ 'source': 'delivery_address',
+ 'many': False,
+ 'read_only': True,
+ 'allow_null': True,
+ },
+ default_include=True,
+ prefetch_fields=['delivery_address'],
+ )
+
class OrderAdjustSerializer(serializers.Serializer):
"""Generic serializer class for adjusting the status of an order."""
diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py
index 72a602b1af..7fa65d45cb 100644
--- a/src/backend/InvenTree/order/test_api.py
+++ b/src/backend/InvenTree/order/test_api.py
@@ -23,7 +23,7 @@ from rest_framework.exceptions import ValidationError as DRFValidationError
from common.currency import currency_codes
from common.models import InvenTreeCustomUserStateModel, InvenTreeSetting
from common.settings import set_global_setting
-from company.models import Company, Contact, SupplierPart, SupplierPriceBreak
+from company.models import Address, Company, Contact, SupplierPart, SupplierPriceBreak
from InvenTree.unit_test import InvenTreeAPITestCase
from order import models
from order.models import SalesOrderAllocation, SalesOrderLineItem, SalesOrderShipment
@@ -114,6 +114,17 @@ class PurchaseOrderTest(OrderTest):
self.assertIn(k, field_data)
self.assertEqual(field_data[k], v)
+ # Checks for the 'address' field
+ check_options(
+ post,
+ 'address',
+ {
+ 'type': 'related field',
+ 'label': 'Supplier Address',
+ 'help_text': 'Supplier address for this order',
+ },
+ )
+
# Checks for the 'order_currency' field
check_options(
post,
@@ -1094,6 +1105,269 @@ class PurchaseOrderTest(OrderTest):
)
+class PurchaseOrderReceivingCompanyTest(OrderTest):
+ """Unit tests for the 'receiving_company' and 'delivery_address' fields of the PurchaseOrder API."""
+
+ LIST_URL = reverse('api-po-list')
+
+ @classmethod
+ def setUpTestData(cls):
+ """Create internal companies and addresses for testing."""
+ super().setUpTestData()
+
+ cls.supplier = Company.objects.get(pk=1)
+
+ cls.alpha = Company.objects.create(name='Alpha Internal', is_internal=True)
+ cls.beta = Company.objects.create(name='Beta Internal', is_internal=True)
+
+ cls.alpha_address = Address.objects.create(company=cls.alpha, title='Alpha HQ')
+ cls.beta_address = Address.objects.create(company=cls.beta, title='Beta HQ')
+ cls.supplier_address = Address.objects.create(
+ company=cls.supplier, title='Supplier HQ'
+ )
+
+ def setUp(self):
+ """Ensure the default internal company setting is configured for each test."""
+ super().setUp()
+
+ self.assignRole('purchase_order.add')
+ self.assignRole('purchase_order.change')
+
+ # Unless otherwise specified, 'beta' is the default internal company
+ set_global_setting('INVENTREE_DEFAULT_INTERNAL_COMPANY', self.beta.pk)
+
+ self.reference_idx = 0
+
+ def tearDown(self):
+ """Reset the default internal company setting."""
+ set_global_setting('INVENTREE_DEFAULT_INTERNAL_COMPANY', '')
+ super().tearDown()
+
+ def order_data(self, **kwargs) -> dict:
+ """Construct data for creating a new PurchaseOrder via the API."""
+ self.reference_idx += 1
+
+ return {
+ 'reference': f'PO-9999{self.reference_idx:04d}',
+ 'supplier': self.supplier.pk,
+ 'description': 'A test purchase order',
+ **kwargs,
+ }
+
+ def create_order(self, **kwargs) -> models.PurchaseOrder:
+ """Create a new PurchaseOrder (with the specified receiving company and address)."""
+ self.reference_idx += 1
+
+ return models.PurchaseOrder.objects.create(
+ reference=f'PO-8888{self.reference_idx:04d}',
+ supplier=self.supplier,
+ **kwargs,
+ )
+
+ def options_default(self):
+ """Return the default value for 'receiving_company' from an OPTIONS request."""
+ response = self.options(self.LIST_URL, expected_code=200)
+ field = response.data['actions']['POST']['receiving_company']
+
+ # Only internal companies can be selected
+ self.assertEqual(field['filters'], {'is_internal': True})
+
+ return field.get('default')
+
+ def test_options_default(self):
+ """The default internal company is presented as the default in an OPTIONS request."""
+ self.assertEqual(self.options_default(), self.beta.pk)
+
+ # Changing the setting changes the presented default
+ set_global_setting('INVENTREE_DEFAULT_INTERNAL_COMPANY', self.alpha.pk)
+ self.assertEqual(self.options_default(), self.alpha.pk)
+
+ def test_options_default_single_company(self):
+ """A single internal company is presented as the default, even without the setting."""
+ set_global_setting('INVENTREE_DEFAULT_INTERNAL_COMPANY', '')
+
+ # Multiple internal companies, and no setting - no default
+ self.assertIsNone(self.options_default())
+
+ # Only a single internal company - it is the default
+ self.beta.is_internal = False
+ self.beta.save()
+
+ self.assertEqual(self.options_default(), self.alpha.pk)
+
+ # No internal companies - no default
+ self.alpha.is_internal = False
+ self.alpha.save()
+
+ self.assertIsNone(self.options_default())
+
+ def test_create_default(self):
+ """The default internal company is used when no receiving company is specified."""
+ response = self.post(self.LIST_URL, self.order_data(), expected_code=201)
+ self.assertEqual(response.data['receiving_company'], self.beta.pk)
+
+ po = models.PurchaseOrder.objects.get(pk=response.data['pk'])
+ self.assertEqual(po.receiving_company, self.beta)
+
+ # Without a default internal company, the field is left blank
+ set_global_setting('INVENTREE_DEFAULT_INTERNAL_COMPANY', '')
+
+ response = self.post(self.LIST_URL, self.order_data(), expected_code=201)
+ self.assertIsNone(response.data['receiving_company'])
+
+ def test_create_explicit(self):
+ """A specified receiving company takes priority over the default."""
+ response = self.post(
+ self.LIST_URL,
+ self.order_data(receiving_company=self.alpha.pk),
+ expected_code=201,
+ )
+ self.assertEqual(response.data['receiving_company'], self.alpha.pk)
+
+ po = models.PurchaseOrder.objects.get(pk=response.data['pk'])
+ self.assertEqual(po.receiving_company, self.alpha)
+
+ # An explicit null value is also respected
+ response = self.post(
+ self.LIST_URL, self.order_data(receiving_company=None), expected_code=201
+ )
+ self.assertIsNone(response.data['receiving_company'])
+
+ def test_create_non_internal(self):
+ """A non-internal company cannot be specified as the receiving company."""
+ self.assertFalse(self.supplier.is_internal)
+
+ response = self.post(
+ self.LIST_URL,
+ self.order_data(receiving_company=self.supplier.pk),
+ expected_code=400,
+ )
+ self.assertIn('receiving_company', response.data)
+
+ def test_create_delivery_address(self):
+ """Regression test: the delivery address must match the receiving company on creation."""
+ # Matching address (with the default receiving company)
+ response = self.post(
+ self.LIST_URL,
+ self.order_data(delivery_address=self.beta_address.pk),
+ expected_code=201,
+ )
+ self.assertEqual(response.data['receiving_company'], self.beta.pk)
+ self.assertEqual(response.data['delivery_address'], self.beta_address.pk)
+ self.assertEqual(
+ response.data['delivery_address_detail']['pk'], self.beta_address.pk
+ )
+
+ # Matching address (with an explicit receiving company)
+ response = self.post(
+ self.LIST_URL,
+ self.order_data(
+ receiving_company=self.alpha.pk, delivery_address=self.alpha_address.pk
+ ),
+ expected_code=201,
+ )
+ self.assertEqual(response.data['delivery_address'], self.alpha_address.pk)
+
+ # Address belongs to a different internal company
+ n = models.PurchaseOrder.objects.count()
+
+ for company, address in [
+ (self.alpha, self.beta_address),
+ (self.beta, self.alpha_address),
+ (self.beta, self.supplier_address),
+ ]:
+ response = self.post(
+ self.LIST_URL,
+ self.order_data(
+ receiving_company=company.pk, delivery_address=address.pk
+ ),
+ expected_code=400,
+ )
+ self.assertIn(
+ 'Delivery address does not match selected receiving company',
+ str(response.data['delivery_address']),
+ )
+
+ # Address specified without a receiving company
+ response = self.post(
+ self.LIST_URL,
+ self.order_data(
+ receiving_company=None, delivery_address=self.beta_address.pk
+ ),
+ expected_code=400,
+ )
+ self.assertIn(
+ 'Receiving company must be specified when a delivery address is provided',
+ str(response.data['receiving_company']),
+ )
+
+ # No orders were created by the invalid requests
+ self.assertEqual(models.PurchaseOrder.objects.count(), n)
+
+ def test_update_delivery_address(self):
+ """Regression test: the delivery address must match the receiving company on update."""
+ po = self.create_order(receiving_company=self.alpha)
+ url = reverse('api-po-detail', kwargs={'pk': po.pk})
+
+ # Address belongs to a different company
+ for address in [self.beta_address, self.supplier_address]:
+ response = self.patch(
+ url, {'delivery_address': address.pk}, expected_code=400
+ )
+ self.assertIn(
+ 'Delivery address does not match selected receiving company',
+ str(response.data['delivery_address']),
+ )
+
+ # Matching address
+ response = self.patch(
+ url, {'delivery_address': self.alpha_address.pk}, expected_code=200
+ )
+ self.assertEqual(response.data['delivery_address'], self.alpha_address.pk)
+
+ # Changing the receiving company without changing the address is rejected
+ response = self.patch(
+ url, {'receiving_company': self.beta.pk}, expected_code=400
+ )
+ self.assertIn(
+ 'Delivery address does not match selected receiving company',
+ str(response.data['delivery_address']),
+ )
+
+ # Clearing the receiving company while an address is set is rejected
+ response = self.patch(url, {'receiving_company': None}, expected_code=400)
+ self.assertIn(
+ 'Receiving company must be specified when a delivery address is provided',
+ str(response.data['receiving_company']),
+ )
+
+ # The order is unchanged by the rejected requests
+ po.refresh_from_db()
+ self.assertEqual(po.receiving_company, self.alpha)
+ self.assertEqual(po.delivery_address, self.alpha_address)
+
+ # Changing both the company and the address together is accepted
+ response = self.patch(
+ url,
+ {
+ 'receiving_company': self.beta.pk,
+ 'delivery_address': self.beta_address.pk,
+ },
+ expected_code=200,
+ )
+ self.assertEqual(response.data['receiving_company'], self.beta.pk)
+ self.assertEqual(response.data['delivery_address'], self.beta_address.pk)
+
+ # Clearing both the company and the address together is accepted
+ response = self.patch(
+ url,
+ {'receiving_company': None, 'delivery_address': None},
+ expected_code=200,
+ )
+ self.assertIsNone(response.data['receiving_company'])
+ self.assertIsNone(response.data['delivery_address'])
+
+
class PurchaseOrderLineItemTest(OrderTest):
"""Unit tests for PurchaseOrderLineItems."""
diff --git a/src/backend/InvenTree/order/tests.py b/src/backend/InvenTree/order/tests.py
index 203c1eef51..2b9e31b055 100644
--- a/src/backend/InvenTree/order/tests.py
+++ b/src/backend/InvenTree/order/tests.py
@@ -14,7 +14,7 @@ from djmoney.money import Money
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,
@@ -111,6 +111,101 @@ class OrderTest(ExchangeRateMixin, PluginRegistryMixin, TestCase):
order.clean() # Should not raise
order.save()
+ def test_default_receiving_company(self):
+ """Test that the default internal company is assigned to a new PurchaseOrder."""
+ supplier = Company.objects.get(pk=1)
+
+ # No internal companies exist - no default
+ po = PurchaseOrder.objects.create(reference='PO-77770001', supplier=supplier)
+ self.assertIsNone(po.receiving_company)
+
+ # Single internal company is used as the default
+ alpha = Company.objects.create(name='Alpha Internal', is_internal=True)
+ po = PurchaseOrder.objects.create(reference='PO-77770002', supplier=supplier)
+ self.assertEqual(po.receiving_company, alpha)
+
+ # Multiple internal companies, with the default specified by the setting
+ beta = Company.objects.create(name='Beta Internal', is_internal=True)
+ set_global_setting('INVENTREE_DEFAULT_INTERNAL_COMPANY', beta.pk)
+
+ po = PurchaseOrder.objects.create(reference='PO-77770003', supplier=supplier)
+ self.assertEqual(po.receiving_company, beta)
+
+ # Explicitly specified company is not overridden
+ po = PurchaseOrder.objects.create(
+ reference='PO-77770004', supplier=supplier, receiving_company=alpha
+ )
+ self.assertEqual(po.receiving_company, alpha)
+
+ # Existing orders are not modified when the default changes
+ set_global_setting('INVENTREE_DEFAULT_INTERNAL_COMPANY', alpha.pk)
+ po = PurchaseOrder.objects.get(reference='PO-77770003')
+ self.assertEqual(po.receiving_company, beta)
+
+ set_global_setting('INVENTREE_DEFAULT_INTERNAL_COMPANY', '')
+
+ def test_validate_delivery_address(self):
+ """Regression test for validation of the delivery address against the receiving company."""
+ order = PurchaseOrder.objects.first()
+
+ alpha = Company.objects.create(name='Alpha Internal', is_internal=True)
+ beta = Company.objects.create(name='Beta Internal', is_internal=True)
+
+ alpha_address = Address.objects.create(company=alpha, title='Alpha HQ')
+ beta_address = Address.objects.create(company=beta, title='Beta HQ')
+ supplier_address = Address.objects.create(
+ company=order.supplier, title='Supplier HQ'
+ )
+
+ # Delivery address belongs to a different company
+ order.receiving_company = alpha
+
+ for address in [beta_address, supplier_address]:
+ order.delivery_address = address
+
+ with self.assertRaises(django_exceptions.ValidationError) as err:
+ order.clean()
+
+ self.assertIn(
+ 'Delivery address does not match selected receiving company',
+ str(err.exception.message_dict['delivery_address']),
+ )
+
+ # Delivery address without a receiving company
+ order.receiving_company = None
+ order.delivery_address = alpha_address
+
+ with self.assertRaises(django_exceptions.ValidationError) as err:
+ order.clean()
+
+ self.assertIn(
+ 'Receiving company must be specified when a delivery address is provided',
+ str(err.exception.message_dict['receiving_company']),
+ )
+
+ # Receiving company which is not an internal company
+ order.receiving_company = order.supplier
+ order.delivery_address = None
+
+ with self.assertRaises(django_exceptions.ValidationError) as err:
+ order.clean()
+
+ self.assertIn('receiving_company', err.exception.message_dict)
+
+ # Valid combinations
+ order.receiving_company = alpha
+ order.delivery_address = alpha_address
+ order.clean()
+ order.save()
+
+ order.receiving_company = alpha
+ order.delivery_address = None
+ order.clean()
+
+ order.receiving_company = None
+ order.delivery_address = None
+ order.clean()
+
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 4ffe28dc83..67a3e58440 100644
--- a/src/frontend/src/forms/PurchaseOrderForms.tsx
+++ b/src/frontend/src/forms/PurchaseOrderForms.tsx
@@ -14,6 +14,7 @@ import {
import { useDisclosure, useId } from '@mantine/hooks';
import {
IconAddressBook,
+ IconBuildingSkyscraper,
IconCalendar,
IconCoins,
IconCurrencyDollar,
@@ -254,6 +255,9 @@ export function usePurchaseOrderFields({
}): ApiFormFieldSet {
const globalSettings = useGlobalSettingsState();
+ const [supplier, setSupplier] = useState(supplierId ?? null);
+ const [receivingCompany, setReceivingCompany] = useState(null);
+
return useMemo(() => {
const fields: ApiFormFieldSet = {
reference: {
@@ -267,6 +271,9 @@ export function usePurchaseOrderFields({
is_supplier: true,
active: true
},
+ onValueChange: (value: any) => {
+ setSupplier(value || null);
+ },
addCreateFields: {
name: {},
description: {},
@@ -301,6 +308,7 @@ export function usePurchaseOrderFields({
link: {},
contact: {
icon: ,
+ disabled: !supplier,
adjustFilters: (value: ApiFormAdjustFilterType) => {
return {
...value.filters,
@@ -310,6 +318,7 @@ export function usePurchaseOrderFields({
},
address: {
icon: ,
+ disabled: !supplier,
adjustFilters: (value: ApiFormAdjustFilterType) => {
return {
...value.filters,
@@ -317,6 +326,26 @@ export function usePurchaseOrderFields({
};
}
},
+ receiving_company: {
+ icon: ,
+ filters: {
+ is_internal: true,
+ active: true
+ },
+ onValueChange: (value: any) => {
+ setReceivingCompany(value || null);
+ }
+ },
+ delivery_address: {
+ icon: ,
+ disabled: !receivingCompany,
+ adjustFilters: (value: ApiFormAdjustFilterType) => {
+ return {
+ ...value.filters,
+ company: value.data.receiving_company
+ };
+ }
+ },
responsible: {
filters: {
is_active: true
@@ -346,7 +375,14 @@ export function usePurchaseOrderFields({
}
return fields;
- }, [duplicateOrderId, supplierId, create, globalSettings]);
+ }, [
+ duplicateOrderId,
+ supplierId,
+ create,
+ globalSettings,
+ supplier,
+ receivingCompany
+ ]);
}
/**
diff --git a/src/frontend/src/pages/purchasing/PurchaseOrderDetailsPanel.tsx b/src/frontend/src/pages/purchasing/PurchaseOrderDetailsPanel.tsx
index bba8889f91..7b640d0f1a 100644
--- a/src/frontend/src/pages/purchasing/PurchaseOrderDetailsPanel.tsx
+++ b/src/frontend/src/pages/purchasing/PurchaseOrderDetailsPanel.tsx
@@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro';
-import { Grid, Skeleton, Stack } from '@mantine/core';
+import { Grid, Skeleton, Stack, Text } from '@mantine/core';
import { useMemo } from 'react';
import TagsList from '@lib/components/TagsList';
@@ -16,6 +16,7 @@ import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { LineItemOverviewTable } from '../../components/details/LineItemOverviewTable';
import { useParameterDetailsGrid } from '../../components/details/ParameterDetailsGrid';
+import { RenderAddress } from '../../components/render/Company';
import { formatCurrency } from '../../defaults/formatters';
import { useGlobalSettingsState } from '../../states/SettingsStates';
@@ -101,6 +102,27 @@ export function PurchaseOrderDetailsPanel({
label: t`Destination`,
hidden: !instance?.destination
},
+ {
+ type: 'link',
+ model: ModelType.company,
+ name: 'receiving_company',
+ label: t`Receiving Company`,
+ icon: 'internal_company',
+ hidden: !instance?.receiving_company
+ },
+ {
+ type: 'text',
+ name: 'delivery_address',
+ label: t`Delivery Address`,
+ icon: 'address',
+ hidden: !instance?.receiving_company && !instance?.delivery_address,
+ value_formatter: () =>
+ instance?.delivery_address_detail ? (
+
+ ) : (
+ {t`Not specified`}
+ )
+ },
{
type: 'text',
name: 'currency',