From e5e5d0712cd60d701338a5afc465e6ddfd48648d Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 14 Jul 2026 02:32:41 +0000 Subject: [PATCH] Add "discount" support to order line items --- .../migrations/0121_add_line_item_discount.py | 119 ++++++++++++++++++ src/backend/InvenTree/order/models.py | 27 +++- src/backend/InvenTree/order/serializers.py | 14 ++- 3 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 src/backend/InvenTree/order/migrations/0121_add_line_item_discount.py diff --git a/src/backend/InvenTree/order/migrations/0121_add_line_item_discount.py b/src/backend/InvenTree/order/migrations/0121_add_line_item_discount.py new file mode 100644 index 0000000000..f5f9371e75 --- /dev/null +++ b/src/backend/InvenTree/order/migrations/0121_add_line_item_discount.py @@ -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", + ), + ), + ] diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index 9f8023b212..84650efd79 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -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, diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index 067d78a169..100abbf820 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -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', @@ -340,6 +343,8 @@ class AbstractExtraLineSerializer( quantity = serializers.FloatField() + discount = InvenTreeDecimalField(required=False) + price = InvenTreeMoneySerializer(allow_null=True) price_currency = InvenTreeCurrencySerializer() @@ -355,6 +360,7 @@ class AbstractExtraLineMeta: fields = [ 'pk', 'description', + 'discount', 'quantity', 'reference', 'notes', @@ -558,6 +564,7 @@ class PurchaseOrderLineItemSerializer( fields = AbstractLineItemSerializer.line_fields([ 'part', 'build_order', + 'discount', 'overdue', 'received', 'purchase_price', @@ -604,7 +611,10 @@ class PurchaseOrderLineItemSerializer( queryset = queryset.annotate( total_price=ExpressionWrapper( - F('purchase_price') * F('quantity'), output_field=models.DecimalField() + F('purchase_price') + * F('quantity') + * (1 - F('discount') / Decimal(100)), + output_field=models.DecimalField(), ) ) @@ -1208,6 +1218,7 @@ class SalesOrderLineItemSerializer( fields = AbstractLineItemSerializer.line_fields([ 'allocated', 'customer_detail', + 'discount', 'overdue', 'part', 'part_detail', @@ -2305,6 +2316,7 @@ class ReturnOrderLineItemSerializer( 'item', 'received_date', 'outcome', + 'discount', 'price', 'price_currency', # Filterable detail fields