Merge pull request #1202 from SchrodingersGat/stock-expiry

StockItem expiry date
This commit is contained in:
Oliver 2021-01-06 23:51:18 +11:00 committed by GitHub
commit 735a3d2eb2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 3024 additions and 1832 deletions

View File

@ -151,12 +151,17 @@ function enableField(fieldName, enabled, options={}) {
}
function clearField(fieldName, options={}) {
setFieldValue(fieldName, '', options);
}
function setFieldValue(fieldName, value, options={}) {
var modal = options.modal || '#modal-form';
var field = getFieldByName(modal, fieldName);
field.val("");
field.val(value);
}

View File

@ -27,6 +27,8 @@ from InvenTree.helpers import increment, getSetting, normalize
from InvenTree.validators import validate_build_order_reference
from InvenTree.models import InvenTreeAttachment
import common.models
import InvenTree.fields
from stock import models as StockModels
@ -819,6 +821,10 @@ class Build(MPTTModel):
location__in=[loc for loc in self.take_from.getUniqueChildren()]
)
# Exclude expired stock items
if not common.models.InvenTreeSetting.get_setting('STOCK_ALLOW_EXPIRED_BUILD'):
items = items.exclude(StockModels.StockItem.EXPIRED_FILTER)
return items
@property

View File

@ -28,7 +28,7 @@ class BuildSerializer(InvenTreeModelSerializer):
quantity = serializers.FloatField()
overdue = serializers.BooleanField()
overdue = serializers.BooleanField(required=False, read_only=True)
@staticmethod
def annotate_queryset(queryset):

View File

@ -903,6 +903,7 @@ class BuildItemCreate(AjaxCreateView):
if self.build and self.part:
available_items = self.build.availableStockItems(self.part, self.output)
form.fields['stock_item'].queryset = available_items
self.available_stock = form.fields['stock_item'].queryset.all()

View File

@ -160,6 +160,35 @@ class InvenTreeSetting(models.Model):
'validator': bool,
},
'STOCK_ENABLE_EXPIRY': {
'name': _('Stock Expiry'),
'description': _('Enable stock expiry functionality'),
'default': False,
'validator': bool,
},
'STOCK_ALLOW_EXPIRED_SALE': {
'name': _('Sell Expired Stock'),
'description': _('Allow sale of expired stock'),
'default': False,
'validator': bool,
},
'STOCK_STALE_DAYS': {
'name': _('Stock Stale Time'),
'description': _('Number of days stock items are considered stale before expiring'),
'default': 0,
'units': _('days'),
'validator': [int],
},
'STOCK_ALLOW_EXPIRED_BUILD': {
'name': _('Build Expired Stock'),
'description': _('Allow building with expired stock'),
'default': False,
'validator': bool,
},
'BUILDORDER_REFERENCE_PREFIX': {
'name': _('Build Order Reference Prefix'),
'description': _('Prefix value for build order reference'),
@ -359,6 +388,12 @@ class InvenTreeSetting(models.Model):
if setting.is_bool():
value = InvenTree.helpers.str2bool(value)
if setting.is_int():
try:
value = int(value)
except (ValueError, TypeError):
value = backup_value
else:
value = backup_value
@ -447,18 +482,26 @@ class InvenTreeSetting(models.Model):
return
# Check if a 'type' has been specified for this value
if type(validator) == type:
# Boolean validator
if validator == bool:
# Value must "look like" a boolean value
if InvenTree.helpers.is_bool(self.value):
# Coerce into either "True" or "False"
self.value = str(InvenTree.helpers.str2bool(self.value))
else:
raise ValidationError({
'value': _('Value must be a boolean value')
})
if validator == bool:
# Value must "look like" a boolean value
if InvenTree.helpers.is_bool(self.value):
# Coerce into either "True" or "False"
self.value = str(InvenTree.helpers.str2bool(self.value))
else:
raise ValidationError({
'value': _('Value must be a boolean value')
})
# Integer validator
if validator == int:
try:
# Coerce into an integer value
self.value = str(int(self.value))
except (ValueError, TypeError):
raise ValidationError({
'value': _('Value must be an integer value'),
})
def validate_unique(self, exclude=None):
""" Ensure that the key:value pair is unique.
@ -500,6 +543,35 @@ class InvenTreeSetting(models.Model):
return InvenTree.helpers.str2bool(self.value)
def is_int(self):
"""
Check if the setting is required to be an integer value:
"""
validator = InvenTreeSetting.get_setting_validator(self.key)
if validator == int:
return True
if type(validator) in [list, tuple]:
for v in validator:
if v == int:
return True
def as_int(self):
"""
Return the value of this setting converted to a boolean value.
If an error occurs, return the default value
"""
try:
value = int(self.value)
except (ValueError, TypeError):
value = self.default_value()
return value
class PriceBreak(models.Model):
"""

View File

@ -21,3 +21,11 @@ def currency_code_default():
code = 'USD'
return code
def stock_expiry_enabled():
"""
Returns True if the stock expiry feature is enabled
"""
return InvenTreeSetting.get_setting('STOCK_ENABLE_EXPIRY')

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -181,7 +181,7 @@ class SalesOrderSerializer(InvenTreeModelSerializer):
status_text = serializers.CharField(source='get_status_display', read_only=True)
overdue = serializers.BooleanField()
overdue = serializers.BooleanField(required=False, read_only=True)
class Meta:
model = SalesOrder

View File

@ -24,6 +24,8 @@ from company.models import Company, SupplierPart
from stock.models import StockItem, StockLocation
from part.models import Part
from common.models import InvenTreeSetting
from . import forms as order_forms
from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
@ -1359,7 +1361,8 @@ class SalesOrderAllocationCreate(AjaxCreateView):
try:
line = SalesOrderLineItem.objects.get(pk=line_id)
queryset = form.fields['item'].queryset
# Construct a queryset for allowable stock items
queryset = StockItem.objects.filter(StockItem.IN_STOCK_FILTER)
# Ensure the part reference matches
queryset = queryset.filter(part=line.part)
@ -1369,6 +1372,10 @@ class SalesOrderAllocationCreate(AjaxCreateView):
queryset = queryset.exclude(pk__in=allocated)
# Exclude stock items which have expired
if not InvenTreeSetting.get_setting('STOCK_ALLOW_EXPIRED_SALE'):
queryset = queryset.exclude(StockItem.EXPIRED_FILTER)
form.fields['item'].queryset = queryset
# Hide the 'line' field

View File

@ -74,6 +74,7 @@
level: 0
lft: 0
rght: 0
default_expiry: 10
- model: part.part
pk: 50
@ -134,6 +135,7 @@
fields:
name: 'Red chair'
variant_of: 10000
IPN: "R.CH"
trackable: true
category: 7
tree_id: 1

View File

@ -181,6 +181,7 @@ class EditPartForm(HelperForm):
'keywords': 'fa-key',
'link': 'fa-link',
'IPN': 'fa-hashtag',
'default_expiry': 'fa-stopwatch',
}
bom_copy = forms.BooleanField(required=False,
@ -228,6 +229,7 @@ class EditPartForm(HelperForm):
'link',
'default_location',
'default_supplier',
'default_expiry',
'units',
'minimum_stock',
'component',

View File

@ -0,0 +1,36 @@
# Generated by Django 3.0.7 on 2021-01-04 12:31
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('company', '0031_auto_20210103_2215'),
('part', '0060_merge_20201112_1722'),
]
operations = [
migrations.AddField(
model_name='part',
name='default_expiry',
field=models.PositiveIntegerField(default=0, help_text='Expiry time (in days) for stock items of this part', validators=[django.core.validators.MinValueValidator(0)], verbose_name='Default Expiry'),
),
migrations.AlterField(
model_name='part',
name='default_supplier',
field=models.ForeignKey(blank=True, help_text='Default supplier part', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_parts', to='company.SupplierPart', verbose_name='Default Supplier'),
),
migrations.AlterField(
model_name='part',
name='minimum_stock',
field=models.PositiveIntegerField(default=0, help_text='Minimum allowed stock level', validators=[django.core.validators.MinValueValidator(0)], verbose_name='Minimum Stock'),
),
migrations.AlterField(
model_name='part',
name='units',
field=models.CharField(blank=True, default='', help_text='Stock keeping units for this part', max_length=20, null=True, verbose_name='Units'),
),
]

View File

@ -0,0 +1,14 @@
# Generated by Django 3.0.7 on 2021-01-04 13:56
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('part', '0061_auto_20210104_2331'),
('part', '0061_auto_20210103_2313'),
]
operations = [
]

