mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-11-04 15:15:42 +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:
		@@ -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)
 | 
			
		||||
 
 | 
			
		||||
@@ -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'),
 | 
			
		||||
    ])),
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										36
									
								
								InvenTree/part/migrations/0096_auto_20230211_0029.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								InvenTree/part/migrations/0096_auto_20230211_0029.py
									
									
									
									
									
										Normal 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),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										26
									
								
								InvenTree/part/migrations/0097_partstocktakereport.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								InvenTree/part/migrations/0097_partstocktakereport.py
									
									
									
									
									
										Normal 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')),
 | 
			
		||||
            ],
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										23
									
								
								InvenTree/part/migrations/0098_auto_20230214_1115.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								InvenTree/part/migrations/0098_auto_20230214_1115.py
									
									
									
									
									
										Normal 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'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -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."""
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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"""
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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)
 | 
			
		||||
 
 | 
			
		||||
@@ -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() {
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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 %}
 | 
			
		||||
 
 | 
			
		||||
@@ -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 %}
 | 
			
		||||
 
 | 
			
		||||
@@ -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" %}
 | 
			
		||||
 
 | 
			
		||||
@@ -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))
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user