[refactor] Remove MPTT mixin from StockItem model (#12360)
* Remove MPTT from StockItem model * cleanup stock fixture file * Remove tree rebuild code for StockItem * Adjust API * Update CHANGELOG and API version * remove tree references * Additional checks * Remove unused tests * Add data migration test * fix fk fields * Cleanup * Tweak unit test * Revert bulk-create in unit test * Don't rebuild StockItem models * Add bulk_create_and_fetch helper model - Overcome limitations of mysql * Add unit test for new bulk-create helper * Apply new helper to codebase * Adjust unit test * bug fix * Adjust query limits * Remove assert
This commit is contained in:
parent
85e13b26ec
commit
766a8b41db
|
|
@ -9,11 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Breaking Changes
|
||||
|
||||
- [#12360](https://github.com/inventree/InvenTree/pull/12360) removes the MPTT mixin from the StockItem model, and removes the self-referential tree structure from the database. This change was made to simplify the StockItem model and improve performance, as the MPTT tree structure was causing significant overhead in certain operations. Any external client applications which made use of the MPTT functionality will need to be updated to account for this change.
|
||||
- [#12320](https://github.com/inventree/InvenTree/pull/12320) changes the default behavior of the `invoke migrate` command. Now, it no longer generates new migrations by default. Instead, it will only apply existing migrations to the database. If you want to detect and generate new migrations, you must now explicitly use the `--detect` flag. This change was made to prevent accidental generation of migrations when running the command, which could lead to unexpected changes in the database schema. Additionally, `invoke update` will no longer result in new migrations being generated, and will only apply existing migrations to the database. This change was made to ensure that the update process is predictable and does not introduce unexpected changes to the database schema.
|
||||
- [#12223](https://github.com/inventree/InvenTree/pull/12223) removes support for python 3.11 and stops providing packages for Debian 11 and Ubuntu 20.04.
|
||||
|
||||
### Added
|
||||
|
||||
- [#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.
|
||||
- [#12117](https://github.com/inventree/InvenTree/pull/12117) adds a "preview" drawer to the InvenTree table component, allowing users to preview the details of a selected row without navigating away from the table view. This feature is optional and can be enabled or disabled via the `PREVIEW_DRAWER_ENABLED` system setting.
|
||||
- [#12341](https://github.com/inventree/InvenTree/pull/12341) adds support for importing internal part prices.
|
||||
- [#12295](https://github.com/inventree/InvenTree/pull/12295) adds "consumable" field to the Part model and API endpoints
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 520
|
||||
INVENTREE_API_VERSION = 521
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v520 -> 2026-07-11 : https://github.com/inventree/InvenTree/pull/xxxx
|
||||
v521 -> 2026-07-12 : https://github.com/inventree/InvenTree/pull/12360
|
||||
- Removes the MPTT mixin from the StockItem model, and removes the self-referential tree structure from the database.
|
||||
|
||||
v520 -> 2026-07-11 : https://github.com/inventree/InvenTree/pull/12310
|
||||
- Adds new "disassemble" API endpoint for stock items
|
||||
- Allows a stock item to be broken down into component parts, based on its Bill of Materials
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
"""Database helper functions for InvenTree."""
|
||||
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import QuerySet
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def bulk_create_and_fetch(
|
||||
model, items, id_field: str = 'pk', filters: Optional[dict] = None
|
||||
) -> QuerySet:
|
||||
"""Bulk create items in the database, and return a queryset of the created items.
|
||||
|
||||
Arguments:
|
||||
model: The Django model class to create instances of.
|
||||
items: A list of dictionaries containing the data for each item to be created.
|
||||
id_field: The name of the field to use as the unique identifier for the created items.
|
||||
filters: Optional dictionary of filters to apply when fetching the created items.
|
||||
|
||||
Returns:
|
||||
A Django QuerySet containing the created items.
|
||||
|
||||
This helper method is required because the Django bulk_create() method
|
||||
does not guarantee that the ID values of the created items will be populated in the returned objects.
|
||||
In particular, MySQL does not support returning the ID values of bulk created items.
|
||||
|
||||
So, we provide temporary metadata to the created items,
|
||||
which can be used to fetch the created items from the database.
|
||||
|
||||
Assumptions:
|
||||
- The provided model type has a "metadata" attribute which can be overloaded for this purpose
|
||||
- No "metadata" is provided in the input items, as this will be overwritten by the method
|
||||
- The model type has an incrementing ID field (default: "pk")
|
||||
|
||||
"""
|
||||
bulk_create_id = uuid.uuid4().hex
|
||||
|
||||
# Generate temporary metadata for bulk fetching
|
||||
metadata = {'bulk_create_id': bulk_create_id}
|
||||
|
||||
lookup_filters = dict(filters) if filters else {}
|
||||
|
||||
lookup_filters['metadata__bulk_create_id'] = bulk_create_id
|
||||
|
||||
if id_field:
|
||||
# Find the "most recent" item in the database, to set a search floor
|
||||
if instance := model.objects.order_by(f'-{id_field}').first():
|
||||
lookup_filters[f'{id_field}__gt'] = getattr(instance, id_field)
|
||||
|
||||
# Overwrite the metadata values
|
||||
for item in items:
|
||||
item.metadata = metadata
|
||||
|
||||
model.objects.bulk_create(items, batch_size=500)
|
||||
|
||||
instances = model.objects.filter(**lookup_filters)
|
||||
|
||||
pks = list(instances.values_list(id_field or 'pk', flat=True))
|
||||
|
||||
# Override the metadata values to remove the temporary bulk_create_id
|
||||
instances.update(metadata=None)
|
||||
|
||||
# Fetch the newly created items (by primary key, as the metadata filter no longer matches)
|
||||
return model.objects.filter(**{f'{id_field or "pk"}__in': pks})
|
||||
|
|
@ -43,16 +43,6 @@ class Command(BaseCommand):
|
|||
except Exception:
|
||||
logger.info('Error rebuilding PartCategory objects')
|
||||
|
||||
# StockItem model
|
||||
try:
|
||||
logger.info('Rebuilding StockItem objects')
|
||||
|
||||
from stock.models import StockItem
|
||||
|
||||
StockItem.objects.rebuild()
|
||||
except Exception:
|
||||
logger.info('Error rebuilding StockItem objects')
|
||||
|
||||
# StockLocation model
|
||||
try:
|
||||
logger.info('Rebuilding StockLocation objects')
|
||||
|
|
|
|||
|
|
@ -0,0 +1,163 @@
|
|||
"""Functional tests for the InvenTree.helpers_db module."""
|
||||
|
||||
from django.db import connection
|
||||
from django.test import TestCase
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
|
||||
from company.models import Company, Contact
|
||||
from InvenTree.helpers_db import bulk_create_and_fetch
|
||||
from order.models import PurchaseOrder
|
||||
from part.models import Part
|
||||
from stock.models import StockItem
|
||||
|
||||
|
||||
class BulkCreateAndFetchTest(TestCase):
|
||||
"""Tests for the bulk_create_and_fetch helper function."""
|
||||
|
||||
def test_basic(self):
|
||||
"""Created items are returned with populated, unique primary keys."""
|
||||
company = Company.objects.create(name='ACME')
|
||||
|
||||
items = [Contact(company=company, name=f'Contact {idx}') for idx in range(10)]
|
||||
|
||||
result = bulk_create_and_fetch(Contact, items)
|
||||
|
||||
self.assertEqual(result.count(), 10)
|
||||
|
||||
pks = [c.pk for c in result]
|
||||
|
||||
# Every item has a valid, unique primary key
|
||||
self.assertTrue(all(pk is not None for pk in pks))
|
||||
self.assertEqual(len(pks), len(set(pks)))
|
||||
|
||||
# Returned items exactly match what is actually in the database
|
||||
db_pks = set(
|
||||
Contact.objects.filter(company=company).values_list('pk', flat=True)
|
||||
)
|
||||
self.assertEqual(set(pks), db_pks)
|
||||
|
||||
# Temporary bulk-create metadata should have been cleared again
|
||||
for contact in Contact.objects.filter(pk__in=pks):
|
||||
self.assertIsNone(contact.metadata)
|
||||
|
||||
def test_with_filters(self):
|
||||
"""Additional filters correctly scope the returned queryset."""
|
||||
company_a = Company.objects.create(name='Company A')
|
||||
company_b = Company.objects.create(name='Company B')
|
||||
|
||||
items_a = [Contact(company=company_a, name=f'A{i}') for i in range(3)]
|
||||
items_b = [Contact(company=company_b, name=f'B{i}') for i in range(4)]
|
||||
|
||||
result_a = bulk_create_and_fetch(
|
||||
Contact, items_a, filters={'company': company_a}
|
||||
)
|
||||
result_b = bulk_create_and_fetch(
|
||||
Contact, items_b, filters={'company': company_b}
|
||||
)
|
||||
|
||||
self.assertEqual(result_a.count(), 3)
|
||||
self.assertEqual(result_b.count(), 4)
|
||||
|
||||
self.assertTrue(all(c.company_id == company_a.pk for c in result_a))
|
||||
self.assertTrue(all(c.company_id == company_b.pk for c in result_b))
|
||||
|
||||
def test_ignores_pre_existing_rows(self):
|
||||
"""The 'search floor' should exclude pre-existing rows in the table."""
|
||||
company = Company.objects.create(name='ACME')
|
||||
|
||||
# Pre-existing item, *not* created via the helper
|
||||
Contact.objects.create(company=company, name='Existing')
|
||||
|
||||
items = [Contact(company=company, name=f'New {i}') for i in range(2)]
|
||||
result = bulk_create_and_fetch(Contact, items)
|
||||
|
||||
self.assertEqual(result.count(), 2)
|
||||
self.assertNotIn('Existing', [c.name for c in result])
|
||||
|
||||
def test_empty_list(self):
|
||||
"""Calling with an empty list of items should not raise, and return no items."""
|
||||
result = bulk_create_and_fetch(Contact, [])
|
||||
self.assertEqual(result.count(), 0)
|
||||
|
||||
def test_does_not_mutate_caller_filters(self):
|
||||
"""The caller-provided 'filters' dict should not be mutated as a side effect."""
|
||||
company = Company.objects.create(name='ACME')
|
||||
filters = {'company': company}
|
||||
|
||||
items = [Contact(company=company, name='Contact')]
|
||||
bulk_create_and_fetch(Contact, items, filters=filters)
|
||||
|
||||
def test_purchase_order(self):
|
||||
"""The helper works for the PurchaseOrder model."""
|
||||
supplier = Company.objects.create(name='Supplier Co', is_supplier=True)
|
||||
|
||||
references = [f'PO-{idx:04d}' for idx in range(10)]
|
||||
|
||||
items = [
|
||||
PurchaseOrder(supplier=supplier, reference=ref, description=f'Order {ref}')
|
||||
for ref in references
|
||||
]
|
||||
|
||||
result = bulk_create_and_fetch(PurchaseOrder, items)
|
||||
|
||||
self.assertEqual(result.count(), 10)
|
||||
|
||||
pks = [o.pk for o in result]
|
||||
self.assertTrue(all(pk is not None for pk in pks))
|
||||
self.assertEqual(len(pks), len(set(pks)))
|
||||
|
||||
self.assertEqual(
|
||||
set(result.values_list('reference', flat=True)), set(references)
|
||||
)
|
||||
|
||||
for order in PurchaseOrder.objects.filter(pk__in=pks):
|
||||
self.assertIsNone(order.metadata)
|
||||
|
||||
def test_stock_item(self):
|
||||
"""The helper works for the StockItem model."""
|
||||
part = Part.objects.create(name='Widget', description='A widget')
|
||||
|
||||
items = [StockItem(part=part, quantity=idx + 1) for idx in range(10)]
|
||||
|
||||
result = bulk_create_and_fetch(StockItem, items)
|
||||
|
||||
self.assertEqual(result.count(), 10)
|
||||
|
||||
pks = [si.pk for si in result]
|
||||
self.assertTrue(all(pk is not None for pk in pks))
|
||||
self.assertEqual(len(pks), len(set(pks)))
|
||||
|
||||
self.assertEqual(
|
||||
sorted(result.values_list('quantity', flat=True)),
|
||||
sorted(idx + 1 for idx in range(10)),
|
||||
)
|
||||
|
||||
for stock_item in StockItem.objects.filter(pk__in=pks):
|
||||
self.assertIsNone(stock_item.metadata)
|
||||
|
||||
def _create_and_count_queries(self, n: int) -> int:
|
||||
"""Bulk-create 'n' Contact items, and return the number of queries used."""
|
||||
company = Company.objects.create(name=f'Company {n}')
|
||||
|
||||
items = [Contact(company=company, name=f'Contact {i}') for i in range(n)]
|
||||
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
result = bulk_create_and_fetch(Contact, items)
|
||||
|
||||
self.assertEqual(result.count(), n)
|
||||
|
||||
MAX_QUERIES = 25 if n > 100 else 10
|
||||
|
||||
self.assertLess(len(ctx.captured_queries), MAX_QUERIES)
|
||||
|
||||
return len(ctx.captured_queries)
|
||||
|
||||
def test_query_count_is_constant(self):
|
||||
"""The number of queries used should not depend on the number of created items."""
|
||||
small_query_count = self._create_and_count_queries(5)
|
||||
large_query_count = self._create_and_count_queries(100)
|
||||
|
||||
self.assertEqual(small_query_count, large_query_count)
|
||||
|
||||
# Perform a very large bulk-create - this will be batched
|
||||
self._create_and_count_queries(2000)
|
||||
|
|
@ -104,10 +104,9 @@ class TreeFixtureTest(TestCase):
|
|||
self.run_tree_test(Build)
|
||||
|
||||
def test_stock(self):
|
||||
"""Test MPTT tree structure for Stock model."""
|
||||
from stock.models import StockItem, StockLocation
|
||||
"""Test MPTT tree structure for StockLocation model."""
|
||||
from stock.models import StockLocation
|
||||
|
||||
self.run_tree_test(StockItem)
|
||||
self.run_tree_test(StockLocation)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -837,9 +837,7 @@ class BuildAllocationTest(BuildAPITest):
|
|||
)
|
||||
|
||||
# Test a fractional quantity when the *available* quantity is less than 1
|
||||
si = StockItem.objects.create(
|
||||
part=si.part, quantity=0.3159, tree_id=0, level=0, lft=0, rght=0
|
||||
)
|
||||
si = StockItem.objects.create(part=si.part, quantity=0.3159)
|
||||
|
||||
self.post(
|
||||
self.url,
|
||||
|
|
|
|||
|
|
@ -527,15 +527,14 @@ class BuildTest(BuildTestBase):
|
|||
|
||||
# Return a partial quantity of each item to stock
|
||||
for item in consumed_items:
|
||||
self.assertEqual(item.get_descendant_count(), 0)
|
||||
q = item.quantity
|
||||
self.assertGreater(item.quantity, 1)
|
||||
item.return_to_stock(location, merge=False, quantity=1)
|
||||
item.refresh_from_db()
|
||||
self.assertEqual(item.quantity, q - 1)
|
||||
self.assertEqual(item.get_descendant_count(), 1)
|
||||
self.assertEqual(item.children.count(), 1)
|
||||
self.assertFalse(item.is_in_stock())
|
||||
child = item.get_descendants().first()
|
||||
child = item.children.first()
|
||||
self.assertTrue(child.is_in_stock())
|
||||
|
||||
def test_change_part(self):
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ from InvenTree.fields import (
|
|||
RoundingDecimalField,
|
||||
)
|
||||
from InvenTree.helpers import decimal2string, pui_url
|
||||
from InvenTree.helpers_db import bulk_create_and_fetch
|
||||
from InvenTree.helpers_model import notify_responsible
|
||||
from order.events import (
|
||||
PurchaseOrderEvents,
|
||||
|
|
@ -1224,15 +1225,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
|||
stock_items.append(new_item)
|
||||
|
||||
else:
|
||||
new_item = stock.models.StockItem(
|
||||
**stock_data,
|
||||
serial='',
|
||||
tree_id=stock.models.StockItem.getNextTreeID(),
|
||||
parent=None,
|
||||
level=0,
|
||||
lft=1,
|
||||
rght=2,
|
||||
)
|
||||
new_item = stock.models.StockItem(**stock_data, serial='', parent=None)
|
||||
|
||||
new_item.set_status(status, custom_values=custom_stock_status_values)
|
||||
|
||||
|
|
@ -1250,18 +1243,10 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
|||
# run validation for items plugin.validate_model_instance
|
||||
item.run_plugin_validation()
|
||||
|
||||
stock.models.StockItem.objects.bulk_create(
|
||||
bulk_create_items, batch_size=250
|
||||
)
|
||||
# Bulk create the stock items and fetch the newly created instances
|
||||
new_items = bulk_create_and_fetch(stock.models.StockItem, bulk_create_items)
|
||||
|
||||
# Fetch them back again
|
||||
tree_ids = [item.tree_id for item in bulk_create_items]
|
||||
|
||||
created_items = stock.models.StockItem.objects.filter(
|
||||
tree_id__in=tree_ids, level=0, lft=1, rght=2, purchase_order=self
|
||||
).prefetch_related('location')
|
||||
|
||||
stock_items.extend(created_items)
|
||||
stock_items.extend(new_items)
|
||||
|
||||
# Generate a new tracking entry for each stock item
|
||||
for item in stock_items:
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ from order.status_codes import (
|
|||
)
|
||||
from part.models import Part
|
||||
from stock.models import StockItem, StockLocation, StockSortOrder
|
||||
from stock.status_codes import StockStatus
|
||||
from stock.status_codes import StockHistoryCode, StockStatus
|
||||
from users.models import Owner
|
||||
|
||||
|
||||
|
|
@ -1416,12 +1416,24 @@ class PurchaseOrderReceiveTest(OrderTest):
|
|||
|
||||
# Check for expected response
|
||||
self.assertEqual(len(response), N_LINES)
|
||||
|
||||
# Check that the expected number of stock items has been created
|
||||
self.assertEqual(N_ITEMS + N_LINES, StockItem.objects.count())
|
||||
|
||||
for item in response:
|
||||
self.assertEqual(item['purchase_order'], po.pk)
|
||||
self.assertEqual(item['status'], StockStatus.QUARANTINED)
|
||||
|
||||
stock_item = StockItem.objects.get(pk=item['pk'])
|
||||
# Check that the item has tracking entries
|
||||
self.assertEqual(stock_item.tracking_info.count(), 1)
|
||||
entry = stock_item.tracking_info.first()
|
||||
self.assertEqual(entry.deltas['quantity'], stock_item.quantity)
|
||||
self.assertEqual(entry.user, self.user)
|
||||
self.assertEqual(
|
||||
entry.tracking_type, StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER
|
||||
)
|
||||
|
||||
# Check that the order has been completed
|
||||
po.refresh_from_db()
|
||||
self.assertEqual(po.status, PurchaseOrderStatus.COMPLETE)
|
||||
|
|
|
|||
|
|
@ -408,21 +408,8 @@ class SalesOrderTest(InvenTreeTestCase):
|
|||
allocations = []
|
||||
stock_items = []
|
||||
|
||||
tree_id = StockItem.objects.all().order_by('-tree_id').first().tree_id
|
||||
|
||||
for idx in range(N_ITEMS):
|
||||
tree_id += 1
|
||||
|
||||
stock_items.append(
|
||||
StockItem(
|
||||
part=part,
|
||||
quantity=1 + idx % 5,
|
||||
level=0,
|
||||
lft=0,
|
||||
rght=0,
|
||||
tree_id=tree_id,
|
||||
)
|
||||
)
|
||||
stock_items.append(StockItem(part=part, quantity=1 + idx % 5))
|
||||
|
||||
StockItem.objects.bulk_create(stock_items)
|
||||
|
||||
|
|
|
|||
|
|
@ -582,6 +582,7 @@ class StockFilter(FilterSet):
|
|||
# Simple filter filters
|
||||
fields = [
|
||||
'supplier_part',
|
||||
'parent',
|
||||
'belongs_to',
|
||||
'build',
|
||||
'customer',
|
||||
|
|
@ -910,15 +911,6 @@ class StockFilter(FilterSet):
|
|||
return queryset.exclude(purchase_price=None)
|
||||
return queryset.filter(purchase_price=None)
|
||||
|
||||
ancestor = rest_filters.ModelChoiceFilter(
|
||||
label='Ancestor', queryset=StockItem.objects.all(), method='filter_ancestor'
|
||||
)
|
||||
|
||||
@extend_schema_field(OpenApiTypes.INT)
|
||||
def filter_ancestor(self, queryset, name, ancestor):
|
||||
"""Filter based on ancestor stock item."""
|
||||
return queryset.filter(parent__in=ancestor.get_descendants(include_self=True))
|
||||
|
||||
category = rest_filters.ModelChoiceFilter(
|
||||
label=_('Category'),
|
||||
queryset=PartCategory.objects.all(),
|
||||
|
|
@ -1027,26 +1019,6 @@ class StockFilter(FilterSet):
|
|||
else:
|
||||
return queryset.exclude(stale_filter)
|
||||
|
||||
exclude_tree = rest_filters.NumberFilter(
|
||||
method='filter_exclude_tree',
|
||||
label=_('Exclude Tree'),
|
||||
help_text=_(
|
||||
'Provide a StockItem PK to exclude that item and all its descendants'
|
||||
),
|
||||
)
|
||||
|
||||
def filter_exclude_tree(self, queryset, name, value):
|
||||
"""Exclude a StockItem and all of its descendants from the queryset."""
|
||||
try:
|
||||
root = StockItem.objects.get(pk=value)
|
||||
pks_to_exclude = [
|
||||
item.pk for item in root.get_descendants(include_self=True)
|
||||
]
|
||||
return queryset.exclude(pk__in=pks_to_exclude)
|
||||
except (ValueError, StockItem.DoesNotExist):
|
||||
# If the value is invalid or the object doesn't exist, do nothing.
|
||||
return queryset
|
||||
|
||||
cascade = rest_filters.BooleanFilter(
|
||||
method='filter_cascade',
|
||||
label=_('Cascade Locations'),
|
||||
|
|
|
|||
|
|
@ -10,10 +10,6 @@
|
|||
quantity: 4000
|
||||
purchase_price: 123
|
||||
purchase_price_currency: AUD
|
||||
tree_id: 20
|
||||
level: 0
|
||||
lft: 1
|
||||
rght: 2
|
||||
|
||||
# 5,000 screws in the bathroom
|
||||
- model: stock.stockitem
|
||||
|
|
@ -22,10 +18,6 @@
|
|||
part: 1
|
||||
location: 2
|
||||
quantity: 5000
|
||||
tree_id: 21
|
||||
level: 0
|
||||
lft: 1
|
||||
rght: 2
|
||||
|
||||
# Capacitor C_22N_0805 in 'Office'
|
||||
- model: stock.stockitem
|
||||
|
|
@ -34,10 +26,6 @@
|
|||
part: 5
|
||||
location: 4
|
||||
quantity: 666
|
||||
tree_id: 16
|
||||
level: 0
|
||||
lft: 1
|
||||
rght: 2
|
||||
|
||||
# 1234 2K2 resistors in 'Drawer_1'
|
||||
- model: stock.stockitem
|
||||
|
|
@ -46,10 +34,6 @@
|
|||
part: 3
|
||||
location: 5
|
||||
quantity: 1234
|
||||
tree_id: 22
|
||||
level: 0
|
||||
lft: 1
|
||||
rght: 2
|
||||
|
||||
# Some widgets in drawer 3
|
||||
- model: stock.stockitem
|
||||
|
|
@ -60,10 +44,6 @@
|
|||
location: 7
|
||||
quantity: 10
|
||||
delete_on_deplete: False
|
||||
tree_id: 28
|
||||
level: 0
|
||||
lft: 1
|
||||
rght: 2
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 101
|
||||
|
|
@ -72,10 +52,6 @@
|
|||
batch: "B2345"
|
||||
location: 7
|
||||
quantity: 5
|
||||
tree_id: 27
|
||||
level: 0
|
||||
lft: 1
|
||||
rght: 2
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 102
|
||||
|
|
@ -84,10 +60,6 @@
|
|||
batch: 'BCDE'
|
||||
location: 7
|
||||
quantity: 0
|
||||
tree_id: 26
|
||||
level: 0
|
||||
lft: 1
|
||||
rght: 2
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 105
|
||||
|
|
@ -97,10 +69,6 @@
|
|||
quantity: 1
|
||||
serial: 1000
|
||||
serial_int: 1000
|
||||
tree_id: 29
|
||||
level: 0
|
||||
lft: 1
|
||||
rght: 2
|
||||
|
||||
# Stock items for template / variant parts
|
||||
- model: stock.stockitem
|
||||
|
|
@ -110,10 +78,6 @@
|
|||
location: 7
|
||||
quantity: 5
|
||||
batch: "BBAAA"
|
||||
tree_id: 2
|
||||
level: 0
|
||||
lft: 1
|
||||
rght: 2
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 501
|
||||
|
|
@ -123,10 +87,6 @@
|
|||
quantity: 1
|
||||
serial: 1
|
||||
serial_int: 1
|
||||
tree_id: 3
|
||||
level: 0
|
||||
lft: 1
|
||||
rght: 2
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 502
|
||||
|
|
@ -136,10 +96,6 @@
|
|||
quantity: 1
|
||||
serial: 2
|
||||
serial_int: 2
|
||||
tree_id: 4
|
||||
level: 0
|
||||
lft: 1
|
||||
rght: 2
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 503
|
||||
|
|
@ -149,10 +105,6 @@
|
|||
quantity: 1
|
||||
serial: 3
|
||||
serial_int: 3
|
||||
tree_id: 5
|
||||
level: 0
|
||||
lft: 1
|
||||
rght: 2
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 504
|
||||
|
|
@ -162,10 +114,6 @@
|
|||
quantity: 1
|
||||
serial: 4
|
||||
serial_int: 4
|
||||
tree_id: 6
|
||||
level: 0
|
||||
lft: 1
|
||||
rght: 2
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 505
|
||||
|
|
@ -175,10 +123,6 @@
|
|||
quantity: 1
|
||||
serial: 5
|
||||
serial_int: 5
|
||||
tree_id: 1
|
||||
level: 0
|
||||
lft: 1
|
||||
rght: 2
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 510
|
||||
|
|
@ -188,10 +132,6 @@
|
|||
quantity: 1
|
||||
serial: 10
|
||||
serial_int: 10
|
||||
tree_id: 24
|
||||
level: 0
|
||||
lft: 1
|
||||
rght: 2
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 511
|
||||
|
|
@ -201,10 +141,6 @@
|
|||
quantity: 1
|
||||
serial: 11
|
||||
serial_int: 11
|
||||
tree_id: 23
|
||||
level: 0
|
||||
lft: 1
|
||||
rght: 2
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 512
|
||||
|
|
@ -214,10 +150,6 @@
|
|||
quantity: 1
|
||||
serial: 12
|
||||
serial_int: 12
|
||||
tree_id: 25
|
||||
level: 0
|
||||
lft: 1
|
||||
rght: 2
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 520
|
||||
|
|
@ -229,10 +161,6 @@
|
|||
serial_int: 20
|
||||
expiry_date: "1990-10-10"
|
||||
barcode_hash: 9e5ae7fc20568ed4814c10967bba8b65
|
||||
tree_id: 18
|
||||
level: 0
|
||||
lft: 1
|
||||
rght: 2
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 521
|
||||
|
|
@ -244,10 +172,6 @@
|
|||
serial_int: 21
|
||||
status: 60
|
||||
barcode_hash: 1be0dfa925825c5c6c79301449e50c2d
|
||||
tree_id: 17
|
||||
level: 0
|
||||
lft: 1
|
||||
rght: 2
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 522
|
||||
|
|
@ -259,10 +183,6 @@
|
|||
serial_int: 22
|
||||
expiry_date: "1990-10-10"
|
||||
status: 70
|
||||
tree_id: 19
|
||||
level: 0
|
||||
lft: 1
|
||||
rght: 2
|
||||
belongs_to: 105
|
||||
|
||||
# Multiple stock items for "Bob" (PK 100)
|
||||
|
|
@ -272,10 +192,6 @@
|
|||
part: 100
|
||||
location: 1
|
||||
quantity: 10
|
||||
tree_id: 9
|
||||
level: 0
|
||||
lft: 1
|
||||
rght: 4
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 1001
|
||||
|
|
@ -284,10 +200,6 @@
|
|||
parent: 1000
|
||||
location: 1
|
||||
quantity: 11
|
||||
tree_id: 9
|
||||
level: 1
|
||||
lft: 2
|
||||
rght: 3
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 1002
|
||||
|
|
@ -295,10 +207,6 @@
|
|||
part: 100
|
||||
location: 1
|
||||
quantity: 12
|
||||
tree_id: 8
|
||||
level: 0
|
||||
lft: 1
|
||||
rght: 2
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 1003
|
||||
|
|
@ -306,10 +214,6 @@
|
|||
part: 100
|
||||
location: 1
|
||||
quantity: 13
|
||||
tree_id: 15
|
||||
level: 0
|
||||
lft: 1
|
||||
rght: 2
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 1004
|
||||
|
|
@ -317,10 +221,6 @@
|
|||
part: 100
|
||||
location: 1
|
||||
quantity: 14
|
||||
tree_id: 7
|
||||
level: 0
|
||||
lft: 1
|
||||
rght: 2
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 1005
|
||||
|
|
@ -328,10 +228,6 @@
|
|||
part: 100
|
||||
location: 1
|
||||
quantity: 15
|
||||
tree_id: 13
|
||||
level: 0
|
||||
lft: 1
|
||||
rght: 2
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 1006
|
||||
|
|
@ -339,10 +235,6 @@
|
|||
part: 100
|
||||
location: 1
|
||||
quantity: 16
|
||||
tree_id: 12
|
||||
level: 0
|
||||
lft: 1
|
||||
rght: 2
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 1007
|
||||
|
|
@ -350,10 +242,6 @@
|
|||
part: 100
|
||||
location: 7
|
||||
quantity: 17
|
||||
tree_id: 11
|
||||
level: 0
|
||||
lft: 1
|
||||
rght: 2
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 1008
|
||||
|
|
@ -361,7 +249,3 @@
|
|||
part: 100
|
||||
location: 7
|
||||
quantity: 18
|
||||
tree_id: 10
|
||||
level: 0
|
||||
lft: 1
|
||||
rght: 2
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
# Generated by Django 5.2.15 on 2026-07-11 03:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("stock", "0123_remove_stockitem_review_needed"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="stockitem",
|
||||
name="level",
|
||||
field=models.PositiveIntegerField(default=0),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="stockitem",
|
||||
name="lft",
|
||||
field=models.PositiveIntegerField(default=0),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="stockitem",
|
||||
name="rght",
|
||||
field=models.PositiveIntegerField(default=0),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="stockitem",
|
||||
name="tree_id",
|
||||
field=models.PositiveIntegerField(db_index=True, default=0),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
# Generated by Django 5.2.15 on 2026-07-11 03:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("stock", "0124_alter_mptt_fields"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="stockitem",
|
||||
name="level",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="stockitem",
|
||||
name="lft",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="stockitem",
|
||||
name="rght",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="stockitem",
|
||||
name="tree_id",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="stockitem",
|
||||
name="parent",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.deletion.SET_NULL,
|
||||
related_name="children",
|
||||
to="stock.stockitem",
|
||||
verbose_name="Parent Stock Item",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -35,7 +35,6 @@ import InvenTree.ready
|
|||
import InvenTree.tasks
|
||||
import order.models
|
||||
import report.mixins
|
||||
import stock.tasks
|
||||
from common.icons import validate_icon
|
||||
from common.settings import get_global_setting
|
||||
from company import models as CompanyModels
|
||||
|
|
@ -422,7 +421,6 @@ STOCK_SORT_DEFAULT = StockSortOrder.DATE_OLDEST
|
|||
|
||||
|
||||
class StockItem(
|
||||
InvenTree.models.PluginValidationMixin,
|
||||
InvenTree.models.InvenTreeAttachmentMixin,
|
||||
InvenTree.models.InvenTreeBarcodeMixin,
|
||||
InvenTree.models.InvenTreeNotesMixin,
|
||||
|
|
@ -431,7 +429,7 @@ class StockItem(
|
|||
report.mixins.InvenTreeReportMixin,
|
||||
common.models.MetaMixin,
|
||||
InvenTree.models.MetadataMixin,
|
||||
InvenTree.models.InvenTreeTree,
|
||||
InvenTree.models.InvenTreeModel,
|
||||
):
|
||||
"""A StockItem object represents a quantity of physical instances of a part.
|
||||
|
||||
|
|
@ -567,15 +565,19 @@ class StockItem(
|
|||
"""Return API url."""
|
||||
return reverse('api-stock-list')
|
||||
|
||||
def api_instance_filters(self):
|
||||
"""Custom API instance filters."""
|
||||
return {'parent': {'exclude_tree': self.pk}}
|
||||
|
||||
@classmethod
|
||||
def barcode_model_type_code(cls):
|
||||
"""Return the associated barcode model type code for this model."""
|
||||
return 'SI'
|
||||
|
||||
def get_children(self):
|
||||
"""Return a queryset of all StockItem objects which are children of this StockItem.
|
||||
|
||||
As the StockItem model is no longer a MPTT model,
|
||||
this function is implemented manually
|
||||
"""
|
||||
return StockItem.objects.filter(parent=self).exclude(pk=self.pk)
|
||||
|
||||
def get_test_keys(self, include_installed=True):
|
||||
"""Construct a flattened list of test 'keys' for this StockItem."""
|
||||
keys = []
|
||||
|
|
@ -709,19 +711,9 @@ class StockItem(
|
|||
raise ValidationError({'part': _('Part must be specified')})
|
||||
|
||||
part = data['part']
|
||||
|
||||
parent = kwargs.pop('parent', None) or data.get('parent')
|
||||
tree_id = kwargs.pop('tree_id', StockItem.getNextTreeID())
|
||||
|
||||
if parent:
|
||||
# Override with parent's tree_id if provided
|
||||
tree_id = parent.tree_id
|
||||
|
||||
# Pre-calculate MPTT fields
|
||||
data['parent'] = parent if parent else None
|
||||
data['level'] = parent.level + 1 if parent else 0
|
||||
data['lft'] = 0 if parent else 1
|
||||
data['rght'] = 0 if parent else 2
|
||||
data['parent'] = parent
|
||||
|
||||
# Force single quantity for each item
|
||||
data['quantity'] = 1
|
||||
|
|
@ -734,27 +726,12 @@ class StockItem(
|
|||
else:
|
||||
data['serial_int'] = 0
|
||||
|
||||
data['tree_id'] = tree_id
|
||||
|
||||
if not parent:
|
||||
# No parent, this is a top-level item, so increment the tree_id
|
||||
# This is because each new item is a "top-level" node in the StockItem tree
|
||||
tree_id += 1
|
||||
|
||||
# Construct a new StockItem from the provided dict
|
||||
items.append(StockItem(**data))
|
||||
|
||||
# Create the StockItem objects in bulk
|
||||
StockItem.objects.bulk_create(items, batch_size=250)
|
||||
|
||||
# We will need to rebuild the stock item tree manually, due to the bulk_create operation
|
||||
if parent and parent.tree_id:
|
||||
# Rebuild the tree structure for this StockItem tree
|
||||
logger.info(
|
||||
'Rebuilding StockItem tree structure for tree_id: %s', parent.tree_id
|
||||
)
|
||||
stock.tasks.rebuild_stock_item_tree(parent.tree_id)
|
||||
|
||||
# Fetch the new StockItem objects from the database
|
||||
items = StockItem.objects.filter(part=part, serial__in=serials)
|
||||
|
||||
|
|
@ -1064,10 +1041,10 @@ class StockItem(
|
|||
return self.part.full_name
|
||||
|
||||
# Note: When a StockItem is deleted, a pre_delete signal handles the parent/child relationship
|
||||
parent = TreeForeignKey(
|
||||
'self',
|
||||
parent = models.ForeignKey(
|
||||
'stock.StockItem',
|
||||
verbose_name=_('Parent Stock Item'),
|
||||
on_delete=models.DO_NOTHING,
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name='children',
|
||||
|
|
@ -2029,7 +2006,6 @@ class StockItem(
|
|||
new_item.set_status(status, custom_values=custom_status_values)
|
||||
|
||||
# Ensure the tree structure is observed
|
||||
new_item.tree_id = None
|
||||
new_item.save(add_note=False)
|
||||
|
||||
deltas = {'stockitem': self.pk, 'quantity': float(line_quantity)}
|
||||
|
|
@ -2078,9 +2054,6 @@ class StockItem(
|
|||
StockHistoryCode.DISASSEMBLED, user, notes=notes, deltas=deltas
|
||||
)
|
||||
|
||||
# Rebuild the stock tree for this item
|
||||
stock.tasks.rebuild_stock_item_tree(self.tree_id)
|
||||
|
||||
trigger_event(StockEvents.ITEM_DISASSEMBLED, id=self.pk)
|
||||
|
||||
return items
|
||||
|
|
@ -2304,7 +2277,6 @@ class StockItem(
|
|||
|
||||
# Set the parent ID correctly
|
||||
data['parent'] = self
|
||||
data['tree_id'] = self.tree_id
|
||||
|
||||
# Generate a new serial number for each item
|
||||
items = StockItem._create_serial_numbers(serials, **data)
|
||||
|
|
@ -2527,9 +2499,6 @@ class StockItem(
|
|||
if len(other_items) == 0:
|
||||
return
|
||||
|
||||
# Keep track of the tree IDs that are being merged
|
||||
tree_ids = {self.tree_id}
|
||||
|
||||
user = kwargs.get('user')
|
||||
location = kwargs.get('location', self.location)
|
||||
notes = kwargs.get('notes') or ''
|
||||
|
|
@ -2553,8 +2522,6 @@ class StockItem(
|
|||
merged_quantity = Decimal(0)
|
||||
|
||||
for other in other_items:
|
||||
tree_ids.add(other.tree_id)
|
||||
|
||||
merged_quantity += other.quantity
|
||||
self.quantity += other.quantity
|
||||
|
||||
|
|
@ -2626,19 +2593,6 @@ class StockItem(
|
|||
|
||||
self.save()
|
||||
|
||||
# Rebuild stock trees as required
|
||||
rebuild_result = True
|
||||
for tree_id in tree_ids:
|
||||
if not stock.tasks.rebuild_stock_item_tree(tree_id, rebuild_on_fail=False):
|
||||
rebuild_result = False
|
||||
|
||||
if not rebuild_result:
|
||||
# If the rebuild failed, offload the task to a background worker
|
||||
logger.warning(
|
||||
'Failed to rebuild stock item tree during merge_stock_items operation, offloading task.'
|
||||
)
|
||||
InvenTree.tasks.offload_task(stock.tasks.rebuild_stock_items, group='stock')
|
||||
|
||||
@transaction.atomic
|
||||
def splitStock(self, quantity, location=None, user=None, **kwargs):
|
||||
"""Split this stock item into two items, in the same location.
|
||||
|
|
@ -2711,7 +2665,6 @@ class StockItem(
|
|||
|
||||
# Update the new stock item to ensure the tree structure is observed
|
||||
new_stock.parent = self
|
||||
new_stock.tree_id = None
|
||||
|
||||
# Create 'deltas' dict to record changes for tracking
|
||||
deltas = {'stockitem': self.pk}
|
||||
|
|
@ -2759,9 +2712,6 @@ class StockItem(
|
|||
record_tracking=record_tracking,
|
||||
)
|
||||
|
||||
# Rebuild the tree for this parent item
|
||||
stock.tasks.rebuild_stock_item_tree(self.tree_id)
|
||||
|
||||
# Attempt to reload the new item from the database
|
||||
try:
|
||||
new_stock.refresh_from_db()
|
||||
|
|
|
|||
|
|
@ -6,71 +6,12 @@ import structlog
|
|||
from opentelemetry import trace
|
||||
|
||||
from common.settings import get_global_setting
|
||||
from InvenTree.tasks import ScheduledTask, offload_task, scheduled_task
|
||||
from InvenTree.tasks import ScheduledTask, scheduled_task
|
||||
|
||||
tracer = trace.get_tracer(__name__)
|
||||
logger = structlog.get_logger('inventree')
|
||||
|
||||
|
||||
@tracer.start_as_current_span('rebuild_stock_items')
|
||||
def rebuild_stock_items():
|
||||
"""Rebuild the entire StockItem tree structure.
|
||||
|
||||
This may be necessary if the tree structure has become corrupted or inconsistent.
|
||||
"""
|
||||
from InvenTree.exceptions import log_error
|
||||
from InvenTree.sentry import report_exception
|
||||
from stock.models import StockItem
|
||||
|
||||
logger.info('Rebuilding StockItem tree structure')
|
||||
|
||||
try:
|
||||
StockItem.objects.rebuild()
|
||||
except Exception as e:
|
||||
# This is a critical error, explicitly report to sentry
|
||||
report_exception(e)
|
||||
|
||||
log_error('rebuild_stock_items')
|
||||
logger.exception('Failed to rebuild StockItem tree: %s', e)
|
||||
|
||||
|
||||
def rebuild_stock_item_tree(tree_id: int, rebuild_on_fail: bool = True) -> bool:
|
||||
"""Rebuild the stock tree structure.
|
||||
|
||||
Arguments:
|
||||
tree_id (int): The ID of the StockItem tree to rebuild.
|
||||
rebuild_on_fail (bool): If True, will attempt to rebuild the entire StockItem tree if the partial rebuild fails.
|
||||
|
||||
Returns:
|
||||
bool: True if the partial tree rebuild was successful, False otherwise.
|
||||
|
||||
- If the rebuild fails, schedule a rebuild of the entire StockItem tree.
|
||||
"""
|
||||
from InvenTree.exceptions import log_error
|
||||
from InvenTree.sentry import report_exception
|
||||
from stock.models import StockItem
|
||||
|
||||
if tree_id:
|
||||
try:
|
||||
StockItem.objects.partial_rebuild(tree_id)
|
||||
logger.info('Rebuilt StockItem tree for tree_id: %s', tree_id)
|
||||
return True
|
||||
except Exception as e:
|
||||
# This is a critical error, explicitly report to sentry
|
||||
report_exception(e)
|
||||
|
||||
log_error('rebuild_stock_item_tree')
|
||||
logger.warning('Failed to rebuild StockItem tree for tree_id: %s', tree_id)
|
||||
# If the partial rebuild fails, rebuild the entire tree
|
||||
if rebuild_on_fail:
|
||||
offload_task(rebuild_stock_items, group='stock')
|
||||
return False
|
||||
else:
|
||||
# No tree_id provided, so rebuild the entire tree
|
||||
StockItem.objects.rebuild()
|
||||
return True
|
||||
|
||||
|
||||
@tracer.start_as_current_span('delete_old_stock_tracking')
|
||||
@scheduled_task(ScheduledTask.DAILY)
|
||||
def delete_old_stock_tracking():
|
||||
|
|
|
|||
|
|
@ -617,17 +617,7 @@ class StockItemListTest(StockAPITestCase):
|
|||
item.delete()
|
||||
|
||||
for idx in range(1000):
|
||||
items.append(
|
||||
StockItem(
|
||||
part=part,
|
||||
location=location,
|
||||
quantity=idx % 10,
|
||||
level=0,
|
||||
lft=0,
|
||||
rght=0,
|
||||
tree_id=0,
|
||||
)
|
||||
)
|
||||
items.append(StockItem(part=part, location=location, quantity=idx % 10))
|
||||
|
||||
StockItem.objects.bulk_create(items, batch_size=250)
|
||||
|
||||
|
|
@ -738,13 +728,6 @@ class StockItemListTest(StockAPITestCase):
|
|||
response = self.get_stock(location=7)
|
||||
self.assertEqual(len(response), 18)
|
||||
|
||||
def test_filter_by_exclude_tree(self):
|
||||
"""Filter StockItem by excluding a StockItem tree."""
|
||||
response = self.get_stock(exclude_tree=1000)
|
||||
for item in response:
|
||||
self.assertNotEqual(item['pk'], 1000)
|
||||
self.assertNotEqual(item['parent'], 1000)
|
||||
|
||||
def test_filter_by_depleted(self):
|
||||
"""Filter StockItem by depleted status."""
|
||||
response = self.get_stock(depleted=1)
|
||||
|
|
@ -1044,15 +1027,7 @@ class StockItemListTest(StockAPITestCase):
|
|||
part = parts[idx % N_PARTS]
|
||||
location = locations[idx % N_LOCATIONS]
|
||||
|
||||
item = StockItem(
|
||||
part=part,
|
||||
location=location,
|
||||
quantity=10,
|
||||
level=0,
|
||||
tree_id=0,
|
||||
lft=0,
|
||||
rght=0,
|
||||
)
|
||||
item = StockItem(part=part, location=location, quantity=10)
|
||||
stock_items.append(item)
|
||||
idx += 1
|
||||
|
||||
|
|
@ -1168,8 +1143,7 @@ class StockItemListTest(StockAPITestCase):
|
|||
prt = Part.objects.first()
|
||||
|
||||
StockItem.objects.bulk_create([
|
||||
StockItem(part=prt, quantity=1, level=0, tree_id=0, lft=0, rght=0)
|
||||
for _ in range(100)
|
||||
StockItem(part=prt, quantity=1) for _ in range(100)
|
||||
])
|
||||
|
||||
# List *all* stock items
|
||||
|
|
@ -1249,7 +1223,7 @@ class StockItemListTest(StockAPITestCase):
|
|||
parent_item.refresh_from_db()
|
||||
|
||||
# Check that the parent item has 5 child items
|
||||
self.assertEqual(parent_item.get_descendants(include_self=False).count(), 5)
|
||||
self.assertEqual(parent_item.get_children().count(), 5)
|
||||
self.assertEqual(my_part.stock_items.count(), 6)
|
||||
|
||||
# Fetch stock list via API
|
||||
|
|
@ -1454,10 +1428,6 @@ class CustomStockItemStatusTest(StockAPITestCase):
|
|||
StockItem(
|
||||
part=part,
|
||||
quantity=1,
|
||||
level=0,
|
||||
tree_id=0,
|
||||
lft=0,
|
||||
rght=0,
|
||||
status=custom_statuses[i % 10].logical_key,
|
||||
status_custom_key=custom_statuses[i % 10].key,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -584,3 +584,82 @@ class TestCreationDateMigration(MigratorTestCase):
|
|||
# Scenario 6: updated=NULL, no stocktake, no tracking → creation_date stays NULL
|
||||
item = StockItem.objects.get(pk=self.pk_s6)
|
||||
self.assertIsNone(item.creation_date)
|
||||
|
||||
|
||||
class TestRemoveMpttFieldsMigration(MigratorTestCase):
|
||||
"""Test data migration which removes MPTT fields (level, lft, rght, tree_id) from StockItem.
|
||||
|
||||
The 'parent' field itself is untouched by this migration - only the MPTT
|
||||
bookkeeping fields are removed. This test ensures that parent-child
|
||||
relationships between StockItem objects survive the migration boundary.
|
||||
"""
|
||||
|
||||
migrate_from = ('stock', '0123_remove_stockitem_review_needed')
|
||||
migrate_to = ('stock', '0125_remove_mptt_fields')
|
||||
|
||||
def prepare(self):
|
||||
"""Create a tree of StockItem objects with parent-child relationships."""
|
||||
Part = self.old_state.apps.get_model('part', 'part')
|
||||
StockItem = self.old_state.apps.get_model('stock', 'stockitem')
|
||||
|
||||
part = Part.objects.create(
|
||||
name='Migration Test Part', level=0, tree_id=0, lft=0, rght=0
|
||||
)
|
||||
|
||||
# Root stock item, with no parent
|
||||
root = StockItem.objects.create(
|
||||
part=part, quantity=100, level=0, tree_id=0, lft=0, rght=0
|
||||
)
|
||||
|
||||
# A set of child items, parented to the root item
|
||||
children = [
|
||||
StockItem.objects.create(
|
||||
part=part, quantity=10, parent=root, level=1, tree_id=0, lft=0, rght=0
|
||||
)
|
||||
for _ in range(3)
|
||||
]
|
||||
|
||||
# A grandchild item, parented to the first child item
|
||||
grandchild = StockItem.objects.create(
|
||||
part=part, quantity=1, parent=children[0], level=2, tree_id=0, lft=0, rght=0
|
||||
)
|
||||
|
||||
# An unrelated top-level item, with no parent
|
||||
orphan = StockItem.objects.create(
|
||||
part=part, quantity=5, level=0, tree_id=0, lft=0, rght=0
|
||||
)
|
||||
|
||||
self.root_pk = root.pk
|
||||
self.child_pks = [child.pk for child in children]
|
||||
self.grandchild_pk = grandchild.pk
|
||||
self.orphan_pk = orphan.pk
|
||||
|
||||
self.assertEqual(StockItem.objects.count(), 6)
|
||||
|
||||
def test_migration(self):
|
||||
"""Test that parent associations are preserved after MPTT fields are removed."""
|
||||
StockItem = self.new_state.apps.get_model('stock', 'stockitem')
|
||||
|
||||
self.assertEqual(StockItem.objects.count(), 6)
|
||||
|
||||
# The MPTT bookkeeping fields should no longer exist on the model
|
||||
field_names = {field.name for field in StockItem._meta.get_fields()}
|
||||
for removed_field in ['level', 'lft', 'rght', 'tree_id']:
|
||||
self.assertNotIn(removed_field, field_names)
|
||||
|
||||
# The root item still has no parent
|
||||
root = StockItem.objects.get(pk=self.root_pk)
|
||||
self.assertIsNone(root.parent_id)
|
||||
|
||||
# Each child item is still correctly parented to the root item
|
||||
for pk in self.child_pks:
|
||||
child = StockItem.objects.get(pk=pk)
|
||||
self.assertEqual(child.parent_id, self.root_pk)
|
||||
|
||||
# The grandchild item is still correctly parented to the first child item
|
||||
grandchild = StockItem.objects.get(pk=self.grandchild_pk)
|
||||
self.assertEqual(grandchild.parent_id, self.child_pks[0])
|
||||
|
||||
# The orphan item still has no parent
|
||||
orphan = StockItem.objects.get(pk=self.orphan_pk)
|
||||
self.assertIsNone(orphan.parent_id)
|
||||
|
|
|
|||
|
|
@ -1138,7 +1138,6 @@ class VariantTest(StockTestBase):
|
|||
# Attempt to create the same serial number but for a variant (should fail!)
|
||||
# Reset the primary key and tree_id values
|
||||
item.pk = None
|
||||
item.tree_id = None
|
||||
item.part = Part.objects.get(pk=10004)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
|
|
@ -1353,187 +1352,6 @@ class StockLocationTreeTest(StockTestBase):
|
|||
self.assertEqual(C22.get_ancestors().count(), 1)
|
||||
|
||||
|
||||
class StockTreeTest(StockTestBase):
|
||||
"""Unit test for StockItem tree structure."""
|
||||
|
||||
def test_stock_split(self):
|
||||
"""Test that stock splitting works correctly."""
|
||||
part = Part.objects.create(name='My part', description='My part description')
|
||||
location = StockLocation.objects.create(name='Test Location')
|
||||
|
||||
# Create an initial stock item
|
||||
item = StockItem.objects.create(part=part, quantity=1000, location=location)
|
||||
|
||||
# Test that the initial MPTT values are correct
|
||||
self.assertEqual(item.level, 0)
|
||||
self.assertEqual(item.lft, 1)
|
||||
self.assertEqual(item.rght, 2)
|
||||
|
||||
children = []
|
||||
|
||||
self.assertEqual(item.get_descendants(include_self=False).count(), 0)
|
||||
self.assertEqual(item.get_descendants(include_self=True).count(), 1)
|
||||
|
||||
# Create child items by splitting stock
|
||||
for idx in range(10):
|
||||
child = item.splitStock(50, None, None)
|
||||
children.append(child)
|
||||
|
||||
# Check that the child item has been correctly created
|
||||
self.assertEqual(child.parent.pk, item.pk)
|
||||
self.assertEqual(child.tree_id, item.tree_id)
|
||||
self.assertEqual(child.level, 1)
|
||||
|
||||
item.refresh_from_db()
|
||||
self.assertEqual(item.get_children().count(), idx + 1)
|
||||
self.assertEqual(item.get_descendants(include_self=True).count(), idx + 2)
|
||||
|
||||
item.refresh_from_db()
|
||||
n = item.get_descendants(include_self=True).count()
|
||||
|
||||
for child in children:
|
||||
# Create multiple sub-childs
|
||||
for _idx in range(3):
|
||||
sub_child = child.splitStock(10, None, None)
|
||||
self.assertEqual(sub_child.parent.pk, child.pk)
|
||||
self.assertEqual(sub_child.tree_id, child.tree_id)
|
||||
self.assertEqual(sub_child.level, 2)
|
||||
|
||||
self.assertEqual(sub_child.get_ancestors(include_self=True).count(), 3)
|
||||
|
||||
child.refresh_from_db()
|
||||
self.assertEqual(child.get_descendants(include_self=True).count(), 4)
|
||||
|
||||
item.refresh_from_db()
|
||||
self.assertEqual(item.get_descendants(include_self=True).count(), n + 30)
|
||||
|
||||
def test_tree_rebuild(self):
|
||||
"""Test that tree rebuild works correctly."""
|
||||
part = Part.objects.create(name='My part', description='My part description')
|
||||
location = StockLocation.objects.create(name='Test Location')
|
||||
|
||||
N = StockItem.objects.count()
|
||||
|
||||
# Create an initial stock item
|
||||
item = StockItem.objects.create(part=part, quantity=1000, location=location)
|
||||
|
||||
# Split out ten child items
|
||||
for _idx in range(10):
|
||||
item.splitStock(10)
|
||||
|
||||
item.refresh_from_db()
|
||||
|
||||
self.assertEqual(StockItem.objects.count(), N + 11)
|
||||
self.assertEqual(item.get_children().count(), 10)
|
||||
self.assertEqual(item.get_descendants(include_self=True).count(), 11)
|
||||
|
||||
# Split the first child item
|
||||
child = item.get_children().first()
|
||||
|
||||
self.assertEqual(child.parent, item)
|
||||
self.assertEqual(child.tree_id, item.tree_id)
|
||||
self.assertEqual(child.level, 1)
|
||||
|
||||
# Split out three grandchildren
|
||||
for _ in range(3):
|
||||
child.splitStock(2)
|
||||
|
||||
item.refresh_from_db()
|
||||
child.refresh_from_db()
|
||||
|
||||
self.assertEqual(child.get_descendants(include_self=True).count(), 4)
|
||||
self.assertEqual(child.get_children().count(), 3)
|
||||
|
||||
# Check tree structure for grandchildren
|
||||
grandchildren = child.get_children()
|
||||
|
||||
for gc in grandchildren:
|
||||
self.assertEqual(gc.parent, child)
|
||||
self.assertEqual(gc.parent.parent, item)
|
||||
self.assertEqual(gc.tree_id, item.tree_id)
|
||||
self.assertEqual(gc.level, 2)
|
||||
self.assertGreater(gc.lft, child.lft)
|
||||
self.assertLess(gc.rght, child.rght)
|
||||
|
||||
self.assertEqual(item.get_children().count(), 10)
|
||||
self.assertEqual(item.get_descendants(include_self=True).count(), 14)
|
||||
|
||||
# Now, delete the child node
|
||||
# We expect that the grandchildren will be re-parented to the parent node
|
||||
child.delete()
|
||||
|
||||
for gc in grandchildren:
|
||||
gc.refresh_from_db()
|
||||
|
||||
# Check that the grandchildren have been re-parented to the top-level
|
||||
self.assertEqual(gc.parent, item)
|
||||
self.assertEqual(gc.tree_id, item.tree_id)
|
||||
self.assertEqual(gc.level, 1)
|
||||
self.assertGreater(gc.lft, item.lft)
|
||||
self.assertLess(gc.rght, item.rght)
|
||||
|
||||
item.refresh_from_db()
|
||||
|
||||
self.assertEqual(item.get_children().count(), 12)
|
||||
self.assertEqual(item.get_descendants(include_self=True).count(), 13)
|
||||
|
||||
def test_serialize(self):
|
||||
"""Test that StockItem serialization maintains tree structure."""
|
||||
part = Part.objects.create(
|
||||
name='My part', description='My part description', trackable=True
|
||||
)
|
||||
|
||||
N = StockItem.objects.count()
|
||||
|
||||
# Create an initial stock item
|
||||
item_1 = StockItem.objects.create(part=part, quantity=1000)
|
||||
item_2 = item_1.splitStock(750)
|
||||
|
||||
item_1.refresh_from_db()
|
||||
|
||||
self.assertEqual(StockItem.objects.count(), N + 2)
|
||||
self.assertEqual(item_1.get_children().count(), 1)
|
||||
self.assertEqual(item_2.parent, item_1)
|
||||
|
||||
loc = StockLocation.objects.filter(structural=False).first()
|
||||
|
||||
# Serialize the secondary item
|
||||
serials = [str(i) for i in range(20)]
|
||||
items = item_2.serializeStock(20, serials, location=loc)
|
||||
|
||||
self.assertEqual(len(items), 20)
|
||||
self.assertEqual(StockItem.objects.count(), N + 22)
|
||||
|
||||
item_1.refresh_from_db()
|
||||
item_2.refresh_from_db()
|
||||
|
||||
self.assertEqual(item_1.get_children().count(), 1)
|
||||
self.assertEqual(item_2.get_children().count(), 20)
|
||||
|
||||
for child in items:
|
||||
self.assertEqual(child.tree_id, item_2.tree_id)
|
||||
self.assertEqual(child.level, 2)
|
||||
self.assertEqual(child.parent, item_2)
|
||||
self.assertGreater(child.lft, item_2.lft)
|
||||
self.assertLess(child.rght, item_2.rght)
|
||||
self.assertEqual(child.location, loc)
|
||||
self.assertIsNotNone(child.location)
|
||||
self.assertEqual(child.tracking_info.count(), 2)
|
||||
|
||||
# Delete item_2 : we expect that all children will be re-parented to item_1
|
||||
item_2.delete()
|
||||
|
||||
for child in items:
|
||||
child.refresh_from_db()
|
||||
|
||||
# Check that the children have been re-parented to item_1
|
||||
self.assertEqual(child.parent, item_1)
|
||||
self.assertEqual(child.tree_id, item_1.tree_id)
|
||||
self.assertEqual(child.level, 1)
|
||||
self.assertGreater(child.lft, item_1.lft)
|
||||
self.assertLess(child.rght, item_1.rght)
|
||||
|
||||
|
||||
class TestResultTest(StockTestBase):
|
||||
"""Tests for the StockItemTestResult model."""
|
||||
|
||||
|
|
|
|||
|
|
@ -299,7 +299,7 @@ export default function StockDetail() {
|
|||
content: stockitem?.pk ? (
|
||||
<StockItemTable
|
||||
tableName='child-stock'
|
||||
params={{ ancestor: stockitem.pk }}
|
||||
params={{ parent: stockitem.pk }}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton />
|
||||
|
|
|
|||
Loading…
Reference in New Issue