mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 05:05:42 +00:00 
			
		
		
		
	Part stocktake (#4069)
* Remove stat context variables
* Revert "Remove stat context variables"
This reverts commit 0989c308d0.
* Allow longer timeout for image download tests
* Create PartStocktake model
Model for representing stocktake entries against any given part
* Admin interface support for new model
* Adds API endpoint for listing stocktake information
* Bump API version
* Enable filtering and ordering for API endpoint
* Add model to permission group
* Add UI hooks for displaying stocktake data for a particular part
* Fix encoded type for 'quantity' field
* Load stocktake table for part
* Add "stocktake" button
* Add "note" field for stocktake
* Add user information when performing stocktake
* First pass at UI elements for performing stocktake
* Add user information to stocktake table
* Auto-calculate quantity based on available stock items
* add stocktake data as tabular inline (admin)
* js linting
* Add indication that a stock item has not been updated recently
* Display last stocktake information on part page
* js style fix
* Test fix for ongoing CI issues
* Add configurable option for controlling default "delete_on_deplete" behaviour
* Add id values to cells
* Hide action buttons (at least for now)
* Adds refresh button to table
* Add API endpoint to delete or edit stocktake entries
* Adds unit test for API list endpoint
* javascript linting
* More unit testing
* Add Part API filter for stocktake
* Add 'last_stocktake' field to Part model
- Gets filled out automatically when a new PartStocktake instance is created
* Update part table to include last_stocktake date
* Add simple unit test
* Fix test
			
			
This commit is contained in:
		| @@ -2,11 +2,14 @@ | ||||
|  | ||||
|  | ||||
| # InvenTree API version | ||||
| INVENTREE_API_VERSION = 85 | ||||
| INVENTREE_API_VERSION = 86 | ||||
|  | ||||
| """ | ||||
| Increment this API version number whenever there is a significant change to the API that any clients need to know about | ||||
|  | ||||
| v86 -> 2022-12-22 : https://github.com/inventree/InvenTree/pull/4069 | ||||
|     - Adds API endpoints for part stocktake | ||||
|  | ||||
| v85 -> 2022-12-21 : https://github.com/inventree/InvenTree/pull/3858 | ||||
|     - Add endpoints serving ICS calendars for purchase and sales orders through API | ||||
|  | ||||
|   | ||||
| @@ -466,7 +466,7 @@ $('#btn-create-output').click(function() { | ||||
|     createBuildOutput( | ||||
|         {{ build.pk }}, | ||||
|         { | ||||
|             trackable_parts: {% if build.part.has_trackable_parts %}true{% else %}false{% endif%}, | ||||
|             trackable_parts: {% js_bool build.part.has_trackable_parts %}, | ||||
|         } | ||||
|     ); | ||||
| }); | ||||
|   | ||||
| @@ -1774,6 +1774,13 @@ class InvenTreeUserSetting(BaseInvenTreeSetting): | ||||
|             'validator': bool, | ||||
|         }, | ||||
|  | ||||
|         'DISPLAY_STOCKTAKE_TAB': { | ||||
|             'name': _('Part Stocktake'), | ||||
|             'description': _('Display part stocktake information'), | ||||
|             'default': True, | ||||
|             'validator': bool, | ||||
|         }, | ||||
|  | ||||
|         'TABLE_STRING_MAX_LENGTH': { | ||||
|             'name': _('Table String Length'), | ||||
|             'description': _('Maximimum length limit for strings displayed in table views'), | ||||
|   | ||||
| @@ -104,6 +104,11 @@ class PartResource(InvenTreeResource): | ||||
|         models.Part.objects.rebuild() | ||||
|  | ||||
|  | ||||
| class StocktakeInline(admin.TabularInline): | ||||
|     """Inline for part stocktake data""" | ||||
|     model = models.PartStocktake | ||||
|  | ||||
|  | ||||
| class PartAdmin(ImportExportModelAdmin): | ||||
|     """Admin class for the Part model""" | ||||
|  | ||||
| @@ -122,6 +127,10 @@ class PartAdmin(ImportExportModelAdmin): | ||||
|         'default_supplier', | ||||
|     ] | ||||
|  | ||||
|     inlines = [ | ||||
|         StocktakeInline, | ||||
|     ] | ||||
|  | ||||
|  | ||||
| class PartPricingAdmin(admin.ModelAdmin): | ||||
|     """Admin class for PartPricing model""" | ||||
| @@ -133,6 +142,12 @@ class PartPricingAdmin(admin.ModelAdmin): | ||||
|     ] | ||||
|  | ||||
|  | ||||
| class PartStocktakeAdmin(admin.ModelAdmin): | ||||
|     """Admin class for PartStocktake model""" | ||||
|  | ||||
|     list_display = ['part', 'date', 'quantity', 'user'] | ||||
|  | ||||
|  | ||||
| class PartCategoryResource(InvenTreeResource): | ||||
|     """Class for managing PartCategory data import/export.""" | ||||
|  | ||||
| @@ -400,3 +415,4 @@ admin.site.register(models.PartTestTemplate, PartTestTemplateAdmin) | ||||
| admin.site.register(models.PartSellPriceBreak, PartSellPriceBreakAdmin) | ||||
| admin.site.register(models.PartInternalPriceBreak, PartInternalPriceBreakAdmin) | ||||
| admin.site.register(models.PartPricing, PartPricingAdmin) | ||||
| admin.site.register(models.PartStocktake, PartStocktakeAdmin) | ||||
|   | ||||
| @@ -13,6 +13,7 @@ from django_filters import rest_framework as rest_filters | ||||
| from django_filters.rest_framework import DjangoFilterBackend | ||||
| from rest_framework import filters, serializers, status | ||||
| from rest_framework.exceptions import ValidationError | ||||
| from rest_framework.permissions import IsAdminUser | ||||
| from rest_framework.response import Response | ||||
|  | ||||
| import order.models | ||||
| @@ -27,6 +28,7 @@ from InvenTree.mixins import (CreateAPI, CustomRetrieveUpdateDestroyAPI, | ||||
|                               ListAPI, ListCreateAPI, RetrieveAPI, | ||||
|                               RetrieveUpdateAPI, RetrieveUpdateDestroyAPI, | ||||
|                               UpdateAPI) | ||||
| from InvenTree.permissions import RolePermission | ||||
| from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus, | ||||
|                                     SalesOrderStatus) | ||||
| from part.admin import PartCategoryResource, PartResource | ||||
| @@ -38,7 +40,7 @@ from .models import (BomItem, BomItemSubstitute, Part, PartAttachment, | ||||
|                      PartCategory, PartCategoryParameterTemplate, | ||||
|                      PartInternalPriceBreak, PartParameter, | ||||
|                      PartParameterTemplate, PartRelated, PartSellPriceBreak, | ||||
|                      PartTestTemplate) | ||||
|                      PartStocktake, PartTestTemplate) | ||||
|  | ||||
|  | ||||
| class CategoryList(APIDownloadMixin, ListCreateAPI): | ||||
| @@ -1061,6 +1063,20 @@ class PartFilter(rest_filters.FilterSet): | ||||
|  | ||||
|         return queryset | ||||
|  | ||||
|     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""" | ||||
|  | ||||
|         value = str2bool(value) | ||||
|  | ||||
|         if (value): | ||||
|             queryset = queryset.exclude(last_stocktake=None) | ||||
|         else: | ||||
|             queryset = queryset.filter(last_stocktake=None) | ||||
|  | ||||
|         return queryset | ||||
|  | ||||
|     is_template = rest_filters.BooleanFilter() | ||||
|  | ||||
|     assembly = rest_filters.BooleanFilter() | ||||
| @@ -1537,6 +1553,7 @@ class PartList(APIDownloadMixin, ListCreateAPI): | ||||
|         'in_stock', | ||||
|         'unallocated_stock', | ||||
|         'category', | ||||
|         'last_stocktake', | ||||
|     ] | ||||
|  | ||||
|     # Default ordering | ||||
| @@ -1696,6 +1713,63 @@ class PartParameterDetail(RetrieveUpdateDestroyAPI): | ||||
|     serializer_class = part_serializers.PartParameterSerializer | ||||
|  | ||||
|  | ||||
| class PartStocktakeFilter(rest_filters.FilterSet): | ||||
|     """Custom fitler for the PartStocktakeList endpoint""" | ||||
|  | ||||
|     class Meta: | ||||
|         """Metaclass options""" | ||||
|  | ||||
|         model = PartStocktake | ||||
|         fields = [ | ||||
|             'part', | ||||
|             'user', | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class PartStocktakeList(ListCreateAPI): | ||||
|     """API endpoint for listing part stocktake information""" | ||||
|  | ||||
|     queryset = PartStocktake.objects.all() | ||||
|     serializer_class = part_serializers.PartStocktakeSerializer | ||||
|     filterset_class = PartStocktakeFilter | ||||
|  | ||||
|     def get_serializer_context(self): | ||||
|         """Extend serializer context data""" | ||||
|         context = super().get_serializer_context() | ||||
|         context['request'] = self.request | ||||
|  | ||||
|         return context | ||||
|  | ||||
|     filter_backends = [ | ||||
|         DjangoFilterBackend, | ||||
|         filters.OrderingFilter, | ||||
|     ] | ||||
|  | ||||
|     ordering_fields = [ | ||||
|         'part', | ||||
|         'quantity', | ||||
|         'date', | ||||
|         'user', | ||||
|     ] | ||||
|  | ||||
|     # Reverse date ordering by default | ||||
|     ordering = '-pk' | ||||
|  | ||||
|  | ||||
| class PartStocktakeDetail(RetrieveUpdateDestroyAPI): | ||||
|     """Detail API endpoint for a single PartStocktake instance. | ||||
|  | ||||
|     Note: Only staff (admin) users can access this endpoint. | ||||
|     """ | ||||
|  | ||||
|     queryset = PartStocktake.objects.all() | ||||
|     serializer_class = part_serializers.PartStocktakeSerializer | ||||
|     permission_classes = [ | ||||
|         IsAdminUser, | ||||
|         RolePermission, | ||||
|     ] | ||||
|  | ||||
|  | ||||
| class BomFilter(rest_filters.FilterSet): | ||||
|     """Custom filters for the BOM list.""" | ||||
|  | ||||
| @@ -2111,6 +2185,12 @@ part_api_urls = [ | ||||
|         re_path(r'^.*$', PartParameterList.as_view(), name='api-part-parameter-list'), | ||||
|     ])), | ||||
|  | ||||
|     # Part stocktake data | ||||
|     re_path(r'^stocktake/', include([ | ||||
|         re_path(r'^(?P<pk>\d+)/', PartStocktakeDetail.as_view(), name='api-part-stocktake-detail'), | ||||
|         re_path(r'^.*$', PartStocktakeList.as_view(), name='api-part-stocktake-list'), | ||||
|     ])), | ||||
|  | ||||
|     re_path(r'^thumbs/', include([ | ||||
|         path('', PartThumbs.as_view(), name='api-part-thumbs'), | ||||
|         re_path(r'^(?P<pk>\d+)/?', PartThumbsUpdate.as_view(), name='api-part-thumbs-update'), | ||||
|   | ||||
							
								
								
									
										28
									
								
								InvenTree/part/migrations/0091_partstocktake.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								InvenTree/part/migrations/0091_partstocktake.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| # Generated by Django 3.2.16 on 2022-12-21 11:26 | ||||
|  | ||||
| from django.conf import settings | ||||
| import django.core.validators | ||||
| from django.db import migrations, models | ||||
| import django.db.models.deletion | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         migrations.swappable_dependency(settings.AUTH_USER_MODEL), | ||||
|         ('part', '0090_auto_20221115_0816'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.CreateModel( | ||||
|             name='PartStocktake', | ||||
|             fields=[ | ||||
|                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||||
|                 ('quantity', models.DecimalField(decimal_places=5, help_text='Total available stock at time of stocktake', max_digits=19, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Quantity')), | ||||
|                 ('date', models.DateField(auto_now_add=True, help_text='Date stocktake was performed', verbose_name='Date')), | ||||
|                 ('note', models.CharField(blank=True, help_text='Additional notes', max_length=250, verbose_name='Notes')), | ||||
|                 ('part', models.ForeignKey(help_text='Part for stocktake', on_delete=django.db.models.deletion.CASCADE, related_name='stocktakes', to='part.part', verbose_name='Part')), | ||||
|                 ('user', models.ForeignKey(blank=True, help_text='User who performed this stocktake', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='part_stocktakes', to=settings.AUTH_USER_MODEL, verbose_name='User')), | ||||
|             ], | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										18
									
								
								InvenTree/part/migrations/0092_part_last_stocktake.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								InvenTree/part/migrations/0092_part_last_stocktake.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| # Generated by Django 3.2.16 on 2022-12-31 09:51 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('part', '0091_partstocktake'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name='part', | ||||
|             name='last_stocktake', | ||||
|             field=models.DateField(blank=True, null=True, verbose_name='Last Stocktake'), | ||||
|         ), | ||||
|     ] | ||||
| @@ -372,6 +372,7 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel): | ||||
|         creation_date: Date that this part was added to the database | ||||
|         creation_user: User who added this part to the database | ||||
|         responsible: User who is responsible for this part (optional) | ||||
|         last_stocktake: Date at which last stocktake was performed for this Part | ||||
|     """ | ||||
|  | ||||
|     objects = PartManager() | ||||
| @@ -1004,6 +1005,11 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel): | ||||
|  | ||||
|     responsible = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True, verbose_name=_('Responsible'), related_name='parts_responible') | ||||
|  | ||||
|     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""" | ||||
| @@ -2161,6 +2167,12 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel): | ||||
|  | ||||
|         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.""" | ||||
| @@ -2878,6 +2890,66 @@ class PartPricing(models.Model): | ||||
|     ) | ||||
|  | ||||
|  | ||||
| class PartStocktake(models.Model): | ||||
|     """Model representing a 'stocktake' 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 user information | ||||
|     """ | ||||
|  | ||||
|     part = models.ForeignKey( | ||||
|         Part, | ||||
|         on_delete=models.CASCADE, | ||||
|         related_name='stocktakes', | ||||
|         verbose_name=_('Part'), | ||||
|         help_text=_('Part for stocktake'), | ||||
|     ) | ||||
|  | ||||
|     quantity = models.DecimalField( | ||||
|         max_digits=19, decimal_places=5, | ||||
|         validators=[MinValueValidator(0)], | ||||
|         verbose_name=_('Quantity'), | ||||
|         help_text=_('Total available stock at time of stocktake'), | ||||
|     ) | ||||
|  | ||||
|     date = models.DateField( | ||||
|         verbose_name=_('Date'), | ||||
|         help_text=_('Date stocktake was performed'), | ||||
|         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'), | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @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 | ||||
|  | ||||
|  | ||||
| class PartAttachment(InvenTreeAttachment): | ||||
|     """Model for storing file attachments against a Part object.""" | ||||
|  | ||||
|   | ||||
| @@ -24,14 +24,16 @@ from InvenTree.serializers import (DataFileExtractSerializer, | ||||
|                                    InvenTreeDecimalField, | ||||
|                                    InvenTreeImageSerializerField, | ||||
|                                    InvenTreeModelSerializer, | ||||
|                                    InvenTreeMoneySerializer, RemoteImageMixin) | ||||
|                                    InvenTreeMoneySerializer, RemoteImageMixin, | ||||
|                                    UserSerializer) | ||||
| from InvenTree.status_codes import BuildStatus | ||||
|  | ||||
| from .models import (BomItem, BomItemSubstitute, Part, PartAttachment, | ||||
|                      PartCategory, PartCategoryParameterTemplate, | ||||
|                      PartInternalPriceBreak, PartParameter, | ||||
|                      PartParameterTemplate, PartPricing, PartRelated, | ||||
|                      PartSellPriceBreak, PartStar, PartTestTemplate) | ||||
|                      PartSellPriceBreak, PartStar, PartStocktake, | ||||
|                      PartTestTemplate) | ||||
|  | ||||
|  | ||||
| class CategorySerializer(InvenTreeModelSerializer): | ||||
| @@ -451,6 +453,7 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer): | ||||
|             'IPN', | ||||
|             'is_template', | ||||
|             'keywords', | ||||
|             'last_stocktake', | ||||
|             'link', | ||||
|             'minimum_stock', | ||||
|             'name', | ||||
| @@ -504,6 +507,44 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer): | ||||
|         return self.instance | ||||
|  | ||||
|  | ||||
| class PartStocktakeSerializer(InvenTreeModelSerializer): | ||||
|     """Serializer for the PartStocktake model""" | ||||
|  | ||||
|     quantity = serializers.FloatField() | ||||
|  | ||||
|     user_detail = UserSerializer(source='user', read_only=True, many=False) | ||||
|  | ||||
|     class Meta: | ||||
|         """Metaclass options""" | ||||
|  | ||||
|         model = PartStocktake | ||||
|         fields = [ | ||||
|             'pk', | ||||
|             'date', | ||||
|             'part', | ||||
|             'quantity', | ||||
|             'note', | ||||
|             'user', | ||||
|             'user_detail', | ||||
|         ] | ||||
|  | ||||
|         read_only_fields = [ | ||||
|             'date', | ||||
|             'user', | ||||
|         ] | ||||
|  | ||||
|     def save(self): | ||||
|         """Called when this serializer is saved""" | ||||
|  | ||||
|         data = self.validated_data | ||||
|  | ||||
|         # Add in user information automatically | ||||
|         request = self.context['request'] | ||||
|         data['user'] = request.user | ||||
|  | ||||
|         super().save() | ||||
|  | ||||
|  | ||||
| class PartPricingSerializer(InvenTreeModelSerializer): | ||||
|     """Serializer for Part pricing information""" | ||||
|  | ||||
|   | ||||
| @@ -53,6 +53,29 @@ | ||||
| </div> | ||||
| {% endif %} | ||||
|  | ||||
| {% settings_value 'DISPLAY_STOCKTAKE_TAB' user=request.user as show_stocktake %} | ||||
| {% if show_stocktake %} | ||||
| <div class='panel panel-hidden' id='panel-stocktake'> | ||||
|     <div class='panel-heading'> | ||||
|         <div class='d-flex flex-wrap'> | ||||
|             <h4>{% trans "Part Stocktake" %}</h4> | ||||
|             {% include "spacer.html" %} | ||||
|             <div class='btn-group' role='group'> | ||||
|                 {% if roles.part.add %} | ||||
|                 <button class='btn btn-success' type='button' id='btn-stocktake' title='{% trans "Add stocktake information" %}'> | ||||
|                     <span class='fas fa-clipboard-check'></span> {% trans "Stocktake" %} | ||||
|                 </button> | ||||
|                 {% endif %} | ||||
|                 <!-- TODO: Buttons --> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
|     <div class='panel-content'> | ||||
|         {% include "part/part_stocktake.html" %} | ||||
|     </div> | ||||
| </div> | ||||
| {% endif %} | ||||
|  | ||||
| <div class='panel panel-hidden' id='panel-test-templates'> | ||||
|     <div class='panel-heading'> | ||||
|         <div class='d-flex flex-wrap'> | ||||
| @@ -423,7 +446,7 @@ | ||||
|             'part-notes', | ||||
|             '{% url "api-part-detail" part.pk %}', | ||||
|             { | ||||
|                 editable: {% if roles.part.change %}true{% else %}false{% endif %}, | ||||
|                 editable: {% js_bool roles.part.change %}, | ||||
|             } | ||||
|         ); | ||||
|     }); | ||||
| @@ -442,6 +465,23 @@ | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     // Load the "stocktake" tab | ||||
|     onPanelLoad('stocktake', function() { | ||||
|         loadPartStocktakeTable({{ part.pk }}, { | ||||
|             admin: {% js_bool user.is_staff %}, | ||||
|             allow_edit: {% js_bool roles.part.change %}, | ||||
|             allow_delete: {% js_bool roles.part.delete %}, | ||||
|         }); | ||||
|  | ||||
|         $('#btn-stocktake').click(function() { | ||||
|             performStocktake({{ part.pk }}, { | ||||
|                 onSuccess: function() { | ||||
|                     $('#part-stocktake-table').bootstrapTable('refresh'); | ||||
|                 } | ||||
|             }); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     // Load the "suppliers" tab | ||||
|     onPanelLoad('suppliers', function() { | ||||
|  | ||||
|   | ||||
| @@ -338,6 +338,20 @@ | ||||
|                 </tr> | ||||
|                 {% endif %} | ||||
|                 {% endwith %} | ||||
|                 {% with part.latest_stocktake as stocktake %} | ||||
|                 {% if stocktake %} | ||||
|                 <tr> | ||||
|                     <td><span class='fas fa-clipboard-check'></span></td> | ||||
|                     <td>{% trans "Last Stocktake" %}</td> | ||||
|                     <td> | ||||
|                         {% decimal stocktake.quantity %} <span class='fas fa-calendar-alt' title='{% render_date stocktake.date %}'></span> | ||||
|                         <span class='badge bg-dark rounded-pill float-right'> | ||||
|                             {{ stocktake.user.username }} | ||||
|                         </span> | ||||
|                     </td> | ||||
|                 </tr> | ||||
|                 {% endif %} | ||||
|                 {% endwith %} | ||||
|                 {% with part.get_latest_serial_number as sn %} | ||||
|                 {% if part.trackable and sn %} | ||||
|                 <tr> | ||||
|   | ||||
| @@ -44,6 +44,11 @@ | ||||
| {% trans "Scheduling" as text %} | ||||
| {% include "sidebar_item.html" with label="scheduling" text=text icon="fa-calendar-alt" %} | ||||
| {% endif %} | ||||
| {% settings_value 'DISPLAY_STOCKTAKE_TAB' user=request.user as show_stocktake %} | ||||
| {% if show_stocktake %} | ||||
| {% trans "Stocktake" as text %} | ||||
| {% include "sidebar_item.html" with label="stocktake" text=text icon="fa-clipboard-check" %} | ||||
| {% endif %} | ||||
| {% if part.trackable %} | ||||
| {% trans "Test Templates" as text %} | ||||
| {% include "sidebar_item.html" with label="test-templates" text=text icon="fa-vial" %} | ||||
|   | ||||
							
								
								
									
										10
									
								
								InvenTree/part/templates/part/part_stocktake.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								InvenTree/part/templates/part/part_stocktake.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| {% load i18n %} | ||||
| {% load inventree_extras %} | ||||
|  | ||||
| <div id='part-stocktake-toolbar'> | ||||
|     <div class='btn-group' role='group'> | ||||
|         {% include "filter_list.html" with id="partstocktake" %} | ||||
|     </div> | ||||
| </div> | ||||
| <table class='table table-condensed table-striped' id='part-stocktake-table' data-toolbar='#part-stocktake-toolbar'> | ||||
| </table> | ||||
| @@ -484,6 +484,16 @@ def primitive_to_javascript(primitive): | ||||
|         return format_html("'{}'", primitive)  # noqa: P103 | ||||
|  | ||||
|  | ||||
| @register.simple_tag() | ||||
| def js_bool(val): | ||||
|     """Return a javascript boolean value (true or false)""" | ||||
|  | ||||
|     if val: | ||||
|         return 'true' | ||||
|     else: | ||||
|         return 'false' | ||||
|  | ||||
|  | ||||
| @register.filter | ||||
| def keyvalue(dict, key): | ||||
|     """Access to key of supplied dict. | ||||
|   | ||||
| @@ -20,7 +20,7 @@ from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus, | ||||
|                                     StockStatus) | ||||
| from part.models import (BomItem, BomItemSubstitute, Part, PartCategory, | ||||
|                          PartCategoryParameterTemplate, PartParameterTemplate, | ||||
|                          PartRelated) | ||||
|                          PartRelated, PartStocktake) | ||||
| from stock.models import StockItem, StockLocation | ||||
|  | ||||
|  | ||||
| @@ -2779,3 +2779,141 @@ class PartInternalPriceBreakTest(InvenTreeAPITestCase): | ||||
|  | ||||
|         with self.assertRaises(Part.DoesNotExist): | ||||
|             p.refresh_from_db() | ||||
|  | ||||
|  | ||||
| class PartStocktakeTest(InvenTreeAPITestCase): | ||||
|     """Unit tests for the part stocktake functionality""" | ||||
|  | ||||
|     superuser = False | ||||
|     is_staff = False | ||||
|  | ||||
|     fixtures = [ | ||||
|         'category', | ||||
|         'part', | ||||
|         'location', | ||||
|     ] | ||||
|  | ||||
|     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 | ||||
|  | ||||
|         # Create some entries | ||||
|         for p in Part.objects.all(): | ||||
|  | ||||
|             for n in range(p.pk): | ||||
|                 PartStocktake.objects.create( | ||||
|                     part=p, | ||||
|                     quantity=(n + 1) * 100, | ||||
|                 ) | ||||
|  | ||||
|             total += p.pk | ||||
|  | ||||
|             response = self.get( | ||||
|                 url, | ||||
|                 { | ||||
|                     'part': p.pk, | ||||
|                 }, | ||||
|                 expected_code=200, | ||||
|             ) | ||||
|  | ||||
|             # List by part ID | ||||
|             self.assertEqual(len(response.data), p.pk) | ||||
|  | ||||
|         # 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('part.add') | ||||
|         self.assignRole('part.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=403) | ||||
|  | ||||
|         # Assign staff permission | ||||
|         self.user.is_staff = True | ||||
|         self.user.save() | ||||
|  | ||||
|         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('part.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('part.delete') | ||||
|  | ||||
|         self.delete(url, expected_code=204) | ||||
|   | ||||
| @@ -17,7 +17,8 @@ from InvenTree import version | ||||
| from InvenTree.helpers import InvenTreeTestCase | ||||
|  | ||||
| from .models import (Part, PartCategory, PartCategoryStar, PartRelated, | ||||
|                      PartStar, PartTestTemplate, rename_part_image) | ||||
|                      PartStar, PartStocktake, PartTestTemplate, | ||||
|                      rename_part_image) | ||||
| from .templatetags import inventree_extras | ||||
|  | ||||
|  | ||||
| @@ -338,6 +339,19 @@ class PartTest(TestCase): | ||||
|         self.r2.delete() | ||||
|         self.assertEqual(PartRelated.objects.count(), 0) | ||||
|  | ||||
|     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) | ||||
|  | ||||
|  | ||||
| class TestTemplateTest(TestCase): | ||||
|     """Unit test for the TestTemplate class""" | ||||
|   | ||||
| @@ -19,6 +19,7 @@ | ||||
|             {% include "InvenTree/settings/setting.html" with key="FORMS_CLOSE_USING_ESCAPE" icon="fa-window-close" user_setting=True %} | ||||
|             {% include "InvenTree/settings/setting.html" with key="PART_SHOW_QUANTITY_IN_FORMS" icon="fa-hashtag" user_setting=True %} | ||||
|             {% include "InvenTree/settings/setting.html" with key="DISPLAY_SCHEDULE_TAB" icon="fa-calendar-alt" user_setting=True %} | ||||
|             {% include "InvenTree/settings/setting.html" with key="DISPLAY_STOCKTAKE_TAB" icon="fa-clipboard-check" user_setting=True %} | ||||
|             {% include "InvenTree/settings/setting.html" with key="TABLE_STRING_MAX_LENGTH" icon="fa-table" user_setting=True %} | ||||
|         </tbody> | ||||
|     </table> | ||||
|   | ||||
| @@ -292,7 +292,7 @@ function loadAttachmentTable(url, options) { | ||||
|                     var html = renderDate(value); | ||||
|  | ||||
|                     if (row.user_detail) { | ||||
|                         html += `<span class='badge bg-dark rounded-pill float-right'>${row.user_detail.username}</div>`; | ||||
|                         html += `<span class='badge bg-dark rounded-pill float-right'>${row.user_detail.username}</span>`; | ||||
|                     } | ||||
|  | ||||
|                     return html; | ||||
|   | ||||
| @@ -343,6 +343,9 @@ function constructForm(url, options) { | ||||
|     // Request OPTIONS endpoint from the API | ||||
|     getApiEndpointOptions(url, function(OPTIONS) { | ||||
|  | ||||
|         // Copy across entire actions struct | ||||
|         options.actions = OPTIONS.actions.POST || OPTIONS.actions.PUT || OPTIONS.actions.PATCH || OPTIONS.actions.DELETE || {}; | ||||
|  | ||||
|         // Extract any custom 'context' information from the OPTIONS data | ||||
|         options.context = OPTIONS.context || {}; | ||||
|  | ||||
|   | ||||
| @@ -33,10 +33,13 @@ | ||||
|     loadPartTable, | ||||
|     loadPartTestTemplateTable, | ||||
|     loadPartSchedulingChart, | ||||
|     loadPartStocktakeTable, | ||||
|     loadPartVariantTable, | ||||
|     loadRelatedPartsTable, | ||||
|     loadSimplePartTable, | ||||
|     partDetail, | ||||
|     partStockLabel, | ||||
|     performStocktake, | ||||
|     toggleStar, | ||||
|     validateBom, | ||||
| */ | ||||
| @@ -678,13 +681,281 @@ function makePartIcons(part) { | ||||
|     } | ||||
|  | ||||
|     return html; | ||||
|  | ||||
| } | ||||
|  | ||||
|  | ||||
| function loadPartVariantTable(table, partId, options={}) { | ||||
| /* | ||||
|  * Render part information for a table view | ||||
|  * | ||||
|  * part: JSON part object | ||||
|  * options: | ||||
|  *  icons: Display part icons | ||||
|  *  thumb: Display part thumbnail | ||||
|  *  link: Display URL | ||||
|  */ | ||||
| function partDetail(part, options={}) { | ||||
|  | ||||
|     var html = ''; | ||||
|  | ||||
|     var name = part.full_name; | ||||
|  | ||||
|     if (options.thumb) { | ||||
|         html += imageHoverIcon(part.thumbnail || part.image); | ||||
|     } | ||||
|  | ||||
|     if (options.link) { | ||||
|         var url = `/part/${part.pk}/`; | ||||
|         html += renderLink(shortenString(name), url); | ||||
|     } else { | ||||
|         html += shortenString(name); | ||||
|     } | ||||
|  | ||||
|     if (options.icons) { | ||||
|         html += makePartIcons(part); | ||||
|     } | ||||
|  | ||||
|     return html; | ||||
| } | ||||
|  | ||||
|  | ||||
| /* | ||||
|  * Guide user through "stocktake" process | ||||
|  */ | ||||
| function performStocktake(partId, options={}) { | ||||
|  | ||||
|     var part_quantity = 0; | ||||
|  | ||||
|     var date_threshold = moment().subtract(30, 'days'); | ||||
|  | ||||
|     // Helper function for formatting a StockItem row | ||||
|     function buildStockItemRow(item) { | ||||
|  | ||||
|         var pk = item.pk; | ||||
|  | ||||
|         // Part detail | ||||
|         var part = partDetail(item.part_detail, { | ||||
|             thumb: true, | ||||
|         }); | ||||
|  | ||||
|         // Location detail | ||||
|         var location = locationDetail(item); | ||||
|  | ||||
|         // Quantity detail | ||||
|         var quantity = item.quantity; | ||||
|  | ||||
|         part_quantity += item.quantity; | ||||
|  | ||||
|         if (item.serial && item.quantity == 1) { | ||||
|             quantity = `{% trans "Serial" %}: ${item.serial}`; | ||||
|         } | ||||
|  | ||||
|         quantity += stockStatusDisplay(item.status, {classes: 'float-right'}); | ||||
|  | ||||
|         // Last update | ||||
|         var updated = item.stocktake_date || item.updated; | ||||
|  | ||||
|         var update_rendered = renderDate(updated); | ||||
|  | ||||
|         if (updated) { | ||||
|             if (moment(updated) < date_threshold) { | ||||
|                 update_rendered += `<div class='float-right' title='{% trans "Stock item has not been checked recently" %}'><span class='fas fa-calendar-alt icon-red'></span></div>`; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Actions | ||||
|         var actions = `<div class='btn-group float-right' role='group'>`; | ||||
|  | ||||
|         // TODO: Future work | ||||
|         // actions += makeIconButton('fa-check-circle icon-green', 'button-line-count', pk, '{% trans "Update item" %}'); | ||||
|         // actions += makeIconButton('fa-trash-alt icon-red', 'button-line-delete', pk, '{% trans "Delete item" %}'); | ||||
|  | ||||
|         actions += `</div>`; | ||||
|  | ||||
|         return ` | ||||
|         <tr> | ||||
|             <td id='part-${pk}'>${part}</td> | ||||
|             <td id='loc-${pk}'>${location}</td> | ||||
|             <td id='quantity-${pk}'>${quantity}</td> | ||||
|             <td id='updated-${pk}'>${update_rendered}</td> | ||||
|             <td id='actions-${pk}'>${actions}</td> | ||||
|         </tr>`; | ||||
|     } | ||||
|  | ||||
|     // First, load stock information for the part | ||||
|     inventreeGet( | ||||
|         '{% url "api-stock-list" %}', | ||||
|         { | ||||
|             part: partId, | ||||
|             in_stock: true, | ||||
|             location_detail: true, | ||||
|             part_detail: true, | ||||
|             include_variants: true, | ||||
|             ordering: '-stock', | ||||
|         }, | ||||
|         { | ||||
|             success: function(response) { | ||||
|                 var html = ''; | ||||
|  | ||||
|                 html += ` | ||||
|                 <table class='table table-striped table-condensed'> | ||||
|                     <thead> | ||||
|                         <tr> | ||||
|                             <th>{% trans "Stock Item" %}</th> | ||||
|                             <th>{% trans "Location" %}</th> | ||||
|                             <th>{% trans "Quantity" %}</th> | ||||
|                             <th>{% trans "Updated" %}</th> | ||||
|                             <th><!-- Actions --></th> | ||||
|                         </tr> | ||||
|                     </thead> | ||||
|                     <tbody> | ||||
|                 `; | ||||
|  | ||||
|                 response.forEach(function(item) { | ||||
|                     html += buildStockItemRow(item); | ||||
|                 }); | ||||
|  | ||||
|                 html += `</tbody></table>`; | ||||
|  | ||||
|                 constructForm(`/api/part/stocktake/`, { | ||||
|                     preFormContent: html, | ||||
|                     method: 'POST', | ||||
|                     title: '{% trans "Part Stocktake" %}', | ||||
|                     confirm: true, | ||||
|                     fields: { | ||||
|                         part: { | ||||
|                             value: partId, | ||||
|                             hidden: true, | ||||
|                         }, | ||||
|                         quantity: { | ||||
|                             value: part_quantity, | ||||
|                         }, | ||||
|                         note: {}, | ||||
|                     }, | ||||
|                     onSuccess: function(response) { | ||||
|                         handleFormSuccess(response, options); | ||||
|                     } | ||||
|                 }); | ||||
|             } | ||||
|         } | ||||
|     ); | ||||
| } | ||||
|  | ||||
|  | ||||
| /* | ||||
|  * Load table for part stocktake information | ||||
|  */ | ||||
| function loadPartStocktakeTable(partId, options={}) { | ||||
|  | ||||
|     var table = options.table || '#part-stocktake-table'; | ||||
|  | ||||
|     var params = options.params || {}; | ||||
|  | ||||
|     params.part = partId; | ||||
|  | ||||
|     var filters = loadTableFilters('stocktake'); | ||||
|  | ||||
|     for (var key in params) { | ||||
|         filters[key] = params[key]; | ||||
|     } | ||||
|  | ||||
|     setupFilterList('stocktake', $(table), '#filter-list-partstocktake'); | ||||
|  | ||||
|     $(table).inventreeTable({ | ||||
|         url: '{% url "api-part-stocktake-list" %}', | ||||
|         queryParams: filters, | ||||
|         name: 'partstocktake', | ||||
|         original: options.params, | ||||
|         showColumns: true, | ||||
|         sortable: true, | ||||
|         formatNoMatches: function() { | ||||
|             return '{% trans "No stocktake information available" %}'; | ||||
|         }, | ||||
|         columns: [ | ||||
|             { | ||||
|                 field: 'quantity', | ||||
|                 title: '{% trans "Quantity" %}', | ||||
|                 switchable: false, | ||||
|                 sortable: true, | ||||
|             }, | ||||
|             { | ||||
|                 field: 'note', | ||||
|                 title: '{% trans "Notes" %}', | ||||
|                 switchable: true, | ||||
|             }, | ||||
|             { | ||||
|                 field: 'date', | ||||
|                 title: '{% trans "Date" %}', | ||||
|                 switchable: false, | ||||
|                 sortable: true, | ||||
|                 formatter: function(value, row) { | ||||
|                     var html = renderDate(value); | ||||
|  | ||||
|                     if (row.user_detail) { | ||||
|                         html += `<span class='badge bg-dark rounded-pill float-right'>${row.user_detail.username}</span>`; | ||||
|                     } | ||||
|  | ||||
|                     return html; | ||||
|                 } | ||||
|             }, | ||||
|             { | ||||
|                 field: 'actions', | ||||
|                 title: '', | ||||
|                 visible: options.admin, | ||||
|                 switchable: false, | ||||
|                 sortable: false, | ||||
|                 formatter: function(value, row) { | ||||
|                     var html = `<div class='btn-group float-right' role='group'>`; | ||||
|  | ||||
|                     if (options.allow_edit) { | ||||
|                         html += makeIconButton('fa-edit icon-blue', 'button-edit-stocktake', row.pk, '{% trans "Edit Stocktake Entry" %}'); | ||||
|                     } | ||||
|  | ||||
|                     if (options.allow_delete) { | ||||
|                         html += makeIconButton('fa-trash-alt icon-red', 'button-delete-stocktake', row.pk, '{% trans "Delete Stocktake Entry" %}'); | ||||
|                     } | ||||
|  | ||||
|                     html += `</div>`; | ||||
|  | ||||
|                     return html; | ||||
|                 } | ||||
|             } | ||||
|         ], | ||||
|         onPostBody: function() { | ||||
|             // Button callbacks | ||||
|             $(table).find('.button-edit-stocktake').click(function() { | ||||
|                 var pk = $(this).attr('pk'); | ||||
|  | ||||
|                 constructForm(`/api/part/stocktake/${pk}/`, { | ||||
|                     fields: { | ||||
|                         quantity: {}, | ||||
|                         note: {}, | ||||
|                     }, | ||||
|                     title: '{% trans "Edit Stocktake Entry" %}', | ||||
|                     onSuccess: function() { | ||||
|                         $(table).bootstrapTable('refresh'); | ||||
|                     } | ||||
|                 }); | ||||
|             }); | ||||
|  | ||||
|             $(table).find('.button-delete-stocktake').click(function() { | ||||
|                 var pk = $(this).attr('pk'); | ||||
|  | ||||
|                 constructForm(`/api/part/stocktake/${pk}/`, { | ||||
|                     method: 'DELETE', | ||||
|                     title: '{% trans "Delete Stocktake Entry" %}', | ||||
|                     onSuccess: function() { | ||||
|                         $(table).bootstrapTable('refresh'); | ||||
|                     } | ||||
|                 }); | ||||
|             }); | ||||
|         } | ||||
|     }); | ||||
| } | ||||
|  | ||||
|  | ||||
| /* Load part variant table | ||||
|  */ | ||||
| function loadPartVariantTable(table, partId, options={}) { | ||||
|  | ||||
|     var params = options.params || {}; | ||||
|  | ||||
| @@ -1625,6 +1896,16 @@ function loadPartTable(table, url, options={}) { | ||||
|         } | ||||
|     }); | ||||
|  | ||||
|     columns.push({ | ||||
|         field: 'last_stocktake', | ||||
|         title: '{% trans "Last Stocktake" %}', | ||||
|         sortable: true, | ||||
|         switchable: true, | ||||
|         formatter: function(value) { | ||||
|             return renderDate(value); | ||||
|         } | ||||
|     }); | ||||
|  | ||||
|     // Push an "actions" column | ||||
|     if (options.actions) { | ||||
|         columns.push({ | ||||
|   | ||||
| @@ -19,7 +19,6 @@ | ||||
|     makeIconBadge, | ||||
|     makeIconButton, | ||||
|     makeOptionsList, | ||||
|     makePartIcons, | ||||
|     modalEnable, | ||||
|     modalSetContent, | ||||
|     modalSetTitle, | ||||
| @@ -1742,15 +1741,11 @@ function loadStockTable(table, options) { | ||||
|         switchable: params['part_detail'], | ||||
|         formatter: function(value, row) { | ||||
|  | ||||
|             var url = `/part/${row.part}/`; | ||||
|             var thumb = row.part_detail.thumbnail; | ||||
|             var name = row.part_detail.full_name; | ||||
|  | ||||
|             var html = imageHoverIcon(thumb) + renderLink(shortenString(name), url); | ||||
|  | ||||
|             html += makePartIcons(row.part_detail); | ||||
|  | ||||
|             return html; | ||||
|             return partDetail(row.part_detail, { | ||||
|                 thumb: true, | ||||
|                 link: true, | ||||
|                 icons: true, | ||||
|             }); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|   | ||||
| @@ -499,6 +499,10 @@ function getAvailableTableFilters(tableKey) { | ||||
|                 type: 'bool', | ||||
|                 title: '{% trans "Subscribed" %}', | ||||
|             }, | ||||
|             stocktake: { | ||||
|                 type: 'bool', | ||||
|                 title: '{% trans "Has stocktake entries" %}', | ||||
|             }, | ||||
|             is_template: { | ||||
|                 type: 'bool', | ||||
|                 title: '{% trans "Template" %}', | ||||
|   | ||||
| @@ -97,6 +97,7 @@ class RuleSet(models.Model): | ||||
|             'part_partrelated', | ||||
|             'part_partstar', | ||||
|             'part_partcategorystar', | ||||
|             'part_partstocktake', | ||||
|             'company_supplierpart', | ||||
|             'company_manufacturerpart', | ||||
|             'company_manufacturerpartparameter', | ||||
|   | ||||
		Reference in New Issue
	
	Block a user