Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2022-04-26 21:13:15 +10:00
commit ae81f50ee5
19 changed files with 430 additions and 173 deletions

View File

@ -4,11 +4,18 @@ InvenTree API version information
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 41 INVENTREE_API_VERSION = 43
""" """
Increment this API version number whenever there is a significant change to the API that any clients need to know about Increment this API version number whenever there is a significant change to the API that any clients need to know about
v43 -> 2022-04-26 : https://github.com/inventree/InvenTree/pull/2875
- Adds API detail endpoint for PartSalePrice model
- Adds API detail endpoint for PartInternalPrice model
v42 -> 2022-04-26 : https://github.com/inventree/InvenTree/pull/2833
- Adds variant stock information to the Part and BomItem serializers
v41 -> 2022-04-26 v41 -> 2022-04-26
- Fixes 'variant_of' filter for Part list endpoint - Fixes 'variant_of' filter for Part list endpoint

View File

@ -871,6 +871,9 @@ class Build(MPTTModel, ReferenceIndexingMixin):
part__in=[p for p in available_parts], part__in=[p for p in available_parts],
) )
# Filter out "serialized" stock items, these cannot be auto-allocated
available_stock = available_stock.filter(Q(serial=None) | Q(serial=''))
if location: if location:
# Filter only stock items located "below" the specified location # Filter only stock items located "below" the specified location
sublocations = location.get_descendants(include_self=True) sublocations = location.get_descendants(include_self=True)

View File

