mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-18 21:15:41 +00:00
Stock Transfer Improvements (#8570)
* Allow transfer of items independent of status marker * Update test * Display errors in stock transsfer form * Add option to set status when transferring stock * Fix inStock check for stock actions * Allow adjustment of status when counting stock item * Allow status adjustment for other actions: - Remove stock - Add stock * Revert error behavior * Enhanced unit test * Unit test fix * Bump API version * Fix for playwright test - Added helper func * Extend playwright tests for stock actions
This commit is contained in:
@ -1,13 +1,16 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 288
|
||||
INVENTREE_API_VERSION = 289
|
||||
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v289 - 2024-11-27 : https://github.com/inventree/InvenTree/pull/8570
|
||||
- Enable status change when transferring stock items
|
||||
|
||||
v288 - 2024-11-27 : https://github.com/inventree/InvenTree/pull/8574
|
||||
- Adds "consumed" filter to StockItem API
|
||||
|
||||
|
@ -1489,12 +1489,15 @@ class StockItem(
|
||||
"""
|
||||
return self.children.count()
|
||||
|
||||
@property
|
||||
def in_stock(self) -> bool:
|
||||
"""Returns True if this item is in stock.
|
||||
def is_in_stock(self, check_status: bool = True):
|
||||
"""Return True if this StockItem is "in stock".
|
||||
|
||||
See also: StockItem.IN_STOCK_FILTER for the db optimized version of this check.
|
||||
Args:
|
||||
check_status: If True, check the status of the StockItem. Defaults to True.
|
||||
"""
|
||||
if check_status and self.status not in StockStatusGroups.AVAILABLE_CODES:
|
||||
return False
|
||||
|
||||
return all([
|
||||
self.quantity > 0, # Quantity must be greater than zero
|
||||
self.sales_order is None, # Not assigned to a SalesOrder
|
||||
@ -1502,9 +1505,16 @@ class StockItem(
|
||||
self.customer is None, # Not assigned to a customer
|
||||
self.consumed_by is None, # Not consumed by a build
|
||||
not self.is_building, # Not part of an active build
|
||||
self.status in StockStatusGroups.AVAILABLE_CODES, # Status is "available"
|
||||
])
|
||||
|
||||
@property
|
||||
def in_stock(self) -> bool:
|
||||
"""Returns True if this item is in stock.
|
||||
|
||||
See also: StockItem.IN_STOCK_FILTER for the db optimized version of this check.
|
||||
"""
|
||||
return self.is_in_stock(check_status=True)
|
||||
|
||||
@property
|
||||
def can_adjust_location(self):
|
||||
"""Returns True if the stock location can be "adjusted" for this part.
|
||||
@ -2073,14 +2083,13 @@ class StockItem(
|
||||
'STOCK_ALLOW_OUT_OF_STOCK_TRANSFER', backup_value=False, cache=False
|
||||
)
|
||||
|
||||
if not allow_out_of_stock_transfer and not self.in_stock:
|
||||
if not allow_out_of_stock_transfer and not self.is_in_stock(check_status=False):
|
||||
raise ValidationError(_('StockItem cannot be moved as it is not in stock'))
|
||||
|
||||
if quantity <= 0:
|
||||
return False
|
||||
|
||||
if location is None:
|
||||
# TODO - Raise appropriate error (cannot move to blank location)
|
||||
return False
|
||||
|
||||
# Test for a partial movement
|
||||
@ -2161,11 +2170,16 @@ class StockItem(
|
||||
return True
|
||||
|
||||
@transaction.atomic
|
||||
def stocktake(self, count, user, notes=''):
|
||||
def stocktake(self, count, user, **kwargs):
|
||||
"""Perform item stocktake.
|
||||
|
||||
When the quantity of an item is counted,
|
||||
record the date of stocktake
|
||||
Arguments:
|
||||
count: The new quantity of the item
|
||||
user: The user performing the stocktake
|
||||
|
||||
Keyword Arguments:
|
||||
notes: Optional notes for the stocktake
|
||||
status: Optionally adjust the stock status
|
||||
"""
|
||||
try:
|
||||
count = Decimal(count)
|
||||
@ -2175,25 +2189,40 @@ class StockItem(
|
||||
if count < 0:
|
||||
return False
|
||||
|
||||
self.stocktake_date = InvenTree.helpers.current_date()
|
||||
self.stocktake_user = user
|
||||
|
||||
if self.updateQuantity(count):
|
||||
tracking_info = {'quantity': float(count)}
|
||||
|
||||
self.stocktake_date = InvenTree.helpers.current_date()
|
||||
self.stocktake_user = user
|
||||
|
||||
# Optional fields which can be supplied in a 'stocktake' call
|
||||
for field in StockItem.optional_transfer_fields():
|
||||
if field in kwargs:
|
||||
setattr(self, field, kwargs[field])
|
||||
tracking_info[field] = kwargs[field]
|
||||
|
||||
self.save(add_note=False)
|
||||
|
||||
self.add_tracking_entry(
|
||||
StockHistoryCode.STOCK_COUNT,
|
||||
user,
|
||||
notes=notes,
|
||||
deltas={'quantity': float(self.quantity)},
|
||||
notes=kwargs.get('notes', ''),
|
||||
deltas=tracking_info,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@transaction.atomic
|
||||
def add_stock(self, quantity, user, notes=''):
|
||||
"""Add items to stock.
|
||||
def add_stock(self, quantity, user, **kwargs):
|
||||
"""Add a specified quantity of stock to this item.
|
||||
|
||||
This function can be called by initiating a ProjectRun,
|
||||
or by manually adding the items to the stock location
|
||||
Arguments:
|
||||
quantity: The quantity to add
|
||||
user: The user performing the action
|
||||
|
||||
Keyword Arguments:
|
||||
notes: Optional notes for the stock addition
|
||||
status: Optionally adjust the stock status
|
||||
"""
|
||||
# Cannot add items to a serialized part
|
||||
if self.serialized:
|
||||
@ -2209,20 +2238,38 @@ class StockItem(
|
||||
return False
|
||||
|
||||
if self.updateQuantity(self.quantity + quantity):
|
||||
tracking_info = {'added': float(quantity), 'quantity': float(self.quantity)}
|
||||
|
||||
# Optional fields which can be supplied in a 'stocktake' call
|
||||
for field in StockItem.optional_transfer_fields():
|
||||
if field in kwargs:
|
||||
setattr(self, field, kwargs[field])
|
||||
tracking_info[field] = kwargs[field]
|
||||
|
||||
self.save(add_note=False)
|
||||
|
||||
self.add_tracking_entry(
|
||||
StockHistoryCode.STOCK_ADD,
|
||||
user,
|
||||
notes=notes,
|
||||
deltas={'added': float(quantity), 'quantity': float(self.quantity)},
|
||||
notes=kwargs.get('notes', ''),
|
||||
deltas=tracking_info,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@transaction.atomic
|
||||
def take_stock(
|
||||
self, quantity, user, notes='', code=StockHistoryCode.STOCK_REMOVE, **kwargs
|
||||
):
|
||||
"""Remove items from stock."""
|
||||
def take_stock(self, quantity, user, code=StockHistoryCode.STOCK_REMOVE, **kwargs):
|
||||
"""Remove the specified quantity from this StockItem.
|
||||
|
||||
Arguments:
|
||||
quantity: The quantity to remove
|
||||
user: The user performing the action
|
||||
|
||||
Keyword Arguments:
|
||||
code: The stock history code to use
|
||||
notes: Optional notes for the stock removal
|
||||
status: Optionally adjust the stock status
|
||||
"""
|
||||
# Cannot remove items from a serialized part
|
||||
if self.serialized:
|
||||
return False
|
||||
@ -2244,7 +2291,17 @@ class StockItem(
|
||||
if stockitem := kwargs.get('stockitem'):
|
||||
deltas['stockitem'] = stockitem.pk
|
||||
|
||||
self.add_tracking_entry(code, user, notes=notes, deltas=deltas)
|
||||
# Optional fields which can be supplied in a 'stocktake' call
|
||||
for field in StockItem.optional_transfer_fields():
|
||||
if field in kwargs:
|
||||
setattr(self, field, kwargs[field])
|
||||
deltas[field] = kwargs[field]
|
||||
|
||||
self.save(add_note=False)
|
||||
|
||||
self.add_tracking_entry(
|
||||
code, user, notes=kwargs.get('notes', ''), deltas=deltas
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
@ -1554,7 +1554,7 @@ class StockAdjustmentItemSerializer(serializers.Serializer):
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
fields = ['item', 'quantity']
|
||||
fields = ['pk', 'quantity', 'batch', 'status', 'packaging']
|
||||
|
||||
pk = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StockItem.objects.all(),
|
||||
@ -1565,6 +1565,17 @@ class StockAdjustmentItemSerializer(serializers.Serializer):
|
||||
help_text=_('StockItem primary key value'),
|
||||
)
|
||||
|
||||
def validate_pk(self, pk):
|
||||
"""Ensure the stock item is valid."""
|
||||
allow_out_of_stock_transfer = get_global_setting(
|
||||
'STOCK_ALLOW_OUT_OF_STOCK_TRANSFER', backup_value=False, cache=False
|
||||
)
|
||||
|
||||
if not allow_out_of_stock_transfer and not pk.is_in_stock(check_status=False):
|
||||
raise ValidationError(_('Stock item is not in stock'))
|
||||
|
||||
return pk
|
||||
|
||||
quantity = serializers.DecimalField(
|
||||
max_digits=15, decimal_places=5, min_value=Decimal(0), required=True
|
||||
)
|
||||
@ -1640,7 +1651,14 @@ class StockCountSerializer(StockAdjustmentSerializer):
|
||||
stock_item = item['pk']
|
||||
quantity = item['quantity']
|
||||
|
||||
stock_item.stocktake(quantity, request.user, notes=notes)
|
||||
# Optional fields
|
||||
extra = {}
|
||||
|
||||
for field_name in StockItem.optional_transfer_fields():
|
||||
if field_value := item.get(field_name, None):
|
||||
extra[field_name] = field_value
|
||||
|
||||
stock_item.stocktake(quantity, request.user, notes=notes, **extra)
|
||||
|
||||
|
||||
class StockAddSerializer(StockAdjustmentSerializer):
|
||||
@ -1658,7 +1676,14 @@ class StockAddSerializer(StockAdjustmentSerializer):
|
||||
stock_item = item['pk']
|
||||
quantity = item['quantity']
|
||||
|
||||
stock_item.add_stock(quantity, request.user, notes=notes)
|
||||
# Optional fields
|
||||
extra = {}
|
||||
|
||||
for field_name in StockItem.optional_transfer_fields():
|
||||
if field_value := item.get(field_name, None):
|
||||
extra[field_name] = field_value
|
||||
|
||||
stock_item.add_stock(quantity, request.user, notes=notes, **extra)
|
||||
|
||||
|
||||
class StockRemoveSerializer(StockAdjustmentSerializer):
|
||||
@ -1676,7 +1701,14 @@ class StockRemoveSerializer(StockAdjustmentSerializer):
|
||||
stock_item = item['pk']
|
||||
quantity = item['quantity']
|
||||
|
||||
stock_item.take_stock(quantity, request.user, notes=notes)
|
||||
# Optional fields
|
||||
extra = {}
|
||||
|
||||
for field_name in StockItem.optional_transfer_fields():
|
||||
if field_value := item.get(field_name, None):
|
||||
extra[field_name] = field_value
|
||||
|
||||
stock_item.take_stock(quantity, request.user, notes=notes, **extra)
|
||||
|
||||
|
||||
class StockTransferSerializer(StockAdjustmentSerializer):
|
||||
|
@ -1780,8 +1780,8 @@ class StocktakeTest(StockAPITestCase):
|
||||
"""Test stock transfers."""
|
||||
stock_item = StockItem.objects.get(pk=1234)
|
||||
|
||||
# Mark this stock item as "quarantined" (cannot be moved)
|
||||
stock_item.status = StockStatus.QUARANTINED.value
|
||||
# Mark the item as 'out of stock' by assigning a customer
|
||||
stock_item.customer = company.models.Company.objects.first()
|
||||
stock_item.save()
|
||||
|
||||
InvenTreeSetting.set_setting('STOCK_ALLOW_OUT_OF_STOCK_TRANSFER', False)
|
||||
@ -1797,7 +1797,7 @@ class StocktakeTest(StockAPITestCase):
|
||||
# First attempt should *fail* - stock item is quarantined
|
||||
response = self.post(url, data, expected_code=400)
|
||||
|
||||
self.assertIn('cannot be moved as it is not in stock', str(response.data))
|
||||
self.assertIn('Stock item is not in stock', str(response.data))
|
||||
|
||||
# Now, allow transfer of "out of stock" items
|
||||
InvenTreeSetting.set_setting('STOCK_ALLOW_OUT_OF_STOCK_TRANSFER', True)
|
||||
|
@ -13,7 +13,7 @@ from company.models import Company
|
||||
from InvenTree.unit_test import AdminTestCase, InvenTreeTestCase
|
||||
from order.models import SalesOrder
|
||||
from part.models import Part, PartTestTemplate
|
||||
from stock.status_codes import StockHistoryCode
|
||||
from stock.status_codes import StockHistoryCode, StockStatus
|
||||
|
||||
from .models import (
|
||||
StockItem,
|
||||
@ -444,11 +444,32 @@ class StockTest(StockTestBase):
|
||||
self.assertIn('Counted items', track.notes)
|
||||
|
||||
n = it.tracking_info.count()
|
||||
self.assertFalse(it.stocktake(-1, None, 'test negative stocktake'))
|
||||
self.assertFalse(
|
||||
it.stocktake(
|
||||
-1,
|
||||
None,
|
||||
notes='test negative stocktake',
|
||||
status=StockStatus.DAMAGED.value,
|
||||
)
|
||||
)
|
||||
|
||||
# Ensure tracking info was not added
|
||||
self.assertEqual(it.tracking_info.count(), n)
|
||||
|
||||
it.refresh_from_db()
|
||||
self.assertEqual(it.status, StockStatus.OK.value)
|
||||
|
||||
# Next, perform a valid stocktake
|
||||
self.assertTrue(
|
||||
it.stocktake(
|
||||
100, None, notes='test stocktake', status=StockStatus.DAMAGED.value
|
||||
)
|
||||
)
|
||||
|
||||
it.refresh_from_db()
|
||||
self.assertEqual(it.quantity, 100)
|
||||
self.assertEqual(it.status, StockStatus.DAMAGED.value)
|
||||
|
||||
def test_add_stock(self):
|
||||
"""Test adding stock."""
|
||||
it = StockItem.objects.get(pk=2)
|
||||
|
Reference in New Issue
Block a user