mirror of
https://github.com/inventree/InvenTree.git
synced 2025-07-01 11:10:54 +00:00
Merge branch 'master' of https://github.com/inventree/InvenTree into sn-append
This commit is contained in:
@ -180,6 +180,20 @@ class StockAssign(generics.CreateAPIView):
|
||||
return ctx
|
||||
|
||||
|
||||
class StockMerge(generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint for merging multiple stock items
|
||||
"""
|
||||
|
||||
queryset = StockItem.objects.none()
|
||||
serializer_class = StockSerializers.StockMergeSerializer
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['request'] = self.request
|
||||
return ctx
|
||||
|
||||
|
||||
class StockLocationList(generics.ListCreateAPIView):
|
||||
"""
|
||||
API endpoint for list view of StockLocation objects:
|
||||
@ -1214,6 +1228,7 @@ stock_api_urls = [
|
||||
url(r'^remove/', StockRemove.as_view(), name='api-stock-remove'),
|
||||
url(r'^transfer/', StockTransfer.as_view(), name='api-stock-transfer'),
|
||||
url(r'^assign/', StockAssign.as_view(), name='api-stock-assign'),
|
||||
url(r'^merge/', StockMerge.as_view(), name='api-stock-merge'),
|
||||
|
||||
# StockItemAttachment API endpoints
|
||||
url(r'^attachment/', include([
|
||||
|
@ -114,19 +114,6 @@
|
||||
lft: 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
|
||||
pk: 501
|
||||
fields:
|
||||
|
@ -0,0 +1,19 @@
|
||||
# Generated by Django 3.2.10 on 2021-12-20 21:49
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stock', '0072_remove_stockitem_scheduled_for_deletion'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='stockitem',
|
||||
name='belongs_to',
|
||||
field=models.ForeignKey(blank=True, help_text='Is this item installed in another item?', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='installed_parts', to='stock.stockitem', verbose_name='Installed In'),
|
||||
),
|
||||
]
|
@ -455,6 +455,7 @@ class StockItem(MPTTModel):
|
||||
|
||||
uid = models.CharField(blank=True, max_length=128, help_text=("Unique identifier field"))
|
||||
|
||||
# Note: When a StockItem is deleted, a pre_delete signal handles the parent/child relationship
|
||||
parent = TreeForeignKey(
|
||||
'self',
|
||||
verbose_name=_('Parent Stock Item'),
|
||||
@ -477,6 +478,7 @@ class StockItem(MPTTModel):
|
||||
help_text=_('Select a matching supplier part for this stock item')
|
||||
)
|
||||
|
||||
# Note: When a StockLocation is deleted, stock items are updated via a signal
|
||||
location = TreeForeignKey(
|
||||
StockLocation, on_delete=models.DO_NOTHING,
|
||||
verbose_name=_('Stock Location'),
|
||||
@ -492,10 +494,11 @@ class StockItem(MPTTModel):
|
||||
help_text=_('Packaging this stock item is stored in')
|
||||
)
|
||||
|
||||
# When deleting a stock item with installed items, those installed items are also installed
|
||||
belongs_to = models.ForeignKey(
|
||||
'self',
|
||||
verbose_name=_('Installed In'),
|
||||
on_delete=models.DO_NOTHING,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='installed_parts', blank=True, null=True,
|
||||
help_text=_('Is this item installed in another item?')
|
||||
)
|
||||
@ -800,14 +803,14 @@ class StockItem(MPTTModel):
|
||||
def can_delete(self):
|
||||
""" Can this stock item be deleted? It can NOT be deleted under the following circumstances:
|
||||
|
||||
- Has child StockItems
|
||||
- Has installed stock items
|
||||
- Has a serial number and is tracked
|
||||
- Is installed inside another StockItem
|
||||
- It has been assigned to a SalesOrder
|
||||
- It has been assigned to a BuildOrder
|
||||
"""
|
||||
|
||||
if self.child_count > 0:
|
||||
if self.installed_item_count() > 0:
|
||||
return False
|
||||
|
||||
if self.part.trackable and self.serial is not None:
|
||||
@ -853,20 +856,13 @@ class StockItem(MPTTModel):
|
||||
|
||||
return installed
|
||||
|
||||
def installedItemCount(self):
|
||||
def installed_item_count(self):
|
||||
"""
|
||||
Return the number of stock items installed inside this one.
|
||||
"""
|
||||
|
||||
return self.installed_parts.count()
|
||||
|
||||
def hasInstalledItems(self):
|
||||
"""
|
||||
Returns true if this stock item has other stock items installed in it.
|
||||
"""
|
||||
|
||||
return self.installedItemCount() > 0
|
||||
|
||||
@transaction.atomic
|
||||
def installStockItem(self, other_item, quantity, user, notes):
|
||||
"""
|
||||
@ -1153,6 +1149,124 @@ class StockItem(MPTTModel):
|
||||
result.stock_item = self
|
||||
result.save()
|
||||
|
||||
def can_merge(self, other=None, raise_error=False, **kwargs):
|
||||
"""
|
||||
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:
|
||||
# Generic checks (do not rely on the 'other' part)
|
||||
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.installed_item_count() > 0:
|
||||
raise ValidationError(_('Stock item contains other items'))
|
||||
|
||||
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:
|
||||
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:
|
||||
if raise_error:
|
||||
raise e
|
||||
else:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@transaction.atomic
|
||||
def merge_stock_items(self, other_items, raise_error=False, **kwargs):
|
||||
"""
|
||||
Merge another stock item into this one; the two become one!
|
||||
|
||||
*This* stock item subsumes the other, which is essentially deleted:
|
||||
|
||||
- The quantity of this StockItem is increased
|
||||
- Tracking history for the *other* item is deleted
|
||||
- Any allocations (build order, sales order) are moved to this StockItem
|
||||
"""
|
||||
|
||||
if len(other_items) == 0:
|
||||
return
|
||||
|
||||
user = kwargs.get('user', None)
|
||||
location = kwargs.get('location', None)
|
||||
notes = kwargs.get('notes', None)
|
||||
|
||||
parent_id = self.parent.pk if self.parent else None
|
||||
|
||||
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()
|
||||
|
||||
# Prevent atomicity issues when we are merging our own "parent" part in
|
||||
if parent_id and parent_id == other.pk:
|
||||
self.parent = None
|
||||
self.save()
|
||||
|
||||
other.delete()
|
||||
|
||||
self.add_tracking_entry(
|
||||
StockHistoryCode.MERGED_STOCK_ITEMS,
|
||||
user,
|
||||
quantity=self.quantity,
|
||||
notes=notes,
|
||||
deltas={
|
||||
'location': location.pk,
|
||||
}
|
||||
)
|
||||
|
||||
self.location = location
|
||||
self.save()
|
||||
|
||||
@transaction.atomic
|
||||
def splitStock(self, quantity, location, user, **kwargs):
|
||||
""" Split this stock item into two items, in the same location.
|
||||
@ -1648,7 +1762,8 @@ class StockItem(MPTTModel):
|
||||
|
||||
@receiver(pre_delete, sender=StockItem, dispatch_uid='stock_item_pre_delete_log')
|
||||
def before_delete_stock_item(sender, instance, using, **kwargs):
|
||||
""" Receives pre_delete signal from StockItem object.
|
||||
"""
|
||||
Receives pre_delete signal from StockItem object.
|
||||
|
||||
Before a StockItem is deleted, ensure that each child object is updated,
|
||||
to point to the new parent item.
|
||||
|
@ -675,6 +675,149 @@ class StockAssignmentSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
|
||||
class StockMergeItemSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for a single StockItem within the StockMergeSerializer class.
|
||||
|
||||
Here, the individual StockItem is being checked for merge compatibility.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
fields = [
|
||||
'item',
|
||||
]
|
||||
|
||||
item = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockItem.objects.all(),
|
||||
many=False,
|
||||
allow_null=False,
|
||||
required=True,
|
||||
label=_('Stock Item'),
|
||||
)
|
||||
|
||||
def validate_item(self, item):
|
||||
|
||||
# Check that the stock item is able to be merged
|
||||
item.can_merge(raise_error=True)
|
||||
|
||||
return item
|
||||
|
||||
|
||||
class StockMergeSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for merging two (or more) stock items together
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
fields = [
|
||||
'items',
|
||||
'location',
|
||||
'notes',
|
||||
'allow_mismatched_suppliers',
|
||||
'allow_mismatched_status',
|
||||
]
|
||||
|
||||
items = StockMergeItemSerializer(
|
||||
many=True,
|
||||
required=True,
|
||||
)
|
||||
|
||||
location = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockLocation.objects.all(),
|
||||
many=False,
|
||||
required=True,
|
||||
allow_null=False,
|
||||
label=_('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):
|
||||
|
||||
data = super().validate(data)
|
||||
|
||||
items = data['items']
|
||||
|
||||
if len(items) < 2:
|
||||
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
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
Serializer for a single StockItem within a stock adjument request.
|
||||
@ -838,7 +981,7 @@ class StockTransferSerializer(StockAdjustmentSerializer):
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
super().validate(data)
|
||||
data = super().validate(data)
|
||||
|
||||
# TODO: Any specific validation of location field?
|
||||
|
||||
|
@ -274,14 +274,6 @@
|
||||
<div class='alert alert-block alert-warning'>
|
||||
{% trans "This stock item is serialized - it has a unique serial number and the quantity cannot be adjusted." %}
|
||||
</div>
|
||||
{% elif item.child_count > 0 %}
|
||||
<div class='alert alert-block alert-warning'>
|
||||
{% trans "This stock item cannot be deleted as it has child items" %}
|
||||
</div>
|
||||
{% elif item.delete_on_deplete and item.can_delete %}
|
||||
<div class='alert alert-block alert-warning'>
|
||||
{% trans "This stock item will be automatically deleted when all stock is depleted." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
@ -842,3 +842,189 @@ class StockAssignTest(StockAPITestCase):
|
||||
|
||||
# 5 stock items should now have been assigned to this customer
|
||||
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)
|
||||
|
Reference in New Issue
Block a user