[feature] Line discount (#12393)

* Add "discount" support to order line items

* Fix total_price annotation for ExtraLineItem

* Add regression tests

* Add frontend support

* Updated documentation

* Added docs

* Bump API version and CHANGELOG

* Remove 'total_price' annotation

- Can be achieved using in-memory python methods

* Apply fix for migration testing

* Revert "Apply fix for migration testing"

This reverts commit 8d9f2dce8c.

* Different approach

* Adjust playwright threshold
This commit is contained in:
Oliver 2026-07-20 20:42:54 +10:00 committed by GitHub
parent 44d8d0e266
commit 4c4c86e180
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 355 additions and 27 deletions

View File

@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- [#12393](https://github.com/inventree/InvenTree/pull/12393) adds "discount" attribute to order line items, allowing users to specify a discount for each line item on an order. The discount can be specified as either a percentage or a fixed amount, and is applied to the line item total when calculating the order total.
- [#12391](https://github.com/inventree/InvenTree/pull/12391) adds facility for bulk deleting line items against orders
- [#12388](https://github.com/inventree/InvenTree/pull/12388) adds uniqueness requirements options for the Parameter and ParameterTemplate models. This allows users to specify whether a parameter value should be unique for a given model type, or globally unique across all models.
- [#12310](https://github.com/inventree/InvenTree/pull/12310) adds the ability to disassemble (or break apart) assembled stock items into their component parts, based on the Bill of Materials (BOM) associated with the stock item. This allows users to easily break down assembled items into their constituent parts, which can be useful for inventory management and tracking purposes.

View File

@ -23,6 +23,36 @@ Throughout this documentation (and within InvenTree) the concepts of *cost* and
| Price | The theoretical amount of money required to pay for something. |
| Cost | The actual amount of money paid. |
## Line Items
Orders (Purchase Orders, Sales Orders, and Return Orders) are made up of *line items*, each linking a *Quantity* to a *Unit Price*. A line item's *Line Total* is calculated as follows:
```
Line Total = Quantity * Unit Price
```
An order's overall *Total Price* is calculated by summing the *Line Total* of every line item and [extra line item](#extra-line-items) associated with the order.
### Extra Line Items
*Extra Line Items* provide a way to add itemized costs to an order which are not tied to a specific part or stock item - for example freight charges, service fees, or other miscellaneous costs. Extra line items support the same *Quantity* and *Unit Price* fields as regular line items, and are included in the order's *Total Price* calculation.
### Line Item Discount
Line items - and their associated [extra line items](#extra-line-items) - support an optional *Discount* field, expressed as a percentage between 0% and 100%. This is available on:
- [Purchase Order](../purchasing/purchase_order.md#add-line-items) line items and [extra line items](../purchasing/purchase_order.md#extra-line-items)
- [Sales Order](../sales/sales_order.md#add-line-items) line items and [extra line items](../sales/sales_order.md#extra-line-items)
- [Return Order](../sales/return_order.md#line-items) line items and [extra line items](../sales/return_order.md#extra-line-items)
If specified, the discount is applied to the *Line Total*, using the following formula:
```
Line Total = Quantity * Unit Price * (1 - Discount / 100)
```
!!! info "Optional"
The discount percentage is optional, and defaults to 0% (no discount) if not specified.
## Currency Support

View File

@ -92,6 +92,12 @@ Once the "Add Line Item" form opens, select a supplier part in the list.
Fill out the rest of the form then click on <span class="badge inventree confirm">Submit</span>
!!! info "Auto Pricing"
Enable the *Auto Pricing* option to automatically calculate the line item's *Unit Price* from the [supplier part pricing data](../part/pricing.md#supplier-pricing), based on the line item quantity. While enabled, the *Unit Price* field is calculated automatically and cannot be edited manually. This option is disabled by default.
!!! info "Discount"
An optional [discount](../concepts/pricing.md#line-item-discount) percentage can be applied to each line item.
#### Upload File
It is possible to upload an exported purchase order from the supplier instead of manually entering each line item. To start the process, click on <span class="badge inventree confirm">{{ icon("upload") }} Upload File</span> button next to the <span class="badge inventree add">{{ icon("plus-circle") }} Add Line Item</span> button and follow the steps.
@ -99,6 +105,10 @@ It is possible to upload an exported purchase order from the supplier instead of
!!! info "Supported Formats"
This process only supports tabular data and the following formats are supported: CSV, TSV, XLS, XLSX, JSON and YAML
### Extra Line Items
While [line items](#add-line-items) must reference a particular supplier part, extra line items are available for any other itemized information that needs to be conveyed with the order - for example freight charges or service fees. Extra line items support an optional [discount](../concepts/pricing.md#line-item-discount) percentage, the same as regular line items.
## Issue Order
Once all the line items were added, click on the {{ icon("send", title="Issue") }} button on the main purchase order detail panel and confirm the order has been submitted.

View File

@ -117,9 +117,12 @@ Return Order line items can be added while the [status](#return-order-status-cod
!!! info "Serialized Stock Only"
Only stock items which are serialized can be selected for return from the customer
!!! info "Discount"
An optional [discount](../concepts/pricing.md#line-item-discount) percentage can be applied to each line item.
### Extra Line Items
While [line items](#line-items) must reference a particular stock item, extra line items are available for any other itemized information that needs to be conveyed with the order.
While [line items](#line-items) must reference a particular stock item, extra line items are available for any other itemized information that needs to be conveyed with the order - for example freight charges or service fees. Extra line items support an optional [discount](../concepts/pricing.md#line-item-discount) percentage, the same as regular line items.
## Return Order Reports

View File

@ -90,6 +90,13 @@ Once the "Add Line Item" form opens, select a part in the list.
Fill out the rest of the form then click on <span class="badge inventree confirm">Submit</span>
!!! info "Discount"
An optional [discount](../concepts/pricing.md#line-item-discount) percentage can be applied to each line item.
### Extra Line Items
While [line items](#add-line-items) must reference a particular part, extra line items are available for any other itemized information that needs to be conveyed with the order - for example freight charges or service fees. Extra line items support an optional [discount](../concepts/pricing.md#line-item-discount) percentage, the same as regular line items.
## Shipments
After all line items were added to the sales order, user needs to create one or more [shipments](#sales-order-shipments) in order to allocate stock for those parts.

View File

@ -1,11 +1,14 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 523
INVENTREE_API_VERSION = 524
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v524 -> 2026-07-20 : https://github.com/inventree/InvenTree/pull/12393
- Adds "discount" field to order line items (and extra line items)
v523 -> 2026-07-14 : https://github.com/inventree/InvenTree/pull/12391
- Adds "bulk delete" support for order line item API endpoints (PurchaseOrder / SalesOrder / ReturnOrder / TransferOrder)
- Adds "bulk delete" support for order extra line item API endpoints

View File

@ -3,6 +3,7 @@
from collections.abc import Iterable
from typing import Any, Optional
from django.apps import apps as global_apps
from django.core.exceptions import ObjectDoesNotExist
from django.db import models
from django.utils.encoding import force_str
@ -117,11 +118,23 @@ class InvenTreeCustomStatusModelField(models.PositiveIntegerField):
return name, path, args, kwargs
def contribute_to_class(self, cls, name):
"""Add the _custom_key field to the model."""
"""Add the _custom_key field to the model.
Only auto-add the companion field for the 'live' app registry. Any other
registry (migration state, or the throwaway model registries that some
backends - e.g. SQLite's table-rebuild routine - construct internally)
either already has the field explicitly declared, or will get it via a
separate migration operation. Auto-adding it there too would create a
duplicate field on the model.
"""
cls._meta.supports_custom_status = True
if not hasattr(self, '_custom_key_field') and not hasattr(
cls, f'{name}_custom_key'
is_live_model = cls._meta.apps is global_apps
if (
is_live_model
and not hasattr(self, '_custom_key_field')
and not hasattr(cls, f'{name}_custom_key')
):
self.add_field(cls, name)

View File

@ -744,7 +744,6 @@ class PurchaseOrderLineItemList(
'reference',
'SKU',
'IPN',
'total_price',
'target_date',
'order',
'status',

View File

@ -0,0 +1,119 @@
# Generated by Django 5.2.15 on 2026-07-14 02:18
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("order", "0120_purchaseorder_tags_returnorder_tags_salesorder_tags_and_more"),
]
operations = [
migrations.AddField(
model_name="purchaseorderextraline",
name="discount",
field=models.DecimalField(
decimal_places=2,
default=0,
help_text="Discount percentage applied to this line item (0-100)",
max_digits=5,
validators=[
django.core.validators.MinValueValidator(0),
django.core.validators.MaxValueValidator(100),
],
verbose_name="Discount",
),
),
migrations.AddField(
model_name="purchaseorderlineitem",
name="discount",
field=models.DecimalField(
decimal_places=2,
default=0,
help_text="Discount percentage applied to this line item (0-100)",
max_digits=5,
validators=[
django.core.validators.MinValueValidator(0),
django.core.validators.MaxValueValidator(100),
],
verbose_name="Discount",
),
),
migrations.AddField(
model_name="returnorderextraline",
name="discount",
field=models.DecimalField(
decimal_places=2,
default=0,
help_text="Discount percentage applied to this line item (0-100)",
max_digits=5,
validators=[
django.core.validators.MinValueValidator(0),
django.core.validators.MaxValueValidator(100),
],
verbose_name="Discount",
),
),
migrations.AddField(
model_name="returnorderlineitem",
name="discount",
field=models.DecimalField(
decimal_places=2,
default=0,
help_text="Discount percentage applied to this line item (0-100)",
max_digits=5,
validators=[
django.core.validators.MinValueValidator(0),
django.core.validators.MaxValueValidator(100),
],
verbose_name="Discount",
),
),
migrations.AddField(
model_name="salesorderextraline",
name="discount",
field=models.DecimalField(
decimal_places=2,
default=0,
help_text="Discount percentage applied to this line item (0-100)",
max_digits=5,
validators=[
django.core.validators.MinValueValidator(0),
django.core.validators.MaxValueValidator(100),
],
verbose_name="Discount",
),
),
migrations.AddField(
model_name="salesorderlineitem",
name="discount",
field=models.DecimalField(
decimal_places=2,
default=0,
help_text="Discount percentage applied to this line item (0-100)",
max_digits=5,
validators=[
django.core.validators.MinValueValidator(0),
django.core.validators.MaxValueValidator(100),
],
verbose_name="Discount",
),
),
migrations.AddField(
model_name="transferorderlineitem",
name="discount",
field=models.DecimalField(
decimal_places=2,
default=0,
help_text="Discount percentage applied to this line item (0-100)",
max_digits=5,
validators=[
django.core.validators.MinValueValidator(0),
django.core.validators.MaxValueValidator(100),
],
verbose_name="Discount",
),
),
]

View File

@ -5,7 +5,7 @@ from typing import Any, Optional, TypedDict
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models, transaction
from django.db.models import F, Q, QuerySet, Sum
from django.db.models.functions import Coalesce
@ -157,7 +157,11 @@ class TotalPriceMixin(models.Model):
continue
try:
total += line.quantity * convert_money(line.price, target_currency)
total += (
line.quantity
* convert_money(line.price, target_currency)
* (1 - line.discount / 100)
)
except MissingRate:
log_error('order.calculate_total_price')
logger.exception("Missing exchange rate for '%s'", target_currency)
@ -171,7 +175,11 @@ class TotalPriceMixin(models.Model):
continue
try:
total += line.quantity * convert_money(line.price, target_currency)
total += (
line.quantity
* convert_money(line.price, target_currency)
* (1 - line.discount / 100)
)
except MissingRate:
# Record the error, try to press on
@ -2109,11 +2117,20 @@ class OrderLineItem(InvenTree.models.InvenTreeMetadataModel):
validators=[MinValueValidator(0)],
)
discount = models.DecimalField(
verbose_name=_('Discount'),
help_text=_('Discount percentage applied to this line item (0-100)'),
default=0,
max_digits=5,
decimal_places=2,
validators=[MinValueValidator(0), MaxValueValidator(100)],
)
@property
def total_line_price(self):
"""Return the total price for this line item."""
"""Return the total price for this line item, after any discount is applied."""
if self.price:
return self.quantity * self.price
return self.quantity * self.price * (1 - self.discount / 100)
line = models.CharField(
max_length=20,

View File

@ -305,6 +305,8 @@ class AbstractLineItemSerializer(FilterableSerializerMixin, serializers.Serializ
required=False, allow_null=True, label=_('Target Date')
)
discount = InvenTreeDecimalField(required=False)
project_code_label = common.filters.enable_project_label_filter()
project_code_detail = common.filters.enable_project_code_filter()
@ -322,6 +324,7 @@ class AbstractExtraLineSerializer(
'pk',
'line',
'description',
'discount',
'link',
'notes',
'order',
@ -331,6 +334,7 @@ class AbstractExtraLineSerializer(
'quantity',
'reference',
'target_date',
'total_price',
# Filterable detail fields
'order_detail',
'project_code_label',
@ -340,10 +344,16 @@ class AbstractExtraLineSerializer(
quantity = serializers.FloatField()
discount = InvenTreeDecimalField(required=False)
price = InvenTreeMoneySerializer(allow_null=True)
price_currency = InvenTreeCurrencySerializer()
total_price = InvenTreeMoneySerializer(
source='total_line_price', allow_null=True, read_only=True
)
project_code_label = common.filters.enable_project_label_filter()
project_code_detail = common.filters.enable_project_code_filter()
@ -355,6 +365,7 @@ class AbstractExtraLineMeta:
fields = [
'pk',
'description',
'discount',
'quantity',
'reference',
'notes',
@ -363,6 +374,7 @@ class AbstractExtraLineMeta:
'order_detail',
'price',
'price_currency',
'total_price',
'link',
]
@ -558,6 +570,7 @@ class PurchaseOrderLineItemSerializer(
fields = AbstractLineItemSerializer.line_fields([
'part',
'build_order',
'discount',
'overdue',
'received',
'purchase_price',
@ -586,7 +599,6 @@ class PurchaseOrderLineItemSerializer(
def annotate_queryset(queryset):
"""Add some extra annotations to this queryset.
- "total_price" = purchase_price * quantity
- "overdue" status (boolean field)
"""
queryset = queryset.prefetch_related(
@ -602,12 +614,6 @@ class PurchaseOrderLineItemSerializer(
'part__manufacturer_part__manufacturer',
)
queryset = queryset.annotate(
total_price=ExpressionWrapper(
F('purchase_price') * F('quantity'), output_field=models.DecimalField()
)
)
queryset = queryset.annotate(
overdue=Case(
When(
@ -648,7 +654,9 @@ class PurchaseOrderLineItemSerializer(
overdue = serializers.BooleanField(read_only=True, allow_null=True)
total_price = serializers.FloatField(read_only=True)
total_price = InvenTreeMoneySerializer(
source='total_line_price', allow_null=True, read_only=True
)
part_detail = OptionalField(
serializer_class=PartBriefSerializer,
@ -1234,6 +1242,7 @@ class SalesOrderLineItemSerializer(
fields = AbstractLineItemSerializer.line_fields([
'allocated',
'customer_detail',
'discount',
'overdue',
'part',
'part_detail',
@ -2331,6 +2340,7 @@ class ReturnOrderLineItemSerializer(
'item',
'received_date',
'outcome',
'discount',
'price',
'price_currency',
# Filterable detail fields

View File

@ -3579,6 +3579,79 @@ class ReturnOrderLineItemTests(InvenTreeAPITestCase):
self.assertEqual(models.ReturnOrderExtraLine.objects.count(), n - 2)
class ExtraLineTotalPriceTest(InvenTreeAPITestCase):
"""Unit tests for the 'total_price' field on ExtraLine API endpoints.
Covers PurchaseOrderExtraLine, SalesOrderExtraLine and ReturnOrderExtraLine,
which all share the same 'total_price' field via AbstractExtraLineSerializer.
"""
fixtures = [
'category',
'part',
'company',
'location',
'supplier_part',
'stock',
'order',
'sales_order',
'return_order',
]
roles = ['purchase_order.change', 'sales_order.change', 'return_order.change']
def check_total_price(
self, order, extra_line_model, list_url_name, detail_url_name
):
"""Create an ExtraLine with a known price/quantity/discount, and check total_price."""
line = extra_line_model.objects.create(
order=order, quantity=5, price=Money(10, 'USD'), discount=20
)
# 5 * 10 = 50, less 20% discount = 40
expected = 40
# List endpoint
response = self.get(
reverse(list_url_name), {'order': order.pk}, expected_code=200
)
result = next(r for r in response.data if r['pk'] == line.pk)
self.assertEqual(float(result['total_price']), expected)
# Detail endpoint
response = self.get(
reverse(detail_url_name, kwargs={'pk': line.pk}), expected_code=200
)
self.assertEqual(float(response.data['total_price']), expected)
def test_po_extra_line_total_price(self):
"""Check 'total_price' for a PurchaseOrderExtraLine."""
self.check_total_price(
models.PurchaseOrder.objects.get(pk=1),
models.PurchaseOrderExtraLine,
'api-po-extra-line-list',
'api-po-extra-line-detail',
)
def test_so_extra_line_total_price(self):
"""Check 'total_price' for a SalesOrderExtraLine."""
self.check_total_price(
models.SalesOrder.objects.get(pk=1),
models.SalesOrderExtraLine,
'api-so-extra-line-list',
'api-so-extra-line-detail',
)
def test_return_order_extra_line_total_price(self):
"""Check 'total_price' for a ReturnOrderExtraLine."""
self.check_total_price(
models.ReturnOrder.objects.get(pk=1),
models.ReturnOrderExtraLine,
'api-return-order-extra-line-list',
'api-return-order-extra-line-detail',
)
class TransferOrderTest(OrderTest):
"""Tests for the TransferOrder API."""

View File

@ -473,6 +473,22 @@ export function DecimalColumn(props: TableColumn): TableColumn {
};
}
export function PercentageColumn(props: TableColumn): TableColumn {
return {
sortable: true,
render: (record: any) => {
const value = resolveItem(record, props.accessor ?? '');
if (value == null || value === 0) {
return '-';
}
return `${formatDecimal(value)}%`;
},
...props
};
}
export function DescriptionColumn(props: TableColumnProps): TableColumn {
return {
accessor: 'description',

View File

@ -91,6 +91,7 @@ export function extraLineItemFields(): ApiFormFieldSet {
quantity: {},
price: {},
price_currency: {},
discount: {},
project_code: ProjectCodeField(),
notes: {},
link: {}

View File

@ -186,6 +186,7 @@ export function usePurchaseOrderLineItemFields({
value: purchasePriceCurrency,
onValueChange: setPurchasePriceCurrency
},
discount: {},
auto_pricing: {
default: create !== false,
value: autoPricing,

View File

@ -133,6 +133,7 @@ export function useReturnOrderLineItemFields({
},
price: {},
price_currency: {},
discount: {},
project_code: ProjectCodeField(),
target_date: {},
notes: {},

View File

@ -197,6 +197,7 @@ export function useSalesOrderLineItemFields({
value: partCurrency,
onValueChange: setPartCurrency
},
discount: {},
project_code: ProjectCodeField(),
target_date: {},
notes: {},

View File

@ -19,6 +19,7 @@ import {
LineItemColumn,
LinkColumn,
NoteColumn,
PercentageColumn,
ProjectCodeColumn
} from '../../components/tables/ColumnRenderers';
import { InvenTreeTable } from '../../components/tables/InvenTreeTable';
@ -69,13 +70,17 @@ export default function ExtraLineItemTable({
currency: record.price_currency
})
},
PercentageColumn({
accessor: 'discount',
title: t`Discount`,
defaultVisible: false
}),
{
accessor: 'total_price',
title: t`Total Price`,
render: (record: any) =>
formatCurrency(record.price, {
currency: record.price_currency,
multiplier: record.quantity
formatCurrency(record.total_price, {
currency: record.price_currency
})
},
ProjectCodeColumn({}),

View File

@ -30,6 +30,7 @@ import {
LocationColumn,
NoteColumn,
PartColumn,
PercentageColumn,
ProjectCodeColumn,
ReferenceColumn,
TargetDateColumn
@ -254,13 +255,17 @@ export function PurchaseOrderLineItemTable({
accessor: 'purchase_price',
title: t`Unit Price`
}),
PercentageColumn({
accessor: 'discount',
title: t`Discount`,
defaultVisible: false
}),
{
accessor: 'total_price',
title: t`Total Price`,
render: (record: any) =>
formatCurrency(record.purchase_price, {
currency: record.purchase_price_currency,
multiplier: record.quantity
formatCurrency(record.total_price, {
currency: record.purchase_price_currency
})
},
TargetDateColumn({}),

View File

@ -23,6 +23,7 @@ import {
LinkColumn,
NoteColumn,
PartColumn,
PercentageColumn,
ProjectCodeColumn,
ReferenceColumn,
StatusColumn,
@ -148,6 +149,11 @@ export default function ReturnOrderLineItemTable({
render: (record: any) =>
formatCurrency(record.price, { currency: record.price_currency })
},
PercentageColumn({
accessor: 'discount',
title: t`Discount`,
defaultVisible: false
}),
DateColumn({
accessor: 'target_date',
title: t`Target Date`

View File

@ -36,6 +36,7 @@ import {
IPNColumn,
LineItemColumn,
LinkColumn,
PercentageColumn,
ProjectCodeColumn,
ReferenceColumn,
RenderPartColumn,
@ -121,13 +122,18 @@ export default function SalesOrderLineItemTable({
currency: record.sale_price_currency
})
},
PercentageColumn({
accessor: 'discount',
title: t`Discount`,
defaultVisible: false
}),
{
accessor: 'total_price',
title: t`Total Price`,
render: (record: any) =>
formatCurrency(record.sale_price, {
currency: record.sale_price_currency,
multiplier: record.quantity
multiplier: record.quantity * (1 - (record.discount ?? 0) / 100)
})
},
DateColumn({

View File

@ -157,7 +157,7 @@ test('Login - Cold vs Warm vs Hot Load', async ({ page }) => {
// Note: Vite server in dev mode is significantly slower than production build
const COLD_MS_THRESHOLD: number = 5000;
const WARM_MS_THRESHOLD: number = 4000;
const HOT_MS_THRESHOLD: number = 3000;
const HOT_MS_THRESHOLD: number = 3500;
const coldMs = await loginAndMeasure(page);

View File

@ -1896,6 +1896,7 @@ def setup_test(
c,
filename=template_dir.joinpath('inventree_data.json'),
clear=True,
ignore_nonexistent=True,
verbose=verbose,
)