mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +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:
parent
4ae278d119
commit
ab4e2aa8bb
@ -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;
|
||||
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* 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={}) {
|
||||
/* Load part variant table
|
||||
*/
|
||||
|
||||
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',
|
||||
|
Loading…
Reference in New Issue
Block a user