@ -932,6 +932,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': bool, 'validator': bool,
}, },
'STOCK_BATCH_CODE_TEMPLATE': {
'name': _('Batch Code Template'),
'description': _('Template for generating default batch codes for stock items'),
'default': '',
},
'STOCK_ENABLE_EXPIRY': { 'STOCK_ENABLE_EXPIRY': {
'name': _('Stock Expiry'), 'name': _('Stock Expiry'),
'description': _('Enable stock expiry functionality'), 'description': _('Enable stock expiry functionality'),

View File

@ -262,6 +262,15 @@ class CategoryTree(generics.ListAPIView):
ordering = ['level', 'name'] ordering = ['level', 'name']
class PartSalePriceDetail(generics.RetrieveUpdateDestroyAPIView):
"""
Detail endpoint for PartSellPriceBreak model
"""
queryset = PartSellPriceBreak.objects.all()
serializer_class = part_serializers.PartSalePriceSerializer
class PartSalePriceList(generics.ListCreateAPIView): class PartSalePriceList(generics.ListCreateAPIView):
""" """
API endpoint for list view of PartSalePriceBreak model API endpoint for list view of PartSalePriceBreak model
@ -279,6 +288,15 @@ class PartSalePriceList(generics.ListCreateAPIView):
] ]
class PartInternalPriceDetail(generics.RetrieveUpdateDestroyAPIView):
"""
Detail endpoint for PartInternalPriceBreak model
"""
queryset = PartInternalPriceBreak.objects.all()
serializer_class = part_serializers.PartInternalPriceSerializer
class PartInternalPriceList(generics.ListCreateAPIView): class PartInternalPriceList(generics.ListCreateAPIView):
""" """
API endpoint for list view of PartInternalPriceBreak model API endpoint for list view of PartInternalPriceBreak model
@ -1920,11 +1938,13 @@ part_api_urls = [
# Base URL for part sale pricing # Base URL for part sale pricing
url(r'^sale-price/', include([ url(r'^sale-price/', include([
url(r'^(?P<pk>\d+)/', PartSalePriceDetail.as_view(), name='api-part-sale-price-detail'),
url(r'^.*$', PartSalePriceList.as_view(), name='api-part-sale-price-list'), url(r'^.*$', PartSalePriceList.as_view(), name='api-part-sale-price-list'),
])), ])),
# Base URL for part internal pricing # Base URL for part internal pricing
url(r'^internal-price/', include([ url(r'^internal-price/', include([
url(r'^(?P<pk>\d+)/', PartInternalPriceDetail.as_view(), name='api-part-internal-price-detail'),
url(r'^.*$', PartInternalPriceList.as_view(), name='api-part-internal-price-list'), url(r'^.*$', PartInternalPriceList.as_view(), name='api-part-internal-price-list'),
])), ])),

View File

@ -7,6 +7,7 @@
part: 100 part: 100
sub_part: 1 sub_part: 1
quantity: 10 quantity: 10
allow_variants: True
# 40 x R_2K2_0805 # 40 x R_2K2_0805
- model: part.bomitem - model: part.bomitem

View File

@ -2732,7 +2732,21 @@ class BomItem(models.Model, DataImportMixin):
for sub in self.substitutes.all(): for sub in self.substitutes.all():
parts.add(sub.part) parts.add(sub.part)
return parts valid_parts = []
for p in parts:
# Inactive parts cannot be 'auto allocated'
if not p.active:
continue
# Trackable parts cannot be 'auto allocated'
if p.trackable:
continue
valid_parts.append(p)
return valid_parts
def is_stock_item_valid(self, stock_item): def is_stock_item_valid(self, stock_item):
""" """

View File

@ -7,7 +7,9 @@ from decimal import Decimal
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.db import models, transaction from django.db import models, transaction
from django.db.models import ExpressionWrapper, F, Q from django.db.models import ExpressionWrapper, F, Q, Func
from django.db.models import Subquery, OuterRef, FloatField
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
@ -15,6 +17,8 @@ from rest_framework import serializers
from sql_util.utils import SubqueryCount, SubquerySum from sql_util.utils import SubqueryCount, SubquerySum
from djmoney.contrib.django_rest_framework import MoneyField from djmoney.contrib.django_rest_framework import MoneyField
from common.settings import currency_code_default, currency_code_mappings
from InvenTree.serializers import (DataFileUploadSerializer, from InvenTree.serializers import (DataFileUploadSerializer,
DataFileExtractSerializer, DataFileExtractSerializer,
InvenTreeAttachmentSerializerField, InvenTreeAttachmentSerializerField,
@ -146,6 +150,13 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
allow_null=True allow_null=True
) )
price_currency = serializers.ChoiceField(
choices=currency_code_mappings(),
default=currency_code_default,
label=_('Currency'),
help_text=_('Purchase currency of this stock item'),
)
price_string = serializers.CharField(source='price', read_only=True) price_string = serializers.CharField(source='price', read_only=True)
class Meta: class Meta:
@ -155,6 +166,7 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
'part', 'part',
'quantity', 'quantity',
'price', 'price',
'price_currency',
'price_string', 'price_string',
] ]
@ -170,6 +182,13 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
allow_null=True allow_null=True
) )
price_currency = serializers.ChoiceField(
choices=currency_code_mappings(),
default=currency_code_default,
label=_('Currency'),
help_text=_('Purchase currency of this stock item'),
)
price_string = serializers.CharField(source='price', read_only=True) price_string = serializers.CharField(source='price', read_only=True)
class Meta: class Meta:
@ -179,6 +198,7 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
'part', 'part',
'quantity', 'quantity',
'price', 'price',
'price_currency',
'price_string', 'price_string',
] ]
@ -308,9 +328,6 @@ class PartSerializer(InvenTreeModelSerializer):
to reduce database trips. to reduce database trips.
""" """
# TODO: Update the "in_stock" annotation to include stock for variants of the part
# Ref: https://github.com/inventree/InvenTree/issues/2240
# Annotate with the total 'in stock' quantity # Annotate with the total 'in stock' quantity
queryset = queryset.annotate( queryset = queryset.annotate(
in_stock=Coalesce( in_stock=Coalesce(
@ -325,6 +342,24 @@ class PartSerializer(InvenTreeModelSerializer):
stock_item_count=SubqueryCount('stock_items') stock_item_count=SubqueryCount('stock_items')
) )
# Annotate with the total variant stock quantity
variant_query = StockItem.objects.filter(
part__tree_id=OuterRef('tree_id'),
part__lft__gt=OuterRef('lft'),
part__rght__lt=OuterRef('rght'),
).filter(StockItem.IN_STOCK_FILTER)
queryset = queryset.annotate(
variant_stock=Coalesce(
Subquery(
variant_query.annotate(
total=Func(F('quantity'), function='SUM', output_field=FloatField())
).values('total')),
0,
output_field=FloatField(),
)
)
# Filter to limit builds to "active" # Filter to limit builds to "active"
build_filter = Q( build_filter = Q(
status__in=BuildStatus.ACTIVE_CODES status__in=BuildStatus.ACTIVE_CODES
@ -429,6 +464,7 @@ class PartSerializer(InvenTreeModelSerializer):
unallocated_stock = serializers.FloatField(read_only=True) unallocated_stock = serializers.FloatField(read_only=True)
building = serializers.FloatField(read_only=True) building = serializers.FloatField(read_only=True)
in_stock = serializers.FloatField(read_only=True) in_stock = serializers.FloatField(read_only=True)
variant_stock = serializers.FloatField(read_only=True)
ordering = serializers.FloatField(read_only=True) ordering = serializers.FloatField(read_only=True)
stock_item_count = serializers.IntegerField(read_only=True) stock_item_count = serializers.IntegerField(read_only=True)
suppliers = serializers.IntegerField(read_only=True) suppliers = serializers.IntegerField(read_only=True)
@ -463,6 +499,7 @@ class PartSerializer(InvenTreeModelSerializer):
'full_name', 'full_name',
'image', 'image',
'in_stock', 'in_stock',
'variant_stock',
'ordering', 'ordering',
'building', 'building',
'IPN', 'IPN',
@ -577,9 +614,10 @@ class BomItemSerializer(InvenTreeModelSerializer):
purchase_price_range = serializers.SerializerMethodField() purchase_price_range = serializers.SerializerMethodField()
# Annotated fields # Annotated fields for available stock
available_stock = serializers.FloatField(read_only=True) available_stock = serializers.FloatField(read_only=True)
available_substitute_stock = serializers.FloatField(read_only=True) available_substitute_stock = serializers.FloatField(read_only=True)
available_variant_stock = serializers.FloatField(read_only=True)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
# part_detail and sub_part_detail serializers are only included if requested. # part_detail and sub_part_detail serializers are only included if requested.
@ -613,11 +651,18 @@ class BomItemSerializer(InvenTreeModelSerializer):
queryset = queryset.prefetch_related('sub_part') queryset = queryset.prefetch_related('sub_part')
queryset = queryset.prefetch_related('sub_part__category') queryset = queryset.prefetch_related('sub_part__category')
queryset = queryset.prefetch_related( queryset = queryset.prefetch_related(
'sub_part__stock_items', 'sub_part__stock_items',
'sub_part__stock_items__allocations', 'sub_part__stock_items__allocations',
'sub_part__stock_items__sales_order_allocations', 'sub_part__stock_items__sales_order_allocations',
) )
queryset = queryset.prefetch_related(
'substitutes',
'substitutes__part__stock_items',
)
queryset = queryset.prefetch_related('sub_part__supplier_parts__pricebreaks') queryset = queryset.prefetch_related('sub_part__supplier_parts__pricebreaks')
return queryset return queryset
@ -707,7 +752,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
), ),
) )
# Calculate 'available_variant_stock' field # Calculate 'available_substitute_stock' field
queryset = queryset.annotate( queryset = queryset.annotate(
available_substitute_stock=ExpressionWrapper( available_substitute_stock=ExpressionWrapper(
F('substitute_stock') - F('substitute_build_allocations') - F('substitute_sales_allocations'), F('substitute_stock') - F('substitute_build_allocations') - F('substitute_sales_allocations'),
@ -715,6 +760,47 @@ class BomItemSerializer(InvenTreeModelSerializer):
) )
) )
# Annotate the queryset with 'available variant stock' information
variant_stock_query = StockItem.objects.filter(
part__tree_id=OuterRef('sub_part__tree_id'),
part__lft__gt=OuterRef('sub_part__lft'),
part__rght__lt=OuterRef('sub_part__rght'),
).filter(StockItem.IN_STOCK_FILTER)
queryset = queryset.alias(
variant_stock_total=Coalesce(
Subquery(
variant_stock_query.annotate(
total=Func(F('quantity'), function='SUM', output_field=FloatField())
).values('total')),
0,
output_field=FloatField()
),
variant_stock_build_order_allocations=Coalesce(
Subquery(
variant_stock_query.annotate(
total=Func(F('sales_order_allocations__quantity'), function='SUM', output_field=FloatField()),
).values('total')),
0,
output_field=FloatField(),
),
variant_stock_sales_order_allocations=Coalesce(
Subquery(
variant_stock_query.annotate(
total=Func(F('allocations__quantity'), function='SUM', output_field=FloatField()),
).values('total')),
0,
output_field=FloatField(),
)
)
queryset = queryset.annotate(
available_variant_stock=ExpressionWrapper(
F('variant_stock_total') - F('variant_stock_build_order_allocations') - F('variant_stock_sales_order_allocations'),
output_field=FloatField(),
)
)
return queryset return queryset
def get_purchase_price_range(self, obj): def get_purchase_price_range(self, obj):
@ -790,6 +876,8 @@ class BomItemSerializer(InvenTreeModelSerializer):
# Annotated fields describing available quantity # Annotated fields describing available quantity
'available_stock', 'available_stock',
'available_substitute_stock', 'available_substitute_stock',
'available_variant_stock',
] ]

