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.
- [#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
+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") }}
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
@@ -68,6 +68,8 @@ Addresses are composed differently across the world, and InvenTree reflects this
| 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 |
| 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.
+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.
#### 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
An optional field used to store tracking number information for the shipment.
@@ -1,11 +1,15 @@
"""InvenTree API version information."""
# 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."""
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
- Add missing nullable annotations for read_only fields
+2
View File
@@ -130,6 +130,8 @@ class AddressList(DataExportViewMixin, ListCreateDestroyAPIView):
'postal_city',
'postal_code',
'country',
'phone',
'email',
]
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
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'),
@@ -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',
]
+40
View File
@@ -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()
@@ -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:
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,
@@ -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()
+44 -1
View File
@@ -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')
@@ -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()
+25 -3
View File
@@ -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<number | undefined>(
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: <IconUser />,
filters: {
company: customer
}
},
tracking_number: {},
invoice_number: {},
tags: TagsField({}),
link: {}
};
}, [customerId, pending]);
}, [customer, pending]);
}
export function useSalesOrderShipmentCompleteFields({
@@ -124,6 +124,14 @@ export function SalesOrderShipmentDetailsPanel({
) : (
<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,
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: {}
@@ -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`,