mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-29 20:16:44 +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:
parent
28ea275d1a
commit
c074250ce6
@ -1,13 +1,16 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# 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."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
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
|
v288 - 2024-11-27 : https://github.com/inventree/InvenTree/pull/8574
|
||||||
- Adds "consumed" filter to StockItem API
|
- Adds "consumed" filter to StockItem API
|
||||||
|
|
||||||
|
@ -1489,12 +1489,15 @@ class StockItem(
|
|||||||
"""
|
"""
|
||||||
return self.children.count()
|
return self.children.count()
|
||||||
|
|
||||||
@property
|
def is_in_stock(self, check_status: bool = True):
|
||||||
def in_stock(self) -> bool:
|
"""Return True if this StockItem is "in stock".
|
||||||
"""Returns True if this item 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([
|
return all([
|
||||||
self.quantity > 0, # Quantity must be greater than zero
|
self.quantity > 0, # Quantity must be greater than zero
|
||||||
self.sales_order is None, # Not assigned to a SalesOrder
|
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.customer is None, # Not assigned to a customer
|
||||||
self.consumed_by is None, # Not consumed by a build
|
self.consumed_by is None, # Not consumed by a build
|
||||||
not self.is_building, # Not part of an active 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
|
@property
|
||||||
def can_adjust_location(self):
|
def can_adjust_location(self):
|
||||||
"""Returns True if the stock location can be "adjusted" for this part.
|
"""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
|
'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'))
|
raise ValidationError(_('StockItem cannot be moved as it is not in stock'))
|
||||||
|
|
||||||
if quantity <= 0:
|
if quantity <= 0:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if location is None:
|
if location is None:
|
||||||
# TODO - Raise appropriate error (cannot move to blank location)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Test for a partial movement
|
# Test for a partial movement
|
||||||
@ -2161,11 +2170,16 @@ class StockItem(
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def stocktake(self, count, user, notes=''):
|
def stocktake(self, count, user, **kwargs):
|
||||||
"""Perform item stocktake.
|
"""Perform item stocktake.
|
||||||
|
|
||||||
When the quantity of an item is counted,
|
Arguments:
|
||||||
record the date of stocktake
|
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:
|
try:
|
||||||
count = Decimal(count)
|
count = Decimal(count)
|
||||||
@ -2175,25 +2189,40 @@ class StockItem(
|
|||||||
if count < 0:
|
if count < 0:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
if self.updateQuantity(count):
|
||||||
|
tracking_info = {'quantity': float(count)}
|
||||||
|
|
||||||
self.stocktake_date = InvenTree.helpers.current_date()
|
self.stocktake_date = InvenTree.helpers.current_date()
|
||||||
self.stocktake_user = user
|
self.stocktake_user = user
|
||||||
|
|
||||||
if self.updateQuantity(count):
|
# 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(
|
self.add_tracking_entry(
|
||||||
StockHistoryCode.STOCK_COUNT,
|
StockHistoryCode.STOCK_COUNT,
|
||||||
user,
|
user,
|
||||||
notes=notes,
|
notes=kwargs.get('notes', ''),
|
||||||
deltas={'quantity': float(self.quantity)},
|
deltas=tracking_info,
|
||||||
)
|
)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def add_stock(self, quantity, user, notes=''):
|
def add_stock(self, quantity, user, **kwargs):
|
||||||
"""Add items to stock.
|
"""Add a specified quantity of stock to this item.
|
||||||
|
|
||||||
This function can be called by initiating a ProjectRun,
|
Arguments:
|
||||||
or by manually adding the items to the stock location
|
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
|
# Cannot add items to a serialized part
|
||||||
if self.serialized:
|
if self.serialized:
|
||||||
@ -2209,20 +2238,38 @@ class StockItem(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
if self.updateQuantity(self.quantity + quantity):
|
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(
|
self.add_tracking_entry(
|
||||||
StockHistoryCode.STOCK_ADD,
|
StockHistoryCode.STOCK_ADD,
|
||||||
user,
|
user,
|
||||||
notes=notes,
|
notes=kwargs.get('notes', ''),
|
||||||
deltas={'added': float(quantity), 'quantity': float(self.quantity)},
|
deltas=tracking_info,
|
||||||
)
|
)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def take_stock(
|
def take_stock(self, quantity, user, code=StockHistoryCode.STOCK_REMOVE, **kwargs):
|
||||||
self, quantity, user, notes='', code=StockHistoryCode.STOCK_REMOVE, **kwargs
|
"""Remove the specified quantity from this StockItem.
|
||||||
):
|
|
||||||
"""Remove items from stock."""
|
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
|
# Cannot remove items from a serialized part
|
||||||
if self.serialized:
|
if self.serialized:
|
||||||
return False
|
return False
|
||||||
@ -2244,7 +2291,17 @@ class StockItem(
|
|||||||
if stockitem := kwargs.get('stockitem'):
|
if stockitem := kwargs.get('stockitem'):
|
||||||
deltas['stockitem'] = stockitem.pk
|
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
|
return True
|
||||||
|
|
||||||
|
@ -1554,7 +1554,7 @@ class StockAdjustmentItemSerializer(serializers.Serializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
"""Metaclass options."""
|
"""Metaclass options."""
|
||||||
|
|
||||||
fields = ['item', 'quantity']
|
fields = ['pk', 'quantity', 'batch', 'status', 'packaging']
|
||||||
|
|
||||||
pk = serializers.PrimaryKeyRelatedField(
|
pk = serializers.PrimaryKeyRelatedField(
|
||||||
queryset=StockItem.objects.all(),
|
queryset=StockItem.objects.all(),
|
||||||
@ -1565,6 +1565,17 @@ class StockAdjustmentItemSerializer(serializers.Serializer):
|
|||||||
help_text=_('StockItem primary key value'),
|
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(
|
quantity = serializers.DecimalField(
|
||||||
max_digits=15, decimal_places=5, min_value=Decimal(0), required=True
|
max_digits=15, decimal_places=5, min_value=Decimal(0), required=True
|
||||||
)
|
)
|
||||||
@ -1640,7 +1651,14 @@ class StockCountSerializer(StockAdjustmentSerializer):
|
|||||||
stock_item = item['pk']
|
stock_item = item['pk']
|
||||||
quantity = item['quantity']
|
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):
|
class StockAddSerializer(StockAdjustmentSerializer):
|
||||||
@ -1658,7 +1676,14 @@ class StockAddSerializer(StockAdjustmentSerializer):
|
|||||||
stock_item = item['pk']
|
stock_item = item['pk']
|
||||||
quantity = item['quantity']
|
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):
|
class StockRemoveSerializer(StockAdjustmentSerializer):
|
||||||
@ -1676,7 +1701,14 @@ class StockRemoveSerializer(StockAdjustmentSerializer):
|
|||||||
stock_item = item['pk']
|
stock_item = item['pk']
|
||||||
quantity = item['quantity']
|
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):
|
class StockTransferSerializer(StockAdjustmentSerializer):
|
||||||
|
@ -1780,8 +1780,8 @@ class StocktakeTest(StockAPITestCase):
|
|||||||
"""Test stock transfers."""
|
"""Test stock transfers."""
|
||||||
stock_item = StockItem.objects.get(pk=1234)
|
stock_item = StockItem.objects.get(pk=1234)
|
||||||
|
|
||||||
# Mark this stock item as "quarantined" (cannot be moved)
|
# Mark the item as 'out of stock' by assigning a customer
|
||||||
stock_item.status = StockStatus.QUARANTINED.value
|
stock_item.customer = company.models.Company.objects.first()
|
||||||
stock_item.save()
|
stock_item.save()
|
||||||
|
|
||||||
InvenTreeSetting.set_setting('STOCK_ALLOW_OUT_OF_STOCK_TRANSFER', False)
|
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
|
# First attempt should *fail* - stock item is quarantined
|
||||||
response = self.post(url, data, expected_code=400)
|
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
|
# Now, allow transfer of "out of stock" items
|
||||||
InvenTreeSetting.set_setting('STOCK_ALLOW_OUT_OF_STOCK_TRANSFER', True)
|
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 InvenTree.unit_test import AdminTestCase, InvenTreeTestCase
|
||||||
from order.models import SalesOrder
|
from order.models import SalesOrder
|
||||||
from part.models import Part, PartTestTemplate
|
from part.models import Part, PartTestTemplate
|
||||||
from stock.status_codes import StockHistoryCode
|
from stock.status_codes import StockHistoryCode, StockStatus
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
StockItem,
|
StockItem,
|
||||||
@ -444,11 +444,32 @@ class StockTest(StockTestBase):
|
|||||||
self.assertIn('Counted items', track.notes)
|
self.assertIn('Counted items', track.notes)
|
||||||
|
|
||||||
n = it.tracking_info.count()
|
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
|
# Ensure tracking info was not added
|
||||||
self.assertEqual(it.tracking_info.count(), n)
|
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):
|
def test_add_stock(self):
|
||||||
"""Test adding stock."""
|
"""Test adding stock."""
|
||||||
it = StockItem.objects.get(pk=2)
|
it = StockItem.objects.get(pk=2)
|
||||||
|
@ -23,6 +23,11 @@ export type ApiFormAdjustFilterType = {
|
|||||||
data: FieldValues;
|
data: FieldValues;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ApiFormFieldChoice = {
|
||||||
|
value: any;
|
||||||
|
display_name: string;
|
||||||
|
};
|
||||||
|
|
||||||
/** Definition of the ApiForm field component.
|
/** Definition of the ApiForm field component.
|
||||||
* - The 'name' attribute *must* be provided
|
* - The 'name' attribute *must* be provided
|
||||||
* - All other attributes are optional, and may be provided by the API
|
* - All other attributes are optional, and may be provided by the API
|
||||||
@ -83,7 +88,7 @@ export type ApiFormFieldType = {
|
|||||||
child?: ApiFormFieldType;
|
child?: ApiFormFieldType;
|
||||||
children?: { [key: string]: ApiFormFieldType };
|
children?: { [key: string]: ApiFormFieldType };
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
choices?: any[];
|
choices?: ApiFormFieldChoice[];
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
exclude?: boolean;
|
exclude?: boolean;
|
||||||
|
@ -213,6 +213,7 @@ export function TableField({
|
|||||||
*/
|
*/
|
||||||
export function TableFieldExtraRow({
|
export function TableFieldExtraRow({
|
||||||
visible,
|
visible,
|
||||||
|
fieldName,
|
||||||
fieldDefinition,
|
fieldDefinition,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
emptyValue,
|
emptyValue,
|
||||||
@ -220,6 +221,7 @@ export function TableFieldExtraRow({
|
|||||||
onValueChange
|
onValueChange
|
||||||
}: {
|
}: {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
|
fieldName?: string;
|
||||||
fieldDefinition: ApiFormFieldType;
|
fieldDefinition: ApiFormFieldType;
|
||||||
defaultValue?: any;
|
defaultValue?: any;
|
||||||
error?: string;
|
error?: string;
|
||||||
@ -253,6 +255,7 @@ export function TableFieldExtraRow({
|
|||||||
<InvenTreeIcon icon='downright' />
|
<InvenTreeIcon icon='downright' />
|
||||||
</Container>
|
</Container>
|
||||||
<StandaloneField
|
<StandaloneField
|
||||||
|
fieldName={fieldName ?? 'field'}
|
||||||
fieldDefinition={field}
|
fieldDefinition={field}
|
||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
error={error}
|
error={error}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Flex, Group, Skeleton, Table, Text } from '@mantine/core';
|
import { Flex, Group, Skeleton, Stack, Table, Text } from '@mantine/core';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { modals } from '@mantine/modals';
|
import { modals } from '@mantine/modals';
|
||||||
import {
|
import {
|
||||||
@ -19,6 +19,7 @@ import RemoveRowButton from '../components/buttons/RemoveRowButton';
|
|||||||
import { StandaloneField } from '../components/forms/StandaloneField';
|
import { StandaloneField } from '../components/forms/StandaloneField';
|
||||||
import type {
|
import type {
|
||||||
ApiFormAdjustFilterType,
|
ApiFormAdjustFilterType,
|
||||||
|
ApiFormFieldChoice,
|
||||||
ApiFormFieldSet
|
ApiFormFieldSet
|
||||||
} from '../components/forms/fields/ApiFormField';
|
} from '../components/forms/fields/ApiFormField';
|
||||||
import {
|
import {
|
||||||
@ -43,6 +44,7 @@ import {
|
|||||||
import { useSerialNumberPlaceholder } from '../hooks/UsePlaceholder';
|
import { useSerialNumberPlaceholder } from '../hooks/UsePlaceholder';
|
||||||
import { apiUrl } from '../states/ApiState';
|
import { apiUrl } from '../states/ApiState';
|
||||||
import { useGlobalSettingsState } from '../states/SettingsState';
|
import { useGlobalSettingsState } from '../states/SettingsState';
|
||||||
|
import { StatusFilterOptions } from '../tables/Filter';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Construct a set of fields for creating / editing a StockItem instance
|
* Construct a set of fields for creating / editing a StockItem instance
|
||||||
@ -430,6 +432,7 @@ type StockRow = {
|
|||||||
function StockOperationsRow({
|
function StockOperationsRow({
|
||||||
props,
|
props,
|
||||||
transfer = false,
|
transfer = false,
|
||||||
|
changeStatus = false,
|
||||||
add = false,
|
add = false,
|
||||||
setMax = false,
|
setMax = false,
|
||||||
merge = false,
|
merge = false,
|
||||||
@ -437,15 +440,29 @@ function StockOperationsRow({
|
|||||||
}: {
|
}: {
|
||||||
props: TableFieldRowProps;
|
props: TableFieldRowProps;
|
||||||
transfer?: boolean;
|
transfer?: boolean;
|
||||||
|
changeStatus?: boolean;
|
||||||
add?: boolean;
|
add?: boolean;
|
||||||
setMax?: boolean;
|
setMax?: boolean;
|
||||||
merge?: boolean;
|
merge?: boolean;
|
||||||
record?: any;
|
record?: any;
|
||||||
}) {
|
}) {
|
||||||
|
const statusOptions: ApiFormFieldChoice[] = useMemo(() => {
|
||||||
|
return (
|
||||||
|
StatusFilterOptions(ModelType.stockitem)()?.map((choice) => {
|
||||||
|
return {
|
||||||
|
value: choice.value,
|
||||||
|
display_name: choice.label
|
||||||
|
};
|
||||||
|
}) ?? []
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [quantity, setQuantity] = useState<StockItemQuantity>(
|
const [quantity, setQuantity] = useState<StockItemQuantity>(
|
||||||
add ? 0 : (props.item?.quantity ?? 0)
|
add ? 0 : (props.item?.quantity ?? 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<number | undefined>(undefined);
|
||||||
|
|
||||||
const removeAndRefresh = () => {
|
const removeAndRefresh = () => {
|
||||||
props.removeFn(props.idx);
|
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(() => {
|
const stockString: string = useMemo(() => {
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return '-';
|
return '-';
|
||||||
@ -481,6 +509,7 @@ function StockOperationsRow({
|
|||||||
<>
|
<>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
|
<Stack gap='xs'>
|
||||||
<Flex gap='sm' align='center'>
|
<Flex gap='sm' align='center'>
|
||||||
<Thumbnail
|
<Thumbnail
|
||||||
size={40}
|
size={40}
|
||||||
@ -489,6 +518,12 @@ function StockOperationsRow({
|
|||||||
/>
|
/>
|
||||||
<div>{record.part_detail?.name}</div>
|
<div>{record.part_detail?.name}</div>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
{props.rowErrors?.pk?.message && (
|
||||||
|
<Text c='red' size='xs'>
|
||||||
|
{props.rowErrors.pk.message}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
{record.location ? record.location_detail?.pathstring : '-'}
|
{record.location ? record.location_detail?.pathstring : '-'}
|
||||||
@ -531,6 +566,15 @@ function StockOperationsRow({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{changeStatus && (
|
||||||
|
<ActionButton
|
||||||
|
size='sm'
|
||||||
|
icon={<InvenTreeIcon icon='status' />}
|
||||||
|
tooltip={t`Change Status`}
|
||||||
|
onClick={() => statusHandlers.toggle()}
|
||||||
|
variant={statusOpen ? 'filled' : 'transparent'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{transfer && (
|
{transfer && (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
size='sm'
|
size='sm'
|
||||||
@ -544,12 +588,30 @@ function StockOperationsRow({
|
|||||||
</Flex>
|
</Flex>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
|
{changeStatus && (
|
||||||
|
<TableFieldExtraRow
|
||||||
|
visible={statusOpen}
|
||||||
|
onValueChange={(value: any) => {
|
||||||
|
setStatus(value);
|
||||||
|
props.changeFn(props.idx, 'status', value || undefined);
|
||||||
|
}}
|
||||||
|
fieldName='status'
|
||||||
|
fieldDefinition={{
|
||||||
|
field_type: 'choice',
|
||||||
|
label: t`Status`,
|
||||||
|
choices: statusOptions,
|
||||||
|
value: status
|
||||||
|
}}
|
||||||
|
defaultValue={status}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{transfer && (
|
{transfer && (
|
||||||
<TableFieldExtraRow
|
<TableFieldExtraRow
|
||||||
visible={transfer && packagingOpen}
|
visible={transfer && packagingOpen}
|
||||||
onValueChange={(value: any) => {
|
onValueChange={(value: any) => {
|
||||||
props.changeFn(props.idx, 'packaging', value || undefined);
|
props.changeFn(props.idx, 'packaging', value || undefined);
|
||||||
}}
|
}}
|
||||||
|
fieldName='packaging'
|
||||||
fieldDefinition={{
|
fieldDefinition={{
|
||||||
field_type: 'string',
|
field_type: 'string',
|
||||||
label: t`Packaging`
|
label: t`Packaging`
|
||||||
@ -604,19 +666,19 @@ function stockTransferFields(items: any[]): ApiFormFieldSet {
|
|||||||
<StockOperationsRow
|
<StockOperationsRow
|
||||||
props={row}
|
props={row}
|
||||||
transfer
|
transfer
|
||||||
|
changeStatus
|
||||||
setMax
|
setMax
|
||||||
key={record.pk}
|
key={record.pk}
|
||||||
record={record}
|
record={record}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
headers: [t`Part`, t`Location`, t`In Stock`, t`Move`, t`Actions`]
|
headers: [t`Part`, t`Location`, t`Stock`, t`Move`, t`Actions`]
|
||||||
},
|
},
|
||||||
location: {
|
location: {
|
||||||
filters: {
|
filters: {
|
||||||
structural: false
|
structural: false
|
||||||
}
|
}
|
||||||
// TODO: icon
|
|
||||||
},
|
},
|
||||||
notes: {}
|
notes: {}
|
||||||
};
|
};
|
||||||
@ -641,6 +703,7 @@ function stockRemoveFields(items: any[]): ApiFormFieldSet {
|
|||||||
<StockOperationsRow
|
<StockOperationsRow
|
||||||
props={row}
|
props={row}
|
||||||
setMax
|
setMax
|
||||||
|
changeStatus
|
||||||
add
|
add
|
||||||
key={record.pk}
|
key={record.pk}
|
||||||
record={record}
|
record={record}
|
||||||
@ -670,7 +733,13 @@ function stockAddFields(items: any[]): ApiFormFieldSet {
|
|||||||
const record = records[row.item.pk];
|
const record = records[row.item.pk];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StockOperationsRow props={row} add key={record.pk} record={record} />
|
<StockOperationsRow
|
||||||
|
changeStatus
|
||||||
|
props={row}
|
||||||
|
add
|
||||||
|
key={record.pk}
|
||||||
|
record={record}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
headers: [t`Part`, t`Location`, t`In Stock`, t`Add`, t`Actions`]
|
headers: [t`Part`, t`Location`, t`In Stock`, t`Add`, t`Actions`]
|
||||||
@ -696,6 +765,7 @@ function stockCountFields(items: any[]): ApiFormFieldSet {
|
|||||||
return (
|
return (
|
||||||
<StockOperationsRow
|
<StockOperationsRow
|
||||||
props={row}
|
props={row}
|
||||||
|
changeStatus
|
||||||
key={row.item.pk}
|
key={row.item.pk}
|
||||||
record={records[row.item.pk]}
|
record={records[row.item.pk]}
|
||||||
/>
|
/>
|
||||||
@ -763,6 +833,7 @@ function stockMergeFields(items: any[]): ApiFormFieldSet {
|
|||||||
props={row}
|
props={row}
|
||||||
key={row.item.item}
|
key={row.item.item}
|
||||||
merge
|
merge
|
||||||
|
changeStatus
|
||||||
record={records[row.item.item]}
|
record={records[row.item.item]}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -653,7 +653,15 @@ export default function StockDetail() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const stockActions = useMemo(() => {
|
const stockActions = useMemo(() => {
|
||||||
const inStock = stockitem.in_stock;
|
const inStock =
|
||||||
|
user.hasChangeRole(UserRoles.stock) &&
|
||||||
|
stockitem.quantity > 0 &&
|
||||||
|
!stockitem.sales_order &&
|
||||||
|
!stockitem.belongs_to &&
|
||||||
|
!stockitem.customer &&
|
||||||
|
!stockitem.consumed_by &&
|
||||||
|
!stockitem.is_building;
|
||||||
|
|
||||||
const serial = stockitem.serial;
|
const serial = stockitem.serial;
|
||||||
const serialized =
|
const serialized =
|
||||||
serial != null &&
|
serial != null &&
|
||||||
|
@ -37,6 +37,18 @@ export const clearTableFilters = async (page) => {
|
|||||||
await page.getByLabel('filter-drawer-close').click();
|
await page.getByLabel('filter-drawer-close').click();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const setTableChoiceFilter = async (page, filter, value) => {
|
||||||
|
await openFilterDrawer(page);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Add Filter' }).click();
|
||||||
|
await page.getByPlaceholder('Select filter').fill(filter);
|
||||||
|
await page.getByRole('option', { name: 'Status' }).click();
|
||||||
|
await page.getByPlaceholder('Select filter value').click();
|
||||||
|
await page.getByRole('option', { name: value }).click();
|
||||||
|
|
||||||
|
await closeFilterDrawer(page);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the parent 'row' element for a given 'cell' element
|
* Return the parent 'row' element for a given 'cell' element
|
||||||
* @param cell - The cell element
|
* @param cell - The cell element
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import { test } from '../baseFixtures.ts';
|
import { test } from '../baseFixtures.ts';
|
||||||
import { baseUrl } from '../defaults.ts';
|
import { baseUrl } from '../defaults.ts';
|
||||||
|
import { clearTableFilters, setTableChoiceFilter } from '../helpers.ts';
|
||||||
import { doQuickLogin } from '../login.ts';
|
import { doQuickLogin } from '../login.ts';
|
||||||
|
|
||||||
test('Sales Orders', async ({ page }) => {
|
test('Sales Orders - Basic Tests', async ({ page }) => {
|
||||||
await doQuickLogin(page);
|
await doQuickLogin(page);
|
||||||
|
|
||||||
await page.goto(`${baseUrl}/home`);
|
await page.goto(`${baseUrl}/home`);
|
||||||
@ -11,7 +12,11 @@ test('Sales Orders', async ({ page }) => {
|
|||||||
|
|
||||||
// Check for expected text in the table
|
// Check for expected text in the table
|
||||||
await page.getByRole('tab', { name: 'Sales Orders' }).waitFor();
|
await page.getByRole('tab', { name: 'Sales Orders' }).waitFor();
|
||||||
await page.getByText('In Progress').first().waitFor();
|
|
||||||
|
await clearTableFilters(page);
|
||||||
|
|
||||||
|
await setTableChoiceFilter(page, 'status', 'On Hold');
|
||||||
|
|
||||||
await page.getByText('On Hold').first().waitFor();
|
await page.getByText('On Hold').first().waitFor();
|
||||||
|
|
||||||
// Navigate to a particular sales order
|
// Navigate to a particular sales order
|
||||||
|
@ -182,10 +182,22 @@ test('Stock - Stock Actions', async ({ page }) => {
|
|||||||
await page.getByLabel('action-menu-stock-operations-count').waitFor();
|
await page.getByLabel('action-menu-stock-operations-count').waitFor();
|
||||||
await page.getByLabel('action-menu-stock-operations-add').waitFor();
|
await page.getByLabel('action-menu-stock-operations-add').waitFor();
|
||||||
await page.getByLabel('action-menu-stock-operations-remove').waitFor();
|
await page.getByLabel('action-menu-stock-operations-remove').waitFor();
|
||||||
|
|
||||||
await page.getByLabel('action-menu-stock-operations-transfer').click();
|
await page.getByLabel('action-menu-stock-operations-transfer').click();
|
||||||
await page.getByLabel('text-field-notes').fill('test notes');
|
await page.getByLabel('text-field-notes').fill('test notes');
|
||||||
await page.getByRole('button', { name: 'Submit' }).click();
|
await page.getByRole('button', { name: 'Submit' }).click();
|
||||||
await page.getByText('This field is required.').first().waitFor();
|
await page.getByText('This field is required.').first().waitFor();
|
||||||
|
|
||||||
|
// Set the status field
|
||||||
|
await page.getByLabel('action-button-change-status').click();
|
||||||
|
await page.getByLabel('choice-field-status').click();
|
||||||
|
await page.getByText('Attention needed').click();
|
||||||
|
|
||||||
|
// Set the packaging field
|
||||||
|
await page.getByLabel('action-button-adjust-packaging').click();
|
||||||
|
await page.getByLabel('text-field-packaging').fill('test packaging');
|
||||||
|
|
||||||
|
// Close the dialog
|
||||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||||
|
|
||||||
// Find an item which has been sent to a customer
|
// Find an item which has been sent to a customer
|
||||||
|
Loading…
x
Reference in New Issue
Block a user