View File

@ -291,11 +291,12 @@ class Part(MPTTModel):
keywords: Optional keywords for improving part search results
IPN: Internal part number (optional)
revision: Part revision
is_template: If True, this part is a 'template' part and cannot be instantiated as a StockItem
is_template: If True, this part is a 'template' part
link: Link to an external page with more information about this part (e.g. internal Wiki)
image: Image of this part
default_location: Where the item is normally stored (may be null)
default_supplier: The default SupplierPart which should be used to procure and stock this part
default_expiry: The default expiry duration for any StockItem instances of this part
minimum_stock: Minimum preferred quantity to keep in stock
units: Units of measure for this part (default='pcs')
salable: Can this part be sold to customers?
@ -759,15 +760,34 @@ class Part(MPTTModel):
# Default to None if there are multiple suppliers to choose from
return None
default_supplier = models.ForeignKey(SupplierPart,
on_delete=models.SET_NULL,
blank=True, null=True,
help_text=_('Default supplier part'),
related_name='default_parts')
default_supplier = models.ForeignKey(
SupplierPart,
on_delete=models.SET_NULL,
blank=True, null=True,
verbose_name=_('Default Supplier'),
help_text=_('Default supplier part'),
related_name='default_parts'
)
minimum_stock = models.PositiveIntegerField(default=0, validators=[MinValueValidator(0)], help_text=_('Minimum allowed stock level'))
default_expiry = models.PositiveIntegerField(
default=0,
validators=[MinValueValidator(0)],
verbose_name=_('Default Expiry'),
help_text=_('Expiry time (in days) for stock items of this part'),
)
units = models.CharField(max_length=20, default="", blank=True, null=True, help_text=_('Stock keeping units for this part'))
minimum_stock = models.PositiveIntegerField(
default=0, validators=[MinValueValidator(0)],
verbose_name=_('Minimum Stock'),
help_text=_('Minimum allowed stock level')
)
units = models.CharField(
max_length=20, default="",
blank=True, null=True,
verbose_name=_('Units'),
help_text=_('Stock keeping units for this part')
)
assembly = models.BooleanField(
default=part_settings.part_assembly_default,

View File

@ -289,6 +289,7 @@ class PartSerializer(InvenTreeModelSerializer):
'component',
'description',
'default_location',
'default_expiry',
'full_name',
'image',
'in_stock',

View File

@ -109,6 +109,13 @@
<td>{{ part.minimum_stock }}</td>
</tr>
{% endif %}
{% if part.default_expiry > 0 %}
<tr>
<td><span class='fas fa-stopwatch'></span></td>
<td><b>{% trans "Stock Expiry Time" %}</b></td>
<td>{{ part.default_expiry }} {% trans "days" %}</td>
</tr>
{% endif %}
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td><b>{% trans "Creation Date" %}</b></td>

View File

@ -35,6 +35,8 @@ from .models import PartSellPriceBreak
from common.models import InvenTreeSetting
from company.models import SupplierPart
import common.settings as inventree_settings
from . import forms as part_forms
from .bom import MakeBomTemplate, BomUploadManager, ExportBom, IsValidBOMFormat
@ -626,6 +628,10 @@ class PartCreate(AjaxCreateView):
"""
form = super(AjaxCreateView, self).get_form()
# Hide the "default expiry" field if the feature is not enabled
if not inventree_settings.stock_expiry_enabled():
form.fields.pop('default_expiry')
# Hide the default_supplier field (there are no matching supplier parts yet!)
form.fields['default_supplier'].widget = HiddenInput()
@ -918,6 +924,10 @@ class PartEdit(AjaxUpdateView):
form = super(AjaxUpdateView, self).get_form()
# Hide the "default expiry" field if the feature is not enabled
if not inventree_settings.stock_expiry_enabled():
form.fields.pop('default_expiry')
part = self.get_object()
form.fields['default_supplier'].queryset = SupplierPart.objects.filter(part=part)

View File

@ -23,6 +23,9 @@ from part.serializers import PartBriefSerializer
from company.models import SupplierPart
from company.serializers import SupplierPartSerializer
import common.settings
import common.models
from .serializers import StockItemSerializer
from .serializers import LocationSerializer, LocationBriefSerializer
from .serializers import StockTrackingSerializer
@ -35,6 +38,8 @@ from InvenTree.api import AttachmentMixin
from decimal import Decimal, InvalidOperation
from datetime import datetime, timedelta
from rest_framework.serializers import ValidationError
from rest_framework.views import APIView
from rest_framework.response import Response
@ -342,10 +347,18 @@ class StockList(generics.ListCreateAPIView):
# A location was *not* specified - try to infer it
if 'location' not in request.data:
location = item.part.get_default_location()
if location is not None:
item.location = location
item.save()
# An expiry date was *not* specified - try to infer it!
if 'expiry_date' not in request.data:
if item.part.default_expiry > 0:
item.expiry_date = datetime.now().date() + timedelta(days=item.part.default_expiry)
item.save()
# Return a response
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
@ -525,6 +538,38 @@ class StockList(generics.ListCreateAPIView):
# Exclude items which are instaled in another item
queryset = queryset.filter(belongs_to=None)
if common.settings.stock_expiry_enabled():
# Filter by 'expired' status
expired = params.get('expired', None)
if expired is not None:
expired = str2bool(expired)
if expired:
queryset = queryset.filter(StockItem.EXPIRED_FILTER)
else:
queryset = queryset.exclude(StockItem.EXPIRED_FILTER)
# Filter by 'stale' status
stale = params.get('stale', None)
if stale is not None:
stale = str2bool(stale)
# How many days to account for "staleness"?
stale_days = common.models.InvenTreeSetting.get_setting('STOCK_STALE_DAYS')
if stale_days > 0:
stale_date = datetime.now().date() + timedelta(days=stale_days)
stale_filter = StockItem.IN_STOCK_FILTER & ~Q(expiry_date=None) & Q(expiry_date__lt=stale_date)
if stale:
queryset = queryset.filter(stale_filter)
else:
queryset = queryset.exclude(stale_filter)
# Filter by customer
customer = params.get('customer', None)

View File

@ -69,7 +69,7 @@
part: 25
batch: 'ABCDE'
location: 7
quantity: 3
quantity: 0
level: 0
tree_id: 0
lft: 0
@ -220,6 +220,7 @@
tree_id: 0
lft: 0
rght: 0
expiry_date: "1990-10-10"
- model: stock.stockitem
pk: 521
@ -232,6 +233,7 @@
tree_id: 0
lft: 0
rght: 0
status: 60
- model: stock.stockitem
pk: 522
@ -243,4 +245,6 @@
level: 0
tree_id: 0
lft: 0
rght: 0
rght: 0
expiry_date: "1990-10-10"
status: 70

View File

@ -16,6 +16,7 @@ from mptt.fields import TreeNodeChoiceField
from InvenTree.helpers import GetExportFormats
from InvenTree.forms import HelperForm
from InvenTree.fields import RoundingDecimalFormField
from InvenTree.fields import DatePickerFormField
from report.models import TestReport
@ -108,6 +109,10 @@ class ConvertStockItemForm(HelperForm):
class CreateStockItemForm(HelperForm):
""" Form for creating a new StockItem """
expiry_date = DatePickerFormField(
help_text=('Expiration date for this stock item'),
)
serial_numbers = forms.CharField(label=_('Serial numbers'), required=False, help_text=_('Enter unique serial numbers (or leave blank)'))
def __init__(self, *args, **kwargs):
@ -129,6 +134,7 @@ class CreateStockItemForm(HelperForm):
'batch',
'serial_numbers',
'purchase_price',
'expiry_date',
'link',
'delete_on_deplete',
'status',
@ -392,6 +398,10 @@ class EditStockItemForm(HelperForm):
part - Cannot be edited after creation
"""
expiry_date = DatePickerFormField(
help_text=('Expiration date for this stock item'),
)
class Meta:
model = StockItem
@ -400,6 +410,7 @@ class EditStockItemForm(HelperForm):
'serial',
'batch',
'status',
'expiry_date',
'purchase_price',
'link',
'delete_on_deplete',

View File

@ -0,0 +1,18 @@
# Generated by Django 3.0.7 on 2021-01-03 12:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stock', '0055_auto_20201117_1453'),
]
operations = [
migrations.AddField(
model_name='stockitem',
name='expiry_date',
field=models.DateField(blank=True, help_text='Expiry date for stock item. Stock will be considered expired after this date', null=True, verbose_name='Expiry Date'),
),
]

