mirror of
https://github.com/inventree/InvenTree.git
synced 2026-11-14 12:32:44 +00:00
[UI] Show consuming build orders in child stock items (#13208)
* Show consuming build orders in child stock items * Reference PR in changelog and API version notes * Clarify stock reference test setup and assertions * Use optional build details for consumed stock * Include consuming build references in stock exports * Make stock prefetch test portable to MySQL
This commit is contained in:
1 parent
5e7bf4f92d
commit
6c363d4a32
9 files changed
+444
-5
No files matched your search
@@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- [#13208](https://github.com/inventree/InvenTree/pull/13208) Shows the consuming build order in the stock item Child Items table, which includes consumed children by default for users without saved filters.
|
||||
|
||||
- [#13166](https://github.com/inventree/InvenTree/pull/13166) Adds an "exclude_from_stock" attribute to the StockLocation model, allowing stock items in specified locations to be excluded from stock availability calculations.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 574
|
||||
INVENTREE_API_VERSION = 575
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v575 -> 2026-11-10 : https://github.com/inventree/InvenTree/pull/13208
|
||||
- Adds optional read-only "consumed_by_detail" to the StockItem API
|
||||
|
||||
v574 -> 2026-11-07 : https://github.com/inventree/InvenTree/pull/13169
|
||||
- breaking: Basic authentication can now be disabled - controlled with INVENTREE_BASIC_AUTH_ENABLED
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Build summaries for related objects without importing stock serializers."""
|
||||
|
||||
from generic.states.fields import InvenTreeCustomStatusSerializerMixin
|
||||
from InvenTree.serializers import InvenTreeModelSerializer
|
||||
|
||||
from .models import Build
|
||||
|
||||
|
||||
class BuildBriefSerializer(
|
||||
InvenTreeCustomStatusSerializerMixin, InvenTreeModelSerializer
|
||||
):
|
||||
"""Serialize build identity and status for related-object detail fields."""
|
||||
|
||||
class Meta:
|
||||
"""Serializer options."""
|
||||
|
||||
model = Build
|
||||
fields = ['pk', 'reference', 'title', 'status', 'status_custom_key']
|
||||
read_only_fields = fields
|
||||
@@ -1113,6 +1113,7 @@ class StockOutputOptions(OutputConfiguration):
|
||||
InvenTreeOutputOption('supplier_part_detail'),
|
||||
InvenTreeOutputOption('location_detail'),
|
||||
InvenTreeOutputOption('tests'),
|
||||
InvenTreeOutputOption('consumed_by_detail'),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import part.serializers as part_serializers
|
||||
import stock.filters
|
||||
import stock.status_codes
|
||||
from audit.helpers import audit_event, write_batched
|
||||
from build.serializers_brief import BuildBriefSerializer
|
||||
from common.settings import get_global_setting
|
||||
from generic.states.fields import InvenTreeCustomStatusSerializerMixin
|
||||
from importer.registry import register_importer
|
||||
@@ -325,6 +326,7 @@ class StockItemSerializer(
|
||||
SKIP_CREATE_FIELDS = ['duplicate']
|
||||
|
||||
export_child_fields = [
|
||||
'consumed_by_detail.reference',
|
||||
'part_detail.name',
|
||||
'part_detail.description',
|
||||
'part_detail.IPN',
|
||||
@@ -353,6 +355,7 @@ class StockItemSerializer(
|
||||
'belongs_to',
|
||||
'build',
|
||||
'consumed_by',
|
||||
'consumed_by_detail',
|
||||
'customer',
|
||||
'delete_on_deplete',
|
||||
'expiry_date',
|
||||
@@ -686,6 +689,18 @@ class StockItemSerializer(
|
||||
help_text=_('Purchase currency of this stock item')
|
||||
)
|
||||
|
||||
consumed_by_detail = OptionalField(
|
||||
serializer_class=BuildBriefSerializer,
|
||||
serializer_kwargs={
|
||||
'label': _('Consumed By'),
|
||||
'source': 'consumed_by',
|
||||
'read_only': True,
|
||||
'allow_null': True,
|
||||
},
|
||||
default_include=False,
|
||||
prefetch_fields=['consumed_by'],
|
||||
)
|
||||
|
||||
purchase_order_reference = serializers.CharField(
|
||||
source='purchase_order.reference', read_only=True, allow_null=True
|
||||
)
|
||||
|
||||
@@ -1260,15 +1260,147 @@ class StockItemListTest(StockAPITestCase):
|
||||
self.assertIn(stock_1.pk, ids)
|
||||
self.assertIn(stock_2.pk, ids)
|
||||
|
||||
def test_consumed_by_detail(self):
|
||||
"""Consuming build details are optional, nullable, and read-only."""
|
||||
self.assignRole('build.view')
|
||||
assembly = Part.objects.create(name='Reference assembly', assembly=True)
|
||||
part = Part.objects.first()
|
||||
parent = StockItem.objects.create(part=part, quantity=10)
|
||||
builds = [
|
||||
build.models.Build.objects.create(
|
||||
part=assembly, quantity=1, title=f'Consuming build {index}'
|
||||
)
|
||||
for index in range(2)
|
||||
]
|
||||
consuming_builds = [*builds, None]
|
||||
children = [
|
||||
StockItem.objects.create(
|
||||
part=part, quantity=1, parent=parent, consumed_by=build_order
|
||||
)
|
||||
for build_order in consuming_builds
|
||||
]
|
||||
params = {'parent': parent.pk}
|
||||
for options in [params, {**params, 'consumed_by_detail': False}]:
|
||||
for record in self.get(self.list_url, options).data:
|
||||
self.assertNotIn('consumed_by_detail', record)
|
||||
self.assertNotIn('consumed_by_reference', record)
|
||||
|
||||
response = self.get(self.list_url, {**params, 'consumed_by_detail': True})
|
||||
records = {item['pk']: item for item in response.data}
|
||||
self.assertEqual(set(records), {item.pk for item in children})
|
||||
for item, build_order in zip(children, consuming_builds, strict=True):
|
||||
detail = records[item.pk]['consumed_by_detail']
|
||||
self.assertEqual(
|
||||
records[item.pk]['consumed_by'], build_order.pk if build_order else None
|
||||
)
|
||||
if build_order:
|
||||
self.assertEqual(detail['pk'], build_order.pk)
|
||||
self.assertEqual(detail['reference'], build_order.reference)
|
||||
self.assertEqual(detail['title'], build_order.title)
|
||||
self.assertEqual(detail['status'], build_order.status)
|
||||
else:
|
||||
self.assertIsNone(detail)
|
||||
|
||||
detail_url = reverse('api-stock-detail', kwargs={'pk': children[0].pk})
|
||||
self.assertNotIn('consumed_by_detail', self.get(detail_url).data)
|
||||
detail = self.get(detail_url, {'consumed_by_detail': True}).data
|
||||
self.assertEqual(detail['consumed_by_detail']['reference'], builds[0].reference)
|
||||
|
||||
original_reference = builds[0].reference
|
||||
self.patch(
|
||||
detail_url, {'consumed_by_detail': {'reference': 'Cannot overwrite'}}
|
||||
)
|
||||
builds[0].refresh_from_db()
|
||||
self.assertEqual(builds[0].reference, original_reference)
|
||||
self.assertEqual(
|
||||
StockItem.objects.get(pk=children[0].pk).consumed_by_id, builds[0].pk
|
||||
)
|
||||
|
||||
# Requesting the field must not bypass build view permissions.
|
||||
self.clearRoles()
|
||||
self.assignRole('stock.view')
|
||||
response = self.get(self.list_url, {**params, 'consumed_by_detail': True})
|
||||
for record in response.data:
|
||||
self.assertNotIn('consumed_by_detail', record)
|
||||
self.assertNotIn(
|
||||
'consumed_by_detail',
|
||||
self.get(detail_url, {'consumed_by_detail': True}).data,
|
||||
)
|
||||
|
||||
def test_consumed_by_detail_export(self):
|
||||
"""Export consuming references only when requested and permitted."""
|
||||
self.assignRole('build.view')
|
||||
assembly = Part.objects.create(name='Export assembly', assembly=True)
|
||||
build_order = build.models.Build.objects.create(part=assembly, quantity=1)
|
||||
part = Part.objects.first()
|
||||
parent = StockItem.objects.create(part=part, quantity=10)
|
||||
consumed = StockItem.objects.create(
|
||||
part=part, quantity=1, parent=parent, consumed_by=build_order
|
||||
)
|
||||
unconsumed = StockItem.objects.create(part=part, quantity=1, parent=parent)
|
||||
filters = {'parent': parent.pk}
|
||||
params = {**filters, 'consumed_by_detail': True}
|
||||
header = 'Consumed By.Reference'
|
||||
|
||||
with self.export_data(self.list_url, params=params) as data_file:
|
||||
rows = self.process_csv(data_file, required_cols=[header], required_rows=2)
|
||||
records = {int(row['ID']): row for row in rows}
|
||||
self.assertEqual(records[consumed.pk][header], build_order.reference)
|
||||
self.assertEqual(records[unconsumed.pk][header], '')
|
||||
|
||||
for options in [filters, {**filters, 'consumed_by_detail': False}]:
|
||||
with self.export_data(self.list_url, params=options) as data_file:
|
||||
self.process_csv(data_file, excluded_cols=[header], required_rows=2)
|
||||
|
||||
self.clearRoles()
|
||||
self.assignRole('stock.view')
|
||||
with self.export_data(self.list_url, params=params) as data_file:
|
||||
self.process_csv(data_file, excluded_cols=[header], required_rows=2)
|
||||
|
||||
def test_consumed_by_detail_prefetch(self):
|
||||
"""Optional details prefetch builds even without StockApiMixin annotations."""
|
||||
from stock.serializers import StockItemSerializer
|
||||
|
||||
assembly = Part.objects.create(name='Prefetch assembly', assembly=True)
|
||||
builds = [
|
||||
build.models.Build.objects.create(part=assembly, quantity=1)
|
||||
for _ in range(10)
|
||||
]
|
||||
StockItem.objects.bulk_create([
|
||||
StockItem(part=assembly, quantity=1, consumed_by=build_order)
|
||||
for build_order in builds
|
||||
])
|
||||
queryset = StockItem.objects.filter(consumed_by__in=builds)
|
||||
serializer = StockItemSerializer(consumed_by_detail=True)
|
||||
self.assertIn('consumed_by', serializer.prefetch_list)
|
||||
self.assertNotIn('consumed_by', StockItemSerializer().prefetch_list)
|
||||
with self.assertNumQueries(2):
|
||||
prefetched = list(serializer.prefetch_queryset(queryset))
|
||||
references = [item.consumed_by.reference for item in prefetched]
|
||||
self.assertCountEqual(
|
||||
references, [build_order.reference for build_order in builds]
|
||||
)
|
||||
|
||||
def test_query_count(self):
|
||||
"""Test that the number of queries required to fetch stock items is reasonable."""
|
||||
# Create a bunch of StockItem objects
|
||||
prt = Part.objects.first()
|
||||
|
||||
assembly = Part.objects.create(name='Query assembly', assembly=True)
|
||||
builds = [
|
||||
build.models.Build.objects.create(part=assembly, quantity=1)
|
||||
for _ in range(10)
|
||||
]
|
||||
# Include consuming builds so missing prefetches exceed the query budget.
|
||||
StockItem.objects.bulk_create([
|
||||
StockItem(part=prt, quantity=1) for _ in range(100)
|
||||
StockItem(part=prt, quantity=1, consumed_by=bo)
|
||||
for bo in builds
|
||||
for _ in range(10)
|
||||
])
|
||||
|
||||
self.assignRole('build.view')
|
||||
self.get(self.list_url, {'consumed_by_detail': True}, max_query_count=35)
|
||||
|
||||
# List *all* stock items
|
||||
self.get(self.list_url, {}, max_query_count=35)
|
||||
|
||||
@@ -2085,6 +2217,7 @@ class StockItemTest(StockAPITestCase):
|
||||
|
||||
def test_output_options(self):
|
||||
"""Test the output options for StockItemt detail."""
|
||||
self.assignRole('build.view')
|
||||
self.run_output_test(
|
||||
reverse('api-stock-detail', kwargs={'pk': 1}),
|
||||
[
|
||||
@@ -2093,6 +2226,7 @@ class StockItemTest(StockAPITestCase):
|
||||
'supplier_part_detail',
|
||||
'location_detail',
|
||||
'tests',
|
||||
'consumed_by_detail',
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -298,6 +298,8 @@ export default function StockDetail() {
|
||||
content: stockitem?.pk ? (
|
||||
<StockItemTable
|
||||
tableName='child-stock'
|
||||
showConsumedBy
|
||||
defaultInStock={null}
|
||||
params={{ parent: stockitem.pk }}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { type NavigateFunction, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { ActionButton } from '@lib/components/ActionButton';
|
||||
import { AddItemButton } from '@lib/components/AddItemButton';
|
||||
@@ -7,10 +8,12 @@ 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 useTable from '@lib/hooks/UseTable';
|
||||
import type { TableFilter } from '@lib/types/Filters';
|
||||
import type { StockOperationProps } from '@lib/types/Forms';
|
||||
import type { TableColumn } from '@lib/types/Tables';
|
||||
import { RenderInlineModel } from '../../components/render/Instance';
|
||||
import {
|
||||
DateColumn,
|
||||
DescriptionColumn,
|
||||
@@ -53,10 +56,14 @@ import { useUserState } from '../../states/UserState';
|
||||
*/
|
||||
function stockItemTableColumns({
|
||||
showLocation,
|
||||
showPricing
|
||||
showPricing,
|
||||
showConsumedBy,
|
||||
navigate
|
||||
}: {
|
||||
showLocation: boolean;
|
||||
showPricing: boolean;
|
||||
showConsumedBy: boolean;
|
||||
navigate: NavigateFunction;
|
||||
}): TableColumn[] {
|
||||
return [
|
||||
PartColumn({
|
||||
@@ -89,6 +96,20 @@ function stockItemTableColumns({
|
||||
]
|
||||
}),
|
||||
StatusColumn({ model: ModelType.stockitem }),
|
||||
{
|
||||
accessor: 'consumed_by_detail.reference',
|
||||
title: t`Consumed By`,
|
||||
sortable: false,
|
||||
hidden: !showConsumedBy,
|
||||
render: (record: any) =>
|
||||
record.consumed_by_detail ? (
|
||||
<RenderInlineModel
|
||||
primary={record.consumed_by_detail.reference}
|
||||
url={getDetailUrl(ModelType.build, record.consumed_by_detail.pk)}
|
||||
navigate={navigate}
|
||||
/>
|
||||
) : null
|
||||
},
|
||||
{
|
||||
accessor: 'batch',
|
||||
sortable: true,
|
||||
@@ -337,6 +358,7 @@ export function StockItemTable({
|
||||
allowAdd = false,
|
||||
showLocation = true,
|
||||
showPricing = true,
|
||||
showConsumedBy = false,
|
||||
allowReturn = false,
|
||||
initialFilters,
|
||||
defaultInStock = true,
|
||||
@@ -346,6 +368,7 @@ export function StockItemTable({
|
||||
allowAdd?: boolean;
|
||||
showLocation?: boolean;
|
||||
showPricing?: boolean;
|
||||
showConsumedBy?: boolean;
|
||||
allowReturn?: boolean;
|
||||
defaultInStock?: boolean | null;
|
||||
initialFilters?: TableFilter[];
|
||||
@@ -376,6 +399,7 @@ export function StockItemTable({
|
||||
});
|
||||
|
||||
const user = useUserState();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const settings = useGlobalSettingsState();
|
||||
|
||||
@@ -388,9 +412,11 @@ export function StockItemTable({
|
||||
() =>
|
||||
stockItemTableColumns({
|
||||
showLocation: showLocation ?? true,
|
||||
showPricing: showPricing ?? true
|
||||
showPricing: showPricing ?? true,
|
||||
showConsumedBy,
|
||||
navigate
|
||||
}),
|
||||
[showLocation, showPricing]
|
||||
[showLocation, showPricing, showConsumedBy, navigate]
|
||||
);
|
||||
|
||||
const tableFilters: TableFilter[] = useMemo(
|
||||
@@ -500,6 +526,7 @@ export function StockItemTable({
|
||||
modelType: ModelType.stockitem,
|
||||
params: {
|
||||
...params,
|
||||
...(showConsumedBy ? { consumed_by_detail: true } : {}),
|
||||
part_detail: true,
|
||||
location_detail: true,
|
||||
supplier_part_detail: true
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import type { APIResponse } from '@playwright/test';
|
||||
|
||||
import { createApi } from '../api.js';
|
||||
import { expect, generateUUID, test } from '../baseFixtures.js';
|
||||
import { clearTableFilters, loadTab, navigate } from '../helpers.js';
|
||||
import { doCachedLogin } from '../login.js';
|
||||
|
||||
for (const savedFilter of [false, true]) {
|
||||
test(`Stock - Consuming builds${savedFilter ? ' with saved filter' : ''}`, async ({
|
||||
browser
|
||||
}) => {
|
||||
const api = await createApi({});
|
||||
const stockIds: number[] = [];
|
||||
const buildIds: number[] = [];
|
||||
let locationId: number | undefined;
|
||||
let partId: number | undefined;
|
||||
|
||||
const create = async (url: string, data: object) => {
|
||||
const response = await api.post(url, { data });
|
||||
expect(response.status(), await response.text()).toBe(201);
|
||||
const body = await response.json();
|
||||
return Array.isArray(body) ? body[0] : body;
|
||||
};
|
||||
|
||||
try {
|
||||
const part = await create('part/', {
|
||||
name: `Consumed reference ${generateUUID().slice(0, 8)}`,
|
||||
assembly: true,
|
||||
component: true
|
||||
});
|
||||
partId = part.pk;
|
||||
const parent = await create('stock/', { part: partId, quantity: 10 });
|
||||
stockIds.push(parent.pk);
|
||||
|
||||
const builds = [];
|
||||
for (let index = 0; index < 2; index++) {
|
||||
const build = await create('build/', {
|
||||
part: partId,
|
||||
quantity: 1,
|
||||
title: `Reference test ${index}`
|
||||
});
|
||||
builds.push(build);
|
||||
buildIds.push(build.pk);
|
||||
}
|
||||
|
||||
const location = await create('stock/location/', {
|
||||
name: `Reference location ${generateUUID().slice(0, 8)}`
|
||||
});
|
||||
locationId = location.pk;
|
||||
|
||||
// Partial transfers create children; the parent field is read-only.
|
||||
const children = [];
|
||||
for (const build of [...builds, null]) {
|
||||
const response = await api.post('stock/transfer/', {
|
||||
data: {
|
||||
location: locationId,
|
||||
items: [{ pk: parent.pk, quantity: 1 }]
|
||||
}
|
||||
});
|
||||
expect(response.ok(), await response.text()).toBeTruthy();
|
||||
const records = await (
|
||||
await api.get('stock/', { params: { parent: parent.pk } })
|
||||
).json();
|
||||
const child = records.find(
|
||||
(record: { pk: number }) => !stockIds.includes(record.pk)
|
||||
);
|
||||
expect(child).toBeTruthy();
|
||||
stockIds.push(child.pk);
|
||||
const patch = await api.patch(`stock/${child.pk}/`, {
|
||||
data: {
|
||||
batch: `Reference-${generateUUID().slice(0, 8)}`,
|
||||
consumed_by: build?.pk ?? null
|
||||
}
|
||||
});
|
||||
expect(patch.ok(), await patch.text()).toBeTruthy();
|
||||
children.push(await patch.json());
|
||||
}
|
||||
|
||||
const unconsumedChild = children[builds.length];
|
||||
const page = await doCachedLogin(browser);
|
||||
const childRequests: URL[] = [];
|
||||
page.on('request', (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (
|
||||
request.method() === 'GET' &&
|
||||
url.pathname === '/api/stock/' &&
|
||||
url.searchParams.get('parent') === String(parent.pk)
|
||||
) {
|
||||
childRequests.push(url);
|
||||
}
|
||||
});
|
||||
|
||||
// Cover fresh table preferences and a previously saved in-stock filter.
|
||||
await page.evaluate((saved) => {
|
||||
localStorage.removeItem('inventree-filterset-table-child-stock');
|
||||
if (saved)
|
||||
localStorage.setItem(
|
||||
'inventree-filterset-table-child-stock',
|
||||
JSON.stringify([{ name: 'in_stock', value: 'true' }])
|
||||
);
|
||||
}, savedFilter);
|
||||
await navigate(page, `stock/item/${parent.pk}/child-items`);
|
||||
await loadTab(page, 'Child Items');
|
||||
const header = page.getByRole('columnheader', {
|
||||
name: 'Consumed By',
|
||||
exact: true
|
||||
});
|
||||
await expect(header).toBeVisible();
|
||||
|
||||
if (savedFilter) {
|
||||
await expect(
|
||||
page.getByRole('cell', { name: unconsumedChild.batch, exact: true })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('link', { name: builds[0].reference, exact: true })
|
||||
).toHaveCount(0);
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByRole('cell', { name: unconsumedChild.batch, exact: true })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('link', { name: builds[0].reference, exact: true })
|
||||
).toHaveCount(0);
|
||||
await clearTableFilters(page);
|
||||
}
|
||||
|
||||
for (const [index, build] of builds.entries()) {
|
||||
const row = page.getByRole('row').filter({
|
||||
has: page.getByRole('cell', {
|
||||
name: children[index].batch,
|
||||
exact: true
|
||||
})
|
||||
});
|
||||
await expect(row).toBeVisible();
|
||||
await expect(
|
||||
row.getByRole('link', { name: build.reference, exact: true })
|
||||
).toHaveAttribute(
|
||||
'href',
|
||||
`/web/manufacturing/build-order/${build.pk}/`
|
||||
);
|
||||
}
|
||||
|
||||
expect(childRequests.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
childRequests.every(
|
||||
(url) => url.searchParams.get('consumed_by_detail') === 'true'
|
||||
)
|
||||
).toBeTruthy();
|
||||
|
||||
const unconsumed = page.getByRole('row').filter({
|
||||
has: page.getByRole('cell', {
|
||||
name: unconsumedChild.batch,
|
||||
exact: true
|
||||
})
|
||||
});
|
||||
await expect(unconsumed).toBeVisible();
|
||||
const columnIndex = await header.evaluate((element) =>
|
||||
Array.from(element.parentElement!.children).indexOf(element)
|
||||
);
|
||||
await expect(unconsumed.getByRole('cell').nth(columnIndex)).toBeEmpty();
|
||||
|
||||
if (!savedFilter) {
|
||||
await page.getByLabel('table-select-columns').click();
|
||||
await page
|
||||
.getByRole('checkbox', { name: 'Consumed By', exact: true })
|
||||
.uncheck();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.reload();
|
||||
await expect(header).toHaveCount(0);
|
||||
await page.getByLabel('table-select-columns').click();
|
||||
await page
|
||||
.getByRole('checkbox', { name: 'Consumed By', exact: true })
|
||||
.check();
|
||||
await page.keyboard.press('Escape');
|
||||
await page
|
||||
.getByRole('link', { name: builds[0].reference, exact: true })
|
||||
.click();
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/manufacturing/build-order/${builds[0].pk}`)
|
||||
);
|
||||
await navigate(page, `part/${partId}/stock`);
|
||||
await expect(header).toHaveCount(0);
|
||||
await page.getByLabel('table-select-columns').click();
|
||||
await expect(
|
||||
page.getByRole('checkbox', { name: 'Consumed By', exact: true })
|
||||
).toHaveCount(0);
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
const cleanupFailures: string[] = [];
|
||||
const cleanup = async (response: APIResponse) => {
|
||||
if (!response.ok()) cleanupFailures.push(await response.text());
|
||||
};
|
||||
|
||||
for (const pk of stockIds.reverse())
|
||||
await cleanup(await api.delete(`stock/${pk}/`));
|
||||
for (const pk of buildIds) {
|
||||
await cleanup(
|
||||
await api.post(`build/${pk}/cancel/`, {
|
||||
data: {
|
||||
remove_allocated_stock: false,
|
||||
remove_incomplete_outputs: true
|
||||
}
|
||||
})
|
||||
);
|
||||
// Cancellation runs in the worker; wait for its committed state.
|
||||
await expect
|
||||
.poll(
|
||||
async () => (await (await api.get(`build/${pk}/`)).json()).status,
|
||||
{ timeout: 15000 }
|
||||
)
|
||||
.toBe(30);
|
||||
await cleanup(await api.delete(`build/${pk}/`));
|
||||
}
|
||||
|
||||
if (partId) {
|
||||
await cleanup(
|
||||
await api.patch(`part/${partId}/`, { data: { active: false } })
|
||||
);
|
||||
await cleanup(await api.delete(`part/${partId}/`));
|
||||
}
|
||||
|
||||
if (locationId)
|
||||
await cleanup(
|
||||
await api.delete(`stock/location/${locationId}/`, {
|
||||
data: { delete_stock_items: false, delete_sub_locations: false }
|
||||
})
|
||||
);
|
||||
|
||||
expect(cleanupFailures).toEqual([]);
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user