mirror of
https://github.com/inventree/InvenTree.git
synced 2025-11-30 09:20:03 +00:00
[UI] Suggested pricing (#10867)
* Refactor NumberField into separate component * Add helper func to ensure a number is a number * Use placeholder value for suggested sale price * Fix for auto-fill * Tweak price calculation * Add UI testing for sales order price breaks * Fix aria label name * Annotate price breaks to supplier part * Fetch price break data * Support price breaks for purchase order pricing * Fix required to prevent circular imports * Add playwright tests for purchase order price breaks * Bump API version * Re-add output options for SupplierPriceBreakList * Revert change * Simplify unit test
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/frontend/src/components/forms/fields/NumberField.tsx
Normal file
91
src/frontend/src/components/forms/fields/NumberField.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user