View File

@ -124,8 +124,7 @@
</div> </div>
</div> </div>
{% settings_value "PART_SHOW_PRICE_HISTORY" as show_price_history %} {% if part.purchaseable or part.salable %}
{% if show_price_history %}
<div class='panel panel-hidden' id='panel-pricing'> <div class='panel panel-hidden' id='panel-pricing'>
{% include "part/prices.html" %} {% include "part/prices.html" %}
</div> </div>
@ -1009,7 +1008,7 @@
pb_url_slug: 'internal-price', pb_url_slug: 'internal-price',
pb_url: '{% url 'api-part-internal-price-list' %}', pb_url: '{% url 'api-part-internal-price-list' %}',
pb_new_btn: $('#new-internal-price-break'), pb_new_btn: $('#new-internal-price-break'),
pb_new_url: '{% url 'internal-price-break-create' %}', pb_new_url: '{% url 'api-part-internal-price-list' %}',
linkedGraph: $('#InternalPriceBreakChart'), linkedGraph: $('#InternalPriceBreakChart'),
}, },
); );
@ -1025,7 +1024,7 @@
pb_url_slug: 'sale-price', pb_url_slug: 'sale-price',
pb_url: "{% url 'api-part-sale-price-list' %}", pb_url: "{% url 'api-part-sale-price-list' %}",
pb_new_btn: $('#new-price-break'), pb_new_btn: $('#new-price-break'),
pb_new_url: '{% url 'sale-price-break-create' %}', pb_new_url: '{% url 'api-part-sale-price-list' %}',
linkedGraph: $('#SalePriceBreakChart'), linkedGraph: $('#SalePriceBreakChart'),
}, },
); );

