diff --git a/CHANGELOG.md b/CHANGELOG.md index 013e8907b4..45f4a02dd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. - [#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. diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index dc09802eff..68c7f97f70 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # 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.""" 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 - Adds "auto-allocate" API endpoint for sales orders - Allow bulk-delete of SalesOrderAllocation objects via the API diff --git a/src/backend/InvenTree/build/test_api.py b/src/backend/InvenTree/build/test_api.py index 32a97e27cd..5ba8824b3a 100644 --- a/src/backend/InvenTree/build/test_api.py +++ b/src/backend/InvenTree/build/test_api.py @@ -1359,9 +1359,10 @@ class BuildOutputCreateTest(BuildAPITest): # Stock items have increased 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): 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): """Create an unserialized build output via the API.""" @@ -1383,6 +1384,9 @@ class BuildOutputCreateTest(BuildAPITest): # Stock items have increased 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): """Unit tests for scrapping build outputs.""" diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index 87be1c8d29..5f686d6b5f 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -1142,6 +1142,10 @@ class PurchaseOrderReceiveTest(OrderTest): self.assertEqual(stock_1.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 self.assertTrue( 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.quantity, 1) self.assertEqual(item.batch, 'B-abc-123') + self.assertIsNotNone(item.creation_date) # A single stock item (quantity 10) created for the second line item items = StockItem.objects.filter(supplier_part=line_2.part) diff --git a/src/backend/InvenTree/stock/api.py b/src/backend/InvenTree/stock/api.py index b93b8edf2c..d1eff6b112 100644 --- a/src/backend/InvenTree/stock/api.py +++ b/src/backend/InvenTree/stock/api.py @@ -916,7 +916,14 @@ class StockFilter(FilterSet): | Q(supplier_part__manufacturer_part__manufacturer=company) ).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( 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' ) + 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 expiry_before = InvenTreeDateFilter( label=_('Expiry date before'), field_name='expiry_date', lookup_expr='lt' @@ -1296,6 +1313,7 @@ class StockList( 'part__IPN', 'updated', 'purchase_price', + 'creation_date', 'stocktake_date', 'expiry_date', 'packaging', diff --git a/src/backend/InvenTree/stock/migrations/0120_stockitem_creation_date.py b/src/backend/InvenTree/stock/migrations/0120_stockitem_creation_date.py new file mode 100644 index 0000000000..e4abb47560 --- /dev/null +++ b/src/backend/InvenTree/stock/migrations/0120_stockitem_creation_date.py @@ -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", + ), + ), + ] diff --git a/src/backend/InvenTree/stock/migrations/0121_auto_20260526_0849.py b/src/backend/InvenTree/stock/migrations/0121_auto_20260526_0849.py new file mode 100644 index 0000000000..d6e5087a8e --- /dev/null +++ b/src/backend/InvenTree/stock/migrations/0121_auto_20260526_0849.py @@ -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), + ] diff --git a/src/backend/InvenTree/stock/migrations/0122_alter_stockitem_creation_date.py b/src/backend/InvenTree/stock/migrations/0122_alter_stockitem_creation_date.py new file mode 100644 index 0000000000..c26c17b8b1 --- /dev/null +++ b/src/backend/InvenTree/stock/migrations/0122_alter_stockitem_creation_date.py @@ -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", + ), + ), + ] diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index 0f4d10536c..a89a94d26d 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -443,7 +443,8 @@ class StockItem( batch: Batch number for this StockItem serial: Unique serial number for this StockItem 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) stocktake_date: Date of last stocktake for this item stocktake_user: User that performed the most recent stocktake @@ -1227,6 +1228,15 @@ class StockItem( 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) delete_on_deplete = models.BooleanField( diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index 570b4f7e51..0bb12450a8 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -371,8 +371,9 @@ class StockItemSerializer( 'SKU', 'MPN', 'barcode_hash', - 'updated', + 'creation_date', 'stocktake_date', + 'updated', 'purchase_price', 'purchase_price_currency', 'use_pack_size', @@ -395,6 +396,7 @@ class StockItemSerializer( read_only_fields = [ 'allocated', 'barcode_hash', + 'creation_date', 'stocktake_date', 'stocktake_user', 'updated', diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index d274fffafa..27de523ec8 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -545,6 +545,62 @@ class StockItemListTest(StockAPITestCase): for ordering in ['part', 'location', 'stock', 'status', 'IPN', 'MPN', 'SKU']: 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): """Test that pagination boundaries are observed correctly. @@ -1454,6 +1510,49 @@ class StockItemTest(StockAPITestCase): data={'part': 1, 'location': 1, 'quantity': 10}, 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): """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 self.assertIsNotNone(item.location) self.assertIn(item.serial, serials) + self.assertIsNotNone(item.creation_date) # There now should be 10 unique stock entries for this part self.assertEqual(trackable_part.stock_entries().count(), 10) diff --git a/src/backend/InvenTree/stock/test_migrations.py b/src/backend/InvenTree/stock/test_migrations.py index 7901239951..c26b881f42 100644 --- a/src/backend/InvenTree/stock/test_migrations.py +++ b/src/backend/InvenTree/stock/test_migrations.py @@ -415,3 +415,172 @@ class TestStockItemTrackingMigration(MigratorTestCase): ) self.assertIn('salesorder', item.deltas) 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) diff --git a/src/frontend/src/tables/Filter.tsx b/src/frontend/src/tables/Filter.tsx index a7d547fdc9..81221bd2d2 100644 --- a/src/frontend/src/tables/Filter.tsx +++ b/src/frontend/src/tables/Filter.tsx @@ -308,7 +308,7 @@ export function UpdatedAfterFilter(): TableFilter { return { name: 'updated_after', label: t`Updated After`, - description: t`Show orders updated after this date`, + description: t`Show items updated after this date`, type: 'date' }; } @@ -317,7 +317,7 @@ export function UpdatedBeforeFilter(): TableFilter { return { name: 'updated_before', label: t`Updated Before`, - description: t`Show orders updated before this date`, + description: t`Show items updated before this date`, type: 'date' }; } diff --git a/src/frontend/src/tables/stock/StockItemTable.tsx b/src/frontend/src/tables/stock/StockItemTable.tsx index 2f65c74edb..8e9f9c01da 100644 --- a/src/frontend/src/tables/stock/StockItemTable.tsx +++ b/src/frontend/src/tables/stock/StockItemTable.tsx @@ -32,6 +32,8 @@ import { } from '../ColumnRenderers'; import { BatchFilter, + CreatedAfterFilter, + CreatedBeforeFilter, HasBatchCodeFilter, InStockFilter, IncludeVariantsFilter, @@ -41,7 +43,9 @@ import { SerialGTEFilter, SerialLTEFilter, StatusFilterOptions, - SupplierFilter + SupplierFilter, + UpdatedAfterFilter, + UpdatedBeforeFilter } from '../Filter'; import { InvenTreeTable } from '../InvenTreeTable'; @@ -143,17 +147,21 @@ function stockItemTableColumns({ sortable: true, defaultVisible: false }, - + DateColumn({ + title: t`Created`, + accessor: 'creation_date', + sortable: true + }), + DateColumn({ + title: t`Last Updated`, + accessor: 'updated' + }), DateColumn({ title: t`Expiry Date`, accessor: 'expiry_date', hidden: !useGlobalSettingsState.getState().isSet('STOCK_ENABLE_EXPIRY'), defaultVisible: false }), - DateColumn({ - title: t`Last Updated`, - accessor: 'updated' - }), DateColumn({ accessor: 'stocktake_date', title: t`Stocktake Date`, @@ -273,18 +281,10 @@ function stockItemTableFilters({ type: 'date', active: enableExpiry }, - { - name: 'updated_before', - label: t`Updated Before`, - description: t`Show items updated before this date`, - type: 'date' - }, - { - name: 'updated_after', - label: t`Updated After`, - description: t`Show items updated after this date`, - type: 'date' - }, + UpdatedBeforeFilter(), + UpdatedAfterFilter(), + CreatedBeforeFilter(), + CreatedAfterFilter(), { name: 'stocktake_before', label: t`Stocktake Before`, @@ -297,6 +297,11 @@ function stockItemTableFilters({ description: t`Show items counted after this date`, type: 'date' }, + { + name: 'has_stocktake', + label: t`Has Stocktake Date`, + description: t`Show items which have a stocktake date` + }, { name: 'external', label: t`External Location`,