mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge pull request #1202 from SchrodingersGat/stock-expiry
StockItem expiry date
This commit is contained in:
commit
735a3d2eb2
@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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()
|
||||
|
@ -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):
|
||||
"""
|
||||
|
@ -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
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
|
36
InvenTree/part/migrations/0061_auto_20210104_2331.py
Normal file
36
InvenTree/part/migrations/0061_auto_20210104_2331.py
Normal 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'),
|
||||
),
|
||||
]
|
14
InvenTree/part/migrations/0062_merge_20210105_0056.py
Normal file
14
InvenTree/part/migrations/0062_merge_20210105_0056.py
Normal 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 = [
|
||||
]
|
@ -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,
|
||||
|
@ -289,6 +289,7 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
'component',
|
||||
'description',
|
||||
'default_location',
|
||||
'default_expiry',
|
||||
'full_name',
|
||||
'image',
|
||||
'in_stock',
|
||||
|
@ -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>
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
@ -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',
|
||||
|
18
InvenTree/stock/migrations/0056_stockitem_expiry_date.py
Normal file
18
InvenTree/stock/migrations/0056_stockitem_expiry_date.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -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):
|
||||
|
@ -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',
|
||||
|
@ -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>
|
||||
|
@ -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):
|
||||
"""
|
||||
|
@ -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
|
||||
|
||||
|
@ -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):
|
||||
|
||||
|
@ -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
|
||||
|
15
InvenTree/templates/InvenTree/expired_stock.html
Normal file
15
InvenTree/templates/InvenTree/expired_stock.html
Normal 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 %}
|
@ -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 %}
|
5
InvenTree/templates/InvenTree/index/on_load.html
Normal file
5
InvenTree/templates/InvenTree/index/on_load.html
Normal file
@ -0,0 +1,5 @@
|
||||
$("#{{ label }}-table").on('load-success.bs.table', function() {
|
||||
var count = $("#{{ label }}-table").bootstrapTable('getData').length;
|
||||
|
||||
$("#{{ label }}-count").html(count);
|
||||
});
|
@ -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>
|
||||
|
@ -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 %}
|
15
InvenTree/templates/InvenTree/stale_stock.html
Normal file
15
InvenTree/templates/InvenTree/stale_stock.html
Normal 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 %}
|
@ -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'>
|
||||
|
@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
@ -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" %}',
|
||||
|
Loading…
Reference in New Issue
Block a user