From 96d1d20b73080df4457b1ed3e7a697231256ca3f Mon Sep 17 00:00:00 2001 From: Reza <50555450+Reza98Sh@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:43:26 +0330 Subject: [PATCH] feat: Add Delivery Address field to Purchase Orders (#9768) * Add delivery address to purchase order; migrate address field to Supplier Address (#9303) * update model related_name and remove migrate file * regenerate migration * Fix migration ordering * Bump API version * add 'receiving_company' field * UI updates * Add regression tests --------- Co-authored-by: Oliver Co-authored-by: Matthias Mair --- CHANGELOG.md | 1 + docs/docs/purchasing/purchase_order.md | 8 + .../InvenTree/InvenTree/api_version.py | 5 +- src/backend/InvenTree/company/models.py | 9 + src/backend/InvenTree/order/admin.py | 2 + .../0124_purchaseorder_delivery_address.py | 39 +++ src/backend/InvenTree/order/models.py | 69 ++++- src/backend/InvenTree/order/serializers.py | 27 ++ src/backend/InvenTree/order/test_api.py | 276 +++++++++++++++++- src/backend/InvenTree/order/tests.py | 97 +++++- src/frontend/src/forms/PurchaseOrderForms.tsx | 38 ++- .../purchasing/PurchaseOrderDetailsPanel.tsx | 24 +- 12 files changed, 589 insertions(+), 6 deletions(-) create mode 100644 src/backend/InvenTree/order/migrations/0124_purchaseorder_delivery_address.py 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',