2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-19 05:25:42 +00:00

Merge branch 'inventree:master' into matmair/issue2385

This commit is contained in:
Matthias Mair
2022-04-05 02:05:05 +02:00
committed by GitHub
16 changed files with 492 additions and 76 deletions

View File

@ -798,6 +798,20 @@ class PartFilter(rest_filters.FilterSet):
return queryset
# unallocated_stock filter
unallocated_stock = rest_filters.BooleanFilter(label='Unallocated stock', method='filter_unallocated_stock')
def filter_unallocated_stock(self, queryset, name, value):
value = str2bool(value)
if value:
queryset = queryset.filter(Q(unallocated_stock__gt=0))
else:
queryset = queryset.filter(Q(unallocated_stock__lte=0))
return queryset
is_template = rest_filters.BooleanFilter()
assembly = rest_filters.BooleanFilter()
@ -1334,6 +1348,7 @@ class PartList(generics.ListCreateAPIView):
'creation_date',
'IPN',
'in_stock',
'unallocated_stock',
'category',
]

View File

@ -38,3 +38,11 @@
part: 1
sub_part: 5
quantity: 3
# Make "Assembly" from "Bob"
- model: part.bomitem
pk: 6
fields:
part: 101
sub_part: 100
quantity: 10

View File

@ -108,6 +108,18 @@
lft: 0
rght: 0
- model: part.part
pk: 101
fields:
name: 'Assembly'
description: 'A high level assembly'
salable: true
active: True
tree_id: 0
level: 0
lft: 0
rght: 0
# A 'template' part
- model: part.part
pk: 10000

View File

@ -1345,7 +1345,8 @@ class Part(MPTTModel):
queryset = OrderModels.SalesOrderAllocation.objects.filter(item__part__id=self.id)
pending = kwargs.get('pending', None)
# Default behaviour is to only return *pending* allocations
pending = kwargs.get('pending', True)
if pending is True:
# Look only for 'open' orders which have not shipped
@ -1433,7 +1434,7 @@ class Part(MPTTModel):
- If this part is a "template" (variants exist) then these are counted too
"""
return self.get_stock_count()
return self.get_stock_count(include_variants=True)
def get_bom_item_filter(self, include_inherited=True):
"""

View File

