2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-10-31 21:25:42 +00:00

SalesOrderShipment address (#10650)

* Adds "shipment_address" attribute to the SalesOrderShipment model:

- Allows different addresses for each shipment
- Defaults to the order shipment address (if not specified)

* Add unit testing for field validation

* Update SalesOrderShipment serializer

* Edit shipment address in UI

* Render date on shipment page

* Improve address rendering

* Update docs

* Bump API version

* Update CHANGELOG.md

* Fix API version
This commit is contained in:
Oliver
2025-10-23 16:37:43 +11:00
committed by GitHub
parent 754b2f2d66
commit ec33c57e85
13 changed files with 252 additions and 39 deletions

View File

@@ -1,12 +1,15 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 414
INVENTREE_API_VERSION = 415
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v414 -> 2025-06-20 : https://github.com/inventree/InvenTree/pull/10629
v415 -> 2025-10-22 : https://github.com/inventree/InvenTree/pull/10650
- Adds "shipment_address" fields to the SalesOrderShipment API endpoints
v414 -> 2025-10-20 : https://github.com/inventree/InvenTree/pull/10629
- Add enums for all ordering fields in schema - no functional changes
v413 -> 2025-10-20 : https://github.com/inventree/InvenTree/pull/10624
@@ -15,10 +18,10 @@ v413 -> 2025-10-20 : https://github.com/inventree/InvenTree/pull/10624
v412 -> 2025-10-19 : https://github.com/inventree/InvenTree/pull/10549
- added a new query parameter for the PartsList api: price_breaks (default: false)
v411 -> 2025-06-19 : https://github.com/inventree/InvenTree/pull/10630
- Editorialy changes to machine api - no functional changes
v411 -> 2025-10-19 : https://github.com/inventree/InvenTree/pull/10630
- Editorial changes to machine api - no functional changes
v410 -> 2025-06-12 : https://github.com/inventree/InvenTree/pull/9761
v410 -> 2025-10-12 : https://github.com/inventree/InvenTree/pull/9761
- Add supplier search and import API endpoints
- Add part parameter bulk create API endpoint

View File

@@ -0,0 +1,28 @@
# Generated by Django 4.2.25 on 2025-10-22 03:04
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("company", "0075_company_tax_id"),
("order", "0112_alter_salesorderlineitem_part"),
]
operations = [
migrations.AddField(
model_name="salesordershipment",
name="shipment_address",
field=models.ForeignKey(
blank=True,
help_text="Shipping address for this shipment",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="company.address",
verbose_name="Address",
),
),
]

View File

