diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 2751a14c2c..a8b8523018 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # 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.""" 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 - Adds "on_order" filter to the BuildLine API endpoint - Allow BuildLine list to be ordered by "on_order" and "in_production" fields diff --git a/src/backend/InvenTree/company/api.py b/src/backend/InvenTree/company/api.py index 12e8a4c1e0..cde689119e 100644 --- a/src/backend/InvenTree/company/api.py +++ b/src/backend/InvenTree/company/api.py @@ -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): """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. - GET: Retrieve list of SupplierPriceBreak objects - POST: Create a new SupplierPriceBreak object """ - queryset = SupplierPriceBreak.objects.all() - serializer_class = SupplierPriceBreakSerializer - filterset_class = SupplierPriceBreakFilter output_options = SupplierPriceBreakOutputOptions - def get_queryset(self): - """Return annotated queryset for the SupplierPriceBreak list endpoint.""" - queryset = super().get_queryset() - queryset = SupplierPriceBreakSerializer.annotate_queryset(queryset) - - return queryset - + filterset_class = SupplierPriceBreakFilter filter_backends = SEARCH_ORDER_FILTER_ALIAS - ordering_fields = ['quantity', 'supplier', 'SKU', 'price'] search_fields = ['part__SKU', 'part__supplier__name'] @@ -507,19 +513,9 @@ class SupplierPriceBreakList(SerializerContextMixin, OutputOptionsMixin, ListCre ordering = 'quantity' -class SupplierPriceBreakDetail(RetrieveUpdateDestroyAPI): +class SupplierPriceBreakDetail(SupplierPriceBreakMixin, RetrieveUpdateDestroyAPI): """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 = [ path( diff --git a/src/backend/InvenTree/company/serializers.py b/src/backend/InvenTree/company/serializers.py index d6bfbfab27..5488abbf43 100644 --- a/src/backend/InvenTree/company/serializers.py +++ b/src/backend/InvenTree/company/serializers.py @@ -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() class SupplierPartSerializer( FilterableSerializerMixin, @@ -373,14 +406,15 @@ class SupplierPartSerializer( 'pack_quantity', 'pack_quantity_native', 'part', - 'part_detail', 'pretty_name', 'SKU', 'supplier', 'supplier_detail', 'updated', 'notes', + 'part_detail', 'tags', + 'price_breaks', ] read_only_fields = [ 'availability_updated', @@ -441,6 +475,18 @@ class SupplierPartSerializer( 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( label=_('Part'), source='part', many=False, read_only=True, allow_null=True ) @@ -489,6 +535,8 @@ class SupplierPartSerializer( Fields: 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( @@ -532,24 +580,24 @@ class SupplierPartSerializer( @register_importer() 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: """Metaclass options.""" model = SupplierPriceBreak fields = [ - 'pk', - 'part', - 'part_detail', - 'quantity', - 'price', - 'price_currency', - 'supplier', + *SupplierPriceBreakBriefSerializer.Meta.fields, 'supplier_detail', - 'updated', + 'part_detail', ] @staticmethod @@ -559,25 +607,15 @@ class SupplierPriceBreakSerializer( 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( CompanyBriefSerializer( source='part.supplier', many=False, read_only=True, allow_null=True ) ) - # Detail serializer for SupplierPart part_detail = enable_filter( SupplierPartSerializer( source='part', brief=True, many=False, read_only=True, allow_null=True - ) + ), + False, ) diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index 87329781ed..2a8b62fa52 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -730,41 +730,13 @@ class PartSerializer( - Allows us to optionally pass extra fields based on the query. """ 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) - # pricing = kwargs.pop('pricing', True) - # path_detail = kwargs.pop('path_detail', False) - # price_breaks = kwargs.pop('price_breaks', False) super().__init__(*args, **kwargs) if isGeneratingSchema(): 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: # These fields are only used for the LIST API endpoint for f in self.skip_create_fields(): diff --git a/src/frontend/lib/functions/Conversion.tsx b/src/frontend/lib/functions/Conversion.tsx index 64ae944e8a..dcc6409104 100644 --- a/src/frontend/lib/functions/Conversion.tsx +++ b/src/frontend/lib/functions/Conversion.tsx @@ -40,3 +40,36 @@ export function identifierString(value: string): string { 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; +} diff --git a/src/frontend/src/components/forms/fields/ApiFormField.tsx b/src/frontend/src/components/forms/fields/ApiFormField.tsx index 7e2dca1089..cc6f12e710 100644 --- a/src/frontend/src/components/forms/fields/ApiFormField.tsx +++ b/src/frontend/src/components/forms/fields/ApiFormField.tsx @@ -1,5 +1,5 @@ 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 { useCallback, useEffect, useMemo } from 'react'; import { type Control, type FieldValues, useController } from 'react-hook-form'; @@ -12,6 +12,7 @@ import DateField from './DateField'; import { DependentField } from './DependentField'; import IconField from './IconField'; import { NestedObjectField } from './NestedObjectField'; +import NumberField from './NumberField'; import { RelatedModelField } from './RelatedModelField'; import { TableField } from './TableField'; import TextField from './TextField'; @@ -92,7 +93,7 @@ export function ApiFormField({ // Callback helper when form value changes const onChange = useCallback( (value: any) => { - let rtnValue = value; + let rtnValue: any = value; // Allow for custom value adjustments (per field) if (definition.adjustValue) { rtnValue = definition.adjustValue(value); @@ -106,34 +107,6 @@ export function ApiFormField({ [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 const fieldInstance = useMemo(() => { switch (fieldDefinition.field_type) { @@ -197,23 +170,14 @@ export function ApiFormField({ case 'float': case 'number': return ( - { - if (value != null && value.toString().trim() === '') { - onChange(null); - } else { - onChange(value); - } + { + onChange(value); }} - step={1} /> ); case 'choice': @@ -284,7 +248,6 @@ export function ApiFormField({ fieldId, fieldName, fieldDefinition, - numericalValue, onChange, onKeyDown, reducedDefinition, diff --git a/src/frontend/src/components/forms/fields/AutoFillRightSection.tsx b/src/frontend/src/components/forms/fields/AutoFillRightSection.tsx new file mode 100644 index 0000000000..c3c5a5661e --- /dev/null +++ b/src/frontend/src/components/forms/fields/AutoFillRightSection.tsx @@ -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 ( + + onChange(emptyValue)} + /> + + ); + } + } else if (!value && definition.placeholder && !definition.disabled) { + // Render auto-fill button + return ( + + onChange(definition.placeholder)} + /> + + ); + } +} diff --git a/src/frontend/src/components/forms/fields/NumberField.tsx b/src/frontend/src/components/forms/fields/NumberField.tsx new file mode 100644 index 0000000000..63bfbb2868 --- /dev/null +++ b/src/frontend/src/components/forms/fields/NumberField.tsx @@ -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 component, + * used for rendering numerical input fields in forms. + */ +export default function NumberField({ + controller, + fieldName, + definition, + placeholderAutofill, + onChange +}: Readonly<{ + controller: UseControllerReturn; + 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 ( + { + if (value != null && value.toString().trim() === '') { + onChange(null); + } else { + onChange(value); + } + }} + rightSection={ + definition.placeholder && + placeholderAutofill && + numericalValue == null && ( + + ) + } + /> + ); +} diff --git a/src/frontend/src/components/forms/fields/TextField.tsx b/src/frontend/src/components/forms/fields/TextField.tsx index 09fd716228..266e48e2ae 100644 --- a/src/frontend/src/components/forms/fields/TextField.tsx +++ b/src/frontend/src/components/forms/fields/TextField.tsx @@ -1,15 +1,7 @@ -import { t } from '@lingui/core/macro'; -import { TextInput, Tooltip } from '@mantine/core'; -import { IconCopyCheck, IconX } from '@tabler/icons-react'; -import { - type ReactNode, - useCallback, - useEffect, - useId, - useMemo, - useState -} from 'react'; +import { TextInput } from '@mantine/core'; +import { useCallback, useEffect, useId, useMemo, useState } from 'react'; import type { FieldValues, UseControllerReturn } from 'react-hook-form'; +import AutoFillRightSection from './AutoFillRightSection'; /* * Custom implementation of the mantine component, @@ -53,44 +45,6 @@ export default function TextField({ setTextValue(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 ( - - onTextChange('')} - /> - - ); - } - } else if ( - !textValue && - definition.placeholder && - placeholderAutofill && - !definition.disabled - ) { - return ( - - onTextChange(definition.placeholder)} - /> - - ); - } - }, [placeholderAutofill, definition, textValue]); - return ( + ) + } /> ); } diff --git a/src/frontend/src/forms/PurchaseOrderForms.tsx b/src/frontend/src/forms/PurchaseOrderForms.tsx index 47a01900a9..284899efe5 100644 --- a/src/frontend/src/forms/PurchaseOrderForms.tsx +++ b/src/frontend/src/forms/PurchaseOrderForms.tsx @@ -34,6 +34,7 @@ import { StandaloneField } from '../components/forms/StandaloneField'; import { ProgressBar } from '@lib/components/ProgressBar'; import { apiUrl } from '@lib/functions/Api'; +import { toNumber } from '@lib/functions/Conversion'; import type { ApiFormAdjustFilterType, ApiFormFieldSet @@ -58,19 +59,59 @@ import { useGlobalSettingsState } from '../states/SettingsStates'; export function usePurchaseOrderLineItemFields({ supplierId, orderId, + currency, create }: { supplierId?: number; orderId?: number; + currency?: string; create?: boolean; }) { const globalSettings = useGlobalSettingsState(); const [purchasePrice, setPurchasePrice] = useState(''); - const [autoPricing, setAutoPricing] = useState(true); + const [purchasePriceCurrency, setPurchasePriceCurrency] = useState( + currency ?? '' + ); + + const [autoPricing, setAutoPricing] = useState(false); + + const [quantity, setQuantity] = useState('1'); // Internal part information const [part, setPart] = useState({}); + const [priceBreaks, setPriceBreaks] = useState([]); + 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(() => { if (autoPricing) { @@ -95,9 +136,11 @@ export function usePurchaseOrderLineItemFields({ part_detail: true, supplier_detail: true, active: true, - part_active: true + part_active: true, + price_breaks: true }, onValueChange: (value, record) => { + setPriceBreaks(record?.price_breaks ?? []); setPart(record?.part_detail ?? {}); }, adjustFilters: (adjust: ApiFormAdjustFilterType) => { @@ -108,14 +151,22 @@ export function usePurchaseOrderLineItemFields({ } }, reference: {}, - quantity: {}, + quantity: { + onValueChange: (value) => { + setQuantity(value); + } + }, purchase_price: { icon: , value: purchasePrice, + placeholder: suggestedPurchasePrice, + placeholderAutofill: true, onValueChange: setPurchasePrice }, purchase_price_currency: { - icon: + icon: , + value: purchasePriceCurrency, + onValueChange: setPurchasePriceCurrency }, auto_pricing: { value: autoPricing, @@ -162,7 +213,8 @@ export function usePurchaseOrderLineItemFields({ globalSettings, supplierId, autoPricing, - purchasePrice + purchasePrice, + suggestedPurchasePrice ]); return fields; diff --git a/src/frontend/src/forms/SalesOrderForms.tsx b/src/frontend/src/forms/SalesOrderForms.tsx index bbc92a9b1d..36a0963e7f 100644 --- a/src/frontend/src/forms/SalesOrderForms.tsx +++ b/src/frontend/src/forms/SalesOrderForms.tsx @@ -3,6 +3,7 @@ import { Table } from '@mantine/core'; import { IconAddressBook, IconCalendar, + IconCoins, IconUser, IconUsers } from '@tabler/icons-react'; @@ -15,6 +16,7 @@ import { StandaloneField } from '../components/forms/StandaloneField'; import { ProgressBar } from '@lib/components/ProgressBar'; import { apiUrl } from '@lib/functions/Api'; +import { toNumber } from '@lib/functions/Conversion'; import type { ApiFormAdjustFilterType, ApiFormFieldSet, @@ -109,26 +111,38 @@ export function useSalesOrderLineItemFields({ create?: boolean; currency?: string; }): ApiFormFieldSet { - const [salePrice, setSalePrice] = useState('0'); + const [salePrice, setSalePrice] = useState(undefined); const [partCurrency, setPartCurrency] = useState(currency ?? ''); const [part, setPart] = useState({}); - const [quantity, setQuantity] = useState(''); + const [quantity, setQuantity] = useState('1'); + // Update suggested sale price when part, quantity, or part currency changes 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 - .filter( - (pb: any) => pb.price_currency == partCurrency && qty <= pb.quantity + if (qty == null || qty <= 0) { + setSalePrice(undefined); + 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) { setSalePrice(applicablePriceBreaks[0].price); } else { - setSalePrice(''); + setSalePrice(undefined); } }, [part, quantity, partCurrency, create]); @@ -151,12 +165,16 @@ export function useSalesOrderLineItemFields({ }, reference: {}, quantity: { - onValueChange: setQuantity + onValueChange: (value) => { + setQuantity(value); + } }, sale_price: { - value: salePrice + placeholder: salePrice, + placeholderAutofill: true }, sale_price_currency: { + icon: , value: partCurrency, onValueChange: setPartCurrency }, diff --git a/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx b/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx index a9c84d8f7e..7fc536e2b0 100644 --- a/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx +++ b/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx @@ -289,7 +289,8 @@ export function PurchaseOrderLineItemTable({ const addPurchaseOrderFields = usePurchaseOrderLineItemFields({ create: true, orderId: orderId, - supplierId: supplierId + supplierId: supplierId, + currency: currency }); const [initialData, setInitialData] = useState({}); @@ -311,7 +312,8 @@ export function PurchaseOrderLineItemTable({ const editLineItemFields = usePurchaseOrderLineItemFields({ create: false, orderId: orderId, - supplierId: supplierId + supplierId: supplierId, + currency: currency }); const editLine = useEditApiFormModal({ diff --git a/src/frontend/tests/pages/pui_build.spec.ts b/src/frontend/tests/pages/pui_build.spec.ts index 74dd51f41e..e76440b758 100644 --- a/src/frontend/tests/pages/pui_build.spec.ts +++ b/src/frontend/tests/pages/pui_build.spec.ts @@ -253,7 +253,7 @@ test('Build Order - Build Outputs', async ({ browser }) => { // Accept the suggested batch code await page - .getByRole('img', { name: 'text-field-batch_code-accept-placeholder' }) + .getByRole('img', { name: 'field-batch_code-accept-placeholder' }) .click(); await page.getByLabel('related-field-location').click(); diff --git a/src/frontend/tests/pages/pui_part.spec.ts b/src/frontend/tests/pages/pui_part.spec.ts index 92042b95d7..1691bd6630 100644 --- a/src/frontend/tests/pages/pui_part.spec.ts +++ b/src/frontend/tests/pages/pui_part.spec.ts @@ -514,10 +514,13 @@ test('Parts - Parameters', async ({ browser }) => { // Submit with "false" value 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 - let row = await getRowFromCell( - await page.getByRole('cell', { name: 'Polarized', exact: true }) - ); + const row = await getRowFromCell(cell); + await row.getByRole('cell', { name: 'No', exact: true }).waitFor(); await row.getByRole('cell', { name: 'allaccess' }).waitFor(); await row.getByLabel(/row-action-menu-/i).click(); @@ -532,13 +535,6 @@ test('Parts - Parameters', async ({ browser }) => { .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 await row.getByLabel(/row-action-menu-/i).click(); await page.getByRole('menuitem', { name: 'Delete' }).click(); diff --git a/src/frontend/tests/pages/pui_purchase_order.spec.ts b/src/frontend/tests/pages/pui_purchase_order.spec.ts index 9d4a3ff552..59dc21bbc0 100644 --- a/src/frontend/tests/pages/pui_purchase_order.spec.ts +++ b/src/frontend/tests/pages/pui_purchase_order.spec.ts @@ -216,6 +216,44 @@ test('Purchase Orders - Filters', async ({ browser }) => { 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 }) => { const page = await doCachedLogin(browser); diff --git a/src/frontend/tests/pages/pui_sales_order.spec.ts b/src/frontend/tests/pages/pui_sales_order.spec.ts index 6370b972af..1a9c04d0c3 100644 --- a/src/frontend/tests/pages/pui_sales_order.spec.ts +++ b/src/frontend/tests/pages/pui_sales_order.spec.ts @@ -279,3 +279,45 @@ test('Sales Orders - Duplicate', async ({ browser }) => { await page.getByText('Complete').first().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 }); +});