[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
23 changed files with 355 additions and 27 deletions
@@ -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
+16 -3
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)
-1
View File
@@ -744,7 +744,6 @@ class PurchaseOrderLineItemList(
'reference',
'SKU',
'IPN',
'total_price',
'target_date',
'order',
'status',
@@ -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",
),
),
]
+22 -5
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,
+18 -8
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
+73
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."""
@@ -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',
+1
View File
@@ -91,6 +91,7 @@ export function extraLineItemFields(): ApiFormFieldSet {
quantity: {},
price: {},
price_currency: {},
discount: {},
project_code: ProjectCodeField(),
notes: {},
link: {}
@@ -186,6 +186,7 @@ export function usePurchaseOrderLineItemFields({
value: purchasePriceCurrency,
onValueChange: setPurchasePriceCurrency
},
discount: {},
auto_pricing: {
default: create !== false,
value: autoPricing,
@@ -133,6 +133,7 @@ export function useReturnOrderLineItemFields({
},
price: {},
price_currency: {},
discount: {},
project_code: ProjectCodeField(),
target_date: {},
notes: {},
@@ -197,6 +197,7 @@ export function useSalesOrderLineItemFields({
value: partCurrency,
onValueChange: setPartCurrency
},
discount: {},
project_code: ProjectCodeField(),
target_date: {},
notes: {},
@@ -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({}),
@@ -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({}),
@@ -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`
@@ -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({
+1 -1
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);