mirror of
https://github.com/inventree/InvenTree.git
synced 2025-08-26 05:15:55 +00:00
[refactor] Stocktake -> Stock History (#10124)
* Remove STOCKTAKE ruleset * Adjust wording of settings * Cleanup * Improve text for global settings * Add BulkDeleteMixin to "stocktake" endpoint * Frontend updates * Migrations - Remove field 'last_stocktake' from Part model - Remove fields 'user' and 'note' from PartStocktake model - Remove model PartStocktakeReport * Frontend cleanup * Rename global setting * Rewrite stocktake functionality * Cleanup * Adds custom exporter for part stocktake data * Frontend cleanup * Bump API version * Tweaks * Frontend updates * Fix unit tests * Fix helper func * Add docs * Fix broken link * Docs updates * Adjust playwright tests * Add unit testing for plugin * Add unit testing for stock history creation * Fix unit test
This commit is contained in:
@@ -1,12 +1,18 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 378
|
||||
INVENTREE_API_VERSION = 379
|
||||
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v379 -> 2025-08-04 : https://github.com/inventree/InvenTree/pull/10124
|
||||
- Removes "PartStocktakeReport" model and associated API endpoints
|
||||
- Remove "last_stocktake" field from the Part model
|
||||
- Remove "user" field from PartStocktake model
|
||||
- Remove "note" field from PartStocktake model
|
||||
|
||||
v378 -> 2025-08-01 : https://github.com/inventree/InvenTree/pull/10111
|
||||
- Adds "scheduled_to_build" annotated field to BuildLine serializer
|
||||
|
||||
|
@@ -279,12 +279,12 @@ def str2bool(text, test=True):
|
||||
return str(text).lower() in ['0', 'n', 'no', 'none', 'f', 'false', 'off']
|
||||
|
||||
|
||||
def is_bool(text):
|
||||
def is_bool(text: str) -> bool:
|
||||
"""Determine if a string value 'looks' like a boolean."""
|
||||
return str2bool(text, True) or str2bool(text, False)
|
||||
|
||||
|
||||
def isNull(text):
|
||||
def isNull(text: str) -> bool:
|
||||
"""Test if a string 'looks' like a null value. This is useful for querying the API against a null key.
|
||||
|
||||
Args:
|
||||
@@ -304,11 +304,14 @@ def isNull(text):
|
||||
]
|
||||
|
||||
|
||||
def normalize(d):
|
||||
def normalize(d, rounding: Optional[int] = None) -> Decimal:
|
||||
"""Normalize a decimal number, and remove exponential formatting."""
|
||||
if type(d) is not Decimal:
|
||||
d = Decimal(d)
|
||||
|
||||
if rounding is not None:
|
||||
d = round(d, rounding)
|
||||
|
||||
d = d.normalize()
|
||||
|
||||
# Ref: https://docs.python.org/3/library/decimal.html
|
||||
|
@@ -1067,9 +1067,9 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
||||
'validator': bool,
|
||||
},
|
||||
'STOCKTAKE_ENABLE': {
|
||||
'name': _('Stocktake Functionality'),
|
||||
'name': _('Enable Stock History'),
|
||||
'description': _(
|
||||
'Enable stocktake functionality for recording stock levels and calculating stock value'
|
||||
'Enable functionality for recording historical stock levels and value'
|
||||
),
|
||||
'validator': bool,
|
||||
'default': False,
|
||||
@@ -1077,27 +1077,34 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
||||
'STOCKTAKE_EXCLUDE_EXTERNAL': {
|
||||
'name': _('Exclude External Locations'),
|
||||
'description': _(
|
||||
'Exclude stock items in external locations from stocktake calculations'
|
||||
'Exclude stock items in external locations from stock history calculations'
|
||||
),
|
||||
'validator': bool,
|
||||
'default': False,
|
||||
},
|
||||
'STOCKTAKE_AUTO_DAYS': {
|
||||
'name': _('Automatic Stocktake Period'),
|
||||
'description': _(
|
||||
'Number of days between automatic stocktake recording (set to zero to disable)'
|
||||
),
|
||||
'validator': [int, MinValueValidator(0)],
|
||||
'default': 0,
|
||||
},
|
||||
'STOCKTAKE_DELETE_REPORT_DAYS': {
|
||||
'name': _('Report Deletion Interval'),
|
||||
'description': _(
|
||||
'Stocktake reports will be deleted after specified number of days'
|
||||
),
|
||||
'default': 30,
|
||||
'description': _('Number of days between automatic stock history recording'),
|
||||
'validator': [int, MinValueValidator(1)],
|
||||
'default': 7,
|
||||
'units': _('days'),
|
||||
'validator': [int, MinValueValidator(7)],
|
||||
},
|
||||
'STOCKTAKE_DELETE_OLD_ENTRIES': {
|
||||
'name': _('Delete Old Stock History Entries'),
|
||||
'description': _(
|
||||
'Delete stock history entries older than the specified number of days'
|
||||
),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'STOCKTAKE_DELETE_DAYS': {
|
||||
'name': _('Stock History Deletion Interval'),
|
||||
'description': _(
|
||||
'Stock history entries will be deleted after specified number of days'
|
||||
),
|
||||
'default': 365,
|
||||
'units': _('days'),
|
||||
'validator': [int, MinValueValidator(30)],
|
||||
},
|
||||
'DISPLAY_FULL_NAMES': {
|
||||
'name': _('Display Users full names'),
|
||||
|
@@ -212,10 +212,8 @@ USER_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
||||
],
|
||||
},
|
||||
'DISPLAY_STOCKTAKE_TAB': {
|
||||
'name': _('Part Stocktake'),
|
||||
'description': _(
|
||||
'Display part stocktake information (if stocktake functionality is enabled)'
|
||||
),
|
||||
'name': _('Show Stock History'),
|
||||
'description': _('Display stock history information in the part detail page'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
|
@@ -52,14 +52,7 @@ class PartPricingAdmin(admin.ModelAdmin):
|
||||
class PartStocktakeAdmin(admin.ModelAdmin):
|
||||
"""Admin class for PartStocktake model."""
|
||||
|
||||
list_display = ['part', 'date', 'quantity', 'user']
|
||||
|
||||
|
||||
@admin.register(models.PartStocktakeReport)
|
||||
class PartStocktakeReportAdmin(admin.ModelAdmin):
|
||||
"""Admin class for PartStocktakeReport model."""
|
||||
|
||||
list_display = ['date', 'user']
|
||||
list_display = ['part', 'date', 'quantity']
|
||||
|
||||
|
||||
@admin.register(models.PartCategory)
|
||||
|
@@ -13,10 +13,14 @@ from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
from rest_framework.response import Response
|
||||
|
||||
import InvenTree.permissions
|
||||
import part.filters
|
||||
from data_exporter.mixins import DataExportViewMixin
|
||||
from InvenTree.api import BulkUpdateMixin, ListCreateDestroyAPIView, MetadataView
|
||||
from InvenTree.api import (
|
||||
BulkDeleteMixin,
|
||||
BulkUpdateMixin,
|
||||
ListCreateDestroyAPIView,
|
||||
MetadataView,
|
||||
)
|
||||
from InvenTree.filters import (
|
||||
ORDER_FILTER,
|
||||
ORDER_FILTER_ALIAS,
|
||||
@@ -52,7 +56,6 @@ from .models import (
|
||||
PartRelated,
|
||||
PartSellPriceBreak,
|
||||
PartStocktake,
|
||||
PartStocktakeReport,
|
||||
PartTestTemplate,
|
||||
)
|
||||
|
||||
@@ -822,16 +825,6 @@ class PartFilter(rest_filters.FilterSet):
|
||||
|
||||
return queryset.filter(q_a | q_b).distinct()
|
||||
|
||||
stocktake = rest_filters.BooleanFilter(
|
||||
label='Has stocktake', method='filter_has_stocktake'
|
||||
)
|
||||
|
||||
def filter_has_stocktake(self, queryset, name, value):
|
||||
"""Filter the queryset based on whether stocktake data is available."""
|
||||
if str2bool(value):
|
||||
return queryset.exclude(last_stocktake=None)
|
||||
return queryset.filter(last_stocktake=None)
|
||||
|
||||
stock_to_build = rest_filters.BooleanFilter(
|
||||
label='Required for Build Order', method='filter_stock_to_build'
|
||||
)
|
||||
@@ -1149,7 +1142,6 @@ class PartList(PartMixin, BulkUpdateMixin, DataExportViewMixin, ListCreateAPI):
|
||||
'unallocated_stock',
|
||||
'category',
|
||||
'default_location',
|
||||
'last_stocktake',
|
||||
'units',
|
||||
'pricing_min',
|
||||
'pricing_max',
|
||||
@@ -1453,10 +1445,10 @@ class PartStocktakeFilter(rest_filters.FilterSet):
|
||||
"""Metaclass options."""
|
||||
|
||||
model = PartStocktake
|
||||
fields = ['part', 'user']
|
||||
fields = ['part']
|
||||
|
||||
|
||||
class PartStocktakeList(ListCreateAPI):
|
||||
class PartStocktakeList(BulkDeleteMixin, ListCreateAPI):
|
||||
"""API endpoint for listing part stocktake information."""
|
||||
|
||||
queryset = PartStocktake.objects.all()
|
||||
@@ -1488,47 +1480,6 @@ class PartStocktakeDetail(RetrieveUpdateDestroyAPI):
|
||||
serializer_class = part_serializers.PartStocktakeSerializer
|
||||
|
||||
|
||||
class PartStocktakeReportList(ListAPI):
|
||||
"""API endpoint for listing part stocktake report information."""
|
||||
|
||||
queryset = PartStocktakeReport.objects.all()
|
||||
serializer_class = part_serializers.PartStocktakeReportSerializer
|
||||
|
||||
filter_backends = ORDER_FILTER
|
||||
|
||||
ordering_fields = ['date', 'pk']
|
||||
|
||||
# Newest first, by default
|
||||
ordering = '-pk'
|
||||
|
||||
|
||||
class PartStocktakeReportDetail(RetrieveUpdateDestroyAPI):
|
||||
"""API endpoint for detail view of a single PartStocktakeReport object."""
|
||||
|
||||
queryset = PartStocktakeReport.objects.all()
|
||||
serializer_class = part_serializers.PartStocktakeReportSerializer
|
||||
|
||||
|
||||
class PartStocktakeReportGenerate(CreateAPI):
|
||||
"""API endpoint for manually generating a new PartStocktakeReport."""
|
||||
|
||||
serializer_class = part_serializers.PartStocktakeReportGenerateSerializer
|
||||
|
||||
permission_classes = [
|
||||
InvenTree.permissions.IsAuthenticatedOrReadScope,
|
||||
InvenTree.permissions.RolePermission,
|
||||
]
|
||||
|
||||
role_required = 'stocktake'
|
||||
|
||||
def get_serializer_context(self):
|
||||
"""Extend serializer context data."""
|
||||
context = super().get_serializer_context()
|
||||
context['request'] = self.request
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class BomFilter(rest_filters.FilterSet):
|
||||
"""Custom filters for the BOM list."""
|
||||
|
||||
@@ -1946,26 +1897,6 @@ part_api_urls = [
|
||||
path(
|
||||
'stocktake/',
|
||||
include([
|
||||
path(
|
||||
r'report/',
|
||||
include([
|
||||
path(
|
||||
'generate/',
|
||||
PartStocktakeReportGenerate.as_view(),
|
||||
name='api-part-stocktake-report-generate',
|
||||
),
|
||||
path(
|
||||
'<int:pk>/',
|
||||
PartStocktakeReportDetail.as_view(),
|
||||
name='api-part-stocktake-report-detail',
|
||||
),
|
||||
path(
|
||||
'',
|
||||
PartStocktakeReportList.as_view(),
|
||||
name='api-part-stocktake-report-list',
|
||||
),
|
||||
]),
|
||||
),
|
||||
path(
|
||||
'<int:pk>/',
|
||||
PartStocktakeDetail.as_view(),
|
||||
|
@@ -3,7 +3,11 @@
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import part.models
|
||||
|
||||
|
||||
def fake_func(*args, **kwargs):
|
||||
"""A placeholder function to avoid import errors."""
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
@@ -19,7 +23,7 @@ class Migration(migrations.Migration):
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('date', models.DateField(auto_now_add=True, verbose_name='Date')),
|
||||
('report', models.FileField(help_text='Stocktake report file (generated internally)', upload_to=part.models.save_stocktake_report, verbose_name='Report')),
|
||||
('report', models.FileField(help_text='Stocktake report file (generated internally)', upload_to=fake_func, verbose_name='Report')),
|
||||
('user', models.ForeignKey(blank=True, help_text='User who requested this stocktake report', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stocktake_reports', to=settings.AUTH_USER_MODEL, verbose_name='User')),
|
||||
],
|
||||
),
|
||||
|
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 4.2.23 on 2025-08-04 08:32
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("part", "0141_auto_20250722_0303"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="part",
|
||||
name="last_stocktake",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="partstocktake",
|
||||
name="note",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="partstocktake",
|
||||
name="user",
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name="PartStocktakeReport",
|
||||
),
|
||||
]
|
@@ -452,7 +452,6 @@ class Part(
|
||||
creation_date: Date that this part was added to the database
|
||||
creation_user: User who added this part to the database
|
||||
responsible_owner: Owner (either user or group) which is responsible for this part (optional)
|
||||
last_stocktake: Date at which last stocktake was performed for this Part
|
||||
|
||||
BOM (Bill of Materials) related attributes:
|
||||
bom_checksum: Checksum for the BOM of this part
|
||||
@@ -1324,10 +1323,6 @@ class Part(
|
||||
related_name='parts_responsible',
|
||||
)
|
||||
|
||||
last_stocktake = models.DateField(
|
||||
blank=True, null=True, verbose_name=_('Last Stocktake')
|
||||
)
|
||||
|
||||
@property
|
||||
def category_path(self):
|
||||
"""Return the category path of this Part instance."""
|
||||
@@ -1743,11 +1738,14 @@ class Part(
|
||||
self.sales_order_allocation_count(**kwargs),
|
||||
])
|
||||
|
||||
def stock_entries(self, include_variants=True, in_stock=None, location=None):
|
||||
def stock_entries(
|
||||
self, include_variants=True, include_external=True, in_stock=None, location=None
|
||||
):
|
||||
"""Return all stock entries for this Part.
|
||||
|
||||
Arguments:
|
||||
include_variants: If True, include stock entries for all part variants
|
||||
include_external: If True, include stock entries which are in 'external' locations
|
||||
in_stock: If True, filter by stock entries which are 'in stock'
|
||||
location: If set, filter by stock entries in the specified location
|
||||
"""
|
||||
@@ -1763,6 +1761,10 @@ class Part(
|
||||
elif in_stock is False:
|
||||
query = query.exclude(StockModels.StockItem.IN_STOCK_FILTER)
|
||||
|
||||
if include_external is False:
|
||||
# Exclude stock entries which are not 'internal'
|
||||
query = query.filter(external=False)
|
||||
|
||||
if location:
|
||||
locations = location.get_descendants(include_self=True)
|
||||
query = query.filter(location__in=locations)
|
||||
@@ -2565,11 +2567,6 @@ class Part(
|
||||
|
||||
return params
|
||||
|
||||
@property
|
||||
def latest_stocktake(self):
|
||||
"""Return the latest PartStocktake object associated with this part (if one exists)."""
|
||||
return self.stocktakes.order_by('-pk').first()
|
||||
|
||||
@property
|
||||
def has_variants(self):
|
||||
"""Check if this Part object has variants underneath it."""
|
||||
@@ -3419,13 +3416,12 @@ class PartPricing(common.models.MetaMixin):
|
||||
|
||||
|
||||
class PartStocktake(models.Model):
|
||||
"""Model representing a 'stocktake' entry for a particular Part.
|
||||
"""Model representing a 'stock history' entry for a particular Part.
|
||||
|
||||
A 'stocktake' is a representative count of available stock:
|
||||
- Performed on a given date
|
||||
- Records quantity of part in stock (across multiple stock items)
|
||||
- Records estimated value of "stock on hand"
|
||||
- Records user information
|
||||
"""
|
||||
|
||||
part = models.ForeignKey(
|
||||
@@ -3456,23 +3452,6 @@ class PartStocktake(models.Model):
|
||||
auto_now_add=True,
|
||||
)
|
||||
|
||||
note = models.CharField(
|
||||
max_length=250,
|
||||
blank=True,
|
||||
verbose_name=_('Notes'),
|
||||
help_text=_('Additional notes'),
|
||||
)
|
||||
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='part_stocktakes',
|
||||
verbose_name=_('User'),
|
||||
help_text=_('User who performed this stocktake'),
|
||||
)
|
||||
|
||||
cost_min = InvenTree.fields.InvenTreeModelMoneyField(
|
||||
null=True,
|
||||
blank=True,
|
||||
@@ -3488,79 +3467,6 @@ class PartStocktake(models.Model):
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_save, sender=PartStocktake, dispatch_uid='post_save_stocktake')
|
||||
def update_last_stocktake(sender, instance, created, **kwargs):
|
||||
"""Callback function when a PartStocktake instance is created / edited."""
|
||||
# When a new PartStocktake instance is create, update the last_stocktake date for the Part
|
||||
if created:
|
||||
try:
|
||||
part = instance.part
|
||||
part.last_stocktake = instance.date
|
||||
part.save()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def save_stocktake_report(instance, filename):
|
||||
"""Save stocktake reports to the correct subdirectory."""
|
||||
filename = os.path.basename(filename)
|
||||
return os.path.join('stocktake', 'report', filename)
|
||||
|
||||
|
||||
class PartStocktakeReport(models.Model):
|
||||
"""A PartStocktakeReport is a generated report which provides a summary of current stock on hand.
|
||||
|
||||
Reports are generated by the background worker process, and saved as .csv files for download.
|
||||
Background processing is preferred as (for very large datasets), report generation may take a while.
|
||||
|
||||
A report can be manually requested by a user, or automatically generated periodically.
|
||||
|
||||
When generating a report, the "parts" to be reported can be filtered, e.g. by "category".
|
||||
|
||||
A stocktake report contains the following information, with each row relating to a single Part instance:
|
||||
|
||||
- Number of individual stock items on hand
|
||||
- Total quantity of stock on hand
|
||||
- Estimated total cost of stock on hand (min:max range)
|
||||
"""
|
||||
|
||||
def __str__(self):
|
||||
"""Construct a simple string representation for the report."""
|
||||
return os.path.basename(self.report.name)
|
||||
|
||||
def get_absolute_url(self):
|
||||
"""Return the URL for the associated report file for download."""
|
||||
if self.report:
|
||||
return self.report.url
|
||||
return None
|
||||
|
||||
date = models.DateField(verbose_name=_('Date'), auto_now_add=True)
|
||||
|
||||
report = models.FileField(
|
||||
upload_to=save_stocktake_report,
|
||||
unique=False,
|
||||
blank=False,
|
||||
verbose_name=_('Report'),
|
||||
help_text=_('Stocktake report file (generated internally)'),
|
||||
)
|
||||
|
||||
part_count = models.IntegerField(
|
||||
default=0,
|
||||
verbose_name=_('Part Count'),
|
||||
help_text=_('Number of parts covered by stocktake'),
|
||||
)
|
||||
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='stocktake_reports',
|
||||
verbose_name=_('User'),
|
||||
help_text=_('User who requested this stocktake report'),
|
||||
)
|
||||
|
||||
|
||||
class PartSellPriceBreak(common.models.PriceBreak):
|
||||
"""Represents a price break for selling this part."""
|
||||
|
||||
|
@@ -29,14 +29,11 @@ import InvenTree.serializers
|
||||
import InvenTree.status
|
||||
import part.filters as part_filters
|
||||
import part.helpers as part_helpers
|
||||
import part.stocktake
|
||||
import part.tasks
|
||||
import stock.models
|
||||
import users.models
|
||||
from importer.registry import register_importer
|
||||
from InvenTree.mixins import DataImportExportSerializerMixin
|
||||
from InvenTree.ready import isGeneratingSchema
|
||||
from InvenTree.tasks import offload_task
|
||||
from users.serializers import UserSerializer
|
||||
|
||||
from .models import (
|
||||
@@ -53,7 +50,6 @@ from .models import (
|
||||
PartSellPriceBreak,
|
||||
PartStar,
|
||||
PartStocktake,
|
||||
PartStocktakeReport,
|
||||
PartTestTemplate,
|
||||
)
|
||||
|
||||
@@ -684,7 +680,6 @@ class PartSerializer(
|
||||
'IPN',
|
||||
'is_template',
|
||||
'keywords',
|
||||
'last_stocktake',
|
||||
'link',
|
||||
'locked',
|
||||
'minimum_stock',
|
||||
@@ -1334,17 +1329,12 @@ class PartStocktakeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
'cost_min_currency',
|
||||
'cost_max',
|
||||
'cost_max_currency',
|
||||
'note',
|
||||
'user',
|
||||
'user_detail',
|
||||
]
|
||||
|
||||
read_only_fields = ['date', 'user']
|
||||
|
||||
quantity = serializers.FloatField()
|
||||
|
||||
user_detail = UserSerializer(source='user', read_only=True, many=False)
|
||||
|
||||
cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True)
|
||||
cost_min_currency = InvenTree.serializers.InvenTreeCurrencySerializer()
|
||||
|
||||
@@ -1361,106 +1351,6 @@ class PartStocktakeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
return super().save()
|
||||
|
||||
|
||||
class PartStocktakeReportSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
"""Serializer for stocktake report class."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines serializer fields."""
|
||||
|
||||
model = PartStocktakeReport
|
||||
fields = ['pk', 'date', 'report', 'part_count', 'user', 'user_detail']
|
||||
read_only_fields = ['date', 'report', 'part_count', 'user']
|
||||
|
||||
user_detail = UserSerializer(source='user', read_only=True, many=False)
|
||||
|
||||
report = InvenTree.serializers.InvenTreeAttachmentSerializerField(read_only=True)
|
||||
|
||||
|
||||
class PartStocktakeReportGenerateSerializer(serializers.Serializer):
|
||||
"""Serializer class for manually generating a new PartStocktakeReport via the API."""
|
||||
|
||||
part = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Part.objects.all(),
|
||||
required=False,
|
||||
allow_null=True,
|
||||
label=_('Part'),
|
||||
help_text=_(
|
||||
'Limit stocktake report to a particular part, and any variant parts'
|
||||
),
|
||||
)
|
||||
|
||||
category = serializers.PrimaryKeyRelatedField(
|
||||
queryset=PartCategory.objects.all(),
|
||||
required=False,
|
||||
allow_null=True,
|
||||
label=_('Category'),
|
||||
help_text=_(
|
||||
'Limit stocktake report to a particular part category, and any child categories'
|
||||
),
|
||||
)
|
||||
|
||||
location = serializers.PrimaryKeyRelatedField(
|
||||
queryset=stock.models.StockLocation.objects.all(),
|
||||
required=False,
|
||||
allow_null=True,
|
||||
label=_('Location'),
|
||||
help_text=_(
|
||||
'Limit stocktake report to a particular stock location, and any child locations'
|
||||
),
|
||||
)
|
||||
|
||||
exclude_external = serializers.BooleanField(
|
||||
default=True,
|
||||
label=_('Exclude External Stock'),
|
||||
help_text=_('Exclude stock items in external locations'),
|
||||
)
|
||||
|
||||
generate_report = serializers.BooleanField(
|
||||
default=True,
|
||||
label=_('Generate Report'),
|
||||
help_text=_('Generate report file containing calculated stocktake data'),
|
||||
)
|
||||
|
||||
update_parts = serializers.BooleanField(
|
||||
default=True,
|
||||
label=_('Update Parts'),
|
||||
help_text=_('Update specified parts with calculated stocktake data'),
|
||||
)
|
||||
|
||||
def validate(self, data):
|
||||
"""Custom validation for this serializer."""
|
||||
# Stocktake functionality must be enabled
|
||||
if not common.settings.get_global_setting('STOCKTAKE_ENABLE'):
|
||||
raise serializers.ValidationError(
|
||||
_('Stocktake functionality is not enabled')
|
||||
)
|
||||
|
||||
# Check that background worker is running
|
||||
if not InvenTree.status.is_worker_running():
|
||||
raise serializers.ValidationError(_('Background worker check failed'))
|
||||
|
||||
return data
|
||||
|
||||
def save(self):
|
||||
"""Saving this serializer instance requests generation of a new stocktake report."""
|
||||
data = self.validated_data
|
||||
user = self.context['request'].user
|
||||
|
||||
# Generate a new report
|
||||
offload_task(
|
||||
part.stocktake.generate_stocktake_report,
|
||||
force_async=True,
|
||||
user=user,
|
||||
part=data.get('part', None),
|
||||
category=data.get('category', None),
|
||||
location=data.get('location', None),
|
||||
exclude_external=data.get('exclude_external', True),
|
||||
generate_report=data.get('generate_report', True),
|
||||
update_parts=data.get('update_parts', True),
|
||||
group='report',
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_field(
|
||||
serializers.CharField(
|
||||
help_text=_('Select currency from available options')
|
||||
|
@@ -1,303 +1,104 @@
|
||||
"""Stocktake report functionality."""
|
||||
|
||||
import io
|
||||
import time
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.files.base import ContentFile
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
"""Stock history functionality."""
|
||||
|
||||
import structlog
|
||||
import tablib
|
||||
from djmoney.contrib.exchange.models import convert_money
|
||||
from djmoney.money import Money
|
||||
|
||||
import common.currency
|
||||
import common.models
|
||||
import InvenTree.helpers
|
||||
import stock.models
|
||||
|
||||
logger = structlog.get_logger('inventree')
|
||||
|
||||
|
||||
def perform_stocktake(target, user: User, note: str = '', commit=True, **kwargs):
|
||||
"""Perform stocktake action on a single part.
|
||||
def perform_stocktake() -> None:
|
||||
"""Generate stock history entries for all active parts."""
|
||||
import InvenTree.helpers
|
||||
import part.models as part_models
|
||||
from common.currency import currency_code_default
|
||||
from common.settings import get_global_setting
|
||||
|
||||
Arguments:
|
||||
target: A single Part model instance
|
||||
user: User who requested this stocktake
|
||||
note: Optional note to attach to the stocktake
|
||||
commit: If True (default) save the result to the database
|
||||
if not get_global_setting('STOCKTAKE_ENABLE', False, cache=False):
|
||||
logger.info('Stocktake functionality is disabled - skipping')
|
||||
return
|
||||
|
||||
kwargs:
|
||||
exclude_external: If True, exclude stock items in external locations (default = False)
|
||||
location: Optional StockLocation to filter results for generated report
|
||||
exclude_external = get_global_setting(
|
||||
'STOCKTAKE_EXCLUDE_EXTERNAL', False, cache=False
|
||||
)
|
||||
|
||||
Returns:
|
||||
PartStocktake: A new PartStocktake model instance (for the specified Part)
|
||||
active_parts = part_models.Part.objects.filter(active=True)
|
||||
|
||||
Note that while we record a *total stocktake* for the Part instance which gets saved to the database,
|
||||
the user may have requested a stocktake limited to a particular location.
|
||||
# New history entries to be created
|
||||
history_entries = []
|
||||
|
||||
In this case, the stocktake *report* will be limited to the specified location.
|
||||
"""
|
||||
import part.models
|
||||
N_BULK_CREATE = 250
|
||||
|
||||
# Determine which locations are "valid" for the generated report
|
||||
location = kwargs.get('location')
|
||||
locations = location.get_descendants(include_self=True) if location else []
|
||||
base_currency = currency_code_default()
|
||||
today = InvenTree.helpers.current_date()
|
||||
|
||||
# Grab all "available" stock items for the Part
|
||||
# We do not include variant stock when performing a stocktake,
|
||||
# otherwise the stocktake entries will be duplicated
|
||||
stock_entries = target.stock_entries(in_stock=True, include_variants=False)
|
||||
logger.info(
|
||||
'Creating new stock history entries for %s active parts', active_parts.count()
|
||||
)
|
||||
|
||||
exclude_external = kwargs.get('exclude_external', False)
|
||||
for part in active_parts:
|
||||
# Is there a recent stock history record for this part?
|
||||
if part_models.PartStocktake.objects.filter(
|
||||
part=part, date__gte=today
|
||||
).exists():
|
||||
continue
|
||||
|
||||
if exclude_external:
|
||||
stock_entries = stock_entries.exclude(location__external=True)
|
||||
pricing = part.pricing
|
||||
|
||||
# Cache min/max pricing information for this Part
|
||||
pricing = target.pricing
|
||||
# Fetch all 'in stock' items for this part
|
||||
stock_items = part.stock_entries(
|
||||
in_stock=True, include_external=not exclude_external, include_variants=True
|
||||
)
|
||||
|
||||
if not pricing.is_valid:
|
||||
# If pricing is not valid, let's update
|
||||
logger.info('Pricing not valid for %s - updating', target)
|
||||
pricing.update_pricing(cascade=False)
|
||||
pricing.refresh_from_db()
|
||||
total_cost_min = Money(0, base_currency)
|
||||
total_cost_max = Money(0, base_currency)
|
||||
|
||||
base_currency = common.currency.currency_code_default()
|
||||
total_quantity = 0
|
||||
items_count = 0
|
||||
|
||||
# Keep track of total quantity and cost for this part
|
||||
total_quantity = 0
|
||||
total_cost_min = Money(0, base_currency)
|
||||
total_cost_max = Money(0, base_currency)
|
||||
for item in stock_items:
|
||||
# Extract cost information
|
||||
|
||||
# Separately, keep track of stock quantity and value within the specified location
|
||||
location_item_count = 0
|
||||
location_quantity = 0
|
||||
location_cost_min = Money(0, base_currency)
|
||||
location_cost_max = Money(0, base_currency)
|
||||
|
||||
for entry in stock_entries:
|
||||
entry_cost_min = None
|
||||
entry_cost_max = None
|
||||
|
||||
# Update price range values
|
||||
if entry.purchase_price:
|
||||
entry_cost_min = entry.purchase_price
|
||||
entry_cost_max = entry.purchase_price
|
||||
|
||||
else:
|
||||
# If no purchase price is available, fall back to the part pricing data
|
||||
entry_cost_min = pricing.overall_min or pricing.overall_max
|
||||
entry_cost_max = pricing.overall_max or pricing.overall_min
|
||||
|
||||
# Convert to base currency
|
||||
try:
|
||||
entry_cost_min = (
|
||||
convert_money(entry_cost_min, base_currency) * entry.quantity
|
||||
if item.purchase_price is not None:
|
||||
entry_cost_min = item.purchase_price
|
||||
entry_cost_max = item.purchase_price
|
||||
|
||||
try:
|
||||
entry_cost_min = (
|
||||
convert_money(entry_cost_min, base_currency) * item.quantity
|
||||
)
|
||||
entry_cost_max = (
|
||||
convert_money(entry_cost_max, base_currency) * item.quantity
|
||||
)
|
||||
except Exception:
|
||||
entry_cost_min = Money(0, base_currency)
|
||||
entry_cost_max = Money(0, base_currency)
|
||||
|
||||
# Update total quantities
|
||||
items_count += 1
|
||||
total_quantity += item.quantity
|
||||
total_cost_min += entry_cost_min
|
||||
total_cost_max += entry_cost_max
|
||||
|
||||
# Add a new stocktake entry for this part
|
||||
history_entries.append(
|
||||
part_models.PartStocktake(
|
||||
part=part,
|
||||
item_count=items_count,
|
||||
quantity=total_quantity,
|
||||
cost_min=total_cost_min,
|
||||
cost_max=total_cost_max,
|
||||
)
|
||||
entry_cost_max = (
|
||||
convert_money(entry_cost_max, base_currency) * entry.quantity
|
||||
)
|
||||
except Exception:
|
||||
entry_cost_min = Money(0, base_currency)
|
||||
entry_cost_max = Money(0, base_currency)
|
||||
|
||||
# Update total cost values
|
||||
total_quantity += entry.quantity
|
||||
total_cost_min += entry_cost_min
|
||||
total_cost_max += entry_cost_max
|
||||
|
||||
# Test if this stock item is within the specified location
|
||||
if location and entry.location not in locations:
|
||||
continue
|
||||
|
||||
# Update location cost values
|
||||
location_item_count += 1
|
||||
location_quantity += entry.quantity
|
||||
location_cost_min += entry_cost_min
|
||||
location_cost_max += entry_cost_max
|
||||
|
||||
# Construct PartStocktake instance
|
||||
# Note that we use the *total* values for the PartStocktake instance
|
||||
instance = part.models.PartStocktake(
|
||||
part=target,
|
||||
item_count=stock_entries.count(),
|
||||
quantity=total_quantity,
|
||||
cost_min=total_cost_min,
|
||||
cost_max=total_cost_max,
|
||||
note=note,
|
||||
user=user,
|
||||
)
|
||||
|
||||
if commit:
|
||||
instance.save()
|
||||
|
||||
# Add location-specific data to the instance
|
||||
instance.location_item_count = location_item_count
|
||||
instance.location_quantity = location_quantity
|
||||
instance.location_cost_min = location_cost_min
|
||||
instance.location_cost_max = location_cost_max
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
def generate_stocktake_report(**kwargs):
|
||||
"""Generated a new stocktake report.
|
||||
|
||||
Note that this method should be called only by the background worker process!
|
||||
|
||||
Unless otherwise specified, the stocktake report is generated for *all* Part instances.
|
||||
Optional filters can by supplied via the kwargs
|
||||
|
||||
kwargs:
|
||||
user: The user who requested this stocktake (set to None for automated stocktake)
|
||||
part: Optional Part instance to filter by (including variant parts)
|
||||
category: Optional PartCategory to filter results
|
||||
location: Optional StockLocation to filter results
|
||||
exclude_external: If True, exclude stock items in external locations (default = False)
|
||||
generate_report: If True, generate a stocktake report from the calculated data (default=True)
|
||||
update_parts: If True, save stocktake information against each filtered Part (default = True)
|
||||
"""
|
||||
import part.models
|
||||
|
||||
# Determine if external locations should be excluded
|
||||
exclude_external = kwargs.get(
|
||||
'exclude_exernal',
|
||||
common.models.InvenTreeSetting.get_setting('STOCKTAKE_EXCLUDE_EXTERNAL', False),
|
||||
)
|
||||
|
||||
parts = part.models.Part.objects.all()
|
||||
user = kwargs.get('user')
|
||||
|
||||
generate_report = kwargs.get('generate_report', True)
|
||||
update_parts = kwargs.get('update_parts', True)
|
||||
|
||||
# Filter by 'Part' instance
|
||||
if p := kwargs.get('part'):
|
||||
variants = p.get_descendants(include_self=True)
|
||||
parts = parts.filter(pk__in=[v.pk for v in variants])
|
||||
|
||||
# Filter by 'Category' instance (cascading)
|
||||
if category := kwargs.get('category'):
|
||||
categories = category.get_descendants(include_self=True)
|
||||
parts = parts.filter(category__in=categories)
|
||||
|
||||
# Filter by 'Location' instance (cascading)
|
||||
# Stocktake report will be limited to parts which have stock items within this location
|
||||
if location := kwargs.get('location'):
|
||||
# Extract flat list of all sublocations
|
||||
locations = list(location.get_descendants(include_self=True))
|
||||
|
||||
# Items which exist within these locations
|
||||
items = stock.models.StockItem.objects.filter(location__in=locations)
|
||||
|
||||
if exclude_external:
|
||||
items = items.exclude(location__external=True)
|
||||
|
||||
# List of parts which exist within these locations
|
||||
unique_parts = items.order_by().values('part').distinct()
|
||||
|
||||
parts = parts.filter(pk__in=[result['part'] for result in unique_parts])
|
||||
|
||||
# Exit if filters removed all parts
|
||||
n_parts = parts.count()
|
||||
|
||||
if n_parts == 0:
|
||||
logger.info('No parts selected for stocktake report - exiting')
|
||||
return
|
||||
|
||||
logger.info('Generating new stocktake report for %s parts', n_parts)
|
||||
|
||||
base_currency = common.currency.currency_code_default()
|
||||
|
||||
# Construct an initial dataset for the stocktake report
|
||||
dataset = tablib.Dataset(
|
||||
headers=[
|
||||
_('Part ID'),
|
||||
_('Part Name'),
|
||||
_('Part Description'),
|
||||
_('Category ID'),
|
||||
_('Category Name'),
|
||||
_('Stock Items'),
|
||||
_('Total Quantity'),
|
||||
_('Total Cost Min') + f' ({base_currency})',
|
||||
_('Total Cost Max') + f' ({base_currency})',
|
||||
]
|
||||
)
|
||||
|
||||
parts = parts.prefetch_related('category', 'stock_items')
|
||||
|
||||
# Simple profiling for this task
|
||||
t_start = time.time()
|
||||
|
||||
# Keep track of each individual "stocktake" we perform.
|
||||
# They may be bulk-commited to the database afterwards
|
||||
stocktake_instances = []
|
||||
|
||||
total_parts = 0
|
||||
|
||||
# Iterate through each Part which matches the filters above
|
||||
for p in parts:
|
||||
# Create a new stocktake for this part (do not commit, this will take place later on)
|
||||
stocktake = perform_stocktake(
|
||||
p, user, commit=False, exclude_external=exclude_external, location=location
|
||||
)
|
||||
|
||||
total_parts += 1
|
||||
# Batch create stock history entries
|
||||
if len(history_entries) >= N_BULK_CREATE:
|
||||
part_models.PartStocktake.objects.bulk_create(history_entries)
|
||||
history_entries = []
|
||||
|
||||
stocktake_instances.append(stocktake)
|
||||
|
||||
# Add a row to the dataset
|
||||
dataset.append([
|
||||
p.pk,
|
||||
p.full_name,
|
||||
p.description,
|
||||
p.category.pk if p.category else '',
|
||||
p.category.name if p.category else '',
|
||||
stocktake.location_item_count,
|
||||
stocktake.location_quantity,
|
||||
InvenTree.helpers.normalize(stocktake.location_cost_min.amount),
|
||||
InvenTree.helpers.normalize(stocktake.location_cost_max.amount),
|
||||
])
|
||||
|
||||
# Save a new PartStocktakeReport instance
|
||||
buffer = io.StringIO()
|
||||
buffer.write(dataset.export('csv'))
|
||||
|
||||
today = InvenTree.helpers.current_date().isoformat()
|
||||
filename = f'InvenTree_Stocktake_{today}.csv'
|
||||
report_file = ContentFile(buffer.getvalue(), name=filename)
|
||||
|
||||
if generate_report:
|
||||
report_instance = part.models.PartStocktakeReport.objects.create(
|
||||
report=report_file, part_count=total_parts, user=user
|
||||
)
|
||||
|
||||
# Notify the requesting user
|
||||
if user:
|
||||
common.notifications.trigger_notification(
|
||||
report_instance,
|
||||
category='generate_stocktake_report',
|
||||
context={
|
||||
'name': _('Stocktake Report Available'),
|
||||
'message': _('A new stocktake report is available for download'),
|
||||
},
|
||||
targets=[user],
|
||||
)
|
||||
|
||||
# If 'update_parts' is set, we save stocktake entries for each individual part
|
||||
if update_parts:
|
||||
# Use bulk_create for efficient insertion of stocktake
|
||||
part.models.PartStocktake.objects.bulk_create(
|
||||
stocktake_instances, batch_size=500
|
||||
)
|
||||
|
||||
t_stocktake = time.time() - t_start
|
||||
logger.info(
|
||||
'Generated stocktake report for %s parts in %ss',
|
||||
total_parts,
|
||||
round(t_stocktake, 2),
|
||||
)
|
||||
if len(history_entries) > 0:
|
||||
# Save any remaining stocktake entries
|
||||
part_models.PartStocktake.objects.bulk_create(history_entries)
|
||||
|
@@ -30,7 +30,7 @@ def notify_low_stock(part: Model):
|
||||
"""Notify interested users that a part is 'low stock'.
|
||||
|
||||
Rules:
|
||||
- Triggered when the available stock for a given part falls be low the configured threhsold
|
||||
- Triggered when the available stock for a given part falls be low the configured threshold
|
||||
- A notification is delivered to any users who are 'subscribed' to this part
|
||||
"""
|
||||
# Do not trigger low-stock notifications for inactive parts
|
||||
@@ -307,7 +307,9 @@ def check_missing_pricing(limit=250):
|
||||
@tracer.start_as_current_span('scheduled_stocktake_reports')
|
||||
@scheduled_task(ScheduledTask.DAILY)
|
||||
def scheduled_stocktake_reports():
|
||||
"""Scheduled tasks for creating automated stocktake reports.
|
||||
"""Scheduled tasks for creating automated 'stocktake' entries.
|
||||
|
||||
A "stocktake" entry is a snapshot of the current stock levels for a given Part.
|
||||
|
||||
This task runs daily, and performs the following functions:
|
||||
|
||||
@@ -315,38 +317,40 @@ def scheduled_stocktake_reports():
|
||||
- Generate new reports at the specified period
|
||||
"""
|
||||
import part.stocktake
|
||||
from part.models import PartStocktakeReport
|
||||
from part.models import PartStocktake
|
||||
|
||||
# First let's delete any old stocktake reports
|
||||
delete_n_days = int(
|
||||
get_global_setting('STOCKTAKE_DELETE_REPORT_DAYS', 30, cache=False)
|
||||
)
|
||||
threshold = datetime.now() - timedelta(days=delete_n_days)
|
||||
old_reports = PartStocktakeReport.objects.filter(date__lt=threshold)
|
||||
if get_global_setting('STOCKTAKE_DELETE_OLD_ENTRIES', False, cache=False):
|
||||
# First let's delete any old stock history entries
|
||||
delete_n_days = int(
|
||||
get_global_setting('STOCKTAKE_DELETE_DAYS', 365, cache=False)
|
||||
)
|
||||
|
||||
if old_reports.count() > 0:
|
||||
logger.info('Deleting %s stale stocktake reports', old_reports.count())
|
||||
old_reports.delete()
|
||||
threshold = datetime.now() - timedelta(days=delete_n_days)
|
||||
old_entries = PartStocktake.objects.filter(date__lt=threshold)
|
||||
|
||||
if old_entries.count() > 0:
|
||||
logger.info('Deleting %s old stock entries', old_entries.count())
|
||||
old_entries.delete()
|
||||
|
||||
# Next, check if stocktake functionality is enabled
|
||||
if not get_global_setting('STOCKTAKE_ENABLE', False, cache=False):
|
||||
logger.info('Stocktake functionality is not enabled - exiting')
|
||||
return
|
||||
|
||||
report_n_days = int(get_global_setting('STOCKTAKE_AUTO_DAYS', 0, cache=False))
|
||||
report_n_days = int(get_global_setting('STOCKTAKE_AUTO_DAYS', 7, cache=False))
|
||||
|
||||
if report_n_days < 1:
|
||||
logger.info('Stocktake auto reports are disabled, exiting')
|
||||
return
|
||||
|
||||
if not check_daily_holdoff('STOCKTAKE_RECENT_REPORT', report_n_days):
|
||||
logger.info('Stocktake report was recently generated - exiting')
|
||||
logger.info('Stock history was recently generated - exiting')
|
||||
return
|
||||
|
||||
# Let's start a new stocktake report for all parts
|
||||
part.stocktake.generate_stocktake_report(update_parts=True)
|
||||
# Generate new stock history entries
|
||||
part.stocktake.perform_stocktake()
|
||||
|
||||
# Record the date of this report
|
||||
# Record the date of this task run
|
||||
record_task_success('STOCKTAKE_RECENT_REPORT')
|
||||
|
||||
|
||||
|
@@ -32,7 +32,6 @@ from part.models import (
|
||||
PartParameter,
|
||||
PartParameterTemplate,
|
||||
PartRelated,
|
||||
PartStocktake,
|
||||
PartTestTemplate,
|
||||
)
|
||||
from stock.models import StockItem, StockLocation
|
||||
@@ -2998,166 +2997,6 @@ class PartInternalPriceBreakTest(InvenTreeAPITestCase):
|
||||
p.refresh_from_db()
|
||||
|
||||
|
||||
class PartStocktakeTest(InvenTreeAPITestCase):
|
||||
"""Unit tests for the part stocktake functionality."""
|
||||
|
||||
superuser = False
|
||||
is_staff = False
|
||||
roles = ['stocktake.view']
|
||||
|
||||
fixtures = ['category', 'part', 'location', 'stock']
|
||||
|
||||
def test_list_endpoint(self):
|
||||
"""Test the list endpoint for the stocktake data."""
|
||||
url = reverse('api-part-stocktake-list')
|
||||
|
||||
self.assignRole('part.view')
|
||||
|
||||
# Initially, no stocktake entries
|
||||
response = self.get(url, expected_code=200)
|
||||
|
||||
self.assertEqual(len(response.data), 0)
|
||||
|
||||
total = 0
|
||||
|
||||
# Iterate over (up to) 5 parts in the database
|
||||
for p in Part.objects.all()[:5]:
|
||||
# Create some entries
|
||||
to_create = []
|
||||
|
||||
n = p.pk % 10
|
||||
|
||||
for idx in range(n):
|
||||
to_create.append(PartStocktake(part=p, quantity=(idx + 1) * 100))
|
||||
|
||||
total += 1
|
||||
|
||||
# Create all entries in a single bulk-create
|
||||
PartStocktake.objects.bulk_create(to_create)
|
||||
|
||||
# Query list endpoint
|
||||
response = self.get(url, {'part': p.pk}, expected_code=200)
|
||||
|
||||
# Check that the expected number of PartStocktake instances has been created
|
||||
self.assertEqual(len(response.data), n)
|
||||
|
||||
# List all entries
|
||||
response = self.get(url, {}, expected_code=200)
|
||||
|
||||
self.assertEqual(len(response.data), total)
|
||||
|
||||
def test_create_stocktake(self):
|
||||
"""Test that stocktake entries can be created via the API."""
|
||||
url = reverse('api-part-stocktake-list')
|
||||
|
||||
self.assignRole('stocktake.add')
|
||||
self.assignRole('stocktake.view')
|
||||
|
||||
for p in Part.objects.all():
|
||||
# Initially no stocktake information available
|
||||
self.assertIsNone(p.latest_stocktake)
|
||||
|
||||
note = f'Note {p.pk}'
|
||||
quantity = p.pk + 5
|
||||
|
||||
self.post(
|
||||
url,
|
||||
{'part': p.pk, 'quantity': quantity, 'note': note},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
p.refresh_from_db()
|
||||
stocktake = p.latest_stocktake
|
||||
|
||||
self.assertIsNotNone(stocktake)
|
||||
self.assertEqual(stocktake.quantity, quantity)
|
||||
self.assertEqual(stocktake.part, p)
|
||||
self.assertEqual(stocktake.note, note)
|
||||
|
||||
def test_edit_stocktake(self):
|
||||
"""Test that a Stoctake instance can be edited and deleted via the API.
|
||||
|
||||
Note that only 'staff' users can perform these actions.
|
||||
"""
|
||||
p = Part.objects.all().first()
|
||||
|
||||
st = PartStocktake.objects.create(part=p, quantity=10)
|
||||
|
||||
url = reverse('api-part-stocktake-detail', kwargs={'pk': st.pk})
|
||||
self.assignRole('part.view')
|
||||
|
||||
# Test we can retrieve via API
|
||||
self.get(url, expected_code=200)
|
||||
|
||||
# Try to edit data
|
||||
self.patch(url, {'note': 'Another edit'}, expected_code=403)
|
||||
|
||||
# Assign 'edit' role permission
|
||||
self.assignRole('stocktake.change')
|
||||
|
||||
# Try again
|
||||
self.patch(url, {'note': 'Editing note field again'}, expected_code=200)
|
||||
|
||||
# Try to delete
|
||||
self.delete(url, expected_code=403)
|
||||
|
||||
self.assignRole('stocktake.delete')
|
||||
|
||||
self.delete(url, expected_code=204)
|
||||
|
||||
def test_report_list(self):
|
||||
"""Test for PartStocktakeReport list endpoint."""
|
||||
from part.stocktake import generate_stocktake_report
|
||||
|
||||
# Initially, no stocktake records are available
|
||||
self.assertEqual(PartStocktake.objects.count(), 0)
|
||||
|
||||
# Generate stocktake data for all parts (default configuration)
|
||||
generate_stocktake_report()
|
||||
|
||||
# At least one report now created
|
||||
n = PartStocktake.objects.count()
|
||||
self.assertGreater(n, 0)
|
||||
|
||||
self.assignRole('stocktake.view')
|
||||
|
||||
response = self.get(reverse('api-part-stocktake-list'), expected_code=200)
|
||||
|
||||
self.assertEqual(len(response.data), n)
|
||||
|
||||
# Stocktake report should be available via the API, also
|
||||
response = self.get(
|
||||
reverse('api-part-stocktake-report-list'), expected_code=200
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 1)
|
||||
|
||||
data = response.data[0]
|
||||
|
||||
self.assertEqual(data['part_count'], 14)
|
||||
self.assertEqual(data['user'], None)
|
||||
self.assertTrue(data['report'].endswith('.csv'))
|
||||
|
||||
def test_report_generate(self):
|
||||
"""Test API functionality for generating a new stocktake report."""
|
||||
url = reverse('api-part-stocktake-report-generate')
|
||||
|
||||
# Permission denied, initially
|
||||
self.assignRole('stocktake.view')
|
||||
response = self.post(url, data={}, expected_code=403)
|
||||
|
||||
# Stocktake functionality disabled
|
||||
InvenTreeSetting.set_setting('STOCKTAKE_ENABLE', False, None)
|
||||
self.assignRole('stocktake.add')
|
||||
response = self.post(url, data={}, expected_code=400)
|
||||
|
||||
self.assertIn('Stocktake functionality is not enabled', str(response.data))
|
||||
|
||||
InvenTreeSetting.set_setting('STOCKTAKE_ENABLE', True, None)
|
||||
response = self.post(url, data={}, expected_code=400)
|
||||
self.assertIn('Background worker check failed', str(response.data))
|
||||
|
||||
|
||||
class PartMetadataAPITest(InvenTreeAPITestCase):
|
||||
"""Unit tests for the various metadata endpoints of API."""
|
||||
|
||||
|
@@ -20,7 +20,6 @@ from .models import (
|
||||
PartCategoryStar,
|
||||
PartRelated,
|
||||
PartStar,
|
||||
PartStocktake,
|
||||
PartTestTemplate,
|
||||
rename_part_image,
|
||||
)
|
||||
@@ -340,18 +339,6 @@ class PartTest(TestCase):
|
||||
self.r2.delete()
|
||||
self.assertEqual(PartRelated.objects.count(), countbefore)
|
||||
|
||||
def test_stocktake(self):
|
||||
"""Test for adding stocktake data."""
|
||||
# Grab a part
|
||||
p = Part.objects.all().first()
|
||||
|
||||
self.assertIsNone(p.last_stocktake)
|
||||
|
||||
ps = PartStocktake.objects.create(part=p, quantity=100)
|
||||
|
||||
self.assertIsNotNone(p.last_stocktake)
|
||||
self.assertEqual(p.last_stocktake, ps.date)
|
||||
|
||||
def test_delete(self):
|
||||
"""Test delete operation for a Part instance."""
|
||||
part = Part.objects.first()
|
||||
@@ -957,3 +944,56 @@ class PartNotificationTest(InvenTreeTestCase):
|
||||
from error_report.models import Error
|
||||
|
||||
self.assertEqual(Error.objects.count(), 0)
|
||||
|
||||
|
||||
class PartStockHistoryTest(InvenTreeTestCase):
|
||||
"""Test generation of stock history entries."""
|
||||
|
||||
fixtures = ['category', 'part', 'location', 'stock']
|
||||
|
||||
def test_stock_history(self):
|
||||
"""Test that stock history entries are generated correctly."""
|
||||
from part.models import Part, PartStocktake
|
||||
from part.stocktake import perform_stocktake
|
||||
|
||||
N_STOCKTAKE = PartStocktake.objects.count()
|
||||
|
||||
# Cache the initial count of stocktake entries
|
||||
stock_history_entries = {
|
||||
part.pk: part.stocktakes.count() for part in Part.objects.all()
|
||||
}
|
||||
|
||||
# Initially, run with stocktake functionality disabled
|
||||
set_global_setting('STOCKTAKE_ENABLE', False)
|
||||
|
||||
perform_stocktake()
|
||||
|
||||
# No change, as functionality is disabled
|
||||
self.assertEqual(PartStocktake.objects.count(), N_STOCKTAKE)
|
||||
|
||||
for p in Part.objects.all():
|
||||
self.assertEqual(p.stocktakes.count(), stock_history_entries[p.pk])
|
||||
|
||||
# Now enable stocktake functionality
|
||||
set_global_setting('STOCKTAKE_ENABLE', True)
|
||||
|
||||
# Ensure that there is at least one inactive part
|
||||
p = Part.objects.first()
|
||||
p.active = False
|
||||
p.save()
|
||||
|
||||
perform_stocktake()
|
||||
self.assertGreater(PartStocktake.objects.count(), N_STOCKTAKE)
|
||||
|
||||
for p in Part.objects.all():
|
||||
if p.active:
|
||||
# Active parts should have stocktake entries created
|
||||
self.assertGreater(p.stocktakes.count(), stock_history_entries[p.pk])
|
||||
else:
|
||||
# Inactive parts should not have stocktake entries created
|
||||
self.assertEqual(p.stocktakes.count(), stock_history_entries[p.pk])
|
||||
|
||||
# Now, run again - should not create any new entries
|
||||
N_STOCKTAKE = PartStocktake.objects.count()
|
||||
perform_stocktake()
|
||||
self.assertEqual(PartStocktake.objects.count(), N_STOCKTAKE)
|
||||
|
@@ -156,7 +156,6 @@ def allow_table_event(table_name):
|
||||
'common_webhookmessage',
|
||||
'part_partpricing',
|
||||
'part_partstocktake',
|
||||
'part_partstocktakereport',
|
||||
]
|
||||
|
||||
return table_name not in ignore_tables
|
||||
|
@@ -0,0 +1,160 @@
|
||||
"""Custom data exporter for part stocktake data."""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from InvenTree.helpers import normalize
|
||||
from part.models import Part
|
||||
from part.serializers import PartSerializer
|
||||
from plugin import InvenTreePlugin
|
||||
from plugin.mixins import DataExportMixin
|
||||
|
||||
|
||||
class PartStocktakeExportOptionsSerializer(serializers.Serializer):
|
||||
"""Custom export options for the PartStocktakeExporter plugin."""
|
||||
|
||||
export_pricing_data = serializers.BooleanField(
|
||||
default=True, label=_('Pricing Data'), help_text=_('Include part pricing data')
|
||||
)
|
||||
|
||||
export_include_external_items = serializers.BooleanField(
|
||||
default=False,
|
||||
label=_('Include External Stock'),
|
||||
help_text=_('Include external stock in the stocktake data'),
|
||||
)
|
||||
|
||||
export_include_variant_items = serializers.BooleanField(
|
||||
default=False,
|
||||
label=_('Include Variant Items'),
|
||||
help_text=_('Include part variant stock in pricing calculations'),
|
||||
)
|
||||
|
||||
|
||||
class PartStocktakeExporter(DataExportMixin, InvenTreePlugin):
|
||||
"""Builtin plugin for exporting part stocktake data.
|
||||
|
||||
Extends the "part" export process, to include stocktake data.
|
||||
"""
|
||||
|
||||
NAME = 'Part Stocktake Exporter'
|
||||
SLUG = 'inventree-stocktake-exporter'
|
||||
TITLE = _('Part Stocktake Exporter')
|
||||
DESCRIPTION = _('Exporter for part stocktake data')
|
||||
VERSION = '1.0.0'
|
||||
AUTHOR = _('InvenTree contributors')
|
||||
|
||||
ExportOptionsSerializer = PartStocktakeExportOptionsSerializer
|
||||
|
||||
def supports_export(
|
||||
self,
|
||||
model_class: type,
|
||||
user=None,
|
||||
serializer_class=None,
|
||||
view_class=None,
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> bool:
|
||||
"""Supported if the base model is Part."""
|
||||
return model_class == Part and serializer_class == PartSerializer
|
||||
|
||||
def generate_filename(self, model_class, export_format: str) -> str:
|
||||
"""Generate a filename for the exported part stocktake data."""
|
||||
from InvenTree.helpers import current_date
|
||||
|
||||
date = current_date().isoformat()
|
||||
return f'InvenTree_Stocktake_{date}.{export_format}'
|
||||
|
||||
def update_headers(self, headers, context, **kwargs):
|
||||
"""Define headers for the Stocktake export."""
|
||||
export_pricing_data = context.get('export_pricing_data', True)
|
||||
include_external_items = context.get('export_include_external_items', True)
|
||||
include_variant_items = context.get('export_include_variant_items', False)
|
||||
|
||||
# Use only a subset of fields from the PartSerializer
|
||||
base_headers = [
|
||||
'pk',
|
||||
'name',
|
||||
'IPN',
|
||||
'description',
|
||||
'category',
|
||||
'allocated_to_build_orders',
|
||||
'allocated_to_sales_orders',
|
||||
'required_for_build_orders',
|
||||
'required_for_sales_orders',
|
||||
'ordering',
|
||||
'building',
|
||||
'scheduled_to_build',
|
||||
'external_stock',
|
||||
'variant_stock',
|
||||
'stock_item_count',
|
||||
'total_in_stock',
|
||||
]
|
||||
|
||||
if not include_external_items:
|
||||
base_headers.remove('external_stock')
|
||||
|
||||
if not include_variant_items:
|
||||
base_headers.remove('variant_stock')
|
||||
|
||||
stocktake_headers = {
|
||||
key: headers[key] for key in base_headers if key in headers
|
||||
}
|
||||
|
||||
if export_pricing_data:
|
||||
stocktake_headers.update({
|
||||
'pricing_min': _('Minimum Unit Cost'),
|
||||
'pricing_max': _('Maximum Unit Cost'),
|
||||
'pricing_min_total': _('Minimum Total Cost'),
|
||||
'pricing_max_total': _('Maximum Total Cost'),
|
||||
})
|
||||
|
||||
return stocktake_headers
|
||||
|
||||
def prefetch_queryset(self, queryset):
|
||||
"""Prefetch related data for the queryset."""
|
||||
return queryset.prefetch_related('stock_items')
|
||||
|
||||
def export_data(
|
||||
self, queryset, serializer_class, headers, context, output, **kwargs
|
||||
):
|
||||
"""Export the data for the given queryset."""
|
||||
export_pricing_data = context.get('export_pricing_data', True)
|
||||
include_external_items = context.get('export_include_external_items', False)
|
||||
include_variant_items = context.get('export_include_variant_items', False)
|
||||
|
||||
data = super().export_data(
|
||||
queryset, serializer_class, headers, context, output, **kwargs
|
||||
)
|
||||
|
||||
if export_pricing_data:
|
||||
for row in data:
|
||||
quantity = Decimal(row.get('total_in_stock', 0))
|
||||
|
||||
if not include_external_items:
|
||||
quantity -= Decimal(row.get('external_stock', 0))
|
||||
|
||||
if not include_variant_items:
|
||||
quantity -= Decimal(row.get('variant_stock', 0))
|
||||
|
||||
if quantity < 0:
|
||||
quantity = Decimal(0)
|
||||
|
||||
pricing_min = row.get('pricing_min', None)
|
||||
pricing_max = row.get('pricing_max', None)
|
||||
|
||||
if pricing_min is not None:
|
||||
pricing_min = Decimal(pricing_min)
|
||||
row['pricing_min_total'] = normalize(
|
||||
pricing_min * quantity, rounding=10
|
||||
)
|
||||
|
||||
if pricing_max is not None:
|
||||
pricing_max = Decimal(pricing_max)
|
||||
row['pricing_max_total'] = normalize(
|
||||
pricing_max * quantity, rounding=10
|
||||
)
|
||||
|
||||
return data
|
@@ -0,0 +1,83 @@
|
||||
"""Unit test for the exporter plugins."""
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||
from plugin.registry import registry
|
||||
|
||||
|
||||
class StocktakeExporterTest(InvenTreeAPITestCase):
|
||||
"""Test the stocktake exporter plugin."""
|
||||
|
||||
fixtures = ['category', 'part', 'location', 'stock', 'bom', 'company']
|
||||
roles = ['part.add', 'part.change', 'part.delete', 'stock.view']
|
||||
|
||||
def test_stocktake_exporter(self):
|
||||
"""Test the stocktake exporter plugin."""
|
||||
from part.models import Part
|
||||
|
||||
slug = 'inventree-stocktake-exporter'
|
||||
|
||||
registry.set_plugin_state(slug, True)
|
||||
|
||||
url = reverse('api-part-list')
|
||||
|
||||
# Download all part data using the 'stocktake' exporter
|
||||
# Use the "default" values
|
||||
with self.export_data(
|
||||
url, export_plugin=slug, export_format='csv'
|
||||
) as data_file:
|
||||
self.process_csv(
|
||||
data_file,
|
||||
required_rows=Part.objects.count(),
|
||||
required_cols=[
|
||||
'Name',
|
||||
'IPN',
|
||||
'Total Stock',
|
||||
'Minimum Unit Cost',
|
||||
'Maximum Total Cost',
|
||||
],
|
||||
excluded_cols=['Active', 'External Stock', 'Variant Stock'],
|
||||
)
|
||||
|
||||
# Now, with additional parameters specific to the plugin
|
||||
with self.export_data(
|
||||
url,
|
||||
export_plugin=slug,
|
||||
export_format='csv',
|
||||
export_pricing_data=True,
|
||||
export_include_external_items=True,
|
||||
export_include_variant_items=True,
|
||||
) as data_file:
|
||||
self.process_csv(
|
||||
data_file,
|
||||
required_rows=Part.objects.count(),
|
||||
required_cols=[
|
||||
'Total Stock',
|
||||
'On Order',
|
||||
'Minimum Unit Cost',
|
||||
'Maximum Total Cost',
|
||||
'External Stock',
|
||||
'Variant Stock',
|
||||
],
|
||||
excluded_cols=['Active'],
|
||||
)
|
||||
|
||||
# Finally, exclude pricing data entirely
|
||||
with self.export_data(
|
||||
url, export_plugin=slug, export_format='csv', export_pricing_data=False
|
||||
) as data_file:
|
||||
self.process_csv(
|
||||
data_file,
|
||||
required_rows=Part.objects.count(),
|
||||
required_cols=['Total Stock', 'On Order'],
|
||||
excluded_cols=[
|
||||
'Minimum Unit Cost',
|
||||
'Maximum Total Cost',
|
||||
'Variant Stock',
|
||||
'External Stock',
|
||||
],
|
||||
)
|
||||
|
||||
# Reset plugin state
|
||||
registry.set_plugin_state(slug, False)
|
@@ -504,7 +504,7 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
|
||||
# Filter by 'mixin' parameter
|
||||
mixin_results = {
|
||||
PluginMixinEnum.BARCODE: 5,
|
||||
PluginMixinEnum.EXPORTER: 3,
|
||||
PluginMixinEnum.EXPORTER: 4,
|
||||
PluginMixinEnum.ICON_PACK: 1,
|
||||
PluginMixinEnum.MAIL: 1,
|
||||
PluginMixinEnum.NOTIFICATION: 3,
|
||||
|
@@ -13,7 +13,6 @@ _roles = {
|
||||
'admin': 'Role Admin',
|
||||
'part_category': 'Role Part Categories',
|
||||
'part': 'Role Parts',
|
||||
'stocktake': 'Role Stocktake',
|
||||
'stock_location': 'Role Stock Locations',
|
||||
'stock': 'Role Stock Items',
|
||||
'build': 'Role Build Orders',
|
||||
|
@@ -12,7 +12,6 @@ class RuleSetEnum(StringEnum):
|
||||
ADMIN = 'admin'
|
||||
PART_CATEGORY = 'part_category'
|
||||
PART = 'part'
|
||||
STOCKTAKE = 'stocktake'
|
||||
STOCK_LOCATION = 'stock_location'
|
||||
STOCK = 'stock'
|
||||
BUILD = 'build'
|
||||
@@ -27,7 +26,6 @@ RULESET_CHOICES = [
|
||||
(RuleSetEnum.ADMIN, _('Admin')),
|
||||
(RuleSetEnum.PART_CATEGORY, _('Part Categories')),
|
||||
(RuleSetEnum.PART, _('Parts')),
|
||||
(RuleSetEnum.STOCKTAKE, _('Stocktake')),
|
||||
(RuleSetEnum.STOCK_LOCATION, _('Stock Locations')),
|
||||
(RuleSetEnum.STOCK, _('Stock Items')),
|
||||
(RuleSetEnum.BUILD, _('Build Orders')),
|
||||
@@ -112,12 +110,12 @@ def get_ruleset_models() -> dict:
|
||||
'part_partparameter',
|
||||
'part_partrelated',
|
||||
'part_partstar',
|
||||
'part_partstocktake',
|
||||
'part_partcategorystar',
|
||||
'company_supplierpart',
|
||||
'company_manufacturerpart',
|
||||
'company_manufacturerpartparameter',
|
||||
],
|
||||
RuleSetEnum.STOCKTAKE: ['part_partstocktake', 'part_partstocktakereport'],
|
||||
RuleSetEnum.STOCK_LOCATION: ['stock_stocklocation', 'stock_stocklocationtype'],
|
||||
RuleSetEnum.STOCK: [
|
||||
'stock_stockitem',
|
||||
|
@@ -118,8 +118,6 @@ export enum ApiEndpoints {
|
||||
part_pricing_internal = 'part/internal-price/',
|
||||
part_pricing_sale = 'part/sale-price/',
|
||||
part_stocktake_list = 'part/stocktake/',
|
||||
part_stocktake_report_list = 'part/stocktake/report/',
|
||||
part_stocktake_report_generate = 'part/stocktake/report/generate/',
|
||||
category_list = 'part/category/',
|
||||
category_tree = 'part/category/tree/',
|
||||
category_parameter_list = 'part/category/parameters/',
|
||||
|
@@ -12,8 +12,7 @@ export enum UserRoles {
|
||||
return_order = 'return_order',
|
||||
sales_order = 'sales_order',
|
||||
stock = 'stock',
|
||||
stock_location = 'stock_location',
|
||||
stocktake = 'stocktake'
|
||||
stock_location = 'stock_location'
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -46,8 +45,6 @@ export function userRoleLabel(role: UserRoles): string {
|
||||
return t`Stock Items`;
|
||||
case UserRoles.stock_location:
|
||||
return t`Stock Location`;
|
||||
case UserRoles.stocktake:
|
||||
return t`Stocktake`;
|
||||
default:
|
||||
return role as string;
|
||||
}
|
||||
|
@@ -279,14 +279,3 @@ export function partStocktakeFields(): ApiFormFieldSet {
|
||||
note: {}
|
||||
};
|
||||
}
|
||||
|
||||
export function generateStocktakeReportFields(): ApiFormFieldSet {
|
||||
return {
|
||||
part: {},
|
||||
category: {},
|
||||
location: {},
|
||||
exclude_external: {},
|
||||
generate_report: {},
|
||||
update_parts: {}
|
||||
};
|
||||
}
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Stack } from '@mantine/core';
|
||||
import {
|
||||
IconClipboardCheck,
|
||||
IconCoins,
|
||||
IconCpu,
|
||||
IconDevicesPc,
|
||||
@@ -103,8 +102,6 @@ const LocationTypesTable = Loadable(
|
||||
lazy(() => import('../../../../tables/stock/LocationTypesTable'))
|
||||
);
|
||||
|
||||
const StocktakePanel = Loadable(lazy(() => import('./StocktakePanel')));
|
||||
|
||||
export default function AdminCenter() {
|
||||
const user = useUserState();
|
||||
|
||||
@@ -197,13 +194,6 @@ export default function AdminCenter() {
|
||||
content: <PartCategoryTemplateTable />,
|
||||
hidden: !user.hasViewRole(UserRoles.part_category)
|
||||
},
|
||||
{
|
||||
name: 'stocktake',
|
||||
label: t`Stocktake`,
|
||||
icon: <IconClipboardCheck />,
|
||||
content: <StocktakePanel />,
|
||||
hidden: !user.hasViewRole(UserRoles.stocktake)
|
||||
},
|
||||
{
|
||||
name: 'labels',
|
||||
label: t`Label Templates`,
|
||||
|
@@ -1,31 +0,0 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Divider, Stack } from '@mantine/core';
|
||||
import { lazy } from 'react';
|
||||
|
||||
import { StylishText } from '../../../../components/items/StylishText';
|
||||
import { GlobalSettingList } from '../../../../components/settings/SettingList';
|
||||
import { Loadable } from '../../../../functions/loading';
|
||||
|
||||
const StocktakeReportTable = Loadable(
|
||||
lazy(() => import('../../../../tables/settings/StocktakeReportTable'))
|
||||
);
|
||||
|
||||
export default function StocktakePanel() {
|
||||
return (
|
||||
<Stack gap='xs'>
|
||||
<GlobalSettingList
|
||||
keys={[
|
||||
'STOCKTAKE_ENABLE',
|
||||
'STOCKTAKE_EXCLUDE_EXTERNAL',
|
||||
'STOCKTAKE_AUTO_DAYS',
|
||||
'STOCKTAKE_DELETE_REPORT_DAYS'
|
||||
]}
|
||||
/>
|
||||
<StylishText size='lg'>
|
||||
<Trans>Stocktake Reports</Trans>
|
||||
</StylishText>
|
||||
<Divider />
|
||||
<StocktakeReportTable />
|
||||
</Stack>
|
||||
);
|
||||
}
|
@@ -3,6 +3,7 @@ import { Skeleton, Stack } from '@mantine/core';
|
||||
import {
|
||||
IconBellCog,
|
||||
IconCategory,
|
||||
IconClipboardList,
|
||||
IconCurrencyDollar,
|
||||
IconFileAnalytics,
|
||||
IconFingerprint,
|
||||
@@ -242,6 +243,22 @@ export default function SystemSettings() {
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'stock-history',
|
||||
label: t`Stock History`,
|
||||
icon: <IconClipboardList />,
|
||||
content: (
|
||||
<GlobalSettingList
|
||||
keys={[
|
||||
'STOCKTAKE_ENABLE',
|
||||
'STOCKTAKE_EXCLUDE_EXTERNAL',
|
||||
'STOCKTAKE_AUTO_DAYS',
|
||||
'STOCKTAKE_DELETE_OLD_ENTRIES',
|
||||
'STOCKTAKE_DELETE_DAYS'
|
||||
]}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'buildorders',
|
||||
label: t`Build Orders`,
|
||||
|
@@ -109,7 +109,7 @@ import { SalesOrderTable } from '../../tables/sales/SalesOrderTable';
|
||||
import { StockItemTable } from '../../tables/stock/StockItemTable';
|
||||
import PartAllocationPanel from './PartAllocationPanel';
|
||||
import PartPricingPanel from './PartPricingPanel';
|
||||
import PartStocktakeDetail from './PartStocktakeDetail';
|
||||
import PartStockHistoryDetail from './PartStockHistoryDetail';
|
||||
import PartSupplierDetail from './PartSupplierDetail';
|
||||
|
||||
/**
|
||||
@@ -909,9 +909,12 @@ export default function PartDetail() {
|
||||
name: 'stocktake',
|
||||
label: t`Stock History`,
|
||||
icon: <IconClipboardList />,
|
||||
content: part ? <PartStocktakeDetail partId={part.pk} /> : <Skeleton />,
|
||||
content: part ? (
|
||||
<PartStockHistoryDetail partId={part.pk} />
|
||||
) : (
|
||||
<Skeleton />
|
||||
),
|
||||
hidden:
|
||||
!user.hasViewRole(UserRoles.stocktake) ||
|
||||
!globalSettings.isSet('STOCKTAKE_ENABLE') ||
|
||||
!userSettings.isSet('DISPLAY_STOCKTAKE_TAB')
|
||||
},
|
||||
|
@@ -1,3 +1,8 @@
|
||||
import { RowDeleteAction, RowEditAction } from '@lib/components/RowActions';
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import type { TableColumn } from '@lib/types/Tables';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { type ChartTooltipProps, LineChart } from '@mantine/charts';
|
||||
import {
|
||||
@@ -8,27 +13,17 @@ import {
|
||||
SimpleGrid,
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { AddItemButton } from '@lib/components/AddItemButton';
|
||||
import { RowDeleteAction, RowEditAction } from '@lib/components/RowActions';
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import type { TableColumn } from '@lib/types/Tables';
|
||||
import dayjs from 'dayjs';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { formatDate, formatPriceRange } from '../../defaults/formatters';
|
||||
import { partStocktakeFields } from '../../forms/PartForms';
|
||||
import {
|
||||
generateStocktakeReportFields,
|
||||
partStocktakeFields
|
||||
} from '../../forms/PartForms';
|
||||
import {
|
||||
useCreateApiFormModal,
|
||||
useDeleteApiFormModal,
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useTable } from '../../hooks/UseTable';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { DecimalColumn } from '../../tables/ColumnRenderers';
|
||||
import { InvenTreeTable } from '../../tables/InvenTreeTable';
|
||||
|
||||
/*
|
||||
@@ -67,7 +62,7 @@ function ChartTooltip({ label, payload }: Readonly<ChartTooltipProps>) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function PartStocktakeDetail({
|
||||
export default function PartStockHistoryDetail({
|
||||
partId
|
||||
}: Readonly<{ partId: number }>) {
|
||||
const user = useUserState();
|
||||
@@ -94,29 +89,19 @@ export default function PartStocktakeDetail({
|
||||
table: table
|
||||
});
|
||||
|
||||
const generateReport = useCreateApiFormModal({
|
||||
url: ApiEndpoints.part_stocktake_report_generate,
|
||||
title: t`Generate Stocktake Report`,
|
||||
fields: generateStocktakeReportFields(),
|
||||
initialData: {
|
||||
part: partId
|
||||
},
|
||||
successMessage: t`Stocktake report scheduled`
|
||||
});
|
||||
|
||||
const tableColumns: TableColumn[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
DecimalColumn({
|
||||
accessor: 'quantity',
|
||||
sortable: false,
|
||||
switchable: false
|
||||
},
|
||||
{
|
||||
}),
|
||||
DecimalColumn({
|
||||
accessor: 'item_count',
|
||||
title: t`Stock Items`,
|
||||
switchable: true,
|
||||
sortable: false
|
||||
},
|
||||
}),
|
||||
{
|
||||
accessor: 'cost',
|
||||
title: t`Stock Value`,
|
||||
@@ -129,38 +114,24 @@ export default function PartStocktakeDetail({
|
||||
},
|
||||
{
|
||||
accessor: 'date',
|
||||
sortable: false
|
||||
},
|
||||
{
|
||||
accessor: 'note',
|
||||
sortable: false
|
||||
sortable: true,
|
||||
switchable: false
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
return [
|
||||
<AddItemButton
|
||||
key='add'
|
||||
tooltip={t`New Stocktake Report`}
|
||||
onClick={() => generateReport.open()}
|
||||
hidden={!user.hasAddRole(UserRoles.stocktake)}
|
||||
/>
|
||||
];
|
||||
}, [user]);
|
||||
|
||||
const rowActions = useCallback(
|
||||
(record: any) => {
|
||||
return [
|
||||
RowEditAction({
|
||||
hidden: !user.hasChangeRole(UserRoles.stocktake),
|
||||
hidden: !user.hasChangeRole(UserRoles.part),
|
||||
onClick: () => {
|
||||
setSelectedStocktake(record.pk);
|
||||
editStocktakeEntry.open();
|
||||
}
|
||||
}),
|
||||
RowDeleteAction({
|
||||
hidden: !user.hasDeleteRole(UserRoles.stocktake),
|
||||
hidden: !user.hasDeleteRole(UserRoles.part),
|
||||
onClick: () => {
|
||||
setSelectedStocktake(record.pk);
|
||||
deleteStocktakeEntry.open();
|
||||
@@ -207,7 +178,6 @@ export default function PartStocktakeDetail({
|
||||
|
||||
return (
|
||||
<>
|
||||
{generateReport.modal}
|
||||
{editStocktakeEntry.modal}
|
||||
{deleteStocktakeEntry.modal}
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
||||
@@ -216,12 +186,13 @@ export default function PartStocktakeDetail({
|
||||
tableState={table}
|
||||
columns={tableColumns}
|
||||
props={{
|
||||
enableSelection: true,
|
||||
enableBulkDelete: true,
|
||||
params: {
|
||||
part: partId,
|
||||
ordering: 'date'
|
||||
},
|
||||
rowActions: rowActions,
|
||||
tableActions: tableActions
|
||||
rowActions: rowActions
|
||||
}}
|
||||
/>
|
||||
{table.isLoading ? (
|
@@ -316,12 +316,6 @@ function partTableFilters(): TableFilter[] {
|
||||
label: t`Subscribed`,
|
||||
description: t`Filter by parts to which the user is subscribed`,
|
||||
type: 'boolean'
|
||||
},
|
||||
{
|
||||
name: 'stocktake',
|
||||
label: t`Has Stocktake`,
|
||||
description: t`Filter by parts which have stocktake information`,
|
||||
type: 'boolean'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
@@ -1,111 +0,0 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { AddItemButton } from '@lib/components/AddItemButton';
|
||||
import { type RowAction, RowDeleteAction } from '@lib/components/RowActions';
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import type { ApiFormFieldSet } from '@lib/types/Forms';
|
||||
import type { TableColumn } from '@lib/types/Tables';
|
||||
import { AttachmentLink } from '../../components/items/AttachmentLink';
|
||||
import { RenderUser } from '../../components/render/User';
|
||||
import { generateStocktakeReportFields } from '../../forms/PartForms';
|
||||
import {
|
||||
useCreateApiFormModal,
|
||||
useDeleteApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useTable } from '../../hooks/UseTable';
|
||||
import { DateColumn } from '../ColumnRenderers';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
|
||||
export default function StocktakeReportTable() {
|
||||
const table = useTable('stocktake-report');
|
||||
|
||||
const tableColumns: TableColumn[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
accessor: 'report',
|
||||
title: t`Report`,
|
||||
sortable: false,
|
||||
switchable: false,
|
||||
render: (record: any) => <AttachmentLink attachment={record.report} />,
|
||||
noContext: true
|
||||
},
|
||||
{
|
||||
accessor: 'part_count',
|
||||
title: t`Part Count`,
|
||||
sortable: false
|
||||
},
|
||||
DateColumn({
|
||||
accessor: 'date',
|
||||
title: t`Date`
|
||||
}),
|
||||
{
|
||||
accessor: 'user',
|
||||
title: t`User`,
|
||||
sortable: false,
|
||||
render: (record: any) => RenderUser({ instance: record.user_detail })
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
|
||||
const [selectedReport, setSelectedReport] = useState<number | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const deleteReport = useDeleteApiFormModal({
|
||||
url: ApiEndpoints.part_stocktake_report_list,
|
||||
pk: selectedReport,
|
||||
title: t`Delete Report`,
|
||||
onFormSuccess: () => table.refreshTable()
|
||||
});
|
||||
|
||||
const generateFields: ApiFormFieldSet = useMemo(
|
||||
() => generateStocktakeReportFields(),
|
||||
[]
|
||||
);
|
||||
|
||||
const generateReport = useCreateApiFormModal({
|
||||
url: ApiEndpoints.part_stocktake_report_generate,
|
||||
title: t`Generate Stocktake Report`,
|
||||
fields: generateFields,
|
||||
successMessage: t`Stocktake report scheduled`
|
||||
});
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
return [
|
||||
<AddItemButton
|
||||
tooltip={t`New Stocktake Report`}
|
||||
onClick={() => generateReport.open()}
|
||||
/>
|
||||
];
|
||||
}, []);
|
||||
|
||||
const rowActions = useCallback((record: any): RowAction[] => {
|
||||
return [
|
||||
RowDeleteAction({
|
||||
onClick: () => {
|
||||
setSelectedReport(record.pk);
|
||||
deleteReport.open();
|
||||
}
|
||||
})
|
||||
];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{generateReport.modal}
|
||||
{deleteReport.modal}
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiEndpoints.part_stocktake_report_list)}
|
||||
tableState={table}
|
||||
columns={tableColumns}
|
||||
props={{
|
||||
enableSearch: false,
|
||||
rowActions: rowActions,
|
||||
tableActions: tableActions
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -100,10 +100,10 @@ export const navigate = async (
|
||||
/**
|
||||
* CLick on the 'tab' element with the provided name
|
||||
*/
|
||||
export const loadTab = async (page, tabName) => {
|
||||
export const loadTab = async (page, tabName, exact?) => {
|
||||
await page
|
||||
.getByLabel(/panel-tabs-/)
|
||||
.getByRole('tab', { name: tabName })
|
||||
.getByRole('tab', { name: tabName, exact: exact ?? false })
|
||||
.click();
|
||||
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
@@ -139,7 +139,8 @@ test('Settings - Global', async ({ browser, request }) => {
|
||||
await loadTab(page, 'Barcodes');
|
||||
await loadTab(page, 'Pricing');
|
||||
await loadTab(page, 'Parts');
|
||||
await loadTab(page, 'Stock');
|
||||
await loadTab(page, 'Stock', true);
|
||||
await loadTab(page, 'Stock History');
|
||||
|
||||
await loadTab(page, 'Notifications');
|
||||
await page
|
||||
|
Reference in New Issue
Block a user