diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py
index f60096525e..c84d2cdb6c 100644
--- a/src/backend/InvenTree/InvenTree/api_version.py
+++ b/src/backend/InvenTree/InvenTree/api_version.py
@@ -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
diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py
index 29aa84cc50..d1c5de1849 100644
--- a/src/backend/InvenTree/stock/models.py
+++ b/src/backend/InvenTree/stock/models.py
@@ -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
diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py
index ccc14c8804..55164bca3e 100644
--- a/src/backend/InvenTree/stock/serializers.py
+++ b/src/backend/InvenTree/stock/serializers.py
@@ -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):
diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py
index 7535fcac59..9be1e753ae 100644
--- a/src/backend/InvenTree/stock/test_api.py
+++ b/src/backend/InvenTree/stock/test_api.py
@@ -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)
diff --git a/src/backend/InvenTree/stock/tests.py b/src/backend/InvenTree/stock/tests.py
index c83e6bf274..8b6dca317e 100644
--- a/src/backend/InvenTree/stock/tests.py
+++ b/src/backend/InvenTree/stock/tests.py
@@ -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)
diff --git a/src/frontend/src/components/forms/fields/ApiFormField.tsx b/src/frontend/src/components/forms/fields/ApiFormField.tsx
index 6cc1d9fe52..7ca2506dc0 100644
--- a/src/frontend/src/components/forms/fields/ApiFormField.tsx
+++ b/src/frontend/src/components/forms/fields/ApiFormField.tsx
@@ -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
@@ -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;
diff --git a/src/frontend/src/components/forms/fields/TableField.tsx b/src/frontend/src/components/forms/fields/TableField.tsx
index 6db54e8164..51dc35b37a 100644
--- a/src/frontend/src/components/forms/fields/TableField.tsx
+++ b/src/frontend/src/components/forms/fields/TableField.tsx
@@ -213,6 +213,7 @@ export function TableField({
*/
export function TableFieldExtraRow({
visible,
+ fieldName,
fieldDefinition,
defaultValue,
emptyValue,
@@ -220,6 +221,7 @@ export function TableFieldExtraRow({
onValueChange
}: {
visible: boolean;
+ fieldName?: string;
fieldDefinition: ApiFormFieldType;
defaultValue?: any;
error?: string;
@@ -253,6 +255,7 @@ export function TableFieldExtraRow({
{
+ return (
+ StatusFilterOptions(ModelType.stockitem)()?.map((choice) => {
+ return {
+ value: choice.value,
+ display_name: choice.label
+ };
+ }) ?? []
+ );
+ }, []);
+
const [quantity, setQuantity] = useState(
add ? 0 : (props.item?.quantity ?? 0)
);
+ const [status, setStatus] = useState(undefined);
+
const removeAndRefresh = () => {
props.removeFn(props.idx);
};
@@ -463,6 +480,17 @@ function StockOperationsRow({
}
});
+ const [statusOpen, statusHandlers] = useDisclosure(false, {
+ onOpen: () => {
+ setStatus(record?.status || undefined);
+ props.changeFn(props.idx, 'status', record?.status || undefined);
+ },
+ onClose: () => {
+ setStatus(undefined);
+ props.changeFn(props.idx, 'status', undefined);
+ }
+ });
+
const stockString: string = useMemo(() => {
if (!record) {
return '-';
@@ -481,14 +509,21 @@ function StockOperationsRow({
<>
-
-
-