mirror of
https://github.com/inventree/InvenTree.git
synced 2025-12-19 13:20:37 -06:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cae0d5066 | ||
|
|
21d266ab95 | ||
|
|
1ae27a6b77 | ||
|
|
b18ac57fb8 | ||
|
|
053b37ec3a | ||
|
|
6fa6063639 | ||
|
|
9b68dea26d | ||
|
|
9d21776c86 | ||
|
|
4c9f042f8c | ||
|
|
3625b8f14c | ||
|
|
cd41ca2a87 | ||
|
|
8a2fce9c36 | ||
|
|
ee87cd7b23 | ||
|
|
940abaa179 | ||
|
|
7fefa5c213 |
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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']
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1461,7 +1461,7 @@ class StockTrackingList(DataExportViewMixin, ListAPI):
|
||||
|
||||
ordering_fields = ['date']
|
||||
|
||||
search_fields = ['title', 'notes']
|
||||
search_fields = ['notes']
|
||||
|
||||
|
||||
class LocationDetail(CustomRetrieveUpdateDestroyAPI):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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`
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[]>({
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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 />
|
||||
)
|
||||
|
||||
@@ -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`
|
||||
};
|
||||
}) ?? []
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -90,7 +90,8 @@ export function RelatedPartTable({
|
||||
part_1: {
|
||||
hidden: true
|
||||
},
|
||||
part_2: {}
|
||||
part_2: {},
|
||||
note: {}
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -48,6 +48,7 @@ export default defineConfig({
|
||||
uploadToken: process.env.CODECOV_TOKEN
|
||||
})
|
||||
],
|
||||
base: '',
|
||||
build: {
|
||||
manifest: true,
|
||||
outDir: '../../src/backend/InvenTree/web/static/web',
|
||||
|
||||
Reference in New Issue
Block a user