Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ba32138
Allow 'company' field of Address model to be null
SchrodingersGat May 31, 2026
2384a99
Add API filtering
SchrodingersGat May 31, 2026
bb39b19
Migrate address fields for order models
SchrodingersGat May 31, 2026
2b917d0
Adjust UI forms
SchrodingersGat May 31, 2026
cb4113b
Refactor address table
SchrodingersGat May 31, 2026
3b77c41
Admin table for internal addresses
SchrodingersGat May 31, 2026
97caa20
New unit tests for order addresses
SchrodingersGat May 31, 2026
a92e2f7
Adjust admin interface
SchrodingersGat May 31, 2026
28ef786
Documentation updates
SchrodingersGat May 31, 2026
6f4312d
Tweak text
SchrodingersGat May 31, 2026
8afae9d
Add fallback for primary address
SchrodingersGat May 31, 2026
801d244
Update CHANGELOG
SchrodingersGat May 31, 2026
22f94d3
lazy load address table
SchrodingersGat May 31, 2026
10b93d2
Display address on PurchaseOrderDetail page
SchrodingersGat May 31, 2026
296ee10
Bump API version
SchrodingersGat May 31, 2026
e1acec9
only staff users can manipulate internal addresses
SchrodingersGat May 31, 2026
755a724
Fix CHANGELOG
SchrodingersGat May 31, 2026
3b1ba84
Test for new API filter
SchrodingersGat May 31, 2026
4a7ba6a
docs fix
SchrodingersGat May 31, 2026
2730775
Adjust address fallback
SchrodingersGat May 31, 2026
27767bb
Merge branch 'master' into delivery-address
SchrodingersGat May 31, 2026
90fba96
Update migration
SchrodingersGat Jun 1, 2026
95b8202
Merge commit 'd38af61ba2fb3c87143da57ddd92bc4845f85a7c' into delivery…
SchrodingersGat Jun 1, 2026
2c8d511
Merge branch 'delivery-address' of github.com:SchrodingersGat/InvenTr…
SchrodingersGat Jun 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- [#9604](https://github.com/inventree/InvenTree/pull/9604) - refactors user API endpoint to be less ambiguous
- [#11893](https://github.com/inventree/InvenTree/pull/11893) bumps Node environment to version 24 LTS - this is only relevant if you build the frontend assets yourself
- [#9303](https://github.com/inventree/InvenTree/pull/9303) - Purchase Orders, Return Orders, and Transfer Orders now require an *internal* address (one not linked to any company) for their `address` field. Previously these orders accepted any company-linked address. Any existing orders with a company-linked address set will fail validation on next save and must have their address field cleared or updated to an internal address.
Comment thread
SchrodingersGat marked this conversation as resolved.
Outdated

### Added

- [#9303](https://github.com/inventree/InvenTree/pull/9303) - adds support for *internal addresses* — addresses that belong to your own organisation rather than to an external company. Internal addresses are managed from the Admin Center and are used as delivery addresses on Purchase Orders, Return Orders, and Transfer Orders. A new `?internal=true/false` filter has been added to the address list API endpoint to distinguish internal from company-linked addresses. If no explicit address is set on an order, the primary internal address is used as a fallback.
Comment thread
SchrodingersGat marked this conversation as resolved.
Outdated

### Changed

- [#12019](https://github.com/inventree/InvenTree/pull/12019) adds a "location" field to the StockCount API endpoint, allowing users to specify a location when performing a stock count. This field is optional, and if not provided, the stock count will be performed without changing the location of the stock item. If a location is provided, the stock item(s) will be moved to the specified location as part of the stock count operation.
- [#12011](https://github.com/inventree/InvenTree/pull/12011) adds a "creation_date" field to the StockItem API endpoint, allowing users to track when each stock item was created. This field is read-only and is automatically set to the current date and time when a new stock item is created.
- [#12000](https://github.com/inventree/InvenTree/pull/12000) adds support for auto-allocation of stock items against sales orders. This includes both backend and frontend changes, allowing users to trigger auto-allocation via the API or through the UI. The auto-allocation process will attempt to allocate available stock items to the sales order line items, based on the specified stock sorting and allocation rules.
Expand Down
33 changes: 32 additions & 1 deletion docs/docs/concepts/company.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ A *contact* can be assigned to orders, (such as [purchase orders](../purchasing/
### Addresses

A company can have multiple registered addresses for use with all types of orders.
An address is broken down to internationally recognised elements that are designed to allow for formatting an address according to user needs.
An address is broken down to internationally recognized elements that are designed to allow for formatting an address according to user needs.
Addresses are composed differently across the world, and InvenTree reflects this by splitting addresses into components:

| Field | Description |
Expand Down Expand Up @@ -97,10 +97,41 @@ Each company can have exactly one (1) primary address.
This address is the default shown on the company profile, and the one that is automatically suggested when creating an order.
Marking a new address as primary will remove the mark from the old primary address.

!!! info "Effective order address"
When an order ([Sales Order](../sales/sales_order.md)) has no explicit address assigned, InvenTree uses the customer's primary address as the effective delivery address. This fallback ensures that reports and shipping labels always have an address to render, even if none was explicitly chosen on the order.

#### Managing Addresses

Addresses can be accessed by the <span class='badge inventree nav main'>{{ icon("map-2") }} Addresses</span> navigation tab, from the company detail page.

Here, the addresses associated with the company are listed, and can be added, edited, or deleted.

{{ image("concepts/edit_address.png", "Edit Address") }}

### Internal Addresses

In addition to addresses linked to external companies, InvenTree supports **internal addresses** — addresses that belong to your own organization rather than to any external company.

Internal addresses are used when the delivery destination is one of your own sites rather than a customer or supplier location. Orders that use internal addresses include:

| Order Type | Address Meaning |
| --- | --- |
| [Purchase Order](../purchasing/purchase_order.md) | Optional delivery destination for incoming goods |
| [Return Order](../sales/return_order.md) | Optional location to which returned items are sent |
| [Transfer Order](../stock/transfer_order.md) | Optional destination for an internal stock transfer |

!!! info "Sales Orders use external addresses"
[Sales Orders](../sales/sales_order.md) are addressed to the *customer*, so they use addresses linked to the customer company, not internal addresses.

#### Managing Internal Addresses

Internal addresses are managed from the **Admin Center**, under the *Internal Addresses* panel. Only staff users can create, edit, or delete internal addresses.
Comment thread
SchrodingersGat marked this conversation as resolved.
Outdated

Each internal address uses the same fields as a company address (Title, Line 1, Line 2, Postal Code, City, Province, Country).

#### Primary Internal Address

One internal address can be marked as the **primary** address. This address is the default suggestion when assigning a delivery address to a new order. Marking a different address as primary will automatically remove the mark from the previous primary address.

!!! info "Effective order address"
When a Purchase Order, Return Order, or Transfer Order has no explicit address assigned, InvenTree falls back to the primary internal address as the effective delivery address. This ensures that reports and shipping documents always have an address to render. If no internal address exists at all, the effective address is blank.
17 changes: 15 additions & 2 deletions src/backend/InvenTree/company/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,16 +105,29 @@ class ContactDetail(RetrieveUpdateDestroyAPI):
serializer_class = ContactSerializer


class AddressFilter(FilterSet):
"""Custom API filters for the Address list endpoint."""

class Meta:
"""Metaclass options."""

model = Address
fields = ['company']

internal = rest_filters.BooleanFilter(
label=_('Internal Address'), field_name='company', lookup_expr='isnull'
)
Comment thread
SchrodingersGat marked this conversation as resolved.
Comment thread
SchrodingersGat marked this conversation as resolved.


class AddressList(DataExportViewMixin, ListCreateDestroyAPIView):
"""API endpoint for list view of Address model."""

queryset = Address.objects.all()
serializer_class = AddressSerializer
filterset_class = AddressFilter
Comment thread
SchrodingersGat marked this conversation as resolved.

filter_backends = SEARCH_ORDER_FILTER

filterset_fields = ['company']

ordering_fields = ['title']

ordering = 'title'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Generated by Django 5.2.14 on 2026-05-31 03:39

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("company", "0079_auto_20260212_1054"),
]

operations = [
migrations.AlterField(
model_name="address",
name="company",
field=models.ForeignKey(
blank=True,
help_text="Select company",
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="addresses",
to="company.company",
verbose_name="Company",
),
),
]
4 changes: 3 additions & 1 deletion src/backend/InvenTree/company/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ class Address(InvenTree.models.InvenTreeModel):
"""An address represents a physical location where the company is located. It is possible for a company to have multiple locations.

Attributes:
company: Company link for this address
company: Company link for this address (null if this is an *internal* address, not associated with a company)
title: Human-readable name for the address
primary: True if this is the company's primary address
line1: First line of address
Expand Down Expand Up @@ -403,6 +403,8 @@ def get_api_url():
on_delete=models.CASCADE,
verbose_name=_('Company'),
help_text=_('Select company'),
null=True,
blank=True,
)

title = models.CharField(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Generated by Django 5.2.14 on 2026-05-31 04:14

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("company", "0080_alter_address_company"),
("order", "0119_transferorderlineitem_line_int"),
]

operations = [
migrations.AlterField(
model_name="purchaseorder",
name="address",
field=models.ForeignKey(
blank=True,
help_text="Address for this order",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="company.address",
verbose_name="Address",
),
),
migrations.AlterField(
model_name="returnorder",
name="address",
field=models.ForeignKey(
blank=True,
help_text="Address for this order",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="company.address",
verbose_name="Address",
),
),
migrations.AlterField(
model_name="salesorder",
name="address",
field=models.ForeignKey(
blank=True,
help_text="Address for this order",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="company.address",
verbose_name="Address",
),
),
migrations.AlterField(
model_name="transferorder",
name="address",
field=models.ForeignKey(
blank=True,
help_text="Address for this order",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="company.address",
verbose_name="Address",
),
),
]
54 changes: 41 additions & 13 deletions src/backend/InvenTree/order/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,7 @@ class Order(

- PurchaseOrder
- SalesOrder
- ReturnOrder

Attributes:
reference: Unique order number / reference / code
Expand All @@ -328,6 +329,7 @@ class Order(
REQUIRE_RESPONSIBLE_SETTING = None
UNLOCK_SETTING = None
IMPORT_ID_FIELDS = ['reference']
INTERNAL_ADDRESS: bool = True

class Meta:
"""Metaclass options. Abstract ensures no database table is created."""
Expand Down Expand Up @@ -420,16 +422,29 @@ def clean(self):
'start_date': _('Start date must be before target date'),
})

# Check that the referenced 'address' matches the correct 'company'
if (
hasattr(self, 'company')
and self.company
and self.address
and (self.address.company != self.company)
):
raise ValidationError({
'address': _('Address does not match selected company')
})
self.clean_address()

def clean_address(self):
"""Check that the address field is valid."""
if self.INTERNAL_ADDRESS:
# Check that the address is an 'internal' address (i.e. not linked to a company)
if self.address and self.address.company:
raise ValidationError({
'address': _(
'Address must be an internal address (not linked to a company)'
)
})
else:
# Check that the referenced 'address' matches the correct 'company'
if (
hasattr(self, 'company')
and self.company
and self.address
and (self.address.company != self.company)
):
raise ValidationError({
'address': _('Address does not match selected company')
})

def clean_line_item(self, line):
"""Clean a line item for this order.
Expand Down Expand Up @@ -572,7 +587,7 @@ def is_overdue(self):
blank=True,
null=True,
verbose_name=_('Address'),
help_text=_('Company address for this order'),
help_text=_('Address for this order'),
related_name='+',
)

Expand All @@ -586,8 +601,18 @@ def company(self):

@property
def order_address(self):
"""Return the Address associated with this order."""
return self.address or self.company.primary_address
"""Return the Address associated with this order.

- If this is an 'internal' order (i.e. INTERNAL_ADDRESS = True), fall back to the primary internal address.
- Otherwise, we can fall back to the primary address of the associated company if no address is specified on the order itself.
"""
if self.INTERNAL_ADDRESS:
return (
self.address
or Address.objects.filter(company=None, primary=True).first()
)
else:
return self.address or self.company.primary_address

@classmethod
def get_status_class(cls):
Expand Down Expand Up @@ -1337,6 +1362,9 @@ class SalesOrder(TotalPriceMixin, Order):
STATUS_CLASS = SalesOrderStatus
UNLOCK_SETTING = 'SALESORDER_EDIT_COMPLETED_ORDERS'

# SalesOrder address must point to the external company (customer)
INTERNAL_ADDRESS = False

class Meta:
"""Model meta options."""

Expand Down
Loading
Loading