mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 11:36:44 +00:00
Build start date (#8915)
* Add 'start_date' to Build model * Add to serializer * Add filtering and ordering * Update BuildOrderTable - Add new column - Add new filtering options * Add sanity check for start_date * Add 'start_date' field to BuildOrder form * Update docs * Bump API version * Tweak unit testing * Display 'start_date' on build page * Refactor UI tests * Fix for 'date' field in forms * Add additional unit tests * Fix helper func * Remove debug msg
This commit is contained in:
parent
87ccf52562
commit
7ad49949c8
15
docs/docs/build/build.md
vendored
15
docs/docs/build/build.md
vendored
@ -49,6 +49,7 @@ The following parameters are available for each Build Order, and can be edited b
|
|||||||
| Sales Order | Link to a *Sales Order* to which the build outputs will be allocated |
|
| Sales Order | Link to a *Sales Order* to which the build outputs will be allocated |
|
||||||
| Source Location | Stock location to source stock items from (blank = all locations) |
|
| Source Location | Stock location to source stock items from (blank = all locations) |
|
||||||
| Destination Location | Stock location where the build outputs will be located |
|
| Destination Location | Stock location where the build outputs will be located |
|
||||||
|
| Start Date | The scheduled start date for the build |
|
||||||
| Target Date | Target date for build completion |
|
| Target Date | Target date for build completion |
|
||||||
| Responsible | User (or group of users) who is responsible for the build |
|
| Responsible | User (or group of users) who is responsible for the build |
|
||||||
| External Link | Link to external webpage |
|
| External Link | Link to external webpage |
|
||||||
@ -262,12 +263,18 @@ The `Cancel Build` form will be displayed, click on the confirmation switch then
|
|||||||
!!! warning "Cancelled Build"
|
!!! warning "Cancelled Build"
|
||||||
**A cancelled build cannot be re-opened**. Make sure to use the cancel option only if you are certain that the build won't be processed.
|
**A cancelled build cannot be re-opened**. Make sure to use the cancel option only if you are certain that the build won't be processed.
|
||||||
|
|
||||||
## Overdue Builds
|
## Build Scheduling
|
||||||
|
|
||||||
Build orders may (optionally) have a target complete date specified. If this date is reached but the build order remains incomplete, then the build is considered *overdue*.
|
### Start Date
|
||||||
|
|
||||||
|
Build orders can be optionally scheduled to *start* at a specified date. This may be useful for planning production schedules.
|
||||||
|
|
||||||
|
### Overdue Builds
|
||||||
|
|
||||||
|
Build orders may (optionally) have a target completion date specified. If this date is reached but the build order remains incomplete, then the build is considered *overdue*.
|
||||||
|
|
||||||
|
This can be useful for tracking production delays, and can be used to generate reports on build order performance.
|
||||||
|
|
||||||
- Builds can be filtered by overdue status in the build list
|
|
||||||
- Overdue builds will be displayed on the home page
|
|
||||||
|
|
||||||
## Build Order Settings
|
## Build Order Settings
|
||||||
|
|
||||||
|
@ -1,13 +1,17 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 302
|
INVENTREE_API_VERSION = 303
|
||||||
|
|
||||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
INVENTREE_API_TEXT = """
|
||||||
|
|
||||||
|
v303 - 2025-01-20 - https://github.com/inventree/InvenTree/pull/8915
|
||||||
|
- Adds "start_date" field to Build model and API endpoints
|
||||||
|
- Adds additional API filtering and sorting options for Build list
|
||||||
|
|
||||||
v302 - 2025-01-18 - https://github.com/inventree/InvenTree/pull/8905
|
v302 - 2025-01-18 - https://github.com/inventree/InvenTree/pull/8905
|
||||||
- Fix schema definition on the /label/print endpoint
|
- Fix schema definition on the /label/print endpoint
|
||||||
|
|
||||||
|
@ -188,6 +188,30 @@ class BuildFilter(rest_filters.FilterSet):
|
|||||||
label=_('Created after'), field_name='creation_date', lookup_expr='gt'
|
label=_('Created after'), field_name='creation_date', lookup_expr='gt'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
has_start_date = rest_filters.BooleanFilter(
|
||||||
|
label=_('Has start date'), method='filter_has_start_date'
|
||||||
|
)
|
||||||
|
|
||||||
|
def filter_has_start_date(self, queryset, name, value):
|
||||||
|
"""Filter by whether or not the order has a start date."""
|
||||||
|
return queryset.filter(start_date__isnull=not str2bool(value))
|
||||||
|
|
||||||
|
start_date_before = InvenTreeDateFilter(
|
||||||
|
label=_('Start date before'), field_name='start_date', lookup_expr='lt'
|
||||||
|
)
|
||||||
|
|
||||||
|
start_date_after = InvenTreeDateFilter(
|
||||||
|
label=_('Start date after'), field_name='start_date', lookup_expr='gt'
|
||||||
|
)
|
||||||
|
|
||||||
|
has_target_date = rest_filters.BooleanFilter(
|
||||||
|
label=_('Has target date'), method='filter_has_target_date'
|
||||||
|
)
|
||||||
|
|
||||||
|
def filter_has_target_date(self, queryset, name, value):
|
||||||
|
"""Filter by whether or not the order has a target date."""
|
||||||
|
return queryset.filter(target_date__isnull=not str2bool(value))
|
||||||
|
|
||||||
target_date_before = InvenTreeDateFilter(
|
target_date_before = InvenTreeDateFilter(
|
||||||
label=_('Target date before'), field_name='target_date', lookup_expr='lt'
|
label=_('Target date before'), field_name='target_date', lookup_expr='lt'
|
||||||
)
|
)
|
||||||
@ -244,6 +268,7 @@ class BuildList(DataExportViewMixin, BuildMixin, ListCreateAPI):
|
|||||||
'part__name',
|
'part__name',
|
||||||
'status',
|
'status',
|
||||||
'creation_date',
|
'creation_date',
|
||||||
|
'start_date',
|
||||||
'target_date',
|
'target_date',
|
||||||
'completion_date',
|
'completion_date',
|
||||||
'quantity',
|
'quantity',
|
||||||
|
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.18 on 2025-01-20 02:00
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('build', '0053_alter_build_part'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='build',
|
||||||
|
name='start_date',
|
||||||
|
field=models.DateField(blank=True, help_text='Scheduled start date for this build order', null=True, verbose_name='Build start date'),
|
||||||
|
),
|
||||||
|
]
|
@ -178,6 +178,12 @@ class Build(
|
|||||||
if self.has_field_changed('part'):
|
if self.has_field_changed('part'):
|
||||||
raise ValidationError({'part': _('Build order part cannot be changed')})
|
raise ValidationError({'part': _('Build order part cannot be changed')})
|
||||||
|
|
||||||
|
# Target date should be *after* the start date
|
||||||
|
if self.start_date and self.target_date and self.start_date > self.target_date:
|
||||||
|
raise ValidationError({
|
||||||
|
'target_date': _('Target date must be after start date')
|
||||||
|
})
|
||||||
|
|
||||||
def report_context(self) -> dict:
|
def report_context(self) -> dict:
|
||||||
"""Generate custom report context data."""
|
"""Generate custom report context data."""
|
||||||
return {
|
return {
|
||||||
@ -344,6 +350,13 @@ class Build(
|
|||||||
auto_now_add=True, editable=False, verbose_name=_('Creation Date')
|
auto_now_add=True, editable=False, verbose_name=_('Creation Date')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
start_date = models.DateField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_('Build start date'),
|
||||||
|
help_text=_('Scheduled start date for this build order'),
|
||||||
|
)
|
||||||
|
|
||||||
target_date = models.DateField(
|
target_date = models.DateField(
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
|
@ -81,6 +81,7 @@ class BuildSerializer(
|
|||||||
'reference',
|
'reference',
|
||||||
'sales_order',
|
'sales_order',
|
||||||
'quantity',
|
'quantity',
|
||||||
|
'start_date',
|
||||||
'status',
|
'status',
|
||||||
'status_text',
|
'status_text',
|
||||||
'status_custom_key',
|
'status_custom_key',
|
||||||
|
@ -22,8 +22,12 @@ export default function DateField({
|
|||||||
fieldState: { error }
|
fieldState: { error }
|
||||||
} = controller;
|
} = controller;
|
||||||
|
|
||||||
const valueFormat =
|
const valueFormat = useMemo(() => {
|
||||||
definition.field_type == 'date' ? 'YYYY-MM-DD' : 'YYYY-MM-DD HH:mm:ss';
|
// Determine the format based on the field type
|
||||||
|
return definition.field_type == 'date'
|
||||||
|
? 'YYYY-MM-DD'
|
||||||
|
: 'YYYY-MM-DD HH:mm:ss';
|
||||||
|
}, [definition.field_type]);
|
||||||
|
|
||||||
const onChange = useCallback(
|
const onChange = useCallback(
|
||||||
(value: any) => {
|
(value: any) => {
|
||||||
@ -31,12 +35,13 @@ export default function DateField({
|
|||||||
if (value) {
|
if (value) {
|
||||||
value = value.toString();
|
value = value.toString();
|
||||||
value = dayjs(value).format(valueFormat);
|
value = dayjs(value).format(valueFormat);
|
||||||
|
value = value.toString().split('T')[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
definition.onValueChange?.(value);
|
definition.onValueChange?.(value);
|
||||||
},
|
},
|
||||||
[field.onChange, definition]
|
[field.onChange, definition, valueFormat]
|
||||||
);
|
);
|
||||||
|
|
||||||
const dateValue: Date | null = useMemo(() => {
|
const dateValue: Date | null = useMemo(() => {
|
||||||
@ -62,7 +67,7 @@ export default function DateField({
|
|||||||
ref={field.ref}
|
ref={field.ref}
|
||||||
type={undefined}
|
type={undefined}
|
||||||
error={definition.error ?? error?.message}
|
error={definition.error ?? error?.message}
|
||||||
value={dateValue ?? null}
|
value={dateValue}
|
||||||
clearable={!definition.required}
|
clearable={!definition.required}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
valueFormat={valueFormat}
|
valueFormat={valueFormat}
|
||||||
|
@ -101,6 +101,9 @@ export function useBuildOrderFields({
|
|||||||
value: batchCode,
|
value: batchCode,
|
||||||
onValueChange: (value: any) => setBatchCode(value)
|
onValueChange: (value: any) => setBatchCode(value)
|
||||||
},
|
},
|
||||||
|
start_date: {
|
||||||
|
icon: <IconCalendar />
|
||||||
|
},
|
||||||
target_date: {
|
target_date: {
|
||||||
icon: <IconCalendar />
|
icon: <IconCalendar />
|
||||||
},
|
},
|
||||||
|
@ -139,12 +139,6 @@ export function constructField({
|
|||||||
};
|
};
|
||||||
|
|
||||||
switch (def.field_type) {
|
switch (def.field_type) {
|
||||||
case 'date':
|
|
||||||
// Change value to a date object if required
|
|
||||||
if (def.value) {
|
|
||||||
def.value = new Date(def.value);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'nested object':
|
case 'nested object':
|
||||||
def.children = {};
|
def.children = {};
|
||||||
for (const k of Object.keys(field.children ?? {})) {
|
for (const k of Object.keys(field.children ?? {})) {
|
||||||
|
@ -181,21 +181,28 @@ export default function BuildDetail() {
|
|||||||
hidden: !build.responsible
|
hidden: !build.responsible
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'date',
|
||||||
name: 'creation_date',
|
name: 'creation_date',
|
||||||
label: t`Created`,
|
label: t`Created`,
|
||||||
icon: 'calendar',
|
icon: 'calendar',
|
||||||
hidden: !build.creation_date
|
hidden: !build.creation_date
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'date',
|
||||||
|
name: 'start_date',
|
||||||
|
label: t`Start Date`,
|
||||||
|
icon: 'calendar',
|
||||||
|
hidden: !build.start_date
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'date',
|
||||||
name: 'target_date',
|
name: 'target_date',
|
||||||
label: t`Target Date`,
|
label: t`Target Date`,
|
||||||
icon: 'calendar',
|
icon: 'calendar',
|
||||||
hidden: !build.target_date
|
hidden: !build.target_date
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'date',
|
||||||
name: 'completion_date',
|
name: 'completion_date',
|
||||||
label: t`Completed`,
|
label: t`Completed`,
|
||||||
icon: 'calendar',
|
icon: 'calendar',
|
||||||
|
@ -105,6 +105,11 @@ export function BuildOrderTable({
|
|||||||
sortable: true
|
sortable: true
|
||||||
},
|
},
|
||||||
CreationDateColumn({}),
|
CreationDateColumn({}),
|
||||||
|
DateColumn({
|
||||||
|
accessor: 'start_date',
|
||||||
|
title: t`Start Date`,
|
||||||
|
sortable: true
|
||||||
|
}),
|
||||||
TargetDateColumn({}),
|
TargetDateColumn({}),
|
||||||
DateColumn({
|
DateColumn({
|
||||||
accessor: 'completion_date',
|
accessor: 'completion_date',
|
||||||
@ -138,6 +143,30 @@ export function BuildOrderTable({
|
|||||||
CreatedAfterFilter(),
|
CreatedAfterFilter(),
|
||||||
TargetDateBeforeFilter(),
|
TargetDateBeforeFilter(),
|
||||||
TargetDateAfterFilter(),
|
TargetDateAfterFilter(),
|
||||||
|
{
|
||||||
|
name: 'start_date_before',
|
||||||
|
type: 'date',
|
||||||
|
label: t`Start Date Before`,
|
||||||
|
description: t`Show items with a start date before this date`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'start_date_after',
|
||||||
|
type: 'date',
|
||||||
|
label: t`Start Date After`,
|
||||||
|
description: t`Show items with a start date after this date`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'has_target_date',
|
||||||
|
type: 'boolean',
|
||||||
|
label: t`Has Target Date`,
|
||||||
|
description: t`Show orders with a target date`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'has_start_date',
|
||||||
|
type: 'boolean',
|
||||||
|
label: t`Has Start Date`,
|
||||||
|
description: t`Show orders with a start date`
|
||||||
|
},
|
||||||
CompletedBeforeFilter(),
|
CompletedBeforeFilter(),
|
||||||
CompletedAfterFilter(),
|
CompletedAfterFilter(),
|
||||||
ProjectCodeFilter({ choices: projectCodeFilters.choices }),
|
ProjectCodeFilter({ choices: projectCodeFilters.choices }),
|
||||||
|
@ -43,7 +43,11 @@ export const setTableChoiceFilter = async (page, filter, value) => {
|
|||||||
await page.getByRole('button', { name: 'Add Filter' }).click();
|
await page.getByRole('button', { name: 'Add Filter' }).click();
|
||||||
await page.getByPlaceholder('Select filter').fill(filter);
|
await page.getByPlaceholder('Select filter').fill(filter);
|
||||||
await page.getByPlaceholder('Select filter').click();
|
await page.getByPlaceholder('Select filter').click();
|
||||||
await page.getByRole('option', { name: filter }).click();
|
|
||||||
|
// Construct a regex to match the filter name exactly
|
||||||
|
const filterRegex = new RegExp(`^${filter}$`, 'i');
|
||||||
|
|
||||||
|
await page.getByRole('option', { name: filterRegex }).click();
|
||||||
|
|
||||||
await page.getByPlaceholder('Select filter value').click();
|
await page.getByPlaceholder('Select filter value').click();
|
||||||
await page.getByRole('option', { name: value }).click();
|
await page.getByRole('option', { name: value }).click();
|
||||||
|
@ -87,6 +87,33 @@ test('Build Order - Basic Tests', async ({ page }) => {
|
|||||||
.waitFor();
|
.waitFor();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Build Order - Edit', async ({ page }) => {
|
||||||
|
await doQuickLogin(page);
|
||||||
|
|
||||||
|
await page.goto(`${baseUrl}/manufacturing/build-order/22/`);
|
||||||
|
|
||||||
|
// Check for expected text items
|
||||||
|
await page.getByText('Building for sales order').first().waitFor();
|
||||||
|
await page.getByText('2024-08-08').waitFor(); // Created date
|
||||||
|
await page.getByText('2025-01-01').waitFor(); // Start date
|
||||||
|
await page.getByText('2025-01-22').waitFor(); // Target date
|
||||||
|
|
||||||
|
await page.keyboard.press('Control+E');
|
||||||
|
|
||||||
|
// Edit start date
|
||||||
|
await page.getByLabel('date-field-start_date').fill('2026-09-09');
|
||||||
|
|
||||||
|
// 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('Build Order - Build Outputs', async ({ page }) => {
|
test('Build Order - Build Outputs', async ({ page }) => {
|
||||||
await doQuickLogin(page);
|
await doQuickLogin(page);
|
||||||
|
|
||||||
|
@ -1,51 +1,38 @@
|
|||||||
import { test } from './baseFixtures.js';
|
import { test } from './baseFixtures.js';
|
||||||
import { baseUrl } from './defaults.js';
|
import { baseUrl } from './defaults.js';
|
||||||
import {
|
import { clearTableFilters, setTableChoiceFilter } from './helpers.js';
|
||||||
clearTableFilters,
|
|
||||||
closeFilterDrawer,
|
|
||||||
openFilterDrawer
|
|
||||||
} from './helpers.js';
|
|
||||||
import { doQuickLogin } from './login.js';
|
import { doQuickLogin } from './login.js';
|
||||||
|
|
||||||
// Helper function to set the value of a specific table filter
|
|
||||||
const setFilter = async (page, name: string, value: string) => {
|
|
||||||
await openFilterDrawer(page);
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Add Filter' }).click();
|
|
||||||
await page.getByPlaceholder('Select filter').click();
|
|
||||||
await page.getByRole('option', { name: name, exact: true }).click();
|
|
||||||
await page.getByPlaceholder('Select filter value').click();
|
|
||||||
await page.getByRole('option', { name: value, exact: true }).click();
|
|
||||||
|
|
||||||
await closeFilterDrawer(page);
|
|
||||||
};
|
|
||||||
|
|
||||||
test('Tables - Filters', async ({ page }) => {
|
test('Tables - Filters', async ({ page }) => {
|
||||||
await doQuickLogin(page);
|
await doQuickLogin(page);
|
||||||
|
|
||||||
// Head to the "build order list" page
|
// Head to the "build order list" page
|
||||||
await page.goto(`${baseUrl}/manufacturing/index/`);
|
await page.goto(`${baseUrl}/manufacturing/index/`);
|
||||||
|
|
||||||
await setFilter(page, 'Status', 'Complete');
|
await clearTableFilters(page);
|
||||||
await setFilter(page, 'Responsible', 'allaccess');
|
|
||||||
await setFilter(page, 'Project Code', 'PRJ-NIM');
|
await setTableChoiceFilter(page, 'Status', 'Complete');
|
||||||
|
await setTableChoiceFilter(page, 'Responsible', 'allaccess');
|
||||||
|
await setTableChoiceFilter(page, 'Project Code', 'PRJ-NIM');
|
||||||
|
|
||||||
await clearTableFilters(page);
|
await clearTableFilters(page);
|
||||||
|
|
||||||
// Head to the "part list" page
|
// Head to the "part list" page
|
||||||
await page.goto(`${baseUrl}/part/category/index/parts/`);
|
await page.goto(`${baseUrl}/part/category/index/parts/`);
|
||||||
|
|
||||||
await setFilter(page, 'Assembly', 'Yes');
|
await setTableChoiceFilter(page, 'Assembly', 'Yes');
|
||||||
|
|
||||||
await clearTableFilters(page);
|
await clearTableFilters(page);
|
||||||
|
|
||||||
// Head to the "purchase order list" page
|
// Head to the "purchase order list" page
|
||||||
await page.goto(`${baseUrl}/purchasing/index/purchaseorders/`);
|
await page.goto(`${baseUrl}/purchasing/index/purchaseorders/`);
|
||||||
|
|
||||||
await setFilter(page, 'Status', 'Complete');
|
await clearTableFilters(page);
|
||||||
await setFilter(page, 'Responsible', 'readers');
|
|
||||||
await setFilter(page, 'Assigned to me', 'No');
|
await setTableChoiceFilter(page, 'Status', 'Complete');
|
||||||
await setFilter(page, 'Project Code', 'PRO-ZEN');
|
await setTableChoiceFilter(page, 'Responsible', 'readers');
|
||||||
|
await setTableChoiceFilter(page, 'Assigned to me', 'No');
|
||||||
|
await setTableChoiceFilter(page, 'Project Code', 'PRO-ZEN');
|
||||||
|
|
||||||
await clearTableFilters(page);
|
await clearTableFilters(page);
|
||||||
});
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user