View File

@ -4,7 +4,6 @@
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %} {% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
{% settings_value 'PART_SHOW_RELATED' as show_related %} {% settings_value 'PART_SHOW_RELATED' as show_related %}
{% settings_value "PART_SHOW_PRICE_HISTORY" as show_price_history %}
{% trans "Parameters" as text %} {% trans "Parameters" as text %}
{% include "sidebar_item.html" with label="part-parameters" text=text icon="fa-th-list" %} {% include "sidebar_item.html" with label="part-parameters" text=text icon="fa-th-list" %}
@ -28,7 +27,7 @@
{% trans "Used In" as text %} {% trans "Used In" as text %}
{% include "sidebar_item.html" with label="used-in" text=text icon="fa-layer-group" %} {% include "sidebar_item.html" with label="used-in" text=text icon="fa-layer-group" %}
{% endif %} {% endif %}
{% if show_price_history %} {% if part.purchaseable or part.salable %}
{% trans "Pricing" as text %} {% trans "Pricing" as text %}
{% include "sidebar_item.html" with label="pricing" text=text icon="fa-dollar-sign" %} {% include "sidebar_item.html" with label="pricing" text=text icon="fa-dollar-sign" %}
{% endif %} {% endif %}

View File

@ -3,6 +3,9 @@
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% load inventree_extras %} {% load inventree_extras %}
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
{% if show_price_history %}
<div class='panel-heading'> <div class='panel-heading'>
<h4>{% trans "Pricing Information" %}</h4> <h4>{% trans "Pricing Information" %}</h4>
</div> </div>
@ -43,7 +46,7 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if part.bom_count > 0 %} {% if part.assembly and part.bom_count > 0 %}
{% if min_total_bom_price %} {% if min_total_bom_price %}
<tr> <tr>
<td><strong>{% trans 'BOM Pricing' %}</strong> <td><strong>{% trans 'BOM Pricing' %}</strong>
@ -147,7 +150,7 @@
</div> </div>
</div> </div>
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %} {% endif %}
{% if part.purchaseable and roles.purchase_order.view %} {% if part.purchaseable and roles.purchase_order.view %}
<a class="anchor" id="supplier-cost"></a> <a class="anchor" id="supplier-cost"></a>
@ -170,7 +173,7 @@
</div> </div>
</div> </div>
{% if price_history %} {% if show_price_history %}
<a class="anchor" id="purchase-price"></a> <a class="anchor" id="purchase-price"></a>
<div class='panel-heading'> <div class='panel-heading'>
<h4>{% trans "Purchase Price" %} <h4>{% trans "Purchase Price" %}
@ -279,6 +282,7 @@
</div> </div>
</div> </div>
{% if show_price_history %}
<a class="anchor" id="sale-price"></a> <a class="anchor" id="sale-price"></a>
<div class='panel-heading'> <div class='panel-heading'>
<h4>{% trans "Sale Price" %} <h4>{% trans "Sale Price" %}
@ -298,3 +302,5 @@
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
{% endif %}

View File

