2
0
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:
Oliver 2025-01-21 00:37:23 +11:00 committed by GitHub
parent 87ccf52562
commit 7ad49949c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 169 additions and 45 deletions

View File

@ -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

View File

@ -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

View File

@ -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',

View File

@ -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'),
),
]

View File

@ -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,

View File

@ -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',

View File

@ -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}

View File

@ -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 />
}, },

View File

@ -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 ?? {})) {

View File

@ -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',

View File

@ -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 }),

View File

@ -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();

View File

@ -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);

View File

@ -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);
}); });