2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-10-30 12:45: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

@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added support for Redis ACL user-based authentication in [#10551](https://github.com/inventree/InvenTree/pull/10551) - Added support for Redis ACL user-based authentication in [#10551](https://github.com/inventree/InvenTree/pull/10551)
- Expose stock adjustment forms to the UI plugin context in [#10584](https://github.com/inventree/InvenTree/pull/10584) - Expose stock adjustment forms to the UI plugin context in [#10584](https://github.com/inventree/InvenTree/pull/10584)
- Allow stock adjustments for "in production" items in [#10600](https://github.com/inventree/InvenTree/pull/10600) - Allow stock adjustments for "in production" items in [#10600](https://github.com/inventree/InvenTree/pull/10600)
- Adds optional shipping address against individual sales order shipments in [#10650](https://github.com/inventree/InvenTree/pull/10650)
### Changed ### Changed

View File

@@ -1,3 +1,4 @@
--- ---
title: Sales Orders title: Sales Orders
--- ---
@@ -59,6 +60,10 @@ Sales Order Status supports [custom states](../concepts/custom_states.md).
The currency code can be specified for an individual sales order. If not specified, the default currency specified against the [customer](./customer.md) will be used. The currency code can be specified for an individual sales order. If not specified, the default currency specified against the [customer](./customer.md) will be used.
### Sales Order Address
A sales order can have a specific shipping address assigned to it. The shipping address can be selected from the list of addresses assigned to the [customer](./customer.md) which is linked to the sales order.
## Create a Sales Order ## Create a Sales Order
Once the sales order page is loaded, click on <span class="badge inventree add">{{ icon("plus-circle") }} New Sales Order</span> which opens the "Create Sales Order" form. Once the sales order page is loaded, click on <span class="badge inventree add">{{ icon("plus-circle") }} New Sales Order</span> which opens the "Create Sales Order" form.
@@ -148,7 +153,6 @@ By default, completed orders are not exported. These can be included by appendin
## Sales Order Shipments ## Sales Order Shipments
Shipments are used to track sales items when they are shipped to customers. Multiple shipments can be created against a [Sales Order](./sales_order.md), allowing line items to be sent to customers in multiple deliveries. Shipments are used to track sales items when they are shipped to customers. Multiple shipments can be created against a [Sales Order](./sales_order.md), allowing line items to be sent to customers in multiple deliveries.
On the main Sales Order detail page, the order shipments are split into two categories, *Pending Shipments* and *Completed Shipments*: On the main Sales Order detail page, the order shipments are split into two categories, *Pending Shipments* and *Completed Shipments*:
@@ -185,6 +189,10 @@ Each shipment provides the following data fields:
A unique number for the shipment, used to identify each shipment within a sales order. By default, this value starts at `1` for the first shipment (for each order) and automatically increments for each new shipment. A unique number for the shipment, used to identify each shipment within a sales order. By default, this value starts at `1` for the first shipment (for each order) and automatically increments for each new shipment.
#### Shipment Address
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.
#### 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.
@@ -197,6 +205,7 @@ An optional field used to store an invoice reference for the shipment.
An optional URL field which can be used to provide a link to an external URL. An optional URL field which can be used to provide a link to an external URL.
All these fields can be edited by the user: All these fields can be edited by the user:
{{ image("order/edit_shipment.png", "Edit shipment") }} {{ image("order/edit_shipment.png", "Edit shipment") }}

View File

@@ -1,12 +1,15 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # 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.""" """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 = """
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 - Add enums for all ordering fields in schema - no functional changes
v413 -> 2025-10-20 : https://github.com/inventree/InvenTree/pull/10624 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 v412 -> 2025-10-19 : https://github.com/inventree/InvenTree/pull/10549
- added a new query parameter for the PartsList api: price_breaks (default: false) - added a new query parameter for the PartsList api: price_breaks (default: false)
v411 -> 2025-06-19 : https://github.com/inventree/InvenTree/pull/10630 v411 -> 2025-10-19 : https://github.com/inventree/InvenTree/pull/10630
- Editorialy changes to machine api - no functional changes - 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 supplier search and import API endpoints
- Add part parameter bulk create API endpoint - 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: Attributes:
allocations: QuerySet of SalesOrderAllocation objects allocations: QuerySet of SalesOrderAllocation objects
address: The shipping address for this shipment (or order)
order: The associated SalesOrder object order: The associated SalesOrder object
reference: Shipment reference string reference: Shipment reference string
shipment: The SalesOrderShipment object itself shipment: The SalesOrderShipment object itself
@@ -2147,6 +2148,7 @@ class SalesOrderShipmentReportContext(report.mixins.BaseReportContext):
allocations: report.mixins.QuerySet['SalesOrderAllocation'] allocations: report.mixins.QuerySet['SalesOrderAllocation']
order: 'SalesOrder' order: 'SalesOrder'
reference: str reference: str
address: 'Address'
shipment: 'SalesOrderShipment' shipment: 'SalesOrderShipment'
tracking_number: str tracking_number: str
title: str title: str
@@ -2168,6 +2170,7 @@ class SalesOrderShipment(
Attributes: Attributes:
order: SalesOrder reference order: SalesOrder reference
shipment_address: Shipping address 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?)
@@ -2186,6 +2189,16 @@ class SalesOrderShipment(
unique_together = ['order', 'reference'] unique_together = ['order', 'reference']
verbose_name = _('Sales Order Shipment') 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 @staticmethod
def get_api_url(): def get_api_url():
"""Return the API URL associated with the SalesOrderShipment model.""" """Return the API URL associated with the SalesOrderShipment model."""
@@ -2196,6 +2209,7 @@ class SalesOrderShipment(
return { return {
'allocations': self.allocations, 'allocations': self.allocations,
'order': self.order, 'order': self.order,
'address': self.address,
'reference': self.reference, 'reference': self.reference,
'shipment': self, 'shipment': self,
'tracking_number': self.tracking_number, 'tracking_number': self.tracking_number,
@@ -2212,6 +2226,16 @@ class SalesOrderShipment(
help_text=_('Sales Order'), 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( shipment_date = models.DateField(
null=True, null=True,
blank=True, blank=True,
@@ -2267,6 +2291,14 @@ class SalesOrderShipment(
max_length=2000, 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): def is_complete(self):
"""Return True if this shipment has already been completed.""" """Return True if this shipment has already been completed."""
return self.shipment_date is not None return self.shipment_date is not None

View File

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

View File

@@ -348,6 +348,58 @@ class SalesOrderTest(InvenTreeTestCase):
self.assertIsNone(self.shipment.delivery_date) self.assertIsNone(self.shipment.delivery_date)
self.assertFalse(self.shipment.is_delivered()) 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): def test_overdue_notification(self):
"""Test overdue sales order notification.""" """Test overdue sales order notification."""
self.ensurePluginsLoaded() self.ensurePluginsLoaded()

View File

@@ -1,5 +1,7 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Text } from '@mantine/core';
import { ModelType } from '@lib/enums/ModelType'; import { ModelType } from '@lib/enums/ModelType';
import { getDetailUrl } from '@lib/functions/Navigation'; import { getDetailUrl } from '@lib/functions/Navigation';
import { type InstanceRenderInterface, RenderInlineModel } from './Instance'; import { type InstanceRenderInterface, RenderInlineModel } from './Instance';
@@ -22,9 +24,19 @@ export function RenderAddress({
.join(', '); .join(', ');
const primary: string = instance.title || text; const primary: string = instance.title || text;
const secondary: string = instance.title ? text : ''; const secondary: string = !!instance.title ? text : '';
return <RenderInlineModel primary={primary} secondary={secondary} />; const suffix: ReactNode = instance.primary ? (
<Text size='xs'>Primary Address</Text>
) : null;
return (
<RenderInlineModel
primary={primary}
secondary={secondary}
suffix={suffix}
/>
);
} }
/** /**

View File

@@ -372,8 +372,10 @@ export function useSalesOrderAllocateSerialsFields({
} }
export function useSalesOrderShipmentFields({ export function useSalesOrderShipmentFields({
customerId,
pending pending
}: { }: {
customerId: number;
pending?: boolean; pending?: boolean;
}): ApiFormFieldSet { }): ApiFormFieldSet {
return useMemo(() => { return useMemo(() => {
@@ -388,11 +390,18 @@ export function useSalesOrderShipmentFields({
delivery_date: { delivery_date: {
hidden: pending ?? true hidden: pending ?? true
}, },
shipment_address: {
placeholder: t`Leave blank to use the order address`,
filters: {
company: customerId,
ordering: '-primary'
}
},
tracking_number: {}, tracking_number: {},
invoice_number: {}, invoice_number: {},
link: {} link: {}
}; };
}, [pending]); }, [customerId, pending]);
} }
export function useSalesOrderShipmentCompleteFields({ export function useSalesOrderShipmentCompleteFields({

View File

@@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { Accordion, Grid, Skeleton, Stack } from '@mantine/core'; import { Accordion, Grid, Skeleton, Stack, Text } from '@mantine/core';
import { IconInfoCircle, IconList } from '@tabler/icons-react'; import { IconInfoCircle, IconList } from '@tabler/icons-react';
import { type ReactNode, useMemo } from 'react'; import { type ReactNode, useMemo } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
@@ -194,8 +194,12 @@ export default function ReturnOrderDetail() {
name: 'address', name: 'address',
label: t`Return Address`, label: t`Return Address`,
icon: 'address', icon: 'address',
hidden: !order.address_detail, value_formatter: () =>
value_formatter: () => <RenderAddress instance={order.address_detail} /> order.address_detail ? (
<RenderAddress instance={order.address_detail} />
) : (
<Text size='sm' c='red'>{t`Not specified`}</Text>
)
}, },
{ {
type: 'text', type: 'text',

View File

@@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { Accordion, Grid, Skeleton, Stack } from '@mantine/core'; import { Accordion, Grid, Skeleton, Stack, Text } from '@mantine/core';
import { import {
IconBookmark, IconBookmark,
IconInfoCircle, IconInfoCircle,
@@ -187,8 +187,12 @@ export default function SalesOrderDetail() {
name: 'address', name: 'address',
label: t`Shipping Address`, label: t`Shipping Address`,
icon: 'address', icon: 'address',
hidden: !order.address_detail, value_formatter: () =>
value_formatter: () => <RenderAddress instance={order.address_detail} /> order.address_detail ? (
<RenderAddress instance={order.address_detail} />
) : (
<Text size='sm' c='red'>{t`Not specified`}</Text>
)
}, },
{ {
type: 'text', type: 'text',
@@ -391,7 +395,12 @@ export default function SalesOrderDetail() {
name: 'shipments', name: 'shipments',
label: t`Shipments`, label: t`Shipments`,
icon: <IconTruckDelivery />, icon: <IconTruckDelivery />,
content: <SalesOrderShipmentTable orderId={order.pk} /> content: (
<SalesOrderShipmentTable
orderId={order.pk}
customerId={order.customer}
/>
)
}, },
{ {
name: 'allocations', name: 'allocations',

View File

@@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack } from '@mantine/core'; import { Grid, Skeleton, Stack, Text } from '@mantine/core';
import { IconBookmark, IconInfoCircle } from '@tabler/icons-react'; import { IconBookmark, IconInfoCircle } from '@tabler/icons-react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
@@ -30,6 +30,7 @@ import AttachmentPanel from '../../components/panels/AttachmentPanel';
import NotesPanel from '../../components/panels/NotesPanel'; import NotesPanel from '../../components/panels/NotesPanel';
import type { PanelType } from '../../components/panels/Panel'; import type { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup'; import { PanelGroup } from '../../components/panels/PanelGroup';
import { RenderAddress } from '../../components/render/Company';
import { formatDate } from '../../defaults/formatters'; import { formatDate } from '../../defaults/formatters';
import { import {
useSalesOrderShipmentCompleteFields, useSalesOrderShipmentCompleteFields,
@@ -104,6 +105,18 @@ export default function SalesOrderShipmentDetail() {
model_field: 'name', model_field: 'name',
hidden: !data.customer hidden: !data.customer
}, },
{
type: 'link',
external: true,
name: 'link',
label: t`Link`,
copy: true,
hidden: !shipment.link
}
];
// Top right: Shipment information
const tr: DetailsField[] = [
{ {
type: 'text', type: 'text',
name: 'customer_reference', name: 'customer_reference',
@@ -119,16 +132,6 @@ export default function SalesOrderShipmentDetail() {
label: t`Shipment Reference`, label: t`Shipment Reference`,
copy: true copy: true
}, },
{
type: 'text',
name: 'allocated_items',
icon: 'packages',
label: t`Allocated Items`
}
];
// Top right: Shipment information
const tr: DetailsField[] = [
{ {
type: 'text', type: 'text',
name: 'tracking_number', name: 'tracking_number',
@@ -144,6 +147,38 @@ export default function SalesOrderShipmentDetail() {
icon: 'serial', icon: 'serial',
value_formatter: () => shipment.invoice_number || '---', value_formatter: () => shipment.invoice_number || '---',
copy: !!shipment.invoice_number copy: !!shipment.invoice_number
}
];
const address: any =
shipment.shipment_address_detail || shipment.order_detail?.address_detail;
const bl: DetailsField[] = [
{
type: 'text',
name: 'address',
label: t`Shipping Address`,
icon: 'address',
value_formatter: () =>
address ? (
<RenderAddress
instance={
shipment.shipment_address_detail ||
shipment.order_detail?.address_detail
}
/>
) : (
<Text size='sm' c='red'>{t`Not specified`}</Text>
)
}
];
const br: DetailsField[] = [
{
type: 'text',
name: 'allocated_items',
icon: 'packages',
label: t`Allocated Items`
}, },
{ {
type: 'text', type: 'text',
@@ -160,14 +195,6 @@ export default function SalesOrderShipmentDetail() {
icon: 'calendar', icon: 'calendar',
value_formatter: () => formatDate(shipment.delivery_date), value_formatter: () => formatDate(shipment.delivery_date),
hidden: !shipment.delivery_date hidden: !shipment.delivery_date
},
{
type: 'link',
external: true,
name: 'link',
label: t`Link`,
copy: true,
hidden: !shipment.link
} }
]; ];
@@ -192,6 +219,8 @@ export default function SalesOrderShipmentDetail() {
</Grid.Col> </Grid.Col>
</Grid> </Grid>
<DetailsTable fields={tr} item={data} /> <DetailsTable fields={tr} item={data} />
<DetailsTable fields={bl} item={data} />
<DetailsTable fields={br} item={data} />
</ItemDetailsGrid> </ItemDetailsGrid>
</> </>
); );
@@ -232,7 +261,8 @@ export default function SalesOrderShipmentDetail() {
}, [isPending, shipment, detailsPanel]); }, [isPending, shipment, detailsPanel]);
const editShipmentFields = useSalesOrderShipmentFields({ const editShipmentFields = useSalesOrderShipmentFields({
pending: isPending pending: isPending,
customerId: shipment.order_detail?.customer
}); });
const editShipment = useEditApiFormModal({ const editShipment = useEditApiFormModal({

View File

@@ -33,8 +33,10 @@ import { DateColumn, LinkColumn } from '../ColumnRenderers';
import { InvenTreeTable } from '../InvenTreeTable'; import { InvenTreeTable } from '../InvenTreeTable';
export default function SalesOrderShipmentTable({ export default function SalesOrderShipmentTable({
customerId,
orderId orderId
}: Readonly<{ }: Readonly<{
customerId: number;
orderId: number; orderId: number;
}>) { }>) {
const user = useUserState(); const user = useUserState();
@@ -43,9 +45,13 @@ export default function SalesOrderShipmentTable({
const [selectedShipment, setSelectedShipment] = useState<any>({}); const [selectedShipment, setSelectedShipment] = useState<any>({});
const newShipmentFields = useSalesOrderShipmentFields({}); const newShipmentFields = useSalesOrderShipmentFields({
customerId: customerId
});
const editShipmentFields = useSalesOrderShipmentFields({}); const editShipmentFields = useSalesOrderShipmentFields({
customerId: customerId
});
const completeShipmentFields = useSalesOrderShipmentCompleteFields({}); const completeShipmentFields = useSalesOrderShipmentCompleteFields({});