View File

@ -27,9 +27,11 @@ from mptt.models import MPTTModel, TreeForeignKey
from djmoney.models.fields import MoneyField
from decimal import Decimal, InvalidOperation
from datetime import datetime
from datetime import datetime, timedelta
from InvenTree import helpers
import common.models
from InvenTree.status_codes import StockStatus
from InvenTree.models import InvenTreeTree, InvenTreeAttachment
from InvenTree.fields import InvenTreeURLField
@ -125,6 +127,7 @@ class StockItem(MPTTModel):
serial: Unique serial number for this StockItem
link: Optional URL to link to external resource
updated: Date that this stock item was last updated (auto)
expiry_date: Expiry date of the StockItem (optional)
stocktake_date: Date of last stocktake for this item
stocktake_user: User that performed the most recent stocktake
review_needed: Flag if StockItem needs review
@ -149,6 +152,9 @@ class StockItem(MPTTModel):
status__in=StockStatus.AVAILABLE_CODES
)
# A query filter which can be used to filter StockItem objects which have expired
EXPIRED_FILTER = IN_STOCK_FILTER & ~Q(expiry_date=None) & Q(expiry_date__lt=datetime.now().date())
def save(self, *args, **kwargs):
"""
Save this StockItem to the database. Performs a number of checks:
@ -428,11 +434,19 @@ class StockItem(MPTTModel):
related_name='stock_items',
null=True, blank=True)
# last time the stock was checked / counted
expiry_date = models.DateField(
blank=True, null=True,
verbose_name=_('Expiry Date'),
help_text=_('Expiry date for stock item. Stock will be considered expired after this date'),
)
stocktake_date = models.DateField(blank=True, null=True)
stocktake_user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True,
related_name='stocktake_stock')
stocktake_user = models.ForeignKey(
User, on_delete=models.SET_NULL,
blank=True, null=True,
related_name='stocktake_stock'
)
review_needed = models.BooleanField(default=False)
@ -459,6 +473,55 @@ class StockItem(MPTTModel):
help_text=_('Single unit purchase price at time of purchase'),
)
def is_stale(self):
"""
Returns True if this Stock item is "stale".
To be "stale", the following conditions must be met:
- Expiry date is not None
- Expiry date will "expire" within the configured stale date
- The StockItem is otherwise "in stock"
"""
if self.expiry_date is None:
return False
if not self.in_stock:
return False
today = datetime.now().date()
stale_days = common.models.InvenTreeSetting.get_setting('STOCK_STALE_DAYS')
if stale_days <= 0:
return False
expiry_date = today + timedelta(days=stale_days)
return self.expiry_date < expiry_date
def is_expired(self):
"""
Returns True if this StockItem is "expired".
To be "expired", the following conditions must be met:
- Expiry date is not None
- Expiry date is "in the past"
- The StockItem is otherwise "in stock"
"""
if self.expiry_date is None:
return False
if not self.in_stock:
return False
today = datetime.now().date()
return self.expiry_date < today
def clearAllocations(self):
"""
Clear all order allocations for this StockItem:
@ -721,36 +784,16 @@ class StockItem(MPTTModel):
@property
def in_stock(self):
"""
Returns True if this item is in stock
Returns True if this item is in stock.
See also: IN_STOCK_FILTER
"""
# Quantity must be above zero (unless infinite)
if self.quantity <= 0 and not self.infinite:
return False
query = StockItem.objects.filter(pk=self.pk)
# Not 'in stock' if it has been installed inside another StockItem
if self.belongs_to is not None:
return False
# Not 'in stock' if it has been sent to a customer
if self.sales_order is not None:
return False
query = query.filter(StockItem.IN_STOCK_FILTER)
# Not 'in stock' if it has been assigned to a customer
if self.customer is not None:
return False
# Not 'in stock' if it is building
if self.is_building:
return False
# Not 'in stock' if the status code makes it unavailable
if self.status in StockStatus.UNAVAILABLE_CODES:
return False
return True
return query.exists()
@property
def tracking_info_count(self):

