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:
Oliver 2022-12-31 23:14:43 +11:00 committed by GitHub
parent 4ae278d119
commit ab4e2aa8bb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 803 additions and 22 deletions

View File

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

View File

@ -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 %},
}
);
});

View File

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

View File

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

View File

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

View 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')),
],
),
]

View 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'),
),
]

View File

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

View File

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

View File

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

View File

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

View File

@ -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" %}

View 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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 || {};

View File

@ -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({

View File

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

View File

@ -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" %}',

View File

@ -97,6 +97,7 @@ class RuleSet(models.Model):
'part_partrelated',
'part_partstar',
'part_partcategorystar',
'part_partstocktake',
'company_supplierpart',
'company_manufacturerpart',
'company_manufacturerpartparameter',