mirror of
https://github.com/inventree/InvenTree.git
synced 2026-10-14 14:11:55 +00:00
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:
1 parent
dec51eab52
commit
720ba157bb
18 files changed
+296
-8
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in new issue
Block a user