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={}) {
|
function clearField(fieldName, options={}) {
|
||||||
|
|
||||||
|
setFieldValue(fieldName, '', options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFieldValue(fieldName, value, options={}) {
|
||||||
|
|
||||||
var modal = options.modal || '#modal-form';
|
var modal = options.modal || '#modal-form';
|
||||||
|
|
||||||
var field = getFieldByName(modal, fieldName);
|
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.validators import validate_build_order_reference
|
||||||
from InvenTree.models import InvenTreeAttachment
|
from InvenTree.models import InvenTreeAttachment
|
||||||
|
|
||||||
|
import common.models
|
||||||
|
|
||||||
import InvenTree.fields
|
import InvenTree.fields
|
||||||
|
|
||||||
from stock import models as StockModels
|
from stock import models as StockModels
|
||||||
@ -819,6 +821,10 @@ class Build(MPTTModel):
|
|||||||
location__in=[loc for loc in self.take_from.getUniqueChildren()]
|
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
|
return items
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -28,7 +28,7 @@ class BuildSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
quantity = serializers.FloatField()
|
quantity = serializers.FloatField()
|
||||||
|
|
||||||
overdue = serializers.BooleanField()
|
overdue = serializers.BooleanField(required=False, read_only=True)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def annotate_queryset(queryset):
|
def annotate_queryset(queryset):
|
||||||
|
@ -903,6 +903,7 @@ class BuildItemCreate(AjaxCreateView):
|
|||||||
|
|
||||||
if self.build and self.part:
|
if self.build and self.part:
|
||||||
available_items = self.build.availableStockItems(self.part, self.output)
|
available_items = self.build.availableStockItems(self.part, self.output)
|
||||||
|
|
||||||
form.fields['stock_item'].queryset = available_items
|
form.fields['stock_item'].queryset = available_items
|
||||||
|
|
||||||
self.available_stock = form.fields['stock_item'].queryset.all()
|
self.available_stock = form.fields['stock_item'].queryset.all()
|
||||||
|
@ -160,6 +160,35 @@ class InvenTreeSetting(models.Model):
|
|||||||
'validator': bool,
|
'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': {
|
'BUILDORDER_REFERENCE_PREFIX': {
|
||||||
'name': _('Build Order Reference Prefix'),
|
'name': _('Build Order Reference Prefix'),
|
||||||
'description': _('Prefix value for build order reference'),
|
'description': _('Prefix value for build order reference'),
|
||||||
@ -359,6 +388,12 @@ class InvenTreeSetting(models.Model):
|
|||||||
if setting.is_bool():
|
if setting.is_bool():
|
||||||
value = InvenTree.helpers.str2bool(value)
|
value = InvenTree.helpers.str2bool(value)
|
||||||
|
|
||||||
|
if setting.is_int():
|
||||||
|
try:
|
||||||
|
value = int(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
value = backup_value
|
||||||
|
|
||||||
else:
|
else:
|
||||||
value = backup_value
|
value = backup_value
|
||||||
|
|
||||||
@ -447,18 +482,26 @@ class InvenTreeSetting(models.Model):
|
|||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if a 'type' has been specified for this value
|
# Boolean validator
|
||||||
if type(validator) == type:
|
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:
|
# Integer validator
|
||||||
# Value must "look like" a boolean value
|
if validator == int:
|
||||||
if InvenTree.helpers.is_bool(self.value):
|
try:
|
||||||
# Coerce into either "True" or "False"
|
# Coerce into an integer value
|
||||||
self.value = str(InvenTree.helpers.str2bool(self.value))
|
self.value = str(int(self.value))
|
||||||
else:
|
except (ValueError, TypeError):
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'value': _('Value must be a boolean value')
|
'value': _('Value must be an integer value'),
|
||||||
})
|
})
|
||||||
|
|
||||||
def validate_unique(self, exclude=None):
|
def validate_unique(self, exclude=None):
|
||||||
""" Ensure that the key:value pair is unique.
|
""" Ensure that the key:value pair is unique.
|
||||||
@ -500,6 +543,35 @@ class InvenTreeSetting(models.Model):
|
|||||||
|
|
||||||
return InvenTree.helpers.str2bool(self.value)
|
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):
|
class PriceBreak(models.Model):
|
||||||
"""
|
"""
|
||||||
|
@ -21,3 +21,11 @@ def currency_code_default():
|
|||||||
code = 'USD'
|
code = 'USD'
|
||||||
|
|
||||||
return code
|
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)
|
status_text = serializers.CharField(source='get_status_display', read_only=True)
|
||||||
|
|
||||||
overdue = serializers.BooleanField()
|
overdue = serializers.BooleanField(required=False, read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SalesOrder
|
model = SalesOrder
|
||||||
|
@ -24,6 +24,8 @@ from company.models import Company, SupplierPart
|
|||||||
from stock.models import StockItem, StockLocation
|
from stock.models import StockItem, StockLocation
|
||||||
from part.models import Part
|
from part.models import Part
|
||||||
|
|
||||||
|
from common.models import InvenTreeSetting
|
||||||
|
|
||||||
from . import forms as order_forms
|
from . import forms as order_forms
|
||||||
|
|
||||||
from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
|
from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
|
||||||
@ -1359,7 +1361,8 @@ class SalesOrderAllocationCreate(AjaxCreateView):
|
|||||||
try:
|
try:
|
||||||
line = SalesOrderLineItem.objects.get(pk=line_id)
|
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
|
# Ensure the part reference matches
|
||||||
queryset = queryset.filter(part=line.part)
|
queryset = queryset.filter(part=line.part)
|
||||||
@ -1369,6 +1372,10 @@ class SalesOrderAllocationCreate(AjaxCreateView):
|
|||||||
|
|
||||||
queryset = queryset.exclude(pk__in=allocated)
|
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
|
form.fields['item'].queryset = queryset
|
||||||
|
|
||||||
# Hide the 'line' field
|
# Hide the 'line' field
|
||||||
|
@ -74,6 +74,7 @@
|
|||||||
level: 0
|
level: 0
|
||||||
lft: 0
|
lft: 0
|
||||||
rght: 0
|
rght: 0
|
||||||
|
default_expiry: 10
|
||||||
|
|
||||||
- model: part.part
|
- model: part.part
|
||||||
pk: 50
|
pk: 50
|
||||||
@ -134,6 +135,7 @@
|
|||||||
fields:
|
fields:
|
||||||
name: 'Red chair'
|
name: 'Red chair'
|
||||||
variant_of: 10000
|
variant_of: 10000
|
||||||
|
IPN: "R.CH"
|
||||||
trackable: true
|
trackable: true
|
||||||
category: 7
|
category: 7
|
||||||
tree_id: 1
|
tree_id: 1
|
||||||
|
@ -181,6 +181,7 @@ class EditPartForm(HelperForm):
|
|||||||
'keywords': 'fa-key',
|
'keywords': 'fa-key',
|
||||||
'link': 'fa-link',
|
'link': 'fa-link',
|
||||||
'IPN': 'fa-hashtag',
|
'IPN': 'fa-hashtag',
|
||||||
|
'default_expiry': 'fa-stopwatch',
|
||||||
}
|
}
|
||||||
|
|
||||||
bom_copy = forms.BooleanField(required=False,
|
bom_copy = forms.BooleanField(required=False,
|
||||||
@ -228,6 +229,7 @@ class EditPartForm(HelperForm):
|
|||||||
'link',
|
'link',
|
||||||
'default_location',
|
'default_location',
|
||||||
'default_supplier',
|
'default_supplier',
|
||||||
|
'default_expiry',
|
||||||
'units',
|
'units',
|
||||||
'minimum_stock',
|
'minimum_stock',
|
||||||
'component',
|
'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
|
keywords: Optional keywords for improving part search results
|
||||||
IPN: Internal part number (optional)
|
IPN: Internal part number (optional)
|
||||||
revision: Part revision
|
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)
|
link: Link to an external page with more information about this part (e.g. internal Wiki)
|
||||||
image: Image of this part
|
image: Image of this part
|
||||||
default_location: Where the item is normally stored (may be null)
|
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_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
|
minimum_stock: Minimum preferred quantity to keep in stock
|
||||||
units: Units of measure for this part (default='pcs')
|
units: Units of measure for this part (default='pcs')
|
||||||
salable: Can this part be sold to customers?
|
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
|
# Default to None if there are multiple suppliers to choose from
|
||||||
return None
|
return None
|
||||||
|
|
||||||
default_supplier = models.ForeignKey(SupplierPart,
|
default_supplier = models.ForeignKey(
|
||||||
on_delete=models.SET_NULL,
|
SupplierPart,
|
||||||
blank=True, null=True,
|
on_delete=models.SET_NULL,
|
||||||
help_text=_('Default supplier part'),
|
blank=True, null=True,
|
||||||
related_name='default_parts')
|
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(
|
assembly = models.BooleanField(
|
||||||
default=part_settings.part_assembly_default,
|
default=part_settings.part_assembly_default,
|
||||||
|
@ -289,6 +289,7 @@ class PartSerializer(InvenTreeModelSerializer):
|
|||||||
'component',
|
'component',
|
||||||
'description',
|
'description',
|
||||||
'default_location',
|
'default_location',
|
||||||
|
'default_expiry',
|
||||||
'full_name',
|
'full_name',
|
||||||
'image',
|
'image',
|
||||||
'in_stock',
|
'in_stock',
|
||||||
|
@ -109,6 +109,13 @@
|
|||||||
<td>{{ part.minimum_stock }}</td>
|
<td>{{ part.minimum_stock }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% 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>
|
<tr>
|
||||||
<td><span class='fas fa-calendar-alt'></span></td>
|
<td><span class='fas fa-calendar-alt'></span></td>
|
||||||
<td><b>{% trans "Creation Date" %}</b></td>
|
<td><b>{% trans "Creation Date" %}</b></td>
|
||||||
|
@ -35,6 +35,8 @@ from .models import PartSellPriceBreak
|
|||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
from company.models import SupplierPart
|
from company.models import SupplierPart
|
||||||
|
|
||||||
|
import common.settings as inventree_settings
|
||||||
|
|
||||||
from . import forms as part_forms
|
from . import forms as part_forms
|
||||||
from .bom import MakeBomTemplate, BomUploadManager, ExportBom, IsValidBOMFormat
|
from .bom import MakeBomTemplate, BomUploadManager, ExportBom, IsValidBOMFormat
|
||||||
|
|
||||||
@ -626,6 +628,10 @@ class PartCreate(AjaxCreateView):
|
|||||||
"""
|
"""
|
||||||
form = super(AjaxCreateView, self).get_form()
|
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!)
|
# Hide the default_supplier field (there are no matching supplier parts yet!)
|
||||||
form.fields['default_supplier'].widget = HiddenInput()
|
form.fields['default_supplier'].widget = HiddenInput()
|
||||||
|
|
||||||
@ -918,6 +924,10 @@ class PartEdit(AjaxUpdateView):
|
|||||||
|
|
||||||
form = super(AjaxUpdateView, self).get_form()
|
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()
|
part = self.get_object()
|
||||||
|
|
||||||
form.fields['default_supplier'].queryset = SupplierPart.objects.filter(part=part)
|
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.models import SupplierPart
|
||||||
from company.serializers import SupplierPartSerializer
|
from company.serializers import SupplierPartSerializer
|
||||||
|
|
||||||
|
import common.settings
|
||||||
|
import common.models
|
||||||
|
|
||||||
from .serializers import StockItemSerializer
|
from .serializers import StockItemSerializer
|
||||||
from .serializers import LocationSerializer, LocationBriefSerializer
|
from .serializers import LocationSerializer, LocationBriefSerializer
|
||||||
from .serializers import StockTrackingSerializer
|
from .serializers import StockTrackingSerializer
|
||||||
@ -35,6 +38,8 @@ from InvenTree.api import AttachmentMixin
|
|||||||
|
|
||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from rest_framework.serializers import ValidationError
|
from rest_framework.serializers import ValidationError
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
@ -342,10 +347,18 @@ class StockList(generics.ListCreateAPIView):
|
|||||||
# A location was *not* specified - try to infer it
|
# A location was *not* specified - try to infer it
|
||||||
if 'location' not in request.data:
|
if 'location' not in request.data:
|
||||||
location = item.part.get_default_location()
|
location = item.part.get_default_location()
|
||||||
|
|
||||||
if location is not None:
|
if location is not None:
|
||||||
item.location = location
|
item.location = location
|
||||||
item.save()
|
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
|
# Return a response
|
||||||
headers = self.get_success_headers(serializer.data)
|
headers = self.get_success_headers(serializer.data)
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
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
|
# Exclude items which are instaled in another item
|
||||||
queryset = queryset.filter(belongs_to=None)
|
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
|
# Filter by customer
|
||||||
customer = params.get('customer', None)
|
customer = params.get('customer', None)
|
||||||
|
|
||||||
|
@ -69,7 +69,7 @@
|
|||||||
part: 25
|
part: 25
|
||||||
batch: 'ABCDE'
|
batch: 'ABCDE'
|
||||||
location: 7
|
location: 7
|
||||||
quantity: 3
|
quantity: 0
|
||||||
level: 0
|
level: 0
|
||||||
tree_id: 0
|
tree_id: 0
|
||||||
lft: 0
|
lft: 0
|
||||||
@ -220,6 +220,7 @@
|
|||||||
tree_id: 0
|
tree_id: 0
|
||||||
lft: 0
|
lft: 0
|
||||||
rght: 0
|
rght: 0
|
||||||
|
expiry_date: "1990-10-10"
|
||||||
|
|
||||||
- model: stock.stockitem
|
- model: stock.stockitem
|
||||||
pk: 521
|
pk: 521
|
||||||
@ -232,6 +233,7 @@
|
|||||||
tree_id: 0
|
tree_id: 0
|
||||||
lft: 0
|
lft: 0
|
||||||
rght: 0
|
rght: 0
|
||||||
|
status: 60
|
||||||
|
|
||||||
- model: stock.stockitem
|
- model: stock.stockitem
|
||||||
pk: 522
|
pk: 522
|
||||||
@ -243,4 +245,6 @@
|
|||||||
level: 0
|
level: 0
|
||||||
tree_id: 0
|
tree_id: 0
|
||||||
lft: 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.helpers import GetExportFormats
|
||||||
from InvenTree.forms import HelperForm
|
from InvenTree.forms import HelperForm
|
||||||
from InvenTree.fields import RoundingDecimalFormField
|
from InvenTree.fields import RoundingDecimalFormField
|
||||||
|
from InvenTree.fields import DatePickerFormField
|
||||||
|
|
||||||
from report.models import TestReport
|
from report.models import TestReport
|
||||||
|
|
||||||
@ -108,6 +109,10 @@ class ConvertStockItemForm(HelperForm):
|
|||||||
class CreateStockItemForm(HelperForm):
|
class CreateStockItemForm(HelperForm):
|
||||||
""" Form for creating a new StockItem """
|
""" 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)'))
|
serial_numbers = forms.CharField(label=_('Serial numbers'), required=False, help_text=_('Enter unique serial numbers (or leave blank)'))
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@ -129,6 +134,7 @@ class CreateStockItemForm(HelperForm):
|
|||||||
'batch',
|
'batch',
|
||||||
'serial_numbers',
|
'serial_numbers',
|
||||||
'purchase_price',
|
'purchase_price',
|
||||||
|
'expiry_date',
|
||||||
'link',
|
'link',
|
||||||
'delete_on_deplete',
|
'delete_on_deplete',
|
||||||
'status',
|
'status',
|
||||||
@ -392,6 +398,10 @@ class EditStockItemForm(HelperForm):
|
|||||||
part - Cannot be edited after creation
|
part - Cannot be edited after creation
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
expiry_date = DatePickerFormField(
|
||||||
|
help_text=('Expiration date for this stock item'),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = StockItem
|
model = StockItem
|
||||||
|
|
||||||
@ -400,6 +410,7 @@ class EditStockItemForm(HelperForm):
|
|||||||
'serial',
|
'serial',
|
||||||
'batch',
|
'batch',
|
||||||
'status',
|
'status',
|
||||||
|
'expiry_date',
|
||||||
'purchase_price',
|
'purchase_price',
|
||||||
'link',
|
'link',
|
||||||
'delete_on_deplete',
|
'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 djmoney.models.fields import MoneyField
|
||||||
|
|
||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from InvenTree import helpers
|
from InvenTree import helpers
|
||||||
|
|
||||||
|
import common.models
|
||||||
|
|
||||||
from InvenTree.status_codes import StockStatus
|
from InvenTree.status_codes import StockStatus
|
||||||
from InvenTree.models import InvenTreeTree, InvenTreeAttachment
|
from InvenTree.models import InvenTreeTree, InvenTreeAttachment
|
||||||
from InvenTree.fields import InvenTreeURLField
|
from InvenTree.fields import InvenTreeURLField
|
||||||
@ -125,6 +127,7 @@ class StockItem(MPTTModel):
|
|||||||
serial: Unique serial number for this StockItem
|
serial: Unique serial number for this StockItem
|
||||||
link: Optional URL to link to external resource
|
link: Optional URL to link to external resource
|
||||||
updated: Date that this stock item was last updated (auto)
|
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_date: Date of last stocktake for this item
|
||||||
stocktake_user: User that performed the most recent stocktake
|
stocktake_user: User that performed the most recent stocktake
|
||||||
review_needed: Flag if StockItem needs review
|
review_needed: Flag if StockItem needs review
|
||||||
@ -149,6 +152,9 @@ class StockItem(MPTTModel):
|
|||||||
status__in=StockStatus.AVAILABLE_CODES
|
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):
|
def save(self, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
Save this StockItem to the database. Performs a number of checks:
|
Save this StockItem to the database. Performs a number of checks:
|
||||||
@ -428,11 +434,19 @@ class StockItem(MPTTModel):
|
|||||||
related_name='stock_items',
|
related_name='stock_items',
|
||||||
null=True, blank=True)
|
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_date = models.DateField(blank=True, null=True)
|
||||||
|
|
||||||
stocktake_user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True,
|
stocktake_user = models.ForeignKey(
|
||||||
related_name='stocktake_stock')
|
User, on_delete=models.SET_NULL,
|
||||||
|
blank=True, null=True,
|
||||||
|
related_name='stocktake_stock'
|
||||||
|
)
|
||||||
|
|
||||||
review_needed = models.BooleanField(default=False)
|
review_needed = models.BooleanField(default=False)
|
||||||
|
|
||||||
@ -459,6 +473,55 @@ class StockItem(MPTTModel):
|
|||||||
help_text=_('Single unit purchase price at time of purchase'),
|
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):
|
def clearAllocations(self):
|
||||||
"""
|
"""
|
||||||
Clear all order allocations for this StockItem:
|
Clear all order allocations for this StockItem:
|
||||||
@ -721,36 +784,16 @@ class StockItem(MPTTModel):
|
|||||||
@property
|
@property
|
||||||
def in_stock(self):
|
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
|
See also: IN_STOCK_FILTER
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Quantity must be above zero (unless infinite)
|
query = StockItem.objects.filter(pk=self.pk)
|
||||||
if self.quantity <= 0 and not self.infinite:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Not 'in stock' if it has been installed inside another StockItem
|
query = query.filter(StockItem.IN_STOCK_FILTER)
|
||||||
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
|
|
||||||
|
|
||||||
# Not 'in stock' if it has been assigned to a customer
|
return query.exists()
|
||||||
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
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def tracking_info_count(self):
|
def tracking_info_count(self):
|
||||||
|
@ -11,10 +11,17 @@ from .models import StockItemTestResult
|
|||||||
|
|
||||||
from django.db.models.functions import Coalesce
|
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 sql_util.utils import SubquerySum, SubqueryCount
|
||||||
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import common.models
|
||||||
from company.serializers import SupplierPartSerializer
|
from company.serializers import SupplierPartSerializer
|
||||||
from part.serializers import PartBriefSerializer
|
from part.serializers import PartBriefSerializer
|
||||||
from InvenTree.serializers import UserSerializerBrief, InvenTreeModelSerializer
|
from InvenTree.serializers import UserSerializerBrief, InvenTreeModelSerializer
|
||||||
@ -106,6 +113,30 @@ class StockItemSerializer(InvenTreeModelSerializer):
|
|||||||
tracking_items=SubqueryCount('tracking_info')
|
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
|
return queryset
|
||||||
|
|
||||||
status_text = serializers.CharField(source='get_status_display', read_only=True)
|
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)
|
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)
|
serial = serializers.CharField(required=False)
|
||||||
|
|
||||||
required_tests = serializers.IntegerField(source='required_test_count', read_only=True, required=False)
|
required_tests = serializers.IntegerField(source='required_test_count', read_only=True, required=False)
|
||||||
@ -155,6 +190,8 @@ class StockItemSerializer(InvenTreeModelSerializer):
|
|||||||
'belongs_to',
|
'belongs_to',
|
||||||
'build',
|
'build',
|
||||||
'customer',
|
'customer',
|
||||||
|
'expired',
|
||||||
|
'expiry_date',
|
||||||
'in_stock',
|
'in_stock',
|
||||||
'is_building',
|
'is_building',
|
||||||
'link',
|
'link',
|
||||||
@ -168,6 +205,7 @@ class StockItemSerializer(InvenTreeModelSerializer):
|
|||||||
'required_tests',
|
'required_tests',
|
||||||
'sales_order',
|
'sales_order',
|
||||||
'serial',
|
'serial',
|
||||||
|
'stale',
|
||||||
'status',
|
'status',
|
||||||
'status_text',
|
'status_text',
|
||||||
'supplier_part',
|
'supplier_part',
|
||||||
|
@ -70,7 +70,14 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
|
|||||||
{% block page_data %}
|
{% block page_data %}
|
||||||
<h3>
|
<h3>
|
||||||
{% trans "Stock Item" %}
|
{% 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 %}
|
{% 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>
|
</h3>
|
||||||
<hr>
|
<hr>
|
||||||
<h4>
|
<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>
|
<td><a href="{% url 'supplier-part-detail' item.supplier_part.id %}">{{ item.supplier_part.SKU }}</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% 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>
|
<tr>
|
||||||
<td><span class='fas fa-calendar-alt'></span></td>
|
<td><span class='fas fa-calendar-alt'></span></td>
|
||||||
<td>{% trans "Last Updated" %}</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.test import APITestCase
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
from InvenTree.helpers import addUserPermissions
|
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):
|
class StockAPITestCase(APITestCase):
|
||||||
@ -26,6 +38,9 @@ class StockAPITestCase(APITestCase):
|
|||||||
|
|
||||||
self.user = user.objects.create_user('testuser', 'test@testing.com', 'password')
|
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
|
# Add the necessary permissions to the user
|
||||||
perms = [
|
perms = [
|
||||||
'view_stockitemtestresult',
|
'view_stockitemtestresult',
|
||||||
@ -76,6 +91,177 @@ class StockLocationTest(StockAPITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
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):
|
class StockItemTest(StockAPITestCase):
|
||||||
"""
|
"""
|
||||||
Series of API tests for the StockItem API
|
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='B', description='location b', parent=top)
|
||||||
StockLocation.objects.create(name='C', description='location c', 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):
|
def test_create_default_location(self):
|
||||||
"""
|
"""
|
||||||
Test the default location functionality,
|
Test the default location functionality,
|
||||||
@ -198,6 +380,56 @@ class StockItemTest(StockAPITestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
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):
|
class StocktakeTest(StockAPITestCase):
|
||||||
"""
|
"""
|
||||||
|
@ -5,7 +5,10 @@ from django.urls import reverse
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
|
|
||||||
|
from common.models import InvenTreeSetting
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
|
||||||
class StockViewTestCase(TestCase):
|
class StockViewTestCase(TestCase):
|
||||||
@ -31,6 +34,9 @@ class StockViewTestCase(TestCase):
|
|||||||
password='password'
|
password='password'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.user.is_staff = True
|
||||||
|
self.user.save()
|
||||||
|
|
||||||
# Put the user into a group with the correct permissions
|
# Put the user into a group with the correct permissions
|
||||||
group = Group.objects.create(name='mygroup')
|
group = Group.objects.create(name='mygroup')
|
||||||
self.user.groups.add(group)
|
self.user.groups.add(group)
|
||||||
@ -135,21 +141,56 @@ class StockItemTest(StockViewTestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
def test_create_item(self):
|
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)
|
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)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
# Copy from a valid item, valid location
|
# 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)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
# Copy from an invalid item, invalid location
|
# 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)
|
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):
|
def test_serialize_item(self):
|
||||||
# Test the serialization view
|
# Test the serialization view
|
||||||
|
|
||||||
|
@ -49,6 +49,40 @@ class StockTest(TestCase):
|
|||||||
Part.objects.rebuild()
|
Part.objects.rebuild()
|
||||||
StockItem.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):
|
def test_is_building(self):
|
||||||
"""
|
"""
|
||||||
Test that the is_building flag does not count towards stock.
|
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
|
# There should be 9000 screws in stock
|
||||||
self.assertEqual(part.total_stock, 9000)
|
self.assertEqual(part.total_stock, 9000)
|
||||||
|
|
||||||
# There should be 18 widgets in stock
|
# There should be 16 widgets "in stock"
|
||||||
self.assertEqual(StockItem.objects.filter(part=25).aggregate(Sum('quantity'))['quantity__sum'], 19)
|
self.assertEqual(
|
||||||
|
StockItem.objects.filter(part=25).aggregate(Sum('quantity'))['quantity__sum'], 16
|
||||||
|
)
|
||||||
|
|
||||||
def test_delete_location(self):
|
def test_delete_location(self):
|
||||||
|
|
||||||
|
@ -26,7 +26,7 @@ from InvenTree.helpers import str2bool, DownloadFile, GetExportFormats
|
|||||||
from InvenTree.helpers import extract_serial_numbers
|
from InvenTree.helpers import extract_serial_numbers
|
||||||
|
|
||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from company.models import Company, SupplierPart
|
from company.models import Company, SupplierPart
|
||||||
from part.models import Part
|
from part.models import Part
|
||||||
@ -1302,6 +1302,10 @@ class StockItemEdit(AjaxUpdateView):
|
|||||||
|
|
||||||
form = super(AjaxUpdateView, self).get_form()
|
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()
|
item = self.get_object()
|
||||||
|
|
||||||
# If the part cannot be purchased, hide the supplier_part field
|
# If the part cannot be purchased, hide the supplier_part field
|
||||||
@ -1513,6 +1517,10 @@ class StockItemCreate(AjaxCreateView):
|
|||||||
|
|
||||||
form = super().get_form()
|
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)
|
part = self.get_part(form=form)
|
||||||
|
|
||||||
if part is not None:
|
if part is not None:
|
||||||
@ -1596,6 +1604,11 @@ class StockItemCreate(AjaxCreateView):
|
|||||||
initials['location'] = part.get_default_location()
|
initials['location'] = part.get_default_location()
|
||||||
initials['supplier_part'] = part.default_supplier
|
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()
|
currency_code = common.settings.currency_code_default()
|
||||||
|
|
||||||
# SupplierPart field has been specified
|
# 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" %}
|
{% extends "base.html" %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load inventree_extras %}
|
||||||
{% block page_title %}
|
{% block page_title %}
|
||||||
InvenTree | {% trans "Index" %}
|
InvenTree | {% trans "Index" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -8,7 +9,7 @@ InvenTree | {% trans "Index" %}
|
|||||||
<h3>InvenTree</h3>
|
<h3>InvenTree</h3>
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<div class='col-sm-6'>
|
<div class='col-sm-4'>
|
||||||
{% if roles.part.view %}
|
{% if roles.part.view %}
|
||||||
{% include "InvenTree/latest_parts.html" with collapse_id="latest_parts" %}
|
{% include "InvenTree/latest_parts.html" with collapse_id="latest_parts" %}
|
||||||
{% include "InvenTree/bom_invalid.html" with collapse_id="bom_invalid" %}
|
{% 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" %}
|
{% include "InvenTree/build_overdue.html" with collapse_id="build_overdue" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class='col-sm-6'>
|
<div class='col-sm-4'>
|
||||||
{% if roles.stock.view %}
|
{% if roles.stock.view %}
|
||||||
{% include "InvenTree/low_stock.html" with collapse_id="order" %}
|
{% 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" %}
|
{% include "InvenTree/required_stock_build.html" with collapse_id="stock_to_build" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class='col-sm-4'>
|
||||||
{% if roles.purchase_order.view %}
|
{% if roles.purchase_order.view %}
|
||||||
{% include "InvenTree/po_outstanding.html" with collapse_id="po_outstanding" %}
|
{% include "InvenTree/po_outstanding.html" with collapse_id="po_outstanding" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -83,6 +91,23 @@ loadBuildTable("#build-overdue-table", {
|
|||||||
disableFilters: true,
|
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' %}", {
|
loadSimplePartTable("#low-stock-table", "{% url 'api-part-list' %}", {
|
||||||
params: {
|
params: {
|
||||||
low_stock: true,
|
low_stock: true,
|
||||||
@ -121,64 +146,19 @@ loadSalesOrderTable("#so-overdue-table", {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#latest-parts-table").on('load-success.bs.table', function() {
|
{% include "InvenTree/index/on_load.html" with label="latest-parts" %}
|
||||||
var count = $("#latest-parts-table").bootstrapTable('getData').length;
|
{% 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() {
|
{% include "InvenTree/index/on_load.html" with label="po-outstanding" %}
|
||||||
var count = $("#starred-parts-table").bootstrapTable('getData').length;
|
{% include "InvenTree/index/on_load.html" with label="so-outstanding" %}
|
||||||
|
{% include "InvenTree/index/on_load.html" with label="so-overdue" %}
|
||||||
$("#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);
|
|
||||||
});
|
|
||||||
|
|
||||||
{% endblock %}
|
{% 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 %}
|
{% else %}
|
||||||
{% if setting.value %}
|
{% if setting.value %}
|
||||||
<i><b>
|
<i><b>
|
||||||
{{ setting.value }}</b>{{ setting.units }}
|
{{ setting.value }}</b> {{ setting.units }}
|
||||||
</i>
|
</i>
|
||||||
{% else %}
|
{% else %}
|
||||||
<i>{% trans "No value set" %}</i>
|
<i>{% trans "No value set" %}</i>
|
||||||
|
@ -10,7 +10,15 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block settings %}
|
{% block settings %}
|
||||||
<div class='alert alert-block alert-info'>
|
<h4>{% trans "Stock Options" %}</h4>
|
||||||
<i>No Stock settings available</i>
|
|
||||||
</div>
|
<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 %}
|
{% 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 %}
|
{% block collapse_preamble %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
<div class='panel-group'>
|
<div class='panel-group panel-index'>
|
||||||
<div class='panel panel-default'>
|
<div class='panel panel-default'>
|
||||||
<div {% block collapse_panel_setup %}class='panel panel-heading'{% endblock %}>
|
<div {% block collapse_panel_setup %}class='panel panel-heading'{% endblock %}>
|
||||||
<div class='panel-title'>
|
<div class='panel-title'>
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load inventree_extras %}
|
||||||
{% load status_codes %}
|
{% load status_codes %}
|
||||||
|
|
||||||
/* Stock API functions
|
/* Stock API functions
|
||||||
@ -532,6 +533,12 @@ function loadStockTable(table, options) {
|
|||||||
html += makeIconBadge('fa-user', '{% trans "Stock item assigned to customer" %}');
|
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) {
|
if (row.allocated) {
|
||||||
html += makeIconBadge('fa-bookmark', '{% trans "Stock item has been allocated" %}');
|
html += makeIconBadge('fa-bookmark', '{% trans "Stock item has been allocated" %}');
|
||||||
}
|
}
|
||||||
@ -583,6 +590,14 @@ function loadStockTable(table, options) {
|
|||||||
return locationDetail(row);
|
return locationDetail(row);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{% settings_value "STOCK_ENABLE_EXPIRY" as expiry %}
|
||||||
|
{% if expiry %}
|
||||||
|
{
|
||||||
|
field: 'expiry_date',
|
||||||
|
title: '{% trans "Expiry Date" %}',
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{% endif %}
|
||||||
{
|
{
|
||||||
field: 'notes',
|
field: 'notes',
|
||||||
title: '{% trans "Notes" %}',
|
title: '{% trans "Notes" %}',
|
||||||
@ -609,8 +624,8 @@ function loadStockTable(table, options) {
|
|||||||
if (action == 'move') {
|
if (action == 'move') {
|
||||||
secondary.push({
|
secondary.push({
|
||||||
field: 'destination',
|
field: 'destination',
|
||||||
label: 'New Location',
|
label: '{% trans "New Location" %}',
|
||||||
title: 'Create new location',
|
title: '{% trans "Create new location" %}',
|
||||||
url: "/stock/location/new/",
|
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(
|
inventreeGet(
|
||||||
`/api/part/${value}/`, {},
|
`/api/part/${value}/`, {},
|
||||||
{
|
{
|
||||||
success: function(response) {
|
success: function(response) {
|
||||||
|
|
||||||
|
// Disable serial number field if the part is not trackable
|
||||||
enableField('serial_numbers', response.trackable);
|
enableField('serial_numbers', response.trackable);
|
||||||
clearField('serial_numbers');
|
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" %}',
|
title: '{% trans "Depleted" %}',
|
||||||
description: '{% trans "Show stock items which are 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: {
|
in_stock: {
|
||||||
type: 'bool',
|
type: 'bool',
|
||||||
title: '{% trans "In Stock" %}',
|
title: '{% trans "In Stock" %}',
|
||||||
|
Loading…
Reference in New Issue
Block a user