2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-10-03 15:52:51 +00:00

[refactor] Stocktake -> Stock History (#10124)

* Remove STOCKTAKE ruleset

* Adjust wording of settings

* Cleanup

* Improve text for global settings

* Add BulkDeleteMixin to "stocktake" endpoint

* Frontend updates

* Migrations

- Remove field 'last_stocktake' from Part model
- Remove fields 'user' and 'note' from PartStocktake model
- Remove model PartStocktakeReport

* Frontend cleanup

* Rename global setting

* Rewrite stocktake functionality

* Cleanup

* Adds custom exporter for part stocktake data

* Frontend cleanup

* Bump API version

* Tweaks

* Frontend updates

* Fix unit tests

* Fix helper func

* Add docs

* Fix broken link

* Docs updates

* Adjust playwright tests

* Add unit testing for plugin

* Add unit testing for stock history creation

* Fix unit test
This commit is contained in:
Oliver
2025-08-06 08:02:56 +10:00
committed by GitHub
parent b31e16eb98
commit 5574e7cf6b
46 changed files with 599 additions and 1123 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -1,8 +1,10 @@
---
title: Part Stocktake
title: Part Stock History
---
## Part Stocktake
## Part Stock History
InvenTree can track the historical stock levels of parts, allowing users to view past stocktake data and generate reports based on this information.
A *Stocktake* refers to a "snapshot" of stock levels for a particular part, at a specific point in time. Stocktake information is used for tracking a historical record of the quantity and value of part stock.
@@ -25,41 +27,33 @@ The total cost of stock on hand is calculated based on the provided pricing data
!!! info "Cost Range"
Cost data is provided as a *range* of values, accounting for any variability in available pricing data.
### Display Stocktake Data
### Display Historical Stock Data
Historical stocktake data for a particular part can be viewed in the *Stocktake* tab, available on the *Part* page.
Historical stock data for a particular part can be viewed in the *Stock History* tab, available on the *Part* page.
This tab displays a chart of historical stock quantity and cost data, and corresponding tabulated data:
{{ image("part/part_stocktake_tab.png", "Part stocktake tab") }}
If this tab is not visible, ensure that the *Part Stocktake* [user setting](../settings/user.md) is enabled in the *Display Settings* section.
If this tab is not visible, ensure that the *Enable Stock History* [user setting](../settings/user.md) is enabled in the *Display Settings* section.
{{ image("part/part_stocktake_enable_tab.png", "Enable stocktake tab") }}
{{ image("part/part_stocktake_enable_tab.png", "Enable stock history tab") }}
!!! info "Permission Required"
The stocktake tab will be unavailable if your user account does not have the [required permissions](#stocktake-permissions)
## Stocktake Reports
While a *Stocktake* entry records a historical snapshot of stock levels for a single *part*, a *Stocktake Report* is used to generate a report data file which contains stocktake entries for multiple parts. Stocktake reports can be generated for the entire range of parts available in the database, or a subset of parts as determined by user-configurable filters.
Stocktake reports can be [generated manually](#performing-a-stocktake) by the user, or (if enabled) [generated automatically](#automatic-stocktake) at a specified interval.
As there is a lot of data to crunch to build a report, stocktake reports are generated by the [background worker process](../settings/tasks.md). When the report is completed, it is saved to the database and made available for download.
!!! tip "Background Worker"
If the background worker process is not running, stocktake reports will be unavailable!
Stocktake reports are made available for download as a tabulated `.csv` file, which can be opened in many external applications for further analysis.
## Stocktake Settings
## Stock History Settings
There are a number of configuration options available in the [settings view](../settings/global.md):
{{ image("part/part_stocktake_settings.png", "Stocktake settings") }}
| Name | Description | Default | Units |
| ---- | ----------- | ------- | ----- |
{{ globalsetting("STOCKTAKE_ENABLE") }}
{{ globalsetting("STOCKTAKE_EXCLUDE_EXTERNAL") }}
{{ globalsetting("STOCKTAKE_AUTO_DAYS") }}
{{ globalsetting("STOCKTAKE_DELETE_OLD_ENTRIES")}}
{{ globalsetting("STOCKTAKE_DELETE_DAYS") }}
### Enable Stocktake
{{ image("part/part_stocktake_settings.png", "Stock history settings") }}
### Enable Stock History
Enable or disable stocktake functionality. Note that by default, stocktake functionality is disabled.
@@ -67,76 +61,10 @@ Enable or disable stocktake functionality. Note that by default, stocktake funct
Configure the number of days between generation of [automatic stocktake reports](#automatic-stocktake). If this value is set to zero, automatic stocktake reports will not be generated.
### Delete Old Reports
### Delete Old Stock History Entries
Configure how many days stocktake reports will be retained, before being deleted automatically.
If enabled, stock history entries older than the specified number of days will be automatically deleted from the database.
### Historical Stocktake Reports
### Stock History Deletion Interval
The *Stocktake Settings* display also provides a table of historical stocktake reports:
{{ image("part/part_stocktake_report_table.png", "Stocktake report table") }}
## Stocktake Permissions
Stocktake data and actions are protected by the [stocktake role](../settings/permissions.md#role):
| Permission | Actions Available |
| --- | --- |
| `stocktake.view` | View historical stocktake data for parts |
| `stocktake.add` | Perform stocktake and generate reports |
| `stocktake.delete` | Delete stocktake records and reports |
## Performing a Stocktake
Manual stocktake can be performed via the web interface in a number of locations. The user can filter the parts for which the stocktake will be performed. A new stocktake entry will be generated for each selected part, and optionally a report can be generated for download.
When performing a stocktake, various options are presented to the user:
{{ image("part/part_stocktake_generate.png", "Generate stocktake report") }}
| Option | Description |
| --- | --- |
| Part | Limit stocktake context to a part. If the selected part is a [template part](./index.md#template), any variant parts will also be included in the stocktake |
| Category | Limit stocktake context to a single [part category](./index.md#part-category). Parts which exist in child categories (under the selected parent category) will also be included. |
| Location | Limit stocktake context to a single [stock location](../stock/index.md#stock-location). Any parts which have stock items contained in this location (or any child locations) will be included in the stocktake |
| Generate Report | Select this option to generate a [stocktake report](#stocktake-reports) for the selected parts. |
| Update Parts | Select this option to save a new stocktake record for each selected part. |
### Part Stocktake
A stocktake report for a single part can be generated from the *Stocktake Tab* on the part page:
{{ image("part/part_stocktake_from_part.png", "Generate part stocktake report") }}
### Category Stocktake
A stocktake report for a part category can be generated from the *Part Category* page:
{{ image("part/part_stocktake_from_category.png", "Generate category stocktake report") }}
### Location Stocktake
A stocktake report for a stock location can be generated from the *Stock Location* page:
{{ image("part/part_stocktake_from_location.png", "Generate location stocktake report") }}
### Automatic Stocktake
If enabled, stocktake reports can be generated automatically at a configured interval, specified in number of days. Automatic stocktake reports are performed on the entire database of parts.
### API Functionality
Stocktake actions can also be performed via the [API](../api/index.md).
## Stocktake Settings
The following settings are available for stocktake:
| Name | Description | Default | Units |
| ---- | ----------- | ------- | ----- |
{{ globalsetting("STOCKTAKE_ENABLE") }}
{{ globalsetting("STOCKTAKE_EXCLUDE_EXTERNAL") }}
{{ globalsetting("STOCKTAKE_AUTO_DAYS") }}
{{ globalsetting("STOCKTAKE_DELETE_REPORT_DAYS") }}
{{ globalsetting("DISPLAY_PROFILE_INFO") }}
Configure how many days historical stock records are retained in the database.

View File

@@ -107,9 +107,9 @@ This tab is only displayed if the part is marked as *Purchaseable*.
The *Sales Orders* tab shows a list of the sales orders for this part. It provides a view for important sales order information like customer, status, creation and shipment dates.
### Stocktake
### Stock History
The *Stocktake* tab provide historical stock level information, based on user-provided stocktake data. Refer to the [stocktake documentation](./stocktake.md) for further information.
The *Stock History* tab provide historical stock level information. Refer to the [stock history documentation](./stocktake.md) for further information.
### Test Templates

View File

@@ -22,6 +22,7 @@ The following builtin plugins are available in InvenTree:
| Data Export | [BOM Exporter](./bom_exporter.md) | Custom [exporter](../mixins/export.md) for BOM data | Yes |
| Data Export | [InvenTree Exporter](./inventree_exporter.md) | Custom [exporter](../mixins/export.md) for InvenTree data | Yes |
| Data Export | [Parameter Exporter](./part_parameter_exporter.md) | Custom [exporter](../mixins/export.md) for part parameter data | Yes |
| Data Export | [Stocktake Exporter](./stocktake_exporter.md) | Custom [exporter](../mixins/export.md) for stocktake data | No |
| Events | [Auto Create Child Builds](./auto_create_builds.md) | Automatically create child build orders for sub-assemblies | No |
| Events | [Auto Issue Orders](./auto_issue.md) | Automatically issue pending orders when target date is reached | No |
| Label Printing | [Label Printer](./inventree_label.md) | Custom [label](../mixins/label.md) for InvenTree data | Yes |

View File

@@ -9,3 +9,7 @@ This plugin provides a mechanism to send notifications to a Slack channel when c
### API Key
To use this plugin, you need to provide a Slack API key. This key is used to authenticate the plugin with the Slack API and send messages to the specified channel.
### Activation
This plugin is an *optional* plugin, and must be enabled in the InvenTree settings.

View File

@@ -0,0 +1,35 @@
---
title: Stocktake Exporter
---
## Stocktake Exporter Plugin
The **Stocktake Exporter Plugin** provides custom "stocktake" export functionality for [Part](../../part/index.md) data.
It utilizes the [ExporterMixin](../mixins/export.md) mixin to provide a custom export format for stocktake data.
This exporter plugin can be used to export a comprehensive list of current stock levels for selected parts.
### Activation
This plugin is an *optional* plugin, and must be enabled in the InvenTree settings.
### Plugin Settings
There are no configurable settings for this plugin.
## Usage
This plugin is used in the same way as the [InvenTree Exporter Plugin](./inventree_exporter.md), but provides a custom export format for stocktake data.
### Export Options
When exporting part data, the *Stocktake Exporter* plugin is available for selection in the export dialog. When selected, the plugin provides some additional export options to control the data export process.
{{ image("stocktake_exporter_options.png", base="plugin/builtin", title="Stocktake Export Options") }}
| Option | Description |
|--------|-------------|
| `Pricing Data` | Include pricing data in the export. This will add columns for the cost of "stock on hand". |
| `Include External Stock` | Include stock from external warehouses in the export. This will add columns for the stock levels in external warehouses, and include the external quantities in the total stock count and valuation. |
| `Include Variant Items` | Include variant items in the export. This will add columns for the variant items associated with each part, and include the variant quantities in the total stock count and valuation. |

View File

@@ -184,7 +184,7 @@ Snippets are included in a template as follows:
{% raw %}{% include 'snippets/<snippet_name.html>' %}{% endraw %}
```
For example, consider a stocktake report for a particular stock location, where we wish to render a table with a row for each item in that location.
For example, consider a custom stocktake report for a particular stock location, where we wish to render a table with a row for each item in that location.
```html
{% raw %}

View File

@@ -257,6 +257,7 @@ nav:
- BOM Exporter: plugins/builtin/bom_exporter.md
- InvenTree Exporter: plugins/builtin/inventree_exporter.md
- Parameter Exporter: plugins/builtin/part_parameter_exporter.md
- Stocktake Exporter: plugins/builtin/stocktake_exporter.md
- Label Printing:
- Label Printer: plugins/builtin/inventree_label.md
- Label Machine: plugins/builtin/inventree_label_machine.md

View File

@@ -1,12 +1,18 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 378
INVENTREE_API_VERSION = 379
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v379 -> 2025-08-04 : https://github.com/inventree/InvenTree/pull/10124
- Removes "PartStocktakeReport" model and associated API endpoints
- Remove "last_stocktake" field from the Part model
- Remove "user" field from PartStocktake model
- Remove "note" field from PartStocktake model
v378 -> 2025-08-01 : https://github.com/inventree/InvenTree/pull/10111
- Adds "scheduled_to_build" annotated field to BuildLine serializer

View File

@@ -279,12 +279,12 @@ def str2bool(text, test=True):
return str(text).lower() in ['0', 'n', 'no', 'none', 'f', 'false', 'off']
def is_bool(text):
def is_bool(text: str) -> bool:
"""Determine if a string value 'looks' like a boolean."""
return str2bool(text, True) or str2bool(text, False)
def isNull(text):
def isNull(text: str) -> bool:
"""Test if a string 'looks' like a null value. This is useful for querying the API against a null key.
Args:
@@ -304,11 +304,14 @@ def isNull(text):
]
def normalize(d):
def normalize(d, rounding: Optional[int] = None) -> Decimal:
"""Normalize a decimal number, and remove exponential formatting."""
if type(d) is not Decimal:
d = Decimal(d)
if rounding is not None:
d = round(d, rounding)
d = d.normalize()
# Ref: https://docs.python.org/3/library/decimal.html

View File

@@ -1067,9 +1067,9 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'validator': bool,
},
'STOCKTAKE_ENABLE': {
'name': _('Stocktake Functionality'),
'name': _('Enable Stock History'),
'description': _(
'Enable stocktake functionality for recording stock levels and calculating stock value'
'Enable functionality for recording historical stock levels and value'
),
'validator': bool,
'default': False,
@@ -1077,27 +1077,34 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'STOCKTAKE_EXCLUDE_EXTERNAL': {
'name': _('Exclude External Locations'),
'description': _(
'Exclude stock items in external locations from stocktake calculations'
'Exclude stock items in external locations from stock history calculations'
),
'validator': bool,
'default': False,
},
'STOCKTAKE_AUTO_DAYS': {
'name': _('Automatic Stocktake Period'),
'description': _(
'Number of days between automatic stocktake recording (set to zero to disable)'
),
'validator': [int, MinValueValidator(0)],
'default': 0,
},
'STOCKTAKE_DELETE_REPORT_DAYS': {
'name': _('Report Deletion Interval'),
'description': _(
'Stocktake reports will be deleted after specified number of days'
),
'default': 30,
'description': _('Number of days between automatic stock history recording'),
'validator': [int, MinValueValidator(1)],
'default': 7,
'units': _('days'),
'validator': [int, MinValueValidator(7)],
},
'STOCKTAKE_DELETE_OLD_ENTRIES': {
'name': _('Delete Old Stock History Entries'),
'description': _(
'Delete stock history entries older than the specified number of days'
),
'default': False,
'validator': bool,
},
'STOCKTAKE_DELETE_DAYS': {
'name': _('Stock History Deletion Interval'),
'description': _(
'Stock history entries will be deleted after specified number of days'
),
'default': 365,
'units': _('days'),
'validator': [int, MinValueValidator(30)],
},
'DISPLAY_FULL_NAMES': {
'name': _('Display Users full names'),

View File

@@ -212,10 +212,8 @@ USER_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
],
},
'DISPLAY_STOCKTAKE_TAB': {
'name': _('Part Stocktake'),
'description': _(
'Display part stocktake information (if stocktake functionality is enabled)'
),
'name': _('Show Stock History'),
'description': _('Display stock history information in the part detail page'),
'default': True,
'validator': bool,
},

View File

@@ -52,14 +52,7 @@ class PartPricingAdmin(admin.ModelAdmin):
class PartStocktakeAdmin(admin.ModelAdmin):
"""Admin class for PartStocktake model."""
list_display = ['part', 'date', 'quantity', 'user']
@admin.register(models.PartStocktakeReport)
class PartStocktakeReportAdmin(admin.ModelAdmin):
"""Admin class for PartStocktakeReport model."""
list_display = ['date', 'user']
list_display = ['part', 'date', 'quantity']
@admin.register(models.PartCategory)

View File

@@ -13,10 +13,14 @@ from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from rest_framework.response import Response
import InvenTree.permissions
import part.filters
from data_exporter.mixins import DataExportViewMixin
from InvenTree.api import BulkUpdateMixin, ListCreateDestroyAPIView, MetadataView
from InvenTree.api import (
BulkDeleteMixin,
BulkUpdateMixin,
ListCreateDestroyAPIView,
MetadataView,
)
from InvenTree.filters import (
ORDER_FILTER,
ORDER_FILTER_ALIAS,
@@ -52,7 +56,6 @@ from .models import (
PartRelated,
PartSellPriceBreak,
PartStocktake,
PartStocktakeReport,
PartTestTemplate,
)
@@ -822,16 +825,6 @@ class PartFilter(rest_filters.FilterSet):
return queryset.filter(q_a | q_b).distinct()
stocktake = rest_filters.BooleanFilter(
label='Has stocktake', method='filter_has_stocktake'
)
def filter_has_stocktake(self, queryset, name, value):
"""Filter the queryset based on whether stocktake data is available."""
if str2bool(value):
return queryset.exclude(last_stocktake=None)
return queryset.filter(last_stocktake=None)
stock_to_build = rest_filters.BooleanFilter(
label='Required for Build Order', method='filter_stock_to_build'
)
@@ -1149,7 +1142,6 @@ class PartList(PartMixin, BulkUpdateMixin, DataExportViewMixin, ListCreateAPI):
'unallocated_stock',
'category',
'default_location',
'last_stocktake',
'units',
'pricing_min',
'pricing_max',
@@ -1453,10 +1445,10 @@ class PartStocktakeFilter(rest_filters.FilterSet):
"""Metaclass options."""
model = PartStocktake
fields = ['part', 'user']
fields = ['part']
class PartStocktakeList(ListCreateAPI):
class PartStocktakeList(BulkDeleteMixin, ListCreateAPI):
"""API endpoint for listing part stocktake information."""
queryset = PartStocktake.objects.all()
@@ -1488,47 +1480,6 @@ class PartStocktakeDetail(RetrieveUpdateDestroyAPI):
serializer_class = part_serializers.PartStocktakeSerializer
class PartStocktakeReportList(ListAPI):
"""API endpoint for listing part stocktake report information."""
queryset = PartStocktakeReport.objects.all()
serializer_class = part_serializers.PartStocktakeReportSerializer
filter_backends = ORDER_FILTER
ordering_fields = ['date', 'pk']
# Newest first, by default
ordering = '-pk'
class PartStocktakeReportDetail(RetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a single PartStocktakeReport object."""
queryset = PartStocktakeReport.objects.all()
serializer_class = part_serializers.PartStocktakeReportSerializer
class PartStocktakeReportGenerate(CreateAPI):
"""API endpoint for manually generating a new PartStocktakeReport."""
serializer_class = part_serializers.PartStocktakeReportGenerateSerializer
permission_classes = [
InvenTree.permissions.IsAuthenticatedOrReadScope,
InvenTree.permissions.RolePermission,
]
role_required = 'stocktake'
def get_serializer_context(self):
"""Extend serializer context data."""
context = super().get_serializer_context()
context['request'] = self.request
return context
class BomFilter(rest_filters.FilterSet):
"""Custom filters for the BOM list."""
@@ -1946,26 +1897,6 @@ part_api_urls = [
path(
'stocktake/',
include([
path(
r'report/',
include([
path(
'generate/',
PartStocktakeReportGenerate.as_view(),
name='api-part-stocktake-report-generate',
),
path(
'<int:pk>/',
PartStocktakeReportDetail.as_view(),
name='api-part-stocktake-report-detail',
),
path(
'',
PartStocktakeReportList.as_view(),
name='api-part-stocktake-report-list',
),
]),
),
path(
'<int:pk>/',
PartStocktakeDetail.as_view(),

View File

@@ -3,7 +3,11 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import part.models
def fake_func(*args, **kwargs):
"""A placeholder function to avoid import errors."""
pass
class Migration(migrations.Migration):
@@ -19,7 +23,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField(auto_now_add=True, verbose_name='Date')),
('report', models.FileField(help_text='Stocktake report file (generated internally)', upload_to=part.models.save_stocktake_report, verbose_name='Report')),
('report', models.FileField(help_text='Stocktake report file (generated internally)', upload_to=fake_func, verbose_name='Report')),
('user', models.ForeignKey(blank=True, help_text='User who requested this stocktake report', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stocktake_reports', to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
),

View File

@@ -0,0 +1,28 @@
# Generated by Django 4.2.23 on 2025-08-04 08:32
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("part", "0141_auto_20250722_0303"),
]
operations = [
migrations.RemoveField(
model_name="part",
name="last_stocktake",
),
migrations.RemoveField(
model_name="partstocktake",
name="note",
),
migrations.RemoveField(
model_name="partstocktake",
name="user",
),
migrations.DeleteModel(
name="PartStocktakeReport",
),
]

View File

@@ -452,7 +452,6 @@ class Part(
creation_date: Date that this part was added to the database
creation_user: User who added this part to the database
responsible_owner: Owner (either user or group) which is responsible for this part (optional)
last_stocktake: Date at which last stocktake was performed for this Part
BOM (Bill of Materials) related attributes:
bom_checksum: Checksum for the BOM of this part
@@ -1324,10 +1323,6 @@ class Part(
related_name='parts_responsible',
)
last_stocktake = models.DateField(
blank=True, null=True, verbose_name=_('Last Stocktake')
)
@property
def category_path(self):
"""Return the category path of this Part instance."""
@@ -1743,11 +1738,14 @@ class Part(
self.sales_order_allocation_count(**kwargs),
])
def stock_entries(self, include_variants=True, in_stock=None, location=None):
def stock_entries(
self, include_variants=True, include_external=True, in_stock=None, location=None
):
"""Return all stock entries for this Part.
Arguments:
include_variants: If True, include stock entries for all part variants
include_external: If True, include stock entries which are in 'external' locations
in_stock: If True, filter by stock entries which are 'in stock'
location: If set, filter by stock entries in the specified location
"""
@@ -1763,6 +1761,10 @@ class Part(
elif in_stock is False:
query = query.exclude(StockModels.StockItem.IN_STOCK_FILTER)
if include_external is False:
# Exclude stock entries which are not 'internal'
query = query.filter(external=False)
if location:
locations = location.get_descendants(include_self=True)
query = query.filter(location__in=locations)
@@ -2565,11 +2567,6 @@ class Part(
return params
@property
def latest_stocktake(self):
"""Return the latest PartStocktake object associated with this part (if one exists)."""
return self.stocktakes.order_by('-pk').first()
@property
def has_variants(self):
"""Check if this Part object has variants underneath it."""
@@ -3419,13 +3416,12 @@ class PartPricing(common.models.MetaMixin):
class PartStocktake(models.Model):
"""Model representing a 'stocktake' entry for a particular Part.
"""Model representing a 'stock history' entry for a particular Part.
A 'stocktake' is a representative count of available stock:
- Performed on a given date
- Records quantity of part in stock (across multiple stock items)
- Records estimated value of "stock on hand"
- Records user information
"""
part = models.ForeignKey(
@@ -3456,23 +3452,6 @@ class PartStocktake(models.Model):
auto_now_add=True,
)
note = models.CharField(
max_length=250,
blank=True,
verbose_name=_('Notes'),
help_text=_('Additional notes'),
)
user = models.ForeignKey(
User,
blank=True,
null=True,
on_delete=models.SET_NULL,
related_name='part_stocktakes',
verbose_name=_('User'),
help_text=_('User who performed this stocktake'),
)
cost_min = InvenTree.fields.InvenTreeModelMoneyField(
null=True,
blank=True,
@@ -3488,79 +3467,6 @@ class PartStocktake(models.Model):
)
@receiver(post_save, sender=PartStocktake, dispatch_uid='post_save_stocktake')
def update_last_stocktake(sender, instance, created, **kwargs):
"""Callback function when a PartStocktake instance is created / edited."""
# When a new PartStocktake instance is create, update the last_stocktake date for the Part
if created:
try:
part = instance.part
part.last_stocktake = instance.date
part.save()
except Exception:
pass
def save_stocktake_report(instance, filename):
"""Save stocktake reports to the correct subdirectory."""
filename = os.path.basename(filename)
return os.path.join('stocktake', 'report', filename)
class PartStocktakeReport(models.Model):
"""A PartStocktakeReport is a generated report which provides a summary of current stock on hand.
Reports are generated by the background worker process, and saved as .csv files for download.
Background processing is preferred as (for very large datasets), report generation may take a while.
A report can be manually requested by a user, or automatically generated periodically.
When generating a report, the "parts" to be reported can be filtered, e.g. by "category".
A stocktake report contains the following information, with each row relating to a single Part instance:
- Number of individual stock items on hand
- Total quantity of stock on hand
- Estimated total cost of stock on hand (min:max range)
"""
def __str__(self):
"""Construct a simple string representation for the report."""
return os.path.basename(self.report.name)
def get_absolute_url(self):
"""Return the URL for the associated report file for download."""
if self.report:
return self.report.url
return None
date = models.DateField(verbose_name=_('Date'), auto_now_add=True)
report = models.FileField(
upload_to=save_stocktake_report,
unique=False,
blank=False,
verbose_name=_('Report'),
help_text=_('Stocktake report file (generated internally)'),
)
part_count = models.IntegerField(
default=0,
verbose_name=_('Part Count'),
help_text=_('Number of parts covered by stocktake'),
)
user = models.ForeignKey(
User,
blank=True,
null=True,
on_delete=models.SET_NULL,
related_name='stocktake_reports',
verbose_name=_('User'),
help_text=_('User who requested this stocktake report'),
)
class PartSellPriceBreak(common.models.PriceBreak):
"""Represents a price break for selling this part."""

View File

@@ -29,14 +29,11 @@ import InvenTree.serializers
import InvenTree.status
import part.filters as part_filters
import part.helpers as part_helpers
import part.stocktake
import part.tasks
import stock.models
import users.models
from importer.registry import register_importer
from InvenTree.mixins import DataImportExportSerializerMixin
from InvenTree.ready import isGeneratingSchema
from InvenTree.tasks import offload_task
from users.serializers import UserSerializer
from .models import (
@@ -53,7 +50,6 @@ from .models import (
PartSellPriceBreak,
PartStar,
PartStocktake,
PartStocktakeReport,
PartTestTemplate,
)
@@ -684,7 +680,6 @@ class PartSerializer(
'IPN',
'is_template',
'keywords',
'last_stocktake',
'link',
'locked',
'minimum_stock',
@@ -1334,17 +1329,12 @@ class PartStocktakeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
'cost_min_currency',
'cost_max',
'cost_max_currency',
'note',
'user',
'user_detail',
]
read_only_fields = ['date', 'user']
quantity = serializers.FloatField()
user_detail = UserSerializer(source='user', read_only=True, many=False)
cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True)
cost_min_currency = InvenTree.serializers.InvenTreeCurrencySerializer()
@@ -1361,106 +1351,6 @@ class PartStocktakeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
return super().save()
class PartStocktakeReportSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for stocktake report class."""
class Meta:
"""Metaclass defines serializer fields."""
model = PartStocktakeReport
fields = ['pk', 'date', 'report', 'part_count', 'user', 'user_detail']
read_only_fields = ['date', 'report', 'part_count', 'user']
user_detail = UserSerializer(source='user', read_only=True, many=False)
report = InvenTree.serializers.InvenTreeAttachmentSerializerField(read_only=True)
class PartStocktakeReportGenerateSerializer(serializers.Serializer):
"""Serializer class for manually generating a new PartStocktakeReport via the API."""
part = serializers.PrimaryKeyRelatedField(
queryset=Part.objects.all(),
required=False,
allow_null=True,
label=_('Part'),
help_text=_(
'Limit stocktake report to a particular part, and any variant parts'
),
)
category = serializers.PrimaryKeyRelatedField(
queryset=PartCategory.objects.all(),
required=False,
allow_null=True,
label=_('Category'),
help_text=_(
'Limit stocktake report to a particular part category, and any child categories'
),
)
location = serializers.PrimaryKeyRelatedField(
queryset=stock.models.StockLocation.objects.all(),
required=False,
allow_null=True,
label=_('Location'),
help_text=_(
'Limit stocktake report to a particular stock location, and any child locations'
),
)
exclude_external = serializers.BooleanField(
default=True,
label=_('Exclude External Stock'),
help_text=_('Exclude stock items in external locations'),
)
generate_report = serializers.BooleanField(
default=True,
label=_('Generate Report'),
help_text=_('Generate report file containing calculated stocktake data'),
)
update_parts = serializers.BooleanField(
default=True,
label=_('Update Parts'),
help_text=_('Update specified parts with calculated stocktake data'),
)
def validate(self, data):
"""Custom validation for this serializer."""
# Stocktake functionality must be enabled
if not common.settings.get_global_setting('STOCKTAKE_ENABLE'):
raise serializers.ValidationError(
_('Stocktake functionality is not enabled')
)
# Check that background worker is running
if not InvenTree.status.is_worker_running():
raise serializers.ValidationError(_('Background worker check failed'))
return data
def save(self):
"""Saving this serializer instance requests generation of a new stocktake report."""
data = self.validated_data
user = self.context['request'].user
# Generate a new report
offload_task(
part.stocktake.generate_stocktake_report,
force_async=True,
user=user,
part=data.get('part', None),
category=data.get('category', None),
location=data.get('location', None),
exclude_external=data.get('exclude_external', True),
generate_report=data.get('generate_report', True),
update_parts=data.get('update_parts', True),
group='report',
)
@extend_schema_field(
serializers.CharField(
help_text=_('Select currency from available options')

View File

@@ -1,303 +1,104 @@
"""Stocktake report functionality."""
import io
import time
from django.contrib.auth.models import User
from django.core.files.base import ContentFile
from django.utils.translation import gettext_lazy as _
"""Stock history functionality."""
import structlog
import tablib
from djmoney.contrib.exchange.models import convert_money
from djmoney.money import Money
import common.currency
import common.models
import InvenTree.helpers
import stock.models
logger = structlog.get_logger('inventree')
def perform_stocktake(target, user: User, note: str = '', commit=True, **kwargs):
"""Perform stocktake action on a single part.
def perform_stocktake() -> None:
"""Generate stock history entries for all active parts."""
import InvenTree.helpers
import part.models as part_models
from common.currency import currency_code_default
from common.settings import get_global_setting
Arguments:
target: A single Part model instance
user: User who requested this stocktake
note: Optional note to attach to the stocktake
commit: If True (default) save the result to the database
if not get_global_setting('STOCKTAKE_ENABLE', False, cache=False):
logger.info('Stocktake functionality is disabled - skipping')
return
kwargs:
exclude_external: If True, exclude stock items in external locations (default = False)
location: Optional StockLocation to filter results for generated report
exclude_external = get_global_setting(
'STOCKTAKE_EXCLUDE_EXTERNAL', False, cache=False
)
Returns:
PartStocktake: A new PartStocktake model instance (for the specified Part)
active_parts = part_models.Part.objects.filter(active=True)
Note that while we record a *total stocktake* for the Part instance which gets saved to the database,
the user may have requested a stocktake limited to a particular location.
# New history entries to be created
history_entries = []
In this case, the stocktake *report* will be limited to the specified location.
"""
import part.models
N_BULK_CREATE = 250
# Determine which locations are "valid" for the generated report
location = kwargs.get('location')
locations = location.get_descendants(include_self=True) if location else []
base_currency = currency_code_default()
today = InvenTree.helpers.current_date()
# Grab all "available" stock items for the Part
# We do not include variant stock when performing a stocktake,
# otherwise the stocktake entries will be duplicated
stock_entries = target.stock_entries(in_stock=True, include_variants=False)
logger.info(
'Creating new stock history entries for %s active parts', active_parts.count()
)
exclude_external = kwargs.get('exclude_external', False)
for part in active_parts:
# Is there a recent stock history record for this part?
if part_models.PartStocktake.objects.filter(
part=part, date__gte=today
).exists():
continue
if exclude_external:
stock_entries = stock_entries.exclude(location__external=True)
pricing = part.pricing
# Cache min/max pricing information for this Part
pricing = target.pricing
# Fetch all 'in stock' items for this part
stock_items = part.stock_entries(
in_stock=True, include_external=not exclude_external, include_variants=True
)
if not pricing.is_valid:
# If pricing is not valid, let's update
logger.info('Pricing not valid for %s - updating', target)
pricing.update_pricing(cascade=False)
pricing.refresh_from_db()
total_cost_min = Money(0, base_currency)
total_cost_max = Money(0, base_currency)
base_currency = common.currency.currency_code_default()
total_quantity = 0
items_count = 0
# Keep track of total quantity and cost for this part
total_quantity = 0
total_cost_min = Money(0, base_currency)
total_cost_max = Money(0, base_currency)
for item in stock_items:
# Extract cost information
# Separately, keep track of stock quantity and value within the specified location
location_item_count = 0
location_quantity = 0
location_cost_min = Money(0, base_currency)
location_cost_max = Money(0, base_currency)
for entry in stock_entries:
entry_cost_min = None
entry_cost_max = None
# Update price range values
if entry.purchase_price:
entry_cost_min = entry.purchase_price
entry_cost_max = entry.purchase_price
else:
# If no purchase price is available, fall back to the part pricing data
entry_cost_min = pricing.overall_min or pricing.overall_max
entry_cost_max = pricing.overall_max or pricing.overall_min
# Convert to base currency
try:
entry_cost_min = (
convert_money(entry_cost_min, base_currency) * entry.quantity
if item.purchase_price is not None:
entry_cost_min = item.purchase_price
entry_cost_max = item.purchase_price
try:
entry_cost_min = (
convert_money(entry_cost_min, base_currency) * item.quantity
)
entry_cost_max = (
convert_money(entry_cost_max, base_currency) * item.quantity
)
except Exception:
entry_cost_min = Money(0, base_currency)
entry_cost_max = Money(0, base_currency)
# Update total quantities
items_count += 1
total_quantity += item.quantity
total_cost_min += entry_cost_min
total_cost_max += entry_cost_max
# Add a new stocktake entry for this part
history_entries.append(
part_models.PartStocktake(
part=part,
item_count=items_count,
quantity=total_quantity,
cost_min=total_cost_min,
cost_max=total_cost_max,
)
entry_cost_max = (
convert_money(entry_cost_max, base_currency) * entry.quantity
)
except Exception:
entry_cost_min = Money(0, base_currency)
entry_cost_max = Money(0, base_currency)
# Update total cost values
total_quantity += entry.quantity
total_cost_min += entry_cost_min
total_cost_max += entry_cost_max
# Test if this stock item is within the specified location
if location and entry.location not in locations:
continue
# Update location cost values
location_item_count += 1
location_quantity += entry.quantity
location_cost_min += entry_cost_min
location_cost_max += entry_cost_max
# Construct PartStocktake instance
# Note that we use the *total* values for the PartStocktake instance
instance = part.models.PartStocktake(
part=target,
item_count=stock_entries.count(),
quantity=total_quantity,
cost_min=total_cost_min,
cost_max=total_cost_max,
note=note,
user=user,
)
if commit:
instance.save()
# Add location-specific data to the instance
instance.location_item_count = location_item_count
instance.location_quantity = location_quantity
instance.location_cost_min = location_cost_min
instance.location_cost_max = location_cost_max
return instance
def generate_stocktake_report(**kwargs):
"""Generated a new stocktake report.
Note that this method should be called only by the background worker process!
Unless otherwise specified, the stocktake report is generated for *all* Part instances.
Optional filters can by supplied via the kwargs
kwargs:
user: The user who requested this stocktake (set to None for automated stocktake)
part: Optional Part instance to filter by (including variant parts)
category: Optional PartCategory to filter results
location: Optional StockLocation to filter results
exclude_external: If True, exclude stock items in external locations (default = False)
generate_report: If True, generate a stocktake report from the calculated data (default=True)
update_parts: If True, save stocktake information against each filtered Part (default = True)
"""
import part.models
# Determine if external locations should be excluded
exclude_external = kwargs.get(
'exclude_exernal',
common.models.InvenTreeSetting.get_setting('STOCKTAKE_EXCLUDE_EXTERNAL', False),
)
parts = part.models.Part.objects.all()
user = kwargs.get('user')
generate_report = kwargs.get('generate_report', True)
update_parts = kwargs.get('update_parts', True)
# Filter by 'Part' instance
if p := kwargs.get('part'):
variants = p.get_descendants(include_self=True)
parts = parts.filter(pk__in=[v.pk for v in variants])
# Filter by 'Category' instance (cascading)
if category := kwargs.get('category'):
categories = category.get_descendants(include_self=True)
parts = parts.filter(category__in=categories)
# Filter by 'Location' instance (cascading)
# Stocktake report will be limited to parts which have stock items within this location
if location := kwargs.get('location'):
# Extract flat list of all sublocations
locations = list(location.get_descendants(include_self=True))
# Items which exist within these locations
items = stock.models.StockItem.objects.filter(location__in=locations)
if exclude_external:
items = items.exclude(location__external=True)
# List of parts which exist within these locations
unique_parts = items.order_by().values('part').distinct()
parts = parts.filter(pk__in=[result['part'] for result in unique_parts])
# Exit if filters removed all parts
n_parts = parts.count()
if n_parts == 0:
logger.info('No parts selected for stocktake report - exiting')
return
logger.info('Generating new stocktake report for %s parts', n_parts)
base_currency = common.currency.currency_code_default()
# Construct an initial dataset for the stocktake report
dataset = tablib.Dataset(
headers=[
_('Part ID'),
_('Part Name'),
_('Part Description'),
_('Category ID'),
_('Category Name'),
_('Stock Items'),
_('Total Quantity'),
_('Total Cost Min') + f' ({base_currency})',
_('Total Cost Max') + f' ({base_currency})',
]
)
parts = parts.prefetch_related('category', 'stock_items')
# Simple profiling for this task
t_start = time.time()
# Keep track of each individual "stocktake" we perform.
# They may be bulk-commited to the database afterwards
stocktake_instances = []
total_parts = 0
# Iterate through each Part which matches the filters above
for p in parts:
# Create a new stocktake for this part (do not commit, this will take place later on)
stocktake = perform_stocktake(
p, user, commit=False, exclude_external=exclude_external, location=location
)
total_parts += 1
# Batch create stock history entries
if len(history_entries) >= N_BULK_CREATE:
part_models.PartStocktake.objects.bulk_create(history_entries)
history_entries = []
stocktake_instances.append(stocktake)
# Add a row to the dataset
dataset.append([
p.pk,
p.full_name,
p.description,
p.category.pk if p.category else '',
p.category.name if p.category else '',
stocktake.location_item_count,
stocktake.location_quantity,
InvenTree.helpers.normalize(stocktake.location_cost_min.amount),
InvenTree.helpers.normalize(stocktake.location_cost_max.amount),
])
# Save a new PartStocktakeReport instance
buffer = io.StringIO()
buffer.write(dataset.export('csv'))
today = InvenTree.helpers.current_date().isoformat()
filename = f'InvenTree_Stocktake_{today}.csv'
report_file = ContentFile(buffer.getvalue(), name=filename)
if generate_report:
report_instance = part.models.PartStocktakeReport.objects.create(
report=report_file, part_count=total_parts, user=user
)
# Notify the requesting user
if user:
common.notifications.trigger_notification(
report_instance,
category='generate_stocktake_report',
context={
'name': _('Stocktake Report Available'),
'message': _('A new stocktake report is available for download'),
},
targets=[user],
)
# If 'update_parts' is set, we save stocktake entries for each individual part
if update_parts:
# Use bulk_create for efficient insertion of stocktake
part.models.PartStocktake.objects.bulk_create(
stocktake_instances, batch_size=500
)
t_stocktake = time.time() - t_start
logger.info(
'Generated stocktake report for %s parts in %ss',
total_parts,
round(t_stocktake, 2),
)
if len(history_entries) > 0:
# Save any remaining stocktake entries
part_models.PartStocktake.objects.bulk_create(history_entries)

View File

@@ -30,7 +30,7 @@ def notify_low_stock(part: Model):
"""Notify interested users that a part is 'low stock'.
Rules:
- Triggered when the available stock for a given part falls be low the configured threhsold
- Triggered when the available stock for a given part falls be low the configured threshold
- A notification is delivered to any users who are 'subscribed' to this part
"""
# Do not trigger low-stock notifications for inactive parts
@@ -307,7 +307,9 @@ def check_missing_pricing(limit=250):
@tracer.start_as_current_span('scheduled_stocktake_reports')
@scheduled_task(ScheduledTask.DAILY)
def scheduled_stocktake_reports():
"""Scheduled tasks for creating automated stocktake reports.
"""Scheduled tasks for creating automated 'stocktake' entries.
A "stocktake" entry is a snapshot of the current stock levels for a given Part.
This task runs daily, and performs the following functions:
@@ -315,38 +317,40 @@ def scheduled_stocktake_reports():
- Generate new reports at the specified period
"""
import part.stocktake
from part.models import PartStocktakeReport
from part.models import PartStocktake
# First let's delete any old stocktake reports
delete_n_days = int(
get_global_setting('STOCKTAKE_DELETE_REPORT_DAYS', 30, cache=False)
)
threshold = datetime.now() - timedelta(days=delete_n_days)
old_reports = PartStocktakeReport.objects.filter(date__lt=threshold)
if get_global_setting('STOCKTAKE_DELETE_OLD_ENTRIES', False, cache=False):
# First let's delete any old stock history entries
delete_n_days = int(
get_global_setting('STOCKTAKE_DELETE_DAYS', 365, cache=False)
)
if old_reports.count() > 0:
logger.info('Deleting %s stale stocktake reports', old_reports.count())
old_reports.delete()
threshold = datetime.now() - timedelta(days=delete_n_days)
old_entries = PartStocktake.objects.filter(date__lt=threshold)
if old_entries.count() > 0:
logger.info('Deleting %s old stock entries', old_entries.count())
old_entries.delete()
# Next, check if stocktake functionality is enabled
if not get_global_setting('STOCKTAKE_ENABLE', False, cache=False):
logger.info('Stocktake functionality is not enabled - exiting')
return
report_n_days = int(get_global_setting('STOCKTAKE_AUTO_DAYS', 0, cache=False))
report_n_days = int(get_global_setting('STOCKTAKE_AUTO_DAYS', 7, cache=False))
if report_n_days < 1:
logger.info('Stocktake auto reports are disabled, exiting')
return
if not check_daily_holdoff('STOCKTAKE_RECENT_REPORT', report_n_days):
logger.info('Stocktake report was recently generated - exiting')
logger.info('Stock history was recently generated - exiting')
return
# Let's start a new stocktake report for all parts
part.stocktake.generate_stocktake_report(update_parts=True)
# Generate new stock history entries
part.stocktake.perform_stocktake()
# Record the date of this report
# Record the date of this task run
record_task_success('STOCKTAKE_RECENT_REPORT')

View File

@@ -32,7 +32,6 @@ from part.models import (
PartParameter,
PartParameterTemplate,
PartRelated,
PartStocktake,
PartTestTemplate,
)
from stock.models import StockItem, StockLocation
@@ -2998,166 +2997,6 @@ class PartInternalPriceBreakTest(InvenTreeAPITestCase):
p.refresh_from_db()
class PartStocktakeTest(InvenTreeAPITestCase):
"""Unit tests for the part stocktake functionality."""
superuser = False
is_staff = False
roles = ['stocktake.view']
fixtures = ['category', 'part', 'location', 'stock']
def test_list_endpoint(self):
"""Test the list endpoint for the stocktake data."""
url = reverse('api-part-stocktake-list')
self.assignRole('part.view')
# Initially, no stocktake entries
response = self.get(url, expected_code=200)
self.assertEqual(len(response.data), 0)
total = 0
# Iterate over (up to) 5 parts in the database
for p in Part.objects.all()[:5]:
# Create some entries
to_create = []
n = p.pk % 10
for idx in range(n):
to_create.append(PartStocktake(part=p, quantity=(idx + 1) * 100))
total += 1
# Create all entries in a single bulk-create
PartStocktake.objects.bulk_create(to_create)
# Query list endpoint
response = self.get(url, {'part': p.pk}, expected_code=200)
# Check that the expected number of PartStocktake instances has been created
self.assertEqual(len(response.data), n)
# List all entries
response = self.get(url, {}, expected_code=200)
self.assertEqual(len(response.data), total)
def test_create_stocktake(self):
"""Test that stocktake entries can be created via the API."""
url = reverse('api-part-stocktake-list')
self.assignRole('stocktake.add')
self.assignRole('stocktake.view')
for p in Part.objects.all():
# Initially no stocktake information available
self.assertIsNone(p.latest_stocktake)
note = f'Note {p.pk}'
quantity = p.pk + 5
self.post(
url,
{'part': p.pk, 'quantity': quantity, 'note': note},
expected_code=201,
)
p.refresh_from_db()
stocktake = p.latest_stocktake
self.assertIsNotNone(stocktake)
self.assertEqual(stocktake.quantity, quantity)
self.assertEqual(stocktake.part, p)
self.assertEqual(stocktake.note, note)
def test_edit_stocktake(self):
"""Test that a Stoctake instance can be edited and deleted via the API.
Note that only 'staff' users can perform these actions.
"""
p = Part.objects.all().first()
st = PartStocktake.objects.create(part=p, quantity=10)
url = reverse('api-part-stocktake-detail', kwargs={'pk': st.pk})
self.assignRole('part.view')
# Test we can retrieve via API
self.get(url, expected_code=200)
# Try to edit data
self.patch(url, {'note': 'Another edit'}, expected_code=403)
# Assign 'edit' role permission
self.assignRole('stocktake.change')
# Try again
self.patch(url, {'note': 'Editing note field again'}, expected_code=200)
# Try to delete
self.delete(url, expected_code=403)
self.assignRole('stocktake.delete')
self.delete(url, expected_code=204)
def test_report_list(self):
"""Test for PartStocktakeReport list endpoint."""
from part.stocktake import generate_stocktake_report
# Initially, no stocktake records are available
self.assertEqual(PartStocktake.objects.count(), 0)
# Generate stocktake data for all parts (default configuration)
generate_stocktake_report()
# At least one report now created
n = PartStocktake.objects.count()
self.assertGreater(n, 0)
self.assignRole('stocktake.view')
response = self.get(reverse('api-part-stocktake-list'), expected_code=200)
self.assertEqual(len(response.data), n)
# Stocktake report should be available via the API, also
response = self.get(
reverse('api-part-stocktake-report-list'), expected_code=200
)
self.assertEqual(len(response.data), 1)
data = response.data[0]
self.assertEqual(data['part_count'], 14)
self.assertEqual(data['user'], None)
self.assertTrue(data['report'].endswith('.csv'))
def test_report_generate(self):
"""Test API functionality for generating a new stocktake report."""
url = reverse('api-part-stocktake-report-generate')
# Permission denied, initially
self.assignRole('stocktake.view')
response = self.post(url, data={}, expected_code=403)
# Stocktake functionality disabled
InvenTreeSetting.set_setting('STOCKTAKE_ENABLE', False, None)
self.assignRole('stocktake.add')
response = self.post(url, data={}, expected_code=400)
self.assertIn('Stocktake functionality is not enabled', str(response.data))
InvenTreeSetting.set_setting('STOCKTAKE_ENABLE', True, None)
response = self.post(url, data={}, expected_code=400)
self.assertIn('Background worker check failed', str(response.data))
class PartMetadataAPITest(InvenTreeAPITestCase):
"""Unit tests for the various metadata endpoints of API."""

View File

@@ -20,7 +20,6 @@ from .models import (
PartCategoryStar,
PartRelated,
PartStar,
PartStocktake,
PartTestTemplate,
rename_part_image,
)
@@ -340,18 +339,6 @@ class PartTest(TestCase):
self.r2.delete()
self.assertEqual(PartRelated.objects.count(), countbefore)
def test_stocktake(self):
"""Test for adding stocktake data."""
# Grab a part
p = Part.objects.all().first()
self.assertIsNone(p.last_stocktake)
ps = PartStocktake.objects.create(part=p, quantity=100)
self.assertIsNotNone(p.last_stocktake)
self.assertEqual(p.last_stocktake, ps.date)
def test_delete(self):
"""Test delete operation for a Part instance."""
part = Part.objects.first()
@@ -957,3 +944,56 @@ class PartNotificationTest(InvenTreeTestCase):
from error_report.models import Error
self.assertEqual(Error.objects.count(), 0)
class PartStockHistoryTest(InvenTreeTestCase):
"""Test generation of stock history entries."""
fixtures = ['category', 'part', 'location', 'stock']
def test_stock_history(self):
"""Test that stock history entries are generated correctly."""
from part.models import Part, PartStocktake
from part.stocktake import perform_stocktake
N_STOCKTAKE = PartStocktake.objects.count()
# Cache the initial count of stocktake entries
stock_history_entries = {
part.pk: part.stocktakes.count() for part in Part.objects.all()
}
# Initially, run with stocktake functionality disabled
set_global_setting('STOCKTAKE_ENABLE', False)
perform_stocktake()
# No change, as functionality is disabled
self.assertEqual(PartStocktake.objects.count(), N_STOCKTAKE)
for p in Part.objects.all():
self.assertEqual(p.stocktakes.count(), stock_history_entries[p.pk])
# Now enable stocktake functionality
set_global_setting('STOCKTAKE_ENABLE', True)
# Ensure that there is at least one inactive part
p = Part.objects.first()
p.active = False
p.save()
perform_stocktake()
self.assertGreater(PartStocktake.objects.count(), N_STOCKTAKE)
for p in Part.objects.all():
if p.active:
# Active parts should have stocktake entries created
self.assertGreater(p.stocktakes.count(), stock_history_entries[p.pk])
else:
# Inactive parts should not have stocktake entries created
self.assertEqual(p.stocktakes.count(), stock_history_entries[p.pk])
# Now, run again - should not create any new entries
N_STOCKTAKE = PartStocktake.objects.count()
perform_stocktake()
self.assertEqual(PartStocktake.objects.count(), N_STOCKTAKE)

View File

@@ -156,7 +156,6 @@ def allow_table_event(table_name):
'common_webhookmessage',
'part_partpricing',
'part_partstocktake',
'part_partstocktakereport',
]
return table_name not in ignore_tables

View File

@@ -0,0 +1,160 @@
"""Custom data exporter for part stocktake data."""
from decimal import Decimal
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from InvenTree.helpers import normalize
from part.models import Part
from part.serializers import PartSerializer
from plugin import InvenTreePlugin
from plugin.mixins import DataExportMixin
class PartStocktakeExportOptionsSerializer(serializers.Serializer):
"""Custom export options for the PartStocktakeExporter plugin."""
export_pricing_data = serializers.BooleanField(
default=True, label=_('Pricing Data'), help_text=_('Include part pricing data')
)
export_include_external_items = serializers.BooleanField(
default=False,
label=_('Include External Stock'),
help_text=_('Include external stock in the stocktake data'),
)
export_include_variant_items = serializers.BooleanField(
default=False,
label=_('Include Variant Items'),
help_text=_('Include part variant stock in pricing calculations'),
)
class PartStocktakeExporter(DataExportMixin, InvenTreePlugin):
"""Builtin plugin for exporting part stocktake data.
Extends the "part" export process, to include stocktake data.
"""
NAME = 'Part Stocktake Exporter'
SLUG = 'inventree-stocktake-exporter'
TITLE = _('Part Stocktake Exporter')
DESCRIPTION = _('Exporter for part stocktake data')
VERSION = '1.0.0'
AUTHOR = _('InvenTree contributors')
ExportOptionsSerializer = PartStocktakeExportOptionsSerializer
def supports_export(
self,
model_class: type,
user=None,
serializer_class=None,
view_class=None,
*args,
**kwargs,
) -> bool:
"""Supported if the base model is Part."""
return model_class == Part and serializer_class == PartSerializer
def generate_filename(self, model_class, export_format: str) -> str:
"""Generate a filename for the exported part stocktake data."""
from InvenTree.helpers import current_date
date = current_date().isoformat()
return f'InvenTree_Stocktake_{date}.{export_format}'
def update_headers(self, headers, context, **kwargs):
"""Define headers for the Stocktake export."""
export_pricing_data = context.get('export_pricing_data', True)
include_external_items = context.get('export_include_external_items', True)
include_variant_items = context.get('export_include_variant_items', False)
# Use only a subset of fields from the PartSerializer
base_headers = [
'pk',
'name',
'IPN',
'description',
'category',
'allocated_to_build_orders',
'allocated_to_sales_orders',
'required_for_build_orders',
'required_for_sales_orders',
'ordering',
'building',
'scheduled_to_build',
'external_stock',
'variant_stock',
'stock_item_count',
'total_in_stock',
]
if not include_external_items:
base_headers.remove('external_stock')
if not include_variant_items:
base_headers.remove('variant_stock')
stocktake_headers = {
key: headers[key] for key in base_headers if key in headers
}
if export_pricing_data:
stocktake_headers.update({
'pricing_min': _('Minimum Unit Cost'),
'pricing_max': _('Maximum Unit Cost'),
'pricing_min_total': _('Minimum Total Cost'),
'pricing_max_total': _('Maximum Total Cost'),
})
return stocktake_headers
def prefetch_queryset(self, queryset):
"""Prefetch related data for the queryset."""
return queryset.prefetch_related('stock_items')
def export_data(
self, queryset, serializer_class, headers, context, output, **kwargs
):
"""Export the data for the given queryset."""
export_pricing_data = context.get('export_pricing_data', True)
include_external_items = context.get('export_include_external_items', False)
include_variant_items = context.get('export_include_variant_items', False)
data = super().export_data(
queryset, serializer_class, headers, context, output, **kwargs
)
if export_pricing_data:
for row in data:
quantity = Decimal(row.get('total_in_stock', 0))
if not include_external_items:
quantity -= Decimal(row.get('external_stock', 0))
if not include_variant_items:
quantity -= Decimal(row.get('variant_stock', 0))
if quantity < 0:
quantity = Decimal(0)
pricing_min = row.get('pricing_min', None)
pricing_max = row.get('pricing_max', None)
if pricing_min is not None:
pricing_min = Decimal(pricing_min)
row['pricing_min_total'] = normalize(
pricing_min * quantity, rounding=10
)
if pricing_max is not None:
pricing_max = Decimal(pricing_max)
row['pricing_max_total'] = normalize(
pricing_max * quantity, rounding=10
)
return data

View File

@@ -0,0 +1,83 @@
"""Unit test for the exporter plugins."""
from django.urls import reverse
from InvenTree.unit_test import InvenTreeAPITestCase
from plugin.registry import registry
class StocktakeExporterTest(InvenTreeAPITestCase):
"""Test the stocktake exporter plugin."""
fixtures = ['category', 'part', 'location', 'stock', 'bom', 'company']
roles = ['part.add', 'part.change', 'part.delete', 'stock.view']
def test_stocktake_exporter(self):
"""Test the stocktake exporter plugin."""
from part.models import Part
slug = 'inventree-stocktake-exporter'
registry.set_plugin_state(slug, True)
url = reverse('api-part-list')
# Download all part data using the 'stocktake' exporter
# Use the "default" values
with self.export_data(
url, export_plugin=slug, export_format='csv'
) as data_file:
self.process_csv(
data_file,
required_rows=Part.objects.count(),
required_cols=[
'Name',
'IPN',
'Total Stock',
'Minimum Unit Cost',
'Maximum Total Cost',
],
excluded_cols=['Active', 'External Stock', 'Variant Stock'],
)
# Now, with additional parameters specific to the plugin
with self.export_data(
url,
export_plugin=slug,
export_format='csv',
export_pricing_data=True,
export_include_external_items=True,
export_include_variant_items=True,
) as data_file:
self.process_csv(
data_file,
required_rows=Part.objects.count(),
required_cols=[
'Total Stock',
'On Order',
'Minimum Unit Cost',
'Maximum Total Cost',
'External Stock',
'Variant Stock',
],
excluded_cols=['Active'],
)
# Finally, exclude pricing data entirely
with self.export_data(
url, export_plugin=slug, export_format='csv', export_pricing_data=False
) as data_file:
self.process_csv(
data_file,
required_rows=Part.objects.count(),
required_cols=['Total Stock', 'On Order'],
excluded_cols=[
'Minimum Unit Cost',
'Maximum Total Cost',
'Variant Stock',
'External Stock',
],
)
# Reset plugin state
registry.set_plugin_state(slug, False)

View File

@@ -504,7 +504,7 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
# Filter by 'mixin' parameter
mixin_results = {
PluginMixinEnum.BARCODE: 5,
PluginMixinEnum.EXPORTER: 3,
PluginMixinEnum.EXPORTER: 4,
PluginMixinEnum.ICON_PACK: 1,
PluginMixinEnum.MAIL: 1,
PluginMixinEnum.NOTIFICATION: 3,

View File

@@ -13,7 +13,6 @@ _roles = {
'admin': 'Role Admin',
'part_category': 'Role Part Categories',
'part': 'Role Parts',
'stocktake': 'Role Stocktake',
'stock_location': 'Role Stock Locations',
'stock': 'Role Stock Items',
'build': 'Role Build Orders',

View File

@@ -12,7 +12,6 @@ class RuleSetEnum(StringEnum):
ADMIN = 'admin'
PART_CATEGORY = 'part_category'
PART = 'part'
STOCKTAKE = 'stocktake'
STOCK_LOCATION = 'stock_location'
STOCK = 'stock'
BUILD = 'build'
@@ -27,7 +26,6 @@ RULESET_CHOICES = [
(RuleSetEnum.ADMIN, _('Admin')),
(RuleSetEnum.PART_CATEGORY, _('Part Categories')),
(RuleSetEnum.PART, _('Parts')),
(RuleSetEnum.STOCKTAKE, _('Stocktake')),
(RuleSetEnum.STOCK_LOCATION, _('Stock Locations')),
(RuleSetEnum.STOCK, _('Stock Items')),
(RuleSetEnum.BUILD, _('Build Orders')),
@@ -112,12 +110,12 @@ def get_ruleset_models() -> dict:
'part_partparameter',
'part_partrelated',
'part_partstar',
'part_partstocktake',
'part_partcategorystar',
'company_supplierpart',
'company_manufacturerpart',
'company_manufacturerpartparameter',
],
RuleSetEnum.STOCKTAKE: ['part_partstocktake', 'part_partstocktakereport'],
RuleSetEnum.STOCK_LOCATION: ['stock_stocklocation', 'stock_stocklocationtype'],
RuleSetEnum.STOCK: [
'stock_stockitem',

View File

@@ -118,8 +118,6 @@ export enum ApiEndpoints {
part_pricing_internal = 'part/internal-price/',
part_pricing_sale = 'part/sale-price/',
part_stocktake_list = 'part/stocktake/',
part_stocktake_report_list = 'part/stocktake/report/',
part_stocktake_report_generate = 'part/stocktake/report/generate/',
category_list = 'part/category/',
category_tree = 'part/category/tree/',
category_parameter_list = 'part/category/parameters/',

View File

@@ -12,8 +12,7 @@ export enum UserRoles {
return_order = 'return_order',
sales_order = 'sales_order',
stock = 'stock',
stock_location = 'stock_location',
stocktake = 'stocktake'
stock_location = 'stock_location'
}
/*
@@ -46,8 +45,6 @@ export function userRoleLabel(role: UserRoles): string {
return t`Stock Items`;
case UserRoles.stock_location:
return t`Stock Location`;
case UserRoles.stocktake:
return t`Stocktake`;
default:
return role as string;
}

View File

@@ -279,14 +279,3 @@ export function partStocktakeFields(): ApiFormFieldSet {
note: {}
};
}
export function generateStocktakeReportFields(): ApiFormFieldSet {
return {
part: {},
category: {},
location: {},
exclude_external: {},
generate_report: {},
update_parts: {}
};
}

View File

@@ -1,7 +1,6 @@
import { t } from '@lingui/core/macro';
import { Stack } from '@mantine/core';
import {
IconClipboardCheck,
IconCoins,
IconCpu,
IconDevicesPc,
@@ -103,8 +102,6 @@ const LocationTypesTable = Loadable(
lazy(() => import('../../../../tables/stock/LocationTypesTable'))
);
const StocktakePanel = Loadable(lazy(() => import('./StocktakePanel')));
export default function AdminCenter() {
const user = useUserState();
@@ -197,13 +194,6 @@ export default function AdminCenter() {
content: <PartCategoryTemplateTable />,
hidden: !user.hasViewRole(UserRoles.part_category)
},
{
name: 'stocktake',
label: t`Stocktake`,
icon: <IconClipboardCheck />,
content: <StocktakePanel />,
hidden: !user.hasViewRole(UserRoles.stocktake)
},
{
name: 'labels',
label: t`Label Templates`,

View File

@@ -1,31 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { Divider, Stack } from '@mantine/core';
import { lazy } from 'react';
import { StylishText } from '../../../../components/items/StylishText';
import { GlobalSettingList } from '../../../../components/settings/SettingList';
import { Loadable } from '../../../../functions/loading';
const StocktakeReportTable = Loadable(
lazy(() => import('../../../../tables/settings/StocktakeReportTable'))
);
export default function StocktakePanel() {
return (
<Stack gap='xs'>
<GlobalSettingList
keys={[
'STOCKTAKE_ENABLE',
'STOCKTAKE_EXCLUDE_EXTERNAL',
'STOCKTAKE_AUTO_DAYS',
'STOCKTAKE_DELETE_REPORT_DAYS'
]}
/>
<StylishText size='lg'>
<Trans>Stocktake Reports</Trans>
</StylishText>
<Divider />
<StocktakeReportTable />
</Stack>
);
}

View File

@@ -3,6 +3,7 @@ import { Skeleton, Stack } from '@mantine/core';
import {
IconBellCog,
IconCategory,
IconClipboardList,
IconCurrencyDollar,
IconFileAnalytics,
IconFingerprint,
@@ -242,6 +243,22 @@ export default function SystemSettings() {
/>
)
},
{
name: 'stock-history',
label: t`Stock History`,
icon: <IconClipboardList />,
content: (
<GlobalSettingList
keys={[
'STOCKTAKE_ENABLE',
'STOCKTAKE_EXCLUDE_EXTERNAL',
'STOCKTAKE_AUTO_DAYS',
'STOCKTAKE_DELETE_OLD_ENTRIES',
'STOCKTAKE_DELETE_DAYS'
]}
/>
)
},
{
name: 'buildorders',
label: t`Build Orders`,

View File

@@ -109,7 +109,7 @@ import { SalesOrderTable } from '../../tables/sales/SalesOrderTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
import PartAllocationPanel from './PartAllocationPanel';
import PartPricingPanel from './PartPricingPanel';
import PartStocktakeDetail from './PartStocktakeDetail';
import PartStockHistoryDetail from './PartStockHistoryDetail';
import PartSupplierDetail from './PartSupplierDetail';
/**
@@ -909,9 +909,12 @@ export default function PartDetail() {
name: 'stocktake',
label: t`Stock History`,
icon: <IconClipboardList />,
content: part ? <PartStocktakeDetail partId={part.pk} /> : <Skeleton />,
content: part ? (
<PartStockHistoryDetail partId={part.pk} />
) : (
<Skeleton />
),
hidden:
!user.hasViewRole(UserRoles.stocktake) ||
!globalSettings.isSet('STOCKTAKE_ENABLE') ||
!userSettings.isSet('DISPLAY_STOCKTAKE_TAB')
},

View File

@@ -1,3 +1,8 @@
import { RowDeleteAction, RowEditAction } from '@lib/components/RowActions';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import type { TableColumn } from '@lib/types/Tables';
import { t } from '@lingui/core/macro';
import { type ChartTooltipProps, LineChart } from '@mantine/charts';
import {
@@ -8,27 +13,17 @@ import {
SimpleGrid,
Text
} from '@mantine/core';
import { useCallback, useMemo, useState } from 'react';
import { AddItemButton } from '@lib/components/AddItemButton';
import { RowDeleteAction, RowEditAction } from '@lib/components/RowActions';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import type { TableColumn } from '@lib/types/Tables';
import dayjs from 'dayjs';
import { useCallback, useMemo, useState } from 'react';
import { formatDate, formatPriceRange } from '../../defaults/formatters';
import { partStocktakeFields } from '../../forms/PartForms';
import {
generateStocktakeReportFields,
partStocktakeFields
} from '../../forms/PartForms';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { useUserState } from '../../states/UserState';
import { DecimalColumn } from '../../tables/ColumnRenderers';
import { InvenTreeTable } from '../../tables/InvenTreeTable';
/*
@@ -67,7 +62,7 @@ function ChartTooltip({ label, payload }: Readonly<ChartTooltipProps>) {
);
}
export default function PartStocktakeDetail({
export default function PartStockHistoryDetail({
partId
}: Readonly<{ partId: number }>) {
const user = useUserState();
@@ -94,29 +89,19 @@ export default function PartStocktakeDetail({
table: table
});
const generateReport = useCreateApiFormModal({
url: ApiEndpoints.part_stocktake_report_generate,
title: t`Generate Stocktake Report`,
fields: generateStocktakeReportFields(),
initialData: {
part: partId
},
successMessage: t`Stocktake report scheduled`
});
const tableColumns: TableColumn[] = useMemo(() => {
return [
{
DecimalColumn({
accessor: 'quantity',
sortable: false,
switchable: false
},
{
}),
DecimalColumn({
accessor: 'item_count',
title: t`Stock Items`,
switchable: true,
sortable: false
},
}),
{
accessor: 'cost',
title: t`Stock Value`,
@@ -129,38 +114,24 @@ export default function PartStocktakeDetail({
},
{
accessor: 'date',
sortable: false
},
{
accessor: 'note',
sortable: false
sortable: true,
switchable: false
}
];
}, []);
const tableActions = useMemo(() => {
return [
<AddItemButton
key='add'
tooltip={t`New Stocktake Report`}
onClick={() => generateReport.open()}
hidden={!user.hasAddRole(UserRoles.stocktake)}
/>
];
}, [user]);
const rowActions = useCallback(
(record: any) => {
return [
RowEditAction({
hidden: !user.hasChangeRole(UserRoles.stocktake),
hidden: !user.hasChangeRole(UserRoles.part),
onClick: () => {
setSelectedStocktake(record.pk);
editStocktakeEntry.open();
}
}),
RowDeleteAction({
hidden: !user.hasDeleteRole(UserRoles.stocktake),
hidden: !user.hasDeleteRole(UserRoles.part),
onClick: () => {
setSelectedStocktake(record.pk);
deleteStocktakeEntry.open();
@@ -207,7 +178,6 @@ export default function PartStocktakeDetail({
return (
<>
{generateReport.modal}
{editStocktakeEntry.modal}
{deleteStocktakeEntry.modal}
<SimpleGrid cols={{ base: 1, md: 2 }}>
@@ -216,12 +186,13 @@ export default function PartStocktakeDetail({
tableState={table}
columns={tableColumns}
props={{
enableSelection: true,
enableBulkDelete: true,
params: {
part: partId,
ordering: 'date'
},
rowActions: rowActions,
tableActions: tableActions
rowActions: rowActions
}}
/>
{table.isLoading ? (

View File

@@ -316,12 +316,6 @@ function partTableFilters(): TableFilter[] {
label: t`Subscribed`,
description: t`Filter by parts to which the user is subscribed`,
type: 'boolean'
},
{
name: 'stocktake',
label: t`Has Stocktake`,
description: t`Filter by parts which have stocktake information`,
type: 'boolean'
}
];
}

View File

@@ -1,111 +0,0 @@
import { t } from '@lingui/core/macro';
import { useCallback, useMemo, useState } from 'react';
import { AddItemButton } from '@lib/components/AddItemButton';
import { type RowAction, RowDeleteAction } from '@lib/components/RowActions';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { apiUrl } from '@lib/functions/Api';
import type { ApiFormFieldSet } from '@lib/types/Forms';
import type { TableColumn } from '@lib/types/Tables';
import { AttachmentLink } from '../../components/items/AttachmentLink';
import { RenderUser } from '../../components/render/User';
import { generateStocktakeReportFields } from '../../forms/PartForms';
import {
useCreateApiFormModal,
useDeleteApiFormModal
} from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { DateColumn } from '../ColumnRenderers';
import { InvenTreeTable } from '../InvenTreeTable';
export default function StocktakeReportTable() {
const table = useTable('stocktake-report');
const tableColumns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'report',
title: t`Report`,
sortable: false,
switchable: false,
render: (record: any) => <AttachmentLink attachment={record.report} />,
noContext: true
},
{
accessor: 'part_count',
title: t`Part Count`,
sortable: false
},
DateColumn({
accessor: 'date',
title: t`Date`
}),
{
accessor: 'user',
title: t`User`,
sortable: false,
render: (record: any) => RenderUser({ instance: record.user_detail })
}
];
}, []);
const [selectedReport, setSelectedReport] = useState<number | undefined>(
undefined
);
const deleteReport = useDeleteApiFormModal({
url: ApiEndpoints.part_stocktake_report_list,
pk: selectedReport,
title: t`Delete Report`,
onFormSuccess: () => table.refreshTable()
});
const generateFields: ApiFormFieldSet = useMemo(
() => generateStocktakeReportFields(),
[]
);
const generateReport = useCreateApiFormModal({
url: ApiEndpoints.part_stocktake_report_generate,
title: t`Generate Stocktake Report`,
fields: generateFields,
successMessage: t`Stocktake report scheduled`
});
const tableActions = useMemo(() => {
return [
<AddItemButton
tooltip={t`New Stocktake Report`}
onClick={() => generateReport.open()}
/>
];
}, []);
const rowActions = useCallback((record: any): RowAction[] => {
return [
RowDeleteAction({
onClick: () => {
setSelectedReport(record.pk);
deleteReport.open();
}
})
];
}, []);
return (
<>
{generateReport.modal}
{deleteReport.modal}
<InvenTreeTable
url={apiUrl(ApiEndpoints.part_stocktake_report_list)}
tableState={table}
columns={tableColumns}
props={{
enableSearch: false,
rowActions: rowActions,
tableActions: tableActions
}}
/>
</>
);
}

View File

@@ -100,10 +100,10 @@ export const navigate = async (
/**
* CLick on the 'tab' element with the provided name
*/
export const loadTab = async (page, tabName) => {
export const loadTab = async (page, tabName, exact?) => {
await page
.getByLabel(/panel-tabs-/)
.getByRole('tab', { name: tabName })
.getByRole('tab', { name: tabName, exact: exact ?? false })
.click();
await page.waitForLoadState('networkidle');

View File

@@ -139,7 +139,8 @@ test('Settings - Global', async ({ browser, request }) => {
await loadTab(page, 'Barcodes');
await loadTab(page, 'Pricing');
await loadTab(page, 'Parts');
await loadTab(page, 'Stock');
await loadTab(page, 'Stock', true);
await loadTab(page, 'Stock History');
await loadTab(page, 'Notifications');
await page