Compare commits

...

15 Commits

Author SHA1 Message Date
Oliver
3cae0d5066 Fix asset file serving (#9295)
- Backport of https://github.com/inventree/InvenTree/pull/9292
2025-03-14 09:04:42 +11:00
github-actions[bot]
21d266ab95 Auto-fill currency for new supplier part (#9286) (#9287)
- Closes https://github.com/inventree/InvenTree/issues/9284

(cherry picked from commit 7a43c3a83e)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-03-13 02:02:49 +11:00
github-actions[bot]
1ae27a6b77 Ignore sentry for TemplateSyntaxError (#9239) (#9241)
- Getting flodded with reports of users misapplied template filters

(cherry picked from commit 017d96f64e)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-03-05 22:01:12 +11:00
github-actions[bot]
b18ac57fb8 Tracking api fix (#9238) (#9240)
* [Bug] Fix search for StockTrackingList

- Removed invalid field

* Add unit test coverage for failing condition

* Fix 'notes' field for extra line item API

(cherry picked from commit 21ae1138ce)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-03-05 22:00:47 +11:00
github-actions[bot]
053b37ec3a Fix font size in location column (#9230) (#9231)
(cherry picked from commit d5a176c121)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-03-04 23:30:28 +11:00
github-actions[bot]
6fa6063639 [UI] Table Update (#9220) (#9221)
- Retain user selection for pageSize

(cherry picked from commit 8cee2e36ca)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-03-03 19:16:53 +11:00
github-actions[bot]
9b68dea26d Remove restriction on row action (#9201) (#9202)
(cherry picked from commit 92a9423c21)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-02-28 16:49:41 +11:00
github-actions[bot]
9d21776c86 Add 'note' field to form (#9186) (#9188)
(cherry picked from commit 92edbf41ab)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-02-26 14:14:30 +11:00
github-actions[bot]
4c9f042f8c Handle case of null stock location (#9183) (#9187)
(cherry picked from commit 94c2157d3c)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-02-26 09:31:58 +11:00
github-actions[bot]
3625b8f14c Use ref pattern on PO duplicate (#9100) (#9147)
* use ref pattern on PO duplicate

* use ref patterns on duplicate for other types of orders

* revert unintentional change to pre-commit

* add playwright tests

---------

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
(cherry picked from commit 2cabd02c6b)

Co-authored-by: Jacob Felknor <jacobfelknor073@gmail.com>
2025-02-22 20:46:00 +11:00
Oliver
cd41ca2a87 Batch code backport (#9138)
* Batch code fix (#9123)

* Fix batch code assignment when receiving items

* Add playwright tests

* Harden playwright tests

* Refactoring

* Handle undefined values

* Fix conflicts
2025-02-22 11:52:16 +11:00
github-actions[bot]
8a2fce9c36 Barcode validation fix (#9127) (#9130)
* Fix logic for adding items to SalesOrder

* Same thing for purchase orders

* Update serializers.py

Revert typo fix

- Otherwise, we need to do an API bump and the PR can't be back-ported!

(cherry picked from commit bc9dbf7df4)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-02-21 22:22:45 +11:00
github-actions[bot]
ee87cd7b23 Ignore inactive parts (#9125) (#9128)
(cherry picked from commit 6930ae7122)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-02-21 21:06:28 +11:00
Oliver
940abaa179 [UI] Pricing chart fixes (#9119) (#9124)
* Fix default values for pricing override

* Fix broken calculation for sale pricing

- Was previously excluding COMPLETED orders

* Fix for PricingOverviewPanel

* Fix for InvenTreeMoneySerializer

- Numbers should be represented as numbers!

* Front-end wrangling too

* Fix unit test
2025-02-21 20:39:20 +11:00
Oliver
7fefa5c213 Update version.py (#9112)
Bump version number to 0.17.8
2025-02-20 11:57:56 +11:00
33 changed files with 344 additions and 191 deletions

View File

@@ -5,6 +5,7 @@ import logging
from django.conf import settings
from django.core.exceptions import ValidationError
from django.http import Http404
from django.template.exceptions import TemplateSyntaxError
import rest_framework.exceptions
import sentry_sdk
@@ -29,6 +30,7 @@ def sentry_ignore_errors():
return [
Http404,
MissingRate,
TemplateSyntaxError,
ValidationError,
rest_framework.exceptions.AuthenticationFailed,
rest_framework.exceptions.NotAuthenticated,

View File

@@ -46,6 +46,12 @@ class InvenTreeMoneySerializer(MoneyField):
super().__init__(*args, **kwargs)
def to_representation(self, obj):
"""Convert the Money object to a decimal value for representation."""
val = super().to_representation(obj)
return float(val)
def get_value(self, data):
"""Test that the returned amount is a valid Decimal."""
amount = super(DecimalField, self).get_value(data)
@@ -74,7 +80,11 @@ class InvenTreeMoneySerializer(MoneyField):
):
return Money(amount, currency)
return amount
try:
fp_amount = float(amount)
return fp_amount
except Exception:
return amount
class InvenTreeCurrencySerializer(serializers.ChoiceField):

View File

@@ -18,7 +18,7 @@ from django.conf import settings
from .api_version import INVENTREE_API_TEXT, INVENTREE_API_VERSION
# InvenTree software version
INVENTREE_SW_VERSION = '0.17.7'
INVENTREE_SW_VERSION = '0.17.8'
logger = logging.getLogger('inventree')

View File

@@ -68,9 +68,9 @@ class GeneralExtraLineList(DataExportViewMixin):
filter_backends = SEARCH_ORDER_FILTER
ordering_fields = ['quantity', 'note', 'reference']
ordering_fields = ['quantity', 'notes', 'reference']
search_fields = ['quantity', 'note', 'reference', 'description']
search_fields = ['quantity', 'notes', 'reference', 'description']
filterset_fields = ['order']

View File

@@ -62,7 +62,6 @@ from order import models as OrderModels
from order.status_codes import (
PurchaseOrderStatus,
PurchaseOrderStatusGroups,
SalesOrderStatus,
SalesOrderStatusGroups,
)
from stock import models as StockModels
@@ -2839,6 +2838,9 @@ class PartPricing(common.models.MetaMixin):
for sub_part in bom_item.get_valid_parts_for_allocation():
# Check each part which *could* be used
if sub_part != bom_item.sub_part and not sub_part.active:
continue
sub_part_pricing = sub_part.pricing
sub_part_min = self.convert(sub_part_pricing.overall_min)
@@ -3134,9 +3136,12 @@ class PartPricing(common.models.MetaMixin):
min_sell_history = None
max_sell_history = None
# Calculate sale price history too
parts = self.part.get_descendants(include_self=True)
# Find all line items for shipped sales orders which reference this part
line_items = OrderModels.SalesOrderLineItem.objects.filter(
order__status=SalesOrderStatus.SHIPPED, part=self.part
order__status__in=SalesOrderStatusGroups.COMPLETE, part__in=parts
)
# Exclude line items which do not have associated pricing data

View File

@@ -11,7 +11,11 @@ import order.models
import plugin.base.barcodes.helper
import stock.models
from InvenTree.serializers import UserSerializer
from order.status_codes import PurchaseOrderStatus, SalesOrderStatus
from order.status_codes import (
PurchaseOrderStatus,
PurchaseOrderStatusGroups,
SalesOrderStatusGroups,
)
class BarcodeScanResultSerializer(serializers.ModelSerializer):
@@ -135,8 +139,8 @@ class BarcodePOAllocateSerializer(BarcodeSerializer):
def validate_purchase_order(self, order: order.models.PurchaseOrder):
"""Validate the provided order."""
if order.status != PurchaseOrderStatus.PENDING.value:
raise ValidationError(_('Purchase order is not pending'))
if order.status not in PurchaseOrderStatusGroups.OPEN:
raise ValidationError(_('Purchase order is not open'))
return order
@@ -213,8 +217,8 @@ class BarcodeSOAllocateSerializer(BarcodeSerializer):
def validate_sales_order(self, order: order.models.SalesOrder):
"""Validate the provided order."""
if order and order.status != SalesOrderStatus.PENDING.value:
raise ValidationError(_('Sales order is not pending'))
if order and order.status not in SalesOrderStatusGroups.OPEN:
raise ValidationError(_('Sales order is not open'))
return order

View File

@@ -1461,7 +1461,7 @@ class StockTrackingList(DataExportViewMixin, ListAPI):
ordering_fields = ['date']
search_fields = ['title', 'notes']
search_fields = ['notes']
class LocationDetail(CustomRetrieveUpdateDestroyAPI):

View File

@@ -1487,13 +1487,13 @@ class StockItemTest(StockAPITestCase):
data = self.get(url, expected_code=200).data
# Check fixture values
self.assertEqual(data['purchase_price'], '123.000000')
self.assertAlmostEqual(data['purchase_price'], 123, 3)
self.assertEqual(data['purchase_price_currency'], 'AUD')
# Update just the amount
data = self.patch(url, {'purchase_price': 456}, expected_code=200).data
self.assertEqual(data['purchase_price'], '456.000000')
self.assertAlmostEqual(data['purchase_price'], 456, 3)
self.assertEqual(data['purchase_price_currency'], 'AUD')
# Update the currency
@@ -2150,6 +2150,11 @@ class StockTrackingTest(StockAPITestCase):
response = self.get(url, {'limit': 1})
self.assertEqual(response.data['count'], N)
# Test with search and pagination
response = self.get(url, {'limit': 1, 'offset': 10, 'search': 'berries'})
self.assertEqual(response.data['count'], 0)
def test_list(self):
"""Test list endpoint."""
url = self.get_url()

View File

@@ -87,11 +87,6 @@ class TemplateTagTest(InvenTreeTestCase):
self.assertNotIn('show_server_selector', rsp)
self.assertEqual(rsp['server_list'], ['aa', 'bb'])
def test_redirects(self):
"""Test the redirect helper."""
response = self.client.get('/assets/testpath')
self.assertEqual(response.url, '/static/web/assets/testpath')
class TestWebHelpers(InvenTreeAPITestCase):
"""Tests for the web helpers."""

View File

@@ -2,7 +2,6 @@
from django.conf import settings
from django.http import JsonResponse
from django.shortcuts import redirect
from django.urls import include, path, re_path
from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.generic import TemplateView
@@ -12,16 +11,6 @@ from rest_framework import permissions, serializers
from InvenTree.mixins import RetrieveUpdateAPI
class RedirectAssetView(TemplateView):
"""View to redirect to static asset."""
def get(self, request, *args, **kwargs):
"""Redirect to static asset."""
return redirect(
f'{settings.STATIC_URL}web/assets/{kwargs["path"]}', permanent=True
)
class PreferredSerializer(serializers.Serializer):
"""Serializer for the preferred serializer session setting."""
@@ -72,14 +61,12 @@ class PreferredUiView(RetrieveUpdateAPI):
spa_view = ensure_csrf_cookie(TemplateView.as_view(template_name='web/index.html'))
assets_path = path('assets/<path:path>', RedirectAssetView.as_view())
urlpatterns = [
path(
f'{settings.FRONTEND_URL_BASE}/',
include([
assets_path,
path(
'set-password?uid=<uid>&token=<token>',
spa_view,
@@ -88,7 +75,6 @@ urlpatterns = [
re_path('.*', spa_view),
]),
),
assets_path,
path(settings.FRONTEND_URL_BASE, spa_view, name='platform'),
]

View File

@@ -83,6 +83,26 @@ export function getStatusCodes(type: ModelType | string) {
return statusCodes;
}
/**
* Return a list of status codes select options for a given model type
* returns an array of objects with keys "value" and "display_name"
*
*/
export function getStatusCodeOptions(type: ModelType | string): any[] {
const statusCodes = getStatusCodes(type);
if (!statusCodes) {
return [];
}
return Object.values(statusCodes?.values ?? []).map((entry) => {
return {
value: entry.key,
display_name: entry.label
};
});
}
/*
* Return the name of a status code, based on the key
*/

View File

@@ -22,10 +22,7 @@ import {
IconUser,
IconUsers
} from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { api } from '../App';
import { ActionButton } from '../components/buttons/ActionButton';
import RemoveRowButton from '../components/buttons/RemoveRowButton';
import { StandaloneField } from '../components/forms/StandaloneField';
@@ -40,6 +37,7 @@ import {
import { Thumbnail } from '../components/images/Thumbnail';
import { ProgressBar } from '../components/items/ProgressBar';
import { StylishText } from '../components/items/StylishText';
import { getStatusCodeOptions } from '../components/render/StatusRenderer';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { ModelType } from '../enums/ModelType';
import { InvenTreeIcon } from '../functions/icons';
@@ -291,10 +289,14 @@ function LineItemFormRow({
order: record?.order
});
// Generate new serial numbers
serialNumberGenerator.update({
part: record?.supplier_part_detail?.part,
quantity: props.item.quantity
});
if (trackable) {
serialNumberGenerator.update({
part: record?.supplier_part_detail?.part,
quantity: props.item.quantity
});
} else {
props.changeFn(props.idx, 'serial_numbers', undefined);
}
}
});
@@ -564,7 +566,10 @@ function LineItemFormRow({
)}
<TableFieldExtraRow
visible={batchOpen}
onValueChange={(value) => props.changeFn(props.idx, 'batch', value)}
onValueChange={(value) => {
props.changeFn(props.idx, 'batch_code', value);
}}
fieldName='batch_code'
fieldDefinition={{
field_type: 'string',
label: t`Batch Code`,
@@ -578,6 +583,7 @@ function LineItemFormRow({
onValueChange={(value) =>
props.changeFn(props.idx, 'serial_numbers', value)
}
fieldName='serial_numbers'
fieldDefinition={{
field_type: 'string',
label: t`Serial Numbers`,
@@ -589,6 +595,7 @@ function LineItemFormRow({
<TableFieldExtraRow
visible={packagingOpen}
onValueChange={(value) => props.changeFn(props.idx, 'packaging', value)}
fieldName='packaging'
fieldDefinition={{
field_type: 'string',
label: t`Packaging`
@@ -599,6 +606,7 @@ function LineItemFormRow({
<TableFieldExtraRow
visible={statusOpen}
defaultValue={10}
fieldName='status'
onValueChange={(value) => props.changeFn(props.idx, 'status', value)}
fieldDefinition={{
field_type: 'choice',
@@ -610,6 +618,7 @@ function LineItemFormRow({
/>
<TableFieldExtraRow
visible={noteOpen}
fieldName='note'
onValueChange={(value) => props.changeFn(props.idx, 'note', value)}
fieldDefinition={{
field_type: 'string',
@@ -634,23 +643,10 @@ type LineItemsForm = {
};
export function useReceiveLineItems(props: LineItemsForm) {
const { data } = useQuery({
queryKey: ['stock', 'status'],
queryFn: async () => {
return api.get(apiUrl(ApiEndpoints.stock_status)).then((response) => {
if (response.status === 200) {
const entries = Object.values(response.data.values);
const mapped = entries.map((item: any) => {
return {
value: item.key,
display_name: item.label
};
});
return mapped;
}
});
}
});
const stockStatusCodes = useMemo(
() => getStatusCodeOptions(ModelType.stockitem),
[]
);
const records = Object.fromEntries(
props.items.map((item) => [item.pk, item])
@@ -660,44 +656,46 @@ export function useReceiveLineItems(props: LineItemsForm) {
(elem) => elem.quantity !== elem.received
);
const fields: ApiFormFieldSet = {
id: {
value: props.orderPk,
hidden: true
},
items: {
field_type: 'table',
value: filteredItems.map((elem, idx) => {
return {
line_item: elem.pk,
location: elem.destination ?? elem.destination_detail?.pk ?? null,
quantity: elem.quantity - elem.received,
batch_code: '',
serial_numbers: '',
status: 10,
barcode: null
};
}),
modelRenderer: (row: TableFieldRowProps) => {
const record = records[row.item.line_item];
return (
<LineItemFormRow
props={row}
record={record}
statuses={data}
key={record.pk}
/>
);
const fields: ApiFormFieldSet = useMemo(() => {
return {
id: {
value: props.orderPk,
hidden: true
},
headers: [t`Part`, t`SKU`, t`Received`, t`Quantity`, t`Actions`]
},
location: {
filters: {
structural: false
items: {
field_type: 'table',
value: filteredItems.map((elem, idx) => {
return {
line_item: elem.pk,
location: elem.destination ?? elem.destination_detail?.pk ?? null,
quantity: elem.quantity - elem.received,
batch_code: '',
serial_numbers: '',
status: 10,
barcode: null
};
}),
modelRenderer: (row: TableFieldRowProps) => {
const record = records[row.item.line_item];
return (
<LineItemFormRow
props={row}
record={record}
statuses={stockStatusCodes}
key={record.pk}
/>
);
},
headers: [t`Part`, t`SKU`, t`Received`, t`Quantity`, t`Actions`]
},
location: {
filters: {
structural: false
}
}
}
};
};
}, [filteredItems, props, stockStatusCodes]);
return useCreateApiFormModal({
...props.formProps,
@@ -707,6 +705,7 @@ export function useReceiveLineItems(props: LineItemsForm) {
initialData: {
location: props.destinationPk
},
size: '80%'
size: '80%',
successMessage: t`Items received`
});
}

View File

@@ -365,7 +365,7 @@ function StockItemDefaultMove({
/>
</Flex>
<Flex direction='column' gap='sm' align='center'>
<Text>{stockItem.location_detail.pathstring}</Text>
<Text>{stockItem.location_detail?.pathstring ?? '-'}</Text>
<InvenTreeIcon icon='arrow_down' />
<Suspense fallback={<Skeleton width='150px' />}>
<Text>{data?.pathstring}</Text>

View File

@@ -144,7 +144,10 @@ export function useTable(tableName: string): TableState {
// Pagination data
const [page, setPage] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(25);
const [pageSize, setPageSize] = useLocalStorage<number>({
key: 'inventree-table-page-size',
defaultValue: 25
});
// A list of hidden columns, saved to local storage
const [hiddenColumns, setHiddenColumns] = useLocalStorage<string[]>({

View File

@@ -362,14 +362,20 @@ export default function BuildDetail() {
onFormSuccess: refreshInstance
});
const duplicateBuildOrderInitialData = useMemo(() => {
const data = { ...build };
// if we set the reference to null/undefined, it will be left blank in the form
// if we omit the reference altogether, it will be auto-generated via reference pattern
// from the OPTIONS response
delete data.reference;
return data;
}, [build]);
const duplicateBuild = useCreateApiFormModal({
url: ApiEndpoints.build_order_list,
title: t`Add Build Order`,
fields: buildOrderFields,
initialData: {
...build,
reference: undefined
},
initialData: duplicateBuildOrderInitialData,
follow: true,
modelType: ModelType.build
});

View File

@@ -277,7 +277,7 @@ export default function SupplierPartDetail() {
label: t`Supplier Pricing`,
icon: <IconCurrencyDollar />,
content: supplierPart?.pk ? (
<SupplierPriceBreakTable supplierPartId={supplierPart.pk} />
<SupplierPriceBreakTable supplierPart={supplierPart} />
) : (
<Skeleton />
)

View File

@@ -68,7 +68,7 @@ function BomPieChart({
return {
// Note: Replace '.' in name to avoid issues with tooltip
name: entry?.name?.replace('.', '') ?? '',
value: entry?.total_price_max,
value: Number.parseFloat(entry?.total_price_max),
color: `${CHART_COLORS[index % CHART_COLORS.length]}.5`
};
}) ?? []

View File

@@ -26,6 +26,7 @@ import { type ReactNode, useCallback, useMemo } from 'react';
import { api } from '../../../App';
import { tooltipFormatter } from '../../../components/charts/tooltipFormatter';
import type { ApiFormFieldSet } from '../../../components/forms/fields/ApiFormField';
import {
EditItemAction,
OptionsActionDropdown
@@ -35,12 +36,14 @@ import { ApiEndpoints } from '../../../enums/ApiEndpoints';
import { InvenTreeIcon } from '../../../functions/icons';
import { useEditApiFormModal } from '../../../hooks/UseForm';
import { apiUrl } from '../../../states/ApiState';
import { useGlobalSettingsState } from '../../../states/SettingsState';
import { panelOptions } from '../PartPricingPanel';
interface PricingOverviewEntry {
icon: ReactNode;
name: panelOptions;
title: string;
valid: boolean;
min_value: number | null | undefined;
max_value: number | null | undefined;
visible?: boolean;
@@ -58,6 +61,8 @@ export default function PricingOverviewPanel({
pricingQuery: UseQueryResult;
doNavigation: (panel: panelOptions) => void;
}>): ReactNode {
const globalSettings = useGlobalSettingsState();
const refreshPricing = useCallback(() => {
const url = apiUrl(ApiEndpoints.part_pricing, part.pk);
@@ -99,19 +104,29 @@ export default function PricingOverviewPanel({
});
}, [part]);
const editPricing = useEditApiFormModal({
title: t`Edit Pricing`,
url: apiUrl(ApiEndpoints.part_pricing, part.pk),
fields: {
const pricingFields: ApiFormFieldSet = useMemo(() => {
return {
override_min: {},
override_min_currency: {},
override_min_currency: {
default:
globalSettings.getSetting('INVENTREE_DEFAULT_CURRENCY') ?? 'USD'
},
override_max: {},
override_max_currency: {},
override_max_currency: {
default:
globalSettings.getSetting('INVENTREE_DEFAULT_CURRENCY') ?? 'USD'
},
update: {
hidden: true,
value: true
}
},
};
}, [globalSettings]);
const editPricing = useEditApiFormModal({
title: t`Edit Pricing`,
url: apiUrl(ApiEndpoints.part_pricing, part.pk),
fields: pricingFields,
onFormSuccess: () => {
pricingQuery.refetch();
}
@@ -168,71 +183,89 @@ export default function PricingOverviewPanel({
const overviewData: PricingOverviewEntry[] = useMemo(() => {
return [
{
name: panelOptions.internal,
title: t`Internal Pricing`,
icon: <IconList />,
min_value: pricing?.internal_cost_min,
max_value: pricing?.internal_cost_max
},
{
name: panelOptions.bom,
title: t`BOM Pricing`,
icon: <IconChartDonut />,
min_value: pricing?.bom_cost_min,
max_value: pricing?.bom_cost_max
},
{
name: panelOptions.purchase,
title: t`Purchase Pricing`,
icon: <IconShoppingCart />,
min_value: pricing?.purchase_cost_min,
max_value: pricing?.purchase_cost_max
},
{
name: panelOptions.supplier,
title: t`Supplier Pricing`,
icon: <IconBuildingWarehouse />,
min_value: pricing?.supplier_price_min,
max_value: pricing?.supplier_price_max
},
{
name: panelOptions.variant,
title: t`Variant Pricing`,
icon: <IconTriangleSquareCircle />,
min_value: pricing?.variant_cost_min,
max_value: pricing?.variant_cost_max
},
{
name: panelOptions.sale_pricing,
title: t`Sale Pricing`,
icon: <IconTriangleSquareCircle />,
min_value: pricing?.sale_price_min,
max_value: pricing?.sale_price_max
},
{
name: panelOptions.sale_history,
title: t`Sale History`,
icon: <IconTriangleSquareCircle />,
min_value: pricing?.sale_history_min,
max_value: pricing?.sale_history_max
},
{
name: panelOptions.override,
title: t`Override Pricing`,
icon: <IconExclamationCircle />,
min_value: pricing?.override_min,
max_value: pricing?.override_max
min_value: Number.parseFloat(pricing?.override_min),
max_value: Number.parseFloat(pricing?.override_max),
valid: pricing?.override_min != null && pricing?.override_max != null
},
{
name: panelOptions.overall,
title: t`Overall Pricing`,
icon: <IconReportAnalytics />,
min_value: pricing?.overall_min,
max_value: pricing?.overall_max
min_value: Number.parseFloat(pricing?.overall_min),
max_value: Number.parseFloat(pricing?.overall_max),
valid: pricing?.overall_min != null && pricing?.overall_max != null
},
{
name: panelOptions.internal,
title: t`Internal Pricing`,
icon: <IconList />,
min_value: Number.parseFloat(pricing?.internal_cost_min),
max_value: Number.parseFloat(pricing?.internal_cost_max),
valid:
pricing?.internal_cost_min != null &&
pricing?.internal_cost_max != null
},
{
name: panelOptions.bom,
title: t`BOM Pricing`,
icon: <IconChartDonut />,
min_value: Number.parseFloat(pricing?.bom_cost_min),
max_value: Number.parseFloat(pricing?.bom_cost_max),
valid: pricing?.bom_cost_min != null && pricing?.bom_cost_max != null
},
{
name: panelOptions.purchase,
title: t`Purchase Pricing`,
icon: <IconShoppingCart />,
min_value: Number.parseFloat(pricing?.purchase_cost_min),
max_value: Number.parseFloat(pricing?.purchase_cost_max),
valid:
pricing?.purchase_cost_min != null &&
pricing?.purchase_cost_max != null
},
{
name: panelOptions.supplier,
title: t`Supplier Pricing`,
icon: <IconBuildingWarehouse />,
min_value: Number.parseFloat(pricing?.supplier_price_min),
max_value: Number.parseFloat(pricing?.supplier_price_max),
valid:
pricing?.supplier_price_min != null &&
pricing?.supplier_price_max != null
},
{
name: panelOptions.variant,
title: t`Variant Pricing`,
icon: <IconTriangleSquareCircle />,
min_value: Number.parseFloat(pricing?.variant_cost_min),
max_value: Number.parseFloat(pricing?.variant_cost_max),
valid:
pricing?.variant_cost_min != null && pricing?.variant_cost_max != null
},
{
name: panelOptions.sale_pricing,
title: t`Sale Pricing`,
icon: <IconTriangleSquareCircle />,
min_value: Number.parseFloat(pricing?.sale_price_min),
max_value: Number.parseFloat(pricing?.sale_price_max),
valid:
pricing?.sale_price_min != null && pricing?.sale_price_max != null
},
{
name: panelOptions.sale_history,
title: t`Sale History`,
icon: <IconTriangleSquareCircle />,
min_value: Number.parseFloat(pricing?.sale_history_min),
max_value: Number.parseFloat(pricing?.sale_history_max),
valid:
pricing?.sale_history_min != null && pricing?.sale_history_max != null
}
].filter((entry) => {
return !(entry.min_value == null || entry.max_value == null);
return entry.valid;
});
}, [part, pricing]);

View File

@@ -21,7 +21,7 @@ export default function PurchaseHistoryPanel({
const calculateUnitPrice = useCallback((record: any) => {
const pack_quantity =
record?.supplier_part_detail?.pack_quantity_native ?? 1;
const unit_price = record.purchase_price / pack_quantity;
const unit_price = Number.parseFloat(record.purchase_price) / pack_quantity;
return unit_price;
}, []);
@@ -95,7 +95,7 @@ export default function PurchaseHistoryPanel({
return table.records.map((record: any) => {
return {
quantity: record.quantity,
purchase_price: record.purchase_price,
purchase_price: Number.parseFloat(record.purchase_price),
unit_price: calculateUnitPrice(record),
name: record.order_detail.reference
};

View File

@@ -57,7 +57,7 @@ export default function SaleHistoryPanel({
return table.records.map((record: any) => {
return {
name: record.order_detail.reference,
sale_price: record.sale_price
sale_price: Number.parseFloat(record.sale_price)
};
});
}, [table.records]);

View File

@@ -36,7 +36,7 @@ export default function SupplierPricingPanel({
table.records?.map((record: any) => {
return {
quantity: record.quantity,
supplier_price: record.price,
supplier_price: Number.parseFloat(record.price),
unit_price: calculateSupplierPartUnitPrice(record),
name: record.part_detail?.SKU
};

View File

@@ -63,8 +63,10 @@ export default function VariantPricingPanel({
return {
part: variant,
name: variant.full_name,
pmin: variant.pricing_min ?? variant.pricing_max ?? 0,
pmax: variant.pricing_max ?? variant.pricing_min ?? 0
pmin: Number.parseFloat(
variant.pricing_min ?? variant.pricing_max ?? 0
),
pmax: Number.parseFloat(variant.pricing_max ?? variant.pricing_min ?? 0)
};
});

View File

@@ -94,14 +94,20 @@ export default function PurchaseOrderDetail() {
}
});
const duplicatePurchaseOrderInitialData = useMemo(() => {
const data = { ...order };
// if we set the reference to null/undefined, it will be left blank in the form
// if we omit the reference altogether, it will be auto-generated via reference pattern
// from the OPTIONS response
delete data.reference;
return data;
}, [order]);
const duplicatePurchaseOrder = useCreateApiFormModal({
url: ApiEndpoints.purchase_order_list,
title: t`Add Purchase Order`,
fields: duplicatePurchaseOrderFields,
initialData: {
...order,
reference: undefined
},
initialData: duplicatePurchaseOrderInitialData,
follow: true,
modelType: ModelType.purchaseorder
});

View File

@@ -329,14 +329,20 @@ export default function ReturnOrderDetail() {
}
});
const duplicateReturnOrderInitialData = useMemo(() => {
const data = { ...order };
// if we set the reference to null/undefined, it will be left blank in the form
// if we omit the reference altogether, it will be auto-generated via reference pattern
// from the OPTIONS response
delete data.reference;
return data;
}, [order]);
const duplicateReturnOrder = useCreateApiFormModal({
url: ApiEndpoints.return_order_list,
title: t`Add Return Order`,
fields: duplicateReturnOrderFields,
initialData: {
...order,
reference: undefined
},
initialData: duplicateReturnOrderInitialData,
modelType: ModelType.returnorder,
follow: true
});

View File

@@ -272,14 +272,20 @@ export default function SalesOrderDetail() {
duplicateOrderId: order.pk
});
const duplicateSalesOrderInitialData = useMemo(() => {
const data = { ...order };
// if we set the reference to null/undefined, it will be left blank in the form
// if we omit the reference altogether, it will be auto-generated via reference pattern
// from the OPTIONS response
delete data.reference;
return data;
}, [order]);
const duplicateSalesOrder = useCreateApiFormModal({
url: ApiEndpoints.sales_order_list,
title: t`Add Sales Order`,
fields: duplicateOrderFields,
initialData: {
...order,
reference: undefined
},
initialData: duplicateSalesOrderInitialData,
follow: true,
modelType: ModelType.salesorder
});

View File

@@ -65,11 +65,14 @@ export function LocationColumn(props: TableColumnProps): TableColumn {
if (!location) {
return (
<Text style={{ fontStyle: 'italic' }}>{t`No location set`}</Text>
<Text
size='sm'
style={{ fontStyle: 'italic' }}
>{t`No location set`}</Text>
);
}
return <Text>{location.name}</Text>;
return <Text size='sm'>{location.name}</Text>;
},
...props
};

View File

@@ -593,7 +593,6 @@ export default function BuildLineTable({
icon: <IconShoppingCart />,
title: t`Order Stock`,
hidden: !canOrder,
disabled: !table.hasSelectedRecords,
color: 'blue',
onClick: () => {
setPartsToOrder([record.part_detail]);

View File

@@ -90,7 +90,8 @@ export function RelatedPartTable({
part_1: {
hidden: true
},
part_2: {}
part_2: {},
note: {}
};
}, []);

View File

@@ -24,7 +24,7 @@ import { type RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
export function calculateSupplierPartUnitPrice(record: any) {
const pack_quantity = record?.part_detail?.pack_quantity_native ?? 1;
const unit_price = record.price / pack_quantity;
const unit_price = Number.parseFloat(record.price) / pack_quantity;
return unit_price;
}
@@ -111,9 +111,9 @@ export function SupplierPriceBreakColumns(): TableColumn[] {
}
export default function SupplierPriceBreakTable({
supplierPartId
supplierPart
}: Readonly<{
supplierPartId: number;
supplierPart: any;
}>) {
const table = useTable('supplierpricebreaks');
@@ -142,7 +142,8 @@ export default function SupplierPriceBreakTable({
title: t`Add Price Break`,
fields: supplierPriceBreakFields,
initialData: {
part: supplierPartId
part: supplierPart.pk,
price_currency: supplierPart.supplier_detail.currency
},
table: table
});
@@ -208,7 +209,7 @@ export default function SupplierPriceBreakTable({
tableState={table}
props={{
params: {
part: supplierPartId,
part: supplierPart.pk,
part_detail: true,
supplier_detail: true
},

View File

@@ -1,3 +1,4 @@
import { expect } from '@playwright/test';
import { test } from '../baseFixtures.ts';
import { baseUrl } from '../defaults.ts';
import {
@@ -271,3 +272,22 @@ test('Build Order - Filters', async ({ page }) => {
await page.waitForTimeout(2500);
});
test('Build Order - Duplicate', async ({ page }) => {
await doQuickLogin(page);
await navigate(page, 'manufacturing/build-order/24/details');
await page.getByLabel('action-menu-build-order-').click();
await page.getByLabel('action-menu-build-order-actions-duplicate').click();
// Ensure a new reference is suggested
await expect(page.getByLabel('text-field-reference')).not.toBeEmpty();
// Submit the duplicate request and ensure it completes
await page.getByRole('button', { name: 'Submit' }).isEnabled();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('tab', { name: 'Build Details' }).waitFor();
await page.getByRole('tab', { name: 'Build Details' }).click();
await page.getByText('Pending').first().waitFor();
});

View File

@@ -1,3 +1,4 @@
import { expect } from '@playwright/test';
import { test } from '../baseFixtures.ts';
import { baseUrl } from '../defaults.ts';
import { clickButtonIfVisible, openFilterDrawer } from '../helpers.ts';
@@ -186,3 +187,22 @@ test('Purchase Orders - Receive Items', async ({ page }) => {
await page.getByRole('button', { name: 'Cancel' }).click();
});
test('Purchase Orders - Duplicate', async ({ page }) => {
await doQuickLogin(page);
await navigate(page, 'purchasing/purchase-order/13/detail');
await page.getByLabel('action-menu-order-actions').click();
await page.getByLabel('action-menu-order-actions-duplicate').click();
// Ensure a new reference is suggested
await expect(page.getByLabel('text-field-reference')).not.toBeEmpty();
// Submit the duplicate request and ensure it completes
await page.getByRole('button', { name: 'Submit' }).isEnabled();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('tab', { name: 'Order Details' }).waitFor();
await page.getByRole('tab', { name: 'Order Details' }).click();
await page.getByText('Pending').first().waitFor();
});

View File

@@ -1,3 +1,4 @@
import { expect } from '@playwright/test';
import { test } from '../baseFixtures.ts';
import { baseUrl } from '../defaults.ts';
import { clearTableFilters, setTableChoiceFilter } from '../helpers.ts';
@@ -149,3 +150,22 @@ test('Purchase Orders', async ({ page }) => {
await page.getByRole('button', { name: 'Issue Order' }).waitFor();
});
test('Sales Orders - Duplicate', async ({ page }) => {
await doQuickLogin(page);
await navigate(page, 'sales/sales-order/11/detail');
await page.getByLabel('action-menu-order-actions').click();
await page.getByLabel('action-menu-order-actions-duplicate').click();
// Ensure a new reference is suggested
await expect(page.getByLabel('text-field-reference')).not.toBeEmpty();
// Submit the duplicate request and ensure it completes
await page.getByRole('button', { name: 'Submit' }).isEnabled();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('tab', { name: 'Order Details' }).waitFor();
await page.getByRole('tab', { name: 'Order Details' }).click();
await page.getByText('Pending').first().waitFor();
});

View File

@@ -48,6 +48,7 @@ export default defineConfig({
uploadToken: process.env.CODECOV_TOKEN
})
],
base: '',
build: {
manifest: true,
outDir: '../../src/backend/InvenTree/web/static/web',