diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ca69f5ca9..9941037275 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) - 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) +- Adds optional shipping address against individual sales order shipments in [#10650](https://github.com/inventree/InvenTree/pull/10650) ### Changed diff --git a/docs/docs/sales/sales_order.md b/docs/docs/sales/sales_order.md index 0d5fba4a61..b85703c2a3 100644 --- a/docs/docs/sales/sales_order.md +++ b/docs/docs/sales/sales_order.md @@ -1,3 +1,4 @@ + --- 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. +### 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 Once the sales order page is loaded, click on {{ icon("plus-circle") }} New Sales Order 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 - 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*: @@ -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. +#### 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 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. + All these fields can be edited by the user: {{ image("order/edit_shipment.png", "Edit shipment") }} diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 92282890f3..36709464b8 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/src/backend/InvenTree/order/migrations/0113_salesordershipment_shipment_address.py b/src/backend/InvenTree/order/migrations/0113_salesordershipment_shipment_address.py new file mode 100644 index 0000000000..77270e98ec --- /dev/null +++ b/src/backend/InvenTree/order/migrations/0113_salesordershipment_shipment_address.py @@ -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", + ), + ), + ] diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index 1222d46a57..f1c4bb0c67 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -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 diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index 04866b74f0..9a4666bf44 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -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 diff --git a/src/backend/InvenTree/order/test_sales_order.py b/src/backend/InvenTree/order/test_sales_order.py index 789a9336e3..e922aac5ee 100644 --- a/src/backend/InvenTree/order/test_sales_order.py +++ b/src/backend/InvenTree/order/test_sales_order.py @@ -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() diff --git a/src/frontend/src/components/render/Company.tsx b/src/frontend/src/components/render/Company.tsx index 66906befbd..5c3ae5b7f0 100644 --- a/src/frontend/src/components/render/Company.tsx +++ b/src/frontend/src/components/render/Company.tsx @@ -1,5 +1,7 @@ import type { ReactNode } from 'react'; +import { Text } from '@mantine/core'; + import { ModelType } from '@lib/enums/ModelType'; import { getDetailUrl } from '@lib/functions/Navigation'; import { type InstanceRenderInterface, RenderInlineModel } from './Instance'; @@ -22,9 +24,19 @@ export function RenderAddress({ .join(', '); const primary: string = instance.title || text; - const secondary: string = instance.title ? text : ''; + const secondary: string = !!instance.title ? text : ''; - return ; + const suffix: ReactNode = instance.primary ? ( + Primary Address + ) : null; + + return ( + + ); } /** diff --git a/src/frontend/src/forms/SalesOrderForms.tsx b/src/frontend/src/forms/SalesOrderForms.tsx index 785c2bca53..ab4c123fa4 100644 --- a/src/frontend/src/forms/SalesOrderForms.tsx +++ b/src/frontend/src/forms/SalesOrderForms.tsx @@ -372,8 +372,10 @@ export function useSalesOrderAllocateSerialsFields({ } export function useSalesOrderShipmentFields({ + customerId, pending }: { + customerId: number; pending?: boolean; }): ApiFormFieldSet { return useMemo(() => { @@ -388,11 +390,18 @@ export function useSalesOrderShipmentFields({ delivery_date: { hidden: pending ?? true }, + shipment_address: { + placeholder: t`Leave blank to use the order address`, + filters: { + company: customerId, + ordering: '-primary' + } + }, tracking_number: {}, invoice_number: {}, link: {} }; - }, [pending]); + }, [customerId, pending]); } export function useSalesOrderShipmentCompleteFields({ diff --git a/src/frontend/src/pages/sales/ReturnOrderDetail.tsx b/src/frontend/src/pages/sales/ReturnOrderDetail.tsx index 5d732af4c3..2c644c4be1 100644 --- a/src/frontend/src/pages/sales/ReturnOrderDetail.tsx +++ b/src/frontend/src/pages/sales/ReturnOrderDetail.tsx @@ -1,5 +1,5 @@ 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 { type ReactNode, useMemo } from 'react'; import { useParams } from 'react-router-dom'; @@ -194,8 +194,12 @@ export default function ReturnOrderDetail() { name: 'address', label: t`Return Address`, icon: 'address', - hidden: !order.address_detail, - value_formatter: () => + value_formatter: () => + order.address_detail ? ( + + ) : ( + {t`Not specified`} + ) }, { type: 'text', diff --git a/src/frontend/src/pages/sales/SalesOrderDetail.tsx b/src/frontend/src/pages/sales/SalesOrderDetail.tsx index 1a85f05dfd..44debd0c5c 100644 --- a/src/frontend/src/pages/sales/SalesOrderDetail.tsx +++ b/src/frontend/src/pages/sales/SalesOrderDetail.tsx @@ -1,5 +1,5 @@ import { t } from '@lingui/core/macro'; -import { Accordion, Grid, Skeleton, Stack } from '@mantine/core'; +import { Accordion, Grid, Skeleton, Stack, Text } from '@mantine/core'; import { IconBookmark, IconInfoCircle, @@ -187,8 +187,12 @@ export default function SalesOrderDetail() { name: 'address', label: t`Shipping Address`, icon: 'address', - hidden: !order.address_detail, - value_formatter: () => + value_formatter: () => + order.address_detail ? ( + + ) : ( + {t`Not specified`} + ) }, { type: 'text', @@ -391,7 +395,12 @@ export default function SalesOrderDetail() { name: 'shipments', label: t`Shipments`, icon: , - content: + content: ( + + ) }, { name: 'allocations', diff --git a/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx b/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx index bef175b8a4..fbc696bb2b 100644 --- a/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx +++ b/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx @@ -1,5 +1,5 @@ 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 { useMemo } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; @@ -30,6 +30,7 @@ import AttachmentPanel from '../../components/panels/AttachmentPanel'; import NotesPanel from '../../components/panels/NotesPanel'; import type { PanelType } from '../../components/panels/Panel'; import { PanelGroup } from '../../components/panels/PanelGroup'; +import { RenderAddress } from '../../components/render/Company'; import { formatDate } from '../../defaults/formatters'; import { useSalesOrderShipmentCompleteFields, @@ -104,6 +105,18 @@ export default function SalesOrderShipmentDetail() { model_field: 'name', 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', name: 'customer_reference', @@ -119,16 +132,6 @@ export default function SalesOrderShipmentDetail() { label: t`Shipment Reference`, copy: true }, - { - type: 'text', - name: 'allocated_items', - icon: 'packages', - label: t`Allocated Items` - } - ]; - - // Top right: Shipment information - const tr: DetailsField[] = [ { type: 'text', name: 'tracking_number', @@ -144,6 +147,38 @@ export default function SalesOrderShipmentDetail() { icon: 'serial', value_formatter: () => 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 ? ( + + ) : ( + {t`Not specified`} + ) + } + ]; + + const br: DetailsField[] = [ + { + type: 'text', + name: 'allocated_items', + icon: 'packages', + label: t`Allocated Items` }, { type: 'text', @@ -160,14 +195,6 @@ export default function SalesOrderShipmentDetail() { icon: 'calendar', value_formatter: () => formatDate(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() { + + ); @@ -232,7 +261,8 @@ export default function SalesOrderShipmentDetail() { }, [isPending, shipment, detailsPanel]); const editShipmentFields = useSalesOrderShipmentFields({ - pending: isPending + pending: isPending, + customerId: shipment.order_detail?.customer }); const editShipment = useEditApiFormModal({ diff --git a/src/frontend/src/tables/sales/SalesOrderShipmentTable.tsx b/src/frontend/src/tables/sales/SalesOrderShipmentTable.tsx index 9af76eb86d..b590f7b795 100644 --- a/src/frontend/src/tables/sales/SalesOrderShipmentTable.tsx +++ b/src/frontend/src/tables/sales/SalesOrderShipmentTable.tsx @@ -33,8 +33,10 @@ import { DateColumn, LinkColumn } from '../ColumnRenderers'; import { InvenTreeTable } from '../InvenTreeTable'; export default function SalesOrderShipmentTable({ + customerId, orderId }: Readonly<{ + customerId: number; orderId: number; }>) { const user = useUserState(); @@ -43,9 +45,13 @@ export default function SalesOrderShipmentTable({ const [selectedShipment, setSelectedShipment] = useState({}); - const newShipmentFields = useSalesOrderShipmentFields({}); + const newShipmentFields = useSalesOrderShipmentFields({ + customerId: customerId + }); - const editShipmentFields = useSalesOrderShipmentFields({}); + const editShipmentFields = useSalesOrderShipmentFields({ + customerId: customerId + }); const completeShipmentFields = useSalesOrderShipmentCompleteFields({});