From 7ad49949c82875ccbb4361d538c92d29b31934e2 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 21 Jan 2025 00:37:23 +1100 Subject: [PATCH] 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 --- docs/docs/build/build.md | 15 +++++-- .../InvenTree/InvenTree/api_version.py | 6 ++- src/backend/InvenTree/build/api.py | 25 ++++++++++++ .../build/migrations/0054_build_start_date.py | 18 +++++++++ src/backend/InvenTree/build/models.py | 13 +++++++ src/backend/InvenTree/build/serializers.py | 1 + .../src/components/forms/fields/DateField.tsx | 13 +++++-- src/frontend/src/forms/BuildForms.tsx | 3 ++ src/frontend/src/functions/forms.tsx | 6 --- src/frontend/src/pages/build/BuildDetail.tsx | 13 +++++-- .../src/tables/build/BuildOrderTable.tsx | 29 ++++++++++++++ src/frontend/tests/helpers.ts | 6 ++- src/frontend/tests/pages/pui_build.spec.ts | 27 +++++++++++++ src/frontend/tests/pui_tables.spec.ts | 39 +++++++------------ 14 files changed, 169 insertions(+), 45 deletions(-) create mode 100644 src/backend/InvenTree/build/migrations/0054_build_start_date.py diff --git a/docs/docs/build/build.md b/docs/docs/build/build.md index 92d5a8671a..7facbfbab5 100644 --- a/docs/docs/build/build.md +++ b/docs/docs/build/build.md @@ -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 | | Source Location | Stock location to source stock items from (blank = all locations) | | 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 | | Responsible | User (or group of users) who is responsible for the build | | 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" **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 diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index b9d1ba6d65..ec19418475 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,13 +1,17 @@ """InvenTree API version information.""" # 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.""" 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 - Fix schema definition on the /label/print endpoint diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py index 9003100ade..455548effb 100644 --- a/src/backend/InvenTree/build/api.py +++ b/src/backend/InvenTree/build/api.py @@ -188,6 +188,30 @@ class BuildFilter(rest_filters.FilterSet): 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( label=_('Target date before'), field_name='target_date', lookup_expr='lt' ) @@ -244,6 +268,7 @@ class BuildList(DataExportViewMixin, BuildMixin, ListCreateAPI): 'part__name', 'status', 'creation_date', + 'start_date', 'target_date', 'completion_date', 'quantity', diff --git a/src/backend/InvenTree/build/migrations/0054_build_start_date.py b/src/backend/InvenTree/build/migrations/0054_build_start_date.py new file mode 100644 index 0000000000..1ef87e634c --- /dev/null +++ b/src/backend/InvenTree/build/migrations/0054_build_start_date.py @@ -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'), + ), + ] diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py index b00afa3093..f0916c1eb4 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -178,6 +178,12 @@ class Build( if self.has_field_changed('part'): 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: """Generate custom report context data.""" return { @@ -344,6 +350,13 @@ class Build( 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( null=True, blank=True, diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index 2352bf3f54..a3844b0677 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -81,6 +81,7 @@ class BuildSerializer( 'reference', 'sales_order', 'quantity', + 'start_date', 'status', 'status_text', 'status_custom_key', diff --git a/src/frontend/src/components/forms/fields/DateField.tsx b/src/frontend/src/components/forms/fields/DateField.tsx index e0a9747c21..76c05409c2 100644 --- a/src/frontend/src/components/forms/fields/DateField.tsx +++ b/src/frontend/src/components/forms/fields/DateField.tsx @@ -22,8 +22,12 @@ export default function DateField({ fieldState: { error } } = controller; - const valueFormat = - definition.field_type == 'date' ? 'YYYY-MM-DD' : 'YYYY-MM-DD HH:mm:ss'; + const valueFormat = useMemo(() => { + // 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( (value: any) => { @@ -31,12 +35,13 @@ export default function DateField({ if (value) { value = value.toString(); value = dayjs(value).format(valueFormat); + value = value.toString().split('T')[0]; } field.onChange(value); definition.onValueChange?.(value); }, - [field.onChange, definition] + [field.onChange, definition, valueFormat] ); const dateValue: Date | null = useMemo(() => { @@ -62,7 +67,7 @@ export default function DateField({ ref={field.ref} type={undefined} error={definition.error ?? error?.message} - value={dateValue ?? null} + value={dateValue} clearable={!definition.required} onChange={onChange} valueFormat={valueFormat} diff --git a/src/frontend/src/forms/BuildForms.tsx b/src/frontend/src/forms/BuildForms.tsx index 1f3c64c847..787cb2a16b 100644 --- a/src/frontend/src/forms/BuildForms.tsx +++ b/src/frontend/src/forms/BuildForms.tsx @@ -101,6 +101,9 @@ export function useBuildOrderFields({ value: batchCode, onValueChange: (value: any) => setBatchCode(value) }, + start_date: { + icon: + }, target_date: { icon: }, diff --git a/src/frontend/src/functions/forms.tsx b/src/frontend/src/functions/forms.tsx index c7c088b904..c4e20ea6e1 100644 --- a/src/frontend/src/functions/forms.tsx +++ b/src/frontend/src/functions/forms.tsx @@ -139,12 +139,6 @@ export function constructField({ }; 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': def.children = {}; for (const k of Object.keys(field.children ?? {})) { diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx index c28090d6bd..da17487e09 100644 --- a/src/frontend/src/pages/build/BuildDetail.tsx +++ b/src/frontend/src/pages/build/BuildDetail.tsx @@ -181,21 +181,28 @@ export default function BuildDetail() { hidden: !build.responsible }, { - type: 'text', + type: 'date', name: 'creation_date', label: t`Created`, icon: 'calendar', 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', label: t`Target Date`, icon: 'calendar', hidden: !build.target_date }, { - type: 'text', + type: 'date', name: 'completion_date', label: t`Completed`, icon: 'calendar', diff --git a/src/frontend/src/tables/build/BuildOrderTable.tsx b/src/frontend/src/tables/build/BuildOrderTable.tsx index b8c397ce06..faa96952ff 100644 --- a/src/frontend/src/tables/build/BuildOrderTable.tsx +++ b/src/frontend/src/tables/build/BuildOrderTable.tsx @@ -105,6 +105,11 @@ export function BuildOrderTable({ sortable: true }, CreationDateColumn({}), + DateColumn({ + accessor: 'start_date', + title: t`Start Date`, + sortable: true + }), TargetDateColumn({}), DateColumn({ accessor: 'completion_date', @@ -138,6 +143,30 @@ export function BuildOrderTable({ CreatedAfterFilter(), TargetDateBeforeFilter(), 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(), CompletedAfterFilter(), ProjectCodeFilter({ choices: projectCodeFilters.choices }), diff --git a/src/frontend/tests/helpers.ts b/src/frontend/tests/helpers.ts index 36d421c865..970d7cbfd6 100644 --- a/src/frontend/tests/helpers.ts +++ b/src/frontend/tests/helpers.ts @@ -43,7 +43,11 @@ export const setTableChoiceFilter = async (page, filter, value) => { await page.getByRole('button', { name: 'Add Filter' }).click(); await page.getByPlaceholder('Select filter').fill(filter); 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.getByRole('option', { name: value }).click(); diff --git a/src/frontend/tests/pages/pui_build.spec.ts b/src/frontend/tests/pages/pui_build.spec.ts index 61475b738f..7572e87a56 100644 --- a/src/frontend/tests/pages/pui_build.spec.ts +++ b/src/frontend/tests/pages/pui_build.spec.ts @@ -87,6 +87,33 @@ test('Build Order - Basic Tests', async ({ page }) => { .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 }) => { await doQuickLogin(page); diff --git a/src/frontend/tests/pui_tables.spec.ts b/src/frontend/tests/pui_tables.spec.ts index 015023c443..601143fbb5 100644 --- a/src/frontend/tests/pui_tables.spec.ts +++ b/src/frontend/tests/pui_tables.spec.ts @@ -1,51 +1,38 @@ import { test } from './baseFixtures.js'; import { baseUrl } from './defaults.js'; -import { - clearTableFilters, - closeFilterDrawer, - openFilterDrawer -} from './helpers.js'; +import { clearTableFilters, setTableChoiceFilter } from './helpers.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 }) => { await doQuickLogin(page); // Head to the "build order list" page await page.goto(`${baseUrl}/manufacturing/index/`); - await setFilter(page, 'Status', 'Complete'); - await setFilter(page, 'Responsible', 'allaccess'); - await setFilter(page, 'Project Code', 'PRJ-NIM'); + await clearTableFilters(page); + + await setTableChoiceFilter(page, 'Status', 'Complete'); + await setTableChoiceFilter(page, 'Responsible', 'allaccess'); + await setTableChoiceFilter(page, 'Project Code', 'PRJ-NIM'); await clearTableFilters(page); // Head to the "part list" page await page.goto(`${baseUrl}/part/category/index/parts/`); - await setFilter(page, 'Assembly', 'Yes'); + await setTableChoiceFilter(page, 'Assembly', 'Yes'); await clearTableFilters(page); // Head to the "purchase order list" page await page.goto(`${baseUrl}/purchasing/index/purchaseorders/`); - await setFilter(page, 'Status', 'Complete'); - await setFilter(page, 'Responsible', 'readers'); - await setFilter(page, 'Assigned to me', 'No'); - await setFilter(page, 'Project Code', 'PRO-ZEN'); + await clearTableFilters(page); + + await setTableChoiceFilter(page, 'Status', 'Complete'); + await setTableChoiceFilter(page, 'Responsible', 'readers'); + await setTableChoiceFilter(page, 'Assigned to me', 'No'); + await setTableChoiceFilter(page, 'Project Code', 'PRO-ZEN'); await clearTableFilters(page); });