mirror of
https://github.com/inventree/InvenTree.git
synced 2025-07-01 03:00:54 +00:00
Merged master
This commit is contained in:
@ -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)
|
||||
|
||||
|
@ -81,7 +81,7 @@
|
||||
part: 25
|
||||
batch: 'ABCDE'
|
||||
location: 7
|
||||
quantity: 3
|
||||
quantity: 0
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
@ -232,6 +232,7 @@
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
expiry_date: "1990-10-10"
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 521
|
||||
@ -244,6 +245,7 @@
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
status: 60
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 522
|
||||
@ -255,4 +257,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
|
||||
|
||||
@ -109,6 +110,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):
|
||||
@ -130,6 +135,7 @@ class CreateStockItemForm(HelperForm):
|
||||
'batch',
|
||||
'serial_numbers',
|
||||
'purchase_price',
|
||||
'expiry_date',
|
||||
'link',
|
||||
'delete_on_deplete',
|
||||
'status',
|
||||
@ -243,7 +249,7 @@ class TestReportFormatForm(HelperForm):
|
||||
templates = TestReport.objects.filter(enabled=True)
|
||||
|
||||
for template in templates:
|
||||
if template.matches_stock_item(self.stock_item):
|
||||
if template.enabled and template.matches_stock_item(self.stock_item):
|
||||
choices.append((template.pk, template))
|
||||
|
||||
return choices
|
||||
@ -394,6 +400,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
|
||||
|
||||
@ -402,6 +412,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,12 @@ 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
|
||||
import report.models
|
||||
|
||||
from InvenTree.status_codes import StockStatus
|
||||
from InvenTree.models import InvenTreeTree, InvenTreeAttachment
|
||||
from InvenTree.fields import InvenTreeURLField
|
||||
@ -129,6 +132,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
|
||||
@ -153,6 +157,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:
|
||||
@ -432,11 +439,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)
|
||||
|
||||
@ -467,6 +482,55 @@ class StockItem(MPTTModel):
|
||||
help_text='Owner (User)',
|
||||
related_name='owner_stockitems')
|
||||
|
||||
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:
|
||||
@ -729,36 +793,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):
|
||||
@ -1271,6 +1315,41 @@ class StockItem(MPTTModel):
|
||||
|
||||
return status['passed'] >= status['total']
|
||||
|
||||
def available_test_reports(self):
|
||||
"""
|
||||
Return a list of TestReport objects which match this StockItem.
|
||||
"""
|
||||
|
||||
reports = []
|
||||
|
||||
item_query = StockItem.objects.filter(pk=self.pk)
|
||||
|
||||
for test_report in report.models.TestReport.objects.filter(enabled=True):
|
||||
|
||||
filters = helpers.validateFilterString(test_report.filters)
|
||||
|
||||
if item_query.filter(**filters).exists():
|
||||
reports.append(test_report)
|
||||
|
||||
return reports
|
||||
|
||||
@property
|
||||
def has_test_reports(self):
|
||||
"""
|
||||
Return True if there are test reports available for this stock item
|
||||
"""
|
||||
|
||||
return len(self.available_test_reports()) > 0
|
||||
|
||||
@property
|
||||
def has_labels(self):
|
||||
"""
|
||||
Return True if there are any label templates available for this stock item
|
||||
"""
|
||||
|
||||
# TODO - Implement this
|
||||
return True
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=StockItem, dispatch_uid='stock_item_pre_delete_log')
|
||||
def before_delete_stock_item(sender, instance, using, **kwargs):
|
||||
|
@ -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',
|
||||
|
@ -81,7 +81,14 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
|
||||
|
||||
<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>
|
||||
@ -112,16 +119,29 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
|
||||
<button id='barcode-options' title='{% trans "Barcode actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'><span class='fas fa-qrcode'></span> <span class='caret'></span></button>
|
||||
<ul class='dropdown-menu' role='menu'>
|
||||
<li><a href='#' id='show-qr-code'><span class='fas fa-qrcode'></span> {% trans "Show QR Code" %}</a></li>
|
||||
<li><a href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a></li>
|
||||
{% if roles.stock.change %}
|
||||
{% if item.uid %}
|
||||
<li><a href='#' id='unlink-barcode'><span class='fas fa-unlink'></span> {% trans "Unlink Barcode" %}</a></li>
|
||||
{% if item.uid %}
|
||||
<li><a href='#' id='unlink-barcode'><span class='fas fa-unlink'></span> {% trans "Unlink Barcode" %}</a></li>
|
||||
{% else %}
|
||||
<li><a href='#' id='link-barcode'><span class='fas fa-link'></span> {% trans "Link Barcode" %}</a></li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
<!-- Document / label menu -->
|
||||
{% if item.has_labels or item.has_test_reports %}
|
||||
<div class='btn-group'>
|
||||
<button id='document-options' title='{% trans "Document actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'><span class='fas fa-file-alt'></span> <span class='caret'></span></button>
|
||||
<ul class='dropdown-menu' role='menu'>
|
||||
{% if item.has_labels %}
|
||||
<li><a href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a></li>
|
||||
{% endif %}
|
||||
{% if item.has_test_reports %}
|
||||
<li><a href='#' id='stock-test-report'><span class='fas fa-file-pdf'></span> {% trans "Test Report" %}</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- Stock adjustment menu -->
|
||||
<!-- Check permissions and owner -->
|
||||
{% if owner_control.value == "False" or owner_control.value == "True" and item.owner == user or user.is_superuser %}
|
||||
@ -175,9 +195,6 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<button type='button' class='btn btn-default' id='stock-test-report' title='{% trans "Generate test report" %}'>
|
||||
<span class='fas fa-file-invoice'/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@ -307,6 +324,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
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
from InvenTree.status_codes import StockStatus
|
||||
@ -34,6 +37,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)
|
||||
@ -138,21 +144,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):
|
||||
|
||||
|
@ -27,7 +27,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
|
||||
@ -1330,12 +1330,17 @@ 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
|
||||
if not item.part.purchaseable:
|
||||
form.fields['supplier_part'].widget = HiddenInput()
|
||||
form.fields['purchase_price'].widget = HiddenInput()
|
||||
|
||||
form.fields.pop('purchase_price')
|
||||
else:
|
||||
query = form.fields['supplier_part'].queryset
|
||||
query = query.filter(part=item.part.id)
|
||||
@ -1629,6 +1634,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:
|
||||
@ -1639,8 +1648,8 @@ class StockItemCreate(AjaxCreateView):
|
||||
form.rebuild_layout()
|
||||
|
||||
if not part.purchaseable:
|
||||
form.fields['purchase_price'].widget = HiddenInput()
|
||||
|
||||
form.fields.pop('purchase_price')
|
||||
|
||||
# Hide the 'part' field (as a valid part is selected)
|
||||
# form.fields['part'].widget = HiddenInput()
|
||||
|
||||
@ -1734,6 +1743,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
|
||||
|
Reference in New Issue
Block a user