View File

@ -11,10 +11,17 @@ from .models import StockItemTestResult
from django.db.models.functions import Coalesce
from django.db.models import Case, When, Value
from django.db.models import BooleanField
from django.db.models import Q
from sql_util.utils import SubquerySum, SubqueryCount
from decimal import Decimal
from datetime import datetime, timedelta
import common.models
from company.serializers import SupplierPartSerializer
from part.serializers import PartBriefSerializer
from InvenTree.serializers import UserSerializerBrief, InvenTreeModelSerializer
@ -106,6 +113,30 @@ class StockItemSerializer(InvenTreeModelSerializer):
tracking_items=SubqueryCount('tracking_info')
)
# Add flag to indicate if the StockItem has expired
queryset = queryset.annotate(
expired=Case(
When(
StockItem.EXPIRED_FILTER, then=Value(True, output_field=BooleanField()),
),
default=Value(False, output_field=BooleanField())
)
)
# Add flag to indicate if the StockItem is stale
stale_days = common.models.InvenTreeSetting.get_setting('STOCK_STALE_DAYS')
stale_date = datetime.now().date() + timedelta(days=stale_days)
stale_filter = StockItem.IN_STOCK_FILTER & ~Q(expiry_date=None) & Q(expiry_date__lt=stale_date)
queryset = queryset.annotate(
stale=Case(
When(
stale_filter, then=Value(True, output_field=BooleanField()),
),
default=Value(False, output_field=BooleanField()),
)
)
return queryset
status_text = serializers.CharField(source='get_status_display', read_only=True)
@ -122,6 +153,10 @@ class StockItemSerializer(InvenTreeModelSerializer):
allocated = serializers.FloatField(source='allocation_count', required=False)
expired = serializers.BooleanField(required=False, read_only=True)
stale = serializers.BooleanField(required=False, read_only=True)
serial = serializers.CharField(required=False)
required_tests = serializers.IntegerField(source='required_test_count', read_only=True, required=False)
@ -155,6 +190,8 @@ class StockItemSerializer(InvenTreeModelSerializer):
'belongs_to',
'build',
'customer',
'expired',
'expiry_date',
'in_stock',
'is_building',
'link',
@ -168,6 +205,7 @@ class StockItemSerializer(InvenTreeModelSerializer):
'required_tests',
'sales_order',
'serial',
'stale',
'status',
'status_text',
'supplier_part',

