Skip to content

Commit

Permalink
Stock Transfer Improvements (#8570)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
SchrodingersGat authored Nov 27, 2024
1 parent 28ea275 commit c074250
Show file tree
Hide file tree
Showing 12 changed files with 281 additions and 52 deletions.
5 changes: 4 additions & 1 deletion src/backend/InvenTree/InvenTree/api_version.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
109 changes: 83 additions & 26 deletions src/backend/InvenTree/stock/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1489,22 +1489,32 @@ def child_count(self):
"""
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
self.belongs_to is None, # Not installed inside another 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.
Expand Down Expand Up @@ -2073,14 +2083,13 @@ def move(self, location, notes, user, **kwargs):
'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
Expand Down Expand Up @@ -2161,11 +2170,16 @@ def updateQuantity(self, quantity):
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)
Expand All @@ -2175,25 +2189,40 @@ def stocktake(self, count, user, notes=''):
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.
Arguments:
quantity: The quantity to add
user: The user performing the action
This function can be called by initiating a ProjectRun,
or by manually adding the items to the stock location
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:
Expand All @@ -2209,20 +2238,38 @@ def add_stock(self, quantity, user, notes=''):
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
Expand All @@ -2244,7 +2291,17 @@ def take_stock(
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

Expand Down
40 changes: 36 additions & 4 deletions src/backend/InvenTree/stock/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -1565,6 +1565,17 @@ class Meta:
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
)
Expand Down Expand Up @@ -1640,7 +1651,14 @@ def save(self):
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):
Expand All @@ -1658,7 +1676,14 @@ def save(self):
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):
Expand All @@ -1676,7 +1701,14 @@ def save(self):
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):
Expand Down
6 changes: 3 additions & 3 deletions src/backend/InvenTree/stock/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1780,8 +1780,8 @@ def test_transfer(self):
"""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)
Expand All @@ -1797,7 +1797,7 @@ def test_transfer(self):
# 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)
Expand Down
25 changes: 23 additions & 2 deletions src/backend/InvenTree/stock/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
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,
Expand Down Expand Up @@ -444,11 +444,32 @@ def test_stocktake(self):
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)
Expand Down
7 changes: 6 additions & 1 deletion src/frontend/src/components/forms/fields/ApiFormField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ export type ApiFormAdjustFilterType = {
data: FieldValues;
};

export type ApiFormFieldChoice = {
value: any;
display_name: string;
};

/** Definition of the ApiForm field component.
* - The 'name' attribute *must* be provided
* - All other attributes are optional, and may be provided by the API
Expand Down Expand Up @@ -83,7 +88,7 @@ export type ApiFormFieldType = {
child?: ApiFormFieldType;
children?: { [key: string]: ApiFormFieldType };
required?: boolean;
choices?: any[];
choices?: ApiFormFieldChoice[];
hidden?: boolean;
disabled?: boolean;
exclude?: boolean;
Expand Down
3 changes: 3 additions & 0 deletions src/frontend/src/components/forms/fields/TableField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -213,13 +213,15 @@ export function TableField({
*/
export function TableFieldExtraRow({
visible,
fieldName,
fieldDefinition,
defaultValue,
emptyValue,
error,
onValueChange
}: {
visible: boolean;
fieldName?: string;
fieldDefinition: ApiFormFieldType;
defaultValue?: any;
error?: string;
Expand Down Expand Up @@ -253,6 +255,7 @@ export function TableFieldExtraRow({
<InvenTreeIcon icon='downright' />
</Container>
<StandaloneField
fieldName={fieldName ?? 'field'}
fieldDefinition={field}
defaultValue={defaultValue}
error={error}
Expand Down
Loading

0 comments on commit c074250

Please sign in to comment.