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
This commit is contained in:
Oliver authored and GitHub committed 2026-10-14 19:33:33 +11:00
1 parent dec51eab52
commit 720ba157bb
18 files changed
+296 -8

No files matched your search

+2
View File
@@ -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. - [#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 - [#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. - [#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 ### Changed
+3 -1
View File
@@ -51,7 +51,7 @@ The list of contacts associated with a particular company is available in the <s
{{ image("purchasing/contact_list.png", "Company Contacts") }} {{ image("purchasing/contact_list.png", "Company Contacts") }}
A *contact* can be assigned to orders, (such as [purchase orders](../purchasing/purchase_order.md) or [sales orders](../sales/sales_order.md)). A *contact* can be assigned to orders, (such as [purchase orders](../purchasing/purchase_order.md) or [sales orders](../sales/sales_order.md)), and to individual [sales order shipments](../sales/sales_order.md#shipment-contact).
### Addresses ### Addresses
@@ -68,6 +68,8 @@ Addresses are composed differently across the world, and InvenTree reflects this
| City | The city/region tied to the postal code | | City | The city/region tied to the postal code |
| Province | The larger region the address is located in. Also known as State in some countries | | Province | The larger region the address is located in. Also known as State in some countries |
| Country | Country the address is located in | | Country | Country the address is located in |
| Phone | Phone number for this address (optional) |
| Email | Email address for this address (optional) |
Here are a couple of examples of how the address structure differs by country, but these components can construct a correctly formatted address for any given country. Here are a couple of examples of how the address structure differs by country, but these components can construct a correctly formatted address for any given country.
+4
View File
@@ -264,6 +264,10 @@ A unique number for the shipment, used to identify each shipment within a sales
A shipping address can be optionally specified for an individual shipment. If not specified, the [shipping address assigned to the sales order](#sales-order-address) will be used. A shipping address can be optionally specified for an individual shipment. If not specified, the [shipping address assigned to the sales order](#sales-order-address) will be used.
#### Shipment Contact
An optional point of contact for the shipment (for example, the person receiving the delivery). The contact must be one of the [contacts](../concepts/company.md#contacts) assigned to the [customer](./customer.md) linked to the sales order.
#### Tracking Number #### Tracking Number
An optional field used to store tracking number information for the shipment. An optional field used to store tracking number information for the shipment.
@@ -1,11 +1,15 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 554 INVENTREE_API_VERSION = 555
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ INVENTREE_API_TEXT = """
v555 -> 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 v554 -> 2026-10-09 : https://github.com/inventree/InvenTree/pull/12986
- Add missing nullable annotations for read_only fields - Add missing nullable annotations for read_only fields
+2
View File
@@ -130,6 +130,8 @@ class AddressList(DataExportViewMixin, ListCreateDestroyAPIView):
'postal_city', 'postal_city',
'postal_code', 'postal_code',
'country', 'country',
'phone',
'email',
] ]
ordering_fields = ['title'] ordering_fields = ['title']
@@ -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",
),
),
]
+15
View File
@@ -343,6 +343,8 @@ class Address(InvenTree.models.InvenTreeModel):
line2: Optional line two for address line2: Optional line two for address
postal_code: Postal code, city and state postal_code: Postal code, city and state
country: Location country 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 shipping_notes: Notes for couriers transporting shipments to this address
internal_shipping_notes: Internal notes regarding shipping to this address internal_shipping_notes: Internal notes regarding shipping to this address
link: External link to additional address information link: External link to additional address information
@@ -464,6 +466,19 @@ class Address(InvenTree.models.InvenTreeModel):
blank=True, 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( shipping_notes = models.CharField(
max_length=100, max_length=100,
verbose_name=_('Courier shipping notes'), verbose_name=_('Courier shipping notes'),
@@ -80,6 +80,8 @@ class AddressSerializer(DataImportExportSerializerMixin, InvenTreeModelSerialize
'postal_city', 'postal_city',
'province', 'province',
'country', 'country',
'phone',
'email',
'shipping_notes', 'shipping_notes',
'internal_shipping_notes', 'internal_shipping_notes',
'link', 'link',
@@ -101,6 +103,8 @@ class AddressBriefSerializer(InvenTreeModelSerializer):
'postal_city', 'postal_city',
'province', 'province',
'country', 'country',
'phone',
'email',
'shipping_notes', 'shipping_notes',
'internal_shipping_notes', 'internal_shipping_notes',
] ]
+40
View File
@@ -426,6 +426,8 @@ class AddressTest(InvenTreeAPITestCase):
'postal_city', 'postal_city',
'province', 'province',
'country', 'country',
'phone',
'email',
]: ]:
self.assertIn(key, response.data) self.assertIn(key, response.data)
@@ -447,6 +449,44 @@ class AddressTest(InvenTreeAPITestCase):
self.assertEqual(data['title'], 'World') 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): def test_delete(self):
"""Test deleting an object.""" """Test deleting an object."""
addr = Address.objects.first() addr = Address.objects.first()
@@ -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",
),
),
]
+18 -1
View File
@@ -2640,6 +2640,7 @@ class SalesOrderShipment(
Attributes: Attributes:
order: SalesOrder reference order: SalesOrder reference
shipment_address: Shipping address for this shipment (optional) 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) shipment_date: Date this shipment was "shipped" (or null)
checked_by: User reference field indicating who checked this order checked_by: User reference field indicating who checked this order
reference: Custom reference text for this shipment (e.g. consignment number?) reference: Custom reference text for this shipment (e.g. consignment number?)
@@ -2661,7 +2662,7 @@ class SalesOrderShipment(
"""Custom clean method for the SalesOrderShipment class. """Custom clean method for the SalesOrderShipment class.
Raises: 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() super().clean()
@@ -2671,6 +2672,12 @@ class SalesOrderShipment(
'shipment_address': _('Shipment address must match the customer') '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 @staticmethod
def get_api_url() -> str: def get_api_url() -> str:
"""Return the API URL associated with the SalesOrderShipment model.""" """Return the API URL associated with the SalesOrderShipment model."""
@@ -2708,6 +2715,16 @@ class SalesOrderShipment(
related_name='+', 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( shipment_date = models.DateField(
null=True, null=True,
blank=True, blank=True,
@@ -1442,6 +1442,7 @@ class SalesOrderShipmentSerializer(
'allocated_items', 'allocated_items',
'shipment_date', 'shipment_date',
'shipment_address', 'shipment_address',
'contact',
'delivery_date', 'delivery_date',
'checked_by', 'checked_by',
'duplicate', 'duplicate',
@@ -1453,6 +1454,7 @@ class SalesOrderShipmentSerializer(
# Extra detail fields # Extra detail fields
'parameters', 'parameters',
'checked_by_detail', 'checked_by_detail',
'contact_detail',
'customer_detail', 'customer_detail',
'order_detail', 'order_detail',
'shipment_address_detail', 'shipment_address_detail',
@@ -1524,6 +1526,18 @@ class SalesOrderShipmentSerializer(
prefetch_fields=['shipment_address'], 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() parameters = common.filters.enable_parameters_filter()
tags = common.filters.enable_tags_filter() tags = common.filters.enable_tags_filter()
+44 -1
View File
@@ -23,7 +23,7 @@ from rest_framework.exceptions import ValidationError as DRFValidationError
from common.currency import currency_codes from common.currency import currency_codes
from common.models import InvenTreeCustomUserStateModel, InvenTreeSetting from common.models import InvenTreeCustomUserStateModel, InvenTreeSetting
from common.settings import set_global_setting 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 InvenTree.unit_test import InvenTreeAPITestCase
from order import models from order import models
from order.models import SalesOrderAllocation, SalesOrderLineItem, SalesOrderShipment from order.models import SalesOrderAllocation, SalesOrderLineItem, SalesOrderShipment
@@ -3247,6 +3247,49 @@ class SalesOrderAllocateTest(OrderTest):
self.assertTrue(self.shipment.is_delivered()) self.assertTrue(self.shipment.is_delivered())
self.assertEqual(self.shipment.delivery_date, datetime(2023, 5, 15).date()) 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): def test_sales_order_shipment_list(self):
"""Test the SalesOrderShipment list API endpoint.""" """Test the SalesOrderShipment list API endpoint."""
url = reverse('api-so-shipment-list') url = reverse('api-so-shipment-list')
@@ -18,7 +18,7 @@ from rest_framework.exceptions import ValidationError as DRFValidationError
import order.tasks import order.tasks
from common.models import InvenTreeSetting, NotificationMessage from common.models import InvenTreeSetting, NotificationMessage
from common.settings import set_global_setting 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 import status_codes as status
from InvenTree.unit_test import ( from InvenTree.unit_test import (
InvenTreeAPITestCase, InvenTreeAPITestCase,
@@ -860,6 +860,36 @@ class SalesOrderTest(InvenTreeAPITestCase):
'Shipment address must match the customer', err.exception.messages '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): def test_overdue_notification(self):
"""Test overdue sales order notification.""" """Test overdue sales order notification."""
self.ensurePluginsLoaded() self.ensurePluginsLoaded()
+25 -3
View File
@@ -518,10 +518,26 @@ export function useSalesOrderShipmentFields({
customerId?: number; customerId?: number;
pending?: boolean; pending?: boolean;
}): ApiFormFieldSet { }): ApiFormFieldSet {
// Customer inferred from the order, if not explicitly provided
const [orderCustomerId, setOrderCustomerId] = useState<number | undefined>(
undefined
);
const customer = customerId ?? orderCustomerId;
return useMemo(() => { return useMemo(() => {
return { return {
order: { 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: {}, reference: {},
shipment_date: { shipment_date: {
@@ -533,16 +549,22 @@ export function useSalesOrderShipmentFields({
shipment_address: { shipment_address: {
placeholder: t`Leave blank to use the order address`, placeholder: t`Leave blank to use the order address`,
filters: { filters: {
company: customerId, company: customer,
ordering: '-primary' ordering: '-primary'
} }
}, },
contact: {
icon: <IconUser />,
filters: {
company: customer
}
},
tracking_number: {}, tracking_number: {},
invoice_number: {}, invoice_number: {},
tags: TagsField({}), tags: TagsField({}),
link: {} link: {}
}; };
}, [customerId, pending]); }, [customer, pending]);
} }
export function useSalesOrderShipmentCompleteFields({ export function useSalesOrderShipmentCompleteFields({
@@ -124,6 +124,14 @@ export function SalesOrderShipmentDetailsPanel({
) : ( ) : (
<Text size='sm' c='red'>{t`Not specified`}</Text> <Text size='sm' c='red'>{t`Not specified`}</Text>
) )
},
{
type: 'text',
name: 'contact_detail.name',
label: t`Contact`,
icon: 'user',
copy: true,
hidden: !instance?.contact_detail?.name
} }
]; ];
@@ -86,6 +86,18 @@ export function AddressTable({
sortable: false, sortable: false,
switchable: true switchable: true
}, },
{
accessor: 'phone',
sortable: false,
switchable: true,
defaultVisible: false
},
{
accessor: 'email',
sortable: false,
switchable: true,
defaultVisible: false
},
{ {
accessor: 'shipping_notes', accessor: 'shipping_notes',
sortable: false, sortable: false,
@@ -111,6 +123,8 @@ export function AddressTable({
postal_city: {}, postal_city: {},
province: {}, province: {},
country: {}, country: {},
phone: {},
email: {},
shipping_notes: {}, shipping_notes: {},
internal_shipping_notes: {}, internal_shipping_notes: {},
link: {} link: {}
@@ -157,6 +157,12 @@ export default function SalesOrderShipmentTable({
switchable: false, switchable: false,
title: t`Items` title: t`Items`
}, },
{
accessor: 'contact_detail.name',
title: t`Contact`,
switchable: true,
sortable: false
},
{ {
accessor: 'checked', accessor: 'checked',
title: t`Checked`, title: t`Checked`,