2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-07-17 18:26:32 +00:00

[Feature] Stocktake reports (#4345)

* Add settings to control upcoming stocktake features

* Adds migration for "cost range" when performing stocktake

* Add cost data to PartStocktakeSerializer

Implement a new custom serializer for currency data type

* Refactor existing currency serializers

* Update stocktake table and forms

* Prevent trailing zeroes in forms

* Calculate cost range when adding manual stocktake entry

* Display interactive chart for part stocktake history

* Ensure chart data are converted to common currency

* Adds new model for building stocktake reports

* Add admin integration for new model

* Adds API endpoint to expose list of stocktake reports available for download

- No ability to edit or delete via API

* Add setting to control automated deletion of old stocktake reports

* Updates for settings page

- Load part stocktake report table
- Refactor function to render a downloadable media file
- Fix bug with forcing files to be downloaded
- Split js code into separate templates
- Make use of onPanelLoad functionalitty

* Fix conflicting migration files

* Adds API endpoint for manual generation of stocktake report

* Offload task to generate new stocktake report

* Adds python function to perform stocktake on a single part instance

* Small bug fixes

* Various tweaks

- Prevent new stocktake models from triggering plugin events when created
- Construct a simple csv dataset

* Generate new report

* Updates for report generation

- Prefetch related data
- Add extra columns
- Keep track of stocktake instances (for saving to database later on)

* Updates:

- Add confirmation message
- Serializer validation checks

* Ensure that background worker is running before manually scheduling a new stocktake report

* Add extra fields to stocktake models

Also move code from part/models.py to part/tasks.py

* Add 'part_count' to PartStocktakeReport table

* Updates for stocktake generation

- remove old performStocktake javascript code
- Now handled by automated server-side calculation
- Generate report for a single part

* Add a new "role" for stocktake

- Allows fine-grained control on viewing / creating / deleting stocktake data
- More in-line with existing permission controls
- Remove STOCKTAKE_OWNER setting

* Add serializer field to limit stocktake report to particular locations

* Use location restriction when generating a stocktake report

* Add UI buttons to perform stocktake for a whole category tree

* Add button to perform stocktake report for a location tree

* Adds a background tasks to handle periodic generation of stocktake reports

- Reports are generated at fixed intervals
- Deletes old reports after certain number of days

* Implement notifications for new stocktake reports

- If manually requested by a user, notify that user
- Cleanup notification table
- Amend PartStocktakeModel for better notification rendering

* Hide buttons on location and category page if stocktake is not enabled

* Cleanup log messages during server start

* Extend functionality of RoleRequired permission mixin

- Allow 'role_required' attribute to be added to an API view
- Useful when using a serializer class that does not have a model defined

* Add boolean option to toggle whether a report will be generated

* Update generateStocktake function

* Improve location filtering

- Don't limit the actual stock items
- Instead, select only parts which exist within a given location tree

* Update API version

* String tweaks

* Fix permissions for PartStocktake API

* More unit testing for stocktake functionality

* QoL fix

* Fix for assigning inherited permissions
This commit is contained in:
Oliver
2023-02-17 11:42:48 +11:00
committed by GitHub
parent e6c9db2ff3
commit 0f445ea6e4
45 changed files with 1700 additions and 713 deletions

View File

@@ -166,6 +166,12 @@ class PartStocktakeAdmin(admin.ModelAdmin):
list_display = ['part', 'date', 'quantity', 'user']
class PartStocktakeReportAdmin(admin.ModelAdmin):
"""Admin class for PartStocktakeReport model"""
list_display = ['date', 'user']
class PartCategoryResource(InvenTreeResource):
"""Class for managing PartCategory data import/export."""
@@ -434,3 +440,4 @@ admin.site.register(models.PartSellPriceBreak, PartSellPriceBreakAdmin)
admin.site.register(models.PartInternalPriceBreak, PartInternalPriceBreakAdmin)
admin.site.register(models.PartPricing, PartPricingAdmin)
admin.site.register(models.PartStocktake, PartStocktakeAdmin)
admin.site.register(models.PartStocktakeReport, PartStocktakeReportAdmin)

View File

@@ -10,9 +10,8 @@ from django.utils.translation import gettext_lazy as _
from django_filters import rest_framework as rest_filters
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters, serializers, status
from rest_framework import filters, permissions, serializers, status
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAdminUser
from rest_framework.response import Response
import order.models
@@ -38,7 +37,7 @@ from .models import (BomItem, BomItemSubstitute, Part, PartAttachment,
PartCategory, PartCategoryParameterTemplate,
PartInternalPriceBreak, PartParameter,
PartParameterTemplate, PartRelated, PartSellPriceBreak,
PartStocktake, PartTestTemplate)
PartStocktake, PartStocktakeReport, PartTestTemplate)
class CategoryList(APIDownloadMixin, ListCreateAPI):
@@ -1598,9 +1597,11 @@ class PartStocktakeList(ListCreateAPI):
ordering_fields = [
'part',
'item_count',
'quantity',
'date',
'user',
'pk',
]
# Reverse date ordering by default
@@ -1615,11 +1616,47 @@ class PartStocktakeDetail(RetrieveUpdateDestroyAPI):
queryset = PartStocktake.objects.all()
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 = [
DjangoFilterBackend,
filters.OrderingFilter,
]
ordering_fields = [
'date',
'pk',
]
# Newest first, by default
ordering = '-pk'
class PartStocktakeReportGenerate(CreateAPI):
"""API endpoint for manually generating a new PartStocktakeReport"""
serializer_class = part_serializers.PartStocktakeReportGenerateSerializer
permission_classes = [
IsAdminUser,
permissions.IsAuthenticated,
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."""
@@ -2038,6 +2075,12 @@ part_api_urls = [
# Part stocktake data
re_path(r'^stocktake/', include([
path(r'report/', include([
path('generate/', PartStocktakeReportGenerate.as_view(), name='api-part-stocktake-report-generate'),
re_path(r'^.*$', PartStocktakeReportList.as_view(), name='api-part-stocktake-report-list'),
])),
re_path(r'^(?P<pk>\d+)/', PartStocktakeDetail.as_view(), name='api-part-stocktake-detail'),
re_path(r'^.*$', PartStocktakeList.as_view(), name='api-part-stocktake-list'),
])),

View File

@@ -0,0 +1,36 @@
# Generated by Django 3.2.16 on 2023-02-11 00:29
import InvenTree.fields
from django.db import migrations
import djmoney.models.fields
import djmoney.models.validators
class Migration(migrations.Migration):
dependencies = [
('part', '0095_alter_part_responsible'),
]
operations = [
migrations.AddField(
model_name='partstocktake',
name='cost_max',
field=InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Estimated maximum cost of stock on hand', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Maximum Stock Cost'),
),
migrations.AddField(
model_name='partstocktake',
name='cost_max_currency',
field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3),
),
migrations.AddField(
model_name='partstocktake',
name='cost_min',
field=InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Estimated minimum cost of stock on hand', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Minimum Stock Cost'),
),
migrations.AddField(
model_name='partstocktake',
name='cost_min_currency',
field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3),
),
]

View File

@@ -0,0 +1,26 @@
# Generated by Django 3.2.16 on 2023-02-12 08:29
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import part.models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('part', '0096_auto_20230211_0029'),
]
operations = [
migrations.CreateModel(
name='PartStocktakeReport',
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')),
('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,23 @@
# Generated by Django 3.2.16 on 2023-02-14 11:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0097_partstocktakereport'),
]
operations = [
migrations.AddField(
model_name='partstocktake',
name='item_count',
field=models.IntegerField(default=1, help_text='Number of individual stock entries at time of stocktake', verbose_name='Item Count'),
),
migrations.AddField(
model_name='partstocktakereport',
name='part_count',
field=models.IntegerField(default=0, help_text='Number of parts covered by stocktake', verbose_name='Part Count'),
),
]

View File

@@ -2335,7 +2335,7 @@ class PartPricing(common.models.MetaMixin):
force_async=True
)
def update_pricing(self, counter: int = 0):
def update_pricing(self, counter: int = 0, cascade: bool = True):
"""Recalculate all cost data for the referenced Part instance"""
if self.pk is not None:
@@ -2362,8 +2362,9 @@ class PartPricing(common.models.MetaMixin):
pass
# Update parent assemblies and templates
self.update_assemblies(counter)
self.update_templates(counter)
if cascade:
self.update_assemblies(counter)
self.update_templates(counter)
def update_assemblies(self, counter: int = 0):
"""Schedule updates for any assemblies which use this part"""
@@ -2890,6 +2891,7 @@ class PartStocktake(models.Model):
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
"""
@@ -2901,6 +2903,12 @@ class PartStocktake(models.Model):
help_text=_('Part for stocktake'),
)
item_count = models.IntegerField(
default=1,
verbose_name=_('Item Count'),
help_text=_('Number of individual stock entries at time of stocktake'),
)
quantity = models.DecimalField(
max_digits=19, decimal_places=5,
validators=[MinValueValidator(0)],
@@ -2929,6 +2937,18 @@ class PartStocktake(models.Model):
help_text=_('User who performed this stocktake'),
)
cost_min = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Minimum Stock Cost'),
help_text=_('Estimated minimum cost of stock on hand'),
)
cost_max = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Maximum Stock Cost'),
help_text=_('Estimated maximum cost of stock on hand'),
)
@receiver(post_save, sender=PartStocktake, dispatch_uid='post_save_stocktake')
def update_last_stocktake(sender, instance, created, **kwargs):
@@ -2944,6 +2964,68 @@ def update_last_stocktake(sender, instance, created, **kwargs):
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 associaed report file for download"""
if self.report:
return self.report.url
else:
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 PartAttachment(InvenTreeAttachment):
"""Model for storing file attachments against a Part object."""

View File

@@ -15,28 +15,32 @@ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from sql_util.utils import SubqueryCount, SubquerySum
import common.models
import company.models
import InvenTree.helpers
import InvenTree.status
import part.filters
import part.tasks
import stock.models
from common.settings import currency_code_default, currency_code_mappings
from InvenTree.serializers import (DataFileExtractSerializer,
DataFileUploadSerializer,
InvenTreeAttachmentSerializer,
InvenTreeAttachmentSerializerField,
InvenTreeCurrencySerializer,
InvenTreeDecimalField,
InvenTreeImageSerializerField,
InvenTreeModelSerializer,
InvenTreeMoneySerializer, RemoteImageMixin,
UserSerializer)
from InvenTree.status_codes import BuildStatus
from InvenTree.tasks import offload_task
from .models import (BomItem, BomItemSubstitute, Part, PartAttachment,
PartCategory, PartCategoryParameterTemplate,
PartInternalPriceBreak, PartParameter,
PartParameterTemplate, PartPricing, PartRelated,
PartSellPriceBreak, PartStar, PartStocktake,
PartTestTemplate)
PartStocktakeReport, PartTestTemplate)
class CategorySerializer(InvenTreeModelSerializer):
@@ -137,16 +141,9 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
quantity = InvenTreeDecimalField()
price = InvenTreeMoneySerializer(
allow_null=True
)
price = InvenTreeMoneySerializer(allow_null=True)
price_currency = serializers.ChoiceField(
choices=currency_code_mappings(),
default=currency_code_default,
label=_('Currency'),
help_text=_('Purchase currency of this stock item'),
)
price_currency = InvenTreeCurrencySerializer(help_text=_('Purchase currency of this stock item'))
class Meta:
"""Metaclass defining serializer fields"""
@@ -169,12 +166,7 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
allow_null=True
)
price_currency = serializers.ChoiceField(
choices=currency_code_mappings(),
default=currency_code_default,
label=_('Currency'),
help_text=_('Purchase currency of this stock item'),
)
price_currency = InvenTreeCurrencySerializer(help_text=_('Purchase currency of this stock item'))
class Meta:
"""Metaclass defining serializer fields"""
@@ -720,6 +712,12 @@ class PartStocktakeSerializer(InvenTreeModelSerializer):
user_detail = UserSerializer(source='user', read_only=True, many=False)
cost_min = InvenTreeMoneySerializer(allow_null=True)
cost_min_currency = InvenTreeCurrencySerializer()
cost_max = InvenTreeMoneySerializer(allow_null=True)
cost_max_currency = InvenTreeCurrencySerializer()
class Meta:
"""Metaclass options"""
@@ -728,7 +726,12 @@ class PartStocktakeSerializer(InvenTreeModelSerializer):
'pk',
'date',
'part',
'item_count',
'quantity',
'cost_min',
'cost_min_currency',
'cost_max',
'cost_max_currency',
'note',
'user',
'user_detail',
@@ -751,6 +754,92 @@ class PartStocktakeSerializer(InvenTreeModelSerializer):
super().save()
class PartStocktakeReportSerializer(InvenTreeModelSerializer):
"""Serializer for stocktake report class"""
user_detail = UserSerializer(source='user', read_only=True, many=False)
report = InvenTreeAttachmentSerializerField(read_only=True)
class Meta:
"""Metaclass defines serializer fields"""
model = PartStocktakeReport
fields = [
'pk',
'date',
'report',
'part_count',
'user',
'user_detail',
]
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')
)
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.models.InvenTreeSetting.get_setting('STOCKTAKE_ENABLE', False):
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.tasks.generate_stocktake_report,
force_async=True,
user=user,
part=data.get('part', None),
category=data.get('category', None),
location=data.get('location', None),
generate_report=data.get('generate_report', True),
update_parts=data.get('update_parts', True),
)
class PartPricingSerializer(InvenTreeModelSerializer):
"""Serializer for Part pricing information"""

View File

@@ -1,16 +1,27 @@
"""Background task definitions for the 'part' app"""
import io
import logging
import random
import time
from datetime import datetime, timedelta
from django.contrib.auth.models import User
from django.core.files.base import ContentFile
from django.utils.translation import gettext_lazy as _
import tablib
from djmoney.contrib.exchange.exceptions import MissingRate
from djmoney.contrib.exchange.models import convert_money
from djmoney.money import Money
import common.models
import common.notifications
import common.settings
import InvenTree.helpers
import InvenTree.tasks
import part.models
import stock.models
from InvenTree.tasks import ScheduledTask, scheduled_task
logger = logging.getLogger("inventree")
@@ -125,3 +136,293 @@ def check_missing_pricing(limit=250):
pricing = p.pricing
pricing.save()
pricing.schedule_for_update()
def perform_stocktake(target: part.models.Part, user: User, note: str = '', commit=True, **kwargs):
"""Perform stocktake action on a single part.
arguments:
target: A single Part model instance
commit: If True (default) save the result to the database
user: User who requested this stocktake
Returns:
PartStocktake: A new PartStocktake model instance (for the specified Part)
"""
# Grab all "available" stock items for the Part
stock_entries = target.stock_entries(in_stock=True, include_variants=True)
# Cache min/max pricing information for this Part
pricing = target.pricing
if not pricing.is_valid:
# If pricing is not valid, let's update
logger.info(f"Pricing not valid for {target} - updating")
pricing.update_pricing(cascade=False)
pricing.refresh_from_db()
base_currency = common.settings.currency_code_default()
total_quantity = 0
total_cost_min = Money(0, base_currency)
total_cost_max = Money(0, base_currency)
for entry in stock_entries:
# Update total quantity value
total_quantity += entry.quantity
has_pricing = False
# Update price range values
if entry.purchase_price:
# If purchase price is available, use that
try:
pp = convert_money(entry.purchase_price, base_currency) * entry.quantity
total_cost_min += pp
total_cost_max += pp
has_pricing = True
except MissingRate:
logger.warning(f"MissingRate exception occured converting {entry.purchase_price} to {base_currency}")
if not has_pricing:
# Fall back to the part pricing data
p_min = pricing.overall_min or pricing.overall_max
p_max = pricing.overall_max or pricing.overall_min
if p_min or p_max:
try:
total_cost_min += convert_money(p_min, base_currency) * entry.quantity
total_cost_max += convert_money(p_max, base_currency) * entry.quantity
except MissingRate:
logger.warning(f"MissingRate exception occurred converting {p_min}:{p_max} to {base_currency}")
# Construct 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()
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
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)
"""
parts = part.models.Part.objects.all()
user = kwargs.get('user', None)
generate_report = kwargs.get('generate_report', True)
update_parts = kwargs.get('update_parts', True)
# Filter by 'Part' instance
if p := kwargs.get('part', None):
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', None):
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', None):
# 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)
# 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(f"Generating new stocktake report for {n_parts} parts")
base_currency = common.settings.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)
if stocktake.quantity == 0:
# Skip rows with zero total quantity
continue
total_parts += 1
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.item_count,
stocktake.quantity,
InvenTree.helpers.normalize(stocktake.cost_min.amount),
InvenTree.helpers.normalize(stocktake.cost_max.amount),
])
# Save a new PartStocktakeReport instance
buffer = io.StringIO()
buffer.write(dataset.export('csv'))
today = datetime.now().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(f"Generated stocktake report for {total_parts} parts in {round(t_stocktake, 2)}s")
@scheduled_task(ScheduledTask.DAILY)
def scheduled_stocktake_reports():
"""Scheduled tasks for creating automated stocktake reports.
This task runs daily, and performs the following functions:
- Delete 'old' stocktake report files after the specified period
- Generate new reports at the specified period
"""
# Sleep a random number of seconds to prevent worker conflict
time.sleep(random.randint(1, 5))
# First let's delete any old stocktake reports
delete_n_days = int(common.models.InvenTreeSetting.get_setting('STOCKTAKE_DELETE_REPORT_DAYS', 30, cache=False))
threshold = datetime.now() - timedelta(days=delete_n_days)
old_reports = part.models.PartStocktakeReport.objects.filter(date__lt=threshold)
if old_reports.count() > 0:
logger.info(f"Deleting {old_reports.count()} stale stocktake reports")
old_reports.delete()
# Next, check if stocktake functionality is enabled
if not common.models.InvenTreeSetting.get_setting('STOCKTAKE_ENABLE', False, cache=False):
logger.info("Stocktake functionality is not enabled - exiting")
return
report_n_days = int(common.models.InvenTreeSetting.get_setting('STOCKTAKE_AUTO_DAYS', 0, cache=False))
if report_n_days < 1:
logger.info("Stocktake auto reports are disabled, exiting")
return
# How long ago was last full stocktake report generated?
last_report = common.models.InvenTreeSetting.get_setting('STOCKTAKE_RECENT_REPORT', '', cache=False)
try:
last_report = datetime.fromisoformat(last_report)
except ValueError:
last_report = None
if last_report:
# Do not attempt if the last report was within the minimum reporting period
threshold = datetime.now() - timedelta(days=report_n_days)
if last_report > threshold:
logger.info("Automatic stocktake report was recently generated - exiting")
return
# Let's start a new stocktake report for all parts
generate_stocktake_report(update_parts=True)
# Record the date of this report
common.models.InvenTreeSetting.set_setting('STOCKTAKE_RECENT_REPORT', datetime.now().isoformat(), None)

