mirror of
https://github.com/inventree/InvenTree.git
synced 2026-10-14 22:20:59 +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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
Reference in new issue
Block a user