View File

@ -70,7 +70,14 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
{% block page_data %}
<h3>
{% trans "Stock Item" %}
{% if item.is_expired %}
<span class='label label-large label-large-red'>{% trans "Expired" %}</span>
{% else %}
{% stock_status_label item.status large=True %}
{% if item.is_stale %}
<span class='label label-large label-large-yellow'>{% trans "Stale" %}</span>
{% endif %}
{% endif %}
</h3>
<hr>
<h4>
@ -293,6 +300,20 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
<td><a href="{% url 'supplier-part-detail' item.supplier_part.id %}">{{ item.supplier_part.SKU }}</a></td>
</tr>
{% endif %}
{% if item.expiry_date %}
<tr>
<td><span class='fas fa-calendar-alt{% if item.is_expired %} icon-red{% endif %}'></span></td>
<td>{% trans "Expiry Date" %}</td>
<td>
{{ item.expiry_date }}
{% if item.is_expired %}
<span title='{% trans "This StockItem expired on" %} {{ item.expiry_date }}' class='label label-red'>{% trans "Expired" %}</span>
{% elif item.is_stale %}
<span title='{% trans "This StockItem expires on" %} {{ item.expiry_date }}' class='label label-yellow'>{% trans "Stale" %}</span>
{% endif %}
</td>
</tr>
{% endif %}
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Last Updated" %}</td>

View File