@ -658,6 +658,94 @@ class PartAPITest(InvenTreeAPITestCase):
self.assertEqual(len(response.data), 101) self.assertEqual(len(response.data), 101)
def test_variant_stock(self):
"""
Unit tests for the 'variant_stock' annotation,
which provides a stock count for *variant* parts
"""
# Ensure the MPTT structure is in a known state before running tests
Part.objects.rebuild()
# Initially, there are no "chairs" in stock,
# so each 'chair' template should report variant_stock=0
url = reverse('api-part-list')
# Look at the "detail" URL for the master chair template
response = self.get('/api/part/10000/', {}, expected_code=200)
# This part should report 'zero' as variant stock
self.assertEqual(response.data['variant_stock'], 0)
# Grab a list of all variant chairs *under* the master template
response = self.get(
url,
{
'ancestor': 10000,
},
expected_code=200,
)
# 4 total descendants
self.assertEqual(len(response.data), 4)
for variant in response.data:
self.assertEqual(variant['variant_stock'], 0)
# Now, let's make some variant stock
for variant in Part.objects.get(pk=10000).get_descendants(include_self=False):
StockItem.objects.create(
part=variant,
quantity=100,
)
response = self.get('/api/part/10000/', {}, expected_code=200)
self.assertEqual(response.data['in_stock'], 0)
self.assertEqual(response.data['variant_stock'], 400)
# Check that each variant reports the correct stock quantities
response = self.get(
url,
{
'ancestor': 10000,
},
expected_code=200,
)
expected_variant_stock = {
10001: 0,
10002: 0,
10003: 100,
10004: 0,
}
for variant in response.data:
self.assertEqual(variant['in_stock'], 100)
self.assertEqual(variant['variant_stock'], expected_variant_stock[variant['pk']])
# Add some 'sub variants' for the green chair variant
green_chair = Part.objects.get(pk=10004)
for i in range(10):
gcv = Part.objects.create(
name=f"GC Var {i}",
description="Green chair variant",
variant_of=green_chair,
)
StockItem.objects.create(
part=gcv,
quantity=50,
)
# Spot check of some values
response = self.get('/api/part/10000/', {})
self.assertEqual(response.data['variant_stock'], 900)
response = self.get('/api/part/10004/', {})
self.assertEqual(response.data['variant_stock'], 500)
class PartDetailTests(InvenTreeAPITestCase): class PartDetailTests(InvenTreeAPITestCase):
""" """
@ -1541,6 +1629,44 @@ class BomItemTest(InvenTreeAPITestCase):
self.assertEqual(len(response.data), i) self.assertEqual(len(response.data), i)
def test_bom_variant_stock(self):
"""
Test for 'available_variant_stock' annotation
"""
Part.objects.rebuild()
# BOM item we are interested in
bom_item = BomItem.objects.get(pk=1)
response = self.get('/api/bom/1/', {}, expected_code=200)
# Initially, no variant stock available
self.assertEqual(response.data['available_variant_stock'], 0)
# Create some 'variants' of the referenced sub_part
bom_item.sub_part.is_template = True
bom_item.sub_part.save()
for i in range(10):
# Create a variant part
vp = Part.objects.create(
name=f"Var {i}",
description="Variant part",
variant_of=bom_item.sub_part,
)
# Create a stock item
StockItem.objects.create(
part=vp,
quantity=100,
)
# There should now be variant stock available
response = self.get('/api/bom/1/', {}, expected_code=200)
self.assertEqual(response.data['available_variant_stock'], 1000)
class PartParameterTest(InvenTreeAPITestCase): class PartParameterTest(InvenTreeAPITestCase):
""" """

View File

