[feature] Break apart assemblies (#12310)

* Implement API endpoint for stock disassembly

* Add basic frontend implementation

* Adjust required user permission

* Add preFormContent

* Read-only if serialized

* Adjust location and status of each subcomponent

* Handle null value

* display installed items in frontend form

* More unit tests

* Traceability

* Add docs

* Exclude virtual / consumable stock

* Additional docs

* Added bundled items docs

* More docs

* more docs tweaks

* Updated part docs

* Add playwright tests

* Robustify test

* suppress certain warnings in schema

* Adjust playwright tests

* bug fix

* Tweak playwright tests
This commit is contained in:
Oliver
2026-07-12 13:31:10 +10:00
committed by GitHub
parent 35eb839f08
commit 4e00e2d0c6
28 changed files with 1998 additions and 237 deletions
+1
View File
@@ -155,6 +155,7 @@ export enum ApiEndpoints {
stock_assign = 'stock/assign/',
stock_status = 'stock/status/',
stock_convert = 'stock/:id/convert/',
stock_disassemble = 'stock/:id/disassemble/',
stock_install = 'stock/:id/install/',
stock_uninstall = 'stock/:id/uninstall/',
stock_serialize = 'stock/:id/serialize/',
@@ -6,16 +6,19 @@ import { InvenTreeIcon } from '../../functions/icons';
export default function RemoveRowButton({
onClick,
disabled,
tooltip = t`Remove this row`,
tooltipAlignment
}: Readonly<{
onClick: () => void;
disabled?: boolean;
tooltip?: string;
tooltipAlignment?: FloatingPosition;
}>) {
return (
<ActionButton
onClick={onClick}
disabled={disabled}
icon={<InvenTreeIcon icon='square_x' />}
tooltip={tooltip}
tooltipAlignment={tooltipAlignment ?? 'top-end'}
+509 -4
View File
@@ -3,6 +3,7 @@ import { StylishText } from '@lib/components/StylishText';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api';
import { formatDecimal } from '@lib/functions/Formatting';
import { getDetailUrl } from '@lib/functions/Navigation';
import type {
ApiFormAdjustFilterType,
@@ -14,18 +15,24 @@ import type {
import { t } from '@lingui/core/macro';
import {
Alert,
Collapse,
Flex,
Group,
List,
NumberInput,
Paper,
Skeleton,
Stack,
Table,
Text
Text,
UnstyledButton
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { modals } from '@mantine/modals';
import {
IconCalendarExclamation,
IconChevronDown,
IconChevronUp,
IconCoins,
IconCurrencyDollar,
IconLink,
@@ -37,6 +44,7 @@ import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import dayjs from 'dayjs';
import {
type JSX,
type ReactNode,
Suspense,
useCallback,
useEffect,
@@ -44,7 +52,7 @@ import {
useRef,
useState
} from 'react';
import { useFormContext } from 'react-hook-form';
import { useFormContext, useWatch } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { api } from '../App';
import RemoveRowButton from '../components/buttons/RemoveRowButton';
@@ -54,8 +62,14 @@ import {
type TableFieldRowProps
} from '../components/forms/fields/TableField';
import { Thumbnail } from '../components/images/Thumbnail';
import { StatusRenderer } from '../components/render/StatusRenderer';
import { RenderStockLocation } from '../components/render/Stock';
import {
StatusRenderer,
getStatusCodeOptions
} from '../components/render/StatusRenderer';
import {
RenderStockItem,
RenderStockLocation
} from '../components/render/Stock';
import { StatusFilterOptions } from '../components/tables/Filter';
import { InvenTreeIcon } from '../functions/icons';
import {
@@ -438,6 +452,497 @@ export function useStockItemSerializeFields({
}, [serialGenerator.result]);
}
function DisassemblyLineRow({
props,
record,
installedItems,
serialized,
statuses
}: Readonly<{
props: TableFieldRowProps;
record: any;
installedItems: any[];
serialized: boolean;
statuses: any;
}>) {
// Number of assemblies being disassembled (top-level form field)
const assemblies = useWatch({ name: 'quantity' });
// Once the user manually edits the row quantity, stop auto-scaling it
const [edited, setEdited] = useState<boolean>(false);
const [installedOpen, installedHandlers] = useDisclosure(false);
const [locationOpen, locationHandlers] = useDisclosure(false, {
onClose: () => props.changeFn(props.rowId, 'location', undefined)
});
const [statusOpen, statusHandlers] = useDisclosure(false, {
onClose: () => props.changeFn(props.rowId, 'status', undefined)
});
useEffect(() => {
if (edited) {
return;
}
const n = Number(assemblies);
const unit = Number(record.quantity);
if (Number.isFinite(n) && Number.isFinite(unit)) {
const expected = unit * n;
if (props.item.quantity !== expected) {
props.changeFn(props.rowId, 'quantity', expected);
}
}
}, [assemblies, edited]);
return (
<>
<Table.Tr>
<Table.Td>
<Group gap='xs' justify='left' wrap='nowrap'>
<Thumbnail
size={32}
src={record.sub_part_detail?.thumbnail}
align='center'
/>
<Stack gap={2}>
<div>{record.sub_part_detail?.name}</div>
{installedItems.length > 0 && (
<UnstyledButton
onClick={() => installedHandlers.toggle()}
aria-label={`toggle-installed-items-${props.rowId}`}
>
<Group gap={4} wrap='nowrap'>
{installedOpen ? (
<IconChevronUp size={14} />
) : (
<IconChevronDown size={14} />
)}
<Text size='xs' c='blue'>
{t`Installed Items`}: {installedItems.length}
</Text>
</Group>
</UnstyledButton>
)}
</Stack>
</Group>
</Table.Td>
<Table.Td>{record.quantity}</Table.Td>
<Table.Td style={{ whiteSpace: 'nowrap' }}>
{serialized ? (
// Quantity is fixed when disassembling a serialized stock item
<Text size='sm' aria-label='text-field-quantity'>
{formatDecimal(props.item.quantity)}
</Text>
) : (
<TableFieldQuantityInput
min={0}
value={props.item.quantity ?? ''}
onChange={(value) => {
setEdited(true);
props.changeFn(props.rowId, 'quantity', value);
}}
error={props.rowErrors?.quantity?.message}
/>
)}
</Table.Td>
<Table.Td style={{ whiteSpace: 'nowrap' }}>
<NumberInput
radius='sm'
aria-label='number-field-purchase_price'
placeholder={t`Automatic`}
min={0}
decimalScale={6}
value={props.item.purchase_price ?? ''}
onChange={(value) => {
props.changeFn(
props.rowId,
'purchase_price',
value === '' ? null : value
);
}}
error={props.rowErrors?.purchase_price?.message}
/>
</Table.Td>
<Table.Td style={{ width: '1%', whiteSpace: 'nowrap' }}>
<Flex gap='1px'>
<ActionButton
size='sm'
onClick={() => locationHandlers.toggle()}
icon={<InvenTreeIcon icon='location' />}
tooltip={t`Set Location`}
tooltipAlignment='top'
variant={locationOpen ? 'outline' : 'transparent'}
/>
<ActionButton
size='sm'
onClick={() => statusHandlers.toggle()}
icon={<InvenTreeIcon icon='status' />}
tooltip={t`Change Status`}
tooltipAlignment='top'
variant={statusOpen ? 'outline' : 'transparent'}
/>
</Flex>
</Table.Td>
<Table.Td>
<RemoveRowButton
onClick={() => props.removeFn(props.rowId)}
disabled={installedItems.length > 0}
tooltip={
installedItems.length > 0
? t`This row cannot be removed as it has installed items`
: undefined
}
/>
</Table.Td>
</Table.Tr>
{installedItems.length > 0 && (
<Table.Tr>
<Table.Td colSpan={6} style={{ padding: 0, borderBottom: 'none' }}>
<Collapse expanded={installedOpen}>
<Paper p='xs' pl='xl'>
<Stack gap='xs'>
<Text size='xs' c='dimmed'>
{t`The following installed items will be uninstalled during disassembly`}
</Text>
{installedItems.map((item: any) => (
<RenderStockItem key={item.pk} instance={item} />
))}
</Stack>
</Paper>
</Collapse>
</Table.Td>
</Table.Tr>
)}
<TableFieldExtraRow
visible={locationOpen}
fieldName='location'
onValueChange={(value) =>
props.changeFn(props.rowId, 'location', value)
}
fieldDefinition={{
field_type: 'related field',
model: ModelType.stocklocation,
api_url: apiUrl(ApiEndpoints.stock_location_list),
filters: {
structural: false
},
value: props.item.location,
label: t`Location`,
description: t`Custom location for the component items`,
icon: <InvenTreeIcon icon='location' />
}}
error={props.rowErrors?.location?.message}
/>
<TableFieldExtraRow
visible={statusOpen}
fieldName='status'
defaultValue={10}
onValueChange={(value) => props.changeFn(props.rowId, 'status', value)}
fieldDefinition={{
field_type: 'choice',
api_url: apiUrl(ApiEndpoints.stock_status),
choices: statuses,
label: t`Status`,
description: t`Status for the component items`
}}
error={props.rowErrors?.status?.message}
/>
</>
);
}
/**
* Display a summary of the stock item which is about to be disassembled.
*/
function DisassemblyItemInfo({
stockItem
}: Readonly<{
stockItem: any;
}>) {
const serialized: boolean =
!!stockItem.serial && Number(stockItem.quantity) == 1;
const details: { label: string; value: ReactNode }[] = useMemo(() => {
const rows = [
{
label: t`Location`,
value: stockItem.location_detail?.pathstring ?? t`No location set`
}
];
if (serialized) {
rows.push({
label: t`Serial Number`,
value: stockItem.serial
});
} else {
rows.push({
label: t`Quantity`,
value: formatDecimal(stockItem.quantity)
});
}
if (stockItem.batch) {
rows.push({
label: t`Batch Code`,
value: stockItem.batch
});
}
return rows;
}, [stockItem, serialized]);
return (
<Paper withBorder p='sm'>
<Group gap='md' wrap='nowrap' align='center'>
<Thumbnail
size={56}
src={stockItem.part_detail?.thumbnail}
align='center'
/>
<Group grow wrap='nowrap'>
<Stack gap={2}>
<Text fw={700}>
{stockItem.part_detail?.full_name ?? stockItem.part_detail?.name}
</Text>
{stockItem.part_detail?.description && (
<Text size='sm' c='dimmed'>
{stockItem.part_detail.description}
</Text>
)}
</Stack>
<Table withRowBorders={false} verticalSpacing={2}>
<Table.Tbody>
{details.map((row) => (
<Table.Tr key={row.label}>
<Table.Td style={{ width: '1%', whiteSpace: 'nowrap' }}>
<Text size='sm' c='dimmed'>
{row.label}
</Text>
</Table.Td>
<Table.Td>
<Text size='sm'>{row.value}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Group>
</Group>
</Paper>
);
}
/**
* Display any installed stock items which could not be matched
* against a BOM line item for the disassembly operation.
*/
function DisassemblyLeftoverItems({
items
}: Readonly<{
items: any[];
}>) {
if (items.length == 0) {
return null;
}
return (
<Alert
color='yellow'
icon={<IconPackage />}
title={t`Additional Installed Items`}
>
<Stack gap='xs'>
<Text size='sm'>
{t`The following items are installed within this stock item, but do not match a listed component. They will be uninstalled during disassembly.`}
</Text>
{items.map((item: any) => (
<RenderStockItem key={item.pk} instance={item} />
))}
</Stack>
</Alert>
);
}
/**
* Form for disassembling a stock item into its component parts,
* based on the Bill of Materials for the associated part.
*/
export function useDisassembleStockItem({
stockItem,
refresh
}: {
stockItem: any;
refresh: () => void;
}) {
// Fetch BOM lines for the part associated with the stock item
const bomQuery = useQuery({
queryKey: ['bom-items-disassemble', stockItem.pk, stockItem.part],
enabled: !!stockItem.part && (stockItem.part_detail?.assembly ?? false),
queryFn: async () =>
api
.get(apiUrl(ApiEndpoints.bom_list), {
params: {
part: stockItem.part,
sub_part_detail: true,
substitutes: true
}
})
.then((response) => response.data ?? [])
});
// Fetch any stock items which are installed within this stock item
const installedQuery = useQuery({
queryKey: ['installed-items-disassemble', stockItem.pk],
enabled: !!stockItem.pk && (stockItem.part_detail?.assembly ?? false),
queryFn: async () =>
api
.get(apiUrl(ApiEndpoints.stock_item_list), {
params: {
belongs_to: stockItem.pk,
part_detail: true
}
})
.then((response) => response.data ?? [])
});
const bomItems: any[] = useMemo(() => {
// Consumable BOM lines are excluded from disassembly,
// as are any lines which point to a virtual part
return (bomQuery.data ?? []).filter(
(elem: any) =>
!elem.consumable &&
!elem.sub_part_detail?.consumable &&
!elem.sub_part_detail?.virtual
);
}, [bomQuery.data]);
const records = useMemo(
() => Object.fromEntries(bomItems.map((elem: any) => [elem.pk, elem])),
[bomItems]
);
// Map installed stock items against the available BOM lines.
// Note: This mirrors the matching performed by the server during disassembly,
// matching against the referenced sub_part or any designated substitute parts.
// Variants of the sub_part cannot be resolved client-side, so any such items
// are reported as "leftover" items instead.
const { installedMap, leftoverItems } = useMemo(() => {
const map: Record<number, any[]> = {};
const leftover: any[] = [];
for (const item of installedQuery.data ?? []) {
const bomItem = bomItems.find(
(elem: any) =>
elem.sub_part === item.part ||
elem.substitutes?.some((sub: any) => sub.part === item.part)
);
if (bomItem) {
map[bomItem.pk] = [...(map[bomItem.pk] ?? []), item];
} else {
leftover.push(item);
}
}
return { installedMap: map, leftoverItems: leftover };
}, [bomItems, installedQuery.data]);
// A serialized stock item must be disassembled in its entirety
const serialized: boolean =
!!stockItem.serial && Number(stockItem.quantity) == 1;
const stockStatusCodes = useMemo(
() => getStatusCodeOptions(ModelType.stockitem),
[]
);
const fields: ApiFormFieldSet = useMemo(() => {
return {
quantity: {
disabled: serialized
},
location: {
filters: {
structural: false
}
},
items: {
field_type: 'table',
value: bomItems.map((elem: any) => {
return {
id: elem.pk,
bom_item: elem.pk,
quantity:
(Number(elem.quantity) || 0) * (Number(stockItem.quantity) || 0),
purchase_price: null,
purchase_price_currency:
stockItem.purchase_price_currency ?? undefined
};
}),
modelRenderer: (row: TableFieldRowProps) => {
const record = records[row.item.bom_item];
if (!record) {
return null;
}
return (
<DisassemblyLineRow
props={row}
record={record}
installedItems={installedMap[row.item.bom_item] ?? []}
serialized={serialized}
statuses={stockStatusCodes}
key={row.rowId}
/>
);
},
headers: [
{ title: t`Component`, style: { minWidth: '200px' } },
{ title: t`Unit Quantity`, style: { width: '100px' } },
{ title: t`Quantity`, style: { width: '200px' } },
{ title: t`Unit Price`, style: { width: '200px' } },
{ title: t`Actions` },
{ title: '', style: { width: '50px' } }
]
},
notes: {}
};
}, [
bomItems,
records,
installedMap,
stockItem,
serialized,
stockStatusCodes
]);
return useCreateApiFormModal({
url: ApiEndpoints.stock_disassemble,
pk: stockItem.pk,
title: t`Disassemble Stock Item`,
fields: fields,
preFormContent: <DisassemblyItemInfo stockItem={stockItem} />,
postFormContent: <DisassemblyLeftoverItems items={leftoverItems} />,
initialData: {
quantity: stockItem.quantity,
location: stockItem.location
},
size: '80%',
successMessage: t`Stock item disassembled`,
onFormSuccess: refresh,
onOpen: () => {
// Ensure the installed items data is up to date when the form is opened
installedQuery.refetch();
}
});
}
function StockItemDefaultMove({
stockItem,
value
@@ -232,6 +232,8 @@ export default function BuildDetail() {
part_detail: true,
tags: true
},
hasPrimaryKey: true,
defaultValue: {},
refetchOnMount: true
});
@@ -13,6 +13,7 @@ import {
import {
IconArrowLeft,
IconArrowRight,
IconArrowsSplit,
IconBookmark,
IconBoxPadding,
IconChecklist,
@@ -69,6 +70,7 @@ import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
import { useApi } from '../../contexts/ApiContext';
import { formatCurrency, formatDecimal } from '../../defaults/formatters';
import {
useDisassembleStockItem,
useFindSerialNumberForm,
useStockFields,
useStockItemSerializeFields
@@ -864,6 +866,11 @@ export default function StockDetail() {
successMessage: t`Stock item serialized`
});
const disassembleStockItem = useDisassembleStockItem({
stockItem: stockitem,
refresh: refreshInstance
});
const orderPartsWizard = OrderPartsWizard({
parts: stockitem.part_detail ? [stockitem.part_detail] : []
});
@@ -950,6 +957,18 @@ export default function StockDetail() {
serializeStockItem.open();
}
},
{
name: t`Disassemble`,
tooltip: t`Disassemble this stock item into its component parts`,
hidden:
!user.hasAddRole(UserRoles.stock) ||
!stockitem.in_stock ||
stockitem.part_detail?.assembly != true,
icon: <IconArrowsSplit color='blue' />,
onClick: () => {
disassembleStockItem.open();
}
},
{
name: t`Order`,
tooltip: t`Order Stock`,
@@ -1118,6 +1137,7 @@ export default function StockDetail() {
{convertStockItem.modal}
{duplicateStockItem.modal}
{serializeStockItem.modal}
{disassembleStockItem.modal}
{stockAdjustActions.modals.map((modal) => modal.modal)}
{orderPartsWizard.wizard}
</>
@@ -148,6 +148,7 @@ test('Build Order - Tags', async ({ browser }) => {
// Check for expected results
await page.getByRole('cell', { name: 'BO0026' }).click();
await page.getByText('Build Order: BO0026').first().waitFor();
await page.getByText('100 x 002.01-PCBA | Widget').waitFor();
// Check for tags displayed on BuildOrder detail page
@@ -279,6 +280,7 @@ test('Build Order - Build Outputs', async ({ browser }) => {
await page.getByRole('cell', { name: 'BO0011' }).click();
await loadTab(page, 'Incomplete Outputs');
await page.getByRole('cell', { name: 'BX-123' }).waitFor();
await page.waitForLoadState('networkidle');
// Check the "printing" actions for the selected outputs
await page.getByRole('checkbox', { name: 'Select all records' }).check();
+54 -196
View File
@@ -1,10 +1,8 @@
import { expect, test } from '../baseFixtures.js';
import { stevenuser } from '../defaults.js';
import {
activateCalendarView,
clearTableFilters,
clickButtonIfVisible,
clickOnRowMenu,
loadTab,
navigate,
openFilterDrawer,
@@ -585,208 +583,68 @@ test('Stock - Location', async ({ browser }) => {
await page.getByText('No match found for barcode data').waitFor();
});
test('Transfer Orders - General', async ({ browser }) => {
const page = await doCachedLogin(browser);
// Test disassembly of an assembled stock item
test('Stock - Disassembly', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'stock/item/1387/details' });
await page.getByRole('tab', { name: 'Stock' }).click();
await page.waitForURL('**/stock/location/index/**');
await loadTab(page, 'Transfer Orders');
await clearTableFilters(page);
// We have now loaded the "Transfer Orders" table. Check for some expected texts
await page.getByText('Complete').first().waitFor();
await page.getByText('Issued').first().waitFor();
await page.getByText('Cancelled').first().waitFor();
// Load a particular Transfer Order
await page.getByRole('cell', { name: 'TO-0002' }).click();
await page.waitForTimeout(200);
// This transfer order should be "issued"
await page.getByText('Issued').first().waitFor();
// Edit the transfer order (via keyboard shortcut)
await page.keyboard.press('Control+E');
await page.getByLabel('text-field-reference', { exact: true }).waitFor();
await page.getByLabel('related-field-project_code').waitFor();
await page.getByRole('button', { name: 'Cancel' }).click();
await page.getByRole('button', { name: 'Complete Order' }).click();
await page.getByRole('button', { name: 'Cancel' }).click();
// Check for other expected actions
await page.getByRole('button', { name: 'action-menu-order-actions' }).click();
await page.getByLabel('action-menu-order-actions-edit').waitFor();
await page.getByLabel('action-menu-order-actions-duplicate').waitFor();
await page.getByLabel('action-menu-order-actions-hold').waitFor();
// Click on some tabs
await loadTab(page, 'Line Items');
await loadTab(page, 'Allocated Stock');
await loadTab(page, 'Parameters');
await loadTab(page, 'Attachments');
await loadTab(page, 'Notes');
});
test('Transfer Order - Reference', async ({ browser }) => {
const page = await doCachedLogin(browser);
// go to transfer orders
await page.getByRole('tab', { name: 'Stock' }).click();
await page.waitForURL('**/stock/location/index/**');
await loadTab(page, 'Transfer Orders');
// click add button
// Duplicate this stock item to avoid impacting other tests
await page
.getByRole('button', { name: 'action-button-add-transfer-' })
.getByRole('button', { name: 'action-menu-stock-item-actions' })
.click();
// Ensure a new reference is suggested
await page
.getByRole('menuitem', { name: 'action-menu-stock-item-actions-duplicate' })
.click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.waitForTimeout(100);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(250);
// Grab the Transfer Order reference
const reference: string = await page
.getByRole('textbox', { name: 'text-field-reference' })
.inputValue();
expect(reference).toMatch(/TO-\d+/);
await page.getByRole('textbox', { name: 'text-field-description' }).click();
// Disassemble this stock item
await page
.getByRole('textbox', { name: 'text-field-description' })
.fill('creating from playwrigh!');
// create the transfer order
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Item Created').waitFor();
// go back to stock page
await page.getByRole('link', { name: 'Stock', exact: true }).click();
.getByRole('button', { name: 'action-menu-stock-operations' })
.click();
await page
.getByRole('button', { name: 'action-button-add-transfer-' })
.getByRole('menuitem', { name: 'action-menu-stock-operations-disassemble' })
.click();
const nextReference: string = await page
.getByRole('textbox', { name: 'text-field-reference' })
.inputValue();
expect(nextReference).toMatch(/TO-\d+/);
// Perform partial disassembly
await page
.getByRole('textbox', {
name: 'number-field-quantity',
description: 'Number of assemblies to disassemble'
})
.fill('6');
// Ensure that the reference has incremented
const refNumber = Number(reference.replace('TO-', ''));
const nextRefNumber = Number(nextReference.replace('TO-', ''));
expect(nextRefNumber).toBe(refNumber + 1);
});
test('Transfer Order - Calendar', async ({ browser }) => {
const page = await doCachedLogin(browser);
await navigate(page, 'stock/location/index/transfer-orders');
await activateCalendarView(page);
// Export calendar data
await page.getByLabel('calendar-export-data').click();
await page.getByRole('button', { name: 'Export', exact: true }).click();
await page.getByText('Process completed successfully').waitFor();
// Required because we downloaded a file
await page.context().close();
});
test('Transfer Order - Edit', async ({ browser }) => {
const page = await doCachedLogin(browser);
await navigate(page, 'stock/transfer-order/2/');
// Check for expected text items
await page.getByText('Consume some paint').first().waitFor();
await page.getByText('2026-04-20').waitFor(); // Created date
await page.getByText('2026-04-23').waitFor(); // Issue date
await page.getByText('PRJ-HEL').waitFor(); // Project Code
await page.keyboard.press('Control+E');
// Edit start date
await page.getByLabel('date-field-start_date').fill('2026-04-28');
// Submit the form
await page.getByRole('button', { name: 'Submit' }).click();
// Expect error
await page.getByText('Errors exist for one or more form fields').waitFor();
await page.getByText('Target date must be after start date').waitFor();
// Cancel the form
await page.getByRole('button', { name: 'Cancel' }).click();
});
test('Transfer Order - Allocate and Transfer', async ({ browser }) => {
const page = await doCachedLogin(browser);
await navigate(page, 'stock/transfer-order/6/');
// Duplicate this transfer order, to ensure a fresh run each time
await page.getByLabel('action-menu-order-actions').click();
await page.getByLabel('action-menu-order-actions-duplicate').click();
// Submit the duplicate request and ensure it completes
await page.getByRole('button', { name: 'Submit' }).isEnabled();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Item Created').waitFor();
// Issue the order
await page.getByRole('button', { name: 'Issue Order' }).click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Issued', { exact: true }).first().waitFor();
await loadTab(page, 'Line Items');
// Allocate line item 1
const cell1 = await page.getByText('C_100pF_0402', { exact: true });
await clickOnRowMenu(cell1);
await page.getByRole('menuitem', { name: 'Allocate Stock' }).click();
await page.getByText('C_100pF_0402Location:Offsite').waitFor();
await page.waitForTimeout(200);
await page.getByRole('button', { name: 'Submit' }).click();
// Allocate line item 1
const cell2 = await page.getByText('R_2.2K_0603_1%', { exact: true });
await clickOnRowMenu(cell2);
await page.getByRole('menuitem', { name: 'Allocate Stock' }).click();
await page.getByText('R_2.2K_0603_1%Location:').waitFor();
await page.waitForTimeout(200);
await page.getByRole('button', { name: 'Submit' }).click();
// Complete the order
await page.getByRole('button', { name: 'Complete Order' }).click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Complete', { exact: true }).first().waitFor();
// Tab should have changed to Transferred Stock
await loadTab(page, 'Transferred Stock');
await page.getByText('C_100pF_0402').waitFor();
await page.getByText('2.2K resistor in 0603 SMD').waitFor();
});
test('Transfer Orders - Duplicate', async ({ browser }) => {
const page = await doCachedLogin(browser, {
url: 'stock/transfer-order/1/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', { exact: true })
).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();
// Set custom status for one item
await page
.getByRole('row', { name: 'Thumbnail XT90-M 10 60 action' })
.getByLabel('action-button-change-status')
.click();
await page
.getByRole('combobox', { name: 'choice-field-status' })
.fill('damaged');
await page.getByRole('option', { name: 'Damaged' }).click();
// Specify different location for one item
await page
.getByRole('row', { name: 'Thumbnail XT90-F 10 60' })
.getByLabel('action-button-set-location')
.click();
await page
.getByRole('cell', { name: 'Location Custom location for' })
.getByPlaceholder('Select location')
.fill('factory');
await page.getByRole('listbox').getByText('Factory', { exact: true }).click();
await page.getByRole('button', { name: 'Submit' }).click();
// Quantity of this assembly has been reduced
await page.getByText('Quantity: 4').first().waitFor();
await loadTab(page, 'Child Items');
await page.getByText('1 - 2 / 2').waitFor();
await page.getByRole('cell', { name: 'Thumbnail XT90-F' }).waitFor();
await page.getByRole('cell', { name: 'Thumbnail XT90-M' }).waitFor();
await page.waitForTimeout(2000);
});
@@ -0,0 +1,215 @@
import { expect, test } from '../baseFixtures.js';
import {
activateCalendarView,
clearTableFilters,
clickOnRowMenu,
loadTab,
navigate
} from '../helpers.js';
import { doCachedLogin } from '../login.js';
test('Transfer Orders - General', async ({ browser }) => {
const page = await doCachedLogin(browser);
await page.getByRole('tab', { name: 'Stock' }).click();
await page.waitForURL('**/stock/location/index/**');
await loadTab(page, 'Transfer Orders');
await clearTableFilters(page);
// We have now loaded the "Transfer Orders" table. Check for some expected texts
await page.getByText('Complete').first().waitFor();
await page.getByText('Issued').first().waitFor();
await page.getByText('Cancelled').first().waitFor();
// Load a particular Transfer Order
await page.getByRole('cell', { name: 'TO-0002' }).click();
await page.waitForTimeout(200);
// This transfer order should be "issued"
await page.getByText('Issued').first().waitFor();
// Edit the transfer order (via keyboard shortcut)
await page.keyboard.press('Control+E');
await page.getByLabel('text-field-reference', { exact: true }).waitFor();
await page.getByLabel('related-field-project_code').waitFor();
await page.getByRole('button', { name: 'Cancel' }).click();
await page.getByRole('button', { name: 'Complete Order' }).click();
await page.getByRole('button', { name: 'Cancel' }).click();
// Check for other expected actions
await page.getByRole('button', { name: 'action-menu-order-actions' }).click();
await page.getByLabel('action-menu-order-actions-edit').waitFor();
await page.getByLabel('action-menu-order-actions-duplicate').waitFor();
await page.getByLabel('action-menu-order-actions-hold').waitFor();
// Click on some tabs
await loadTab(page, 'Line Items');
await loadTab(page, 'Allocated Stock');
await loadTab(page, 'Parameters');
await loadTab(page, 'Attachments');
await loadTab(page, 'Notes');
});
test('Transfer Order - Reference', async ({ browser }) => {
const page = await doCachedLogin(browser);
// go to transfer orders
await page.getByRole('tab', { name: 'Stock' }).click();
await page.waitForURL('**/stock/location/index/**');
await loadTab(page, 'Transfer Orders');
// click add button
await page
.getByRole('button', { name: 'action-button-add-transfer-' })
.click();
// Ensure a new reference is suggested
await page.waitForLoadState('networkidle');
await page.waitForTimeout(250);
// Grab the Transfer Order reference
const reference: string = await page
.getByRole('textbox', { name: 'text-field-reference' })
.inputValue();
expect(reference).toMatch(/TO-\d+/);
await page.getByRole('textbox', { name: 'text-field-description' }).click();
await page
.getByRole('textbox', { name: 'text-field-description' })
.fill('creating from playwrigh!');
// create the transfer order
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Item Created').waitFor();
// go back to stock page
await page.getByRole('link', { name: 'Stock', exact: true }).click();
await page
.getByRole('button', { name: 'action-button-add-transfer-' })
.click();
const nextReference: string = await page
.getByRole('textbox', { name: 'text-field-reference' })
.inputValue();
expect(nextReference).toMatch(/TO-\d+/);
// Ensure that the reference has incremented
const refNumber = Number(reference.replace('TO-', ''));
const nextRefNumber = Number(nextReference.replace('TO-', ''));
expect(nextRefNumber).toBe(refNumber + 1);
});
test('Transfer Order - Calendar', async ({ browser }) => {
const page = await doCachedLogin(browser);
await navigate(page, 'stock/location/index/transfer-orders');
await activateCalendarView(page);
// Export calendar data
await page.getByLabel('calendar-export-data').click();
await page.getByRole('button', { name: 'Export', exact: true }).click();
await page.getByText('Process completed successfully').waitFor();
// Required because we downloaded a file
await page.context().close();
});
test('Transfer Order - Edit', async ({ browser }) => {
const page = await doCachedLogin(browser);
await navigate(page, 'stock/transfer-order/2/');
// Check for expected text items
await page.getByText('Consume some paint').first().waitFor();
await page.getByText('2026-04-20').waitFor(); // Created date
await page.getByText('2026-04-23').waitFor(); // Issue date
await page.getByText('PRJ-HEL').waitFor(); // Project Code
await page.keyboard.press('Control+E');
// Edit start date
await page.getByLabel('date-field-start_date').fill('2026-04-28');
// Submit the form
await page.getByRole('button', { name: 'Submit' }).click();
// Expect error
await page.getByText('Errors exist for one or more form fields').waitFor();
await page.getByText('Target date must be after start date').waitFor();
// Cancel the form
await page.getByRole('button', { name: 'Cancel' }).click();
});
test('Transfer Order - Allocate and Transfer', async ({ browser }) => {
const page = await doCachedLogin(browser);
await navigate(page, 'stock/transfer-order/6/');
// Duplicate this transfer order, to ensure a fresh run each time
await page.getByLabel('action-menu-order-actions').click();
await page.getByLabel('action-menu-order-actions-duplicate').click();
// Submit the duplicate request and ensure it completes
await page.getByRole('button', { name: 'Submit' }).isEnabled();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Item Created').waitFor();
// Issue the order
await page.getByRole('button', { name: 'Issue Order' }).click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Issued', { exact: true }).first().waitFor();
await loadTab(page, 'Line Items');
// Allocate line item 1
const cell1 = await page.getByText('C_100pF_0402', { exact: true });
await clickOnRowMenu(cell1);
await page.getByRole('menuitem', { name: 'Allocate Stock' }).click();
await page.getByText('C_100pF_0402Location:Offsite').waitFor();
await page.waitForTimeout(200);
await page.getByRole('button', { name: 'Submit' }).click();
// Allocate line item 1
const cell2 = await page.getByText('R_2.2K_0603_1%', { exact: true });
await clickOnRowMenu(cell2);
await page.getByRole('menuitem', { name: 'Allocate Stock' }).click();
await page.getByText('R_2.2K_0603_1%Location:').waitFor();
await page.waitForTimeout(200);
await page.getByRole('button', { name: 'Submit' }).click();
// Complete the order
await page.getByRole('button', { name: 'Complete Order' }).click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Complete', { exact: true }).first().waitFor();
// Tab should have changed to Transferred Stock
await loadTab(page, 'Transferred Stock');
await page.getByText('C_100pF_0402').waitFor();
await page.getByText('2.2K resistor in 0603 SMD').waitFor();
});
test('Transfer Orders - Duplicate', async ({ browser }) => {
const page = await doCachedLogin(browser, {
url: 'stock/transfer-order/1/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', { exact: true })
).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();
});
+2 -2
View File
@@ -233,10 +233,10 @@ test('Forms - DateTime Field', async ({ browser }) => {
hour: string,
minute: string
) => {
const field = page.getByLabel(fieldLabel);
const field = page.getByLabel(fieldLabel).first();
await expect(field).toBeVisible();
await field.click();
await page.getByRole('button', { name: day }).click();
await page.getByRole('button', { name: day }).first().click();
const spinbuttons = page.getByRole('spinbutton');
await spinbuttons.nth(0).fill(hour);