@ -1,11 +1,23 @@
"""
Unit testing for the Stock API
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from datetime import datetime, timedelta
from rest_framework.test import APITestCase
from rest_framework import status
from django.urls import reverse
from django.contrib.auth import get_user_model
from InvenTree.helpers import addUserPermissions
from InvenTree.status_codes import StockStatus
from .models import StockLocation
from common.models import InvenTreeSetting
from .models import StockItem, StockLocation
class StockAPITestCase(APITestCase):
@ -26,6 +38,9 @@ class StockAPITestCase(APITestCase):
self.user = user.objects.create_user('testuser', 'test@testing.com', 'password')
self.user.is_staff = True
self.user.save()
# Add the necessary permissions to the user
perms = [
'view_stockitemtestresult',
@ -76,6 +91,177 @@ class StockLocationTest(StockAPITestCase):
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
class StockItemListTest(StockAPITestCase):
"""
Tests for the StockItem API LIST endpoint
"""
list_url = reverse('api-stock-list')
def get_stock(self, **kwargs):
"""
Filter stock and return JSON object
"""
response = self.client.get(self.list_url, format='json', data=kwargs)
self.assertEqual(response.status_code, status.HTTP_200_OK)
# Return JSON-ified data
return response.data
def test_get_stock_list(self):
"""
List *all* StockItem objects.
"""
response = self.get_stock()
self.assertEqual(len(response), 19)
def test_filter_by_part(self):
"""
Filter StockItem by Part reference
"""
response = self.get_stock(part=25)
self.assertEqual(len(response), 7)
response = self.get_stock(part=10004)
self.assertEqual(len(response), 12)
def test_filter_by_IPN(self):
"""
Filter StockItem by IPN reference
"""
response = self.get_stock(IPN="R.CH")
self.assertEqual(len(response), 3)
def test_filter_by_location(self):
"""
Filter StockItem by StockLocation reference
"""
response = self.get_stock(location=5)
self.assertEqual(len(response), 1)
response = self.get_stock(location=1, cascade=0)
self.assertEqual(len(response), 0)
response = self.get_stock(location=1, cascade=1)
self.assertEqual(len(response), 2)
response = self.get_stock(location=7)
self.assertEqual(len(response), 16)
def test_filter_by_depleted(self):
"""
Filter StockItem by depleted status
"""
response = self.get_stock(depleted=1)
self.assertEqual(len(response), 1)
response = self.get_stock(depleted=0)
self.assertEqual(len(response), 18)
def test_filter_by_in_stock(self):
"""
Filter StockItem by 'in stock' status
"""
response = self.get_stock(in_stock=1)
self.assertEqual(len(response), 16)
response = self.get_stock(in_stock=0)
self.assertEqual(len(response), 3)
def test_filter_by_status(self):
"""
Filter StockItem by 'status' field
"""
codes = {
StockStatus.OK: 17,
StockStatus.DESTROYED: 1,
StockStatus.LOST: 1,
StockStatus.DAMAGED: 0,
StockStatus.REJECTED: 0,
}
for code in codes.keys():
num = codes[code]
response = self.get_stock(status=code)
self.assertEqual(len(response), num)
def test_filter_by_batch(self):
"""
Filter StockItem by batch code
"""
response = self.get_stock(batch='B123')
self.assertEqual(len(response), 1)
def test_filter_by_serialized(self):
"""
Filter StockItem by serialized status
"""
response = self.get_stock(serialized=1)
self.assertEqual(len(response), 12)
for item in response:
self.assertIsNotNone(item['serial'])
response = self.get_stock(serialized=0)
self.assertEqual(len(response), 7)
for item in response:
self.assertIsNone(item['serial'])
def test_filter_by_expired(self):
"""
Filter StockItem by expiry status
"""
# First, we can assume that the 'stock expiry' feature is disabled
response = self.get_stock(expired=1)
self.assertEqual(len(response), 19)
# Now, ensure that the expiry date feature is enabled!
InvenTreeSetting.set_setting('STOCK_ENABLE_EXPIRY', True, self.user)
response = self.get_stock(expired=1)
self.assertEqual(len(response), 1)
for item in response:
self.assertTrue(item['expired'])
response = self.get_stock(expired=0)
self.assertEqual(len(response), 18)
for item in response:
self.assertFalse(item['expired'])
# Mark some other stock items as expired
today = datetime.now().date()
for pk in [510, 511, 512]:
item = StockItem.objects.get(pk=pk)
item.expiry_date = today - timedelta(days=pk)
item.save()
response = self.get_stock(expired=1)
self.assertEqual(len(response), 4)
response = self.get_stock(expired=0)
self.assertEqual(len(response), 15)
class StockItemTest(StockAPITestCase):
"""
Series of API tests for the StockItem API
@ -94,10 +280,6 @@ class StockItemTest(StockAPITestCase):
StockLocation.objects.create(name='B', description='location b', parent=top)
StockLocation.objects.create(name='C', description='location c', parent=top)
def test_get_stock_list(self):
response = self.client.get(self.list_url, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_create_default_location(self):
"""
Test the default location functionality,
@ -198,6 +380,56 @@ class StockItemTest(StockAPITestCase):
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
def test_default_expiry(self):
"""
Test that the "default_expiry" functionality works via the API.
- If an expiry_date is specified, use that
- Otherwise, check if the referenced part has a default_expiry defined
- If so, use that!
- Otherwise, no expiry
Notes:
- Part <25> has a default_expiry of 10 days
"""
# First test - create a new StockItem without an expiry date
data = {
'part': 4,
'quantity': 10,
}
response = self.client.post(self.list_url, data)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertIsNone(response.data['expiry_date'])
# Second test - create a new StockItem with an explicit expiry date
data['expiry_date'] = '2022-12-12'
response = self.client.post(self.list_url, data)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertIsNotNone(response.data['expiry_date'])
self.assertEqual(response.data['expiry_date'], '2022-12-12')
# Third test - create a new StockItem for a Part which has a default expiry time
data = {
'part': 25,
'quantity': 10
}
response = self.client.post(self.list_url, data)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
# Expected expiry date is 10 days in the future
expiry = datetime.now().date() + timedelta(10)
self.assertEqual(response.data['expiry_date'], expiry.isoformat())
class StocktakeTest(StockAPITestCase):
"""

View File

@ -5,7 +5,10 @@ from django.urls import reverse
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from common.models import InvenTreeSetting
import json
from datetime import datetime, timedelta
class StockViewTestCase(TestCase):
@ -31,6 +34,9 @@ class StockViewTestCase(TestCase):
password='password'
)
self.user.is_staff = True
self.user.save()
# Put the user into a group with the correct permissions
group = Group.objects.create(name='mygroup')
self.user.groups.add(group)
@ -135,21 +141,56 @@ class StockItemTest(StockViewTestCase):
self.assertEqual(response.status_code, 200)
def test_create_item(self):
# Test creation of StockItem
response = self.client.get(reverse('stock-item-create'), {'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
"""
Test creation of StockItem
"""
url = reverse('stock-item-create')
response = self.client.get(url, {'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse('stock-item-create'), {'part': 999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
response = self.client.get(url, {'part': 999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# Copy from a valid item, valid location
response = self.client.get(reverse('stock-item-create'), {'location': 1, 'copy': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
response = self.client.get(url, {'location': 1, 'copy': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# Copy from an invalid item, invalid location
response = self.client.get(reverse('stock-item-create'), {'location': 999, 'copy': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
response = self.client.get(url, {'location': 999, 'copy': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
def test_create_stock_with_expiry(self):
"""
Test creation of stock item of a part with an expiry date.
The initial value for the "expiry_date" field should be pre-filled,
and should be in the future!
"""
# First, ensure that the expiry date feature is enabled!
InvenTreeSetting.set_setting('STOCK_ENABLE_EXPIRY', True, self.user)
url = reverse('stock-item-create')
response = self.client.get(url, {'part': 25}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# We are expecting 10 days in the future
expiry = datetime.now().date() + timedelta(10)
expected = f'name=\\\\"expiry_date\\\\" value=\\\\"{expiry.isoformat()}\\\\"'
self.assertIn(expected, str(response.content))
# Now check with a part which does *not* have a default expiry period
response = self.client.get(url, {'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
expected = 'name=\\\\"expiry_date\\\\" placeholder=\\\\"\\\\"'
self.assertIn(expected, str(response.content))
def test_serialize_item(self):
# Test the serialization view

View File

@ -49,6 +49,40 @@ class StockTest(TestCase):
Part.objects.rebuild()
StockItem.objects.rebuild()
def test_expiry(self):
"""
Test expiry date functionality for StockItem model.
"""
today = datetime.datetime.now().date()
item = StockItem.objects.create(
location=self.office,
part=Part.objects.get(pk=1),
quantity=10,
)
# Without an expiry_date set, item should not be "expired"
self.assertFalse(item.is_expired())
# Set the expiry date to today
item.expiry_date = today
item.save()
self.assertFalse(item.is_expired())
# Set the expiry date in the future
item.expiry_date = today + datetime.timedelta(days=5)
item.save()
self.assertFalse(item.is_expired())
# Set the expiry date in the past
item.expiry_date = today - datetime.timedelta(days=5)
item.save()
self.assertTrue(item.is_expired())
def test_is_building(self):
"""
Test that the is_building flag does not count towards stock.
@ -143,8 +177,10 @@ class StockTest(TestCase):
# There should be 9000 screws in stock
self.assertEqual(part.total_stock, 9000)
# There should be 18 widgets in stock
self.assertEqual(StockItem.objects.filter(part=25).aggregate(Sum('quantity'))['quantity__sum'], 19)
# There should be 16 widgets "in stock"
self.assertEqual(
StockItem.objects.filter(part=25).aggregate(Sum('quantity'))['quantity__sum'], 16
)
def test_delete_location(self):

View File

@ -26,7 +26,7 @@ from InvenTree.helpers import str2bool, DownloadFile, GetExportFormats
from InvenTree.helpers import extract_serial_numbers
from decimal import Decimal, InvalidOperation
from datetime import datetime
from datetime import datetime, timedelta
from company.models import Company, SupplierPart
from part.models import Part
@ -1302,6 +1302,10 @@ class StockItemEdit(AjaxUpdateView):
form = super(AjaxUpdateView, self).get_form()
# Hide the "expiry date" field if the feature is not enabled
if not common.settings.stock_expiry_enabled():
form.fields.pop('expiry_date')
item = self.get_object()
# If the part cannot be purchased, hide the supplier_part field
@ -1513,6 +1517,10 @@ class StockItemCreate(AjaxCreateView):
form = super().get_form()
# Hide the "expiry date" field if the feature is not enabled
if not common.settings.stock_expiry_enabled():
form.fields.pop('expiry_date')
part = self.get_part(form=form)
if part is not None:
@ -1596,6 +1604,11 @@ class StockItemCreate(AjaxCreateView):
initials['location'] = part.get_default_location()
initials['supplier_part'] = part.default_supplier
# If the part has a defined expiry period, extrapolate!
if part.default_expiry > 0:
expiry_date = datetime.now().date() + timedelta(days=part.default_expiry)
initials['expiry_date'] = expiry_date
currency_code = common.settings.currency_code_default()
# SupplierPart field has been specified

View File

@ -0,0 +1,15 @@
{% extends "collapse_index.html" %}
{% load i18n %}
{% block collapse_title %}
<span class='fas fa-calendar-times icon-header'></span>
{% trans "Expired Stock" %}<span class='badge' id='expired-stock-count'><span class='fas fa-spin fa-spinner'></span></span>
{% endblock %}
{% block collapse_content %}
<table class='table table-striped table-condensed' id='expired-stock-table'>
</table>
{% endblock %}

View File

@ -1,5 +1,6 @@
{% extends "base.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block page_title %}
InvenTree | {% trans "Index" %}
{% endblock %}
@ -8,7 +9,7 @@ InvenTree | {% trans "Index" %}
<h3>InvenTree</h3>
<hr>
<div class='col-sm-6'>
<div class='col-sm-4'>
{% if roles.part.view %}
{% include "InvenTree/latest_parts.html" with collapse_id="latest_parts" %}
{% include "InvenTree/bom_invalid.html" with collapse_id="bom_invalid" %}
@ -19,11 +20,18 @@ InvenTree | {% trans "Index" %}
{% include "InvenTree/build_overdue.html" with collapse_id="build_overdue" %}
{% endif %}
</div>
<div class='col-sm-6'>
<div class='col-sm-4'>
{% if roles.stock.view %}
{% include "InvenTree/low_stock.html" with collapse_id="order" %}
{% settings_value "STOCK_ENABLE_EXPIRY" as expiry %}
{% if expiry %}
{% include "InvenTree/expired_stock.html" with collapse_id="expired" %}
{% include "InvenTree/stale_stock.html" with collapse_id="stale" %}
{% endif %}
{% include "InvenTree/required_stock_build.html" with collapse_id="stock_to_build" %}
{% endif %}
</div>
<div class='col-sm-4'>
{% if roles.purchase_order.view %}
{% include "InvenTree/po_outstanding.html" with collapse_id="po_outstanding" %}
{% endif %}
@ -83,6 +91,23 @@ loadBuildTable("#build-overdue-table", {
disableFilters: true,
});
loadStockTable($("#expired-stock-table"), {
params: {
expired: true,
location_detail: true,
part_detail: true,
},
});
loadStockTable($("#stale-stock-table"), {
params: {
stale: true,
expired: false,
location_detail: true,
part_detail: true,
},
});
loadSimplePartTable("#low-stock-table", "{% url 'api-part-list' %}", {
params: {
low_stock: true,
@ -121,64 +146,19 @@ loadSalesOrderTable("#so-overdue-table", {
}
});
$("#latest-parts-table").on('load-success.bs.table', function() {
var count = $("#latest-parts-table").bootstrapTable('getData').length;
{% include "InvenTree/index/on_load.html" with label="latest-parts" %}
{% include "InvenTree/index/on_load.html" with label="starred-parts" %}
{% include "InvenTree/index/on_load.html" with label="bom-invalid" %}
{% include "InvenTree/index/on_load.html" with label="build-pending" %}
{% include "InvenTree/index/on_load.html" with label="build-overdue" %}
$("#latest-parts-count").html(count);
});
{% include "InvenTree/index/on_load.html" with label="expired-stock" %}
{% include "InvenTree/index/on_load.html" with label="stale-stock" %}
{% include "InvenTree/index/on_load.html" with label="low-stock" %}
{% include "InvenTree/index/on_load.html" with label="stock-to-build" %}
$("#starred-parts-table").on('load-success.bs.table', function() {
var count = $("#starred-parts-table").bootstrapTable('getData').length;
$("#starred-parts-count").html(count);
});
$("#bom-invalid-table").on('load-success.bs.table', function() {
var count = $("#bom-invalid-table").bootstrapTable('getData').length;
$("#bom-invalid-count").html(count);
});
$("#build-pending-table").on('load-success.bs.table', function() {
var count = $("#build-pending-table").bootstrapTable('getData').length;
$("#build-pending-count").html(count);
});
$("#build-overdue-table").on('load-success.bs.table', function() {
var count = $("#build-overdue-table").bootstrapTable('getData').length;
$("#build-overdue-count").html(count);
});
$("#low-stock-table").on('load-success.bs.table', function() {
var count = $("#low-stock-table").bootstrapTable('getData').length;
$("#low-stock-count").html(count);
});
$("#stock-to-build-table").on('load-success.bs.table', function() {
var count = $("#stock-to-build-table").bootstrapTable('getData').length;
$("#stock-to-build-count").html(count);
});
$("#po-outstanding-table").on('load-success.bs.table', function() {
var count = $("#po-outstanding-table").bootstrapTable('getData').length;
$("#po-outstanding-count").html(count);
});
$("#so-outstanding-table").on('load-success.bs.table', function() {
var count = $("#so-outstanding-table").bootstrapTable('getData').length;
$("#so-outstanding-count").html(count);
});
$("#so-overdue-table").on('load-success.bs.table', function() {
var count = $("#so-overdue-table").bootstrapTable('getData').length;
$("#so-overdue-count").html(count);
});
{% include "InvenTree/index/on_load.html" with label="po-outstanding" %}
{% include "InvenTree/index/on_load.html" with label="so-outstanding" %}
{% include "InvenTree/index/on_load.html" with label="so-overdue" %}
{% endblock %}

View File

@ -0,0 +1,5 @@
$("#{{ label }}-table").on('load-success.bs.table', function() {
var count = $("#{{ label }}-table").bootstrapTable('getData').length;
$("#{{ label }}-count").html(count);
});

View File

@ -17,7 +17,7 @@
{% else %}
{% if setting.value %}
<i><b>
{{ setting.value }}</b>{{ setting.units }}
{{ setting.value }}</b> {{ setting.units }}
</i>
{% else %}
<i>{% trans "No value set" %}</i>

View File

@ -10,7 +10,15 @@
{% endblock %}
{% block settings %}
<div class='alert alert-block alert-info'>
<i>No Stock settings available</i>
</div>
<h4>{% trans "Stock Options" %}</h4>
<table class='table table-striped table-condensed'>
{% include "InvenTree/settings/header.html" %}
<tbody>
{% include "InvenTree/settings/setting.html" with key="STOCK_ENABLE_EXPIRY" %}
{% include "InvenTree/settings/setting.html" with key="STOCK_STALE_DAYS" %}
{% include "InvenTree/settings/setting.html" with key="STOCK_ALLOW_EXPIRED_SALE" %}
{% include "InvenTree/settings/setting.html" with key="STOCK_ALLOW_EXPIRED_BUILD" %}
</tbody>
</table>
{% endblock %}

View File

@ -0,0 +1,15 @@
{% extends "collapse_index.html" %}
{% load i18n %}
{% block collapse_title %}
<span class='fas fa-stopwatch icon-header'></span>
{% trans "Stale Stock" %}<span class='badge' id='stale-stock-count'><span class='fas fa-spin fa-spinner'></span></span>
{% endblock %}
{% block collapse_content %}
<table class='table table-striped table-condensed' id='stale-stock-table'>
</table>
{% endblock %}

View File

@ -1,6 +1,6 @@
{% block collapse_preamble %}
{% endblock %}
<div class='panel-group'>
<div class='panel-group panel-index'>
<div class='panel panel-default'>
<div {% block collapse_panel_setup %}class='panel panel-heading'{% endblock %}>
<div class='panel-title'>

View File

@ -1,4 +1,5 @@
{% load i18n %}
{% load inventree_extras %}
{% load status_codes %}
/* Stock API functions
@ -532,6 +533,12 @@ function loadStockTable(table, options) {
html += makeIconBadge('fa-user', '{% trans "Stock item assigned to customer" %}');
}
if (row.expired) {
html += makeIconBadge('fa-calendar-times icon-red', '{% trans "Stock item has expired" %}');
} else if (row.stale) {
html += makeIconBadge('fa-stopwatch', '{% trans "Stock item will expire soon" %}');
}
if (row.allocated) {
html += makeIconBadge('fa-bookmark', '{% trans "Stock item has been allocated" %}');
}
@ -583,6 +590,14 @@ function loadStockTable(table, options) {
return locationDetail(row);
}
},
{% settings_value "STOCK_ENABLE_EXPIRY" as expiry %}
{% if expiry %}
{
field: 'expiry_date',
title: '{% trans "Expiry Date" %}',
sortable: true,
},
{% endif %}
{
field: 'notes',
title: '{% trans "Notes" %}',
@ -609,8 +624,8 @@ function loadStockTable(table, options) {
if (action == 'move') {
secondary.push({
field: 'destination',
label: 'New Location',
title: 'Create new location',
label: '{% trans "New Location" %}',
title: '{% trans "Create new location" %}',
url: "/stock/location/new/",
});
}
@ -828,14 +843,25 @@ function createNewStockItem(options) {
}
);
// Disable serial number field if the part is not trackable
// Request part information from the server
inventreeGet(
`/api/part/${value}/`, {},
{
success: function(response) {
// Disable serial number field if the part is not trackable
enableField('serial_numbers', response.trackable);
clearField('serial_numbers');
// Populate the expiry date
if (response.default_expiry <= 0) {
// No expiry date
clearField('expiry_date');
} else {
var expiry = moment().add(response.default_expiry, 'days');
setFieldValue('expiry_date', expiry.format("YYYY-MM-DD"));
}
}
}
);

View File

@ -106,6 +106,16 @@ function getAvailableTableFilters(tableKey) {
title: '{% trans "Depleted" %}',
description: '{% trans "Show stock items which are depleted" %}',
},
expired: {
type: 'bool',
title: '{% trans "Expired" %}',
description: '{% trans "Show stock items which have expired" %}',
},
stale: {
type: 'bool',
title: '{% trans "Stale" %}',
description: '{% trans "Show stock which is close to expiring" %}',
},
in_stock: {
type: 'bool',
title: '{% trans "In Stock" %}',