mirror of
https://github.com/inventree/InvenTree.git
synced 2026-05-28 11:59:23 +00:00
[db] Stock creation date (#12011)
* Migrations * Add to serializer * Set the "creation_date" field to auto_now_add * Ordering and filtering * Add unit test * Add "has_stocktake" filter * Add test for data migration * Additional unit tests for StockItem API endpoint * Udpate API and CHANGELOG
This commit is contained in:
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- [#12011](https://github.com/inventree/InvenTree/pull/12011) adds a "creation_date" field to the StockItem API endpoint, allowing users to track when each stock item was created. This field is read-only and is automatically set to the current date and time when a new stock item is created.
|
||||||
- [#12000](https://github.com/inventree/InvenTree/pull/12000) adds support for auto-allocation of stock items against sales orders. This includes both backend and frontend changes, allowing users to trigger auto-allocation via the API or through the UI. The auto-allocation process will attempt to allocate available stock items to the sales order line items, based on the specified stock sorting and allocation rules.
|
- [#12000](https://github.com/inventree/InvenTree/pull/12000) adds support for auto-allocation of stock items against sales orders. This includes both backend and frontend changes, allowing users to trigger auto-allocation via the API or through the UI. The auto-allocation process will attempt to allocate available stock items to the sales order line items, based on the specified stock sorting and allocation rules.
|
||||||
- [#11920](https://github.com/inventree/InvenTree/pull/11920) adds support for renaming attachments after they have been uploaded. This includes both backend and frontend changes, allowing users to rename attachments via the API or through the UI.
|
- [#11920](https://github.com/inventree/InvenTree/pull/11920) adds support for renaming attachments after they have been uploaded. This includes both backend and frontend changes, allowing users to rename attachments via the API or through the UI.
|
||||||
- [#11914](https://github.com/inventree/InvenTree/pull/11914) adds a "maximum_stock" field to the Part model, allowing users to specify a maximum preferred stock level for each part. This is used in conjunction with the existing "minimum_stock" field to allow users to define a preferred stock range for each part. The "high_stock" filter has also been added to the Part API endpoint, allowing users to filter parts which are above their maximum stock level.
|
- [#11914](https://github.com/inventree/InvenTree/pull/11914) adds a "maximum_stock" field to the Part model, allowing users to specify a maximum preferred stock level for each part. This is used in conjunction with the existing "minimum_stock" field to allow users to define a preferred stock range for each part. The "high_stock" filter has also been added to the Part API endpoint, allowing users to filter parts which are above their maximum stock level.
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 495
|
INVENTREE_API_VERSION = 496
|
||||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
INVENTREE_API_TEXT = """
|
||||||
|
|
||||||
|
v496 -> 2026-05-26 : https://github.com/inventree/InvenTree/pull/12011
|
||||||
|
- Add "creation_date" field to the StockItem API endpoint
|
||||||
|
|
||||||
v495 -> 2026-05-25 : https://github.com/inventree/InvenTree/pull/12000
|
v495 -> 2026-05-25 : https://github.com/inventree/InvenTree/pull/12000
|
||||||
- Adds "auto-allocate" API endpoint for sales orders
|
- Adds "auto-allocate" API endpoint for sales orders
|
||||||
- Allow bulk-delete of SalesOrderAllocation objects via the API
|
- Allow bulk-delete of SalesOrderAllocation objects via the API
|
||||||
|
|||||||
@@ -1359,9 +1359,10 @@ class BuildOutputCreateTest(BuildAPITest):
|
|||||||
# Stock items have increased
|
# Stock items have increased
|
||||||
self.assertEqual(n_items + 5, part.stock_items.count())
|
self.assertEqual(n_items + 5, part.stock_items.count())
|
||||||
|
|
||||||
# Serial numbers have been created
|
# Serial numbers have been created, each with a creation_date
|
||||||
for sn in range(1, 6):
|
for sn in range(1, 6):
|
||||||
self.assertTrue(part.stock_items.filter(serial=sn).exists())
|
self.assertTrue(part.stock_items.filter(serial=sn).exists())
|
||||||
|
self.assertIsNotNone(part.stock_items.get(serial=sn).creation_date)
|
||||||
|
|
||||||
def test_create_unserialized_output(self):
|
def test_create_unserialized_output(self):
|
||||||
"""Create an unserialized build output via the API."""
|
"""Create an unserialized build output via the API."""
|
||||||
@@ -1383,6 +1384,9 @@ class BuildOutputCreateTest(BuildAPITest):
|
|||||||
# Stock items have increased
|
# Stock items have increased
|
||||||
self.assertEqual(n_items + 1, part.stock_items.count())
|
self.assertEqual(n_items + 1, part.stock_items.count())
|
||||||
|
|
||||||
|
# The new output must have a creation_date set
|
||||||
|
self.assertIsNotNone(part.stock_items.order_by('-pk').first().creation_date)
|
||||||
|
|
||||||
|
|
||||||
class BuildOutputScrapTest(BuildAPITest):
|
class BuildOutputScrapTest(BuildAPITest):
|
||||||
"""Unit tests for scrapping build outputs."""
|
"""Unit tests for scrapping build outputs."""
|
||||||
|
|||||||
@@ -1142,6 +1142,10 @@ class PurchaseOrderReceiveTest(OrderTest):
|
|||||||
self.assertEqual(stock_1.last().expiry_date, one_week_from_today)
|
self.assertEqual(stock_1.last().expiry_date, one_week_from_today)
|
||||||
self.assertEqual(stock_2.last().expiry_date, one_week_from_today)
|
self.assertEqual(stock_2.last().expiry_date, one_week_from_today)
|
||||||
|
|
||||||
|
# creation_date must be populated on both received items
|
||||||
|
self.assertIsNotNone(stock_1.last().creation_date)
|
||||||
|
self.assertIsNotNone(stock_2.last().creation_date)
|
||||||
|
|
||||||
# Barcodes should have been assigned to the stock items
|
# Barcodes should have been assigned to the stock items
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
StockItem.objects.filter(barcode_data='MY-UNIQUE-BARCODE-123').exists()
|
StockItem.objects.filter(barcode_data='MY-UNIQUE-BARCODE-123').exists()
|
||||||
@@ -1218,6 +1222,7 @@ class PurchaseOrderReceiveTest(OrderTest):
|
|||||||
self.assertEqual(item.serial, str(i))
|
self.assertEqual(item.serial, str(i))
|
||||||
self.assertEqual(item.quantity, 1)
|
self.assertEqual(item.quantity, 1)
|
||||||
self.assertEqual(item.batch, 'B-abc-123')
|
self.assertEqual(item.batch, 'B-abc-123')
|
||||||
|
self.assertIsNotNone(item.creation_date)
|
||||||
|
|
||||||
# A single stock item (quantity 10) created for the second line item
|
# A single stock item (quantity 10) created for the second line item
|
||||||
items = StockItem.objects.filter(supplier_part=line_2.part)
|
items = StockItem.objects.filter(supplier_part=line_2.part)
|
||||||
|
|||||||
@@ -916,7 +916,14 @@ class StockFilter(FilterSet):
|
|||||||
| Q(supplier_part__manufacturer_part__manufacturer=company)
|
| Q(supplier_part__manufacturer_part__manufacturer=company)
|
||||||
).distinct()
|
).distinct()
|
||||||
|
|
||||||
# Update date filters
|
created_before = InvenTreeDateFilter(
|
||||||
|
label=_('Created before'), field_name='creation_date', lookup_expr='lt'
|
||||||
|
)
|
||||||
|
|
||||||
|
created_after = InvenTreeDateFilter(
|
||||||
|
label=_('Created after'), field_name='creation_date', lookup_expr='gt'
|
||||||
|
)
|
||||||
|
|
||||||
updated_before = InvenTreeDateFilter(
|
updated_before = InvenTreeDateFilter(
|
||||||
label=_('Updated before'), field_name='updated', lookup_expr='lt'
|
label=_('Updated before'), field_name='updated', lookup_expr='lt'
|
||||||
)
|
)
|
||||||
@@ -933,6 +940,16 @@ class StockFilter(FilterSet):
|
|||||||
label=_('Stocktake After'), field_name='stocktake_date', lookup_expr='gt'
|
label=_('Stocktake After'), field_name='stocktake_date', lookup_expr='gt'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
has_stocktake = rest_filters.BooleanFilter(
|
||||||
|
label=_('Has Stocktake Date'), method='filter_has_stocktake'
|
||||||
|
)
|
||||||
|
|
||||||
|
def filter_has_stocktake(self, queryset, name, value):
|
||||||
|
"""Filter by whether or not the StockItem has a stocktake date."""
|
||||||
|
if str2bool(value):
|
||||||
|
return queryset.exclude(stocktake_date=None)
|
||||||
|
return queryset.filter(stocktake_date=None)
|
||||||
|
|
||||||
# Stock "expiry" filters
|
# Stock "expiry" filters
|
||||||
expiry_before = InvenTreeDateFilter(
|
expiry_before = InvenTreeDateFilter(
|
||||||
label=_('Expiry date before'), field_name='expiry_date', lookup_expr='lt'
|
label=_('Expiry date before'), field_name='expiry_date', lookup_expr='lt'
|
||||||
@@ -1296,6 +1313,7 @@ class StockList(
|
|||||||
'part__IPN',
|
'part__IPN',
|
||||||
'updated',
|
'updated',
|
||||||
'purchase_price',
|
'purchase_price',
|
||||||
|
'creation_date',
|
||||||
'stocktake_date',
|
'stocktake_date',
|
||||||
'expiry_date',
|
'expiry_date',
|
||||||
'packaging',
|
'packaging',
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.14 on 2026-05-26 08:49
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("stock", "0119_alter_stockitemtestresult_date"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="stockitem",
|
||||||
|
name="creation_date",
|
||||||
|
field=models.DateTimeField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Date that this stock item was created",
|
||||||
|
null=True,
|
||||||
|
verbose_name="Creation Date",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# Generated by Django 5.2.14 on 2026-05-26 08:49
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from tqdm import tqdm
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def set_creation_date(apps, schema_editor):
|
||||||
|
"""Set the creation_date field for existing StockItem entries."""
|
||||||
|
|
||||||
|
StockItem = apps.get_model('stock', 'StockItem')
|
||||||
|
StockItemTracking = apps.get_model('stock', 'StockItemTracking')
|
||||||
|
|
||||||
|
# First pass - find all StockHistoryCode.CREATED entries
|
||||||
|
# Note: As of 2026-05-26, the relevant status codes were:
|
||||||
|
# CREATED = 1
|
||||||
|
|
||||||
|
creation_entries = StockItemTracking.objects.filter(tracking_type=1)
|
||||||
|
|
||||||
|
items_to_update = []
|
||||||
|
|
||||||
|
def process_item(item):
|
||||||
|
"""Process a StockItem entry for update.
|
||||||
|
|
||||||
|
As we are iterating over a potentially large number of StockItem entries,
|
||||||
|
we periodically write updates to the database in batches to avoid memory issues.
|
||||||
|
"""
|
||||||
|
nonlocal items_to_update
|
||||||
|
|
||||||
|
items_to_update.append(item)
|
||||||
|
|
||||||
|
if len(items_to_update) >= 250:
|
||||||
|
StockItem.objects.bulk_update(items_to_update, ['creation_date'])
|
||||||
|
items_to_update = []
|
||||||
|
|
||||||
|
if creation_entries.count() > 0:
|
||||||
|
progress = tqdm(total=creation_entries.count(), desc='stock.0121: Setting creation_date for StockItem entries')
|
||||||
|
|
||||||
|
|
||||||
|
for entry in creation_entries:
|
||||||
|
item = StockItem.objects.filter(pk=entry.item_id).first()
|
||||||
|
if item and item.creation_date is None:
|
||||||
|
item.creation_date = entry.date
|
||||||
|
process_item(item)
|
||||||
|
|
||||||
|
progress.update(1)
|
||||||
|
|
||||||
|
progress.close()
|
||||||
|
|
||||||
|
# Next: Find any StockItem entries that have a null creation_date
|
||||||
|
items = StockItem.objects.filter(creation_date__isnull=True)
|
||||||
|
|
||||||
|
if items.count() > 0:
|
||||||
|
|
||||||
|
progress = tqdm(total=items.count(), desc='stock.0121: Setting creation_date for remaining StockItem entries')
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
# Source the creation_date from the stock item
|
||||||
|
earliest_entry = StockItemTracking.objects.filter(item_id=item.pk).order_by('date').first()
|
||||||
|
|
||||||
|
# Gather potential datetime sources for the creation_date field
|
||||||
|
utc = datetime.timezone.utc
|
||||||
|
|
||||||
|
def make_aware(dt):
|
||||||
|
"""Ensure a datetime is UTC-aware for safe cross-type comparison."""
|
||||||
|
return dt.replace(tzinfo=utc) if dt.tzinfo is None else dt.astimezone(utc)
|
||||||
|
|
||||||
|
raw_options = [
|
||||||
|
item.updated,
|
||||||
|
datetime.datetime(item.stocktake_date.year, item.stocktake_date.month, item.stocktake_date.day, tzinfo=utc) if item.stocktake_date else None,
|
||||||
|
earliest_entry.date if earliest_entry else None,
|
||||||
|
]
|
||||||
|
date_options = [make_aware(d) for d in raw_options if d is not None]
|
||||||
|
|
||||||
|
if date_options:
|
||||||
|
item.creation_date = min(date_options)
|
||||||
|
process_item(item)
|
||||||
|
|
||||||
|
progress.update(1)
|
||||||
|
|
||||||
|
# Update the remaining items
|
||||||
|
StockItem.objects.bulk_update(items_to_update, ['creation_date'], batch_size=250)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
"""This migration extracts the 'creation_date' field for each StockItem entry.
|
||||||
|
|
||||||
|
- If a CREATED entry exists in the StockItemTracking table, this is used as the source for the creation_date field.
|
||||||
|
- If no CREATED entry exists, the creation_date is sourced from the earliest of the following
|
||||||
|
- The 'updated' field of the StockItem entry
|
||||||
|
- The 'stocktake_date' field of the StockItem entry (converted to a datetime)
|
||||||
|
- The earliest entry in the StockItemTracking table for that item
|
||||||
|
"""
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("stock", "0120_stockitem_creation_date"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(set_creation_date, reverse_code=migrations.RunPython.noop),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.14 on 2026-05-26 09:32
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("stock", "0121_auto_20260526_0849"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="stockitem",
|
||||||
|
name="creation_date",
|
||||||
|
field=models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
help_text="Date that this stock item was created",
|
||||||
|
null=True,
|
||||||
|
verbose_name="Creation Date",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -443,7 +443,8 @@ class StockItem(
|
|||||||
batch: Batch number for this StockItem
|
batch: Batch number for this StockItem
|
||||||
serial: Unique serial number for this StockItem
|
serial: Unique serial number for this StockItem
|
||||||
link: Optional URL to link to external resource
|
link: Optional URL to link to external resource
|
||||||
updated: Date that this stock item was last updated (auto)
|
creation_date: Date that this stock item was created (auto)
|
||||||
|
updated: Date that the quantity of this stock item was last updated (auto)
|
||||||
expiry_date: Expiry date of the StockItem (optional)
|
expiry_date: Expiry date of the StockItem (optional)
|
||||||
stocktake_date: Date of last stocktake for this item
|
stocktake_date: Date of last stocktake for this item
|
||||||
stocktake_user: User that performed the most recent stocktake
|
stocktake_user: User that performed the most recent stocktake
|
||||||
@@ -1227,6 +1228,15 @@ class StockItem(
|
|||||||
related_name='stocktake_stock',
|
related_name='stocktake_stock',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
creation_date = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
auto_now_add=True,
|
||||||
|
editable=False,
|
||||||
|
verbose_name=_('Creation Date'),
|
||||||
|
help_text=_('Date that this stock item was created'),
|
||||||
|
)
|
||||||
|
|
||||||
review_needed = models.BooleanField(default=False)
|
review_needed = models.BooleanField(default=False)
|
||||||
|
|
||||||
delete_on_deplete = models.BooleanField(
|
delete_on_deplete = models.BooleanField(
|
||||||
|
|||||||
@@ -371,8 +371,9 @@ class StockItemSerializer(
|
|||||||
'SKU',
|
'SKU',
|
||||||
'MPN',
|
'MPN',
|
||||||
'barcode_hash',
|
'barcode_hash',
|
||||||
'updated',
|
'creation_date',
|
||||||
'stocktake_date',
|
'stocktake_date',
|
||||||
|
'updated',
|
||||||
'purchase_price',
|
'purchase_price',
|
||||||
'purchase_price_currency',
|
'purchase_price_currency',
|
||||||
'use_pack_size',
|
'use_pack_size',
|
||||||
@@ -395,6 +396,7 @@ class StockItemSerializer(
|
|||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
'allocated',
|
'allocated',
|
||||||
'barcode_hash',
|
'barcode_hash',
|
||||||
|
'creation_date',
|
||||||
'stocktake_date',
|
'stocktake_date',
|
||||||
'stocktake_user',
|
'stocktake_user',
|
||||||
'updated',
|
'updated',
|
||||||
|
|||||||
@@ -545,6 +545,62 @@ class StockItemListTest(StockAPITestCase):
|
|||||||
for ordering in ['part', 'location', 'stock', 'status', 'IPN', 'MPN', 'SKU']:
|
for ordering in ['part', 'location', 'stock', 'status', 'IPN', 'MPN', 'SKU']:
|
||||||
self.run_ordering_test(self.list_url, ordering)
|
self.run_ordering_test(self.list_url, ordering)
|
||||||
|
|
||||||
|
def test_creation_date_filter_and_ordering(self):
|
||||||
|
"""Test created_before / created_after filters and ordering by creation_date."""
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
part = Part.objects.first()
|
||||||
|
location = StockLocation.objects.first()
|
||||||
|
|
||||||
|
# Create items with known, spread-out creation_dates via UPDATE after insert
|
||||||
|
dates = [
|
||||||
|
datetime.date(2020, 1, 1),
|
||||||
|
datetime.date(2021, 6, 15),
|
||||||
|
datetime.date(2023, 3, 30),
|
||||||
|
]
|
||||||
|
pks = []
|
||||||
|
for d in dates:
|
||||||
|
item = StockItem.objects.create(part=part, location=location, quantity=1)
|
||||||
|
StockItem.objects.filter(pk=item.pk).update(creation_date=d)
|
||||||
|
pks.append(item.pk)
|
||||||
|
|
||||||
|
# created_after=2020-12-31 should exclude the 2020 item
|
||||||
|
result_pks = [r['pk'] for r in self.get_stock(created_after='2020-12-31')]
|
||||||
|
self.assertNotIn(pks[0], result_pks)
|
||||||
|
self.assertIn(pks[1], result_pks)
|
||||||
|
self.assertIn(pks[2], result_pks)
|
||||||
|
|
||||||
|
# created_before=2022-01-01 should exclude the 2023 item
|
||||||
|
result_pks = [r['pk'] for r in self.get_stock(created_before='2022-01-01')]
|
||||||
|
self.assertIn(pks[0], result_pks)
|
||||||
|
self.assertIn(pks[1], result_pks)
|
||||||
|
self.assertNotIn(pks[2], result_pks)
|
||||||
|
|
||||||
|
# combined: only the 2021 item falls in the window
|
||||||
|
result_pks = [
|
||||||
|
r['pk']
|
||||||
|
for r in self.get_stock(
|
||||||
|
created_after='2020-12-31', created_before='2022-01-01'
|
||||||
|
)
|
||||||
|
]
|
||||||
|
self.assertNotIn(pks[0], result_pks)
|
||||||
|
self.assertIn(pks[1], result_pks)
|
||||||
|
self.assertNotIn(pks[2], result_pks)
|
||||||
|
|
||||||
|
# ordering=creation_date: our three items must appear in ascending date order
|
||||||
|
results = self.get(
|
||||||
|
self.list_url, {'ordering': 'creation_date'}, expected_code=200
|
||||||
|
).data
|
||||||
|
ordered_pks = [r['pk'] for r in results if r['pk'] in pks]
|
||||||
|
self.assertEqual(ordered_pks, pks)
|
||||||
|
|
||||||
|
# ordering=-creation_date: descending
|
||||||
|
results = self.get(
|
||||||
|
self.list_url, {'ordering': '-creation_date'}, expected_code=200
|
||||||
|
).data
|
||||||
|
ordered_pks = [r['pk'] for r in results if r['pk'] in pks]
|
||||||
|
self.assertEqual(ordered_pks, list(reversed(pks)))
|
||||||
|
|
||||||
def test_pagination(self):
|
def test_pagination(self):
|
||||||
"""Test that pagination boundaries are observed correctly.
|
"""Test that pagination boundaries are observed correctly.
|
||||||
|
|
||||||
@@ -1454,6 +1510,49 @@ class StockItemTest(StockAPITestCase):
|
|||||||
data={'part': 1, 'location': 1, 'quantity': 10},
|
data={'part': 1, 'location': 1, 'quantity': 10},
|
||||||
expected_code=201,
|
expected_code=201,
|
||||||
)
|
)
|
||||||
|
# creation_date must be populated on the newly created item
|
||||||
|
item = StockItem.objects.get(pk=response.data[0]['pk'])
|
||||||
|
self.assertIsNotNone(item.creation_date)
|
||||||
|
|
||||||
|
def test_creation_date_is_readonly(self):
|
||||||
|
"""creation_date must not be modifiable via the API."""
|
||||||
|
item = StockItem.objects.create(
|
||||||
|
part=Part.objects.get(pk=1),
|
||||||
|
location=StockLocation.objects.get(pk=1),
|
||||||
|
quantity=1,
|
||||||
|
)
|
||||||
|
original_date = item.creation_date
|
||||||
|
self.assertIsNotNone(original_date)
|
||||||
|
|
||||||
|
url = reverse('api-stock-detail', kwargs={'pk': item.pk})
|
||||||
|
self.patch(
|
||||||
|
url, data={'creation_date': '2000-01-01T00:00:00Z'}, expected_code=200
|
||||||
|
)
|
||||||
|
# Field is read-only; the DB value must be unchanged
|
||||||
|
item.refresh_from_db()
|
||||||
|
self.assertEqual(item.creation_date, original_date)
|
||||||
|
|
||||||
|
def test_creation_date_set_on_serialize(self):
|
||||||
|
"""creation_date must be set on items produced by the serialize endpoint."""
|
||||||
|
# Stock item 100: part 25 (trackable), quantity 10, location 7
|
||||||
|
item = StockItem.objects.get(pk=100)
|
||||||
|
url = reverse('api-stock-item-serialize', kwargs={'pk': item.pk})
|
||||||
|
|
||||||
|
self.post(
|
||||||
|
url,
|
||||||
|
data={
|
||||||
|
'quantity': 3,
|
||||||
|
'serial_numbers': '901,902,903',
|
||||||
|
'destination': item.location.pk,
|
||||||
|
},
|
||||||
|
expected_code=201,
|
||||||
|
)
|
||||||
|
new_items = StockItem.objects.filter(
|
||||||
|
part=item.part, serial__in=['901', '902', '903']
|
||||||
|
)
|
||||||
|
self.assertEqual(new_items.count(), 3)
|
||||||
|
for new_item in new_items:
|
||||||
|
self.assertIsNotNone(new_item.creation_date)
|
||||||
|
|
||||||
def test_stock_item_create_with_supplier_part(self):
|
def test_stock_item_create_with_supplier_part(self):
|
||||||
"""Test creation of a StockItem via the API, including SupplierPart data."""
|
"""Test creation of a StockItem via the API, including SupplierPart data."""
|
||||||
@@ -1597,6 +1696,7 @@ class StockItemTest(StockAPITestCase):
|
|||||||
# Item location should have been set automatically
|
# Item location should have been set automatically
|
||||||
self.assertIsNotNone(item.location)
|
self.assertIsNotNone(item.location)
|
||||||
self.assertIn(item.serial, serials)
|
self.assertIn(item.serial, serials)
|
||||||
|
self.assertIsNotNone(item.creation_date)
|
||||||
|
|
||||||
# There now should be 10 unique stock entries for this part
|
# There now should be 10 unique stock entries for this part
|
||||||
self.assertEqual(trackable_part.stock_entries().count(), 10)
|
self.assertEqual(trackable_part.stock_entries().count(), 10)
|
||||||
|
|||||||
@@ -415,3 +415,172 @@ class TestStockItemTrackingMigration(MigratorTestCase):
|
|||||||
)
|
)
|
||||||
self.assertIn('salesorder', item.deltas)
|
self.assertIn('salesorder', item.deltas)
|
||||||
self.assertEqual(item.deltas['salesorder'], 1)
|
self.assertEqual(item.deltas['salesorder'], 1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreationDateMigration(MigratorTestCase):
|
||||||
|
"""Test the backfill data migration for StockItem.creation_date (stock.0121).
|
||||||
|
|
||||||
|
The migration has two passes:
|
||||||
|
- Pass 1: items with a CREATED (tracking_type=1) entry get creation_date from that entry.
|
||||||
|
- Pass 2: remaining nulls get min(updated, stocktake_date, earliest_tracking_entry).
|
||||||
|
|
||||||
|
Six scenarios are exercised to cover every meaningful code path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
migrate_from = ('stock', '0119_alter_stockitemtestresult_date')
|
||||||
|
migrate_to = ('stock', '0122_alter_stockitem_creation_date')
|
||||||
|
|
||||||
|
def prepare(self):
|
||||||
|
"""Create StockItem entries with varied data to exercise all backfill paths."""
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from django.db import connection
|
||||||
|
|
||||||
|
Part = self.old_state.apps.get_model('part', 'part')
|
||||||
|
StockItemTracking = self.old_state.apps.get_model('stock', 'stockitemtracking')
|
||||||
|
|
||||||
|
utc = datetime.timezone.utc
|
||||||
|
|
||||||
|
part = Part.objects.create(
|
||||||
|
name='Migration Test Part', level=0, tree_id=1, lft=0, rght=0
|
||||||
|
)
|
||||||
|
|
||||||
|
def make_item(stocktake_date=None):
|
||||||
|
"""Insert a StockItem row via raw SQL, bypassing the duplicate-column ORM bug.
|
||||||
|
|
||||||
|
The historical model at migration 0119 has status_custom_key enumerated twice
|
||||||
|
(once from contribute_to_class on the status field, once from migration 0113's
|
||||||
|
explicit AddField), so ORM-generated INSERTs fail with
|
||||||
|
'column specified more than once'. Raw SQL avoids the ORM field list entirely.
|
||||||
|
Raw SQL also leaves updated=NULL (no DB-level default for auto_now), which
|
||||||
|
makes Scenario 6 a clean "no date sources available" case.
|
||||||
|
"""
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO stock_stockitem
|
||||||
|
(part_id, quantity, level, tree_id, lft, rght,
|
||||||
|
status, delete_on_deplete, review_needed, is_building,
|
||||||
|
link, serial_int, barcode_data, barcode_hash)
|
||||||
|
VALUES (%s, 1, 0, 0, 0, 0, 10, false, false, false, '', 0, '', '')
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
[part.pk],
|
||||||
|
)
|
||||||
|
pk = cursor.fetchone()[0]
|
||||||
|
if stocktake_date is not None:
|
||||||
|
cursor.execute(
|
||||||
|
'UPDATE stock_stockitem SET stocktake_date = %s WHERE id = %s',
|
||||||
|
[stocktake_date, pk],
|
||||||
|
)
|
||||||
|
return pk
|
||||||
|
|
||||||
|
def add_tracking(pk, tracking_type, date):
|
||||||
|
"""Create a tracking entry, then override its auto_now_add date."""
|
||||||
|
entry = StockItemTracking.objects.create(
|
||||||
|
item_id=pk, tracking_type=tracking_type
|
||||||
|
)
|
||||||
|
# auto_now_add prevents setting date on INSERT; use UPDATE to set a historical value
|
||||||
|
StockItemTracking.objects.filter(pk=entry.pk).update(date=date)
|
||||||
|
|
||||||
|
# --- Scenario 1 ---
|
||||||
|
# Item with a single CREATED (type=1) tracking entry.
|
||||||
|
# Pass 1 should set creation_date = that entry's date.
|
||||||
|
pk = make_item()
|
||||||
|
add_tracking(pk, 1, datetime.datetime(2022, 1, 15, 10, 0, 0, tzinfo=utc))
|
||||||
|
self.pk_s1 = pk
|
||||||
|
self.expected_s1 = datetime.datetime(2022, 1, 15, 10, 0, 0, tzinfo=utc)
|
||||||
|
|
||||||
|
# --- Scenario 2 ---
|
||||||
|
# Item with a CREATED entry (newer date) AND an older non-CREATED entry.
|
||||||
|
# Pass 1 sets creation_date = CREATED entry date; older entry is ignored.
|
||||||
|
# This verifies pass 1 wins over pass 2's min() logic.
|
||||||
|
pk = make_item()
|
||||||
|
add_tracking(
|
||||||
|
pk, 1, datetime.datetime(2023, 6, 1, 0, 0, 0, tzinfo=utc)
|
||||||
|
) # CREATED, newer
|
||||||
|
add_tracking(
|
||||||
|
pk, 2, datetime.datetime(2018, 3, 10, 0, 0, 0, tzinfo=utc)
|
||||||
|
) # non-CREATED, older
|
||||||
|
self.pk_s2 = pk
|
||||||
|
self.expected_s2 = datetime.datetime(2023, 6, 1, 0, 0, 0, tzinfo=utc)
|
||||||
|
self.rejected_s2 = datetime.date(2018, 3, 10)
|
||||||
|
|
||||||
|
# --- Scenario 3 ---
|
||||||
|
# Item with only non-CREATED tracking entries.
|
||||||
|
# Pass 2 uses min(earliest_entry_date), so earliest entry wins.
|
||||||
|
pk = make_item()
|
||||||
|
add_tracking(
|
||||||
|
pk, 2, datetime.datetime(2021, 7, 20, 0, 0, 0, tzinfo=utc)
|
||||||
|
) # later
|
||||||
|
add_tracking(
|
||||||
|
pk, 3, datetime.datetime(2020, 2, 14, 0, 0, 0, tzinfo=utc)
|
||||||
|
) # earliest
|
||||||
|
self.pk_s3 = pk
|
||||||
|
self.expected_s3 = datetime.datetime(2020, 2, 14, 0, 0, 0, tzinfo=utc)
|
||||||
|
|
||||||
|
# --- Scenario 4 ---
|
||||||
|
# Item with only stocktake_date set; no tracking entries.
|
||||||
|
# Pass 2 uses stocktake_as_datetime (in the past) as the date.
|
||||||
|
pk = make_item(stocktake_date=datetime.date(2019, 11, 5))
|
||||||
|
self.pk_s4 = pk
|
||||||
|
self.expected_s4_date = datetime.date(2019, 11, 5)
|
||||||
|
|
||||||
|
# --- Scenario 5 ---
|
||||||
|
# Item with stocktake_date AND a non-CREATED tracking entry where the tracking
|
||||||
|
# entry is older than stocktake_date.
|
||||||
|
# Pass 2 uses min(stocktake, tracking); tracking wins.
|
||||||
|
pk = make_item(stocktake_date=datetime.date(2021, 4, 1))
|
||||||
|
add_tracking(pk, 2, datetime.datetime(2017, 8, 22, 0, 0, 0, tzinfo=utc))
|
||||||
|
self.pk_s5 = pk
|
||||||
|
self.expected_s5 = datetime.datetime(2017, 8, 22, 0, 0, 0, tzinfo=utc)
|
||||||
|
|
||||||
|
# --- Scenario 6 ---
|
||||||
|
# Item inserted via raw SQL with no stocktake_date and no tracking entries,
|
||||||
|
# so updated=NULL. No date sources exist → creation_date stays NULL after migration.
|
||||||
|
pk = make_item()
|
||||||
|
self.pk_s6 = pk
|
||||||
|
|
||||||
|
def test_migration(self):
|
||||||
|
"""Verify creation_date is correctly backfilled for each scenario."""
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
StockItem = self.new_state.apps.get_model('stock', 'stockitem')
|
||||||
|
utc = datetime.timezone.utc
|
||||||
|
|
||||||
|
def at_utc(dt):
|
||||||
|
"""Normalise to UTC and strip sub-second precision for comparison."""
|
||||||
|
return dt.astimezone(utc).replace(microsecond=0)
|
||||||
|
|
||||||
|
# Scenario 1: CREATED tracking entry → creation_date = entry date
|
||||||
|
item = StockItem.objects.get(pk=self.pk_s1)
|
||||||
|
self.assertIsNotNone(item.creation_date)
|
||||||
|
self.assertEqual(at_utc(item.creation_date), self.expected_s1)
|
||||||
|
|
||||||
|
# Scenario 2: CREATED entry (newer) wins over older non-CREATED entry
|
||||||
|
item = StockItem.objects.get(pk=self.pk_s2)
|
||||||
|
self.assertIsNotNone(item.creation_date)
|
||||||
|
self.assertEqual(at_utc(item.creation_date), self.expected_s2)
|
||||||
|
# Explicitly confirm the older non-CREATED date was NOT chosen
|
||||||
|
self.assertNotEqual(item.creation_date.astimezone(utc).date(), self.rejected_s2)
|
||||||
|
|
||||||
|
# Scenario 3: Earliest non-CREATED tracking entry wins (pass 2 min())
|
||||||
|
item = StockItem.objects.get(pk=self.pk_s3)
|
||||||
|
self.assertIsNotNone(item.creation_date)
|
||||||
|
self.assertEqual(at_utc(item.creation_date), self.expected_s3)
|
||||||
|
|
||||||
|
# Scenario 4: stocktake_date (past) wins over auto_now 'updated' (now)
|
||||||
|
item = StockItem.objects.get(pk=self.pk_s4)
|
||||||
|
self.assertIsNotNone(item.creation_date)
|
||||||
|
self.assertEqual(
|
||||||
|
item.creation_date.astimezone(utc).date(), self.expected_s4_date
|
||||||
|
)
|
||||||
|
|
||||||
|
# Scenario 5: Oldest tracking entry wins over stocktake_date (pass 2 min())
|
||||||
|
item = StockItem.objects.get(pk=self.pk_s5)
|
||||||
|
self.assertIsNotNone(item.creation_date)
|
||||||
|
self.assertEqual(at_utc(item.creation_date), self.expected_s5)
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|||||||
@@ -308,7 +308,7 @@ export function UpdatedAfterFilter(): TableFilter {
|
|||||||
return {
|
return {
|
||||||
name: 'updated_after',
|
name: 'updated_after',
|
||||||
label: t`Updated After`,
|
label: t`Updated After`,
|
||||||
description: t`Show orders updated after this date`,
|
description: t`Show items updated after this date`,
|
||||||
type: 'date'
|
type: 'date'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -317,7 +317,7 @@ export function UpdatedBeforeFilter(): TableFilter {
|
|||||||
return {
|
return {
|
||||||
name: 'updated_before',
|
name: 'updated_before',
|
||||||
label: t`Updated Before`,
|
label: t`Updated Before`,
|
||||||
description: t`Show orders updated before this date`,
|
description: t`Show items updated before this date`,
|
||||||
type: 'date'
|
type: 'date'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import {
|
|||||||
} from '../ColumnRenderers';
|
} from '../ColumnRenderers';
|
||||||
import {
|
import {
|
||||||
BatchFilter,
|
BatchFilter,
|
||||||
|
CreatedAfterFilter,
|
||||||
|
CreatedBeforeFilter,
|
||||||
HasBatchCodeFilter,
|
HasBatchCodeFilter,
|
||||||
InStockFilter,
|
InStockFilter,
|
||||||
IncludeVariantsFilter,
|
IncludeVariantsFilter,
|
||||||
@@ -41,7 +43,9 @@ import {
|
|||||||
SerialGTEFilter,
|
SerialGTEFilter,
|
||||||
SerialLTEFilter,
|
SerialLTEFilter,
|
||||||
StatusFilterOptions,
|
StatusFilterOptions,
|
||||||
SupplierFilter
|
SupplierFilter,
|
||||||
|
UpdatedAfterFilter,
|
||||||
|
UpdatedBeforeFilter
|
||||||
} from '../Filter';
|
} from '../Filter';
|
||||||
import { InvenTreeTable } from '../InvenTreeTable';
|
import { InvenTreeTable } from '../InvenTreeTable';
|
||||||
|
|
||||||
@@ -143,17 +147,21 @@ function stockItemTableColumns({
|
|||||||
sortable: true,
|
sortable: true,
|
||||||
defaultVisible: false
|
defaultVisible: false
|
||||||
},
|
},
|
||||||
|
DateColumn({
|
||||||
|
title: t`Created`,
|
||||||
|
accessor: 'creation_date',
|
||||||
|
sortable: true
|
||||||
|
}),
|
||||||
|
DateColumn({
|
||||||
|
title: t`Last Updated`,
|
||||||
|
accessor: 'updated'
|
||||||
|
}),
|
||||||
DateColumn({
|
DateColumn({
|
||||||
title: t`Expiry Date`,
|
title: t`Expiry Date`,
|
||||||
accessor: 'expiry_date',
|
accessor: 'expiry_date',
|
||||||
hidden: !useGlobalSettingsState.getState().isSet('STOCK_ENABLE_EXPIRY'),
|
hidden: !useGlobalSettingsState.getState().isSet('STOCK_ENABLE_EXPIRY'),
|
||||||
defaultVisible: false
|
defaultVisible: false
|
||||||
}),
|
}),
|
||||||
DateColumn({
|
|
||||||
title: t`Last Updated`,
|
|
||||||
accessor: 'updated'
|
|
||||||
}),
|
|
||||||
DateColumn({
|
DateColumn({
|
||||||
accessor: 'stocktake_date',
|
accessor: 'stocktake_date',
|
||||||
title: t`Stocktake Date`,
|
title: t`Stocktake Date`,
|
||||||
@@ -273,18 +281,10 @@ function stockItemTableFilters({
|
|||||||
type: 'date',
|
type: 'date',
|
||||||
active: enableExpiry
|
active: enableExpiry
|
||||||
},
|
},
|
||||||
{
|
UpdatedBeforeFilter(),
|
||||||
name: 'updated_before',
|
UpdatedAfterFilter(),
|
||||||
label: t`Updated Before`,
|
CreatedBeforeFilter(),
|
||||||
description: t`Show items updated before this date`,
|
CreatedAfterFilter(),
|
||||||
type: 'date'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'updated_after',
|
|
||||||
label: t`Updated After`,
|
|
||||||
description: t`Show items updated after this date`,
|
|
||||||
type: 'date'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'stocktake_before',
|
name: 'stocktake_before',
|
||||||
label: t`Stocktake Before`,
|
label: t`Stocktake Before`,
|
||||||
@@ -297,6 +297,11 @@ function stockItemTableFilters({
|
|||||||
description: t`Show items counted after this date`,
|
description: t`Show items counted after this date`,
|
||||||
type: 'date'
|
type: 'date'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'has_stocktake',
|
||||||
|
label: t`Has Stocktake Date`,
|
||||||
|
description: t`Show items which have a stocktake date`
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'external',
|
name: 'external',
|
||||||
label: t`External Location`,
|
label: t`External Location`,
|
||||||
|
|||||||
Reference in New Issue
Block a user