mirror of
https://github.com/inventree/InvenTree.git
synced 2026-07-21 22:23:03 +00:00
[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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user