Stocktake external (#5182)

* Add 'location' filtering option for part.stock_entries

* Add "exclude_external" field to stocktake report

* Add "stocktake_exclude_external" default option

* Implement setting to exclude external stock

* Split stocktake functionality out into separate file

* Change name of internal setting

* Refactoring

* Add 'exclude_external' field to stocktake form
This commit is contained in:
Oliver 2023-07-05 22:53:44 +10:00 committed by GitHub
parent c91fbdbc48
commit 831693e941
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 315 additions and 270 deletions

View File

@ -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)'),

View File

@ -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):

View File

@ -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),
)

276
InvenTree/part/stocktake.py Normal file
View File

@ -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")

View File

@ -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):

View File

@ -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)

View File

@ -13,6 +13,7 @@
<table class='table table-striped table-condensed'>
<tbody>
{% include "InvenTree/settings/setting.html" with key="STOCKTAKE_ENABLE" icon="fa-clipboard-check" %}
{% include "InvenTree/settings/setting.html" with key="STOCKTAKE_EXCLUDE_EXTERNAL" icon="fa-sitemap" %}
{% include "InvenTree/settings/setting.html" with key="STOCKTAKE_AUTO_DAYS" icon="fa-calendar-alt" %}
{% include "InvenTree/settings/setting.html" with key="STOCKTAKE_DELETE_REPORT_DAYS" icon="fa-trash-alt" %}
</tbody>

View File

@ -849,6 +849,10 @@ function generateStocktakeReport(options={}) {
fields.location = options.location;
}
fields.exclude_external = {
value: global_settings.STOCKTAKE_EXCLUDE_EXTERNAL,
};
if (options.generate_report) {
fields.generate_report = options.generate_report;
}