2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-12-16 17:28:11 +00:00

Merge branch 'master' into generic-parameters

This commit is contained in:
Oliver
2025-11-23 19:56:37 +11:00
committed by GitHub
25 changed files with 578 additions and 234 deletions

View File

@@ -1,11 +1,14 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 425 INVENTREE_API_VERSION = 426
"""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 = """
v426 -> 2025-11-19 : https://github.com/inventree/InvenTree/pull/10867
- Adds optional "price_breaks" filter to the SupplierPart API endpoint
v425 -> 2025-11-11 : https://github.com/inventree/InvenTree/pull/10802 v425 -> 2025-11-11 : https://github.com/inventree/InvenTree/pull/10802
- Adds "on_order" filter to the BuildLine API endpoint - Adds "on_order" filter to the BuildLine API endpoint
- Allow BuildLine list to be ordered by "on_order" and "in_production" fields - Allow BuildLine list to be ordered by "on_order" and "in_production" fields

View File

@@ -460,6 +460,20 @@ class SupplierPriceBreakFilter(FilterSet):
) )
class SupplierPriceBreakMixin:
"""Mixin class for SupplierPriceBreak API endpoints."""
queryset = SupplierPriceBreak.objects.all()
serializer_class = SupplierPriceBreakSerializer
def get_queryset(self):
"""Return annotated queryset for the SupplierPriceBreak list endpoint."""
queryset = super().get_queryset()
queryset = SupplierPriceBreakSerializer.annotate_queryset(queryset)
return queryset
class SupplierPriceBreakOutputOptions(OutputConfiguration): class SupplierPriceBreakOutputOptions(OutputConfiguration):
"""Available output options for the SupplierPriceBreak endpoints.""" """Available output options for the SupplierPriceBreak endpoints."""
@@ -477,27 +491,19 @@ class SupplierPriceBreakOutputOptions(OutputConfiguration):
] ]
class SupplierPriceBreakList(SerializerContextMixin, OutputOptionsMixin, ListCreateAPI): class SupplierPriceBreakList(
SupplierPriceBreakMixin, SerializerContextMixin, OutputOptionsMixin, ListCreateAPI
):
"""API endpoint for list view of SupplierPriceBreak object. """API endpoint for list view of SupplierPriceBreak object.
- GET: Retrieve list of SupplierPriceBreak objects - GET: Retrieve list of SupplierPriceBreak objects
- POST: Create a new SupplierPriceBreak object - POST: Create a new SupplierPriceBreak object
""" """
queryset = SupplierPriceBreak.objects.all()
serializer_class = SupplierPriceBreakSerializer
filterset_class = SupplierPriceBreakFilter
output_options = SupplierPriceBreakOutputOptions output_options = SupplierPriceBreakOutputOptions
def get_queryset(self): filterset_class = SupplierPriceBreakFilter
"""Return annotated queryset for the SupplierPriceBreak list endpoint."""
queryset = super().get_queryset()
queryset = SupplierPriceBreakSerializer.annotate_queryset(queryset)
return queryset
filter_backends = SEARCH_ORDER_FILTER_ALIAS filter_backends = SEARCH_ORDER_FILTER_ALIAS
ordering_fields = ['quantity', 'supplier', 'SKU', 'price'] ordering_fields = ['quantity', 'supplier', 'SKU', 'price']
search_fields = ['part__SKU', 'part__supplier__name'] search_fields = ['part__SKU', 'part__supplier__name']
@@ -507,19 +513,9 @@ class SupplierPriceBreakList(SerializerContextMixin, OutputOptionsMixin, ListCre
ordering = 'quantity' ordering = 'quantity'
class SupplierPriceBreakDetail(RetrieveUpdateDestroyAPI): class SupplierPriceBreakDetail(SupplierPriceBreakMixin, RetrieveUpdateDestroyAPI):
"""Detail endpoint for SupplierPriceBreak object.""" """Detail endpoint for SupplierPriceBreak object."""
queryset = SupplierPriceBreak.objects.all()
serializer_class = SupplierPriceBreakSerializer
def get_queryset(self):
"""Return annotated queryset for the SupplierPriceBreak list endpoint."""
queryset = super().get_queryset()
queryset = SupplierPriceBreakSerializer.annotate_queryset(queryset)
return queryset
manufacturer_part_api_urls = [ manufacturer_part_api_urls = [
path( path(

View File

@@ -329,6 +329,39 @@ class ManufacturerPartParameterSerializer(
) )
class SupplierPriceBreakBriefSerializer(
FilterableSerializerMixin, InvenTreeModelSerializer
):
"""Brief serializer for SupplierPriceBreak object.
Used to provide a list of price breaks against the SupplierPart object.
"""
no_filters = True
class Meta:
"""Metaclass options."""
model = SupplierPriceBreak
fields = [
'pk',
'part',
'quantity',
'price',
'price_currency',
'supplier',
'updated',
]
quantity = InvenTreeDecimalField()
price = InvenTreeMoneySerializer(allow_null=True, required=True, label=_('Price'))
price_currency = InvenTreeCurrencySerializer()
supplier = serializers.PrimaryKeyRelatedField(
source='part.supplier', many=False, read_only=True
)
@register_importer() @register_importer()
class SupplierPartSerializer( class SupplierPartSerializer(
FilterableSerializerMixin, FilterableSerializerMixin,
@@ -373,14 +406,15 @@ class SupplierPartSerializer(
'pack_quantity', 'pack_quantity',
'pack_quantity_native', 'pack_quantity_native',
'part', 'part',
'part_detail',
'pretty_name', 'pretty_name',
'SKU', 'SKU',
'supplier', 'supplier',
'supplier_detail', 'supplier_detail',
'updated', 'updated',
'notes', 'notes',
'part_detail',
'tags', 'tags',
'price_breaks',
] ]
read_only_fields = [ read_only_fields = [
'availability_updated', 'availability_updated',
@@ -441,6 +475,18 @@ class SupplierPartSerializer(
pack_quantity_native = serializers.FloatField(read_only=True) pack_quantity_native = serializers.FloatField(read_only=True)
price_breaks = enable_filter(
SupplierPriceBreakBriefSerializer(
source='pricebreaks',
many=True,
read_only=True,
allow_null=True,
label=_('Price Breaks'),
),
False,
filter_name='price_breaks',
)
part_detail = part_serializers.PartBriefSerializer( part_detail = part_serializers.PartBriefSerializer(
label=_('Part'), source='part', many=False, read_only=True, allow_null=True label=_('Part'), source='part', many=False, read_only=True, allow_null=True
) )
@@ -489,6 +535,8 @@ class SupplierPartSerializer(
Fields: Fields:
in_stock: Current stock quantity for each SupplierPart in_stock: Current stock quantity for each SupplierPart
""" """
queryset = queryset.prefetch_related('part', 'pricebreaks')
queryset = queryset.annotate(in_stock=part.filters.annotate_total_stock()) queryset = queryset.annotate(in_stock=part.filters.annotate_total_stock())
queryset = queryset.annotate( queryset = queryset.annotate(
@@ -532,24 +580,24 @@ class SupplierPartSerializer(
@register_importer() @register_importer()
class SupplierPriceBreakSerializer( class SupplierPriceBreakSerializer(
FilterableSerializerMixin, DataImportExportSerializerMixin, InvenTreeModelSerializer SupplierPriceBreakBriefSerializer,
DataImportExportSerializerMixin,
InvenTreeModelSerializer,
): ):
"""Serializer for SupplierPriceBreak object.""" """Serializer for SupplierPriceBreak object.
Note that this inherits from the SupplierPriceBreakBriefSerializer,
and does so to prevent circular serializer import issues.
"""
class Meta: class Meta:
"""Metaclass options.""" """Metaclass options."""
model = SupplierPriceBreak model = SupplierPriceBreak
fields = [ fields = [
'pk', *SupplierPriceBreakBriefSerializer.Meta.fields,
'part',
'part_detail',
'quantity',
'price',
'price_currency',
'supplier',
'supplier_detail', 'supplier_detail',
'updated', 'part_detail',
] ]
@staticmethod @staticmethod
@@ -559,25 +607,15 @@ class SupplierPriceBreakSerializer(
return queryset return queryset
quantity = InvenTreeDecimalField()
price = InvenTreeMoneySerializer(allow_null=True, required=True, label=_('Price'))
price_currency = InvenTreeCurrencySerializer()
supplier = serializers.PrimaryKeyRelatedField(
source='part.supplier', many=False, read_only=True
)
supplier_detail = enable_filter( supplier_detail = enable_filter(
CompanyBriefSerializer( CompanyBriefSerializer(
source='part.supplier', many=False, read_only=True, allow_null=True source='part.supplier', many=False, read_only=True, allow_null=True
) )
) )
# Detail serializer for SupplierPart
part_detail = enable_filter( part_detail = enable_filter(
SupplierPartSerializer( SupplierPartSerializer(
source='part', brief=True, many=False, read_only=True, allow_null=True source='part', brief=True, many=False, read_only=True, allow_null=True
) ),
False,
) )

View File

@@ -730,41 +730,13 @@ class PartSerializer(
- Allows us to optionally pass extra fields based on the query. - Allows us to optionally pass extra fields based on the query.
""" """
self.starred_parts = kwargs.pop('starred_parts', []) self.starred_parts = kwargs.pop('starred_parts', [])
# category_detail = kwargs.pop('category_detail', False)
# location_detail = kwargs.pop('location_detail', False)
# parameters = kwargs.pop('parameters', False)
create = kwargs.pop('create', False) create = kwargs.pop('create', False)
# pricing = kwargs.pop('pricing', True)
# path_detail = kwargs.pop('path_detail', False)
# price_breaks = kwargs.pop('price_breaks', False)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if isGeneratingSchema(): if isGeneratingSchema():
return return
"""
if not category_detail:
self.fields.pop('category_detail', None)
if not location_detail:
self.fields.pop('default_location_detail', None)
if not parameters:
self.fields.pop('parameters', None)
if not path_detail:
self.fields.pop('category_path', None)
if not price_breaks:
self.fields.pop('price_breaks', None)
if not pricing:
self.fields.pop('pricing_min', None)
self.fields.pop('pricing_max', None)
self.fields.pop('pricing_updated', None)
"""
if not create: if not create:
# These fields are only used for the LIST API endpoint # These fields are only used for the LIST API endpoint
for f in self.skip_create_fields(): for f in self.skip_create_fields():

View File

@@ -1,5 +1,7 @@
"""Sample plugin for registering custom machines.""" """Sample plugin for registering custom machines."""
import time
from django.db import models from django.db import models
import structlog import structlog
@@ -26,7 +28,14 @@ class SamplePrinterDriver(LabelPrinterBaseDriver):
'name': 'Connection String', 'name': 'Connection String',
'description': 'Custom string for connecting to the printer', 'description': 'Custom string for connecting to the printer',
'default': '123-xx123:8000', 'default': '123-xx123:8000',
} },
'DELAY': {
'name': 'Print Delay',
'description': 'Delay (in seconds) before printing',
'default': 0,
'units': 'seconds',
'validator': int,
},
} }
def init_machine(self, machine: BaseMachineType) -> None: def init_machine(self, machine: BaseMachineType) -> None:
@@ -44,7 +53,14 @@ class SamplePrinterDriver(LabelPrinterBaseDriver):
**kwargs, **kwargs,
) -> None: ) -> None:
"""Send the label to the printer.""" """Send the label to the printer."""
print_delay = machine.get_setting('DELAY', 'D')
print('MOCK LABEL PRINTING:') print('MOCK LABEL PRINTING:')
if print_delay > 0:
print(f' - Delaying for {print_delay} seconds...')
time.sleep(print_delay)
print('- machine:', machine) print('- machine:', machine)
print('- label:', label) print('- label:', label)
print('- item:', item) print('- item:', item)

View File

@@ -1907,7 +1907,12 @@ class StockItem(
data = dict(StockItem.objects.filter(pk=self.pk).values()[0]) data = dict(StockItem.objects.filter(pk=self.pk).values()[0])
if location: if location:
data['location'] = location if location.structural:
raise ValidationError({
'location': _('Cannot assign stock to structural location')
})
data['location_id'] = location.pk
# Set the parent ID correctly # Set the parent ID correctly
data['parent'] = self data['parent'] = self
@@ -1920,7 +1925,17 @@ class StockItem(
history_items = [] history_items = []
for item in items: for item in items:
# Construct a tracking entry for the new StockItem # Construct tracking entries for the new StockItem
if entry := item.add_tracking_entry(
StockHistoryCode.SPLIT_FROM_PARENT,
user,
quantity=1,
notes=notes,
location=location,
commit=False,
):
history_items.append(entry)
if entry := item.add_tracking_entry( if entry := item.add_tracking_entry(
StockHistoryCode.ASSIGNED_SERIAL, StockHistoryCode.ASSIGNED_SERIAL,
user, user,
@@ -1937,7 +1952,9 @@ class StockItem(
StockItemTracking.objects.bulk_create(history_items) StockItemTracking.objects.bulk_create(history_items)
# Remove the equivalent number of items # Remove the equivalent number of items
self.take_stock(quantity, user, notes=notes) self.take_stock(
quantity, user, code=StockHistoryCode.STOCK_SERIZALIZED, notes=notes
)
return items return items

View File

@@ -53,6 +53,7 @@ class StockHistoryCode(StatusCode):
STOCK_COUNT = 10, _('Stock counted') STOCK_COUNT = 10, _('Stock counted')
STOCK_ADD = 11, _('Stock manually added') STOCK_ADD = 11, _('Stock manually added')
STOCK_REMOVE = 12, _('Stock manually removed') STOCK_REMOVE = 12, _('Stock manually removed')
STOCK_SERIZALIZED = 13, _('Serialized stock items')
RETURNED_TO_STOCK = 15, _('Returned to stock') # Stock item returned to stock RETURNED_TO_STOCK = 15, _('Returned to stock') # Stock item returned to stock

View File

@@ -1271,9 +1271,11 @@ class StockTreeTest(StockTestBase):
self.assertEqual(item_1.get_children().count(), 1) self.assertEqual(item_1.get_children().count(), 1)
self.assertEqual(item_2.parent, item_1) self.assertEqual(item_2.parent, item_1)
loc = StockLocation.objects.filter(structural=False).first()
# Serialize the secondary item # Serialize the secondary item
serials = [str(i) for i in range(20)] serials = [str(i) for i in range(20)]
items = item_2.serializeStock(20, serials) items = item_2.serializeStock(20, serials, location=loc)
self.assertEqual(len(items), 20) self.assertEqual(len(items), 20)
self.assertEqual(StockItem.objects.count(), N + 22) self.assertEqual(StockItem.objects.count(), N + 22)
@@ -1290,6 +1292,9 @@ class StockTreeTest(StockTestBase):
self.assertEqual(child.parent, item_2) self.assertEqual(child.parent, item_2)
self.assertGreater(child.lft, item_2.lft) self.assertGreater(child.lft, item_2.lft)
self.assertLess(child.rght, item_2.rght) self.assertLess(child.rght, item_2.rght)
self.assertEqual(child.location, loc)
self.assertIsNotNone(child.location)
self.assertEqual(child.tracking_info.count(), 2)
# Delete item_2 : we expect that all children will be re-parented to item_1 # Delete item_2 : we expect that all children will be re-parented to item_1
item_2.delete() item_2.delete()

View File

@@ -40,3 +40,36 @@ export function identifierString(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]/g, '-'); return value.toLowerCase().replace(/[^a-z0-9]/g, '-');
} }
export function toNumber(
value: any,
defaultValue: number | null = 0
): number | null {
// Convert the provided value into a number (if possible)
if (value == undefined || value == null || value === '') {
return defaultValue;
}
// Case 1: numeric already
if (typeof value === 'number') return value;
// Case 2: react-number-format object
if (typeof value === 'object') {
if ('floatValue' in value && typeof value.floatValue === 'number') {
return value.floatValue;
}
if ('value' in value) {
const parsed = Number(value.value);
return Number.isNaN(parsed) ? Number.NaN : parsed;
}
}
// Case 3: string
if (typeof value === 'string') {
const parsed = Number(value);
return Number.isNaN(parsed) ? Number.NaN : parsed;
}
return Number.NaN;
}

View File

@@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { Alert, FileInput, NumberInput, Stack } from '@mantine/core'; import { Alert, FileInput, Stack } from '@mantine/core';
import { useId } from '@mantine/hooks'; import { useId } from '@mantine/hooks';
import { useCallback, useEffect, useMemo } from 'react'; import { useCallback, useEffect, useMemo } from 'react';
import { type Control, type FieldValues, useController } from 'react-hook-form'; import { type Control, type FieldValues, useController } from 'react-hook-form';
@@ -12,6 +12,7 @@ import DateField from './DateField';
import { DependentField } from './DependentField'; import { DependentField } from './DependentField';
import IconField from './IconField'; import IconField from './IconField';
import { NestedObjectField } from './NestedObjectField'; import { NestedObjectField } from './NestedObjectField';
import NumberField from './NumberField';
import { RelatedModelField } from './RelatedModelField'; import { RelatedModelField } from './RelatedModelField';
import { TableField } from './TableField'; import { TableField } from './TableField';
import TextField from './TextField'; import TextField from './TextField';
@@ -92,7 +93,7 @@ export function ApiFormField({
// Callback helper when form value changes // Callback helper when form value changes
const onChange = useCallback( const onChange = useCallback(
(value: any) => { (value: any) => {
let rtnValue = value; let rtnValue: any = value;
// Allow for custom value adjustments (per field) // Allow for custom value adjustments (per field)
if (definition.adjustValue) { if (definition.adjustValue) {
rtnValue = definition.adjustValue(value); rtnValue = definition.adjustValue(value);
@@ -106,34 +107,6 @@ export function ApiFormField({
[fieldName, definition] [fieldName, definition]
); );
// Coerce the value to a numerical value
const numericalValue: number | null = useMemo(() => {
let val: number | null = 0;
if (value == null) {
return null;
}
switch (definition.field_type) {
case 'integer':
val = Number.parseInt(value, 10) ?? '';
break;
case 'decimal':
case 'float':
case 'number':
val = Number.parseFloat(value) ?? '';
break;
default:
break;
}
if (Number.isNaN(val) || !Number.isFinite(val)) {
val = null;
}
return val;
}, [definition.field_type, value]);
// Construct the individual field // Construct the individual field
const fieldInstance = useMemo(() => { const fieldInstance = useMemo(() => {
switch (fieldDefinition.field_type) { switch (fieldDefinition.field_type) {
@@ -197,23 +170,14 @@ export function ApiFormField({
case 'float': case 'float':
case 'number': case 'number':
return ( return (
<NumberInput <NumberField
{...reducedDefinition} controller={controller}
radius='sm' fieldName={fieldName}
ref={field.ref} definition={reducedDefinition}
id={fieldId} placeholderAutofill={fieldDefinition.placeholderAutofill ?? false}
aria-label={`number-field-${field.name}`} onChange={(value: any) => {
value={numericalValue === null ? '' : numericalValue} onChange(value);
error={definition.error ?? error?.message}
decimalScale={definition.field_type == 'integer' ? 0 : 10}
onChange={(value: number | string | null) => {
if (value != null && value.toString().trim() === '') {
onChange(null);
} else {
onChange(value);
}
}} }}
step={1}
/> />
); );
case 'choice': case 'choice':
@@ -284,7 +248,6 @@ export function ApiFormField({
fieldId, fieldId,
fieldName, fieldName,
fieldDefinition, fieldDefinition,
numericalValue,
onChange, onChange,
onKeyDown, onKeyDown,
reducedDefinition, reducedDefinition,

View File

@@ -0,0 +1,57 @@
import { t } from '@lingui/core/macro';
import { Tooltip } from '@mantine/core';
import { IconCopyCheck, IconX } from '@tabler/icons-react';
/**
* Custom "RightSection" component for form fields,
* to implement functionality such as:
*
* - Clear field value
* - Auto-fill with suggested placeholder
*/
export default function AutoFillRightSection({
value,
fieldName,
definition,
onChange
}: {
value: any;
fieldName: string;
definition: any;
placeholderAutofill?: boolean;
onChange: (value: any) => void;
}) {
if (definition.rightSection) {
// Use the specified override value
return definition.rightSection;
}
if (value) {
// If a value is provided, render a button to clear the field
if (!definition.required && !definition.disabled) {
const emptyValue = definition.allow_null ? null : '';
return (
<Tooltip label={t`Clear`} position='top-end'>
<IconX
aria-label={`field-${fieldName}-clear`}
size='1rem'
color='red'
onClick={() => onChange(emptyValue)}
/>
</Tooltip>
);
}
} else if (!value && definition.placeholder && !definition.disabled) {
// Render auto-fill button
return (
<Tooltip label={t`Accept suggested value`} position='top-end'>
<IconCopyCheck
aria-label={`field-${fieldName}-accept-placeholder`}
size='1rem'
color='green'
onClick={() => onChange(definition.placeholder)}
/>
</Tooltip>
);
}
}

View File

@@ -0,0 +1,91 @@
import { NumberInput } from '@mantine/core';
import { useId, useMemo } from 'react';
import type { FieldValues, UseControllerReturn } from 'react-hook-form';
import AutoFillRightSection from './AutoFillRightSection';
/**
* Custom implementation of the mantine <NumberInput> component,
* used for rendering numerical input fields in forms.
*/
export default function NumberField({
controller,
fieldName,
definition,
placeholderAutofill,
onChange
}: Readonly<{
controller: UseControllerReturn<FieldValues, any>;
definition: any;
fieldName: string;
placeholderAutofill?: boolean;
onChange: (value: any) => void;
}>) {
const fieldId = useId();
const {
field,
fieldState: { error }
} = controller;
const { value } = field;
// Coerce the value to a numerical value
const numericalValue: number | null = useMemo(() => {
let val: number | null = 0;
if (value == null || value == undefined || value === '') {
return null;
}
switch (definition.field_type) {
case 'integer':
val = Number.parseInt(value, 10) ?? '';
break;
case 'decimal':
case 'float':
case 'number':
val = Number.parseFloat(value) ?? '';
break;
default:
break;
}
if (Number.isNaN(val) || !Number.isFinite(val)) {
val = null;
}
return val;
}, [definition.field_type, value]);
return (
<NumberInput
{...definition}
radius={'sm'}
ref={field.ref}
id={fieldId}
aria-label={`number-field-${field.name}`}
error={definition.error ?? error?.message}
value={numericalValue === null ? '' : numericalValue}
decimalScale={definition.field_type == 'integer' ? 0 : 10}
step={1}
onChange={(value: number | string | null) => {
if (value != null && value.toString().trim() === '') {
onChange(null);
} else {
onChange(value);
}
}}
rightSection={
definition.placeholder &&
placeholderAutofill &&
numericalValue == null && (
<AutoFillRightSection
value={field.value}
fieldName={field.name}
definition={definition}
onChange={onChange}
/>
)
}
/>
);
}

View File

@@ -1,15 +1,7 @@
import { t } from '@lingui/core/macro'; import { TextInput } from '@mantine/core';
import { TextInput, Tooltip } from '@mantine/core'; import { useCallback, useEffect, useId, useMemo, useState } from 'react';
import { IconCopyCheck, IconX } from '@tabler/icons-react';
import {
type ReactNode,
useCallback,
useEffect,
useId,
useMemo,
useState
} from 'react';
import type { FieldValues, UseControllerReturn } from 'react-hook-form'; import type { FieldValues, UseControllerReturn } from 'react-hook-form';
import AutoFillRightSection from './AutoFillRightSection';
/* /*
* Custom implementation of the mantine <TextInput> component, * Custom implementation of the mantine <TextInput> component,
@@ -53,44 +45,6 @@ export default function TextField({
setTextValue(value || ''); setTextValue(value || '');
}, [value]); }, [value]);
// Construct a "right section" for the text field
const textFieldRightSection: ReactNode = useMemo(() => {
if (definition.rightSection) {
// Use the specified override value
return definition.rightSection;
} else if (textValue) {
if (!definition.required && !definition.disabled) {
// Render a button to clear the text field
return (
<Tooltip label={t`Clear`} position='top-end'>
<IconX
aria-label={`text-field-${fieldName}-clear`}
size='1rem'
color='red'
onClick={() => onTextChange('')}
/>
</Tooltip>
);
}
} else if (
!textValue &&
definition.placeholder &&
placeholderAutofill &&
!definition.disabled
) {
return (
<Tooltip label={t`Accept suggested value`} position='top-end'>
<IconCopyCheck
aria-label={`text-field-${fieldName}-accept-placeholder`}
size='1rem'
color='green'
onClick={() => onTextChange(definition.placeholder)}
/>
</Tooltip>
);
}
}, [placeholderAutofill, definition, textValue]);
return ( return (
<TextInput <TextInput
{...definition} {...definition}
@@ -114,7 +68,16 @@ export default function TextField({
} }
onKeyDown(event.code); onKeyDown(event.code);
}} }}
rightSection={textFieldRightSection} rightSection={
placeholderAutofill && (
<AutoFillRightSection
value={textValue}
fieldName={field.name}
definition={definition}
onChange={onChange}
/>
)
}
/> />
); );
} }

View File

@@ -34,6 +34,7 @@ import { StandaloneField } from '../components/forms/StandaloneField';
import { ProgressBar } from '@lib/components/ProgressBar'; import { ProgressBar } from '@lib/components/ProgressBar';
import { apiUrl } from '@lib/functions/Api'; import { apiUrl } from '@lib/functions/Api';
import { toNumber } from '@lib/functions/Conversion';
import type { import type {
ApiFormAdjustFilterType, ApiFormAdjustFilterType,
ApiFormFieldSet ApiFormFieldSet
@@ -58,19 +59,59 @@ import { useGlobalSettingsState } from '../states/SettingsStates';
export function usePurchaseOrderLineItemFields({ export function usePurchaseOrderLineItemFields({
supplierId, supplierId,
orderId, orderId,
currency,
create create
}: { }: {
supplierId?: number; supplierId?: number;
orderId?: number; orderId?: number;
currency?: string;
create?: boolean; create?: boolean;
}) { }) {
const globalSettings = useGlobalSettingsState(); const globalSettings = useGlobalSettingsState();
const [purchasePrice, setPurchasePrice] = useState<string>(''); const [purchasePrice, setPurchasePrice] = useState<string>('');
const [autoPricing, setAutoPricing] = useState(true); const [purchasePriceCurrency, setPurchasePriceCurrency] = useState<string>(
currency ?? ''
);
const [autoPricing, setAutoPricing] = useState(false);
const [quantity, setQuantity] = useState<string>('1');
// Internal part information // Internal part information
const [part, setPart] = useState<any>({}); const [part, setPart] = useState<any>({});
const [priceBreaks, setPriceBreaks] = useState<any[]>([]);
const [suggestedPurchasePrice, setSuggestedPurchasePrice] = useState<
string | undefined
>(undefined);
// Update suggested purchase price when part, quantity, or price breaks change
useEffect(() => {
// Only attempt to set purchase price for new line items
if (!create) return;
const qty = toNumber(quantity, null);
if (qty == null || qty <= 0) {
setSuggestedPurchasePrice(undefined);
return;
}
if (!part || !priceBreaks || priceBreaks.length === 0) {
setSuggestedPurchasePrice(undefined);
return;
}
const applicablePriceBreaks = priceBreaks
.filter((pb: any) => qty >= pb.quantity)
.sort((a: any, b: any) => b.quantity - a.quantity);
if (applicablePriceBreaks.length) {
setSuggestedPurchasePrice(applicablePriceBreaks[0].price);
} else {
setSuggestedPurchasePrice(undefined);
}
}, [create, part, quantity, priceBreaks]);
useEffect(() => { useEffect(() => {
if (autoPricing) { if (autoPricing) {
@@ -95,9 +136,11 @@ export function usePurchaseOrderLineItemFields({
part_detail: true, part_detail: true,
supplier_detail: true, supplier_detail: true,
active: true, active: true,
part_active: true part_active: true,
price_breaks: true
}, },
onValueChange: (value, record) => { onValueChange: (value, record) => {
setPriceBreaks(record?.price_breaks ?? []);
setPart(record?.part_detail ?? {}); setPart(record?.part_detail ?? {});
}, },
adjustFilters: (adjust: ApiFormAdjustFilterType) => { adjustFilters: (adjust: ApiFormAdjustFilterType) => {
@@ -108,14 +151,22 @@ export function usePurchaseOrderLineItemFields({
} }
}, },
reference: {}, reference: {},
quantity: {}, quantity: {
onValueChange: (value) => {
setQuantity(value);
}
},
purchase_price: { purchase_price: {
icon: <IconCurrencyDollar />, icon: <IconCurrencyDollar />,
value: purchasePrice, value: purchasePrice,
placeholder: suggestedPurchasePrice,
placeholderAutofill: true,
onValueChange: setPurchasePrice onValueChange: setPurchasePrice
}, },
purchase_price_currency: { purchase_price_currency: {
icon: <IconCoins /> icon: <IconCoins />,
value: purchasePriceCurrency,
onValueChange: setPurchasePriceCurrency
}, },
auto_pricing: { auto_pricing: {
value: autoPricing, value: autoPricing,
@@ -162,7 +213,8 @@ export function usePurchaseOrderLineItemFields({
globalSettings, globalSettings,
supplierId, supplierId,
autoPricing, autoPricing,
purchasePrice purchasePrice,
suggestedPurchasePrice
]); ]);
return fields; return fields;

View File

@@ -3,6 +3,7 @@ import { Table } from '@mantine/core';
import { import {
IconAddressBook, IconAddressBook,
IconCalendar, IconCalendar,
IconCoins,
IconUser, IconUser,
IconUsers IconUsers
} from '@tabler/icons-react'; } from '@tabler/icons-react';
@@ -15,6 +16,7 @@ import { StandaloneField } from '../components/forms/StandaloneField';
import { ProgressBar } from '@lib/components/ProgressBar'; import { ProgressBar } from '@lib/components/ProgressBar';
import { apiUrl } from '@lib/functions/Api'; import { apiUrl } from '@lib/functions/Api';
import { toNumber } from '@lib/functions/Conversion';
import type { import type {
ApiFormAdjustFilterType, ApiFormAdjustFilterType,
ApiFormFieldSet, ApiFormFieldSet,
@@ -109,26 +111,38 @@ export function useSalesOrderLineItemFields({
create?: boolean; create?: boolean;
currency?: string; currency?: string;
}): ApiFormFieldSet { }): ApiFormFieldSet {
const [salePrice, setSalePrice] = useState<string>('0'); const [salePrice, setSalePrice] = useState<string | undefined>(undefined);
const [partCurrency, setPartCurrency] = useState<string>(currency ?? ''); const [partCurrency, setPartCurrency] = useState<string>(currency ?? '');
const [part, setPart] = useState<any>({}); const [part, setPart] = useState<any>({});
const [quantity, setQuantity] = useState<string>(''); const [quantity, setQuantity] = useState<string>('1');
// Update suggested sale price when part, quantity, or part currency changes
useEffect(() => { useEffect(() => {
if (!create || !part || !part.price_breaks) return; // Only attempt to set sale price for new line items
if (!create) return;
const qty = quantity ? Number.parseInt(quantity, 10) : 0; const qty = toNumber(quantity, null);
const applicablePriceBreaks = part.price_breaks if (qty == null || qty <= 0) {
.filter( setSalePrice(undefined);
(pb: any) => pb.price_currency == partCurrency && qty <= pb.quantity return;
}
if (!part || !part.price_breaks || part.price_breaks.length === 0) {
setSalePrice(undefined);
return;
}
const applicablePriceBreaks = part?.price_breaks
?.filter(
(pb: any) => pb.price_currency == partCurrency && qty >= pb.quantity
) )
.sort((a: any, b: any) => a.quantity - b.quantity); .sort((a: any, b: any) => b.quantity - a.quantity);
if (applicablePriceBreaks.length) { if (applicablePriceBreaks.length) {
setSalePrice(applicablePriceBreaks[0].price); setSalePrice(applicablePriceBreaks[0].price);
} else { } else {
setSalePrice(''); setSalePrice(undefined);
} }
}, [part, quantity, partCurrency, create]); }, [part, quantity, partCurrency, create]);
@@ -151,12 +165,16 @@ export function useSalesOrderLineItemFields({
}, },
reference: {}, reference: {},
quantity: { quantity: {
onValueChange: setQuantity onValueChange: (value) => {
setQuantity(value);
}
}, },
sale_price: { sale_price: {
value: salePrice placeholder: salePrice,
placeholderAutofill: true
}, },
sale_price_currency: { sale_price_currency: {
icon: <IconCoins />,
value: partCurrency, value: partCurrency,
onValueChange: setPartCurrency onValueChange: setPartCurrency
}, },

View File

@@ -161,8 +161,8 @@ export function useDeleteApiFormModal(props: ApiFormModalProps) {
() => ({ () => ({
...props, ...props,
method: props.method ?? 'DELETE', method: props.method ?? 'DELETE',
submitText: t`Delete`, submitText: props.submitText ?? t`Delete`,
submitColor: 'red', submitColor: props.submitColor ?? 'red',
successMessage: successMessage:
props.successMessage === null props.successMessage === null
? null ? null

View File

@@ -1,11 +1,7 @@
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { import { type RowAction, RowEditAction } from '@lib/components/RowActions';
type RowAction,
RowDeleteAction,
RowEditAction
} from '@lib/components/RowActions';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType'; import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles'; import { UserRoles } from '@lib/enums/Roles';
@@ -14,7 +10,8 @@ import { ActionButton } from '@lib/index';
import type { TableFilter } from '@lib/types/Filters'; import type { TableFilter } from '@lib/types/Filters';
import type { StockOperationProps } from '@lib/types/Forms'; import type { StockOperationProps } from '@lib/types/Forms';
import type { TableColumn } from '@lib/types/Tables'; import type { TableColumn } from '@lib/types/Tables';
import { IconCircleDashedCheck } from '@tabler/icons-react'; import { Alert } from '@mantine/core';
import { IconCircleDashedCheck, IconCircleX } from '@tabler/icons-react';
import { useConsumeBuildItemsForm } from '../../forms/BuildForms'; import { useConsumeBuildItemsForm } from '../../forms/BuildForms';
import { import {
useDeleteApiFormModal, useDeleteApiFormModal,
@@ -177,8 +174,14 @@ export default function BuildAllocatedStockTable({
const deleteItem = useDeleteApiFormModal({ const deleteItem = useDeleteApiFormModal({
pk: selectedItemId, pk: selectedItemId,
url: ApiEndpoints.build_item_list, url: ApiEndpoints.build_item_list,
title: t`Delete Stock Allocation`, title: t`Remove Allocated Stock`,
table: table submitText: t`Remove`,
table: table,
preFormContent: (
<Alert color='red' title={t`Confirm Removal`}>
{t`Are you sure you want to remove this allocated stock from the order?`}
</Alert>
)
}); });
const [selectedItems, setSelectedItems] = useState<any[]>([]); const [selectedItems, setSelectedItems] = useState<any[]>([]);
@@ -251,13 +254,17 @@ export default function BuildAllocatedStockTable({
editItem.open(); editItem.open();
} }
}), }),
RowDeleteAction({ {
title: t`Remove`,
tooltip: t`Remove allocated stock`,
icon: <IconCircleX />,
color: 'red',
hidden: !user.hasDeleteRole(UserRoles.build), hidden: !user.hasDeleteRole(UserRoles.build),
onClick: () => { onClick: () => {
setSelectedItemId(record.pk); setSelectedItemId(record.pk);
deleteItem.open(); deleteItem.open();
} }
}) }
]; ];
}, },
[user] [user]

View File

@@ -5,6 +5,7 @@ import {
IconCircleCheck, IconCircleCheck,
IconCircleDashedCheck, IconCircleDashedCheck,
IconCircleMinus, IconCircleMinus,
IconCircleX,
IconShoppingCart, IconShoppingCart,
IconTool, IconTool,
IconWand IconWand
@@ -15,11 +16,7 @@ import { useNavigate } from 'react-router-dom';
import { ActionButton } from '@lib/components/ActionButton'; import { ActionButton } from '@lib/components/ActionButton';
import { ProgressBar } from '@lib/components/ProgressBar'; import { ProgressBar } from '@lib/components/ProgressBar';
import { import { RowEditAction, RowViewAction } from '@lib/components/RowActions';
RowDeleteAction,
RowEditAction,
RowViewAction
} from '@lib/components/RowActions';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType'; import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles'; import { UserRoles } from '@lib/enums/Roles';
@@ -110,12 +107,16 @@ export function BuildLineSubTable({
onEditAllocation?.(record.pk); onEditAllocation?.(record.pk);
} }
}), }),
RowDeleteAction({ {
title: t`Remove`,
tooltip: t`Remove allocated stock`,
icon: <IconCircleX />,
color: 'red',
hidden: !onDeleteAllocation || !user.hasDeleteRole(UserRoles.build), hidden: !onDeleteAllocation || !user.hasDeleteRole(UserRoles.build),
onClick: () => { onClick: () => {
onDeleteAllocation?.(record.pk); onDeleteAllocation?.(record.pk);
} }
}), },
RowViewAction({ RowViewAction({
title: t`View Stock Item`, title: t`View Stock Item`,
modelType: ModelType.stockitem, modelType: ModelType.stockitem,
@@ -660,8 +661,14 @@ export default function BuildLineTable({
const deleteAllocation = useDeleteApiFormModal({ const deleteAllocation = useDeleteApiFormModal({
url: ApiEndpoints.build_item_list, url: ApiEndpoints.build_item_list,
pk: selectedAllocation, pk: selectedAllocation,
title: t`Delete Stock Allocation`, title: t`Remove Allocated Stock`,
onFormSuccess: table.refreshTable submitText: t`Remove`,
onFormSuccess: table.refreshTable,
preFormContent: (
<Alert color='red' title={t`Confirm Removal`}>
{t`Are you sure you want to remove this allocated stock from the order?`}
</Alert>
)
}); });
const [partsToOrder, setPartsToOrder] = useState<any[]>([]); const [partsToOrder, setPartsToOrder] = useState<any[]>([]);

View File

@@ -26,7 +26,7 @@ import { formatDate } from '../../defaults/formatters';
import { useTestResultFields } from '../../forms/StockForms'; import { useTestResultFields } from '../../forms/StockForms';
import { useCreateApiFormModal } from '../../hooks/UseForm'; import { useCreateApiFormModal } from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable'; import { useTable } from '../../hooks/UseTable';
import { LocationColumn } from '../ColumnRenderers'; import { LocationColumn, PartColumn } from '../ColumnRenderers';
import { import {
BatchFilter, BatchFilter,
HasBatchCodeFilter, HasBatchCodeFilter,
@@ -243,6 +243,14 @@ export default function PartTestResultTable({
const tableColumns: TableColumn[] = useMemo(() => { const tableColumns: TableColumn[] = useMemo(() => {
// Fixed columns // Fixed columns
const columns: TableColumn[] = [ const columns: TableColumn[] = [
PartColumn({
title: t`Part`,
part: 'part_detail',
full_name: true,
ordering: 'part',
sortable: true,
switchable: true
}),
{ {
accessor: 'stock', accessor: 'stock',
title: t`Stock Item`, title: t`Stock Item`,
@@ -354,6 +362,7 @@ export default function PartTestResultTable({
part_detail: true, part_detail: true,
location_detail: true, location_detail: true,
tests: true, tests: true,
part: partId,
build: buildId build: buildId
}, },
enableSelection: true, enableSelection: true,

View File

@@ -289,7 +289,8 @@ export function PurchaseOrderLineItemTable({
const addPurchaseOrderFields = usePurchaseOrderLineItemFields({ const addPurchaseOrderFields = usePurchaseOrderLineItemFields({
create: true, create: true,
orderId: orderId, orderId: orderId,
supplierId: supplierId supplierId: supplierId,
currency: currency
}); });
const [initialData, setInitialData] = useState<any>({}); const [initialData, setInitialData] = useState<any>({});
@@ -311,7 +312,8 @@ export function PurchaseOrderLineItemTable({
const editLineItemFields = usePurchaseOrderLineItemFields({ const editLineItemFields = usePurchaseOrderLineItemFields({
create: false, create: false,
orderId: orderId, orderId: orderId,
supplierId: supplierId supplierId: supplierId,
currency: currency
}); });
const editLine = useEditApiFormModal({ const editLine = useEditApiFormModal({

View File

@@ -4,7 +4,6 @@ import { useCallback, useMemo, useState } from 'react';
import { ActionButton } from '@lib/components/ActionButton'; import { ActionButton } from '@lib/components/ActionButton';
import { import {
type RowAction, type RowAction,
RowDeleteAction,
RowEditAction, RowEditAction,
RowViewAction RowViewAction
} from '@lib/components/RowActions'; } from '@lib/components/RowActions';
@@ -15,7 +14,8 @@ import { apiUrl } from '@lib/functions/Api';
import type { TableFilter } from '@lib/types/Filters'; import type { TableFilter } from '@lib/types/Filters';
import type { StockOperationProps } from '@lib/types/Forms'; import type { StockOperationProps } from '@lib/types/Forms';
import type { TableColumn } from '@lib/types/Tables'; import type { TableColumn } from '@lib/types/Tables';
import { IconTruckDelivery } from '@tabler/icons-react'; import { Alert } from '@mantine/core';
import { IconCircleX, IconTruckDelivery } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { formatDate } from '../../defaults/formatters'; import { formatDate } from '../../defaults/formatters';
import { useSalesOrderAllocationFields } from '../../forms/SalesOrderForms'; import { useSalesOrderAllocationFields } from '../../forms/SalesOrderForms';
@@ -215,7 +215,13 @@ export default function SalesOrderAllocationTable({
const deleteAllocation = useDeleteApiFormModal({ const deleteAllocation = useDeleteApiFormModal({
url: ApiEndpoints.sales_order_allocation_list, url: ApiEndpoints.sales_order_allocation_list,
pk: selectedAllocation, pk: selectedAllocation,
title: t`Delete Allocation`, title: t`Remove Allocated Stock`,
preFormContent: (
<Alert color='red' title={t`Confirm Removal`}>
{t`Are you sure you want to remove this allocated stock from the order?`}
</Alert>
),
submitText: t`Remove`,
onFormSuccess: () => table.refreshTable() onFormSuccess: () => table.refreshTable()
}); });
@@ -237,8 +243,11 @@ export default function SalesOrderAllocationTable({
editAllocation.open(); editAllocation.open();
} }
}), }),
RowDeleteAction({ {
tooltip: t`Delete Allocation`, title: t`Remove`,
tooltip: t`Remove allocated stock`,
icon: <IconCircleX />,
color: 'red',
hidden: hidden:
isShipped || isShipped ||
!allowEdit || !allowEdit ||
@@ -247,7 +256,7 @@ export default function SalesOrderAllocationTable({
setSelectedAllocation(record.pk); setSelectedAllocation(record.pk);
deleteAllocation.open(); deleteAllocation.open();
} }
}), },
RowViewAction({ RowViewAction({
tooltip: t`View Shipment`, tooltip: t`View Shipment`,
title: t`View Shipment`, title: t`View Shipment`,

View File

@@ -253,7 +253,7 @@ test('Build Order - Build Outputs', async ({ browser }) => {
// Accept the suggested batch code // Accept the suggested batch code
await page await page
.getByRole('img', { name: 'text-field-batch_code-accept-placeholder' }) .getByRole('img', { name: 'field-batch_code-accept-placeholder' })
.click(); .click();
await page.getByLabel('related-field-location').click(); await page.getByLabel('related-field-location').click();

View File

@@ -514,10 +514,13 @@ test('Parts - Parameters', async ({ browser }) => {
// Submit with "false" value // Submit with "false" value
await page.getByRole('button', { name: 'Submit' }).click(); await page.getByRole('button', { name: 'Submit' }).click();
const cell = await page.getByRole('cell', {
name: 'Is this part polarized?'
});
// Check for the expected values in the table // Check for the expected values in the table
let row = await getRowFromCell( const row = await getRowFromCell(cell);
await page.getByRole('cell', { name: 'Polarized', exact: true })
);
await row.getByRole('cell', { name: 'No', exact: true }).waitFor(); await row.getByRole('cell', { name: 'No', exact: true }).waitFor();
await row.getByRole('cell', { name: 'allaccess' }).waitFor(); await row.getByRole('cell', { name: 'allaccess' }).waitFor();
await row.getByLabel(/row-action-menu-/i).click(); await row.getByLabel(/row-action-menu-/i).click();
@@ -532,13 +535,6 @@ test('Parts - Parameters', async ({ browser }) => {
.click(); .click();
await page.getByRole('button', { name: 'Submit' }).click(); await page.getByRole('button', { name: 'Submit' }).click();
row = await getRowFromCell(
await page.getByRole('cell', { name: 'Polarized', exact: true })
);
await row.getByRole('cell', { name: 'Yes', exact: true }).waitFor();
await page.getByText('1 - 1 / 1').waitFor();
// Finally, delete the parameter // Finally, delete the parameter
await row.getByLabel(/row-action-menu-/i).click(); await row.getByLabel(/row-action-menu-/i).click();
await page.getByRole('menuitem', { name: 'Delete' }).click(); await page.getByRole('menuitem', { name: 'Delete' }).click();
@@ -585,6 +581,15 @@ test('Parts - Parameter Filtering', async ({ browser }) => {
await page.getByText(/\/ 42\d/).waitFor(); await page.getByText(/\/ 42\d/).waitFor();
}); });
test('Parts - Test Results', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: '/part/74/test_results' });
await page.waitForTimeout(2500);
await page.getByText(/1 - \d+ \/ 1\d\d/).waitFor();
await page.getByText('Blue Paint Applied').waitFor();
});
test('Parts - Notes', async ({ browser }) => { test('Parts - Notes', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'part/69/notes' }); const page = await doCachedLogin(browser, { url: 'part/69/notes' });

View File

@@ -216,6 +216,44 @@ test('Purchase Orders - Filters', async ({ browser }) => {
await page.getByRole('option', { name: 'Target Date After' }).waitFor(); await page.getByRole('option', { name: 'Target Date After' }).waitFor();
}); });
test('Purchase Orders - Price Breaks', async ({ browser }) => {
const page = await doCachedLogin(browser, {
url: 'purchasing/purchase-order/14/line-items'
});
await page
.getByRole('button', { name: 'action-button-add-line-item' })
.click();
await page.getByLabel('related-field-part').fill('002.01');
await page.getByRole('option', { name: 'PCBWOY PCB-002.01' }).click();
// Expected price-break values
const priceBreaks = {
1: 500,
8: 500,
10: 565,
99: 565,
999: 205
};
for (const [qty, expectedPrice] of Object.entries(priceBreaks)) {
await page.getByLabel('number-field-quantity').fill(qty);
await expect(
page.getByRole('textbox', { name: 'number-field-purchase_price' })
).toHaveAttribute('placeholder', expectedPrice.toString(), {
timeout: 500
});
}
// Auto-fill the suggested sale price
await page.getByLabel('field-purchase_price-accept-placeholder').click();
await expect(
page.getByRole('textbox', { name: 'number-field-purchase_price' })
).toHaveValue('205', { timeout: 500 });
});
test('Purchase Orders - Order Parts', async ({ browser }) => { test('Purchase Orders - Order Parts', async ({ browser }) => {
const page = await doCachedLogin(browser); const page = await doCachedLogin(browser);

View File

@@ -279,3 +279,45 @@ test('Sales Orders - Duplicate', async ({ browser }) => {
await page.getByText('Complete').first().waitFor(); await page.getByText('Complete').first().waitFor();
await page.getByText('2 / 2').waitFor(); await page.getByText('2 / 2').waitFor();
}); });
/**
* Test auto-calculation of price breaks on sales order line items
*/
test('Sales Orders - Price Breaks', async ({ browser }) => {
const page = await doCachedLogin(browser, {
url: 'sales/sales-order/14/line-items'
});
await page
.getByRole('button', { name: 'action-button-add-line-item' })
.click();
await page.getByLabel('related-field-part').fill('software');
await page.getByRole('option', { name: 'Software License' }).click();
const priceBreaks = {
1: 123,
2: 123,
10: 104,
49: 104,
56: 96,
104: 78
};
for (const [qty, expectedPrice] of Object.entries(priceBreaks)) {
await page.getByLabel('number-field-quantity').fill(qty);
await expect(
page.getByRole('textbox', { name: 'number-field-sale_price' })
).toHaveAttribute('placeholder', expectedPrice.toString(), {
timeout: 500
});
}
// Auto-fill the suggested sale price
await page.getByLabel('field-sale_price-accept-placeholder').click();
// The sale price field should now contain the suggested value
await expect(
page.getByRole('textbox', { name: 'number-field-sale_price' })
).toHaveValue('78', { timeout: 500 });
});