mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Add some unit tests for the new endpoint
This commit is contained in:
parent
943b27e195
commit
31dbb9563b
@ -252,6 +252,9 @@ class StockHistoryCode(StatusCode):
|
|||||||
SPLIT_FROM_PARENT = 40
|
SPLIT_FROM_PARENT = 40
|
||||||
SPLIT_CHILD_ITEM = 42
|
SPLIT_CHILD_ITEM = 42
|
||||||
|
|
||||||
|
# Stock merging operations
|
||||||
|
MERGED_STOCK_ITEMS = 45
|
||||||
|
|
||||||
# Build order codes
|
# Build order codes
|
||||||
BUILD_OUTPUT_CREATED = 50
|
BUILD_OUTPUT_CREATED = 50
|
||||||
BUILD_OUTPUT_COMPLETED = 55
|
BUILD_OUTPUT_COMPLETED = 55
|
||||||
@ -288,6 +291,8 @@ class StockHistoryCode(StatusCode):
|
|||||||
SPLIT_FROM_PARENT: _('Split from parent item'),
|
SPLIT_FROM_PARENT: _('Split from parent item'),
|
||||||
SPLIT_CHILD_ITEM: _('Split child item'),
|
SPLIT_CHILD_ITEM: _('Split child item'),
|
||||||
|
|
||||||
|
MERGED_STOCK_ITEMS: _('Merged stock items'),
|
||||||
|
|
||||||
SENT_TO_CUSTOMER: _('Sent to customer'),
|
SENT_TO_CUSTOMER: _('Sent to customer'),
|
||||||
RETURNED_FROM_CUSTOMER: _('Returned from customer'),
|
RETURNED_FROM_CUSTOMER: _('Returned from customer'),
|
||||||
|
|
||||||
|
@ -114,19 +114,6 @@
|
|||||||
lft: 0
|
lft: 0
|
||||||
rght: 0
|
rght: 0
|
||||||
|
|
||||||
- model: stock.stockitem
|
|
||||||
pk: 501
|
|
||||||
fields:
|
|
||||||
part: 10001
|
|
||||||
location: 7
|
|
||||||
batch: "AAA"
|
|
||||||
quantity: 1
|
|
||||||
serial: 1
|
|
||||||
level: 0
|
|
||||||
tree_id: 0
|
|
||||||
lft: 0
|
|
||||||
rght: 0
|
|
||||||
|
|
||||||
- model: stock.stockitem
|
- model: stock.stockitem
|
||||||
pk: 501
|
pk: 501
|
||||||
fields:
|
fields:
|
||||||
|
@ -1153,18 +1153,51 @@ class StockItem(MPTTModel):
|
|||||||
result.stock_item = self
|
result.stock_item = self
|
||||||
result.save()
|
result.save()
|
||||||
|
|
||||||
def can_merge(self, other=None, raise_error=False):
|
def can_merge(self, other=None, raise_error=False, **kwargs):
|
||||||
"""
|
"""
|
||||||
Check if this stock item can be merged into another
|
Check if this stock item can be merged into another stock item
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
allow_mismatched_suppliers = kwargs.get('allow_mismatched_suppliers', False)
|
||||||
|
|
||||||
|
allow_mismatched_status = kwargs.get('allow_mismatched_status', False)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not self.in_stock:
|
# Generic checks (do not rely on the 'other' part)
|
||||||
raise ValidationError(_("Item must be in stock"))
|
if self.sales_order:
|
||||||
|
raise ValidationError(_('Stock item has been assigned to a sales order'))
|
||||||
|
|
||||||
|
if self.belongs_to:
|
||||||
|
raise ValidationError(_('Stock item is installed in another item'))
|
||||||
|
|
||||||
|
if self.customer:
|
||||||
|
raise ValidationError(_('Stock item has been assigned to a customer'))
|
||||||
|
|
||||||
|
if self.is_building:
|
||||||
|
raise ValidationError(_('Stock item is currently in production'))
|
||||||
|
|
||||||
if self.serialized:
|
if self.serialized:
|
||||||
raise ValidationError(_("Serialized stock cannot be merged"))
|
raise ValidationError(_("Serialized stock cannot be merged"))
|
||||||
|
|
||||||
|
if other:
|
||||||
|
# Specific checks (rely on the 'other' part)
|
||||||
|
|
||||||
|
# Prevent stock item being merged with itself
|
||||||
|
if self == other:
|
||||||
|
raise ValidationError(_('Duplicate stock items'))
|
||||||
|
|
||||||
|
# Base part must match
|
||||||
|
if self.part != other.part:
|
||||||
|
raise ValidationError(_("Stock items must refer to the same part"))
|
||||||
|
|
||||||
|
# Check if supplier part references match
|
||||||
|
if self.supplier_part != other.supplier_part and not allow_mismatched_suppliers:
|
||||||
|
raise ValidationError(_("Stock items must refer to the same supplier part"))
|
||||||
|
|
||||||
|
# Check if stock status codes match
|
||||||
|
if self.status != other.status and not allow_mismatched_status:
|
||||||
|
raise ValidationError(_("Stock status codes must match"))
|
||||||
|
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
if raise_error:
|
if raise_error:
|
||||||
raise e
|
raise e
|
||||||
@ -1174,7 +1207,7 @@ class StockItem(MPTTModel):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def merge_stock_item(self, other, **kwargs):
|
def merge_stock_items(self, other_items, raise_error=False, **kwargs):
|
||||||
"""
|
"""
|
||||||
Merge another stock item into this one; the two become one!
|
Merge another stock item into this one; the two become one!
|
||||||
|
|
||||||
@ -1185,15 +1218,48 @@ class StockItem(MPTTModel):
|
|||||||
- Any allocations (build order, sales order) are moved to this StockItem
|
- Any allocations (build order, sales order) are moved to this StockItem
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# If the stock item cannot be merged, return
|
if len(other_items) == 0:
|
||||||
if not self.can_merge(other):
|
|
||||||
return
|
return
|
||||||
|
|
||||||
user = kwargs.get('user', None)
|
user = kwargs.get('user', None)
|
||||||
location = kwargs.get('location', None)
|
location = kwargs.get('location', None)
|
||||||
|
notes = kwargs.get('notes', None)
|
||||||
|
|
||||||
# TODO: Merge!
|
for other in other_items:
|
||||||
|
# If the stock item cannot be merged, return
|
||||||
|
if not self.can_merge(other, raise_error=raise_error, **kwargs):
|
||||||
|
return
|
||||||
|
|
||||||
|
for other in other_items:
|
||||||
|
|
||||||
|
self.quantity += other.quantity
|
||||||
|
|
||||||
|
# Any "build order allocations" for the other item must be assigned to this one
|
||||||
|
for allocation in other.allocations.all():
|
||||||
|
|
||||||
|
allocation.stock_item = self
|
||||||
|
allocation.save()
|
||||||
|
|
||||||
|
# Any "sales order allocations" for the other item must be assigned to this one
|
||||||
|
for allocation in other.sales_order_allocations.all():
|
||||||
|
|
||||||
|
allocation.stock_item = self()
|
||||||
|
allocation.save()
|
||||||
|
|
||||||
|
# Delete the other stock item
|
||||||
|
other.delete()
|
||||||
|
|
||||||
|
self.add_tracking_entry(
|
||||||
|
StockHistoryCode.MERGED_STOCK_ITEMS,
|
||||||
|
user,
|
||||||
|
notes=notes,
|
||||||
|
deltas={
|
||||||
|
'location': location.pk,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.location = location
|
||||||
|
self.save()
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def splitStock(self, quantity, location, user, **kwargs):
|
def splitStock(self, quantity, location, user, **kwargs):
|
||||||
|
@ -701,6 +701,7 @@ class StockMergeItemSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
class StockMergeSerializer(serializers.Serializer):
|
class StockMergeSerializer(serializers.Serializer):
|
||||||
"""
|
"""
|
||||||
Serializer for merging two (or more) stock items together
|
Serializer for merging two (or more) stock items together
|
||||||
@ -710,6 +711,9 @@ class StockMergeSerializer(serializers.Serializer):
|
|||||||
fields = [
|
fields = [
|
||||||
'items',
|
'items',
|
||||||
'location',
|
'location',
|
||||||
|
'notes',
|
||||||
|
'allow_mismatched_suppliers',
|
||||||
|
'allow_mismatched_status',
|
||||||
]
|
]
|
||||||
|
|
||||||
items = StockMergeItemSerializer(
|
items = StockMergeItemSerializer(
|
||||||
@ -726,6 +730,25 @@ class StockMergeSerializer(serializers.Serializer):
|
|||||||
help_text=_('Destination stock location'),
|
help_text=_('Destination stock location'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
notes = serializers.CharField(
|
||||||
|
required=False,
|
||||||
|
allow_blank=True,
|
||||||
|
label=_('Notes'),
|
||||||
|
help_text=_('Stock merging notes'),
|
||||||
|
)
|
||||||
|
|
||||||
|
allow_mismatched_suppliers = serializers.BooleanField(
|
||||||
|
required=False,
|
||||||
|
label=_('Allow mismatched suppliers'),
|
||||||
|
help_text=_('Allow stock items with different supplier parts to be merged'),
|
||||||
|
)
|
||||||
|
|
||||||
|
allow_mismatched_status = serializers.BooleanField(
|
||||||
|
required=False,
|
||||||
|
label=_('Allow mismatched status'),
|
||||||
|
help_text=_('Allow stock items with different status codes to be merged'),
|
||||||
|
)
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
|
|
||||||
data = super().validate(data)
|
data = super().validate(data)
|
||||||
@ -735,11 +758,63 @@ class StockMergeSerializer(serializers.Serializer):
|
|||||||
if len(items) < 2:
|
if len(items) < 2:
|
||||||
raise ValidationError(_('At least two stock items must be provided'))
|
raise ValidationError(_('At least two stock items must be provided'))
|
||||||
|
|
||||||
|
unique_items = set()
|
||||||
|
|
||||||
|
# The "base item" is the first item
|
||||||
|
base_item = items[0]['item']
|
||||||
|
|
||||||
|
data['base_item'] = base_item
|
||||||
|
|
||||||
|
# Ensure stock items are unique!
|
||||||
|
for element in items:
|
||||||
|
item = element['item']
|
||||||
|
|
||||||
|
if item.pk in unique_items:
|
||||||
|
raise ValidationError(_('Duplicate stock items'))
|
||||||
|
|
||||||
|
unique_items.add(item.pk)
|
||||||
|
|
||||||
|
# Checks from here refer to the "base_item"
|
||||||
|
if item == base_item:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check that this item can be merged with the base_item
|
||||||
|
item.can_merge(
|
||||||
|
raise_error=True,
|
||||||
|
other=base_item,
|
||||||
|
allow_mismatched_suppliers=data.get('allow_mismatched_suppliers', False),
|
||||||
|
allow_mismatched_status=data.get('allow_mismatched_status', False),
|
||||||
|
)
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
# TODO
|
"""
|
||||||
pass
|
Actually perform the stock merging action.
|
||||||
|
At this point we are confident that the merge can take place
|
||||||
|
"""
|
||||||
|
|
||||||
|
data = self.validated_data
|
||||||
|
|
||||||
|
base_item = data['base_item']
|
||||||
|
items = data['items'][1:]
|
||||||
|
|
||||||
|
request = self.context['request']
|
||||||
|
user = getattr(request, 'user', None)
|
||||||
|
|
||||||
|
items = []
|
||||||
|
|
||||||
|
for item in data['items'][1:]:
|
||||||
|
items.append(item['item'])
|
||||||
|
|
||||||
|
base_item.merge_stock_items(
|
||||||
|
items,
|
||||||
|
allow_mismatched_suppliers=data.get('allow_mismatched_suppliers', False),
|
||||||
|
allow_mismatched_status=data.get('allow_mismatched_status', False),
|
||||||
|
user=user,
|
||||||
|
location=data['location'],
|
||||||
|
notes=data.get('notes', None)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class StockAdjustmentItemSerializer(serializers.Serializer):
|
class StockAdjustmentItemSerializer(serializers.Serializer):
|
||||||
|
@ -842,3 +842,189 @@ class StockAssignTest(StockAPITestCase):
|
|||||||
|
|
||||||
# 5 stock items should now have been assigned to this customer
|
# 5 stock items should now have been assigned to this customer
|
||||||
self.assertEqual(customer.assigned_stock.count(), 5)
|
self.assertEqual(customer.assigned_stock.count(), 5)
|
||||||
|
|
||||||
|
|
||||||
|
class StockMergeTest(StockAPITestCase):
|
||||||
|
"""
|
||||||
|
Unit tests for merging stock items via the API
|
||||||
|
"""
|
||||||
|
|
||||||
|
URL = reverse('api-stock-merge')
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
self.part = part.models.Part.objects.get(pk=25)
|
||||||
|
self.loc = StockLocation.objects.get(pk=1)
|
||||||
|
self.sp_1 = company.models.SupplierPart.objects.get(pk=100)
|
||||||
|
self.sp_2 = company.models.SupplierPart.objects.get(pk=101)
|
||||||
|
|
||||||
|
self.item_1 = StockItem.objects.create(
|
||||||
|
part=self.part,
|
||||||
|
supplier_part=self.sp_1,
|
||||||
|
quantity=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.item_2 = StockItem.objects.create(
|
||||||
|
part=self.part,
|
||||||
|
supplier_part=self.sp_2,
|
||||||
|
quantity=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.item_3 = StockItem.objects.create(
|
||||||
|
part=self.part,
|
||||||
|
supplier_part=self.sp_2,
|
||||||
|
quantity=50,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_missing_data(self):
|
||||||
|
"""
|
||||||
|
Test responses which are missing required data
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Post completely empty
|
||||||
|
|
||||||
|
data = self.post(
|
||||||
|
self.URL,
|
||||||
|
{},
|
||||||
|
expected_code=400
|
||||||
|
).data
|
||||||
|
|
||||||
|
self.assertIn('This field is required', str(data['items']))
|
||||||
|
self.assertIn('This field is required', str(data['location']))
|
||||||
|
|
||||||
|
# Post with a location and empty items list
|
||||||
|
data = self.post(
|
||||||
|
self.URL,
|
||||||
|
{
|
||||||
|
'items': [],
|
||||||
|
'location': 1,
|
||||||
|
},
|
||||||
|
expected_code=400
|
||||||
|
).data
|
||||||
|
|
||||||
|
self.assertIn('At least two stock items', str(data))
|
||||||
|
|
||||||
|
def test_invalid_data(self):
|
||||||
|
"""
|
||||||
|
Test responses which have invalid data
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Serialized stock items should be rejected
|
||||||
|
data = self.post(
|
||||||
|
self.URL,
|
||||||
|
{
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'item': 501,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item': 502,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'location': 1,
|
||||||
|
},
|
||||||
|
expected_code=400,
|
||||||
|
).data
|
||||||
|
|
||||||
|
self.assertIn('Serialized stock cannot be merged', str(data))
|
||||||
|
|
||||||
|
# Prevent item duplication
|
||||||
|
|
||||||
|
data = self.post(
|
||||||
|
self.URL,
|
||||||
|
{
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'item': 11,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item': 11,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'location': 1,
|
||||||
|
},
|
||||||
|
expected_code=400,
|
||||||
|
).data
|
||||||
|
|
||||||
|
self.assertIn('Duplicate stock items', str(data))
|
||||||
|
|
||||||
|
# Check for mismatching stock items
|
||||||
|
data = self.post(
|
||||||
|
self.URL,
|
||||||
|
{
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'item': 1234,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item': 11,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'location': 1,
|
||||||
|
},
|
||||||
|
expected_code=400,
|
||||||
|
).data
|
||||||
|
|
||||||
|
self.assertIn('Stock items must refer to the same part', str(data))
|
||||||
|
|
||||||
|
# Check for mismatching supplier parts
|
||||||
|
payload = {
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'item': self.item_1.pk,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item': self.item_2.pk,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'location': 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
data = self.post(
|
||||||
|
self.URL,
|
||||||
|
payload,
|
||||||
|
expected_code=400,
|
||||||
|
).data
|
||||||
|
|
||||||
|
self.assertIn('Stock items must refer to the same supplier part', str(data))
|
||||||
|
|
||||||
|
def test_valid_merge(self):
|
||||||
|
"""
|
||||||
|
Test valid merging of stock items
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Check initial conditions
|
||||||
|
n = StockItem.objects.filter(part=self.part).count()
|
||||||
|
self.assertEqual(self.item_1.quantity, 100)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'item': self.item_1.pk,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item': self.item_2.pk,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item': self.item_3.pk,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'location': 1,
|
||||||
|
'allow_mismatched_suppliers': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
self.post(
|
||||||
|
self.URL,
|
||||||
|
payload,
|
||||||
|
expected_code=201,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.item_1.refresh_from_db()
|
||||||
|
|
||||||
|
# Stock quantity should have been increased!
|
||||||
|
self.assertEqual(self.item_1.quantity, 250)
|
||||||
|
|
||||||
|
# Total number of stock items has been reduced!
|
||||||
|
self.assertEqual(StockItem.objects.filter(part=self.part).count(), n - 2)
|
||||||
|
Loading…
Reference in New Issue
Block a user