diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 9dafef844b..2387d9b879 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,13 +1,16 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 255 +INVENTREE_API_VERSION = 256 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v256 - 2024-09-19 : https://github.com/inventree/InvenTree/pull/7704 + - Adjustments for "stocktake" (stock history) API endpoints + v255 - 2024-09-19 : https://github.com/inventree/InvenTree/pull/8145 - Enables copying line items when duplicating an order diff --git a/src/backend/InvenTree/company/api.py b/src/backend/InvenTree/company/api.py index b0b9fcfe98..5765b11b85 100644 --- a/src/backend/InvenTree/company/api.py +++ b/src/backend/InvenTree/company/api.py @@ -436,6 +436,13 @@ class SupplierPriceBreakList(ListCreateAPI): serializer_class = SupplierPriceBreakSerializer filterset_class = SupplierPriceBreakFilter + def get_queryset(self): + """Return annotated queryset for the SupplierPriceBreak list endpoint.""" + queryset = super().get_queryset() + queryset = SupplierPriceBreakSerializer.annotate_queryset(queryset) + + return queryset + def get_serializer(self, *args, **kwargs): """Return serializer instance for this endpoint.""" try: @@ -468,6 +475,13 @@ class SupplierPriceBreakDetail(RetrieveUpdateDestroyAPI): 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 ba6d845c0a..da763f8d7b 100644 --- a/src/backend/InvenTree/company/serializers.py +++ b/src/backend/InvenTree/company/serializers.py @@ -512,6 +512,13 @@ class SupplierPriceBreakSerializer( if not part_detail: self.fields.pop('part_detail', None) + @staticmethod + def annotate_queryset(queryset): + """Prefetch related fields for the queryset.""" + queryset = queryset.select_related('part', 'part__supplier', 'part__part') + + return queryset + quantity = InvenTreeDecimalField() price = InvenTreeMoneySerializer(allow_null=True, required=True, label=_('Price')) diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index c115a4aae4..74afcdfbed 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -991,11 +991,30 @@ class SalesOrderShipmentFilter(rest_filters.FilterSet): return queryset.filter(delivery_date=None) -class SalesOrderShipmentList(ListCreateAPI): - """API list endpoint for SalesOrderShipment model.""" +class SalesOrderShipmentMixin: + """Mixin class for SalesOrderShipment endpoints.""" queryset = models.SalesOrderShipment.objects.all() serializer_class = serializers.SalesOrderShipmentSerializer + + def get_queryset(self, *args, **kwargs): + """Return annotated queryset for this endpoint.""" + queryset = super().get_queryset(*args, **kwargs) + + queryset = queryset.prefetch_related( + 'order', + 'order__customer', + 'allocations', + 'allocations__item', + 'allocations__item__part', + ) + + return queryset + + +class SalesOrderShipmentList(SalesOrderShipmentMixin, ListCreateAPI): + """API list endpoint for SalesOrderShipment model.""" + filterset_class = SalesOrderShipmentFilter filter_backends = SEARCH_ORDER_FILTER_ALIAS @@ -1003,12 +1022,9 @@ class SalesOrderShipmentList(ListCreateAPI): ordering_fields = ['delivery_date', 'shipment_date'] -class SalesOrderShipmentDetail(RetrieveUpdateDestroyAPI): +class SalesOrderShipmentDetail(SalesOrderShipmentMixin, RetrieveUpdateDestroyAPI): """API detail endpooint for SalesOrderShipment model.""" - queryset = models.SalesOrderShipment.objects.all() - serializer_class = serializers.SalesOrderShipmentSerializer - class SalesOrderShipmentComplete(CreateAPI): """API endpoint for completing (shipping) a SalesOrderShipment.""" diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index ac4a59fcad..1285378de2 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -1715,6 +1715,13 @@ class PartStocktakeReportList(ListAPI): ordering = '-pk' +class PartStocktakeReportDetail(RetrieveUpdateDestroyAPI): + """API endpoint for detail view of a single PartStocktakeReport object.""" + + queryset = PartStocktakeReport.objects.all() + serializer_class = part_serializers.PartStocktakeReportSerializer + + class PartStocktakeReportGenerate(CreateAPI): """API endpoint for manually generating a new PartStocktakeReport.""" @@ -2184,6 +2191,11 @@ part_api_urls = [ PartStocktakeReportGenerate.as_view(), name='api-part-stocktake-report-generate', ), + path( + '/', + PartStocktakeReportDetail.as_view(), + name='api-part-stocktake-report-detail', + ), path( '', PartStocktakeReportList.as_view(), diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index 4ad688df0b..c520ca2abd 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -1193,6 +1193,7 @@ class PartStocktakeReportSerializer(InvenTree.serializers.InvenTreeModelSerializ model = PartStocktakeReport fields = ['pk', 'date', 'report', 'part_count', 'user', 'user_detail'] + read_only_fields = ['date', 'report', 'part_count', 'user'] user_detail = InvenTree.serializers.UserSerializer( source='user', read_only=True, many=False diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index 1b574a65f4..b4447fe153 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -95,6 +95,8 @@ export enum ApiEndpoints { part_pricing_internal = 'part/internal-price/', part_pricing_sale = 'part/sale-price/', part_stocktake_list = 'part/stocktake/', + part_stocktake_report_list = 'part/stocktake/report/', + part_stocktake_report_generate = 'part/stocktake/report/generate/', category_list = 'part/category/', category_tree = 'part/category/tree/', category_parameter_list = 'part/category/parameters/', diff --git a/src/frontend/src/forms/PartForms.tsx b/src/frontend/src/forms/PartForms.tsx index 0241f12078..7146695231 100644 --- a/src/frontend/src/forms/PartForms.tsx +++ b/src/frontend/src/forms/PartForms.tsx @@ -202,3 +202,29 @@ export function usePartParameterFields({ }; }, [editTemplate, fieldType, choices]); } + +export function partStocktakeFields(): ApiFormFieldSet { + return { + part: { + hidden: true + }, + quantity: {}, + item_count: {}, + cost_min: {}, + cost_min_currency: {}, + cost_max: {}, + cost_max_currency: {}, + note: {} + }; +} + +export function generateStocktakeReportFields(): ApiFormFieldSet { + return { + part: {}, + category: {}, + location: {}, + exclude_external: {}, + generate_report: {}, + update_parts: {} + }; +} diff --git a/src/frontend/src/functions/notifications.tsx b/src/frontend/src/functions/notifications.tsx index 0306d1d92c..79550e65a3 100644 --- a/src/frontend/src/functions/notifications.tsx +++ b/src/frontend/src/functions/notifications.tsx @@ -6,10 +6,13 @@ import { IconCircleCheck, IconExclamationCircle } from '@tabler/icons-react'; * Show a notification that the feature is not yet implemented */ export function notYetImplemented() { + notifications.hide('not-implemented'); + notifications.show({ title: t`Not implemented`, message: t`This feature is not yet implemented`, - color: 'red' + color: 'red', + id: 'not-implemented' }); } diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx index a34e5e15ca..d3252bfb64 100644 --- a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx @@ -9,6 +9,7 @@ import { Title } from '@mantine/core'; import { + IconClipboardCheck, IconCoins, IconCpu, IconDevicesPc, @@ -91,6 +92,8 @@ const LocationTypesTable = Loadable( lazy(() => import('../../../../tables/stock/LocationTypesTable')) ); +const StocktakePanel = Loadable(lazy(() => import('./StocktakePanel'))); + export default function AdminCenter() { const user = useUserState(); @@ -162,6 +165,12 @@ export default function AdminCenter() { icon: , content: }, + { + name: 'stocktake', + label: t`Stocktake`, + icon: , + content: + }, { name: 'labels', label: t`Label Templates`, diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/StocktakePanel.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/StocktakePanel.tsx new file mode 100644 index 0000000000..fdc10e1f36 --- /dev/null +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/StocktakePanel.tsx @@ -0,0 +1,31 @@ +import { Trans } from '@lingui/macro'; +import { Divider, Stack } from '@mantine/core'; +import { lazy } from 'react'; + +import { StylishText } from '../../../../components/items/StylishText'; +import { GlobalSettingList } from '../../../../components/settings/SettingList'; +import { Loadable } from '../../../../functions/loading'; + +const StocktakeReportTable = Loadable( + lazy(() => import('../../../../tables/settings/StocktakeReportTable')) +); + +export default function StocktakePanel() { + return ( + + + + Stocktake Reports + + + + + ); +} diff --git a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx index 22969847b7..987a50decb 100644 --- a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx +++ b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx @@ -3,7 +3,6 @@ import { Skeleton, Stack } from '@mantine/core'; import { IconBellCog, IconCategory, - IconClipboardCheck, IconCurrencyDollar, IconFileAnalytics, IconFingerprint, @@ -225,12 +224,6 @@ export default function SystemSettings() { /> ) }, - { - name: 'stocktake', - label: t`Stocktake`, - icon: , - content: - }, { name: 'buildorders', label: t`Build Orders`, diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 08b769f905..d34764497f 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -2,7 +2,9 @@ import { t } from '@lingui/macro'; import { Accordion, Alert, + Center, Grid, + Loader, Skeleton, Space, Stack, @@ -80,7 +82,10 @@ import { } from '../../hooks/UseForm'; import { useInstance } from '../../hooks/UseInstance'; import { apiUrl } from '../../states/ApiState'; -import { useGlobalSettingsState } from '../../states/SettingsState'; +import { + useGlobalSettingsState, + useUserSettingsState +} from '../../states/SettingsState'; import { useUserState } from '../../states/UserState'; import { BomTable } from '../../tables/bom/BomTable'; import { UsedInTable } from '../../tables/bom/UsedInTable'; @@ -99,6 +104,7 @@ import { SalesOrderTable } from '../../tables/sales/SalesOrderTable'; import { StockItemTable } from '../../tables/stock/StockItemTable'; import { TestStatisticsTable } from '../../tables/stock/TestStatisticsTable'; import PartPricingPanel from './PartPricingPanel'; +import PartStocktakeDetail from './PartStocktakeDetail'; /** * Detail view for a single Part instance @@ -112,6 +118,7 @@ export default function PartDetail() { const [treeOpen, setTreeOpen] = useState(false); const globalSettings = useGlobalSettingsState(); + const userSettings = useUserSettingsState(); const { instance: part, @@ -550,7 +557,7 @@ export default function PartDetail() { name: 'stock', label: t`Stock`, icon: , - content: part.pk && ( + content: part.pk ? ( + ) : ( +
+ +
) }, { @@ -680,17 +691,22 @@ export default function PartDetail() { hidden: !part.salable, content: part.pk ? : }, + { + name: 'stocktake', + label: t`Stock History`, + icon: , + content: part ? : , + hidden: + !user.hasViewRole(UserRoles.stocktake) || + !globalSettings.isSet('STOCKTAKE_ENABLE') || + !userSettings.isSet('DISPLAY_STOCKTAKE_TAB') + }, { name: 'scheduling', label: t`Scheduling`, icon: , - content: - }, - { - name: 'stocktake', - label: t`Stocktake`, - icon: , - content: + content: , + hidden: !userSettings.isSet('DISPLAY_SCHEDULE_TAB') }, { name: 'test_templates', @@ -746,7 +762,7 @@ export default function PartDetail() { ) } ]; - }, [id, part, user]); + }, [id, part, user, globalSettings, userSettings]); // Fetch information on part revision const partRevisionQuery = useQuery({ diff --git a/src/frontend/src/pages/part/PartStocktakeDetail.tsx b/src/frontend/src/pages/part/PartStocktakeDetail.tsx new file mode 100644 index 0000000000..bd384ab85f --- /dev/null +++ b/src/frontend/src/pages/part/PartStocktakeDetail.tsx @@ -0,0 +1,200 @@ +import { t } from '@lingui/macro'; +import { LineChart } from '@mantine/charts'; +import { Center, Loader, SimpleGrid } from '@mantine/core'; +import { useCallback, useMemo, useState } from 'react'; + +import { AddItemButton } from '../../components/buttons/AddItemButton'; +import { formatPriceRange } from '../../defaults/formatters'; +import { ApiEndpoints } from '../../enums/ApiEndpoints'; +import { UserRoles } from '../../enums/Roles'; +import { + generateStocktakeReportFields, + partStocktakeFields +} from '../../forms/PartForms'; +import { + useCreateApiFormModal, + useDeleteApiFormModal, + useEditApiFormModal +} from '../../hooks/UseForm'; +import { useTable } from '../../hooks/UseTable'; +import { apiUrl } from '../../states/ApiState'; +import { useUserState } from '../../states/UserState'; +import { TableColumn } from '../../tables/Column'; +import { InvenTreeTable } from '../../tables/InvenTreeTable'; +import { RowDeleteAction, RowEditAction } from '../../tables/RowActions'; + +export default function PartStocktakeDetail({ partId }: { partId: number }) { + const user = useUserState(); + const table = useTable('part-stocktake'); + + const stocktakeFields = useMemo(() => partStocktakeFields(), []); + + const [selectedStocktake, setSelectedStocktake] = useState< + number | undefined + >(undefined); + + const editStocktakeEntry = useEditApiFormModal({ + pk: selectedStocktake, + url: ApiEndpoints.part_stocktake_list, + title: t`Edit Stocktake Entry`, + fields: stocktakeFields, + table: table + }); + + const deleteStocktakeEntry = useDeleteApiFormModal({ + pk: selectedStocktake, + url: ApiEndpoints.part_stocktake_list, + title: t`Delete Stocktake Entry`, + table: table + }); + + const generateReport = useCreateApiFormModal({ + url: ApiEndpoints.part_stocktake_report_generate, + title: t`Generate Stocktake Report`, + fields: generateStocktakeReportFields(), + initialData: { + part: partId + }, + successMessage: t`Stocktake report scheduled` + }); + + const tableColumns: TableColumn[] = useMemo(() => { + return [ + { + accessor: 'quantity', + sortable: true, + switchable: false + }, + { + accessor: 'item_count', + title: t`Stock Items`, + switchable: true, + sortable: true + }, + { + accessor: 'cost', + title: t`Stock Value`, + render: (record: any) => { + return formatPriceRange(record.cost_min, record.cost_max, { + currency: record.cost_min_currency + }); + } + }, + { + accessor: 'date', + sortable: true + }, + { + accessor: 'note' + } + ]; + }, []); + + const tableActions = useMemo(() => { + return [ + generateReport.open()} + hidden={!user.hasAddRole(UserRoles.stocktake)} + /> + ]; + }, [user]); + + const rowActions = useCallback( + (record: any) => { + return [ + RowEditAction({ + hidden: !user.hasChangeRole(UserRoles.stocktake), + onClick: () => { + setSelectedStocktake(record.pk); + editStocktakeEntry.open(); + } + }), + RowDeleteAction({ + hidden: !user.hasDeleteRole(UserRoles.stocktake), + onClick: () => { + setSelectedStocktake(record.pk); + deleteStocktakeEntry.open(); + } + }) + ]; + }, + [user] + ); + + const chartData = useMemo(() => { + let records = + table.records?.map((record: any) => { + return { + date: record.date, + quantity: record.quantity, + value_min: record.cost_min, + value_max: record.cost_max + }; + }) ?? []; + + // Sort records to ensure correct date order + records.sort((a, b) => { + return new Date(a.date) < new Date(b.date) ? -1 : 1; + }); + + return records; + }, [table.records]); + + return ( + <> + {generateReport.modal} + {editStocktakeEntry.modal} + {deleteStocktakeEntry.modal} + + + {table.isLoading ? ( +
+ +
+ ) : ( + + )} +
+ + ); +} diff --git a/src/frontend/src/pages/part/pricing/SupplierPricingPanel.tsx b/src/frontend/src/pages/part/pricing/SupplierPricingPanel.tsx index 89f38f821b..b290c64251 100644 --- a/src/frontend/src/pages/part/pricing/SupplierPricingPanel.tsx +++ b/src/frontend/src/pages/part/pricing/SupplierPricingPanel.tsx @@ -32,14 +32,16 @@ export default function SupplierPricingPanel({ }, [table.records]); const supplierPricingData = useMemo(() => { - return table.records.map((record: any) => { - return { - quantity: record.quantity, - supplier_price: record.price, - unit_price: calculateSupplierPartUnitPrice(record), - name: record.part_detail?.SKU - }; - }); + return ( + table.records?.map((record: any) => { + return { + quantity: record.quantity, + supplier_price: record.price, + unit_price: calculateSupplierPartUnitPrice(record), + name: record.part_detail?.SKU + }; + }) ?? [] + ); }, [table.records]); return ( diff --git a/src/frontend/src/tables/InvenTreeTable.tsx b/src/frontend/src/tables/InvenTreeTable.tsx index 985a0422ad..f04a5a2588 100644 --- a/src/frontend/src/tables/InvenTreeTable.tsx +++ b/src/frontend/src/tables/InvenTreeTable.tsx @@ -5,7 +5,6 @@ import { Box, Group, Indicator, - LoadingOverlay, Space, Stack, Tooltip @@ -711,12 +710,6 @@ export function InvenTreeTable>({ - - { + return [ + { + accessor: 'report', + title: t`Report`, + sortable: false, + switchable: false, + render: (record: any) => + }, + { + accessor: 'part_count', + title: t`Part Count`, + sortable: false + }, + DateColumn({ + accessor: 'date', + title: t`Date` + }), + { + accessor: 'user', + title: t`User`, + sortable: false, + render: (record: any) => RenderUser({ instance: record.user_detail }) + } + ]; + }, []); + + const [selectedReport, setSelectedReport] = useState( + undefined + ); + + const deleteReport = useDeleteApiFormModal({ + url: ApiEndpoints.part_stocktake_report_list, + pk: selectedReport, + title: t`Delete Report`, + onFormSuccess: () => table.refreshTable() + }); + + const generateFields: ApiFormFieldSet = useMemo( + () => generateStocktakeReportFields(), + [] + ); + + const generateReport = useCreateApiFormModal({ + url: ApiEndpoints.part_stocktake_report_generate, + title: t`Generate Stocktake Report`, + fields: generateFields, + successMessage: t`Stocktake report scheduled` + }); + + const tableActions = useMemo(() => { + return [ + generateReport.open()} + /> + ]; + }, []); + + const rowActions = useCallback((record: any): RowAction[] => { + return [ + RowDeleteAction({ + onClick: () => { + setSelectedReport(record.pk); + deleteReport.open(); + } + }) + ]; + }, []); + + return ( + <> + {generateReport.modal} + {deleteReport.modal} + + + ); +} diff --git a/src/frontend/tests/pui_general.spec.ts b/src/frontend/tests/pui_general.spec.ts index 1b832d0e7f..5ab8a6b05a 100644 --- a/src/frontend/tests/pui_general.spec.ts +++ b/src/frontend/tests/pui_general.spec.ts @@ -18,7 +18,7 @@ test('PUI - Parts', async ({ page }) => { await page.getByRole('tab', { name: 'Suppliers' }).click(); await page.getByRole('tab', { name: 'Purchase Orders' }).click(); await page.getByRole('tab', { name: 'Scheduling' }).click(); - await page.getByRole('tab', { name: 'Stocktake' }).click(); + await page.getByRole('tab', { name: 'Stock History' }).click(); await page.getByRole('tab', { name: 'Attachments' }).click(); await page.getByRole('tab', { name: 'Notes' }).click(); await page.getByRole('tab', { name: 'Related Parts' }).click(); diff --git a/src/frontend/tests/pui_settings.spec.ts b/src/frontend/tests/pui_settings.spec.ts index 7091272fc8..fda1e7c15c 100644 --- a/src/frontend/tests/pui_settings.spec.ts +++ b/src/frontend/tests/pui_settings.spec.ts @@ -29,7 +29,6 @@ test('PUI - Admin', async ({ page }) => { await page.getByRole('tab', { name: 'Labels' }).click(); await page.getByRole('tab', { name: 'Reporting' }).click(); - await page.getByRole('tab', { name: 'Stocktake' }).click(); await page.getByRole('tab', { name: 'Build Orders' }).click(); await page.getByRole('tab', { name: 'Purchase Orders' }).click(); await page.getByRole('tab', { name: 'Sales Orders' }).click();