From 4b9777f27f06f8a178c315a187b796a61dd54979 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 12 Jul 2026 04:36:04 +0000 Subject: [PATCH 1/4] add empty pricing app --- CONTRIBUTING.md | 1 + src/backend/InvenTree/InvenTree/settings.py | 1 + src/backend/InvenTree/pricing/__init__.py | 0 src/backend/InvenTree/pricing/apps.py | 10 ++++++++++ src/backend/InvenTree/pricing/migrations/__init__.py | 0 src/backend/InvenTree/pricing/models.py | 4 ++++ tasks.py | 1 + 7 files changed, 17 insertions(+) create mode 100644 src/backend/InvenTree/pricing/__init__.py create mode 100644 src/backend/InvenTree/pricing/apps.py create mode 100644 src/backend/InvenTree/pricing/migrations/__init__.py create mode 100644 src/backend/InvenTree/pricing/models.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e9115615dc13..96d765eba703 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,6 +77,7 @@ The following Django apps are defined in `src/backend/InvenTree/`: | `machine/` | Support for external machines and devices | | `order/` | Purchase orders and sales orders | | `part/` | Parts catalogue and categories | +| `pricing/` | Pricing calculation and caching | | `stock/` | Stock items and locations | | `report/` | Report templates and generation | | `plugin/` | Plugin system | diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index 06ba9dfc6471..49eb0b956df4 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -321,6 +321,7 @@ 'machine.apps.MachineConfig', 'data_exporter.apps.DataExporterConfig', 'importer.apps.ImporterConfig', + 'pricing.apps.PricingConfig', 'web', 'generic', 'InvenTree.apps.InvenTreeConfig', # InvenTree app runs last diff --git a/src/backend/InvenTree/pricing/__init__.py b/src/backend/InvenTree/pricing/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/backend/InvenTree/pricing/apps.py b/src/backend/InvenTree/pricing/apps.py new file mode 100644 index 000000000000..91079d108a79 --- /dev/null +++ b/src/backend/InvenTree/pricing/apps.py @@ -0,0 +1,10 @@ +"""AppConfig for the 'pricing' app.""" + +from django.apps import AppConfig + + +class PricingConfig(AppConfig): + """AppConfig class for the 'pricing' app.""" + + default_auto_field = 'django.db.models.BigAutoField' + name = 'pricing' diff --git a/src/backend/InvenTree/pricing/migrations/__init__.py b/src/backend/InvenTree/pricing/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/backend/InvenTree/pricing/models.py b/src/backend/InvenTree/pricing/models.py new file mode 100644 index 000000000000..e82317862dd9 --- /dev/null +++ b/src/backend/InvenTree/pricing/models.py @@ -0,0 +1,4 @@ +"""Database models for the 'pricing' app. + +No models are defined here yet. +""" diff --git a/tasks.py b/tasks.py index 6e086e2c3007..692231cee3b3 100644 --- a/tasks.py +++ b/tasks.py @@ -326,6 +326,7 @@ def builtin_apps(): 'machine', 'order', 'part', + 'pricing', 'report', 'stock', 'users', From a9c0bd13aaaa07894300222b98bc3e352ac7191e Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 1 Aug 2026 04:46:10 +0000 Subject: [PATCH 2/4] Add new "pricing" ruleset --- src/backend/InvenTree/users/ruleset.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/backend/InvenTree/users/ruleset.py b/src/backend/InvenTree/users/ruleset.py index 3787eb89b9b7..62a7b9fa9d5d 100644 --- a/src/backend/InvenTree/users/ruleset.py +++ b/src/backend/InvenTree/users/ruleset.py @@ -12,6 +12,7 @@ class RuleSetEnum(StringEnum): ADMIN = 'admin' PART_CATEGORY = 'part_category' PART = 'part' + PRICING = 'pricing' BOM = 'bom' STOCK_LOCATION = 'stock_location' STOCK = 'stock' @@ -28,6 +29,7 @@ class RuleSetEnum(StringEnum): (RuleSetEnum.ADMIN, _('Admin')), (RuleSetEnum.PART_CATEGORY, _('Part Categories')), (RuleSetEnum.PART, _('Parts')), + (RuleSetEnum.PRICING, _('Part Pricing')), (RuleSetEnum.BOM, _('Bills of Material')), (RuleSetEnum.STOCK_LOCATION, _('Stock Locations')), (RuleSetEnum.STOCK, _('Stock Items')), @@ -128,6 +130,7 @@ def get_ruleset_models() -> dict: 'company_supplierpart', 'company_manufacturerpart', ], + RuleSetEnum.PRICING: [], RuleSetEnum.STOCK_LOCATION: ['stock_stocklocation', 'stock_stocklocationtype'], RuleSetEnum.STOCK: [ 'stock_stockitem', From 5c8aebcd0bbdbe84efa55d7da4bc3b1dedc0a274 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 1 Aug 2026 04:47:50 +0000 Subject: [PATCH 3/4] Add placeholder files --- src/backend/InvenTree/pricing/admin.py | 1 + src/backend/InvenTree/pricing/api.py | 1 + src/backend/InvenTree/pricing/serializers.py | 1 + 3 files changed, 3 insertions(+) create mode 100644 src/backend/InvenTree/pricing/admin.py create mode 100644 src/backend/InvenTree/pricing/api.py create mode 100644 src/backend/InvenTree/pricing/serializers.py diff --git a/src/backend/InvenTree/pricing/admin.py b/src/backend/InvenTree/pricing/admin.py new file mode 100644 index 000000000000..6a80e719c641 --- /dev/null +++ b/src/backend/InvenTree/pricing/admin.py @@ -0,0 +1 @@ +"""Admin class definitions for the pricing app.""" diff --git a/src/backend/InvenTree/pricing/api.py b/src/backend/InvenTree/pricing/api.py new file mode 100644 index 000000000000..9071432a8592 --- /dev/null +++ b/src/backend/InvenTree/pricing/api.py @@ -0,0 +1 @@ +"""API endpoints for the pricing app.""" diff --git a/src/backend/InvenTree/pricing/serializers.py b/src/backend/InvenTree/pricing/serializers.py new file mode 100644 index 000000000000..66d8a278f8fe --- /dev/null +++ b/src/backend/InvenTree/pricing/serializers.py @@ -0,0 +1 @@ +"""DRF API serializers for the pricing app.""" From caa37da04f4af2b27d5f665849bbbe68c6a8b2ab Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 1 Aug 2026 05:19:44 +0000 Subject: [PATCH 4/4] Add StockItemCost model --- src/backend/InvenTree/pricing/admin.py | 15 ++ .../pricing/migrations/0001_initial.py | 173 ++++++++++++++++++ src/backend/InvenTree/pricing/models.py | 125 ++++++++++++- src/backend/InvenTree/pricing/status_codes.py | 23 +++ src/backend/InvenTree/users/oauth2_scopes.py | 1 + src/backend/InvenTree/users/ruleset.py | 2 +- 6 files changed, 335 insertions(+), 4 deletions(-) create mode 100644 src/backend/InvenTree/pricing/migrations/0001_initial.py create mode 100644 src/backend/InvenTree/pricing/status_codes.py diff --git a/src/backend/InvenTree/pricing/admin.py b/src/backend/InvenTree/pricing/admin.py index 6a80e719c641..62467bb83905 100644 --- a/src/backend/InvenTree/pricing/admin.py +++ b/src/backend/InvenTree/pricing/admin.py @@ -1 +1,16 @@ """Admin class definitions for the pricing app.""" + +from django.contrib import admin + +from .models import StockItemCost + + +@admin.register(StockItemCost) +class StockItemCostAdmin(admin.ModelAdmin): + """Admin class for the StockItemCost model.""" + + list_display = ['stock_item', 'part', 'cost_type', 'min_cost', 'max_cost', 'date'] + + list_filter = ['cost_type'] + + autocomplete_fields = ['stock_item', 'part', 'user'] diff --git a/src/backend/InvenTree/pricing/migrations/0001_initial.py b/src/backend/InvenTree/pricing/migrations/0001_initial.py new file mode 100644 index 000000000000..f5a5b9489715 --- /dev/null +++ b/src/backend/InvenTree/pricing/migrations/0001_initial.py @@ -0,0 +1,173 @@ +# Generated by Django 5.2.16 on 2026-08-01 05:10 + +import InvenTree.fields +import django.db.models.deletion +import djmoney.models.fields +import djmoney.models.validators +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("part", "0152_alter_partpricing_currency"), + ("stock", "0126_serial_number_concurrency_guard"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="StockItemCost", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "cost_type", + models.PositiveIntegerField( + choices=[ + (10, "Purchase"), + (20, "Landed"), + (30, "Manufacturing"), + (40, "Manual"), + (50, "System"), + ], + default=10, + help_text="Source of this cost entry", + verbose_name="Cost Type", + ), + ), + ( + "min_cost_currency", + djmoney.models.fields.CurrencyField( + choices=[], default="", editable=False, max_length=3, null=True + ), + ), + ( + "min_cost", + InvenTree.fields.InvenTreeModelMoneyField( + blank=True, + currency_choices=[], + decimal_places=6, + default_currency="", + help_text="Minimum estimated cost for this entry", + max_digits=19, + null=True, + validators=[djmoney.models.validators.MinMoneyValidator(0)], + verbose_name="Minimum Cost", + ), + ), + ( + "max_cost_currency", + djmoney.models.fields.CurrencyField( + choices=[], default="", editable=False, max_length=3, null=True + ), + ), + ( + "max_cost", + InvenTree.fields.InvenTreeModelMoneyField( + blank=True, + currency_choices=[], + decimal_places=6, + default_currency="", + help_text="Maximum estimated cost for this entry", + max_digits=19, + null=True, + validators=[djmoney.models.validators.MinMoneyValidator(0)], + verbose_name="Maximum Cost", + ), + ), + ( + "cost_currency", + djmoney.models.fields.CurrencyField( + choices=[], default="", editable=False, max_length=3, null=True + ), + ), + ( + "cost", + InvenTree.fields.InvenTreeModelMoneyField( + blank=True, + currency_choices=[], + decimal_places=6, + default_currency="", + help_text="Single point-value estimate for this entry", + max_digits=19, + null=True, + validators=[djmoney.models.validators.MinMoneyValidator(0)], + verbose_name="Cost", + ), + ), + ( + "date", + models.DateTimeField( + auto_now_add=True, + help_text="Date at which this cost entry was calculated", + verbose_name="Date", + ), + ), + ( + "source_data", + models.JSONField( + blank=True, + help_text="Source data used to calculate this cost entry", + null=True, + verbose_name="Source Data", + ), + ), + ( + "notes", + models.CharField( + blank=True, + help_text="Notes associated with this cost entry", + max_length=512, + verbose_name="Notes", + ), + ), + ( + "part", + models.ForeignKey( + editable=False, + help_text="Part associated with this cost entry", + on_delete=django.db.models.deletion.CASCADE, + related_name="stock_cost_entries", + to="part.part", + verbose_name="Part", + ), + ), + ( + "stock_item", + models.ForeignKey( + help_text="Stock item to which this cost entry applies", + on_delete=django.db.models.deletion.CASCADE, + related_name="cost_entries", + to="stock.stockitem", + verbose_name="Stock Item", + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + help_text="User associated with this cost calculation", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + verbose_name="User", + ), + ), + ], + options={ + "verbose_name": "Stock Item Cost", + "ordering": ["-date"], + }, + ), + ] diff --git a/src/backend/InvenTree/pricing/models.py b/src/backend/InvenTree/pricing/models.py index e82317862dd9..4d001a23a146 100644 --- a/src/backend/InvenTree/pricing/models.py +++ b/src/backend/InvenTree/pricing/models.py @@ -1,4 +1,123 @@ -"""Database models for the 'pricing' app. +"""Database models for the 'pricing' app.""" -No models are defined here yet. -""" +from django.contrib.auth import get_user_model +from django.db import models +from django.utils.translation import gettext_lazy as _ + +import InvenTree.fields + +from .status_codes import CostType + + +class StockItemCost(models.Model): + """Model representing a single landed-cost calculation for a StockItem. + + This is an append-only ledger - a new entry is created every time the cost + of a StockItem is (re)calculated, rather than updating a single record in place. + This preserves a full history of how the cost of an item has changed over time + (e.g. as purchase invoices, duties, or freight costs are reconciled). + + Attributes: + stock_item: The StockItem that this cost entry applies to + part: The Part associated with the linked StockItem (denormalized for query convenience) + cost_type: The type (source) of this cost entry + min_cost: The minimum estimated cost for this entry + max_cost: The maximum estimated cost for this entry + cost: A single point-value estimate for this entry (e.g. a weighted / representative cost) + date: Date at which this cost entry was calculated + user: The user associated with this cost calculation (nullable, e.g. for automated calculations) + source_data: JSON field capturing the source data used to calculate this cost + notes: Optional notes associated with this cost entry + """ + + class Meta: + """Meta options for the StockItemCost model.""" + + verbose_name = _('Stock Item Cost') + ordering = ['-date'] + + stock_item = models.ForeignKey( + 'stock.StockItem', + on_delete=models.CASCADE, + related_name='cost_entries', + verbose_name=_('Stock Item'), + help_text=_('Stock item to which this cost entry applies'), + ) + + part = models.ForeignKey( + 'part.Part', + on_delete=models.CASCADE, + related_name='stock_cost_entries', + editable=False, + verbose_name=_('Part'), + help_text=_('Part associated with this cost entry'), + ) + + cost_type = models.PositiveIntegerField( + default=CostType.PURCHASE.value, + choices=CostType.items(), + verbose_name=_('Cost Type'), + help_text=_('Source of this cost entry'), + ) + + min_cost = InvenTree.fields.InvenTreeModelMoneyField( + null=True, + blank=True, + verbose_name=_('Minimum Cost'), + help_text=_('Minimum estimated cost for this entry'), + ) + + max_cost = InvenTree.fields.InvenTreeModelMoneyField( + null=True, + blank=True, + verbose_name=_('Maximum Cost'), + help_text=_('Maximum estimated cost for this entry'), + ) + + cost = InvenTree.fields.InvenTreeModelMoneyField( + null=True, + blank=True, + verbose_name=_('Cost'), + help_text=_('Single point-value estimate for this entry'), + ) + + date = models.DateTimeField( + auto_now_add=True, + editable=False, + verbose_name=_('Date'), + help_text=_('Date at which this cost entry was calculated'), + ) + + user = models.ForeignKey( + get_user_model(), + on_delete=models.SET_NULL, + null=True, + blank=True, + verbose_name=_('User'), + help_text=_('User associated with this cost calculation'), + ) + + source_data = models.JSONField( + null=True, + blank=True, + verbose_name=_('Source Data'), + help_text=_('Source data used to calculate this cost entry'), + ) + + notes = models.CharField( + max_length=512, + blank=True, + verbose_name=_('Notes'), + help_text=_('Notes associated with this cost entry'), + ) + + def save(self, *args, **kwargs): + """Ensure that the 'part' link is always up to date.""" + if self.stock_item: + self.part = self.stock_item.part + + super().save(*args, **kwargs) + + def __str__(self): + """Return string representation of this cost entry.""" + return f'{self.stock_item} - {CostType(self.cost_type).label}' diff --git a/src/backend/InvenTree/pricing/status_codes.py b/src/backend/InvenTree/pricing/status_codes.py new file mode 100644 index 000000000000..cf2cb298ef66 --- /dev/null +++ b/src/backend/InvenTree/pricing/status_codes.py @@ -0,0 +1,23 @@ +"""Status codes for the 'pricing' app.""" + +from django.utils.translation import gettext_lazy as _ + +from generic.states import ColorEnum, StatusCode + + +class CostType(StatusCode): + """Defines the type (source) of a StockItemCost entry. + + Attributes: + PURCHASE: Cost taken directly from a purchase (e.g. supplier price break, PO line item) + LANDED: Purchase cost plus additional landed costs (freight, duty, handling, etc) + MANUFACTURING: Cost calculated from a build order (BOM cost plus labor / overhead) + MANUAL: Cost manually entered (or overridden) by a user + SYSTEM: Cost calculated automatically by the pricing system (e.g. a pricing plugin) + """ + + PURCHASE = 10, _('Purchase'), ColorEnum.primary + LANDED = 20, _('Landed'), ColorEnum.info + MANUFACTURING = 30, _('Manufacturing'), ColorEnum.secondary + MANUAL = 40, _('Manual'), ColorEnum.warning + SYSTEM = 50, _('System'), ColorEnum.success diff --git a/src/backend/InvenTree/users/oauth2_scopes.py b/src/backend/InvenTree/users/oauth2_scopes.py index 52656a33fa94..82e335b5dd94 100644 --- a/src/backend/InvenTree/users/oauth2_scopes.py +++ b/src/backend/InvenTree/users/oauth2_scopes.py @@ -13,6 +13,7 @@ def get_granular_scope(method, role=None, type='r'): 'admin': 'Role Admin', 'part_category': 'Role Part Categories', 'part': 'Role Parts', + 'pricing': 'Role Part Pricing', 'stock_location': 'Role Stock Locations', 'stock': 'Role Stock Items', 'bom': 'Role Bills of Material', diff --git a/src/backend/InvenTree/users/ruleset.py b/src/backend/InvenTree/users/ruleset.py index 62a7b9fa9d5d..bc6f54482ce1 100644 --- a/src/backend/InvenTree/users/ruleset.py +++ b/src/backend/InvenTree/users/ruleset.py @@ -130,7 +130,7 @@ def get_ruleset_models() -> dict: 'company_supplierpart', 'company_manufacturerpart', ], - RuleSetEnum.PRICING: [], + RuleSetEnum.PRICING: ['pricing_stockitemcost'], RuleSetEnum.STOCK_LOCATION: ['stock_stocklocation', 'stock_stocklocationtype'], RuleSetEnum.STOCK: [ 'stock_stockitem',