2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-07-02 19:50:59 +00:00

[Feature] External build order (#9312)

* Add BuildOrder reference to PurchaseOrderLineItem

* Add setting to enable / disable external build orders

* Fix for supplier part detail

* Update forms

* Filter build list by "external" status

* Add "external" attribute to BuildOrder

* Filter by external build when selecting against purchase order line item

* Add frontend elements

* Prevent creation of build outputs

* Tweak related model field

- Send filters when fetching initial data

* Fix migrations

* Fix some existing typos

* Add build info when receiving line items

* Logic fix

* Bump API version

* Updated relationship

* Add external orders tab for order

* Display table of external purchase orders against a build order

* Fix permissions

* Tweak field definition

* Add unit tests

* Tweak api_version.py

* Playwright testing

* Fix discrepancy in 'building' filter

* Add basic documentation

( more work required )

* Tweak docs macros

* Migration fix

* Adjust build page tabs

* Fix imports

* Fix broken import

* Update playywright tests

* Bump API version

* Handle DB issues

* Improve filter

* Cleaner code

* Fix column ordering bug

* Add filters to build output table

* Documentation

* Tweak unit test

* Add "scheduled_for_production" field

* Add helper function to part model

* Cleanup
This commit is contained in:
Oliver
2025-06-12 18:27:15 +10:00
committed by GitHub
parent 5915a1e13d
commit c6848b8e87
46 changed files with 864 additions and 157 deletions

View File

@ -410,6 +410,7 @@ function ProgressBarValue(props: Readonly<FieldProps>) {
return (
<ProgressBar
size='lg'
value={props.field_data.progress}
maximum={props.field_data.total}
progressLabel

View File

@ -1,7 +1,8 @@
import { Trans } from '@lingui/react/macro';
import { forwardRef, useImperativeHandle, useState } from 'react';
import { ApiEndpoints, apiUrl } from '@lib/index';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { apiUrl } from '@lib/functions/Api';
import { api } from '../../../../App';
import type { PreviewAreaComponent } from '../TemplateEditor';

View File

@ -1,4 +1,5 @@
import { ApiEndpoints, apiUrl } from '@lib/index';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { apiUrl } from '@lib/functions/Api';
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import {

View File

@ -71,24 +71,30 @@ export function RelatedModelField({
return;
}
api.get(url).then((response) => {
const pk_field = definition.pk_field ?? 'pk';
if (response.data?.[pk_field]) {
const value = {
value: response.data[pk_field],
data: response.data
};
const params = definition?.filters ?? {};
// Run custom callback for this field (if provided)
if (definition.onValueChange) {
definition.onValueChange(response.data[pk_field], response.data);
api
.get(url, {
params: params
})
.then((response) => {
const pk_field = definition.pk_field ?? 'pk';
if (response.data?.[pk_field]) {
const value = {
value: response.data[pk_field],
data: response.data
};
// Run custom callback for this field (if provided)
if (definition.onValueChange) {
definition.onValueChange(response.data[pk_field], response.data);
}
setInitialData(value);
dataRef.current = [value];
setPk(response.data[pk_field]);
}
setInitialData(value);
dataRef.current = [value];
setPk(response.data[pk_field]);
}
});
});
} else {
setPk(null);
}

View File

@ -1,6 +1,6 @@
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { UserRoles } from '@lib/index';
import type { ApiFormFieldSet } from '@lib/types/Forms';
import { t } from '@lingui/core/macro';
import { Table } from '@mantine/core';

View File

@ -126,7 +126,8 @@ export function useBuildOrderFields({
filters: {
is_active: true
}
}
},
external: {}
};
if (create) {
@ -137,6 +138,10 @@ export function useBuildOrderFields({
delete fields.project_code;
}
if (!globalSettings.isSet('BUILDORDER_EXTERNAL_BUILDS', true)) {
delete fields.external;
}
return fields;
}, [create, destination, batchCode, globalSettings]);
}

View File

@ -64,9 +64,14 @@ export function usePurchaseOrderLineItemFields({
orderId?: number;
create?: boolean;
}) {
const globalSettings = useGlobalSettingsState();
const [purchasePrice, setPurchasePrice] = useState<string>('');
const [autoPricing, setAutoPricing] = useState(true);
// Internal part information
const [part, setPart] = useState<any>({});
useEffect(() => {
if (autoPricing) {
setPurchasePrice('');
@ -92,6 +97,9 @@ export function usePurchaseOrderLineItemFields({
active: true,
part_active: true
},
onValueChange: (value, record) => {
setPart(record?.part_detail ?? {});
},
adjustFilters: (adjust: ApiFormAdjustFilterType) => {
return {
...adjust.filters,
@ -119,6 +127,14 @@ export function usePurchaseOrderLineItemFields({
destination: {
icon: <IconSitemap />
},
build_order: {
disabled: !part?.assembly,
filters: {
external: true,
outstanding: true,
part: part?.pk
}
},
notes: {
icon: <IconNotes />
},
@ -127,12 +143,24 @@ export function usePurchaseOrderLineItemFields({
}
};
if (!globalSettings.isSet('BUILDORDER_EXTERNAL_BUILDS', false)) {
delete fields.build_order;
}
if (create) {
fields['merge_items'] = {};
}
return fields;
}, [create, orderId, supplierId, autoPricing, purchasePrice]);
}, [
create,
orderId,
part,
globalSettings,
supplierId,
autoPricing,
purchasePrice
]);
return fields;
}

View File

@ -32,7 +32,7 @@ import RemoveRowButton from '../components/buttons/RemoveRowButton';
import { StandaloneField } from '../components/forms/StandaloneField';
import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl } from '@lib/index';
import { getDetailUrl } from '@lib/functions/Navigation';
import type {
ApiFormAdjustFilterType,
ApiFormFieldChoice,

View File

@ -248,6 +248,7 @@ export default function SystemSettings() {
<GlobalSettingList
keys={[
'BUILDORDER_REFERENCE_PATTERN',
'BUILDORDER_EXTERNAL_BUILDS',
'BUILDORDER_REQUIRE_RESPONSIBLE',
'BUILDORDER_REQUIRE_ACTIVE_PART',
'BUILDORDER_REQUIRE_LOCKED_PART',

View File

@ -8,6 +8,7 @@ import {
IconList,
IconListCheck,
IconListNumbers,
IconShoppingCart,
IconSitemap
} from '@tabler/icons-react';
import { useMemo } from 'react';
@ -25,6 +26,7 @@ import {
type DetailsField,
DetailsTable
} from '../../components/details/Details';
import DetailsBadge from '../../components/details/DetailsBadge';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
@ -49,12 +51,14 @@ import {
} from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import useStatusCodes from '../../hooks/UseStatusCodes';
import { useGlobalSettingsState } from '../../states/SettingsState';
import { useUserState } from '../../states/UserState';
import BuildAllocatedStockTable from '../../tables/build/BuildAllocatedStockTable';
import BuildLineTable from '../../tables/build/BuildLineTable';
import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
import BuildOrderTestTable from '../../tables/build/BuildOrderTestTable';
import BuildOutputTable from '../../tables/build/BuildOutputTable';
import { PurchaseOrderTable } from '../../tables/purchasing/PurchaseOrderTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
/**
@ -64,6 +68,7 @@ export default function BuildDetail() {
const { id } = useParams();
const user = useUserState();
const globalSettings = useGlobalSettingsState();
const buildStatus = useStatusCodes({ modelType: ModelType.build });
@ -124,6 +129,24 @@ export default function BuildDetail() {
hidden:
!build.status_custom_key || build.status_custom_key == build.status
},
{
type: 'boolean',
name: 'external',
label: t`External`,
icon: 'manufacturers',
hidden: !build.external
},
{
type: 'text',
name: 'purchase_order',
label: t`Purchase Order`,
icon: 'purchase_orders',
copy: true,
hidden: !build.external,
value_formatter: () => {
return 'TODO: external PO';
}
},
{
type: 'text',
name: 'reference',
@ -287,10 +310,38 @@ export default function BuildDetail() {
},
{
name: 'line-items',
label: t`Line Items`,
label: t`Required Stock`,
icon: <IconListNumbers />,
content: build?.pk ? <BuildLineTable build={build} /> : <Skeleton />
},
{
name: 'allocated-stock',
label: t`Allocated Stock`,
icon: <IconList />,
hidden:
build.status == buildStatus.COMPLETE ||
build.status == buildStatus.CANCELLED,
content: build.pk ? (
<BuildAllocatedStockTable buildId={build.pk} showPartInfo allowEdit />
) : (
<Skeleton />
)
},
{
name: 'consumed-stock',
label: t`Consumed Stock`,
icon: <IconListCheck />,
content: (
<StockItemTable
allowAdd={false}
tableName='build-consumed'
showLocation={false}
params={{
consumed_by: id
}}
/>
)
},
{
name: 'incomplete-outputs',
label: t`Incomplete Outputs`,
@ -320,32 +371,18 @@ export default function BuildDetail() {
)
},
{
name: 'allocated-stock',
label: t`Allocated Stock`,
icon: <IconList />,
hidden:
build.status == buildStatus.COMPLETE ||
build.status == buildStatus.CANCELLED,
name: 'external-purchase-orders',
label: t`External Orders`,
icon: <IconShoppingCart />,
content: build.pk ? (
<BuildAllocatedStockTable buildId={build.pk} showPartInfo allowEdit />
<PurchaseOrderTable externalBuildId={build.pk} />
) : (
<Skeleton />
)
},
{
name: 'consumed-stock',
label: t`Consumed Stock`,
icon: <IconListCheck />,
content: (
<StockItemTable
allowAdd={false}
tableName='build-consumed'
showLocation={false}
params={{
consumed_by: id
}}
/>
)
),
hidden:
!user.hasViewRole(UserRoles.purchase_order) ||
!build.external ||
!globalSettings.isSet('BUILDORDER_EXTERNAL_BUILDS')
},
{
name: 'child-orders',
@ -377,7 +414,7 @@ export default function BuildDetail() {
model_id: build.pk
})
];
}, [build, id, user, buildStatus]);
}, [build, id, user, buildStatus, globalSettings]);
const buildOrderFields = useBuildOrderFields({ create: false });
@ -531,6 +568,12 @@ export default function BuildDetail() {
status={build.status_custom_key}
type={ModelType.build}
options={{ size: 'lg' }}
/>,
<DetailsBadge
label={t`External`}
color='blue'
key='external'
visible={build.external}
/>
];
}, [build, instanceQuery]);

View File

@ -13,14 +13,25 @@ import PermissionDenied from '../../components/errors/PermissionDenied';
import { PageDetail } from '../../components/nav/PageDetail';
import type { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup';
import { useGlobalSettingsState } from '../../states/SettingsState';
import { useUserState } from '../../states/UserState';
import { PartCategoryFilter } from '../../tables/Filter';
import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
function BuildOrderCalendar() {
const globalSettings = useGlobalSettingsState();
const calendarFilters: TableFilter[] = useMemo(() => {
return [PartCategoryFilter()];
}, []);
return [
{
name: 'external',
label: t`External`,
description: t`Show external build orders`,
active: globalSettings.isSet('BUILDORDER_EXTERNAL_BUILDS')
},
PartCategoryFilter()
];
}, [globalSettings]);
return (
<OrderCalendar

View File

@ -266,7 +266,10 @@ export default function SupplierPartDetail() {
label: t`Purchase Orders`,
icon: <IconShoppingCart />,
content: supplierPart?.pk ? (
<PurchaseOrderTable supplierPartId={supplierPart.pk} />
<PurchaseOrderTable
supplierId={supplierPart.supplier}
supplierPartId={supplierPart.pk}
/>
) : (
<Skeleton />
)

View File

@ -288,11 +288,12 @@ export default function PartDetail() {
name: 'required',
label: t`Required for Orders`,
hidden: part.required <= 0,
icon: 'tick_off'
icon: 'stocktake'
},
{
type: 'progressbar',
name: 'allocated_to_build_orders',
icon: 'tick_off',
total: part.required_for_build_orders,
progress: part.allocated_to_build_orders,
label: t`Allocated to Build Orders`,
@ -303,6 +304,7 @@ export default function PartDetail() {
},
{
type: 'progressbar',
icon: 'tick_off',
name: 'allocated_to_sales_orders',
total: part.required_for_sales_orders,
progress: part.allocated_to_sales_orders,
@ -320,11 +322,12 @@ export default function PartDetail() {
hidden: true // TODO: Expose "can_build" to the API
},
{
type: 'string',
type: 'progressbar',
name: 'building',
unit: true,
label: t`In Production`,
hidden: !part.assembly || !part.building
progress: part.building,
total: part.scheduled_to_build,
hidden: !part.assembly || (!part.building && !part.scheduled_to_build)
}
];

View File

@ -1,8 +1,8 @@
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl } from '@lib/functions/Navigation';
import { apiUrl } from '@lib/index';
import { t } from '@lingui/core/macro';
import { Group, Skeleton, Stack, Text } from '@mantine/core';
import { IconInfoCircle, IconPackages, IconSitemap } from '@tabler/icons-react';

View File

@ -34,6 +34,7 @@ import type { TableColumn } from './Column';
import InvenTreeTableHeader from './InvenTreeTableHeader';
import { type RowAction, RowActions } from './RowActions';
const ACTIONS_COLUMN_ACCESSOR: string = '--actions--';
const defaultPageSize: number = 25;
const PAGE_SIZES = [10, 15, 20, 25, 50, 100, 500];
@ -313,7 +314,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
// If row actions are available, add a column for them
if (tableProps.rowActions) {
cols.push({
accessor: '--actions--',
accessor: ACTIONS_COLUMN_ACCESSOR,
title: ' ',
hidden: false,
resizable: false,
@ -359,6 +360,23 @@ export function InvenTreeTable<T extends Record<string, any>>({
columns: dataColumns
});
// Ensure that the "actions" column is always at the end of the list
// This effect is necessary as sometimes the underlying mantine-datatable columns change
useEffect(() => {
const idx: number = tableColumns.columnsOrder.indexOf(
ACTIONS_COLUMN_ACCESSOR
);
if (idx >= 0 && idx < tableColumns.columnsOrder.length - 1) {
// Actions column is not at the end of the list - move it there
const newOrder = tableColumns.columnsOrder.filter(
(col) => col != ACTIONS_COLUMN_ACCESSOR
);
newOrder.push(ACTIONS_COLUMN_ACCESSOR);
tableColumns.setColumnsOrder(newOrder);
}
}, [tableColumns.columnsOrder]);
// Reset the pagination state when the search term changes
useEffect(() => {
tableState.setPage(1);

View File

@ -12,8 +12,10 @@ import { RenderUser } from '../../components/render/User';
import { useBuildOrderFields } from '../../forms/BuildForms';
import { useCreateApiFormModal } from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { useGlobalSettingsState } from '../../states/SettingsState';
import { useUserState } from '../../states/UserState';
import {
BooleanColumn,
CreationDateColumn,
DateColumn,
PartColumn,
@ -59,6 +61,7 @@ export function BuildOrderTable({
parentBuildId?: number;
salesOrderId?: number;
}>) {
const globalSettings = useGlobalSettingsState();
const table = useTable(!!partId ? 'buildorder-part' : 'buildorder-index');
const tableColumns = useMemo(() => {
@ -109,6 +112,13 @@ export function BuildOrderTable({
accessor: 'priority',
sortable: true
},
BooleanColumn({
accessor: 'external',
title: t`External`,
sortable: true,
switchable: true,
hidden: !globalSettings.isSet('BUILDORDER_EXTERNAL_BUILDS')
}),
CreationDateColumn({}),
StartDateColumn({}),
TargetDateColumn({}),
@ -126,7 +136,7 @@ export function BuildOrderTable({
},
ResponsibleColumn({})
];
}, [parentBuildId]);
}, [parentBuildId, globalSettings]);
const tableFilters: TableFilter[] = useMemo(() => {
const filters: TableFilter[] = [
@ -160,6 +170,12 @@ export function BuildOrderTable({
HasProjectCodeFilter(),
IssuedByFilter(),
ResponsibleFilter(),
{
name: 'external',
label: t`External`,
description: t`Show external build orders`,
active: globalSettings.isSet('BUILDORDER_EXTERNAL_BUILDS')
},
PartCategoryFilter()
];
@ -174,7 +190,7 @@ export function BuildOrderTable({
}
return filters;
}, [partId]);
}, [partId, globalSettings]);
const user = useUserState();

View File

@ -2,17 +2,11 @@ import { t } from '@lingui/core/macro';
import { ActionIcon, Badge, Group, Text, Tooltip } from '@mantine/core';
import { IconCirclePlus } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useState
} from 'react';
import { type ReactNode, useEffect, useMemo, useState } from 'react';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api';
import { ModelType } from '@lib/index';
import type { TableFilter } from '@lib/types/Filters';
import type { ApiFormFieldSet } from '@lib/types/Forms';
import { PassFailButton } from '../../components/buttons/YesNoButton';
@ -26,7 +20,6 @@ import { useUserState } from '../../states/UserState';
import type { TableColumn } from '../Column';
import { LocationColumn } from '../ColumnRenderers';
import { InvenTreeTable } from '../InvenTreeTable';
import type { RowAction } from '../RowActions';
import { TableHoverCard } from '../TableHoverCard';
/**
@ -235,13 +228,6 @@ export default function BuildOrderTestTable({
return [];
}, []);
const rowActions = useCallback(
(record: any): RowAction[] => {
return [];
},
[user]
);
return (
<>
{createTestResult.modal}
@ -256,7 +242,6 @@ export default function BuildOrderTestTable({
tests: true,
build: buildId
},
rowActions: rowActions,
tableFilters: tableFilters,
tableActions: tableActions,
modelType: ModelType.stockitem

View File

@ -6,10 +6,12 @@ import {
Group,
Paper,
Space,
Stack,
Text
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
IconBuildingFactory2,
IconCircleCheck,
IconCircleX,
IconExclamationCircle
@ -22,6 +24,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import type { TableFilter } from '@lib/types/Filters';
import { ActionButton } from '../../components/buttons/ActionButton';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import { ProgressBar } from '../../components/items/ProgressBar';
@ -43,6 +46,7 @@ import { useTable } from '../../hooks/UseTable';
import { useUserState } from '../../states/UserState';
import type { TableColumn } from '../Column';
import { LocationColumn, PartColumn, StatusColumn } from '../ColumnRenderers';
import { StatusFilterOptions } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
import { type RowAction, RowEditAction, RowViewAction } from '../RowActions';
import { TableHoverCard } from '../TableHoverCard';
@ -335,6 +339,17 @@ export default function BuildOutputTable({
}
});
const tableFilters: TableFilter[] = useMemo(() => {
return [
{
name: 'status',
label: t`Status`,
description: t`Filter by stock status`,
choiceFunction: StatusFilterOptions(ModelType.stockitem)
}
];
}, []);
const tableActions = useMemo(() => {
return [
<ActionButton
@ -373,11 +388,11 @@ export default function BuildOutputTable({
<AddItemButton
key='add-build-output'
tooltip={t`Add Build Output`}
hidden={!user.hasAddRole(UserRoles.build)}
hidden={build.external || !user.hasAddRole(UserRoles.build)}
onClick={addBuildOutput.open}
/>
];
}, [user, table.selectedRecords, table.hasSelectedRecords]);
}, [build, user, table.selectedRecords, table.hasSelectedRecords]);
const rowActions = useCallback(
(record: any): RowAction[] => {
@ -568,32 +583,44 @@ export default function BuildOutputTable({
opened={drawerOpen}
close={closeDrawer}
/>
<InvenTreeTable
tableState={table}
url={apiUrl(ApiEndpoints.stock_item_list)}
columns={tableColumns}
props={{
params: {
part_detail: true,
location_detail: true,
tests: true,
is_building: true,
build: buildId
},
enableLabels: true,
enableReports: true,
dataFormatter: formatRecords,
tableActions: tableActions,
rowActions: rowActions,
enableSelection: true,
onRowClick: (record: any) => {
if (hasTrackedItems && !!record.serial) {
setSelectedOutputs([record]);
openDrawer();
<Stack gap='xs'>
{build.external && (
<Alert
color='blue'
icon={<IconBuildingFactory2 />}
title={t`External Build`}
>
{t`This build order is fulfilled by an external purchase order`}
</Alert>
)}
<InvenTreeTable
tableState={table}
url={apiUrl(ApiEndpoints.stock_item_list)}
columns={tableColumns}
props={{
params: {
part_detail: true,
location_detail: true,
tests: true,
is_building: true,
build: buildId
},
enableLabels: true,
enableReports: true,
dataFormatter: formatRecords,
tableFilters: tableFilters,
tableActions: tableActions,
rowActions: rowActions,
enableSelection: true,
onRowClick: (record: any) => {
if (hasTrackedItems && !!record.serial) {
setSelectedOutputs([record]);
openDrawer();
}
}
}
}}
/>
}}
/>
</Stack>
</>
);
}

View File

@ -8,10 +8,12 @@ import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import type { TableFilter } from '@lib/types/Filters';
import { useNavigate } from 'react-router-dom';
import { ActionButton } from '../../components/buttons/ActionButton';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import ImporterDrawer from '../../components/importer/ImporterDrawer';
import { ProgressBar } from '../../components/items/ProgressBar';
import { RenderInstance } from '../../components/render/Instance';
import { RenderStockLocation } from '../../components/render/Stock';
import { dataImporterSessionFields } from '../../forms/ImporterForms';
import {
@ -40,7 +42,8 @@ import {
type RowAction,
RowDeleteAction,
RowDuplicateAction,
RowEditAction
RowEditAction,
RowViewAction
} from '../RowActions';
import { TableHoverCard } from '../TableHoverCard';
@ -62,6 +65,7 @@ export function PurchaseOrderLineItemTable({
}>) {
const table = useTable('purchase-order-line-item');
const navigate = useNavigate();
const user = useUserState();
// Data import
@ -142,6 +146,23 @@ export function PurchaseOrderLineItemTable({
sortable: false
},
ReferenceColumn({}),
{
accessor: 'build_order',
title: t`Build Order`,
sortable: true,
render: (record: any) => {
if (record.build_order_detail) {
return (
<RenderInstance
instance={record.build_order_detail}
model={ModelType.build}
/>
);
} else {
return '-';
}
}
},
{
accessor: 'quantity',
title: t`Quantity`,
@ -276,7 +297,7 @@ export function PurchaseOrderLineItemTable({
const [selectedLine, setSelectedLine] = useState<number>(0);
const editPurchaseOrderFields = usePurchaseOrderLineItemFields({
const editLineItemFields = usePurchaseOrderLineItemFields({
create: false,
orderId: orderId,
supplierId: supplierId
@ -286,7 +307,7 @@ export function PurchaseOrderLineItemTable({
url: ApiEndpoints.purchase_order_line_list,
pk: selectedLine,
title: t`Edit Line Item`,
fields: editPurchaseOrderFields,
fields: editLineItemFields,
table: table
});
@ -326,6 +347,13 @@ export function PurchaseOrderLineItemTable({
receiveLineItems.open();
}
},
RowViewAction({
hidden: !record.build_order,
title: t`View Build Order`,
modelType: ModelType.build,
modelId: record.build_order,
navigate: navigate
}),
RowEditAction({
hidden: !user.hasChangeRole(UserRoles.purchase_order),
onClick: () => {

View File

@ -53,10 +53,12 @@ import { InvenTreeTable } from '../InvenTreeTable';
*/
export function PurchaseOrderTable({
supplierId,
supplierPartId
supplierPartId,
externalBuildId
}: Readonly<{
supplierId?: number;
supplierPartId?: number;
externalBuildId?: number;
}>) {
const table = useTable('purchase-order');
const user = useUserState();
@ -178,7 +180,8 @@ export function PurchaseOrderTable({
params: {
supplier_detail: true,
supplier: supplierId,
supplier_part: supplierPartId
supplier_part: supplierPartId,
external_build: externalBuildId
},
tableFilters: tableFilters,
tableActions: tableActions,

View File

@ -2,8 +2,8 @@ import { t } from '@lingui/core/macro';
import { useMemo } from 'react';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { UserRoles } from '@lib/index';
import { notifications, showNotification } from '@mantine/notifications';
import { IconTrashXFilled, IconX } from '@tabler/icons-react';
import { api } from '../../App';

View File

@ -3,6 +3,7 @@ import { test } from '../baseFixtures.ts';
import {
activateCalendarView,
clearTableFilters,
clickOnRowMenu,
getRowFromCell,
loadTab,
navigate,
@ -65,7 +66,7 @@ test('Build Order - Basic Tests', async ({ browser }) => {
await loadTab(page, 'Attachments');
await loadTab(page, 'Notes');
await loadTab(page, 'Incomplete Outputs');
await loadTab(page, 'Line Items');
await loadTab(page, 'Required Stock');
await loadTab(page, 'Allocated Stock');
// Check for expected text in the table
@ -373,3 +374,53 @@ test('Build Order - Duplicate', async ({ browser }) => {
await page.getByText('Pending').first().waitFor();
});
// Tests for external build orders
test('Build Order - External', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'manufacturing/index/' });
await loadTab(page, 'Build Orders');
// Filter to show only external builds
await clearTableFilters(page);
await setTableChoiceFilter(page, 'External', 'Yes');
await page.getByRole('cell', { name: 'BO0026' }).waitFor();
await page.getByRole('cell', { name: 'BO0025' }).click();
await page
.locator('span')
.filter({ hasText: /^External$/ })
.waitFor();
await loadTab(page, 'Allocated Stock');
await loadTab(page, 'Incomplete Outputs');
await page
.getByText('This build order is fulfilled by an external purchase order')
.waitFor();
await loadTab(page, 'External Orders');
await page.getByRole('cell', { name: 'PO0016' }).click();
await loadTab(page, 'Attachments');
await loadTab(page, 'Received Stock');
await loadTab(page, 'Line Items');
const cell = await page.getByRole('cell', {
name: '002.01-PCBA',
exact: true
});
await clickOnRowMenu(cell);
await page.getByRole('menuitem', { name: 'Receive line item' }).waitFor();
await page.getByRole('menuitem', { name: 'Duplicate' }).waitFor();
await page.getByRole('menuitem', { name: 'Edit' }).waitFor();
await page.getByRole('menuitem', { name: 'View Build Order' }).click();
// Wait for navigation back to build order detail page
await page.getByText('Build Order: BO0025', { exact: true }).waitFor();
// Let's look at BO0026 too
await navigate(page, 'manufacturing/build-order/26/details');
await loadTab(page, 'External Orders');
await page.getByRole('cell', { name: 'PO0017' }).waitFor();
await page.getByRole('cell', { name: 'PO0018' }).waitFor();
});