View File

@@ -29,6 +29,12 @@
{% url 'admin:part_partcategory_change' category.pk as url %}
{% include "admin_button.html" with url=url %}
{% endif %}
{% settings_value "STOCKTAKE_ENABLE" as stocktake_enable %}
{% if stocktake_enable and roles.stocktake.add %}
<button type='button' class='btn btn-outline-secondary' id='category-stocktake' title='{% trans "Perform stocktake for this part category" %}'>
<span class='fas fa-clipboard-check'></span>
</button>
{% endif %}
{% if category %}
{% if starred_directly %}
<button type='button' class='btn btn-outline-secondary' id='toggle-starred' title='{% trans "You are subscribed to notifications for this category" %}'>
@@ -253,6 +259,20 @@
{% block js_ready %}
{{ block.super }}
{% settings_value "STOCKTAKE_ENABLE" as stocktake_enable %}
{% if stocktake_enable and roles.stocktake.add %}
$('#category-stocktake').click(function() {
generateStocktakeReport({
category: {
{% if category %}value: {{ category.pk }},{% endif %}
},
location: {},
generate_report: {},
update_parts: {},
});
});
{% endif %}
{% if category %}
onPanelLoad('stock', function() {

View File

@@ -53,15 +53,16 @@
</div>
{% endif %}
{% settings_value 'STOCKTAKE_ENABLE' as stocktake_enable %}
{% settings_value 'DISPLAY_STOCKTAKE_TAB' user=request.user as show_stocktake %}
{% if show_stocktake %}
{% if stocktake_enable and show_stocktake %}
<div class='panel panel-hidden' id='panel-stocktake'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Part Stocktake" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% if roles.part.add %}
{% if roles.stocktake.add %}
<button class='btn btn-success' type='button' id='btn-stocktake' title='{% trans "Add stocktake information" %}'>
<span class='fas fa-clipboard-check'></span> {% trans "Stocktake" %}
</button>
@@ -468,18 +469,24 @@
// Load the "stocktake" tab
onPanelLoad('stocktake', function() {
loadPartStocktakeTable({{ part.pk }}, {
admin: {% js_bool user.is_staff %},
allow_edit: {% js_bool roles.part.change %},
allow_delete: {% js_bool roles.part.delete %},
allow_edit: {% js_bool roles.stocktake.change %},
allow_delete: {% js_bool roles.stocktake.delete %},
});
{% if roles.stocktake.add %}
$('#btn-stocktake').click(function() {
performStocktake({{ part.pk }}, {
onSuccess: function() {
$('#part-stocktake-table').bootstrapTable('refresh');
}
generateStocktakeReport({
part: {
value: {{ part.pk }}
},
location: {},
generate_report: {
value: false,
},
update_parts: {},
});
});
{% endif %}
});
// Load the "suppliers" tab

View File

@@ -342,12 +342,12 @@
{% if stocktake %}
<tr>
<td><span class='fas fa-clipboard-check'></span></td>
<td>{% trans "Last Stocktake" %}</td>
<td>
{% decimal stocktake.quantity %} <span class='fas fa-calendar-alt' title='{% render_date stocktake.date %}'></span>
<span class='badge bg-dark rounded-pill float-right'>
{{ stocktake.user.username }}
</span>
{% trans "Last Stocktake" %}
</td>
<td>
{% decimal stocktake.quantity %}
<span class='badge bg-dark rounded-pill float-right'>{{ stocktake.user.username }}</span>
</td>
</tr>
{% endif %}

View File

@@ -44,8 +44,9 @@
{% trans "Scheduling" as text %}
{% include "sidebar_item.html" with label="scheduling" text=text icon="fa-calendar-alt" %}
{% endif %}
{% settings_value 'STOCKTAKE_ENABLE' as stocktake_enable %}
{% settings_value 'DISPLAY_STOCKTAKE_TAB' user=request.user as show_stocktake %}
{% if show_stocktake %}
{% if roles.stocktake.view and stocktake_enable and show_stocktake %}
{% trans "Stocktake" as text %}
{% include "sidebar_item.html" with label="stocktake" text=text icon="fa-clipboard-check" %}
{% endif %}

View File

@@ -1,6 +1,10 @@
{% load i18n %}
{% load inventree_extras %}
<div id='part-stocktake' style='max-height: 300px;'>
<canvas id='part-stocktake-chart' width='100%' style='max-height: 300px;'></canvas>
</div>
<div id='part-stocktake-toolbar'>
<div class='btn-group' role='group'>
{% include "filter_list.html" with id="partstocktake" %}

View File

@@ -2839,6 +2839,7 @@ class PartStocktakeTest(InvenTreeAPITestCase):
'category',
'part',
'location',
'stock',
]
def test_list_endpoint(self):
@@ -2887,8 +2888,8 @@ class PartStocktakeTest(InvenTreeAPITestCase):
url = reverse('api-part-stocktake-list')
self.assignRole('part.add')
self.assignRole('part.view')
self.assignRole('stocktake.add')
self.assignRole('stocktake.view')
for p in Part.objects.all():
@@ -2930,12 +2931,6 @@ class PartStocktakeTest(InvenTreeAPITestCase):
self.assignRole('part.view')
# Test we can retrieve via API
self.get(url, expected_code=403)
# Assign staff permission
self.user.is_staff = True
self.user.save()
self.get(url, expected_code=200)
# Try to edit data
@@ -2948,7 +2943,7 @@ class PartStocktakeTest(InvenTreeAPITestCase):
)
# Assign 'edit' role permission
self.assignRole('part.change')
self.assignRole('stocktake.change')
# Try again
self.patch(
@@ -2962,6 +2957,59 @@ class PartStocktakeTest(InvenTreeAPITestCase):
# Try to delete
self.delete(url, expected_code=403)
self.assignRole('part.delete')
self.assignRole('stocktake.delete')
self.delete(url, expected_code=204)
def test_report_list(self):
"""Test for PartStocktakeReport list endpoint"""
from part.tasks import generate_stocktake_report
n_parts = Part.objects.count()
# Initially, no stocktake records are available
self.assertEqual(PartStocktake.objects.count(), 0)
# Generate stocktake data for all parts (default configuration)
generate_stocktake_report()
# There should now be 1 stocktake entry for each part
self.assertEqual(PartStocktake.objects.count(), n_parts)
self.assignRole('stocktake.view')
response = self.get(reverse('api-part-stocktake-list'), expected_code=200)
self.assertEqual(len(response.data), n_parts)
# 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))