@ -7,7 +7,7 @@ from decimal import Decimal
from django.urls import reverse_lazy
from django.db import models, transaction
from django.db.models import Q
from django.db.models import ExpressionWrapper, F, Q
from django.db.models.functions import Coalesce
from django.utils.translation import ugettext_lazy as _
@ -24,7 +24,10 @@ from InvenTree.serializers import (DataFileUploadSerializer,
InvenTreeAttachmentSerializer,
InvenTreeMoneySerializer)
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
from InvenTree.status_codes import (BuildStatus,
PurchaseOrderStatus,
SalesOrderStatus)
from stock.models import StockItem
from .models import (BomItem, BomItemSubstitute,
@ -363,6 +366,51 @@ class PartSerializer(InvenTreeModelSerializer):
),
)
"""
Annotate with the number of stock items allocated to sales orders.
This annotation is modeled on Part.sales_order_allocations() method:
- Only look for "open" orders
- Stock items have not been "shipped"
"""
so_allocation_filter = Q(
line__order__status__in=SalesOrderStatus.OPEN, # LineItem points to an OPEN order
shipment__shipment_date=None, # Allocated item has *not* been shipped out
)
queryset = queryset.annotate(
allocated_to_sales_orders=Coalesce(
SubquerySum('stock_items__sales_order_allocations__quantity', filter=so_allocation_filter),
Decimal(0),
output_field=models.DecimalField(),
)
)
"""
Annotate with the number of stock items allocated to build orders.
This annotation is modeled on Part.build_order_allocations() method
"""
bo_allocation_filter = Q(
build__status__in=BuildStatus.ACTIVE_CODES,
)
queryset = queryset.annotate(
allocated_to_build_orders=Coalesce(
SubquerySum('stock_items__allocations__quantity', filter=bo_allocation_filter),
Decimal(0),
output_field=models.DecimalField(),
)
)
# Annotate with the total 'available stock' quantity
# This is the current stock, minus any allocations
queryset = queryset.annotate(
unallocated_stock=ExpressionWrapper(
F('in_stock') - F('allocated_to_sales_orders') - F('allocated_to_build_orders'),
output_field=models.DecimalField(),
)
)
return queryset
def get_starred(self, part):
@ -376,9 +424,12 @@ class PartSerializer(InvenTreeModelSerializer):
category_detail = CategorySerializer(source='category', many=False, read_only=True)
# Calculated fields
allocated_to_build_orders = serializers.FloatField(read_only=True)
allocated_to_sales_orders = serializers.FloatField(read_only=True)
unallocated_stock = serializers.FloatField(read_only=True)
building = serializers.FloatField(read_only=True)
in_stock = serializers.FloatField(read_only=True)
ordering = serializers.FloatField(read_only=True)
building = serializers.FloatField(read_only=True)
stock_item_count = serializers.IntegerField(read_only=True)
suppliers = serializers.IntegerField(read_only=True)
@ -399,7 +450,8 @@ class PartSerializer(InvenTreeModelSerializer):
partial = True
fields = [
'active',
'allocated_to_build_orders',
'allocated_to_sales_orders',
'assembly',
'category',
'category_detail',
@ -430,6 +482,7 @@ class PartSerializer(InvenTreeModelSerializer):
'suppliers',
'thumbnail',
'trackable',
'unallocated_stock',
'units',
'variant_of',
'virtual',

View File

@ -9,7 +9,7 @@ from rest_framework import status
from rest_framework.test import APIClient
from InvenTree.api_tester import InvenTreeAPITestCase
from InvenTree.status_codes import StockStatus
from InvenTree.status_codes import BuildStatus, StockStatus
from part.models import Part, PartCategory
from part.models import BomItem, BomItemSubstitute
@ -17,6 +17,9 @@ from stock.models import StockItem, StockLocation
from company.models import Company
from common.models import InvenTreeSetting
import build.models
import order.models
class PartOptionsAPITest(InvenTreeAPITestCase):
"""
@ -247,7 +250,7 @@ class PartAPITest(InvenTreeAPITestCase):
data = {'cascade': True}
response = self.client.get(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 13)
self.assertEqual(len(response.data), Part.objects.count())
def test_get_parts_by_cat(self):
url = reverse('api-part-list')
@ -815,6 +818,10 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
'location',
'bom',
'test_templates',
'build',
'location',
'stock',
'sales_order',
]
roles = [
@ -826,6 +833,9 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
super().setUp()
# Ensure the part "variant" tree is correctly structured
Part.objects.rebuild()
# Add a new part
self.part = Part.objects.create(
name='Banana',
@ -880,6 +890,153 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
self.assertEqual(data['in_stock'], 1100)
self.assertEqual(data['stock_item_count'], 105)
def test_allocation_annotations(self):
"""
Tests for query annotations which add allocation information.
Ref: https://github.com/inventree/InvenTree/pull/2797
"""
# We are looking at Part ID 100 ("Bob")
url = reverse('api-part-detail', kwargs={'pk': 100})
part = Part.objects.get(pk=100)
response = self.get(url, expected_code=200)
# Check that the expected annotated fields exist in the data
data = response.data
self.assertEqual(data['allocated_to_build_orders'], 0)
self.assertEqual(data['allocated_to_sales_orders'], 0)
# The unallocated stock count should equal the 'in stock' coutn
in_stock = data['in_stock']
self.assertEqual(in_stock, 126)
self.assertEqual(data['unallocated_stock'], in_stock)
# Check that model functions return the same values
self.assertEqual(part.build_order_allocation_count(), 0)
self.assertEqual(part.sales_order_allocation_count(), 0)
self.assertEqual(part.total_stock, in_stock)
self.assertEqual(part.available_stock, in_stock)
# Now, let's create a sales order, and allocate some stock
so = order.models.SalesOrder.objects.create(
reference='001',
customer=Company.objects.get(pk=1),
)
# We wish to send 50 units of "Bob" against this sales order
line = order.models.SalesOrderLineItem.objects.create(
quantity=50,
order=so,
part=part,
)
# Create a shipment against the order
shipment_1 = order.models.SalesOrderShipment.objects.create(
order=so,
reference='001',
)
shipment_2 = order.models.SalesOrderShipment.objects.create(
order=so,
reference='002',
)
# Allocate stock items to this order, against multiple shipments
order.models.SalesOrderAllocation.objects.create(
line=line,
shipment=shipment_1,
item=StockItem.objects.get(pk=1007),
quantity=17
)
order.models.SalesOrderAllocation.objects.create(
line=line,
shipment=shipment_1,
item=StockItem.objects.get(pk=1008),
quantity=18
)
order.models.SalesOrderAllocation.objects.create(
line=line,
shipment=shipment_2,
item=StockItem.objects.get(pk=1006),
quantity=15,
)
# Submit the API request again - should show us the sales order allocation
data = self.get(url, expected_code=200).data
self.assertEqual(data['allocated_to_sales_orders'], 50)
self.assertEqual(data['in_stock'], 126)
self.assertEqual(data['unallocated_stock'], 76)
# Now, "ship" the first shipment (so the stock is not 'in stock' any more)
shipment_1.complete_shipment(None)
# Refresh the API data
data = self.get(url, expected_code=200).data
self.assertEqual(data['allocated_to_build_orders'], 0)
self.assertEqual(data['allocated_to_sales_orders'], 15)
self.assertEqual(data['in_stock'], 91)
self.assertEqual(data['unallocated_stock'], 76)
# Next, we create a build order and allocate stock against it
bo = build.models.Build.objects.create(
part=Part.objects.get(pk=101),
quantity=10,
title='Making some assemblies',
status=BuildStatus.PRODUCTION,
)
bom_item = BomItem.objects.get(pk=6)
# Allocate multiple stock items against this build order
build.models.BuildItem.objects.create(
build=bo,
bom_item=bom_item,
stock_item=StockItem.objects.get(pk=1000),
quantity=10,
)
# Request data once more
data = self.get(url, expected_code=200).data
self.assertEqual(data['allocated_to_build_orders'], 10)
self.assertEqual(data['allocated_to_sales_orders'], 15)
self.assertEqual(data['in_stock'], 91)
self.assertEqual(data['unallocated_stock'], 66)
# Again, check that the direct model functions return the same values
self.assertEqual(part.build_order_allocation_count(), 10)
self.assertEqual(part.sales_order_allocation_count(), 15)
self.assertEqual(part.total_stock, 91)
self.assertEqual(part.available_stock, 66)
# Allocate further stock against the build
build.models.BuildItem.objects.create(
build=bo,
bom_item=bom_item,
stock_item=StockItem.objects.get(pk=1001),
quantity=10,
)
# Request data once more
data = self.get(url, expected_code=200).data
self.assertEqual(data['allocated_to_build_orders'], 20)
self.assertEqual(data['allocated_to_sales_orders'], 15)
self.assertEqual(data['in_stock'], 91)
self.assertEqual(data['unallocated_stock'], 56)
# Again, check that the direct model functions return the same values
self.assertEqual(part.build_order_allocation_count(), 20)
self.assertEqual(part.sales_order_allocation_count(), 15)
self.assertEqual(part.total_stock, 91)
self.assertEqual(part.available_stock, 56)
class BomItemTest(InvenTreeAPITestCase):
"""

View File

@ -46,7 +46,7 @@ class BomItemTest(TestCase):
# TODO: Tests for multi-level BOMs
def test_used_in(self):
self.assertEqual(self.bob.used_in_count, 0)
self.assertEqual(self.bob.used_in_count, 1)
self.assertEqual(self.orphan.used_in_count, 1)
def test_self_reference(self):