@ -13,18 +13,6 @@ from django.conf.urls import url, include
from . import views from . import views
sale_price_break_urls = [
url(r'^new/', views.PartSalePriceBreakCreate.as_view(), name='sale-price-break-create'),
url(r'^(?P<pk>\d+)/edit/', views.PartSalePriceBreakEdit.as_view(), name='sale-price-break-edit'),
url(r'^(?P<pk>\d+)/delete/', views.PartSalePriceBreakDelete.as_view(), name='sale-price-break-delete'),
]
internal_price_break_urls = [
url(r'^new/', views.PartInternalPriceBreakCreate.as_view(), name='internal-price-break-create'),
url(r'^(?P<pk>\d+)/edit/', views.PartInternalPriceBreakEdit.as_view(), name='internal-price-break-edit'),
url(r'^(?P<pk>\d+)/delete/', views.PartInternalPriceBreakDelete.as_view(), name='internal-price-break-delete'),
]
part_parameter_urls = [ part_parameter_urls = [
url(r'^template/new/', views.PartParameterTemplateCreate.as_view(), name='part-param-template-create'), url(r'^template/new/', views.PartParameterTemplateCreate.as_view(), name='part-param-template-create'),
url(r'^template/(?P<pk>\d+)/edit/', views.PartParameterTemplateEdit.as_view(), name='part-param-template-edit'), url(r'^template/(?P<pk>\d+)/edit/', views.PartParameterTemplateEdit.as_view(), name='part-param-template-edit'),
@ -86,12 +74,6 @@ part_urls = [
# Part category # Part category
url(r'^category/', include(category_urls)), url(r'^category/', include(category_urls)),
# Part price breaks
url(r'^sale-price/', include(sale_price_break_urls)),
# Part internal price breaks
url(r'^internal-price/', include(internal_price_break_urls)),
# Part parameters # Part parameters
url(r'^parameter/', include(part_parameter_urls)), url(r'^parameter/', include(part_parameter_urls)),

View File

@ -18,7 +18,6 @@ from django.forms import HiddenInput
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from moneyed import CURRENCIES
from djmoney.contrib.exchange.models import convert_money from djmoney.contrib.exchange.models import convert_money
from djmoney.contrib.exchange.exceptions import MissingRate from djmoney.contrib.exchange.exceptions import MissingRate
@ -33,7 +32,6 @@ from decimal import Decimal
from .models import PartCategory, Part from .models import PartCategory, Part
from .models import PartParameterTemplate from .models import PartParameterTemplate
from .models import PartCategoryParameterTemplate from .models import PartCategoryParameterTemplate
from .models import PartSellPriceBreak, PartInternalPriceBreak
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
from company.models import SupplierPart from company.models import SupplierPart
@ -389,8 +387,12 @@ class PartDetail(InvenTreeRoleMixin, DetailView):
context.update(**ctx) context.update(**ctx)
show_price_history = InvenTreeSetting.get_setting('PART_SHOW_PRICE_HISTORY', False)
context['show_price_history'] = show_price_history
# Pricing information # Pricing information
if InvenTreeSetting.get_setting('PART_SHOW_PRICE_HISTORY', False): if show_price_history:
ctx = self.get_pricing(self.get_quantity()) ctx = self.get_pricing(self.get_quantity())
ctx['form'] = self.form_class(initial=self.get_initials()) ctx['form'] = self.form_class(initial=self.get_initials())
@ -1226,102 +1228,3 @@ class CategoryParameterTemplateDelete(AjaxDeleteView):
return None return None
return self.object return self.object
class PartSalePriceBreakCreate(AjaxCreateView):
"""
View for creating a sale price break for a part
"""
model = PartSellPriceBreak
form_class = part_forms.EditPartSalePriceBreakForm
ajax_form_title = _('Add Price Break')
def get_data(self):
return {
'success': _('Added new price break')
}
def get_part(self):
try:
part = Part.objects.get(id=self.request.GET.get('part'))
except (ValueError, Part.DoesNotExist):
part = None
if part is None:
try:
part = Part.objects.get(id=self.request.POST.get('part'))
except (ValueError, Part.DoesNotExist):
part = None
return part
def get_form(self):
form = super(AjaxCreateView, self).get_form()
form.fields['part'].widget = HiddenInput()
return form
def get_initial(self):
initials = super(AjaxCreateView, self).get_initial()
initials['part'] = self.get_part()
default_currency = inventree_settings.currency_code_default()
currency = CURRENCIES.get(default_currency, None)
if currency is not None:
initials['price'] = [1.0, currency]
return initials
class PartSalePriceBreakEdit(AjaxUpdateView):
""" View for editing a sale price break """
model = PartSellPriceBreak
form_class = part_forms.EditPartSalePriceBreakForm
ajax_form_title = _('Edit Price Break')
def get_form(self):
form = super().get_form()
form.fields['part'].widget = HiddenInput()
return form
class PartSalePriceBreakDelete(AjaxDeleteView):
""" View for deleting a sale price break """
model = PartSellPriceBreak
ajax_form_title = _("Delete Price Break")
ajax_template_name = "modal_delete_form.html"
class PartInternalPriceBreakCreate(PartSalePriceBreakCreate):
""" View for creating a internal price break for a part """
model = PartInternalPriceBreak
form_class = part_forms.EditPartInternalPriceBreakForm
ajax_form_title = _('Add Internal Price Break')
permission_required = 'roles.sales_order.add'
class PartInternalPriceBreakEdit(PartSalePriceBreakEdit):
""" View for editing a internal price break """
model = PartInternalPriceBreak
form_class = part_forms.EditPartInternalPriceBreakForm
ajax_form_title = _('Edit Internal Price Break')
permission_required = 'roles.sales_order.change'
class PartInternalPriceBreakDelete(PartSalePriceBreakDelete):
""" View for deleting a internal price break """
model = PartInternalPriceBreak
ajax_form_title = _("Delete Internal Price Break")
permission_required = 'roles.sales_order.delete'

View File

@ -0,0 +1,19 @@
# Generated by Django 3.2.12 on 2022-04-26 10:19
from django.db import migrations, models
import stock.models
class Migration(migrations.Migration):
dependencies = [
('stock', '0073_alter_stockitem_belongs_to'),
]
operations = [
migrations.AlterField(
model_name='stockitem',
name='batch',
field=models.CharField(blank=True, default=stock.models.generate_batch_code, help_text='Batch code for this stock item', max_length=100, null=True, verbose_name='Batch Code'),
),
]

View File

@ -8,6 +8,8 @@ from __future__ import unicode_literals
import os import os
from jinja2 import Template
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError, FieldError from django.core.exceptions import ValidationError, FieldError
from django.urls import reverse from django.urls import reverse
@ -213,6 +215,32 @@ class StockItemManager(TreeManager):
) )
def generate_batch_code():
"""
Generate a default 'batch code' for a new StockItem.
This uses the value of the 'STOCK_BATCH_CODE_TEMPLATE' setting (if configured),
which can be passed through a simple template.
"""
batch_template = common.models.InvenTreeSetting.get_setting('STOCK_BATCH_CODE_TEMPLATE', '')
now = datetime.now()
# Pass context data through to the template randering.
# The folowing context variables are availble for custom batch code generation
context = {
'date': now,
'year': now.year,
'month': now.month,
'day': now.day,
'hour': now.minute,
'minute': now.minute,
}
return Template(batch_template).render(context)
class StockItem(MPTTModel): class StockItem(MPTTModel):
""" """
A StockItem object represents a quantity of physical instances of a part. A StockItem object represents a quantity of physical instances of a part.
@ -644,7 +672,8 @@ class StockItem(MPTTModel):
batch = models.CharField( batch = models.CharField(
verbose_name=_('Batch Code'), verbose_name=_('Batch Code'),
max_length=100, blank=True, null=True, max_length=100, blank=True, null=True,
help_text=_('Batch code for this stock item') help_text=_('Batch code for this stock item'),
default=generate_batch_code,
) )
quantity = models.DecimalField( quantity = models.DecimalField(

View File

@ -11,6 +11,7 @@
<table class='table table-striped table-condensed'> <table class='table table-striped table-condensed'>
<tbody> <tbody>
{% include "InvenTree/settings/setting.html" with key="STOCK_BATCH_CODE_TEMPLATE" icon="fa-layer-group" %}
{% include "InvenTree/settings/setting.html" with key="STOCK_ENABLE_EXPIRY" icon="fa-stopwatch" %} {% include "InvenTree/settings/setting.html" with key="STOCK_ENABLE_EXPIRY" icon="fa-stopwatch" %}
{% include "InvenTree/settings/setting.html" with key="STOCK_STALE_DAYS" icon="fa-calendar" %} {% include "InvenTree/settings/setting.html" with key="STOCK_STALE_DAYS" icon="fa-calendar" %}
{% include "InvenTree/settings/setting.html" with key="STOCK_ALLOW_EXPIRED_SALE" icon="fa-truck" %} {% include "InvenTree/settings/setting.html" with key="STOCK_ALLOW_EXPIRED_SALE" icon="fa-truck" %}

View File

@ -807,15 +807,28 @@ function loadBomTable(table, options={}) {
var url = `/part/${row.sub_part_detail.pk}/?display=part-stock`; var url = `/part/${row.sub_part_detail.pk}/?display=part-stock`;
// Calculate total "available" (unallocated) quantity // Calculate total "available" (unallocated) quantity
var total = row.available_stock + row.available_substitute_stock; var base_stock = row.available_stock;
var substitute_stock = row.available_substitute_stock || 0;
var variant_stock = row.allow_variants ? (row.available_variant_stock || 0) : 0;
var text = `${total}`; var available_stock = base_stock + substitute_stock + variant_stock;
if (total <= 0) { var text = `${available_stock}`;
if (available_stock <= 0) {
text = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock Available" %}</span>`; text = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock Available" %}</span>`;
} else { } else {
if (row.available_substitute_stock > 0) { var extra = '';
text += `<span title='{% trans "Includes substitute stock" %}' class='fas fa-info-circle float-right icon-blue'></span>`; if ((substitute_stock > 0) && (variant_stock > 0)) {
extra = '{% trans "Includes variant and substitute stock" %}';
} else if (variant_stock > 0) {
extra = '{% trans "Includes variant stock" %}';
} else if (substitute_stock > 0) {
extra = '{% trans "Includes substitute stock" %}';
}
if (extra) {
text += `<span title='${extra}' class='fas fa-info-circle float-right icon-blue'></span>`;
} }
} }
@ -910,7 +923,7 @@ function loadBomTable(table, options={}) {
formatter: function(value, row) { formatter: function(value, row) {
var can_build = 0; var can_build = 0;
var available = row.available_stock + row.available_substitute_stock; var available = row.available_stock + (row.available_substitute_stock || 0) + (row.available_variant_stock || 0);
if (row.quantity > 0) { if (row.quantity > 0) {
can_build = available / row.quantity; can_build = available / row.quantity;

View File

@ -1425,19 +1425,36 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
title: '{% trans "Available" %}', title: '{% trans "Available" %}',
sortable: true, sortable: true,
formatter: function(value, row) { formatter: function(value, row) {
var total = row.available_stock + row.available_substitute_stock;
var text = `${total}`; var url = `/part/${row.sub_part_detail.pk}/?display=part-stock`;
if (total <= 0) { // Calculate total "available" (unallocated) quantity
var base_stock = row.available_stock;
var substitute_stock = row.available_substitute_stock || 0;
var variant_stock = row.allow_variants ? (row.available_variant_stock || 0) : 0;
var available_stock = base_stock + substitute_stock + variant_stock;
var text = `${available_stock}`;
if (available_stock <= 0) {
text = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock Available" %}</span>`; text = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock Available" %}</span>`;
} else { } else {
if (row.available_substitute_stock > 0) { var extra = '';
text += `<span title='{% trans "Includes substitute stock" %}' class='fas fa-info-circle float-right icon-blue'></span>`; if ((substitute_stock > 0) && (variant_stock > 0)) {
extra = '{% trans "Includes variant and substitute stock" %}';
} else if (variant_stock > 0) {
extra = '{% trans "Includes variant stock" %}';
} else if (substitute_stock > 0) {
extra = '{% trans "Includes substitute stock" %}';
}
if (extra) {
text += `<span title='${extra}' class='fas fa-info-circle float-right icon-blue'></span>`;
} }
} }
return text; return renderLink(text, url);
} }
}, },
{ {

View File

@ -672,7 +672,20 @@ function loadPartVariantTable(table, partId, options={}) {
field: 'in_stock', field: 'in_stock',
title: '{% trans "Stock" %}', title: '{% trans "Stock" %}',
formatter: function(value, row) { formatter: function(value, row) {
return renderLink(value, `/part/${row.pk}/?display=part-stock`);
var base_stock = row.in_stock;
var variant_stock = row.variant_stock || 0;
var total = base_stock + variant_stock;
var text = `${total}`;
if (variant_stock > 0) {
text = `<em>${text}</em>`;
text += `<span title='{% trans "Includes variant stock" %}' class='fas fa-info-circle float-right icon-blue'></span>`;
}
return renderLink(text, `/part/${row.pk}/?display=part-stock`);
} }
} }
]; ];
@ -1917,7 +1930,9 @@ function loadPriceBreakTable(table, options) {
formatNoMatches: function() { formatNoMatches: function() {
return `{% trans "No ${human_name} information found" %}`; return `{% trans "No ${human_name} information found" %}`;
}, },
queryParams: {part: options.part}, queryParams: {
part: options.part
},
url: options.url, url: options.url,
onLoadSuccess: function(tableData) { onLoadSuccess: function(tableData) {
if (linkedGraph) { if (linkedGraph) {
@ -2023,36 +2038,45 @@ function initPriceBreakSet(table, options) {
} }
pb_new_btn.click(function() { pb_new_btn.click(function() {
launchModalForm(pb_new_url,
{ constructForm(pb_new_url, {
success: reloadPriceBreakTable, fields: {
data: { part: {
part: part_id, hidden: true,
} value: part_id,
} },
); quantity: {},
price: {},
price_currency: {},
},
method: 'POST',
title: '{% trans "Add Price Break" %}',
onSuccess: reloadPriceBreakTable,
});
}); });
table.on('click', `.button-${pb_url_slug}-delete`, function() { table.on('click', `.button-${pb_url_slug}-delete`, function() {
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');
launchModalForm( constructForm(`${pb_url}${pk}/`, {
`/part/${pb_url_slug}/${pk}/delete/`, method: 'DELETE',
{ title: '{% trans "Delete Price Break" %}',
success: reloadPriceBreakTable onSuccess: reloadPriceBreakTable,
} });
);
}); });
table.on('click', `.button-${pb_url_slug}-edit`, function() { table.on('click', `.button-${pb_url_slug}-edit`, function() {
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');
launchModalForm( constructForm(`${pb_url}${pk}/`, {
`/part/${pb_url_slug}/${pk}/edit/`, fields: {
{ quantity: {},
success: reloadPriceBreakTable price: {},
} price_currency: {},
); },
title: '{% trans "Edit Price Break" %}',
onSuccess: reloadPriceBreakTable,
});
}); });
} }