From 720ba157bb006cb0edcebe9dba05a2b9d0a3d7ba Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 14 Oct 2026 19:33:33 +1100 Subject: [PATCH] Shipment contact info (#13016) * Add 'contact' field to SalesOrderShipment model * add unit test * Update shipment table * improve UI form * Add 'phone' and 'email' fields to Address model * Bump API version * Update CHANGELOG * Updated docs --- CHANGELOG.md | 2 + docs/docs/concepts/company.md | 4 +- docs/docs/sales/sales_order.md | 4 ++ .../InvenTree/InvenTree/api_version.py | 6 ++- src/backend/InvenTree/company/api.py | 2 + .../migrations/0082_address_phone_email.py | 33 ++++++++++++++ src/backend/InvenTree/company/models.py | 15 +++++++ src/backend/InvenTree/company/serializers.py | 4 ++ src/backend/InvenTree/company/test_api.py | 40 +++++++++++++++++ .../0123_salesordershipment_contact.py | 28 ++++++++++++ src/backend/InvenTree/order/models.py | 19 +++++++- src/backend/InvenTree/order/serializers.py | 14 ++++++ src/backend/InvenTree/order/test_api.py | 45 ++++++++++++++++++- .../InvenTree/order/test_sales_order.py | 32 ++++++++++++- src/frontend/src/forms/SalesOrderForms.tsx | 28 ++++++++++-- .../sales/SalesOrderShipmentDetailsPanel.tsx | 8 ++++ .../src/tables/company/AddressTable.tsx | 14 ++++++ .../tables/sales/SalesOrderShipmentTable.tsx | 6 +++ 18 files changed, 296 insertions(+), 8 deletions(-) create mode 100644 src/backend/InvenTree/company/migrations/0082_address_phone_email.py create mode 100644 src/backend/InvenTree/order/migrations/0123_salesordershipment_contact.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 74f4eea94d..1129b2ee0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ 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. +- [#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. ### Changed diff --git a/docs/docs/concepts/company.md b/docs/docs/concepts/company.md index 80da97e2dd..b99128c7fb 100644 --- a/docs/docs/concepts/company.md +++ b/docs/docs/concepts/company.md @@ -51,7 +51,7 @@ The list of contacts associated with a particular company is available in the 2026-10-14 : https://github.com/inventree/InvenTree/pull/13016 + - Adds "contact" field to the SalesOrderShipment model and API endpoint + - Adds "phone" and "email" fields to the Address model and API endpoint + v554 -> 2026-10-09 : https://github.com/inventree/InvenTree/pull/12986 - Add missing nullable annotations for read_only fields diff --git a/src/backend/InvenTree/company/api.py b/src/backend/InvenTree/company/api.py index 6465a270c0..b48e581975 100644 --- a/src/backend/InvenTree/company/api.py +++ b/src/backend/InvenTree/company/api.py @@ -130,6 +130,8 @@ class AddressList(DataExportViewMixin, ListCreateDestroyAPIView): 'postal_city', 'postal_code', 'country', + 'phone', + 'email', ] ordering_fields = ['title'] diff --git a/src/backend/InvenTree/company/migrations/0082_address_phone_email.py b/src/backend/InvenTree/company/migrations/0082_address_phone_email.py new file mode 100644 index 0000000000..bb470c4d14 --- /dev/null +++ b/src/backend/InvenTree/company/migrations/0082_address_phone_email.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.17 on 2026-10-14 01:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("company", "0081_remove_company_notes_remove_manufacturerpart_notes_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="address", + name="email", + field=models.EmailField( + blank=True, + help_text="Email address for this address", + max_length=254, + verbose_name="Email", + ), + ), + migrations.AddField( + model_name="address", + name="phone", + field=models.CharField( + blank=True, + help_text="Phone number for this address", + max_length=50, + verbose_name="Phone number", + ), + ), + ] diff --git a/src/backend/InvenTree/company/models.py b/src/backend/InvenTree/company/models.py index 5340505793..c3b7f5f133 100644 --- a/src/backend/InvenTree/company/models.py +++ b/src/backend/InvenTree/company/models.py @@ -343,6 +343,8 @@ class Address(InvenTree.models.InvenTreeModel): line2: Optional line two for address postal_code: Postal code, city and state country: Location country + phone: Phone number for this address + email: Email address for this address shipping_notes: Notes for couriers transporting shipments to this address internal_shipping_notes: Internal notes regarding shipping to this address link: External link to additional address information @@ -464,6 +466,19 @@ class Address(InvenTree.models.InvenTreeModel): blank=True, ) + phone = models.CharField( + max_length=50, + verbose_name=_('Phone number'), + help_text=_('Phone number for this address'), + blank=True, + ) + + email = models.EmailField( + verbose_name=_('Email'), + help_text=_('Email address for this address'), + blank=True, + ) + shipping_notes = models.CharField( max_length=100, verbose_name=_('Courier shipping notes'), diff --git a/src/backend/InvenTree/company/serializers.py b/src/backend/InvenTree/company/serializers.py index a0df524770..586956b6a5 100644 --- a/src/backend/InvenTree/company/serializers.py +++ b/src/backend/InvenTree/company/serializers.py @@ -80,6 +80,8 @@ class AddressSerializer(DataImportExportSerializerMixin, InvenTreeModelSerialize 'postal_city', 'province', 'country', + 'phone', + 'email', 'shipping_notes', 'internal_shipping_notes', 'link', @@ -101,6 +103,8 @@ class AddressBriefSerializer(InvenTreeModelSerializer): 'postal_city', 'province', 'country', + 'phone', + 'email', 'shipping_notes', 'internal_shipping_notes', ] diff --git a/src/backend/InvenTree/company/test_api.py b/src/backend/InvenTree/company/test_api.py index 93dccf80e3..a8139b8b02 100644 --- a/src/backend/InvenTree/company/test_api.py +++ b/src/backend/InvenTree/company/test_api.py @@ -426,6 +426,8 @@ class AddressTest(InvenTreeAPITestCase): 'postal_city', 'province', 'country', + 'phone', + 'email', ]: self.assertIn(key, response.data) @@ -447,6 +449,44 @@ class AddressTest(InvenTreeAPITestCase): self.assertEqual(data['title'], 'World') + def test_phone_email(self): + """Test setting the phone and email fields for an Address.""" + addr = Address.objects.first() + assert addr + + url = reverse('api-address-detail', kwargs={'pk': addr.pk}) + + self.assignRole('purchase_order.change') + + # Fields are optional, and blank by default + data = self.get(url, expected_code=200).data + self.assertEqual(data['phone'], '') + self.assertEqual(data['email'], '') + + # Invalid email address is rejected + response = self.patch(url, {'email': 'not-an-email'}, expected_code=400) + self.assertIn('email', response.data) + + data = self.patch( + url, + {'phone': '+61 2 1234 5678', 'email': 'shipping@example.com'}, + expected_code=200, + ).data + + self.assertEqual(data['phone'], '+61 2 1234 5678') + self.assertEqual(data['email'], 'shipping@example.com') + + # Search by phone number and email address + for term in ['1234 5678', 'shipping@example']: + response = self.get(self.url, {'search': term}, expected_code=200) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]['pk'], addr.pk) + + # Fields can be cleared + data = self.patch(url, {'phone': '', 'email': ''}, expected_code=200).data + self.assertEqual(data['phone'], '') + self.assertEqual(data['email'], '') + def test_delete(self): """Test deleting an object.""" addr = Address.objects.first() diff --git a/src/backend/InvenTree/order/migrations/0123_salesordershipment_contact.py b/src/backend/InvenTree/order/migrations/0123_salesordershipment_contact.py new file mode 100644 index 0000000000..b8b6e4eb8b --- /dev/null +++ b/src/backend/InvenTree/order/migrations/0123_salesordershipment_contact.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.17 on 2026-10-14 01:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("company", "0081_remove_company_notes_remove_manufacturerpart_notes_and_more"), + ("order", "0122_remove_purchaseorder_notes_remove_returnorder_notes_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="salesordershipment", + name="contact", + field=models.ForeignKey( + blank=True, + help_text="Point of contact for this shipment", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="company.contact", + verbose_name="Contact", + ), + ), + ] diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index adbc50115d..cc4bd4e73d 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -2640,6 +2640,7 @@ class SalesOrderShipment( Attributes: order: SalesOrder reference shipment_address: Shipping address for this shipment (optional) + contact: Point of contact for this shipment (optional) shipment_date: Date this shipment was "shipped" (or null) checked_by: User reference field indicating who checked this order reference: Custom reference text for this shipment (e.g. consignment number?) @@ -2661,7 +2662,7 @@ class SalesOrderShipment( """Custom clean method for the SalesOrderShipment class. Raises: - ValidationError: If the shipment address does not match the customer + ValidationError: If the shipment address or contact does not match the customer """ super().clean() @@ -2671,6 +2672,12 @@ class SalesOrderShipment( 'shipment_address': _('Shipment address must match the customer') }) + if self.order and self.contact: + if self.contact.company != self.order.customer: + raise ValidationError({ + 'contact': _('Contact does not match the customer') + }) + @staticmethod def get_api_url() -> str: """Return the API URL associated with the SalesOrderShipment model.""" @@ -2708,6 +2715,16 @@ class SalesOrderShipment( related_name='+', ) + contact = models.ForeignKey( + Contact, + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name=_('Contact'), + help_text=_('Point of contact for this shipment'), + related_name='+', + ) + shipment_date = models.DateField( null=True, blank=True, diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index 5a7602899f..f59a2ca76b 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -1442,6 +1442,7 @@ class SalesOrderShipmentSerializer( 'allocated_items', 'shipment_date', 'shipment_address', + 'contact', 'delivery_date', 'checked_by', 'duplicate', @@ -1453,6 +1454,7 @@ class SalesOrderShipmentSerializer( # Extra detail fields 'parameters', 'checked_by_detail', + 'contact_detail', 'customer_detail', 'order_detail', 'shipment_address_detail', @@ -1524,6 +1526,18 @@ class SalesOrderShipmentSerializer( prefetch_fields=['shipment_address'], ) + contact_detail = OptionalField( + serializer_class=ContactSerializer, + serializer_kwargs={ + 'source': 'contact', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, + default_include=True, + prefetch_fields=['contact'], + ) + parameters = common.filters.enable_parameters_filter() tags = common.filters.enable_tags_filter() diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index ec2aa0d946..72a602b1af 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, SupplierPart, SupplierPriceBreak +from company.models import Company, Contact, SupplierPart, SupplierPriceBreak from InvenTree.unit_test import InvenTreeAPITestCase from order import models from order.models import SalesOrderAllocation, SalesOrderLineItem, SalesOrderShipment @@ -3247,6 +3247,49 @@ class SalesOrderAllocateTest(OrderTest): self.assertTrue(self.shipment.is_delivered()) self.assertEqual(self.shipment.delivery_date, datetime(2023, 5, 15).date()) + def test_shipment_contact(self): + """Test setting the 'contact' field for a shipment via the API.""" + url = reverse('api-so-shipment-detail', kwargs={'pk': self.shipment.pk}) + + customer = self.shipment.order.customer + other_company = Company.objects.exclude(pk=customer.pk).first() + + contact = Contact.objects.create(company=customer, name='Shipment Contact') + other_contact = Contact.objects.create( + company=other_company, name='Other Contact' + ) + + # No contact set by default + response = self.get(url, expected_code=200) + self.assertIsNone(response.data['contact']) + self.assertIsNone(response.data['contact_detail']) + + # A contact for a different company is rejected + response = self.patch(url, {'contact': other_contact.pk}, expected_code=400) + self.assertIn('Contact does not match the customer', str(response.data)) + + self.shipment.refresh_from_db() + self.assertIsNone(self.shipment.contact) + + # A contact for the customer is accepted + response = self.patch(url, {'contact': contact.pk}, expected_code=200) + self.assertEqual(response.data['contact'], contact.pk) + self.assertEqual(response.data['contact_detail']['name'], contact.name) + + self.shipment.refresh_from_db() + self.assertEqual(self.shipment.contact, contact) + + # The contact can be cleared + response = self.patch(url, {'contact': None}, expected_code=200) + self.assertIsNone(response.data['contact']) + + # Deleting the contact clears the reference + self.shipment.contact = contact + self.shipment.save() + contact.delete() + self.shipment.refresh_from_db() + self.assertIsNone(self.shipment.contact) + def test_sales_order_shipment_list(self): """Test the SalesOrderShipment list API endpoint.""" url = reverse('api-so-shipment-list') diff --git a/src/backend/InvenTree/order/test_sales_order.py b/src/backend/InvenTree/order/test_sales_order.py index f1420a241b..8ebe43dce9 100644 --- a/src/backend/InvenTree/order/test_sales_order.py +++ b/src/backend/InvenTree/order/test_sales_order.py @@ -18,7 +18,7 @@ from rest_framework.exceptions import ValidationError as DRFValidationError import order.tasks from common.models import InvenTreeSetting, NotificationMessage from common.settings import set_global_setting -from company.models import Address, Company +from company.models import Address, Company, Contact from InvenTree import status_codes as status from InvenTree.unit_test import ( InvenTreeAPITestCase, @@ -860,6 +860,36 @@ class SalesOrderTest(InvenTreeAPITestCase): 'Shipment address must match the customer', err.exception.messages ) + def test_shipment_contact(self): + """Unit tests for SalesOrderShipment contact field.""" + shipment = SalesOrderShipment.objects.first() + self.assertIsNotNone(shipment) + self.assertIsNone(shipment.contact) + + contact = Contact.objects.create( + company=shipment.order.customer, name='Shipment Contact' + ) + + shipment.contact = contact + shipment.clean() + shipment.save() + + shipment.refresh_from_db() + self.assertEqual(shipment.contact, contact) + + # Contact must belong to the order customer + other_company = Company.objects.exclude(pk=shipment.order.customer.pk).first() + self.assertIsNotNone(other_company) + + shipment.contact = Contact.objects.create( + company=other_company, name='Other Contact' + ) + + with self.assertRaises(ValidationError) as err: + shipment.clean() + + self.assertIn('Contact does not match the customer', err.exception.messages) + def test_overdue_notification(self): """Test overdue sales order notification.""" self.ensurePluginsLoaded() diff --git a/src/frontend/src/forms/SalesOrderForms.tsx b/src/frontend/src/forms/SalesOrderForms.tsx index 71d0240a3f..4ab41c8205 100644 --- a/src/frontend/src/forms/SalesOrderForms.tsx +++ b/src/frontend/src/forms/SalesOrderForms.tsx @@ -518,10 +518,26 @@ export function useSalesOrderShipmentFields({ customerId?: number; pending?: boolean; }): ApiFormFieldSet { + // Customer inferred from the order, if not explicitly provided + const [orderCustomerId, setOrderCustomerId] = useState( + undefined + ); + + const customer = customerId ?? orderCustomerId; + return useMemo(() => { return { order: { - disabled: true + disabled: true, + onValueChange: (_value: any, record: any) => { + // Record may be the fetched shipment, or the selected order instance + const orderCustomer = + record?.order_detail?.customer ?? record?.customer; + + if (orderCustomer) { + setOrderCustomerId(orderCustomer); + } + } }, reference: {}, shipment_date: { @@ -533,16 +549,22 @@ export function useSalesOrderShipmentFields({ shipment_address: { placeholder: t`Leave blank to use the order address`, filters: { - company: customerId, + company: customer, ordering: '-primary' } }, + contact: { + icon: , + filters: { + company: customer + } + }, tracking_number: {}, invoice_number: {}, tags: TagsField({}), link: {} }; - }, [customerId, pending]); + }, [customer, pending]); } export function useSalesOrderShipmentCompleteFields({ diff --git a/src/frontend/src/pages/sales/SalesOrderShipmentDetailsPanel.tsx b/src/frontend/src/pages/sales/SalesOrderShipmentDetailsPanel.tsx index 5280abac94..85fc1e5661 100644 --- a/src/frontend/src/pages/sales/SalesOrderShipmentDetailsPanel.tsx +++ b/src/frontend/src/pages/sales/SalesOrderShipmentDetailsPanel.tsx @@ -124,6 +124,14 @@ export function SalesOrderShipmentDetailsPanel({ ) : ( {t`Not specified`} ) + }, + { + type: 'text', + name: 'contact_detail.name', + label: t`Contact`, + icon: 'user', + copy: true, + hidden: !instance?.contact_detail?.name } ]; diff --git a/src/frontend/src/tables/company/AddressTable.tsx b/src/frontend/src/tables/company/AddressTable.tsx index a683cfbd54..90a09ddaf7 100644 --- a/src/frontend/src/tables/company/AddressTable.tsx +++ b/src/frontend/src/tables/company/AddressTable.tsx @@ -86,6 +86,18 @@ export function AddressTable({ sortable: false, switchable: true }, + { + accessor: 'phone', + sortable: false, + switchable: true, + defaultVisible: false + }, + { + accessor: 'email', + sortable: false, + switchable: true, + defaultVisible: false + }, { accessor: 'shipping_notes', sortable: false, @@ -111,6 +123,8 @@ export function AddressTable({ postal_city: {}, province: {}, country: {}, + phone: {}, + email: {}, shipping_notes: {}, internal_shipping_notes: {}, link: {} diff --git a/src/frontend/src/tables/sales/SalesOrderShipmentTable.tsx b/src/frontend/src/tables/sales/SalesOrderShipmentTable.tsx index 483838c043..7b4ca9ebe1 100644 --- a/src/frontend/src/tables/sales/SalesOrderShipmentTable.tsx +++ b/src/frontend/src/tables/sales/SalesOrderShipmentTable.tsx @@ -157,6 +157,12 @@ export default function SalesOrderShipmentTable({ switchable: false, title: t`Items` }, + { + accessor: 'contact_detail.name', + title: t`Contact`, + switchable: true, + sortable: false + }, { accessor: 'checked', title: t`Checked`,