2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-08-26 05:15:55 +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

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