diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 23f83a7587..1b546dd0e2 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -1685,6 +1685,13 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'default': False, }, + 'STOCKTAKE_EXCLUDE_EXTERNAL': { + 'name': _('Exclude External Locations'), + 'description': _('Exclude stock items in external locations from stocktake calculations'), + 'validator': bool, + 'default': False, + }, + 'STOCKTAKE_AUTO_DAYS': { 'name': _('Automatic Stocktake Period'), 'description': _('Number of days between automatic stocktake recording (set to zero to disable)'), diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 7b1d228696..8b1edb7870 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -1447,13 +1447,13 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) ], ) - def stock_entries(self, include_variants=True, in_stock=None): + def stock_entries(self, include_variants=True, in_stock=None, location=None): """Return all stock entries for this Part. - - If this is a template part, include variants underneath this. - - Note: To return all stock-entries for all part variants under this one, - we need to be creative with the filtering. + Arguments: + include_variants: If True, include stock entries for all part variants + in_stock: If True, filter by stock entries which are 'in stock' + location: If set, filter by stock entries in the specified location """ if include_variants: query = StockModels.StockItem.objects.filter(part__in=self.get_descendants(include_self=True)) @@ -1465,6 +1465,10 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) elif in_stock is False: query = query.exclude(StockModels.StockItem.IN_STOCK_FILTER) + if location: + locations = location.get_descendants(include_self=True) + query = query.filter(location__in=locations) + return query def get_stock_count(self, include_variants=True): diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 3d145c00f2..dc49af7c89 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -23,6 +23,7 @@ import InvenTree.helpers import InvenTree.serializers import InvenTree.status import part.filters +import part.stocktake import part.tasks import stock.models from InvenTree.status_codes import BuildStatusGroups @@ -932,6 +933,12 @@ class PartStocktakeReportGenerateSerializer(serializers.Serializer): label=_('Location'), help_text=_('Limit stocktake report to a particular stock location, and any child locations') ) + exclude_external = serializers.BooleanField( + default=True, + label=_('Exclude External Stock'), + help_text=_('Exclude stock items in external locations') + ) + generate_report = serializers.BooleanField( default=True, label=_('Generate Report'), @@ -965,12 +972,13 @@ class PartStocktakeReportGenerateSerializer(serializers.Serializer): # Generate a new report offload_task( - part.tasks.generate_stocktake_report, + part.stocktake.generate_stocktake_report, force_async=True, user=user, part=data.get('part', None), category=data.get('category', None), location=data.get('location', None), + exclude_external=data.get('exclude_external', True), generate_report=data.get('generate_report', True), update_parts=data.get('update_parts', True), ) diff --git a/InvenTree/part/stocktake.py b/InvenTree/part/stocktake.py new file mode 100644 index 0000000000..8e82d10261 --- /dev/null +++ b/InvenTree/part/stocktake.py @@ -0,0 +1,276 @@ +"""Stocktake report functionality""" + +import io +import logging +import time +from datetime import datetime + +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 InvenTree.helpers +import part.models +import stock.models + +logger = logging.getLogger('inventree') + + +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 + + kwargs: + exclude_external: If True, exclude stock items in external locations (default = False) + + Returns: + PartStocktake: A new PartStocktake model instance (for the specified Part) + """ + + # Grab all "available" stock items for the Part + # We do not include variant stock when performing a stocktake, + # otherwise the stocktake entries will be duplicated + stock_entries = target.stock_entries(in_stock=True, include_variants=False) + + exclude_external = kwargs.get('exclude_external', False) + + if exclude_external: + stock_entries = stock_entries.exclude(location__external=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 occurred 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 + exclude_external: If True, exclude stock items in external locations (default = False) + generate_report: If True, generate a stocktake report from the calculated data (default=True) + update_parts: If True, save stocktake information against each filtered Part (default = True) + """ + + # Determine if external locations should be excluded + exclude_external = kwargs.get( + 'exclude_exernal', + common.models.InvenTreeSetting.get_setting('STOCKTAKE_EXCLUDE_EXTERNAL', False) + ) + + parts = part.models.Part.objects.all() + user = kwargs.get('user', 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) + + if exclude_external: + items = items.exclude(location__external=True) + + # List of parts which exist within these locations + unique_parts = items.order_by().values('part').distinct() + + parts = parts.filter( + pk__in=[result['part'] for result in unique_parts] + ) + + # Exit if filters removed all parts + n_parts = parts.count() + + if n_parts == 0: + logger.info("No parts selected for stocktake report - exiting") + return + + logger.info(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, exclude_external=exclude_external) + + 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") diff --git a/InvenTree/part/tasks.py b/InvenTree/part/tasks.py index 23781fd839..3b48f8e8f6 100644 --- a/InvenTree/part/tasks.py +++ b/InvenTree/part/tasks.py @@ -1,21 +1,14 @@ """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.exceptions import ValidationError -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 @@ -24,8 +17,8 @@ import InvenTree.helpers import InvenTree.helpers_model import InvenTree.tasks import part.models -import stock.models -from InvenTree.tasks import ScheduledTask, scheduled_task +import part.stocktake +from InvenTree.tasks import ScheduledTask, check_daily_holdoff, scheduled_task logger = logging.getLogger("inventree") @@ -141,242 +134,6 @@ def check_missing_pricing(limit=250): 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 - # We do not include variant stock when performing a stocktake, - # otherwise the stocktake entries will be duplicated - stock_entries = target.stock_entries(in_stock=True, include_variants=False) - - # 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 occurred 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. @@ -410,27 +167,15 @@ def scheduled_stocktake_reports(): 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 + if not check_daily_holdoff('_STOCKTAKE_RECENT_REPORT', report_n_days): + logger.info("Stocktake report was recently generated - exiting") + return # Let's start a new stocktake report for all parts - generate_stocktake_report(update_parts=True) + part.stocktake.generate_stocktake_report(update_parts=True) # Record the date of this report - common.models.InvenTreeSetting.set_setting('STOCKTAKE_RECENT_REPORT', datetime.now().isoformat(), None) + common.models.InvenTreeSetting.set_setting('_STOCKTAKE_RECENT_REPORT', datetime.now().isoformat(), None) def rebuild_parameters(template_id): diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index e39b86365d..84af79e941 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -2941,7 +2941,7 @@ class PartStocktakeTest(InvenTreeAPITestCase): def test_report_list(self): """Test for PartStocktakeReport list endpoint""" - from part.tasks import generate_stocktake_report + from part.stocktake import generate_stocktake_report # Initially, no stocktake records are available self.assertEqual(PartStocktake.objects.count(), 0) diff --git a/InvenTree/templates/InvenTree/settings/part_stocktake.html b/InvenTree/templates/InvenTree/settings/part_stocktake.html index 01bec1cd5f..b13c7e27d3 100644 --- a/InvenTree/templates/InvenTree/settings/part_stocktake.html +++ b/InvenTree/templates/InvenTree/settings/part_stocktake.html @@ -13,6 +13,7 @@