@@ -2137,6 +2137,7 @@ class SalesOrderShipmentReportContext(report.mixins.BaseReportContext):
Attributes:
allocations: QuerySet of SalesOrderAllocation objects
address: The shipping address for this shipment (or order)
order: The associated SalesOrder object
reference: Shipment reference string
shipment: The SalesOrderShipment object itself
@@ -2147,6 +2148,7 @@ class SalesOrderShipmentReportContext(report.mixins.BaseReportContext):
allocations: report.mixins.QuerySet['SalesOrderAllocation']
order: 'SalesOrder'
reference: str
address: 'Address'
shipment: 'SalesOrderShipment'
tracking_number: str
title: str
@@ -2168,6 +2170,7 @@ class SalesOrderShipment(
Attributes:
order: SalesOrder reference
shipment_address: Shipping address 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?)
@@ -2186,6 +2189,16 @@ class SalesOrderShipment(
unique_together = ['order', 'reference']
verbose_name = _('Sales Order Shipment')
def clean(self):
"""Custom clean method for the SalesOrderShipment class."""
super().clean()
if self.order and self.shipment_address:
if self.shipment_address.company != self.order.customer:
raise ValidationError({
'shipment_address': _('Shipment address must match the customer')
})
@staticmethod
def get_api_url():
"""Return the API URL associated with the SalesOrderShipment model."""
@@ -2196,6 +2209,7 @@ class SalesOrderShipment(
return {
'allocations': self.allocations,
'order': self.order,
'address': self.address,
'reference': self.reference,
'shipment': self,
'tracking_number': self.tracking_number,
@@ -2212,6 +2226,16 @@ class SalesOrderShipment(
help_text=_('Sales Order'),
)
shipment_address = models.ForeignKey(
Address,
on_delete=models.SET_NULL,
blank=True,
null=True,
verbose_name=_('Address'),
help_text=_('Shipping address for this shipment'),
related_name='+',
)
shipment_date = models.DateField(
null=True,
blank=True,
@@ -2267,6 +2291,14 @@ class SalesOrderShipment(
max_length=2000,
)
@property
def address(self) -> Address:
"""Return the shipping address for this shipment.
If no specific shipment address is assigned, return the address from the order.
"""
return self.shipment_address or self.order.address
def is_complete(self):
"""Return True if this shipment has already been completed."""
return self.shipment_date is not None

View File

@@ -1226,9 +1226,9 @@ class SalesOrderShipmentSerializer(
fields = [
'pk',
'order',
'order_detail',
'allocated_items',
'shipment_date',
'shipment_address',
'delivery_date',
'checked_by',
'reference',
@@ -1237,6 +1237,10 @@ class SalesOrderShipmentSerializer(
'barcode_hash',
'link',
'notes',
# Extra detail fields
'checked_by_detail',
'order_detail',
'shipment_address_detail',
]
@staticmethod
@@ -1253,6 +1257,13 @@ class SalesOrderShipmentSerializer(
read_only=True, allow_null=True, label=_('Allocated Items')
)
checked_by_detail = enable_filter(
UserSerializer(
source='checked_by', many=False, read_only=True, allow_null=True
),
True,
)
order_detail = enable_filter(
SalesOrderSerializer(
source='order', read_only=True, allow_null=True, many=False
@@ -1260,6 +1271,13 @@ class SalesOrderShipmentSerializer(
True,
)
shipment_address_detail = enable_filter(
AddressBriefSerializer(
source='shipment_address', many=False, read_only=True, allow_null=True
),
True,
)
class SalesOrderAllocationSerializer(
FilterableSerializerMixin, InvenTreeModelSerializer

View File

@@ -348,6 +348,58 @@ class SalesOrderTest(InvenTreeTestCase):
self.assertIsNone(self.shipment.delivery_date)
self.assertFalse(self.shipment.is_delivered())
def test_shipment_address(self):
"""Unit tests for SalesOrderShipment address field."""
shipment = SalesOrderShipment.objects.first()
self.assertIsNotNone(shipment)
# Set an address for the order
address_1 = Address.objects.create(
company=shipment.order.customer, title='Order Address', line1='123 Test St'
)
# Save the address against the order
shipment.order.address = address_1
shipment.order.clean()
shipment.order.save()
# By default, no address set
self.assertIsNone(shipment.shipment_address)
# But, the 'address' accessor defaults to the order address
self.assertIsNotNone(shipment.address)
self.assertEqual(shipment.address, shipment.order.address)
# Set a custom address for the shipment
address_2 = Address.objects.create(
company=shipment.order.customer,
title='Shipment Address',
line1='456 Another St',
)
shipment.shipment_address = address_2
shipment.clean()
shipment.save()
self.assertEqual(shipment.address, shipment.shipment_address)
self.assertNotEqual(shipment.address, shipment.order.address)
# Check that the shipment_address validation works
other_company = Company.objects.exclude(pk=shipment.order.customer.pk).first()
self.assertIsNotNone(other_company)
address_2.company = other_company
address_2.save()
shipment.refresh_from_db()
# This should error out (address company does not match customer)
with self.assertRaises(ValidationError) as err:
shipment.clean()
self.assertIn(
'Shipment address must match the customer', err.exception.messages
)
def test_overdue_notification(self):
"""Test overdue sales order notification."""
self.ensurePluginsLoaded()